Linux进程间通信:从管道到System V IPC,一篇讲透进程协作的底层逻辑

在这里插入图片描述

🌈 say-fall:个人主页
🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》
💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

在前面的文章中,我们已经学习了进程创建、进程控制和进程替换。但你是否想过一个问题:每个进程都有自己独立的虚拟地址空间,进程 A 根本看不到进程 B 的数据,它们是彼此隔离的——

对比项进程内进程间
数据可见性同一地址空间,直接访问❌ 互相隔离,看不到对方
通信方式全局变量、传参即可❌ 普通手段全部失效

🔍 问题来了: 进程之间既然彼此隔离,又该如何交换数据、协同工作呢?

答案就是——进程间通信(IPC,Inter-Process Communication)!而 IPC 的本质只有一句话:让不同的进程,看到同一份资源。这份资源必须由操作系统来提供。围绕"这份资源是什么、长什么样",就衍生出了管道、共享内存、消息队列、信号量等一整套通信方案。

文章目录


一、🌲 进程间通信的本质与分类

1.1 为什么进程间通信必须由操作系统介入

进程具有独立性,这是操作系统刻意设计的结果——地址空间隔离保证了安全和稳定。但代价是:进程之间想要通信,不能直接私发消息,必须找一个双方都能看到的"公共地带"。

这个公共地带只能是操作系统提供的:

  • 一块内核缓冲区(管道)
  • 一块物理内存(共享内存)
  • 一个内核队列(消息队列)
  • 一个内核计数器(信号量)

💡 所以理解 IPC 的第一性原理是:先由 OS 提供一份公共资源,再让不同进程都能"看到"它,通信自然就发生了。

1.2 通信的四大目的

目的说明
数据传输一个进程需要将它的数据发送给另一个进程
资源共享多个进程之间共享同样的资源
通知事件一个进程向另一个(或一组)进程发送消息,通知发生了某种事件(如进程终止时通知父进程)
进程控制有些进程希望完全控制另一个进程的执行(如 Debug 进程),拦截它的所有陷入和异常,及时知道它的状态改变

1.3 发展历程与分类

IPC 的发展经历了三个阶段:管道 → System V 进程间通信 → POSIX 进程间通信。

大类具体方案
管道匿名管道 pipe、命名管道 FIFO
System V IPC消息队列、共享内存、信号量
POSIX IPC消息队列、共享内存、信号量、互斥量、条件变量、读写锁

本文按"管道 → 共享内存 → 消息队列 → 信号量"的顺序,把 Linux 中最核心的 IPC 方案逐一讲透。


二、🚿 匿名管道:最古老的 IPC

2.1 什么是管道

管道是 Unix 中最古老的进程间通信形式。我们把从一个进程连接到另一个进程的一个数据流称为一个"管道"。

你在命令行里敲过的 ls | grep xxx,其中的 | 就是管道——ls 的输出流向 grep 的输入,这就是一个典型的数据流。

2.2 pipe 函数:创建匿名管道

语法:

#include <unistd.h>

int pipe(int fd[2]);
// 参数 fd:文件描述符数组
//   fd[0] —— 读端
//   fd[1] —— 写端
// 返回值:成功返回 0,失败返回错误代码

2.3 用 fork 共享管道

pipe 创建的管道,进程自己一个人用没有意义(自己写自己读)。真正的用法是:先 pipe,再 fork——

💡 关键机制: fork 之后,子进程会继承父进程的文件描述符表。于是父进程的 fd[0] 和 fd[1],子进程也有一份,而且指向同一个管道。这就是"两个进程看到同一份资源"的实现。

站在文件描述符的角度:父进程 fork 前打开了读写两端,fork 后父子各持有一套指向同一管道的 fd。实践中通常是单向通信:

  • 父进程关闭读端 fd[0],只写;
  • 子进程关闭写端 fd[1],只读。

2.4 站在内核角度:管道的本质

别被"管道"这个名字唬住,它的本质是内核里维护的一块内存级缓冲区,只不过 OS 把它包装成了"文件"的样子,让我们用 read/write/close 这套文件接口来操作。

所以,看待管道,就如同看待文件一样!管道的使用和文件一致,迎合了 “Linux 一切皆文件” 的思想。

2.5 实例代码:父子进程通信

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

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

int main(int argc, char *argv[])
{
    int pipefd[2];
    if (pipe(pipefd) == -1)
        ERR_EXIT("pipe error");

    pid_t pid;
    pid = fork();
    if (pid == -1)
        ERR_EXIT("fork error");

    if (pid == 0) {
        // 子进程:关闭读端,只写
        close(pipefd[0]);
        write(pipefd[1], "hello", 5);
        close(pipefd[1]);
        exit(EXIT_SUCCESS);
    }

    // 父进程:关闭写端,只读
    close(pipefd[1]);
    char buf[10] = {0};
    read(pipefd[0], buf, 10);
    printf("buf=%s\n", buf);
    return 0;
}

2.6 管道的读写规则

当没有数据可读时:

设置行为
O_NONBLOCK 关闭(默认)read 调用阻塞,进程暂停执行,直到有数据到来
O_NONBLOCK 打开read 返回 -1,errno 值为 EAGAIN

当管道写满时:

设置行为
O_NONBLOCK 关闭(默认)write 调用阻塞,直到有进程读走数据
O_NONBLOCK 打开write 返回 -1,errno 值为 EAGAIN

读写端关闭的两种情况:

  • 如果所有管道写端对应的文件描述符被关闭,read 返回 0(读到文件结尾的语义);
  • 如果所有管道读端对应的文件描述符被关闭,write 操作会产生信号 SIGPIPE,进而可能导致 write 进程退出。

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

2.7 验证管道通信的 4 种情况

情况现象
读正常 && 写满write 阻塞,等读端取走数据
写正常 && 读空read 阻塞,等写端写入数据
写关闭 && 读正常read 返回 0,表示读到了末尾
读关闭 && 写正常write 触发 SIGPIPE 信号,进程退出

2.8 管道的特点总结

  • 只能用于具有亲缘关系的进程(共同祖先):通常一个进程创建管道后调用 fork,此后父子进程之间使用该管道;
  • 管道提供流式服务;
  • 一般而言,进程退出,管道释放,所以管道的生命周期随进程;
  • 一般而言,内核会对管道操作进行同步与互斥(这一点后面和共享内存对比时非常重要);
  • 管道是半双工的,数据只能向一个方向流动;需要双方通信时,需要建立两个管道。

2.9 进阶应用:用管道实现进程池

匿名管道最经典的应用之一就是进程池(Process Pool):父进程(master)预先创建一批子进程,每个子进程配一条管道;父进程通过往管道里写"任务编号"来派发任务,子进程从标准输入读命令并执行。

整体设计遵循 “先描述,再组织”:

// ========== Channel:描述一条信道 ==========
class Channel
{
public:
    Channel(int wfd, pid_t who) : _wfd(wfd), _who(who)
    {
        // Channel-3-1234 这种命名,方便调试
        _name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
    }
    void Send(int cmd) { ::write(_wfd, &cmd, sizeof(cmd)); } // 派发任务
    void Close()       { ::close(_wfd); }// ::是为了说明这是一种底层调用,表示在全局
    pid_t Id()         { return _who; }
    int wFd()          { return _wfd; }
private:
    int _wfd;          // 写端 fd
    std::string _name;
    pid_t _who;        // 对应的子进程 pid
};

初始化进程池的核心逻辑:

// 2. 创建指定个数个进程
for (int i = 0; i < processnum; i++)
{
    // 1. 先有管道
    int pipefd[2] = {0};
    int n = pipe(pipefd);
    if (n < 0) return PipeError;

    // 2. 创建进程
    pid_t id = fork();
    if (id < 0) return ForkError;

    // 3. 建立通信信道
    if (id == 0)
    {
        // 关闭历史 wfd:新子进程必须关掉之前所有管道的写端
        for (auto &c : channels) c.Close();
        ::close(pipefd[1]);
        dup2(pipefd[0], 0);   // 把读端重定向到标准输入
        work();               // 执行任务循环
        ::exit(0);
    }
    // 父进程:关闭读端,只保留写端
    ::close(pipefd[0]);
    channels.emplace_back(pipefd[1], id);
}

⚠️ 两个容易踩的坑:

  • 子进程必须关闭历史 wfd:fork 是层层继承的,第 3 个子进程手里也握着前两条管道的写端。如果不清掉,父进程关闭写端时,管道还有别的写端,子进程 read 永远不会返回 0,无法正常退出。
  • 退出时要"先关 fd、再 wait":先关闭所有写端让子进程 read 到 0 退出,再 waitpid 回收,顺序反了会阻塞。

任务派发采用轮询(Round-Robin)方式,把任务均匀地分给每个子进程;子进程的 Worker 逻辑非常简单——从 fd 里读到一个整数就执行对应任务,读到 0(写端全部关闭)就退出:

void Worker()
{
    while (true)
    {
        int cmd = 0;
        int n = ::read(0, &cmd, sizeof(cmd));
        if (n == sizeof(cmd))
        {
            tm.Excute(cmd);   // 执行编号为 cmd 的任务
        }
        else if (n == 0)
        {
            break;            // 写端全关了,退出
        }
    }
}

三、📁 命名管道 FIFO:让陌生进程也能通信

3.1 为什么需要命名管道

管道的一个明显限制:只能在具有共同祖先(亲缘关系)的进程间通信。

如果我们想在不相关的进程之间交换数据,可以使用 FIFO 文件来做这项工作,它经常被称为命名管道。命名管道是一种特殊类型的文件——注意,它虽然叫"文件",但数据并不落盘,本质还是内核缓冲区,只是有了一个路径名,任何进程都能通过路径找到它。

3.2 创建命名管道

命令行方式:

$ mkfifo filename

程序内创建:

#include <sys/stat.h>

int mkfifo(const char *filename, mode_t mode);

// 示例
int main(int argc, char *argv[])
{
    mkfifo("p2", 0644);
    return 0;
}

3.3 命名管道的打开规则

为读而打开 FIFO 时:

设置行为
O_NONBLOCK 关闭阻塞直到有相应进程为写而打开该 FIFO
O_NONBLOCK 打开立刻返回成功

为写而打开 FIFO 时:

设置行为
O_NONBLOCK 关闭阻塞直到有相应进程为读而打开该 FIFO
O_NONBLOCK 打开立刻返回失败,错误码为 ENXIO

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

对比项匿名管道 pipe命名管道 FIFO
创建方式pipe 函数创建并打开mkfifo 创建,open 打开
适用场景有亲缘关系的进程任意进程
语义一旦创建打开完成,两者具有相同的语义同左

唯一的区别就在创建与打开的方式不同,一但这些工作完成之后,它们具有相同的语义。

3.5 实例:server & client 通信

server 端创建 FIFO 并读取:

// serverPipe.c
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, exit now!\n");
            exit(EXIT_SUCCESS);
        } else {
            ERR_EXIT("read");
        }
    }
    return 0;
}
// clientPipe.c:写端,与 server 无亲缘关系
int main()
{
    int wfd = open("mypipe", O_WRONLY);   // 靠路径名 "mypipe" 找到同一个 FIFO
    if (wfd < 0) ERR_EXIT("open");

    char buf[1024];
    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 {
            ERR_EXIT("read");
        }
    }
    return 0;
}

💡 server 先运行时 open 会阻塞,直到 client 以写方式打开同一个 FIFO——这正是上面"打开规则"的体现。


四、🧠 System V 共享内存:最快的 IPC

4.1 原理:让两个进程映射同一块物理内存

System V 是一种 IPC 标准,Linux 支持这种标准,专门设计了一个 IPC 通信模块。再强调一遍 IPC 的本质:不同进程看到同一份资源。

共享内存的原理分两步:

  1. 先申请一块内存空间(物理内存);
  2. 映射(挂接):通过页表把这块物理内存同时映射到进程 A 的虚拟地址空间和进程 B 的虚拟地址空间。
进程A虚拟地址空间          物理内存            进程B虚拟地址空间
┌─────────────┐         ┌─────────┐         ┌─────────────┐
│  栈          │         │         │         │  栈          │
│      ↓       │         │         │         │      ↑       │
│  共享区 ─────┼───页表──►│ 共享内存 │◄──页表───┼─ 共享区      │
│      ↑       │         │         │         │      ↓       │
│  堆          │         └─────────┘         │  堆          │
└─────────────┘                             └─────────────┘

💡 三个关键细节:

  1. 所有工作(申请、映射、关联)都是由操作系统完成的(系统调用);
  2. 取消关联关系后,OS 才会将内存销毁;
  3. 系统中可能同时存在多组进程、使用不同的共享内存通信 ⇒ 每一个共享内存,都需要一个描述它的内核数据结构对象——又是熟悉的"先描述,再组织"。

4.2 共享内存的数据结构

操作系统为了管理共享内存,专门设计了描述结构体:

struct shmid_ds {
    struct ipc_perm    shm_perm;    /* 操作权限 */
    int                shm_segsz;   /* 段的大小(字节) */
    __kernel_time_t    shm_atime;   /* 最后一次 attach 时间 */
    __kernel_time_t    shm_dtime;   /* 最后一次 detach 时间 */
    __kernel_time_t    shm_ctime;   /* 最后一次修改时间 */
    __kernel_ipc_pid_t shm_cpid;    /* 创建者的 pid */
    __kernel_ipc_pid_t shm_lpid;    /* 最后一个操作者的 pid */
    unsigned short     shm_nattch;  /* 当前挂接数 */
    ...
};

struct ipc_perm {
    __kernel_key_t  __key;   /* 用户层构建的 key */
    __kernel_uid_t  uid;
    __kernel_gid_t  gid;
    __kernel_uid_t  cuid;    /* 创建者 */
    __kernel_gid_t  cgid;
    __kernel_mode_t mode;    /* 权限位 */
    unsigned short  __seq;
};

4.3 shmget 与 ftok:如何让两个进程看到同一块内存

语法:

#include <sys/ipc.h>
#include <sys/shm.h>

int shmget(key_t key, size_t size, int shmflg);
// 参数 key:这个共享内存段的名字(唯一性标识)
// 参数 size:共享内存大小
// 参数 shmflg:由九个权限标志构成,用法和创建文件时的 mode 模式标志一样
// 返回值:成功返回一个非负整数,即该共享内存段的标识码(shmid,内核使用)
//         失败返回 -1

shmflg 的两个核心选项:

选项含义
IPC_CREAT共享内存不存在就创建并返回;已存在就获取并返回
IPC_CREAT | IPC_EXCL共享内存不存在就创建并返回;已存在则出错返回

💡 单独使用 IPC_EXCL 没有意义。它和 IPC_CREAT "或"起来的意义是:保证调用者拿到的一定是一块全新的共享内存(通信的发起者用它)。

那么 key 从哪来?key 要保证全系统唯一,不同进程用 shm 通信时靠 key 标识共享内存的唯一性。它由用户通过约定好的算法生成:

#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);
// 传一个真实存在的路径 + 一个用户自定义的 id
// 该函数会构建出一个 key,成功返回 key,失败返回 -1

只要两个进程传相同的 pathname 和 proj_id,ftok 就会得到相同的 key,从而找到同一块共享内存。

💡 将共享内存与文件系统类比:

  • fd 与 shmid 类似:fd 是进程私有的,shmid 是全局的;
  • 文件名与 key 类似:文件名是"已有"的,而 key 需要"创建"出来(ftok 生成)。
  • key 只用来在内核层面区分唯一性;后续的删除、控制等操作都不用 key,用户只使用 shmid。

4.4 shmat / shmdt / shmctl:挂接、分离与控制

挂接(attach)——把共享内存连接到进程地址空间,类似 malloc(在堆上、在虚拟空间中申请):

#include <sys/types.h>
#include <sys/shm.h>

void *shmat(int shmid, const void *shmaddr, int shmflg);
// 参数 shmid:共享内存标识
// 参数 shmaddr:指定连接的地址,一般传 NULL,让 OS 自动选择
// 参数 shmflg:标志位
//   0            —— 可读可写
//   SHM_RDONLY   —— 只读
//   SHM_RND      —— 地址自动向下调整为 SHMLBA 的整数倍
// 返回值:成功返回指向共享内存第一个字节的指针,失败返回 -1
// 成员 nattach 表示当前已挂接数

分离(detach)——将共享内存段与当前进程脱离:

int shmdt(const void *shmaddr);
// 参数:shmat 所返回的指针
// 返回值:成功返回 0,失败返回 -1

⚠️ 将共享内存段与当前进程脱离不等于删除共享内存段!分离只是断开了本进程的映射关系。

控制——删除、查属性、设属性:

int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// 参数 cmd:将要采取的动作
// 参数 buf:指向保存共享内存模式状态和访问权限的数据结构(输出型参数,
//           删除等操作不需要读取属性时可以传 NULL)
// 返回值:成功返回 0,失败返回 -1
cmd作用
IPC_STAT查看属性
IPC_RMID删除(即便仍有进程挂接,依旧删除)
IPC_SET设置属性/权限

4.5 完整实例:server 与 client 通信

先封装公共接口(comm.c):

// comm.h
#ifndef _COMM_H_
#define _COMM_H_
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define PATHNAME "."
#define PROJ_ID 0x6666
int createShm(int size);   // 创建全新的共享内存(server 用)
int destroyShm(int shmid);
int getShm(int size);      // 获取已存在的共享内存(client 用)
#endif

// comm.c
static int commShm(int size, int flags)
{
    key_t key = ftok(PATHNAME, PROJ_ID);
    if (key < 0) { perror("ftok"); return -1; }

    int shmid = 0;
    if ((shmid = shmget(key, size, flags)) < 0) {
        perror("shmget"); return -2;
    }
    return shmid;
}

int destroyShm(int shmid)
{
    if (shmctl(shmid, IPC_RMID, NULL) < 0) {
        perror("shmctl"); return -1;
    }
    return 0;
}

int createShm(int size) { return commShm(size, IPC_CREAT|IPC_EXCL|0666); }
int getShm(int size)    { return commShm(size, IPC_CREAT); }

server 端(读方):

int main()
{
    int shmid = createShm(4096);          // 建议创建 PAGE(4096) 的整数倍
    char *addr = shmat(shmid, NULL, 0);   // 挂接
    int i = 0;
    while (i++ < 26) {
        printf("client# %s\n", addr);     // 直接读地址,像读自己的内存一样
        sleep(1);
    }
    shmdt(addr);                          // 分离
    destroyShm(shmid);                    // 删除
    return 0;
}

client 端(写方):

int main()
{
    int shmid = getShm(4096);
    char *addr = shmat(shmid, NULL, 0);
    int i = 0;
    while (i < 26) {
        addr[i] = 'A' + i;    // 直接写地址!没有任何系统调用
        i++;
        addr[i] = 0;
        sleep(1);
    }
    shmdt(addr);
    return 0;
}

4.6 两个命令 + 一个重要认知

$ ipcs -m       # 查看共享内存(ipcs 不带选项则查看所有 IPC 资源)
------ Shared Memory Segments --------
key        shmid      owner      perms      bytes      nattch     status
0x66026a25 688145     root       666        4096       0

$ ipcrm -m 688145   # 删除共享内存,需要的是 shmid(不是 key!)

⚠️ 重要认知:共享内存的生命周期随内核!
进程结束,如果没有删除共享内存资源,它会一直存在。上面 shmget: File exists 报错,就是上次进程异常退出没清理导致的。删除方式有两种:命令行 ipcrm,或者代码里 shmctl(shmid, IPC_RMID, NULL)——删除 IPC 资源是进程自己该做的事情。消息队列、信号量同理:IPC 资源必须删除,否则不会自动清除,除非重启,所以 System V IPC 资源的生命周期随内核。

💡 大小的小细节: 共享内存在创建的时候,它的大小最好是 4KB(4096,即 PAGE 大小)的整数倍。内核实际会向上取整,比如申请 4097,内核会给你 8192——多出来的部分被浪费了。

4.7 共享内存为什么是最快的 IPC?又快在哪里、缺在哪里?

共享内存区是最快的 IPC 形式,原因有二:

  1. 映射完成后,读写能立即被对方看到——数据不经过内核中转;
  2. 读写无需系统调用——像 malloc 拿到地址后直接用一样,管道的读写却每次都要进入内核。

对比一下:管道的缓冲区属于内核,每次 read/write 都是一次系统调用;而共享内存挂接在进程的用户空间共享区,直接用地址访问即可。

⚠️ 但天下没有免费的午餐:共享内存没有同步与互斥机制!它缺乏访问控制,会带来并发问题。
通信双方对"对方写没写完"毫无感知,一边还在写、另一边已经开始读,数据就不一致了。

4.8 补救方案:用命名管道给共享内存加"访问控制"

思路:在 A、B 进程之间额外设计一条命名管道,发送方写好数据后向管道写一个通知,接收方收到通知再读共享内存。管道自带阻塞语义(没数据 read 就阻塞),天然实现了"顺序性":

// 发送方:写完共享内存后,向 FIFO 写一个整数通知
int fd = open(FIFO_NAME, O_WRONLY);
read(0, shmaddr, SHM_SIZE - 1);   // 读用户输入到共享内存
uint32_t temp = 1;
write(fd, &temp, sizeof(temp));   // Signal:唤醒接收方

// 接收方:先等通知,再读共享内存
int fd = open(FIFO_NAME, O_RDONLY);
uint32_t temp = 0;
read(fd, &temp, sizeof(temp));    // Wait:阻塞,直到发送方通知
printf("%s\n", shmaddr);          // 临界区:安全读取

💡 这里管道扮演的角色,其实就是"同步与互斥"的雏形——而这个问题的标准化解决方案,就是我们最后要讲的信号量。


五、📮 System V 消息队列

5.1 原理

消息队列的原理:将两个进程的数据块引入队列中,可实现双向传递。

结论:

  1. 消息队列提供了一个进程给另一个进程发送有类型的数据块的方法——每个数据块都有一个类型,接收进程可以按类型选择接收,这就区分了发送方与接收方;
  2. 它与共享内存很类似:同属 System V 标准。

5.2 接口

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>

int msgget(key_t key, int msgflg);
// 与 shmget 类似,成功返回标识符 msgid

// 发送
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
//          标识符        结构体指针      数据块大小

// 接收(可按类型接收)
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
//                                                 ↑ 按类型挑选数据块

// 数据块的格式:自带类型字段
struct msgbuf {
    long mtype;        /* 类型 */
    char mtext[1];     /* 内容,柔性数组,实际大小由 msgsz 决定 */
};

生命周期:随内核。查看与删除用 ipcs -q 和 ipcrm -q。

💡 消息队列的特点:数据有边界、有类型,读写天然带"先进先出"的队列语义,适合结构化的消息传递,但每次收发都要经过内核,效率不如共享内存。选学了解即可。


六、🚦 System V 信号量:保护公共资源的方案

6.1 为什么需要信号量

前面说过:共享内存需要保护机制,而信号量就是一种具体的解决方案。要理解它,得先铺垫几个多执行流(进程)并发访问的概念:

概念定义
共享资源(公共资源)多个执行流能看到的同一份资源
临界资源被保护起来的公共资源
临界区进程中访问临界资源的代码段(你的代码 = 临界区 + 非临界区)
互斥任何时刻,只允许一个执行流访问资源
同步多个执行流访问临界资源时,具有一定的顺序性
原子性要么做,要么不做,没有中间状态

💡 一句话串联: 对共享资源进行保护,本质是对访问共享资源的代码(临界区)进行保护。保护的常见方式就是互斥与同步,而互斥 ⇌ 互斥资源。

┌──────────┐
│ 非临界区  │
├──────────┤  ← 加锁(申请锁时必须是原子的!)
│  临界区   │
├──────────┤  ← 解锁
│ 非临界区  │
└──────────┘

🔍 一个尖锐的问题: 锁本身也是多个执行流共享的,那谁来保护锁的安全?——所以申请锁这个动作本身必须是原子的,这是所有保护机制的地基。

6.2 信号量是什么:电影院模型

信号量本质是一个计数器,用来表明临界资源中资源的数量多少(就像路口的信号灯,所以也叫信号灯)。

用电影院来类比:

  • 电影院的座位 = 临界资源被划分成的多个区域;
  • 买票 = 对资源的预订机制 = 申请信号量;
  • 票数有限 ⇒ 不要让多余的人(进程)进来(计数器减到 0 就进不去了);
  • 一人一座 ⇒ 不要重复访问同一位置(票和座位一一对应);
  • 效果:把共享内存分区、分区使用,多个进程并发访问且不出问题!

操作方面,信号量只有两种操作:

操作计数器变化含义
P 操作申请资源,计数器 --原子性
V 操作释放资源,计数器 ++原子性

两个重要细节:

  1. 信号量本身就是共享资源——所有进程都要先"看到同一个信号量"才能协同。既然它自己也是被并发访问的,那么 P/V 操作必须是原子的(这就是为什么内核专门实现它);
  2. 当计数器取值只有 0 或 1 时,称之为二元信号量,它等价于互斥功能——一进一出,同一时刻只有一个执行流能访问资源。

🔍 问题:信号量为什么不能是一个普通整数变量?
因为对普通整数的 ++/-- 编译后是多条指令(读内存 → 修改 → 写回),并不是原子的,并发执行时可能丢失更新。信号量的 P/V 操作由内核保证原子性,这正是它存在的意义。

6.3 信号量与通信的关系

两个关键认知:

  1. 要协同访问共享内存,每个进程必须先看到同一个信号量 → 由 System V 解决(它和共享内存、消息队列一样,也是 key 标识的公共资源);
  2. 不只有传递数据才是 IPC——通知事件、同步互斥也算通信!

6.4 接口

#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>

int semget(key_t key, int nsems, int semflg);
// 创建信号量集,nsems 是信号量的个数(集合数)

int semctl(int semid, int semnum, int cmd, ...);
// 控制第 semnum 个信号量,cmd 如 SETVAL
// 可变参数对应一个联合体,里面有 val 字段,用来设置计数器的初始值

int semop(int semid, struct sembuf *sops, size_t nsops);
// 对信号量做 P/V 操作:sops 是数组,nsops 是数组个数

struct sembuf {
    unsigned short sem_num;   // 对第几个信号量操作
    short          sem_op;    // 具体操作:-1 即 P,+1 即 V
    short          sem_flg;
};

和共享内存一样:三个接口都靠 key 区分唯一性;生命周期随内核;用 ipcs -s 查看、ipcrm -s 删除。

6.5 内核是如何组织管理 IPC 资源的

一个很有意思的问题:共享内存 shmid、消息队列 msqid、信号量 semid,这三种"看起来完全不同"的资源,内核是怎么统一管理的?

答案还是那四个字:先描述,再组织。在 Linux 内核(以 2.6.x 为参考)中:

  • 所有 System V IPC 资源由一个统一的 ipc_ids 结构管理,其中的 entries 指向一个系统级指针数组;
  • 数组中存放的都是 kern_ipc_perm * 类型的指针——不管底层是 msg_queue(消息队列)、sem_array(信号量集)还是 shmid_kernel(共享内存),入口处都是同一个权限描述结构;
  • 每种资源都"自带"一个 ipc_perm 成员,通过强制类型转换,就能用同一套函数进行统一管理;
  • 而我们拿到的 xxxid(shmid/msqid/semid),实际上是数组的下标!
                 ipc_ids
                ┌─────────┐
                │ *entries │────► 系统数组(ipc_id_ary)
                └─────────┘         ┌──────────────────┐
                                    │ [0] kern_ipc_perm* ──► msg_queue     (消息队列)
                                    │ [1] kern_ipc_perm* ──► sem_array     (信号量集)
                                    │ [2] kern_ipc_perm* ──► shmid_kernel  (共享内存)
                                    │ ...                  指针类型统一
                                    └──────────────────┘

💡 顺便一提: 这种"入口结构统一 + 按实际类型强转分发"的手法,正是 C 语言实现多态的经典方式——面向对象的多态思想,在内核里随处可见。


七、📊 全文总结:五种 IPC 横向对比

方案数据形态速度同步互斥生命周期适用场景
匿名管道字节流(流式服务)中✅ 内核提供随进程有亲缘关系的进程间单向通信
命名管道字节流中✅ 内核提供随进程任意进程间通信,也可给共享内存做访问控制
共享内存原始内存,直接读写✅ 最快❌ 完全没有随内核大数据量、高频交互
消息队列有类型的数据块中✅ 队列语义随内核结构化消息传递
信号量不传数据,传"状态"—✅ 它就是干这个的随内核同步与互斥、保护临界资源

最后记住三句话:

  1. IPC 的本质是让不同进程看到同一份资源,资源由 OS 提供,一切通过系统调用完成;
  2. 共享内存最快但最"裸"——没有同步互斥,需要配合管道或信号量做访问控制;
  3. System V IPC 资源的生命周期随内核——用完必须由进程自己删除,ipcs 查一查,别让内存"泄漏"在内核里。

八、🤔 核心思考题

  1. 为什么匿名管道只能用于有亲缘关系的进程?
    因为管道是 fork 前创建的,靠子进程继承文件描述符表才能指向同一根管道;没有亲缘关系的进程拿不到对方的 fd。命名管道靠"路径名"解决这个问题。

  2. 共享内存为什么是最快的 IPC?
    映射完成后读写直接操作用户空间地址,①数据不经过内核中转,②无需 read/write 系统调用,省去了用户态与内核态的切换开销。

  3. 信号量为什么不能是一个普通整数?
    ++/-- 不是原子操作(读-改-写三条指令),并发执行会丢失更新。信号量的 P/V 操作由内核保证原子性。

  4. shmid 和 fd 有什么异同?
    二者都是"标识符",但 fd 是进程私有的,shmid 是全局的;文件名与 key 类似——文件名是现成的,key 需要通过 ftok 创建,且 key 只服务于内核层的唯一性区分,用户层操作只用 shmid。

  5. 为什么 System V IPC 资源的生命周期随内核?
    它们是内核维护的全局资源(ipc_ids 数组统一管理),进程退出不会自动回收,必须显式删除(shmctl/msgctl/semctl 或 ipcrm 命令),除非重启。


本节完

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐