【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账号,我们一同成长!
(~ ̄▽ ̄)~

相关推荐
groundhappy3 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
通信小小昕4 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫5 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生6 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.117 小时前
linux基础IO
linux·运维·服务器
BelongPanda9 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯9 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_777998349 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器
yunwei379 小时前
eBPF 教程:检查 exec 后真正安装的可执行镜像
linux·开源
笑锝没心没肺10 小时前
yum update 报错:GPG key retrieval failed: [Errno 14] HTTPS Error 404 - Not Found
linux·运维·服务器