一、管道
1、管道的基本使用
- 管道是Unix中最古老的进程间通信的形式。
- 我们把从一个进程连接到另一个进程的一个数据流称为一个“管道”
例如我们通过who | wc -l
命令可以看到who
进程将数据传递给了wc -l
进程,两个进程通过管道完成了简单的通信。
有一点需要注意的是我们使用管道时,管道两边的的进程都会运行起来,而不是先运行管道左边的进程然后运行管道右边的进程。而且在命令行中用管道链接的进程属于兄弟进程关系。
2、管道的原理
在Linux
中一切皆文件,管道也是文件,而且管道是一个彻彻底底的内存文件,也就是说,管道不能够向磁盘输出数据,因为这个特性管道里面就可以存储两个进程通信的数据了。
当我们的一个父进程分别以读和写的方式打开一个同一管道文件,这时我们的进程中就有了两个文件描述符指向这个管道文件。
然后我们再让父进程创建子进程,这时我们父子进程的内核数据结构是几乎相同的,此时我们子进程的也有两个文件描述符指向管道文件而且这个管道文件与父进程指向的管道文件是同一个一个管道文件。
(注意:创建子进程的时候,fork子进程,只会复制进程相关的数据结构对象不会复制父进程曾经打开的文件对象!
这就是为什么fork之后,父子进程printf
,cout
,都会向同一个显示器终端打印数据的原因!)
这个时候我们的父子进程就可以一个向管道文件写入数据一个从管道中读取数据,这样两个进程就可以完成通信了。
最后一个步骤就是关闭不需要的文件描述符了,如果父进程进行写入,就关闭父进程的读端,关闭子进程的写端。反之则同理。
关闭不需要的文件描述符的原因:
因为文件的缓冲区只有一个,一个缓冲区只有一个读和写位置,管道也是。例如父进程向管道中写入数据,然后子进程也写入数据,由于缓冲区的读写位置只有一个,那么我们在读取数据时,父进程与子进程写入管道的数据根本没有办法区分,就有可能造成通信错误。管道这种进程间的通信方式只能进行单向通信。
如果我们想要父子进程都能够进行读写,我们可以创建两个管道,这样它们的读写的数据就不会相互影响了。
补充:我们介绍的这种管道被称为匿名管道,因为所有的文件都要有路径以及文件名的,而这里我们并不知道所以叫匿名管道。
3、实例代码
纸上得来终觉浅,绝知此事要躬行。下面我们尝试在代码中来使用管道来进行进程间的通信。
我们先来了解一个Linux
的系统调用pipe
,这个系统调用可以帮我们打开一个匿名管道文件。
- 参数:输出型参数,外部传入一个数组(此数组至少要有两个
int
的空间),数组的0
号下标的位置放的是读端的文件描述符fd
,数组的1
号下标的位置放的是写端的文件描述符fd
。 - 返回值:返回值为
0
,代表打开管道文件成功,返回值为-1
,代表打开管道文件失败了。
我们让多个进程通信大致分为以下几个步骤:
- 父进程打开一个匿名管道文件。
- 父进程创建子进程,关闭不需要的文件描述符。
- 进行进程间的通信。
- 关闭所有管道文件,进程退出。
代码示例:
#include <iostream> #include <cstring> #include <cstdlib> #include <cerrno> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> #define NUM 1024 int main() { // 1.打开管道 int pipefd[2] = {0}; int err = pipe(pipefd); if (err == -1) { std::cout << "错误码" << errno << ",错误信息:" << strerror(errno) << std::endl; exit(-1); } // 2.1创建子进程 int id = fork(); if (id < 0) { std::cout << "错误码" << errno << ",错误信息:" << strerror(errno) << std::endl; exit(-1); } else if (id == 0) { // child process char buffer[NUM] = {0}; // 2.2.关闭子进程不需要的文件描述符 close(pipefd[1]); // 3.子进程进行进程间的通信 ssize_t n = read(pipefd[0], buffer, sizeof(buffer) - 1); if (n < 0) { std::cout << "错误码" << errno << ",错误信息:" << strerror(errno) << std::endl; } buffer[n] = '\0'; std::cout << "父进程给我的消息是:" << buffer << std::endl; // 4.子进程关闭管道文件,子进程退出。 close(pipefd[0]); exit(0); } else { // parent process char buffer[NUM] = {0}; const char *s = "Hello, I am parent process!"; snprintf(buffer, NUM, "%s : %d", s, getpid()); // 2.2.父进程关闭不需要的文件描述符 close(pipefd[0]); // 3.父进程进行进程间的通信 ssize_t n = write(pipefd[1], buffer, strlen(buffer)); if (n < 0) { std::cout << "错误码" << errno << ",错误信息:" << strerror(errno) << std::endl; } } // 4.父进程关闭管道文件,父进程退出。 close(pipefd[1]); int status = 0; waitpid(id, &status, 0); if (WIFEXITED(status)) { std::cout << "子进程的退出码" << WEXITSTATUS(status) << std::endl; } else { std::cout << "子进程异常退出!" << std::endl; } return 0; }
代码运行结果:
可以看到通过管道我们能够让两个进程完成进程之间的通信。
4、管道的特点
根据前面我们讲的管道的原理以及实验现象。我们可以总结出一些管道的特点。
- 管道只能进行单向通信,管道的一种特殊半双工通信(管道两侧只能一个进行写入,一个读取,一旦分工确定就不能够再进行更改了)
- 管道的本质是文件,文件描述符
fd
的生命周期是随进程的,进程退出时,文件描述符fd
也会消失,所以管道的生命周期是随进程的。 - 用匿名管道进行通信,这种方式通常只能够让有血缘关系的进程进行通信,因为没有血缘关系的进程并不知道应该打开哪一个管道文件,有血缘关系的进程可以通过继承来打开同一个管道文件。
接下来我们来看一些特殊场景,来帮助我们更好的理解管道的特点:
- 当父进程写的比较慢,子进程读的比较快时。
运行结果:
运行结果是正常的,说明这种通信是合理的。 - 当父进程写的比较快,子进程读的比较慢时。
运行结果:
可以看出,父进程写了多次的内容,子进程一次就全部读取出来了,这就显现出了管道的第四个特点:读和写的次数并没有强相关。
接下来我们继续通过一些特殊情况,来研究管道:
- 假设我们管道的写端不发数据,那读端会怎么办?
运行结果:
可以看到:如果读端读取完毕了所有的管道数据,写端对方不发,读端就只能等待。
- 如果我们写端讲管道写满了还能进行写入吗?
运行结果:
当你运行时,你可以以看到写入65535个后,不再进行写入,过4-5秒以后,可以看到,子进程读取了数据。
运行结果说明了,管道写满了就不能够再进行写入了,而且也说明了管道的大小是64KB。
通过1、2这两种特殊情况,我们能够总结出管道的第五个特点:管道有一定的协同能力,让读端和写端能够按照一定的步骤进行通信(自带同步机制)
- 如果我们在读端读取数据时,突然关闭了写端,会发生什么?
运行结果:
- 写端一直写,读端关闭,会发生什么呢?答案是:这种行为没有意义!OS不会维护无意义,低效率,或者浪费资源的事情。OS会杀死一直在写入的进程! OS会通过信号来终止进程,13)SIGPIPE
运行结果:
管道的第六个特点:
- 当要写入的数据量不大于
PIPE_BUF
时,linux将保证写入的原子性。 - 当要写入的数据量大于
PIPE_BUF
时,linux将不再保证写入的原子性。
在Linux
上PIPE_BUF
通常代表的是4096
字节。
二、有名管道
管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名管道,命名管道是一种特殊类型的文件。
1、创建一个命名管道
命名管道可以从命令行上创建,命令行方法是使用下面这个命令:
mkfifo 文件名
在Linux
中以p
开头的文件类型是管道文件,管道文件不会讲内容刷新到磁盘,所以不管我们怎么操作管道文件,管道文件的大小始终都为0
可以看到的是,本来应该在左边终端里面打印的消息,却被打印到了右边!
另一种方式是在代码中创建有名管道:
- 参数:第一个是参数是路径加文件名,第二个参数是创建的文件的权限。
- 返回值 :如果成功创建就返回
0
,如果创建失败就返回-1
实例代码:
#include <iostream> #include <sys/stat.h> #include <sys/types.h> int main() { umask(0); mkfifo("./fifo", 0666); return 0; }
运行结果:
2、匿名管道与命名管道的区别
- 匿名管道由pipe函数创建并打开。
- 命名管道由mkfifo函数创建,打开用
open
,FIFO(命名管道)与pipe
(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。 - 由于管道不需要刷盘,所以管道文件只有
inode
没有Data block
3、命名管道的原理
因为命名管道是有名称的,我们可以让两个进程分别以读和写的方式打开文件,然后就可以让两个进程进行通信了,命名管道的使用要比匿名管道的使用简单的多!
4、用命名管道实现server&client通信
实例代码:
通过下面的代码我们能够让两个进程显示相同的消息
command.hpp
#include <iostream> #include <cerrno> #include <cstring> #include <unistd.h> #include <fcntl.h> #include <sys/stat.h> #include <sys/types.h> #define NUM 1024 // 创建一个有名管道 int CreatFifo() { umask(0); int err = mkfifo("./fifo", 0664); if (err < 0) { std::cerr << "mkfifo fail: " << errno << strerror(errno) << std::endl; return -1; } return 0; }
serve.cpp
#include "common.hpp" int main() { // 1.创建一个有名管道 int err = CreatFifo(); if (err < 0) { return -1; } std::cout << "create fifo file success" << std::endl; // 2.打开管道的读端 int fd = open("./fifo", O_RDONLY); if (fd < 0) { std::cerr << "open fail: " << errno << strerror(errno) << std::endl; return -1; } std::cout << "open fifo success, begin ipc" << std::endl; // 3.等待客户端的消息 char buffer[NUM] = {0}; while (true) { ssize_t n = read(fd, buffer, sizeof(buffer) - 1); if (n > 0) { buffer[n] = '\0'; std::cout << "server// " << buffer << std::endl; } else if (n == 0) { std::cout << "客户端退出了,我也退出了" << std::endl; break; } else { std::cerr << "read fail: " << errno << strerror(errno) << std::endl; } } // 关闭文件描述符 close(fd); // 删除管道文件 unlink("./fifo"); return 0; }
client.cpp
#include "common.hpp" #include <string> int main() { // 1.打开文件 int fd = open("./fifo", O_WRONLY); if (fd < 0) { std::cerr << "open fail: " << errno << strerror(errno) << std::endl; return -1; } // 2.开始通信 std::string s; while (true) { std::cout << "client// "; std::getline(std::cin, s); if (s == "quit") { break; } ssize_t n = write(fd, s.c_str(), s.size()); if (n < 0) { std::cerr << "write fail: " << errno << strerror(errno) << std::endl; continue; } } // 关闭文件描述符,进程退出 close(fd); return 0; }
运行结果: