Reentrant vs Thread-safe
a whole copy of MagicJackTing's blog. thanks ot his sharing.
Reentrancy 和 thread-safty 是兩個容易被搞混了的觀念. 其中最嚴重的是誤以為 reentrant function 必定是 thread-safe 或者相反以為 thread-safe function 必為 reentrant, stackoverflow 網站上的答覆甚至同時出現二種答案的現象.
Reentrancy 和 Thread-safty 二者的差異
首先來看 reentrancy: 字面上的意思是可重入. Reentrancy 原先是討論單一執行緒環境下 (即沒有使用多工作業系統時) 的主程式和中斷服務程式 (ISR) 之間共用函數的問題. 主要的達成條件是共用函數不使用靜態變數或全域變數 (意即只用區域變數).
再來是 thread-safety: 字面上的意思是執行緒 (線程) 安全. Thread-safe 一開始就針對多執行緒的環境, 討論的是某一段程式碼在多執行緒環境中如何保持資料的一致性 (及完整性), 使不致於因為執行緒的切換而產生不一致 (及不完整) 或錯誤的結果. 問題的產生點一般出現在對某一共用變數 (或資源) 進行 read-modify-write 或者類似的動作時. 例如:
Thread 1 | Thread 2 | Register in CPU | Variable in RAM | |
---|---|---|---|---|
0 | ||||
read | 0 | <-- | 0 | |
inc by 1 | 1 | 0 | ||
write | 1 | --> | 1 | |
read | 1 | <-- | 1 | |
inc by 1 | 2 | 1 | ||
write | 2 | --> | 2 |
Thread 1 | Thread 2 | Register in CPU | Variable in RAM | |
---|---|---|---|---|
0 | ||||
read | 0 | <-- | 0 | |
read | 0 | <-- | 0 | |
inc by 1 | 1 | 0 | ||
inc by 1 | 1 | 0 | ||
write | 1 | --> | 1 | |
write | 1 | --> | 1 |
類似 read-modify-write 的動作還有 test-and-set, fetch-and-add, 和 compare-and-swap. 請參看 Wiki Atomic Operation 相關說明
我們會把 reentrancy 和 thread-safety 搞混是因為它們的狀況近似, 問題都發生在一個函數 (或者是一小段程式碼) 執行時間重疊. 但是一個 thread-safe 函數不見得就一定是 reentrant. 舉例來說, 某個函數可以用一個 mutex 把原本的函數整個包裹起來 (如此可以避掉多執行緒環境引起的問題), 但是如果中斷服務程式 ISR 也使用到這個函數, 那它就可能在那兒苦苦的等不到 mutex 被先前鎖住它的執行緒把它釋放出來.
所以結論是 reentrancy 主要是檢討在 ISR 中函數庫裡的哪一些函數是可以呼叫的, 以及自己寫的函數可不可以在 ISR 中使用, 及如何撰寫才可以共用. 而 thread-safe 則是如何確保共用的資料/資源在多個執行緒之間 (不包含 ISR) 可以如預期的被使用.
實作上的要點
Reentrant 實作上比較簡單, 要注意:
- 不要使用共用資源 (global variables and static variable); 或者也可以在寫入共用變數之前, 把數值暫存在區域變數中, 使用完畢後回存.
- 不修改自身的程式碼
- 不呼叫 non-reentrant 函數. 例如: clib 中的 strtok(), rand(),srand() 都是 non-reentrant, 對應的 reentrant 版本是 strtok_r(),rand_r(), srand_r().
Thread-safe 實作的方法有很多, Wiki 網站提到 thread-safe 實作方法上可分為二類:
- 避免發生共用
- 使用可重入 (Reentrancy) 技術: 把靜態變數及全域變數全部改為區域變數 (區域變數通常放在 stack 區, 可以順利避免共用).
- 使用執行緒自身的儲存空間 (TLS, Thread Local Storage): 所以每一個執行緒都不同, 都有自己的一份拷貝. (C11 支援加上 keyword_Thread_local 來將變數移到 TLS; C++11 改用 keywordthread_local; gnu 或者其他 C++ compiler 則用 keyword__thread)
- 當無法避免共用時, 採用鎖定 (同步) 機制
- 使用互斥鎖 (Mutex, Mutual Exclusion): 利用序列化機制 (serialization) 來保證任一時間點都只有一個執行緒讀或寫共用資料. 但是多個 mutex 一起運作時需要小心仔細的對待, 不恰當的實作可能引起一些負作用, 如: deadlocks, livelocks, resource starvation.
- 使用原子操作 (AO, Atomic Operations): 在存取共用資料時禁止被其他執行緒打斷. 一般實作上需要一些新的硬體指令來支援, 它是實作執行緒鎖定的元件, 也是前一項 mutex 實作的基礎. 現代的多核 CPU (x86, MIPS, ARMv6 and later) 都至少有支援一對指令可以協助完成 AO 動作 (可能只支援部份 AO). 單核 CPU 在 OS 核心的部份可以用中斷鎖定 (disable/enable interrupt) 來支援 AO 的需求. 但是如果是在用戶空間 (user-space) 卻是不可以, 詳細請參考: Emulated atomic operations and real-time scheduling.
- 使用不可變物件 (immutable objects): 物件構建 (construct) 之後即無法改變內容. 要實作改變時, 是以重新構建 (re-construct) 來取代修改現有之內容.
Part 2: C 語言例子 (reentrant function)
先來看 reentrant 的例子: 最常見到用來說明 reentrant 的例子大概就屬swap() 了
int t;
void swap(int *x,int *y) {
t = *x;
*x = *y;
*y = t;
}
void isr(void) {
int x = 1, y = 2;
swap(&x, &y);
...
return;
}
void main(void) {
int x = 3, y = 4;
...
swap(&x, &y);
...
}
這個例子中的 swap() 函數是 non-reentrant function (而且也不是 thread-save). 原因是第4行及第6行用到共用的變數 t . 即便是我們用的是 32bit CPU, 甚至也有技援 mem. to mem. 移轉資料不被中斷, 也還是有二個時間點(在第5行執行前或執行後) 發生中斷會產生錯誤的執行結果. 如下表的 NG Example 1 和 NG Example 2 所示
main() | isr() | *x | *y | t |
---|---|---|---|---|
swap(); | - | |||
t = *x; | 3 | 4 | 3 | |
*x = *y; | 4 | 4 | 3 | |
*y = t; | 4 | 3 | 3 | |
swap(); | 3 | |||
t = *x; | 1 | 2 | 1 | |
*x = *y; | 2 | 2 | 1 | |
*y = t; | 2 | 1 | 1 |
main() | isr() | *x | *y | t |
---|---|---|---|---|
swap(); | - | |||
t = *x; | 3 | 4 | 3 | |
swap(); | 3 | |||
t = *x; | 1 | 2 | 1 | |
*x = *y; | 2 | 2 | 1 | |
*y = t; | 2 | 1 | 1 | |
*x = *y; | 4 | 4 | 1 | |
*y = t; | 4 | 1 | 1 |
main() | isr() | *x | *y | t |
---|---|---|---|---|
swap(); | - | |||
t = *x; | 3 | 4 | 3 | |
*x = *y; | 4 | 4 | 3 | |
swap(); | 3 | |||
t = *x; | 1 | 2 | 1 | |
*x = *y; | 2 | 2 | 1 | |
*y = t; | 2 | 1 | 1 | |
*y = t; | 4 | 1 | 1 |
上面的程式只要稍微修改一下, 把第1行搬到第3,4行中間. 也就是把變數 t 變成區域變數, 即可將 swap() 變成 reentrant function.
void swap(int *x,int *y) {
int t;
t = *x;
*x = *y;
*y = t;
}
void isr(void) {
int x = 1, y = 2;
swap(&x, &y);
//...
return;
}
void main(void) {
intx = 3, y = 4;
//...
swap(&x, &y);
//...
}
Wiki 網站則刻意把共用的變數 t 保留著, 加入一個區域變數 s , 一樣可以使 swap() 變成 reentrant function. (懷疑嗎? 可以仿照上面的例子畫一張表格填填看)
int t;
void swap(int *x,int *y) {
int s;
s = t; // save global variable
t = *x;
*x = *y;
*y = t;
t = s; // restore global variable
}
void isr(void) {
int x = 1, y = 2;
swap(&x, &y);
...
return;
}
void main(void) {
int x = 3, y = 4;
...
swap(&x, &y);
...
}
這樣就 OK 了嗎? 不...
這是一個刻意設計用來作解說而非常不實用的例子, 因為 main() 和 isr() 呼叫swap() 所用的參數都是自己 local 變數. 一但 main() 和 isr() 呼叫swap() 所用的參數其中有一個是二者共用的變數, 整個程式的行為就會很奇怪:
- main() 和 isr() 呼叫 swap() 的次序不同結果不同 (邏輯上說不通)
- swap() 又變成是 non-reentrant function 了 (試一下把變數 y 變成共用變數)
大家不可不慎.
Part 3: C 語言例子 (thread-safe function)
接下來, 我們來看一些 thread-safe 的例子: 首先是在 wiki 網站上的一個 Thread-safe 但不是 reentrant 的例子.
#include <pthread.h>
int increment_counter() {
static int counter = 0;
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&mutex);
// only allow one thread to increment at a time
++counter;
// store value before any other threads increment it further
int result = counter;
pthread_mutex_unlock(&mutex);
return result;
}
這個例子中的 increment_counter() 可以被多個執行緒呼叫而不會產生任何問題, 因為它用了一個 mutex 來保護 (同步) 所有對共用的靜態變數 counter 的存取. 但是如果中斷服務程式 ISR 也呼叫了 increment_counter(), 就會很容易使系統當掉. 原因是如果中斷發生在執行緒正呼叫 increment_counter() 時 (尤其是 mutex lock 和 unlock 之間), 那 ISR 將永遠等不到 mutex 被 unlock. 因為 CPU 接受中斷進入 ISR 後, 只有 ISR 完成, 才會回到執行緒. 記住: 中斷永遠比正常執行優先, 所以 ISR 要比執行緒或者是 OS 核心優先執行.
接著下來的例子是從 http://www.thegeekstuff.com/2012/07/c-thread-safe-and-reentrant/ 節錄整理來的, 一樣是一個 Thread-safe 但不是 reentrant 的例子.
例子中意圖要控制一個字元陣列 arr 的元素依序被各個執行緒佔用.
//...
char arr[10];
int index = 0;
int func(charc) {
if(index >= sizeof(arr)) {
printf("\n No storage\n");
return -1;
}
arr[index] = c;
index++;
return index;
}
//...
很明顯的, 上面的函數 func() 一但被多個執行緒呼叫執行, 就破功了. 它的問題有二個:
- 第12行及第13行之間不可以被別的執行緒插斷, 原因是 index 還沒來得及 +1 以保護剛存入陣列元素的字元變數 c .
- 第7行取出共用變數 index 來檢查, 但是萬一在第12~13行還沒執行前就被別的執行緒插斷, 等到回復執行之後變數 index 值很可能已經被更動了, 但是 CPU 暫存器中的拷貝卻沒有更新, 而接著執行的第12~13行就會覆蓋了已經被別的執行緒所佔用的陣列元素了.
所以上面的例子必需適當的修改 (這裡只用註解標記應該加入 mutex lock/unlock 的修改處), 如:
//...
char arr[10];
int index = 0;
int func(charc) {
int tmp;
/* Lock a mutex here */
tmp = index;
if(index >= sizeof(arr)) {
/* unlock the mutex here */
printf("\n No storage\n");
return -1;
}
index++;
/* unlock the mutex here */
arr[tmp] = c;
return tmp;
}
//...
上例的修改可能比較不那麼好 (好看及好維護), 原因是 mutex lock/unlock 不對稱, 出現了 2 個 mutex unlock. 但它還有一個重點是: 先佔用資源 ( index++ ), 再把值存入. 這麼作可以早一點點把 mutex 放開 (重要!). 改寫成下面這個樣子可能看起來好些 (一樣只用註解標記應該加入 mutex lock/unlock 的修改處).
//...
char arr[10];
int index = 0;
int func(charc) {
int tmp = -1;
/* Lock a mutex here */
if(index < sizeof(arr))
tmp = index++;
/* unlock the mutex here */
if(tmp < 0)
printf("\n No storage\n");
else
arr[tmp] = c;
return tmp;
}
//...
修改過後的 func() 可以被多個執行緒呼叫執行而不會發生 存放位置 和 位置索引 不一致的問題了. 請注意: 用了 mutex 就不是 reentrant function.
Reference:
http://magicjackting.pixnet.net/blog/post/113860339http://magicjackting.pixnet.net/blog/post/113859925
http://magicjackting.pixnet.net/blog/post/117448003