為什麼單一閒置串列在多執行緒下會死掉
本級到目前為止的一切,都悄悄假設只有一個執行緒。第 1 篇那個教科書配置器——一條閒置串列、在某個放置策略下走訪、附帶切割與合併——運作得很漂亮,直到第二個執行緒同時呼叫 malloc() 的那一刻。兩個執行緒同時編輯同一條鏈結串列會把它弄壞:一個正在解開某個區塊,另一個卻讀到它剛剛改掉的那個指標。於是顯而易見的修法,是用一把全域的鎖(pthread_mutex_lock())把每一次 malloc() 與 free() 包起來。正確,但對效能是致命的。
想像一台八核伺服器,每個執行緒都在它的熱迴圈裡配置。只有一把鎖時,這些核心一輩子都在那把互斥鎖後面排隊,而增加核心會讓事情變糟而非變好——這就是配置器的爭用(contention),它表現為:無論你丟多少硬體進去,程式都拒絕擴展。鎖甚至不是全部的代價。兩個執行緒在各自的私有快取之間彈來彈去地搶同一把鎖與同一個閒置串列頭,會把一條快取行拖著橫越晶片來回搬運;這種乒乓現象(稱為偽共享)可能比鎖本身還貴。正式環境配置器吸收到的教訓很直白:malloc() 的快路徑必須完全不碰任何共享狀態。
共用的藍圖:架在大小級距之上的執行緒快取
驚喜在這裡:jemalloc、tcmalloc 與 mimalloc 在上百個細節上各不相同,但它們的骨架幾乎一模一樣,而每一根骨頭你都已經認得。從第 4 篇的大小級距出發——不再用任意大小,每個請求都向上取整到固定菜單上的一個(8、16、32、48、64……位元組)。每個大小級距擁有自己一池相同的格位,正像一塊 slab。從一池固定大小格位的 slab 配置不需要搜尋、也不需要切割:你從該級距的串列上彈出一個閒置格位,釋放就把它推回去。光是這一點,就把 O(n) 的閒置串列走訪變成 O(1)。
現在用「給每個執行緒自己一份這些格位的私藏」來解決爭用:一個執行緒快取。malloc(n) 的快路徑變成——把 n 取整到某個大小級距,從這個執行緒針對該級距的快取彈出一個格位,完成。沒有鎖、沒有原子操作、不碰任何共享記憶體,只是讀取執行緒區域儲存的幾條指令。free(p) 是它的鏡像:找出 p 的大小級距,把格位推回這個執行緒的快取。所有配置裡有九成多從不會走得比這更遠。當一個執行緒的快取見底時,它在一把鎖之下從共享後備倉庫一次抓一批格位,把那把鎖均攤到數十次配置上——鎖的均攤成本因此趨近於零。
malloc(n): fast path slow path (cache empty)
---------------------------------------- ----------------------------
c = size_class(n) // round up lock(central[c])
slot = tcache[c].pop() // thread-local grab a BATCH into tcache[c]
if slot != NULL: unlock(central[c])
return slot // no lock! return tcache[c].pop()
central store, if empty, splits a fresh run/span/slab from a big arena
(mmap'd region), carved into fixed-size slots for class c.三者分道揚鑣之處
在那個共用骨架之上,有趣的工程在於各自為執行緒快取背後那個共享後備倉庫所做的選擇——那一層負責對抗碎裂化,並決定記憶體如何從核心而來。tcmalloc(Google,「thread-caching malloc」)是上述藍圖最純粹的體現:每個執行緒的快取擺在每個大小級距一條中央閒置串列前面,再由一個把作業系統分頁成段交出的分頁堆積支撐。它整個身分就是:為龐大的多執行緒服務做快速的小物件配置,執行緒快取居於正中央。
jemalloc(最初為 FreeBSD 而生,後來以 Facebook 採用而著名)倚重第 2 篇的競技場(arena)概念。它把堆積切成數個彼此獨立的競技場(arena),每一個都是自成一體、有自己的鎖與閒置結構的配置器,並把每個執行緒指派到一個競技場。位於不同競技場的兩個執行緒,即使在慢路徑上也從不爭用——這是靠分割堆積本身來降低爭用,而不只是在一個共享堆積前面做快取。jemalloc 也在刻意的碎裂化控制與可調性上投入甚多,附帶豐富的執行期統計;正是這種低碎裂化加上可預測性的組合,讓它贏得了馴服長期執行伺服器行程的名聲——這類行程的記憶體否則會連續好幾天緩緩往上爬。
mimalloc(Microsoft,2019)最年輕,按下的是另一根操縱桿:每個執行緒一個由「分頁」(每頁一個大小級距)構成的堆積,搭配一套巧妙的閒置串列分片(free list sharding)機制。它的深層想法是讓釋放保持在地。當執行緒 A 釋放一塊原本由執行緒 B 配置的區塊時,那塊區塊屬於 B 的分頁;mimalloc 為每個分頁用一條獨立的原子「thread-free」串列,於是跨執行緒的釋放只是一次免鎖的原子推入,而 B 之後一次成批回收。結果是一條微小而快速的快路徑,且即使在「一個執行緒配置、另一個執行緒釋放」的生產者—消費者模式下也表現良好——這正是讓天真的執行緒快取吃苦頭的情況。
它們全都得面對的難題
執行緒快取並非免費的午餐;它製造出簡單配置器從未有過的問題,而各自如何解決這些問題,就是剩下工程的大半。第一個是快取膨脹(cache blowup):每個執行緒都握著自己每一個大小級距的私藏,可能釘住一大堆沒人在用的記憶體。一個配置了價值一 MiB 格位、然後永遠沉睡的執行緒,會把它們扣為人質。所以快取有大小上限,並週期性地把閒置格位沖回共享倉庫——這是用一點點額外記憶體直接換取大量速度,也是真實部署會去調的一個旋鈕。
第二個是生產者—消費者的釋放,也就是 mimalloc 正面瞄準的情況:執行緒 A 配置、把物件交給執行緒 B、B 把它釋放。那塊區塊屬於 A 的快取或競技場,所以 B 不能就這樣把它丟進自己的執行緒區域串列,否則會在 A 的帳目上漏掉它。每個正式環境配置器都需要一條給這些遠端釋放用的安全路徑——一條由擁有者稍後回收、每執行緒或每分頁的串列——而要把它做到既正確又免鎖,是真正微妙的,得倚靠同步那一級的原子操作與記憶體排序觀念。這正是「就用個執行緒快取吧」不再輕鬆的地方之一。
第三個是硬體區域性。在多插槽機器上,從插槽 1 去存取物理上接在插槽 0 的記憶體比較慢;好的配置器會實行NUMA 感知配置,把一個執行緒的記憶體導向它自己的插槽,這樣它就不必在每次存取時付跨插槽的代價。與之密切相關的是對齊:大型配置會被對齊,好讓它們能乾淨地映射到大分頁上;而格位大小的選擇,會讓物件分散到不同的快取行上,而不是把兩個熱物件塞進同一行、害兩個核心為它互鬥。配置器悄悄地是一件效能工程的工具,它形塑了你的資料如何與記憶體階層相遇。
實務上用一個——以及它修不了什麼
可愛之處在於試用一個的代價何其低。因為這三者都遵守和系統配置器相同的 malloc()/free() 介面,你不必動一行自己的程式碼就能換上它們——這就是配置器插入(interposition)。你把那個函式庫連結進來,甚至只在執行期預先載入,你行程裡的每一次 malloc() 就被悄悄重新導向。以下是 Linux 上免重新編譯的途徑:
- 照常建置,對著一般的標頭檔:gcc -O2 -Wall main.c -o app——你在原始碼裡什麼都不改。
- 在啟動時預先載入該配置器,讓它的 malloc() 勝過 libc 的:$ LD_PRELOAD=/usr/lib/libjemalloc.so ./app(或 libtcmalloc.so,或 libmimalloc.so)。
- 去量測,不要用猜的。在每一個之下跑你真實的工作負載,盯著常駐記憶體尖峰與尾端延遲——對的配置器取決於工作負載,唯一誠實的答案來自你自己的數字。
不過要看清它的限制,因為更快的配置器並不是有臭蟲程式的解藥。換到 jemalloc 不會治好記憶體漏失——若你的程式碼從不呼叫 free(),沒有任何配置器能回收那塊記憶體;它只是漏得更快、或統計數字更漂亮而已。它救不了你的釋放後使用或重複釋放;事實上換一個配置器會讓這類臭蟲以不同方式浮現,所以一支在 glibc 上「能跑」的程式,在 jemalloc 下崩潰,恰恰正因為它一直在仰賴未定義行為。而且沒有任何配置器能完全逃離碎裂化——這些設計把它管理得出色,但並未把它廢除。
退一步,把整個這一級納入同一個畫面。你從配置器的工作與一條單一鏈結串列起步;你加上了競技場與遞增指標、slab 與夥伴系統、大小級距與對齊。那其中的每一樣,都是這些正式環境配置器拴在一起的一塊零件:大小級距給出 O(1) 的查找、slab 給出免搜尋的固定大小池、競技場分割爭用、執行緒快取讓常見情況免鎖,而邊界標記與合併仍潛伏在慢路徑裡對抗碎裂化。jemalloc、tcmalloc 與 mimalloc 並不奇異——它們就是你第 1 篇那個配置器,為許多核心而強化、並對著機器真實的成本模型量測過。