若是邏輯控制流在時間上重疊,那麼它們就是併發的。程序員
這種常見的現象稱爲併發。其實併發出如今計算機的不少層面上:硬件異常處理,進程和Linux信號處理程序都是;數據庫
可是這裏主要將併發看做是一種操做系統內核用來運行多個應用程序的機制。編程
可是併發並不侷限於內核,它能夠再應用程序中扮演重要的角色。瀏覽器
例如:看到Unix信號處理程序如何容許應用響應異步事件,例如用戶鍵入ctrl-c,或者程序訪問虛擬存儲器的一個未定義的區域。應用級併發有時候也頗有用。安全
1)訪問慢速I/O設備服務器
當一個應用程序正在等待來自慢速I/O設備的數據到達時,內核會運行其餘進程,使CPU保持繁忙。網絡
每一個應用均可以按照相似的方式,經過交替執行I/O請求和其餘有用的工做來使用併發。多線程
2)與人交互併發
和計算機交互的人要求計算機有同時執行多個任務的能力。異步
現代視窗系統利用併發來提供這種能力。每次用戶請求某種操做時,一個獨立的併發邏輯流被建立來執行這個操做。
3)經過推遲工做以下降延遲
有時,應用程序可以經過推遲其餘操做和併發地執行它們,利用併發來下降某些操做的延遲。
好比,一個動態存儲分配器能夠經過延遲合併,把它放到一個運行在較低優先級上的併發「合併」流中,在有空閒CPU週期時充分利用這些空閒週期,從而下降單個free操做的延遲。
4)服務多個網絡客戶端
建立一個併發服務器以替代迭代服務器,來使得服務器可以同時爲成千上萬個客戶端提供服務。
5)在多核機器上進行並行計算
多核處理器中包含多個CPU。被劃分紅併發流的應用程序一般在多核機器上比在單處理器機器上運行得快,由於這些流會被並行地執行,而不是交錯執行。
使用應用級併發的應用程序稱爲併發程序。
現代操做系統提供了三種基本的構造併發程序的方法:
進程:每一個邏輯控制流都是一個進程,由內核來調度和維護。由於進程有獨立的虛擬地址空間,想要和其餘流通訊,控制流必須使用某種顯式的進程間(IPC interprocess communication)通訊機制。
I/O多路複用:在這種形式的併發編程中,應用程序在一個進程的上下文中顯式地調度它們本身的邏輯流。邏輯流被模型化爲狀態機,數據到達文件描述符後,主程序顯式地從一個狀態轉換到另外一個狀態。由於程序是一個單獨的進程,因此全部的流都共享同一個地址空間。
線程:線程是運行在一個單一進程上下文中的邏輯流,由內核進行調度。能夠把線程看做是其餘兩種方式的混合體。像進程流同樣由內核進行調度,而像I/O多路複用流同樣共享一個虛擬地址空間。
====================================================
一、基於進程的併發編程
構造併發程序最簡單的方法就是用進程。
基於進程的併發服務器
基本方式就是父進程中接受客戶端的鏈接請求,而後建立一個新的子進程來爲每一個新客戶端提供服務。
關於進程的優劣
對於在父子進程間共享狀態信息,進程有個很是清晰的模型:共享文件表,可是不能共享用戶地址空間。
進程有獨立的地址空間既是優勢也是缺點:
優勢是進程不可能由於一不當心而覆蓋另外一個進程的虛擬存儲器,這消除了不少使人迷惑的錯誤。
缺點是使得共享狀態信息變得困難。爲了共享信息,它們必須使用顯式的IPC(進程間通訊)機制。
基於進程的設計的另外一個缺點是,它們每每比較慢,由於進程控制和IPC的開銷很高。
====================================================
二、基於I/O多路複用的併發編程
服務器必須響應兩個互相獨立的I/O事件。
1)網絡客戶端發起鏈接請求
2)用戶在鍵盤上鍵入命令行
咱們要優先等待哪一個事件呢?沒有哪一個選擇是理想的。
針對這種困境的解決方法是I/O多路複用技術。
基本思路就是使用select函數,要求內核掛起進程,只有在一個活多個I/O事件發生後,纔將控制返回給應用程序。
基於I/O多路複用的併發事件驅動服務器
I/O多路複用能夠用做併發事件驅動程序的基礎。
在事件驅動程序中,流是由於某種事件而前進的。
通常概念是將邏輯流模型化爲狀態機。
不嚴格地說,一個狀態機就是一組狀態、輸入事件、轉移;
轉移就是將狀態和輸入事件映射到狀態。
每一個轉移都將一個對(輸入狀態,輸入事件)映射到一個輸出狀態。
自循環是同一輸入和輸出狀態間的轉移。
一般把狀態機畫成有向圖,其中節點表示狀態,有向弧表示轉移,而弧上的標號表示輸入事件。
一個狀態機從某種初始狀態開始執行。
每一個輸入事件都會引起一個從當前狀態到下一個狀態的轉移。
I/O多路複用技術的優劣
事件驅動程序設計的一個優勢是:它比基於進程的設計給了程序員更多的對程序行爲的控制。
另外一個優勢:基於I/O多路複用的事件驅動服務器是運行在單一進程上下文中的。所以每一個邏輯流都能訪問該進程的所有地址空間,這使得流之間共享數據變得很容易。
事件驅動設計的一個明顯缺點是編碼複雜。隨着併發粒度減少,複雜性還會上升。
這裏的粒度是指每一個邏輯流每一個時間片執行的指令數量。
還有一個缺點是不能充分利用多核處理器。
====================================================
三、基於線程的併發編程
上面介紹了兩種建立併發邏輯流的方法。
接下來介紹第三種方法:基於線程。它是兩種方法的混合。
線程就是運行在進程上下文中的邏輯流。
現代操做系統容許咱們編寫一個進程裏同時運行多個線程的程序。
線程由內核調度。
每一個線程都有本身的線程上下文。
這個上下文包括:整數線程ID、棧、棧指針、程序計數器、通用目的寄存器和條件碼。
全部運行在一個進程裏的線程共享該進程的整個虛擬地址空間。
線程的執行模型
每一個進程開始生命週期時都是單一線程,這個線程成爲主線程。
在某個時刻,主線程建立一個對等線程(peer thread)。
從這個時刻開始,兩個線程併發地運行。
最後,由於主線程執行一個慢速系統調用,或者它被系統的間隔計時器中斷,控制就會經過上下文切換傳遞到對等線程。
對等線程會執行一段時間,而後控制傳遞迴主線程。以此類推。
線程與進程的不一樣之處
1)線程的上下文切換比進程的上下文切換要快得多,由於線程的上下文要比進程的上下文小得多;
2)線程不像進程那樣,不是嚴格按照父子層次來組織的。和一個進程相關的線程組成一個對等線程池(pool),獨立於其餘進程建立的線程。
3)主線程和其餘線程的區別僅在於它老是進程中第一個運行的線程。
4)對等線程池概念的主要影響是,一個線程能夠殺死它的任何對等線程,或者等待它的任意對等線程終止。
5)另外,每一個對等線程都能讀寫相同的共享數據。
Posix線程
Posix線程是在C程序中處理線程的一個標準接口。
Pthreads 定義大約60個函數,容許程序建立、殺死和回收線程,與對等線程安全地共享數據,還能夠通知對等線程系統狀態的變化。
建立線程:pthread_create
終止線程:pthread_exit
回收已終止線程的資源:pthread_join 等待其餘線程終止。
分離線程:pthread_detach 可結合的線程是可以被其餘線程回收其資源並殺死的,分離的線程是不能被其餘線程回收或殺死的。
初始化線程:pthread_once
====================================================
四、多線程程序中的共享變量
線程頗有吸引力的一個方面就是多個線程很容易共享相同的程序變量。
可是這種共享也是棘手的。咱們必須對所謂的共享以及它是如何工做的有很清楚的瞭解。
爲了理解C程序中的一個變量是不是共享的,有一些基本的問題要解答:
1)線程的基礎存儲器模型是什麼?
2)根據這個模型,變量實例是如何映射到存儲器的?
3)最後,有多少線程引用這些實例?一個變量是共享的,當且僅當多個線程引用這個變量的某個實例。
線程存儲器模型
一組併發線程運行在一個進程的上下文中。
每一個線程都有它本身獨立的線程上下文,包括線程ID、棧、棧指針、程序計數器、條件碼、通用目的寄存器值。
每一個線程和其餘線程一塊兒共享進程上下文的剩餘部分。
這個剩餘部分包括整個用戶虛擬地址空間,它是由只讀文本(代碼)、讀/寫數據、堆以及全部的共享庫代碼和數據區域組成的。
線程也共享一樣的打開文件的集合。
寄存器是從不共享的,而虛擬存儲器老是共享的。
將變量映射到存儲器
線程化的C程序中變量根據它們的存儲類型被映射到虛擬存儲器:
全局變量
全局變量是定義在函數以外的變量。
在運行時,虛擬存儲器的讀/寫區域只包含每一個全局變量的一個實例,任何線程均可以引用。
本地自助變量
本地自動變量就是定義在函數內部可是沒有static屬性的變量。
在運行時,每一個線程的棧都包含它本身的全部本地自動變量的實例。
本地靜態變量
定義在函數內部並有static屬性的變量。
和全局變量同樣,虛擬存儲器的讀/寫去也只包含在程序中聲明的每一個本地靜態變量的一個實例。
共享變量
咱們說一個變量是共享的,是指當且僅當它的一個實例被一個以上的線程引用時。
====================================================
五、用信號量同步線程
共享變量的使用是十分方便的,可是它們也引入了同步錯誤的可能性。
進度圖將n個併發線程的執行模型化爲一條n維笛卡爾空間中的軌跡線。
咱們但願確保每一個線程在執行它的臨界區中的指令時,擁有對共享變量的互斥訪問。一般這種現象稱爲互斥。
兩個臨界區的交集造成的狀態空間稱爲不安全區。
有些軌跡若是穿越了不安全區,那麼這種行爲就是不安全的。
信號量
信號量是一種特殊類型的變量,用於解決同步不一樣執行線程問題。
信號量s是具備非負整數值的全局變量,只能由兩種特殊的操做來處理,這兩種操做稱爲P和V:
P,若是s是非零,則P將s減1;
V,將s加1
P和V確保了一個正在運行的程序毫不可能進入這樣一種狀態,也就是一個正確初始化的信號量有一個負值。
這個屬性稱爲信號不變性,爲控制併發程序的軌跡線提供了強有力的工具。
Posix標準定義了許多操做信號量的函數。
使用信號量來實現互斥:
信號量是一種特殊的共享變量。
它提供了一種很方便的方法來確保對共享變量的互斥訪問。
基本思想是將每一個共享變量與一個信號量聯繫起來。
而後用P和V操做將相應的臨界區包圍起來。
以這種方式來保護共享變量的信號量叫作二元信號量,由於它的值老是0或1。
以提供互斥爲目的的二元信號量經常也稱爲互斥鎖。
在一個互斥鎖上執行P操做稱爲對互斥鎖加鎖。相似地,執行V操做稱爲對互斥鎖解鎖。
對互斥鎖加鎖,可是沒有對互斥鎖解鎖的線程稱爲佔用這個互斥鎖。
一個被用做一組可用資源的計數器的信號量稱爲計數信號量。
P和V操做建立的禁止區使得在任什麼時候間點上,在被包圍的臨界區中,不可能有多個線程在執行指令。
換句話說,信號量操做確保了對臨界區的互斥訪問。就像不可能同時有兩我的在上一個茅坑同樣。
最終的目的,不管是在單處理器仍是多處理器上運行程序,都要同步你對共享變量的訪問。
利用信號量來調度共享資源:
除了提供互斥以外,信號量還有一個重要的做用是,調度對共享資源的訪問。
在這種場景中,一個線程用信號量來通知另外一個線程,程序狀態中的某個條件已經成真了。
如下有兩個經典而有用的例子:
一、生產者-消費者問題
生產者和消費者線程共享一個有n個槽的有限緩衝區。
生產者線程反覆地生成新的項目(item),並把它們插入到緩衝區中。
消費者線程不斷地從緩衝區中取出這些項目,而後消費它們。
由於插入和取出項目都涉及更新共享變量,因此咱們必須保證對緩衝區的訪問的都是互斥的。
可是僅僅保證互斥仍是不夠的,咱們還須要調度對緩衝區的訪問。
若是緩衝區是滿的(沒有空的槽位),那麼生產者必須等待到有空的槽位可用爲止。
若是緩衝區是空的(沒有可取用的槽位),那麼消費者必須等待直到有一個項目變爲可用爲止。
二、讀者-寫者問題
讀者-寫者問題是互斥問題的一個歸納。
一組併發地線程要訪問一個共享對象。有些線程只讀對象,有些線程只修改對象。
修改對象的線程叫作寫者。
只讀對象的線程叫作讀者。
寫者必須擁有對對象的獨佔的訪問,而讀者能夠和無限多個其餘的讀者共享對象。
讀者-寫者問題有幾個變種:
1)讀者優先,要求不要讓讀者等待,除非已經把使用對象的權限賦予了一個寫者。換句話說,讀者不會由於有一個寫者在等待而等待。
2)寫者優先,要求一旦一個寫者準備好能夠寫,它就會盡量地完成它的寫操做。在一個寫者後到達的讀者必須等待。
第一個變種會引起一個問題就是飢餓。若是有讀者不斷地到達,寫者就可能無限期地等待。
====================================================
六、使用線程提升並行性
對於並行性的利用愈來愈重要。
許多現代處理器都有多核,併發程序一般在這樣的機器上運行得更快。
Web服務器、瀏覽器,數據庫服務器等應用中,並行性也變得愈來愈有用。
全部程序的集合均可以劃分紅不相交的順序程序的集合和併發程序的集合。
寫順序程序只有一條邏輯流。
寫併發程序有多條併發流。
並行程序是一個運行在多個處理器上的併發程序。
所以,並行程序的集合是併發程序集合的真子集。
並行程序有一些衡量程序性能的指標。稱爲加速比和效率。
效率是對並行化形成的開銷的衡量。高效率的程序在同步和通訊上花費的時間更短。
強擴展 弱擴展;
====================================================
七、其餘併發問題
線程安全
定義出四類線程不安全的函數(不相交):
1)不保護共享變量的函數
2)保持跨越多個調用的狀態的函數
僞隨機數是這類線程不安全函數的簡單例子;
3)返回指向靜態變量的指針的函數
4) 調用線程不安全函數的函數
可重入性
可重入函數是線程安全函數的真子集。
可重入函數的特色是不會引用任何共享數據。
在線程化的程序中使用已存在的庫函數
Unix系統提供大多數線程不安全函數的可重入版本。
可重入版本的名字老是以「_r」後綴結尾的。
競爭
死鎖
指的是一組進程被阻塞了,它們在等待一個永遠不會爲真的條件。
解決方法是:添加互斥鎖加鎖順序。按照這個順序來求鎖,並按照這個順序來解鎖。
====================================================
八、小結
一個併發程序是由在時間上重疊的一組邏輯流組成的。
三種不一樣的構建併發程序的機制:進程、I/O多路複用和線程;
進程是由內核自動調度的,並且由於它們有各自獨立的虛擬地址空間,因此要實現共享數據,必需要有顯式的IPC機制。
事件驅動程序建立它們本身的併發邏輯流,這些邏輯流被模型化爲狀態機,用I/O多路複用來顯式地調度這些流。由於程序運行在單一的進程中,因此在流之間共享數據速度很快並且很容易。
線程是這兩種方法的綜合。
線程也是由內核自動調度的。可是線程是運行在一個單一進程的上下文中,所以能夠快速而方便地共享數據。
不管哪一種併發機制,同步對共享數據的併發訪問都是一個困難的問題。
信號量P和V操做能夠用來提供對共享數據的互斥訪問;
生產者-消費者程序中的有限緩衝區或者讀者-寫者系統中的共享對象這樣的資源訪問進行調度。
併發也引入一些困難的問題。
被線程調用的函數必須具備一個稱爲線程安全的屬性。
咱們定義了四類線程不安全的函數,以及一些將它們變爲線程安全的建議。
可重入函數是線程安全的真子集,它不訪問任何共享數據。
可重入函數一般比不可重入函數更加有效,由於它不調用任何同步原語。
競爭和死鎖是併發程序中出現的另外一些困難的問題。
當程序員錯誤地假設邏輯流該如何調度時,就會發生競爭。
當一個流等待永遠不會發生的事件時,就會發生死鎖。