[校招面試]Linux多線程編程講解之系列一

Linux線程概述

了解如何正確運用線程是每一個優秀程序員必備的素質。

線程類似于進程。如同進程,線程由內核按時間分片進行管理。在單處理器系統中,內核使用時間分片來模擬線程的并發執行,這種方式和進程的相同。而在多處理器系統中,如同多個進程,線程實際上一樣可以并發執行。

那么為什么對于大多數合作性任務,多線程比多個獨立的進程更優越呢?這是因為,線程共享相同的內存空間。不同的線程可以存取內存中的同一個變量。所以,程序中的所有線程都可以讀或寫聲明過的全局變量。如果曾用 fork() 編寫過重要代碼,就會認識到這個工具的重要性。為什么呢?雖然 fork() 允許創建多個進程,但它還會帶來以下通信問題: 如何讓多個進程相互通信,這里每個進程都有各自獨立的內存空間。對這個問題沒有一個簡單的答案。雖然有許多不同種類的本地 IPC (進程間通信),但它們都遇到兩個重要障礙:強加了某種形式的額外內核開銷,從而降低性能。

對于大多數情形,IPC 不是對于代碼的“自然”擴展。通常極大地增加了程序的復雜性。雙重壞事: 開銷和復雜性都非好事。如果曾經為了支持 IPC 而對程序大動干戈過,那么你就會真正欣賞線程提供的簡單共享內存機制。由于所有的線程都駐留在同一內存空間,POSIX 線程無需進行開銷大而復雜的長距離調用。只要利用簡單的同步機制,程序中所有的線程都可以讀取和修改已有的數據結構。而無需將數據經由文件描述符轉儲或擠入緊窄的共享內存空間。僅此一個原因,就足以讓你考慮應該采用單進程/多線程模式而非多進程/單線程模式

線程是快捷的不僅如此。線程同樣還是非常快捷的。與標準 fork() 相比,線程帶來的開銷很小。內核無需單獨復制進程的內存空間或文件描述符等等。這就節省了大量的 CPU 時間,使得線程創建比新進程創建快上十到一百倍。因為這一點,可以大量使用線程而無需太過于擔心帶來的 CPU 或內存不足。使用 fork() 時導致的大量 CPU 占用也不復存在。這表示只要在程序中有意義,通常就可以創建線程。

當然,和進程一樣,線程將利用多 CPU。如果軟件是針對多處理器系統設計的,這就真的是一大特性(如果軟件是開放源碼,則最終可能在不少平臺上運行)。特定類型線程程序(尤其是 CPU 密集型程序)的性能將隨系統中處理器的數目幾乎線性地提高。如果正在編寫 CPU 非常密集型的程序,則絕對想設法在代碼中使用多線程。一旦掌握了線程編碼,無需使用繁瑣的 IPC 和其它復雜的通信機制,就能夠以全新和創造性的方法解決編碼難題。所有這些特性配合在一起使得多線程編程更有趣、快速和靈活。

線程是可移植的

如果熟悉 Linux 編程,就有可能知道 __clone() 系統調用。__clone() 類似于 fork(),同時也有許多線程的特性。例如,使用 __clone(),新的子進程可以有選擇地共享父進程的執行環境(內存空間,文件描述符等)。這是好的一面。但 __clone() 也有不足之處。正如__clone() 在線幫助指出:“__clone 調用是特定于 Linux 平臺的,不適用于實現可移植的程序。欲編寫線程化應用程序(多線程控制同一內存空間),最好使用實現 POSIX 1003.1c 線程 API 的庫,例如 Linux-Threads 庫。參閱 pthread_create(3thr)。”

雖然 __clone() 有線程的許多特性,但它是不可移植的。當然這并不意味著代碼中不能使用它。但在軟件中考慮使用 __clone() 時應當權衡這一事實。值得慶幸的是,正如 __clone() 在線幫助指出,有一種更好的替代方案:POSIX 線程。如果想編寫 可移植的 多線程代碼,代碼可運行于 Solaris、FreeBSD、Linux 和其它平臺,POSIX 線程是一種當然之選。

線程創建

線程與進程

相對進程而言,線程是一個更加接近于執行體的概念,它可以與同進程中的其他線程共享數據,但擁有自己的棧空間,擁有獨立的執行序列。在串行程序基礎上引入線程和進程是為了提高程序的并發度,從而提高程序運行效率和響應時間。

線程和進程在使用上各有優缺點:線程執行開銷小,但不利于資源的管理和保護;而進程正相反。同時,線程適合于在SMP機器上運行,而進程則可以跨機器遷移。

創建線程

POSIX通過pthread_create()函數創建線程,API定義如下:

int? pthread_create(pthread_t? *? thread, pthread_attr_t * attr,

void * (*start_routine)(void *), void * arg)

與fork()調用創建一個進程的方法不同,pthread_create()創建的線程并不具備與主線程(即調用pthread_create()的線程)同樣的執行序列,而是使其運行start_routine(arg)函數。thread返回創建的線程ID,而attr是創建線程時設置的線程屬性(見下)。pthread_create()的返回值表示線程創建是否成功。盡管arg是void *類型的變量,但它同樣可以作為任意類型的參數傳給start_routine()函數;同時,start_routine()可以返回一個void *類型的返回值,而這個返回值也可以是其他類型,并由pthread_join()獲取。

線程創建屬性

pthread_create()中的attr參數是一個結構指針,結構中的元素分別對應著新線程的運行屬性,主要包括以下幾項:

__detachstate,表示新線程是否與進程中其他線程脫離同步,如果置位則新線程不能用pthread_join()來同步,且在退出時自行釋放所占用的資源。缺省為PTHREAD_CREATE_JOINABLE狀態。這個屬性也可以在線程創建并運行以后用pthread_detach()來設置,而一旦設置為PTHREAD_CREATE_DETACH狀態(不論是創建時設置還是運行時設置)則不能再恢復到PTHREAD_CREATE_JOINABLE狀態。

__schedpolicy,表示新線程的調度策略,主要包括SCHED_OTHER(正常、非實時)、SCHED_RR(實時、輪轉法)和SCHED_FIFO(實時、先入先出)三種,缺省為SCHED_OTHER,后兩種調度策略僅對超級用戶有效。運行時可以用過pthread_setschedparam()來改變。

__schedparam,一個struct sched_param結構,目前僅有一個sched_priority整型變量表示線程的運行優先級。這個參數僅當調度策略為實時(即SCHED_RR或SCHED_FIFO)時才有效,并可以在運行時通過pthread_setschedparam()函數來改變,缺省為0。

__inheritsched,有兩種值可供選擇:PTHREAD_EXPLICIT_SCHED和PTHREAD_INHERIT_SCHED,前者表示新線程使用顯式指定調度策略和調度參數(即attr中的值),而后者表示繼承調用者線程的值。缺省為PTHREAD_EXPLICIT_SCHED。

__scope,表示線程間競爭CPU的范圍,也就是說線程優先級的有效范圍。POSIX的標準中定義了兩個值:PTHREAD_SCOPE_SYSTEM和PTHREAD_SCOPE_PROCESS,前者表示與系統中所有線程一起競爭CPU時間,后者表示僅與同進程中的線程競爭CPU。目前LinuxThreads僅實現了PTHREAD_SCOPE_SYSTEM一值。

pthread_attr_t結構中還有一些值,但不使用pthread_create()來設置。

為了設置這些屬性,POSIX定義了一系列屬性設置函數,包括pthread_attr_init()、pthread_attr_destroy()和與各個屬性相關的pthread_attr_get---/pthread_attr_set---函數。

線程創建的Linux實現

我們知道,Linux的線程實現是在核外進行的,核內提供的是創建進程的接口do_fork()。內核提供了兩個系統調用__clone()和fork(),最終都用不同的參數調用do_fork()核內API。當然,要想實現線程,沒有核心對多進程(其實是輕量級進程)共享數據段的支持是不行的,因此,do_fork()提供了很多參數,包括CLONE_VM(共享內存空間)、CLONE_FS(共享文件系統信息)、CLONE_FILES(共享文件描述符表)、CLONE_SIGHAND(共享信號句柄表)和CLONE_PID(共享進程ID,僅對核內進程,即0號進程有效)。當使用fork系統調用時,內核調用do_fork()不使用任何共享屬性,進程擁有獨立的運行環境,而使用pthread_create()來創建線程時,則最終設置了所有這些屬性來調用__clone(),而這些參數又全部傳給核內的do_fork(),從而創建的"進程"擁有共享的運行環境,只有棧是獨立的,由__clone()傳入。

Linux線程在核內是以輕量級進程的形式存在的,擁有獨立的進程表項,而所有的創建、同步、刪除等操作都在核外pthread庫中進行。pthread庫使用一個管理線程(__pthread_manager(),每個進程獨立且唯一)來管理線程的創建和終止,為線程分配線程ID,發送線程相關的信號(比如Cancel),而主線程(pthread_create())的調用者則通過管道將請求信息傳給管理線程。

線程取消

線程取消的定義

一般情況下,線程在其主體函數退出的時候會自動終止,但同時也可以因為接收到另一個線程發來的終止(取消)請求而強制終止。

線程取消的語義

線程取消的方法是向目標線程發Cancel信號,但如何處理Cancel信號則由目標線程自己決定,或者忽略、或者立即終止、或者繼續運行至Cancelation-point(取消點),由不同的Cancelation狀態決定。

線程接收到CANCEL信號的缺省處理(即pthread_create()創建線程的缺省狀態)是繼續運行至取消點,也就是說設置一個CANCELED狀態,線程繼續運行,只有運行至Cancelation-point的時候才會退出。

取消點

根據POSIX標準,pthread_join()、pthread_testcancel()、pthread_cond_wait()、pthread_cond_timedwait()、sem_wait()、sigwait()等函數以及read()、write()等會引起阻塞的系統調用都是Cancelation-point,而其他pthread函數都不會引起Cancelation動作。但是pthread_cancel的手冊頁聲稱,由于LinuxThread庫與C庫結合得不好,因而目前C庫函數都不是Cancelation-point;但CANCEL信號會使線程從阻塞的系統調用中退出,并置EINTR錯誤碼,因此可以在需要作為Cancelation-point的系統調用前后調用pthread_testcancel(),從而達到POSIX標準所要求的目標,即如下代碼段:

? ? pthread_testcancel();

????retcode = read(fd, buffer, length);

????pthread_testcancel();

程序設計方面的考慮

如果線程處于無限循環中,且循環體內沒有執行至取消點的必然路徑,則線程無法由外部其他線程的取消請求而終止。因此在這樣的循環體的必經路徑上應該加入pthread_testcancel()調用。

與線程取消相關的pthread函數

int pthread_cancel(pthread_t thread)?發送終止信號給thread線程,如果成功則返回0,否則為非0值。發送成功并不意味著thread會終止。

int pthread_setcancelstate(int state, int *oldstate)?設置本線程對Cancel信號的反應,state有兩種值:PTHREAD_CANCEL_ENABLE(缺省)和PTHREAD_CANCEL_DISABLE,分別表示收到信號后設為CANCLED狀態和忽略CANCEL信號繼續運行;old_state如果不為NULL則存入原來的Cancel狀態以便恢復。

int pthread_setcanceltype(int type, int *oldtype)?設置本線程取消動作的執行時機,type由兩種取值:PTHREAD_CANCEL_DEFFERED和PTHREAD_CANCEL_ASYCHRONOUS,僅當Cancel狀態為Enable時有效,分別表示收到信號后繼續運行至下一個取消點再退出和立即執行取消動作(退出);oldtype如果不為NULL則存入運來的取消動作類型值。

void pthread_testcancel(void)?檢查本線程是否處于Canceld狀態,如果是,則進行取消動作,否則直接返回。

第一個線程

下面是一個 POSIX 線程的簡單示例程序:

#include<pthread.h>??

#include<stdlib.h>

#include<unistd.h>

void?*thread_function(void?*arg)?{??

int?i;??

for?(?i=0;?i<20;?i++)?{??

printf("Thread?says?hi!\n");??

????sleep(1);??

??}??

return?NULL;??

}??

int?main(void)?{??

??pthread_t?mythread;??


if?(?pthread_create(?&mythread,?NULL,?thread_function,?NULL)?)?{??

printf("error?creating?thread.");??

????abort();??

??}??

if?(?pthread_join?(?mythread,?NULL?)?)?{??

printf("error?joining?thread.");??

????abort();??

??}??

??exit(0);??

}??


要編譯這個程序,只需先將程序存為 thread1.c,然后輸入:

$ gcc thread1.c -o thread1 -lpthread

運行則輸入:

$ ./thread1

理解 thread1.c

thread1.c 是一個非常簡單的線程程序。雖然它沒有實現什么有用的功能,但可以幫助理解線程的運行機制。

下面,我們一步一步地了解這個程序是干什么的。

main() 中聲明了變量 mythread,類型是 pthread_t。pthread_t 類型在 pthread.h 中定義,通常稱為“線程 id”(縮寫為 "tid")。可以認為它是一種線程句柄。mythread 聲明后(記住 mythread 只是一個 "tid",或是將要創建的線程的句柄),調用 pthread_create 函數創建一個真實活動的線程。不要因為 pthread_create() 在 "if" 語句內而受其迷惑。由于 pthread_create() 執行成功時返回零而失敗時則返回非零值,將 pthread_create() 函數調用放在 if() 語句中只是為了方便地檢測失敗的調用。讓我們查看一下 pthread_create 參數。第一個參數 &mythread 是指向 mythread 的指針。第二個參數當前為 NULL,可用來定義線程的某些屬性。由于缺省的線程屬性是適用的,只需將該參數設為 NULL。

第三個參數是新線程啟動時調用的函數名。本例中,函數名為 thread_function()。當 thread_function() 返回時,新線程將終止。本例中,線程函數沒有實現大的功能。它僅將 "Thread says hi!" 輸出 20 次然后退出。注意 thread_function() 接受 void * 作為參數,同時返回值的類型也是 void *。這表明可以用 void * 向新線程傳遞任意類型的數據,新線程完成時也可返回任意類型的數據。那如何向線程傳遞一個任意參數?很簡單。只要利用 pthread_create() 中的第四個參數。本例中,因為沒有必要將任何數據傳給微不足道的 thread_function(),所以將第四個參數設為 NULL。

也許已推測到,在 pthread_create() 成功返回之后,程序將包含兩個線程。等一等, 兩個 線程?我們不是只創建了一個線程嗎?不錯,我們只創建了一個進程。但是主程序同樣也是一個線程。可以這樣理解:如果編寫的程序根本沒有使用 POSIX 線程,則該程序是單線程的(這個單線程稱為“主”線程)。創建一個新線程之后程序總共就有兩個線程了。

我想此時大家至少有兩個重要問題。第一個問題,新線程創建之后主線程如何運行。答案,主線程按順序繼續執行下一行程序(本例中執行 "if (pthread_join(...))")。第二個問題,新線程結束時如何處理。答案,新線程先停止,然后作為其清理過程的一部分,等待與另一個線程合并或“連接”。

現在,來看一下 pthread_join()。正如 pthread_create() 將一個線程拆分為兩個, pthread_join() 將兩個線程合并為一個線程。pthread_join() 的第一個參數是 tid mythread。第二個參數是指向 void 指針的指針。如果 void 指針不為 NULL,pthread_join 將線程的 void * 返回值放置在指定的位置上。由于我們不必理會 thread_function() 的返回值,所以將其設為 NULL。

你會注意到 thread_function() 花了 20 秒才完成。在 thread_function() 結束很久之前,主線程就已經調用了 pthread_join()。如果發生這種情況,主線程將中斷(轉向睡眠)然后等待 thread_function() 完成。當 thread_function() 完成后, pthread_join() 將返回。這時程序又只有一個主線程。當程序退出時,所有新線程已經使用 pthread_join() 合并了。這就是應該如何處理在程序中創建的每個新線程的過程。如果沒有合并一個新線程,則它仍然對系統的最大線程數限制不利。這意味著如果未對線程做正確的清理,最終會導致 pthread_create() 調用失敗。

無父,無子。

如果使用過 fork() 系統調用,可能熟悉父進程和子進程的概念。當用 fork() 創建另一個新進程時,新進程是子進程,原始進程是父進程。這創建了可能非常有用的層次關系,尤其是等待子進程終止時。例如,waitpid() 函數讓當前進程等待所有子進程終止。waitpid() 用來在父進程中實現簡單的清理過程。

而 POSIX 線程就更有意思。你可能已經注意到我一直有意避免使用“父線程”和“子線程”的說法。這是因為 POSIX 線程中不存在這種層次關系。雖然主線程可以創建一個新線程,新線程可以創建另一個新線程,POSIX 線程標準將它們視為等同的層次。所以等待子線程退出的概念在這里沒有意義。POSIX 線程標準不記錄任何“家族”信息。缺少家族信息有一個主要含意:如果要等待一個線程終止,就必須將線程的 tid 傳遞給pthread_join()。線程庫無法為你斷定 tid(ps -efL|grep xxx或者top -Hp可以查看到開啟的線程信息)。

對大多數開發者來說這不是個好消息,因為這會使有多個線程的程序復雜化。不過不要為此擔憂。POSIX 線程標準提供了有效地管理多個線程所需要的所有工具。實際上,沒有父/子關系這一事實卻為在程序中使用線程開辟了更創造性的方法。例如,如果有一個線程稱為線程 1,線程 1 創建了稱為線程 2 的線程,則線程 1 自己沒有必要調用 pthread_join() 來合并線程 2,程序中其它任一線程都可以做到。當編寫大量使用線程的代碼時,這就可能允許發生有趣的事情。例如,可以創建一個包含所有已停止線程的全局“死線程列表”,然后讓一個專門的清理線程專等停止的線程加到列表中。這個清理線程調用 pthread_join() 將剛停止的線程與自己合并。現在,僅用一個線程就巧妙和有效地處理了全部清理。

同步漫游

現在我們來看一些代碼,這些代碼做了一些意想不到的事情。thread2.c 的代碼如下:

#include<pthread.h>??

#include<unistd.h>

#include<stdio.h>

#include<stdlib.h>???

int?myglobal;??

void?*thread_function(void?*arg)?{??

int?i,j;??

for?(?i=0;?i<20;?i++)?{??

????j=myglobal;??

????j=j+1;??

????sleep(1);??

????myglobal=j;??

??}??

return?NULL;??

}??

int?main(void)?{??

??pthread_t?mythread;??

int?i;??

if?(?pthread_create(?&mythread,?NULL,?thread_function,?NULL)?)?{??

printf("error?creating?thread.");??

????abort();??

??}??

for?(?i=0;?i<20;?i++)?{??

????myglobal=myglobal+1;??

????sleep(1);??

??}??

if?(?pthread_join?(?mythread,?NULL?)?)?{??

printf("error?joining?thread.");??

????abort();??

??}??

printf("\nmyglobal?equals?%d\n",myglobal);??

??exit(0);??

}??

理解 thread2.c

如同第一個程序,這個程序創建一個新線程。主線程和新線程都將全局變量 myglobal 加一 20 次。但是程序本身產生了某些意想不到的結果。編譯代碼請輸入:

$ gcc thread2.c -o thread2 -lpthread

運行請輸入:

$ ./thread2

輸出:

$ ./thread2

myglobal equals 21

非常意外吧!因為 myglobal 從零開始,主線程和新線程各自對其進行了 20 次加一, 程序結束時 myglobal 值應當等于 40。由于 myglobal 輸出結果為 21,這其中肯定有問題。但是究竟是什么呢?

放棄嗎?好,讓我來解釋是怎么一回事。首先查看函數 thread_function()。注意如何將 myglobal 復制到局部變量 "j" 了嗎? 接著將 j 加一, 再睡眠一秒,然后到這時才將新的 j 值復制到 myglobal?這就是關鍵所在。設想一下,如果主線程就在新線程將 myglobal 值復制給 j 后 立即將 myglobal 加一,會發生什么?當 thread_function() 將 j 的值寫回 myglobal 時,就覆蓋了主線程所做的修改。

當編寫線程程序時,應避免產生這種無用的副作用,否則只會浪費時間(當然,除了編寫關于 POSIX 線程的文章時有用)。那么,如何才能排除這種問題呢?

由于是將 myglobal 復制給 j 并且等了一秒之后才寫回時產生問題,可以嘗試避免使用臨時局部變量并直接將 myglobal 加一。雖然這種解決方案對這個特定例子適用,但它還是不正確。如果我們對 myglobal 進行相對復雜的數學運算,而不是簡單的加一,這種方法就會失效。但是為什么呢?

要理解這個問題,必須記住線程是并發運行的。即使在單處理器系統上運行(內核利用時間分片模擬多任務)也是可以的,從程序員的角度,想像兩個線程是同時執行的。thread2.c 出現問題是因為 thread_function() 依賴以下論據:在 myglobal 加一之前的大約一秒鐘期間不會修改 myglobal。需要有些途徑讓一個線程在對 myglobal 做更改時通知其它線程“不要靠近”。將在下一篇文章中講解如何做到這一點。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容