别被“一行代码”骗了:彻底搞懂 Python 线程安全、原子操作与并发陷阱
在 Python 并发编程里,有一个特别危险的错觉:
“这条语句只有一行,执行起来应该是原子的吧?”
比如:
count+=1再比如:
ifkeynotincache:cache[key]=load_data()或者:
iftasks:task=tasks.pop()代码看起来非常自然,单线程运行也几乎不会出问题。但一旦两个线程同时访问这些共享状态,事情就可能完全不同。
这也是“线程安全”真正值得学习的地方:问题通常不在于代码会不会报错,而在于程序能否在任意线程交错执行的情况下,仍然保持正确的数据状态和业务规则。
尤其需要注意的是,从 Python 3.13 开始,CPython 已支持可以关闭 GIL 的 free-threaded 构建,使多个线程真正并行执行 Python 代码。传统上“反正有 GIL 帮我兜底”的编程习惯,越来越不适合作为程序正确性的基础。(docs.python.org)
本文就围绕一个核心问题展开:
什么是线程安全?Python 中哪些操作看似原子,却绝不能想当然地依赖?
一、到底什么叫“线程安全”?
假设程序中有一个共享计数器:
counter=0两个线程都需要修改它。
如果无论两个线程按照什么顺序运行,最终结果都符合程序设计要求,那么这段代码才可以认为是线程安全的。
线程安全关注的不是:
程序有没有使用 Thread而是:
多个线程同时访问共享状态时, 程序的不变量是否仍然成立?例如一个库存系统要求:
库存永远不能小于 0一个银行账户要求:
余额不能因为并发更新而凭空丢失一个任务队列要求:
同一个任务不能被两个消费者重复领取只要并发执行可能破坏这些规则,就存在竞态条件(Race Condition)。
Python 官方文档把“原子操作”描述为:从其他线程视角看,它像一个不可分割的步骤一样完成;同时也明确指出,Python并不保证高级语言语句天然具有原子性,官方甚至直接以x += 1作为非原子操作的例子。(Python documentation)
所以请先记住本文最重要的一句话:
一行 Python 代码,不等于一个原子操作。
二、GIL 为什么不能等同于线程安全?
谈 Python 多线程,很容易遇到 GIL——Global Interpreter Lock,全局解释器锁。
在传统的 CPython GIL 构建中,同一时刻通常只有一个线程执行 Python 字节码,因此 CPU 密集型代码很难依靠普通线程获得真正的多核并行;但对于 I/O 密集型工作,线程仍然十分实用。Python 3.13 起还出现了可禁用 GIL 的 free-threaded CPython。(Python documentation)
于是很多开发者产生了一个误解:
有 GIL ↓ 同一时间只有一个线程执行 Python ↓ 所以我的共享变量天然线程安全问题出在最后一步。
GIL 保护的是解释器执行层面,而你的业务操作可能需要多个步骤:
读取旧值 ↓ 计算新值 ↓ 写回新值线程完全可能在这些步骤之间发生切换。
也就是说:
GIL ≠ 业务事务锁 GIL ≠ 所有 Python 语句都是原子的 GIL ≠ 可以忽略共享状态同步而在 free-threaded 构建中,这个问题更加明显:多个线程可以真正并行执行。官方虽然为list、dict、set等内置对象加入了内部同步机制,但仍明确建议,在多个线程共享这些对象时,应优先使用threading.Lock等同步工具,而不是依赖对象内部锁的实现细节。(Python documentation)
三、经典陷阱:counter += 1为什么不是原子的?
来看最经典的代码:
counter=0defincrease():globalcounter counter+=1很多初学者会觉得:
counter+=1只有一行。
实际上逻辑上至少包含:
old_value=counter new_value=old_value+1counter=new_value我们甚至可以使用dis模块观察 CPython 为函数生成的字节码:
importdis counter=0defincrease():globalcounter counter+=1dis.dis(increase)你通常会看到类似“读取变量 → 执行加法 → 写回变量”的多个指令,而不是一个不可分割的“自增指令”。
需要特别说明的是,不要把某一版本的具体字节码结构反过来当成并发 API 保证。Python 官方明确指出,CPython 字节码属于实现细节,不同 Python 版本之间可能增加、删除或调整指令。(Python documentation)
真正应该依赖的是文档明确提供的同步语义,而不是:
“我看了一下当前版本的 bytecode, 感觉这里应该不会切线程。”这种代码迟早会成为维护陷阱。
四、用一个确定性实验制造“丢失更新”
线程竞态有一个很讨厌的特点:
它可能运行一万次都正常,第一万零一次突然出错。
为了更直观地看到问题,我们主动让两个线程都先读取旧值,再同时写入:
importthreading counter=0barrier=threading.Barrier(2)defunsafe_increment():globalcounter old_value=counter# 两个线程都读取完成后再继续barrier.wait()counter=old_value+1t1=threading.Thread(target=unsafe_increment)t2=threading.Thread(target=unsafe_increment)t1.start()t2.start()t1.join()t2.join()print(counter)你期待:
2但实际得到:
1执行过程相当于:
线程 A:读取 counter -> 0 线程 B:读取 counter -> 0 线程 A:计算 0 + 1 -> 写入 1 线程 B:计算 0 + 1 -> 写入 1两次更新发生了,但其中一次被覆盖。
这就是典型的:
Read Modify Write也就是读—改—写竞态。
五、正确做法:给“整个业务操作”加锁
解决方法不是只保护某一次读或写,而是保护完整的不变量。
importthreading counter=0lock=threading.Lock()defsafe_increment():globalcounterwithlock:counter+=1此时:
withlock:counter+=1形成一个临界区。
当线程 A 持有锁时,线程 B 必须等待。
Python 官方文档明确说明,threading.Lock的锁操作具有原子执行语义;锁也支持上下文管理协议,因此工程代码通常应该优先使用:
withlock:...而不是手动写:
lock.acquire()...lock.release()这样即使临界区抛出异常,也更不容易忘记释放锁。(Python documentation)
六、真正危险的是这些“看起来很合理”的代码
1.x += 1
count+=1不能把它当成原子自增。
同类代码还有:
count-=1balance+=amount obj.version+=1它们本质上通常都是:
读取 → 计算 → 写回2. 字典计数:d[key] = d[key] + 1
例如:
stats["success"]=stats["success"]+1或者:
stats["success"]+=1即使单次字典读取、写入各自具有一定线程安全保证,也不能推出:
读取 + 修改 + 写入这个组合是原子的。
Python 当前的 free-threaded 文档直接将:
d[key]=d[key]+1列为NOT atomic的例子。(Python documentation)
正确做法:
withlock:stats["success"]+=1七、最隐蔽的陷阱:Check-Then-Act
下面这种代码在实际项目里比+=更危险:
ifkeynotincache:cache[key]=load_from_database(key)逻辑看起来没有任何问题。
但可能发生:
线程 A:key 不存在 线程 B:key 不存在 线程 A:查询数据库 线程 B:查询数据库 线程 A:写入缓存 线程 B:再次写入缓存于是一个本来只应该执行一次的昂贵操作被执行了两次。
更麻烦的是,如果初始化函数具有副作用,例如:
create_user()charge_money()send_email()allocate_resource()问题就不是“多查了一次数据库”这么简单了。
这类模式称为:
Check Then Act 检查之后再执行检查和操作之间存在时间窗口,也就是经典的 TOCTOU:
Time Of Check ↓ Time Of UsePython 官方线程安全文档同样把下面这种代码列为非原子模式:
ifkeyind:deld[key]因为执行完检查以后,真正删除之前,其他线程完全可能已经修改了字典。(Python documentation)
八、if list: list.pop()也不安全
下面这段代码非常常见:
iftasks:task=tasks.pop()很多人的推理是:
list.pop() 很安全 所以这一段也安全这是错误的。
因为这是两个独立操作:
iftasks:和:
tasks.pop()可能发生:
线程 A:发现列表非空 线程 B:发现列表非空 线程 A:pop 最后一个元素 线程 B:再次 pop线程 B 此时就可能遇到:
IndexErrorPython 当前 free-threaded 文档甚至直接使用:
iflst:item=lst.pop()作为check-then-act 非原子操作的示例。(Python documentation)
因此:
单个操作安全,不代表多个安全操作组合起来仍然安全。
这条原则非常重要。
九、一个真实项目案例:电商库存扣减
假设商品只剩一件:
stock={"iphone":1}现在两个用户同时购买:
importtimedefbuy():ifstock["iphone"]>0:time.sleep(0.01)stock["iphone"]-=1print("购买成功")两个线程同时执行时可能出现:
线程 A:库存 = 1,可以买 线程 B:库存 = 1,可以买 线程 A:库存变成 0 线程 B:库存继续减成 -1于是:
stock["iphone"]==-1问题不在于字典坏掉了。
字典对象可能完全健康。
真正被破坏的是:
库存 >= 0这个业务不变量。
正确设计应该把:
检查库存 + 扣减库存看成一个不可分割的业务操作:
importthreading lock=threading.Lock()defbuy():withlock:ifstock["iphone"]<=0:print("库存不足")returnFalsestock["iphone"]-=1print("购买成功")returnTrue这也是工程上判断“锁应该加在哪里”的关键:
不要问“哪个变量需要锁”,而应该问“哪个业务不变量需要被原子地维护”。
十、哪些内置操作现在确实具有原子或线程安全保证?
这里需要特别纠正一个容易走向另一个极端的说法:
“Python 所有内置容器操作都不能依赖线程安全。”
这也不准确。
当前 Python free-threaded 文档已经对部分内置操作给出了明确的线程安全等级。
例如对list,当前文档明确说明:
lst[i]单元素读取是原子的;
lst.append(x)lst.pop()其中尾部append、尾部pop被明确描述为原子操作。(Python documentation)
对于dict,例如:
d[key]d.get(key)keyindlen(d)当前 free-threaded 文档也给出了原子访问保证;单元素写入、删除等操作有内部同步,不会简单地因为并发操作就把字典内部结构破坏掉。(Python documentation)
但这里有三个必须注意的限制。
第一:原子容器操作 ≠ 原子业务流程
d.get(key)安全,不代表:
value=d.get(key)value+=1d[key]=value整体安全。
第二:组合多个原子操作后,组合本身通常不原子
例如:
iftasks:tasks.pop()即使:
bool(tasks)和:
tasks.pop()分别能够安全执行,也不能保证它们之间没有其他线程插入。
第三:不要随意把当前实现规律推广成永久语言保证
传统 GIL 环境中,Python FAQ 曾列出不少“看起来原子”的内置操作,例如:
L.append(x)L.pop()D[x]=y D.update(...)同时也列出:
i=i+1L.append(L[-1])D[x]=D[x]+1等非原子组合,并给出了非常实用的建议:
拿不准时就使用互斥锁。(Python documentation)
而随着 free-threaded Python 的发展,更应该区分:
语言规范保证 当前 CPython 文档保证 当前 CPython 实现细节 碰巧测试没出问题这四者不是同一回事。
十一、还有一个特别经典的坑:Queue 的empty()
生产者—消费者系统里,有人会这样写:
ifnotq.empty():item=q.get()看起来特别合理。
实际上仍然是 Check-Then-Act。
执行完:
q.empty()到调用:
q.get()之间,其他线程完全可能已经把任务取走。
Python 官方queue文档明确指出,empty()、qsize()等方法只能反映近似状态:
empty() 返回 False并不能保证随后执行:
get()一定不会阻塞。(Python documentation)
更合理的设计是直接使用队列提供的同步能力:
item=q.get()或者:
importqueuetry:item=q.get_nowait()exceptqueue.Empty:passqueue.Queue本身就是面向多生产者、多消费者线程通信设计的同步队列,并实现了必要的锁语义。(Python documentation)
这通常比自己维护:
shared_list+Lock+Condition更加可靠,也更容易维护。
十二、线程安全代码的五条实战原则
原则一:优先减少共享可变状态
最好的锁有时候是:
根本不需要锁。
例如每个线程处理自己的局部数据:
defworker(items):local_result=[]foriteminitems:local_result.append(process(item))returnlocal_result最后统一合并结果,比所有线程不断修改同一个全局列表更容易理解。
原则二:保护“不变量”,而不是机械地保护某一行
错误思路:
读取加一个锁 写入再加一个锁正确思路:
读取 判断 修改如果这三步共同维护一个业务规则,就应该放在同一个临界区中。
原则三:锁的范围尽可能小,但不能小到破坏逻辑
不要这样:
withlock:result=slow_http_request()如果 HTTP 请求需要几秒钟,那么其他所有线程都必须等几秒。
更合理:
result=slow_http_request()withlock:cache[key]=result当然,如果“只允许一个线程加载这个 key”本身就是业务要求,那么还需要重新设计缓存协议。
所谓“缩小锁范围”,前提永远是:
不能破坏需要保护的原子业务过程。
原则四:线程通信优先考虑 Queue
当问题本质上是:
线程 A 产生任务 ↓ 线程 B/C/D 消费任务优先考虑:
queue.Queue而不是:
共享list+Lock+Event+Condition+一堆状态变量成熟的并发原语通常比自己手搓同步协议可靠得多。Python 官方也正是把queue定位为适合多线程安全交换信息的同步队列。(Python documentation)
原则五:不要把“运行没出错”当成线程安全证明
下面测试运行 1000 次:
全部正确不能推出:
线程安全竞态条件依赖线程调度、机器负载、CPU 核数、操作系统、Python 版本甚至某个偶然的时间窗口。
测试只能帮助发现竞态,不能证明不存在竞态。
真正可靠的方法依然是从代码结构分析:
是否存在共享可变状态? ↓ 是否有多个线程访问? ↓ 是否至少一个线程修改? ↓ 多个操作之间是否存在业务不变量? ↓ 是否使用了明确同步机制?十三、如何主动寻找项目里的线程安全问题?
代码 Review 时,我通常特别关注下面这些模式:
x+=1d[key]+=1ifkeynotind:d[key]=...ifkeyind:deld[key]ifitems:items.pop()iflen(items)>index:value=items[index]ifbalance>=amount:balance-=amount以及:
obj.status=...obj.version+=1obj.updated_at=...如果这些变量会被多个线程共享,就值得追问一句:
如果线程恰好在这两步之间切换,会发生什么?
很多并发 Bug 就会立刻暴露出来。
十四、Python 3.13+ 后,这个问题为什么更重要?
PEP 703 推动 CPython 支持可选的无 GIL / free-threaded 模式。从 Python 3.13 开始,CPython 已经提供 free-threaded 构建,允许线程真正并行执行 Python 代码。(Python documentation)
为了兼容这种执行方式,dict、list、set等内置对象内部增加了相应同步措施。
但官方同样强调:
历史上的 Python 并没有承诺所有内置类型并发修改都具有你想象中的具体语义,因此应用层仍然应该优先使用显式同步,而不是依赖内部锁。(Python documentation)
这意味着未来写 Python 并发程序时,一个非常值得建立的习惯是:
不要问: “GIL 会不会保护我?” 而要问: “我的同步协议是否正确?”这才是能够跨 Python 版本、跨运行模式、跨项目规模长期成立的思维方式。
十五、一张表快速记住
| 代码模式 | 能否直接当作原子业务操作 | 建议 |
|---|---|---|
x += 1 | ❌ | Lock |
d[k] = d[k] + 1 | ❌ | Lock |
if k in d: del d[k] | ❌ | pop()或锁 |
if lst: lst.pop() | ❌ | 锁或 Queue |
if balance >= n: balance -= n | ❌ | 整段加锁 |
lst.append(x) | 当前文档对特定场景有原子保证 | 不要扩展成复杂事务假设 |
lst.pop()尾部弹出 | 当前文档有原子保证 | 与其他检查组合后需重新分析 |
d.get(k) | 当前文档有原子访问保证 | “读取后计算再写入”仍不原子 |
d[k] = value | 有内部线程安全保护 | 不代表复合业务操作安全 |
q.empty(); q.get() | ❌ | 直接get()或捕获queue.Empty |
核心规律只有一条:
单次线程安全操作的组合,不会自动变成线程安全的业务事务。(Python documentation)
十六、最后总结:真正应该依赖什么?
理解 Python 线程安全,不需要死记几十个“哪个操作原子、哪个不原子”。
更有效的方法是掌握四个判断原则:
第一,看有没有共享可变状态。 第二,看操作是否属于 Read-Modify-Write。 第三,看是否存在 Check-Then-Act。 第四,看业务不变量有没有被同一个同步机制完整保护。尤其要避免:
“一行代码,所以原子”“有 GIL,所以线程安全”“list/dict 自己有锁,所以随便组合都安全”“压测没出问题,所以没有竞态”真正成熟的 Python 并发代码,通常不是因为开发者知道了更多“解释器小技巧”,而是因为他们开始有意识地设计:
谁拥有数据? 谁能够修改数据? 修改期间谁必须等待? 哪些步骤必须作为一个整体完成? 是否可以通过消息传递替代共享状态?当这些问题能够回答清楚,线程安全往往就不再神秘。
Python 的简洁让我们很容易写出并发程序,但也正因为语法太自然,一些竞态条件会隐藏得格外深。越是看起来“这么简单肯定没问题”的一行代码,越值得在多线程环境里多想半秒。
这半秒,可能就是一次线上事故和稳定系统之间的距离。
参考资料
本文关于 GIL、free-threaded Python、原子操作、list/dict线程安全语义及同步原语的说明主要依据 Python 官方文档与 PEP 703。(Python documentation)
关于多生产者、多消费者任务通信,可继续阅读 Python 官方queue模块文档。(Python documentation)
**互动思考:**你的项目里有没有出现过“单线程永远正常,一上并发偶尔出错”的 Python Bug?如果把项目中的共享变量列出来,你是否能立即判断哪些地方存在 Read-Modify-Write 或 Check-Then-Act?这往往就是排查线程安全问题最好的起点。