本文深入解析JavaScript单线程模型下的事件循环机制,对比Chrome浏览器与Node.js环境中setTimeout与Promise的执行差异。通过具体实例分析,阐明宏任务与微任务的优先级规则,并探讨Node.js基于libuv库的线程池工作原理及其对高并发场景的影响。
JavaScript与Node.js事件循环机制概述
在与同事探讨代码执行顺序时,我们关注到了JavaScript中的event loop机制。这一讨论引出了一个有趣的现象:当代码中同时包含setTimeout和Promise时,Chrome浏览器与Node.js环境下的执行结果存在显著差异。这背后涉及Node.js特有的event loop机制,本文对此进行记录与梳理,旨在厘清其中的执行逻辑。
console.log(1)
setTimeout(function() {
new Promise(function(resolve, reject) {
console.log(2)
resolve()
})
.then(() => {
console.log(3)
})
}, 0)
setTimeout(function() {
console.log(4)
}, 0)
// chrome中运行:1 2 3 4
// Node中运行: 1 2 4 3
Chrome与Node.js在执行结果上的不一致,揭示了两者底层运行机制的不同,值得深入探究。
JavaScript单线程与任务队列模型
1. JavaScript的任务队列模型
JavaScript采用单线程模型,即同一时刻仅能执行一个任务。为何不采用多线程以提升效率?这与JavaScript的设计初衷密切相关。
JavaScript最初作为浏览器脚本语言,核心职责是与用户交互及操作DOM。若允许多线程同时操作DOM,将引发复杂的同步冲突。例如,一个线程添加节点而另一个线程删除该节点,浏览器无法确定以哪个线程的操作为准。
为避免此类复杂性,JavaScript自诞生起便确立为单线程模型,这一核心特征预计将持续存在。
为利用多核CPU算力,HTML5引入了Web Worker标准,允许创建子线程。但子线程受主线程严格控制,且禁止操作DOM,因此并未改变JavaScript单线程的本质。
2. 任务队列与事件循环(Event Loop)
单线程意味着所有任务必须排队,前一个任务结束,后一个任务才能开始。若前一个任务耗时过长,后续任务将被迫等待。
任务由此分为同步任务(synchronous)与异步任务(asynchronous)。同步任务在主线程上按序执行,前一个任务完成后才能执行下一个;异步任务不进入主线程,而是进入“任务队列”(task queue)。只有当任务队列通知主线程异步任务可执行时,该任务才会进入主线程。
异步执行的具体运行机制如下(同步任务可视为无异步任务的特殊异步执行):
所有同步任务在主线程上执行,形成执行栈(execution context stack)。主线程外存在“任务队列”(task queue)。异步任务产生结果后,会在任务队列中放置一个事件。当执行栈中的所有同步任务执行完毕,系统读取任务队列中的事件,对应的异步任务结束等待状态,进入执行栈开始执行。主线程不断重复此过程。

只要主线程空闲,便会读取“任务队列”,这就是JavaScript的运行机制,该过程循环往复。
定时器机制:setTimeout与setInterval
3. 定时器机制 setTimeout与setInterval
定时器功能主要由setTimeout()和setInterval()两个函数实现,两者内部运行机制一致,区别在于前者代码仅执行一次,后者则反复执行。
setTimeout(fn,0)表示指定任务在主线程最早可得空闲时间执行,即尽可能早地执行。它在“任务队列”尾部添加事件,因此需等待同步任务和现有事件处理完毕后才执行。
HTML5标准规定setTimeout()第二个参数的最小值(最短间隔)不得低于4毫秒,若低于此值则自动增加。此前老版本浏览器最短间隔设为10毫秒。此外,DOM变动(尤其是涉及页面重渲染部分)通常不会立即执行,而是每16毫秒执行一次。此时使用requestAnimationFrame()的效果优于setTimeout()。
需注意,setTimeout()仅将事件插入“任务队列”,必须等待当前代码(执行栈)执行完毕,主线程才会执行指定的回调函数。若当前代码耗时较长,回调函数可能延迟很久才执行,因此无法保证回调函数一定在setTimeout()指定的时间执行。
Node.js Event Loop运行机制解析
4. Node.js的事件循环机制
事件轮询主要针对事件队列进行轮询。事件生产者将事件放入队列,队列另一端的线程(事件消费者)不断查询队列中是否有事件。若有事件,立即执行。为防止堵塞操作影响当前线程读取队列,事件消费者线程会委托线程池专门执行这些堵塞操作。

JavaScript前端与Node.js机制类似,均基于事件轮询模型。有人误认为Node.js是单线程,即事件消费者为单线程不断轮询,若遇到堵塞操作是否会阻塞当前单线程执行?
实际上,Node.js底层拥有线程池,专门执行各种堵塞操作,从而不影响单线程主线程进行队列事件轮询及任务执行。线程池操作完成后,会将结果作为事件生产者放入同一队列中。
综上,一个事件轮询(Event Loop)需要三个核心组件:
事件队列(Event Queue),采用FIFO模型,一端推入事件数据,另一端拉出事件数据,两端仅通过队列通信,属于异步松耦合。队列读取轮询线程,即事件消费者,是Event Loop的主角。独立的线程池(Thread Pool)专门执行长任务、重任务等繁重工作。
Node.js同样采用单线程Event Loop,但其运行机制与浏览器环境不同。

根据图示,Node.js的运行机制如下:
V8引擎解析JavaScript脚本,解析后的代码调用Node API。libuv库负责Node API的执行,它将不同任务分配给不同线程,形成Event Loop(事件循环),以异步方式将任务执行结果返回给V8引擎,V8引擎再将结果返回给用户。
可见,Node.js的核心实际上是libuv库。该库由C语言编写,支持多线程技术,而我们的JavaScript应用保持单线程。
Node.js异步任务执行流程:

用户编写的代码是单线程的,但Node.js内部并非单线程!
事件机制:
Node.js并非为每个请求使用多个线程执行工作,而是将所有工作添加到一个事件队列中,由单独线程循环提取队列中的事件。事件循环线程抓取队列中最上方的条目执行,然后抓取下一个条目。当执行长期运行或有阻塞I/O的代码时:
在Node.js中,由于仅有一个单线程不断轮询队列中是否有事件,对于数据库、文件系统等I/O操作及HTTP请求等易堵塞等待的操作,若也在该单线程中实现,必然堵塞并影响其他工作任务。JavaScript/Node.js会委托底层线程池执行,并告知线程池一个回调函数,单线程继续执行其他任务。当这些堵塞操作完成后,其结果与提供的回调函数一起放入队列中。当单线程从队列中不断读取事件,读取到这些堵塞操作结果后,会将这些操作结果作为回调函数的输入参数,然后激活运行回调函数。
请注意,Node.js的单线程不仅负责读取队列事件,还负责执行回调函数,这是其区别于多线程模式的主要特点。多线程模式下,单线程仅负责读取队列事件,不再执行其他任务,而是委托其他线程执行,特别是在多核情况下,一个CPU核负责读取队列事件,另一个CPU核负责执行激活的任务,这种方式最适合高CPU计算任务。反之,Node.js的执行激活任务(即回调函数中的任务)仍在负责轮询的单线程中执行,这注定其无法执行CPU繁重的任务(如JSON转换为其他数据格式等),因为这些任务会影响事件轮询的效率。
Node.js特性与适用场景分析
5. Node.js的特点

Node.js的显著特点:异步机制、事件驱动。
事件轮询过程不会阻塞新用户的连接,也无需维护连接。基于此机制,理论上Node.js可陆续响应用户请求连接,因此能支持比Java、PHP程序更高的并发量。
虽然维护事件队列需要成本,且由于Node.js是单线程,事件队列越长,响应时间越长,并发量提升后仍会力不从心。
RESTful API是Node.js最理想的应用场景,可处理数万条连接,本身逻辑不多,只需请求API并组织数据返回即可。
Chrome与Node.js执行结果差异实例
6. 实例分析
观察一个具体实例:
console.log('1')
setTimeout(function() {
console.log('2')
new Promise(function(resolve) {
console.log('4')
resolve()
}).then(function() {
console.log('5')
})
setTimeout(() => {
console.log('haha')
})
new Promise(function(resolve) {
console.log('6')
resolve()
}).then(function() {
console.log('66')
})
})
setTimeout(function() {
console.log('hehe')
}, 0)
new Promise(function(resolve) {
console.log('7')
resolve()
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9')
new Promise(function(resolve) {
console.log('11')
resolve()
}).then(function() {
console.log('12')
})
})
new Promise(function(resolve) {
console.log('13')
resolve()
}).then(function() {
console.log('14')
})
// node1 : 1,7,13,8,14,2,4,6,hehe,9,11,5,66,12,haha // 结果不稳定
// node2 : 1,7,13,8,14,2,4,6,hehe,5,66,9,11,12,haha // 结果不稳定
// node3 : 1,7,13,8,14,2,4,6,5,66,hehe,9,11,12,haha // 结果不稳定
// chrome : 1,7,13,8,14,2,4,6,5,66,hehe,9,11,12,haha
Chrome运行结果较为稳定,而Node环境下运行不稳定,可能出现两种情况。
Chrome运行结果的原因在于Promise、process.nextTick()的微任务Event Queue权限高于普通宏任务Event Queue。在从事件队列取事件时,若有微任务,先执行微任务队列中的任务,除非该任务在下一轮Event Loop中。微任务队列清空后再执行宏任务队列中的任务。