咱們將介紹爲單個運行進程提供的新抽象:線程(thread)。經典觀點是一個程序只有一個執行序列(一個程序計數器,用來存放要執行的指令),但多線程(multi-threaded)程序會有多個執行序列(多個程序計數器,每一個都用於取指令和執行)。換一個角度來看,每一個線程相似於獨立的進程,只有一點區別:它們共享地址空間,從而可以訪問相同的數據。安全
所以,單個線程的狀態與進程狀態很是相似。線程有一個程序計數器,記錄程序從哪裏獲取指令。每一個線程有本身的一組用於計算的寄存器。因此,若是有兩個線程運行在一個處理器上,從一個線程切換到另外一個線程時,一定發生上下文切換(context switch)。線程之間的上下文切換相似於進程間的上下文切換。對於進程,咱們將狀態保存到進程控制塊(ProcessControl Block,PCB)。如今,咱們須要一個或多個線程控制塊(Thread Control Block,TCB),保存每一個線程的狀態。可是,與進程相比,線程之間的上下文切換有一點主要區別:地址空間保持不變(即不須要切換當前使用的頁表)。多線程
線程和進程之間的另外一個主要區別在於棧。在簡單的傳統進程地址空間模型(咱們如今能夠稱之爲單線程(single-threaded)進程)中,只有一個棧,一般位於地址空間的底部(見圖左)。函數
然而,在多線程的進程中,每一個線程獨立運行,能夠調用各類例程來完成正在執行的任何工做。此時地址空間中不僅有一個棧,而是每一個線程都有一個棧。假設有一個多線程的進程,它有兩個線程,結果地址空間看起來不一樣(見圖右)。oop
之前,堆和棧能夠互不影響地增加,直到空間耗盡,多個棧就沒有這麼簡單了。幸運的是,一般棧不會很大(除了大量使用遞歸的程序)。this
假設咱們想運行一個程序,它建立兩個線程,每一個線程都作一些獨立的工做:分別打印「A」或「B」。代碼如圖所示。spa
主程序建立了兩個線程,分別執行函數mythread(),可是傳入不一樣的參數。一旦線程建立,可能會當即運行,或者處於就緒狀態,等待執行。建立了兩個線程後,主程序調用pthread_join(),等待特定線程完成。操作系統
1 #include <stdio.h> 2 #include <assert.h> 3 #include <pthread.h> 4 5 void *mythread(void *arg) { 6 printf("%s\n", (char *) arg); 7 return NULL; 8 } 9 10 int 11 main(int argc, char *argv[]) { 12 pthread_t p1, p2; 13 int rc; 14 printf("main: begin\n"); 15 rc = pthread_create(&p1, NULL, mythread, "A"); assert(rc == 0); 16 rc = pthread_create(&p2, NULL, mythread, "B"); assert(rc == 0); 17 // join waits for the threads to finish 18 rc = pthread_join(p1, NULL); assert(rc == 0); 19 rc = pthread_join(p2, NULL); assert(rc == 0); 20 printf("main: end\n"); 21 return 0; 22 }
此時,系統中存在有三個線程。代碼每次的執行結果都有可能與上次不一樣,有不少可能的順序,這取決於調度程序決定在給定時刻運行哪一個線程。線程
線程建立有點像進行函數調用。然而,並非首先執行函數而後返回給調用者,而是爲被調用的例程建立一個新的執行線程,它能夠獨立於調用者運行,可能在從建立者返回以前運行,但也許會晚得多。指針
設想一個簡單的例子,有兩個線程但願更新全局共享變量。代碼如圖所示。code
1 #include <stdio.h> 2 #include <pthread.h> 3 #include "mythreads.h" 4 5 static volatile int counter = 0; 6 7 // 8 // mythread() 9 // 10 // Simply adds 1 to counter repeatedly, in a loop 11 // No, this is not how you would add 10,000,000 to 12 // a counter, but it shows the problem nicely. 13 // 14 void * 15 mythread(void *arg) 16 { 17 printf("%s: begin\n", (char *) arg); 18 int i; 19 for (i = 0; i < 1e7; i++) { 20 counter = counter + 1; 21 } 22 printf("%s: done\n", (char *) arg); 23 return NULL; 24 } 25 26 // 27 // main() 28 // 29 // Just launches two threads (pthread_create) 30 // and then waits for them (pthread_join) 31 // 32 int 33 main(int argc, char *argv[]) 34 { 35 pthread_t p1, p2; 36 printf("main: begin (counter = %d)\n", counter); 37 Pthread_create(&p1, NULL, mythread, "A"); 38 Pthread_create(&p2, NULL, mythread, "B"); 39 40 // join waits for the threads to finish 41 Pthread_join(p1, NULL); 42 Pthread_join(p2, NULL); 43 printf("main: done with both (counter = %d)\n", counter); 44 return 0; 45 }
這段代碼的意圖很簡單:兩個線程分別將共享變量的計數器加一,並在循環中執行1000萬次。所以,預期的最終結果是20000000。
遺憾的是,即便是在單處理器上運行這段代碼,也不必定能得到預期結果。有時會這樣:
prompt> ./main main: begin (counter = 0) A: begin B: begin A: done B: done main: done with both (counter = 19345221)
並且,每次運行不但會產生錯誤的結果,甚至結果都不盡相同。那麼不由要問了:爲何會出現這種狀況?
爲了理解爲何會發生這種狀況,咱們必須瞭解編譯器爲更新計數器生成的代碼序列。在這個例子中,咱們只是想給counter加上一個數字。所以,作這件事的代碼序列可能看起來像這樣:
mov 0x8049a1c, %eax add $0x1, %eax mov %eax, 0x8049a1c
這個例子假定,變量counter位於地址0x8049a1c。在這3條指令中,先用mov指令從內存地址處取出值,放入eax。而後,給eax寄存器的值加1。最後,eax的值被存回內存中相同的地址。
設想線程1進入這個代碼區域,它將counter的值(假設它這時是50)加載到寄存器eax中。而後它向寄存器加1,所以eax = 51。如今,一件不幸的事情發生了:時鐘中斷髮生。操做系統將當前正在運行的線程(它的程序計數器、寄存器,包括eax等)的狀態保存到線程的TCB。
而後線程2被調度運行,並進入同一段代碼。它也執行了第一條指令,獲取計數器的值並將其放入eax中。此時counter的值仍爲50,所以eax = 50。線程2繼續執行接下來的兩條指令,將eax遞增1(此時eax = 51),而後將eax的內容保存到counter中。所以,全局變量counter如今的值是51。
最後,又發生一次上下文切換,線程1恢復運行。它已經執行過mov和add指令,如今準備執行最後一條mov指令。回憶一下,如今eax=51。最後的mov指令執行,將值保存到內存,counter再次被設置爲51。
發生的狀況是:增長counter的代碼被執行兩次,初始值爲50,可是結果爲51。
這裏展現的狀況稱爲競態條件(race condition):結果取決於代碼的時間執行。因爲執行過程當中發生的上下文切換,咱們獲得了錯誤的結果。事實上,可能每次都會獲得不一樣的結果。
因爲執行這段代碼的多個線程可能致使競爭狀態,所以咱們將此段代碼稱爲臨界區(criticalsection)。臨界區是訪問共享變量(或更通常地說,共享資源)的代碼片斷,必定不能由多個線程同時執行。
咱們真正想要的代碼就是所謂的互斥(mutual exclusion)。這個屬性保證了若是一個線程在臨界區內執行,其餘線程將被阻止進入臨界區。
解決這個問題的一種途徑是擁有更強大的指令,只需一步就能完成要作的事,從而消除不合時宜的中斷的可能性。好比,若是有一條超級指令原子地支持對內存變量的自增操做,上面的程序就能夠獲得正確的結果。
但在通常狀況下,不會有這樣的指令。所以,咱們要作的是要求硬件提供一些有用的指令,而後在這些指令上構建一個通用的集合,即所謂的同步原語(synchronization primitive)。經過使用這些硬件同步原語,加上操做系統的一些幫助,咱們將可以構建多線程代碼,以同步和受控的方式訪問臨界區,從而可靠地產生正確的結果。
事實證實,線程之間還有另外一種常見的交互,即一個線程在繼續以前必須等待另外一個線程完成某些操做。例如,當進程執行磁盤I/O並進入睡眠狀態時,會產生這種交互。當I/O完成時,該進程須要從睡眠中喚醒,以便繼續進行。
所以,咱們不只要研究如何構建同步原語來支持原子性,還要研究支持在多線程程序中常見的睡眠/喚醒交互的機制。
編寫多線程程序的第一步就是建立新線程,在POSIX中以下:
#include <pthread.h> int pthread_create(pthread_t * thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
該函數有4個參數:thread、attr、start_routine和arg。第一個參數thread是指向pthread_t結構類型的指針,咱們將利用這個結構與該線程交互,所以須要將它傳入pthread_create(),以便將它初始化。
第二個參數attr用於指定該線程可能具備的任何屬性。一些例子包括設置棧大小,或關於該線程調度優先級的信息。每一個屬性經過單獨調用pthread_attr_init()來初始化,在大多數狀況下,默認值就行。在這個例子中,咱們只需傳入NULL。
第三個參數最複雜,但它實際上只是問:這個線程應該在哪一個函數中運行?在C中,咱們把它稱爲一個函數指針(function pointer),這個指針告訴咱們須要如下內容:一個函數名稱(start_routine),它被傳入一個類型爲void *的參數,而且它返回一個void *類型的值(即一個void指針)。
注:在C中,將void指針做爲函數的參數,容許咱們傳入任何類型的參數,將它做爲返回值,容許函數返回任何類型的結果。
最後,第四個參數arg就是要傳遞給線程開始執行的函數的參數。
下面是建立線程的一個程序實例:
1 #include <pthread.h> 2 3 typedef struct myarg_t { 4 int a; 5 int b; 6 } myarg_t; 7 8 void *mythread(void *arg) { 9 myarg_t *m = (myarg_t *) arg; 10 printf("%d %d\n", m->a, m->b); 11 return NULL; 12 } 13 14 int 15 main(int argc, char *argv[]) { 16 pthread_t p; 17 int rc; 18 19 myarg_t args; 20 args.a = 10; 21 args.b = 20; 22 rc = pthread_create(&p, NULL, mythread, &args); 23 ... 24 }
若是想等待線程完成,你必須調用函數pthread_join()。該函數有兩個參數,第一個是pthread_t類型,用於指定要等待的線程。這個變量是由線程建立函數初始化的(當你將一個指針做爲參數傳遞給pthread_create()時),若是你保留了它,就能夠用它來等待該線程終止。
第二個參數是一個指針,指向你但願獲得的返回值。函數能夠返回任何東西,因此它被定義爲返回一個指向void的指針。由於pthread_join()函數改變了傳入參數的值,因此你須要傳入一個指向該值的指針,而不僅是該值自己。
下面是一個程序實例:
1 #include <stdio.h> 2 #include <pthread.h> 3 #include <assert.h> 4 #include <stdlib.h> 5 6 typedef struct myarg_t { 7 int a; 8 int b; 9 } myarg_t; 10 11 typedef struct myret_t { 12 int x; 13 int y; 14 } myret_t; 15 16 void *mythread(void *arg) { 17 myarg_t *m = (myarg_t *) arg; 18 printf("%d %d\n", m->a, m->b); 19 myret_t *r = Malloc(sizeof(myret_t)); 20 r->x = 1; 21 r->y = 2; 22 return (void *) r; 23 } 24 25 int 26 main(int argc, char *argv[]) { 27 int rc; 28 pthread_t p; 29 myret_t *m; 30 31 myarg_t args; 32 args.a = 10; 33 args.b = 20; 34 Pthread_create(&p, NULL, mythread, &args); 35 Pthread_join(p, (void **) &m); 36 printf("returned %d %d\n", m->x, m->y); 37 return 0; 38 }
除了線程建立和join以外,POSIX線程庫提供的最有用的函數集,多是經過鎖(lock)來提供互斥進入臨界區的那些函數。這方面最基本的一對函數是:
int pthread_mutex_lock(pthread_mutex_t *mutex); int pthread_mutex_unlock(pthread_mutex_t *mutex);
若是在調用pthread_mutex_lock()時沒有其餘線程持有鎖,線程將獲取該鎖並進入臨界區。若是另外一個線程確實持有該鎖,那麼嘗試獲取該鎖的線程將不會從該調用返回,直到得到該鎖(意味着持有該鎖的線程經過解鎖調用釋放該鎖)。在給定的時間內,許多線程可能會卡住,在獲取鎖的函數內部等待。然而,只有得到鎖的線程才應該調用解鎖。
不過還有幾點須要注意。首先,在使用這些函數以前,必須確保全部的鎖被正確地初始化,以保證它們具備正確的值,在鎖和解鎖被調用時按照須要工做。
對於POSIX線程,有兩種方法來初始化鎖。一種方法是使用宏PTHREAD_MUTEX_INITIALIZER,這樣作會將鎖設置爲默認值。另外一種是調用pthread_mutex_init(),此函數的第一個參數是鎖自己的地址,而第二個參數是一組可選屬性,傳入NULL就是使用默認值。不管哪一種方式都有效,但咱們一般使用後者。
注:當用完鎖時,還應該相應地調用pthread_mutex_destroy()來釋放資源。
其次,在調用獲取鎖和釋放鎖時還須要有檢查錯誤代碼。就像UNIX系統中調用的任何庫函數同樣,這些函數也可能會失敗!若是你的代碼沒有正確地檢查錯誤代碼,失敗將會靜靜地發生,可能會容許多個線程進入臨界區。所以咱們至少要使用包裝的函數,對函數成功加上斷言。
獲取鎖和釋放鎖函數不是pthread與鎖進行交互的僅有的函數。還有兩個可能會用到的函數:
int pthread_mutex_trylock(pthread_mutex_t *mutex); int pthread_mutex_timedlock(pthread_mutex_t *mutex, struct timespec *abs_timeout);
這兩個函數用於獲取鎖。若是鎖已被佔用,則trylock函數將失敗;timedlock函數會在超時或獲取鎖後返回,以先發生者爲準。所以,將超時時間設置爲0的timedlock將退化爲trylock。一般應避免使用這兩個函數,但有些狀況下,好比死鎖時,避免卡在獲取鎖的函數中會頗有用。
全部線程庫還有一個主要組件,就是存在一個條件變量(condition variable)。當線程之間必須發生某種信號時,若是一個線程在等待另外一個線程繼續執行某些操做,條件變量就頗有用。相關函數主要有以下兩個:
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex); int pthread_cond_signal(pthread_cond_t *cond);
要使用條件變量,必須另外有一個與此條件相關的鎖。在調用上述任何一個函數時,應該持有這個鎖。
第一個函數pthread_cond_wait()使調用線程進入休眠狀態,所以等待其餘線程發出信號,一般當程序中的某些內容發生變化時,喚醒如今正在休眠的線程。典型的用法以下所示:
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cond = PTHREAD_COND_INITIALIZER; Pthread_mutex_lock(&lock); while (ready == 0) Pthread_cond_wait(&cond, &lock); Pthread_mutex_unlock(&lock);
在這段代碼中,在初始化相關的鎖和條件變量以後,一個線程檢查變量ready是否已經被設置爲零之外的值。若是沒有,那麼線程只是簡單地調用等待函數以便休眠,直到其餘線程喚醒它。喚醒線程的代碼運行在另外某個線程中,像下面這樣:
Pthread_mutex_lock(&lock); ready = 1; Pthread_cond_signal(&cond); Pthread_mutex_unlock(&lock);
關於這段代碼有一些注意事項。首先,在發出信號時(以及修改全局變量ready時),始終確保持有鎖。這確保咱們不會在代碼中意外引入競態條件。
其次,你可能會注意到等待調用將鎖做爲其第二個參數,而信號調用僅須要一個條件。形成這種差別的緣由在於,等待調用除了使調用線程進入睡眠狀態外,還會讓調用者睡眠時釋放鎖。想象一下,若是不是這樣:其餘線程如何得到鎖並將其喚醒?可是,在被喚醒以後返回以前,pthread_cond_wait()會從新獲取該鎖,從而確保等待線程在等待序列開始時獲取鎖與結束時釋放鎖之間運行的任什麼時候間,它持有鎖。
最後一點須要注意:等待線程在while循環中從新檢查條件,而不是簡單的if語句。一般使用while循環是一件簡單而安全的事情,雖然它從新檢查了這種狀況(可能會增長一點開銷),但有一些pthread實現可能會錯誤地喚醒等待的線程。在這種狀況下,沒有從新檢查,等待的線程會繼續認爲條件已經改變。所以,將喚醒視爲某種事物可能已經發生變化的暗示,而不是絕對的事實,這樣更安全。
當構建多線程程序時,這裏有一些簡單而重要的建議: