管道通信
进程之间可以互相交换数据,其工作方式是通过管道来实现。在Linux环境中,很多时候可以使用管道符|
来让不同进程之间通过管道通信。例如命令ls | cat
。这个命令使命令ls
的标准输出内容通过管道传达给cat
命令的标准输入。
C标准管道操作
C语函数库<stdio.h>
中提供了比较高级的函数来打开和关闭管道,它们是:
#include <stdio.h>
FILE * popen(const char * command, const char * open_mode);
int pclose(FILE * stream_to_close);
popen()
函数中command
参数是能够在shell中运行的命令+参数
,例如ls -a
,open_mode
参数只能是w
或r
,w
表示创建管道的程序会往管道里写数据,而r
表示创建管道的程序会从管道里读数据。这个函数执行成功之后会返回一个文件流,此后程序可以通过fwrite()
往这个文件流写数据来实现管道通信。而对于通过这个函数被打开的程序来说,它并不知道自己是被另一个程序通过管道打开的,它会把管道视为标准输入,对于这个被打开的程序来说,它只是跟正常情况下一样,从标准输入(在这里是管道)读取数据。
pclose()
函数会关闭与管道相对应的文件流,注意如果调用这个函数的时候,管道创建进程还没有执行完成,则这个函数会等待进程完成再返回,并且返回进程的结束码。反过来,如果在调用这个函数之前子进程已经执行完毕,那么就会导致函数无法返回子进程的结束码,这时函数就会返回-1
并设置errno
为ECHILD
。
下面是一个简单的例子:
=============sender.c==================
#include <stdio.h>
int main(){
fprintf(stdout, "Here is some content from another program.\n");
}
=======================================
=============builder.c=================
#include <stdio.h>
#include <string.h>
#define N 50
int main(void){
//打开一个用来读数据的管道
FILE * pipe;
char * command = "./sender";
pipe = popen(command, "r");
printf("Reading content from pipe:\n");
char buffer[N];
memset(buffer, '\0', sizeof(buffer));
fread(buffer, sizeof(char), N, pipe);
printf("%s\n", buffer);
pclose(pipe);
command = "./receiver";
pipe = popen(command, "w");
printf("Now writing some content into a pipe.\n");
fprintf(pipe, "Here are some content writted into a pipe.\n");
pclose(pipe);
}
=======================================
=============receiver.c================
#include <stdio.h>
#include <string.h>
#define N 50
int main(void){
char buffer[N];
memset(buffer, '\0', sizeof(buffer));
fread(buffer, sizeof(char), N, stdin);
printf("%s", buffer);
}
=======================================
从上面的例子中可以看出,对于创建管道的程序来说,只需要把管道当成一个文件来处理即可,而被其他程序用管道创建的进程通过标准I/O来操作管道。
Linux系统中popen()
的本质是新创建一个shell,并执行command
指定的命令,这样可以使command
变得非常复杂,以完成更复杂的任务,例如cat *.c | wc -l
,但是缺点是重新启动了一个shell,需要消耗额外的系统资源。
pipe系统调用
上面的管道操作是通过调用C语言标准函数实现的,除了这样的方式之外,Linux系统还提供了底层的借口,用于直接创建管道,并对其进行操作。其函数如下:
#include <unistd.h>
int pipe(int file_descriptor[2]);
pipe()
函数的参数是一个具有两个元素的int
类型的文件描述符数组,该函数在数组中填上两个文件描述符之后返回0
,并且所有写入到file_descreptor[1]
中的数据都可以从file_descriptor[0]
中读取回来,读取的顺序遵守FIFO
原则。需要注意的是,file_descriptor
是文件描述符,而不是文件流,所以不能使用高级函数fread()
等来操作,而只能使用底层调用read()
和write()
来操作。下面是一个例子:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main(void){
int data_processed;
int file_pipes[2];
const char some_data[] = "123";
char buffer[BUFSIZ + 1]; //BUFSIZ是 stdio.h 里面定义的宏,用来实现fread()的缓冲功能
memset(buffer, '\0', sizeof(buffer));
if(pipe(file_pipes) == 0){
data_processed = write(file_pipes[1], some_data, strlen(some_data));
printf("Wrote %d bytes.\n", data_processed);
data_processed = read(file_pipes[0], buffer, BUFSIZ);
printf("Read %d bytes: %s\n", data_processed, buffer);
}
}
使用系统调用跟使用高级函数的区别在于,高级函数中将管道视为一个文件流,而底层调用中没有文件流,取而代之的是文件描述符,所以操作更加繁琐,但是也更加灵活,节省资源。
上面的例子只是创建了一个管道并在同一个进程中访问管道的两端,如果在创建了一个管道之后,再创建一个新进程,并且将管道的一端作为参数传递给新进程,这样就能实现使用管道让两个进程相互通信,下面是一个例子:
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(void){
int data_processed;
int file_pipes[2];
const char some_data[] = "123";
char buffer[BUFSIZ + 1];
__pid_t fork_result; //子进程的pid
memset(buffer, '\0', sizeof(buffer));
if(pipe(file_pipes) == 0){
fork_result = fork();
if(fork_result == -1){
fprintf(stderr, "Fork failure");
exit(EXIT_FAILURE);
}
}
if(fork_result == 0){ //表示我们在子进程中
data_processed = read(file_pipes[0], buffer, BUFSIZ);
printf("Read %d bytes: %s\n", data_processed, buffer);
exit(EXIT_SUCCESS);
}else{ //否则,我们在父进程中
data_processed = write(file_pipes[1], some_data, strlen(some_data));
printf("Wrote %d bytes\n", data_processed);
exit(EXIT_SUCCESS);
}
}
上面的例子使用了fork()
函数来创建另一个子进程,更进一步,还可以使用exec
系列函数来让两个不同的程序之间使用管道进行通信。为了使用管道通信,需要在创建另一个子进程的时候将管道的文件描述符作为参数传递到子进程中,例如:
======================pipe_exec1.c=========================
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(void){
int data_processed;
int file_pipes[2];
const char some_data[] = "123";
char buffer[BUFSIZ + 1];
__pid_t fork_result; //子进程的pid
memset(buffer, '\0', sizeof(buffer));
if(pipe(file_pipes) == 0){
fork_result = fork();
if(fork_result == -1){
fprintf(stderr, "Fork failure");
exit(EXIT_FAILURE);
}
}
if(fork_result == 0){ //表示我们在子进程中
//将管道的文件描述符存放在buffer中
sprintf(buffer, "%d", file_pipes[0]);
//将buffer作为参数传递到另一个程序中
(void) execl("pipe_exec2", "pipe_exec2", buffer, (char *)0);
}else{ //否则,我们在父进程中
data_processed = write(file_pipes[1], some_data, strlen(some_data));
printf("%d - wrote %d bytes\n", getpid(), data_processed);
}
exit(EXIT_SUCCESS);
}
======================pipe_exec2.c=========================
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(int argc, char * argv[]){
int data_processed;
char buffer[BUFSIZ + 1];
int file_descriptor;
memset(buffer, '\0', sizeof(buffer));
sscanf(argv[1], "%d", &file_descriptor);
data_processed = read(file_descriptor, buffer, BUFSIZ);
printf("%d - read %d bytes: %s\n", getpid(), data_processed, buffer);
exit(EXIT_SUCCESS);
}
除了上面的用法之外,还可以在子进程中,将管道文件描述符设置成默认的标准输入,这样就能简化读取文件时的操作,直接使用标准输入读取文件即可,而不需要每次都通过一个特定的文件描述符读取文件。这个功能可以使用dup()
来实现,dup()
的函数签名如下所示:
#include <unistd.h>
int dup(int file_descriptor);
int dup2(int file_descriptor_one, int file_descriptor_two);
dup()
的作用是返回一个新的文件描述符,这个新返回的文件描述符跟作为参数的文件描述符指向同一个文件,返回文件描述符的时候,dup()
会尽量返回一个小的值作为文件描述符,而dup2()
会返回一个大于file_descriptor_two
的尽量小的文件描述符。使用这一特性,我们可以先关闭标准输入0
,然后使用dup()
使文件描述符0
指向管道的输出端,这样就实现了通过标准输入来读取管道中的数据。
系统默认用于特定用途的文件描述符如下:
文件描述符 | 作用 |
---|---|
0 | 标准输入 |
1 | 标准输出 |
2 | 标准错误输出 |
3 | 管道文件描述符 |
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
int main(void){
int data_processed;
int file_pipes[2];
const char some_data[] = "123";
char buffer[BUFSIZ + 1];
__pid_t fork_result; //子进程的pid
memset(buffer, '\0', sizeof(buffer));
if(pipe(file_pipes) == 0){
fork_result = fork();
if(fork_result == -1){
fprintf(stderr, "Fork failure");
exit(EXIT_FAILURE);
}
}
if(fork_result == 0){ //表示我们在子进程中
close(0); //关闭标准输入,使得文件描述符0可以被分配给管道的输出端
dup(file_pipes[0]); //让标准输入文件描述符0指向管道的输出端
close(file_pipes[0]);//删除管道输出端的文件描述符,因为不会再用到了
close(file_pipes[1]);//删除管道输入端的文件描述符,因为不需要了
execlp("od", "od", "-c", (char *) 0);
exit(EXIT_FAILURE);
}else{ //否则,我们在父进程中
close(file_pipes[0]); //关闭不需要的管道输入端
data_processed = write(file_pipes[1], some_data, strlen(some_data));
close(file_pipes[1]);
printf("%d - wrote %d bytes\n", (int) getpid(), data_processed);
}
exit(EXIT_SUCCESS);
}
命名管道FIFO
上面的例子只是在两个互相关联的进程(从属同一个父进程)之间通过管道传输数据,通过FIFO的方法,可以在任意两个不相关的进程之间建立管道。命名管道是一种特殊的文件,它在文件系统中以文件名的形式存在,但是它的行为与没命名管道类似,所以在使用这样的文件的时候,可以将其看作一个有名字的管道。
在Linux系统中,可以使用mknod
(老版本)或mkfifo
(新版本)来创建命名管道。在编写程序的时候,可以使用以下函数来创建命名管道:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char * filename, mode_t mode);
int mknod(const char * filename, mode_t mode | S_IFIFO, (dev_t) 0);
与mknod
命令一样,我们可以用mknod()
函数建立许多特殊类型的文件。要想通过这个函数建立一个命名管道,唯一具有可移植性的方法是使用一个dev_t
类型的值0,并将文件访问模式与S_IFIFO
按位或。
因为管道文件在系统中作为一个有名字的文件,所以在程序中读写管道之前需要先使用open()
打开文件,之后将其作为一个文件访问即可,但是与访问普通文件不同的,命名管道在使用的时候有一些区别:
-
文件模式不能是
P_RDWR
,因为管道一般是单向的,要实现双向传输数据最好使用一对管道。 -
O_NONBLOCK
选项不仅改变open()
调用的处理方式,还会改变对这次open()
调用返回的文件描述符进行的读写请求的处理方式。
O_NONBLOCK
,O_RDONLY
,O_WRONLY
这三个标志有四种组合方式,其结果和对应的处理方式如下:
-
open(const char * path, O_RDONLY)
:open()
调用被阻塞,除非有一个进程以写方式打开同一个FIFO,否则他不会返回。 -
open(const char * path, O_RDONLY | O_NONBLOCK)
:即使没有其他进程以写方式打开FIFO,调用也会立即成功并返回。 -
open(const char * path, O_WRONLY)
:open()
被阻塞,直到另一个进程以读方式打开FIFO。 -
open(const char * path, O_WRONLY | O_NONBLOCK)
:即使没有其他进程以读方式打开FIFO,调用也会立即返回,但是不会打开管道,并返回-1(调用失败)。
下面是一个例子,该例子中,通过向程序中传入不同的参数,让open()
以不同的模式运行,从而观察不同模式下open()
的行为:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#define FIFO_NAME "fifo"
int main(int argc, char * argv []){
int res;
int open_mode;
int i;
if(argc < 2){
fprintf(stderr, "Usage: %s <some combination os O|RDONLY O_RWONLY O_NONBLOCK>\n", *argv);
exit(EXIT_FAILURE);
}
//设置open()的启动模式
for(int i = 0; i < argc; i++){
if(strncmp(argv[i], "O_RDONLY", 8) == 0) open_mode |= O_RDONLY;
if(strncmp(argv[i], "O_WRONLY", 8) == 0) open_mode |= O_WRONLY;
if(strncmp(argv[i], "O_NONBLOCK", 10) == 0) open_mode |= O_NONBLOCK;
}
//检查FIFO文件是否存在,没有则创建
if(access(FIFO_NAME, F_OK) == -1){
res = mkfifo(FIFO_NAME, 0777);
if(res != 0){
fprintf(stderr, "Could not create fifo %s\n", FIFO_NAME);
exit(EXIT_FAILURE);
}
}
//根据不同的值创建对应模式的fifo
printf("Process %d opening FIFO\n", getpid());
res = open(FIFO_NAME, open_mode);
printf("Process %d result %d\n", getpid(), res);
sleep(5);
if(res != -1) (void)close(res);
printf("Process %d finished\n", getpid());
exit(EXIT_SUCCESS);
}
通过下面的命令对其进行编译并测试,可以清楚的观察到不同程序之间的时序关系:
scholar@scholar-PC:~$ gcc FIFO.c -o a
scholar@scholar-PC:~$ ./a O_RDONLY &
[1] 8699
scholar@scholar-PC:~$ Process 8699 opening FIFO
./a O_WRONLY
Process 8700 opening FIFO
Process 8700 result 3
Process 8699 result 3
Process 8700 finished
Process 8699 finished
[1]+ 已完成
是否设置O_NONBLOCK
标志会影响对FIFO的read()
和write()
的行为,对于阻塞的(没有O_NONBLOCK
)FIFO,read()
会等到有数据可读的时候才会返回,相反则会立即返回,并返回0
表示没有读到数据。
类似的,对于阻塞的FIFO,write()
会被阻塞到能够写入数据之后才会返回。对于write()
需要特别说明的是,系统在头文件limits.h
中定义了一个PIPE_BUF
来设置管道的长度,如果一次write()
操作写入的数据长度超过这个值,那么这一次write()
操作将会被拆分成多次完成。这种情况下对于只有一个程序对管道写入数据时不会造成任何后果,但是对于多个程序同时向管道写数据的时候,就会是多个程序的数据交叉在一起。所以在多个程序同时向管道写数据的时候,要使用阻塞方式打开FIFO,并保证每次向管道写入数据的长度小于等于PIPE——BUF。
下面是一个简单的通过管道在两个程序之间传输数据的例子:
==========================producer.c=========================
#include <stdio.h>
#include <limits.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <errno.h>
#define FIFO_NAME "fifo"
#define BUFFER_SIZE PIPE_BUF
#define TEN_MEG (1024 * 1024 * 10) //要向FIFO文件中写入的总字节数
int main(void){
int pipe_fd; //打开FIFO文件之后的文件描述符
int res; //存放每次对文件操作之后的结果
int open_mode = O_WRONLY;
int bytes_sent = 0; //目前已经写入的字节数
char buffer[BUFFER_SIZE + 1];
//如果没有FIFO文件则创建一个
if(access(FIFO_NAME, F_OK) == -1){
res = mkfifo(FIFO_NAME, 0777);
if(res != 0){
fprintf(stderr, "Could not create file %s\n", FIFO_NAME);
exit(EXIT_FAILURE);
}
}
//开始打开FIFO文件
printf("Writing process %d opening FIFO O_WRONLY\n", getpid());
pipe_fd = open(FIFO_NAME, open_mode);
printf("Writing process %d result %d\n", getpid(), pipe_fd);
//如果FIFO文件打开成功
if(pipe_fd != -1){
while(bytes_sent < TEN_MEG){
//每次向FIFO文件中写入BUFFER_ZISE大小的数据,数据内容为buffer中未初始化的内容
res = write(pipe_fd, buffer, BUFFER_SIZE);
if(res == -1){
fprintf(stderr, "Write error on pipe");
exit(EXIT_FAILURE);
}
bytes_sent += BUFFER_SIZE;
}
close(pipe_fd);
}else{
printf("Writing process %d could not write FIFO %s", getpid(), FIFO_NAME);
exit(EXIT_FAILURE);
}
printf("Writing process %d finished\n", getpid());
exit(EXIT_SUCCESS);
}
=============================end=======================
========================consumer.c=====================
#include <stdio.h>
#include <limits.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
#define FIFO_NAME "fifo"
#define BUFFER_SIZE PIPE_BUF
int main(void){
int pipe_fd; //打开FIFO文件之后的文件描述符
int res;
int open_mode = O_RDONLY;
int bytes_read = 0; //已经读取的字节数
char buffer[BUFFER_SIZE + 1];
memset(buffer, '\0', sizeof(buffer));
//打开FIFO文件
printf("Reading process %d opening FIFO O_RDONLY\n", getpid());
pipe_fd = open(FIFO_NAME, open_mode);
printf("Reading process %d result %d\n", getpid(), pipe_fd);
//如果成功打开FIFO文件则不停地读取数据,直到没有数据为止
if(pipe_fd != -1){
do{
res = read(pipe_fd, buffer, sizeof(buffer));
bytes_read += res;
}while(res > 0);
(void) close(pipe_fd);
}else{
printf("Reading process %d Could not read FIFO %s", getpid(), FIFO_NAME);
exit(EXIT_FAILURE);
}
printf("Reading process %d finished\n", getpid());
exit(EXIT_SUCCESS);
}
==============================end========================
上面的例子演示了如何使用命名管道在两个完全不相关的程序之间传输数据,如果先运行生产者程序,则生产者程序在创建管道的时候会被阻塞,直到消费者程序以写的方式打开管道。打开管道之后,只需要将管道看作一个普通的文件,使用底层系统调用read()
和write()
对其进行操作即可。