委托的线程安全:+= 看着是一步,实际是三步
上一篇聊 delegate 和 event 的时候留了个尾巴:手写 Add/Remove 那版代码是裸的读-改-写,并发下会丢订阅。这篇把它单独拆开。
一句话先放这儿:+= 看着像一步,实际是三步,而三步之间是可以被别的线程插进来的。
根本原因:委托是不可变的
_handlers += handler 并不是”往现有列表里追加一项”。委托对象一旦创建就不能改,所以这句实际做的是:
// 编译器展开后
_handlers = Delegate.Combine(_handlers, handler);
// ↑ 读旧的 → 合并出一个【全新的委托对象】 → 赋值给字段
也就是说每次 += 都造了个新对象,然后把字段的引用换掉。它等价于:
var old = _handlers; // ① 读
var newOne = (MessageHandler)Delegate.Combine(old, handler); // ② 造新的
_handlers = newOne; // ③ 写回
丢订阅的过程
两个线程同时订阅,理想结果是链上有 3 个方法,实际只剩 2 个:
初始:_handlers = [原始方法]
线程A:① 读到 [原始方法]
线程B:① 读到 [原始方法] ← 两人读到的是同一个旧值
线程A:② 造出 [原始, A的方法]
线程A:③ 写回 → _handlers = [原始, A] ✅
线程B:② 造出 [原始, B的方法] ← 它根本不知道 A 已经加过了
线程B:③ 写回 → _handlers = [原始, B] ❌ A 的订阅被整个覆盖掉
后写的直接把先写的覆盖了,因为 B 是基于过期的旧值算出来的。这就是经典的 lost update(丢失更新)。
为什么 private 救不了
private 是编译期的访问控制,管的是”谁的代码能访问这个字段”。而线程安全是运行时问题:只要 Add 是 public 的,两个线程就能同时执行这个方法体,最终都在改同一个对象里那个私有字段。可见性管的是”谁能写”,管不了”几个人同时执行”——跟前面说 event 防”越权”是两个维度。
顺带说说”可见性”这个隐患
这里有个容易被说岔的地方,值得较真一下。
常见说法是”没有 lock 就没有内存屏障,线程 A 写进去的新值线程 B 看不到”。这句话方向反了一半。 按 CLR 的内存模型,每一次写(不管是不是 volatile 字段)都算 release,写是真的会落到内存里的,不会赖在寄存器里不出来。
真正出问题的是读那一侧。CLR 模型里明写着 “Non-volatile loads can be reordered freely”——非 volatile 的读可以被自由重排,JIT 也完全可以把一次字段读取提到循环外、缓存进寄存器。MSDN 上那个经典的 polling loop 就是活例子:
class PollingLoopExample
{
private bool _loop = true; // 注意:非 volatile
public static void Main()
{
var test1 = new PollingLoopExample();
new Thread(() => { test1._loop = false; }).Start();
while (test1._loop) ; // 这个循环可能永远不会结束
}
}
在 x86/x64 上它照样可能挂死——JIT 把 _loop 读了一次存进寄存器,之后循环就一直看那个寄存器,而寄存器永远不会变。
所以准确的表述是:写那边没问题,读这边可能被优化成”只看一次旧值”。 不过话说回来,委托丢订阅这个场景里,主因是原子性(三步被人穿插),可见性是次要隐患;真要一起解决,用 lock 或者 Interlocked 就都覆盖了——它们都带屏障。
那为什么编译器生成的 add/remove 就没事
因为它把这三步做成了原子的 CAS 循环。注意这里有个很容易写错的地方:算新值的那一步必须在循环内部,否则重试时拿的还是基于旧值算出来的那个 newOne,白重试。
正确的长相是这样:
do
{
var old = _handlers; // ① 读当前值
var newOne = (MessageHandler)Delegate.Combine(old, handler); // ② 【循环内】基于刚读到的值重新算
var witness = Interlocked.CompareExchange(ref _handlers, newOne, old);
// ↑ 只有"字段现在还等于我刚读到的 old"才写入,否则说明被人改过
}
while (witness != old); // 被人抢先了 → 丢掉刚才的结果,用新值重新算一遍再试
线程 B 发现自己算的时候值已经变了,就基于最新值重新 Combine 再试,所以 A 的订阅不会丢。
我一开始把 newOne 写在循环外面,看着”也能跑”,其实重试多少次都是在写同一个过期结果——这种 bug 很隐蔽,因为多数时候第一次 CAS 就成功了。
顺带一个好消息
委托不可变也带来一个好处:调用方 Invoke 时是先读一份快照到本地变量(这正是 ?.Invoke() 的写法),所以即使遍历过程中有人 +=,也不会像 List 那样抛”集合已修改”异常——只是这一轮调用用的是旧快照。
所以多线程下的问题是”丢订阅”,不是崩溃。 这个区别挺重要:程序不会报错,就是莫名其妙少了个监听器,等你发现某个回调死活不触发时,已经过去很久了。
那自己手写该怎么办
三条路,按推荐顺序:
-
用 event。 上面那套 CAS 循环是编译器免费送的,自己写错的概率不低。
-
加 lock。 想自己控制就老老实实锁,最直接也最不容易出错:
private readonly object _gate = new(); public void Add(MessageHandler handler) { lock (_gate) { _handlers += handler; } } -
把订阅挪到单线程阶段。 如果所有订阅都发生在初始化阶段(还没起任何后台线程),那根本不存在并发,裸
+=也安全。这条最省事,但前提是你得确认清楚——怎么判断有没有多线程、算不算共享,见《什么时候才需要管线程安全》。
一句话收尾:+= 是读-改-写三步,private 只防外人写代码碰它,防不了几个线程同时跑这段代码,所以必须靠 CAS 或 lock。
参考:Interlocked.CompareExchange、C# 内存模型理论与实践、CLR 内存模型(Chris Brumme)