2025 年 11 月,我们的图像处理服务在线上出了一个非常诡异的故障:压测时 CPU 利用率怎么也上不去,8 核机器只跑到 220% 左右,p99 延迟从 80ms 飙到 1.4s。所有人第一反应都是"GIL 嘛,Python 多线程就是这样",但事故的真相比 GIL 这两个字复杂得多。复盘 7 天里,我们才搞清楚:真正的瓶颈不是 GIL 本身,而是我们调用的某个 C 扩展(老版本的 libwebp 绑定)在关键热点路径上根本没释放 GIL,把多线程退化成了纯串行,叠加 OpenCV-Python 的另一个相似问题,两块拼起来才让吞吐崩盘。这篇是我们用 py-spy + GIL 监控脚本 + 自定义 Cython 重写定位 + 4 种修法对比的完整路线。
服务背景:看起来"应该多线程友好"的负载
这个服务做的事很简单:每个请求接收一张用户上传的图片(平均 500KB),做 4 步处理:解码(JPEG/WebP) → resize → 加水印 → 编码回 JPEG/WebP。CPU 密集为主,IO 极少,典型"嵌入式 CPU 任务"。我们部署在 8c16g 的 K8s pod 里,gunicorn 启 8 个 worker(进程),每 worker 8 个 gthread 线程,这是 1 年前压测得出的最佳配置。当时吞吐 1900 RPS,p99 是 75ms。整个服务用 Python 3.11.7,FastAPI 0.109,pillow 10.2,opencv-python 4.5.5,我们认为这些都是"稳的、被压测过的"组合,没人会去想 1 年后的流量翻倍会让这些底层库选型变成炸药。
故障是流量翻倍之后逐渐暴露的:从 1100 RPS 涨到 2200 RPS 的过程中,p99 从 75ms 平稳上升,直到某天突然在 1800 RPS 处出现"拐点",再多一点 RPS,p99 就线性恶化。这是典型的资源耗尽信号,但 CPU 看着没耗尽——top 里 8 个 worker 加一起才 220%(意味着平均每个 worker 只用了 27% 的 CPU)。这个数字让我们一开始走偏了方向。SRE 一度怀疑是 K8s CPU throttling,把 cpu limit 拉满之后,top 数字没动,p99 也没动,这才说明限制不在容器层。
事后看,这种"CPU 用不完但延迟雪崩"的现象在 Python 服务里其实非常典型——但典型不等于显而易见,因为它跟"线程没在跑"是同一种现象的两面。如果你只看 CPU,会觉得资源还有富余;如果你看请求队列堆积,会以为是上游打过来太快。没有 GIL 维度的监控,你根本不知道线程在不在跑、为什么不在跑。这是这次复盘最痛的一课:Python 服务的可观测性体系里,如果没有 GIL 持有时间这个指标,等于在裸奔。
事故时间线
| 时刻 | 事件 | 关键指标 |
|---|---|---|
| D1 上午 | SRE 报警:p99 超 800ms | RPS 1800, CPU 220% |
| D1 下午 | 扩容到 12 个 pod 缓解,但单 pod 利用率仍上不去 | p99 回落到 300ms |
| D2 | 怀疑是 gunicorn 配置问题,试了 sync/gthread/gevent 三种 worker | 无显著差异 |
| D3 | 用 py-spy 抓到 60% 时间卡在 libwebp 的 decode 上 | 初步定位 |
| D4 | 手写 GIL holding 监控,确认 decode 全程持有 GIL | 根因 1 锁定 |
| D5 | 查 OpenCV-Python 同样有 imencode 不释放 GIL 的问题 | 根因 2 锁定 |
| D6 | 尝试 4 种修法,选定"换 Pillow-SIMD + 自写 Cython 释放 GIL"组合 | 验证 |
| D7 | 灰度 → 全量,p99 回到 65ms,单 pod 吞吐提升 2.7 倍 | 结案 |
第一轮排查:走错的两条路
D2 一整天我们都在尝试调整 gunicorn 配置,从 gthread 换 gevent,worker 数从 8 改到 4 改到 16,完全没有解决问题。当时我们对故障的假设是"Python 多线程不行,需要换异步",但其实异步根本救不了 CPU 密集型负载。这是一个典型的"工具假设错位"——把 IO 密集的解法套到 CPU 密集的场景上。
# D2 试过的几组配置,效果都几乎一样
gunicorn -k sync -w 8 ...
gunicorn -k gthread -w 8 --threads 8 ...
gunicorn -k gthread -w 4 --threads 16 ...
gunicorn -k gevent -w 8 ...
# 结果都是 RPS 卡在 1900 左右上不去
D3 早上我用 py-spy 把生产 pod 抓了 60 秒火焰图,意外的发现是 60% 的 CPU 时间都集中在 libwebp.WebPDecode 这一个函数上。问题不在 gunicorn,也不在 Python 层,而在 C 扩展。这个数字本身就反常——一个图像处理服务,decode/resize/watermark/encode 4 步应该比较均匀地分摊 CPU 时间,如果其中一步占了 60%,要么是它本身耗时太长(那是另一种 bug),要么是那一步堵住了其他线程,所以表现在火焰图上就像它"独自跑了很久"。后者就是 GIL 被独占的典型征兆。
py-spy 在这件事上要特别说一下用法:它有两种模式,top 是实时看,record 是抓火焰图。生产环境我们最常用 record --rate 100 --duration 60,即每秒采样 100 次、持续 60 秒,这个采样率对生产基本无干扰(单核 < 2% 开销),但拿到的数据足够细。我们后来把这个命令封装进了一个 SOP 手册,新来的 SRE 不用记参数,直接抄就能查 Python 性能问题。
kubectl exec -it $POD -- py-spy record -o /tmp/profile.svg --pid 1 --duration 60 --rate 100
kubectl cp $POD:/tmp/profile.svg ./profile.svg
# 火焰图 60% 集中在 _webp_decode → libwebp.so 的 WebPDecodeRGBA
D3 下午的转折:GIL 持有时间监控
看到火焰图后,我意识到要回答的关键问题不是"什么慢",而是"慢的时候 GIL 在不在被某个线程独占"。Python 标准库不直接提供这个数据,我们手写了一个监控脚本,核心思路是:启动一个高优先级线程,定期尝试运行一个极轻量的 Python 操作,如果它被阻塞超过阈值,就说明 GIL 被别人长时间持有。
import threading, time, os
from collections import deque
class GilMonitor:
"""监控 GIL 持有情况:定期尝试在 Python 层做空操作,
如果延迟超过阈值就说明有线程长期没释放 GIL"""
def __init__(self, interval_ms: int = 5, threshold_ms: int = 50):
self.interval = interval_ms / 1000
self.threshold = threshold_ms / 1000
self.events: deque = deque(maxlen=10000)
self._stop = threading.Event()
def _probe(self):
last = time.monotonic()
while not self._stop.is_set():
now = time.monotonic()
delta = now - last
if delta > self.threshold + self.interval:
# 延迟超过阈值,说明 GIL 被独占了
self.events.append({
"ts": now,
"blocked_ms": (delta - self.interval) * 1000,
"thread": threading.current_thread().name,
})
last = now
time.sleep(self.interval)
def start(self):
t = threading.Thread(target=self._probe, daemon=True, name="gil_probe")
t.start()
def stop(self):
self._stop.set()
def report(self):
if not self.events:
return {"blocks": 0, "max_ms": 0, "avg_ms": 0}
blocks = list(self.events)
return {
"blocks": len(blocks),
"max_ms": max(e["blocked_ms"] for e in blocks),
"avg_ms": sum(e["blocked_ms"] for e in blocks) / len(blocks),
"p95_ms": sorted(e["blocked_ms"] for e in blocks)[int(len(blocks) * 0.95)],
}
# 接到 FastAPI 启动:
monitor = GilMonitor(interval_ms=5, threshold_ms=20)
monitor.start()
# /admin/gil 接口返回 report()
跑了 5 分钟,数据非常震撼:p95 GIL 阻塞时间 87ms,max 阻塞 312ms,块数 4200+。也就是说每秒钟有十几次"某个线程独占 GIL 几十甚至几百毫秒"的事件——这就是为什么 8 个 worker × 8 个线程实际跑不出 8 倍并发。
GilMonitor 这套机制后来我们做成了一个内部 pip 包 internal-gilmon,被几乎所有 Python 服务接入。它的额外开销很小(probe 线程每 5ms 唤醒一次,空闲时 CPU 占用 0.4%),但对发现"沉默的 GIL 持有者"非常有用——很多次后续故障都是它先于 py-spy 报警的,因为它在毫秒级就能感知阻塞,而 py-spy 需要你主动去采样。两者搭配:GilMonitor 报警,py-spy 定位,是我们如今 Python 服务可观测性的两大基础组件。
顺带提一句,有人会问为什么不直接读 Python 的 sys.getswitchinterval() 或者 ceval.c 内部状态。理论上可以,但那需要 CPython 编译时打开调试符号,而且数据是"理论上的 GIL 切换间隔",不反映实际被某线程独占的时长。我们 monitor 的做法在用户态、纯标准库,适用所有 CPython 3.x,任何生产环境直接放心用。
问题本质:C 扩展不释放 GIL 的两个真凶
定位到 GIL 之后,我去翻了 libwebp Python binding 的源码(我们用的是某个老的 webp 0.x 版本),发现关键路径根本没有 Py_BEGIN_ALLOW_THREADS / Py_END_ALLOW_THREADS 这对宏。这意味着调用 WebPDecode 时 GIL 全程被持有,整段 C 代码运行期间其他 Python 线程一点 CPU 也拿不到。这对宏是 CPython 跟 C 扩展约定的"我现在不需要解释器了,你们其他线程随便跑"的协议,如果 C 扩展的作者懒得加(或者不知道要加),解释器只能默认假设它需要 GIL,于是 Python 多线程就完全失效。
同样的问题在 OpenCV-Python 的 cv2.imencode 上也存在——准确地说,部分版本释放 GIL 但部分不释放,我们用的 4.5 版本不释放。我们的请求路径同时用到 webp decode + cv2 encode,两个不释放 GIL 的 C 调用串联,把多线程并发能力完全吃掉。后来翻 GitHub issue 才发现 opencv-python 在 4.7 之后陆续给一些函数加了 GIL 释放,但全量覆盖还要等到 4.9+,而且因为 opencv 内部用了大量 lazy import 和全局状态,即使升级版本也得仔细测过才敢上。我们最后选择放弃 opencv 走 Pillow 路线,部分原因就是历史包袱的迁移成本比"重新选型"更高。
这件事也让我重新思考"选型"这件事:很多团队在 PoC 阶段对 C 扩展的选择特别随意——"GitHub star 多就用"、"文档清楚就用"、"以前用过就继续用"。但是不是释放 GIL 这个看似底层、技术细节性的属性,在 Python 服务规模化时是决定生死的属性,跟选 ORM 时关心"支持不支持事务"一样重要。我们后来在选型 checklist 里加了一条"必须验证关键路径释放 GIL",新的库进项目前先跑 bench_gil_release。
把这两个事实拼起来,故障的因果链就清楚了:
修法 1:用 multiprocessing 绕过(否决)
最直觉的方案是"既然 GIL 是问题,那就用进程不用线程"。这是 Python 圈子里被反复说了 20 年的"标准答案",但它不是适用所有场景的银弹。我们的 worker 已经是 gunicorn 的进程模式了——每个进程内的 8 个 gthread 本来就是为了"共享一个 webp/cv2 解码 cache + 减少 fork 开销"。如果全切到 sync worker(每进程 1 线程),要把 worker 数从 8 翻到 64 才能保持等价并发,内存会爆。
| 配置 | worker 数 | 每 worker 线程 | 预估内存 | 问题 |
|---|---|---|---|---|
| 原始 gthread | 8 | 8 | 3.2GB | GIL 卡死 |
| 纯 sync | 64 | 1 | 12.8GB | OOM(16GB pod 装不下) |
| sync + 缩容 | 32 | 1 | 6.4GB | 并发减半 |
这条路被否决。不是说 multiprocessing 不好,而是不能用进程的并发来掩盖 C 扩展不释放 GIL 这个根因,治标不治本,而且要付 4 倍的内存代价。另外有一个 multiprocessing 的隐藏代价很多人没体感:每个 worker 启动时要 fork 全套 Python 解释器 + 加载所有依赖,内存看似是 shared(COW),但只要进程后续做任何变更,就要 page-by-page 复制,真实驻留内存远大于 ps 显示的 RSS。我们做过测算,同样代码 64 进程对比 8 进程 × 8 线程,工作集内存差距大约是 3.2 倍而不是 8 倍,这是因为 Python 内部的引用计数会让几乎所有页面都"被写过"。即便如此,3.2 倍内存的成本对我们来说仍然太贵。
另一个隐藏代价是 IPC:gunicorn worker 之间不共享 cache,意味着你常常加载的水印模板图、字体文件、模型权重每个 worker 都要装一份。我们的水印模板大约 80MB(高分辨率 PNG),如果切 64 worker 就要 5GB 只放水印模板。这种"共享只读资源"在多线程模型下天然只装一份,在多进程模型下要么走 shared memory(开发成本陡升)要么硬吃内存——绝大多数团队会选硬吃,然后某天 OOM 就找上门。
修法 2:换库 → Pillow-SIMD + WebP via Pillow
Pillow 在主路径上对绝大多数解码 / 编码函数都正确释放了 GIL(社区 PR 长期维护),Pillow-SIMD 是它的 SSE/AVX 优化分支,在 x86 上速度比标准 Pillow 快 4-6 倍。这就是我们最终方案里的一部分。
from PIL import Image
import io
def decode_webp_pillow(buf: bytes) -> "Image.Image":
return Image.open(io.BytesIO(buf)).convert("RGB")
def encode_jpeg_pillow(img: "Image.Image", quality: int = 85) -> bytes:
out = io.BytesIO()
img.save(out, format="JPEG", quality=quality, optimize=False)
return out.getvalue()
# Pillow 的 C 实现在 decode/encode 关键段都有 Py_BEGIN_ALLOW_THREADS
# 实测:同样 500KB WebP,8 线程并发解码,吞吐从 540 张/秒提升到 3100 张/秒
切换 Pillow-SIMD 之后,GilMonitor 报告的 p95 阻塞从 87ms 掉到 9ms。但还不够好——p99 阻塞仍有 60ms,原因是水印环节我们自己写了一段纯 Python 的"读取像素 → 计算位置 → 覆盖像素"代码,这部分代码在 Python 层运行,GIL 当然不会释放,把效果又拖回去了。这是一个非常典型的"完美主义自坑":水印逻辑因为业务上要做透明度混合、位置自适应、按 EXIF 旋转方向校正,涉及一些"奇怪规则",我们当时为了灵活就直接用 numpy 写了纯 Python 实现。"反正只跑 50ms"——但当其他环节优化掉之后,这 50ms 就显得格外刺眼,而且因为持有 GIL,直接把整 worker 的 8 个线程拖在等待。
Pillow-SIMD 还有一个不显眼的好处是,它在 ARM 服务器(Graviton)上也跑得好。我们后来部分 pod 迁到 ARM 节省成本,opencv-python 在 ARM 上有一些兼容性坑(某些向量化指令),但 Pillow 系列在 ARM 上是一等公民,迁移几乎零工作量。这种"跨架构兼容性"在 2026 年云成本优化的大趋势下非常重要,选型时多加 1 分权重不亏。
修法 3:水印环节用 Cython + nogil 重写
我们把热点的水印计算用 Cython 重写,关键是 with nogil: 块——它告诉 Python 解释器"这段代码不用 GIL,放心让别的线程跑"。
# watermark.pyx (Cython)
# cython: language_level=3, boundscheck=False, wraparound=False
import numpy as np
cimport numpy as np
from cython.parallel import prange
def blend_watermark(np.ndarray[np.uint8_t, ndim=3] base,
np.ndarray[np.uint8_t, ndim=3] mark,
int ox, int oy, float alpha):
cdef:
int H = base.shape[0]
int W = base.shape[1]
int mh = mark.shape[0]
int mw = mark.shape[1]
int i, j, c
float a = alpha
float ia = 1.0 - alpha
np.uint8_t bv, mv
# 关键:nogil 让这段 C 循环不持有 GIL
with nogil:
for i in prange(mh, schedule='static'):
if i + oy >= H:
continue
for j in range(mw):
if j + ox >= W:
continue
for c in range(3):
bv = base[i + oy, j + ox, c]
mv = mark[i, j, c]
base[i + oy, j + ox, c] = (bv * ia + mv * a)
setup.py 配置 OpenMP 让 prange 真的多线程:
from setuptools import setup, Extension
from Cython.Build import cythonize
import numpy
extensions = [
Extension(
"image_ops.watermark",
["image_ops/watermark.pyx"],
include_dirs=[numpy.get_include()],
extra_compile_args=["-O3", "-fopenmp", "-march=native"],
extra_link_args=["-fopenmp"],
)
]
setup(
name="image_ops",
ext_modules=cythonize(extensions, compiler_directives={"language_level": "3"}),
)
水印这步从平均 22ms 压到 1.8ms,p99 从 45ms 压到 4ms。更重要的是它不再持有 GIL,GilMonitor 上水印环节的阻塞事件基本消失。Cython 的另一个好处是它跟 numpy 数组互操作非常自然——传 numpy 数组进去几乎零开销,不像 ctypes 还要打包/拷贝。我们后来把图像编码前的色彩空间转换(BT.709 / BT.2020 之类)也用 Cython nogil 改了,又再削掉一点尾部延迟。
给想用 Cython 的同学一个真实数字感:从决定改写到生产部署,我们花了 5 天——其中 1 天写代码、1 天写单测、1 天调 OpenMP 配置(Mac 和 Linux 的 OpenMP 行为有差异,本地能跑生产挂)、2 天压测验证。如果你的团队没人写过 Cython,预算翻倍到 10 天比较保险。但跟"换语言重写整个服务"相比,这点投入完全合算。
修法 4:不能换库的环节 → 用进程池外包
剩下一个少量场景必须用一个老的 C 扩展(某种内部专用格式),没办法换、也没源码改。我们用 concurrent.futures.ProcessPoolExecutor 把这个调用外包到独立的小进程池,主进程只负责 IO + 调度,这样 GIL 持有问题就被隔离到子进程里,不影响主进程其他线程。
from concurrent.futures import ProcessPoolExecutor
import asyncio, os
# 全局进程池(进程数 = 物理核心数)
_pool = ProcessPoolExecutor(max_workers=os.cpu_count())
def _legacy_codec_decode(buf: bytes) -> bytes:
# 这是个会持有 GIL 的老 C 库,只能整段外包
from legacy_codec import decode_internal
return decode_internal(buf)
async def decode_legacy(buf: bytes) -> bytes:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(_pool, _legacy_codec_decode, buf)
注意几个坑:
- 进程池要在模块顶层 import 之前不要触碰——某些 C 库在 import 期就 fork 出后台线程,跟进程池的 fork 行为会冲突。我们在
gunicorn的 post_fork hook 里初始化。 - 传给子进程的 buf 不能太大(>10MB),pickle 序列化开销会吃掉收益。我们的图片普遍 200KB-2MB,这块没问题,但前段用户传过来如果是 50MB 的 TIFF,就要走 shared memory。
- 进程池 worker 不要复用太久——某些 C 库有内存泄漏,跑几万次后单进程内存膨胀。我们配
max_tasks_per_child=1000强制定期重启。
组合后的最终架构
性能对比
| 方案 | 单 pod RPS | p99 | CPU 利用率 | GIL p95 阻塞 |
|---|---|---|---|---|
| 原始(libwebp + cv2) | 1900 | 800ms | 220% | 87ms |
| + Pillow-SIMD | 3700 | 180ms | 510% | 9ms |
| + Cython nogil 水印 | 4800 | 95ms | 680% | 3ms |
| + ProcessPool 外包老库 | 5100 | 65ms | 750% | 2ms |
同样的 8 核 pod,从 1900 RPS 涨到 5100 RPS,CPU 利用率从 220% 涨到 750%(理论上限 800%),延迟从 800ms 压到 65ms。集群规模从 24 个 pod 缩到 9 个 pod 就能扛同样流量,每月成本从 8400 美元降到 3200 美元。换 Pillow-SIMD 这一步是最大收益,工作量也最小(改 requirements + 改两个 import,大约半天),后两步 Cython 改写和 process pool 隔离工作量大但收益逐次递减。如果你只能花一天时间优化 Python 图像服务,优先做 Pillow-SIMD,这是 ROI 最高的一刀。
另外一个被很多人忽视的收益是延迟方差变小。原架构下 p99/p50 比值约 10(因为某些请求倒霉撞到 GIL 长时间持有),新架构下 p99/p50 比值降到 2.3。这件事对 SLA 体感的改善比平均延迟下降更明显——用户报"偶尔特别卡"的频率几乎归零,客服收到的"图片传半天传不上来"投诉每周从 14 条降到 0-1 条。
判断"C 扩展是否释放 GIL"的实用方法
很多人不知道某个 C 扩展到底释放不释放 GIL。我们沉淀了 3 个判断手段:
方法 1:看源码。grep 关键宏 Py_BEGIN_ALLOW_THREADS、Py_BLOCK_THREADS。出现在你调用的函数主体里就说明释放了。这是最权威的方法,但需要源码。
方法 2:GilMonitor 实测。在测试环境跑你关心的函数,看 monitor 的 p95 阻塞数据。如果一个 C 调用平均跑 30ms,monitor 阻塞数据也大约 30ms,说明它持有了 GIL 全程;如果 monitor 数据明显小(<5ms),说明它有释放。
方法 3:多线程压测对比。同一个 C 函数,1 线程 vs 8 线程串行调用,如果总时长几乎不变,说明 GIL 被持有;如果 8 线程接近 1 线程 / 8,说明 GIL 被释放。这是黑盒最简单的判断。
import threading, time
def bench_gil_release(fn, args, threads: int = 8, rounds: int = 100):
def _worker():
for _ in range(rounds):
fn(*args)
t0 = time.monotonic()
ts = [threading.Thread(target=_worker) for _ in range(threads)]
for t in ts: t.start()
for t in ts: t.join()
parallel = time.monotonic() - t0
t0 = time.monotonic()
for _ in range(threads * rounds):
fn(*args)
serial = time.monotonic() - t0
speedup = serial / parallel
print(f"speedup with {threads} threads: {speedup:.2f}x (>4 = releases GIL)")
return speedup
5 周里其他 6 个发现 / 错误
- numpy 的大多数操作释放 GIL,但
np.einsum/ 复杂where链有些不释放——压测都要单测。 - requests / urllib3 在网络 IO 上释放 GIL,但 JSON parse 不释放,大 JSON 解析时仍是单线程瓶颈。
- asyncio + 同步 C 扩展是大坑:如果你在 async 函数里直接调用一个不释放 GIL 的 C 函数,整个事件循环会被锁死,所有 coroutine 都停在那 87ms,影响远比线程模型大。这种情况必须
loop.run_in_executor(threading_pool, fn)或干脆 process pool。 - Python 3.13 free-threaded(no-GIL)版本不是银弹:它解决了 Python 层的 GIL,但你调用的 C 扩展如果本身不是线程安全的,no-GIL 反而会让你撞 race condition。我们试过 3.13t,因为依赖里有 3 个不安全的扩展,数据时不时被踩烂。
- Cython 的 nogil 是"承诺"而非"强制":你必须保证 nogil 块里不调用任何 Python 对象的方法。我们最早写错过一次,在 nogil 里调了 numpy 的
.shape属性,运行时直接 crash。 - perf top 看不到 Python 函数名——必须用 py-spy(或 austin)才能拿到 Python 层的火焰图。这两个工具在生产环境定位 CPU 问题是必备。
我们立的 8 条工程纪律
- 引入任何 C 扩展前必须跑 bench_gil_release,8 线程加速比小于 4 直接拒绝(或限定在 process pool 里用)。
- 生产服务必须挂 GilMonitor,p95 阻塞 > 30ms 报警。
- 关键路径上的纯 Python 计算超过 5ms,优先考虑 Cython nogil 或 numpy 矢量化。
- 用 Pillow-SIMD 替换图像类 C 扩展(opencv-python 仅在它独有的 CV 算法时用,日常 IO/几何变换用 Pillow)。
- asyncio 函数体内禁止直接调用 CPU > 5ms 的同步函数,必须
run_in_executor。这条 lint 化进了 ruff 自定义规则。 - ProcessPoolExecutor 必须配 max_tasks_per_child,防 C 库长期累积内存泄漏。
- 火焰图采集脚本默认部署:每个 pod 容器内 py-spy 二进制就位,kubectl exec 一行命令可以采。
- 压测必须做"线程数扫描":1/2/4/8 线程下吞吐曲线如果不接近线性,就要立刻找 GIL 阻塞点,不能等线上炸。
关于 Python 3.13 free-threaded 的判断
2025 年下半年 Python 3.13 的 free-threaded(--disable-gil)版本开始 PEP 703 实验阶段。我们花了 2 天测它,目前结论是:对纯 Python 计算密集型代码(数学、字符串处理)有明显收益,8 线程能跑出 6x 加速比;对依赖 C 扩展的代码,要看每个扩展是否 "PEP 703 ready"——pillow / numpy 主线已经支持,opencv / lxml / 某些数据库驱动还没。它不是替代 nogil/Cython 的方案,而是把 GIL 解决方案从"用户层 hack"变成"语言层支持"。我们计划 2026 Q2 在 dev 环境正式跟。
从工程角度,我个人的判断是:即使 free-threaded 全面 ready 也不会完全替代 Cython / ProcessPool 这些方案。因为 Cython 还提供了 SIMD、OpenMP、与 C/C++ 互操作的能力,不只是"释放 GIL"那么简单。但 free-threaded 会大幅降低"普通业务代码"中多线程的心智负担——以后大概不需要每个 Python 工程师都学怎么判断 GIL 释放了。
总结
这次故障让我明白一个朴素但容易忘的道理:Python 多线程跑不快不是 Python 的问题,是 C 扩展不释放 GIL 的问题。这两件事在我刚学 Python 的年代被混为一谈,以至于一代人养成了"反正 Python 单线程"的认知。但 Python 从 1996 年的 1.4 版本就支持 C 扩展释放 GIL,这是个 30 年的老接口,只是很多开发者一辈子没碰过这一层,所以"反正 Python 慢"就变成了一种集体潜意识。这次复盘里我有几个工程师跟我说"我以前一直以为 Python 多线程没用"——他们不是不聪明,只是没人告诉他们这个隐藏维度。
另外一个收获是故障复盘的产出不止"修复方案",还要包含"观测体系"。这次我们的最大长期资产是 GilMonitor 这套监控,而不是单次故障的修法。如果只修了 bug 没有把"GIL 持有时间"这个指标长期挂在 Grafana 上,下次再出现类似问题(比如某个新引入的 C 扩展又不释放 GIL),我们还是会被打个措手不及。把指标化、SLO 化变成日常生产基础设施,才是把"经验"转成"组织能力"的关键。
如果你的 Python 服务 CPU 上不去、p99 雪崩、加 worker 也没用,先别忙着重写成 Rust 或者切 Go——花 1 小时挂上 GilMonitor 跑一遍,你大概率能在 80 行代码内找到那个不释放 GIL 的 C 扩展。换库或者 Cython 重写 200 行,可能就能省下大半个集群。重写整个服务从来不是第一选项,真正聪明的工程是在原架构里找到那个最贵的瓶颈,精准消灭它。Python 这条技术栈的天花板远比很多人想象得高,关键是你愿不愿意去看 C 扩展那一层——大多数人懒得看,所以大多数人才会一遍又一遍重复"Python 不行"的论调。
—— 别看了 · 2026