# Linux 核心專題: RCU 研究和實作 > 執行人: JUSTUSVMOS ### Reviewed by `jserv` 如何驗證 RCU 實作行為符合預期且具備 lock-free/wait-free 特性? >1. 先看 RCU 讀取端的程式路徑,確認沒有用 mutex 或 spinlock 把 read 包起來,這樣 reader 才不會因為其他執行緒被卡住。 >2. 可以直接開 kernel 裡的 `CONFIG_PROVE_LOCKING` 或 `__rcu` sparse 這些工具,讓它幫你找出同步問題或 API 用錯的地方,比較保險。 >3. 直接跑 rcutorture 或 liburcu 壓力測試,把 reader 跟 writer 數量開大,看看讀端會不會被卡住,只要 writer 再怎麼寫,讀的都能順利執行就沒問題,效能數據也可以順便看一下。 :::danger 理工人員測試若只憑感覺、沒辦法拿出理論和量化分析,該如何保證品質呢? 使用本課程教材對於並行處理的分析方式,來探討上述問題。 ::: 解說錄影呢? ## TODO: 研讀 RCU 教材和學習 Userspace RCU > 紀錄閱讀課程教材 (以第一手材料為主) 過程中遇到的疑惑,充分討論 > 在自己的電腦上準備 Userspace RCU (URCU) 函式庫並進行評估,注意不得使用虛擬機器 ## TODO: 重作[第 15 週測驗題](https://hackmd.io/@sysprog/linux2025-quiz15)及其延伸題目 > 1. 解釋給定程式碼的原理,指出其缺失並予以改進 > 2. 開發得以搭配 Userspace RCU 和測驗題程式碼運作的效能評比程式,量化在高度並行場景的效能表現 > 3. 改進測驗題程式碼,運用於[高度並行的 Valkey 實作](https://hackmd.io/@sysprog/HyqlsB7Zxe),並評估其效能表現,隨後指出改進方案 ## 解釋給定程式碼的原理,指出其缺失並予以改進 ### 1. 批次更新(Batching) #### 1-1 原理 ``` writer thread(N 個) reader thread(M 個) ─────────────────── ───────────────────── loop { loop { ① atomic_exchange(ptr) rcu_read_lock() → 舊指標丟進 batch[] ···讀資料··· rcu_read_unlock() ② if batch 滿 或 超時 ┌────────────── flush ───────────────┐ │ a) synchronize_rcu(); │ │ b) free(batch[0..cnt-1]); │ │ c) batch_cnt = 0; │ └────────────────────────────────────┘ } ``` **為什麼快?** * 舊版:每筆更新都跑一次 `synchronize_rcu()` → 一次 GP ≅ 0.6–1 ms。 * 批次版:預設 **256 筆或 5 ms** 才跑一次 GP。 *在 20 µs 更新頻率下,GP 次數從每秒 \~800 次降到 \~40 次;寫端阻塞時間大幅減少。* **為什麼安全?** * RCU 只要求:「被淘汰的指標**釋放前**,所有當前 reader 都已離開臨界區」。 * 批次版仍在 `free()` 前執行 **一次** `synchronize_rcu()`,故滿足語義; 不管累積了 2 筆還是 200 筆,**只要 free 之前做過 GP,就不會出錯**。 #### 1-2 程式碼 ##### 1-2-1 批次參數與 thread-local 變數 ```c #define BATCH_SIZE 256 // 滿 256 筆就 flush #define BATCH_USEC 5000 // 或超過 5 ms 也 flush static __thread uint64_t *batch[BATCH_SIZE]; static __thread int batch_cnt = 0; static __thread uint64_t last_flush_us = 0; ``` *每個 writer thread 各自維護一組陣列與計數器,避免鎖競爭。* ##### 1-2-2 取得單調遞增時間 ```c static inline uint64_t now_us(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000000ULL + ts.tv_nsec / 1000ULL; } ``` *用 `CLOCK_MONOTONIC` 避免被 NTP、`date` 調整影響。* ##### 1-2-3 批次 flush 核心 ```c static void batch_flush(void) { if (batch_cnt == 0) return; // 沒東西就跳過 synchronize_rcu(); // ◎ 唯一昂貴步驟:等待 GP for (int i = 0; i < batch_cnt; i++) // 現在 reader 全退出 free(batch[i]); // 安全釋放 batch_cnt = 0; // 重置迴圈 last_flush_us = now_us(); } ``` > **安全性重點**:`free()` 緊跟在 `synchronize_rcu()` 之後,永遠不會早於 GP。 ##### 1-2-4 改寫 `update_global_state()`(Writer 快路徑) ```c static void update_global_state(void) { uint64_t *new_state = malloc(sizeof(*new_state)); *new_state = 5; // 新內容 uint64_t *old_state = atomic_exchange_explicit(&global_shared_state, new_state, memory_order_release); // ★ publish if (old_state) // 舊指標進批次表 batch[batch_cnt++] = old_state; if (batch_cnt == BATCH_SIZE || // 數量門檻 now_us() - last_flush_us >= BATCH_USEC) // 時間門檻 batch_flush(); // → GP+free } ``` * **`atomic_exchange(..., memory_order_release)`** * `release`:保證先寫入 `*new_state`,再讓其他 CPU 看到指標; reader 用 `acquire`/`consume` load 就能正確讀到資料。\* * **push → flush** * 只做 O(1) 陣列 push,極輕量。 * 進入 flush 時才真正阻塞。\* ##### 1-2-5 Writer 執行緒結束前的保險 flush ```c static void *updater_func(void *arg) { while (!exit_flag) { update_global_state(); usleep(update_interval_us); // user-side負載節流 } batch_flush(); // 結束前把殘餘指標回收 ... } ``` *確保程式結束時不留下尚待回收的記憶體。* ##### 1-2-6 Reader 臨界區(未改動) ```c rcu_read_lock(); uint64_t *p = atomic_load_explicit(&global_shared_state, memory_order_acquire); uint64_t val = *p; // 真正讀資料 asm volatile("" : : "r"(val) : "memory"); // 防 DCE rcu_read_unlock(); ``` * 使用 `memory_order_acquire` 對應 writer 的 `release`,確保指標→資料順序。 * 其餘 lock/unlock 仍僅是兩個 `atomic_signal_fence(seq_cst)`,成本極低。\* ##### 1-2-7 Fence F 仍保留「兩半 GP 分割」 ```c /* Fence F – closes first half of grace period. */ heavy_fence_seq_cst(); // membarrier:舊世界終點 ``` *無論批次與否,真正進入 GP 時仍需 **一次** membarrier 來截斷 A/F、C/F 之間的潛在亂序。* ##### 1-2-8 從 `gp_holdouts` futex 等待返回後的 Fence H ```c /* Fence H – pairs with Fence D. */ atomic_thread_fence(memory_order_seq_cst); ``` *確保「所有 reader 退出」→「釋放資料」有全域 SC 順序。* #### 1-3 效能與延遲摘要 | 指標(32R╳8W, 20 µs) | 每筆即 GP | 批次 256/5 ms | 說明 | | ----------------- | -------- | ------------ | ------- | | Updater 迴圈 /3 s | \~45 | **\~2500** | ↑約 60 倍 | | 平均 GP 延遲 | 60–90 ms | **0.8–1 ms** | ↓約 70 倍 | | Reader 吞吐 | 3–5×10⁸ | 3–5×10⁸ | 幾乎不變 | --- ### 2. 批次 + 延遲回收(Batching + Deferred Callback) #### 2-1 整體原理快速圖解 ``` 更新指標 ─▶┌───────────── Writer Thread (多個) ───────────────┐ │ ① atomic_exchange() 把舊指標拿下來 │ │ ② push 到 thread-local batch[ ]   │ │ ③ batch 滿 / 逾時 → batch_flush() ─────────────┐ │ └───────────────────────────────────────────────│─┘ │ ▼ call_rcu() ┌──────────────────────────────────┐ │ 全域 MPSC 佇列 cb_head   │ └──────────────────────────────────┘ │ (pop-all) ▼ ┌────────────────────────────────────────────┐ │ Reclaimer Thread (唯一) │ │ a. 偷走整批 callback list │ │ b. synchronize_rcu() → 1 次 GP │ │ c. 逐一執行 c->func(c->arg) (free 指標) │ └────────────────────────────────────────────┘ ``` * **批次(thread-local batch)** 把大量寫入先累積起來,一次 `batch_flush()` 送到全域佇列,降低每筆寫入的 CAS 次數與記憶體屏障。 * **延遲回收(call\_rcu + reclaimer)** Writer **永遠不自己跑 GP**;真正的 `synchronize_rcu()` 只在背景 reclaimer 執行緒中發生,寫端完全非阻塞。 #### 2-2 程式碼 ##### 2-2-1 thread-local 批次 (L1) ```c #define BATCH_SIZE 256 static __thread uint64_t *batch[BATCH_SIZE]; static __thread int batch_cnt = 0; // 已累積幾個 static __thread uint64_t last_flush_us = 0; // 上次 flush 的時間戳 ``` *每位 writer 都有自己的小池子;Writer path 只有 push 陣列,無鎖、無系統呼叫。* ##### 2-2-2 更新邏輯 (writer fast-path) ```c static void update_global_state(void) { uint64_t *new_state = malloc(sizeof(*new_state)); *new_state = 5; // 示範資料 uint64_t *old_state = atomic_exchange_explicit(&global_shared_state, new_state, memory_order_release); if (old_state) // ≈50 ns batch[batch_cnt++] = old_state; if (batch_cnt == BATCH_SIZE || now_us() - last_flush_us >= BATCH_USEC) batch_flush(); } ``` *1 筆更新 → 1× `atomic_exchange` + 1× push;絕大多數讀者場景耗時 < 100 ns。* ##### 2-2-3 `batch_flush()`──把 L1 丟進 L2 ```c static void batch_flush(void) { if (batch_cnt == 0) return; for (int i = 0; i < batch_cnt; i++) { // L1 → L2 struct rcu_cb *cb = malloc(sizeof(*cb)); // callback wrapper call_rcu(cb, free, batch[i]); // 延遲 free() } batch_cnt = 0; last_flush_us = now_us(); } ``` *此時 **沒有 GP**;Writer flush 完立即返回,繼續衝下一批。* ##### 2-2-4 `call_rcu()`──MPSC lock-free push ```c static _Atomic(struct rcu_cb *) cb_head = NULL; static void call_rcu(struct rcu_cb *c, void (*fn)(void *), void *arg) { c->func = fn; // 通常 = free c->arg = arg; struct rcu_cb *old; do { old = atomic_load_explicit(&cb_head, memory_order_relaxed); c->next = old; } while (!atomic_compare_exchange_weak_explicit( &cb_head, &old, c, memory_order_release, memory_order_relaxed)); } ``` *單純「把新節點掛到頭」的 CAS;保證 push 前的寫入對 reclaimer *acquire* 可見。* ##### 2-2-5 `reclaimer_fn()`──偷走→GP→free ```c while (!stop_reclaimer) { /* pop-all : 把佇列歸零並取得舊 head */ struct rcu_cb *list = atomic_exchange_explicit(&cb_head, NULL, memory_order_acquire); if (!list) { usleep(200); continue; } /* reverse 為 FIFO;保持原寫入時間順序 */ struct rcu_cb *rev = NULL, *n; while (list) { n=list->next; list->next=rev; rev=list; list=n; } synchronize_rcu(); // ----► 只跑一次 GP while (rev) { // 逐一清理 struct rcu_cb *c = rev; rev = rev->next; c->func(c->arg); // free(old_state) free(c); // 釋放 wrapper } } ``` *背景執行緒把所有 callback **一口氣** 搬走,**一次 GP** 就釋放整批。 GP 數量 = callback 佇列清空次數,遠低於寫入頻率。* ##### 2-2-6 啟動與關閉 reclaimer(`main()` 中) ```c pthread_create(&reclaimer_th, NULL, reclaimer_fn, NULL); /* … 整個壓力測試 … */ stop_reclaimer = true; pthread_join(reclaimer_th, NULL); ``` #### 2-3 記憶體順序保證 :::danger 務必使用課程規範的術語,見 https://hackmd.io/@sysprog/concurrency/%2F%40sysprog%2Fconcurrency-atomics 不要依賴大語言模型來產生內容,以課程教材和 Linux 核心第一手材料為主 ::: | 路徑 | 主要 atomic operation | 為何安全 | | ------------------- | --------------------------------------- | ------------------------------------------ | | Writer push L1 → L2 | `call_rcu()`:`store-release`(CAS 成功) | 保證 **舊指標內容** 先於 `cb_head` 更新對 reclaimer 可見 | | Reclaimer 取得 list | `exchange-acquire` | 讀到最新 list 之後,能看到所有節點 `arg` 實體 | | GP 之前 **不得 free** | `synchronize_rcu()` 強制等待所有 reader 離開舊世界 | 符合 RCU 安全定義 | | GP 之後 free() | Reader 此時最早也看到新指標,不會再解參考舊指標 | 無 use-after-free | --- 1. **Writer zero-blocking** *flush* 不再 `synchronize_rcu()`;背景 GP 與 writer 解耦。 2. **兩級批次** L1 (thread-local) → 降低 CAS 與共享寫流量; L2 (global stack) → 把 N×GP 壓成 1×GP。 3. **單 GP 釋放一大批** reclaimer 每秒只跑 10–50 次 membarrier,即使寫入頻率百萬級也撐得住。 --- ### 3. GP Coalescing(Grace-Period 合併) #### 3-1 原理總覽──為何「GP 合併」能再提速 ``` Writer (多個) ──► call_rcu(cb) ┌────────────┐ │ 全域 stack │ ← lock-free, MPSC └────────────┘ │ Reclaimer (唯一) ──► pop-all → ① 累積 batch[ ] │ ② if 256 筆 or 5 ms │ ③ synchronize_rcu() │ ← 只跑一次 GP ④ 逐一 free(arg) ▼ ``` * **舊法**:每筆 `call_rcu()` 就對應一次 `GP`(或小批次)。 * **改版**: *reclaimer* 先把 **所有** callback 偷進 `batch[256]`, 滿 256 筆或逾 5 ms 才 **一次** `GP + free()` → GP 次數理論上 ≈ (寫入次數 / 256),寫端完全非阻塞。 #### 3-2 重要參數 | 巨集 | 作用 | 預設 | | --------------------- | ------------- | ------- | | `COALESCE_BATCH_SIZE` | 單批最多 callback | 256 | | `COALESCE_TIMEOUT_US` | 最長等待時間 | 5000 µs | #### 3-3 程式碼 ##### 3-3-1 `call_rcu()` — lock-free MPSC push (L43) ```c do { old = atomic_load_explicit(&cb_head, memory_order_relaxed); c->next = old; } while (!atomic_compare_exchange_weak_explicit( &cb_head, &old, c, memory_order_release, memory_order_relaxed)); ``` * **store-release**:保證 *callback* 內容先寫好,再讓 reclaimer 看見新 head。 * 單 CAS → Writer path 成本 ≈ 50 ns。 --- ##### 3-3-2 Reclaimer 主迴圈 (L68) ```c struct rcu_cb *list = atomic_exchange_explicit(&cb_head, NULL, memory_order_acquire); ``` * **exchange-acquire**:偷走整條 stack,一次歸零;之後能讀到每個 `cb->arg`。 * 沒偷到東西時 `usleep(200)`,避免忙迴圈。 --- ##### 3-3-3 Batch 累積 + 超時判定 (L71-77) ```c while (list && cnt < COALESCE_BATCH_SIZE) { ... } bool timeout = (now_us() - last) >= COALESCE_TIMEOUT_US; if (cnt == COALESCE_BATCH_SIZE || timeout) ... // 同批執行 GP ``` * 兩種門檻:**數量** or **時間**,保證最長延遲 ≤ 5 ms。 * `now_us()` 採 `CLOCK_MONOTONIC`,不受 NTP 影響。 --- ##### 3-3-4 批次 GP + free (L78-83) ```c synchronize_rcu(); /* ◎ 僅此一次昂貴 GP */ for (unsigned i = 0; i < cnt; i++) { batch[i]->fn(batch[i]->arg); /* 通常 = free(ptr) */ free(batch[i]); /* 釋放包裝 */ } cnt = 0; last = now_us(); ``` * **安全性要點**:`free()` 永遠排在 **GP 之後**,符合 RCU 語意。 * 實務上 256 筆 × 8 bytes ≈ 2 KB → 批次成本極低。 --- ##### 3-3-5 Reclaimer 收尾 (L87-97) * 在 `stop_reclaimer` 置真後,再把殘餘 callback 清完;避免洩漏。 --- ##### 3-3-6 Updater 路徑 (L121-134) ```c uint64_t *oldp = atomic_exchange_explicit(&global_shared_state, newp, memory_order_release); if (oldp) { struct rcu_cb *cb = malloc(sizeof(*cb)); call_rcu(cb, free_cb, oldp); } ``` * Writer **不再自己 batch+GP**,而是直接交給 `call_rcu()`。 * 讓「資料結構更新」與「回收舊指標」完全解耦。 --- ##### 3-3-7 Thread-local 統計 (L106, 139) * `__thread unsigned long long iterations`:量化每執行緒工作量,便於觀察效能。 --- ##### 3-3-8 測試參數解析 (L147-158) * `-r/-u/-t/-i`:可快速調換 Reader/Updater 數與更新間隔,驗證不同負載下 GP 合併效果。 --- #### 3-4 記憶體順序總表(改版特有路徑) :::danger 務必使用課程規範的術語,見 https://hackmd.io/@sysprog/concurrency/%2F%40sysprog%2Fconcurrency-atomics 不要依賴大語言模型來產生內容,以課程教材和 Linux 核心第一手材料為主 ::: | 路徑 | 主要atomic operation | 保證 | | ------------------ | --------------------- | ------------------------- | | Writer → cb\_head | CAS **release** | callback 內容對 reclaimer 可見 | | Reclaimer 偷 list | `exchange-acquire` | 看到每個 `cb` 內 arg 的最新值 | | Reclaimer → free() | `synchronize_rcu()` 後 | Reader 不再持有舊指標,避免 UAF | --- #### 3-5 實測建議指標 | 測項 | 觀察重點 | | ----------------------- | ----------------------------------------- | | **Avg/Max GP 間隔** | 應落在 5 ms 以內,除非寫入頻率極低 | | **Updater p99 latency** | 理想值 ≈ `atomic_exchange` + malloc ≈ 200 ns | | **Reclaimer CPU 占用** | 高频寫入時大幅低於「每筆 GP」版本 | > 寫端阻塞 ≈ 0,GP 數量取決於「匯入速度」vs 「批次門檻」。 > 如需再壓縮延遲,可改成 **動態 BATCH\_SIZE**(根據 cb 積壓量調整)。 --- #### 3-6 這版比上一版強在哪? 1. **寫端零 GP**:`synchronize_rcu()` 全在背景執行緒,不再打斷 Updater。 2. **一次 GP 釋放 256 筆**:GP 成本被 **256 倍攤提**。 3. **最長延遲 5 ms**:兼顧高頻寫入與可控回收時限。 ### 4. 效能評估與比較 開發環境 ```shell pkg-config --modversion liburcu Linux carpenter666-NUC10i7FNH 6.11.0-26-generic #26~24.04.1-Ubuntu SMP PREEMPT_DYNAMIC Thu Apr 17 19:20:47 UTC 2 x86_64 x86_64 x86_64 GNU/Linux PRETTY_NAME="Ubuntu 24.04.2 LTS" NAME="Ubuntu" VERSION_ID="24.04" VERSION="24.04.2 LTS (Noble Numbat)" VERSION_CODENAME=noble CPU(s): 12 On-line CPU(s) list: 0-11 Model name: Intel(R) Core(TM) i7-10710U CPU @ 1.10GHz Thread(s) per core: 2 Socket(s): 1 CPU(s) scaling MHz: 16% CPU max MHz: 4700.0000 CPU min MHz: 400.0000 NUMA node0 CPU(s): 0-11 total used free shared buff/cache available Mem: 23Gi 5.3Gi 12Gi 557Mi 6.8Gi 17Gi Swap: 8.0Gi 0B 8.0Gi gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0 ldd (Ubuntu GLIBC 2.39-0ubuntu8.4) 2.39 liburcu 0.14.0 ``` [實驗影片](https://youtu.be/p7BOuMwSx-A?feature=shared) #### 4-1 實驗設計與流程 本次實驗以 32 個 reader、8 個 updater 執行緒為測試架構,統一測試時間 3000 ms,採用不同的 updater interval(-i)參數,觀察四種 RCU 優化版本的寫入與讀取效能。 | 測試參數 | 數值 | | ------------- | --------------- | | Readers | 32 | | Updaters | 8 | | 測試時間 | 3 秒 | | Updater 間隔 -i | 1、2、5、20、100 μs | * main:每筆寫入都同步一次 GP * main\_delay:每筆寫入加上 usleep 間隔後執行 GP * main\_batch:延遲回收,累積 256 筆或 5ms 一次 GP(寫端負責) * main\_gpc:寫端僅送 callback,GP 由單一 reclaimer thread 合併處理 --- #### 4-2 數據總表與曲線圖 ##### 4-2-1 Updater / Reader Throughput 統計(每 3 秒) :::danger 改進以下: 1. 實驗環境的描述 2. 實驗方法 3. 實驗的檢討 4. 附上對應的展示影片,確保是自己進行的實驗 ::: | 版本 | -i=1 μs | -i=2 μs | -i=5 μs | -i=20 μs | -i=100 μs | | ----- | -------------- | -------------- | -------------- | ------------- | -------------- | | main | \~107 / 3.27e9 | \~107 / 2.96e9 | \~104 / 2.96e9 | \~95 / 2.96e9 | \~103 / 2.96e9 | | delay | 120,147/2.69e9 | 118,031/2.67e9 | 113,834/2.70e9 | 93,502/2.72e9 | 48,012/2.80e9 | | batch | 7,604/2.94e9 | 7,870/2.95e9 | 7,244/2.94e9 | 6,313/2.96e9 | 2,868/2.95e9 | | gpc | 118,740/2.67e9 | 117,447/2.66e9 | 112,167/2.68e9 | 91,890/2.71e9 | 47,948/2.79e9 | (單位:Updater 次/3秒 / Reader 次/3秒;科學記號 e.g. 2.96e9 = 2,960,000,000) ![throughput_both](https://hackmd.io/_uploads/BylwOrSrlg.png) *圖:各 RCU 策略下,隨 updater 間隔 -i 改變,Updater 與 Reader 吞吐量的變化(橫軸 log 標示)* #### 4-2 重點觀察 1. **main(每筆都同步 GP)** * Updater throughput 長期受限,每 3 秒僅能做數十次更新,主因為每筆都要同步一個長 GP(60–90ms)。 * Reader throughput 維持高水準,受寫端影響極小。 * 嚴重不適合高頻寫入。 2. **main\_delay(每筆 usleep + GP)** * Updater throughput 明顯提升(達數萬等級),因 usleep 休眠遠大於 GP 時間,GP 開銷被藏在排程延遲內。 * Reader throughput 與 main 類似。 * 若 usleep(-i)縮短到小於系統定時粒度,updater 速度會再被 OS 實際排程限制(如 2μs 實際睡 60μs)。 3. **main\_batch(累積批次 GP)** * Updater throughput 約落在數千級,明顯優於 main,但低於 main\_delay/gpc。 * 原因:每累積 256 筆才做一次 GP,但 flush、malloc 等仍有 overhead。 * Reader throughput 穩定。 4. **main\_gpc(延遲回收+reclaimer 合併 GP)** * Updater throughput 可逼近 main\_delay,甚至更高(因 GP 全部由背景 reclaimer 集中執行)。 * Reader throughput 保持高檔。 * 在 -i 極小時,main\_gpc 才能進一步展現高頻寫端效能上限。 --- #### 4-4 各機制比較與適用場景 | 機制 | Updater 吞吐 | Reader 吞吐 | GP 開銷主控點 | 適用場景 | | ----------- | ---------- | --------- | ------------- | ------- | | main | 最差 | 高 | 每筆更新後執行 | 低頻寫,高頻讀 | | main\_delay | 極佳(看 -i) | 高 | usleep > GP | 寫端負載中等 | | main\_batch | 普通 | 高 | 256 筆合併一次 GP | 間歇高頻寫 | | main\_gpc | 最佳 | 高 | 寫端僅送 callback | 高頻寫、低延遲 | * 若寫端很密集、要求可擴展性,建議採用 main\_gpc。 * 若系統以讀端為主、寫端較少,可用 main 或 main\_batch 。 * main\_delay 僅適合在更新頻率本就低、usleep 延遲大於 GP 延遲的場合。 --- ## 開發得以搭配 Userspace RCU 和測驗題程式碼運作的效能評比程式,量化在高度並行場景的效能表現 #### 5-1. 實驗設計與方法 承接前章 mini-RCU 四個版本(main、delay、batch、gpc)的測試架構,本章新增參考實務界廣泛應用的 **Userspace RCU (liburcu)** 進行同場壓力測試。 測試條件完全一致(32 reader、8 updater、3 秒、-i 1/2/5/20/100),確保能直接比較不同實作的真實吞吐極限與行為。 ##### 5-1-1 測試版本摘要 | 版本 | 實現方式簡述 | | ------- | --------------------------------------- | | main | 每筆寫入即同步 GP | | delay | 每筆寫入前 usleep,仍每筆 GP | | batch | 累積 256 筆/5ms,批次 GP | | gpc | 寫端僅送 callback,集中合併 GP | | liburcu | 官方 API,call\_rcu 與 rcu\_read\_lock 典型用法 | #### 5-2. 測試結果總表 | 版本 | -i=1 | -i=2 | -i=5 | -i=20 | -i=100 | 讀端吞吐 (範例) | | ----------- | ------- | ------- | ------- | ------ | ------ | ---------- | | main | 109 | 106 | 106 | 116 | 104 | 3.2e9 | | delay | 119,716 | 119,010 | 113,491 | 92,655 | 47,925 | 2.7e9 | | batch | 7,786 | 8,101 | 7,410 | 5,515 | 2,720 | 2.9e9 | | gpc | 117,598 | 115,839 | 111,514 | 91,508 | 47,741 | 2.6e9 | | **liburcu** | 116,591 | 116,232 | 111,783 | 91,775 | 47,807 | **3.99e8** | *單位:Updater 次/3秒,Reader 次/3秒(科學記號 e.g. 3.2e9 = 3,200,000,000)* ![throughput_both_professional](https://hackmd.io/_uploads/B1CNOSrrxg.png) :::danger 文字訊息不要用圖片展現,確認圖片的存取權限 ::: ##### 5-3. 主要觀察 1. **寫端效能比較** * liburcu 與 gpc 兩者寫入吞吐**極為接近**,尤其在 -i 1/2/5/20/100 µs 各級都能維持在十萬級別,說明只要將 GP 徹底 decouple,user-level 實作亦能達到專業函式庫極限。 * main 版僅百次等級,batch 僅數千,效能落差明顯。 * delay 在 usleep 主導下也能達到極高寫入效能,但理論上限受排程粒度影響。 2. **讀端效能比較** * **liburcu 讀端吞吐量明顯高於所有自製版本**,最高約 3.99e8,反映其 QSBR/MEMBARRIER 等高效讀端設計。 * mini-RCU 各版本讀端雖也維持 2.5e9 以上,但在極高並發下可能受到臨界區、鎖存、fence 等機制影響而有微幅落差。 3. **寫入/讀取雙極限情境** * 若同時要求寫入高頻與讀端超高吞吐,liburcu 仍有整體最佳可擴展性與穩定性。 * gpc 版雖已充分 decouple GP,但在部分排程與記憶體同步細節上仍可能落後 liburcu 一小段距離。 ## 改進測驗題程式碼,運用於高度並行的 Valkey 實作,並評估其效能表現,隨後指出改進方案 ### 1. 目標 | 編號 | 目標敘述 | |-----:|----------| | **G1** | 把自製 **mini-URCU hash table** 以 Module 方式植入 **Valkey**,驗證「讀多寫少」場景能否 **降低延遲、提升吞吐**。 | | **G2** | 與 Valkey 原生 `SET/GET` 進行 **定量比較**(ops/sec、avg / p95 / p99 latency)。 | | **G3** | 探索 **桶數** 與 **GP 批次回收 (GPC)** 對寫入效率的影響,提出最佳化指引。 | --- ### 2 核心實作 #### 2-1 mini-URCU Hash-Table(`rcu_ht.c`) ```c /* ① 建表:2^pow2 個 bucket,各自一把 mutex */ struct rcu_ht *ht_create(unsigned pow2) { size_t n = 1ULL << pow2; struct rcu_ht *ht = calloc(1, sizeof *ht); ht->nb = n; ht->b = calloc(n, sizeof(struct bucket)); for (size_t i = 0; i < n; i++) pthread_mutex_init(&ht->b[i].mtx, NULL); return ht; } /* ② 寫入:Copy-Update → rcu_assign_pointer → call_rcu() 延遲 free */ int ht_insert(struct rcu_ht *ht, const char *k, const char *v) { struct node *n = malloc(sizeof *n); n->k = strdup(k); n->v = strdup(v); size_t i = hash(k) & (ht->nb - 1); pthread_mutex_lock(&ht->b[i].mtx); n->next = rcu_dereference(ht->b[i].head); rcu_assign_pointer(ht->b[i].head, n); pthread_mutex_unlock(&ht->b[i].mtx); return 0; } /* ③ 刪除:unlink 後 call_rcu() 回收 */ static void free_node_cb(struct rcu_head *rh) { struct node *n = caa_container_of(rh, struct node, rh); free(n->k); free(n->v); free(n); } ```` * **讀側**:`rcu_read_lock/unlock()`,零鎖、零系統呼叫。 * **寫側**:Copy-Update + **GPC** —— 單一 reclaimer 執行緒每 **5 ms / 256 node** 觸發一次 `synchronize_rcu()`。 --- #### 2-2 Valkey 模組(`rcuht_module.c`) ```c #define HT_BUCKET_POW2 17 /* 131 072 bucket ⇒ load-factor ≈0.75 */ static struct rcu_ht *HT; /* RCU.HT.SET key val */ int RCUHT_Set(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (argc != 3) return RedisModule_WrongArity(ctx); size_t klen,vlen; const char *k = RedisModule_StringPtrLen(argv[1], &klen); const char *v = RedisModule_StringPtrLen(argv[2], &vlen); ht_insert(HT, k, v); return RedisModule_ReplyWithSimpleString(ctx, "OK"); } /* RCU.HT.GET key */ int RCUHT_Get(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (argc != 2) return RedisModule_WrongArity(ctx); size_t klen; const char *k = RedisModule_StringPtrLen(argv[1], &klen); char *val = ht_lookup(HT, k); if (!val) return RedisModule_ReplyWithNull(ctx); return RedisModule_ReplyWithStringBuffer(ctx, val, strlen(val)); } /* Module 初始化 */ int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { RedisModule_Init(ctx, "rcu.ht", 1, REDISMODULE_APIVER_1); HT = ht_create(HT_BUCKET_POW2); RedisModule_CreateCommand(ctx, "rcu.ht.set", RCUHT_Set, "write", 0,0,0); RedisModule_CreateCommand(ctx, "rcu.ht.get", RCUHT_Get, "readonly", 0,0,0); return REDISMODULE_OK; } ``` --- #### 3 實驗環境與流程 | 項目 | 規格 | | ------- | ------------------------------------------------------------------------- | | CPU | Intel® NUC 10 i7 (8c/16t) | | OS | Ubuntu 24.04 LTS × Linux 6.8 | | Valkey | commit `293be3e3` + `rcuht.so` | | liburcu | 0.14 (memb) | | 測試工具 | `valkey-benchmark` (500 k req / 500 conn / pipeline 16 / key space 100 k) | > 啟動 command > > ```bash > # native 6379 > ./src/valkey-server --port 6379 & > # RCU-HT 6380 > ./src/valkey-server --loadmodule ./src/modules/rcuht.so --port 6380 & > ``` :::danger 注意用語! ::: ### 4 量測結果(最終) | 實作 (port) | 指令 | Throughput (ops/s) | Avg Latency (ms) | | ---------------- | ----- | ------------------ | ---------------- | | **RCU-HT** :6380 | `GET` | **902 527** | **7.95** | | | `SET` | **145 349** | **0.70** | | **Native** :6379 | `GET` | 1 179 245 | 6.01 | | | `SET` | 970 874 | 7.48 | (吞吐取 `rps`;延遲取 `avg_latency_ms`,皆來自 `valkey-benchmark --csv` 輸出) <table> <tr> <th>Throughput (ops/sec)</th> <th>Average latency (ms)</th> </tr> <tr> <td><img src="https://hackmd.io/_uploads/SkkJuBSHgl.png)" width="450"></td> <td><img src="https://hackmd.io/_uploads/HJJWOBSrle.png" width="450"></td> </tr> </table> :::danger 修正圖片的存取權限 :::