禁用 Python GC,Instagram 性能提升10%,pythoninstagram,未经许可,禁止转载!英文


本文由 编橙之家 - iScream 翻译,黄利民 校稿。未经许可,禁止转载!
英文出处:Instagram 工程博客。欢迎加入翻译组。

通过关闭 Python 垃圾收集(GC)机制,该机制通过收集和释放未使用的数据来回收内存,Instagram 的运行效率提高了 10 %。是的,你没听错!通过禁用 GC,我们可以减少内存占用并提高 CPU 中 LLC 缓存的命中率。如果你对为什么会这样感兴趣,带你发车咯!

我们如何运行 Web 服务器的?

Instagram 的 Web 服务器在多进程模式下运行 Django,使用主进程创建数十个工作(worker)进程,而这些工作进程会接收传入的用户请求。对于应用程序服务器来说,我们使用带分叉模式的 uWSGI 来平衡主进程和工作进程之间的内存共享。

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

了解内存

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

我们的猜测:读取时复制

Linux内核具有一种称为写入时复制(Copy-on-Write,CoW)的机制,用作 fork 进程的优化。一个子进程开始于与其父进程共享每个内存页。而仅当该页面被写入时,该页面才会被复制到子进程内存空间中(有关详细信息,请参阅 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 的引用计数。

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

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

结果是令人失望的,因为共享内存没有变化。当我们试图找出原因是,我们意识到我们找不到任何可靠的指标来证明我们的黑客行为起作用,也不能证明共享内存和代码对象的拷贝之间的联系。显然,这里缺少一些东西。获得的教训:在行动之前先验证你的理论。

页面错误分析

在对 Copy-on-Write 这个问题谷歌搜索一番以后,我们了解到 Copy-on-Write 与系统中的页面错误是相关联的。每个 CoW 在运行过程中都可能触发页面错误。Linux 提供的 Perf 工具允许记录硬件/软件系统事件,包括页面错误,甚至可以提供堆栈跟踪!

所以我们用到了一个 prod,重新启动该服务器,等待它 fork,继而得到一个工作进程 PID,然后运行如下命令。

perf record -e page-faults -g -p <PID>

然后,当在堆栈跟踪的过程中发生页面错误时,我们有了一个主意。

结果与我们的预期不同。首要嫌疑人是 collect 而非是复制代码对象,它属于 gcmodule.c,并在触发垃圾回收时被调用。在理解了 GC 在 CPython 中的工作原理后,我们有了以下理论:

CPython的 GC 完全是基于阈值而触发的。这个默认阈值非常低,因此它在很早的阶段就开始了。 它维护着许多代的对象链表,并且在进行 GC 时,链表会被重新洗牌。因为链表结构与对象本身一样是存在的(就像 ob_refcount),在链表中改写这些对象会导致页面在写入时被复制,这是一个不幸的副作用。

/* 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 打补丁是我们最后要做的事情,因为它为其他做同样的事情的库打开了一扇门,在未来我们没注意的时候。首先,我们需要证明禁用 GC 实际上是有帮助。答案再次落在 gcmodule.c 上。 作为 gc.disable 的替代,我们做了 gc.set_threshold(0),这一次,没有库能将其恢复了。

就这样,我们成功地将每个工作进程的共享内存从 140MB 提高到了 225MB,并且每台机器的主机上的总内存使用量减少了 8GB。 这为整个Django 机队节省了 25% 的 RAM。有了这么大头的空间,我们能够运行更多的进程或运行具有更高的 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-pro 在顶上显示。当这种情况发生时,该主机上的可用内存下降到接近零并跳回,强制清除所有的缓存内存。之后当所有的代码/数据需要从磁盘读取的时候(DSK 100%),一切都变得很缓慢。

这敲响了一个警钟,即 Python 在解释器关闭之前会做一个最后的 GC,这将导致在很短的时间内内存使用量的巨大跳跃。再次,我想先证明它,然后弄清楚如何正确处理它。所以,我注释掉了对 Py_Finalize 在 uWSGI 的 python 插件的调用,问题也随之消失了。

但显然我们不能只是禁用 Py_Finalize。我们有一系列重要的使用 atexit 钩子的清理工具依赖着它。最后我们做的是为 CPython 添加一个运行标志,这将完全禁用 GC。

最后,我们要把它推广到更大的规模。我们在这之后尝试在整个机队中使用它,但是连续部署再次终止了。然而,这次它只是在旧型号 CPU( Sandybridge )的机器上发生,甚至更难重现了。得到的教训:经常性地在旧的客户端/模型做测试,因为它们通常是最容易出问题的。

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

再一次地,通过 perf 分析,我们再次看到了 Py_Finalize。 在关机时,除了最终的 GC,Python 还做了一系列的清理操作,如破坏类型对象和卸载模块。这种行为再一次地,破坏了共享内存。

第4次尝试:关闭GC的最后一步的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)

这是基于这个事实,即 atexi t函数以注册表的相反顺序运行。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,这些 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 高速缓存线,指向相同的物理存储器地址,使得高速缓存命中率变得更高。

正如我们所看到的,并不是每个组件都按预期工作,有时,结果会非常令人惊讶。 所以保持挖掘和嗅探,你会惊讶于万物到底是如何运作的! Wu Chenyang 是一名软件工程师,而 Ni Min 则是 Instagram 的工程经理。

评论关闭