进程间通信(IPC)是 Linux 系统编程的核心课题之一。本文结合经典原理与完整可运行代码,深度剖析匿名管道、命名管道、System V 共享内存、消息队列、信号量的接口、典型案例及易错点。全文约 1.7 万字,附经典代码解析与对比表格,并特别增加了核心系统调用解释,帮助读者从用户态到底层彻底搞懂 IPC。所有代码均可在 Linux 下编译运行,代码中包含了详细的中文注释,方便理解和复用。

前置知识:熟悉 C/C++ 基本语法、Linux 基础命令、进程概念(fork、exec、waitpid)。如果对虚拟内存和文件描述符不太熟悉,建议先阅读相关章节。

文章目录


📌 一、进程间通信概述

1.1 为什么需要 IPC?

目的 说明
数据传输 一个进程将数据发给另一个进程
资源共享 多个进程共享同一块内存或资源
通知事件 进程发生某种事件(如终止)时通知其他进程
进程控制 控制进程(如 debugger)拦截目标进程的所有陷入和异常,获取状态变化

1.2 IPC 发展脉络

  • 管道(匿名管道、命名管道)—— 最古老,所有 Unix 系统都支持
  • System V IPC(共享内存、消息队列、信号量)—— 功能强大,生命周期随内核
  • POSIX IPC(消息队列、共享内存、信号量、互斥锁、条件变量等)—— 更现代,接口更简洁,线程安全
  • 本文聚焦管道System V IPC,帮助读者理解经典 IPC 的本质。POSIX IPC 会在后续文章中详解。

1.3 什么是系统调用?

在深入具体 IPC 之前,我们先明确系统调用这个概念。
操作系统内核负责管理硬件、进程、内存等资源,用户程序运行在受限制的“用户态”,不能直接访问内核资源。当程序需要内核服务(如读写文件、创建进程、申请共享内存)时,必须通过系统调用(System Call)切换到“内核态”,由内核完成操作后返回结果。

例如,我们熟悉的 read()write()fork() 都是系统调用。IPC 相关函数如 pipe()shmget()semop() 等,封装层虽在 C 库中,但底层都会触发一个对应的系统调用,陷入内核执行。
了解每个 IPC 机制涉及的系统调用,有助于理解其性能开销、原子性及资源管理方式。


🧵 二、匿名管道(pipe)—— 亲缘进程的“数据河流”

2.1 核心原理与系统调用

系统调用:pipe()
#include <unistd.h>
int pipe(int pipefd[2]);
  • 作用:创建一个管道,内核分配一块环形缓冲区,并返回两个文件描述符:pipefd[0] 为读端,pipefd[1] 为写端。
  • 内核行为:调用 pipe() 时,进程陷入内核,内核在进程的打开文件表中分配两个文件描述符,指向同一个管道对象。该管道对象在内核中维护一个缓冲区、两个等待队列(读等待、写等待)和引用计数。
  • 返回值:成功返回 0,失败返回 -1 并设置 errno

与管道相关的其他系统调用

  • read() / write():对管道的读写实际上是对文件描述符的操作,最终也通过系统调用在内核中搬运数据。

  • close():关闭管道的某一端,减少引用计数;当所有写端都关闭时,读端 read 返回 0;当所有读端都关闭时,写端触发 SIGPIPE

  • 半双工:数据只能单向流动。如需双向通信,需创建两个管道。

  • 生命周期:随进程 —— 所有打开管道的进程退出后,管道自动释放。

  • 原子性:单次写入 ≤ PIPE_BUF(Linux 下 4096 字节)时,write 是原子的,多进程同时写不会交错。

下面的示意图展示了 shell 命令行 who | wc 中管道的工作方式:who 进程的标准输出连接到管道写端,wc 进程的标准输入连接到管道读端,数据在内核中流动。

在这里插入图片描述

2.2 基础示例:父子进程单向通信

#include <unistd.h>      // pipe, fork, read, write, close
#include <iostream>      // std::cout, std::cerr
#include <cstring>       // strlen
#include <sys/wait.h>    // wait

int main() {
    int fd[2];           // fd[0]读端, fd[1]写端

    // 1. 创建匿名管道
    if (pipe(fd) == -1) {
        std::cerr << "pipe error\n";
        return 1;
    }

    // 2. 创建子进程
    pid_t pid = fork();
    if (pid == -1) {
        std::cerr << "fork error\n";
        return 1;
    }

    if (pid == 0) {                     // 子进程:负责读数据
        // 【重要】子进程不需要写端,立即关闭,否则会影响管道检测
        close(fd[1]);                   // 关闭写端

        char buf[128] = {0};
        // 从管道读端读取数据,阻塞直到有数据或写端全部关闭
        ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            std::cout << "child read: " << buf << std::endl;
        } else if (n == 0) {
            std::cout << "child: write end closed\n";
        } else {
            std::cerr << "read error\n";
        }
        close(fd[0]);                   // 关闭读端
        exit(EXIT_SUCCESS);
    } else {                           // 父进程:负责写数据
        // 【重要】父进程不需要读端,立即关闭
        close(fd[0]);                   // 关闭读端

        const char *msg = "hello from parent";
        if (write(fd[1], msg, strlen(msg)) == -1) {
            std::cerr << "write error\n";
        }
        close(fd[1]);                   // 写完关闭写端,子进程read会返回0

        wait(nullptr);                  // 等待子进程结束,防止僵尸进程
    }
    return 0;
}

⚠️ 易错点1:fork 之后,父子进程各自必须关闭不需要的一端,否则管道无法正确检测到对方关闭(例如写端一直不关,读端 read 会永远阻塞)。

下图展示了 pipe() 创建管道后,父进程和子进程各自拥有读端和写端的文件描述符(fork 复制了描述符表):

在这里插入图片描述

2.3 管道读写规则(重点)

场景 行为
读端在读,写端关闭 read 返回 0(表示读到文件末尾)
读端关闭,写端继续写 内核发送 SIGPIPE 信号,导致写进程默认终止。可捕获或忽略信号,write 返回 -1 并设置 errno=EPIPE
管道空,读端以阻塞方式打开 read 阻塞,直到有数据或所有写端关闭
管道空,读端以非阻塞方式打开 (O_NONBLOCK) read 返回 -1,errno 置为 EAGAIN
管道满,写端以阻塞方式打开 write 阻塞,直到有空间
管道满,写端以非阻塞方式打开 write 返回 -1,errno 置为 EAGAIN

为了在双向通信中正确关闭不需要的文件描述符,可以对照下面这张简化图理解:

在这里插入图片描述

2.4 高级应用:进程池

下面实现一个基于管道的进程池:父进程创建 N 个子进程,每个子进程通过管道接收任务码,执行对应的回调函数。代码由 Task.hppProcessPool.hppMain.cc 组成,可完整编译运行。

🔍 代码结构梳理
文件 作用
Task.hpp 定义任务函数及 TaskManager,管理任务码和分发执行
ProcessPool.hpp 定义 Channel(封装写端 fd 和子进程 pid)、ChannelManager(轮询选择子进程)、ProcessPool(整体控制:创建进程、派发任务、回收)
Main.cc 创建进程池,循环派发任务,最后停止回收
🧩 关键类解析

① Task.hpp:任务管理

#pragma once
#include <vector>
#include <functional>
#include <cstdlib>
#include <ctime>
#include <iostream>

// 任务类型:返回void的无参函数,可以是普通函数、lambda、std::function
using task_t = std::function<void()>;

// 示例任务函数
void PrintLog() { std::cout << "Logging...\n"; }
void Download() { std::cout << "Downloading...\n"; }
void Upload()   { std::cout << "Uploading...\n"; }

// 任务管理器:负责注册任务、随机选择任务码、执行任务
class TaskManager {
public:
    TaskManager() {
        srand(time(nullptr));   // 随机种子,用于Code()产生随机索引
    }
    // 注册一个任务,添加到任务列表中
    void Register(task_t t) {
        _tasks.push_back(t);
    }
    // 随机返回一个任务索引(0 到 tasks.size()-1)
    int Code() {
        return rand() % _tasks.size();
    }
    // 根据任务码执行对应的任务
    void Execute(int code) {
        if (code >= 0 && code < (int)_tasks.size())
            _tasks[code]();     // 调用函数
    }
private:
    std::vector<task_t> _tasks; // 存储任务对象
};

② ProcessPool.hpp:进程池核心

#pragma once
#include <unistd.h>
#include <sys/wait.h>
#include <vector>
#include <cstdlib>
#include <cerrno>
#include <iostream>
#include "Task.hpp"

// 通道类:封装一个子进程的管道写端和子进程PID
class Channel {
public:
    // 构造:保存写端文件描述符和子进程ID
    Channel(int fd, pid_t id) : _wfd(fd), _subid(id) {}

    // 向子进程发送一个整数任务码
    void Send(int code) {
        // write 可能会被信号中断,这里简单处理,生产环境可重试
        if (write(_wfd, &code, sizeof(code)) == -1)
            std::cerr << "Channel::Send error\n";
    }
    // 关闭写端(触发子进程 read 返回 0)
    void Close() {
        close(_wfd);
    }
    // 等待子进程退出并回收资源
    void Wait() {
        waitpid(_subid, nullptr, 0);
    }
private:
    int _wfd;       // 指向子进程的管道写端(父进程持有)
    pid_t _subid;   // 子进程PID
};

// 通道管理器:维护所有子进程的Channel,并提供轮询选择
class ChannelManager {
public:
    // 添加一个子进程通道
    void Insert(int fd, pid_t id) {
        _channels.emplace_back(fd, id);
    }
    // 轮询选择一个通道(负载均衡)
    Channel &Select() {
        auto &c = _channels[_next];
        _next = (_next + 1) % _channels.size(); // 循环递增
        return c;
    }
    // 关闭所有通道的写端(通知所有子进程退出)
    void StopSubProcess() {
        for (auto &ch : _channels) ch.Close();
    }
    // 等待所有子进程结束
    void WaitSubProcess() {
        for (auto &ch : _channels) ch.Wait();
    }
private:
    std::vector<Channel> _channels;
    size_t _next = 0;   // 下一次选择的索引
};

// 进程池主类
class ProcessPool {
public:
    ProcessPool(int num, TaskManager &tm) : _process_num(num), _tm(tm) {}

    // 启动进程池:创建子进程和管道,初始化通道
    bool Start() {
        // 记录所有历史写端,供后续子进程关闭(关键!避免子进程持有额外写端导致无法检测EOF)
        std::vector<int> previous_wfds;

        for (int i = 0; i < _process_num; i++) {
            int pipefd[2];
            // 1. 创建管道
            if (pipe(pipefd) == -1) {
                std::cerr << "pipe error\n";
                return false;
            }
            // 2. 创建子进程
            pid_t subid = fork();
            if (subid == -1) {
                std::cerr << "fork error\n";
                return false;
            }
            if (subid == 0) {
                // ---------- 子进程执行代码 ----------
                // 【重点】子进程需要关闭所有历史管道的写端(这些是父进程为之前子进程保留的)
                // 如果不关闭,当前子进程的读端将无法检测到自己的写端关闭(因为还有其他进程持有写端)
                for (int wfd : previous_wfds) {
                    close(wfd);
                }
                // 关闭当前管道的写端,只保留读端
                close(pipefd[1]);
                // 进入任务接收循环
                Work(pipefd[0]);
                close(pipefd[0]);
                exit(EXIT_SUCCESS);
            } else {
                // ---------- 父进程执行代码 ----------
                // 保存当前写端,供未来子进程关闭历史写端
                previous_wfds.push_back(pipefd[1]);
                // 父进程关闭读端,只保留写端
                close(pipefd[0]);
                // 将写端和子进程ID加入通道管理器
                _cm.Insert(pipefd[1], subid);
            }
        }
        return true;
    }

    // 派发一个任务:随机选择任务码,轮询选择一个子进程,发送任务码
    void Run() {
        int taskcode = _tm.Code();      // 获取随机任务码
        auto &c = _cm.Select();         // 轮询选择一个子进程通道
        c.Send(taskcode);               // 通过管道发送任务码
    }

    // 停止进程池:关闭所有写端,等待子进程退出
    void Stop() {
        _cm.StopSubProcess();   // 关闭所有写端 -> 子进程 read 返回 0,退出循环
        _cm.WaitSubProcess();   // 回收所有子进程
    }

private:
    // 子进程的工作循环:不断读取任务码并执行
    void Work(int rfd) {
        while (true) {
            int code = 0;
            ssize_t n = read(rfd, &code, sizeof(code));
            if (n == sizeof(code)) {
                _tm.Execute(code);      // 执行任务
            } else if (n == 0) {
                // 写端关闭,正常退出
                break;
            } else if (n == -1) {
                // 被信号中断则重试,否则退出
                if (errno == EINTR) continue;
                std::cerr << "read error\n";
                break;
            }
        }
    }

    int _process_num;      // 子进程数量
    TaskManager &_tm;      // 任务管理器引用
    ChannelManager _cm;    // 通道管理器
};

💡 设计亮点与易错说明

  • 为什么子进程必须关闭历史写端?
    父进程每创建一个子进程,都会保留该子进程的管道写端(放入 previous_wfds)。当创建第二个及以后子进程时,这些写端 fd 会被复制到子进程中。如果子进程不关闭这些历史写端,那么即使父进程后来关闭了当前子进程自己的写端,管道仍因为其他写端(历史)的存在而不会 read 返回 0,导致子进程无法正常退出。上述代码通过子进程遍历 previous_wfds 并关闭它们,完美解决了这个问题。
  • Work() 函数增加了对 EINTR 的处理,避免因信号中断而错误退出。
  • 轮询负载均衡简单有效,避免了部分子进程过载。

③ Main.cc

#include "ProcessPool.hpp"

int main() {
    TaskManager tm;
    // 注册三个任务函数
    tm.Register(PrintLog);
    tm.Register(Download);
    tm.Register(Upload);

    // 创建4个子进程的进程池
    ProcessPool pool(4, tm);
    if (!pool.Start()) {
        std::cerr << "ProcessPool start failed\n";
        return 1;
    }

    // 模拟派发 10 个任务
    for (int i = 0; i < 10; i++) {
        pool.Run();                 // 派发一个任务
        usleep(100000);            // 休眠 0.1 秒,避免过快的派发
    }

    pool.Stop();                    // 停止并回收子进程
    return 0;
}

编译g++ -std=c++11 Main.cc -o pool && ./pool


📂 三、命名管道(FIFO)—— 无亲缘进程通信

3.1 与匿名管道的区别

对比项 匿名管道 (pipe) 命名管道 (FIFO)
适用范围 仅亲缘进程(父子、兄弟) 任意两个进程
文件系统可见 有,磁盘上的一个特殊文件
创建方式 pipe() mkfifo 命令 / mkfifo() 函数
打开方式 无需 open,直接使用 fd open() 像普通文件一样打开
读写阻塞规则 同左 同左

命名管道在内核缓冲区、数据流以及阻塞特性上均与匿名管道相同,唯一的区别在于它有一个文件系统中的入口,可以被任意进程打开。下图示意了命名管道的通信模型:

在这里插入图片描述

3.2 核心系统调用

mkfifo()
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
  • 作用:在文件系统中创建一个 FIFO 特殊文件(命名管道)。这个文件只是入口,真正的缓冲区在内核中。
  • 内核行为:调用 mkfifo 后,内核在指定路径创建一个索引节点(inode),标记为 FIFO 类型,同时建立对应的内核管道对象(结构与匿名管道相似)。用户通过 open 这个路径来获取读写文件描述符。
  • 关键细节:FIFO 文件大小始终为 0,数据不写入磁盘,只是内核缓冲区的一个引用点。
open()(作用于 FIFO)
  • 对 FIFO 执行 open 时,内核会检查是否有其他进程已经以互补模式打开(读对写)。默认阻塞直到配对成立。
  • 若设置了 O_NONBLOCK,则读端立即成功,写端若无读端则返回 -1(ENXIO)。
read() / write()
  • 与匿名管道完全一致,数据在内存中传输,不经过磁盘。

3.3 安全打开命名管道(避免死锁)

// 安全的写端打开函数:如果读端尚未打开,则等待(非阻塞轮询)
int open_fifo_safe(const char *path) {
    int fd;
    do {
        // 使用 O_NONBLOCK 避免因读端未打开而永久阻塞
        fd = open(path, O_WRONLY | O_NONBLOCK);
        if (fd == -1 && errno == ENXIO) {
            usleep(100000); // 等待读端打开,100ms
            continue;
        } else break;
    } while (1);
    // 取消非阻塞标志,之后的读写按阻塞方式(更符合预期)
    int flags = fcntl(fd, F_GETFL);
    fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
    return fd;
}

3.4 完整聊天示例:Server / Client

serverPipe.c(读端)

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>      // open
#include <unistd.h>     // read, write, close, unlink
#include <sys/stat.h>   // mkfifo, umask
#include <errno.h>

int main() {
    const char *fifo_name = "./chatfifo";
    umask(0);   // 清除umask,确保创建的文件权限为0666

    // 创建命名管道,如果已存在则不报错
    if (mkfifo(fifo_name, 0666) == -1 && errno != EEXIST) {
        perror("mkfifo");
        exit(1);
    }

    printf("Server: waiting for client...\n");
    // 以只读方式打开管道(阻塞直到有写端打开)
    int fd = open(fifo_name, O_RDONLY);
    if (fd == -1) {
        perror("open");
        exit(1);
    }
    printf("Server: client connected, reading...\n");

    char buf[256];
    while (1) {
        ssize_t n = read(fd, buf, sizeof(buf) - 1);
        if (n > 0) {
            buf[n] = '\0';
            printf("Received: %s", buf);
            if (buf[0] == 'q') break;   // 收到 quit 命令退出
        } else if (n == 0) {
            break;  // 写端关闭,退出循环
        } else {
            if (errno == EINTR) continue;   // 被信号中断,重试
            perror("read");
            break;
        }
    }
    close(fd);
    unlink(fifo_name);   // 删除管道文件
    return 0;
}

clientPipe.c(写端,使用安全打开函数)

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

// 安全打开写端函数(见上文)
int open_fifo_safe(const char *path) {
    int fd;
    do {
        fd = open(path, O_WRONLY | O_NONBLOCK);
        if (fd == -1 && errno == ENXIO) {
            usleep(100000);
            continue;
        } else break;
    } while (1);
    // 恢复阻塞模式
    int flags = fcntl(fd, F_GETFL);
    fcntl(fd, F_SETFL, flags & ~O_NONBLOCK);
    return fd;
}

int main() {
    const char *fifo_name = "./chatfifo";
    int fd = open_fifo_safe(fifo_name);
    if (fd == -1) {
        perror("open");
        exit(1);
    }

    char buf[256];
    while (1) {
        printf("Enter message (q to quit): ");
        fflush(stdout);
        if (fgets(buf, sizeof(buf), stdin) == NULL) break;
        // 写入管道,注意包含换行符
        if (write(fd, buf, strlen(buf)) == -1) {
            perror("write");
            break;
        }
        if (buf[0] == 'q') break;
    }
    close(fd);
    return 0;
}

编译gcc serverPipe.c -o server && gcc clientPipe.c -o client,先运行 server,再运行 client。


🚀 四、System V 共享内存 —— 最快的 IPC

4.1 原理图解

共享内存是内核将一块物理内存映射到多个进程的虚拟地址空间中。进程直接读写该内存,不经过内核拷贝,因此速度是所有 IPC 中最快的。

下图展示了两个进程通过页表映射到同一块物理内存区域,从而实现零拷贝数据共享:

在这里插入图片描述

4.2 核心系统调用

shmget() —— 创建或获取共享内存段
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);
  • 作用:向内核申请一段共享内存。如果 key 不存在且标志包含 IPC_CREAT,则新建一个大小为 size(会向上圆整为页大小)的共享内存段;否则获取已有的。
  • 内核行为:分配 struct shmid_kernel 结构,挂入全局 shm_ids 数组中,返回用户态的标识符 shmid。内核并不立即映射到进程地址空间。
  • 注意size 建议为 4096 的整数倍(页对齐)。
shmat() —— 挂接共享内存
void *shmat(int shmid, const void *shmaddr, int shmflg);
  • 作用:将共享内存段映射到当前进程的虚拟地址空间,返回映射后的起始地址。
  • 内核行为:在进程的页表中建立虚拟页到共享物理页的映射。此后进程对这段内存的读写如同访问普通内存,不再需要任何系统调用
  • 参数shmaddr 一般传 NULL 让内核选择地址;shmflg 可设为 SHM_RDONLY 只读挂接。
shmdt() —— 脱离共享内存
int shmdt(const void *shmaddr);
  • 作用:解除当前进程与共享内存的映射关系,但不删除内核中的对象
  • 内核行为:移除对应页表项,减少挂接计数。
shmctl() —— 控制操作
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • 常用命令:IPC_RMID(标记删除)、IPC_STAT(获取属性)、IPC_SET(修改权限)。
  • 重要IPC_RMID 只是标记删除,当所有进程都 shmdt 后,内核才真正释放该段内存。

4.3 基础例子与竞态条件问题

(原始未同步代码略)

⚠️ 严重缺陷:共享内存本身没有同步机制。多个进程同时读写会出现竞态条件,产生数据错乱。必须配合同步机制。

4.4 改进版:共享内存 + 命名管道同步

下面的设计实现了一个带访问控制的共享内存通信,用命名管道传递一个字节的“唤醒”信号,确保读写顺序。代码包含 Shm.hppFifo.hppserver.ccclient.cc

🔧 Shm.hpp —— 共享内存封装
#pragma once
#include <sys/ipc.h>      // ftok
#include <sys/shm.h>      // shmget, shmat, shmdt, shmctl
#include <cstring>
#include <string>
#include <stdexcept>

class Shm {
public:
    static constexpr int DEFAULT_SIZE = 4096;
    static constexpr const char* CREATOR = "creator";   // 创建者角色
    static constexpr const char* USER = "user";         // 使用者角色

    // 构造函数:根据用户类型创建或获取共享内存,并挂接到进程地址空间
    Shm(const std::string &pathname, int proj_id, const std::string &user_type, size_t size = DEFAULT_SIZE)
        : _size(size), _user_type(user_type) {
        // 生成唯一的key(需要pathname存在)
        _key = ftok(pathname.c_str(), proj_id);
        if (_key == -1) throw std::runtime_error("ftok failed");

        if (_user_type == CREATOR) {
            // 创建全新的共享内存:IPC_CREAT | IPC_EXCL 保证若已存在则失败
            _shmid = shmget(_key, _size, IPC_CREAT | IPC_EXCL | 0666);
            if (_shmid == -1) throw std::runtime_error("shmget create failed");
        } else if (_user_type == USER) {
            // 获取已存在的共享内存(不创建)
            _shmid = shmget(_key, _size, 0666);
            if (_shmid == -1) throw std::runtime_error("shmget get failed");
        } else {
            throw std::runtime_error("Invalid user type");
        }
        Attach();   // 挂接到当前进程
    }

    // 返回共享内存的虚拟地址指针
    void* VirtualAddr() { return _start_mem; }

    // 析构:脱离共享内存,如果是创建者则删除共享内存对象
    ~Shm() {
        shmdt(_start_mem);
        if (_user_type == CREATOR) {
            if (shmctl(_shmid, IPC_RMID, nullptr) == -1)
                perror("shmctl IPC_RMID");
        }
    }

private:
    // 将共享内存挂接到进程地址空间
    void Attach() {
        _start_mem = shmat(_shmid, nullptr, 0);
        if (_start_mem == (void*)-1) throw std::runtime_error("shmat failed");
    }

    int _shmid;              // 共享内存标识符
    key_t _key;              // 唯一key
    void* _start_mem;        // 挂接后的虚拟地址
    size_t _size;            // 共享内存大小
    std::string _user_type;  // 角色:"creator" 或 "user"
};
🔧 Fifo.hpp —— 命名管道封装(用于同步信号)
#pragma once
#include <sys/stat.h>   // mkfifo, umask
#include <fcntl.h>      // open, O_RDONLY, O_WRONLY
#include <unistd.h>     // read, write, close, unlink
#include <string>
#include <stdexcept>

class Fifo {
public:
    // 构造函数:创建FIFO文件(仅当create==true时创建)
    Fifo(const std::string &path, const std::string &name, bool create)
        : _fifo_path(path + "/" + name) {
        if (create) {
            umask(0);
            if (mkfifo(_fifo_path.c_str(), 0666) == -1 && errno != EEXIST)
                throw std::runtime_error("mkfifo failed");
        }
    }

    // 以只写方式打开管道(用于唤醒信号发送方)
    void OpenForWrite() {
        _fd = open(_fifo_path.c_str(), O_WRONLY);
        if (_fd == -1) throw std::runtime_error("open for write failed");
    }

    // 以只读方式打开管道(用于等待唤醒信号)
    void OpenForRead() {
        _fd = open(_fifo_path.c_str(), O_RDONLY);
        if (_fd == -1) throw std::runtime_error("open for read failed");
    }

    // 发送一个字节的唤醒信号
    void Wakeup() {
        char c = 'c';
        if (write(_fd, &c, 1) == -1)
            perror("Fifo::Wakeup");
    }

    // 等待唤醒信号(阻塞),返回true表示收到信号,false表示管道关闭
    bool Wait() {
        char c;
        ssize_t n = read(_fd, &c, 1);
        if (n == 1) return true;
        if (n == 0) return false;
        if (errno == EINTR) return Wait();   // 被信号中断,重试
        perror("Fifo::Wait");
        return false;
    }

    ~Fifo() {
        if (_fd >= 0) close(_fd);
    }

private:
    std::string _fifo_path;
    int _fd = -1;
};
🔧 server.cc(服务端:创建共享内存 + 等待唤醒)
#include "Shm.hpp"
#include "Fifo.hpp"
#include <cstdio>
#include <string>

const std::string PATH = "./";
const std::string FIFO_NAME = "syncfifo";
const char* PATHNAME = "./shmfile";  // ftok 需要存在的文件

int main() {
    try {
        // 确保 ftok 使用的文件存在(临时创建)
        FILE* f = fopen(PATHNAME, "w"); 
        if (f) fclose(f);

        // 1. 创建共享内存(作为创建者)
        Shm shm(PATHNAME, 0x58, Shm::CREATOR);
        // 2. 创建命名管道(作为创建者)
        Fifo fifo(PATH, FIFO_NAME, true);
        // 3. 以只读方式打开管道,等待客户端唤醒信号
        fifo.OpenForRead();

        char* mem = static_cast<char*>(shm.VirtualAddr());
        // 循环等待唤醒信号,每次收到信号就打印共享内存中的内容
        while (fifo.Wait()) {
            printf("server received: %s\n", mem);
        }
    } catch (const std::exception& e) {
        fprintf(stderr, "Server error: %s\n", e.what());
    }
    // 删除管道文件(由服务端负责)
    unlink((PATH + FIFO_NAME).c_str());
    return 0;
}
🔧 client.cc(客户端:获取共享内存,写入数据,唤醒服务端)
#include "Shm.hpp"
#include "Fifo.hpp"
#include <unistd.h>
#include <cstdio>

const std::string PATH = "./";
const std::string FIFO_NAME = "syncfifo";
const char* PATHNAME = "./shmfile";

int main() {
    try {
        // 1. 打开已存在的命名管道(非创建者)
        Fifo fifo(PATH, FIFO_NAME, false);
        fifo.OpenForWrite();     // 以写方式打开,用于发送唤醒信号

        // 2. 获取已存在的共享内存(使用者)
        Shm shm(PATHNAME, 0x58, Shm::USER);
        char* mem = static_cast<char*>(shm.VirtualAddr());

        // 3. 模拟写入数据,每次写入后唤醒服务端
        for (char c = 'A'; c <= 'Z'; c += 2) {
            sleep(1);                      // 间隔1秒
            mem[0] = c;                    // 写入字符
            mem[1] = '\0';                 // 字符串结尾
            printf("client wrote: %c\n", c);
            fifo.Wakeup();                 // 发送唤醒信号
        }
    } catch (const std::exception& e) {
        fprintf(stderr, "Client error: %s\n", e.what());
    }
    return 0;
}

编译运行

touch shmfile
g++ -std=c++11 server.cc -o server
g++ -std=c++11 client.cc -o client
./server &   # 后台运行
./client

4.5 补充:POSIX 共享内存(简要对比)

虽然本文主要聚焦 System V IPC,但作为对比,提一下 POSIX 共享内存(shm_open + mmap):

  • 使用文件描述符,更像普通文件操作。
  • 通过 ftruncate 设置大小。
  • 存在于 /dev/shm 目录,命名规则不同。
  • 生命周期同样随内核,需要手动 shm_unlink
  • 可移植性和现代接口更优,推荐在新项目中使用。

📨 五、System V 消息队列 —— 带类型的定向消息

5.1 核心系统调用

msgget() —— 创建/获取消息队列
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
  • 内核行为:分配 struct msg_queue 结构,包含消息链表、权限、队列大小等。返回用户态标识符 msqid
msgsnd() —— 发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
  • 作用:将用户空间的消息拷贝到内核的消息队列中。消息以链表形式组织,按类型存放。
  • 内核行为:如果队列已满(达到系统限制 msgmnb 或总消息数限制),则阻塞直到有空间,除非指定 IPC_NOWAIT
  • 消息格式:必须以 long mtype 开头,后跟数据。msgsz 是不包含 mtype 的数据长度。
msgrcv() —— 接收消息
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
  • 内核行为:按 msgtyp 规则查找第一个符合条件的消息,将数据拷贝到用户空间,然后从内核队列中移除该消息。
  • msgtyp:>0 接收该类型的第一条消息;0 接收队列中第一条消息(任意类型);<0 接收类型值 ≤ |msgtyp| 的最小类型消息。
msgctl() —— 控制操作
  • IPC_RMID 删除消息队列,立即唤醒所有阻塞的读者/写者,使其调用返回 -1(EIDRM)。

5.2 多客户端请求-响应示例(按类型区分)

下面是一个更经典的例子:多个客户端,每个客户端向服务端发送请求(type = 1),服务端处理后将响应发给对应的客户端(type = client_id + 100)。

common.h

#ifndef COMMON_H
#define COMMON_H

#define SERVER_TYPE 1           // 客户端请求消息的类型
#define BASE_REPLY_TYPE 100     // 响应消息的基准类型,实际使用 client_id + BASE_REPLY_TYPE

// 请求消息结构体(必须包含 long mtype 作为第一个字段)
struct req_msg {
    long mtype;          // 固定为 SERVER_TYPE
    int client_id;       // 客户端唯一标识(例如进程ID)
    char text[200];      // 请求正文
};

// 响应消息结构体
struct resp_msg {
    long mtype;          // client_id + BASE_REPLY_TYPE
    char reply[200];     // 响应正文
};

#endif

server.c(服务端:接收请求,按客户端ID返回响应)

#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <string.h>
#include <unistd.h>
#include "common.h"

int main() {
    // 生成唯一的 key,使用 ftok
    key_t key = ftok("./msgfile", 65);
    if (key == -1) {
        perror("ftok");
        exit(1);
    }

    // 创建消息队列(如果不存在则创建,权限0666)
    int msqid = msgget(key, IPC_CREAT | 0666);
    if (msqid == -1) {
        perror("msgget");
        exit(1);
    }

    struct req_msg req;
    struct resp_msg resp;

    while (1) {
        // 接收类型为 SERVER_TYPE 的请求消息(阻塞)
        if (msgrcv(msqid, &req, sizeof(req.text), SERVER_TYPE, 0) == -1) {
            perror("msgrcv");
            break;
        }
        printf("Server: request from client %d: %s\n", req.client_id, req.text);

        // 构造响应消息,消息类型为 client_id + BASE_REPLY_TYPE
        resp.mtype = req.client_id + BASE_REPLY_TYPE;
        snprintf(resp.reply, sizeof(resp.reply), "Hello client %d, your msg received", req.client_id);

        // 发送响应(不阻塞,队列通常不会满)
        if (msgsnd(msqid, &resp, strlen(resp.reply) + 1, 0) == -1) {
            perror("msgsnd");
            break;
        }
    }

    // 删除消息队列(正常退出时清理)
    msgctl(msqid, IPC_RMID, NULL);
    return 0;
}

client.c(客户端:发送请求,等待自己的响应)

#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <string.h>
#include <unistd.h>
#include "common.h"

int main() {
    key_t key = ftok("./msgfile", 65);
    if (key == -1) {
        perror("ftok");
        exit(1);
    }

    // 获取已存在的消息队列(不创建,因为服务端已创建)
    int msqid = msgget(key, 0666);
    if (msqid == -1) {
        perror("msgget");
        exit(1);
    }

    // 使用进程ID的一部分作为客户端标识(简单起见)
    int client_id = getpid() % 100;
    struct req_msg req;
    req.mtype = SERVER_TYPE;
    req.client_id = client_id;
    snprintf(req.text, sizeof(req.text), "Hello from client %d", client_id);

    // 发送请求
    if (msgsnd(msqid, &req, sizeof(req.text), 0) == -1) {
        perror("msgsnd");
        exit(1);
    }
    printf("Client %d: request sent\n", client_id);

    // 接收自己的响应(消息类型 = client_id + BASE_REPLY_TYPE)
    struct resp_msg resp;
    if (msgrcv(msqid, &resp, sizeof(resp.reply), client_id + BASE_REPLY_TYPE, 0) == -1) {
        perror("msgrcv");
        exit(1);
    }
    printf("Client %d received: %s\n", client_id, resp.reply);
    return 0;
}

编译gcc server.c -o server && gcc client.c -o client,先运行 server,再多次运行 client(可以开多个终端)。


🔐 六、System V 信号量 —— 进程同步/互斥利器

6.1 核心系统调用

semget() —— 创建/获取信号量集
int semget(key_t key, int nsems, int semflg);
  • 作用:创建包含 nsems 个信号量的集合。所有信号量初始值未定义,必须由 semctl 初始化。
  • 内核行为:分配 struct sem_array,其中包含 sem_base 数组(每个信号量的值和等待队列)。
semop() —— 执行 P/V 操作
int semop(int semid, struct sembuf *sops, size_t nsops);
  • 作用:对信号量集执行一组操作,这些操作是原子的(全部成功或全部阻塞)。
  • 内核行为:遍历 sops 数组,检查每个信号量的值是否满足操作(如 P 操作需值 ≥ |sem_op|)。若满足,则直接修改所有信号量的值;若不满足,则进程挂在该信号量的等待队列上,直到条件满足或收到信号。
  • sembuf 结构
    struct sembuf {
        unsigned short sem_num;  // 信号量索引
        short sem_op;            // 操作值(-1为P,+1为V)
        short sem_flg;           // SEM_UNDO, IPC_NOWAIT等
    };
    
  • SEM_UNDO:内核会记录进程对信号量的操作,当进程异常退出时自动反向执行(即释放其占用的信号量),防止因进程崩溃导致其他进程永久死锁。在生产者-消费者等关键场景中强烈推荐开启
semctl() —— 控制与初始化
int semctl(int semid, int semnum, int cmd, ...);
  • SETVAL:设置单个信号量的值(初始化关键步骤)。
  • GETVAL:获取当前值。
  • IPC_RMID:删除信号量集。

6.2 经典生产者-消费者模型(使用 SEM_UNDO,带详细注释)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <sys/shm.h>
#include <sys/wait.h>

// 联合体,用于 semctl 初始化
union semun {
    int val;
    struct semid_ds *buf;
    unsigned short *array;
};

// 初始化信号量集中指定索引的信号量值
void sem_init(int semid, int semnum, int val) {
    union semun arg;
    arg.val = val;
    if (semctl(semid, semnum, SETVAL, arg) == -1) {
        perror("semctl SETVAL");
        exit(1);
    }
}

int main() {
    // 生成唯一的key用于共享内存和信号量
    key_t shm_key = ftok("./semfile", 'A');
    key_t sem_key = ftok("./semfile", 'B');

    // 创建共享内存(存放一个整数,作为共享缓冲区)
    int shmid = shmget(shm_key, sizeof(int), IPC_CREAT | 0666);
    if (shmid == -1) {
        perror("shmget");
        exit(1);
    }
    int *p = (int*)shmat(shmid, NULL, 0);
    *p = 0;   // 初始化共享数据

    // 创建信号量集:包含2个信号量
    //  sem[0]: 空槽数量(初始为1,表示缓冲区有一个空位)
    //  sem[1]: 产品数量(初始为0)
    int semid = semget(sem_key, 2, IPC_CREAT | 0666);
    if (semid == -1) {
        perror("semget");
        exit(1);
    }
    sem_init(semid, 0, 1);  // empty = 1
    sem_init(semid, 1, 0);  // full  = 0

    pid_t pid = fork();
    if (pid == 0) { // 子进程:消费者
        struct sembuf wait_full  = {1, -1, SEM_UNDO};   // P(full) :等待产品
        struct sembuf signal_empty = {0, 1, SEM_UNDO};  // V(empty):释放空槽
        for (int i = 0; i < 5; i++) {
            semop(semid, &wait_full, 1);        // 等待有产品
            printf("Consumer got: %d\n", *p);   // 消费数据
            semop(semid, &signal_empty, 1);     // 空槽数+1
            sleep(1);
        }
        exit(0);
    } else {        // 父进程:生产者
        struct sembuf wait_empty = {0, -1, SEM_UNDO};  // P(empty):占用空槽
        struct sembuf signal_full = {1, 1, SEM_UNDO};  // V(full) :增加产品
        for (int i = 1; i <= 5; i++) {
            semop(semid, &wait_empty, 1);       // 等待有空槽
            *p = i;                             // 生产数据
            printf("Producer put: %d\n", i);
            semop(semid, &signal_full, 1);      // 产品数+1
        }
        wait(NULL);                             // 等待子进程结束
        // 清理资源
        shmdt(p);
        shmctl(shmid, IPC_RMID, NULL);
        semctl(semid, 0, IPC_RMID);
    }
    return 0;
}

编译gcc producer_consumer.c -o pc && ./pc


📊 七、IPC 方案横向对比(含 POSIX IPC)

IPC类型 System V/管道 POSIX 替代 通信范围 数据传输方式 同步机制 速度 生命周期 典型用途
匿名管道 pipe - 亲缘进程 字节流,无边界 读写阻塞 中等 随进程 父子进程简单通信
命名管道 mkfifo - 任意进程 字节流,无边界 读写阻塞 中等 文件系统节点持久 无亲缘进程字节流传输
消息队列 msgget mq_open 任意进程 带类型消息,有边界 收发阻塞 较慢 随内核 结构化消息定向收发
共享内存 shmget/shmat shm_open/mmap 任意进程 直接内存访问,无边界 ,需外部同步 最快 随内核 海量数据高速交换
信号量 semget/semop sem_open 任意进程 不传输数据 P/V 原子操作 - 随内核 多进程同步/互斥

补充说明

  • POSIX 消息队列支持消息优先级、异步通知,接口更现代。
  • POSIX 共享内存通过文件描述符操作,可配合 mmap 使用,更符合 Unix 哲学。
  • System V 信号量集操作复杂,POSIX 信号量(命名/无名)更简单,适合线程。

🧠 八、内核如何管理 IPC 资源

8.1 内核数据结构

所有 System V IPC 对象都被内核统一管理,例如共享内存的 shmid_kernel 结构:

struct shmid_kernel {
    struct kern_ipc_perm shm_perm;  // 权限、key、所有者
    struct file *shm_file;          // 共享内存的物理页面
    unsigned long shm_nattch;       // 挂接数
    unsigned long shm_segsz;        // 大小
    time_t shm_atim, shm_dtim, shm_ctim;
    pid_t shm_cprid, shm_lprid;
};

消息队列对应 struct msg_queue,信号量集对应 struct sem_array,均含有 kern_ipc_perm 用于权限管理。

8.2 常用管理命令

  • ipcs:查看共享内存 -m、消息队列 -q、信号量 -s
  • ipcrmipcrm -m <shmid> 删除共享内存。
  • 系统限制调整:/proc/sys/kernel/shmmax/proc/sys/kernel/msgmnb 等。

8.3 资源泄漏与清理

  • System V IPC 的生命周期随内核,进程退出不会自动销毁。必须手动调用 xxxctl(IPC_RMID)ipcrm
  • shmctl(IPC_RMID) 只是标记删除,当最后一个进程 shmdt 后才释放物理内存。
  • 信号量删除时若有进程阻塞在上面,它们将被唤醒并返回错误(EIDRM)。

🔧 九、调试技巧(实用工具)

  1. strace 跟踪系统调用

    strace -e trace=ipc,pipe,file ./your_program
    

    可以看到进程执行了哪些 IPC 相关的系统调用,参数和返回值,快速定位失败原因。

  2. ipcs / ipcrm 查看和清理残留资源

    ipcs -m   # 查看共享内存
    ipcrm -m <shmid>
    
  3. lsof 查看进程打开的文件描述符

    lsof -p <pid>
    

    可以看到进程打开的所有文件、管道(FIFO)、共享内存映射等。

  4. /proc 文件系统

    cat /proc/<pid>/maps   # 查看进程的内存映射(包括共享内存)
    ls -l /proc/<pid>/fd   # 查看打开的文件描述符
    
  5. valgrind --tool=helgrind 检测多进程竞态条件(共享内存场景)


❗ 十、常见易错点与注意事项

  1. 管道未关闭多余端read 可能永不返回 0,必须关闭自己不需要的一端。
  2. 共享内存无同步 → 必须配合信号量、管道、文件锁等。
  3. System V IPC 资源泄漏 → 手动删除,或注册 atexit 清理。
  4. 信号量初始值未设置 → 必须使用 semctl SETVAL 初始化。
  5. 命名管道打开死锁 → 使用 O_NONBLOCK 或严格约定打开顺序。
  6. 子进程回收 → 必须 waitpid 防止僵尸进程。
  7. 共享内存大小对齐 → 建议 4096 整数倍。
  8. 信号中断 → 系统调用返回 -1 且 errno == EINTR 时需重启调用。
  9. ftok 陷阱 → 路径必须存在且稳定,生产环境可硬编码 key。
  10. SEM_UNDO 重要性 → 进程异常退出时可自动释放信号量,防止死锁。

📝 十一、总结与思考

本文从匿名管道出发,深入到命名管道共享内存消息队列信号量,结合完整的可运行代码,并特别增加了每个阶段的核心系统调用解释,帮助读者理解用户态 API 背后的内核行为。通过进程池、共享内存同步、消息队列请求响应、生产者-消费者等实战案例,以及横向对比表格和易错点汇总,希望您能系统掌握 Linux 进程间通信。

思考题

  1. 如何改造进程池,支持动态增减子进程数量?
  2. 如果不使用命名管道,改用 System V 信号量如何完成共享内存的读写同步?试写出代码框架。
  3. 消息队列与命名管道相比,在哪些场景下更有优势?请举例说明。
  4. 通过 /proc 文件系统观察一个进程打开的文件描述符,能否找到管道或共享内存的痕迹?尝试解释。

最后送上一句:IPC 没有银弹,根据场景选择最合适的方案 —— 小数据用管道,大数据用共享内存,要同步用信号量,要结构化用消息队列。

如果本文对你有帮助,欢迎点赞、收藏、评论,你的支持是我创作的最大动力! 😊


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐