
线程同步这四个字头一回听到多半是在面试题里第一次被它真正教训往往是在线上对不上账的那个晚上。我至今记得一个计数服务四个线程各加一百万次最后结果不是四百万而是三百七十多万并且每次跑出来的数字都不一样。问题不在业务逻辑而在于多个线程同时读写同一块共享内存谁先谁后完全看调度器的脸色。线程同步要解决的就是把这种看运气变成有规矩。线程同步机制说白了就是让多个执行流在访问共享资源时能排好队、商量好顺序的一套约定和工具。不管你是刚接触并发编程的新手还是写过几年多线程代码、却总在排查偶发问题的老手这一篇都会有收获。我会从竞态条件讲起把互斥锁、自旋锁、读写锁、条件变量、信号量、屏障、原子操作这些常用线程同步机制逐个拆开讲清楚它们各自适合什么场景、代价在哪里、代码该怎么写、我踩过哪些坑。看完之后你至少能做到两件事拿到一个并发场景能快速判断该选哪种机制遇到死锁、数据错乱、CPU 空转这类问题时知道从哪儿下手。1. 线程同步到底在解决什么问题1.1 一个能稳定复现的竞态条件现场先看一段几乎每个人初学多线程时都写过的代码C 版本两个线程各自给同一个全局变量加一百万次。#include thread #include iostream long long counter 0; void add() { for (int i 0; i 1000000; i) { counter; } } int main() { std::thread t1(add); std::thread t2(add); t1.join(); t2.join(); std::cout counter std::endl; // 期望 2000000实际经常偏小 }跑十次你可能得到 1352744、1690233、1888610 这种毫无规律的结果。原因很简单counter在机器层面根本不是一条指令它至少拆成三步——把counter从内存读进寄存器寄存器里加一把结果写回内存。两个线程各自拿着自己那份读到的一半状态去写回后写的那个就把前一个的成果直接覆盖掉了。这就是丢失更新也是竞态条件最经典的形态。关键在于这种错误不会每次都出现。它依赖于两个线程恰好交错在那个极窄的时间窗口里压力小的时候可能跑一万次都不出错一旦并发量上来或者机器负载变化就突然冒出来。这也是并发问题难查的根本原因它不是确定性错误而是概率性错误你复现不了不代表它不存在。1.2 原子性、可见性、有序性同步的三条底线把上面这类问题归归类其实所有的线程同步需求最终都落在三个点上原子性、可见性、有序性。理解了这三条后面选什么机制就是顺理成章的事。原子性指的是一个操作要么全部完成要么完全没发生中间状态对外不可见。counter不满足原子性而原子操作、加锁保护下的临界区就满足。可见性指的是一个线程改了共享变量另一个线程能不能立刻看到。现代 CPU 有多级缓存写入先进本地缓存另一个核心上的线程读的可能还是旧值这不是它读错了而是根本没被通知。有序性指的是编译器和 CPU 为了性能会重排指令单线程下重排不影响结果多线程下就可能让另一个线程看到半成品状态。我习惯用一个生活化的场景来解释三个人共用一本手写账本原子性就是谁写字的时候别人不能同时写可见性是你写完要让大家都能看见这一页的新内容有序性是先记账再核对不能顺序反了。加锁本质上就是给账本配了一把钥匙谁拿到钥匙谁写写完锁上别人再拿钥匙时能看到最新内容。1.3 同步的代价别把锁当成万能药很多新手学会加锁之后会走到另一个极端只要是多线程访问的变量统统加锁。结果是程序不报错了但性能掉了十几倍甚至出现加了锁比单线程还慢的尴尬情况。这不是锁不好而是没算清楚账。锁的开销主要来自三块一是加锁解锁本身的系统调用或原子指令开销无竞争时通常是几十个时钟周期二是上下文切换当一个线程拿不到锁被挂起操作系统要保存它的寄存器现场、调度另一个线程上来这一套动作动辄几微秒三是缓存一致性流量多个核心反复争抢同一个缓存行会导致缓存行在核心之间来回弹跳这个代价在核数多的机器上非常可观。所以真正的高手写并发代码第一反应不是哪里加锁而是能不能不加锁——用线程私有数据、消息传递、不可变对象把共享状态消掉。只有消不掉的时候才退而求其次去选一把合适的锁。这个思路上的顺序比记住多少种锁都重要。2. 常用线程同步机制横向拆解2.1 互斥锁最通用也最容易用错的一把锁互斥锁是使用频率最高的线程同步机制没有之一。它的语义很直白同一时刻只允许一个线程进入临界区其他线程要么排队阻塞要么按策略被唤醒。C 里是std::mutexJava 里是synchronized和ReentrantLockGo 里是sync.MutexPython 里是threading.Lock本质都一样。互斥锁最大的优势是通用。不管临界区里有多少行代码、访问多少变量一把锁全包住正确性最容易保证。它的代价是阻塞式等待拿不到锁就睡醒了再抢一来一回都是钱。所以判断要不要用互斥锁我的标准是看临界区的长度如果临界区里有 IO、有循环、有复杂计算那必须用互斥锁让其他线程睡觉比让它们空转划算得多。用错互斥锁的典型场景有几个锁范围过大把不相干的耗时操作也圈进去了锁范围过小保护了一半漏了一半锁对象不统一两个地方保护同一个变量却用了两把不同的锁。还有一种隐蔽的错误就是在持有锁的时候调用外部回调或者虚函数你根本不知道对方的代码里会不会再来抢同一把锁。2.2 自旋锁临界区极短时的替代方案自旋锁和互斥锁解决的是同一类问题区别在于等待方式。互斥锁是拿不到就睡自旋锁是拿不到就反复问问到为止也就是原地空转。CPU 在自旋期间什么都没干纯粹在烧电听起来很蠢但在临界区特别短的场景下它反而是最优解。道理不难理解线程被挂起再唤醒的成本大概是几千个时钟周期如果临界区本身只需要几十个时钟周期那么睡一觉的开销远远大于等一会儿。自旋锁把这段切换成本省了下来在核数多、竞争不激烈的场景里效果明显。std::atomic_flag可以实现一个最简自旋锁Linux 内核和各大数据库里也大量使用这类结构。注意自旋锁在单核机器上没有意义。你空转的时候持锁线程根本得不到 CPU 时间片来释放锁结果就是白等到超时。另外临界区里绝对不能有阻塞操作、IO 或者可能睡眠的调用否则会拖垮整个系统。我在项目里用过一段时间的自旋锁保护一个高频读写的统计计数器性能确实比互斥锁好不少。后来并发进一步上去自旋的线程开始互相干扰性能曲线反而下滑最后还是换成了分段原子累加。这个经历告诉我自旋锁的适用范围比想象中窄一定要用压测数据说话不能凭感觉。2.3 读写锁与顺序锁读多写少的场景优化很多共享数据的访问模式是极度不对称的读操作占了百分之九十以上写操作寥寥无几。这种场景下用互斥锁就很亏因为多个读操作本可以并行互斥锁却把它们强行串行化了。读写锁正是为这种情况设计的读锁共享写锁独占多个读者可以同时进写者进场时所有读者和写者都得等。C17 提供了std::shared_mutexJava 有ReentrantReadWriteLockGo 有sync.RWMutex。用法上要注意读锁和写锁必须成对使用而且不能在一个线程里既持读锁又想升级成写锁绝大多数实现都不支持锁升级直接写就会死锁。读写锁有个经典的写饥饿问题如果读者源源不断地来写者可能永远等不到机会。有的实现比如某些系统的读写锁提供了写优先策略来缓解但代价是读者可能被饿死。Java 的ReentrantReadWriteLock支持公平模式代价是吞吐下降。真到了读写都极高频、又互不干扰的程度可以考虑顺序锁这种更激进的方案写者直接改数据并递增版本号读者读完检查版本号有没有变变了就重读一遍把写者的等待降到最低。2.4 条件变量与信号量让线程学会等待和放行前面几种锁解决的是互斥问题而条件变量和信号量解决的是协作问题。线程不能干等一个条件成立那样太浪费 CPU也不能不停轮询那样同样浪费。条件变量提供了一种优雅的机制线程在条件不满足时挂起等另一个线程改变了状态再把它唤醒。条件变量有一个必须记住的搭配规则它永远要和一把互斥锁一起用。wait调用时会自动释放锁并挂起被唤醒后重新获取锁再返回。这个设计是为了避免检查条件和进入等待之间出现时间窗口导致唤醒信号丢失。这也是为什么几乎所有的教材都强调条件变量的判断必须是while循环而不是if。信号量则更像一个带计数的令牌桶。它维护一个整数wait操作把计数减一并可能阻塞post操作把计数加一并可能唤醒等待者。当计数上限为一它就退化成互斥锁当计数大于一它可以用来限制同时访问某个资源的线程数量比如连接池限制最多十个并发连接。Java 的Semaphore还提供了一次性申请多个许可的重载做批量资源控制很方便。2.5 屏障、原子操作与无锁结构屏障解决的是另一类协同问题一批线程必须全部到达某个点之后才能继续往下走。比如分块计算把所有分块都算完才能做汇总这时候就得用屏障。C20 的std::barrier、Java 的CyclicBarrier、POSIX 的pthread_barrier都是这个语义。CyclicBarrier还支持所有线程到齐后执行一个回调动作非常适合迭代式的并行算法。原子操作是最轻量的一类同步手段。它不阻塞、不加锁靠 CPU 提供的原子指令比如比较并交换 CAS来保证单个变量操作的原子性。Java 的AtomicInteger、LongAdderC 的std::atomicGo 的sync/atomic都属于这一类。它适合做计数器、状态标志、无锁栈和无锁队列这类结构。不过原子操作不等于随便用都安全。单个原子操作是原子的多个原子操作组合起来依然可能被交错执行这中间没有任何保护。而且 CAS 在竞争激烈时会疯狂失败重试性能反而不如加锁。很多人一听说无锁就兴奋实际上无锁结构的正确性证明非常困难工程上更推荐用现成的、经过充分验证的无锁容器而不是自己手搓。2.6 机制选型对照表把上面几种机制放在一起横向对比选型时心里就有谱了。机制适用场景优势主要代价典型实现互斥锁通用临界区保护语义简单、正确性易保证阻塞切换开销std::mutex、synchronized自旋锁临界区极短、竞争低省去线程切换空转烧 CPUstd::atomic_flag读写锁读多写少读操作可并行写饥饿、实现重shared_mutex、RWMutex条件变量等待某条件成立无轮询、响应快必须配锁、易丢失唤醒condition_variable信号量限制并发数量计数灵活语义易被误用Semaphore屏障阶段性同步天然适合并行分块只用得上特定场景CyclicBarrier原子操作单变量计数与标志无阻塞、开销小组合操作不安全AtomicInteger、std::atomic看表的时候要记住一句话没有最好的机制只有最贴合场景的机制。同一个功能用互斥锁能写对用原子操作可能写错换成高并发压力下原子操作可能又成了唯一选择。选型永远是在正确性和性能之间找平衡点。3. 互斥锁实操从加锁范围到死锁规避3.1 加锁范围与锁粒度的设计取舍锁粒度是并发代码里最考验功力的地方。粒度太粗所有线程挤在一把锁上排队并发度上不去粒度太细锁的数量暴涨加锁解锁开销和维护成本都上来了还容易漏保护或者引入死锁。我一般的做法是先把共享状态按访问关系分成若干独立的组每组配一把锁组内的变量永远只被该组的锁保护这样既清晰又不容易出错。具体到代码里加锁范围的原则是够用就好不要多一行。下面这段代码是典型反面教材std::mutex mtx; void process(const Request req) { std::lock_guardstd::mutex lock(mtx); auto data loadFromDisk(req); // 耗时的 IO不应该在锁内 auto result heavyCompute(data); // 耗时计算同样不该在锁内 cache[key] result; // 真正需要保护的就这一行 }磁盘读取和复杂计算放在锁里等于让所有线程陪着一个人做 IO。正确写法是把耗时的准备工作挪到锁外只在真正读写共享状态的瞬间加锁。这个改动在压测环境里经常能带来数倍的吞吐提升。3.2 死锁的四个必要条件与工程规避手法死锁是并发编程里最经典的事故形态两个线程各持一把锁又都在等对方那把谁都动不了程序直接卡死。教科书上总结的死锁四个必要条件——互斥、持有并等待、不可剥夺、循环等待——只要破坏其中任意一个死锁就不会发生。工程上我常用的是后面两个的变体。破坏循环等待最有效的办法是规定统一的加锁顺序。比如系统里有 A、B 两把锁那就约定所有地方必须先锁 A 再锁 B绝不允许反过来。这样环形等待就形成不了。这套规则写进团队文档、用代码评审兜住比事后用工具排查靠谱得多。破坏持有并等待可以用try_lock配合超时。C11 的std::lock和 C17 的std::scoped_lock能一次性锁住多把互斥量内部用避免死锁的算法来实现比自己手动按顺序加锁更省心。下面这段示例展示了两种写法std::mutex m1, m2; // 推荐一次性锁定多把锁内部保证不死锁 void safe_transfer() { std::scoped_lock lock(m1, m2); // 操作共享数据 } // 兜底尝试加锁失败就退避重试 void try_transfer() { while (true) { std::unique_lockstd::mutex l1(m1, std::defer_lock); std::unique_lockstd::mutex l2(m2, std::defer_lock); if (std::try_lock(l1, l2) -1) { // 拿到两把锁干活 return; } // 没拿到释放已持有的锁稍后重试 } }注意不要把加锁和解锁写成散布在函数各处的裸调用。用 RAII 包装lock_guard、unique_lock、scoped_lock异常发生时锁会自动释放这一条能避免相当比例的线上事故。3.3 条件变量配合互斥锁的标准写法条件变量的使用姿势几乎有定式写错的方式也就那几种全部集中在判断语句和锁的顺序上。std::mutex mtx; std::condition_variable cv; std::dequeint queue; void consumer() { while (true) { std::unique_lockstd::mutex lock(mtx); // 必须用 while 判断不能写 if cv.wait(lock, [] { return !queue.empty(); }); int item queue.front(); queue.pop_front(); lock.unlock(); handle(item); } } void producer(int value) { { std::lock_guardstd::mutex lock(mtx); queue.push_back(value); } // 先释放锁 cv.notify_one(); // 再通知也可以放在锁外 }这里有三处细节值得反复强调。第一wait必须配合while或者带谓词的重载原因是有伪唤醒——条件变量可能在没有任何人通知的情况下返回也可能是多个等待者被同时唤醒但只有一个能拿到资源用if判断就会直接往下执行读到空数据。第二wait的调用必须先持有锁否则唤醒信号可能在挂起之前就发出去了造成丢失唤醒线程永远醒不来。第三notify_one放在锁外通常更好因为持锁通知会让被唤醒的线程立刻又卡在锁上多一次无谓的切换。4. 高并发场景下的进阶做法4.1 原子操作与内存序别只会用默认参数std::atomic的默认内存序是seq_cst也就是最强的顺序一致性代价是每次操作都要插入内存屏障在 x86 上表现为完整的缓存同步指令。很多场景其实并不需要这么强例如一个纯粹的统计计数器只要保证不丢更新即可不需要跟其他变量建立任何先后关系那就可以用memory_order_relaxed。内存序的六个级别初看很绕抓住核心就两条线acquire负责我后面的读写不能被重排到它前面release负责我前面的读写不能被重排到它后面。两者配对使用时就能建立release 之前的所有写入对 acquire 之后的读取都可见这条同步关系这正是实现无锁队列、单次初始化这类结构的基石。relaxed则只保证操作本身的原子性不提供任何跨线程的可见性承诺。提示内存序是并发领域最容易写错又最难调试的部分。如果拿不准就用默认的seq_cst正确性优先等性能分析工具明确指认出这里是瓶颈再回头做精细化调整。4.2 无锁队列的思路与 ABA 问题无锁队列的经典实现是 Michael-Scott 队列核心思路是用 CAS 操作维护头尾指针入队时不断尝试把尾节点的 next 指针从空改成新节点出队时类似。它的卖点是不阻塞、不会因为某个线程挂起而拖垮整个队列在实时系统和某些高吞吐中间件里很受欢迎。但无锁结构有个绕不开的坑叫ABA 问题。假设线程甲读到栈顶是 A正准备用 CAS 把栈顶换成 B就在这期间线程乙把 A 弹出、又把 A 压了回去栈顶看起来还是 A甲的 CAS 就成功了可实际上整个结构早就变了样子结果就是数据丢失或者结构损坏。解决办法是给指针带上一个版本号用双字 CAS 一起比较只要版本号变过就重试。Java 的AtomicStampedReference就是干这个的。还有一个更麻烦的问题叫内存回收。在无锁结构里一个节点被摘下来之后不能立刻释放因为可能还有别的线程正在读它。C 生态里通常用风险指针、引用计数或者hazard pointer这类机制来兜底这也是为什么我并不推荐业务代码自己手写无锁队列——里面藏着太多超出加锁范畴的坑。4.3 伪共享与缓存行填充这是个非常容易被忽视但影响巨大的性能问题。现代 CPU 的缓存以缓存行为单位x86 上通常是 64 字节。如果两个不同变量恰好落在同一个缓存行里被两个不同核心频繁修改那么每次修改都会让对方的缓存行失效两个核心在总线上来回抢这条缓存行性能断崖式下跌。变量本身没有任何逻辑关联仅仅因为内存布局挨着就被牵连这就是伪共享。我在一个统计模块里遇到过这个问题每个线程有自己的计数变量理论上是无竞争的但吞吐量始终上不去。后来用性能工具一看缓存失效次数高得离谱把结构体按 64 字节对齐之后性能直接翻了一倍多。struct alignas(64) PaddedCounter { std::atomiclong long value{0}; char padding[64 - sizeof(std::atomiclong long)]; };对齐到 64 字节是为了保证每个计数变量独占一个缓存行。代价是内存占用变大所以只在确认是热点、并且已经被性能数据证实的情况下才做这个优化别无脑到处加。5. 常见问题排查实录5.1 死锁、活锁、惊群与伪唤醒死锁最好识别症状是程序彻底卡住、CPU 占用接近零。活锁则相反线程一直在运行、一直在重试但业务毫无进展CPU 占用还不低这种通常出现在两个线程互相退让的重试逻辑里。解决办法是引入随机退避让双方的重试节奏错开。惊群是另一个常见现象条件满足时唤醒操作把一大群等待线程全叫醒了结果只有一个能拿到资源其余全部白醒一遍白白消耗调度开销。在 Linux 上可以把唤醒拆成若干个条件变量按哈希分散或者优先使用只唤醒一个的接口。这个问题的量级取决于等待线程数几十个线程可能感觉不到上千个就很明显了。伪唤醒前面提过本质是条件变量只负责通知不负责保证条件真的成立。任何时候被唤醒都必须重新检查条件。这一条在跨平台代码里尤其重要不同系统对伪唤醒的宽容度不一样本地测试没问题不代表上线没问题。5.2 排查工具与定位手法遇到并发问题先抓现场别急着改代码。Linux 上最直接的手段是gdb连上去执行thread apply all bt把所有线程的调用栈打出来看有没有两个线程分别卡在两把锁上、互相等待。pstack也能达到类似效果。Java 这边用jstack抓线程快照它会直接告诉你found one Java-level deadlock把涉及的线程和锁都列出来省事很多。数据错乱类的问题更依赖动态检测工具。C/C 里可以用ThreadSanitizer编译时加-fsanitizethread或者 Valgrind 的 Helgrind 工具它们能监测到没有同步保护的并发访问并给出调用栈代价是程序跑得慢好几倍适合在测试环境跑回归。Java 可以用jconsole或者 Java Flight Recorder 观察线程状态分布和锁竞争情况。性能类问题则推荐perf。perf top能看出 CPU 时间花在自旋还是系统调用上perf stat能看到上下文切换次数。如果上下文切换异常高说明锁竞争或者睡眠唤醒太频繁那就该往减少锁粒度、换成自旋方向去想。5.3 避坑速查清单下面这张表是我这些年攒下来的出问题的时候照着对一遍命中率相当高。现象可能原因排查方向处理手法程序卡死、CPU 为 0死锁抓全部线程栈找循环等待统一加锁顺序、用 scoped_lock计数结果偏小丢失更新检查共享变量是否有保护加锁或改原子操作读到空数据/旧值丢失唤醒、可见性检查 wait 是否用 while补谓词判断、加内存屏障CPU 空转居高不下自旋过度perf 看自旋占比换互斥锁、加大退避线程频繁唤醒又睡惊群观察唤醒数量和竞争拆分条件变量、notify_one单线程逻辑对、多线程错数据竞争TSan/Helgrind 检测定位到具体变量加保护性能随核数增加反而降伪共享perf 看缓存失效结构体按缓存行对齐这张表里我特别想强调统一加锁顺序这一条。它看起来朴素但实际收益极高因为绝大多数死锁都源于顺序不一致。团队里只要有一个人写反了顺序整个服务就可能在某个低概率路径上挂掉而这种问题往往在压测里也复现不出来。6. 我在实际项目里的一些体会写并发代码这些年最深的感受是同步问题从来不是靠某一种机制解决的而是靠设计。一个共享状态满天飞的架构你往上堆多少锁都只是打补丁而一个把共享状态压缩到最小、边界划分清晰的架构可能只需要几把锁甚至几处原子操作就足够了。所以在动手写锁之前先问自己一句这份数据真的需要共享吗能不能改成每个线程一份、最后合并第二个体会是不要迷信无锁。无锁结构在论文里很漂亮落到工程里要考虑内存回收、ABA、公平性、调试难度每一项都是成本。我参与过的一个服务曾经把一段互斥锁逻辑改成无锁队列压测确实快了两成但上线之后偶发数据错乱排查了将近两周最后还是改回加锁版本。这次的教训让我明白在没有充分的测试覆盖和明确的性能收益之前加锁永远是更稳妥的选择。最后分享一个小技巧。当你在排查一个棘手的并发问题时不妨先把线程数降到两个把并行改成可预测的交错顺序用断点或者日志把每一步操作都打出来。很多看代码看不出来的问题在慢速交错的环境里会变得非常明显。等问题定位清楚之后再把线程数调回去验证修复效果这个流程比盲目加日志、大海捞针高效得多。