从 IO 到协程

2018-07-26

这篇文章是为了准备内部的技术分享,将 ppt 的内容进行了一定的整理,作为演讲的基础。

确定这个分享内容是周末看到一篇讲 异步、并发、协程 相关的文章,想到平时的工作中虽然经常和这些概念打交道,但是却没有真正地对每个相关的概念都理解得很透彻,便决定自己整理一遍。

Unix 体系架构

Unix 体系架构 首先说到 IO,就不得不先说一说 Unix 的体系架构。由于部分指令的权限较高,一旦错用可能产生严重的后果,所以 CPU 将指令分为不同级别,内核才能调用高级别的指令,对于普通的应用程序来说,则只能调用低级别的指令。基于此,操作系统产生了三层的体系架构,分别是最内层的内核、最外层的用户空间和连接两者的系统调用。

内核拥有最高级别的权限,可以访问内存的所有数据,控制所有的硬件设备,以及执行任务调度等。而上层的应用程序则只能访问受限的内存空间,也不能访问硬件设备,其运行须依赖内核提供的资源,而且占用 CPU 资源可能被其他程序获取。为了使上层应用能够访问到这些资源,内核为上层应用提供了系统调用作为访问的接口,系统调用是系统中的最小功能单位。

用户态和内核态

用户态和内核态则是描述操作系统的两种运行状态,当一个任务(进程)执行系统调用而陷入内核代码中执行时,我们就称进程处于内核态。此时处理器处于特权级最高的内核代码。当进程在执行用户自己的代码时,则称其处于用户态。即此时处理器在特权级最低的用户代码中运行。应用程序创建的进程开始是运行在用户态的,如果需要进行 I/O 操作,则需通过系统调用从内核态将数据拷贝到用户态,这中间会经历 等待数据准备 和 拷贝数据 两个阶段。在调用 IO 操作时,应用程序与内核之间的不通交互模式就是不同的 IO 模型。

同步和异步

在讲常见的 IO 模型之前,有两对概念可以先看一看。

同步是指应用程序发起 I/O 请求后需要等待或者轮询内核 I/O 操作完成后才能继续执行。 异步是指应用程序发起 I/O 请求后仍继续执行,当内核 I/O 操作完成后会通知应用程序,或者调用应用程序注册的回调函数。 同步(Synchronised)和异步(Asynchronized)的概念描述的是应用程序与内核的交互方式,与这两个概念相关的是指令执行顺序。

简单的来说,同步是指在代码中包含 IO 操作的时候,代码是顺序执行的,IO 操作完成后,代码才会继续往下执行,而异步则是指发起 IO 操作后代码会继续往下执行,等到 IO 操作完成后,内核通过发送信号通知或者执行回调函数的方式让程序继续执行。异步需要多线程、多 CPU 或者 非阻塞 IO 的支持。

阻塞和非阻塞

阻塞是指 I/O 操作需要彻底完成后才返回到用户空间,在此之前调用线程或进程会被操作系统挂起。 非阻塞是指 I/O 操作被调用后立即返回给用户一个状态值,无需等到 I/O 操作彻底完成,而不会挂起调用线程或进程。 阻塞(Blocking)和非阻塞(Non-blocking)的概念描述的是进程或线程调用内核 I/O 操作的结果。

虽然乍一看阻塞非阻塞和同步异步有点相似,但是实际上这两组概念是完全不同的,它们之间也并没有什么必然联系。

常见的网络 IO 模型

同步阻塞IO(Blocking IO)

同步阻塞IO

1.当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。(用户态切换到内核态-准备数据)

2.对于network io来说,很多时候数据在一开始还没有到达,这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。(用户态-阻塞,内核态-准备数据中)

3.当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果(用户态-阻塞,内核态-拷贝数据到用户进程)

4.用户进程才解除block的状态,重新运行起来。(用户态-解除阻塞,内核态-数据返回完毕)

特点:blocking IO的特点就是在IO执行的两个阶段都被block了。

同步非阻塞IO(Non-blocking IO)

同步阻塞IO

1.当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。(用户态 不阻塞------内核态-准备数据中)

2.从用户进程角度讲,它发起一个read操作后,并不需要等待,而是用户进程马上获取结果,如果结果是error,它就知道数据还没有准备好,于是它可以再次发送read操作。(用户态 不阻塞-------内核态-准备数据中)

3.一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。(用户态 进入阻塞-----内核态-拷贝数据回进程)

特点:用户进程其实是需要不断的主动询问kernel数据好了没有。

IO多路复用(IO Multiplexing)

同步阻塞IO

1.当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。(用户态-阻塞,内核态-准备数据中并通知)

2.这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。(用户态-阻塞,内核态-拷贝数据会用户进程)

特点:这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗)。

select 和 epoll 的区别例子:滴滴司机接到一单,乘客在地铁站,如果是 select 方式的话司机不知道乘客在哪个出口,epoll 方式则会知道具体出口。

异步IO(Asynchronous IO)

同步阻塞IO

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

另外还有一种信号驱动 IO,但是由于 tcp 连接中信号发生特别频繁,所以只适用于 UDP,使用并不广泛。

并发和并行

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它收到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

另外还有一种信号驱动 IO,但是由于 tcp 连接中信号发生特别频繁,所以只适用于 UDP,使用并不广泛。

抢占式调度和协同式调度

抢占式调度允许操作系统剥夺进程执行权限,抢占控制流,因而天然适合服务器和图形操作系统,因为调度器可以优先保证对用户交互和网络事件的快速响应。抢占式调度依赖于 CPU,只有 CPU 支持分级特权指令才能实现抢占式调度。

协同式调度则等到进程时间片用完或系统调用时转移执行权限,因此适合实时或分时等等对运行时间有保障的系统。因为协同式调度对硬件没有什么要求,所以可以运行在很多嵌入式设备上,像我们现在用的 mac 和 windows 在早期由于 CPU 不支持也是用的协同式调度。

进程、线程、协程

进程

  • 进程是资源分配的最小单位
  • 进程间不共享内存,每个进程拥有自己独立的内存
  • 进程间可以通过信号、信号量、共享内存、管道、队列等来通信
  • 新开进程开销大,并且 CPU 切换进程成本也大
  • 进程由操作系统调度
  • 多进程方式比多线程更加稳定

线程

  • 线程是程序执行流的最小单位
  • 线程是来自于进程的,一个进程下面可以开多个线程
  • 每个线程都有自己一个栈,不共享栈,但多个线程能共享同一个属于进程的堆
  • 线程因为是在同一个进程内的,可以共享内存
  • 线程也是由操作系统调度,线程是 CPU 调度的最小单位
  • 新开线程开销小于进程,CPU 在切换线程成本也小于进程
  • 某个线程发生致命错误会导致整个进程崩溃
  • 线程间读写变量存在锁的问题处理起来相对麻烦

协程

  • 对于操作系统来说只有进程和线程,协程的控制由应用程序显式调度,非抢占式的
  • 协程的执行最终靠的还是线程,应用程序来调度协程选择合适的线程来获取执行权
  • 切换非常快,成本低。一般占用栈大小远小于线程(协程 KB 级别,线程 MB 级别),所以可以开更多的协程
  • 协程比线程更轻量级

从编程角度上看,协程的思想本质上就是控制流的主动让出(yield)和恢复(resume)机制,迭代器常被用来实现协程,所以大部分的语言实现的协程中都有 yield 关键字,比如 Python、PHP、Lua。但也有特殊比如 Go 就使用的是通道来通信。Swoole 2.0、3.0、4.0 分别用不同的方式实现了协程。

参考文档