調用malloc分配內存大概是微秒級別,高并發低延遲系統的關鍵路徑上,要慎用malloc/new,特別是在線程數量很大的情況下。
給一個測試數據:linux 64位系統,標準庫malloc,單線程,gcc開O3優化,分配的size在4M以下隨機,平均每次分配大概0.1-3微秒,具體數值跟分配行為有關,跟分配后是否free有關。
多線程下的malloc性能開銷,我沒有測,應該會比單線程下差很多很多。
微秒級的執行時間是什么概念?一般而言,簡單的函數調用,里面做個加減乘除+拷貝幾十個字節+邏輯判斷,應該是幾十個納秒級別,由此可見,malloc/new調用是比較慢的。
我們來看看doris是怎么做內存管理的,推測這個方案是從某個開源庫借鑒(chao)過來的,any way,性能不錯,值得研究。
Doris內存管理分三層
系統配置器(system_allocator)
- 封裝系統/標準接口,提供allocate/free接口
- allocate(size)根據size調用posix_memalign()或者mmap()
- free()接口調用munmap()或者free()
- 會在分配的時候做內存對齊。
塊配置器(chunk_allocator)
- 塊(Chunk):通過system_allocator::allocate接口分配的內存塊,包含內存塊首地址指針、尺寸、core_id等信息
- 為每個CPU core維護一個chunk_arena
- 每個chunk_arena包含一個chunk_list
- chunk_list為每個size維護一個該size的chunk集合
- 為了減少各種size的數量,只維護固定size的chunk集合,比如8、16、32、64、128、256...,所以如果分配請求的大小是34字節,那么會向上圓整到64
- 塊配置器會使用系統配置器分配/回收內存
- 塊配置器是單件(唯一實例)
內存池(MemPool)
- 對外提供allocate()、clear()、free_all()等接口
- 維護通過allocate接口分配的ChunkInfo的列表,ChunkInfo在Chunk上增加了一個已分配字節數
- 內存池會通過塊分配器分配大塊,每次分配的大塊的大小會按X2(策略決定)增加,從而確保不會頻繁調用塊分配器的allocate接口
- 通過內存池的allocate接口分配的內存,不支持單個塊free,只支持統一釋放:free_all()
- clear()接口支持內存復用
三者之間的關系如下
system_allocator的作用
屏蔽了動態內存管理相關的底層系統調用和標準C/Posix編程接口
- 如果單次申請的chunk size大于某個閾值,那就調用mmap/munmap
- 否則調用posix_memalign
- 上層應用不再直接調用底層API,而是調用system_allocator封裝的編程接口:allocate/free
chunk_allocator是怎么工作的?
chunk_allocator是system_allocator的上層,會使用system_allocator的allocate/free接口申請和回收內存塊。
chunk_allocator是MemPool的下層,提供allocate和free接口供MemPool使用。
chunk_allocator主要是減少了多線程競爭,chunk_allocator維護core_num個ChunkArena對象,該對象內維護一個chunk_list,為size=2^n的每個塊維護一個free list,內存申請的時候,會對請求的size向上圓整。
因為每個core都有一個ChunkArena對象,所以上層應用代碼申請內存的時候,先獲取代碼正在哪個核上執行,從而找到對應的ChunkArena對象,再通過size找到對應的free列表,再從該free list上摘除一個塊。
多個邏輯線程依然可能調度到同一個核上執行,雖然多個線程不會在一個核上同時執行申請動態內存,但多個線程在一個核上交錯執行(申請內存)的情況,依然會引發對free list的數據競爭(雖然這種情況出現的概率很小),這時候只需要用test_and_swap原子操作不停嘗試就行了,如果嘗試一定次數還不成功,則執行線程主動yield,讓出CPU,從而讓另一個在該核上執行內存分配的線程有機會繼續執行,進而修改atomic_flag,然后之前yield CPU的線程被重新調度執行。
TAS(test and swap)是很快的,且沖突概率變得非常小(因為每個核都有一個atomic_flag,不會所有線程競爭一個鎖),這樣的免鎖設計,讓分配內存變得很高效。
chunk_allocator也做了一層cache,通過chunk_allocator::free釋放的內存塊,并不一定會真正調用底層的free,只在預留size超過限額的情況下,才會調用system_allocator的free(),這樣進一步減少了對系統底層動態內存管理相關API的調用。
chunk_allocator是單件,唯一實例。
MemPool設計
咱們進一部分分析MemPool的設計,先給一張MemPool的圖:
MemPool設計
MemPool的作用
內存池在system_allocator/chunk_allocator/MemPool的層次結構中,位于頂層,它依賴于下層chunk_allocator,間接依賴system_allocator,下層的類不反向依賴于MemPool。
先說Chunk和ChunkInfo。
Chunk就是底層接口單次分配的內存塊,Chunk持有內存塊首地址data,內存塊大小size,以及分配的時候執行線程在哪個core上執行。
ChunkInfo包含Chunk,同時多了一個int allocated_size,這是因為,為了減少對
system_allocator::allocate()的調用次數,所以單次分配的chunk會比較大,幾K,幾十K,甚至XX M(兆),這個大的size記錄在chunk->size上。但是,上層應用一次分配的內存可能比較小,幾十字節之類,所以,該chunk還有多少字節可用(已經使用了多少字節),需要有一個記錄,這就是allocated_size,相當于一個游標,每次從該chunk分配x字節,那就把allocated_size這個游標往增長的方向移動x字節(實際上會考慮到對齊)。
所以,對
system_allocator::allocate()的調用,相當于批發進貨。對MemPool::allocate()的調用,相當于零售。效果上,就是減少了底層API的調用頻率,減少了多線程競爭。
MemPool持有一個next_chunk_size,它表示下次調用ChunkAllocator分配接口allocator的時候,需要分配多大,它被初始化為4K,下次分配的時候,會增加到8K,當然如果下次申請的size大于8K,則會取max。
next_chunk_size會一直增加,直到觸達最大配置值,這樣的設計,目的還是為了減少底層分配次數。
每次ChunkAllocator::allocate()都會返回一個Chunk,進而包裝為ChunkInfo,被MemPool管理起來,所以MemPool會有多個ChunkInfo,用chunk_index標識chunk。
MemPool記錄一個current_chunk_idx,這個idx記錄了上次成功分配的ChunkInfo,下次分配的時候,先從current_chunk_idx指向的chunkInfo里嘗試分配,如果該ChunkInfo的剩余內存空間不夠,則會查找其他ChunkInfo,直到找到能滿足分配請求的ChunkInfo,如果現有的所有ChunkInfo都不滿足,那就走ChunkAllocator的allocate,并把新申請的Chunk,放入ChunkInfo list。
MemPool不支持單次分配的內存free,但是支持free_all,這會free該MemPool的所有Chunk。
MemPool::Clear()接口不會真正free Chunk,而是會重置allocated_size,復用原內存chunk。
一個細節,關于ChunkAllocator,分配的時候,會首先從線程運行的core上的ChunkArena分配,如果沒有合適的,會從其他Core的ChunkArena里分配,再分配不到,才會從system_allocate,這樣做的目的,是減少內存cache量。
我們做內存池有幾個目標
- 吞吐,吞吐越大越好,能滿足各種不同size,各種內存分配場景的大吞吐最好。
- 提高存儲空間利用率,千方百計減少碎片(內碎片+外碎片,不懂請補課)。
- 為了提高速度,我們經常要做cache,但是cache多了,會造成寶貴的內存資源的浪費,所以,需要balance。
- 最后,非常重要的一點,提高cache利用率。
大家可以結合以上幾點,慢慢體會該內存池的方式,是如何做到的。
很多人會質疑內存池的必要性,我只能說,如果線程很多,并發很大,時延要求也高,那可能真的需要加這么一層,不信你可以去測試一下。
不過,所有的方案都有缺點都有優點,都需要通用性,專用性,性能,效率,內存利用率等各個方面做出權衡,要結合業務,結合上層代碼來定制。
Nginx,clickhouse的內存管理方案也不錯,讀者有興趣可以去找來看看。