概述

昨天运营商线路出了问题,体验了一把电话线拨号上网一样的网速。

打开浏览器是这样的场景:

  1. 先白屏一会儿。
  2. 逐渐显示出网页上的全部文字。
  3. 卡一会儿。
  4. 慢慢地把网页上的所有图片显示出来。

正在无聊地等待网页加载的时候阴差阳错地意识到浏览器这种一边渲染一边下载的工作方式在历史上还促成了协程和线程这一概念的产生。那么就来讲一讲故事吧。

背景

早期的多道 OS 还只有进程这一概念,OS 的最小调度单位也是进程。每个程序都有自己独立的内存空间,互不干扰,看起来十分和谐。但是也有一些不那么方便的地方。

就拿浏览器举例,我们把浏览器的工作简化为下列几项:

  • 从服务器下载数据
  • 显示文本
  • 解压缩图片
  • 显示图片

如果我们按照从上到下的顺序来执行这些工作,会给用户带来非常差的使用体验,所以开发者希望这些工作可以交替进行,最好是能自由控制什么时机执行哪些工作。

能不能通过启动多个不同功能的进程实现呢?很难,因为访问其它进程的内存是一件很麻烦或者被 OS 禁止的事情,这就会导致从服务器下载下来的数据难以被文本显示进程等渲染进程获取到。同时因为进程的调度是由 Kernel 控制的,用户层无法干预,也就无法在合适的时机切换到指定的工作中。

协程

于是协程就产生了。开发者在用户层自己实现了一套调度机制,可以按照开发者的意愿切换到同一个进程内的不同的执行序列,同时把每个执行序列称为协程

将其应用在上文提到的浏览器上就会变成四个执行序列,分别交替执行下载数据、显示文本、解压缩图片和显示图片这四项工作,开发者可以按照自己的意愿切换到某个工作中,比如图片解压缩协程完成了一部分解压工作后就主动切换到图片显示协程显示出部分图片。看起来很美好,但是还是有问题。

因为下载数据会涉及到网卡 I/O,而一旦启动了网卡 I/O,整个进程都会被阻塞,由于上文提到的四个协程都在同一个进程中,所以这四个协程也会随着进程的阻塞而被迫停下来,直到网卡 I/O 完成,进程被 kernel 调度到后才能继续。

这不是我们想要的,因为我们希望数据下载的过程中先切换到其它协程进行渲染已经下载的数据,不让用户等待那么久,但是现在只要启动了网卡 I/O,就不得不停下来等待,渲染工作也就无法进行。

线程

大家认识到这个问题后,也出于更好地利用多核和多处理器这一目的,就引入了线程这一概念,并很快在各个 OS 上实将其实现。一个进程可以有多个线程,这些线程共用它们所属的进程的内存空间,所以互相通信方便了很多。同时线程成为了 OS 调度的最小单位,其中一个线程因为某些原因阻塞不会影响到其它线程,于是上面的问题就得到解决了。

这时我们可以开四个线程,分别交替执行下载数据、显示文本、解压缩图片和显示图片这四项工作。这样即使数据下载线程阻塞,渲染线程也能继续执行渲染工作。

不过缺点是线程的调度完全由 kernel 控制,用户层无法干预。不过这不是问题,一种做法就是开两个线程 A 和 B:

  • A 线程:从服务器下载数据。
  • B 线程:在线程内部启动三个协程,分别负责显示文本、解压缩图片和显示图片,这三个渲染工作可以按照开发者的意愿自由地切换,这样也保留一定的自由度。

总结

协程是最近几年才火起来的,容易让人产生协程是新的概念这一错觉,实际上协程的出现比线程要早。

协程的优点是切换代价比线程小很多,自由度高,缺点是一旦启动了 I/O 会被全部阻塞。

线程的优点就是其中一个线程被阻塞,不会影响到其它线程,缺点是切换代价比协程大很多。

折中的方案就是按照程序的特点在特定的线程上启动一定数量的协程,一些需要 I/O 的工作则交给其它的线程,保证 I/O 时不会阻塞协程。这种方式固然优点都不如线程协程那么大,但是缺点也不如线程协程那么大。所以有时候太极端也不是好事,折中一下通常是不错的解决方案。放弃了一些东西也会得到一些东西。