owned this note
owned this note
Published
Linked with GitHub
## Linux 核心專題: RCU 實作
> 執行人: eleanorLYJ
> [專題解說錄影](https://youtu.be/BB_tW7bQSlw)
### Reviewed by `dcciou`
是否有考慮過如何在維持CTP的優點下改善其成本的問題
### Reviewed by `yuyuan0625`
1. 下文[記憶體使用量](https://hackmd.io/2Jv0y1OpRgG0IdlcBD9Snw?both#%E8%A8%98%E6%86%B6%E9%AB%94%E4%BD%BF%E7%94%A8%E9%87%8F1)段落的圖片無法讀取,可能要再重新貼上
2. 想請問為何在更多的 reader 的情境,slot-list 就沒有明顯有快取優勢呢?
### Reviewed by `Ackerman666`
感覺這句話有點不嚴謹,應該是始於 writer 修改資料後呼叫 `synchronize_rcu()`
> RCU 寬限期(RCU Grace Period,以下縮寫GP):這是一個時間窗口,開始於 writer 開始修改 RCU 保護資料
### Reviewed by `Shiang1212`
> 操作:提供 O(1) 無鎖讀取,並由序列化 writer 在 O(N log(M)) 時間內垃圾收集未引用的值。
請問這裡的 N、M 是指什麼?請附上說明。
## 任務簡介
Userspace RCU 已成功將 Linux 核心的 RCU 的機制帶到若干應用場域,不過其程式碼規模龐大、整合較困難,且授權條款較嚴格 (LGPL 2.1),於是本任務嘗試評估 [ctp: C Thread Primitives](https://github.com/nicowilliams/ctp),探討是否可作為 userspace RCU 的替代選擇。
## TODO: 理解 RCU
> 閱讀 [Linux 核心設計: RCU 同步機制](https://hackmd.io/@sysprog/linux-rcu), [Is Parallel Programming Hard, And, If So, What Can You Do About It?](https://mirrors.edge.kernel.org/pub/linux/kernel/people/paulmck/perfbook/perfbook.html) 的第九章 Deferred Processing,紀錄自己的認知和相關問題
RCU(Read-Copy-Update)是一種高效的同步機制,主要用於多執行緒環境中讀多寫少,並且 reader 端要求高效但對新舊資料不敏感的場景。RCU 允許多個 reader 並行讀取共享資料,而不需要阻塞寫操作,從而效能提升。
### RCU 兩個主要元件
1. RCU 讀側臨界區(RCU Read-Side Critical Section,以下縮寫 cs):這是一段由 `rcu_read_lock()` 和 `rcu_read_unlock()` 包圍的程式碼區域,處於這段區域內的 reader 可以安全地讀取 RCU 保護的共享資料。
2. RCU 寬限期(RCU Grace Period,以下縮寫GP):這是一個時間窗口,開始於 writer 開始修改 RCU 保護資料,至在GP開始前所有的 reader 結束的時間區間。RCU 保證所有在 GP 開始之前進入 cs 的 reader 都已經完成。`synchronize_rcu()` 用於等待一個寬限期的結束。
藉此確保每個 reader 對於不同物件保有前後一致的讀取。 (coherent view of each object)
補充 RCU 核心 API
![Core RCU API](https://hackmd.io/_uploads/SJRfT8ZIA.png)
### RCU 基礎
為了實作 RCU 的特性,RCU 有了三個重要基礎:
1. publish-subscribe 機制
2. 等待已經存在的 RCU readers
3. 維護最近更新物件的多個版本
<!-- 討論為什麼不 plain access,RCU 都是保護 pointer -->
#### publish-subscribe 機制
reader 的讀取視為對目前版本的 RCU 保護資料的訂閱,而 updater 的操作則視為發布新版本。
RCU 的 publish-subscribe 機制是藉由兩個 primitive 達成的:
**Publish**
Updater 用 `rcu_assign_pointer()` 這 primitive 做到確保在實際 publish 前這 pointer 的 store 操作的順序正確,這意味著用 memory barrier 防止 pointer 更新之後再執行寫入的操作。
在 Linux kernel, `rcu_assign_pointer()` 是按照 `smp_store_release()` 定義的。
updater 程式碼範例:
```c=
new_value->a = 1;
new_value->b = 2;
new_value->c = 3;
rcu_assign_pointer(global_ptr, new_value); // 保證將 new_value assign 給 pointer 發生於 line 1-3 之後
```
:::danger
注意用語:
* access 是「存取」,而非「訪問」
務必詳閱 https://hackmd.io/@sysprog/it-vocabulary
:::
**Subscribe**
Reader 用 `rcu_dereference()` 這 primitive 做到確保實際資料被<s>訪問</s> 前 load 操作順序正確,而這代表有使用 memory barrier,確保資料的一致性。
reader 程式碼範例:
```c
struct data *ptr = rcu_dereference(global_ptr);
int value = ptr->value;
```
- `rcu_dereference(global_ptr)` 這行程式碼確保 `global_ptr` 被安全地讀取,並且其指向的資料在此之後的讀取操作是有序的,沒有被重新排序。
- `ptr->value`: 實際讀取到 global_ptr 所指向的資料
### 等待已經存在的 RCU readers
這一部分是更深入的討論寬限期(GP)的規則
對於 RCU reader
(1) 如果給定 RCU 讀側臨界區間 (cs) 的任何部分先於 GP 的開始,則該 cs 的整體會先於該 GP 的結束。
(2) 如果給定 RCU cs 的任何部分在 GP 結束之後,則該 cs 的整個部分在該 GP 開始之後。
**RCU 禁止讀端臨界區與寬限期完全重疊**
以下圖片為例說明 cs 與 GP 的執行順序的關係,並且說明為什麼最終 r1 的值為1,而 r2 的值是0。
P0() 是 reader,P1() 是 updater。
![image](https://hackmd.io/_uploads/HkNrfw-LC.png)
`P0()` 進入 RCU 的 cs (`rcu_read_lock()`) 並且在這個區域內讀取變數 `x` 和 `y`。
`P1()` 修改變數 `x` 並設置為 `1`,然後呼叫 `synchronize_rcu()` 開始 RCU GP。在 GP 結束後,`P1()` 修改變數 `y` 並設置為 `1`。
因為`P1()` 在 GP 開始之前將 `x` 設置為 `1`,所以 `r1` 讀取到 `1`。`P1()` 在 GP 結束後將 `y` 設置為 `1`,所以 `r2` 讀取到寬限期開始之前的值,即 `0`。
:::danger
注意用語: call 是「呼叫」,而非「調用」。
參見 [資訊科技詞彙翻譯](https://hackmd.io/@sysprog/it-vocabulary) 和 [詞彙對照表](https://hackmd.io/@l10n-tw/glossaries)
:::
**摘要: RCU 寬限期 的順序保證** (Summary of RCU Grace-Period Ordering Guarantees)
以下討論四個可能情況,圖示中的左方塊都是 RCU reader,右方塊是 RCU updater。
第一種情況:
reader 在 updater 開始刪除之前執行,因此該 reader 可能擁有對已刪除資料元素的參考。因此,updater 在 reader 完成之前不得釋放該元素。
![image](https://hackmd.io/_uploads/HkH8EPWUC.png)
第二種情況:
reader 直到刪除完成後才開始執行。reader 不可能獲得對已刪除的資料元素的引用,因此可以在 reader 完成之前釋放該元素。
![image](https://hackmd.io/_uploads/HJA8EDZI0.png)
第三種情況與第二種情況類似,但說明即使 reader 不可能獲得對元素的引用,仍然允許推遲該元素的釋放,直到 reader 完成之後。
![image](https://hackmd.io/_uploads/r1uD4vb8C.png)
在第四個也是最後一個場景中,reader 在 updater 開始刪除資料元素之前進入 cs,但該元素在 reader 完成之前被(錯誤地)釋放,然而,正確的 RCU 實作不會允許第四種情況發生!
![image](https://hackmd.io/_uploads/By0DVDZ8R.png)
### 維護最近更新物件的多個版本
由於 無同步(synchronization-free)的 reader 提供的時間同步非常弱,RCU 透過空間同步進行補償。
多個弱一致性的板本會帶來一些意外,以下為例:
reader 存取同時也在更新的鏈結串列
1. 在圖中的第一行中,reader 引用 A
2. 在第二行中,它前進到 B,到目前為止已經看到了 A,然後是 B
3. 在第三行中,updater 刪除元素 A
4. 在第四行中,updater 將元素 E 新增至串列末端
5. 在最後一行中,reader 完成了存取,看到了元素 A 到 E
![image](https://hackmd.io/_uploads/ry-p9vbLC.png)
不同時間存取的 reader 會得到不同結果,例如再一次存取鏈結會得到 B 到 D 的結果,但在這特殊情況取得 A 到 E 的結果也是合法的。倘若 RCU 的不一致性會造成問題,可以用更強的同步機制或是使用 timestamps 與 versioning 等方式處理,但這些處理會造成成本增加。
## TODO: 探討 [CTP](https://github.com/nicowilliams/ctp) 實作 RCU 哪些關鍵特徵
> CTP 專案建構在 thread-safe variable (TSV),確保 reader 執行緒在無 lock 的情況下運行,且不會被 writer 阻塞。另一方面,writer 會等待最後引用的 reader 執行緒完成讀取後才會寫入。
### 什麼是 thread-safe variable(tsv)?
CTP 專案中提及的 tsv 本身並不是一個廣泛被認可的理論概念,但它像是結合了 [thread safety](https://en.wikipedia.org/wiki/Thread_safety)和 [thread specific data](https://www.ibm.com/docs/ro/aix/7.2?topic=programming-thread-specific-data) 的概念,確保多個執行緒安全並行存取變數的具體實作。
在 CTP 中,提供一種機制能確保資料在多執行緒中保持一致性並且防止 race condition 等問題,其中在某實作中(如 slot-list),每個 reader 執行緒可以有自己的 slot 能夠儲存目前的副本,這類似於特定執行緒的資料,但透過安全機制更新和管理這些值。
根據 [Using Thread Safe Variables to Protect Data](https://www.ni.com/docs/en-US/bundle/labwindows-cvi/page/cvi/programmerref/threadsafevariable.htm),得知關於 tsv 好處:
- 避免常見的多執行緒錯誤,如在存取變數之前忘記取得鎖的常見錯誤。
- 易於資料傳遞,只需傳遞 thread-safe variable handle,即可在函數之間傳遞受保護的資料,而無需同時傳遞 thread-lock handle 和受保護的變數。
:::danger
data 是「資料」
:::
CTP 使用兩種主要實作來實作執行緒安全變數: slot-pair 與 slot-list
### ctp 目前有六個函式能被使用
`int thread_safe_var_init(thread_safe_var *vpp, thread_safe_var_dtor_f dtor)`: 初始化 tsv 變數的所有成員
`thread_safe_var_destroy`
`int thread_safe_var_get(thread_safe_var vp, void **res, uint64_t *version)`: reader 取得變數的目前值與其版本,此外管理引用計數 (reference counting) 和 slot 選擇。
`void thread_safe_var_release(thread_safe_var vp)`: 清掉執行緒上的特定資料
`int thread_safe_var_set(thread_safe_var vp, void *data, uint64_t *new_version)`: data 是要寫入結構為 value * 的值(new_value),vp 是 Pointer to thread-safe global variable 與 new_version 則會填入 new_value 的版本號。返回 0 代表成功。管理記憶體並確保只有在 readers 完成讀取後才更新。
`int thread_safe_var_wait(thread_safe_var vp)` : 當還沒有任何資料被寫入時,reader 需要等待,直到資料被寫入。
## TODO: Slot-Pair 與 Slot-List 的差異
### Slot-Pair
結構:每個 tsv 都有兩個 slot,一個用於目前資料,另一個用於上一個或下一個資料
操作:具有 O(1) 無鎖和 spin-less 讀取以及 O(1) 序列化(serialized)寫入。
reader 行為:
reader 呼叫 free() 和值析構函數(value destructor),並且可能需要向潛在等待的 writer 發出訊號,這涉及獲取互斥鎖。儘管這在技術上是阻塞的,但它通常是在無競爭的資源上。
writer 行為:
writer 將"上一個" slot 切換到"目前" slot。讀者從目前指定的 slot 讀取。
writer 會等待,直到前一個 slot 沒有活躍的 reader 為止。該值是引用計數的,因此當最後一個引用被刪除時會自動釋放。
### Slot-List
操作:提供 O(1) 無鎖讀取,並由序列化 writer 在 O(N log(M)) 時間內垃圾收集未引用的值。
結構:維護引用值的鏈結串列,串列的頭部始終是目前值。它還維護一個 subscription slots ,每個 reader 執行緒都有一個 slot。第一次讀取時,reader 分配一個 slot 並將值串列的頭部複製到其 slot 中。
reader 行為:
subscription slots 分配是無鎖的。reader 中的所有內容都是無鎖的
writer 行為:
writer 對引用值的鏈結串列執行垃圾回收(Garbage Collection, GC)。值在最後一個引用被刪除後的第一次寫入時被釋放,因為它們被 writer 回收。
### API 的實作
在 threead_safe_global.h 中表示, `thread_safe_var` 將替代 `thread_safe_var_s *`
```c
typedef struct thread_safe_var_s *thread_safe_var;
```
#### `thread_safe_var_wait(thread_safe_var vp)`
這是 slot-pair 與 slot-list 共有的函式,其目的是等著 writer 第一次將值寫入。
倘若已經 能從 vp 取得到值,那就不用等了,直接 `return 0`。
其餘狀況,先鎖定 waiter_lock,之後使用 `pthread_cond_wait()` 阻塞,直到能讀到 writer 第一次寫入值,之後用 pthread_cond_signal() 喚醒等待在指定條件變數上的一個執行緒,之後同樣再用 pthread_cond_signal() 逐一喚起其他的 waiter。
```c
if (thread_safe_var_get(vp, &junk, NULL) == 0 && junk != NULL)
return 0;
pthread_mutex_lock(&vp->waiter_lock);
while (thread_safe_var_get(vp, &junk, NULL) == 0 && junk == NULL) {
if (pthread_cond_wait(&vp->waiter_cv, &vp->waiter_lock) != 0) {
pthread_mutex_unlock(&vp->waiter_lock);
return err;
}
}
```
最後,跟 futex 一樣,為了防止 [thundering herd problem](https://en.wikipedia.org/wiki/Thundering_herd_problem) (驚群問題),呼叫 `pthread_cond_signal(&vp->waiter_cv)` ,而不是用廣播的方式,確保所有等待的 reader 執行緒是逐一喚醒。
```c
pthread_cond_signal(&vp->waiter_cv);
pthread_mutex_unlock(&vp->waiter_lock);
```
### slot-pair
tsv 中有兩個 slot,一個 slot 存 "current" value,另一個 存 "previous"/"next" value 。
writer 將 "previous" slot 放入下一個 "current" slot,而 reader 從看起來是 "current" slot 中讀取。
#### 資料結構
核心的資料在 vwrapper 中,var 為 vwrapper 的 wrapper, 而在 thread_safe_var_s 中存有 一對 slot 另外還有多個條件變數與鎖。
```c
/* This is an element on the list of referenced values */
struct value {
volatile struct value *next; /* previous (still ref'd) value */
void *value; /* actual value */
volatile uint64_t version; /* version number */
volatile uint32_t referenced; /* for mark and sweep */
};
/*
* Each thread that has read this thread-safe global variable gets one
* of these.
*/
struct slot {
volatile struct value *value; /* reference to last value read */
volatile uint32_t in_use; /* atomic */
thread_safe_var vp; /* for cleanup from thread key dtor */
/* We could add a pthread_t here */
};
/*
* Slots are allocated in arrays that are linked into one larger logical
* array.
*/
struct slots {
volatile struct slots *next; /* atomic */
struct slot *slot_array;/* atomic */
volatile uint32_t slot_count; /* atomic */
uint32_t slot_base; /* logical index of slot_array[0] */
};
struct thread_safe_var_s {
pthread_key_t tkey; /* to detect thread exits */
pthread_mutex_t write_lock; /* one writer at a time */
pthread_mutex_t waiter_lock; /* to signal waiters */
pthread_cond_t waiter_cv; /* to signal waiters */
var_dtor_t dtor; /* value destructor */
volatile struct value *values; /* atomic ref'd value list head */
volatile struct slots *slots; /* atomic reader subscription slots */
volatile uint32_t next_slot_idx; /* atomic index of next new slot */
volatile uint32_t slots_in_use; /* atomic count of live readers */
uint32_t nvalues; /* writer-only; for housekeeping */
};
```
#### `thread_safe_var_get`
此函數目的: reader 會取 tsv (vp)的版本號得知目前 slot 是哪個,接著讀取這 slot 的值 (wrapper),並且將引用計數加一,接著 reader 將會保留此 wrapper 當作執行緒的特定資料。
以下一步步講解函式組成:
`get` 的快速路徑為,如果這個執行緒擁有的 `wrapper` (`pthread_getspecific(vp->tkey)`) 與 `tsv` 的 `next_version - 1` 一致,就將 `wrapper` 的版本與實際值(`ptr`)存回參數中,並結束函式。
```c
if ((wrapper = pthread_getspecific(vp->tkey)) != NULL &&
wrapper->version == atomic_read_64(&vp->next_version) - 1) {
/* Fast path */
*version = wrapper->version;
*res = wrapper->ptr;
return 0;
}
```
在確定 slot 為目前最新的 slot 的迴圈中,會與 writers 競爭。先讀取 version 之後讀取 slot (`v`),之後判斷如果 `atomic_read_64(&vp->next_version) == (*version + 1)` 代表中途是否有其他執行緒在競爭,如果等於,這就確定 slot。反之不等於並且如果 slot 的 活躍 reader (nreader) 是 0,就 `signal_writer()`通知 writer 寫入,其方式是使用 `vp->cv` 條件變數告知 writer ,而 reader 則繼續在此迴圈,直到確定好 slot。
```c
static int signal_writer(thread_safe_var vp)
{
pthread_mutex_lock(&vp->cv_lock);
err = pthread_cond_signal(&vp->cv);
return pthread_mutex_unlock(&vp->cv_lock);
}
```
選擇好 `slot` 後,就可以將 `slot` 中的 `vwrapper` 引用加一,並將 `vwrapper` 的值寫進 `*version` 與 `*res` 中。
接著表示讀取完畢,將 `slot` 的活躍 reader 減一,然而此時,活躍 reader 等於 0,如果執行緒的最新版不是此目前 `slot` 的版本,那就要 `signal_write` ,因為代表有 writer 在等待。這樣的作法是要確保讀寫操作的同步,避免競爭。
```c
if (atomic_dec_32_nv(&v->nreaders) == 0 &&
atomic_read_64(&vp->next_version) != (*version + 1))
signal_writer(vp);
```
最後設置新的 `wrapper`。
```c
pthread_setspecific(vp->tkey, wrapper);
```
#### `thread_safe_var_set`
首先,構建一個新的 `wrapper` 變數來保存新的值。
```c
wrapper = calloc(1, sizeof(*wrapper)
wrapper->dtor = vp->dtor;
wrapper->nref = 0;
wrapper->ptr = cfdata;
```
接著,使用 `pthread_mutex_lock(&vp->write_lock)` 來鎖定寫入操作,這也起到 memory barrier 的作用,以確保之前的寫操作完成,寫入操作是序列化。隨後,將新 `wrapper` 的版本號設置為 `vp->next_version`,也將這個版本號返回給 `new_version`。
接著,獲取下一個 slot `v`。
如果是這 tsv 第一次寫入,則將這新 `wrapper` 設置在兩個 slot 上。每個 slot 的 wrapper 引用計數(`nref`) 增加一次,並將這些 slot 的 wrapper 更新為新的 `wrapper`。然後,將 `vp->next_version` 增加一,並藉由 `pthread_cond_signal(&vp->waiter_cv)`通知等待的 reader ,讀取新的值。最後,解鎖寫入操作並結束函式。
如果這不是第一次寫入,則這新 `wrapper` 的引用計數增加一次。然後,等待直到 slot 沒有活躍的 reader。如果有活躍的 reader,則使用 `pthread_cond_wait(&vp->cv, &vp->cv_lock)` 不斷等待,直到所有 reader 釋放 `cv`。
```c
while (atomic_read_32(&v->nreaders) > 0) {
pthread_cond_wait(&vp->cv, &vp->cv_lock)) != 0);
```
接著,解鎖 `cv_lock` 後,將 `v->wrapper` 更新為新的 `wrapper`,並將 `v->version` 和 `vp->next_version` 都增加一。最後,釋放舊的 `wrapper`,並解鎖 `write_lock`。
這個過程與 QSBR(Quiescent State Based Reclamation)很相似,只有在最後一個活躍的 reader 不再讀取後,才能寫入新值並回收舊值。
:::danger
如何確保上述流程可正確運作?
:::
### slot-list
#### 資料結構
```c
struct value {
volatile struct value *next; /* 指向之前的(仍引用的)值 */
void *value; /* 實際值 */
volatile uint64_t version; /* 版本 */
volatile uint32_t referenced; /* 用於標記和清除 */
};
/*
* 每個讀取此 tsv 的執行緒都會獲得其中一個。
*/
struct slot {
volatile struct value *value; /* 最後讀取的值的引用 */
volatile uint32_t in_use;
thread_safe_var vp; /* for cleanup from thread key dtor */
};
/*
* slots 被分配在一個陣列中,而這陣列會被連結到一個更大的邏輯陣列。
*/
struct slots {
volatile struct slots *next;
struct slot *slot_array;
volatile uint32_t slot_count;
uint32_t slot_base; /* slot_array[0] 的索引*/
};
struct thread_safe_var_s {
pthread_key_t tkey; /* 檢測執行緒退出 */
pthread_mutex_t write_lock; /* 一次一個 writer */
pthread_mutex_t waiter_lock; /* 向 waiters 發出訊號時用 */
pthread_cond_t waiter_cv; /* 向 waiters 發出訊號 */
var_dtor_t dtor; /* value destructor */
volatile struct value *values; /* atomic 引用值得鏈結串列的頭 */
volatile struct slots *slots; /* atomic reader 的 subscription slots */
volatile uint32_t next_slot_idx; /* atomic 的下一個新 slot的索引 */
volatile uint32_t slots_in_use; /* atomic 的活著 reader 數 */
uint32_t nvalues; /* writer-only; for housekeeping */
};
```
<!-- slots_in_use 跟 nref 很像-->
#### `int thread_safe_var_get(thread_safe_var vp, void **res, uint64_t *version)`
- 將 vp->tkey 寫入 slot 變數
- 如果還沒寫入任何值進此執行緒的特定資料過 (`pthread_getspecific(vp->tkey) == NULL`),用 get_free_slot 尋找空的 slot,倘若都沒有空的 slot 才用 grow_slot 去產出一個新的 slot,之後將 slot 寫入此執行緒的特定資料。
```c
if ((slot = pthread_getspecific(vp->tkey)) == NULL) {
/* First time for this thread -> O(N) slow path (subscribe thread) */
slot_idx = atomic_inc_32_nv(&vp->next_slot_idx) - 1;
if ((slot = get_free_slot(vp)) == NULL) {
/* Slower path still: grow slots array list */
err = grow_slots(vp, slot_idx, 2); /* O(log N) */
slot = get_slot(vp, slot_idx); /* O(N) */
atomic_write_32(&slot->in_use, 1);
}
slots_in_use = atomic_inc_32_nv(&vp->slots_in_use);
if ((err = pthread_setspecific(vp->tkey, slot)) != 0)
return err;
}
```
接著在迴圈中反覆確認,slot 的 value 就是 `vp->tkey->values` 的首位, 也就是目前最新的值,倘若不是,就將 slot->value 的值寫進 `vp->tkey->values` 的首位。
```c
while (atomic_read_ptr((volatile void **)&slot->value) !=
(newest = atomic_read_ptr((volatile void **)&vp->values)))
atomic_write_ptr((volatile void **)&slot->value, newest);
```
#### `grow_slots(thread_safe_var vp, uint32_t slot_idx, int tries)`
slot_idx 為期望該 slot 的在 slots 索引位置,tries 為次數上限。無鎖定方式增長 slot_array。
首先去計算目前總 vp->slots 這鏈結串列的 slot 總數,每一個 slots 結構中有包含 slot_count,因此迭代鏈結串列並加總 `slot_count` 即能得知。
```c
volatile struct slots **slotsp;
struct slots *new_slots;
for (slotsp = &vp->slots;
atomic_read_ptr((volatile void **)slotsp) != NULL;
slotsp = &((struct slots *)atomic_read_ptr((volatile void **)slotsp))->next)
nslots += (*slotsp)->slot_count;
```
倘若總 slot 數超過期望slot的索引位置,返回 0,表示成功。而嘗試次數超過預期就回傳 EAGAIN,以外就新建 new_slots 分配新的記憶體。
```c
if (nslots > slot_idx)
return 0;
if (tries < 1)
return EAGAIN;
if ((new_slots = calloc(1, sizeof(*new_slots))) == NULL)
return errno;
```
EAGAIN 為錯誤碼,定義於 C 標準函式庫裡,`EAGAIN` 被定義在 `/usr/include/asm-generic/errno-bash.h`
```c
#define EAGAIN 11 /* Try again */
```
確定要新增 slot 的數量(`additions`)。如果目前沒有任何 slot,初始 addition 數量設為 4。否則,初始設置為目前 nslots 的 1.5 倍。這可能是一種增量擴展策略。最終期望能夠增加的 slot 數能夠超過 slot_idx。 接著就是初始化新的 slot_array:
```c
additions = (nslots == 0) ? 4 : nslots + nslots / 2;
while (slot_idx >= nslots + additions) {
additions += additions + additions / 2;
tries++;
}
new_slots->slot_array = calloc(additions, sizeof(*new_slots->slot_array));
// Then initialize the new slot_array
```
`slot_idx - nslots` 為 slot_idx 在 new_slots 中的索引值,將它標記為有使用。 <!-- 但 slot 中 .in_use 這成員好像可以刪掉 -->
```c
atomic_write_32(&new_slots->slot_array[slot_idx - nslots].in_use, 1);
```
嘗試將 new_slots 加入到 `&vp->slots` 的最後一位
```c
if (atomic_cas_ptr((volatile void **)slotsp, NULL, new_slots) != NULL) {
free(new_slots->slot_array);
free(new_slots);
}
```
最後做遞迴,倘若此次擴展的 slot 數超過 slot_idx,會在下次遞迴中結束,倘若此競爭失敗,就再次嘗試擴展。
```c
return grow_slots(vp, slot_idx, tries - 1);
```
#### `thread_safe_var_set`
建構 new_value (存data 進去)之後,new_value->next 指向 values 的開頭,之後也確定 new_version 了
```c
new_value->value = data;
new_value->next = atomic_read_ptr((volatile void **)&vp->values);
if (new_value->next == NULL)
new_value->version = 1;
else
new_value->version = new_value->next->version + 1;
*new_version = new_value->version;
```
接著確定 new_value 為 vp->values 的開頭,並且 vp 上 的 nvalues 加一。
```c
/* Publish the new value */
atomic_write_ptr((volatile void **)&vp->values, new_value);
vp->nvalues++;
```
同樣有記得要去 `pthread_cond_signal(&vp->waiter_cv);` 呼叫在等待的執行緒。
呼叫 `mark_values(vp)` 對舊值執行垃圾回收(Garabage Collection, GC),標記仍在使用的值並識別可以釋放的值。下一段介紹更多。
```c
old_values = mark_values(vp);
```
接著,呼叫 [`sched_yield()`](https://www.man7.org/linux/man-pages/man2/sched_yield.2.html) 放棄 CPU,允許其他執行緒(最好是 reader )運作,接著解鎖 write_lock、釋放不再使用的舊值的記憶體,如果已設置,則呼叫析構函數 dtor,最終返回解鎖寫鎖的狀態。
#### `mark_value`
`mark_values` 的目的是標記目前被引用的值,並回收那些不再被引用的舊值,基於 mark-and-sweep 的 GC。以下透過如何不把 `thread_safe_var_set` 剛寫入的值給回收掉的角度,去理解 `mark_value`。
mark-and-sweep 主要兩階段:
- [ ] 1. 標記階段 (mark phase):
先走訪 tsv (變數名稱: `vp`)上所有的 `value` 這些值儲存在 `old_values_array` 中,接著走訪所有的 slots 中的 `value`,如果這 value 也有出現在 `old_values_array`,被認為還有被引用,在第二階段時就不會被回收掉。
```c
for (i = 0, v = atomic_read_ptr((volatile void **)&vp->values); i < vp->nvalues && v != NULL; v = v->next, i++)
old_values_array[i] = v;
qsort(old_values_array, vp->nvalues, sizeof(old_values_array[0]),value_cmp);
vp->values->referenced = 1; /* curr value is always in use */
```
為了加速,使用 qsort 為 `old_values_array` 做排序,之後用二分搜尋法判斷某 value 是否存在在 `old_values_array`。
```c
for (slots = atomic_read_ptr((volatile void **)&vp->slots); slots != NULL;) {
for (i = 0; i < slots->slot_count; i++) {
slot = &slots->slot_array[i];
v = atomic_read_ptr((volatile void **)&slot->value);
if (v == NULL || v == vp->values) /* vp->values 中的最新值總是被設定為 referenced,確保了它不會被誤刪除 */
continue;
if ((value_binary_search(old_values_array, vp->nvalues, v)) != NULL) {
v->referenced = 1;
}
}
slots = slots->next;
}
```
- [ ] 2. 清除階段 (sweep phase):
將那些 tsv 的 `values` 沒有被標記的值從鍊結串列中移除,並將這些 `values` 加入 `old_values` 鍊結串列中。 而那些有在標記階段的有標記的 values,重設 referenced 為 0,為了下一次做 GC 有機會被回收。
```c
for (p = &vp->values; *p != NULL;) {
v = *p;
if (!v->referenced) {
*p = v->next;
v->next = old_values;
old_values = v; // 將 v 接到 old_values 的頭
vp->nvalues--;
continue;
}
v->referenced = 0;
p = &v->next;
}
```
理解 mark_values 的關鍵在於理解 mark_values 函數如何區分目前有效的值和無效的值。
在發佈新值之前,slot-list 實作不會等待顯式寬限期。相反,它依賴於 `mark_values()` 確定哪些值可以安全回收。
## TODO: CTP 與 Userspace RCU 的效能評估
> 注意 Userspace RCU 的 flavor 和 CTP 的 slot-{pair,list}
```shell
$ lscpu
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 39 bits physical, 48 bits virtual
Byte Order: Little Endian
CPU(s): 12
On-line CPU(s) list: 0,2,4,6,8-11
Off-line CPU(s) list: 1,3,5,7
Vendor ID: GenuineIntel
Model name: 12th Gen Intel(R) Core(TM) i5-12450H
CPU family: 6
Model: 154
Thread(s) per core: 1
Core(s) per socket: 8
Socket(s): 1
Stepping: 3
CPU max MHz: 4400.0000
CPU min MHz: 0.0000
BogoMIPS: 4992.00
arch_capabilities
Virtualization features:
Virtualization: VT-x
Caches (sum of all):
L1d: 320 KiB (8 instances)
L1i: 384 KiB (8 instances)
L2: 7 MiB (5 instances)
L3: 12 MiB (1 instance)
NUMA:
NUMA node(s): 1
NUMA node0 CPU(s): 0,2,4,6,8-11
```
關閉 hyperthreading 設定
根據論壇討論: [Disable hyperthreading from within Linux (no access to BIOS)](https://serverfault.com/questions/235825/disable-hyperthreading-from-within-linux-no-access-to-bios)
```shell
$ cat /sys/devices/system/cpu/smt/active
1
$ echo off | sudo tee /sys/devices/system/cpu/smt/control
$ cat /sys/devices/system/cpu/smt/active
0
```
與授課教師,討論測試結果異常,可能原因出自於,我的處理器有分 P-cores 與 E-cores,因此以下的測試,都集中於 P-cores 進行。
### 如何分辨 P-core 與 E-core
雖然從 intel 的問答區 [how to know a core is P-core or E-core](https://community.intel.com/t5/Processors/how-to-know-a-core-is-P-core-or-E-core/td-p/1468471?profile.language=zh-CN),得知無直接得知哪一核為 P-core 或 E-core,但從reddit 討論 :[how to identify p-cores and e-cores](https://www.reddit.com/r/intel/comments/whwwvw/how_to_identify_p_and_e_cores/?rdt=51698) 得知以下操作
```
$ lscpu --all --extended
CPU NODE SOCKET CORE L1d:L1i:L2:L3 ONLINE MAXMHZ MINMHZ MHZ
0 0 0 0 0:0:0:0 yes 4400.0000 400.0000 865.329
1 - - - - no - - -
2 0 0 1 4:4:1:0 yes 4400.0000 400.0000 1200.428
3 - - - - no - - -
4 0 0 2 8:8:2:0 yes 4400.0000 400.0000 921.834
5 - - - - no - - -
6 0 0 3 12:12:3:0 yes 4400.0000 400.0000 1223.704
7 - - - - no - - -
8 0 0 4 20:20:5:0 yes 3300.0000 400.0000 1302.425
9 0 0 5 21:21:5:0 yes 3300.0000 400.0000 400.000
10 0 0 6 22:22:5:0 yes 3300.0000 400.0000 1106.814
11 0 0 7 23:23:5:0 yes 3300.0000 400.0000 1177.823
```
查看 MAXMHZ 列,便能得知
我有 4 P-cores 為 Cores 0, 2, 4 與 6,而 4 E-cores 為 Cores 8, 9, 10 與 11。
### power management
[cpufreq-set(1)](https://linux.die.net/man/1/cpufreq-set)
> cpufreq-set - A small tool which allows to modify cpufreq settings.
`-c` 設定處理器編號
`-g` 設定 cpufreq governor
`--max`, `--min`: 設定 cpufreq governor 可以選擇的新的最高, 最低 CPU 頻率。
接著逐一設定 p-core:
以下我設定處理器編號為 0 的 P-core 使用的 CPUfreq 策略為 perfromace ,從而避免其他 governor(如 powersave)通常發生的動態頻率調整。這確保了可預測的最佳性能:
```shell
$ sudo cpufreq-set -c 0 -g performance --max 4400000 --min 4200000
```
用以下迴圈檢查是否設定成功
```shell
$ for core in 0 2 4 6; do echo "Core $core:"; sudo cpufreq-info -c $core; done
```
## 實驗重現步驟
除了上述的設定,以下闡述關於我測試檔的使用方式。
取得程式碼:
```shell
$ git clone https://github.com/eleanorLYJ/ctp
```
測試 ctp 必要條件為建構 `libtsgv.so`, 因此需要在 `ctp` 目錄中使用 `make` 獲取該檔案。而主要測試檔案存於 `ctp/test`, 故再切目錄至`test`。
```shell
$ make
$ cd test
```
**記憶體與吞吐量長條圖**
1. 使用 `make` 生成執行檔。
```shell
$ make
```
2. 執行以下命令產生性能結果:
```shell
$ ./run_perf.sh {RUN_MODE(0)} {NUM_READERS} {NUM_WRITERS} {VALID_CPUS} {OUTPUT_DIR} {FIFO_PRIORITY}
```
範例:
```shell
$ ./run_perf.sh 0 7 1 0,2,4,6 output 90
```
以上命令,將限制 0 2 4 6 編碼的 cpu 執行程式碼,之後使用不同的方法(5 urcu + 2 ctp)生成 1 個 writer 和 7 個 reader 的記憶體與吞吐量長條圖,並且將中間結果存於 output 目錄,並且設定 FIFO 優先級(整數值)為 90。
**記憶體與吞吐量折線圖**
使用 make 生成可執行文件。
執行以下命令生成性能結果:
```shell
$ ./run_perf.sh {RUN_MODE(1)} {NUM_READERS} {NUM_WRITERS} {VALID_CPUS} {OUTPUT_DIR} {FIFO_PRIORITY}
```
範例:
```shell
$ ./run_perf.sh 1 100 1 0,2,4,6 output 90
```
以上命令,這將使用不同的方法(5 urcu + 2 ctp)生成 1 個寫入者和多個讀取者 [1, 100] 的不同組合而成的的記憶體與吞吐量折線圖,並且將中間結果存於 output 目錄,並且設定 FIFO 優先級(整數值)為 90。
### 建立 7 個 reader 執行緒 1 個 writer 執行緒,測 URCU 與 CTP
以下測試是建立 7 個 reader 執行緒,1 個 writer 執行緒,在主執行緒建立完所有的執行緒後,會等待 1 秒,接著改變終止變數,讓所有的執行緒就要停止計算讀取 tsv 或對 tsv 寫入隨機資料的計數。
其中 5 個 flavor 的 URCU 中,qsbr 具比較特別的寫法,在 reader 執行緒中,每 1024 次讀取會進入一次 quiescent_state (呼叫 `rcu_quiescent_state`),藉此writer 能夠檢測到 GP 的結束,從而判斷記憶體的回收時機。
#### throughput
比較不同實作 1 秒內 7 個 readers 的讀取次數的總和的直條圖
![reader_comparison_bar](https://hackmd.io/_uploads/BypQeDSvA.png)
同時,比較不同實作 1 秒內 1 個 writer 的讀取次數的總和的直條圖
![writer_comparison_bar](https://hackmd.io/_uploads/rJpXewBwR.png)
以下是在我設定 CPU affinity, 關閉 hperthreading, 設定 taskset 等操作之前的圖 (固定 2 writers,搭配 1 到 10 個 reader)
出現了只有一個執行的 reader 執行緒總讀取最多,增加執行緒後總讀取次數卻下降的奇怪現象
![Figure_1](https://hackmd.io/_uploads/rkYr_GjLR.png)
![Figure_2](https://hackmd.io/_uploads/HktHdfsUR.png)
推估有幾個問題造成的:
1. Cache Coherency Overhead
2. Context Switching
3. CPU mitgration
我期待設定 cpu affinity 後能減少 context switch 與 CPU mitgration 帶來的影響。
我在各個測試檔中設定 CPU affinity
- [commit 37d7785: Add functions to set and check thread CPU affinity](https://github.com/eleanorLYJ/ctp/commit/37d7785d21d34788990c897cba021d64e05303df)
- [Commit bd58843: Enhance Validation and Diagnostics in CTP Testing](https://github.com/eleanorLYJ/ctp/commit/bd58843fcd9828ecef9fd1386efad87777d192db)
設定 cpu affinity 中,兩個重要的函式
> [pthread_setaffinity_np, pthread_getaffinity_np](https://man7.org/linux/man-pages/man3/pthread_setaffinity_np.3.html): set/get CPU affinity of a thread
除了設定 cpu affinity 之後 ,使用 [`taskset`](https://man7.org/linux/man-pages/man1/taskset.1.html) 控制執行緒在指定 CPU 上的運行,另外關閉 [hyperthread](https://zh.wikipedia.org/zh-tw/%E8%B6%85%E5%9F%B7%E8%A1%8C%E7%B7%92) 讓 CPU 一次只處理一個執行緒。在此之後重新畫的折線圖。
以下說明 設定 cpu affinity 對我測試的作用:
其環境沒有關閉 hyperthread,也沒有設定 taskset 的結果:
`perf stat -e cycles,instructions,cache-references,cache-misses,context-switches,cpu-migrations {.exe} 7 2`
Benchmark|qsbr|signal|mb|memb|bp|slotpair|slotlist
| -------- | -------- | -------- |-------- | -------- | -------- | --|--
Cycles(core/atom)|66M/55M|118M/86M|94M/63M|114M/77M|71M/52M|114M/72M|121M/77M
Cache Misses (core/atom)|93k/72k|87k/71k|96k/42k|79k/55k|101k/53k|185k/78k|219k/137k
context-switches|48k|50k|69k|46k|51k|49k|52k
CPU Migrations|1040|1261|992|987|1296|6638|1,0552
以下為在測試檔中設定 cpu affinity 後,之後可以看到 cycles,Cache Misses, context-switches 與 cpu-mitgrations 次數都下降
Program| qsbr|signal|mb|memb|bp|slotpair|slotlist|
| -------- | -------- | -------- |-------- | -------- | -------- | --|--
| Cycles (core/atom)|2.776B/3.687B|4.526B/68.07M|5.32B/4.014B|3.557B/7.597B|3.324B/3.726B|3.162B/6.289B|4.929B/5.75B|
Cache Misses (core/atom)|46.28k/54.29k |57.18k/60.60k |62.64k/36.96k |43.65k/44.42k |52.10k/71.09k |128.60k/240.79k |146.68k/131.63k
Context Switches|42.47k|43.19k|66.15k|45.04k|45.77k|35.54k|47.76k
CPU Migrations|12|10|10|12|13|7|8
### 建立 [1, 100] 個 reader 執行緒,1 個 writer 執行緒 測 urcu 與 CTP
在讀取吞吐量測試中,qsbr 和 bp 表現最佳,一秒內的讀取次數範圍在 555,000 到 655,000 次之間。這表明它們在高頻率讀取的操作具有優異的表現。mb 的讀取表現次好,但在初期從 1 到 10 個讀取者時,其讀取次數有顯著下降,隨後變得平穩,保持在第三高的讀取次數。相比之下,memb 和 signal 表現中等,而 slotpair 和 slotlist 的讀取表現最差。
> TODO: 探討為什麼是 3 readers 和 1 writer 的組合讀取總量特別低
![image](https://hackmd.io/_uploads/Syg90UHvC.png)
在寫入吞吐量測試中,CTP 方法(slotpair 和 slotlist)表現最佳,一秒內的寫入次數範圍在 30,000 到 41,000 次之間。其餘方法在 reader 增加到 2 以後,寫入次數驟降到十位數。
![image](https://hackmd.io/_uploads/SkNqCISPR.png)
#### 記憶體使用量
:::danger
Wikipedia 不可簡稱 wiki。
參見 CS:APP 第 6 章的描述及〈[每位程式開發者都該有的記憶體知識](https://sysprog21.github.io/cpumemory-zhtw/)〉。
:::
閱讀 [Wikipedia: CPU cache](https://en.wikipedia.org/wiki/CPU_cache)
+ Instruction cache (I-cache): 用於加速可執行指令的存取
+ Data cache (D-cache): 用於加速資料存取和儲存;資料快取通常被組織為更多快取層級的層次結構(L1、L2 等)
> 補 CS:APP ch6
[perf stat 輸出解讀](http://zhengheng.me/2015/11/12/perf-stat/)
用 perf 測快取使用量
```shell
perf stat -e cache-references,cache-misses,L1-dcache-loads,L1-dcache-stores,LLC-load-misses,LLC-store-misses
```
我每一次測的圖形都不盡相同,我認為是因為我是寫入隨機值的原因,而以下就討論各方法的相對位置
Cache Misses:
CTP 方法的 cache miss 數量較高,這表明其資料載入效率較低,可能是因為頻繁的寫入操作導致更多的資料需要從主記憶體載入。
![Cache miss](https://hackmd.io/_uploads/Sy5F08rv0.png)
L1 DCache Load Misses:
CTP 方法的 load miss 數量較高,而 bp 和 qsbr 的數量較低,顯示出它們在資料載入方面的效率更高。
![L1 Load miss](https://hackmd.io/_uploads/Sy7uAIrPC.png)
LL Load Misses:
CTP 方法相較其他方法更容易出現異常高的 load miss 數,這可能影響其效能,而在大部分情況下,所有方法的 load miss 數位於中間部分。
![LL Load miss](https://hackmd.io/_uploads/Bks_AUHPC.png)
L1 DCache Store Misses: 所有方法的 store miss 數量均等於 2,顯示出在寫入操作上的一致性。
![L1 store miss](https://hackmd.io/_uploads/BkzFRIrw0.png)
LL Store Misses: CTP 方法的 store miss 數量較低,這表明其在寫入操作上的效率較高。bp 和 qsbr 的 store miss 數量較高,而 mb、memb 和 signal 位於中間部分。
![LL store miss](https://hackmd.io/_uploads/H1UKCLrDA.png)
### 建立 [1, 100] 個 reader 執行緒,1 個 writer 執行緒測試 bullet-proof URCU 是否註冊執行緒會影響效能
在撰寫 urcu 測試檔中,發現 [urcu README](https://github.com/urcu/userspace-rcu/blob/master/README.md) 談到 bullet-proof 的 urcu (bp) 可以不用 ruc_init() 與註冊(reigster)執行緒。
> urcu_bp_init(), and urcu_bp_unregister_thread() all become nops, whereas calling urcu_bp_register_thread() becomes optional.
我發現了在計算 throughput 時,bp 將 rcu_register_thread() 與 rcu_unregister_thread() 移除,整體的 throughput 會下降 , 因此有了以下的折線圖
我將淺藍色線 (bp_no_reg) 為使用bp uruc 同架構的程式碼,但 rcu_init()、 rcu_register_thread() 與 rcu_unregister_thread() 移除。
![reader_comparison_plot](https://hackmd.io/_uploads/SJjxg3Bw0.png)
writer 讀寫次數最大 103 最低到 4,然而兩測試檔測到的寫入量都太少,因此從圖片無法看出
![writer_comparison_plot](https://hackmd.io/_uploads/Hyclg2Sv0.png)
從圖中能看出,少了 rcu_init()、 rcu_register_thread() 與 rcu_unregister_thread() 的 bp_no_reg 的吞吐量測試變得非常地不穩定,而這還需要更多的細討論原因。
> TODO: 看 bullet-proof urcu 中,呼叫 rcu_register_thread() 會動用到哪一塊原始碼。
## TODO: 評估 CTP 的限制和改進方案
<!-- - 使用 futex -->
修改部分為 slotlist 的 `grow_slots()`,此函式考慮在多執行緒中無鎖擴增 slots。會發生該函式的主要競爭條位置在 slotsp (vp->slots 的最後一個 slots)上,此使用該 atomic_cas_ptr 函式嘗試寫入,這確保一次只有一個執行緒可以成功更新 slot_array 。如果一個執行緒由於另一個執行緒競爭失敗而無法更新,它將進行清理並遞迴重試。以下修改目的是要減少不必要的遞迴次數。
```diff
/* Add new slots to logical array of slots */
if (atomic_cas_ptr((volatile void **)slotsp, NULL, new_slots) != NULL) {
free(new_slots->slot_array);
free(new_slots);
+ grow_slots(vp, slot_idx, tries - 1);
}
- return grow_slots(vp, slot_idx, tries - 1);
+ return 0
}
```
測試 7 readers 與 1 writer 的讀取或寫入次數
| | before | after |
| -------- | -------- | -------- |
| reader counts | 425055 | 466009 |
| writer counts | 319918 | 333802 |
## 總結
### CTP 與 URCU 相比
CTP 優點
1. 更簡單的 writer 邏輯:CTP 具有更簡單的 writer 邏輯,因為 CTP 依賴於引用計數,而不是管理多個資料版本和寬限期。
2. 寫入操作較好:在測試中,CTP 的 store miss 次數較低,這表明在寫入操作上,CTP 具有較高的效率,因為它避免了頻繁的快取儲存失敗
3. API 簡單使用,使用者不需要進行大量的程式碼修改
CTP 缺點
1. 缺少豐富並行處理資料結構,如 linked list, queue, hash 等
2. 讀取操作較差: 在測試中,CTP 的 load miss 次數較高,特別是在 L1 DCache load miss 和 LLC load miss 方面。這表明在讀取操作上,CTP 的效率不如 URCU。
:::danger
闡述更多記憶體使用量的分析。
> ok
:::
### 結論
從分析程式碼,能得知 slor-pair 僅維護兩個版本的值,實作簡單, slot-list 維護多版本的值,然而在測試結果中,兩者的優勢與劣勢都相似,適合讀寫比例較均衡且需要立即記憶體釋放的場景。
根據測試結果,URCU 方法適合高讀取且低寫入的應用情境,如查詢系統。具體資料顯示,一秒鐘 qsbr 和 bp 的讀取次數範圍在 555,000 到 655,000 次之間,而 CTP 方法在這方面較差,讀取次數範圍在 355,000 到 450,000 次之間,但在寫入次數上顯著優於 URCU 方法,大概落在 30000~41000 之間,而 urcu 都降至個位數,因此 CTP 方法,儘管其在記憶體載入效率(如 cache miss 和 load miss)方面的表現不如 URCU 方法,但在高頻寫入操作下,CTP 方法能提供更好的效能和效率。
<!--slot-list 的 writer 相較 slot-pair 的 writer 處理較複雜。
在 reader: writer = 1:1,slot pair L2 快取有作用,而,slot list 有高的未命中。
然而在更多的 reader 的情境,slot-list 就沒有明顯有快取優勢,甚至差於 slot-pair。 -->