Python的全局解释器锁(GIL)被吐槽了二十多年。多线程在CPU密集任务上基本是摆设——开4个线程处理图片,CPU占用率永远卡在25%左右,因为同一时刻只有一个线程能执行Python字节码。之前绕过GIL的方案要么用多进程(内存开销大、数据传递麻烦),要么用C扩展释放GIL(需要自己管理锁),要么换其他解释器。
Python 3.13带来了一个重量级变化:可以通过编译选项--disable-gil构建一个没有GIL的Python解释器,官方称之为FREE-THREADED模式。在这个模式下,多个线程可以真正并行执行Python代码。上个月刚好有个批量生成缩略图的需求,我顺手测了一把,数据很有趣,决定把整个过程写下来。
一、FREE-THREADED模式是什么
Python 3.13引入的FREE-THREADED模式不是简单地把GIL去掉,而是用了一套更细粒度的锁机制来保证线程安全。在传统模式下,GIL是一把全局大锁,任何线程想执行Python代码都要先获取它。在新模式下,锁的粒度从“整个解释器”缩小到了“单个对象”,不同的线程操作不同的对象时可以完全并行。
安装FREE-THREADED版本的方式:
# 通过pyenv安装(推荐)
pyenv install 3.13.0t
# 或者从源码编译
./configure --disable-gil
make -j$(nproc)
make install
安装后的解释器叫python3.13t,文件扩展名也会带上t后缀,lib-dynload目录和共享库都有独立的路径,跟普通的3.13不会冲突。验证是否启用了FREE-THREADED:
import sysconfig
print(sysconfig.get_config_var('Py_GIL_DISABLED')) # 输出 1 表示GIL已禁用
二、实测场景:批量缩略图生成管道
我手上的需求是这样的:一个目录下有大约5000张原始照片(单张5MB到20MB不等),需要生成三种尺寸的缩略图(200px、800px、1600px宽),同时做轻微锐化和水印叠加。纯Python用Pillow库处理,每张图耗时在400ms到800ms之间。
最初用单线程跑,5000张图要跑将近一个小时。改成concurrent.futures.ThreadPoolExecutor之后,传统Python 3.12下4个线程耗时只比单线程快了不到20%——因为Pillow的编解码部分虽然释放了GIL,但Python层的循环调度和参数处理仍然被GIL卡住。
下面是测试的完整处理函数:
from PIL import Image, ImageFilter, ImageDraw, ImageFont
from pathlib import Path
THUMBNAIL_SIZES = [200, 800, 1600]
WATERMARK_TEXT = "© 2024"
def process_image(input_path: Path, output_dir: Path) -> dict:
"""处理单张图片,生成多尺寸缩略图"""
try:
img = Image.open(input_path).convert('RGB')
result = {'original': input_path.name, 'thumbnails': []}
for size in THUMBNAIL_SIZES:
# 按宽度等比缩放
ratio = size / img.width
new_height = int(img.height * ratio)
thumb = img.resize((size, new_height), Image.LANCZOS)
# 轻微锐化
thumb = thumb.filter(ImageFilter.SHARPEN)
# 叠加水印
draw = ImageDraw.Draw(thumb)
draw.text((10, 10), WATERMARK_TEXT, fill=(255, 255, 255, 128))
# 保存
output_name = f"{input_path.stem}_{size}w.jpg"
output_path = output_dir / output_name
thumb.save(output_path, 'JPEG', quality=85)
result['thumbnails'].append(str(output_path))
return result
except Exception as e:
return {'error': str(e), 'file': str(input_path)}
三、多线程执行对比
用一个简单的线程池跑这批任务:
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
def batch_process(input_folder: str, output_folder: str, workers: int = 4):
input_dir = Path(input_folder)
output_dir = Path(output_folder)
output_dir.mkdir(parents=True, exist_ok=True)
image_files = list(input_dir.glob('*.jpg')) + list(input_dir.glob('*.png'))
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(process_image, f, output_dir): f
for f in image_files
}
for future in as_completed(futures):
result = future.result()
if 'error' in result:
print(f"处理失败: {result['file']} - {result['error']}")
print(f"处理完成,共 {len(image_files)} 张图片")
在两台相同的机器上跑同一个数据集,对比结果如下(处理1000张样本,worker=8):
| 运行环境 | 总耗时 | CPU平均利用率 | 相对单线程加速比 |
|---|---|---|---|
| Python 3.12(单线程) | 652秒 | 12.5% | 1.0x |
| Python 3.12(8线程) | 543秒 | 18% | 1.2x |
| Python 3.13t FREE-THREADED(单线程) | 660秒 | 12.5% | 1.0x |
| Python 3.13t FREE-THREADED(8线程) | 68秒 | 87% | 9.6x |
| Python 3.13t FREE-THREADED(18线程) | 44秒 | 93% | 14.8x |
在FREE-THREADED模式下,8个线程几乎跑满了全部CPU核心,加速比从1.2倍直接跳到9.6倍。推到18个线程时加速比达到14.8倍,基本上接近线性加速。这个提升不需要改一行业务代码,只是换了一个解释器。
四、线程安全性需要注意的点
GIL被移除后,代码中原来“靠GIL保护”的隐式线程安全不再存在。比如之前两个线程同时往一个list里append,在GIL下由于append是原子操作,不会出问题。但在FREE-THREADED模式下,append不再保证原子性,需要显式加锁。
在上面缩略图的例子里,每个线程写的是独立文件,处理的对象互不重叠,所以不需要额外锁。但如果要用一个共享的列表收集结果,就必须用threading.Lock来保护:
from threading import Lock
results_lock = Lock()
all_results = []
def process_with_collect(input_path, output_dir):
result = process_image(input_path, output_dir)
with results_lock:
all_results.append(result)
return result
另一个需要注意的地方是某些第三方库的线程安全性。大多数纯Python库在FREE-THREADED模式下开箱即用,但如果库内部用到了C扩展且自己维护了全局状态,可能会出问题。Pillow在FREE-THREADED模式下测试结果正常,但numpy的某些操作需要留意——numpy本身支持多线程,它与Python线程的交互在无GIL环境下需要更多测试。
五、内存占用的变化
GIL被替换成细粒度锁之后,每个对象都会增加一些元数据开销。在缩略图处理的测试中,相同数量的图片对象,FREE-THREADED模式比传统模式多消耗了大约12%的内存。这个差距主要来自对象头上的per-object锁和引用计数字段。如果处理的是小对象密集的场景,内存增长会更明显。
有一个简单的查看方式:
import sys
lst = [object() for _ in range(100000)]
print(sys.getsizeof(lst)) # 对比不同模式下的值
对于大多数Web应用和批处理任务来说,这12%的内存在可接受范围内。但如果你的程序已经接近内存上限,迁移前最好做一次完整的压力测试。
六、生态兼容现状
FREE-THREADED模式引入后,PyPA社区推出了一套兼容标记。带cp313t后缀的wheel包是专门为FREE-THREADED解释器构建的。目前主流科学计算和图像处理库的兼容情况:
- Pillow:10.4.0版本开始提供
cp313twheel,本次测试就用它。 - numpy:2.1.0版本开始实验性支持,基本功能可用。
- requests、flask等纯Python库:直接可用,无需特殊版本。
- lxml、protobuf等带C扩展的库:部分已适配,部分仍在跟进。
安装时如果pip找不到对应平台的FREE-THREADED wheel,会自动回退到源码构建。大多数情况下构建是成功的,但可能缺少编译优化。
七、适合迁移的场景
并不是所有Python程序都能从FREE-THREADED模式中受益。适合迁移的典型场景:
- CPU密集的并行任务:图像处理、数据分析、加密解密、科学计算。这些任务在传统模式下被迫用多进程,迁移后可以用更轻量的多线程。
- 混合I/O和CPU的工作流:比如从网络拉图片后做处理。多线程既能并发请求又能并行处理,不再需要在
asyncio和ProcessPoolExecutor之间来回切换。 - 需要共享大量内存的并行场景:多进程共享数据要经过序列化和管道传输,多线程共享内存直接访问,大矩阵传递效率高得多。
不适合的情况:纯I/O密集型任务(传统多线程已经够用)、单线程脚本(本来就没有并行需求)、大量小对象的频繁创建销毁(锁开销反而会拖慢)。
八、总结
Python 3.13的FREE-THREADED模式是Python并发编程的一个分水岭。它解决了困扰开发者多年的“多线程几乎不能用”的问题,而且迁移成本几乎为零——不用改写代码逻辑,只需要在必要的地方加上锁。
这次缩略图处理的需求,从原来跑将近一小时缩短到不到一分钟,成本就是换了一个解释器加几行锁代码。对于手中跑着大量多线程但受制于GIL的项目来说,这个投入产出比很划算。接下来我打算把公司内部的几个数据清洗管道也迁到FREE-THREADED上试试,如果有新的发现再补充记录。

