c#无独立内存模型,其多线程行为由cli规范和运行时共同约束;volatile仅禁止重排序并保证可见性但不保证原子性或互斥;lock和interlocked提供更强同步保障。

Memory Model 在 C# 中不是语言标准强制定义的独立规范
C# 本身没有像 Java 那样明确定义一套独立的、可被程序员直接引用的“内存模型”文档;它的多线程行为由 ECMA-335(CLI 规范)和 .NET 运行时(尤其是 JIT 编译器与 GC)共同约束。真正起作用的是 CLI 的 memory model,它规定了读写重排序边界、happens-before 关系以及 volatile 语义等底层契约。
这意味着你不能只看 C# 语法就推断线程安全——必须结合运行时实际行为,特别是 volatile、Interlocked、lock 等机制如何映射到 CLI 内存屏障(memory barrier)指令。
volatile 关键字在 C# 中到底保证什么
volatile 不是万能锁,它只提供两个关键保障:禁止编译器和 JIT 对该字段的读/写进行重排序,并确保每次读都从主内存(或最新缓存行)取值、每次写都立即刷回主内存(对其他线程可见)。但它不保证原子性(比如 volatile int 的 ++ 仍是非原子的),也不提供互斥。
- 适用于单个布尔标志、状态标记等简单场景,例如:
private volatile bool _isRunning; - 不能用于复合操作:
_counter++即使_counter是volatile,仍可能丢失更新 - .NET 5+ 中
volatile的语义更严格(对应 CLI 的volatile.前缀),但依然不等于std::atomic的全功能
lock 和 Interlocked 是更可靠的选择
lock 本质是 Monitor.Enter/Exit,会在进入和退出时插入 full memory barrier,既防止重排序,又保证临界区内所有内存访问对其他线程可见。而 Interlocked 系列(如 Interlocked.Increment、Interlocked.CompareExchange)则通过 CPU 原子指令(如 xchg、lock xadd)实现无锁同步,同时自带 acquire/release 语义。
-
lock适合保护一段逻辑(含多个读写),但有开销;Interlocked适合单变量原子操作,性能更高 -
Interlocked.Read(ref long)在 32 位系统上是必需的(否则long读写非原子),64 位下也推荐用以明确语义 - 不要混用:对同一变量,一边用
volatile读,一边用Interlocked写,虽然通常可行,但语义混乱且易误判依赖关系
容易被忽略的坑:字段初始化与静态构造函数的线程安全性
C# 编译器会把 Static readonly 字段的初始化提升到静态构造函数中,而 CLI 保证静态构造函数只执行一次且具有 happens-before 语义——这是少数几个无需显式同步就能天然保证可见性的场景。但普通字段(哪怕 readonly)在构造函数中初始化后,若被发布到其他线程,仍需正确发布(如通过 volatile 引用、lock 或 Interlocked 赋值)才能确保其他线程看到其完全构造好的状态。
一个典型错误是对象逃逸(escape):在构造函数未完成时,就把 this 发布给其他线程(比如注册事件、放入全局集合),此时其他线程可能看到部分初始化的对象字段——这种问题无法靠 volatile 修复,只能靠设计规避。