资讯详情

资讯详情

Linux多线程数据竞争:互斥锁与原子操作选型实战

前阵子我负责的一个统计服务又出问题了。同样的输入数据8个线程并行处理结果每次跑出来都不一样有时候差几条有时候差几百条。一开始怀疑是上游数据有误排查了好几天最后用ThreadSanitizer工具一跑问题清清楚楚数据竞争就发生在最普普通通的count这一行上。这种问题在Linux下的多线程程序里太典型了——它不报错、不崩溃只会让结果在概率上出错而且越到高并发、多核环境下越难复现。这篇文章就是想把互斥锁和原子操作这两把处理数据竞争的刀讲透它们分别解决了什么问题、底层是怎么实现的、实际场景里该怎么选、以及我在调试过程中踩过的那些坑。如果你是Linux服务端开发、C/C多线程入门的同学或者写Java/Python时对多线程同步似懂非懂都可以从中找到对应的参照。我会尽量用大白话加实测数据来讲保证你看完能直接用到项目里。1. 一个看似无辜的count是怎么引发数据竞争的1.1 从一段“正确”的代码说起先看一段我当年特别自信地写进代码库里的逻辑// bad_counter.c #include pthread.h #include stdio.h #define THREAD_NUM 8 #define LOOP_COUNT 100000 int counter 0; void* worker(void* arg) { for (int i 0; i LOOP_COUNT; i) { counter; // 就这一行 } return NULL; } int main() { pthread_t tids[THREAD_NUM]; for (int i 0; i THREAD_NUM; i) { pthread_create(tids[i], NULL, worker, NULL); } for (int i 0; i THREAD_NUM; i) { pthread_join(tids[i], NULL); } printf(counter %d, expected %d\n, counter, THREAD_NUM * LOOP_COUNT); return 0; }编译运行counter最后等于多少理论上应该是800000实际上你跑十次十次结果可能都不一样比如某一次我跑出532118某一次跑出611009没有一次能对上800000。单线程下这代码完全没问题人畜无害一旦进了Linux多线程环境就变成了一颗概率炸弹。1.2 一条在CPU眼里其实是三条指令为什么counter会出错因为它在CPU那里根本不是“一步”完成的而是要拆成三步把counter当前的值从内存读到寄存器在寄存器里加1把寄存器里的新值写回内存。当8个线程同时执行这三步时调度顺序完全由操作系统决定这个顺序是不可预测的。假设两个线程A和B同时操作counter初始值是10A执行完第一步读到10B也执行完第一步也读到10然后A加1写回变成11B再加1写回还是11因为它手里的值是10加出来的——两次操作结果只增加了1这就是典型的丢失更新lost update。用个生活化的类比两个同事共用一个纸质记账本协约是“先看余额、再改余额”。A看了一眼余额是100转身去拿笔B也看了一眼余额是100先写上新余额101。等A回来他也写上101——明明两个人各存了一笔钱账上却只多出1块。问题不是谁粗心而是“读旧值、改新值”这个完整动作没有被保护成一个整体。1.3 数据竞争的标准定义C/C标准里对数据竞争data race有非常严格的定义两个或两个以上线程同时访问同一块内存其中至少有一个是写操作并且它们之间没有任何同步机制锁、原子操作等来规定先后顺序。这里的关键是“同时”但不是指“同一纳秒”而是指访问顺序没有被明确定义。只要出现了数据竞争程序行为就属于 undefined behavior未定义行为编译器可以做出任何优化这可能带来远超“结果不对”的诡异后果。这也是我在实际排查中体会最深的一点数据竞争问题不能靠跑测试来验证。你跑100次可能都正常第101次崩了单核环境永远复现不了文件一提交CI却偶尔挂。这就是为什么必须在写代码时就理解它的成因而不是等出了问题再去“概率性复现”。2. 互斥锁的完整使用闭环加锁、解锁与那些吃过的亏2.1 从 pthread_mutex 到 RAII 的写法演变解决数据竞争最直接的办法就是给共享变量的“读改写”过程加锁。Linux下POSIX线程库提供pthread_mutex_t基本用法是所有多线程教材都会教的套路// mutex_counter.c #include pthread.h pthread_mutex_t lock PTHREAD_MUTEX_INITIALIZER; int counter 0; void* worker(void* arg) { for (int i 0; i LOOP_COUNT; i) { pthread_mutex_lock(lock); counter; pthread_mutex_unlock(lock); } return NULL; }这么一改counter的最终结果就能稳定等于800000。但这段代码有个非常危险的隐患如果counter和unlock之间出现异常、提前 return锁就永远不会释放其他线程全部卡死。所以我在实际项目里几乎不用裸的lock/unlock而是用C的RAII封装让锁的生命周期和变量作用域绑定// mutex_counter.cpp #include mutex std::mutex mtx; int counter 0; void worker() { for (int i 0; i LOOP_COUNT; i) { std::lock_guardstd::mutex guard(mtx); // 构造时加锁析构时自动解锁 counter; } }这也是我给团队新人的第一个强制要求能用RAII就用RAII别手写 unlock。lock_guard这种写法在counter抛异常、函数提前返回时都能保证解锁从根源上规避了“锁泄漏”问题。Java里的synchronized、Python里的with threading.Lock()也都是同样的思路。2.2 锁的粒度锁定范围比是否加锁更重要互斥锁的核心思想是“串行化”同一时刻只允许一个线程进入临界区。那是不是把整个函数都锁上就万事大吉了不是。锁的范围越大并发度越低性能越差甚至可能引入死锁。我见过一个真实案例某个模块把读配置文件的整个解析流程都锁住了结果高并发下这个模块成了全系统瓶颈QPS直接掉了一半。后来改成“只锁共享的解析结果不锁读取文件本身”性能立刻回来了。关于粒度我的经验总结是三条临界区要尽量小只包住“读旧值、修改、写回”这种必须原子的动作别把日志打印、网络IO、磁盘操作也包进去临界区里不要调用未知函数你不知道它内部会不会再次加锁这会导致死锁锁保护的是数据不是代码所有访问同一份共享数据的路径必须使用同一把锁哪怕那条路径只是“读取”也要加锁否则一个线程写、另一个线程读依然会产生数据竞争。2.3 死锁的四个必要条件与锁顺序死锁是互斥锁使用中最经典、也最容易在复杂项目里翻车的坑。两个线程各自持有一把锁同时等待对方释放另一把锁程序就永远卡住了。死锁发生需要四个条件互斥、持有并等待、不可剥夺、循环等待。实际代码里最常见的触发点是“多把锁互相嵌套”。规避方法也很简单就一条硬性规则所有线程按照同一个全局顺序加锁。比如有两把锁lock_a和lock_b规定必须先拿lock_a再拿lock_b所有代码都这么写循环等待就不可能发生。我早期在一个多线程插件系统里调试过一个卡死问题两个模块各自维护一把锁在交互调用时互相等待最后只能用XML堆栈分析一点点理清加锁顺序。从那以后我对每个涉及多锁的模块都会画一张“锁顺序表”放在代码注释最前面谁改谁负责。2.4 锁的隐藏开销系统调用与上下文切换互斥锁不是免费的。很多刚接触多线程的人以为加锁只是一条普通指令实际上在Linux下一个互斥锁在产生竞争时会触发futex系统调用线程被放进等待队列挂起等锁释放再被唤醒——这一切都涉及上下文切换代价非常昂贵。我实测过一个大致的量级不同机器会有差异但数量级可以参考无竞争的单次加锁/解锁大约20~50纳秒一旦发生竞争涉及挂起唤醒的锁操作可能飙升到几微秒甚至几十微秒相当于执行几千条普通指令。这也引出后面一个重要思路如果共享数据的访问是高频且轻量的锁可能是“杀鸡用牛刀”原子操作会更合适。3. 原子操作何时能替代互斥锁从硬件CAS说起3.1 C11std::atomic和GCC内置原子函数先说结论对于“计数器累加”“标志位翻转”这类简单操作原子操作常常比互斥锁更合适。C11 提供了std::atomic把任意类型的“读改写”操作包装成硬件级别的原子指令// atomic_counter.cpp #include atomic std::atomicint counter{0}; void worker() { for (int i 0; i LOOP_COUNT; i) { counter.fetch_add(1, std::memory_order_relaxed); // 原子自增 } }如果你还在用纯CGCC也提供了内置原子函数比如__atomic_add_fetch(counter, 1, __ATOMIC_RELAXED)或者老一点的__sync_fetch_and_add(counter, 1)效果差不多。这些函数最终会编译成一条带lock前缀的CPU指令直接在硬件层面保证“读—改—写”三步不被其他核心打断。3.2 CPU是如何保证原子性的原子操作的底层原理要从CPU指令说去。x86架构对内存原子性有一套完整机制对于对齐的单条内存读写指令CPU硬件本身就保证不会撕裂对于read-modify-write这种复合操作则通过lock前缀来实现。早期CPU用总线锁执行lock指令时锁住前端总线其他核心无法访问内存。后来总线锁代价太大现代CPU改成了缓存锁利用缓存一致性协议比如MESI在锁粒度上细化只锁住目标缓存行其他核心如果碰同一块数据就要等待。无论哪种方式对程序员来说效果都是一样的要么这条指令完整执行要么它看起来就没执行不存在中间状态。更有趣的是互斥锁本身也是基于原子操作实现的。pthread_mutex_lock内部也要靠一条类似“试锁”的原子指令来抢占锁所有权抢不到才走系统调用挂起。所以从层级上看硬件原子指令是地基互斥锁是盖在地基上的高级建筑。3.3volatile为什么救不了数据竞争聊到原子操作总有人会问我用volatile声明变量是不是也能解决并发问题答案很明确不能。volatile的作用是告诉编译器“这个变量的值可能在外部改变不要把它缓存到寄存器里优化掉”它只防止编译器的优化不提供CPU层面的原子性。一个volatile int的自增依然会被编译成“读、加、写”三条指令线程交错时一样会丢失更新。用句话概括就是volatile管的是“编译器别偷懒”原子操作管的是“硬件别打断”。两者解决的问题完全不同并发安全一律交给原子操作和锁别指望volatile。3.4 锁与原子操作的分界线到底在哪那是不是所有锁都能换成原子操作当然不是。原子操作适合的是单次内存访问级别的简单操作自增、自减、交换、布尔标记、引用计数。一旦临界区里需要执行多条语句、多个共享变量的修改必须保持整体一致原子操作就不够了因为它无法把“多个独立的内存操作”打包成一个不可分割的单元。打个比方原子操作相当于“一次只能锁一个抽屉”互斥锁相当于“能够锁住整个房间”。如果你只是从抽屉里拿一个独立文件锁抽屉就够了如果你要在房间里做整理动作——先搬A桌子、再移B椅子——就必须锁整个房间。所以我的选型逻辑很朴素先看临界区是“一条指令能完成”还是“一组动作需要一致”。前者优先原子操作后者使用互斥锁。实际项目中引用计数器和各种统计指标我基本全用原子变量业务数据的修改我全用互斥锁。4. 实测对照同一个计数器在锁与原子操作下的差距4.1 测试环境与测试代码光讲原理不够我做个直接的实测。测试机是某台普通的4核Linux服务器方法是用8个线程同时对同一个计数器自增循环次数设为1000万次分别用互斥锁保护、std::atomic原子自增两种方案各跑5次取最短耗时。核心代码如下完整版我把日志和初始化代码省略了只保留关键部分// mutex_vs_atomic.cpp #include atomic #include mutex #include thread #include vector #include chrono const int THREAD_NUM 8; const long LOOP_COUNT 10000000L; long g_mutex_counter 0; std::mutex g_mutex; std::atomiclong g_atomic_counter{0}; void mutex_worker() { for (long i 0; i LOOP_COUNT; i) { std::lock_guardstd::mutex lock(g_mutex); g_mutex_counter; } } void atomic_worker() { for (long i 0; i LOOP_COUNT; i) { g_atomic_counter.fetch_add(1, std::memory_order_relaxed); } }4.2 测试结果与解读跑了5轮之后单次最短耗时大致如下方案最终结果单轮耗时约相对耗时互斥锁std::mutex80000000正确2.9秒约为原子版的5.2倍原子操作std::atomic80000000正确0.56秒基准这个结果非常有代表性。8个线程疯狂抢同一把锁时绝大多数时间都花在了“被挂起、被唤醒”的上下文切换上真正干活的时间反而很少而原子操作通过一条CPU指令解决战斗虽然竞争时也会出现缓存行争用但不需要内核介入开销低了一个数量级以上。这也是为什么Linux内核里很多统计计数都用原子操作而不是锁比如网络协议栈中的各种包计数器和字节计数器。高频率、简单、允许丢失中间过程只求最终一致——这些特征决定了原子操作是更优雅的解法。4.3 竞争程度不同结论可能完全反转不过上面这个对比有个前提竞争非常激烈。如果换一个场景——每个线程在自己的局部变量里计算很久偶尔才更新一次全局状态锁的开销占比就会小很多甚至因为代码更简单可读反而更合适。举个极端例子如果一个共享日志队列每秒钟才被写入一次用锁完全没有性能压力而如果用原子操作去维护一个复杂的数据结构状态代码会变得非常难维护还容易出错。所以选型不能“一刀切”要看两个指标访问频率有多高、临界区有多复杂。频率高、操作简单 → 原子操作频率低、操作复杂、需要一致状态 → 互斥锁。我在另一个高并发服务里就做过一次这样的重构把原来所有用锁保护的统计计数全部换成原子变量整体吞吐提升明显而且正确性一点没受影响。但同一批改动里我试图把一个“更新两个关联字段”的操作也改成原子操作就写出了非常丑陋的CAS循环最后改成锁反而更清晰。那个经验告诉我不要为了炫技而用原子操作合适才是唯一标准。5. 再往前一步伪共享、内存序与其他语言的多线程5.1 伪共享一个比数据竞争更隐蔽的性能杀手当你解决了数据竞争、开始优化性能时第一个遇到的坑多半是伪共享false sharing。它不导致结果错误但会让你的原子操作慢得离谱。CPU不是按字节读写内存的而是按缓存行cache line加载常见大小是64字节。如果两个不同的变量恰好落在同一个缓存行里并且由两个不同的核心分别频繁修改那么即使它们逻辑上毫无关联缓存一致性协议也会让两个核心不断互相通知“这个缓存行脏了”互相等待刷新。明明是两个无关变量却表现得像是在竞争共享数据。我在一个标志位场景里中过招定义了两个bool变量分别控制两个独立任务为了提高效率特意用了原子操作结果8线程测试时发现两个任务互相拖慢。排查后发现这两个bool紧挨着位于同一缓存行。解决办法很简单用alignas(64)把关键变量对齐到单独的缓存行struct alignas(64) PaddedCounter { std::atomiclong a; // 后面的填充自动补满64字节a独占一个缓存行 };改造之后两个原子变量不再互相“打架”性能立刻上来了。如果你在用线程池跑大量并行任务并且对共享数据做了精细切分一定要警惕伪共享。这是并发代码优化中最容易被忽略的隐藏成本。5.2 内存序大多数时候用默认值就够了使用std::atomic时memory_order参数是另一个容易让人困惑的点。C11 提供了memory_order_relaxed、memory_order_acquire、memory_order_release、memory_order_seq_cst等选项。新手看文档很容易懵我到底该选哪个我的建议很务实默认用memory_order_seq_cst顺序一致跑通正确性后再考虑优化。seq_cst是“最容易不出错”的模型它保证所有线程对所有原子操作的观察顺序一致逻辑上最符合直觉。relaxed只保证原子性、不保证顺序适合纯粹的计数比如上面的累加测试acquire/release用于在无锁编程里传递数据依赖但那是另一个复杂度层级了。实际项目里能用锁解决的业务逻辑就别上无锁真到了需要无锁的地基段再用acquire/release这种高级内存序。我自己的经验是95%的业务代码用seq_cst或者relaxed就够了剩下5%的场景才值得花时间去推演内存序。5.3 Java和Python里的多线程同步如果你不是在C/C环境而是搜到这篇文章顺便想看看Java或Python该怎么办这里也做个简单对照。Java里synchronized相当于互斥锁AtomicInteger相当于原子操作ReentrantLock则更接近手动锁。Java的内存模型JMM把volatile设计成了“可见性保证”但它和C的volatile语义不同Javavolatile可以保证读写的顺序可见性却依然不能保证i原子性——所以Java里写过volatile int i; i;同样翻车得用AtomicInteger。Python则比较特殊因为全局解释器锁GIL的存在CPython的多线程在同一个进程里实际上同一时刻只有一个线程在执行Python字节码所以纯Python代码里的count 1在GIL保护下不会因为指令交错而丢失更新。但如果进入C扩展层、或使用多进程、或涉及阻塞式的IO释放GIL数据竞争依然真实存在。因此Python多线程编程里threading.Lock照样是重要工具只是它解决的核心问题从“字节码交错”变成了“IO等待期间的共享状态维护”。5.4 我个人的实际选择习惯最后说点我的个人习惯。每次接到一个Linux下的多线程模块我不会一上来就写代码而是先做两件事梳理共享数据的清单给每个共享变量标注“访问频率”和“写操作复杂度”。高频简单的用原子操作低频复杂的用互斥锁先保证正确性再通过性能观察决定是否把高频路径上的锁改成原子操作或更细粒度的锁。调试工具方面ThreadSanitizerTSan和valgrind --toolhelgrind是我排查数据竞争的左膀右臂前者在编译时加-fsanitizethread就能用CI上建议直接开启后者适合不需要重编译的场景。这些工具只能帮你定位到位置真正理解数据竞争的成因、锁和原子操作的取舍还得靠对底层机制的认识。数据竞争是Linux多线程开发里最常见的隐蔽问题但也是完全可以通过规范设计来避免的。搞懂互斥锁和原子操作各自的边界就像一个工具箱里分清扳手和螺丝刀的用途——用对了并发代码又稳又快用错了轻则性能难看重则线上事故。希望这篇文章能帮你少走几步弯路。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →