← 返回题目列表

哪些 Python 库和对象是线程安全的?怎么判断?

中等 第 22 / 27 题 更新于 2026/08/01
线程安全标准库requestssqlite3

简化版

「线程安全」不是一个是非题,它至少有三个层次:① 进程不崩溃(不出现段错误、解释器状态损坏);② 单个操作不产生脏数据(一次 append、一次 dict[k]=v 是完整的);③ 一组操作的组合也是正确的(「先检查再修改」不会被插入)。GIL 只保证了第一层——它保护的是解释器内部的数据结构**(防止两个线程同时改一个 list 的内部指针导致崩溃),完全不保护你的业务逻辑。所以「Python 有 GIL 所以线程安全」是个彻底的误解。快速结论内置容器的「单个操作」在 CPython 里通常是原子的list.appenddict[k]=vd.setdefault),但组合操作绝不安全if k not in d: d[k]=vx += 1);queue.Queueloggingthreading 的同步原语是明确线程安全的(内部有锁);random 模块的全局函数线程安全但会争抢同一个状态(多线程建议每线程一个 random.Random() 实例);sqlite3 的连接默认不允许跨线程使用check_same_thread=True);requests.Session 官方明确说不是线程安全的(虽然实践中大多能用,但连接池和 cookie 有竞态);datetimere 编译后的 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.dequeappend/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 += 1LOAD/ADD/STORE 三条字节码,线程随时可能在中间被切走(默认每 5ms 一次)。所以「有 GIL 所以不用加锁」是彻底的误解。② 「某个操作是原子的」是 CPython 的实现细节,不是语言保证list.append 之所以原子,是因为它是一次 C 层调用、中间不会释放 GIL;但这没有写进语言规范,其他实现(PyPy、Jython)不保证,自由线程构建下也可能变化。所以别去背「哪些操作碰巧原子」,一律用 LockQueue。③ 文档里没写「线程安全」通常就意味着不安全:Python 生态的惯例是「安全的会明确写出来」(queuelogging 都在文档里声明了),没说的默认按不安全处理——尤其是持有连接、缓冲区、游标状态的对象(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.appenddict[k]=vsetdefaultset.add),共同点是一次 C 层调用、执行期间不释放 GIL。但不能依赖它们,有三条理由:① 不是语言规范(其他实现不保证);② 会变(3.11 的字节码重构、自由线程构建里内置容器改用细粒度锁);③ 容易误判——D[k] = v 原子,但 D[k] = compute()compute() 期间会切换L.append(x) 原子,但 L.append(f(x)) 不是一个整体,「看起来像一个操作」和「真的是一个操作」经常不一致。正确的心态是:共享可变状态一律用 LockQueue——锁的开销只有 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.environsys.path全局可变状态,运行中别改)。不可变即天然安全str/int/tupledatetime、以及 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 串),推荐每线程一个 Sessionhttpx.Client 文档说是线程安全的。数据库驱动看 DB-API 的 threadsafety 属性(PEP 249 定义 0~3 级:1 = 模块可共享但连接不能,最常见;2 = 连接可共享但游标不能),通用做法是连接池 + 每线程取一个连接科学计算:numpy 数组只读安全、并发写会数据竞争(不崩但结果错),而且底层 BLAS 自己会起线程池要设 OMP_NUM_THREADS=1pandas 明确不保证线程安全云 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 只保证解释器不崩溃、不保证你的逻辑正确;别去背哪些操作碰巧原子,共享可变状态一律用 LockQueue;不确定某个库是否线程安全,就每线程一份。」

记忆钩子:「★『线程安全』有三个层次★:①进程不崩溃(解释器内部结构不被写坏)②单个操作完整(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] = vx += 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 个。所以正确做法是共享可变状态一律用 LockQueue:锁的开销只有 50~100ns,换来的是「不用思考」和「换实现也不会坏」。
  • 误区:requests.Session 可以在多个线程间共享,反正大家都这么用。 官方文档明确说明 Session 不保证线程安全。内部的连接池状态和 cookie jar 在并发访问下存在竞态:可能出现 cookie 被串到别的请求、连接被重复归还或状态错乱、以及在连接池满时的竞争问题。实践中「大多数时候能用」是因为竞态窗口小,但在高并发下会出现难以复现的诡异 bug(认证信息串号、偶发的连接错误)。推荐做法是每线程一个 Session(用 threading.local 惰性创建),这样既保留了连接复用的好处,又避免了共享状态。补充两点:urllib3PoolManager 本身是线程安全的(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 生态的惯例是「安全的会明确写出来」queueloggingurllib3httpx 都在文档里声明了),没有声明就应该按不安全处理。判断的辅助信号很实用:看 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 += 1LOAD/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) 超限)。共享可变状态一律用 LockQueue(锁只要 50~100ns)。速查queue.Queueloggingthreading 原语、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=1pandas 不保证;boto3 的 Session 不安全但 client 安全同一文件对象被多线程写会交错迭代器/生成器并发 next() 会抛 ValueError判断库是否安全的四步法查文档搜 “thread safety”(没写就按不安全处理)→ 看有没有连接/会话/游标/缓冲区状态 → 看有无模块级可变全局状态和锁 → 不确定就每线程一份threading.local,协程用 contextvars)。最后,延迟初始化最简单可靠的写法是模块级初始化(导入锁保证只执行一次)或 functools.cache