1. 程式人生 > >併發技術、程序、執行緒和鎖拾遺

併發技術、程序、執行緒和鎖拾遺

併發技術、程序、執行緒和鎖拾遺

Part1. 多工

計算機發展起初,CPU 資源十分昂貴,如果讓 CPU 只能執行一個程式那麼當 CPU 空閒下來(例如等待 I/O 時),CPU 資源就會被浪費,為了使 CPU 資源得到更好的利用,先驅編寫了一個監控程式,如果發現某個程式暫時無需使用 CPU 時,監控程式就把另外的正在等待 CPU 資源的程式啟動起來,以充分利用 CPU資源。這種方法稱為 - 多道程式(Multiprogramming)

對於多道程式,最大的弊端是各程式之間不區分輕重緩急,對於使用者互動式的程式來說,對 CPU 計算時間的需求並不多,但是對於響應速度卻有比較高的要求。而對於計算類程式來說則相反,對響應速度要求低,但需要長時間的 CPU 計算。想象一個場景:我在同時在瀏覽網頁和聽音樂,我們希望瀏覽器能夠快速響應,同時也希望音樂不停,這時候多道程式就沒法達到我們的要求了。

於是人們改進了多道程式,使得每個程式執行一段時間之後,都主動讓出 CPU 資源,這樣每個程式在一段時間內都有機會執行一小段時間。這樣像瀏覽器這樣的互動式程式就能夠快速地被處理,同時計算類程式也不會受到很大影響。這種程式協作方式被稱為 分時系統(Time-Sharing System)。

在分時系統的幫助下,我們可以邊用瀏覽器邊聽歌了。但是如果某個程式出現了錯誤,導致了死迴圈,不僅僅是這個程式會出錯,整個系統都會宕機,為了避免這種情況,一個更為先進的作業系統模式被髮明處理,也就是我們現在熟悉的多工系統(Multi-tasking System)。

作業系統從最底層接管了所有硬體資源。所有的應用程式在作業系統上以 程序(Process) 的方式執行,每個程序都有自己獨立的地址空間,相互隔離。CPU 由作業系統統一統一進行分配。每個程序都有機會得到 CPU,同時在作業系統控制下,如果一個程序執行超過了一定時間,就會被暫停掉,失去 CPU 資源。這樣就避免了一個程式的錯誤導致整個系統宕機。如果作業系統分配給各個程序的執行時間都很短,CPU 可以在多個程序間快速切斷,就像很多程序都同時在執行的樣子。幾乎所有現代作業系統都是採用這樣的方式支援多工。

Part2. 程序

程序(Process)是計算機中的程式關於某資料集合上的一次執行活動,是系統進行資源分配和排程的基本單位,是作業系統結構的基礎。它可以申請和擁有系統資源,是一個活動的實體。它不只是程式的程式碼,還包括當前的活動,通過程式計數器的值和處理遞存器的內容來表示。

  • 程序是一個實體,每一個程序都有它自己的地址空間,一般情況下,包括文字區域(text region)、資料區域(data region)和堆疊(stack region)。文字區域儲存器執行的程式碼;資料區域儲存變數和程序執行期間使用的動態分配的記憶體;堆疊區域儲存著活動過程呼叫的指令和本地變數。
  • 程序是一個“執行中的程式”。程式是一個沒有生命的實體,只有處理器賦予程式生命時,它才能成為一個活動的實體,我們稱其為程序。

1. 程序的基本狀態

  1. 等待態:等待某個事件的完成;
  2. 就緒態:等待系統分配處理器以便執行;
  3. 執行態:佔有處理器正在執行;

幾種狀態的切換:

  • 執行態 -> 等待態:往往是由於等待外設,等待主存等資源分配或等待人工干預而引起的。
  • 等待態 -> 就緒態:則是等待的條件已滿足,只需分配到處理器後就能執行。
  • 執行態 -> 就緒態:不是由於自身原因,而是由外界原因使執行狀態的程序讓出處理器,這時就變成就緒態。例如:時間片用完,或有更高優先順序的程序來搶佔處理器等
  • 就緒態 -> 執行態:系統按某種策略選中就緒佇列中的一個程序佔用處理器,此時就變成了執行態。

2. 程序排程

排程種類

高階、中級和低階排程作業從提交開始直到完成,往往要經歷下述三級排程:

  • 高階排程(High-Level Scheduling):又稱為作業排程,它決定把後備作業調入記憶體執行;
  • 中級排程(Intermediate-Level Scheduling):又稱為在虛擬儲存器中引入,在內、外存對換區進行程序對換。
  • 低階排程(Low-Level Scheduling):又稱為程序排程,它決定把就緒佇列的某程序獲得 CPU。

非搶佔式排程與搶佔式排程

  • 非搶佔式
    分派程式一旦把處理機分配給某程序後便讓它一直執行下去,直到程序完成或者發生程序排程某事件而阻塞時,才把處理機分配給另一個程序。
  • 搶佔式
    作業系統將正在執行的程序強行暫停,由排程程式將 CPU 分配給其他就緒程序的排程方式。

排程策略的設計

  • 響應時間:從使用者輸入到產生反應的時間
  • 週轉時間:從任務開始到任務結束的時間

CPU 任務可以分為互動式任務和批處理任務,排程最終的目標是合理的使用 CPU,使得互動式任務的響應時間儘可能短,使用者不至於感到延遲,同時使得批處理任務的週轉時間儘可能短,減少使用者等待的時間。

排程演算法

  1. FIFO 或 First Come,First Served(FCFS)
    排程的順序就是任務到達就緒佇列的順序。
    公平、簡單(FIFO 佇列)、非搶佔、不適合互動式。未考慮任務特性,平均等待時間可以縮短。

  2. Shortest Job First(SJF)
    最短的作業(CPU 區間長度最小)優先排程
    可以證明,SJF 可以保證最小的平均等待時間
    Shortest Job First (SRJF): SJF 的可搶佔版本,比 SJF 更有優勢
    SJF、SRJF 如何知道下一 CPU 區間大小?根據歷史進行預測:指數平均法。

  3. 優先權排程
    每個任務關聯一個優先權、排程優先權最高的任務。
    注意:優先權太低的任務一直就緒,得不到執行,出現“飢餓”現象。
    FCFS 是 RR 的特例,SJF 是優先權排程的特例,這些排程演算法都不適合於互動式系統。

  4. Round-Robin(RR)
    設定一個時間片,按時間片來輪轉排程(“輪叫”演算法)
    優點:定時有響應,等待時間較短;缺點:上下文切換次數較多;
    如何確定時間片?時間片太大,響應時間太長;吞吐量變小,週轉時間變長;當時間片過長時,退化為 FCFS。

  5. 多級佇列排程
    按照一定的規則建立多個程序佇列
    不同的佇列有固定的優先順序(高優先順序有搶佔權)
    不同的佇列可以給不同的時間片和採用不同的排程方法
    存在問題 1:沒法區分 I/O bound 和 CPU bound;
    存在問題 2:也存在一定程度的“飢餓”現象

  6. 多級反饋佇列
    在多級佇列的基礎上,任務可以在佇列之間移動,更細緻的區分任務。
    可以根據“享用”CPU 時間多少來移動佇列,阻止“飢餓”。
    最通用的排程演算法,多數 OS 都使用該方法或其變形,如 UNIX、Windows 等。

3. 程序同步

臨界資源與臨界區

在作業系統中,程序是佔有資源的最小單位(執行緒可以訪問其所在程序內的所有資源,但執行緒本身並不佔有資源或僅僅佔有一點必須資源)。但對於某些資源來說,其在同一時間只能被一個程序所佔用。這些一次只能被一個程序所佔用的資源就是所謂的臨界資源。
典型的臨界資源比如物理上的印表機,或是存在硬碟或記憶體中被多個程序所共享的一些變數和資料等(如果這類資源不被看成臨界資源加以保護,那麼很有可能造成丟資料的問題)。
對於臨界資源的訪問,必須是互斥進行。也就是當臨界資源被佔用時,另一個申請臨界資源的程序會被阻塞,直到其所申請的臨界資源被釋放。而程序內訪問臨界資源的程式碼被稱為臨界區。

解決臨界區問題可能的方法:

  1. 一般軟體方法
  2. 關中斷方法
  3. 硬體原子指令方法
  4. 訊號量方法

訊號量

訊號量是一個確定的二元組(s,q),其中 s 是一個具有非負初值的整型變數,q 是一個初始狀態為空的佇列,整型變數 s 表示系統中某類資源的數目:

  • 當 s ≥ 0 時,表示系統中當前可用資源的數目
  • 當 s < 0 時,其絕對值表示系統中因請求該類資源而被阻塞的程序數目

除訊號量的初值外,訊號量的值僅能由 P 操作和 V 操作更改,作業系統利用它的狀態對程序和資源進行管理。

P 操作:P 操作記為 P(s),其中 s 為一訊號量,它執行時主要完成以下動作:

// 可理解為佔用一個資源,若原來就沒有則記賬“欠”1 個
s.value = s.value - 1;

s.value ≥ 0,則程序繼續執行,否則(即s.value < 0),則程序被阻塞,並將該程序插入到訊號量 s 的等待佇列 s.queue 中。
實際上,P 操作可以理解為分配資源的計數器,或是使程序處於等待狀態的控制指令

V 操作:V 操作記為 V(s),其中 s 為一訊號量,它執行時,主要完成以下動作:

// 可理解為歸還一個資源,若原來就沒有則意義是用此資源還 1 個欠賬
s.value = s.value + 1;

s.value > 0,則程序繼續執行,否則(即 s.value ≤ 0),則從訊號量 s 的等待佇列 s.queue 中移出第一個程序,使其變為就緒狀態,然後返回原程序繼續執行。
實際上,V 操作可以理解為歸還資源的計數器,或是喚醒程序使其處於就緒狀態的控制指令

訊號量方法實現:生產者 - 消費者互斥與同步控制

semaphore fullBuffers = 0;//倉庫中已填滿的貨架個數
semaphore emptyBuffers = BUFFER_SIZE;//倉庫貨架空閒個數
semaphore mutex = 1;//生產 - 消費互斥訊號

Producer() 
{ 
    while(True)
    {  
       /*生產產品item*/
       emptyBuffers.P(); 
       mutex.P(); 
       /*item存入倉庫buffer*/
       mutex.V();
       fullBuffers.V();
    }
}
 
Consumer() 
{
    while(True)
    {
        fullBuffers.P(); 
        mutex.P();  
        /*從倉庫buffer中取產品item*/
        mutex.V();
        emptyBuffers.V();
        /*消費產品item*/
    }
}

死鎖

死鎖:多個程序因迴圈等待而造成的無法執行的現象
死鎖會造成程序無法執行,同時會造成系統資源的極大浪費(資源無法釋放)。
死鎖產生的 4 個必要條件:

  • 互斥使用(Mutual exclusion)
    • 指程序對所有分配到的資源進行排他性使用,即在一段時間內某資源只由一個程序佔用。如果此時還有其他程序請求資源,則請求者只能等待,直至佔有資源的程序用畢釋放。
  • 不可搶佔(No preemption)
    • 指程序已獲得的資源,在未使用完之前,不能被剝奪,只能在使用完時由自己釋放。
  • 請求和保持(Hold and wait)
    • 指程序已經保持至少一個資源,但又提出了新的資源請求,而該資源已被其他程序佔有,此時請求程序阻塞,但又對自己已獲得其他資源保持不放。
  • 迴圈等待(Circular wait)
    • 指在發生死鎖時,必然存在一個程序-資源的環形鏈,即程序集合{P0, P1, P2, P3, P4, ..., Pn} 中的 P0 正在等待一個 P1 佔用的資源;P1 正在等待 P2 佔用的資源,...,Pn 正在等待已被 P0 佔用的資源。

死鎖的避免:銀行家演算法
思想:判斷此次請求是否造成死鎖,若會造成死鎖,則拒絕該請求。

4. 程序間通訊

本地程序間通訊的方式有很多,可以總結為下面四類:

  • 訊息傳遞(管道、FIFO、訊息佇列)
  • 同步(互斥量、條件變數、讀寫鎖、檔案和寫記錄鎖、訊號量)
  • 共享記憶體(匿名的和具名的)
  • 遠端過程呼叫(Solaris門 和 Sun RPC)

Part3. 執行緒

多執行緒解決了前面提到的多工問題。然而很多時候不同的程式需要共享同樣的資源(檔案,訊號量等),如果全都使用程序的話會導致切換的成本很高,造成 CPU 資源的浪費。於是出現了執行緒的概念。
執行緒,有時被稱為輕量級程序(Lightweight Process,LWP),是程式執行流的最小單元。一個標準的執行緒由執行緒 ID,當前指令指標(PC),暫存器集合和堆疊組成。
執行緒具有以下屬性:

  1. 輕型實體
    執行緒中的實體基本上不擁有系統資源,只是有一點必不可少的、能保證獨立執行的資源。執行緒的實體包括:程式、資料和 TCB(Thread Control Block)。執行緒是動態概念,它的動態特性由執行緒控制塊 TCB 描述。

  2. 獨立排程和分派的基本單位
    在多執行緒 OS 中,執行緒是能獨立執行的基本單位,因而也是獨立排程和分派的基本的單位。由於執行緒很“輕”,故執行緒的切換非常迅速且開銷小(在同一程序中的)

  3. 可併發執行
    在一個程序中的多個執行緒之間,可以併發執行,甚至允許在一個程序中所有執行緒都能併發執行;

  4. 共享程序資源
    在同一程序中的各個執行緒,都可以共享該程序所擁有的資源,這首先表現在:所有執行緒都具有相同的地址空間(程序的地址空間),這意味著,執行緒可以訪問改地址空間的每一個虛擬地址;此外,還可以訪問程序所擁有的已開啟檔案、定時器、訊號量等。由於同一個程序內的執行緒共享記憶體和檔案,所以執行緒之間互相通訊不必呼叫核心。
    執行緒共享的環境包括:程序程式碼段、程序的公有資料(利用這些共享的資料,執行緒很容易的實現相互之前的通訊)、程序開啟的檔案描述符、訊號的處理器、程序的當前目錄和程序使用者 ID 與程序組 ID。

Part4. 鎖

鎖要解決的是執行緒之間爭奪資源的問題:

  • 資源是否是獨佔(獨佔鎖 - 共享鎖)
  • 搶佔不到資源怎麼辦(互斥鎖 - 自旋鎖)
  • 自己能不能重複搶(重入鎖 - 不可重入鎖)
  • 競爭讀的情況比較多,讀可不可以不加鎖(讀寫鎖)

上面幾個角度並非相互獨立,在實際場景中需要將他們集合起來才能構造出一個合適的鎖。

獨佔鎖 - 共享鎖

當一個共享資源只有一份的時候,通常我們使用獨佔鎖,常見的即各個語言中的 Mutex。當共享資源有多份時,可以使用訊號量(Semaphere)。

互斥鎖 - 自旋鎖

對於互斥鎖來說,如果一個執行緒已經鎖定了一個互斥鎖,第二個執行緒又試圖去獲取這個互斥鎖,則第二個執行緒將會被掛起(即休眠、不佔用 CPU 資源)。

在計算機系統中,頻繁的掛起和切換執行緒,也是有成本的。自旋鎖就是解決這個問題的。

自旋鎖:指當一個執行緒在獲取鎖的時候,如果鎖已經被其他執行緒獲取,那麼該執行緒將迴圈等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。

容易看出,當資源等待的時間較長,用互斥鎖讓執行緒休眠,會消耗更少的資源,當資源等待的時間較短時,使用自旋鎖將減少執行緒的切換,獲得更高的效能。

Java 中的 synchornized 和 .NET 中的 lockMonitor)的實現,是結合了兩種鎖的特點。簡單說,它們在發現資源被搶佔之後,會先試著自旋等待一段時間,如果等待時間太長,則會進入掛起狀態。通過這樣的實現,可以較大程度上挖掘出鎖的效能。

重入鎖 - 不可重入鎖

可重入鎖(ReetrantLock),也叫作遞迴鎖,指的是同一執行緒內,外層函式獲得鎖之後,內層遞迴函式仍然可以獲取到該鎖。
換而言之:同一執行緒再次進入同步程式碼時,可以使用自己已獲取到的鎖。

使用可重入鎖時,在同一執行緒中多次獲取鎖,不會導致死鎖。使用不可重入鎖,則會導致死鎖發生。

Java 中的 synchornized 和 .NET 中的 lockMonitor) 都是可重入的。

讀寫鎖

有些情況下,對於共享資源讀競爭的情況遠遠多於寫競爭,這種情況下,對讀操作每次都進行加鎖,是得不償失的。讀寫鎖就是為了解決這個問題。

讀寫鎖允許同一時刻被多個讀執行緒訪問,但是在寫執行緒訪問時,所有讀執行緒和其他的寫執行緒都會被阻塞。簡單可以總結為,讀讀不互斥,讀寫互斥,謝謝互斥。

對讀寫鎖來說,有一個升級和降級的概念,即當前獲得了讀鎖,想把當前的鎖變成寫鎖,成為升級,反之稱為降級。鎖的升降級本身也是為了提升效能,通過改變當前鎖的性質,避免重複獲取鎖。

Part.5 協程

協程,又稱為微執行緒,纖程。英文名: Coroutine
協程可以理解為使用者級執行緒,協程和執行緒的區別是:執行緒是搶佔式的排程,而協程是協同式的排程,協程避免了無意義的排程,由此可以提高效能,但也因此,程式設計師必須自己承擔排程的責任,同時,協程也失去了標準執行緒使用多 CPU 的能力。

Part.6 IO多路複用

基本概念

IO 多路複用是指核心一旦發現程序指定的一個或者多個 IO 條件準備讀取,他就通知該程序。IO 多路複用適用於如下場景:

  1. 當客戶處理多個描述字時(一般是互動式輸入和網路套介面),必須使用 I/O 複用。
  2. 當一個客戶同時處理多個套介面時,而這種情況是可能的,但很少出現。
  3. 如果一個 TCP 伺服器既要處理監聽套介面,又要處理已連線套介面,一般也要用到 I/O 複用。
  4. 如果一個度武器既要處理 TCP,又要處理 UDP,一般要使用 I/O 複用。
  5. 如果一個伺服器要處理多個服務或多個協議,一般要使用 I/O 複用。

與多程序和多執行緒技術相比,I/O 多路複用技術的最大優勢是系統開銷小,系統不必建立 程序/執行緒,也不必維護這些 程序/執行緒,從而大大減小了系統的開銷。

常見的 IO 複用實現

  • select (Linux/Windows/BSD)
  • epoll (Linux)
  • kqueue (BSD/Mac OS)

更多幹貨文章

部落格:www.qiuxuewei.com
微信公眾號:@開發者成長之路

一個沒有雞湯只有乾貨的公眾號
*************************************************