layout | title | subtitle | date | author | header-img | catalog | tags | ||||
---|---|---|---|---|---|---|---|---|---|---|---|
post |
Linux內核33-信號量 |
Linux-信號量的工作原理以及應用場合 |
2020-04-12 |
Tupelo Shen |
img/post-bg-unix-linux.jpg |
true |
|
1 什麼是信號量?
對於信號量我們並不陌生。信號量在計算機科學中是一個很容易理解的概念。本質上,信號量就是一個簡單的整數,對其進行的操作稱爲PV操作。進入某段臨界代碼段就會調用相關信號量的P操作;如果信號量的值大於0,該值會減1,進程繼續執行。相反,如果信號量的值等於0,該進程就會等待,直到有其它程序釋放該信號量。釋放信號量的過程就稱爲V操作,通過增加信號量的值,喚醒正在等待的進程。
注:
信號量,這一同步機制爲什麼稱爲PV操作。原來,這些術語都是來源於狄克斯特拉使用荷蘭文定義的。因爲在荷蘭文中,通過叫
passeren
,釋放叫vrijgeven
,PV操作因此得名。這是在計算機術語中不是用英語表達的極少數的例子之一。
事實上,Linux提供了兩類信號量:
- 內核使用的信號量
- 用戶態使用的信號量(遵循
System V IPC
信號量要求)
在本文中,我們集中研究內核信號量,至於進程間通信使用的信號量以後再分析。所以,後面再提及的信號量指的是內核信號量。
信號量與自旋鎖及其類型,不同之處是使用自旋鎖的話,獲取鎖失敗的時候,進入忙等待狀態,也就是一直在自旋。而使用信號量的話,如果獲取信號量失敗,則相應的進程會被掛起,知道資源被釋放,相應的進程就會繼續運行。因此,信號量只能由那些允許休眠的程序可以使用,像中斷處理程序和可延時函數等不能使用。
2 信號量的實現
信號量的結構體是semaphore
,包含下面的成員:
-
count
是一個
atomic_t
類型原子變量。該值如果大於0,則信號量處於釋放狀態,也就是可以被使用。如果等於0,說明信號量已經被佔用,但是沒有其它進程在等待信號量保護的資源。如果是負值,說明被保護的資源不可用且至少有一個進程在等待這個資源。 -
wait
休眠進程等待隊列列表的地址,這些進程都是要訪問該信號保護的資源。當然了,如果count大於0,這個等待隊列是空的。
-
sleepers
標誌是否有進程正在等待該信號。
雖然信號量可以支持很大的count,但是在linux內核中,大部分情況下還是使用信號量的一種特殊形式,也就是互斥信號量(MUTEX)
。所以,在早期的內核版本(2.6.37
之前),專門提供了一組函數:
init_MUTEX() // 將count設爲1
init_MUTEX_LOCKED() // 將count設爲0
用它們來初始化信號量,實現獨佔訪問。init_MUTEX()函數將互斥信號設爲1,允許進程使用這個互斥信號量加鎖訪問資源。init_MUTEX_LOCKED()函數將互斥信號量設爲0,說明資源已經被鎖住,進程想要訪問資源需要先等待別的地方解鎖,然後再請求鎖獨佔訪問該資源。這種初始化方式一般是在該資源需要其它地方準備好後才允許訪問,所以初始狀態先被鎖住。等準備後,再釋放鎖允許等待進程訪問資源。
另外,還分別有兩個靜態初始化方法:
DECLARE_MUTEX
DECLARE_MUTEX_LOCKED
這兩個宏的作用和上面的初始化函數一致,但是靜態分配信號量變量。當然了,count還可以被初始化爲一個整數值n(n大於1),這樣的話,可以允許多達n個進程併發訪問資源。
但是,從Linux內核2.6.37版本之後,上面的函數和宏已經不存在。這是爲什麼呢?因爲大家發現在Linux內核的設計實現中通常使用互斥信號量,而不會使用信號量。那既然如此,爲什麼不直接使用自旋鎖和一個int型整數設計信號量呢?這樣的話,因爲自旋鎖本身就有互斥性,代碼豈不更爲簡潔?這樣設計,還有一個原因就是之前使用atomic原子變量表示count,但是等待該信號量的進程隊列還是需要自旋鎖進行保護,有點重複。於是,2.6.37版本內核開始,就使用自旋鎖和count設計信號量了。代碼如下:
struct semaphore {
raw_spinlock_t lock;
unsigned int count;
struct list_head wait_list;
};
這樣的設計使用起來更爲方便簡單。當然了,結構體的變化必然導致操作信號量的函數發生設計上的改變。
3 如何獲取和釋放信號量
前面我們已經知道,信號量實現在內核發展的過程中發生了更變。所以,其獲取和釋放信號量的過程必然也有了改變。爲了更好的理解信號量,也爲了嘗試理解內核在設計上的一些思想和機制。我們還是先了解一下早期版本內核獲取和釋放信號量的過程。
因爲信號量的釋放過程比獲取更爲簡單,所以我們先以釋放信號量的過程爲例進行分析。如果一個進程想要釋放內核信號量,會調用up()函數。這個函數,本質上等價於下面的代碼:
movl $sem->count,%ecx
lock; incl (%ecx)
jg 1f // 標號1後面的f字符表示向前跳轉,如果是b表示向後跳轉
lea %ecx,%eax
pushl %edx
pushl %ecx
call __up
popl %ecx
popl %edx
1:
上面的代碼實現的過程大概是,先把信號量的count拷貝到寄存器ecx中,然後使用lock指令原子地將ecx寄存器中的值加1。如果eax寄存器中的值大於0,說明沒有進程在等待這個信號,則跳轉到標號1處開始執行。使用加載有效地址指令lea
將寄存器ecx中的值的地址加載到eax寄存器中,也就是說把變量sem->count的地址(因爲count是第一個成員,所以其地址就是sem變量的地址)加載到eax寄存器中。至於兩個pushl指令把edx和ecx壓棧,是爲了保存當前值。因爲後面調用__up()
函數的時候約定使用3個寄存器(eax,edx和ecx)傳遞參數,雖然此處只有一個參數。爲此調用C函數的內核棧準備好了,可以調用__up()
函數了。該函數的代碼如下:
__attribute__((regparm(3))) void __up(struct semaphore *sem)
{
wake_up(&sem->wait);
}
反過來,如果一個進程想要請求一個內核信號量,調用down()
函數,也就是實施p操作。該函數的實現比較複雜,但是大概內容如下:
down:
movl $sem->count,%ecx
lock; decl (%ecx);
jns 1f
lea %ecx, %eax
pushl %edx
pushl %ecx
call __down
popl %ecx
popl %edx
1:
上面代碼實現過程:移動sem->count到ecx寄存器中,然後對ecx寄存器進行原子操作,減1。然後檢查它的值是否爲負值。如果該值大於等於0,則說明當前進程請求信號量成功,可以執行信號量保護的代碼區域;否則,說明信號量已經被佔用,進程需要掛起休眠。因而,把sem->count的地址加載到eax寄存器中,並將edx和ecx寄存器壓棧,爲調用C語言函數做好準備。接下來,就可以調用__down()
函數了。
__down()
函數是一個C語言函數,內容如下:
__attribute__((regparm(3))) void __down(struct semaphore * sem)
{
DECLARE_WAITQUEUE(wait, current);
unsigned long flags;
current->state = TASK_UNINTERRUPTIBLE;
spin_lock_irqsave(&sem->wait.lock, flags);
add_wait_queue_exclusive_locked(&sem->wait, &wait);
sem->sleepers++;
for (;;) {
if (!atomic_add_negative(sem->sleepers-1, &sem->count)) {
sem->sleepers = 0;
break;
}
sem->sleepers = 1;
spin_unlock_irqrestore(&sem->wait.lock, flags);
schedule();
spin_lock_irqsave(&sem->wait.lock, flags);
current->state = TASK_UNINTERRUPTIBLE;
}
remove_wait_queue_locked(&sem->wait, &wait);
wake_up_locked(&sem->wait);
spin_unlock_irqrestore(&sem->wait.lock, flags);
current->state = TASK_RUNNING;
}
__down()
函數改變進程的運行狀態,從TASK_RUNNING到TASK_UNINTERRUPTIBLE,然後把它添加到該信號量的等待隊列中。其中sem->wait中包含一個自旋鎖spin_lock,使用它保護wait等待隊列這個數據結構。同時,還要關閉本地中斷。通常,queue操作函數從隊列中插入或者刪除一個元素,都是需要lock保護的,也就是說,有一個請求、釋放鎖的過程。但是,__down()函數還使用這個queue的自旋鎖保護其它成員,所以擴大了鎖的保護範圍。所以調用的queue操作函數都是帶有_locked
後綴的函數,表示鎖已經在函數外被請求成功了。
__down()
函數的主要任務就是對信號量結構體中的count計數進行減1操作。sleepers如果等於0,則說明沒有進行在等待隊列中休眠;如果等於1,則相反。
以MUTEX信號量爲例進行說明。
-
第1種情況:count等於1,sleepers等於0。
也就是說,信號量現在沒有進程使用,也沒有等待該信號量的進程在休眠。
down()
直接通過自減指令設置count爲0,滿足跳轉指令的條件是一個非負數,直接調轉到標籤1處開始執行,也就是請求信號量成功。那當然也就不會再調用__down()
函數了。 -
第2種情況:count等於0,sleepers也等於0。
這種情況下,會調用
__down()
函數進行處理(count等於-1),設置sleepers等於1。然後判斷atomic_add_negative()
函數的執行結果:因爲在進入for循環之前,sleepers先進行了自加,所以,sem->sleepers-1
等於0。所以,if條件不符合,不跳出循環。那麼此時count等於-1,sleepers等於0。也就是說明請求信號量失敗,因爲已經有進程佔用信號量,但是沒有進程在等待這個信號量。然後,循環繼續往下執行,設置sleepers等於1,表示當前進程將會被掛起,等待該信號量。然後執行schedule(),切換到那個持有信號量的進程執行,執行完之後釋放信號量。也就是將count設爲1,sleepers設爲0。而當前被掛起的進程再次被喚醒後,繼續檢查if條件是否符合,因爲此時count等於1,sleepers等於0。所以if條件爲真,將sleepers設爲0之後,跳出循環。請求鎖失敗。 -
第3種情況:count等於0,sleepers等於1。
進入
__down()
函數之後(count等於-1),設置sleepers等於2。if條件爲真,所以設置sleepers等於0,跳出循環。說明已經有一個持有信號量的進程在等待隊列中。所以,跳出循環後,嘗試喚醒等待隊列中的進程執行。 -
第4種情況:count是-1,sleepers等於0。
這種情況下,進入
__down()
函數之後,count等於-2,sleepers臨時被設爲1。那麼atomic_add_negative()
函數的計算結果小於0,返回1。if條件爲假,繼續往下執行,設置sleepers等於1,表明當前進程將被掛起。然後,執行schedule(),切換到持有該信號的進程運行。運行完後,釋放信號量,喚醒當前的進程繼續執行。而當前被掛起的進程再次被喚醒後,繼續檢查if條件是否符合,因爲此時count等於1,sleepers等於0。所以if條件爲真,將sleepers設爲0之後,跳出循環。請求鎖失敗。 -
第5種情況:count是-1,sleepers等於1。
這種情況下,進入
__down()
函數之後,count等於-2,sleepers臨時被設爲2。if條件爲真,所以設置sleepers等於0,跳出循環。說明已經有一個持有信號量的進程在等待隊列中。所以,跳出循環後,嘗試喚醒等待隊列中的進程執行。
通過上面幾種情況的分析,我們可知不管哪種情況都能正常工作。wake_up()每次最多可以喚醒一個進程,因爲在等待隊列中的進程是互斥的,不可能同時有兩個休眠進程被激活。
3 請求信號量的其它函數版本
在上面的分析過程中,我們知道down()函數的實現過程,需要關閉中斷,而且這個函數會掛起進程,而中斷服務例程中是不能掛起進程的。所以,只有異常處理程序,尤其是系統調用服務例程可以調用down()函數。基於這個原因,Linux還提供了其它版本的請求信號量的函數:
-
down_trylock()
可以被中斷和延時函數調用。基本上與down()函數的實現一致,除了當信號量不可用時立即返回,而不是將進程休眠外。
-
down_interruptible()
廣泛的應用在驅動程序中,因爲它允許當信號量忙時,允許進程可以接受信號,從而中止請求信號量的操作。如果正在休眠的進程在取得信號量之前被其它信號喚醒,這個函數將信號量的count值加1,並且返回
-EINTR
。正常返回0。驅動程序通常判斷返回-EINTR
後,終止I/O操作。
其實,通過上面的分析,很容易看出down()函數有點雞肋。它能實現的功能,down_interruptible()函數都能實現。而且down_interruptible()還能滿足中斷處理程序和延時函數的調用。所以,在2.6.37版本以後的內核中,這個函數已經被廢棄。