进程间通信概述
本质与目的
进程间通信(IPC, Inter-Process Communication)指的是两个或多个进程之间进行信息传递的过程。由于进程具有独立性,一个进程的数据直接发送给另一个进程并非易事。
虽然父子进程在 fork 后能访问父进程的全局变量,但这不算严格的 IPC,因为这种关系是单向的且不可持续。真正的 IPC 需要满足以下核心需求:
- 数据传输:一个进程将数据发送给另一个进程。
- 资源共享:多个进程共享同一份资源。
- 通知事件:进程间发送消息通知状态变化(如子进程终止)。
- 进程控制:控制进程拦截并监控另一进程的执行状态。
实现 IPC 的本质前提是让不同的进程看到同一份资源,这通常需要操作系统提供系统调用支持。
发展标准
相对于网络协议的标准严格性,IPC 标准的发展经历了一个过程。System V 标准是早期的重要规范,定义了共享内存、消息队列和信号量等机制。尽管部分机制在现代应用中逐渐被网络通信替代,但理解其原理对于掌握底层系统编程依然至关重要。
管道通信
匿名管道特点
匿名管道基于文件描述符,具有以下显著特点:
- 单向通信:设计之初仅允许单向数据传输。
- 血缘限制:通常用于具有亲缘关系的进程(如父子进程)。
- 生命周期:管道随进程退出而关闭,文件的生命周期依附于进程。
- 同步机制:内部实现了进程间的同步。
- 字节流:面向字节流,读写次数不匹配可能导致阻塞或数据丢失。
常见场景分析
- 写慢读快:以写端节奏为准,读端等待数据就绪。
- 写快读慢:读端一次性读取所有写入数据。
- 写端关闭:若写端未写且关闭,读端会收到 EOF。
- 读端关闭:若读端关闭,写端继续写入会触发 SIGPIPE 信号。
命名管道
命名管道(FIFO)解决了匿名管道只能用于有亲缘关系进程的局限,允许毫不相干的进程通过文件系统路径进行通信。
关键细节
- 创建方式:使用
mkfifo创建管道文件。 - 打开行为:读端打开时若没有写端,会阻塞;反之亦然。这天然形成了一种同步机制。
- 字符串处理:不需要像 C 语言字符串那样手动添加
\0结束符,按字节读写即可。 - 权限管理:需设置合适的文件权限(如
0666)。
代码示例
服务器端 (server.cc)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#define FIFO_NAME "fifo"
int main() {
// 创建 FIFO(如果已存在则忽略错误)
if (mkfifo(FIFO_NAME, 0666) == -1) {
perror("mkfifo");
if (errno != EEXIST) exit(1);
}
printf("Server waiting for client...\n");
int fd = open(FIFO_NAME, O_RDONLY);
if (fd == -1) {
perror("open");
exit(1);
}
printf("Client connected.\n");
char buf[1024];
ssize_t n;
while ((n = read(fd, buf, sizeof(buf)-1)) > 0) {
buf[n] = '\0';
printf("client say# %s", buf);
fflush(stdout);
}
if (n == -1) perror("read");
close(fd);
unlink(FIFO_NAME);
return 0;
}
客户端 (client.cc)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#define FIFO_NAME "fifo"
int main() {
printf("Please Enter@ ");
fflush(stdout);
char msg[1024];
if (fgets(msg, sizeof(msg), stdin) == NULL) {
perror("fgets");
exit(1);
}
// 以只写方式打开 FIFO(会阻塞直到有读者)
int fd = open(FIFO_NAME, O_WRONLY);
if (fd == -1) {
perror("open");
exit(1);
}
write(fd, msg, strlen(msg));
close(fd);
return 0;
}
Makefile
all: client server
client: client.cc
g++ -o $@ $^ -std=c++11
server: server.cc
g++ -o $@ $^ -std=c++11
.PHONY: clean
clean:
rm -f client server
运行顺序需注意:先启动服务端(阻塞等待连接),再启动客户端。
共享内存
共享内存是进程间通信中速度最快的方式,因为它避免了内核与用户空间之间的数据拷贝。
原理与地址空间
共享内存的核心在于让不同的进程映射到同一块物理内存区域。操作系统通过页表将虚拟地址映射到物理地址,使得多个进程的虚拟地址空间可以指向同一物理页。
在进程地址空间布局中,共享内存通常位于堆和栈之间的共享区。动态库也是利用这一机制实现代码共享的。
关键系统调用
- shmget: 创建或获取共享内存标识符。
- 需要指定键值(key)、大小和权限。
- 键值通常由
ftok生成,确保唯一性。
- shmat: 将共享内存挂接到当前进程的虚拟地址空间。
- 返回一个指针,后续可直接通过指针访问数据。
- 无需系统调用即可读写,这是其高效的原因。
- shmdt: 解除共享内存的映射(去关联)。
- shmctl: 控制共享内存,包括删除(IPC_RMID)和查询属性(IPC_STAT)。
注意事项
- 键值冲突:
IPC_CREAT | IPC_EXCL标志用于确保新创建,避免覆盖已有的共享内存。 - 生命周期:共享内存的生命周期属于内核,而非进程。即使所有进程退出,若未显式删除,重启前仍可能残留。
- 对齐问题:建议大小设置为 4096 的整数倍,否则底层可能会向上对齐导致浪费。
- 同步保护:共享内存本身不提供互斥保护。多进程同时读写可能导致数据竞争,需配合信号量使用。
封装类示例
为了简化操作,我们可以将共享内存的管理封装为 C++ 类。
Shm.hpp
#ifndef __SHM_HPP
#define __SHM_HPP
#include <iostream>
#include <cstdio>
#include <unistd.h>
#include <sys/shm.h>
#include <string>
const std::string proj_name = "/home";
const int proj_id = 0x6666;
const int g_size = 4096;
static std::string ToHex(int data) {
char hex[64];
snprintf(hex, sizeof(hex), "0x%x", data);
return hex;
}
class Shm {
public:
Shm(int size = g_size) : _shmid(-1), _size(size), _key(0) {}
~Shm() { Detach(); Delete(); }
private:
key_t GetKey() {
_key = ftok(proj_name.c_str(), proj_id);
if (_key < 0) perror("ftok");
return _key;
}
bool CreateCoreHelper(int flags) {
key_t k = GetKey();
_shmid = shmget(k, _size, flags);
if (_shmid < 0) {
perror("shmget");
return false;
}
return true;
}
public:
bool Create() {
return CreateCoreHelper(IPC_CREAT | IPC_EXCL | 0666);
}
bool Get() {
return CreateCoreHelper(IPC_CREAT);
}
bool Delete() {
int n = shmctl(_shmid, IPC_RMID, NULL);
return n < 0 ? false : true;
}
void GetShmAttr() {
struct shmid_ds ds;
int n = shmctl(_shmid, IPC_STAT, &ds);
if (n < 0) { perror("shmctl"); return; }
std::cout << "pid: " << getpid() << std::endl;
std::cout << "creator_pid: " << ds.shm_cpid << std::endl;
std::cout << "size: " << ds.shm_segsz << std::endl;
std::cout << "key: " << ToHex(ds.shm_perm.__key) << std::endl;
}
void* Attach() {
return shmat(_shmid, nullptr, 0);
}
bool Detach() {
return shmdt(_start) == 0;
}
void Debug() {
std::cout << "key: " << ToHex(_key) << std::endl;
std::cout << "shmid: " << _shmid << std::endl;
}
private:
key_t _key;
int _shmid;
int _size;
void* _start;
};
#endif
Reader.cc (接收方)
#include "Shm.hpp"
#include <iostream>
#include <string>
#include <unistd.h>
struct buffer_t {
int count;
char buffer[26 * 2];
};
int main() {
Shm shm;
shm.Create();
sleep(3); // 模拟延迟,给 Writer 时间连接
void* addr = shm.Attach();
buffer_t* shm_addr = (buffer_t*)addr;
int old = shm_addr->count;
while (true) {
if (old != shm_addr->count) {
std::cout << "count : " << shm_addr->count << std::endl;
std::cout << "data : " << shm_addr->buffer << std::endl;
old = shm_addr->count;
}
usleep(50000);
if (shm_addr->count >= 26) break;
}
shm.Detach();
shm.Delete();
return 0;
}
Writer.cc (发送方)
#include "Shm.hpp"
#include <iostream>
#include <string>
#include <cstring>
Shm shm;
class Init {
public:
Init() {
shm.Get();
addr = (char*)shm.Attach();
std::cout << "addr: " << ToHex((long long)addr) << std::endl;
}
~Init() {
shm.Detach();
}
char* Addr() { return addr; }
public:
char* addr;
};
Init init;
int main() {
std::cout << "test Begin..." << std::endl;
buffer_t* shm_data = (buffer_t*)init.Addr();
shm_data->count = 0;
memset(shm_data->buffer, 0, 4096);
char ch = 'A';
for (int i = 0; i < 26 * 2; i += 2, ch++) {
shm_data->buffer[i] = ch;
shm_data->buffer[i + 1] = ch;
usleep(7000000);
shm_data->count++;
sleep(1);
}
return 0;
}
性能与总结
共享内存之所以最快,是因为它减少了数据拷贝次数。传统 IPC 可能需要两次拷贝(用户态 -> 内核态 -> 用户态),而共享内存只需一次(用户态 -> 用户态)。
但这也带来了风险:没有自带保护机制。任何挂接到该区域的进程都可以随时读写,可能导致数据覆盖。在实际生产中,必须结合信号量等同步原语来保证线程安全。
此外,清理工作不可忽视。使用 ipcs -m 查看,ipcrm -m 删除,或者在程序析构时自动调用 shmctl(..., IPC_RMID) 都是必要的维护手段。


