EventLoop

EventLoopnode


# 浏览器事件循环

一图胜千言

# Task & Microtask

# 微任务有哪些

  • process.nextTicknode
  • promise.then
  • Object.observe
  • MutationObserver

# 宏任务有哪些

  • script
  • setTimeout
  • setInterval
  • setImmediate
  • I/O 网络请求完成、文件读写完成事件
  • UI rendering
  • 用户交互事件(比如鼠标点击、滚动页面、放大缩小等)

# 事件循环的流程

  • 执行同步代码,这属于宏任务
  • 执行栈为空,查询是否有微任务需要执行
  • 执行所有微任务
  • 必要的话渲染 UI
  • 然后开始下一轮 Event loop,执行宏任务中的异步代码

总结起来就是:一次 Eventloop 循环会处理一个宏任务和所有这次循环中产生的微任务

# 一个例子

  • 因为微任务是在 UI 渲染之前执行的,所以页面的背景会直接呈现黄色
<script>
  document.body.style.background = 'red'

  console.log(1)

  Promise.resolve().then((_) => {
    console.log(3)
    document.body.style.background = 'yellow'
  })

  console.log(2)
</script>
1
2
3
4
5
6
7
8
9
10
11
12
  • 因为宏任务的执行与 UI 渲染不在同一次事件循环周期,所以页面背景会有从红色变为黄色的过程
<script>
  document.body.style.background = 'red'

  console.log(1)

  setTimeout((_) => {
    console.log(3)
    document.body.style.background = 'yellow'
  })

  console.log(2)
</script>
1
2
3
4
5
6
7
8
9
10
11
12

# Node 事件循环

当 Node.js 开始启动时,会初始化一个 Eventloop,处理输入的代码脚本,事件循环本质上就像一个 while 循环,源码参考 (opens new window)

以下内容来自libuv 源码粗读(6):libuv event-loop 详解 #35 (opens new window)

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int ran_pending;

  r = uv__loop_alive(loop); // 判断是否还存在活跃句柄
  if (!r)
    uv__update_time(loop); // 如果不存在直接更新event-loop的loop->time(libuv事件循环内部维护的时间)

  while (r != 0 && loop->stop_flag == 0) {
    uv__update_time(loop); // 更新`loop->time`的时间
    uv__run_timers(loop); // 处理timers相关事件
    ran_pending = uv__run_pending(loop); // 处理pending相关事件
    uv__run_idle(loop); // 处理idle相关事件
    uv__run_prepare(loop); // 处理prepare相关事件

    timeout = 0; // 初始化uv__io_poll的轮询时间timeout
    if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT) // 添加对evnet-loop运行模式的判断,从而决定uv__io_poll要阻塞的时长
      timeout = uv_backend_timeout(loop);

    uv__io_poll(loop, timeout); // 执行`uv__io_poll`阻塞循环`timeout`时长
    uv__run_check(loop); // 处理check相关事件
    uv__run_closing_handles(loop); // 处理close相关事件

    if (mode == UV_RUN_ONCE) { // 添加对evnet-loop运行模式的判断,从而决定是否再次更新loop->time处理timers相关事件
      /* UV_RUN_ONCE implies forward progress: at least one callback must have
       * been invoked when it returns. uv__io_poll() can return without doing
       * I/O (meaning: no callbacks) when its timeout expires - which means we
       * have pending timers that satisfy the forward progress constraint.
       *
       * UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
       * the check.
       */
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop); // 判断是否还存在活跃句柄
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) // 添加对evnet-loop运行模式的判断从而决定是否跳出event-loop
      break;
  }

  /* The if statement lets gcc compile it to a conditional store. Avoids
   * dirtying a cache line.
   */
  if (loop->stop_flag != 0)
    loop->stop_flag = 0;

  return r;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50

当 Node.js 启动后,它会初始化事件循环,处理已提供的输入脚本,它可能会调用一些异步的 API、调度定时器,或者调用 process.nextTick(),然后开始处理事件循环。

根据 nodejs 官方文档,在通常情况下,nodejs 中的事件循环根据不同的操作系统可能存在特殊的阶段,但总体是可以分为以下 6 个阶段:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • 每个框被称为事件循环机制的一个阶段
  • 每个阶段都有一个 FIFO 队列来执行回调
  • 每个阶段都做了什么?
    • 通常情况下,当事件循环进入给定的阶段时,它将执行特定于该阶段的任何操作然后执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。
  • 如何进入下一个阶段?
    • 当该队列已用尽或达到回调限制,事件循环将移动到下一阶段
  • 每次执行执行一个宏任务后就会清空微任务(执行顺序和浏览器一致,在 node11 版本以上)
  • process.nextTick 是优先级最高的 node 中的微任务,会被放在当前执行栈的底部,优先级比 promise 要高

# timers

重要

这个阶段执行 setTimeout 和 setInterval 调度的回调函数,底层实现用到的数据结构是二叉堆,最快到期的节点在最上面参考 (opens new window)

至于定时器合适执行,是在 poll 阶段进行控制的

# pending callbacks 阶段

大部分 I/O 回调任务都是在 poll 阶段执行的,但是也会存在一些上一次事件循环遗留的被延时的 I/O 回调函数 ,那么此阶段就是为了调用之前事件循环延迟执行的 I/O 回调函数。

# idle,prepare 阶段

仅系统内部使用,只需要知道有这 2 个阶段就可以

# poll 阶段

重要

poll 阶段是一个重要且复杂的阶段,几乎所有 I/O 相关的回调,都在这个阶段执行(除了 setTimeoutsetIntervalsetImmediate 以及一些因为 exception 意外关闭产生的回调)

通常,随着代码执行,事件循环最终会命中 poll 阶段,在那等待传入连接、请求等。但是,如果回调已使用 setImmediate()调度过,并且 poll 阶段变为空闲状态,则它将结束 poll 阶段,并继续到 check 阶段而不是继续等待轮询事件

poll 阶段其实主要做了两件事:

  • 计算应该阻塞和轮询 I/O 的时间
  • 处理 poll 队列里的事件

这个阶段的主要流程如下图所示

当事件循环进入 poll 阶段且 没有被调度的计时器时 ,将发生以下两种情况之一:

  • 如果 poll 队列 不是空的 ,事件循环将循环访问回调队列并同步执行它们,直到队列已用尽,或者达到了与系统相关的硬性限制。

  • 如果 poll 队列 是空的 ,还有两件事发生:

    • 如果脚本被 setImmediate() 调度,则事件循环将结束 poll 阶段,并继续 check 阶段以执行那些被调度的脚本。

    • 如果脚本 未被 setImmediate()调度,则事件循环将等待回调被添加到队列中,然后立即执行。

  • 一旦 poll 队列为空,事件循环将检查 已达到时间阈值的计时器。如果一个或多个计时器已准备就绪,则事件循环将绕回 timer 阶段以执行这些计时器的回调。

# check 阶段

重要

当 poll 阶段回调函数队列为空且脚本使用 setImmediate() 后被排列在队列中的时候,开始进入 check 阶段,主要执行 setImmediate 回调函数

# close callbacks

执行注册 close 事件的回调函数

# setImmediate 和 setTimeout

一下内容来自彻底理解 Event Loop (opens new window)

# 案例一

setTimeout(() => {
  setTimeout(() => {
    console.log('setTimeout')
  }, 0)
  setImmediate(() => {
    console.log('setImmediate')
  })
}, 0

// 结果
'setImmediate'
'setTimeout'
1
2
3
4
5
6
7
8
9
10
11
12

流程分析

  • 外层是一个 setTimeout,所以执行他的回调的时候已经在 timers 阶段了
  • 处理里面的 setTimeout,因为本次循环的 timers 正在执行,所以他的回调其实加到了下个 timers 阶段
  • 处理里面的 setImmediate,将它的回调加入 check 阶段的队列
  • 外层 timers 阶段执行完,进入 pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
  • 到了 check 阶段,发现了 setImmediate 的回调,拿出来执行
  • 然后是 close callbacks,队列是空的,跳过
  • 又是 timers 阶段,执行我们的 console

# 案例二

setTimeout(() => {
  console.log('setTimeout')
}, 0)

setImmediate(() => {
  console.log('setImmediate')
})
// 输出结果不一定
1
2
3
4
5
6
7
8

解析

node.js 里面 setTimeout(fn, 0)会被强制改为 setTimeout(fn, 1),浏览器里面 setTimeout 最小的时间限制是 4ms

  • 外层同步代码一次性全部执行完,遇到异步 API 就塞到对应的阶段
  • 遇到 setTimeout,虽然设置的是 0 毫秒触发,但是被 node.js 强制改为 1 毫秒,塞入 times 阶段
  • 遇到 setImmediate 塞入 check 阶段
  • 同步代码执行完毕,进入 Event Loop
  • 先进入 times 阶段,检查当前时间过去了 1 毫秒没有,如果过了 1 毫秒,满足 setTimeout 条件,执行回调,如果没过 1 毫秒,此时 timers 队列为空,跳过进入下一阶段
  • 跳过空的阶段,进入 check 阶段,执行 setImmediate 回调

# 案例三

process.nextTick(function () {
  console.log('1')
})
process.nextTick(function () {
  console.log('2')
  setImmediate(function () {
    console.log('3')
  })
  process.nextTick(function () {
    console.log('4')
  })
})

setImmediate(function () {
  console.log('5')
  process.nextTick(function () {
    console.log('6')
  })
  setImmediate(function () {
    console.log('7')
  })
})

setTimeout((e) => {
  console.log(8)
  new Promise((resolve, reject) => {
    console.log(8 + 'promise')
    resolve()
  }).then((e) => {
    console.log(8 + 'promise+then')
  })
}, 0)

setTimeout((e) => {
  console.log(9)
}, 0)

setImmediate(function () {
  console.log('10')
  process.nextTick(function () {
    console.log('11')
  })
  process.nextTick(function () {
    console.log('12')
  })
  setImmediate(function () {
    console.log('13')
  })
})

console.log('14')
new Promise((resolve, reject) => {
  console.log(15)
  resolve()
}).then((e) => {
  console.log(16)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57

输出结果如下:

14
15
1
2
4
16
8
8 + promise
8 + promise + then
9
5
6
10
11
12
3
713
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

解析:

  • 同步代码先执行,输出 14 15

  • nextTick 执行,回调直接插入当前执行栈,输出 1 2 4

  • 可以将 Promise 的 then 方法理解成 process.nextTick,只不过优先级要低于 nextTick

Last Updated: 10/21/2024, 4:15:17 PM