深入Linux核心架構——鎖與程序間通訊
Linux作為多工系統,當一個程序生成的資料傳輸到另一個程序時,或資料由多個程序共享時,或程序必須彼此等待時,或需要協調資源的使用時,應用程式必須彼此通訊。
一、控制機制
1、競態條件
幾個程序在訪問資源時彼此干擾的情況通常稱之為競態條件(race condition)。在對分散式應用程式設計時,這種情況是一個主要的問題,因為競態條件無法通過系統的試錯法檢測。只有徹底研究原始碼(深入瞭解各種可能發生的程式碼路徑)並通過敏銳的直覺,才能找到並消除競態條件。
2、臨界區
對於競態條件,其問題的本質是程序的執行在不應該的地方被中斷,從而導致程序工作得不正確。對於此問題的解決方案不一定要求臨界區不能中斷,只要沒有其他程序進入臨界區,那麼在臨界區中執行的程式是可以中斷的。確保幾個程序不能同時改變共享值的禁止條件稱為互斥。
大多數系統採用的方案是訊號量(semaphore)的使用。訊號量是由E. W. Dijkstra在1965年設計的。實質上,最初的訊號量是受保護的特別變數,能夠表示為正負整數,初始值為1。它有兩個標準操作(up和down),這兩個操作分別用於控制關鍵程式碼範圍的進入和退出,且假定相互競爭的程序訪問訊號量機會均等。
在一個程序想要進入關鍵程式碼時,它呼叫down函式。這會將訊號量的值減1,即將其設定為0,然後執行危險程式碼段(此時若有其他程序想進入該程式碼段呼叫down操作則會等待進入關鍵程式碼的程序完成操作)。在執行完操作之後,呼叫up函式將訊號量的值加1,即重置為初始值。
訊號量在使用者層可以正常工作,原則上也可以用於解決核心內部的各種鎖問題。但事實並非如此:效能是核心最首先的一個目標,雖然訊號量初看起來容易實現,但其開銷對核心來說過大,這也是核心中提供了許多不同的鎖和同步機制的原因。
二、核心鎖機制
在多處理器系統上,如果幾個處理器同時處於核心態,理論上它們可以同時訪問一個數據結構,剛好引發了競態條件。因此,在第一個SMP功能的核心版本中,對該問題的處理是每次只允許一個處理器處於核心態,但這樣效率不高。現在,核心使用了由鎖組成的細粒度網路,用以明確保護各資料結構(如果處理器A在操作資料結構X,則處理器B可以執行任何其他的核心操作,但不能操作X)。
核心提供了各種鎖選項,分別優化不同的核心資料使用模式:
原子操作:這些是最簡單的鎖操作。它們保證簡單的操作,諸如計數器加1之類,可以不中斷地原子執行,即使操作由幾個彙編語句組成,也可以保證;
自旋鎖:這些是最常用的鎖選項,它們用於短期保護某段程式碼,以防止其他處理器的訪問,在核心等待自旋鎖釋放時,會重複檢查是否能獲取鎖,而不會進入睡眠狀態(忙等待),如果等待時間較長,則效率顯然不高;
訊號量:這些是用經典方法實現的,在等待訊號量釋放時,核心進入睡眠狀態,直至被喚醒,喚醒後,核心才重新嘗試獲取訊號量,互斥量是訊號量的特例,互斥量保護的臨界區,每次只能有一個使用者進入;
讀者/寫者鎖:這些鎖會區分對資料結構的兩種不同型別的訪問,任意數目的處理器都可以對資料結構進行併發讀訪問,但只有一個處理器能進行寫訪問(在進行寫訪問時,讀訪問是無法進行的)。
1、對整數的原子操作
核心定義了atomic_t資料型別,用作對整數計數器的原子操作的基礎。從核心的角度看,這些操作相當於一條彙編語句。
為使得核心中平臺獨立的部分能夠使用原子操作,用於操縱atomic_t型別變數的操作必須由特定於體系結構的程式碼提供(因為核心將標準型別進行了封裝,原子變數只能藉助於ATOMIC_INIT巨集初始化,不能用普通運算子處理)。
核心為SMP系統提供了local_t資料型別。該型別允許在單個CPU上的原子操作。為修改此型別變數,核心提供了基本上與atomic_t資料型別相同的一組函式,只是將atomic替換為local。
2、自旋鎖
自旋鎖用於保護短的程式碼段,其中只包含少量C語句,會很快執行完畢。大多數核心資料結構都有自身的自旋鎖,在處理結構中的關鍵成員時,必須獲得相應的自旋鎖。
自旋鎖通過spinlock_t資料結構實現,基本上可使用spin_lock和spin_unlock操縱。(自旋鎖的實現與體系結構相關,幾乎全是組合語言)
自旋鎖工作情況:
- 如果核心中其他地方尚未獲得lock,則由當前處理器獲取。其他處理器不能再進入lock保護的程式碼範圍;
- 如果lock已經由另一個處理器獲得,spin_lock進入一個無限迴圈,重複地檢查lock是否已經由spin_unlock釋放(自旋鎖因此得名)。如果已經釋放,則獲得lock,並進入臨界區。
自旋鎖使用注意:
- 如果獲得鎖之後不釋放,系統將變得不可用,所有的處理器(包括獲得鎖的在內),遲早需要進入鎖對應的臨界區,它們會進入無限迴圈等待鎖釋放,但等不到,便產生了死鎖;
- 自旋鎖決不應該長期持有,因為所有等待鎖釋放的處理器都處於不可用狀態,無法用於其他工作;
- 核心進入到由自旋鎖保護的臨界區時,就停用核心搶佔,在啟用了核心搶佔的單處理器核心中,spin_lock(基本上)等價於preempt_disable,而spin_unlock則等價於preempt_enable。
3、訊號量
核心使用的訊號量定義如下(使用者空間訊號量的實現有所不同):
1 struct semaphore { 2 atomic_t count; //count指定了可以同時處於訊號量保護的臨界區中程序的數目 3 int sleepers; //sleepers指定了等待允許進入臨界區的程序的數目 4 wait_queue_head_t wait; //wait用於實現一個佇列,儲存所有在該訊號量上睡眠的程序的task_struct 5 };
與自旋鎖相比,訊號量適合於保護更長的臨界區,以防止並行訪問。它們不應該用於保護較短的程式碼範圍,因為競爭訊號量時需要使程序睡眠和再次喚醒,代價很高。
大多數情況下,不需要使用訊號量的所有功能,只是將其用作互斥量,這是一種二值訊號量。
訊號量工作情況:
- 在進入臨界區時,用down對使用計數器減1,在計數器為0時,其他程序不能進入臨界區;
- 在試圖用down獲取已經分配的訊號量時,當前程序進入睡眠,並放置在與該訊號量關聯的等待佇列上,同時,該程序被置於TASK_UNINTERRUPTIBLE狀態,在等待進入臨界區的過程中無法接收訊號,如果訊號量沒有分配,則該程序可以立即獲得訊號量並進入到臨界區,而不會進入睡眠;
- 在退出臨界區時,必須呼叫up,該例程負責喚醒在訊號量睡眠的某個程序,該程序然後允許進入臨界區,而所有其他等待的程序繼續睡眠。
除了只能用於核心的互斥量之外,Linux也提供了futex(快速使用者空間互斥量,fast userspacemutex),由核心態和使用者狀態組合而成,為使用者空間程序提供了互斥量功能。
4、RCU機制
RCU(read-copy-update)是一個同步機制,該機制記錄了指向共享資料結構的指標的所有使用者。在該結構將要改變時,則首先建立一個副本(或一個新的例項),在副本中修改。在所有進行讀訪問的使用者結束對舊副本的讀取之後,指標可以替換為指向新的、修改後副本的指標(允許讀寫併發進行,但不對寫訪問之間的相互干擾提供保護)。使用RCU要求如下:
- 對共享資源的訪問在大部分時間應該是隻讀的,寫訪問應該相對很少;
- 在RCU保護的程式碼範圍內,核心不能進入睡眠狀態;
- 受保護資源必須通過指標訪問。
RCU可以保護一般指標,也可以保護雙鏈表。以一般指標為例,假定指標ptr指向一個被RCU保護的資料結構,直接反引用指標是禁止的,首先必須呼叫rcu_dereference(ptr),然後反引用返回的結果,此外,反引用指標並使用其結果的程式碼,需要用rcu_read_lock和rcu_read_unlock呼叫保護起來。對於雙向連結串列,核心也是以RCU機制為基礎,提供了標準函式進行保護。此外由struct hlist_head和struct hlist_node組成的散列表也可以通過RCU保護。
5、記憶體和優化屏障
儘管鎖足以確保原子性,但對編譯器和處理器優化過的程式碼,鎖不能永遠保證時序正確。與競態條件相比,這個問題不僅影響SMP系統,也影響單處理器計算機。
核心提供了下面幾個函式,可阻止處理器和編譯器進行程式碼重排。
mb()、rmb()、wmb()將硬體記憶體屏障插入到程式碼流程中。rmb()是讀訪問記憶體屏障。它保證在屏障之後發出的任何讀取操作執行之前,屏障之前發出的所有讀取操作都已經完成。wmb適用於寫訪問,語義與rmb類似。讀者應該能猜到,mb()合併了二者的語義。
barrier插入一個優化屏障。該指令告知編譯器,儲存在CPU暫存器中、在屏障之前有效的所有記憶體地址,在屏障之後都將失效。本質上,這意味著編譯器在屏障之前發出的讀寫請求完成之前,不會處理屏障之後的任何讀寫請求。
但CPU仍然可以重排時序!
smb_mb()、smp_rmb()、smp_wmb()相當於上述的硬體記憶體屏障,但只用於SMP系統。它們在單處理器系統上產生的是軟體屏障。
read_barrier_depends()是一種特殊形式的讀訪問屏障,它會考慮讀操作之間的依賴性。如果屏障之後的讀請求,依賴於屏障之前執行的讀請求的資料,那麼編譯器和硬體都不能重排這些請求。
6、讀者/寫者鎖
通常,任意數目的程序都可以併發讀取資料結構,而寫訪問只能限於一個程序。因此核心提供了額外的訊號量和自旋鎖版本,分別稱之為讀者/寫者訊號量和讀者/寫者自旋鎖。
讀者/寫者自旋鎖定義為rwlock_t資料型別。必須根據讀寫訪問,以不同的方法獲取鎖。
程序對臨界區進行讀訪問時,在進入和離開時需要分別執行read_lock和read_unlock,核心會允許任意數目的讀程序併發訪問臨界區;
write_lock和write_unlock用於寫訪問。核心保證只有一個寫程序(此時沒有讀程序)能夠處於臨界區中。
讀/寫訊號量的用法類似。所用的資料結構是struct rw_semaphore,down_read和up_read用於獲取對臨界區的讀訪問。寫訪問藉助於down_write和up_write進行。
7、大核心鎖
大核心鎖(big kernel lock)可以鎖定整個核心,確保沒有處理器在核心態並行執行(已經過時啦)。使用lock_kernel可鎖定整個核心,對應的解鎖使用unlock_kernel。SMP系統和啟用了核心搶佔的單處理器系統如果設定了配置選項PREEMPT_BKL,則允許搶佔大核心鎖。
8、互斥量
儘管訊號量可用於實現互斥量的功能,訊號量的通用性導致的開銷通常是不必要的。因此,核心包含了一個專用互斥量的獨立實現,它們不依賴訊號量。核心包含互斥量的兩種實現:一種是經典的互斥量,另一種是用來解決優先順序反轉問題的實時互斥量。
(1)經典的互斥量
經典互斥量的基本資料結構定義如下:
1 struct mutex { 2 /* 1: 未鎖定, 0: 鎖定, 負值:鎖定,可能有等待者 */ 3 atomic_t count; 4 spinlock_t wait_lock; 5 struct list_head wait_list; 6 };
如果互斥量未鎖定,則count為1。鎖定分為兩種情況:如果只有一個程序在使用互斥量,則count設定為0。如果互斥量被鎖定,而且有程序在等待互斥量解鎖(在解鎖時需要喚醒等待程序),則count為負值。這種特殊處理有助於加快程式碼的執行速度,因為在通常情況下,不會有程序在互斥量上等待。
定義新的互斥量:
- 靜態互斥量可以在編譯時通過使用DEFINE_MUTEX產生(與DECLARE_MUTEX區分,後者是基於訊號量的互斥量);
- mutex_init在執行時動態初始化一個新的互斥量;
- mutex_lock和mutex_unlock分別用於鎖定和解鎖互斥量。
(2)實時互斥量
實時互斥量是核心支援的另一種形式的互斥量,需要在編譯時通過配置選項CONFIG_RT_MUTEX顯式啟用。與普通的互斥量相比,它們實現了優先順序繼承(priority inheritance),該特性可用於解決(或在最低限度上緩解)優先順序反轉的影響。
對於優先順序反轉問題,可以通過優先順序繼承解決。如果高優先順序程序阻塞在互斥量上,該互斥量當前由低優先順序程序持有,那麼低優先順序程序的優先順序會臨時提高到高優先順序程序的優先順序。
實時互斥量的定義非常接近於普通互斥量:
1 struct rt_mutex { 2 spinlock_t wait_lock; 3 struct plist_head wait_list; 4 struct task_struct *owner; 5 };
互斥量的所有者通過owner指定,wait_lock提供實際的保護,所有等待的程序都在wait_list中排隊。與普通互斥量相比,決定性的改變是等待列表中的程序按優先順序排序。在等待列表改變時,核心可相應地校正鎖持有者的優先順序。這需要到排程器的一個介面,可由函式rt_mutex_setprio提供。該函式更新動態優先順序task_struct->prio,而普通優先順序task_struct->normal_priority不變。
9、近似的per_CPU計數器
如果系統安裝有大量CPU,計數器可能成為瓶頸:每次只有一個CPU可以修改其值;所有其他CPU都必須等待操作結束,才能再次訪問計數器。如果計數器頻繁訪問,則會嚴重影響系統性能。
對某些計數器,沒有必要時時瞭解其準確的數值。這種計數器的近似值與準確值,作用上沒什麼差別,可以利用這種情況,引入per-CPU計數器,加速SMP系統上計數器的操作。如圖1所示,計數器的準確值儲存在記憶體中某處,準確值所在記憶體位置之後是一個數組,每個陣列項對應於系統中的一個CPU。
圖1 近似per-CPU計數器的資料結構
如果一個處理器想要修改計數器的值(加上或減去某個值n),它不會直接修改計數器的值,因為這需要防止其他的CPU訪問計數器(這是一個費時的操作)。相反,所需的修改將儲存到與計數器相關的陣列中特定於當前CPU的陣列項。(舉例:,如果計數器應該加3,那麼陣列中對應的陣列項為+3。如果同一個CPU在其他時間需要從計數器減去某個值(假定是5),它也不會對計數器直接操作,而是運算元組中特定於CPU的項:將3減去5,新值為-2。任何處理器讀取計數器值時,都不是完全準確的。如果原值為15,在經過前述的操作之後應該是13,但仍然是15。如果只需要大致瞭解計數器的值,13也算得上是15的一個比較好的近似了。)
如果某個特定於CPU的陣列元素修改後的絕對值超出某個閾值,則認為這種修改有問題,將隨之修改計數器的值(這種改變很少發生)。在這種情況下,核心需要確保通過適當的鎖機制來保護這次訪問。
只要計數器改變適度,這種方案中讀操作得到的平均值會相當接近於計數器的準確值。
per-CPU計數器如下:
1 struct percpu_counter { 2 spinlock_t lock; 3 long count; 4 long *counters; 5 };
count是計數器的準確值,lock是一個自旋鎖,用於在需要準確值時保護計數器。counters陣列中各陣列項是特定於CPU的,該陣列快取了對計數器的操作。
10、鎖競爭與細粒度鎖
Linux在多CPU系統上的可伸縮性已經成為一個非常重要的目標。在對核心程式碼設計鎖規則時,特別需要考慮這個問題。鎖需要滿足下面兩個目的(不過二者通常很難同時實現):
必須防止對程式碼的併發訪問,否則將導致失敗;
對效能的影響必須儘可能小。
對於核心頻繁使用的資料,同時滿足這兩個要求是非常複雜的,如果一整個資料結構都由一個鎖保護,那麼在核心的某個部分需要獲取鎖的時候,該鎖已經被系統的其他部分獲取的概率很高,這種情況下會出現較多的鎖競爭(lock contention),該鎖也會成為核心的一個熱點。對此,將資料結構標識為各個獨立的部分,使用多個鎖來保護,這種解決方案稱為細粒度鎖。
細粒度鎖在較大的計算機上對提高可伸縮性很有好處,但也有兩個弊端:
獲取多個鎖會增加操作的開銷,特別是在較小的SMP計算機上;
在通過多個鎖保護一個數據結構時,很自然會出現一個操作需要同時訪問兩個受保護區域的情形,因而需要同時持有多個鎖,這要求必須遵守某種鎖定次序,必須按序獲取和釋放鎖,否則,仍然會導致死鎖。
三、System V程序間通訊
Linux使用System V(SysV)引入的機制,來支援使用者程序的程序間通訊和同步。
1、System V機制
System V UNIX的3種程序間通訊(IPC)機制(訊號量、訊息佇列、共享記憶體),都使用了全系統範圍的資源,可以由幾個程序同時共享。
在各個獨立程序能夠訪問SysV IPC物件之前,IPC物件必須在系統內唯一標識。為此,每種IPC結構在建立時分配了一個號碼,稱為魔數。凡知道這個魔數的各個程式,都能夠訪問對應的結構。如果獨立的應用程式需要彼此通訊,則通常需要將該魔數永久地編譯到程式中。
在訪問IPC物件時,系統採用了基於檔案訪問許可權的一個許可權系統。每個IPC物件都有一個使用者ID和一個組ID,依賴於產生IPC物件的程式在何種UID/GID之下執行。讀寫許可權在初始化時分配。類似於普通的檔案,這些控制了3種不同使用者類別的訪問:所有者、組、其他。
要建立一個授予所有可能訪問許可權的訊號量(所有者、組、其他使用者都有讀寫許可權),則必須指定標誌0666。
2、訊號量
(1)使用System V訊號量
System V的訊號量不再當作是用於支援原子執行預定義操作的簡單型別變數,它是指一整套訊號量,可以允許幾個操作同時進行(使用者看上去是原子的)。也可以請求只有一個訊號量的訊號量集合,並定義函式模擬原始訊號量的簡單操作。
(2)資料結構
核心使用了幾個資料結構來描述所有註冊訊號量的當前狀態,並建立了一種網狀結構。它們不僅負責管理訊號量及其特徵(值、讀寫許可權,等等),還負責通過等待列表將訊號量與等待程序關聯起來。
初始的預設的IPC名稱空間通過ipc_namespace的靜態例項init_ipc_ns實現。每個名稱空間都包含如下資訊:
1 struct ipc_namespace { 2 ... 3 struct ipc_ids *ids[3]; 4 /* 資源限制 */ 5 ... 6 }
這裡略去了與監視資源消耗和設定資源限制相關的很多資料結構成員(比如共享記憶體頁的最大數目、共享記憶體段的最大長度、訊息佇列的最大數目等)。陣列ids的每個元素對應於一種IPC機制:訊號量、訊息佇列、共享記憶體(按順序),每個陣列項指向一個struct ipc_ids的例項,用於跟蹤各類別現存的IPC物件。為防止對每個類別都需要查詢對應的正確陣列索引,核心提供了輔助函式msg_ids、shm_ids和sem_ids。
struct ipc_ids定義如下:
1 struct ipc_ids { 2 int in_use; //儲存了當前使用中IPC物件的數目 3 unsigned short seq; //seq和seq_max用於連續產生使用者空間IPC ID(不等同於序號) 4 unsigned short seq_max; 5 struct rw_semaphore rw_mutex; //一個核心訊號量,用於實現訊號量操作,避免使用者空間中的競態條件 6 struct idr ipcs_idr; 7 };
每個IPC物件都由kern_ipc_perm的一個例項表示,每個物件都有一個核心內部ID,ipcs_idr用於將ID關聯到指向對應的kern_ipc_perm例項的指標。使用中IPC物件的數目可能動態地增長和縮減,核心提供了一個類似於基數樹的標準資料結構用於管理該資訊。
1 struct kern_ipc_perm 2 { 3 int id; 4 key_t key; //儲存了使用者程式用來標識訊號量的魔數 5 uid_t uid; //指所有者的使用者ID 6 gid_t gid; //指所有者的組ID 7 uid_t cuid; //儲存了產生訊號量的程序的使用者ID 8 gid_t cgid; //儲存了產生訊號量的程序的組ID 9 mode_t mode; //儲存了位掩碼,指定了所有者、組、其他使用者的訪問許可權 10 unsigned long seq; //分配IPC物件時使用的序號 11 };
該結構不僅可用於訊號量,還可以用於其他的IPC機制。該結構不足以儲存訊號量所需的所有資訊,各程序的task_struct例項中有一個與IPC相關的成員:
1 struct task_struct { 2 ... 3 #ifdef CONFIG_SYSVIPC 4 /* ipc相關 */ 5 struct sysv_sem sysvsem; 6 #endif 7 ... 8 };
只有設定了配置選項CONFIG_SYSVIPC時,SysV相關程式碼才會編譯到核心中。sysv_sem資料結構封裝了一個成員struct sem_undo_list *undo_list用於撤銷訊號量(用於崩潰程序修改了訊號量狀態的情況)。
sem_queue是另一個數據結構,用於將訊號量與睡眠程序關聯起來,該程序想要執行訊號量操作,但目前不允許執行。
1 struct sem_queue { 2 struct sem_queue * next; /* 佇列中下一項 */ 3 struct sem_queue ** prev; /* 佇列中的前一項,對於第一項有*(q->prev) == q */ 4 struct task_struct* sleeper; /* 睡眠的程序 */ 5 struct sem_undo * undo; /* 用於撤銷的結構 */ 6 int pid; /* 請求訊號量操作的程序ID。 */ 7 int status; /* 操作的完成狀態 */ 8 struct sem_array * sma; /* 操作的訊號量陣列 */ 9 int id; /* 內部訊號量ID */ 10 struct sembuf * sops; /* 待決運算元組 */ 11 int nsops; /* 運算元目 */ 12 int alter; /* 操作是否改變了陣列? */ 13 };
系統中每個訊號量集合,都對應於sem_array資料結構的一個例項,該例項用於管理集合中的所有訊號量,sem_array結構如下:
1 struct sem_array { 2 struct kern_ipc_perm sem_perm; /* 許可權,參見ipc.h */ 3 time_t sem_otime; /* 最後一次訊號量操作的時間 */ 4 time_t sem_ctime; /* 最後一次修改的時間 */ 5 struct sem *sem_base; /* 指向陣列中第一個訊號量的指標 */ 6 struct sem_queue *sem_pending; /* 需要處理的待決操作 */ 7 struct sem_queue **sem_pending_last; /* 上一個待決操作 */ 8 struct sem_undo *undo; /* 該陣列上的撤銷請求 */ 9 unsigned long sem_nsems; /* 陣列中訊號量的數目 */ 10 };
圖2給出了所涉及的各個資料結構之間的相互關係。
圖2 訊號量各資料結構之間的相互關係
kern_ipc_perm是用於管理IPC物件的資料結構的第一個成員,不僅對訊號量是這樣,訊息佇列和共享記憶體物件也是如此。
(3)實現系統呼叫
所有對訊號量的操作都使用一個名為ipc的系統呼叫執行。該呼叫不僅用於訊號量,也用於操作訊息佇列和共享記憶體。其第一個引數用於將實際工作委託給其他函式。用於訊號量的函式如下所示。
- SEMCTL執行訊號量操作,並由sys_semctl實現;
- SEMGET讀取訊號量ID,相關的實現由sys_semget提供;
- SEMOP和SEMTIMEDOP負責增加和減少訊號量值,後者可以指定超時時間限制。
(4)許可權檢查
IPC物件的保護機制,與普通的基於檔案的物件相同。訪問許可權可以分別對物件的所有者、所有者所在組和所有其他使用者指定(可能的許可權包括讀、寫、執行)。函式ipcperms負責檢查對任意IPC物件的某種操作是否有許可權進行。
3、訊息佇列
程序之間通訊的另一個方法是交換訊息。這是使用訊息佇列機制完成的,其實現基於System V模型。訊息佇列的功能原理相對簡單,如圖3所示。
圖3 System V訊息佇列的功能原理
產生訊息並將其寫到佇列的程序通常稱之為傳送者,而一個或多個其他程序(邏輯上稱之為接收者)則從佇列獲取資訊。各個訊息包含訊息正文和一個(正)數,以便在訊息佇列內實現幾種型別的訊息。接收者可以根據該數字檢索訊息(比如可以指定只接受編號1的訊息,或接受編號不大於5的訊息)。在訊息已經讀取後,核心將其從佇列刪除。即使幾個程序在同一通道上監聽,每個訊息仍然只能由一個程序讀取。
同一編號的訊息按先進先出次序處理。放置在佇列開始的訊息將首先讀取。但如果有選擇地讀取訊息,則先進先出次序就不再適用。
訊息佇列也是使用前述訊號量哪些資料結構實現,起始點是當前名稱空間的適當的ipc_ids例項。內部的ID號形式上關聯到kern_ipc_perm例項,在訊息佇列的實現中,需要通過型別轉換獲得不同的資料型別(struct msg_queue)。該結構定義如下:
1 struct msg_queue { 2 struct kern_ipc_perm q_perm; 3 time_t q_stime; /* 上一次呼叫msgsnd傳送訊息的時間 */ 4 time_t q_rtime; /* 上一次呼叫msgrcv接收訊息的時間 */ 5 time_t q_ctime; /* 上一次修改的時間 */ 6 unsigned long q_cbytes; /* 佇列上當前位元組數目 */ 7 unsigned long q_qnum; /* 佇列中的訊息數目 */ 8 unsigned long q_qbytes; /* 佇列上最大位元組數目 */ 9 pid_t q_lspid; /* 上一次呼叫msgsnd的pid */ 10 pid_t q_lrpid; /* 上一次接收訊息的pid */ 11 struct list_head q_messages; 12 struct list_head q_receivers; 13 struct list_head q_senders; 14 };
3個標準的核心連結串列用於管理睡眠的傳送者(q_senders)、睡眠的接收者(q_receivers)和訊息本身(q_messages)。各個連結串列都使用獨立的資料結構作為連結串列元素。
q_messages中的各個訊息都封裝在一個msg_msg例項中。
1 struct msg_msg { 2 struct list_head m_list; 3 long m_type; //指定了訊息型別,用於支援前文所述訊息佇列中不同的訊息型別。 4 int m_ts; /* 訊息正文長度 */ 5 struct msg_msgseg* next; //如果儲存超過一個記憶體頁的長訊息,則需要next 6 /* 接下來是實際的訊息 */ 7 };
結構中沒有指定儲存訊息自身的欄位。因為每個訊息都(至少)分配了一個記憶體頁,msg_msg例項則儲存在該頁的起始處,剩餘的空間可用於儲存訊息正文,如圖4所示。從記憶體頁的長度,減去msg_msg結構的長度,即可得到msg_msg頁中可用於訊息正文的最大位元組數目。
圖4 記憶體中IPC訊息的管理
訊息正文緊接著該資料結構的例項之後儲存。使用next,可以使訊息分佈到任意數目的頁上。在通過訊息佇列通訊時,傳送程序和接收程序都可以進入睡眠:如果訊息佇列已經達到最大容量,則傳送者在試圖寫入訊息時會進入睡眠;如果佇列中沒有訊息,那麼接收者在試圖獲取訊息時會進入睡眠。
睡眠的傳送者放置在msg_queue的q_senders連結串列中,連結串列元素使用下列資料結構:
1 struct msg_sender { 2 struct list_head list; //連結串列元素 3 struct task_struct* tsk; //指向對應程序的task_struct的指標 4 };
q_receivers連結串列中用於儲存接收程序的資料結構要稍長一點。
1 struct msg_receiver { 2 struct list_head r_list; 3 struct task_struct *r_tsk; 4 int r_mode; 5 long r_msgtype; 6 long r_maxsize; 7 struct msg_msg *volatile r_msg; 8 };
其中不僅儲存了指向對應程序的task_struct的指標,還包括了對預期訊息的描述,以及指向msg_msg例項的一個指標(訊息可用時,該指標指定了複製資料的目標地址)。
圖5是訊息佇列所涉及資料結構之間的相互關係(忽略睡眠的傳送程序連結串列)。
圖5 System V訊息佇列的資料結構
4、共享記憶體
與訊號量和訊息佇列相比,共享記憶體沒有本質性的不同。
- 應用程式請求的IPC物件,可以通過魔數和當前名稱空間的核心內部ID訪問;
- 對記憶體的訪問,可能受到許可權系統的限制;
- 可以使用系統呼叫分配與IPC物件關聯的記憶體,具備適當授權的所有程序,都可以訪問該記憶體。
核心的實現採用了與訊號量和訊息佇列非常類似的概念,相關資料結構關係如圖6所示。
圖6 System V共享記憶體的資料結構
在smd_ids全域性變數的entries陣列中儲存了kern_ipc_perm和shmid_kernel的組合,以便管理IPC物件的訪問許可權。對每個共享記憶體物件都建立一個偽檔案,通過shm_file連線到shmid_kernel的例項。核心使用shm_file->f_mapping指標訪問地址空間物件(struct address_space),用於建立匿名對映。還需要設定所涉及各程序的頁表,使得各個程序都能夠訪問與該IPC物件相關的記憶體區域。
四、其他IPC機制
SysV IPC通常只對應用程式設計師有意義,但對shell的使用者,訊號和管道更常用。
1、訊號
與SysV機制相比,訊號是一種比較原始的通訊機制,其底層概念非常簡單,kill命令根據PID向程序傳送訊號。訊號通過-s sig指定,是一個正整數,最大長度取決於處理器型別。
程序必須設定處理程式例程來處理訊號。這些例程在訊號傳送到程序時呼叫(程序可以決定阻塞某些訊號,但有幾個訊號的行為無法修改,如SIGKILL)。如果沒有顯式設定處理程式例程,核心則使用預設的處理程式實現。(init程序屬於特例,核心會忽略傳送給該程序的SIGKILL訊號。)
(1)實現訊號處理程式
sigaction系統呼叫用於設定新的處理程式。如果沒有為某個訊號分配使用者定義的處理程式函式,核心會自動設定預定義函式,提供合理的標準操作來處理相應的情況。
sigaction型別中用於描述處理程式的欄位,其定義是平臺相關的,但在所有體系結構上幾乎都相同。
1 struct sigaction { 2 __sighandler_t sa_handler; //一個指向核心在訊號到達時呼叫的處理程式函式的指標 3 unsigned long sa_flags; //包含了額外的標誌,用於指定訊號處理方式的一些約束 4 ... 5 sigset_t sa_mask; //包含了一個位掩碼,每個位元位對應於系統中的一個訊號 6 };
訊號處理程式的函式原型如下:
1 typedef void __signalfn_t(int); 2 typedef __signalfn_t __user *__sighandler_t;
其引數是訊號的編號,因此可以使用同一個處理程式函式處理不同的訊號。
訊號處理程式使用sigaction系統呼叫設定,該呼叫將藉助使用者定義的處理程式函式替換SIGTERM的預設處理程式。
(2)實現訊號管理
所有訊號相關的資料都是藉助於鏈式資料結構管理的,其入口是task_struct結構,其中包含了各個與訊號相關的欄位。
1 struct task_struct { 2 ... 3 /* 訊號處理程式 */ 4 struct signal_struct *signal; 5 struct sighand_struct *sighand; 6 sigset_t blocked; 7 struct sigpending pending; 8 unsigned long sas_ss_sp; 9 size_t sas_ss_size; 10 ... 11 };
訊號處理髮生在核心中,但設定的訊號處理程式是在使用者狀態執行,通常,訊號處理程式使用所述程序在使用者狀態下的棧。但POSIX強制要求提供一種選項,在專門用於訊號處理的棧上執行訊號處理程式,這個附加的棧(必須通過使用者應用程式顯式分配),其地址和長度分別儲存在sas_ss_sp和sas_ss_size。
用於管理設定的訊號處理程式的資訊的sighand_struct如下所示:
1 struct sighand_struct { 2 atomic_t count; //儲存了共享該結構例項的程序數目 3 struct k_sigaction action[_NSIG]; //儲存設定的訊號處理程式,_NSIG指定了可以處理的不同訊號的數目 4 } ;
所有阻塞訊號由task_struct的blocked成員定義,所使用的sigset_t資料型別是一個位掩碼,所包含的位元位數目必須(至少)與所支援的訊號數目相同,其資料結構為:
1 typedef struct { 2 unsigned long sig[_NSIG_WORDS]; 3 } sigset_t;
pending是task_struct中與訊號處理相關的最後一個成員。它建立了一個連結串列,包含所有已經引發、仍然有待核心處理的訊號。它們使用了下列資料結構:
1 struct sigpending { 2 struct list_head list; //通過雙鏈表管理所有待決訊號 3 sigset_t signal; //位掩碼,指定了仍然有待處理的所有訊號的編號 4 };
圖7為各結構體之間的關係。
圖7 訊號管理結構體之間關係
(3)實現訊號處理
核心用於實現訊號處理的最重要的系統呼叫有kill(向程序組的所有程序傳送一個訊號)、tkill(向單個程序傳送一個訊號)、sigpending(檢查是否有待決訊號)、sigprocmask(操作阻塞訊號的位掩碼)、sigsuspend(進入睡眠,直至接收某特定訊號)。
對於傳送訊號,不論名稱如何,實際上kill和tkill基本相同,以sys_tkill為例,其程式碼流程圖如圖8所示。
圖8 sys_tkill程式碼流程圖
在find_task_by_vpid找到目標程序的task_struct之後,核心將檢查程序是否有傳送該訊號所需許可權的工作委託給check_kill_permission,該函式檢查許可權。剩餘的訊號處理工作則傳遞給specific_send_sig_info進行,如果訊號被阻塞(可以用sig_ignored檢查),則立即放棄處理;否則由send_signal產生一個新的sigqueue例項(使用sigqueue_cachep快取),其中填充了訊號資料,並新增到目標程序的sigpending連結串列;若傳送陳宮,則可以使用signal_wake_up喚醒程序。
對於訊號佇列的處理,每次由核心態切換到使用者狀態時,核心都會完成此工作。處理的發起獨立於特定的體系結構,此後,最終的效果就是呼叫do_signal函式(此處不詳述)。
從時序上看,訊號處理的過程如圖9所示。
圖9 訊號處理的執行
2、管道和套接字
管道和套接字是流行的程序間通訊機制。管道使用了虛擬檔案系統物件,套接字使用了各種網路函式以及虛擬檔案系統。
管道是用於交換資料的連線。一個程序向管道的一端供給資料,另一個在管道另一端取出資料,供進一步處理。幾個程序可以通過一系列管道連線起來。
管道是程序地址空間中的資料物件,在用fork或clone複製程序時同樣會被複制。使用管道通訊的程式就利用了這種特徵。在exec系統呼叫用另一個程式替換子程序之後,兩個不同的應用程式之間就建立了一條通訊鏈路(必須把管道描述符重定向到標準輸入和輸出,或者呼叫dup系統呼叫,確保exec呼叫時不會關閉檔案描述符)。
套接字物件在核心中初始化時也返回一個檔案描述符,因此可以像普通檔案一樣處理,與管道不同之處在於它可以雙向使用,還可以用於通過網路連線的遠端系統通訊。從使用者的角度來看,同一系統上兩個本地程序之間基於套接字的通訊與分別處於兩個不同大陸兩臺計算機上執行的應用程式之間的通訊沒有太大差別。