浏览器事件循环&Node事件循环

14 阅读17分钟

浏览器事件循环&Node事件循环

进程与线程的概念

进程:正在运行的一个程序,是系统分配资源的最小单位。

线程:进程里面的执行单元,是 CPU 调度的最小单位。

关系:一个进程里面至少有一个线程,进程包含线程

形象比喻:

进程 = 一个独立的工厂

  • 有自己的厂房、设备、内存空间
  • 工厂之间互相隔离,互不干扰
  • 一个工厂倒闭,不影响其他工厂

线程 = 工厂里的工人

  • 共享工厂的设备、资源
  • 一个工厂可以有一个或多个工人同时干活
  • 工人之间配合更快、通信更方便

可以吧进程比作容器,线程比作干活的

特点:进程之间资源不共享,线程共享所属进程的内存和资源

以谷歌浏览器为例:

谷歌浏览器是一个多进程多线程的软件,每个页签都是一个进程,除此之外还有 浏览器主进程,GPU 进程,网络进程,存储服务进程,扩展进程等。

image.png

浏览器内核

浏览器内核包含两部分:

渲染引擎

负责:

  • 解析 HTML → 生成 DOM 树

  • 解析 CSS → 生成 CSSOM 树

  • 布局、排版、绘制页面

简单说:负责把页面画出来、排好位置、上色样式

JS引擎

负责:

  • 解析、执行 JavaScript 代码

  • 做逻辑运算、事件监听、页面交互

简单说: 负责让页面能点击、弹窗、请求接口、动态变内容

浏览器内核运行实例与标签页

浏览器会为每个页签根据浏览器内核代码生成一个运行实例,所以每个页签都是独立的渲染进程与 JS 引擎线程。

每个标签页都有一些常驻线程:

  1. GUI渲染线程
  2. JavaScript 引擎线程
  3. 定时触发器线程
  4. 事件触发线程
  5. 异步 http 请求线程

我们说 JS 是单线程的是因为 JS 的执行依靠的是 JavaScript 引擎线程来执行

每个标签页中的常驻线程

GUI 渲染线程

  • 主要负责页面的渲染,解析 HTMLCSS,构建 DOM 树,布局和绘制等。
  • 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
  • 该线程与 JS 引擎线程互斥,当执行 JS 引擎线程时,GUI 渲染会被挂起,当任务队列空闲时,主线程才会去执行 GUI 渲染。

JavaScript 引擎线程

  • 该线程当然是主要负责处理 JavaScript 脚本,执行代码。
  • 也是主要负责执行准备好待执行的事件,即定时器计数结束,或者异步请求成功并正确返回时,将依次进入任务队列,等待 JS 引擎线程的执行。
  • 当然,该线程与 GUI 渲染线程互斥,当 JS 引擎线程执行 JavaScript 脚本时间过长,将导致页面渲染的阻塞。

Tips:我们说 JS 执行会阻塞页面的渲染的根本原因是 GUI 渲染线程与 JavaScript 引擎线程是互斥的,同时只能有一个线程执行。这样设计的原因是 JS 中可能会操作 DOM,导致进行一些不必要的渲染流程。

定时触发器线程

  • 负责执行异步定时器一类的函数的线程,如:setTimeout、setInterval
  • 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JS 引擎线程执行。

事件触发线程

  • 主要负责将准备好的事件交给 JS 引擎线程执行。

比如 setTimeout 定时器计数结束, ajax 等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS 引擎线程的执行。

异步 http 请求线程

  • 负责执行异步请求一类的函数的线程,如:Promise、fetch、ajax 等。
  • 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JS 引擎线程执行。

浏览器中的事件循环

首先搞清楚为什么要有事件循环?

因为浏览器每个页签只有一个 JS 引擎线程,当页面出现一些事件触发或者定时器到期或者接口请求回调触发时需要执行相应的代码,而为了解决先执行谁后执行谁的问题,所以想出了一个让大家排队的方案,定时器触发就去延时队列排队,点击事件就去交互队列排队,网络请求回调就去网络队列排队,异步代码回调执行就去微队列排队等。根据队列的优先级,将其任务取出扔到浏览器主线程中依次执行。

宏任务(延迟队列,交互队列,网络队列)和微任务

事件循环中的异步队列有两种:宏任务( macro )队列和微任务( micro )队列。

  • 常见的宏任务有:
    1. 定时器触发线程推送过来的:setTimeout、setInterval
    2. 异步请求线程推送过来的:ajax 回调,fetch 回调
    3. 事件触发线程推送过来的:鼠标事件,键盘事件,滚轮事件等
    4. 一开始全局上下文(script 标签)被推入宏任务
    5. 、ajax 回调,script, 事件触发线程推送过来的*等。
  • 常见的微任务有:
    1. Promise.then / .catch / .finally
    2. async/await 后面的代码(本质是 Promise 微任务)
    3. queueMicrotask() 手动添加微任务
    4. MutationObserver 监听 DOM 变化的回调、

不同队列的优先级:

队列类型优先级对应任务
微队列最高Promise.then/catch/finally、queueMicrotask、MutationObserver
交互队列用户点击、输入、滚动等 DOM 事件回调
延时队列setTimeout、setInterval 回调
网络队列AJAX、fetch 请求完成后的回调

AJAX 网络请求回调,属于宏任务

分清楚:发起请求 和 回调执行

  1. xhr.send() 发起网络请求

同步代码直接执行,浏览器底层新开网络线程去请求接口,不进事件循环

  1. 请求完成后的 onload / onreadystatechange 回调

浏览器把这个回调放入 宏任务队列,等主线程空闲、清空所有微任务后,才执行。

==requestAnimationFrame==:requestAnimationFrame(简称 rAF)是浏览器专门给「做动画」提供的 API跟着屏幕刷新率自动执行回调,做流畅动画、替代 setTimeout 做动画。

它既不是宏任务也不是微任务,它是浏览器渲染流水线的专属任务,有自己独立的执行时机。

事件循环完整顺序:

  1. 执行同步代码
  2. 清空所有微任务
  3. 执行 requestAnimationFrame 回调
  4. 浏览器页面重绘渲染
  5. 然后才去执行下一个宏任务
核心作用
  1. 做流畅网页动画

    位移、缩放、旋转、滚动动画、canvas 动画、游戏动画。

  2. 精准适配屏幕刷新率

    普通屏幕 60 帧 / 秒,约 16.7ms 执行一次回调,和屏幕刷新节奏同步,不丢帧、不卡顿

  3. 自动节能

    标签页切后台、最小化窗口时,自动暂停执行,不浪费 CPU、电量。

  4. 统一浏览器渲染时机回调会在浏览器下一次重绘之前执行,修改 DOM 样式正好赶上渲染,不会多余刷新。

事件循环流程

一个完整的事件循环过程,可以概括为以下阶段:

image.png

  • 一开始执行栈空,我们可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。微任务队列空,宏任务队列里有且只有一个 script 脚本(整体代码)。

  • 全局上下文( script 标签)被推入执行栈,同步代码执行。在执行的过程中,会判断是同步任务还是异步任务,通过对一些接口的调用,可以产生新的宏任务与微任务,它们会分别被推入各自的任务队列里。同步代码执行完了,script 脚本会被移出宏任务队列,这个过程本质上是队列的宏任务的执行和出队的过程。

  • 上一步我们出队的是一个宏任务,这一步我们处理的是微任务。但需要注意的是:当一个宏任务执行完毕后,会执行所有的微任务,也就是将整个微任务队列清空。

  • 执行渲染操作,更新界面

  • 检查是否存在 Web worker 任务,如果有,则对其进行处理

  • 上述过程循环往复,直到两个队列都清空

宏任务和微任务的执行流程,总结起来就是:

当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。

执行流程如下图所示:

image.png

这里我们可以来看两道具体的代码题目加深理解:

console.log('script start');
setTimeout(function () {
  console.log('setTimeout');
}, 0);

let res = Promise.resolve(1).then(
  () => {
    console.log('成功的回调');
  },
  () => {
    console.log('失败的回调');
  }
);

console.log(res);

console.log('script end');

上面的代码输出的结果为:

script start
Promise { <pending> }
script end
成功的回调
setTimeout

原因很简单,首先会执行同步的任务,输出 script start 以及 script end。接下来是处理异步任务,异步任务分为宏任务队列和微任务队列,在执行宏任务队列中的每个宏任务之前先把微任务清空一遍,由于 promise 是微任务,所以会先被执行,而 setTimeout 由于是一个宏任务,会在微任务队列被清空后再执行。

let res = Promise.resolve(1).then(
  () => {
    console.log('成功的回调');
  },
  () => {
    console.log('失败的回调');
  }
);

这里注意下这个 then,就算 Promise 已经是已决成功状态,.then 的回调也绝不会立即同步执行,一律扔进微任务队列

总结:

Promise.resolve(1) 同步瞬间已决;但 then 回调天生是微任务,强制异步;不会立即执行,必须等同步代码全部干完,再执行微任务回调。

Promise.resolve().then(()=>{
  console.log('Promise1')
  setTimeout(()=>{
    console.log('setTimeout2')
  },0)
})
setTimeout(()=>{
  console.log('setTimeout1')
  Promise.resolve().then(()=>{
    console.log('Promise2')
  })
},0)

上面的代码输出的结果为:

Promise1
setTimeout1
Promise2
setTimeout2

一开始执行栈的同步任务(这属于宏任务)执行完毕,会去查看是否有微任务队列,上题中存在(有且只有一个),然后执行微任务队列中的所有任务输出 Promise1,同时会生成一个宏任务 setTimeout2

然后去查看宏任务队列,宏任务 setTimeout1setTimeout2 之前,先执行宏任务 setTimeout1,输出 setTimeout1。在执行宏任务 setTimeout1 时会生成微任务 Promise2 ,放入微任务队列中,接着先去清空微任务队列中的所有任务,输出 Promise2

清空完微任务队列中的所有任务后,就又会去宏任务队列取一个,这回执行的是 setTimeout2

Node.js 中的事件循环

Node.js 事件循环介绍

Node.js 中的事件循环和浏览器中的是完全不相同的东西。

Node.js 采用 V8 作为 JS 的解析引擎,而 I/O 处理方面使用了自己设计的 libuvlibuv 是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的 API,事件循环机制也是它里面的实现。

image.png

可以看出 Node.JS 的事件循环比浏览器端复杂很多。Node.js 的运行机制如下:

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

整个架构图如下所示:

image.png

Node.js中的宏任务与微任务

Node.js 任务分为宏任务微任务,完全隔离:

1. 宏任务(进入事件循环阶段队列

事件循环 6 个阶段,每个阶段对应一个队列:

  • timers 队列:setTimeout/setInterval
  • poll 队列:fs.readFile/ 网络 I/O
  • check 队列:setImmediate
  • 其他阶段队列...
2. 微任务(独立专属队列,不进事件循环阶段)

分为两个子队列(优先级:nextTick > Promise):

  1. process.nextTick 队列(Node 独有,最高优先级)
  2. 标准微任务队列Promise / queueMicrotask / MutationObserver

Tips:

❌ 错误:Promise 进入 timers/poll/check 队列

✅ 正确:Promise 有独立微任务队列,和事件循环阶段无关

❌ 错误:微任务在事件循环某个阶段执行

✅ 正确:微任务插队执行,每执行一个宏任务,就清空所有微任务

❌ 错误:Node 的 Promise 和浏览器不一样

✅ 正确:Node 11+ 完全和浏览器行为统一(现在全是这个版本)

事件循环的 6 个阶段

其中 libuv 引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

image.png

从上图中,大致看出 Node.js 中的事件循环的顺序:

外部输入数据 –-> 轮询阶段( poll )-–> 检查阶段( check )-–> 关闭事件回调阶段( close callback )–-> 定时器检测阶段( timer )–-> I/O 事件回调阶段( I/O callbacks )-–>闲置阶段( idle、prepare )–->轮询阶段(按照该顺序反复运行)...

以上 6 个阶段所做的事情如下:

  • timers 阶段:这个阶段执行 timersetTimeout、setInterval )的回调

  • pending callbacks 阶段:操作系统底层的延迟回调(几乎用不到)

  • idle、prepare 阶段:纯 Node 内部使用,处理内部逻辑,和开发者无关,直接忽略

  • poll 阶段:

    • 文件 I/O:fs.readFile/fs.writeFile
    • 网络 I/O:http/net 请求回调
    • 流(stream)回调
    • 几乎所有异步 I/O 操作都在这里
  • check 阶段:执行 setImmediate( ) 的回调

  • close callbacks 阶段:执行 socketclose 事件回调

接下去我们详细介绍 timers、poll、check3 个阶段,因为日常开发中的绝大部分异步任务都是在这 3 个阶段处理的。

timer 阶段:

timers 阶段会执行 setTimeoutsetInterval 回调,并且是由 poll 阶段控制的。同样,Node.js 中定时器指定的时间也不是准确时间,只能是尽快执行

poll 阶段:

poll 是一个至关重要的阶段,这一阶段中,系统会做两件事情:

  • 回到 timer 阶段执行回调
  • 执行 I/O 回调

并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情:

  • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制
  • 如果 poll 队列为空时,会有两件事发生:
    • 如果有 setImmediate 回调需要执行,poll 阶段会停止并且进入到 check 阶段执行回调
    • 如果没有 setImmediate 回调需要执行,会等待回调被加入到队列中并立即执行回调,这里同样会有个超时时间设置防止一直等待下去

当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。

假设 poll 被堵塞,那么即使 timer 已经到时间了也只能等着,这也是为什么上面说定时器指定的时间并不是准确的时间。例如:

const start = Date.now();
setTimeout(function f1() {
    console.log("setTimeout", Date.now() - start);
}, 200);

const fs = require('fs');

fs.readFile('./index.js', 'utf-8', function f2() {
    console.log('readFile');
    const start = Date.now();
    // 强行延时 500 毫秒
    while (Date.now() - start < 500) { }
})

Tips:

上面代码第 4 行中延迟时间设置为 200ms,同步代码的执行时间肯定是远远小于 200ms 的,所以当所有同步代码执行完,setTimeout 进入 timers 队列,readFile 进入 poll 队列时,此时按照 timers -> I/O callbacks -> idel,prepare -> poll -> check -> close callbacks 的队列优先级顺序执行代码,发现定时器还没有到期,所以往后寻找到 poll 队列开始执行,导致定时器被阻塞,最终输出 500~505ms 的值。

特殊的:加入将第 4 行的延迟时间设置为一些临界值,就有可能出现定时器有时候到期有时未到期的情况,总结如下:

定时器延迟状态执行顺序
0ms / 1ms绝对到期(Node 最小精度 1ms)稳定:先 setTimeout,后 readFile
2ms临界值,随机波动不稳定:顺序随机
≥10ms绝对未到期稳定:先 readFile,后 setTimeout
check 阶段:

setImmediate( ) 的回调会被加入 check 队列中,从事件循环的阶段图可以知道,check 阶段的执行顺序在 poll 阶段之后。

我们先来看个例子:

console.log('start')
setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(function() {
    console.log('promise1')
  })
}, 0)
setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(function() {
    console.log('promise2')
  })
}, 0)
Promise.resolve().then(function() {
  console.log('promise3')
})
console.log('end')
// 输出结果:start => end => promise3 => timer1 => promise1 => timer2 => promise2

一开始执行同步任务,依次打印出 start end,并将 2timer 依次放入 timer 队列,之后会立即执行微任务队列,所以打印出 promise3

然后进入 timers 阶段,执行 timer1 的回调函数,打印 timer1,发现有一个 promise.then 回调将其加入到微任务队列并且立即执行,之后同样的步骤执行 timer2,打印 timer2 以及 promise2

一些注意点:

setTimeoutsetImmediate 区别

二者非常相似,区别主要在于调用时机不同。

  • setImmediate 设计在 poll 阶段完成时执行,即 check 阶段
  • setTimeout 设计在 poll 阶段为空闲时,且设定时间到达后执行,但它在 timer 阶段执行

来看一个具体的示例:

setTimeout(function timeout () {
  console.log('timeout');
},0);
setImmediate(function immediate () {
  console.log('immediate');
});

对于以上代码来说,setTimeout 可能执行在前,也可能执行在后。首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的,进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调。如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了。

但当二者在异步 I/O callback 内部调用时,总是先执行 setImmediate,再执行 setTimeout,例如:

const fs = require('fs')
fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('timeout');
    }, 0)
    setImmediate(() => {
        console.log('immediate')
    })
})
// immediate
// timeout

在上述代码中,setImmediate 永远先执行。因为两个代码写在 I/O 回调中,I/O 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。

独立专属队列:process.nextTick (优先级最高)

任何一个宏任务执行完他都会插队执行

这个函数其实是独立于事件循环之外的,它有一个自己的队列。当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。

setTimeout(() => {
 console.log('timer1')
 Promise.resolve().then(function() {
   console.log('promise1')
 })
}, 0)
process.nextTick(() => {
 console.log('nextTick')
 process.nextTick(() => {
   console.log('nextTick')
   process.nextTick(() => {
     console.log('nextTick')
     process.nextTick(() => {
       console.log('nextTick')
     })
   })
 })
})
// nextTick => nextTick => nextTick => nextTick => timer1 => promise1

微队列 promise.then/catch/finally (优先级次高)

任何一个宏任务执行完他都会插队执行, 但在 process.nextTIck 之后

Promise.then 也是独立于事件循环之外的,有一个自己的队列,但是优先级要比 process.nextTick 要低,所以当微任务中同时存在 process.nextTickPromise.then 时,会优先执行前者。

setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
    process.nextTick(() => {
        console.log('nexttick');
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
// timer1、nexttick、promise1、timer2、promise2

Node.js 与浏览器的事件队列的差异

队列结构:浏览器宏任务队列(交互队列,延迟队列,网络队列);Node 多阶段宏任务队列(timers, pending callbacks, idle/prepare, poll, check, close callbacks)

微任务:浏览器同级;Node nextTick 优先级高于 Promise

执行时机:两者新版完全一致(1 个宏任务 → 清空微任务)

独有 API:浏览器有 rAF / 渲染;Node 有 nextTick/setImmediate/fs I/O

定时器:浏览器最小 4ms;Node 最小 1ms

image.png