“共识层级解释了读改写原语之间并非只有性能差异。它为无锁数据结构给出选择下界,也说明为什么从弱原语实现强对象时,算法常不得不降低进展保证、限制参与者数量或引入更强硬件操作。”
形式陈述
一个原子状态转移
对保存状态
其中
当多个调用并发发生时,线性化要求每个完成的调用,都能解释为在其调用与返回之间的某个瞬间执行了这个状态转移,并与其他调用形成符合规格的顺序。实现可能依赖专用指令、缓存一致性或重试;“原子”描述可观察语义,不要求硬件只用一个时钟周期。[1]
| 原语 | 新状态 | 常见返回值 |
|---|---|---|
exchange(v) |
旧值 | |
fetch_add(d) |
旧值 |
旧值 |
test_and_set() |
true |
旧布尔值 |
compare_exchange(e, v) |
旧值等于 |
成功标志,并可回传实际读到的值 |
对于最初的计数器,两个 fetch_add(1) 必须表现为某个先后顺序:一个读改写把
直觉
两次正确的读取,也可能丢掉一次更新
共享计数器初值为
| 时刻 | 线程 A | 线程 B | 共享值 |
|---|---|---|---|
| 1 | 读到 |
||
| 2 | 读到 |
||
| 3 | 写入 |
||
| 4 | 写入 |
两次增加最终只留下
例子与边界
CAS 怎样把计算与提交分开
compare-and-swap(CAS)的思路是:“只有状态仍等于我据以计算的旧值,才提交新值。”对共享值做一般的变换
例如,共享值从 CAS(10,20) 失败,所以不会用过期结果覆盖 B 的更新。A 改用
失败重试意味着本地计算可能执行多次。因而不能把不可重复的外部副作用随意放进计算
语言接口还可能区分弱 CAS 与强 CAS。以 C++ 的 compare_exchange_weak 为例,它允许比较值相同却报告失败,即伪失败;失败时还会用实际读值更新 expected。循环必须按该语义组织,不能把每次弱 CAS 失败都解释成另一个线程已经成功修改了对象。C++ 还把成功的 compare-exchange 归为 RMW,失败的比较归为原子读;两条路径的内存序约束并不相同。[2]
ABA 与进度保证
CAS 比较的是当前值,不是完整修改历史。如果一个位置先为
给状态附加版本号,可以把“值相同但版本不同”区分开;还需要处理版本回绕。内存回收协议则控制节点何时能释放和复用,解决悬空引用以及部分地址复用问题,但不自动消除所有逻辑上的 ABA。
进度也不能从“用了 CAS”直接推出。无锁要求系统整体持续有操作完成,无等待要求每个操作都在有限个自身步骤内完成。某个线程可能在 CAS 循环中反复失败;阻塞分配器也可能破坏整体进度保证。要证明进度,必须分析整个实现及所依赖原语,而不是只看最后一条指令。
Herlihy 的共识层级还区分了原语的理论能力:在标准异步共享内存模型下,普通读写寄存器的共识数为
推论与应用
原子更新与内存可见性各自解决什么
RMW 可以防止对同一原子对象的丢失更新,但其他内存位置的读写顺序仍由内存模型规定。以 C++ 为例,memory_order_relaxed 的 fetch_add 依然是原子 RMW,却不凭这次操作建立一般的跨线程数据发布关系。[2]
例如,一个线程先填写普通数据,再设置“已准备好”标志;另一个线程观察标志后读取数据。除了标志本身原子,还需要恰当的 release/acquire 同步,使前面的数据写入对后面的读取建立所需的顺序。把一个 relaxed 计数器放到旁边,并不能替代这项关系。
同样,对两个不同变量各做一次原子操作,也不自动形成“同时修改两个变量”的事务。单对象原子性、跨对象不变量与内存顺序,是三份需要分别说明的保证。
参考资料
[1] Maurice Herlihy,Wait-Free Synchronization,ACM TOPLAS 13(1),1991,124–149,§3:读改写操作与共识层级。
[2] C++ working draft,Order and consistency 与 Atomic types:原子 RMW、内存序和 compare-exchange 接口。