哪些 Python 库和对象是线程安全的?怎么判断?
简化版
「线程安全」不是一个是非题,它至少有三个层次:① 进程不崩溃(不出现段错误、解释器状态损坏);② 单个操作不产生脏数据(一次 append、一次 dict[k]=v 是完整的);③ 一组操作的组合也是正确的(「先检查再修改」不会被插入)。GIL 只保证了第一层——它保护的是解释器内部的数据结构**(防止两个线程同时改一个 list 的内部指针导致崩溃),完全不保护你的业务逻辑。所以「Python 有 GIL 所以线程安全」是个彻底的误解。快速结论:内置容器的「单个操作」在 CPython 里通常是原子的(list.append、dict[k]=v、d.setdefault),但组合操作绝不安全(if k not in d: d[k]=v、x += 1);queue.Queue、logging、threading 的同步原语是明确线程安全的(内部有锁);random 模块的全局函数线程安全但会争抢同一个状态(多线程建议每线程一个 random.Random() 实例);sqlite3 的连接默认不允许跨线程使用(check_same_thread=True);requests.Session 官方明确说不是线程安全的(虽然实践中大多能用,但连接池和 cookie 有竞态);datetime、re 编译后的 pattern、不可变对象是安全的(只读)。判断方法:查文档的「thread safety」章节 → 看它有没有模块级可变全局状态 → 看它内部有没有加锁 → 实在不确定就每线程一个实例(最省事的通用解法)。核心记忆:GIL 只防解释器崩溃,不防业务竞态;组合操作一律要锁;不确定就每线程一份。
详细版
常见对象/库的线程安全速查:
| 对象/库 | 线程安全? | 说明 |
|---|---|---|
list.append / dict[k]=v / set.add | ✅ 单个操作原子(CPython 实现细节) | 组合操作不安全 |
x += 1(含 dict[k] += 1) | ❌ 读-改-写三步 | 必须加锁 |
if k not in d: d[k]=v | ❌ 检查后修改 | 用 setdefault |
queue.Queue | ✅ 明确线程安全 | 内部有锁,推荐用它通信 |
collections.deque 的 append/popleft | ✅ 原子 | 但 deque[i] += 1 不是 |
logging | ✅ 每个 Handler 一把锁 | 不是进程安全 |
random 全局函数 | ✅ 有锁但争抢同一状态 | 多线程建议每线程一个实例 |
datetime / re.compile 后的 pattern | ✅ 不可变,只读 | —— |
sqlite3.Connection | ⚠️ 默认禁止跨线程 | check_same_thread=False 要自己加锁 |
requests.Session | ⚠️ 官方说不保证 | 每线程一个 Session 最稳 |
numpy 数组 | ✅ 读安全 / ❌ 并发写不安全 | 底层 BLAS 自己有线程池 |
pandas.DataFrame | ❌ 不保证 | 并发写必炸 |
os.environ | ❌ 全局可变状态 | 运行中改环境变量是竞态源 |
sys.path / 模块级全局变量 | ❌ | 启动时配置好,运行中别改 |
import threading, queue, random, sqlite3, collections
# ① ★单个操作原子 ≠ 组合操作安全★
d = {}
d["k"] = 1 # ✅ 原子(一条字节码 STORE_SUBSCR,中间不会切换)
# ✗ 下面两种都不安全:
# if "k" not in d: d["k"] = 1 # ★检查和赋值之间可能被插入★
# d["k"] += 1 # ★读-加-写三步★
d.setdefault("k", 1) # ✅ ★单个 C 层调用,原子★
counter = collections.Counter() # ✗ Counter 的 += 同样不安全
# ② ★正确的计数器★
class Counter:
def __init__(self):
self._n = 0
self._lock = threading.Lock()
def incr(self):
with self._lock: # ★必须加锁★
self._n += 1
# ③ ★queue.Queue:线程间通信的首选(明确线程安全)★
q = queue.Queue(maxsize=1000)
q.put(item) # ✅ 内部有锁 + 条件变量
item = q.get(timeout=1) # ✅
# ★但"组合"仍不安全:
# if not q.empty(): q.get() # ✗ ★empty() 到 get() 之间可能被别人取走★
try:
item = q.get_nowait() # ✅ 用异常代替检查
except queue.Empty:
pass
# ④ random:全局函数有锁,但★多线程会争抢同一个状态★
random.random() # ✅ 不会崩,但多线程争用同一个 Mersenne Twister
_local = threading.local()
def rnd():
if not hasattr(_local, "r"):
_local.r = random.Random() # ★每线程一个实例,无争用且序列独立★
return _local.r.random()
# ⑤ ★sqlite3:默认禁止跨线程★
conn = sqlite3.connect("app.db")
# 另一个线程里用 conn → ProgrammingError: SQLite objects created in a thread
# can only be used in that same thread
conn = sqlite3.connect("app.db", check_same_thread=False) # ★放开检查★
# ★但放开后必须自己保证串行化(加锁),否则游标状态会错乱★
# ⑥ requests.Session:官方文档说"不保证线程安全"
_sessions = threading.local()
def session():
if not hasattr(_sessions, "s"):
_sessions.s = requests.Session() # ★每线程一个★
return _sessions.s
# ⑦ ★最通用的解法:每线程一份(threading.local)★
_local = threading.local()
def get_client():
if not hasattr(_local, "client"):
_local.client = ExpensiveClient()
return _local.client
# ★协程场景要用 contextvars 而不是 threading.local★
⚠️ 三个必须澄清的认知:① GIL 保证的是「解释器不崩溃」,不是「你的代码正确」。它确保两个线程不会同时修改一个
list的内部指针导致段错误,但完全不保护跨字节码的逻辑——counter += 1是LOAD/ADD/STORE三条字节码,线程随时可能在中间被切走(默认每 5ms 一次)。所以「有 GIL 所以不用加锁」是彻底的误解。② 「某个操作是原子的」是 CPython 的实现细节,不是语言保证:list.append之所以原子,是因为它是一次 C 层调用、中间不会释放 GIL;但这没有写进语言规范,其他实现(PyPy、Jython)不保证,自由线程构建下也可能变化。所以别去背「哪些操作碰巧原子」,一律用Lock或Queue。③ 文档里没写「线程安全」通常就意味着不安全:Python 生态的惯例是「安全的会明确写出来」(queue、logging都在文档里声明了),没说的默认按不安全处理——尤其是持有连接、缓冲区、游标状态的对象(HTTP Session、数据库连接、文件对象)。
完整版教学
一、「线程安全」的三个层次
★ 层次一:进程不崩溃(内存安全)
两个线程同时 list.append,会不会把 list 的内部数组指针写坏、导致段错误?
→ ★不会★,这正是 GIL 保护的东西
→ CPython 的内置类型在 GIL 下不会出现"内部结构损坏"
★ 但注意:★自由线程构建(no-GIL)里,这一层靠的是内置容器的细粒度锁★
★ 层次二:单个操作的完整性(原子性)
d[k] = v 会不会写到一半?x = lst[0] 会不会读到半个对象?
→ ★不会★:这些是单条字节码 / 单次 C 层调用,中途不会切换线程
→ 但这个保证来自 ★CPython 的实现方式★,不是语言规范
★ 层次三:★一组操作的正确性(业务逻辑)★
if k not in d: ← 线程 A 判断完,被切走
d[k] = compute() ← 线程 B 也判断完并赋值,A 回来又赋一次
→ ★GIL 完全不保护这一层★
→ 这才是 99% 的并发 bug 所在
┌────────────────────────────────────────────────────┐
│ GIL 保护的范围:★层次一 + 层次二★ │
│ 你必须自己保护的:★层次三(组合操作、跨语句的不变式)★│
└────────────────────────────────────────────────────┘
★ 一个精确的表述(面试可直接用):
"GIL 保证同一时刻只有一个线程执行 Python 字节码,
所以★单条字节码是原子的★、解释器内部结构不会损坏;
但它★不保证一条 Python 语句是原子的★(x += 1 是三条字节码),
更不保证多条语句的组合是原子的。
所以共享可变状态仍然必须用 Lock 或改用 Queue 传递。"
★ 判断一段代码是否需要加锁的方法:
① 它读写共享的可变状态吗? → 否 → 安全
② 它是"单个原子操作"吗? → 是 → ★勉强安全(但别依赖)★
③ 它包含"读-改-写"或"检查-再操作"吗?→ 是 → ★必须加锁★
④ 它要维持多个变量之间的不变式吗? → 是 → ★必须加锁★
「线程安全」要分三个层次理解。层次一是进程不崩溃(两个线程同时 append 不会把 list 的内部指针写坏)——这正是 GIL 保护的;层次二是单个操作的完整性(d[k]=v 不会写到一半)——来自「单条字节码/单次 C 调用中途不切换」这个实现方式;层次三是一组操作的正确性(if k not in d: d[k]=v 不被插入)——GIL 完全不保护这一层,而 99% 的并发 bug 都在这里。一个精确的表述可以直接用于面试:「GIL 保证同一时刻只有一个线程执行字节码,所以单条字节码是原子的、解释器内部结构不会损坏;但它不保证一条 Python 语句是原子的(x += 1 是三条字节码),更不保证多条语句的组合是原子的。」 判断是否需要加锁只需四问:读写共享可变状态吗 → 是单个原子操作吗 → 包含「读-改-写」或「检查-再操作」吗 → 要维持多个变量之间的不变式吗。
二、内置类型:原子性是实现细节
★ CPython 里"通常原子"的操作(★别背,理解就好★):
L.append(x) L.extend(iterable) L.pop()
D[k] = v D.update(other) D.setdefault(k, v)
S.add(x) x = L[i] x = D[k]
L1 = L2(引用赋值) sorted(L)(读取快照)
→ 共同点:★都是一次 C 层调用,执行期间不释放 GIL★
★ 明确不安全的操作:
x += 1 ← LOAD, ADD, STORE ★三条字节码★
D[k] += 1 ← 同上(读、加、写回)
L[i] = L[i] + 1
if k not in D: D[k]=v ← ★检查后修改★
if L: x = L.pop() ← ★检查后操作(可能被别人先 pop 空)★
a, b = b, a(跨共享变量) ← 中间状态可见
两个变量的同步更新(扣库存 + 记流水)
★ 为什么"原子性"不能依赖(★三条理由★):
① ★不是语言规范★:CPython 文档只是"提到"某些操作原子,
没有作为语言保证;PyPy/Jython/GraalPy 不保证
② ★会变★:3.11 的字节码重构、专用化指令都可能改变边界;
★自由线程构建里内置容器改用细粒度锁,语义可能不同★
③ ★容易误判★:
D[k] = v 原子 ✓,但 ★D[k] = compute()★ 里 compute() 期间会切换;
L.append(x) 原子 ✓,但 ★L.append(f(x))★ 不是一个整体
→ "看起来像一个操作"和"真的是一个操作"经常不一致
★ 正确的心态:
✗ 背一张"哪些操作原子"的表,然后在代码里精打细算
✓ ★共享可变状态一律用 Lock 或 Queue★
- 锁的开销只有 ~50~100ns,绝大多数场景可以忽略
- 换来的是"不用思考"和"换实现也不会坏"
✓ 只有在★被 profile 证实是热点★时才考虑利用原子性
★ 线程安全的内置替代:
普通计数 → ★用 Lock 包一下★(或 itertools.count 的 next,
它在 CPython 里是原子的,但同样别依赖)
队列 → ★queue.Queue(明确线程安全)★
两端操作 → collections.deque(append/popleft 原子,且有 maxlen)
每线程独立 → threading.local(协程用 contextvars)
一次性初始化 → ★functools.lru_cache★(有锁)/ 模块级导入(有 import 锁)
CPython 里确实有一批「通常原子」的操作(list.append、dict[k]=v、setdefault、set.add),共同点是一次 C 层调用、执行期间不释放 GIL。但不能依赖它们,有三条理由:① 不是语言规范(其他实现不保证);② 会变(3.11 的字节码重构、自由线程构建里内置容器改用细粒度锁);③ 容易误判——D[k] = v 原子,但 D[k] = compute() 在 compute() 期间会切换;L.append(x) 原子,但 L.append(f(x)) 不是一个整体,「看起来像一个操作」和「真的是一个操作」经常不一致。正确的心态是:共享可变状态一律用 Lock 或 Queue——锁的开销只有 50~100ns,绝大多数场景可以忽略,换来的是「不用思考」和「换实现也不会坏」;只有被 profile 证实是热点时才考虑利用原子性。线程安全的内置替代品:队列用 queue.Queue、两端操作用 deque、每线程独立用 threading.local(协程用 contextvars)、一次性初始化用 lru_cache 或模块级导入。
三、标准库的线程安全性清单
★ 明确线程安全(内部有锁,文档有说明):
queue.Queue / LifoQueue / PriorityQueue / SimpleQueue ★线程通信首选★
logging(★每个 Handler 一把 RLock★,但★不是进程安全★)
threading 的所有同步原语(Lock/RLock/Event/Condition/Semaphore/Barrier)
concurrent.futures 的 Executor(submit/shutdown 是安全的)
functools.lru_cache(有锁;★3.12 前的实现里锁还会影响多线程性能★)
★ 安全但有"争用"或"共享状态"问题:
random 模块的全局函数
→ 内部有锁,不会崩,但★所有线程共用一个 Mersenne Twister 状态★
→ 多线程高频取随机数时有锁争用;★更重要的是"可复现性"没了★
✓ 每线程一个 random.Random() 实例
os.environ
→ 读写本身是 dict 操作,但★是全局可变状态★
→ 运行中修改环境变量会影响所有线程(★典型竞态源★)
✓ 启动时读取一次存进配置对象
sys.path / warnings.filters / locale 设置
→ 同上,★全局状态,运行中别改★
★ 不可变 = 天然安全(只读):
str / bytes / int / float / tuple / frozenset
datetime / date / time / timedelta(★不可变★)
re.compile() 返回的 Pattern 对象(★编译后只读,可以跨线程共享★)
★ 但 re 的 match 对象是每次调用新建的,也无共享问题
★ ⚠️ 有条件安全:
sqlite3
→ ★默认 check_same_thread=True:连接只能在创建它的线程用★
→ 设 False 之后可以跨线程,但★必须自己串行化★(游标状态、事务状态是共享的)
→ sqlite3.threadsafety 属性告诉你底层库的级别(0/1/2/3)
✓ 推荐:★每线程一个连接★(SQLite 支持多连接读,写有文件锁)
decimal
→ ★上下文是 thread-local 的★(每个线程有自己的精度设置)
→ 所以在一个线程里 setcontext 不影响其他线程(★这是好事,但容易误解★)
tempfile
→ 创建是安全的(用了 O_EXCL)
shelve / dbm
→ ★不是线程安全★,要自己加锁
http.cookiejar
→ ★不保证★
★ 明确不安全:
文件对象(同一个 f 被多线程 write)→ ★内容会交错★(缓冲区是共享的)
io.StringIO / BytesIO 的并发写
任何"模块级可变全局变量"
迭代器/生成器(★两个线程同时 next() 会导致 ValueError 或数据错乱★)
✓ 要共享迭代器必须自己加锁包装
★ 一个特别的:模块导入
import 语句有 ★导入锁★(3.3+ 是 per-module 锁)
→ "模块级单例"是线程安全的初始化方式:
# config.py
CONFIG = load_config() # ★import 时执行一次,天然线程安全★
✓ 这是最简单可靠的"一次性初始化"手段(★比双重检查锁好用★)
标准库可以分成五类。明确线程安全的:queue.Queue(线程通信首选)、logging(每个 Handler 一把锁,但不是进程安全)、threading 的所有同步原语、concurrent.futures 的 Executor、lru_cache。安全但有争用/共享状态问题的:random 的全局函数(有锁不会崩,但所有线程共用一个状态,高频时有争用且可复现性丢失,建议每线程一个 Random() 实例)、os.environ 和 sys.path(全局可变状态,运行中别改)。不可变即天然安全:str/int/tuple、datetime、以及 re.compile() 返回的 Pattern(编译后只读,可放心跨线程共享)。有条件安全:sqlite3(默认禁止跨线程,放开后必须自己串行化,推荐每线程一个连接)、decimal(上下文是 thread-local 的)。明确不安全:同一个文件对象被多线程写(内容会交错)、迭代器和生成器(两个线程同时 next() 会抛 ValueError 或数据错乱)。最后一个实用点:import 有导入锁,所以「模块级单例」是最简单可靠的一次性初始化方式,比双重检查锁好用得多。
四、第三方库的线程安全性
★ requests / httpx
requests.Session ★官方文档明确说"不保证线程安全"★
→ 内部的连接池、cookie jar 在并发下有竞态
→ 实践中"大多数时候能用",但可能出现 cookie 串、连接状态错乱
✓ ★每线程一个 Session★(threading.local)或用连接池库
✓ httpx.Client 文档说是线程安全的(★但 AsyncClient 是给协程用的★)
★ urllib3 的 PoolManager 是线程安全的(requests 底层就是它)
★ 数据库驱动(★看 DB-API 的 threadsafety 属性★)
import psycopg2; print(psycopg2.threadsafety) # 2
级别定义(PEP 249):
0 = 完全不能共享
1 = ★模块可共享,连接不能★(最常见)
2 = 模块和连接可共享,★游标不能★
3 = 都可以共享
常见值:sqlite3=1(或按版本 2/3)、psycopg2=2、mysqlclient=1、pymysql=1
✓ ★通用做法:连接池 + 每个线程/每个请求取一个连接★
(SQLAlchemy 的 QueuePool 就是干这个的,且它★不允许连接跨线程共享★)
★ 科学计算
numpy 数组
✓ 多线程★只读★安全
✗ 并发写同一块内存 → ★数据竞争(不会崩,但结果错)★
★ 底层 BLAS/OpenMP ★自己会起线程池★ → 和你的多线程叠加会超订
→ 设 OMP_NUM_THREADS=1
pandas.DataFrame
✗ ★不保证线程安全★,并发写必出问题
✓ 每个线程处理自己的切片,最后再合并
★ 云 SDK / 客户端
boto3:★Session 不是线程安全的,但 client/resource 是★
→ 常见做法:每线程一个 session,或用同一个 client(★文档明确说 client 线程安全★)
redis-py:Redis 客户端是线程安全的(内部连接池)
kafka-python / confluent-kafka:Producer 线程安全,★Consumer 不是★
elasticsearch-py:客户端线程安全
★ Web 框架
Flask 的 g / request:★基于上下文(thread-local / contextvars)★,天然隔离
Django 的 ORM:★连接是 thread-local 的★,每个线程自己的连接
★ 但你自己写的"模块级全局变量"在任何框架里都不安全
★ 判断一个库是否线程安全的四步法:
① ★查文档★:搜 "thread safety" / "thread-safe" / "concurrency"
(安全的库通常会明确写出来,★没写就按不安全处理★)
② ★看 API 形态★:
有"连接/会话/游标/缓冲区/迭代器状态"的对象 → ★大概率不安全★
纯函数、不可变对象 → 安全
③ ★看源码有没有全局可变状态和锁★:
模块级的 dict/list/计数器、缓存、单例 → 危险信号
内部有 threading.Lock → 作者考虑过并发
④ ★不确定就每线程一份★(threading.local)——★最省事的通用解法★
第三方库要逐个确认。requests.Session 官方明确说「不保证线程安全」(内部连接池和 cookie jar 有竞态,实践中大多能用但可能出现 cookie 串),推荐每线程一个 Session;httpx.Client 文档说是线程安全的。数据库驱动看 DB-API 的 threadsafety 属性(PEP 249 定义 0~3 级:1 = 模块可共享但连接不能,最常见;2 = 连接可共享但游标不能),通用做法是连接池 + 每线程取一个连接。科学计算:numpy 数组只读安全、并发写会数据竞争(不崩但结果错),而且底层 BLAS 自己会起线程池要设 OMP_NUM_THREADS=1;pandas 明确不保证线程安全。云 SDK 各不相同:boto3 的 Session 不安全但 client 安全、redis-py 安全、Kafka 的 Producer 安全而 Consumer 不安全。判断方法是四步:查文档搜 “thread safety”(没写就按不安全处理)→ 看 API 形态(有连接/会话/游标/迭代器状态的大概率不安全)→ 看源码有无全局可变状态和锁 → 不确定就每线程一份。
五、怎么写线程安全的代码和库
★ 优先级从高到低的五种策略:
① ★不共享(最好的策略)★
- 每线程一份:threading.local(协程用 contextvars)
- 参数传递代替全局变量
- 每个任务处理自己的数据切片,最后合并
→ ★没有共享就没有并发问题★
② ★共享不可变数据★
- 用 tuple/frozenset/不可变对象
- 需要更新时"复制 + 原子替换引用"(★RCU 模式★)
→ 读者零开销、无锁
③ ★用线程安全的容器传递★
- queue.Queue 是线程间通信的★标准答案★
- 生产者-消费者模型天然避免共享状态
④ ★加锁★
- with lock: 保护"读-改-写"和"多变量不变式"
- ★锁的粒度:够用就好★(太粗损性能,太细易死锁)
- ★统一加锁顺序★防死锁
⑤ 原子操作(★最后考虑★)
- 只在被证实是热点、且非常清楚语义时使用
★ 写库时的自查清单(★别让用户替你踩坑★):
□ 有模块级可变全局变量吗?(配置、缓存、计数器、单例)
□ 有延迟初始化吗?(★双重检查在 Python 里也需要锁★)
□ 对象内部有可变状态吗?(连接、缓冲区、游标、迭代位置)
□ ★在文档里明确写出线程安全性★(这是对用户最大的善意)
□ 提供"每线程一个实例"的推荐用法或工厂函数
□ ★在自由线程构建(3.13t)下跑一遍测试★(很多竞态会直接暴露)
★ 延迟初始化的正确写法(常见的坑):
✗ if _client is None: _client = create() # ★两个线程可能都进来★
✓ 方案 A:★模块级初始化★(import 锁保证只执行一次)——最简单
_client = create() # 模块顶层
✓ 方案 B:加锁
with _lock:
if _client is None:
_client = create()
✓ 方案 C:functools.lru_cache / functools.cache
@functools.cache
def get_client(): return create() # ★内部有锁★
✓ 方案 D:threading.local(每线程一个,不需要同步)
★ 测试线程安全的手段:
① ★sys.setswitchinterval(0.000001)★ —— 提高切换频率,★放大竞态★
② 压力测试:N 个线程 × M 次操作,最后断言"最终一致性"
(如 100 线程各加 1000 次,结果必须正好是 100000)
③ ★在自由线程构建下跑★(3.13t,真并行会让竞态必现)
④ 用 faulthandler + py-spy dump 排查死锁
⑤ 代码审查:搜索所有模块级可变变量和"检查后修改"模式
写线程安全代码的策略按优先级排:① 不共享(最好的策略)——每线程一份、参数传递代替全局变量、每个任务处理自己的切片;② 共享不可变数据(需要更新时用「复制 + 原子替换」的 RCU 模式,读者零开销);③ 用 queue.Queue 传递(线程间通信的标准答案);④ 加锁(粒度够用就好,统一加锁顺序防死锁);⑤ 原子操作(最后考虑)。写库时的自查清单里最重要的是:有没有模块级可变全局变量、延迟初始化有没有加锁、在文档里明确写出线程安全性(这是对用户最大的善意)。延迟初始化是常见的坑——if _client is None: _client = create() 会让两个线程都进来,最简单可靠的解法是模块级初始化(import 锁保证只执行一次),其次是加锁或用 functools.cache(内部有锁)。测试手段里最有效的两个是:sys.setswitchinterval(0.000001) 提高切换频率放大竞态,以及在自由线程构建(3.13t)下跑测试(真并行会让竞态必现)。
六、自由线程时代的变化
★ 无 GIL(3.13t)之后,这套结论会怎么变:
层次一(不崩溃):
★仍然保证★——但机制变了:从"一把大锁"变成"内置容器的细粒度锁 + 无锁读"
→ list/dict 的内部结构仍然不会被写坏
层次二(单个操作原子):
★大体仍然成立★(CPython 为内置容器加了 per-object 锁)
→ 但★不要依赖★:具体哪些操作原子可能随版本变化
层次三(组合操作):
★问题被大幅放大★
有 GIL 时:切换点有限(每 5ms 或阻塞调用),竞态窗口小 → "偶尔出错"
无 GIL 时:★真正并行执行★ → 竞态窗口随时存在 → "几乎必然出错"
实测:4 线程各 +100 万,有 GIL 时通常正好 400 万,
无 GIL 时常在 ★150 万~300 万★
★ 结论:★过去"侥幸没出问题"的代码会开始出问题★
→ 但正确的代码(用了 Lock/Queue)不受任何影响
→ 这也说明"一律加锁"这个建议一直是对的
★ 第三方库的适配:
未声明支持自由线程的 C 扩展 → ★解释器会自动重新启用 GIL★
→ 所以短期内很多库仍然跑在有 GIL 的模式下
→ 但★纯 Python 库的线程安全问题会先暴露★
★ 现在就该做的(无论用不用无 GIL):
□ 共享可变状态一律加锁或改用 Queue
□ 消除模块级可变全局状态
□ 库作者审视缓存、单例、延迟初始化
□ ★CI 里加一个自由线程构建的测试任务★(提前发现)
★ 最终心法(★一句话记住整题★):
★"GIL 只保证解释器不崩溃,不保证你的逻辑正确;
别去背哪些操作碰巧原子,共享可变状态一律用 Lock 或 Queue;
不确定某个库是否线程安全,就每线程一份。"★
自由线程(no-GIL)时代这套结论会有变化。层次一(不崩溃)仍然保证,但机制从「一把大锁」变成「内置容器的细粒度锁 + 无锁读」;层次二(单个操作原子)大体仍成立但更不该依赖;层次三(组合操作)的问题被大幅放大——有 GIL 时切换点有限、竞态窗口小、表现为「偶尔出错」,无 GIL 时真正并行、窗口随时存在、几乎必然出错(实测 4 线程各加 100 万,有 GIL 时通常正好 400 万,无 GIL 时常在 150 万~300 万)。结论是:过去「侥幸没出问题」的代码会开始出问题,而正确的代码(用了 Lock/Queue)不受任何影响——这恰恰说明「一律加锁」这个建议一直是对的。现在就该做的是:消除模块级可变全局状态、共享状态一律加锁、库作者审视缓存和单例、CI 里加一个自由线程构建的测试任务。整题的心法可以浓缩成一句:「GIL 只保证解释器不崩溃、不保证你的逻辑正确;别去背哪些操作碰巧原子,共享可变状态一律用 Lock 或 Queue;不确定某个库是否线程安全,就每线程一份。」
记忆钩子:「★『线程安全』有三个层次★:①进程不崩溃(解释器内部结构不被写坏)②单个操作完整(d[k]=v 不会写一半)③★一组操作的正确性★——★GIL 只保护前两层,99% 的并发 bug 都在第三层★。精确表述:『GIL 保证同一时刻只有一个线程执行字节码,所以单条字节码原子、解释器不崩溃;但★不保证一条 Python 语句原子★(x += 1 是 LOAD/ADD/STORE 三条),更不保证多条语句的组合原子』。★别去背『哪些操作碰巧原子』★——那是 CPython 的实现细节不是语言保证(PyPy 不保证、版本会变、★自由线程下内置容器改用细粒度锁★),而且极易误判(D[k]=v 原子但 ★D[k]=compute() 期间会切换★,L.append(x) 原子但 L.append(f(x)) 不是整体)。★共享可变状态一律用 Lock 或 Queue★(锁只要 50~100ns)。速查:★queue.Queue/logging/threading 原语/lru_cache 明确安全★(但 logging ★不是进程安全★);★random 全局函数有锁但所有线程争抢同一状态★(建议每线程一个 Random 实例);不可变对象和 ★re.compile 后的 Pattern★ 天然安全;★sqlite3 默认 check_same_thread=True 禁止跨线程★(放开后要自己串行化,推荐每线程一个连接);★requests.Session 官方说不保证★(每线程一个);数据库驱动看 ★DB-API 的 threadsafety 属性★(1=连接不能共享、2=游标不能共享);★numpy 只读安全、并发写会数据竞争★且底层 BLAS 自带线程池要设 OMP_NUM_THREADS=1;★pandas 不保证★;boto3 的 ★Session 不安全但 client 安全★;★文件对象被多线程写会交错★;★迭代器/生成器并发 next() 会 ValueError★。判断库是否安全四步:★查文档搜 thread safety(没写就按不安全处理)→ 看有没有连接/会话/游标/缓冲区状态 → 看有无模块级可变全局状态和锁 → 不确定就每线程一份(threading.local,协程用 contextvars)★。延迟初始化最简单可靠的写法是★模块级初始化★(import 锁保证只执行一次)或 functools.cache。」
七、常见误区与追问
- 误区:Python 有 GIL,所以多线程代码天然是线程安全的。 GIL 保证的是**「同一时刻只有一个线程执行 Python 字节码」——它保护的是解释器内部的数据结构**(防止两个线程同时修改一个 list 的内部指针导致段错误),完全不保护你的业务逻辑。因为它不保证一条 Python 语句是原子的:
counter += 1编译成LOAD/ADD/STORE三条字节码,线程随时可能在中间被切走(默认每 5ms 或遇到阻塞调用);更不保证多条语句的组合是原子的。所以if k not in d: d[k] = v、x += 1、「扣库存 + 记流水」这些都需要显式加锁。正确的理解是:GIL 让你的程序不会崩溃,但不会让它正确。 - 误区:
list.append是原子的,所以我可以放心地不加锁。 「原子」这件事有三个陷阱。① 它是 CPython 的实现细节,不是语言保证——文档只是「提到」某些操作原子,PyPy、Jython、GraalPy 都不保证,而且 3.11 的字节码重构、3.13 自由线程构建里内置容器改用细粒度锁都可能改变边界。② 极易误判范围:L.append(x)原子,但L.append(f(x))不是一个整体(f(x)执行期间会切换);D[k] = v原子,但D[k] = compute()不是。③ 单个操作原子解决不了组合问题——即使append原子,if len(L) < 10: L.append(x)依然会超出 10 个。所以正确做法是共享可变状态一律用Lock或Queue:锁的开销只有 50~100ns,换来的是「不用思考」和「换实现也不会坏」。 - 误区:
requests.Session可以在多个线程间共享,反正大家都这么用。 官方文档明确说明 Session 不保证线程安全。内部的连接池状态和 cookie jar 在并发访问下存在竞态:可能出现 cookie 被串到别的请求、连接被重复归还或状态错乱、以及在连接池满时的竞争问题。实践中「大多数时候能用」是因为竞态窗口小,但在高并发下会出现难以复现的诡异 bug(认证信息串号、偶发的连接错误)。推荐做法是每线程一个 Session(用threading.local惰性创建),这样既保留了连接复用的好处,又避免了共享状态。补充两点:urllib3的PoolManager本身是线程安全的(requests 底层用的就是它),而httpx.Client文档声明是线程安全的(但AsyncClient是给协程用的,别混)。 - 误区:
sqlite3连接可以设check_same_thread=False后随便跨线程用。 那个参数只是关闭了 Python 层的检查,并不会让连接变得线程安全。SQLite 连接内部有共享的游标状态和事务状态,多线程并发使用同一个连接会导致结果集错乱、事务边界混乱、甚至database is locked错误。正确做法有三种:① 每线程一个连接(推荐——SQLite 支持多个连接同时读,写操作由文件锁串行化);② 保留单连接但自己加锁,把所有数据库操作串行化;③ 用连接池(SQLAlchemy 的QueuePool默认就不允许连接跨线程共享)。另外可以查sqlite3.threadsafety属性了解底层库的支持级别(PEP 249 定义的 0~3 级),以及注意「写操作会锁整个数据库文件」这个 SQLite 的固有特性——高并发写场景本来就不该选它。 - 误区:文档里没提线程安全,说明作者觉得没必要提,应该是安全的。 恰恰相反——Python 生态的惯例是「安全的会明确写出来」(
queue、logging、urllib3、httpx都在文档里声明了),没有声明就应该按不安全处理。判断的辅助信号很实用:看 API 形态——凡是持有「连接、会话、游标、缓冲区、迭代位置」这类内部可变状态的对象,大概率不安全(HTTP Session、数据库连接、文件对象、生成器);纯函数和不可变对象则天然安全。还可以看源码:有模块级的可变 dict/list/计数器/缓存是危险信号,内部用了threading.Lock说明作者考虑过并发。实在不确定时,「每线程一份实例」(threading.local)是最省事的通用解法——代价通常只是多占一点内存和多几次初始化。 - 追问:为什么
random模块的全局函数「线程安全」却仍然建议每线程一个实例? 因为**「不会崩溃」和「适合多线程使用」是两回事**。random的全局函数(random.random()、random.choice()等)背后是一个共享的 Mersenne Twister 状态对象,CPython 的实现保证了单次调用的原子性,所以多线程调用不会把内部状态搞坏。但有两个实际问题:① 锁争用——高频取随机数时所有线程抢同一把锁,成为性能瓶颈;② 可复现性丧失——random.seed(42)之后,单线程能得到确定的序列,但多线程交错取数会让每个线程拿到的子序列不可预测,测试和调试变得困难。解法是每线程一个random.Random()实例(用threading.local惰性创建),既消除争用又让每个线程的序列独立可控。同样的道理适用于 numpy:多线程场景应该用np.random.default_rng()为每个线程创建独立的生成器,或用SeedSequence.spawn()生成互不相关的子种子。 - 追问:延迟初始化(单例)在 Python 里怎么写才线程安全?
if _client is None: _client = create()是不安全的——两个线程可能同时通过判断,各自创建一个实例(如果create()有副作用,比如建立连接或注册回调,问题会更严重)。四种正确写法按推荐度排:① 模块级初始化——直接在模块顶层写CLIENT = create(),Python 的导入锁保证模块只被初始化一次,这是最简单可靠的方案(代价是导入时就执行,如果初始化很慢会拖慢启动);②functools.cache/lru_cache装饰的工厂函数——内部有锁,写法简洁;③ 显式双重检查加锁——if _c is None: with _lock: if _c is None: _c = create()(注意 Python 里内层判断同样必要);④threading.local——每线程一个实例,根本不需要同步(适合「实例本身不该共享」的场景,如数据库连接、HTTP Session)。要避免的是「用原子性投机」——比如指望dict.setdefault来做单例,那样create()仍会被调用多次。 - 追问:自由线程(no-GIL)会让这些结论失效吗? 核心结论不变,但「侥幸能跑」的空间被大幅压缩。层次一(不崩溃)仍然保证——CPython 为内置容器实现了细粒度锁和无锁读,
list/dict的内部结构依然不会被写坏;层次二(单个操作原子)大体仍成立,但更不该依赖(具体边界可能随版本变化);层次三(组合操作)的问题被放大到必现——有 GIL 时线程切换点有限(每 5ms 或阻塞调用),竞态窗口小、表现为「跑一万次错一次」;无 GIL 时线程真正并行执行,窗口随时存在,实测 4 个线程各做 100 万次counter += 1,有 GIL 时通常恰好得到 400 万,无 GIL 时常在 150 万~300 万之间。所以结论是:过去侥幸没出问题的代码会开始出问题,而正确的代码(用了Lock/Queue)不受任何影响——这恰恰证明「一律加锁」的建议一直是对的。现在就该做的是消除模块级可变全局状态、给共享状态加锁,并在 CI 里加一个跑在python3.13t上的测试任务来提前暴露问题。
八、加强记忆
「线程安全」有三个层次:① 进程不崩溃(解释器内部结构不被写坏);② 单个操作完整(d[k]=v 不会写一半);③ 一组操作的正确性——GIL 只保护前两层,而 99% 的并发 bug 都在第三层。精确表述是:「GIL 保证同一时刻只有一个线程执行字节码,所以单条字节码原子、解释器不会崩溃;但它不保证一条 Python 语句是原子的(x += 1 是 LOAD/ADD/STORE 三条),更不保证多条语句的组合是原子的。」别去背「哪些操作碰巧原子」——那是 CPython 的实现细节而非语言保证(PyPy 不保证、版本会变、自由线程下内置容器改用细粒度锁),而且极易误判(D[k]=v 原子但 D[k]=compute() 期间会切换,L.append(x) 原子但 L.append(f(x)) 不是整体,即使 append 原子也挡不住 if len(L)<10: L.append(x) 超限)。共享可变状态一律用 Lock 或 Queue(锁只要 50~100ns)。速查:queue.Queue、logging、threading 原语、lru_cache 明确安全(但 logging 不是进程安全);random 全局函数有锁但所有线程争抢同一状态(建议每线程一个 Random() 实例,兼顾性能与可复现性);不可变对象和 re.compile 后的 Pattern 天然安全;sqlite3 默认 check_same_thread=True 禁止跨线程(放开后必须自己串行化,推荐每线程一个连接);requests.Session 官方说不保证(每线程一个);数据库驱动看 DB-API 的 threadsafety 属性(1 = 连接不能共享、2 = 游标不能共享);numpy 只读安全、并发写会数据竞争且底层 BLAS 自带线程池要设 OMP_NUM_THREADS=1;pandas 不保证;boto3 的 Session 不安全但 client 安全;同一文件对象被多线程写会交错;迭代器/生成器并发 next() 会抛 ValueError。判断库是否安全的四步法:查文档搜 “thread safety”(没写就按不安全处理)→ 看有没有连接/会话/游标/缓冲区状态 → 看有无模块级可变全局状态和锁 → 不确定就每线程一份(threading.local,协程用 contextvars)。最后,延迟初始化最简单可靠的写法是模块级初始化(导入锁保证只执行一次)或 functools.cache。