const char* GenLogFilename(const char *path, int idx)
{
char filename[100];
sprintf(filename, "%s/log-%d.txt", path, idx);
return filename;
}
執行緒同步 17:本地存儲
作為本系列完結的最後一期,本篇要來介紹一種性質特殊的變數:執行緒本地變數。
首先是對於這東西的稱呼稍微混亂一點, 有地方稱它為「執行緒本地變數」(Thread-Local Variable), 有的稱呼為「執行緒本地存儲」(Thread-Local Storage), 但其實指的是一樣的東西,而其共同點就是 Thread-Local。
回顧記憶體區間劃分
那麼 thread-local 是什麼性質呢? 為了解釋這東西,先讓我們來回顧計算機上基本的三種資料存儲空間, 也就是堆疊(stack)、堆積(heap)、和全域(global)空間。
堆疊空間(stack)存放的就是那些自動分配的變數,如函式內的區域變數與函式參數等。
這些變數的生命週期相依於程式碼上的變數可見域。
函式內的區域變數在函式被呼叫時存在,在函式返回時失效,
甚至於我們可以使用花括弧(「{」和「}」)去限制區域變數的作用域在更小的範圍內。
這些變數在記憶體內都是被規劃存放在堆疊的空間,而堆疊本身具有上述的這些特性。
程式在進入特定作用域的時候會對堆疊進行 push 操作以取得空間並分配給區域內的變數,
而在離開特定作用域的時候則會對堆疊進行 pop 操作以釋出那些變數的空間。
並且因為每一條執行緒擁有屬於自己的堆疊,因此不同執行緒之間的區域變數是獨立存在的。
堆積空間(heap)則是由程式碼的指示,也就是我們程式設計者手動執行的操作來進行分配與釋放,
也就是我們透過像 malloc()/free() 或 new/delete 這類操作來進行空間的索取與釋放。
堆積空間的生命週期與程式碼作用域無關,也沒有所屬的執行緒,
它的建立與刪除全由明確的呼叫指示來進行。
並且不同的執行緒共用同一個堆積空間,所有執行緒都可以操作使用並互相影響,
而由這個執行緒所索取分配的空間亦可被其它執行緒釋放。
三者之中大概就是全域空間(global)當屬最單純了。 全域空間就是一個單純的記憶體空間,沒有什麼索取分配與釋放的操作機制, 或者說它的分配與釋放是在整個應用程式的執行與退出時由作業系統來進行的。 全域變數就是在 C/C++ 程式碼裡面那些被宣告在任何函式之外,不屬於任何函式的變數; 或者宣告在函式內的靜態變數(static variable),或宣告在類別內的靜態變數, 其實都只是程式碼可見域的不同,其實質仍都是屬於全域變數。 而當然,與堆積空間相同,全域空間也是屬於行程的, 因此所有執行緒共享同一個全域空間,大家都可以操作使用全域空間內的變數並且互相影響。
堆疊、堆積、與全域空間是行程記憶體管理佈局的真實劃分, 而所有其它更加豐富多元的變數或物件型態都是建立在此之上的花俏設計, 如 RAII 或垃圾回收(garbage collection)等, 也包含了本篇所重點關注的 thread-local 型態變數。 thread-local 類型的變數同時具有全域變數和區域變數的部份特性, 既像全域變數一樣能讓所有的執行緒看見, 又像區域變數一樣是每個執行緒各自擁有一份實體的獨立變數。
需求場景
前一節的敘述也許可能沒能讓人足夠清楚明白,那麼我們就來從實際的例子看看, 到底在什麼情況下會讓我們需要 thread-local 變數? 也就是說,thread-local 機制到底是為了什麼樣的需求目的而存在的?
比方當我們剛開始在學習 C 語言時,也許在許多時候會有想要寫下下面這樣函式的衝動:
上面這樣的程式碼比較常出現在新手手裡,我自己在剛開始學的時候也是同樣的菜, 在我們足夠熟練 C 語言之後自然也會改用其它更加合適恰當的寫法。 只不過上面這樣的寫法正好可以用來說明本篇所關注的重點, 因此就讓我們暫時忽略掉一些屬於設計上的不適當問題, 但是請並不要學習上面程式的設計規劃方式。
上面的函式有一個大問題,其中的 filename 陣列是一個區域變數,在離開函式之後就失效了!
(不過因為 C 語言不會多事去清除無用記憶體空間的內容,
因此雖然 filename 變數已經失效,但儲存在記憶體上的資料可能還沒有被其它資料給覆蓋。
也就是說上面的函式很可能在許多情況下仍能夠產生如設計預期的行為表現,
因此很可能會讓初學者在動手進行的測試摸索中認為可以這麼寫,而沒有察覺到它存在的問題。
我想包含我在內的許多當年的初學者可能都有經歷過那樣鬼打牆的經驗!)
那麼要如何解決區域變數在離開函式之後失效的問題呢?
其中一個簡單暴力的方法就是把那個 filename 改成靜態變數,也就是全域變數吧:
const char* GenLogFilename(const char *path, int idx)
{
// 將這個變數寫成靜態變數(全域變數)!
static char filename[100];
sprintf(filename, "%s/log-%d.txt", path, idx);
return filename;
}
這樣子就簡單粗暴的解決問題了!
別管優雅不優雅,但確實能用。
而且稍微回憶就會發現 C 語言標準函式庫裡面的許多函式就是這麼設計的,
比方 strtok()、gmtime()、localtime() 等,還有那個 errno 也是如此。
這種設計在從前來說也沒什麼太大的問題。 雖然說這種設計方式把資料寫入在共用的記憶體空間裡, 只要其它地方的程式碼也呼叫了這些函式,就會導致這些資料被修改覆寫。 但是只要使用者在呼叫這些函式之後立刻將資料進行應該做的處理, 或將資料拷貝出來到別的地方也能繞過這些問題; 至於如果有人就愛叫了這類函式之後不立刻處理結果, 偏喜歡在安插了許多其它一百個工作之後才回頭處理這些資料並遭遇資料不匹配的問題, 那我們一般會歸咎於是使用者不能正確使用函式所造成的問題。
然而在多緒設計的程式裡,這種使用全域空間儲存與傳遞資料的手段卻遭遇了問題!
即便我們呼叫了函式之後就立刻處理資料,
但有沒有可能正好遇上其它執行緒也呼叫了相關的函式並覆寫了我們所需要的那些資料呢?
不只可能,在實際的應用上這機率可是太高了!
那麼既然如此,當時為什麼設計出這樣不優雅的函式呢?
如果是一般的泛泛之輩或在家自學就圖個興趣的設計者做出這種設計那也就罷,
但是 C 語言標準函式庫的設計那肯定不是簡單的角色能承擔的,怎麼就做出了這樣的函式設計呢?
原來就是我在本系列曾解釋過的原因:
執行緒畢竟是相較比較近代才出現的東西,而那個古老的從前並沒有多緒的問題
(如需要平行運算的話會採用多行程的做法)。
然而時過境遷,現在它產生了問題。
不過到現在才想要重新修改函式介面設計的話已是不可能,
因為已經有太多既成的程式呼叫了那些函式,貿然改變函式介面就會產生不相容的問題;
如真想改變介面定義,那只能設計新的函式並推廣鼓勵使用者改用它,例如 strtok_r() 等。
那麼那些舊的函式只能放生了嗎?
有沒有什麼解決方案能夠讓我們解決舊的函式在多緒時代的適用性問題呢?
有的!
這就是 thread-local 型態最大的用武之地,
也是為什麼標準函式庫的那些函式在現代多緒環境下使用也沒有造成什麼困擾的解決方法。
以上面的範例函式來說,只要把那個 filename 宣告成 thread-local 型態的全域變數就可以了:
const char* GenLogFilename(const char *path, int idx)
{
// 將這個變數寫成 thread-local 變數!
static thread_local char filename[100];
sprintf(filename, "%s/log-%d.txt", path, idx);
return filename;
}
如此僅僅加上了 thread_local 修飾字,
就解決了 filename 這個全域變數在多緒條件下所產生的資料混淆錯亂問題。
這個 thread-local 型態的 filename 變數與原來的純靜態變數一樣都具有全域變數的特性,
也就是在函式返回之後仍然存在並可用,其在整個程式的生命週期都有效且可用。
(其實更正確的說法是在執行緒的生命週期有效,然而目前為止這點差異還不影響理解。)
然而在前述的基礎之上,filename 卻實際是每一個執行緒所獨立擁有的,
這部份使它俱備與區域變數相似的特性。
因此函式仍然可以使用靜態變數或全域變數傳遞資料,卻不會受到不同執行緒之間的干擾影響,
使用起來就好似不存在多緒似的!
Thread-Local 的特性與使用方式
在前面的範例,僅使用 thread_local 來修飾全域變數(靜態變數)就簡單的解決了問題。
thread_local 是從 C11 開始加入的功能,專門用來修飾全域變數或靜態變數。
在 C11,thread_local 是一個巨集,被定義在 <threads.h> 內;
然而它到 C23 被移除了,只不過被移除的原因並不是因為被棄用,
而是直接變成 C 語言關鍵字,不需要引用 <threads.h> 也能夠使用了!
不過其實早在 C/C++ 納入 thread-local 之前,
許多編譯器就已經以自己的擴展功能來支援 thread-local 型態變數,
例如 GCC 的 Builtin 關鍵字:__thread。
經過 thread_local 修飾的全域變數(及靜態變數)實際上成為了 thread-local 型態的變數,
其同時俱備全域變數與區域變數的部份特性,具有與全域變數一樣的生命週期和可見性,
又具有區域變數一樣由每一個執行緒各自獨立擁有一份空間而不互相干擾的特性,
為此我們也可以很形象化的形容它為一種「由個別執行緒所獨立擁有的全域變數」。
雖然原理上 thread-local 的使用就是這樣簡單單純,然而實際的使用上卻存在一些限制。 對於這部份,我在網上查閱的 C 語言相關說明 [1] 描述的相當陽春,於是這裡改用 GCC 官方文檔的說明 [2] 為依據。 總結相關的使用條件與限制如下:
-
thread_local可以被修飾在任何形式的全域變數上, 包含無其它修飾的全域變數,及被static或extern關鍵字所修飾的全域變數。言下之意就是只能用在全域變數上。這是只針對全域變數所設計,也只被全域變數所需要的型態; 事實上其它型態的變數如區域變數,也不存在多緒互相干擾的問題!
-
因為 thread-local 型態變數實際上在每一條執行緒內都擁有一份獨立的資料空間, 因此如果你使用取址操作(即使用「
&」取得變數的記憶體位址), 那麼得到的會是當下執行緒所屬變數的位址; 也就是說在不同的執行緒下對同一個 thread-local 變數進行取址,會得到不同的記憶體位址。而當然的,若你直接把這個地址傳遞給別的執行緒做處理使用, 那麼其它的執行緒就可以讀寫修改原本只屬於你的執行緒的變數內容了!
-
Thread-local 只能在執行時期被初始化,而不能如真正的全域變數那樣做靜態初始化; 不過這不是說我們不能夠在程式碼上給 thread-local 變數寫下靜態初始化的語句, 只不過實際會被延遲到執行緒被建立的當下才進行。 因此 thread-local 的靜態初始化語句只能夠使用在編譯時期就能夠確定下來的內容來填寫, 例如字面常數,或 C++ 的 constant-expression。
這是因為如同在前面所描述的,thread-local 在實際上仍然是建立在那三個基本記憶體之上的包裝機制, 而
thread_local這樣簡單的修飾字其實就是 C 語言的語法糖。 因此落實到實際面, 個別執行緒的 thread-local 變數其實是在執行緒被建立與銷毀的時候所動態建立與銷毀的, 因此編譯器需要能夠知道到底該如何初始化變數的內容, 也因此才導致 thread-local 的初值必須能在編譯時期確定下來。
範例測試
講解完 thread-local 變數的原理與特性之後,接下來讓我們使用一個範例來進行實際的測試。 在這個範例,我建立了兩個用來作演示的變數,而當然它倆是全域的:
// 這裡定義兩個全域變數,
// 其中一個是普通的變數,
// 另一個則是 thread-local 型態的變數。
int thread_share_value = 0;
thread_local int thread_local_value = 0;
這範例程式建立了兩個執行緒,兩個執行緒分別執行兩個函式, 但由於這兩個函式的內容非常相似,因此這裡只展示其中一個就可以了:
int ThrdFunc1(void *userarg)
{
SleepMs(500);
for(int i = 0; i < 5; ++i)
{
thread_share_value += 1;
thread_local_value += 1;
printf("| Thrd-1:\t%d\t%d\t(+1)\n",
thread_share_value, thread_local_value);
SleepMs(1000);
}
printf("Thrd-1: Addr. of share_value=%p, local_value=%p\n",
&thread_share_value, &thread_local_value);
return 0;
}
在執行緒裡面,我使用一個迴圈來給那兩個全域變數不斷累加。 這裡看到我這條執行緒每次都給數值加一,但另一條執行緒是每次給數值加二。 主要目的是為了能更容易的區別出來變數值的變化到底是由哪條執行緒所造成的, 但對程式的行為沒什麼影響,只要知道有這件事存在就可以了。
此外多緒共同對那個普通的全域變數進行操作,在理論上可能會遭遇資料衝突的問題, 在正常情況下的解決方法也很簡單,改使用原子變數,或使用互斥鎖保護即可。 然而為了避免混淆誤會或者在閱讀上的干擾, 因此在這個範例我還是捨棄了原子變數而選擇使用普通的變數。 那麼既然使用了普通的變數,又該如何避免資料競爭的問題呢? 在這範例程式裡可看到我在休眠的時間上做了一些小手段,使兩條執行緒的迴圈步階正好互相錯開 0.5 秒, 使它們不會產生同時搶操作的問題,就這樣迴避了可能導致的競爭問題。 那麼 thread-local 型態的全域變數在實際的應用中會遭遇資料競爭的問題嗎? Thread-local 只不過是看起來像是共用的變數,實際上是每一條執行緒自己獨自擁有一份的, 因此不存在多續競爭的問題。 (當然你若非要傳遞指標讓其它執行緒可以存取覆寫並產生衝突的情況除外!)
完整的程式碼可以從這裡下載, 而其在我電腦上執行的結果如下:
| Thread Share Local Comment
|---------------------------------------
| Thrd-2: 2 2 (+2)
| Thrd-1: 3 1 (+1)
| Thrd-2: 5 4 (+2)
| Thrd-1: 6 2 (+1)
| Thrd-2: 8 6 (+2)
| Thrd-1: 9 3 (+1)
| Thrd-2: 11 8 (+2)
| Thrd-1: 12 4 (+1)
| Thrd-2: 14 10 (+2)
| Thrd-1: 15 5 (+1)
Thrd-2: Addr. of share_value=0x5652e697d014, local_value=0x7fb4f69416fc
Thrd-1: Addr. of share_value=0x5652e697d014, local_value=0x7fb4f71426fc
Thrd-0: Addr. of share_value=0x5652e697d014, local_value=0x7fb4f714373c
從上面的結果可以看出來,普通的全域變數確實是大家一起共用的,所有的修改都會互相影響; 而 thread-local 型態的全域變數則其實實際上是各玩各的, 每一條執行緒擁有自己獨立的一份,彼此並不互相影響。 列印這些變數的記憶體位址也同樣印證前面所敘述的內容, 由不同的執行緒對普通全域變數取址的結果是一樣的,而對 thread-local 變數所取出來的地址則不相同, 因此眼見證實 thread-local 的變數確實是在不同的執行緒之下各自不同。
手動操作維護 Thread-Local Storage
如果你只對 thread-local 變數的原理與使用方式感興趣, 只想知道如何在你的程式碼裡面使用 thread-local 的修飾寫法的話, 那麼到此為止的內容已經完全足夠使用了,並不需要繼續了解手動操作 thread-local 的相關內容。
雖然無論是 C/C++ 或者是 pthread 的程式庫都提供了手動建立與維護 thread-local 存儲的 API,
然而就作者我的理解與使用經驗來說,如果我需要的話也只會使用 thread_local 或 __thread 修飾字而已,
一時還想不到有任何基於必要性的理由讓我去手動進行這件事。
其實 thread_local 或 __thread 都只是一種語法糖,
其背後的真實底層機制仍脫不開本篇前面所敘述的那三個基本記憶體分區,
其實際上仍只是編譯器在其之上所進行的高級包裝功能而已。
當我們簡單的寫下修飾字就能讓變數產生神奇的效果,
其實也就是編譯器在背後幫我們進行相關的建立與銷毀等維護工作的展開,
連 GCC 的說明文件都明說了相關的機制還需要連結 pthread 的功能來實現。
那麼若脫離了編譯器的自動工作而選擇自己來執行相關的維護操作,肯定是會比較複雜瑣碎的。
然而在現實上,若我需要使用 thread-local 的機制,
自然是為了解決原本某個全域變數的使用在多緒環境下所產生的問題,
這時自然最需要也最該做的就是直接使用語法糖來修飾這些變數,讓編譯器自動去搞定背後的一切。
至於若要讓我去手動操作控制維護?
如果說都能夠改寫或重做到這個份上了,也許我更會考慮的是重新設計更加合適的軟體結構來應對問題,
而不是去選擇手動操作與維護 thread-local 變數。
那麼說一千道一萬,為什麼我還要在這裡展示如何手動維護 thread-local 的方式呢? 也許是因為透過自己動手實際玩一次的經驗,可以更加透撤的明白 thread-local 如何在背後運作的機制, 並且更能夠理解與掌握它的各種特性與限制吧!
那麼這裡就繼續使用前面示範測試的那個程式碼,去掉對那個 thread_local 修飾字的使用,
然後以手動呼叫相關功能 API 的方式來實現同樣的功能吧!
首先就是將原本以 thread_local 修飾的那個全域變數改為一個 tss_t 型態的 thread_local_storage 變數。
thread_local_storage 在這裡可是一個貨真價實的全域變數,它的作用其實相當於是一個標籤,
然後我們要在每一個需要使用到它功能的執行緒之下給它繫結一個專供那個執行緒所使用的記憶體空間。
關於繫結的部份晚一點再說明,這裡先展示這個標籤變數的宣告如下:
// 這裡同樣定義一個普通的全域變數和 thread-local 全域變數,
// 但由於這個範例要演示手動做維護 thread-local 的關係,
// 所以這裡使用的是 tss_t ,可以理解為 thread-local 變數的標籤。
int thread_share_value = 0;
tss_t thread_local_storage;
那個 thread_local_storage 變數本身也是一個普通的變數,因此也是需要被手動初始化與銷毀的。 初始化的工作需要在任何執行緒對這標籤進行操作使用之前完成, 而銷毀的工作則需要在所有執行緒都銷毀了它們對該標籤所繫結的空間之後才能進行, 因此這裡選擇在主函式的開始與結尾處進行對標籤的相關呼叫操作:
// 在開始使用 thread-local 標籤之前,
// 這個標籤必須要先被初始化。
// 其中這裡可以指派一個用來銷毀 thread-local 變數的函式,
// 讓系統在執行緒結束時自動呼叫它來銷毀所屬的 thread-local 變數。
// 當然也可以不指派銷毀函式(設定為 NULL),
// 不過這要就會需要在執行緒退出前自己手動銷毀所屬的 thread-local 變數。
int e = tss_create(
&thread_local_storage,
(void(*)(void*)) ReleaseCustomisedVariable);
assert( e == thrd_success );
/* ...中間省略建立執行緒與等待執行緒結束的過程... */
// 最後要銷毀 thread-local 標籤。
// 並且注意這件事必須在 thread-local 標籤所關聯的
// 所有 thread-local 變數都被妥善銷毀之後,
// 才能執行這個銷毀標籤的動作!
tss_delete(thread_local_storage);
在初始化標籤的時候,可以指派給它一個函式,
用來在執行緒退出的時候讓系統呼叫它去執行該如何處理我們所繫結的記憶體空間的工作;
當然這裡也可以選擇不指派這個函式而傳入 NULL,
但這樣的話每當我們在執行緒結束之前就需要自行手動處理相關的工作。
如果我們給執行緒所繫結的記憶體空間是經由 malloc() 這類函式簡單配置出來的空間,
在銷毀的時候也不需要進行什麼複雜的工作而只需要使用 free() 去釋放空間的話,
那麼這裡也可以選擇直接將 free() 函式給指派進去。
只不過在這個範例裡面因為我還想要打印一些訊息,讓我們明確知道它在什麼時候去執行了銷毀繫結的工作,
因此這裡我還是選擇指派給它一個自定義的 ReleaseCustomisedVariable() 函式。
而 ReleaseCustomisedVariable() 函式,
以及其對稱的用來建立繫結空間的 CreateCutomisedVariable() 函式內容如下:
int* CreateCutomisedVariable(int init_val)
{
int *val = malloc(sizeof(*val));
assert(val);
*val = init_val;
printf("# Create customised variable with initial-value=%d\n", *val);
return val;
}
void ReleaseCustomisedVariable(int *val)
{
assert(val);
printf("# Release customised variable with current-value=%d\n", *val);
free(val);
}
標籤的相關工作完成之後,就到了執行緒內索取維護與使用這個標籤的內容了。
如同前面所述,我們可以經由標籤來索取專門供應當下這個執行緒所專屬的記憶體空間,
這個索取繫結空間的行為可以透過呼叫 tss_get() 來取得。
但是這個標籤要怎麼知道我們需要的這個空間多大?該如何初始化?
以及裡面是否還存有其它複合的物件,又該如何正確初始化它們呢?
答案是這標籤當然不知道!
事實上這個空間是需要我們自己建立並初始化完成的,
然後透過 tss_set() 的呼叫將這個空間繫結給指定的標籤,
標籤就會記得呼叫 tss_set() 的執行緒繫結的是這個記憶體空間(指標),
這樣下次同一個執行緒再呼叫 tss_get() 的時候就可以取得這個空間了;
而當然,不同的執行緒呼叫 tss_set() 和 tss_get() 的作用是和其它執行緒完全獨立且隔離的。
如此,其中第一條執行緒函式的內容如下:
int ThrdFunc1(void *userarg)
{
// 其實這個動作不是必要的,
// 因為這個執行緒啟動時的 thread-local 變數初始肯定是 NULL,
// 不過為了證明這件事,所以這裡還是演示一下。
int *thread_local_value = tss_get(thread_local_storage);
assert( thread_local_value == NULL );
// 建立一個給這個執行緒專用的變數,
// 並將其指派給那個 thread-local 標籤。
int e = tss_set(
thread_local_storage, CreateCutomisedVariable(0));
assert( e == thrd_success );
SleepMs(600);
for(int i = 0; i < 5; ++i)
{
// 在執行緒執行的任何階段,
// 都可以向 thread-local 標籤索取它所關聯的變數。
// 並且這個變數是這個執行緒所專用的(就是上面才剛設定的那個)。
int *thread_local_value = tss_get(thread_local_storage);
assert(thread_local_value);
thread_share_value += 1;
*thread_local_value += 1;
printf("| Thrd-1:\t%d\t%d\t(+1)\n",
thread_share_value, *thread_local_value);
SleepMs(1000);
}
// 執行緒要結束了。
// 在 thread-local 有設定了銷毀函式的情況下,
// 可以選擇手動銷毀 thread-local 變數,也可以讓系統自動處理。
// 這裡選擇讓系統自動處理,於是不做任何事。
printf("# Thrd-1 is finished\n");
return 0;
}
上面那個執行緒只建立並繫結了一個空間給標籤,卻沒有再進行那個空間的釋放工作。 這是因為在初始化標籤的時候已經指派了用來執行釋放工作的函式, 因此系統會在執行緒函式離開的時候自動的去呼叫它來進行釋放工作, 所以不需要在執行緒函式裡手動進行這件事了。 但是既然作為範例,因此還是要演示一下手動執行釋放工作並解繫標籤的內容, 於是在第二條執行緒函式裡就進行了這件事:
int ThrdFunc2(void *userarg)
{
// ...前面的內容和另一條執行緒是高度相似的,因此就省略了!
// 執行緒要結束了。
// 在 thread-local 有設定了銷毀函式的情況下,
// 可以選擇手動銷毀 thread-local 變數,也可以讓系統自動處理。
// 這裡選擇手動銷毀 thread-local 變數。
printf("# Thrd-2 manually releasing thread-local variable\n");
{
int *thread_local_value = tss_get(thread_local_storage);
assert(thread_local_value);
ReleaseCustomisedVariable(thread_local_value);
// 手動銷毀了原本設定的 thread-local 變數之後,
// 要記得將 thread-local 標籤的關聯設定為 NULL,
// 免得觸發系統自動銷毀,造成重複銷毀的問題!
tss_set(thread_local_storage, NULL);
}
printf("# Thrd-2 is finished\n");
return 0;
}
完整的程式碼可以從這裡下載, 而其在我電腦上執行的結果如下:
| Thread Share Local Comment
|---------------------------------------
# Create customised variable with initial-value=0
# Create customised variable with initial-value=0
| Thrd-2: 2 2 (+2)
| Thrd-1: 3 1 (+1)
| Thrd-2: 5 4 (+2)
| Thrd-1: 6 2 (+1)
| Thrd-2: 8 6 (+2)
| Thrd-1: 9 3 (+1)
| Thrd-2: 11 8 (+2)
| Thrd-1: 12 4 (+1)
| Thrd-2: 14 10 (+2)
| Thrd-1: 15 5 (+1)
# Thrd-2 manually releasing thread-local variable
# Release customised variable with current-value=10
# Thrd-2 is finished
# Thrd-1 is finished
# Release customised variable with current-value=5
# Threads are terminated
從測試的結果可看出,程式的行為表現就與前一個範例是一樣的,
差異只是前一個範例使用了 thread_local 修飾字來簡單的完成功能,
而這一個範例則使用手動呼叫各 API 的方式來完成。
不過也因為如此,這個範例的輸出結果可以讓我們看見更多關於標籤與繫結空間相關的操作記錄。
在兩條執行緒剛開始的時候各自建立了一個專屬自己使用的記憶體空間,並將其繫結給同一個標籤。
而兩條執行緒要結束的時候表現出了不同,
第二條執行緒因為是手動進行的銷毀動作,因此可見這個行為所產生的記錄是在執行緒函式返回之前進行的;
而第一條執行緒因為是讓系統自動銷毀繫結的緣故,因此可見這個動作所產生的記錄是在執行緒函式返回之後進行的。
總結
經過了將近五個月的努力,終於把執行緒同步系列給完結了!
作為壓軸的最後一篇,講述的內容其實已經與「同步」這件事沒什麼關係, 不過因為其性質的奇特性,還是值得書寫並了解一番。 另一方面,如果有在使用 C11 的執行緒相關功能並查閱過說明文件的話, 會發現 C11 的執行緒相關 API 相比於 pthread 或其他作業系統的 API 有所刪減, 比如讀寫鎖和信號量等就沒有出現在其內。 從某方面來說,這也表示被留下來並進入標準規範的功能都是精華,而其中就包含了 thread-local 這個部份。 因此為我來說如果真的只介紹那些與同步有關的項目而略過 thread-local 的話,好似總有種遺憾缺失的感覺。 所以最後還是把這最後一塊拼圖給補上了,算是為我自己感到高興吧!
最後的最後,衷心希望本系列文章除了完成我個人的成就之外,也能夠真實的幫助到有緣的讀者, 無論是讓人了解了執行緒的相關機制原理,或者解決了實際遇到的問題,或者是解答了某種困惑的疑問, 或者僅僅只是因為中文的緣故而減少了閱讀英文文件所需要耗費的時間。 希望本系列的存在能夠真實的對充實中文資訊內容的部份有所幫助, 並住各位有緣的讀者能夠精益求精,更上層樓。