跳转至

CH5 补充读物:IPC(从 pingpong 读懂 pipe

1. 这份读物怎么用

这份文档是给你课后独立看进程代码用的,不是课堂提纲。

读完后,你应该能回答下面 8 个问题:

  1. 当前仓库真正实现了哪一种 IPC?
  2. pingpong 为什么是读 CH5 最合适的起点?
  3. pipe() 创建出来的到底是什么内核对象?
  4. 为什么 fork() 对 ordinary pipe 特别关键?
  5. struct pipe 如何体现 bounded buffer?
  6. 缓冲区空或满时,当前代码如何阻塞与唤醒?
  7. 当前 pipe 在 naming、synchronization、buffering 三个维度上分别是什么语义?
  8. 哪些 PPT 内容在本仓库里还没有对应实现?

2. 从一个真实程序开始:先运行 pingpong

建议你先在系统里实际运行一次:

pingpong

然后按这条路径阅读代码:

  1. user/pingpong.c:父子进程如何创建两条管道并互相发消息。
  2. user/syscall.cpipe/read/write/fork/close/wait 如何触发 ecall
  3. kernel/core/syscall.csys_pipesys_readsys_write 如何进入内核对象层。
  4. kernel/core/file.c:通用文件接口如何把 read/write/close 转发给 pipe。
  5. kernel/core/pipe.c:FIFO 缓冲区、空满判断和阻塞唤醒的真正实现。
  6. kernel/core/proc.csleep() / wakeup() 如何支撑阻塞式 IPC。

如果你能把这 6 步串起来,CH5 在当前仓库里的主线就已经抓住了。

3. 先把边界说清:当前仓库实现了哪种 IPC

在这套代码里,最值得看的 IPC 机制是:ordinary pipe(匿名管道)

也就是说:

  1. 已实现并且有用户程序样例的,是 pipe(int fd[2])
  2. 最合适的观察样本是 user/pingpong.c
  3. 当前没有 POSIX shared memory、mmap 风格共享映射、FIFO 或 socket() 用户接口。
  4. 当前 user/sh.c 只支持普通命令执行,不支持解析 | 这样的 shell 管道语法。

这一章的正确期待

读 CH5 时,不要试图在当前仓库里把所有 IPC 机制都找齐。更准确的理解是:课程代码提供了一个非常典型、非常适合教学的 IPC 样本——ordinary pipe;你需要借它把 PPT 中的 bounded buffer、blocking communication 和 producer-consumer 真正看懂。

4. cooperating processes 在当前代码里最直观的样子

PPT 里说 cooperating processes 是“会互相交换数据、互相影响推进”的执行实体。

在当前仓库中,最直观的例子就是 pingpong

  1. 父进程先调用 pipe() 创建通信通道。
  2. 再调用 fork() 创建子进程。
  3. 父子进程分别关闭自己不用的端口。
  4. 一侧 write(),另一侧 read()
  5. 双方通过同一个内核 pipe 对象交换数据并同步推进。

这就是 cooperating processes 在课程代码里的最小闭环。

5. pipe() 是怎么从系统调用变成内核对象的

从用户态看,接口很简单:

int fd[2];
pipe(fd);

但内核里实际上发生了下面几步:

  1. user/syscall.c:pipe() 把系统调用号放进寄存器后执行 ecall
  2. kernel/core/syscall.c:sys_pipe() 调用 pipealloc(&rf, &wf) 创建读端和写端。
  3. sys_pipe() 再通过 fdalloc() 把这两个 struct file * 挂到当前进程的 ofile[] 表中。
  4. 最后通过 copyout() 把两个文件描述符编号写回用户态数组。

因此,用户态拿到的 fd[0] / fd[1] 只是编号;真正的通信对象已经在内核里创建好了。

6. 为什么 fork() 对 ordinary pipe 特别关键

ordinary pipe 最自然的使用场景通常是 related processes,尤其是 parent-child。当前代码里这个结论是可以直接从实现读出来的。

关键点在 kernel/core/proc.c:fork()

  1. 子进程会复制父进程的 ofile[]
  2. 复制方式不是新建一套 pipe,而是对现有 struct file 调用 filedup() 增加引用计数。
  3. 这两个 struct file 又都指向同一个 struct pipe

这意味着:

  1. 父子进程天然共享同一对 pipe 端点。
  2. 之后只要各自关闭不用的一端,就能形成清晰的单向通信路径。

为什么 pingpong 要用两条 pipe

一条 ordinary pipe 在当前实现里是单向的:fd[0] 读,fd[1] 写。因此 pingpong 要完成“去一次、回一次”的双向通信,就必须建立两条 pipe。

7. struct pipe:bounded buffer 在代码里的真实形态

PPT 里的 bounded buffer 在当前代码里并不是抽象图,而是 kernel/core/pipe.c 里的这个对象:

  1. data[PIPESIZE]:真正的数据缓冲区。
  2. nread:已经读到哪里。
  3. nwrite:已经写到哪里。
  4. readopen / writeopen:读端和写端是否还开着。
  5. lock:保护共享状态的自旋锁。

PIPESIZE 在当前实现里被定义为:

#define PIPESIZE 512

这就是 CH5 里“有限容量缓冲区”的代码落点。

8. Producer-Consumer 在当前代码里到底怎么跑

如果把 pipe 看成 producer-consumer 模型,那么:

  1. pipewrite() 是 producer。
  2. piperead() 是 consumer。
  3. struct pipe 就是双方共享的有限 FIFO 缓冲区。

8.1 缓冲区满时,写者怎么办

pipewrite() 的核心判断是:

  1. 如果 pi->nwrite == pi->nread + PIPESIZE,说明缓冲区满了。
  2. 此时写者不会 busy waiting。
  3. 它会先 wakeup(&pi->nread) 提醒可能的读者,再 sleep(&pi->nwrite, &pi->lock) 进入阻塞。

8.2 缓冲区空时,读者怎么办

piperead() 的核心判断是:

  1. pi->nread == pi->nwritewriteopen 仍为真时,说明“暂时没数据,但写端还活着”。
  2. 此时读者也不会空转。
  3. 它会 sleep(&pi->nread, &pi->lock) 等待新数据到来。

8.3 这正是 PPT 里“不要忙等”的代码版本

因此,PPT 里的 bounded buffer 在当前仓库里已经不是伪代码,而是完整可运行逻辑:

  1. 满了就让 producer 睡眠。
  2. 空了就让 consumer 睡眠。
  3. 条件变化后,对端负责 wakeup()

9. pipe 在 naming、synchronization、buffering 三个维度上的语义

把当前实现放回 PPT 的分析框架,可以得到很清楚的结论:

9.1 Naming

当前 pipe 不是 mailbox,也不是 FIFO 文件名。

它更接近:

  1. 进程通过文件描述符持有通信端点。
  2. 这些端点通常经由 fork() 继承给 related processes。
  3. 因此它没有一个全局名字供任意进程随时打开。

9.2 Synchronization

当前 pipe 具有明显的 blocking 语义:

  1. 读端可能因为“缓冲区空”而睡眠。
  2. 写端可能因为“缓冲区满”而睡眠。
  3. 所以它不是简单的 non-blocking message queue。

9.3 Buffering

当前 pipe 是典型的 bounded buffering:

  1. 底层 FIFO 长度有限。
  2. 容量上限由 PIPESIZE 决定。
  3. 缓冲区满时,发送方必须等待。

10. close() 为什么也是 IPC 语义的一部分

很多同学读 CH5 时只看 read/write,忽略 close,这是不够的。

在当前代码里:

  1. fileclose() 会在 FD_PIPE 情况下转发到 pipeclose()
  2. pipeclose() 会更新 readopenwriteopen
  3. 同时会 wakeup() 对端,避免对方永远睡下去。

这会带来两个非常重要的语义:

  1. 如果写端都关了,而且缓冲区也读空了,piperead() 会返回 0,这就是 EOF 风格语义。
  2. 如果读端已经关了,pipewrite() 会看到 readopen == 0,从而返回错误。

所以,普通的“关闭文件描述符”在 pipe 场景下其实也是通信协议的一部分。

11. Shared Memory 和 Message Passing 在当前仓库里怎么区分

PPT 把 IPC 分成 shared memory 和 message passing 两大类。放到当前仓库里,最容易得出的结论是:

  1. 当前课程代码主要落在 message passing 这一侧,因为用户态通过 read/write 面向“通道”交换数据。
  2. 但从内核实现视角看,pipe 又确实是一块被锁保护的共享 FIFO 对象。

两个视角都对

从接口层看,pipe 是消息传递;从实现层看,pipe 又是共享对象上的受控读写。真正重要的不是二选一,而是你要分清自己此刻讨论的是“抽象 API”还是“内核内部实现”。

12. 当前代码没有实现哪些 CH5 机制

读完 pipe.c 之后,请明确边界,不要过度延伸:

  1. 当前没有 POSIX shared memory 的 shm_open() / mmap() 路径。
  2. 当前没有 named pipe(FIFO)对应的用户接口。
  3. 当前没有网络协议栈与 socket() 接口。
  4. 当前 shell 也没有把 | 解析成管道命令。

所以 CH5 的正确阅读姿势是:

  1. pipe 把 IPC 的基础模型看懂。
  2. 用 PPT 建立更完整的 IPC 地图。
  3. 始终区分“课程代码已经实现了什么”和“理论课还介绍了哪些更一般机制”。

13. 建议的代码阅读主线

按下面顺序读,会最顺:

  1. user/pingpong.c
  2. user/syscall.c
  3. kernel/core/syscall.c 中的 sys_pipesys_readsys_write
  4. kernel/core/file.c
  5. kernel/core/pipe.c
  6. kernel/core/proc.c 中的 sleep()wakeup()

这条线能把“用户程序里的两个 fd”一路追到“内核里的 bounded buffer 和阻塞唤醒机制”。

14. 最常见误解

  1. 以为 CH5 在当前仓库里应该能直接看到 shared memory、FIFO 和 socket 的完整实现。
  2. 以为 shell 已经支持 cmd1 | cmd2,于是去 user/sh.c 里找不存在的解析逻辑。
  3. 以为 ordinary pipe 是天然双向的,忽略了当前实现其实是一读一写的单向通道。
  4. 以为 IPC 只是“把数据送过去”,忽略了空/满条件、阻塞语义和关闭语义。
  5. sleep/wakeup 当成某种用户态库函数,而不是内核里的阻塞协调机制。

15. 最小自测(仅自检)

你可以问自己这 8 个问题:

  1. 当前仓库里最适合读 IPC 的用户程序为什么是 pingpong
  2. sys_pipe() 做了哪几件关键事情?
  3. fork() 为什么会让 ordinary pipe 天然适合 parent-child?
  4. struct pipe 里哪几个字段最能体现 bounded buffer?
  5. pipewrite() 在缓冲区满时为什么不 busy waiting?
  6. piperead() 什么时候会返回 0
  7. 当前 pipe 在 naming、synchronization、buffering 三个维度上的语义分别是什么?
  8. 当前仓库里有哪些 CH5 机制还没有实现?

如果这些问题都能结合源码回答,CH5 的主线就真正掌握了。