若是邏輯控制流在實際上重疊,那麼它們就是併發的,這種常見的現象稱爲併發,出如今計算機系統的許多不一樣層面上。程序員
應用級併發在其餘狀況下也是頗有用的:編程
使用應用級併發的應用程序稱爲併發程序。現代操做系統提供了三種基本的構造併發程序的方法:安全
構造併發編程最簡單的方法就是用進程,使用那些你們都很熟悉的函數,像fork、exec和waitpid。服務器
步驟:網絡
1)服務器監聽一個監聽描述符上的鏈接請求。多線程
2)服務器接受了客戶端1的鏈接請求,並返回一個已鏈接描述符。併發
3)在接受了鏈接請求以後,服務器派生一個子進程,這個子進程得到服務器描述符表的完整拷貝。子進程關閉它的拷貝中的監聽描述符3,而父進程關閉它的已鏈接描述符4的拷貝,由於再也不須要這些描述符了。dom
4)子進程正忙於爲客戶端提供服務,父進程繼續監聽新的請求。函數
注意:子進程關閉監聽描述符和父進程關閉已鏈接描述符是很重要的,由於父子進程共用同一文件表,文件表中的引用計數會增長,只有當引用計數減爲0時,文件描述符纔會真正關閉。因此,若是父子進程不關閉不用的描述符,將永遠不會釋放這些描述符,最終將引發存儲器泄漏而最終消耗盡能夠的存儲器,是系統崩潰。工具
使用進程併發編程要注意的問題:
1)首先,一般服務器會運行很長的時間,因此咱們必需要包括一個SIGCHLD處理程序,來回收僵死子進程的資源。由於當SIGCHLD處理程序執行時,SIGCHLD信號時阻塞的,而Unix信號時不排隊的,因此SIGCHLD處理程序必須準備好回收多個僵死子進程的資源。
2)其次,子進程必須關閉它們各自的connfd拷貝。就像咱們已經提到過的,這對父進程而言尤其重要,它必須關閉它的已鏈接描述符,以免存儲器泄漏。
3)最後,由於套接字的文件表表項中的引用計數,直到父子進程的connfd都關閉了,到客戶端的鏈接纔會終止。
缺點:
對於父子進程間共享狀態信息,進程有一個很是清晰的模型:共享文件表,可是不共享用戶地址空間。進程有獨立的地址空間既是優勢也是缺點。這樣一來,一個進程不可能不當心覆蓋另外一個進程的虛擬存儲器,這就消除了許多使人迷惑的錯誤——這是一個明顯的優勢。
另外一方面,獨立的地址空間使得進程共享狀態信息變得更加困難。爲了共享信息,它們必須使用顯式的IPC(進程間通訊)機制。基於進程的設計的另外一個缺點是,它們每每比較慢,由於進程控制和IPC的開銷很高。
一、面對困境——服務器必須響應兩個互相獨立的I/O事件:1)網絡客戶端發起的鏈接請求 2)用戶在鍵盤上鍵入的命令 ,解決的辦法是I/O多路複用技術。基本思想是,使用select函數,要求內核掛起進程,只有在一個或多個I/O事件發生後,纔將控制返回給應用程序。
可使用select、poll和epoll來實現I/O複用。
I/O多路複用技術的優劣:
1)使用事件驅動編程,這樣比基於進程的設計給了程序更多的對程序行爲的控制。
2)一個基於I/O多路複用的事件驅動服務器是運行在單一進程上下文中的,所以每一個邏輯流都訪問該進程的所有地址空間。這使得在流之間共享數據變得很容易。一個與做爲單進程運行相關的優勢是,你能夠利用熟悉的調試工具,例如GDB來調試你的併發服務器,就像對順序程序那樣。最後,事件驅動設計經常比基於進程的設計要高效不少,由於它們不須要進程上下文切換來調度新的流。
缺點:
事件驅動設計的一個明星的缺點就是編碼複雜。咱們的事件驅動的併發服務器須要比基於進程的多三倍。不幸的是,隨着併發粒度的減少,複雜性還會上升。這裏的粒度是指每一個邏輯流每一個時間片執行的指令數量。
基於事件的設計的另外一重大的缺點是它們不能充分利用多核處理器。
在使用進程併發編程中,咱們爲每一個流使用了單獨的進程。內核會自動調用每一個進程。每一個進程有它本身的私有地址空間,這使得流共享數據很困難。在使用I/O多路複用的併發編程中,咱們建立了本身的邏輯流,並利用I/O多路複用來顯式地調度流。由於只有一個進程,全部的流共享整個地址空間。而基於線程的方法,是這兩種方法的混合。
線程就是運行在進程上下文的邏輯流。線程由內核自動調度。每一個線程都有它本身的線程上下文,包括一個惟一的整數線程ID、棧、棧指針、程序計數器、通用目的寄存器和條件碼。全部的運行在一個進程裏的線程共享該進程的整個虛擬地址空間。
基於線程的邏輯流結合了基於線程和基於I/O多路複用的流的特性。同進程同樣,線程由內核自動調度,而且內核經過一個整數ID來標識線程。同基於I/O多路複用的流同樣,多個線程運行在單一進程的上下文中,所以共享這個線程虛擬地址空間的整個內容,包括它的代碼、數據、堆、共享庫和打開的文件。
線程執行模型
多線程的執行模型在某些方面和多進程的執行模型是類似的。每一個進程開始生命週期時都是單一線程,這個線程是主線程。在某一時刻,主線程建立一個對等線程,從這個時間點開始,兩個線程就併發地運行。最後,由於主線程執行一個慢速系統調用,例如read和sleep,或者由於它被系統的間隔計時器中斷,控制就會經過上下文切換到對等線程。對等線程會執行一段時間,而後控制傳遞迴主線程,依次類推。
在一些重要的方法,線程執行時不一樣於進程的。由於一個線程的上下文要比一個進程的上下文小不少,線程的上下文切換要比進程的上下文切換快得多。另外一個不一樣就是線程不像進程那樣,不是按照嚴格的父子層次來組織的。和一個進程相關的線程組成一個對等(線程)池,獨立於其餘線程建立的線程。主線程和其餘線程的區別僅在於它老是進程中第一個運行的線程。對等(線程)池概念的主要影響是,一個線程能夠殺死它的任何對等線程,或者等待它的任意對等線程終止。另外,每一個對等線程都能讀寫相同的共享數據。
建立線程
線程經過調用pthread_create函數來建立其餘線程:
pthread_create函數建立一個新的線程,並帶着一個輸入變量arg,在新線程的上下文中運行線程例程f。能用attr參數來改變新建立線程的默認屬性。
當pthread_create返回時,參數tid包含新建立線程的ID。新線程能夠經過調用pthread_self函數來得到它本身的線程ID。
終止線程
一個線程是如下列方式之一來終止的:
回收已終止線程的資源
pthread_join函數會終止,直到線程tid終止,將線程例程返回的(void*)指針賦值爲thread_return指向的位置,而後回收已終止線程佔用的全部存儲器資源。和wait不一樣,該函數只能回收指定id的線程,不能回收任意線程。
分離線程
在任何一個時間點上,線程是可結合的或者是分離的。一個可結合的線程可以被其餘線程收回其資源和殺死。在被其餘線程回收以前,它的存儲器資源(例如棧)式沒有被釋放的。相反,一個分離的線程是不能被其餘線程回收和殺死的。它的存儲器資源在它終止時由系統自動釋放。
默認狀況下,線程被建立成可結合的。爲了不存儲器泄漏,每一個可結合線程都應該要麼被其餘線程顯式地收回,要麼經過調用pthread_detach函數被分離。
pthread_detach函數分離可結合線程tid。線程可以經過以pthread_self()爲參數的pthread_detach調用來分離它們本身。
初始化線程:該函數用來初始化多個線程共享的全局變量。
從一個程序員的角度來看,線程頗有吸引力的一個方面就是多個線程很容易共享相同的程序變量。然而,這種共享也是很棘手的。爲了編寫正確的線程化程序,咱們必須對所謂的共享以及它是如何工做的有很清楚的瞭解。
爲了理解變量是不是共享的,有一些基本的問題要解答:1)線程的基礎存儲器模型是什麼?2)根據這個模型,變量實例是如何映射到存儲器的?3)最後,有多少線程引用這些實例?一個變量是共享的,當且僅當多個線程引用這個變量的某個實例。
線程存儲器模型:
一組併發線程運行在一個進程的上下文中。 每一個線程都有它本身獨自的線程上下文,包括線程ID、棧、棧指針、程序計數器、條件碼和通用目的寄存器值。每一個線程和其餘線程一塊兒共享進程上下文的剩餘部分。這包括整個用戶虛擬地址空間,它是由只讀文本(代碼)、讀/寫數據、堆以及全部的共享庫代碼和數據區域組成的。線程也共享一樣的打開文件的集合。
從實際操做的角度來講,讓一個線程去讀或寫另外一個線程的寄存器值時不可能的。另外一方面,任何線程均可以訪問共享虛擬存儲器的任意位置。若是某個線程修改了存儲器的位置,那麼其餘每一個線程最終都能在它讀這個位置時發現這個變化。所以,寄存器是歷來不共享的,而虛擬存儲器老是共享的。
各自獨立的線程棧的存儲器模型不是那麼整齊清楚的。這些棧被保存在虛擬地址空間的棧區域中,而且一般是被相應的線程獨立地訪問的。咱們說一般而不是老是,是由於不一樣的線程棧是不對其餘線程設防的。因此,若是一個線程以某種方式獲得一個指向其餘線程棧的指針,那麼它就能夠讀寫這個棧的任何部分。
將變量映射到存儲器:
線程化的C程序中變量根據它們的存儲器類型被映射到虛擬存儲器:
共享變量
咱們說一個變量v是共享的,當且僅當它的一個實例被一個以上的線程引用。
共享變量的同步與互斥
1)使用信號量同步線程
共享變量引入了同步錯誤。
進度圖: 軌跡線示例: 臨界區(不安全區):
信號量:是用信號量解決同步問題,信號量s是具備非負整數值的全局變量,有兩種特殊的操做來處理(P和V):
P(s):若是s非零,那麼P將s減1,而且當即返回。若是s爲0,那麼就掛起這個線程,直到s變爲非零;
V(s):V操做將s加1。
使用信號量實現互斥:
利用信號量調度共享資源:在這種場景中,一個線程用信號量操做來通知另外一個線程,程序狀態中的某個條件已經爲真了。兩個經典應用:
a)生產者——消費者問題
要求:必須保證對緩衝區的訪問是互斥的;還須要調度對緩衝區的訪問,即,若是緩衝區是滿的(沒有空的槽位),那麼生產者必須等待直到有一個空的槽位爲止,若是緩衝區是空的(即沒有可取的項目),那麼消費者必須等待直到有一個項目變爲可用。
註釋:5~13行,緩衝區初始化,主要是對緩衝區結構體進行相關操做;16~19行,釋放緩衝區存儲空間;22~29行,生產(有空槽的話,在空槽中插入內容);32~4行,消費(去除某個槽中的內容,使該槽爲空)
b)讀者——寫者問題
修改對象的線程叫作寫者;只讀對象的線程叫作讀者。寫着必須擁有對對象的獨佔訪問,而讀者能夠和無限多個其餘讀者共享對象。讀者——寫者問題基本分爲兩類:第一類,讀者優先,要求不要讓讀者等待,除非已經把使用對象的權限賦予了一個寫者。換句話說,讀者不會由於有一個寫者等待而等待;第二類,寫者優先,要求必定能寫者準備好能夠寫,它就會盡量地完成它的寫操做。同第一類問題不一樣,在一個寫者後到達的讀者必須等待,即便這個寫者也是在等待。如下程序給出了第一類讀者——寫者問題的解答:
註釋:信號量w控制對訪問共享對象的臨界區的訪問。信號量mutex保護對共享變量readcnt的訪問,readcnt統計當前臨界區的讀者數量。每當一個寫者進入臨界區,它就對互斥鎖w加鎖,每當它離開臨界區時,對w解鎖,這就保證了任意時刻臨界區最多有一個寫者;另外一方面,只有第一個進入臨界區的讀者對w加鎖,而只有最後一個離開臨界區的讀者對w解鎖。
綜合:基於預線程的併發服務器以前介紹的基於線程的併發服務器,須要爲每一個客戶端新建一個新線程,致使不小的代價。一個基於預線程化的服務器經過使用以下圖所示的生產者——消費者模型來下降這種開銷。服務器是由一個主線程和一組工做組線程構成的。主線程不斷地接受來自客戶端的鏈接請求,並將獲得的鏈接描述符放在一個有限緩衝區中。每個工做組線程反覆地從共享緩衝區中取出描述符,爲客戶端服務,而後等待下一個描述符。
程序示例以下圖:
註釋:26~27行,產生工做組線程;29~32行,接受客戶端的鏈接請求,並把這些描述符放到緩衝區;35~43行,每一個線程所要完成的工做;19行,初始化線程共享的全局變量。初始化有兩種方式,一種是它要求主線程顯示地調用一個初始化函數;第二種是,在此顯示的,當第一次有某個線程調用echo_cnt函數時,使用pthread_once函數去調用初始化函數。
1)線程安全
當用線程編寫程序時,咱們必須當心地編寫那些具備稱爲線程安全性屬性的函數。一個函數被稱爲線程安全的,當且僅當被多個併發線程反覆地調用時,它會一直產生正確的結果。若是一個函數不是線程安全的,咱們就說它是線程不安全的。
咱們可以定義出四個(不想交的)線程不安全函數類:
第一類:不保護共享變量的函數。
第二類:保持跨越多個調用的狀態的函數。一個僞隨機數生成器是這類線程不安全函數的簡單例子。rand函數是線程不安全的,由於檔期調用的結果依賴於前次調用的中間結果。當調用srand爲rand設置了一個終止後,咱們從一個但線程中反覆地調用rand,可以預期獲得一個可重複的隨機數字序列。
第三類:返回指向靜態變量的指針的函數。某些函數,例如ctime和gethostbyname,將計算結果放在一個static變量中,而後返回一個指向這個變量的指針。若是咱們從併發線程中調用這些函數,那麼將可能發生災難,由於正在被一個線程使用的結果會被另外一個線程悄悄地覆蓋了。
有兩種方法來處理這類線程不安全函數。一種選擇是重寫函數,使得調用者傳遞存放結果的變量的地址。這就消除了全部共享數據,可是它要求程序員可以修改函數的源代碼。
若是線程不安全是難以修改或不可能修改的,那麼另一種選擇是使用加鎖-拷貝技術。基本思想是將線程不安全函數與互斥鎖聯繫起來,在每個調用位置,對互斥鎖加鎖,調用線程不安全函數,將函數返回的結果拷貝到一個私有的存儲器位置,而後對互斥鎖解鎖。爲了儘量減小對調用者的修改,你應該定義一個線程安全的包裝函數,它執行加鎖-拷貝,而後經過調用這個包裝函數來取代對線程不安全函數的調用。
第四類:調用線程不安全函數的函數。若是函數f調用線程不安全函數g,那麼f就是線程不安全的嗎?不必定。若是g是第二類資源,即依賴於跨越屢次調用的狀態,那麼f也是線程不安全的,並且除了重寫g覺得,沒有辦法。然而,若是g是第一類或第三類函數,那麼只要你用一個互斥鎖保護調用位置和任何獲得的共享數據,f仍然多是線程安全的。
2)可重入性
有一類重要的線程安全函數,叫作可重入函數,其特色在於它們具備這樣一種屬性:當它們被多個線程調用時,不會引用共享數據。儘管線程安全和可重入有時會被用作同義詞,可是它們之間仍是有清晰的技術差異的。下圖表示了可重入函數、線程安全函數和線程不安全函數之間的集合關係。可重入函數集合是線程安全函數的一個真子集。
可重入函數一般比不可重入函數高效一些,由於不須要同步操做。
若是全部的函數參數都是傳值傳遞(沒有指針),且全部的數據引用都是本地的自動棧變量(沒有引用靜態或全局變量),則函數是顯式可重入的,不管如何調用,都沒有問題。
容許顯式可重入函數中部分參數用指針傳遞,則隱式可重入的。在調用線程時當心傳遞指向非共享數據的指針,它纔是可重入。如rand_r。
可重入性同時是調用者和被調用者的屬性。
3)競爭
當一個程序的正確性依賴於一個線程要在另外一個線程到達y點以前到達它的控制流中的x點時,就會發生競爭。
4)死鎖
信號量引入了一種潛在的使人厭惡的運行時錯誤,叫作死鎖,它指的是一組線程被阻塞了,等待一個永遠也不會爲真的條件。
避免死鎖是很困難的。當使用二進制信號量來實現互斥時,能夠用以下規則避免:
若是用於程序中每對互斥鎖(s,t),每一個既包含s也包含t的線程都按照相同順序同時對它們加鎖,則程序是無死鎖的。