别被“一行代码”骗了:彻底搞懂 Python 线程安全、原子操作与并发陷阱 📅 2026/8/15 1:56:30 别被“一行代码”骗了彻底搞懂 Python 线程安全、原子操作与并发陷阱在 Python 并发编程里有一个特别危险的错觉“这条语句只有一行执行起来应该是原子的吧”比如count1再比如ifkeynotincache:cache[key]load_data()或者iftasks:tasktasks.pop()代码看起来非常自然单线程运行也几乎不会出问题。但一旦两个线程同时访问这些共享状态事情就可能完全不同。这也是“线程安全”真正值得学习的地方问题通常不在于代码会不会报错而在于程序能否在任意线程交错执行的情况下仍然保持正确的数据状态和业务规则。尤其需要注意的是从 Python 3.13 开始CPython 已支持可以关闭 GIL 的 free-threaded 构建使多个线程真正并行执行 Python 代码。传统上“反正有 GIL 帮我兜底”的编程习惯越来越不适合作为程序正确性的基础。(docs.python.org)本文就围绕一个核心问题展开什么是线程安全Python 中哪些操作看似原子却绝不能想当然地依赖一、到底什么叫“线程安全”假设程序中有一个共享计数器counter0两个线程都需要修改它。如果无论两个线程按照什么顺序运行最终结果都符合程序设计要求那么这段代码才可以认为是线程安全的。线程安全关注的不是程序有没有使用 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为什么不是原子的来看最经典的代码counter0defincrease():globalcounter counter1很多初学者会觉得counter1只有一行。实际上逻辑上至少包含old_valuecounter new_valueold_value1counternew_value我们甚至可以使用dis模块观察 CPython 为函数生成的字节码importdis counter0defincrease():globalcounter counter1dis.dis(increase)你通常会看到类似“读取变量 → 执行加法 → 写回变量”的多个指令而不是一个不可分割的“自增指令”。需要特别说明的是不要把某一版本的具体字节码结构反过来当成并发 API 保证。Python 官方明确指出CPython 字节码属于实现细节不同 Python 版本之间可能增加、删除或调整指令。(Python documentation)真正应该依赖的是文档明确提供的同步语义而不是“我看了一下当前版本的 bytecode 感觉这里应该不会切线程。”这种代码迟早会成为维护陷阱。四、用一个确定性实验制造“丢失更新”线程竞态有一个很讨厌的特点它可能运行一万次都正常第一万零一次突然出错。为了更直观地看到问题我们主动让两个线程都先读取旧值再同时写入importthreading counter0barrierthreading.Barrier(2)defunsafe_increment():globalcounter old_valuecounter# 两个线程都读取完成后再继续barrier.wait()counterold_value1t1threading.Thread(targetunsafe_increment)t2threading.Thread(targetunsafe_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 counter0lockthreading.Lock()defsafe_increment():globalcounterwithlock:counter1此时withlock:counter1形成一个临界区。当线程 A 持有锁时线程 B 必须等待。Python 官方文档明确说明threading.Lock的锁操作具有原子执行语义锁也支持上下文管理协议因此工程代码通常应该优先使用withlock:...而不是手动写lock.acquire()...lock.release()这样即使临界区抛出异常也更不容易忘记释放锁。(Python documentation)六、真正危险的是这些“看起来很合理”的代码1.x 1count1不能把它当成原子自增。同类代码还有count-1balanceamount obj.version1它们本质上通常都是读取 → 计算 → 写回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)逻辑看起来没有任何问题。但可能发生线程 Akey 不存在 线程 Bkey 不存在 线程 A查询数据库 线程 B查询数据库 线程 A写入缓存 线程 B再次写入缓存于是一个本来只应该执行一次的昂贵操作被执行了两次。更麻烦的是如果初始化函数具有副作用例如create_user()charge_money()send_email()allocate_resource()问题就不是“多查了一次数据库”这么简单了。这类模式称为Check Then Act 检查之后再执行检查和操作之间存在时间窗口也就是经典的 TOCTOUTime Of Check ↓ Time Of UsePython 官方线程安全文档同样把下面这种代码列为非原子模式ifkeyind:deld[key]因为执行完检查以后真正删除之前其他线程完全可能已经修改了字典。(Python documentation)八、if list: list.pop()也不安全下面这段代码非常常见iftasks:tasktasks.pop()很多人的推理是list.pop() 很安全 所以这一段也安全这是错误的。因为这是两个独立操作iftasks:和tasks.pop()可能发生线程 A发现列表非空 线程 B发现列表非空 线程 Apop 最后一个元素 线程 B再次 pop线程 B 此时就可能遇到IndexErrorPython 当前 free-threaded 文档甚至直接使用iflst:itemlst.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 lockthreading.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)安全不代表valued.get(key)value1d[key]value整体安全。第二组合多个原子操作后组合本身通常不原子例如iftasks:tasks.pop()即使bool(tasks)和tasks.pop()分别能够安全执行也不能保证它们之间没有其他线程插入。第三不要随意把当前实现规律推广成永久语言保证传统 GIL 环境中Python FAQ 曾列出不少“看起来原子”的内置操作例如L.append(x)L.pop()D[x]y D.update(...)同时也列出ii1L.append(L[-1])D[x]D[x]1等非原子组合并给出了非常实用的建议拿不准时就使用互斥锁。(Python documentation)而随着 free-threaded Python 的发展更应该区分语言规范保证 当前 CPython 文档保证 当前 CPython 实现细节 碰巧测试没出问题这四者不是同一回事。十一、还有一个特别经典的坑Queue 的empty()生产者—消费者系统里有人会这样写ifnotq.empty():itemq.get()看起来特别合理。实际上仍然是 Check-Then-Act。执行完q.empty()到调用q.get()之间其他线程完全可能已经把任务取走。Python 官方queue文档明确指出empty()、qsize()等方法只能反映近似状态empty() 返回 False并不能保证随后执行get()一定不会阻塞。(Python documentation)更合理的设计是直接使用队列提供的同步能力itemq.get()或者importqueuetry:itemq.get_nowait()exceptqueue.Empty:passqueue.Queue本身就是面向多生产者、多消费者线程通信设计的同步队列并实现了必要的锁语义。(Python documentation)这通常比自己维护shared_listLockCondition更加可靠也更容易维护。十二、线程安全代码的五条实战原则原则一优先减少共享可变状态最好的锁有时候是根本不需要锁。例如每个线程处理自己的局部数据defworker(items):local_result[]foriteminitems:local_result.append(process(item))returnlocal_result最后统一合并结果比所有线程不断修改同一个全局列表更容易理解。原则二保护“不变量”而不是机械地保护某一行错误思路读取加一个锁 写入再加一个锁正确思路读取 判断 修改如果这三步共同维护一个业务规则就应该放在同一个临界区中。原则三锁的范围尽可能小但不能小到破坏逻辑不要这样withlock:resultslow_http_request()如果 HTTP 请求需要几秒钟那么其他所有线程都必须等几秒。更合理resultslow_http_request()withlock:cache[key]result当然如果“只允许一个线程加载这个 key”本身就是业务要求那么还需要重新设计缓存协议。所谓“缩小锁范围”前提永远是不能破坏需要保护的原子业务过程。原则四线程通信优先考虑 Queue当问题本质上是线程 A 产生任务 ↓ 线程 B/C/D 消费任务优先考虑queue.Queue而不是共享listLockEventCondition一堆状态变量成熟的并发原语通常比自己手搓同步协议可靠得多。Python 官方也正是把queue定位为适合多线程安全交换信息的同步队列。(Python documentation)原则五不要把“运行没出错”当成线程安全证明下面测试运行 1000 次全部正确不能推出线程安全竞态条件依赖线程调度、机器负载、CPU 核数、操作系统、Python 版本甚至某个偶然的时间窗口。测试只能帮助发现竞态不能证明不存在竞态。真正可靠的方法依然是从代码结构分析是否存在共享可变状态 ↓ 是否有多个线程访问 ↓ 是否至少一个线程修改 ↓ 多个操作之间是否存在业务不变量 ↓ 是否使用了明确同步机制十三、如何主动寻找项目里的线程安全问题代码 Review 时我通常特别关注下面这些模式x1d[key]1ifkeynotind:d[key]...ifkeyind:deld[key]ifitems:items.pop()iflen(items)index:valueitems[index]ifbalanceamount:balance-amount以及obj.status...obj.version1obj.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❌Lockd[k] d[k] 1❌Lockif k in d: del d[k]❌pop()或锁if lst: lst.pop()❌锁或 Queueif 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这往往就是排查线程安全问题最好的起点。