本文从计算机底层进程与线程概念入手,解析浏览器多进程架构及渲染进程内的多线程协作机制。重点阐述JavaScript为何采用单线程设计,并深入剖析Event Loop运行机制,包括同步与异步任务、宏任务与微任务的执行优先级及渲染时机,帮助前端开发者彻底理解异步编程核心原理。
引言
在大多数JavaScript相关教材中,通常指出JS运行于单线程环境,并依赖事件队列(Event Loop)机制来处理异步回调。对于许多初学者而言,单线程的JS为何具备异步能力是一个难点。因此,本文将从进程和线程的基础概念出发,对这一机制进行解释。
中央处理器(CPU)

计算机的核心组件是CPU,它负责执行所有的计算任务。
可以将CPU比作一座持续运转的工厂。
进程(Process)

假设工厂的电力供应有限,同一时间只能供给一个车间使用。这意味着当一个车间开工时,其他车间必须停工。这背后的含义是,单个CPU在任一时刻只能执行一个任务。
进程类似于工厂中的车间,代表CPU所能处理的单个任务。进程之间相互独立,在任何时刻,CPU只运行一个进程,其余进程处于非运行状态。CPU通过时间片轮转调度算法来实现多个进程看似同时运行的效果。
线程(Thread)

一个车间内可以拥有多名工人,他们共享车间的所有资源,并协同完成一项任务。
线程类似于车间里的工人,一个进程可以包含多个线程,这些线程共享进程的资源。
CPU、进程与线程的关系
基于前文的介绍,我们对CPU、进程和线程有了初步了解,现总结如下。
- 进程是CPU资源分配的最小单位(即拥有资源和独立运行的最小单位)。
- 线程是CPU调度的最小单位(线程建立在进程基础上,是程序运行的一次执行单位,一个进程可包含多个线程)。
- 不同进程间也可以进行通信,但代价相对较高。
- 单线程与多线程均指在一个进程内部线程数量的单与多。
浏览器的多进程架构
了解了CPU、进程和线程的关系后,对于计算机而言,每个应用程序都是一个独立的进程。而每个应用程序通常包含多个功能模块,这些模块实际上通过子进程来实现。这种子进程扩展方式使得应用程序呈现多进程特征。
浏览器同样采用多进程架构。例如,在Chrome浏览器中打开多个标签页后,通过Windows任务管理器观察:

如上图所示,可以看到Chrome浏览器启动后生成了多个进程。
总结如下:
- 浏览器是多进程的。
- 每一个标签页(Tab)对应一个独立的进程。
浏览器包含的进程类型
主进程(Main Process)
- 负责协调和控制其他子进程(如创建和销毁)。
- 负责浏览器界面显示、用户交互(如前进、后退、收藏)。
- 将渲染进程中生成的内存位图(Bitmap)绘制到用户界面上。
- 处理不可见操作,如网络请求、文件访问等。
第三方插件进程(Plugin Process)
每种类型的插件对应一个独立进程,仅在使用该插件时才创建。
GPU进程(GPU Process)
主要用于3D绘制等图形处理任务。
渲染进程(Renderer Process),即常说的浏览器内核。
- 负责页面渲染、脚本执行及事件处理等核心功能。
- 每个标签页对应一个独立的渲染进程。
既然浏览器包含如此多的进程,对于普通前端开发而言,最关键的是哪个部分?
答案是渲染进程,也就是我们常说的浏览器内核。
浏览器内核(渲染进程)内部机制
前文提到,进程和线程是一对多的关系,即一个进程包含多条线程。
渲染进程同样包含多线程,接下来我们查看渲染进程具体包含哪些线程。
GUI渲染线程(GUI Rendering Thread)
- 负责页面的渲染,包括布局和绘制。
- 当页面需要重绘(Repaint)或回流(Reflow)时,该线程会执行相应操作。
- 该线程与JS引擎线程互斥,以防止渲染结果出现不可预期的情况。
JS引擎线程(JS Engine Thread)
- 负责解析和执行JavaScript脚本程序。
- JS引擎线程只有一个(即单线程)。
- 该线程与GUI渲染线程互斥,以防止渲染结果出现不可预期的情况。
事件触发线程(Event Trigger Thread)
- 负责管理事件循环,处理鼠标点击、setTimeout、Ajax等事件。
- 当事件满足触发条件时,将事件放入JS引擎所在的执行队列中。
定时触发器线程(Timer Trigger Thread)
- 负责处理setInterval与setTimeout相关的任务。
- 定时任务并非由JS引擎计时,而是由定时触发线程负责计时。
- 计时结束后,通知事件触发线程。
异步HTTP请求线程(Asynchronous HTTP Request Thread)
- 浏览器拥有独立线程用于处理Ajax请求。
- 当请求完成时,若有回调函数,则通知事件触发线程。
了解了渲染进程包含的这些线程后,我们思考两个关键问题:
- 为什么JavaScript是单线程的?
- 为什么GUI渲染线程与JS引擎线程互斥?
JavaScript为何采用单线程设计
首先是历史原因,在创建JavaScript语言时,多进程多线程架构并不流行,硬件支持也相对有限。
其次是因为多线程操作的复杂性,多线程操作需要加锁,这会显著增加编码的复杂度。
此外,如果同时操作DOM且不加锁,多线程环境下最终会导致DOM渲染结果不可预期。
GUI渲染线程与JS引擎线程互斥的原因
由于JavaScript可以操作DOM,如果同时修改元素属性并渲染界面(即JS线程和UI线程同时运行),渲染线程前后获取的元素可能不一致。
因此,为防止渲染出现不可预期的结果,浏览器设定GUI渲染线程和JS引擎线程为互斥关系。当JS引擎线程执行时,GUI渲染线程会被挂起,GUI更新会被保存在队列中,等待JS引擎线程空闲时立即执行。
从Event Loop看JavaScript运行机制
至此,我们终于进入主题:什么是Event Loop。
先理解一些基础概念:
- JavaScript任务分为同步任务和异步任务。
- 同步任务均在JS引擎线程上执行,形成一个执行栈。
- 事件触发线程管理一个任务队列,当异步任务触发条件达成,将回调事件放入任务队列。
- 当执行栈中所有同步任务执行完毕,JS引擎线程空闲,系统会读取任务队列,将可运行的异步任务回调事件添加到执行栈中开始执行。

在前端开发中,我们会通过setTimeout/setInterval指定定时任务,通过XHR/fetch发送网络请求。接下来简述setTimeout/setInterval和XHR/fetch具体执行了什么操作。
我们知道,无论是setTimeout/setInterval还是XHR/fetch代码,在执行时本身属于同步任务,而其中的回调函数才是异步任务。
当代码执行到setTimeout/setInterval时,实际上是JS引擎线程通知定时触发器线程,间隔一定时间后触发一个回调事件。定时触发器线程接收消息后,在等待时间结束后,将回调事件放入由事件触发线程管理的事件队列中。
当代码执行到XHR/fetch时,实际上是JS引擎线程通知异步HTTP请求线程发送网络请求,并指定请求完成后的回调事件。异步HTTP请求线程接收消息后,在请求成功后,将回调事件放入由事件触发线程管理的事件队列中。
当同步任务执行完毕,JS引擎线程会询问事件触发线程,事件队列中是否有待执行的回调函数。如果有,则将其加入执行栈交给JS引擎线程执行。
通过一张图来解释这一过程:

再通过代码示例进行说明:
let timerCallback = function() {
console.log('wait one second');
};
let httpCallback = function() {
console.log('get server data success');
}
// 同步任务
console.log('hello');
// 同步任务
// 通知定时器线程 1s 后将 timerCallback 交由事件触发线程处理
// 1s 后事件触发线程将 timerCallback 加入到事件队列中
setTimeout(timerCallback,1000);
// 同步任务
// 通知异步http请求线程发送网络请求,请求成功后将 httpCallback 交由事件触发线程处理
// 请求成功后事件触发线程将 httpCallback 加入到事件队列中
$.get('www.xxxx.com',httpCallback);
// 同步任务
console.log('world');
//...
// 所有同步任务执行完后
// 询问事件触发线程在事件事件队列中是否有需要执行的回调函数
// 如果没有,一直询问,直到有为止
// 如果有,将回调事件加入执行栈中,开始执行回调代码总结如下:
- JS引擎线程只执行执行栈中的事件。
- 执行栈中的代码执行完毕后,会读取事件队列中的事件。
- 事件队列中的回调事件,是由各自线程插入到事件队列中的。
- 如此循环往复。
宏任务与微任务
在基本了解执行栈和事件队列后,我们深入了解一下事件循环中的宏任务和微任务。
什么是宏任务(Macro-task)
我们可以将每次执行栈执行的代码视为一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。每个宏任务会从头到尾执行完毕,期间不会执行其他宏任务。
前文提到JS引擎线程和GUI渲染线程互斥。为了使宏任务和DOM任务有序进行,浏览器会在一个宏任务执行结果后、下一个宏任务执行前,由GUI渲染线程开始工作,对页面进行渲染。
// 宏任务-->渲染-->宏任务-->渲染-->渲染...
主代码块、setTimeout、setInterval等,均属于宏任务。
第一个例子:
document.body.style = 'background:black';
document.body.style = 'background:red';
document.body.style = 'background:blue';
document.body.style = 'background:grey';可以将这段代码放入浏览器控制台执行,观察效果:

我们会看到页面背景在瞬间变成灰色。以上代码属于同一次宏任务,因此全部执行完才触发页面渲染。渲染时GUI线程会将所有UI改动优化合并,所以视觉上只会看到页面变成灰色。
第二个例子:
document.body.style = 'background:blue';
setTimeout(function(){
document.body.style = 'background:black'
},0)执行一下,再观察效果:

我们会看到页面先显示为蓝色背景,然后瞬间变成黑色背景。这是因为以上代码属于两次宏任务。第一次宏任务执行时将背景变为蓝色,随后触发渲染,将页面变为蓝色;接着触发第二次宏任务将背景变为黑色。
什么是微任务(Micro-task)
我们已知宏任务结束后会执行渲染,然后执行下一个宏任务。而微任务可以理解成在当前宏任务执行后立即执行的任务。
也就是说,当宏任务执行完,会在渲染前,将执行期间所产生的所有微任务都执行完毕。
Promise、process.nextTick等,属于微任务。
第一个例子:
document.body.style = 'background:blue'
console.log(1);
Promise.resolve().then(()=>{
console.log(2);
document.body.style = 'background:black'
});
console.log(3);执行一下,再观察效果:

控制台输出 1 3 2,是因为promise对象的then方法的回调函数是异步执行,所以2最后输出。
页面背景色直接变成黑色,没有经过蓝色阶段,是因为我们在宏任务中将背景设置为蓝色,但在进行渲染前执行了微任务。在微任务中将背景变为黑色,然后才执行渲染。
第二个例子:
setTimeout(() => {
console.log(1)
Promise.resolve(3).then(data => console.log(data))
}, 0)
setTimeout(() => {
console.log(2)
}, 0)
// print : 1 3 2上面代码共包含两个setTimeout,即除主代码块外,共有两个宏任务。其中第一个宏任务执行中输出1,并创建了微任务队列。因此在下一个宏任务队列执行前,先执行微任务。在微任务执行中输出3,微任务执行后,执行下一次宏任务,执行中输出2。
总结
- 执行一个宏任务(栈中没有就从事件队列中获取)。
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中。
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)。
- 当前宏任务执行完毕,开始检查渲染,然后GUI线程接管渲染。
- 渲染完毕后,JS线程继续接管,开始下一个宏任务(从事件队列中获取)。

以上就是JavaScript进阶之从多线程到Event Loop全面梳理的详细内容,更多关于JS Event Loop的资料请关注本站其它相关文章!