把 lock-free 理解成「更快的锁」会在服务端立刻失效。无锁结构买的是某种进度保证和某种争用下的可扩展性,代价是 ABA、内存序、重试循环,以及几乎无法用类型证明的正确性。错误的 lock-free 比一把粗 Mutex 更慢,也更容易在负载尖峰以数据损坏的方式失败。共享状态的默认假设仍是:先不要共享;非共享不可时用互斥;测出争用再拆锁;拆不动再考虑原子和无锁队列。

这篇文章只讲服务进程里的争用:临界区怎么变短、异步代码为什么不能拿着 std 锁去 await、读多写少时 RwLock 会不会更差、原子能覆盖哪类问题。读完你应该能独立回答三个问题:这段共享状态能不能改成每个任务一份、持锁跨 await 会卡住谁、profiler 上的锁等待高是该换 lock-free 还是该先分片。

1. 争用是缓存行和临界区的病,不是原语名字的病

多核上两颗 CPU 写同一缓存行,行会在核之间来回作废。一把全局 Mutex<HashMap> 保护的不只是逻辑互斥,还有这行缓存的所有权来回飞。lock-free 的 AtomicU64 如果大家都在打同一个计数器,同样会打满这条行。换原语不换布局,争用还在。

所以第一步不是选 Mutex 还是 CAS,是问:必须共享吗? 每条连接的状态放在连接任务里,汇总用定时器或带界的 channel 把计数刷到全局,全局几乎不在热路径上被写。会话表按 id 分片,十六把锁比一把锁的缓存行少十六倍冲突(在访问均匀时)。假共享:两个无关原子挤在同一行,也会一起飞——CachePadded 或对齐填充是布局问题。

Amdahl 定律在这里很具体:临界区占 5% 的 CPU 时间,把这部分加速十倍,整体几乎不动。先用 profiler 看锁等待是否真的在前几名。不在,去做分配、拷贝、下游延迟。在,再看等待的是哪一把、临界区里在干什么。临界区里有 IO、有分配、有解析,先把这些挪出去,往往锁就不热了。

把 profiler 的看法当成门禁:没有争用证据的 lock-free PR,默认不合并。证据至少包括:锁等待占总 CPU 或占 p99 的比例、临界区长度、核数增加时 QPS 是否已经不再线性。缺这三项,所谓「无锁会更快」是猜测。猜测在低负载下通常成立(未争用的 CAS 也很快),在高争用下经常反过来变成重试风暴。用生产采样或压测复现,再换原语。

2. 单所有权仍然是最快的并发

任务 A 拥有 Session,任务 B 不碰。要让 B 做事,把消息 move 过去。没有锁,没有原子,缓存行只在这条核上待着。这就是 actor / 每连接一任务的模型。它的上限是:有些状态天生是全局的(连接表、限流器、路由表),有些操作要跨会话(广播、踢人)。

全局读多写少:Arc<Snapshot>,写的人造一份新快照再原子换指针(arc-swap / RwLock 写很少)。全局写多:分片,或接受这不是这条进程该扛的热点,拆服务。

struct Worker {
    local_hits: u64,
    flush: tokio::sync::mpsc::Sender<u64>,
}

impl Worker {
    fn hit(&mut self) {
        self.local_hits += 1;
        if self.local_hits >= 1024 {
            let n = self.local_hits;
            self.local_hits = 0;
            let _ = self.flush.try_send(n);
        }
    }
}

热路径只打任务私有的 u64。满了才向全局刷。丢几次刷(try_send 失败)对指标可接受,对账本不可接受——账本就不要走这条。模式的要点:把争用频率从「每请求」降到「每 N 请求」。

3. Mutex:短临界区里最便宜的正确性

std::sync::Mutex / parking_lot::Mutex 保证:同一时刻一个写者(对 Mutex 来说就是唯一持有者)。实现上,未争用时是一次原子,争用时把线程挂起。parking_lot 通常更瘦、更快、没有毒化;std 的毒化告诉你另一个线程已经在临界区里 panic——这是信息,不是麻烦。服务里中毒后继续 into_inner 等于在损坏的状态上跑。优先让进程或至少让这个会话死掉,并修那个 panic。

未争用路径的成本决定你能不能把锁放进每请求热路径。std::sync::Mutex 未争用在 Linux 上通常是一次原子;一旦争用,就要进内核。热到需要每请求加锁的状态,先问能不能改成任务本地再汇总。能,锁从热路径消失,比换一把更快的锁有效。不能,再看临界区里是不是只改几个字——是,原子或分片;不是,先把临界区剪短。 parking_lot 没有毒化,临界区 panic 之后其它线程继续看到半更新。因此 parking_lot 的临界区更不能跑可能 panic 的代码:不要 vec[index] 不检查、不要 unwrap 一个 Option。std Mutex 至少能把「这里崩过」传给后来者。选 parking_lot 是为了瘦和快,就要用更严的临界区纪律来补丢失的毒化信号。 异步运行时里「等锁」和「等 IO」在指标上要能分开。一把锁上等了 50ms,和下游 RTT 50ms,优化方向完全相反。没有分开的直方图,你会把锁问题当成下游问题去扩容对端。TimedMutex 或 tracing 的 lock_wait span 是为了这个分叉。没有它,争用是 anecdata。 跨分片的操作(要从 shard 3 挪到 shard 7)会引入锁顺序。规定小号先拿,永远不要反过来。操作失败要能回滚已经改过的那一 shard,否则你会在两个 shard 里看到同一条或零条。这已经接近事务。能避免跨分片就避免:让 key 的哈希稳定,迁移用离线重建快照替换,而不是在线逐条搬。

临界区规则:

  • 只碰内存。计算哈希、拼字符串、等 IO,都在锁外。
  • 拿锁、改、放,函数最好短到一眼看见。
  • 不要在锁里调用户回调——回调会再抢另一把锁。
  • 多把锁有固定顺序,写进注释,最好用类型(先 shards[i] 再 global,永远不要反过来)。

异步代码用 std::sync::Mutex 是合法的,只要守卫不跨 await。短短地取一个快照 let n = *lock.lock(); 然后 await,完全正确,而且比 tokio::sync::Mutex 便宜——后者为了能在 await 时让出,实现更重,未争用成本也更高。tokio::sync::Mutex 的正当理由只有:临界区必须跨 await(极少是好设计)或等待者必须是任务而非线程。先改设计把 await 拿出去。

async fn add_session(map: &std::sync::Mutex<HashMap<u64, Arc<Sess>>>, s: Arc<Sess>) {
    {
        let mut g = map.lock().unwrap();
        g.insert(s.id, s.clone());
    } // 守卫在这里 drop
    advertise(&s).await;
}

花括号是产品。没有它,advertise().await 会把守卫带过 await。多线程 runtime 上 std 守卫 !Send,通常直接编不过;编过了(parking_lot 某些配置、或 tokio mutex)就是把互斥范围拉成一次网络 RTT。

4. RwLock:读并行的条件很窄

RwLock 允许并行读、独占写。读多写极少(配置、路由表、几乎不变的元数据)时,它比 Mutex 能让更多核同时读。写一多,读者要等写、写要等读者,读者之间的锁缓存行同样会撞。写多时 RwLock 比 Mutex 更差:锁本身更复杂,还没有互斥那么干脆的进度。

饥饿:写者可能一直等持续到来的读者。有的实现写优先,有的不保证。不要在「读侧每请求都拿读锁」的路径上假设写还能在 1ms 内完成。更好的读多模型常常是 Arc<T> 快照 + 原子替换:读者只 load 一个指针,不进锁;写者分配新 T,swap。读者可能看到旧快照——这是语义选择,配置更新晚一毫秒通常可以,账户余额不行。

std::sync::RwLock 在写者上也可能毒化。parking_lot 的 RwLock 没有毒化、实现更快,语义要自己读文档(是否写优先)。异步 RwLock 同样:不要持读守卫跨 await,否则写者要等一个 RTT,读守卫把「读并行」变成「读着的人去睡觉」。

5. 原子:只覆盖你真正理解的那几个操作

计数、开关、epoch、状态机的小枚举,用 AtomicU64 / AtomicBool / AtomicU8。成功路径是 fetch_add、load、store、compare_exchange。失败是你以为两个原子一起构成了事务——它们没有。hits 和 bytes 两个原子分别加,读取的人会看到一个加了、另一个还没加。需要一起被看到,就回到锁或单任务所有权。

内存序:

  • Relaxed:这个位置原子,不管别人。计数器常用。
  • Acquire / Release:配对发布。写者 Release store 指针,读者 Acquire load 后能看见指针所指对象的初始化。
  • AcqRel:CAS 同时有两种角色。
  • SeqCst:全序,最贵,也最好推理。先 SeqCst 换正确,热了再收。

不要在发布「配置指针」时用 Relaxed。读者可能看到新指针,却看不到指针指向的字段已经被写完。这是经典的「偶发读到未初始化」。

ABA:CAS 期望值是 A,中间别人改成 B 再改回 A,你的 CAS 成功,但世界已经不是你以为的那个 A。节点复用的无锁链表会踩这个。修复是指针打 tag、epoch 回收(crossbeam)、或干脆不要复用节点。能被 ABA 碰到的结构,不要自己写,用经过验证的 crate,并承认你买的是依赖而不是「几行 CAS」。

6. lock-free 队列和「进度」到底保证什么

lock-free 的定义是:系统中至少有一个线程能在有限步内完成操作,即使其它线程被调度走。它不保证无等待,不保证更快,不保证简单。某个线程可以一直 CAS 失败(活锁边缘),只要另一些在前进。wait-free 更强,服务端几乎不需要自己提供 wait-free 队列。

无锁 MPSC/SPSC 队列在「单生产者单消费者」或「多生产者单消费者」下有成熟实现(crossbeam、tokio::sync::mpsc 内部)。它们把锁争用换成了原子和缓冲。有界无锁队列满了仍然要有策略:等待(这就有了锁或 park)、丢、扩容(扩容往往要停或锁)。无锁没有把背压变没,只是把互斥换了形态。

自己写无锁双向链表、无锁哈希表,在生产服务里几乎总是错的。正确性依赖的场景组合(抢占、弱内存模型的 ARM、重试、回收)测不完。论文实现和工业实现之间隔着十年的 bug。需要并发哈希:用 DashMap、分片 Mutex<HashMap>、或 flurry/papaya 这类有人维护的。然后仍然要测,因为它们在写多时可能并不比分片 Mutex 好。

7. 分片、arc-swap、seqlock:中间地带

分片锁:Vec<Mutex<HashMap<..>>> 按 key 哈希取模。争用从一把变成 N 把。N 太大,内存和缓存都差;N 太小,热点 key 仍挤在同一把。热点 key(某个大客户 id)分片救不了——那要把热点再拆,或接受单 key 的串行。

arc-swap:原子替换 Arc<T>。读者 load 便宜。写者构造完整新对象。适合整表替换、配置、路由。不适合每秒万次小更新——每次写都分配整份快照。

seqlock:写者加版本,读者读两遍版本看有没有被打断。读无锁、写要短且不能在写时让读者看到撕裂的中间态。payload 必须能容忍读到垃圾再重试(不能是指针,除非你能证明指针始终有效)。适合时钟、计数快照,不适合拥有堆对象的结构。

RCU 风格(epoch-based reclamation)让读者无锁,写者等所有读者过完 epoch 再释放旧对象。crossbeam::epoch 提供原语。自己做 RCU 很容易把对象释放早了。这已经是「专家结构」档,代码评审要按那个标准。

8. 异步锁、公平性、优先级反转

tokio::sync::Mutex 的等待者是任务,会让出 worker。适合必须跨 await 的互斥(例如对某个协议状态机逐步读写,且中间必须 IO)。即便如此,持锁时间仍是下游 RTT,其它人全部排队。能拆成「锁外 IO + 锁内改状态」就拆。

公平性:有的锁 FIFO,有的不公平(刚释放的线程更容易再拿到,缓存友好但可能饿死旁人)。尾延迟敏感的路径要知道你用的锁会不会让某个任务饿几百毫秒。不公平锁在吞吐上好看,在 p99 上可能很难看。

优先级反转在服务端以另一种面目出现:持锁的任务被调度去跑别的 CPU 活(或被阻塞池拖住),等高优先级的短请求在锁上排队。协作式调度下,持锁任务若不及时 await 让出,等于把锁的释放推迟到它下一次让出。又一条理由:临界区禁止重计算和 IO。

9. 取舍顺序:一张该钉在墙上的表

  1. 不共享。任务内所有权,channel 汇总。
  2. 不可变共享。Arc<T>,T 无内部可变。
  3. 极少更新的快照。Arc swap / RwLock 写很少。
  4. 短临界区互斥。parking_lot::Mutex 或 std::sync::Mutex,守卫不跨 await。
  5. 分片。按 key 打散。
  6. 读多且读路径极热。考虑 arc-swap 或成熟并发 map,带着你的写比例去测。
  7. 单计数器/标志。原子。
  8. profiler 证明队列或特定结构是热点,且分片无效。才考虑无锁 crate,不自己发明。

每一步都有人想跳到第 8 步。跳步的代码在低负载下「更快」的错觉来自未争用路径,一到多核打满就变成重试风暴。评审时要看的是争用证据,不是博客标题。

10. 排障:锁等待、死锁、缓存行、错误的无锁

  1. 锁等待在火焰图前几名。 看临界区里有什么。有 IO/分配/回调,先挪。没有,看是否该分片或改单所有权。
  2. 死锁。 两把锁顺序、持锁 await 再抢另一把、channel 与锁组成环。tokio-console 能看任务在等哪把异步锁。std 锁死锁会把 worker 线程钉在内核,console 里它们只是不 poll 了。
  3. p99 尖刺,平均好。 不公平锁饥饿、持锁任务偶尔做了重活、锁中毒恢复、GC 式的集中 drop 打在同一把锁上。
  4. CPU 打满在 CAS 循环。 无锁结构在高争用下自旋。这比 Mutex 把线程挂起更吃电、更吃缓存。退回锁或分片。
  5. 偶发数据损坏。 内存序过弱、ABA、把 Relaxed 用于发布指针、自己写的无锁表。立刻换成熟实现或退回 Mutex。损坏比慢更贵。
  6. 异步 Mutex 很慢。 未争用也贵。检查是否本可以用 std mutex 的短临界区。

度量:每把锁的等待时间直方图(自己在 RAII 守卫里打点,或用 tracing)、争用次数、分片的热点分布(是否 80% 请求打在 1 个 shard)。没有分片分布,你会把 N 加到 256 仍然被一个热点 key 嘲讽。

11. 和所有权、调度、背压的关系

锁是共享可变别名的运行时证明,对应所有权篇里 Mutex<T> 把 &T 变成内部可变。调度篇要求这个证明的守卫不要跨 await,否则别名的互斥范围变成时间上的「整个下游」。背压篇要求争用下的等待有界:锁上无限排队和 channel 无限排队是同一种病。三者一起看:能不共享就不共享;共享就短持锁;等锁也要能超时或失败,让背压冒泡到入口。

Send/Sync 在这里再次出现。Mutex<T>: Sync 要求 T: Send(守卫可能在另一线程 drop 那个 T 的内容——对 std 来说谁解锁谁持有)。T 不是 Send 就不要放进跨线程 Mutex。类型又一次把「这个状态能不能离开这条线程」写死。不要 unsafe impl 来图省事。

锁的粒度还和数据结构内部布局绑在一起。HashMap 在扩容时会移动所有桶,持锁时间随元素数涨。热路径上插入导致扩容,p99 就会出现和「偶发大锁」一样的尖刺。预分配容量、或改用不会整体搬迁的结构(slab、分代槽),是在缩短临界区,不是提前优化。同样,clone 整个 HashMap 出来再在锁外读,临界区短了,但分配和拷贝可能比锁等待更贵——这要测。小表 clone 出去往往赢;大表用快照指针或只拷需要的键。

condvar 在异步世界里对应 Notify + 条件标志,或 watch。不要用 std::sync::Condvar 在 async 里 wait,那会睡死 worker。异步等待必须让出。写「等某个会话出现」这类逻辑,用 HashMap + Notify,等待方被叫醒后重新查表,查不到再等。不要假设一次 notify 对应一次插入,notify 可能多余、也可能在你注册前已经发过——丢失的唤醒要用状态来补,不能只用边沿触发。

读写比例会随时间变。上线初期路由表几乎不改,RwLock 或 arc-swap 很合适;后来做成每秒多次的动态配置,写者开始排队,读者也被拖累。这时不是「RwLock 骗了你」,是工作负载变了。度量写次数和等待时间,允许自己把实现从 RwLock 换成分片 Mutex 或反过来。把选择写进注释:我们假设写 < 1% 。假设失效时,代码评审有依据去改。

假共享值得单独当一次事故看。两个 AtomicU64 在结构体里相邻,分别被不同核每请求自增,这一行缓存会在两核之间来回。profiler 上看到原子指令占比高、QPS 加核几乎不涨,就是它。repr(align(64)) 或 crossbeam::utils::CachePadded 把它们拆开。不要给所有计数器都垫 64 字节——那是在用内存换不知道存不存在的争用。先测相邻热计数,再垫。NUMA 上还有更远的跳:跨 socket 的原子比同 socket 的锁还慢。把任务和它的状态钉在同一 NUMA 节点,是代理类产品在机器变大之后的招,不是起步默认。起步默认是减少共享。

锁中毒策略要写成代码而不是口头。std::sync::Mutex 在临界区 panic 后,后续 lock() 返回 Err。选项:unwrap 把恐慌传播成下一请求也崩;into_inner 忽略毒继续用可能损坏的表;重启进程或丢掉这个分片。对内存里的会话表,损坏后继续可能比崩更糟(给错用户数据)。对纯指标计数,into_inner 可以接受。把选择和数据结构的「损坏是否可观测到错误结果」绑在一起。parking_lot::Mutex 没有毒化,panic 之后锁释放、状态可能半更新——你失去了「曾经坏过」这个信号。用它就要用别的方式保证临界区不 panic:临界区里只做不会失败的内存操作。

读多写少的快照还有一个陷阱:读者长期握着旧 Arc,写者已经 swap 了几十代,旧快照占着内存。Arc 的释放取决于最慢的那个读者。请求处理如果把快照 clone 进一个长时间的 await(下游超时 30s),这 30s 内的旧表都不能释放。短请求路径上 load 用完即丢;必须跨 await 时,只把需要的字段拷出来,不要把整表 Arc 带过 IO。这和 Bytes 钉住整块读缓冲是同一个计数泄漏,只是对象从字节变成了表。

批量持锁是另一类临界区膨胀。循环里对每个元素 lock(); update; unlock; 看起来碎,但缓存行反复获取;反过来整表一把锁更新一万个元素,其它核全停。中间形态是:按分片聚桶,每个分片锁一次更新一批。批量大小和分片数一起调。不要在循环里调用会分配的 format! 还握着锁——把要写的字符串在锁外造好。这条规则简单,火焰图上却经常看到 malloc 在 pthread_mutex 下面,原因就是它。

无锁结构的测试要包含:单线程语义等价、多线程随机交错、miri(如果你没碰平台相关原子)、以及「暂停一个线程再恢复」(模拟调度)。CAS 循环在恢复后必须仍然正确。漏了暂停场景,ABA 和 use-after-reclaim 会在生产的 STW 式延迟里才出现——例如一次长时间的 GC 式 drop 或一次 CPU steal。自己写的无锁队列若没有这套测试,就还不是生产组件。用 crate 时也看它有没有这类测试,没有的当实验。

std::sync::atomic::fence 和原子操作上的 order 不是一回事。需要「这之前的所有写对那之后的读可见」而手头是 Relaxed 计数时,才考虑 fence。多数业务代码不该直接 fence。能用 Release store / Acquire load 表达的,用配对表达,意图更清楚。代码里出现 fence,评审按「为什么原子 order 不够」来问,而不是按「看起来很底层所以很快」来过。

假共享会让加核不涨 QPS:两个热原子挤同一缓存行。只给测过的相邻热计数垫 64 字节,不要给所有计数器垫。NUMA 上跨 socket 原子可能比同 socket 的锁还慢,钉节点是机器变大之后的招,起步仍是减少共享。 OnceLock 保证初始化一次,不是互斥更新,更不要在请求路径第一次才编正则。读者优先的 RwLock 在读风暴下会饿死写者,流量打到已经下线的地址;能接受旧快照就用 arc-swap,不要用 RwLock 硬保证读到最新。旧 Arc 被一个 30s 的 await 握着,写者 swap 几十代也释放不了——跨 await 只拷字段。 诊断死锁靠锁的名字、持有时间直方图、持有者任务 id。自己包一层 TimedMutex,看见谁拿了 40ms,比上 lock-free 更常解决问题。临界区循环里 format! 会在火焰图里把 malloc 放进 pthread_mutex 下面。Drop 里不要抢可能被别人持有的锁,析构顺序叠加锁顺序是夜间死锁。 无锁结构的测试要包含暂停一个线程再恢复。漏了这个,ABA 会在一次长时间 drop 或 CPU steal 里才出现。代码里出现 fence,评审问为什么配对的 Release/Acquire 不够。批量更新按分片聚桶,不要整表一把锁扫一万行,也不要每个元素加一次锁把缓存行打飞。

12. 小结

服务端并发的原理是 争用下的正确性。先减少共享和临界区,再用最朴素的互斥,测到热点再分片或换快照,最后才是原子和无锁。lock-free 不是奖章。没有争用证据就上无锁,是在用正确性风险换一篇好看的技术分享。服务端默认的英雄路径是:不共享、短 Mutex、分片、快照、原子计数。走到无锁 crate 时,代码评审按实验项目的标准:测试含暂停线程、有生产采样、有回退方案。回退写在开关里,比写在「出了事再改」的口头计划里可靠。 下一篇离开运行时,看把这些 crate 拼成产品时的构建图:workspace 边界、feature 统一、release profile 如何决定体积和尾延迟。

争用优化的验收标准不是「换成了无锁」,是 p99 和核数扩展性在同一份压测脚本下变好,且正确性测试(含暂停线程、含 panic 注入)仍然绿。任何一项变差,回退开关要能在一次部署内打回 Mutex。没有回退的 lock-free 上线,等于没有刹车的下坡。