目标:理解 Linux 进程为什么需要 IPC,掌握管道、FIFO、消息队列、共享内存、信号量、信号和 Unix 域套接字的原理、常用接口、适用场景与避坑方法。
核心结论:进程地址空间彼此隔离,IPC 的本质是借助内核提供的数据通道或共享区域,在进程之间传递数据、事件或同步状态。
第一章、IPC 基础与核心概念
1.1 IPC 的定义
IPC(Inter-Process Communication,进程间通信)是不同进程之间交换数据和协调执行顺序的机制。
进程 A 的独立地址空间
|
| 内核提供的 IPC 机制
v
进程 B 的独立地址空间
数据传递:管道、FIFO、消息队列、Socket
共享数据:共享内存、mmap
事件通知:信号、eventfd
同步互斥:信号量、进程共享 mutex
线程通常共享同一进程的地址空间,可以直接访问全局变量和堆;进程地址空间相互隔离,不能直接解引用另一个进程中的普通指针。
第二章、Linux IPC 机制全景与选型
2.1 常见 IPC 方式对比
2.2 快速选型
父子进程传少量字节流 -> pipe
无亲缘进程的简单字节流 -> FIFO
需要一条条接收命令/事件 -> 消息队列
大量、高频数据交换 -> 共享内存 + 同步机制
只通知“发生了某件事” -> signal / eventfd
本机客户端—服务端、双向通信 -> Unix 域 Socket
需要跨主机 -> TCP / UDP Socket
第三章、常用 IPC 机制原理与实践
3.1 匿名管道 pipe
3.1.1 原理
匿名管道是内核中的字节流缓冲区。pipe() 返回两个文件描述符:fd[0] 用于读,fd[1] 用于写。
写进程 内核管道 读进程
write(fd[1]) ----------> [字节流缓冲区] ----------> read(fd[0])
#include <unistd.h>
int pipe(int fd[2]);
匿名管道本身没有文件路径,通常在 fork() 前创建,让父子进程继承文件描述符。普通管道是单向的;需要双向通信时,应创建两条管道或改用 socketpair()。
3.1.2 父子进程示例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void)
{
int fd[2];
pid_t pid;
if (pipe(fd) == -1) {
perror("pipe");
return 1;
}
pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
char buf[64];
ssize_t n;
close(fd[1]);
n = read(fd[0], buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("child receive: %s\n", buf);
}
close(fd[0]);
return 0;
}
close(fd[0]);
write(fd[1], "hello", strlen("hello"));
close(fd[1]);
waitpid(pid, NULL, 0);
return 0;
}
关键点:
- 父子进程必须关闭自己不用的管道端,否则可能导致读端一直等不到 EOF。
- 当所有写端都关闭后,读端读完剩余数据,
read() 返回 0,表示 EOF。 - 没有任何读端时继续写管道,会产生
SIGPIPE,write() 通常返回 EPIPE。 - 管道是字节流,没有天然消息边界;接收方必须自行设计长度、分隔符或固定格式。
- 不超过
PIPE_BUF 的单次写入具有原子性;更大的写入可能与其他写进程的数据交错。
3.1.3 Shell 管道的本质
ps aux | grep app
Shell 创建管道和两个子进程,再通过 dup2() 把前一个进程的标准输出连接到管道写端,把后一个进程的标准输入连接到管道读端:
ps 的 stdout -> pipe -> grep 的 stdin
3.2 命名管道 FIFO
FIFO 与匿名管道一样传输无边界字节流,但它在文件系统中有路径,因此无亲缘关系的进程也能通过路径打开同一条通道。
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
命令行演示:
mkfifo /tmp/demo_fifo
# 终端 1:先阻塞等待数据
cat /tmp/demo_fifo
# 终端 2:写入数据
echo "hello fifo" > /tmp/demo_fifo
rm /tmp/demo_fifo
C 程序通过普通文件接口访问 FIFO:
int fd = open("/tmp/demo_fifo", O_WRONLY);
write(fd, data, length);
close(fd);
注意事项:
- 只读方式打开 FIFO 时,默认等待写端;只写方式打开时,默认等待读端。
O_NONBLOCK 会改变打开和读写行为,程序必须处理 ENXIO、EAGAIN 等返回值。- FIFO 路径只是入口,实际数据仍经过内核缓冲区,不会写入磁盘文件内容。
- 多进程写同一 FIFO 时,同样应关注
PIPE_BUF 原子写入限制和应用层消息边界。
3.3 消息队列
消息队列由内核保存一组有边界的消息。与管道的连续字节流不同,接收方每次读取一条消息,不需要自己从字节流中拆包。
发送进程 -> [消息 1][消息 2][消息 3] -> 接收进程
内核消息队列
Linux 常见两套接口:
| | | |
|---|
| msgget | msgsnd | msgctl(..., IPC_RMID, ...) |
| mq_open | mq_send | mq_close |
3.3.1 System V 消息队列
#include <sys/ipc.h>
#include <sys/msg.h>
int msgget(key_t key, int msgflg);
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz,
long msgtyp, int msgflg);
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
消息结构的第一个成员必须是类型为 long 的消息类型字段;发送时其值必须大于 0,可用作消息分类:
struct message {
long type;
char text[128];
};
发送和接收示例:
struct message msg = {1, "start"};
/* msgsz 不包含 long type */
msgsnd(msqid, &msg, sizeof(msg.text), 0);
msgrcv(msqid, &msg, sizeof(msg.text), 1, 0);
msgrcv() 的 msgtyp 可按类型选择消息:
System V IPC 对象通常不会因为创建进程退出就自动删除,应由明确的所有者调用 IPC_RMID 清理。
3.3.2 POSIX 消息队列
POSIX 消息队列使用类似文件名的名字,例如 /app_cmd,支持消息优先级和通知机制:
mqd_t mq_open(const char *name, int oflag, ...);
int mq_send(mqd_t mqdes, const char *msg_ptr,
size_t msg_len, unsigned int msg_prio);
ssize_t mq_receive(mqd_t mqdes, char *msg_ptr,
size_t msg_len, unsigned int *msg_prio);
消息队列适合命令、状态和小块结构化数据。若持续传输大块数据,频繁在用户态与内核之间复制会增加开销,通常考虑共享内存。
3.4 共享内存
共享内存把同一组物理内存页映射到多个进程的虚拟地址空间。建立映射后,进程可像访问普通内存一样读写数据,适合大块、高频数据交换。
进程 A 虚拟地址 共享物理内存 进程 B 虚拟地址
0x7f... ───────────────> [共享数据区域] <────────────── 0x7a...
两个进程中的虚拟地址可以不同,指向的共享内存页相同。
Linux 常见两套共享内存接口:
| | | |
|---|
| shmget | shmat | shmdt |
| shm_open | mmap | munmap |
3.4.1 POSIX 共享内存基本流程
创建方:
int fd = shm_open("/demo_shm", O_CREAT | O_RDWR, 0600);
ftruncate(fd, sizeof(struct shared_data));
struct shared_data *p = mmap(NULL, sizeof(*p),
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
close(fd);
使用方打开同名对象并映射:
int fd = shm_open("/demo_shm", O_RDWR, 0);
struct shared_data *p = mmap(NULL, sizeof(*p),
PROT_READ | PROT_WRITE,
MAP_SHARED, fd, 0);
close(fd);
结束时:
munmap(p, sizeof(*p));
shm_unlink("/demo_shm"); /* 由对象所有者执行一次 */
shm_unlink() 删除名字;已经建立的映射仍可使用,直到相关进程 munmap() 或退出。
3.4.2 共享内存为什么快
管道、消息队列和 Socket 通常需要通过内核缓冲区传递数据:
发送进程用户空间 -> 内核缓冲区 -> 接收进程用户空间
共享内存建立映射后,双方直接访问同一内存区域,避免每次消息传递都复制整块业务数据:
进程 A ──读写──> 同一共享内存页 <──读写── 进程 B
但共享内存只解决“数据放在哪里”,不解决“谁先读写”。没有同步机制时,同样会发生数据竞争、读到半更新数据或覆盖未消费数据。
3.5 信号量:进程间同步
信号量本质是一个受内核或原子机制管理的计数器:
生产者:写共享内存 -> post(data_ready)
消费者:wait(data_ready) -> 读共享内存
信号量负责同步和资源计数,不负责传输业务数据。常与共享内存配合:共享内存存数据,信号量保护互斥并通知数据是否可用。
3.5.1 POSIX 信号量
#include <semaphore.h>
int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);
int sem_post(sem_t *sem);
两种形式:
无名信号量用于进程间共享时,必须把它本身放入共享内存,并设置 pshared 为非 0:
sem_init(&shared->sem, 1, 0);
3.5.2 进程共享 mutex
互斥锁也可以放在共享内存中,并通过属性设置为进程共享:
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&shared->mutex, &attr);
pthread_mutexattr_destroy(&attr);
如果持锁进程可能异常退出,可研究 robust mutex;否则锁的共享状态可能永久停留在“已占用”。
3.6 信号 signal
信号是内核发送给进程或线程的异步事件通知,适合表达“发生了某件事”,不适合传输大量数据。
常见信号:
| |
|---|
SIGINT | |
SIGTERM | |
SIGKILL | |
SIGCHLD | |
SIGHUP | |
SIGPIPE | |
推荐使用 sigaction() 注册处理函数:
#include <signal.h>
static volatile sig_atomic_t stop;
static void on_signal(int signo)
{
(void)signo;
stop = 1;
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
while (!stop) {
/* 正常工作 */
}
return 0;
}
信号处理函数会打断正常执行流,只能调用异步信号安全函数。不要在处理函数中调用 printf()、malloc()、获取普通互斥锁或执行复杂业务逻辑。常见做法是只设置 sig_atomic_t 标志,或在处理函数中对预先创建且已设为非阻塞的 self-pipe/eventfd 文件描述符调用 write() 写入通知,再由主循环处理;若返回 EAGAIN,可合并或丢弃本次通知,由主循环统一处理。
标准信号通常不会记录重复到达次数;同一种信号阻塞期间多次到达,可能只保留一个待处理状态。需要排队和携带少量值时,可了解 POSIX 实时信号,但仍不应把信号当作常规数据通道。
3.7 Unix 域 Socket
Unix 域 Socket 使用 Socket 编程接口在同一台主机的进程间双向通信,不经过 IP 网络协议栈,常用于本机客户端—服务端。
客户端进程 <========== 双向通信 ==========> 服务端进程
connect() Unix 域 Socket bind()/listen()/accept()
地址族使用 AF_UNIX 或 AF_LOCAL:
struct sockaddr_un addr = {0};
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, "/tmp/app.sock",
sizeof(addr.sun_path) - 1);
常见类型:
| |
|---|
SOCK_STREAM | |
SOCK_DGRAM | |
SOCK_SEQPACKET | |
服务端流程:
socket -> unlink 旧路径 -> bind -> listen -> accept -> read/write -> close
客户端流程:
socket -> connect -> read/write -> close
与 FIFO 相比,Unix 域 Socket 天然支持全双工、多个客户端和更清晰的连接管理;在 Linux 上还可传递文件描述符和获取对端凭据。使用文件系统路径时,服务端退出前后应妥善清理旧的 Socket 文件,并设置合适的目录与文件权限。
3.7.1 socketpair
socketpair() 创建一对已连接的本地 Socket,适合亲缘进程双向通信:
int fd[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, fd);
与两条单向管道相比,socketpair() 天然全双工,常用于父子进程控制通道。
3.8 其他常见机制
3.8.1 mmap 文件映射
多个进程用 mmap(..., MAP_SHARED, ...) 映射同一个普通文件,也可以共享数据并持久化到文件。它与 POSIX 共享内存的映射方式相似,但底层对象是实际文件。
3.8.2 eventfd
eventfd 是 Linux 提供的 64 位计数通知机制,可被 select、poll、epoll 监听。它适合线程或亲缘进程之间进行事件计数和唤醒,但通常不传输业务数据。
3.8.3 文件锁
flock() 或 fcntl() 记录锁可用于协调多个进程对同一文件的访问。多数文件锁是协作式锁:所有参与进程都必须主动遵守加锁规则。
3.9 阻塞、非阻塞与 I/O 多路复用
管道、FIFO 和 Socket 的读写通常支持阻塞或非阻塞模式;POSIX 消息队列可在 mq_open() 时使用 O_NONBLOCK,System V 消息队列则在 msgsnd() 或 msgrcv() 的调用标志中使用 IPC_NOWAIT:
| |
|---|
| |
| 暂时无法读写时立即返回;非阻塞 fd 和 POSIX 消息队列常见 EAGAIN/EWOULDBLOCK,System V 消息队列空读返回 ENOMSG、满写返回 EAGAIN |
当一个进程需要同时管理多个管道或 Socket 时,可使用 select、poll 或 epoll 等待多个文件描述符事件,避免为每条连接单独创建一个阻塞线程。
并非所有 IPC 都能直接作为文件描述符交给 epoll。普通管道、FIFO、Socket、eventfd 可以;System V 消息队列和 System V 信号量不能直接这样监听。
第四章、工程应用、排查与可靠性设计
4.1 典型应用场景与选型
| | |
|---|
| | 通过 fork() 继承描述符;使用完及时关闭无用端。 |
| | |
| | |
| | |
| | 信号只做轻量通知;需要接入事件循环时优先考虑 eventfd。 |
| | |
| | |
4.2 生命周期与清理
不同 IPC 对象的清理方式不同:
| |
|---|
| |
| |
| msgctl(..., IPC_RMID, ...) |
| shmctl(..., IPC_RMID, ...) |
| mq_unlink() |
| shm_unlink() |
| sem_unlink() |
| |
设计 IPC 时应明确“谁创建、谁初始化、谁删除”。多个进程都随意初始化或删除对象,容易造成竞态、数据丢失和重启失败。
4.3 查看与排查 IPC
4.3.1 System V IPC
ipcs -q # 消息队列
ipcs -m # 共享内存
ipcs -s # 信号量
ipcs -a # 全部 System V IPC
ipcrm -q <msqid>
ipcrm -m <shmid>
ipcrm -s <semid>
4.3.2 文件描述符与 Unix 域 Socket
ls -l /proc/<pid>/fd
lsof -p <pid>
ss -xl # Unix 域监听 Socket
ss -xa # 全部 Unix 域 Socket
4.3.3 系统调用跟踪
strace -f -e trace=%process,%ipc,%signal,%desc ./app
-f 跟踪子进程;可观察 fork、pipe、read/write、System V IPC 和信号相关调用。排查阻塞问题时,重点确认进程卡在哪个系统调用、哪些文件描述符没有关闭、IPC 对象是否仍存在。
4.4 常见问题与避坑
4.4.1 把字节流当成完整消息
read() 一次不保证正好读取一次 write() 的全部数据。管道、FIFO 和 SOCK_STREAM 都必须设计应用层协议,例如固定长度、分隔符或“长度头 + 数据体”。
[4 字节长度 N][N 字节数据][4 字节长度 M][M 字节数据]...
4.4.2 忽略短读和短写
read()、write()、send()、recv() 的返回值可能小于请求长度,也可能被信号中断并返回 EINTR。可靠程序需要循环处理剩余数据,并区分 EOF、可重试错误和永久错误。
4.4.3 忘记关闭无用文件描述符
fork() 会复制文件描述符表。父子进程必须关闭自己不用的管道端或 Socket 端,否则 EOF、资源释放和故障检测都可能延迟。
4.4.4 共享内存没有同步
共享内存不是“自动线程安全”。应明确数据所有权、读写顺序、内存可见性和异常恢复方式,并使用信号量、进程共享 mutex 或无锁协议同步。
4.4.5 IPC 对象残留
System V IPC、POSIX 命名对象、FIFO 和 Unix 域 Socket 路径都可能在异常退出后残留。程序启动和退出流程都要考虑检查、复用或清理旧对象。
4.4.6 权限设置过宽
FIFO、Socket 路径和命名 IPC 对象都可能成为进程间的数据入口。应使用最小权限,校验对端身份和输入长度,不要为了方便直接使用 0666 或对所有用户开放。
4.4.7 信号处理函数做复杂工作
处理函数中只执行异步信号安全操作。复杂清理、日志输出和状态变更应转移到正常执行流处理。
第五章、核心知识速览与面试要点
5.1 核心接口速查
| |
|---|
| pipe |
| mkfifo |
| msgget |
| mq_open、mq_send、mq_receive、mq_unlink |
| shmget |
| shm_open、ftruncate、mmap、shm_unlink |
| sem_init/sem_open |
| sigaction |
| socket、bind、listen、accept、connect |
| socketpair |
| select |
5.2 高频面试题
5.2.1 进程和线程通信有什么区别
同一进程的线程共享地址空间,可直接访问全局变量和堆,但必须解决并发同步;不同进程地址空间隔离,需要通过内核 IPC 或共享映射交换数据,隔离性更强,通信方式也更明确。
5.2.2 管道和消息队列有什么区别
管道是连续字节流,没有消息边界,通常按读写顺序消费;消息队列保留消息边界,还可按消息类型或优先级接收。管道简单,消息队列更适合传递离散命令和事件。
5.2.3 为什么共享内存通常最快
映射建立后,多个进程直接访问同一组物理内存页,不必让每块业务数据反复经过“发送方用户态—内核缓冲区—接收方用户态”的复制路径。但它必须额外解决同步、数据一致性和异常恢复。
5.2.4 共享内存和信号量如何配合
共享内存存放业务数据,信号量负责互斥、资源计数或就绪通知。例如生产者写完数据后 post,消费者 wait 成功后读取;多槽缓冲区常使用“空槽计数 + 数据计数 + 互斥锁”。
5.2.5 pipe 和 FIFO 有什么区别
两者都是内核字节流管道。匿名管道没有路径,通常依靠 fork() 继承描述符,适合亲缘进程;FIFO 在文件系统中有名字,无亲缘进程也能通过相同路径打开。
5.2.6 signal 能否用于传输数据
普通信号主要传递信号编号,只适合异步事件通知;标准信号还可能合并,不能可靠记录重复次数。少量附加值可用实时信号,但常规数据应选择管道、消息队列、共享内存或 Socket。
5.2.7 Unix 域 Socket 和网络 Socket 的区别
两者编程模型相似。Unix 域 Socket 只在本机使用,通过路径或抽象地址标识端点,通常开销更低,还支持传递文件描述符和对端凭据;网络 Socket 可跨主机通信。
5.3 快速记忆
字节流:pipe、FIFO、SOCK_STREAM
消息:消息队列、SOCK_DGRAM、SOCK_SEQPACKET
大数据:共享内存 + 信号量/mutex
通知:signal、eventfd
本机服务:Unix 域 Socket
跨主机:TCP / UDP Socket
管道和流式 Socket 没有消息边界
共享内存速度快,但必须自己同步
信号适合通知,不适合传输业务数据
IPC 对象必须明确创建者、所有者和清理者