Python 的 GIL 与 asyncio 协程原理

举报
yd_236430038 发表于 2026/11/12 23:46:18 2026/11/12
【摘要】 Python 的 GIL 与 asyncio 协程原理很多人第一次在 Python 里写多线程,都是被结果打脸的:开了 8 个线程做同一个计算任务,耗时和单线程几乎一样,CPU 却只跑满一个核。第二次尝试换成 asyncio,写了一堆 async/await,结果某个同事在协程里调了一个同步的 requests.get,整个服务瞬间卡死。这两个现象背后是同一套东西:CPython 的执行模...

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 选择了"一把大锁 + 简单实现"。这个决定带来了两个长期后果:

  1. 单线程性能好:没有原子操作开销,解释器实现简单。
  2. 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,每一轮做三件事:

  1. 从 selector 里取出"已经就绪的 I/O 事件"(Linux 上是 epoll,macOS 上是 kqueue,Windows 上是 IOCP/select)。
  2. 把就绪事件对应的回调放进待执行队列。
  3. 执行队列里的回调,以及到期的定时器回调。

把这些回调换成协程,就是 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 场景的优势所在。

常见坑清单

  1. 在异步代码里调同步库。最致命,也最常见。上线前用 asyncio.debug = True 或 PYTHONASYNCIODEBUG=1 跑一遍,能抓到执行时间过长的回调。
  2. 忘记 await。协程对象不会自动执行,注意看 RuntimeWarning。
  3. 用 asyncio.gather 做无限并发。10000 个任务同时打出去,先挂的可能是自己的连接池或者对端。
  4. 在协程里做 CPU 密集计算。事件循环被占满,表现为"并发数上不去、P99 飙升"。
  5. 线程池里再套 asyncio。跨线程操作事件循环必须用 loop.call_soon_threadsafe / asyncio.run_coroutine_threadsafe,直接 await 会报错。
  6. FastAPI 里的同步路由。def 定义的路径函数会被放到线程池执行,async def 的才会跑在事件循环上。在 async def 里调同步 ORM,等于把整个服务串行化。要么全链路异步,要么老老实实用 def。
  7. asyncio.run() 反复调用。每次都会新建并销毁事件循环,连接池、后台任务都会失效。长生命周期服务应该在启动时创建一次循环。
  8. 取消不等于立即停止。task.cancel() 只是往协程里抛 CancelledError,如果协程在 finally 里做阻塞清理,取消会被拖住,await task 可能卡很久。
  9. 子进程里 fork + 线程。多线程程序 fork 后,子进程只有当前线程,其他线程持有的锁可能永远不释放,容易死锁。3.14 起默认启动方式改成 forkserver,就是为这个。
  10. 无脑用多线程做 CPU 密集。GIL 摆在那里,除了让 CPU 空转和增加切换开销,没有别的作用。

怎么选,一句话版本

  • 任务是等 I/O,并发量在几百以内:线程池,代码最简单,兼容性最好。
  • 任务是等 I/O,并发量上千、连接数多:asyncio,内存和调度开销都低一个量级。
  • 任务是算,纯 Python:多进程,别犹豫。
  • 任务是算,但计算在 NumPy/OpenCV 这类释放 GIL 的库里:线程也能用,先压测再决定。
  • 任务两者混合:进程池算、事件循环调度、run_in_executor 连起来,别指望一个模型打天下。

GIL 不是 bug,是一个四十年前做出的工程权衡,今天仍然在决定 Python 的并发姿势。理解它的边界,比记住"Python 多线程是假的"这句口号有用得多:线程在 I/O 场景下一直是真的有用,协程也不是万能的加速器,它们只是把等待这件事组织得更便宜。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。