进程线程的读书笔记

1,388 阅读18分钟

在之前发布过NodeJS 进程与线程相关的内容,以及孤儿/僵尸/守护进程的内容,本篇文章主要是操作系统层面的进程/线程读书笔记

并发和并行的区别

image

什么是进程

进程的组成

PCB + 程序段 + 数据段

进程的特点

  • 动态性:动态的创建、终止
  • 并发性:内存中存在多个进程,可以并发执行
  • 独立性:进程之间运行都是独立的,独立的获取资源、调度
  • 异步性:独立且不可预知的速度执行
  • 结构性:每个进程都会被分配一个 PCB

程序和进程的区别和联系

联系

  • 程序是产生进程的基础,程序每次运行产生的进程都不相同
  • 进程是程序功能的体现,通过多次执行,一个程序可对应多个进程;通过调用关系,一个进程可包括多个程序

区别

  • 进程是动态的,程序是静止的;程序是代码的集合,进程是程序的执行
  • 进程是暂时的,会有一个状态的变化过程;程序是长久保存在磁盘上的
  • 进程的组成包含了程序/数据/进程控制块(PCB)

进程如何创建/终止

创建

  1. 启动操作系统时,通常会创建很多进程
  2. 运行的程序可以执行创建进程的系统调用
  3. 用户请求创建一个新进程(双击某个应用图标)

在 UNIX 系统中,只有 fork 的系统调用才可以创建新的进程

终止

  1. 正常退出:进程完成工作以后,正常终止
  2. 错误退出:程序执行出错
  3. 被其他进程杀死

什么是 PCB,存储了什么信息

PCB 是进程的唯一标识

🤔 为什么 PCB 是进程的唯一标识

系统总是通过 PCB 对进程进行控制的。
设置该进程的恢复运行状态时需要从 PCB 中读取内存地址从而找到数据;执行过程中需要和别的进程实现通信或者访问文件时,需要访问 PCB;暂停执行时,需要将相关断点位置的相关环境保存在 PCB 中。


PCB 中包含了进程相关的信息

  • 进程控制信息:进程状态和进程优先级
  • 资源分配清单:内存地址空间信息、打开的文件信息、所使用的 I/O 设备信息
  • CPU 相关信息:各种寄存器值,切换进程时使用

进程的状态及生命周期管理

状态转换图

image

状态基本内容

image

状态转换

image

🤔 当内存中的阻塞进程很多的时候,会如何处理对应?

对于 I/O 密集型的进程来说,一个进程进入了阻塞态之后,CPU 就回去处理另一个就绪态的进程,但是 CPU 的处理速度相对于 I/O 要快很多,所以可能导致进程都处于阻塞态,导致处理器效率低下。针对于这种情况有什么办法可以解决呢?

一种直接的解决方案就是扩充内存适应更多的内存。缺点是内存的价格昂贵;程序对内存的空间的需求会越来越大。

另一种间接解决方案就是交换。把内存中某个进程的一部分或者是全部移到磁盘中,此时我们产生了一个新的状态来表示当前进程尚未占据物理内存,这就是挂起状态

挂起状态又分为两种

  • 阻塞挂起状态:进程在外存并等待某个事件的出现
  • 就绪挂起状态:进程在外存,但只要进入内存,即刻立刻运行
image image

进程的组织方式

链接方式

image

索引表方式

image

Linux 中的进程状态

image image

进程的上下文切换

CPU 上下文切换

  • CPU 上下文

    CPU 寄存器和程序计数器就是 CPU 上下文(CPU 在运行任务前必须依赖这两者)

  • CPU 上下文切换

    就是先把前一个任务的 CPU 上下文保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务

系统调用

举个 🌰,当我们想读取 file.txt 文件里面的内容,我们需要先 open 该文件,然后 read() 文件的内容,write() 将内容写到标准输出里,最后 close 掉该文件。

CPU 寄存器里原来用户态的指令位置,需要先保存起来。接着,为了执行内核态代码, CPU 寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运行内核任务。

所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。

这和我们通常所说的进程上下文切换是不一样的,进程上下文切换,是指从一个进程切换到另一个进程运行。而系统调用过程中一直是同一个进程在运行

进程上下文切换

进程是由内核来管理和调度的,进程的切换只能发生在内核态

进程的上下文不仅包括了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、 寄存器等内核空间的状态

因此对于进程上下文切换会比系统调用多一步,在保存当前进程的内核状态和 CPU 寄存器之前,需要先把该进程的虚拟内存、栈等保存下来;而加载了下一进程的内核态后,还需要刷新进程的虚拟内存和用户栈

image

只有在进程调度的时候,才会发生进程上下文的切换

线程

线程出现的原因

我们的播放器进程需要做很多件事情,先把数据从远端获取而来,展示字幕/播放画面/播放声音,在单进程中,这三件事情是不可能同时完成的。

image

如果使用多进程来完成这件事情,能够实现多功能函数的并发执行,但是进程如何通信完成数据共享?

image

那需要一种新的抽象实体,实体之间可以并发运行;实体之间共享相同的地址空间。

这个实体就是线程,线程之间可以并发运行,且共享相同的地址空间

什么是线程

同一进程的多个线程可以共享代码段、数据段、打开的文件等资源,每个线程各自都有一套独立的寄存器和栈。

image

线程的上下文切换

线程与进程最大的区别在于,线程是调度的基本单位,而进程则是资源拥有的基本单位

所谓内核中的任务调度,实际上的调度对象是线程;而进程只是给线程提供了虚拟内存、全局变量等资源。对于线程和进程,可以如下区分

  • 当进程只有一个线程时,可以认为进程等于线程
  • 当进程拥有多个线程时,这些线程共享虚拟内存和全局变量等资源。这些资源在上下文切换的时候是不需要更改的

但是对于线程来说有也私有资源,例如栈和寄存器等,这些资源也需要被保存

针对于这种情况,线程的上下文切换可以分为两类

  • 前后两个线程属于不同进程,资源不共享,和进程切换上下文一致
  • 前后两个线程属于同一进程,资源共享,只需要切换线程的私有数据、寄存器等不共享的数据

这里我们可以看到同为上下文切换,但同进程内的线程切换,要比多进程间的切换消耗更少的资源

线程与进程的比较

  • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系
  • 线程能减少并发执行的时间和空间开销
    • 线程的创建时间比进程快。进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们
    • 线程的终止时间比进程快。因为线程释放的资源相比进程少很多
    • 同一个进程内的线程切换比进程切换快。因为线程具有相同的地址空间,这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的
    • 同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了

线程的实现

线程的实现分为两类:用户级线程和内核级线程

  • 用户级线程

    用户级线程是通过线程库来创建的,位于操作系统的用户空间,操作系统内核感知不到这个库的存在。从内核的角度来说,我们在某个进程中通过线程库创建多个线程,内核并不会知道我们创建了多个线程,在他的眼中只有这个进程。

    image

    在这种情况下,操作系统并不能够对这些线程进行调度,但是开发者可以为你的应用程序定制调度算法。

    值得注意的是,如果某个线程进入了阻塞态,在操作系统的眼中是这个进程进入了阻塞态,因此在这个线程阻塞操作结束前,这个进程都无法得到 CPU 资源。那就相当于所有的线程都被阻塞了。

  • 内核级线程

    许多操作系统都已经支持内核级线程了。为了实现线程,内核里就需要有用来记录系统里所有线程的线程表。

    image

    所以在我们创建线程的时候,就需要进行一个系统调用,然后由操作系统进行线程表的更新

    内核级线程的好处就是内核知道线程的存在,就可以像调度进程一样,把这些线程放在好几个 CPU 核心上,就能做到实际上的并行了。而且假如线程1阻塞了,与他同属一个进程的线程也不会被阻塞

    因为我们每次创建内核级线程都需要陷入内核态,而操作系统从用户态到内核态的转变是有开销的,所以说内核级线程切换的代价要比用户级线程大。

    image

进程的同步和互斥

临界资源

对于一些资源来说,某一时刻只能够被一个进程占用,这种资源就被称为临界资源(例如:打印机

对于临界资源,需要互斥访问。一个进程访问临界资源时,另一个进程不能够访问。只有等该进程访问完成之后,释放该资源之后才能够进行访问

而进程内访问临界资源的代码被成为临界区

对于临界区的访问过程

  • 进入区:检测是否能够访问临界资源,如果可以则进入临界区;否则阻塞
  • 临界区:访问临界资源的代码
  • 退出区:解除正在访问临界资源标识
  • 剩余区:做其他处理

对于临界区的访问原则

  • 空闲让进:临界区空闲时,允许进程访问
  • 忙则等待:临界区正在被访问时,其他想要访问的进程需要等待
  • 有限等待:要在有限时间内进入临界区
  • 让权等待:进不了临界区的进程,要释放处理机

进程同步

image

进程同步也是进程之间的直接制约关系,是为了完成某种任务而建立的两个或者多个进程。这些进程需要在一些位置上协调他们的先后顺序

进程互斥

🤔思考一下,如果没有互斥

image

进程 A 和进程 B 都执行上述的代码。进程 A 在使用打印机的时候,时间片使用完了,被调度出去,进程 B 进入了处理机,使用打印机。导致的后果就是 A/B 的打印内容被打印在了一起

上述的打印机这种在一个时间段内只允许一个进程使用的资源(这也就是互斥的意思),我们将其称为临界资源,正常的顺序应该如下

image

简单对比一下同步和互斥

  • 同步是进程 A 在进程 B 前面执行
  • 互斥是进程 A 和进程 B 不能在同一时刻执行,互斥是一种特殊的进程同步

互斥实现

单标记法

  • 算法思想
    两个进程在访问完临界区后会把使用临界区的权限转交给另外一个进程,每个进程进入临界区的权限只能被另一个进程所赋予,该算法可以实现同一时刻最多只允许一个进程访问临界区

  • 如何实现

    image
  • 存在的问题
    在这种算法下,对于临界区的访问一定是按照 P1 -> P0 -> P1…这样进行的,当 P1 一直不进入临界区,即使临界区空闲,P0 也无法访问

双标记先检查

  • 算法思想
    设置一个 flag 数组,标志每个进程是否想要进入临界区。例如 flag[0]=true 标识进程0想要访问临界区。每个进程进入临界区之前都会去检查一下是否有别的进程想要进入临界区

  • 如何实现

    image
  • 存在的问题
    由于判断是否进入临界区和标记进去临界区是两个操作,可能不会一气呵成。可能存在检查之后,上锁之前发生了进程切换,就会导致 P0/P1 同时访问临界区

双标记后检查

  • 算法思想
    和前一个差不多思想,双标记先检查是先检查在上锁;双标记后检查是先上锁后检查

  • 算法实现

    image
  • 存在的问题
    和双标记先检查算法一样,在上锁之后可能出现经常进程切换,会导致两个进程都想进入临界区,但是都无法进入临界区

Peterson 算法

  • 算法思想
    为了防止两个进程为了进入临界区而无限等待,在添加一个变量 turn,每个进程先设置自己的标志后再设置 turn 标志,再同时检测另一个进程状态标志和不允许进入标志,以保证两个进程同时要求进入临界区时,只允许一个进程进入临界区

  • 算法实现

    image

信号量机制

什么是信号量?

本质是一个变量(整型/记录型),表示系统中某种资源的数量。拥有两种原子操作

  • P 操作(wait 语法):该操作会把信号量减去1,相减后如果信号量 < 0 则表示资源已经被占用,进程需要阻塞;相减后如果信号量 ≥ 0,则表明还有资源可以使用,进程可以正常执行
  • V 操作(signal 语法):该操作会把信号量加上1,相加后如果信号量 ≤ 0,则表明当前有阻塞中的进程,于是会把该进程唤醒;相加后如果信号量 > 0,则表明当前没有阻塞中的进程

整型信号量

使用一个整形变量作为信号量,用于表示资源个数。
与普通变量的区别在于,对于信号量的操作只有三种(初始化、P/V 操作)

image image

当进程 A 需要使用资源时,必须先进行 P 操作,sigalNum=1,不会处于等待,当前资源减一,sigalNum=0
发生进程切换,进程 B 也想使用资源,必须进行 P 操作,不过这个时候 sigalNum=0,进程 B 一直处于等待状态,直到进程 A 执行 V 操作释放资源

记录型信号量

需要使用一个资源数量的整形变量,还需要一个进程链表,用于链接所有等待该资源的进程

image image

信号量实现进程同步

实现思路

  • 分析必须要先后执行的两段代码
  • 设置记录型信号量 S,初始值为0
  • 在必须先执行的代码之后执行 V 操作
  • 在必须后执行的代码之前执行 P 操作
image

如果当 P2 先执行 wait(S),当前的 value=-1,把当前进程放入阻塞态;当 code1/2 执行完成后执行sigal(S)后,value=0,唤醒 P2 进入就绪态

image

生产者消费者问题

问题详解

image

抽象一下

  • 一个场所:数据交互的地
  • 两个角色:生产者和消费者
  • 三种关系:生产者和生产者是互斥关系,消费者和消费者是互斥关系,生产者和消费者是同步关系
image

系统中有一组生产者进程和消费者进程。生产者进程每次生产一个数据放到缓存区,消费者进程每次从缓存区中取出一个数据并使用。
生产者、消费者共享一个初始为空,大小为 n 的缓冲区。
只有缓存区没满时,生产者才能生产数据放入缓冲区,否则需要等待。
只有缓存区不空时,消费者才能从中取出数据,否则需要等待。
缓冲区是临界资源,各进程必须互斥的访问。

问题分析

  • 相关关系
    同步关系:生产者想要放入数据需要等待缓冲区有空间;消费者想要获取数据需要等缓存区有数据
    互斥关系:缓冲区为临界资源,各个进程必须互斥访问

  • 确定相关的流程
    生产者放入数据,其实是资源减少的过程;消费者读取数据实际上归还资源的过程
    生产者每次对缓存区进行一次 P 操作,就 V 操作一个数据;消费者每次读取一个数据就是 P 操作一个数据,对缓存区进行一次 V 操作

  • 设置信号量
    互斥信号量 bufferMutex = 1,对缓存区实现互斥访问
    同步信号量 bufferEmpty = n,表示空闲缓存区
    同步信号量 dataNumber = 0,表示数据的数量

    image
  • 代码实现

    image

    实现互斥的P操作一定要在实现同步的P操作之后

死锁

概念

多个进程/线程竞争资源而造成的一种相互影响的局面,如果没有外力作用,将一直保持现状无法前进

image

Jack 拿了 Rose 房间的钥匙,Rose 拿了 Jack 的房间的钥匙,他们分别都在自己房间。如果 Jack 要从自己的房间走出去,必须要拿到 Rose 手中的钥匙,但 Rose 要走出来又必须要拿到 Jack 手中的钥匙,于是形成了死锁

形成条件

互斥条件

只有对必须互斥使用的资源抢夺时才可能导致死锁,例如:打印机等。

当进程 A 已经在使用打印机的时候,不能够再被进程 B 持有,这个时候进程 B 只有等待,直到进程 A 释放该资源

image

不可剥夺条件

当进程 A 已经持有了资源,在自己使用完之前不能被其他进程获取,进程 B 如果也想使用此资源,则只能在进程 A 使用完并释放后才能获取

image

持有并等待条件

当进程 A 拥有了资源1,又想去获取资源2,可是资源2被进程 C 占据着,此时进程 A 处于等待状态,但是这个时候进程 A 在等待资源2的时候并不会释放自己已经持有的资源1

image

环路等待条件

进程 A 已经持有资源1又想请求资源2,进程 B 已经获得资源2又想获得资源1,这就形成了环路等待

image

死锁只有同时满足这四个条件才会发生

代码实现

初始化两个互斥锁,创建两个线程,并执行

image

对于线程1执行过程:

  1. 先去获取资源1,休息1s
  2. 在获取资源2
  3. 最后释放相关资源
image

对于线程2执行过程:

  1. 先去获取资源2,休息1s
  2. 在获取资源1
  3. 最后释放相关资源
image

执行情况如下

image

如何避免死锁

最常见的解决方案就是,有序分配资源,来破坏环路等待条件。

线程1和线程2获取资源的顺序一致。我们修改上述例子中的线程2如下:

image image image