問題和現有方法 Linux提供了popen和pclose函數 ,用於創建和關閉管道與另外一個進程進行通信。其接口如下: FILE *popen(const char *command, const char *mode); int pclose(FILE *stream); 遺憾的是,popen創建的管道只能是單向的--mode只能是 "r" 或 "w" 而不能是某種組合--用戶只能選擇要麼往裡寫,要麼從中讀,而不能同時在一個管道中進行讀寫。實際應用中,經常會有同時進行讀寫的要求,比如,我們可能希望把文本數據送往sort工具排序後再取回結果。此時popen就無法用上了。我們需要尋找其它的解決方案。 有一種解決方案是使用pipe函數 創建兩個單向管道。沒有錯誤檢測的代碼示意如下: int pipe_in[2], pipe_out[2]; pid_t pid; pipe(&pipe_in); // 創建父進程中用於讀取數據的管道 pipe(&pipe_out); // 創建父進程中用於寫入數據的管道 if ( (pid = fork()) == 0) { // 子進程 close(pipe_in[0]); // 關閉父進程的讀管道的子進程讀端 close(pipe_out[1]); // 關閉父進程的寫管道的子進程寫端 dup2(pipe_in[1], STDOUT_FILENO); // 復制父進程的讀管道到子進程的標准輸出 dup2(pipe_out[0], STDIN_FILENO); // 復制父進程的寫管道到子進程的標准輸入 close(pipe_in[1]); // 關閉已復制的讀管道 close(pipe_out[0]); // 關閉已復制的寫管道 /* 使用exec執行命令 */ } else { // 父進程 close(pipe_in[1]); // 關閉讀管道的寫端 close(pipe_out[0]); // 關閉寫管道的讀端 /* 現在可向pipe_out[1]中寫數據,並從pipe_in[0]中讀結果 */ close(pipe_out[1]); // 關閉寫管道 /* 讀取pipe_in[0]中的剩余數據 */ close(pipe_in[0]); // 關閉讀管道 /* 使用wait系列函數等待子進程退出並取得退出代碼 */ } 當然,這樣的代碼的可讀性(特別是加上錯誤處理代碼之後)比較差,也不容易封裝成類似於popen/pclose的函數,方便高層代碼使用。究其原因,是pipe函數返回的一對文件描述符只能從第一個中讀、第二個中寫(至少對於Linux是如此)。為了同時讀寫,就只能采取這麼累贅的兩個pipe調用、兩個文件描述符的形式了。
新方法 使用pipe就只能如此了。不過,Linux實現了一個源自BSD的socketpair調用 ,可以實現上述在同一個文件描述符中進行讀寫的功能(該調用目前也是POSIX規范的一部分 )。該系統調用能創建一對已連接的(UNIX族)無名socket。在Linux中,完全可以把這一對socket當成pipe返回的文件描述符一樣使用,唯一的區別就是這一對文件描述符中的任何一個都可讀和可寫。 這已經接近了我們想要的目標了。但仍然存在一個問題,阻礙我們使用socketpair實現一個管道與子進程通信:為了解決我前面的提出的使用sort的應用問題,我們需要關閉子進程的標准輸入通知子進程數據已經發送完畢,而後從子進程的標准輸出中讀取數據直到遇到EOF。使用兩個單向管道的話每個管道可以單獨關閉,因而不存在此問題;而在使用雙向管道時,如果不關閉管道就無法通知對端數據已經發送完畢,但關閉了管道又無法從中讀取結果數據。--這一問題不解決的話,使用socketpair的設想就變得毫無意義。 在查找和試驗之後,我發現shutdown調用 可解決此問題。畢竟socketpair產生的文件描述符是一對socket,socket上的標准操作都可以使用,其中也包括shutdown。--利用shutdown,可以實現一個半關閉操作,通知對端本進程不再發送數據,同時仍可以利用該文件描述符接收來自對端的數據。沒有錯誤檢測的代碼示意如下: int fd[2]; pid_t pid; socketpair(AF_UNIX, SOCKET_STREAM, 0, fd); // 創建管道 if ( (pid = fork()) == 0) { // 子進程 close(fd[0]); // 關閉管道的父進程端 dup2(fd[1], STDOUT_FILENO); // 復制管道的子進程端到標准輸出 dup2(fd[1], STDIN_FILENO); // 復制管道的子進程端到標准輸入 close(fd[1]); // 關閉已復制的讀管道 /* 使用exec執行命令 */ } else { // 父進程 close(fd[1]); // 關閉管道的子進程端 /* 現在可在fd[0]中讀寫數據 */ shutdown(fd[0], SHUT_WR); // 通知對端數據發送完畢 /* 讀取剩余數據 */ close(fd[0]); // 關閉管道 /* 使用wait系列函數等待子進程退出並取得退出代碼 */ } 很清楚,這比使用兩個單向管道的方案要簡潔不少。我將在此基礎上作進一步的封裝和改進。
封裝和實現 直接使用上面的方法,無論怎麼看,至少也是丑陋和不方便的。程序的維護者想看到的是程序的邏輯,而不是完成一件任務的各種各樣的繁瑣細節。我們需要一個好的封裝。 封裝可以使用C或者C++。此處,我按照UNIX的傳統,提供一個類似於POSIX標准中popen/pclose函數調用的C封裝,以保證最大程度的可用性。接口如下: FILE *dpopen(const char *command); int dpclose(FILE *stream); int dphalfclose(FILE *stream); 關於接口,以下幾點需要注意一下: 與pipe函數類似,dpopen返回的是文件結構的指針,而不是文件描述符。這意味著,我們可以直接使用fprintf之類的函數,文件緩沖區會緩存寫入管道的數據(除非使用setbuf函數關閉文件緩沖區),要保證數據確實寫入到管道中需要使用fflush函數。 由於dpopen返回的是可讀寫的管道,所以popen的第二個表示讀/寫的參數不再需要。 在雙向管道中我們需要通知對端寫數據已經結束,此項操作由dphalfclose函數來完成。 具體的實現請直接查看程序源代碼,其中有詳細的注釋和doxygen文檔注釋 。我只略作幾點說明: 本實現使用了一個鏈表來記錄所有dpopen打開的文件指針和子進程ID的對應關系,因此,在同時用dpopen打開的管道的多的時候,dpclose(需要搜索鏈表)的速度會稍慢一點。我認為在通常使用過程中這不會產生什麼問題。如果在某些特殊情況下這會是一個問題的話,可考慮更改dpopen的返回值類型和dpclose的傳入參數類型(不太方便使用,但實現簡單),或者使用哈希表/平衡樹來代替目前使用的鏈表以加速查找(接口不變,但實現較復雜)。 當編譯時在gcc中使用了"-pthread"命令行參數時,本實現會啟用POSIX線程支持,使用互斥量保護對鏈表的訪問。因此本實現可以安全地用於POSIX多線程環境之中。 與popen類似 ,dpopen會在fork產生的子進程中關閉以前用dpopen打開的管道。 如果傳給dpclose的參數不是以前用dpopen返回的非NULL值,當前實現除返回-1表示錯誤外,還會把errno設為EBADF。對於pclose而言,這種情況在POSIX規范中被視為不確定(unspecified)行為 。 實現中沒有使用任何平台相關特性,以方便移植到其它POSIX平台上。 下面的代碼展示了一個簡單例子,將多行文本送到sort中,然後取回結果、顯示出來: #include <stdio.h> #include <stdlib.h> #include "dpopen.h" #define MAXLINE 80 int main() { char line[MAXLINE]; FILE *fp; fp = dpopen("sort"); if (fp == NULL) { perror("dpopen error"); exit(1); } fprintf(fp, "orange\n"); fprintf(fp, "apple\n"); fprintf(fp, "pear\n"); if (dphalfclose(fp) < 0) { perror("dphalfclose error"); exit(1); } for (;;) { if (fgets(line, MAXLINE, fp) == NULL) break; fputs(line, stdout); } dpclose(fp); return 0; } 輸出結果為: apple orange pear
總結 本文闡述了一個使用socketpair系統調用在Linux上實現雙向進程通訊管道的方法,並提供了一個實現。該實現提供的接口與POSIX規范中的popen/pclose函數較為接近,因而非常易於使用。該實現沒有使用平台相關的特性,因而可以不加修改或只進行少量修改即可移植到支持socketpair調用的POSIX系統中去。