多任务与执行体

多任务的需求是随处可见的,比如我们想边工作边听音乐、或者我们需要跑一个后台监控程序,以报告随时可能发生的异常。那么,怎么才能做到多任务?

从物理层面看,最早期的CPU基本上都是单核的,也就是同一时间只能执行一条指令。物理层面的多任务,有两个办法:一个是多颗CPU,一个单颗CPU多个核心。

但如果我们实际只有一个单核的CPU,是否就没办法实现多任务?当然可以。方法是把CPU的时间切成一段段时间片,每个时间片只运行某一个软件。这个时间片给软件A,下个时间片给软件B。因为时间片很小,我们会感觉这些软件同时都在运行。这种时间分片实现的多任务系统,我们把他叫分时系统。

分时系统的原理其实是把当前的任务状态先保存起来,把另一个任务的状态恢复,并把执行权交给他即可。这里面涉及的问题有:

  • 任务是什么,怎么抽象任务这样的一个概念;
  • 任务的状态都有什么?怎么保存和恢复
  • 什么时机会发生任务切换

从当前的实现看,任务的抽象并不是唯一的。大部分操作系统提供了两套:进程和线程。有的操作系统还会提供第三套叫协程。

从CPU的角度,执行程序主要依赖的内置存储:寄存器和内存,他们构成执行体的上下文。

寄存器:我们把CPU的执行权从软件A切换到软件B的时候,要把软件A所用到的寄存器都保存起来,并且把寄存器的值恢复到软件B上一次执行时的值,然后才把执行权交给软件B。

内存:CPU在实模式和保护模式下的内存访问机制完全不同,我们分别进行讨论。在实模式下,多个执行体同在一个内存地址空间,相互并无干扰。在保护模式下,不同的任务可以有不同的内存地址空间,它主要通过不同的地址映射表来体现。怎么切换地址映射表?也是寄存器。

总结来说:执行体的上下文,就是一堆寄存器的值。要切换执行体,只需要保存和恢复一堆寄存器的值即可。无论进程、线程还是协程都是如此。

进程、线程、协程

执行体

三者之间的区别如上图。

协程

协程不是操作系统内核提供的,是在用户态下实现的,故有时也被称为用户态线程。

为什么会出现协程?看起来他要应对的需求和线程一样,但是功能比线程弱很多?因为实现高性能的网络服务器的需要。对于常规的桌面程序来说,“进程+线程绰绰有余”。但对于网络服务器,大佬的来自客户端的请求包和服务器的返回包,都是网络io;在响应请求的过程中,往往需要访问存储来保存和读取自身的状态,这也涉及本地或者网络io。如果这个网络服务器有很多客户,那么整个服务器就充斥着大量并行的io请求。

不使用协程,操作系统提供的标准网络 io 有以下成本:

  • 系统调用机制产生的开销;
  • 数据多次拷贝的开销(数据总是先写到操作系统缓存再传到用户传入的内存)
  • 因没有数据而阻塞,需要产生调度重新获得执行权,增加的时间成本
  • 线程的空间成本和时间成本(标准的io请求都是同步调用,想要请求并行只能使用更多线程)

从操作系统内核的主线程来说,内核是独立进程,但是从系统调度的角度来说,操作系统内核更像是一个多线程的程序,每个系统调用是来自某个线程的函数调用。

为了改进网络服务器的吞吐能力,现在主流的做法是用 epoll 或 IOCP 机制,这两个机制颇为类似,都是需要 io 时登记一个 io 请求,然后统一在某个线程查询谁的 io 先完成了,谁完成了就让谁处理。从系统调用次数的角度,epoll、locp 都是产生了更多次数的系统调用,从内存拷贝来说也没有减少。所以真正最有意义的事情是:减少了线程的数量。

减少线程数量的意义

时间成本:

  1. 执行体切换本身的开销,它主要是寄存器保存和恢复的成本
  2. 执行体的调度开销,它主要是如何在大量已准备好的执行体中选出谁获得执行权
  3. 执行体之间的同步与互斥成本

空间成本:

  1. 执行体的执行状态
  2. 线程局部存储
  3. 执行体的堆栈

如果一个线程 1 MB,那么 1000 个线程就到了 GB 级别,消耗太快

既然不希望用太多线程,网络服务器就不能用标准的同步 io 来写程序。知名的 libevent 就是对 epoll 和 locp 这些机制包装了一套跨平台的异步 io 编程模型。

综上,协程的目的如下:

  1. 回归到同步 io 的编程模式
  2. 降低执行体的空间成本和时间成本