# Lock-Free Linked List with Lockless Memory Allocation contributed by < `Korin777` > > [GitHub](https://github.com/Korin777/Lock-Free-Linked-List-with-Lockless-Memory-Allocation) 以[第 11 週測驗](https://hackmd.io/@sysprog/linux2022-quiz11)題第二題的程式碼為基礎,實作有效的並行鏈結串列,並搭配[第 13 週測驗題](https://hackmd.io/@sysprog/linux2022-quiz13)第一題的記憶體管理 (以 mmap 系統呼叫實作) 程式,儘量降低 lock contention。 1. 論文研讀: 〈[Lock-Free Linked Lists and Skip Lists](http://www.cse.yorku.ca/~ruppert/papers/lfll.pdf)〉 2. 撰寫測試程式: 設計實驗量化分析 scability,參考 [concurrent-ll](https://github.com/sysprog21/concurrent-ll) 3. glibc malloc、free 會用到 mutex -> 設計更有效率的 malloc、free 來降低 lock contention 4. memory barrier: fence(防止 instruction reorder) 5. madvise: 參考 [mmap-benchmark](https://github.com/exabytes18/mmap-benchmark) ## 實驗環境 :::spoiler Architecture: x86_64 CPU op-mode(s): 32-bit, 64-bit Byte Order: Little Endian Address sizes: 39 bits physical, 48 bits virtual CPU(s): 8 On-line CPU(s) list: 0-7 Thread(s) per core: 2 Core(s) per socket: 4 Socket(s): 1 NUMA node(s): 1 Vendor ID: GenuineIntel CPU family: 6 Model: 142 Model name: Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz Stepping: 10 CPU MHz: 1600.035 CPU max MHz: 3400.0000 CPU min MHz: 400.0000 BogoMIPS: 3600.00 Virtualization: VT-x L1d cache: 128 KiB L1i cache: 128 KiB L2 cache: 1 MiB L3 cache: 6 MiB NUMA node0 CPU(s): 0-7 ::: ## 論文研讀 1. node->next 被 flag 表示正要移除 node->next 2. node->next 被 mark 表示 node 已被 logically delete 3. 若 node 已被 logically delete 將它 physically delete 同時把前一個 node 的 flag 消除 4. node 不能同時被 mark 及 flag 5. 被 mark 或 flag 的 node 的下一個 node 不會被更動 , 其他人會先幫忙把正在移除的 node 移除 ## 撰寫測試程式 測試程式修改自 [concurrent-ll](https://github.com/sysprog21/concurrent-ll) 以符合[第 11 週測驗](https://hackmd.io/@sysprog/linux2022-quiz11)題第二題給定的 nblist (non-blocking singly-linked list) 實作,主要修改的地方在 `test` 函式 * `top` : 取出開頭的 key * `push` : 在開頭新增 key * `pop` : 在開頭移除 key ```c void *test(void *data) { thread_data_t *d = (thread_data_t *) data; uint32_t read_thresh = 256 * finds / 100; seeds = seed_rand(); uint32_t rand_max = max_key; val_t the_value; int last = -1; for (int i = 0; i < d->n_add; ++i) { the_value = (val_t) my_random(&seeds[0], &seeds[1], &seeds[2]) & rand_max; if (push(the_list,the_value) == 0) i--; } barrier_cross(d->barrier); while (*running) { the_value = my_random(&seeds[0], &seeds[1], &seeds[2]) & rand_max; uint32_t op = my_random(&seeds[0], &seeds[1], &seeds[2]) & 0xff; if (op < read_thresh) { // 更換原 list_contain top(the_list); } else if (last == -1) { // 更換原 list_add if (push(the_list, the_value)) { d->n_insert++; last = 1; } } else { // 更換原 list_remove if(pop(the_list)) { d->n_remove++; last = -1; } } d->n_ops++; } return NULL; } ``` * 在多執行緒會發生 `Aborted (core dumped)` , 原因是 `push` 允許 `nblist_push` 失敗的次數太少而執行到 `abort()` , 將 `spins` 值變大能避免此問題 * 在多執行緒程式不會遇到任何 assert 錯誤 , 且測試程式的 expected size 和 actual size 一致。 ```c static struct item *push(struct nblist *list, int value) { struct item *it = malloc(sizeof(*it)); it->value = value; int spins = 0; while (!nblist_push(list, nblist_top(list), &it->link)) { /* spin */ printf("%s: repeating", __func__); if (++spins == 10) abort(); } return it; } ``` ```shell $ ./out/linked list -n7 Thread 0 #operations : 2626095 #inserts : 266828 #removes : 266827 Thread 1 #operations : 1215180 #inserts : 123428 #removes : 123428 Thread 2 #operations : 2301196 #inserts : 233250 #removes : 233249 Thread 3 #operations : 1251367 #inserts : 127070 #removes : 127069 Thread 4 #operations : 1232578 #inserts : 124981 #removes : 124980 Thread 5 #operations : 2235207 #inserts : 226891 #removes : 226890 Thread 6 #operations : 1383432 #inserts : 139984 #removes : 139983 Duration : 1000 (ms) #txs : 12245055 (12245055.000000 / s) Expected size: 1029 Actual size: 1029 ``` 透過 `run_ll.sh` 及 `create_plots_ll.sh` 來觀察 throughput 及 scability * Update: (push+pop)操作次數/100 * Size: 一開始 linked list 有多少 key , key 介於 0 ~ 2 * size ![](https://i.imgur.com/cxgOtK4.png) * 只有 `top` 操作時 throughput 及 scability 隨著執行緒數量增加成長 ![](https://i.imgur.com/cTGSIHr.png) ![](https://i.imgur.com/1Z7ahEp.png) * 當有 `push` 及 `pop` throughput 及 scability 都隨著 thread 增加而下降 * 我認為這個結果是因為 push 及 pop 都是對開頭進行變更 , 而就算有多個執行緒同時在工作,每次也只會有一個執行緒能成功執行 , 而其他執行緒就必須 spin , 造成多執行緒產生的開銷大於其所帶來的效益 ### 參考論文實作 `insert` 及 `delete` 函式 以下操作都建立在一個 key 值唯一且由小到大排序的 linked list `search`: ```c struct nblist_node *list_search(val_t val, struct nblist_node *curr_node, struct nblist_node **left_node) { uintptr_t curr_p = atomic_load(&curr_node->next); struct nblist_node *next_node = n_ptr(curr_p); while(next_node && (container_of(next_node,struct item, link)->value <= val)) { uintptr_t next_p = atomic_load(&next_node->next); while((next_p & F_MARK) && (curr_p & F_FLAG)) { if(n_ptr(curr_node->next) == next_node) {// help physically delete rend_the_marked(curr_node,next_node,next_p); } curr_p = atomic_load(&curr_node->next); next_node = n_ptr(curr_p); if(next_node == 0) // arrive list end break; next_p = atomic_load(&next_node->next); } if(next_node && container_of(next_node,struct item, link)->value <= val) { curr_node = next_node; curr_p = atomic_load(&curr_node->next); next_node = n_ptr(curr_p); } } *left_node = curr_node; return next_node; } ``` * 自 `curr_node` 開始尋找兩相鄰節點 n1, n2 * n1.value ≤ k < n2.value - [ ] `insert` ```c bool list_insert(struct nblist *the_list, val_t val) { struct nblist_node *prev = NULL; struct nblist_node *next = list_search(val, &the_list->n, &prev); if(container_of(prev,struct item,link)->value == val) // duplicate key return false; struct item *it = malloc(sizeof(*it)); if(!it) return false; it->value = val; struct nblist_node *newNode = &it->link; while (1) { uintptr_t prev_succ = prev->next; if(prev_succ & F_FLAG) // help the corresponding deletion to complete clear_flag(prev,n_ptr(prev_succ)); else { newNode->next = (uintptr_t)next; if(atomic_compare_exchange_strong_explicit(&prev->next, &next, (uintptr_t) newNode, memory_order_release, memory_order_relaxed)) { // ensure prev and next are consecutive and prev isn't marked or flaged return true; } else { prev_succ = prev->next; if(prev_succ & F_FLAG) clear_flag(prev,n_ptr(prev_succ)); while(prev->next & F_MARK) // Possibly a failure due to marking. Traverse a chain of backlinks to reach an unmarked node. prev = prev->backlink; } } next = list_search(val,prev,&prev); // search two consecutive node again from prev node if(container_of(prev,struct item,link)->value == val) { free(it); return false; } } } ``` - [ ] `delete` ```c bool list_delete(struct nblist *the_list, val_t val) { struct nblist_node *prev = NULL; struct nblist_node *next = list_search(val-1, &the_list->n, &prev); if(!next || container_of(next,struct item,link)->value != val) // no such key return false; bool got = try_flag(&prev, prev->next, next); if (prev) clear_flag(prev, next); return got; } ``` ![](https://i.imgur.com/EjQzJ8e.png) ![](https://i.imgur.com/CMLoUVp.png) ![](https://i.imgur.com/KKCDr26.png) * Thoughput 及 Scability 都有隨著執行緒數量的增加而提升 * 隨著 linked list size 的增加,Scability 的提升也更顯著,我認為是因為 key 越多,要遇到正在進行移除的節點次數就會比較少,因此要幫助移除正在進行移除的節點及透過 backlink 回到尚未被移除的節點次數也就變少了,而我覺得這些都是可能降低多執行緒 throughput 的原因 - [ ] `try_flag` ```c= static bool try_flag(struct nblist_node **pp, uintptr_t p_val, struct nblist_node *n) { struct nblist_node *p = *pp; const uintptr_t new_val = (uintptr_t) n | F_FLAG; bool got; for (;;) { if (p_val == new_val) { *pp = p; return false; } uintptr_t old_val = (uintptr_t) n; got = atomic_compare_exchange_strong(&p->next, &old_val, new_val); if (got || old_val == new_val) { *pp = p; return got; } p_val = old_val; while ((p_val & F_MARK) != 0) { p = atomic_load_explicit(&p->backlink, memory_order_relaxed); assert(p); p_val = atomic_load_explicit(&p->next, memory_order_relaxed); } struct nblist_node *del_node = list_search(container_of(n,struct item,link)->value-1,p,&p); if(del_node != n) { *pp = NULL; return false; } /* @p is no longer @n's parent. walk forward until the parent is * found, or return NULL. */ // assert(n_ptr(p_val)); // while (n_ptr(p_val) != n) { // p = n_ptr(p_val); // p_val = atomic_load_explicit(&p->next, memory_order_relaxed); // if (!n_ptr(p_val)) { // *pp = NULL; // return false; // } // } } *pp = p; return got; } ``` * 這裡嘗試去改進 `try_flag`,當要被 flag 的 predecessor 被 mark 時,會透過 backlink 來找到未被 mark 的 predecessor(因為 node 不能同時 flag 及 mark,且被 mark 表示正要被 physically delete),在從這個 predecessor 去找出 del_node(這裡為 `n`) 新的 predecessor * 32 ~ 43 行就是要找這個新的 predecessor,這裡的實作跟論文中不同,走訪過程中不會去幫忙節點的移除,所以當原本正在移除該節點的人停下來且其他人也都沒幫忙移除,就會使 `try_flag` 停滯不前 * 26 ~ 30 行就是跟論文一樣,去幫忙移除走訪過程中所遇到的正在移除中的節點 ## 實做自己的 memory allocator > [GitHub](https://github.com/Korin777/TestMalloc) ### memory allocator 結構 * `vm_head` * `vm_t *next` : 透過 mmap 配置的 4096 Byte 記憶體空間 * `p_rel array[N_VM_ELEMENTS]` : 存放相對位置 , 及第 i 個配置的記憶體地址與 `array[i]` 地址的差 * `char raw[0]` : `vm_t` 結構中可以讓我們動態配置記憶體的起始位置 * `my_stack_t freed[256]` : 被釋放的記憶體會根據大小 , push 到對應的 `freed[i]` , 配置記憶體實會優先重這裡 pop 出來 * `reuse_block_t *next` : 被釋放的記憶體區塊 - [ ] `vm_extend_map` ```c static vm_t *vm_extend_map(vm_head_t *head) { vm_t *nod = head->next; vm_t *new_nod = vm_new(); head->next = new_nod; return new_nod; } ``` * 這裡將新配置出的 `vm_t` 加在開頭的位置,來避免在 `vm_add` 走訪所有的 `vm_t` - [ ] `vm_add` ```c uintptr_t vm_add(size_t sz, struct vm_head *head) { sz = align_up(sz); uintptr_t block = pop(&head->freed[(sz >> 3) - 1]); if(block) return block; vm_t *nod = head->next; if(!nod) nod = vm_extend_map(head); retry: if ((int) ((nod->array[nod->use] + (sizeof(p_rel) * nod->use) + sz)) >= PAGESIZE || nod->use >= nod->max) { nod->max = nod->use + 1; /* addr > map */ nod = vm_extend_map(head); goto retry; } char *p = getaddr(nod->array[nod->use]) + sz; setaddr(nod->array[nod->use + 1], p); nod->use++; return getaddr(nod->array[nod->use - 1]); } ``` * 從對應的 `my_stack_t freed[256]` 或 `char raw[0]` 來配置記憶體空間,若沒有可用的空間在透過 mmap 配置新的 `vm_t` * 記憶體空間對齊 8 byte - [ ] `vm_remove` ```c void vm_remove(uintptr_t ptr, int sz, struct vm_head *head) { if(sz == 0 || !ptr) return; sz = align_up(sz); push(&head->freed[(sz >> 3) - 1],ptr); } ``` * 將記憶體 push 進對應的 `my_stack_t freed[256]` 讓後續的記憶體配置能重用此空間 ### 多執行緒的配置及釋放記憶體 目前的作法是每個 thread 都有自己的 memory allocator , 這樣就不會配置到相同的記憶體位置 ### 測試多執行緒 malloc/free 的 throughput 及 scability * 測試程式嘗試配置記憶體並寫值到該記憶體空間,接著去釋放該記憶體 * 紅線及藍 x 是 glibc 的實做 ; 綠線及紅 + 是我的實做 * Malloc_Size : 動態配置記憶體的大小 * Count : 連續配置記憶體及釋放記憶體的次數(e.g: count = 1 表示配置完 1 個後接著釋放 1 個,反覆執行) * Throughput: malloc 及 free 操作的次數 ![](https://i.imgur.com/IKrbbdK.png) ![](https://i.imgur.com/XqvqoJ6.png) ![](https://i.imgur.com/X3Q5c1V.png) * 可以發現 glibc 的實做雖然有用到 lock,在 thread 數為 4 以下時,隨著 thread 的增加,Scability 還是有很好的成長,因為 glibc 有透過多個 allocation arenas 來避免 lock contention * 我的實做雖然沒用到 lock,Scability 卻也在 thread 數為 4 以上時,增長不明顯 * 這裡還有發現,如果是一次配置一個接著釋放一個這樣輪流做的話,glibc 的實做 throughput 較佳 ; 如果是一次配置一些接著釋放一些這樣輪流做的化,我的實做 throughput 較佳 ## memory reclamation [be9a14608c](https://github.com/Korin777/Lock-Free-Linked-List-with-Lockless-Memory-Allocation/tree/be9a14608c1965bbd64ac5d1d717248913f4a14d) 引入 Homework5 研讀過的 [hp_list](https://github.com/sysprog21/concurrent-programs/tree/master/hp_list) 裡面 hazard pointer 的實做,來幫助記憶體的釋放及重用,目前可以在多執行緒且只有 `push` 及 `pop` 的操作下,不觸發 AddressSanitizer 的錯誤 ```c bool nblist_push(struct nblist *list, struct nblist_node *top, struct nblist_node *n, int tid) { assert(((uintptr_t) n & F__MASK) == 0); uintptr_t old = atomic_load_explicit(&list->n.next, memory_order_acquire); while ((old & F_FLAG) != 0) { list_hp_protect_ptr(hp, 1, (uintptr_t) (old & ~F__MASK), tid); if(list->n.next == old) clear_flag(&list->n, n_ptr(old), tid); old = atomic_load(&list->n.next); } list_hp_clear(hp,tid); assert((old & F_MARK) == 0); n->next = old; n->backlink = NULL; return n_ptr(old) == top && atomic_compare_exchange_strong_explicit( &list->n.next, &old, (uintptr_t) n, memory_order_release, memory_order_relaxed); } struct nblist_node *nblist_pop(struct nblist *list, int tid) { struct nblist_node *p;// predecessor uintptr_t p_val; struct nblist_node *n; // del_node retry: p = &list->n; p_val = atomic_load(&p->next); assert((p_val & F_MARK) == 0); // not dead n = n_ptr(p_val); list_hp_protect_ptr(hp, 1, (uintptr_t) n, tid); if((p->next & ~F_FLAG) != (uintptr_t) n) goto retry; while (n) { if ((p_val & F__MASK) != 0) { // p_val mark or flag p = n; list_hp_protect_ptr(hp, 0, (uintptr_t) n, tid); p_val = atomic_load(&p->next); } else if (atomic_compare_exchange_strong(&p->next, &p_val, p_val | F_FLAG)) { // 確保 p->next 沒 mark or flag break; } n = n_ptr(p_val); list_hp_protect_ptr(hp, 1, (uintptr_t) n, tid); if((p->next & ~F_FLAG) != (uintptr_t) n) goto retry; } if (!n) // empty return NULL; clear_flag(p, n, tid); list_hp_clear(hp,tid); list_hp_retire(hp,n,tid); return n; } static bool clear_flag(struct nblist_node *prev, struct nblist_node *n, int tid) { struct nblist_node *old = atomic_exchange_explicit(&n->backlink, prev, memory_order_release); assert(!old || old == prev); uintptr_t nextval = atomic_load_explicit(&n->next, memory_order_relaxed); while ((nextval & F_MARK) == 0) { // mark n while ((nextval & F_FLAG) != 0) { // n->next 要被移除 , 要先移出它才能 mark n list_hp_protect_ptr(hp, 2, nextval & ~F__MASK, tid); if(n->next == nextval) { clear_flag(n, n_ptr(nextval),tid); } nextval = atomic_load(&n->next); } if (atomic_compare_exchange_strong_explicit( &n->next, &nextval, nextval | F_MARK, memory_order_release, memory_order_relaxed)) { nextval |= F_MARK; } } return rend_the_marked(prev, n, nextval); } ``` * 主要就是將要用到的 node 加入 harzard pointer,加入之後要確保 node 還沒被 physically delete,因為 physically delete 的 node 可能在加入前就被釋放了,遇到這個狀況就必須重來 * `push` 最多需要保護兩個 node,而 `pop` 最多則需要保護 3 個 * `insert` 和 `delete` 因為會透過 `backlink` 來往回走,所以只保護 3 個 node 應該是不夠的,且也無法像上面一樣透過 physically delete 來判斷加進 hazard pointer 前,node 是否以被釋放,目前還要想該怎麼做 ### 觀察記憶體使用量 學習 [kdnvt](https://hackmd.io/@kdnvt/concurrent-ll-2022) 同學,利用 [lab0](https://hackmd.io/@sysprog/linux2022-lab0) 看過的 valgrind 搭配 massif 來觀察 memory 的使用情況,這裡 thread 數為 4 、 node 數維持在 1024 左右 - [ ] 沒做 memory reclamation ![](https://i.imgur.com/D1WYqxR.png) - [ ] 有 memory reclamation ![](https://i.imgur.com/vID8Nhi.png) * 綠色為 push 新的 node 所使用的記憶體量 * 可以發現沒做 memory reclamation 的話,`pop` 並不會釋放記憶體,而 `push` 則一直配置新的記憶體,使記憶體使用量隨時間成長到 132Mib 左右 * 反之,有做 memory reclamation 的記憶體使用量則維持在 24KiB 左右,這 24 Kib 也就是 node 大小(24B) * node 數(1024左右),可見 `pop` 有成功釋放記憶體並讓 push 去重用它們 ## 用自己的 Memory Allocator 配置 linked list 記憶體 [2278af42c3](https://github.com/Korin777/Lock-Free-Linked-List-with-Lockless-Memory-Allocation/tree/2278af42c3b3bf95d64f99c4f55c9f9ee52c4e70) 引入自己實做的 memory allocator * 運用在 linked list 上時,想到配置及釋放記憶體的 thread 不一定是同一個,且想像 `malloc`、`free` 一樣只傳記憶體地址進去,所以配置記憶體時會額外給 16 bytes,4 bytes 存記憶體大小,4 bytes 存 Memory Allocator id,8 bytes 存下一個 reuse block 的位址 * 也因為可能從多個 thread 同時釋放記憶體到某個 thread 上,在 push `reuse_block_t` 到 `freed[i]` 及 pop 出 `reuse_block_t` 時要透過 CAS 來保護 ### 觀察不同 memory allocator 用在 linked list 上 throughput 及 scability 的差異 紅線及藍 x 是 glibc 的實做 ; 綠線及紅 + 是我的實做 #### push 及 pop 的 throughput 和 scability * push 和 pop 有做 memory reclamation * Update: 100 次操作中有幾次為 push 或 pop 操作 * Size: 一開始 linked list 有多少 key,key 介於 0 ~ 2 * size * Throughput: push 及 pop 操作的次數 ![](https://i.imgur.com/fXyEezd.png) ![](https://i.imgur.com/6j9p59Q.png) ![](https://i.imgur.com/dCY0s8r.png) * 兩者的 throughput 並沒有太大的差異,而我的實做 scability 稍微高一些,不過因為前面提到的 `push` 會 spin 的緣故,兩者的 scability 及 throughput 都是隨著 thread 的增加而下降 #### insert 及 delete 的 throughput 和 scability * insert 和 delete 沒做 memory reclamation * Update: 100 次操作中有幾次為 push 或 pop 操作 * Size: 一開始 linked list 有多少 key,key 介於 0 ~ 2 * size * Count : 連續配置記憶體及釋放記憶體的次數(e.g: count = 1 表示配置完 1 個後接著釋放 1 個,反覆執行) * Throughput: push 及 pop 操作的次數 ![](https://i.imgur.com/vNLerDb.png) ![](https://i.imgur.com/OeoWLk3.png) ![](https://i.imgur.com/4aqoz2t.png) ![](https://i.imgur.com/lQLgcLZ.png) ![](https://i.imgur.com/hBm5XJz.png) ![](https://i.imgur.com/VMZYqOd.png) * 根據不同的 size、update、count 實驗觀察後無法明確指出什麼情況下我的實做會比較好,但從圖可以看到依據不同的 Size 及 Count,scability 及 throughput 都有些微的差異,有時我的實做較佳(如圖(二)(四)(五)),有時則是 glibc 的實做較佳 (如圖(一)(三)(六)) * 其中有發現當 `size=128/count=50 圖(四)` 蠻特別的 , glibc 的 scability 會在 thread 數為 4 以上就不再上升 , 不過 size 變大後就沒有這個情況了 `圖(五)、圖(六)` ## 參考資料 [How does malloc work in a multithreaded environment?](https://stackoverflow.com/questions/10706466/how-does-malloc-work-in-a-multithreaded-environment) [Begun/lockfree-malloc](https://github.com/Begun/lockfree-malloc) [Scalable Lock-Free Dynamic Memory Allocation](https://www.cs.tufts.edu/~nr/cs257/archive/neal-glew/mcrt/Non-blocking%20data%20structures/p35-michael.pdf)