系统的异步I/O实现现状
异步I/O与非阻塞I/O
从计算机内核I/O而言,异步/同步和阻塞/非阻塞实际上是两回事。
操作系统内核对于I/O只有两种方式:
- 阻塞。调用阻塞I/O时,应用程序需要等待I/O完成才返回。
- 非阻塞。调用阻塞I/O时,会立即返回。
非阻塞需要通过轮询去确认是否完全完成数据获取。
理想的非阻塞异步I/O = 非阻塞 + 异步 :应用程序发起非阻塞调用,无须通过遍历或者事件唤醒等方式轮询,可以直接处理下一个任务,只需在I/O完成后通过信号或回调将数据传递给应用程序。
Node的异步I/O
事件循环
Node进程启动时,Node便会创建一个类似while(true)的循环,每执行一次循环体的过程我们称为Tick。每个Tick的过程就是查看是否有事件待处理,如果有,就取出事件及其相关的回调函数。如果存在关联的回调函数,就执行他们。然后进入下一个循环,如果不再有事件处理,就退出进程。流程图如下:
观察者
事件循环是一个典型的生产者/消费者模型。异步I/O、网络请求等则是事件的生产者,源源不断为Node提供不同类型的事件,这些事件被传递到对应的观察者那里,事件循环则从观察者那里取出事件处理。
在Windows下,这个循环基于IOCP创建,而在*nix下则基于多线程创建。
请求对象
从Javascript发起调用到内核执行完I/O操作的过渡过程中,存在一种中间产物,它叫请求对象。
下面以最简单的fs.open()
(windows下)方法为例,探索Node与底层之间如何执行异步I/O调用以及回调函数究竟是如何被调用执行:
1 | fs.open = function(path, flags, mode, callback) { |
调用示意图如下:
在libuv封装层中,uv_fs_open()
的调用过程中,我们创建了一个FSReqWrap
请求对象。从Javascript层传入的参数和回调函数都被封装在这个请求对象中。对象包装完毕后,在Windows平台下,则调用QueueUserWorkItem()
方法将这个FSReqWrap
对象推入线程池中等待执行。
至此,Javascript调用立即返回,由Javascript层面发起的异步调用的第一阶段就此结束。Javascript线程可以继续执行当前任务的后续操作。当前的I/O操作在线程池中等待执行,不会影响Javascript线程,如此达到异步的目的。
请求对象是异步I/O过程中的重要中间产物,所有的状态都保存在这个对象中,包括送入线程池等待执行以及I/O操作完毕后的回调处理。
执行回调
组装好请求对象,送入I/O线程池等待执行,这是第一部分,回调通知是第二部分。
线程池中的I/O操作调用完毕之后,会将获取的结果储存在请求对象的result属性上,然后通过IOCP,告知当前对象操作已经完成。
这个过程中,还动用了事件循环的I/O观察者。在每次Tick的执行中,观察者会检查线程池中是否有执行完的请求,如果存在,会将请求对象加入到I/O观察者的队列中,然后将其当做事件处理。
I/O观察者回调函数的行为就是取出请求对象的result属性作为参数,取出回调函数,然后调用执行,以此达到调用Javascript中传入的回调函数的目的。
至此,整个异步I/O的流程完全结束。
事件循环、观察者、请求对象、I/O线程池这四者共同构建了Node异步I/O模型的基本要素。
非I/O的异步API
setTimeout()
、setInterval()
、setImmediate()
、process.nextTick()
定时器
setTimeout()
、setInterval()
的实现原理与异步I/O比较类似,只是不需要I/O线程池的参与。创建的定时器被插入到定时器观察者内部的一个红黑树中。每次Tick执行时,会从该红黑树中迭代取出定时器对象,检查是否超过定时时间,如果超过,就形成一个事件,它的回调函数立即执行。
process.nextTick()、setImmediate()
process.nextTick()
、setImmediate()
十分类似,都是将回调函数延迟执行。但是process.nextTick()
属于微任务micro-task。
⚠️注意 :node版本更新到11,Event Loop运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout,setInterval和setImmediate)就立刻执行微任务队列,这点就跟浏览器端一致。
- 从结果来看,
process.nextTick()
中的回调函数执行的优先级要高于setImmediate()
; - 在具体实现上,
process.nextTick()
的回调函数保存在一个数组中,setImmediate()
的回调则是保存在链表中; - 行为上,
process.nextTick()
在每轮循环中会将数组中的回调函数全部执行完,而setImmediate()
在每轮循环中执行链表中的一个回调函数。
事件驱动与高性能服务器
从异步实现的原理,可以勾勒出事件驱动的实质:
通过主循环加事件触发的方式来运行程序。
利用Node构建Web服务器,正是在这样一个基础上实现,其流程如图:
3种经典的服务器模型,对比一下它们的优缺点:
- 同步式:一次只能处理一个请求,并且其余请求都处于等待状态;
- 每进程/每请求:为每个请求启动一个进程,这样可以处理多个请求,但是它不具备扩展性,因为系统资源不够;
- 每线程/每请求:为每个请求启动一个线程。线程比进程要轻,但线程也会占用一定内存,当高并发时,内存会很快用光。代表软件Apache。
总结
事件循环是异步实现的核心,node11之后与浏览器中的执行模型几乎完成一致。