2020-04-12-Linux內核33-信號量

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
Linux
Linux內核
信號量
semaphore

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還提供了其它版本的請求信號量的函數:

  1. down_trylock()

    可以被中斷和延時函數調用。基本上與down()函數的實現一致,除了當信號量不可用時立即返回,而不是將進程休眠外。

  2. down_interruptible()

    廣泛的應用在驅動程序中,因爲它允許當信號量忙時,允許進程可以接受信號,從而中止請求信號量的操作。如果正在休眠的進程在取得信號量之前被其它信號喚醒,這個函數將信號量的count值加1,並且返回-EINTR。正常返回0。驅動程序通常判斷返回-EINTR後,終止I/O操作。

其實,通過上面的分析,很容易看出down()函數有點雞肋。它能實現的功能,down_interruptible()函數都能實現。而且down_interruptible()還能滿足中斷處理程序和延時函數的調用。所以,在2.6.37版本以後的內核中,這個函數已經被廢棄。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章