static int global_variable_0 = 0;
static int global_variable_1 = 0;
static int global_variable_2 = 0;
static int global_variable_3 = 0;
static
int ThrdFunc3(void *arg)
{
for(int i = 0; i < 7; ++i)
{
global_variable_3++;
printf("In function 3: variables = %d, %d, %d, %d\n",
global_variable_0, global_variable_1, global_variable_2, global_variable_3);
SleepMs(700);
}
return 0;
}
執行緒同步 1:簡介
這個系列要來介紹各種執行緒同步的方法工具,包含各種的鎖等等。 而本篇首先讓我們先來初步認識什麼是執行緒,並簡單的嘗試自己建立執行緒的方法。
雖然說本篇主打的是一個新手教學,但也並不是針對完完全全的新手。 這裡期望的讀者可以不知道或沒有接觸過執行緒,可以是執行緒的新手,但是不能夠是程式設計的新手! 本系列將假定讀者對於一般通俗的 C/C++ 程式設計已經擁有一定程度的熟練掌握, 並且在此基礎之上針對執行緒的知識進行介紹與講解。 所以如果甚至還沒學會基本的程式設計的話,那麼閱讀本系列將可能會有些吃力!
什麼時候需要多執行緒?
什麼是執行緒?一個執行緒其實就是一個程式的執行流程。
我們一般入門程式設計時的書籍和課程都不會觸及到執行緒這個東西 (畢竟先把程式基礎的邏輯和用法學起來就已經份量相當足夠了), 也可以說我們一般入門學的程式都是只有單一執行緒的結構, 也就是只有程式一執行起來時系統就給了的那一個執行緒而已。 單一執行緒就是我們的程式只有一個執行流程,依照程式碼的順序從頭執行到尾, 也許中間有些跳轉情況如分歧、迴圈、函式呼叫等,但終究還是只有一個執行流。 那麼什麼是多執行緒呢?多執行緒就是程式中存在多個同時、平行、同步在進行的執行流程。
舉個例子,用我們生活中一般使用電腦的經驗來類比的話, 那麼當我們開啟網路瀏覽器在上網看網頁的同時,後面又有一個音樂播放器正在播放音樂, 旁邊還有一個網路聊天程式隨時會顯示好友傳送的訊息。 那麼這時我們其實就是在進行所謂的多工作業, 三隻程式個別都有(至少)一個程式執行流正在進行,並且是同時都在進行的, 才讓我們可以在看網頁的同時一邊和朋友聊天、聽音樂。 這其實就是一種多緒(執行緒)的應用場景; 或者也可以這麼說,雖然我們自己也許可能從來沒有認知到這件事, 但是只要我們有使用電腦同時執行多個程式的經驗的話,那麼我們其實都已經使用過多執行緒技術了!
回到我們自己寫的程式碼這裡, 只有一個執行流的程式往往使我們不得不停下來等待某些需要耗時的工作完成。 例如若我做一個在按下按鈕時會從 A 處複製一大堆檔案到 B 處的程式, 那麼當按鈕按下去的時候程式就會卡住在那裡……, 好吧其實它很認真的在工作,正在努力的不斷複製檔案, 但是整個程式的操作介面就這麼卡住了,直到工作完成前顯然沒辦法再響應其它的按鍵、滑鼠等操作。 然後使用者可能就會因為覺得程式當掉了而直接把程式終止! 這時我們就可以將程式改為建立一個單獨的執行流,即一個新的執行緒,然後在那個執行緒裡面去處理檔案複製的工作。 而本來的執行緒就可以繼續進行其它工作, 例如繼續響應使用者的操作、三不五時檢查另一個執行緒的工作狀態並顯示完成進度等等。
依此類推,我們還可以建立多條執行緒去分別處理各種比較耗時的工作, 讓它們可以同時同步進行,而不需要一個等一個, 而這就是學習並建立執行緒的一種常見用途。 此外還有更多其它用途, 例如將一個相當複雜又耗時的運算分割成幾個部份,然後分派給幾個執行緒同步進行計算。 這就是所謂的「平行處理」,充份利用多個 CPU 核心的性能來分攤計算量,縮短總體的計算時間。 (其實使用顯示卡 GPU 或近年流行起來的 NPU 來進行運算都是相似的平行運算原理。) 總之,學習並善用多緒的能力可以給我們的程式帶來更多的設計彈性和性能提升空間。 當然,多緒也會給我們帶來更多憶想不到的複雜性增加,以及各種問題和挑戰, 而這也是為什麼光一個執行緒同步的主題可以變成一個文章系列的原因!
範例
假設我們有一個函式在執行的時候需要花費許多時間 (在更真實的應用中有時候除非你叫它停,否則它永遠不會停), 那麼我們可能就會想要把這樣的函式放在一個獨立的執行緒裡面去執行, 讓我們原本的程式碼可以繼續去做其它的事情,不用等待函式結束。 那麼這裡作為範例,就假設有一個像下面這樣的函式:
這個函式看上去就是一個普通的 C 函式,它可以接收一個 void* 型態的參數,
並且會返回 int 型態的回傳值。
正常來說所有進出函式的資料都應該透過那個 arg 參數給傳遞進去,使用全域變數是個壞習慣,
但在這個範例裡面主要是為了能夠更加直觀的表達不同執行緒可以存取共同記憶體空間的緣故,
因此還是使用了全域變數的寫法;
但是這是相當特殊情況的寫法,請小白們不要亂學,平常還是不要隨便寫全域變數的好!
對於這樣一個函式,一般來說如果我們要去呼叫它的話,會像下面這樣寫:
int res = ThrdFunc3(NULL);
啟動執行緒
就像前面說的,上面那函式的執行會花上不少時間,而我們呼叫的地方又不希望卡住停在那裡等它。 那麼這裡我們就來建立另外一條執行緒,然後把這個函式放在那個獨立的執行緒裡與我們平行執行, 做法如下:
thrd_t thr; // Handler of the new created thread
int err = thrd_create(&thr, ThrdFunc3, NULL);
assert( err == 0 );
這裡呼叫了 thrd_create() 函式,並讓 thrd_create() 代替我們去呼叫 ThrdFunc3() 函式。
為什麼要這樣間接呼叫呢?
因為 thrd_create() 會建立一條新的執行緒,然後用那條新的執行緒去執行 ThrdFunc3()。
因此這個 thrd_create() 完成它的任務之後就會直接返回了,
而它返回的時候那個 ThrdFunc3() 函式可能還正在努力的執行中呢!
但是這裡還有一個問題,前面我們的 ThrdFunc3() 不是需要傳入一個參數嗎?那現在這個參數要怎麼傳給它呢?
雖然我們現在的這個範例並沒有實際使用到這個參數,
但是在實際用途中總不能夠沒辦法傳遞參數給我們要間接執行的那個函式吧?
其實 thrd_create() 的第三個參數就是為此而設計的。
我們能將任何能轉型成 void* 型態的參數填入 thrd_create() 的第三個參數,
然後 thrd_create() 就會幫我們轉交給我們要它代為執行的那個 ThrdFunc3()。
完整的 thrd_create() 函式介面如下:
typedef int(*thrd_start_t)(void *arg);
int thrd_create(thrd_t *thr, thrd_start_t func, void *arg);
其中有兩點事情需要注意:
-
func()在執行緒被建立之後可能很快就會被執行, 甚至可能在thrd_create()返回之前就已經開始執行了。 因此所有新的執行緒函式需要取用到的相關資源都需要在呼叫thrd_create()就已處於可用狀態。 -
func()也有可能在thrd_create()返回之前就已經執行結束了。 比方說傳入的函式內容相當短小精練以至於一下子就執行結束了, 或者是系統自己卡了一下等等,都是可能造成這個現象的原因。 無論如何,使用者都需要意識到這個現象的可能存在, 除非設計有彼此互相確保協同的機制,否則寫作的程式不應假定func()必定還在執行中狀態而還未結束。
關閉執行緒
也許你注意到了,呼叫了 thrd_create() 之後它會給我們一個 thrd_t 型態的物件,這東西也是有作用的。
它就是一個指向新建立的執行緒的操作器(handler),我們可以使用它來對那條執行緒進行許多操作,
比方比較執行緒、調整執行緒屬性、暫停與恢復執行緒等等(不過 C11 的 API 好像沒有覆蓋這麼多功能);
然而其中有一項功能是我們最常使用且必須使用的,那就是關閉執行緒操作器!
就如同開了檔案後要記得關檔,或者分配了記憶體後要記得釋放一樣,
啟動執行緒並得到操作器之後也要記得關閉它,否則也同樣可能會產生資源洩漏的情況。
不過這裡要注意一個區別,
所謂關閉操作器僅僅只是關閉掉那個 thrd_t 物件而已,它本身並不會結束執行緒,
而執行緒本身的結束與否在於我們丟給執行緒的那個函式什麼時候自己返回退出。
要關閉執行緒操作器,可以選擇呼叫下面兩個函式的其中一個:
int thrd_join(thrd_t thr, int *res);
int thrd_detach(thrd_t thr);
上面兩個函式都能關閉執行緒操作器,區別在於 thrd_detach() 只是單純的關閉而已,
關了之後就不管了,那個執行緒可能仍在執行中,或者早就已經結束掉,都無所謂,thrd_detach() 都會直接返回。
而 thrd_join() 不一樣,它會等待直到那條執行緒結束之後才會返回;
當然如果那條執行緒早就已經結束了的話那就會直接返回。
此外還有一個差別,因為 thrd_join() 會等待執行緒結束,因此可以順便轉交給你你的執行緒函式的返回值,
那個 res 參數就是用來接收返回值的;
而 thrd_detach() 因為返回的時候執行緒可能都還沒結束,自然就無法回給你這東西了。
因為這兩個函式只負責關閉執行緒操作器而已,而與該執行緒本身的運作無關,
因此在你呼叫這兩函式之前,執行緒有可能早就已經自己結束了千八百年了,
比方你經過很久才呼叫關閉操作器,或者你的執行緒函式本身就短小精幹等。
這一點在設計的時候也需要注意,以免得發生一不小心去存取了早就在你的執行緒函式裡面銷毀了的資源!
也因為同樣的關係,如果你的設計上並不需要留著操作器進行一些進階的操作的話,
甚至也可以在剛呼叫了 thrd_create() 建立完執行緒之後,立刻就呼叫 thrd_detach() 關閉操作器,
然後就繼續做你的事情,就放著新的執行緒在背後該幹什麼就幹什麼。
|
Note
|
在我寫作執行緒同步系列文章搜尋資料的時候才發現, 有些妖術會教你用 cancel thread 之類的 API 去停止執行緒,這是不對的,請不要學! 那些 API 確實可以把執行緒停下來,但那其實是非正常強制終止執行緒的手段。 若總是使用它來讓你的執行緒停下來,那就好像是每次都使用系統的行程管理器去強制殺停你的程式一樣, 能用是能用,但是不正規,不是正常關閉,有時候會產生一些後遺症。 同樣的,當你把強制停止執行緒的方法當作正常停止的方法來使用, 那麼程式因此發生什麼奇奇怪怪的異常問題也都是很正常的事情了! 正規的途徑還是要使用旗標狀態檢查等手段,讓執行緒函式自己正常自然的退出並結束。 |
範例
解釋完如何建立和關閉執行緒之後,這裡提供一個範例。
這個範例內手動建立了 3 個執行緒,加上一個程式本來就有的執行緒,總共執行緒有 4 個。
每一個執行緒都執行了一段與上面展示的 ThrdFunc3() 一樣的程式碼,
只不過各自的顯示訊息、迴圈等待的時長、和覆寫的變數等等稍有差異。
這個範例執行後的結果輸出如下:
In function 1: variables = 0, 1, 0, 0
In function main: variables = 1, 1, 0, 0
In function 3: variables = 1, 1, 0, 1
In function 2: variables = 1, 1, 1, 1
In function 1: variables = 1, 2, 1, 1
In function 2: variables = 1, 2, 2, 1
In function 1: variables = 1, 3, 2, 1
In function 3: variables = 1, 3, 2, 2
In function 1: variables = 1, 4, 2, 2
In function main: variables = 2, 4, 2, 2
In function 2: variables = 2, 4, 3, 2
In function 1: variables = 2, 5, 3, 2
In function 3: variables = 2, 5, 3, 3
In function 2: variables = 2, 5, 4, 3
In function 1: variables = 2, 6, 4, 3
In function 1: variables = 2, 7, 4, 3
In function main: variables = 3, 7, 4, 3
In function 2: variables = 3, 7, 5, 3
In function 3: variables = 3, 7, 5, 4
In function 1: variables = 3, 8, 5, 4
In function 1: variables = 3, 9, 5, 4
In function 2: variables = 3, 9, 6, 4
In function 1: variables = 3, 10, 6, 4
In function 3: variables = 3, 10, 6, 5
In function main: variables = 4, 10, 6, 5
In function 2: variables = 4, 10, 7, 5
In function 1: variables = 4, 11, 7, 5
In function 1: variables = 4, 12, 7, 5
In function 3: variables = 4, 12, 7, 6
In function 2: variables = 4, 12, 8, 6
In function 1: variables = 4, 13, 8, 6
In function 1: variables = 4, 14, 8, 6
In function main: variables = 5, 14, 8, 6
In function 2: variables = 5, 14, 9, 6
In function 3: variables = 5, 14, 9, 7
In function 1: variables = 5, 15, 9, 7
In function 2: variables = 5, 15, 10, 7
In function 1: variables = 5, 16, 10, 7
In function 1: variables = 5, 17, 10, 7
從交叉輸出的訊息,我們能夠確定四個函式(三個建立的執行緒加主程式原有的一個)確實是同時在進行的。 而從它們輸出的變數狀態都不斷在變化的情況,也能確認在不同的執行緒之間確實能夠共享程式資源 (在這個範例裡展現的就是可以共同存取程式內的變數)。
關於系列文章的規劃藍圖
文章至此,讀者應該已經俱備了能夠創建多執行緒的基本能力,可以開始實現多緒的程式設計了。 不過執行緒的各種眉角和陷阱還可多著,否則又怎麼會讓人人都說多緒編程難呢! 其實有經驗的讀者可能已經發現了,本篇的範例相當雞賊的繞過了多緒競爭的問題, 畢竟只是一個剛開始的範例,還沒必要搞到那麼複雜; 至於其中更多的問題與對策,就留待後續的文章慢慢介紹了。
然而與執行緒有關的議題實在太多太大,作者我的個人能力實在沒能夠全盤交代清楚, 因此本系列文章的絕大部份內容將專注於執行緒同步方面的問題,介紹與解釋各種對策方案及其原理。
最後則是交代一下關於執行緒 API 的選用部份。
關於執行緒,各作業系統都有自己的執行緒相關 API。
例如以建立新執行緒來說,
就有 POSIX 相容系統(如 BSD、Linux、和 UNIX 等)的 pthread_create()、
和 Windows API 的 CreateThread() 等,
以及 2011 年以後的 C++11 的 std::thread、和 C11 的 thrd_create(),
此外可能還有更多比較小眾的 API 與函式庫等等。
執行緒的 API 與函式庫有好多種不同的介面與實現,
每一種方案各有些細節和特性上的不同,實在很難每一個都清楚解釋;
然而它們雖然各自不同,在用法結構上卻大同小異。
因此本系列文章將避免過於深入各種實現細節,而只針對原理和框架等層面去做說明解釋,
畢竟大部份情況下我們缺少的更可能是對整體、原理、特性、和適合用途的通盤理解,
至於操作細節只要去查閱各 API 的相關說明文件就能知道了!
不過文章的說明解釋是一回事,但程式碼範例總是要能夠編譯執行的。 這程式碼的部份,可能會相當多的採用 C++ 寫作, 畢竟 C++ 的封裝用起來還是比純 C 更加方便簡潔; 然而在所需要解說的執行緒相關操作上則會使用 C 語法與呼叫, 因為 C 語言雖然可能瑣碎點但更加簡單直觀,更適合作為學習範例使用。 因此在執行緒的 API 選用部份,我會以 C11 標準的執行緒程式庫為主, 當遇到標準 C11 覆蓋不夠全面的相關功能時則會使用 pthread 程式庫, 至於當遇到前兩者都不俱備的功能時才會使用更加冷門少見的 API; 至於那些與主要訴求無關的其它程式碼,則可能會相當的採用 C++ 語法與工具。
下一篇:「執行緒同步 2:多工原理」