Python GIL 与线程
从 OS Thread、CPU Core 与 CPython Interpreter 三层解释 GIL:它限制传统 CPython 同一解释器内多线程并行执行 Python bytecode,但线程仍适合阻塞 I/O,也可承载会释放 GIL 的 native computation。
[!info] related notes
Python GIL 与线程
一句话理解
GIL(Global Interpreter Lock)是传统 CPython 解释器中的一把全局锁:同一个 interpreter 内,通常只有一个 OS Thread 能在某一时刻执行 Python bytecode。
它不是“CPU 只能用一个核心”,也不是“Python 不能并发”。真正要分清的是:
CPU Core
→ 由 OS Scheduler 分配给 Thread / Process
OS Thread
→ 可以被调度到不同 CPU Core
GIL
→ 决定同一个 CPython interpreter 中,哪个 Thread 此刻有资格执行 Python bytecode
所以即使机器有 8 个核心、进程里也有 8 个线程,如果 8 个线程都在执行大量纯 Python bytecode,它们仍会竞争同一把 GIL。
为什么这个概念容易被误解
常见说法:
“Python 多线程是假的,因为 Python 只能单核。”
这句话太粗糙。
更准确:
OS Thread 本身可以多核调度
+
传统 CPython 同一 interpreter 的 Python bytecode 并行受 GIL 限制
也就是说:
Thread parallelism capability
!=
Python bytecode parallelism capability
如果线程进入 I/O 等待,或者进入会释放 GIL 的 C/C++/NumPy/PyTorch 等 native code,多个线程仍然可能获得非常有价值的并发甚至底层并行。
三层模型:OS Scheduler、GIL、Event Loop
这三者千万不要混在一起。
| 层次 | 作用 | 调度/约束对象 |
|---|---|---|
| OS Scheduler | 抢占式分配 CPU 时间 | Thread / Process |
| GIL | 限制传统 CPython bytecode 执行资格 | 同一 interpreter 内的 Thread |
| asyncio Event Loop | 协作式调度 coroutine | Task / coroutine |
例如一个 FastAPI 进程:
Python Process
├─ Thread A
│ └─ Event Loop
│ ├─ Task 1
│ ├─ Task 2
│ └─ Task 3
│
└─ Thread B
└─ blocking / native work
- Task 1/2/3 由 Event Loop 在
awaitsuspend point 协作式切换。 - Thread A/B 由 OS 抢占式调度。
- 如果 A/B 都在执行普通 Python bytecode,还要竞争同一个 GIL。
因此:
Event Loop scheduling
!= OS thread scheduling
!= GIL competition
纯 Python CPU-heavy 多线程为什么通常不加速
例如:
def crunch(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
如果开两个 Thread 同时跑:
Thread A ─┐
├─ one CPython interpreter + one GIL
Thread B ─┘
它们更像:
A: █████ █████ █████
B: █████ █████
而不是:
A: █████████████████
B: █████████████████
↑ 两份 Python bytecode 真正同时执行
因此总 CPU 工作并没有因为“线程更多”而消失,反而还有:
- GIL competition;
- context switching;
- cache disturbance;
等额外开销。
GIL 是怎么被释放/切换的
可以先记三类情况。
1. 纯 Python 长时间执行:周期性给其他线程竞争机会
一个线程不会永久霸占 GIL。解释器会周期性产生线程切换机会,让其他等待线程尝试获得 GIL。
这让多个 Python Thread 可以“交替前进”,但仍不等于同时并行执行多份 Python bytecode。
2. Blocking I/O:等待时释放 GIL
当线程进行文件、socket 等阻塞 I/O 时,它大部分时间其实没有 Python 计算可做。
许多 CPython I/O 路径会在等待期间释放 GIL:
Thread A
→ 发起 blocking I/O
→ release GIL
→ OS 等 I/O
Thread B
→ acquire GIL
→ 继续执行 Python
所以线程对 blocking I/O 仍然非常有价值。
3. Native Extension 主动释放 GIL
C/C++ extension 如果进入一段不需要操作 Python 对象的耗时工作,可以主动释放 GIL。
因此:
Python call
→ NumPy / PyTorch / BLAS / CUDA
→ native computation
可能在 native 层使用多核或 GPU,而不是被“Python 线程”这几个字限制成单核。
这也是为什么 SentenceTransformer / PyTorch Embedding 的实际并行能力必须 benchmark,而不能只靠 GIL 直觉判断。
为什么线程对 I/O-bound 很适合
假设五个同步网络请求,每个都需要等待 200ms。
单线程串行:
A wait 200
B wait 200
C wait 200
D wait 200
E wait 200
≈ 1s
多个 Thread 可以让等待重叠:
T1 → request A → wait
T2 → request B → wait
T3 → request C → wait
...
因为等待 I/O 时通常不需要持续持有 GIL,所以总 wall-clock 可以接近最慢一批 I/O,而不是简单相加。
最小示例
from concurrent.futures import ThreadPoolExecutor, as_completed
from time import perf_counter, sleep
def fetch_meta(item_id: str) -> str:
sleep(0.2) # 模拟 blocking I/O
return f"meta:{item_id}"
def main() -> None:
ids = [f"id-{i}" for i in range(5)]
t0 = perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
futures = [pool.submit(fetch_meta, i) for i in ids]
results = [f.result() for f in as_completed(futures)]
print(len(results), round(perf_counter() - t0, 2))
if __name__ == "__main__":
main()
关键收益是等待重叠,不是五个线程一起执行五份 Python bytecode。
GIL 与 asyncio.to_thread() 的关系
result = await asyncio.to_thread(blocking_fn)
会把 blocking_fn 放到 Worker Thread,让 Event Loop 所在线程可以继续调度其他 coroutine。
如果 blocking_fn 是纯 Python CPU-heavy:
Event Loop Thread
↕
GIL
↕
Worker Thread running heavy Python
那么 to_thread() 的主要收益是:
保护 Event Loop responsiveness。
它不保证 blocking_fn 自身变快或事件循环满足硬延迟,因为 Worker Thread 仍要竞争 GIL;长时间持有 GIL 的原生调用也可能卡住其他 Python 线程。取消 to_thread 的等待不会强制终止已经运行的工作线程。
因此:
Event Loop responsiveness ↑
!= CPU task latency ↓
详见 Executor、Thread、Process 与 asyncio.to_thread。
Thread 与 Process 的关键差别
Thread
same process
same memory
same interpreter
traditional CPython: same GIL
优点:
- 共享对象方便;
- 创建/切换较轻;
- 适合 blocking I/O / native library。
代价:
- shared mutable state 复杂;
- 纯 Python CPU parallelism 受 GIL 限制。
Process
Process A → interpreter A → GIL A
Process B → interpreter B → GIL B
它们可以分别被 OS 调度到不同核心,真正并行执行多份 Python bytecode。
代价:
- 独立 memory;
- IPC / pickle;
- 启动更重;
- 大模型可能复制多份。
Thread Safety:共享内存带来的另一面
线程共享同一个进程内存,因此两个 Thread 可能同时看到同一个:
shared_list = []
shared_model = ...
shared_cache = {}
这很方便,但也意味着必须思考:
谁拥有这个 mutable state?
能否并发访问?
是否需要 Lock / Queue / Semaphore?
库本身 thread-safe 吗?
例如 BodySense 如果多个 Worker Thread 同时调用一个共享 SentenceTransformer 实例,就必须确认模型调用的线程安全与底层资源竞争,而不能只看到 to_thread() 能运行。
Free-threaded CPython 是一个重要边界
CPython 3.13 起提供可选 free-threaded 构建,3.14 成为正式支持的可选构建;它可禁用 GIL,但运行时配置或不兼容扩展仍可能启用 GIL,不能只看 Python 版本号。
但在学习和审查常规生产 Python 服务时,仍应先确认实际 interpreter/build/runtime,再决定能否使用“无 GIL”心智。
审查 BodySense L4 时,若尚无当前运行时证据,可先采用以下保守分析假设;这不是对当前部署构建的已验证结论:
GIL-enabled CPython
+ asyncio Event Loop
+ explicit thread/process/native boundaries
而不是假设所有部署都已经 free-threaded。
常见误区
[!warning] “GIL = Python 只有一个线程” 错。Python 进程完全可以有多个真实 OS Thread;GIL 约束的是同一传统 CPython interpreter 中的 Python bytecode 并行执行。
[!warning] “有 8 核,所以 8 个 Python Thread 一定能同时执行纯 Python” 错。OS 可以把线程放到不同核心,但它们仍要争 GIL。
[!warning] “线程对 Python 没用” 错。blocking I/O、同步 SDK、会释放 GIL 的 native library 都是线程的重要使用场景。
[!warning] “to_thread = CPU 加速” 错。它首先是 blocking-work isolation / Event Loop protection。
[!warning] “GIL 与 Event Loop 是同一套调度机制” 错。Event Loop 调 coroutine,OS 调 Thread,GIL 只控制 interpreter bytecode 执行资格。
工程选择速查
| 工作负载 | 常见优先方案 |
|---|---|
| 原生 async HTTP / DB | asyncio native async |
| 只有 sync API 的 blocking I/O | ThreadPool / asyncio.to_thread() |
| 会释放 GIL 的同步 native compute | bounded Thread + benchmark |
| 重纯 Python CPU | ProcessPool / worker |
| 很轻的本地 CPU | inline,别过度 offload |
自测题
- OS Thread 能否被调度到不同 CPU Core?
- GIL 限制的是 CPU Core,还是 Python bytecode 执行资格?
- Event Loop、OS Scheduler、GIL 分别负责什么?
- 为什么 blocking I/O 中线程仍然有效?
- GIL 常在哪三类情况下释放/切换?
- 为什么
to_thread()可以保护 Event Loop,却不保证纯 Python CPU 任务更快? - 为什么 PyTorch/NumPy 不能简单按“Python Thread 单核”判断?
- 为什么 heavy pure Python 常更适合 ProcessPool?
本节总结
最准确的心智不是“Python 单线程”,而是:
OS 可以多线程、多核调度
+
传统 CPython 同一 interpreter 的 Python bytecode 受 GIL 限制
+
I/O / native code 可以释放 GIL
+
asyncio Event Loop 又是另一层 coroutine scheduler
理解这四层后,Thread、Process、Executor 和 asyncio 的选型就不再依赖模糊口号。