【Linux之旅】Linux 进程间通信(IPC)全解析:从管道到共享内存,吃透进程协作核心

前言

在 Linux 中,进程是资源分配的基本单位,彼此独立且拥有各自的地址空间 ------ 这意味着进程间无法直接访问对方的数据。但实际开发中,进程间协作无处不在:比如终端中who | wc -l的管道通信、服务器进程与客户端进程的数据交互、多进程共享配置文件等。这就需要进程间通信(IPC,Inter-Process Communication) 机制打破隔离,实现数据传输、资源共享和事件通知。

本文从原理、用法、实例到场景选型,细致拆解 Linux 主流 IPC 机制,重点聚焦管道(匿名 / 命名)和共享内存(最快 IPC),辅以消息队列、信号量的核心逻辑,帮你彻底掌握进程协作的底层逻辑。

请君浏览

    • 前言
    • [一、IPC 核心基础:为什么需要通信?有哪些方式?](#一、IPC 核心基础:为什么需要通信?有哪些方式?)
      • [1.1 进程间通信的核心目的](#1.1 进程间通信的核心目的)
      • [1.2 Linux IPC 的分类](#1.2 Linux IPC 的分类)
    • [二、管道通信:最经典的 IPC(匿名 + 命名)](#二、管道通信:最经典的 IPC(匿名 + 命名))
      • [2.1 管道的由来](#2.1 管道的由来)
      • [2.2匿名管道(pipe):亲缘进程的 "秘密通道"](#2.2匿名管道(pipe):亲缘进程的 “秘密通道”)
        • [2.2.1 核心原理](#2.2.1 核心原理)
        • [2.2.2 实例:父子进程间管道通信](#2.2.2 实例:父子进程间管道通信)
        • [2.2.3 一些小细节](#2.2.3 一些小细节)
        • [2.2.3 匿名管道的读写规则](#2.2.3 匿名管道的读写规则)
        • [2.2.4 特点与场景](#2.2.4 特点与场景)
      • [2.3 命名管道(FIFO):跨进程的 "公共管道"](#2.3 命名管道(FIFO):跨进程的 “公共管道”)
        • [2.3.1 命名管道(FIFO)的核心定义](#2.3.1 命名管道(FIFO)的核心定义)
        • [2.3.2 命名管道的核心特性](#2.3.2 命名管道的核心特性)
        • [2.3.3 命名管道的创建方式](#2.3.3 命名管道的创建方式)
        • [2.3.4 命名管道的使用代码示例](#2.3.4 命名管道的使用代码示例)
    • [三、共享内存:最快的 IPC 机制](#三、共享内存:最快的 IPC 机制)
      • [3.1 共享内存的原理](#3.1 共享内存的原理)
      • [3.2 共享内存的主要接口](#3.2 共享内存的主要接口)
        • [3.2.1 生成唯一 Key(ftok 函数)](#3.2.1 生成唯一 Key(ftok 函数))
        • [3.2.2 创建 / 获取共享内存(shmget)](#3.2.2 创建 / 获取共享内存(shmget))
        • [3.2.3 关联共享内存到进程地址空间(shmat)](#3.2.3 关联共享内存到进程地址空间(shmat))
        • [3.2.4 读写共享内存(直接操作指针)](#3.2.4 读写共享内存(直接操作指针))
        • [3.2.5 解除共享内存关联(shmdt)](#3.2.5 解除共享内存关联(shmdt))
        • [3.2.6 删除共享内存(shmctl)](#3.2.6 删除共享内存(shmctl))
      • [3.3 使用共享内存完成通信](#3.3 使用共享内存完成通信)
      • [3.4 一些小问题](#3.4 一些小问题)
        • [3.4.1 共享内存的生命周期](#3.4.1 共享内存的生命周期)
        • [3.4.2 共享内存的大小](#3.4.2 共享内存的大小)
    • [四、其他 IPC 机制:消息队列与信号量](#四、其他 IPC 机制:消息队列与信号量)
      • [4.1 System V 消息队列](#4.1 System V 消息队列)
      • [4.2 System V 信号量](#4.2 System V 信号量)
      • [4.3 与 System V 共享内存的相似之处](#4.3 与 System V 共享内存的相似之处)
      • [4.4 总结](#4.4 总结)
    • 五、总结
    • 尾声

一、IPC 核心基础:为什么需要通信?有哪些方式?

进程间通信(Inter-Process Communication, IPC)是指运行在同一台计算机或不同计算机上的多个进程之间进行数据交换和通信的技术。由于每个进程都有自己的地址空间,它们无法之间访问彼此的数据,因此需要通过特定的机制实现通信。

进程因地址空间隔离 (虚拟内存机制下,每个进程拥有独立的虚拟地址空间,默认物理内存与数据互不可见)而具备独立性,要实现通信,本质是打破这种隔离 ;而让不同进程看到同一份受管控的资源,是最核心、最直接的一类实现思路。同时这份资源一定不会属于进行通信中的进程,所以这里的资源并不属于任何一个进程,它是由操作系统提供的一片"公共区域"。

举一个简单的例子:我们可以把进程类比成 "被关在独立玻璃房里的工作者",每个进程都是一间带独立门锁的玻璃房------ 有自己专属的 "工作空间(地址空间)",只能看到自己房里的所有东西(数据),且每个房间都有人脸识别,只有自己能够进入。这种 "隔离" 是为了安全,但也让进程之间成了 "看得见、够不着" 的邻居。那么位于不同房间中的工作者该如何交流呢?

误区:不是让某间房 "贡献出自己的物品" 给别人用------ 毕竟每个房间中的东西都是专属的,强行分享会乱套。

真正的解法是:找个 "公共区域"------ 比如走廊里的共享白板、茶水间的置物架。只有让所有进程都能够到达这个 "公共区域",才有了互相传递信息的基础,这就是进程间通信(IPC)的核心逻辑。

进程间通信(IPC),本质是让不同玻璃房的工作者,通过统一规则使用公共区域,完成信息交换的技术------ 不管这些 "玻璃房" 是在同一栋楼(同一台电脑),还是在不同城市(不同设备)。它不只是 "传个数据" 那么简单:小到手机里 "音乐 APP 和歌词插件同步进度",大到分布式系统里 "多个服务协作处理订单",都得靠 IPC 来让隔离的进程 "对齐动作"。

操作系统就是通信的 "场地管理员 + 规则制定者",进程自己打不开玻璃房的门,得靠 "大楼管理员(操作系统)" 帮忙:

  1. 它负责提供 "公共区域"(比如共享内存、管道缓冲区这些资源);
  2. 给进程发 "门禁卡"(系统调用)------ 只有刷这张卡,进程才能进入公共区域;

1.1 进程间通信的核心目的

  • 数据传输:进程 A 将数据发送给进程 B(如客户端向服务器传请求参数);
  • 资源共享:多个进程共享同一资源(如多进程读写同一配置文件);
  • 事件通知:进程 A 通知进程 B 发生特定事件(如子进程终止通知父进程回收资源);
  • 进程控制:进程 A 控制进程 B 的执行(如调试器拦截被调试进程的异常)。

1.2 Linux IPC 的分类

Linux 支持多种 IPC 机制,按发展阶段和特性可分为三类,核心差异集中在 "速度、适用场景、编程复杂度":

分类 具体机制 核心特点
传统 IPC 匿名管道、命名管道(FIFO) 基于文件系统,接口简单,适用于简单通信
System V IPC 共享内存、消息队列、信号量 基于内核对象,效率高,生命周期随内核
POSIX IPC 共享内存、信号量、互斥量、条件变量 跨平台兼容,接口更统一(本文重点讲 System V)

其中,管道(匿名 + 命名)共享内存是最常用的两种,也是本文的核心重点。(本文所讲的共享内存等都是System版本的)

当下System V IPC几乎被弃用,因此下面关于共享内存等最主要的在于其原理而不是代码实现。对于代码实现部分看看即可。至于POSIX IPC后面会有其他章节讲解。

二、管道通信:最经典的 IPC(匿名 + 命名)

2.1 管道的由来

管道(Pipe)的诞生,源于 Unix 系统对 "简单程序组合完成复杂任务" 的设计哲学,是为解决早期进程间通信的低效问题而被发明的核心 IPC(进程间通信)机制。

当时的进程间数据传递,主要依赖临时文件:

  1. 程序 A 把输出写入临时文件;
  2. 程序 B 从这个临时文件读取数据;
  3. 任务完成后还要手动删除临时文件。

这种方式的缺点很明显:

  • 效率极低:频繁的磁盘读写(临时文件要落地),远慢于内存操作;
  • 资源浪费:临时文件占用磁盘空间,还容易因忘记删除导致垃圾堆积;
  • 流程繁琐:协作逻辑被临时文件的创建、删除步骤割裂。

为解决上述痛点,1973 年左右,管道机制被正式引入 Unix V3 版本,核心设计思路是:

设计一个基于内核缓冲区的 "内存通道",让一个进程的输出直接成为另一个进程的输入,全程不经过磁盘。

这个设计完美契合 Unix 的核心哲学 ------"组合小程序" :比如 who | wc -l 这条命令,who 的输出不用写临时文件,而是通过管道直接传给 wc,一步完成 "统计当前登录用户信息 + 输出用户数量" 的任务。

管道是操作系统提供的内核级通信通道,用于在进程间传输字节流数据,主要分为两类:

  1. 匿名管道(Pipe)
    • 是内核维护的环形缓冲区 (并非进程直接创建的数据流),仅能用于有亲缘关系的进程(如父子、兄弟进程);
    • 半双工的:同一时间内,数据流只能单向传输(需两个管道才能实现双向通信);
    • 数据是字节流形式(无消息边界),写入的内容会被内核暂存,读取后会从缓冲区中移除。
  2. 命名管道(FIFO)
    • 文件系统中可见的特殊文件 (通过文件名标识),可用于无亲缘关系的进程
    • 本质仍是内核维护的缓冲区,文件本身不存储数据,仅作为进程连接管道的 "标识入口"。

什么是字节流?

在编程中,"流(Stream)" 可以理解为数据在数据源(文件、网络、内存等)和程序之间传输的 "管道"(这里的管道只是对流进行的比喻,与我们所讲的管道是两回事)。

字节流(Byte Stream)就是这个 "管道" 里,以字节(Byte) 为最小传输单位的数据流 ------1 个字节等于 8 个二进制位(0/1),是计算机中最基础的数据存储和传输单位。

字节流的核心特点:

  • 直接操作原始二进制数据,不关心数据的含义、编码格式(比如是文本还是图片);
  • 通用性极强,能处理任何类型的文件(图片、视频、音频、文本、可执行文件等);
  • 是所有流的基础,字符流、对象流等本质上都是基于字节流封装的。

使用字节流传输数据时,双方必须预先约定好读取 / 解析的格式规则,否则接收方拿到的只是一堆无意义的原始字节,根本无法正确还原出发送方想要传输的有效数据 ------ 这是字节流传输的核心原则。

管道是 Unix 最古老的 IPC 机制,核心思想是 "用内核缓冲区模拟文件流"------ 进程通过读写管道的 "两端"(读端fd[0]、写端fd[1])实现通信,完全遵循 "Linux 一切皆文件" 的设计哲学。

2.2匿名管道(pipe):亲缘进程的 "秘密通道"

匿名管道仅支持有共同祖先的进程 (如父子、兄弟进程),由pipe()系统调用创建,生命周期随进程。

2.2.1 核心原理
  1. 创建管道 :调用int pipe(int fd[2])fd[2]是输出型参数,pipe生成两个文件描述符:fd[0]是读端(从管道里 "取数据"),fd[1]是写端(往管道里 "放数据")。内核在中间维护一块环形缓冲区,管道的⽣命周期是随进程的;
  2. 共享管道 :父进程调用fork()创建子进程时,子进程会复制父进程的文件描述符表,因此父子进程共享同一个管道的读写端;
  3. 单向通信 :管道是半双工的(数据只能单向流动),需关闭无用的端(如父进程写、子进程读,则父关fd[0],子关fd[1])。

下面我们先通过一张图理解一下匿名管道是如何使父子进程进行通信的:

步骤 1:父进程创建管道

  • 父进程先调用 pipe() 系统调用,内核会创建一个匿名管道(内核里的内存缓冲区)
  • 同时,内核会给父进程的 "文件描述符表" 新增 2 个 fd
    • fd[0] = 3:对应管道的读端
    • fd[1] = 4:对应管道的写端
    • 图里左边的 fd 表中,0/1/2 是默认的终端(tty),3/4 是新的管道读写端,箭头连接着管道的两个口。

此时父进程同时握着管道的 "取件口" 和 "放件口",但管道是单向通信的,所以还需要后续步骤调整。

步骤 2:父进程 fork 子进程

  • 父进程调用 fork() 创建子进程:fork 的核心特性是子进程会完整复制父进程的文件描述符表
  • 所以子进程的 fd 表和父进程完全一样:也有 fd[0]=3(读端)fd[1]=4(写端),并且这两个 fd 对应的是**同一个管道。

此时的问题是:父子进程都同时握着管道的 "取件口" 和 "放件口"------ 如果两边都能 "放" 或 "取",管道的单向通信逻辑就乱了,所以必须 "关掉多余的把手"。

当父进程fork一个子进程时,子进程只会复制一份父进程的文件描述符表,不会再在内核中给对应的文件创建对应的struct file内核结构体,在struc tfile中,有一个引用计数的变量,每创建出一个子进程,该值就会加一。只有当引用计数为0,此时内核才会销毁对应文件的 struct file,回收它的内核资源。

步骤 3:父进程关读端,子进程关写端

  • 父进程关闭自己的 fd[0](读端):只保留 fd[1](写端)(变成 "只能往管道里放数据的角色");
  • 子进程关闭自己的 fd[1](写端):只保留 fd[0](读端)(变成 "只能从管道里取数据的角色")。

最终效果:管道变成了父进程→子进程的单向通信通道 ------ 父进程通过 fd[1] 往管道写数据,子进程通过 fd[0] 从管道读数据,数据只在内存的管道缓冲区里传输,全程不碰磁盘。

下面让我们用代码示例来演示一下上述的过程,同时来测试一下匿名管道能否让我们来进行进程间通信。

2.2.2 实例:父子进程间管道通信

下面是一段简单的父子进程通过管道进行通信的代码示例:

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

void ChildTask(int rfd)
{
    char buffer[1024];
    while(true)
    {
        // 初始化缓冲区,避免残留上一次的垃圾数据
        buffer[0] = 0;
        size_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if(n > 0)
        {
            // 手动补字符串终止符,让read的二进制流变成合法C字符串
            buffer[n] = '\0';
            std::cout << "Parent say: \n" << buffer << std::endl;
        }
        else if(n == 0)
        {
            // read返回0 → 管道写端已关闭,且无数据可读,子进程可以退出
            std::cout << "写端已经关闭,且管道没有数据可读,本进程退出" << std::endl;
            break;
        }   
        else 
        {
            // read返回-1 → 读操作出错(如管道异常),直接退出
            break;
        }
    }
}

void ParentTask(int wfd)
{
    char buffer[1024];
    int cnt = 0;
    while(true)
    {
        int len = snprintf(buffer, sizeof(buffer), "I am Parent, my pid is %d, cnt = %d\n", getpid(), cnt++); 
        write(wfd, buffer, len);

        if(cnt == 10)
            break;
    }

}

int main()
{
    // 1.创建管道
    int fd[2] = {0};
    int n = pipe(fd);
    if(n == -1)
    {
        std::cerr << "pipe create fasle" << std::endl;
        exit(1);
    }

    // 2.创建子进程
    pid_t id = fork();    
    if(id == 0)
    {
        // 3. 关闭不需要的写端fd[1],形成通信信道
        // c -> w
        close(fd[1]);
        ChildTask(fd[0]);

        close(fd[0]);
        exit(0);
    }

    // 3. 关闭不需要的读端fd[1],形成通信信道
    // p -> r
    close(fd[0]);
    ParentTask(fd[1]);
    close(fd[1]);

    //获取子进程的退出信息
    int status = 0;
    int ret = waitpid(id, &status, 0);
    if(ret > 0)
    {
        printf("exit code: %d, exit signal: %d\n", (status >> 8) & 0xFF, status & 0x7F);
    }

    return 0;
}

整个程序的执行是一个 "线性 + 并发" 的过程,核心时序如下:

步骤 1:主进程初始化 ------ 创建匿名管道

main 函数首先调用 pipe(fd) 创建管道:

  • 管道是内核维护的内存缓冲区,fd[0] 为读端、fd[1] 为写端;
  • 若管道创建失败(返回 -1),代码会立即关闭未初始化的文件描述符并退出,避免资源泄漏。

步骤 2:进程分叉 ------ 创建父子进程

调用 fork() 拆分出子进程,此时父子进程共享管道的读 / 写端:

  • 子进程(pid == 0):关闭不需要的写端 fd[1](仅保留读端),进入 ChildTask 执行读数据逻辑;
  • 父进程(pid > 0):关闭不需要的读端 fd[0](仅保留写端),进入 ParentTask 执行写数据逻辑;
  • 关键:父子进程必须关闭无用的管道端 ,否则管道无法检测 "写端关闭" 事件,子进程会一直阻塞在 read 上。

步骤 3:并发通信 ------ 父写子读

  • 父进程写数据:

    循环 10 次,用 snprintf格式化字符串(包含父进程 PID 和计数),仅写入有效字符串长度(而非整个缓冲区),保证每次写入≤PIPE_BUF(4096 字节),实现原子写入(数据不被打断);写完 10 条后关闭写端 fd[1]。

  • 子进程读数据:

    读取前初始化缓冲区、读取后手动补 \0(将二进制流转为合法 C 字符串);若 read 返回 0(写端关闭且无数据),则退出循环,关闭读端后正常退出。

步骤 4:资源回收 ------ 父进程等待子进程退出

父进程写完数据后,调用 waitpid(pid, &status, 0) 等待子进程退出:

  • 回收子进程资源,避免子进程变成 "僵尸进程"(占用 PID 和系统资源);
  • 解析子进程的退出码和终止信号,便于排查子进程异常退出原因。

步骤 5:程序收尾

子进程正常退出,父进程回收资源后打印日志,最终主进程退出,整个通信流程结束。

2.2.3 一些小细节

在上述的过程中提到了PIPE_BUF,它是一个常量,管道对于写入数据小于PIPE_BUF的数据写入时具有原子性

原子性(Atomicity)

指一个写入操作要么完整地把所有数据写入管道 ,要么完全不写入,中间不会被其他进程的写入操作打断。写入的内容是一个 "不可分割" 的整体,其他进程不会看到 "半完成" 的数据。

PIPE_BUF

这是系统内核定义的常量(Linux 默认 4096 字节),它代表了管道写入操作能保证原子性的最大数据量

举个例子:

  1. 写入数据 ≤ PIPE_BUF → 保证原子性:

    整个数据块会被一次性写入管道缓冲区,不会被其他进程的写入操作打断或拆分。

    • 进程 A 和进程 B 同时往同一个管道写数据,A 写 3000 字节(≤4096),B 写 2000 字节(≤4096)。
    • 管道里的数据只会是 A 的完整 3000 字节 + B 的完整 2000 字节 ,或者 B 的完整 2000 字节 + A 的完整 3000 字节,不会出现 A 和 B 的数据交错(比如 A 的前 2000 字节 + B 的 2000 字节 + A 的后 1000 字节)。
  2. 写入数据 > PIPE_BUF → 不保证原子性:

    写入操作会被内核拆分成多个小的写入请求,可能被其他进程的写入操作打断,导致多个进程的数据在管道里交错("粘包" 或 "拆包")。

    • 进程 A 写 5000 字节(>4096),进程 B 写 3000 字节(≤4096)。
    • 管道里可能出现 A 的前 4096 字节 → B 的 3000 字节 → A 的剩余 904 字节,A 的数据被 B 的数据打断,不是完整的一块。

PIPE_BUF 是为了解决多进程并发写入管道时的数据错乱问题。它保证了 "小数据块" 的完整性,让多进程写入的小数据不会互相干扰,这在日志收集、命令行管道等场景中非常重要。

除此之外还有对字符串手动补'\0',这是一种防御性编程。

为什么要手动补'\0'呢?

是为了让读取到的字节流符合 C 风格字符串的规范 ,避免后续处理时出现未定义行为。对于一些系统调用,例如read()来说,它是一个二进制安全的系统调用,它的作用是从文件描述符(如管道、文件)读取原始字节到缓冲区:

  • 它只负责复制字节,不会自动在数据末尾添加 \0
  • 返回值 n 表示实际读取的字节数,但不会对内容做任何字符串相关的处理。

如果读取的是文本数据(比如管道传输的字符串),read() 只会把字符字节复制到 buffer,不会主动添加终止符。

C 标准库中处理字符串的函数(如 strlen()strcpy()printf("%s")),都依赖 \0 来判断字符串的结束位置。如果 buffer 没有 \0 结尾:

  • strlen(buffer) 会一直向后扫描内存,直到遇到某个随机的 \0,导致计算出错误的长度;
  • printf("%s", buffer) 会输出缓冲区内容后,继续打印后续内存中的垃圾数据,直到遇到 \0,引发乱码或程序崩溃;
  • 严重时会触发内存越界访问,导致程序崩溃或安全漏洞。

当我们直接使用字符串字面量 / 初始化合法的字符数组或者使用「字符串专用库函数」写入数据(函数自动补 \0)时不需要手动补 \0

看起来很多,但实际上是否补 \0只需要简单记:

  • 只要数据来源是「二进制流(read/memcpy)」,哪怕后续用字符串库函数,必须补 \0;只要数据是「合法 C 字符串(strcpy / 字面量)」,无需补

手动补 \0 是「防御性编程」,只需要 1 行代码,就能覆盖所有边界情况,避免未定义行为;因此只要不是 "标准库字符串函数直接处理" 的场景,涉及字符缓冲区且要当字符串用,手动补 \0 总没错。

2.2.3 匿名管道的读写规则

可能有人会有疑问,为什么要 "关掉多余的 fd",不用不就好了吗?上面讲解代码时其实已经提到了原因,这是因为管道的读写行为依赖 "读写端的引用计数":

  • 如果子进程不关闭写端(fd[1]),父进程写完数据关闭写端后,子进程的写端还开着,此时子进程读数据时,内核会认为 "还有进程能写数据",读操作会一直阻塞等待;
  • 同理,父进程不关闭读端,子进程读完数据后,父进程的读端还开着,写操作也可能异常。

关闭多余的 fd,本质是让管道的 "读写端引用计数归位",保证单向通信的逻辑正确。

这里我们就需要了解匿名管道的四种通信情况,匿名管道的本质是内核维护的固定大小缓冲区(Linux 默认约 64KB,不同系统略有差异),读写行为完全由「缓冲区状态(空 / 满)」和「读写速度」决定,以下四种是最典型的通信场景:

情况 1:快写慢读(父写快、子读慢)

核心定义:

  • 写端(父进程)写入速度远大于读端(子进程)读取速度(如父 1 秒写 10 条,子 3 秒读 1 条)。

表现特征:

  • 写端:write 几乎不阻塞,数据先暂存到管道缓冲区,直到缓冲区被写满;
  • 读端:慢慢读取缓冲区数据,数据不会丢失;
  • 关键现象:管道缓冲区充当 "临时仓库",消化写端的快速写入。

原理解析:

  • 内核管道缓冲区会先接收写端的所有数据,只要缓冲区没满,write 调用会立即返回(非阻塞);读端即使慢,也能从缓冲区逐步取数据,直到读完所有数据。
cpp 复制代码
// 父进程(快写):1秒写1条,写10条
void ParentTask(int wfd) {
    char buffer[1024];
    int cnt = 0;
    while(cnt < 10) {
        int len = snprintf(buffer, sizeof(buffer), "cnt=%d\n", cnt++);
        write(wfd, buffer, len);
        sleep(1); // 每秒写1条(快写)
    }
}

// 子进程(慢读):3秒读1条
void ChildTask(int rfd) {
    char buffer[1024];
    while(true) {
        sleep(3); // 每3秒读1条(慢读)
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
        if(n > 0) { buffer[n] = '\0'; cout << buffer; }
        else if(n == 0) break;
    }
}

运行现象:

  • 父进程快速写完 10 条数据(暂存管道缓冲区),子进程每 3 秒打印 1 条,直到读完所有数据,无乱码、无数据丢失。

    子进程这里打印的一条于父进程写的十条中的一条不一定是对应的:

    子进程调用 read 时,会读取「管道中当前可读取的字节数」,但最多不超过 max_len(一次读的最大上限):

    1. 如果管道中当前数据量 ≤ max_len → 一次性读完管道中所有可用数据,read 返回实际读取的字节数(就是管道里的全部数据量);
    2. 如果管道中当前数据量 > max_len → 本次只读取 max_len 字节,剩余数据仍留在管道缓冲区,等待下一次 read 读取。

情况 2:快读慢写(子读快、父写慢)

核心定义:

  • 读端(子进程)读取速度远大于写端(父进程)写入速度(如子 1 秒读 1 次,父 5 秒写 1 条)。

表现特征:

  • 读端:管道为空时,read 调用会阻塞,直到写端写入数据后被内核唤醒;
  • 写端:write 立即返回(缓冲区有空),无阻塞;
  • 关键现象:读端 "等数据",大部分时间阻塞在 read 调用上。

原理解析:

  • 管道缓冲区为空时,内核会挂起读进程;当写端写入至少 1 字节数据后,内核会唤醒读进程,read 才能读取到数据并返回。
cpp 复制代码
// 父进程(慢写):5秒写1条
void ParentTask(int wfd) {
    char buffer[1024];
    int cnt = 0;
    while(cnt < 3) {
        int len = snprintf(buffer, sizeof(buffer), "cnt=%d\n", cnt++);
        write(wfd, buffer, len);
        sleep(5); // 5秒写1条(慢写)
    }
}

// 子进程(快读):1秒读1次
void ChildTask(int rfd) {
    char buffer[1024];
    while(true) {
        cout << "子进程等待数据..." << endl;
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1); // 管道空时阻塞
        if(n > 0) { 
            buffer[n] = '\0'; 
            cout << "读取到:" << buffer << endl; 
        }
        else if(n == 0) break;
        sleep(1); // 1秒读1次(快读)
    }
}

运行现象:

  • 子进程反复打印 "子进程等待数据...",每 5 秒被唤醒一次,打印父进程写入的一条数据,其余时间阻塞在 read

情况 3:写满管道(写速远大于读速,缓冲区满)

核心定义:

  • 写端写入速度远超读端,导致管道缓冲区被完全写满(如父疯狂写数据,子几乎不读)。

表现特征:

  • 写端:write 调用阻塞,直到读端取走部分数据,缓冲区有空闲空间;
  • 读端:只要读取数据,写端就会被唤醒,继续写入;
  • 关键现象:写端 "等空间",卡在 write 调用上。

原理解析:

  • 管道缓冲区有固定上限(Linux 约 64KB),当缓冲区被写满时,内核会挂起写进程;只有读端读取数据释放缓冲区空间后,写进程才会被唤醒,write 继续执行。
cpp 复制代码
// 父进程(疯狂写):无间隔写数据,直到缓冲区满
void ParentTask(int wfd) {
    char buffer[1024];
    int cnt = 0;
    while(true) {
        int len = snprintf(buffer, sizeof(buffer), "cnt=%d\n", cnt++);
        cout << "父进程尝试写入第" << cnt << "条..." << endl;
        write(wfd, buffer, len); // 缓冲区满时阻塞
        // 无sleep,疯狂写
    }
}

// 子进程(极慢读):10秒读1条
void ChildTask(int rfd) {
    char buffer[1024];
    while(true) {
        sleep(10); // 10秒读1条(极慢)
        ssize_t n = read(rfd, buffer, sizeof(buffer)-1);
        if(n > 0) { buffer[n] = '\0'; cout << "读取到:" << buffer; }
    }
}

运行现象:

  • 父进程快速打印 "尝试写入第 X 条",直到缓冲区满,之后卡在 write 不再打印;每 10 秒子进程读取一条数据后,父进程会被唤醒,再写几条,又卡住。

情况 4:关闭管道端(写端 / 读端提前关闭)

这是管道通信的 "边界场景",分两种子情况:

子情况 a:写端关闭(父写完数据后关写端)

表现特征:

  • 读端:继续读取缓冲区剩余数据,读完后 read 返回 0(表示无更多数据),读进程可正常退出;
  • 关键:必须关闭所有写端,read 才会返回 0(父子进程都要关无用写端)。

示例现象:

  • 之前的代码中,父进程写完 10 条后 close(fd[1]),子进程读完所有数据后,read 返回 0,打印 "写端关闭" 并退出。

子情况 b:读端关闭(子进程提前关读端)

表现特征:

  • 写端:继续 write 时,内核会给写进程发送 SIGPIPE 信号 (默认终止进程);若捕获该信号,write 返回 -1,errnoEPIPE
  • 关键:读端全关后,写端写入无意义,内核触发 SIGPIPE。
cpp 复制代码
// 子进程(提前关读端)
void ChildTask(int rfd) {
    close(rfd); // 子进程刚启动就关闭读端
    sleep(10); // 子进程不退出,读端已关
}

// 父进程(继续写)
void ParentTask(int wfd) {
    char buffer[1024];
    int cnt = 0;
    while(cnt < 3) {
        int len = snprintf(buffer, sizeof(buffer), "cnt=%d\n", cnt++);
        write(wfd, buffer, len); // 子关读端后,第一次write触发SIGPIPE
        sleep(1);
    }
}

运行现象:

  • 父进程执行 write 时,被 SIGPIPE 信号终止,终端打印 "Broken pipe"(管道破裂)。

总结:

  1. 快写慢读:数据暂存管道缓冲区,写端不阻塞(直到缓冲区满),读端慢慢消费;
  2. 快读慢写:读端阻塞等数据,管道空时 read 挂起,写端写入后唤醒;
  3. 写满管道:写端 write 阻塞,直到读端取走数据释放缓冲区;
  4. 关闭管道端:写端关→读端 read 返回 0;读端关→写端触发 SIGPIPE。

核心规律:匿名管道的读写行为完全由「内核缓冲区状态(空 / 满)」决定,阻塞是内核的 "自动流量控制",避免数据丢失或无效写入。

匿名管道除了默认的阻塞模式(Blocking Mode) ,唯一的另一种核心工作模式是 非阻塞模式(Non-Blocking Mode,通过 O_NONBLOCK 标志开启)

两种模式的核心差异是:阻塞模式下 read/write 会等待条件满足(如管道有数据 / 有空间),非阻塞模式下 read/write 会立即返回,通过返回值和 errno 告诉调用者 "当前能不能读写"

2.2.4 特点与场景
  • 优点:简单高效,内核自动同步互斥;

    • 互斥层面:靠原子性实现(解决 "多进程写不打架")
    • 同步层面:靠阻塞机制实现(解决 "读写节奏匹配",和原子性无关)
  • 缺点:仅支持亲缘进程,半双工,数据流式无结构;

  • 场景:父子进程协作(如父进程分发任务,子进程执行)。

我们用一个匿名管道可以实现父子进程间进行单向通信,那可不可以让它们进行双向通信呢?当然是可以的,既然一个管道可以进行单向通信,那我们同时使用两个管道就可以实现双向通信了,如下图所示:

感兴趣的可以自行验证,这里不再赘述。

2.3 命名管道(FIFO):跨进程的 "公共管道"

匿名管道只能用来进行具有血缘关系的进程间的通信,那么对于两个以及若干个无关的进程之间该如何进行通信呢?它们之间想要进行通信可以通过命名管道来进行。

匿名管道的限制是 "亲缘进程",而命名管道(FIFO)通过文件系统中的 "特殊文件" 标识,突破了这一限制,支持任意进程通信。

当进程 A 和进程 B 同时打开同一个文件时,内核不会重复加载该文件的 inode 和文件内容,因为这类资源是全局共享的,重复加载无意义。进程 A 和 B 打开同一个文件时,各自维护独立的 struct file(私有上下文),但内存中只会有一份 inode 和内核缓冲区(全局资源),这些进程的 struct file都指向这同一份资源。这是 Linux「一切皆文件」设计下的资源复用机制。

  1. 命名管道是让不同进程看到的同一份资源,本质是文件

    • 命名管道在 Linux 文件系统中拥有唯一的路径和 inode,多个进程可以通过这个路径打开它,共享同一个内核级别的管道资源,符合 "一切皆文件" 的设计思想。
    • 它的文件属性(如权限、创建时间)会被持久化在文件系统中,但传输的数据不会。
  2. 数据仅在内存中传递,不会刷入磁盘持久化,无需磁盘 I/O 刷新

    • 命名管道的核心是内存中的环形缓冲区,数据仅在进程间传递时暂存于内存,被读取后立即释放,不会写入磁盘。
    • 普通文件的 fsync() 等刷新操作对命名管道完全无效,因为它没有磁盘数据需要同步。
2.3.1 命名管道(FIFO)的核心定义

命名管道(First In First Out,简称 FIFO)是 Linux 中一种特殊的文件系统对象,本质是内核维护的字节缓冲区(和匿名管道一样),但它有两个核心区别于匿名管道的特征:

  1. 文件系统可见 :FIFO 会以 "文件" 的形式出现在文件系统中(有路径,如/tmp/myfifo),但这个文件不存储实际数据(数据只存在内核缓冲区),仅作为进程通信的 "标识";
  2. 支持无亲缘进程通信:任意进程(无论是否有父子 / 兄弟关系),只要能访问这个 FIFO 文件路径,就能通过它通信。

也就是说命名管道更像是一个地址,一个在内核中的地址,进程可以通过这个地址向内核中放入或者取出数据。

举个日常生活中的例子:

  • 命名管道 = 小区快递柜的柜门编号(如 A08)(贴在柜子外面,所有人能看到,是 "地址");
  • 内核缓冲区 = 快递柜 A08 的内部空间(真正放快递的地方,在柜子里,对应内核中的内存区域);
  • 进程 = 寄 / 取快递的人:通过 "柜门编号(FIFO 路径)" 找到对应的柜子,往里面放快递(写数据)或取快递(读数据),快递只存在柜子里,编号只是 "找到柜子的地址"。
2.3.2 命名管道的核心特性
  1. **半双工通信:**同一时间只能单向传输(A→B 或 B→A),双向通信需创建两个 FIFO
  2. **字节流特性:**和匿名管道一致,无消息边界,仅按字节传输,需手动处理 "消息拆分 / 粘包"
  3. **阻塞/非阻塞模式:**默认阻塞模式,可通过O_NONBLOCK开启非阻塞
  4. **原子性写入:**和匿名管道一致:写入≤PIPE_BUF(默认 4096 字节)保证原子性,超过则不保证
  5. **生命周期:**随内核缓冲区销毁(所有进程关闭 FIFO 后,缓冲区释放),但 FIFO 文件需手动删除
  6. **权限控制:**创建时可指定权限(如 0666),只有拥有对应权限的进程才能访问
2.3.3 命名管道的创建方式

命名管道有两种创建方式:命令行创建(临时测试)、函数创建(代码中使用)。

  1. 命令行创建(mkfifo命令)

    bash 复制代码
    # 创建FIFO文件,路径为/tmp/myfifo,权限0666
    mkfifo filename

    命名管道是一类特殊的文件,它的文件标识为p,如下图所示:

  2. 代码中创建(mkfifo 函数)

    我们可以通过mkfifo函数来创建FIFO:

    cpp 复制代码
    #include <sys/stat.h>
    #include <sys/type.h>
    
    // 函数原型:
    int mkfifo(const char *pathname, mode_t mode);
    // 参数1:FIFO文件路径;参数2:权限(如0666,需结合umask)
    // 返回值:0成功,-1失败(如文件已存在,errno=EEXIST)

命名管道的权限:

命名管道本质是用于进程间通信(IPC) 的特殊文件,其权限控制的核心目的是保障 IPC 通信的安全性和可控性------ 这是 Linux/Unix "权限至上" 设计思想在进程通信场景的延伸,避免管道被未授权的进程滥用、篡改或窃取数据。

命名管道的权限位和普通文件一致(读r、写w、执行x,其中x对管道无意义),可针对属主、同组用户、其他用户精细化控制,具体作用分三类:

  • 核心安全:防止未授权的数据泄露 / 篡改

    这是权限控制最关键的作用。命名管道的数据在内存中传输,若权限开放过宽,任何进程 / 用户都能访问,会直接导致敏感数据泄露或通信被干扰。

  • 避免无关进程误操作管道

    系统中可能存在多个命名管道,权限控制能限定 "只有合法进程" 才能操作目标管道,避免无关进程的误读写导致通信异常。

  • 遵循 "最小权限原则"(系统安全最佳实践)

    进程仅拥有完成任务所需的最小权限,能大幅降低安全风险(比如进程被劫持后,因权限不足无法进一步破坏)。

使用命名管道与使用普通文件大体上是一致的。

对命名管道的 open/read/write/close 操作和普通文件完全一致,无需额外学习新接口。

2.3.4 命名管道的使用代码示例

命名管道的使用遵循 "创建→打开→读写→关闭→删除" 的流程,核心是打开规则(阻塞 / 非阻塞)。

FIFO 的打开行为是其最关键的特性,默认阻塞模式下:

  • 以 "读模式"(O_RDONLY)打开:会阻塞,直到有进程以 "写模式"(O_WRONLY)打开同一个 FIFO;
  • 以 "写模式"(O_WRONLY)打开:会阻塞,直到有进程以 "读模式"(O_RDONLY)打开同一个 FIFO;
  • 以 "读写模式"(O_RDWR)打开:不会阻塞(但不推荐,会破坏 "读写配对" 逻辑)。

非阻塞模式(O_NONBLOCK)下:

  • 以读模式打开:立即返回(即使无写进程),若此时无写进程,后续 read 会返回 - 1(errno=EAGAIN);
  • 以写模式打开:立即返回,但如果无读进程,write 会返回 - 1(errno=ENXIO)。

一般情况下我们都使用默认的阻塞模式。

下面是完整代码示例:两个无亲缘进程通信

进程 A(写进程):向 FIFO 写入数据

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<sys/stat.h>
#include<fcntl.h>

#define FIFO_PATH "./myfifo"
#define FIFO_MODE 0666

int main()
{
    // 1.创建FIFO(若不存在)
    int ret = mkfifo(FIFO_PATH, FIFO_MODE);
    if(ret == -1 && errno != EEXIST)
    {
        std::cerr << "mkfifo create false" << std::endl;
        exit(1);
    }

    // 2.以写模式打开FIFO(默认阻塞,直到有进程以读打开)
    int wfd = open(FIFO_PATH, O_WRONLY);
    if(wfd == -1)
    {
        std::cerr << "open fifo failed" << std::endl;
        exit(1);
    }
    std::cout << "写进程:FIFO打开成功,开始写入数据..." << std::endl;

    // 3.向FIFO写入数据(10条)
    char buffer[1024];
    for(int i = 0; i < 10; i++)
    {
        int len = snprintf(buffer, sizeof(buffer), "写进程PID = %d, 第%d条数据\n", getpid(), i);
        ssize_t n = write(wfd, buffer, len);
        if(n < 0)
        {
            std::cerr << "write failed" << std::endl;
            break;
        }

        std::cout << "写进程:已写入第" << i + 1 << "条数据" << std::endl;
        sleep(2); // 每2秒写1条
    }

    // 4.关闭FIFO
    close(wfd);
    std::cout << "写进程:数据写入完成,关闭FIFO" << std::endl;

    return 0;
}

这个程序是命名管道的数据发送端,执行流程如下:

  1. 定义常量 :指定了管道文件路径 .myfifo 和创建权限 0666
  2. 创建命名管道:
    • 调用 mkfifo() 尝试创建管道。
    • 如果创建失败,且失败原因不是 "管道已存在",则打印错误并退出。
    • 这一步保证了管道一定存在(不管是自己创建的,还是读进程先创建的)。
  3. 打开管道(只写模式):
    • open()O_WRONLY(只写)模式打开管道。
    • 这里是阻塞模式 :如果此时还没有读进程打开这个管道,写进程会一直卡在 open() 这里,直到有读进程连接。
  4. 循环写入数据:
    • 循环 10 次,每次用 snprintf() 把 "当前进程 PID + 数据序号" 格式化到缓冲区。
    • 调用 write() 把缓冲区内容写入管道。
    • 每次写成功后,打印 "已写入第 N 条数据",然后 sleep(2) 暂停 2 秒,模拟间隔发送。
    • 如果写入失败,打印错误并提前退出循环。
  5. 关闭管道并退出:
    • 写完 10 条数据后,调用 close() 关闭管道写端。
    • 打印 "数据写入完成",程序结束。

进程 B(读进程):从 FIFO 读取数据

cpp 复制代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<sys/stat.h>
#include<fcntl.h>

#define FIFO_PATH "./myfifo"
#define FIFO_MODE 0666

int main()
{
    // 1.确保FIFO存在(也可由读进程创建)
    int ret = mkfifo(FIFO_PATH, FIFO_MODE);
    if(ret == -1 && errno != EEXIST)
    {
        std::cerr << "mkfifo create false" << std::endl;
        exit(1);
    }

    // 2.以读模式打开FIFO(默认阻塞,直到有进程以写打开)
    int rfd = open(FIFO_PATH, O_RDONLY);
    if(rfd == -1)
    {
        std::cerr << "open fifo failed" << std::endl;
        exit(1);
    }
    std::cout << "读进程:FIFO打开成功,开始读出数据..." << std::endl;

    // 3.从FIFO读出数据
    char buffer[1024];
    while (true) 
    {
        buffer[0] = 0;
        ssize_t n = read(rfd, buffer, sizeof(buffer) - 1);
        if (n > 0) 
        {
            buffer[n] = '\0';
            std::cout << "读进程:读取到数据:" << buffer;
        } 
        else if (n == 0) 
        {
            // 写端关闭,无更多数据
            std::cout << "读进程:写端已关闭,退出" << std::endl;
            break;
        } 
        else 
        {
            perror("read failed");
            break;
        }
    }

    // 4.关闭FIFO
    close(rfd);
    std::cout << "写进程:数据读取完成,关闭FIFO" << std::endl;
    
    return 0;
}

这个程序是命名管道的数据接收端,执行流程如下:

  1. 定义常量 :和写进程一致,确保用的是同一个管道 .myfifo 和权限。
  2. 确保管道存在:
    • 同样调用 mkfifo() 尝试创建管道。
    • 目的是保证管道一定存在(如果写进程还没创建,读进程自己先创建)。
  3. 打开管道(只读模式):
    • open()O_RDONLY(只读)模式打开管道。
    • 这里也是阻塞模式 :如果此时还没有写进程打开管道,读进程会卡在 open() 这里,直到写进程连接。
  4. 循环读取数据:
    • 进入 while(true) 死循环,持续读取管道数据。
    • 每次读取前先清空缓冲区,调用 read() 从管道读取数据。
    • 读取结果处理:
      • n > 0:读到有效数据,在缓冲区末尾加字符串结束符 \0,然后打印 "读取到数据:xxx"。
      • n = 0:写端已经关闭(写进程退出或调用 close()),打印 "写端已关闭,退出" 并跳出循环。
      • n < 0:读取出错,用 perror() 打印错误并退出。
  5. 退出程序:循环结束后,文件描述符会被系统自动关闭,程序正常退出。

编译 & 运行步骤

bash 复制代码
# 编译两个进程
g++ -o fifo_writer fifo_writer.cpp -std=c++11
g++ -o fifo_reader fifo_reader.cpp -std=c++11

# 终端1:运行读进程(会阻塞,直到写进程打开FIFO)
./fifo_reader

# 终端2:运行写进程(打开FIFO后,读进程被唤醒,开始通信)
./fifo_writer

命名管道 = 匿名管道 + "文件系统路径标识",解决了匿名管道 "只能亲缘进程通信" 的问题,其他核心机制(字节流、阻塞、原子性)和匿名管道完全相同。

两者均是 Linux 内核维护的字节流缓冲区(数据不落地),半双工通信,小数据写入保证原子性,内核自动管控读写同步互斥;

  • 匿名管道:仅支持亲缘进程通信,无文件实体,轻量无残留,简单易用;
  • 命名管道:支持任意进程通信,有文件路径标识,需手动清理残留文件;

三、共享内存:最快的 IPC 机制

共享内存(Shared Memory,简称 shm)是内核在物理内存中划出的一块连续内存区域 ,内核会为其分配唯一的标识(shmid);多个进程可将这段内存映射到自己的虚拟地址空间,直接读写该内存(如同操作自己进程内的变量),数据无需在进程间拷贝 ------ 这是它称作最快的IPC机制的核心原因。

共享内存是所有 IPC 中速度最快的 ------ 一旦映射到进程地址空间,进程间数据传递无需经过内核,直接操作内存。其核心是 "内核开辟一块内存,多个进程挂接到自己的地址空间"。

举个简单的例子:

  • 管道:进程间 "传纸条"(数据从 A 进程拷贝到内核,再从内核拷贝到 B 进程,两次拷贝);
  • System V 共享内存:多个进程围坐 "公共白板"(内核内存),直接在白板上写 / 读内容,无数据拷贝,效率最高。

3.1 共享内存的原理

我们先通过一张图来直观看一下共享内存是如何被使用的:

步骤 1:内核创建共享内存区域("加载到内存" 的本质)

当你调用 shmget()时,内核会执行两个关键操作:

  1. 分配物理内存 :内核在系统物理内存中划出一块连续的内存区域,这块区域属于内核管理的公共资源,不隶属于任何单个进程;
  2. 登记标识 :内核为这块物理内存分配唯一标识(System V 是 shmid),用于后续进程找到这块内存。

⚠️ 关键补充:

  • 这一步完成后,共享内存已经存在于物理内存中,但任何进程都还不能访问它(没有访问入口)。

步骤 2:进程将共享内存映射到自身虚拟地址空间("挂载" 的本质)

每个进程都有独立的虚拟地址空间 (比如 32 位进程的 0~4GB 地址),进程无法直接访问物理内存,只能通过 "虚拟地址→物理地址" 的映射关系访问。当你调用 shmat()(System V)或 mmap()(POSIX)时:

  1. 建立地址映射:内核会把步骤 1 中分配的共享物理内存,关联到当前进程的虚拟地址空间的某个空闲区域(比如 0x7f000000~0x7f001000);
  2. 返回访问入口:系统调用会返回这个虚拟地址的指针,进程后续操作这个指针,就等同于直接操作共享的物理内存。

⚠️ 关键补充:

  • 不同进程映射到的虚拟地址可以完全不同 (比如进程 A 映射到 0x7f000000,进程 B 映射到 0x7e000000),但这些虚拟地址最终都会指向同一块物理内存
  • 映射过程没有任何数据拷贝,只是建立 "虚拟地址→物理地址" 的关联关系 ------ 这是共享内存速度最快的核心原因。

在上面的步骤中,提到了共享内存有自己的唯一标识,那为什么会有这个唯一标识呢?我们知道,两个进程进行通信的前提是要先看到同一份资源,那么如何看到是关键。多进程能通过共享内存通信的核心是「访问同一块物理内存」,而进程无法直接指定物理内存地址,只能通过约定好的标识符告诉内核:"我要访问的是这一块,不是其他的"。

3.2 共享内存的主要接口

我们需要了解一下有关共享内存的接口,这样才能在程序中创建共享内存并且去使用它。

System V 共享内存的使用遵循「创建→关联→读写→解除关联→删除」的固定流程,核心依赖 5 个系统调用(ftokshmgetshmatshmdtshmctl)。

3.2.1 生成唯一 Key(ftok 函数)

多个进程需通过相同的 Key 值 找到同一块共享内存,Key 通常由 ftok() 生成(也可手动指定固定值,如 0x1234):

cpp 复制代码
#include <sys/ipc.h>
// 函数原型:key_t ftok(const char *pathname, int proj_id);
// 参数1:存在的文件路径(如"/tmp");参数2:非0字符(如'a');返回值:成功返回key,失败返回-1
key_t key = ftok("/tmp", 'a');
if (key == -1) { perror("ftok failed"); return 1; }

ftok 返回的 key_t 值基于文件的inode 号和proj_id 的低 8 位生成,仅当文件路径对应的 inode 不变且proj_id 相同时,返回值才相同;若文件被删除重建(inode 改变),即使路径和 proj_id 相同,ftok 返回值也会不同。因此若需稳定的 Key:手动指定固定值 (如 #define SHM_KEY 0x12345678),避免依赖 ftok

若用 ftok:确保依赖的文件不被删除重建,且进程启动前检查文件是否存在。

3.2.2 创建 / 获取共享内存(shmget)
cpp 复制代码
#include <sys/shm.h>
// 函数原型:int shmget(key_t key, size_t size, int shmflg);
// 参数1:Key值(多个进程需一致);参数2:共享内存大小(字节,如4096);参数3:标志位+权限
// 常用标志位:IPC_CREAT(创建)、IPC_EXCL(仅创建新的,避免复用)、0666(权限)
int shmid = shmget(key, 4096, IPC_CREAT | IPC_EXCL | 0666);
if (shmid == -1) {
    if (errno == EEXIST) { // 共享内存已存在,直接获取
        shmid = shmget(key, 4096, 0666);
    } else {
        perror("shmget failed"); return 1;
    }
}

IPC_EXCL必须和IPC_CREAT同时使用,单独使用时无意义。

因此常用的两中标志位如下:

  • IPC_CREAT:如果目标共享内存不存在,就创建,否则打开这个已经存在的共享内存并返回。
  • IPC_CREAT | IPC_EXCL:如果目标共享内存不存在,就创建,否则出错返回。
3.2.3 关联共享内存到进程地址空间(shmat)

将内核的共享内存映射到进程自己的虚拟地址空间,返回可直接操作的指针:

cpp 复制代码
// 函数原型:void *shmat(int shmid, const void *shmaddr, int shmflg);
// 参数1:shmid(shmget返回的标识);参数2:指定映射地址(NULL=内核自动分配);参数3:0(默认读写)
// 返回值:成功返回映射后的指针,失败返回(void*)-1
char *shm_ptr = (char*)shmat(shmid, NULL, 0);
if (shm_ptr == (void*)-1) { perror("shmat failed"); return 1; }
3.2.4 读写共享内存(直接操作指针)

如同操作进程内的普通内存,无需系统调用,效率极高:

cpp 复制代码
// 写数据(进程A)
strcpy(shm_ptr, "Hello System V Shared Memory!");
// 读数据(进程B)
printf("读取到共享内存数据:%s\n", shm_ptr);
3.2.5 解除共享内存关联(shmdt)

进程不再使用时,解除映射(仅断开关联,不删除共享内存):

cpp 复制代码
// 函数原型:int shmdt(const void *shmaddr);
// 参数:shmat返回的指针;返回值:0成功,-1失败
if (shmdt(shm_ptr) == -1) { perror("shmdt failed"); return 1; }
3.2.6 删除共享内存(shmctl)

共享内存不会随进程退出销毁,需手动删除(否则内核中残留,造成内存泄漏):

cpp 复制代码
// 函数原型:int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 参数1:shmid;参数2:IPC_RMID(删除命令);参数3:NULL(无需获取属性)
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
    perror("shmctl failed"); return 1;
}

shmctl不仅仅能删除共享内存,进程通过它能对已创建的共享内存段执行查询、修改、删除、锁定等所有管理操作,是操作 System V 共享内存元信息的唯一入口。

cpp 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • shmid:要操作的共享内存段 ID(由 shmget() 返回的内核唯一标识)

  • cmd:要执行的操作命令(核心:查询 / 修改 / 删除,扩展:锁定 / 解锁)

    cmd 参数决定了 shmctl() 执行的操作,核心分为 4 类,其中查询、修改、删除是最常用的:

    • IPC_STAT(查):查询共享内存元信息
      1. 将内核中共享内存的元信息(大小、挂载数、权限、PID 等)拷贝到用户态 struct shmid_ds
      2. buf 必须指向有效 shmid_ds 结构体,不可传 NULL
    • IPC_SET(改):修改共享内存属性
      1. 仅能修改 shm_perm 下的 uid(属主 UID)、gid(属主 GID)、mode(访问权限);
      2. 需是创建者 / 属主或 root 权限;
      3. 需先通过 IPC_STAT 获取原始信息再修改。
    • IPC_RMID(删):标记删除共享内存
      1. 释放 System V 共享内存的唯一方式,调用后 keyshmid 立即解关联;
      2. 非立即释放内存,需等所有进程 shmdt() 后(挂载数为 0)内核才回收;
      3. buf 可传 NULL
  • buf:输出型参数,一个指向 struct shmid_ds 结构体的指针,用来返回共享内存的信息

    • 查询 / 修改时:传递用户态内存地址,用于数据交互;
    • 删除 / 锁定时:可传 NULL

    因为可能同时存在多组进程使用不同的共享内存来进行通信,所以OS系统内多个共享内存会同时存在,所以有共享内存的内核结构体对象,用来描述共享内存,上述的 struct shmid_ds 就是是用户态能访问的共享内存元信息结构体(内核 shmid_kernel 的子集),核心字段如下:

    cpp 复制代码
    struct shmid_ds {
     struct ipc_perm shm_perm;  // 权限/标识(key/UID/GID/mode)
     size_t          shm_segsz; // 共享内存段大小(字节)
     pid_t           shm_cpid;  // 创建者 PID
     pid_t           shm_lpid;  // 最后一次操作的 PID
     shmatt_t        shm_nattch;// 当前挂载(映射)的进程数
     time_t          shm_atime; // 最后一次 attach 时间
     time_t          shm_dtime; // 最后一次 detach 时间
     time_t          shm_ctime; // 最后一次修改时间
    };

    shm_perm的具体结构如下图所示:

    它是 System V IPC(包括共享内存、消息队列、信号量)的通用权限与标识核心结构体 (类型为 struct ipc_perm),也就是说对于该标准下的其他IPC的内核结构体中都有该字段(消息队列和信号量中),是内核为了统一管理所有 System V IPC 对象(共享内存、消息队列、信号量)而设计的通用结构体。

返回值 成功返回 0;失败返回 -1,并设置 errno(如 EINVAL 表示 shmid 无效)

3.3 使用共享内存完成通信

共享内存没有内置的同步机制------ 多个进程同时读写会导致数据混乱(临界资源竞争)。解决方案是搭配管道、信号量等实现访问控制。

那么我们就通过上面所讲的管道 + 共享内存实现同步通信,核心思路:用命名管道作为 "信号量",客户端写入共享内存后发送信号,服务器收到信号后读取,避免竞争。

comm.h(公共头文件)

cpp 复制代码
#pragma once

#include<sys/ipc.h>
#include<sys/shm.h>
#include<sys/stat.h>
#include<sys/types.h>
#include<fcntl.h>
#include<iostream>
#include<cstring>
#include<unistd.h>

#define PATH_NAME "."
#define PROJ_ID 0x6666
#define MAX_SIZE 4096
#define FIFO_NAME "./myfifo"
#define FIFO_MODE 0666

// 创建共享内存
int createShm(int size);
// 获取共享内存
int getShm(int size);
// 删除共享内存
int destroyShm(int size);

void Wait(int fd);
void Signal(int fd);

comm.cc(实现):

cpp 复制代码
#include"comm.h"

static int commShm(int size, int shmflg)
{
    key_t key = ftok(PATH_NAME, PROJ_ID);
    if(key < 0)
    {
        std::cerr << "ftok failed" << std::endl;
        exit(1);
    }
    
    int shmid = shmget(key, size, shmflg);
    if(shmid < 0)
    {
        std::cerr << "shmget failed" << strerror(errno) << std::endl;
        exit(1);
    }
    return shmid;
}

// 创建共享内存
int createShm(int size)
{
    return commShm(size, IPC_CREAT | IPC_EXCL | 0666);
}
// 获取共享内存
int getShm(int size)
{
    return commShm(size, IPC_CREAT | 0666);
}
// 删除共享内存
int destroyShm(int shmid)
{
    return shmctl(shmid, IPC_RMID, nullptr);
}

void Wait(int fd)
{
    char buffer[1];
    read(fd, buffer, 1);
}
void Signal(int fd)
{
    char x = 1;
    write(fd, &x, sizeof(x));
}

Server.cc(带同步的服务器)

cpp 复制代码
#include "comm.h"

int main()
{
    // 1.创建FIFO
    mkfifo(FIFO_NAME, FIFO_MODE);
    int fifo_fd = open(FIFO_NAME, O_RDONLY);

    // 2.创建共享内存
    int shmid = createShm(MAX_SIZE);

    // 3.关联共享内存
    char *shm_ptr = (char*)shmat(shmid, nullptr, 0);

    // 4.同步读取
    while(true)
    {
        Wait(fifo_fd);
        std::cout << "收到:" << shm_ptr << std::endl;
        if (strcmp(shm_ptr, "quit") == 0) break;
    }

    // 5.清理资源
    close(fifo_fd);
    shmdt(shm_ptr);
    destroyShm(shmid);
    unlink(FIFO_NAME);
    return 0;
}

Client.cc(带同步的客户端)

cpp 复制代码
#include "comm.h"

int main()
{
    // 1.打开FIFO
    int fifo_fd = open(FIFO_NAME, O_WRONLY);
    sleep(2);
    // 2.获取共享内存
    int shmid = getShm(MAX_SIZE);

    // 3.关联共享内存
    char *shm_ptr = (char*)shmat(shmid, nullptr, 0);

    // 4.写入数据
    while(true)
    {
        ssize_t s = read(0, shmaddr, SHM_SIZE - 1);
        if (s > 0) 
        {
            shmaddr[s - 1] = 0;
            Signal(fd);
            if (strcmp(shmaddr, "quit") == 0)
            break;
		}
    }

    // 5.清理资源
    close(fifo_fd);
    shmdt(shm_ptr);
    
    return 0;
}

3.4 一些小问题

3.4.1 共享内存的生命周期

进程结束了, 如果没有删除共享内存,那么共享内存会一直存在,共享内存的生命周期随内核------如果没有显示删除,即便进程退出了,IPC资源依旧被占用。我们可以通过ipcs -m命令查看当前存在的共享内存:

删除共享内存有两种方式,一种是在程序中使用shmctl函数,另一种则是在终端使用ipcrm -m shmid命令,如下图所示:

3.4.2 共享内存的大小

在创建共享内存时,它的大小必须是4KB的整数倍,如果不够则向上取整,例如设置的大小为3096字节,那么它的实际大小为4KB,如果是4097字节,那么实际的大小是8KB。不过我们能够使用的大小仍旧是我们设置的大小!

四、其他 IPC 机制:消息队列与信号量

4.1 System V 消息队列

System V 消息队列是内核维护的带 "类型标识" 的消息链表,进程可以按 "消息类型" 发送 / 接收数据,是一种 "有分类、有序" 的 IPC 机制(数据需经过内核中转)。

就像公司的分类信箱:

  • 进程 A 可以把不同类型的消息(比如 "订单消息" 类型 = 1、"通知消息" 类型 = 2)放进信箱(内核);
  • 进程 B 可以指定只取 "订单消息"(类型 = 1),不用接收所有消息;
  • 内核负责保管消息,直到被接收或手动删除。

它的核心特点是:

  • 自带 "数据传输" 能力,无需手动同步(消息按发送顺序 / 指定类型读取);
  • 速度比共享内存慢(数据要从用户态拷贝到内核态,再拷贝到目标进程);
  • 核心接口:msgget()(创建 / 获取队列)、msgsnd()(发消息)、msgrcv()(收消息)、msgctl()(管理 / 删除队列)。

4.2 System V 信号量

System V 信号量不是用来传数据的同步工具,本质是内核维护的 "计数器",用来控制多个进程对共享资源(比如共享内存)的访问,避免 "同时操作导致数据混乱"。

就像单人自习室的「门锁」:

  • 进程要访问共享资源(比如写共享内存)前,先 "开锁"(信号量值 - 1,叫 P 操作);
  • 用完资源后 "锁门"(信号量值 + 1,叫 V 操作);
  • 如果门锁被占用(信号量值 = 0),进程就等待,直到其他进程释放。

它的核心特点是:

  • 无数据传输功能,仅做 "同步和互斥";
  • 通常和共享内存搭配使用(共享内存无内置同步,靠信号量保证安全);
  • 核心接口:semget()(创建 / 获取信号量集)、semop()(执行 P/V 操作)、semctl()(管理 / 删除信号量)。

所以说并不是说只有传递数据才叫进程间通信,通知,同步互斥这也叫做进程间通信。

4.3 与 System V 共享内存的相似之处

这三者同属 System V IPC 体系,底层设计和使用逻辑高度一致,核心相似点有 4 个:

  1. 统一的标识与接口体系

    • 都用 key_t(通常通过 ftok() 生成)作为进程间的 "约定暗号",内核会分配唯一的 ID(共享内存 = shmid、消息队列 = msqid、信号量 = semid);
    • 核心接口命名规律一致:xxxget()(创建 / 获取)、xxxctl()(管理 / 删除),操作逻辑完全相通。
  2. 统一的权限与所有权管理

    • 都依赖 struct ipc_perm 结构体(身份 + 权限):通过 mode 字段控制访问权限(如 0664),uid/gid 管理属主,只有创建者 / 属主 /root 能修改、删除;
    • 权限校验逻辑一致:进程访问时,内核会对比 UID/GID 和 ipc_perm,无权限则拒绝。
  3. 生命周期与进程解耦

    • 都不会随进程退出自动销毁:即使所有使用的进程都退出,内核仍会保留这些 IPC 对象,需显式调用 xxxctl(IPC_RMID) 删除;
    • 残留的 IPC 对象都可通过 ipcs 命令查看,ipcrm 命令手动删除(共享内存 =ipcs -m、消息队列 =ipcs -q、信号量 =ipcs -s)。
  4. 统一的内核管理逻辑

    • 内核都为其维护专属的管理结构体(共享内存 = shmid_kernel、消息队列 = msqid_kernel、信号量 = semid_kernel);
    • 用户态都可通过 xxxctl(IPC_STAT) 获取精简版的元信息结构体(如 shmid_ds/msqid_ds/semid_ds),查看大小、挂载数、权限等信息。

    在Linux中,我们可以通过ipcs -m/-q/-s来查看存在的共享内存、消息队列和信号量

4.4 总结

  1. System V 消息队列是带分类的内核中转式数据传输工具,信号量是无数据的同步互斥工具,共享内存是零拷贝的高速数据共享工具;
  2. 三者核心相似点:统一的 System V IPC 标识 / 接口 / 权限体系,生命周期与进程解耦,需手动销毁。

简单来说:三者是 "同门师兄弟",底层规则完全一致,只是分工不同 ------ 共享内存负责 "高速传数据",消息队列负责 "有序传数据",信号量负责 "保证数据操作安全"。在操作系统中这三者被当做了同一种资源!

Linux 内核通过ipc_ids结构体统一管理所有 System V IPC 资源(共享内存、消息队列、信号量),核心特点:

  1. 每个 IPC 资源对应一个kern_ipc_perm结构体,存储 key、权限、所有者等信息;
  2. IPC 资源的生命周期随内核 ,进程退出后资源不会自动释放,需通过shmctlmsgctl等手动删除;

IPC 选型指南:按场景选对机制

IPC 机制 速度 适用进程 数据结构 同步支持 典型场景
匿名管道 亲缘进程 流式 内核自动 父子进程简单通信
命名管道 任意进程 流式 内核自动 无亲缘关系的简单通信
共享内存 任意进程 字节流 需手动实现 高频、大数据量传输(如实时数据)
消息队列 任意进程 结构化 内核自动 低频率、有结构的少量数据
信号量 - 任意进程 - 核心功能 同步互斥(搭配共享内存)

选型原则

  1. 简单通信(少量数据):优先选管道(匿名 / 命名);
  2. 大量 / 高频数据:选共享内存 + 信号量 / 管道(同步);
  3. 有结构数据:可选消息队列(或用共享内存自定义结构);
  4. 跨进程通信:命名管道或共享内存。

五、总结

Linux 进程间通信的核心是 "打破进程隔离",不同机制各有侧重:管道胜在简单,共享内存胜在速度,信号量负责同步。实际开发中,需根据数据量、通信频率、进程关系选择合适的 IPC 机制 ------ 比如简单的日志传输用命名管道,实时视频流用共享内存 + 信号量。

在现代 Linux 系统编程 / 服务端开发中,System V 进程间通信(System V IPC) 确实几乎被弃用,仅存于老项目维护场景,新开发中完全不会选用,属于被逐步淘汰的传统 IPC 技术。不过虽然其标准落后,但是共享内存、信号量、消息队列的底层核心原理完全没变,POSIX 标准的 IPC 本质是在相同核心原理的基础上,做了更贴合 Linux 设计理念、更易用的工程实现。

因此掌握 IPC 的关键不仅是记住接口,更要理解底层原理(如管道的文件描述符复制、共享内存的映射机制)和同步问题的解决方案。希望本文的细致解析和实例能帮你在实际开发中灵活运用 IPC,实现进程间的高效协作。

尾声

本章讲解就到此结束了,若有纰漏或不足之处欢迎大家在评论区留言或者私信,同时也欢迎各位一起探讨学习。感谢您的观看!

更多内容可见主页](https://blog.csdn.net/2302_80243065?spm=1000.2115.3001.5343)

相关推荐
薛定谔的猫喵喵15 小时前
基于C++ Qt的唐代诗歌查询系统设计与实现
c++·qt·sqlite
阿昭L15 小时前
C++异常处理机制反汇编(三):32位下的异常结构分析
c++·windows·逆向工程
匆匆那年96715 小时前
llamafactory推理消除模型的随机性
linux·服务器·学习·ubuntu
Cinema KI15 小时前
C++11(下) 入门三部曲终章(基础篇):夯实语法,解锁基础编程能力
开发语言·c++
好好学习天天向上~~15 小时前
5_Linux学习总结_vim
linux·学习·vim
燃于AC之乐15 小时前
深入解剖STL List:从源码剖析到相关接口实现
c++·stl·list·源码剖析·底层实现
汉克老师15 小时前
GESP2025年6月认证C++二级( 第一部分选择题(9-15))
c++·循环结构·求余·gesp二级·gesp2级·整除、
不想睡觉_15 小时前
优先队列priority_queue
c++·算法
Coder个人博客21 小时前
Linux6.19-ARM64 mm mmu子模块深入分析
大数据·linux·车载系统·系统架构·系统安全·鸿蒙系统