目錄
- 進程與線程比較
- 多線程
- 同步與互斥
- 生產者與消費者
- 哲學家就餐問題
- 讀者寫者問題
- 進程間通信
- 管道
- 消息隊列
- 共享內存
- 信號量
- 信號
- Socket
- 鎖
- 互斥鎖與自旋鎖
- 讀寫鎖
- 樂觀鎖與悲觀鎖
- 死鎖
進程與線程比較
- 進程是資源(包括內存、打開的文件等)分配的單位,線程是 CPU 調度的單位;
- 進程擁有一個完整的資源平臺,而線程只獨享必不可少的資源,如寄存器和棧;
- 線程同樣具有就緒、阻塞、執行三種基本狀態,同樣具有狀態之間的轉換關系;
- 線程能減少并發執行的時間和空間開銷;
線程相比進程能減少開銷,體現在:
- 線程的創建時間比進程快,因為進程在創建的過程中,還需要資源管理信息,比如內存管理信息、文件管理信息,而線程在創建的過程中,不會涉及這些資源管理信息,而是共享它們;
- 線程的終止時間比進程快,因為線程釋放的資源相比進程少很多;
- 同一個進程內的線程切換比進程切換快,因為線程具有相同的地址空間(虛擬內存共享),這意味著同一個進程的線程都具有同一個頁表,那么在切換的時候不需要切換頁表。而對于進程之間的切換,切換的時候要把頁表給切換掉,而頁表的切換過程開銷是比較大的;
- 由于同一進程的各線程間共享內存和文件資源,那么在線程之間數據傳遞的時候,就不需要經過內核了,這就使得線程之間的數據交互效率更高了
多線程
同步與互斥
多個線程如果競爭共享資源,如果不采取有效的措施,則會造成共享數據的混亂。
由于多線程執行操作共享變量的這段代碼可能會導致競爭狀態,因此我們將此段代碼稱為臨界區(*critical section*),它是訪問共享資源的代碼片段,一定不能給多線程同時執行。
我們希望這段代碼是互斥(mutualexclusion)的,也就說保證一個線程在臨界區執行時,其他線程應該被阻止進入臨界區
所謂同步,就是并發進程/線程在一些關鍵點上可能需要互相等待與互通消息,這種相互制約的等待與互通信息稱為進程/線程同步。
- 鎖:加鎖、解鎖操作;
- 信號量:P、V 操作;
這兩個都可以方便地實現進程/線程互斥,而信號量比鎖的功能更強一些,它還可以方便地實現進程/線程同步。
鎖分為無等待鎖與自旋鎖:
當獲取不到鎖時,線程就會一直 wile 循環,不做任何事情,所以就被稱為「忙等待鎖」,也被稱為自旋鎖(*spin lock*)。
無等待鎖顧明思議就是獲取不到鎖的時候,不用自旋。
既然不想自旋,那當沒獲取到鎖的時候,就把當前線程放入到鎖的等待隊列,然后執行調度程序,把 CPU 讓給其他線程執行。
信號量表示資源的數量,對應的變量是一個整型(sem
)變量
- P 操作:將
sem
減1
,相減后,如果sem < 0
,則進程/線程進入阻塞等待,否則繼續,表明 P 操作可能會阻塞; - V 操作:將
sem
加1
,相加后,如果sem <= 0
,喚醒一個等待中的進程/線程,表明 V 操作不會阻塞;
對于兩個并發線程,互斥信號量的值僅取 1、0 和 -1 三個值,分別表示:
如果互斥信號量為 1,表示沒有線程進入臨界區;
如果互斥信號量為 0,表示有一個線程進入臨界區;
如果互斥信號量為 -1,表示一個線程進入臨界區,另一個線程等待進入。
通過互斥信號量的方式,就能保證臨界區任何時刻只有一個線程在執行,就達到了互斥的效果。
生產者與消費者
- 生產者在生成數據后,放在一個緩沖區中;
- 消費者從緩沖區取出數據處理;
- 任何時刻,只能有一個生產者或消費者可以訪問緩沖區;
- 任何時刻只能有一個線程操作緩沖區,說明操作緩沖區是臨界代碼,需要互斥;
- 緩沖區空時,消費者必須等待生產者生成數據;緩沖區滿時,生產者必須等待消費者取出數據。說明生產者和消費者需要同步。
我們需要三個信號量 :
互斥信號量 mutex
:用于互斥訪問緩沖區,初始化值為 1
資源信號量 fullBuffers
:用于消費者詢問緩沖區是否有數據,有數據則讀取數據,初始化值為 0(表明緩沖區一開始為空);
資源信號量 emptyBuffers
:用于生產者詢問緩沖區是否有空位,有空位則生成數據,初始化值為 n (緩沖區大小);
哲學家就餐問題
5
個老大哥哲學家,閑著沒事做,圍繞著一張圓桌吃面;- 巧就巧在,這個桌子只有
5
支叉子,每兩個哲學家之間放一支叉子; - 哲學家圍在一起先思考,思考中途餓了就會想進餐;
- 奇葩的是,這些哲學家要兩支叉子才愿意吃面,也就是需要拿到左右兩邊的叉子才進餐;
- 吃完后,會把兩支叉子放回原處,繼續思考;
那么問題來了,如何保證哲 學家們的動作有序進行,而不會出現有人永遠拿不到叉子呢?
讓偶數編號的哲學家「先拿左邊的叉子后拿右邊的叉子」,奇數編號的哲學家「先拿右邊的叉子后拿左邊的叉子」
上面的程序,在 P 操作時,根據哲學家的編號不同,拿起左右兩邊叉子的順序不同。另外,V 操作是不需要分支的,因為 V 操作是不會阻塞的。
讀者寫者問題
讀者只會讀取數據,不會修改數據,而寫者即可以讀也可以修改數據。
不談讀優先于寫優先鎖,直接談公平讀寫鎖。
公平策略 :
開始來了一些讀者讀數據,它們全部進入讀者隊列,此時來了一個寫者,執行 P(falg) 操作,使得后續到來的讀者都阻塞在 flag 上,不能進入讀者隊列,這會使得讀者隊列逐漸為空,即 rCount 減為 0。
這個寫者也不能立馬開始寫(因為此時讀者隊列不為空),會阻塞在信號量 wDataMutex 上,讀者隊列中的讀者全部讀取結束后,最后一個讀者進程執行 V(wDataMutex),喚醒剛才的寫者,寫者則繼續開始進行寫操作。
為此時讀者隊列不為空),會阻塞在信號量 wDataMutex 上,讀者隊列中的讀者全部讀取結束后,最后一個讀者進程執行 V(wDataMutex),喚醒剛才的寫者,寫者則繼續開始進行寫操作。
進程間通信
每個進程的用戶地址空間都是獨立的,一般而言是不能互相訪問的,但內核空間是每個進程都共享的,所以進程之間要通信必須通過內核。
管道
管道傳輸數據是單向的,如果想相互通信,我們需要創建兩個管道才行。
匿名管道,用完了就銷毀。
命名管道,也被叫做 FIFO
,因為數據是先進先出的傳輸方式
只有當管道里的數據被讀完后,命令才可以正常退出。
管道這種通信方式效率低,不適合進程間頻繁地交換數據。當然,它的好處,自然就是簡單,同時也我們很容易得知管道里的數據已經被另一個進程讀取了。
對于匿名管道,它的通信范圍是存在父子關系的進程。因為管道沒有實體,也就是沒有管道文件,只能通過 fork 來復制父進程 fd 文件描述符,來達到通信的目的。
對于命名管道,它可以在不相關的進程間也能相互通信。因為命令管道,提前創建了一個類型為管道的設備文件,在進程里只要使用這個設備文件,就可以相互通信。
消息隊列
A 進程要給 B 進程發送消息,A 進程把數據放在對應的消息隊列后就可以正常返回了,B 進程需要的時候再去讀取數據就可以了。
消息隊列是保存在內核中的消息鏈表,在發送數據時,會分成一個一個獨立的數據單元,也就是消息體(數據塊),消息體是用戶自定義的數據類型,消息的發送方和接收方要約定好消息體的數據類型,所以每個消息體都是固定大小的存儲塊,不像管道是無格式的字節流數據。如果進程從消息隊列中讀取了消息體,內核就會把這個消息體刪除。
消息隊列不適合比較大數據的傳輸,因為在內核中每個消息體都有一個最大長度的限制,同時所有隊列所包含的全部消息體的總長度也是有上限。
消息隊列通信過程中,存在用戶態與內核態之間的數據拷貝開銷,因為進程寫入數據到內核中的消息隊列時,會發生從用戶態拷貝數據到內核態的過程,同理另一進程讀取內核中的消息數據時,會發生從內核態拷貝數據到用戶態的過程。
共享內存
共享內存的機制,就是拿出一塊虛擬地址空間來,映射到相同的物理內存中。
這樣這個進程寫入的東西,另外一個進程馬上就能看到了,都不需要拷貝來拷貝去,傳來傳去,大大提高了進程間通信的速度。
信號量
了防止多進程競爭共享資源,而造成的數據錯亂,所以需要保護機制,使得共享的資源,在任意時刻只能被一個進程訪問。正好,信號量就實現了這一保護機制。
信號量其實是一個整型的計數器,主要用于實現進程間的互斥與同步,而不是用于緩存進程間通信的數據。
控制信號量的方式有兩種原子操作 :
一個是 P 操作,這個操作會把信號量減去 -1,相減后如果信號量 < 0,則表明資源已被占用,進程需阻塞等待;相減后如果信號量 >= 0,則表明還有資源可使用,進程可正常繼續執行。
另一個是 V 操作,這個操作會把信號量加上 1,相加后如果信號量 <= 0,則表明當前有阻塞中的進程,于是會將該進程喚醒運行;相加后如果信號量 > 0,則表明當前沒有阻塞中的進程;
P 操作是用在進入共享資源之前,V 操作是用在離開共享資源之后,這兩個操作是必須成對出現的 。
兩個進程互斥訪問共享內存,我們可以初始化信號量為 1
。
進程 A 在訪問共享內存前,先執行了 P 操作,由于信號量的初始值為 1,故在進程 A 執行 P 操作后信號量變為 0,表示共享資源可用,于是進程 A 就可以訪問共享內存。
若此時,進程 B 也想訪問共享內存,執行了 P 操作,結果信號量變為了 -1,這就意味著臨界資源已被占用,因此進程 B 被阻塞。
直到進程 A 訪問完共享內存,才會執行 V 操作,使得信號量恢復為 0,接著就會喚醒阻塞中的線程 B,使得進程 B 可以訪問共享內存,最后完成共享內存的訪問后,執行 V 操作,使信號量恢復到初始值 1。
信號初始化為 1
,就代表著是互斥信號量,它可以保證共享內存在任何時刻只有一個進程在訪問,這就很好的保護了共享內存。
進程 A 是負責生產數據,而進程 B 是負責讀取數據,這兩個進程是相互合作、相互依賴的,進程 A 必須先生產了數據,進程 B 才能讀取到數據,所以執行是有前后順序的。
信號量來實現多進程同步的方式,我們可以初始化信號量為 0
。
如果進程 B 比進程 A 先執行了,那么執行到 P 操作時,由于信號量初始值為 0,故信號量會變為 -1,表示進程 A 還沒生產數據,于是進程 B 就阻塞等待;
接著,當進程 A 生產完數據后,執行了 V 操作,就會使得信號量變為 0,于是就會喚醒阻塞在 P 操作的進程 B;
最后,進程 B 被喚醒后,意味著進程 A 已經生產了數據,于是進程 B 就可以正常讀取數據了。
信號
對于異常情況下的工作模式,就需要用「信號」的方式來通知進程。
信號是進程間通信機制中唯一的異步通信機制,因為可以在任何時候發送信號給某一進程,一旦有信號產生,我們就有下面這幾種,用戶進程對信號的處理方式。
用戶進程對信號的處理方式 :
1.執行默認操作。
2.捕捉信號。 為信號定義一個信號處理函數。當信號發生時,我們就執行相應的信號處理函數
3.忽略信號
Socket
跨網絡與不同主機上的進程之間通信,就需要 Socket 通信了。
鎖
互斥鎖與自旋鎖
加鎖的目的就是保證共享資源在任意時間里,只有一個線程訪問,這樣就可以避免多線程導致共享數據錯亂的問題。
-
互斥鎖加鎖失敗后,線程會釋放 CPU ,給其他線程;
-
自旋鎖加鎖失敗后,線程會忙等待,直到它拿到鎖;
互斥鎖是一種「獨占鎖」,比如當線程 A 加鎖成功后,此時互斥鎖已經被線程 A 獨占了,只要線程 A 沒有釋放手中的鎖,線程 B 加鎖就會失敗,于是就會釋放 CPU 讓給其他線程,既然線程 B 釋放掉了 CPU,自然線程 B 加鎖的代碼就會被阻塞。
互斥鎖加鎖失敗時,會從用戶態陷入到內核態,讓內核幫我們切換線程,雖然簡化了使用鎖的難度,但是存在一定的性能開銷成本。 會有兩次線程上下文切換的成本 。
如果你能確定被鎖住的代碼執行時間很短,就不應該用互斥鎖,而應該選用自旋鎖,否則使用互斥鎖。
自旋鎖 在「用戶態」完成加鎖和解鎖操作,不會主動產生線程上下文切換,所以相比互斥鎖來說,會快一些,開銷也小一些 。
使用自旋鎖的時候,當發生多線程競爭鎖的情況,加鎖失敗的線程會「忙等待」,直到它拿到鎖。
自旋鎖是最比較簡單的一種鎖,一直自旋,利用 CPU 周期,直到鎖可用。需要注意,在單核 CPU 上,需要搶占式的調度器(即不斷通過時鐘中斷一個線程,運行其他線程)。否則,自旋鎖在單 CPU 上無法使用,因為一個自旋的線程永遠不會放棄 CPU。
讀寫鎖
讀寫鎖適用于能明確區分讀操作和寫操作的場景。
讀寫鎖從字面意思我們也可以知道,它由「讀鎖」和「寫鎖」兩部分構成,如果只讀取共享資源用「讀鎖」加鎖,如果要修改共享資源則用「寫鎖」加鎖。
讀寫鎖的工作原理是:
當「寫鎖」沒有被線程持有時,多個線程能夠并發地持有讀鎖。
但是,一旦「寫鎖」被線程持有后,讀線程的獲取讀鎖的操作會被阻塞,而且其他寫線程的獲取寫鎖的操作也會被阻塞。
讀寫鎖在讀多寫少的場景,能發揮出優勢。
讀寫鎖可以分為「讀優先鎖」和「寫優先鎖」。
「讀優先鎖」
當讀線程 A 先持有了讀鎖,寫線程 B 在獲取寫鎖的時候,會被阻塞,并且在阻塞過程中,后續來的讀線程 C 仍然可以成功獲取讀鎖,最后直到讀線程 A 和 C 釋放讀鎖后,寫線程 B 才可以成功獲取寫鎖。
「寫優先鎖」
當讀線程 A 先持有了讀鎖,寫線程 B 在獲取寫鎖的時候,會被阻塞,并且在阻塞過程中,后續來的讀線程 C 獲取讀鎖時會失敗,于是讀線程 C 將被阻塞在獲取讀鎖的操作,這樣只要讀線程 A 釋放讀鎖后,寫線程 B 就可以成功獲取讀鎖。
「公平讀寫鎖」
公平讀寫鎖比較簡單的一種方式是:用隊列把獲取鎖的線程排隊,不管是寫線程還是讀線程都按照先進先出的原則加鎖即可,這樣讀線程仍然可以并發,也不會出現「饑餓」的現象。
樂觀鎖與悲觀鎖
互斥鎖、自旋鎖、讀寫鎖,都是屬于悲觀鎖 。
悲觀鎖做事比較悲觀,它認為多線程同時修改共享資源的概率比較高,于是很容易出現沖突,所以訪問共享資源前,先要上鎖。
那相反的,如果多線程同時修改共享資源的概率比較低,就可以采用樂觀鎖。
樂觀鎖做事比較樂觀,它假定沖突的概率很低,它的工作方式是:先修改完共享資源,再驗證這段時間內有沒有發生沖突,如果沒有其他線程在修改資源,那么操作完成,如果發現有其他線程已經修改過這個資源,就放棄本次操作。
樂觀鎖全程并沒有加鎖,所以它也叫無鎖編程。
只有在沖突概率非常低,且加鎖成本非常高的場景時,才考慮使用樂觀鎖。
死鎖
當線程A持有一個資源A,正在等待獲取資源B;而線程B持有資源B,卻在等待獲取資源A,死鎖就產生了。
產生條件:
- 互斥:多個線程對于需要的資源進行互斥的訪問(生產者和消費者競爭一個mutex鎖)
- 非搶占:線程在競爭到資源后,不能被搶占(消費者搶到mutex,迫使生產者等待)
- 持有并等待:線程在競爭中持有了資源,同時有在等待其他資源。(消費者獲得mutex鎖后,等待生產者的sem_post(¬_empty))
- 環路等待:線程之間存在一個環路,環路上每個線程都負責控制一個資源,但這個資源又是下一個線程所要申請的。
方法一 . 破壞互斥條件
如果我們能在兩個線程跑之前,能給每個線程單獨拷貝一份資源AB的副本,就能有效的避免死鎖了 。
方法二. 破壞環路等待條件
可以強制規定任何線程取資源的順序只能是 “先取A再取B”的話,就能避免死鎖了 .
方法三.破壞不剝奪條件
除非線程自己還資源,否則線程會一直占有資源,是形成不可剝奪條件的原因 。 可以通過設置一個”最長占用時間“的閾值來解決這個問題 。如果過了10分鐘仍然沒有進入下一個步驟,則歸還已有的資源。
方法四.破壞請求和保持條件
這里我們可以通過規定在任何情況下,一個線程獲取一個資源之后,必須歸還了資源之后才能請求另一個資源,就可以有效解決這個問題。