从管道到共享内存:深入理解 Linux 进程间通信

在 Linux 系统中,进程是独立的执行单元,它们各自拥有独立的内存空间。然而,在许多复杂的应用场景中,进程之间需要进行协作和数据交换。这就引出了我们今天的主题——进程间通信(IPC, Inter-Process Communication)。本文将从匿名管道、命名管道,再到共享内存,层层递进,带你深入理解这些核心的 IPC 机制。

一、管道:最古老的 IPC 形式

管道是 Linux 中一种非常基础的通信方式,它提供了一种单向的数据流。我们可以将其分为匿名管道和命名管道两种类型。

1、匿名管道

匿名管道有几个关键特性:

  • 血缘关系限制:它只能用于具有共同祖先的进程(通常是父子进程)之间的通信。当一个进程 fork 出一个子进程后,这个管道就在父子之间建立起来。

  • 半双工:数据只能在一个方向上流动。如果需要双向通信,必须建立两个独立的管道。

  • 生命周期:管道的生命周期跟随进程。当使用管道的进程退出后,管道本身也会被释放。

下图形象地展示了其原理。进程 1和进程 2(父子进程)通过各自的文件描述符(files_struct)指向同一个 struct file,最终操作同一块内核缓冲区,从而实现通信。

这意味着,对于完全无关的两个进程,匿名管道无能为力。那么,我们该如何让两个毫不相关的进程进行对话呢?这就需要命名管道登场了。

2、命名管道

为了解决匿名管道的限制,命名管道(也称为 FIFO,先进先出)应运而生。

核心思想

让不同的进程,看到并操作同一份资源。

如何做到?答案是在文件系统中为管道建立一个实体名称。这个管道文件就像一个普通文件,任何有权限的进程都可以通过其路径来访问它:

只要访问同一个路径下的同一个文件即可!
路径 + 文件名 = 唯一的 inode!

这意味着,命名管道在文件系统中有一个可见的节点,它不依赖于进程的生命周期,而是独立存在。这就为无关进程间的通信提供了桥梁。

上图的终端操作也验证了这一点:

  1. mkfifo named_pipe:在文件系统中创建一个名为 named_pipe 的管道文件。

  2. echo "aaaaa" > named_pipe:一个进程(echo)向管道写入数据。

  3. cat < named_pipe:另一个进程(cat)从管道读取数据。

  4. 发现named_pipe 大小为0,原理是named_pipe在文件系统中仅以一个特殊的文件节点形式存在,其在磁盘上的大小始终为 0。该节点只是用于在进程间建立通信通道的占位符,并不实际存放数据。通过 FIFO 传输的数据由内核维护,存储于内核的管道缓冲区中,而不会写入磁盘。

代码实战

接下来,我们通过分析提供的 Server.cc 和 Client.cc 代码来看看命名管道是如何在实践中应用的。

  • Pipe.hpp:管道的封装

这是一个非常实用的 Fifo 类,它封装了命名管道的创建、打开、读写和删除操作。

// Pipe.hpp
#pragma once
#include <iostream>
#include<cstdlib>
#include<cerrno>
#include<cstring>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>

const std::string gcommfile = "./fifo";

#define For_Read 1
#define For_Write 2

class Fifo
{
public:
    Fifo(const std::string& commfile = gcommfile) :_commfile(commfile), _mode(0666), _fd(-1)
    {}

    // 1. 创建管道文件
    void Build()
    {
        umask(0);
        int n = mkfifo(_commfile.c_str(), _mode);
        // ... 错误处理
    }

    // 2. 打开管道文件
    void Open(int mode)
    {
        if (mode == For_Read) _fd = open(_commfile.c_str(), O_RDONLY);
        else if (mode == For_Write) _fd = open(_commfile.c_str(), O_WRONLY);
        // ... 错误处理
    }

    // 发送和接收
    void Send(const std::string &msgin)
    {
        ssize_t n = write(_fd, msgin.c_str(), msgin.size());
        (void)n;
    }

    int Recv(std::string *msgout)
    {
        char buffer[128];
        ssize_t n = read(_fd, buffer, sizeof(buffer)-1);
        // ... 处理读取结果
    }

    // 3. 删除管道文件
    void Delete()
    {
        int n = unlink(_commfile.c_str());
        // ... 错误处理
    }

    // ... 省略其他部分
private:
    std::string _commfile;
    mode_t _mode;
    int _fd;
};
  • Server.cc:读取端

服务器端首先负责创建 (Build) 管道文件,然后以只读模式打开 (Open(For_Read)),并循环等待从中接收 (Recv) 消息。通信结束后,负责删除 (Delete) 管道文件。

// Server.cc
#include "Pipe.hpp"

int main()
{
    Fifo pipefile;
    pipefile.Build();
    pipefile.Open(For_Read);
    std::string msg;

    while (true)
    {
        int n = pipefile.Recv(&msg);
        if (n > 0)
        {
            std::cout << "Client Say# " << msg << std::endl;
        }
        else
            break;
    }

    pipefile.Delete();
    return 0;
}
  • Client.cc:写入端

客户端则简单得多,它只需要以只写模式打开 (`Open(For_Write)`) 同一个管道文件,然后从标准输入读取内容并发送 (`Send`) 出去。

// Client.cc
#include "Pipe.hpp"

int main()
{
    Fifo fileclient;
    fileclient.Open(For_Write);
    
    while(true)
    {
        std::cout << "Please Enter# ";
        std::string msg;
        std::getline(std::cin, msg);
        fileclient.Send(msg);
    }
    return 0;
}

这组代码清晰地展示了两个无关进程如何通过一个共享的文件系统路径 (./fifo) 进行通信。

二、共享内存:最高效的 IPC 方式

管道缺点

尽管管道非常实用,但它有一个固有的特点:数据需要在用户空间和内核空间之间进行复制。具体来说,发送方需要从用户空间 write 到内核的管道缓冲区,接收方再从内核的缓冲区 read 到自己的用户空间。这一来一回的复制在高频率、大数据量的场景下会成为性能瓶颈。

有没有更高效的方式?有,那就是共享内存

核心思想

将同一块物理内存,映射到不同进程的虚拟地址空间中。

从上图可以看出,操作系统将一块物理内存同时挂接到进程 A 和进程 B 的页表上。这样,A 和 B 就可以像访问自己的内存一样,直接读写这块共享区域,完全省去了内核的介入和数据复制。这使得共享内存成为了最快的 IPC 方式

共享内存特点

  1. 访问共享内存,不需要系统调用,因为shm已经映射到了进程的用户共享区了!
  2. 写端数据拷贝的shm,其他端立马能看到-->共享内存,是所有进程间通信方式中,速度最快的-->拷贝次数少直接映射,不需要系统调用!
  3. 缺点::没有资源的保护机制,没有同步或者互斥!-->需要由用户自己完成保护

理解

  1. 映射到进程的虚拟地址空间中的共享区
  2. 共享内存原理,是一个简化版本的动态库映射
  3. 共享内存管理结构体 + 共享内存本身 = 共享内存

如何使用共享内存?

使用共享内存通常遵循以下步骤:

  1. 创建/获取共享内存:使用 shmget 系统调用。

  2. 挂接(Attach):将共享内存映射到进程自己的虚拟地址空间,使用 shmat。

  3. 使用:像操作普通指针一样读写这块内存。

  4. 脱离(Detach):解除映射关系,使用 shmdt。

  5. 释放共享内存:在所有进程都脱离后,由其中一个进程负责释放,使用 shmctl。

系统函数调用介绍

shmget 函数:
int shmget(key_t key, size_t size, int shmflg);
  • key: 一个唯一的键值,类似于文件路径,用于让不同进程找到同一个共享内存区段。

  • size: 要申请的共享内存大小。(4096的整数倍!)

  • shmflg: 标志位 

    • IPC_EXCL: 不能单独使用;

    • IPC_CREAT: 可以单独传递,如果创建的共享内存不存在,就创建之,如果存在,我就获取它;

    • IPC_CREAT | IPC_EXCL:如果创建的共享内存不存在,就创建之,如果存在,出错返回

确保了服务器端可以安全地创建共享内存,而客户端可以稳定地获取到它,从而实现高效通信。

shmat 函数:
void *shmat(int shmid, const void *shmaddr, int shmflg);
  • shmid: 共享内存标识符,即由 shmget 函数成功调用后返回的那个“钥匙”。

  • shmaddr: 指定连接的地址。用于建议内核将共享内存映射到进程的哪个地址。

    • NULL: 这是最常用、最推荐的用法。表示由内核自动选择一个合适的、可用的地址进行映射。

    • 非NULL: 如果指定一个具体的地址,内核会尝试使用该地址。可以配合 SHM_RND 标志位来做地址对齐。

  • shmflg: 标志位,用于控制连接的行为。

    • 0: 表示以默认的读写模式进行连接。

    • SHM_RDONLY: 表示以只读模式连接。连接后,进程只能读取该共享内存,任何写入操作都将导致段错误(Segmentation Fault)。

    • SHM_RND: (Round) 配合 shmaddr 使用,表示将连接地址向下舍入到合适的边界。

调用 shmat 成功后,会返回一个指向共享内存区域起始位置的指针,后续所有对共享内存的读写操作都通过这个指针进行。

shmdt 函数
int shmdt(const void *shmaddr);
  • shmaddr: 需要断开的共享内存地址,即之前调用 shmat 时成功返回的那个指针。

调用 shmdt 之后,当前进程将无法再通过该地址访问这块共享内存。

注意: 将共享内存段与当前进程脱离,不等于删除这个共享内存段。它只是当前进程“离开”了,内存段本身依然存在于内核中,其他进程仍然可以访问,直到有进程调用 shmctl 将其删除。

shmctl 函数
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
  • shmid: 共享内存标识符,即由 shmget 返回的那个“key”。

  • cmd: 控制命令,指定要执行的具体操作。最常用的三个值是:

    • IPC_STAT: 获取共享内存的当前状态。内核会将该内存段的 shmid_ds 结构体信息复制到 buf 指向的内存中。

    • IPC_SET: 设置共享内存的属性。内核会用 buf 指向的结构体中的值来更新该内存段的属性(例如所有者、权限等)。

    • IPC_RMID: 标记删除共享内存。这是最终释放共享内存的方式。当最后一个挂接该内存的进程与其分离后,内核才会真正地回收这块内存。

  • buf: 一个指向 shmid_ds 结构体的指针。

    • 在执行 IPC_STAT 时,它是一个输出参数,用于接收内核返回的状态信息。

    • 在执行 IPC_SET 时,它是一个输入参数,用于向内核传递要设置的新属性。

    • 在执行 IPC_RMID 时,此参数被忽略,可以设置为 NULL。

shmctl 是确保共享内存资源被正确管理和释放的关键,特别是在程序退出时,必须由一个指定的进程(通常是服务端)调用 shmctl 并使用 IPC_RMID 来防止内存泄漏。

代码实现

问题:两个独立的进程,没有任何亲缘关系,如何在茫茫的内核中精确地找到并约定使用同一块内存?

答案:使用一个系统中唯一的键值(key)。这个key就像一个独特的“门牌号”,所有需要协同工作的进程都必须持有这个相同的“门牌号”,才能找到正确的资源。

Linux 提供了一个标准函数 ftok 来生成这个键值:

#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);
  • pathname:一个在文件系统中真实存在的路径名。ftok 会使用这个文件的 inode 号的一部分来生成 key。

  • proj_id:一个用户自定义的项目ID(一个非零整数)。

操作系统会将 pathname 对应文件的 inode 信息和 proj_id 结合,通过内部算法生成一个大概率唯一的 key 值。因此,只要所有进程使用相同的 pathname 和 proj_id,它们就能通过 ftok 获得完全相同的 key,从而确保它们指向的是内核中同一份 IPC 资源。

为了更好地管理共享内存的创建、挂接、分离和删除等一系列操作,我们通常会将其封装在一个类中,如下面的 shm.hpp 所示:

#pragma once
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <sys/shm.h>

const int gsize = 128; // 4096的整数倍!

//下边两个参数由用户自定义
#define PATHNAME "/tmp"
#define PROJ_ID 0x66

class Shm
{
public:
    Shm() : _shmid(-1), _size(gsize), _start_addr(nullptr)
    {
    }

    void Delete()
    {
        int n = shmctl(_shmid, IPC_RMID, nullptr);
        (void)n;
    }
    void Attach()
    {
        _start_addr = shmat(_shmid, nullptr, 0);
        if ((long long int)_start_addr == -1)
            exit(3);
    }
    void PrintAddr()
    {
        struct shmid_ds ds;
        int n = shmctl(_shmid, IPC_STAT, &ds);
        if (n < 0)
        {
            perror("shmctl");
            exit(4);
        }
        printf("key:0x%x\n",ds.shm_perm_key);
        printf("shm_nattch:%ld\n",ds.shm_nattch);
        printf("shm_segsz:0x%lx\n",ds.shm_segsz);
    }
    void Detach()
    {
        int n = shmdt(_start_addr);
        (void)n;
    }

    void Get()
    {
        GetHelper(IPC_CREAT);
    }
    void Create()
    {
        GetHelper(IPC_CREAT | IPC_EXCL | 0666);
    }
    void *Addr()
    {
        return _start_addr;
    }
    int Size()
    {
        return _size;  
    }

    ~Shm() {}

private:
    key_t GetKey()
    {
        return ftok(PATHNAME, PROJ_ID);
    }
    void GetHelper(int shmflg)
    {
        // 1. 构建键值
        key_t k = GetKey();
        if (k < 0)
        {
            std::cerr << "GetKey error";
            exit(1);
        }
        // 2. 创建新的共享内存
        _shmid = shmget(k, _size, shmflg);
        if (_shmid < 0)
        {
            perror("shmget");
            exit(2);
        }
        printf("key=0x%x, _shmid = %d\n", k, _shmid);
    }

private:
    int _shmid;
    int _size;
    void *_start_addr;
};

拥有了key这个“门牌号”之后,我们就可以使用 shmget 函数来真正地创建或获取共享内存了。shm.hpp 类中的 GetHelper 方法封装了对 shmget 的调用,其 shmflg 标志位是精髓所在,它精确地定义了进程的角色和行为:

  • 服务端(Server):作为创建者
    服务端的典型做法是调用 Create() 方法,它传入 IPC_CREAT | IPC_EXCL | 0666 组合。

    • IPC_CREAT:如果以 key 标识的共享内存不存在,则创建它。

    • IPC_EXCL:这个标志必须与 IPC_CREAT 配合使用。它增加了一个排他性检查:如果共享内存已经存在,shmget 调用就会失败。这个机制可以完美地防止多个服务端实例被错误地启动,保证了资源的唯一创建者。

    • 0666:设置共享内存的访问权限(所有者、所属组、其他人都有读写权限)。

  • 客户端(Client):作为使用者
    客户端则调用 Get() 方法,它传入 IPC_CREAT。这意味着如果共享内存已存在,就直接获取它的标识符;如果不存在,就创建它。对于客户端而言,它的主要意图是“获取”,即便在服务端未启动时意外地创建了,通常也能在后续流程中正常工作。

key 和 shmid 的区分
  • key(键值):是内核层面的资源标识,它的作用是“唯一标识”

  • shmid(标识符):是用户层面的操作句柄,它的作用是“控制与操作”。即用户是通过shmid实现共享内存的删除

这就解释了为什么后续的所有操作,如挂接 (shmat)、分离 (shmdt)、删除 (shmctl),以及命令行工具 ipcrm,都使用 shmid 而不是 key。key 的使命在 shmget 成功的那一刻就已经完成了。

shmget 返回 shmid 后,共享内存段只是在内核中被创建或打开了,进程本身的用户空间还无法直接访问它。我们必须执行一个关键步骤——挂接(Attach),将这块内核中的内存映射到自己进程的虚拟地址空间中。这个任务由 shmat 函数完成,并被封装在我们的 Attach() 方法中。

shmat 成功后,会返回一个指向共享内存区域起始位置的 void* 指针。之后,进程就可以像操作 malloc 返回的指针一样,自由地对这块内存进行读写了。

接下来,我们看看服务端和客户端是如何使用这个 Shm 类的:

Server.cc

#include "Shm.hpp"

int main()
{
    // 生命周期的代码级管理!
    Shm sharedmem;
    sharedmem.Create();
    sharedmem.Attach();

    char *shm_start = (char *)sharedmem.Addr();
    int size = sharedmem.Size();
    while (true)
    {
        // 本质就是读取共享内存!
        for (int i = 0; i < size; i++)
        {
            std::cout << shm_start[i] << ' ';
        }
        std::cout << std::endl;
        sleep(1);
    }

    sharedmem.Detach();
    sharedmem.Delete();
    return 0;
}

Client.cc

#include "Shm.hpp"

int main()
{
    // 不需要创建内核级共享内存,当然也不需要删除
    Shm sharedmem;
    sharedmem.Get();
    sharedmem.Attach();

    char *shm_start = (char*)sharedmem.Addr();
    int size = sharedmem.Size();
    int index = 0;
    while(true)
    {
        std::cout << "Please Enter@ ";
        std::cin >> *shm_start;
        // *(shm_start + index) = ch;
        // shm_start[index++] = ch;
        shm_start++;

        index %= size;
        // sleep(1);
    }

    sharedmem.Detach();
    return 0;
}

当进程不再需要访问共享内存时,应当执行分离(Detach)操作,即解除这个映射关系。这由 shmdt 函数(封装在 Detach() 方法中)完成。每次 shmat 成功,内核中对应共享内存段的挂接计数(nattch)就会加 1,shmdt 则会使其减 1。我们可以通过 ipcs -m 命令清晰地看到这个计数值的变化。

注意:System V IPC 资源(包括共享内存)的生命周期是随内核的,而非随进程!

这意味着,即使所有使用共享内存的进程都退出了,这块内存区段依然会固执地存在于内核中,直到系统重启或被显式删除。如果忘记删除,就会造成永久性的内存泄漏。

因此,必须有一个进程(通常是服务端)承担起最后清理资源的责任。这个操作通过 shmctl 函数(封装在 Delete() 方法中)完成,其 cmd 参数为 IPC_RMID 时,内核会将该共享内存段标记为“待销毁”。它并不会被立即删除,而是等到挂接计数(nattch)降为 0 时,由内核最终回收。

这种“延迟删除”的机制非常健壮,确保了即使有进程正在使用共享内存,删除操作也不会导致其突然崩溃。

之后问题

共享内存以其零拷贝的特性,完美地解决了管道等通信方式的性能瓶颈,为进程间高效的数据交换提供了最强有力的工具。但我们允许多个进程直接、无差别地访问同一块内存,这无异于将多线程编程中的并发问题引入到了多进程的场景中。如果没有额外的保护措施,当多个进程同时写入共享内存时,极有可能发生数据覆盖、读到中间状态等问题,导致数据错乱,即竞态条件(Race Condition)

因此,共享内存很少“独行”,它几乎总是需要与同步与互斥机制(如信号量、互斥锁、读写锁等)协同工作,以确保在任意时刻,只有一个进程能以安全的方式修改共享数据。如何实现这种保护,将是我们探索进程间通信下一阶段的核心议题。

三、总结

今天我们探讨了 Linux 中三种重要的进程间通信方式:

特性匿名管道命名管道 (FIFO)共享内存
通信对象有亲缘关系的进程系统上任意进程系统上任意进程
底层实现内核缓冲区(内存)文件系统中的一个特殊文件直接内存映射
数据流动单向(半双工)单向(半双工)无方向,直接读写
数据传输需要内核介入,数据复制需要内核介入,数据复制无需内核介入,无复制
效率较低较低最高
生命周期随进程随文件随内核(需手动释放)

从只能“父子对话”的匿名管道,到可以“广交朋友”的命名管道,再到实现“零距离接触”的共享内存,我们看到了进程间通信技术为了解决不同问题而演进的路线。理解它们的原理和差异,将帮助我们在开发中根据具体场景选择最合适的 IPC 方案。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值