ARTICLE DETAIL

资讯详情

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

线程间共享数据实战:从数据竞争到互斥量、死锁与原子操作

线程间共享数据实战:从数据竞争到互斥量、死锁与原子操作 写多线程程序干了好几年我最怕听到的一句话是这个崩溃是偶发的。而偶发崩溃背后十次里有八九次是线程间共享数据出了问题。也是在被线上事故折腾了无数个通宵之后我才真正把线程间共享数据这一章从看过变成懂了一点。这篇归纳总结就是把那点真正有用的东西抽出来——不是什么高深理论而是能直接指导写代码的判断依据。适合刚学并发编程的人建立整体框架也适合已经有几年经验但被各种诡异bug折磨过的同学对照自查。1. 从一次线上事故说起共享数据的真实成本1.1 那个让我排查三天的崩溃bug事情是这样的一套任务分发系统单线程压测一切正常一旦切到8个线程并行处理运行几个小时之后必然出现一次奇怪的崩溃。崩溃点是访问一个被提前释放的对象但从代码逻辑上看对象生命周期明明是够的。我在崩溃现场翻来覆去看了很久最后通过排查调用链发现任务队列中存的不只是数据本体还有一个指向内部缓存的指针。两个线程同时从队列里取任务时一个线程正在修改缓存另一个线程读到了中间状态然后基于这个脏数据触发了对象释放。这就是最典型的线程间共享数据问题你明明在逻辑上保证了先入队再出队却忘了队里的指针指向的那块数据本身同时被多个线程操作。数据竞争一旦发生你的程序就不再是按代码逻辑运行而是进入未定义行为的领域——表现可能是崩溃可能是死循环也可能是在某个角落里默默算错一个数字直到未来某天才爆发。1.2 数据竞争为什么是未定义行为很多新手有个误解觉得数据竞争就是两个线程同时读写结果不确定而已。不对。在C的内存模型里只要两个线程同时访问同一块内存、至少有一个是写操作、并且没有同步机制约束先后顺序那么整个程序的行为就是未定义行为。注意是整个程序不只是那一行代码。编译器在优化时会把未定义行为当作永远不会发生来对待它可能把访问顺序重排、把变量缓存进寄存器、甚至干脆删掉某些代码分支。所以不要试图用sleep一下等它完成或者概率上不会撞上来侥幸过关。我见过太多人给共享变量加个volatile就觉得安全了实际上volatile在C里只是告诉编译器别优化这个变量的访问跟线程同步没有半点关系处理器缓存和指令重排它管不了。正确应对数据竞争的手段本质上只有两条路要么用互斥机制把访问串行化要么用原子操作加上内存序约束让访问变得有序。1.3 线程间共享数据到底在共享什么深入看线程间共享数据可以分成几类处理难度完全不同共享标量变量比如计数器、状态标志。这类最简单一个std::atomic通常就能搞定。共享容器比如队列、列表、哈希表。这类需要锁或者无锁容器难点在接口设计。共享对象不只共享数据还共享对象的行为和生命周期。这类最危险因为问题往往不在数据本身而在对象内部方法的交叉调用。日常项目里最难的永远是第三类。你以为自己在保护一个变量实际上你保护的是对象在某个瞬间的完整状态。这也是为什么我下面会反复强调一个原则处理好共享数据不是在给单个变量加锁而是在给状态的不变量加锁。2. 互斥量最可靠但最容易被用错的保护手段2.1 互斥量的本质是锁住代码段而不是锁住数据刚学的时候我也犯过这个错误以为把互斥量跟变量绑在一起声明变量就安全了。比如下面这种std::mutex mtx; int shared_value;很多人会下意识认为mtx和shared_value是一伙的但编译器没有这种绑定关系。真正的保护逻辑是所有访问shared_value的代码路径都必须先锁定mtx。换句话说互斥量保护的是代码段临界区数据本身只是一块普通内存。只要有一条路径忘了加锁你加的锁全部白费。所以我在工程里更推荐的做法是把数据和锁一起封装进类里对外只暴露安全的方法class Counter { public: void increment() { std::lock_guardstd::mutex lock(mtx_); value_; } int get() const { std::lock_guardstd::mutex lock(mtx_); return value_; } private: mutable std::mutex mtx_; int value_{0}; };注意这里get()是const方法但mutex被声明为mutable因为锁本身不是对象状态的逻辑组成部分却必须在const方法里被修改。这种封装的意义在于从类设计上杜绝忘了加锁这种低级错误。2.2 std::lock_guard与std::unique_lock的使用边界标准库提供了两种最常用的RAII锁std::lock_guard和std::unique_lock。std::lock_guard是轻量级的构造时加锁、析构时解锁适合锁一段代码然后整个作用域都别解锁的场景。大多数时候用它就够了因为它开销最低、语义最清晰。缺点是不支持手动解锁和延迟加锁。std::unique_lock功能更全面可以延迟加锁、手动解锁、用于配合条件变量wait必须在内部反复解锁/加锁。但这份灵活性也有代价对象本身更大、操作更多略微影响性能。所以我的原则是能用lock_guard的地方不要用unique_lock需要unique_lock的地方主要是条件变量和复杂锁策略才用它。还有一个经常被忽略的点unique_lock支持移动语义可以像函数返回值一样把一个锁的所有权转移出去。这在某些需要解锁后处理耗时操作的场景很有用。先用锁保护好共享状态取到数据后立刻unlock()再用局部变量做后续计算。2.3 把数据关进牢笼类封装与接口设计正确的共享数据设计应该像银行柜台客户不能自己进金库只能通过窗口办事。对应到代码里就是几个铁律不要把受保护数据的指针或引用返回出去。哪怕你返回前加了锁调用方拿到引用后后续使用就不受锁保护了。不要在持锁状态下调用外部函数。你根本不知道外部函数会不会去尝试获取同一把锁或者会执行多耗时的操作这会放大锁竞争甚至引入死锁。临界区越短越好。只锁访问共享数据的那几行代码不要把整个业务逻辑都锁进去。第一条最容易被违反。比如你给队列写了个front()返回引用然后调用方拿着这个引用去做一系列判断中途另一个线程修改了数据问题立刻出现。正确做法是像下面这样把取数据修改打包进一个原子操作bool try_pop(T out) { std::lock_guardstd::mutex lock(mtx_); if (queue_.empty()) return false; out queue_.front(); queue_.pop(); return true; }2.4 我见过最典型的几个错误用法分享几个真实代码里踩过的坑都是血泪错误一用锁保护了一个变量但内部缓存/指针指向的内存没保护。最典型的例子就是开头说的任务队列存指针。锁只保证了队列本身的push/pop没问题却保证不了指针指向的对象在其他线程里安全。遇到这种要么把指针指向的对象也纳入锁保护范围要么改用std::shared_ptr配合引用计数避免提前释放。错误二把锁放在循环外面结果整个耗时处理过程都锁着。一个线程长时间持锁其他线程全部阻塞程序吞吐量直线下降。如果有人还在这期间做了网络请求那基本就是事故现场。错误三在加锁状态下调用了回调函数。回调可能是上层业务注册进来的内部完全不可控一旦它尝试获取同一把锁就是死锁。就算不死锁也会把锁的持有时间拉长。错误四一个类里有多个互斥量但传入外部引用后又从外部操作。锁的归属必须和管理的数据强绑定一旦外部拿到的是裸数据保护边界就破了。3. 死锁四个条件、两种解法、一次真实复现3.1 死锁的四个必要条件死锁这东西说起来就是四个条件同时满足互斥资源一次只能被一个线程占用、持有并等待线程拿着一个锁还去等另一个锁、不可剥夺锁不能被外界强行抢走、循环等待多个线程之间形成等锁环路。实际排查时一个环路上四个条件缺一个都不会死锁。我早期写代码时有个错误认知以为死锁很罕见。实际上在复杂业务里锁一多、代码路径一复杂死锁一点都不罕见。尤其是两个人维护同一套代码时A加的锁顺序和B加的锁顺序不一致死锁就很容易被偶发触发。低并发时两个线程恰好没同时抢到锁高并发一来问题立刻爆发。3.2 固定加锁顺序与std::lock最简单有效的死锁规避是所有需要多个锁的地方都按同样的顺序加锁。只要全工程约定了先加锁A再加锁B永远不反过来循环等待就不存在。道理很简单但落地难——团队里总有人不遵守约定而且调用层级一深顺序就藏不住了。于是就有了std::lock这个一次性锁多个互斥量的工具。它内部会做一些规避避免在先锁A再锁B的时候因为B已被占用而卡死。典型用法class Account { public: void transfer(Account other, int amount) { std::lock(mtx_, other.mtx_); std::lock_guardstd::mutex lock1(mtx_, std::adopt_lock); std::lock_guardstd::mutex lock2(other.mtx_, std::adopt_lock); // 安全地操作两个账户 } };注意这里用了std::adopt_lock告诉lock_guard锁已经拿到了你负责持有并在析构时释放就行。千万不要在std::lock之后再对同一把锁调用lock那是未定义行为。不过说实话std::lock也不是银弹。它能解决同时拿多把锁的原子性问题但如果业务里还涉及先解锁一把再等另一把这种动态锁序它帮不上忙还是得靠高层级的锁顺序约定。3.3 层级锁工程上更系统的做法比固定顺序更进一步的做法是层级锁给每一把锁设定一个层级编号规则是持有高层级锁时不允许再申请低层级锁。这相当于用代码强制检查了加锁顺序一旦违反规则直接抛异常或者断言失败。层级锁的思想很简单把锁按业务模块的依赖关系排列模块A依赖模块B那么A的锁层级必须比B高或者低整个工程只遵循一个方向。我自己实现过一个简易层级锁大概逻辑是维护一个线程本地变量记录当前线程已持有的最高层级加锁前检查目标锁层级是否违反顺序。这种做法能快速暴露隐藏在深层调用里的锁序问题比等到线上死锁再排查划算得多。3.4 死锁排查的实用工具与思路真到了死锁现场第一件事永远是保留现场。Linux下可以用gdb查看所有线程的栈重点看等待锁的栈帧或者用pstack抓取线程栈结合/proc/pid/stack分析。Windows下可以用WER或者Visual Studio的并行堆栈窗口。更系统化的工具是AddressSanitizer配合ThreadSanitizerTSan尤其是TSan它在检测数据竞争和死锁方面非常强大缺点是运行时开销大一般只用于测试环境。但工具只是辅助排查思路才是核心。我的习惯是从最后一个异常任务的调用链往前倒找出它在等哪把锁那把锁被谁持有持有者又在等谁。顺着这个链条画出来死锁环路基本一目了然。最重要的还是预防——定好锁顺序、缩小临界区、减少同时持锁数量把这些做成代码评审时的审查项死锁在代码阶段就能被拦住大半。4. 条件变量与线程间通知把轮询变成事件驱动4.1 为什么需要条件变量互斥量解决了不能同时访问的问题但没解决我要等某个状态变成true的问题。最原始的方案是轮询std::lock_guardstd::mutex lock(mtx); while (queue.empty()) { lock.unlock(); std::this_thread::sleep_for(1ms); lock.lock(); }这条路能跑但浪费CPU、延迟还高。条件变量就是为了解决这种场景一个线程等待某个条件另一个线程在条件变成true时发通知唤醒它。4.2 wait的底层逻辑与虚假唤醒先看标准用法std::condition_variable cv; std::mutex mtx; std::queueint data_queue; void producer(int v) { { std::lock_guardstd::mutex lock(mtx); data_queue.push(v); } cv.notify_one(); // 注意通知时不需要持锁 } void consumer() { std::unique_lockstd::mutex lock(mtx); cv.wait(lock, [] { return !data_queue.empty(); }); int v data_queue.front(); data_queue.pop(); lock.unlock(); // 处理v }wait看起来简单内部动作其实很多把锁解除、挂起线程等待唤醒、唤醒后重新加锁、检查谓词。如果谓词不满足再次解锁挂起。所谓被唤醒时不代表条件一定成立这就是虚假唤醒spurious wakeup。系统层面有多种原因但你不必深究只需要知道一个应对方法永远在wait的谓词里检查真正的条件而不是醒来后不加判断直接往下走。用带谓词的wait(lock, pred)重载就是标准答案。这里也解释了为什么wait需要的是unique_lock而不是lock_guard——wait中间要反复解锁和加锁必须用支持这些操作的unique_lock。4.3 notify_one还是notify_all绝大多数情况下notify_one就够了。它只唤醒一个正在等待的线程开销小。但有一个隐患如果你有多个线程在等待不同条件而你不知道哪个条件被满足notify_one可能唤醒一个谓词仍然不满足的线程它醒来后检查谓词发现不满足继续挂起然后其他真正该被唤醒的线程就没被通知到程序就卡住了。所以选择原则是每个条件变量通常只对应一个条件用notify_one如果多个消费者线程等待的谓词不同用notify_all。还有一种情况唤醒的那个线程读了队列头部处理完成后又触发另一个线程继续处理这种链式唤醒场景notify_one反而不是最优会退化成串行。此时要看吞吐量测试结果。4.4 条件变量的常见配套陷阱陷阱一通知丢失。如果producer在consumer进入wait之前就notify了而当时队列非空其实无妨因为consumer的wait谓词第一次检查就会通过。真正的风险是检查条件-进入wait之间被插入了其他线程的操作。wait(lock, pred)把检查谓词和挂起打包在锁保护下就是为了避免这个竞态。陷阱二死锁等待。consumer持锁调用wait时锁会被释放但如果你在wait外面做了一些不该持锁的操作或者producer在push后依旧持锁、然后通知而consumer又在wait里拿不到锁就会永远卡住。所以记住通知者不持锁这是最稳妥的写法。不过话说回来notify时持锁也不一定出事只是带来不必要的竞争能避免就避免。陷阱三条件变量与互斥量生命周期。条件变量本身没有持锁的内存所以它比锁更轻量。但std::condition_variable要求配合的就是std::mutex如果你用的是std::shared_mutex得用std::condition_variable_any。5. 原子类型与无锁探索性能与正确性的较量5.1 自增运算为什么不是原子的value在高级语言里是一行但在CPU层面通常是读取→加一→写回三步。两个线程同时执行可能都在同一点读取到10然后一个写回11另一个也写回11结果少加了一次。这就是经典的数据竞争。有人会问加个volatile行不行不行volatile不会让读-改-写变成原子操作它只防止变量被编译器优化到寄存器解决不了多核同时操作的问题。所以标准库提供了std::atomicstd::atomicint counter{0}; void thread_increment() { counter.fetch_add(1); // 原子自增 }fetch_add是底层的原子读-改-写操作从硬件指令级别保证不会被其他线程打断。这个类就是为并发访问而生的无锁不阻塞线程。5.2 std::atomic到底做了什么事std::atomic底层依赖CPU提供的原子指令比如x86下的LOCK前缀指令或者CMPXCHG系列。它保证一个操作要么完全执行要么完全没执行中间状态对其他线程不可见。常见操作有load、store、fetch_add、compare_exchange_weak/strongCAS。CAS值得一提compare_exchange是如果当前值等于期望值就替换成新值否则不做任何事的原子操作。它是实现无锁数据结构和自定义并发算法的基石。但要注意CAS面对高竞争时会有活锁问题——多个线程不断重试虽然不死锁但可能让某个线程一直被挤出去。用得不好性能比锁还差。5.3 内存序acquire/release最简单的理解方式如果你只用std::atomicint做计数器默认的memory_order_seq_cst足够安全只是相对慢一点。一旦你用它来传递数据比如生产者把数据写入后设置一个ready标志消费者看到ready就读取数据就必须理解内存序。最常用的组合是release/acquire。简单理解release你写入这个原子变量之前的普通内存写操作必须对其他线程可见不能被重排到这次写入之后。acquire你读取这个原子变量之后的普通内存读操作能看到另一个线程在release之前写的值。举一个实际例子std::atomicbool ready{false}; std::atomicint result{0}; void producer() { result.store(42, std::memory_order_release); // 先写数据 ready.store(true, std::memory_order_release); // 再发通知 } void consumer() { while (!ready.load(std::memory_order_acquire)) {} // 这里可以安全地读取result等于42 int r result.load(std::memory_order_relaxed); }这里如果省掉内存序result的读取仍然是数据竞争因为普通变量没有被同步。加了release/acquire之后ready这个原子变量就像一道门只有看到门开了acquire到true你才能安全地走进门后的房间。还有更宽松的memory_order_relaxed它只保证原子操作本身不限制顺序适合纯计数器这种不需要传递数据的场景。内存序是个大坑日常开发里我建议能默认seq_cst就默认不要为了优化去碰relaxed/acquire/release除非你真正懂得内存模型并且有性能数据支撑。我见过太多人为了性能把内存序改成relaxed结果数据同步一塌糊涂最后全部改回默认。5.4 谨慎进入无锁编程无锁编程听起来很酷——不阻塞、没有锁竞争、不会死锁。但它的代价是极高的大脑负担无锁队列、无锁栈的生命周期管理、ABA问题、内存回收hazard pointer/epoch-based reclamation每一样都能让你头疼好几天。诚实的建议是先测量再决定。绝大多数业务场景下锁竞争并没有那么大用一个设计良好的std::mutex就够了。真正需要无锁的场景典型特征是临界区极短比如就是一个CAS、线程数很多、锁竞争导致CPU利用率上不去、你实测过锁版本确实是瓶颈。在没有性能数据支撑的情况下为炫技引入无锁结构通常会在项目后期变成维护噩梦。我自己只在两处用过无锁一个是高并发的日志计数器另一个是短消息的多生产者单消费者队列都是有明确测量结论后才敢动的。6. 锁粒度与接口设计性能和安全同时兼顾的经验小结6.1 锁的粒度怎么控制锁粒度是性能与安全之间最常见的平衡点。锁得太粗把整个处理过程锁住并发度急剧下降锁得太细每读一个字段加锁解锁又可能引入复杂的多次获取、一致性难保证。我的实践原则是让临界区刚好覆盖共享状态从读取到更新的完整阶段不多也不少。比如从队列取任务加锁、判断队列非空、取出任务、解锁。这就是最小临界区。如果你取出任务后还需要根据任务内容更新一个共享统计值那这部分要么也放进同一个临界区要么用第二个独立的小锁单独保护统计值而不是顺手把整个业务处理都包进去。判断标准只有一个会不会因为中途放开锁导致另一个线程修改了我们依赖的状态。6.2 共享数据接口设计的几个铁律把多线程环境和单线程环境对比最本质的区别是单线程里读—判断—写是天然原子的多线程里必须显式保证。围绕这个区别我把接口设计收敛成几条铁律所有共享数据的修改入口只能通过公开方法禁止暴露裸指针/引用。方法内部自行加锁不要在调用方加锁。调用方加锁会导致锁的泄漏——别人不知道你锁了哪把。不要在持锁状态下调用任何可能阻塞的代码IO、sleep、网络请求。单个方法只做一件事读或者写不要又读又写还要回调外部。优先返回快照值而不是引用。比如要设计一个共享配置表我倾向于返回一份std::shared_ptrconst Config而不是返回const Config。前者在锁内拷贝一个指向不变快照的指针调用方拿到后即使别的线程更新了配置也不影响它的读取非常干净。6.3 从读多写少到std::shared_mutex如果你的共享数据是典型的读多写少——比如配置表、路由表、缓存——用一把互斥量会白白牺牲读并发因为读操作之间本来可以并行。C17提供了std::shared_mutex支持两种锁模式共享锁std::shared_lock多个线程可以同时持有共享锁用于读场景。独占锁std::unique_lock只能一个线程持有用于写场景。用法也很直观std::shared_mutex rw_mutex; void read_config() { std::shared_lockstd::shared_mutex lock(rw_mutex); // 多个读者可以同时进来 } void update_config() { std::unique_lockstd::shared_mutex lock(rw_mutex); // 写者独占 }注意几点shared_mutex的写操作需要等待所有读者退出如果有写者长期无法获得锁会饿死标准库实现一般允许写者优先或读者优先视平台而定但这通常不是你需要操心的点。还有一个性能现实shared_mutex比普通mutex略慢因为它要维护读者计数。所以如果读操作非常短普通mutex可能反而更快读者大多数情况下会撞不上写者不需要额外开销。只有在临界区较长、读并发明显时shared_mutex才值得。6.4 最后分享一个我自己的工程习惯写了这么多年多线程代码踩了那么多坑我现在养成的习惯很简单每个涉及共享数据的类第一行注释就写清楚本类线程安全性由谁保证、锁的层级是多少、持有锁时禁止调用什么。这个注释在团队协作里价值极大因为它把隐式的约束变成显式的接口契约。代码评审时我会刻意挑那些返回裸引用、持锁调外部函数的代码那几乎都是雷区。还有一条任何锁相关的优化必须有性能数据作为依据。没有测量就不要去碰那些看似高深的并发技巧。也要记住线程间共享数据不是会加锁就完事的简单知识点。它真正考验的是你能不能把一个并发问题抽象成状态一致性问题然后选择最合适的同步原语。水平越高的人越知道怎么把共享降到最低。所以如果让我给刚接触并发的同学一个建议我的建议是先别急着学各种高大上的无锁和并发容器老老实实把互斥量、条件变量、原子类型这三种基本功打牢能解决你身边80%的真实问题。把这些工具用对了比什么花哨的技巧都管用。
返回列表