重学操作系统(十三)

重学操作系统(十三)

前言

我们经常在面试中遇到进程和线程的问题。很多时候我们只能知道一些比较表面的知识点,可能答不了特别的深入,因此面试官不满意。今天这一章节,先来看下进程和线程的基本知识。

进程和线程

进程(Process),正在运行的应用程序,软件的执行脚本。
线程,可以看作是轻量级的进程。

进程是分配资源的基础单位,而线程很长一段时间被称为轻量级进程(Light Weighted Process),是程序执行的基本单位。

资源分配问题

设计进程和线程,操作系统需要思考分配资源。最重要的三种资源是:计算资源(CPU)、内存资源和文件资源。早期的OS设计,没有线程,3种资源都分配给进程,多个进程通过分时技术交替执行,进程之间通过管道技术等进行通信。

但是如果这样,一个应用需要开多个进程,因为应用经常有很多必须要并行做的事情。因此,设计人员便引入了线程的概念。

轻量级进程

线程设计出来后,只需要被分配计算资源,因此被称为轻量级进程。被分配的方式,是操作系统调度线程。操作系统创建一个进程后,进程的入口程序被分配到一个主线程执行,这样操作系统其实是在调用进程中的线程。

分时和调度

因为一般机器的CPU核心数少,一般就几个到几十个,进程和线程数量很多(有的一个进程可能就几十个线程),因此进程们在操作系统中可能需要排队执行。每个进程执行时,都会获得操作系统分配的一个时间片段,如果超出这个时间,就会轮到下一个进程执行。

分配时间片段

图片来源于: @拉钩教育

以进程为单位的时间片段分配

上图展示了操作系统对每个进程分配时间片段的过程。对于线程,操作系统依旧这么处理。

进程和线程的状态

一个进程(线程)运行的过程,会经历三个状态:

  1. 进程(线程)创建后,就开始排队,此时它会处在“就绪”(Ready)状态。
  2. 当轮到该进程(线程)执行时,会变成“运行”(Running)状态。
  3. 当一个进程(线程)将操作系统分配的时间片段用完后,会回到“就绪”(Ready)状态。
  4. 如果一个进程(线程),等待磁盘读取数据,此时这个进程(线程)会进入“阻塞”(Block)状态。
  5. 由于一个处于“就绪”(Ready)状态的进程(线程)还在排队,因此进程(线程)内的程序无法执行,因此进程(线程)是无法从“就绪”(Ready)状态变为“阻塞”(Block)状态。
  6. 处于“阻塞”(Block)状态的进程(线程)如果处理完响应的任务,它需要重新排队,不能直接回到“运行”(Running)状态。

图片来源于: @拉钩教育

进程(线程)状态切换

进程和线程的设计

进程和线程的表示

可以设计两个表,一个是进程表,一个是线程表。

进程表记录进程在内存中的位置,PID,状态,内存大小等信息。如果没有进程表,操作系统不知道自己在哪些进程。这张表可以直接放在内核中。

进程表记录的信息:

  1. 描述信息:进程的唯一标识,也就是PID,包括进程名称,所属用户等信息
  2. 资源信息:记录进程拥有的资源,比如进程和虚拟内存如何映射,拥有哪些文件,使用哪些I/O设备等
  3. 内存布局:操作系统也约定了进程如何使用内存。

操作系统还需要一张表记录线程,这就是线程表。线程也需要ID,可以叫作ThreadID。线程需要记录自己的执行状态,优先级,程序计数器,所有寄存器的值等。线程需要记录寄存器和程序计数器的值,是因为多个线程需要共用一个CPU,线程经常需要切换,因此需要在内存中保存寄存器和PC指针的值。

用户级线程和内核级线程存在映射关系,因此可以考虑内核中维护一张内核级线程表,这个表也包括上面的字段。

如果考虑不到这种映射关系,可以将线程信息还是存在进程中,每次执行时候才使用内核级线程。相当于内核中有一个线程池,等待用户空间去使用。每次用户级线程把程序计数器等传递过去,执行结束后,内核线程不销毁,等待下一个任务。因此总体来说,创建进程开销大,成本高。创建线程开销小,成本低。

隔离方案

操作系统中存在大量进程,为了让他们互相不干扰,考虑分配彼此完全隔离的内存区域,即便进程内部读取了相同的地址,而实际的物理地址也不同。这种方法叫作地址空间。

进程(线程)切换

进程(线程)在操作系统中是不断切换的,现代操作系统中只有线程的切换。每次切换,需要先保存当前寄存器的值的内存,PC指针也是一种寄存器。当恢复执行时,需要从内存中读出所有寄存器,恢复之前的状态,然后执行。

图片来源于: @拉钩教育

进程(线程)切换

上述内容可以总结为五个步骤:

  1. 当操作系统发现一个进程(线程)需要被切换时,直接控制PC指针跳转是非常危险的事情,因此操作系统需要发送一个“中断”信号给CPU,停下当前执行的进程(线程)。
  2. 当CPU收到中断信号后,正在执行的进程(线程)会立即停止。
  3. 操作系统接管中断,趁着寄存器还没有被破坏,需要执行一小段很底层的程序(汇编写的),帮助寄存器保存之前进程(线程)的状态。
  4. 操作系统保存好进程状态后,执行调度程序,决定下一个要被执行的进程(线程)。
  5. 最后操作系统执行下一个进程(线程)。
  6. 操作系统执行一小段底层程序,帮助进程(线程)回复状态。

图片来源于: @拉钩教育

进程(线程)切换流程

实现上述步骤3 - 6的算法可能是通过栈来实现,进程(线程)中断后,操作系统负责压栈关键数据。恢复执行后,操作系统负责出栈和恢复寄存器的值。

多核处理

多核一般维持上述设计,只不过可以并行的执行进程(线程)。通常情况下,CPU有几个核,就可以并行执行几个进程(线程)。通常说的并行,指的是Concurrent,指在一段时间内,几个任务看上去同时执行(不要求多核)。而另一个英文,Parallel,是绝对的并行,任务要同时执行(要求多核)。

创建进程(线程)的API

最直观的创建进程的方式,就是命令行执行一个程序,或者打开一个应用。

另外,操作系统提供了fork指令,它的作用是,当一个应用执行完一段代价昂贵的初始化程序后,将当前程序的状态复制好几份,变成一个个单独执行的进程。

图片来源于: @拉钩教育

进程(线程)fork指令的应用

每次fork,都会多创建一个克隆的进程,所有状态和原进程一样,但是会有自己的地址空间。如果创造两个克隆进程,就要fork两次。

如果仅是想启动一个新的程序,那么操作系统提供了启动新程序的API。

如果想执行一小段程序,比如服务端处理客户端的请求,那么最好使用线程去处理。因为进程创建成本很高。

Java的Thread类,go的go-routine都是用来创建线程的API。

总结

进程和线程经过这一章节确实了解了很多。知道了很多在面试八股文中无法知晓的,便原理的内容,受益匪浅。下次再面试遇到,可以和面试官侃大山了。

那么,进程的开销比线程大在哪里呢?

Linux中创建进程就会创建一个线程,就是主线程。创建进程需要为进程划分出一块完整的内存空间,有大量的初始化操作,比如把内存分段(堆栈等)。创建线程只需要确定PC指针和寄存器的值,并且给线程分配一个栈用于执行程序。同一个进程的多个线程可以复用堆栈。因此创建进程的开销大,速度慢。

一个思考题:

Q:
fork()
fork()
fork()
print(“Hello World\n”)

请问这个程序执行后, 输出结果 Hello World 会被打印几次?

A:打印八次,第一次fork后,产生了父子两个进程,然后第二次fork,它俩分别fork,就变成四个,之后变成八个。

参考

[1] https://kaiwu.lagou.com/course/courseInfo.htm?courseId=478#/detail/pc?id=4625


 本篇
重学操作系统(十三) 重学操作系统(十三)
重学操作系统(十三)前言我们经常在面试中遇到进程和线程的问题。很多时候我们只能知道一些比较表面的知识点,可能答不了特别的深入,因此面试官不满意。今天这一章节,先来看下进程和线程的基本知识。 进程和线程进程(Process),正在运行的应用程
下一篇 
重学操作系统(十二) 重学操作系统(十二)
重学操作系统(十二)前言之前提到了用户态进行系统调用会有一个中断的操作(Trap)。今天就来看下中断到底是什么。 在此之前,我们可以回想下。我们每天都会和键盘,鼠标这种外设有互动。可是,Java等语言是怎么捕获到键盘的输入的呢?后面会先讲一
  目录