(在阅读本文前,需要具备Linux基础IO的基本知识)
在某些特定情况下,多进程需要协同处理任务,此时就需要进程交互。然而我们知道进程之间是具有的独立性的,因此数据交互的成本较高。在这种矛盾的推动下,进程间通信就应运而生了,它主要有以下四种目的:
两个进程之间是没有交集的,因此进程间通信的本质在于如何让两个进程看到同一份资源,而不在于如何通信,资源的不同决定了通信方式的不同。理解这点非常重要!!
我们可以分别使用 cat()
和 echo()
指令打印同一份磁盘文件内容时,这本质上也属于一种非常原始的通信—— cat 进程和 echo 进程看到了同一份磁盘资源。但是磁盘的读写速度是非常慢的,我们期待实现内存级别的进程间通信,因此必须要以内存作为数据的缓冲区。
匿名管道想必大家都接触过,其实就是命令行窗口中的 |
,例如下图这个例子:匿名管道 |
将 yum list 指令的输出结果作为 grep ncurses 指令的输入数据。为什么要叫它管道呢?其实也不难理解,谈到管道大家可能会联想到石油,而数据就是计算机世界中的石油,传输数据的媒介自然叫管道。
一个指令其实就对应着一个进程,因此,通过匿名管道,我们实现了 yum 进程和 grep 进程间的数据交互
从上面的例子中,我们也能初步总结出管道的特点(对命名管道同样适用):
当我们使用 fork() 函数创建子进程的时候,子进程的内核数据结构基本拷贝自父进程,自然而然的子进程也会继承父进程的 文件描述符表 (Linux基础IO(二):深入理解Linux文件描述符),这就意味着子进程所打开的文件和父进程打开的是完全一样的。父子进程向同一个文件中读取或写入,由此实现进程间通信。
然而上文中我们也谈到,磁盘的访问速度太慢,我们需要实现内存级别的进程间通信。因此父进程所打开的管道文件是一种特殊的文件,它与磁盘去关联,写入时直接向缓冲区中写入,读取时也直接从缓冲区中读取,由此实现内存级别的通信。
[问题一]:如何区分普通文件和管道文件?
// 在inode结构体下可以看到如下的联合体(adree_space包含有struct inode) struct inode { union { struct pipe_inode_info *i_pipe; // 管道设备 struct block_device *i_bdev; // 磁盘设备 struct cdev *i_cdev; // 字符设备 }; }
底层是通过一个联合体来区分文件属性的,当创建一个管道的时候,对应的管道字段就会生效
[问题二]:为什么父进程要先后以读写的方式打开同一个管道文件?
答:子进程拷贝父进程后,就不需要再以读或者写的方式打开管道文件了。
[问题三]:为什么父子进程要分别关闭读端和写端
答:确保管道通信的单向性,例如上图中数据只能从父进程流向子进程
[问题四]:父进程究竟是关闭读端还是关闭写端是由什么决定的?
答:由用户需求决定。如果希望数据从父进程流向子进程,就关闭父进程的读端,子进程的写端;如果希望数据从子进程流向父进程,就关闭父进程的写端,子进程的读端
[作用]:创建匿名管道
[参数说明]: pipefd[2]是一个返回型参数
[返回值]:创建管道成功返回0;失败返回-1
[函数说明]:
pipe()
函数自动以读写的方式打开同一个管道文件并将文件描述符返回给 pipefd[2][管道读写规则]:
O_NONBLOCK
disable:read调用阻塞,一直等到有数据来到为止O_NONBLOCK
enable:read调用返回-1,errno值为EAGAINO_NONBLOCK
disable: write调用阻塞,直到有进程读走数据O_NONBLOCK
enable:调用返回-1,errno值为EAGAINSIGPIPE
,进而可能导致write进程退出// 使用案例:数据从父进程传递给子进程
int main()
{
int pipefd[2] = {0};
if(pipe(pipefd) != 0) // 创建管道失败
{
cerr << "pipe" << endl;
return 1;
}
pid_t pid = fork();
if(pid == 0) // 子进程关闭写端
{
close(pipefd[1]);
char buff[20];
while(true)
{
ssize_t ss = read(pipefd[0], buff, sizeof(buff));
if(ss == 0)
{
cout << "父进程不写入了,我也不读取了" << endl;
break;
}
buff[ss] = '\0';
cout << "子进程收到消息:" << buff << " 时间:" << time(nullptr) << endl;
}
}
else // 父进程关闭读端
{
close(pipefd[0]);
char msg[] = "hello world!";
for(int i = 0; i < 5; i++)
{
// 不要写入字符串末尾的'\0'
// ‘\0’结尾是C语言的标准,文件可不吃这一套
write(pipefd[1], msg, sizeof(msg) - 1);
sleep(1);
}
cout << "父进程写入完毕" << endl;
close(pipefd[1]);
waitpid(pid, nullptr, 0);
}
return 0;
}
[问题五]:为什么子进程没有sleep,但是会随着父进程休眠呢?(观察时间戳)
答:pipe函数自带访问控制机制,父子读写时具有一定的顺序性:
- 当一个进程尝试从一个空的管道中读取时,read接口会被阻塞直到管道内有数据为止
- 当一个进程尝试向一个满的管道中写入时,write接口会被阻塞直到足量的数据从管道中被读取走为止
[问题六]:子进程如何感知父进程关闭管道了呢?
答:每当一个进程打开一个文件的时候,该文件的引用计数会加一;每当一个进程关闭一个文件的时候,该文件的引用计数会减一。当一个文件的引用计数减为0时,表明没有进程打开这个文件,那么这个文件才会被真正被关闭。当管道文件的引用计数为1时,表明父进程已经关闭管道文件,子进程读完当前消息就可以作为文件的结尾而退出了。因此子进程是可以感知父进程是否关闭写端的。
#include
#include
#include
#include
#include
#include
#include
#include
using namespace std;
typedef void(*function)();
vector<function> task;
void func1()
{
cout << "正在执行网络任务……" << " 时间" << time(nullptr) << endl;
}
void func2()
{
cout << "正在执行磁盘任务……" << " 时间" << time(nullptr) << endl;
}
void func3()
{
cout << "正在执行驱动任务……" << " 时间" << time(nullptr) << endl;
}
void LoadFunc() // 加载任务到vector中
{
task.push_back(func1);
task.push_back(func2);
task.push_back(func3);
}
int main()
{
LoadFunc();
srand((unsigned int)time(nullptr) ^ getpid());
int pipefd[2] = {0};
if(pipe(pipefd) != 0)
{
cerr << "pipe" << endl;
return 1;
}
pid_t pid = fork();
if(pid == 0)
{
close(pipefd[1]);
while(true)
{
uint32_t n = 0;
ssize_t ss = read(pipefd[0], &n, sizeof(uint32_t));
if(ss == 0)
{
cout << "我是打工人,老板走了,我也下班了" << endl;
break;
}
assert(ss == sizeof(uint32_t));
task[n]();
}
}
else
{
close(pipefd[0]);
for(int i = 0; i < 10; i++)
{
uint32_t ss = rand() % 3;
write(pipefd[1], &ss, sizeof(uint32_t));
sleep(1);
}
cout << "任务全部处理完毕" << endl;
close(pipefd[1]);
waitpid(pid, nullptr, 0);
}
return 0;
}
- 匿名管道只能用于具有血缘关系的进程之间。通常用于父子进程之间通信(兄弟之间也可以:父进程打开管道后创建两次子进程,再将父进程的管道的读写端关闭,就可以实现两个兄弟进程间的通信)
- 管道必须是单向的,Linux内核在设计的时候就是当管道为单向的
- 管道自带访问控制机制
- 管道是面向字节流的。先写的字符一定是先被读取的、读取时没有格式边界需要用户来限制内容的边界(例如在使用read函数时我们要指定读取多少个字节)
- 管道也是文件,当进程退出管道的引用计数被减为0时管道的生命周期才会结束
- 一般而言,内核会对管道操作进行同步与互斥