ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

线程间共享数据同步实战:从竞态条件到锁与原子操作

线程间共享数据同步实战:从竞态条件到锁与原子操作 并发编程这门课或者这本书看到第三章大多数人都会冒出一个念头明明开线程就是为了快怎么一牵扯到共享数据代码反而变得又慢又别扭还动不动就出诡异问题这一章专门讲线程间共享数据核心说白了就一个字同步。但要真正搞懂同步得先把“为什么要同步”这个问题掰开揉碎。我最初学到这里的时候也是一知半解以为给变量加个锁就万事大吉结果线程池一压测性能掉得惨不忍睹后来又遇到了死锁、虚假唤醒、数据竞争这些连环坑才慢慢把整章内容串起来。这篇就是按我自己的理解和踩坑经历做的一份归纳总结把知识点串成一线再把工程里真正有用的细节补上希望能帮你少走点弯路。这章内容适合正在学操作系统、并发编程的读者也适合已经写了几年业务代码、但一直对线程安全似懂非懂的工程师。我会从最基础的竞态条件讲起一直讲到锁的粒度、条件变量、线程安全设计最后再整理一份问题排查清单尽量把理论和实操之间的缝隙填上。1. 为什么共享数据是并发问题的重灾区1.1 一段极简代码看透竞态条件先看一个最简单的例子。两个线程分别对一个全局变量执行counter循环十万次你预期结果是二十万跑出来往往只有十几万而且每次结果都不一样。原因在于counter从来不是一条原子操作它在底层至少对应三步读值到寄存器、寄存器加一、写回内存。两个线程交错执行时完全可能同时读到同一个旧值各自加一再各自写回最终等于只增加了一次。这种“多个线程同时读写了同一份数据且结果取决于线程调度顺序”的情况就叫竞态条件。这一章后面所有内容本质上都是在解决这一个问题。我在早期犯过一个低级错误为了“让结果看起来对”直接在循环里加了个sleep(1)结果测试通过搬到生产环境又崩了。原因很简单加 sleep 只是降低了竞争概率并没有消除它。真正要消除竞争必须引入互斥机制或者原子操作而不是靠玄学调度。1.2 共享变量的本质与临界区共享数据从哪来同一个进程内的所有线程天然共享堆内存、全局变量和静态变量。所以线程 A 在堆上 new 出来的对象线程 B 只要拿到了指针就能直接访问。这是线程比进程轻量的原因也是并发 bug 最容易产生的地方。我们把“访问共享数据的代码段”叫作临界区。写并发代码的第一准则就是识别出所有临界区并用同步手段把它们保护起来。这里有个工程判断标准只要有一段代码里出现了共享变量而且这段代码可能被多个线程同时执行就必须考虑加锁、原子操作或者干脆不共享。举一个我实际项目里的例子一个嵌入式设备控制程序主线程负责定时采集传感器数据子线程负责把数据写到 Flash。两个线程共用同一个缓冲区指针最初没做同步结果设备跑十几个小时之后写进 Flash 的数据偶尔出现整段乱码。定位很久才发现是主线程在拷贝数据时子线程已经开始擦写缓冲区被写了一半。这就是典型的临界区没保护。1.3 编译器与CPU两个看不见的“捣乱者”很多人以为“我加了锁数据就安全了”其实还不够。即使你加了锁还有一个隐藏问题编译器和 CPU 会为了性能对指令进行重排。只要重排不改变单线程语义编译器就可能把某条读操作提到锁外面去或者把一个变量的写操作挪到另一个变量之后。在多线程环境下这种重排会导致另一个线程看到“没按预期顺序发生”的事件。和重排紧密相关的还有一个问题就是 CPU 缓存。现代多核 CPU 中每个核心有自己的 L1/L2 缓存线程 A 修改了变量可能只写到了本地缓存还没刷回主内存线程 B 在另一个核上读到的还是旧值。传统互斥锁之所以能“解决问题”是因为锁的实现内部包含了内存屏障能在加锁/解锁时同步缓存和阻止重排。所以在 C 里即使你只是读一个标志位来决定要不要退出循环也应该用std::atomic配合适当的内存序而不是直接用裸bool。用裸 bool 在单线程下没问题在 Release 优化后的多线程环境下就可能因为编译器把读取优化成一次性寄存器缓存导致循环永远退不出去。这个坑我在编写工作线程时遇到过最后排查到汇编层才确认。2. 同步原语的选择与取舍2.1 互斥锁最朴素也最可靠的方案互斥锁的核心语义就一句话同一时刻只允许一个线程进入临界区。其他想进入的线程必须等持有锁的线程释放这就是“互斥”。在 C 里建议直接用std::lock_guard或std::scoped_lock不要手动lock()后忘记unlock()。lock_guard的生命周期结束时会自动解锁哪怕临界区里抛了异常也会正确释放。这个“RAII 式”的习惯必须养成我见过太多线上事故就是因为某个提前 return 分支漏了解锁导致其他线程永久阻塞。互斥锁最大的缺点是“贵”。加锁和解锁涉及系统调用或至少是用户态原子操作 可能的 futex 唤醒在高频访问的场景下会严重拉低性能。比如之前我做一个 C 服务把一个热路径中的 map 访问加锁后吞吐量直接减少一半。后来改成读写锁读多写少场景下才把性能拉回来。2.2 读写锁与共享互斥量读多写少场景的优化读写锁允许多个线程同时读但写线程独占。在“读远多于写”的场景下它比普通互斥锁并行度高得多。C17 提供了std::shared_mutex配合std::shared_lock做读锁std::unique_lock做写锁。但是读写锁不是银弹。如果写操作频繁写线程可能长期被读线程饿死因为读锁可以被不断重入而写锁必须等所有读锁释放。部分实现写优先策略但仍然存在性能波动。我自己的经验是当写操作占比超过 10% 时读写锁的优势就很有限了不如老老实实用互斥锁或者直接上无锁结构。关于锁的选择先记住这张表基本能应对大多数场景场景推荐方案说明低频共享变量保护std::mutexlock_guard简单可靠开销可接受读多写少的共享缓存std::shared_mutex写锁独占读锁共享计数器、标志位std::atomicint无锁性能高注意内存序工作线程任务队列互斥锁 condition_variable经典生产者消费者模型单线程内共享数据不需要同步明确线程模型减少无谓加锁2.3 原子操作脱离锁的高性能路径原子操作是一种不需要显式加锁的同步方式。它的原理是让 CPU 底层保证某条指令在读改写时不可被中断比如atomicint的fetch_add底层会用到 x86 的lock xadd指令。相比互斥锁原子操作通常没有系统调用竞争不激烈时效率高得多。但原子操作真正的难点在内存序。C 里有memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_seq_cst等。默认值是seq_cst语义最强性能也可能最低。新手阶段我建议无脑用默认值先保证正确再考虑优化。等你真正理解了 happens-before 关系再去碰 relaxed 语义。举个最常见的场景线程池中的任务计数器。用std::atomicint记录当前执行中的任务数线程开始任务时fetch_add(1, std::memory_order_acq_rel)任务结束时fetch_sub(1, std::memory_order_acq_rel)。主线程判断计数器归零后再回收资源既避免锁的开销又能保证内存可见性。3. 条件变量与线程间通信的实战套路3.1 条件变量为什么必须配锁使用条件变量解决的问题是“线程需要等待某个条件成立后再继续执行”。最典型的场景就是工作线程等待任务队列非空没有条件变量时只能轮询队列浪费 CPU有了条件变量线程可以被挂起沉睡等条件满足时被唤醒。但要特别注意条件变量的使用必须配合互斥锁。因为条件变量的wait操作是“原子地释放锁并进入休眠”这样才不会出现“刚判断完条件、还未来得及睡眠其他线程已经把条件改掉并发来通知”的竞态。如果你只调wait而不传已锁定的unique_lock代码根本编译不过这也是 API 设计在强制你做正确的事。我在工作项目里曾试过用一个全局标志位代替条件变量通知子线程退出子线程每 50ms 检查一次标志结果不仅响应慢还白白浪费电量。后来改成条件变量主线程调用notify_one()子线程在wait上准时醒来整个退出流程立刻清爽了。费电的嵌入式设备更要特别注意这种区别。3.2 生产者消费者模型实现要点生产者和消费者模型是线程间共享数据的经典题目也是线程池、消息队列这些组件背后的基本思想。核心结构通常是一个“互斥锁 条件变量 队列”。生产者加锁后往队列里 push然后notify_one()消费者调用wait(lock, predicate)队列非空才继续否则挂起等待。写这个模型时我建议把队列封装成一个带同步逻辑的类不要在每个调用点手动加锁。这一步能避免大量低级错误。接口可以暴露push(T item)和T pop()内部统一处理锁和条件变量。虽然多了一层函数调用开销但换来的是“锁不会乱加、通知不会漏发”非常值得。另一个容易被忽略的点是notify_one()和notify_all()的选择。多个消费者线程在等待同一个条件时如果生产一次只产生一个任务可以直接用notify_one()避免把全部线程都唤醒又抢同一个任务。但如果是“缓冲区内空位较多可以同时消费多个”的场景用notify_all()反而更好。需要结合业务权衡。3.3 谓词判断规避虚假唤醒的唯一正确姿势条件变量有一个反直觉的特性wait可能在没有任何线程调用notify的时刻被唤醒这叫虚假唤醒。这是操作系统底层的某些实现细节造成的概率很低但绝不能抱有侥幸心理。所以正确的用法永远是std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return queue.empty() false; });第二种带谓词的重载本质上是一个循环如果谓词不成立就继续等待即使被意外唤醒也会重新检查条件。有些人觉得条件变量太复杂没搞明白之前直接裸 sleep但工作线程里用 sleep 轮询真不是长久之计该用条件变量就得用。我之前在代码评审时看到有同事用while (!flag) { sleep(10ms); }做线程等待短期能用但响应时间和 CPU 占用都非常差。我说服他改成条件变量之后那一路的功耗直接下降了一个档次。4. 经典场景代码实操两个线程并行读写一个大数组4.1 需求描述与方案推演这里我们来做一个特别贴合实际问题的练习两个线程分别对一个很大的数组进行读写。比如一个线程往数组里填充数据另一个线程读取并统计数据。如果对整段数组加同一把锁那读取线程会频繁被阻塞根本起不到“并行”的作用。一个合理的思路是把大数组按索引范围分成多个区块每个区块配一把锁。线程 A 和线程 B 只要操作不同区块就能真正并行执行只有操作同一区块时才需要互相等待。这种“细粒度锁”的思想本质上就是数据库中的行锁、页锁设计思路。比如数组长度是 100 万可以把这 100 万个数分成 100 个区每区 1 万个元素配一个std::mutex。读写某个元素时先根据下标算出区块号再对该区块的锁加锁。这样有效降低了锁竞争同时在实现上也不算复杂。4.2 分块互斥的实现下面是一段可以直接运行的 C 实现思路#include vector #include mutex class PartitionedArray { public: PartitionedArray(size_t size, size_t partCount) : data_(size), locks_(partCount) {} void write(size_t index, int value) { auto lock locks_[index / blockSize()]; std::lock_guardstd::mutex guard(lock); data_[index] value; } int read(size_t index) const { auto lock locks_[index / blockSize()]; std::lock_guardstd::mutex guard(lock); return data_[index]; } private: size_t blockSize() const { return data_.size() / locks_.size(); } std::vectorint data_; mutable std::vectorstd::mutex locks_; };这里的mutable关键字是为了在const成员函数里仍然可以对锁加锁。细节虽小但如果你忘了加编译都过不去。实际测试时这个方案的并发性能相比“一把大锁”有明显提升尤其是两个线程分别操作数组两端时锁竞争概率几乎为零。但要注意如果读写总是集中在某几个区块那分块锁也没多大用因为热点区块仍然会激烈竞争。4.3 双缓冲思路读写分离的另一种选择如果业务允许“读到的数据可以不是实时最新”双缓冲是一个更极致的方案。基本思路是准备两份缓冲区 A 和 B写线程只往 A 写写完后原子地交换角色读线程只从 B 读读到的是上一轮的完整快照。双缓冲的特点是避免了读写线程之间的大部分锁竞争只需要在交换角色时用原子操作保护一下。代价是读线程看到的数据会有一轮的延迟。在某些实时性要求不高、但吞吐量要求很高的场景比如图形渲染、音频数据采集这个方案非常流行。我做过一个数据采集系统传感器以 1000Hz 频率上报数据上位机需要实时绘图。如果让绘图线程直接读采集缓冲每隔一小段时间就会出现画面撕裂。改成双缓冲后采集线程写前台缓冲绘图线程读后台缓冲只在交换指针时加锁整晚运行画面都稳定了。5. 死锁、活锁与锁的粒度控制5.1 死锁的四个必要条件死锁是锁用得多了以后最容易出现的问题。它需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。在写代码时我们不可能去掉“互斥”这个条件不然锁就失去了意义。所以工程上主要从“循环等待”下手。最常见的死锁场景是线程 A 持有锁 1 等待锁 2线程 B 持有锁 2 等待锁 1两个线程互不相让永远卡死。比如两个账户之间转账同时锁住两个账户余额顺序不一致就很容易死锁。解决办法是强制所有加锁操作都按照同一全局顺序进行比如先锁账户编号小的再锁编号大的这样环就断了。C17 提供了一个小工具std::scoped_lock可以同时锁住多个互斥量并且内部通过算法避免了死锁。如果你需要同时持有多把锁优先用它而不是单独一把一把地lock()。5.2 避免死锁的工程实践在真实项目中我只靠“加锁顺序一致”这一条规则就解决过 90% 的死锁问题。剩下的 10% 出在嵌套锁上一个持锁的函数内部调用了另一个也要锁的函数锁的粒度没有规划好。我的建议是在设计接口时就明确“加锁边界”。类的公有方法内部统一加锁但绝不对外暴露带锁的句柄任何时候不要在持锁状态下调用外部的回调函数因为你不知道回调里会做什么。如果回调里也要锁死锁随时可能发生。还有一种工程技巧是避免长时间持锁。比如把耗时的计算、网络请求、文件写入等操作移到锁外面锁只保护共享变量本身的修改。这能显著减少锁竞争和死锁概率但需要你仔细分析临界区范围不能机械地在函数开头加锁。5.3 锁的粒度性能与安全的平衡艺术锁的粒度太粗性能差太细代码复杂、容易遗漏保护点。这里的“度”需要根据访问频率、临界区执行时间、并发线程数来权衡。一般来说临界区执行时间小于 1 微秒的用细粒度锁收益明显如果临界区本来就要跑几毫秒加锁优化与否其实差别不大瓶颈在临界区本身。我经常跟团队说第一版代码先加粗粒度锁保证正确性确认功能没问题后再用 profiler 量一下热点确实有锁竞争再细化。不要一开始就设计 100 把锁的复杂方案最后却搞不清哪个字段该由哪把锁保护。热点锁的优化也可以考虑“锁分段”和“无锁结构”。前者对应上面的分区数组后者则像std::atomic、读写队列等。但无锁编程需要更深的功底工程上越是强调稳定性的系统越应该保守。宁可要 90% 的性能 100% 的正确性也不要 99% 的性能 一周一次的诡异崩溃。6. 线程安全设计从数据封装到无锁思路6.1 不要暴露裸数据接口级线程安全编写线程安全代码的第一原则不是“在每个地方加锁”而是“把共享数据封装起来让外部只能通过同步接口访问”。如果外部能直接拿到内部容器的引用或指针锁就形同虚设因为别人完全可以在锁管理范围之外操作数据。拿线程安全队列来说对外只暴露push()和pop()内部用互斥锁保护真正的容器。这样调用方不需要关心锁的逻辑天然就不容易犯错。相反如果你直接把std::queue的引用传给多个线程即使你反复强调“先加锁再操作”迟早也会有人忘记。这里顺带提一下“线程安全 list”这类问题。标准库容器本身不是线程安全的std::list、std::vector都是如此。如果在多个线程里同时读写同一个容器必须自己加锁或者使用明确线程安全的容器。C 标准库并没有提供开箱即用的线程安全容器需要自己封装或者用第三方库。6.2 线程本地存储与不可变性从源头上减少共享同步是“事后擦屁股”真正的优雅方案是“从设计上不共享”。两种常用手段是thread_local线程本地存储和不可变数据。thread_local变量每个线程拥有独立实例天然不存在竞争。它的典型用途是线程池中的每个工作线程持有一个独立的任务上下文、随机数种子、连接池对象等避免每次取用都打全局锁。我写网络服务时每个线程一个独立连接池压测性能直线上涨原理就是完全消灭了竞争。不可变数据的思路更简单对象一旦创建内部状态永不改变。既然不可变就不需要加锁。这种思路在函数式编程里很常见在 C 中可以用const “成员变量只读且没有mutable修改路径”来实现。比如一条配置项解析完成之后就只读不写多个线程随便并发读。6.3 无锁编程的实用边界无锁编程听起来很高级实际上它的核心就是“用原子操作替代锁”配合精心设计的内存序和链表操作实现无阻塞的并发访问。经典实现有无锁栈、无锁队列如boost::lockfree::queue。但无锁不是免费的午餐它最大的问题是“难写、难调、难证明”。即使是业界大佬也无法轻易判断一段无锁代码在另一个平台上是否仍然正确。你可能会遇到 ABA 问题、内存回收问题、内存序不一致问题每一个都足以让你排查几天。我目前的态度是嵌入式或高性能中间件场景如果确认是核心热点才考虑使用成熟的无锁库而不是自己手写。业务系统、UI 线程、普通服务用互斥锁 细粒度分区已经足够了。记住一句话性能优化是“先测量再优化”不是“先炫技再填坑”。7. 线程安全设计与常见问题排查实录7.1 检测数据竞争的工具数据竞争是最让人头疼的并发 bug因为它的出现往往依赖特定的时序不是每次运行都会触发。好在有工具可以帮忙。C 开发者可以重点了解一下 ThreadSanitizer在编译时加上-fsanitizethread运行程序时它会自动检测数据竞争。我在项目里就靠 TSan 抓到过一个隐晦的问题一个全局配置对象在程序启动时由主线程初始化之后由多个工作线程只读访问。理论上没问题但某个版本中新增了一个“运行时刷新配置”的功能把config_ newConfig写到了主线程虽然工作线程只读旧对象但指针变量本身被并发读写已经有了数据竞争。这种边界场景人眼很难发现TSan 一跑就报出来了。Java 开发可以试试使用 javac 配合适当的 JVM 参数或者借助一些静态分析工具。不过最有效的方法仍然是在高并发压测环境下运行压力测试同时开启 sanitizer不能只靠代码评审。7.2 线程问题排查和调试技巧线程程序的调试比单线程麻烦不少。这里分享几个我实际用过、并觉得有效的方法。第一学会记录日志和利用断言。代码里多打一些“进入临界区”“释放锁”之类的日志问题发生时能迅速定位到是哪个线程卡在哪里。第二使用 debugger 的线程面板。比如在 IDE 里挂起所有线程查看每个线程的调用栈如果某个线程长时间停在lock()处那它很可能在等待一把被其他线程持有的锁。第三如果有崩溃转储文件可以直接查看所有线程的栈定位死锁或空指针异常。特别提一下调试工具的一个常见现象有时你在 IDE 里给子线程代码设了断点但运行后断点没有触发。这通常是因为 IDE 设置了“只挂起主线程”或者调试器没有正确识别子线程创建事件。解决办法是在调试设置里把“挂起所有线程”打开并确认子线程确实启动成功。这类问题跟业务逻辑无关但排查起来也费时写在这提醒一下。7.3 排查速查表为了方便大家在实际开发中快速定位问题我整理了一张速查表。当然每个项目的代码结构不同遇到问题还是要结合具体上下文试错务必从“最可能的原因”开始避免大海捞针现象可能原因排查方向程序偶尔结果不对竞态条件检查共享变量是否加锁开启 TSan线程卡住不退出死锁或条件变量漏通知查看线程栈检查锁顺序性能急剧下降锁竞争激烈或锁粒度过大用 profiler 找热点细化锁粒度数据读到旧值可见性问题检查是否用了原子变量或内存屏障子线程断点不停调试器配置问题挂起所有线程确认子线程创建被识别多线程写同一个容器崩溃容器非线程安全封装容器并加锁或换线程安全容器8. 写在最后我的几点体会学完这一章我最大的感悟是并发编程不是“多用锁”的艺术而是“少共享”的艺术。很多看似高深的并发问题只要在设计阶段把数据边界划清楚让每个线程只拥有自己的局部数据问题就消失了一半。剩下的再靠锁、条件变量、原子操作去兜底。在团队协作中我一般会强烈建议每个线程函数尽量保持“输入清晰、输出明确”的风格访问共享数据必须走接口绝不直接暴露内部容器。这样做代码虽然多写了一点但维护起来的幸福感是成倍增加的。最后做一个小练习建议把你手头正在用线程的模块拿出来画一张“线程与共享数据”的对应关系图标出每份共享数据被哪些线程读写、通过什么机制保护再开 TSan 跑一遍压测。结果大概率会让你后背发凉——但也正是这一步能让你真正理解这一章所有的知识。
返回列表