进程概念及重要系统调用
本节导读
本节的内容有:
介绍进程的概念以及它和一些其他相近的概念的比较;
从应用开发者或是用户的角度介绍我们的实现中一种简单的类 Unix 进程模型;
介绍三个最重要的进程相关的系统调用并给出一些用例。
进程概念
在本章的引言中,出于方便应用开发和使得应用功能更加强大的目标,我们引入了进程的概念。所谓 进程 (Process) ,就是指 正在执行的程序 。尽管说起来很容易,但事实上进程是一个内涵相当丰富且深刻、难以从单个角度解释清楚的抽象概念。我们可以先试着从动态和静态的角度来进行初步的思考。我们知道,当一个应用被成功构建之后,它会从源代码变为某种格式的可执行文件,如果将其展开的话可以在它的内存布局中看到若干个功能迥异的逻辑段。但仅是如此的话,它也就只是某种格式特殊的、被 静态 归档到存储器上的一个文件而已。
然而,可执行文件与其他类型文件的决定性的不同就在于它可以被内核加载并执行。这一过程自然是不能凭空进行的,而是需要占据某些真实的硬件资源。例如,可执行文件一定需要被加载到物理内存的某些区域中才能执行,另外还可能需要预留一些可执行文件内存布局中未规划的区域(比如栈),这就会消耗掉部分内存空间;在执行的时候需要占据一个 CPU 的全部硬件资源,我们之前介绍过的有通用寄存器(其中程序计数器 pc 和栈指针 sp 两个意义尤其重大)、CSR 、各级 cache 、TLB 等。
打一个比方,可执行文件本身可以看成一张编译器解析源代码之后总结出的一张记载如何利用各种硬件资源进行一轮生产流程的 蓝图 。而内核的一大功能便是作为一个硬件资源管理器,它可以随时启动一轮生产流程(即执行任意一个应用),这需要选中一张蓝图(此时确定执行哪个可执行文件),接下来就需要内核按照蓝图上所记载的对资源的需求来对应的将各类资源分配给它让这轮生产流程得以顺利进行。当按照蓝图上的记载生产流程完成(应用退出)之后,内核还需要将对应的硬件资源回收以便后续的重复利用。
因此,进程就是选取某个可执行文件并对其进行一次动态执行的过程。相比可执行文件,它的动态性主要体现在:
它是一个过程,从时间上来看有开始也有结束;
在该过程中对于可执行文件中给出的需求要相应对 硬件资源 进行 动态绑定 。
这里需要指出的是,两个进程可以选择同一个可执行文件执行,然而它们却是截然不同的进程:它们的启动时间、占据的硬件资源、输入数据均有可能是不同的,这些条件均会导致它们是不一样的执行过程。在某些情况下,我们可以看到它们的输出是不同的——这是其中一种可能的直观表象。
在内核中,需要有一个进程管理器,在其中记录每个进程对资源的占用情况,这是内核作为一个硬件资源管理器所必须要做到的。进程管理器通常需要管理多个进程,因为如果同一时间只有一个进程的话,就可以简单的将所有的硬件资源都交给该进程,同时内核也会像第一章《RV64 裸机应用》那样退化成一个函数库。
本节接下来主要站在应用开发者和用户的角度来介绍如何理解进程概念并基于它编写应用程序。
注解
为何要在这里才引入进程
根据我们多年来的OS课程经验,学生对 进程
的简单定义“ 正在执行的程序 ”比较容易理解。但对于多个运行的程序之间如何切换,会带来哪些并发问题,进程创建与虚拟内存的关系是啥等问题很难一下子理解清楚,也不清楚试图解决这些问题的原因。
这是由于在 进程
这个定义背后,有特权级切换、异常处理,程序执行的上下文切换、地址映射、地址空间、虚存管理等一系列的知识的支撑,才能理解清楚操作系统对进程的整个管理过程。所以,我们在前面几章对上述知识进行了铺垫。并以此为基础,更加全面地来分析操作系统是如何管理进程的。
注解
进程,线程和协程
进程,线程和协程是操作系统中经常出现的名词,它们都是操作系统中的抽象概念,有联系和共同的地方,但也有区别。计算机的核心是CPU,它承担了基本上所有的计算任务;而操作系统是计算机的管理者,它可以以进程,线程和协程为基本的管理和调度单位来使用CPU执行具体的程序逻辑。
从历史角度上看,它们依次出现的顺序是进程、线程和协程。在还没有进程抽象的早期操作系统中,计算机科学家把程序在计算机上的一次执行过程称为一个任务(task)或一个工作(job),其特点是任务和工作在其整个的执行过程中,不会被切换。这样其他任务必须等待一个任务结束后,才能执行,这样系统的效率会比较低。
在引入面向CPU的分时切换机制和面向内存的虚拟内存机制后,进程的概念就被提出了,进程成为CPU(也称处理器)调度(scheduling)和分派(switch)的对象,各个进程间以时间片为单位轮流使用CPU,且每个进程有各自独立的一块内存,使得各个进程之间内存地址相互隔离。这时,操作系统通过进程这个抽象来完成对应用程序在CPU和内存使用上的管理。
随着计算机的发展,对计算机系统性能的要求越来越高,而进程之间的切换开销相对较大,于是计算机科学家就提出了线程。线程是程序执行中一个单一的顺序控制流程,线程是进程的一部分,一个进程可以包含一个或多个线程。各个线程之间共享进程的地址空间,但线程要有自己独立的栈(用于函数访问,局部变量等)和独立的控制流。且线程是处理器调度和分派的基本单位。对于线程的调度和管理,可以在操作系统层面完成,也可以在用户态的线程库中完成。用户态线程也称为绿色线程(GreenThread)。如果是在用户态的线程库中完成,操作系统是“看不到”这样的线程的,也就谈不上对这样线程的管理了。
协程(coroutines,也称纤程(Fiber)),也是程序执行中一个单一的顺序控制流程,建立在线程之上(即一个线程上可以有多个协程),但又比线程更加轻量级的处理器调度对象。协程一般是由用户态的协程管理库来进行管理和调度,这样操作系统是看不到协程的。而且多个协程共享同一线程的栈,这样协程在时间和空间的管理开销上,相对于线程又有很大的改善。在具体实现上,协程可以在用户态运行时库这一层面通过函数调用来实现;也可在语言级支持协程,比如Rust语言引入的 async
、 wait
关键字等,通过编译器和运行时库二者配合来简化程序员编程的负担并提高整体的性能。
进程模型与重要系统调用
目前,我们只介绍一种我们的内核实现中所采用的一种非常简单的进程模型。这个进程模型有三个运行状态:就绪态、运行态和等待态;有基于独立的页表的地址空间;可被操作系统调度来分时占用CPU执行;可以动态创建和退出;可通过系统调用获得操作系统的服务。 前面我们并没有给出进程需要使用哪些类型的资源,这其实取决于内核提供给应用的系统调用接口以及内核的具体实现。我们实现的进程模型建立在地址空间抽象之上:每个进程都需要一个地址空间,它涵盖了它选择的可执行文件的内存布局,还包含一些其他的逻辑段。且进程模型需要操作系统支持一些重要的系统调用:创建进程、执行新程序、等待进程结束等,来达到应用程序执行的动态灵活性。接下来会介绍这些系统调用的基本功能和设计思路。
fork 系统调用
系统中同一时间存在的每个进程都被一个不同的 进程标识符 (PID, Process Identifier) 所标识。在内核初始化完毕之后会创建一个进程——即 用户初始进程 (Initial Process) ,它是目前在内核中以硬编码方式创建的唯一一个进程。其他所有的进程都是通过一个名为 fork
的系统调用来创建的。
/// 功能:当前进程 fork 出来一个子进程。
/// 返回值:对于子进程返回 0,对于当前进程则返回子进程的 PID 。
/// syscall ID:220
pub fn sys_fork() -> isize;
进程A调用 fork
系统调用之后,内核会创建一个新进程B,这个进程B和调用 fork
的进程A在返回用户态那一瞬间几乎处于相同的状态:这意味着它们包含的用户态的代码段、堆栈段及其他数据段的内容完全相同,但是它们是被放在两个独立的地址空间中的。因此新进程的地址空间需要从原有进程的地址空间完整拷贝一份。两个进程通用寄存器也几乎完全相同。例如, pc 相同意味着两个进程会从同一位置的一条相同指令(我们知道其上一条指令一定是用于系统调用的 ecall 指令)开始向下执行, sp 相同则意味着两个进程的用户栈在各自的地址空间中的位置相同。其余的寄存器相同则确保了二者回到了相同的执行流状态。
但是唯有用来保存 fork
系统调用返回值的 a0 寄存器(这是 RV64 函数调用规范规定的函数返回值所用的寄存器)的值是不同的。这区分了两个进程:原进程的返回值为新创建进程的 PID ,而新创建进程的返回值为 0 。由于新的进程是原进程主动调用 fork
衍生出来的,我们称新进程为原进程的 子进程 (Child Process) ,相对的原进程则被称为新进程的 父进程 (Parent Process) 。这样二者就建立了一种父子关系。注意到每个进程可能有多个子进程,但最多只能有一个父进程,于是所有进程可以被组织成一颗有根树,其根节点正是代表用户初始程序-initproc的第一个用户态的初始进程。
相比创建一个进程, fork
更重要的功能是建立一对新的父子关系。在我们的进程模型中,父进程和子进程之间的联系更为紧密,它们更容易进行合作或通信,而且一些重要的机制也需要在它们之间才能展开。
waitpid 系统调用
当一个进程通过 exit
系统调用退出之后,它所占用的资源并不能够立即全部回收。比如该进程的内核栈目前就正用来进行系统调用处理,如果将放置它的物理页帧回收的话,可能会导致系统调用不能正常处理。对于这种问题,一种典型的做法是当进程退出的时候内核立即回收一部分资源并将该进程标记为 僵尸进程 (Zombie Process) 。之后,由该进程的父进程通过一个名为 waitpid
的系统调用来收集该进程的返回状态并回收掉它所占据的全部资源,这样这个进程才被彻底销毁。系统调用 waitpid
的原型如下:
/// 功能:当前进程等待一个子进程变为僵尸进程,回收其全部资源并收集其返回值。
/// 参数:pid 表示要等待的子进程的进程 ID,如果为 -1 的话表示等待任意一个子进程;
/// exit_code 表示保存子进程返回值的地址,如果这个地址为 0 的话表示不必保存。
/// 返回值:如果要等待的子进程不存在则返回 -1;否则如果要等待的子进程均未结束则返回 -2;
/// 否则返回结束的子进程的进程 ID。
/// syscall ID:260
pub fn sys_waitpid(pid: isize, exit_code: *mut i32) -> isize;
一般情况下一个进程要负责通过 waitpid
系统调用来等待所有它 fork
出来的子进程结束并回收掉它们占据的资源,这也是父子进程间的一种同步手段。但这并不是必须的:如果一个进程先于它的子进程结束,在它退出的时候,它的所有子进程将成为进程树的根节点——用户初始进程的子进程,同时这些子进程的父进程也会变成用户初始进程。这之后,这些子进程的资源就由用户初始进程负责回收了,这也是用户初始进程很重要的一个用途。后面我们会介绍用户初始进程是如何实现的。
exec 系统调用
如果仅有 fork
的话,那么所有的进程都只能和用户初始进程一样执行同样的代码段,这显然是远远不够的。于是我们还需要引入 exec
系统调用来执行不同的可执行文件:
/// 功能:将当前进程的地址空间清空并加载一个特定的可执行文件,返回用户态后开始它的执行。
/// 参数:path 给出了要加载的可执行文件的名字;
/// 返回值:如果出错的话(如找不到名字相符的可执行文件)则返回 -1,否则不应该返回。
/// syscall ID:221
pub fn sys_exec(path: &str) -> isize;
注意,我们知道 path
作为 &str
类型是一个胖指针,既有起始地址又包含长度信息。在实际进行系统调用的时候,我们只会将起始地址传给内核(对标 C 语言仅会传入一个 char*
)。这就需要应用负责在传入的字符串的末尾加上一个 \0
,这样内核才能知道字符串的长度。下面给出了用户库 user_lib
中的调用方式:
// user/src/exec.rs
pub fn sys_exec(path: &str) -> isize {
syscall(SYSCALL_EXEC, [path.as_ptr() as usize, 0, 0])
}
这样,利用 fork
和 exec
的组合,我们很容易在一个进程内 fork
出一个子进程并执行一个特定的可执行文件。
注解
为何创建进程要通过两个系统调用而不是一个?
读者可能会有疑问,对于要达成执行不同应用的目标,我们为什么不设计一个系统调用接口同时实现创建一个新进程并加载给定的可执行文件两种功能? 因为如果使用 fork
和 exec
的组合,那么 fork
出来的进程仅仅是为了 exec
一个新应用提供空间。而执行 fork
中对父进程的地址空间拷贝没有用处,还浪费了时间,且在后续清空地址空间的时候还会产生一些资源回收的额外开销。 然而这样做是经过实践考验的——事实上 fork
和 exec
是一种灵活的系统调用组合。上述的这些开销能够通过一些技术方法(如 copy on write
等)大幅降低,且拆分为两个系统调用后,可以灵活地支持 重定向 (Redirection) 等功能。 上述方法是UNIX类操作系统的典型做法,这一点与Windows操作系统不一样。在Windows中, CreateProcess
函数用来创建一个新的进程和它的主线程,通过这个新进程运行指定的可执行文件。虽然是一个函数,但这个函数的参数十个之多,使得这个函数很复杂,且没有 fork
和 exec
的组合的灵活性。
应用程序示例
我们刚刚介绍了 fork/waitpid/exec
三个重要系统调用,借助它们我们可以开发功能更为强大的应用程序。下面我们通过描述两个重要的应用程序: 用户初始程序-init 和 shell程序-user_shell 的开发过程,来展示这些重要系统调用的使用方法。
系统调用封装
读者可以在 user/src/syscall.rs
中看到以 sys_*
开头的系统调用的函数原型,它们后续还会在 user/src/lib.rs
中被封装成方便应用程序使用的形式。如 sys_fork
被封装成 fork
,而 sys_exec
被封装成 exec
。这里值得一提的是 sys_waitpid
被封装成两个不同的 API :
|
|
其中 wait
表示等待任意一个子进程结束,根据 sys_waitpid
的约定它需要传的 pid 参数为 -1
;而 waitpid
则等待一个 PID 固定的子进程结束。在具体实现方面,我们看到当 sys_waitpid
返回值为 -2
,即要等待的子进程存在但它却尚未退出的时候,我们调用 yield_
主动交出 CPU 使用权,待下次 CPU 使用权被内核交还给它的时候再次调用 sys_waitpid
查看要等待的子进程是否退出。这样做可以减小 CPU 资源的浪费。
目前的实现风格是尽可能简化内核,因此 sys_waitpid
是立即返回的,即它的返回值只能给出返回这一时刻的状态。如果这一时刻要等待的子进程还尚未结束,那么也只能如实向应用报告这一结果。于是用户库 user_lib
就需要负责对返回状态进行持续的监控,因此它里面便需要进行循环检查。在后面的实现中,我们会将 sys_waitpid
的内核实现设计为 阻塞 的,也即直到得到一个确切的结果位置都停在内核内,也就意味着内核返回给应用的结果可以直接使用。那是 wait
和 waitpid
两个 API 的实现便会更加简单。
用户初始程序-initproc
我们首先来看用户初始程序-initproc是如何实现的:
|
|
第 19 行为
fork
返回值为 0 的分支,表示子进程,此行直接通过exec
执行shell程序user_shell
,注意我们需要在字符串末尾手动加入\0
,因为 Rust 在将这些字符串连接到只读数据段的时候不会插入\0
。第 21 行开始则为返回值不为 0 的分支,表示调用
fork
的用户初始程序-initproc自身。可以看到它在不断循环调用wait
来等待那些被移交到它下面的子进程并回收它们占据的资源。如果回收成功的话则会打印一条报告信息给出被回收子进程的 PID 和返回值;否则就yield_
交出 CPU 资源并在下次轮到它执行的时候再回收看看。这也可以看出,用户初始程序-initproc对于资源的回收并不算及时,但是对于已经退出的僵尸进程,用户初始程序-initproc最终总能够成功回收它们的资源。
shell程序-user_shell
由于shell程序-user_shell需要捕获我们的输入并进行解析处理,我们需要加入一个新的用于输入的系统调用:
/// 功能:从文件中读取一段内容到缓冲区。
/// 参数:fd 是待读取文件的文件描述符,切片 buffer 则给出缓冲区。
/// 返回值:如果出现了错误则返回 -1,否则返回实际读到的字节数。
/// syscall ID:63
pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize;
在实际调用的时候我们必须要同时向内核提供缓冲区的起始地址及长度:
// user/src/syscall.rs
pub fn sys_read(fd: usize, buffer: &mut [u8]) -> isize {
syscall(SYSCALL_READ, [fd, buffer.as_mut_ptr() as usize, buffer.len()])
}
我们在用户库中将其进一步封装成每次能够从 标准输入 中获取一个字符的 getchar
函数:
// user/src/lib.rs
pub fn read(fd: usize, buf: &mut [u8]) -> isize { sys_read(fd, buf) }
// user/src/console.rs
const STDIN: usize = 0;
pub fn getchar() -> u8 {
let mut c = [0u8; 1];
read(STDIN, &mut c);
c[0]
}
其中,我们每次临时声明一个长度为 1 的缓冲区。
接下来就可以介绍shell程序- user_shell
是如何实现的了:
|
|
可以看到,在以第 25 行开头的主循环中,每次都是调用 getchar
获取一个用户输入的字符,并根据它相应进行一些动作。第 23 行声明的字符串 line
则维护着用户当前输入的命令内容,它也在不断发生变化。
注解
在应用中使能动态内存分配
我们知道,在 Rust 中可变长字符串类型 String
是基于动态内存分配的。因此本章我们还要在用户库 user_lib
中支持动态内存分配,与第四章的做法相同,只需加入以下内容即可:
use buddy_system_allocator::LockedHeap;
const USER_HEAP_SIZE: usize = 16384;
static mut HEAP_SPACE: [u8; USER_HEAP_SIZE] = [0; USER_HEAP_SIZE];
#[global_allocator]
static HEAP: LockedHeap = LockedHeap::empty();
#[alloc_error_handler]
pub fn handle_alloc_error(layout: core::alloc::Layout) -> ! {
panic!("Heap allocation error, layout = {:?}", layout);
}
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start() -> ! {
unsafe {
HEAP.lock()
.init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE);
}
exit(main());
}
如果用户输入回车键(第 28 行),那么user_shell 会 fork 出一个子进程(第 34 行开始)并试图通过
exec
系统调用执行一个应用,应用的名字在字符串line
中给出。这里我们需要注意的是由于子进程是从user_shell 进程中 fork 出来的,它们除了 fork 的返回值不同之外均相同,自然也可以看到一个和user_shell 进程维护的版本相同的字符串line
。第 35 行对exec
的返回值进行了判断,如果返回值为 -1 的话目前说明在应用管理器中找不到名字相同的应用,此时子进程就直接打印错误信息并退出;反之exec
则根本不会返回,而是开始执行目标应用。fork 之后的user_shell 进程自己的逻辑可以在第 41 行找到。可以看出它只是在等待 fork 出来的子进程结束并回收掉它的资源,还会顺带收集子进程的退出状态并打印出来。
如果用户输入退格键(第 53 行),首先我们需要将屏幕上当前行的最后一个字符用空格替换掉,这可以通过输入一个特殊的退格字节
BS
来实现。其次,user_shell 进程内维护的line
也需要弹出最后一个字符。如果用户输入了一个其他字符(第 61 行),它将会被视为用户的正常输入,我们直接将它打印在屏幕上并加入到
line
中。
当内核初始化完毕之后,它会从可执行文件 initproc
中加载并执行用户初始程序-initproc,而用户初始程序-initproc中又会 fork
并 exec
来运行shell程序- user_shell
。这两个应用虽然都是在 CPU 的 U 特权级执行的,但是相比其他应用,它们要更加基础。原则上应该将它们作为一个组件打包在操作系统中。但这里为了实现更加简单,我们并不将它们和其他应用进行区分。
除此之外,我们还从 \(\mu\text{core}\) 中借鉴了很多应用测例。它们可以做到同一时间 并发 多个进程并能够有效检验我们内核实现的正确性。感兴趣的读者可以参考 matrix
和 forktree
等应用。