在了解异步之前,我们需要先来说一下同步,同步的概念是我们最为常见的,也是使用最多的,因为其编程思想符合程序员编程的顺序。但是同步式编程有一个很明显的缺陷,那就是同步阻塞,多线程模式也会有死锁,状态同步等的问题,因此,异步编程便随之而出,解决了同步阻塞的问题,并且使用单线程避免了死锁的问题。切记,单线程指的是js代码的执行是单线程,而Node本身是多线程。让我们进一步了解 异步编程的好处吧!
一. 为什么要异步 I/O
1. 运行时间
- 同步写法
// 同步写法
getData('A'); // 消耗 M ms
getData('B'); // 消耗 N ms
// 总消耗: M + N
- 异步写法
getData('A', data =>{
// 消耗 M ms
});
getData('B', data =>{
// 消耗 N ms
});
// 总消耗: Max(M, N)
从执行时间上我们就直接感受到了异步的强大,同步执行时间是各个资源消耗的总和,而异步编程执行时间是一个资源消耗的最长时间。
2. 资源分配
假设业务场景有一组互不相关的任务需要完成,现行的主流方法有两种:
- 单线程穿行依次执行:单线程顺序执行方式比较符合编程人员按顺序思考的思维方式。但是性能十分低下,很容易造成代码阻塞。
- 多线程并行完成:如果创建多线程的开销小于并行执行,那么多线程一定是首选,多线程的代价在于创建线程和执行期上下文切换开销较大。另外在复杂业务中,多线程编程经常面临锁、状态同步等问题,但是多线程在多核CPU上能够有效提升CPU的利用率。
而异步执行期望 I/O 调用不在阻塞后续运算,执行流程如下:
如上是我们期待的运行流程,但是具体应该怎么实现呢?我们继续往下看。
二. 异步 I/O 实现现状
1. 异步I/O与非阻塞I/O
操作系统对于I/O只有两种方式:阻塞与非阻塞。
阻塞I/O特性:调用之后一直等待系统内核完成所有操作,在此期间一直等待数据返回,大大浪费了CPU的性能资源。如果所示:
非阻塞I/O:调用后立即返回,CPU 的时间片可以用来处理其他事物,此时性能提升很明显的,
但是其也有缺点,虽然立即返回,但是该返回并没有拿到处理过的数据,只是拿到了一个状态,因此还得考虑,应用层如何知道数据处理完了,因此就需要接触一个新的只是,叫轮询。
轮询
轮询也就是说应用层要不断去确认数据是否处理完成,可以拿回来使用,因此,频繁判断也造成成了CPU资源的浪费,我们来看一下轮询的发展:
- read:它是最初始、性能最低的一种,通过重复调用I/O的状态来完成完整数据的读取,在得到最终数据之前,cpu 一直处于等待上
- select:它是在 read 的基础上进行了改进的方案,通过文件描述符上的事件状态来进行判断。
- poll:该方案较 select 有所改进,采用的方式避免数组长度的限制,其次他能避免不需要的检查。
- epoll:该方案是Linux下效率最高的I/O事件通知机制,在进入轮询时候如果没有检查到 I/O 事件,将会进行休眠,直到事件发生将它唤醒。它是真正的利用了事件通知、执行回调的方式,而不是进行遍历查询,所以不会浪费CPU,执行效率高,工作原理如果所示:
- kqueue:该方案实现与 epoll 类似,不过仅在FreeBSD系统下存在。
轮询技术满足了获取完整数据的需求,但是仍然算是一种同步,因为应用程序仍需要等待I/O完全返回,依旧花时间来等待。因此这种方式还是不够好。
2. 理想的非阻塞异步I/O
理想的实现是应用层调用异步方法后,直接进行其他操作,等数据完整拿到后再去执行回调,理想的事件执行流程图如下:
3. 现实的异步I/O
现实往往要比理想骨干一些,通过让部分线程进行阻塞I/O或者非阻塞I/O加轮询技术来完成数据获取,让一个线程进行计算处理,通过线程之间的通信将I/O得到的数据进行传递,这就实现了异步I/O。
- *uix下
采用了 libeio 配合 libev 实现的I/O 部分,实现了异步IO,在Node v0.9.3 中,自行实现了线程池来完成异步I/O。
- window 下的 IOCP
它在某种程度上提供了理想的异步I/O:调用异步方法,等到I/O完成后的通知,执行回调,无需进行轮询。其内部是由线程池原理,不同之处在与这些线程池由系统内核接受管理。
- 二者兼容
node 提供了 libuv 作为抽象封装层,使得所有平台兼容新的判断都由这一层来完成,并保证深层的Node 与下层自定义的线程池及IOCP 之间的独立。Node 在编译期间会判断平台的条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中去,架构如图所示:
三. Node 的异步I/O
node 实现异步I/O有使事件循环、观察者模式和请求对象等。
1. 事件循环
原理:在进程启动时,Node 便会创建一个类似于while(true)的循环,每执行一次循环体过程我们称之为一个Tick。每个 Tick 的过程就是查看是否有事件待处理,如果有就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行它们。然后进入下一个循环,如果不再有事件处理,就退出流程。
2. 观察者
观察者的作用就是用来判断是否有事件要处理。浏览器采用了类似的机制,事件可能来自用户的点击或者加载某些文件时产生,而这些事件都有各自对应的观察者。在 Node 中,事件主要来源于网络请求、文件I/O等,这些事件对应的观察者有文件I/O观察者、网络I/O观察者等。观察者将事件进行了分类。
事件循环是一个典型的生产者/消费者模型。异步I/O 、网络请求等则是事件生产者,源源不断为Node 提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件并处理。
3. 请求对象
我们的普通函数由开发者自行调用,而回调函数是由系统直接调用,那么从我们发出调用后,到回调函数执行,中间发生了什么呢?从 javascript 发起调用到内核执行完 I/O 操作的过渡过程中,存在一种中间产物,就是请求对象。
首先 JS 在调用回调之后,会调用C++ 内建模块,由 C++ 完成后续操作,创建一个 FSReqWrap 请求对象。从 JS 中传入的参数和当前方法都被封装在这个请求对象中,其中,回调函数被设置在这个对象的 oncomplete_sym 属性上:
req_wrap -> object_ -> Set(oncomplete_sym, callback);
对象包装完毕后,在 windows 下,则调用QueueUserWorkItem() 方法将这个 FSReqWrap 对象推入线程池中等待执行,该方法代码如下所示:
QueueUserWorkItem(&uv_fs_thread_proc, req, WT_EXECUTEDEFAULT);
该方法接受三个参数,第一个是将要执行的方法的引用,第二个是第一个方法的参数,第三个参数是执行的标志。
至此 JS 调用立即返回,由 JS 层面发起的异步调用第一阶段就此结束,JS 可以执行后续操作,后续操作交给了其他线程,这样就达到了异步的目的。
请求对象是异步I/O 过程中重要的中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。
4. 执行回调
线程中的I/O操作调用完毕之后,会将获取的结果储存在 req-> result 属性上,然后调用 PostQueuedCompletionStatus() 通知 IOCP,告知当前对象操作已经完成:
PostQueuedCompletionStatus((loop)->iocp, 0, 0, &((req)->overlapped));
PostQueuedCompletionStatus()方法的作用就是向IOCP 提交执行状态,并将线程归还线程池。通过该方法提交的状态,可以通过 GetQueuedCompletionStatus() 提取。
在这个过程中我们还动用了事件循环的I/O 观察者。在每次Tick的执行中,他会调用IOCP相关的GetQueuedCompletionStatus() 方法检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到I/O观察者的队列中,然后将其当作事件处理。
IO 观察者回调函数的行为就是取出请求对象的 result 属性作为参数,取出 oncomplete_sym 属性作为方法,然后调用执行,以此达到调用JS中传入回调函数的目的。
至此,整个异步IO 调用流程全部结束。
小结:事件循环、观察者、请求对象、IO 线程池共同构成了Node异步IO模型的基本要素。在Node中,除了 JS 是单线程的,Node自身是多线程,只是IO线程使用的CPU较少。另一个需要知道的是,除了用户代码无法并行执行,所有的IO都是可以并行执行的。
四. 非 I/O 的异步API
除了异步I/O外,还有一些与I/O 无关的异步API,他们分别是setTimeout()、setInterval()、setImmediate() 和 process.nextTick()。
1. 定时器
与异步I/O 不同的是它没有线程池的参与,调用定时器后,将回调插入到定时器观察者内部的一个红黑树中。每次 Tick 执行,会从该红黑树中迭代取出的定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数立即执行。
明白这个后,我们就明白了为什么定时器执行为什么每次都有偏差。尽管事件循环十分快,但是如果某次定时器还有1ms 超过定时,因此不会执行,而下一个循环占用的时间较多,那么下次轮到这个事件时,已经超时很久了,所以就会与我们预期设置的时间有偏差。
2. process.nextTick()
有时候我们也许需要立即执行一个异步任务,会这样调用
setTimeout(()=>{}, 0);
由于事件循环自身特点,定时器的精确度不够。除此之外还用动用红黑树,创建定时器对象和迭代等操作,十分浪费性能。而process.nextTick() 方法相对轻量,将回调放入一个队列中,在下一次Tick 时取出来执行。采用定时器红黑树时间复杂度为O(lgn),nextTick 的时间复杂度为O(1),相比之下,nextTick 更高效。
3. setImmediate()
该方法同 process.nextTick() 相同,都是异步执行,但是 setImmediate () 是将回调保存在链表中,而process.nextTick 是保存在数组中,并且process.nextTick 每次循环会将数组中的回调函数全部执行完,而 setImmediate 只执行一个,且 process.nextTick 的执行优先级更高。
setImmediate(()=>{
console.log('setImmediate 执行1');
process.nextTick(()=>{
console.log('process.nextTick 强行插入')
})
})
process.nextTick(()=>{
console.log('process.nextTick 执行1')
})
setImmediate(()=>{
console.log('setImmediate 执行2');
})
process.nextTick(()=>{
console.log('process.nextTick 执行2')
})
console.log('正常执行');
执行结果:
五. 事件驱动与高性能服务器
Node 异步同时用在了网络套接字的处理,将请求形成事件交给I/O观察者。事件循环会不停的处理这些网络I/O事件。因此不会形成请求堵塞,成就了高性能服务器。因此在大型项目中,我们经常用Node 作为中间层,去处理这些大量的请求,会大幅提升一个程序的服务质量。
六. 总结
事件循环是实现异步的核心,它与浏览器中的执行模式保持一致,Node 正是依靠构建了一套完整的高性能异步I/O框架,打破了javascript 在服务器端止步不前的局面。