程式間通訊機制(管道、訊號、共享記憶體/訊號量/訊息佇列)、執行緒間通訊機制(互斥鎖、條件變數、posix匿名訊號量)

s1mba發表於2013-09-16

注:本分類下文章大多整理自《深入分析linux核心原始碼》一書,另有參考其他一些資料如《linux核心完全剖析》、《linux c 程式設計一站式學習》等,只是為了更好地理清系統程式設計和網路程式設計中的一些概念性問題,並沒有深入地閱讀分析原始碼,我也是草草翻過這本書,請有興趣的朋友自己參考相關資料。此書出版較早,分析的版本為2.4.16,故出現的一些概念可能跟最新版本核心不同。

此書已經開源,閱讀地址 http://www.kerneltravel.net


一、管道

在Linux 中,管道是一種使用非常頻繁的通訊機制。從本質上說,管道也是一種檔案,但它又和一般的檔案有所不同,管道可以克服使用檔案進行通訊的兩個問題,具體表現如下所述。

• 限制管道的大小。實際上,管道是一個固定大小的緩衝區。在Linux 中,該緩衝區的大小為1 頁,即4KB,使得它的大小不像檔案那樣不加檢驗地增長。使用單個固定緩衝區也會帶來問題,比如在寫管道時可能變滿,當這種情況發生時,隨後對管道的write()呼叫將預設地被阻塞,等待某些資料被讀取,以便騰出足夠的空間供write()呼叫寫。

• 讀取程式也可能工作得比寫程式快。當所有當前程式資料已被讀取時,管道變空。當這種情況發生時,一個隨後的read()呼叫將預設地被阻塞,等待某些資料被寫入,這解決了read()呼叫返回檔案結束的問題。

注意,從管道讀資料是一次性操作,資料一旦被讀,它就從管道中被拋棄,釋放空間以便寫更多的資料。

(一)、管道的結構

在Linux 中,管道的實現並沒有使用專門的資料結構,而是藉助了檔案系統的file 結構和VFS 的索引節點inode。通過將兩個 file 結構指向同一個臨時的 VFS 索引節點,而這個 VFS 索引節點又指向一個物理頁面而實現的。如圖7.1 所示。


圖7.1 中有兩個 file 資料結構,但它們定義檔案操作例程地址是不同的,其中一個是向管道中寫入資料的例程地址,而另一個是從管道中讀出資料的例程地址。這樣,使用者程式的系統呼叫仍然是通常的檔案操作,而核心卻利用這種抽象機制實現了管道這一特殊操作。

一個普通的管道僅可供具有共同祖先的兩個程式之間共享,並且這個祖先必須已經建立了供它們使用的管道。
注意,在管道中的資料始終以和寫資料相同的次序來進行讀,這表示lseek()系統呼叫對管道不起作用。

二、訊號

(一)、訊號在核心中的表示


中斷的響應和處理都發生在核心空間,而訊號的響應發生在核心空間,訊號處理程式的執行卻發生在使用者空間。
那麼,什麼時候檢測和響應訊號呢?通常發生在以下兩種情況下:
(1)當前程式由於系統呼叫、中斷或異常而進入核心空間以後,從核心空間返回到使用者空間前夕;
(2)當前程式在核心中進入睡眠以後剛被喚醒的時候,由於檢測到訊號的存在而提前返回到使用者空間。

當有訊號要響應時,處理器執行路線的示意圖如圖33.2 所示。


當使用者程式通過系統呼叫剛進入核心的時候,CPU會自動在該程式的核心棧上壓入下圖所示的內容:



在處理完系統呼叫以後,就要呼叫do_signal()函式進行設定frame等工作。這時核心堆疊的狀態應該跟下圖左半部分類似(系統呼叫將一些資訊壓入棧了):

在找到了訊號處理函式之後,do_signal() 函式首先把核心堆疊中存放返回執行點的eip儲存為old_eip,然後將eip替換為訊號處理函式的地址,然後將核心中儲存的ESP(即使用者態棧地址)減去一定的值,目的是擴大使用者態的棧,然後將核心棧上的內容儲存到使用者棧上,這個過程就是設定frame.值得注意的是下面兩點:

1、之所以把EIP的值設定成訊號處理函式的地址,是因為一旦程式返回使用者態,就要去執行訊號處理程式,所以EIP要指向訊號處理程式而不是原來應該執行的地址。
2、之所以要把frame從核心棧拷貝到使用者棧,是因為程式從核心態返回使用者態會清理這次呼叫所用到的核心棧(類似函式呼叫),核心棧又太小,不能單純的在棧上儲存另一個frame(想象一下巢狀訊號處理),而我們需要EAX(系統呼叫返回值)、EIP這些資訊以便執行完訊號處理函式後能繼續執行程式,所以把它們拷貝到使用者態棧以儲存起來。

這時程式返回使用者空間,就會根據核心棧中的EIP值執行訊號處理函式。那麼,訊號處理程式執行完後,怎麼返回程式繼續執行呢?
訊號處理程式執行完畢之後,程式會主動呼叫sigreturn()系統呼叫再次回到核心,檢視有沒有其他訊號需要處理,如果沒有,這時核心就會做一些善後工作,將之前儲存的frame恢復到核心棧,恢復eip的值為old_eip,然後返回使用者空間,程式就能夠繼續執行。至此,核心遍完成了一次(或幾次)訊號處理工作。



 C++ Code 
1
2
 
(By default,  the  signal  handler  is invoked on the normal process stack.  It is possible to arrange that the signal handler
 uses an alternate stack; see sigaltstack(2for a discussion of how to do this and when it might be useful.)



三、System V 的IPC 機制

為了提供與其他系統的相容性,Linux 也支援3 種system Ⅴ的程式間通訊機制:訊息、訊號量(semaphores)和共享記憶體,Linux 對這些機制的實施大同小異。我們把訊號量、消息和共享記憶體統稱System V IPC 的物件,每一個物件都具有同樣型別的介面,即系統呼叫。就像每個檔案都有一個開啟檔案號一樣,每個物件也都有唯一的識別號,程式可以通過系統呼叫傳遞的識別號來存取這些物件,與檔案的存取一樣,對這些物件的存取也要驗證存取權限,System V IPC 可以通過系統呼叫對物件的建立者設定這些物件的存取許可權。在Linux 核心中,System V IPC 的所有物件有一個公共的資料結構pc_perm 結構,它是IPC 物件的許可權描述,在linux/ipc.h 中定義如下:

 C++ Code 
1
2
3
4
5
6
7
8
9
10
 
struct ipc_perm
{
    key_t key; /* 鍵 */
    ushort uid; /* 物件擁有者對應程式的有效使用者識別號和有效組識別號 */
    ushort gid;
    ushort cuid; /* 物件建立者對應程式的有效使用者識別號和有效組識別號 */
    ushort cgid;
    ushort mode; /* 存取模式 */
    ushort seq; /* 序列號 */
};

在這個結構中,要進一步說明的是鍵(key)。鍵和識別號指的是不同的東西。系統支持兩種鍵:公有和私有。如果鍵是公有的,則系統中所有的程式通過許可權檢查後,均可以找到System V IPC 物件的識別號。如果鍵是私有的,則鍵值為0,說明每個程式都可以用鍵值0 建立一個專供其私用的物件。注意,對System V IPC 物件的引用是通過識別號而不是通過鍵。

(一)、訊號量

Linux 中訊號量是通過核心提供的一系列資料結構實現的,這些資料結構存在於核心空間,對它們的分析是充分理解訊號量及利用訊號量實現程式間通訊的基礎,下面先給出訊號量的資料結構(存在於include/linux/sem.h 中)

 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
 
(1)系統中每個訊號量的資料結構(sem)
struct sem
{
    int semval; /* 訊號量的當前值 */
    unsigned short  semzcnt;  /* # waiting for zero */
    unsigned short  semncnt;  /* # waiting for increase */
    int sempid; /*在訊號量上最後一次操作的程式識別號*/
};

(2)系統中表示訊號量集合(set)的資料結構(semid_ds)
struct semid_ds
{
    struct ipc_perm sem_perm; /* IPC 許可權 */
    long sem_otime; /* 最後一次對訊號量操作(semop)的時間 */
    long sem_ctime; /* 對這個結構最後一次修改的時間 */
    struct sem *sem_base; /* 在訊號量陣列中指向第一個訊號量的指標 */
    struct sem_queue *sem_pending; /* 待處理的掛起操作*/
    struct sem_queue **sem_pending_last; /* 最後一個掛起操作 */
    struct sem_undo *undo; /* 在這個陣列上的undo 請求 */
    ushort sem_nsems; /* 在訊號量陣列上的訊號量號 */
};

(3)系統中每一訊號量集合的佇列結構(sem_queue)
struct sem_queue
{
    struct sem_queue *next;  /* 佇列中下一個節點 */
    struct sem_queue **prev;  /* 佇列中前一個節點, *(q->prev) == q */
    struct wait_queue *sleeper;  /* 正在睡眠的程式 */
    struct sem_undo *undo;  /* undo 結構*/
    int pid; /* 請求程式的程式識別號 */
    int status; /* 操作的完成狀態 */
    struct semid_ds *sma;  /*有操作的訊號量集合陣列 */
    struct sembuf *sops;  /* 掛起操作的陣列 */
    int nsops; /* 操作的個數 */
};

 C++ Code 
1
2
3
4
5
6
 
struct sembuf
{
    ushort sem_num; /* 在陣列中訊號量的索引值 */
    short sem_op; /* 訊號量操作值(正數、負數或0) */
    short sem_flg; /* 操作標誌,為IPC_NOWAIT 或SEM_UNDO*/
};



如果程式被掛起,Linux 必須儲存訊號量的操作狀態並將當前程式放入等待佇列。為此,Linux 核心在堆疊中建立一個 sem_queue 結構並填充該結構。新的 sem_queue 結構新增到集合的等待佇列中(利用 sem_pending 和 sem_pending_last 指標)。當前程式放入sem_queue 結構的等待佇列中(sleeper)後呼叫排程程式選擇其他的程式執行。

當某個程式修改了訊號量而進入臨界區之後,卻因為崩潰或被“殺死(kill)”而沒有退出臨界區,這時,其他被掛起在訊號量上的程式永遠得不到執行機會,這就是所謂的死鎖。Linux 通過維護一個訊號量陣列的調整列表(semadj)來避免這一問題。其基本思想是,當應用這些“調整”時,讓訊號量的狀態退回到操作實施前的狀態。

(二)、訊息佇列

Linux 中的訊息可以被描述成在核心地址空間的一個內部連結串列,每一個訊息佇列由一個IPC 的標識號唯一地標識。Linux 為系統中所有的訊息佇列維護一個 msgque 連結串列,該連結串列中的每個指標指向一個 msgid_ds 結構,該結構完整描述一個訊息佇列。



 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
 
(1)訊息緩衝區(msgbuf)
/* msgsnd 和msgrcv 系統呼叫使用的訊息緩衝區*/
struct msgbuf
{
    long mtype; /* 訊息的型別,必須為正數 */
    char mtext[1]; /* 訊息正文 */
};

(2)訊息結構(msg)
struct msg
{
    struct msg *msg_next; /* 佇列上的下一條訊息 */
    long msg_type; /*訊息型別*/
    char *msg_spot; /* 訊息正文的地址 */
    short msg_ts; /* 訊息正文的大小 */
};

(3)訊息佇列結構(msgid_ds)
/* 在系統中的每一個訊息佇列對應一個msgid_ds 結構 */
struct msgid_ds
{
    struct ipc_perm msg_perm;
    struct msg *msg_first; /* 佇列上第一條訊息,即連結串列頭*/
    struct msg *msg_last; /* 佇列中的最後一條訊息,即連結串列尾 */
    time_t msg_stime; /* 傳送給佇列的最後一條訊息的時間 */
    time_t msg_rtime; /* 從訊息佇列接收到的最後一條訊息的時間 */
    time_t msg_ctime; /* 最後修改佇列的時間*/
    ushort msg_cbytes; /*佇列上所有訊息總的位元組數 */
    ushort msg_qnum; /*在當前佇列上訊息的個數 */
    ushort msg_qbytes; /* 佇列最大的位元組數 */
    ushort msg_lspid; /* 傳送最後一條訊息的程式的pid */
    ushort msg_lrpid; /* 接收最後一條訊息的程式的pid */
};

(三)、共享記憶體

與訊息佇列和訊號量集合類似,核心為每一個共享記憶體段(存在於它的地址空間)維護著一個特殊的資料結構shmid_ds,這個結構在include/linux/shm.h 中定義如下:

 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 
/* 在系統中 每一個共享記憶體段都有一個shmid_ds 資料結構. */
struct shmid_ds
{
    struct ipc_perm shm_perm; /* 操作許可權 */
    int shm_segsz; /* 段的大小(以位元組為單位) */
    time_t shm_atime; /* 最後一個程式附加到該段的時間 */
    time_t shm_dtime; /* 最後一個程式離開該段的時間 */
    time_t shm_ctime; /* 最後一次修改這個結構的時間 */
    unsigned short shm_cpid; /*建立該段程式的 pid */
    unsigned short shm_lpid; /* 在該段上操作的最後一個程式的pid */
    short shm_nattch; /*當前附加到該段的程式的個數 */
    /* 下面是私有的 */
    unsigned short shm_npages; /*段的大小(以頁為單位) */
    unsigned long *shm_pages; /* 指向frames -> SHMMAX 的指標陣列 */
    struct vm_area_struct *attaches; /* 對共享段的描述 */
};

我們用圖 7.4 來表示共享記憶體的資料結構shmid_ds 與其他相關資料結構的關係。


某個程式第1 次訪問共享虛擬記憶體時將產生缺頁異常。這時,Linux 找出描述該記憶體的vm_area_struct 結構,該結構中包含用來處理這種共享虛擬記憶體段的處理函式地址。共享內存缺頁異常處理程式碼對shmid_ds 的頁表項表進行搜尋,以便檢視是否存在該共享虛擬記憶體的頁表項。如果沒有,系統將分配一個物理頁並建立頁表項,該頁表項加入 shmid_ds 結構的同時也新增到程式的頁表中。這就意味著當下一個程式試圖訪問這頁記憶體時出現缺頁異常,共享記憶體的缺頁異常處理程式碼則把新建立的物理頁給這個程式。因此說,第1 個程式對共享記憶體的存取引起建立新的物理頁面,而其他程式對共享記憶體的存取引起把那個頁新增到它們的地址空間。

當某個程式不再共享其虛擬記憶體時,利用系統呼叫將共享段從自己的虛擬地址區域中移去,並更新程式頁表。當最後一個程式釋放了共享段之後,系統將釋放給共享段所分配的物理頁。

當共享的虛擬記憶體沒有被鎖定到實體記憶體時,共享記憶體也可能會被交換到交換區中。

四、Posix 的IPC 機制

訊號量:分為命名和匿名訊號量。命名訊號量通常用於不共享記憶體的程式之間(核心實現);匿名訊號量可以用於執行緒通訊(存放於執行緒共享的記憶體,如全域性變數),或者用於程式間通訊(存放於程式共享的記憶體,如System V/ Posix 共享記憶體)。

訊息佇列、共享記憶體:與System V 類似。

互斥鎖mutex + 匿名訊號量:執行緒通訊
互斥鎖mutex + 條件變數condition :執行緒通訊

五、程式池的實現思路

    1、父子程式之間使用管道通訊,傳遞任務資料狀態等,主程式使用某種演算法主動選擇子程式。最簡單、最常用的演算法是隨機演算法和Round-Robin(輪流選取)演算法,但更優秀、更智慧的演算法將使任務在各個工作程式中更均勻地分配、從而減輕伺服器的整體壓力。
    2、主程式和所有子程式通過一個共享的工作佇列來同步,子程式都睡眠在該工作佇列上。當有新的任務到來時,主程式將任務新增到工作佇列中。這將喚醒正在等待任務的子程式,不過只有一個子程式獲得新任務的“接管權”,它可以從工作佇列中取出並執行之,而其他子程式將繼續睡眠在工作佇列上。



六、執行緒池的實現思路
       主執行緒接到任務時將任務新增到全域性共享的佇列中(可以用連結串列實現),新增任務前後需要加鎖,新增任務內部操作成功時觸發成功全域性共享的訊號量或者條件變數,此時所有子執行緒都在等待可用訊號,wait 成功的執行緒從任務佇列取走任務去執行。






相關文章