weak_ptr::lock() 要在强引用还没归零时把计数加 1。几个核同时加减同一个计数,最常见的写法是 CAS 循环:平均很快,可某个线程运气差时要重试很多次,重试多少次没有上界。对 P99.9 敏感的路径上,这种“平均快、偶尔很慢”就是问题。wait-free 换了个思路:每个线程走的步数都有上界,别的线程再快、再慢、甚至停住,都拖不住它。下面用一个归零以后不许复活的计数器,看这件事怎么做到,代价又是什么。
1. wait-free:每个线程的步数都有上界
1.1 进度保证:锁、lock-free、wait-free 差在哪
#16 的自旋锁有一个躲不开的问题:持锁的线程被换下 CPU,等锁的线程全部停住。它们能不能做完,取决于另一个线程什么时候回来。按“别的线程出了事(变慢、被换下、甚至死掉),我还能不能做完”,进度保证分四档:
- 阻塞式(blocking):别人停住,我可能永远做不完。所有的锁都是。
- obstruction-free:只有别人都停下时,我一个人能做完;大家一起动时可能谁都做不完。实际里很少提。
- lock-free:任何时候总有某个线程能在有限步内做完,整体一直在前进;但具体某个线程可能一直输。CAS 重试循环就是。
- wait-free:每个线程都在有限步内做完,不管别人快慢、停不停。
lock-free 和 wait-free 差的就是 #16 1.7 说的饿死:lock-free 允许某个线程一直重试,wait-free 不允许。
“有限步”不等于“有限时间”。wait-free 只保证我自己要走的步数有上界,不保证这几步不被操作系统打断、不缺页、不碰上缓存不命中。所以要让耗时可预测,还要绑核隔离(#1)、mlockall、数据独占缓存行这些层一起上。
1.2 sticky counter:归零以后不许复活的计数器
一个“粘”在零上的计数器,三个操作:
increment_if_not_zero():还没归零就加 1,返回true;已经归零就拒绝,返回false。decrement():减 1。恰好把它减到零的那一次返回true,其余都返回false。read():读当前值。
它就是引用计数:weak_ptr::lock() 要做的正是“强引用还没归零才能加 1”,decrement() 返回 true 的那个线程负责销毁对象。所以整个设计要守住一条:恰好一个 decrement() 返回 true。多一个是重复销毁,少一个是泄漏。
1.3 lock-free 版:CAS 循环为什么没有上界
先看常见的 lock-free 写法:
struct LockFreeStickyCounter {
std::atomic<uint64_t> n{1};
bool increment_if_not_zero() {
uint64_t cur = n.load(std::memory_order_relaxed);
while (cur != 0) {
if (n.compare_exchange_weak(cur, cur + 1, std::memory_order_relaxed))
return true; // 失败时 cur 被改成最新值,再判断一次
}
return false;
}
bool decrement() {
if (n.fetch_sub(1, std::memory_order_release) != 1) return false;
std::atomic_thread_fence(std::memory_order_acquire); // 要销毁的一方才 acquire
return true;
}
uint64_t read() { return n.load(std::memory_order_relaxed); }
};
increment_if_not_zero() 要“没归零才加”,判断和加必须是一个原子操作,所以用 #16 1.3 的 CAS 循环:读出当前值,不是 0 就试着把它从 cur 改成 cur + 1。CAS 失败,说明在“读”和“改”之间有别的线程改了 n;那个线程的操作成功了,所以整体在前进,这是 lock-free。可这个线程自己要重试,别的线程一直在改,它就可能一直失败,重试次数没有上界,所以不是 wait-free。
实际里争得不凶时,一次 CAS 就成功,这个版本很轻。要防的是尾部:很多核一起加减同一个计数时,个别线程会重试很多次,对 P99.9 敏感的地方,这就是风险。
1.4 wait-free 版:先做,再看旧值
去掉循环的办法是:不先判断再改,而是无条件地改,再看改之前是什么。要做到这一点,计数里得带着“已经归零了没有”这个状态,所以借两个高位当标志:
struct WaitFreeStickyCounter {
static constexpr uint64_t is_zero = 1ull << 63; // 已经钉成零
static constexpr uint64_t helped = 1ull << 62; // 是读的一方帮忙钉的
std::atomic<uint64_t> counter{1}; // 低 62 位是计数
bool increment_if_not_zero() {
// 先加,再看加之前有没有 is_zero:没有才算加上了
return (counter.fetch_add(1, std::memory_order_relaxed) & is_zero) == 0;
}
bool decrement() {
if (counter.fetch_sub(1, std::memory_order_release) == 1) { // ① 是我把它减到了 0
uint64_t expect = 0;
if (counter.compare_exchange_strong(expect, is_zero, std::memory_order_relaxed)) {
std::atomic_thread_fence(std::memory_order_acquire); // 认领成功才 acquire
return true; // ② 我自己钉成零
}
if ((expect & helped) &&
(counter.exchange(is_zero, std::memory_order_relaxed) & helped)) {
std::atomic_thread_fence(std::memory_order_acquire); // 认领成功才 acquire
return true; // ③ 读的一方替我钉了,我来认领
}
}
return false; // 没减到 0,或者被别人加回去了
}
uint64_t read() {
uint64_t val = counter.load(std::memory_order_relaxed);
if (val == 0 && counter.compare_exchange_strong(val, is_zero | helped,
std::memory_order_relaxed))
return 0; // 读到窗口里的 0:不等,帮忙钉成零
return (val & is_zero) ? 0 : (val & ~(is_zero | helped));
}
};
is_zero 一旦被设上,这个计数就算永久归零。“没归零才加”于是变成:先 fetch_add(1),再看返回的旧值里有没有 is_zero。没有,加上了;有,就当没加。已经归零时这次加并没有撤回,低位多了 1,但 is_zero 还在,read() 和以后的 increment 只认这一位,多出来的计数不影响任何结果。fetch_add 在 x86 上是一条 lock xadd,不会失败,也就没有循环。
decrement() 用 fetch_sub 减 1。旧值是 1,说明是我把它减到了 0,但这时内存里只是一个普通的 0,还没有 is_zero,要再用一次 CAS 把它从 0 改成 is_zero,才算钉死。每个操作最多一次 fetch、一次 CAS、一次 exchange,步数有固定上界,没有任何循环。
1.5 窗口:内存里的 0 还不算归零
decrement() 的 fetch_sub 把值减成 0,到 CAS 把它钉成 is_zero,中间隔着一段:内存里是 0,逻辑上还没归零。别的线程这时进来,会碰上三种情况,整个算法就是围着这段窗口设计的:
| 窗口里发生了什么 | 结果 |
|---|---|
| 没人打扰 | decrement() 自己的 CAS 把 0 改成 is_zero,返回 true |
increment 进来,把 0 加回 1(旧值里没有 is_zero,所以算加上了) | decrement() 的 CAS 失败,读回来的值里也没有 helped,返回 false:当作这次减发生在那次加之后,没减到零 |
read 进来,读到 0 | read 用 CAS 把 0 改成 is_zero | helped,返回 0;decrement() 的 CAS 失败,但看到 helped,去认领 true |
第三种情况叫 helping(协作)。read() 读到 0,知道有个 decrement() 做了一半。它要是等那个线程把零钉好再返回,那个线程被换下 CPU,它就得一直等,又成了阻塞式。所以 read() 不等,替它把剩下的一步做完,自己立刻返回 0,再留下 helped 告诉那个线程“零是我替你钉的,true 还是你的”。
1.6 exchange:认领只发一张
decrement() 的第 ③ 步,看到 helped 还不能直接返回 true,因为窗口里可能不止一个 decrement()。例如:T1 把 1 减到 0;T2 的 increment 把它加回 1,T2 又马上 decrement,把它减回 0。这时 T1、T2 都认为“是我减到 0 的”。read 进来只会钉一次零、留一个 helped,T1 和 T2 的 CAS 都失败,读回来的值里都有 helped。看到就返回 true 的话,两个线程都去销毁对象。
exchange(is_zero) 是原子地“取走旧值、写入新值”:谁先执行,谁拿到的旧值里带 helped,返回 true;后执行的拿到的旧值已经被换成了 is_zero,不带 helped,返回 false。它像一张只发一次的票,谁先取走谁认领,保证恰好一个 true。
1.7 线性一致性:用排先后代替重试
线性一致性(linearizability)说的是:每个操作从调用到返回占一段时间,要能在这段时间里各挑一个点,当作它一次做完的时刻;所有操作按这些点排成先后,在单线程里依次执行,每个操作的返回值都要和实际一样。点必须落在自己那段时间里,所以先结束的操作一定排在后开始的操作前面。
lock-free 版遇到冲突就重试,直到自己的操作能干净地插进去。wait-free 版不重试,而是给冲突一个解释。下图里,T1 的 decrement() 用 fetch_sub 把 1 减到 0,还没来得及钉零,T2 的 increment() 就把 0 加回了 1,T1 的 CAS 因此失败。把 T1 的点选在 CAS 那一刻(还在 T1 自己那段时间里),它就排在 T2 的加后面:1 加成 2,再减成 1,没减到零,返回 false 自洽。CAS 失败不再意味着“再来一次”,而是“我排在它后面”,所以不需要循环。
这个解释有边界:increment、decrement 的返回值和“是不是零”都能这样排通,read() 返回的非零数值不一定能。最小的例子(初始计数 1):
- T1
decrement:fetch_sub,1 → 0。 - T2
increment:fetch_add,0 → 1,旧值没有is_zero,加上了。 - T3
read:读到 1,返回 1。 - T2
decrement:fetch_sub,1 → 0。 - T1 的 CAS 把 0 改成
is_zero,返回true。 - T2 的 CAS 失败,值里没有
helped,返回false。
要排出一个先后,得同时对上返回值和时间。先看返回值:T1 返回 true,说明它是最后一个把计数减到 0 的,要排在 T2 的减后面;T2 的加成功了,要排在计数归零之前。所以三次加减只有一种排法:T2 加、T2 减、T1 减。再看时间:T2 的加在 read 开始之前就结束了,read 在 T2 的减开始之前就结束了,所以 read 只能夹在 T2 的加和 T2 的减之间。那一刻计数是 2,read 返回的却是内存里的 1,怎么排都对不上。是不是零是可靠的,具体的非零数值只能当近似值,和 shared_ptr::use_count() 在多线程下只是近似值一个道理。
1.8 代价和前提:有上界不等于更快
wait-free 买到的是上界,不是速度。 没人争时,lock-free 版加一次是一次 CAS,减一次是一次 fetch_sub;wait-free 版减到零时要多一次 CAS,可能再多一次 exchange,代码也难写对得多。所以只有读多写少、对 P99、P99.9 敏感、不能被别人拖住的底层场景,比如引用计数,才值得;写得密、不在乎尾部时,lock-free 或自旋锁可能更好。
它也不管每一步多贵。 8 个核一起高频 fetch_add 同一个计数,每一步都是对同一条行的读改写,行要在核之间搬(#16 1.4)。步数有上界,每一步的时间却随争用变长。wait-free 去掉的是重试,不是缓存行争用。
“没有循环”还要靠硬件。 x86 上 fetch_add、exchange、compare_exchange_strong 各是一条带 lock 的指令,不会失败重试。ARM 在 ARMv8.1 的 LSE(大型系统扩展)之前没有这些单指令原子操作,编译器会把它们编成“独占读、独占写、失败就重来”的循环,这时严格说只是 lock-free。
内存序:计数本身对不对(恰好一个 true)只靠 RMW 的原子性,同一个字上的 RMW 自然排成一条队,和内存序无关。内存序保护的是计数以外的东西,也就是对象本身,代码里只有两处要求顺序:
- 减 1 用 release:我放手之前对对象的读写,要先于这次减 1 被别人看到,否则可能对象已经销毁了,我的写才落地。
- 认领成功(返回
true)以后加一道 acquire 栅栏:析构和delete不能提到它前面,而且能看到所有人放手之前的读写。栅栏只在拿到true的路径上,别的线程不付这份钱。
其余的加 1、钉零、read() 都用 relaxed:它们做完以后都不碰对象,没有东西要传给别人。Boost 和 libc++ 的 shared_ptr 加引用也用 relaxed。
有两处细节。**acquire 要跟在决定认领的那次读后面,不能紧跟 fetch_sub。**窗口里计数可能被别人加回去、用一下对象、再减回 0:1.7 那个例子里,T1 在第 5 步认领,它要看到 T2 用对象时写的东西,把它和 T2 第 4 步那次 release 减 1 连起来的是 CAS 读到的值,不是 fetch_sub 读到的 1。钉零的 CAS 和 exchange 用 relaxed,也不会断开这条线:一次 release 写之后,同一个变量上的 RMW 不论用什么内存序,都接在它的 release sequence(释放序列)上。这个计数的所有修改都是 RMW,所以认领者读到最后的值,就和之前每一次 release 减 1 都同步了。
在 x86 上这些都不多花指令:带 lock 的 RMW 本身就是全屏障,acquire 栅栏只约束编译器,全写 acq_rel 和写成最弱的内存序生成的是同一份机器码。差别在 ARM 上:ldaddal 变成 ldaddl,只有认领者多一条 dmb ishld。
1.9 等不到时怎么办:自旋、让出、睡,还是失败返回
一个操作做不下去(锁被拿着、队列满了),线程有四种选择,#16 讲了前三种:
- 自旋:原地转着等。线程绑在自己的核上、要等的时间比睡一次(2–10 µs)短时最快。
- 让出(
sched_yield):把 CPU 让给同核的别的线程。绑核隔离的核上本来没有别的线程,等于空转再加一次系统调用,基本不用。 - 睡(futex、条件变量):要等得久、线程没绑核、或者要省 CPU 时用,代价是醒来要几 µs。
- 失败返回:不等,告诉调用者“现在做不了”,由调用者决定丢掉、记一笔、换条路还是过会儿再试。
wait-free 的接口走的是第四条。#4 的 SPSC 环就是例子:try_claim() 满了返回 nullptr,几次读写、没有循环,是 wait-free;claim() 满了就在循环里空转,等消费者腾出位置,消费者不动它就永远返回不了,是阻塞式。环是不是 wait-free,看的是满了以后的接口,不是环本身。 多个生产者用 CAS 抢槽位的环,抢输了要重试,是 lock-free。交易系统的热路径通常选失败返回:行情来不及处理就丢掉旧的、记一个计数,不让热路径被别人拖住。