☰
Python多线程同步:从竞态条件到锁、GIL与并发协作实践
2026/10/15 17:00:50 网站建设 项目流程

很多 Python 项目从单线程爬到多线程,翻车的方式往往不是功能直接不可用,而是数据悄悄出错、偶发报错、线上进程卡死。我见过最典型的一种情况是:多个线程在并发更新同一个统计值,代码跑起来看着很正常,一旦压测或者线上流量上来,数字就开始对不上,纯 Python 代码逻辑又看不出毛病。排查到最后,根子几乎都在同一个地方——线程间同步没做,或者在错误的地方做了错误的同步。

这篇文章想系统梳理 Python 的线程间同步以及同步机制。内容会覆盖threading模块里Lock、RLock、Condition、Event、Semaphore、Barrier这些互斥与协作工具的原理、适用场景和踩坑经验,也会聊到 GIL 的真正边界,以及什么时候该换multiprocessing或asyncio。无论你是刚开始写多线程的小白,还是已经在维护一套并发服务的开发,都应该能从里面找到能直接抄走的东西。

1. 竞态条件演示:两个线程同时改一个变量会发生什么

1.1 一段“看起来没毛病”的计数器代码

先看一段最常见的多线程代码,逻辑简单到不行:10 个线程,每个线程对同一个全局变量累加 10000 次。

import threading counter = 0 def work(): global counter for _ in range(10000): counter += 1 threads = [threading.Thread(target=work) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print("counter =", counter)

你猜结果是多少?很多人会下意识认为,加锁都不用,Python 不是有 GIL 吗,按理说counter += 1是“原子操作”。但实际跑出来的结果几乎不可能是 100000,经常是八万多、九万多,每次还不太一样。我之前在本地跑过一次,结果为 84673,再跑一次变成 91202。

这类问题在并发领域有一个正式名字:竞态条件(Race Condition)。当多个线程的执行顺序不确定时,程序的输出结果不唯一,错误就像幽灵一样间歇性出现。

1.2 为什么会少算:字节码级别的切换与GIL的边界

要搞清楚为什么,得往下挖一层。Python 代码会被编译成字节码,解释器一条一条执行。counter += 1在字节码层面并不是一条指令,而是至少三条:

  1. 读取counter当前值
  2. 把值加 1
  3. 把新值写回counter

从源码角度看是一次操作,从解释器角度看是“读-改-写”三步。GIL 保证的是同一时刻只有一个线程在执行 Python 字节码,但它没保证这三条字节码之间不切换线程。只要线程 A 执行完“读取”,在“写入”之前被切走,线程 B 进来也读到了同一个旧值,然后两个线程各自加 1、各自写回,最终结果只增加了 1,而不是 2。这个场景就是典型的声明丢失。

如果你想知道自己的 Python 代码实际会被拆成几条字节码,可以用dis.dis("counter += 1")看一眼。看完就会明白,很多看起来显然安全的表达式,在并发环境下根本不安全。

1.3 这类问题在真实项目里长什么样

计数器少加几万可能只是测试数据不好看,但同样的“读-改-写”模式出现在真实业务里就麻烦了:

  • 多个线程同时更新缓存里的某个总量字段,最终总量和真实数额不一致
  • 并发生成单号,两个线程读到同一个基数,生成重复编号
  • 多个工作线程往同一个列表追加任务,偶发出现丢数据
  • 日志写入同一个文件时,一行日志被另一条线程从中间截断

我见过有人在生产环境里用普通dict做跨线程缓存,再配合定时刷新任务,结果出现“键明明存在却偶尔 KeyError”的诡异报错。原因就是某线程正在执行dict的扩容或者写入中途,另一个线程来读取,看到了不一致的中间状态。虽然 CPython 的dict单独每个方法的很多操作有内部保护,但组合起来的多步操作同样存在竞态窗口。

所以说,GIL 并不能代替同步机制。它只是让解释器层面的内存管理相对安全,但业务数据的“多线程协作”必须由开发者自己负责。

2. Lock与RLock:最基础的互斥与可重入边界

2.1 Lock 的基础用法和应用场景

要修上面那个计数器问题,最简单的办法是引入互斥锁:

import threading counter = 0 lock = threading.Lock() def work(): global counter for _ in range(10000): with lock: counter += 1 threads = [threading.Thread(target=work) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print("counter =", counter)

这次结果稳定输出 100000。with lock:进入时获取锁,代码块执行完自动释放,把counter += 1的“读-改-写”包成了一个临界区。任何时刻只有一个线程能进入这段代码,其他线程在门口排队等着。

Lock最适合保护的场景其实很窄但很关键:同一份可变状态的短小修改。比如计数器、开关标志、某个共享配置项的临时修改。它不擅长的是复杂业务协作——让一个线程等待另一个线程完成特定条件后再继续,这不是锁能优雅解决的。

使用Lock要注意一点:它不支持重入。同一个线程在持有锁的情况下再次acquire同一个锁,会直接死锁。下面这段代码就会卡死:

import threading lock = threading.Lock() def outer(): with lock: inner() def inner(): with lock: print("这里永远执行不到") threading.Thread(target=outer).start()

2.2 with语法:把解锁交给上下文管理器

手动写acquire()和release()最大的风险是中间抛异常时忘记释放锁,导致其他线程永久阻塞。虽然很多教程里会教你用try...finally,但最省心的方式是用with lock:。

它等价于进入时acquire、离开时release,即使临界区内部抛出异常,锁也会被释放。这不仅让代码更短,还消灭了一整类“异常导致死锁”的问题。

另外,Lock还支持非阻塞获取和超时获取:

if lock.acquire(timeout=3): try: # 拿到了锁,做事情 pass finally: lock.release() else: print("3秒内没拿到锁,不等待了")

timeout参数在面对需要快速响应的场景时非常有用,比如健康检查线程发现某个锁长时间不释放,可以放弃等待并记录告警,而不是无限卡住。

2.3 RLock:为什么有时候需要“同一线程再拿同一把锁”

RLock也就是可重入锁,它允许同一个线程多次获取同一个锁。锁内部维护了一个计数器,每acquire一次计数加 1,每release一次计数减 1,只有计数归零时锁才真正被其他线程获取。

什么时候必须用RLock?典型场景是:一个对外的方法需要加锁,但它内部又调用了另外一个也加了同一把锁的方法。

import threading class TaskManager: def __init__(self): self.lock = threading.RLock() self.data = [] def add(self, item): with self.lock: self.data.append(item) def add_batch(self, items): with self.lock: for item in items: self.add(item) # 再获取同一把锁

如果这里用的是普通Lock,add_batch获取锁之后调用add,add再次尝试获取锁,两个调用属于同一个线程,而普通锁不区分持有者,结果就是死锁。复仇的出路就是换成RLock。简单记:锁范围之间有嵌套调用关系时,优先考虑RLock。

不过RLock也不是万能药,它引入的复杂性在于更难看清楚锁的层级。嵌套太深之后,锁的临界区实际上覆盖了很长一段调用链,合理可控的并发度会被压缩。我的习惯是:能拆成短临界区就拆,真正需要嵌套保护时才用RLock。

2.4 锁粒度:不下错地方也是一种优化

锁的正确性解决之后,下一个问题是锁的粒度。锁加的太粗,比如把整个函数体都with lock包起来,虽然正确,但其他线程基本等于串行执行,性能损失明显。锁加得太细,临界区外面可能又留出竞态窗口。

这里有个很经典的标准:锁的粒度应该覆盖“最小的、不可分割的共享状态修改区间”。比如读取并修改一个共享列表,只把append用锁包上没有问题;但如果你的操作是“先判断列表长度,再根据长度决定是否插入”,那么读取和判断也必须和插入放在同一个锁内,否则判断结果在锁外就已经过期了。

我在实际项目里常用一个粗略的取舍:如果是几十行以内的状态更新,直接整段上锁,开发效率最高;如果临界区里包含网络请求、磁盘 IO、大量计算这类耗时操作,必须想办法把耗时操作挪到锁外,锁只保护共享状态的读取和修改。

3. Condition、Event与Barrier:线程间协作的三个层次

3.1 Condition:经典的“等待-通知”机制

如果说 Lock 解决的是“互斥”,那 Condition 解决的是“协作”。它常用在生产者-消费者模型里:生产线程往队列里放数据,消费线程从队列里取数据处理。消费线程不能盲目轮询,因为轮询浪费 CPU;生产线程也不能随便通知,因为可能消费线程还没准备好。

Condition提供了一对核心方法:

  • wait():释放底层锁,进入等待状态,直到被notify唤醒并重新拿到锁
  • notify()/notify_all():唤醒等待中的一个或多个线程

看一个典型例子:

import threading import time import random queue = [] max_size = 5 cond = threading.Condition() def producer(): for i in range(20): with cond: while len(queue) >= max_size: cond.wait() queue.append(i) print(f"生产 {i},队列长度 {len(queue)}") cond.notify_all() time.sleep(0.1) def consumer(): for _ in range(20): with cond: while not queue: cond.wait() item = queue.pop(0) print(f"消费 {item},队列长度 {len(queue)}") cond.notify_all() time.sleep(0.15) t1 = threading.Thread(target=producer) t2 = threading.Thread(target=consumer) t1.start() t2.start() t1.join() t2.join()

这里有两个细节值得注意。

第一,wait()放在while循环里,而不是简单的if。原因是线程被notify唤醒后并不代表条件一定成立,可能唤醒你的那个线程只是通知了一条消息,但队列又被其他线程拿空了。所以每次醒来必须重新检查条件,这就是经典的“虚假唤醒”防护。

第二,notify_all()比notify()更适合大多数场景。如果只唤醒一个线程,而被唤醒的那个线程发现条件仍不满足,继续wait(),其他同样等待的线程就没有机会被唤醒,整个程序可能卡住。多等几个线程被唤醒,各自检查条件,顶多多几次空转,但能显著降低卡死风险。

3.2 Event:适合广播一次性信号

Event是一个更轻量的协作工具,本质是一个布尔标志位加一个等待队列。主要方法只有四个:set()、clear()、wait()、is_set()。

它适合的场景是:一个线程等待另一个线程完成某个动作,然后继续执行。比如程序启动后,后台线程等配置加载完成再开始处理任务;或者主线程通知所有工作线程“现在可以开始了”。

import threading import time start_event = threading.Event() def worker(name): print(f"线程 {name} 等待启动信号") start_event.wait() print(f"线程 {name} 开始工作") ts = [threading.Thread(target=worker, args=(i,)) for i in range(3)] for t in ts: t.start() time.sleep(1) print("主线程准备就绪,广播启动信号") start_event.set()

这里set()一调用,所有wait()的线程同时解除阻塞。Event 不像 Condition 那样需要频繁复位,如果希望一次广播之后还能再次等待,可以再调用clear()把标志位清掉。

Event 有个小陷阱:set()和clear()本身是原子安全的,但如果你在业务逻辑里依赖“先检查is_set()再做动作”,检查和动作之间的窗口仍然可能被其他线程打乱。Event 只保证标志位本身一致,不保证基于标志位的判断和后续操作是原子的。

3.3 Barrier:让一批线程同时越过栅栏

Barrier是三种协作工具里相对少见的,但它解决了一个很有意思的问题:多个线程各自做准备工作,但必须等大家都准备好之后才能一起进入下一阶段。类似现实中的集合点,所有人到齐之前,谁都不能先走。

标准用法:

import threading import time barrier = threading.Barrier(3) # 需要 3 个线程到齐 def stage(name): for round_num in range(2): time.sleep(0.2 * (ord(name[0]) % 3)) print(f"线程 {name} 完成第 {round_num} 轮准备,等待集合") barrier.wait() print(f"线程 {name} 通过第 {round_num} 轮屏障") ts = [threading.Thread(target=stage, args=(chr(65 + i),)) for i in range(3)] for t in ts: t.start() for t in ts: t.join()

Barrier(3)表示需要 3 个线程都调用一次wait(),屏障才会打开。如果有人数不够,wait()会一直阻塞。这在并行计算、批量任务同步等场景里非常实用,比如多个线程从不同数据源拉数据,全部就绪后统一写盘。

Barrier有一个默认行为要注意:如果某个线程在wait()等待时被取消或者异常退出,屏障会进入 broken 状态,其余线程会收到BrokenBarrierError。这种设计其实是为了防止“无限等待”。生产上我遇到过的问题是某个工作线程提前挂掉,其他线程集体报错。处理办法是在wait()外层捕获异常,并决定是重启线程还是放弃本轮同步。

4. Semaphore与Queue:限流与解耦的正确姿势

4.1 Semaphore 控制并发访问数量:从限制到资源池

Semaphore维护一个计数器,每次acquire()计数器减 1,release()计数器加 1,当计数器降到 0 时,后续acquire()阻塞。它非常适合限制并发访问数量。

比如最多允许 3 个线程同时访问外部接口:

import threading import time sem = threading.Semaphore(3) def call_api(name): with sem: print(f"线程 {name} 开始访问接口") time.sleep(1) print(f"线程 {name} 结束访问接口") ts = [threading.Thread(target=call_api, args=(i,)) for i in range(10)] for t in ts: t.start()

在这个例子中,10 个线程同时启动,但同一时刻最多只有 3 个进入临界区。这个特性很适合做简单的流量控制、连接池限制、数据库连接上限等。

Semaphore还有一个带初始值的变体用法:初始值为 N 时可以当做一个资源池,acquire表示占用资源,release表示归还资源。不过要注意,Semaphore的计数可能被错误地过度释放,导致超出初始上限。也就是说,调用了比acquire更多的release时,计数器会越界增长,这会让资源池彻底失去限制意义。生产代码里我一般会用BoundedSemaphore,它在计数器超过初始值时直接抛错,能尽早暴露问题。

4.2 Queue 为什么经常比手写锁更省心

聊了这么多同步机制,你可能已经感觉到:用锁去保护一个共享队列,再靠 Condition 去通知生产者消费者,代码量不小,逻辑还容易错。实际上,Python 标准库的queue.Queue已经把这两件事封装好了。

Queue内部本身就带锁,put()和get()都是线程安全的,而且支持阻塞和超时。生产者和消费者之间不再需要自己管理 Condition,只需关心业务逻辑。

import queue import threading import random task_queue = queue.Queue(maxsize=5) def producer(name): for i in range(10): task_queue.put(f"任务-{name}-{i}") print(f"[{name}] 放入任务 {i}") def consumer(name): while True: try: task = task_queue.get(timeout=2) except queue.Empty: break print(f"[{name}] 处理 {task}") task_queue.task_done() for i in range(2): threading.Thread(target=producer, args=(i,)).start() for i in range(3): threading.Thread(target=consumer, args=(i,)).start()

task_queue.task_done()配合主线程的join(),可以很方便地确认队列里所有任务都被处理完,比手动统计“已消费数量”可靠得多。这条经验值得单独记住:能用Queue解决的协作问题,不要手写 Lock + Condition 组合,标准库已经帮你把最容易错的细节处理掉了。

4.3 用 ThreadPoolExecutor 替代手写线程管理

很多时候,你想要的其实不是亲手控制线程同步,而是“把一批任务并发执行,等结果回来”。这种场景用ThreadPoolExecutor是最省力的选择。

from concurrent.futures import ThreadPoolExecutor, as_completed def fetch(url): # 模拟 IO 耗时 return len(url) urls = ["http://example.com/a", "http://example.com/b", "http://example.com/c"] with ThreadPoolExecutor(max_workers=3) as executor: futures = [executor.submit(fetch, url) for url in urls] for future in as_completed(futures): print(future.result())

线程池把线程创建、任务分配、结果收集都封装好了。提交任务后,future.result()内部已经处理了线程安全的数据传递。推荐的做法是:优先考虑ThreadPoolExecutor或Queue,确实有特殊同步需求时,再直接使用Lock、Condition这些底层工具,而不是一开始就从裸线程加锁写起。

5. 死锁的成因、复现与规避:锁顺序和超时缺一不可

5.1 一个教科书级死锁的完整复现

死锁大概是多线程里最让人头疼的问题。它的发生条件是:两个线程各自持有一个锁,同时等待对方释放另一个锁。

下面这段代码可以稳定复现死锁:

import threading import time lock_a = threading.Lock() lock_b = threading.Lock() def thread_1(): with lock_a: time.sleep(0.1) with lock_b: print("thread_1 执行完成") def thread_2(): with lock_b: time.sleep(0.1) with lock_a: print("thread_2 执行完成") t1 = threading.Thread(target=thread_1) t2 = threading.Thread(target=thread_2) t1.start() t2.start() t1.join() t2.join()

thread_1拿到lock_a后等lock_b,thread_2拿到lock_b后等lock_a,两边都等不到对方释放,程序永久阻塞。这个例子看起来很直白,但真实项目里的死锁往往藏得很深:锁 A 可能在某个类的方法入口,锁 B 可能在另一个模块的回调里,调用链绕了几层之后,谁都没意识到锁的顺序反了。

5.2 规避死锁的两个落地习惯

规避死锁最有效的策略不是靠事后排查,而是靠事前约束。

第一,给所有锁定义一个全局的获取顺序。如果代码里有锁 A 和锁 B,那么所有线程都严格按照“先 A 后 B”的顺序获取。只要每个线程都遵守同一顺序,就不会出现循环等待。这个条约束一旦被打破,死锁风险马上回来。

第二,获取锁时尽量加超时。超时不能解决所有死锁,但可以有效避免程序永久卡死。一个线程在一定时间内拿不到锁,可以记录日志、退避重试或者直接放弃,至少系统还能继续对外服务。

if lock_b.acquire(timeout=2): try: pass finally: lock_b.release() else: print("获取锁超时,当前路径有问题")

实际排查死锁时还有个笨办法但很有效:把进程的堆栈打印出来,看所有线程停在哪里。Python 的faulthandler.dump_traceback_later()或者外部信号都能做到。看到两个线程互相停在对方的acquire上,问题基本就定性了。

5.3 锁之外的问题:饥饿与不必要阻塞

除了死锁,还要警惕两个隐藏问题。

饥饿:某些线程长期拿不到锁而被饿死。比如高优先级线程频繁抢锁,低优先级的线程永远排不上队。Python 的Lock不保证公平性,不像某些语言锁那样默认按 FIFO 顺序分配。如果某个线程循环持有锁且处理很快,其他线程存在长期等待的可能。

不必要阻塞:锁的临界区里做了大量耗时操作,比如网络请求、数据库写入。某个线程慢下来,后面所有等待这个锁的线程全部被拖慢。这类问题的解法是优化锁的粒度,或者改用无锁数据结构,比如queue.Queue、collections.deque的线程安全方法、甚至用copy让每个线程操作自己的本地副本再合并。

6. GIL之外的真实边界:多线程同步到底能保护什么

6.1 GIL的机制误区与多线程的适用范围

聊到线程同步,总绕不开 GIL。很多人误以为 GIL 存在,线程就不需要同步了。从第一节的例子可以看到,这个想法是错的。GIL 只保证“Python 字节码层面同一时刻单线程执行”,但它不保证你的业务逻辑是原子的。而且对于 I/O 密集型任务,GIL 在等待 I/O 时会释放,多线程仍然能获得明显的并发收益。

具体来说,Python 多线程适合的场景包括:

  • 网络请求并发:多个线程各自请求不同 URL,等待响应时释放 GIL
  • 文件读写:读写操作本身耗时,多线程能提高整体吞吐
  • 数据库访问:连接池配合多线程处理查询请求

不适合的场景是纯 CPU 计算:两个线程同时做大量浮点运算,GIL 让它们无法利用多核并行,效率甚至可能不如单线程,因为还要承担线程切换的开销。这时候需要的是multiprocessing或者concurrent.futures.ProcessPoolExecutor。

6.2 如何选择:多线程、多进程还是协程

这里是个人经验里比较关键的一张决策表:

场景特点推荐方案原因
I/O 密集、需要处理大量网络/文件请求多线程 + Queue/ThreadPoolExecutorGIL 在 I/O 等待时释放,线程开销小
纯 CPU 密集计算多进程 ProcessPoolExecutor绕过 GIL,利用多核
单线程内高效管理大量 I/O 等待asyncio 协程无线程切换开销,但需要异步写法
任务之间有复杂协作Condition/Event/Barrier精准控制线程状态
简单并发批量任务ThreadPoolExecutor避免手写线程管理

多进程也有代价:进程间通信(IPC)比线程间共享内存慢,且更复杂。进程的内存天然隔离,不需要考虑共享状态加锁,但也不方便直接共享大数据结构。

协程则完全是一个单线程内的调度模型。它适合高并发 I/O 场景,代码量少,性能出色,但所有代码必须用async/await风格重写,且不能出现阻塞调用。如果现有代码里有大量同步 I/O 操作,改写成本会比较高。

6.3 同步机制横向对照表与选用建议

最后给一张线程同步工具的横向对照表,方便你在写代码前快速定位。

同步工具主要解决什么核心方法最常用场景
Lock互斥访问共享状态acquire / release(with)保护计数器和共享变量修改
RLock同一线程可重入的互斥锁acquire / release嵌套调用同一把锁的方法
Condition线程间条件等待和通知wait / notify / notify_all生产者消费者模型
Event广播一次性信号set / wait / clear等待就绪通知
Semaphore限制并发数量acquire / release限流、连接池
Barrier多个线程集合点wait并行任务分批同步
Queue线程安全队列,自带互斥put / get / task_done任务分发、生产者消费者

选型时我有一个相对固定的思路:先想清楚业务是“数据共享”还是“线程协作”。如果是数据共享,优先问自己能不能用Queue或线程池避免直接共享;如果必须共享,选Lock保护最小临界区。如果是线程协作,一个线程等另一个线程完成某件事,选Event;多个线程互相等待条件满足,选Condition;需要集体到齐再继续,选Barrier。

6.4 最后一点经验:把同步当成接口设计的一部分

个人经验是,线程同步设计的越晚,项目付出的代价越大。很多项目先写完全不带锁的代码,等出了问题再到处补锁,结果锁的位置不对、粒度不对,问题修了又冒出来。更好的做法是,在定义共享接口的那一刻就决定:这个状态是否会被多线程访问,如果会,谁负责锁、锁保护哪些操作,这些写进注释里,后续维护者就少踩很多坑。

我自己的套路是:所有可能被多线程访问的方法,都在 docstring 里写明“线程安全”或“调用方必须持有锁”。这样哪怕几个月后回来重构,也能一眼看出边界在哪里。同步机制不是事后补救的创可贴,而是并发系统设计里的一部分,用对工具、用对粒度,多线程项目才能真正稳定下来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询