把 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:配对发布。写者Releasestore 指针,读者Acquireload 后能看见指针所指对象的初始化。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. 取舍顺序:一张该钉在墙上的表
- 不共享。任务内所有权,channel 汇总。
- 不可变共享。
Arc<T>,T 无内部可变。 - 极少更新的快照。
Arcswap /RwLock写很少。 - 短临界区互斥。
parking_lot::Mutex或std::sync::Mutex,守卫不跨 await。 - 分片。按 key 打散。
- 读多且读路径极热。考虑 arc-swap 或成熟并发 map,带着你的写比例去测。
- 单计数器/标志。原子。
- profiler 证明队列或特定结构是热点,且分片无效。才考虑无锁 crate,不自己发明。
每一步都有人想跳到第 8 步。跳步的代码在低负载下「更快」的错觉来自未争用路径,一到多核打满就变成重试风暴。评审时要看的是争用证据,不是博客标题。
10. 排障:锁等待、死锁、缓存行、错误的无锁
- 锁等待在火焰图前几名。 看临界区里有什么。有 IO/分配/回调,先挪。没有,看是否该分片或改单所有权。
- 死锁。 两把锁顺序、持锁 await 再抢另一把、channel 与锁组成环。tokio-console 能看任务在等哪把异步锁。std 锁死锁会把 worker 线程钉在内核,console 里它们只是不 poll 了。
- p99 尖刺,平均好。 不公平锁饥饿、持锁任务偶尔做了重活、锁中毒恢复、GC 式的集中 drop 打在同一把锁上。
- CPU 打满在 CAS 循环。 无锁结构在高争用下自旋。这比 Mutex 把线程挂起更吃电、更吃缓存。退回锁或分片。
- 偶发数据损坏。 内存序过弱、ABA、把
Relaxed用于发布指针、自己写的无锁表。立刻换成熟实现或退回 Mutex。损坏比慢更贵。 - 异步 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 上线,等于没有刹车的下坡。