天天看點

Linux核心33-信号量1 什麼是信号量?2 信号量的實作3 如何擷取和釋放信号量3 請求信号量的其它函數版本

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版本以後的核心中,這個函數已經被廢棄。