1 分钟阅读 次阅读

概要:介绍管道(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 被唤醒,读到数据。

留下评论