本节目标:理解 GIL 是什么、保护什么、为什么存在,用实测数据判断线程/进程/异步的选型,并掌握 threading 的核心工具。
适用版本:Python 3.12+(实测 3.14.6)
12.1 GIL 与线程/进程模型
第 11 章我们把脚本做成了命令行工具。但只要工具稍微实用一点,就会遇到「同时处理多个任务」的需求:下载一百个文件、同时服务多个客户端、并行跑一堆计算。这时你会听到一个绕不开的词——GIL。本节先把 GIL 讲清楚,再给出「什么任务用什么模型」的决策依据。
12.1.1 GIL 是什么
GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器里的一把全局互斥锁。任何线程想执行 Python 字节码,都必须先持有它。由此得到一条核心结论:
同一时刻,一个 CPython 进程里只有一个线程在执行 Python 字节码。
注意措辞——是「执行 Python 字节码」时才需要 GIL,不是「线程存在」就需要。当线程在等 I/O、time.sleep()、或在 C 扩展里主动释放 GIL 时,锁会让出去,别的线程就能跑。
GIL 保护的是解释器内部状态的一致性:引用计数、对象分配、垃圾回收标记等。CPython 用引用计数管理内存,如果两个线程同时修改同一个对象的引用计数而不加锁,计数就会错乱,导致对象被提前回收或内存泄漏。给每个对象都加细粒度锁代价极高,于是 CPython 选择了「一把大锁」这个简单方案。
12.1.2 实测:GIL 确实存在
光讲原理不够,我们用计时把 GIL 的后果直接测出来。下面的脚本分别对 CPU 密集与 IO 密集任务,比较「单线程顺序执行」和「4 线程并发」,各跑 5 次取最小值以抵消机器噪声:
import time
import threading
def cpu_work(n: int) -> int: # 纯计算,占满 CPU
total = 0
for i in range(n):
total += i * i
return total
def io_work(seconds: float) -> float: # 模拟等待 I/O
time.sleep(seconds)
return seconds
def run(target, args, threaded):
t0 = time.perf_counter()
if threaded:
ts = [threading.Thread(target=target, args=args) for _ in range(4)]
for t in ts:
t.start()
for t in ts:
t.join()
else:
for _ in range(4):
target(*args)
return time.perf_counter() - t0
def best(target, args, threaded):
return min(run(target, args, threaded) for _ in range(5))
for label, target, args in [("CPU 密集", cpu_work, (6_000_000,)),
("IO 密集", io_work, (0.5,))]:
seq = best(target, args, False)
thr = best(target, args, True)
print(f"{label}: 单线程 {seq:.3f}s | 多线程 {thr:.3f}s | 加速比 {seq / thr:.2f}x")
CPU 密集: 单线程 1.085s | 多线程 1.028s | 加速比 1.06x
IO 密集: 单线程 2.018s | 多线程 0.507s | 加速比 3.98x
结论一目了然:
- CPU 密集任务:4 个线程跑得和 1 个线程一样快(1.06x)。GIL 把它们串行化了,多开线程纯属白忙。
- IO 密集任务:4 个线程拿到 3.98x 加速,接近理论上限。因为
sleep期间线程主动让出了 GIL。
计时绝对值受 CPU 型号、负载、后台进程影响,每次都有波动;但「CPU 密集几乎无加速、IO 密集接近线程数」这个定性结论在任何机器上都成立。
12.1.3 Thread 的基本用法与 join
最基础的线程用法:
import threading
import time
def job(name: str, seconds: float) -> None:
print(f" {name} 开始")
time.sleep(seconds)
print(f" {name} 结束")
threads = [threading.Thread(target=job, args=(f"T{i}", 0.2)) for i in range(3)]
for t in threads:
t.start() # 启动,不阻塞
for t in threads:
t.join() # 等这个线程结束
print("全部完成")
T0 开始
T1 开始
T2 开始
T0 结束
T2 结束
T1 结束
全部完成
start() 立即返回,主线程继续;join() 阻塞直到该线程结束。不 join 就退出主线程,未完成的线程可能被强制中断。若某线程「后台跑就行、不必等它」,设成守护线程:
bg = threading.Thread(target=heartbeat, daemon=True)
bg.start()
print(bg.is_alive(), bg.daemon) # True True
守护线程会在所有非守护线程结束后被自动回收,适合心跳、监控这类附属任务。
12.1.4 竞态条件与 Lock
线程共享内存,不加保护就会出竞态。看这个读-改-写循环:
import threading
N = 1_000_000
counter = 0
def next_value(v: int) -> int:
return v + 1
def worker():
global counter
for _ in range(N):
current = counter # 读
counter = next_value(current) # 计算(一次函数调用)后写回
def run():
global counter
counter = 0
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
return counter
for i in range(3):
got = run()
print(f"无锁 第{i + 1}次: counter = {got} (期望 {2 * N},丢失 {2 * N - got})")
无锁 第1次: counter = 1652878 (期望 2000000,丢失 347122)
无锁 第2次: counter = 1488634 (期望 2000000,丢失 511366)
无锁 第3次: counter = 1285499 (期望 2000000,丢失 714501)
两个线程各加 100 万次,结果却少了几十万——丢失的更新。原因是「读 → 计算 → 写回」不是原子的:线程 A 读到 100,还没写回,GIL 在函数调用处切换给线程 B,B 也读到 100,各自加 1 都写回 101,两次加法只生效一次。
一个反直觉的实测细节:若循环体只有一行
counter += 1,在 CPython 3.10+ 上往往观察不到丢失——因为 GIL 切换点落在循环回边(写回之后),这条语句碰巧成了「事实上的原子操作」。一旦读和写之间插入任何会触发切换点的操作(函数调用、I/O、sleep),竞态立刻暴露。别把「测不出来」当成「不存在」。
用 Lock 把临界区保护起来,结果就对了:
lock = threading.Lock()
def worker():
global counter
for _ in range(N):
with lock: # 同一时刻只有一个线程能进入
current = counter
counter = next_value(current)
加锁: counter = 2000000 (期望 2000000)
Lock 不可重入:同一线程连续 acquire() 两次会死锁。若临界区里要再次获取同一把锁,用 RLock(可重入锁),它记录持有者与重入次数。
12.1.5 Event、Semaphore 与 Queue
Lock 解决互斥,另外三个工具解决协作:
| 工具 | 用途 | 关键方法 |
|---|---|---|
Event | 一个线程通知另一个「可以开始了」 | set() / wait() / clear() |
Semaphore | 限制同时访问某资源的线程数 | acquire() / release() |
Queue | 线程安全的生产者-消费者队列 | put() / get() / task_done() |
Event 是最轻量的「通知」机制:一个线程调用 ready.wait() 阻塞住,另一个线程在条件成熟时调用 ready.set() 把它唤醒(常用于「初始化完成后开始工作」)。Semaphore(2) 用来限流,最多允许 2 个线程同时进入;下面用 active 计数直观看到并发上限始终不超过 2:
import threading
import time
sem = threading.Semaphore(2) # 最多 2 个线程同时进入
active = 0
guard = threading.Lock()
def limited(i):
global active
with sem: # 拿不到名额就在这里排队
with guard:
active += 1
cur = active
print(f" task-{i} 进入,当前并发 {cur}")
time.sleep(0.1)
with guard:
active -= 1
ts = [threading.Thread(target=limited, args=(i,)) for i in range(5)]
for t in ts:
t.start()
for t in ts:
t.join()
task-0 进入,当前并发 1
task-1 进入,当前并发 2
task-2 进入,当前并发 2
task-3 进入,当前并发 2
task-4 进入,当前并发 2
Queue(queue.Queue)是线程安全的生产者-消费者队列:put() 放入、get() 取出(无数据时阻塞),内部已加锁,常用哨兵值 None 通知消费者结束。它把生产与消费彻底解耦,是线程间传数据最安全的方式。
12.1.6 threading.local:线程私有数据
有时你想「每个线程各有一份同名变量」,互不干扰。threading.local() 就是为此而生:
import threading
import time
local = threading.local()
results = {}
def store(name):
local.value = name # 每个线程各有一份
time.sleep(0.01)
results[name] = local.value # 不会被别的线程覆盖
ts = [threading.Thread(target=store, args=(f"T{i}",)) for i in range(3)]
for x in ts:
x.start()
for x in ts:
x.join()
print("local:", results)
local: {'T0': 'T0', 'T1': 'T1', 'T2': 'T2'}
Web 框架常用它保存「当前请求上下文」(如数据库连接),避免把上下文一路当参数传递。
12.1.7 自由线程构建与 sys._is_gil_enabled
Python 3.13 引入了 PEP 703 的自由线程构建(俗称 no-GIL,实验性),编译时用 --disable-gil 开启。要判断当前解释器是否带 GIL,可以查询:
import sys
print("GIL 已启用:", sys._is_gil_enabled())
GIL 已启用: True
本机是标准构建,返回 True。自由线程构建下会返回 False(除非运行时又把它打开)。需要强调:自由线程目前仍是实验特性,生态兼容性、单线程性能都还在打磨,生产环境不要贸然使用;它属于「值得知道、暂不依赖」的范畴。
12.1.8 选型决策表
把本节结论浓缩成一张表:
| 任务类型 | 推荐模型 | 理由 |
|---|---|---|
| IO 密集(网络、磁盘、DB) | 线程 或 异步 | 等待时释放 GIL,可近线性提速 |
| CPU 密集(纯 Python 计算) | 多进程 | 绕过 GIL,真正并行 |
| 受 C 库释放 GIL 的计算(NumPy 等) | 线程也行 | 计算在 C 层释放 GIL,能并行 |
| 大量并发连接 | 异步(第 13 章) | 单线程事件循环,开销最低 |
一句话记忆:「等」用线程/异步,「算」用进程。 下一节我们就把「用进程算」落地成可复用的工具。
12.1.9 延伸阅读
想深入 GIL 的历史、移除尝试与性能影响,可看专题 Python 的 GIL 对并发编程有哪些影响 ;线程、进程、异步的综合对比见 Python 并发与性能 。
小结
- GIL 是 CPython 的全局锁,保证同一时刻只有一个线程执行字节码;它保护引用计数等解释器内部状态。
- 实测:CPU 密集多线程几乎无加速(1.06x),IO 密集接近线性(3.98x)——因为 I/O 等待会释放 GIL。
- 读-改-写不是原子操作,不加锁会丢失更新;用
Lock保护临界区,可重入场景用RLock。 Event做通知、Semaphore做限流、Queue做线程安全传递,threading.local存线程私有数据。- 3.13 的自由线程构建(PEP 703)仍是实验特性,用
sys._is_gil_enabled()查询当前解释器是否启用 GIL。 - 选型口诀:IO 密集用线程/异步,CPU 密集用多进程。
本节我们看清了 GIL 的边界,也知道了「CPU 密集该用进程」。下一节就把这个结论变成可复用的代码——concurrent.futures 用统一接口管理线程池与进程池。
阅读导航:上一节:argparse 与命令行工具 · 下一节:concurrent.futures 与 multiprocessing 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。