通過關閉 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)!
1 2 3 4 5 6 7 8 |
#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,然後執行如下命令。
1 |
perf record -e page-faults -g -p <PID> |
然後,當在堆疊跟蹤的過程中發生頁面錯誤時,我們有了一個主意。
結果與我們的預期不同。首要嫌疑人是 collect 而非是複製程式碼物件,它屬於 gcmodule.c,並在觸發垃圾回收時被呼叫。在理解了 GC 在 CPython 中的工作原理後,我們有了以下理論:
CPython的 GC 完全是基於閾值而觸發的。這個預設閾值非常低,因此它在很早的階段就開始了。 它維護著許多代的物件連結串列,並且在進行 GC 時,連結串列會被重新洗牌。因為連結串列結構與物件本身一樣是存在的(就像 ob_refcount),在連結串列中改寫這些物件會導致頁面在寫入時被複制,這是一個不幸的副作用。
1 2 3 4 5 6 7 8 9 |
/* 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 秒以上。
1 |
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 指令碼中以這樣的方式結束:
1 2 3 4 5 6 7 |
# 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 執行時總是檢查它的引用計數是否降到零。在這種情況下,將呼叫物件的釋放器。垃圾回收的主要目的是終止引用計數不起作用的那些引用週期。
1 2 3 4 5 6 7 8 |
#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吞吐量也得到改善。
1 2 3 4 5 |
# 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 的工程經理。