[單刷APUE系列]第十五章——程式間通訊

山河永寂發表於2019-05-10

引言

在前面章節中講解了程式的派生和常見呼叫,但是程式之間通訊的唯一途徑就是通過開啟的檔案,或者是使用程式之前訊號傳輸,由於這些技術的侷限性,Unix系統提供了一種程式間通訊模型(IPC)。
IPC是程式通訊各種方式的統稱,目前只有一些經典的IPC方式能做到移植使用:管道、FIFO、訊息佇列、訊號量、共享儲存。還有基於套接字技術的網路IPC。

管道

管道是很古老的程式間通訊機制了,基本所有的Unix系統或者非Unix系統都支援這種方式,管道有以下特性:

  1. 半雙工,也就是資料只能做到單向流動

  2. 管道只能在具有公共祖先的兩個進城之間使用。

雖然具有侷限性,但是由於它的可移植性,所以目前仍然是首選的程式間通訊技術,管道在shell中非常常見,我們常常使用以下命令

> command1 | command2 ... commandn

shell使用管道將前一個程式的標準輸出與後一條命令的標準輸入相連線。
開發者呼叫pipe函式建立管道

int pipe(int fildes[2]);

filedes引數包含了兩個檔案描述符,Unix手冊上這麼描述The first descriptor connects to the read end of the pipe; the second connects to the write end.,也就是說,filedes[0]是讀的一端,filedes[1]是寫入的一端,這就是一個半雙工的管道,然後可以通過fork的方式將檔案描述符分給父子程式,從而實現通訊。在fork以後,雙方都持有讀寫的埠,而父子程式可以關閉其中兩個,每個程式只持有一個,從而做到真正的管道。管道有以下特點:

  1. 讀一個寫口關閉的管道時,當緩衝區所有資料都讀取後,會返回0,代表檔案結束。

  2. 寫一個讀口關閉的管道時,會產生SIGPIPE訊號,如果忽略該訊號或者捕獲該訊號並且從訊號處理函式返回,則write返回-1,errno設定為EPIPE。

我們實際上可以將管道理解為一塊核心維護的緩衝區,所以常量PIPE_BUF規定了管道的大小。

#include "include/apue.h"

int main(int argc, char *argv[])
{
    int n;
    int fd[2];
    pid_t pid;
    char line[MAXLINE];
    
    if (pipe(fd) < 0)
        err_sys("pipe error");
    if ((pid = fork()) < 0)
        err_sys("fork error");
    else if (pid == 0) {
        close(fd[0]);
        write(fd[1], "hello world
", 12);
    } else {
        close(fd[1]);
        n = read(fd[0], line, MAXLINE);
        write(STDOUT_FILENO, line, n);
    }
    exit(0);
}

上面就是非常簡單的父子程式使用同一個管道通訊的小例項。

popen和close函式

除了上面的pipe函式以外,更常見的做法是建立一個連線到另一個程式的管道,然後讀其輸出或者向其輸出端傳送資料,為此標準IO庫提供了popen和pclose函式

FILE *popen(const char *command, const char *mode);
int pclose(FILE *stream);

我們可以看到,這兩個函式是標準C庫提供的函式,並且返回的是FILE結構體的指標,並且這兩個函式的實現是:建立一個管道,fork一個子程式,關閉管道端,exec一個shell執行命令,然後等待命令終止。
popen函式的type引數指示返回的檔案指標連線的型別,如果type是”r”,則檔案指標連線到cmdstring的標準輸出。如果type是”w”,則檔案指標連線到cmdstring的標準輸入。
pclose函式關閉標準IO流,並且等待命令終止,最後返回shell的終止狀態

協同程式

在日常Unix使用中,通常我們會使用管道連線多個命令,當一個程式標準輸入輸出都連線到管道的時候,這就是協同程式。
下面是一個父子程式實現的協同程式

#include "include/apue.h"

int main(int argc, char *argv[])
{
    int n, int1, int2;
    char line[MAXLINE];
    
    while ((n = read(STDIN_FILENO, line, MAXLINE)) > 0) {
        line[n] = 0;
        if (sscanf(line, "%d%d", &int1, &int2) == 2) {
            sprintf(line, "%d
", int1 + int2);
            n = strlen(line);
            if (write(STDOUT_FILENO, line, n) != n)
                err_sys("write error");
        } else {
            if (write(STDOUT_FILENO, "invalid args
", 13) != 13)
                err_sys("write error");
        }
    }
    exit(0);
}

上面的程式碼非常簡單,就是標準輸入讀取,計算後輸出到標準輸出,將其編譯為add2程式。

#include "include/apue.h"

static void sig_pipe(int);

int main(int argc, char *argv[])
{
    int n, fd1[2], fd2[2];
    pid_t pid;
    char line[MAXLINE];
    
    if (signal(SIGPIPE, sig_pipe) == SIG_ERR)
        err_sys("signal error");
        
    if (pipe(fd1) < 0 || pipe(fd2) < 0)
        err_sys("pipe error");
    
    if ((pid = fork()) < 0)
        err_sys("fork error");
    else if (pid > 0) {
        close(fd1[0]);
        close(fd2[1]);
        
        while (fgets(line, MAXLINE, stdin) != NULL) {
            n = strlen(line);
            if (write(fd1[1], line, n) != n)
                err_sys("write error to pipe");
            if ((n = read(fd2[0], line, MAXLINE)) < 0)
                err_sys("read error from pipe");
            if (n == 0) {
                err_msg("child closed pipe");
                break;
            }
            line[n] = 0;
            if (fputs(line, stdout) == EOF)
                err_sys("fputs error");
        }
        if (ferror(stdin))
            err_sys("fgets error on stdin");
        exit(0);
    } else {
        close(fd1[1]);
        close(fd2[0]);
        if (fd1[0] != STDIN_FILENO) {
            if (dup2(fd1[0], STDIN_FILENO) != STDIN_FILENO)
                err_sys("dup2 error to stdin");
            close(fd1[0]);
        }
        if (fd2[1] != STDOUT_FILENO) {
            if (dup2(fd2[1], STDOUT_FILENO) != STDOUT_FILENO)
                err_sys("dup2 error to stdout");
            close(fd2[1]);
        }
        if (execl("./add2", "add2", (char *)0) < 0)
            err_sys("execl error");
    }
    exit(0);
}

程式建立了兩個管道,父子程式各自關閉不需要使用的管道。然後使用dup2函式將其移到標準輸入輸出,最後呼叫execl。

FIFO

FIFO就是First In First Out先進先出佇列,也被稱為命名管道。未命名的管道只能在兩個相關程式使用,而命名管道就能全域性使用。
建立一個FIFO就等同於建立一個檔案,在前面的時候就講過,FIFO就是一種檔案,並且在stat檔案結構中就有st_mode成員編碼可以知道是否是FIFO。

int mkfifo(const char *path, mode_t mode);

還有一個mkfifoat函式在某些系統中是不可用的,其實就是一個檔案描述符版本的mkfifo函式,基本一樣,其中mode引數和open函式中mode引數相同。新的FIFO使用者和組的所有權規則和前面章節中講述的一樣。
當建立完FIFO檔案後,需要使用open函式開啟,因為這確實是一個檔案,幾乎所有的正常檔案IO函式都能操作FIFO。
非阻塞標誌(O_NONBLOCK)對FIFO會有以下影響:

  1. 未指定標誌的時候,只讀的open會一直阻塞到有程式寫開啟,只寫open會阻塞到有程式讀開啟。

  2. 指定了標誌,則只讀open立刻返回。但是如果沒有程式為只讀開啟,只寫的open會出錯。

和管道類似,如果write一個沒有程式讀開啟的FIFO則會產生SIGPIPE訊號。如果讀完所有資料,read函式會返回檔案結束。
由於這是一個檔案,所以多個程式讀寫是非常正常的事情,所以為了保證讀寫安全,原子寫操作是必須要考慮的。FIFO有以下用途:

  1. shell命令使用FIFO將資料從一條管道傳輸到另一條管道不需要建立中間檔案

  2. C/S架構中,FIFO用作中間點,在客戶伺服器之間傳輸資料

這裡就不再講例項了。需要的朋友自行尋找程式碼。

XSI IPC

在IPC中有三種被稱為XSI IPC,他們有很多共同點。這裡先講解共同點。

識別符號和鍵

XSI IPC在核心中存在著IPC結構,它們都用一個非負整數作為識別符號,這點很像檔案描述符,但是檔案描述符永遠是當前最小的開始,比如,第一個檔案描述符必然是從3開始,然後這個檔案描述符刪除後,再次開啟一個檔案,檔案描述符仍然是3,而IPC結構則不會減少,會變成4,然後不斷增加直到整數的最大值,然後又迴轉到0。
識別符號是IPC結構的內部名稱,為了能全域性使用,需要有一個鍵作為外部名稱,無論何時建立IPC結構,都應當指定一個鍵名,這個鍵的資料結構是基本系統資料型別key_t。並且有很多種方法使客戶程式和伺服器程式在同一個IPC結構匯聚

  1. 伺服器程式指定IPC_PRIVATE鍵建立一個新的IPC結構。返回的識別符號被存放在一個檔案中,客戶端程式讀取這個檔案來參與IPC結構。

  2. 在一個公用標頭檔案中定義一個統一的識別符號,然後服務端根據這個識別符號建立新的IPC結構,但是很有可能導致衝突

  3. 客戶端和服務端程式認同同一個路徑名和專案ID。接著呼叫函式ftok將這兩個值變為一個鍵,然後在建立一個IPC結構

key_t ftok(const char *path, int id);

path引數必須是一個現有的檔案,當產生鍵的時候,只會使用id引數低八位。
ftok建立鍵一般依據如下行為:首先根據給定的path引數獲得對應檔案的stat結構中st_dev和st_ino欄位,然後將他們和專案ID組合。

許可權結構

每個IPC結構都關聯了一個ipc_perm結構,這個結構體關聯了許可權和所有者。

struct ipc_perm
{
        uid_t           uid;            /* [XSI] Owner`s user ID */
        gid_t           gid;            /* [XSI] Owner`s group ID */
        uid_t           cuid;           /* [XSI] Creator`s user ID */
        gid_t           cgid;           /* [XSI] Creator`s group ID */
        mode_t          mode;           /* [XSI] Read/write permission */
        unsigned short  _seq;           /* Reserved for internal use */
        key_t           _key;           /* Reserved for internal use */
};

上面是蘋果平臺的結構體內容,一般來說,都會有uid、gid、cuid、cgi、mode這些基本的內容,而其他則是各個實現自由發揮。在建立的時候,這些欄位都會被賦值,而後,如果想要修改這些欄位,則必須是保證具有root許可權或者是建立者。

優點和缺點

XSI IPC一個問題就是:IPC結構是在系統範圍內使用的,但是卻沒有引用計數。如果程式使用完但是沒有對其進行刪除就終止了,那就會導致IPC依然在系統中存在,而管道有引用計數,等最後一個引用管道的程式終止便會自動回收。FIFO就算沒有刪除,但是等最後一個引用FIFO的程式終止,裡面的資料已經被刪除了。
XSI IPC還有一個問題就是它不是檔案,我們不能使用ls和rm等檔案操作函式或者命令處理它們,它們也沒有檔案描述符,這就限制了它們的使用,並且如果需要使用還得攜帶一大堆額外的API。

訊息佇列

訊息佇列,正如其名稱一樣,是訊息的連結串列形式,它由核心儲存維護。並且和XSI IPC結構一樣,由訊息佇列識別符號標識。
msgget函式建立一個佇列或者開啟一個現有佇列,msgsnd將新資料新增到訊息末尾,每個訊息包含一個正的長整形欄位、一個非負的長度以及實際資料位元組數。msgrcv從佇列中獲得訊息。

struct __msqid_ds {
            struct __ipc_perm_new   msg_perm; /* [XSI] msg queue permissions */
        __int32_t       msg_first;      /* RESERVED: kernel use only */
        __int32_t       msg_last;       /* RESERVED: kernel use only */
        msglen_t        msg_cbytes;     /* # of bytes on the queue */
        msgqnum_t       msg_qnum;       /* [XSI] number of msgs on the queue */
        msglen_t        msg_qbytes;     /* [XSI] max bytes on the queue */
        pid_t           msg_lspid;      /* [XSI] pid of last msgsnd() */
        pid_t           msg_lrpid;      /* [XSI] pid of last msgrcv() */
        time_t          msg_stime;      /* [XSI] time of last msgsnd() */
        __int32_t       msg_pad1;       /* RESERVED: DO NOT USE */
        time_t          msg_rtime;      /* [XSI] time of last msgrcv() */
        __int32_t       msg_pad2;       /* RESERVED: DO NOT USE */
        time_t          msg_ctime;      /* [XSI] time of last msgctl() */
        __int32_t       msg_pad3;       /* RESERVED: DO NOT USE */
        __int32_t       msg_pad4[4];    /* RESERVED: DO NOT USE */
}

每個系統實現都會在SUS標準的基礎上增加自己私有的欄位,所以可能和上面的有所區別。這個結構體定義了佇列的當前狀態。

int msgget(key_t key, int flag);

msgget根據key獲得已有佇列或者新佇列。

int msgctl(int msqid, int cmd, struct msqid_ds *buf);

msgctl用於對佇列進行多種操作,其中,msqid則是訊息佇列ID,cmd引數是命令引數,可以取以下值:

  1. IPC_STAT 取佇列msqid_ds結構體,並且存放在buf引數指定的位置

  2. IPC_SET 將buf引數指定的結構體複製到這個佇列中的結構體,需要檢查root許可權或者建立者許可權

  3. IPC_RMID 從系統中刪除訊息佇列

int msgsnd(int msqid, const void *ptr, size_t nbytes, int flag);

這個函式很好理解,就是把ptr指標對應的訊息放入訊息佇列中,flag的值可以指定為IPC_NOWAIT,這點類似於檔案IO非阻塞標誌。

ssize_t msgrcv(int msqid, void *ptr, size_t nbytes, long type, int flag);

和msgsnd一樣,這個ptr引數指向一個長整形數,隨後跟隨的是儲存實際區域的緩衝區。nbytes指定資料緩衝區的長度。引數type則可以指定想要哪種訊息。

訊號量

訊號量是一個計數器,針對多個程式提供對共享資料物件的訪問。訊號量的使用主要是用來保護共享資源的,使得資源在一個時刻只會被一個程式(執行緒)擁有。
訊號量的使用如下:

  1. 測試控制該資源的訊號量

  2. 如果訊號量為正,則程式可以使用該資源,這種情況下,訊號量會減一,表示已經使用了一個資源。

  3. 如果訊號量為0,則程式進入休眠狀態,知道訊號量變為正,程式將會喚醒。

核心為每個訊號量集合維護著一個semid_ds結構體,根據每個系統實現不同會有不同的欄位,這裡就不列出了。當我們想要使用訊號量的時候,使用如下函式

int semget(key_t key, int nsems, int semflg);

我們知道,XSI IPC實際上具有其共性,所以如同訊息佇列一樣,這裡將key變換為識別符號的規則也是一樣的。

int semctl(int semid, int semnum, int cmd, ...);

就如同是前面的ioctl等函式一樣,這個函式也是用於控制訊號量,其中第四個引數是可選的,取決於cmd引數。

     IPC_STAT     Fetch the semaphore set`s struct semid_ds, storing it in the memory pointed to by arg.buf.

     IPC_SET      Changes the sem_perm.uid, sem_perm.gid, and sem_perm.mode members of the semaphore set`s struct semid_ds to match those of the struct pointed to
                  by arg.buf.  The calling process`s effective uid must match either sem_perm.uid or sem_perm.cuid, or it must have superuser privileges.

     IPC_RMID     Immediately removes the semaphore set from the system.  The calling process`s effective uid must equal the semaphore set`s sem_perm.uid or
                  sem_perm.cuid, or the process must have superuser privileges.

     GETVAL       Return the value of semaphore number semnum.

     SETVAL       Set the value of semaphore number semnum to arg.val.  Outstanding adjust on exit values for this semaphore in any process are cleared.

     GETPID       Return the pid of the last process to perform an operation on semaphore number semnum.

     GETNCNT      Return the number of processes waiting for semaphore number semnum`s value to become greater than its current value.

     GETZCNT      Return the number of processes waiting for semaphore number semnum`s value to become 0.

     GETALL       Fetch the value of all of the semaphores in the set into the array pointed to by arg.array.

     SETALL       Set the values of all of the semaphores in the set to the values in the array pointed to by arg.array.  Outstanding adjust on exit values for
                  all semaphores in this set, in any process are cleared.

上面就是cmd可選值,除了GETALL以外的所有命令,semctl都返回相應值,除此以外,還有個semop函式自動執行訊號量集合上的運算元組

int semop(int semid, struct sembuf *sops, size_t nsops);

sembuf引數是一個指標,指向了sembuf結構體,實際上這是一個陣列,

共享儲存

共享儲存是一項很有用的技術,它允許兩個或者更多的程式共享同一個給定儲存區,由於資料不需要複製,所以這是一種最快的IPC方式,共享儲存最重要的就是資源的競爭,所以訊號量一般用於共享儲存訪問。
在前面的章節中,我們看到了一種共享儲存的方式,就是記憶體對映技術,但是相比儲存對映,共享儲存不需要建立中間檔案。

int shmget(key_t key, size_t size, int shmflg);
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

就如同是其他的XSI IPC一樣,這裡也是一個建立函式,通過這個函式獲得儲存識別符號。然後就是shmctl函式,具體的使用直接查手冊,基本上都是差不多的。
當建立完成共享儲存段後,使用shmat將其連結到自己的地址空間中。

void *shmat(int shmid, const void *shmaddr, int shmflg);
int shmdt(const void *shmaddr);

實際上,由於架構的不同和平臺不同,為了保持可移植性,我們不應當去指定共享儲存段的地址,而是由系統自行分配,最終返回的就是共享儲存段的地址,當操作完成後,我們需要使用shmdt函式將其分離。

POSIX訊號量

POSIX訊號量是三種IPC機制之一,相比XSI標準規定的IPC方式,POSIX的方式更加簡潔好用。
POSIX訊號量有兩種型別:命名的和未命名的,他們兩者的區別就像是命名管道和未命名管道一樣,有了識別符號的訊號量就能全域性使用,而沒有識別符號的訊號量只能在同一記憶體區域內使用。

sem_t *sem_open(const char *name, int oflag, ...);
The parameters "mode_t mode" and "unsigned int value" are optional.

The value of oflag is formed by or`ing the following values:

    O_CREAT         create the semaphore if it does not exist
    O_EXCL          error if create and semaphore exists

實際上這個函式技能建立也能使用現有訊號量,上面的是Unix手冊節選的內容,應該算是相當清楚了,當函式返回的時候,sem_open會返回一個指標,讓我們傳遞到其他的函式上,等一切結束,使用sem_close關閉訊號量指標。

int sem_close(sem_t *sem);

當然,也能使用sem_unlink函式銷燬一個命名訊號量。

int sem_unlink(const char *name);

在這裡我們能看出來,POSIX訊號量的命名訊號量和檔案很像。不像XSI訊號量,POSIX訊號量的值只能通過一個函式呼叫來調節,也就是sem_wait函式

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);

後一個是sem_wait函式的嘗試版本,還有一個是超時版本,但是蘋果下好像不存在,建議少使用。
還可以呼叫sem_post函式使訊號量值+1,這個解鎖一個二進位制訊號量或者釋放一個技術訊號量資源的過程是很像的。

int sem_post(sem_t *sem);

對於未命名訊號量,只能使用在單程式或者單執行緒中,是非常容易的,我們只需要使用sem_init函式建立,然後使用sem_destroy函式銷燬。這裡就不在贅述。

相關文章