操作系统的任务调度机制(二)进程和线程模型

操作系统的任务调度机制(二)进程和线程模型

2月 10, 2021
操作系统

进程模型 #

进程的概念是随着多道批处理系统的概念产生的,MULTICS 之前还叫 Job,后来它为了区别这个 IBM 公司发明的术语改为了 Process,进程不是程序,程序是放入内存的一段数据,而进程是带有状态的一种活动、一系列动作,它保存了程序运行时的一系列状态。如果一个程序运行两遍,那就算作是两个进程,只不过可能只有一个副本被考入内存。

进程作为拥有资源分配的最小单位,是一个正在执行程序的实例,它为每个程序维护着运行时的各种资源,比如进程ID、进程的页表、进程执行现场的寄存器值、程序计数器、进程各个段地址空间分布信息以及进程执行时的维护信息等,它们在程序的运行期间会被经常或实时更新。

实际上一个 CPU 核心只有一个物理程序计数器,每个程序运行时,它的逻辑程序计数器被装入实际的程序计数器中,当该程序执行结束(或被暂停)时,物理程序计数器的值(程序被执行到的指令位置)被保存在内存中该进程的逻辑程序计数器中。

进程状态 #

现代操作系统的设计中,进程的生命周期一般分为新生态、就绪态、运行态、阻塞态、终止态五种状态。

新生态:表示一个进程刚刚被创建出来,还未完成初始化,不能被调度执行。被初始化之后会进入就绪状态。

运行态:该时刻进程实际占用 CPU,被中断后会进入就绪态。

就绪态:可运行,但因为其他进程正在占用 CPU 而暂时停止

阻塞态:表示该进程正在等待外部事件,暂时无法被调度。当进程等待的外部事件完成后,它会进入就绪状态。

终止态:表示该进程已经完成了执行,且不会再被调度。

进程的内存空间 #

为了实现进程模型,操作系统维护着一张表格(链表或树实现),即进程表(Process Table),每个进程占用一个进程表项,也叫进程控制块(Process Control Block,PCB),Linux 系统中对应了 task_struct 结构体。PCB 包含了进程的重要信息。一般来说,PCB 里存储的资料包括寄存器、程序计数器、状态字、栈指针、优先级、进程ID、父进程 ID、信号、创建时间、所耗CPU时间以及当前持有的各种句柄等。

除了为进程初始化 PCB,操作系统还会为进程分配专门的内存空间,用来存放程序和数据,同时程序的执行还需要用来跟踪函数调用和参数传递的栈,还有进程用来动态分配内存的堆。这么看,进程的虚拟地址空间,大概就是下面这个样子了。不过注意,这是虚拟内存空间的管理,不是实际的物理内存空间的布局。

内核部分(Kernel):内核内存处于进程地址空间的最顶端,每个进程的虚拟地址空间都映射了相同的内核内存,当进程在用户态运行时,内核内存对其不可见,只有当进程进入内核态时,才能访问内核内存。

内核态和用户态是 CPU 运行的两种状态,Linux 中的进程一般只有内核态和用户态两种状态,从用户态到内核态的切换通过系统调用来完成。

用户栈(Stack):栈保存了进程需要使用的各种临时数据(如局部变量的值),栈是一种可以伸缩的数据结构,其拓展方向是自顶向下生长。

代码库:进程的执行有时需要依赖共享的代码库(比如 libc),这些代码库会被映射到用户栈下方的虚拟地址处,并被标记为只读。

用户堆(Heap):堆管理的是进程动态分配的内存,与栈拓展方向相反,它是自底向上生长。一般是通过 mallocnewfreedelete 等函数进行管理的。

未初始化变量区(BSS):存储未被初始化的全局变量和静态变量。

数据段(Data):数据段主要保存的是源代码中预定义的全局变量、静态变量的值。

代码段(Text):代码段主要保存的是进程执行所需要的代码,即机器指令。

进程的上下文切换 #

进程在操作系统中不是单独唯一存在的,否则就不会存在进程概念,操作系统中一定会有多个进程同时存在,它会通过周期性的时钟中断夺走一个进程的控制权,切换给另一个进程,这个过程就是上下文切换(Context Switch)机制。

进程的上下文包括进程运行时的寄存器状态,它能够用于保存和恢复一个进程在处理器上的运行状态,上下文切换是通过中断机制或系统调用在进入内核态后,将前一个进程的寄存器状态保存到 PCB 中,然后将下一个进程先前保存在 PCB 中的寄存器状态值再写入寄存器,从而切换到该进程执行。

不过随着操作系统的线程概念的加入,上下文切换的基本单位也由进程变成了线程,下面会再介绍线程的上下文切换。

进程创建(fork) #

Linux 用 fork 系统调用创建一个进程并返回进程 ID,不同的进程具有不同的进程 ID(PID)。

#include <unistd.h>
#include <stdio.h>

int main()
{
    int pid = fork();

    if (pid == -1)
        return -1;

    if (pid)
    {
        printf("I am father, my pid is %d\n", getpid());
        return 0;
    } else {
        printf("I am child, my pid is %d\n", getpid());
        return 0;
    }
}
//I am father, my pid is 82075
//I am child, my pid is 82076

调用 fork 后父进程和子进程会获取不同的返回值,因此父子进程进入不同的分支并完成打印。对于父进程,fork 将返回子进程的 PID,对于子进程则得到 0,当无法创建子进程时,fork 将返回 -1 。

调用 fork 之后,无法确定父、子进程谁将率先访问 CPU,在某些要求特定执行序列的情况下,可能会触发竞争条件而导致程序结果错误。为了保证某一特定执行顺序,必须采用某种同步技术,第四篇会详细介绍。

进程的问题 #

随着多核 CPU 的发展,程序的可并行程度提高,进程开始显得过于笨重:

  1. 创建进程的开销过大,需要创建独立的地址空间。
  2. 进程进行数据共享和同步比较麻烦,只能通过共享虚拟内存或进程间通信技术。
  3. 进程的上下文切换需要进入内核态,相比线程还是慢了一个量级。

下面开始看看线程模型来体会下它的优点。

线程模型 #

综合上面进程的缺点,线程的产生其实就是为了解决进程自己必须要阻塞、但是又不想主动交出当前 CPU 的控制权的问题,最大限度的实现处理器资源的利用。日常的开发经验也许已经告诉你,很多场景确实需要这种特性,比如文件的处理、网络包的处理等等。

进程是资源隔离的最小单位,线程是操作系统调度时的最基本单元。到了当前的这个发展阶段,CPU 的眼里还是只有进程,一个进程会至少包含一个线程,每个线程都会占用兆级别以上的内存空间,应用程序的执行都是由线程来完成的。

多线程模型 #

Linux 中,线程和进程一样,也分为内核态线程(user-level thread)和用户态线程(kernel-level thread)。内核态线程由内核创建,由操作系统调度器直接管理,可以理解为是一个轻量级进程。用户态线程由进程自己创建,内核不可见,但是功能有限,涉及内核操作(如系统调用)需要借助内核线程才能完成。

用户态线程如果有系统调用的操作需要借助内核态线程协助才能完成,为了实现用户态线程和内核态线程的协作,操作系统会建立两类线程之间的关系,这种关系成为多线程模型(multithreading model)。多线程模型主要有三种。

多对一模型(M:1) #

多对一模型将多个用户态线程映射给单一的内核态线程。这种模型较为简单,但由于只有一个内核态线程,因此每次只能有一个用户态线程可以进入内核,其他需要内核服务的用户态线程会被阻塞。再加上多核机器的普及,用户态线程越来越多,多对一模型难以使用这种变化,目前在主流操作系统中已经不再使用了。

一对一模型(1:1) #

一对一模型为每个用户态线程映射单独的内核态线程,相比于多对一模型,一对一模型提供了更好的可拓展性,因为每个用户态线程可以使用自己的内核态线程执行与内核相关的逻辑,无需担心阻塞其他用户态线程。

一对一模型最大的缺点是,由于用户态线程数量的不断增加,内核态线程数量也会增加,肯定会对操作系统性能造成不利影响,所以需要对数量做限制。目前 Linux 和 Windows 系列的操作系统采用的都是一对一模型。

多对多模型(M:N) #

多对多模型可以将 N 个用户态线程映射到 M 个内核态线程中,其中 N > M,并且 N 不受数量限制。多对多模型是一对一模型和多对一模型的结合,既减轻了多对一模型因为内核数量太少而导致的阻塞问题,也解决了一对一模型因为内核数量过多而导致的性能问题。目前 MacOS 和 iOS 的面向用户体验的调度器 GCD 就是使用多对多模型。

M:N 的最大问题是过于复杂,线程调度任务由内核及用户空间的线程库共同承担,二者之间势必要进行分工协作和信息交换。

线程的内存空间 #

在多线程模型中,每个线程的执行相对独立,进程为每个线程都准备了不同的栈,供它们存放临时数据,在内核中,每个线程也有对应的内核栈,当线程切换到内核中执行时,它的栈指针就会切换到对应的内核栈,内核栈用来存放内核程序运行时需要的用户栈数据的拷贝。

进程出栈以外的其他区域都由该进程的所有线程共享,包括堆、数据段、代码段等。下图是单进程/线程和多线程的地址空间对比。

同操作系统通过进程控制块(PCB)管理进程一样,进程为了管理线程也有线程控制块(Thread Control Block,TCB),TCP 也分为内核态 TCB 和用户态 TCB,内核态 TCB 存储了线程自身相关的信息,例如线程的运行状态,内存映射、标识符等。用户态 TCB 则更多用来存储用户态相关的信息,比较重要的功能就是线程本地存储(Thread Local Storage,TLS)。

线程本地存储(TLS)是一种方法,给定的多线程进程中的每个线程可以使用这种方法分配用以存储线程特定的数据的位置。数据还是存放在了进程中的共享的数据空间,但是每个线程修改的位置都不一样,即线程之间是多份数据拷贝。

线程的上下文切换 #

线程的上下文切换行为和进程类似,线程也是保存一个线程当前的运行时信息,比如页表切换、寄存器和程序计数器的值保存在内核 TCB 中等。线程上下文切换可以分为两种:

(1)前后两个线程属于不同进程。此时因为资源不共享,所以切换过程就跟进程上下文切换是一样。

(2)前后两个线程属于同一个进程。此时,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

虽然同为上下文切换,但同进程内的线程切换,要比多进程间的切换消耗更少的资源,最主要的就是不需要切换虚拟内存,因为切换虚拟内存意味着查询页表并刷新 TLB 缓存数据,缓存失效的话虚拟地址到物理地址的转换就会变慢,这也正是多线程代替多进程的一个优势。

基本接口 #

相比进程的 forkwaitexecexit 等标准的系统调用,各类操作系统都为线程提供了更为丰富的接口供开发者使用,下面以 POSIX 的线程库为例。

使用 pthread_create 接口创建线程。

使用 pthread_exit 接口终止并退出线程。

使用 pthread_yield 出让资源。

使用 pthread_join 合并线程执行。

使用 sleep 挂起线程。

以上只是基础的,Pthread 库接口太多,可自行查阅。

当线程调用 yield 之后它仍会处于就绪状态,并可能很快被调度。当线程调用 sleep 后,他会进入就绪状态,只要条件满足后才会重新恢复到就绪状态。

多线程与信号 #

信号概念是基于 Unix 进程模型设计的,问世要比线程早几十年,信号动作属于进程层面的通信,如果一个进程下有多个线程,如何接收信号是开发时特别需要考虑的问题,Pthread 模型也提供了一些接口,这里先不展开。

本文共 4316 字,上次修改于 Jul 9, 2024,以 CC 署名-非商业性使用-禁止演绎 4.0 国际 协议进行许可。

相关文章

» 操作系统的任务调度机制(一)演进历史

» 操作系统的内存管理机制

» 使用 DOSBox 和 Debug 命令调试汇编程序

» 汇编语言不会编?

» CPU 与寄存器