自由线程 Python 实战:8 进程 3.8GB 内存压到 560MB 的改造记录

2026-09-11 0 169

扩容那天是周四。风控服务从 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 保护的写入。

顺便说一个很多人会踩的认知偏差:dictlist 的单个操作在 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 的保护下写了六年从来没出过事的写法。

自由线程 Python 实战:8 进程 3.8GB 内存压到 560MB 的改造记录
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python 自由线程 Python 实战:8 进程 3.8GB 内存压到 560MB 的改造记录 https://www.taomawang.com/server/python/2745.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务