进程间通信(IPC)
第1节:进程间通信介绍
1.1 为什么要进程间通信?
每个进程都有自己独立的虚拟地址空间,进程A无法直接访问进程B的内存数据。这就像两个人各自住在独立的房间里,墙壁隔音隔光,要交流就必须借助"传话筒"或"窗口"。
四大目的:
| 目的 | 说明 | 实际例子 |
|---|---|---|
| 数据传输 | 一个进程发数据给另一个 | 管道、消息队列 |
| 资源共享 | 多个进程共享同一份数据 | 共享内存 |
| 通知事件 | 通知对方发生了某事 | 子进程退出通知父进程(SIGCHLD) |
| 进程控制 | 完全控制另一个进程的执行 | GDB调试器通过ptrace拦截被调试进程的所有信号 |
1.2 IPC的发展历史
Unix早期 System V (AT&T) POSIX标准
│ │ │
▼ ▼ ▼
管道(pipe) 消息队列 消息队列
共享内存 共享内存
信号量 信号量
互斥量/条件变量/读写锁
关键区别:
- System V IPC:由AT&T的System V Unix引入,IPC资源(共享内存、消息队列、信号量)的生命周期随内核,不主动删除就一直存在(甚至重启后可能残留),需要用
ipcs命令查看,ipcrm命令删除。 - POSIX IPC:POSIX标准定义的IPC,接口更现代,语义更清晰。比如POSIX共享内存可以通过文件系统路径来标识。
第2节:管道概述
什么是管道?
管道是Unix中最古老的IPC机制,本质就是内核中的一块缓冲区(内存),通过两个文件描述符(一端读、一端写)来操作。
进程A write(fd[1], ...) ──→ [内核管道缓冲区] ──→ read(fd[0], ...) 进程B

最经典的管道例子:
who | wc -l
这条命令的含义是:who 命令输出当前登录用户列表,通过管道传给 wc -l 统计行数。
执行过程:
- Shell 创建一个管道(内核缓冲区)
- Shell fork 出两个子进程
- 第一个子进程执行
who,将其 stdout(fd=1)重定向到管道的写端 - 第二个子进程执行
wc -l,将其 stdin(fd=0)重定向到管道的读端 - 数据从
who流经管道,到达wc -l
拓展: 这就是Shell中
|连接符的本质!每次你在Shell中使用|,底层都是在创建管道。
第3节:匿名管道(Anonymous Pipe)

3-1 pipe() 系统调用
#include <unistd.h>
int pipe(int fd[2]);
fd[0]:管道的读端fd[1]:管道的写端- 返回值:成功返回0,失败返回-1
关键理解: pipe() 调用后,当前进程就持有了两个文件描述符,分别指向内核中同一个管道的两端。这就像打开了一个水管的两个口。
简单示例:
int fds[2];
pipe(fds);
// 此时:
// fds[0] = 3 → 可以从管道读数据
// fds[1] = 4 → 可以往管道写数据
write(fds[1], "hello", 5); // 写入5字节
char buf[10];
read(fds[0], buf, 10); // 读出 "hello"
3-2 fork 共享管道原理(核心重点)
这是理解管道最关键的部分。

当父进程先调用 pipe(),再调用 fork() 时:
父进程调用pipe():
父进程文件描述符表
┌─────┐
│ fd[0]=3 ──→ 读端 ──┐
│ fd[1]=4 ──→ 写端 ──┤
└─────┘ ▼
┌─────────┐
│ 管道 │ ← 内核缓冲区
└─────────┘
父进程fork()后:
父进程文件描述符表 子进程文件描述符表
┌─────┐ ┌─────┐
│ fd[0]=3 ──→ 读端 ──┐ │ fd[0]=3 ──→ 读端 ──┐
│ fd[1]=4 ──→ 写端 ──┤ │ fd[1]=4 ──→ 写端 ──┤
└─────┘ ▼ └─────┘ ▼
┌─────────┐
│ 管道 │ ← 同一个内核缓冲区!
└─────────┘
为什么子进程能共享管道?
因为 fork() 时,子进程会复制父进程的文件描述符表。但文件描述符只是"指针/索引",它们指向的是内核中同一个管道对象。所以父子进程读写的是同一个管道。
最佳实践:关闭不用的端
父子进程建立单向通信:
父进程(只写) 子进程(只读)
fd[0]=3 ✗ 关闭读端 fd[0]=3 → 读端(保留)
fd[1]=4 → 写端(保留) fd[1]=4 ✗ 关闭写端
写端 ──→ [管道] ──→ 读端
为什么要关闭不用的端?
- 资源管理:不关闭会浪费文件描述符
- 正确语义:读端检测到所有写端关闭时,
read()返回0(EOF)。如果不关闭多余的写端,读端永远等不到EOF - 信号触发:所有读端关闭后,写端再写会收到
SIGPIPE信号
3-3 从文件描述符角度深度理解管道
创建管道前:
进程文件描述符表:
fd[0] → stdin (终端)
fd[1] → stdout (终端)
fd[2] → stderr (终端)
调用 pipe() 后:
fd[0] → stdin (终端)
fd[1] → stdout (终端)
fd[2] → stderr (终端)
fd[3] → 管道读端 ──┐
fd[4] → 管道写端 ──┤
▼
[内核管道缓冲区]
fork 后(父写子读):
父进程 子进程
fd[0] → stdin fd[0] → stdin
fd[1] → stdout fd[1] → stdout
fd[2] → stderr fd[2] → stderr
fd[3] ✗ (关闭读端) fd[3] → 管道读端 ──┐
fd[4] → 管道写端 ──┐ fd[4] ✗ (关闭写端) │
▼ ▼
[内核管道缓冲区] ← 同一个对象
3-4 管道的内核本质

在Linux内核中,管道的实现是这样的:
进程A的file结构 进程B的file结构
┌──────────────┐ ┌──────────────┐
│ f_inode ─────┼──┐ │ f_inode ─────┼──┐
│ f_op │ │ │ f_op │ │
│ f_pos │ │ │ f_pos │ │
│ ... │ │ │ ... │ │
└──────────────┘ │ └──────────────┘ │
│ │
└──────────┬─────────────────────┘
▼
┌─────────┐
│ inode │ ← 管道对应的inode
└────┬────┘
▼
┌─────────┐
│ 数据页 │ ← 实际存储数据的内核页面
└─────────┘
核心理解:
- 管道在内核中就是一个特殊的inode,这个inode关联了一块内核内存页面作为缓冲区
file结构体中的f_inode指向这个inode- 多个进程的
file结构可以指向同一个inode - 对管道的
read/write操作,最终都是操作这块内核缓冲区 - 管道就是文件! 完全符合Linux"一切皆文件"的哲学
拓展: 匿名管道在文件系统上没有路径名,所以不相关的进程无法通过路径打开它,只能通过fork继承文件描述符来使用。
3-5 进程池代码解析
下面通过一个完整的进程池项目来演示管道的实际应用,完整代码见文末,感兴趣的可以去看一下。
这是一个非常实用的设计模式,逐层解析:
架构设计
Master进程(父进程)
├── Channel[0] ──write──→ pipe0 ──read──→ Worker[0](子进程)
├── Channel[1] ──write──→ pipe1 ──read──→ Worker[1](子进程)
└── Channel[2] ──write──→ pipe2 ──read──→ Worker[2](子进程)
Channel 类的作用: 封装了"往哪个管道写"和"写给谁"的信息
class Channel {
int _wfd; // 管道写端文件描述符
pid_t _who; // 子进程PID
string _name; // 通道名称,用于调试
};
InitProcessPool 的核心流
for (int i = 0; i < processnum; i++) {
// 1. 创建管道
int pipefd[2];
pipe(pipefd);
// 2. fork子进程
pid_t id = fork();
if (id == 0) {
// === 子进程 ===
// 关闭之前创建的所有管道写端(防止子进程之间互相干扰)
for (auto &c : channels) c.Close();
// 关闭自己的写端
close(pipefd[1]);
// 将读端重定向到stdin
dup2(pipefd[0], 0);
// 执行工作任务(从stdin读取任务编号)
work();
exit(0);
}
// === 父进程 ===
close(pipefd[0]); // 关闭读端
channels.emplace_back(pipefd[1], id); // 记录写端和子进程PID
}
关键设计点:
-
dup2(pipefd[0], 0):这是精妙之处!子进程通过dup2将管道读端复制到stdin(fd=0),之后子进程只需从stdin读取就能获取任务。这样Worker()函数直接用read(0, &cmd, sizeof(cmd))即可。 -
子进程关闭历史写端:每次循环创建新管道后,子进程需要关闭之前所有已经创建的管道写端,否则那些管道永远不会收到EOF。
-
轮询派发:
DispatchTask()用who %= channels.size()实现简单的轮询调度(Round-Robin),把任务均匀分配给各个子进程。
3-6 管道读写规则(重要)
| 场景 | 阻塞模式(默认) | 非阻塞模式 |
|---|---|---|
| 无数据可读 |
进程暂停执行,一直等到有数据来到为止。 | read() 返回 -1,errno=EAGAIN |
| 管道已满 |
直到有进程读走数据 | write() 返回 -1,errno=EAGAIN |
| 所有写端关闭 | read() 返回 0(EOF) | 同左 |
| 所有读端关闭 | write() 产生 SIGPIPE 信号 | 同左 |
关于 PIPE_BUF 的原子性:
- 当写入数据量 ≤
PIPE_BUF(Linux上通常为4096字节)时,内核保证写入是原子的(一次写完,不会被打断) - 当写入数据量 >
PIPE_BUF时,内核不保证原子性,数据可能被其他写者穿插
面试高频点: 管道的大小是有限的!Linux上默认是64KB(通过
/proc/sys/fs/pipe-max-size可查看/修改)。当管道满时写阻塞,当管道空时读阻塞,这就是天然的生产者-消费者同步机制。
3-7 管道特点总结
| 特点 | 说明 |
|---|---|
| 只能用于亲缘关系进程 | 因为匿名管道没有文件名,只能通过fork继承fd。只能用于具有共同祖先的进程(具有亲缘关系的进程)之间进行通信;通常,一个管道由一个进 程创建,然后该进程调用fork,此后父、子进程之间就可应用该管道。 |
| 流式服务 | 数据没有边界,read可以读取任意字节数 |
| 生命周期随进程 | 进程退出,管道自动释放 |
| 自带同步互斥 | 内核保证读写的原子性和阻塞同步 |
| 半双工 | 数据只能单向流动,双向通信需要两个管道 |
半双工 vs 全双工:
半双管(管道):A ──→ B (只能一个方向)
全双工(需要两个管道):
A ──→ 管道1 ──→ B
A ←── 管道2 ←── B
3-8 管道通信的4种情况
| 情况 | 描述 | 结果 |
|---|---|---|
| 读正常 + 写满 | 管道满了,还在写 | 写阻塞(等读端读走数据) |
| 写正常 + 读空 | 管道空了,还在读 | 读阻塞(等写端写入数据) |
| 写关闭 + 读正常 | 所有写端都关了 | read() 返回 0(EOF) |
| 读关闭 + 写正常 | 所有读端都关了 | write() 产生 SIGPIPE,进程可能终止 |
面试题及详细解答
面试题1:匿名管道能否用于不相关的进程间通信?为什么?
答: 不能。匿名管道(pipe)只能用于具有亲缘关系的进程间通信。
原因: pipe() 创建的管道在文件系统中没有路径名(没有文件名),其他进程无法通过 open() 打开它。只有通过 fork() 继承文件描述符的方式,子进程才能获得父进程创建的管道的fd。如果需要不相关进程间通信,应使用**命名管道(FIFO)**或System V/POSIX IPC机制。
面试题2:父进程创建管道后fork,父子进程如何实现单向通信?为什么要关闭不用的端?
答:
实现方式:
int fd[2];
pipe(fd);
if (fork() == 0) {
// 子进程:只读
close(fd[1]); // 关闭写端
read(fd[0], buf, size);
close(fd[0]);
} else {
// 父进程:只写
close(fd[0]); // 关闭读端
write(fd[1], data, len);
close(fd[1]);
}
必须关闭不用端的原因:
- EOF语义:
read()返回0(EOF)的条件是所有写端都被关闭。如果父进程不关闭自己的写端(假设父进程是读者),那么子进程的read()永远不会返回0。 - SIGPIPE信号:如果所有读端关闭后,进程还往管道写,会收到
SIGPIPE信号导致进程异常终止。如果不关闭多余的读端,就无法及时触发这个信号。 - 资源泄漏:不关闭的fd会一直占用文件描述符资源。
面试题3:管道的大小是多少?写满管道会发生什么?
答:
管道大小: Linux默认管道缓冲区大小为 65536字节(64KB)。可以通过 cat /proc/sys/fs/pipe-max-size 查看,也可以通过 fcntl(fd, F_SETPIPE_SZ, size) 修改(有上限)。
写满时的行为:
- 阻塞模式(默认):
write()系统调用会阻塞,进程暂停执行,直到有进程从管道读走数据、腾出空间为止。 - 非阻塞模式:
write()立即返回 -1,errno设为EAGAIN。
这本质上是一个生产者-消费者模型,管道满时生产者阻塞,管道空时消费者阻塞,内核自动实现了同步。
面试题4:如果管道读端关闭,进程继续写会怎样?
答: 内核会向写进程发送 SIGPIPE 信号。SIGPIPE 的默认处理动作是终止进程。
这是一个常见的编程陷阱:如果写进程没有忽略或处理 SIGPIPE 信号,它会意外退出。在网络编程中尤其常见(socket也有类似的机制)。
防护措施:
signal(SIGPIPE, SIG_IGN); // 忽略SIGPIPE
// 或
signal(SIGPIPE, handler); // 自定义处理
面试题5:请解释 who | wc -l 在Shell中是如何通过管道实现的
答:
1. Shell进程调用 pipe() 创建管道,得到 fd[0](读端) 和 fd[1](写端)
2. Shell调用 fork() 创建子进程A
- 子进程A:close(fd[0]),dup2(fd[1], STDOUT_FILENO),exec("who")
- 此时 who 的输出会写入管道
3. Shell调用 fork() 创建子进程B
- 子进程B:close(fd[1]),dup2(fd[0], STDIN_FILENO),exec("wc -l")
- 此时 wc -l 从管道读取数据
4. Shell关闭 fd[0] 和 fd[1](父进程不参与通信)
5. Shell调用 waitpid() 等待两个子进程结束
关键系统调用: pipe() + fork() + dup2() + exec()
面试题6:管道和普通文件有什么区别?
答:
| 对比项 | 管道 | 普通文件 |
|---|---|---|
| 存储介质 | 内核内存(RAM) | 磁盘 |
| 生命周期 | 随进程 | 永久保存 |
| 容量 | 有限(64KB) | 受磁盘空间限制 |
| 访问方式 | 只能顺序读写 | 可随机访问(seek) |
| 文件系统可见性 | 匿名管道不可见 | 有路径名 |
| 同步机制 | 自带阻塞同步 | 无内置同步 |
相同点: 都通过文件描述符操作,都支持 read()/write() 系统调用,都体现了"一切皆文件"的设计思想。
第4节:命名管道(FIFO)
• 管道应用的一个限制就是只能在具有共同祖先(具有亲缘关系)的进程间通信。
• 如果我们想在不相关的进程之间交换数据,可以使用FIFO文件来做这项工作,它经常被称为命名 管道。
• 命名管道是一种特殊类型的文件
4-1 创建命名管道
命令行创建:
mkfifo filename
# 查看
ls -la filename
# prw-r--r-- 1 root root 0 ... filename
# 权限第一位'p'表示这是一个管道文件(FIFO)
代码创建:
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *filename, mode_t mode);
// filename: 管道文件路径
// mode: 权限,如 0644
// 返回值: 成功返回0,失败返回-1,errno设为相应错误码
// 注意: 如果文件已存在,会返回EEXIST错误
关键理解: mkfifo() 只在文件系统中创建了一个inode节点(特殊的文件类型),但不分配内核缓冲区。只有当某个进程 open() 这个FIFO文件时,内核才会真正分配管道缓冲区。
4-2 匿名管道 vs 命名管道
• 匿名管道由pipe函数创建并打开。
• 命名管道由mkfifo函数创建,打开用open
• FIFO(命名管道)与pipe(匿名管道)之间唯一的区别在它们创建与打开的方式不同,一但这些 工作完成之后,它们具有相同的语义。
匿名管道(pipe): 命名管道(FIFO):
┌──────────┐ ┌──────────────────┐
│ 无文件名 │ │ /tmp/mypipe │
│ 无inode │ │ 有inode(p类型) │
│ 仅限fork │ │ 任意进程可open │
│ 继承fd │ │ │
└──────────┘ └──────────────────┘
│ │
└────────── 共同点 ────────────────┘
│
┌─────────┴─────────┐
│ 内核缓冲区在内存中 │
│ 数据不写入磁盘 │
│ 半双工,流式服务 │
│ read/write语义相同 │
└───────────────────┘
4-3 命名管道的打开规则(面试高频)
• 如果当前打开操作是为读而打开FIFO时
◦ O_NONBLOCK disable:阻塞直到有相应进程为写而打开该FIFO
◦ O_NONBLOCK enable:立刻返回成功
• 如果当前打开操作是为写而打开FIFO时
◦ O_NONBLOCK disable:阻塞直到有相应进程为读而打开该FIFO
◦ O_NONBLOCK enable:立刻返回失败,错误码为ENXIO
┌─────────────────────────────────────────┐
│ 命名管道 open() 行为 │
├──────────────┬──────────────────────────┤
│ 阻塞模式 │ 非阻塞模式 │
│ (默认) │ (O_NONBLOCK) │
┌───────────────┼──────────────┼──────────────────────────┤
│ 为读而打开 │ 阻塞等待 │ 立即返回成功(fd可用) │
│ │ 直到有写端 │ │
├───────────────┼──────────────┼──────────────────────────┤
│ 为写而打开 │ 阻塞等待 │ 立即返回失败 │
│ │ 直到有读端 │ errno = ENXIO │
└───────────────┴──────────────┴──────────────────────────┘
为什么这样设计?
命名管道的 open() 阻塞行为是一种隐式握手机制:
- 读端
open()阻塞 → 确保有写端后才继续 → 保证后续read()不会永远阻塞 - 写端
open()阻塞 → 确保有读端后才继续 → 保证后续write()不会产生SIGPIPE
实例1:用命名管道实现文件拷贝
架构: 两个独立进程通过FIFO配合,完成文件拷贝
进程A(写端): 进程B(读端):
读取源文件abc 从FIFO读取
│ │
▼ ▼
写入FIFO(tp) ──→ [FIFO: tp] ──→ 写入目标文件abc.bak
writer.c — 读取文件,写入命名管道
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
// 1. 创建命名管道"tp"
// 如果已存在会报错(EEXIST),这里忽略错误
mkfifo("tp", 0644);
// 2. 以只读方式打开源文件"abc"
int infd;
infd = open("abc", O_RDONLY);
if (infd == -1) ERR_EXIT("open");
// 3. 以只写方式打开命名管道"tp"
// 这里会阻塞!直到有另一个进程以读方式打开"tp"
// 也就是reader.c运行后,这里才会解除阻塞
int outfd;
outfd = open("tp", O_WRONLY);
if (outfd == -1) ERR_EXIT("open");
// 4. 循环: 从源文件读 → 写入管道
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0)
{
// read返回实际读到的字节数n
// write写入恰好n字节(避免写入多余的脏数据)
write(outfd, buf, n);
}
// 5. 清理: 关闭文件描述符
close(infd);
close(outfd);
return 0;
}
reader.c — 读取管道,写入目标文件
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
// 1. 以写方式创建目标文件"abc.bak"
// O_WRONLY | O_CREAT | O_TRUNC:
// 只写 + 不存在则创建 + 已存在则清空
int outfd;
outfd = open("abc.bak", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (outfd == -1) ERR_EXIT("open");
// 2. 以只读方式打开命名管道"tp"
// 如果writer.c还没运行,这里会阻塞
// 如果writer.c已经运行并open了写端,这里解除阻塞
int infd;
infd = open("tp", O_RDONLY);
if (infd == -1) ERR_EXIT("open");
// 3. 循环: 从管道读 → 写入目标文件
char buf[1024];
int n;
while ((n = read(infd, buf, 1024)) > 0)
{
// read返回0时: writer关闭了写端,所有数据已读完
// read返回-1时: 出错
// read返回正数时: 读到了n字节数据
write(outfd, buf, n);
}
// 4. 清理
close(infd);
close(outfd);
// 5. 删除FIFO文件(用完即删)
// 注意: unlink的是文件系统中的FIFO文件
// 不是删除管道缓冲区(管道在close时已释放)
unlink("tp");
return 0;
}
运行顺序:
# 终端1: 先启动writer(会阻塞在open("tp", O_WRONLY))
$ ./writer
# 阻塞中...等待reader打开读端
# 终端2: 启动reader(两个open都会立即返回,因为writer已经打开了写端)
$ ./reader
# 此时:
# writer从abc读数据 → 写入tp → reader从tp读数据 → 写入abc.bak
# 传输完成后, writer的read返回0, 循环结束
# reader的read返回0(因为writer关闭了写端), 循环结束
执行时序图:
writer.c reader.c
──────── ────────
mkfifo("tp")
open("abc", O_RDONLY) → infd
open("tp", O_WRONLY) → 阻塞! ──────────────→ open("tp", O_RDONLY) 解除
open("abc.bak", O_CREAT)
loop: loop:
read(infd, buf, 1024) → n read(infd, buf, 1024) → n
write(outfd, buf, n) ──FIFO──→ write(outfd, buf, n)
... (直到abc读完) ... ... (直到读到EOF) ...
close(infd)
close(outfd) ──→ 写端关闭 ──→ read返回0 ←── close(infd)
close(outfd)
unlink("tp")
实例2:用命名管道实现 server & client 通信
serverPipe.c — 服务端(读端)
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#define ERR_EXIT(m) \
do{ \
perror(m); \
exit(EXIT_FAILURE);\
}while(0)
int main()
{
// 1. 设置umask为0,确保创建的FIFO权限就是0644
// 不受父进程umask影响
umask(0);
// 2. 创建命名管道"mypipe"
if(mkfifo("mypipe", 0644) < 0){
ERR_EXIT("mkfifo");
}
// 3. 以只读方式打开管道
// 阻塞!直到client以写方式打开"mypipe"
int rfd = open("mypipe", O_RDONLY);
if(rfd < 0){
ERR_EXIT("open");
}
// 4. 循环读取client发送的消息
char buf[1024];
while(1){
buf[0] = 0; // 清空buf第一个字节(相当于buf[0]='\0')
printf("Please wait...\n");
// 从管道读取数据
// s > 0: 读到了数据
// s == 0: client关闭了写端(client退出了)
// s < 0: 读取出错
ssize_t s = read(rfd, buf, sizeof(buf)-1);
if(s > 0 ){
// 去掉末尾的换行符
// client通过read(0,...)从键盘读取,末尾带'\n'
// 把'\n'替换为'\0',打印时不会多一个空行
buf[s-1] = 0;
// 打印client发来的消息
printf("client say# %s\n", buf);
}else if(s == 0){
// read返回0: 所有写端关闭 → client已退出
printf("client quit, exit now!\n");
exit(EXIT_SUCCESS);
}else{
// read返回负数: 出错
ERR_EXIT("read");
}
}
// 5. 关闭读端(正常不会走到这里,因为上面是while(1))
close(rfd);
return 0;
}
clientPipe.c — 客户端(写端)
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#define ERR_EXIT(m) \
do{ \
perror(m); \
exit(EXIT_FAILURE);\
}while(0)
int main()
{
// 1. 以写方式打开命名管道
// 阻塞!直到server以读方式打开"mypipe"
// 两个进程的open()会互相"握手",同时解除阻塞
int wfd = open("mypipe", O_WRONLY);
if(wfd < 0){
ERR_EXIT("open");
}
// 2. 循环: 从键盘读取 → 写入管道
char buf[1024];
while(1){
buf[0] = 0; // 清空
// 打印提示符
printf("Please Enter# ");
fflush(stdout); // 必须刷新!因为stdout是行缓冲
// printf没有换行符,不会自动刷新
// 如果不fflush,提示符可能不会显示
// 从stdin(键盘)读取一行
// 返回值s: 读到的字节数(包含末尾的'\n')
ssize_t s = read(0, buf, sizeof(buf)-1);
if(s > 0 ){
buf[s] = 0; // 在读到的数据末尾加'\0'
// 写入管道
// strlen(buf)计算实际长度(不含'\0')
// 注意: buf中包含'\n',所以server端需要buf[s-1]=0去掉它
write(wfd, buf, strlen(buf));
}else if(s <= 0){
// Ctrl+D(EOF)或出错
ERR_EXIT("read");
}
}
// 3. 关闭写端(正常不会走到这里)
// 关闭后,server的read()返回0,server退出
close(wfd);
return 0;
}
运行演示:

# 终端1: 启动server
$ ./serverPipe
Please wait...
# 阻塞中...等待client连接
# 终端2: 启动client
$ ./clientPipe
Please Enter# hello world ← 用户输入
Please Enter# nihao ← 用户输入
Please Enter# good ← 用户输入
Please Enter# Ctrl+C ← 用户终止
# 终端1的输出:
Please wait...
client say# hello world
Please wait...
client say# nihao
Please wait...
client say# good
Please wait...
client quit, exit now! ← client关闭写端,server读到EOF
关键细节:
问题: client按Ctrl+C后,server为什么能检测到client退出?
回答:
Ctrl+C → client进程收到SIGINT → 进程终止
→ 内核自动关闭client的所有fd,包括wfd(管道写端)
→ 此时管道的所有写端都关闭了
→ server的read()返回0(EOF)
→ server打印"client quit"并退出
第5节:System V 共享内存详解
共享内存区是最快的IPC形式。一旦这样的内存映射到共享它的进程的地址空间,这些进程间数据传递 不再涉及到内核,换句话说是进程不再通过执行进入内核的系统调用来传递彼此的数据
5-1 共享内存原理

物理内存
┌─────────────────┐
│ 共享内存页面 │ ← 同一块物理内存
│ (例如4KB) │
└────────┬────────┘
│
┌────────────┼────────────┐
│ │ │
▼ │ ▼
进程A的页表 │ 进程B的页表
虚拟地址:0x7f000 │ 虚拟地址:0x8a000
│
两个不同的虚拟地址
映射到同一块物理内存
进程A写入后,进程B直接可读
无需经过内核中转!零拷贝!
5-2 共享内存数据结构
struct shmid_ds {
struct ipc_perm shm_perm; // 权限信息(uid,gid,mode)
int shm_segsz; // 共享内存大小(字节)
__kernel_time_t shm_atime; // 最后一次挂接(shmat)时间
__kernel_time_t shm_dtime; // 最后一次脱离(shmdt)时间
__kernel_time_t shm_ctime; // 最后一次修改(shmctl)时间
__kernel_ipc_pid_t shm_cpid; // 创建者PID
__kernel_ipc_pid_t shm_lpid; // 最后操作者PID
unsigned short shm_nattch; // 当前挂接的进程数 ← 重要!
// ...
};
shm_nattch的意义: 当前有多少个进程挂接着这块共享内存。当shm_nattch为0时,表示没有进程在使用。可以用ipcs -m查看。
5-3 四大核心函数详解
ftok() — 生成唯一key
key_t ftok(const char *pathname, int proj_id);
// pathname: 必须是一个已存在的文件路径
// proj_id: 项目ID(只用低8位,0~255)
// 返回值: 成功返回key_t,失败返回-1
// 原理: 文件inode号 + proj_id低8位 → 位运算 → 唯一key
// 两个进程用相同的pathname和proj_id,就能得到相同的key
shmget() — 创建/获取共享内存
int shmget(key_t key, size_t size, int shmflg);
// key: 共享内存标识(由ftok生成)
// size: 共享内存大小(字节),建议4096的整数倍
// shmflg: 标志位
// shmflg取值:
// IPC_CREAT : 不存在则创建,已存在则获取
// IPC_CREAT|IPC_EXCL: 不存在则创建,已存在则报错(EEXIST)
// IPC_CREAT|0666 : 创建时指定权限
// 返回值: 成功返回shmid(内核标识符),失败返回-1
key vs shmid 的区别:
用户层面 内核层面
┌─────────┐ ┌─────────┐
│ key │ ──shmget──→ │ shmid │ ──shmat──→ 实际操作
│(文件名) │ │(文件描述符)│
└─────────┘ └─────────┘
key: 用户用来"命名"共享内存(类似文件名)
shmid: 内核用来"标识"共享内存(类似文件描述符)
后续所有操作(shmat/shmdt/shmctl)都用shmid
shmat() — 挂接到进程地址空间
void *shmat(int shmid, const void *shmaddr, int shmflg);
// shmid: shmget返回的共享内存标识
// shmaddr: 挂接地址
// NULL → 内核自动选择合适的地址(推荐)
// 非NULL → 用户指定地址(需要对齐,不推荐)
// shmflg: 0 → 读写模式(默认)
// SHM_RDONLY → 只读模式
// 返回值: 成功返回共享内存首地址(类似malloc返回的指针)
// 失败返回(void*)-1
使用示例:
char *addr = (char*)shmat(shmid, NULL, 0);
if (addr == (void*)-1) {
perror("shmat");
exit(1);
}
// 现在可以直接读写addr了!
strcpy(addr, "hello world"); // 写入共享内存
printf("%s\n", addr); // 从共享内存读取
shmdt() — 脱离共享内存
int shmdt(const void *shmaddr);
// shmaddr: shmat返回的地址
// 返回值: 成功返回0,失败返回-1
// 注意: "脱离"≠"删除"
// 脱离后:
// - 当前进程不能再访问这块内存
// - 共享内存仍在系统中,其他进程可用
// - shm_nattch减1
shmctl() — 控制共享内存
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
// shmid: shmget返回的标识
// cmd: IPC_STAT → 获取状态信息,存入buf
// IPC_SET → 设置属性(需要权限)
// IPC_RMID → 删除共享内存(最重要!)
// buf: 接收/发送数据的结构体,不需要时传NULL
讲几个命令:

实例1:共享内存实现通信
comm.h — 公共头文
#ifndef _COMM_H_
#define _COMM_H_
# include <stdio.h>
# include <sys/types.h>
# include <sys/ipc.h>
# include <sys/shm.h>
// ftok参数:
# define PATHNAME "." // 用当前目录生成key(基于当前目录的inode)
# define PROJ_ID 0x6666 // 项目ID(任意值,只用低8位)
// 函数声明
int createShm(int size); // 创建共享内存
int destroyShm(int shmid); // 销毁共享内存
int getShm(int size); // 获取已存在的共享内存
#endif
comm.c — 公共实现
#include "comm.h"
// 底层函数: 创建或获取共享内存
// flags决定行为: IPC_CREAT|IPC_EXCL=创建, IPC_CREAT=获取
static int commShm(int size, int flags)
{
// 1. 生成公共key
// 两个进程用相同的PATHNAME和PROJ_ID,得到相同的key
// 这样才能找到同一个共享内存
key_t key = ftok(PATHNAME, PROJ_ID);
if(key < 0){
perror("ftok");
return -1;
}
// 2. 创建/获取共享内存
int shmid = 0;
if( (shmid = shmget(key, size, flags)) < 0){
perror("shmget");
return -2;
}
// 3. 返回shmid
return shmid;
}
// 销毁共享内存
int destroyShm(int shmid)
{
// IPC_RMID: 标记删除
// 即使有进程还在挂接,也会标记删除
// 内核等到所有进程都脱离后才真正释放
if(shmctl(shmid, IPC_RMID, NULL) < 0){
perror("shmctl");
return -1;
}
return 0;
}
// 创建全新的共享内存
// IPC_CREAT|IPC_EXCL: 如果已存在则报错(避免误用别人的共享内存)
int createShm(int size)
{
return commShm(size, IPC_CREAT|IPC_EXCL|0666);
}
// 获取已存在的共享内存
// IPC_CREAT: 如果不存在则创建,已存在则获取
int getShm(int size)
{
return commShm(size, IPC_CREAT);
}
server.c — 服务端(创建者)
#include "comm.h"
int main()
{
// 1. 创建共享内存(4096字节,恰好是1页)
// server是通信发起者,负责创建
int shmid = createShm(4096);
// 2. 挂接到自己的地址空间
// addr就是共享内存的首地址,可以像普通指针一样读写
char *addr = shmat(shmid, NULL, 0);
// 3. 等待2秒,给client时间挂接
// (实际项目中应该用信号量等同步机制)
sleep(2);
// 4. 循环读取client写入共享内存的数据
// 每隔1秒读一次,共读26次
int i = 0;
while(i++ < 26){
// 直接从addr读取!不需要read()系统调用!
// 这就是共享内存快的原因:零拷贝
printf("client# %s\n", addr);
sleep(1);
}
// 5. 脱离共享内存
// 解除映射,addr不再可用
shmdt(addr);
// 6. 等待2秒,让client也脱离
sleep(2);
// 7. 销毁共享内存
// IPC_RMID后,内核标记删除
// 等所有进程都脱离后真正释放
destroyShm(shmid);
return 0;
}
client.c — 客户端(使用者)
#include "comm.h"
int main()
{
// 1. 获取已存在的共享内存
// 注意:这里用getShm(),不是createShm()
// 因为server已经创建了共享内存
int shmid = getShm(4096);
// 2. 等待1秒(让server先挂接)
sleep(1);
// 3. 挂接到自己的地址空间
// 此时addr和server的addr指向同一块物理内存!
// 虽然虚拟地址可能不同,但数据是共享的
char *addr = shmat(shmid, NULL, 0);
// 4. 等待2秒
sleep(2);
// 5. 循环写入: 逐个写入字母A~Z
// 每秒写一个,server每秒读一次,刚好同步
int i = 0;
while(i < 26){
addr[i] = 'A' + i; // 写入字母(A,B,C,...,Z)
i++;
addr[i] = 0; // 在字母后加'\0',server打印时作为字符串结束符
sleep(1);
}
// 6. 脱离共享内存
shmdt(addr);
// 7. 等待2秒
sleep(2);
return 0;
}
运行结果:

# 终端1: 启动server
$ ./server
client# A
client# AB
client# ABC
client# ABCD
...
client# ABCDEFGHIJKLMNOPQRSTUVWXYZ
ctrl+c终止进程,再次重启
# ./server
shmget: File exists
# ipcs -m
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x66026a25 688145 root 666 4096 0
# ipcrm -m 688145 #删除shm ipc资源,注意,不是必须通过手动来删除,这里只为演示相关指
令,删除IPC资源是进程该做的事情
执行时序:
时间轴(秒) 0 1 2 3 4 5 ... 28 29 30
│ │ │ │ │ │ │ │ │
server: 创建 │ 挂接 │ 开始读(每秒1次) │ 脱离 │ 销毁
shm │ sleep(2) ──循环26次──→ │ sleep(2)
│ │ │ │ │ │ │ │ │
client: │ 获取 │ 挂接 │ 开始写(每秒1个) │ 脱离
│ shm │ sleep(2) ──循环26次──→ │ sleep(2)
│ │ │ │ │ │ │ │ │
共享内存: 创建 挂接 ←── A,AB,ABC,... ──→ 销毁
这个例子有严重的并发问题: server和client没有同步机制,完全靠
sleep(1)来"假同步"。如果时序不对,server可能读到不完整的数据。这就是下面"带访问控制版本"要解决的问题。
实例2:借助管道实现访问控制版的共享内存
设计思路: 共享内存负责传输数据(快),管道负责通知/同步(控制访问顺序)。
client: server:
写数据到共享内存 阻塞在管道read()上(等待通知)
write(管道) ←── 通知server ──→ read(管道)解除阻塞
继续写... 读取共享内存数据(此时数据是完整的)
Comm.hpp — 公共模块(含同步工具)
#pragma once
#include <fcntl.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <cassert>
#include <cstdio>
#include <ctime>
#include <cstring>
#include <iostream>
using namespace std;
// ========== 日志系统 ==========
// 日志级别: 数值越小越详细
#define Debug 0 // 调试信息
#define Notice 1 // 通知信息
#define Warning 2 // 警告信息
#define Error 3 // 错误信息
const std::string msg[] = {
"Debug",
"Notice",
"Warning",
"Error"
};
// 日志函数: 输出格式为 " | 时间戳 | 级别 | 消息"
std::ostream &Log(std::string message, int level)
{
std::cout << " | " << (unsigned)time(nullptr)
<< " | " << msg[level]
<< " | " << message;
return std::cout; // 返回cout支持链式调用: Log("xxx", Debug) << "附加信息\n";
}
// ========== 常量定义 ==========
#define PATH_NAME "/home/hyb" // ftok使用的路径
#define PROJ_ID 0x66 // ftok使用的项目ID
#define SHM_SIZE 4096 // 共享内存大小(建议是页大小的整数倍)
#define FIFO_NAME "./fifo" // 命名管道文件名
// ========== Init类: 自动创建和销毁FIFO ==========
// 利用构造函数和析构函数实现RAII(资源获取即初始化)
// 创建Init对象时自动创建FIFO,对象销毁时自动删除FIFO
class Init {
public:
Init() {
umask(0);
// 创建命名管道,用于同步控制
int n = mkfifo(FIFO_NAME, 0666);
assert(n == 0); // 断言成功(开发阶段用,生产环境应更优雅地处理)
(void)n; // 消除unused变量警告
Log("create fifo success", Notice) << "\n";
}
~Init() {
// 删除FIFO文件
unlink(FIFO_NAME);
Log("remove fifo success", Notice) << "\n";
}
};
// ========== FIFO操作封装 ==========
#define READ O_RDONLY
#define WRITE O_WRONLY
// 打开FIFO
int OpenFIFO(std::string pathname, int flags) {
int fd = open(pathname.c_str(), flags);
assert(fd >= 0);
return fd;
}
// 关闭FIFO
void CloseFifo(int fd) {
close(fd);
}
// Wait: 从管道读取(阻塞等待通知)
// 读到数据说明有进程通知自己可以继续了
void Wait(int fd) {
Log("等待中....", Notice) << "\n";
uint32_t temp = 0;
// 读取4字节(一个uint32_t)
// 如果管道为空,read会阻塞
// 如果管道写端关闭,read返回0
ssize_t s = read(fd, &temp, sizeof(uint32_t));
assert(s == sizeof(uint32_t)); // 确保读到了完整的4字节
(void)s;
}
// Signal: 往管道写入(通知对方)
// 写入数据后,对方的read()解除阻塞
void Signal(int fd) {
uint32_t temp = 1;
// 写入4字节
ssize_t s = write(fd, &temp, sizeof(uint32_t));
assert(s == sizeof(uint32_t));
(void)s;
Log("唤醒中....", Notice) << "\n";
}
// 辅助函数: 把key_t转成十六进制字符串(用于日志输出)
string TransToHex(key_t k) {
char buffer[32];
snprintf(buffer, sizeof buffer, "0x%x", k);
return buffer;
}
ShmServer.cc — 服务端
#include "Comm.hpp"
// 全局对象: main之前自动调用构造函数创建FIFO
// main结束时自动调用析构函数删除FIFO
Init init;
int main() {
// ====== 1. 创建公共Key ======
// ftok根据文件路径和proj_id生成唯一的key
// server和client用相同的参数,得到相同的key
key_t k = ftok(PATH_NAME, PROJ_ID);
assert(k != -1);
Log("create key done", Debug) << " server key : " << TransToHex(k) << endl;
// ====== 2. 创建共享内存 ======
// IPC_CREAT|IPC_EXCL: 创建全新的,已存在则报错
// 0666: 权限(读写)
int shmid = shmget(k, SHM_SIZE, IPC_CREAT | IPC_EXCL | 0666);
if (shmid == -1) {
perror("shmget");
exit(1);
}
Log("create shm done", Debug) << " shmid : " << shmid << endl;
// ====== 3. 挂接到自己的地址空间 ======
// 返回值shmaddr就是共享内存的首地址
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
Log("attach shm done", Debug) << " shmid : " << shmid << endl;
// ====== 4. 访问控制: 用FIFO做同步 ======
// 以只读方式打开FIFO
// 阻塞!直到client以写方式打开FIFO
int fd = OpenFIFO(FIFO_NAME, O_RDONLY);
while (true) {
// 【Wait】: 阻塞等待client通知
// client写完共享内存后,通过FIFO发送通知
// 收到通知后,server才去读共享内存
// 这样保证server读到的一定是完整的数据
Wait(fd);
// 【临界区】: 安全地读取共享内存
// 此时client已经写完并通知了,数据是完整的
printf("%s\n", shmaddr);
// 检查退出条件: client发送"quit"
if (strcmp(shmaddr, "quit") == 0)
break;
}
// 关闭FIFO读端
CloseFifo(fd);
// ====== 5. 脱离共享内存 ======
// 解除映射,shmaddr不再可用
int n = shmdt(shmaddr);
assert(n != -1);
(void)n;
Log("detach shm done", Debug) << " shmid : " << shmid << endl;
// ====== 6. 删除共享内存 ======
// IPC_RMID: 标记删除
// 即使client还在挂接,也会标记
// 内核等到所有进程都脱离后才真正释放
n = shmctl(shmid, IPC_RMID, nullptr);
assert(n != -1);
(void)n;
Log("delete shm done", Debug) << " shmid : " << shmid << endl;
return 0;
}
ShmClient.cc — 客户端
#include "Comm.hpp"
int main() {
// ====== 1. 创建公共Key ======
// 和server用相同的参数,得到相同的key
key_t k = ftok(PATH_NAME, PROJ_ID);
if (k < 0) {
Log("create key failed", Error) << " client key : " << TransToHex(k) << endl;
exit(1);
}
Log("create key done", Debug) << " client key : " << TransToHex(k) << endl;
// ====== 2. 获取共享内存 ======
// 只用IPC_CREAT(不加IPC_EXCL),获取已存在的共享内存
// 因为server已经创建了,这里只需要获取
int shmid = shmget(k, SHM_SIZE, 0);
if (shmid < 0) {
Log("create shm failed", Error) << " client key : " << TransToHex(k) << endl;
exit(2);
}
Log("create shm success", Error) << " client key : " << TransToHex(k) << endl;
// ====== 3. 挂接共享内存 ======
// 挂接后,shmaddr指向和server同一块物理内存
char* shmaddr = (char*)shmat(shmid, nullptr, 0);
if (shmaddr == nullptr) {
Log("attach shm failed", Error) << " client key : " << TransToHex(k) << endl;
exit(3);
}
Log("attach shm success", Error) << " client key : " << TransToHex(k) << endl;
// ====== 4. 写入数据 ======
// 以写方式打开FIFO
// 阻塞!直到server以读方式打开FIFO
// 双方的open()互相"握手",同时解除阻塞
int fd = OpenFIFO(FIFO_NAME, O_WRONLY);
while (true) {
// 从键盘读取一行到共享内存
// 直接写入shmaddr!不需要write()系统调用!零拷贝!
ssize_t s = read(0, shmaddr, SHM_SIZE - 1);
if (s > 0) {
// 去掉末尾的换行符
shmaddr[s - 1] = 0;
// 【Signal】: 通知server"我写完了,你可以读了"
Signal(fd);
// 如果输入"quit",退出循环
if (strcmp(shmaddr, "quit") == 0)
break;
}
}
// 关闭FIFO写端
CloseFifo(fd);
// ====== 5. 脱离共享内存 ======
int n = shmdt(shmaddr);
assert(n != -1);
Log("detach shm success", Error) << " client key : " << TransToHex(k) << endl;
return 0;
}
运行结果:
# 终端1: 启动server
$ ./ShmServer
| 1716000000 | Notice | create fifo success
| 1716000000 | Debug | create key done server key : 0x66xxxxxx
| 1716000000 | Debug | create shm done shmid : 12345
| 1716000000 | Debug | attach shm done shmid : 12345
| 1716000000 | Notice | 等待中....
# 阻塞中...
# 终端2: 启动client
$ ./ShmClient
hello world ← 用户输入
| 1716000005 | Notice | 唤醒中....
# 终端1输出:
| 1716000005 | Notice | 等待中....
hello world ← server读到完整数据!
| 1716000006 | Notice | 等待中....
同步流程图:
时间 ────────────────────────────────────────────────────────→
client: read(0) → 写入shmaddr → Signal(fd) → read(0) → ...
│
FIFO通知
│
server: Wait(fd) ←───┘ → 读shmaddr → Wait(fd) → ...
(阻塞) (安全读取) (阻塞)
对比无同步版本的问题:
无同步(靠sleep):
client: 写A ──sleep── 写B ──sleep── 写C
server: sleep── 读 ──sleep── 读 ──sleep── 读
问题: 如果client写到一半,server就去读了 → 读到不完整数据!
有同步(用FIFO):
client: 写完ABC → Signal → 写完DEF → Signal
server: Wait → 读(保证ABC完整) → Wait → 读(保证DEF完整)
解决: server只在收到通知后才读,保证数据完整性
第6节:System V 消息队列详解
6-1 消息队列原理
消息队列是内核中的一个链表,每个节点是一条带类型的消息:
内核中的消息队列:
┌─────────────────────────────────────────────────────┐
│ msgid_ds (队列控制结构)
│ ┌─────────────────────────────────────────────┐
│ │ msg_first ──→ [类型2|"hello"]
│ │ ──→ [类型1|"world"]
│ │ ──→ [类型3|"foo"]
│ │ ──→ NULL
│ │ msg_qnum = 3 (消息数量)
│ │ msg_qbytes = 16384 (队列最大字节数)
│ └─────────────────────────────────────────────┘
└─────────────────────────────────────────────────────┘
进程A: msgsnd() → 往链表尾部添加一个节点
进程B: msgrcv() → 从链表中取出一个节点(可按类型过滤)
6-2 消息结构体
// 用户自定义的消息结构体
struct msgbuf {
long mtype; // 消息类型,必须 > 0
char mtext[100]; // 消息数据,大小自定义
};
// mtype是必须的,mtext的内容和大小由用户决定
6-3 核心函数
msgget() — 创建/获取消息队列
int msgget(key_t key, int msgflg);
// key: ftok生成的标识
// msgflg: IPC_CREAT | IPC_EXCL (创建) 或 IPC_CREAT (获取)
// 返回值: 成功返回msgid,失败返回-1
msgsnd() — 发送消息
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
// msqid: msgget返回的队列标识
// msgp: 指向msgbuf结构体的指针(包含类型和数据)
// msgsz: 消息数据的大小(不包含mtype的大小)
// msgflg: 0=阻塞(队列满时等待) IPC_NOWAIT=非阻塞(队列满时返回EAGAIN)
msgrcv() — 接收消息
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
// msqid: 队列标识
// msgp: 接收缓冲区
// msgsz: 缓冲区大小
// msgtyp: 消息类型过滤(重点!)
// 0: 接收队列中第一条消息(任意类型)
// >0: 只接收mtype==msgtyp的消息
// <0: 接收mtype≤|msgtyp|中最小类型的消息
// msgflg: 0=阻塞 IPC_NOWAIT=非阻塞 MSG_NOERROR=截断超长消息
msgctl() — 控制消息队列
int msgctl(int msqid, int cmd, struct msqid_ds *buf);
// cmd: IPC_STAT(获取状态) IPC_SET(设置属性) IPC_RMID(删除队列)
6-4 使用示例
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <string.h>
struct msgbuf {
long mtype; // 消息类型
char mtext[100]; // 消息内容
};
int main()
{
// 1. 创建消息队列
key_t key = ftok(".", 0x66);
int msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);
// 2. 发送消息
struct msgbuf msg;
msg.mtype = 1; // 类型1
strcpy(msg.mtext, "hello world"); // 内容
msgsnd(msqid, &msg, strlen(msg.mtext), 0);
msg.mtype = 2; // 类型2
strcpy(msg.mtext, "foo bar");
msgsnd(msqid, &msg, strlen(msg.mtext), 0);
// 3. 接收消息(按类型过滤)
struct msgbuf rcv;
msgrcv(msqid, &rcv, sizeof(rcv.mtext), 2, 0);
// 只接收类型2的消息: "foo bar"
printf("type=%ld, text=%s\n", rcv.mtype, rcv.mtext);
msgrcv(msqid, &rcv, sizeof(rcv.mtext), 1, 0);
// 只接收类型1的消息: "hello world"
printf("type=%ld, text=%s\n", rcv.mtype, rcv.mtext);
// 4. 删除消息队列
msgctl(msqid, IPC_RMID, NULL);
return 0;
}
消息类型过滤的妙用:
场景: 3个客户端向同一个服务端发消息
客户端1(登录服务): mtype=1, "用户登录"
客户端2(订单服务): mtype=2, "创建订单"
客户端3(支付服务): mtype=3, "支付请求"
队列中: [1|登录] → [2|订单] → [3|支付] → [2|订单] → [1|登录]
服务端处理:
msgrcv(msqid, &msg, size, 0, 0) → 取第一条: [1|登录] (FIFO)
msgrcv(msqid, &msg, size, 3, 0) → 只取类型3: [3|支付] (优先处理支付)
msgrcv(msqid, &msg, size, -2, 0) → 取类型≤2中最小的: [1|登录]
面试题及详细解答
面试题1:命名管道和匿名管道的本质区别是什么?一旦打开后还有区别吗?
答:
唯一的本质区别:创建和打开方式不同。
| 匿名管道 | 命名管道 | |
|---|---|---|
| 创建 | pipe() 自动创建并打开 | mkfifo() 创建文件,需手动 open() |
| 可见性 | 无文件路径,不相关进程无法访问 | 有文件路径,任意进程可 open() |
一旦打开后,两者没有任何区别。 它们的内核实现完全相同(都是内核中的字节流缓冲区),read()/write() 语义一致,读写规则一致。
面试题2:共享内存为什么是最快的IPC?需要几次数据拷贝?
答:
共享内存只需要 0次数据拷贝。
- 管道/消息队列:需要 2次拷贝(用户空间 → 内核缓冲区 → 用户空间)
- 共享内存:0次拷贝。两个进程的虚拟地址空间映射到同一块物理内存,进程A写入后,进程B直接可读,不经过内核中转。
代价是:共享内存没有同步机制,需要额外的同步手段(信号量、管道、互斥锁等)。
面试题3:System V共享内存的 shmctl(shmid, IPC_RMID, NULL) 是立即删除吗?
答:
不是立即删除,而是标记删除。
调用 IPC_RMID 后:
- 内核将共享内存标记为"待删除"
- 不再允许新的
shmat()挂接 - 已经挂接的进程仍然可以正常使用
- 当所有进程都
shmdt()脱离后(shm_nattch减到0),内核才真正释放内存
这个设计类似于文件的"删除"——如果还有进程打开着文件,文件数据不会真正释放。
面试题4:ftok() 生成的key会不会冲突?
答:
理论上可能冲突,但实际概率极低。
ftok 的算法:
key = (proj_id & 0xFF) << 24 | (st_dev & 0xFF) << 16 | (st_ino & 0xFFFF);
只有32位空间,且只用了inode的低16位,所以不同文件的inode低16位相同时,会产生相同的key。
避免冲突的方法:
- 选择inode号较大的文件(如
/tmp下的文件) - 使用不同的
proj_id值 - 如果确实冲突了,
shmget会获取到不同的共享内存(因为内核内部用更精确的方式区分)
面试题5:消息队列相比管道有什么优势?
答:
| 特性 | 管道 | 消息队列 |
|---|---|---|
| 数据格式 | 无结构字节流 | 有类型的消息块 |
| 数据边界 | 无边界 | 有边界(每条消息独立) |
| 读取方式 | 严格FIFO | 可按类型选择性读取 |
| 优先级 | 无 | 通过类型实现简单优先级 |
| 生命周期 | 随进程 | 随内核 |
消息队列最大的优势是带类型的读取,可以实现消息过滤和简单的优先级调度。
面试题6:为什么共享内存通信中需要额外的同步机制?请举例说明。
答:
因为共享内存是"裸"的内存,没有任何保护。如果两个进程同时读写:
时序问题示例:
client正在写入: addr = "hello wo"
server此时读取: printf("%s\n", addr) → 输出 "hello wo"(不完整!)
client继续写入: addr = "hello world"
正确做法(用FIFO同步):
client: 写完"hello world" → Signal通知
server: Wait等待 → 收到通知 → 读取(保证完整) → "hello world"
常见同步方案:
- 信号量:最正规的方案,P操作申请资源,V操作释放资源
- 管道/FIFO:作为通知机制(如课件中的方案)
- 互斥锁+条件变量:POSIX线程同步原语,也可用于进程间(需放在共享内存中,且设置
PTHREAD_PROCESS_SHARED属性)
第7节:System V 信号量
这里简单提一下,后面还要详解
7-1 并发编程核心概念
在讲解信号量之前,必须先理解这些基础概念:
┌─────────────────────────────────────────────────────────────┐
│ 并发编程概念体系
├─────────────────────────────────────────────────────────────┤
│
│ 多个执行流(进程/线程)能看到的同一份公共资源
│ │
│ ▼
│ 共享资源 (Shared Resource)
│ │
│ ▼
│ 被保护起来的资源 → 临界资源 (Critical Resource)
│ │
│ ▼
│ 访问临界资源的代码 → 临界区 (Critical Section)
│ │
│ ▼
│ 保护方式:
│ ├── 互斥 (Mutual Exclusion): 任一时刻只允许一个执行流访问
│ └── 同步 (Synchronization): 多个执行流按一定顺序访问
│
└─────────────────────────────────────────────────────────────┘
用生活例子理解:
互斥 = 厕所门锁: 一次只能进一个人
同步 = 排队叫号: 按顺序来,叫到号才能办理
┌──────────────────────────────────────────────────┐
│
│ 你的代码 = 临界区代码 + 非临界区代码
│
│ ┌──────────────┐
│ │ 非临界区 ← 不访问共享资源,无需保护
│ ├──────────────┤
│ │ 加锁 ← 进入临界区前必须加锁
│ ├──────────────┤
│ │ 临界区 ← 访问共享资源的代码
│ ├──────────────┤
│ │ 解锁 ← 离开临界区后解锁
│ ├──────────────┤
│ │ 非临界区
│ └──────────────┘
│
│ 所谓"对共享资源进行保护"
│ 本质是"对访问共享资源的代码(临界区)进行保护"
│
└──────────────────────────────────────────────────┘

互斥 vs 同步的区别:
互斥(Mutual Exclusion):
目标: 防止同时访问
例子: 两个进程同时写同一个文件 → 数据混乱
解决: 加锁,一次只允许一个进程写
同步(Synchronization):
目标: 保证执行顺序
例子: 生产者还没生产,消费者就去消费 → 读到空数据
解决: 先生产再消费,按顺序来
7-2 信号量详解
信号量是什么?
一句话:信号量就是一个计数器 + 等待队列。
信号量结构:
┌───────────────────────────────┐
│ semval (计数器): 当前可用资源数
│ 例如: 3 表示有3个空闲车位
├───────────────────────────────┤
│ 等待队列: 资源不够时,阻塞的进程
│ 例如: P1,P2正在等待
└───────────────────────────────┘
信号量的本质理解
信号量的本质是对资源的"预订机制"。

类比: 电影院卖票
电影院有100个座位(资源总数)
信号量semval = 100 (初始值)
来一个观众:
P操作: semval-- (预订一个座位)
如果semval >= 0: 可以进场
如果semval < 0: 没座位了,排队等待
走一个观众:
V操作: semval++ (释放一个座位)
如果有人在排队: 唤醒一个人进场
P/V 操作
P操作 (Proberen, 荷兰语"尝试"):
申请资源
semval--
if (semval < 0):
当前进程进入等待队列,阻塞
V操作 (Verhogen, 荷兰语"增加"):
释放资源
semval++
if (semval <= 0):
从等待队列唤醒一个进程
详细执行流程:
初始: semval = 1 (互斥锁模式,只有1个资源)
进程A: P操作 → semval = 0 → 0 >= 0 → 进入临界区 ✓
进程B: P操作 → semval = -1 → -1 < 0 → 阻塞等待 ✗
进程A: V操作 → semval = 0 → 0 <= 0 ────────┘ (唤醒B)
进程B: 被唤醒 → 进入临界区 ✓
当semval=1时,信号量就是一个互斥锁(mutex)!
semval = 1 (二元信号量/互斥锁):
P操作 = 加锁
V操作 = 解锁
semval = N (计数信号量):
最多允许N个进程同时访问
例如: 数据库连接池有5个连接,semval=5
两种资源使用模式

模式1: 资源整体使用 (互斥)
┌──────────┐
│ ████████ │ 资源被一个进程独占
│ ████████ │ semval = 1
│ ████████ │ 典型: 互斥锁
└──────────┘
模式2: 资源分块使用 (计数)
┌───┬───┬───┬───┐
│ P1│ P2│ │ │ 4个资源槽位
├───┼───┼───┼───┤ semval = 2 (还有2个空闲)
│ │ │ │ │ 典型: 连接池,停车位
└───┴───┴───┴───┘
System V 信号量的特点
信号量集合(Semaphore Set): System V的信号量不是单个计数器,而是一组计数器的集合。
semid = semget(key, 5, IPC_CREAT|0666);
// 创建一个包含5个信号量的集合
信号量集合:
┌─────────────────────────────────────────────────┐
│ semid: 12345
│ ┌─────┬─────┬─────┬─────┬─────┐
│ │sem 0│sem 1│sem 2│sem 3│sem 4│
│ │ =1 │ =3 │ =0 │ =5 │ =1 │
│ └─────┴─────┴─────┴─────┴─────┘
│ 每个信号量都是独立的计数器
│ 可以对单个信号量进行P/V操作
└─────────────────────────────────────────────────┘
semget() — 创建/获取信号量集合
int semget(key_t key, int nsems, int semflg);
// key: ftok生成的标识
// nsems: 信号量集合中信号量的个数
// 创建时必须指定;获取时传0即可
// semflg: IPC_CREAT|IPC_EXCL (创建) 或 IPC_CREAT (获取)
// 权限标志如 0666
// 返回值: 成功返回semid,失败返回-1
semop() — P/V操作
int semop(int semid, struct sembuf *sops, unsigned nsops);
// semid: 信号量集合标识
// sops: 操作数组(可以一次执行多个操作)
// nsops: 操作数组的元素个数
核心结构体 struct sembuf:
struct sembuf {
unsigned short sem_num; // 信号量编号(在集合中的下标,从0开始)
short sem_op; // 操作值
short sem_flg; // 标志位
};
// sem_op 取值:
// -1: P操作(申请资源), semval-- , 如果semval<0则阻塞
// 1: V操作(释放资源), semval++ , 如果有等待者则唤醒
// 0: 等待semval变为0(用于等待所有资源被释放)
//
// sem_flg 取值:
// 0: 默认,阻塞模式
// IPC_NOWAIT: 非阻塞,资源不够时立即返回EAGAIN
// SEM_UNDO: 进程异常退出时自动撤销操作(防止死锁)
P/V操作代码示例:
// P操作(申请资源): semval--
struct sembuf sem_p;
sem_p.sem_num = 0; // 操作第0个信号量
sem_p.sem_op = -1; // -1 = P操作
sem_p.sem_flg = 0; // 阻塞模式
semop(semid, &sem_p, 1);
// V操作(释放资源): semval++
struct sembuf sem_v;
sem_v.sem_num = 0; // 操作第0个信号量
sem_v.sem_op = 1; // +1 = V操作
sem_v.sem_flg = 0;
semop(semid, &sem_v, 1);
semctl() — 控制信号量
int semctl(int semid, int semnum, int cmd, ...);
// semid: 信号量集合标识
// semnum: 信号量编号(从0开始)
// cmd: 控制命令
// 第4个参数: 可选,取决于cmd
常用cmd:
| cmd | 说明 | 第4个参数 |
|---|---|---|
IPC_RMID | 删除整个信号量集合 | 不需要 |
SETVAL | 设置单个信号量的值 | int val |
GETVAL | 获取单个信号量的值 | 不需要 |
SETALL | 设置所有信号量的值 | unsigned short array[] |
GETALL | 获取所有信号量的值 | unsigned short array[] |
初始化信号量:
// 设置第0个信号量的初始值为1(互斥锁)
semctl(semid, 0, SETVAL, 1);
// 或者用union方式(某些系统要求)
union semun {
int val; // SETVAL的值
struct semid_ds *buf; // IPC_STAT/IPC_SET的缓冲区
unsigned short *array; // GETALL/SETALL的数组
};
union semun arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);
完整示例:用信号量实现互斥
#include <stdio.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>
union semun {
int val;
struct semid_ds *buf;
unsigned short *array;
};
// P操作: 申请资源(进入临界区)
void P(int semid, int sem_num)
{
struct sembuf sem;
sem.sem_num = sem_num;
sem.sem_op = -1; // semval--
sem.sem_flg = 0;
semop(semid, &sem, 1);
}
// V操作: 释放资源(离开临界区)
void V(int semid, int sem_num)
{
struct sembuf sem;
sem.sem_num = sem_num;
sem.sem_op = 1; // semval++
sem.sem_flg = 0;
semop(semid, &sem, 1);
}
int main()
{
// 1. 创建信号量集合(1个信号量)
key_t key = ftok(".", 0x66);
int semid = semget(key, 1, IPC_CREAT | IPC_EXCL | 0666);
// 2. 初始化: semval = 1 (互斥锁模式)
union semun arg;
arg.val = 1;
semctl(semid, 0, SETVAL, arg);
// 3. fork子进程,演示互斥访问
pid_t pid = fork();
if (pid == 0) {
// 子进程
P(semid, 0); // 加锁
printf("子进程进入临界区\n");
sleep(3); // 模拟临界区操作
printf("子进程离开临界区\n");
V(semid, 0); // 解锁
} else {
// 父进程
P(semid, 0); // 加锁(如果子进程持有锁,这里会阻塞)
printf("父进程进入临界区\n");
sleep(3);
printf("父进程离开临界区\n");
V(semid, 0); // 解锁
}
// 4. 清理
waitpid(-1, NULL, 0); // 等待子进程
semctl(semid, 0, IPC_RMID); // 删除信号量
return 0;
}
输出结果(互斥保证了不会交错):
子进程进入临界区
... (等待3秒) ...
子进程离开临界区
父进程进入临界区
... (等待3秒) ...
父进程离开临界区
SEM_UNDO 的重要性
sem.sem_flg = SEM_UNDO; // 自动撤销
问题场景: 进程在临界区中崩溃(被kill或段错误),没有执行V操作,信号量永远无法释放 → 死锁。
SEM_UNDO 的解决方式: 内核为每个进程维护一个"撤销计数"。当进程异常退出时,内核自动执行V操作,释放信号量。
正常流程:
进程A: P操作 → 进入临界区 → V操作 → 离开
异常流程(无SEM_UNDO):
进程A: P操作 → 进入临界区 → 崩溃! → 信号量卡死 → 死锁
异常流程(有SEM_UNDO):
进程A: P操作 → 进入临界区 → 崩溃! → 内核自动V操作 → 安全
最佳实践: 生产环境中,
sem_flg应该始终使用SEM_UNDO。
第8节:内核如何组织管理 IPC 资源
8-1 核心思想:C 语言实现多态
Linux内核用C语言实现了面向对象的"多态"效果。对于管道、消息队列、共享内存、信号量这四种IPC资源,内核用统一的接口来管理它们。
用户层调用:
shmget() / msgget() / semget() / pipe()
│ │ │ │
└─────────┴─────────┴────────┘
│
▼
统一的IPC管理层
│
┌──────────┬───┴───┬──────────┐
▼ ▼ ▼ ▼
管道 消息队列 共享内存 信号量
8-2 内核数据结构全景图

┌─────────────────────────────────────────────────────────────────┐
│ 内核IPC资源管理结构 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 管道(Pipe): │
│ ┌──────────────────┐ ┌──────────────┐ │
│ │ inode │────→│ pipe_inode_ │ │
│ │ (特殊inode) │ │ info │ │
│ └──────────────────┘ │ ┌─────────┐ │ │
│ │ │ 环形缓冲 │ │ │
│ │ │ 区(页面) │ │ │
│ │ └─────────┘ │ │
│ └──────────────┘ │
│ │
│ 消息队列(Message Queue): │
│ ┌──────────────────┐ ┌──────────────┐ │
│ │ kern_ipc_perm │ │ msg_queue │ │
│ │ (权限结构) │←───→│ msg_first ──│→ [msg]→[msg]→NULL │
│ └──────────────────┘ │ msg_qnum │ │
│ │ msg_qbytes │ │
│ └──────────────┘ │
│ │
│ 共享内存(Shared Memory): │
│ ┌──────────────────┐ ┌──────────────┐ ┌─────────┐ │
│ │ kern_ipc_perm │ │ shmid_kernel │ │ 物理页面 │ │
│ │ (权限结构) │←───→│ shm_segsz │────→│ (数据) │ │
│ └──────────────────┘ │ shm_nattch │ └─────────┘ │
│ └──────────────┘ │
│ │
│ 信号量(Semaphore): │
│ ┌──────────────────┐ ┌──────────────┐ │
│ │ kern_ipc_perm │ │ sem_array │ │
│ │ (权限结构) │←───→│ semval[0..n]│ │
│ └──────────────────┘ │ 等待队列 │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
8-3 kern_ipc_perm — 公共权限结构
所有System V IPC资源都有一个共同的权限结构 kern_ipc_perm:
struct kern_ipc_perm {
spinlock_t lock; // 自旋锁(保护并发访问)
bool deleted; // 是否已标记删除
int id; // IPC资源的内核标识符
key_t key; // 用户指定的key
kuid_t uid; // 创建者用户ID
kgid_t gid; // 创建者组ID
kuid_t cuid; // 创建者用户ID(实际)
kgid_t cgid; // 创建者组ID(实际)
umode_t mode; // 访问权限(类似文件权限)
unsigned long seq; // 序列号(用于生成IPC ID)
};
这就是C语言实现"多态"的关键:
struct shmid_kernel {
struct kern_ipc_perm shm_perm; // ← 公共基类(第一个成员)
struct shm_region *shm_region;
// ... 共享内存特有字段
};
struct msg_queue {
struct kern_ipc_perm q_perm; // ← 公共基类(第一个成员)
struct list_head q_messages;
// ... 消息队列特有字段
};
struct sem_array {
struct kern_ipc_perm sem_perm; // ← 公共基类(第一个成员)
struct sem *sems;
// ... 信号量特有字段
};
为什么这样设计?
// 统一的权限检查函数
int ipc_check_perm(struct kern_ipc_perm *perm, ...)
{
// 只需要访问公共字段(uid, gid, mode)
// 不需要知道具体是哪种IPC资源
if (perm->uid != current_uid ...)
return -EACCES;
return 0;
}
// 调用时,把具体结构的首地址当作kern_ipc_perm*传入
// 因为kern_ipc_perm是第一个成员,地址相同!
// 这就是C语言版的"多态"
ipc_check_perm(&shmid_kernel->shm_perm, ...);
ipc_check_perm(&msg_queue->q_perm, ...);
ipc_check_perm(&sem_array->sem_perm, ...);
8-4 各IPC资源的内核结构
管道 — pipe_inode_info
struct pipe_inode_info {
struct mutex mutex; // 互斥锁(保护并发读写)
wait_queue_head_t wait; // 等待队列(读写阻塞时在此等待)
unsigned int nrbufs; // 当前有数据的缓冲区数量
unsigned int curbuf; // 当前读取的缓冲区下标
struct pipe_buffer *bufs; // 环形缓冲区数组
};
struct pipe_buffer {
struct page *page; // 指向物理页面(实际存数据的地方)
unsigned int offset; // 数据在页面中的起始偏移
unsigned int len; // 数据长度
const struct pipe_buf_operations *ops; // 操作函数表
};
管道的环形缓冲区:
pipe_buffer数组(环形):
┌──────┬──────┬──────┬──────┬──────┐
│page 0│page 1│page 2│page 3│page 4│
│offset│offset│offset│offset│offset│
│len │len │len │len │len │
└──┬───┴──┬───┴──────┴──────┴──┬───┘
│ │ │
│ └── curbuf(当前读位置)│
└── nrbufs(有数据的个数)
写入: 从curbuf+nrbufs位置写入新页面
读取: 从curbuf位置读取,然后curbuf++,nrbufs--
满: nrbufs == 管道容量(默认16个页面=64KB)
空: nrbufs == 0
消息队列 — msg_queue
struct msg_queue {
struct kern_ipc_perm q_perm; // 公共权限结构
time_t q_stime; // 最后一次msgsnd时间
time_t q_rtime; // 最后一次msgrcv时间
time_t q_ctime; // 最后一次修改时间
unsigned long q_cbytes; // 队列中当前字节数
unsigned long q_qnum; // 队列中当前消息数
unsigned long q_qbytes; // 队列最大字节数
pid_t q_lspid; // 最后一次msgsnd的PID
pid_t q_lrpid; // 最后一次msgrcv的PID
struct list_head q_messages; // 消息链表头
struct list_head q_receivers; // 等待接收的进程链表
struct list_head q_senders; // 等待发送的进程链表
};
消息队列的链表结构:
q_messages → [msg1] → [msg2] → [msg3] → NULL
│
▼
struct msg_msg {
struct list_head m_list; // 链表节点
long m_type; // 消息类型
int m_ts; // 消息大小
struct msg_msgseg *next; // 大消息的分段指针
char m_text[]; // 消息数据(柔性数组)
};
共享内存 — shmid_kernel
struct shmid_kernel {
struct kern_ipc_perm shm_perm; // 公共权限结构
struct shm_region *shm_region; // 指向实际的共享内存区域
pid_t shm_cprid; // 创建者PID
pid_t shm_lprid; // 最后操作者PID
time_t shm_atim; // 最后挂接时间
time_t shm_dtim; // 最后脱离时间
time_t shm_ctim; // 最后修改时间
unsigned long shm_nattch; // 当前挂接数
};
共享内存的页面映射:
shmid_kernel
│
└──→ shm_region
│
└──→ 物理页面集合
┌──────────┐
│ page 0 │ ← 物理页面
│ page 1 │
│ page 2 │
└──────────┘
↑
┌───────────┼───────────┐
│ │ │
进程A的页表 进程B的页表 进程C的页表
虚拟地址X 虚拟地址Y 虚拟地址Z
映射到page 0 映射到page 0 映射到page 0
三个不同的虚拟地址 → 同一个物理页面
信号量 — sem_array
struct sem_array {
struct kern_ipc_perm sem_perm; // 公共权限结构
time_t sem_otime; // 最后一次semop时间
time_t sem_ctime; // 最后一次修改时间
struct sem *sems; // 信号量数组
struct list_head sem_pending; // 挂起的操作队列
unsigned long sem_nsems; // 信号量个数
};
struct sem {
int semval; // 当前值(计数器)
int sempid; // 最后操作者的PID
struct list_head sem_pending; // 等待此信号量的进程链表
};
信号量集合的结构:
sem_array
│
├── sems[0]: { semval=1, sempid=1234, 等待链表 }
├── sems[1]: { semval=3, sempid=5678, 等待链表 }
├── sems[2]: { semval=0, sempid=0, 等待链表→[P1]→[P2] }
└── sems[3]: { semval=5, sempid=0, 等待链表 }
sems[2]的等待队列: P1,P2正在等待(因为semval=0,资源耗尽)
8-5 统一的IPC管理 — ipc_ids
内核为每种IPC资源维护一个 ipc_ids 结构,用于管理该类型的所有资源实例:
struct ipc_ids {
rwlock_t rwlock; // 读写锁
int in_use; // 当前使用的IPC资源数
int max_id; // 最大ID
unsigned short seq; // 序列号
unsigned short seq_max; // 最大序列号
struct ipc_id_ary nullentry;// 空入口(初始时)
struct ipc_id_ary *entries; // 指针数组,指向所有IPC资源
};
内核中的IPC资源管理:
ipc_ids (共享内存管理器)
│
├── entries[0] ──→ shmid_kernel (key=0x1234)
├── entries[1] ──→ NULL
├── entries[2] ──→ shmid_kernel (key=0x5678)
└── entries[3] ──→ NULL
ipc_ids (消息队列管理器)
│
├── entries[0] ──→ msg_queue (key=0xabcd)
└── entries[1] ──→ NULL
ipc_ids (信号量管理器)
│
├── entries[0] ──→ sem_array (key=0x9999)
├── entries[1] ──→ sem_array (key=0x8888)
└── entries[2] ──→ NULL
8-6 从用户调用到内核的完整链路
用户调用: shmget(key, size, IPC_CREAT|0666)
│
▼
系统调用: sys_shmget()
│
▼
IPC管理层: ipcget()
│
├── 根据key查找ipc_ids.entries[]
│ ├── 找到 → 返回已有的shmid
│ └── 没找到 → 创建新的shmid_kernel
│ ├── 分配shmid_kernel结构
│ ├── 设置kern_ipc_perm(key,uid,gid,mode)
│ ├── 分配物理页面(shm_region)
│ └── 插入ipc_ids.entries[]
│
▼
返回shmid给用户空间
8-7 C语言实现多态的技巧总结
核心技巧: 结构体第一个成员的地址 == 结构体本身的地址
struct shmid_kernel {
struct kern_ipc_perm shm_perm; // 第一个成员
// ...
};
shmid_kernel *k = ...;
kern_ipc_perm *p = (kern_ipc_perm *)k;
// p == k (地址相同!)
// 可以把shmid_kernel*当作kern_ipc_perm*使用
// 这就是C语言的"继承"
类似技巧在Linux内核中随处可见:
- list_head 链表
- container_of 宏
- file_operations 函数表(面向对象的"虚函数表")
对比C++多态:
C++多态:
class Shm : public IpcPerm { ... };
IpcPerm *p = new Shm();
p->check_perm(); // 虚函数表,动态分派
C语言"多态":
struct shmid_kernel {
struct kern_ipc_perm perm; // 第一个成员相当于"基类"
// ...
};
kern_ipc_perm *p = (kern_ipc_perm *)shm;
ipc_check_perm(p); // 直接函数调用,无虚函数表
面试题及详细解答
面试题1:什么是信号量?它和互斥锁有什么区别?
答:
信号量(Semaphore) 是一个计数器 + 等待队列,用于控制多个进程/线程对共享资源的访问。
| 对比项 | 信号量 | 互斥锁 |
|---|---|---|
| 计数器 | 可以 > 1(允许多个进程同时访问) | 只能 = 1(互斥) |
| P/V vs lock/unlock | semop(-1) / semop(+1) | lock() / unlock() |
| 所有者 | 无所有者(任何进程可V操作) | 有所有者(谁加的锁谁解) |
| 用途 | 既可用于互斥,也可用于同步 | 只用于互斥 |
当信号量的初始值为1时,它就是一个互斥锁(二元信号量)。 但信号量更通用——初始值为N时,可以允许最多N个进程同时访问。
面试题2:什么是P操作和V操作?请描述semval从1到-1再到0的完整过程。
答:
- P操作(申请资源):
semval--,如果结果 < 0,当前进程阻塞 - V操作(释放资源):
semval++,如果结果 ≤ 0,唤醒一个等待进程
过程演示(semval初始为1):
semval = 1 (初始,有1个可用资源)
进程A: P操作 → semval = 0 → 0 >= 0 → 进入临界区 ✓
进程B: P操作 → semval = -1 → -1 < 0 → 进程B阻塞 ✗
进程A: V操作 → semval = 0 → 0 <= 0 → 唤醒进程B
进程B: 被唤醒,进入临界区 ✓
进程B: V操作 → semval = 1 → 1 > 0 → 无需唤醒(没有等待者)
面试题3:为什么信号量操作建议使用 SEM_UNDO?
答:
SEM_UNDO 的作用是:当进程异常退出时,内核自动撤销该进程对信号量的操作(自动执行V操作)。
没有 SEM_UNDO 的风险:
进程A: P操作 → 进入临界区 → 进程崩溃(SIGSEGV/SIGKILL)
→ semval 仍然是 -1 或 0
→ 其他进程永远无法获取信号量
→ 死锁!
有 SEM_UNDO 的保护:
进程A: P操作(SEM_UNDO) → 进入临界区 → 进程崩溃
→ 内核检测到进程退出
→ 自动执行V操作
→ semval 恢复正常
→ 其他进程可以正常获取信号量
面试题4:信号量的 semval 可以为负数吗?负数代表什么含义?
答:
可以。semval 为负数时,其绝对值表示当前在等待队列中阻塞的进程数量。
semval = 1: 有1个可用资源,无等待者
semval = 0: 资源刚好用完,无等待者
semval = -1: 资源用完,有1个进程在等待
semval = -3: 资源用完,有3个进程在等待
面试题5:内核如何用C语言实现IPC资源的统一管理?
答:
核心技巧是把公共权限结构 kern_ipc_perm 作为每种IPC资源结构体的第一个成员。
struct shmid_kernel {
struct kern_ipc_perm shm_perm; // 第一个成员
// ... 共享内存特有字段
};
// 因为第一个成员的地址 == 结构体地址
// 所以可以把 shmid_kernel* 强制转换为 kern_ipc_perm*
// 用统一的函数处理所有IPC资源的权限检查
这相当于C++中的继承和多态,但没有虚函数表的开销。内核通过函数指针(file_operations、pipe_buf_operations 等)实现类似虚函数的动态分派效果。
面试题6:请描述 shmget() 系统调用从用户空间到内核的完整执行流程。
答:
用户调用: shmget(key, 4096, IPC_CREAT|0666)
│
▼ 系统调用陷入内核
│
sys_shmget(key, size, shmflg)
│
▼
ipcget(key, &shm_ids, &shm_ops)
│
├── 1. 在 shm_ids->entries[] 中根据 key 查找
│ ├── 找到已存在的: 检查权限, 返回已有shmid
│ └── 没找到: 进入步骤2
│
├── 2. 分配 shmid_kernel 结构
│ ├── 设置 shm_perm (uid, gid, mode, key)
│ ├── 分配物理页面 (shm_region)
│ └── 设置其他字段 (nattch=0, cpid=current_pid...)
│
├── 3. 将 shmid_kernel 插入 shm_ids->entries[]
│ └── 分配唯一的 shmid (基于数组下标+序列号)
│
└── 4. 返回 shmid 给用户空间
好了,全部讲完了。总结一下覆盖的内容:
| 节 | 内容 | 要点 |
|---|---|---|
| 1 | 进程间通信介绍 | 四大目的、System V vs POSIX |
| 2 | 管道概述 | Shell中 ` |
| 3 | 匿名管道 | pipe+fork原理、读写规则、进程池 |
| 4 | 命名管道 | FIFO打开规则、server/client通信 |
| 5 | 共享内存 | 零拷贝、生命周期随内核、同步问题 |
| 6 | 消息队列 | 带类型的消息、按类型读取 |
| 7 | 信号量 | P/V操作、互斥 vs 同步、SEM_UNDO |
| 8 | 内核IPC管理 | C语言多态、kern_ipc_perm、统一管理 |
3-5 管道样例详解
3-5-1 测试管道读写
功能: 子进程往管道写入 "hello",父进程从管道读出并打印。
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>
#include <string.h>
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while(0)
int main(int argc, char *argv[])
{
int pipefd[2]; // pipefd[0]是读端, pipefd[1]是写端
// 1. 创建管道
// 调用后,当前进程持有两个fd,分别指向内核中同一个管道的两端
if (pipe(pipefd) == -1)
ERR_EXIT("pipe error");
// 2. fork子进程
// fork后,父子进程各持有pipefd[0]和pipefd[1]
// 但它们指向的是同一个内核管道
pid_t pid;
pid = fork();
if (pid == -1)
ERR_EXIT("fork error");
// === 子进程:负责写 ===
if (pid == 0) {
// 子进程不需要读,关闭读端
// 原因1: 节省fd资源
// 原因2: 如果不关,父进程的read永远等不到EOF(因为还有写端存在)
close(pipefd[0]);
// 往管道写入5字节: "hello"
// write()不会在末尾自动加'\0',所以写入的是纯字符
write(pipefd[1], "hello", 5);
// 写完后关闭写端
// 此时管道两端在子进程中都已关闭
// 但父进程还持有读端,所以管道不会被销毁
close(pipefd[1]);
// 子进程退出
exit(EXIT_SUCCESS);
}
// === 父进程:负责读 ===
// 父进程不需要写,关闭写端
// 关键: 这是最后一个写端被关闭的位置
// 当子进程关闭pipefd[1]后,系统中只剩父进程这个写端
// 父进程再关掉它,所有写端都关闭了
// 此时子进程已经写了数据在管道中,父进程可以直接读
close(pipefd[1]);
char buf[10] = {0}; // 初始化为全0,方便打印时自动截断
// 从管道读取数据
// read()返回实际读到的字节数,这里是5
// 读完后,管道中没有数据了,且所有写端已关闭
// 所以如果再次read(),会返回0(EOF)
read(pipefd[0], buf, 10);
printf("buf=%s\n", buf); // 输出: buf=hello
// 关闭读端
close(pipefd[0]);
// 父进程退出
// 此时父子进程都不再持有管道的fd,内核释放管道缓冲区
return 0;
}
执行时序图:
时间 ──────────────────────────────────────────────────→
父进程: pipe() → fork() → close(写) → read() → printf → close(读)
│ ↑
子进程: └→ close(读) → write("hello") → close(写) → exit
│
数据进入管道
关键点总结:
write()写入的是5字节原始数据,不含\0buf初始化为全0,所以"hello"后面自然有\0结尾,printf能正确打印- 父进程先关闭写端再读,确保最终能读到EOF
3-5-2 进程池完整实现
这是一个非常经典的Master-Worker 架构:
Master(父进程) Worker(子进程)
───────────── ─────────────
持有所有管道的写端 每个Worker持有自己管道的读端
负责派发任务 负责执行任务
┌──────────┐ ┌──────────┐
│ Master │──write→ pipe0 ──read→ │ Worker 0 │
│ │──write→ pipe1 ──read→ │ Worker 1 │
│ │──write→ pipe2 ──read→ │ Worker 2 │
└──────────┘ └──────────┘
Channel.hpp — 通信信道
作用: 把"往哪个管道写"和"写给哪个子进程"封装成一个对象。
#ifndef __CHANNEL_HPP__
#define __CHANNEL_HPP__
#include <iostream>
#include <string>
#include <unistd.h>
// Channel 描述了一条从父进程到子进程的通信信道
// 每个Channel对象 = 一个管道的写端 + 对应的子进程PID
class Channel
{
public:
// 构造: 传入管道写端fd和子进程PID
// 例如: Channel(5, 1234) 表示 fd=5的管道写端, 对应子进程PID=1234
Channel(int wfd, pid_t who) : _wfd(wfd), _who(who)
{
// 生成一个可读的名字,用于调试输出
// 例如: "Channel-5-1234"
_name = "Channel-" + std::to_string(wfd) + "-" + std::to_string(who);
}
// 获取信道名称(用于日志输出)
std::string Name()
{
return _name;
}
// 往管道写入一个int(任务编号)
// 这是整个进程池的核心通信方式:
// Master通过这个函数把"任务编号"发送给Worker
// Worker从stdin读到这个编号后,执行对应的任务
void Send(int cmd)
{
// ::write 表示调用全局的write系统调用(不是类成员)
// &cmd: 把int的4个字节直接写入管道
// sizeof(cmd): 写入4字节
::write(_wfd, &cmd, sizeof(cmd));
}
// 关闭管道写端
// 在清理阶段调用,关闭写端后Worker的read()会返回0(EOF)
// Worker收到EOF就知道Master让自己退出了
void Close()
{
::close(_wfd);
}
// 获取对应的子进程PID(用于waitpid回收)
pid_t Id()
{
return _who;
}
// 获取写端fd(用于调试和子进程关闭历史fd)
int wFd()
{
return _wfd;
}
~Channel()
{
}
private:
int _wfd; // 管道写端文件描述符
std::string _name; // 信道名称(调试用)
pid_t _who; // 对应的子进程PID
};
#endif
Task.hpp — 任务管理
作用: 定义一批任务,Worker根据收到的任务编号执行对应的任务。
#pragma once
#include <iostream>
#include <vector>
#include <functional>
#include <ctime>
#include <sys/types.h>
#include <unistd.h>
using task_t = std::function<void()>; // 任务类型: 无参无返回值的可调用对象
// TaskManger: 任务管理器
// 内部维护一个任务列表,每个任务是一个lambda函数
// Worker收到任务编号后,调用Excute(number)执行对应任务
class TaskManger
{
public:
TaskManger()
{
srand(time(nullptr));
// 注册4个任务,每个任务就是一个lambda
// 实际项目中这里可以是: 数据库查询、文件IO、加密解密、网络请求等
tasks.push_back([]()
{ std::cout << "sub process[" << getpid() << " ] 执行访问数据库的任务\n" << std::endl; });
tasks.push_back([]()
{ std::cout << "sub process[" << getpid() << " ] 执行url解析\n" << std::endl; });
tasks.push_back([]()
{ std::cout << "sub process[" << getpid() << " ] 执行加密任务\n" << std::endl; });
tasks.push_back([]()
{ std::cout << "sub process[" << getpid() << " ] 执行数据持久化任务\n" << std::endl; });
}
// 随机选择一个任务编号(0~3)
// Master调用此函数决定派发哪个任务
int SelectTask()
{
return rand() % tasks.size();
}
// 执行指定编号的任务
// Worker调用此函数: 从管道读到编号后,执行对应任务
void Excute(unsigned long number)
{
// 边界检查: 防止编号越界
if (number > tasks.size() || number < 0)
return;
// 执行任务: tasks[number]是一个lambda,加()调用它
tasks[number]();
}
~TaskManger()
{
}
private:
std::vector<task_t> tasks; // 任务列表
};
// 全局任务管理器对象
// 所有子进程fork后都会继承这个对象
TaskManger tm;
// Worker函数: 子进程的主循环
// 子进程通过dup2把管道读端重定向到了stdin(fd=0)
// 所以这里直接read(0, ...)就是从管道读数据
void Worker()
{
while (true)
{
int cmd = 0;
// 从stdin(实际上是管道读端)读取4字节(int的大小)
// 这里读到的就是Master通过Send()写入的任务编号
int n = ::read(0, &cmd, sizeof(cmd));
if (n == sizeof(cmd))
{
// 正常读到4字节: 执行对应任务
// cmd的值就是任务编号(0~3)
tm.Excute(cmd);
}
else if (n == 0)
{
// read返回0: 说明所有写端都关闭了(EOF)
// 这意味着Master让自己退出
// 触发条件: Master调用了Channel::Close()关闭了所有写端
std::cout << "pid: " << getpid() << " quit..." << std::endl;
break;
}
else
{
// read返回负数: 出错(一般不会走到这里)
}
}
}
Worker的执行流程:
循环 {
read(0, &cmd, 4) ← 从管道读4字节(任务编号)
│
├── 读到4字节 → Excute(cmd) → 执行任务 → 继续循环
├── 读到0字节 → 退出循环(EOF, Master让退出)
└── 读到负数 → 出错
}
ProcessPool.hpp — 进程池核心
这是整个项目最核心的部分,逐段详解。
#ifndef __PROCESS_POOL_HPP__
#define __PROCESS_POOL_HPP__
#include <iostream>
#include <string>
#include <vector>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <functional>
#include "Task.hpp"
#include "Channel.hpp"
using work_t = std::function<void()>; // Worker函数类型
// 错误码枚举
enum
{
OK = 0, // 成功
UsageError, // 用法错误
PipeError, // 管道创建失败
ForkError // fork失败
};
class ProcessPool
{
public:
// 构造: n=子进程数量, w=Worker函数(子进程要执行的工作函数)
ProcessPool(int n, work_t w)
: processnum(n), work(w)
{
}
// ========== 核心函数: 初始化进程池 ==========
// 流程: 循环n次, 每次创建一个管道 + fork一个子进程
int InitProcessPool()
{
for (int i = 0; i < processnum; i++)
{
// ---- 步骤1: 创建管道 ----
// 每个子进程对应一个独立的管道
// pipefd[0]=读端, pipefd[1]=写端
int pipefd[2] = {0};
int n = pipe(pipefd);
if (n < 0)
return PipeError;
// ---- 步骤2: fork子进程 ----
pid_t id = fork();
if (id < 0)
return ForkError;
// ---- 步骤3: 分别处理父子进程 ----
if (id == 0)
{
// ===== 子进程 =====
// 【关键】关闭所有"历史"管道写端
// 为什么? 因为每次循环都会创建新管道
// 假设这是第3次循环(i=2), 前面已经创建了pipe0和pipe1
// channels中已经有了Channel(pipe0写端)和Channel(pipe1写端)
// 当前子进程是从fork中复制出来的,也继承了pipe0和pipe1的写端
// 如果不关掉,这些管道永远不会收到EOF
// 因为还有进程(当前子进程)持有这些管道的写端!
std::cout << getpid() << ", child close history fd: ";
for (auto &c : channels)
{
std::cout << c.wFd() << " ";
c.Close(); // 关闭历史写端
}
std::cout << " over" << std::endl;
// 关闭当前管道的写端(子进程只需要读)
::close(pipefd[1]);
// 【核心】把管道读端重定向到stdin(fd=0)
// 这样Worker函数中read(0, ...)就变成了从管道读数据
// 子进程不需要知道管道fd是多少,统一从stdin读即可
std::cout << "debug: " << pipefd[0] << std::endl;
dup2(pipefd[0], 0);
// 调用Worker函数(即Task.hpp中的Worker())
// Worker会循环read(0, ...)获取任务并执行
// 直到read返回0(所有写端关闭)才退出
work();
// Worker退出后,子进程结束
::exit(0);
}
// ===== 父进程 =====
// 关闭读端(父进程只需要写)
::close(pipefd[0]);
// 记录这条信道: 写端fd + 子进程PID
// channels是vector<Channel>,存储了所有通信信道
channels.emplace_back(pipefd[1], id);
}
return OK;
}
// ========== 派发任务 ==========
void DispatchTask()
{
int who = 0; // 轮询索引: 决定下一个任务发给谁
// 派发20个任务
int num = 20;
while (num--)
{
// a. 随机选择一个任务编号(0~3)
int task = tm.SelectTask();
// b. 轮询选择一个子进程
// who从0开始,每次+1,取模后循环
// 例如3个子进程: 0→1→2→0→1→2→...
Channel &curr = channels[who++];
who %= channels.size();
// 打印日志
std::cout << "#######################" << std::endl;
std::cout << "send " << task << " to " << curr.Name()
<< ", 任务还剩: " << num << std::endl;
std::cout << "#######################" << std::endl;
// c. 派发任务: 通过管道写入任务编号(4字节int)
// 子进程的Worker()会从stdin读到这个编号并执行
curr.Send(task);
// 每次派发后sleep 1秒,方便观察输出
sleep(1);
}
}
// ========== 清理进程池 ==========
void CleanProcessPool()
{
for (auto &c : channels)
{
// 步骤1: 关闭写端
// 所有写端关闭后,子进程的read()返回0(EOF)
// 子进程收到EOF后退出Worker循环,调用exit(0)
c.Close();
// 步骤2: 回收子进程
// waitpid阻塞等待对应子进程退出
// 避免产生僵尸进程
pid_t rid = ::waitpid(c.Id(), nullptr, 0);
if (rid > 0)
{
std::cout << "child " << rid << " wait ... success" << std::endl;
}
}
}
// 调试打印所有信道信息
void DebugPrint()
{
for (auto &c : channels)
{
std::cout << c.Name() << std::endl;
}
}
private:
std::vector<Channel> channels; // 所有通信信道
int processnum; // 子进程数量
work_t work; // Worker函数
TaskManger tm; // 任务管理器
};
#endif
Main.cc — 入口
#include "ProcessPool.hpp"
#include "Task.hpp"
// 使用说明
void Usage(std::string proc)
{
std::cout << "Usage: " << proc << " process-num" << std::endl;
}
// Master进程入口
// 命令行用法: ./processpool 3 (创建3个子进程的进程池)
int main(int argc, char *argv[])
{
// 参数检查: 必须传入子进程数量
if (argc != 2)
{
Usage(argv[0]);
return UsageError;
}
// 解析子进程数量
int num = std::stoi(argv[1]);
// 创建进程池对象
// 参数1: 子进程数量
// 参数2: Worker函数(子进程要执行的工作)
ProcessPool *pp = new ProcessPool(num, Worker);
// 1. 初始化: 创建管道 + fork子进程
pp->InitProcessPool();
// 2. 派发任务: 往管道写入任务编号
pp->DispatchTask();
// 3. 清理: 关闭管道 + 回收子进程
pp->CleanProcessPool();
delete pp;
return 0;
}
Makefile
BIN=processpool # 最终生成的可执行文件名
CC=g++ # 编译器
FLAGS=-c -Wall -std=c++11 # 编译选项: -c只编译不链接, -Wall开警告, -std=c++11
LDFLAGS=-o # 铞接选项
SRC=$(wildcard *.cc) # 所有.cc源文件列表
OBJ=$(SRC:.cc=.o) # 把.cc替换成.o,得到目标文件列表
# 链接: 所有.o文件链接成可执行文件
$(BIN):$(OBJ)
$(CC) $(LDFLAGS) $@ $^
# 编译: 每个.cc编译成对应的.o
%.o:%.cc
$(CC) $(FLAGS) $<
# 清理
.PHONY:clean
clean:
rm -f $(BIN) $(OBJ)
# 调试: 打印变量值
.PHONY:test
test:
@echo $(SRC)
@echo $(OBJ)
整体运行流程图
./processpool 3
main()
│
├── InitProcessPool()
│ │
│ ├── i=0: pipe() → fork() → 子进程0启动Worker()
│ │ 父进程: close(读), channels=[Channel(写端0, pid0)]
│ │
│ ├── i=1: pipe() → fork() → 子进程1启动Worker()
│ │ 子进程1: 关闭写端0(历史), 重定向读端到stdin
│ │ 父进程: close(读), channels=[Ch0, Ch1]
│ │
│ └── i=2: pipe() → fork() → 子进程2启动Worker()
│ 子进程2: 关闭写端0和1(历史), 重定向读端到stdin
│ 父进程: close(读), channels=[Ch0, Ch1, Ch2]
│
├── DispatchTask()
│ │
│ └── 循环20次:
│ 任务编号 = SelectTask() // 随机0~3
│ 目标 = channels[who++] // 轮询 0→1→2→0→1→2...
│ 目标.Send(任务编号) // write(写端, &cmd, 4)
│ sleep(1)
│
└── CleanProcessPool()
│
└── 遍历channels:
Close() // 关闭写端
waitpid(pid, NULL, 0) // 等子进程退出并回收
子进程侧(Worker):
while(true) {
n = read(0, &cmd, 4) // 从管道(已被dup2到stdin)读任务编号
if (n == 4) Excute(cmd) // 执行任务
if (n == 0) break // EOF → 退出
}
exit(0)
关键设计思想总结
1. 为什么用 dup2 重定向到stdin?
dup2(pipefd[0], 0); // 管道读端 → stdin
这样Worker函数不需要知道管道fd是多少,统一用 read(0, ...) 即可。解耦了通信方式和业务逻辑——Worker函数完全不知道自己是通过管道接收任务的,它只管从stdin读。
2. 为什么子进程要关闭历史写端?
假设创建3个子进程,循环到i=2(创建第3个)时:
此时channels中有:
Ch0: pipe0写端
Ch1: pipe1写端
当前fork出来的子进程2,继承了:
pipe0读端 + pipe0写端 (来自i=0时的pipe)
pipe1读端 + pipe1写端 (来自i=1时的pipe)
pipe2读端 + pipe2写端 (来自当前的pipe)
如果子进程2不关闭pipe0写端和pipe1写端:
→ pipe0永远不会收到EOF(因为子进程2还持有写端)
→ 子进程0的Worker永远阻塞在read(),不会退出!
3. 为什么CleanProcessPool要先Close再waitpid?
Close(): 关闭写端 → 子进程read返回0 → 子进程退出
waitpid(): 回收子进程,避免僵尸进程
如果反过来(先waitpid再Close):
waitpid会阻塞,但子进程还在等read(),没人给它写数据
→ 子进程永远不退出
→ waitpid永远阻塞
→ 死锁!


&spm=1001.2101.3001.5002&articleId=161186563&d=1&t=3&u=12b3936d04c640e18068af6e65bb0c21)

被折叠的 条评论
为什么被折叠?



