歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
Linux教程網 >> Linux基礎 >> Linux教程 >> Linux IPC之管道和FIFO

Linux IPC之管道和FIFO

日期:2017/2/28 13:49:16   编辑:Linux教程

導言:管道是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個字節的語義

Copyright © Linux教程網 All Rights Reserved