Race condition in C wrapper of mutex class
最近[1]在寫 code 時發現了一個隱藏的 race condition,是因為 API 的 prototype 設計錯誤,造成 client code[2] 幾乎無可避免地會寫出 race condition 的程式。
原本的 API 設計
我們有套用 C++ 寫的 thread library,用起來很像 Boost.Thread[3],不過因為我們有許多 C code 也需要用到 thread 的功能,因此這個用 C++ 寫的 thread library,也有提供 C 的 API。
C++ 版 mutex 當然依照 RAII 的原則,在 constructor 裡初始化內部的系統 mutex 資料結構,於 destructor 裡銷毀內部的系統 mutex 資料結構[4]。這個 mutex class 大致上長的像這個樣子:
mutex.hpp:
#ifndef MUTEX_HPP_INCLUDED
#define MUTEX_HPP_INCLUDED
class mutex
{
public:
mutex();
~mutex();
void lock();
void unlock();
class guard
{
public:
guard(mutex& mx) : mx_(mx) { mx_.lock(); }
~guard() { mx_.unlock(); }
private:
mutex& mx_;
};
private:
// ...
};
#endif /* MUTEX_HPP_INCLUDED */
而 C 版本的 wrapper functions 則是很簡單地這麼設計:
mutex.h:
#ifndef MUTEX_H_INCLUDED
#define MUTEX_H_INCLUDED
#ifdef __cplusplus
extern "C" {
#endif
// forward declaration only to hide real mutex_t definition
#ifndef __cplusplus
typedef struct mutex_t mutex_t;
#endif
struct mutex_t;
mutex_t* mutex_create();
void mutex_destroy(mutex_t* mx);
void mutex_lock(mutex_t* mx);
void mutex_unlock(mutex_t* mx);
#ifdef __cplusplus
} // extern "C"
#endif
#endif /* MUTEX_H_INCLUDED */
mutex.cpp:
#include <mutex.h>
#include <mutex.hpp>
// C++ implementations
mutex::mutex() { ... }
mutex::~mutex() { ... }
void mutex::lock() { ... }
void mutex::unlock() { ... }
// C wrappers (many checks omitted)
extern "C" {
// real definition of mutex_t
struct mutex_t
{
mutex mx_;
};
mutex_t* mutex_create()
{
return new mutex_t(); // will call mutex::mutex()
}
void mutex_destroy(mutex_t* mx)
{
delete mx; // will call mutex::~mutex()
}
void mutex_lock(mutex_t* mx)
{
mx->mx_.lock();
}
void mutex_unlock(mutex_t* mx)
{
mx->mx_.unlock();
}
} // extern "C"
請注意到 mutex.h 並沒有引入 mutex.hpp。由於 C API 皆是透過 mutex_t 的指標操作,因此我們不需要將 mutex_t 的真實結構於 header 裡暴露,僅需有個 forward declaration 即可。這樣一來,我們就可以在 mutex.cpp 裡,將真正的 C++ 版 mutex 包在 mutex_t 裡,然後將 create/destroy/lock/unlock 的操作,delegate 給 C++ 版 mutex 執行。
隱藏的 race condition
不過,這樣的寫法,卻會造成隱藏的 race condition。舉例來說,client code 可能會用 static local variable[5] 這麼寫:
unsigned int get_serial_number()
{
static unsigned int s_serial = 0;
static mutex_t* s_serial_mutex = 0; // to protect s_serial
unsigned int serial = 0; // used to hold the incremented s_serial
if (s_serial_mutex == 0) { // (1)
// initialize pmx when first using it
s_serial_mutex = mutex_create(); // (2)
}
mutex_lock(s_serial_mutex); // (3)
++s_serial;
serial = s_serial;
mutex_unlock(s_serial_mutex);
// mutex destruction omitted, safe since we're quiting anyway
return serial;
}
問題出在 s_serial_mutex 這個 pointer 並沒有受到 mutex 保護,若兩個 thread 都通過了 (1) 處的 (s_serial_mutex == 0) 的測試,其中之一跑的較快,已經執行過了 (3) 處的 mutex_lock(s_serial_mutex),此時第二條 thread 才執行 (2) 處的 mutex_create(),s_serial_mutex 就會被覆蓋掉,使得第二條 thread 也可以進入 (3) 處的 lock,造成 race condition。
弔詭的是,我們正在初始化 mutex,哪有辦法用 mutex 來保護 mutex?
為了解決這個問題,我們必須要先把問題發生點,納入 library 的範圍裡,接下來,才能夠在 library 裡面,對問題發生點,進行保護。
將問題點納入控制
要解決這個問題,第一步就是把「assign new mutex to s_serial_mutex」這件事,收回來由 library 自己做,而不要讓呼叫者做,這樣才有可能想辦法保護 mutex。解法其實也很簡單,把 mutex_create() 從「回傳 pointer to the new mutex」改成「receive pointer to a mutex buffer」。也就是說,改成下面這個樣子:
mutex.h:
typedef implementation-defined mutex_t; // (1) #define NULL_MUTEX implementation-defined // (2) /** * Returns 0 when success. * Returns 1 when mx is already initialized. * Returns negative value for other kind of errors. */ int mutex_init(mutex_t* mx); // (3)
main.cpp:
static unsigned int g_serial;
static mutex_t g_serial_mutex = NULL_MUTEX; // to protect g_serial
int get_serial_number()
{
if (0 == mutex_init(&g_serial_mutex)) { // (4)
atexit(destroy_serial_mutex); // (5)
}
mutex_lock(&g_serial_mutex);
++g_serial;
serial = g_serial;
mutex_unlock(&g_serial_mutex);
return serial;
}
void destroy_serial_mutex()
{
mutex_destroy(&g_serial_mutex);
}
這樣一來,我們就可以在 (4) 處,於 mutex_init() 裡面處理 s_serial_mutex 的設值,故可以對這個步驟上鎖,確保不會產生 race condition。不過,這樣的寫法,有以下幾個要點,必須要注意。
mutex_create()改名為mutex_init()-
於 (3) 處,因為 prototype 的語意,從「產生一個
mutex_t物件」變成「對一個mutex_t物件做一些事」,所以為了讓名字符合其表面上的意義,所以mutex_create()應改名為mutex_init()。 mutex_t必須可以 bit-wise copy 且無害於其語意-
首先,由於這是 C,故 (1) 處的 implementation-defined 不可能使用 C++ 的 class,因此不可能利用將 copy constructors 置於 private 區間的技巧,禁止使用者複製
mutex_t物件。也就是說,g_serial_mutex物件,隨時可能會被複製使用,故mutex_t必須用「可以做 bit-wise copy,且無害其語意」的型別實作,例如int。必須注意的一點是,即使
CRITICAL_SECTION這類型別,在 syntax 層面上,可以被 bit-wise copy,我們也不敢保證,對其做 bit-wise copy 之後,會不會破壞其語意[6]。因此,這些限制註定了
mutex_t必須是個類似 handle 的東西,內部有個 table,將 handle 對應到真正的 mutex 實作物件。然而,這種 handle-resource table 的架構,除了在實作上有著許許多多的限制之外,其效率也比較差[7]。一般來說,除非必要,我會應該盡量避免之。或者,
mutex_t也可以是個 pointer,指向真正的 mutex 實作物件[8]。可是,使用 pointer 有個問題就是,address 可能會重複。舉例來說,當前一個 mutex 實作物件被 delete 後,又 new 一個新的 mutex 實作物件,這時,新的 mutex 實作物件,有可能被配置在同樣的記憶體位址。如果指向前一個 mutex 實作物件的mutex_t指標,在 delete 時,沒有 reset,就會造成 dangling pointer,在後一個 mutex 實作物件被配置時,張冠李戴,造成邏輯上的錯誤[9]。這種 dangling pointer 我們不能怪罪於使用者,因為「
mutex_t其實是個 pointer」這個資訊,應該要被隱藏起來。幸好,mutex_destroy()也是接受 address ofmutex_t參數,就好像我們對mutex_init()的處理一樣,可以於mutex_destroy()裡面 resetmutex_t指標。 - 應能檢驗
mutex_t變數是否已被初始化 -
在一般的物件導向程式裡,物件的「記憶體空間配置」與「初始化」,是合併在一起做的。也就是說,只要物件存在,就表示「記憶體空間配置」與「初始化」皆已完成,否則就是統統未完成[10]。因此,並沒有所謂的「尚未初始化的物件(記憶體空間)」這種情況發生,毋需特別檢查。
然而,在 C 的世界裡,記憶體空間的配置與初始化,是可以且一般都是分開來做的:
- 若我們是提供
mutex_create(),通常是用 C 去表現物件導向的語意,將「記憶體空間配置」與「初始化」綁在一起做。除非兩者皆成功了,才會回傳有效的mutex_t*;否則,就回傳NULL,代表兩者相當於皆沒有發生過[11],也就是全有或全無。 - 但如果提供的是
mutex_init()的話,則兩者就是分開來做。記憶體的配置,可以由呼叫者自行配置於 stack、heap 或 global 處,或者若堅持mutex_t記憶體的管理由 library 做的話,就是由 library 另外提供mutex_allocate()與mutex_free()函式,讓呼叫者得以命令 library 幫他為mutex_t物件配置記憶體。
不過,因為前面所述,為了將「assign 到最後儲存表示 mutex 的變數(物件)」這件事收回來自己作,我們勢必採用如下的 prototype:
int mutex_XXX(mutex_type* mx);
如果是採用
mutex_create()的作法,呼叫端不應該自行配置記憶體,故僅持有一個指向mutex_t物件的指標,使用方法就會是:int mutex_create(mutex_t** ppmx); int main() { mutex_t* pmx = 0; mutex_create(&pmx); }然而,這種寫法,要用到「指標的指標」,過於「曲折」,所以我偏好採用
mutex_init()的作法:int mutex_init(mutex_t* pmx); int main() { mutex_t mx; mutex_init(&mx); }不過,這樣問題就來了,我們要怎麼知道
mx是否已經被初始化過了?將mx設為「零值」[12]不可行,因為這樣一來,「零值」的檢查[13]就會和mutex_init()脫勾,一樣會造成 race condition。最好的方法,是直接由
mutex_init()來一併判斷,mx是否已被初始化。由於通常檢查mx 是否已被初始化,是為了要避免重複初始化,因此,我們可以把mutex_init()的回傳值,設計成這個樣子:- 如果有任何錯誤發生,就回傳 negative value,代表錯誤原因;
- 如果未被初始化,就初始化之,然後回傳 0;
- 如果已被初始化,就不初始化之,然後回傳 1。
因此,如果我們不在乎,
mx是否已被初始化,只需要確認,mx已經被正確初始化過就好,則我們可以檢查mutex_init()的回傳值,是否小於0即可:if (mutex_init(&mx) < 0) { fprintf(stderr, "ERROR: Failed to initialize mutex.\n"); exit(1); }否則,如果需要知道
mx是否在「這次」被初始化,就這麼用:int r = mutex_init(&mx); if (r == 0) atexit(destroy_mx); // destroy_mx() will destroy mx } else if (r < 0) { fprintf(stderr, "ERROR: Failed to initialize mutex.\n"); exit(1); } - 若我們是提供
- 銷毀
mutex_t物件的時機 -
在前一個版本當中,我們放棄銷毀
s_serial_mutex,這當然是不對的。只不過,在 C 的世界裡,實在沒有什麼好辦法銷毀一個區域的靜態變數[14],所以這回只好把s_serial與s_serial_mutex提升成為全域變數g_serial和g_serial_mutex—雖然(幾乎)只有get_serial_number()有用到它們。這樣一來,我們就可以有兩種方法可以銷毀全域的
g_serial_mutex:在main()結束前銷毀之,或是利用atexit()註冊一個函式,在main()或exit()之後被呼叫,然後銷毀之,例如前面提到的destroy_mx()函式。前者的缺點是,當程式龐大時,不一定都有機會,能夠執行到main()的最後面,可能在中途就exit()或丟出例外而沒有人接[15]。而後者的缺點則是,有些系統只能註冊有限個數的atexit()函式,當atexit()失敗時,事情就麻煩了。
經過以上種種考量之後,我們終於搞定了 mutex 的 C API 的介面設計,接下來就是真的來解「如何用 mutex 來保護 mutex」的兩難。
保護問題點
有了可以保護 mutex 的 mutex_init() 這個 prototype 之後,接下來就是要來真的來實作「保護 mutex」這件事。怎樣「用 mutex 保護 mutex」說穿了其實也很簡單,只要有兩種不同的 mutex 就可以了。事實上,既然這個 library 的目的就是要包裝 OS 提供的 threading 機制,提供跨平台的簡單介面。而且,這個 library 甚至還提供了 C 與 C++ 兩個不同語言的介面,所以在 library 裡,我們至少有三種 mutex 可以用:
- 用 C API 所包裝出來的 mutex
- 用 C++ API 所包裝出來的 mutex
- 底層平台所提供的,被 C/C++ API 包裝的 mutex
因為有問題的部份,只有在 C API 上,而實際上 C API 也是把 C++ API 包裝起來,所以,只要在 mutex_init() 裡面,利用 C++ API 所包裝出來的 mutex,來保護 C API 的mutex 之初始化動作即可。這個 mutex_init() 的實作方法,可能如下:
mutex.h
#ifndef MUTEX_H_INCLUDED #define MUTEX_H_INCLUDED // forward declaration #ifndef __cplusplus typedef struct mutex_impl_t mutex_impl_t; #endif struct mutex_impl_t; typedef mutex_impl_t* mutex_t; #define NULL_MUTEX NULL /** * Returns 0 when success. * Returns 1 when mx is already initialized. * Returns negative value for other kind of errors. */ int mutex_init(mutex_t* pmx); #endif /* MUTEX_H_INCLUDED */
mutex.cpp
#include "mutex.h"
// real declaration
struct mutex_impl_t
{
mutex mx_;
};
int mutex_init(mutex_t* pmx)
{
static mutex mxmx; // for protecting initialization of *pmx
mutex::guard lock(mxmx); // lock on construct, unlock on destruct
if (pmx == NULL) {
return -1; // pmx must point to a mutex_t variable
}
mutex_impl_t* mx = *pmx;
if (mx == NULL_MUTEX) {
// Will construct the internal mutex object in mutex_impl_t, too.
mx = new mutex_impl_t();
return 0; // successfully initialized
}
else {
return 1; // already initialized
}
}
結語
以我的淺薄的學識,純 C 的 mutex 介面,大概最好就只能做到這樣了[16]。幸好有識出 mutex_create() 潛在的 race condition,免去了以後可能的莫名其妙的難解臭蟲。不過,因為不能苟同 C 的世界對程式設計師的「信任」,我最後還是完全放棄了 C 的介面。因為如同前面稍微提到的,除了 mutex 以外,還有其他地方,存在著難以抉擇的兩難—而這些在 C++ 的世界裡,都不是問題。
參考資料 (2008-01-23 新增)
- Boost.Thread: Rationale for the Lock Design - Explains why the mutex lock concept in Boost.Thread is not thread-safe.
- Boost.Thread: Rationale for NonCopyable Thread Type -
- 也不是最近啦,這篇又是寫很久,又臭又長的那種。 ↩
- 這裡指應用 API 寫出來的程式。 ↩
- 其實根本就是 Boost.Thread 的精簡版。 ↩
- 如使用 POSIX thread 的話,就要用
pthread_mutex_init()初始化pthread_mutex_t,以及使用pthread_mutex_destroy()銷毀pthread_mutex_t。 ↩ - Global variable 其實也會有同樣的問題。 ↩
- 2007-12-26 更新:從 MSDN 上的
InitializeCriticalSection()這一頁裡,可以看到這句:"A critical section object cannot be moved or copied."。 ↩ - 這個也可以講很多,可以自成一題。 ↩
- 在我的觀念裡,pointer 是一種 handle,但 handle 不一定是 pointer。 ↩
- 其實,若 mutex_t 是個 handle,也會有這種「張冠李戴」的問題,看來是確實要另開一篇專門討論 handle-resource table 的問題了。 ↩
- 一般以丟出 std::bad_alloc 或其他 exception 的形式反應出來。 ↩
- 若初始化失敗了,會先把配置好的記憶體釋放,然後才回傳 NULL。 ↩
- 呼叫
memset(&mx, 0, sizeof(mx))將mx整個設成零,或於宣告時這麼宣告:mutex_t mx = {0};。 ↩ - 使用
mutex_initialized()或與 0 比較。 ↩ - 中文真饒口,英文 static local variable 還是比較好懂些。 ↩
- 這當然是因為程式寫的不夠好的緣故。 ↩
- Pthread 裡的
pthread_mutex_init()的 prototype 也跟這篇的mutex_init()幾乎一樣,只多了個pthread_mutexattr_t參數而已。 ↩
8 Comments
我認為 init_mutex 還是不夠安全的, 至少以泛用 library 的角度來看.
例如 user 在進入 main 之前使用 init_mutex 來啟始一個 lifetime 是全局的 mutex, 那誰來確保 init_mutx 當中的 mxmx 的正確起始呢?
這個實作依然沒成功跳脫出 "使用一個 global instance 去確保另一個 global instance 的正確啟始的矛盾".
fr3@K,
Static variable inside a function 保證在第一次進入該 function,走到該行時,被初始化(成 0 或呼叫 ctor),而 mutex 的初始化,最終會呼叫到 OS 所提供的 API,所以只要 OS 所提供的 API 沒有問題,那 mxmx 的正確起始,應該就沒有問題。
Jeff Hung
如果一個以上的 thread 在進入 main 之前使用 init_mutex 呢?
不好意思, 之前 comment 的時候完全搞錯. 問題跟進入 main() 與否一點關係也沒有. Sorry.
但 race condition 依然存在. 若有一個以上的 thread 呼叫 mutex_init(), 就有多個 thread 競爭同一個可能未初始化的 function static instance (a mutex, in this case) 的狀況.
fr3@K,
那最終還是要看 OS 提供的 API 的能力,如果 OS 提供的 API,沒辦法解決這個問題的話,那整件事情就多半無解了。
不過話又說回來,如果不考慮「不經由我們的library而建立的thread」的話,大可以再建立thread之前,先確保這些該初始化過的mutex先初始好。
Jeff Hung
如果原來的 client code, 也就是 get_serial_number 改成這樣:
unsigned int get_serial_number()
{
static unsigned int s_serial = 0;
static mutex_t* s_serial_mutex = mutex_create();
// ...
其實就跟改進後的 API 實效差異不大. 都是依靠一個 file local static instance 來保護另外一個 instance.
如果 OS 是 unix-alike 或底層可用 pthread, 可以試試 pthread_once 或是以 PTHREAD_MUTEX_INITIALIZER 來啟始一個 file static 的 pthread_mutex_t 物件.
如果 OS 是 Windows, 可以嘗試以 InterlockedIncrement 等的原子運算去操作一個 file scope static 的 integer, 將該 integer 當作一個 spin lock 使用.
再次為之前的 comment 致歉.
fr3@K,
無須致歉啊。:-)
我有印象 static variable 的 initialization 不能夠呼叫 function。查了一下 C99,6.7.8.4 這麼說:
因為 mutex_create() 是 C 的 API,可能被純 C 的程式呼叫,因此這一行是違反 standard 的:
雖然現在的 compiler 多是 C/C++ 混合版,沒那麼嚴格。
另外,我認為 API 的設計,最好是能夠「堵死」所有可能的出錯寫法。所以,mutex_init() 會比 mutex_create() 好。
至於怎樣應用 OS 提供的 API 以確保 thread-safe-mutex-initialization,您提得這些都是很有效的辦法,頗值得再細究一番。
Jeff Hung
Hi Jeff,
你說的對, 我上一個 comment 提到的等效方法看來 pure C 是不支持的.
另, 在這篇的第 16 個 footnote, 你提到 pthread_mutex_init() 與這篇的 mutex_init() 除了 pthread_mutexattr_t 參數之外是一樣的. 提醒一下, 雖然 interface 是一致的, 但語意確不相同. Quote from man 3 pthread_mutex_init:
Post a Comment