一、阻塞IO、非阻塞IO、异步IO
阻塞IO与非阻塞IO的区别
1. 文件描述符概念:文件描述符是系统内核处理IO操作时与应用程序之间用于信息交互的一个凭证,应用程序进行IO调用时,需要先打开文件描述符,然后根据文件描述符中的状态去实现文件数据的读写;
2. 阻塞IO:应用程序调用IO后,等待系统进行IO操作,完成整个读写过程后才返回文件描述符;
3. 非阻塞IO:系统进行IO操作时,直接返回文件描述符,但此时文件描述符还没有携带任何返回数据与状态,要获取数据,必须通过文件描述符再次获取,而为了确定获取数据的完整性,我们必须不停的反复去通过文件描述符再次获取,这就是一个轮询的过程,大家可以想象早期没用socket的聊天室,就是采用这种 heart beat 方式的解决方案。非阻塞IO的轮询原理的几种方案
由于不停的通过文件描述符去轮询,所以我们不可避免的要不停的去判断文件描述符的状态,这个过程是对CPU的资源浪费,所以以下方案目的就是为了降低这个过程对CPU的损耗- read:最原始的轮询,就是 heart-beat 的方式不停的轮询,没有任何优化,这也是最早期的方式;
- select:利用一个数组来记录文件描述符的轮询状态,待所有文件描述符的状态都是已获取数据时再返回,但 select 方法的限制也局限在数组长度是 1024 这个限制上,比 read 方式有了一层状态存储的判断,减少了对文件描述符的操作次数;
- poll: 与 select 方法一样,但是换成了利用链表来存储状态,没有了长度限制,但一旦文件过大时,性能仍然堪忧;
- epoll:基于 linux 下效率最高的 IO 事件通知机制,类似于 socket 的解决方案,当应用程序调用一次 IO 后,CPU调用系统内核执行 IO 后,如果没有消息返回则会处于 休眠 状态,此时 CPU 没有损耗来去判断文件描述符的状态,直到有了消息或称为数据后,再通过事件机制唤醒 CPU 返回数据进入文件描述符,丢回给应用程序;
上述几种方案都是轮询的方案,大家会发现一个共同的缺点,应用程序仍然需要等待 IO 返回完整数据才能进行其他操作,还是,CPU 要么是在遍历文件描述符状态,要么就是闲置,这并不是我们想要的方案,我们需要的应该是一种异步 IO 的方案,即进行 IO 时我们直接进行其他的函数处理,待 IO 返回后自动执行我们设置好的回调事件去处理完整数据,而等待这个过程的时间,CPU 已经在应用程序内去处理我们其他的工作了。
现实中的异步IO方案
异步 IO 的实现不论是 linux 下还是 windows 下,其实都是基于线程池的,我们将 I/O 的过程都放分到每个线程池的新线程中,而如今的 node 则是通过 libuv 跨平台实现不同的异步 IO 处理方式,libuv 会判断当前运行环境是在 linux 还是 windows,若在 linux 则会采用自行定义的线程池来处理,如果是 windows 则会直接利用 IOCP 方案,有些同学可能会疑惑我们前面提到过 nodeJs 与 javascript 一样是单线程的,为什么还有线程池概念呢?答案是 单线程指的是 javascript 的执行是单线程,注意是 js 的执行过程是单线程,不是指其中的 IO 或 Node 本身是单线程,实际上 Node 本身是多线程,从libuv 库跨平台调用后分别使用的线程池方案就可以看的出来。
二、Node环境下的事件循环(event loop)机制(异步实现核心重点)
在浏览器环境下的 js 执行中,我们知道有几个异步 api 来帮助我们解决异步队列的问题
- setTimeout
- Promise
- generator(yeild)
- async await
这些可以解决这个问题,而那道经典的循环输出 setTimeout 中结果的作用域、闭包题也是基于这个原理,但是大家不能只知道它是异步的,还更应该了解它们在事件循环下的调用栈顺序是怎样的,原理又是怎样的,才能帮助大家再次在此类问题上犯错
不论 runtime 在 浏览器 下还是在 Node 下,都是事件循环机制作为异步循环基础的,大家需要清楚明白的了解 macro task 与 micro task
中各个异步 api 属于哪类任务,在进入调用栈的顺序是怎样执行的,这可以说是 js 的一个基础知识了,我遇到过很多同学都说不出来,所以我推荐大家一定要好好看我下面推荐的这篇博客,这是我以往看过讲的最好的一篇事件循环机制原理的文章,我曾经看的时候是拿着笔在本子上手画调用栈来学懂的,希望大家也能花一定的时间去弄懂它,因为这个基础知识点真的很重要!
大家有兴趣也可以将这位大神的基础知识部分章节全部补完,我看到也发现他前面写的一些知识点竟然跟我以前写的博客一模一样,所以我认为这些知识绝对是 js 的基础重点了。
在这个知识点上,我会进行如下的一些重点补充
macro task列表
- main script(脚本主函数)
- setTimeout
- setInterval
- setImmediate
- IO
- UI rendering
micro task列表
- process.nextTick
- Promise(Promise 构建函数内的执行属于 主函数顺序,是先执行的)
- MutationObserver(H5 新 api ,用于检测 dom 树是否发生变化,vue 核心源码中有采用)
同调用栈条件下,macro task(宏任务) 的队列调用返回顺序
- 主函数
- setTimeout 队列
- setImmediate 队列
- IO回调函数
同宏任务下,micro task(微任务) 的队列调用返回顺序
- process.nextTick
- promise.then
- 同宏任务队列下,会将微任务队列全部执行完才会开始下一宏任务队列,记住是同一宏任务队列主函数执行完
比如在 2 个 setTimeout 的情况下都是 proccess.nextTick ,此时 2 个 setTimeout 都属于 setTimeout 队列,所以会先将 2 个 setTimeout 内的主函数都执行完之后,才顺序执行微任务队列
下面我也有道额外的题来讲解这个过程,如果大家看完上面那篇博客,应该会和我分析的是一样的结果
setImmediate(() => {
console.log('set1')
process.nextTick(() => {
console.log('set1 -> nextTick1')
process.nextTick(() => {
console.log('set1 -> nextTick1 -> nextTick1')
})
})
process.nextTick(() => {
console.log('set1 -> nextTick2')
})
})
fs.stat(path.join(__dirname, 'view/index.html'), (err, stats) => {
console.log('stats')
})
fs.stat(path.join(__dirname, 'view/index.html'), (err, stats) => {
console.log('stats2')
})
setTimeout(() => {
console.log('timeout')
})
process.nextTick(() => {
console.log('nextTick1')
process.nextTick(() => {
console.log('nextTick1 -> nextTick1')
})
})
process.nextTick(() => {
console.log('nextTick2')
process.nextTick(() => {
console.log('nextTick2 -> nextTick1')
})
})
setImmediate(() => {
console.log('set2')
process.nextTick(() => {
console.log('set2 -> nextTick1')
})
process.nextTick(() => {
console.log('set2 -> nextTick2')
})
})
好,我们首先先列出宏任务顺序队列及对应微任务队列(调用栈中,每个队列内的代码块都代表队列中的一个子集)
宏任务(macro task) | 微任务(micro task) |
---|---|
主函数 | nextTick1 nextTick2 nextTick1 内 nextTick nextTick2 内 nextTick |
setTimeout | (无队列,因为只有setTimeout内主函数执行完便没有任何内部队列了) |
setImmediate1 setImmediate2 | set1 中 nextTick1 set1 中 nextTick2 set2 中 nextTick1 set2 中 nextTick2 set1 中 nextTick1 中 nextTick |
IO1 IO2 | (无队列,只有回调函数输出) |
分析完毕后,我们可以得出最后输出打印结果应该为
- nextTick1
- nextTick2
- nextTick1 => nextTick1
- nextTick2 => nextTick1
- timeout
- set1
- set2
- set1 => nextTick1
- set1 => nextTick2
- set2 => nextTick1
- set2 => nextTick2
- set1 => nextTick1 => nextTick1
- stats 或 stats2 (IO 回调顺序由CPU读取顺序决定,2个IO回调没有优先级排名,不一定先后,此时我们通常要利用异步API来控制输出顺序,在此我只写结果,解决方案用 promise、 nextTick、async await 封装都可以)
- 上次输出为 stats ,这次就为 stats2,否则相反。
三、最后2个额外的小点
定时器是有可能超时执行的:本质上来说,我们是通过事件循环机制去检测时间的,当循环过程中,有某些任务如果一直占用 CPU 的执行,而没有其他 CPU 资源去执行其他时间检测等任务,便会发生延迟执行定时器内任务
书中 63 页的代码佐证最后输出结果错误,正确结果应为
正确执行
nextTick延迟执行1
nextTick延迟执行2
setImmediate延迟执行1
setImmediate延迟执行2
强势插入
在 setImmediate 的宏任务下,应为所有 setImmediate 队列主函数都执行完毕,才按照微任务声明顺序执行,下面贴上书中图片及实际代码验证结果,一开始我怀疑是 node 版本更新修改过底层时间循环的代码,但是 commit 和 更新日志太多了,我就没一一去看,后来我提了 issue,也得到了朴灵老师的解答。
P63 页 next_tick_set_immediate.js 书中执行结果错误