C1X 系列 : 多執行緒 (N1494)
感謝林永聽投遞本文。 校對:方騰飛
1. 關於 C1X 標準
C1X 是 C 語言的下一個標準,用於取代現有的 C99 標準。 C1X 是一個非正式名字,該標準仍在制訂中,最新的工作草案是 N1494 ,釋出於 2010 年 6 月。與 C99 相比, C1X 在語言和庫上有顯著的變化,本文重點分析 N1494 草案中的多執行緒部分。
2. 呼之欲出的多執行緒
不瞞你說, C99 標準裡面的記憶體模型仍然是單執行緒的,即所有程式碼都執行在一個執行緒(程序)內。也許,你簡直不敢相信這個是真的,因為說不定你每天都與多執行緒打交道, 使用 _beginthread , CreateThread 或 pthread_create 等不同平臺的函式去建立執行緒。當你擔心每個變數被編譯器優化時,不得不加上 volatile 修飾符時,或者加上記憶體柵欄 (memory barrier) ,都印證了 C99 為單執行緒的記憶體模型。
32 位保護模式的出現,催生了多工作業系統的誕生,隨之而來的就是多執行緒環境。隨著多核時代的到來,多執行緒環境成為程式開發不可逃避的問題。無鎖程式設計,並行程式設計等已經成熟的技術在 C 社群裡,常常給初學者遙不可及的感覺。在迫切需要多執行緒的時代,不同廠商和平臺都紛紛開發了各自的多執行緒庫,如 POSIX 的 pthreads 和 window 的 winThread 。軟體工程師在特定的平臺下,使用各自的執行緒庫來開發多執行緒或併發程式,並非很難,但可移植卻成了他們面臨的問題。如 pthreads 在預設情況下,互斥鎖是非遞迴的,要使用遞迴互斥鎖,程式庫必須支援一些擴充套件 feature ;而 window 下的鎖預設情況下是遞迴的。 lock-wait-wakup 方式也不盡然相同,這對開發跨平臺的多執行緒程式來說無異於雪上加霜。
放眼 C 語言的後來者,如 Java ,很早就支援了多執行緒,並且它的記憶體模型在不斷地修改,以適應發揮多執行緒的優勢。 Erlang 作為一種天生的併發程式語言,踏上程式語言的行列,成為併發程式設計的新星。同時,很多指令碼語都內建地支援執行緒類或結構。 C/C++ 作為後來加入到多執行緒的行列,儘管已經太晚了,但對於 C 語言社群來說,這無疑是最令人興奮的訊息了。
3. C1X 多執行緒介面與語義
如果你有 pthreads 的開發經驗,應該對 C1X threading 不會感到陌生,因為它們在 API ,引數和語義方面都驚人地一致,以致於你學會了 pthreads ,基本就學會了大半個 C1X threading 了。相反,對於 winthread 的開發人員來說,需要換一個角度來看等多執行緒,特別是 cnd_wait 和 cnd_signal 之間的 time race 關係。好吧,讓我們一覽目前最新的 C1X threading 程式設計吧。
3.1 執行緒管理
int thrd_create(thrd_t *thr, thrd_start_t func, void *arg);
thrd_create 建立一個新執行緒,該執行緒的工作就是執行 func(arg) 呼叫,程式設計師需要為執行緒編寫一個函式,函式簽名為: thrd_start_t ,即 int (*)(void*) 型別的函式。新建立的執行緒的識別符號存放在 thr 內。
與 pthread_create 函式相比, thrd_create 函式沒有執行緒屬性這一引數,並具執行緒函式的返回值是 int ,而非 pthreads 的 void * 。這一特點與程序的返回值一致,都是使用整數表示一個任務的結束狀態。
thrd_t thrd_current(void);
thrd_current 函式返回呼叫執行緒的識別符號。類似於 pthreads 下的 pthread_self() 函式。
int thrd_detach(thrd_t thr);
thrd_detach 知會作業系統,當該執行緒結束時,作業系統負責回收該執行緒所佔用的資源。
int thrd_equal(thrd_t thr0, thrd_t thr1);
thrd_equal 用於判斷兩個執行緒識別符號是否相等(即標識同一執行緒), thrd_t 是標準約定的型別,可能是一個基礎型別,也可能會是結構體,開發人員應該使用 thrd_equal 來判斷兩者是否相等,不能直接使用 == 。即使 == 在某個平臺下表現出來是正確的,但它不是標準的做法,也不可跨平臺。
void thrd_exit(int res)
thrd_exit 函式提早結束當前執行緒, res 是它的退出狀態碼。這與程序中的 exit 函式類似。
int thrd_join(thrd_t thr, int *res)
thrd_join 將阻塞當前執行緒,直到執行緒 thr 結束時才返回。如果 res 非空,那麼 res 將儲存 thr 執行緒的結束狀態碼。如果某一執行緒內沒有呼叫 thrd_detach 函式將自己設定為 detach 狀態,那麼當它結束時必須由另外一個執行緒呼叫 thrd_join 函式將它留下的僵死狀態變為結束,並回收它所佔用的系統資源。
void thrd_sleep(const xtime *xt)
thrd_sleep 函式讓當前執行緒中途休眠,直到由 xt 指定的時間過去後才醒過來。
void thrd_yield(void)
thrd_yield 函式讓出 CPU 給其它執行緒或程序。
3.2 互斥物件和函式
C1X threading 中提供了豐富的互斥物件,使用者只需 mtx_init 初始化時,指定該互斥物件的型別即可,如遞迴的,支援 timeout 和,或者支援鎖檢測。
int mtx_int(mtx_t *mtx, int type);
mtx_init 函式用於初始化互斥物件, type 決定互斥物件的型別,一共有下面 6 種類型:
- mtx_plain — 簡單的,非遞迴互斥物件
- mtx_timed — 非遞迴的,支援超時的互斥物件
- mtx_try — 非遞迴的,支援鎖檢測的互斥物件
- mtx_plain | mtx_recursive — 簡單的,遞迴互斥物件
- mtx_timed | mtx_recursive — 支援超時的遞迴互斥物件
- mtx_try | mtx_recursive – 支援鎖檢測的遞迴互斥物件
int mtx_lock(mtx_t *mtx) int mtx_timedlock(mtx_t *mtx, const xtime *xt) int mtx_trylock(mtx_t *mtx)
mtx_xxxlock 函式對 mtx 互斥物件進行加鎖 , 它們會阻塞,直到獲取鎖,或者 xt 指定的時間已過去。而 trylock 版本會進行鎖檢測,如果該鎖已被其它執行緒佔用,那麼它馬上返回 thrd_busy 。
int mtx_unlock(mtx_t *mtx)
mtx_unlock 對互斥物件 mtx 進行解鎖。
3.3 條件變數
C1X 中的條件變數與 pthreads 中的條件變數是一樣的, C1X 通過 mtx 物件和條件變數來實現 wait-notify 機制,這與 Java 語言裡 Object 物件中的 wait() 和 notify() 方法類似。
int cnd_init(cnd_t *cond)
初始化條件變數,所有條件變數必須初始化後才能使用。
int cnd_wait(cnd_t *cond, mtx_t *mtx) int cnd_timedwait(cnd_t *cond, mtx_t *mtx, const xtime *xt)
cnd_wait 函式自動對 mtx 互斥物件進行解鎖操作,然後阻塞,直到條件變數 cond 被 cnd_signal 或 cnd_broadcast 呼叫喚醒,當前執行緒變為非阻塞時,它將在返回之前鎖住 mtx 互斥物件。 cnd_timedwait 函式與 cnd_wait 類似,例外之處是當前執行緒在 xt 時間點上還未能被喚醒時,它將返回,此時返回值為 thrd_timeout 。 cnd_wait 和 cnd_timedwait 函式在被呼叫前,當前執行緒必須鎖住 mtx 互斥物件。
int cnd_signal(cnd_t *cond) int cnd_broadcast(cnd_t *cond)
cnd_broadcast 函式用於喚醒那些當前已經阻塞在 cond 條件變數上的所有執行緒,而 cnd_signal 只喚醒其中之一。
void cnd_destroy(cnd_t *cond)
cnd_destroy函式用於銷燬條件變數。
3.4 初始化函式
試想一下,如何在一個多執行緒同時執行的環境下來初始化一個變數,即著名的延遲初始化單例模式。你可能會使用 DCL 技術。但在 C1X threading 環境下,你可以直接使用 call_once 函來實現。
void call_once(once_flag *flag, void (*func)(void))
call_once 函式使用 flag 來保確 func 只被呼叫一次。第一個執行緒使用 flag 去呼叫 call_once 時,函式 func 會被呼叫,而接下來的使用相同 flag 來呼叫的 call_once , func 均不會再次被呼叫,以保正 func 在多執行緒環境只被呼叫一次。
3.5 執行緒專有資料 (thread-specific data, TSD) 和執行緒區域性資料 (thread-local storage, TLS)
在多執行緒開發中,並不是所有的同步都需要加鎖的,有時巧妙的資料分解也可減少鎖的碰撞。每個執行緒都擁有自己私有資料,使用它可以減少執行緒間共享資料之間的同步開銷。
如果要將一些遺留程式碼進行執行緒化,很多函式都使用了全域性變數,而在多執行緒環下,最好的方法可能是將這些全域性量變數換成執行緒私有的全域性變數即可。
TSD 和 TLS 就是專門用來處理執行緒私有資料的。 它的生存週期是整個執行緒的生存週期,但它在每個執行緒都有一份拷貝,每個執行緒只能 read-write-update 屬於自己的那份。如果通過指標方式來 read-write-update 其它執行緒的備份,它的行為是未定義的。
C1X 同時提供了 TSD 和 TLS 特性,而 pthreads 只提供 TSD ,但在 linux 下的 gcc 編譯器提供了 TLS 作為擴充套件特性。 TSD 可認為執行緒私有記憶體下的 void * 組數,每個資料項的 key 對應於陣列的下標,用於索引功能。當一個新執行緒建立時,執行緒的 TSD 區域將所有 key 關聯的值設定為 NULL 。 TSD 是通過函式的方式來操作的。 C1X 中 TSD 提供的標準函式如下:
int tss_create(tss_t *key, tss_dtor_t dtor) void tss_delete(tss_t key) void *tss_get(tss_t key) int tss_set(tss_t key, void *val)
tss_create 函式建立一個 key , dtor 為該 key 將要關聯 value 的解構函式。當執行緒退出時,會呼叫 dtor 函式來釋放該 key 關聯的 value 所佔用的資源,當然,如果退出時 value 值為 NULL , dtor 將不被呼叫。 tss_delete 函式刪除一個 key , tss_get/tss_set 分別獲得或設定該 key 所關聯的 value 。
通過上述 TSD 來操作執行緒私有變數的方式,顯得相對繁瑣 ; C1X 提供了 TLS 方法,可以像一般變數的方式去訪問執行緒私有變數。做法很簡單,在宣告和定義執行緒私變數時指定 _Thread_local 儲存修飾符即可,關於 _Thread_local , C1X 有如下的描述:
在宣告式中,_Thread_local 只能單獨使用,或者跟 static 或 extern 一起使用。
在某一區塊( block scope) 中宣告某一物件,如果宣告儲存修飾符有 _Thread_local ,那麼必須同時有 static 或 extern 。
如果 _Thread_local 出現在一物件的某個宣告式中,那麼此物件的其餘各處宣告式都應該有 _Thread_local 儲存修飾符。
如果某一物件的宣告式中出現 _Thread_local 儲存修飾符,那麼它有執行緒儲存期( thread storage duration )。該物件的生命週期為執行緒的整個執行週期,它線上程出生時建立,並在執行緒啟動時初始化。每個執行緒均有一份該物件,使用宣告時的名字即可引用正在執行當前表示式的執行緒所關聯的那個物件。
TLS 方式與傳統的全域性變數或 static 變數的使用方式完全一致,不同的是, TLS 變數在不同的執行緒上均有各自的一份。執行緒訪問 TLS 時不會產生 data race ,因為不需要任何加鎖機制。 TLS 方式需要編譯器的支援,對於任何 _Thread_local 變數,編譯器要將之編譯並生成放到各個執行緒的 private memory 區域,並且訪問這些變數時,都要獲得當前執行緒的資訊,從而訪問正確的物理物件,當然這一切都是在連結過程早已安排好的。
4. C1X threading 的未來
C1X threading 的整體設計與 pthreads 的驚人地一致,我甚至懷疑它們是出自一人(團隊)之手。我最初接觸多執行緒編譯中的等待 – 通知原語是從 Java 中 Object 物件裡的 wait 和 notify 函式中獲得感性認識,然後在工作中將 pthreads 中的 pthread_cond_wait 和 pthread_cond_signal 函式應用於實際工作中,並解決了很多實際問題,如編寫執行緒安全的資料結構。現在最新的 C1Xthreading 標準,執行緒等待 – 通知的方式與上述兩者如同一轍。
與 pthreads 相比, C1X threading 沒有了訊號量操作,讀寫鎖和自旋鎖。顯然開發人員可以借用 C1X threading 中提供的同步機制來實現訊號量和讀寫鎖,但要實現自旋鎖是比較難,這需要深入瞭解所在平臺和作業系統提供的原語,在此基礎上再實現自旋鎖。
對於 window 開發者來說, C1X threading 是一個新的標準,裡面提供的同步原語與 winthread 相比,顯得有點冷清。不過 window 下的開發人員通常都使用 C++ 或 MFC 提供的多執行緒庫來開發,只是對於那些完全使用 C語言來開始跨平臺多執行緒程式的同行來說,只能完全遵循 C1X threading 標準了。
C1X threading 以記憶體共享模型作為多執行緒程式設計模型,提供的同步機制基於鎖來實現。將來是否會提供系統級別的,基於訊息傳遞來實現無鎖同步。
5. 總結
多執行緒和多核開發時代已悄悄降臨在我們的身邊,無論你現在的開發工作是否與並行開發相關, C1X threading 都應該成為你手中的一把利器。 C1X threading 了卻你心中很多疑慮,可移植, TSD/TLS 之抉擇等。