操作系统04 进程与线程
2022-07-15 19:24:46

进程

进程是运行中的程序实体。如果把代码视为状态机,那么进程即处于特定状态的状态机。

进程的状态

经典的五态模型:新生->就绪->运行->阻塞->终止

进程的状态由内核调度器负责维护

此外还有一些其它状态(页表基地址、栈指针、进程组等等)储存在进程控制块(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包括但不限于:

  1. printf在buffered时fork会与预期不同
  2. 多线程fork
  3. 文件描述符继承带来的安全隐患
  4. 性能问题

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)也支持用户态线程的创建和管理,称为协程。