Node.js 10+ 版本后 在运行结果上与浏览器是一致的,但两者在原理上一个是基于libuv库上,一个是基于浏览器。浏览器的核心是宏任务和微任务,而 Node.js 还有阶段性任务执行。
事件循环就类似一个无限的while循环,假设我们要开发一个业务涉及到while循环,我们可能需要思考以下几个问题:
带着这些问题,我们来瞧瞧 Node.js 10+ 官网的事件循环原理的核心流程图
该阶段执行由setTimeout()和setInterval()这两个函数启动的回调函数
该阶段执行某些系统操作的回调函数,如TCP错误类型
仅系统内部使用
主要处理异步 I/O(网络 I/O和文件 I/O)的回调函数,以及其它回调函数
该阶段执行setImmediate()的回调函数。setImmediate并不是立马执行,而是当事件循环 poll 中没有新的事件处理时才执行该部分,即先执行回调函数,再执行setImmediate
执行一些关闭的回调函数,如 socket.on('close',...)
Node.js事件循环的发起点有如下四个:
换句话说:当Node.js 进程启动后,就发起了一个新的事件循环,即事件循环的起点。可为何?下面的代码在执行时先输出2再输出1呢?
setTimeout(() => { console.log('1'); // 该回调函数是新一轮事件循环的起点 }, 0); console.log('2');
这里有个小点需要注意,当Node.js启动后,会初始化事件循环,处理已提供的输入脚本,它可能会先调用一些异步的API、调度定时器,或者 process.nextTick(),然后再处理事件循环
Node.js事件循环有一个核心的主线程,它的执行阶段主要处理三个核心逻辑:
const fs = require('fs'); // 主流程执行完成后,超过1ms时,会将setTimeout回调函数逻辑插入到待执行回调函数 poll 队列中 setTimeout(() => { console.log("setTimeout100") // 文件 I/O fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file sync100 success'); }); }, 100); // setTimeout 如果不设置时间或者设置时间为0,则会默认为1ms setTimeout(() => { console.log("setTimeout0") // 文件 I/O fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file sync0 success'); }); }, 0); // 文件 I/O 优先级高于 setTimeout,但处理事件长于1ms fs.readFile('./test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file success'); }); // 微任务 Promise.resolve().then(()=>{ console.log('Promise callback'); }); // 微任务,process.nextTick 优先级高于 Promise process.nextTick(()=>{ console.log("process callback") }) // 主流程 console.log('start');
当所有的微任务和宏任务都清空的时候,即当前没有任务可执行,也无法代表循环结束,可能存在当前还未回调的异步I/O,因此该循环时没有终点的,只要进程在,且新的任务存在,就会去执行
假设我们在setTimeout中新增一个阻塞逻辑,只有等待当前事件循环结束后,才执行fs.readFile回调函数
const fs = require('fs'); setTimeout(() => { // 新的事件循环的起点 console.log('1'); sleep(10000) console.log('sleep 10s'); }, 0); // 将会在 poll 阶段执行 fs.readFile('./config/test.conf', {encoding: 'utf-8'}, (err, data) => { if (err) throw err; console.log('read file success'); }); // 阻塞逻辑 function sleep ( n ) { var start = new Date().getTime() ; while ( true ) { if ( new Date().getTime() - start > n ) { break; } } }
从输出现象会发现,fs.readFile虽已处理完且通知回调到主线程,但主线程由于在处理回调时被阻塞了,导致无法处理fs.readFile。接下来,我们来印证一下,将 setTimeout 的时间更改为 10ms,则输出
你会优先看到fs.readFile的回调函数,这是因为fs.readFile执行完成了,还没启动下一个事件循环
Node.js不善于处理CPU密集型的业务,易导致性能问题,我们分别执行主线程和异步I/O处理一个耗时CPU的计算(计算从0到1,000,000,000之间的和),比对各自的效果
执行时间 total为1.084-1.090
执行时间 total为 0.562-0.597
异步网络I/O充分利用了Node.js的异步事件驱动能力,将耗时CPU计算逻辑分配给其它进程处理,因此主线程可直接处理其它请求逻辑,而在主流程执行耗时CPU计算,导致其无法处理其他逻辑,进而影响性能,因此上面服务的执行时间相差甚远
遍历Node.js事件循环当前事件是在主线程,而主线程是单线程执行的,而异步I/O事件、setTimeout以及垃圾回收、内存优化等则是多线程执行。
基于Node.js事件循环的原理,我们在使用Node.js时应减少或者避免在Node.js主线程中被阻塞以及进行一些大内存(V8 内存上限三1.4G)和CPU密集的场景,比如图片处理、大字符串、大数组类处理、大文件读写处理等等。
Node.js的优势在于其异步事件驱动能力较强,能够处理更高的并发,因此我们可以寻找网络I/O处理多、CPU计算少,业务复杂度高的服务
处理业务相关的通用逻辑,比如通用的协议转化、通用的鉴权处理以及其他一些业务安全处理
在上面开放API的应用场景中,粉色框内的功能都是基于缓存来处理业务逻辑,大部分是网络I/O,并未涉及CPU密集逻辑。因此这类轻CPU运算服务在技术选型上可考虑Node.js作为服务端语言
运营系统往往逻辑复杂,需根据业务场景进行多次迭代、优化,并发高,但可不涉及底层数据库的读写,更多的是缓存数据的处理,如投票活动
中台的概念是将应用中一些通用的业务服务进行集中,其着重关注:网络I/O(高低都可)、并发(高低都可)、通用性(必须好)以及业务复杂度,一般情况下不涉及复杂的CPU运算(低运算),比如常见的中台业务系统
系统 | 通用性 | CPU计算 | 网络I/O | 并发 |
---|---|---|---|---|
前端配置系统 | 是 | 否 | 低 | 高 |
反馈系统 | 是 | 否 | 高 | 低 |
推送系统 | 是 | 否 | 低 | 低 |
系统工具 | 是 | 否 | 低 | 低 |
这样的系统在Node.js主线程中,可快速处理各类业务场景,不会存在阻塞的情况