事件循环(Event Loop)

474 阅读10分钟

javascript 从诞生之日起就是一门单线程的非阻塞的脚本语言。而非阻塞则是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如 I/O 事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。到底是如何实现非阻塞这一点呢?答案就是 事件循环(Event Loop)

当函数被调用时,会被添加到 调用栈 栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。每次栈内被清空都会去读取 任务队列 有没有任务,有就按照顺序读取执行。如果这个时候栈中又出现了事件,该事件又去调用了 WebAPIs 里的异步方法,那这些异步方法会在再被调用的时候放在 任务队列 里,一直循环读取-执行的操作,就形成了 事件循环

调用栈\执行栈(call stack) 是一种 后进先出(LIFO) 的数据结构,任务队列先进先出(FIFO) 的数据结构。

任务队列

事件循环是通过 任务队列(task queue) 的机制来进行协调的。一个事件循环中,可以有一个或者多个任务队列,一个任务队列便是一系列有序任务 task 的集合,每个任务都有一个任务源 task source,源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。

JavaScript 单线程中的任务分为 同步任务异步任务。同步任务会在 调用栈 中按照顺序排队等待主线程执行,异步任务则会在异步有了结果后将注册的回调函数添加到 任务队列(消息队列) 中等待主线程空闲(即栈内被清空)的时候读取到栈中等待主线程执行。

异步任务队列可分为 task(macrotask) 宏任务队列和 microtask(job) 微任务队列两类,不同的 API 注册的异步任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行,宏任务队列可以有多个,微任务队列只有一个。

  • 宏任务主要包含:script(整体代码)setTimeoutsetIntervalI/OUI交互事件postMessageMessageChannelsetImmediate(Node.js 环境)requestAnimationFrame
  • 微任务主要包含:Promise的方法及其派生MutationObserver(浏览器)Object.observe(已废弃)查阅

浏览器中的事件循环

流程

在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:

  • 在此次 tick 中选择最先进入队列的任务(oldest task),如果有则执行(一个),如果执行中有异步任务就放至各自的队列中
  • 检查是否存在 Microtasks,如果存在则不停地执行,直至清空 Microtasks Queue
  • UI 更新渲染 Update the UI rendering (GUI 线程,开始)
  • 取出下一个宏任务 task,主线程重复执行上述步骤(回到 JS 线程)

示意图

相关点

await

await 将直接使用 Promise.resolve() 相同语义查阅,即:

async function async1() {
  await async2();
  console.log('async1 end');
}
//等价于
function async1() {
  Promise.resolve(async2()).then(() => {
    console.log('async1 end');
  });
}
//等价于
function async1() {
  new Promise((resolve) => {
    resolve(async2());
  }).then(() => {
    console.log('async1 end');
  });
}

update rendering(更新渲染)

update rendering(更新渲染)发生在本轮事件循环的 microtask 队列被执行完之后,也就是说执行任务的耗时会影响视图渲染的时机。通常浏览器以每秒 60 帧(60fps)的速率刷新页面,这个帧率最适合人眼交互,大概 16.7ms 渲染一帧,所以如果要让用户觉得顺畅,单个 macrotask 及它相关的所有 microtask 最好能在 16.7ms 内完成。

也不是每轮事件循环都会执行 update rendering,浏览器有自己的优化策略,可能把几次的视图更新累积到一起重绘。重绘之前会通知 requestAnimationFrame 执行回调函数,即requestAnimationFrame 的执行时机是在一次或多次事件循环的 UI render 阶段。查阅 1,查阅 2

life of a frame

浏览器页面是一帧一帧绘制出来的,每一帧(Frame)都需要完成哪些工作?

  1. Input event:处理用户的交互,如点击、触碰、滚动等事件
  2. JSJS 解析执行(可能有多个事件循环)
  3. Begin frame:帧开始,更新渲染开始。窗口尺寸变更(resize执行),页面滚动(scroll执行)等的处理
  4. rAfrequestAnimationFrame
  5. Layout:布局
  6. Paint: 绘制

life of a frame

上面六个步骤完成后没超过 16 ms,说明时间有富余,此时就会执行 IntersectionObserverrequestIdleCallback 里注册的任务。

requestAnimationFrame & requestIdleCallback

  • requestAnimationFrame: 告诉浏览器在下次重绘之前执行传入的回调函数(通常是用于操纵 dom,更新动画的函数);由于是每帧执行一次,那结果就是每秒的执行次数与浏览器屏幕刷新次数一样,通常是每秒 60 次。

  • requestIdleCallback: 会在浏览器空闲时间执行回调,也就是允许开发人员在主事件循环中执行低优先级任务,而不影响一些延迟关键事件。如果有多个回调,会按照先进先出原则执行;但是当传入了 timeout,为了避免超时,有可能会打乱这个顺序;由于它发生在一帧的最后,此时页面布局已经完成,所以不建议在 requestIdleCallback 里再操作 DOM,这样会导致页面再次重绘。

Promise 不建议在这里面进行,因为 Promise 的回调属性 Event loop 中优先级较高的一种微任务,会在 requestIdleCallback 结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms。

例子:

// 一个sleep函数,模拟阻塞
function sleep(d) {
    for (var t = Date.now(); Date.now() - t <= d;);
}
let count = 0;
function callself(){
    console.log(++count, 'frame')
    sleep(16)
    if(count<20){
        window.requestAnimationFrame(callself);
    }
}
// 当count<20时候,就一直使用raf占满16ms,这样模拟一帧中无空闲时间
window.requestAnimationFrame(callself);

function cb1({didTimeout}){
    console.log('idle cb1', didTimeout)
}
function cb2({didTimeout}){
    console.log('idle cb2', didTimeout)
}
function cb3({didTimeout}){
    console.log('idle cb3', didTimeout)
}

// 注册三个rIC回调,正常是按照先进先出原则执行这三个回调,当设置的有timeout,该回调会被提前
window.requestIdleCallback(cb1)
window.requestIdleCallback(cb2)
window.requestIdleCallback(cb3, {
    timeout: 30
})

浏览器举例

通过例子加深对浏览器事件循环执行顺序的理解:

eg1:

Promise.resolve().then(function promise1() {
  console.log('promise1');
});
setTimeout(function setTimeout1() {
  console.log('setTimeout1');
  Promise.resolve().then(function promise2() {
    console.log('promise2');
  });
}, 0);

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

eg2:

解析查阅

new Promise((resolve) => {
  resolve(1);
  Promise.resolve().then(() => console.log(2));
  console.log(4);
}).then((t) => console.log(t));
console.log(3);

eg3:

解析查阅 参照

new Promise((resolve) => {
  resolve(1);
  Promise.resolve({
    then: function (resolve, reject) {
      console.log(2);
      resolve(3);
    },
  }).then((t) => console.log(t));
  console.log(4);
}).then((t) => console.log(t));
console.log(5);

eg4:

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(function () {
  console.log('setTimeout');
}, 0);
async1();
new Promise(function (resolve) {
  console.log('promise1');
  resolve();
})
  .then(function () {
    console.log('promise2');
  })
  .then(function () {
    console.log('promise3');
  });
console.log('script end');

eg5:

console.log('start');

var intervalA = setInterval(() => {
  console.log('intervalA');
}, 0);

setTimeout(() => {
  console.log('timeout');

  clearInterval(intervalA);
}, 0);

var intervalB = setInterval(() => {
  console.log('intervalB');
}, 0);

var intervalC = setInterval(() => {
  console.log('intervalC');
}, 0);

new Promise((resolve, reject) => {
  console.log('promise');

  for (var i = 0; i < 10000; ++i) {
    i === 9999 && resolve();
  }

  console.log('promise after for-loop');
})
  .then(() => {
    console.log('promise1');
  })
  .then(() => {
    console.log('promise2');

    clearInterval(intervalB);
  });

new Promise((resolve, reject) => {
  setTimeout(() => {
    console.log('promise in timeout');
    resolve();
  });

  console.log('promise after timeout');
})
  .then(() => {
    console.log('promise4');
  })
  .then(() => {
    console.log('promise5');

    clearInterval(intervalC);
  });

Promise.resolve().then(() => {
  console.log('promise3');
});

console.log('end');

eg6:

解析查阅

<script>
  console.log('start');

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

  Promise.resolve().then(() => {
    console.log('promise1');
  });
</script>
<script>
  setTimeout(() => {
    console.log('timeout2');
  }, 0);

  requestAnimationFrame(() => {
    console.log('requestAnimationFrame');
  });

  Promise.resolve().then(() => {
    console.log('promise2');
  });

  console.log('end');
</script>
<!-- 输出:start promise1 end promise2 requestAnimationFrame timeout1 timeout2  -->

NODE 中的事件循环(适用于 NODE 11 以下)

Node.js 采用 V8 作为 js 的解析引擎,而事件循环方面使用了自己设计的 libuvNode.js 的事件循环核心对应 libuv 中的 uv_run 函数,整个事件循环迭代就是一个 while 无限循环。

libuv 是使用 C 语言实现的单线程非阻塞异步 I/O 解决方案,本质上它是对常见操作系统底层异步 I/O 操作的封装,并对外暴露功能一致的 API, 首要目的是尽可能的为 nodejs 在不同系统平台上提供统一的事件循环模型。

事件循环模型

   ┌───────────────────────┐
┌─>│        timers         │ 执行到期的 `setTimeout` 和 `setInterval` 回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │ 执行到期的一些被延迟调用的 `I/O` 回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │ 仅 `node` 内部使用
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<──connections───     │ 立即执行大部分 `I/O` 回调
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │ 执行到期的 `setImmediate` 的回调
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │ 执行注册 `close` 事件的回调,如 `socket` 等
   └───────────────────────┘

其中外部输入数据则从 poll 阶段开始。

  • event loop 总是要经历以上阶段,由 timer 阶段开始,由 close 回调函数阶段结束。
  • event loop 的每个阶段都有一个任务队列。
  • event loop 到达某个阶段时,将执行该阶段的任务队列,该任务队列完成后执行 nextTick队列,然后执行 微任务队列。直到队列清空或执行的回调达到系统上限后,才会转入下一个阶段。
  • 当所有阶段被顺序执行一次后,称 event loop 完成了一个 tick

示意图

poll

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

  • 计算当前轮询需要阻塞后续阶段(即维持)的时间:由后续 tick 各个阶段是否存在不为空的回调函数队列最近的计时器时间节点 决定。若所有队列为空且不存在任何计时器,那么事件循环将 无限制地维持在 poll 阶段。其中
    • 对于事件循环部分属性而言:
      • uv_stop() 函数标记为停止时,不阻塞;
      • 不处于活动状态时且不存在活动的 request 时,不阻塞;
      • idle 句柄队列不为空时,不阻塞;
      • I/O callbacks 回调队列不为空时,不阻塞;
      • closing 句柄不为空时,不阻塞;
    • 对于计时器而言:
      • 若不存在任何计时器(setTimeout/setInterval),那么当前事件循环中的 poll 阶段将一直阻塞
      • 若最近计时器时间节点<=开始时间,则表明在计时器二叉最小堆中至少存在一个过期的计时器,那么当前 poll 阶段的超时时间将被设置为 0 即不阻塞。这是为了尽可能快的进入下一阶段,即尽可能快地结束当前事件循环。
      • 若最近计时器时间节点>开始时间,poll 将根据此差值来阻塞当前阶段,阻塞是为了保持在该阶段从而尽可能快的处理异步 I/O 事件。
  • 处理 poll 队列的事件回调(事件循环 tick 总有一种维持 poll 状态的倾向,为了尽可能快的处理随时可能到来异步 I/O 事件)

如果 poll 阶段进入 idle 状态并且存在 setImmediate,那么 poll 阶段将打破无限制的等待状态,并进入 check 阶段执行 setImmediate

poll 阶段控制了计时器回调函数的执行时机:在没有满足 poll 阶段的结束条件时,就无法进入到下一个事件循环 tick 的 timer 阶段,就无法执行 timer queue 中到期计时器的回调函数。 因为 poll 阶段的超时时间在进入 poll 阶段之前计算,故当前 poll 阶段中回调函数队列中的计时器并不影响当前 poll 阶段的超时时间。

node 内置定时器

setTimeout(() => {
  //一些代码;
}, timeout);

nodejs 中所有计时器是通过一个双向链表实现关联。有且仅有两种计时器:setTimeout/setIntervalsetImmediate。同浏览器一致,所有的计时器实现都不能保证在到达时间阈值后回调函数一定会被立即执行,它们只能保证在到达时间阈值后,尽快执行由计时器注册的回调函数。

所有计时器在 libuv 中是以计时器回调函数的执行时间节点(即 time + timeout,而不是计时器时间阈值(上述代码里的timeout))构成的二叉最小堆结构来存储。通过二叉最小堆的根节点来获取时间线上最近的 timer 对应的回调函数的句柄,再通过该句柄对应的 timeout 值获取最近的计时器的执行时间节点。

时间阈值 timeout 的取值范围是 1 ~ 231-1 ms,且为整数。所有超出时间阈值范围的时间阈值都会被重置为 1ms,且所有非整数值会被转换为 整数值。即 setTimeout(callback, 0) 会自动转为 setTimeout(callback, 1)

node 注意点

  • process.nextTick(): 这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当 每个阶段 完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且 优先于其他 microtask 执行
  • setTimeout(callback, 0)setImmediate(callback) 的执行顺序是随机的,跟代码执行时间与 1ms 大小比较有关。而上述代码在 I/0 callbacks 阶段调用则执行顺序是 setImmediate 在前,setTimeout 在后(第二轮)。

node 举例

根据以上知识点,以下这些例子就很容易理解了:

eg1:

const fs = require('fs');

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

eg2:

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');

eg3:

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');
      });
    });
  });
});

eg4:

function sleep(time) {
  let startTime = new Date();
  while (new Date() - startTime < time) {}
  console.log('1s over');
}
setTimeout(() => {
  console.log('setTimeout - 1');
  setTimeout(() => {
    console.log('setTimeout - 1 - 1');
    sleep(1000);
  });
  new Promise((resolve) => resolve()).then(() => {
    console.log('setTimeout - 1 - then');
    new Promise((resolve) => resolve()).then(() => {
      console.log('setTimeout - 1 - then - then');
    });
  });
  sleep(1000);
});

setTimeout(() => {
  console.log('setTimeout - 2');
  setTimeout(() => {
    console.log('setTimeout - 2 - 1');
    sleep(1000);
  });
  new Promise((resolve) => resolve()).then(() => {
    console.log('setTimeout - 2 - then');
    new Promise((resolve) => resolve()).then(() => {
      console.log('setTimeout - 2 - then - then');
    });
  });
  sleep(1000);
});

node 11 版本后

和浏览器趋同,都是每执行一个宏任务就执行完微任务队列,故上面例子在不同版本表现不一致。查阅 1,查阅 2

两者循环区别(NODE 11 之前)

  • 在浏览器中,事件循环是由 macrotask、microtask 组成,执行顺序是 macrotask->microtask
  • Node.js 中,事件循环由多个 阶段 phase多个回调函数队列 callbacks queues 组成。在每一个阶段执行顺序是 macrotask->nextTick->microtask

其他