事件循环

1,676 阅读5分钟

浏览器事件循环

执行栈

用于存放各种函数的执行环境,每一个函数执行之前,它的相关信息会加入到执行栈。函数调用之前,创建执行环境,然后加入到执行栈;函数调用之后,销毁执行环境。每次 js 引擎执行的都是执行栈顶的代码。

function f1 () {
    f2();
}
function f2 () {
    f3();
}
function f3() {
    console.log('f3');
}
f1();

相对于执行栈的情况:

浏览器常驻线程

  • JS引擎:负责执行执行栈的最顶部代码
  • GUI线程:负责渲染页面
  • 事件监听线程:负责监听各种事件
  • 计时器线程:负责计时
  • 网络线程:负责网络通信

事件队列

任务分类: 在 JavaScript 中,函数分为两种,一种是同步执行,一种是异步执行。

对于异步执行的函数,例如发送网络请求,计时器等。会委托浏览器中的其他线程帮我们做事,当做完之后回调函数会被放入做事件队列中。

当JS引擎发现,执行栈中已经没有了任何内容后,会将事件队列中的第一个函数加入到执行栈中执行。

宏任务和微任务

异步任务分为宏任务和微任务,相应的,事件队列也有两个,分别为宏队列和微队列。

微任务的优先级比宏任务的优先级要高,当两个队列都非空的时候,微队列中的任务会被优先执行。

宏任务:计时器的回调,ajax,注册的事件,基本上所有的异步操作都是宏任务。

微任务:PromiseMutationObserver

setTimeout(() => console.log(1), 0);
new Promise(resolve => {
    resolve()
    console.log(2)
}).then(() => {
    console.log(3)
});
console.log(4);
// 2
// 4
// 3
// 1

Node事件循环

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

事件循环分为六个阶段,每个阶段都有一个 FIFO 队列来执行回调。

通常情况下,当事件循环进入给定的阶段时,将执行该阶段队列中的回调,直到队列用尽或最大回调数已执行。当该队列已用尽或达到回调限制,事件循环将移动到下一阶段。

阶段概述

  • timers:本阶段执行已经被 setTimeout()setInterval() 的调度回调函数。
  • pending:系统级回调
  • idle, prepare:仅系统内部使用
  • poll:执行与 I/O 相关的回调(除了关闭的回调函数,那些由计时器和 setImmediate() 调度的之外的几乎所有回调)
  • checksetImmediate() 回调函数在这里执行。
  • close:一些关闭的回调函数,如:socket.on('close', ...)

在每次运行的事件循环之间,Node.js 检查它是否在等待任何异步 I/O 或计时器,如果没有的话,则完全关闭。

阶段详情

timers

在 Node 中,计时器分为 ImmediateTimeout 两类,这两种计时器都是一个 Node 对象。Timeout 类计时器的回调在该阶段执行。

一旦计时器过期,在下一轮事件循环的 timers 阶段就会调用回调。

Node 中 setIntervalsetTimeout 都可创建该类计时器,在创建该计时器时, delay 参数是可选的,如果没有提供值或指定的值为 0,那么该参数值默认情况下为 1 毫秒。

和浏览器类似,计时器也是不够准确的,因为不是计时器一过期就会执行,只有到达该阶段才会执行。

pending

一些系统级回调将会在此阶段执行。例如,TCP 套接字在尝试连接时接收到 ECONNREFUSED,则某些 *nix 的系统希望等待报告错误。

poll

I/O 回调在此阶段执行,例如 fs.readFilehttp.createServer,而且该阶段是 Node 事件循环中最常呆的阶段。

轮询阶段所做的事:

  1. 如果该阶段队列不为空,则循环访问回调队列并同步执行它们,直到队列已用尽或者达到了与系统相关的硬性限制
  2. 如果该阶段的队列为空且其他阶段队列不为空,则该阶段结束
  3. 如果该阶段的队列为空且其他阶段队列也为空阻塞
const fs = require('fs');

const start = Date.now();

function sleep(n) {
  const start = Date.now();
  while (Date.now() - start < n);
}

setTimeout(() => {
  console.log('timeout', Date.now() - start);
}, 100);


fs.readFile('./index.html', () => {
  console.log('readFile', Date.now() - start);
  sleep(200);
});

// readFile 3
// timeout 208

check

只有 setImmediate() 回调会在该阶段中执行。这使能够在 poll 阶段变得空闲时立即执行一些代码。

setImmediatesetTimeout 的区别:

  1. 两者所属队列不同

  2. setImmediate 会立即将回调加入 checks 队列,而 setTimeout 会开启计时器线程,等待计时器过期

  3. setImmediatesetTimeout 效率高

    function test(fn, name) {
        let i = 0;
        console.time(name);
        const run = () => {
            i++;
            if (i < 1000) {
                fn(run);
            } else {
                console.timeEnd(name);
            }
        }
        run();
    }
    
    test(setTimeout, 'setTimeout');
    test(setImmediate, 'setImmediate');
    // setImmediate: 6.696ms
    // setTimeout: 1.698s
    
  4. 计时器受进程性能的约束,二者的回调运行顺序非确定,取决于系统当时的状况

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

nextTick和Promise

process.nextTick() 是异步 API 的一部分,但从技术上讲不是事件循环的一部分。

每次执行一个事件循环中每个阶段的每一个回调之前,必须要清空 nextTick队列和 microtask对列。

根据语言规格,Promise 对象的回调函数,会进入异步任务里面的 microtask队列,但是微任务队列追加在process.nextTick 队列的后面。而且只有一个队列清空完毕才会清空另一个。

process.nextTick(() => {
    console.log(1);
    process.nextTick(() => {
        console.log(2)
    })
});
Promise.resolve().then(() => console.log(3));
process.nextTick(() => console.log(4));
Promise.resolve().then(() => console.log(5));
// 1
// 4
// 2
// 3
// 5