首頁 > 軟體

Linux IPC之管道和FIFO

2020-06-16 17:34:01

導言:管道是UNIX系統上最古老的IPC方法,管道提供了一種優雅的解決方案:給定兩個執行不同程式的進程,在shell中如何讓一個進程的輸出作為另一個進程的輸入?管道可以用來在相關(一個共同的祖先進程建立管道)進程之間傳遞資料。FIFO是管道概念的一個變體,它們之間的一個重要差別在於FIFO可以用於任意進程間的通訊。

概述

每個shell使用者都對在命令中使用管道比較熟悉,例如,統計一個目錄中檔案的數目:

ls | wc -l

解釋:為了執行上面的命令,shell建立了兩個進程來分別執行ls和wc(通過使用fork()和exec()來完成)。如下圖所示:

管道的特徵

  1. 一個管道是一個位元組流(無邊界,順序的)
    意味著在使用管道時,是不存在訊息或訊息邊界的概念,從管道中讀取資料的進程可以讀取任意大小的資料塊,而不管寫入進程寫入管道的資料塊的大小是什麼。此外,通過管道傳遞的資料是順序的,從管道中讀取出來的位元組順序與它們被寫入管道的順序是完全一樣的,在管道中無法使用lseek()來隨機地存取資料。

    如果需要在管道中實現離散訊息的傳遞,就必須要在應用程式中完成這些工作,但是對於此類需求,最好使用其他IPC機制,比如,訊息佇列,資料包socket。

  2. 從管道中讀取資料(讀空管道將阻塞,讀端遇0為關閉)
    試圖從一個當前為空的管道中讀取資料將會被阻塞直到至少有一個位元組被寫入到管道中為止。如果管道的寫入端被關閉了,那麼從管道中讀取資料的進程在讀完管道中剩餘的所有資料之後將會看到檔案結束(即,read()返回0)。

  3. 管道是單向的
    在管道中資料的傳遞方向是單向的,管道的一端用於寫入,另一端用於讀取。

  4. 可以確保寫入不超過PIPE_BUF位元組的操作是原子的
    如果多個進程寫入同一個管道,那麼如果每個進程在一個時刻寫入的資料量不超過PIPE_BUF位元組,那麼就可以確保寫入的資料不會發生相互交叉的情況。SUSv3要求PIPE_BUF至少為_POSIX_PIPE_BUF(512),不同的UNIX實現上的PIPE_BUF不同,在Linux上,PIPE_BUF的值為4096

  5. 管道的容量是有限的
    管道其實是一個在核心中維護的緩衝器,這個緩衝器的儲存能力是有限的。一旦管道被填滿後,後續向管道的寫入操作就會被阻塞,直到讀者從管道中移除了一些資料為止。

SUSv3並沒有規定管道的儲存能力,從Linux2.6.11起,管道的儲存能力是65536位元組(64KB),其他UNIX實現上的管道的儲存能力可能是不同的。一般來講,一個應用程式無需知道管道的實際儲存能力,如果需要防止寫者進程阻塞,那麼管道中讀取資料的進程應該被設計成以盡可能快的速度從管道中讀取資料。

在核心中針對管道使用較大的緩衝器的原因是:效率。每當寫者充滿管道時,核心必須要執行一個上下文切換以允許讀者被排程來消耗管道中的一些資料。使用較大的緩衝器意味著需要執行的上下文切換次數更少。
從Linux2.6.35開始就可以修改一個管道的儲存能力了。Linux特有的fcntl(fd, F_SETPIPE_SZ, size)呼叫會將fd參照的管道的儲存能力修改為至少size位元組。非特權進程可以將管道的儲存能力修改為範圍在系統的頁面大小到/proc/sys/fs/pipe-max-size中規定的值之內的任何一個值。pipe-max-size的預設值是1048576位元組(1MB)。fcntl(fd, F_GETPIPE_SZ)呼叫返回為管道分配的實際大小。

管道的用法

#include <unistd.h>
int pipe(int filedes[2]);

成功的pipe()呼叫會在陣列filedes中返回兩個開啟的檔案描述符:一個表示管道的讀取端(filedes[0]),另一個表示管道的寫入端(filedes[1])。與所有檔案描述符一樣,可以使用read()write()系統呼叫來在管道上執行I/O,管道上的read()呼叫會讀取的資料量為所請求的位元組數與管道中當前存在的位元組數兩者之間較小的那個,但當管道為空時阻塞。

ioctl(fd, FIONREAD, &cnt)呼叫返回檔案描述符fd所參照的管道或FIFO中未讀取的位元組數。其他一些實現也提供了這個特性,但SUSv3並沒有對此進行規定。

通常,使用管道讓兩個進程進程通訊,為了讓兩個進程通過管道進行連線,在呼叫完pipe()之後可以呼叫fork()。在fork()期間,子進程會繼承父進程的檔案描述符。雖然,父進程和子進程都可以從管道中讀取和寫入資料,但這種做法並不常見,因此,在fork()呼叫之後,其中一個進程應該立即關閉管道的寫入端的描述符,另一個進程應該關閉讀取端的描述符。

int filedes[2];

if (pipe(filedes) == -1)
    errExit("pipe");

switch(fork()) {
    case -1:
        errExit("fork");

    case 0:/* Child */
        // close unused write end
        if (close(filedes[1]) == -1)
            errExit("close");
        // Child now reads from pipe
        break;

    default:/* Parent */
        // close unused read end
        if (close(filedes[0]) == -1)
            errExit("close");
        // Parent now writes to pipe
        break;     
}

如果需要雙向通訊,則可以使用一種更加簡單的方法:建立兩個管道,在兩個進程之間傳送資料的兩個方向上各使用一個。(如果使用這種技術,需要考慮死鎖的問題,因為如果兩個進程都試圖從空管道中讀取資料或嘗試向已滿的管道中寫入資料就可能會發生死鎖。)

  1. 從2.6.27核心開始,Linux支援一個全新的非標準系統呼叫pipe2(),這個系統呼叫執行的任務和pipe()一樣,但支援額外的引數flags。
  2. 管道只能用於相關進程之間的通訊,有一種例外,通過UNIX domain socket將管道的檔案描述符傳遞給一個非相關的進程使用。

為什麼要關閉管道未使用的檔案描述符?

從管道中讀取資料的進程,會關閉其持有的管道的寫入描述符,這樣當其他進程完成輸出並關閉其寫入描述符之後,讀者就能夠看到檔案結束。如果讀取進程沒有關閉管道的寫入端,那麼在其他進程關閉了寫入描述符之後,讀者也不會看到檔案結束,即使它讀完了管道中所有資料。此時read()將會阻塞以等待資料,這是因為核心知道至少還存在一個管道的寫入描述符開啟著(讀取進程自己開啟了這個描述符)。

寫入進程關閉其持有的管道的讀取描述符是出於不同的原因。當一個進程試圖向一個管道中寫入資料但沒有任何進程擁有該管道的開啟著的讀取描述符時,核心會向寫入進程傳送一個SIGPIPE信號,在預設情況下,這個信號會殺死一個進程,但進程可以捕獲或忽略該信號,這樣就會導致管道上的write()操作因為EPIPE錯誤而失敗,收到SIGPIPE信號或得到EPIPE錯誤對於標示出管道的狀態是有用的。如果寫入進程沒有關閉管道的讀取端,那麼即使在其他進程已經關閉了管道的讀取端之後寫入進程仍然能夠向管道寫入資料,最後寫入進程會將資料充滿整個管道,後續的寫入請求會被永遠阻塞。

關閉未使用檔案描述符的最後一個原因,是只有當所有進程中所有參照一個管道的檔案描述符被關閉之後才會銷毀該管道,以及釋放該管道占用的資源以供其他進程複用,此時,管道中所有未讀取的資料都會丟失。

例子:在父進程和子進程之間使用管道通訊。
https://github.com/gerryyang/TLPI/blob/master/src/pipes/simple_pipe.c

FIFO(命名管道)

FIFO與管道類似,它們最大的差別是,FIFO在檔案系統中擁有一個名稱,並且其開啟方式與開啟一個普通檔案是一樣的,這樣就能夠將FIFO用於非相關進程之間的通訊。

# 使用mkfifo命令可以在shell中建立一個fifo
$ mkfifo [-m mode] pathname

mkfifo()函數建立一個名為pathname的全新FIFO。大多數UNIX實現提供了mkfifo(),它是構建於mknod()之上的一個庫函數。一旦FIFO被建立,任何進程都能夠開啟它,只要它能夠通過常規的檔案許可權檢測。

#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);

在大多數UNIX實現(包括Linux)上,當開啟一個FIFO時可以通過指定O_RDWR標記來繞開開啟FIFO時的阻塞行為,這樣open()就會立即返回,但??法使用返回的檔案描述符在FIFO上讀取和寫入資料。這種做法破壞了FIFO的I/O模型,SUSv3明確指出以O_RDWR標記開啟一個FIFO的結果是未知的,因此出於可移植性的原因,開發人員不應該使用這項技術。對於那些需要避免在開啟FIFO時發生阻塞地需求,open()O_NONBLOCK標記提供了一種標準化的方法來完成這個任務。

使用管道實現一個用戶端/伺服器應用程式

所有用戶端使用一個伺服器FIFO來向伺服器傳送請求,標頭檔案定義了眾所周知的名稱(/tmp/seqnum_sv),伺服器的FIFO將使用這個名稱。這個名稱是固定的,因此所有用戶端知道如何聯絡到伺服器。(在一個像/tmp這樣公共可寫的目錄中建立檔案可能會導致各種安全隱患,因此實際應用中的程式不應該使用這種目錄)

無法使用單個FIFO向所有用戶端傳送響應,因為多個用戶端在從FIFO中讀取資料時會相互競爭,這樣就可能出現各個用戶端讀取到了其他用戶端的響應訊息。因此,每個用戶端需要建立一個唯一的FIFO,伺服器使用這個FIFO來向用戶端傳遞響應。並且伺服器需要知道如何找到各個用戶端的FIFO。

解決這個問題的一種方法是,讓用戶端生成自己的FIFO路徑名,然後將路徑名作為請求訊息的一部分傳遞給伺服器。另一種方法是,用戶端和伺服器可以約定一個構建用戶端FIFO路徑名的規則,然後用戶端可以將構建自己的路徑名所需要的相關資訊作為請求的一部分傳送給伺服器。

記住管道和FIFO中的資料時位元組流,訊息之間是沒有邊界的。這意味著當多條訊息被傳遞到一個進程中時,傳送者和接收者必須要約定某種規則來分隔訊息。這可以使用多種方法:

  • 每條訊息使用諸如換行符之類的分割字元結束。
    特點:讀取訊息的進程在從FIFO中掃描資料時必須要逐個位元組地分析直到找到分隔符為止。

  • 在每條訊息中包含一個大小固定的頭,頭中包含一個表示訊息長度的欄位,該欄位指定了訊息中剩餘部分的長度。這樣讀取進程就需要首先從FIFO中讀取頭,然後使用頭中的長度欄位來確定需要讀取的訊息中剩餘部分的位元組數。
    特點:這種方法能夠高效地讀取任意大小的訊息

  • 使用固定長度的訊息,並讓伺服器總是讀取這個大小固定的訊息。
    特點:這種方法的優勢在於簡單性,但是它對訊息的大小設定了一個上限,意味著會浪費一些通道容量(因為需要對較短的訊息進行填充以滿足固定長度),此外,如果其中一個用戶端意外地或故意傳送了一條長度不對的訊息,那麼所有後續的訊息都會出現步調不一致的情況,並且在這種情況下伺服器是難以恢復的。

注意,不管使用這三種技術中的哪種,每條訊息的總長度必須要小於PIPE_BUF位元組,以防止核心對訊息進行拆分,從造成與其他寫者傳送的訊息錯亂的情況發生。

TODO

非阻塞I/O

當一個進程開啟一個FIFO的一端時,如果FIFO的另一端還沒有被開啟,那麼該進程會被阻塞,但有些時候阻塞並不是期望的行為,而這可以通過在呼叫open()時指定O_NONBLOCK標記來實現。

fd = open("filepath", O_RDONLY | O_NONBLOCK);
if (fd == -1) errExit("open");

如果FIFO的另一端已經被開啟,那麼O_NONBLOCKopen()呼叫不會產生任何影響。只有當FIFO的另一端還沒有被開啟的時候,O_NONBLOCK標記才會起作用,而具體產生的影響則依賴於開啟FIFO是用於讀取還是用於寫入的:

  • 如果是為了讀取,不管FIFO的寫入端當前是否已經被開啟,open()呼叫都會立即成功。
  • 如果是為了寫入,並且還沒有開啟FIFO的另一端來讀取資料,那麼open()呼叫會失敗,並將errno設定為ENXIO

開啟一個FIFO時使用O_NONBLOCK標記存在兩個目的:
1. 它允許單個進程開啟一個FIFO的兩端。
2. 它防止開啟兩個FIFO的進程之間產生死鎖。

在FIFO上呼叫open()的語意

管道和FIFO中read()和write()的語意

從一個包含p位元組的管道或FIFO中讀取n位元組的語意

向一個管道或FIFO寫入n個位元組的語意

本文永久更新連結地址http://www.linuxidc.com/Linux/2016-09/134993.htm


IT145.com E-mail:sddin#qq.com