Linux 下的程式間通訊:共享儲存

Marty Kalin發表於2019-05-07

學習在 Linux 中程式是如何與其他程式進行同步的。

Filing papers and documents
Filing papers and documents

本篇是 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,又或者隱式地關閉這個檔案。(當程式終止時,所有被它開啟的檔案都會被自動關閉,從而釋放了鎖)

  • 上面的程式接著初始化其他的域。主要的效果是整個檔案都將被鎖上。但是,有關鎖的 API 允許特別指定的位元組被上鎖。例如,假如檔案包含多個文字記錄,則單個記錄(或者甚至一個記錄的一部分)可以被鎖,而其餘部分不被鎖。
  • 第一次呼叫 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,這既結合了記憶體獲取的速度優勢,又獲得了檔案儲存的永續性。

下面的共享記憶體例子中包含兩個程式,分別名為 memwritermemreader,並使用訊號量來調整它們對共享記憶體的獲取。在任何時候當共享記憶體進入一個寫入者場景時,無論是多程式還是多執行緒,都有遇到基於記憶體的競爭條件的風險,所以,需要引入訊號量來協調(同步)對共享記憶體的獲取。

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;
}
複製程式碼

下面是 memwritermemreader 程式如何通過共享記憶體來通訊的一個總結:

  • 上面展示的 memwriter 程式呼叫 shm_open 函式來得到作為系統協調共享記憶體的備份檔案的檔案描述符。此時,並沒有記憶體被分配。接下來呼叫的是令人誤解的名為 ftruncate 的函式
ftruncate(fd, ByteSize); /* get the bytes */
複製程式碼

它將分配 ByteSize 位元組的記憶體,在該情況下,一般為大小適中的 512 位元組。memwritermemreader 程式都只從共享記憶體中獲取資料,而不是從備份檔案。系統將負責共享記憶體和備份檔案之間資料的同步。

  • 接著 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_tc 開頭,它代表 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(任意一個唯一的非空名稱)用來在 memwritermemreader 識別訊號量。初始值 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;
}
複製程式碼

memwritermemreader 程式中,共享記憶體的主要著重點都在 shm_openmmap 函式上:在成功時,第一個呼叫返回一個備份檔案的檔案描述符,而第二個呼叫則使用這個檔案描述符從共享記憶體段中獲取一個指標。它們對 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 中給定)。在當前版本的 memwritermemreader 程式中,下面的語句

shm_unlink(BackingFile); /* removes backing file */
複製程式碼

將會移除備份檔案。假如沒有 unlink 這個語句,則備份檔案在程式終止後仍然持久地儲存著。

memreadermemwriter 一樣,在呼叫 sem_open 函式時,通過訊號量的名字來獲取訊號量。但 memreader 隨後將進入等待狀態,直到 memwriter 將初始值為 0 的訊號量的值增加。

if (!sem_wait(semptr)) { /* wait until semaphore != 0 */
複製程式碼

一旦等待結束,memreader 將從共享記憶體中讀取 ASCII 資料,然後做些清理工作並終止。

共享記憶體 API 包括顯式地同步共享記憶體段和備份檔案。在這次的示例中,這些操作都被省略了,以免文章顯得雜亂,好讓我們專注於記憶體共享和訊號量的程式碼。

即便在訊號量程式碼被移除的情況下,memwritermemreader 程式很大機率也能夠正常執行而不會引入競爭條件:memwriter 建立了共享記憶體段,然後立即向它寫入;memreader 不能訪問共享記憶體,直到共享記憶體段被建立好。然而,當一個寫操作處於混合狀態時,最佳實踐需要共享記憶體被同步。訊號量 API 足夠重要,值得在程式碼示例中著重強調。

總結

上面共享檔案和共享記憶體的例子展示了程式是怎樣通過共享儲存來進行通訊的,前者通過檔案而後者通過記憶體塊。這兩種方法的 API 相對來說都很直接。這兩種方法有什麼共同的缺點嗎?現代的應用經常需要處理流資料,而且是非常大規模的資料流。共享檔案或者共享記憶體的方法都不能很好地處理大規模的流資料。按照型別使用管道會更加合適一些。所以這個系列的第二部分將會介紹管道和訊息佇列,同樣的,我們將使用 C 語言寫的程式碼示例來輔助講解。


via: opensource.com/article/19/…

作者:Marty Kalin 選題:lujun9972 譯者:FSSlc 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

相關文章