目录
在编程的世界中,匿名管道是一种非常重要的通信机制。今天,让我们一起来深入探讨一下匿名管道的奥秘。
一、什么是匿名管道?
匿名管道是一种在具有亲缘关系的进程间进行单向通信的方式。它主要用于父子进程之间的数据传递。 Linux指令中的 | 就是在使用匿名管道:
用于查找当前系统中所有包含字符串
vim
的进程
ps ajx
:使用ps
命令获取系统中的进程信息。|
:竖线|
是管道符号,将前一个命令的输出作为输入传递给下一个命令。grep vim
:使用grep
命令在前面获取的进程信息中搜索包含字符串vim
的行。
可以发现,管道是操作系统提供的资源,让ps ajx这个进程的输出重定向到这个管道资源,然后由另一个进程grep vim 来读取这个管道的内容作为输入,以上就是一个简单的进程间使用匿名管道通信的过程。
三、创建与使用匿名管道
在代码中,可以通过特定的系统调用来创建匿名管道。一旦创建成功,父进程和子进程就可以通过相应的读写操作来进行通信。
1、匿名管道的创建,需要通过下面这个系统调用:
cpp
//返回值:成功返回0,失败返回-1
int pipe(int fd[2]) //参数fd是输出型参数,返回两个fd
这里表示创建一个匿名管道,并返回了两个文件描述符,一个是管道的读取端描述符
fd[0]
,另一个是管道的写入端描述符fd[1]
。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
到此为止,也只是一个进程通过系统调用pipe创建了管道,如何实现通信呢?
我们可以使用 fork
创建子进程,创建的子进程会复制父进程的文件描述符 ,这样就做到了两个进程各有两个「 fd[0]
与 fd[1]
」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
管道只能一端写入,另一端读出,上面这种模式容易造成混乱,所以创建子进程后,我们需要让管道只能单向通信,父子进程根据实际情况各自切断一个读写fd。
- 父进程关闭读取的 fd[0],只保留写入的 fd[1];
- 子进程关闭写入的 fd[1],只保留读取的 fd[0];
最终实现父进程持有写入fd,子进程持有读取fd:
单向信道建立完成后,两个进程分别通过write、read的系统调用来向管道读写,从而实现了进程的通信;
匿名管道的通信的单向的(半双工),所以如果需要父子进程互相通信,我们就要再创建一个管道
以上是父子进程的通信的例子,那如果是向上面指令 ps ajx | grep vim ,通过匿名管道实现通信的原理细节是怎样的呢?
在 shell 里面执行
A | B
命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell;继承了shell的文件描述符,子进程A、B再通过各自关闭自己的一个文件fd,就可以实现A、B进程的单向通信了!
所以,匿名管道可以实现的具有亲缘关系的进程之间的通信(父子、兄弟、爷孙...)
三、匿名管道的特点
对于匿名管道,我们可以总结出四种情况、五种特性:
匿名管道的四种情况
1、正常情况下,如果管道没有数据了,读端会阻塞等待,直到写端写入数据
2、正常情况下,如果管道被写满,写端会阻塞等待,直到读端读取数据
管道是一种临界资源,同一时刻只允许一个进程读取或写入;
管道的数据被读取后,就会标记为失效,允许数据写入时覆盖;
3、写端关闭,读端会一直读取,直到读完管道内的数据,读端read会返回0,表示读到文件结尾
4、读端关闭,此时写端再向这个管道写入已经没有意义且浪费系统资源,OS会向写端进程发送SIGPIPE(13)信号,终止写端进程
匿名管道的五种特性
1、匿名管道仅限于具有血缘关系的进程间通信,常用于父子、兄弟
2、匿名管道默认给读写端提供同步机制,确保读写操作的正确性和顺序性
同步: 两个或两个以上的进程在运行过程中协同步调,按预定的先后次序运行。比如,A任务的运行依赖于B任务产生的数据。
3、匿名管道是面向字节流的
在匿名管道中,数据的传输是连续的,没有明确的边界或结构。发送方可以逐个字节地向管道中写入数据,接收方可以逐个字节地从管道中读取数据,它不需要对数据进行额外的格式化或解析,发送方和接收方只需要关注字节的顺序和数量。
4、匿名管道的生命周期是随进程的
管道本质上是通过文件进行通信的,也就是说管道依赖于文件系统,那么当所有打开该文件的进程都退出后,该文件也就会被释放掉,所以说管道的生命周期随进程。
5、管道是单向通信的,半双工通信的一种特殊情况
半双工通信指数据可以双向交替传输,但不能同时双向传输。而管道这种严格的单向通信可以看作是半双工通信的一种更为特殊和受限的情况。
匿名管道的优势
- 简单易用:提供了一种直接的通信方式。
- 高效:对于少量、频繁的数据交换非常有效。
应用场景
- 进程间简单的命令传递和结果反馈。
- 一些需要快速交互的小型任务协调。
四、匿名管道的实践应用---进程池
接下来通过一个进程池demo,对匿名管道实践应用
首先,什么是进程池呢?
进程池是一种用于管理多个进程资源的机制。
具体来说,进程池预先创建一定数量的进程并保持它们处于待命状态。当有任务需要执行时,直接从进程池中选取一个空闲的进程来处理该任务,而不是每次需要执行任务时都临时创建新的进程。
进程池具有以下一些优点:
- 提高效率:避免了频繁创建和销毁进程的开销,从而提升系统整体性能。
- 资源管理:能够更好地控制和管理系统中的进程资源,确保资源的合理分配。
- 并发处理能力:可以同时处理多个任务,提高系统的并发处理水平。
进程池常用于服务器等需要处理大量并发任务的场景,通过合理配置进程池的大小和管理策略,可以有效地应对高并发的业务需求。
父进程批量创建匿名管道和子进程,父进程设为写端,子进程设为读端,当父进程有任务需要交给子进程时,就选取一个管道写入控制指令,对应子进程读取数据后,根据指令执行特定的任务;我们要考虑子进程完成任务的负载均衡,可以较为平均的把任务交给子进程
以下是代码:
cpp
#include <iostream>
#include <string>
#include <vector>
#include <cassert>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include "Task.hpp"
const int num = 5;
static int number = 1;
//表示通信信道,包含控制文件描述符、进程 ID 和名称
class channel
{
public:
channel(int fd, pid_t id) : ctrlfd(fd), workerid(id)
{
name = "channel-" + std::to_string(number++);
}
public:
int ctrlfd;
pid_t workerid;
std::string name;
};
void Work()
{
while (true)
{
int code = 0;
ssize_t n = read(0, &code, sizeof(code));
if (n == sizeof(code))
{
if (!init.CheckSafe(code))
continue;
init.RunTask(code);
}
else if (n == 0)
{
break;
}
else
{
// do nothing
}
}
std::cout << "child quit" << std::endl;
}
void PrintFd(const std::vector<int> &fds)
{
std::cout << getpid() << " close fds: ";
for(auto fd : fds)
{
std::cout << fd << " ";
}
std::cout << std::endl;
}
// 传参形式:
// 1. 输入参数:const &
// 2. 输出参数:*
// 3. 输入输出参数:&
void CreateChannels(std::vector<channel> *c)
{
// bug
std::vector<int> old; //记录上一轮创建的管道的写端文件描述符
for (int i = 0; i < num; i++)
{
// 1. 定义并创建管道
int pipefd[2];
int n = pipe(pipefd);
assert(n == 0);
(void)n;
// 2. 创建进程
pid_t id = fork();
assert(id != -1);
// 3. 构建单向通信信道
if (id == 0) // child
{
if(!old.empty())//子进程需要关闭从父进程继承到的之前轮次的写端fd
{
for(auto fd : old)
{
close(fd);
}
PrintFd(old);
}
close(pipefd[1]);
dup2(pipefd[0], 0); //将子进程的读端重定向到0,Work就不用传参pipe[0]
Work();
exit(0); // 会自动关闭自己打开的所有的fd
}
// father
close(pipefd[0]);
c->push_back(channel(pipefd[1], id));
old.push_back(pipefd[1]);
// childid, pipefd[1]
}
}
void PrintDebug(const std::vector<channel> &c)
{
for (const auto &channel : c)
{
std::cout << channel.name << ", " << channel.ctrlfd << ", " << channel.workerid << std::endl;
}
}
void SendCommand(const std::vector<channel> &c, bool flag, int num = -1)
{
int pos = 0;
while (true)
{
// 1. 选择任务
int command = init.SelectTask();
// 2. 选择信道(进程)
const auto &channel = c[pos++];
pos %= c.size();
// debug
std::cout << "send command " << init.ToDesc(command) << "[" << command << "]"
<< " in "
<< channel.name << " worker is : " << channel.workerid << std::endl;
// 3. 发送任务
write(channel.ctrlfd, &command, sizeof(command));
// 4. 判断是否要退出
if (!flag)
{
num--;
if (num <= 0)
break;
}
sleep(1);
}
std::cout << "SendCommand done..." << std::endl;
}
void ReleaseChannels(std::vector<channel> c)
{
// version 2
// int num = c.size() - 1;
// for (; num >= 0; num--)
// {
// close(c[num].ctrlfd);
// waitpid(c[num].workerid, nullptr, 0);
// }
// version 1
for (const auto &channel : c)
{
close(channel.ctrlfd);
waitpid(channel.workerid, nullptr, 0);
}
// for (const auto &channel : c)
// {
// pid_t rid = waitpid(channel.workerid, nullptr, 0);
// if (rid == channel.workerid)
// {
// std::cout << "wait child: " << channel.workerid << " success" << std::endl;
// }
// }
}
int main()
{
std::vector<channel> channels;
// 1. 创建信道,创建进程
CreateChannels(&channels);
// 2. 开始发送任务
const bool g_always_loop = true;
// SendCommand(channels, g_always_loop);
SendCommand(channels, !g_always_loop, 10);
// 3. 回收资源,想让子进程退出,并且释放管道,只要关闭写端
ReleaseChannels(channels);
return 0;
}
- 定义了一个
channel
类来表示通信信道,包含控制文件描述符、进程 ID 和名称。Work
函数用于子进程不断从标准输入读取指令并进行处理。CreateChannels
函数创建一定数量的管道和相应的子进程,并构建单向通信信道,同时记录相关信息到channel
对象并添加到vector<channel>中。PrintDebug
函数用于打印信道的相关信息。SendCommand
函数根据条件选择任务和信道,向信道发送任务命令。ReleaseChannels
函数用于释放信道资源,包括关闭文件描述符和等待子进程结束。需要注意的是,
CreateChannels
中,创建了信道和子进程后,把子进程写端fd:pipefd[0]重定向到了0,将子进程的读端重定向到标准输入(文件描述符 0)之后,在Work
函数中就可以直接从标准输入读取数据,而不需要再专门传递管道的读端文件描述符pipe[0]
了;
是因为每个子进程的读端都被重定向到了0,当子进程执行Work时,就直接从它们各自的文件描述符表中读取0即可,因为进程池中的每个子进程原本的读端fd是不同的;子进程执行work,调用read时就需要不同的文件描述符。
还有一个需要注意的点:
CreateChannels中old的作用
在创建新的进程和管道时,old用于记录上一轮创建的管道的写端文件描述符。当子进程创建后,在子进程中需要关闭之前轮次创建的这些管道写端,保证每个管道文件都只有一个写端指向和一个读端指向,以确保资源的正确管理和避免干扰。
Task.hpp:
cpp
#pragma once
#include <iostream>
#include <functional>
#include <vector>
#include <ctime>
#include <unistd.h>
// using task_t = std::function<void()>;
typedef std::function<void()> task_t;
void Download()
{
std::cout << "我是一个下载任务"
<< " 处理者: " << getpid() << std::endl;
}
void PrintLog()
{
std::cout << "我是一个打印日志的任务"
<< " 处理者: " << getpid() << std::endl;
}
void PushVideoStream()
{
std::cout << "这是一个推送视频流的任务"
<< " 处理者: " << getpid() << std::endl;
}
// void ProcessExit()
// {
// exit(0);
// }
class Init
{
public:
// 任务码
const static int g_download_code = 0;
const static int g_printlog_code = 1;
const static int g_push_videostream_code = 2;
// 任务集合
std::vector<task_t> tasks;
public:
Init()
{
tasks.push_back(Download);
tasks.push_back(PrintLog);
tasks.push_back(PushVideoStream);
srand(time(nullptr) ^ getpid());
}
bool CheckSafe(int code)
{
if (code >= 0 && code < tasks.size())
return true;
else
return false;
}
void RunTask(int code)
{
return tasks[code]();
}
int SelectTask()
{
return rand() % tasks.size();
}
std::string ToDesc(int code)
{
switch (code)
{
case g_download_code:
return "Download";
case g_printlog_code:
return "PrintLog";
case g_push_videostream_code:
return "PushVideoStream";
default:
return "Unknow";
}
}
};
Init init; // 定义对象
- 定义了任务类型
task_t
为std::function<void()>
,方便表示各种无参数无返回值的任务函数。- 定义了一些具体的任务函数,如
Download
、PrintLog
、PushVideoStream
等,它们输出一些描述信息和当前进程 ID。Init
类负责管理任务集合:
- 在构造函数中初始化任务集合,并设置随机数种子。
CheckSafe
方法用于检查任务码是否合法。RunTask
方法根据任务码执行相应任务。SelectTask
方法随机选择一个任务码。ToDesc
方法根据任务码返回任务描述字符串。- 最后定义了一个全局的
Init
对象init
。