MIT 6.S081 util 实验篇(lab1):primes (moderate/hard)

primes (moderate/hard)


实验目标

primes 是 util 实验里真正把 pipe + fork 组合成一条"进程流水线" 的一关,难度比 pingpong 高一档。它要我们用并发的方式实现「素数筛」------这是 Unix 管道发明者 Doug McIlroy 提出的经典并发思想:不靠任何共享内存,只用"一串进程 + 一串管道",就把 2~35 里的素数全部筛出来。

具体要做的事:

  • 用 pipe + fork 搭起一条进程流水线 :第一个进程把 2~35 依次喂进流水线。
  • 每遇到一个素数,就新建一个进程,它从左邻居的管道读、向右邻居的另一个管道写。
  • 每个进程的规则都一样:从左边读到的第一个数一定是素数,打印它;然后把后面所有「不是它倍数」的数传给右邻居。
  • 因为 xv6 的文件描述符和进程数量都有限,流水线喂到 35 就可以停。

官方 lab 里给的正确输出如下(prime 2 到 prime 31,共 11 个素数):

复制代码
$ primes
prime 2
prime 3
prime 5
prime 7
prime 11
prime 13
prime 17
prime 19
prime 23
prime 29
prime 31
$

前置知识

1. 进程与 fork 基础(本关多了一个「递归 + 进程链」)

pingpong 里 fork() 只 fork 一次、父子各干各的;primes 里每个进程都会再 fork 一个右邻居 ,形成一条不断变长的进程链 。但底层规则没变:fork 之后子进程拿到父进程文件描述符表的副本(指向同一批内核文件对象),所以父子能看到同一条管道------这正是"左邻居能写、右邻居能读"的根基。

2. 管道(pipe)是什么------结合 xv6 手册

xv6 handout(Chapter 1: Pipes)

A pipe is a small kernel buffer that is exposed to processes as a pair of file descriptors, one for reading and one for writing.

pipe§ creates a new pipe, records the read and write file descriptors in p0 and p1, and returns 0 or -1.

After fork(), the parent and the child have file descriptors referring to the same pipe.

If no data is available, a read on a pipe waits for data to be written; ... if all file descriptors referring to the write end of a pipe are closed, a read returns end-of-file (0).

提炼成要点:

  • 管道 = 内核里一小块缓冲区 ,对进程暴露为一对 fd:p[0] 是读端 ,p[1] 是写端 。pipe(p) 把读/写 fd 分别填进 p[0]、p[1]。
  • 写入 p[1] 的数据可以从 p[0] 读出(FIFO 先进先出)。
  • fork 之后父子持有指向同一管道的 fd,因此能通过它通信------这是整条素数筛流水线能跑起来的前提。
  • 管道缓冲区大小有限(xv6 里 PIPESIZE = 512 字节),不过本实验只传几十个 4 字节 int,缓冲区不是瓶颈。

3. 为什么必须 close 不用的端(本关是「不关必死」)

"if all file descriptors referring to the write end of a pipe are closed, a read returns end-of-file (0)"

pingpong 只传 1 字节,不关写端也未必立刻死锁;但 primes 是多进程、多轮、依赖"数据结束"来收尾 的,任何一处忘了关掉用不到的写端,对端的 read 就永远等不到 EOF,整条流水线直接卡死。而且 xv6 的资源很紧:

  • 每个进程最多打开 16 个文件描述符 (kernel/param.h 里 NOFILE = 16);
  • 全系统最多 64 个进程 (NPROC = 64)、全系统最多 100 个打开的文件 (NFILE = 100)。

流水线每多一个进程都要占 fd,泄漏一个写端就可能在对端阻塞,泄漏多了还没筛到 35 就先把资源耗光了。所以官方 hint 第一条就是 "关掉你用不到的 fd"------本关里这不是"好习惯",而是"能不能跑完"的硬条件。

4. 本实验的通信模型------Doug McIlroy 的并发素数筛

官方 lab 里"the picture halfway down this page"指向 Russ Cox 的 Bell Labs and CSP Threads 一文,Hoare 把这种写法归功于 Unix 管道发明者 Doug McIlroy。用管道串起一条流水线,每个进程只做三件事:

复制代码
p = get a number from left neighbor
print p
loop:
    n = get a number from left neighbor
    if (p does not divide n)
        send n to right neighbor

对应的进程链画出来是这样:

复制代码
       2,3,4,5,...35          3,5,7,9,...35           5,7,11,13,...35
feeder ────────────────► 筛掉 2 的倍数 ─────────► 筛掉 3 的倍数 ─────────► ...
                         (prime 2)                (prime 3)                (prime 5)

一个生成进程把 2, 3, 4, ..., 1000 灌进流水线左端:第 1 个进程筛掉 2 的倍数、第 2 个筛掉 3 的倍数、第 3 个筛掉 5 的倍数,依此类推。

关键问题是:为什么"从左边读到的第一个数一定是素数"? 因为它已经"躲过"了前面所有进程的筛选------它既不是 2 的倍数、也不是 3 的倍数、也不是 5 的倍数......那它只能是一个新的素数。于是每个进程"读第一个数 → 打印 → 用它的倍数往下筛"就构成了完整的埃拉托斯特尼筛(Sieve of Eratosthenes)。

实现思路

顺着「建初始管道 → 递归地 fork 出进程链 → 每个进程筛一次 → 用 -1 当 EOF 哨兵 → 逐级 wait 收尾」的思路:

  1. main 建一条初始管道 input_pipe,fork 出一个子进程。
  2. 父进程 关掉读端,把 2~35 写进管道,最后写一个 -1 作为结束哨兵 ,再 wait(0) 等整条链结束。
  3. 子进程 关掉写端,进入 sieve(input_pipe) 递归。
  4. sieve(pleft) 里:
    • 从 pleft 读第一个数 p------它一定是素数,打印 prime p;
    • 若读到 -1,说明前面已经筛完,直接 exit(0);
    • 建新管道 pright,fork 右邻居:子进程关掉用不到的端、递归 sieve(pright);
    • 当前进程继续从 pleft 读后续数字,把「不是 p 的倍数」的数写进 pright;
    • 读完(收到 -1)后把 -1 也传给右邻居,然后 wait(0) 等子进程、exit(0)。

几个要点:

  • 用 -1 这个显式哨兵标记"数据结束" 。为什么需要它?因为每个进程在筛选期间必须保持写端打开 (边读边写),下游无法靠"所有写端关闭 → read 返回 0"来判断结束,所以用 -1 显式地在链上一级一级传下去。代码里 while (read(...) && buf != -1) 同时兼容了 read 返回 0(EOF)的情况,是双保险。
  • 直接写 4 字节 int,不做 ASCII 格式化 (官方 hint 也建议这样做),代码里 write(..., &i, sizeof(i)) 就是这么写的。
  • 每个进程都 wait 自己的子进程 :这样 main 进程只有在整条流水线(含孙进程、曾孙......)全部输出并退出之后才退出------这正是官方 hint 里 "wait until the entire pipeline terminates" 的要求。
  • 进程是"按需创建"的:不是一开始就建满一条链,而是每筛出一个素数才 fork 一个新的右邻居(hint:create the processes in the pipeline only as they are needed)。

代码实现

user/primes.c ------ 递归进程链 + 管道素数筛

c 复制代码
// primes.c

#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"

#define RD 0
#define WR 1

// 筛选质数的函数, 传入一个管道
void sieve(int pleft[2])
{
    // 传入 pleft 前,所在进程就已经关闭了管道 pleft 的写端, 后面无需重复关闭

    // 读入第一个数字: 从管道中读取的第一个数字一定是质数
    int p;
    read(pleft[RD], &p, sizeof(p));
    if (p == -1) { // 如果已经读到了末尾
        exit(0);
    }
    printf("prime %d\n", p); // 否则输出该质数

    // 创建一个新的管道, 向下一个进程传入筛选后的数字(递归调用)
    int pright[2];
    pipe(pright);

    if (fork() == 0) { // 右邻居(下一个进程)
        close(pright[WR]); // 该进程不会用到 prihgt 的写端
        close(pleft[RD]); // 该进程不会用到 pleft 的读端
        // close(pleft[WR]);   每次递归调用 sieve() 前,就已经 close 掉了

        sieve(pright); // 递归调用
        exit(0);
    } else { // 当前进程
        close(pright[RD]); // 当前进程不会用到 pright 的读端
        // 读入第一个数字之后的所有数字,
        // 并将不是第一个数字的倍数的数传给下一个进程
        int buf;
        while (read(pleft[RD], &buf, sizeof(buf)) && buf != -1) {
            if (buf % p != 0) {
                write(pright[WR], &buf, sizeof(buf));
            }
        }
        // 循环结束, buf = -1 , 将结束标记也传给下一个进程
        write(pright[WR], &buf, sizeof(buf));
        // 等待当前子进程的结束
        wait(0);
        exit(0);
    }

}

int main()
{
    // 创建初始管道
    int input_pipe[2];
    pipe(input_pipe);

    if (fork() == 0) { // 右邻居
        close(input_pipe[WR]); // 右邻居用不到该管道的写端
        sieve(input_pipe);
        exit(0);
    } else {
        close(input_pipe[RD]); // 当前进程用不到管道的读端
        // 向管道的写端传入数字
        int i;
        for (i = 2; i <= 35; ++i) {
            write(input_pipe[WR], &i, sizeof(i));
        }
        // 传入结束标记
        i = -1;
        write(input_pipe[WR], &i, sizeof(i));
        // 等待当前子进程的结束
        wait(0);
        exit(0);
    }
}

几点说明:

  • RD=0、WR=1 对应 pipe() 返回的读/写端下标,和 pingpong 里一样用宏让语义更清楚。
  • 递归 + fork 是本程序的核心结构 :sieve() 每被调用一次就"筛掉一个素数、再造一个右邻居",直到读到 -1 递归终止。整条进程链不是预先建好的,而是"筛出一个素数、长出下一段"。
  • read(pleft[RD], &p, sizeof(p)) 读第一个数 ,代码里没有检查返回值:因为左邻居要么会写数据、要么会写 -1 哨兵,read 一定能读到 4 字节,不会返回 0 或小于 4 的情况。
  • while (read(...) && buf != -1) 的循环条件是双保险 :既处理了显式 -1 哨兵,也处理了"写端全关、read 返回 0(EOF)"的情况,两者任一满足都会退出循环。
  • 每个进程 wait(0) 等自己的右邻居结束,配合最外层 main 的 wait(0),保证main 在整条链全部输出、全部退出后才退出。

Makefile ------ 把程序编进内核镜像

和 pingpong 一样,新写的用户程序需要在 Makefile 的 UPROGS 里注册:

makefile 复制代码
UPROGS=\
	$U/_cat\
	$U/_echo\
	...
	$U/_sleep\
	$U/_pingpong\
	$U/_primes\    # 添加 $U/_primes

加上这一行后,make qemu 才会把 primes 编进 fs.img,你才能在 xv6 shell 里运行它。

验证

方式一:手动在 xv6 里测
bash 复制代码
make clean && make qemu          # 启动 xv6

进入 shell 后执行:

bash 复制代码
primes

预期输出(顺序固定、从 prime 2 到 prime 31 共 11 个素数):

复制代码
prime 2
prime 3
prime 5
prime 7
prime 11
prime 13
prime 17
prime 19
prime 23
prime 29
prime 31

方式二:用评分脚本测

在 Linux 终端运行:

bash 复制代码
./grade-lab-util primes

应看到测试通过(末尾的 (Xs) 是耗时,视机器而定):

复制代码
== Test primes == primes: OK (2.9s)

评分脚本里的判定逻辑(grade-lab-util)就是匹配这 11 个 prime %d 输出:

python 复制代码
@test(20, "primes")
def test_primes():
    r.run_qemu(shell_script([
        'primes', 'echo OK'
    ]))
    args = ['prime %d' % i for i in [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31]]
    args.append('^OK$')
    r.match(*args)

复盘

本实验解决了什么

表面是"用并发筛素数",实则把 pingpong 里立起来的 IPC 骨架从"一条线"扩展成了"一棵链":

  • 第一次把 pipe + fork 组合成"进程流水线" ------理解了"每级进程 = 一个过滤器(filter)"的并发范式,这正是 Unix 哲学里 filter-and-pipeline 的原型。
  • 第一次用递归 + fork 动态构造进程拓扑------进程不是预先建好的,而是"筛出一个素数、再 fork 下一段",即官方 hint 说的 "create the processes only as they are needed"。
  • 第一次直面 EOF / 结束标记的多进程语义 ------pingpong 只传 1 字节可以"蒙混过关",primes 逼你搞清楚"数据到底什么时候算结束",于是引入了显式的 -1 哨兵。

为什么 close 是铁律(primes 是最典型的反例)

结合手册的 EOF 规则:只有当所有 写端 fd 都关闭,read 才会返回 0(EOF)。pingpong 里单次传 1 字节,不 close 未必立刻死锁;但 primes 是多进程 + 多轮 + 依赖结束信号收尾的:

  • 任何一个进程忘了关掉自己用不到的写端 ,它的下游 read 就永远等不到 EOF → 那个下游进程永远阻塞 → 它又 wait 着自己的子进程 → 整条链从中间开始冻住 ,main 进程的 wait() 也永远不返回,程序不死也不退。
  • 而且 xv6 的 NOFILE=16、NPROC=64、NFILE=100 都很紧,泄漏的 fd 会直接耗光资源 ,还没筛到 35 就先 filealloc 失败甚至 panic。所以官方 hint 把"关掉用不到的 fd"放在第一位。

把"用完即关"变成肌肉记忆,比事后用 gdb 追一条多进程死锁划算得多。

收获

  • 并发素数筛(Doug McIlroy / CSP):能用"进程链 + 管道"解释埃拉托斯特尼筛的并发版本,说清"每级进程读到的第一个数一定是素数"的原因;这也是 Go 语言 channel、CSP 并发模型的源头之一。
  • 递归 + fork 构造进程拓扑:能写出"每个进程 fork 一个子进程、递归下去"的代码,并理解它与"一次性 fork 一批子进程"的区别(按需创建,省资源)。
  • EOF vs 显式哨兵 :理解 read 返回 0(所有写端关闭)和"自己定义一个 -1 结束标记"是两套不同的结束机制;当进程必须边读边写、不能提前关写端时,就得靠显式哨兵。
  • wait 的级联回收 :每个进程 wait 自己的直接子进程,逐级向上,最终 main 等完整条链结束后才退出------这正是官方 hint 强调的 "wait until the entire pipeline terminates"。
相关推荐
硅基手札2 小时前
【Linux 内核专栏 13】快IO层
linux·服务器·嵌入式硬件
嵌入式分享2 小时前
嵌入式分享#63:RK3588 怎么选?全系列选型指南
linux
Pointer Pursuit2 小时前
Linux调试器 ——gdb/cgdb
linux·运维·服务器
chenlance2 小时前
PADS灌铜、覆铜过程和技巧小汇总
linux·服务器·网络
HideF3 小时前
【无标题】
linux
高山有多高3 小时前
【Linux笔记】NAT与内网穿透
linux
迷途之人不知返3 小时前
自主实现一个简单的shell命令行
linux
顺风尿一寸4 小时前
深入 Linux 6.18 挂载机制:从 VFS 到 ext4 的完整调用链
linux
她说彩礼65万4 小时前
C语言 assert
java·linux·c语言