標籤:
並發編程
如果邏輯控制流程在實際上重疊,那麼它們就是並發的,這種常見的現象稱為並發,出現在電腦系統的許多不同層面上。
應用級並發在其他情況下也是很有用的:
- 訪問慢速I/O裝置。
- 與人互動。
- 通過延遲工作以降低延遲。
- 服務多個網路用戶端。
- 在多核機器上進行並行計算。
使用應用級並發的應用程式稱為並發程式。現代作業系統提供了三種基本的構造並發程式的方法:
- 進程。用這種方法,每個邏輯控制流程都是一個進程,由核心來調度和維護。因為進程有獨立的虛擬位址空間,想要和其他流通訊,控制流程必須使用某種顯式的處理序間通訊機制。
- I/O多工。在這種形式的並發編程中,應用程式在一個進程的上下文中顯式地調度它們自己的邏輯流。邏輯流被模型化為狀態機器,資料到達檔案描述符後,主程式顯式地從一個狀態轉換到另一個狀態。因為程式是一個單進程,所以所有的流都共用同一地址空間。
- 線程。線程是運行在一個單一進程上下文中的邏輯流,由核心進行調度。你可以把線程看成是其他兩種方式的混合體,像進程流一樣由核心進行調度,而像I/O多工流一樣共用同一虛擬位址空間。
基於進程的並發編程
構造並發編程最簡單的方法就是用進程,使用那些大家都很熟悉的函數,像fork、exec和waitpid。
步驟:
1)伺服器監聽一個監聽描述符上的串連請求。
2)伺服器接受了用戶端1的串連請求,並返回一個已串連描述符。
3)在接受了串連請求之後,伺服器派生一個子進程,這個子進程獲得伺服器描述符表的完整拷貝。子進程關閉它的拷貝中的監聽描述符3,而父進程關閉它的已串連描述符4的拷貝,因為不再需要這些描述符了。
4)子進程正忙於為用戶端提供服務,父進程繼續監聽新的請求。
注意:子進程關閉監聽描述符和父進程關閉已串連描述符是很重要的,因為父子進程共用同一檔案表,檔案表中的引用計數會增加,只有當引用計數減為0時,檔案描述符才會真正關閉。所以,如果父子進程不關閉不用的描述符,將永遠不會釋放這些描述符,最終將引起儲存空間泄漏而最終消耗盡可以的儲存空間,是系統崩潰。
使用進程並發編程要注意的問題:
1)首先,通常伺服器會運行很長的時間,所以我們必須要包括一個SIGCHLD處理常式,來回收僵死子進程的資源。因為當SIGCHLD處理常式執行時,SIGCHLD訊號時阻塞的,而Unix訊號時不排隊的,所以SIGCHLD處理常式必須準備好回收多個僵死子進程的資源。
2)其次,子進程必須關閉它們各自的connfd拷貝。就像我們已經提到過的,這對父進程而言尤為重要,它必須關閉它的已串連描述符,以避免儲存空間泄漏。
3)最後,因為通訊端的檔案表表項中的引用計數,直到父子進程的connfd都關閉了,到用戶端的串連才會終止。
缺點:
對於父子進程間共用狀態資訊,進程有一個非常清晰的模型:共用檔案表,但是不共用使用者地址空間。進程有獨立的地址空間既是優點也是缺點。這樣一來,一個進程不可能不小心覆蓋另一個進程的虛擬儲存空間,這就消除了許多令人迷惑的錯誤——這是一個明顯的優點。
另一方面,獨立的地址空間使得進程共用狀態資訊變得更加困難。為了共用資訊,它們必須使用顯式的IPC(處理序間通訊)機制。基於進程的設計的另一個缺點是,它們往往比較慢,因為進程式控制制和IPC的開銷很高。
基於I/O多工並發編程
1、面對困境——伺服器必須響應兩個互相獨立的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,或者因為它被系統的間隔計時器中斷,控制就會通過環境切換到對等線程。對等線程會執行一段時間,然後控制傳遞迴主線程,依次類推。
在一些重要的方法,線程執行時不同於進程的。因為一個線程的上下文要比一個進程的上下文小很多,線程的環境切換要比進程的環境切換快得多。另一個不同就是線程不像進程那樣,不是按照嚴格的父子層次來組織的。和一個進程相關的線程組成一個對等(線程)池,獨立於其他線程建立的線程。主線程和其他線程的區別僅在於它總是進程中第一個啟動並執行線程。對等(線程)池概念的主要影響是,一個線程可以殺死它的任何對等線程,或者等待它的任意對等線程終止。另外,每個對等線程都能讀寫相同的共用資料。
Posix 線程
建立線程
線程通過調用pthread_create函數來建立其他線程:
pthread_create函數建立一個新的線程,並帶著一個輸入變數arg,在新線程的上下文中運行線程常式f。能用attr參數來改變新建立線程的預設屬性。
當pthread_create返回時,參數tid包含新建立線程的ID。新線程可以通過調用pthread_self函數來獲得它自己的線程ID。
終止線程
一個線程是以下列方式之一來終止的:
- 當頂層的線程常式返回時,線程會隱式地終止
- 通過調用pthread_exit函數,線程會顯式地終止。如果主線程調用pthread_exit,它會等待所有其他對等線程終止,然後再終止主線程和這個進程,傳回值為thread_return。
- 某個對等線程調用exit函數,則函數終止進程和所有與該進程相關的線程;
- 另一個對等線程調用以當前ID為參數的函數ptherad_cancel來終止當前線程。
回收已終止線程的資源
pthread_join函數會終止,直到線程tid終止,將線程常式返回的(void*)指標賦值為thread_return指向的位置,然後回收已終止線程佔用的所有儲存空間資源。和wait不同,該函數只能回收指定id的線程,不能回收任意線程。
分離線程
在任何一個時間點上,線程是可結合的或者是分離的。一個可結合的線程能夠被其他線程收回其資源和殺死。在被其他線程回收之前,它的儲存空間資源(例如棧)式沒有被釋放的。相反,一個分離的線程是不能被其他線程回收和殺死的。它的儲存空間資源在它終止時由系統自動釋放。
預設情況下,線程被建立成可結合的。為了避免儲存空間泄漏,每個可結合線程都應該要麼被其他線程顯式地收回,要麼通過調用pthread_detach函數被分離。
pthread_detach函數分離可結合線程tid。線程能夠通過以pthread_self()為參數的pthread_detach調用來分離它們自己。
初始化線程:該函數用來初始化多個線程共用的全域變數。
多線程程式中的共用變數
從一個程式員的角度來看,線程很有吸引力的一個方面就是多個線程很容易共用相同的程式變數。然而,這種共用也是很棘手的。為了編寫正確的線程化程式,我們必須對所謂的共用以及它是如何工作的有很清楚的瞭解。
為了理解變數是否是共用的,有一些基本的問題要解答:1)線程的基礎儲存空間模型是什嗎?2)根據這個模型,變數執行個體是如何映射到儲存空間的?3)最後,有多少線程引用這些執行個體?一個變數是共用的,若且唯若多個線程引用這個變數的某個執行個體。
線程儲存空間模型:
一組並發線程運行在一個進程的上下文中。 每個線程都有它自己獨自的線程上下文,包括線程ID、棧、棧指標、程式計數器、條件碼和通用目的寄存器值。每個線程和其他線程一起共用進程內容相關的剩餘部分。這包括整個使用者虛擬位址空間,它是由唯讀文本(代碼)、讀/寫資料、堆以及所有的共用庫代碼和資料區域組成的。線程也共用同樣的開啟檔案的集合。
從實際操作的角度來說,讓一個線程去讀或寫另一個線程的寄存器值時不可能的。另一方面,任何線程都可以訪問共用虛擬儲存空間的任意位置。如果某個線程修改了儲存空間的位置,那麼其他每個線程最終都能在它讀這個位置時發現這個變化。因此,寄存器是從來不共用的,而虛擬儲存空間總是共用的。
各自獨立的線程棧的儲存空間模型不是那麼整齊清楚的。這些棧被儲存在虛擬位址空間的棧地區中,並且通常是被相應的線程獨立地訪問的。我們說通常而不是總是,是因為不同的線程棧是不對其他線程設防的。所以,如果一個線程以某種方式得到一個指向其他線程棧的指標,那麼它就可以讀寫這個棧的任何部分。
將變數映射到儲存空間:
線程化的C程式中變數根據它們的儲存空間類型被映射到虛擬儲存空間:
- 全域變數。全域變數是定義在函數之外的變數。在運行時,虛擬儲存空間的讀/寫地區只包含每個全域變數的一個執行個體,任何線程都可以引用。
- 本地自動變數。本地自動變數就是定義在函數內部但是沒有static屬性的變數。在運行時,每個線程的棧都包含它自己的所有本地自動變數的執行個體。即使當多個線程執行同一個線程常式時也是如此。
- 本地靜態變數。本地靜態變數是定義在函數內部並有static屬性的變數。和全域變數一樣,虛擬儲存空間的讀/寫地區只包含在程式中聲明的每個本地靜態變數的一個執行個體。
共用變數
我們說一個變數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的線程都按照相同順序同時對它們加鎖,則程式是無死結的。
深入理解電腦系統結構——並發編程