在本章的學習中,咱們的學習目標以下:程序員
併發的概念:數據庫
若是邏輯控制流在時間上是重疊的,那麼它們就是併發的。編程
應用級併發的應用:安全
現代操做系統中三種構造併發程序的方法:服務器
線程與進程的區分:網絡
返回目錄多線程
構造併發編程最簡單的方法就是用進程,使用那些你們都很熟悉的函數,像fork、exec和waitpid。併發
步驟:app
四個步驟的示意圖以下:socket
注意:子進程關閉監聽描述符和父進程關閉已鏈接描述符是很重要的,由於父子進程共用同一文件表,文件表中的引用計數會增長,只有當引用計數減爲0時,文件描述符纔會真正關閉。因此,若是父子進程不關閉不用的描述符,將永遠不會釋放這些描述符,最終將引發存儲器泄漏而最終消耗盡能夠的存儲器,是系統崩潰。
使用進程併發編程要注意的問題:
進程的優劣:
對於在父、子進程間共享狀態信息,進程有一個很是清晰的模型:共享文件表,可是不共享用戶地址空間。進程有獨立的地址控件愛你既是優勢又是缺點。因爲獨立的地址空間,因此進程不會覆蓋另外一個進程的虛擬存儲器。可是另外一方面進程間通訊就比較麻煩,至少開銷很高。
編寫的代碼以下:
#include "csapp.h" void echo(int connfd); void sigchld_handler(int sig) { while (waitpid(-1, 0, WNOHANG) > 0) ; return; } int main(int argc, char **argv) { int listenfd, connfd, port, clientlen=sizeof(struct sockaddr_in); struct sockaddr_in clientaddr; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } port = atoi(argv[1]); Signal(SIGCHLD, sigchld_handler); listenfd = Open_listenfd(port); while (1) { connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen); if (Fork() == 0) { Close(listenfd); /* Child closes its listening socket */ echo(connfd); /* Child services client */ Close(connfd); /* Child closes connection with client */ exit(0); /* Child exits */ } Close(connfd); /* Parent closes connected socket (important!) */ } }
面對困境——服務器必須響應兩個互相獨立的I/O事件:
針對這種困境的一個解決辦法就是I/O多路複用技術。
I/O多路複用技術基本思想是:
可使用select、poll和epoll來實現I/O複用。使用select函數,要求內核掛起進程,只有在一個或者多個I/O事件發生後,纔將控制返給應用程序。
select函數以下圖所示:
使用select函數的過程以下:
基於i/o多路複用的併發事件驅動服務器:
I/O多路複用能夠用作併發事件驅動程序的基礎,在事件驅動程序中,流是由於某種事件而前進的,通常概念是將邏輯流模型化爲狀態機,不嚴格地說,一個狀態機就是一組狀態,輸入事件和轉移,其中轉移就是將狀態和輸入事件映射到狀態,每一個轉移都將一個(輸入狀態,輸入事件)對映射到一個輸出狀態,自循環是同一輸入和輸出狀態之間的轉移,一般把狀態機畫成有向圖,其中節點表示狀態,有向弧表示轉移,而弧上的標號表示輸人事件,一個狀態機從某種初始狀態開始執行,每一個輸入事件都會引起一個從當前狀態到下一狀態的轉移,對於每一個新的客戶端k,基於I/O多路複用的併發服務器會建立一個新的狀態機S,並將它和已鏈接描述符d聯繫起來。
I/O多路複用技術的優勢:
使用事件驅動編程,這樣比基於進程的設計給了程序更多的對程序行爲的控制。
一個基於I/O多路複用的事件驅動服務器是運行在單一進程上下文中的,所以每一個邏輯流都訪問該進程的所有地址空間。這使得在流之間共享數據變得很容易。一個與做爲單進程運行相關的優勢是,你能夠利用熟悉的調試工具,例如GDB來調試你的併發服務器,就像對順序程序那樣。最後,事件驅動設計經常比基於進程的設計要高效不少,由於它們不須要進程上下文切換來調度新的流。
缺點:
事件驅動設計的一個明星的缺點就是編碼複雜。咱們的事件驅動的併發服務器須要比基於進程的多三倍。不幸的是,隨着併發粒度的減少,複雜性還會上升。這裏的粒度是指每一個邏輯流每一個時間片執行的指令數量。
基於事件的設計的另外一重大的缺點是它們不能充分利用多核處理器。
編寫的代碼以下:
#include "csapp.h" void echo(int connfd); void command(void); int main(int argc, char **argv) { int listenfd, connfd, port, clientlen = sizeof(struct sockaddr_in); struct sockaddr_in clientaddr; fd_set read_set, ready_set; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } port = atoi(argv[1]); listenfd = Open_listenfd(port); FD_ZERO(&read_set); FD_SET(STDIN_FILENO, &read_set); FD_SET(listenfd, &read_set); while (1) { ready_set = read_set; Select(listenfd+1, &ready_set, NULL, NULL, NULL); if (FD_ISSET(STDIN_FILENO, &ready_set)) command(); /* read command line from stdin */ if (FD_ISSET(listenfd, &ready_set)) { connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); echo(connfd); /* echo client input until EOF */ } } } void command(void) { char buf[MAXLINE]; if (!Fgets(buf, MAXLINE, stdin)) exit(0); /* EOF */ printf("%s", buf); /* Process the input command */ }
在使用進程併發編程中,咱們爲每一個流使用了單獨的進程。內核會自動調用每一個進程。每一個進程有它本身的私有地址空間,這使得流共享數據很困難。在使用I/O多路複用的併發編程中,咱們建立了本身的邏輯流,並利用I/O多路複用來顯式地調度流。由於只有一個進程,全部的流共享整個地址空間。而基於線程的方法,是這兩種方法的混合。
線程執行模型:
線程和進程的執行模型有些類似。每一個進程的聲明週期都是一個線程,咱們稱之爲主線程。線程是對等的,主線程跟其餘線程的區別就是它先執行。
線程就是運行在進程上下文的邏輯流,以下圖所示。線程由內核自動調度。每一個線程都有它本身的線程上下文,包括一個惟一的整數線程ID、棧、棧指針、程序計數器、通用目的寄存器和條件碼。全部的運行在一個進程裏的線程共享該進程的整個虛擬地址空間。
基於線程的邏輯流結合了基於線程和基於I/O多路複用的流的特性。同進程同樣,線程由內核自動調度,而且內核經過一個整數ID來標識線程。同基於I/O多路複用的流同樣,多個線程運行在單一進程的上下文中,所以共享這個線程虛擬地址空間的整個內容,包括它的代碼、數據、堆、共享庫和打開的文件。
posix線程
POSIX線程是在C程序中處理線程的一個標準接口。它最先出如今1995年,並且在大多數Unix系統上均可用。Pthreads定義了大約60個函數,容許程序建立、殺死和回收線程,與對等線程安全地共享數據,還能夠通知對等線程系統狀態的變化。
建立線程:
pthread_create函數用來建立其餘進程。
pthread_create函數建立一個新的線程,並帶着一個輸入變量arg,在新線程的上下文中運行線程例程f。能用attr參數來改變新建立線程的默認屬性。
當pthread_create返回時,參數tid包含新建立線程的ID。
獲取自身ID:
pthread_self函數用來獲取自身ID。
終止線程:
一個線程是如下列方式之一來終止的:
pthread_exit函數和ptherad_cancel函數函數以下所示:
回收已終止線程的資源:
pthread_join函數會終止,直到線程tid終止。和wait不一樣,該函數只能回收指定id的線程,不能回收任意線程。
** 分離線程:**
在任何一個時間點上,線程是可結合的或者是分離的。一個可結合的線程可以被其餘線程收回其資源和殺死。在被其餘線程回收以前,它的存儲器資源(例如棧)式沒有被釋放的。相反,一個分離的線程是不能被其餘線程回收和殺死的。它的存儲器資源在它終止時由系統自動釋放。
默認狀況下,線程被建立成可結合的。爲了不存儲器泄漏,每一個可結合線程都應該要麼被其餘線程顯式地收回,要麼經過調用pthread_detach函數被分離。
pthread_detach函數分離可結合線程tid。線程可以經過以pthread_self()爲參數的pthread_detach調用來分離它們本身。
初始化線程:
pthread_once()函數用來初始化多個線程共享的全局變量。
編寫代碼以下:
/* * echoservert.c - A concurrent echo server using threads */ /* $begin echoservertmain */ #include "csapp.h" void echo(int connfd); void *thread(void *vargp); int main(int argc, char **argv) { int listenfd, *connfdp, port, clientlen=sizeof(struct sockaddr_in); struct sockaddr_in clientaddr; pthread_t tid; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } port = atoi(argv[1]); listenfd = Open_listenfd(port); while (1) { connfdp = Malloc(sizeof(int)); *connfdp = Accept(listenfd, (SA *) &clientaddr, &clientlen); Pthread_create(&tid, NULL, thread, connfdp); } } /* thread routine */ void *thread(void *vargp) { int connfd = *((int *)vargp); Pthread_detach(pthread_self()); Free(vargp); echo(connfd); Close(connfd); return NULL; }
每一個線程都有它本身獨自的線程上下文,包括線程ID、棧、棧指針、程序計數器、條件碼和通用目的寄存器值。每一個線程和其餘線程一塊兒共享進程上下文的剩餘部分。寄存器是從不共享的,而虛擬存儲器老是共享的。線程化的c程序中變量根據它們的存儲器類型被映射到虛擬存儲器:全局變量,本地自動變量(不共享),本地靜態變量。
線程存儲器模型:
一組併發線程運行在一個進程的上下文中。每一個線程都有它本身獨立的線程上下文,包括線程ID、棧、棧指針、程序計數器、條件碼和通用目的寄存器值。每一個線程和其餘線程一塊兒共享進程上下文的剩餘部分。這包括整個用戶虛擬地址空間,它是由只讀文本代碼、讀/寫數據、堆以及全部的共享庫代碼和數據區域組成的。線程也共享一樣的打開文件的集合。
從實際操做的角度來講,讓一個線程去讀或寫另外一個線程的寄存器值是不可能的。另外一方面,任何線程均可以訪問共享虛擬存儲器的任意位置。若是某個線程修改了一個存儲器位置,那麼其餘每一個線程最終都能在它讀這個位置時發現這個變化。所以,寄存器是從不共享的,而虛擬存儲器老是共享的。
各自獨立的線程棧的存儲器模型不是那麼整齊清楚的。這些棧被保存在虛擬地址空間的棧區域中,而且一般是被相應的線程獨立地訪問的。咱們說一般而不是老是,是由於不一樣的線程棧是不對其餘線程設防的因此,若是個線程以某種方式獲得個指向其餘線程棧的指慧:那麼它就能夠讀寫這個棧的任何部分。
線程化的C程序中變量根據它們的存儲類型被映射到虛擬存儲器:
共享變量:
咱們說一個變量v是共享的,當且僅當它的一個實例被一個以上的線程引用。
編寫代碼以下:
#include "csapp.h" #define N 2 void *thread(void *vargp); char **ptr; /* global variable */ int main() { int i; pthread_t tid; char *msgs[N] = { "Hello from foo", "Hello from bar" }; ptr = msgs; for (i = 0; i < N; i++) Pthread_create(&tid, NULL, thread, (void *)i); Pthread_exit(NULL); } void *thread(void *vargp) { int myid = (int)vargp; static int cnt = 0; printf("[%d]: %s (cnt=%d)\n", myid, ptr[myid], ++cnt); }
臨界區使用規則:
OS以一個地位高於進程的管理者的角度來解決公有資源的使用問題,信號量就是OS提供的管理公有資源的有效手段。
信號量的定義:
type semaphore=record count: integer; queue: list of process end; var s:semaphore;
對信號量的兩個原子操做:
進程進入臨界區以前,首先執行wait(s)原語,若s.count小於0,則進程調用阻塞原語,將本身阻塞,並插入到s.queue隊列排隊;
一旦其它某個進程執行了signal(s)原語中的s.count+1操做後,發現s.count ≤0,即阻塞隊列中還有被阻塞進程,則調用喚醒原語,把s.queue中第一個進程修改成就緒狀態,送就緒隊列,準備執行臨界區代碼。
wait(s) s.count :=s.count-1; if s.count<0 then begin 進程阻塞; 進程進入s.queue隊列; end;
signal(s) s.count :=s.count+1; if s.count ≤0 then begin 喚醒隊首進程; 將進程從s.queue阻塞隊列中移出; end;
經典進程互斥與同步問題:
基於預線程化的併發服務器
在如圖所示的併發服務器中,咱們爲每個新客戶端建立了一個新線程這種方法的缺點是咱們爲每個新客戶端建立一個新線程,致使不小的代價。一個基於預線程化的服務器試圖經過使用如圖所示的生產者-消費者模型來下降這種開銷。服務器是由一個主線程和一組工做者線程構成的。主線程不斷地接受來自客戶端的鏈接請求,並將獲得的鏈接描述符放在一個不限緩衝區中。每個工做者線程反覆地從共享緩衝區中取出描述符,爲客戶端服務,而後等待下一個描述符。
編寫代碼以下:
/* * badcnt.c - An improperly synchronized counter program */ /* $begin badcnt */ #include "csapp.h" #define NITERS 200000000 void *count(void *arg); /* shared counter variable */ unsigned int cnt = 0; int main() { pthread_t tid1, tid2; Pthread_create(&tid1, NULL, count, NULL); Pthread_create(&tid2, NULL, count, NULL); Pthread_join(tid1, NULL); Pthread_join(tid2, NULL); if (cnt != (unsigned)NITERS*2) printf("BOOM! cnt=%d\n", cnt); else printf("OK cnt=%d\n", cnt); exit(0); } /* thread routine */ void *count(void *arg) { int i; for (i = 0; i < NITERS; i++) cnt++; return NULL; }
到目前爲止,在對併發的研究中,咱們都假設併發線程是在單處許多現代機器具備多核處理器。併發程序一般在這樣的機器上運理器系統上執行的。然而,在多個核上並行地調度這些併發線程,而不是在單個核順序地調度,在像繁忙的Web服務器、數據庫服務器和大型科學計算代碼這樣的應用中利用這種並行性是相當重要的。
1.四種不安全函數
(1):不保護共享變量的函數。
(2):保持跨越多個調用的狀態的函數。一個僞隨機數生成器是這類線程不安全函數的簡單例子。rand函數是線程不安全的,由於檔期調用的結果依賴於前次調用的中間結果。當調用srand爲rand設置了一個終止後,咱們從一個但線程中反覆地調用rand,可以預期獲得一個可重複的隨機數字序列。
(3):返回指向靜態變量的指針的函數。某些函數,例如ctime和gethostbyname,將計算結果放在一個static變量中,而後返回一個指向這個變量的指針。若是咱們從併發線程中調用這些函數,那麼將可能發生災難,由於正在被一個線程使用的結果會被另外一個線程悄悄地覆蓋了。
有兩種方法來處理這類線程不安全函數。一種選擇是重寫函數,使得調用者傳遞存放結果的變量的地址。這就消除了全部共享數據,可是它要求程序員可以修改函數的源代碼。
若是線程不安全是難以修改或不可能修改的,那麼另一種選擇是使用加鎖-拷貝技術。基本思想是將線程不安全函數與互斥鎖聯繫起來,在每個調用位置,對互斥鎖加鎖,調用線程不安全函數,將函數返回的結果拷貝到一個私有的存儲器位置,而後對互斥鎖解鎖。爲了儘量減小對調用者的修改,你應該定義一個線程安全的包裝函數,它執行加鎖-拷貝,而後經過調用這個包裝函數來取代對線程不安全函數的調用。
(4):調用線程不安全函數的函數。若是函數f調用線程不安全函數g,那麼f就是線程不安全的嗎?不必定。若是g是第二類資源,即依賴於跨越屢次調用的狀態,那麼f也是線程不安全的,並且除了重寫g覺得,沒有辦法。然而,若是g是第一類或第三類函數,那麼只要你用一個互斥鎖保護調用位置和任何獲得的共享數據,f仍然多是線程安全的。
2.可重入函數。可重入函數是線程安全函數的一個真子集,它不訪問任何共享數據。可重入安全函數一般比不可重入函數更有效,由於它們不須要任何同步原語。
3.競爭。當程序員錯誤地假設邏輯流該如何調度時,就會發生競爭。爲了消除競爭,一般咱們會動態地分配內存空間。
4.死鎖。當一個流等待一個永遠不會發生的事件時,就會發生死鎖。