本文将开启第一种进程间通讯方式,也是最古老的进程间通讯方式——管道的介绍。管道又可以细分为有名管道与无名管道,详细可以参看下文。
一、无名管道
无名管道的概述
(1)概念理解
管道也叫无名管道,它是是 UNIX 系统 IPC(进程间通信) 的最古老形式,几乎所有的 UNIX 系统都支持这种通信机制。我们把从一个进程连接到另一个进程的数据流称为一个“管道”,我们可以类比现实生活中管子,管子的一端塞东西,管子的另一端取东西。
例如:我们统计一个目录中文件的数目,需要执行一下命令,ls | wc –l
[外链
为了执行该命令,shell 创建了两个进程来分别执行 ls 和 wc。当它们运行起来后就变成了两个进程,ls 进程通过标准输出将数据打到“管道”当中,wc进程再通过标准输入从“管道”当中读取数据,至此便完成了数据的传输,进而完成数据的进一步加工处理。
【注意】ls 命令用于查看当前目录下的所有文件夹名和目录名,wc -l 用于统计当前的个数。
(2)管道的特点
管道其实是一个在内核内存(由 Linux 内核维护)中维护的缓冲器,这个缓冲器的存储能力是有限的,不同的操作系统大小不一定相同。
管道也可以看做一种特殊类型的文件,其拥有文件的特质:读操作、写操作,匿名管道没有文件实体,有名管道有文件实体,但不存储数据。可以按照操作文件的方式对管道进行操作。所以管道在应用层体现为两个打开的文件描述符。
一个管道是一个字节流,使用管道时不存在消息或者消息边界的概念,从管道读取数据的进程可以读取任意大小的数据块,而不管写入进程写入管道的数据块的大小是多少。
通过管道传递的数据是顺序的,从管道中读取出来的字节的顺序和它们被写入管道的顺序是完全一样的,写入管道中的数据遵循先入先出的规则。
在管道中的数据的传递方向是单向的,一端用于写入,一端用于读取,管道是半双工的。
【补充】单工通信、半双工通信、全双工通信:
- 单工通信(Simplex Communication):单工模式的数据传输是单向的。通信双方中,一方固定为发送端,另一方固定为接收端。比如遥控器可以发射信号给电视机,但是电视不能发射信号给遥控器。
- 半双工通信(Half Duplex):半双工数据传输指数据可以在一个信号载体的两个方向上传输,但是不能同时传输。即同一时间数据只能往一个方向传递,类似于对讲机。
- 全双工通信(Full Duplex):全双工通信允许数据在两个方向上同时传输,它的能力相当于两个单工通信方式的结合。全双工可以同时(瞬时)进行信号的双向传输。比如打电话,打电话双方都可以随时互相给对方发送消息。
从管道读数据是一次性操作,数据一旦被读走,它就从管道中被抛弃,释放空间以便写更多的数据,在管道中无法使用 lseek() 来随机的访问数据。
匿名管道只能在具有公共祖先的进程(父进程与子进程,或者两个兄弟进程,具有亲缘关系)之间使用。
管道所传送的数据是无格式的,这要求管道的读出方与写入方必须事先约定好数据的格式,如多少字节算一个消息等。
管道内部实现的数据结构:循环队列。
(3)为什么可以使用管道进行进程间通信?
理解了管道大致的概念,我们深入探讨一下,为什么可以使用管道进行进程间通信?
进程间通信的本质就是,让不同的进程看到同一份资源,使用匿名管道实现父子进程间通信的原理就是,让两个父子进程先看到同一份被打开的文件资源:子进程在 fork 之后会完全拷贝父进程的内存空间,因此子进程与父进程相当于共享文件描述符,然后父子进程就可以对该文件进行写入或是读取操作,进而实现父子进程间通信。
- 这里父子进程看到的同一份文件资源是由操作系统来维护的,所以当父子进程对该文件进行写入操作时,该文件缓冲区当中的数据并不会进行写时拷贝。
- 管道虽然用的是文件的方案,但操作系统一定不会把进程进行通信的数据刷新到磁盘当中,因为这样做有IO参与会降低效率,而且也没有必要。也就是说,这种文件是一批不会把数据写到磁盘当中的文件,换句话说,磁盘文件和内存文件不一定是一一对应的,有些文件只会在内存当中存在,而不会在磁盘当中存在。
无名管道的 API
(1)pipe 函数
#include <unistd.h>
int pipe(int pipefd[2]);
功能:创建无名管道,用来进程间通信。
参数:
pipefd : 为 int 型数组的首地址,其存放了管道的文件描述符 pipefd[0]、pipefd[1]。
pipefd[0] 对应的是管道的读端
pipefd[1] 对应的是管道的写端
一般文件I/O的函数都可以用来操作管道 ( lseek() 除外)。
返回值:
成功:0
失败:-1
示例:子进程通过无名管道给父进程传递一个字符串数据
// test.c :
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define SIZE 64
// 父子进程使用无名管道进行通信:父进程写管道 子进程读管道
int main()
{
int ret = -1;
int fds[2];
char buf[SIZE];
pid_t pid = -1;
// 1、创建无名管道,注意:一定要在 fork 之前创建管道
ret = pipe(fds);
if (-1 == ret)
{
perror("pipe");
return 1;
}
// 2、创建子进程
pid = fork();
if (-1 == pid)
{
perror("fork");
return 1;
}
// 子进程 读管道
if (0 == pid)
{
// 关闭写端
close(fds[1]);
memset (buf, 0,SIZE);
// 读管道的内容
ret = read ( fds[0], buf, SIZE);
if (ret < 0 )
{
perror("read");
exit(-1);
}
printf("child process buf: %s\n", buf);
//关闭读端
close(fds[0]);
//进程退出
exit(0);
}
// 父进程 写管道
// 关闭读端
close(fds[0]);
// 写管道
ret = write(fds[1], "ABCDEGHIJK", 10);
if (ret < 0 )
{
perror("write");
exit(1);
}
printf("parent process wirte len: %d\n", ret);
// 关闭写端
close(fds[1]);
return 0;
}
运行结果:
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
parent process wirte len: 10
child process buf: ABCDEGHIJK
- 在实际开发的过程中,我们一般不会实现父子进程间相互发送数据,一般只会实现一个流向的数据发送:要不是父进程流向子进程,要不是子进程流向父进程。因为双向发数据很容易导致发送数据方接收到自己发送的收据,接收数据方接收到自己发送的数据。所以上例代码中,父进程关闭写端,子进程关闭读端。
- 一定要在 fork 之前创建管道,这样父子进程内存空间的指针才会执行相同的管道,相当于让不同的进程看到同一份资源。
(2)查看管道缓冲大小命令
可以使用 ulimit -a
命令来查看当前系统中创建管道文件所对应的内核缓冲区大小。
如上图所示,该管道有8块,每块有512bytes,所以一共有4k的缓存大小。
一个管道有两个缓冲区,分别是读缓冲区和写缓冲区。
(3)查看管道缓冲大小函数
#include <unistd.h>
long fpathconf(int fd, int name);
功能:该函数可以通过name参数查看不同的属性值
参数:
fd:文件描述符
name:
_PC_PIPE_BUF,查看管道缓冲区大小
_PC_NAME_MAX,文件名字字节数的上限
返回值:
成功:根据name返回的值的意义也不同。
失败: -1
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main()
{
int pipefd[2];
int ret = pipe(pipefd);
// 获取管道的大小
long size = fpathconf(pipefd[0], _PC_PIPE_BUF);
printf("pipe size : %ld\n", size);
close(pipefd[0]);
close(pipefd[1]);
return 0;
}
yxm@192:~$ gcc test.c -o test
yxm@192:~$ ./test
pipe size : 4096
无名管道的实例
/*
实现 ps aux | grep xxx 父子进程间通信,步骤如下:
1、pipe()
2、父进程:获取到数据,过滤
3、子进程: ps aux, 子进程结束后,将数据发送给父进程
子进程将标准输出 stdout_fileno 重定向到管道的写端。
execlp()
*/
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <wait.h>
int main()
{
int fd[2];
int ret = pipe(fd); // 创建一个管道
if(ret == -1)
{
perror("pipe");
exit(0);
}
pid_t pid = fork(); // 创建子进程
if(pid > 0)
{ // 父进程
close(fd[1]); // 关闭写端
char buf[1024] = {0}; // 从管道中读取
int len = -1;
while((len = read(fd[0], buf, sizeof(buf) - 1)) > 0)
{
// 过滤数据输出
printf("%s", buf);
memset(buf, 0, 1024);
}
wait(NULL);
}
else if(pid == 0)
{ // 子进程
close(fd[0]); // 关闭读端
dup2(fd[1], STDOUT_FILENO); // 文件描述符的重定向 stdout_fileno -> fd[1]
execlp("ps", "ps", "aux", NULL); // 执行 ps aux
perror("execlp");
exit(0);
}
else
{
perror("fork");
exit(0);
}
return 0;
}
无名管道的读写特点
使用管道需要注意以下4种特殊情况(假设都是阻塞 I/O 操作,没有设置 O_NONBLOCK 标志):
如果所有指向管道写端的文件描述符都关闭了(管道写端引用计数为0),而仍然有进程从管道的读端读数据,那么管道中剩余的数据都被读取后,再次read会返回0,就像读到文件末尾一样。
如果有指向管道写端的文件描述符没关闭(管道写端引用计数大于0),而持有管道写端的进程也没有向管道中写数据,这时有进程从管道读端读数据,那么管道中剩余的数据都被读取后,再次read会阻塞,直到管道中有数据可读了才读取数据并返回。
如果所有指向管道读端的文件描述符都关闭了(管道读端引用计数为0),这时有进程向管道的写端write,那么该进程会收到信号 SIGPIPE,通常会导致进程异常终止。当然也可以对 SIGPIPE 信号实施捕捉,不终止进程。具体方法信号章节详细介绍。
如果有指向管道读端的文件描述符没关闭(管道读端引用计数大于0),而持有管道读端的进程也没有从管道中读数据,这时有进程向管道写端写数据,那么在管道被写满时再次write会阻塞,直到管道中有空位置了才写入数据并返回。
以上无名管道的读写特点,可以总结为:
读管道:
管道中有数据,read返回实际读到的字节数。
管道中无数据:
- 管道写端被全部关闭,read返回0 (相当于读到文件结尾)
- 写端没有全部被关闭,read阻塞等待(不久的将来可能有数据递达,此时会让出 cpu)
写管道:
管道读端全部被关闭, 进程异常终止(也可使用捕捉 SIGPIPE 信号,使进程终止)
管道读端没有全部关闭:
- 管道已满,write 阻塞。
- 管道未满,write 将数据写入,并返回实际写入的字节数。
设置为非阻塞的方法
设置方法(fcntl()
函数详细可参看之前的系列文章):
//获取原来的flags
int flags = fcntl(fd[0], F_GETFL);
// 设置新的flags
flag |= O_NONBLOCK; // 位或:表示追加的方式
// flags = flags | O_NONBLOCK;
fcntl(fd[0], F_SETFL, flags);
结论: 如果写端没有关闭,读端设置为非阻塞, 如果没有数据,直接返回-1。
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
int main()
{
int pipefd[2]; // 在fork之前创建管道
int ret = pipe(pipefd);
if(ret == -1)
{
perror("pipe");
exit(0);
}
pid_t pid = fork(); // 创建子进程
if(pid > 0)
{ // 父进程
printf("i am parent process, pid : %d\n", getpid());
close(pipefd[1]); // 关闭写端
char buf[1024] = {0}; // 从管道的读取端读取数据
int flags = fcntl(pipefd[0], F_GETFL); // 获取原来的flag
flags |= O_NONBLOCK; // 修改flag的值
fcntl(pipefd[0], F_SETFL, flags); // 设置新的flag
while(1)
{
int len = read(pipefd[0], buf, sizeof(buf));
printf("len : %d\n", len);
printf("parent recv : %s, pid : %d\n", buf, getpid());
memset(buf, 0, 1024);
sleep(1);
}
}
else if(pid == 0)
{ // 子进程
printf("i am child process, pid : %d\n", getpid());
close(pipefd[0]); // 关闭读端
char buf[1024] = {0};
while(1)
{
// 向管道中写入数据
char * str = "hello,i am child";
write(pipefd[1], str, strlen(str));
sleep(5);
}
}
return 0;
}
二、有名管道
有名管道的概述
无名管道,由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了命名管道(FIFO),也叫有名管道、FIFO文件。
有名管道(FIFO)不同于无名管道之处在于它提供了一个路径名与之关联,以 FIFO 的文件形式存在于文件系统中,这样,即使与 FIFO 的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过 FIFO 相互通信,因此,通过 FIFO 不相关的进程也能交换数据。
一旦打开了 FIFO,就能在它上面使用与操作匿名管道和其他文件的系统调用一样的 I/O 系统调用了,如 read()
、write()
和 close()
。与管道一样,FIFO 也有一个写入端和读取端,并且从管道中读取数据的顺序与写入的顺序是一样的。FIFO 的名称也由此而来:先入先出。
命名管道(FIFO) 和无名管道(pipe)有一些特点是相同的,不一样的地方在于:
管道可以看做一种特殊类型的文件,其拥有文件的特质:读操作、写操作。但是匿名管道没有文件实体,有名管道有文件实体,但不存储数据,因为 FIFO 在文件系统中作为一个特殊的文件而存在, FIFO 中的数据存放在内存缓冲区中,一旦程序结束,缓冲区中的数据清零(FIFO 文件大小为0)。
当使用 FIFO 的进程退出后,FIFO 文件将继续保存在文件系统中以便以后使用。
FIFO 有名字,不相关的进程可以通过打开命名管道进行通信。
有名管道的使用
(1)有名管道使用的流程
创建管道
通过命令创建有名管道:mkfifo 名字
通过函数创建有名管道
#include <sys/types.h> #include <sys/stat.h> int mkfifo(const char *pathname, mode_t mode);
一旦使用 mkfifo 创建了一个 FIFO,就可以使用 open 打开它,常见的文件 I/O 函数都可用于 fifo,如:close、read、write、unlink 等。
FIFO 严格遵循先进先出(First in First out),对 FIFO 的读总是从开始处返回数据,对它们的写则把数据添加到末尾。所以它们不支持诸如
lseek()
等文件定位操作。
(2)创建有名管道
通过命令创建有名管道
通过 API 函数创建有名管道
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
功能:
命名管道的创建。
参数:
pathname : 普通的路径名,也就是创建后 FIFO 的名字。
mode : 文件的权限,与打开普通文件的 open() 函数中的 mode 参数相同是一个八进制的数 。
返回值:
成功:0 状态码
失败:如果文件已经存在,则会出错且返回 -1。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
int main(void)
{
int ret = access("fifo1", F_OK); // 判断文件是否存在
if (-1 == ret)
{
ret = mkfifo("fifo", 0644); // 创建一个有名管道, 管道名字为fifo
if (-1 == ret)
{
perror("mkfifo");
return 1;
}
}
return 0;
}
(3)有名管道读写操作
一旦使用mkfifo创建了一个 FIFO,就可以使用 open 打开它,常见的文件I/O函数都可用于fifo,如:close、read、write、unlink等。
FIFO严格遵循先进先出(first in first out),对管道及FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>
// 向管道中写数据
int main()
{
// 1.判断文件是否存在
int ret = access("fifo", F_OK);
if(ret == -1)
{
printf("管道不存在,创建管道\n");
// 2.创建管道文件
ret = mkfifo("fifo", 0664);
if(ret == -1)
{
perror("mkfifo");
exit(0);
}
}
// 3.以只写的方式打开管道
int fd = open("test", O_WRONLY);
if(fd == -1) {
perror("open");
exit(0);
}
// 写数据
for(int i = 0; i < 100; i++)
{
char buf[1024];
sprintf(buf, "hello, %d\n", i);
printf("write data : %s\n", buf);
write(fd, buf, strlen(buf));
sleep(1);
}
close(fd);
return 0;
}
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
// 从管道中读取数据
int main()
{
// 1.以只读的方式打开管道文件
int fd = open("test", O_RDONLY);
if(fd == -1)
{
perror("open");
exit(0);
}
// 读数据
while(1)
{
char buf[1024] = {0};
int len = read(fd, buf, sizeof(buf));
if(len == 0)
{
printf("写端断开连接了...\n");
break;
}
printf("recv buf : %s\n", buf);
}
close(fd);
return 0;
}
有名管道注意事项
一个为只读而打开一个管道的进程会阻塞直到另外一个进程为只写打开该管道
一个为只写而打开一个管道的进程会阻塞直到另外一个进程为只读打开该管道
一个管道以读写的方式打开不会阻塞,但是最好不要以读写方式打开,因为这样可能导致双向读写数据。
【注意】在使用管道进行进程间通讯时,一般不会实现进程间相互发送数据(双向读写),只会实现一个流向的数据发送:要不是 A 进程流向 B 进程,要不是 B 进程流向 A 进程。因为双向发数据很容易导致发送数据方接收到自己发送的收据,接收数据方接收到自己发送的数据。
有名管道读写的特点与无名管道读写的特点相同。
有名管道的实例
有名管道实现简单版聊天功能:这个聊天功能非常简单,进程 A 发送一条数据,进程 B 收到该条数据后再向进程 A 回复一条数据,进程 A 再接收回复数据。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kD1U21LF-1660198435767)(assets/1659480446284.png)]
单进程有名管道实现聊天程序示例:
//talkA.C
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
int main() {
// 1.判断有名管道文件是否存在
int ret = access("fifo1", F_OK);
if(ret == -1) {
// 文件不存在
printf("管道不存在,创建对应的有名管道\n");
ret = mkfifo("fifo1", 0664);
if(ret == -1) {
perror("mkfifo");
exit(0);
}
}
ret = access("fifo2", F_OK);
if(ret == -1) {
// 文件不存在
printf("管道不存在,创建对应的有名管道\n");
ret = mkfifo("fifo2", 0664);
if(ret == -1) {
perror("mkfifo");
exit(0);
}
}
// 2.以只写的方式打开管道fifo1
int fdw = open("fifo1", O_WRONLY);
if(fdw == -1) {
perror("open");
exit(0);
}
printf("打开管道fifo1成功,等待写入...\n");
// 3.以只读的方式打开管道fifo2
int fdr = open("fifo2", O_RDONLY);
if(fdr == -1) {
perror("open");
exit(0);
}
printf("打开管道fifo2成功,等待读取...\n");
char buf[128];
// 4.循环的写读数据
while(1) {
memset(buf, 0, 128);
// 获取标准输入的数据
fgets(buf, 128, stdin);
// 写数据
ret = write(fdw, buf, strlen(buf));
if(ret == -1) {
perror("write");
exit(0);
}
// 5.读管道数据
memset(buf, 0, 128);
ret = read(fdr, buf, 128);
if(ret <= 0) {
perror("read");
break;
}
printf("buf: %s\n", buf);
}
// 6.关闭文件描述符
close(fdr);
close(fdw);
return 0;
}
// talkB.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>
int main() {
// 1.判断有名管道文件是否存在
int ret = access("fifo1", F_OK);
if(ret == -1) {
// 文件不存在
printf("管道不存在,创建对应的有名管道\n");
ret = mkfifo("fifo1", 0664);
if(ret == -1) {
perror("mkfifo");
exit(0);
}
}
ret = access("fifo2", F_OK);
if(ret == -1) {
// 文件不存在
printf("管道不存在,创建对应的有名管道\n");
ret = mkfifo("fifo2", 0664);
if(ret == -1) {
perror("mkfifo");
exit(0);
}
}
// 2.以只读的方式打开管道fifo1
int fdr = open("fifo1", O_RDONLY);
if(fdr == -1) {
perror("open");
exit(0);
}
printf("打开管道fifo1成功,等待读取...\n");
// 3.以只写的方式打开管道fifo2
int fdw = open("fifo2", O_WRONLY);
if(fdw == -1) {
perror("open");
exit(0);
}
printf("打开管道fifo2成功,等待写入...\n");
char buf[128];
// 4.循环的读写数据
while(1) {
// 5.读管道数据
memset(buf, 0, 128);
ret = read(fdr, buf, 128);
if(ret <= 0) {
perror("read");
break;
}
printf("buf: %s\n", buf);
memset(buf, 0, 128);
// 获取标准输入的数据
fgets(buf, 128, stdin);
// 写数据
ret = write(fdw, buf, strlen(buf));
if(ret == -1) {
perror("write");
exit(0);
}
}
// 6.关闭文件描述符
close(fdr);
close(fdw);
return 0;
}
如果想要进程 A 不断地发送数据,进程 B 不断接受数据,就不能把都和写放到同一个进程中,因为放到同一个进程中,读和写必定有一个是阻塞的,不能同时被执行。 可以将进程A中读写管道分别放入到父进程和子进程中,比如父进程读,子进程写,将进程B中读写管道也分别放入到父进程和子进程中,与进程A的读写管道相反,父进程写,子进程读。