--- tags: System Software --- # String Interning ## Material [2021q1 第 2 週測驗題](https://hackmd.io/@sysprog/linux2021-quiz2) ## Start From Simple 我們可以先從一個簡化的實作來窺探 [string interning](https://en.wikipedia.org/wiki/String_interning) 究竟葫蘆裡賣甚麼藥。在此之前,注意到在這個範例中,黃敬群老師為了測驗和教學之便而做了許多簡化,想對此主題有更完整的認識,參考專案 [chriso/intern](https://github.com/chriso/intern) 來理解 [string interning](https://en.wikipedia.org/wiki/String_interning) 當是更好的作法。 不過我們還是可以透過此範例對 string interning 的基本觀念有一定的掌握,過程中我也會嘗試指出我發現的問題,有時間的話也再深入探討 [chriso/intern](https://github.com/chriso/intern) 以得到更完整的理解。 :::info :notes: 顯然加一些圖解會更容易理解,不過此筆記目前主要是為了幫助自己學習,因此雖然腦中有對此架構的形狀,~~筆者本人則偷懶而沒有附上~~,也許未來有機會再探討此議題時補充 QQ ::: ### Some Structure ```cpp enum { CSTR_PERMANENT = 1, CSTR_INTERNING = 2, CSTR_ONSTACK = 4, }; #define CSTR_INTERNING_SIZE (32) #define CSTR_STACK_SIZE (128) typedef struct __cstr_data { char *cstr; uint32_t hash_size; uint16_t type; uint16_t ref; } * cstring; typedef struct __cstr_buffer { cstring str; } cstr_buffer[1]; ``` * enum 中定義不同的字串類型,除了可以讓函式對不同的類型做不同的相應處理以外,類型也影響 `cstring` 中的 `ref` / `hash_size` 的實質意義,以及 `cstr` 中用到的空間 * `cstr` 指向的空間可能來自 heap 直接分配,或者 `__cstr_node` 中的 `buffer`,或者 stack) * `CSTR_INTERNING_SIZE` 是會做 intern 的字串長度,換句話說,超過此長度的字串會選擇直接分配空間而不是使用 intern 的 reference * `CSTR_STACK_SIZE` 則是 `CSTR_ONSTACK` 類型的字串長度 * `cstring` 結構表示程式中的字串 * `cstr` 可能指向: * `__cstr_node` 中的 buffer(`CSTR_INTERNING` type) * heap 上直接分配的空間(type == 0) * 字串為 literal (`CSTR_PERMANENT` type) * stack 上的空間(`CSTR_ONSTACK` type) * `hash_size` 可能代表: * 字串長度(`CSTR_ONSTACK` type) * hash value(`CSTR_INTERNING` type) * 或者為 0,不被使用(其他) * `type` 為類型,如前所述根據類型各變數會有不同的使用方式 * `ref` 為 reference 數量: * type == 0 時,`ref` 會代表實際的 reference 數量,因此 release 時可以判斷 reference 數量是否歸零才去 free * 其他類型下 `ref` 則無實際用途 ```cpp #define INTERNING_POOL_SIZE 1024 #define HASH_START_SIZE 16 /* must be power of 2 */ struct __cstr_node { char buffer[CSTR_INTERNING_SIZE]; struct __cstr_data str; struct __cstr_node *next; }; struct __cstr_pool { struct __cstr_node node[INTERNING_POOL_SIZE]; }; struct __cstr_interning { int lock; int index; unsigned size; unsigned total; struct __cstr_node **hash; struct __cstr_pool *pool; }; ``` * `__cstr_node` 儲存 intern 字串 * `struct __cstr_data` 即 `cstring` 結構,其中的 `cstr` 會指向 `buffer` 中的儲存字串 * node 會以 linked list 結構被放在某個 `hash` 中的 bucket ,`next` 指向 linked list 的下一個 * `__cstr_interning` 透過 hash 儲存被 intern 的字串 * 考慮到多執行緒下的擴充,`lock` 用來保護對 `__cstr_interning` 的操作 * `index` 儲存目前取用 `pool` 中 `__cstr_node` 數量 * `size` 為 `hash` 的 bucket 數量 * `hash` 為一個 hash table * `pool` 則為預先分配好的 `__cstr_node`,會被連接到某個 `hash` 的 bucket 上 ### `CSTR_BUFFER` ```cpp #define CSTR_BUFFER(var) \ char var##_cstring[CSTR_STACK_SIZE] = {0}; \ struct __cstr_data var##_cstr_data = {var##_cstring, 0, CSTR_ONSTACK, 0}; \ cstr_buffer var; \ var->str = &var##_cstr_data; ``` * `CSTR_ONSTACK` 是字串的一種類型,可以透過 `CSTR_BUFFER` 建立 * 假設呼叫方法是 `CSTR_BUFFER(a)` * 在 stack 上建立一個名為 `a_cstring` 的 `CSTR_STACK_SIZE` 大小 char 陣列 * 宣告一個 `__cstr_data` 結構,名為 `a_cstr_data`,其字串使用的為前面建立的 char 陣列(因此第 3 項為 `CSTR_ONSTACK`),`hash_size` 和 `ref` 則初始為 0 * 宣告一個 `cstr_buffer` 結構名為 `a`,且其中的 `cstring` 成員即為前面的 `a_cstr_data` ### `CSTR_LITERAL` ```cpp #define CSTR_LITERAL(var, cstr) \ static cstring var = NULL; \ if (!var) { \ cstring tmp = cstr_clone("" cstr, (sizeof(cstr) / sizeof(char)) - 1); \ if (tmp->type == 0) { \ tmp->type = CSTR_PERMANENT; \ tmp->ref = 0; \ } \ if (!__sync_bool_compare_and_swap(&var, NULL, tmp)) { \ if (tmp->type == CSTR_PERMANENT) \ free(tmp); \ } \ } ``` * 透過 `cstr_clone` 得到 `cstring` 結構 * `type == 0` 表示字串配置在 heap 上,但為了建立 literal 類型更改為 `CSTR_PERMANENT` type(此 type 下 `ref` 無用途,設為 0) * 通過 `__sync_bool_compare_and_swap`,如果 `var` 為 NULL,則更新為 `tmp` * 如果 swap 失敗,且為 `CSTR_PERMANENT` 類型,釋放 `tmp` 避免 leak * 這代表 `CSTR_LITERAL` 存在失敗的可能,但是程式沒有明確阻止使用者操作失敗時的 NULL 結果 :::warning 可以發現 `CSTR_LITERAL` 如果是分配在 heap 上的話,不存在對應的釋放函式(`cstr_release` 只作用於 type == 0 的類型上),這些 literal 類型的字串應該被紀錄起來,並在程式結束時釋放 ::: ### `CSTR_CLOSE` ```cpp #define CSTR_CLOSE(var) \ do { \ if (!(var)->str->type) \ cstr_release((var)->str); \ } while (0) ``` * 檢查字串類型是否 `type == 0`,若是則 `cstr_release` 釋放 ### `cstr_cat` ```cpp cstring cstr_cat(cstr_buffer sb, const char *str) { cstring s = sb->str; if (s->type == CSTR_ONSTACK) { int i = s->hash_size; while (i < CSTR_STACK_SIZE - 1) { s->cstr[i] = *str; if (*str == 0) return s; ++s->hash_size; ++str; ++i; } s->cstr[i] = 0; } cstring tmp = s; sb->str = cstr_cat2(tmp->cstr, str); cstr_release(tmp); return sb->str; } ``` * 從 `sb` 中取出字串,如果該字串為 `CSTR_ONSTACK`,則 `s->hash_size` 可表字串的長度 `i`,接下來只要將 `str` 內容逐一放到 `s->str` 即可 * 非 `CSTR_ONSTACK` 狀況下,使用 `cstr_cat2` 去處理 * 經 `cstr_cat2` 後,`tmp` 這個 `cstring` 結構已經不再被需要了,因此透過 `cstr_release` 釋放(如果其字串空間來自 `xalloc`) ### `cstr_cat2` ```cpp static cstring cstr_cat2(const char *a, const char *b) { size_t sa = strlen(a), sb = strlen(b); if (sa + sb < CSTR_INTERNING_SIZE) { char tmp[CSTR_INTERNING_SIZE]; memcpy(tmp, a, sa); memcpy(tmp + sa, b, sb); tmp[sa + sb] = 0; return cstr_interning(tmp, sa + sb, hash_blob(tmp, sa + sb)); } cstring p = xalloc(sizeof(struct __cstr_data) + sa + sb + 1); if (!p) return NULL; char *ptr = (char *) (p + 1); p->cstr = ptr; p->type = 0; p->ref = 1; memcpy(ptr, a, sa); memcpy(ptr + sa, b, sb); ptr[sa + sb] = 0; p->hash_size = 0; return p; } ``` * 首先,對於輸入的兩個 `char` 字串如果總和小於 `CSTR_INTERNING_SIZE`,直接在 stack 上接起來。並透過 `cstr_interning` 查找是否已存在相同內容的字串被 intern,若無則將其 intern 一個,最後得到被 intern 的字串 * 如果字串長度長於 `CSTR_INTERNING_SIZE`,這個字串不會被 intern,而是直接 `xalloc` 必要的大小並把這個銜接起來的 string copy 到 cstring 結構中 * `(p + 1)` 讓指標前進一個 `cstring` 大小,此時 `ptr` 指向的是當時分配空間時預留給字串的大小 `sa + sb + 1`,因此 `p->cstr` assign 為 `ptr` 即所表示的字串 * `p->type` 被設為 0,而 `p->ref` 被設為 1,且 `hash_size = 0`,代表此字串沒被 intern 的狀態 ### `cstr_clone` 原則上和 `cstr_cat2` 很相似,只是一個是直接建立一個字串另一個是銜接兩個字串,這裡就不多做解釋。 ### `cstr_release` ```cpp void cstr_release(cstring s) { if (s->type || !s->ref) return; if (__sync_sub_and_fetch(&s->ref, 1) == 0) free(s); } ``` * 如果 `type` 被設為 0 且 reference count 大於零,表示此字串的類型為空間分配在 heap 上(且非 heap 上的 `struct` 中的陣列空間) * `__sync_sub_and_fetch` 從 `&s->ref` 减去 1,並返回操作後的數值,因此如果 reference count 因此被減為 0,`s` 會被釋放。 ### `hash_blob` ```cpp static inline uint32_t hash_blob(const char *buffer, size_t len) { const uint8_t *ptr = (const uint8_t *) buffer; size_t h = len; size_t step = (len >> 5) + 1; for (size_t i = len; i >= step; i -= step) h = h ^ ((h << 5) + (h >> 2) + ptr[i - 1]); return h == 0 ? 1 : h; } ``` * JS Hash invented by Justin Sobel? * 對於輸入的字串 `buffer` 長度為 `len`,得到 hash 後的 index ### `CSTR_LOCK` / `CSTR_UNLOCK` ```cpp /* FIXME: use C11 atomics */ #define CSTR_LOCK() \ ({ \ while (__sync_lock_test_and_set(&(__cstr_ctx.lock), 1)) { \ } \ }) #define CSTR_UNLOCK() ({ __sync_lock_release(&(__cstr_ctx.lock)); }) ``` * `CSTR_LOCK` 透過 [Test-and-Set](https://en.wikipedia.org/wiki/Test-and-set) 方法嘗試去 acquire lock,將 1 寫入 `__cstr_ctx.lock` 並返回 lock 原本的值(透過 gcc builtin 整個流程為 atomic operation)。 * `CSTR_UNLOCK` 則透過 `__sync_lock_release` 將 寫為 0,解鎖 lock ### `cstr_interning` ```cpp static cstring cstr_interning(const char *cstr, size_t sz, uint32_t hash) { cstring ret; CSTR_LOCK(); ret = interning(&__cstr_ctx, cstr, sz, hash); if (!ret) { expand(&__cstr_ctx); ret = interning(&__cstr_ctx, cstr, sz, hash); } ++__cstr_ctx.total; CSTR_UNLOCK(); return ret; } ``` * 因為 `__cstr_ctx` 是全域的變數,因此需要透過 lock 來保護(雖然目前還沒有 multi thread 的設計 * `interning` 察看是否已經有被使用過的相同字串,詳見下面對 `interning` 的解釋,簡而言之 * 如果有相同的字串,直接從中拿出來用 * 如果沒有 * 當可被 intern 的字串數量超過 threshold,會返回 NULL,因此需要額外 `expand` 然後重新 `interning` * 否則直接把該字串也 intern 然後拿出來用 * `_cstr_ctx.total` + 1 表示被提取的總量 + 1 :::warning 總覺得以被提取的總量來決定是否 expand 不符合邏輯? `_cstr_ctx.total` 應該要用來代表被 intern 的字串總量? ::: ### `expand` ```cpp static void expand(struct __cstr_interning *si) { unsigned new_size = si->size * 2; if (new_size < HASH_START_SIZE) new_size = HASH_START_SIZE; struct __cstr_node **new_hash = xalloc(sizeof(struct __cstr_node *) * new_size); memset(new_hash, 0, sizeof(struct __cstr_node *) * new_size); for (unsigned i = 0; i < si->size; ++i) { struct __cstr_node *node = si->hash[i]; while (node) { struct __cstr_node *tmp = node->next; insert_node(new_hash, new_size, node); node = tmp; } } free(si->hash); si->hash = new_hash; si->size = new_size; } ``` * `expand` 增加 hash 的儲存空間大小 * 第一次呼叫 `expand` 時,至少確保有 `HASH_START_SIZE` 的大小 * 透過 `xalloc` (對 `malloc` 的包裝,確保 malloc 失敗時的後續處理) 建立 `new_size` 個 `__cstr_node *` 空間,並先重設為 0 * 透過 `insert_node` 將舊的 hash 的內容更新到 `new_hash` 中 * `insert_node` 會根據每個字串的原始 hash 值和新的 hash 範圍,重新找到舊的 node 在新的 hash 中應存在的位置(bucket) * 釋放舊的 hash 後,再新的 hash 和 size assign 到 `si` 結構中 ### `interning` ```cpp static cstring interning(struct __cstr_interning *si, const char *cstr, size_t sz, uint32_t hash) { if (!si->hash) return NULL; int index = (int) (hash & (si->size - 1)); struct __cstr_node *n = si->hash[index]; while (n) { if (n->str.hash_size == hash) { if (!strcmp(n->str.cstr, cstr)) return &n->str; } n = n->next; } // 80% (4/5) threshold if (si->total * 5 >= si->size * 4) return NULL; ``` * 對於字串 `cstr`,尋找是否已經有存在的 instance,避免在記憶體中存在多份副本帶來的空間浪費 * 透過 `hash & (si->size - 1)` 得到 hash 的 `index`,透過 `index` 找到對應的 `__cstr_node *n` * 每個 hash 到的 `__cstr_node` 會形成一個 linked list,linear search 之。在 node 中的 `cstring` 之 `hash_size` 用來代表 hash 值,因此首先比較 hash 值如果不同就必為相異字串 * 如果 hash 值相同再用 `strcmp` 進一步確認,相同則直接返回 reference * `si->total` 是從 intern 提取的字串總量,如果其數量多於 hash 的 size 之 4 / 5,先不對 `cstr` 做 intern,返回 NULL * 在 `cstr_interning` 中可以看到若返回 NULL 會先透過 `expand` 增加 hash 大小,再呼叫 `interning` 重新 intern 一次 ```cpp if (!si->pool) { si->pool = xalloc(sizeof(struct __cstr_pool)); si->index = 0; } n = &si->pool->node[si->index++]; ``` * 如果 `si->pool` 不存在則透過 `xalloc` 建立,並重置 `si->index` * `struct __cstr_interning` 中的 `hash` 是指向 `struct __cstr_node *` 的 pointer。所指向的 `struct __cstr_node *` 空間就來自 `pool` * 因此 `index` 代表下一個被使用的 `struct __cstr_node *` :::warning 可以注意到原始的程式沒有對 `pool` 的使用超出 `INTERNING_POOL_SIZE` 做規範,`expand` 也沒有擴充大小的限制,因此當 `index` 累計超出 `INTERNING_POOL_SIZE` 會產生 overflow! ::: ```cpp memcpy(n->buffer, cstr, sz); n->buffer[sz] = 0; cstring cs = &n->str; cs->cstr = n->buffer; cs->hash_size = hash; cs->type = CSTR_INTERNING; cs->ref = 0; n->next = si->hash[index]; si->hash[index] = n; return cs; } ``` * 每個 `struct __cstr_node *` 計錄一個被 intern 的字串。將字串 copy 到 `n->buffer` 後,就可以將 intern 的字串透過 `cstring` 結構返回 * 因為字串來自 interning,因此 type 是 `CSTR_INTERNING` 且 * `hash_size` 在此 type 下代表此字串的 hash value * *`ref` 設為 0 的正確性???* * 最後將 `struct __cstr_node *` n 接到所屬的 `hash[]` 下的 linked list 的開頭,並回傳被 intern 的字串 ### `cstr_equal` ```cpp int cstr_equal(cstring a, cstring b) { if (a == b) return 1; if ((a->type == CSTR_INTERNING) && (b->type == CSTR_INTERNING)) return 0; if ((a->type == CSTR_ONSTACK) && (b->type == CSTR_ONSTACK)) { if (a->hash_size != b->hash_size) return 0; return memcmp(a->cstr, b->cstr, a->hash_size) == 0; } uint32_t hasha = cstr_hash(a); uint32_t hashb = cstr_hash(b); if (hasha != hashb) return 0; return !strcmp(a->cstr, b->cstr); } ``` * 字串的比較部份,因為字串有許多 metadata,因此可以不用直接透過 `strcmp` 先比較 * `a == b` 即同位置上的字串,自然內容相同 * 如果位置不同且兩者皆為 `CSTR_INTERNING`,則必不同(因為 `CSTR_INTERNING` 對同個字串只有一次 intern) * 對於 `CSTR_ONSTACK` 類型,可以先比較長度(`hash_size`),再用 `memcmp` 比較實際內容 * 如果其他類型,可以先比對 hash 值,最後的情況再用 `strcmp` 確認 ### `cstr_grab` ```cpp cstring cstr_grab(cstring s) { if (s->type & (CSTR_PERMANENT | CSTR_INTERNING)) return s; if (s->type == CSTR_ONSTACK) return cstr_clone(s->cstr, s->hash_size); if (s->ref == 0) s->type = CSTR_PERMANENT; else __sync_add_and_fetch(&s->ref, 1); return s; } ``` * 取得一份 `cstring s`,根據類型回傳的型態會有差異 ### Bonus: `cstr_interning_free` ```cpp void cstr_interning_free() { free(__cstr_ctx.pool); free(__cstr_ctx.hash); } ``` * 在老師原始的設計中沒有這個 function,不過我們應該在程式結束時主動釋放這些空間 ### 總結 上面的整理可能會有些紊亂(我很抱歉QQ),不過我們還是可以歸納出一些 string interning 的相關技巧: * 將可能出現在多處的相同字串簡化為單一空間,透過 hash 方式在使用這些字串時快速的取得 reference * 不同類型的字串(主要是長度差異,並有部份是 user 的選擇) 需要的 meta-data 可能不同,struct 中的變數可以根據需求直接複用(或許可以考慮改成 union 增加可讀性) * hash 的建立所需要的 `node` 並非每次新的字串進來時再去分配,反之,預先分配好一定數量的 `node`(也就是 pool),需要時再去取用,減少頻繁呼叫 `malloc` 可能產生的時間問題 * 字串帶有 hash 值、長度等 meta data 可以協助字串的比對,有機會更有效率的產生比對結果