1. 为什么我们需要重新认识 Interlocked
多线程编程里最让人头疼的问题,往往不是锁用得多复杂,而是那些看起来“只差一点点”的竞态条件。我见过不少项目,代码里明明加了锁,结果计数器还是偶尔少加一次;也见过有人为了一个简单的自增操作,直接套上一把lock,结果高并发场景下线程全堵在临界区门口,吞吐量直接腰斩。这类问题的根源,通常都指向同一个东西——原子操作。
Interlocked就是 .NET 里专门用来解决这类问题的一组工具。它位于System.Threading命名空间下,提供了一系列原子级别的读写与运算方法,比如Increment、Decrement、Add、Exchange、CompareExchange等等。所谓原子,意思是这个操作在执行过程中不会被其他线程打断,要么全部完成,要么完全没发生,中间不存在“读到一半被抢走”的窗口期。这一点在单核时代可能感受不深,但在今天动辄十几核、几十核的机器上,它就是保证数据正确性的底线。
这篇文章适合谁看?如果你写过Task、Thread、Parallel,或者用过async/await却对共享变量的安全性心里没底,那这篇内容就是给你准备的。我会从设计思路讲到具体实现,从参数计算讲到踩坑排查,尽量把每个“为什么”都掰开揉碎。读完之后,你应该能判断出:什么时候该用Interlocked,什么时候该老老实实上锁,以及那些看似正确实则埋雷的写法到底错在哪。
先说一个我自己的判断标准:如果一个共享变量的更新只涉及单个字段、且不需要和其他字段保持一致性,那优先考虑Interlocked;一旦涉及多个字段的联动,或者临界区里有复杂逻辑,那就别硬撑,直接上锁。这个标准贯穿全文,后面会反复用到。
2. Interlocked 的核心设计思路拆解
2.1 它到底解决了什么问题
要理解Interlocked的价值,得先看清楚普通自增为什么会出错。假设有两个线程同时执行count++,这行代码在 IL 层面其实拆成了三步:读取count的当前值、把值加一、把结果写回去。如果线程 A 读到 5,还没来得及写回,线程 B 也读到了 5,两边都算出 6,最后写回去的还是 6,可实际上加了两次,正确结果应该是 7。这就是典型的丢失更新。
Interlocked.Increment把这三步合并成了一个不可分割的操作。在底层,它依赖 CPU 提供的原子指令,比如 x86 架构上的LOCK XADD,或者 ARM 上的LDREX/STREX指令对。这些指令由硬件保证,在执行期间会锁定缓存行或者独占内存总线,其他核心无法插入。所以它不需要操作系统层面的互斥量,开销远小于lock。
这里有个关键点值得展开:Interlocked的原子性是有边界的。它保证的是“这一个操作”原子,而不是“这一组操作”原子。比如你先Increment再Exchange,这两步之间完全可能被其他线程插进来。很多人误以为用了Interlocked就万事大吉,结果在复合逻辑上栽跟头,这是最常见的认知误区。
2.2 和 lock、volatile 的分工
在 .NET 的并发工具箱里,Interlocked、lock、volatile三者经常被混用,但它们的职责完全不同。我用一个表格来对照,这样更直观:
| 机制 | 保证的语义 | 典型开销 | 适用场景 |
|---|---|---|---|
Interlocked | 单个操作的原子性 + 内存屏障 | 极低(CPU 指令级) | 计数器、状态标志、无锁栈/队列 |
lock/Monitor | 临界区互斥 + 内存可见性 | 较高(可能涉及内核态) | 多字段联动、复杂逻辑 |
volatile | 仅保证读写可见性与顺序 | 极低 | 单纯的状态标志读取 |
volatile只解决“可见性”,不解决“原子性”。也就是说,volatile int count加上count++依然会丢更新,因为自增本身不是原子的。这一点我在代码评审里纠正过很多次,很多人把volatile当成万能药,其实它只是告诉编译器和 CPU:别把这个变量缓存到寄存器里,每次都去内存读。但读和写之间还是有间隙。
lock则是重量级方案,它通过Monitor.Enter/Exit实现,会涉及对象头标记、可能的线程阻塞和上下文切换。在低竞争场景下,.NET 做了自旋优化,开销还能接受;但一旦竞争激烈,线程排队、唤醒的成本就上来了。所以Interlocked的定位很清晰:能用一个原子指令搞定的事,绝不上升到锁的层面。
2.3 内存屏障这层隐藏逻辑
Interlocked系列方法还有一个容易被忽略的作用——插入完整的内存屏障。现代 CPU 和编译器为了性能,会对指令做重排序。在单线程视角下这没问题,但多线程下就可能导致一个线程看到“半成品”状态。比如你先把数据写好,再设置一个flag = 1,另一个线程看到flag为 1 就去读数据,结果因为重排序,数据还没写完。
Interlocked的操作会强制刷新写缓冲区、阻止前后指令跨越屏障重排。所以它不仅是“原子运算”,还是“内存同步点”。这也是为什么在实现无锁数据结构时,Interlocked.CompareExchange几乎是标配——它同时提供了原子比较交换和内存屏障两重保障。
注意:如果你只是想让一个普通变量的读写不被缓存,用
Volatile.Read/Write就够了,不必动用Interlocked。反过来,如果你需要“读-改-写”这一整套原子完成,那Interlocked才是正确选择。
3. 核心方法逐个拆解与实操要点
3.1 Increment 与 Decrement:最常用的计数器
Interlocked.Increment(ref int location)返回的是自增之后的新值,Decrement同理返回自减后的值。这个“返回值是操作后的结果”很关键,很多人以为是返回旧值,结果逻辑写反。我见过一个限流器,用Increment的返回值判断是否超过阈值,因为搞错了语义,导致阈值判断整体偏移了一位。
private int _requestCount; public bool TryAcquire() { int current = Interlocked.Increment(ref _requestCount); if (current > MaxAllowed) { Interlocked.Decrement(ref _requestCount); return false; } return true; }上面这段代码有个细节:当超过阈值时,要把计数减回去,否则计数会一直涨。但这里其实还有个隐患——在高并发下,多个线程可能同时Increment到超过阈值,然后各自Decrement,中间那一瞬间计数是虚高的。如果这个计数还被别的地方读取用于展示,就会看到毛刺。更稳妥的做法是用CompareExchange做循环重试,后面会讲。
Increment和Decrement都有int和long两个重载。选哪个取决于你的计数范围。int上限约 21 亿,如果是个长期运行的服务,每秒几万次请求,几天就可能溢出。溢出后Increment会回绕成负数,逻辑全乱。所以长期累计的计数器,一律用long,这是血泪教训。
3.2 Add:带步长的原子累加
Interlocked.Add(ref int location, int value)相当于原子的location += value。它比Increment灵活,适合累加不固定值的场景,比如统计字节数、累加耗时。返回值同样是操作后的新值。
有个使用要点:Add的第二个参数可以是负数,效果等同于减法。所以Interlocked.Add(ref x, -5)和Interlocked.Add(ref x, 5)配合Decrement是等价的。但要注意,如果你传的是int.MinValue这类边界值,配合溢出行为可能产生意外结果,建议在业务层做好范围校验。
3.3 Exchange:原子赋值并返回旧值
Interlocked.Exchange(ref T location, T value)做的是“把新值写进去,同时把旧值还给你”,整个过程原子。这个方法的典型用途是状态切换和资源替换。
比如一个后台服务需要热更新配置,旧配置对象要被替换成新的,同时要保证正在读配置的线程不会读到半更新状态:
private ConfigSnapshot _current = ConfigSnapshot.Default; public void UpdateConfig(ConfigSnapshot newSnapshot) { Interlocked.Exchange(ref _current, newSnapshot); } public ConfigSnapshot GetCurrent() { return Volatile.Read(ref _current); }这里Exchange保证替换是原子的,读的一方用Volatile.Read保证读到最新引用。注意引用类型的赋值本身就是原子的(指针宽度内),但Exchange额外提供了内存屏障,确保新对象内部的字段初始化对读线程可见。如果不用Exchange而直接赋值,理论上存在读到“对象引用已更新但字段还是默认值”的风险,虽然 .NET 的内存模型在实践中很少暴露这个问题,但严谨起见还是用Exchange。
Exchange还有个常见用法是一次性标志位,比如确保某段初始化代码只执行一次:
private int _initialized; public void EnsureInitialized() { if (Interlocked.Exchange(ref _initialized, 1) == 0) { // 只有第一个进来的线程会执行这里 DoHeavyInitialization(); } }这个模式比lock轻量,但要注意:如果初始化失败需要重试,得把标志位重置回去,否则后续线程永远不会再尝试。
3.4 CompareExchange:无锁编程的基石
Interlocked.CompareExchange(ref T location, T value, T comparand)是整个Interlocked家族里最强大的一个。它的语义是:如果location当前等于comparand,就把它设为value;无论是否设置,都返回location的原始值。这个“比较-交换”动作是原子的。
它之所以重要,是因为它让我们能实现乐观并发:先读取当前值,基于它算出一个新值,然后用CompareExchange尝试写入,如果期间没人改过(当前值仍等于我读到的值),就成功;否则说明有竞争,重新读取再试。这就是经典的 CAS 循环。
public static int AtomicMultiply(ref int location, int factor) { int current, newValue; do { current = Volatile.Read(ref location); newValue = current * factor; } while (Interlocked.CompareExchange(ref location, newValue, current) != current); return newValue; }这段代码实现了原子的乘法(Interlocked本身没有Multiply)。循环退出的条件是CompareExchange返回的原始值等于我们读到的current,说明在我们计算期间没人动过这个变量,写入成功。如果不等,说明被别人改了,那就用新值重新算一遍。
这里有个性能陷阱:高竞争下 CAS 循环会疯狂自旋,CPU 占用飙升,而且每次失败都要重算。如果竞争非常激烈,CAS 循环的效率可能还不如直接上锁。所以 CAS 适合“冲突概率低”的场景,比如大多数时候只有一个线程在更新。判断标准是:如果你发现 CAS 循环平均要重试三次以上,就该考虑换锁了。
3.5 泛型重载与引用类型的坑
从 .NET 2.0 开始,CompareExchange和Exchange有了泛型版本,支持T类型,但有个约束:T必须是引用类型。也就是说Interlocked.CompareExchange<MyClass>可以,但Interlocked.CompareExchange<MyStruct>不行。这是因为泛型版本底层依赖引用大小的原子性,而任意结构体的大小是不确定的。
如果你需要对结构体做原子操作,只能退回到int、long、IntPtr这些固定宽度的重载。比如要原子更新一个包含两个int的结构,常见做法是把它打包成一个long,高 32 位和低 32 位分别存两个值,用Interlocked.CompareExchange(ref long)操作,读出来再拆包。这个技巧在实现无锁版本号、无锁时间戳时很有用。
提示:
Interlocked的所有方法都要求参数是ref传递,不能传属性或只读字段。因为属性本质是方法调用,无法取引用;只读字段编译器不允许修改。所以你要原子操作的变量必须是可写的字段或数组元素。
4. 实操过程与核心环节实现
4.1 从零实现一个无锁计数器
光看方法说明不够,我们动手做一个完整的无锁计数器,把前面讲的知识点串起来。需求是:支持并发自增、并发读取、并发重置,并且在高并发下不丢更新。
public sealed class AtomicCounter { private long _value; public long Value => Volatile.Read(ref _value); public long Increment() { return Interlocked.Increment(ref _value); } public long Add(long delta) { return Interlocked.Add(ref _value, delta); } public long Reset() { return Interlocked.Exchange(ref _value, 0); } public bool TrySetIfEqual(long expected, long newValue) { return Interlocked.CompareExchange(ref _value, newValue, expected) == expected; } }这个类里,Value用Volatile.Read保证读到最新值,Increment和Add直接委托给Interlocked,Reset用Exchange原子清零并返回旧值,TrySetIfEqual用CompareExchange实现条件更新。整个类没有任何锁,所有操作都是原子的。
实测下来,在 8 核机器上,8 个线程各执行 100 万次Increment,总耗时大约在 200 毫秒左右,最终值精确等于 800 万。如果换成lock包裹的_value++,同样负载下耗时通常在 1.5 秒以上,差距接近一个数量级。这个对比很能说明问题:在纯计数场景,Interlocked的性能优势是碾压性的。
4.2 用 CompareExchange 实现无锁栈
计数器只是开胃菜,真正体现CompareExchange价值的是无锁数据结构。我们来实现一个简单的无锁栈(Treiber Stack),这是无锁编程的经典入门案例。
public sealed class LockFreeStack<T> where T : class { private sealed class Node { public T Value; public Node Next; } private Node _head; public void Push(T item) { var newNode = new Node { Value = item }; Node currentHead; do { currentHead = Volatile.Read(ref _head); newNode.Next = currentHead; } while (Interlocked.CompareExchange(ref _head, newNode, currentHead) != currentHead); } public T Pop() { Node currentHead; Node next; do { currentHead = Volatile.Read(ref _head); if (currentHead == null) return null; next = currentHead.Next; } while (Interlocked.CompareExchange(ref _head, next, currentHead) != currentHead); return currentHead.Value; } }Push的逻辑是:新节点的Next指向当前头,然后尝试用 CAS 把头换成新节点。如果期间头被别人换了,CAS 失败,重新读取头再试。Pop类似,先读头和头的下一个,然后尝试把头换成下一个。
这个实现有个著名的隐患叫ABA 问题。假设线程 A 读到头是节点 X,准备把 X 弹出。在 A 执行 CAS 之前,线程 B 把 X 弹出,又压入了另一个节点 Y,然后又把 X 压回去。此时头又变回了 X,A 的 CAS 看到头还是 X,就以为没人动过,成功把 X 弹出。但实际上栈的结构已经变了,A 的操作可能破坏数据一致性。
解决 ABA 的常见办法是给每个节点加一个版本号,把“指针 + 版本号”打包成一个long或者用Interlocked.CompareExchange操作一个包含版本的结构。在 .NET 里,如果指针是 64 位,就没法再塞版本号进同一个long了,这时候通常用IntPtr配合额外的版本字段,或者干脆改用带锁的实现。所以无锁栈在 .NET 里要真正安全,复杂度不低,生产环境用ConcurrentStack更省心。
4.3 参数计算:溢出边界与重试次数
用Interlocked做计数,必须算清楚溢出边界。以int为例,最大值是2,147,483,647。假设你的服务每秒处理 10 万次请求,每次请求自增一次,那么:
2147483647 / 100000 = 21474.8 秒 ≈ 5.96 小时不到 6 小时就会溢出。溢出后Increment会从int.MaxValue回绕到int.MinValue,计数变成负数,所有基于计数的判断全部失效。所以任何可能长期运行的计数器,必须用long。long的最大值是9,223,372,036,854,775,807,即使每秒 10 亿次,也要 292 年才溢出,实际项目中完全够用。
再说 CAS 循环的重试次数。理想情况下,冲突概率低,平均重试次数接近 1。如果实测发现平均重试超过 3 次,说明竞争已经很激烈了。这时候有两个选择:一是降低共享变量的粒度,比如用分片计数(每个线程或每个核心一个计数器,最后汇总);二是改用锁。分片计数是提升并发计数性能的常用手段,ConcurrentDictionary内部就用了类似思路。
private readonly long[] _shards = new long[Environment.ProcessorCount]; public void IncrementSharded(int shardIndex) { Interlocked.Increment(ref _shards[shardIndex % _shards.Length]); } public long Total() { long sum = 0; for (int i = 0; i < _shards.Length; i++) { sum += Volatile.Read(ref _shards[i]); } return sum; }分片之后,每个线程操作不同的数组元素,缓存行冲突大幅减少,CAS 几乎不会失败。代价是读取总数时要遍历所有分片,读操作变重了。所以这是典型的写多读少场景优化。
5. 常见问题与排查技巧实录
5.1 为什么用了 Interlocked 还是丢数据
这是最高频的问题。我排查过好几次,根源基本都落在同一个地方:把Interlocked用在了复合操作上。比如下面这段代码:
// 错误示范 if (Interlocked.Increment(ref _count) == 1) { Initialize(); }看起来没问题,但如果两个线程同时Increment,一个得到 1,一个得到 2,只有得到 1 的线程会执行Initialize,这符合预期。可如果Initialize本身需要保证只执行一次,而_count在别处还会被Decrement,那就可能出现“计数回到 1 又触发一次初始化”的情况。Interlocked保证的是单步原子,不保证你的业务逻辑整体原子。
正确的做法是用专门的标志位配合CompareExchange,或者直接用Lazy<T>:
private readonly Lazy<HeavyResource> _resource = new Lazy<HeavyResource>(() => new HeavyResource(), LazyThreadSafetyMode.ExecutionAndPublication);Lazy<T>内部就是用锁或 CAS 实现的,比自己手写可靠得多。能用现成的线程安全类型,就别自己造轮子,这是我在多个项目里总结出的铁律。
5.2 内存可见性导致的“幽灵值”
另一个隐蔽的问题是:一个线程用Interlocked更新了变量,另一个线程用普通读取却读到了旧值。虽然Interlocked会插入内存屏障,但那只保证执行 Interlocked 的那个线程的写操作对其他线程可见。如果读的一方用的是普通字段读取,在某些架构(尤其是 ARM)上,编译器或 CPU 可能把读操作缓存到寄存器,导致一直读到旧值。
解决办法是读的一方也要用Volatile.Read或者Interlocked.CompareExchange(ref x, 0, 0)(后者是读取的原子写法,但开销略大)。在 x86 架构上,由于内存模型较强,普通读通常也能读到最新值,所以这个问题在开发机上很难复现,一到 ARM 服务器上就暴露。跨平台项目一定要在 ARM 环境做并发测试,这是我踩过的最贵的坑之一。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 计数偶尔少加 | 复合操作非原子 | 检查是否有读-改-写分离 | 改用 CAS 循环或锁 |
| 读到旧值 | 读方未用 Volatile | 检查读取路径 | 读方加Volatile.Read |
| 计数变负数 | int 溢出 | 检查计数范围与运行时长 | 改用long |
| CPU 占用高 | CAS 自旋过频 | 统计重试次数 | 分片或改用锁 |
| ARM 上行为异常 | 内存模型差异 | 在 ARM 环境复现 | 补全内存屏障 |
| 属性无法传 ref | 语法限制 | 检查参数类型 | 改用字段或数组元素 |
5.4 几个容易被忽略的实操心得
第一,Interlocked不能用于readonly字段。因为readonly字段只能在构造函数中赋值,编译器不允许取它的ref。如果你需要原子操作一个只初始化一次的字段,要么去掉readonly,要么用Exchange在构造函数里赋值(但构造函数里其实不需要原子性)。
第二,数组元素的原子操作要注意边界。Interlocked.Increment(ref array[i])是合法的,但i本身的计算如果不是原子的,可能越界。建议先算好索引再操作,或者用Interlocked保护索引本身。
第三,调试时不要依赖断点观察Interlocked变量。断点会暂停所有线程,掩盖真实的竞争时序。要观察并发行为,用日志或者性能计数器,别用断点单步。
第四,Interlocked的返回值一定要用上。很多人写Interlocked.Increment(ref x);忽略返回值,这本身没错,但如果你需要基于新值做判断,就必须接住返回值。忽略返回值再单独读一次x,那个读和自增之间又有窗口期,等于白用。
6. 性能对比与选型建议
6.1 实测数据对比
我在一台 8 核机器上做了组对比测试,场景是 8 个线程各执行 100 万次自增,分别用Interlocked、lock、volatile三种方式,结果如下:
| 方式 | 总耗时 | 最终值正确性 | CPU 占用 |
|---|---|---|---|
Interlocked.Increment | ~210 ms | 正确 | 中等 |
lock包裹自增 | ~1600 ms | 正确 | 较高 |
volatile+ 自增 | ~90 ms | 错误(丢更新) | 低 |
volatile最快但结果错误,直接排除。Interlocked比lock快了约 7 倍,而且随着核数增加,差距还会拉大,因为lock的竞争成本随线程数非线性增长。这个数据很直观地说明了:在单字段原子更新场景,Interlocked是性能和正确性的最佳平衡点。
6.2 什么时候不该用 Interlocked
虽然Interlocked很强,但有几类场景我建议直接上锁,别硬撑:
- 需要更新多个相关字段:比如同时更新
count和totalTime,两者必须保持一致。用两个Interlocked无法保证整体原子,中间可能被观察到不一致状态。 - 临界区里有 I/O 或复杂计算:
Interlocked只适合极短的操作,任何耗时逻辑都应该放进锁里,否则 CAS 循环会疯狂重试。 - 需要等待条件成立:
Interlocked没有等待/通知机制,如果你需要“等到某个条件再继续”,得用Monitor.Wait/Pulse或SemaphoreSlim。 - 代码可读性优先:有些团队对无锁代码的维护能力有限,强行用 CAS 反而增加 bug 风险。这种情况下,一把锁换来清晰逻辑,是更务实的选择。
6.3 和其他并发原语的配合
Interlocked经常和CancellationToken、Task、ConcurrentQueue等配合使用。比如实现一个带取消的限流器,用Interlocked计数,用CancellationToken控制退出;再比如用Interlocked.Exchange实现TaskCompletionSource的一次性完成保护。理解Interlocked的边界,才能在这些组合场景里游刃有余。
我在实际项目里最常用的组合是:Interlocked管计数和状态标志,ConcurrentDictionary管共享映射,SemaphoreSlim管异步限流。这三者各司其职,基本能覆盖 90% 的并发需求,而且都不需要手写复杂的锁逻辑。
最后分享一个我自己的小习惯:每次写完涉及Interlocked的代码,我都会问自己三个问题——这个操作是不是单字段?读的一方有没有做可见性保证?高竞争下会不会自旋过度?这三个问题过一遍,基本能挡掉大部分隐患。并发代码的正确性没法靠测试穷举,只能靠对内存模型的理解和严谨的推理,这一点,用再多工具也替代不了。