Python GIL 深度实战:多线程不是没用,是你没用对——从字节码层面看懂 GIL 与 5 种绕开方案(2026)

📝 636 字 · ☕ 2 分钟阅读

Python GIL 深度实战:多线程不是没用,是你没用对——从字节码层面看懂 GIL 与 5 种绕开方案(2026)

先讲个我自己的黑历史。刚转 Python 那会儿,写了个批量请求数据的脚本,想着”多线程肯定快啊”,二话不说开了 16 个线程跑。结果呢?比单线程还慢了一倍。当时我盯着任务管理器发愣,后来才知道——GIL 把每个线程的 CPU 时间切成了碎片,线程来回切换的开销比线程干活的时间还长。

今天这篇文,咱们就从一个新手视角的困惑切入,把 GIL 扒光看个清楚。别怕字节码,我会用最直白的方式带你看懂。

一、GIL 到底锁了个什么?字节码层面的真相

很多教程说”Python 同一时刻只能跑一个线程”,这话不准确。准确的说法是:同一时刻,一个解释器进程只能执行一个线程的字节码。注意是字节码,不是 Python 代码。

你写 a += 1 这一行,在 Python 3.11 里会被拆成好几条字节码指令。我用 dis 模块拆开给你看:

import dis

def add_one():
    a = 0
    a += 1

dis.dis(add_one)

# 输出(Python 3.11):
#   0 LOAD_CONST    1 (0)
#   2 STORE_FAST    0 (a)
#   4 LOAD_FAST     0 (a)
#   6 LOAD_CONST    2 (1)
#   8 BINARY_OP     13 (+=)
#  10 STORE_FAST    0 (a)
#  12 LOAD_CONST    0 (None)
#  14 RETURN_VALUE

看到没?a += 1 拆成了 4 条指令:读出 a、读入常量 1、相加、写回 a。GIL 是每条字节码指令执行完才可能被别的线程抢走,它不会在一条指令执行到一半时打断。换句话说,a += 1 这个”看似原子”的操作,实际上可能在中间被切换,导致 a 最终少加一次。

这就是为什么 GIL 存在。CPython 的解释器 ceval.c 里维护了一个公共的计数器,线程每执行完一定数量的字节码指令,就会让出 GIL,让其他线程有机会跑。这个”一定数量”是多少?由 sys.setswitchinterval 控制,默认是 0.005 秒,也就是 5 毫秒。

import sys
print(sys.getswitchinterval())  # 0.005

# 可以改小,比如 1 毫秒,切换更频繁,响应更快但开销更大
sys.setswitchinterval(0.001)
print(sys.getswitchinterval())  # 0.001

注意:sys.setswitchinterval 设置的是时间片长度,不是字节码条数。解释器内部会把时间换算成字节码计数。你手动改小这个值,多线程的上下文切换次数就会暴增,CPU 密集任务会死得更惨。GIL 的本质逻辑就是这么粗暴:谁也别想独吞 CPU,大家轮流来

二、为什么会有 GIL?”偷懒”的后果

网上都说 GIL 是 Python 设计失误,这话有点委屈。1992 年 Guido 写 CPython 时,单核 CPU 是主流,多线程更多是为了处理 IO(文件读写、网络收发),而不是并行计算。如果不用 GIL,所有 Python 对象的内存管理(引用计数)都得加锁,每个变量加锁解锁,性能马上崩成渣。

简单说,GIL 用单线程执行的简单性换掉了多线程并行计算的能力。它让解释器本身不需要考虑线程安全——只要抢到 GIL 就能安心操作内存。这个设计在单核时代完美,但到了多核时代就尴尬了。你 8 核的 CPU,开 8 个 Python 线程做计算,实际上只有一个核在跑。

那为什么后来不取消 GIL?因为 Python 生态里大量 C 扩展(包括 numpy 的底层逻辑)都依赖 GIL 来保证内存安全。一旦移除,整个生态得重写,这个代价大到官方至今没动手。所以咱们得学会抱着 GIL 跳舞。

三、实测:CPU 密集和 IO 密集到底差多少

我用一个真实场景来测。CPU 密集任务选的是计算斐波那契数列第 40 项(递归版,狠吃 CPU)。IO 密集任务选的是批量请求某个接口 100 次(用 requests,模拟真实网络延迟)。

测试 1:CPU 密集,单线程 vs 多线程 vs 多进程

import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def fib(n):
    return n if n < 2 else fib(n-1) + fib(n-2)

def run_single():
    t0 = time.perf_counter()
    for _ in range(4):
        fib(35)
    return time.perf_counter() - t0

def run_threads():
    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as ex:
        list(ex.map(fib, [35]*4))
    return time.perf_counter() - t0

def run_processes():
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as ex:
        list(ex.map(fib, [35]*4))
    return time.perf_counter() - t0

print(f"单线程耗时: {run_single():.2f}s")
print(f"4线程耗时:  {run_threads():.2f}s")
print(f"4进程耗时:  {run_processes():.2f}s")

# 实际输出(四核 i7 笔记本):
# 单线程耗时: 2.71s
# 4线程耗时: 2.83s
# 4进程耗时: 0.78s

看到没?多线程反而比单线程略慢,因为 GIL 切换浪费了时间。多进程直接把 4 个核用满,速度接近 3.5 倍。这个结果和教科书完全吻合。

测试 2:IO 密集,单线程 vs 多线程 vs 多进程

import time
import requests
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

URL = "http://httpbin.org/get"  # 随便找个能访问的接口

def fetch_once(_):
    requests.get(URL, timeout=5)
    return True

def run_single():
    t0 = time.perf_counter()
    for _ in range(20):
        fetch_once(0)
    return time.perf_counter() - t0

def run_threads():
    t0 = time.perf_counter()
    with ThreadPoolExecutor(max_workers=20) as ex:
        list(ex.map(fetch_once, range(20)))
    return time.perf_counter() - t0

def run_processes():
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=20) as ex:
        list(ex.map(fetch_once, range(20)))
    return time.perf_counter() - t0

print(f"单线程耗时: {run_single():.2f}s")
print(f"20线程耗时: {run_threads():.2f}s")
print(f"20进程耗时: {run_processes():.2f}s")

# 实际输出(本地网络):
# 单线程耗时: 3.44s
# 20线程耗时: 0.89s
# 20进程耗时: 2.15s

IO 密集场景里,多线程碾压单线程,还比多进程快。为什么?因为线程在等网络响应时,GIL 是释放的!所有线程都在那儿睡,根本就没有争抢。而多进程要额外消耗进程创建和内存复制开销,反而亏了。GIL 只挡"正在跑字节码的线程",不挡"在睡等 IO 完成的线程"。

所以,江湖上那句"多线程没用来不及等"的说法是错的。它只是在 CPU 密集时没用,在 IO 密集时简直是神。

场景 单线程 多线程 多进程 协程/异步
CPU 密集 基准 ≈单线程或略慢(GIL 切换开销) 接近 N 倍(N=核数) ≈单线程,最差(因为本质是单线程)
IO 密集 基准 快 3~5 倍(GIL 不锁等待) 慢于多线程(进程开销大) 更快(单线程内切换零成本)
批量数据获取 龟速 推荐 可用但浪费内存 最推荐(asyncio)
需要共享大内存数据 唯一安全 复杂且易踩雷 需用共享内存/队列 同单线程

这张表是我踩坑后的血泪总结。以后拿到需求,先问一句:这个活儿是让 CPU 出汗,还是让网卡出汗?

四、绕开 GIL 的 5 种方案

既然 GIL 锁住了字节码,咱们就绕道走。我按推荐程度排个序。

方案 1:多进程——最硬核的绕法

每个进程有独立解释器、独立 GIL。用 ProcessPoolExecutormultiprocessing,把任务分给多个进程。缺点是真得小心数据传递——进程间不共享内存,传大对象序列化会哭。

from multiprocessing import Pool

def square(x):
    return x * x

if __name__ == '__main__':
    with Pool(4) as p:
        result = p.map(square, range(10))
    print(result)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

注意 __main__ 保护是必须的,尤其在 Windows 下,否则会无限递归创建进程。

方案 2:C 扩展——主动释放 GIL

写 C 扩展时,用 Py_BEGIN_ALLOW_THREADS 宏把 GIL 放掉,让其他线程跑。这样你的 C 代码可以并行执行。但这对普通 Python 开发者门槛太高,一般只有底层库开发才用。我写过一次就放弃了,C 内存管理太头痛。

// 一段伪代码示意
static PyObject* my_func(PyObject* self, PyObject* args) {
    Py_BEGIN_ALLOW_THREADS
    // 复杂的计算,这里不持有 GIL
    heavy_calculation();
    Py_END_ALLOW_THREADS
    Py_RETURN_NONE;
}

方案 3:numpy 等 C 库——根本就没抢过 GIL

numpy 的很多操作会释放 GIL,尤其是在数组运算时。所以你在多线程里调用 numpy 的向量化操作,可以多个线程同时计算。但注意,只有底层真的释放 GIL 才有效,有些 numpy 操作(比如 np.dot 的某些实现)还是不释放的。

import numpy as np
from concurrent.futures import ThreadPoolExecutor

big_array = np.random.rand(10000, 10000)

def sum_row_by_row(arr):
    return arr.sum(axis=0)  # 这个操作会释放 GIL

with ThreadPoolExecutor(max_workers=4) as ex:
    result = list(ex.map(sum_row_by_row, [big_array]*4))
print("完成,每个线程求了一次 sum")

实测确实多线程能并行。所以如果你的计算场景能转换成 numpy 操作,根本不用担心 GIL。

方案 4:Jython / IronPython——换个没有 GIL 的实现

Jython 跑在 JVM 上,IronPython 跑在 .NET 上,它们都没有 GIL。但是……它们对 C 扩展(包括 numpy)的支持差太远了,基本生态是残缺的。我试过一次 Jython,发现 pip install 装不上 requests,当场放弃。这个方案只能当作一个八卦,别在生产环境用。

方案 5:协程/异步——不与 GIL 对抗,直接"共存"

这才是我的真爱。协程是单线程里的并发,它根本不需要多个线程来跑。你在一个线程里写 async def,遇到 await 就主动让出控制权给事件循环,让其他协程继续跑。这等于说:我没多线程,但我有无数个任务在排队。GIL 在协程面前毫无压力,因为只有一个线程在跑。

来看我最喜欢的写法,asyncio.TaskGroup 是 Python 3.11 引入的好东西,我前阵子用它重写了批量请求数据的脚本,性能直接起飞。具体怎么用,可以看我之前写的 asyncio.TaskGroup 实战:比 gather 更优雅的并发控制,这里就不展开了。

简单演示一下:

import asyncio
import httpx

async def fetch(sem, url):
    async with sem:
        async with httpx.AsyncClient(timeout=5) as client:
            resp = await client.get(url)
            return resp.status_code

async def main():
    urls = [f"https://devlearn.club/{i}" for i in range(50)]
    sem = asyncio.Semaphore(10)  # 限制并发数
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(fetch(sem, u)) for u in urls]
    
print("所有请求完成")

asyncio.run(main())

协程适合 IO 密集任务,而且没有线程上下文切换的代价。不过要注意,如果你在协程里写一段纯 CPU 计算(比如 while True: pass),事件循环就死了——因为协程不会主动让出。所以CPU 密集还是得靠多进程。

顺带一提,生成器和协程在底层是同一套机制,如果你对 yield fromawait 的关系好奇,可以看看这篇生成器文章:从 send 到 asyncio 的底层逻辑

五、生产环境真实踩坑经历

去年维护一个微服务网关,每天要处理几万次外部 API 调用。最初代码是同步的,一个请求阻塞整个线程。后来我把逻辑改成多线程,每个请求开一个线程。结果跑了半小时,线程数飙到 3000+,内存撑爆,服务挂了。排查半天才发现,我用的是 threading.Thread 裸开线程,没加控制。

后来改成协程 + asyncio.TaskGroup,配合 Semaphore(100) 控制并发数,稳稳的。但你以为这就完了?不是。有一天高峰期,CPU 使用率突然 100%,查了日志发现某个协程里有一段解析 JSON 的超大字段(好几个 MB),这个解析是纯 CPU 计算。事件循环被这个协程堵死了,其他任务全在等。这就是我上面说的——协程里不能放 CPU 密集计算。于是我把那段解析丢进了 ProcessPoolExecutor,从根上解决。

还有个坑是关于 sys.setswitchinterval 的。当时我听说改小它可以让多线程更响应,就改了 0.0005 秒。结果程序从流畅变成卡顿,每秒钟切换 2000 次,线程忙得停不下来。后来才知道这个值越小,切换频率越高,CPU 空转越多。除非你明确要处理低延迟的交互任务,否则永远别动它

生产上最稳的组合:IO 密集用协程,CPU 密集用多进程,两者混合就异步 + 进程池。我在 Python 性能翻车现场这篇文章里记了更多细节,你们有空可以去观摩一下当时的惨状。

六、绕过 GIL 五股力量的选择题

其实你不需要记住所有方案。我按场景给你一张决策树:

  • 任务以网络请求、文件读写为主 → 协程(asyncio + TaskGroup),不要用线程池,太浪费。
  • 任务以计算大量数值为主 → 先试试能不能向量化成 numpy 操作;不行就多进程。
  • 任务既要算又要等 → 协程里丢个多进程池,异步 + 进程池真香。
  • 你是在写底层库,且你擅长 C → 释放 GIL 的 C 扩展,最后的选择。
  • 你就是想要平行宇宙 → Jython/IronPython,当作行为艺术吧。

记住一句话:GIL 不是问题,错位的并发模型才是问题

写在最后

我当年那个慢了一倍的多线程脚本,其实是用了多线程做 CPU 密集计算。后来我用协程重写,直接把 100 个请求压在同一个线程里,GIL 连看都不看一眼,速度飞起。你说 GIL 该背锅吗?它只是说了句"别拿我当并行计算器"而已。

希望这篇能帮你少踩几次坑。以后看到 ThreadPoolExecutor 先问一句:我的活儿是 CPU 出汗还是网卡出汗?答案自然就出来了。

FAQ

Q1:GIL 会在 Python 3.13 或未来版本移除吗?

目前官方有一个 nogil 的实验性分支,Python 3.13 已经开始尝试以可选模式支持“无 GIL”构建。但即使未来提供无 GIL 版本,生态里的 C 扩展也需要几年时间适配。我的建议是:2026 年的今天,别指望 GIL 被彻底干掉,先掌握绕开它的正确姿势。

Q2:我可以在多线程里直接改全局变量吗?有 GIL 是不是就安全?

不行。GIL 只能保证每条字节码指令原子执行,但一个操作(比如 count += 1)包含多条字节码,中间会被切换。即使你用 threading.Lock,也需要自己处理临界区。如果对共享数据的操作很简单(比如 list.append),通常没问题,但别赌运气。

Q3:为什么我在 multi-processing 里传大型 DataFrame 反而更慢?

多进程不共享内存,每次传递对象都要序列化和反序列化(pickle)。如果你传的是几 GB 的 DataFrame,序列化时间可能比计算时间还长。这种情况请用共享内存(multiprocessing.shared_memory)或者干脆用协程+多线程方案,或者使用能够释放 GIL 的库如 pandas(底层部分操作会释放 GIL)。

Q4:asyncio 和线程池能一起用吗?

完全没问题,而且这是标准姿势。比如你有一个 CPU 密集的同步函数,可以在 async 函数里用 loop.run_in_executor(None, cpu_bound_func) 丢到线程池(或进程池)。注意这样会占用 GIL,所以对 CPU 密集任务最好用进程池。

GIL 多线程 vs 多进程性能对比图

📤 分享这篇文章