扩容那天是周四。风控服务从 4 个 worker 加到 8 个,QPS 上去了,但监控里的 RSS 也跟着翻倍,从 1.9GB 涨到 3.8GB。那台机器一共 8GB,PostgreSQL 还占着 2GB。
运维在群里 @ 我:「这服务是有内存泄漏吗?」
没有泄漏。是设计问题。每个 worker 进程启动时都会把整套规则集和参考数据加载到自己的地址空间里,大概 480MB。8 个进程就是 8 份一模一样的内容,互不相干,各自占着物理内存。
4 个 worker 的时候我们忍了两年。8 个的时候忍不了了。
为什么当初非用多进程不可
规则引擎的 hot path 全是纯 Python:几百条规则,每条做几次字符串匹配、几次数值比较,偶尔跑个正则。没有 numpy,没有 C 扩展参与计算。这是最典型的 CPU-bound 纯 Python 负载,也是 GIL 最不友好的场景。
2019 年搭这套东西的时候,多进程确实是唯一的选择。GIL 摆在那儿,一个进程里的多线程再怎么加也跑不满一个核,加线程只会让上下文切换变多、吞吐变差。所以配置里写的是 gunicorn 的 preload 加多个 sync worker,这是当年被反复推荐的标准做法。
它工作了六年。代价是每个进程都装着一份完整的规则集。
先确认手上这把新锤子是真的
Python 3.13 引入了实验性的自由线程构建,二进制名字后面带个 t。3.14 开始它不再叫实验性了,虽然依然不是默认构建,但官方已经把它列为受支持的状态了。
装的话,用 pyenv 或者 uv 都行:
uv python install 3.14t
uv venv --python 3.14t .venv-free
source .venv-free/bin/activate
装完第一件事,别急着跑业务代码,先确认 GIL 真的是关的:
import sys
print(sys.version)
print('GIL enabled:', sys._is_gil_enabled())
输出里 sys.version 会带上 free-threading build 的标记,_is_gil_enabled() 返回 False。如果它返回 True,说明这个构建没关掉 GIL,或者你装错版本了。
第二件事更重要:把服务启动一遍,盯着 stderr。
自由线程的机制是这样的——任何一个 C 扩展,如果它的作者没有在模块定义里声明「我能安全地跑在没有 GIL 的环境下」,那么它被 import 的时候,运行时会把 GIL 重新打开,并且打一条警告:
RuntimeWarning: The global interpreter lock (GIL) has been enabled to
load module 'xxx', which has not declared that it can run safely
without the GIL. For more information, see:
https://docs.python.org/3/howto/free-threading-extensions.html
这条警告出现的那一刻,你后面所有的努力就全白费了。GIL 一旦被重新打开,这个进程里就不会再关掉——除非你在启动前设了 PYTHON_GIL=0 强制锁死。
我先用 PYTHON_GIL=0 跑了一遍,看看那些没声明的扩展到底是哪些。
第一次尝试:三个不兼容的依赖
PYTHON_GIL=0 python -X importtime -c "import app" 2>&1 | grep -i gil
抓出来三个:
一个是公司内部的日志收集 SDK,纯 C 写的,编译的时候压根没想到这回事。这个好办,我们自己维护,加上 Py_mod_gil 声明重新编一遍就行。
一个是老版本的某个序列化库,用的版本太旧。升到最新版之后没问题了。
第三个是 OpenSSL 绑定。这个我们自己也搞不定,只能等上游。
但好消息是,OpenSSL 相关的调用都在网络 IO 上,而那些调用本来就释放 GIL,加不加上声明影响不大。所以我采取的方案是:不用 PYTHON_GIL=0 强制,而是让它在 import 的时候正常重新启用 GIL,然后在启动完成后手动确认一下状态——因为我们的核心计算路径不碰这几个库。
Hmm,这里我需要更正一下自己。我一开始以为「import 之后 GIL 被打开,之后就永远是打开状态」。实际上正确做法是:如果你确实需要在整个进程生命周期里保持 GIL 关闭,用 PYTHON_GIL=0 强制;如果你容忍某几个扩展带来的锁,那就接受它的后果——只要那些扩展不参与你的 hot path,重新打开的 GIL 只会在它们内部的 C 调用上生效……
不对。GIL 是个全局的东西,要么开要么关,没有”只在某个模块里生效”这种说法。一旦被重新打开,整个解释器就退回串行执行了。
所以正确的判断是:只要有一个 import 触发了重新启用,你就完全没有自由线程了。我们最后的选择是把那个 OpenSSL 绑定换掉了,换成了不需要它的实现方式。
这个过程花了差不多三天,比后面改业务代码还久。教训是:迁移之前先把依赖清点一遍,别写代码写到一半发现路走不通。
线程安全审计:翻出三个真问题
GIL 关掉之后,以前那些「靠 GIL 保护所以不会出事」的代码就全暴露了。我按模块过了一遍,找到三处。
第一处:手写的 LRU 缓存
大概长这样:
class LruCache:
def __init__(self, size):
self.size = size
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
if key in self.data:
self.data.move_to_end(key)
self.data[key] = value
if len(self.data) > self.size:
self.data.popitem(last=False)
get 里那个 move_to_end 加上读取,是两个操作;put 里检查再赋值,也是两个操作。单线程下没问题,多线程下 OrderedDict 的内部链表会被踩坏,最坏的情况是死循环。
这玩意儿本来是用来缓存规则评估结果的。直接换成 functools.lru_cache 就完了——标准库在自由线程构建里给这些容器加了必要的锁。
不过有一点要说清楚:lru_cache 保证的是内部结构不被破坏,它不保证同一个 key 只会计算一次。两个线程同时请求同一个未缓存的 key,函数会被调用两次。对我们来说无所谓,因为计算是幂等的,多算一次的代价远小于加锁的代价。但如果你的被缓存函数有副作用,那就得自己加锁。
第二处:模块级的懒加载
_rules = None
def get_rules():
global _rules
if _rules is None:
_rules = load_rules_from_disk() # 要读 200MB 的 json
return _rules
这段代码在多进程模型下面一直好好的,因为每个进程只有一个线程。改成 8 线程之后,服务刚启动的那几秒里,8 个线程同时冲进 load_rules_from_disk,磁盘 IO 直接打满,启动时间从 4 秒变成 11 秒,而且内存里同时存在 8 份正在解析的中间对象,峰值内存冲到 1.2GB。
不是数据错了,是干了 8 倍的活。上面那个双重检查锁的写法也不能直接用——没有 GIL 之后,”读全局变量”和”写全局变量”之间没有内存屏障保证。最后用的是 threading.Lock 加显式的 threading.Event,让其余线程安静地等第一个线程加载完。
第三处:一个统计计数器
self.hit_count += 1
这行代码编译出来是 LOAD、ADD、STORE 三步。单线程统计总数是准的,多线程下会丢数。这个统计只用于内部监控面板,丢几个数其实无所谓,但既然是审计,就顺手改成了 itertools.count 或者 threading.Lock 保护的写入。
顺便说一个很多人会踩的认知偏差:dict、list 的单个操作在 CPython 里本来就是原子的,自由线程构建里也一样。你不用担心 d[key] = value 或者 lst.append(x) 会把数据结构搞坏。真正的问题永远出在「读-改-写」这种复合操作上。所以审计的重点不是把每个 dict 都换成线程安全的,而是找到那些跨越多个操作的逻辑。
基准测试:数字和预期不太一样
测试机器是 16 vCPU 的 EPYC,8GB 内存。
先看内存,这是迁移的主要动机:
多进程 8 worker RSS 合计 3.8GB
单进程 8 线程 RSS 合计 560MB
降了 85%,比预期的还好一点。原因是除了规则集之外,还有一堆解析后的索引结构、编译好的正则对象、热身后的字符串字面量,这些现在都只有一份了。
吞吐:
多进程 8 worker 4180 rps p50 2.1ms p99 47ms
单进程 8 线程 4360 rps p50 1.9ms p99 31ms
吞吐只涨了 4%,基本可以认为是持平。这符合预期——并行度都是 8,我们是换了个实现方式,不是增加了算力。
p99 从 47ms 降到 31ms,这个提升比吞吐更有价值。原因有两个:一是省掉了进程间的调度开销;二是缓存共享了,以前某个 worker 冷缓存的时候会慢一拍,现在所有线程共用一份热数据。
然后是线程数扫描。这一轮测试只跑纯计算部分(不包含数据库和 Redis 的等待),单条规则评估在单线程下的耗时是 1.94ms:
线程数 加速比
1 1.00x
2 1.92x
4 3.61x
6 5.02x
8 6.31x
10 6.44x
12 6.51x
16 6.38x
8 线程只拿到 6.31x,效率 79%。这不是 GIL 的问题(它确实关掉了),是内存分配器和 CPU 缓存争用造成的——几百条规则同时读写共享的参考数据,cache line 在核之间来回弹。超过 10 个线程就开始掉,说明已经到瓶颈了。
所以最后的生产配置定的是 10 线程,不是 16。
单线程性能确实变慢了
这一点必须提前说清楚,不然上线之后会被吓到。
同一个规则集,同样的输入,单线程跑:
普通构建 1.83ms
自由线程构建 1.94ms
慢了 6%。跑 pyperformance 全套也差不多是这个量级。原因主要是内存表示变了:自由线程构建下每个对象头多了几个字节,引用计数要用偏向引用计数(biased reference counting)那一套机制,比普通构建里简单的一次加减贵。
这个损失是拿并行能力换来的,躲不掉。如果你的服务本来就是单线程跑、延迟敏感,那换自由线程纯属自找麻烦。
上线之后遇到的几个问题
第一个是 GC 停顿。自由线程构建里的循环垃圾回收依然是会打断其他线程的。8 个线程并行跑的时候,一次 gen2 的回收能让 p99 冒出几个毛刺。处理办法是在服务预热完成后调一次 gc.freeze(),把启动阶段创建的那些不会被回收的对象摘出去,然后适度调高阈值。改完之后 p99 的毛刺从二十几毫秒降到个位数。
第二个和 sys.setswitchinterval 有关。我们老代码里有一行调整线程切换间隔的调用,是为早期某个场景调优留下的。在自由线程构建下它不再起作用——线程调度的机制整个变了。这行代码没报错也没警告,就是静静地什么都不做。是后来对着旧代码做 code review 才发现的。
第三个是线程数的调优比多进程更敏感。多进程的时候,进程数设成 16 和设成 8,差距只是资源浪费;线程数设多了会实实在在地拉低吞吐,因为争用是发生在同一个地址空间里的。建议从核数的一半开始往上试,别一上来就顶到核数。
什么情况下不值得折腾
这轮改造做下来,我的判断标准大概是这样。
如果你的负载是 IO 密集的,别折腾。普通构建的线程在等待网络和磁盘的时候本来就会释放 GIL,自由线程带来的收益接近零,而那 6% 的单线程损失是实打实的。
如果你的计算部分压在 numpy、pandas 或某个 C 扩展上,先去查那个库的版本支不支持自由线程。不支持的话,import 的那一刻 GIL 就回来了,后面做什么都没用。
如果你的依赖树里有几十个 C 扩展,而且大部分是第三方维护、更新缓慢的,那这个迁移的成本会远超你的预期。清点依赖这一步,比改业务代码重要得多。
反过来,如果你的场景是「大量纯 Python 计算 + 大块只读的共享数据 + 多进程带来的内存压力」,那自由线程几乎是为你准备的。我们这个案例里的内存账算得很清楚:省下 3.2GB,等于把机器规格降了一档,或者让同一台机器能跑两个实例。
至于那 6% 的单线程损失,换来的是一套不再需要预加载、不再需要处理进程间一致性的架构。这笔账怎么算,取决于你的具体数字。
迁移前的检查清单
把这次做过的所有事按顺序列一遍,供参考:
先装一个自由线程构建,用 sys._is_gil_enabled() 确认状态。
然后在完整依赖的环境里跑一次 import,把所有触发 GIL 重新启用的扩展列出来。这一条不过关,后面全是白干。
测单线程性能损失。用你自己的真实负载,别用基准测试套件,两者可能差好几倍。
审计共享可变状态。重点找「读-改-写」这种复合操作,以及全局变量的懒初始化。单个 dict 操作不用管。
测线程数曲线,不要直接按核数配。
处理 GC:预热后 gc.freeze(),调整阈值,观察 p99。
最后,把多进程配置保留成一个可回滚的分支。我们上线的时候是灰度切流量的,新实例先接 10%、再 50%、最后全量,中间隔了四天。这套流程救过一次——切到 50% 时发现有个定时任务在自由线程构建下偶尔会算错聚合值,回滚之后查了一天,是那个任务里用了 self.total += ...。修完重新上线就没问题了。
那次之后我把整个仓库的 += 过了一遍,又找到两处类似的。都是老代码,都是在 GIL 的保护下写了六年从来没出过事的写法。

