从进程到协程

进程

早期的计算机执行程序,是顺序执行的。按顺序一次做一件事情,只有当前的程序执行完了,才能执行下一个程序。

这样做有什么问题呢?

  1. 程序只能按顺序执行,如果当前的程序计算量比较大,运行时间比较长,后序的程序就长时间得不到运行。系统会表现的像死机一样。

  2. 属于同一个程序的计算和IO直接也是顺序执行的。在程序进行IO的时候,CPU只能等待。资源利用率很低。

为此,在系统中引入多道程序技术,使得程序直接可以并发执行。

程序并发执行,在单CPU环境下,表现为时间分片。程序快速切换,看起来像是大家一块跑。

程序并发执行,系统中的资源由各个程序共享,那么将失去其封闭性,并具有间断性和不可再现性。比如,某个程序进行多次方程的解运算,计算到一半,别的程序突然插进来,此时的中间状态怎么办?内存会不会被覆盖?所以,跑并发需要处理上下文切换的问题。

进程就是这样抽象出来的一个概念,进程是指在系统中能够独立运行并作为资源分配的基本单位,由一组机器指令、数据和堆栈等组成。这样就可以对并发执行的程序加以描述和控制,管理独立的程序运行、切换。

多CPU环境下,同一时间,不同的进程可以跑在不同的CPU上,这就是并行。

线程

进程的管理,是由操作系统来做的。程序运行期间遇到了IO访问,阻塞了后面的计算,为了不浪费CPU,此时操作系统就会将当前进程挂起,把CPU让给其他进程使用。一切换进程,就得陷入内核,置换掉一大堆的状态,这个代价的很大的。如果系统中的进程数一多,IO操作也多,进程切换频繁发生,系统资源就都被进程切换吃掉了。整个系统就会变得很慢。

于是又提出了线程的概念,大致意思就是,这个地方阻塞了,但我还有其他地方的逻辑流可以计算,这些逻辑流是共享一个地址空间的,不用特别麻烦的切换页表、刷新TLB,只要把寄存器刷新一遍就行,能比切换进程开销少点。比如,进程内的线程遇到IO操作,就切换到其他线程执行,该进程并不需要被切换出去,避免了大量进程切换,提高了工作效率。

同时,一个进程中的多个线程可以并发执行,不同进程中的线程也能并发执行,使得操作系统具有更好的并发性,从而能够更加有效的提高系统资源的利用率和系统的吞吐量。

异步IO

多路复用

在写一个服务器程序的时候,我们为每一个连接进来的用户创建一个线程为其服务。当用户量很小的时候,这么做是没有问题的。后来用户量上来了,为每个用户创建线程的做法就不行了,因为线程虽然比进程更轻量,但是创建、切换、销毁线程也是一笔很大的开销,线程也会占用系统资源,操作系统所能支持的线程数目也是有限的。

于是,有了线程池。程序维护一定数量的线程,线程不会被轻易的创建和销毁,而是得到了复用。如果连接超出了线程池能承受的范围,就将其放入队列,等待有空闲线程了再行分配。

这样看起来好像一定程度上解决了问题,但是也有其致命的缺陷,因为其本质还是依赖线程:

  1. 线程很占内存

  2. 线程的切换带来的资源消耗。有可能恰好轮到一个线程的时间片,但此时这个线程被io阻塞,这时会发生线程切换(无意义的损耗)

  3. 如果线程池定义了100个线程,意味着同时只能为100个用户服务。倘若服务器同故障节点通信,由于其io是阻塞的,如果所有可用线程被故障节点阻塞,那么新的请求在队列中排队,直到连接超时。

所以,面对数十万的连接请求,线程池也是无能为力的。

于是,IO多路复用登场。IO复用的优势在于它可以同时处理多个connection。这意味着单个线程就有可能处理成千上万个连接。

1
2
3
4
5
6
7
8
9
10
// epoll
// 事先调用epoll_ctl注册感兴趣的事件到epollfd
while true {
// 返回触发注册事件的流
active_stream[] = epoll_wait(epollfd)
// 无须遍历所有的流
for i in active_stream[] {
read or write till
}
}

很多工具都使用了IO多路复用的技术,比如,Netty、Redis、Nginx等等。

异步IO

比IO多路复用更为理想的IO模型是异步IO:应用程序发起异步调用,而不需要进行轮询,进而处理下一个任务,只需在I/O完成后通过信号或是回调将数据传递给应用程序即可。

1
2
3
4
var fs = require('fs');
fs.open('./test.txt', "w", function(err, fd) {
//..do something
});

上面是一个典型的NodeJs读取文件的操作。调用fs.open的线程不会阻塞,他只是发起了一个调用,然后就马上返回了,紧接着便可以处理后续的代码。原因在于fs.open这个函数是异步函数,调用函数发起了一个IO读操作便可直接返回。而IO操作是由别的线程异步执行的,当读取文件这个IO操作完成后,NodeJs会调用传入的callback函数进行处理。

回想一下Java读取文件的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
File file = new File(fileName);
InputStream in = null;
try {
in = new FileInputStream(file);
int tempbyte;
while ((tempbyte = in.read()) != -1) {
System.out.write(tempbyte);
}
in.close();
} catch (IOException e) {
e.printStackTrace();
return;
}

如果忽略掉缓存的话,每次调用in.read方法,便发起了一个阻塞IO,当前线程便会被挂起,CPU切换到其他线程执行。这样便发生了线程切换。

异步IO与之相比,让单线程远离阻塞,同时规避了线程切换(恢复现场)的开销,让单一线程在执行 I/O 操作后立即进行其他操作。

那么,NodeJs是如何实现异步IO的呢?答案是线程池+IO复用|阻塞IO模拟异步IO。

node异步

由于Windows平台和*nix平台的差异,Node.js提供了libuv来作为抽象封装层,Linux下,采用线程池+IO复用|阻塞IO模拟异步IO,Windows下,采用其独有的内核异步IO实现IOCP(IOCP的思路也是通过线程实现,不同在于这些线程由系统内核接手管理)。

node异步

有了异步IO,再搭配事件循环,单线程的NodeJS便可以处理成千上万条连接。

事件循环

注意,单线程的NodeJS只适合处理IO密集型的任务,IO操作较多时,NodeJS才能快速的执行事件循环,各个任务能够得到执行的机会;一旦涉及到大量的计算,那么线程便会阻塞,影响到事件循环的进行。

协程

异步回调的缺点

异步IO的后续操作需要通过回调进行,回调函数本身并没有问题,它的问题出现在多个回调函数嵌套。假定读取A文件之后,再读取B文件,代码如下。

1
2
3
4
5
fs.readFile(fileA, function (err, data) {
fs.readFile(fileB, function (err, data) {
// ...
});
});

不难想象,如果依次读取多个文件,就会出现多重嵌套。代码不是纵向发展,而是横向发展,很快就会乱成一团,无法管理。这种情况就称为”回调地狱”。

怎么解决这个问题?也就是说,异步的代码如何用同步的方式来书写?答案就是协程。

NodeJs中的协程

协程有点像线程,是运行与线程之内的。它的运行流程大致如下。

1
2
3
4
5
6
7
第一步,协程A开始执行。
第二步,协程A执行到一半,进入暂停,执行权转移到协程B。
第三步,(一段时间后)协程B交还执行权。
第四步,协程A恢复执行。

在单个线程内,协程A和协程B交互运行。这种情况类似于单CPU下的多个线程的执行。

本质上,协程就是用户空间下的线程。在NodeJs里,对于协程的支持就是Generator。

TJ Holowaychuk编写的co模块,可以帮助程序员把异步执行的代码封装成同步的写法。其原理就是利用Promise对象。将异步操作包装成Promise对象,用then方法交回执行权。可以让Generator函数的自动执行,从而实现Generator函数的自动流程管理。

关于co的原理,详细的解释参考阮一峰的博客:

就性能而言,调度协程有CPU开销,保存协程上下文有内存开销,性能可能反而不如事件驱动异步回调的编程模型。

Golang中的协程

Golang对于协程的支持则更为先进,他在语言层面实现了协程的调度器。

协程是基于线程的。内部实现上,维护了一组数据结构和n个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,有这n个线程从队列中拉出来执行。这就解决了协程的执行问题。那么协程是怎么切换的呢?答案是:golang对各种io函数进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步io函数,当这些异步函数返回busy或bloking时,golang利用这个时机将现有的执行序列压栈,让线程去拉另外一个协程的代码来执行,基本原理就是这样,利用并封装了操作系统的异步函数。包括linux的epoll,select和windows的iocp,event等。

在NodeJs中,协程的切换需要手动控制,而Golang在协程阻塞时自动切换协程,在写Golang的时候所有的代码可以都写同步代码,然后用go关键字去调用。

Golang中可以启用多个线程并行执行相同数量的协程,线程:协程 = m:n。

而NodeJs的用户代码只能跑在单线程中,无法并行执行,故无法处理计算密集型应用场景。

(完)