跳到正文
我的博客
返回

委托的线程安全:`+=` 看着是一步,实际是三步

目录

委托的线程安全:+= 看着是一步,实际是三步

上一篇聊 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 那样抛”集合已修改”异常——只是这一轮调用用的是旧快照。

所以多线程下的问题是”丢订阅”,不是崩溃。 这个区别挺重要:程序不会报错,就是莫名其妙少了个监听器,等你发现某个回调死活不触发时,已经过去很久了。

那自己手写该怎么办

三条路,按推荐顺序:

  1. 用 event。 上面那套 CAS 循环是编译器免费送的,自己写错的概率不低。

  2. 加 lock。 想自己控制就老老实实锁,最直接也最不容易出错:

    private readonly object _gate = new();
    
    public void Add(MessageHandler handler)
    {
        lock (_gate) { _handlers += handler; }
    }
  3. 把订阅挪到单线程阶段。 如果所有订阅都发生在初始化阶段(还没起任何后台线程),那根本不存在并发,裸 += 也安全。这条最省事,但前提是你得确认清楚——怎么判断有没有多线程、算不算共享,见《什么时候才需要管线程安全》。


一句话收尾:+= 是读-改-写三步,private 只防外人写代码碰它,防不了几个线程同时跑这段代码,所以必须靠 CAS 或 lock。

参考:Interlocked.CompareExchange、C# 内存模型理论与实践、CLR 内存模型(Chris Brumme)


分享这篇文章:

上一篇
C# 的语法糖有多甜?——? 家族、=> 与属性模式的一次清点
下一篇
什么时候才需要管线程安全?——一个能直接套的判断清单