欢迎光临
我们一直在努力

POSIX 共享内存底层机制与 RAII 基础封装

POSIX 共享内存底层机制与 RAII 基础封装

进程间通信(IPC)的方式很多:管道、Socket、消息队列、共享内存。工程上选共享内存几乎只有一个理由——它是唯一能做到零拷贝的方案。这篇笔记从"为什么快"讲到"怎么用",再讲到"裸 API 太危险,怎么用 RAII 包一层",再到共享内存本身不管同步,工程里到底怎么加锁。

一、为什么共享内存的吞吐量最高

1.1 数据链路的物理差异

Linux 下每个进程拥有独立的虚拟地址空间,这是隔离性的来源,但也是 IPC 开销的来源。传统 IPC 和共享内存的本质区别,就在于数据要不要经过内核中转:

[ 传统 IPC (Socket / Pipe / Message Queue) ]
进程 A 用户空间 —(拷贝 1: write)–> 内核缓冲区 (Kernel Buffer)
|
进程 B 用户空间 <–(拷贝 2: read)— (内核调度/中转)

———————————————————————-

[ POSIX 共享内存 (shm_open + mmap) ]
进程 A 虚拟地址 (VMA A) \\
===> 物理内存页 (DRAM / tmpfs Page) [零拷贝数据传输]
进程 B 虚拟地址 (VMA B) /

维度传统 IPC(Socket/管道/MQ)POSIX 共享内存(shm_open + mmap)
内存拷贝次数 2 次(用户态 → 内核态 → 目标用户态) 0 次(物理内存页直连映射,读写即完成传输)
系统调用开销 高(每次收发都触发 read/write 或 send/recv 上下文切换) 极低(仅初始化建立映射时触发 mmap,数据传输 0 系统调用)
传输延迟 微秒级 纳秒到亚微秒级(依赖 CPU 缓存行命中)
同步开销 内核隐式同步(无数据时阻塞读,自动管理) 无隐式同步(必须显式配合信号量/互斥锁/无锁队列)

最后一行是关键:共享内存把"零拷贝"这个好处让给了你,但把"同步"这个麻烦事也甩给了你。这就是为什么共享内存看起来简单,实际踩坑最多——第六节会专门补上这块。

1.2 工业场景:V4L2 相机数据流零拷贝

域控制器或边缘 AI 节点处理图像流时,全链路零拷贝的路径大致是:

  • 硬件写入:相机 Sensor 采集数据经 ISP 写入内核预留的 DMA 缓冲区。
  • 内核映射:应用层通过 V4L2 的 ioctl(VIDIOC_REQBUFS) 申请缓存,再用 mmap() 把 /dev/videoX 对应的内核 DMA 内存直接投影到进程虚拟地址空间。
  • 跨进程共享:采集进程把这块内存封装为 POSIX 共享内存区域,AI 推理进程(TensorRT/OpenCV)用 mmap() 挂载同一块内存。
  • 全链路零拷贝:数据从 Sensor 硬件 DMA 落地到 AI 算力核推理,全程没有一次 CPU 内存拷贝。
  • 这也是为什么后面第七节会提到 DMA-BUF——普通 POSIX 共享内存只解决"CPU 进程间"的零拷贝,跨 CPU/GPU/NPU 设备的零拷贝需要另一套机制。

    二、POSIX 共享内存 API 与映射标志

    2.1 挂载点:shm_open 与 tmpfs

    POSIX 共享内存不是磁盘上的实体文件,而是挂载在 tmpfs(/dev/shm)上的虚拟文件系统节点:

    • shm_open("/my_shm", O_CREAT | O_RDWR, 0666) 会在 /dev/shm/my_shm 生成一个节点,返回标准文件描述符 fd。
    • 数据完全驻留在物理内存(RAM)与 Swap 分区,系统重启或断电后自动清空——它天生不是持久化方案。

    2.2 MAP_SHARED vs MAP_PRIVATE

    mmap 的 flags 参数决定虚拟内存页和底层对象的绑定逻辑,这是全篇最容易被记混的一对标志:

    [ MAP_SHARED ]
    进程 A 写入内存 ──> 直接更新物理内存页 ──> 进程 B 实时读取(多进程可见)

    [ MAP_PRIVATE ]
    进程 A 尝试写入 ──> 触发 CPU 页异常 ──> 内核分配新物理页 (COW) ──> 仅进程 A 可见(与原对象隔离)

    映射标志跨进程可见性写时复制(COW)数据写回行为典型场景
    MAP_SHARED 实时可见(各进程共享同一物理内存页) 修改直接作用于共享内存对象或物理文件 多进程 IPC、V4L2 图像流共享、DDS 零拷贝底座
    MAP_PRIVATE 互不可见(写隔离) 是(修改时触发内核页异常并分配私有页) 任何修改不会写回底层文件/共享内存 动态库(.so)加载、只读配置安全读取、沙箱解析

    一个直觉记忆点:动态库加载用的是 MAP_PRIVATE。你的进程加载 libc.so 时,如果代码段被改了(比如调试器打了断点),改动只对你的进程可见,不会污染磁盘上的 .so 文件,也不会影响同时加载同一个库的其他进程。

    2.3 void* 的系统级语义

    mmap 返回 void*,代表内核只交付一块不带类型信息的连续虚拟内存起始地址。void* 不能直接做指针算术(ptr + 1 在 C++ 里是非法操作),使用前必须显式转型:

    • 字节级偏移解析:转为 uint8_t* 或 char*(步长 1 字节)。
    • 结构体写入:用 static_cast<T*> 或 reinterpret_cast<T*> 映射为确切的数据结构。

    三、生命周期管理

    Linux 内核用引用计数管理 POSIX 共享内存对象的生命周期,这是最容易搞混的地方——"删除"共享内存到底是删了什么。

    +————————————————————————–+
    | 内核 POSIX SHM 物理内存对象 |
    | Ref Count = (打开此对象 fd 的进程数) + (已建立 mmap 映射的进程数) |
    +————————————————————————–+
    ^ ^
    | close(fd) | shm_unlink()
    | (仅减扣 fd 引用) | (从 /dev/shm 移除路径名)
    +———————–+ +———————–+
    | 进程 A 局部句柄 | | 全局文件系统命名空间 |
    +———————–+ +———————–+

    3.1 close(fd) vs shm_unlink()

    • close(fd):只关闭当前进程持有的文件描述符,引用计数减 1。物理对象不释放,/dev/shm/ 里的条目依然存在,其他进程仍可以 shm_open 打开并映射。
    • shm_unlink(name)(延迟销毁 / Lazy Unlink):从 /dev/shm 命名空间注销路径名,阻止新进程继续 shm_open 建立连接。但不立即回收物理内存——真正的回收要等到所有映射该区域的进程都 munmap 并关闭全部 fd(引用计数归零)后,内核才会彻底释放物理页。

    这个机制和 Linux 文件系统里"先 unlink 再 close 才真正删除文件"的语义是一致的,理解了后者就理解了前者。

    3.2 存储介质边界:RAM(tmpfs)vs 磁盘文件映射(msync)

  • POSIX 共享内存(shm_open):运行在 RAM / tmpfs,没有持久化落盘的概念。
  • 磁盘文件映射(open 磁盘文件 + mmap):映射受内核 Page Cache 纳管,进程写入内存后内核会异步刷新到 SSD/HDD。想要主动控制刷盘时机,就要用 msync(addr, len, flags):
    • MS_ASYNC:发起异步刷盘请求,立即返回。
    • MS_SYNC:阻塞等待内核把 Page Cache 强制落盘,保证断电崩溃时的数据一致性。
  • 两者的边界很清晰:shm_open 这条路径下的共享内存永远不需要调用 msync,因为它本来就没有对应的磁盘文件。

    四、核心 API 参考

    在动手封装 RAII 类之前,把用到的四个系统调用过一遍,尤其是容易踩坑的参数。

    mmap——建立内存映射

    #include <sys/mman.h> // 提供 mmap 原型、PROT_* 与 MAP_* 标志宏及 MAP_FAILED
    #include <sys/types.h> // 提供 off_t 类型定义

    void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

    • addr:映射的建议起始地址,直接填 nullptr,由内核自动选一个安全地址。
    • length:映射的内存字节数。
    • prot:内存保护权限,按位或组合以下几个宏:
      • PROT_READ:允许读取这块内存,读端至少要有这一位。
      • PROT_WRITE:允许写入。没有这一位的映射,写入会触发 SIGSEGV(段错误)。
      • PROT_EXEC:允许把这块内存当代码执行(比如动态加载器映射 .so 的代码段)。共享内存传数据几乎不会用到。
      • PROT_NONE:禁止任何访问,一般用来占位保留一段地址空间,或者故意制造访问就报错的"哨兵页"。
      • 常用组合是 PROT_READ | PROT_WRITE,只读挂载时用 PROT_READ。
    • flags:映射类型,常用的三个:
      • MAP_SHARED:共享映射,对内存的修改会同步给所有映射了同一对象的进程,并写回底层文件/共享内存对象。
      • MAP_PRIVATE:私有写时复制(COW),修改只在当前进程可见,不写回底层,典型用途是加载动态库、只读打开配置文件防止被意外改写。
      • MAP_ANONYMOUS(或 MAP_ANON):匿名映射,不绑定任何文件或共享内存对象,纯粹跟内核要一块内存,此时 fd 必须填 -1。常见于父子进程 fork 后共享一段匿名内存,不需要经过 shm_open 这套命名机制。
      • 这三者可以按位或组合,比如 MAP_SHARED | MAP_ANONYMOUS 就是"匿名但可跨进程共享",通常配合 fork 使用。
    • fd:shm_open 或 open 返回的文件描述符(匿名映射时传 -1)。
    • offset:映射起点在文件/共享内存中的偏移量,必须是页大小(通常 4096 字节)的整数倍,一般直接填 0。
    • 返回值:成功返回映射区首地址;失败返回 MAP_FAILED(即 (void*)-1),并设置 errno。

    munmap——解除内存映射

    #include <sys/mman.h>

    int munmap(void *addr, size_t length);

    addr 必须是 mmap 返回的地址,length 必须和 mmap 传入的 length 一致。成功返回 0,失败返回 -1。

    msync——强制刷盘(仅用于磁盘文件 + mmap 的持久化场景,tmpfs 下的共享内存无需调用)

    #include <sys/mman.h>

    int msync(void *addr, size_t length, int flags);

    flags 是以下几个标志的按位或组合:

    • MS_SYNC:同步刷盘,函数阻塞,直到数据真正写入磁盘才返回,用于要求强一致性的场景(比如写完必须落盘才能返回成功给上游)。
    • MS_ASYNC:异步刷盘,只是把"该刷了"的请求交给内核,函数立即返回,实际写盘时机由内核决定。
    • MS_INVALIDATE:让其他已经映射了同一文件的进程,其页缓存中的旧数据失效,下次访问时重新从磁盘读取最新内容。通常和 MS_SYNC/MS_ASYNC 之一搭配使用,单独用意义不大。

    MS_SYNC 和 MS_ASYNC 是互斥的,二者选一;MS_INVALIDATE 是可选的附加位。

    shm_open 与 ftruncate——配套的资源准备接口

    #include <sys/mman.h> // shm_open
    #include <fcntl.h> // O_CREAT, O_RDWR
    #include <sys/stat.h> // 权限标志

    int shm_open(const char *name, int oflag, mode_t mode);

    • name:共享内存对象的名字,POSIX 规定必须以 / 开头,且内部不能再有第二个 /(比如 /my_shm 合法,/dir/my_shm 不合法)。
    • oflag:打开方式,按位或组合,和 open() 的语义基本一致:
      • O_CREAT:对象不存在时创建,存在则直接打开。
      • O_EXCL:必须和 O_CREAT 一起用,若对象已存在则返回失败——用于保证"只有我能创建,不能覆盖已有对象"的场景。
      • O_RDONLY:只读打开,适合只读挂载的读端。
      • O_RDWR:读写打开,创建者一般用这个。
      • 常用组合:创建者 O_CREAT | O_RDWR,只读读端 O_RDONLY。
    • mode:新建对象时的权限位,格式和文件权限一致(比如 0666 表示所有用户可读写),只有配合 O_CREAT 才有意义;打开已存在对象时会被忽略。
    • 返回值:成功返回文件描述符;失败返回 -1 并设置 errno。

    #include <unistd.h> // ftruncate
    #include <sys/types.h> // off_t

    int ftruncate(int fd, off_t length);

    • fd:shm_open 返回的文件描述符。
    • length:目标大小(字节)。off_t 是 POSIX 定义的有符号整数类型,专门用来表示文件偏移量/大小,宽度通常是 64 位(取决于平台的 _FILE_OFFSET_BITS 设置),用它而不是 int/long 是为了在不同平台上都能安全表示超过 2GB 的大文件偏移。
    • 行为:如果 length 比对象当前大小大,会用空字节(\\0)填充多出的部分;如果比当前大小小,多余部分会被截断丢弃。共享内存场景一般只在创建时调用一次,把大小从 0 扩展到目标尺寸。
    • 返回值:成功返回 0;失败返回 -1 并设置 errno。

    shm_open 新创建的对象初始大小是 0 字节,如果不调用 ftruncate 扩展到目标尺寸,后续读写这块内存会直接触发 SIGBUS(总线错误)——这是新手最常见的第一个坑,进程会被内核直接杀掉,而且现场往往不好复现,因为它只在写入越界那一刻才炸。

    五、5 分钟跑通:写端 + 读端

    抽掉所有异常处理和封装,只看核心骨架,先建立"这就是在操作指针"的直觉。

    写端 shm_writer.cpp

    #include <iostream>
    #include <fcntl.h>
    #include <sys/mman.h>
    #include <sys/stat.h>
    #include <unistd.h>
    #include <cstring>

    int main() {
    // 1. 创建/打开共享内存对象,相当于在 /dev/shm 下开辟空间
    int fd = shm_open("/my_test_shm", O_CREAT | O_RDWR, 0666);
    if (fd == 1) { perror("shm_open failed"); return 1; }

    // 2. 确定大小为 1024 字节(不做这一步 mmap 后写入会 SIGBUS)
    ftruncate(fd, 1024);

    // 3. 把这块内存投影到当前进程的虚拟地址空间
    void* ptr = mmap(NULL, 1024, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    if (ptr == MAP_FAILED) { perror("mmap failed"); close(fd); return 1; }

    // 4. void* 转成 char*,像操作普通数组一样直接写
    char* data_ptr = static_cast<char*>(ptr);
    const char* msg = "Hello mmap zero-copy!";
    std::strcpy(data_ptr, msg); // 没有调用 write(),直接改内存

    std::cout << "Successfully wrote to mmap: " << data_ptr << std::endl;

    munmap(ptr, 1024);
    close(fd);
    shm_unlink("/my_test_shm"); // 彻底删除命名节点
    return 0;
    }

    读端 shm_reader.cpp

    #include <iostream>
    #include <fcntl.h>
    #include <sys/mman.h>
    #include <sys/stat.h>
    #include <unistd.h>

    int main() {
    int fd = shm_open("/my_test_shm", O_RDONLY, 0666);
    if (fd == 1) { perror("shm_open failed (请确保写入端已运行且尚未销毁)"); return 1; }

    void* ptr = mmap(NULL, 1024, PROT_READ, MAP_SHARED, fd, 0);
    if (ptr == MAP_FAILED) { perror("mmap failed"); close(fd); return 1; }

    char* data_ptr = static_cast<char*>(ptr);
    std::cout << "Reader received: " << data_ptr << std::endl;

    munmap(ptr, 1024);
    close(fd);
    return 0;
    }

    编译:

    g++ -std=c++17 shm_writer.cpp -o shm_writer -lrt
    g++ -std=c++17 shm_reader.cpp -o shm_reader -lrt

    联调时要注意:写端一跑完就 shm_unlink 把共享内存删了,读端根本来不及挂载。实际验证时在 shm_unlink 前加一句 sleep(10),跑起来后开另一个终端立刻执行 ./shm_reader,就能看到读端瞬间打印出 Reader received: Hello mmap zero-copy!——这就是最纯粹的零拷贝进程间通信。

    裸调用能跑通,但生产代码不会这么写:任何一步失败都要清理已经拿到的句柄,任何一个进程忘了 munmap/close/shm_unlink 都会造成资源泄漏或 /dev/shm 里的僵尸文件。这正是 RAII 要解决的问题。

    六、C++17 RAII 封装

    6.1 为什么裸 API 必须包一层

    三个系统调用 shm_open → ftruncate → mmap 构成一条初始化链,任何一步失败都必须回滚已经拿到的资源,否则就是资源泄漏:

    shm_open()→失败 throwftruncate()\\text{shm\\_open()} \\xrightarrow[\\text{失败 throw}]{} \\text{ftruncate()}shm_open()失败 throwftruncate()
    →失败 close(fd) & throwmmap()\\xrightarrow[\\text{失败 close(fd) \\& throw}]{} \\text{mmap()}失败 close(fd) & throwmmap()
    →失败 munmap()/close(fd) & throw完成初始化\\xrightarrow[\\text{失败 munmap()/close(fd) \\& throw}]{} \\text{完成初始化}失败 munmap()/close(fd) & throw完成初始化

    • shm_open 失败:直接抛 std::runtime_error(或更精确的 std::system_error),没有任何句柄需要清理。
    • ftruncate 失败:必须先 close(fd_),再抛异常——漏了这一步就是 fd 泄漏。
    • mmap 失败:必须先 close(fd_),再抛异常。

    手写这条链最容易犯的错,是在某个失败分支里忘了清理前面已经拿到的资源。用 RAII 把这条链封装进构造函数,配合异常,能保证"要么完全成功,要么完全回滚",不存在中间状态。

    6.2 资源独占:Rule of 5

    fd_ 和 addr_ 是独占系统资源,浅拷贝会导致两个对象的析构函数都对同一块内存调用 munmap,也就是 Double Free:

    SharedMemory(const SharedMemory&) = delete;
    SharedMemory& operator=(const SharedMemory&) = delete;

    移动语义则相反,必须支持——移动构造/移动赋值接管底层 fd_ 和 addr_,并把原对象的 fd_ 置为 -1、addr_ 置为 nullptr,让资源所有权无缝转移,同时保证原对象析构时不会误删已经转移出去的资源。

    析构函数只做两件事,顺序不能反:先解除映射,再关闭描述符。

    if (addr_ && addr_ != MAP_FAILED) { munmap(addr_, size_); }
    if (fd_ != 1) { close(fd_); }

    6.3 完整实现

    // SharedMemory.hpp
    #ifndef SHARED_MEMORY_HPP
    #define SHARED_MEMORY_HPP

    #include <string>
    #include <string_view>
    #include <stdexcept>
    #include <system_error>
    #include <utility>
    #include <fcntl.h>
    #include <sys/mman.h>
    #include <sys/stat.h>
    #include <unistd.h>

    class SharedMemory {
    public:
    // 构造函数:按顺序完成 shm_open -> ftruncate -> mmap 的资源获取
    SharedMemory(std::string_view name, size_t size, bool create_if_absent = true)
    : name_(name), size_(size) {

    int flags = create_if_absent ? (O_CREAT | O_RDWR) : O_RDWR;

    // 1. 创建或打开 POSIX 共享内存对象
    // 显式加 :: 调用全局 POSIX C API,防止被类内同名函数遮蔽(Name Shadowing)
    fd_ = ::shm_open(name_.c_str(), flags, 0666);
    if (fd_ == 1) {
    // std::system_error(错误码, 字典分类器, 上下文前缀)
    // errno 是系统调用的错误码,generic_category() 把它翻译成可读文本
    throw std::system_error(errno, std::generic_category(), "shm_open failed");
    }

    // 2. 仅在创建新对象时截断设置文件大小
    if (create_if_absent) {
    // shm_open 刚创建的对象初始大小为 0,必须在 mmap 前扩展,
    // 否则读写会触发 SIGBUS。只读挂载的进程不需要重复这一步。
    if (::ftruncate(fd_, size_) == 1) {
    ::close(fd_);
    fd_ = 1;
    throw std::system_error(errno, std::generic_category(), "ftruncate failed");
    }
    }

    // 3. 将物理内存页映射至进程虚拟地址空间
    addr_ = ::mmap(nullptr, size_, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0);
    if (addr_ == MAP_FAILED) {
    addr_ = nullptr;
    ::close(fd_);
    fd_ = 1;
    throw std::system_error(errno, std::generic_category(), "mmap failed");
    }
    }

    ~SharedMemory() { cleanup(); }

    // 禁止拷贝,防止 Double Free
    SharedMemory(const SharedMemory&) = delete;
    SharedMemory& operator=(const SharedMemory&) = delete;

    // 移动构造:接管资源所有权
    // name_ 是 std::string,管理堆内存,需要 std::move 转移;
    // 其余成员都是内置类型,没有堆资源可"偷",直接拷贝值即可。
    SharedMemory(SharedMemory&& other) noexcept
    : name_(std::move(other.name_)),
    size_(other.size_),
    addr_(other.addr_),
    fd_(other.fd_) {
    other.addr_ = nullptr;
    other.fd_ = 1;
    other.size_ = 0;
    }

    // 移动赋值:先释放自身资源,再接管对方资源
    SharedMemory& operator=(SharedMemory&& other) noexcept {
    if (this != &other) {
    cleanup();

    name_ = std::move(other.name_);
    size_ = other.size_;
    addr_ = other.addr_;
    fd_ = other.fd_;

    other.addr_ = nullptr;
    other.fd_ = 1;
    other.size_ = 0;
    }
    return *this;
    }

    // [[nodiscard]]:忽略返回值通常意味着逻辑错误或资源泄漏,编译期报警
    [[nodiscard]] void* data() const noexcept { return addr_; }
    [[nodiscard]] size_t size() const noexcept { return size_; }
    [[nodiscard]] int fd() const noexcept { return fd_; }

    // 静态辅助函数:主动销毁共享内存命名节点
    static void unlink(std::string_view name) noexcept {
    ::shm_unlink(std::string(name).c_str());
    }

    private:
    void cleanup() noexcept {
    if (addr_ && addr_ != MAP_FAILED) {
    ::munmap(addr_, size_);
    addr_ = nullptr;
    }
    if (fd_ != 1) {
    ::close(fd_);
    fd_ = 1;
    }
    }

    std::string name_; // 共享内存对象名称(如 "/my_shm"),用于 shm_open 和 shm_unlink
    size_t size_ = 0; // 共享内存大小(字节),用于 ftruncate 和 munmap
    void* addr_ = nullptr; // mmap 映射成功后的虚拟地址首地址
    int fd_ = 1; // shm_open 返回的文件描述符(-1 表示无效或已转移)
    };

    #endif // SHARED_MEMORY_HPP

    6.4 验证:创建、写入、移动、只读挂载

    // main.cpp
    #include <iostream>
    #include <cstring>
    #include "SharedMemory.hpp"

    int main() {
    const std::string shm_name = "/test_raii_shm";
    const size_t shm_size = 1024;

    SharedMemory::unlink(shm_name); // 清理上次残留

    try {
    std::cout << "[Step 1] Creating SharedMemory Object…" << std::endl;
    SharedMemory shm_writer(shm_name, shm_size, true);

    const char* msg = "Hello, RAII POSIX Shared Memory!";
    std::memcpy(shm_writer.data(), msg, std::strlen(msg) + 1);
    std::cout << "Wrote to SHM: " << static_cast<char*>(shm_writer.data()) << std::endl;

    std::cout << "[Step 2] Testing Move Semantics…" << std::endl;
    SharedMemory shm_moved = std::move(shm_writer);

    std::cout << "Original shm_writer addr after move: " << shm_writer.data() << std::endl; // 应为 nullptr
    std::cout << "Moved shm_moved data: " << static_cast<char*>(shm_moved.data()) << std::endl;

    std::cout << "[Step 3] Mapping Existing SharedMemory in Read-Only Mode…" << std::endl;
    SharedMemory shm_reader(shm_name, shm_size, false);
    std::cout << "Reader read data: " << static_cast<char*>(shm_reader.data()) << std::endl;

    } catch (const std::exception& e) {
    std::cerr << "Exception caught: " << e.what() << std::endl;
    return 1;
    }

    SharedMemory::unlink(shm_name);
    std::cout << "[Step 4] Cleaned up and exited safely." << std::endl;
    return 0;
    }

    这个 SharedMemory 类目前是"基础版":它只管资源的申请和释放,不管并发。下一节讲共享内存不带同步机制,工程上到底怎么办。

    七、共享内存不管同步,谁来管

    共享内存只解决了"数据怎么在两个进程间可见",没有解决"两个进程同时读写同一块内存会不会踩踏"。裸的 mmap 区域上跑并发读写,和多线程共享一个没加锁的变量是同一类问题,只是范围从线程扩大到了进程。

    7.1 跨进程互斥锁:pthread_mutex + PTHREAD_PROCESS_SHARED

    pthread_mutex_t 默认只能在同一进程的线程间使用,要跨进程共享,必须:

  • 把 pthread_mutex_t 对象本身也放进共享内存(不能放在某个进程的私有栈/堆上,否则另一个进程访问到的是野内存)。
  • 用 pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED) 显式声明这把锁要跨进程共享,再用这个 attr 初始化锁。
  • pthread_mutexattr_t attr;
    pthread_mutexattr_init(&attr);
    pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);

    // mutex_ptr 必须指向共享内存区域内部,比如共享内存起始地址的前 sizeof(pthread_mutex_t) 字节
    pthread_mutex_t* mutex_ptr = static_cast<pthread_mutex_t*>(shm.data());
    pthread_mutex_init(mutex_ptr, &attr);

    之后各进程 lock/unlock 这同一个 mutex_ptr 即可。一个容易忽略的风险点:如果持有锁的进程崩溃在临界区内,锁永远不会释放,其他进程会永久阻塞——这就是为什么生产系统里跨进程锁经常要配合超时(pthread_mutex_timedlock)或者健康检查机制。

    7.2 POSIX 信号量:sem_open / sem_init

    信号量比互斥锁更通用,除了做锁(二值信号量),还能做生产者-消费者的计数控制。两种用法:

    • 有名信号量:sem_open("/my_sem", O_CREAT, 0666, 1),类似 shm_open,靠名字跨进程关联,不需要放在共享内存里。
    • 无名信号量:sem_init(sem_ptr, 1, 1),第二个参数 pshared=1 表示跨进程共享,此时 sem_ptr 必须指向共享内存区域,用法上和 pthread_mutex 的跨进程用法类似。

    7.3 更进一步:无锁队列(Lock-Free Queue)

    锁的问题是有上下文切换开销,还有前面提到的死锁风险。对延迟极度敏感的场景(比如车载中间件里的高频传感器数据分发),通常会在共享内存上实现无锁的环形缓冲区(Ring Buffer / SPSC Queue),用 std::atomic 的读写指针配合内存序(memory_order)来保证正确性,完全不调用内核锁原语。这是一个足够独立的主题,值得单独展开一篇文章,这里先留一个坑:共享内存 + 原子变量 + 内存序,是工业级零拷贝通信框架(比如 DDS 的共享内存传输层、ROS2 的 intra-process 通信)的标准做法。

    八、现代 Linux(5.x/6.x)的演进

    linux中间件开发在经典 POSIX API 之上,通常会用到几个"进化版"接口:

    • memfd_create 匿名共享内存:正在部分取代 shm_open。它直接在内存中创建匿名文件,不需要在 /dev/shm 挂载显式路径,配合 UNIX Domain Socket 传递 fd,彻底避免了进程异常崩溃后 /dev/shm 遗留僵尸共享内存文件的问题——这也是 shm_open 方案的一个真实痛点:如果创建者进程被 kill -9,shm_unlink 根本没机会执行,残留文件只能靠外部清理脚本或重启处理。
    • DMA-BUF / NVMM 硬件级零拷贝:自动驾驶、机器人等端侧 AI 场景中,相机/点云原始数据需要在 CPU(Host)和 GPU/NPU(Device)之间共享。DMA-BUF 替代普通 POSIX 共享内存,实现跨硬件设备的零拷贝,这正是第一节 V4L2 场景里"全链路零拷贝"最终依赖的底层机制。
    • io_uring 高性能异步 IO:吞吐量要求极高的场景下,io_uring 正在部分取代传统的 epoll + read/write 模式。它和共享内存不是同一层的技术,但设计哲学一致:都是想办法绕开传统系统调用的固定开销。

    九、面试小结

    “进程间通信有多种方式,为什么共享内存的吞吐量最高?它有什么局限性?”

    回答骨架:

  • 零拷贝特性:传统 IPC(Socket/管道)需要"用户态 → 内核态缓冲区 → 目标进程用户态"两次拷贝;共享内存直接把同一块物理内存页映射到各进程虚拟地址空间,读写无需内核中转。
  • 硬件/缓存优势:数据直接在 CPU Cache/DRAM 中读写,没有系统调用带来的上下文切换开销,延迟可达纳秒/微秒级。
  • 局限与工程妥协:共享内存不提供同步机制,多进程同时读写容易产生数据竞争,必须额外引入跨进程互斥锁、信号量或无锁队列来控制;此外它没有内置的"有数据才唤醒"能力,不像管道/Socket 那样能阻塞等待,实际工程里往往还要搭配信号量或 futex 做等待通知。
  • 小结

    这篇笔记走了一条完整的路径:先从数据链路的物理层面理解共享内存为什么快(零拷贝),再进入 API 层理解 mmap 的标志位怎么选(MAP_SHARED vs MAP_PRIVATE),然后是最容易被忽视的生命周期管理(close 和 shm_unlink 的引用计数语义),接着落地到一个能在生产代码里直接用的 RAII 封装,最后补上原笔记留白但工程必备的同步机制,以及现代 Linux 上更贴近实际大厂场景的演进技术。

    裸 API 会用只是第一步,真正的工程能力在于:资源获取失败时怎么回滚、进程崩溃时资源会不会泄漏、多进程并发写入时怎么保证正确性。这三条线,分别对应本文的第六节、第三节和第七节。

    赞(0)
    未经允许不得转载:171主机测评 » POSIX 共享内存底层机制与 RAII 基础封装
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址