JavaScript运行机制之事件循环(Event Loop)详解

作者:岩茶

时间:2026-10-29

浏览:194

本文详细解析JavaScript单线程模型下的事件循环机制。从单线程设计的必要性出发,阐述同步与异步执行的区别,深入剖析执行栈、任务队列及回调函数的运作流程。同时对比浏览器环境与Node.js中Event Loop的差异,重点解析setTimeout、setImmediate与process.nextTick的执行顺序及底层原理。

文章目录

JavaScript单线程设计原理

一、JavaScript为何采用单线程模型?

JavaScript语言的核心特性之一是单线程,即同一时刻仅能执行一项任务。为何不设计为多线程以提升效率?

JavaScript的单线程特性源于其应用场景。作为浏览器脚本,其主要职责是与用户交互及操作DOM。若允许多线程,将引发复杂的同步问题,例如一个线程添加DOM节点而另一个删除该节点时,浏览器无法确定以哪个线程的操作为准。因此,为规避复杂性,JavaScript自诞生起便确立为单线程,且这一核心特征未来不会改变。

为利用多核CPU算力,HTML5引入Web Worker标准,允许JavaScript创建多个线程。然而,子线程完全受控于主线程,且禁止操作DOM。故此标准并未改变JavaScript单线程的本质。

任务队列与异步执行机制

二、任务队列机制

单线程意味着所有任务必须排队,前一个任务结束后才能执行后一个。若前一个任务耗时过长,后续任务将不得不等待。

若排队源于计算量大导致CPU繁忙,尚可理解;但常见情况是CPU空闲,因IO设备(如Ajax从网络读取数据)响应缓慢,必须等待结果返回后继续执行。

JavaScript设计者意识到,此时CPU可忽略IO设备,挂起等待中的任务,先执行后续任务。待IO设备返回结果后,再恢复执行挂起的任务。

由此,JavaScript衍生出两种执行模式:一是CPU按顺序执行,前一个任务结束后再执行下一个,称为同步执行;二是CPU跳过耗时长的任务,优先处理后续任务,称为异步执行。程序员可根据需求选择执行方式。

具体而言,异步执行的运行机制如下(同步执行亦可视为无异步任务的异步执行)。

(1)所有任务均在主线程上执行,形成执行栈(execution context stack)。 (2)主线程外存在一个“任务队列”(task queue)。系统将异步任务放入该队列,随后继续执行后续任务。 (3)一旦“执行栈”中所有任务执行完毕,系统便读取“任务队列”。若此时异步任务已结束等待状态,则从“任务队列”进入执行栈并恢复执行。 (4)主线程不断重复上述第三步。

下图展示了主线程与任务队列的关系。

JavaScript单线程设计原理

只要主线程空闲,便会读取“任务队列”,此即JavaScript的运行机制,该过程会不断重复。

事件、回调函数与Event Loop

三、事件与回调函数

“任务队列”实质上是事件队列(或消息队列)。IO设备完成一项任务,便在“任务队列”中添加一个事件,表明相关异步任务可进入“执行栈”。主线程读取“任务队列”,即检查其中包含的事件。

“任务队列”中的事件除IO设备事件外,还包括用户产生的事件(如鼠标点击、页面滚动等)。只要指定了回调函数,这些事件发生时便会进入“任务队列”,等待主线程处理。

所谓“回调函数”(callback),即那些会被主线程挂起的代码。异步任务必须指定回调函数,当异步任务从“任务队列”返回执行栈时,回调函数随即执行。

“任务队列”是先进先出(FIFO)的数据结构,排在前面的事件优先返回主线程。主线程的读取过程基本自动化,只要执行栈清空,“任务队列”中首位事件便自动返回主线程。然而,由于存在“定时器”功能,主线程需检查执行时间,某些事件必须在规定时间返回主线程。

四、Event Loop(事件循环)

主线程从“任务队列”中读取事件的过程循环往复,因此整个运行机制被称为Event Loop(事件循环)。

为更好地理解Event Loop,请参考下图(转引自Philip Roberts的演讲《Help, I'm stuck in an event-loop》)。

JavaScript单线程设计原理

上图中,主线程运行时产生堆(heap)和栈(stack),栈中代码调用各种外部API,它们在“任务队列”中加入各种事件(click,load,done)。只要栈中代码执行完毕,主线程便会读取“任务队列”,依次执行对应事件的回调函数。

执行栈中的代码总是在读取“任务队列”之前执行。参见以下示例。

代码如下:

var req = new XMLHttpRequest();
req.open('GET', url);
req.onload = function (){};
req.onerror = function (){};
req.send();

上述代码中的req.send方法是Ajax操作向服务器发送数据,属于异步任务,意味着仅当前脚本所有代码执行完毕后,系统才会读取“任务队列”。因此,它与以下写法等价。


代码如下:

var req = new XMLHttpRequest();
req.open('GET', url);
req.send();
req.onload = function (){};
req.onerror = function (){};

这表明,指定回调函数的部分(onload和onerror)在send()方法前或后并无区别,因为它们同属执行栈的一部分,系统总是先执行它们,再去读取“任务队列”。

定时器与任务队列调度

五、定时器功能

除放置异步任务外,“任务队列”还可放置定时事件,即指定某些代码在多少时间后执行。这称为“定时器”(timer)功能,即定时执行的代码。

定时器功能主要由setTimeout()和setInterval()两个函数实现,其内部运行机制相同,区别在于前者指定代码一次性执行,后者则为反复执行。以下主要讨论setTimeout()。

setTimeout()接受两个参数:第一个是回调函数,第二个是推迟执行的毫秒数。

代码如下:

console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);

上述代码执行结果为1,3,2,因为setTimeout()将第二行推迟至1000毫秒后执行。

若将setTimeout()的第二个参数设为0,表示当前代码执行完(执行栈清空)后立即执行(0毫秒间隔)指定的回调函数。

代码如下:

setTimeout(function(){console.log(1);}, 0);
console.log(2);

上述代码执行结果恒为2,1,因为仅在执行完第二行后,系统才会执行“任务队列”中的回调函数。 HTML5标准规定setTimeout()的第二个参数最小值(最短间隔)不得低于4毫秒,若低于此值,系统将自动增加。在此之前,旧版浏览器将最短间隔设为10毫秒。

此外,对于DOM变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。此时使用requestAnimationFrame()的效果优于setTimeout()。

需注意,setTimeout()仅将事件插入“任务队列”,必须等到当前代码(执行栈)执行完毕,主线程才会执行其指定的回调函数。若当前代码耗时较长,回调函数可能需等待很久,因此无法保证回调函数一定在setTimeout()指定的时间执行。

Node.js环境下的Event Loop差异

六、Node.js的Event Loop

Node.js同样采用单线程Event Loop,但其运行机制与浏览器环境不同。

请参考下图(作者@BusyRich)。

JavaScript单线程设计原理

根据上图,Node.js的运行机制如下。

(1)V8引擎解析JavaScript脚本。 (2)解析后的代码调用Node API。 (3)libuv库负责Node API的执行。它将不同任务分配给不同线程,形成Event Loop(事件循环),以异步方式将任务执行结果返回给V8引擎。 (4)V8引擎再将结果返回给用户。

除setTimeout和setInterval外,Node.js还提供了另外两个与“任务队列”相关的方法:process.nextTick和setImmediate。它们有助于加深对“任务队列”的理解。

process.nextTick方法可在当前“执行栈”尾部——主线程下一次读取“任务队列”之前——触发回调函数。即其指定任务总是发生在所有异步任务之前。setImmediate方法则是在当前“任务队列”尾部触发回调函数,即其指定任务总是在主线程下一次读取“任务队列”时执行,这与setTimeout(fn, 0)相似。参见以下示例(via StackOverflow)。

代码如下:

process.nextTick(function A() {
console.log(1);
process.nextTick(function B(){console.log(2);});
});
setTimeout(function timeout() {
 console.log('TIMEOUT FIRED');
}, 0)
// 1
// 2
// TIMEOUT FIRED

上述代码中,由于process.nextTick方法指定的回调函数总是在当前“执行栈”尾部触发,因此不仅函数A比setTimeout指定的回调函数timeout先执行,而且函数B也比timeout先执行。这表明,若有多个process.nextTick语句(无论是否嵌套),它们将在当前“执行栈”中全部执行。

现在,再看setImmediate。

代码如下:

setImmediate(function A() {
console.log(1);
setImmediate(function B(){console.log(2);});
});
setTimeout(function timeout() {
 console.log('TIMEOUT FIRED');
}, 0)
// 1
// TIMEOUT FIRED
// 2

上述代码包含两个setImmediate。第一个setImmediate指定在当前“任务队列”尾部(下一次“事件循环”时)触发回调函数A;同时,setTimeout也指定在当前“任务队列”尾部触发回调函数timeout,因此输出结果中,TIMEOUT FIRED排在1之后。至于2排在TIMEOUT FIRED之后,是因为setImmediate的另一个重要特点:一次“事件循环”只能触发一个由setImmediate指定的回调函数。

由此我们得到一个重要区别:多个process.nextTick语句总是一次执行完,而多个setImmediate则需要多次才能执行完。事实上,这正是Node.js 10.0版添加setImmediate方法的原因,否则像下面这样的递归调用process.nextTick将没完没了,主线程根本不会去读取“事件队列”!

代码如下:

process.nextTick(function foo() {
process.nextTick(foo);
});

事实上,若现在写出递归的process.nextTick,Node.js会抛出警告,要求改为setImmediate。


另外,由于process.nextTick指定的回调函数是在本次“事件循环”触发,而setImmediate指定的是在下次“事件循环”触发,因此显然,前者总是比后者发生得早,且执行效率更高(因为无需检查“任务队列”)。

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章