🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位 :以一个"控制端输入进程名,服务端唤醒多个工作进程,再由目标进程决定是否行动"的实验为主线,完整串起
shm_open、ftruncate、mmap(MAP_SHARED)、进程共享互斥锁和进程共享条件变量。💡 学习目标 :理解共享内存只解决"看见同一份数据",同步原语才解决"按正确顺序读写";掌握跨进程
pthread_mutex_t/pthread_cond_t的初始化条件;能写出带事件谓词、边界检查和清理顺序的可运行版本。📌 阅读说明:示例面向 Linux,使用 POSIX 共享内存和 pthread,默认编译器支持 C++17。原始实验采用单缓冲区加广播唤醒,本文保留这条主线,并补上伪唤醒、丢通知、消息覆盖、启动顺序和生命周期等容易被忽略的问题。
文章目录
- 一、先看需求:一次通信其实包含数据与同步两条线
- [二、mmap 为什么能让不同进程看到同一份数据](#二、mmap 为什么能让不同进程看到同一份数据)
- [三、从 shm_open 到 mmap:共享区域是怎样建立的](#三、从 shm_open 到 mmap:共享区域是怎样建立的)
- 四、互斥锁与条件变量怎样跨越进程边界
- 五、通信协议设计:广播负责唤醒,谓词负责判断
- 六、完整实现:SharedMem.hpp、Server.cc、Client.cc
- 七、编译与运行:观察一次定向唤醒
- [八、为什么条件等待必须写成 while](#八、为什么条件等待必须写成 while)
- [九、close、munmap 与 shm_unlink 的生命周期](#九、close、munmap 与 shm_unlink 的生命周期)
- 十、原始单槽方案的边界与工程化改进
- 十一、常见误区、高频面试题与练习
- 总结
一、先看需求:一次通信其实包含数据与同步两条线
假设现在有一个服务端进程。它启动后再 fork 出 10 个工作进程,加上主进程本身,一共有 11 个执行者:
text
process-main
process-0
process-1
...
process-9
另有一个独立启动的客户端。用户在客户端输入命令:
text
process-3
all
end
我们希望得到这样的行为:
- 输入
process-3:所有等待者都可以被唤醒,但只有process-3执行动作; - 输入
all:所有工作进程都执行动作; - 输入
end:所有工作进程结束等待并退出; - 客户端与服务端不依赖父子关系,它们通过同一个 POSIX 共享内存名称连接。
表面看,这是"把一个字符串从客户端传给服务端"。真正实现时,却必须同时解决两类问题。
1.1 数据线:命令放在哪里
如果客户端把命令写进自己的普通变量:
cpp
std::string command = "process-3";
服务端不会凭空看到它。每个进程拥有独立的虚拟地址空间,普通堆、栈和全局变量默认只属于当前进程。即使两个进程中的变量碰巧打印出同一个虚拟地址,也不代表它们背后是同一组物理页。
因此,需要一片能被多个进程共同映射的区域,用来放置命令内容和协议状态。
1.2 同步线:何时读、谁先写、谁应该行动
共享内存建立后,多个进程的确能看见同一批字节,但新的问题随之出现:
- 客户端写到一半,服务端能不能读取?
- 多个进程同时读取和修改状态,会不会形成数据竞争?
- 没有新命令时,工作进程是忙等,还是进入睡眠?
- 一次广播唤醒所有进程后,怎样保证只有目标进程行动?
- 条件变量发生伪唤醒时,怎样避免重复处理旧命令?
这就是互斥锁、条件变量和事件谓词存在的意义。
一句话概括:
mmap负责把同一片后端页接入多个地址空间;互斥锁负责保护共享状态;条件变量负责等待与通知;谓词负责判断"这次醒来是否真的有新事件"。
二、mmap 为什么能让不同进程看到同一份数据
mmap 的核心动作,不是"把文件内容复制进某个数组",而是在当前进程的虚拟地址空间中建立一段映射关系。
当多个进程都使用 MAP_SHARED 映射同一个后端对象时,它们各自页表中的虚拟页,最终可以指向同一组后端页。进程 A 写入这些页后,进程 B 再从自己的映射区域读取,就能观察到更新。

2.1 虚拟地址不同,不影响共享
假设三个进程分别得到以下返回值:
text
进程 A:0x7f10...
进程 B:0x7a90...
进程 C:0x7010...
这些地址完全可以不同。mmap(nullptr, ...) 会让内核为每个进程选择合适的虚拟地址,而进程间共享的是地址翻译后的后端页,不是数值相同的指针。
因此,共享区域中不应该保存只在某个进程有效的裸指针。例如:
cpp
struct BadState
{
char* data; // 错误:这个地址只在创建它的进程中有意义
};
即使 data 字段本身位于共享内存中,它指向的普通堆内存仍然属于原进程。另一个进程拿到同样的地址值后,可能对应未映射区域,也可能对应完全不同的对象。
跨进程数据结构更适合使用:
- 固定大小数组;
- 相对共享区域起点的偏移量;
- 明确长度的 POD 风格字段;
- 专门设计的共享内存分配器。
2.2 MAP_SHARED 与 MAP_PRIVATE 的区别
调用形式如下:
cpp
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 出来的,无法继承那段匿名映射,所以需要一个可重新定位的后端对象:
text
/mmap_ipc_demo
客户端把这个名称交给 shm_open,就能打开同一个 POSIX 共享内存对象,再通过 mmap 接入自己的地址空间。
三、从 shm_open 到 mmap:共享区域是怎样建立的
POSIX 共享内存对象很像一种"可被映射的命名内核对象"。在 Linux 上,常能在 /dev/shm 观察到对应条目,但程序应该通过 shm_open / shm_unlink 管理它,而不是依赖具体挂载路径。
3.1 服务端调用链
服务端是共享区域的所有者,职责包含创建、定长、映射和初始化:
text
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_EXCL 与 O_CREAT 一起使用,可以避免两个服务端同时把自己当作初始化者。若对象已存在,创建会以 EEXIST 失败,比悄悄覆盖正在使用的共享状态安全得多。
3.2 为什么 ftruncate 不能省
新建 POSIX 共享内存对象时,它的初始长度通常为 0。mmap 的长度参数只说明"想建立多大的虚拟映射",不会自动把底层对象扩展到同样大小。
因此,服务端必须先执行:
cpp
ftruncate(fd, sizeof(SharedState));
如果映射范围超出底层对象有效长度,真正访问越界页时可能收到 SIGBUS。这类错误很迷惑:mmap 看起来已经成功,程序却在随后读写某个字段时突然崩溃。
3.3 客户端调用链
客户端不是所有者,不应该重复初始化共享锁和条件变量:
text
shm_open(O_RDWR)
↓
fstat 检查对象长度
↓
mmap(PROT_READ | PROT_WRITE, MAP_SHARED)
↓
加锁、写命令、递增版本号、广播

这也决定了最简单可靠的启动顺序:
bash
# 终端 1
./server
# 终端 2
./client
如果先启动客户端,shm_open 应明确失败并提示"服务端尚未创建共享对象",而不是由客户端擅自创建一片尚未初始化的区域。
3.4 共享内存名称的约束
为了可移植,POSIX 共享内存名称通常写成:
text
/name
也就是以 / 开头,后面不再出现其他 /。本文使用:
cpp
inline constexpr char kSharedMemoryName[] = "/mmap_ipc_demo";
它不是普通路径名,不能写成:
text
./mmap_ipc_demo
/tmp/mmap_ipc_demo
四、互斥锁与条件变量怎样跨越进程边界
很多人第一次把 pthread_mutex_t 放进共享内存后,会发现程序依旧不可靠。原因是:pthread 同步对象默认用于同一进程内的线程,而不是不同进程。
想让它们跨进程工作,必须同时满足两个条件。
4.1 条件一:同步对象本体位于共享内存
下面这种写法没有意义:
cpp
pthread_mutex_t mutex; // 位于各进程自己的普通内存
每个进程得到的是自己的锁对象。进程 A 锁住它,不会阻止进程 B 锁住 B 自己的副本。
正确做法是把同步对象直接放进共享结构:
cpp
struct SharedState
{
pthread_mutex_t mutex;
pthread_cond_t cond;
std::uint64_t version;
char command[256];
};

4.2 条件二:属性设置为 PTHREAD_PROCESS_SHARED
互斥锁初始化:
cpp
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);
条件变量初始化:
cpp
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。因此推荐这样处理:
cpp
void CheckPthread(int code, const char* operation)
{
if (code != 0)
{
throw std::system_error(
code,
std::generic_category(),
operation
);
}
}
而不是只写:
cpp
pthread_mutex_lock(&mutex);
perror("pthread_mutex_lock"); // errno 可能不是这次失败的原因
五、通信协议设计:广播负责唤醒,谓词负责判断
原始实验使用一个字符串缓冲区:客户端写入目标进程名,然后调用 pthread_cond_broadcast 唤醒所有等待者。这个思路很适合演示"广播通知 + 自主筛选"。
5.1 为什么广播后还要检查命令
假设客户端发送:
text
process-1
pthread_cond_broadcast 不知道谁叫 process-1。它只知道某个条件变量上有哪些等待者,所以会把所有等待者唤醒。
每个工作进程重新获得互斥锁后,读取同一条命令:
cpp
if (command == process_name || command == "all")
{
DoWork();
}
于是:
process-1执行动作;- 其他进程发现目标不是自己,忽略本轮;
all会被每个工作进程接受;end会让每个工作进程跳出循环。

5.2 仅有字符串还不够
如果共享结构只有:
cpp
char command[256];
工作进程无法可靠区分:
- 这是刚发布的新命令;
- 还是上一次唤醒留下的旧字符串;
- 这次返回只是条件变量的伪唤醒;
- 自己是否在真正进入等待前错过了广播。
所以需要一个事件谓词。本文使用单调递增的版本号:
cpp
std::uint64_t version;
每个等待者在自己的私有内存中维护:
cpp
std::uint64_t seen = 0;
客户端每发布一次命令,就在持锁状态下执行:
cpp
CopyCommand();
++state->version;
pthread_cond_broadcast(&state->cond);
等待者则使用:
cpp
while (state->version == seen)
{
pthread_cond_wait(&state->cond, &state->mutex);
}
seen = state->version;
这样,即使广播发生在某个工作进程真正睡眠之前,它稍后获得锁时仍会发现 version != seen,直接读取新命令,而不是永远睡下去。
5.3 锁保护的不是一行代码,而是不变量
客户端的关键区间必须把"写命令、更新版本、发送通知"看成一个整体:
text
lock
写 command
version++
broadcast
unlock
工作进程的关键区间则是:
text
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
cpp
#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
cpp
#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 这个普通成员。
如果子进程用普通 return 或 std::exit 走完所有者析构,就可能销毁互斥锁、条件变量并调用 shm_unlink。因此示例在子进程完成后使用 _exit,不执行继承来的 C++ 析构逻辑;真正的所有者清理由父进程完成。
在更大的项目中,最好进一步把"可继承的映射视图"和"仅父进程拥有的清理令牌"拆成不同类型,避免靠调用约定维持所有权。
6.3 Client.cc
cpp
#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
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 能覆盖更多常见教学环境。
七、编译与运行:观察一次定向唤醒
目录结构:
text
mmap_ipc/
├── SharedMem.hpp
├── Server.cc
├── Client.cc
└── Makefile
7.1 编译
bash
make
正常情况下生成:
text
server
client
7.2 启动服务端
终端一:
bash
./server
可能看到:
text
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 启动客户端并发送命令
终端二:
bash
./client
输入:
text
Please Enter# process-3
Please Enter# all
Please Enter# end
服务端可能输出:
text
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 用系统工具观察共享对象
服务端运行期间,可以查看:
bash
ls -l /dev/shm
通常会看到类似:
text
mmap_ipc_demo
还可以查看映射:
bash
grep mmap_ipc_demo /proc/$(pidof server)/maps
注意,一个程序中包含父进程和多个子进程时,pidof server 可能返回多个 PID。调试时可以选择其中一个 PID 单独查看。
7.5 上次异常退出留下对象怎么办
如果服务端被 kill -9 终止,它没有机会执行 shm_unlink。下一次使用 O_CREAT | O_EXCL 创建时会得到 EEXIST。
确认没有旧服务端仍在运行后,可以在实验环境中清理:
bash
rm -f /dev/shm/mmap_ipc_demo
生产程序更适合提供独立的管理命令,或在启动时读取对象中的 magic、版本号和 owner PID,再决定是拒绝启动、接管还是清理,不能看到 EEXIST 就无条件删除。
八、为什么条件等待必须写成 while
条件变量最重要的一条使用规则是:
条件变量本身不保存"事件已经发生"的事实;共享谓词才保存事实。等待者每次返回后,都必须在持锁状态下重新检查谓词。

8.1 pthread_cond_wait 做了什么
调用前,线程或进程必须已经持有互斥锁:
cpp
pthread_mutex_lock(&state->mutex);
pthread_cond_wait(&state->cond, &state->mutex);
pthread_cond_wait 会把两个动作作为不可分割的等待转换完成:
- 释放
mutex; - 进入条件变量等待队列并睡眠。
被唤醒后,它会在返回调用者之前重新获得 mutex。因此函数返回时,调用者再次持有锁,可以安全检查共享状态。
如果"释放锁"和"进入等待"是两个普通步骤,中间就会存在经典竞态:发布者恰好在这条缝隙里修改谓词并发出通知,等待者随后睡下,从而错过事件。条件变量 API 正是为了关闭这条缝隙。
8.2 为什么不能使用 if
错误写法:
cpp
if (state->version == seen)
{
pthread_cond_wait(&state->cond, &state->mutex);
}
UseCommand(state->command);
它假设"从 wait 返回,就一定出现了自己需要的新命令",这个假设不成立。
可能的情况包括:
- 伪唤醒:没有发布者发出目标事件,等待函数仍然返回;
- 广播竞争:多个等待者同时醒来,但重新获得互斥锁有先后顺序;
- 谓词已被其他参与者改变:轮到当前等待者时,条件可能已不再成立;
- 旧通知与新等待交错:仅凭一次函数返回无法证明版本已变化。
正确写法:
cpp
while (state->version == seen)
{
pthread_cond_wait(&state->cond, &state->mutex);
}
这里的 while 不是为了"多等几次",而是为了把条件变量降级为纯通知机制,把真相交还给受互斥锁保护的共享谓词。
8.3 广播为什么通常在持锁状态下发生
本文的发布顺序是:
cpp
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 的生命周期
共享内存清理经常被混成一句"关闭文件"。实际上至少有三种资源:
shm_open返回的文件描述符;- 当前进程中的虚拟内存映射;
- 可由名称重新打开的 POSIX 共享内存对象。

9.1 close(fd):关闭句柄
mmap 成功后,可以立即关闭文件描述符:
cpp
void* address = mmap(...);
close(fd);
关闭 fd 不会解除已有映射。当前进程仍然可以通过 address 访问共享页。
这也是示例构造函数在映射成功后立刻关闭 fd_ 的原因:后续读写通过虚拟内存完成,不需要长期占用描述符。
9.2 munmap:解除当前进程的映射
cpp
munmap(address, sizeof(SharedState));
它只删除当前进程指定地址范围的映射。其他进程仍可继续使用自己的映射。
解除后再访问原地址属于非法内存访问,通常会触发崩溃。每个进程退出时,内核也会自动清理该进程仍持有的映射,但显式 munmap 更能表达所有权并方便错误检查。
9.3 shm_unlink(name):删除名称
cpp
shm_unlink("/mmap_ipc_demo");
它让这个名称不能再被新的 shm_open 打开,语义类似普通文件的 unlink。已有的打开句柄和映射不会因此瞬间失效;当最后一个引用消失后,底层对象才真正释放。
这带来一个实用设计:服务端可以在确认所有参与者已经连接后提前 shm_unlink,让对象最终自动消失。但本文需要允许客户端在服务端运行期间随时启动,所以把 shm_unlink 放在服务端结束阶段。
9.4 正确的结束顺序
本文结束过程是:
text
client 发布 end 并解锁
↓
全部 worker 醒来并退出等待循环
↓
父进程 waitpid 回收子进程
↓
父进程销毁 cond 和 mutex
↓
父进程 munmap
↓
父进程 shm_unlink
如果还有等待者时就调用 pthread_cond_destroy,或还有持锁者时就调用 pthread_mutex_destroy,结果不是"替它们强制收尾",而是程序错误。
十、原始单槽方案的边界与工程化改进
到这里,这个实验已经能稳定演示:共享映射、跨进程锁、条件等待、广播唤醒和按名称筛选。但它仍然不是一个通用消息队列。
10.1 单槽会覆盖中间命令
共享状态只有一个:
cpp
char command[256];
假设客户端极快地发布:
text
process-1
process-2
process-3
某个工作进程还没来得及读取第一条命令,缓冲区就可能已变成第三条。version 能告诉它"发生过变化",却不能恢复被覆盖的中间内容。
所以这个协议适合:
- 单个交互式客户端;
- 低频控制命令;
- 只关心最新状态;
- 教学演示广播与筛选。
不适合:
- 每条消息都必须处理一次;
- 高频生产者;
- 多生产者并发排队;
- 大块可变长度数据;
- 需要确认、重试、超时和持久化的任务。
10.2 想保证每条消息不丢,需要环形队列
可以把共享结构扩展为:
cpp
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 类型大小都可能不一致。
工程代码常增加:
cpp
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 大数据不应该反复复制到固定数组
本文命令只有几十字节。若需要传输大对象,可以把共享内存分成:
text
固定头部:状态、偏移、长度、版本、校验
可变数据区:真正的 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_SHARED 与 MAP_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:signal 和 broadcast 有什么区别?
signal 至少唤醒一个等待者,具体是谁通常不可预测;broadcast 唤醒所有等待者。两者都不替代共享谓词。
问题 9:close(fd) 后还能访问映射吗?
可以。成功建立 mmap 后,关闭文件描述符不会使映射失效。
问题 10:shm_unlink 与 munmap 有什么区别?
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 头部
为共享结构增加 magic、version、size 和 ready 字段,让客户端拒绝连接不兼容或尚未初始化的对象。
练习八:研究 robust mutex
让某个持锁进程异常退出,捕获 EOWNERDEAD,尝试修复共享状态并调用 pthread_mutex_consistent。
练习九:比较不同 IPC
分别用 pipe、Unix 域套接字、POSIX 消息队列和共享内存实现同样的控制程序,比较:
- 数据复制次数;
- 消息边界;
- 多生产者/多消费者支持;
- 阻塞与通知模型;
- 调试和清理复杂度。
11.5 参考资料
- Linux manual page:
mmap(2) - Linux manual page:
shm_open(3) - Linux manual page:
pthread_mutexattr_setpshared(3) - POSIX Programmer's Manual:
pthread_condattr_setpshared(3p) - The Open Group Base Specifications:
pthread_condattr_getpshared / setpshared
总结
这份 mmap 进程间通信实验,表面上只有一个共享字符串和几次唤醒,背后却把虚拟内存、IPC 和并发控制连在了一起。
整条主线可以压缩为:
text
服务端 shm_open 创建命名对象
↓
ftruncate 设置 SharedState 大小
↓
双方用 MAP_SHARED 映射同一后端页
↓
同步原语放入共享区,并设为 PROCESS_SHARED
↓
客户端持锁写命令、递增 version、broadcast
↓
工作进程醒来后重新竞争锁,并用 while 检查 version
↓
每个进程复制命令,再判断自己是否应该行动
↓
所有使用者退出后,所有者 destroy、munmap、shm_unlink
真正值得记住的不是四五个 API 名字,而是四个设计原则:
- 共享内存只提供共享字节,不提供并发协议。
- 条件变量只负责通知,共享谓词才负责表达事实。
- 同步对象要跨进程工作,位置与属性必须同时正确。
- 创建、连接、映射、销毁和移除名称属于不同生命周期。
掌握这四点后,再从单槽扩展到环形队列、从广播扩展到定向通知、从正常退出扩展到崩溃恢复,就不再是堆叠系统调用,而是在有意识地设计一套共享状态机。