Python 的 GIL 与 asyncio 协程原理
Python 的 GIL 与 asyncio 协程原理
很多人第一次在 Python 里写多线程,都是被结果打脸的:开了 8 个线程做同一个计算任务,耗时和单线程几乎一样,CPU 却只跑满一个核。第二次尝试换成 asyncio,写了一堆
async/await,结果某个同事在协程里调了一个同步的requests.get,整个服务瞬间卡死。这两个现象背后是同一套东西:CPython 的执行模型,以及建立在它之上的事件循环。把这两件事讲清楚,你才知道什么时候该用线程、什么时候该用进程、什么时候才轮到协程。
先看现场:多线程为什么没有变快
import threading, time
def burn(n):
s = 0
for i in range(n):
s += i * i
return s
N = 20_000_000
t = time.perf_counter()
burn(N)
print("single:", round(time.perf_counter() - t, 2))
t = time.perf_counter()
ts = [threading.Thread(target=burn, args=(N,)) for _ in range(4)]
for x in ts: x.start()
for x in ts: x.join()
print("4 threads:", round(time.perf_counter() - t, 2))
在一台 8 核机器上,典型结果大概是 single: 2.1、4 threads: 2.2。四个线程几乎没有带来任何收益。用 top 看,CPU 占用率稳定在 100% 左右,也就是只有一个核在干活。
原因不是操作系统调度器坏了,而是 CPython 解释器在每个进程里只有一把"全局解释器锁":任何时刻,同一个进程内只有一个线程能执行 Python 字节码。这就是 GIL。
GIL 到底锁住了什么
GIL 不是锁住你的变量,也不是锁住某个对象,它锁住的是解释器本身。一个线程想执行下一条字节码,必须先持有 GIL;执行一小段时间后,它会主动释放,让别的线程有机会拿。
这带来一个非常容易误解的结论:GIL 不等于线程安全。x += 1 在字节码层面是 LOAD_FAST / LOAD_CONST / BINARY_OP / STORE_FAST 好几条指令,线程可能在任意两条之间被切走,所以共享计数器的自增依然会丢更新:
counter = 0
def inc():
global counter
for _ in range(1_000_000):
counter += 1
# 4 个线程跑完,counter 通常远小于 4_000_000
GIL 只保证"字节码不会被执行到一半被另一个线程插入",不保证"多条字节码组成的一个逻辑操作"是原子的。所以该加的锁一个都不能少。
为什么要有 GIL
核心原因是 CPython 的内存管理方式:引用计数。每个对象有一个 ob_refcnt,引用加减就加减这个整数。如果多个线程同时改同一个对象的引用计数,计数就会错,对象可能被提前释放(悬垂指针,直接段错误)或者永远不释放(内存泄漏)。
要无锁地做到线程安全,就得给每个对象的引用计数加原子操作,或者用更复杂的 GC 方案。1990 年代做这个选择的成本太高,于是 Guido 选择了"一把大锁 + 简单实现"。这个决定带来了两个长期后果:
- 单线程性能好:没有原子操作开销,解释器实现简单。
- C 扩展生态繁荣:大量 C 扩展(NumPy、lxml、数据库驱动)默认假设调用时持有 GIL,可以放心地操作 Python 对象。
四十年后,GIL 成了多核时代的枷锁,但它同时也是整个生态的地基。这也解释了为什么"去掉 GIL"这件事推进得如此谨慎。
GIL 什么时候会释放
这点非常关键,因为它决定了线程在什么场景下有用。线程会在以下时机释放 GIL:
- 字节码计数/时间片到期:默认每 5ms(
sys.getswitchinterval())强制切换一次。 - 阻塞式系统调用:读写文件、
socket.recv、time.sleep、select等,调用前主动释放 GIL,返回后再重新获取。 - C 扩展显式释放:NumPy 的大部分计算、
zlib压缩、hashlib的update等,会在执行重计算前用Py_BEGIN_ALLOW_THREADS放开 GIL。
所以线程的真实价值边界很清楚:
- I/O 密集(网络、磁盘、数据库):线程有用,因为等待期间 GIL 是放开的。
- 纯 Python 的 CPU 密集:线程无用,四个线程抢一把锁,还可能因为切换带来额外开销。
- 调用释放 GIL 的 C 扩展做计算(如 NumPy 矩阵运算):线程有用,因为计算发生在 C 层且不持锁。
3.13 之后的变化
Python 3.12 通过 PEP 684 支持了子解释器各自拥有独立的 GIL,但要用 interpreters 模块显式创建,普通 threading 用不到。
Python 3.13 通过 PEP 703 提供了一个实验性的 free-threading 构建(python3.13t),编译时可以选择关闭 GIL。它是真的没有 GIL 了,代价是:
- 单线程性能有可感知的下降(早期版本大约 10%~20%,随版本在收敛)。
- 大量依赖 GIL 隐式保护的 C 扩展需要重写,否则会崩。
- 内存占用上升,因为要支持偏向锁、延迟引用计数之类的机制。
结论是:今天写业务代码,仍然要按"有 GIL"来设计。不要把 3.13t 当成解决方案,它现在更适合做实验和适配测试。真要吃满多核,用多进程。
三条并发路线的选择
| 路线 | 并发单位 | 适合 | 不适合 | 主要成本 |
|---|---|---|---|---|
| 多线程 | threading.Thread |
I/O 密集、调用了释放 GIL 的 C 扩展 | 纯 Python CPU 密集 | 线程创建、上下文切换、共享状态要加锁 |
| 多进程 | multiprocessing / ProcessPoolExecutor |
CPU 密集、需要吃满多核 | 需要频繁共享大对象 | 进程启动、序列化、内存翻倍 |
| 协程 | asyncio |
高并发 I/O(几千连接) | CPU 密集、同步库 | 全链路必须异步,一个阻塞调用就拖垮全局 |
线程和进程的边界可以用一句话概括:能不能吃满多核。ProcessPoolExecutor 默认按 CPU 核数起进程,每个进程一份独立解释器和独立 GIL,天然并行。
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
results = list(pool.map(burn, [N] * 8))
上面那段 burn 用进程池跑,8 核机器上耗时基本能压到单进程的 1/7 左右。注意两点:传给子进程的参数和返回值都要 pickle,大对象会让序列化成本超过计算收益;Windows 上用的是 spawn 启动方式,子进程会重新 import 主模块,所以入口必须放在 if __name__ == "__main__": 里。
协程则是另一个维度:它不是"更快的线程",而是用单线程 + 事件循环承载海量并发连接。一个协程在等待 I/O 时把控制权交还给事件循环,循环去推进别的协程,所以几万个并发连接只对应一个线程,没有线程栈开销,也没有线程切换开销。
asyncio 是怎么跑起来的
抛开 async/await 的语法糖,asyncio 的核心只有三样东西:事件循环、Future、Task。
事件循环
事件循环本质上是一个 while True,每一轮做三件事:
- 从 selector 里取出"已经就绪的 I/O 事件"(Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 IOCP/select)。
- 把就绪事件对应的回调放进待执行队列。
- 执行队列里的回调,以及到期的定时器回调。
把这些回调换成协程,就是 asyncio 的调度模型。可以用一个极度简化的事件循环看清这个结构:
import selectors, types
class MiniLoop:
def __init__(self):
self.sel = selectors.DefaultSelector()
self.ready = []
def call_soon(self, fn, *args):
self.ready.append((fn, args))
def run(self):
while True:
for fn, args in self.ready:
fn(*args)
self.ready.clear()
if not self.sel.get_map():
break
for key, _ in self.sel.select(timeout=1):
self.ready.append((key.data, ()))
真实实现(asyncio/base_events.py 里的 _run_once)要复杂得多,但骨架就是这个:selector 等 I/O,就绪后执行回调,回调里可能再注册新的 I/O。
协程、Task、Future 的关系
- 协程函数(
async def)调用后返回一个协程对象,它自己不会执行,必须被驱动。 - Future 是一个"占位结果",有
set_result/set_exception,可以被await。 - Task 是协程的包装器,它把协程注册进事件循环,并在协程让出时安排下一次推进。
await 的语义是:“我这个协程现在要等一个 Future,等它完成后再从挂起点继续执行”。在实现层面,await 会通过 send() 把执行权交回驱动方(Task/事件循环),协程在内部被编译成一个状态机,挂起点就是状态机的分叉点。
一个最小的例子可以验证这件事:
import asyncio
async def worker(name, n):
for i in range(n):
print(name, i)
await asyncio.sleep(0) # 主动让出,回到事件循环
async def main():
await asyncio.gather(worker("A", 2), worker("B", 2))
asyncio.run(main())
输出是交错的 A 0 / B 0 / A 1 / B 1。注意 asyncio.sleep(0) 不是"睡 0 秒",而是"让出一次控制权"。这也说明了协程并发的本质:并发发生在 await 点上。如果你的协程从头到尾没有 await,它就是一段顺序执行的普通代码。
asyncio.run() 做的事情是:创建事件循环、把 main() 包成 Task、跑循环直到 Task 完成、清理未完成的 Task 并关闭循环。它只能调用一次,且不能在已有循环里嵌套调用(Jupyter 里报 This event loop is already running 就是这个原因)。
一个必须记住的细节:协程对象不执行
async def hello():
print("hi")
hello() # 什么都不打印,只是创建了一个协程对象
asyncio.run(hello()) # 这样才会执行
忘记 await 是最高频的 bug 之一。Python 会在运行时给出 RuntimeWarning: coroutine 'hello' was never awaited,但这个警告经常被日志淹没,代码就这么"看起来跑通了但什么都没做"。
协程性能的生死线:不要在协程里阻塞
事件循环是单线程的。任何一个同步阻塞调用,都会让所有协程一起卡住。这句话不是修辞,是字面意思。
async def handler():
resp = requests.get("https://example.com/slow") # 阻塞 3 秒
return resp.text
这 3 秒里,事件循环发不出新的请求、处理不了已完成的连接,整个服务 100% 不可用。同样是 3 秒,在 Flask 的一个线程里只会卡住那一个请求。
常见的阻塞源,包括但不限于:
requests、urllib、大部分同步数据库驱动(pymysql、psycopg2)。time.sleep(要用asyncio.sleep)。- 同步的文件读写(大文件尤其明显)。
- CPU 密集计算:即使是纯 Python 循环,也会一直占用事件循环。
- 某些库在初始化时做的耗时操作(比如加载大模型、读大 JSON)。
处理办法分两类:
换成异步库。requests → httpx.AsyncClient / aiohttp,pymysql → aiomysql / asyncpg,redis → redis.asyncio,文件 I/O → aiofiles 或 asyncio.to_thread。这条路彻底,但依赖链要一起换。
扔到线程/进程池。库没有异步版本时,用 asyncio.to_thread(3.9+)把阻塞调用丢给默认线程池,或者 loop.run_in_executor(ProcessPoolExecutor(), fn) 把 CPU 任务丢给进程池:
import asyncio
async def fetch_sync(url):
# 阻塞调用放到线程池,事件循环继续跑
return await asyncio.to_thread(requests.get, url, timeout=5)
async def crunch(data):
# CPU 密集放到进程池,绕开 GIL
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, heavy_compute, data)
这里要注意线程池容量。asyncio.to_thread 用的是循环的默认 executor,默认上限是 min(32, os.cpu_count() + 4)。如果每个请求都往里丢一个阻塞调用,池子很快被打满,后面的调用就开始排队,表现出来是"延迟越来越高但 CPU 不高"。必要时用独立的有界 executor,配合信号量限制并发。
一个能跑的高并发抓取示例
把上面的点串起来:限流、超时、异常隔离、批量并发。
import asyncio, httpx
URLS = [f"https://httpbin.org/delay/1?i={i}" for i in range(200)]
async def fetch(client, url, sem):
async with sem:
try:
r = await client.get(url, timeout=5.0)
return url, r.status_code
except Exception as e:
return url, type(e).__name__
async def main():
sem = asyncio.Semaphore(50) # 最多 50 个在途
limits = httpx.Limits(max_connections=60) # 连接池上限略大于并发
async with httpx.AsyncClient(limits=limits) as client:
tasks = [fetch(client, u, sem) for u in URLS]
results = await asyncio.gather(*tasks, return_exceptions=False)
from collections import Counter
print(Counter(code for _, code in results).most_common(5))
asyncio.run(main())
几个要点:
Semaphore(50)控制的是进入请求的并发数,不是连接数。两者的关系是"信号量 ≤ 连接池上限",否则信号量形同虚设。return_exceptions=False时,任何一个任务抛异常都会让gather直接抛出,但其他任务不会被取消,它们会继续跑完,异常被吞掉。想要"全部跑完再统一看结果",用return_exceptions=True,或者用asyncio.TaskGroup(3.11+)——它在任何一个子任务失败时会取消同组的所有任务,语义更干净。- 超时必须同时设在客户端和任务层。只设
httpx的 timeout 挡不住 DNS 卡住之类的极端情况,外层再套asyncio.wait_for更稳。 - 对同一个域名压测时要小心,服务端限流会让你误以为代码有问题。
一个粗略的量级参考:在单机、网络 RTT 50ms 的条件下,200 个 1 秒延迟的请求,串行要 200 秒,50 并发大约 4 秒。同样的任务用 200 个线程也能做到,但线程栈占用的内存和上下文切换成本会高一个数量级。这就是协程在高并发 I/O 场景的优势所在。
常见坑清单
- 在异步代码里调同步库。最致命,也最常见。上线前用
asyncio.debug = True或PYTHONASYNCIODEBUG=1跑一遍,能抓到执行时间过长的回调。 - 忘记 await。协程对象不会自动执行,注意看
RuntimeWarning。 - 用
asyncio.gather做无限并发。10000 个任务同时打出去,先挂的可能是自己的连接池或者对端。 - 在协程里做 CPU 密集计算。事件循环被占满,表现为"并发数上不去、P99 飙升"。
- 线程池里再套 asyncio。跨线程操作事件循环必须用
loop.call_soon_threadsafe/asyncio.run_coroutine_threadsafe,直接await会报错。 - FastAPI 里的同步路由。
def定义的路径函数会被放到线程池执行,async def的才会跑在事件循环上。在async def里调同步 ORM,等于把整个服务串行化。要么全链路异步,要么老老实实用def。 asyncio.run()反复调用。每次都会新建并销毁事件循环,连接池、后台任务都会失效。长生命周期服务应该在启动时创建一次循环。- 取消不等于立即停止。
task.cancel()只是往协程里抛CancelledError,如果协程在finally里做阻塞清理,取消会被拖住,await task可能卡很久。 - 子进程里 fork + 线程。多线程程序 fork 后,子进程只有当前线程,其他线程持有的锁可能永远不释放,容易死锁。3.14 起默认启动方式改成 forkserver,就是为这个。
- 无脑用多线程做 CPU 密集。GIL 摆在那里,除了让 CPU 空转和增加切换开销,没有别的作用。
怎么选,一句话版本
- 任务是等 I/O,并发量在几百以内:线程池,代码最简单,兼容性最好。
- 任务是等 I/O,并发量上千、连接数多:asyncio,内存和调度开销都低一个量级。
- 任务是算,纯 Python:多进程,别犹豫。
- 任务是算,但计算在 NumPy/OpenCV 这类释放 GIL 的库里:线程也能用,先压测再决定。
- 任务两者混合:进程池算、事件循环调度、
run_in_executor连起来,别指望一个模型打天下。
GIL 不是 bug,是一个四十年前做出的工程权衡,今天仍然在决定 Python 的并发姿势。理解它的边界,比记住"Python 多线程是假的"这句口号有用得多:线程在 I/O 场景下一直是真的有用,协程也不是万能的加速器,它们只是把等待这件事组织得更便宜。
- 点赞
- 收藏
- 关注作者
评论(0)