JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

競技場、推進與物件池配置器

第 1 篇那條通用閒置串列,為它的彈性付出了代價:每次呼叫都要搜尋、切割與合併。本篇要展示相反的交易——如果你放棄一些「何時釋放、釋放什麼」的自由,配置就能塌縮成單一次指標推進,或從串列上彈出一小塊,而 free() 幾乎可以變成免費。

為什麼專用配置器能勝出

在第 1 篇你建了一條通用閒置串列:它能以任何順序,回應任何 n 的 malloc(n),而且 free() 可以在任何時刻發生。正是這份通用性使它慢——每次呼叫都得搜尋串列、也許切割一塊區塊,free() 時也許還要合併鄰居。但大多數程式其實並不需要這麼多自由。一個網頁請求配置上百個小結構,然後一次全部丟棄。一個遊戲迴圈每一幀都堆起一疊暫時物件,每一幀又重置。一個剖析器配置數千個節點,它們全共享同一段生命週期。當使用樣式這麼規律時,通用配置器就是浪費掉的通用性——而你可以打造一個小巧的客製配置器,快上數量級。

這裡三種配置器的共通手法都一樣:拿一些自由去換速度。推進配置器(bump allocator)放棄了「逐塊釋放」的能力——你只能一次重置整個東西。物件池配置器(pool allocator)放棄了「配置任意大小」的能力——每一塊都是同一個固定大小。作為交換,配置不再是一場搜尋,而變成寥寥幾次指標運算。關鍵是,這些都不取代 malloc();每一種都坐落你從通用配置器(或直接用 mmap() 向核心要來)拿到的一大塊之上,再切片發出去。它們是手術刀,不是新的心臟。

推進配置器:把配置變成一次加法

從現存最簡單的配置器開始。拿一塊連續的記憶體區域,保留單一個指標,叫它 next,坐在第一個閒置位元組上。要配置 n 位元組,你只做一件事:讀 next、為對齊把它向上取整、把那個位址記成結果、把 next 往前推進 n、再回傳記下來的位址。這就是整個演算法。沒有串列、沒有標頭、沒有搜尋——配置就是一次加法加一次邊界檢查。這就是推進(bump)(又稱線性)配置器,得名於每個請求都只是把指標往前推。

  start                      next (free pointer)            end
    |                           |                             |
    v                           v                             v
    [ obj A | obj B | obj C | . . . . . . . . . . . . . . . . . ]
     <----- already handed out ----->  <------ still free ----->

  allocate(n):
     p     = align_up(next, alignment)
     if p + n > end:  return NULL        // region is full
     next  = p + n                       // the whole "bump"
     return p

  reset():  next = start                 // frees EVERYTHING at once
推進配置器就是一個從 start 向 end 行進的指標。allocate() 推進它;沒有逐物件釋放,只有一次性的 reset()。

看看缺了什麼,因為那才是重點所在。沒有逐塊的 free():你無法在保留 A 與 C 的同時交還物件 B,因為唯一的狀態就是一個指標,而它完全不知道 B 從哪裡開始、或那個空隙能不能再利用。要回收記憶體唯一的辦法是 reset():把 next 設回 start,整塊區域就在一個指令內再次閒置。所以推進配置器在「許多物件共享一段生命週期」時完美無缺——整個請求期間隨意配置,最後 reset() 一次。當物件在各自獨立、無法預測的時刻死亡時,它就毫無用處。

競技場:一筆勾銷的整塊區域

競技場(arena)(又稱區域(region)配置器)是推進配置器長大成熟後的實用工具。核心仍是一個推進指標,但多了兩樣東西。第一,當目前的區域填滿時,競技場不回傳 NULL,而是再向 malloc() 或 mmap() 抓一大塊,串接到一條區塊串列上,於是它能無上限地成長。第二,釋放被重新定義為釋放整個競技場:走過底層各區塊的串列,把每一塊都歸還。你從不釋放單一物件——你摧毀整座競技場,裡頭的一切一同死去。

這對應到驚人數量的真實程式。想想處理一個 HTTP 請求:你建立一座請求競技場,在請求期間從中配置每一個標頭、剖析出的欄位、暫存緩衝區,然後在回應送出時用單一次呼叫釋放整座競技場。你不去——也絕不該去——追蹤個別物件。這就是為什麼這個觀念有時被稱作記憶體池或競技場:生命週期是按工作階段分組,而不是按個別物件。報酬極為可觀:那些物件中任何一個都零洩漏風險(它們全一起死),而整批的 free() 是走一遍短短的區塊串列,而不是上千次各自要碰標頭與閒置串列的獨立 free() 呼叫。

不過要把代價說精確,因為競技場的長處同時是它的陷阱。競技場內的記憶體在整座競技場死亡之前永遠不會被回收。如果一個長壽物件藏在一座你一直留著的競技場裡,它就釘住整座競技場——曾被推進過的每個位元組都繼續駐留。所以競技場以一種特別的方式洩漏:不是因為弄丟指標,而是因為把不該歸在一起的東西歸在一起。經驗法則是把分組的大小裁切到工作本身:每個請求一座、每幀一座、每次剖析一座——某個有著乾淨、短暫、定義良好的結束點的東西。把一段長生命週期混進一座短命競技場,你就造出了一個緩慢而無聲的記憶體大胃王。

物件池:一條由相同區塊構成的閒置串列

推進與競技場配置器是靠放棄逐塊 free() 來換速度。物件池配置器(又稱物件配置器)保留了逐塊 free(),但以不同的方式付費:物件池裡每一塊都剛好是同一個固定大小。假設你的程式不停地配置與釋放某一種型別的物件——譬如鏈結串列中一個 48 位元組的節點,做上數百萬次。物件池把一大塊區域切成一格格相等的 48 位元組槽位,並把所有閒置槽位串到一條閒置串列上,就和第 1 篇一樣——但因為每一格都相同,這條閒置串列不需要大小欄位、不需要搜尋、也不需要切割。

現在配置與釋放都是 O(1),完全沒有搜尋。配置時:從閒置串列彈出第一格(讀它內嵌的「下一個」指標,那成為新的串列頭)並回傳。釋放時:把該格推回閒置串列前端(把舊的串列頭寫進該格,再把串列頭指向該格)。每個方向各兩次指標寫入。和第 1 篇的手法一樣,「下一個」指標就住在每個閒置槽位裡面,所以閒置串列不花額外記憶體。而且因為只有一個區塊大小,根本不存在外部碎裂化——每個被釋放的槽位都能和任何請求互換,所以孔洞永遠能再利用。

  region carved into fixed 48-byte slots (S0..S5):
    [ S0 ][ S1 ][ S2 ][ S3 ][ S4 ][ S5 ]

  free list links the FREE slots through their own bodies:
    head -> S1 -> S4 -> S5 -> NULL        (S0,S2,S3 are in use)

  allocate():  p = head;  head = *(void**)head;  return p;   // pop
  free(p):     *(void**)p = head;  head = p;                  // push

  no size field, no search, no splitting, no coalescing.
物件池是一條由相同槽位構成的閒置串列;配置是一次 pop,釋放是一次 push,各只需兩次指標運算。

誠實的代價是勝利的另一面:一座物件池只裝一種大小,所以你通常會跑許多座物件池,每個常見物件大小各一座。把一個 24 位元組的物件存進 48 位元組槽位的物件池,你就浪費 24 位元組於內部碎裂化;存一個 60 位元組的物件則根本塞不下。而一旦兩個執行緒共用一座物件池,那個閒置串列頭就成了一個被爭用的指標,需要一把鎖或一次原子的比較並交換。這些正是後續指南要解決的問題:第 3 篇的物件平板配置器(slab allocator)把物件池一般化,去管理多個物件快取並回收已初始化的物件,而第 4 篇的大小級距則是有原則地決定「你保留幾座物件池、每座的區塊多大」的方法。

在它們之間做選擇

這三者與其說是競爭對手,不如說是裁切成不同形狀、以貼合不同生命週期樣式的工具。挑選其一的單一問題是:我的物件在何時、以何種方式死亡?把配置器對應到答案,你就能用極少的程式碼,得到一個手工系統的大部分速度。

  1. 是否有許多物件共享一段生命週期,並在一個乾淨的階段邊界(每請求、每幀、每次剖析)一同死亡?用競技場:推進來配置、一筆勾銷地釋放整塊區域、絕不追蹤個別物件。
  2. 你是否配置暫存空間、用它、然後想以嚴格相反的順序讓它消失?用堆疊配置器:在工作前存一個標記、工作後還原標記,整批一次彈出。
  3. 你是否反覆攪動數不清的、單一固定大小、在各自獨立時刻死亡的物件?用物件池:O(1) 的 pop 配置、O(1) 的 push 釋放,對該大小零外部碎裂化。
  4. 大小是否真正任意、生命週期是否真正無法預測,沒有任何樣式可資利用?那就留在第 1 篇的通用閒置串列——那正是它被打造來應付的情況,而專用配置器只會礙事。

留意那條把本篇連回第 1 篇、又連向本級其餘部分的主線。第 1 篇的通用配置器之所以慢,是因為它不做任何假設;這裡每一個配置器之所以快,是因為它做了一個強假設並把它強制執行。推進配置器假設共享生命週期;物件池假設共享大小。像 jemalloc 與 tcmalloc 這樣的正式環境配置器並不挑其一——它們把它們疊起來,在一個通用後備之下跑著「每大小一座物件池、每執行緒一座競技場」,讓常見情況命中快路徑,只有罕見情況才為完整通用性付費。你剛剛認識了它們賴以構成的兩塊積木;第 3 到 5 篇將展示高手們如何把它們組裝起來。