【Linux】进程池

目录

个人主页:矢望

个人专栏:C++LinuxC语言数据结构Coze-AIMySQL

一、进程池的逻辑框架

1.1 管理管道和子进程

上期博客我们提到了主从模式,也就是主从模式进程池,今天我们来编写一下代码。

首先要让一个进程对多个子进程做管理,就需要让父进程创建多个管道,并且创建出多个子进程,那么为了分清楚那个子进程对应着哪个管道,所以我们需要一个类对它们进行管理。我们把这个类叫做通道类Channel,用它来管理匿名管道的写端文件描述符和对应管道另一端的子进程。

cpp 复制代码
// 通道类,管理匿名管道和读端的子进程
class Channel
{
public:
    Channel()
    {}

    ~Channel()
    {}

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

1.2 创建多个管道和子进程

要进行进程池,首先就需要有多个管道和进程在此待命,所以我们需要创建若干个管道和子进程。

我们可以将要创建的数量定义成全局变量,然后使用循环批量创建。还可以使用自定义退出码的方式自己设定返回值的含义。

cpp 复制代码
// 自定义退出码含义
enum
{
    OK,
    PIPE_ERROR,
    FORK_ERROR
};

const int process_num = 5;

void Do_Task(int fd) // 子进程的入口函数
{}

// ...

int main()
{
    // 1. 创建多个管道和多个子进程
    for(int i = 1; i <= process_num; i++)
    {
        int pipefd[2];
        int n = pipe(pipefd); // 创建管道
        if(n < 0)
        {
            std::cerr << "pipe create error!" << std::endl; // 向标准错误写
            exit(PIPE_ERROR);
        }

        pid_t id = fork(); // 创建子进程
        if(id < 0)
        {
            std::cerr << "fork error!" << std::endl; // 向标准错误写
            exit(FORK_ERROR);
        }
        else if(id == 0)
        {
            // 子进程
            close(pipefd[1]); // 子进程关闭管道写端
            
            Do_Task(pipefd[0]); // 子进程通过读端的文件描述符获取任务
            exit(OK); // 子进程执行完任务直接退出
        }

        // 父进程
        close(pipefd[0]); // 父进程关闭管道读端
    }

    return 0;
}

如上,我们完成了创建多个管道和子进程的工作,也完成了子进程要执行任务的雏形Do_Task。还做了一些执行失败时的差错处理。将来子进程就会在if(id == 0){}中完成它的工作,如果父进程没有给它派发任务,它就一直等。父进程派发任务,子进程就执行任务,执行完成之后就退出。

当然还有一些没有完善。

仔细体会上面的代码,结合相关进程要执行的工作,我们发现,子进程它关闭了写端,并且拿到了读端,将读端的文件描述符交给了Do_Task函数,将来就可以通过它来获取任务了。

可是父进程它虽然关闭了读端,但是它的写端没有被管理起来,等到下一个循环就会被覆盖丢失。也就是父进程未来不知道通过哪个文件描述符给子进程传递任务,所以我们需要将它们管理起来。

我们前面已经创建出了相关的管理通道类,就是为了这一步。

cpp 复制代码
// 通道类,管理匿名管道和读端的子进程
class Channel
{
public:
    Channel(int wfd, pid_t sub_pid)
        :_wfd(wfd)
        ,_sub_pid(sub_pid)
    {
        _sub_name = "子进程:" + std::to_string(sub_pid);
    }

    ~Channel()
    {}

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

int main()
{
    // 0. 组织Channel的容器
    std::vector<Channel> channels;

    // 1. 创建多个管道和多个子进程
    for(int i = 1; i <= process_num; i++)
    {
        // ...

        // 父进程
        close(pipefd[0]); // 父进程关闭管道读端
        Channel ch(pipefd[1], id);
        channels.push_back(ch); // 管理每个管道和对应的子进程
    }

    return 0;
}

这样我们就创建出了多个管道和子进程,未来就可以让父进程通过channels控制子进程了。

代码测试

我们这里通过打印来进行代码测试。

cpp 复制代码
class Channel
{
public:
    Channel(int wfd, pid_t sub_pid)
        :_wfd(wfd)
        ,_sub_pid(sub_pid)
    {
        _sub_name = "子进程:" + std::to_string(sub_pid);
    }

    ~Channel()
    {}

    void PrintInfo()
    {
        printf("父进程管理:wfd -> %d, pid -> %d, 描述 -> %s\n", _wfd, _sub_pid, _sub_name.c_str());
    }

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

for(int i = 1; i <= process_num; i++)
{
    // ...

    std::cout << "创建子进程:" << id << " 成功..." << std::endl;
    sleep(1); // 间隔 1 秒创建一次
}

for(auto& c : channels)
{
    c.PrintInfo();
}
sleep(100); // 让父进程休眠

编译测试

如上图,成功创建了并且父进程也使用channels将它们管理起来了。

1.3 改造代码 并 新建进程池类

现在代码能够正常运行,这没有问题。但既然我们在写进程池,不如,我们就创建一个进程池类。

由于父进程通过channels实现对子进程的控制,所以我们可以将channels容器变成这个进程池类的私有成员,而我们的第一步创建多个管道和多个子进程过程所执行的工作,可以封装成为进程池类的一个函数,将来方便管理。

这样就初步有了对这个进程池类的设想,将来我不希望将创建函数暴露出去,我可以使用Init()函数完成创建多个管道和多个子进程的工作。而我们上一个阶段的代码测试的部分可以成为进程池类中的一个DeBug函数。我们直接调用即可。

因此,进程池类:

cpp 复制代码
class ProcessPool
{
public:
	ProcessPool()
    {}

    ~ProcessPool()
    {}
    
    void Init()
    {
        CreateProcessChannel();
    }

    void DeBug()
    {
        for(auto& c : channels)
        {
            c.PrintInfo();
        }
    }

private:
    void CreateProcessChannel()
    {
        // 1. 创建多个管道和多个子进程
        for(int i = 1; i <= process_num; i++)
        {
            int pipefd[2];
            int n = pipe(pipefd); // 创建管道
            if(n < 0)
            {
                std::cerr << "pipe create error!" << std::endl; // 向标准错误写
                exit(PIPE_ERROR);
            }

            pid_t id = fork(); // 创建子进程
            if(id < 0)
            {
                std::cerr << "fork error!" << std::endl; // 向标准错误写
                exit(FORK_ERROR);
            }
            else if(id == 0)
            {
                // 子进程
                close(pipefd[1]); // 子进程关闭管道写端

                Do_Task(pipefd[0]); // 子进程通过读端的文件描述符获取任务
                exit(OK); // 子进程执行完任务直接退出
            }

            // 父进程
            close(pipefd[0]); // 父进程关闭管道读端
            channels.emplace_back(pipefd[1], id); // 管理每个管道和对应的子进程
            // 上面的写法更优,作用等价于下面的
            // Channel ch(pipefd[1], id);
            // channels.push_back(ch); // 管理每个管道和对应的子进程

            std::cout << "创建子进程:" << id << " 成功..." << std::endl;
            sleep(1); // 间隔 1 秒创建一次
        }
    }

private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};

测试代码

cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init();
    pp.DeBug();
    sleep(100); // 让父进程休眠

    return 0;
}

编译运行

此外,如果你不想暴露你的通道类Channel,你可以将它变成进程池类的内部类,这里就不再进行。

接下来还有做一层改造,现在我们的子进程执行任务时,直接就死板的调用了Do_Task函数,如果将来想让子进程执行其它函数,还要手动修改相关代码吗?

所以子进程在执行任务的时候可以采用回调的方式,由父进程给子进程安排任务,而子进程不知道自己要执行什么任务。

这里我们采用包装器来实现回调。

cpp 复制代码
// 定义任务函数类型 - 带 int 参数
using task_t = std::function<void(int)>;

void Do_Task(int fd) // 子进程的入口函数
{}

class ProcessPool
{
public:
	// ...
	
    void Init(task_t cb) // 初始化进程池,设置任务处理回调
    {
        CreateProcessChannel(cb);
    }
    
private:
    void CreateProcessChannel(task_t cb)
    {
        // 1. 创建多个管道和多个子进程
        for(int i = 1; i <= process_num; i++)
        {
            // ...
            
            else if(id == 0)
            {
                // 子进程
                close(pipefd[1]); // 子进程关闭管道写端
                
				// 回调
                cb(pipefd[0]); // 子进程通过读端的文件描述符获取任务
                exit(OK); // 子进程执行完任务直接退出
            }

            // ...
        }
    }

private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};
cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);
    pp.DeBug();
    sleep(100); // 让父进程休眠

    return 0;
}

再次编译运行:

二、父进程控制子进程

这个有几个问题需要说明。

父进程在控制子进程的时候,会向子进程派发任务,但是可能父进程在向子进程派发任务的时候,子进程正忙着做其它任务,这个任务的耗费时间久,等到子进程执行完之后,父进程往管道中已经写了好几个任务,由于管道是面向字节流的,所以子进程会一口气将所有的任务全部读出来,这样就不好进行任务的辨别了

因此,我们约定,进程在读写时统一读写4字节。

另外父进程在选择子进程执行任务时,需要负载均衡 ,不能总是选择一个进程发放任务。那就有几个策略,包括轮询、随机、和设置权重。轮询就是轮着发放,随机可以设置随机数选择子进程,设置权重可以按照权重来发放任务。这里我们会选择轮询选择子进程的策略

那么将来还要派发任务,和选择子进程需要的过程一样,我们将会随机选择子进程。

也就是说父进程控制子进程的工作分为三步,第一步是选择一个子进程 ,第二步是选择一个任务 ,第三步是给指定子进程派送指定任务

2.1 选择一个子进程

我们采用轮询的方式选择。在进程池类中将选择的函数封装为私有。

cpp 复制代码
class ProcessPool
{
public:
    void Run()
    {
        while(true)
        {
            // 1. 选择一个 Channel, 管道 + 子进程
            int index = SelectProcessIndex();
            std::cout << "index: " << index << std::endl;
            sleep(1);

            // 2. 选择一个任务

            // 3. 发放任务给特定的子进程
        }
    }

private:
    int SelectProcessIndex()
    {
        static int index = 0;
        int Selectnum = index;
        
        index++;
        index %= channels.size();

        return Selectnum;
    }
};
cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);

    // 1.父进程控制子进程
    pp.Run();
    sleep(100); // 让父进程休眠

    return 0;
}

运行测试

如上图,轮询选择子进程。

2.2 选择一个任务

这里采用随机数的方式选择一个任务。

相关任务列表就直接使用硬编码定义了。

cpp 复制代码
void SyncDisk()
{
    std::cout << getpid() << " -> 刷新数据到磁盘任务....." << std::endl;
    sleep(1);
}

void Download()
{
    std::cout << getpid() << " -> 下载数据到系统中....." << std::endl;
    sleep(1);
}

void PrintLog()
{
    std::cout << getpid() << " -> 打印日志到本地....." << std::endl;
    sleep(1);
}

void UpdateStatus()
{
    std::cout << getpid() << " -> 更新一次用户的状态....." << std::endl;
    sleep(1);
}

typedef void (*cb_task)(); // 函数指针

cb_task tasks[4] = {SyncDisk, Download, PrintLog, UpdateStatus}; // 任务列表

然后使用随机数选择任务。使用随机数需要种下随机数种子,这里就种在进程池的构造函数里了。

cpp 复制代码
class ProcessPool
{
public:
    ProcessPool()
    {
    	// 随机数种子
        srand((unsigned int)time(nullptr) ^ getpid());  // 让它更随机
    }

    void Run()
    {
        while(true)
        {
            // 1. 选择一个 Channel, 管道 + 子进程
            int index = SelectProcessIndex();

            // 2. 选择一个任务
            int itask = SelectTasks();
            std::cout << "index: " << index  << " itask: " << itask << std::endl;
            sleep(1);

            // 3. 发放任务给特定的子进程
        }
    }

private:
    int SelectProcessIndex() // 选择一个进程
    {
        static int index = 0;
        int Selectnum = index;

        index++;
        index %= channels.size();

        return Selectnum;
    }

    int SelectTasks() // 选择一个任务
    {
        return rand() % 4;
    }
};
cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);
    // pp.DeBug();

    // 1.父进程控制子进程
    pp.Run();
    sleep(100); // 让父进程休眠

    return 0;
}
cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);

    // 1.父进程控制子进程
    pp.Run();
    sleep(100); // 让父进程休眠

    return 0;
}

运行测试

如上图,轮询选择进程,随机选择任务,符合预期。

2.3 父进程发送任务给子进程

现在选择出了一个任务,并且选择出了要执行任务的进程,所以现在父进程就可以通过管道向子进程派发任务了。相关派送任务的函数可以设置成进程池类的私有成员函数。

cpp 复制代码
// 通道类,管理匿名管道和读端的子进程
class Channel
{
public:
    // ...

    void Write(int itask) // 通过管道传递任务给子进程
    {
        ssize_t n = write(_wfd, &itask, sizeof itask); // 约定父进程一次写 4 字节数据
        (void)n; // 防止编译器告警
    }

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

class ProcessPool
{
public:
    // ...

    void Run()
    {
        while(true)
        {
        	std::cout << "------------------------------------" << std::endl;
        	
            // 1. 选择一个 Channel, 管道 + 子进程
            int index = SelectProcessIndex();

            // 2. 选择一个任务
            int itask = SelectTasks();
            std::cout << "index: " << index  << " itask: " << itask << std::endl;

            // 3. 发放任务给特定的子进程
            std::cout << "派送 " << itask << " 任务给 " << index << " 号子进程" << std::endl;
            SendTask2Salver(index, itask); 

            sleep(1); // 一秒一个任务
        }
    }

private:
    void SendTask2Salver(int index, int itask)
    {
        if(index < 0 || index >= channels.size())
            return;
        if(itask < 0 || itask >= 4)
            return;
        
        channels[index].Write(itask); // 调用通道类的成员函数,通过管道传递任务给子进程
    }

    
    // ...

private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};

上面的代码中父进程通过进程池类的私有成员函数SendTask2Salver通过管道向子进程写数据,这个函数内部又调用了通道类的成员函数Write向子进程写数据,并且约定父进程一次写入四字节数据。

这样父进程就向子进程写入了数据,紧接着就应该子进程读取数据。子进程的逻辑是创建出来之后,关闭自己的写端,然后转而执行我们给的回调函数cb,在这里就是Do_Task函数,执行完这个函数里面的任务,子进程就退出了。

所以子进程读取数据的代码就发生在Do_Task函数内部。并且它会在这个函数中执行完任务。

cpp 复制代码
void Do_Task(int fd) // 子进程的入口函数
{
    while(true)
    {
        int task_code = 0; // 存储要执行的任务的编号
        ssize_t n = read(fd, &task_code, sizeof task_code); // 约定子进程一次读取 4 字节数据

        if(n == sizeof task_code)
        {
            tasks[task_code](); // 子进程执行任务
        }
        else if(n == 0) // 父进程关闭了管道的写端
        {
            std::cout << "子进程:" << getpid() << " 退出..." << std::endl;
            break;
        }
        else
        {
            std::cerr << "Reading error!" << std::endl;
            break;
        }
    }
}

以上就是子进程读取数据的逻辑,约定子进程一次读取四字节的数据。子进程会通过读取到的数据来执行任务。当父进程关闭写端时,子进程会读到0,也就是read返回0(EOF),此时子进程也应该退出了。

cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);

    // 1.父进程控制子进程
    pp.Run();
    sleep(100); // 让父进程休眠

    return 0;
}

运行测试

如上图,通过运行结果,我们看到父进程成功给每个子进程派发了任务,并且子进程执行了父进程派发的任务,这就是主从模式的进程池

三、释放和回收资源

上面我们的父进程是一直在给子进程派发任务的,那么如果未来是要执行有限次的任务,当派发任务结束并且子进程执行完任务时,父进程也要能够释放管道并且回收子进程。

所以接下来的执行,我就让父进程派发五次任务就结束。

我们在设计子进程时,是当子进程读到0,就会退出回调函数Do_Task,然后子进程要执行的就是exit退出,此时子进程就变成僵尸状态了。

所以我们只需要让父进程关闭写端,此时子进程就会变成僵尸状态

释放和回收资源要做的工作就是让所有子进程退出回收所有子进程 两个步骤。所以这个可以封装在进程池类的Quit函数中。

让所有子进程退出可以遍历所有通道,并且关闭写端,我们的写端_wfd和子进程的_sub_pid都在通道类内,所以关闭写端和回收子进程两个工作,都可以在通道类中进行。

cpp 复制代码
// 通道类,管理匿名管道和读端的子进程
class Channel
{
public:
    // ...

    void ClosePipe()
    {
        close(_wfd); // 父进程关闭写端
    }

    void Wait()
    {
        pid_t rid = waitpid(_sub_pid, nullptr, 0);
        (void)rid; // 避免编译器告警
        std::cout << "回收子进程 " << _sub_pid << " 成功..." << std::endl;
    }

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

class ProcessPool
{
public:
    // ...

    void Quit()
    {
        // 1. 让所有子进程退出
        for(auto& ch : channels) // 遍历所有通道并关闭写端
        {
            ch.ClosePipe();
        }

        // 2. 回收子进程
        for(auto& ch : channels)
        {
            ch.Wait();
        }
    }
	
	// ...

private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};
cpp 复制代码
int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);

    // 1.父进程控制子进程
    pp.Run();

    // 3.释放管道,回收子进程
    pp.Quit();

    return 0;
}

运行测试

如上图,释放管道并且回收子进程成功。

3.1 一个问题

如果父进程在让子进程退出时,子进程的管道里还有任务没有执行,那么会怎样?

子进程会将管道中的任务执行完成之后,再次调用read读取数据返回0时,直接退出。所以所有已写入的任务都会被处理,不会因为父进程关闭管道而丢失。这正是管道机制的优雅之处

3.2 解决 Bug

分析出现原因

我们的进程池代码基本写完了,非常的优雅!

但是现在存在一个Bug,当进程池类的Quit函数,像下面这样写时,就会出错。上面我们的Quit函数中包含两个循环,一个循环关闭子进程,另一个循环回收子进程,为什么不边关闭边回收呢? 我们改一下。

cpp 复制代码
class ProcessPool
{
public:
	void Quit()
    {
        for(auto& ch : channels)
        {
            ch.ClosePipe(); // 让子进程退出
            ch.Wait(); // 回收子进程
        }
    }
private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};

运行测试

如上图,竟然一个子进程都没退!那么问题出在哪里呢?

我们来分析一下管道的创建过程,父进程首先会创建匿名管道,并且创建子进程,当它创建第一个子进程时,是这样的:

如上,父进程创建第一个进程的时候很顺利,最后父进程是管道的写端,子进程是管道的读端。

那么如果父进程再创建一个子进程呢? 依旧是先创建管道再创建子进程,然后关闭读写端,这里我们直接呈现出最后的效果图。

如上图,这是连续创建两个子进程的情况,发现了吗?子进程会以父进程为模板创建出来,所以父进程管理之前子进程的写端也被复制下来了,所以后来的进程也会有之前子进程管道的写端。

因此最先创建的子进程是最吃亏的,除了父进程有它的管道的写端,其余的所有进程都会有它的管道的写端

所以没有一个子进程退出的原因就是由于我们是正序遍历的,所以父进程将第一个进程写端关闭之后,其余的所有子进程都还有这个子进程的写端,因此第一个子进程就会阻塞在那里,等待管道的写端发送数据,因此没有一个子进程退出

所以原因:写端文件描述符wfd没有关闭完毕

验证,我们要看到后面的子进程的文件描述符确实不止一个,这里我们就让父进程创建出所有子进程后一直休眠,然后我们查询所有子进程的文件描述符。

运行测试

如上图,验证完毕,我们发现后面的子进程有的文件描述符的数量越来越多。

解决方案

  • 方案一逆向回收

我们发现前面的子进程的写端不只有子进程一个,后面的子进程都会有指向前面的子进程管道的写端。所以最后一个子进程的写端只有父进程一个,我们只要倒着来,就可以成功回收。

cpp 复制代码
void Quit()
{
    // 逆向回收
    int end = channels.size() - 1;
    while(end >= 0)
    {
        channels[end].ClosePipe();
        channels[end].Wait();
        end--;
    }
}

运行测试

如上,释放所有管道,回收所有子进程成功。

  • 方案二解决bug,让每个子进程都只有父进程一个写端

上面的方案一并没有真正解决bug,只是和之前一样,带着bug回收了子进程。这里我们要解决bug

回想一下,我们的子进程在创建的时候,可能会有很多多余的指向其它子进程的管道的写端,所以我们的子进程只要关闭历史上父进程创建的写端就好了。
这样就保证了子进程自己只有自己管道的读端,如此一来子进程管道的写端和读端就都只有一个了

那么历史的父进程的写端在那里保存呢? 当然是在通道类中,我们的通道类中有一个成员就是_wfd,存储每个管道的写端

所以我们修改创建子进程时的代码,让子进程在创建出来之后,先关闭历史上父进程的写端。

cpp 复制代码
// 通道类,管理匿名管道和读端的子进程
class Channel
{
public:
    // ...

    void ClosePipe()
    {
        close(_wfd); // 当前进程关闭写端
    }
    
	// ...

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

class ProcessPool
{
public:
    // ...

    void Init(task_t cb)
    {
        CreateProcessChannel(cb);
    }

    // ...

private:
    // ...

    void CreateProcessChannel(task_t cb) // 初始化进程池,设置任务处理回调
    {
        // 1. 创建多个管道和多个子进程
        for(int i = 1; i <= process_num; i++)
        {
            // ...
            pid_t id = fork(); // 创建子进程
            if(id < 0)
            {
                std::cerr << "fork error!" << std::endl; // 向标准错误写
                exit(FORK_ERROR);
            }
            else if(id == 0)
            {
                // 子进程关闭历史上父进程管理的管道写端
                if(!channels.empty())
                {
                    for(auto& ch : channels)
                    {
                        ch.ClosePipe();
                    }
                }

                // 子进程
                close(pipefd[1]); // 子进程关闭管道写端

                // 回调
                cb(pipefd[0]); // 子进程通过读端的文件描述符获取任务
                exit(OK); // 子进程执行完任务直接退出
            }

            // ...
        }
    }

private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};

上面的代码中,fork创建子进程之后,子进程和父进程的channels就都是旧的了。如果在子进程关闭写端之前,父进程进行了写端的插入,也不用担心,此时父进程由于要修改数据,所以就会发生写时拷贝,然后,父子进程都有各自的channels,也就是说,子进程关闭的永远都是历史父进程创建的写端wfd

如果还有人不理解,这里以表格呈现:

方面 说明
写时拷贝 父子进程各自拥有独立的channels副本,互不干扰
历史写端 子进程关闭的是fork时刻存在的写端,不会影响父进程后续添加
引用计数 每个管道的写端最终只被父进程持有,关闭即退出
内存安全 写时拷贝确保进程不会访问彼此的内存空间

这次我们就可以正序边释放管道,边回收子进程了。

cpp 复制代码
void Quit()
    {

        for(auto& ch : channels)
        {
            ch.ClosePipe(); // 让子进程退出
            ch.Wait(); // 回收子进程
        }

        // 逆向回收
        // int end = channels.size() - 1;
        // while(end >= 0)
        // {
        //     channels[end].ClosePipe();
        //     channels[end].Wait();
        //     end--;
        // }
    }

运行测试

如上,Bug被修复了,每个子进程的管道都只有一个写端一个读端。

再次验证,让父进程创建完毕之后休眠,我们查询子进程的fd

如上图,子进程都只有一个读端!父进程掌管着各子进程管道的写端!

到此,进程池代码编写完毕。

四、源代码

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
#include <functional>
#include <cstdio>
#include <cstdlib>
#include <ctime>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>


////////////////////子进程要完成的任务//////////////////////////////
void SyncDisk()
{
    std::cout << getpid() << " -> 刷新数据到磁盘任务....." << std::endl;
    sleep(1);
}

void Download()
{
    std::cout << getpid() << " -> 下载数据到系统中....." << std::endl;
    sleep(1);
}

void PrintLog()
{
    std::cout << getpid() << " -> 打印日志到本地....." << std::endl;
    sleep(1);
}

void UpdateStatus()
{
    std::cout << getpid() << " -> 更新一次用户的状态....." << std::endl;
    sleep(1);
}

typedef void (*cb_task)(); // 函数指针

cb_task tasks[4] = {SyncDisk, Download, PrintLog, UpdateStatus}; // 任务列表


////////////////////进程池相关/////////////////////////////////////

// 自定义退出码含义
enum
{
    OK,
    PIPE_ERROR,
    FORK_ERROR
};

const int process_num = 5;

// 定义任务函数类型 - 带 int 参数
using task_t = std::function<void(int)>;

void Do_Task(int fd) // 子进程的入口函数
{
    while(true)
    {
        int task_code = 0; // 存储要执行的任务的编号
        ssize_t n = read(fd, &task_code, sizeof task_code); // 约定子进程一次读取 4 字节数据

        if(n == sizeof task_code)
        {
            tasks[task_code](); // 子进程执行任务
        }
        else if(n == 0) // 父进程关闭了管道的写端
        {
            std::cout << "子进程:" << getpid() << " 退出..." << std::endl;
            break;
        }
        else
        {
            std::cerr << "Reading error!" << std::endl;
            break;
        }
    }
}

// 通道类,管理匿名管道和读端的子进程
class Channel
{
public:
    Channel(int wfd, pid_t sub_pid)
        :_wfd(wfd)
        ,_sub_pid(sub_pid)
    {
        _sub_name = "子进程:" + std::to_string(sub_pid);
    }

    ~Channel()
    {}

    void PrintInfo()
    {
        printf("父进程管理:wfd -> %d, pid -> %d, 描述 -> %s\n", _wfd, _sub_pid, _sub_name.c_str());
    }

    void Write(int itask) // 通过管道传递任务给子进程
    {
        ssize_t n = write(_wfd, &itask, sizeof itask); // 约定父进程一次写 4 字节数据
        (void)n; // 防止编译器告警
    }

    void ClosePipe()
    {
        close(_wfd); // 当前进程关闭写端
    }

    void Wait()
    {
        pid_t rid = waitpid(_sub_pid, nullptr, 0);
        (void)rid; // 避免编译器告警
        std::cout << "回收子进程 " << _sub_pid << " 成功..." << std::endl;
    }

private:
    int _wfd;               // 写端的文件描述符
    pid_t _sub_pid;          // 管道读端子进程的 pid
    std::string _sub_name;   // 关于子进程的描述
};

class ProcessPool
{
public:
    ProcessPool()
    {
        // 随机数种子
        srand((unsigned int)time(nullptr) ^ getpid());  // 让它更随机
    }

    ~ProcessPool()
    {}

    void Init(task_t cb)
    {
        CreateProcessChannel(cb);
    }

    void DeBug()
    {
        for(auto& c : channels)
        {
            c.PrintInfo();
        }
    }

    void Run()
    {
        int cnt = 5;
        // while(true)
        while(cnt--)
        {
            std::cout << "------------------------------------" << std::endl;

            // 1. 选择一个 Channel, 管道 + 子进程
            int index = SelectProcessIndex();

            // 2. 选择一个任务
            int itask = SelectTasks();
            std::cout << "index: " << index  << " itask: " << itask << std::endl;

            // 3. 发放任务给特定的子进程
            std::cout << "派送 " << itask << " 任务给 " << index << " 号子进程" << std::endl;
            SendTask2Salver(index, itask); 

            sleep(1); // 一秒一个任务
        }
    }

    void Quit()
    {

        for(auto& ch : channels)
        {
            ch.ClosePipe(); // 让子进程退出
            ch.Wait(); // 回收子进程
        }

        // 逆向回收
        // int end = channels.size() - 1;
        // while(end >= 0)
        // {
        //     channels[end].ClosePipe();
        //     channels[end].Wait();
        //     end--;
        // }

        // // 1. 让所有子进程退出
        // for(auto& ch : channels) // 遍历所有通道并关闭写端
        // {
        //     ch.ClosePipe();
        // }

        // // 2. 回收子进程
        // for(auto& ch : channels)
        // {
        //     ch.Wait();
        // }
    }

private:
    void SendTask2Salver(int index, int itask)
    {
        if(index < 0 || index >= channels.size())
            return;
        if(itask < 0 || itask >= 4)
            return;
        
        channels[index].Write(itask); // 调用通道类的成员函数,通过管道传递任务给子进程
    }

    int SelectProcessIndex() // 选择一个进程
    {
        static int index = 0;
        int Selectnum = index;

        index++;
        index %= channels.size();

        return Selectnum;
    }

    int SelectTasks() // 选择一个任务
    {
        return rand() % 4;
    }

    void CreateProcessChannel(task_t cb) // 初始化进程池,设置任务处理回调
    {
        // 1. 创建多个管道和多个子进程
        for(int i = 1; i <= process_num; i++)
        {
            int pipefd[2];
            int n = pipe(pipefd); // 创建管道
            if(n < 0)
            {
                std::cerr << "pipe create error!" << std::endl; // 向标准错误写
                exit(PIPE_ERROR);
            }

            pid_t id = fork(); // 创建子进程
            if(id < 0)
            {
                std::cerr << "fork error!" << std::endl; // 向标准错误写
                exit(FORK_ERROR);
            }
            else if(id == 0)
            {
                // 子进程关闭历史上父进程管理的管道写端
                if(!channels.empty())
                {
                    for(auto& ch : channels)
                    {
                        ch.ClosePipe();
                    }
                }

                // 子进程
                close(pipefd[1]); // 子进程关闭管道写端

                // 回调
                cb(pipefd[0]); // 子进程通过读端的文件描述符获取任务
                exit(OK); // 子进程执行完任务直接退出
            }

            // 父进程
            close(pipefd[0]); // 父进程关闭管道读端
            channels.emplace_back(pipefd[1], id); // 管理每个管道和对应的子进程
            // 上面的写法更优,作用等价于下面的
            // Channel ch(pipefd[1], id);
            // channels.push_back(ch); // 管理每个管道和对应的子进程

            std::cout << "创建子进程:" << id << " 成功..." << std::endl;
            sleep(1); // 间隔 1 秒创建一次
        }
    }

private:
    // 0. 组织Channel的容器
    std::vector<Channel> channels;
};

int main()
{
    // 0.创建并初始化进程池
    ProcessPool pp;
    pp.Init(Do_Task);
    // pp.DeBug();

    // 1.父进程控制子进程
    pp.Run();
    // sleep(100); // 让父进程休眠

    // 3.释放管道,回收子进程
    pp.Quit();

    return 0;
}

总结:
以上就是本期博客分享的全部内容啦!如果觉得文章还不错的话可以三连支持一下,你的支持就是我前进最大的动力!
技术的探索永无止境! 道阻且长,行则将至!后续我会给大家带来更多优质博客内容,欢迎关注我的CSDN账号,我们一同成长!
(~ ̄▽ ̄)~

相关推荐
feng一样的男子2 小时前
Rocky Linux 9 配置 IPv6 完整指南
linux·网络
十五年专注C++开发2 小时前
Linux 下用 VS Code 高效调试
linux·运维·服务器·c++·vscode
Sylvia33.2 小时前
体育数据API实战:用火星数据实现NBA赛事实时比分与状态同步
java·linux·开发语言·前端·python
大胖某人2 小时前
Kali系统安装OpenClaw调用DeepSeek API部署方法详解
linux·人工智能
七夜zippoe2 小时前
OpenClaw CLI 完整命令手册
linux·服务器·网络·cli·openclaw·命令手册
桌面运维家2 小时前
理解 Linux Front Page:构建动态Web首页指南
linux·运维·服务器
旺仔.2912 小时前
死锁 详解
linux·开发语言·计算机网络·安全
季明洵2 小时前
预处理详解(上)
linux·c语言·数据结构·预定义
toooooop82 小时前
linux常用命令nano和vim有啥区别
linux·运维·vim