进程通信详解

你的进程池为什么卡死了?

先跑起来看看。

这段代码是一个进程池------父进程创建 N 个子进程,每个子进程通过匿名管道 接收父进程发过来的任务码(4 字节整数),查到对应函数后执行。父进程这边用轮询 做负载均衡:_next = (_next + 1) % channels.size(),依次挑一个子进程发任务。

cpp 复制代码
// 任务:一个函数表,下标就是任务码
std::vector<std::function<void()>> tasks;
void LoadTasks() {
    tasks.push_back(TaskA);
    tasks.push_back(TaskB);
    tasks.push_back(TaskC);
    // ...
}

// channel:描述一个管道 + 子进程
class Channel {
    int _writefd;
    pid_t _pid;
    std::string _name;
public:
    void CloseWrite() { close(_writefd); }
    void Wait()       { waitpid(_pid, nullptr, 0); }
};

// 进程池
class ProcessPool {
    std::vector<Channel> channels;
    int _next;
public:
    void Start(int num) {
        for (int i = 0; i < num; i++) {
            int pipefd[2];
            pipe(pipefd);                    // ① 创建管道
            pid_t id = fork();               // ② 创建子进程
            if (id == 0) {
                close(pipefd[1]);            // 子进程关写端
                // ③ 循环读 4 字节任务码,查表执行
                while (true) {
                    int code = 0;
                    ssize_t n = read(pipefd[0], &code, 4);
                    if (n == 0) break;       // 写端关闭,退出
                    if (n == 4) tasks[code]();
                    else break;
                }
                close(pipefd[0]);
                exit(0);
            }
            // 父进程:关闭读端,只写
            close(pipefd[0]);
            channels.push_back(Channel(pipefd[1], id));
        }
    }
    // 轮询选择下一个子进程
    Channel& NextChannel() {
        Channel& c = channels[_next];
        _next = (_next + 1) % channels.size();
        return c;
    }
    // 向选中的子进程发送任务码(必须是 4 字节)
    void SendTask(int code) {
        Channel& c = NextChannel();
        write(c.GetWriteFd(), &code, 4);
    }
    void Stop() {
        for (auto &ch : channels) {
            ch.CloseWrite();    // 关一个
            ch.Wait();          // 等一个
        }
    }
};

启动 10 个子进程,跑完任务后调 Stop()

预期:10 个子进程依次退出,父进程回收完毕,程序结束。

实际

bash 复制代码
$ ./process_pool 10
$ ps ax | grep process_pool
# 10 个子进程全挂在那,一个没退

卡死了。见鬼。


画出来就清楚了

我们不急着修。先拿 3 个子进程当例子,一步步画文件描述符表。

第一轮

父进程有一个文件描述符表,012 已经被 stdin/stdout/stderr 占了。我们从 3 开始。

创建第一个管道pipe() 返回两个 fd------pipefd[0] 是读端,pipefd[1] 是写端。怎么记?0 像一张嘴(只管往里吸数据),1 像一支笔(只管往外写数据)。设本次分配:3 = 读端,4 = 写端。

复制代码
父进程 fd 表:
  0 → stdin
  1 → stdout
  2 → stderr
  3 → 管道1 读端
  4 → 管道1 写端

fork 第一个子进程:子进程以父进程的 fd 表为模板,写时拷贝一份。

复制代码
子进程1 fd 表(继承自父进程):
  0 → stdin
  1 → stdout
  2 → stderr
  3 → 管道1 读端
  4 → 管道1 写端

然后各自关掉不需要的端------父写子读:

  • 父进程:close(3),只留 4(写)

  • 子进程1:close(4),只留 3(读)

    父进程 fd 表: 子进程1 fd 表:
    0 → stdin 0 → stdin
    1 → stdout 1 → stdout
    2 → stderr 2 → stderr
    3 → (空) 3 → 管道1 读端
    4 → 管道1 写端 4 → (空)

完美。父通过 4 写,子通过 3 读。一条干净的单向信道。

第二轮

父进程继续循环。创建第二个管道pipe() 再分配两个 fd。此时 3 是空的(刚被 close 掉了),4 还占着。

所以新管道:3 = 读端,5 = 写端。

复制代码
父进程 fd 表:
  0 → stdin
  1 → stdout
  2 → stderr
  3 → 管道2 读端      ← 新
  4 → 管道1 写端      ← 旧,还开着!
  5 → 管道2 写端      ← 新

fork 第二个子进程 。子进程以当前父进程的 fd 表为模板------把整个表拷贝过去

复制代码
子进程2 fd 表(继承自父进程):
  0 → stdin
  1 → stdout
  2 → stderr
  3 → 管道2 读端      ← 这有问题吗?没有,子进程2 需要读管道2
  4 → 管道1 写端      ← 等等......这个是什么?
  5 → 管道2 写端      ← 子进程2 不需要写

注意到了吗?子进程 2 不光拿到了自己管道(管道 2)的读写端,还拿到了管道 1 的写端!

因为 fork 拷贝的是整个 fd 表,不是只拷贝"刚刚创建的"那两个 fd。父进程的 4 号 fd 还指向管道 1 的写端,所以子进程 2 的 4 号 fd 也指向管道 1 的写端。

然后各自关不需要的:

  • 父进程:close(3)(管道 2 读端)

  • 子进程 2:close(5)(管道 2 写端)

    父进程 fd 表: 子进程2 fd 表:
    0 → stdin 0 → stdin
    1 → stdout 1 → stdout
    2 → stderr 2 → stderr
    3 → (空) 3 → 管道2 读端
    4 → 管道1 写端 4 → 管道1 写端 ← 这就是那条多余的腿
    5 → 管道2 写端 5 → (空)

好了,现在管道 1 的写端有两个进程指着------父进程和子进程 2。

第三轮

父进程继续。创建第三个管道:3 空着,4、5 占着。所以新 fd:3 = 读端,6 = 写端。

复制代码
父进程 fd 表:
  0 → stdin
  1 → stdout
  2 → stderr
  3 → 管道3 读端
  4 → 管道1 写端
  5 → 管道2 写端
  6 → 管道3 写端

fork 第三个子进程。拷贝整个 fd 表。

复制代码
子进程3 fd 表:
  0 → stdin
  1 → stdout
  2 → stderr
  3 → 管道3 读端      ← 需要
  4 → 管道1 写端      ← 多余!
  5 → 管道2 写端      ← 多余!
  6 → 管道3 写端      ← 不需要(子进程只读)

各自关不需要的之后:

复制代码
子进程3 fd 表:
  3 → 管道3 读端
  4 → 管道1 写端      ← 还指着呢
  5 → 管道2 写端      ← 还指着呢
  6 → (空)

规律出来了

如果创建的是 N 个子进程:

  • 管道 1 的写端 :被 1(父进程)+ (N-1) 个后续子进程 = 共 N 个进程指向它
  • 管道 2 的写端 :被 1(父进程)+ (N-2) 个后续子进程 = 共 N-1 个进程指向它
  • 管道 k 的写端 :被 1(父进程)+ (N-k) 个后续子进程 = 共 N-k+1 个进程指向它
  • 管道 N 的写端:只有父进程指着,1 个

内核维护的文件对象上有一个引用计数。每一个 fd 指向它,计数就加一。只有引用计数归零,文件才会真正关闭,读端才能读到 EOF(0)。

回到 Stop() 的问题

cpp 复制代码
void Stop() {
    for (auto &ch : channels) {   // 从第一个管道开始
        ch.CloseWrite();           // close(4) → 管道1 写端引用计数减 1
        ch.Wait();                 // waitpid 等待子进程1 退出
    }
}

当父进程 close(4)(管道 1 的写端),引用计数从 N 减到 N-1。不为零。 所以管道 1 还没死。

子进程 1 的 read(3, ...)------管道里没数据了,但写端还有人指着(子进程 2、子进程 3 还指着呢),所以 read 不会返回 0,也不会返回 -1,它阻塞住了。

子进程 1 不退出 → waitpid(pid1) 等不到 → 父进程被挂起 → 整个程序卡死。

换个问法:管道什么时候才算"写端关闭"?当没有任何进程持有它的写端 fd 时。 父进程关了一个,还有一堆子进程指着,所以不算关。


为什么有些搞法能工作

搞法一:先关完所有的,再统一等

cpp 复制代码
void Stop() {
    for (auto &ch : channels)
        ch.CloseWrite();           // 把所有管道的写端全关掉
    for (auto &ch : channels)
        ch.Wait();                 // 再统一回收
}

能工作。因为当你 close 管道 N 的写端时,它的引用计数直接归零(只有父进程指着它),子进程 N 的 read 立即返回 0,子进程 N 退出。子进程 N 一退出,它身上所有 fd 都释放------包括它指着的管道 N-1 的写端。于是管道 N-1 的引用计数也降了......连锁反应,从后往前依次释放。

搞法二:倒着回收

cpp 复制代码
void Stop() {
    for (int i = channels.size() - 1; i >= 0; i--) {
        channels[i].CloseWrite();
        channels[i].Wait();
    }
}

也能工作。和上面逻辑一样------先关最后一个管道(写端只有父进程指着),子进程立即退出释放资源,连锁效应向上传导。

搞法三:正着来,关一个等一个

这就是我们一开始的代码。不行。 第一个管道的写端还有子进程 2、3、4...N 指着,引用计数不为零,子进程 1 的 read 永远阻塞。


修好它

核心一句话:每个子进程,除了关掉自己管道不需要的那一端,还要把从父进程继承下来的、历史上其他管道的写端也全关掉。

具体怎么做?子进程在 fork 后能看到父进程的 channels vector(写时拷贝)。这个 vector 里保存了之前创建的每一个管道的写端 fd。遍历它,全部 close 掉。

cpp 复制代码
// 在子进程的代码块里,关闭自己不需要的写端之后,加上这一段:
for (auto &ch : channels) {
    ch.CloseWrite();   // 关掉历史上所有管道写端
}

为什么写时拷贝保证了安全性?子进程 fork 之后拿到的是父进程 channels 的一个副本。父进程后续向 channelspush_back 新的 channel,修改的是父进程自己的那份,不影响子进程已经拿到的副本。子进程这里随便关,不会把父进程刚创建的写端关掉(因为还没 fork 出这个子进程时,那个写端还不存在于副本里)。

第一次 forkchannels 为空,循环不执行。

第二次 forkchannels 里有管道 1 的写端,关掉。

第三次 forkchannels 里有管道 1 和管道 2 的写端,关掉。

每个子进程变成了干净的一个读端,没有多余的写端指着别的管道。

此时每个管道的写端只有父进程指着 。父进程 close 一个,引用计数归零,子进程 read 返回 0,退出,waitpid 立即返回。畅通无阻。

就这么回事。


命名管道:让陌生人也能聊天

匿名管道有个致命限制:只能父子/兄弟进程之间通信。 靠的是 fork 继承 fd 表,让双方看到同一个文件对象。

那如果我就是要让两个八竿子打不着的进程通信呢?比如两个终端里分别启动的程序。

核心原理:一个不刷磁盘的文件

回想一下普通文件被打开时的流程:

  1. 进程 A 调用 open("ipc.txt", O_WRONLY) → 内核创建 struct file,加载 inode 和内容到内核缓冲区,返回 fd 3
  2. 进程 B 调用 open("ipc.txt", O_RDONLY) → 文件已经被打开过了,内核不会再加载一份,直接复用 inode 和缓冲区,创建新的 struct file,返回 fd 3

现在 A 往 fd 3 里写,B 从 fd 3 里读。从逻辑上讲,这不就可以通信了吗?

问题是:普通文件会把数据刷到磁盘上。A 写了数据,刷到磁盘,B 再从磁盘读------中间过了外设 IO,慢,而且这本来就不是为通信设计的。

命名管道就是做了减法。 它也是一个磁盘上有名字的文件(ls -l 看到类型是 p),有自己的 inode------权限、时间戳、所属用户这些属性全都有,跟普通文件一模一样。但它做了一件事:写入内核缓冲区的数据,不刷新到磁盘。 数据留在内存里,等着另一方来读。对于操作系统来说,识别到文件类型是 p,就不执行"刷磁盘"那一步------裁减一个功能而已,编码并不难。

换个问法------它其实就是把匿名管道那一套内核结构,挂了一个磁盘上的路径名上去。两个不相干的进程,通过同一条路径 + 文件名,打开同一个管道文件,看到同一块内核缓冲区。让不同进程先看到同一份资源。

这就是命名管道。

一个关键的阻塞点:open 本身就会卡住

这一点和普通文件完全不同。对于命名管道:

  • 当 server 以 O_RDONLYopen 时,如果还没有任何进程以写方式打开同一个管道,open 会阻塞,直到有写端出现。
  • 反过来,client 以 O_WRONLYopen 时,如果还没有读端,open 也会阻塞。

换句话说,命名管道的 open 本身就是一种同步点 ------双方必须都到场,通信才能开始。这也是为什么必须先启动 server(让它卡在 open 上),再启动 client(让两边的 open 同时返回),通信才正式开始。

这一点和匿名管道不一样。匿名管道通过 fork 继承,父子双方拿到的 fd 天然就指向同一个文件对象,不存在"你先开我再开"的协调问题。

命令行先试一下

不用写代码,直接用 shell 就能验证。

bash 复制代码
# 创建一个命名管道文件
$ mkfifo fifo

# 看一下
$ ls -l fifo
prw-rw-r-- 1 user user 0 Jun 22 10:00 fifo
#   ↑ p = pipe,管道文件。大小始终是 0。

# 开一个终端,往里面写
$ while true; do echo "hello $(date)"; sleep 1; done > fifo

# 开另一个终端,从里面读
$ cat fifo
hello Mon Jun 22 10:00:01 CST 2026
hello Mon Jun 22 10:00:02 CST 2026
hello Mon Jun 22 10:00:03 CST 2026
...

在写入和读取期间,ls -l fifo 看到的文件大小始终是 0。数据没有落盘。

如果把读端 Ctrl+C 关掉,写端也会收到 SIGPIPE,bash 进程直接被干掉------和匿名管道的特性一模一样。

除了"让任意进程通信"这一点不同,命名管道的五种特性(字节流、单向、自带同步、随进程生命周期、可用于任意进程通信)和匿名管道完全一致。

这里把管道的四种经典读写场景和五种特性集中列一下,因为后面讲命名管道时全部复用。

管道的四种读写场景

拿父子进程的匿名管道举例------父写子读,单向。

场景 写端操作 读端操作 结果
写得慢 / 暂停写(但不关) 读得快,管道读空后继续 read 读端阻塞,直到有数据或写端关闭
一直写 一直不读 管道写满后写端阻塞,直到有空闲空间
不写了,且关闭写端 读端把剩余数据读完后再 read read 返回 0(读到文件结尾)
一直写 读端关闭自己的读 fd 操作系统向写进程发送 SIGPIPE,默认动作:杀死写进程

场景 ④ 就是上面命令行 demo 里把 cat 关掉后 bash 进程直接挂掉的原因------bash 往已关闭读端的管道里写,收到了 SIGPIPE 信号。

管道的五种特性
  1. 匿名管道仅限于有血缘关系的进程之间通信 (靠 fork 继承 fd 表看到同一资源)。命名管道把这条变成了任意进程均可通信(靠文件路径看到同一资源)。
  2. 提供字节流服务------管道本质是一个以字节为单位的队列(先进先出),不存在消息边界。你写 5 个字节,对方可以分 3 次读,也可以 1 次读 5 个。接收方自己定协议。
  3. 生命周期随进程------所有引用管道的进程退出后,管道自动释放。
  4. 自带同步机制------场景 ① 和 ②:管道空时读端自动阻塞,管道满时写端自动阻塞。不需要额外加锁。
  5. 单向通信------一条管道只能一个方向流动。父写子读,或者父读子写。想双向就得建两条管道。

写成代码

两个独立编译的可执行程序,通过一个公共头文件约定好管道路径。

公共头文件 common.hpp

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

const std::string FIFO_PATH = "./fifo";

服务端 server.cc------负责创建管道,以读方式打开,阻塞读取。

关于系统调用:mkfifo 就是 shell 命令 mkfifo 的底层实现,原型在 <sys/types.h><sys/stat.h> 里------

c 复制代码
int mkfifo(const char *pathname, mode_t mode);
// 成功返回 0,失败返回 -1 并设置 errno

至于删除管道文件,Linux 里没有叫 rm 的系统调用。删文件用的是 unlinkman 2 unlink,头文件 <unistd.h>)------它在当前目录里去掉文件名和 inode 的映射关系,同时把 inode 的引用计数减一。引用计数归零时文件才真正从磁盘上清除。rm 命令和 unlink 系统调用做的事本质一样。

cpp 复制代码
#include <iostream>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstring>
#include "common.hpp"

int main() {
    // 1. 创建命名管道
    //    如果不调 umask(0),创建的管道权限会是 664 而不是 666。
    //    因为系统默认 umask(通常 0002)会屏蔽掉 "other" 的写权限位。
    //    umask(0) 把掩码清 0,mkfifo 才能原样使用 mode 参数。
    //
    //    你可以自己验证:先注释掉 umask(0),ls -l 看到的是 prw-rw-r--(664);
    //    再加上 umask(0),重新编译运行,ls -l 看到 prw-rw-rw-(666)。
    umask(0);
    if (mkfifo(FIFO_PATH.c_str(), 0666) < 0) {
        perror("mkfifo");
        return 1;
    }
    std::cout << "server: pipe created\n";
    // 如果你想让肉眼看到管道文件确实被创建出来了,可以在这里 sleep 几秒:
    // sleep(5);
    // 然后在另一个终端 ls -l,你会看到 prw-rw-rw- 1 ... ./fifo
    // 等 server 退出后 unlink 删掉,文件就消失了。

    // 2. 以只读方式打开管道文件
    //    注意:命名管道用 open() 打开,只返回一个 fd(读或写,你选)。
    //    这和 pipe() 不一样------pipe() 一次返回两个 fd(读 + 写)。
    //    头文件:open 需要 <fcntl.h>,read/write/close 需要 <unistd.h>
    int rfd = open(FIFO_PATH.c_str(), O_RDONLY);
    if (rfd < 0) {
        perror("open for read");
        return 2;
    }
    std::cout << "server: pipe opened for reading\n";

    // 3. 循环读
    char buffer[1024];
    while (true) {
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0) {
            buffer[n] = '\0';
            std::cout << "client says: " << buffer << std::endl;
        }
        else if (n == 0) {
            // 写端关闭,读到文件结尾
            std::cout << "server: client closed write end, exiting\n";
            break;
        }
        else {
            perror("read");
            break;
        }
    }

    // 4. 关闭并删除管道文件
    close(rfd);
    unlink(FIFO_PATH.c_str());
    return 0;
}

客户端 client.cc------不创建不删除,打开管道文件,发送键盘输入:

cpp 复制代码
#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include "common.hpp"

int main() {
    // 1. 以只写方式打开(管道已被 server 创建好了)
    int wfd = open(FIFO_PATH.c_str(), O_WRONLY);
    if (wfd < 0) {
        perror("open for write");
        return 1;
    }

    // 2. 从键盘读,写入管道
    std::string line;
    while (true) {
        std::cout << "> ";
        std::getline(std::cin, line);
        write(wfd, line.c_str(), line.size());
        // 注意:不要写 '\0'。管道是字节流,不是 C 字符串。
        // '\0' 是 C 语言在内存里的字符串终结约定,和管道传输无关。
        // 接收方读到了数据自己加 '\0',发送方只发有效字节。
    }

    close(wfd);
    return 0;
}

Makefile------一次编译出两个可执行程序:

makefile 复制代码
all: server client

server: server.cc common.hpp
	g++ -o server server.cc -std=c++11

client: client.cc common.hpp
	g++ -o client client.cc -std=c++11

clean:
	rm -f server client fifo

跑起来:

bash 复制代码
# 终端1:先启动 server(创建管道,阻塞等待读)
$ ./server
server: pipe created
server: pipe opened for reading

# 终端2:启动 client,输入消息
$ ./client
> 你好
> 吃了吗
> bye

# 终端1 同步显示:
client says: 你好
client says: 吃了吗
client says: bye

# Ctrl+C 关掉 client → server 的 read 返回 0 → server 退出
server: client closed write end, exiting

ps ax 能看到 server 和 client 是两个完全独立的进程,父进程各不相同。没有 fork 关系,没有血缘------但通信照常。


所以到底发生了什么?

三件事。

第一fork 拷贝的是整个文件描述符表,不只是你刚创建的那两个 fd。每多创建一个子进程,它就会多继承一堆历史上其他管道的写端。这些多余的 fd 让管道引用计数降不到零,子进程的 read 永远阻塞。

第二 ,修法很简单:每个子进程在 fork 之后,遍历父进程传下来的 channels(写时拷贝的副本),把历史上所有管道的写端全 close 掉。干净了,引用计数控制权回到父进程手里,关一个收一个,畅通无阻。

第三 ,命名管道的原理和匿名管道一模一样------内核缓冲区、先看到同一份资源。唯一不同的是:它靠文件路径让不相干的进程找到对方,而不是靠 fork 继承。mkfifo 创建的就是一个披着文件名的管道,数据不落盘,留在内存里当队列用。