通过关闭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()在引导时被清除。