星光编译者头像
关注
【mmap 进程间通信】从共享内存到跨进程唤醒:用互斥锁、条件变量控制多个进程封面图

【mmap 进程间通信】从共享内存到跨进程唤醒:用互斥锁、条件变量控制多个进程

🔥 星光编译者 · 个人主页

📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》

🌄 向云端飞扬,编译属于自己的代码星河。


☕ 写在开篇

  你好,这里是 星光编译者

  这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。

  比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把“为什么会这样”和“应该怎样解决”说明白,让每一次踩坑都沉淀成可以复用的经验。

  如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

星光编译者博客开场动画

🔥 本文定位:以一个“控制端输入进程名,服务端唤醒多个工作进程,再由目标进程决定是否行动”的实验为主线,完整串起 shm_openftruncatemmap(MAP_SHARED)、进程共享互斥锁和进程共享条件变量。

💡 学习目标:理解共享内存只解决“看见同一份数据”,同步原语才解决“按正确顺序读写”;掌握跨进程 pthread_mutex_t / pthread_cond_t 的初始化条件;能写出带事件谓词、边界检查和清理顺序的可运行版本。

📌 阅读说明:示例面向 Linux,使用 POSIX 共享内存和 pthread,默认编译器支持 C++17。原始实验采用单缓冲区加广播唤醒,本文保留这条主线,并补上伪唤醒、丢通知、消息覆盖、启动顺序和生命周期等容易被忽略的问题。


文章目录


一、先看需求:一次通信其实包含数据与同步两条线

假设现在有一个服务端进程。它启动后再 fork 出 10 个工作进程,加上主进程本身,一共有 11 个执行者:

process-main
process-0
process-1
...
process-9

另有一个独立启动的客户端。用户在客户端输入命令:

process-3
all
end

我们希望得到这样的行为:

  • 输入 process-3:所有等待者都可以被唤醒,但只有 process-3 执行动作;
  • 输入 all:所有工作进程都执行动作;
  • 输入 end:所有工作进程结束等待并退出;
  • 客户端与服务端不依赖父子关系,它们通过同一个 POSIX 共享内存名称连接。

表面看,这是“把一个字符串从客户端传给服务端”。真正实现时,却必须同时解决两类问题。

1.1 数据线:命令放在哪里

如果客户端把命令写进自己的普通变量:

std::string command = "process-3";

服务端不会凭空看到它。每个进程拥有独立的虚拟地址空间,普通堆、栈和全局变量默认只属于当前进程。即使两个进程中的变量碰巧打印出同一个虚拟地址,也不代表它们背后是同一组物理页。

因此,需要一片能被多个进程共同映射的区域,用来放置命令内容和协议状态。

1.2 同步线:何时读、谁先写、谁应该行动

共享内存建立后,多个进程的确能看见同一批字节,但新的问题随之出现:

  1. 客户端写到一半,服务端能不能读取?
  2. 多个进程同时读取和修改状态,会不会形成数据竞争?
  3. 没有新命令时,工作进程是忙等,还是进入睡眠?
  4. 一次广播唤醒所有进程后,怎样保证只有目标进程行动?
  5. 条件变量发生伪唤醒时,怎样避免重复处理旧命令?

这就是互斥锁、条件变量和事件谓词存在的意义。

一句话概括:mmap 负责把同一片后端页接入多个地址空间;互斥锁负责保护共享状态;条件变量负责等待与通知;谓词负责判断“这次醒来是否真的有新事件”。


二、mmap 为什么能让不同进程看到同一份数据

mmap 的核心动作,不是“把文件内容复制进某个数组”,而是在当前进程的虚拟地址空间中建立一段映射关系。

当多个进程都使用 MAP_SHARED 映射同一个后端对象时,它们各自页表中的虚拟页,最终可以指向同一组后端页。进程 A 写入这些页后,进程 B 再从自己的映射区域读取,就能观察到更新。

在这里插入图片描述

2.1 虚拟地址不同,不影响共享

假设三个进程分别得到以下返回值:

进程 A:0x7f10...
进程 B:0x7a90...
进程 C:0x7010...

这些地址完全可以不同。mmap(nullptr, ...) 会让内核为每个进程选择合适的虚拟地址,而进程间共享的是地址翻译后的后端页,不是数值相同的指针。

因此,共享区域中不应该保存只在某个进程有效的裸指针。例如:

struct BadState
{
    char* data;   // 错误:这个地址只在创建它的进程中有意义
};

即使 data 字段本身位于共享内存中,它指向的普通堆内存仍然属于原进程。另一个进程拿到同样的地址值后,可能对应未映射区域,也可能对应完全不同的对象。

跨进程数据结构更适合使用:

  • 固定大小数组;
  • 相对共享区域起点的偏移量;
  • 明确长度的 POD 风格字段;
  • 专门设计的共享内存分配器。

2.2 MAP_SHAREDMAP_PRIVATE 的区别

调用形式如下:

void* addr = mmap(
    nullptr,
    size,
    PROT_READ | PROT_WRITE,
    MAP_SHARED,
    fd,
    0
);

其中最关键的是 MAP_SHARED

  • 当前进程对映射区的写入对其他映射同一对象的进程可见;
  • 对文件支持的共享映射,修改还会反映到底层对象;
  • 这正是进程间共享数据所需要的语义。

如果换成 MAP_PRIVATE,得到的是私有的写时复制视图。进程可以读取初始内容,但后续写入不会成为其他进程共同观察到的共享修改。

2.3 为什么这里不用 MAP_SHARED | MAP_ANONYMOUS

匿名共享映射适合“先映射、后 fork”的亲缘进程:子进程继承父进程已有的映射,不需要名称再次打开。

本文还存在一个独立启动的客户端。它不是服务端 fork 出来的,无法继承那段匿名映射,所以需要一个可重新定位的后端对象:

/mmap_ipc_demo

客户端把这个名称交给 shm_open,就能打开同一个 POSIX 共享内存对象,再通过 mmap 接入自己的地址空间。


三、从 shm_open 到 mmap:共享区域是怎样建立的

POSIX 共享内存对象很像一种“可被映射的命名内核对象”。在 Linux 上,常能在 /dev/shm 观察到对应条目,但程序应该通过 shm_open / shm_unlink 管理它,而不是依赖具体挂载路径。

3.1 服务端调用链

服务端是共享区域的所有者,职责包含创建、定长、映射和初始化:

shm_open(O_CREAT | O_EXCL | O_RDWR)
    ↓
ftruncate(fd, sizeof(SharedState))
    ↓
mmap(PROT_READ | PROT_WRITE, MAP_SHARED)
    ↓
初始化 mutex、cond、version、command
    ↓
fork 工作进程并进入等待循环

O_EXCLO_CREAT 一起使用,可以避免两个服务端同时把自己当作初始化者。若对象已存在,创建会以 EEXIST 失败,比悄悄覆盖正在使用的共享状态安全得多。

3.2 为什么 ftruncate 不能省

新建 POSIX 共享内存对象时,它的初始长度通常为 0。mmap 的长度参数只说明“想建立多大的虚拟映射”,不会自动把底层对象扩展到同样大小。

因此,服务端必须先执行:

ftruncate(fd, sizeof(SharedState));

如果映射范围超出底层对象有效长度,真正访问越界页时可能收到 SIGBUS。这类错误很迷惑:mmap 看起来已经成功,程序却在随后读写某个字段时突然崩溃。

3.3 客户端调用链

客户端不是所有者,不应该重复初始化共享锁和条件变量:

shm_open(O_RDWR)
    ↓
fstat 检查对象长度
    ↓
mmap(PROT_READ | PROT_WRITE, MAP_SHARED)
    ↓
加锁、写命令、递增版本号、广播

在这里插入图片描述

这也决定了最简单可靠的启动顺序:

# 终端 1
./server

# 终端 2
./client

如果先启动客户端,shm_open 应明确失败并提示“服务端尚未创建共享对象”,而不是由客户端擅自创建一片尚未初始化的区域。

3.4 共享内存名称的约束

为了可移植,POSIX 共享内存名称通常写成:

/name

也就是以 / 开头,后面不再出现其他 /。本文使用:

inline constexpr char kSharedMemoryName[] = "/mmap_ipc_demo";

它不是普通路径名,不能写成:

./mmap_ipc_demo
/tmp/mmap_ipc_demo

四、互斥锁与条件变量怎样跨越进程边界

很多人第一次把 pthread_mutex_t 放进共享内存后,会发现程序依旧不可靠。原因是:pthread 同步对象默认用于同一进程内的线程,而不是不同进程。

想让它们跨进程工作,必须同时满足两个条件。

4.1 条件一:同步对象本体位于共享内存

下面这种写法没有意义:

pthread_mutex_t mutex;  // 位于各进程自己的普通内存

每个进程得到的是自己的锁对象。进程 A 锁住它,不会阻止进程 B 锁住 B 自己的副本。

正确做法是把同步对象直接放进共享结构:

struct SharedState
{
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    std::uint64_t version;
    char command[256];
};

在这里插入图片描述

4.2 条件二:属性设置为 PTHREAD_PROCESS_SHARED

互斥锁初始化:

pthread_mutexattr_t mutex_attr;
pthread_mutexattr_init(&mutex_attr);
pthread_mutexattr_setpshared(
    &mutex_attr,
    PTHREAD_PROCESS_SHARED
);
pthread_mutex_init(&state->mutex, &mutex_attr);
pthread_mutexattr_destroy(&mutex_attr);

条件变量初始化:

pthread_condattr_t cond_attr;
pthread_condattr_init(&cond_attr);
pthread_condattr_setpshared(
    &cond_attr,
    PTHREAD_PROCESS_SHARED
);
pthread_cond_init(&state->cond, &cond_attr);
pthread_condattr_destroy(&cond_attr);

两个条件缺一不可:

同步对象位置pshared 属性结果
普通进程内存PROCESS_PRIVATE只能同步本进程线程
共享内存PROCESS_PRIVATE对象虽然可见,但行为不满足跨进程同步要求
普通进程内存PROCESS_SHARED其他进程仍然拿不到同一个对象
共享内存PROCESS_SHARED可以作为跨进程同步原语

4.3 初始化只能由一个进程完成

服务端负责初始化,客户端只负责连接。否则两个进程可能同时对同一段字节执行 pthread_mutex_init,共享对象会进入未定义状态。

同样,销毁也只能发生一次,而且必须确认:

  • 没有进程持有互斥锁;
  • 没有进程仍在条件变量上等待;
  • 没有进程即将再次访问共享区域。

这就是为什么资源所有权和启动/退出协议不能省略。

4.4 pthread 错误码不能只看 errno

多数 pthread 函数成功返回 0,失败时直接返回错误码,而不一定设置 errno。因此推荐这样处理:

void CheckPthread(int code, const char* operation)
{
    if (code != 0)
    {
        throw std::system_error(
            code,
            std::generic_category(),
            operation
        );
    }
}

而不是只写:

pthread_mutex_lock(&mutex);
perror("pthread_mutex_lock"); // errno 可能不是这次失败的原因

五、通信协议设计:广播负责唤醒,谓词负责判断

原始实验使用一个字符串缓冲区:客户端写入目标进程名,然后调用 pthread_cond_broadcast 唤醒所有等待者。这个思路很适合演示“广播通知 + 自主筛选”。

5.1 为什么广播后还要检查命令

假设客户端发送:

process-1

pthread_cond_broadcast 不知道谁叫 process-1。它只知道某个条件变量上有哪些等待者,所以会把所有等待者唤醒。

每个工作进程重新获得互斥锁后,读取同一条命令:

if (command == process_name || command == "all")
{
    DoWork();
}

于是:

  • process-1 执行动作;
  • 其他进程发现目标不是自己,忽略本轮;
  • all 会被每个工作进程接受;
  • end 会让每个工作进程跳出循环。

在这里插入图片描述

5.2 仅有字符串还不够

如果共享结构只有:

char command[256];

工作进程无法可靠区分:

  • 这是刚发布的新命令;
  • 还是上一次唤醒留下的旧字符串;
  • 这次返回只是条件变量的伪唤醒;
  • 自己是否在真正进入等待前错过了广播。

所以需要一个事件谓词。本文使用单调递增的版本号:

std::uint64_t version;

每个等待者在自己的私有内存中维护:

std::uint64_t seen = 0;

客户端每发布一次命令,就在持锁状态下执行:

CopyCommand();
++state->version;
pthread_cond_broadcast(&state->cond);

等待者则使用:

while (state->version == seen)
{
    pthread_cond_wait(&state->cond, &state->mutex);
}

seen = state->version;

这样,即使广播发生在某个工作进程真正睡眠之前,它稍后获得锁时仍会发现 version != seen,直接读取新命令,而不是永远睡下去。

5.3 锁保护的不是一行代码,而是不变量

客户端的关键区间必须把“写命令、更新版本、发送通知”看成一个整体:

lock
  写 command
  version++
  broadcast
unlock

工作进程的关键区间则是:

lock
  while (version == seen)
      cond_wait
  拷贝 command
  seen = version
unlock

命令要在锁内复制到当前进程的局部 std::string,随后再解锁。否则客户端可能在工作进程使用共享字符数组时覆盖它。


六、完整实现:SharedMem.hpp、Server.cc、Client.cc

下面给出一份可以直接放进 Linux 环境编译的版本。它与原实验保持相同交互方式,但增加了:

  • O_CREAT | O_EXCL,确保只有一个初始化者;
  • fstat,让客户端检查对象长度;
  • version 谓词,抵抗伪唤醒和“先通知、后等待”;
  • 有界字符串复制并保证 \0 结尾;
  • -pthread 编译/链接选项;
  • 明确区分创建者和连接者的清理职责。

6.1 SharedMem.hpp

#pragma once

#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <system_error>

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

namespace ipc
{

inline constexpr char kSharedMemoryName[] = "/mmap_ipc_demo";
inline constexpr std::size_t kCommandCapacity = 256;

struct SharedState
{
    pthread_mutex_t mutex;
    pthread_cond_t cond;
    std::uint64_t version;
    char command[kCommandCapacity];
};

inline void CheckPthread(int code, const char* operation)
{
    if (code != 0)
    {
        throw std::system_error(
            code,
            std::generic_category(),
            operation
        );
    }
}

class SharedMemory
{
public:
    enum class Mode
    {
        Create,
        OpenExisting
    };

    explicit SharedMemory(Mode mode)
        : owner_(mode == Mode::Create)
    {
        const int flags = owner_
            ? (O_CREAT | O_EXCL | O_RDWR)
            : O_RDWR;

        fd_ = ::shm_open(kSharedMemoryName, flags, 0600);
        if (fd_ == -1)
        {
            throw std::system_error(
                errno,
                std::generic_category(),
                "shm_open"
            );
        }

        if (owner_)
        {
            if (::ftruncate(fd_, sizeof(SharedState)) == -1)
            {
                FailConstruction("ftruncate");
            }
        }
        else
        {
            struct stat info {};
            if (::fstat(fd_, &info) == -1)
            {
                FailConstruction("fstat");
            }

            if (info.st_size < static_cast<off_t>(sizeof(SharedState)))
            {
                const int saved = EINVAL;
                ::close(fd_);
                fd_ = -1;
                throw std::system_error(
                    saved,
                    std::generic_category(),
                    "shared memory is smaller than SharedState"
                );
            }
        }

        void* address = ::mmap(
            nullptr,
            sizeof(SharedState),
            PROT_READ | PROT_WRITE,
            MAP_SHARED,
            fd_,
            0
        );

        if (address == MAP_FAILED)
        {
            FailConstruction("mmap");
        }

        state_ = static_cast<SharedState*>(address);

        // mmap 成功后即可关闭 fd,映射仍然有效。
        if (::close(fd_) == -1)
        {
            const int saved = errno;
            ::munmap(state_, sizeof(SharedState));
            state_ = nullptr;
            if (owner_)
            {
                ::shm_unlink(kSharedMemoryName);
            }
            throw std::system_error(
                saved,
                std::generic_category(),
                "close"
            );
        }
        fd_ = -1;

        if (owner_)
        {
            try
            {
                InitializeState();
            }
            catch (...)
            {
                ::munmap(state_, sizeof(SharedState));
                state_ = nullptr;
                ::shm_unlink(kSharedMemoryName);
                throw;
            }
        }
    }

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

    ~SharedMemory()
    {
        if (state_ == nullptr)
        {
            return;
        }

        if (owner_)
        {
            const int cond_code = ::pthread_cond_destroy(&state_->cond);
            if (cond_code != 0)
            {
                std::fprintf(
                    stderr,
                    "pthread_cond_destroy: %s\n",
                    std::strerror(cond_code)
                );
            }

            const int mutex_code = ::pthread_mutex_destroy(&state_->mutex);
            if (mutex_code != 0)
            {
                std::fprintf(
                    stderr,
                    "pthread_mutex_destroy: %s\n",
                    std::strerror(mutex_code)
                );
            }
        }

        if (::munmap(state_, sizeof(SharedState)) == -1)
        {
            std::perror("munmap");
        }
        state_ = nullptr;

        if (owner_ && ::shm_unlink(kSharedMemoryName) == -1)
        {
            if (errno != ENOENT)
            {
                std::perror("shm_unlink");
            }
        }
    }

    std::string WaitNext(std::uint64_t& seen)
    {
        CheckPthread(
            ::pthread_mutex_lock(&state_->mutex),
            "pthread_mutex_lock"
        );

        while (state_->version == seen)
        {
            const int code = ::pthread_cond_wait(
                &state_->cond,
                &state_->mutex
            );

            if (code != 0)
            {
                ::pthread_mutex_unlock(&state_->mutex);
                CheckPthread(code, "pthread_cond_wait");
            }
        }

        // 必须在锁内复制,共享缓冲区随后可能被客户端覆盖。
        std::string command = state_->command;
        seen = state_->version;

        CheckPthread(
            ::pthread_mutex_unlock(&state_->mutex),
            "pthread_mutex_unlock"
        );

        return command;
    }

    void Publish(const std::string& command)
    {
        CheckPthread(
            ::pthread_mutex_lock(&state_->mutex),
            "pthread_mutex_lock"
        );

        std::snprintf(
            state_->command,
            kCommandCapacity,
            "%.*s",
            static_cast<int>(kCommandCapacity - 1),
            command.c_str()
        );

        ++state_->version;

        const int broadcast_code =
            ::pthread_cond_broadcast(&state_->cond);

        const int unlock_code =
            ::pthread_mutex_unlock(&state_->mutex);

        CheckPthread(
            broadcast_code,
            "pthread_cond_broadcast"
        );
        CheckPthread(
            unlock_code,
            "pthread_mutex_unlock"
        );
    }

private:
    [[noreturn]] void FailConstruction(const char* operation)
    {
        const int saved = errno;

        if (fd_ != -1)
        {
            ::close(fd_);
            fd_ = -1;
        }

        if (owner_)
        {
            ::shm_unlink(kSharedMemoryName);
        }

        throw std::system_error(
            saved,
            std::generic_category(),
            operation
        );
    }

    void InitializeState()
    {
        std::memset(state_, 0, sizeof(SharedState));

        pthread_mutexattr_t mutex_attr {};
        CheckPthread(
            ::pthread_mutexattr_init(&mutex_attr),
            "pthread_mutexattr_init"
        );

        int code = ::pthread_mutexattr_setpshared(
            &mutex_attr,
            PTHREAD_PROCESS_SHARED
        );
        if (code != 0)
        {
            ::pthread_mutexattr_destroy(&mutex_attr);
            CheckPthread(code, "pthread_mutexattr_setpshared");
        }

        code = ::pthread_mutex_init(&state_->mutex, &mutex_attr);
        ::pthread_mutexattr_destroy(&mutex_attr);
        CheckPthread(code, "pthread_mutex_init");

        pthread_condattr_t cond_attr {};
        code = ::pthread_condattr_init(&cond_attr);
        if (code != 0)
        {
            ::pthread_mutex_destroy(&state_->mutex);
            CheckPthread(code, "pthread_condattr_init");
        }

        code = ::pthread_condattr_setpshared(
            &cond_attr,
            PTHREAD_PROCESS_SHARED
        );
        if (code != 0)
        {
            ::pthread_condattr_destroy(&cond_attr);
            ::pthread_mutex_destroy(&state_->mutex);
            CheckPthread(code, "pthread_condattr_setpshared");
        }

        code = ::pthread_cond_init(&state_->cond, &cond_attr);
        ::pthread_condattr_destroy(&cond_attr);
        if (code != 0)
        {
            ::pthread_mutex_destroy(&state_->mutex);
            CheckPthread(code, "pthread_cond_init");
        }

        state_->version = 0;
        state_->command[0] = '\0';
    }

private:
    bool owner_ {false};
    int fd_ {-1};
    SharedState* state_ {nullptr};
};

} // namespace ipc

6.2 Server.cc

#include "SharedMem.hpp"

#include <cerrno>
#include <cstdlib>
#include <iostream>
#include <string>
#include <system_error>
#include <vector>

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

void RunWorker(ipc::SharedMemory& memory, const std::string& process_name)
{
    std::cout
        << "process is running: "
        << process_name
        << std::endl;

    std::uint64_t seen = 0;

    while (true)
    {
        const std::string command = memory.WaitNext(seen);

        if (command == "end")
        {
            std::cout
                << process_name
                << " is quit!"
                << std::endl;
            break;
        }

        if (command == process_name || command == "all")
        {
            std::cout
                << process_name
                << " is active!"
                << std::endl;
        }
    }
}

void WaitChildren(const std::vector<pid_t>& children)
{
    for (pid_t child : children)
    {
        while (::waitpid(child, nullptr, 0) == -1)
        {
            if (errno == EINTR)
            {
                continue;
            }

            std::perror("waitpid");
            break;
        }
    }
}

int main()
{
    try
    {
        ipc::SharedMemory memory(ipc::SharedMemory::Mode::Create);
        std::vector<pid_t> children;

        for (int i = 0; i < 10; ++i)
        {
            const pid_t id = ::fork();

            if (id == -1)
            {
                std::perror("fork");

                // 已创建的子进程可能正在等待,让它们统一退出。
                memory.Publish("end");
                WaitChildren(children);
                return EXIT_FAILURE;
            }

            if (id == 0)
            {
                RunWorker(
                    memory,
                    "process-" + std::to_string(i)
                );

                // 子进程不能执行所有者析构,否则会销毁共享对象。
                ::_exit(EXIT_SUCCESS);
            }

            children.push_back(id);
        }

        RunWorker(memory, "process-main");
        WaitChildren(children);

        // 离开作用域后:销毁同步原语、munmap、shm_unlink。
        return EXIT_SUCCESS;
    }
    catch (const std::system_error& error)
    {
        std::cerr << error.what() << std::endl;
        return EXIT_FAILURE;
    }
}

这里有一个很容易忽略的细节:服务端在 fork 之前创建 SharedMemory 对象,子进程会继承映射,也会继承 owner_ == true 这个普通成员。

如果子进程用普通 returnstd::exit 走完所有者析构,就可能销毁互斥锁、条件变量并调用 shm_unlink。因此示例在子进程完成后使用 _exit,不执行继承来的 C++ 析构逻辑;真正的所有者清理由父进程完成。

在更大的项目中,最好进一步把“可继承的映射视图”和“仅父进程拥有的清理令牌”拆成不同类型,避免靠调用约定维持所有权。

6.3 Client.cc

#include "SharedMem.hpp"

#include <cstdlib>
#include <iostream>
#include <string>
#include <system_error>

int main()
{
    try
    {
        ipc::SharedMemory memory(
            ipc::SharedMemory::Mode::OpenExisting
        );

        std::string command;

        while (true)
        {
            std::cout << "Please Enter# " << std::flush;

            if (!std::getline(std::cin, command))
            {
                break;
            }

            if (command.empty())
            {
                continue;
            }

            memory.Publish(command);

            if (command == "end")
            {
                break;
            }
        }

        return EXIT_SUCCESS;
    }
    catch (const std::system_error& error)
    {
        std::cerr
            << "client: "
            << error.what()
            << "\n请先启动 server,并确认没有残留的旧对象。"
            << std::endl;
        return EXIT_FAILURE;
    }
}

6.4 Makefile

CXX := g++
CXXFLAGS := -std=c++17 -Wall -Wextra -Wpedantic -O2 -g -pthread
CPPFLAGS :=
LDLIBS := -pthread -lrt

TARGETS := server client
OBJS := Server.o Client.o
DEPS := $(OBJS:.o=.d)

.PHONY: all clean

all: $(TARGETS)

server: Server.o
	$(CXX) $^ $(LDLIBS) -o $@

client: Client.o
	$(CXX) $^ $(LDLIBS) -o $@

%.o: %.cc
	$(CXX) $(CPPFLAGS) $(CXXFLAGS) -MMD -MP -c $< -o $@

-include $(DEPS)

clean:
	$(RM) $(TARGETS) $(OBJS) $(DEPS)

Makefile 配方行必须以 Tab 开头。-pthread 不只是一个普通链接库选项,它还会让编译器启用线程相关的编译和链接设置,因此比只写 -lpthread 更合适。

旧版本 Linux/glibc 环境常要求 shm_open 链接 -lrt;较新的 glibc 已把相关符号并入 libc。保留 -lrt 能覆盖更多常见教学环境。


七、编译与运行:观察一次定向唤醒

目录结构:

mmap_ipc/
├── SharedMem.hpp
├── Server.cc
├── Client.cc
└── Makefile

7.1 编译

make

正常情况下生成:

server
client

7.2 启动服务端

终端一:

./server

可能看到:

process is running: process-0
process is running: process-1
process is running: process-2
...
process is running: process-9
process is running: process-main

进程输出顺序不保证固定。fork 之后谁先被调度,取决于内核调度器。

7.3 启动客户端并发送命令

终端二:

./client

输入:

Please Enter# process-3
Please Enter# all
Please Enter# end

服务端可能输出:

process-3 is active!
process-7 is active!
process-2 is active!
process-main is active!
...
process-0 is quit!
process-1 is quit!
...
process-main is quit!

all 对应的多行输出顺序同样不固定。每个等待者都被唤醒,但它们需要逐个重新获得互斥锁,复制命令,再释放锁。

7.4 用系统工具观察共享对象

服务端运行期间,可以查看:

ls -l /dev/shm

通常会看到类似:

mmap_ipc_demo

还可以查看映射:

grep mmap_ipc_demo /proc/$(pidof server)/maps

注意,一个程序中包含父进程和多个子进程时,pidof server 可能返回多个 PID。调试时可以选择其中一个 PID 单独查看。

7.5 上次异常退出留下对象怎么办

如果服务端被 kill -9 终止,它没有机会执行 shm_unlink。下一次使用 O_CREAT | O_EXCL 创建时会得到 EEXIST

确认没有旧服务端仍在运行后,可以在实验环境中清理:

rm -f /dev/shm/mmap_ipc_demo

生产程序更适合提供独立的管理命令,或在启动时读取对象中的 magic、版本号和 owner PID,再决定是拒绝启动、接管还是清理,不能看到 EEXIST 就无条件删除。


八、为什么条件等待必须写成 while

条件变量最重要的一条使用规则是:

条件变量本身不保存“事件已经发生”的事实;共享谓词才保存事实。等待者每次返回后,都必须在持锁状态下重新检查谓词。

在这里插入图片描述

8.1 pthread_cond_wait 做了什么

调用前,线程或进程必须已经持有互斥锁:

pthread_mutex_lock(&state->mutex);
pthread_cond_wait(&state->cond, &state->mutex);

pthread_cond_wait 会把两个动作作为不可分割的等待转换完成:

  1. 释放 mutex
  2. 进入条件变量等待队列并睡眠。

被唤醒后,它会在返回调用者之前重新获得 mutex。因此函数返回时,调用者再次持有锁,可以安全检查共享状态。

如果“释放锁”和“进入等待”是两个普通步骤,中间就会存在经典竞态:发布者恰好在这条缝隙里修改谓词并发出通知,等待者随后睡下,从而错过事件。条件变量 API 正是为了关闭这条缝隙。

8.2 为什么不能使用 if

错误写法:

if (state->version == seen)
{
    pthread_cond_wait(&state->cond, &state->mutex);
}

UseCommand(state->command);

它假设“从 wait 返回,就一定出现了自己需要的新命令”,这个假设不成立。

可能的情况包括:

  • 伪唤醒:没有发布者发出目标事件,等待函数仍然返回;
  • 广播竞争:多个等待者同时醒来,但重新获得互斥锁有先后顺序;
  • 谓词已被其他参与者改变:轮到当前等待者时,条件可能已不再成立;
  • 旧通知与新等待交错:仅凭一次函数返回无法证明版本已变化。

正确写法:

while (state->version == seen)
{
    pthread_cond_wait(&state->cond, &state->mutex);
}

这里的 while 不是为了“多等几次”,而是为了把条件变量降级为纯通知机制,把真相交还给受互斥锁保护的共享谓词。

8.3 广播为什么通常在持锁状态下发生

本文的发布顺序是:

pthread_mutex_lock(&state->mutex);
CopyCommand();
++state->version;
pthread_cond_broadcast(&state->cond);
pthread_mutex_unlock(&state->mutex);

等待者虽然被广播唤醒,但在客户端解锁之前无法通过 pthread_cond_wait 返回,因为它还要重新获得同一把锁。

这样可以保证:当等待者真正继续执行时,命令和版本号已经构成一致状态。通知与谓词更新之间没有可见的中间状态。


九、close、munmap 与 shm_unlink 的生命周期

共享内存清理经常被混成一句“关闭文件”。实际上至少有三种资源:

  1. shm_open 返回的文件描述符;
  2. 当前进程中的虚拟内存映射;
  3. 可由名称重新打开的 POSIX 共享内存对象。

在这里插入图片描述

9.1 close(fd):关闭句柄

mmap 成功后,可以立即关闭文件描述符:

void* address = mmap(...);
close(fd);

关闭 fd 不会解除已有映射。当前进程仍然可以通过 address 访问共享页。

这也是示例构造函数在映射成功后立刻关闭 fd_ 的原因:后续读写通过虚拟内存完成,不需要长期占用描述符。

9.2 munmap:解除当前进程的映射

munmap(address, sizeof(SharedState));

它只删除当前进程指定地址范围的映射。其他进程仍可继续使用自己的映射。

解除后再访问原地址属于非法内存访问,通常会触发崩溃。每个进程退出时,内核也会自动清理该进程仍持有的映射,但显式 munmap 更能表达所有权并方便错误检查。

9.3 shm_unlink(name):删除名称

shm_unlink("/mmap_ipc_demo");

它让这个名称不能再被新的 shm_open 打开,语义类似普通文件的 unlink。已有的打开句柄和映射不会因此瞬间失效;当最后一个引用消失后,底层对象才真正释放。

这带来一个实用设计:服务端可以在确认所有参与者已经连接后提前 shm_unlink,让对象最终自动消失。但本文需要允许客户端在服务端运行期间随时启动,所以把 shm_unlink 放在服务端结束阶段。

9.4 正确的结束顺序

本文结束过程是:

client 发布 end 并解锁
    ↓
全部 worker 醒来并退出等待循环
    ↓
父进程 waitpid 回收子进程
    ↓
父进程销毁 cond 和 mutex
    ↓
父进程 munmap
    ↓
父进程 shm_unlink

如果还有等待者时就调用 pthread_cond_destroy,或还有持锁者时就调用 pthread_mutex_destroy,结果不是“替它们强制收尾”,而是程序错误。


十、原始单槽方案的边界与工程化改进

到这里,这个实验已经能稳定演示:共享映射、跨进程锁、条件等待、广播唤醒和按名称筛选。但它仍然不是一个通用消息队列。

10.1 单槽会覆盖中间命令

共享状态只有一个:

char command[256];

假设客户端极快地发布:

process-1
process-2
process-3

某个工作进程还没来得及读取第一条命令,缓冲区就可能已变成第三条。version 能告诉它“发生过变化”,却不能恢复被覆盖的中间内容。

所以这个协议适合:

  • 单个交互式客户端;
  • 低频控制命令;
  • 只关心最新状态;
  • 教学演示广播与筛选。

不适合:

  • 每条消息都必须处理一次;
  • 高频生产者;
  • 多生产者并发排队;
  • 大块可变长度数据;
  • 需要确认、重试、超时和持久化的任务。

10.2 想保证每条消息不丢,需要环形队列

可以把共享结构扩展为:

struct Message
{
    std::uint64_t sequence;
    char target[32];
    char payload[224];
};

struct SharedQueue
{
    pthread_mutex_t mutex;
    pthread_cond_t not_empty;
    pthread_cond_t not_full;

    std::size_t head;
    std::size_t tail;
    std::size_t count;

    Message slots[64];
};

生产者在队列满时等待 not_full,消费者在队列空时等待 not_empty。这样协议从“最新值广播”升级为“有界消息队列”。

不过,多个消费者怎样分配定向消息又是下一层设计:

  • 所有消费者抢同一队列,再把不属于自己的消息放回去,通常很低效;
  • 每个 worker 一条独立队列,定向最清晰,但共享状态更大;
  • 一个调度进程读取总队列,再分发到每个 worker 的邮箱;
  • 使用 Unix 域套接字、消息队列或成熟 IPC 库,让内核/库承担更多协议工作。

10.3 broadcast 有惊群成本

只为了唤醒 process-3,却让所有工作进程都参与一次调度和互斥锁竞争,这就是典型的惊群效应。

进程数量很少、命令很低频时,它足够直观;进程数达到几百、几千时,应考虑:

  • 每个工作进程独立条件变量;
  • POSIX 信号量;
  • eventfd + epoll
  • Unix 域套接字;
  • POSIX/System V 消息队列;
  • 共享环形队列配合更精确的通知机制。

10.4 进程异常退出会怎样

如果某个进程持有普通进程共享互斥锁时崩溃,其他进程可能永远阻塞。

Linux/pthread 提供 robust mutex 机制,可以把互斥锁属性设置为 robust。下一个获得锁的进程可能收到 EOWNERDEAD,负责检查共享状态、修复不变量,再调用 pthread_mutex_consistent

但 robust mutex 不是自动恢复:

  • 它只能告诉你上一个持锁者异常死亡;
  • 共享数据可能处于写到一半的状态;
  • 恢复者必须知道怎样把状态修复到一致点;
  • 修复失败时应把共享区域标记为不可用,而不是继续带病运行。

10.5 共享结构需要版本和兼容性标记

如果服务端和客户端不是同一次构建,sizeof(SharedState)、字段对齐、pthread 类型大小都可能不一致。

工程代码常增加:

struct Header
{
    std::uint32_t magic;
    std::uint16_t abi_version;
    std::uint16_t header_size;
    std::uint32_t total_size;
    std::uint32_t ready;
};

客户端映射后先验证 magic、ABI 版本和大小,再访问后续字段。不同架构、不同 libc 或不同语言运行时之间,不应直接把 pthread_mutex_t 当作通用序列化格式。

10.6 大数据不应该反复复制到固定数组

本文命令只有几十字节。若需要传输大对象,可以把共享内存分成:

固定头部:状态、偏移、长度、版本、校验
可变数据区:真正的 payload

共享头部里保存“相对映射起点的偏移量”,而不是裸指针。还要设计容量分配、回收、并发访问和数据完整性协议。

在这里插入图片描述


十一、常见误区、高频面试题与练习

11.1 常见误区

误区一:用了 mmap 就自动线程安全

mmap 只建立可见性路径,不提供互斥、不提供消息边界,也不提供“写完了”的通知。

误区二:两个进程映射地址必须相同

不需要。每个进程可以在不同虚拟地址映射同一个对象。真正共享的是后端页。

误区三:把普通 mutex 的地址放进共享内存就行

同步对象本体必须位于共享内存,而且属性必须设置为 PTHREAD_PROCESS_SHARED

误区四:pthread_cond_broadcast 会让所有进程同时运行

它只是唤醒等待者。等待者还要重新竞争互斥锁,真正继续执行的先后顺序不确定。

误区五:从 pthread_cond_wait 返回就一定有新消息

条件变量允许伪唤醒。必须在持锁状态下用 while 重新检查共享谓词。

误区六:广播不会丢,所以不需要版本号

条件变量通知不是消息存储。如果通知发生时某个进程尚未等待,通知本身不会为它排队。版本号等共享谓词才会保留“状态已经变化”的事实。

误区七:mmap 成功就说明底层对象足够大

不是。新建共享内存对象通常长度为 0,必须先 ftruncate。超出对象有效长度访问可能触发 SIGBUS

误区八:strncpy(buffer, input, input.size()) 总会补 \0

当复制长度达到或超过容量时,不一定存在结尾空字符,甚至可能越界。必须限制最大长度,并显式保证结尾。

误区九:shm_unlink 会立即让所有映射失效

它主要删除名称。已有映射可以继续存在,直到最后一个引用解除。

误区十:父子进程都会自动替我安全清理 C++ 对象

fork 会复制进程状态。若子进程继承了“所有者”对象并执行其析构,可能重复销毁共享资源。所有权必须显式设计。

11.2 高频面试题

问题 1:mmap 如何用于进程间通信?

多个进程以 MAP_SHARED 映射同一个文件或 POSIX 共享内存对象,让各自虚拟地址最终关联同一组后端页;再使用跨进程同步机制协调读写。

问题 2:MAP_SHAREDMAP_PRIVATE 有什么区别?

MAP_SHARED 的写入可被其他映射同一对象的进程观察;MAP_PRIVATE 使用私有写时复制语义,写入不会成为其他进程的共享更新。

问题 3:为什么 shm_open 后要调用 ftruncate

新建对象初始长度通常为 0。ftruncate 为底层对象设置足够大小,使映射范围对应有效存储。

问题 4:为什么同步对象要放在共享内存中?

不同进程必须操作同一个同步对象的内部状态;如果 mutex/cond 位于各自普通内存中,它们只是互不相关的副本。

问题 5:怎样让 pthread mutex 跨进程使用?

把 mutex 放在共享内存,并通过 pthread_mutexattr_setpshared(..., PTHREAD_PROCESS_SHARED) 初始化。

问题 6:pthread_cond_wait 返回时还持有锁吗?

持有。它等待时会原子释放锁,返回前重新获得锁。

问题 7:为什么等待条件要写成 while

为了处理伪唤醒、竞争和通知交错。醒来只代表“应该重新检查”,不代表谓词一定成立。

问题 8:signalbroadcast 有什么区别?

signal 至少唤醒一个等待者,具体是谁通常不可预测;broadcast 唤醒所有等待者。两者都不替代共享谓词。

问题 9:close(fd) 后还能访问映射吗?

可以。成功建立 mmap 后,关闭文件描述符不会使映射失效。

问题 10:shm_unlinkmunmap 有什么区别?

shm_unlink 删除命名对象的名称;munmap 解除当前进程的一段虚拟内存映射。

问题 11:这个单槽方案为什么不是消息队列?

因为它只保存最新一条命令。发布速度超过消费速度时,中间命令会被覆盖,没有逐条入队、出队和容量控制。

问题 12:进程持锁崩溃后怎样恢复?

普通 mutex 可能永久阻塞。可研究 robust process-shared mutex,并为 EOWNERDEAD 设计共享状态修复流程。

11.3 排查清单

遇到“有时能跑、有时卡住”的共享内存程序,可以按以下顺序检查:

  • 所有进程是否映射了同一个后端对象?
  • 映射标志是否为 MAP_SHARED
  • PROT_WRITE 是否与 O_RDWR 打开方式匹配?
  • 服务端是否在 mmap 前执行了 ftruncate
  • 客户端是否在服务端完成初始化后才连接?
  • mutex 和 cond 本体是否位于共享区域?
  • 两个同步对象是否都设置为 PTHREAD_PROCESS_SHARED
  • 等待逻辑是否使用 while (predicate_false)
  • 修改谓词和命令时是否持有同一把锁?
  • 工作进程是否在锁内复制共享命令?
  • 字符串复制是否限制容量并保证 \0 结尾?
  • 是否错误地把进程私有裸指针存进共享区?
  • 是否有多个进程重复初始化或销毁同步对象?
  • shm_unlink 是否发生得过早?
  • 异常退出后是否留下旧的 /dev/shm 对象?
  • 多条消息快速到来时,协议是否允许覆盖?

11.4 建议练习

练习一:观察映射地址

在客户端和每个服务端进程中打印 state_ 地址,验证地址可以不同,但命令内容仍然共享。

练习二:把 MAP_SHARED 改成 MAP_PRIVATE

观察客户端写入后,服务端为什么无法按预期看到同一份更新。完成后恢复原值。

练习三:删除 PTHREAD_PROCESS_SHARED

观察跨进程同步出现的异常,再解释“共享字节”与“同步语义”为什么是两个条件。

练习四:制造先通知、后等待

让客户端尽早发送命令,再故意延迟某个 worker 进入 WaitNext。验证 version 能让它发现已经发生的状态变化。

练习五:快速连续发送三条命令

通过脚本快速写入多个命令,观察单槽可能覆盖中间消息,并据此实现一个 16 槽环形队列。

练习六:加入超时等待

pthread_cond_wait 改为 pthread_cond_timedwait,设计超时后打印心跳、继续等待或退出的策略。

练习七:加入 ABI 头部

为共享结构增加 magicversionsizeready 字段,让客户端拒绝连接不兼容或尚未初始化的对象。

练习八:研究 robust mutex

让某个持锁进程异常退出,捕获 EOWNERDEAD,尝试修复共享状态并调用 pthread_mutex_consistent

练习九:比较不同 IPC

分别用 pipe、Unix 域套接字、POSIX 消息队列和共享内存实现同样的控制程序,比较:

  • 数据复制次数;
  • 消息边界;
  • 多生产者/多消费者支持;
  • 阻塞与通知模型;
  • 调试和清理复杂度。

11.5 参考资料


总结

这份 mmap 进程间通信实验,表面上只有一个共享字符串和几次唤醒,背后却把虚拟内存、IPC 和并发控制连在了一起。

整条主线可以压缩为:

服务端 shm_open 创建命名对象
    ↓
ftruncate 设置 SharedState 大小
    ↓
双方用 MAP_SHARED 映射同一后端页
    ↓
同步原语放入共享区,并设为 PROCESS_SHARED
    ↓
客户端持锁写命令、递增 version、broadcast
    ↓
工作进程醒来后重新竞争锁,并用 while 检查 version
    ↓
每个进程复制命令,再判断自己是否应该行动
    ↓
所有使用者退出后,所有者 destroy、munmap、shm_unlink

真正值得记住的不是四五个 API 名字,而是四个设计原则:

  1. 共享内存只提供共享字节,不提供并发协议。
  2. 条件变量只负责通知,共享谓词才负责表达事实。
  3. 同步对象要跨进程工作,位置与属性必须同时正确。
  4. 创建、连接、映射、销毁和移除名称属于不同生命周期。

掌握这四点后,再从单槽扩展到环形队列、从广播扩展到定向通知、从正常退出扩展到崩溃恢复,就不再是堆叠系统调用,而是在有意识地设计一套共享状态机。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/weixin_64304261/article/details/165098450

文章来源转载

评论

赞0

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--