天天看點

在Linux中使用線程

我并不假定你會使用linux的線程,是以在這裡就簡單的介紹一下。如果你之前有過多線程方面的程式設計經驗,完全可以忽略本文的内容,因為它非常的初級。

首先說明一下,在linux編寫多線程程式需要包含頭檔案pthread.h。也就是說你在任何采用多線程設計的程式中都會看到類似這樣的代碼:

#include 當然,進包含一個頭檔案是不能搞定線程的,還需要連接配接libpthread.so這個庫,是以在程式連接配接階段應該有類似這樣的指令:

gcc program.o -o program -lpthread.h>

1. 第一個例子

在linux下建立的線程的api接口是pthread_create(),它的完整定義是:

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

void *(*start_routine)(void *) void *arg);

當你的程式調用了這個接口之後,就會産生一個線程,而這個線程的入口函數就是start_routine()。如果線程建立成功,這個接口會傳回0。

start_routine()函數有一個參數,這個參數就是pthread_create的最後一個參數arg。這種設計可以線上程建立之前就幫它準備好一些專有資料,最典型的用法就是使用c++程式設計時的this指針。start_routine()有一個傳回值,這個傳回值可以通過pthread_join()接口獲得。

pthread_create()接口的第一個參數是一個傳回參數。當一個新的線程調用成功之後,就會通過這個參數将線程的句柄傳回給調用者,以便對這個線程進行管理。

pthread_create()接口的第二個參數用于設定線程的屬性。這個參數是可選的,當不需要修改線程的預設屬性時,給它傳遞null就行。具體線程有那些屬性,我們後面再做介紹。

好,那麼我們就利用這些接口,來完成在linux上的第一個多線程程式,見代碼1所示:

代碼1第一個多線程程式設計例子

将這段代碼儲存為thread.c檔案,可以執行下面的指令來生成可執行檔案:

$ gcc thread.c -o thread -lpthread

這段代碼的執行結果可能是這樣:

$ ./thread

this is the main process.

this is a thread and arg = 10.

thread_ret = 0.

注意,我說的是可能有這樣的結果,在不同的環境下可能會有出入。因為這是多線程程式,線程代碼可能先于第24行代碼被執行。

我們回過頭來再分析一下這段代碼。在第18行調用pthread_create()接口建立了一個新的線程,這個線程的入口函數是start_thread(),并且給這個入口函數傳遞了一個參數,且參數值為10。這個新建立的線程要執行的任務非常簡單,隻是将顯示“this is a thread and arg = 10”這個字元串,因為arg這個參數值已經定義好了,就是10。之後線程将arg參數的值修改為0,并将它作為線程的傳回值傳回給系統。與此同時,主程序做的事情就是繼續判斷這個線程是否建立成功了。在我們的例子中基本上沒有建立失敗的可能。主程序會繼續輸出“this is the main process”字元串,然後調用pthread_join()接口與剛才的建立進行合并。這個接口的第一個參數就是新建立線程的句柄了,而第二個參數就會去接受線程的傳回值。pthread_join()接口會阻塞主程序的執行,直到合并的線程執行結束。由于線程在結束之後會将0傳回給系統,那麼pthread_join()獲得的線程傳回值自然也就是0。輸出結果“thread_ret = 0”也證明了這一點。

那麼現在有一個問題,那就是pthread_join()接口幹了什麼?什麼是線程合并呢?

2. 線程的合并與分離

我們首先要明确的一個問題就是什麼是線程的合并。從前面的叙述中讀者們已經了解到了,pthread_create()接口負責建立了一個線程。那麼線程也屬于系統的資源,這跟記憶體沒什麼兩樣,而且線程本身也要占據一定的記憶體空間。衆所周知的一個問題就是c或c++程式設計中如果要通過malloc()或new配置設定了一塊記憶體,就必須使用free()或delete來回收這塊記憶體,否則就會産生著名的記憶體洩漏問題。既然線程和記憶體沒什麼兩樣,那麼有建立就必須得有回收,否則就會産生另外一個著名的資源洩漏問題,這同樣也是一個嚴重的問題。那麼線程的合并就是回收線程資源了。

線程的合并是一種主動回收線程資源的方案。當一個程序或線程調用了針對其它線程的pthread_join()接口,就是線程合并了。這個接口會阻塞調用程序或線程,直到被合并的線程結束為止。當被合并線程結束,pthread_join()接口就會回收這個線程的資源,并将這個線程的傳回值傳回給合并者。

與線程合并相對應的另外一種線程資源回收機制是線程分離,調用接口是pthread_detach()。線程分離是将線程資源的回收工作交由系統自動來完成,也就是說當被分離的線程結束之後,系統會自動回收它的資源。因為線程分離是啟動系統的自動回收機制,那麼程式也就無法獲得被分離線程的傳回值,這就使得pthread_detach()接口隻要擁有一個參數就行了,那就是被分離線程句柄。

線程合并和線程分離都是用于回收線程資源的,可以根據不同的業務場景酌情使用。不管有什麼理由,你都必須選擇其中一種,否則就會引發資源洩漏的問題,這個問題與記憶體洩漏同樣可怕。

3. 線程的屬性

前面還說到過線程是有屬性的,這個屬性由一個線程屬性對象來描述。線程屬性對象由pthread_attr_init()接口初始化,并由pthread_attr_destory()來銷毀,它們的完整定義是:

int pthread_attr_init(pthread_attr_t *attr);

int pthread_attr_destory(pthread_attr_t *attr);

那麼線程擁有哪些屬性呢?一般地,linux下的線程有:綁定屬性、分離屬性、排程屬性、堆棧大小屬性和滿占警戒區大小屬性。下面我們就分别來介紹這些屬性。

3.1 綁定屬性

說到這個綁定屬性,就不得不提起另外一個概念:輕程序(light weight process,簡稱lwp)。輕程序和linux系統的核心線程擁有相同的概念,屬于核心的排程實體。一個輕程序可以控制一個或多個線程。預設情況下,對于一個擁有n個線程的程式,啟動多少輕程序,由哪些輕程序來控制哪些線程由作業系統來控制,這種狀态被稱為非綁定的。那麼綁定的含義就很好了解了,隻要指定了某個線程“綁”在某個輕程序上,就可以稱之為綁定的了。被綁定的線程具有較高的相應速度,因為作業系統的排程主體是輕程序,綁定線程可以保證在需要的時候它總有一個輕程序可用。綁定屬性就是幹這個用的。

設定綁定屬性的接口是pthread_attr_setscope(),它的完整定義是:

int pthread_attr_setscope(pthread_attr_t *attr, int scope);

它有兩個參數,第一個就是線程屬性對象的指針,第二個就是綁定類型,擁有兩個取值:pthread_scope_system(綁定的)和pthread_scope_process(非綁定的)。代碼2示範了這個屬性的使用。

代碼2設定線程綁定屬性

不知道你是否在這裡發現了本文的沖突之處。就是這個綁定屬性跟我們之前說的nptl有沖突之處。在介紹nptl的時候就說過業界有一種m:n的線程方案,就跟這個綁定屬性有關。但是筆者還說過nptl因為linux的“蠢”沒有采取這種方案,而是采用了“1:1”的方案。這也就是說,linux的線程永遠都是綁定。對,linux的線程永遠都是綁定的,是以pthread_scope_process在linux中不管用,而且會傳回enotsup錯誤。

既然linux并不支援線程的非綁定,為什麼還要提供這個接口呢?答案就是相容!因為linux的ntpl是号稱posix标準相容的,而綁定屬性正是posix标準所要求的,是以提供了這個接口。如果讀者們隻是在linux下編寫多線程程式,可以完全忽略這個屬性。如果哪天你遇到了支援這種特性的系統,别忘了我曾經跟你說起過這玩意兒:)

3.2 分離屬性

前面說過線程能夠被合并和分離,分離屬性就是讓線程在建立之前就決定它應該是分離的。如果設定了這個屬性,就沒有必要調用pthread_join()或pthread_detach()來回收線程資源了。

設定分離屬性的接口是pthread_attr_setdetachstate(),它的完整定義是:

pthread_attr_setdetachstat(pthread_attr_t *attr, int detachstate);

它的第二個參數有兩個取值:pthread_create_detached(分離的)和pthread_create_joinable(可合并的,也是預設屬性)。代碼3示範了這個屬性的使用。

代碼3設定線程分離屬性

3.3 排程屬性

線程的排程屬性有三個,分别是:算法、優先級和繼承權。

linux提供的線程排程算法有三個:輪詢、先進先出和其它。其中輪詢和先進先出排程算法是posix标準所規定,而其他則代表采用linux自己認為更合适的排程算法,是以預設的排程算法也就是其它了。輪詢和先進先出排程算法都屬于實時排程算法。輪詢指的是時間片輪轉,當線程的時間片用完,系統将重新配置設定時間片,并将它放置在就緒隊列尾部,這樣可以保證具有相同優先級的輪詢任務獲得公平的cpu占用時間;先進先出就是先到先服務,一旦線程占用了cpu則一直運作,直到有更高優先級的線程出現或自己放棄。

設定線程排程算法的接口是pthread_attr_setschedpolicy(),它的完整定義是:

pthread_attr_setschedpolicy(pthread_attr_t *attr, int policy);

它的第二個參數有三個取值:sched_rr(輪詢)、sched_fifo(先進先出)和sched_other(其它)。

linux的線程優先級與程序的優先級不一樣,程序優先級我們後面再說。linux的線程優先級是從1到99的數值,數值越大代表優先級越高。而且要注意的是,隻有采用shced_rr或sched_fifo排程算法時,優先級才有效。對于采用sched_other排程算法的線程,其優先級恒為0。

設定線程優先級的接口是pthread_attr_setschedparam(),它的完整定義是:

struct sched_param {

int sched_priority;

}

int pthread_attr_setschedparam(pthread_attr_t *attr,

struct sched_param *param);

sched_param結構體的sched_priority字段就是線程的優先級了。

此外,即便采用sched_rr或sched_fifo排程算法,線程優先級也不是随便就能設定的。首先,程序必須是以root賬号運作的;其次,還需要放棄線程的繼承權。什麼是繼承權呢?就是當建立新的線程時,新線程要繼承父線程(建立者線程)的排程屬性。如果不希望新線程繼承父線程的排程屬性,就要放棄繼承權。

設定線程繼承權的接口是pthread_attr_setinheritsched(),它的完整定義是:

int pthread_attr_setinheritsched(pthread_attr_t *attr, int inheritsched);

它的第二個參數有兩個取值:pthread_inherit_sched(擁有繼承權)和pthread_explicit_sched(放棄繼承權)。新線程在預設情況下是擁有繼承權。

代碼4能夠示範不同排程算法和不同優先級下各線程的行為,同時也展示如何修改線程的排程屬性。

代碼4設定線程排程屬性

這段代碼中含有一些沒有介紹過的接口,讀者們可以使用linux的聯機幫助來檢視它們的具體用法和作用。

3.4 堆棧大小屬性

從前面的這些例子中可以了解到,線程的主函數與程式的主函數main()有一個很相似的特性,那就是可以擁有局部變量。雖然同一個程序的線程之間是共享記憶體空間的,但是它的局部變量确并不共享。原因就是局部變量存儲在堆棧中,而不同的線程擁有不同的堆棧。linux系統為每個線程預設配置設定了8mb的堆棧空間,如果覺得這個空間不夠用,可以通過修改線程的堆棧大小屬性進行擴容。

修改線程堆棧大小屬性的接口是pthread_attr_setstacksize(),它的完整定義為:

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

它的第二個參數就是堆棧大小了,以位元組為機關。需要注意的是,線程堆棧不能小于16kb,而且盡量按4kb(32位系統)或2mb(64位系統)的整數倍配置設定,也就是記憶體頁面大小的整數倍。此外,修改線程堆棧大小是有風險的,如果你不清楚你在做什麼,最好别動它(其實我很後悔把這麼危險的東西告訴了你:)。

3.5 滿棧警戒區屬性

既然線程是有堆棧的,而且還有大小限制,那麼就一定會出現将堆棧用滿的情況。線程的堆棧用滿是非常危險的事情,因為這可能會導緻對核心空間的破壞,一旦被有心人士所利用,後果也不堪設想。為了防治這類事情的發生,linux為線程堆棧設定了一個滿棧警戒區。這個區域一般就是一個頁面,屬于線程堆棧的一個擴充區域。一旦有代碼通路了這個區域,就會發出sigsegv信号進行通知。

雖然滿棧警戒區可以起到安全作用,但是也有弊病,就是會白白浪費掉記憶體空間,對于記憶體緊張的系統會使系統變得很慢。所有就有了關閉這個警戒區的需求。同時,如果我們修改了線程堆棧的大小,那麼系統會認為我們會自己管理堆棧,也會将警戒區取消掉,如果有需要就要開啟它。

修改滿棧警戒區屬性的接口是pthread_attr_setguardsize(),它的完整定義為:

int pthread_attr_setguardsize(pthread_attr_t *attr, size_t guardsize);

它的第二個參數就是警戒區大小了,以位元組為機關。與設定線程堆棧大小屬性相仿,應該盡量按照4kb或2mb的整數倍來配置設定。當設定警戒區大小為0時,就關閉了這個警戒區。

雖然棧滿警戒區需要浪費掉一點記憶體,但是能夠極大的提高安全性,是以這點損失是值得的。而且一旦修改了線程堆棧的大小,一定要記得同時設定這個警戒區。

4. 線程本地存儲

内線程之間可以共享記憶體位址空間,線程之間的資料交換可以非常快捷,這是線程最顯著的優點。但是多個線程通路共享資料,需要昂貴的同步開銷,也容易造成與同步相關的bug,更麻煩的是有些資料根本就不希望被共享,這又是缺點。可謂:“成也蕭何,敗也蕭何”,說的就是這個道理。

c程式庫中的errno是個最典型的一個例子。errno是一個全局變量,會儲存最後一個系統調用的錯誤代碼。在單線程環境并不會出現什麼問題。但是在多線程環境,由于所有線程都會有可能修改errno,這就很難确定errno代表的到底是哪個系統調用的錯誤代碼了。這就是有名的“非線程安全(non thread-safe)”的。

此外,從現代技術角度看,在很多時候使用多線程的目的并不是為了對共享資料進行并行處理(在linux下有更好的方案,後面會介紹)。更多是由于多核心cpu技術的引入,為了充分利用cpu資源而進行并行運算(不互相幹擾)。換句話說,大多數情況下每個線程隻會關心自己的資料而不需要與别人同步。

為了解決這些問題,可以有很多種方案。比如使用不同名稱的全局變量。但是像errno這種名稱已經固定了的全局變量就沒辦法了。在前面的内容中提到線上程堆棧中配置設定局部變量是不線上程間共享的。但是它有一個弊病,就是線程内部的其它函數很難通路到。目前解決這個問題的簡便易行的方案是線程本地存儲,即thread local storage,簡稱tls。利用tls,errno所反映的就是本線程内最後一個系統調用的錯誤代碼了,也就是線程安全的了。

linux提供了對tls的完整支援,通過下面這些接口來實作:

int pthread_key_create(pthread_key_t *key, void (*destructor)(void*));

int pthread_key_delete(pthread_key_t key);

void* pthread_getspecific(pthread_key_t key);

int pthread_setspecific(pthread_key_t key, const void *value);

pthread_key_create()接口用于建立一個線程本地存儲區。第一個參數用來傳回這個存儲區的句柄,需要使用一個全局變量儲存,以便所有線程都能通路到。第二個參數是線程本地資料的回收函數指針,如果希望自己控制線程本地資料的生命周期,這個參數可以傳遞null。

pthread_key_delete()接口用于回收線程本地存儲區。其唯一的參數就要回收的存儲區的句柄。

pthread_getspecific()和pthread_setspecific()這個兩個接口分别用于擷取和設定線程本地存儲區的資料。這兩個接口在不同的線程下會有不同的結果不同(相同的線程下就會有相同的結果),這也就是線程本地存儲的關鍵所在。

代碼5展示了如何在linux使用線程本地存儲,注意執行結果,分析一下線程本地存儲的一些特性,以及記憶體回收的時機。

代碼5使用線程本地存儲

5. 線程的同步

雖然線程本地存儲可以避免線程通路共享資料,但是線程之間的大部分資料始終還是共享的。在涉及到對共享資料進行讀寫操作時,就必須使用同步機制,否則就會造成線程們哄搶共享資料的結果,這會把你的資料弄的七零八落理不清頭緒。

linux提供的線程同步機制主要有互斥鎖和條件變量。其它形式的線程同步機制用得并不多,本書也不準備詳細講解,有興趣的讀者可以參考相關文檔。

5.1 互斥鎖

首先我們看一下互斥鎖。所謂的互斥就是線程之間互相排斥,獲得資源的線程排斥其它沒有獲得資源的線程。linux使用互斥鎖來實作這種機制。

既然叫鎖,就有加鎖和解鎖的概念。當線程獲得了加鎖的資格,那麼它将獨享這個鎖,其它線程一旦試圖去碰觸這個鎖就立即被系統“拍暈”。當加鎖的線程解開并放棄了這個鎖之後,那些被“拍暈”的線程會被系統喚醒,然後繼續去争搶這個鎖。至于誰能搶到,隻有天知道。但是總有一個能搶到。于是其它來湊熱鬧的線程又被系統給“拍暈”了……如此反複。感覺線程的“頭”很痛:)

從互斥鎖的這種行為看,線程加鎖和解鎖之間的代碼相當于一個獨木橋,同意時刻隻有一個線程能執行。從全局上看,在這個地方,所有并行運作的線程都變成了排隊運作了。比較專業的叫法是同步執行,這段代碼區域叫臨界區。同步執行就破壞了線程并行性的初衷了,臨界區越大破壞得越厲害。是以在實際應用中,應該盡量避免有臨界區出現。實在不行,臨界區也要盡量的小。如果連縮小臨界區都做不到,那還使用多線程幹嘛?

互斥鎖在linux中的名字是mutex。這個似乎優點眼熟。對,在前面介紹nptl的時候提起過,但是那個叫futex,是系統底層機制。對于提供給使用者使用的則是這個mutex。linux初始化和銷毀互斥鎖的接口是pthread_mutex_init()和pthead_mutex_destroy(),對于加鎖和解鎖則有pthread_mutex_lock()、pthread_mutex_trylock()和pthread_mutex_unlock()。這些接口的完整定義如下:

int pthread_mutex_init(pthread_mutex_t *restrict mutex,

const pthread_mutexattr_t *restrict attr);

int pthread_mutex_destory(pthread_mutex_t *mutex );

int pthread_mutex_lock(pthread_mutex_t *mutex);

int pthread_mutex_trylock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);

從這些定義中可以看到,互斥鎖也是有屬性的。隻不過這個屬性在絕大多數情況下都不需要改動,是以使用預設的屬性就行。方法就是給它傳遞null。

phtread_mutex_trylock()比較特别,用它試圖加鎖的線程永遠都不會被系統“拍暈”,隻是通過傳回ebusy來告訴程式員這個鎖已經有人用了。至于是否繼續“強闖”臨界區,則由程式員決定。系統提供這個接口的目的可不是讓線程“強闖”臨界區的。它的根本目的還是為了提高并行性,留着這個線程去幹點其它有意義的事情。當然,如果很幸運恰巧這個時候還沒有人擁有這把鎖,那麼自然也會取得臨界區的使用權。

代碼6示範了在linux下如何使用互斥鎖。

代碼6使用互斥鎖

最後需要補充一點,互斥鎖在同一個線程内,沒有互斥的特性。也就是說,線程不能利用互斥鎖讓系統将自己“拍暈”。解釋這個現象的一個很好的理由就是,擁有鎖的線程把自己“拍暈”了,誰還能再擁有這把鎖呢?但是另外情況需要避免,就是兩個線程已經各自擁有一把鎖了,但是還想得到對方的鎖,這個時候兩個線程都會被“拍暈”。一旦這種情況發生,就誰都不能獲得這個鎖了,這種情況還有一個著名的名字——死鎖。死鎖是永遠都要避免的事情,因為這是嚴重損人不利己的行為。

5.2 條件變量

條件變量關鍵點在“變量”上。與鎖的不同之處就是,當線程遇到這個“變量”,并不是類似鎖那樣的被系統給“拍暈”,而是根據“條件”來選擇是否在那裡等待。等待什麼呢?等待允許通過的“信号”。這個“信号”是系統控制的嗎?顯然不是!它是由另外一個線程來控制的。

如果說互斥鎖可以比作獨木橋,那麼條件變量這就好比是馬路上的紅綠燈。車輛遇到紅綠燈肯定會根據“燈”的顔色來判斷是否通行,畢竟紅燈停綠燈行這個道理在幼稚園的時候老師就教了。那麼誰來控制“燈”的顔色呢?一定是交警啊,至少你我都不敢動它(有人會說那是自動的,可是間隔多少時間變換也是交警設定不是?)。那麼“車輛”和“交警”就是馬路上的兩類線程,大多數情況下都是“車”多“交警”少。

更深一步了解,條件變量是一種事件機制。由一類線程來控制“事件”的發生,另外一類線程等待“事件”的發生。為了實作這種機制,條件變量必須是共享于線程之間的全局變量。而且,條件變量也需要與互斥鎖同時使用。

初始化和銷毀條件變量的接口是pthread_cond_init()和pthread_cond_destory();控制“事件”發生的接口是pthread_cond_signal()或pthread_cond_broadcast();等待“事件”發生的接口是pthead_cond_wait()或pthread_cond_timedwait()。它們的完整定義如下:

int pthread_cond_init(pthread_cond_t *cond,

const pthread_condattr_t *attr);

int pthread_cond_destory(pthread_cond_t *cond);

int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);

int pthread_cond_timedwait(pthread_cond_t *cond,

pthread_mutex_t *mutex,

const timespec *abstime);

int pthread_cond_signal(pthread_cond_t *cond);

int pthread_cond_broadcast(pthread_cond_t *cond);

對于等待“事件”的接口從其名稱中可以看出,一種是無限期等待,一種是限時等待。後者與互斥鎖的pthread_mutex_trylock()有些類似,即當等待的“事件”經過一段時間之後依然沒有發生,那就去幹點别的有意義的事情去。而對于控制“事件”發生的接口則有“單點傳播”和“廣播”之說。所謂單點傳播就是隻有一個線程會得到“事件”已經發生了的“通知”,而廣播就是所有線程都會得到“通知”。對于廣播情況,所有被“通知”到的線程也要經過由互斥鎖控制的獨木橋。

對于條件變量的使用,可以參考代碼7,它實作了一種生産者與消費者的線程同步方案。

代碼7使用條件變量

從代碼中會發現,等待“事件”發生的接口都需要傳遞一個互斥鎖給它。而實際上這個互斥鎖還要在調用它們之前加鎖,調用之後解鎖。不單如此,在調用操作“事件”發生的接口之前也要加鎖,調用之後解鎖。這就面臨一個問題,按照這種方式,等于“發生事件”和“等待事件”是互為臨界區的。也就是說,如果“事件”還沒有發生,那麼有線程要等待這個“事件”就會阻止“事件”的發生。更幹脆一點,就是這個“生産者”和“消費者”是在來回的走獨木橋。但是實際的情況是,“消費者”在緩沖區滿的時候會得到這個“事件”的“通知”,然後将字元逐個列印出來,并清理緩沖區。直到緩沖區的所有字元都被列印出來之後,“生産者”才開始繼續工作。

為什麼會有這樣的結果呢?這就要說明一下pthread_cond_wait()接口對互斥鎖做什麼。答案是:解鎖。pthread_cond_wait()首先會解鎖互斥鎖,然後進入等待。這個時候“生産者”就能夠進入臨界區,然後在條件滿足的時候向“消費者”發出信号。當pthead_cond_wait()獲得“通知”之後,它還要對互斥鎖加鎖,這樣可以防止“生産者”繼續工作而“撐壞”緩沖區。另外,“生産者”在緩沖區不滿的情況下才能工作的這個限定條件是很有必要的。因為在pthread_cond_wait()獲得通知之後,在沒有對互斥鎖加鎖之前,“生産者”可能已經重新進入臨界區了,這樣“消費者”又被堵住了。也就是因為條件變量這種工作性質,導緻它必須與互斥鎖聯合使用。

此外,利用條件變量和互斥鎖,可以模拟出很多其它類型的線程同步機制,比如:event、semaphore等。本書不再多講,有興趣的讀者可以參考其它著作。或自己根據它們的行為自己來模拟實作。 

上一篇: utf8編碼規則

繼續閱讀