1. 从一次诡异的计数偏差说起
去年帮一个朋友排查他那个小工具的性能问题,场景很简单:多个后台线程同时往一个共享计数器里累加数据,主线程定期读取这个计数做进度展示。单线程跑的时候数字分毫不差,一旦把并发线程数拉到4以上,最终结果就总是比预期少那么几十上百次。他一开始怀疑是数据丢了,加了日志、加了锁,甚至把累加逻辑改成了先写本地变量再合并,问题依旧。最后我让他把count++换成Interlocked.Increment(ref count),跑了一晚上压测,数字稳如磐石。
这个案例几乎是我这些年遇到的多线程入门问题的缩影。很多人写C#多线程代码时,第一反应是"加个锁就完事了",lock确实好用,但它在高频、细粒度的计数、标志位切换、引用交换这类场景下,开销和复杂度都不划算。而Interlocked这个类,就是.NET给我们准备的一把"轻量级手术刀"——它不阻塞线程,靠CPU层面的原子指令保证操作的不可分割性,专门解决共享变量的并发读写问题。
这篇文章我想把Interlocked从"会用"讲到"用对"。不管你是刚接触多线程的新手,还是写过几年并发代码但一直没系统梳理过原子操作的老手,都能从中找到能直接抄作业的东西。我会讲清楚它到底解决了什么问题、每个常用方法背后的原理、什么场景该用它什么场景不该用、以及那些文档里不会写但实际会踩的坑。核心关键词就一个:原子操作,围绕它展开的所有细节,都是我在真实项目里验证过的。
2. 为什么普通自增在并发下会丢数据
2.1 一行count++背后其实有三步
很多人觉得count++是一个动作,但在CPU和IL层面,它至少被拆成三步:读取count的当前值到寄存器,计算加1,写回内存。这三步之间没有任何保护,多个线程完全可能交错执行。
设想两个线程同时执行count++,初始值是0。线程A读到0,线程B也读到0,A算出1写回,B也算出1写回,最终结果是1而不是2。这就是典型的丢失更新。线程数越多、操作越频繁,丢得越厉害。我朋友那个工具丢几十上百次,就是这个原因。
这里有个容易被忽略的点:即使你用的是int这种32位类型,在32位进程里读写本身可能是原子的,但"读-改-写"这个复合操作永远不是原子的。原子性针对的是单个操作,不是操作序列。
2.2 加锁能解决,但代价你未必愿意付
最直接的修复当然是加锁:
private readonly object _lock = new object(); private int _count; public void Add() { lock (_lock) { _count++; } }这段代码逻辑上没问题,但有几个现实问题。第一,lock会引发线程阻塞和上下文切换,在每秒几十万次累加的场景下,锁竞争带来的开销可能比业务逻辑本身还大。第二,锁的粒度如果没控制好,很容易把不相关的操作也圈进去,导致并发度骤降。第三,也是最隐蔽的——锁只能保护"所有访问这个变量的人都用同一把锁"的情况,一旦有人漏加锁,数据照样出错,而且这种bug极难复现。
Interlocked的思路完全不同:它不靠"排队"来保证安全,而是依赖CPU提供的原子指令(比如x86上的LOCK前缀指令、CMPXCHG比较并交换指令),让"读-改-写"在硬件层面变成一个不可中断的整体。没有锁、没有阻塞、没有上下文切换,性能通常比锁高一个数量级。
2.3 内存屏障:原子操作顺带解决的另一个问题
除了原子性,Interlocked还隐含了内存屏障的语义。现代CPU和编译器为了性能,会对指令做重排序,这在单线程下没问题,但多线程下可能导致一个线程的写入对另一个线程"看起来"顺序错乱。
举个典型场景:线程A先写数据,再设置一个flag = true表示数据就绪;线程B看到flag为true后去读数据。如果写数据和写flag被重排,线程B可能读到flag为true但数据还是旧的。Interlocked系列方法在操作前后会插入完整的内存屏障,保证操作的可见性和顺序性。这一点在写无锁数据结构时至关重要,后面讲CompareExchange时会再展开。
3. Interlocked 家族方法逐个拆解
3.1 Increment / Decrement:最常用的计数器
Interlocked.Increment(ref int location)和Interlocked.Decrement(ref int location)分别做原子的加1和减1,返回操作后的新值。它们有int和long两个重载。
private int _requestCount; public void OnRequest() { int current = Interlocked.Increment(ref _requestCount); // current 是自增后的值,可以直接用来做判断 if (current % 1000 == 0) { Console.WriteLine($"已处理 {current} 个请求"); } }这里有个实用技巧:Increment返回的是自增之后的值,所以你可以直接拿它做"每N次触发一次"的逻辑,不需要再单独读一次变量。如果用count++再读,中间可能被其他线程改掉,判断就不准了。
需要注意的是,Increment只保证自增这个动作原子,不保证你后续基于返回值的业务逻辑原子。上面那个% 1000判断是安全的,因为它只依赖返回值;但如果你要"自增后如果等于某个值就执行一段复杂逻辑",那段逻辑本身还是需要额外同步的。
3.2 Add:批量累加的正确姿势
Interlocked.Add(ref int location, int value)做原子加法,返回新值。它比循环调用Increment高效得多,适合批量统计场景,比如累加一批数据的字节数。
private long _totalBytes; public void Accumulate(byte[] buffer) { Interlocked.Add(ref _totalBytes, buffer.Length); }注意Add的long重载在32位进程里也是真正原子的,.NET内部会处理好,不用担心被拆成两次32位操作。这一点比你自己用两个int拼一个long要靠谱得多。
3.3 Exchange:原子交换与标志位切换
Interlocked.Exchange(ref T location, T value)把location设为value,并返回原来的值。它支持int、long、float、double、object以及泛型引用类型。
这个方法的经典用途是一次性初始化和状态机切换:
private int _state = 0; // 0=未初始化, 1=初始化中, 2=已完成 public void EnsureInitialized() { if (Interlocked.Exchange(ref _state, 1) == 0) { // 只有第一个把0换成1的线程会进来 DoHeavyInitialization(); Interlocked.Exchange(ref _state, 2); } }不过要提醒一句,上面这种"检查并设置"的写法在严格意义上不是线程安全的完整方案——如果初始化失败需要回滚状态,或者有线程在状态为1时就需要等待,那还是得配合其他同步原语。Exchange更适合"我只关心谁抢到了第一次"这种场景。
3.4 CompareExchange:无锁编程的基石
Interlocked.CompareExchange(ref T location, T value, T comparand)是最强大也最难用的一个。它的逻辑是:如果location当前等于comparand,就把它设为value,返回原来的值;否则什么都不做,直接返回原值。
用伪代码表达就是:
old = location; if (old == comparand) location = value; return old;整个"比较并替换"是一个原子操作。它是实现无锁算法(lock-free)的核心工具,因为你可以用它做"乐观更新":先读当前值,算好新值,再用CompareExchange尝试替换,如果失败说明被别人改过了,就重试。
private int _maxValue; public void UpdateMax(int candidate) { int current; do { current = _maxValue; if (candidate <= current) return; // 已经有人放了更大的值 } while (Interlocked.CompareExchange(ref _maxValue, candidate, current) != current); }这段代码是典型的CAS(Compare-And-Swap)循环。它的精妙之处在于:不需要锁,多个线程可以同时尝试更新,失败的那个重试即可。在高并发但冲突不激烈的场景下,这种方式的吞吐量远超锁。
3.5 Read:64位读取的原子保证
Interlocked.Read(ref long location)在32位平台上保证64位读取的原子性。在64位平台上它基本等同于普通读取,但为了代码的可移植性和明确意图,涉及跨线程共享的long读取时,建议统一用Interlocked.Read。
private long _timestamp; public long GetTimestamp() { return Interlocked.Read(ref _timestamp); }3.6 方法速查表
| 方法 | 作用 | 返回值 | 典型场景 |
|---|---|---|---|
Increment | 原子加1 | 新值 | 计数器、序号生成 |
Decrement | 原子减1 | 新值 | 信号量、剩余数量 |
Add | 原子加N | 新值 | 批量统计、字节累加 |
Exchange | 原子赋值 | 旧值 | 标志位切换、一次性初始化 |
CompareExchange | 条件原子赋值 | 旧值 | 无锁算法、乐观更新 |
Read | 原子读64位 | 当前值 | 跨线程读long |
4. 无锁栈实战:把 CompareExchange 用起来
4.1 需求与设计思路
光看API说明很难真正理解CompareExchange的威力,我拿一个无锁栈(lock-free stack)的实现来演示。需求很朴素:多个线程可以并发地Push和Pop,不用任何锁。
栈的底层用单链表,每个节点持有数据和指向下一个节点的引用。栈顶用一个Node字段表示。Push的逻辑是:新节点的Next指向当前栈顶,然后用CompareExchange把栈顶替换成新节点。如果替换失败(说明栈顶被别人改了),就重试。
4.2 完整实现
public class LockFreeStack<T> { private class Node { public T Value; public Node Next; public Node(T value) { Value = value; } } private Node _head; public void Push(T value) { var newNode = new Node(value); Node currentHead; do { currentHead = _head; newNode.Next = currentHead; } while (Interlocked.CompareExchange(ref _head, newNode, currentHead) != currentHead); } public bool TryPop(out T value) { Node currentHead; do { currentHead = _head; if (currentHead == null) { value = default; return false; } } while (Interlocked.CompareExchange(ref _head, currentHead.Next, currentHead) != currentHead); value = currentHead.Value; return true; } }Push的循环逻辑是:每次重试都重新读一遍_head,把新节点的Next指向它,然后尝试CAS。TryPop类似,只是把栈顶替换成currentHead.Next。
4.3 这个实现藏着一个经典陷阱
上面这段代码在单生产者单消费者场景下工作良好,但在多生产者多消费者下有一个著名的ABA问题。设想:线程A读到栈顶是节点X,准备把栈顶换成X.Next。在A执行CAS之前,线程B把X弹出,又压入了节点Y和Z,恰好Z的内存地址复用了X原来的位置。此时A的CAS看到栈顶"还是X"(其实是Z),就成功执行了,结果栈顶被设成了X.Next,而X.Next指向的可能是已经被释放的节点,整个栈就乱了。
解决ABA问题的标准做法是给栈顶加一个版本号,把"指针+版本号"打包成一个整体做CAS。在C#里可以用(Node, int)元组配合CompareExchange的泛型重载,或者用IntPtr加偏移量的技巧。不过说实话,除非你在写底层框架,否则.NET的ConcurrentStack<T>已经把这些坑都填好了,直接用就行。我讲这个例子是为了让你理解CompareExchange的能力边界——它能保证"比较并替换"原子,但不能自动帮你解决所有并发语义问题。
提示:自己实现无锁数据结构时,ABA问题、内存回收(节点何时能安全释放)是两个绕不开的难点。生产环境优先用
System.Collections.Concurrent下的现成集合。
5. 那些文档不会告诉你的坑
5.1 别把 Interlocked 当万能锁用
我见过有人试图用Interlocked保护一段多步业务逻辑,比如"读余额、判断是否足够、扣减",写成CAS循环。这种写法在逻辑简单时可行,但一旦中间涉及多个变量的联动更新,CAS循环会变得极其复杂且容易出错。原子操作保护的是单个变量,涉及多个变量的不变式时,锁才是更清晰的选择。
判断标准很简单:如果你能用一句话描述"我要原子地改变哪个变量的什么值",用Interlocked;如果描述里出现了"并且""同时""如果……那么……"这类连接词,先考虑锁。
5.2 volatile 和 Interlocked 不是一回事
volatile关键字保证的是可见性和禁止重排序,但它不保证复合操作的原子性。volatile int count; count++;依然会丢数据。很多人把这两个概念混为一谈,结果写出了"看起来加了volatile就安全了"的代码。
正确的组合是:需要原子性的复合操作用Interlocked,单纯需要可见性的标志位读取可以用volatile。实际上Interlocked的方法内部已经包含了完整的内存屏障,所以对同一个变量,用了Interlocked就不需要再叠volatile。
5.3 结构体和自定义类型用不了
Interlocked的泛型重载只支持引用类型,不支持结构体和自定义值类型。CompareExchange<T>的约束是where T : class。如果你需要对一个结构体做原子更新,要么把它拆成几个基础类型分别处理,要么用锁,要么用System.Threading.Volatile配合其他手段。这是很多人在设计数据结构时容易忽略的限制。
5.4 高频CAS循环可能比锁还慢
CAS循环在冲突激烈时会疯狂重试,CPU空转,吞吐量反而下降。我做过一个粗略的对比测试:8个线程同时对一个计数器做累加,冲突极高时,Interlocked.Increment依然很快(因为它是单条指令),但手写的CAS循环更新一个复杂对象时,性能可能不如lock。
经验法则是:简单的数值增减用Interlocked的内置方法,复杂的条件更新先评估冲突概率。如果冲突概率高,考虑用lock或者分片(把一个大计数器拆成多个,每个线程操作自己的分片,最后汇总)。
5.5 调试时的可见性陷阱
在调试器里单步执行多线程代码时,Interlocked的效果可能被调试器的行为掩盖。比如你在断点处观察变量,调试器本身会暂停所有线程,这时候你看到的"正确结果"不代表真实并发下也正确。验证并发代码的正确性,压测和压力下的日志比单步调试可靠得多。
6. 性能对比与选型建议
6.1 三种方案的开销量级
我整理了一张对比表,数据来自我在一台普通开发机上的实测(具体数字因硬件而异,但量级关系是稳定的):
| 方案 | 单次操作开销 | 高并发表现 | 适用场景 |
|---|---|---|---|
普通count++ | 极低 | 数据错误 | 单线程 |
lock | 中等(含上下文切换) | 竞争激烈时下降明显 | 多变量、复杂逻辑 |
Interlocked | 低(单条原子指令) | 竞争激烈时仍稳定 | 单变量原子更新 |
在低竞争场景下,Interlocked通常比lock快3到10倍;在高竞争场景下,差距可能拉大到几十倍,因为lock的线程阻塞和唤醒成本随竞争加剧而急剧上升。
6.2 什么时候该果断上锁
Interlocked不是银弹。以下几种情况我会毫不犹豫地用lock:
- 需要同时更新多个相关变量,且它们之间有不变量约束。
- 临界区里有I/O操作、方法调用或其他可能抛异常的复杂逻辑。
- 代码可读性优先于极致性能,且性能瓶颈不在这里。
- 需要等待某个条件成立(
Interlocked做不了等待,只能做尝试)。
6.3 分片计数:高竞争下的进阶技巧
如果确实需要极高的计数吞吐,又不想用锁,可以用分片(sharding)思路:维护一个计数器数组,每个线程根据自己的ID或哈希选一个分片做Interlocked.Increment,读取时把所有分片加起来。
private readonly int[] _shards = new int[Environment.ProcessorCount * 4]; public void Increment() { int index = Thread.CurrentThread.ManagedThreadId % _shards.Length; Interlocked.Increment(ref _shards[index]); } public long GetTotal() { long total = 0; for (int i = 0; i < _shards.Length; i++) { total += Interlocked.Read(ref Unsafe.As<int, long>(ref _shards[i])); } return total; }这个技巧的核心是把竞争分散到多个缓存行上,减少CPU缓存一致性协议带来的开销。代价是读取时需要汇总,且汇总结果不是某一瞬间的精确快照。适合"最终一致"的统计场景,不适合需要精确瞬时值的场景。
7. 我在实际项目里踩过的几个具体坑
第一个坑是关于CompareExchange的返回值判断。我早期写CAS循环时,习惯写成while (Interlocked.CompareExchange(...) != comparand),后来发现更稳妥的写法是把返回值存下来判断,因为comparand在某些重载下可能被隐式转换,直接比较容易出意外。现在我统一写成先赋值再比较,可读性也更好。
第二个坑是Interlocked和async/await的配合。Interlocked操作的是内存位置,而await之后线程可能切换,如果你在await前后对同一个变量做Interlocked操作,逻辑上没问题(原子性依然成立),但要清楚此时"哪个线程执行"已经变了,不要依赖线程本地状态。
第三个坑最隐蔽:在struct的字段上做Interlocked操作。如果结构体是装箱的,你操作的是装箱后的副本,原结构体根本没变。这种bug不会报错,只会让结果莫名其妙。涉及结构体时,务必确认你操作的是同一个内存位置。
最后一个经验:写并发代码时,先想清楚不变式是什么,再选工具。Interlocked保证的是单个操作的原子性,它不会帮你维护跨操作的不变式。把不变式想明白了,工具选择自然就清晰了。