筆者在 C 的世界第一站,選擇先把地基打好:memory pool
因為在 C 裡要 hash 沒有、陣列也無法自動擴容
要物件需要自己 malloc/free
,一不小心就記憶體外洩 memory leak
只要 memory pool 先到位,後面的 value/array/table/matcher 都能「有秩序地長大」
pool->error
,上層每次呼叫後只要檢查一個地方pool->alloc
,記憶體統一列管,介面單純pool->alloc(pool, size)
接近「指標往前推」的成本reset
持有記憶體但指標歸零,一秒回到解放前,free
一次收工pool->error
,上層統一檢查trace(ptr, size)
把外部記憶體跟 pool 綁一起,free 時能一併處理(或至少集中紀錄)最小模型是「一條 chunk 的 linked list」
每個 chunk 長這樣:
typedef struct mongory_memory_node {
void *ptr; // 這塊記憶體的起點
size_t size; // 總大小
size_t used; // 已用位元組
struct mongory_memory_node *next; // 下一塊 chunk
} mongory_memory_node;
typedef struct mongory_memory_pool_ctx {
size_t chunk_size; // 下一塊 chunk 預設大小(會倍增)
mongory_memory_node *head; // 第一塊
mongory_memory_node *current; // 正在使用的那一塊
mongory_memory_node *extra; // 追蹤外部分配用的清單
} mongory_memory_pool_ctx;
倍增策略的好處是:當需求從 2KB → 4KB → 8KB… 成長時,chunk 數量是對數級(O(log n)),減少系統呼叫與碎片化
這點在長時間跑 matcher 的情境,穩定性差很多
malloc
/free
次數驟降used
歸零,指標回到 head
,下一輪直接重用綜合上面兩個考量,筆者使用倍增策略的 chunk pool,開發體驗簡直像在寫高階語言一樣簡單
struct mongory_memory_pool {
void *(*alloc)(mongory_memory_pool *pool, size_t size);
void (*trace)(mongory_memory_pool *pool, void *ptr, size_t size);
void (*reset)(mongory_memory_pool *pool);
void (*free)(mongory_memory_pool *pool);
void *ctx; // 指向 mongory_memory_pool_ctx
mongory_error *error; // 發生錯誤時掛在這裡
};
alloc
:若 current
不夠,呼叫 grow,成功就回傳對齊後的位址,used += size
trace
:把外部 ptr
串到 extra
清單(視設計決定 reset/free 的處理策略)reset
:把 current
指回 head
,每塊的 used
歸零(chunk 還在,下次可重用)free
:釋放整條 head
串列與 extra
清單把所有的 function 通通抽象成 function pointer ,核心的分配 ctx 又做成抽象的 void,有什麼好處?
好處是如果未來某一天,有別的專案要引用 mongory core,但記憶體分配策略想要配合他自己的方式,這時候
mongory_memory_pool *my_pool = {
.alloc = my_custom_alloc
.trace = my_custom_trace
.reset = my_custom_reset
.free = my_custom_free
.ctx = my_custom_context
.error = NULL // 乖一點,一開始不要亂塞 error
}
像這樣定義好你自己的記憶體分配策略,然後送進 Mongory core 系統,一樣可以配合使用!
這做法很適合用在效能敏感或嵌入式設備等場景
static inline mongory_memory_node *
mongory_memory_chunk_new(size_t size) { /* malloc + 初始化 used=0 */ }
static inline bool
mongory_memory_pool_grow(mongory_memory_pool_ctx *ctx, size_t need) {
size_t newsz = ctx->chunk_size;
while (newsz < need) newsz <<= 1; // 倍增到能裝下
mongory_memory_node *node = mongory_memory_chunk_new(newsz);
if (!node) return false;
ctx->chunk_size = newsz << 1; // 下一次預估再加倍
ctx->current->next = node;
ctx->current = node;
return true;
}
static inline void *
mongory_memory_pool_alloc(mongory_memory_pool *pool, size_t size) {
mongory_memory_pool_ctx *ctx = pool->ctx;
// 對齊略
if (ctx->current->size - ctx->current->used < size) {
if (!mongory_memory_pool_grow(ctx, size)) {
// 掛錯誤,回傳 NULL
return NULL;
}
}
void *ptr = (char*)ctx->current->ptr + ctx->current->used;
ctx->current->used += size;
return ptr;
}
為了讓上層方便、也能在錯誤時回到 pool:筆者讓所有高階物件(value/array/table/matcher)都帶有 pool*
欄位
這讓:
obj->pool->alloc
obj->pool->error
pool->error
,不要直接 fprintf(stderr, ...)
後就結束,上層需要可預期的錯誤型別在 1e5 次固定大小分配的情境下,memory pool 的 alloc 幾乎是 pointer bump 的等級,搭配倍增,chunk 次數落在對數級,整體分配時間相當穩定
這讓後續的 matcher tree 建構能放心從大塊 chunk 上高效分配出大量小物件,不怕碎片化拖垮效能
void test_initial_pool_size(void) {
mongory_memory_pool *pool = mongory_memory_pool_new();
mongory_memory_pool_ctx *ctx = pool->ctx;
TEST_ASSERT_EQUAL(2048, ctx->chunk_size); // 例如預設 2KB
pool->free(pool);
}
void test_reset_reuse(void) {
mongory_memory_pool *pool = mongory_memory_pool_new();
void *p1 = pool->alloc(pool, 256);
pool->reset(pool);
void *p2 = pool->alloc(pool, 256);
TEST_ASSERT_NOT_NULL(p1);
TEST_ASSERT_NOT_NULL(p2); // reset 後仍可分配且從頭開始
pool->free(pool);
}
第一步就實作 memory pool ,並把「分配/釋放/擴容/錯誤/追蹤」一次想清楚,後面才有底氣一路寫到 value、array、table、matcher
這套 pool 讓筆者在 C 裡用「內部無腦 alloc,交給外部統一 free」的工作流,寫 matcher 幾乎跟寫高階語言一樣自然,完全沒有「我是不是忘了 free」的心理負擔
下一篇 Day 11,筆者會把「值的世界」整理成一個統一的 mongory_value
:型別標籤、比較函式、字串化、與 function pointer dispatch,讓 matcher 能在 C 裡輕鬆說話