管道的实现
概要:介绍管道(pipe)作为进程间通信机制的内核实现:pipe 创建读写两端、fork 后父子进程共享同一内核缓冲区、子进程将读端接到 stdin 的关键操作,以及 read/write 的阻塞行为。
管道(pipe)是 UNIX/Linux 中最经典、最基础的进程间通信(IPC, Inter-Process Communication)机制之一。
1 示例
echo "hello world" | wc
实际上的实现如下:
int p[2];
char *argv[2];
argv[0] = "wc";
argv[1] = 0;
pipe(p);
if(fork() == 0) {
close(0);
dup(p[0]);
close(p[0]);
close(p[1]);
exec("/bin/wc", argv);
} else {
write(p[1], "hello world\n", 12);
close(p[0]);
close(p[1]);
}
2 管道的本质:内核中的一个缓冲区
当调用:
int p[2];
pipe(p);
内核会创建:
内核空间
+----------------+
| pipe buffer |
+----------------+
^ ^
p[1] p[0]
写端 读端
返回的两个文件描述符:
| 描述符 | 含义 |
|---|---|
p[0] |
读端 |
p[1] |
写端 |
这和普通文件描述符一样,本质是进程文件描述符表中的两个入口,只是它们指向的是同一个内核 pipe 对象。
3 fork 之后为什么父子进程都能用管道
pipe(p);
fork();
fork 会复制文件描述符表:
父进程 fd表: 子进程 fd表:
p[0] -> pipe p[0] -> pipe
p[1] -> pipe p[1] -> pipe
注意: 复制的是描述符,不是复制管道本身 所以父子进程是共享同一个内核缓冲区的。
4 子进程做了什么:把管道接到 stdin
子进程部分:
close(0); // 关闭标准输入
dup(p[0]); // 把管道读端复制为 fd=0
dup 总是返回当前最小可用描述符,所以:
fd 0 -> pipe read end
此时子进程的 fd 状态:
0 -> pipe read
1 -> terminal
2 -> terminal
这意味着:
wc 从 stdin 读取
↓
实际是从管道读取
5 为什么子进程必须关闭写端
close(p[1]);
这是管道编程中最容易写错的地方。
原因是: read 只有在所有写端关闭后才会返回 EOF
如果子进程不关闭写端:
父进程: write end open
子进程: write end 也 open
那么:
wc.read()
↓
内核认为:还有写端存在
↓
read 阻塞等待更多数据
于是 wc 永远等不到 EOF,程序卡住
6 父进程行为:写入然后关闭
write(p[1], "hello world\n", 12);
close(p[1]);
这一步非常关键:
写数据
↓
关闭写端
↓
内核发送 EOF
↓
wc 结束读取
7 管道的阻塞行为总结
管道是同步通信机制,有两种阻塞:
7.1 read 阻塞
read(p[0], buf, 100);
如果:
缓冲区为空 AND 写端仍然存在
read 会:
睡眠等待
7.2 write 阻塞
如果 pipe buffer 满了(默认约 64KB):
write(p[1], big_data);
写进程会阻塞,直到:
读进程消费数据
8 关键问题
8.1 子进程关闭写端,父进程的写端会不会一起关闭?
不会。
因为 fork() 之后:
父进程 fd 表 子进程 fd 表
------------------ ------------------
p[0] -> pipe inode p[0] -> pipe inode
p[1] -> pipe inode p[1] -> pipe inode
这里有一个重要概念:
文件描述符是进程私有的,但它们指向的内核对象是共享的
也就是说:
close(p[1]) // 只关闭当前进程的描述符表项
不会影响另一个进程的 fd 表。
可以类比为:
两个指针指向同一个对象
free(一个指针) ≠ 销毁对象
真正的销毁条件是:
最后一个引用关闭
内核里 pipe 的结构有引用计数:
pipe->readers
pipe->writers
只有当:
writers == 0
read 才会返回 EOF。
8.2 父子进程同时运行,会不会出现问题
- fork 之后
pipe buffer
/ \
父 p[1] 子 p[1]
考虑最极端情况:
- 情况1:父进程先写
父进程: write -> pipe buffer
父进程: close write end
子进程: 之后才 exec wc
结果:数据已经在 pipe 中,wc 依然能读到。
- 情况2:子进程先执行
子进程: exec wc
wc: read() -> 阻塞等待
父进程: write()
结果:wc 被唤醒,读到数据。
留下评论