准备工作:理解原理与编写 Makefile
一、进程池原理
一个进程预先创建多个子进程,同时和每个子进程建立管道通信,父进程作为写端,子进程们作为读端
当父进程没有向管道写入数据时,子进程就会阻塞等待
而父进程写入的数据某种程度上可以视为一种命令
只有父进程向某个子进程写入命令,子进程才能接收到命令并做执行操作,否则就等待
就像将军指定派发任务给某个士兵命令,若没有收到命名则等待
这种情况就是进程池!
发布命令的父进程称为: master
接收任务的子进程称为:worker
/ slaver
在命令行窗口使用匿名管道:
$ ps ajx | grep hello
ps axj | grep hello
:这样管道连起来的两个命令,本质上是父进程shell创建两个子进程,这两个子进程是兄弟进程,通过管道连接起来,前者作为写端,后者作为读端
我们也可以往自己的 自制 shell 中创建这种功能:本质思路是识别有多少个竖画线管道,就循环创建多少个子进程(注意是+1:一个管道两个进程,最终创建进程数量为 管道数+1)
每次循环创建一个一对管道连接
像是进程池这种池化机制一定要了解,利于理解网络部分
二、编写 Makefile
解释一下该 Makefile
中用到的语法知识
-
变量:在
Makefile
命名变量,然后使用$(变量)
的方式就能使用该变量 -
通配符
%
:和 shell 中的*
是一个用法 -
$@
:表示要形成的目标文件 -
$^
:表示所有用于编译形成目标文件的文件,如目标文件是.exe
文件,则$^
作用是将所有需要编译的.o
文件展开,用于编译 -
-Wall
:w
是worning
警告,all
所有,将所有警告全部显示出来 -
罗列当前目录下的所有
.cc
文件:-
$(shell ls *.cc)
:相当于在 shell 中执行命令ls *.cc
,然后将结果放到这 -
$(wildcard *.cc)
:这是Makefile
中一个函数语法,本质上就是执行上面这条shell
命令
-
-
将
.cc
文件形成同名.o
:- 配合
SRC=$(shell ls *.cc)
,将当前目录下的所有.cc
文件形成同名.o
:$(SRC:.c=.o)
- 配合
-
变量名
FLAGS
:在 Makefile 中,FLAGS
是一个常用的变量名,用于存储编译器的命令选项和标志
如何在 shell
中快速创建同名且带有序号的多个文件:
例如创建:code1.cc、code2.cc、code3.cc...
touch code{1..10}.cc #创建十个
编写一段 Makefile
代码,通过 test
试验一下 $(SRC)
和 $(OBJ)
功能
test
通过两个 echo
命令将 $(SRC)
和 $(OBJ)
打印出来
BIN=processpool
CC=g++
FLAGS=-c -Wall -std=c++11
LDFLAGS=-o
#SRC=$(shell ls *.cc)
SRC=$(wildcard *.cc)
OBJ=$(SRC:.cc=.o)
.PHONY:clean
clean:
rm -rf $(SRC) $(OBJ)
.PHONY:test
test:
@echo $(SRC)
@echo $(OBJ)
先在当前目录下创建十个 code
,再使用 make test
结果如下:确实如愿的将所有 .cc
文件展示出来,并且将所有 .cc
文件变成同名 .o
后缀文件
这段代码是一个GNU Makefile的片段,用于编译C++项目。下面是对每个部分的解释:
BIN=processpool
:定义了一个名为BIN
的变量,其值为processpool
。通常这个变量用来指定最终生成的可执行文件的名字。
CC=g++
:指定了C++编译器为g++
。CC
在这里不是标准命名,通常使用CXX
来表示C++编piler,但你也可以使用CC
作为变量名。
FLAGS=-c -Wall -std=c++11
:定义了编译标志(FLAGS
),其中:
-c
表示编译或汇编源文件,但是不进行链接。-Wall
启用所有警告信息。-std=c++11
指定使用C++11标准进行编译。
LDFLAGS=-o
:这里看起来有些问题,因为通常LDFLAGS
用于指定链接器选项,而-o
是输出文件名的选项,后面应该跟随输出文件的名字。在这个例子中,它可能被设计为与其他字符串组合使用以形成完整的命令行参数。
SRC=$(wildcard *.cc)
:使用wildcard
函数查找当前目录下所有的.cc
文件,并将它们的名称赋值给SRC
变量。这与注释掉的那一行#SRC=$(shell ls *.cc)
功能相似,后者通过shell命令ls
列出所有.cc
文件。
OBJ=$(SRC:.cc=.o)
:这是一个**替换引用,将SRC
中的所有.cc
后缀替换为.o
,从而生成目标文件的名字列表**。
.PHONY:clean
和clean:
规则定义了一个名为clean
的伪目标,它不是一个实际的文件,而是用于清理生成的文件。执行make clean
时,会删除所有源文件(.cc
)和目标文件(.o
)。
.PHONY:test
和test:
定义了另一个伪目标test
,运行make test
时,不会生成任何文件,而是简单地打印出SRC
和OBJ
变量的内容,这对于调试Makefile很有帮助。
SRC
和OBJ
本质上都是记录一些文件名
在该Makefile片段中:
SRC=$(wildcard *.cc)
:确实用于记录当前目录下所有以.cc
为后缀的文件名。通过使用wildcard
函数,它会自动查找并列出目录内所有符合模式*.cc
的文件,然后将这些文件名存储在SRC
变量中。
OBJ=$(SRC:.cc=.o)
:这个表达式的作用是基于SRC
变量中的文件名列表,将其中每个文件名的后缀从.cc
替换为.o
,从而生成一个新的列表。这个新列表存储在OBJ
变量中,代表了每一个源文件对应的编译后的目标文件(通常是编译过程中生成的中间文件)的名称。因此,
SRC
和OBJ
本质上都是用于记录文件名的变量,分别对应着项目中的源代码文件(.cc文件)和它们编译后的目标文件(.o文件)。这种机制便于在Makefile中引用这些文件进行编译、链接等操作,而无需手动列出每个文件的名字。这不仅提高了灵活性,也使得添加或删除源文件时不需要修改Makefile,只需保证文件命名遵循相应的规则即可。
完整版Makefile
代码
BIN=processpool
CC=g++
FLAGS=-c -Wall -std=c++11
LDFLAGS=-o
#SRC=$(shell ls *.cc)
SRC=$(wildcard *.cc)
OBJ=$(SRC:.cc=.o)
#编译可执行程序:将所有.o文件一起展开编译成可执行程序
$(BIN):$(OBJ)
$(CC) $(LDFLAGS) $@ $^
#将所有.cc文件一一编译成.o文件
%.o:%.cc
$(CC) $(FLAGS) $<
.PHONY:clean
clean:
rm -rf $(OBJ)
#下面这坨是测试
.PHONY:test
test:
@echo $(SRC)
@echo $(OBJ)
正式编写:进程池代码
一、初始化进程池
1、创建多个子进程
-
通过
main
命令行参数获取需要创建子进程的个数-
判断
argc
个数,使用usage
提示在编程和命令行工具中,“usage” 通常指的是命令或程序的使用说明,即如何正确使用该命令或程序
-
-
master
循环创建子进程与链接管道 -
创建管道类:
master
管理多个通过管道链接通信的子进程:先描述再组织,将管道描述成一个类- 记录该管道的写端(即父进程的写端 fd)
- 管道的名字:便于 debug
- 子进程 id:为了好维护,后期也会用到一个管道需要对应一下他的子进程
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
// 管道类
class Channel
{
public:
Channel() = default;
Channel(int fd, int order, int pid)
: _write_fd(fd),
_process_id(pid)
{
_ChannelName = std::string(std::to_string(order)) + " 号管道, pid = " + std::to_string(pid) + ", write_fd = " + std::to_string(fd);
}
~Channel() = default;
// Get 函数
int getWriteFd() const { return _write_fd; }
std::string getChannelName() const { return _ChannelName; }
int getProcessId() const { return _process_id; }
private:
int _write_fd; // 记录当前管道连接的写端
std::string _ChannelName; // 记录当前管道的名字
int _process_id; // 记录当前管道连接的进程 id
};
void Work()
{
std::cout << "I am child, pid: " << getpid() << '\n';
// sleep(2);
}
// 打印管道信息
void DebugPrint(std::vector<Channel> &channels)
{
for (auto &i : channels)
{
std::cout << i.getChannelName() << '\n';
}
}
void Usage()
{
std::cout << "Usage: [filename] [number], example: ./myfile 3" << '\n';
}
int main(int argc, char *argv[])
{
// 创建子进程,同时创建和连接管道, 同时派发进程任务
// 根据命令行参数决定创建多少个子进程: 缺省为 2 个子进程
if (argc > 2)
{
Usage();
return 1;
}
// 组织管道: 创建一个管道数组
std::vector<Channel> ChannelArr;
int procNum = argc < 2 ? 2 : atoi(argv[1]);
// 创建子进程+创建和连接管道
for (int i = 0; i < procNum; ++i)
{
// 1.创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if (ret < 0)
{
std::cerr << "Channel error" << std::endl;
return 1;
}
// 2.创建子进程
int pid = fork();
if (pid < 0)
{
std::cerr << "fork error" << std::endl;
return 1;
}
else if (pid == 0)
{
// 子进程:关闭写端,必须注意因为子进程继承了父进程的管道读写端,父进程本身还留存着上一个有效子进程的写端,所以必须关闭前面所有继承下来的”意外“
int n = i;
while (n--)
{
close(ChannelArr[n].getWriteFd());
}
// 子进程的业务逻辑:从管道中读取数据, 执行work函数
char buff[1024] = {0};
read(fd[1], buff, sizeof(buff));
// 子进程的业务逻辑
Work();
// 子进程结束:关闭读端
close(fd[0]);
exit(0);
}
else
{
// 父进程:关闭读端
close(fd[0]);
// 创建管道, 将当前管道的信息记录到管道数组中
// Channel p(fd[1], i, pid);
// ChannelArr.push_back(p);
ChannelArr.emplace_back(fd[1], i, pid);
}
}
sleep(2);
DebugPrint(ChannelArr);
return 0;
}
2、优化
加一条优化:将进程池初始化部分封装成一个函数
加一条优化:设置退出码枚举
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
enum exit_code
{
OK = 0,
fork_failed = 1,
Channel_failed = 2,
argc_error = 3
};
// 管道类
class Channel
{
public:
Channel() = default;
Channel(int fd, int order, int pid)
: _write_fd(fd),
_process_id(pid)
{
_ChannelName = std::string(std::to_string(order)) + " 号管道, to childprocess pid = " + std::to_string(pid) + ", write_fd = " + std::to_string(fd);
}
~Channel() = default;
// Get 函数
int getWriteFd() const { return _write_fd; }
std::string getChannelName() const { return _ChannelName; }
int getProcessId() const { return _process_id; }
private:
int _write_fd; // 记录当前管道连接的写端
std::string _ChannelName; // 记录当前管道的名字
int _process_id; // 记录当前管道连接的进程 id
};
void Work()
{
std::cout << "I am child, pid: " << getpid() << '\n';
// sleep(2);
}
// 创建子进程+创建和连接管道
int InitProcessPool(std::vector<Channel> &ChannelArr, int nums)
{
for (int i = 0; i < nums; ++i)
{
// 1.创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if (ret < 0)
{
std::cerr << "Channel error" << std::endl;
return Channel_failed;
}
// 2.创建子进程
int pid = fork();
if (pid < 0)
{
std::cerr << "fork error" << std::endl;
return fork_failed;
}
else if (pid == 0)
{
// 子进程:关闭写端,必须注意因为子进程继承了父进程的管道读写端,父进程本身还留存着上一个有效子进程的写端,所以必须关闭前面所有继承下来的”意外“
int n = i;
while (n--)
{
close(ChannelArr[n].getWriteFd());
}
// 子进程的业务逻辑:从管道中读取数据, 执行work函数
char buff[1024] = {0};
read(fd[1], buff, sizeof(buff));
// 子进程的业务逻辑
Work();
// 子进程结束:关闭读端
close(fd[0]);
exit(0);
}
else
{
// 父进程:关闭读端
close(fd[0]);
// 创建管道, 将当前管道的信息记录到管道数组中
// Channel p(fd[1], i, pid);
// ChannelArr.push_back(p);
ChannelArr.emplace_back(fd[1], i, pid);
}
}
return OK;
}
void DebugPrint(std::vector<Channel> &channels)
{
for (auto &i : channels)
{
std::cout << i.getChannelName() << '\n';
}
}
void Usage()
{
std::cout << "Usage: [filename] [number], example: ./myfile 3" << '\n';
}
int main(int argc, char *argv[])
{
// 创建子进程,同时创建和连接管道, 同时派发进程任务
// 根据命令行参数决定创建多少个子进程: 缺省为 2 个子进程
if (argc > 2)
{
Usage();
return argc_error;
}
// 组织管道: 创建一个管道数组
std::vector<Channel> ChannelArr;
int procNum = argc < 2 ? 2 : atoi(argv[1]);
InitProcessPool(ChannelArr, procNum);
sleep(2);
DebugPrint(ChannelArr);
return 0;
}
加一条优化: 当前设计的子进程 work
函数有点固定化,我们希望子进程能够执行我们指定的不同任务,即通过 function
包装器包装
使得创建进程和子进程执行任务解耦合
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
using work_t = std::function<void()>; // 注意是函数指针类型 void()
enum exit_code
{
OK = 0,
fork_failed = 1,
Channel_failed = 2,
argc_error = 3
};
// 管道类
class Channel
{
public:
Channel() = default;
Channel(int fd, int order, int pid)
: _write_fd(fd),
_process_id(pid)
{
_ChannelName = std::string(std::to_string(order)) + " 号管道, to childprocess pid = " + std::to_string(pid) + ", write_fd = " + std::to_string(fd);
}
~Channel() = default;
// Get 函数
int getWriteFd() const { return _write_fd; }
std::string getChannelName() const { return _ChannelName; }
int getProcessId() const { return _process_id; }
private:
int _write_fd; // 记录当前管道连接的写端
std::string _ChannelName; // 记录当前管道的名字
int _process_id; // 记录当前管道连接的进程 id
};
void Work()
{
std::cout << "I am child, pid: " << getpid() << '\n';
// sleep(2);
}
void Work_print()
{
std::cout << "This is 打印 work !" << '\n';
}
void Work_sql()
{
std::cout << "This is 数据库同步 work !" << '\n';
}
void Work_backup()
{
std::cout << "This is 备份 work !" << '\n';
}
// 创建子进程+创建和连接管道
int InitProcessPool(std::vector<Channel> &ChannelArr, int nums, work_t work)
{
for (int i = 0; i < nums; ++i)
{
// 1.创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if (ret < 0)
{
std::cerr << "Channel error" << std::endl;
return Channel_failed;
}
// 2.创建子进程
int pid = fork();
if (pid < 0)
{
std::cerr << "fork error" << std::endl;
return fork_failed;
}
else if (pid == 0)
{
// 子进程:关闭写端,必须注意因为子进程继承了父进程的管道读写端,父进程本身还留存着上一个有效子进程的写端,所以必须关闭前面所有继承下来的”意外“
int n = i;
while (n--)
{
close(ChannelArr[n].getWriteFd());
}
// 子进程的业务逻辑:从管道中读取数据, 执行work函数
char buff[1024] = {0};
read(fd[1], buff, sizeof(buff));
// 子进程的业务逻辑
work();
// 子进程结束:关闭读端
close(fd[0]);
exit(0);
}
else
{
// 父进程:关闭读端
close(fd[0]);
// 创建管道, 将当前管道的信息记录到管道数组中
// Channel p(fd[1], i, pid);
// ChannelArr.push_back(p);
ChannelArr.emplace_back(fd[1], i, pid);
}
}
return OK;
}
void DebugPrint(std::vector<Channel> &channels)
{
for (auto &i : channels)
{
std::cout << i.getChannelName() << '\n';
}
}
void Usage()
{
std::cout << "Usage: [filename] [number], example: ./myfile 3" << '\n';
}
int main(int argc, char *argv[])
{
// 创建子进程,同时创建和连接管道, 同时派发进程任务
// 根据命令行参数决定创建多少个子进程: 缺省为 2 个子进程
if (argc > 2)
{
Usage();
return argc_error;
}
// 组织管道: 创建一个管道数组
std::vector<Channel> ChannelArr;
int procNum = argc < 2 ? 2 : atoi(argv[1]);
InitProcessPool(ChannelArr, procNum, Work_print);
// InitProcessPool(ChannelArr, procNum, Work_sql);
// InitProcessPool(ChannelArr, procNum, Work_backup);
sleep(2);
DebugPrint(ChannelArr);
return 0;
}
运行结果如下:
至此,我们就可以通过指定数量的创建进程,让进程执行指定的任务了!!
二、派发和接收任务
传输任务码:我们不直接派发字符串的任务,而是通过任务码编号每个任务,子进程通过读取任务码,到任务数组中找到需要执行的任务
制定协议:父进程向管道内写入 int 数据,表示任务码,而子进程一次读取 4 个字节,表示一次读取出一个 int 大小的数据(这里有点像制定通信协议)
有些平台 int 的大小不定,为了兼容性,可以 sizeof(int)
而并非 4 这样的硬编码
派发任务的顺序:因为进程池中有多个子进程可以执行任务,我们应该将任务尽量平均分配给每个子进程,这就是负载均衡
选择管道就是选择目标子进程!
派发任务的顺序方式:可以轮询派发、可以随机派发、可以派发给任务量少的…… 我们这里使用轮询派发
// 2、派发任务: 选择任务、选择子进程、派发任务
while(true)
{
}
将任务 封装成任务类,并与其相关方法封装到头文件中 Task.hpp
任务码对应任务的结构:我们使用 unordered_map<int, task_t> tasks
实现
Task.hpp
任务类
#pragma once
#include<iostream>
#include<unordered_map>
#include<functional>
#include<unistd.h>
#include<ctime>
using task_t = std::function<void()>;
void task_print()
{
std::cout << "This is 打印任务 ..., pid: " << getpid() << '\n';
}
void task_sql()
{
std::cout << "This is 数据库同步任务 ..., pid: " << getpid() << '\n';
}
void task_backup()
{
std::cout << "This is 备份任务 ..., pid: " << getpid() << '\n';
}
class TaskManger
{
public:
// 构造和析构
TaskManger()
{
// 初始插入三个任务
Insert_Task(task_print);
Insert_Task(task_sql);
Insert_Task(task_backup);
srand(time(0));
}
~TaskManger()
{
}
void Insert_Task(task_t t)
{
tasks[num++] = t;
}
void Execute_Task(int num)
{
if(tasks.find(num) == tasks.end()) return;
tasks[num]();
}
int Choose_Task()
{
// 随机一个任务
return rand() % num; // 刚好在合法范围内随机
}
private:
std::unordered_map<int, task_t> tasks;
static int num;
};
int TaskManger::num = 0;
TaskManger taskmanger; // 全局定义一个单例即可
processpool.cc
函数代码
加一条优化: 使得每个子进程都从自己的标准输入里面读取,而非固定的 管道读端
为什么要加这条优化:每个子进程会调用
Work()
函数,在该函数内部,子进程需要从管道读取任务代号,执行指定代号的任务而
read
从管道读取就需要参数:该子进程对于的管道读端,每个子进程的读端不一定相同,因此导致需要向Work()
函数 传入一个参数(子进程对于读端)但是我们设计了
using work_t = function<void()>;
此时若传递一个参数,无疑是破坏这个结构
因此,不如统一将所有子进程的管道读端重定向为 标准输入,使得每个子进程读管道就从
fd=0
读取
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
#include "Task.hpp"
enum exit_code
{
OK = 0,
fork_failed = 1,
Channel_failed = 2,
argc_error = 3,
write_error = 4
};
// 管道类
class Channel
{
public:
Channel() = default;
Channel(int fd, int order, int pid)
: _write_fd(fd),
_process_id(pid)
{
_ChannelName = std::string(std::to_string(order)) + " 号管道, to childprocess pid = " + std::to_string(pid) + ", write_fd = " + std::to_string(fd);
}
~Channel() = default;
// Get 函数
int getWriteFd() const { return _write_fd; }
std::string getChannelName() const { return _ChannelName; }
int getProcessId() const { return _process_id; }
private:
int _write_fd; // 记录当前管道连接的写端
std::string _ChannelName; // 记录当前管道的名字
int _process_id; // 记录当前管道连接的进程 id
};
// 子进程的业务逻辑
void Work()
{
// 从管道读取: 即标准输入中读
while (1)
{
// ssize_t read(int fd, void *buf, size_t count);
int cmd = 0;
int n = read(0, &cmd, sizeof(cmd));
// 读取到数据时
if (n == sizeof(cmd))
{
std::cout << "I am child, pid: " << getpid() << '\n';
taskmanger.Execute_Task(cmd); // 运行任务
}
else if (n == 0)
{
break; // 当读取不到数据时, 退出循环读取管道
}
else
{
std::cerr << "read error !" << '\n';
}
}
}
// 创建子进程+创建和连接管道
int InitProcessPool(std::vector<Channel> &ChannelArr, int nums, task_t work)
{
for (int i = 0; i < nums; ++i)
{
// 1.创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if (ret < 0)
{
std::cerr << "Channel error" << std::endl;
return Channel_failed;
}
// 2.创建子进程
int pid = fork();
if (pid < 0)
{
std::cerr << "fork error" << std::endl;
return fork_failed;
}
else if (pid == 0)
{
// 子进程:关闭写端,必须注意因为子进程继承了父进程的管道读写端,父进程本身还留存着上一个有效子进程的写端,所以必须关闭前面所有继承下来的”意外“
int n = i;
while (n--)
{
close(ChannelArr[n].getWriteFd());
}
// 子进程的业务逻辑
dup2(fd[0], 0);
work();
// 子进程结束:关闭读端
close(fd[0]);
exit(0);
}
else
{
// 父进程:关闭读端
close(fd[0]);
// 创建管道, 将当前管道的信息记录到管道数组中
// Channel p(fd[1], i, pid);
// ChannelArr.push_back(p);
ChannelArr.emplace_back(fd[1], i, pid);
}
}
return OK;
}
void DebugPrint(std::vector<Channel> &channels)
{
for (auto &i : channels)
{
std::cout << i.getChannelName() << '\n';
}
}
void Usage()
{
std::cout << "Usage: [filename] [number], example: ./myfile 3" << '\n';
}
// 选择任务:即轮询选择任务数组中的任务
Channel& Choose_process(std::vector<Channel> &channels)
{
// 轮询选一个管道类对象
static int num = 0;
// 获取该管道
return (channels[(num++) % channels.size()]);
}
// 派发任务: 选择子进程,向子进程发送任务
int send_Tasks(int fd, int taskcode)
{
// 父进程向指定fd的管道写入taskcode
// ssize_t write(int fd, const void *buf, size_t count);
int n = write(fd, &taskcode, sizeof(taskcode));
if (n < 0)
return write_error;
return OK;
}
int main(int argc, char *argv[])
{
// 创建子进程,同时创建和连接管道, 同时派发进程任务
// 根据命令行参数决定创建多少个子进程: 缺省为 2 个子进程
if (argc > 2)
{
Usage();
return argc_error;
}
// 1、组织管道: 创建一个管道数组
std::vector<Channel> ChannelArr;
int procNum = argc < 2 ? 2 : atoi(argv[1]);
InitProcessPool(ChannelArr, procNum, Work);
// 2、派发任务: 选择任务、选择子进程、发送任务
while (true)
{
// 选择任务
int taskcode = taskmanger.Choose_Task();
// 选择子进程:即获取管道对象
Channel& channel = Choose_process(ChannelArr);
// 日志
std::cout << "——————————————————————————" << '\n';
std::cout << "send " << taskcode << " 号任务 to " << channel.getChannelName() << '\n';
// 派发任务
send_Tasks(channel.getWriteFd(), taskcode);
sleep(1);
}
sleep(2);
DebugPrint(ChannelArr);
return 0;
}
代码运行结果如下:
由图可知,任务被随机选择轮询式的派发给不同管道(即子进程),子进程在 work
函数中读取 int 大小的任务码,并运行任务
进一步优化: 将派发任务的逻辑封装到函数 DispatchTasks()
中,其他不变
void DispatchTasks(std::vector<Channel> &channels)
{
while (true)
{
// 选择任务
int taskcode = taskmanger.Choose_Task();
// 选择子进程:即获取管道对象
Channel& channel = Choose_process(channels);
// 日志
std::cout << "——————————————————————————" << '\n';
std::cout << "send " << taskcode << " 号任务 to " << channel.getChannelName() << '\n';
// 派发任务
send_Tasks(channel.getWriteFd(), taskcode);
sleep(1);
}
}
// 本进程即为 master
int main(int argc, char *argv[])
{
//.....
DebugPrint(ChannelArr);
//.....
}
三、退出进程池
1、父进程回收子进程
(1)先循环关闭父进程的所有写端,子进程读端读完读到 n == 0,会自动退出(break
)
(2)再循环回收所有子进程
在匿名管道通信中,当管道文件缓冲区没有数据可以读取时,读端进程的 read 就会阻塞等待数据,当写端进程 write 数据到管道中,读端进程才会执行 read ,并返回一个大于零的数值(表示读到一定量的数据);当管道的写端关闭时,读端也就没有读取的必要了,读端 read 会返回 n == 0,表示读到文件尾部,也表示不再继续读取,因此这时候可以退出读端进程
因此,在子进程 Work
函数逻辑中,n==0
就可以退出循环
2、小优化:为了好观察,派发任务的函数中的打印日志修改成如下
// 日志
cout << "——————————————————————————" << '\n';
cout << "第 " << num << " 轮 : ";
cout << "send " << taskcode << " 号任务 to " << channel.getChannelName() << '\n';
完整代码
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include "Task.hpp"
enum exit_code
{
OK = 0,
fork_failed = 1,
Channel_failed = 2,
argc_error = 3,
write_error = 4,
wait_error = 5
};
// 管道类
class Channel
{
public:
Channel() = default;
Channel(int fd, int order, int pid)
: _write_fd(fd),
_process_id(pid)
{
_ChannelName = std::string(std::to_string(order)) + " 号管道, to childprocess pid = " + std::to_string(pid) + ", write_fd = " + std::to_string(fd);
}
~Channel() = default;
// Get 函数
int getWriteFd() const { return _write_fd; }
std::string getChannelName() const { return _ChannelName; }
int getProcessId() const { return _process_id; }
// Close 函数: 关闭连接着子进程的管道, 即关闭父进程master的写端
void Close()
{
close(_write_fd);
}
private:
int _write_fd; // 记录当前管道连接的写端
std::string _ChannelName; // 记录当前管道的名字
int _process_id; // 记录当前管道连接的进程 id
};
// 子进程的业务逻辑
void Work()
{
// 从管道读取: 即标准输入中读
while (1)
{
// ssize_t read(int fd, void *buf, size_t count);
int cmd = 0;
int n = read(0, &cmd, sizeof(cmd));
// 读取到数据时
if (n == sizeof(cmd))
{
std::cout << "I am child, pid: " << getpid() << '\n';
taskmanger.Execute_Task(cmd); // 运行任务
}
else if (n == 0)
{
break; // 当读取不到数据时, 退出循环读取管道
}
else
{
std::cerr << "read error !" << '\n';
}
}
}
// 创建子进程+创建和连接管道
int InitProcessPool(std::vector<Channel> &ChannelArr, int nums, task_t work)
{
for (int i = 0; i < nums; ++i)
{
// 1.创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if (ret < 0)
{
std::cerr << "Channel error" << std::endl;
return Channel_failed;
}
// 2.创建子进程
int pid = fork();
if (pid < 0)
{
std::cerr << "fork error" << std::endl;
return fork_failed;
}
else if (pid == 0)
{
// 子进程:关闭写端
close(fd[1]);
// 子进程的业务逻辑
dup2(fd[0], 0);
work();
// 子进程结束:关闭读端
close(fd[0]);
exit(0);
}
else
{
// 父进程:关闭读端
close(fd[0]);
// 创建管道, 将当前管道的信息记录到管道数组中
// Channel p(fd[1], i, pid);
// ChannelArr.push_back(p);
ChannelArr.emplace_back(fd[1], i, pid);
}
}
return OK;
}
void DebugPrint(std::vector<Channel> &channels)
{
for (auto &i : channels)
{
std::cout << i.getChannelName() << '\n';
}
}
void Usage()
{
std::cout << "Usage: [filename] [number], example: ./myfile 3" << '\n';
}
// 选择任务:即轮询选择任务数组中的任务
Channel& Choose_process(std::vector<Channel> &channels)
{
// 轮询选一个管道类对象
static int num = 0;
// 获取该管道
return (channels[(num++) % channels.size()]);
}
// 选择子进程、发送任务
int send_Tasks(int fd, int taskcode)
{
// 父进程向指定fd的管道写入taskcode
// ssize_t write(int fd, const void *buf, size_t count);
int n = write(fd, &taskcode, sizeof(taskcode));
if (n < 0)
return write_error;
return OK;
}
// 派发任务: 选择任务、选择子进程、发送任务
void DispatchTasks(std::vector<Channel> &channels)
{
int num = 1; // 固定任务量,观察父进程等待效果
while (num <= 4)
{
// 选择任务
int taskcode = taskmanger.Choose_Task();
// 选择子进程:即获取管道对象
Channel& channel = Choose_process(channels);
// 日志
std::cout << "——————————————————————————" << '\n';
std::cout << "第 " << num << " 轮 : ";
std::cout << "send " << taskcode << " 号任务 to " << channel.getChannelName() << '\n';
// 派发任务
send_Tasks(channel.getWriteFd(), taskcode);
sleep(1);
num++;
}
}
// 3、退出进程池
int ExitProcessPool(std::vector<Channel>& channels)
{
// (1) 循环关闭管道: 关闭写端, 子进程会收到 SIGChannel 信号(子进程会自己退出, 后续父进程wait子进程)
for(auto& ch : channels)
{
ch.Close();
}
// (2) 循环等待子进程退出
for(auto& ch : channels)
{
int ret = waitpid(ch.getProcessId(), NULL, 0); // 阻塞等待
if(ret < 0) return wait_error;
std::cout << "——————————————" << '\n';
std::cout << "wait pid: " << ch.getProcessId() << " sucess !" << '\n';
}
return OK;
}
int main(int argc, char *argv[])
{
// 创建子进程,同时创建和连接管道, 同时派发进程任务
// 根据命令行参数决定创建多少个子进程: 缺省为 2 个子进程
if (argc > 2)
{
Usage();
return argc_error;
}
// 1、组织管道: 创建一个管道数组
std::vector<Channel> ChannelArr;
int procNum = argc < 2 ? 2 : atoi(argv[1]);
InitProcessPool(ChannelArr, procNum, Work);
// 2、派发任务: 选择任务、选择子进程、发送任务
DispatchTasks(ChannelArr);
// 3、退出进程池
ExitProcessPool(ChannelArr);
return 0;
}
代码运行结果如下:
派发完任务后就退出
四、整理封装所有方法
ProcessPool.hpp
进程池头文件
1、将所有和进程池相关的函数封装到 一个进程池类中
2、将所有重复传递的参数提取出来,作为全局变量
我们可以将下面这几个函数封装到一个类中,就可以将几个重复传递的参数写成成员变量:
// 1、初始化进程池
InitProcessPool();
// 2、派发任务
DispatchTasks();
// 3、退出进程池
ExitProcessPool();
// 选择子进程
Channel &Choose_process();
// 派发任务
int send_Tasks(int fd, int taskcode)
ProcessPool
进程池类
class ProcessPool
{
public:
ProcessPool(int num, work_t work)
:_work(work), _processNum(num)
{}
// Debug
void DebugPrint()
{
for (auto &i : channels)
{
cout << i.GetName() << '\n';
}
}
// channels 即为输出型参数:调用该函数,获得管道数组
// work_t work : 回调方法
// 1、初始化进程池:循环创建子进程与链接管道
int InitProcessPool()
{
for (int i = 0; i < _processNum; ++i)
{
// 创建管道
int fds[2] = {0};
int ret = pipe(fds);
if (ret < 0)
{
cout << "creat pipe failed!" << '\n';
return pipe_failed;
}
// 创建子进程
pid_t id = fork();
if (id < 0)
{
cout << "fork failed!" << '\n';
return fork_failed;
}
// 链接管道
// child read
if (id == 0)
{
::close(fds[1]);
dup2(fds[0], 0);
_work(); // 子进程工作: 直接执行传过来的指定任务
exit(0);
}
// father write
else if (id > 0)
{
::close(fds[0]);
// 创建管道对象,数组组织起来
channels.emplace_back(fds[1], i + 1, id); // 更优雅的写法
// Channel ch(fds[1], i+1, id);
// channels.push_back(ch);
}
}
return OK;
}
// 选择子进程
Channel &Choose_process()
{
// 轮询选一个管道类对象
static int num = 0;
// 获取该管道
return (channels[(num++) % channels.size()]);
}
// 派发任务
int send_Tasks(int fd, int taskcode)
{
// 父进程向指定fd的管道写入taskcode
// ssize_t write(int fd, const void *buf, size_t count);
int n = write(fd, &taskcode, sizeof(taskcode));
if (n < 0)
return write_error;
return OK;
}
// 2、派发任务: 选择任务、选择子进程、发送任务
void DispatchTasks()
{
int num = 1; // 固定任务量,观察父进程等待效果
while (num <= 4)
{
// 选择任务
int taskcode = taskmanger.Choose_Task();
// 选择子进程:即获取管道对象
Channel &channel = Choose_process();
// 日志
cout << "——————————————————————————" << '\n';
cout << "第 " << num << " 轮 : ";
cout << "send " << taskcode << " 号任务 to " << channel.GetName() << '\n';
// 派发任务
send_Tasks(channel.GetFd(), taskcode);
sleep(1);
num++;
}
}
// 3、退出进程池
int ExitProcessPool()
{
for (auto &ch : channels)
{
ch.Close();
}
for (auto &ch : channels)
{
int ret = waitpid(ch.GetId(), NULL, 0); // 阻塞等待
if (ret < 0)
return wait_error;
cout << "——————————————" << '\n';
cout << "wait pid: " << ch.GetId() << " sucess !" << '\n';
}
return OK;
}
private:
vector<Channel> channels;
work_t _work;
int _processNum;
};
ProcessPool.hpp
进程池头文件
完整代码
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include "Channel.hpp"
using work_t = std::function<void()>; // 注意是函数指针类型 void()
enum exit_code
{
OK = 0,
fork_failed = 1,
Channel_failed = 2,
argc_error = 3,
write_error = 4,
wait_error = 5
};
// 选择任务:即轮询选择任务数组中的任务
Channel& Choose_process(std::vector<Channel> &channels)
{
// 轮询选一个管道类对象
static int num = 0;
// 获取该管道
return (channels[(num++) % channels.size()]);
}
// 选择子进程、发送任务
int send_Tasks(int fd, int taskcode)
{
// 父进程向指定fd的管道写入taskcode
// ssize_t write(int fd, const void *buf, size_t count);
int n = write(fd, &taskcode, sizeof(taskcode));
if (n < 0)
return write_error;
return OK;
}
// 封装进程池类
class ProcessPool
{
public:
ProcessPool(int num, work_t work)
: _work(work), _processNum(num)
{
}
~ProcessPool() = default;
// DeBug 函数
void DebugPrint()
{
for (auto &i : channels)
{
std::cout << i.getChannelName() << '\n';
}
}
// 1、初始化进程池: 创建子进程+创建和连接管道
int InitProcessPool()
{
for (int i = 0; i < _processNum; ++i)
{
// 1.创建匿名管道
int fd[2] = {0};
int ret = pipe(fd);
if (ret < 0)
{
std::cerr << "Channel error" << std::endl;
return Channel_failed;
}
// 2.创建子进程
int pid = fork();
if (pid < 0)
{
std::cerr << "fork error" << std::endl;
return fork_failed;
}
else if (pid == 0)
{
// 子进程:关闭写端
close(fd[1]);
// 子进程的业务逻辑
dup2(fd[0], 0);
_work();
// 子进程结束:关闭读端
close(fd[0]);
exit(0);
}
else
{
// 父进程:关闭读端
close(fd[0]);
// 创建管道, 将当前管道的信息记录到管道数组中
// Channel p(fd[1], i, pid);
// ChannelArr.push_back(p);
channels.emplace_back(fd[1], i, pid);
}
}
return OK;
}
// 2、派发任务: 选择任务、选择子进程、发送任务
void DispatchTasks()
{
int num = 1; // 固定任务量,观察父进程等待效果
while (num <= 4)
{
// 选择任务
int taskcode = taskmanger.Choose_Task();
// 选择子进程:即获取管道对象
Channel &channel = Choose_process(channels);
// 日志
std::cout << "——————————————————————————" << '\n';
std::cout << "第 " << num << " 轮 : ";
std::cout << "send " << taskcode << " 号任务 to " << channel.getChannelName() << '\n';
// 派发任务
send_Tasks(channel.getWriteFd(), taskcode);
sleep(1);
num++;
}
}
// 3、退出进程池
int ExitProcessPool()
{
// (1) 循环关闭管道: 关闭写端, 子进程会收到 SIGChannel 信号(子进程会自己退出, 后续父进程wait子进程)
for (auto &ch : channels)
{
ch.Close();
}
// (2) 循环等待子进程退出
for (auto &ch : channels)
{
int ret = waitpid(ch.getProcessId(), NULL, 0); // 阻塞等待
if (ret < 0)
return wait_error;
std::cout << "——————————————" << '\n';
std::cout << "wait pid: " << ch.getProcessId() << " sucess !" << '\n';
}
return OK;
}
private:
std::vector<Channel> channels;
work_t _work;
int _processNum;
};
Task.hpp
任务类头文件
我们将子进程的 Work
函数也放到这里了
#pragma once
#include<iostream>
#include<unordered_map>
#include<functional>
#include<unistd.h>
#include<ctime>
using task_t = std::function<void()>;
void task_print()
{
std::cout << "This is 打印任务 ..., pid: " << getpid() << '\n';
}
void task_sql()
{
std::cout << "This is 数据库同步任务 ..., pid: " << getpid() << '\n';
}
void task_backup()
{
std::cout << "This is 备份任务 ..., pid: " << getpid() << '\n';
}
class TaskManger
{
public:
// 构造和析构
TaskManger()
{
// 初始插入三个任务
Insert_Task(task_print);
Insert_Task(task_sql);
Insert_Task(task_backup);
srand(time(0));
}
~TaskManger()
{
}
void Insert_Task(task_t t)
{
tasks[num++] = t;
}
void Execute_Task(int num)
{
if(tasks.find(num) == tasks.end()) return;
tasks[num]();
}
int Choose_Task()
{
// 随机一个任务
return rand() % num; // 刚好在合法范围内随机
}
private:
std::unordered_map<int, task_t> tasks;
static int num;
};
int TaskManger::num = 0;
TaskManger taskmanger; // 全局定义一个单例即可
Channel.hpp
管道类头文件
这个也单独封装成一个头文件
#pragma once
#include<iostream>
#include<string>
#include<unistd.h>
// 管道类
class Channel
{
public:
Channel() = default;
Channel(int fd, int order, int pid)
: _write_fd(fd),
_process_id(pid)
{
_ChannelName = std::string(std::to_string(order)) + " 号管道, to childprocess pid = " + std::to_string(pid) + ", write_fd = " + std::to_string(fd);
}
~Channel() = default;
// Get 函数
int getWriteFd() const { return _write_fd; }
std::string getChannelName() const { return _ChannelName; }
int getProcessId() const { return _process_id; }
// Close 函数: 关闭连接着子进程的管道, 即关闭父进程master的写端
void Close()
{
close(_write_fd);
}
private:
int _write_fd; // 记录当前管道连接的写端
std::string _ChannelName; // 记录当前管道的名字
int _process_id; // 记录当前管道连接的进程 id
};
// 子进程的业务逻辑
void Work()
{
// 从管道读取: 即标准输入中读
while (1)
{
// ssize_t read(int fd, void *buf, size_t count);
int cmd = 0;
int n = read(0, &cmd, sizeof(cmd));
// 读取到数据时
if (n == sizeof(cmd))
{
std::cout << "I am child, pid: " << getpid() << '\n';
taskmanger.Execute_Task(cmd); // 运行任务
}
else if (n == 0)
{
break; // 当读取不到数据时, 退出循环读取管道
}
else
{
std::cerr << "read error !" << '\n';
}
}
}
Main.cc
主函数
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <unistd.h>
#include <sys/wait.h>
#include "Task.hpp"
#include "processPool.hpp"
void Usage()
{
std::cout << "Usage: [filename] [number], example: ./myfile 3" << '\n';
}
int main(int argc, char *argv[])
{
// 创建子进程,同时创建和连接管道, 同时派发进程任务
// 根据命令行参数决定创建多少个子进程: 缺省为 2 个子进程
if (argc > 2)
{
Usage();
return argc_error;
}
int procNum = argc < 2 ? 2 : atoi(argv[1]);
// 1、初始化进程池
ProcessPool myProcessPool(procNum, Work);
myProcessPool.InitProcessPool();
// 2、派发任务: 选择任务、选择子进程、发送任务
myProcessPool.DispatchTasks();
// 3、退出进程池
myProcessPool.ExitProcessPool();
return 0;
}
五、解决一个藏得很深的 Bug
观察 Bug
在我们的退出进程池的代码逻辑中:先关闭所有父进程写端 fd,再回收所有子进程
// 3、退出进程池
int ExitProcessPool()
{
// (1) 循环关闭管道: 关闭写端, 子进程会收到 SIGChannel 信号(子进程会自己退出, 后续父进程wait子进程)
for (auto &ch : channels)
{
ch.Close();
}
// (2) 循环等待子进程退出
for (auto &ch : channels)
{
int ret = waitpid(ch.getProcessId(), NULL, 0); // 阻塞等待
if (ret < 0)
return wait_error;
std::cout << "——————————————" << '\n';
std::cout << "wait pid: " << ch.getProcessId() << " sucess !" << '\n';
}
return OK;
}
问题:这两个步骤就不能合并到一个循环里面执行吗,明明循环的 “外壳” 是一样的
for (auto &ch : channels)
{
ch.Close();
int ret = waitpid(ch.getProcessId(), NULL, 0); // 阻塞等待
if (ret < 0)
return wait_error;
std::cout << "——————————————" << '\n';
std::cout << "wait pid: " << ch.getProcessId() << " sucess !" << '\n';
}
尝试一下:将代码换成这个逻辑,再运行代码
则你会发现,回收子进程时直接卡死,阻塞住不动了!!!
这就是我所说的大 Bug !
为什么?
Bug
原因
我们之前讲解过,父子进程之间通过管道实现通信的原理是:父进程首先创建一个管道,并获得该管道的读端和写端文件描述符。子进程通过继承父进程的文件描述符表,也获得了对同一个管道的读端和写端的访问权限。然后,通过父进程关闭其不需要的一端(例如读端),以及子进程关闭其不需要的一端(例如写端),实现了管道的单向进程间通信。
在进程池结构中,如果一个父进程对应多个子进程,如 2 个子进程,则:
- 第一个子进程的创建:继承父进程的管道读写端
fd = 3 和 4
。 - 第二个子进程的创建:因为父进程在创建上一个子进程后就将读端关闭,因此当前最小可分配的文件描述符为
fd=3
,若父进程创建新的管道,则新管道的读写端为fd = 3 和 5
,该子进程继承父进程的这些新管道的读写端fd = 3 和 5
,同时也继承了前一个管道的写端:fd = 4
。
这会出现这样的规律:
第二个进程有着 第一个进程 对应管道的 写端
第三个进程有着 第一个进程和第二个进程 对应管道的 写端
第四个进程有着 第一个进程、第二个进程和第三个进程 对应管道的 写端
……
由于多个子进程是对同一个父进程的文件描述符表进行拷贝继承,因此后面的子进程确实会继承到前面创建的所有管道的写端,这意味着一个管道文件可能会被多个子进程同时指向。
为什么回收子进程时会卡住: 因为在多个写端存在的情况下,除非所有写端都被关闭,否则子进程不会认为输出流结束(EOF),从而不会正常退出,导致父进程无法回收这些子进程。
也就是说,倒着看:
管道 3 的写端有 1 个:一个父进程指向的
管道 2 的写端有 2 个:一个父进程指向的、一个子进程 3 指向的
管道 1 的写端有 3 个:一个父进程指向的、一个子进程 3 指向的、一个子进程 2 指向的
Bug
解决办法
我们前面最为直接的方法是:先循环关闭父进程的所有写端,再循环等待所有子进程
这个方式能解决Bug的原因是:
因为我们最后一个子进程对应的管道仅仅只有父进程一个写端指向,因此循环关闭父进程的所有写端后,最后一个子进程对应会被信号杀死(因为他对应的管道仅有一个写端指向,此时该管道的写端已全部关闭,则父进程会发信号给该子进程,杀掉他)
恰好的是,最后一个子进程的文件描述符表中记录着前面所有子进程对应管道的写端!该子进程被杀死后,文件描述符表也就被销毁,对应前面所有子进程对应管道的写端也就被抹除。
原本倒数第二个进程的写端指向包括两个部分:1、父进程指向自己对应管道的那一个写端;2、最后一个子进程指向自己的
现在最后一个子进程已死,同时前面的 循环关闭父进程的所有写端 ,这两个操作就将 倒数第二个进程的写端 全部关闭,因此倒数第二个进程也就能顺理成章的被发送过来的信号杀死
其他进程同理
从这个原理来看,无非就是从后向前关闭管道就行,因此下面所有方式也类似围绕这样的原理来思考:
(1)倒着关闭写端与回收子进程
for (int i = channels.size() - 1; i >= 0; --i)
{
// 关闭父进程写端 fd
channels[i].Close();
// 回收子进程
int ret = waitpid(channels[i].getProcessId(), NULL, 0); // 阻塞等待
if (ret < 0)
return wait_error;
cout << "——————————————" << '\n';
cout << "wait pid: " << channels[i].getProcessId() << " sucess !" << '\n';
}
倒着关闭写端与回收子进程的原理是:
第一轮关闭管道 3 的写端,再回收 子进程 3 ,此时子进程 3所指向的 管道 2 和 管道 1的写端全部没了
同理,这样消灭所有”重复“指向的写端
(2)创建子进程时关闭重复的写端
从上面的讲解可以发现,子进程重复包含的写端恰好时当前已经创建的所有管道的写端,
我们可以遍历 vector<Channel> channels
获取所有写端,关闭掉当前子进程重复包含的写端
// 关闭重复包含的写端
for(auto& i : channels){
i.Close();
}
// 链接管道
// child read
if (id == 0)
{
// 关闭重复包含的写端
for(auto& i : channels){
i.Close();
}
::close(fds[1]);
dup2(fds[0], 0);
_work(); // 子进程工作: 直接执行传过来的指定任务
exit(0);
}
这样,你会发现,即使是先前那段阻塞住的代码也能跑了!
加上一些打印信息看一下:
if (id == 0)
{
// 关闭重复包含的写端
cout << "child close history wfd: ";
for(auto& i : channels){
i.Close();
cout << i.GetFd() << ", ";
}
cout << " over" << '\n';
::close(fds[1]);
dup2(fds[0], 0);
_work(); // 子进程工作: 直接执行传过来的指定任务
exit(0);
}