本系列共有三篇文章,以及一篇補充資料,建議依照以下順序閱讀:

最近[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 of mutex_t 參數,就好像我們對 mutex_init() 的處理一樣,可以於 mutex_destroy() 裡面 reset mutex_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_serials_serial_mutex 提升成為全域變數 g_serialg_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 新增)


  1. 也不是最近啦,這篇又是寫很久,又臭又長的那種。
  2. 這裡指應用 API 寫出來的程式。
  3. 其實根本就是 Boost.Thread 的精簡版。
  4. 如使用 POSIX thread 的話,就要用 pthread_mutex_init() 初始化 pthread_mutex_t,以及使用 pthread_mutex_destroy() 銷毀 pthread_mutex_t
  5. Global variable 其實也會有同樣的問題。
  6. 2007-12-26 更新:從 MSDN 上的 InitializeCriticalSection() 這一頁裡,可以看到這句:"A critical section object cannot be moved or copied."。
  7. 這個也可以講很多,可以自成一題。
  8. 在我的觀念裡,pointer 是一種 handle,但 handle 不一定是 pointer。
  9. 其實,若 mutex_t 是個 handle,也會有這種「張冠李戴」的問題,看來是確實要另開一篇專門討論 handle-resource table 的問題了。
  10. 一般以丟出 std::bad_alloc 或其他 exception 的形式反應出來。
  11. 若初始化失敗了,會先把配置好的記憶體釋放,然後才回傳 NULL。
  12. 呼叫 memset(&mx, 0, sizeof(mx))mx 整個設成零,或於宣告時這麼宣告:mutex_t mx = {0};
  13. 使用 mutex_initialized() 或與 0 比較。
  14. 中文真饒口,英文 static local variable 還是比較好懂些。
  15. 這當然是因為程式寫的不夠好的緣故。
  16. Pthread 裡的 pthread_mutex_init() 的 prototype 也跟這篇的 mutex_init() 幾乎一樣,只多了個 pthread_mutexattr_t 參數而已。