c语言 - 进程间通信 管道

c语言 - 进程间通信 管道

匿名管道pipe

如果你使用过Linux的命令,那么对于管道这个名词你一定不会感觉到陌生,因为我们通常通过符号”|”来使用管道;

但是管道的真正定义是什么呢?
管道是一个进程连接数据流到另一个进程的通道,它通常是用作把一个进程的输出通过管道连接到另一个进程的输入;

举个例子,在shell中输入命令:ls -l | grep string
我们知道ls命令(其实也是一个进程)会把当前目录中的文件都列出来,但是它不会直接输出,而是把本来要输出到屏幕上的数据通过管道输出到grep这个进程中,作为grep这个进程的输入,然后这个进程对输入的信息进行筛选,把存在string的信息的字符串(以行为单位)打印在屏幕上;

匿名管道pipe
int pipe(filedes[2]);:创建一个匿名管道

  • 头文件:unistd.h
  • filedes[2]:输出参数,用于接收pipe返回的两个文件描述符;filedes[0]读管道、filedes[1]写管道
  • 返回值:成功返回0,失败返回-1,并设置errno

匿名管道实质上是一个先进先出(FIFO)的队列
filedes[0]是队头(front),filedes[1]是队尾(rear);

数据从队尾进,从队头出,遵循先进先出的原则;

pipe()创建的管道,其实是一个在内核中的缓冲区,该缓冲区的大小一般为一页,即4K字节;

先来看一个简单的例子:

注意到父进程的sleep(1);语句:
fork调用之前,父进程创建了一个匿名管道,假设文件描述符为filedes[] = {3, 4},即3为队头,4为队尾;
fork调用之后,创建了一个子进程,子进程也拥有了这两个文件描述符,引用计数都分别加1;

因为实质上在内核中只存在一个管道缓冲区,是父进程创建的,只不过子进程通过fork也拥有了它的引用;
所以,如果父进程发送msg之后,子进程没有及时的读取走数据,那么会被父进程后面的read读取,违背了我们的目的;

所以,一般是不建议上面这种做法的,通常做法是:
一个进程要么往管道里写数据,要么从管道里读数据;
如果既需要读又需要写,那么需要创建两个匿名管道,一个专门读取数据,一个专门写入数据;

比如这样:

默认的阻塞模式
pipe()创建的管道默认是阻塞模式的,阻塞和非阻塞的区别与socket的阻塞、非阻塞很相似:

管道读写规则
当没有数据可读时

  • O_NONBLOCK关闭:read调用阻塞,即进程暂停执行,一直等到有数据来到为止;
  • O_NONBLOCK打开:read调用返回-1,errno值为EAGAIN;

当管道满的时候

  • O_NONBLOCK关闭:write调用阻塞,直到有进程读走数据;
  • O_NONBLOCK打开:调用返回-1,errno值为EAGAIN;

如果所有管道写端对应的文件描述符被关闭,则read返回0;
如果所有管道读端对应的文件描述符被关闭,则write操作会产生信号SIGPIPE;

当要写入的数据量不大于PIPE_BUF时,linux将保证写入的原子性;
当要写入的数据量大于PIPE_BUF时,linux将不再保证写入的原子性;

PIPE_BUF的大小为4096字节,注意,这不是管道的缓冲区大小,这个大小和写入的原子性有关;
所谓原子性:

  • 阻塞模式时且n<PIPE_BUF:写入具有原子性,如果没有足够的空间供n个字节全部写入,则阻塞直到有足够空间将n个字节全部写入管道;
  • 非阻塞模式时且n<PIPE_BUF:写入具有原子性,立即全部成功写入,否则一个都不写入,返回错误;
  • 阻塞模式时且n>PIPE_BUF:不具有原子性,可能中间有其他进程穿插写入,直到将n字节全部写入才返回,否则阻塞等待写入;
  • 非阻塞模式时且n>PIPE_BUF:不具有原子性,如果管道满的,则立即失败,一个都不写入,返回错误,如果不满,则返回写入的字节数,即部分写入,写入时可能有其他进程穿插写入;

设置为非阻塞模式
获取fd的flags值:int flags = fcntl(fd, F_GETFL, 0);
设置为非阻塞fd:fcntl(fd, F_SETFL, flags | O_NONBLOCK);
设置为阻塞fd:fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);

命名管道fifo

前面介绍的匿名管道中,我们看到了如何使用匿名管道来在进程之间传递数据,同时也看到了这个方式的一个缺陷,就是这些进程都由一个共同的祖先进程启动,这给我们在不相关的的进程之间交换数据带来了不方便;这里将会介绍进程的另一种通信方式:命名管道,来解决不相关进程间的通信问题;

什么是命名管道
命名管道也被称为FIFO文件,它是一种特殊类型的文件,它在文件系统中以文件名的形式存在,但是它的行为却和之前所讲的没有名字的管道(匿名管道)类似;

由于Linux中所有的事物都可被视为文件,所以对命名管道的使用也就变得与文件操作非常的统一,也使它的使用非常方便,同时我们也可以像平常的文件名一样在命令中使用;

创建命名管道
我们可以使用以下两个函数之一来创建一个命名管道,原型如下:

头文件:sys/types.hsys/stat.h
int mkfifo(const char *filename, mode_t mode);
int mknod(const char *filename, mode_t mode | S_IFIFO, (dev_t)0);
返回值:执行成功返回0,失败返回-1,并设置errno

这两个函数都能创建一个FIFO文件,注意是创建一个真实存在于文件系统中的文件,filename指定了文件名,而mode则指定了文件的读写权限;

或者,可以直接在shell中使用命令mkfifomknod来创建一个FIFO文件;
mkfifo fifo_filemknod fifo_file p

例子:

访问命名管道

打开FIFO文件
与打开其他文件一样,FIFO文件也可以使用open调用来打开;注意,mkfifo函数只是创建一个FIFO文件,要使用命名管道还是将其打开;

但是有一点要注意:
不能以O_RDWR模式打开FIFO文件进行读写操作,而其行为也未明确定义,因为如一个管道以读/写方式打开,进程就会读回自己的输出,同时我们通常使用FIFO只是为了单向的数据传递;

打开FIFO文件通常有四种方式,

  • 头文件:sys/types.hsys/stat.hfcntl.h
  • open(const char *path, O_RDONLY);:阻塞模式打开,只读模式;
  • open(const char *path, O_RDONLY | O_NONBLOCK);:非阻塞模式打开,只读模式;
  • open(const char *path, O_WRONLY);:阻塞模式打开,只写模式;
  • open(const char *path, O_WRONLY | O_NONBLOCK);:非阻塞模式打开,只写模式;
  • 返回值:执行成功返回打开的文件描述符fd,执行失败返回-1,并设置errno

对于以只读方式(O_RDONLY)打开的FIFO文件,如果open调用是阻塞的(即第二个参数为O_RDONLY),除非有一个进程以写方式打开同一个FIFO,否则它不会返回;如果open调用是非阻塞的的(即第二个参数为O_RDONLY | O_NONBLOCK),则即使没有其他进程以写方式打开同一个FIFO文件,open调用将成功并立即返回;

对于以只写方式(O_WRONLY)打开的FIFO文件,如果open调用是阻塞的(即第二个参数为O_WRONLY),open调用将被阻塞,直到有一个进程以只读方式打开同一个FIFO文件为止;如果open调用是非阻塞的(即第二个参数为O_WRONLY | O_NONBLOCK),open总会立即返回,但如果没有其他进程以只读方式打开同一个FIFO文件,open调用将返回-1,并且FIFO也不会被打开;

例子:利用FIFO来在两个非亲缘关系的进程之间传输文件:
send.c

recv.c

命名管道的安全问题
前面的例子是两个进程之间的通信问题,也就是说,一个进程向FIFO文件写数据,而另一个进程则在FIFO文件中读取数据;
试想这样一个问题,只使用一个FIFO文件,如果有多个进程同时向同一个FIFO文件写数据,而只有一个读FIFO进程在同一个FIFO文件中读取数据时,会发生怎么样的情况呢?
会发生数据块的相互交错是很正常的,而且个人认为多个不同进程向一个FIFO读进程发送数据是很普通的情况;

为了解决这一问题,就是让写操作的原子化:
FIFO写操作的原子化同pipe()匿名管道,即:每次写入的数据小于等于PIPE_BUF的大小,即可保证要么一次性全部写入,要么一个字节也不写入;

匿名管道与命名管道
使用匿名管道,通信的进程之间需要一个父子关系,通信的两个进程一定是由一个共同的祖先进程启动;但是匿名管道没有上面说到的数据交叉的问题;

与使用匿名管道相比,我们可以看到send和recv这两个进程是没有什么必然的联系的,如果硬要说他们具有某种联系,就只能说是它们都访问同一个FIFO文件;
它解决了之前在匿名管道中出现的通信的两个进程一定是由一个共同的祖先进程启动的问题;但是为了数据的安全,我们很多时候要采用阻塞的FIFO,让写操作变成原子操作;