使用管道实现进程间通信 (IPC, Inter-Process Communitation)

使用管道实现进程间通信

进程间通信

目前用户和计算机做的绝大部分操作,都是计算机通过进程的方式完成。而很多进程之间是可以互相协作的 ,进程和进程之间互相协作的前提 条件是两个进程之间可以传递信息。因此就有了进程间通信 IPC (Inter-Process Communitation) 的概念存在。

这种进程之间传递信息的行为和进程具有独立性是相违背的 ,因此保持进程独立的同时完成进程间通信需要有一个进程间通信的中间媒介

父母吵架,彼此不想见面,都想保持独立,于是作为儿子/女儿就充当了父母交流的媒介。

进程之间传递信息也可以通过套接字也就是网络实现,只需选择协议即可。这些留到网络编程再单独开一篇。

进程间通信的目的

  • 进程间数据传输:一个进程需要将它的数据发送给另一个进程。

  • 进程间资源共享:多个进程之间共享同样的资源。

  • 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件(如进程终止时要通知父进程)。

  • 进程控制:有些进程希望完全控制另一个进程的执行(如 Debug 进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。

进程间通信的发展

进程间通信的本质是先让不同的进程看到同一份资 源,同时这份资源不能由进程提供

若进程提供资源给其他进程看,则需要公开自身的部分进程地址空间,这和进程具有独立性相违背。

如何提供进程间通信的资源,一些比较权威的机构,它自己定出了一套标准,然后有操作系统去实现。因此基于特定标准之下,就有了各种通讯方式。

早期人们设计这些通信方式的时候,思维上总是想着直接复用以前的代码,看能不能让现有的操作系统代码支持通信,所以就有了基于文件的通信方式 ,形象上叫做管道

任何技术的解决方案,那么一定会有多种方案,人们总是会倾向于选择好用的方案,淘汰稍微不好用的方案。所以进程间通信的方案还保留了 2 种:System V 进程间通信POSIX

这些通信方式的分类:

  1. 管道:

    • 匿名管道 pipe。
    • 命名管道。
  2. System V IPC (Inter-process Communication) :

    • System V 消息队列。
    • System V 共享内存。
    • System V 信号量。
  3. POSIX IPC:

    • 消息队列。
    • 共享内存。
    • 信号量。
    • 互斥量。
    • 条件变量。
    • 读写锁。

POSIX 是基于跨网络的,让主机之间能够实现跨主机进行通信。

System V 版本的进程间通信方式现在有很多,代码的兼容性做得不够好,所以现在这个方式属于一个使用比较少的。

这里重点介绍管道。管道是 Unix 中最古老的进程间通信的形式。从一个进程连接到另一个进程 的一个数据流称 为一个 "管道"。

例如这个指令:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ who
Bjarne2  pts/2        2026-01-04 00:14 (1xx.1xx.1xx.1xx) # 这里暂时隐藏自己的主机IP
Bjarne   pts/3        2026-01-04 00:15 (1xx.1xx.1xx.1xx)
[Bjarne@VM-8-8-centos cppTest]$ who | wc -l
2
[Bjarne@VM-8-8-centos cppTest]$ 

who 会输出当前已登录的用户会话信息。 wc 用于单词计数,使用选项 -l 会统计行数。who 的信息通过管道传输给 wc -l 进行解析。

匿名管道

匿名管道 本质是在内存中 的操作系统内核存在的文件 ,因此匿名管道和磁盘文件不同,可以和进程进行极低成本的交流工作

匿名管道主要通过父进程调用系统调用 pipe 生成,在进程创建时继承给子进程。生成的匿名管道没有文件名,没有文件路径,只能通过已有的文件描述符进行访问。

匿名管道 主要用于拥有亲缘关系的进程间的通信 ,例如父进程和子进程、父进程和各级孙子进程。父子进程虽然可通过继承的方式传递信息,但无法传递各自运行途中发生变化的信息

pipe创建匿名管道

匿名管道通过系统调用 pipe 进行创建,并在 fork 时继承给子进程。

pipe 函数原型:

c 复制代码
#include <unistd.h>
int pipe(int fd[2]);

功能:创建一个匿名管道文件,该文件以 2 种方式打开。

参数 fd :文件描述符数组,其中 fd[0] 表示读端,fd[1] 表示写端。

返回值:成功返回 0,失败返回错误代码。

使用 pipe 后再使用 fork ,父进程会将同一个管道的 2 个文件描述符也继承给子进程(见文件的描述与组织)。

工程中的匿名管道 通常是单向信道 ,因此在创建时关闭子进程的写端和父进程的读端构建父进程对子进程的单向信道 。或在创建时关闭父进程的写端和子进程的读端 ,构建子进程对父进程的单向信道

在进行进程间通信时,进程信息的字节流 通过匿名管道的写端写入另一个进程 即可通过匿名管道的读端读取 ,读取时是以拷贝的形式读取 ,但读取完成之后数据还在管道里 ,只是因为管理信息被判定无效,会被后输入的数据给覆盖

字节流可以理解为一段存储有某种信息的二进制序列,通过不同的方式解读可得到不同的信息,例如按字符串的方式解读,按照字符宽度读取,读到某个标志例如 \0 时停下,就能读取到完整的信息。

在实现匿名管道的时候,单向信道在操作上会比较简单一些。双向通信的话建立 2 个管道文件即可。

这里通过一个父进程通过管道向子进程进行对话的代码演示管道的作用:

cpp 复制代码
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <iostream>
#include <string>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;

int main() {
    // 第1步,创建管道
    int pipefd_ftoc[2] = {
        0}; // father to child,父进程向子进程发送信息的单向信道
    int pipefd_ctof[2] = {
        0}; // child to father,子进程向父进程发送信息的单向信道
    int n1 = pipe(pipefd_ftoc), n2 = pipe(pipefd_ctof);
    assert(n1 == 0 || n2 == 0);
    cout << n1 << ' ' << n2 << endl; // 简单用一下n1和n2防止编译器警告
    // 第2步,创建子进程
    pid_t id = fork();
    if (id < 0) {
        perror("fork:");
        exit(1);
    }
    if (id == 0) {
        // 子进程
        close(pipefd_ftoc[1]); // 关闭ftoc的写端
        close(pipefd_ctof[0]); // 关闭ctof的读端
        char buf[1024] = {0};
        string st;

        sleep(1);
        ssize_t n = read(pipefd_ftoc[0], buf, 1023); // 接受父进程的信息
        if (n > 0 && string(buf) == "测试父进程发给子进程信息")
            st = "子进程:收到父进程的信息,子进程向父进程问好";
        else
            st = "子进程:没收到信息,测试失败";
        write(pipefd_ctof[1], st.c_str(), st.size());
        exit(0); // 做完应答后直接关闭
    }
    close(pipefd_ftoc[0]); // 关闭ftoc的读端
    close(pipefd_ctof[1]); // 关闭ctof的写端
    string st = "测试父进程发给子进程信息";

    cout << "父进程:向子进程发送信息:" << st << endl;
    write(pipefd_ftoc[1], st.c_str(), st.size());
    cout << "父进程:信息已经发送,正在等待回信" << endl;

    sleep(2);
    char buf[1024];
    ssize_t n = read(pipefd_ctof[0], buf, 1023);
    if (n > 0)
        cout << "父进程:收到信息:\n\t" << buf << endl;
    else
        cout << "测试失败\n";
    return 0;
}

交互界面:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
0 0
父进程:向子进程发送信息:测试父进程发给子进程信息
父进程:信息已经发送,正在等待回信
父进程:收到信息:
        子进程:收到父进程的信息,子进程向父进程问好
[Bjarne@VM-8-8-centos cppTest]$ 

如此便通过 2 个单向信道完成了父、子进程之间的通信。

从文件描述符角度理解管道:

内核角度的管道本质:

管道的使用和文件基本一致,迎合了 " Linux一切皆文件思想 " 。

pipe2创建匿名管道

参考 man 2 pipe2

c 复制代码
#include <fcntl.h>
#include <unistd.h>
int pipe2(int pipefd[2], int flags);

pipe2pipe 多了一个参数 flag

  • flag 为 0 时,pipe2pipe 相同。
  • 可通过或运算赋予 flag 这些值:
    • O_NONBLOCK,为两个新的打开文件描述设置 O_NONBLOCK 文件状态标志,使匿名管道不会阻塞进程。
    • O_CLOEXEC ,强制为新文件描述符设置 close-on-exec 标志 。使用该标志后,再使用 exec 系列函数进行程序替换时,被替换后的进程将无法使用这个文件描述符 。详细见使用exec系列函数进行程序替换-CSDN博客

这里简单使用 pipe2 创建一个不阻塞进程的管道实现进程间通信。

cpp 复制代码
#include <cstdio>
#include <fcntl.h>
#include <iostream>
#include <unistd.h>
using namespace std;

// 使用宏复写代码块
#define ERROR(func) \
    { \
        perror(#func); \
        exit(-1); \
    }

int main() {
    int pipefd_ftoc[2] = {0};
    if (pipe2(pipefd_ftoc, O_NONBLOCK) < 0) // 管道不会阻塞进程
        ERROR(pipe2);
    pid_t id = fork();
    if (id < 0)
        ERROR(fork);
    if (id == 0) {
        // 子进程
        close(pipefd_ftoc[1]); // 关闭ftoc的写端
        char buf[1024] = {0};
        string st;

        sleep(1);
        for (int i = 1; i <= 15; i++) {
            ssize_t n = read(pipefd_ftoc[0], buf, 1023); // 接受父进程的信息
            if (n > 0) {
                st = "子进程:收到父进程的信息: ";
                cout << st << buf << endl;
                exit(0);
            } else
                cout << "子进程:没收到信息" << endl;
            sleep(1);
        }
        exit(0);
    }
    // 父进程
    close(pipefd_ftoc[0]); // 关闭ftoc的读端
    string st = "man, what can I say.";

    cout << "父进程:向子进程发送信息:" << st << endl;
    sleep(5);
    write(pipefd_ftoc[1], st.c_str(), st.size());
    cout << "父进程:信息已经发送信" << endl;
    sleep(2);
    return 0;
}

交互界面:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -g -std=c++11
./a.exe
父进程:向子进程发送信息:man, what can I say.
子进程:没收到信息
子进程:没收到信息
子进程:没收到信息
子进程:没收到信息
父进程:信息已经发送信
子进程:收到父进程的信息: man, what can I say.
[Bjarne@VM-8-8-centos cppTest]$ 

一般推荐使用 pipe2 ,尤其是程序中涉及 fork + exec 时。

管道读写规则

没有数据可读时:

  • O_NONBLOCK disable(禁用非阻塞模式 ):read 调用阻塞 ,即进程暂停执行,直到有数据 为止。之前的子进程便是一直等待父进程发送数据,所以即使将 sleep 给注释掉也不影响交互结果。
  • O_NONBLOCK enable(进入非阻塞模式 ):若 read 调用返回 -1,errno 值为 EAGAIN。

管道满的时候

  • O_NONBLOCK disable: write 调用阻塞直到有进程读走数据
  • O_NONBLOCK enable:调用返回 -1errno 值为 EAGAIN。

例如这个程序:

这个代码中父进程会持续向子进程写入数据,子进程一次只读 20 个字节。用户通过键盘输入的数据被父进程接收,然后发送给子进程,子进程再输出。

  • 若用户迟迟不输入数据,则父进程进入阻塞状态被阻塞,子进程为等待管道信息一样被阻塞。
  • 若父进程给的信息量太过庞大,子进程需要分若干次才能读完,在子进程读完之前父进程被阻塞。
cpp 复制代码
#include <cassert>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <iostream>
#include <string>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
using namespace std;

int main() {
    int ftoc[2] = {0};
    if (pipe(ftoc)) {
        cerr << "System call pipe error:";
        exit(-1);
    }
    pid_t id = fork();
    if (id < 0) {
        cerr << "System call fork error:";
        exit(-2);
    }
    if (id == 0) {
        // 子进程
        close(ftoc[1]); // 子进程关闭写端
        char buf[1024] = {0};
        string st;
        while (read(ftoc[0], buf, 20) > 0) {//部分Linux的EOF为0,为防歧义如此设置
            st = buf;
            cout << "子进程" << getpid() << ": " << st << endl;
            //sleep(10);//可解除注释观察父进程的状态
            memset(buf, 0, 1024);
        }
        exit(0);
    }
    // 父进程
    close(ftoc[0]); // 父进程关闭读端
    string st;
    while (cin >> st) {
        cout << "父进程" << getpid() << ": " << st << endl;
        write(ftoc[1], st.c_str(), st.size());
    }
    return 0;
}

交互界面:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
123456789123456789123456789
父进程6264: 123456789123456789123456789
子进程6265: 12345678912345678912
子进程6265: 3456789 # 子进程会一直读取,读取完之前父进程会被一直阻塞
^C
[Bjarne@VM-8-8-centos cppTest]$ echo $?
130 # 128+2,表示进程被2号信号中断
[Bjarne@VM-8-8-centos cppTest]$ 

如果所有管道写端对应的文件描述符被关闭,则 read 返回 0。

如果所有管道读端对应的文件描述符被关闭,则 write 操作会产生信号 SIGPIPE,进而可能导致 write 进程退出。

当要写入的数据量不大于 PIPE_BUF 时,Linux 将保证写入的原子性。

原子性指的是一个操作或一系列操作不可分割、不可中断的特性。详细理解原子性需要了解线程的概念。这里可以暂时理解为一件事情要么没做,要做就已经做完了,这件事情的这种性质就叫做原子性。

当要写入的数据量大于 PIPE_BUF 时,linux 将不再保证写入的原子性。

匿名管道特点

使用 pipe 创建的管道的 4 种情况:

  1. 正常情况,如果管道没有数据 了,读端必须等待,直到有数据为止,标志是写端写入数据。
  2. 正常情况,如果管道被写满 了,写端必须等待,直到有空间为止,标志是读端读走数据。
  3. 写端关闭读端一直读取 ,读端会读到 read 返回值为 0, 表示读到文件结尾。
  4. 读端关闭写端一直写入 ,OS 会直接杀掉写端进程,通过向目标进程发送 SIGPIPE(13)信号,终止目标进程

管道的 5 种特性:

  1. 匿名管道 ,可以允许具有血缘关系的进程 之间进行进程间通信 ,常用与父子进程。通常一个管道由一个进程创建,然后该进程调用 fork,此后父、子进程之间就可应用该管道。
  2. 匿名管道 ,默认给读写端要提供同步机制
  3. 面向字节流。字节流按照不同的方式解读可得到不同的信息。
  4. 管道的生命周期是随进程的。进程退出,管道释放。
  5. 管道是单向通信 的,半双工通信 (类比人与人交流互相不打断的情况,数据只能向一个方向流动)的一种特殊情况。需要双方通信时,需要建立起两个管道。

一般而言,内核会对管道操作进行同步与互斥。

接下来将对匿名管道的 4 种情况和 5 种特性进行程序验证。内容冗余,出于数据的严谨性记录下来,可跳过。

管道空时读端等待

情况 1:正常情况,如果管道没有数据 了,读端必须等待,直到有数据为止,标志是写端写入数据。

这里建立子进程向父进程传输信息的单向信道,然后子进程直接休眠 100 秒,观察父进程的状态。

测试代码:

cpp 复制代码
// 头文件套用上一个代码的即可
int main() {
    // 第1步,创建管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n != 0)
        perror("pipe:"), exit(-1);
    // 第2步,创建子进程
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        close(pipefd[0]); // 关闭读端
        sleep(100);
        write(pipefd[1], "Hello, father.", 14);
        exit(0);
    }
    // 父进程
    close(pipefd[1]); // 关闭写端
    char buf[1024] = {0};
    read(pipefd[0], buf, 1023);
    cout << buf << endl;
    return 0;
}

交互界面:

bash 复制代码
# 界面1
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ make go
./a.exe 
## 这里等待100秒后,父进程会输出信息

# 界面2
[Bjarne@VM-8-8-centos cppTest]$ ps ajx | head -1 && ps ajx | grep a.exe|grep -v grep
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
24422 24423 24422 19539 pts/6    24422 S+    1001   0:00 ./a.exe
24423 24424 24422 19539 pts/6    24422 S+    1001   0:00 ./a.exe

可以看到,父子进程都处于可唤醒的睡眠状态,父进程在等子进程写入数据,子进程被 sleep 强制沉睡 100 秒。此时父子进程展示了一种等待管道信息的产生的顺序。

管道满时写段等待

情况2:正常情况,如果管道被写满 了,写端必须等待,直到有空间为止,标志是读端读走数据。

让负责写的进程正常写,负责读的进程故意放慢读取速度。因为负责读的进程读取太慢,负责写的进程在管道满了之后会阻塞等待,负责读的进程会读取大量重复的片段,且很可能是填满某个缓冲区后再输出。

因为输出的量很大,所以使用重定向查看规律。

测试代码:

cpp 复制代码
// 头文件套用之前的代码的即可
int main() {
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n != 0)
        perror("pipe:"), exit(-1);
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        close(pipefd[0]); // 关闭读端
        for (int i = 0; i < 1000; i++)
            write(pipefd[1], "Hello, father.", 14);
        exit(0);
    }
    // 父进程
    close(pipefd[1]); // 关闭写端
    char buf[1024] = {0};
    while (read(pipefd[0], buf, 1023) != 0) { // 读不到数据时read会返回0
        cout << buf << endl;
        sleep(1);
    }
    return 0;
}

交互界面:

bash 复制代码
# 界面1
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe>out.txt
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe>out.txt
[Bjarne@VM-8-8-centos cppTest]$ 
# 界面2
[Bjarne@VM-8-8-centos cppTest]$ ps ajx|head -1 && ps ajx | grep a.exe | grep -v grep
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
[Bjarne@VM-8-8-centos cppTest]$ ps ajx|head -1 && ps ajx | grep a.exe | grep -v grep
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
 3228  3781  3781  3228 pts/1     3781 S+    1001   0:00 ./a.exe
 3781  3782  3781  3228 pts/1     3781 Z+    1001   0:00 [a.exe] <defunct>

这里子进程早就写完全部的数据,因为父进程读取的太慢,子进程提前退出,进程状态变成僵尸状态,只为等待父进程读取完毕后回收子进程。查看 out.txt 的前几行:

bash 复制代码
Hello, father./*省略大量重复信息*/ Hello, father.# 这一行有126个字符
Hello, father./*省略大量重复信息*/Hello, father.H # 这一行有1023个字符
ello, father.Hello, father./*省略大量重复信息*/Hello, father.He
# 这一行有1024个字符
# 省略后续的字符

写端关闭读端继续

情况3:写端关闭读端一直读取 ,读端会读到 read 返回值为 0, 表示读到文件结尾。

在之前的代码中已使用 read 的返回值作为判断。

cpp 复制代码
int main(){
    // 代码和之前的一样
    // 父进程
    close(pipefd[1]); // 关闭写端
    char buf[1024] = {0};
    while (read(pipefd[0], buf, 1023) != 0){//读不到数据时read会返回0
        cout << buf << endl;
        sleep(1);
    }
    return 0;
}

若将 0 改成别的,例如 -1,则这个循环将无法终止,造成死循环。

读端关闭写端终止

情况4:读端关闭,写端一直写入,OS 会直接杀掉写端进程,通过向目标进程发送 SIGPIPE(13)信号,终止目标进程。

因为进程试图读取一个已经被关闭的文件,本身就是越界行为,所以会被操作进程干掉。

测试代码:

cpp 复制代码
// 头文件相同
int main() {
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n != 0)
        perror("pipe:"), exit(-1);
    pid_t id = fork();
    if (id == 0) {
        // 子进程
        close(pipefd[0]); // 关闭读端
        sleep(3);
        write(pipefd[1], "Hello, father.", 14); // 试图向已关闭的管道输出数据
        sleep(3);
        exit(0);
    }
    // 父进程
    close(pipefd[1]); // 关闭写端
    close(pipefd[0]); // 关闭读端,子进程作为写端会一直向写端写入,会被信号终止
    int stat = 0;
    waitpid(id, &stat, 0);
    cout << (stat & 0x7F) << endl;
    return 0;
}

交互界面:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
13 # 可以侦测到子进程被13号信号终止
[Bjarne@VM-8-8-centos cppTest]$ 

匿名管道大小

可通过一直向管道写入进行测试。

cpp 复制代码
// 头文件完全一致
int main() {
    int pipefd[2] = {0};
    if (pipe(pipefd) != 0)
        perror("pipe:"), exit(-1);
    pid_t id = fork();
    if (id < 0)
        perror("fork"), exit(-2);
    if (id == 0) {
        // 子进程
        close(pipefd[0]); // 子进程负责写
        int cnt = 0;
        for (int i = 0; i < 70000; i++) {
            ++cnt;
            write(pipefd[1], "a", 1);
            cout << cnt << endl;
        }
        exit(0);
    }
    close(pipefd[1]); // 父进程负责读
    int stat = 0;
    waitpid(id, &stat, 0);
    return 0;
}

交互界面:

bash 复制代码
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe > out.txt
^C # 物理超度俩进程,直接查看out.txt的结果
[Bjarne@VM-8-8-centos cppTest]$ cat out.txt|tail -1
65536
[Bjarne@VM-8-8-centos cppTest]$ 

这里子进程将管道填满后进入阻塞状态,父进程因等子进程也进入阻塞状态,双阻塞情况下这里使用 Ctrl + C 直接物理超度俩进程,直接查看out.txt的结果。

所以管道的大小为 65536 个字节,即 64 KB。

shell使用管道的原理

形如 ps ajx | grep a.exe | grep -v grep 的指令,若使用匿名管道实现,可通过循环连续创建 3 个进程和 2 个管道,第 1 个进程获取的信息通过管道发送给第 2 个进程,经第 2 个进程处理后通过管道发送给第 3 个。

模拟实现这个过程的话,需要对字符串进行解析,通过 |指令拆分成若干个部分 ,然后依次通过循环创建进程和管道,同时将 1 号文件描述符重定向到管道。

但无论如何管道都是先创建的,后续的子进程需要关闭无关的管道文件描述符。

基于匿名管道的简易进程池

当系统中的很多资源,它在创建的时候有比较大的成本时,可以提前把一批资源创建好。例如进程,当有任务时就把任务直接派发给已创建好的进程,让已创建好的进程去执行任务,这样就省略了大量的创建进程的时间。这一批被提前创建好的进程叫做进程池。

耗费的成本无外乎就是时间和空间。

  • 空间成本例如 STL 的容器配置器采用按需实现的话,当出现大量使用内存的情况时,开辟内存会变得异常缓慢。
  • 时间成本例如调用系统调用需要访问操作系统内核,然后再将数据返回。这个过程比起在内存中直接运行会耗费大量时间,若是访问外部硬件资源的系统调用更费时。

类似进程池这种提前把资源先准备好 ,当任务需要时直接分配 的思路在计算机领域叫池化技术 。池化技术除了进程池,还有线程池、内存池等。STL 的空间配置器用的是内存池技术,是为了在概率上减少用户调用系统调用申请空间的次数。

根据匿名管道没有数据读端必须等待,直到有数据为止的特性,可通过通知事件让一个进程控制另一个进程。

即一个父进程提前创建若干个子进程对等数量的匿名管道 ,当父进程向子进程传输某种信息时,子进程可以完成当前任务或替换为其他程序。这时父进程可以叫主控进程 ,其他子进程都可以叫工作进程,然后对所有匿名管道的管理变成了对某个队列数据结构的管理。

具体思路

具体思路(面向对象思路):

  1. 通过循环创建父写子读匿名管道 和配套的进程 。同时使用一个队列维护所有的管道。

    a. 对匿名管道需要进行先描述再组织。描述一个匿名管道可以给它安排一个名称和编号,将名称和编号,连同这个管道的文件描述符、链接的进程 ID 共同组织成一个结构体,将这个结构体命名为 Channel

    cpp 复制代码
    using std::string;
    using std::to_string;
    
    struct Channel { // 信道类,为方便测试用struct,可用class
        Channel(int fd, pid_t id) : ctrlFD(fd), workerID(id) {
            name = "channel" + to_string(++number);
        }
        int ctrlFD;        // 匿名管道描述符
        pid_t workerID;    // 管道链接的进程 ID
        string name;       // 信道名
        static int number; // 进程创建的信道数
    };
    int Channel::number = 0;

    b. 设计 Task 类,表示已准备好的任务。这里让进程进行简单的四则运算,并通过设置结果码标注任务完成情况。

    cpp 复制代码
    using std::string;
    using std::to_string;
    struct Task { // 任务描述
        // 运算数据
        int data_x;
        int data_y;
        char op;
        // 答案和结果码
        int ans;
        int code;
        // 任务相关
        static const string ope;
        enum {
            dv = 0,   // defaultvalue
            ok,       // 运行结果正常
            div_zero, // 除零错误
            mod_zero, // 模零错误
            unknowop  // 未知运算符
        };
        Task(int x = dv, int y = dv, char op = dv)
            : data_x(x), data_y(y), op(op), ans(dv), code(dv) {}
        // 执行任务
        void run() {}
        // 查看任务
        string printTask() {}
    };
    const string Task::ope = "~+-*/%!^";

    c. 设计 Procpool 类,负责创建子进程和信道、安排任务。对所有 Channel 类的对象使用 vectorlist 进行管理。

    cpp 复制代码
    struct Procpool
    {//主控类
        vector<Channel> channels;
        void CreateChannel(int num = 5){
            //1. 创建匿名管道
            //2. 创建子进程,子进程关闭写端和冗余信道
            //3. 父进程关闭读端
        }
        void arrange(){
            // 父进程安排任务
        }
    };

    d. 注意父进程在创建子进程时,子进程会继承父进程的所有文件描述符。因此需要根据父进程维护的信道数组去关闭子进程的冗余信道

    注意到在创建子进程时,子进程会完整继承父进程的所有代码 ,自然也包括 Procpool 类,且无论父进程如何修改 自己的 channels 数组都不影响自身 。所以可通过遍历 channels 数组关闭所有子进程继承的写端。

  2. 当接受到任务请求后,根据被管理的所有管道信息,选择空闲进程执行任务。这里为测试方便,直接循环遍历所有信道发布任务。若是正经的线程池,需要按照某种条件选择合适的进程执行任务。

  3. 传递的信息若为 STL 容器或含有指向堆区的指针,则对象和内容需要分别上传。

参考程序v1.0

参考程序(procpool.hpp):

cpp 复制代码
#pragma once
#include "Task.hpp"
#include <cassert>
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <fcntl.h>
#include <iostream>
#include <random>
#include <string>
#include <sys/stat.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>

namespace mystd {
using std::cerr;
using std::cout;
using std::endl;
using std::string;
using std::to_string;
using std::vector;

struct Channel { // 信道类
    Channel(int fd, pid_t id) : ctrlFD(fd), workerID(id) {
        name = "channel" + to_string(++number);
    }
    int ctrlFD;        // 匿名管道描述符
    pid_t workerID;    // 管道链接的进程 ID
    string name;       // 信道名
    static int number; // 进程创建的信道数
};
int Channel::number = 0;

class Procpool { // 主控类
private:
    vector<Channel> channels; // 控制所有匿名管道

public:
    void CreateChannel(int num = 5) {
        for (int i = 0; i < num; i++) {
            // 定义并创建管道
            int pipeid[2] = {0};
            if (pipe(pipeid) != 0)
                perror("System call Class Procpool, CreateChannel::pipe is "
                       "abnormal:"),
                    exit(-1);

            // 生成子进程
            pid_t id = fork();
            if (id == -1)
                perror("System call  Class Procpool, CreateChannel::fork is "
                       "abnormal:"),
                    exit(-2);
            if (id == 0) {
                // 子进程
                close(pipeid[1]); // 子进程关闭信道的写端
                for (
                    const auto &c :
                    channels) // 其他子进程可能打开别的文件,因此直接通过信道队列关闭冗余信道
                    close(c.ctrlFD);
                // 安排任务,这个缓冲区当成void*数组使用
                char buf[1024] = {'\0'};
                // 子进程一直读取管道数据,直到父进程被终止
                while (read(pipeid[0], buf, 1024) > 0) {
                    cout << "子进程" << getpid() << ":执行任务:";
                    Task task;
                    // 二进制缓冲区可按照指定方式解读
                    // 在C++建议创建一个额外的对象存储数据
                    // 这里Task类没有使用容器,所以可直接拷贝
                    // 若使用了容器,则需要重新解读内容
                    memcpy(&task, buf, sizeof(Task));
                    cout << task.printTask() << "===>";
                    task.run();
                    cout << task.printTask() << endl;
                    memset(buf, 0, 1024);
                }
                exit(0);
            }

            // 父进程
            close(pipeid[0]); // 父进程关闭信道的读端
            channels.push_back(Channel(pipeid[1], id));
        }
        cout << "父进程" << getpid() << ": 所有信道安排完成" << endl;
    }

    void arrange(int num = 5) {
        // 父进程安排任务
        // 高精度随机数
        std::mt19937 rng(
            std::chrono::steady_clock().now().time_since_epoch().count());
        for (int i = 0; i < num; i++) {
            int x = rng() % 1000 + i, y = rng() % 1000 + i,
                opsz = rng() % Task::ope.size();
            Task *task = new Task(x, y, Task::ope[opsz]);
            ssize_t num = write(channels[i].ctrlFD, task, sizeof(Task));
            if (num < 0) {
                cerr << ("System call Class Procpool, CreateChannel::write is "
                         "abnormal:");
                exit(-1);
            }
            delete task;
            sleep(2);
        }
    }
    ~Procpool() { // 回收资源
        if (!channels.empty()) {
            for (const auto &c : channels)
                close(c.ctrlFD);
            for (const auto &c : channels) { // 等待所有子进程退出
                int status;
                waitpid(c.workerID, &status, 0);
            }
        }
    }
};

void testProcpool() {
    Procpool MastorCtrl;
    MastorCtrl.CreateChannel();
    MastorCtrl.arrange();
}
} // namespace mystd

Task.hpp 参考:

cpp 复制代码
#pragma once
#include <string>

namespace mystd {

using std::string;
using std::to_string;
struct Task { // 任务描述
    // 运算数据
    int data_x;
    int data_y;
    char op;
    // 答案和结果码
    int ans;
    int code;
    // 任务相关
    static const string ope;
    enum {
        dv = 0,   // defaultvalue
        ok,       // 运行结果正常
        div_zero, // 除零错误
        mod_zero, // 模零错误
        unknowop  // 未知运算符
    };
    Task(int x = dv, int y = dv, char op = dv)
        : data_x(x), data_y(y), op(op), ans(dv), code(dv) {}
    // 消费者运行
    void run() {
        switch (op) {
        case '+':
            ans = data_x + data_y;
            code = ok;
            break;
        case '-':
            ans = data_x - data_y;
            code = ok;
            break;
        case '*':
            ans = data_x * data_y;
            code = ok;
            break;
        case '/':
            if (data_y == 0) {
                code = div_zero;
                break;
            }
            ans = data_x / data_y;
            code = ok;
            break;
        case '%':
            if (data_y == 0) {
                code = mod_zero;
                break;
            }
            ans = data_x % data_y;
            code = ok;
            break;
        default:
            code = unknowop;
            break;
        }
    }
    // 查看任务
    string printTask() {
        string st;
        st += to_string(data_x), st += op, st += to_string(data_y);
        st += "=", st += to_string(ans);
        st += "[", st += to_string(code), st += "]";
        return st;
    }
};
const string Task::ope = "~+-*/%!^";
} // namespace mystd

任务交给进程池中的子进程时,要较为平均地把任务交给子进程。这里因为是测试,所以直接循环调用。

像这种如何实现把任务较为平均的交付 给对应的后端进程或其他资源,避免某些进程或别的单位资源过于空闲的问题,就叫做负载均衡问题。

其他可尝试添加功能:

比如指定某个子进程退出 ,父进程可关闭某个信道的写端 ,子进程无法使用 read 读取匿名管道的数据,会返回 0,此时直接 exit(0) 即可。

命名管道

匿名管道 应用的一个限制就是只能在具有共同祖先或具有亲缘关系的进程间通信 。若想在不相关的进程之间交换数据 ,可以使用命名管道(FIFO)文件来做这项工作。

命名管道 和匿名管道相比,有路径和文件名。因为路径和文件名具有唯一性,所以可以使用路径加文件名,来让不同的进程看到同一份资源。

未来通过路径和文件名就能找到这个文件。使用 mkfifo 指令可在当前目录下创建一个管道文件,这个文件就是命名管道。

命名管道原理和特征除了命名管道有路径和文件名和匿名管道基本相同

指令创建命名管道

命名管道可以从命令行上创建,命令行方法是使用下面这个命令:

bash 复制代码
mkfifo filename

交互界面:

bash 复制代码
# 同IP地址的主机界面1
[Bjarne@VM-8-8-centos cppTest]$ mkfifo pipo
[Bjarne@VM-8-8-centos cppTest]$ ls -al|grep pipo
prw-rw-r-- 1 Bjarne Bjarne     0 Jan 14 10:15 pipo
[Bjarne@VM-8-8-centos cppTest]$ while :; do echo "hello world">pipo;sleep 1;done # 循环向命名管道打印数据
^C
[Bjarne@VM-8-8-centos cppTest]$ 

# 同IP地址的主机界面2
[Bjarne@VM-8-8-centos cppTest]$ while :; do cat<pipo;done 
hello world # 会一直尝试读取,读不到会阻塞
hello world
# 省略重复数据
^C
[Bjarne@VM-8-8-centos cppTest]$ 

# 同IP地址的主机界面3
[Bjarne@VM-8-8-centos cppTest]$ while :; do ls -al|grep pipo;sleep 1;done
prw-rw-r-- 1 Bjarne Bjarne     0 Jan 14 10:25 pipo
prw-rw-r-- 1 Bjarne Bjarne     0 Jan 14 10:25 pipo

注意到通过 ls -al 查询到的命名管道的大小始终为 0,这是因为命名管道 只是内存中的一个缓冲区 ,不是磁盘文件,数据不写入磁盘,而 ls 指令读取到的文件大小信息只生效与磁盘文件。

man 3 mkfifo 的部分信息:

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename,mode_t mode);

filename 是命名管道的文件名和路径名, mode 是创建文件时的权限。

man 2 unlink 的部分信息:

c 复制代码
#include <unistd.h>
int unlink(const char *pathname);

如果某文件名指向一个套接字、FIFO(命名管道)或设备文件,使用 unlink 时会将该文件名移除,但已打开该对象的进程仍可继续使用它。

创建命名管道:

c 复制代码
int main(int argc, char *argv[]) {
    mkfifo("p2", 0644);
    return 0;
}

匿名管道与命名管道的区别

  • 匿名管道由 pipe 函数创建并打开。

  • 命名管道由 mkfifo 函数创建,打开用 open

  • FIFO(命名管道)与匿名管道之间唯一的区别在它们创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

创建出来的命名管道还需要通过 open 进行二次打开。

例如这 2 个程序,a.exe 程序创造了管道文件 fifo,通过文件 in.txt 读取数据到管道,b.exe 程序读取管道的数据到 out.txt,并使用 ulink 关闭管道文件 fifo。两个进程配合便实现了文本的拷贝。

a.exe 程序源码 a.cpp:

cpp 复制代码
#include <errno.h>
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

#define ERR_EXIT(m) \
    do { \
        perror(m); \
        exit(EXIT_FAILURE); \
    } while (0)

int main(int argc, char *argv[]) {
    mkfifo("fifo", 0644);
    int infd = open("in.txt", O_RDONLY);
    if (infd == -1)
        ERR_EXIT("open");
    int outfd;
    outfd = open("fifo", O_WRONLY);
    if (outfd == -1)
        ERR_EXIT("open");
    char buf[1024];
    int n;
    while ((n = read(infd, buf, 1023)) > 0) {
        write(outfd, buf, n);
    }
    close(infd);
    close(outfd);
    return 0;
}

b.exe 程序源码 b.cpp

cpp 复制代码
#include <errno.h>
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

#define ERR_EXIT(m) \
    do { \
        perror(m); \
        exit(EXIT_FAILURE); \
    } while (0)

int main(int argc, char *argv[]) {
    int outfd;
    outfd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (outfd == -1)
        ERR_EXIT("open");
    int infd;
    infd = open("fifo", O_RDONLY);
    if (outfd == -1)
        ERR_EXIT("open");
    char buf[1024];
    int n;
    while ((n = read(infd, buf, 1023)) > 0) {
        write(outfd, buf, n);
    }
    close(infd);
    close(outfd);
    unlink("fifo");
    return 0;
}

交互界面:

bash 复制代码
# 同IP地址主机的界面1
[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ g++ b.cpp -o b.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ cat in.txt
123456789123456789123456789
123456789
123456789123456789
123456789123456
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe # 这里会先阻塞,等待b进程读走数据
[Bjarne@VM-8-8-centos cppTest]$ 

# 同IP地址主机的界面2
[Bjarne@VM-8-8-centos cppTest]$ ./b.exe
[Bjarne@VM-8-8-centos cppTest]$ cat out.txt
123456789123456789123456789
123456789
123456789123456789
123456789123456
[Bjarne@VM-8-8-centos cppTest]$ 

命名管道的打开规则

如果当前打开操作是为读而打开 FIFO 时:

O_NONBLOCK disable:阻塞,直到有进程为写而打开该 FIFO

O_NONBLOCK enable:立刻返回成功。

如果当前打开操作是为写而打开 FIFO 时:

O_NONBLOCK disable:阻塞,直到有进程为读而打开该 FIFO

O_NONBLOCK enable:立刻返回失败,错误码为 ENXIO

实现简易的server&client通信

在正式的项目中,部分项目分为客户端和服务端。这里网络和跨主机通信的内容不是重点,于是就在同一 IP 地址下模拟客户端和服务端的交互。

客户端 (Client) 是提出请求 的一方,服务端 (Server) 是响应请求的一方。它们本质上是角色分工。例如 2 个进程,一个负责提出请求,就是客户端进程;另一个是响应请求,就是服务端进程。

服务端 server.exe 的目标:

  1. 创建命名管道。
  2. 打印客户端的信息。

客户端 client.exe的目标:

  1. 从键盘得到信息。
  2. 通过命名管道向服务端发送消息。

server.cpp 代码:

c 复制代码
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

// 使用宏复写代码块
#define ERR_EXIT(m) \
    do { \
        perror(m); \
        exit(EXIT_FAILURE); \
    } while (0)

int main() {
    umask(0);
    if (mkfifo("mypipe", 0644) < 0) 
        ERR_EXIT("mkfifo");
    int rfd = open("mypipe", O_RDONLY);
    if (rfd < 0) 
        ERR_EXIT("open");

    char buf[1024];
    while (1) {
        buf[0] = 0;
        printf("Please wait...\n");
        ssize_t s = read(rfd, buf, sizeof(buf) - 1);
        if (s > 0) {
            buf[s - 1] = 0;
            printf("client say: %s\n", buf);
        } 
        else if (s == 0) {
            printf("client quit, server exit now!\n");
            exit(0);
        } else {
            ERR_EXIT("read");
        }
    }
    close(rfd);
    unlink("fifo");
    return 0;
}

client.cpp

c 复制代码
#include <fcntl.h>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>

#define ERR_EXIT(m) \
    do { \
        perror(m); \
        exit(EXIT_FAILURE); \
    } while (0)

int main() {
    int wfd = open("mypipe", O_WRONLY);
    if (wfd < 0)
        ERR_EXIT("open");
    char buf[1024] = {0};
    while (1) {
        buf[0] = 0;
        printf("Please Enter: ");
        fflush(stdout);
        ssize_t s = read(0, buf, sizeof(buf) - 1);
        if (s > 0) {
            buf[s] = 0;
            write(wfd, buf, strlen(buf));
        } else if (s <= 0)
            ERR_EXIT("read");
    }
    close(wfd);
    return 0;
}

交互界面:

bash 复制代码
# 同IP地址主机界面1
[Bjarne@VM-8-8-centos cppTest]$ make
g++ client.cpp -o client.exe -std=c++11 -g
g++ server.cpp -o server.exe -std=c++11 -g
[Bjarne@VM-8-8-centos cppTest]$ ./server.exe
Please wait...
client say: 你好
Please wait...
client say: 你傻嘛
Please wait...
client say: Anser me, server.
Please wait...
client say: 不回答是吧,我泡了
Please wait...
client quit, server exit now!
[Bjarne@VM-8-8-centos cppTest]$ 

# 同IP地址主机界面2
[Bjarne@VM-8-8-centos cppTest]$ ./client.exe
Please Enter: 你好
Please Enter: 你傻嘛
Please Enter: Anser me, server.
Please Enter: 不回答是吧,我泡了
Please Enter: ^C
[Bjarne@VM-8-8-centos cppTest]$ 

之前的简易进程池可将匿名管道更换成命名管道。这里省略。

相关推荐
乱七八糟的屋子1 小时前
【C++数值计算】Armadillo超详细入门教程
c++·线性代数·数值计算·科学计算·矩阵运算·armadillo
Yyyyyy~1 小时前
【C++】vector
c++·c++primer
冻柠檬飞冰走茶1 小时前
PTA基础编程题目集 7-37 整数分解为若干项之和(C语言实现)
c语言·开发语言·数据结构·算法
Nuanyt1 小时前
Linux系统的 Shell 常见指令和常见知识点(以bash为主)
linux·运维·chrome·bash
程序员老陆1 小时前
FFmpeg 硬解在 Linux 上:从“能跑”到“跑满 GPU”的实战笔记
linux·笔记·ffmpeg
星恒随风1 小时前
Linux 权限详解:从 rwx、chmod 到 umask、目录权限与粘滞位
linux·运维·服务器·笔记·学习
拳里剑气1 小时前
Linux:进程间通信
linux·运维·数据库·进程
lsh曙光2 小时前
防火墙-iptables
linux
阳光九叶草LXGZXJ2 小时前
达梦数据库-报错-12-[-7184]:对象定义[XXX]被修改,版本检查失败
linux·运维·服务器·数据库·sql·学习