【linux】进程间通信

目录

进程间通信分类:

  • 管道
    • 匿名管道pipe
    • 命名管道
    • 进程值
  • System V IPC
    • 消息队列
    • 共享内存
    • 信息量

在 Linux/Unix 系统中,进程作为独立的执行单元,彼此间的内存空间相互隔离,但实际开发中(比如进程池、多任务协作场景),进程间的信息交互是绕不开的核心需求 ------ 这就是进程间通信(IPC,Inter-Process Communication)的价值所在。

从最基础的管道到高性能的共享内存,Linux 提供了多套 IPC 机制,而其中管道(Pipe)和 System V IPC(消息队列、共享内存、信号量)是最经典、最常用的两类。本文将从 "进程间通信的核心前提(让不同进程看到同一份资源)" 出发,结合大量可运行的 C/C++ 代码示例和实操演示,拆解匿名管道、命名管道、共享内存等核心 IPC 方式的底层原理、使用方法、特性限制,帮你彻底搞懂 Linux 进程间通信的底层逻辑。

通信的前提:让不同的进程看到同一份的资源

  1. 同一份资源:某种形式的内存空间
  2. 提供资源的人:只是操作系统

管道


什么是管道

管道是Unix中最古老的进程间通信的形式

我们把从一个进程链接到另一个进程的一个数据流称为一个"管道"

下图结果是 2,可能的情况包括:

  • 你通过两个终端登录了这个节点
  • 你和一个其他用户同时登录了这个节点
  • 系统服务创建了一个登录会话

匿名管道

管道不需要路径,不需要名字

  1. 父进程创建管道
  2. 父进程fork出子进程
  3. 关闭对应的读端写端(管道只能进行单向通信)

匿名管道 = 临时的单向数据通道,适用于父子进程简单通信。

Shell 的 | 就是匿名管道,比如 cat file.txt | grep "hello"

缺点:不能跨非亲缘进程,不能双向通信(需用 FIFO 或 socket)。

验证接口

c 复制代码
int pipe(int pipefd[2]);  // 创建管道,返回两个文件描述符

Shell 中的匿名管道(|)

Shell 的 | 符号就是匿名管道的典型应用:

bash 复制代码
ls -l | grep ".txt"  # ls 的输出通过管道传给 grep

ls -l 的 stdout(写端)连接到管道的写端。

grep 的 stdin(读端)连接到管道的读端。

匿名管道的限制

单向通信:如果需要双向通信,必须创建两个管道。

仅限亲缘进程:无法用于无关进程(需用命名管道 FIFO 或 socket)。

无持久性:进程退出后,管道自动销毁。

缓冲区有限:默认 64KB,写满会阻塞。

演示管道(pipe)的创建

cpp 复制代码
#include <iostream>
#include <unistd.h>
using namespace std;

int main() {
    int fds[2] = {0};
    int n = pipe(fds); //fds:输出型参数
    if(n == 0)
    {
        std::cout << "fds[0] = " << fds[0] << std::endl;
        std::cout << "fds[1] = " << fds[1] << std::endl;
    }
    return 0;
}

运行结果:

(1)理解管道的基础机制

管道是 Linux/Unix 中 进程间通信(IPC) 的一种方式。

它创建了一个 单向数据通道,一端写入数据,另一端读取数据。

(2)观察文件描述符的分配

系统默认会分配当前可用的最小文件描述符(通常是 3 和 4,因为 0、1、2 已被标准输入、输出、错误占用)。

cpp 复制代码
#include <iostream>
#include <cstdlib>
#include <string>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;

//管道通信的示例程序
//Fater process -> read
//Child process -> write
int main() {
    //1. 创建管道
    int fds[2] = {0};
    int n = pipe(fds); //fds:输出型参数
    if(n != 0)
    {
        std::cerr << "pipe error" << std::endl;
        return -1;
    }

    //2. 创建子进程
    pid_t pid = fork();
    if(pid < 0)
    {
        std::cerr << "fork error" << std::endl;
        return 2;
    }
    else if(pid == 0)
    {
        //子进程
        //3. 关闭不需要的fd,关闭read
        ::close(fds[0]); //:: 表示系统调用的函数,区分库函数

        int cnt = 0;
        while(true)
        {
            std::string message = "hello, bit, hello ";
            message += std::to_string(getpid());
            message += ", ";
            message += std::to_string(cnt);

            //fds[1]
            ::write(fds[1], message.c_str(), message.size());
            cnt++;
            sleep(1);
        }
        exit(0);
    }
    else
    {
        //父进程
        //3. 关闭不需要的fd,关闭write
        ::close(fds[1]);

        char buffer[1024];
        while(true)
        {
            ssize_t n = ::read(fds[0], buffer, 1023);
            if(n > 0)
            {
                buffer[n] = 0;
                std::cout << "father read from pipe: " << buffer << std::endl;
            }
            
        }    
        pid_t rid = waitpid(pid, NULL, 0);
        std::cout << "father wait child success: " << rid << std::endl;
    }
    return 0;
}

IPC本质:先让不同的进程,看到同一份资源

可能导致数据不一致问题->保护共享资源 -- 临界资源

现象:

  1. 管道为空 && 管道正常,read会阻塞
  2. 管道为满 && 管道正常,write会阻塞
  3. 管道写端关闭 && 读端继续,读端读到0,表示读到文件结尾
  4. 管道写端正常 && 读端关闭,OS会直接杀掉写入进程

匿名管道特性:

  1. 面向字节流
  2. 用来进行具有血缘关系的进程,进行IPC,常用于父子
  3. 文件的生命周期,随进程,管道也是
  4. 单向数据通信
  5. 管道自带同步互斥等保护机制

⭐完整例子请跳转至_->例子

cpp 复制代码
// 1. 头文件说明:Linux 多进程+管道编程核心头文件
#include <iostream>   // 标准输入输出(打印提示、调试信息)
#include <string>     // 字符串处理(Channel 名称拼接)
#include <vector>     // 动态数组(管理多个 Channel 对象)
#include <cstdlib>    // 通用工具:stoi(字符串转整数)、exit(进程退出)
#include <unistd.h>   // Linux 系统调用:pipe/fork/close/dup2/sleep
#include <sys/types.h> // 系统类型定义:pid_t(进程ID类型)
#include <functional> // 函数对象封装(std::function)
using namespace std;

// 2. 类型别名:定义"无参数、无返回值"的工作函数类型(子进程业务逻辑的统一接口)
// work_t 可以接收任意符合"void()"签名的函数/lambda/函数对象
using work_t = std::function<void()>; 

// 3. 枚举错误码:规范程序退出状态,替代魔法数字(提高可读性/可维护性)
enum
{
    OK = 0,          // 执行成功
    UsageError = 1,  // 命令行参数错误
    PipeError,       // 管道创建失败
    ForkError        // 进程创建失败
};

// 4. Channel 类:封装"管道写端 + 对应子进程PID",管理单个通信信道
// 作用:绑定"主进程写端fd"和"子进程PID",方便主进程管理每个子进程的通信入口
class Channel
{
public:
    // 构造函数:初始化管道写端和子进程PID,生成唯一信道名称
    // 参数:_wfd - 管道写端文件描述符;who - 对应子进程的PID
    Channel(int wfd, pid_t who):_wfd(wfd),_who(who)
    {
        // 拼接信道名称:Channel-写端fd-子进程PID(方便调试时区分不同信道)
        _name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
    }

    // 成员函数:获取信道名称(用于调试打印)
    std::string Name(){
        return _name;
    }

    // 析构函数:空实现(当前无需自定义释放资源,fd由主进程手动管理)
    ~Channel()
    {}

private:
    int _wfd;          // 核心:管道的写端文件描述符(主进程通过此fd向子进程写数据)
    std::string _name; // 信道名称(仅标识/调试用,无实际通信作用)
    pid_t _who;        // 对应子进程的PID(主进程可通过PID回收/管理子进程)
};

// 5. Usage 函数:命令行参数错误时,打印用法提示
// 参数:proc - 程序名(argv[0])
void Usage(string proc){
    cout << "Usage: " << proc << " proc_num" << endl; // 提示格式:./程序名 子进程数量
}

// 6. Worker 函数:子进程的业务逻辑(示例)
// 后续可替换为任意符合 work_t 签名的函数(比如处理任务、计算等)
void Worker()
{
    // 子进程被重定向后,标准输入(0)就是管道读端,可通过read(0, ...)读取主进程数据
    // 此处sleep(10)模拟子进程"干活"的耗时操作
    sleep(10); 
}

// 7. InitProcessPool 函数:初始化进程池(核心函数)
// 参数:
//   processnum - 要创建的子进程数量
//   channels - 输出型参数(主进程传入空vector,函数内填充Channel对象)
//   work - 回调函数(子进程要执行的业务逻辑)
// 返回值:错误码(OK/UsageError/PipeError/ForkError)
int InitProcessPool(const int &processnum, std::vector<Channel>& channels, work_t work){
    // 循环创建 processnum 个子进程 + 对应匿名管道
    for(int i = 0; i < processnum; ++i){
        // 7.1 定义管道文件描述符数组:pipefd[0] = 读端,pipefd[1] = 写端(Linux管道固定规则)
        int pipefd[2] = {0};
        // 7.2 创建匿名管道:成功返回0,失败返回-1
        int n = pipe(pipefd);
        if(n < 0){ 
            perror("pipe error"); // 打印管道创建失败原因(Linux 错误提示)
            return PipeError;     // 返回管道错误码
        }

        // 7.3 创建子进程:fork() 复制当前进程,返回值分三种情况
        pid_t id = fork();
        if(id < 0){ // 情况1:fork失败
            perror("fork error");
            return ForkError; // 返回进程创建错误码
        }
        else if(id == 0){ // 情况2:子进程分支(fork返回0给子进程)
            // 子进程只需要读管道,关闭写端(避免资源泄漏,防止误写)
            ::close(pipefd[1]); 
            // 核心:将子进程的"标准输入(文件描述符0)"重定向到管道读端
            // 作用:后续子进程可通过 read(0, ...) 读取主进程数据(无需关心管道fd,更通用)
            dup2(pipefd[0], 0);
            // 执行子进程的业务逻辑(回调函数,解耦框架与业务)
            work();
            // 子进程执行完业务后退出,避免继续执行父进程的循环代码
            ::exit(0);
        }
        else{ // 情况3:父进程分支(fork返回子进程PID给父进程)
            // 父进程只需要写管道,关闭读端(避免资源泄漏)
            ::close(pipefd[0]); 
            // 7.4 封装Channel对象,存入vector(主进程管理所有通信信道)
            // emplace_back:直接在vector中构造对象(比push_back更高效,避免拷贝)
            channels.emplace_back(pipefd[1], id);
            // 等价写法(可读性更高,新手友好):
            // Channel ch(pipefd[1], id);
            // channels.push_back(ch);
        }
    }
    // 所有子进程创建成功,返回成功码
    return OK;
}

// 8. DebugPrint 函数:调试用,打印所有通信信道的名称
// 参数:channels - 主进程管理的所有Channel对象
void DebugPrint(std::vector<Channel>& channels){
    for(auto &ch : channels){ // 范围for循环遍历所有Channel
        cout << ch.Name() << endl; // 打印每个信道的名称(如Channel-5-12345)
    }
}

// ------------------- 主函数(Master 进程核心逻辑) ------------------- //
int main(int argc, char* argv[]){
    // 步骤1:检查命令行参数(必须传入"子进程数量",否则提示用法)
    // argc - 参数个数(./程序名 10 → argc=2;仅./程序名 → argc=1)
    if (argc != 2){
        Usage(argv[0]);          // 打印用法提示
        return UsageError;       // 返回参数错误码
    }

    // 步骤2:将命令行传入的"子进程数量"字符串转为整数
    int num = std::stoi(argv[1]);
    // 步骤3:创建空vector,用于存储所有子进程的通信信道
    std::vector<Channel> channels;

    // 步骤4:初始化进程池(创建指定数量的子进程+管道)
    // 传入:子进程数量、空channels(输出型)、子进程业务函数Worker
    InitProcessPool(num, channels, Worker);

    // 步骤5:调试打印所有信道名称(验证进程池创建成功)
    DebugPrint(channels);

    // 可选:主进程后续逻辑(比如通过channels中的写端fd给子进程发任务)
    // sleep(100); // 模拟主进程"下发任务"的耗时操作

    return 0; // 主进程正常退出
}
  1. 进程池初始化成功:按参数创建 10 个子进程,无 fork/pipe 失败;
  2. 通信信道建立完成:10 个 Channel 封装了父子进程的管道写端 + 子进程 PID,通信链路就绪;
  3. 子进程状态正常:子进程处于睡眠等待任务状态,业务逻辑可扩展;

命名管道

管道通信的场景 -> 进程池

这段代码是 Linux 环境下 "主从进程(Master-Worker)" 通信框架的基础实现:

  • Master 进程(主进程):根据命令行传入的参数,创建指定数量的 Worker 子进程;
  • 匿名管道:为每个子进程创建一个匿名管道,实现 Master → Worker 的单向通信(Master 持管道写端,Worker 持管道读端);
  • Channel 类:封装 "管道写端文件描述符" 和 "对应子进程 PID",方便 Master 统一管理所有通信信道;
  • 核心依赖 Linux 系统调用(pipe/fork/close)实现进程创建和管道通信,是多进程编程的典型入门案例。
  1. 例子
bash 复制代码
mkfifo fifo


我们把这种管道就叫做命名管道

  1. 命名管道的原理,理解(匿名打通)
    为什么叫做命名管道? 因为是真正存在的文件!
    路径+文件名 ---> 具有唯一性

代码演示:

形成两个可执行程序

公共资源:一般要让指定的一个进程先创建

创建 && 使用

获取 && 使用

创建管道本质也是新建文件

mode创建文件难度的权限

cpp 复制代码
#pragma once
#include <iostream>
#include "Comm.hpp"

class Init 
{
public:
    Init()
    {
        umask(0);
        int n = ::mkfifo(gpipeFile.c_str(), gmode);
        if (n < 0)  
        {
            std::cerr << "mkfifo error" << std::endl;
            return;
        }
        std::cout << "mkfifo success" << std::endl;   
    }
    ~Init()
    {
        int n = ::unlink(gpipeFile.c_str());
        //unlink;删除一个文件的名字和inode之间的联系
        if (n < 0)  
        {
            std::cerr << "unlink error" << std::endl;
            return;
        }
        std::cout << "unlink success" << std::endl;   
    }
};

Init inti;

class Server 
{
public:
    Server():_fd(gdefultfd)
    {

    }
    bool OpenPiprForRead()
    {
        _fd = OpenPipe(gForRead);
        if(_fd < 0) return false;
        return true;
    }

    //std::string *: 输出型参数
    //const std::string &: 输入型参数
    //std::string *&: 输入输出型参数
    int recvPipe(std::string *out)
    {
        char buffer[gsize] = {0};
        ssize_t n = ::read(_fd, buffer, sizeof(buffer)-1);
        if(n > 0)
        {
            buffer[n] = 0;
            *out = buffer;
        }
        return n;
    }
    void ClosePipe()
    {

    }
    ~Server()
    {
        ClosePipeHelper(_fd);
    }
private:
    int _fd;
};

详细代码跳转至👉命名管道 FIFO


共享内存

共享内存存在任何时刻,可以在OS内部同事存在很多个

  • IPC_CREAT: 在单独使用时,如果shm不存在,创建。如果存在,获取并返回
  • IPC_EXCL:单独使用无意义

IPC_CREAT | IPC_EXCL: 如果shm不存在,就创建它。如果存在,出错返回 ------ 只要成功,一定是新的共享内存!

Linux是如何在应用层面,保证不同进程看到同一份资源的?

路径+项目ID

Server.cc示例代码

cpp 复制代码
#include <iostream>
#include "Comm.hpp"
int main()
{
    key_t k = ::ftok(gpath.c_str(), gprojId);
    //1. 创建key
    if(k < 0){
        std::cerr << "ftok failed" << std::endl;
        return 1;
    }
    std::cout << "key: " << ToHex(k) << std::endl;
    //2. 创建共享内存 && 获取
    int shmid = ::shmget(k, gshmsize, IPC_CREAT | IPC_EXCL);
    if(shmid < 0){
        std::cerr << "shmget failed" << std::endl;
        return 2;
    }
    std::cout << "shmid: " << shmid << std::endl;
    return 0;
}

在这里第一次运行成功了,第二次再运行 ./server 会失败

因为共享内存的生命周期是随内核的,所以有一下解决方案:

  1. 用户让OS释放
  2. OS重启

共享内存的管理指令:

ipcs -m : 查看
ipcrm -m [shmid] 删除

  • shmid vs key
  1. shmid:只给用户用的一个表示shm的标识符
  2. key: 只作为内核中,区分shm唯一性的标识符,不作为用户管理shm的id值
  • shmat : 将内核中的共享内存段挂接到当前进程的虚拟地址空间
  • shmdt 去除关联

建立共享资源

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include "Comm.hpp"
int main()
{
    key_t k = ::ftok(gpath.c_str(), gprojId);
    //1. 创建key
    if(k < 0){
        std::cerr << "ftok failed" << std::endl;
        return 1;
    }
    std::cout << "key: " << ToHex(k) << std::endl;
    //2. 创建共享内存 && 获取
    //注意:为什么共享内存也有权限!
    int shmid = ::shmget(k, gshmsize, IPC_CREAT | IPC_EXCL | gmode);
    if(shmid < 0){
        std::cerr << "shmget failed" << std::endl;
        return 2;
    }
    std::cout << "shmid: " << shmid << std::endl;

    sleep(5);

    //3. 共享内存挂机到自己的地址空间
    void *ret = shmat(shmid, nullptr, 0);
    std::cout << "attach done: " << (long long)ret << std::endl;
    sleep(5);

    ::shmdt(ret);
    std::cout << "detach done" << std::endl;
    sleep(5);

	//在这里进行通信

    //n. 删除共享内存
    shmctl(shmid, IPC_RMID, nullptr);
    std::cout << "delete shm done" << std::endl;
    sleep(5);
    return 0;
}
/**
 * 监控脚本:
 * 1.查IP资源
 * while :; do ipcs -m; sleep 1; done
 */

共享内存的特点:

  1. 通信速度最快
  2. 让两个进程再各自的用户空间共享内存块,但是,没有加任何保护机制!
  3. 共享内存,保护机制,需要由用户自己完成保护 -- 信号量 -- 命名管道
    共享内存被保护起来叫做临界资源
    访问公共资源的代码叫做临界区

完整的代码👉共享内存示例/学习用 demo


system V消息队列

内核提供的一种进程通信的方式

消息队列的本质:一个进程向另一个进程发送有类型的数据块的方法!

消息队列的管理指令:

  • ipcs -q : 查看
  • ipcrm -q msqid : 删除
  • smop: 信号量的操作

system V信号量

保护的常见方式:互斥和同步
->(任何时候,只允许一个执行流访问资源,叫做互斥)
->(多个执行流,访问临界资源的时候,具有一定的顺序性,叫做同步)

  • 信号量 != 信号
    信号量是一个计数器

特性方面:IPC资源必须删除,否则不会自动清楚,除非重启,所以system V IPC 资源的生命周期随内核

信号量,信号灯:对资源进行预定的计数器

二元信号量:0,1

信号量支持同时对多个信号量进行PV操作

  1. 互斥访问,把资源当作整体使用,临界区是串行执行
  2. 不同进程,访问共享资源,具有一定的并发性

System V 是如何实现IPC呢?和管道为什么不同?

  1. 应用角度,看IPC属性

    推测:在 OS方面,IPC是同类资源
cpp 复制代码
void ShmMeta(){
	structshmid_dsbuffer;//系统提供的数据类型
	int n =::shmctl(_shmid,IPC_STAT,&buffer);
	if(n <0) return;
	std::cout << buffer.shm_atime << std::endl;
	std::cout << buffer.shm_cpid << std::endl;
	std::cout<buffer.shm_ctime < std::endl;
	std::cout < buffer.shm_nattch< std::endl;
	std::cout << buffer.shm_perm.__key << std::endl;
}
  1. 内核角度,看IPC结构
    IPC资源一定是全局的资源!被所有的进程看到!
    消息队列,共享内存,信号量,key区分唯一性

进程间通信的本质,始终是 "让不同进程访问同一份系统级资源"------ 管道依托文件系统实现单向 / 跨进程的数据传输,System V IPC 则通过内核级的全局资源(共享内存、消息队列)实现更高性能的交互,而信号量则为这些共享资源提供了关键的同步互斥保护。

本文从基础概念到代码实操,覆盖了管道(匿名 / 命名)、共享内存、System V 消息队列和信号量的核心知识点,你可以通过文中的示例代码动手编译运行,结合ipcs/ipcrm等指令观察 IPC 资源的生命周期,加深对 "IPC 资源随内核""临界资源需要保护" 等核心原则的理解。

当然,Linux 的 IPC 体系远不止这些(比如 Socket、信号也是重要的 IPC 方式),掌握本文的基础后,你可以进一步学习不同 IPC 方式的性能对比、场景选型(比如共享内存快但无保护,管道简单但效率低),以及实际项目中进程池 + IPC 的综合应用,让这些底层知识真正落地到工程实践中。

相关推荐
AC赳赳老秦1 小时前
DeepSeek一体机部署:中小企业本地化算力成本控制方案
服务器·数据库·人工智能·zookeeper·时序数据库·terraform·deepseek
code monkey.1 小时前
【Linux之旅】Linux 动静态库与 ELF 加载全解析:从制作到底层原理
linux·服务器·c++·动静态库
Pluto_CSND2 小时前
CentOS系统中创建定时器
linux·运维·centos
好好沉淀2 小时前
Docker 部署 Kibana:查 ES 版本 + 版本匹配 + 中文界面
linux·docker
!chen2 小时前
PLG log server note
运维·jenkins
Honmaple2 小时前
OpenClaw 远程访问配置指南:SSH 隧道与免密登录
运维·ssh
China_Yanhy2 小时前
入职 Web3 运维日记 · 第 4 日:拒绝“裸奔” —— 接口加固与监控闭环
运维·区块链
kylezhao20192 小时前
C#中开放 - 封闭原则(**Open-Closed Principle,OCP**)
服务器·c#·开闭原则