第14章 訊號量,共用記憶體與訊息佇列
在這一章,我們將會討論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函數的簡化介面。
在刪除訊號量之前,使用參數調用的程式拷貝會等待其他的調用結束。如果訊號量沒有刪除,他就會繼續存在於系統中,儘管已經沒有程式再使用他。在實際的程式中,保證我們沒有遺留訊號是十分重要的。在我們下一次運行程式時,遺留的訊號量會引起問題,而且訊號量是限制資源,我們必須小心使用。