weixin_39855634
weixin_39855634
2020-12-29 07:19

浏览器和NodeJS中不同的Event Loop

异步处理机制 Event Loop(下文中简称EL或事件轮询) 大概是每个前端的必修知识。写成文章,一是为系统地总结学习 Node.js 的 EventsTimer 时对于事件轮询机制的追问,而是希望能对处于迷惑阶段的同学有所帮助。如果你和我一样,对于task、microtask、Node事件循环的phase、process.nextTick() 执行时机等存在疑惑的话,那么这篇文章可以提供一点参考,如果能解答你的疑惑,不胜荣幸,致谢参考文档。

本文内容旨在厘清浏览器(browsing context)和Node环境中不同的 Event Loop。

目录

在文章开始之前,我们先来做道题目

(请先尝试回答下面代码的运行结果,之后再在浏览器和node下验证。如果答案一致,那么可以关闭当前文档了hhh)

下文中对这段代码会再做分析

js
setTimeout(() => console.log('setTimeout1'), 0);
setTimeout(() => {
    console.log('setTimeout2');
    Promise.resolve().then(() => {
        console.log('promise2');
        Promise.resolve().then(() => {
            console.log('promise3');
        })
        console.log(5)
    })
    setTimeout(() => console.log('setTimeout4'), 0);
}, 0);
setTimeout(() => console.log('setTimeout3'), 0);
Promise.resolve().then(() => {
    console.log('promise1');
})

答案戳 - 浏览器 - Node

js是单线程的,EL机制实现异步

轮询发生的前提:所有代码皆在主线程调用栈完成执行

轮询发生的时机:当主线程任务清空后,轮询任务队列中的任务

我们要讨论的是,轮询机制在浏览器和Node中的区别

browsing contexts

EL在HTML规范中的定义

To coordinate events, user interaction, scripts, rendering, networking, and so forth, user agents must use event loops as described in this section. There are two kinds of event loops: those for browsing contexts, and those for workers.

为了协调事件、用户交互、脚本、UI渲染、网络请求等行为,用户引擎必须使用Event Loop。EL包含两类:基于browsing contexts,基于worker。二者独立。

本文讨论的浏览器中的EL基于browsing contexts

浏览器上下文 是一个将document对象呈现给用户的环境

图解Event Loop

eventloop

  • 同步任务直接进入主执行栈(call stack)中执行
  • 等待主执行栈中任务执行完毕,由EL将异步任务推入主执行栈中执行

task

一个EL中有 一个或多个 task队列。

来自不同任务源的task会放入不同的task队列中:比如,用户代理会为鼠标键盘事件分配一个task队列,为其他的事件分配另外的队列。

task执行顺序是由进入队列的时间决定的,先进队列的先被执行。

典型的任务源有以下几种(Generic task sources):

  • DOM操作任务源:响应DOM操作
  • 用户交互任务源:对用户交互作出反应,例如键盘或鼠标输入。响应用户操作的事件(例如click)必须使用task队列
  • 网络任务源:响应网络活动
  • history traversal任务源:当调用history.back()等类似的api时,将任务插进task队列

    task在网上也被成为macrotask 可能是为了和 microtask 做对照。但是规范中并不是这么描述任务的。

除了上述task来源,常见的来源还有 数据库操作、setTimeout/setInterval等,可以概括为以下几种

  • script代码
  • setTimeout/setInterval
  • I/O
  • UI交互
  • setImmediate(nodejs环境中)

Microtask

一个EL中只有一个microtask队列,通常下面几种任务被认为是microtask

  • promise(promisethencatch才是microtask,本身其内部的代码并不是)
  • MutationObserver
  • process.nextTick(nodejs环境中)

EL循环过程

一个EL只要存在,就会不断执行下边的步骤:

  1. 在所有task队列中选择一个最早进队列的task,用户代理可以选择任何task队列,如果没有可选的任务,则跳到6Microtasks步骤
  2. 将前一步选择的task设置为 currently running task
  3. Run: 运行被选择的task
  4. 运行结束之后,将event loop的 currently running task 置为 null
  5. 从task队列里移除前边Run里运行的task
  6. Microtasks: 执行microtasks任务检查点。(也就是执行microtasks队列里的任务)
  7. 更新渲染(可能会发生,改部分细致解释可在【拓展】部分看详细解释
  8. 如果这是一个worker event loop,但是task队列中没有任务,并且WorkerGlobalScope对象的closing标识为true,则销毁EL,中止这些步骤,然后 run a worker
  9. 返回到第1步

简化一下上面的步骤,可以用下面的伪代码描述EL循环过程:

一个宏任务,所有微任务(,更新渲染),一个宏任务,所有微任务(,更新渲染)......

执行完microtask队列里的任务,有可能会渲染更新。在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ的频率更新视图

js
while (true) {
    宏任务队列.shift()
    微任务队列全部任务()
}

掌握了吗?在浏览器中运行文章开头的代码

unnamed (1).png

运行结果:

js
promise1
setTimeout1
setTimeout2
promise2
5
promise3
setTimeout3
setTimeout4

过程分析: unnamed (2).png

node (version<=10)

Node中的EL由 libuv库 实现,它为 Node.js 提供了跨平台,线程池,事件池,异步 I/O 等能力。

event loop是libuv的核心所在,js是单线程的,会把回调和任务交给libuv 何时来调用回调就是 libuv实现的 event loop 来控制的。 event loop 首先会在内部维持多个事件队列,比如 时间队列、网络队列等等,而libuv会执行一个相当于 while true的无限循环,不断的检查各个事件队列上面是否有需要处理的pending状态事件,如果有则按顺序去触发队列里面保存的事件,同时由于libuv的事件循环每次只会执行一个回调,从而避免了 竞争的发生

个人理解,它与浏览器中的轮询机制(一个task,所有microtasks;一个task,所有microtasks…)最大的不同是,node轮询有phase(阶段)的概念,不同的任务在不同阶段执行,进入下一阶段之前执行process.nextTick() 和 microtasks。(以下概念性描述和例子均是对于<=10的node版本而言,node11在EL的处理上与浏览器趋同,可参考这篇文档 New Changes to the Timers and Microtasks in Node v11.0.0 ( and above)

Node事件轮询中的几个阶段

js
   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │

事件循环必须跑完这六个阶段才算一个轮回

每个阶段都有一个回调函数FIFO(先进先出)队列。 EL进入一个阶段会执行里面所有的操作,然后执行回调函数,直到队列消耗尽,或是回调函数执行数量达到最大限制 清理nextTickQueue/microtasks 之后进入下一个阶段

阶段里的执行队列: - Timers Queue setTimeout() setInterval()设定的回调函数 - I/O Queue 几乎所有的回调,除了timers、close callbacks、check阶段的回调 - Check Queue setImmediate() 设定的回调函数 - Close Queue 比如 socket.on('close', ...)

timers

在这个阶段检查是否有到达阈值的timer(setTimeout/setInterval),有的话就执行他们的回调

但timer设定的阈值不是执行回调的确切时间(只是最短的间隔时间),node内核调度机制和其他的回调函数会推迟它的执行

由poll阶段来控制什么时候执行timers callbacks

I/O callbacks

处理异步事件的回调,比如网络I/O,比如文件读取I/O。当这些I/O动作都结束的时候,在这个阶段会触发它们的回调。

~~idle, prepare内部使用,忽略~~

poll

获取新的I/O事件,node会在适当的情况下阻塞在这里

为防止poll phase 耗尽 event loop,libuv 也有一个最大值(基于系统),会在超过最大值之后停止轮询更多的事件

由于其它各个阶段的操作都有可能导致新的事件发生,并使得内核向poll queue中添加事件,所以在poll阶段处理事件的时候可能还会有新的事件产生,最终,长时间的调用回调函数将会导致定时器过期,所以在poll阶段与定时器会有"合作"

poll阶段主要的两个功能: - 处理poll queue的callbacks - 回到timers phase执行timers callbacks(当到达timers指定的时间时)

进入poll阶段,timer的设定有下面两种情况: 1. event loop进入了poll阶段, 未设定timer - poll queue不为空:event loop将同步的执行queue里的callback,直到清空或执行的callback到达系统上限 - poll queue为空 - 如果有设定setImmediate() callback, event loop将结束poll阶段进入check阶段,并执行check queue (check queue是 setImmediate设定的) - 如果代码没有设定setImmediate() callback,event loop将阻塞在该阶段等待callbacks加入poll queue 2. event loop进入了 poll阶段, 设定了timer - 如果poll进入空闲状态,event loop将检查timers,如果有1个或多个timers时间时间已经到达,event loop将回到 timers 阶段执行timers queue

check

  • 一旦poll队列闲置下来或者是代码被setImmediate调度,EL会马上进入check phase
  • check是特殊的timer

close callbacks

  • 关闭I/O的动作,比如文件描述符的关闭,连接断开等
  • 如果socket突然中断,close事件会在这个阶段被触发

看个例子~

js
var fs = require('fs');
function someAsyncOperation (callback) {
    // 假设用了95ms
    fs.readFile('/path/to/file', callback);
}

var timeoutScheduled = Date.now();
setTimeout(function () {
    var delay = Date.now() - timeoutScheduled;
    console.log(delay + "ms have passed since I was scheduled");
}, 100);

someAsyncOperation(function () {
    var startCallback = Date.now();
    while (Date.now() - startCallback < 10) {
        ;
    }
});

// log: 105ms have passed since I was scheduled

  1. timers:定时器加入到timers queue中,定时的时间设置为100ms,进入下阶段
  2. I/O callbacks:没有回调队列
  3. poll:执行I/O操作,由于读取文件要耗费95ms的时间,这时它的任务队列为空,poll将会阻塞在这里循环相应的回调函数。大约在95ms时相应的文件读取I/O操作执行完毕,对应的回调函数又耗费了10ms。这时poll queue为空,此时poll会检查有没有到达阈值到期的timer。发现存在一个已经超时近5ms的定时器
  4. timers:回到timers阶段执行回调函数,打log

循环过程

对于循环开始之前的process.nextTick() microtasks会不会被处理,和小组的同学们有过讨论。在node官方文档中我们看到这样的定义

When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

所以开始循环之前,process.nextTick()/microtasks 是会被先清掉的

循环开始之前

  1. 所有同步任务
  2. 同步任务中的异步操作发出异步请求
  3. 规划好同步任务中的定时器生效时间
  4. 执行process.nextTick()

开始循环

  1. 清空当前循环内的 Timers Queue,清空NextTick Queue,清空Microtask Queue
  2. 清空当前循环内的 I/O Queue,清空NextTick Queue,清空Microtask Queue
  3. poll情况比较复杂(前面已经分析过了)
  4. 清空当前循环内的 Check Queue,清空NextTick Queue,清空Microtask Queue
  5. 清空当前循环内的 Close Queue,清空NextTick Queue,清空Microtask Queue
  6. 进入下一轮循环

伪代码

js
while (true) {
  loop.forEach((阶段) => {
    阶段全部任务()
    nextTick全部任务()
    microTask全部任务()
  })
  loop = loop.next
}

优先级:nextTick > microtask | setTimeout/setInterval > setImmediate

setTimeout 和 setImmediate 的区别

  • setImmediate 一旦当前poll阶段结束(poll queue为空或执行任务到达上限)就执行一次脚本
  • setTimeout 设定一个最短的调度该脚本的时间阈值
  • 不在同一个I/O cycle中的时候,回调的调度顺序是不被保证的

    js
    // timeout_vs_immediate.js
    setTimeout(() => {
        console.log('timeout');
    }, 0);
    <p>setImmediate(() => {
        console.log('immediate');
    });</p>
    <p>// terminal
    $ node timeout_vs_immediate.js
    timeout
    immediate</p>
    <p>$ node timeout_vs_immediate.js
    immediate
    timeout</p>
    <p></p>
    - 在同一个I/O cycle中,immediate 总比 timeout 更早被调度

    js
    // timeout_vs_immediate.js
    const fs = require('fs');
    <p>fs.readFile(__filename, () => {
        setTimeout(() => {
            console.log('timeout');
        }, 0);
        setImmediate(() => {
            console.log('immediate');
        });
    });</p>
    <p>// terminal
    $ node timeout_vs_immediate.js
    immediate
    timeout</p>
    <p>$ node timeout_vs_immediate.js
    immediate
    timeout</p>
    <p></p>

process.nextTick()

process.nextTick() 不是Node的EL中的一部分(虽然它也是异步API),但是,任意阶段的操作结束之后 nextTickQueue 就会被处理。

nextTickQueue & microtasks

日常应用中经常会将 promise、process.nextTick、nextTickQueue、microtask 混为一谈,其实真正注册为 microtask 的任务的目前只有 promise。但是问题来了,v8 目前是没有暴露 runMicrotasks ,也就是说我们目前还没有办法通过内核的 API 执行 microtask queue 的任务。

Node.js 最终选择的实现方法是将 microtask queue 的任务通过一个 runMicrotasks 对象暴露给上游,然后通过 nextTick 方法把它们推进了 nextTickQueue,也就是说最终 microtask queue 的任务变成了 nextTickQueue 的任务,所以我们用 promise.thenprocess.nextTick 可以实现相同的效果。

process.nextTick() 和 setImmediate()

官方推荐使用 setImmediate(),因为更容易推理,也兼容更多的环境,例如浏览器环境

  • process.nextTick() 在当前循环阶段结束之前触发
  • setImmediate() 在下一个事件循环中的check阶段触发

通过process.nextTick()触发的回调也会在进入下一阶段前被执行结束,这会允许用户递归调用 process.nextTick() 造成I/O被榨干,使EL不能进入poll阶段

因此node作者推荐我们尽量使用setImmediate,因为它只在check阶段执行,不至于导致其他异步回调无法被执行到

掌握了吗:在浏览器中运行文章开头的代码

unnamed (1).png

运行结果(执行demo的node版本为v8.*)

js
promise1
setTimeout1
setTimeout2
setTimeout3
promise2
5
promise3
setTimeout4

过程分析: unnamed (3).png

拓展

web worker

【转向Javascript系列】深入理解Web Worker Web Worker浅识

简单介绍:web worker是HTML5标准的一部分,将浏览器js线程分为主线程和worker线程,在主线程中,通过 new Worker()创建一个worker实例,参数是一个js文件。主线程和worker之间的通信是通过postMessage/onMessage的形式来做的,彼此发送数据,接受数据

MutationObserver

MutationObserver - Web API 接口 | MDN

执行栈

javaScript是单线程,也就是说只有一个主线程。

主线程有一个栈,每一个函数执行的时候,都会生成新的execution context(执行上下文),执行上下文会包含一些当前函数的参数、局部变量之类的信息,它会被推入栈中, running execution context(正在执行的上下文)始终处于栈的顶部。当函数执行完后,它的执行上下文会从栈弹出。

call-stack

简单的例子

js
function bar() {
console.log('bar');
}

function foo() {
console.log('foo');
bar();
}

foo();

执行栈的变化 call-stack-change

更新渲染

在上面说到的浏览器的Event Loop 循环过程中,执行完microtask队列里的任务,有可能会渲染更新。这取决于在当下渲染是否“获益”。

更新渲染在HTML的规范中包括了十二个步骤,大致是做5件事情:

1-4. 判断 document 在此时间点渲染是否会『获益』,是否有效。浏览器只需保证 60Hz 的刷新率即可(在机器负荷重时还会降低刷新率),若 eventloop 频率过高,一帧以内的多次dom变动浏览器不会立即响应,即使渲染了浏览器也无法及时展示。所以并不是每轮 eventloop 都会执行 UI Render。 5-9. 执行各种渲染所需工作,如 触发 resize、scroll 事件、建立媒体查询、运行 CSS 动画等等 10. 执行 request animation frame callbacks 11. 执行 IntersectionObserver callback 12. 渲染 UI

名词解释: requestAnimationFrame是js绘制动画的API,注册callback,浏览器更新渲染时触发animate,animate触发cb IntersectionObserver API 会注册一个回调方法,当期望监听的元素进入/退出另一个元素/浏览器视窗,或者是两个元素交集的部分大小发生变化,该回调就会被执行

参考

该提问来源于开源项目:kaola-fed/blog

  • 点赞
  • 写回答
  • 关注问题
  • 收藏
  • 复制链接分享
  • 邀请回答

9条回答

  • weixin_39855634 weixin_39855634 3月前

    楼主你好,在浏览器中运行的结果时对的,但在node中运行的结果不是你解释的那个预期,反而打印出来和浏览器运行结果相同,请问是什么原因呢? Screen Shot 2019-08-10 at 8.04.42 PM.png

    可以看下你的Node版本哦,>=v11.0.0 的EL有所更新,可以参考下这个文章 New Changes to the Timers and Microtasks in Node v11.0.0 ( and above)

    点赞 评论 复制链接分享
  • weixin_39761645 weixin_39761645 3月前

    楼主你好,在浏览器中运行的结果时对的,但在node中运行的结果不是你解释的那个预期,反而打印出来和浏览器运行结果相同,请问是什么原因呢? Screen Shot 2019-08-10 at 8.04.42 PM.png

    点赞 评论 复制链接分享
  • weixin_39855634 weixin_39855634 3月前

    你的demo肯定没有自己执行过,或者自己对着代码写一遍,因为你里面没有promise2这个东西,你把promise3和promise4,变成了promise2和promise3.。。。。。

    不好意思,很久没关注这个issue,现在回复。 demo书写有误,已更正:promise3/promse4=>promise2/promise3,感谢你的仔细阅读👍

    点赞 评论 复制链接分享
  • weixin_39575502 weixin_39575502 3月前

    不错

    点赞 评论 复制链接分享
  • weixin_39620684 weixin_39620684 3月前

    你的demo肯定没有自己执行过,或者自己对着代码写一遍,因为你里面没有promise2这个东西,你把promise3和promise4,变成了promise2和promise3.。。。。。

    点赞 评论 复制链接分享
  • weixin_39864261 weixin_39864261 3月前

    我要研究一下

    点赞 评论 复制链接分享
  • weixin_39578516 weixin_39578516 3月前

    Node V11.X版本执行和浏览器执行保持一致

    点赞 评论 复制链接分享
  • weixin_39884373 weixin_39884373 3月前

    你好,『因此node作者推荐我们尽量使用setImmediate,因为它只在check阶段执行,不至于导致其他异步回调无法被执行到』 请问这句话应该如何理解呢? 也就是说,process.nexttick() 会让其他异步的回调无法执行,可以举一些具体的例子嘛?

    点赞 评论 复制链接分享
  • weixin_39689377 weixin_39689377 3月前

    你好,『因此node作者推荐我们尽量使用setImmediate,因为它只在check阶段执行,不至于导致其他异步回调无法被执行到』 请问这句话应该如何理解呢? 也就是说,process.nexttick() 会让其他异步的回调无法执行,可以举一些具体的例子嘛?

    按照实现来说,process.nectTick()是将回调函数存入数组中,setImmediate是将回调存在链表上的。 在以前的版本(看朴灵的书的版本),会在一个tick里将process.nectTick()的回调数组都执行完才会到下一个tick,而setImmediate会取一个节点来执行,避免占用CPU过多阻塞后续操作。

    去查了一下源码,https://github.com/nodejs/node/commit/460ee75f7ea36fdb5da15ce868a37a881e0a06d9#diff-e7ef4821107f4cae3bd0fea4dec350bf 这次修改优化了nextTick,避免阻塞的情况出现。

    用新版的话可以不用考虑这么多了吧

    点赞 评论 复制链接分享

为你推荐