本篇是 Linux 下行程間通訊(IPC)系列的第一篇文章。這個系列將使用 C 語言程式碼示例來闡明以下 IPC 機制:
在聚焦上面提到的共享檔案和共享記憶體這兩個機制之前,這篇文章將帶你回顧一些核心的概念。
核心概念
行程是執行著的程式,每個行程都有著它自己的地址空間,這些空間由行程被允許訪問的記憶體地址組成。行程有一個或多個執行執行緒,而執行緒是一系列執行指令的集合:單執行緒行程就只有一個執行緒,而多執行緒的行程則有多個執行緒。一個行程中的執行緒共享各種資源,特別是地址空間。另外,一個行程中的執行緒可以直接透過共享記憶體來進行通訊,儘管某些現代語言(例如 Go)鼓勵一種更有序的方式,例如使用執行緒安全的通道。當然對於不同的行程,預設情況下,它們不能共享記憶體。
有多種方法啟動之後要進行通訊的行程,下麵所舉的例子中主要使用了下麵的兩種方法:
fork
,以此生髮另一個行程(子行程)。第一個例子採用了上面使用終端的方法。這些程式碼示例的 ZIP 壓縮包可以從我的網站下載到。
共享檔案
程式員對檔案訪問應該都已經很熟識了,包括許多坑(不存在的檔案、檔案許可權損壞等等),這些問題困擾著程式對檔案的使用。儘管如此,共享檔案可能是最為基礎的 IPC 機制了。考慮一下下麵這樣一個相對簡單的例子,其中一個行程(生產者 producer
)建立和寫入一個檔案,然後另一個行程(消費者 consumer
)從這個相同的檔案中進行讀取:
-
writes +-----------+ reads
-
producer-------->| disk file |consumer
-
+-----------+
在使用這個 IPC 機制時最明顯的挑戰是競爭條件可能會發生:生產者和消費者可能恰好在同一時間訪問該檔案,從而使得輸出結果不確定。為了避免競爭條件的發生,該檔案在處於讀或寫狀態時必須以某種方式處於被鎖狀態,從而阻止在寫操作執行時和其他操作的衝突。在標準系統庫中與鎖相關的 API 可以被總結如下:
共享鎖可以提升效率。假如一個行程只是讀入一個檔案的內容,而不去改變它的內容,就沒有什麼原因阻止其他行程來做同樣的事。但如果需要寫入內容,則很顯然需要檔案有排斥鎖。
標準的 I/O 庫中包含一個名為 fcntl
的實用函式,它可以被用來檢查或者操作一個檔案上的排斥鎖和共享鎖。該函式透過一個檔案描述符(一個在行程中的非負整數值)來標記一個檔案(在不同的行程中不同的檔案描述符可能標記同一個物理檔案)。對於檔案的鎖定, Linux 提供了名為 flock
的庫函式,它是 fcntl
的一個精簡包裝。第一個例子中使用 fcntl
函式來暴露這些 API 細節。
示例 1. 生產者程式
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <fcntl.h>
-
#include <unistd.h>
-
-
#define FileName "data.dat"
-
-
void report_and_exit(const char* msg) {
-
[perror][4](msg);
-
[exit][5](-1); /* EXIT_FAILURE */
-
}
-
-
int main() {
-
struct flock lock;
-
lock.l_type = F_WRLCK; /* read/write (exclusive) lock */
-
lock.l_whence = SEEK_SET; /* base for seek offsets */
-
lock.l_start = 0; /* 1st byte in file */
-
lock.l_len = 0; /* 0 here means 'until EOF' */
-
lock.l_pid = getpid(); /* process id */
-
-
int fd; /* file descriptor to identify a file within a process */
-
if ((fd = open(FileName, O_RDONLY)) < 0) /* -1 signals an error */
-
report_and_exit("open to read failed...");
-
-
/* If the file is write-locked, we can't continue. */
-
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
-
if (lock.l_type != F_UNLCK)
-
report_and_exit("file is still write locked...");
-
-
lock.l_type = F_RDLCK; /* prevents any writing during the reading */
-
if (fcntl(fd, F_SETLK, &lock) < 0)
-
report_and_exit("can't get a read-only lock...");
-
-
/* Read the bytes (they happen to be ASCII codes) one at a time. */
-
int c; /* buffer for read bytes */
-
while (read(fd, &c, 1) > 0) /* 0 signals EOF */
-
write(STDOUT_FILENO, &c, 1); /* write one byte to the standard output */
-
-
/* Release the lock explicitly. */
-
lock.l_type = F_UNLCK;
-
if (fcntl(fd, F_SETLK, &lock) < 0)
-
report_and_exit("explicit unlocking failed...");
-
-
close(fd);
-
return 0;
-
}
上面生產者程式的主要步驟可以總結如下:
struct flock
的變數,它代表一個鎖,並對它的 5 個域做了初始化。第一個初始化
-
lock.l_type = F_WRLCK; /* exclusive lock */
使得這個鎖為排斥鎖(read-write)而不是一個共享鎖(read-only)。假如生產者獲得了這個鎖,則其他的行程將不能夠對檔案做讀或者寫操作,直到生產者釋放了這個鎖,或者顯式地呼叫 fcntl
,又或者隱式地關閉這個檔案。(當行程終止時,所有被它開啟的檔案都會被自動關閉,從而釋放了鎖)
fcntl
-
if (fcntl(fd, F_SETLK, &lock) < 0)
嘗試排斥性地將檔案鎖住,並檢查呼叫是否成功。一般來說, fcntl
函式傳回 -1
(因此小於 0)意味著失敗。第二個引數 F_SETLK
意味著 fcntl
的呼叫不是堵塞的;函式立即做傳回,要麼獲得鎖,要麼顯示失敗了。假如替換地使用 F_SETLKW
(末尾的 W
代指等待),那麼對 fcntl
的呼叫將是阻塞的,直到有可能獲得鎖的時候。在呼叫 fcntl
函式時,它的第一個引數 fd
指的是檔案描述符,第二個引數指定了將要採取的動作(在這個例子中,F_SETLK
指代設定鎖),第三個引數為鎖結構的地址(在本例中,指的是 &lock;
)。
l_type
域為 unlock
值:
-
lock.l_type = F_UNLCK;
並呼叫 fcntl
來執行解鎖操作。最後程式關閉了檔案並退出。
示例 2. 消費者程式
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <fcntl.h>
-
#include <unistd.h>
-
-
#define FileName "data.dat"
-
-
void report_and_exit(const char* msg) {
-
[perror][4](msg);
-
[exit][5](-1); /* EXIT_FAILURE */
-
}
-
-
int main() {
-
struct flock lock;
-
lock.l_type = F_WRLCK; /* read/write (exclusive) lock */
-
lock.l_whence = SEEK_SET; /* base for seek offsets */
-
lock.l_start = 0; /* 1st byte in file */
-
lock.l_len = 0; /* 0 here means 'until EOF' */
-
lock.l_pid = getpid(); /* process id */
-
-
int fd; /* file descriptor to identify a file within a process */
-
if ((fd = open(FileName, O_RDONLY)) < 0) /* -1 signals an error */
-
report_and_exit("open to read failed...");
-
-
/* If the file is write-locked, we can't continue. */
-
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
-
if (lock.l_type != F_UNLCK)
-
report_and_exit("file is still write locked...");
-
-
lock.l_type = F_RDLCK; /* prevents any writing during the reading */
-
if (fcntl(fd, F_SETLK, &lock) < 0)
-
report_and_exit("can't get a read-only lock...");
-
-
/* Read the bytes (they happen to be ASCII codes) one at a time. */
-
int c; /* buffer for read bytes */
-
while (read(fd, &c, 1) > 0) /* 0 signals EOF */
-
write(STDOUT_FILENO, &c, 1); /* write one byte to the standard output */
-
-
/* Release the lock explicitly. */
-
lock.l_type = F_UNLCK;
-
if (fcntl(fd, F_SETLK, &lock) < 0)
-
report_and_exit("explicit unlocking failed...");
-
-
close(fd);
-
return 0;
-
}
相比於鎖的 API,消費者程式會相對複雜一點兒。特別的,消費者程式首先檢查檔案是否被排斥性的被鎖,然後才嘗試去獲得一個共享鎖。相關的程式碼為:
-
lock.l_type = F_WRLCK;
-
...
-
fcntl(fd, F_GETLK, &lock); /* sets lock.l_type to F_UNLCK if no write lock */
-
if (lock.l_type != F_UNLCK)
-
report_and_exit("file is still write locked...");
在 fcntl
呼叫中的 F_GETLK
操作指定檢查一個鎖,在本例中,上面程式碼的宣告中給了一個 F_WRLCK
的排斥鎖。假如特指的鎖不存在,那麼 fcntl
呼叫將會自動地改變鎖型別域為 F_UNLCK
以此來顯示當前的狀態。假如檔案是排斥性地被鎖,那麼消費者將會終止。(一個更健壯的程式版本或許應該讓消費者睡會兒,然後再嘗試幾次。)
假如當前檔案沒有被鎖,那麼消費者將嘗試獲取一個共享(read-only)鎖(F_RDLCK
)。為了縮短程式,fcntl
中的 F_GETLK
呼叫可以丟棄,因為假如其他行程已經保有一個讀寫鎖,F_RDLCK
的呼叫就可能會失敗。重新呼叫一個只讀鎖能夠阻止其他行程向檔案進行寫的操作,但可以允許其他行程對檔案進行讀取。簡而言之,共享鎖可以被多個行程所保有。在獲取了一個共享鎖後,消費者程式將立即從檔案中讀取位元組資料,然後在標準輸出中列印這些位元組的內容,接著釋放鎖,關閉檔案並終止。
下麵的 %
為命令列提示符,下麵展示的是從相同終端開啟這兩個程式的輸出:
-
% ./producer
-
Process 29255 has written to data file...
-
-
% ./consumer
-
Now is the winter of our discontent
-
Made glorious summer by this sun of York
在本次的程式碼示例中,透過 IPC 傳輸的資料是文字:它們來自莎士比亞的戲劇《理查三世》中的兩行臺詞。然而,共享檔案的內容還可以是紛繁複雜的,任意的位元組資料(例如一個電影)都可以,這使得檔案共享變成了一個非常靈活的 IPC 機制。但它的缺點是檔案獲取速度較慢,因為檔案的獲取涉及到讀或者寫。同往常一樣,程式設計總是伴隨著折中。下麵的例子將透過共享記憶體來做 IPC,而不是透過共享檔案,在效能上相應的有極大的提升。
共享記憶體
對於共享記憶體,Linux 系統提供了兩類不同的 API:傳統的 System V API 和更新一點的 POSIX API。在單個應用中,這些 API 不能混用。但是,POSIX 方式的一個壞處是它的特性仍在發展中,並且依賴於安裝的核心版本,這非常影響程式碼的可移植性。例如,預設情況下,POSIX API 用記憶體對映檔案來實現共享記憶體:對於一個共享的記憶體段,系統為相應的內容維護一個備份檔案。在 POSIX 規範下共享記憶體可以被配置為不需要備份檔案,但這可能會影響可移植性。我的例子中使用的是帶有備份檔案的 POSIX API,這既結合了記憶體獲取的速度優勢,又獲得了檔案儲存的永續性。
下麵的共享記憶體例子中包含兩個程式,分別名為 memwriter
和 memreader
,並使用訊號量來調整它們對共享記憶體的獲取。在任何時候當共享記憶體進入一個寫入者場景時,無論是多行程還是多執行緒,都有遇到基於記憶體的競爭條件的風險,所以,需要引入訊號量來協調(同步)對共享記憶體的獲取。
memwriter
程式應當在它自己所處的終端首先啟動,然後 memreader
程式才可以在它自己所處的終端啟動(在接著的十幾秒內)。memreader
的輸出如下:
-
This is the way the world ends...
在每個源程式的最上方註釋部分都解釋了在編譯它們時需要新增的連結引數。
首先讓我們複習一下訊號量是如何作為一個同步機制工作的。一般的訊號量也被叫做一個計數訊號量,因為帶有一個可以增加的值(通常初始化為 0)。考慮一家租用腳踏車的商店,在它的庫存中有 100 輛腳踏車,還有一個供職員用於租賃的程式。每當一輛腳踏車被租出去,訊號量就增加 1;當一輛腳踏車被還回來,訊號量就減 1。在訊號量的值為 100 之前都還可以進行租賃業務,但如果等於 100 時,就必須停止業務,直到至少有一輛腳踏車被還回來,從而訊號量減為 99。
二元訊號量是一個特例,它只有兩個值:0 和 1。在這種情況下,訊號量的表現為互斥量(一個互斥的構造)。下麵的共享記憶體示例將把訊號量用作互斥量。當訊號量的值為 0 時,只有 memwriter
可以獲取共享記憶體,在寫操作完成後,這個行程將增加訊號量的值,從而允許 memreader
來讀取共享記憶體。
示例 3. memwriter 行程的源程式
-
/** Compilation: gcc -o memwriter memwriter.c -lrt -lpthread **/
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <sys/mman.h>
-
#include <sys/stat.h>
-
#include <fcntl.h>
-
#include <unistd.h>
-
#include <semaphore.h>
-
#include <string.h>
-
#include "shmem.h"
-
-
void report_and_exit(const char* msg) {
-
[perror][4](msg);
-
[exit][5](-1);
-
}
-
-
int main() {
-
int fd = shm_open(BackingFile, /* name from smem.h */
-
O_RDWR | O_CREAT, /* read/write, create if needed */
-
AccessPerms); /* access permissions (0644) */
-
if (fd < 0) report_and_exit("Can't open shared mem segment...");
-
-
ftruncate(fd, ByteSize); /* get the bytes */
-
-
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
-
ByteSize, /* how many bytes */
-
PROT_READ | PROT_WRITE, /* access protections */
-
MAP_SHARED, /* mapping visible to other processes */
-
fd, /* file descriptor */
-
0); /* offset: start at 1st byte */
-
if ((caddr_t) -1 == memptr) report_and_exit("Can't get segment...");
-
-
[fprintf][7](stderr, "shared mem address: %p [0..%d]\n", memptr, ByteSize - 1);
-
[fprintf][7](stderr, "backing file: /dev/shm%s\n", BackingFile );
-
-
/* semahore code to lock the shared mem */
-
sem_t* semptr = sem_open(SemaphoreName, /* name */
-
O_CREAT, /* create the semaphore */
-
AccessPerms, /* protection perms */
-
0); /* initial value */
-
if (semptr == (void*) -1) report_and_exit("sem_open");
-
-
[strcpy][8](memptr, MemContents); /* copy some ASCII bytes to the segment */
-
-
/* increment the semaphore so that memreader can read */
-
if (sem_post(semptr) < 0) report_and_exit("sem_post");
-
-
sleep(12); /* give reader a chance */
-
-
/* clean up */
-
munmap(memptr, ByteSize); /* unmap the storage */
-
close(fd);
-
sem_close(semptr);
-
shm_unlink(BackingFile); /* unlink from the backing file */
-
return 0;
-
}
下麵是 memwriter
和 memreader
程式如何透過共享記憶體來通訊的一個總結:
memwriter
程式呼叫 shm_open
函式來得到作為系統協調共享記憶體的備份檔案的檔案描述符。此時,並沒有記憶體被分配。接下來呼叫的是令人誤解的名為 ftruncate
的函式
-
ftruncate(fd, ByteSize); /* get the bytes */
它將分配 ByteSize
位元組的記憶體,在該情況下,一般為大小適中的 512 位元組。memwriter
和 memreader
程式都只從共享記憶體中獲取資料,而不是從備份檔案。系統將負責共享記憶體和備份檔案之間資料的同步。
memwriter
呼叫 mmap
函式:
-
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
-
ByteSize, /* how many bytes */
-
PROT_READ | PROT_WRITE, /* access protections */
-
MAP_SHARED, /* mapping visible to other processes */
-
fd, /* file descriptor */
-
0); /* offset: start at 1st byte */
來獲得共享記憶體的指標。(memreader
也做一次類似的呼叫。) 指標型別 caddr_t
以 c
開頭,它代表 calloc
,而這是動態初始化分配的記憶體為 0 的一個系統函式。memwriter
透過庫函式 strcpy
(字串複製)來獲取後續寫操作的 memptr
。
memwriter
已經準備好進行寫操作了,但首先它要建立一個訊號量來確保共享記憶體的排斥性。假如 memwriter
正在執行寫操作而同時 memreader
在執行讀操作,則有可能出現競爭條件。假如呼叫 sem_open
成功了:
-
sem_t* semptr = sem_open(SemaphoreName, /* name */
-
O_CREAT, /* create the semaphore */
-
AccessPerms, /* protection perms */
-
0); /* initial value */
那麼,接著寫操作便可以執行。上面的 SemaphoreName
(任意一個唯一的非空名稱)用來在 memwriter
和 memreader
識別訊號量。初始值 0 將會傳遞給訊號量的建立者,在這個例子中指的是 memwriter
賦予它執行寫操作的權利。
memwriter* 透過呼叫
sem_post` 函式將訊號量的值增加到 1:
-
if (sem_post(semptr) < 0) ..
增加訊號了將釋放互斥鎖,使得 memreader
可以執行它的讀操作。為了更好地測量,memwriter
也將從它自己的地址空間中取消對映,
-
munmap(memptr, ByteSize); /* unmap the storage *
這將使得 memwriter
不能進一步地訪問共享記憶體。
示例 4. memreader 行程的原始碼
-
/** Compilation: gcc -o memreader memreader.c -lrt -lpthread **/
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <sys/mman.h>
-
#include <sys/stat.h>
-
#include <fcntl.h>
-
#include <unistd.h>
-
#include <semaphore.h>
-
#include <string.h>
-
#include "shmem.h"
-
-
void report_and_exit(const char* msg) {
-
[perror][4](msg);
-
[exit][5](-1);
-
}
-
-
int main() {
-
int fd = shm_open(BackingFile, O_RDWR, AccessPerms); /* empty to begin */
-
if (fd < 0) report_and_exit("Can't get file descriptor...");
-
-
/* get a pointer to memory */
-
caddr_t memptr = mmap(NULL, /* let system pick where to put segment */
-
ByteSize, /* how many bytes */
-
PROT_READ | PROT_WRITE, /* access protections */
-
MAP_SHARED, /* mapping visible to other processes */
-
fd, /* file descriptor */
-
0); /* offset: start at 1st byte */
-
if ((caddr_t) -1 == memptr) report_and_exit("Can't access segment...");
-
-
/* create a semaphore for mutual exclusion */
-
sem_t* semptr = sem_open(SemaphoreName, /* name */
-
O_CREAT, /* create the semaphore */
-
AccessPerms, /* protection perms */
-
0); /* initial value */
-
if (semptr == (void*) -1) report_and_exit("sem_open");
-
-
/* use semaphore as a mutex (lock) by waiting for writer to increment it */
-
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
-
int i;
-
for (i = 0; i < [strlen][6](MemContents); i++)
-
write(STDOUT_FILENO, memptr + i, 1); /* one byte at a time */
-
sem_post(semptr);
-
}
-
-
/* cleanup */
-
munmap(memptr, ByteSize);
-
close(fd);
-
sem_close(semptr);
-
unlink(BackingFile);
-
return 0;
-
}
memwriter
和 memreader
程式中,共享記憶體的主要著重點都在 shm_open
和 mmap
函式上:在成功時,第一個呼叫傳回一個備份檔案的檔案描述符,而第二個呼叫則使用這個檔案描述符從共享記憶體段中獲取一個指標。它們對 shm_open
的呼叫都很相似,除了 memwriter
程式建立共享記憶體,而 `memreader 只獲取這個已經建立的記憶體:
-
int fd = shm_open(BackingFile, O_RDWR | O_CREAT, AccessPerms); /* memwriter */
-
int fd = shm_open(BackingFile, O_RDWR, AccessPerms); /* memreader */
有了檔案描述符,接著對 mmap
的呼叫就是類似的了:
-
caddr_t memptr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
mmap
的第一個引數為 NULL
,這意味著讓系統自己決定在虛擬記憶體地址的哪個地方分配記憶體,當然也可以指定一個地址(但很有技巧性)。MAP_SHARED
標志著被分配的記憶體在行程中是共享的,最後一個引數(在這個例子中為 0 ) 意味著共享記憶體的偏移量應該為第一個位元組。size
引數特別指定了將要分配的位元組數目(在這個例子中是 512);另外的保護引數(AccessPerms
)暗示著共享記憶體是可讀可寫的。
當 memwriter
程式執行成功後,系統將建立並維護備份檔案,在我的系統中,該檔案為 /dev/shm/shMemEx
,其中的 shMemEx
是我為共享儲存命名的(在頭檔案 shmem.h
中給定)。在當前版本的 memwriter
和 memreader
程式中,下麵的陳述句
-
shm_unlink(BackingFile); /* removes backing file */
將會移除備份檔案。假如沒有 unlink
這個陳述句,則備份檔案在程式終止後仍然持久地儲存著。
memreader
和 memwriter
一樣,在呼叫 sem_open
函式時,透過訊號量的名字來獲取訊號量。但 memreader
隨後將進入等待狀態,直到 memwriter
將初始值為 0 的訊號量的值增加。
-
if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
一旦等待結束,memreader
將從共享記憶體中讀取 ASCII 資料,然後做些清理工作並終止。
共享記憶體 API 包括顯式地同步共享記憶體段和備份檔案。在這次的示例中,這些操作都被省略了,以免文章顯得雜亂,好讓我們專註於記憶體共享和訊號量的程式碼。
即便在訊號量程式碼被移除的情況下,memwriter
和 memreader
程式很大機率也能夠正常執行而不會引入競爭條件:memwriter
建立了共享記憶體段,然後立即向它寫入;memreader
不能訪問共享記憶體,直到共享記憶體段被建立好。然而,當一個寫操作處於混合狀態時,最佳實踐需要共享記憶體被同步。訊號量 API 足夠重要,值得在程式碼示例中著重強調。
總結
上面共享檔案和共享記憶體的例子展示了行程是怎樣透過共享儲存來進行通訊的,前者透過檔案而後者透過記憶體塊。這兩種方法的 API 相對來說都很直接。這兩種方法有什麼共同的缺點嗎?現代的應用經常需要處理流資料,而且是非常大規模的資料流。共享檔案或者共享記憶體的方法都不能很好地處理大規模的流資料。按照型別使用管道會更加合適一些。所以這個系列的第二部分將會介紹管道和訊息佇列,同樣的,我們將使用 C 語言寫的程式碼示例來輔助講解。
朋友會在“發現-看一看”看到你“在看”的內容