先说结论:那个接口慢,真不是线程开少了
2024 年 3 月我接手一个订单导出接口,逻辑很简单,一次请求要串行去调 12 个下游 HTTP 服务,其中 3 个还是历史遗留的 SOAP 接口,改不动。每个下游平均耗时 260ms 左右,串行跑下来 P50 是 3.2 秒,P99 4.7 秒。业务那边每天在群里 @ 我,运营同事导出一次要等五六秒,体验确实差。我第一反应跟大家一样:这不就是典型的 IO 密集型吗,上线程池。改成 ThreadPoolExecutor(max_workers=8),跑一遍,P50 直接掉到 480ms,P99 620ms。当时还挺得意,提交、合并、上线。然后当晚 11 点多,容器开始 OOM 重启了。
压测的数据我其实看见过,但没看懂
上线前我其实用 locust 打过 200 QPS,那时候已经不对劲了。P99 从 620ms 一路爬到 6.8 秒,但 CPU 使用率只有 35%,load average 却飙到 14。这个组合挺反直觉的——CPU 没跑满,负载却这么高,说明大量时间不是在算,而是在排队和切换。我上了个 vmstat 1,cs 那一列(上下文切换次数)稳定在 18 万/秒上下,sy(内核态 CPU)占了 25% 左右。当时我盯着这个数字看了好一会儿才反应过来:200 QPS 乘 8 个 worker,等于同时有 1600 个线程活着。Linux 上每个线程默认要预留 8MB 栈空间,用 ulimit -s 看一般是 8192KB,1600 乘 8MB 就是 12.8GB 的虚拟地址空间。物理内存确实是按需分配的,但光是内核要去管理这么多 mmap 区域、给每个线程做调度记账,成本就已经很可观了。
多线程最贵的从来不是锁,是「你不知道线程在等什么」
这里要说个很多人搞反的事。大家一提多线程性能就想到 GIL,觉得 GIL 就是原罪,其实在我们这个场景里 GIL 影响没那么大。CPython 的 GIL 在 socket 读写阻塞的时候是会主动释放的,所以真正在等下游接口返回的那部分时间,多个线程是能重叠的,这也是为什么第一次优化效果那么好。问题出在另外两段:一是 json.loads,12 个响应加起来大概 200KB JSON,解析一次差不多 40ms,这部分是纯 C 层的计算,根本不释放 GIL,线程之间只能靠 sys.getswitchinterval() 那个默认 5ms 的强制切换来轮转;二是 requests 的连接建立,我们当时每次请求都新建连接,netstat -an | grep TIME_WAIT | wc -l 出来是 32000 多条。这两件事叠在一起,线程越多,抢 GIL 和抢连接资源就越凶,吞吐反而往下掉。后来我们把解析换成了 orjson(Rust 实现的,解析同样的 200KB 从 40ms 降到 9ms 左右),再用 requests.Session 配 HTTPAdapter(pool_connections=32, pool_maxsize=32) 复用连接,TIME_WAIT 直接掉到 200 以内。
最终方案:max_workers 从 8 降到 4,再套一层全局信号量
改法说出来挺朴素的。第一,max_workers 从 8 改成 4。第二,在请求入口加一个全局 threading.Semaphore(64),不管单个请求开几个线程,整个进程同时执行的下游调用不超过 64 个。第三,给每个下游单独设 800ms 超时,超了就返回兜底数据,不再傻等。改完之后 200 QPS 下,P50 是 410ms,P99 稳在 700ms 以内,cs 那列从 18 万降到 4 万出头,CPU 使用率反而涨到 55%——因为时间真的花在干活上了。这里插一句,很多人调线程池的方式是「感觉不够快就往上加」,这跟调 JVM 的 -Xmx 差不多,是在赌。真要算的话有个老公式:N_threads = N_cpu × U_cpu × (1 + W/C),W 是等待时间,C 是计算时间。但我觉得这公式最大的坑在于 W/C 特别难测准,而且它假设任务之间互相独立——真实系统里所有线程可能都在抢同一个数据库连接池,那就完全不是这么回事了。
横向看一眼 Java 和 Go,会更清楚 Python 卡在哪
Java 21 的虚拟线程(Project Loom)现在已经是正式特性了,默认载体线程数就是 CPU 核数(ForkJoinPool,可以用 jdk.virtualThreadScheduler.parallelism 调),单个虚拟线程初始栈只有几百字节,开百万级都没问题,因为它不跟 OS 线程一对一绑定。Go 的 goroutine 初始栈是 2KB,而且 Go 1.14 之后有基于信号的异步抢占,不会出现一个 goroutine 在 for 循环里卡住整个调度器的情况。Python 这边,3.12 通过 PEP 684 把 GIL 变成了「每个子解释器一把」,3.13 通过 PEP 703 提供了 free-threaded 构建(编译时加 --disable-gil)。我本地拿 pyperformance 在 3.13.0 上跑过一遍,free-threaded 构建大概比默认构建慢 10%~15%,而且不少 C 扩展还没适配。所以短期内,Python 多线程的定位还是「IO 重叠工具」,不是「并行计算工具」,这一点在选架构的时候就该想清楚。
现在这个接口上线快一年了,max_workers 还是 4
中间有同事提过要不要调到 16,我拦住了。不是因为 16 一定不行,是因为我们没有任何数据支持它更好。后来我加了个简单的打点,把每个线程从提交到拿到结果、以及真正阻塞在下游 IO 上的时间分别上报到 Prometheus,现在调参是看 P99 和线程等待时间分布,不是看感觉。最后留一句可能有点反直觉的:并发问题里,绝大多数「加机器能解决」的结论,都是因为在单机上还没搞清楚线程到底在等什么。先把等待时间量出来,再决定要不要加线程,这个顺序最好别反。