通过取消Python垃圾收集(GC)机制,该机制可以通过收集和释放未使用的数据来回收内存,Instagram的运行效率可以提高10%。是的,您没听错!通过禁用GC,我们可以减少内存占用并提高CPU LLC缓存命中率。如果您想知道为什么,请系好安全带!
Instagram的网络服务器在Django上以多进程模式运行,并具有一个主进程,该主进程本身会创建数十个辅助进程,以接收传入的用户请求。对于应用程序服务器,我们将uWSGI与前叉模式结合使用,以利用主进程与工作进程之间的内存共享。为了防止Django服务器运行到OOM中,uWSGI主进程提供了一种在RSS内存超出预定义限制时重新启动工作进程的机制。
我们首先研究为什么工作RSS内存在主进程生成后立即增长如此之快。一个观察结果是,即使RSS内存以250MB开头,它的共享内存也会很快下降-在几秒钟内从250MB下降到大约140MB(可以从/ proc / PID / smaps读取共享内存的大小)。这里的数字并不有趣,因为它们一直在变化,但是共享内存下降的规模非常有趣-大约占总内存的1/3。接下来,我们想了解在工作程序生成开始时,为什么每个进程此共享内存都变为私有内存。
Linux内核具有一种称为写时复制(CoW)的机制,可作为分叉进程的优化。子进程首先与其父进程共享每个内存页面。仅在页面被写入时才复制到孩子的内存空间(有关更多详细信息,请参阅Wiki https://en.wikipedia.org/wiki/Copy-on-write)。但是在Python领域,由于引用计数的原因,事情变得很有趣。每次我们读取Python对象时,解释器都会增加其引用计数,这实际上是对其底层数据结构的写操作。这导致了CoW。因此,使用Python,我们可以进行读取时复制(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的引用计数。
在Instagram,我们首先要做简单的事情。鉴于这是一个实验,我们对CPython解释器进行了一些小小的修改,并验证了代码对象上的引用计数没有变化,然后将该CPython运送到我们的生产服务器中。结果令人失望,因为共享内存没有变化。当我们试图找出原因时,我们意识到找不到可靠的指标来证明我们的黑客行为可行,也无法证明共享内存与代码对象副本之间的联系。显然,这里缺少一些东西。经验教训:在进行理论验证之前先证明自己的理论。
在对“写时复制”进行了一些搜索之后,我们了解到“写时复制”与系统中的页面错误相关联。每个CoW都会触发该过程中的页面错误。 Linux附带的Perf工具允许记录硬件/软件系统事件,包括页面错误,甚至可以在可能的情况下提供堆栈跟踪!因此,我们转到产品服务器,重新启动服务器,等待它派生,获得工作进程PID,然后运行以下命令。
然后,我们有了堆栈跟踪过程中何时发生页面错误的想法。
结果与我们的预期不同。除了复制代码对象外,最可疑的是收集,它属于gcmodule.c,在触发垃圾收集时被调用。在阅读完GC如何在CPython中工作之后,我们有了以下理论:基于阈值确定性地触发CPython的GC。默认阈值非常低,因此它在很早的阶段就开始发挥作用。它维护对象代的链接列表,并且在GC期间,链接列表被重新排列。因为链接列表结构与对象本身一起存在(就像ob_refcount一样),所以在链接列表中混洗这些对象将导致页面被编译,这是一个不幸的副作用。
/ * GC信息存储在对象结构之前。 * / typedef union _gc_head {struct {union _gc_head * gc_next; union _gc_head * gc_prev; Py_ssize_t gc_refs; } GC;长双假人/ *强制最坏情况对齐* /} PyGC_Head;
好吧,由于GC背叛了我们,请禁用它!我们在自举脚本中添加了gc.disable()调用。我们重新启动了服务器,但是再次失败了!如果我们再次查看性能,我们会看到gc.collect仍然被调用,并且内存仍然被复制。通过GDB的一些调试,我们发现很显然,我们使用的一个第三方库(msgpack)调用gc.enable()将其重新带回,因此在引导时将gc.disable()冲洗掉了。修补msgpack是我们要做的最后一件事,因为它为将来其他库在没有引起我们注意的情况下留出了余地。首先,我们需要证明禁用GC确实有帮助。答案再次存在于gcmodule.c中。作为gc.disable的替代方法,我们执行了gc.set_threshold(0),这一次,没有库将其恢复。这样,我们成功地将每个工作进程的共享内存从140MB提升到225MB,并且主机上每台计算机的总内存使用量减少了8GB。这为整个Django舰队节省了25%的RAM。有了如此大的头部空间,我们能够运行更多的进程或以更高的RSS内存阈值运行。实际上,这将Django层的吞吐量提高了10%以上。
在尝试了一堆设置之后,我们决定在一个更大的规模上尝试它:一个集群。反馈非常快,并且我们的连续部署中断了,因为在禁用GC的情况下重新启动Web服务器变得非常慢。通常重新启动的时间少于10秒,但是在禁用GC的情况下,有时重新启动的时间超过60秒。
2016-05-02_21:46:05.57499 WSGI应用程序0(mountpoint ='')在115秒内在解释器0x92f480 pid上准备就绪:4024654(默认应用程序)
重现此错误非常痛苦,因为它不是确定性的。经过大量的实验,顶部显示了一个真实的再现。发生这种情况时,该主机上的可用内存下降到几乎为零,然后跳回,从而迫使所有缓存的内存耗尽。然后是需要从磁盘读取所有代码/数据(DSK 100%)的那一刻,一切都很缓慢。这使Python会在解释器关闭之前进行最终的GC敲响了警钟,这将在很短的时间内导致内存使用量的巨大跳跃。再次,我想先证明它,然后找出如何正确处理它。因此,我注释掉了uWSGI的python插件中对Py_Finalize的调用,问题消失了。但显然我们不能照原样禁用Py_Finalize。我们使用了依赖于它的atexit挂钩进行了一堆重要的清理工作。我们最终要做的是向CPython添加一个运行时标志,这将完全禁用GC。最后,我们必须将其大规模推广。此后,我们尝试了整个机队,但连续部署再次中断。但是,这一次它仅在具有旧CPU型号(Sandybridge)的计算机上损坏,并且甚至更难于重新配置。经验教训:始终测试旧的客户端/模型,因为它们通常是最容易破坏的。因为我们的连续部署是一个相当快的过程,所以为了真正了解发生的事情,我在推出命令中添加了一个单独的顶部。我们能够捕捉到高速缓存内存真的不足的时刻,所有uWSGI进程都会触发很多MINFLT(较小的页面错误)。
同样,通过性能分析,我们再次看到了Py_Finalize。在关闭时,除了最终的GC外,Python还会执行一系列清理操作,例如销毁类型对象和卸载模块。同样,这会损害共享内存。
为什么我们需要全部清理?这个过程将要结束,我们将为其另外替换。我们真正关心的是为应用程序做清理的atexit挂钩。至于Python的清理,我们不必这样做。这就是我们在引导脚本中得到的结果:
#gc.disable()无法正常工作,因为某些随机的第三方库会#隐式地启用它。 gc.set_threshold(0)#其他atexit函数完成后立即自杀。 #CPython将在Py_Finalize中进行一堆清理,#将再次导致写时复制,包括最终的GC atexit.register(os._exit,0)
这是基于atexit函数以与注册表相反的顺序运行的事实。 atexit函数完成其他清理,然后在最后一步中调用os._exit(0)退出当前进程。通过两行更改,我们最终完成了将其推广到我们整个机队的工作。仔细调整内存阈值后,我们获得了10%的全球容量胜利!
在回顾这次性能胜利时,我们有两个问题:首先,没有垃圾回收,Python的内存不会耗尽,因为所有内存分配都不会被释放吗? (请记住,Python内存中没有真正的堆栈,因为所有对象都是在堆上分配的。)幸运的是,事实并非如此。 Python中释放对象的主要机制仍然是引用计数。当取消引用对象(调用Py_DECREF)时,Python运行时始终检查其引用计数是否降至零。在这种情况下,将调用对象的解除分配器。垃圾回收的主要目的是打破引用计数不起作用的引用周期。
#定义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 *) )); \}而(0)
第二个问题:收益来自何处?禁用GC的收益有两个:
我们为每台服务器释放了大约8GB的RAM,我们用来为内存绑定服务器生成创建更多工作进程的服务器,或者降低了CPU绑定服务器生成的工作重生率;
#性能统计-a -e缓存缺失,缓存引用-睡眠10个系统范围内的性能计数器统计信息:268,195,790缓存缺失#所有缓存参考的12.240%[100.00%] 2,191,115,722缓存-参考10.019172636秒的时间已过去
在禁用GC的情况下,缓存丢失率下降了2-3%,这是IPC提高10%的主要原因。 CPU高速缓存未命中代价高昂,因为它会使CPU管线停滞不前。通常,对CPU高速缓存命中率进行小的改进通常可以显着提高IPC。使用更少的CoW,具有不同虚拟地址(在不同工作进程中)的更多CPU缓存行将指向同一物理内存地址,从而提高了缓存命中率。如我们所见,并不是每个组件都能按预期工作,有时结果可能非常令人惊讶。因此,不断地探索和嗅探,事情会真正发生,您会感到惊讶!
吴晨阳是Instagram的软件工程师,倪敏是Instagram的工程经理。