并发数据结构 · 从 CAS 到无锁队列与内存回收
单线程的数据结构有唯一正确答案:给定一串操作,结果是确定的。多线程一进来这个前提就没了——两个线程同时 push,谁先谁后不由代码决定。于是正确性判据要换:linearizability 要求每个操作看起来在它的调用与返回之间的某个瞬间原子生效,且这些瞬间排成的顺序既能被顺序规格解释、又与实际的重叠区间相容。
效率判据也要换。渐近复杂度对并发结构几乎不起作用——一把大锁护住的栈与 Treiber 栈都是 O(1) push,差别在进展保证:wait-free 保证每个线程在有界步数内完成,lock-free 只保证总有一个线程在推进,而上锁的版本什么都不保证,持锁者一停全场停摆。这三档与吞吐无关,lock-free 不等于更快。
本系列的实现约定是一条硬约束:JavaScript 单线程,所以不假装做真并发。每个「线程」是一串带标号的原子步骤,调度器决定下一步走谁,任意交错都可复现、可枚举、可测试。于是「小规模下穷举全部交错」成了这里最有力的手法:Treiber 栈两线程三操作共 168843 条交错全部可线性化,而普通 store 链接的坏队列在 20 条交错里有 18 条丢元素。
判据与原语:怎么算对,怎么算有进展
先把两把尺子立起来:linearizability 判对错,进展保证的三级判效率。再看唯一的原语 compare-and-swap 与它自带的坑——指针值回到原样但中间发生过变化,CAS 察觉不到。
正确性判据与进展保证
并发执行没有唯一正确答案,正确性判据换成 linearizability:每个操作看起来在调用与返回之间的某个瞬间原子生效。效率判据换成进展保证的三级,而这三级说的不是吞吐。
CAS 与 ABA
无锁结构只靠一条原语:compare-and-swap 把「值仍是我读到的那个」与写入绑成一步。它的盲区是 ABA——指针值回到原样但中间发生过变化,CAS 察觉不到。
交错模拟器的适用边界
判据与原语 · 延伸阅读
- Herlihy & Wing · Linearizability: A Correctness Condition for Concurrent Objects (1990) cs.brown.edu linearizability 的原始论文:定义、与 serializability 的区别,以及「局部性」(各对象各自可线性化即整体可线性化)这条关键性质。
- Herlihy · Wait-Free Synchronization (1991) dl.acm.org consensus number 与同步原语的层级:CAS 的 consensus number 是无穷,所以它能实现任何对象的 wait-free 版本,而 test-and-set 只到 2。
- std::atomic::compare_exchange — cppreference cppreference.com weak 与 strong 两个版本的差别(前者允许伪失败)、两个 memory order 参数的含义,以及失败时 expected 会被写回。
- ABA problem — Wikipedia en.wikipedia.org ABA 的成因与四类对策:tagged pointer、双宽 CAS、LL/SC 指令、以及延迟回收。
两个经典结构与它们共同的难题
Michael-Scott 队列用 head / tail 两个指针各自 CAS 推进,靠「任何线程都可以替落后的 tail 推一把」保住 lock-free。Treiber 栈比它简单得多,却把真正的难题暴露得更彻底:一个线程正要读的节点可能已被另一个线程释放。
Michael-Scott 队列
链表队列的 head 与 tail 各用 CAS 推进,中间必然出现「节点已链上、tail 还没跟上」的状态。任何线程都可以替落后的 tail 推一把,这条帮助机制是 lock-free 成立的前提。
Treiber 栈与内存回收
Treiber 栈只有一个 top 指针,却把无锁编程最难的一关暴露得最彻底:一个线程正要读的节点可能已被另一个线程释放。引用计数、hazard pointer 与 epoch-based reclamation 是三条出路,代价各不相同。
带 GC 的语言藏起了最难的一半
无锁队列、栈与内存回收 · 延伸阅读
- Michael & Scott · Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms (1996) cs.rochester.edu MS 队列的伪代码原文与作者主页上的完整版本。tail 落后一格时的帮助推进、dummy 节点为何不可省,都在这页伪代码里。
- Treiber · Systems Programming: Coping with Parallelism (1986) patents.google.com Treiber 栈的出处(IBM 研究报告 RJ 5118)。算法本身只有一个 top 指针加一个 CAS 循环,简单到常被当作无锁编程的第一个例子。
- Michael · Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects (2004) erdani.org hazard pointer 的完整方案:每线程声明正在读的指针,回收前扫描全部声明。论文给出了「延迟回收的节点数有界」这条关键性质的证明。
- Fraser · Practical Lock-Freedom (2004) cl.cam.ac.uk 博士论文,第 5 章是 epoch-based reclamation 的出处。与 hazard pointer 的取舍:读路径几乎零开销,代价是一个滞留线程能把回收完全卡住。
- What is RCU? — Linux kernel documentation docs.kernel.org RCU 与 epoch-based reclamation 同族:读侧无锁无原子操作,写侧等所有读者过完一个 grace period 再回收。内核里最广泛使用的延迟回收方案。
规模:把争用摊开,以及重排序
线程一多,正确的结构也会因为大家挤在同一个 cell 上而废掉。分片把一把大锁换成 N 把小锁、再换成 per-bucket 的 CAS。最后一页回到最底层:relaxed 与 acquire-release 的差别,以及为什么 SPSC 环形队列只需要后者。
争用、分片与内存模型 · 延伸阅读
-
openjdk · Striped64.java
github.com
LongAdder与LongAccumulator的共同基类:cell 数组按需扩张到不超过 CPU 数、线程 probe 撞车后重算、以及@Contended注解带来的 padding。 -
java.util.concurrent.ConcurrentHashMap — Java 8 API
docs.oracle.com
Java 8 版本的文档。Java 7 的 segment 分段锁在这一版被换成 per-bucket 的 CAS 加 synchronized,
concurrencyLevel参数随之只保留兼容语义。 - False sharing 与 cache line padding usenix.org 两个线程各写各的变量却因为落在同一条 cache line 上而互相作废。缓解手段是 padding,代价是每个 cell 独占一整条 line 的空间。
-
C/C++11 mappings to processors — Peter Sewell
cl.cam.ac.uk
各 memory order 在 x86、ARM、POWER 上分别编译成什么指令。x86 上 acquire / release 载入存储是普通指令,seq_cst 存储要一条
mfence或xchg。 - Adve & Gharachorloo · Shared Memory Consistency Models: A Tutorial (1995) hpl.hp.com 内存模型的经典教程:为什么硬件不给顺序一致性、各种放松模型放松了哪一条,以及 litmus test 这套讲法的来历。