後台開發:核心技術與應用實踐–執行緒與進程間通訊

多執行緒

進程在多數早期多任務作業系統中是執行工作的基本單元。進程是包含程式指令和相關資源的集合,每個進程和其他進程一起參與調度,競爭 CPU 、記憶體等系統資源。每次進程切換,都存在進程資源的保存和恢復動作,這稱為上下文切換。進程的引入可以解決多用戶支援的問題,但是多進程系統也在如下方面產生了新的問題:進程頻繁切換引起的額外開銷可能會嚴重影響系統性能。

同一個進程內部的多個執行緒,共享的是同一個進程的所有資源。比如,與每個進程獨有自己的記憶體空間不同,同屬一個進程的多個執行緒共享該進程的記憶體空間。通過執行緒可以支援同一個應用程式內部的並發,免去了進程頻繁切換的開銷,另外並發任務間通訊也更簡單。執行緒的切換是輕量級的,所以可以保證足夠快。每當有事件發生狀態改變,都能有執行緒及時響應,而且每次執行緒內部處理的計算強度和複雜度都不大。

一個棧中只有最下方的幀可被讀寫,相應的,也只有該幀對應的那個函數被激活,處於工作狀態。為了實現多執行緒,則必須繞開棧的限制。為此,在創建一個新的執行緒時,需要為這個執行緒建一個新的棧,每個棧對應一個執行緒,當某個棧執行到全部彈出時,對應執行緒完成任務,並結束。所以,多執行緒的進程在記憶體中有多個棧,多個棧之間以一定的空白區域隔開,以備棧的增長。每個執行緒可調用自己棧最下方的幀中的參數和變數,並與其他執行緒共享記憶體中的 Text、heap和global data 區域。要注意的是,對於多執行緒來說,由於同一個進程空間中存在多個棧,任何一個空白區域被填滿都會導致棧溢出

在並發情況下,指令執行的先後順序由內核決定。同一個執行緒內部,指令按照先後順序執行,但不同執行緒之間的指令很難說清楚哪一個會先執行,如果運行的結果依賴於不同執行緒執行的先後的話,那麼就會造成競爭條件,在這樣的狀況下,電腦的結果很難預知,所以
應該盡量避免競爭條件的形成。最常見的解決競爭條件的方法是將原先分離的兩個指令構成不可分割的一個原子操作,而其他任務不能插入到原子操作中。

對於多執行緒程式來說,同步是指在一定的時間內只允許某一個執行緒訪問某個資源。而在此時間內,不允許其他的執行緒訪問該資源,可以通過互斥鎖(mutex) 、條件變數(condition variable)、讀寫鎖(reader-writer lock)和訊號量(emphore)來同步資源。

  1. 互斥鎖

互斥鎖是一個特殊的變數,它有鎖上(lock)和打開(unlock)兩個狀態。互斥鎖一般被設置成全局變數,打開的互斥鎖可以由某個執行緒獲得。一旦獲得,這個互斥鎖會鎖上,此後只有該執行緒有權打開,其他想要獲得互斥鎖的執行緒, 會等待直到互斥鎖再次打開的時候。我們可以將互斥鎖想像成一個只能容納一個人的洗手間, 當某個人進入洗手間的時候,可以從裡面將洗手間鎖上,其他人只能在互斥鎖外面等待那個人出來,才能進去。但在外面等候的人並沒有排隊,誰先看到洗手間了,就可以首先衝進去。

  1. 條件變數

互斥量是執行緒程式必需的工具,但並非是萬能的。例如,如果執行緒正在等待共享數據內某個條件出現,那會發生什麼呢?它可能重複對互斥對象鎖定和解鎖,每次都會檢查共享數據結構,以查找某個值。但這是在浪費時間和資源,而且這種繁忙查詢的效率非常低。
在每次檢查之間,可以讓調用執行緒短暫地進入睡眠,比如睡眠3秒,但是由此執行緒程式碼就無法最快作出響應。真正需要的是這樣一種方法,當執行緒在等待滿足某些條件時使執行緒進入睡眠狀態,一旦條件滿足,就喚醒因等待滿足特定條件而睡眠的執行緒。如果能夠做到這一點,執行緒程式碼將是非常高效的,並且不會佔用寶貴的互斥對象鎖。而這正是條件變數能做的事!
條件變數通過允許執行緒阻塞和等待另一個執行緒發送訊號的方法彌補互斥鎖的不足,它常和互斥鎖一起使用。使用時,條件變數被用來阻塞一個執行緒,當條件不滿足時,執行緒往往解開相應的互斥鎖並等待條件發生變化。一旦其他的某個執行緒改變了條件變數,它將通知相應的條件變數喚醒一個或多個正被此條件變數阻塞的執行緒,這些執行緒將重新鎖定互斥鎖並重新測試條件是否滿足。
條件變數特別適用於多個執行緒等待某個條件的發生。如果不使用條件變數,那麼每個執行緒就需要不斷獲得互斥鎖並檢查條件是否發生,這樣大大浪費了系統的資源。

  1. 讀寫鎖

對某些資源的訪問會存在兩種可能的情況,一種是訪問必須是排他性的,就是獨佔的意思,這稱作寫操作;另一種情況就是訪問方式可以是共享的,就是說可以有多個執行緒同時去訪問某個資源,這種就稱作讀操作。可以有多個執行緒同時佔用讀模式的讀寫鎖,但是只能有一個執行緒佔用寫模式的讀寫鎖,讀寫鎖的3種狀態如下所述。

  1. 當讀寫鎖是寫加鎖狀態時,在這個鎖被解鎖之前,所有試圖對這個鎖加鎖的執行緒都會被阻塞
  2. 當讀寫鎖在讀加鎖狀態時,所有試圖以讀模式對它進行加鎖的執行緒都可以得到訪問權,但是以寫模式對它進行加鎖的執行緒將會被阻塞
  3. 當讀寫鎖在讀模式的鎖狀態時,如果有另外的執行緒試圖以寫模式加鎖,讀寫鎖通常會阻塞隨後的讀模式鎖的請求,這樣可以避免讀模式鎖長期佔用,而等待的寫模式鎖請求則長期阻塞。
  1. 訊號量

訊號量和互斥鎖的區別:互斥鎖只允許一個執行緒進入臨界區,而訊號量允許多個執行緒同時進入臨界區。

可重入函數

所謂「可重入函數」,是指可以由多於一個任務並發使用,而不必擔心數據錯誤的函數。相反,「不可函數」則是只能由一個任務所佔用,除非能確保函數的互斥(或者使用訊號量,或者在程式碼的關鍵部分禁用中斷)。可重入函數可以在任意時刻被中斷,稍後再繼續運行,且不會丟失數據,可重入函數要在使用本地變數或在使用全局變數時保護自己的數據。

可重入函數有以下特點

  1. 不為連續的調用持有靜態數據
  2. 不返回指向靜態數據的指針
  3. 所有數據都由函數的調用者提供
  4. 使用本地數據,或者通過製作全局數據的本地副本來保護全局數據
  5. 如果必須訪問全局變數,要利用互斥鎖、訊號量等來保護全局變數
  6. 絕不調用任何不可重入函數

不可重入函數有以下特點

  1. 函數中使用了靜態變數,無論是全局靜態變數還是局部靜態變數
  2. 函數返回靜態變數
  3. 函數中調用了不可重人函數
  4. 函數體內使用了靜態的數據結構
  5. 函數體內調用了 malloc() 或者的 free() 函數
  6. 函數體內調用了其他標準 I/O 函數

編寫的多執行緒程式,通過定義宏 _REENTRANT 來告訴編譯器需要可重人功能,這個宏的定義必須出現於程式中的任何 #include 語句之前,它將為我們做三件事:

  1. 它會對部分函數重新定義它們的可安全重入的版本
  2. stdio.h 中原來以宏的形式出現的一些函數將變成可安全重入函數
  3. 在 error.h 中定義的變數 error 現在將成為一個函數調用,它能夠以一種安全的多執行緒方式來獲取真正的 errno 的值

進程

進程,是電腦中處於運行中程式的實體。以前,進程是最小的運行單位;有了執行緒之後,執行緒成為最小的運行單位,而進程則是執行緒的容器。

進程結構一般由3部分組成:程式碼段、數據段和堆棧段。程式碼段是用於存放程式程式碼的數據,假如機器中有數個進程運行相同的一個程式,那麼它們就可以使用同一個程式碼段。而數據段則存放程式的全局變數、常量和靜態變數。堆棧段中的棧用於函數調用,它存放著函數的參數、函數內部定義的局部變數。堆棧段還包括了進程式控制制塊(Process Control Block, PCB), 處於進程核心堆棧的底部,不需要額外分配空間,是進程存在的唯一標識,系統通過PCB的存在而感知進程的存在。系統通過PCB對進程進行管理和調度。PCB包括創建進程、執行程式、退出進程以及改變進程的優先順序等。

進程的創建有兩種方式:一種是由作業系統創建,一種是由父進程創建。

Linux 系統下使用 fork() 函數創建一個子進程,其函數原型如下:

#include <unistd.h>
pid_t fork(void);

fork()函數不需要參數,返回值是一個進程標識符(PID)。對於返回值,有以下3種情況:

  1. 對於父進程, fork() 函數返回新創建的子進程的 ID
  2. 對於子進程, fork() 函數返回0
  3. 如果創建出錯,則fork() 函數返回-1

fork() 函數會創建一個新的進程,並從內核中為此進程分配一個新的可用的進程標識符(PID),之後,為這個新進程分配進程空間,並將父進程的進程空間中的內容複製到子進程的進程空間中,包括父進程的數據段和堆棧段,並且和父進程共享程式碼段。這時候,系統中又多了一個進程,這個進程和父進程一樣,兩個進程都接受系統的調度。由於在複製時複製了父進程的堆棧段,所以兩個進程都停留在了 fork() 函數中,等待返回。因此,fork() 函數會返回兩次,一次是在父進程中返回,另一次是在子進程中返回,這兩次的返回值是不一樣的。

示例:

#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>

int main(int argc, const char* argv[])
{

    for(int i=0;i<2;++i)
    {
        if(fork()==0){
            printf("sub-process, pid: %d,ppid: %d\n",getpid(),getppid());
            exit(1);
        }else{
            printf("parent process, pid %d, ppid %d\n",getpid(),getppid());
        }
    }
    sleep(1);
    return 0;
}
/*
output:
parent process, pid 89607, ppid 5595
parent process, pid 89607, ppid 5595
sub-process, pid: 89608,ppid: 89607
sub-process, pid: 89609,ppid: 89607
*/

子進程完全複製了父進程的地址空間的內容,包括堆棧段和數據段的。但是,子進程並沒有複製程式碼段,而是和父進程共用程式碼段。程式碼段是只讀的,不存在被修改的問題。

Linux 環境下使用 exit()函數退出進程,其函數原型如下:

#include<stdlib.h> 
void exit(int status);

exit() 函數的參數表示進程的退出狀態,這個狀態的值是一個整型,保存在全局變數 $? 中。$? 是 Linux shell 中的一個內置變數,其中保存的是最近一次運行的進程的返回值。

在 UNIX/Linux 中,正常情況下,子進程是通過父進程創建的,子進程在創建新的進程。子進程的結束和父進程的運行是一個非同步過程,即父進程永遠無法預測子進程到底什麼時候結束。於是就產生了孤兒進程和殭屍進程。

孤兒進程,是指一個父進程退出後,而它的一個或多個子進程還在運行,那麼那些子進程將成為孤兒進程。孤兒進程將被 init 進程(進程號為 1)所收養,並由 init 進程對它們完成狀態收集工作,並會周期性地調用 wait 系統調用來清除各個殭屍的子進程。

殭屍進程,是指一個進程使用 fork 創建子進程,如果子進程退出,而父進程並沒有調 wait 或 waitpid 獲取子進程的狀態資訊,那麼子進程的進程描述符仍然保存在系統中,這種進程稱為殭屍進程。當一個進程完成它的工作終止之後,它的父進程需要調用 wait() 或者 waitpid() 系統調用取得子進程的終止狀態。

進程一旦調用了 wait 函數,就立即阻塞自己,由 wait 自動分析是否當前進程的某個子進程已經退出,如果讓它找到了這樣一個已經變成殭屍的子進程, wait 就會收集這個子進程的資訊,並把它徹底銷毀後返回;如果沒有找到這樣一個子進程, wait 就會一直阻塞在這裡,直到有一個出現為止。函數原型為:

#include<sys/types.h> 
#include<sys/wait.h>
pid_t wait(int * status);

wait() 會暫時停止目前進程的執行,直到有訊號來到或子進程結束 如果在調用 wait() 時子進程已經結束,則 wait() 會立即返回子進程結束狀態值。子進程的結束狀態值會由參數 status 返回,而子進程的進程識別碼也會一快返回。如果不需要結束狀態值,則參數 status 可以設成 NULL。

守護進程是脫離於終端並且在後台運行的進程。守護進程脫離於終端是為了避免進程在執行過程中的資訊在任何終端上顯示並且進程也不會被任何終端所產生的終端資訊所打斷。守護進程是一個生存期較長的進程,通常獨立於控制終端並且周期性地執行某種任務或等待處理某些發生的事件。守護進程常常在系統引導裝入時啟動,在系統關閉時終止。

進程間通訊

進程間通訊就是在不同進程之間傳播或交換資訊,用於進程間通訊的方法主要有:管道、消息隊列、共享記憶體、訊號量、套接字等。其中,前面4種主要用於同一台機器上的進程間通訊,而套接字則主要用於不同機器之間的網路通訊。

  1. 管道

管道是一種兩個進程間進行單向通訊的機制,因為管道傳遞數據的單向性,管道又稱為半雙工管道,管道的這一特點決定了其使用的局限性,具有以下特點:

  1. 數據只能由一個進程流向另一個進程(其中一個讀管道,一個寫管道),如果要進行雙工通訊,則需要建立兩個管道
  2. 管道只能用於父子進程或者兄弟進程間通訊,也就是說管道只能用於具有親緣關係的進程間通訊。
    從本質上說,管道也是一種文件,但它又和一般的文件有所不同,可以克服使用文件進行通訊的兩個問題,這個文件只存在記憶體中
    通過管道通訊的兩個進程,一個進程向管道寫數據,另外一個從中讀數據。寫入的數據每次都添加到管道緩衝區的末尾,讀數據的時候都是從緩衝區的頭部讀出數據的
    管道存在有名和無名的區別,其中,對於無名管道來說,只能進行有親緣關係的進程間的通訊,而有名管道與無名管道的區別就是提供了一個路勁名與之關聯,以FIFO的文件形式存在於系統中。這樣,即使 FIFO 的創建進程不存在親緣關係,只要可以訪問該路徑,就能夠彼此通過 FIFO 相互通訊。有名管道與無名管道的區別:
  1. 消息隊列

消息隊列用於運行於同一台機器上的進程間通訊,它和管道很相似,是一個在系統內核中用來保存消息的隊列,它在系統內核中是以消息鏈表的形式出現,消息鏈表中節點的結構用msg聲明
消息隊列跟有名管道有不少的相同之處,消息隊列進行通訊的進程可以是不相關的進程,同時它們都是通過發送和接收的方式來傳遞數據的。在命名管道中, 發送數據用 write 函數,接收數據用 read 函數,則在消息隊列中,發送數據用 msgsnd 函數,接收數據用 msgrcv 函數。而且它們對每個數據都有一個最大長度的限制。
與命名管道相比,消息隊列的優勢在於: 1. 消息隊列也可以獨立於發送和接收進程而存在,從而消除了在同步命名管道的打開和關閉時可能產生的困難;2. 可以同時通過發送消息以避免命名管道的同步和阻塞問題,而不需要由進程自己來提供同步方法;3. 接收程式可以通過消息類型有選擇地接收數據,而不是像命名管道中那樣,只能默認地接收

  1. 共享記憶體

共享記憶體就是允許兩個不相關的進程訪問同一個邏輯記憶體。共享記憶體是在兩個正在運行的進程之間共享和傳遞數據的一種非常有效的方式。不同進程之間共享的記憶體通常安排在同一段物理記憶體中。進程可以將同一段共享記憶體連接到它們自己的地址空間中,所有進程都可以訪問共享記憶體中的地址。不過,共享記憶體並未提供同步機制。
使用共享記憶體的優缺點如下所述:
優點:使用共享記憶體進行進程間的通訊非常方便,而且函數的介面也簡單,數據的共享還使進程間的數據不用傳送,而是直接訪問記憶體,也加快了程式的效率。同時,它也不像無名管道那樣要求通訊的進程有一定的父子關係
缺點:共享記憶體沒有提供同步的機制,這使得在使用共享記憶體進行進程間通訊時,往往要藉助其他的手段來進行進程間的同步工作

  1. 訊號量