原文地址:https://techtalk.intersec.com/2013/10/memory-part-4-intersecs-custom-allocators/ 算法
malloc()因爲其通用性而很是易於使用。它沒有對分配和釋放的上下文作任何的假設。 這樣的分配器能夠連續使用,也能夠在一整個執行任務先後分開使用。它們能夠用在同一個線程,也能夠在多個線程。由於它很通用,每一次分配都是不一樣的,這意味着生存週期長的分配內存和生存週期短的分配內存都在一個內存池裏。 數組
這樣致使malloc()的實現比較複雜。由於內存能夠在多個線程之間共享,內存池也必須是共享的,而且必需要上鎖。因爲現代硬件有愈來愈多的物理線程(注,應該是指CPU的多核),每次分配都對內存池上鎖會對性能有災難性的影響。所以,現代的malloc()實現採用線程本地緩存,只有當本地緩存太大或過小的時候纔會鎖主內存池。這也致使一些內存駐留在線程本地緩存中,不容易被其餘線程使用。 緩存
既然內存塊駐留在不一樣的位置(線程本地緩存,全局內存池,或進程簡單分配),堆會變得碎片化。未使用的內存則很難被釋放回內核。而且有很大的可能,兩次連續的分配,獲得的內存離得很遠。致使在堆上作隨機訪問。正如咱們在前面的文章看到的,對內存隨機訪問的方式離最優方案還差的很遠。 安全
所以,有時候,行爲可預測的特殊分配器是頗有必要的。在Intersec,咱們有好幾個這樣的分配器,分別用在不一樣場景下。在一些特定的用例中,咱們能夠提高几個數量級的性能。 網絡
爲了提供一些可對比的點,咱們跑了一些本身寫的性能測試。咱們測試了malloc()和free()在兩個場景的性能。第一個是簡單場景:咱們分配100萬個指針,而後釋放它們。咱們使用單線程環境和小塊的分配來測試原始的分配器。 多線程
#include <stdlib.h> #include <stdio.h> #include <stdint.h> #include <sys/time.h> struct list { struct list *next; }; static int64_t timeval_diffmsec(const struct timeval *tv2, const struct timeval *tv1) { int64_t delta = tv2->tv_sec - tv1->tv_sec; return delta * 1000 + (tv2->tv_usec - tv1->tv_usec) / 1000; } int main(int argc, char *argv[]) { for (int k = 0; k < 3; k++) { struct timeval start; struct timeval end; struct list *head = NULL; struct list *tail = NULL; /* Allocation */ gettimeofday(&start, NULL); head = tail = malloc(sizeof(struct list)); for (int i = 0; i < 100000000; i++) { tail->next = malloc(sizeof(struct list)); tail = tail->next; } tail->next = NULL; gettimeofday(&end, NULL); printf("100,000,000 allocations in %ldms (%ld/s)\n", timeval_diffmsec(&end, &start), 100000000UL * 1000 / timeval_diffmsec(&end, &start)); /* Deallocation */ gettimeofday(&start, NULL); while (head) { struct list *cur = head; head = head->next; free(cur); } gettimeofday(&end, NULL); printf("100,000,000 deallocations in %ldms (%ld/s)\n", timeval_diffmsec(&end, &start), 100000000UL * 1000 / timeval_diffmsec(&end, &start)); } return 0; }
第二個場景增長了多線程:給咱們的指針都分配完內存之後,咱們開始在另外一個線程釋放它們,同時在主線程分配另一批指針。這樣,分配和釋放分別在兩個線程同時進行,分配池產生了競爭。 函數
性能測試執行了三次:一次用ptmmalloc()(glibc的實現),另外一次是用tcmalloc()(Google的實現),最後是用jemalloc()(FreeBSD實如今Linux上的移植版)。 性能
結果的確依賴於malloc()的不一樣實現。沒有競爭的狀況下,ptmalloc()比tcmalloc()性能稍微好一點(可是花費了大得多的內存空間)。tcmalloc()在多線程環境表現好得多。 測試
一批8字節指針包含100M個時,意味着要分配800MB(762MiB。注,原文這裏用詞比較注意,MB應該指以1000爲計算單位,MiB則是以1024爲計算單位)。所以在單線程的用例裏,實際數據就是762MiB大小。咱們能夠看到tcmalloc在內存佔用上優化的更好。不過奇怪的是,tcmalloc釋放被分配更慢:釋放速度沒法和分配速度匹配,致使咱們在多線程測試中增長線程數的時候,內存佔用一直在增長。 優化
性能測試用例是人爲造的,只是用極其特別的用例對小塊內存的分配進行壓力測試。所以這不能做爲一個絕對的證據證實多線程環境tcmalloc更快,而單線程環境ptmalloc更快。可是,測試說明了沒有完美的malloc()實現,爲你的用例選擇正確的實現可能對整體性能有巨大的影響。
最後,但不是最不重要,測試告訴你,每秒只能作一兩百萬的內存分配/釋放。這個數字看起來很大,可是若是你每秒要處理幾十萬的事件,每一個事件要觸發1個或多個分配,malloc()將會成爲瓶頸。
在Intersec,第一個(固然也是用得最多的)自定義分配器是棧分配器。這是一個後進先出(LIFO)分配器,意思是分配和釋放的順序相反。它模仿了程序棧的行爲,由於分配老是以幀爲一組,釋放也都是一次一幀。
棧分配器是大舞臺式的分配器。它分配很是巨大的塊(block),而後分割爲小的塊(chunk)。
對於每一塊(block),它追蹤兩個關鍵信息:
當一個分配被執行時,棧底增長請求的大小(加上對齊和溢出校驗值canaries)。若是當前block不能分配請求的大小,那麼就會分配另一個block:分配器不會嘗試去填充前一個block的空隙。
當一個幀被建立,前一個幀的開始位置被壓入棧底。分配器老是知道當前幀的開始位置。這樣,幀的移除很是快:分配器設置棧底爲當前幀的開始位置,而後從新加載前一個幀的位置,並置爲當前幀。另外,分配器會列出全部徹底空閒的block,並釋放它們。
釋放一個幀是分期返還的常量時間,它不依賴於幀上分配的chunk數量,可是依賴於幀包含的block數量。通常block的大小能夠放下好幾個典型的幀,這樣在大部分狀況下,釋放一個幀不須要釋聽任何block。
一般分配和釋放是一個嚴格的順序,兩次連續的分配會返回連續的內存chunk(除非新的分配請求須要一個新的區塊)。這會提高程序內存的局部性訪問。更進一步,多虧了順序分配,棧分配器裏幾乎沒有碎片。所以,當實際分配的內存都這樣作時,棧分配器的壓力會降低。
咱們的確有一個特殊的棧分配器:t_stack。這是一個線程本地單實例的棧分配器。它被用做普通程序棧的補充。t_stack的主要優點是能夠動態高效的分配臨時內存。物理何時,咱們想在函數內分配內存,在函數結束時釋放它們,咱們都使用基於t_stack的分配。
t_stack上幀的建立和釋放是綁定在函數範圍的。一個特別的宏定義t_scope用在詞法範圍的開頭。這個宏利用GNU的cleanup屬性在C中模擬C++的RAII行爲:它建立了某個幀,而且而且增長一個清除句柄,這樣不管何時退出其定義所在的詞法範圍,該幀都護被銷燬。
static inline void t_scope_cleanup(const void **frame_ptr) { if (unlikely(*unused != mem_stack_pop(&t_pool_g))) { e_panic("unbalanced t_stack"); } } #define t_scope__(n) \ const void *t_scope_##n __attribute__((unused,cleanup(t_scope_cleanup))) \ = mem_stack_push(&t_pool_g) #define t_scope_(n) t_scope__(n) #define t_scope t_scope_(__LINE__)
用t_stack分配內存而不帶t_scope聲明很明顯是跟普通程序棧的行爲相悖的。對普通程序棧來講,函數不能給棧帶來反作用:當函數退出時,它要把棧恢復到函數開始調用的時候。爲了減少混亂,咱們使用一個編碼約定:當一個函數會對t_stack有反作用時(也就是它能夠在它的調用者建立的幀上進行分配),它的名稱必須帶一個「t_」前綴。這樣就比較容易的檢測到缺失的t_scope:若是一個函數使用了t_stack提供的函數,可是沒有包含t_scope,那麼要麼它的名稱有「t_」前綴,要麼就是疏忽了聲明t_scope。
t_stack另一個優勢是,跟堆分配器相比,它常常(但不老是)讓錯誤管理更簡單。因爲釋放是在t_scope的結尾處自動進行的,在出錯的時候也不須要加額外的處理。
/* Error handling with heap-allocated memory. */ int process_data_heap(int len) { /* We need a variable to remember the result. */ int ret; /* We will need to deallocate memory, so we have to keep the * pointer in a variable. */ byte *data = (byte *)malloc(len * sizeof(byte)); ret = do_something(data, len); free(data); return ret; } /* Error handling with t_stack-allocated memory. */ int process_data_t_stack(int len) { /* Associate the function scope to a t_stack frame. * That way all `t_stack`-allocated memory within the * function will be released at exit */ t_scope; return do_something(t_new(byte, len), len); }
t_stack依賴於一些非標準的C擴展,對於Intersec的一些新人來講有些難以想象。可是在Intersec它的確是對語言提供的標準庫以外一個很好的補充。
咱們對棧分配器作了性能測試:
正如你看到的,這個分配器很快:它比ptmalloc和tcmalloc的最優狀況更好。多虧幀機制,釋放徹底不依賴於分配(測試代碼能夠改善一下,測算幀的建立和銷燬性能)。
棧分配器的當前實現用__BIGGEST_ALIGNMENT__來作最小分配對齊。這是一個平臺相關的常量,表示CPU的最大對齊要求。在x86_64上,這個常量是16字節,由於一些操做16字節數組的指令(例如SSE指令)要求按16字節對齊。這解釋了爲何內存佔用是最優狀況的兩倍。
# 先進先出分配器(FIFO)
## 先進先出問題
另外一個常常用到的內存用法是先進先出(FIFO)管道:內存的釋放順序跟分配順序(近似的)一致。一個典型的用例是在網絡協議的實現中緩衝請求的上下文:當一個請求被髮出並釋放,以及收到響應時,每個請求都要關聯到一個已分配的上下文。大部分狀況下,請求被以發出的順序處理(這不老是正確的,可是即便有個別處理時間很長的請求,對於這類處理來講,也只是很短的時間)。
當先進先出數據在堆上直接分配時,它會放大碎片問題,由於下一個釋放的chunk極可能不在堆的末端,這樣就會產生一個空洞(而且因爲堆上不只有先進先出數據,還有其餘分配可能夾雜在兩個先進先出的分配之間,狀況會變得更糟)。
由於這些緣由,咱們以爲把這種使用模式跟其餘的分配方式獨立開來。咱們使用自定義的分配器來取代堆分配器。
這個分配器基本上跟棧分配器工做方式相同:它內部存放被線性使用的巨大的block(新的block只有在當前block沒法知足下一次分配時纔會被建立)。沒有使用幀的模型,先進先出分配器使用計算每一個block大小的機制。每個block維護它內部分配的內存。當block內全部的數據都被釋放時,它本身也被釋放。block使用mmap來分配,以避免干擾到堆(所以也不會產生碎片)。
由於先進先出分配器使用跟棧分配器同樣的分配模式(但不是同樣的釋放模式),它也有一些同樣的屬性。其中之一是,連續的分配獲得的地址也是連續的。可是因爲先進先出分配器的使用模式,這裏局部性帶來的好處並不重要:大部分時間,它被用來分配獨立的元素,這些元素幾乎不會一塊兒使用。
先進先出分配器被設計爲在單線程環境中使用,所以不須要處理競爭的問題。
咱們使用先進先出分配器來運行咱們的無競爭版性能測試,這樣能夠和malloc來比較性能:
跟棧分配器同樣,先進先出分配器的性能比malloc實現更好。可是比棧分配器慢一點點,這是由於它要單獨追蹤每一個分配的信息,所以沒有想棧分配器同樣優化。
## 循環分配器
循環分配器是棧分配器和先進先出分配器的混合。它使用幀來給內存分配分組,能夠在常量時間釋放一大批的分配,儘管它大部分是先進先出的使用模式。循環分配器中的幀不是堆疊的,每一個堆是獨立且自包含的。
爲了在循環分配器上分配內存,首先要建立一個新的幀。這要求以前的幀已經封閉。當一個幀在分配器中被打開,就能夠進行分配,而且自動成爲活動幀的一部分。當全部的分配都完成,幀必須被封起來。一個封閉的幀仍然是活躍的,這意味着它包含的分配仍然是能夠訪問的,只是幀不能再進行新的分配。當幀再也不被須要時,必須被釋放。
在幀分配器中釋放內存是線程安全的。這使得這個分配器在工做線程中構造用於傳輸的消息很是有用。在那個上下文中,它幾乎能夠在那些須要處理多線程的代碼中直接替換t_stack來工做。
/* Single threaded version, use t_stack. */ void do_job(void) { t_scope; job_ctx_t *ctx = t_new(job_ctx_t, 1); run_job(ctx); } /* Multi-threaded version, using ring allocation. * Note that it uses Apple's block extension. */ void do_job(void) { const void *frame = r_newframe(); job_ctx_t *ctx = r_new(job_ctx_t, 1); r_seal(); thr_schedule(^{ run_job(ctx); r_release(frame); }); }
咱們使用這個分配器再次運行基準測試。因爲環形分配器是線程安全的,基準測試覆蓋了有競爭和無競爭的用例:
## 其餘自定義分配器
本文介紹了Intersec使用的三種自定義分配器。這三個分配器不適用於通常的用途:它們是爲了特別的使用模式而優化的。幸運的是,在大部分狀況下,這三個分配器的組合使用足夠讓咱們避開咱們碰到的跟malloc有關的問題,好比缺少局部性,分配時的鎖競爭和堆碎片。
然而,有些時候沒有辦法,咱們沒有其餘選擇,必須實現一個自定義通用內存分配器來知足咱們的性能要求。所以,咱們也有一個基於TLSF (Two Level Segregate Fit)的分配器。TLSF是被設計來作實時處理的分配算法,它保證操做是常數時間(分配和釋放都是)。
還有更多有趣的,咱們也有頁分配器和持久化分配器。最後一個咱們可能會在之後的文章講到。