写在前面
打开任何一篇"线程安全"教程,多半从 lock 怎么用讲起;打开 Java 的,从 synchronized 讲起;打开 Go 的,从 channel 讲起。工具各不相同,但它们要解决的问题是同一个,而且不属于任何语言——它是"多核硬件 + 编译器优化"与"程序员的顺序直觉"之间的一道鸿沟。
这篇按四层结构从根源讲起:硬件层(缓存与重排序)→ 模型层(内存模型这份"合同")→ 语言层(.NET / Java / C++ / Go / Rust 五种答案)→ 工具层(C# 同步原语的屏障语义)。示例以 C# 为主,但读完你会发现:lock、synchronized、sync.Mutex 底下是同一套东西。
前置知识:知道线程是什么就够(《操作系统学习笔记(二):进程、线程与协程》讲了调度侧);不需要先读任何 .NET 并发文章。
一、三种"不安全":原子性、可见性、有序性
线程安全不是一个问题,是三个问题的统称。
1. 原子性(atomicity)——一个操作被"劈开"了。
| |
两个线程各执行一次 counter++,如果交错在"看一眼"和"写回"之间,结果可能是 1 而不是 2。注意这不需要多核:单核上时钟中断导致的线程切换同样能造成交错。原子性问题是三个里最古老、最容易理解的。
2. 可见性(visibility)——写了,别人看不见。
| |
一个线程修改了 stop,另一个线程可能一直在读旧值——缓存、寄存器复用和编译器/JIT 优化都可能造成"我写了但你读不到"。即使只有一个处理器,缺少同步仍允许编译器把读取缓存到寄存器或移出循环;多核缓存层级只是让问题更容易暴露。不能把单核环境当成可见性保证。
3. 有序性(ordering)——执行顺序和你写的不一样。
你在单线程里写 a = 1; b = 2;,只要最终结果一样,编译器和 CPU 都有权交换顺序(as-if-serial)。问题出现在另一个线程在旁边看:它观察到的写入顺序可能与你的程序顺序相反。这不是 bug,是优化——但你的直觉没跟上。
把三个问题合起来,得到贯穿全文的定义——数据竞争(data race):
两个线程访问同一内存位置,至少一个是写,且两者之间没有同步关系。
数据竞争 ≠ 竞态条件
一个容易混淆、值得开头就掰清的区分:数据竞争是内存层面的(定义如上,三条缺一不可),竞态条件(race condition)是逻辑层面的——程序的正确性取决于线程的相对时序。关键是两者可以独立出现。
没有数据竞争、却有竞态条件的例子(每个操作自身都线程安全):
| |
两个线程同时通过检查、先后写入——最终"谁后到谁覆盖",结果对不对取决于时序,而全程没有任何数据竞争(集合内部负责并发安全)。修法也不是在外面重复一次检查,而是使用与业务语义匹配的单操作 API,例如 TryAdd(key, value)。GetOrAdd 能保证字典最终只采用一个值,但它的 valueFactory 在锁外执行,竞争时可能调用多次;工厂若有副作用或创建成本很高,仍要单独设计。
反方向也存在:有数据竞争但结果碰巧正确(两个线程都往同一个 bool 写 true,怎么交错都是 true——所谓 benign data race)。但在 C++ 里它照样是 UB;Java/Go 虽禁止凭空值,一旦类型宽过机器字(Java 的非 volatile long/double、Go 的多字值),撕裂照样可能发生——“碰巧对"不构成依赖的理由。
所以准确的表述是两层:消灭数据竞争是内存正确性的门槛——靠同步原语和内存模型;消灭有害竞态是逻辑正确性的门槛——只能靠把"检查 + 行动"设计成原子单元,硬件帮不了你。
二、硬件层:为什么"看见"这么难
缓存层级:慢的不是运算,是搬运
《每个程序员都该知道的延迟数字》里那张表:L1 缓存约 0.5ns,主内存约 100ns——差了约 200 倍。所以每个核心都有私有缓存(L1/L2),共享的只有 L3。多线程的可见性问题,从这个结构就开始了。
缓存一致性:它保证的比你想的少
硬件当然知道这个问题。现代多核系统用缓存一致性协议(MESI 是最常见的实现)保证两件事(教科书总结为两条不变量):
- SWMR(Single-Writer-Multiple-Reader):任一时刻,一个缓存行要么没有 writer,要么恰好有一个 writer;
- 数据值不变量:一个位置的值要么是上次的值,要么是某个处理器真正写进去的值——不会无中生有。
听起来很完美?缓存一致性 ≠ 顺序一致性。一致性协议维护同一缓存行副本之间的规则,却不替语言程序建立 happens-before,也不承诺一个缺少同步的读取必须在某个期限内观察到写入。程序观察到的先后差距来自写缓冲、失效处理和处理器内存排序等机制:
Store buffer(写缓冲区)。CPU 写内存时不是直接写缓存,而是先塞进自己的写缓冲区,然后继续干别的(等缓存行拿到独占权)。副作用:自己的后续读请求可以先于自己之前的写生效——对外表现就是"写还没被别人看见,但后面的读已经跑了”。这就是著名的 store→load 重排序。
用一个经典的 litmus test(litmus test = 判例测试,两个线程各跑几条指令,看哪些结果组合可能出现):
| |
怎么可能两个都是 0?两个线程都把写留在 store buffer 里、先执行了读。这个结果不违反缓存一致性(没有"错的值"),但违反你的顺序直觉。
Invalidate queue(失效队列)。收到"别人要独占这行缓存"的通知时,CPU 先回 ACK 再慢慢处理失效,于是写传播又慢了一拍。Paul McKenney 的经典论文《Memory Barriers: a Hardware View for Software Hackers》把这两个部件讲得最透(见参考资料)。
x86 和 ARM:强序与弱序
不是所有架构重排程度一样:
| 架构 | 普通读写的重排 | 说明 |
|---|---|---|
| x86 / x86-64 | 只允许 store→load 一种 | 写写、读读、读写都不重排;学术化的名字是 x86-TSO(Total Store Order,所有核共享一个全局写顺序,每核一个 FIFO store buffer) |
| ARM / AArch64 | 比 x86-TSO 更弱,允许更多观察结果 | 具体约束还受指令类型和地址/数据依赖影响;需要跨核顺序时由语言运行时生成 acquire/release 指令或 DMB 等屏障 |
这就是为什么"这段代码在我电脑上没问题"不等于没问题:x86 的强序掩盖了很多错误代码,同一段程序跑在 ARM(手机、Apple Silicon、云上的 Graviton)上才暴露。MSDN 的说法很直白:x86 上唯一可能的重排效应就是"写值不会立刻对其他处理器可用"。
别忘了第二个重排来源:编译器
上面全是硬件。但在指令到 CPU 之前,编译器/JIT 已经重排过一轮了——寄存器缓存、指令调度、循环外提,都发生在单核时代。你面对的乱序是两层叠加的:编译器重排 × CPU 重排。所以"volatile 到底拦谁"这类问题的答案永远是"两层都拦"——后面细说。
小结这一层:硬件给你的是性能优先的弱保证。而程序员的直觉是顺序一致性(sequential consistency, SC)——所有线程的操作仿佛排成一条全局队列、按程序顺序交错执行。直觉与硬件之间的鸿沟,就需要下一层的"合同"来填。
三、内存模型:语言和程序员的合同
内存模型(memory model)= 一门语言对"什么操作以什么顺序对谁可见"的正式承诺。它站在你和"编译器 × 硬件"的两次重排之间:语言保证"只要你按合同写代码,我就把底层收拾得符合你的直觉"。
合同的核心概念是 happens-before,由三种关系构成:
- 程序顺序:单线程内,前面的语句 happens-before 后面的;
- 同步边:一个同步动作和另一个之间有明确的先后——解锁 happens-before 后续对同一把锁的加锁;volatile 字段的写 happens-before 后续对它的读;
- 传递性:A happens-before B,B happens-before C,则 A happens-before C。
只要"写 A"和"读 A"之间存在 happens-before 链条,这次读就能看到这次写(以及写之前的一切)——这就是锁语义的真正内涵:进入锁能看到上一个持锁线程做的所有修改,不是"锁有魔法",而是解锁→加锁构成 happens-before 边。
有了定义,主流语言几乎都遵守同一条总纲——DRF-SC(Data-Race-Free → Sequential Consistency):
程序没有数据竞争,就表现得像顺序一致;有数据竞争,后果按各语言的条款处理。
“按各语言的条款处理"是关键差异所在,下一节展开。这里先给结论:C/C++ 说有竞争就是未定义行为(编译器可以假设竞争不存在);Java/Go 承认竞争发生但约束其后果(读到旧值可以,读到凭空的值不行);Rust 让安全代码根本写不出竞争;.NET 的处境比较微妙——见下。
四、语言层:同一问题的五种答案
.NET:基线很弱,实现很强,文档没跟上
.NET 的正式合同是 ECMA-335 Partition I §12.6(Memory model and optimizations),基线相当保守。落到 C# 规范里有两段可查的硬承诺:
原子性(C# 规范 §9.6):bool、char、byte/sbyte、short/ushort、int/uint、float、枚举(以上为底层类型)和所有引用类型的读写是原子的;long、ulong、double、decimal 和自定义类型不保证原子。
volatile(C# 规范 §15.5.4):volatile 读具有 acquire 语义(之后的访存不会提前到它前面),volatile 写具有 release 语义(之前的访存不会推迟到它后面)。
CLR 的具体实现往往比语言规范基线更强,历史文章也记录过特定 CLR 版本和处理器上的额外排序保证。但这些属于“版本 × 运行时 × 架构”的实现事实,不能从旧版 x86/CLR 经验外推到所有现代 .NET 目标。工程代码应依赖 C# 规范和同步 API 的公开合同,而不是依赖某代 JIT 恰好没有采用某种重排。
工程结论:按 ECMA/C# 规范的基线写代码,不要依赖实现的额外保证——代码才可能在 x86 和 ARM 上同时正确。
Java:JSR-133,语言级内存模型的标杆
2004 年的 JSR-133 重写了 Java 内存模型(JMM),至今仍是"语言把内存模型讲清楚"的标杆。它的 happens-before 清单写在 JLS §17.4.5:程序顺序、解锁→加锁、volatile 写→读、线程 start/join、传递性。
两条对工程影响最大的承诺:
- volatile 的语义类似锁的获取/释放两侧:官方 FAQ 将 volatile 写的内存效果类比为释放锁,将 volatile 读类比为获取锁;具体生成什么指令取决于 JVM 和处理器。
- final 字段的初始化安全保证:只要构造函数里没有让
this提前逃逸,其他线程在正确取得对象引用后能获得 JLS 对 final 字段规定的可见性保证。它支撑不可变对象设计,但不会自动让对象中的所有可变状态都线程安全。
另外 JLS §17.7 明确:非 volatile 的 long/double 写可能被拆成两个 32 位写(撕裂),和 C# 规范对 64 位类型的保留态度如出一辙。
C++:把选择权全部给你,代价是全归你管
C++11 引入了六档 std::memory_order:relaxed(只保原子性不管顺序)、acquire、release、acq_rel、seq_cst(最强:额外保证所有线程看到同一个全局顺序),外加实践中基本不用的 consume(已走向弃用)。锁在模型里也有位置:mutex 的 lock() 是 acquire 操作、unlock() 是 release 操作。
最重要的条款:数据竞争 = 未定义行为([intro.races]:“Any such data race results in undefined behavior”)。编译器可以假设你的程序没有竞争来做激进优化——一旦真有竞争,不是"读到旧值"这么温柔,是任何行为都合法。
Go:只给最强档,附赠一句忠告
Go 内存模型(2022 年随 Go 1.19 修订)做了减法:只提供顺序一致的原子操作(“Go only provides sequentially consistent atomics”),不提供 relaxed 等弱形式。happens-before 清单很实用:
go语句 happens-before 新 goroutine 开始执行(但 goroutine 的结束没有对应保证——这是常见坑);- channel:发送 happens-before 对应接收完成;关闭 happens-before 收到零值;无缓冲 channel 的接收 happens-before 对应发送完成;容量 C 的 channel 第 k 次接收 happens-before 第 k+C 次发送完成;
sync.Mutex:第 n 次Unlock()happens-before 第 m 次Lock()返回;sync.Once:f()的完成 happens-before 任何Do(f)的返回。
数据竞争的后果介于 Java 和 C++ 之间:不是完全未定义(读到的一定是某人真正写过的值,禁止凭空值),但多字大小的竞争可能导致"不属于任何一次写入的混合值"甚至内存破坏。Go 文档还专门把双检锁列为错误用法(见第六节),以及那句著名的忠告:
“如果你必须读完这份文档才能理解你程序的行为,你太聪明了。别耍聪明。”
Rust:把线程安全搬进类型系统
Rust 的答案最彻底:两个 marker trait,Send(可以安全地送到另一个线程)和 Sync(可以安全地在线程间共享,T: Sync ⇔ &T: Send),由编译器自动推导、组合传递。效果是:安全 Rust 不可能构造出数据竞争(Rustonomicon 原话:“A data race has Undefined Behavior, and is therefore impossible to perform in Safe Rust”)——不是运行时报错,是编译不过。
注意边界:Rust 防的是 data race,不防一般竞态——死锁、逻辑时序错误在 Rust 里照样合法且"安全”。
五种答案对照
| 语言 | 模型文档 | data race 后果 | 原子操作 | 特色 |
|---|---|---|---|---|
| .NET | ECMA-335 基线 + 实现事实,无完整形式化文档 | 有约束(按 ECMA),但边界模糊 | Interlocked 原子操作 + 强同步排序 | 规范弱、实现强,按基线写 |
| Java | JMM(JLS ch.17,JSR-133) | 有约束:禁止凭空值 | volatile + VarHandle 多档 | final 字段安全发布 |
| C++ | 标准完整 | 未定义行为 | 六档 memory_order | 全给你选,全归你管 |
| Go | 官方文档(2022 修订) | 有约束:单字有值保证,多字可破坏 | 只有 seq_cst 一档 | “别耍聪明” |
| Rust | 标准库文档 + Nomicon | 安全代码不可能 | seq_cst 起步 | Send/Sync 编译期防护 |
同一道题,五种解法。选择的差异背后是哲学差异:C++ 信任专家,Go 替你选好,Rust 不信任任何人(包括你)。
五、工具层:C# 的同步原语与屏障语义
现在回到写代码。C# 的同步工具不多,但每个的"屏障语义"不同——选错工具的根源几乎都是没搞清它在三层问题上解决了哪层。
lock / Monitor:互斥 + 获取/释放语义
| |
lock 一次性解决三个问题:互斥(临界区不会并发交错)、有序性(获取锁具有 acquire 效果,释放锁具有 release 效果)、可见性(上一个持锁者释放同一把锁之前的修改,对随后成功获取它的线程可见)。工程上常把锁概括成“强屏障”,但不能据此说 Enter 和 Exit 各自都是双向全屏障;真正需要依赖的是这对获取/释放语义以及由它建立的同步关系。
C# 13 / .NET 9 起新增 System.Threading.Lock 类型,lock 语句遇到它会编译成 using (x.EnterScope()),语义更干净。
Interlocked:原子操作 + 强同步排序
| |
Interlocked 为目标位置提供原子操作:Increment、Add、Exchange、CompareExchange 等是原子读-改-写(RMW),Read 提供原子的 64 位读取;这些 API 同时承担跨线程同步所需的强内存排序。具体机器指令由运行时和目标架构决定,不应把 x86 的 lock 前缀当成跨平台合同。它适合计数器、状态转换和无锁结构内部,但不能自动把多条业务操作组成一个不可分割的临界区。
volatile:半个屏障,三个"不保证"
| |
C# 的 volatile 可按读 acquire + 写 release理解。它的三个"不保证"每个都坑过人:
- 不把复合操作变成原子操作——即使字段可声明为 volatile,
counter++仍是读、改、写三步; - 不提供互斥——两个 volatile 变量的复合操作照样交错;
- 不保证 volatile 写的全局总顺序——规范原话:“不要求为 volatile 写提供所有线程可见的单一总顺序”。
还有一条硬限制:long、ulong、double、decimal 不能声明为 volatile——正因为它们的读写不保证原子,标 volatile 会给人错误的安全感。微软官方文档对 volatile 的告诫是"常被误解和误用",建议改用 Interlocked 或 lock。
64 位撕裂:一个跨语言的经典坑
C# 规范明说 long/double 读写不必原子;Interlocked.Read 的文档更直白:“64 位系统上不需要 Read(64 位读已原子);32 位系统上 64 位读除非用 Read,否则不原子。“两个 32 位写拼出一个混合值的撕裂读,在 32 位运行时是真实风险。Java JLS 对非 volatile long/double 有一样的条款——这是"字长 < 数据宽度"的普遍问题,不是哪家实现的缺陷。
线程 API 自带屏障
容易忽略的一点:Thread.Start、Task.Run/Task.Factory.StartNew、ThreadPool.QueueUserWorkItem 这些 API 隐含屏障语义——启动线程之前的所有写,新线程都能看到(“几乎每个线程 API 都必须有某种屏障语义才能正常工作”)。所以"创建后交给别的线程"这个模式不需要你额外做事;出问题的是后面反复读写共享状态的阶段。
原语对照表
| 原语 | 原子性 | 有序性/屏障 | 适用 |
|---|---|---|---|
lock | ✔(临界区整体互斥) | 获取/释放语义 | 保护复合操作,默认选择 |
Interlocked.* | ✔(对应的单次操作) | 强同步排序 | 计数、CAS、无锁结构内部 |
volatile | ✘ | acquire/release | 状态标志、发布引用 |
| 无标注字段 | 仅规范列出的类型 | 无 | 靠其他手段保护 |
六、贯穿案例:双检锁的三次重生
双检锁(Double-Checked Locking, DCL)是把前五节串起来的完美案例——它错了二十年、被修复、然后过时,每一步都对应一条内存模型规则。
版本 1:无 volatile 的 DCL——为什么错
| |
问题在 _instance = new Singleton()。这条语句实际是三步:分配内存 → 运行构造函数 → 把引用写到字段。在没有同步约束的情况下,第 2、3 步可以重排为"分配 → 发布引用 → 构造”。另一个线程在第一次检查处读到非 null,直接开始用一个还没构造完的对象。
注意这里的微妙:两个线程访问 _instance 一个读一个写、没有 happens-before——这是教科书级的 data race,而 data race 的后果按上一节的语言条款来。在 C++ 里直接 UB;在 x86 的 .NET 上"恰好"通常没事(强序掩盖);ARM 上就是真 bug。
版本 2:加 volatile 的 DCL——为什么对
| |
只改一个词。原理正好用上 volatile 的两条语义:
- 写 release:构造函数的所有写 happens-before 字段写入(之前的访存不会推迟到 volatile 写之后)——保证"发布的一定是构造完的”;
- 读 acquire:字段读取 happens-before 后续通过该引用的一切访问——保证"看到引用就能看到构造结果"。
C# 语义下这是正确的(ECMA 基线足够)。顺带一提,Java 在 JSR-133 之后 volatile 版 DCL 同样成立(官方 FAQ 明说新模型下"给实例字段加 volatile 能修复双检锁");而 Go 官方文档至今把 DCL 列为错误用法,建议改用 sync.Once——同一模式在不同语言的合同下合法性不同,这正是"内存模型是合同"的最好注脚。
版本 3:static readonly 与 Lazy<T>——为什么过时
《设计模式实战(八):单例模式》已经给过结论"双检锁过时了",现在可以说清"过时"的准确含义:不是它错了,是有更省心的。
| |
| |
Lazy<T> 默认使用 ExecutionAndPublication,由运行时负责同步初始化并向其他调用者发布结果。当前运行时源码会使用状态字段和锁协调快路径与初始化路径,但这是实现细节,不是应用代码应复制的合同。工程上直接依赖 Lazy<T> / LazyInitializer 的公开语义,比维护手写双检锁稳妥。
一个案例,三次演进,每一步都是内存模型条款的直接应用。
七、同步的代价:争用与 false sharing
锁是正确的默认选择,但同步不是免费的。代价主要不在锁指令本身(未争用时一次 lock 只有几十纳秒),而在两个隐蔽的地方。
争用(contention)。锁被别人持有时,你要么自旋烧 CPU,要么挂起——挂起意味着上下文切换(微秒级,见延迟数字篇)加缓存污染。高争用下的锁会从"互斥工具"退化成"排队点",《.NET 高并发编程全景》里线程安全集合的细粒度锁、无锁队列,都是为了让线程少排队。
False sharing(伪共享)。更隐蔽:两个逻辑上毫无关系的变量,恰好落在同一缓存行(通常 64 字节)里,硬件的一致性协议按缓存行工作,于是两个线程各写各的变量,却让对方的缓存行反复失效——性能损失像是凭空出现的。
| |
.NET 9 的性能优化文档里 Stephen Toub 给了实测例子:两个逻辑独立的 double 被错误共享后缓存未命中约 3 倍,修复方法是填充(padding)把它们隔开到不同缓存行。工程上的判断顺序:先减少共享(每次请求新建对象、thread-local 状态),真有共享再考虑对齐/填充,最后才是无锁。
八、为什么你的单元测试测不出来
数据竞争最恶心的性质:它是概率性的。竞争窗口可能只有几纳秒,测试环境的线程数、时序、JIT 行为都和生产不同——单测跑一万次全绿,线上偶发一次 mixed value。这类 bug 还具有"观察即扰动"的性质(加日志、断点、调试器都会改变时序让它消失),heisenbug 之名由此而来。
各生态的检测工具现状差距很大:
| 生态 | 工具 | 效果 |
|---|---|---|
| Go | go test -race / go run -race(基于 ThreadSanitizer) | 插桩后在实际执行覆盖到竞争时报告访问栈;不能证明未覆盖路径没有竞争 |
| C/C++ | ThreadSanitizer、Valgrind Helgrind/DRD | 前者通常由编译器插桩,后者在动态分析环境中运行;能力和开销不同 |
| Java | OpenJDK jcstress | 并发正确性压力测试框架,用大量调度结果验证允许/禁止的观察值,不是通用数据竞争检测器 |
| .NET | 无内置等价物(截至 .NET 10) | 如实说明:没有 Go 式 -race 开关 |
.NET 这栏值得展开一句:官方 SDK 没有提供 Go 式数据竞争检测开关。开源的 Microsoft Coyote 可以通过重写 IL 和受控调度反复探索并发路径,但它覆盖的是支持和实际建模到的调度点,也不是对任意原生线程竞争的穷举证明。常规工程仍要组合使用压力测试、针对性并发测试和代码评审:检查共享可变状态是否有统一同步策略、锁对象是否一致、复合操作是否由一个原子协议保护。
但比检测更有效的策略是让竞争写不出来——这就到最后一节。
九、边界与取舍:四种并发策略
消灭数据竞争只有两条路:碰同一处就建立同步,或者干脆不碰同一处。工程上展开成四种策略,按"心智负担"排序:
| 策略 | 代表工具 | 保证来源 | 适用 |
|---|---|---|---|
| 锁 | lock、Monitor | 互斥 + 获取/释放语义 | 复合操作,默认选择 |
| 原子/并发容器 | Interlocked、ConcurrentDictionary | 单操作原子性或容器公开的并发合同 | 计数、状态翻转、缓存结构 |
| 不可变 | record、ImmutableList、readonly | 值不变则无竞争 | 配置、快照、值对象 |
| 消息传递 | Channel<T>、actor、Go channel | 单线程所有权转移 | 流水线、生产者-消费者 |
两个容易问的问题:
“async/await 需要考虑线程安全吗?” 需要,但不能把请求简单等同于线程。一次 async 调用在每个连续代码片段内按程序顺序执行,跨 await 后却可能换线程;多个任务也可能在同一线程上交错。真正的风险仍是多个并发操作共享同一个可变状态——例如 ASP.NET Core singleton 服务的可变字段会被多个请求同时访问。判断时既要检查跨线程的数据竞争,也要检查即使单线程交错仍可能出现的逻辑竞态。
“什么时候可以基本不考虑共享同步?” 状态被限制在单次调用内,且不会通过字段、返回值、回调或任务逃逸给并发执行者时。局部变量这个名字本身并不构成保证:局部引用仍可能指向共享对象;lambda 捕获的局部变量通常会被提升到闭包对象中,闭包一旦被多个任务使用就成为共享状态。每次调用新建对象也只有在对象没有逃逸并被并发共享时才安全。
小结
四层回顾:
- 硬件层:缓存一致性只保证"值最终正确传播"(SWMR + 无凭空值),store buffer 和失效队列带来重排;x86 只重排 store→load,ARM 四种全来;编译器还叠加一层。硬件的合同是性能,不是你的直觉。
- 模型层:内存模型是语言替你和底层签的合同,核心是 happens-before;总纲 DRF-SC——没竞争就顺序一致。
- 语言层:同一道题五种答案——C++ 有竞争就 UB,Java/Go 约束后果,Go 只给最强档还劝你别耍聪明,Rust 让安全代码写不出竞争,.NET 规范弱实现强、按基线写。
- 工具层:
lock用互斥和获取/释放语义保护临界区,Interlocked提供单操作原子性与强同步排序,volatile只提供有限的获取/释放语义且不保护复合操作;64 位类型在规范不保证原子的场景下需要Interlocked或锁,延迟初始化优先使用Lazy<T>。
三句话带走:
- 内存安全先消灭数据竞争,完整的线程安全还要消灭依赖错误时序的逻辑竞态;
- 锁的可见性不是魔法,是"解锁 happens-before 加锁"这条合同条款;
- 在 x86 上能跑不等于正确——按语言的基线合同写,别按硬件的宽容写。
参考资料
硬件与模型:
- x86-TSO 形式化模型:Sewell et al., x86-TSO: A Rigorous and Usable Processor Memory Model for x86, CACM 2010 — https://www.cl.cam.ac.uk/~pes20/weakmemory/cacm.pdf
- Paul E. McKenney, Memory Barriers: a Hardware View for Software Hackers — http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf
- Nagarajan, Sorin, Hill, Wood, A Primer on Memory Consistency and Cache Coherence(开放获取) — https://link.springer.com/book/10.1007/978-3-031-01764-3
- Intel SDM Vol. 3A “Memory Ordering” 章节 — https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html
.NET:
- Igor Ostrovsky, C# Memory Model in Theory and Practice, Part 1(2012) — https://learn.microsoft.com/en-us/archive/msdn-magazine/2012/december/csharp-the-csharp-memory-model-in-theory-and-practice
- Igor Ostrovsky, C# Memory Model in Theory and Practice, Part 2(2013) — https://learn.microsoft.com/en-us/archive/msdn-magazine/2013/january/csharp-the-csharp-memory-model-in-theory-and-practice-part-2
- Joe Duffy, CLR 2.0 Memory Model(实现模型规则) — https://joeduffyblog.com/2007/11/10/clr-20-memory-model/
- Vance Morrison, Understanding Low-Lock Techniques(三层模型) — https://learn.microsoft.com/en-us/archive/msdn-magazine/2005/october/understanding-low-lock-techniques-in-multithreaded-apps
- C# 规范 §9.6(原子性) — https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/variables
- C# 规范 §15.5.4(volatile 字段) — https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/classes
volatile关键字文档(告诫与 64 位限制) — https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/volatileInterlocked.Read(32 位平台上 64 位读不原子) — https://learn.microsoft.com/en-us/dotnet/api/system.threading.interlocked.readLazy<T>(ExecutionAndPublication 与实现源码) — https://learn.microsoft.com/en-us/dotnet/api/system.lazy-1ConcurrentDictionary.GetOrAdd(工厂可能执行多次) — https://learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent.concurrentdictionary-2.getoradd- Microsoft Coyote(受控并发测试及适用边界) — https://github.com/microsoft/coyote
- Stephen Toub, Performance Improvements in .NET 9(false sharing 实测) — https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-9/
其他语言:
- Java:JLS ch.17(内存模型) — https://docs.oracle.com/javase/specs/jls/se21/html/jls-17.html ;JSR-133 FAQ — https://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html
- OpenJDK jcstress(并发正确性压力测试框架) — https://openjdk.org/projects/code-tools/jcstress/
- C++:
std::memory_order— https://en.cppreference.com/w/cpp/atomic/memory_order ;标准 [intro.races] — https://eel.is/c++draft/intro.races - Go 内存模型(2022 修订) — https://go.dev/ref/mem
- Rust:Send 与 Sync — https://doc.rust-lang.org/nomicon/send-and-sync.html ;数据竞争 — https://doc.rust-lang.org/nomicon/races.html