Skip to content

第三章:进程模型

操作系统管理的是程序的一次执行实例,而非静态 ELF 文件。两次执行同一文件会获得不同的 PID、地址空间和文件描述符表。进程包含一次执行所拥有的资源和身份;线程是其中可独立调度的执行流。

本章暂时不拆页表,先回答两个更高层的问题:程序如何被创建?彼此隔离的程序又如何组合起来完成工作?

进程状态

“正在运行的程序”不是严格定义,因为进程可能长期不占用 CPU。内核为每个进程维护以下状态与对象关系:

  • 它有哪些虚拟内存映射;
  • 持有哪些文件、socket、pipe 等对象;
  • 以什么身份运行,受哪些资源限制;
  • 如何处理信号,父进程是谁;
  • 包含哪些线程,它们正在运行、就绪还是等待。

程序代码只是其中一项资源,而且常被多个进程共享。/bin/ls 的只读代码页不必为每次执行复制一份。

教科书常说“进程是资源分配单位,线程是调度单位”。它适合建立第一印象,但现代内核允许地址空间、FD 表等资源被选择性共享,边界没有这句话那么整齐。更可靠的判断方法是:具体在共享什么状态,谁拥有独立生命周期?

在 xv6 中,这个抽象直接落到 struct procstate 表示 UNUSED/USED/SLEEPING/RUNNABLE/RUNNING/ZOMBIEpagetablesz 描述用户地址空间,trapframe 保存用户现场,context 保存内核调度现场,ofile[] 是打开文件表,cwd 是当前目录。读者可以在一个结构体中看到“一次执行实例”究竟由哪些状态组成。

allocproc() 从全局 proc[] 中取得 UNUSED 槽,分配 PID、trapframe 和用户页表,并为该槽预留内核栈。RISC-V 硬件没有“创建进程”指令;进程是 xv6 用这些内存对象组织出来的内核抽象。

xv6 的每个进程只有一条用户执行流,所以资源容器和调度单位都落在同一个 struct proc。Linux 支持同一地址空间中的多线程后,才需要进一步区分线程自身状态和线程组共享状态。

Linux 扩展

在 Linux 中,这个问题可以直接落到 struct task_struct:每条线程有一个 task_struct,其中通过 mm 指向地址空间、通过 files 指向 FD 表、通过 cred 指向凭证。clone() 的 flags 决定新线程与调用者共享哪些指针。所谓“进程”不是一个额外的硬件对象,而是这些内核对象按特定方式共享后形成的关系。

进程复制

fork() 创建一个几乎相同的子进程。它最特别的地方是一次调用产生两个返回:父进程得到子 PID,子进程得到 0。两者从同一代码位置继续,却已经有独立的进程身份。

在当前 xv6 的内核实现 kfork() 中,allocproc() 先建立子进程,uvmcopy() 为每个用户页分配新物理页并复制内容,父进程的 trapframe 被复制后把子进程的 a0 改成 0。filedup()idup() 增加打开文件与当前目录的引用计数,最后把子进程置为 RUNNABLE

因此,当前 xv6 的地址空间确实会在 fork() 时逐页复制。第四章将以它为基线,再通过 COW lab 说明怎样把复制推迟到首次写入。

Linux 扩展

Linux 的具体入口位于 kernel/fork.cfork() 最终进入 kernel_clone(),核心构造工作由 copy_process() 完成。普通 fork() 通过写时复制暂时共享物理页;FD table 被复制,但条目仍引用相同的 open file description。clone() flags 还可选择共享 mm/files/sighand 等对象。

多线程程序中,其他线程可能在 fork() 时持有用户态锁,而这些线程不会出现在子进程中。子进程再次获取相应锁将永久等待。因此,fork 到 exec 之间只能执行一组受限的安全操作;posix_spawn() 通常更适合复杂多线程进程。

程序替换

execve() 不创建新进程。它丢弃当前地址空间,按照 ELF 建立新映像,重置一部分进程属性,然后从新入口开始。PID 不变,默认情况下打开的 FD 也仍在。

这正是 shell 需要的接缝:在 fork()execve() 之间,子进程仍会执行 shell 的代码,因此可以先把 FD 接好、调整工作目录和资源限制;execve() 之后,新程序自然继承这套环境。

成功的 execve() 不返回。失败时旧程序仍在,得到错误码。这种“先完全准备,成功后一次替换”的事务式接口,避免进程处于半个旧程序、半个新程序的状态。

在当前 xv6 的内核实现 kexec() 中,内核先创建一张新页表,再逐个读取 ELF program header,以 uvmalloc() 建立地址、loadseg() 从 inode 读入 segment,随后建立用户栈并复制 argv。只有全部成功后,才把 p->pagetablep->sztrapframe->epcsp 换成新值,再释放旧页表。失败路径则释放尚未提交的新页表。

Linux 扩展

Linux 中可从 do_execveat_common() 继续追踪到 search_binary_handler();ELF 文件由 load_elf_binary() 解析。内核读取 ELF program header,建立新的 mm 和 VMA、布置用户栈、设置入口 RIP,最后在返回用户态时使用新的寄存器现场。execve() 的“替换”就是这些可见状态被换成新映像,而不是 CPU 执行了一条叫 exec 的硬件指令。

文件描述符表

文件描述符只是当前进程 FD 表的索引。条目指向一个内核对象,记录“允许通过哪个接口操作谁”。普通文件、目录、pipe、socket、终端、事件通知乃至某些设备都能以 FD 出现。

以普通文件为例,至少有两层容易混淆:

text
进程 FD 表                  系统级对象

FD 3 ───────────────→ 打开的文件描述 ─────→ inode
                       偏移 = 120            文件身份与元数据
                       O_APPEND 等标志

dup(3) 产生另一个 FD,指向同一个打开描述,所以共享偏移;重新 open() 同一路径一般产生新的打开描述,偏移独立。fork() 复制 FD 表后,父子也会共享这些打开描述。

FD 具有 capability 语义:内核完成路径解析和权限检查后,进程获得指向具体对象的引用。服务可以通过 Unix domain socket 将 FD 传递给另一个进程,从而委托对该对象的访问,而不授予更广泛的目录查找权限。

在 xv6 中,整数 FD 直接索引 proc.ofile[NOFILE],槽中保存 struct file *struct file 再记录类型、引用计数、可读写标志、pipe 或 inode 指针和当前 offset。fdalloc() 只是寻找一个空槽;argfd() 则在系统调用入口把用户整数解析回 struct file *

Linux 扩展

Linux 的 task_struct->files 指向 struct files_struct,其中 fdtable.fd[n] 才指向 struct file。整数 3 没有全局意义;它只表示“在当前 files_struct 的第 3 个槽中查找”。这也直接解释了为什么关闭一个进程的 FD 3 不会关闭另一个无关进程的 FD 3。

管道

pipe() 创建读端和写端。xv6 的实现位于 pipe.cstruct pipe 包含固定大小字节数组、读写计数和两端是否仍打开。写入的字节暂存在这块有界缓冲区,读端按序取得:

text
producer --write--> [ kernel pipe buffer ] --read--> consumer

缓冲区空时,阻塞式 read() 等待;缓冲区满时,write() 也可能等待。于是管道不仅搬运数据,还施加背压:消费者跟不上时,生产者不能无限制造内存占用。

EOF 不是特殊数据字节。管道缓冲区为空且系统中所有写端 FD 均已关闭时,读端返回 EOF。若 shell 或后代进程仍保留写端,读者将继续等待。因此,FD 引用生命周期属于管道协议语义的一部分。

pipewrite() 在环形缓冲区已满时 sleeppiperead() 在为空且仍有 writer 时 sleeppipeclose() 在最后一个写端消失后唤醒 reader。管道的阻塞、背压和 EOF 都落在这三个状态转换上。

流水线

对于 xv6 shell 中的:

bash
cat input.txt | worker

xv6 的 user/sh.c 对 pipe command 的关键动作可以抽象成:

text
pipe() → (r, w)

fork left child:
    close(1)
    dup(w)
    close(r, w)
    exec(left)

fork right child:
    close(0)
    dup(r)
    close(r, w)
    exec(right)

parent:
    close(r, w)
    wait for children

xv6 的 dup() 总是返回最低可用 FD,所以先关闭 FD 0 或 1,就能把 pipe 端点安到标准输入或输出。Linux 的 dup2(old, target) 把这两步合成一次原子操作,避免多线程或 signal handler 在 close 与 dup 之间抢占目标槽。两者都只修改 FD 表引用,不复制 pipe 数据。

Linux 线程

一个进程可有多条线程。它们共享地址空间、堆和 FD,因而传数据便宜;各自保有寄存器、用户栈、内核栈和调度状态。

共享地址空间意味着错误也共享:一个线程写坏指针可以破坏整个进程,任一线程调用进程级退出都会终止伙伴。线程提供并发执行,不提供安全隔离。浏览器将不可信站点放进不同进程,而在每个进程内部再用线程并行,正是在隔离成本与协作成本之间折中。

线程退出时,其他线程仍可能持有指向其栈的指针。进程退出时,内核关闭 FD、释放映射,并保留退出状态供父进程 wait();zombie 表示尚未被父进程回收的退出记录,不再具有可执行线程。

Linux 信号

信号让内核通知进程发生了某类事件,例如非法访存、终端中断或子进程退出。它不是普通函数调用:信号可能在用户代码的任意指令边界被递送,处理器要暂时转去运行用户注册的 handler,之后再恢复现场。

信号 handler 可能打断正在持锁的库函数;若 handler 再次调用相同库,可能发生死锁。因此,handler 中只允许调用 async-signal-safe 操作。事件驱动程序还可以使用 signalfd 等机制将信号转换为可轮询 FD,纳入统一事件循环。

进程间共享

进程提供默认地址空间隔离,但保留多种显式共享方式:

  • 显式共享内存会让不同进程映射同一物理页;
  • 继承或传递的 FD 让内核对象跨进程共享;
  • 调试、进程间内存访问等接口可在权限允许时跨越边界;
  • CPU cache、内存带宽和时间仍是共享物理资源。

因此,进程模型由默认隔离和显式共享共同构成。下一章讨论地址空间隔离的主要硬件基础:虚拟内存。

实验

先在 xv6 中给 kfork()kexec()pipewrite()piperead() 设置断点,运行一条简单的 pipeline。记录子进程的 trapframe->a0ofile[0]ofile[1] 和进程状态变化。再用下面的 Linux 工具观察规模更大的实现。

bash
# 观察 shell 如何搭建管道并 exec
strace -f -e trace=process,desc sh -c 'printf hello | wc -c > result'

# 当前 shell 持有哪些对象
ls -l /proc/$$/fd
cat /proc/$$/fdinfo/1

# 查看进程与线程关系
ps -eLf | less

尝试写一个程序:创建 pipe 后 fork,故意让父进程不关闭写端,子进程持续读取。解释它为什么收不到 EOF,再关闭父写端验证。

练习

  1. xv6 的 kfork() 为什么把子进程的 trapframe->a0 改成 0?
  2. kexec() 为什么最后才替换 p->pagetable?更早提交会破坏什么性质?
  3. xv6 shell 的 close(1); dup(w) 与 Linux 的 dup2(w, 1) 分别依赖什么条件?
  4. 管道的所有写端关闭后,piperead() 如何识别 EOF?漏关一个写端会发生什么?
  5. 当前 xv6 的一个 struct proc 只有一条用户执行流。Linux 线程需要拆分或共享其中哪些对象?

上一章:受限执行 · 下一章:虚拟内存 →