信號量

信號量,共享內存與消息隊列

在這一章,我們將會討論Unix發行版AT&T系統V.2所引入的進程通信工具集合。因爲這些程序出現在相關的發行版本中並且具有類似的編程接口,他們通常被稱之爲IPC程序,或是更爲通常的System V IPC。正如我們已經瞭解到的,他們絕不是進程之間通信的唯一方法,但是System V IPC通常用來指這些特殊的程序。

在這一章,我們將會討論下列內容:

用於管理資源訪問的信號量
用於程序之間高效共享數據的共享內存
用於在程序之間簡單傳遞數據的消息隊列

信號量

當我們在多用戶系統,多進程系統,或是兩者混合的系統中使用線程操作編寫程序時,我們經常會發現我們有段臨界代碼,在此處我們需要保證一個進程(或是一個線程的執行)需要排他的訪問一個資源。

信號量有一個複雜的編程接口。幸運的是,我們可以很容易的爲自己提供一個對於大多數的信號量編程問題足夠高效的簡化接口。

我們在第7章的第一個例子程序中--使用dbm訪問數據庫--如果多個程序嘗試同時更新數據庫,那麼數據將會被破壞。兩個不同的程序要求兩個不同的用戶爲數據庫輸入數據則沒有問題;問題的本質就在於更新數據庫的代碼部分。這些代碼實際上執行數據更新並且需要排他的執行,就被稱之爲臨界代碼。通常他們只是一個大程序中的幾行代碼。

爲了阻止多個程序同時訪問一個共享資源所引起的問題,我們需要一種方法生成並且使用一個標記從而保證在臨界區部分一次只有一個線程執行。我們在第12章簡要的瞭解了一些線程相關的方法,我們可以使用互斥或信號量來控制一個多線程程序對於臨界區的訪問。在這一章,我們將會回到信號量這個話題,但是我們會了解如何更爲通用的在不同的進程之間使用信號量。

編寫通用目的的代碼保證一個程序排他的訪問一個特定的資源是十分困難的,儘管有一個名爲Dekker的算法解決方法。不幸的是,這個算法依賴於"忙等待" 或是"自旋鎖",即一個進程的連續運行需要等待一個內存地址發生改變。在一個多任務環境中,例如Linux,這是對CPU資源的無謂浪費。如果硬件支持,這樣的情況就要容易得多,通常以特定CPU指令的形式來支持排他訪問。硬件支持的例子可以是訪問指令與原子方式增加寄存器值,從而在讀取/增加/寫入的操作之間就不會有其他的指令運行。

我們已經瞭解到的一個要行的解決方法就是使用O_EXCL標記調用open函數來創建文件,這提供了原子方式的文件創建。這會使得一個進程成功的獲得一個標記:新創建的文件。這個方法可以用於簡單的問題,但是對於複雜的情況就要顯得煩瑣與低效了。

當Dijkstr引入信號量的概念以後,並行編程領域前進了一大步。正如我們在第12章所討論的,信號量是一個特殊的變量,他是一個整數,並且只有兩個操作可以使得其值增加:等待(wait)與信號(signal)。因爲在Linux與UNIX編程中,"wait"與"signal"已經具有特殊的意義了,我們將使用原始概念:
用於等待(wait)的P(信號量變量)
用於信號(signal)的V(信號量變量)

這兩字母來自等待(passeren:通過,如同臨界區前的檢測點)與信號(vrjgeven:指定或釋放,如同釋放臨界區的控制權)的荷蘭語。有時我們也會遇到與信號量相關的術語"up"與"down",來自於信號標記的使用。

信號量定義

最簡單的信號量是一個只有0與1兩個值的變量,二值信號量。這是最爲通常的形式。具有多個正數值的信號量被稱之爲通用信號量。在本章的其餘部分,我們將會討論二值信號量。

P與V的定義出奇的簡單。假定我們有一個信號量變量sv,兩個操作定義如下:

P(sv)    如果sv大於0,減小sv。如果sv爲0,掛起這個進程的執行。
V(sv)    如果有進程被掛起等待sv,使其恢復執行。如果沒有進行被掛起等待sv,增加sv。

信號量的另一個理解方式就是當臨界區可用時信號量變量sv爲true,當臨界區忙時信號量變量被P(sv)減小,從而變爲false,當臨界區再次可用時被V(sv)增加。注意,簡單的具有一個我們可以減小或是增加的通常變量並不足夠,因爲我們不能用C,C++或是其他的編程語言來表述生成信號,進行原子測試來確定變量是否爲true,如果是則將其變爲false。這就是使得信號量操作特殊的地方。

一個理論例子

我們可以使用一個簡單的理論例子來了解一下信號量是如何工作的。假設我們有兩個進程proc1與proc2,這兩個進程會在他們執行的某一時刻排他的訪問一個數據庫。我們定義一個單一的二值信號量,sv,其初始值爲1並且可以爲兩個進程所訪問。兩個進程然後需要執行同樣的處理來訪問臨界區代碼;實際上,這兩個進程可以是同一個程序的不同調用。

這兩個進程共享sv信號量變量。一旦一個進程已經執行P(sv)操作,這個進程就可以獲得信號量並且進入臨界區。第二個進程就會被阻止進行臨界區,因爲當他嘗試執行P(sv)時,他就會等待,直到第一個進程離開臨界區並且執行V(sv)操作來釋放信號量。

所需要的過程如下:

semaphore sv = 1;
loop forever {
    P(sv);
    critical code section;
    V(sv);
    noncritical code section;
}

這段代碼出奇的簡單,因爲P操作與V操作是十分強大的。圖14-1顯示了P操作與V操作如何成爲進行臨界區代碼的門檻。

Linux信號量工具

現在我們已經瞭解了什麼是信號量以及他們在理論上是如何工作的,現在我們可以來了解一下這些特性在Linux中是如何實現的。信號量函數接口設計十分精細,並且提供了比通常所需要的更多的實用性能。所有的Linux信號量函數在通用的信號量數組上進行操作,而不是在一個單一的二值信號量上進行操作。乍看起來,這似乎使得事情變得更爲複雜,但是在一個進程需要鎖住多個資源的複雜情況下,在信號量數組上進行操作將是一個極大的優點。在這一章,我們將會關注於使用單一信號量,因爲在大多數情況下,這正是我們需要使用的。

信號量函數定義如下:

#include <sys/sem.h>
int semctl(int sem_id, int sem_num, int command, ...);
int semget(key_t key, int num_sems, int sem_flags);
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);

事實上,爲了獲得我們特定操作所需要的#define定義,我們需要在包含sys/sem.h文件之前通常需要包含sys/types.h與sys/ipc.h文件。而在某些情況下,這並不是必須的。

因爲我們會依次瞭解每一個函數,記住,這些函數的設計是用於操作信號量值數組的,從而會使用其操作向比單個信號量所需要的操作更爲複雜。

注意,key的作用類似於一個文件名,因爲他表示程序也許會使用或是合作所用的資源。相類似的,由semget所返回的並且爲其他的共享內存函數所用的標識符與由fopen函數所返回 的FILE *十分相似,因爲他被進程用來訪問共享文件。而且與文件類似,不同的進程會有不同的信號量標識符,儘管他們指向相同的信號量。key與標識符的用法對於在這裏所討論的所有IPC程序都是通用的,儘管每一個程序會使用獨立的key與標識符。

semget

semget函數創建一個新的信號量或是獲得一個已存在的信號量鍵值。

int semget(key_t key, int num_sems, int sem_flags);

第一個參數key是一個用來允許不相關的進程訪問相同信號量的整數值。所有的信號量是爲不同的程序通過提供一個key來間接訪問的,對於每一個信號量系統生成一個信號量標識符。信號量鍵值只可以由semget獲得,所有其他的信號量函數所用的信號量標識符都是由semget所返回的。

還有一個特殊的信號量key值,IPC_PRIVATE(通常爲0),其作用是創建一個只有創建進程可以訪問的信號量。這通常並沒有有用的目的,而幸運的是,因爲在某些Linux系統上,手冊頁將IPC_PRIVATE並沒有阻止其他的進程訪問信號量作爲一個bug列出。

num_sems參數是所需要的信號量數目。這個值通常總是1。

sem_flags參數是一個標記集合,與open函數的標記十分類似。低九位是信號的權限,其作用與文件權限類似。另外,這些標記可以與 IPC_CREAT進行或操作來創建新的信號量。設置IPC_CREAT標記並且指定一個已經存在的信號量鍵值並不是一個錯誤。如果不需要,IPC_CREAT標記只是被簡單的忽略。我們可以使用IPC_CREAT與IPC_EXCL的組合來保證我們可以獲得一個新的,唯一的信號量。如果這個信號量已經存在,則會返回一個錯誤。

如果成功,semget函數會返回一個正數;這是用於其他信號量函數的標識符。如果失敗,則會返回-1。

semop

函數semop用來改變信號量的值:

int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);

第一個參數,sem_id,是由semget函數所返回的信號量標識符。第二個參數,sem_ops,是一個指向結構數組的指針,其中的每一個結構至少包含下列成員:

struct sembuf {
    short sem_num;
    short sem_op;
    short sem_flg;
}

第一個成員,sem_num,是信號量數目,通常爲0,除非我們正在使用一個信號量數組。sem_op成員是信號量的變化量值。(我們可以以任何量改變信號量值,而不只是1)通常情況下中使用兩個值,-1是我們的P操作,用來等待一個信號量變得可用,而+1是我們的V操作,用來通知一個信號量可用。

最後一個成員,sem_flg,通常設置爲SEM_UNDO。這會使得操作系統跟蹤當前進程對信號量所做的改變,而且如果進程終止而沒有釋放這個信號量,如果信號量爲這個進程所佔有,這個標記可以使得操作系統自動釋放這個信號量。將sem_flg設置爲SEM_UNDO是一個好習慣,除非我們需要不同的行爲。如果我們確實變我們需要一個不同的值而不是SEM_UNDO,一致性是十分重要的,否則我們就會變得十分迷惑,當我們的進程退出時,內核是否會嘗試清理我們的信號量。

semop的所用動作會同時作用,從而避免多個信號量的使用所引起的競爭條件。我們可以在手冊頁中瞭解關於semop處理更爲詳細的信息。

semctl

semctl函數允許信號量信息的直接控制:

int semctl(int sem_id, int sem_num, int command, ...);

第一個參數,sem_id,是由semget所獲得的信號量標識符。sem_num參數是信號量數目。當我們使用信號量數組時會用到這個參數。通常,如果這是第一個且是唯一的一個信號量,這個值爲0。command參數是要執行的動作,而如果提供了額外的參數,則是union semun,根據X/OPEN規範,這個參數至少包括下列參數:

union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
}

許多版本的Linux在頭文件(通常爲sem.h)中定義了semun聯合,儘管X/Open確認說我們必須定義我們自己的聯合。如果我們發現我們確實需要定義我們自己的聯合,我們可以查看semctl手冊頁瞭解定義。如果有這樣的情況,建議使用手冊頁中提供的定義,儘管這個定義與上面的有區別。

有多個不同的command值可以用於semctl。在這裏我們描述兩個會經常用到的值。要了解semctl功能的詳細信息,我們應該查看手冊頁。

這兩個通常的command值爲:

SETVAL:用於初始化信號量爲一個已知的值。所需要的值作爲聯合semun的val成員來傳遞。在信號量第一次使用之前需要設置信號量。
IPC_RMID:當信號量不再需要時用於刪除一個信號量標識。

semctl函數依據command參數會返回不同的值。對於SETVAL與IPC_RMID,如果成功則會返回0,否則會返回-1。

使用信號量

正如我們在前面部分的描述中所看到的,信號量操作是相當複雜的。這是最不幸的,因爲使用臨界區進行多進程或是多線程編程是一個十分困難的問題,而其擁有其自己複雜的編程接口也增加了編程負擔。

幸運的是,我們可以使用最簡單的二值信號量來解決大多數需要信號量的問題。在我們的例子中,我們會使用所有的編程接口來創建一個非常簡單的用於二值信號量的P
與V類型接口。然後,我們會使用這個簡單的接口來演示信號量如何工作。

要試驗信號量,我們將會使用一個簡單的程序,sem1.c,這個程序我們可以多次調用。我們將會使用一個可選的參數來標識這個程序是負責創建信號量還是銷燬信號量。

我們使用兩個不同字符的輸出來標識進入與離開臨界區。使用參數調用的程序會在進入與離開其臨界區時輸出一個X,而另一個程序調用會在進入與離開其臨界區時輸出一個O。因爲在任何指定的時間內只有一個進程能夠進入其臨界區,所以所有X與O字符都是成對出現的。

試驗--信號量

1 在#include語句之後,我們定義函數原型與全局變量,然後我們進入main函數。在這裏使用semget函數調用創建信號量,這會返回一個信號量 ID。如果程序是第一次調用(例如,使用一個參數並且argc > 1來調用),程序就會調用set_semvalue來初始化信號量並且將op_char設置爲X。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

#include "semun.h"

static int set_semvalue(void);
static void del_semvalue(void);
static int semaphore_p(void);
static int semaphore_v(void);

static int sem_id;

int main(int argc, char **argv)
{
    int i;
    int pause_time;
    char op_char = 'O';

    srand((unsigned int)getpid());

    sem_id = semget((key_t)1234, 1, 0666 | IPC_CREAT);

    if(argc > 1)
    {
        if(!set_semvalue())
        {
            fprintf(stderr, "Failed to initialize semaphore/n");
            exit(EXIT_FAILURE);
        }
        op_char = 'X';
        sleep(2);
    }
2 然後我們使用一個循環代碼進入並且離開臨界區10次。此時會調用semaphore_p函數,這個函數會設置信號量並且等待程序進入臨界區。
    for(i=0;i<10;i++)
    {
        if(!semaphore_p()) exit(EXIT_FAILURE);
        printf("%c", op_char); fflush(stdout);
        pause_time = rand() % 3;
        sleep(pause_time);
        printf("%c", op_char); fflush(stdout);
3 在臨界區之後,我們調用semaphore_v函數,在隨機的等待之後再次進入for循環之後,將信號量設置爲可用。在循環之後,調用del_semvalue來清理代碼。
        if(!semaphore_v()) exit(EXIT_FAILURE);

        pause_time = rand() % 2;
        sleep(pause_time);
    }

    printf("/n%d - finished/n", getpid());

    if(argc > 1)
    {
        sleep(10);
        del_semvalue();
    }

    exit(EXIT_SUCCESS);
    }

4 函數set_semvalue在一個semctl調用中使用SETVAL命令來初始化信號量。在我們使用信號量之前,我們需要這樣做。

static int set_semvalue(void)
{
    union semun sem_union;

    sem_union.val = 1;
    if(semctl(sem_id, 0, SETVAL, sem_union) == -1) return 0;
    return 1;
}
5 del_semvalue函數幾乎具有相同的格式,所不同的是semctl調用使用IPC_RMID命令來移除信號量ID:

static void del_semvalue(void)
{
    union semun sem_union;

    if(semctl(sem_id, 0, IPC_RMID, sem_union) == -1)
        fprintf(stderr, "Failed to delete semaphore/n");
}

6 semaphore_p函數將信號量減1(等待):

static int semaphore_p(void)
{
    struct sembuf sem_b;

    sem_b.sem_num = 0;
    sem_b.sem_op = -1;
    sem_b.sem_flag = SEM_UNDO;
    if(semop(sem_id, &sem_b, 1) == -1)
    {
        fprintf(stderr, "semaphore_p failed/n");
        return 0;
    }
    return 1;
}

7 semaphore_v函數將sembuf結構的sem_op部分設置爲1,從而信號量變得可用。

static int semaphore_v(void)
{
    struct sembuf sem_b;

    sem_b.sem_num = 0;
    sem_b.sem_op = 1;
    sem_b.sem_flag = SEM_UNDO;
    if(semop(sem_id, &sem_b, 1) == -1)
    {
        fprintf(stderr, "semaphore_v failed/n");
        return 0;
    }
    return 1;
}

注意,這個簡單的程序只有每個程序有一個二值信號量,儘管如果我們需要多個信號量,我們可以擴展這個程序來傳遞多個信號量變量。通常,一個簡單的二值信號量就足夠了。

我們可以通過多次調用這個程序來測試我們的程序。第一次,我們傳遞一個參數來通知程序他並不負責創建與刪除信號量。另一次調用沒有傳遞參數。

下面是兩次調用的示例輸出結果:

$ ./sem1 1 &
[1] 1082
$ ./sem1
OOXXOOXXOOXXOOXXOOXXOOOOXXOOXXOOXXOOXXXX
1083 - finished
1082 - finished
$

正如我們所看到了,O與X是成對出現的,表明臨界區部分被正確的處理了。如果這個程序在我們的系統上不能正常運行,也許我們需要在調用程序之前使用命令stty -tostop來保證生成tty輸出的後臺程序不會引起信號生成。

工作原理

這個程序由我們選擇使用semget函數所獲得的鍵生成一個信號量標識開始。IPC_CREAT標記會使得如果需要的時候創建一個信號量。

如果這個程序有參數,他負責使用我們的set_semvalue函數來初始化信號量,這是更爲通用的semctl函數的一個簡化接口。同時,他也使用所提供的參數來決定要輸出哪一個字符。sleep只是簡單的使得我們在這個程序執行多次之前有時間調用程序的另一個拷貝。在程序中我們使用srand與 rand來引入一些僞隨機計數。

這個程序循環十次,在其臨界區與非臨界區等待一段隨機的時間。臨界區代碼是通過調用我們的semaphore_p與semaphore_v函數來進行保護的,這兩個函數是更爲通用的semop函數的簡化接口。

在刪除信號量之前,使用參數調用的程序拷貝會等待其他的調用結束。如果信號量沒有刪除,他就會繼續存在於系統中,儘管已經沒有程序再使用他。在實際的程序中,保證我們沒有遺留信號是十分重要的。在我們下一次運行程序時,遺留的信號量會引起問題,而且信號量是限制資源,我們必須小心使用。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章