前言

软件开发中各类知识都是具有一定相关性的,前端开发虽然大部分时间都是在写页面、做交互,但是除了页面开发之外,我们也可以掌握一些网络、操作系统、后端、数据库等其他的知识,扩充自己的知识面。我一直倾向于多的掌握各类知识,领会它们之间的联系,做一个有见识的人*,然后再去深挖背后的原理和细节。**在学习时先建立起完善的知识结构,然后再追本溯源,找到知识的源头,“要识庐山真面目,不应身在此山中*”**。

今天就一起来了解下进程、线程和协程,目标是弄明白下面几个问题:

  1. 程序、进程、线程、协程是什么?
  2. 程序运行的基本过程?
  3. 为什么要有进程、线程、协程?
  4. 如何使用进程、线程和协程?

基本概念

程序

计算机程序是指一组指示电子计算机或其他具有消息处理能力设备每一步动作的指令,通常用某种程序设计语言编写,运行于某种目标体系结构上。——《维基百科》

我们都知道程序其实就是一系列的指令,用一种计算机程序设计语言编写,然后用编译器或者解释器翻译成机器语言。指令可以分为操作数据,对应编程语言中的算法数据结构。

进程

程序相当于是一个名词,描述了一件事如何去做,而进程是程序运行的真正实例。当下达了运行程序的命令后,操作系统会把程序相关的内容和资源加载到内存中,这些资源就是进程。进程是资源分配的基本单位

微信截图_20201226225004.png

一个进程通常包括或者说拥有下面这些资源:

  • 那个程序的可执行机器代码的一个在存储器的映像。
  • 分配到的存储器(通常是虚拟的一个存储器区域)。存储器的内容包括可执行代码、特定于进程的资料(输入、输出)、调用堆栈、堆栈(用于保存运行时运输中途产生的资料)。
  • 分配给该进程的资源的操作系统描述符,诸如文件描述符(Unix术语)或文件句柄(Windows)、资料源和资料终端。
  • 安全特性,诸如进程拥有者和进程的权限集(可以容许的操作)。
  • 处理器状态(内文),诸如寄存器内容、物理存储器寻址等。当进程正在运行时,状态通常存储在寄存器,其他情况在存储器。——《维基百科》

虚拟内存

进程的创建或者说程序的加载是由操作系统的加载器来完成的。内存资源是有限的,所以程序不是一下子全部加载进内存,而是使用了虚拟内存。操作系统会为程序创建一个连续的虚拟地址空间,内存会被分成很多页,然后通过页表记录虚拟内存到物理内存之间的映射,每一页对应物理内存中的一块(页帧)。操作系统会记录程序入口地址(虚拟地址), 等到程序要开始运行时,从该地址中取指令开始运行。此时进程处于就绪状态

内存中以字节为存储单位,每一个字节分配一个物理地址。以 32 位 CPU 位例,可以表示 232个地址,也就是 4G * 1B = 4GB 的物理空间(这也是为什么 32 位 CPU 最多支持 4G 内存。通过 PAE 可以扩展到 64GB)。虚拟内存和物理内存会被分为很多块,按每块 4KB 计算,一个页表应该有 1M 个页表项。要想表示 1M 个页表项,需要 20 位,在 x86 中页表项除了块地址外还包含其他信息总共 32 位,也就是 4B,因此一个页表的大小就是 4MB。

虚拟地址和物理地址.png

进程在执行时 CPU 需要将虚拟地址转换成物理地址,转换主要通过 MMU(内存管理单元) 来完成。MMU 是CPU的一部分,每个处理器核心都有。每次转换,MMU首先在 TLB(转译后备缓冲区,快表) 中检查现有的缓存。如果没有命中,根据 CR3 寄存器,Table Walk Unit 将从内存中的页表查询。

虚拟地址转换.jpg

进程的切换

我们都知道,程序并不完全是同时运行的,而是操作系统按照一定的调度算法轮流让进程获取 CPU 时间来执行的。当一个进程的执行时间到了就会挂起该进程,切换到另一个进程运行。当一个进程获取到 CPU 时间开始执行时,进程就处于运行状态。有时候,正在进行的进程由于发生某个事件而暂时无法继续执行时,便放弃处理机而处于暂停状态,这种暂停状态叫阻塞进程阻塞,此时进程处于等待状态。

AryWDI.png

进程在切换时,需要先保留当前进程的现场,然后恢复另一个进程的现场,这一过程叫做进程上下文切换。进程的上下文切换,主要可以分为两个部分

  1. 虚拟地址空间的切换。
  2. 线程上下文切换(见下文)。

前面提到操作系统会为每个进程分配一个虚拟地址空间,CPU 执行指令时,拿到的地址都是虚拟地址,然后 MMU 获取物理地址。操作系统需要给每个进程设置虚拟地址空间,在进程调度过程中,需要同时切换虚拟地址空间,否则 CPU 通过 MMU 获取到的物理地址就不正确,切换也就是把不同页表的地址放入到 MMU 中。

关于线程的切换部分在后面线程调度的过程中再具体讲述。

进程间通信

  1. 管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。例如:node.js 中 fork 进程,linux 中管道符“|”。
  2. 命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
  3. 消息队列MessageQueue:消息队列是由消息的链表,存放在内核中并由消息队列标识符标识。消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
  4. 共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号两,配合使用,来实现进程间的同步和通信。
  5. 信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
  6. 套接字Socket:套解口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同及其间的进程通信。
  7. 信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。

线程

线程(英语:thread)是操作系统能够进行运算调度的最小单位。大部分情况下,它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
线程是独立调度和分派的基本单位。——《维基百科》

程序就是一系列的指令,在程序运行的过程中 CPU 按照顺序执行指令,当执行某个指令所需要的的资源未就绪时,执行就会陷入阻塞。为了提高 CPU 利用率,可以将指令的执行分为多段,让某一段指令执行阻塞时,可以切换到另一段流程继续执行。这一段指令流就是线程。因此,可以说线程是程序执行的基本单位

用户线程和内核线程

根据操作系统内核是否对线程可感知,可以把线程分为内核线程用户线程。

在用户线程中,有关线程管理的所有工作都由应用程序完成,内核意识不到线程的存在。对于系统内核而言,其实就是一个单线程的进程在运行。

在内核线程中,内核线程建立和销毁都是由操作系统负责、通过系统调用完成的。线程管理的所有工作由内核完成,应用程序没有进行线程管理的代码,只有一个到内核级线程的编程接口。内核为进程及其内部的每个线程维护上下文信息,调度也是在内核基于线程架构的基础上完成。

线程模型.png

用户线程和内核线程之间根据根据实现可以是一对一、多对一、多对多的关系。

线程的切换

在了解线程切换之前需要先理解一下用户态和内核态。

在 Linux 中,按照特权等级,把进程的运行空间分为内核空间和用户空间,分别对应着下图中, CPU 特权等级的 Ring 0 和 Ring 3。

  • 内核空间(Ring 0)具有最高权限,可以直接访问所有资源;
  • 用户空间(Ring 3)只能访问受限资源,不能直接访问硬件设备,必须通过系统调用陷入到内核中,才能访问这些特权资源。
image.png

进程既可以在用户空间运行,又可以在内核空间中运行。进程在用户空间运行时,被称为进程的用户态,而陷入内核空间的时候,被称为进程的内核态。

当需要访问系统资源时,我们都需要系统调用来进行,也就是进入内核态。其实就是完成某些操作的代码放到了内核中,用户代码没办法直接访问操作系统资源,需要调用内核暴露的接口来进行,相当于一个内核库。

内核线程的管理是由内核完成的,因此当线程切换时还会涉及到用户态到内核态之间的切换,其实可以理解为需要执行内核线程调度和切换的代码。

线上的切换就包括用户态和内核态的切换以及 CPU 硬件上下文的切换两部分。硬件上下文的切换很容易理解,就是 CPU 寄存器中数据需要缓存起来,然后 PC(程序计数器) 切换到新的指令开始执行,等到线程恢复运行时恢复之前缓存的数据继续执行指令。

线程锁

多个线程对同一竞态资源的抢夺会引发线程安全问题。竞态资源是对多个线程可见的共享资源,主要包括全局(非const)变量、静态(局部)变量、堆变量、资源文件等。

通过锁机制,能够保证在多核多线程环境中,在某一个时间点上,只能有一个线程进入临界区代码,从而保证临界区中操作数据的一致性。

依据锁的特性、锁的设计、锁的状态常见的分类如下:

  • **乐观锁、悲观锁:**乐观锁认为一个线程去拿数据的时候不会有其他线程对数据进行更改,所以不会上锁,而是在更新数据的判断是否被修改;悲观锁认为一个线程去拿数据时一定会有其他线程对数据进行更改。所以一个线程在拿数据的时候都会顺便加锁,这样别的线程此时想拿这个数据就会阻塞。
  • 自旋锁、互斥锁:自旋锁的线程一直在那循环检测锁标志位,全程消耗 cpu,起始开销虽然低于互斥锁,但随着持锁时间加锁开销是线性增长。当一个线程获得互斥锁后,其他线程会进入随便状态,由操作系统调度唤醒并获取锁。
  • 独享锁、共享锁:字面意思。
  • 公平锁、非公平锁:公平锁中多个线程相互竞争时要排队,多个线程按照申请锁的顺序来获取锁;非公平锁中多个线程相互竞争时,先尝试插队,插队失败再排队。

协程

协程可以理解为用户线程,工作的方式很像是线程池。协程的切换完全是在用户空间进行,由我们自己编写的代码来控制。协程在切换时,只有 CPU 上下文的切换,相比较线程切换涉及到用户空间和内核空间的切换并且需要操作系统老大来调度,协程切换的开销比线程切换要小得多。同时,有好必有坏,协程也有如下的缺点:

  • 无法利用多核资源。
  • 一个协程如果阻塞会导致整个线程挂起。

程序执行的基本过程

计算机组成.png
  1. 程序就是一堆指令和数据,平时躺在硬盘里。
  2. 当开始运行该程序时,操作系统会通过虚拟内存技术为程序分配虚拟的内存空间,创建好页面等各种信息,此时一个进程就起来了。此时程序代码依旧在硬盘中。
  3. 当操作系统通过调度轮到该进程执行的时候,就把他的页表地址放到MMU中,程序入口地址放到 PC 中。CPU 开始执行指令。
  4. 此时指令还在内存中还没有程序的指令,会触发缺页中断,然后由异常中断程序负责从外存在中加载指令和数据到内存中。
  5. 程序的指令就这样一点点的被加载到内存中,由 CPU 负责执行,执行的一系列指令就是线程。

JavaScript 中的基本使用

多进程

单个 Node.js 实例运行在单个线程中。 为了充分利用多核系统,有时需要启用一组 Node.js 进程去处理负载任务。

const cluster = require('cluster')
const http = require('http')
const numCPUs = require('os').cpus().length

if (cluster.isMaster) {
  console.log(`主进程 ${process.pid} 正在运行`)

  // 衍生工作进程。
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork()
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`工作进程 ${worker.process.pid} 已退出`)
  })
} else {
  // 工作进程可以共享任何 TCP 连接。
  // 在本例子中,共享的是 HTTP 服务器。
  http
    .createServer((req, res) => {
      res.writeHead(200)
      res.end('你好世界\n')
    })
    .listen(8000)

  console.log(`工作进程 ${process.pid} 已启动`)
}
cluster进程信息.png

多线程

我们都知道 JavaScript 是单线程的,既然是单线程的,在某个特定的时刻只有特定的代码能够被执行,并阻塞其它的代码。JavaScript 通过事件和回调实现异步任务。当所有同步任务执行完成后,就会依次取出异步的任务开始执行。具体可以参考JavaScript 运行机制详解:再谈Event Loop

function foo() {
  console.log('first')
  setTimeout(function () {
    console.log('second')
  }, 5)
}

console.time()
for (let i = 0; i < 5000; i++) {
  foo()
}
console.timeEnd()
image.png

Web Worker 的作用,就是为 JavaScript 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给后者运行。在主线程运行的同时,Worker 线程在后台运行,两者互不干扰。等到 Worker 线程完成计算任务,再把结果返回给主线程。这样的好处是,一些计算密集型或高延迟的任务,被 Worker 线程负担了,主线程(通常负责 UI 交互)就会很流畅,不会被阻塞或拖慢。

协程

Generator 函数是协程在 ES6 的实现,最大特点就是可以交出函数的执行权(即暂停执行)。

function* fun(a) {
  console.log('a', a)
  const b = yield a + 1
  console.log('b', b)
  const c = yield b + 2
  console.log('c', c)
  return c
}

var gen = fun(1)
console.log('next', gen.next(4))
console.log('next', gen.next(5))
console.log('next', gen.next(6))

通过生成器可以使用类似用户级线程,控制任务处理的流程。下面是生产者-消费者的一个简单例子。

const BUFFER_MAX_SIZE = 10
const buffer = []

function block() {
  return Math.random() < 0.1
}

function* produce() {
  let count = 0
  while (true) {
    if (buffer.length >= BUFFER_MAX_SIZE || block()) {
      yield count
      count = 0
    } else {
      const item = Math.round(Math.random() * 100)
      console.log('生产 item:' + item)
      buffer.push(item)
      count++
    }
  }
}

function* consume() {
  let count = 0
  while (true) {
    if (buffer.length <= 0 || block()) {
      yield count
      count = 0
    } else {
      const item = buffer.shift()
      console.log('消费 item:' + item)
      count++
    }
  }
}

function main() {
  const producer = produce()
  const consumr = consume()
  let i = 0
  while (i < 10) {
    if (Math.random() > 0.5) {
      console.log('开始生产')
      console.log(`此次生产了 ${producer.next().value} 个`)
    } else {
      console.log('开始消费')
      console.log(`此次消费了 ${consumr.next().value} 个`)
    }
    i++
  }
  console.log(buffer)
}

main()

参考链接

  1. 知乎页表
  2. 一文让你明白CPU上下文切换
  3. 操作系统是个大骗子?
  4. 程序执行过程
  5. 深入理解linux内存管理之页表管理
  6. 进程的切换过程
  7. 页目录项和页表项
  8. 进程间通信IPC (InterProcess Communication)
  9. 为什么应该在 Linux 上使用命名管道
  10. 进程间通信及使用场景
  11. 线程的3种实现方式--内核级线程, 用户级线程和混合型线程
  12. 用户态和内核态的理解和区别