进程
进程是运行中的程序实体。如果把代码视为状态机,那么进程即处于特定状态的状态机。
进程的状态
经典的五态模型:新生->就绪->运行->阻塞->终止
进程的状态由内核调度器负责维护
此外还有一些其它状态(页表基地址、栈指针、进程组等等)储存在进程控制块(Process Control Block)内。本质上是一个内核里的数据结构。
进程切换
回忆程序执行的上下文、上下文切换、栈切换等等ICS内容
实际上现代操作系统的最小调度单位是线程,即单个进程内可以有多个并发的执行流。
进程相关API
主要是创建进程、销毁进程
fork()
语义是复制当前进程cp得到一个子进程sp。若cp存在多个执行流,那么sp中只有执行fork()
的执行流被复制了一份。考虑到fork()
来自上个世纪,这一点是很自然的。
fork()
通过返回值区分父子进程,除此之外二者没有任何区别(不考虑多线程)。与fork()
相关的重要技术是Copy-On-Write。
Linux上的第一个进程是/usr/sbin/init
,在Ubuntu22.04下可以看到是systemd的符号链接。想想还是很神奇的,所有的进程同宗同源,都是分裂来的。
时至今日fork()
也有了落后时代的地方,具体可以看微软那篇paper,一些quirk包括但不限于:
- printf在buffered时fork会与预期不同
- 多线程fork
- 文件描述符继承带来的安全隐患
- 性能问题
execve()
语义是将当前进程重置为指定程序的初始状态。常见做法是搭配
fork()+execve()
实现新进程运行新的程序,同时在二者之间可以做一些初始化的操作。
kill()
实际上kill()
只负责发送信号,具体结束进程的操作是由sighandler实现的,可以看后续的进程间通信部分。
进程管理
wait()
进程同步,即可以等待子进程结束,具体参数看手册。
wait()
带来的一个设计就是僵尸进程。如果父进程先于子进程挂掉了,那么这些孤儿进程将无法被wait()
回收,这也是为什么叫僵尸
Linux和xv6的设计是由init进程定期收养(手册原文是adopt,哈哈)僵尸进程,然后由init来wait()
进程组(process group)
定义为进程的集合,每个进程属于唯一的进程组,父子进程默认在同一个进程组。
操作系统可以以进程组为接收单位发送信号(signal),
会话(session)
定义为进程组的集合,分为前台进程组和后台进程组。那么一个会话就可以通过前台进程与用户交互,进而控制后台进程组中的进程。
线程
进程创建和切换的开销比较大(涉及到地址空间切换,要预热cache冲刷TLB),因此引入了更轻量级的调度单位线程。
线程是一个进程内的执行流,共享同一个地址空间。为了实现多个执行流的并发执行,需要多个线程栈。
内核态线程
内核是最早的多线程程序,内核需要并行地为多个用户进程提供服务是原因之一。
内核态线程是内核创建的线程(废话),也是操作系统可以直接调度的实体。这意味着如果有n核n内核线程,那么这n个线程就是可以同时执行的。
这也意味着内核线程的数量限制了内核并发提供系统调用的能力
用户态线程
用户态线程对内核透明。注意到实现多个逻辑上并发的执行流并不需要陷入内核yield()
,因此可以利用例如makecontext()
或setjmp()
等API来构造用户态上下文,然后在用户态管理多个并发的执行流。
一个例子是协程
线程本地存储(Thread Local Storage)
通过一个关键字即可实现Seemingly全局变量,Factually线程局部的全局变量。实现可以通过特殊的寄存器(X86用fs,risc-v用tp)加上偏移量
POSIX线程库
man -k pthread_*
纤程
多进程调度引入抢占式调度是因为不同进程相互隔离、互不信任,因此每个进程不能长时间霸占CPU,这是前提;
而多线程同样利用抢占式的调度就不合理了,因为同一个进程内的执行流应当相互信任、相互配合以达到效果。因此引入纤程(用户态线程)的概念,使得用户程序能够利用更多的信息实现调度的最优化(例如主动让出调度)
一些编程语言(C++ Go Lua)也支持用户态线程的创建和管理,称为协程。