C# 高级教程 - 06 多线程基础

在现代应用中,充分利用多核心 CPU 的能力是提升性能的关键途径。基础教程中我们已经了解了 Task 和 async/await 的基本用法,但多线程编程远比这更加复杂。本文将深入探讨 System.Threading 层面的核心概念:线程生命周期、线程池、同步原语(锁、信号量、屏障)、以及如何避免死锁和竞态条件。

线程 vs 任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 原始线程 —— 重量级资源(约 1MB 栈空间)
Thread thread = new Thread(() =>
{
Console.WriteLine($"线程 {Thread.CurrentThread.ManagedThreadId} 运行中");
Thread.Sleep(1000);
});
thread.IsBackground = true; // 后台线程不会阻止进程退出
thread.Start();
thread.Join(); // 等待线程结束

// 线程池线程 —— 重用线程(推荐)
ThreadPool.QueueUserWorkItem(state =>
{
Console.WriteLine($"线程池线程 {Thread.CurrentThread.ManagedThreadId}");
});

// Task —— 基于线程池的高级抽象
await Task.Run(() => Console.WriteLine("Task 运行中"));

核心区别Thread 是 OS 层面的概念,每个线程消耗大量资源;Task 是 .NET 层面的异步工作单元,默认由线程池调度,创建和销毁成本低得多。

线程间同步

1. lock 语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private readonly object _lock = new();
private int _counter;

public void Increment()
{
lock (_lock)
{
_counter++;
}
}

// lock 的本质 —— Monitor 的语法糖
public void IncrementExpanded()
{
bool lockTaken = false;
try
{
Monitor.Enter(_lock, ref lockTaken);
_counter++;
}
finally
{
if (lockTaken) Monitor.Exit(_lock);
}
}

2. 自旋锁(SpinLock)—— 低锁竞争场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private SpinLock _spinLock = new(false); // 结构体!不能是 readonly

public void SafeOperation()
{
bool lockTaken = false;
try
{
_spinLock.Enter(ref lockTaken);
// 极短的操作
}
finally
{
if (lockTaken) _spinLock.Exit();
}
}

SpinLock 让线程忙等待而不是进入休眠,适合锁持有时间极短(纳秒级)且锁竞争低的场景。错误使用会导致 CPU 100%。

3. 读写锁(ReaderWriterLockSlim)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private readonly ReaderWriterLockSlim _rwLock = new();
private readonly Dictionary<string, string> _cache = new();

public string? Read(string key)
{
_rwLock.EnterReadLock();
try
{
return _cache.TryGetValue(key, out var value) ? value : null;
}
finally
{
_rwLock.ExitReadLock();
}
}

public void Write(string key, string value)
{
_rwLock.EnterWriteLock();
try
{
_cache[key] = value;
}
finally
{
_rwLock.ExitWriteLock();
}
}

多个线程可以同时持有读锁,但写锁是独占的。适用于读多写少的场景。

4. Mutex —— 跨进程同步

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 确保只运行一个实例
using var mutex = new Mutex(true, "Global\\MyAppUniqueName");
if (!mutex.WaitOne(TimeSpan.Zero))
{
Console.WriteLine("程序已在运行");
return;
}

try
{
// 运行主逻辑
}
finally
{
mutex.ReleaseMutex();
}

5. SemaphoreSlim —— 限制并发数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private readonly SemaphoreSlim _semaphore = new(3, 3); // 最多 3 个并发

async Task ProcessRequestAsync(HttpRequest request)
{
await _semaphore.WaitAsync();
try
{
// 处理请求(最多 3 个并行)
}
finally
{
_semaphore.Release();
}
}

6. Barrier —— 协同并行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Barrier barrier = new(3, b =>
{
Console.WriteLine($"阶段 {b.CurrentPhaseNumber} 完成");
});

// 三个线程协同
for (int i = 0; i < 3; i++)
{
int id = i;
Task.Run(() =>
{
Console.WriteLine($"参与者 {id} 到达阶段 1");
barrier.SignalAndWait();
Console.WriteLine($"参与者 {id} 到达阶段 2");
barrier.SignalAndWait();
});
}

7. CountdownEvent —— 等待所有完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using var countdown = new CountdownEvent(3);

for (int i = 0; i < 3; i++)
{
int id = i;
Task.Run(() =>
{
Thread.Sleep(id * 200);
Console.WriteLine($"{id} 完成");
countdown.Signal();
});
}

countdown.Wait(); // 等待所有 3 个信号
Console.WriteLine("全部完成");

避免死锁

经典的死锁例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
object lockA = new();
object lockB = new();

Task t1 = Task.Run(() =>
{
lock (lockA)
{
Thread.Sleep(100); // 确保 t2 获得 lockB
lock (lockB) { } // 等待 t2 释放 lockB
}
});

Task t2 = Task.Run(() =>
{
lock (lockB)
{
Thread.Sleep(100);
lock (lockA) { } // 等待 t1 释放 lockA
}
});

await Task.WhenAll(t1, t2); // 死锁!

预防策略

  1. 固定锁顺序:始终以相同顺序获取锁
  2. 使用超时机制
1
2
3
4
5
6
7
8
9
10
11
12
if (Monitor.TryEnter(lockA, TimeSpan.FromSeconds(1)))
{
try
{
if (Monitor.TryEnter(lockB, TimeSpan.FromSeconds(1)))
{
try { /* 临界区 */ }
finally { Monitor.Exit(lockB); }
}
}
finally { Monitor.Exit(lockA); }
}
  1. 减少锁粒度:分散到大范围的细粒度锁
  2. 使用无锁数据结构ConcurrentDictionaryInterlocked
  3. 避免锁中嵌套锁

volatile 与内存模型

1
2
3
4
5
6
7
8
private volatile bool _shouldStop;

void Worker()
{
while (!_shouldStop) { /* 工作 */ }
}

void Stop() => _shouldStop = true;

volatile 确保:

  • 读取时总是从内存中取最新值(不缓存到 CPU 寄存器)
  • 防止编译器/CPU 重排序越过 volatile 操作

在现代 .NET 中 volatile 已较少直接使用,Interlockedlock 提供了更强的保证。Volatile.Read/Write 提供了更细粒度的控制。

Interlocked 原子操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private long _counter;

public void AtomicIncrement()
{
Interlocked.Increment(ref _counter);
}

public void AtomicAdd(int value)
{
Interlocked.Add(ref _counter, value);
}

public long ExchangeIfGreater(long newValue)
{
long initial;
long computed;
do
{
initial = _counter;
computed = Math.Max(initial, newValue);
}
while (Interlocked.CompareExchange(ref _counter, computed, initial) != initial);
return computed;
}

Interlocked 操作直接映射到 CPU 的原子指令(如 lock cmpxchg),无锁且零开销上下文切换。

ThreadLocal —— 线程本地存储

1
2
3
4
5
6
7
8
9
10
11
private static readonly ThreadLocal<int> _threadId =
new(() => Thread.CurrentThread.ManagedThreadId);

// 每个线程拥有独立的 Random 实例(避免线程安全问题)
private static readonly ThreadLocal<Random> _random =
new(() => new Random(Thread.CurrentThread.ManagedThreadId));

Parallel.For(0, 100, i =>
{
Console.WriteLine($"线程 {_threadId.Value} 生成随机数: {_random.Value.Next()}");
});

总结:多线程编程需要深刻理解 OS 线程模型和 .NET 提供的各种同步原语。选择合适的同步工具(lock/SpinLock/Semaphore/Barrier)、遵循固定的锁顺序避免死锁、使用原子操作或无锁数据结构提升性能,是编写健壮并发代码的关键。在多数场景下,优先使用更高级的 TPL/Dataflow 抽象而不要直接操作 Thread,但在理解底层原理之前,高级抽象也容易误用。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: