为什么双重检查锁定的C#单例实现需要使用Volatile.Write?
你的前半部分理解是正确的:用临时变量temp确实避免了直接给s_value赋值时,构造函数和赋值操作的指令重排导致其他线程拿到半初始化对象的问题。必须使用Volatile.Write的核心原因和内存可见性规则相关:
核心原因:弱内存模型下的可见性顺序重排
.NET遵循ECMA CLI规范定义的弱内存模型,允许CPU对内存写入的可见性顺序进行重排(只要不影响单线程的执行逻辑):
- 对当前执行初始化的线程来说,执行顺序确实是
执行Singleton构造 -> 赋值给temp -> 赋值给s_value,单线程下逻辑完全正确。 - 但对其他CPU核心上运行的线程来说,它们看到的内存变更顺序可能是
s_value被赋值为非null->Singleton实例内部的字段完成初始化赋值。
这种可见性重排不会影响当前线程,但其他线程在锁外执行无锁判断if (s_value != null)时,如果拿到了非null的s_value,但此时实例内部的字段还没完成初始化同步,访问这些字段就会拿到默认值,触发不可预期的错误。
而Volatile.Write带有Release内存屏障语义,它会强制保证:所有在Volatile.Write调用之前的内存写入操作(包括Singleton构造函数内的所有字段写入),都必须同步到所有CPU核心可见之后,才会执行s_value的写入操作。只要其他线程读到了非null的s_value,就一定能拿到完全初始化完成的实例,不会出现内部字段未就绪的问题。
常见疑问补充:为什么Monitor.Exit的屏障不够?
Monitor.Exit本身确实会触发Release屏障,但锁的屏障只能保证持有同一把锁的线程之间的可见性。其他线程在执行锁外的无锁s_value读取时,并没有获取同一个锁,所以不受到Monitor.Exit屏障的约束,还是可能看到乱序的内存变更。只有Volatile.Write能保证无锁读取场景下的写入顺序可见性。
等价实现方案
如果你给s_value字段加上volatile修饰:
private static volatile Singleton s_value = null;
那么就可以直接用普通赋值s_value = temp,不需要Volatile.Write:因为volatile字段的写入自带Release语义,读取自带Acquire语义,效果完全等价。
内容的提问来源于stack exchange,提问作者user16276760

