写点什么

禁用 Python 的 GC 机制后,Instagram 性能提升 10%

2017 年 2 月 08 日

通过关闭 Python 垃圾回收(Garbage Collection,GC)机制(通过回收和释放未使用的数据来回收内存),Instagram 的性能可以提高 10%。是的,你没有听错!通过禁用 GC,我们可以减少内存占用并提高 CPU LLC 缓存命中率。如果你想知道为什么,那么就来阅读 Chenyang Wu 和 Min Ni 为此撰写的文章

作者Chenyang Wu 是Instagram 的软件工程师,Min Ni 是Instagram 的技术经理。

我们如何管理Web 服务器

Instagram 的 web 服务器以多进程的模式运行在 Django 上,主进程分叉创建几十个工作进程,用来接收传入的用户请求。对于应用程序服务器,我们使用带前置模式的 uWSGI 来利用主进程和工作进程之间的内存共享。

为了防止 Django 服务器运行到 OOM,uWSGI 主进程提供了一种机制,当其 RSS 内存超过阈值时重新启动工作进程。

了解内存

我们开始研究工作 RSS 内存为什么在由主进程产生后迅速增长。一个观察是,即使 RSS 存储器以 250MB 开始,其共享内存下降非常快:在几秒钟内从 250MB 降到约 140MB(共享内存的大小可以从/proc/PID/smaps读取)。这里的数字是无趣的,因为它们一直在变动,但共享内存丢弃的规模很有趣:大约 1/3 的总内存。接下来,我们想要了解为什么共享内存在工作器产生伊始就变为每个进程的私有内存。

我们的理论:读时复制

Linux 内核有一个称为写入复制(Copy-on-Write,CoW)的机制,用作分叉进程的优化。子进程通过与其父进程共享每个内存页开始。仅当页面被写入时复制到子内存空间的页面(有关详细信息,请参阅维基百科上的 Copy_on_Write词条)。

但在 Python 中,由于引用了计数,事情变得有趣了。每次我们读取一个 Python 对象时,解释器将增加其引用计数,这本质上是对其底层数据结构的写入。这就导致了 CoW。因此,使用 Python,我们就进行读时复制(Copy-on-Read,CoR)!

复制代码
#define PyObject_HEAD \
_PyObject_HEAD_EXTRA \
Py_ssize_t ob_refcnt; \
struct _typeobject *ob_type;
...
typedef struct _object {
PyObject_HEAD
} PyObject;

那么问题是:我们是在写时复制不可变对象(如代码对象)么?给定PyCodeObject确实是PyObject的“子类”,那么答案显然为:是。我们的第一个想法,是禁用对PyCodeObject的引用计数。

尝试 1:禁用代码对象的引用计数

在 Instagram,我们先做简单的事情。考虑到这是一个实验,我们对 CPython 解释器做了一些小的修改,验证了引用计数对代码对象没有改变,然后将 CPython 应用到我们的一个生产服务器。

结果令人失望,因为共享内存没有变化。当我们试图找出原因时,我们意识到没有任何可靠的指标来证明分析是否正确,也不能证明共享内存和代码对象的副本之间的关系。显然,这里缺少一些什么东西。由此获得的经验是:在运作之前证明你的理论。

分析页面故障

当我们在 Google 上搜索关于 Copy-on-Write 的资料后,了解到 Copy-on-Write 与系统中的页面错误是相关联的。每个 CoW 在过程中触发页面错误。Linux 附带的 Perf 工具允许记录硬件 / 软件系统事件,包括页面错误,甚至可以提供堆栈跟踪!

于是我们运行了一个 prod 服务器,重启服务器后,等待它进行分叉,得到了一个工作进程的 PID,然后运行以下命令:

perf record -e page-faults -g -p <PID>我们就有了一个新的想法,看看当页面错误如果发生在堆栈跟踪的过程中会发生什么。

(点击放大图像)

结果出乎意料,并没有复制代码对象,最大的疑凶是 collect,它属于gcmodule.c,并在触发垃圾回收时被调用。在阅读了 GC 在 CPython 中的工作原理后,我们得出了以下理论:

基于阈值确定性地触发 CPython 的 GC。默认阈值非常低,因此它在很早的阶段就开始了。它维护对象的分代链接列表,并且在 GC 期间,链接列表被洗牌。因为链接列表结构与对象本身一起存在(就像ob_refcount),在链接列表中改写这些对象将导致页面被 CoW,这是一个不幸的副作用。

复制代码
/* GC information is stored BEFORE the object structure. */
typedef union _gc_head {
struct {
union _gc_head *gc_next;/
union _gc_head *gc_prev;
Py_ssize_t gc_refs;
} gc;
long double dummy; /* force worst-case alignment */
} PyGC_Head;

尝试 2:尝试禁用 GC

既然是 GC 捅了我们一刀,那就禁用它!

我们引导脚本添加了一个gc.disable()调用,然后重启了服务器。我们重新启动了服务器,但是,很不幸!如果我们再次查看 perf,将会看到gc.collect仍然被调用,并且内存仍然被复制。利用 GDB 的一些调试,我们发现,使用的一个第三方库(msgpack)调用gc.enable()将其恢复,因此 gc.disable() 在引导时被清除。

修补 msgpack 是我们要做的最后一件事,因为它意味着我们没有注意到其他库在未来也会做同样的事情。首先,我们需要证实禁用 GS 实际上是很有帮助的。答案存在于gcmodule.c中。作为gc.disable的替代,我们做了gc.set_threshold(0),这一次,没有任何库被恢复过来。

这样,我们成功地将每个工作进程的共享内存从 140MB 提高到 225MB,并且每台机器在主机上的总内存使用量减少了 8GB。这就为整个 Django 集群节约了 25% 的内存。有了这么大的头部空间,,我们能够运行更多的进程或运行带有更高的 RSS 内存阈值。实际上,这样的改进将 Django 层的吞吐量提高了 10%以上。

尝试 3:需要完全禁止 GC

在我们尝试了一堆设置之后,我们决定在更大的范围内尝试:集群。反馈相当快,因为禁用 GC 后,重启 Web 服务器变得很慢,以至于我们的连续部署被中断了。通常重启耗时不到 10 秒钟,但禁用 GC 后,有时候,耗时会超过 60 秒。

复制代码
2016-05-02_21:46:05.57499 WSGI app 0 (mountpoint='') ready in 115 seconds on interpreter 0x92f480 pid: 4024654 (default app)

重现这个 bug 非常伤脑筋,因为它不是确定性的。经过大量实验后,一个真正的 re-top 在顶部显示了。当发生这种情况时,主机上的可用内存骤降到接近零并跳回,强迫所有的高速缓存内存撤出。然后到所有的代码 / 数据需要从磁盘读取(DSK 100%)的时刻,一切都慢吞吞的。

听上去很奇怪,Python 会在关闭解释器之前做最后一个 GC,这会在很短的时间内,导致内存使用量产生巨大的飞跃。再者就是,我想先证明它,然后弄清楚如何正确处理它。因此,我在 uWSGI 的 python 插件中注释掉Py_Finalize的调用,问题就消失了。

但显然的是,我们不能对Py_Finalize只是一禁了之。因为我们有一堆重要的清理,要用到依赖它的 atexit 钩子。最后,我们所做的就是,在 CPython 添加一个运行时标志,来完全禁用 GC。

最后,我们开始将这个做法推广到更大的规模。此后,我们在整个集群进行尝试,但是,连续部署再次被中断了。不过,这次它只是在旧 CPU 型号(Sandybridge)的机器上中断了,甚至更难重现。经验教训:要多测试旧式客户端 / 旧型号,因为他们最容易被中断。

因为我们的连续部署是一个相当快的过程,为了真正捕获发生了什么,我在 rollout 命令添加了一个单独的atop。这样我们就能够抓住高速缓存内存真的很低的一个时刻。所有 uWSGI 进程触发了很多 MINFLT(minor page faults,小页面错误)。

(点击放大图像)

再次通过perf 得出的概要,我们再次看到了 Py_Finalize。在关机时,除了最终的 GC,Python 做了一堆清理操作,如破坏类型对象和卸载模块。这又一次损害了共享内存。

(点击放大图像)

尝试 4:关闭 GC 的最后一步:无须清理

为什么我们需要清理?这个进程将会死掉去,我们将得到另一个替代品。我们真正关心的是清理应用程序的 atexit 钩子。至于 Python 的清理,我们不必这样做。下面是在 bootstrapping 脚本中的结束:

复制代码
# gc.disable() doesn't work, because some random 3rd-party library will
# enable it back implicitly.
gc.set_threshold(0)
# Suicide immediately after other atexit functions finishes.
# CPython will do a bunch of cleanups in Py_Finalize which
# will again cause Copy-on-Write, including a final GC
atexit.register(os._exit, 0)

基于这个事实,atexit 函数以注册表的相反顺序运行。atexit 函数完成其他清除,然后调用os._exit(0)来退出最后一步的当前进程。

随着这两条线的变化,我们终于完成了整个集群的推广。在仔细调整内存阈值后,我们获得了 10%的全局性能提升!

回顾

在回顾这次性能的提升时,我们有两个疑问。

首先,没有垃圾回收的话,因为所有的内存分配不会释放,Python 内存就不会爆破吗?(记住,在 Python 内存中没有真正的堆栈,因为所有的对象都是在堆上分配的。)

幸运的是,这并非事实。Python 中用于释放对象的主要机制仍然是引用计数。当一个对象被解除引用(调用Py_DECREF)时,Python 运行时总是检查其引用计数是否降到零。在这种情况下,将调用对象的释放器。垃圾回收的主要目的是打破引用计数不起作用的参考周期。

复制代码
#define Py_DECREF(op) \
do { \
if (_Py_DEC_REFTOTAL _Py_REF_DEBUG_COMMA \
--((PyObject*)(op))->ob_refcnt != 0) \
_Py_CHECK_REFCNT(op) \
else \
_Py_Dealloc((PyObject *)(op)); \
} while (0)

打破增益

第二个问题:增益来自哪里?

禁用 GC 的增益是两倍:

  • 我们为每个服务器释放了大约 8GB 的 RAM,用于为内存绑定服务器生成创建更多的工作进程,或者降低 CPU 绑定服务器生成的工作程序刷新率;
  • 随着每周期 CPU 指令(IPC)增加约 10%,CPU 吞吐量也随之提高。
复制代码
# perf stat -a -e cache-misses,cache-references -- sleep 10
Performance counter stats for 'system wide':
268,195,790 cache-misses # 12.240 % of all cache refs [100.00%]
2,191,115,722 cache-references
10.019172636 seconds time elapsed

禁用 GC 时,高速缓存未命中率有 2~3%的下降,主要原因是 IPC 增加 10% 所致。CPU 高速缓存未命中的代价太高了,因为它使 CPU 管道停滞。对 CPU 缓存命中率的微小改进通常可以显著提高 IPC。使用较少的 CoW,具有不同虚拟地址(在不同的工作进程中)的更多 CPU 高速缓存线指向相同的物理存储器地址,导致更好的高速缓存命中率。

我们可以看到,并非每个组件都按预期工作,有时,结果可能会非常令人惊讶。所以继续挖掘、四处观望,你会惊讶事情究竟是如何运作的!


感谢魏星对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2017 年 2 月 08 日 16:037431
用户头像

发布了 325 篇内容, 共 120.5 次阅读, 收获喜欢 802 次。

关注

评论

发布
暂无评论
发现更多内容

Redis追命连环问,你能回答到第几问?(上)Redis简介,数据类型及缓存雪崩缓存击穿缓存穿透

大柚子

Java redis 缓存 面试 后端

Linux系统监控工具推荐

王坤祥

监控 工具软件

区块链交易所系统开发内容,数字货币交易所搭建

13530558032

马方业:区块链就是新未来 区块链就是新财富

CECBC区块链专委会

区块链 新未来 新财富

ARTS 打卡第四周(200601-200607)

老胡爱分享

ARTS 打卡计划

ARTS 挑战打卡第七周(200622-200628)

老胡爱分享

ARTS 打卡计划

视频码控:CBR、VBR和ABR

潇湘落木

直播 SRS 视频编码 码控

程序员不愿996,创建6个涉黄平台,涉案5000余万元!

程序员生活志

程序员

一文讲透布隆过滤器

flyer0126

布隆过滤器

ARTS 打卡第三周(200525-200531)

老胡爱分享

ARTS 打卡计划

深圳区块链支付系统开发,USDT支付系统服务商

13530558032

ARTS 挑战打卡第九周(200706-200712)

老胡爱分享

ARTS 打卡计划

Spring Boot 集成 Sharding-JDBC + Mybatis-Plus 实现分库分表

简爱W

交易所合约跟单开发方,数字资产合约跟单系统搭建

13530558032

小米的护城河

石云升

小米 护城河

朱嘉明 算力革命背后是分配制度革命 没有算力就没有未来

CECBC区块链专委会

区块链 数字货币 数字经济

基于ALBERT的文本相似度解决方案

华宇法律科技

人工智能 自然语言处理 Pytho

从雕像到肖像画,这位设计师用 GAN 和 PS 复原了他眼中的古罗马皇帝「群像」

程序员生活志

高频面试题——你真的搞懂物理内存与虚拟内存了吗

大柚子

操作系统 内存管理 虚拟内存 物理内存

ARTS 打卡第二周(200518-200524)

老胡爱分享

ARTS 打卡计划

ARTS挑战打卡第六周(200615-200621)

老胡爱分享

ARTS 打卡计划

ARTS挑战打卡第八周(200629-200705)

老胡爱分享

ARTS 打卡计划

MySQL如何快速插入数据

Simon

MySQL 数据库

教你用SQL实现统计排名

Simon

MySQL

企业信息化到底重不重要?

代码制造者

低代码 零代码 信息化 编程开发 运营管理

一个人的精益

escray

学习 面试 面试现场

浅谈备受开发者好评的.NET core敏捷开发工具,讲讲LEARUN工作流引擎

Learun

工作流 开发工具 计算机程序设计艺术 表单

ARTS挑战打卡第五周(200608-200614)

老胡爱分享

ARTS 打卡计划

JeecgBoot手记

卧石漾溪

用科学的方法理解每日优鲜

石云升

新零售 每日优鲜 多快好省 科学分析

定时任务最简单的3种实现方法(超实用)

王磊

Java 定时任务

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

禁用Python的GC机制后,Instagram性能提升10%-InfoQ