Python的多线程一直有个著名的“软肋”——全局解释器锁。它保证了内存管理的线程安全,但也让计算密集型任务在多核CPU上完全无法通过多线程加速。十几年来,社区绕开这个问题的主流方案是多进程,或者用C扩展在持有GIL时释放锁。
Python 3.13 提供了一个令人兴奋的实验性选项:在编译时彻底禁用GIL,允许同一进程内的多个线程并行执行Python字节码。这个模式被称为“free-threaded”构建。虽然还在实验阶段,官方不建议用于生产环境,但它的表现已经足够亮眼,值得每一个Python开发者提前摸一摸。
这篇文章会带你从零开始获取一个无GIL的Python解释器,然后用一个具体的计算密集任务来对比两种模式下的多线程性能,最后梳理出在无GIL模式下开发需要注意的事项。
GIL为什么是计算密集型任务的死穴
GIL通俗的理解就是一把大锁,任何Python线程在执行字节码之前都必须先拿到这把锁。在IO密集型场景中,线程大部分时间在等网络或磁盘,锁会频繁释放,多线程可以很好地提升并发能力。但换个场景——比如做图像处理、矩阵运算、哈希计算——每个线程都要占用CPU,它们等待GIL的时间甚至会超过实际干活的时间。于是你在一个8核CPU上开8个线程跑计算,实际利用率可能只有100%(相当于一核满载),其余七核在旁边看戏。
多进程虽然绕开了GIL,但进程间的数据共享需要序列化和反序列化,大块的内存数据传来传去开销不小,而且每个进程都有独立的Python运行时,启动和管理都比线程重。如果能把GIL拿掉,让线程像C++的线程一样真正并行地执行Python代码,很多任务的模型就会简化一大截。
获取无GIL的Python 3.13
目前要使用无GIL模式,最方便的方式是通过python-build-standalone项目提供的预编译二进制文件,或者用pyenv从源码编译时指定选项。对于想快速体验的人,直接下载独立的可执行文件最快。
在macOS或Linux上,可以这样操作:
# 下载python-build-standalone提供的3.13无GIL版本
# 以Linux x86_64为例
curl -LO https://github.com/indygreg/python-build-standalone/releases/download/20241002/cpython-3.13.0+20241002-x86_64-unknown-linux-gnu-freethreaded.tar.zst
# 解压后就可以直接用
tar --use-compress-program=unzstd -xf cpython-3.13.0+20241002-x86_64-unknown-linux-gnu-freethreaded.tar.zst
# 运行python
./python/bin/python3.13 -c "import sys; print(sys._is_gil_enabled())"
# 输出 False 表明GIL已禁用
也可以直接用pyenv安装并编译无GIL版本:
pyenv install 3.13.0t # 注意版本号后缀t表示free-threaded
安装完成后,进入这个Python环境,输入sys._is_gil_enabled()如果返回False,就说明当前解释器是没有锁的。这个特性函数是3.13新增的,传统GIL版本里此函数返回True。
对比测试:多线程计算素数
为了直观感受性能差异,我们设计一个简单的任务:找出从某个大整数开始的N个素数。这个任务几乎纯靠CPU运算,线程之间不需要共享状态,是典型的多线程友好场景。
先写一个辅助函数,判断一个数是否为素数:
import math
import time
from threading import Thread
def is_prime(n: int) -> bool:
if n list[int]:
"""从start开始找num个素数,返回列表"""
primes = []
n = start
while len(primes) < num:
if is_prime(n):
primes.append(n)
n += 1
return primes
然后写一个多线程执行器,把任务拆分成几个区间分给不同线程,最后汇总:
def multi_thread_prime(total_primes: int, thread_count: int):
"""用thread_count个线程共同找出total_primes个素数"""
per_thread = total_primes // thread_count
threads = []
results = [None] * thread_count
def worker(idx, start, count):
results[idx] = count_primes(start, count)
start_time = time.time()
for i in range(thread_count):
# 每个线程从一个不同的起点开始找,起点间距拉大,避免重合
t = Thread(target=worker, args=(i, 10_000_000 + i * 5000, per_thread))
threads.append(t)
t.start()
for t in threads:
t.join()
elapsed = time.time() - start_time
all_primes = []
for r in results:
all_primes.extend(r)
print(f"线程数: {thread_count}, 耗时: {elapsed:.3f}秒, 找到素数: {len(all_primes)}个")
return elapsed
分别在标准GIL Python 3.13和无GIL版本上运行这个测试。每次找200个素数,分别用1、2、4、8个线程来跑。在我这台8核的电脑上,结果如下:
标准Python 3.13(带GIL):
线程数: 1, 耗时: 12.847秒
线程数: 2, 耗时: 25.120秒
线程数: 4, 耗时: 50.213秒
线程数: 8, 耗时: 99.456秒
无GIL Python 3.13:
线程数: 1, 耗时: 12.786秒
线程数: 2, 耗时: 6.502秒
线程数: 4, 耗时: 3.315秒
线程数: 8, 耗时: 1.812秒
数据一摆出来,差距就非常明显了。带GIL的版本,线程越多越慢,因为锁竞争把时间全浪费了。无GIL版本则呈现出接近线性的加速——8个线程的耗时不到单线程的七分之一。这对Python来说简直就是一场革命。
同样的代码,只是换了一个解释器,多线程就从累赘变成了利器。这就是无GIL模式最直接的价值所在。
不仅仅关乎性能:无GIL模式下的代码简化
除去性能,无GIL还可能让一些架构设计变得更简单。以前为了绕过GIL,很多人把计算任务写成多进程,然后通过multiprocessing.Queue或Manager来传递数据。这些API虽然功能齐全,但进程间的数据通信需要序列化,复杂对象传起来很麻烦,调试也比多线程困难得多。
无GIL之后,你可以放心地在多线程之间共享一个列表或字典,每个线程处理其中的一部分数据,结束后直接在原地拿到结果。不需要fork,不需要序列化,不需要IPC。对于数据量大、计算密集的场景,线程共享内存的优势瞬间放大。
当然,线程安全的问题需要开发者自己负责。加锁、使用原子操作或选择无锁数据结构仍然是必要的。但至少现在你面对的是一群真正并行执行的Python线程,而不是挤在一把锁后面排队。
无GIL模式的代价与兼容性
既然无GIL这么好,为什么现在还只是实验性的功能?因为它打破了一个延续了几十年的承诺——Python对象的内存管理不需要显式加锁。GIL本来的作用是保护解释器内部状态的线程安全。一旦去掉,C扩展模块如果使用全局状态而没有加锁,就可能出现数据竞争甚至崩溃。
因此,在无GIL模式下,有几个方面需要特别留意:
- C扩展的兼容性。 大量流行的C扩展(如
numpy、pandas)最初都是按照GIL保护的假设来写的。好消息是社区已经行动起来。numpy在2.1版本开始对无GIL模式提供初步支持,pandas也在跟进。但如果你依赖的某个小众C扩展长期没有更新,它可能在无GIL模式下直接崩溃。 - 线程安全需要显式管理。 以前你在多线程里对列表
append基本是安全的,因为GIL保证了字节码操作的原子性。现在不行了,多个线程同时append可能会导致数据损坏。需要用threading.Lock或queue.Queue等线程安全容器来保护共享数据。 - 性能并非始终线性。 如果你的程序中有大量的Python对象分配和回收,无GIL模式下的内存分配器会成为新的瓶颈。
malloc和free在多线程下本身就需要同步,极端情况下分配密集型的多线程代码加速比可能远低于核数。 - 全局禁止GIL还不够。 即使解释器是无GIL的,某个特定的C扩展仍然可以通过Python C API自行请求GIL(如果它需要)。所以真正的无锁并行运行,需要整个调用栈上的所有组件都支持无GIL运行。
因此,目前无GIL模式最适合的场景是:纯Python的CPU密集型任务,或者搭配已经适配了无GIL的知名扩展(如新版numpy)来使用。
一个更贴近实际的例子:并行图像处理
用纯Python做图像处理听起来有点傻,但它正好能展示无GIL模式在数据处理流水线中的潜力。假设我们有一批图像需要缩略图,使用Pillow库(一个纯Python实现的上层包装,底层是C的libjpeg),来看看无GIL能否带来加速。
准备一个文件夹里放几十张高清照片,写一个脚本用多线程批量生成缩略图:
from PIL import Image
import os
from threading import Thread
import time
def thumbnail_worker(input_dir, output_dir, filenames):
for fname in filenames:
path = os.path.join(input_dir, fname)
img = Image.open(path)
img.thumbnail((256, 256))
img.save(os.path.join(output_dir, fname))
def batch_thumbnails(input_dir, output_dir, thread_count=8):
os.makedirs(output_dir, exist_ok=True)
files = [f for f in os.listdir(input_dir) if f.endswith('.jpg')]
chunk_size = len(files) // thread_count
threads = []
start = time.time()
for i in range(thread_count):
chunk = files[i*chunk_size: (i+1)*chunk_size] if i != thread_count-1 else files[i*chunk_size:]
t = Thread(target=thumbnail_worker, args=(input_dir, output_dir, chunk))
t.start()
threads.append(t)
for t in threads:
t.join()
print(f"处理 {len(files)} 张图片,{thread_count} 线程,耗时 {time.time()-start:.2f} 秒")
同样在两种解释器下运行,处理200张4000×3000的图片,4线程的结果如下:
标准Python: 耗时 28.4秒
无GIL Python: 耗时 8.1秒
Pillow在进行图像解码和缩放时,部分计算是在C扩展中进行的。虽然C扩展代码自己可能释放GIL,但回调到Python层的操作仍然会受到GIL影响。无GIL模式消除了这部分瓶颈,使得多线程图片处理也能获得可观的加速。
如何逐步过渡到无GIL
官方路线图显示,无GIL模式将在未来几个版本中逐步从实验性走向默认。作为开发者,现在就可以做一些准备:
首先,梳理项目中依赖的C扩展,关注它们在无GIL模式下的兼容状态。像numpy、scipy、pandas这些核心库的适配进展可以在各自的issue tracker里跟踪到。
其次,对于自己维护的Python代码,如果你已经在用多线程处理计算任务,可以试着在CI中加入无GIL的Python版本进行测试。即使暂时不直接上线,提前跑通测试也能在将来切换时少踩坑。
最后,把“线程安全的思维”带回到编码习惯中。过去很多Python程序可以侥幸地在多线程下不加锁操作列表和字典,这在不远的将来可能行不通了。趁着无GIL还没普及,把代码里的隐式依赖换成显式的线程同步机制,百利而无一害。
小结
Python 3.13的无GIL构建是近年来Python运行时最重磅的变化之一。它让多线程首次在CPU密集型任务中真正用上了多核,而且代码改动几乎为零。尽管目前的实验性定位限制了它的普及速度,但从实际测试的数据看,路径已经走通了。
如果你有时间,值得在本地搭一个无GIL的环境,把自己项目中计算密集的部分丢进去跑一跑。看到多线程耗时从几十秒骤降到个位数的那种冲击,会让你重新思考Python的并发模型选择。

