当多个线程同时读写同一份数据时,更新可能会丢失,或者被别的线程看到一半完成的状态。lock 语句让一段代码互斥执行:一个线程在里面时,其他所有走到同一对象上 lock 的线程都要排队等待。
问题:竞态条件
四个任务各自给一个共享计数器加 100,000 次。正确答案应该是 400,000:
示例输出:
Expected 400000, got 245609
总数通常会偏少,而且每次运行少的数量都不一样(在单核机器上偶尔可能是对的,这正是这类 bug 难以发现的原因)。count++ 看起来是一个操作,其实是三个:读取 count,加一,再写回去。两个线程可能都读到 500,都算出 501,又都写入 501,于是一次自增就丢了。
解决方法:lock
把“读取、修改、写回”这一组操作包在一个共享对象的 lock 里:
输出:
Expected 400000, got 400000
现在同一时刻只有一个线程能进入这段代码,所以每次自增都会在下一次读取之前完成。lock 还保证了可见性:进入 lock 的线程能看到上一个持有者离开前做的所有写入。
只有当所有对 count 的访问都经过同一把锁时,这种保护才有效。程序里别处只要有一个没加锁的 count++,竞态条件就会回来。
lock 被编译成什么
lock 是 Monitor 类的简写,外面套了一层 try/finally,所以即使代码块抛出异常,锁也会被释放:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor 还提供 TryEnter(obj, timeout),它在超时后放弃,而不是一直等下去;以及 Wait/Pulse,用于线程之间互相通知。已经持有某把锁的线程可以再次进入它(锁是可重入的),所以一个加了锁的方法可以调用同一对象上另一个加了锁的方法。
选择锁对象
lock (...) 中的对象只是各个线程约定好的一个标记。规则如下:
- 使用类型为
object的私有只读字段。private readonly object sync = new object();。私有意味着外部代码无法获取同一把锁;readonly意味着在某个线程持有锁时,这个标记不会被换掉。 - 永远不要
lock (this)。 任何持有你对象引用的代码都能锁它,那段代码就会阻塞你的代码,或者与之死锁。 - 永远不要锁字符串或
typeof(...)。 字符串字面量会被驻留(进程中所有的"orders"都是同一个对象),而Type对象在整个应用中共享,所以毫不相干的代码可能会争抢同一把锁。 - 永远不要锁值类型。 对
int写lock (count)无法通过编译(错误 CS0185),而手动装箱每次都会创建一个新对象,等于什么都没锁住。 - 每组需要保持一致的数据用一把锁, 静态数据用一个 static 锁字段,实例数据用一个实例字段。
保护多步操作
最需要 lock 的地方,是检查和动作必须一起完成的时候。下面这个线程安全的账户同时展示了“先检查再行动”的模式,以及如何得到两个字段的一致快照:
输出:
10 left after 33 withdrawals
如果没有锁,两个线程可能都看到余额是 40,都通过了检查,又都取出 30,账户就变成了负 20。Summary 方法也加了锁,所以它永远不会把取款之后的 balance 和取款之前的 withdrawals 次数拼在一起报告。
Dictionary<TKey, TValue> 和 List<T> 也不是线程安全的:并发写入不只会丢失更新,还可能破坏它们内部的数组。用 lock 保护它们,或者使用 ConcurrentDictionary<TKey, TValue> 以及 System.Collections.Concurrent 中的其他类型。
用 Interlocked 处理单个值
当共享状态只是一个整数或一个引用,而且更新只有一步时,Interlocked 类无需加锁就能原子地完成它:
输出:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment、Decrement、Add 和 Exchange 对应处理器的原子操作(在 x64 上是一条指令)。CompareExchange(ref location, newValue, expected) 只有当该位置仍然是 expected 时才写入,并返回它看到的值,借此你可以把任何更新写成一个重试循环。凡是同时涉及两个变量的操作,仍然需要 lock。
死锁
当两个线程各自持有对方需要的锁时,就会发生死锁:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
如果线程 1 拿到了 accountA,同时线程 2 拿到了 accountB,那么两者都会永远等待对方。没有异常,也没有超时,程序就这样卡住了。一个先锁“转出”账户、再锁“转入”账户的转账操作,在两笔方向相反的转账同时运行时,正好会出现这种情况。
标准的防御手段:
- 按固定的全局顺序加锁。 对于转账,不管钱往哪个方向走,都先锁 ID 较小的账户。
- 持有锁的时间要短, 把耗时的工作(I/O、日志、网络调用)放在锁外面。
- 持有锁时不要调用你不了解的代码: 事件、回调和虚方法可能会获取它们自己的锁。
- 在卡住比失败更糟糕的地方,使用带超时的
Monitor.TryEnter。
lock 中不能 await:使用 SemaphoreSlim
lock 块中不允许使用 await(编译错误 CS1996)。在 await 之后,方法可能会在另一个线程上继续执行,而 Monitor 锁必须由获取它的那个线程释放。对于异步代码,计数为 1 的 SemaphoreSlim 可以充当兼容异步的锁:
输出:
5 saved, one at a time
一定要在 finally 中把 WaitAsync 和 Release 配对,否则一个异常就会让这道门永远关着。SemaphoreSlim(3, 3) 允许三个调用者同时进入,你可以用这种方式限制对有速率限制的 API 的并发请求数。
System.Threading.Lock(.NET 9)
.NET 9 配合 C# 13 新增了专用的 System.Threading.Lock 类型。当 lock 语句中的对象是 Lock 时,编译器会使用它更快的 EnterScope API,而不是 Monitor:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
选择锁对象的规则不变。在更早的版本中,private readonly object 就是正确的选择。
常见错误
- 只给部分访问加锁。 对共享数据的每一次读写都必须获取同一把锁。
lock (this)、lock (typeof(X))、lock ("name")。 外部代码可以获取同一把锁。- 在锁里做耗时的 I/O。 其他所有线程都在等;让代码块保持简短。
- 在不同地方以不同顺序获取两把锁。 这就是经典的死锁。
- 用
lock包住await。 这无法通过编译;改用SemaphoreSlim。 - 每次调用都新建一把锁。
lock (new object())什么也保护不了;锁对象必须是共享的。
常见问题
C# 中 lock 的作用是什么?
lock (obj) { ... } 保证对同一个锁对象而言,同一时刻只有一个线程能运行这段代码。第二个线程走到同一对象上的 lock 时,会等到第一个线程离开这段代码。即使代码块抛出异常,锁也会被释放,因为 lock 会被编译成放在 try/finally 里的 Monitor.Enter 和 Monitor.Exit。
C# 中应该锁哪个对象?
一个专用的私有字段:private readonly object _sync = new object();。它必须是引用类型,被所有访问受保护数据的线程共享,并且类外部的代码拿不到它。永远不要锁 this、Type(typeof(MyClass))或字符串,因为其他代码也可能锁同一个对象,从而阻塞你或与你死锁。
什么时候用 Interlocked 代替 lock?
当共享状态只是一个数字或一个引用,而且操作只有一步时:Interlocked.Increment(ref count)、Interlocked.Add(ref total, x)、Interlocked.Exchange 或 CompareExchange。它们是硬件层面的原子操作,比 lock 更快。凡是涉及多个字段或一个集合的操作,都用 lock。
C# 中能在 lock 里使用 await 吗?
不能,编译器不允许在 lock 块中使用 await(错误 CS1996),因为 await 之后的代码可能在另一个线程上继续执行,而不是持有锁的那个线程。改用计数为 1 的 SemaphoreSlim:await sem.WaitAsync(); try { ... } finally { sem.Release(); }。
lock 是怎样造成死锁的?
线程 1 持有锁 A 并等待锁 B,同时线程 2 持有锁 B 并等待锁 A。两者都无法继续,程序卡住,而且不会抛出任何异常。预防方法:需要多把锁时始终按同一个全局顺序获取,持有锁的时间尽量短,并且持有锁时绝不调用你不了解的代码。