Linux IPC 详解:匿名管道、命名管道、共享内存与信号量


文章目录

  • 引言
  • [1. 为什么需要进程间通信](#1. 为什么需要进程间通信)
    • [1.1 进程独立性带来的问题](#1.1 进程独立性带来的问题)
    • [1.2 进程间通信的目的](#1.2 进程间通信的目的)
    • [1.3 常见 IPC 分类](#1.3 常见 IPC 分类)
  • [2. 管道:最容易入门的进程间通信方式](#2. 管道:最容易入门的进程间通信方式)
    • [2.1 什么是管道](#2.1 什么是管道)
    • [2.2 匿名管道 pipe](#2.2 匿名管道 pipe)
    • [2.3 最简单的 pipe 读写代码](#2.3 最简单的 pipe 读写代码)
    • [2.4 为什么 pipe 经常要配合 fork 使用](#2.4 为什么 pipe 经常要配合 fork 使用)
    • [2.5 站在文件描述符角度理解管道](#2.5 站在文件描述符角度理解管道)
  • [3. 用管道实现一个简单进程池](#3. 用管道实现一个简单进程池)
    • [3.1 为什么进程池也能和管道联系起来](#3.1 为什么进程池也能和管道联系起来)
    • [3.2 Channel:描述父进程到子进程的通信通道](#3.2 Channel:描述父进程到子进程的通信通道)
    • [3.3 TaskManager:任务表](#3.3 TaskManager:任务表)
    • [3.4 Worker:子进程为什么从标准输入读任务](#3.4 Worker:子进程为什么从标准输入读任务)
    • [3.5 初始化进程池的核心逻辑](#3.5 初始化进程池的核心逻辑)
    • [3.6 DispatchTask:父进程轮询派发任务](#3.6 DispatchTask:父进程轮询派发任务)
    • [3.7 CleanProcessPool:为什么关闭管道写端可以让子进程退出](#3.7 CleanProcessPool:为什么关闭管道写端可以让子进程退出)
  • [4. 管道读写规则和容易踩的坑](#4. 管道读写规则和容易踩的坑)
    • [4.1 没有数据可读时](#4.1 没有数据可读时)
    • [4.2 管道满的时候](#4.2 管道满的时候)
    • [4.3 写端关闭时 read 返回 0](#4.3 写端关闭时 read 返回 0)
    • [4.4 读端关闭时 write 可能触发 SIGPIPE](#4.4 读端关闭时 write 可能触发 SIGPIPE)
    • [4.5 PIPE_BUF 和原子性](#4.5 PIPE_BUF 和原子性)
    • [4.6 管道的特点总结](#4.6 管道的特点总结)
  • [5. 命名管道 FIFO:让不相关进程也能通信](#5. 命名管道 FIFO:让不相关进程也能通信)
    • [5.1 匿名管道的限制](#5.1 匿名管道的限制)
    • [5.2 创建命名管道](#5.2 创建命名管道)
    • [5.3 匿名管道和命名管道的区别](#5.3 匿名管道和命名管道的区别)
    • [5.4 命名管道的打开规则](#5.4 命名管道的打开规则)
  • [6. 用命名管道实现文件拷贝](#6. 用命名管道实现文件拷贝)
    • [6.1 写端:读取普通文件,写入命名管道](#6.1 写端:读取普通文件,写入命名管道)
    • [6.2 读端:读取命名管道,写入目标文件](#6.2 读端:读取命名管道,写入目标文件)
  • [7. 用命名管道实现 server 和 client 通信](#7. 用命名管道实现 server 和 client 通信)
    • [7.1 Makefile](#7.1 Makefile)
    • [7.2 serverPipe.c](#7.2 serverPipe.c)
    • [7.3 clientPipe.c](#7.3 clientPipe.c)
  • [8. System V 共享内存:最快的 IPC 形式](#8. System V 共享内存:最快的 IPC 形式)
    • [8.1 共享内存为什么快](#8.1 共享内存为什么快)
    • [8.2 共享内存的数据结构](#8.2 共享内存的数据结构)
    • [8.3 共享内存的核心函数](#8.3 共享内存的核心函数)
      • [8.3.1 shmget:创建或获取共享内存](#8.3.1 shmget:创建或获取共享内存)
      • [8.3.2 shmat:挂接共享内存](#8.3.2 shmat:挂接共享内存)
      • [8.3.3 shmdt:去关联共享内存](#8.3.3 shmdt:去关联共享内存)
      • [8.3.4 shmctl:控制共享内存](#8.3.4 shmctl:控制共享内存)
  • [9. 共享内存通信代码分析](#9. 共享内存通信代码分析)
    • [9.1 comm.h](#9.1 comm.h)
    • [9.2 comm.c](#9.2 comm.c)
    • [9.3 server.c](#9.3 server.c)
    • [9.4 client.c](#9.4 client.c)
    • [9.5 共享内存的资源残留问题](#9.5 共享内存的资源残留问题)
  • [10. 共享内存为什么需要访问控制](#10. 共享内存为什么需要访问控制)
  • [11. System V 消息队列和信号量](#11. System V 消息队列和信号量)
    • [11.1 消息队列的基本理解](#11.1 消息队列的基本理解)
    • [11.2 并发编程中的共享资源、临界资源和临界区](#11.2 并发编程中的共享资源、临界资源和临界区)
    • [11.3 信号量是什么](#11.3 信号量是什么)
    • [11.4 信号量和共享内存的关系](#11.4 信号量和共享内存的关系)
  • [12. 内核如何管理 IPC 资源](#12. 内核如何管理 IPC 资源)
    • [12.1 IPC 资源不是普通用户变量](#12.1 IPC 资源不是普通用户变量)
    • [12.2 key 和 id 的区别](#12.2 key 和 id 的区别)
    • [12.3 内核管理 IPC 的统一思路](#12.3 内核管理 IPC 的统一思路)
    • [12.4 C 语言如何体现"多态"思想](#12.4 C 语言如何体现“多态”思想)
  • [13. minishell 中管道实现思路](#13. minishell 中管道实现思路)
    • [13.1 为什么 shell 管道值得单独看](#13.1 为什么 shell 管道值得单独看)
    • [13.2 命令输入和解析](#13.2 命令输入和解析)
    • [13.3 解析管道符](#13.3 解析管道符)
    • [13.4 执行管道](#13.4 执行管道)
  • 结语
  • [高频技术题 / 面试题](#高频技术题 / 面试题)
    • [1. 什么是进程间通信?为什么需要 IPC?](#1. 什么是进程间通信?为什么需要 IPC?)
    • [2. 匿名管道为什么通常用于父子进程?](#2. 匿名管道为什么通常用于父子进程?)
    • [3. pipe 函数中 fd[0] 和 fd[1] 分别表示什么?](#3. pipe 函数中 fd[0] 和 fd[1] 分别表示什么?)
    • [4. 管道为什么也能用 read 和 write?](#4. 管道为什么也能用 read 和 write?)
    • [5. 为什么管道通信时要关闭不用的一端?](#5. 为什么管道通信时要关闭不用的一端?)
    • [6. 管道中 read 返回 0 表示什么?](#6. 管道中 read 返回 0 表示什么?)
    • [7. 如果管道所有读端都关闭,写端继续 write 会怎样?](#7. 如果管道所有读端都关闭,写端继续 write 会怎样?)
    • [8. 匿名管道和命名管道有什么区别?](#8. 匿名管道和命名管道有什么区别?)
    • [9. 命名管道以写方式打开时为什么可能阻塞?](#9. 命名管道以写方式打开时为什么可能阻塞?)
    • [10. 为什么共享内存是最快的 IPC 形式?](#10. 为什么共享内存是最快的 IPC 形式?)
    • [11. shmat 和 shmdt 的作用分别是什么?](#11. shmat 和 shmdt 的作用分别是什么?)
    • [12. 为什么共享内存资源可能会残留?](#12. 为什么共享内存资源可能会残留?)
    • [13. 共享内存为什么需要信号量或其他同步机制?](#13. 共享内存为什么需要信号量或其他同步机制?)
    • [14. 什么是临界资源和临界区?](#14. 什么是临界资源和临界区?)
    • [15. 信号量的本质是什么?](#15. 信号量的本质是什么?)
    • [16. 管道和消息队列有什么区别?](#16. 管道和消息队列有什么区别?)
    • [17. key 和 shmid 有什么区别?](#17. key 和 shmid 有什么区别?)
    • [18. shell 中 `cmd1 | cmd2` 底层大致如何实现?](#18. shell 中 cmd1 | cmd2 底层大致如何实现?)

引言

刚开始学进程的时候,我一直有一个很自然的误解:既然每个进程都有自己的地址空间,那进程之间是不是就"互不打扰",各干各的就行了?

后来真正写到 forkpipe、共享内存这些东西的时候,我才发现:进程独立只是操作系统保护资源的一种方式,但真实的程序世界里,进程之间经常需要配合。比如一个进程负责读数据,一个进程负责处理数据,一个进程负责输出结果;再比如 shell 里面的 who | wc -l,表面上只是一个命令,底层其实就是两个进程通过管道在通信。

学到这里我才意识到,进程间通信并不是单纯记几个 API,而是要理解几个问题:

进程为什么不能直接互相访问?

内核为什么要参与通信?

管道为什么也能像文件一样读写?

共享内存为什么快,但又容易出问题?

信号量为什么本质上是在"预订资源"?

这篇博客就按我自己的学习过程,把 Linux 进程间通信这块重新梳理一遍。重点不是死背概念,而是尽量从文件描述符、内核资源、进程地址空间、同步互斥这些角度,把 IPC 的底层逻辑串起来。


1. 为什么需要进程间通信

1.1 进程独立性带来的问题

进程是操作系统进行资源分配的基本单位。每个进程都有自己的虚拟地址空间,这样可以保证一个进程崩了,不会轻易影响另一个进程。

但是问题也来了:进程之间默认是相互独立的。

一个进程中的变量,另一个进程不能直接访问;一个进程中的地址,在另一个进程里也没有相同含义。这样虽然安全,但如果进程之间要协作,就必须借助操作系统提供的通信机制。

💡进程之间的关系有点像宿舍里的几个人,每个人都有自己的柜子。你不能直接翻别人的柜子,但如果要交换东西,可以通过公共桌子、门缝传纸条,或者约定一个管理员来转交。这个"公共桌子"或者"管理员",在 Linux 里就可以理解成各种 IPC 机制。

1.2 进程间通信的目的

我理解进程间通信主要有四类目的。

第一类是数据传输。一个进程把数据发给另一个进程,比如命令行中前一个命令的输出,成为后一个命令的输入。

第二类是资源共享。多个进程需要看到同一份资源,比如共享内存。

第三类是通知事件。一个进程需要通知另一个进程某件事发生了,比如子进程退出后通知父进程。

第四类是进程控制。比如调试器控制被调试进程,能够感知它的状态变化。

刚开始我以为 IPC 只是"传数据",后来才发现它还和同步、互斥、资源生命周期、内核对象管理有关。

1.3 常见 IPC 分类

Linux 下常见的 IPC 可以大致分成三类。

第一类是管道:

  • 匿名管道 pipe
  • 命名管道 FIFO

第二类是 System V IPC:

  • System V 消息队列
  • System V 共享内存
  • System V 信号量

第三类是 POSIX IPC:

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

这里我重点整理匿名管道、命名管道、共享内存、消息队列、信号量,以及内核如何管理这些 IPC 资源。

2. 管道:最容易入门的进程间通信方式

2.1 什么是管道

管道是 Unix 中比较古老的一种 IPC 形式。

从表面上看,管道就是从一个进程连接到另一个进程的一条数据流。一个进程往管道里写,另一个进程从管道里读。

最经典的例子就是:

bash 复制代码
who | wc -l

who 进程把结果写入管道,wc -l 进程从管道中读取数据,然后统计行数。

这里有一个很关键的点:管道不是普通变量,也不是两个进程直接共享了一块用户空间内存。管道本质上是由内核维护的一段缓冲区。

💡管道可以想象成食堂打饭窗口旁边的传送槽。A 同学把饭盒放进槽里,B 同学从另一头拿出来。A 和 B 没有直接见面,但他们通过这个公共通道完成了传递。

2.2 匿名管道 pipe

匿名管道通过 pipe 函数创建。

c 复制代码
#include <unistd.h>

int pipe(int fd[2]);

参数:

  • fd 是一个文件描述符数组
  • fd[0] 表示读端
  • fd[1] 表示写端

返回值:

  • 成功返回 0
  • 失败返回 -1

我一开始看到 fd[0]fd[1] 时,感觉它们只是两个普通数字。后来结合文件描述符表之后才明白,数字只是索引,真正指向的是内核中的文件对象。管道之所以能用 readwrite 操作,是因为 Linux 把管道也抽象成了文件。

2.3 最简单的 pipe 读写代码

下面这段代码完成的事情是:从标准输入读取数据,写入管道,再从管道读出,最后写到标准输出。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
    int fds[2];
    char buf[100];
    int len;

    if (pipe(fds) == -1)
        perror("make pipe"), exit(1);

    while (fgets(buf, 100, stdin)) {
        len = strlen(buf);

        if (write(fds[1], buf, len) != len) {
            perror("write to pipe");
            break;
        }

        memset(buf, 0x00, sizeof(buf));

        if ((len = read(fds[0], buf, 100)) == -1) {
            perror("read from pipe");
            break;
        }

        if (write(1, buf, len) != len) {
            perror("write to stdout");
            break;
        }
    }

    return 0;
}

这段代码虽然简单,但里面已经把管道的基本使用流程体现出来了:

先用 pipe(fds) 创建管道。

然后用 write(fds[1], buf, len) 往写端写数据。

再用 read(fds[0], buf, 100) 从读端读数据。

最后用 write(1, buf, len) 输出到标准输出。

这里的 1 是标准输出的文件描述符,也就是 stdout 对应的底层 fd。

💡fd[0]fd[1] 有点像快递柜的两个口:一个只能取件,一个只能放件。你不能从放件口取东西,也不能往取件口塞东西。

2.4 为什么 pipe 经常要配合 fork 使用

上面那段代码其实是在同一个进程里自写自读,只是为了验证管道能工作。真正有意义的管道通信,通常发生在父子进程之间。

原因是匿名管道没有名字,它不能像普通文件一样通过路径找到。它依赖 fork 后父子进程共享打开的文件描述符。

大致过程是:

  1. 父进程先调用 pipe 创建管道
  2. 父进程再调用 fork
  3. 子进程继承父进程的文件描述符表
  4. 父子进程分别关闭不需要的一端
  5. 一个进程写,一个进程读
c 复制代码
#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 = 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;
}

这段代码中,子进程关闭读端 pipefd[0],只负责写;父进程关闭写端 pipefd[1],只负责读。

这里有一个非常重要的细节:不用的一端一定要关闭。

如果父进程读,但写端一直没有全部关闭,父进程可能会一直认为未来还可能有数据到来,从而导致读阻塞。这个问题在管道代码里特别常见。

2.5 站在文件描述符角度理解管道

管道最容易理解错的地方,是把 fd[0]fd[1] 当成管道本身。

实际上,fd 只是进程文件描述符表中的下标。真正的管道缓冲区在内核中。文件描述符指向 struct filestruct file 再关联到管道相关的内核对象。

所以,从文件描述符角度看:

  • fd[0] 指向管道读端
  • fd[1] 指向管道写端
  • 父子进程通过继承 fd,间接访问同一个内核管道对象

这也是 Linux "一切皆文件"思想的体现。管道虽然不是磁盘上的普通文件,但是它可以被 readwriteclose 操作。

💡文件描述符就像酒店房卡上的房间号。房间号不是房间本身,但你可以通过它找到对应的房间。fd 也不是文件或管道本身,它只是进程访问内核对象的入口。

3. 用管道实现一个简单进程池

3.1 为什么进程池也能和管道联系起来

刚开始看到进程池代码的时候,我觉得它和管道好像是两件事。后来才发现,进程池的核心问题其实就是:父进程如何给子进程派发任务。

如果父进程提前创建多个子进程,每个子进程等待任务,那么父进程只需要通过某种 IPC 方式告诉子进程:"你该执行第几个任务了"。

这里就可以用管道。

父进程给每个子进程维护一个写端,子进程把读端重定向到标准输入。父进程写入任务编号,子进程读取任务编号,然后执行对应任务。

3.2 Channel:描述父进程到子进程的通信通道

cpp 复制代码
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__

#include <iostream>
#include <string>
#include <unistd.h>

class Channel
{
public:
    Channel(int wfd, pid_t who)
        : _wfd(wfd), _who(who)
    {
        _name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
    }

    std::string Name()
    {
        return _name;
    }

    void Send(int cmd)
    {
        ::write(_wfd, &cmd, sizeof(cmd));
    }

    void Close()
    {
        ::close(_wfd);
    }

    pid_t Id()
    {
        return _who;
    }

    int wFd()
    {
        return _wfd;
    }

    ~Channel()
    {}

private:
    int _wfd;
    std::string _name;
    pid_t _who;
};

#endif

这个类其实就是对"父进程写端 + 子进程 pid"的封装。

_wfd 表示父进程持有的管道写端。
_who 表示这个通道对应哪个子进程。
Send(int cmd) 本质上就是向管道写入一个整数任务编号。

这里我觉得比较妙的一点是:父进程不需要知道子进程内部怎么执行任务,只需要把任务编号写过去。子进程读到编号后自己执行。

3.3 TaskManager:任务表

cpp 复制代码
#pragma once

#include <iostream>
#include <vector>
#include <functional>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>

using task_t = std::function<void()>;

class TaskManger
{
public:
    TaskManger()
    {
        srand(time(nullptr));

        tasks.push_back([]() {
            std::cout << "sub process[" << getpid()
                      << " ] 执行访问数据库的任务\n" << std::endl;
        });

        tasks.push_back([]() {
            std::cout << "sub process[" << getpid()
                      << " ] 执行url解析\n" << std::endl;
        });

        tasks.push_back([]() {
            std::cout << "sub process[" << getpid()
                      << " ] 执行加密任务\n" << std::endl;
        });

        tasks.push_back([]() {
            std::cout << "sub process[" << getpid()
                      << " ] 执行数据持久化任务\n" << std::endl;
        });
    }

    int SelectTask()
    {
        return rand() % tasks.size();
    }

    void Excute(unsigned long number)
    {
        if (number >= tasks.size())
            return;

        tasks[number]();
    }

    ~TaskManger()
    {}

private:
    std::vector<task_t> tasks;
};

TaskManger tm;

这里 tasks 是一个函数对象数组。每个任务都是一个 std::function<void()>

父进程发送的是整数,子进程拿到整数后调用:

cpp 复制代码
tm.Excute(cmd);

这就相当于通过任务编号选择具体任务。

3.4 Worker:子进程为什么从标准输入读任务

cpp 复制代码
void Worker()
{
    while (true)
    {
        int cmd = 0;
        int n = ::read(0, &cmd, sizeof(cmd));

        if (n == sizeof(cmd))
        {
            tm.Excute(cmd);
        }
        else if (n == 0)
        {
            std::cout << "pid: " << getpid() << " quit..." << std::endl;
            break;
        }
        else
        {
        }
    }
}

这里最关键的是 read(0, &cmd, sizeof(cmd))

0 是标准输入,但是在子进程创建时,会通过 dup2(pipefd[0], 0) 把管道读端重定向到标准输入。

所以 Worker 表面上从标准输入读,实际上是从管道读。

这个设计让我感觉很有意思:只要把 fd 重定向好,程序内部就可以用统一的读写方式,不需要关心底层到底是键盘、文件还是管道。

💡这有点像学校里统一用校园卡消费。你刷卡买饭、进宿舍、借书,动作都是"刷卡",但背后连接的系统不一样。Linux 里的 fd 也有类似的统一入口效果。

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

cpp 复制代码
int InitProcessPool()
{
    for (int i = 0; i < processnum; i++)
    {
        int pipefd[2] = {0};
        int n = pipe(pipefd);

        if (n < 0)
            return PipeError;

        pid_t id = fork();

        if (id < 0)
            return ForkError;

        if (id == 0)
        {
            std::cout << getpid() << ", child close history fd: ";

            for (auto &c : channels)
            {
                std::cout << c.wFd() << " ";
                c.Close();
            }

            std::cout << " over" << std::endl;

            ::close(pipefd[1]);

            std::cout << "debug: " << pipefd[0] << std::endl;

            dup2(pipefd[0], 0);
            work();
            ::exit(0);
        }

        ::close(pipefd[0]);
        channels.emplace_back(pipefd[1], id);
    }

    return OK;
}

这段代码里面有几个重点。

第一,必须先 pipefork

因为匿名管道要靠父子进程继承 fd。

第二,子进程要关闭历史写端。

如果不关闭历史 fd,可能会导致某些管道写端没有真正关闭,后面父进程等待子进程退出时出现阻塞。

第三,子进程关闭当前管道写端,只保留读端。

子进程负责读任务,不需要写。

第四,父进程关闭当前管道读端,只保留写端。

父进程负责派发任务,不需要读。

第五,dup2(pipefd[0], 0) 把管道读端重定向到标准输入。

这样 Worker 统一从 fd=0 读取任务。

3.6 DispatchTask:父进程轮询派发任务

cpp 复制代码
void DispatchTask()
{
    int who = 0;
    int num = 20;

    while (num--)
    {
        int task = tm.SelectTask();

        Channel &curr = channels[who++];
        who %= channels.size();

        std::cout << "######################" << std::endl;
        std::cout << "send " << task << " to "
                  << curr.Name()
                  << ", 任务还剩: " << num << std::endl;
        std::cout << "######################" << std::endl;

        curr.Send(task);

        sleep(1);
    }
}

这里的调度策略很简单,就是轮询。

who 表示这次选择哪个子进程。
who %= channels.size() 保证下标循环。
curr.Send(task) 本质上就是向对应子进程的管道写端写入任务编号。

这让我理解了一个点:进程池的复杂度不一定一开始就来自算法,有时候核心只是"主进程如何稳定地把任务分发给多个工作进程"。

3.7 CleanProcessPool:为什么关闭管道写端可以让子进程退出

cpp 复制代码
void CleanProcessPool()
{
    for (auto &c : channels)
    {
        c.Close();

        pid_t rid = ::waitpid(c.Id(), nullptr, 0);

        if (rid > 0)
        {
            std::cout << "child " << rid
                      << " wait ... success" << std::endl;
        }
    }
}

这里先关闭父进程持有的写端。

当某个管道的所有写端都关闭后,子进程继续 read 时会返回 0。Worker 中判断到 n == 0,就会退出循环。

这就是管道读写规则在实际项目里的应用。

如果写端没有关闭干净,子进程可能一直阻塞在 read,父进程再 waitpid 就可能一直等不到子进程退出。

4. 管道读写规则和容易踩的坑

4.1 没有数据可读时

如果管道中没有数据,read 的行为和是否设置非阻塞有关。

当没有设置 O_NONBLOCK 时,read 会阻塞,进程暂停执行,一直等到有数据到来。

当设置了 O_NONBLOCK 时,read 会返回 -1,并且 errnoEAGAIN

所以阻塞和非阻塞最大的区别是:阻塞会等,非阻塞不会等。

💡阻塞 read 就像你在快递站等快递,没到就一直等;非阻塞 read 像你去问了一下,没到就马上回宿舍,不会一直站在那里。

4.2 管道满的时候

如果管道缓冲区已经满了,继续写也会受到阻塞模式影响。

没有设置 O_NONBLOCK 时,write 会阻塞,直到有进程读走数据。

设置 O_NONBLOCK 时,write 会返回 -1,并且 errnoEAGAIN

这说明管道不是无限大的。它是内核中的缓冲区,有容量限制。

4.3 写端关闭时 read 返回 0

如果所有管道写端对应的文件描述符都被关闭,那么读端再 read 会返回 0

这个规则在进程池退出时特别重要。父进程关闭写端,子进程读到 0,就知道没有任务了,可以退出。

4.4 读端关闭时 write 可能触发 SIGPIPE

如果所有读端都关闭了,写端继续 write,会产生 SIGPIPE 信号,进而可能导致写进程退出。

这个场景也很常见:接收方已经不读了,发送方还在写。

4.5 PIPE_BUF 和原子性

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

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

这里的原子性可以理解为:一次写入不会被其他进程写入的数据插队打散。

💡几个人同时往一个收纳箱里放小纸条,如果每张纸条都很小,就能保证一张纸条完整放进去;但如果有人搬一大箱资料进去,中途就可能和别人的东西混在一起。

4.6 管道的特点总结

匿名管道有几个重要特点:

第一,只能用于具有共同祖先的进程之间通信,通常是父子进程。

第二,管道提供的是流式服务。读出来的是字节流,而不是一条一条天然分隔好的消息。

第三,管道生命周期一般随进程。进程退出后,相关管道资源会释放。

第四,内核会对管道操作进行同步与互斥。

第五,管道是半双工的,数据只能向一个方向流动。如果双方都要互相发送数据,需要两个管道。

5. 命名管道 FIFO:让不相关进程也能通信

5.1 匿名管道的限制

匿名管道最大的问题是没有名字。

它适合父子进程,因为子进程可以继承父进程创建好的文件描述符。但是如果两个进程没有亲缘关系,它们没办法直接拿到同一个匿名管道的 fd。

这时候就需要命名管道。

命名管道也叫 FIFO,是一种特殊类型的文件。它可以出现在文件系统中,所以不相关的进程可以通过同一个路径打开它。

5.2 创建命名管道

可以用命令创建:

bash 复制代码
mkfifo filename

也可以在程序里创建:

c 复制代码
int mkfifo(const char *filename, mode_t mode);

简单示例:

c 复制代码
#include <sys/types.h>
#include <sys/stat.h>

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

这里 p2 是命名管道的路径,0644 是权限。

💡匿名管道像临时拉了一根只有父子进程知道的线;命名管道像宿舍楼公共留言箱,只要知道位置,不同房间的人也可以通过它传东西。

5.3 匿名管道和命名管道的区别

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

命名管道由 mkfifo 创建,再用 open 打开。

它们最大的区别在创建和打开方式不同。一旦创建和打开完成,读写语义基本相同。

5.4 命名管道的打开规则

如果以读方式打开 FIFO:

  • 阻塞模式下,会一直阻塞,直到有进程以写方式打开该 FIFO
  • 非阻塞模式下,会立刻返回成功

如果以写方式打开 FIFO:

  • 阻塞模式下,会一直阻塞,直到有进程以读方式打开该 FIFO
  • 非阻塞模式下,会立刻失败,错误码为 ENXIO

这个规则一开始很容易忽略。尤其是只启动写端,不启动读端时,程序可能卡在 open

6. 用命名管道实现文件拷贝

6.1 写端:读取普通文件,写入命名管道

c 复制代码
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

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

int main(int argc, char *argv[])
{
    mkfifo("tp", 0644);

    int infd = open("abc", O_RDONLY);
    if (infd == -1)
        ERR_EXIT("open");

    int outfd = open("tp", O_WRONLY);
    if (outfd == -1)
        ERR_EXIT("open");

    char buf[1024];
    int n;

    while ((n = read(infd, buf, 1024)) > 0)
    {
        write(outfd, buf, n);
    }

    close(infd);
    close(outfd);

    return 0;
}

这段代码的流程是:

先创建命名管道 tp

然后打开源文件 abc

再以写方式打开命名管道。

最后循环读取源文件内容,并写入 FIFO。

这里要注意,open("tp", O_WRONLY) 在阻塞模式下会等待读端打开。如果另一个读取程序没启动,这里可能阻塞。

6.2 读端:读取命名管道,写入目标文件

c 复制代码
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

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

int main(int argc, char *argv[])
{
    int outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (outfd == -1)
        ERR_EXIT("open");

    int infd = open("tp", O_RDONLY);
    if (infd == -1)
        ERR_EXIT("open");

    char buf[1024];
    int n;

    while ((n = read(infd, buf, 1024)) > 0)
    {
        write(outfd, buf, n);
    }

    close(infd);
    close(outfd);
    unlink("tp");

    return 0;
}

这段代码从 FIFO 中读数据,再写入 abc.bak

最后调用 unlink("tp") 删除命名管道文件。

这个例子让我对 FIFO 的理解更清楚:它虽然在文件系统中有名字,但它不是用来长期保存数据的普通文件,而是一个通信入口。

7. 用命名管道实现 server 和 client 通信

7.1 Makefile

makefile 复制代码
.PHONY:all
all:clientPipe serverPipe

clientPipe:clientPipe.c
	gcc -o $@ $^

serverPipe:serverPipe.c
	gcc -o $@ $^

.PHONY:clean
clean:
	rm -f clientPipe serverPipe

这个 Makefile 同时生成 clientPipeserverPipe

7.2 serverPipe.c

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

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

int main()
{
    umask(0);

    if (mkfifo("mypipe", 0644) < 0) {
        ERR_EXIT("mkfifo");
    }

    int rfd = open("mypipe", O_RDONLY);
    if (rfd < 0) {
        ERR_EXIT("open");
    }

    char buf[1024];

    while (1) {
        buf[0] = 0;

        printf("Please wait...\n");

        ssize_t s = read(rfd, buf, sizeof(buf) - 1);

        if (s > 0) {
            buf[s - 1] = 0;
            printf("client say# %s\n", buf);
        }
        else if (s == 0) {
            printf("client quit, exit now!\n");
            exit(EXIT_SUCCESS);
        }
        else {
            ERR_EXIT("read");
        }
    }

    close(rfd);

    return 0;
}

server 端主要做三件事:

第一,创建 FIFO。

第二,以只读方式打开 FIFO。

第三,循环读取 client 写入的数据。

这里 s == 0 表示写端关闭了,也就是 client 退出了。

7.3 clientPipe.c

c 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

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

int main()
{
    int wfd = open("mypipe", O_WRONLY);

    if (wfd < 0) {
        ERR_EXIT("open");
    }

    char buf[1024];

    while (1) {
        buf[0] = 0;

        printf("Please Enter# ");
        fflush(stdout);

        ssize_t s = read(0, buf, sizeof(buf) - 1);

        if (s > 0) {
            buf[s] = 0;
            write(wfd, buf, strlen(buf));
        }
        else if (s <= 0) {
            ERR_EXIT("read");
        }
    }

    close(wfd);

    return 0;
}

client 端打开同一个 FIFO 的写端,然后从标准输入读取用户输入,再写入 FIFO。

这个例子很适合用来理解 server 和 client 的基本通信模式:server 等待,client 输入,FIFO 负责传递。

8. System V 共享内存:最快的 IPC 形式

8.1 共享内存为什么快

共享内存是 System V IPC 中很重要的一种方式,也是速度非常快的一种 IPC。

它的核心思想是:让多个进程把同一块物理内存映射到各自的虚拟地址空间中。

这样一来,进程之间传递数据时,不需要每次都通过 readwrite 陷入内核拷贝数据。进程可以直接像访问普通内存一样访问共享区域。

刚开始我觉得"共享内存快"只是因为名字听起来高级,后来理解地址空间映射后才明白:它快在减少了数据拷贝和频繁系统调用。

💡管道像两个人通过快递传东西,每次都要经过中转站;共享内存像两个人共同使用一个白板,一个人写上去,另一个人直接看。白板很快,但如果两个人同时乱写,就会出现覆盖和混乱。

8.2 共享内存的数据结构

共享内存在内核中也需要被管理。它对应的数据结构中会记录权限、大小、创建者、最近操作进程、挂接数量等信息。

c 复制代码
struct shmid_ds {
    struct ipc_perm shm_perm;       /* operation perms */
    int shm_segsz;                  /* size of segment (bytes) */
    __kernel_time_t shm_atime;      /* last attach time */
    __kernel_time_t shm_dtime;      /* last detach time */
    __kernel_time_t shm_ctime;      /* last change time */
    __kernel_ipc_pid_t shm_cpid;    /* pid of creator */
    __kernel_ipc_pid_t shm_lpid;    /* pid of last operator */
    unsigned short shm_nattch;      /* no. of current attaches */
    unsigned short shm_unused;
    void *shm_unused2;
    void *shm_unused3;
};

这里我觉得比较重要的是 shm_nattch,它表示当前有多少进程挂接到了这块共享内存。

8.3 共享内存的核心函数

8.3.1 shmget:创建或获取共享内存

c 复制代码
int shmget(key_t key, size_t size, int shmflg);

参数理解:

key 表示共享内存段的名字,可以理解成内核识别 IPC 资源的关键值。
size 表示共享内存大小。
shmflg 表示权限和创建方式。

常见用法:

c 复制代码
IPC_CREAT

如果共享内存不存在,就创建;如果已经存在,就获取。

c 复制代码
IPC_CREAT | IPC_EXCL

如果共享内存不存在,就创建;如果已经存在,就出错。

返回值:

成功返回共享内存标识码 shmid,失败返回 -1

8.3.2 shmat:挂接共享内存

c 复制代码
void *shmat(int shmid, const void *shmaddr, int shmflg);

这个函数把共享内存连接到当前进程的地址空间。

shmidshmget 返回的标识码。
shmaddr 一般传 NULL,让内核自动选择地址。
shmflg 可以设置只读等属性。

成功返回共享内存起始地址,失败返回 (void*)-1

8.3.3 shmdt:去关联共享内存

c 复制代码
int shmdt(const void *shmaddr);

这个函数只是让当前进程和共享内存脱离关系。

注意:脱离不等于删除。

这是我一开始容易搞混的点。shmdt 只是"我不用了",而不是"把这块共享内存从系统中删掉"。

8.3.4 shmctl:控制共享内存

c 复制代码
int shmctl(int shmid, int cmd, struct shmid_ds *buf);

常见命令包括:

  • IPC_STAT:获取共享内存当前属性
  • IPC_SET:设置共享内存属性
  • IPC_RMID:删除共享内存

共享内存需要主动删除,否则 System V IPC 资源可能会一直存在,直到系统重启或手动清理。

【插入目的】

帮助理解共享内存为什么快,以及为什么多个进程能看到同一份数据。

9. 共享内存通信代码分析

9.1 comm.h

c 复制代码
#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);
int destroyShm(int shmid);
int getShm(int size);

#endif

这里定义了共享内存相关的封装接口。

PATHNAMEPROJ_ID 会传给 ftok,用于生成 key。

9.2 comm.c

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

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);
}

这段代码把创建和获取共享内存统一封装到了 commShm 中。

createShm 使用:

c 复制代码
IPC_CREAT | IPC_EXCL | 0666

表示必须创建一个新的共享内存,如果已经存在就失败。

getShm 使用:

c 复制代码
IPC_CREAT

表示如果存在就获取,如果不存在就创建。

这里更推荐让 server 作为创建者,client 作为获取者。因为通信双方应该有明确的资源创建和销毁责任。

9.3 server.c

c 复制代码
#include "comm.h"
#include <unistd.h>

int main()
{
    int shmid = createShm(4096);

    char *addr = shmat(shmid, NULL, 0);

    sleep(2);

    int i = 0;

    while (i++ < 26) {
        printf("client# %s\n", addr);
        sleep(1);
    }

    shmdt(addr);

    sleep(2);

    destroyShm(shmid);

    return 0;
}

server 端做了四件事:

第一,创建共享内存。

第二,挂接共享内存。

第三,循环读取共享内存中的内容。

第四,去关联并删除共享内存。

这里共享内存大小设置为 4096,刚好是常见页大小。共享内存大小最好按页对齐,这样更符合底层内存管理习惯。

9.4 client.c

c 复制代码
#include "comm.h"
#include <unistd.h>

int main()
{
    int shmid = getShm(4096);

    sleep(1);

    char *addr = shmat(shmid, NULL, 0);

    sleep(2);

    int i = 0;

    while (i < 26) {
        addr[i] = 'A' + i;
        i++;
        addr[i] = 0;
        sleep(1);
    }

    shmdt(addr);

    sleep(2);

    return 0;
}

client 端获取共享内存后,把字符逐步写进去。

server 会看到共享内存内容不断变化。

这个例子虽然能通信,但也暴露了共享内存的一个大问题:它本身不提供同步和互斥。

也就是说,client 写的时候,server 可能正在读;多个进程同时访问时,可能产生并发问题。

9.5 共享内存的资源残留问题

如果程序异常终止,可能出现这种情况:

bash 复制代码
./server
shmget: File exists

这说明共享内存资源还在。

可以使用:

bash 复制代码
ipcs -m

查看共享内存资源。

再用:

bash 复制代码
ipcrm -m shmid

删除指定共享内存。

这也说明 System V IPC 资源的生命周期和普通进程资源不完全一样。进程退出了,不代表 IPC 资源一定自动释放。

💡共享内存有点像教室里借用的一块公共白板。人走了,白板还在。如果没人负责擦掉,下次来的人可能会看到上一次留下的内容,甚至影响新的使用。

10. 共享内存为什么需要访问控制

10.1 共享内存的问题

共享内存很快,但它不自带同步机制。

如果一个进程正在写,另一个进程同时读,就可能读到不完整的数据。如果多个进程同时写,问题会更严重。

所以共享内存经常需要搭配信号量、互斥锁、管道等机制来控制访问顺序。

这里用管道辅助共享内存,本质上是让写端写完后通知读端:"我写好了,你可以读了"。

10.2 Comm.hpp

cpp 复制代码
#pragma once

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

using namespace std;

#define Debug 0
#define Notice 1
#define Warning 2
#define Error 3

const std::string msg[] = {
    "Debug",
    "Notice",
    "Warning",
    "Error"
};

std::ostream &Log(std::string message, int level)
{
    std::cout << " | " << (unsigned)time(nullptr)
              << " | " << msg[level] << " | " << message;

    return std::cout;
}

#define PATH_NAME "/home/hyb"
#define PROJ_ID 0x66
#define SHM_SIZE 4096
#define FIFO_NAME "./fifo"

class Init {
public:
    Init() {
        umask(0);
        int n = mkfifo(FIFO_NAME, 0666);
        assert(n == 0);
        (void)n;

        Log("create fifo success", Notice) << "\n";
    }

    ~Init() {
        unlink(FIFO_NAME);
        Log("remove fifo success", Notice) << "\n";
    }
};

#define READ O_RDONLY
#define WRITE O_WRONLY

int OpenFIFO(std::string pathname, int flags) {
    int fd = open(pathname.c_str(), flags);
    assert(fd >= 0);
    return fd;
}

void CloseFifo(int fd) {
    close(fd);
}

void Wait(int fd) {
    Log("等待中....", Notice) << "\n";

    uint32_t temp = 0;
    ssize_t s = read(fd, &temp, sizeof(uint32_t));

    assert(s == sizeof(uint32_t));
    (void)s;
}

void Signal(int fd) {
    uint32_t temp = 1;
    ssize_t s = write(fd, &temp, sizeof(uint32_t));

    assert(s == sizeof(uint32_t));
    (void)s;

    Log("唤醒中....", Notice) << "\n";
}

string TransToHex(key_t k) {
    char buffer[32];
    snprintf(buffer, sizeof buffer, "0x%x", k);
    return buffer;
}

这里 WaitSignal 是关键。

Wait(fd) 从 FIFO 里读一个整数,如果没有数据就阻塞。
Signal(fd) 往 FIFO 里写一个整数,用来唤醒对方。

这个设计其实已经有一点信号量的味道:通过一个额外的同步通道,控制共享内存什么时候能被读。

10.3 ShmServer.cc

cpp 复制代码
#include "Comm.hpp"

Init init;

int main() {
    key_t k = ftok(PATH_NAME, PROJ_ID);
    assert(k != -1);

    Log("create key done", Debug)
        << " server key : " << TransToHex(k) << endl;

    int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);

    if (shmid == -1) {
        perror("shmget");
        exit(1);
    }

    Log("create shm done", Debug)
        << " shmid : " << shmid << endl;

    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    Log("attach shm done", Debug)
        << " shmid : " << shmid << endl;

    int fd = OpenFIFO(FIFO_NAME, O_RDONLY);

    while (true) {
        Wait(fd);

        printf("%s\n", shmaddr);

        if (strcmp(shmaddr, "quit") == 0)
            break;
    }

    CloseFifo(fd);

    int n = shmdt(shmaddr);
    assert(n != -1);
    (void)n;

    Log("detach shm done", Debug)
        << " shmid : " << shmid << endl;

    n = shmctl(shmid, IPC_RMID, nullptr);
    assert(n != -1);
    (void)n;

    Log("delete shm done", Debug)
        << " shmid : " << shmid << endl;

    return 0;
}

server 的逻辑是:

先创建共享内存。

再挂接共享内存。

然后打开 FIFO 读端。

每次 Wait(fd) 阻塞等待 client 通知。

收到通知后再读取共享内存内容。

最后释放并删除共享内存。

这里把"共享内存负责数据传输"和"FIFO 负责通知顺序"分开了。

10.4 ShmClient.cc

cpp 复制代码
#include "Comm.hpp"

int main() {
    key_t k = ftok(PATH_NAME, PROJ_ID);

    if (k < 0) {
        Log("create key failed", Error)
            << " client key : " << TransToHex(k) << endl;
        exit(1);
    }

    Log("create key done", Debug)
        << " client key : " << TransToHex(k) << endl;

    int shmid = shmget(k, SHM_SIZE, 0);

    if (shmid < 0) {
        Log("create shm failed", Error)
            << " client key : " << TransToHex(k) << endl;
        exit(2);
    }

    Log("create shm success", Error)
        << " client key : " << TransToHex(k) << endl;

    char* shmaddr = (char*)shmat(shmid, nullptr, 0);

    if (shmaddr == nullptr) {
        Log("attach shm failed", Error)
            << " client key : " << TransToHex(k) << endl;
        exit(3);
    }

    Log("attach shm success", Error)
        << " client key : " << TransToHex(k) << endl;

    int fd = OpenFIFO(FIFO_NAME, O_WRONLY);

    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;
        }
    }

    CloseFifo(fd);

    int n = shmdt(shmaddr);
    assert(n != -1);

    Log("detach shm success", Error)
        << " client key : " << TransToHex(k) << endl;

    return 0;
}

client 的逻辑是:

从标准输入读内容。

直接写入共享内存。

写完后调用 Signal(fd) 通知 server。

如果输入 quit,就退出。

这段代码让我对"同步"和"通信"有了更清楚的区分。

共享内存解决的是数据在哪里传。

FIFO 解决的是什么时候能读。

如果没有 FIFO,server 可能读到不完整数据。

如果没有共享内存,FIFO 也能传数据,但不如共享内存直接。

11. System V 消息队列和信号量

11.1 消息队列的基本理解

消息队列提供了一种从一个进程向另一个进程发送数据块的方法。

每个数据块都可以带有类型,接收进程可以按照不同类型接收消息。

它和管道的一个区别是:管道是字节流,而消息队列更像一条一条有边界的消息。

💡管道像水管,数据像水流,读的时候不天然区分每一杯水;消息队列像快递柜,每个包裹是一条消息,还可以根据包裹类型分类取件。

System V 消息队列也属于 IPC 资源,需要主动删除,否则不会自动清理,除非系统重启。

11.2 并发编程中的共享资源、临界资源和临界区

学习信号量之前,必须先理解几个概念。

多个执行流能看到同一份公共资源,这份资源就是共享资源。

被保护起来的共享资源叫临界资源。

访问临界资源的代码区域叫临界区。

保护临界资源的常见方式有互斥和同步。

互斥强调同一时刻只能有一个执行流访问临界资源。

同步强调多个执行流访问临界资源时要有一定顺序。

我觉得这里最重要的一句话是:保护共享资源,本质上是保护访问共享资源的代码。

因为资源本身不会主动保护自己,真正可能出问题的是多个执行流同时执行访问资源的代码。

11.3 信号量是什么

信号量本质上是一个计数器。

它主要用于同步和互斥。

从作用上看,信号量用来保护临界区。

从本质上看,信号量是一种资源预订机制。

常见操作:

  • 申请资源:计数器 --,也叫 P 操作
  • 释放资源:计数器 ++,也叫 V 操作

如果信号量的值表示可用资源数量,那么 P 操作就是先预订一个资源,V 操作就是归还一个资源。

💡电影院座位就是一个很好理解的信号量例子。影厅还有 10 个座位,信号量就是 10。每卖出一张票,座位数减 1;有人退票,座位数加 1。如果座位数为 0,后面的人就不能再买票了。

11.4 信号量和共享内存的关系

共享内存很快,但是它没有自带同步和互斥。

所以在真实场景中,共享内存经常配合信号量使用。

共享内存负责提供数据区域。

信号量负责控制谁能进入临界区。

这两个东西组合起来,才更接近可用的进程间共享数据方案。

12. 内核如何管理 IPC 资源

12.1 IPC 资源不是普通用户变量

管道、共享内存、消息队列、信号量这些东西,看起来是用户调用 API 创建出来的,但真正的资源都由内核管理。

这也是为什么 System V IPC 资源有时会在进程退出后仍然存在。

因为它们不是普通的局部变量,不会随着函数返回自动消失。它们是内核维护的 IPC 对象。

12.2 key 和 id 的区别

学习共享内存时,有两个值很容易混:

key 是创建或查找 IPC 资源时使用的键值。
id 是内核返回给用户的资源标识符,比如 shmid

可以这样理解:

key 更像"名字"。
id 更像"内核发给你的句柄"。

用户先通过 ftok 生成 key,再用 shmget 得到 shmid,后续操作主要使用 shmid。

💡key 有点像快递单上的取件码,shmid 像快递柜系统内部真正分配的柜门编号。你通过取件码找到包裹,但系统内部还需要用自己的编号管理柜门。

12.3 内核管理 IPC 的统一思路

虽然消息队列、共享内存、信号量功能不同,但它们都属于 IPC 资源。

内核管理它们时,通常会抽象出一些公共属性,比如:

  • 权限
  • 拥有者
  • key
  • id
  • 创建时间
  • 最近访问时间
  • 引用或挂接情况

不同 IPC 类型再有自己的专属字段。

这其实有点类似 C 语言里实现"多态"的思想:公共部分抽象出来,不同对象再扩展自己的部分。

12.4 C 语言如何体现"多态"思想

C 语言没有 C++ 那种类和虚函数,但可以通过结构体嵌套、函数指针、公共头部等方式实现类似多态的效果。

比如内核中可以把 IPC 对象的公共属性放在一个基础结构中,不同类型的 IPC 资源再包含这个公共结构。

这样统一管理时,可以先按照公共字段处理;真正执行不同操作时,再根据具体类型执行不同逻辑。

这个地方对我来说挺重要,因为它说明:所谓多态并不一定非要语言层面支持,底层 C 代码也可以通过设计结构体和函数指针来实现类似效果。

13. minishell 中管道实现思路

13.1 为什么 shell 管道值得单独看

学管道时,如果只看 pipe 函数,很容易停留在 API 层面。

但是 shell 里的管道更接近真实应用,比如:

bash 复制代码
ls | grep test | wc -l

这不是一个管道,而是多个命令之间形成的管道链。

核心思路是:

  1. 解析命令行
  2. | 切分多个命令
  3. 为每段命令创建管道
  4. fork 子进程执行命令
  5. dup2 重定向标准输入和标准输出
  6. execvp 替换子进程程序

13.2 命令输入和解析

c 复制代码
#define MAX_CMD 1024
char command[MAX_CMD];

int do_face()
{
    memset(command, 0x00, MAX_CMD);

    printf("minishell$ ");
    fflush(stdout);

    if (scanf("%[^\n]%*c", command) == 0) {
        getchar();
        return -1;
    }

    return 0;
}

char **do_parse(char *buff)
{
    int argc = 0;
    static char *argv[32];
    char *ptr = buff;

    while (*ptr != '\0') {
        if (!isspace(*ptr)) {
            argv[argc++] = ptr;

            while ((!isspace(*ptr)) && (*ptr) != '\0') {
                ptr++;
            }

            continue;
        }

        *ptr = '\0';
        ptr++;
    }

    argv[argc] = NULL;

    return argv;
}

do_face 负责读取一整行命令。
do_parse 负责按空白字符拆分参数。

这里使用 static char *argv[32],是为了让返回的数组在函数结束后仍然有效。

13.3 解析管道符

c 复制代码
int do_command(char *buff)
{
    int pipe_num = 0;
    char *ptr = buff;

    pipe_command[pipe_num] = ptr;

    while (*ptr != '\0') {
        if (*ptr == '|') {
            pipe_num++;
            *ptr++ = '\0';
            pipe_command[pipe_num] = ptr;
            continue;
        }

        ptr++;
    }

    pipe_command[pipe_num + 1] = NULL;

    return pipe_num;
}

这段代码的做法是:遇到 | 就把它改成 \0,从而把一整行命令切成多个字符串。

比如:

bash 复制代码
ls | grep test | wc -l

会被切成:

text 复制代码
ls
grep test
wc -l

13.4 执行管道

c 复制代码
int do_pipe(int pipe_num)
{
    int pid = 0;
    int i;
    int pipefd[10][2] = {{0}};
    char **argv = NULL;

    for (i = 0; i <= pipe_num; i++) {
        pipe(pipefd[i]);
    }

    for (i = 0; i <= pipe_num; i++) {
        pid = fork();

        if (pid == 0) {
            do_redirect(pipe_command[i]);

            argv = do_parse(pipe_command[i]);

            if (i != 0) {
                close(pipefd[i][1]);
                dup2(pipefd[i][0], 0);
            }

            if (i != pipe_num) {
                close(pipefd[i + 1][0]);
                dup2(pipefd[i + 1][1], 1);
            }

            execvp(argv[0], argv);
        }
        else {
            close(pipefd[i][0]);
            close(pipefd[i][1]);
            waitpid(pid, NULL, 0);
        }
    }

    return 0;
}

这里最关键的是 dup2

如果当前命令不是第一个命令,就要把前一个管道的读端重定向到标准输入。

如果当前命令不是最后一个命令,就要把当前管道的写端重定向到标准输出。

所以 shell 管道的本质就是:用管道连接多个进程,再用 dup2 改变标准输入输出的指向。

💡shell 管道有点像接力赛。第一个命令跑完把接力棒交给第二个命令,第二个命令再交给第三个命令。管道就是接力棒传递的通道,dup2 就是把每个人的"接棒口"和"交棒口"安排好。

结语

学完这部分之后,我对 Linux IPC 的理解比一开始清楚很多。

刚开始我只是觉得进程间通信就是"让两个进程传数据"。后来才发现,不同 IPC 机制解决的问题侧重点不一样。

管道简单,适合有亲缘关系的进程,符合 Linux 一切皆文件的思想。

命名管道有路径名,可以让不相关进程通信。

共享内存速度快,但同步和互斥要额外考虑。

消息队列更强调一条一条带类型的消息。

信号量不主要负责传数据,而是负责保护临界区、控制资源访问顺序。

System V IPC 资源由内核管理,生命周期不一定跟进程完全绑定,所以要注意主动删除。

这部分最让我有收获的是:不要只记 API,而要从内核对象、文件描述符、地址空间、同步互斥这些角度去看。这样管道、共享内存、信号量就不是孤立知识点,而是 Linux 为了让多个进程安全协作而设计出来的一整套机制。

高频技术题 / 面试题

1. 什么是进程间通信?为什么需要 IPC?

进程间通信就是让不同进程之间进行数据传输、资源共享、事件通知或进程控制的一组机制。

需要 IPC 的原因是进程之间具有独立地址空间。一个进程不能直接访问另一个进程的变量或内存,这保证了安全性,但也让协作变得困难。所以 Linux 需要通过管道、共享内存、消息队列、信号量等机制,让进程在受控条件下进行通信。

常见误区是认为进程之间可以直接通过全局变量通信。实际上,fork 后父子进程虽然代码相同,但地址空间是独立的,写时拷贝后变量修改不会直接影响对方。

2. 匿名管道为什么通常用于父子进程?

匿名管道没有路径名,不能通过文件系统找到。它由 pipe 创建后返回两个文件描述符,分别表示读端和写端。

父进程调用 pipe 后再 fork,子进程会继承父进程的文件描述符表,所以父子进程可以访问同一个内核管道对象。

如果两个进程没有亲缘关系,就无法自然继承同一组 fd,因此匿名管道不适合不相关进程通信。

3. pipe 函数中 fd[0] 和 fd[1] 分别表示什么?

fd[0] 表示读端,fd[1] 表示写端。

进程从 fd[0] 读取管道数据,向 fd[1] 写入管道数据。

这两个 fd 本质上是文件描述符表中的下标,真正的管道缓冲区在内核中。fd 只是进程访问内核管道对象的入口。

4. 管道为什么也能用 read 和 write?

因为 Linux 把管道也抽象成了文件。

虽然管道不是磁盘上的普通文件,但它在内核中也有对应的文件对象,可以通过文件描述符访问。因此用户层可以用统一的 readwriteclose 操作管道。

这体现了 Linux "一切皆文件"的思想。

5. 为什么管道通信时要关闭不用的一端?

如果不用的一端不关闭,可能导致读写行为异常。

比如父进程读、子进程写。如果父进程没有关闭自己的写端,那么即使子进程退出,内核仍然认为管道还有写端存在,父进程读端可能不会返回 0,从而一直阻塞。

所以管道通信中,一般读进程关闭写端,写进程关闭读端。

6. 管道中 read 返回 0 表示什么?

当管道所有写端都关闭后,读端调用 read 会返回 0

这通常表示没有写者了,也可以理解成管道读到了"结束"。

进程池中父进程关闭写端后,子进程 read 返回 0,就可以判断没有新任务,从而退出。

7. 如果管道所有读端都关闭,写端继续 write 会怎样?

如果所有读端都关闭,写端继续 write,会产生 SIGPIPE 信号,写进程可能因此退出。

这表示接收方已经不存在,发送方继续发送没有意义。

8. 匿名管道和命名管道有什么区别?

匿名管道由 pipe 创建并打开,没有路径名,通常用于父子进程等有亲缘关系的进程。

命名管道由 mkfifo 创建,是文件系统中的一种特殊文件,需要用 open 打开,可以用于不相关进程之间通信。

二者创建和打开方式不同,但一旦打开完成,读写语义基本类似。

9. 命名管道以写方式打开时为什么可能阻塞?

如果以阻塞方式打开 FIFO 的写端,而当前没有进程打开读端,那么 open 会阻塞。

这是因为写端需要有读端配合,否则写入的数据没有接收者。

如果设置了 O_NONBLOCK,写方式打开 FIFO 时没有读端会直接失败,错误码通常是 ENXIO

10. 为什么共享内存是最快的 IPC 形式?

共享内存把同一块物理内存映射到多个进程的虚拟地址空间中。

映射完成后,进程之间传递数据不需要每次调用 readwrite 进入内核,也不需要在内核和用户空间之间来回复制数据。

因此共享内存速度很快。

但它的问题是没有自带同步和互斥,多个进程同时访问时可能出现数据竞争。

11. shmat 和 shmdt 的作用分别是什么?

shmat 用于把共享内存段挂接到当前进程地址空间,成功后返回共享内存的起始地址。

shmdt 用于将共享内存段和当前进程地址空间解除关联。

注意,shmdt 不等于删除共享内存。删除共享内存需要使用 shmctl(shmid, IPC_RMID, NULL)

12. 为什么共享内存资源可能会残留?

System V IPC 资源的生命周期随内核,不一定随进程退出而自动消失。

如果进程异常退出,没有执行删除逻辑,共享内存可能仍然存在。再次创建时可能出现 File exists

可以用:

bash 复制代码
ipcs -m

查看共享内存资源,用:

bash 复制代码
ipcrm -m shmid

删除指定共享内存。

13. 共享内存为什么需要信号量或其他同步机制?

共享内存只是提供一块多个进程都能访问的内存区域,它不保证访问顺序。

如果一个进程正在写,另一个进程同时读,可能读到不完整数据。

如果多个进程同时写,可能造成数据覆盖。

因此共享内存通常要配合信号量、互斥锁、管道等机制控制访问顺序。

14. 什么是临界资源和临界区?

临界资源是同一时刻只允许一个执行流访问的共享资源。

临界区是访问临界资源的代码区域。

保护共享资源,本质上是保护访问共享资源的代码。因为真正造成并发问题的,是多个执行流同时进入临界区。

15. 信号量的本质是什么?

信号量本质是一个计数器,用来表示可用资源数量。

P 操作表示申请资源,计数器减一。

V 操作表示释放资源,计数器加一。

如果计数器为 0,说明资源不可用,继续申请的执行流需要等待。

信号量本质上是一种资源预订机制。

16. 管道和消息队列有什么区别?

管道提供的是字节流服务,数据之间没有天然消息边界。

消息队列传递的是一块一块的数据,每个数据块可以带类型。接收进程可以根据消息类型接收不同消息。

所以管道更像连续水流,消息队列更像一个个带标签的包裹。

17. key 和 shmid 有什么区别?

key 用于创建或查找 IPC 资源,可以理解成资源名字。

shmidshmget 返回的共享内存标识码,是后续操作共享内存时使用的 id。

一般流程是先用 ftok 生成 key,再用 shmget 根据 key 获取 shmid。

18. shell 中 cmd1 | cmd2 底层大致如何实现?

shell 会创建管道,然后 fork 出子进程执行命令。

cmd1 来说,需要把标准输出重定向到管道写端。

cmd2 来说,需要把标准输入重定向到管道读端。

这个重定向通常通过 dup2 完成。最后子进程调用 execvp 执行真正的命令。

所以 shell 管道本质上是 pipe + fork + dup2 + exec 的组合。

相关推荐
蔡俊锋10 小时前
大模型背后的数学魔法:AI Infra入门科普
人工智能·深度学习·机器学习
拜特说10 小时前
自进化智能体的范式跃迁:从静态模型到终身学习
人工智能
陆业聪10 小时前
微调:让通用大模型变成你的「专属定制ROM」——从AOSP到LoRA的迁移学习
人工智能·aigc
Mr数据杨10 小时前
【CanMV K210】传感器实验 DS18B20 温度读取与环境判断
人工智能·硬件开发·canmv k210
wy_hhxx10 小时前
Win11 环境部署 Codex、Claude Code + 国产模型
人工智能
计算机安禾11 小时前
【c++面向对象编程】第39篇:简单工厂模式与工厂方法模式:C++实现
c++·简单工厂模式·工厂方法模式
汽车搬砖家11 小时前
VM Fusion安装Ubuntu系统
linux
薛定猫AI11 小时前
【深度解析】Antigravity 更新背后的工程化思路:从沙盒权限到长上下文的 AI 编程工具演进
人工智能
qcx2311 小时前
【系统学AI】02 token机制全解:LLM如何‘读懂‘人类语言
人工智能·llm·产品经理·token·费用·deepseek