别被“一行代码”骗了:彻底搞懂 Python 线程安全、原子操作与并发陷阱

别被“一行代码”骗了:彻底搞懂 Python 线程安全、原子操作与并发陷阱

别被“一行代码”骗了:彻底搞懂 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 构建中,这个问题更加明显:多个线程可以真正并行执行。官方虽然为listdictset等内置对象加入了内部同步机制,但仍明确建议,在多个线程共享这些对象时,应优先使用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 Use

Python 官方线程安全文档同样把下面这种代码列为非原子模式:

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 此时就可能遇到:

IndexError

Python 当前 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:pass

queue.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+=1
d[key]+=1
ifkeynotind: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)

为了兼容这种执行方式,dictlistset等内置对象内部增加了相应同步措施。

但官方同样强调:

历史上的 Python 并没有承诺所有内置类型并发修改都具有你想象中的具体语义,因此应用层仍然应该优先使用显式同步,而不是依赖内部锁。(Python documentation)

这意味着未来写 Python 并发程序时,一个非常值得建立的习惯是:

不要问: “GIL 会不会保护我?” 而要问: “我的同步协议是否正确?”

这才是能够跨 Python 版本、跨运行模式、跨项目规模长期成立的思维方式。


十五、一张表快速记住

代码模式能否直接当作原子业务操作建议
x += 1Lock
d[k] = d[k] + 1Lock
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?这往往就是排查线程安全问题最好的起点。