资讯动态

并发编程中的竞态条件与同步机制:互斥锁和信号量实战解析

发布时间:2026/9/11 2:40:51 来源:尧图企业网站定制
搞懂并发编程里的竞态条件是我学操作系统时一个很难迈过去的坎。表面上看代码没什么问题逻辑也顺可一跑起来结果就是不对而且每次跑的结果还不一样。后来才明白这就是竞态条件在捣鬼。要解决它就得靠同步机制而互斥锁和信号量就是其中最经典、最常用的两把武器。这篇文章就把我的理解、踩过的坑和实际跑过的例子整理出来给正在学并发编程或者准备面试的同学做个参考。1. 先理解竞态条件并发世界里最隐蔽的bug1.1 从一次计数器事故说起我第一次真正被竞态条件毒打是写一个多线程计数器。程序很简单4个线程同时对一个全局变量做100000次加1操作理想结果当然是400000。import threading total 0 N 100000 THREADS 4 def add_task(): global total for _ in range(N): total 1 threads [] for _ in range(THREADS): t threading.Thread(targetadd_task) threads.append(t) t.start() for t in threads: t.join() print(final total , total, expected , THREADS * N)我第一次跑的时候输出是final total 293451第二次是358923反正很少能看到400000。当时我的第一个反应是“Python是不是有bug”后来查了一圈才发现是我自己没搞懂并发执行的基本原理。问题出在total 1这行代码。很多人以为它是一步操作其实在底层它至少拆成三步读取当前值、把值加1、把新值写回去。当多个线程同时在执行这三步时线程A刚读完旧值线程B也读到了同一个旧值两人各自加1再写回结果只加了1次而不是2次。这就是典型的丢失更新。我后来用C语言也复现过同样的问题现象一样结果随机。这说明竞态条件不是某个语言特有的毛病而是并发编程的底层问题。哪怕Python有GIL只保证单个字节码指令的原子性也没法保证total 1这三步是连续的。1.2 竞态条件产生需要哪几个条件竞态条件不是平白无故出现的只要同时满足下面三个条件它就必然存在多个线程或进程并发访问同一个共享资源。至少有一个线程在对这个资源做写操作。这个“读-改-写”过程不是原子的中间可以被其他线程打断。你可以把共享资源想象成一张记分表几个裁判同时要往上加分。每个人都是先看一眼当前分数在心里加好再写回去。如果两个裁判在同一瞬间都看到了“10分”都觉得“该写成11分”最后表上就是11分而不是12分。两个人谁都没有做错计算但结果错了。问题出在“看”和“写”之间没有形成一个不可分割的整体。这也解释了为什么单线程程序永远不会有竞态条件。没有并发自然不会有人来打断你。只要引入多线程操作系统随时可能在线程执行的任意机器指令边界上做上下文切换这个切换点是随机的所以竞争的结果也是不确定的。竞态条件在真实系统里随处可见。最典型的是银行转账账户A扣款和账户B入账如果分两步执行中途另一个线程读取了中间状态那么账就对不上。还有电商秒杀里的库存扣减多个请求同时读到剩余库存为1都认为自己可以下单结果就超卖了。缓存系统中常见的缓存击穿、并发失效重建本质上也是竞态条件对共享状态的管理失控。2. 同步机制基石之一互斥锁2.1 加锁前后程序发生了什么知道了问题根源解决的思路也就清晰了既然问题出在“读-改-写”没有原子性那就想办法把这段操作变成原子操作不让其他线程插进来。互斥锁Mutex全称Mutual Exclusion就是干这个的。互斥锁的语义非常朴素同一时间只允许一个线程持有锁。想进入临界区被保护的代码段线程必须先尝试加锁如果锁已经被别人持有这个线程就停下来等待直到锁被释放。释放之后等锁的线程里会有一个被唤醒拿到锁继续执行。用互斥锁修正上面的计数器改动很小import threading total 0 mutex threading.Lock() N 100000 THREADS 4 def add_task(): global total for _ in range(N): with mutex: total 1 # 创建线程并执行过程与之前的示例相同 threads [] for _ in range(THREADS): t threading.Thread(targetadd_task) threads.append(t) t.start() for t in threads: t.join() print(final total , total, expected , THREADS * N)这次跑出来的结果每次都是400000。原因是total 1被with mutex包起来之后同一时刻最多只有一个线程能执行这三步操作。其他线程想进来只能在门口排队。这样就不会出现两个线程读到同一个旧值的情况。注意这里加锁保护的并不只是“1”这一个动作而是从读到写回这整个临界区。锁的作用是把临界区变成一个整体你可以把它理解成厕所的门锁进去的人把门反锁上外面的人再急也得等里面的人出来才行。2.2 锁的实现机制自旋、睡眠与硬件原子指令很多人会好奇锁本身是怎么实现的加锁和解锁也是操作为什么它们不会自己产生竞态条件答案在硬件层。现代CPU都提供了原子指令比如x86的lock前缀指令、xchg、cmpxchg这些指令在执行过程中不会被上下文切换打断操作系统内核或者线程库就基于这些原子指令来实现锁。常见的锁实现分两种。一种是自旋锁线程拿不到锁的时候不释放CPU而是原地循环检测锁是否可用。自旋锁的好处是没有线程切换开销适合锁持有时间非常短的场景。坏处是如果锁持有时间长线程一直在空转白白烧CPU。另一种是睡眠锁也叫阻塞锁拿不到锁的线程会被放入等待队列主动让出CPU等锁释放时再被唤醒。这种锁避免了CPU浪费但线程切换本身也有开销。操作系统中典型的互斥锁比如Linux的futex就是把两者结合先自旋一小会儿如果还没等到再进入睡眠等待。用C语言写多线程程序时最常用的接口是POSIX线程库里的pthread_mutex_lock和pthread_mutex_unlock。在操作系统课里还会要求你用test-and-set之类的硬件原子指令手写一个自旋锁做过一次之后就能体会到用户态一切看似简单的锁底层都离不开硬件和内核的配合。2.3 用互斥锁修复竞态为什么with比手动lock更安全回到Python示例。我一开始图省事写的是手动mutex.acquire()和mutex.release()结果中途有一次代码抛异常release()没执行其他线程全部卡死。从那以后我只要能用with就用with。它会在代码块结束或出现异常时自动释放锁避免忘记解锁的悲剧。这里也特别提醒一下加锁之后任何能导致提前返回、抛出异常的分支都要确保锁能被释放。C语言里这种问题尤其常见需要goto到统一的清理标签或者用RAII封装。Python虽然方便但如果你在某些底层代码里手动管理锁依然要小心。互斥锁还分可重入如Python的threading.RLock和不可重入的普通锁。普通锁同一个线程重复加锁会直接把自己锁死。可重入锁则允许同一个线程多次获取同一把锁内部有计数器每次释放减一。递归函数里加锁最好用可重入锁。2.4 互斥锁的经典陷阱死锁与锁粒度互斥锁用不好最大的麻烦就是死锁。经典的死锁场景是“两个线程互相等对方手里的锁”线程A持有锁1等着拿锁2线程B持有锁2等着拿锁1。谁也不让谁程序就彻底卡住。避免死锁有几个常用办法所有线程按同样的顺序加锁比如都先拿锁1再拿锁2破坏循环等待条件。使用trylock拿不到锁就释放自己手里的锁退避一下再重试。给加锁操作设置超时时间超时就不再等待。尽量减小锁的持有时间不把无关操作放进临界区。还有一个容易忽略的问题是锁粒度。锁加得太细临界区保护不全竞态条件依旧存在锁加得太粗比如把整个函数都包起来程序又退化成半串行并发性能大幅下降。正确姿势是只锁共享变量被读改写的那一小段锁内的代码越少越好不碰IO、不碰耗时的计算。3. 同步机制基石之二信号量3.1 信号量是怎么设计的互斥锁解决的是“这个资源只能同时一个人用”但现实中很多资源并不是只有一个。比如数据库连接池有10个连接线程池有8个线程这些资源允许多个使用者同时访问但数量有上限。这时候就需要另一种同步机制信号量Semaphore。信号量本质上是一个计数器配合两个原子操作使用。Dijkstra把它们命名为P操作proberen尝试和V操作verhogen增加现代接口里通常叫wait和signal或者acquire和release。简单说wait/acquire计数器减1如果计数器变为负数调用线程就会阻塞直到有其他线程执行signal。signal/release计数器加1如果之前有线程在等待就唤醒其中一个。计数器的初始值代表可用资源的数量。初始值为N就表示允许N个线程同时使用这个资源。第N1个线程来申请时会被挡在外面直到有人释放。信号量有一个非常经典的使用场景就是控制并发数。比如一个接口同时只能处理3个数据库查询超过的请求必须排队下面的代码用Python演示import threading import time import random # 只允许3个线程同时访问资源 semaphore threading.Semaphore(3) def access_db(worker_id): with semaphore: print(fworker {worker_id} acquired a connection) time.sleep(random.uniform(0.2, 0.8)) print(fworker {worker_id} released the connection) tasks [threading.Thread(targetaccess_db, args(i,)) for i in range(10)] for t in tasks: t.start() for t in tasks: t.join()运行结果能看出来不管怎么跑同时打印“acquired”的线程最多只有3个。另外7个任务要么在门口等着要么等前一个释放后再进入。这就是计数信号量的典型用法资源总量可控超过上限就排队。3.2 二值信号量和互斥锁有什么区别信号量如果把初始值设为1行为和互斥锁看起来就很像了同一时间只有一个线程能进入临界区。这种叫二值信号量。但“像”不等于“是”两者有本质区别。我把它们的差异整理成了表格维度互斥锁Mutex二值信号量所有权只有加锁的线程能解锁任何线程都能释放信号量使用场景保护临界区、共享数据互斥、事件通知、任务同步重入性普通互斥锁不可重入RLock可以不可重入优先级继承典型互斥锁支持可缓解优先级反转通常没有语义锁的是“代码段”控制的是“资源数量”最核心的区别就是所有权。互斥锁要求谁加的锁谁去解因为锁的业务含义是“你正在使用这片受保护的资源”。而信号量释放时不需要检查是谁acquire的纯粹是计数器加一。这个区别在生产者消费者模型里特别明显生产者往缓冲区放数据消费者取数据两边各自操作信号量根本不关心是谁最后调用了release。3.3 信号量的高级玩法生产者消费者模型信号量真正厉害的地方是可以同时用多个信号量来表达复杂的同步关系。最经典的例子就是生产者消费者模型。假设缓冲区最多能放5件商品生产者负责往里放消费者负责往外取。如果缓冲区满了生产者必须等待如果缓冲区空了消费者必须等待。用两个信号量就能表达这两条规则import threading import time from collections import deque buffer deque(maxlen5) EMPTY threading.Semaphore(5) # 还剩多少个空位 FULL threading.Semaphore(0) # 已经占用了多少个位置 def producer(): for i in range(10): EMPTY.acquire() buffer.append(i) print(fproduce item {i}) FULL.release() time.sleep(0.1) def consumer(): for _ in range(10): FULL.acquire() item buffer.popleft() print(fconsume item {item}) EMPTY.release() time.sleep(0.2) t1 threading.Thread(targetproducer) t2 threading.Thread(targetconsumer) t1.start() t2.start() t1.join() t2.join()这段代码里生产者每次生产前先申请“空位”信号量如果空位为0就阻塞生产完成之后释放“已占用”信号量通知消费者可以取货。消费者反过来先申请“已占用”再释放“空位”。两个信号量配合既保证不会向满缓冲区写入也不会从空缓冲区读取。这里要提醒一句上面示例为了教学简洁没有给buffer本身加锁。Python中deque.append和popleft在GIL下是原子的加上信号量控制了访问时机所以问题不大。真正工程项目里要么用线程安全队列要么给队列加锁信号量只负责流量控制不能替代对共享数据结构本身的保护。4. 实操跑一遍竞态、互斥锁、信号量的完整对比4.1 复现竞态把不稳定结果当对照组学习同步机制我建议你也亲手跑一遍无锁版本因为看到“错误结果随机出现”带来的冲击比看书上十行理论都有效。把第一段无锁代码保存为race.py连续运行5次python3 race.py python3 race.py python3 race.py python3 race.py python3 race.py我跑到的结果是这样一组数据293451、358923、400000、186734、312780。注意其中有一次碰巧跑出了正确结果这正是坑人的地方。有的bug在测试环境跑一百次都不出问题一上高并发的线上环境就爆炸。这就是竞态条件最难排查的根因它不一定总能复现。如果你用C语言做同样的实验把#pragma omp parallel for或者pthread加上错误率会更高因为C里没有GIL线程在真正的多核上并行执行竞争窗口更大。想观察得更细可以在累加处加一行打印当前线程ID和当前值你会看到线程被切换的具体位置直观感受一下“读改写三步被劈开”是什么感觉。4.2 用互斥锁和信号量分别修复同一个问题修复计数器用互斥锁已经足够了。但为了理解两者边界我用信号量也实现了一遍import threading total 0 sem threading.Semaphore(1) N 100000 THREADS 4 def add_task(): global total for _ in range(N): sem.acquire() total 1 sem.release() # 创建并启动线程与前面的示例一致 threads [] for _ in range(THREADS): t threading.Thread(targetadd_task) threads.append(t) t.start() for t in threads: t.join() print(final total , total, expected , THREADS * N)结果同样是正确的。这说明二值信号量在“互斥”这件事上确实可以达到和互斥锁相同的效果。但从工程角度我还是强烈建议保护共享变量优先用互斥锁而不是信号量。因为互斥锁语义清晰所有权明确编译器和运行时能帮你做更多检查比如死锁检测、重入检查。信号量的用途更广但太灵活自由度太高用来做互斥容易发生误操作比如别的线程随手一个release就直接破坏了你临界区的保护。4.3 如果还嫌锁太重原子操作与读写锁有时候锁的代价还是太高尤其是读多写少的场景。比如一个高并发系统里绝大多数线程只是在读配置只有极少数线程在更新配置。这时候如果一股脑用互斥锁所有读操作都要排队并发能力会被白白浪费。针对这种情况有两种更细粒度的选择。第一种是读写锁允许多个读者同时持有锁但写者必须独占。读读之间不互斥读写、写写之间互斥。Python里是threading.RLock不对读写锁是threading.RLock不是这个。Python标准库其实没有直接的读写锁需要自己用Condition实现或者用第三方库。C/C里有pthread_rwlock_tJava里有ReentrantReadWriteLock。第二种是原子操作完全不加锁直接在硬件层完成一些简单的复合操作。比如fetch_add能原子地完成“读-加-写”用于计数器、序列号生成器非常合适。C11里的std::atomicint就是这个思路。对单个整数的操作场景用原子变量通常比锁快一个数量级以上。我学习时踩过一个坑过度设计把简单的计数器也上了锁性能惨不忍睹后来换成原子操作代码更简洁性能还更好。5. 排查与避坑速查5.1 死锁现场怎么定位死锁的典型表现是程序卡住不报错、不退出、CPU占用率可能还是0。排查死锁第一步是拿到所有线程的调用栈。Linux上用gdb挂到进程上执行thread apply all bt就能看到每个线程阻塞在哪里。如果输出里多个线程都停在pthread_mutex_lock或sem_wait上死锁嫌疑就很大。第二步看锁的持有关系。手动对照几个线程的栈帧找出“线程A持有锁1等待锁2”和“线程B持有锁2等待锁1”这样的环。只要形成了环死锁就实锤了。Windows上用Visual Studio的并行堆栈窗口也能直观看到谁持有什么锁。Python环境里可以用faulthandler.dump_traceback_later()定期打印线程栈或者用py-spy dump工具在线观察。更省事的办法是写代码之前就建立锁顺序规范所有需要多把锁的地方必须按全局统一顺序加锁。我给自己定的规矩是永远先拿地址小的锁再拿地址大的锁这样不会产生环。5.2 性能上不去多半是锁竞争另一种很常见的问题是程序功能正确但性能一塌糊涂。用perf top或者top -H看CPU大量消耗在锁相关函数上说明锁竞争太严重。锁竞争的本质是多个线程抢同一把锁大部分时间都在等待。优化锁竞争可以从几个方向入手。减少锁持有时间把耗时的IO、计算、网络请求移出临界区。减小锁粒度用分段锁、哈希表每个桶一把锁降低碰撞概率。读写分离读多写少场景用读写锁。用原子操作替代锁计数器、标志位这类简单状态优先用原子变量。考虑无锁数据结构比如无锁队列、无锁哈希表但实现复杂度高没必要一上来就上。实在不行调整并发模型用单线程事件循环如Redis从根上避免多线程共享状态。我记得有一次优化一个并发请求处理程序最开始用一把大锁保护整个缓存吞吐量一直上不去。后来改成读写锁读请求不再互相阻塞吞吐直接翻倍。但也不是所有场景都适合读写锁写操作特别多的时候读写锁因为内部维护读者计数和等待队列反而比普通互斥锁更慢。所以还是要先统计实际读写比例再选型。5.3 常见问题速查表我把平时遇到比较多的问题整理成了一张速查表放在下面方便你排查时快速对照问题现象可能原因排查思路并发程序运行结果每次都不一致共享数据存在竞态条件检查是否存在多线程读改写同一个变量程序偶发卡住不报错死锁用gdb或py-spy查看各线程调用栈寻找等待环加了锁还是出错锁没覆盖所有写路径或锁粒度不对检查所有修改共享变量的地方是否都加了同一把锁多线程程序CPU占用超低但任务不推进锁等待或线程池耗尽查看线程状态是否有线程长期阻塞在锁等待上一个线程释放锁后其他线程长时间才被唤醒锁实现或调度问题检查是否有“惊群”问题锁唤醒机制是否高效递归函数里加锁直接死锁使用了不可重入锁改用可重入锁或重构避免重复加锁先说一个我自己的经验教训很多人包括我刚开始学并发时下意识就会把共享变量找出来然后加锁但忽略了“代码里是不是还有其他出口在悄悄修改共享变量”。比如初始化缓存时有两段代码都会往缓存里写值只给一段加了锁另一段没加照样会出竞态条件。排查的时候先把所有读改写共享变量的地方都列出来再逐一检查是否都在临界区保护之下这个清单越完整bug越无处藏身。还有一个细节是加锁的范围。我见过有同学把time.sleep写进临界区导致整个系统的并发能力断崖式下降。锁只应该保护共享资源的读改写那一瞬间线程不需要在持锁状态下睡觉、等待IO、请求网络。记住一个原则临界区越小越好锁内代码越少越好。如果用C语言做底层实验还有几个容易忽略的坑一个是编译器优化。你加锁保护的变量最好声明成volatile否则编译器可能把变量缓存在寄存器里锁根本起不到效果。另一个是内存屏障。现代CPU为了性能会乱序执行指令锁的实现内部会带上内存屏障但你如果绕过锁直接读写共享变量就可能看到“半新不旧”的数据。这也是为什么我强烈建议只要能用锁或原子库就不要自己用裸指针去手搓同步很容易在底层优化上翻车。最后再分享一个学习路径上的体会。并发编程这东西光看书真的不够。我建议你把今天这些示例代码从头到尾自己敲一遍先跑无锁版感受结果不稳定再看加锁版恢复正确最后把互斥锁换成信号量试试思考两者行为上的差异。操作系统课里很多概念比如临界区、原子性、死锁、上下文切换看再多遍都不如亲手踩一次坑记得牢。你把这几段代码玩透了再去看pthread、POSIX信号量或者Java的并发包会发现底层思路全是通的。底子打牢了上层学什么都快。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价