本文是个人在实际开发和学习中对nodejs的一些理解,现整理出来方便日后查阅,如果能给您启发将不胜荣幸。
非阻塞I/O
I/O:即 Input / Output,一个系统的输入和输出。
一个系统可以理解为一个个体,比如说一个人,你说话就是输出,你听就是输入。
阻塞 I/O 与非阻塞 I/O 的区别就在于系统接收输入再到输出期间,能不能接收其他输入。
下面以两个例子来说明什么是阻塞 I/O 和非阻塞 I/O:
1、打饭
首先我们要确定一个系统的范围,在这个例子中食堂阿姨和餐厅的服务生看成是一个系统,输入就是点菜,输出就是端菜。
那么在点菜和端菜之间能不能接受其他人的点菜,就可以判断是阻塞I/O还是非阻塞I/O。
对于食堂阿姨,他在点菜的时候,是不能帮其他同学点菜的,只有这个同学点完菜端菜走了之后,才能接受下一个同学的点菜,所以食堂阿姨是阻塞I/O。
对于餐厅服务员,他可以在点完菜以后,这个客人端菜之前是可以服务下一位客人的,所以服务员是非阻塞I/O。
2、做家务
在洗衣服的时候,是不需要等着洗衣机旁边的,这个时候可以去扫地和整理书桌,当整理完书桌后衣服也洗好了,这个时候去晾衣服,那么总共只需要25分钟。
洗衣服其实就是一个非阻塞I/O,在把衣服扔进洗衣机和洗完衣服期间,你是可以干其他事情的。
非阻塞I/O之所以能提升性能,是因为它可以把不必要的等待给节省掉。
理解非阻塞I/O的要点在于:
- 确定一个进行I/O的系统边界。这非常关键,如果把系统扩大,上面餐厅的例子,如果把系统扩大到整个餐厅,那么厨师肯定是一个阻塞 I/O。
- 在 I/O 过程中,能不能进行其他 I/O。
nodejs的非阻塞 I/O
nodejs的非阻塞 I/O 是怎么体现的呢?前面说过理解非阻塞 I/O 的一个重要点是先确定一个系统边界,node的系统边界就是主线程。
如果下面的架构图按照线程的维护划分,左边虚线部分是nodejs线程,右边虚线部分是c++线程。
现在 nodejs 线程需要去查询数据库,这是一个典型的 I/O 操作,它不会等待 I/O 的结果,而且继续处理其他的操作,它会把大量的计算能力分发到其他的c++线程去计算。
等到结果出来后返回给nodejs线程,在获得结果之前nodejs 线程还能进行其他的I/O操作,所以是非阻塞的。
nodejs 线程 相当于左边部分是服务员,c++ 线程是厨师。
所以,node的非阻塞I/O是通过调用c++的worker threads来完成的。
那当 c++ 线程获取结果后怎么通知 nodejs 线程呢?答案是事件驱动。
事件驱动
阻塞:I/O时进程休眠,等待I/O完成后进行下一步;
非阻塞:I/O时函数立即返回,进程不等待I/O完成。
那怎么知道返回的结果,就需要用到事件驱动。
所谓事件驱动可以理解为跟前端点击事件一样,我首先写一个点击事件,但是我不知道什么时候触发,只有触发的时候就去让主线程执行事件驱动函数。
这种模式也是一种观察者模式,就是我首先先监听这个事件,等触发时我就去执行。
那怎么实现事件驱动呢?答案是异步编程。
异步编程
上面说过nodejs有大量的非阻塞I/O,那么非阻塞I/O的结果是需要通过回调函数来获取的,这种通过回调函数的方式,就是异步编程。比如下面的代码是通过回调函数获取结果的:
glob(__dirname+'/**/*', (err, res) => { result = res console.log('get result') })
回调函数格式规范
nodejs的回调函数第一个参数是error,后面的参数才是结果。为什么要这么做呢?
try { interview(function () { console.log('smile') }) } catch(err) { console.log('cry', err) } function interview(callback) { setTimeout(() => { if(Math.random() < 0.1) { callback('success') } else { throw new Error('fail') } }, 500) }
执行之后,没有被捕获,错误被扔到了全局,导致整个nodejs程序崩溃了。
没有被try catch捕获是因为setTimeout重新开启了事件循环,每开启一个事件循环就重新生一个调用栈context,try catch是属于上一个事件循环的调用栈的,setTimeout的回调函数执行的时候,调用栈都不一样了,在这个新的调用栈中是没有try catch,所以这个错误被扔到全局,无法捕获。具体可以参考这一篇文章异步队列进行try catch时的问题。
那么怎么办呢?把错误也作为一个参数:
function interview(callback) { setTimeout(() => { if(Math.random() < 0.5) { callback('success') } else { callback(new Error('fail')) } }, 500) } interview(function (res) { if (res instanceof Error) { console.log('cry') return } console.log('smile') })
但是这样就比较麻烦,在回调中还要判断,所以就产生一种约定成熟的规定,第一个参数是err,如果不存在表示执行成功。
function interview(callback) { setTimeout(() => { if(Math.random() < 0.5) { callback(null, 'success') } else { callback(new Error('fail')) } }, 500) } interview(function (res) { if (res) { return } console.log('smile') })
异步流程控制
nodejs的回调写法,不仅会带来回调地域,还会带来异步流程控制的问题。
异步流程控制主要是指当并发的时候,怎么来处理并发的逻辑。还是上面的例子,如果你同事面试两家公司,只有当成功面试两家的时候,才可以不面试第三家,那么怎么写这个逻辑呢?需要全局顶一个一个变量count:
var count = 0 interview((err) => { if (err) { return } count++ if (count >= 2) { // 处理逻辑 } }) interview((err) => { if (err) { return } count++ if (count >= 2) { // 处理逻辑 } })
像上面这种写法就非常麻烦,且难看。所以,后来就出现了promise,async/await的写法。
promise
当前事件循环得不到的结果,但未来的事件循环会给你结果。很像一个渣男说的话。
promise不仅是一个渣男,还是一个状态机:
- pending
- fulfilled/resolved
- rejectd
const pro = new Promise((resolve, reject) => { setTimeout(() => { resolve('2') }, 200) }) console.log(pro) // 打印:Promise { <pending> }
then & .catch
- resolved 状态的 promise 会调用后面的第一个 then
- rejected 状态的 promise 会调用后面的第一个 catch
- 任何一个 reject 状态且后面没有 .catch 的 promise,都会造成浏览器或者 node 环境的全局错误。uncaught 表示未捕获的错误。
执行then或者catch会返回一个新的promise,该promise最终状态根据then和catch的回调函数的执行结果决定:
- 如果回调函数始终是throw new Error,该promise是rejected状态
- 如果回调函数始终是return,该promise是resolved状态
- 但如果回调函数始终是return一个promise,该promise会和回调函数return的promise状态保持一致。
function interview() { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve('success') } else { reject(new Error('fail')) } }) }) } var promise = interview() var promise1 = promise.then(() => { return new Promise((resolve, reject) => { setTimeout(() => { resolve('accept') }, 400) }) })
promise1的状态是由return里面的promise的状态决定的,也就是return里面的promise执行完后的状态就是promise1的状态。这样有什么好处呢?这样可以解决回调地狱的问题。
var promise = interview() .then(() => { return interview() }) .then(() => { return interview() }) .then(() => { return interview() }) .catch(e => { console.log(e) })
then如果返回的promise的状态是rejected,那么会调用后面第一个catch,后面的then就不会在调用了。记住:rejected调用后面的第一个catch,resolved调用后面的第一个then。
promise解决异步流程控制
如果promise仅仅是为了解决地狱回调,太小看promise了,promise最主要的作用是解决异步流程控制问题。下面如果要同时面试两家公司:
function interview() { return new Promise((resolve, reject) => { setTimeout(() => { if (Math.random() > 0.5) { resolve('success') } else { reject(new Error('fail')) } }) }) } promise .all([interview(), interview()]) .then(() => { console.log('smile') }) // 如果有一家公司rejected,就catch .catch(() => { console.log('cry') })
async/await
sync/await到底是什么:
console.log(async function() { return 4 }) console.log(function() { return new Promise((resolve, reject) => { resolve(4) }) })
打印的结果一样,也就是async/await是promse的语法糖而已。
我们知道try catch捕获错误是依赖调用栈的,只能捕获到调用栈以上的错误。但是如果使用await后能捕捉到调用栈所有函数的错误。即便这个错误是在另一个事件循环的调用栈抛出的,比如setTimeout。
改造面试代码,可以看到代码精简了很多。
try { await interview(1) await interview(2) await interview(2) } catch(e => { console.log(e) })
如果是并行任务呢?
await Promise.all([interview(1), interview(2)])
事件循环
因为nodejs的非阻塞 I/0, 所以需要利用事件驱动的方式获取 I/O 的结果,实现事件驱动拿到结果必须使用异步编程,比如回调函数。那么如何来有序的执行这些回调函数来获取结果呢?那就需要使用事件循环。
事件循环是实现 nodejs 非阻塞 I/O 功能的关键基础,非阻塞I/O和事件循环都是属于 libuv
这个c++库提供的能力。
代码演示:
const eventloop = { queue: [], loop() { while(this.queue.length) { const callback = this.queue.shift() callback() } setTimeout(this.loop.bind(this), 50) }, add(callback) { this.queue.push(callback) } } eventloop.loop() setTimeout(() => { eventloop.add(() => { console.log('1') }) }, 500) setTimeout(() => { eventloop.add(() => { console.log('2') }) }, 800)
setTimeout(this.loop.bind(this), 50)
保证了50ms就会去看队列中是否有回调,如果有就去执行。这样就形成了一个事件循环。
当然实际的事件要复杂的多,队列也不止一个,比如有一个文件操作对列,一个时间对列。
const eventloop = { queue: [], fsQueue: [], timerQueue: [], loop() { while(this.queue.length) { const callback = this.queue.shift() callback() } this.fsQueue.forEach(callback => { if (done) { callback() } }) setTimeout(this.loop.bind(this), 50) }, add(callback) { this.queue.push(callback) } }
总结
首先我们弄清楚了什么是非阻塞I/O,即遇到I/O立刻跳过执行后面的任务,不会等待I/O的结果。当I/O处理好了之后就会调用我们注册的事件处理函数,这就叫事件驱动。实现事件驱动就必须要用异步编程,异步编程是nodejs中最重要的环节,它从回调函数到promise,最后到async/await(使用同步的方法写异步逻辑)。