14、进程间通信(IPC):共享内存、命名管道、消息队列的跨平台封装
进程间通信,简称 IPC,说白了就是让两个或多个进程能互相“说上话”。
我刚开始做跨平台开发时,最头疼的就是这个。Windows 上有自己的那一套,Linux 上又是另一套。你想想看,同一个功能,在 Windows 下用 CreateFileMapping,到了 Linux 就得换成 shmget,代码写得跟两门语言似的。
所以这一章,我们就来聊聊怎么把这些底层接口封装成统一的 C++ 接口。我会重点讲三种最常用的 IPC 方式:共享内存、命名管道、消息队列。
14.1 共享内存的跨平台封装
共享内存,说白了就是让两个进程直接读写同一块物理内存。这是最快的 IPC 方式,没有之一。我在项目中遇到过需要实时传输视频帧的场景,用共享内存延迟能控制在微秒级,换成别的方案根本扛不住。
但跨平台封装时,有几个坑你得注意:
- 创建方式不同:Windows 用 CreateFileMapping + MapViewOfFile,Linux 用 shmget + shmat 或 mmap
- 命名规则不同:Windows 的命名空间是全局的,Linux 的共享内存对象在 /dev/shm 下
- 权限控制不同:Windows 用安全描述符,Linux 用文件权限位
核心思路:定义一个抽象基类 SharedMemory,把创建、映射、解除映射、销毁四个操作抽象成纯虚函数。然后分别实现 WindowsSharedMemory 和 LinuxSharedMemory。
下面是我个人习惯用的接口设计:
class SharedMemory {
public:
virtual ~SharedMemory() = default;
// 创建或打开共享内存
virtual bool create(const std::string& name, size_t size) = 0;
virtual bool open(const std::string& name) = 0;
// 映射到进程地址空间
virtual void* map() = 0;
virtual void unmap() = 0;
// 销毁
virtual void destroy() = 0;
// 获取大小
virtual size_t size() const = 0;
};
嗯,这里要注意一点:Windows 的命名需要加上 "Global\" 前缀才能跨会话访问。我曾经因为这个坑,在远程桌面环境下调试了整整一个下午。
小技巧:在 Linux 下,我建议用 mmap + shm_open 的组合,而不是传统的 System V 接口(shmget)。因为 POSIX 接口更现代,而且支持文件描述符操作,方便集成到事件循环中。
14.2 命名管道的跨平台封装
命名管道,也叫 FIFO,是一种半双工的通信方式。它适合做“流式”数据传输,比如日志收集、命令下发等场景。
Windows 和 Linux 的命名管道差异非常大:
| 特性 | Windows | Linux |
|---|---|---|
| 创建方式 | CreateNamedPipe | mkfifo |
| 连接方式 | ConnectNamedPipe / CreateFile | open(阻塞或非阻塞) |
| 读写模式 | 支持重叠 I/O | 标准文件 I/O |
| 命名格式 | \\.\pipe\name | /tmp/name 或任意路径 |
封装时,我建议把命名管道抽象成“服务端”和“客户端”两个角色:
class NamedPipeServer {
public:
virtual bool create(const std::string& name) = 0;
virtual bool waitForConnection() = 0;
virtual size_t read(void* buffer, size_t size) = 0;
virtual size_t write(const void* data, size_t size) = 0;
virtual void close() = 0;
};
class NamedPipeClient {
public:
virtual bool connect(const std::string& name) = 0;
virtual size_t read(void* buffer, size_t size) = 0;
virtual size_t write(const void* data, size_t size) = 0;
virtual void close() = 0;
};
避坑指南:我曾经在 Linux 下用命名管道做双向通信,结果发现 mkfifo 创建的管道是单向的。如果你需要双向通信,要么创建两个管道,要么改用 Unix Domain Socket。Windows 的命名管道默认就是双向的,这点很容易让人产生误解。
14.3 消息队列的跨平台封装
消息队列适合做“异步”通信。发送方把消息丢进队列,接收方按需取走。这种模式在微服务架构中很常见。
跨平台封装时,你会发现:
- Windows 的消息队列(MSMQ)太重,而且不是所有 Windows 版本都默认安装
- Linux 的 POSIX 消息队列(mq_open)功能有限,消息大小和队列长度都有上限
- System V 消息队列(msgget)虽然功能强,但接口老旧
我个人建议:不要直接封装系统原生的消息队列。更好的做法是在共享内存或命名管道之上,自己实现一个轻量级的消息队列。
下面是我在项目中用过的方案:
class MessageQueue {
public:
// 初始化队列(基于共享内存)
bool init(const std::string& name, size_t maxMessages, size_t maxMessageSize);
// 发送消息(非阻塞)
bool send(const void* data, size_t size, int priority = 0);
// 接收消息(阻塞)
bool receive(void* buffer, size_t& size, int& priority);
// 尝试接收(非阻塞)
bool tryReceive(void* buffer, size_t& size, int& priority);
// 清理
void destroy();
private:
// 内部使用共享内存 + 信号量实现
SharedMemory* shm_;
Semaphore* sem_;
};
为什么这么做?因为系统原生的消息队列在跨平台时,行为差异太大。比如 POSIX 消息队列不支持优先级反转处理,而 System V 的消息队列在进程崩溃后可能留下垃圾数据。自己封装一层,反而更可控。
14.4 整体架构图
下面这张图展示了三种 IPC 方式在跨平台封装中的位置关系:
14.5 封装策略总结
做跨平台 IPC 封装,我总结了三条原则:
- 抽象优先:先定义好纯虚接口,再考虑平台实现。不要反过来。
- 条件编译要克制:只在平台适配层用 #ifdef,上层代码不要出现任何平台相关的宏。
- 测试要覆盖边界:进程崩溃、资源泄漏、并发读写,这些场景在跨平台下表现可能完全不同。
最后说一句:如果你只是做 Linux 下的开发,那直接用 Unix Domain Socket 就好了,它比命名管道和消息队列都灵活。但如果你必须支持 Windows,那还是老老实实按我上面说的方式封装吧。