Glibc-Malloc 和 TCMalloc 基本設計原理 及 Glibc-malloc-Jemalloc-TCMalloc 性能差異

當前業界的主流分配器 jemalloc 以及 tcmalloc 因其性能優勢被廣泛應用,很好奇底層的實現細節,因爲從宏觀上來看內存分配器的作用無非就是分配內存而已,很簡單,用戶需要多少內存,拿多少內存不就行了嗎?google 和 facebook 何必花費大功夫分別搞了一個 tcmalloc 和 jemalloc 呢?越簡單的東西向底層追溯越複雜。。。做基礎軟件的同學應該是深有體會,一個普普通通,平凡得再不能平凡得 write/read 系統調用,其在 OS 的實現鏈路研究過的同學肯定體會過其複雜程度。

所以想要花一些時間仔細研究一下內存分配器的實現原理,作爲一個最爲基礎的底層軟件,是如何在內存分配上體現其性能呢?

介紹之前我們先看一下性能,有了性能我們才能對這幾個分配器提起興趣,纔想知道一個普通的內存分配釋放到底是需要多精細的設計?

性能測試


測試代碼如下:

#include <assert.h>
#include <inttypes.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <unistd.h>

#define VMRSS_LINE 21 // The physical memory statistics line in /proc/pid/status
#define pid_t int
int NowMicros() {
  struct timeval tv;
  gettimeofday(&tv, NULL);
  return (int)(tv.tv_sec * 1000000 + tv.tv_usec);
}

struct Args {
  int t_id;
  size_t range;
  size_t end_range;
  int num;
};

// Malloc execution
void* do_something(void* args) {
  int local_range = ((struct Args*)args)->range;
  int local_end_range = ((struct Args*)args)->end_range;
  int local_num = ((struct Args*)args)->num;
  for (size_t i = 0; i < local_num; i++) {
    void* ptr =
        malloc(rand() % (local_end_range - local_range + 1) + local_range);
    if (ptr == NULL) fprintf(stdout, "malloc error \n");
  }
  pthread_exit(NULL);
}

// Get the physical memory in /proc/%d/status
int get_phy_mem(const pid_t p) {
  char file[64] = {0};       
  FILE* fd;                
  char line_buff[256] = {0}; 
  sprintf(file, "/proc/%d/status", p);
  fd = fopen(file, "r");
  assert(fd >= 0);
  
  int i;
  char name[32];
  int vmrss;
 
  for (i = 0; i < VMRSS_LINE - 1; i++) {
    char* ret = fgets(line_buff, sizeof(line_buff), fd);
  }
  char* ret1 = fgets(line_buff, sizeof(line_buff), fd);
  sscanf(line_buff, "%s %d", name, &vmrss);
  fclose(fd); 
  return vmrss;
}

int main(int argc, char** argv) {
  assert(argc == 5);
  int start = NowMicros(); // used for timeval
  int num = atoi(argv[1]); // request num for per thread
  const int threads = atoi(argv[2]); // threads num
  
  // random malloc between [range, end]
  int range = atoi(argv[3]); 
  int end = atoi(argv[4]);
  fprintf(stdout, "num : %d \n", num);

  pthread_t tid_arr[threads];
  struct Args args[threads];
  for (size_t i = 0; i < threads; i++) {
    args[i].t_id = i;
    args[i].range = range;
    args[i].end_range = end;
    args[i].num = num;
    pthread_create(&tid_arr[i], NULL, do_something, &args[i]);
  }

  for (size_t i = 0; i < threads; i++) {
    pthread_join(tid_arr[i], NULL);
  }

  fprintf(stdout, "time : %dus \n", NowMicros() - start);
  fprintf(stdout, "rss mem: %dKB\n", get_phy_mem(getpid()));
  return 0;
}

使用不同的 malloc 依賴編譯以上測試文件,測試的話可以直接跑如下測試腳本,會編譯測試一起進行:

如果想要開啓 jemalloc prof 能力,編譯的時候 需要開啓 --enable-prof,./autogen.sh --enable-prof,否則後續調試 jemalloc 或者 想要 jemalloc 生成 profiling 文件時無法生效。

#!/bin/bash

function run_smallmem_malloc() {
  echo "----- allocator : $1 -----"
  echo "small_malloc 1 thread"
  ./ex_prints 1000000 1 1 256
  echo "small_malloc 8 threads"
  ./ex_prints 125000 8 1 256
  echo "small_malloc 32 threads"
  ./ex_prints 31250 32 1 256
}

function run_largemem_malloc() {
  echo "----- allocator : $1 -----"
  echo "large_malloc 1 thread"
  ./ex_prints 1000000 1 65536 2097152
  echo "large_malloc 8 threads"
  ./ex_prints 125000 8 65536 2097152
  echo "large_malloc 32 threads"
  ./ex_prints 31250 32 65536 2097152
}

function run_mixmem_malloc() {
  echo "----- allocator : $1 -----"
  echo "mix_malloc 1 thread"
  ./ex_prints 1000000 1 1 2097152
  echo "mix_malloc 8 threads"
  ./ex_prints 125000 8 1 2097152
  echo "mix_malloc 32 threads"
  ./ex_prints 31250 32 1 2097152
}

function run() {
  run_smallmem_malloc $1
  run_largemem_malloc $1
  run_mixmem_malloc $1
}

# tcmalloc
g++ -std=c++11 ex_prints.c -o ex_prints -lpthread -ltcmalloc
run "tcmalloc"

# glibc-malloc
g++ -std=c++11 ex_prints.c -o ex_prints -lpthread
run "glibc-malloc"

# jemalloc
g++ -std=c++11 ex_prints.c -o ex_prints -lpthread -ljemalloc
run "jemalloc"

這個性能測試較爲簡陋, 主要分三部分:小內存分配效率 (小於 256B),大內存分配效率 (256K-2M 之間),混合大小內存分配的效率 (64B-2M)。

測試的分配器包括:jemalloc, tcmalloc, glibc-malloc。

  1. 小內存分配性能

表格內容是 隨機分配 1B-256B 之間的內存,分配 1,000,000 次的耗時,單位是 us:

  1. 大內存分配性能

表格內容是 隨機分配 64K-2M 之間的內存,分配 1,000,000 次的耗時,單位是 us:

  1. 混合內存分配性能

表格內容是 隨機分配 1B-2M 之間的內存,分配 1,000,000 次的耗時,單位是 us:

測試結論

  1. 多線程因素:每一種分配器的多線程分配性能相比於單線程來說都會有所損耗,因爲 從 arena 競爭分配內存需要加鎖

  2. Jemalloc 小內存分配的性能比其他兩個分配器好 50%-80% 且 小內存分配場景的內存消耗比其他兩個分配器少一倍(沒有任何參數優化的情況下),tcmalloc 和 glibc-malloc 在這個場景下的內存消耗差不多。

  3. Jemalloc 大內存以及混合內存(包含大內存和小內存)的多線程分配場景下仍然是性能最好的,比其他兩個分配器性能好 30%-50%,但是 tcmalloc 則是這個場景下內存消耗最少的,性能和 glibc-malloc 接近。

我們知道了內存分配之間確實有性能差異,那下文將以一位大佬的實踐描述展開,先從基礎的設計來概覽 glibc 的 malloc 和 tcmalloc 之間的差異,並不會涉及底層的代碼細節,這一部分還是需要逐層遞進,一點一點啃。

背景


在使用 Rocksdb 並鏈接 glibc 作爲 Allocator 之後,長時間寫入,發現物理內存上漲了非常多,當然 Rocksdb 對內存的需求還是比較大,比如 memtable 組件,block-cache 組件等都是關鍵路徑上的內存組件,這一些組件的工作效率對整個引擎的性能至關重要。

以爲是發生了內存泄漏,再用 tcmalloc 的 heap profiling 追蹤了一下發現並沒有,堆內存佔用的很少。這裏用了 glibc 之後進程的內存佔用介於系統(並沒有還給操作系統)和應用(非堆消耗)之間,這一些內存被消耗在哪裏了呢?

Glibc Malloc 設計

malloc 使用的是一種叫做 Arena 的結構進行內存分配的,每一個用戶線程會被綁定到一個獨立的 arena 之上,默認每個邏輯 CPU 會有四個 arena 來減少內存分配鎖之間的競爭,每個 arena 管理的內存是相互獨立的。

這個結構在 malloc 調用者需要內存的時候負責從系統中獲取內存返回給用戶。使用 glibc 的 malloc 對用戶提出了較爲苛刻的需求,系統用戶在分配了內存之後按照相反的順序手動釋放之前分配的內存,如果沒有手動釋放,那這一些內存就會被 “鎖住”,後續的用戶進程無法使用(內存泄漏)。

接下來詳細看一下這個 “鎖住” 在 glibc 的 malloc 中是如何發生的?

基礎內存分配

如下圖,代表一個 Arena 分配的內存情況:

有三個內存區域通過 malloc 被分配,100KB,40KB,1KB。

接下來用戶通過 free 釋放了 40KB 和 100KB 大小的內存區域。

可以看到 glibc 的 malloc 以及 free 之後 當前進程的 arena 內存佔用情況有三種:

整個系統物理內存的佔用 RES = Available + Used

雖然介紹的是 glibc malloc 的使用方式,但這也是 rocksdb 使用了 tcmalloc 之後內存佔用上漲的原因,tcmalloc 也會有 available 這樣的內存。因爲 malloc 是從堆上分配的內存,想要釋放內存的也只能從堆頂進行釋放,上面的案例中,因爲堆頂是 1kb 並未被釋放,其阻塞了 140kb available 內存的釋放,所以,如果 1kb 的內存區域也被用戶釋放了, 那所有的內存就都可以還給操作系統了。

這也是爲什麼使用 glibc malloc 的時候同一個作用域內調用了 malloc,那作用域結束之後也需要調用 free,保證這一部分內存被釋放,並正常還給操作系統。當然,Available 區域的內存也是能用的,但是長久這樣不及時釋放 1kb 的內存資源,內存碎片肯定會不斷得增加,類似如下圖(內存碎片問題是所有的分配器都會有的,只是不同的分配器設計會不同程度的優化這一部分):

內存碎片如何導致高物理內存被佔用

那內存碎片是如何導致大量的物理內存被消耗的, 我們需要再多線程應用下來整體看看內存分配的過程。

用戶線程從當前 Arena 分配內存時需要持有一把排他鎖,防止同一塊內存被分配給了多個線程。因爲分配內存需要持有鎖,多線程下會產生鎖競爭從而降低了應用的性能。爲了解決這個問題,malloc 會創建多個 arena,這也是最前面描述的一個邏輯 CPU 會有多個 arena 的原因,具體的創建過程如下:

  1. A 線程嘗試從上一次分配內存的 arena 上獲取一個新的內存區域,這個時候會請求一把排他鎖。

  2. 如果這把鎖被 B 線程持有,A 線程會嘗試請求下一個 arena(每一個邏輯核默認有 4 個 arenas),並持有它的鎖。

  3. 如果所有的 arenas 都各自被一個線程持有,那 A 線程會創建一個新的 arena,並從該 arena 上獲取內存。

  4. 其他線程也可以持續按照以上三步從 arena 獲取內存,但是每一個邏輯核能夠創建的 arena 上限時 8 個。

多線程下的內存分配方式,創建了過多的 arena,也會導致大量的物理內存被消耗。而且,每一個 arena 之間的內存分配時獨立的,不能將一個 arena 的空餘內存的管理權遷移到另一個 arena 上,這對內存的浪費時較爲嚴重的,看看如下場景:

Thread1 從 arena1 請求一個 20KB 大小的內存,前面說過,一個線程會優先從之前分配過內存的 arena 請求分配新內存,因爲 arena1 還有足夠的 free 空間,所以可以直接滿足 thread1 的內存分配需求。但是,arena2 有一個 available 的 20KB 的內存,剛好可以滿足 thread1,卻因爲不同的 arena 之間的內存管理時獨立的,無法共享,所以 arena1 就多消耗了 20KB 的物理內存。

所以 glibc 的 malloc 設計是在性能和內存放大之間做的權衡,爲了降低鎖衝突,設置了多 arena 機制,卻間接增加了內存碎片,從而導致物理內存的消耗增加。

如何緩解 glibc malloc 造成的內存碎片

如何通過配置來有效減少 glibc allocator 的內存碎片呢:

  1. export MALLOC_ARENA_MAX=2 可以配置這個進程環境變量參數爲 2,設置當前線程在當前 core 允許創建的最大 arena 個數。肯定的是能爲 arena 個數少了,內存管理的效率肯定高了, 但是鎖衝突也會很明顯,對性能的影響比較大。

  2. malloc_trim 調用這個函數來回收 arena 中 available 的內存。它會同時遍歷所有的 arenas,爲所有 arenas 加鎖並釋放所有 available 的 chunk。這個函數會導致調用期間的 arena 鎖衝突較爲嚴重(應用內存分配的低峯期使用),並且會執行大量的系統調用來釋放內存到操作系統,間接造成大量的 page-fault(用戶再次申請內存,arena 中的內存不夠,需要再次申請物理內存) 從而降低了應用性能。

  3. export M_MMAP_THRESHOLD=65536 配置這個系統參數,這樣所有應用在他們請求的內存大小超過這個系統參數設置的閾值之後,分配內存會直接用 mmap,mmap 不會從 arena 中直接拿內存。這樣能夠降低鎖衝突 ,並且在用戶釋放之後能夠直接還給操作系統。這樣,用戶在分配大內存的時候就不會有內存碎片問題,直接 mmap 就好。當然,大內存通過 mmap 機進行分配的代價也很高,每次分配都會造成 page_fault。

Glibc malloc 使用了多 arena 來分配內存,雖然增加了內存碎片,但卻提升了內存分配效率。

TCMalloc 設計

雖然 glibc 的 malloc 最開始是單線程模型,且爲了性能最後優化了多線程模型下的內存分配方式,但是 tcmalloc 從設計之初就是多線程下的內存分配模型。接下來一起概覽一下 tcmalloc 如何在內存碎片和性能之間 trade-off 的。

整體的 tcmalloc 架構如下:

上圖中可以看到,大體分爲了三個組件:

Front-end,直接和應用進行交互,每一個用戶線程都有一個私有 cache,cache 內部管理部分內存。

Middle-end,這一部分是 Tcmalloc 的設計核心,主要提升多線程內存分配性能並有效降低內存碎片。它負責:

  1. 在 Front-end 部分的 thread-cache 內存不夠的時候進行填充

  2. 將未使用的內存釋放還給給操作系統。

  3. 這一部分最重要:可以將一個 thread-cache 的內存移動到另一個 thread-cache,從而降低內存碎片,提升內存使用效率。

接下來看看 Tcmalloc 相比於 glibc malloc 的內存分配流程,查看如下內存分配流程圖:

分配需求還是一樣的,thread1 請求分配 20kb 大小的內存,且 thread1 的 thread-cache 中沒有充足可用內存來滿足 20kb 的分配需求,但是 thread-cache2 剛好有一個可用的 20kb 的內存,在 tcmalloc 中,這樣的場景會執行如下流程:

  1. Thread-cache2 有一個不需要的內存區域,會將這一部分區域還給 從 Front-end 還給 Middle-end。

  2. thread1 從 自己的 thread-cache1 中請求 20kb 的內存大小

  3. 因爲 thread1 的 thread-cache1 無法滿足 20kb 的分配需求,會請求 Middle-end 分配內存,此時 Middle-end 會擁有其他 thread-cache 不需要的內存,這樣就能直接給 thread-cache1 複用。

因爲 TCMalloc 的 Middle-end 實現了不同 thread-cache 之間的空閒內存的複用,既保證了多線程下的內存分配性能,又降低了內存碎片。

當然, TCMalloc 相比於 glibc 還有有一些其他的優勢來讓用戶更願意去使用。後續的分配器系列文章會不斷得輸出更底層的設計細節 以及 和 jemalloc 相關的設計。

總結

一個合理的內存分配器的選擇,既能夠保證自己的應用性能,又能夠讓資源使用得到良好的管控。埋頭寫代碼的同時,還需要抬頭向前看,有的時候選擇比努力重要很多。

回到最開頭的背景問題,在 rocksdb 鏈接了 TCMalloc 之後,保證了性能的同時 內存使用顯著降低,這個和文章最開始的三個 Allocator 性能測試的結論差不多(tcmalloc 的性能和 glibc 接近,但是內存使用少了很多)。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/aBtBHhor9eX5wnA9LzIChg