JS事件循环机制(event loop)

为什么 javascript 是单线程的 ?

首先是历史原因,在创建 javascript 这门语言时,多线程并不流行,硬件支持也不好。

其次是因为多线程的复杂性,多线程操作需要加锁,编码的复杂性会增高。

如果多个线程同时操作 DOM ,会导致 DOM 渲染的结果不可预期。

为什么 GUI 渲染线程与 JS 引擎线程互斥

这是由于 JS 是可以操作 DOM 的,如果同时修改元素属性并同时渲染界面(即 JS 线程和 UI 线程同时运行),那么渲染线程前后获得的元素就可能不一致了。

因此,为了防止渲染出现不可预期的结果,浏览器设定 GUI 渲染线程和 JS 引擎线程为互斥关系,当 JS 引擎线程执行时 GUI 渲染线程会被挂起,GUI 更新则会被保存在一个队列中等待 JS 引擎线程空闲时立即被执行。

JS 引擎线程、定时器线程、事件(触发)线程三者是如何协同工作的?

当代码执行到 setTimeout/setInterval 时,实际上是 JS 引擎线程通知定时触发器线程,间隔一个时间后,会触发一个回调事件, 而定时触发器线程在接收到这个消息后,会在等待的时间后,将回调事件放入到由事件触发线程所管理的事件队列中。

当我们的同步任务执行完,JS 引擎线程会询问事件触发线程,在事件队列中是否有待执行的回调函数,如果有就会加入到执行栈中交给JS 引擎线程执行。

JS 引擎线程GUI 渲染线程是互斥的关系,浏览器为了能够使宏任务和 DOM 任务有序的进行,会在一个宏任务执行结果后,在下一个宏任务执行前,GUI 渲染线程开始工作,对页面进行渲染。此阶段如果时间足够,会执行 requestAnimationFrame

为什么需要有微任务 ?

我们来看一个 async function 的例子 const asyncTick = () => Promise.resolve();

(async function () {
  for (let i = 0; i < 10; i++) {
    await asyncTick();
  }
})();

我们看到这里明明其实没有异步等待的任务,但是如果 Promise.resolve 每次都和 setTimeout 一样往异步队列里丢一个任务然后等待一个事件循环来执行。看起来似乎没有什么大的问题,因为『事件循环』和一个 for 循环听起来似乎并没有什么本质上的不同。

然后在事实上,一次事件循环的耗时是远远超出一次 for 循环的。

我们都知道 setTimeout(fn, 0) 并非真的是立即执行,而是要等待至少 4ms (事实上可能是 10ms)才会执行。

这意味着如果没有微任务的概念,我们仍然采用宏任务的机制去执行 async function(实际上就是 Promise) ,性能会非常的糟糕。

执行流程

先执行同步代码(包括 promise 自身),遇到 then 则等当前周期主流程执行完立刻执行,遇到定时器则放入事件队列等待下一次执行周期。

异步代码分为:宏任务和微任务。 生成宏任务(Macrotask):setTimeout、setInterval、script(整体代码块) 生成微任务(Microtask):node 下的 nextTick、promise 的 then

代码示例(浏览器控制台执行)

console.log('start');
setTimeout(() => {
  console.log('timer1');
  new Promise(() => {
    console.log('promise1');
  });
});
window.requestAnimationFrame(() => {
  console.log('requestAnimationFrame'); // 下一次事件循环的间隔执行(浏览器专有)
});
requestIdleCallback(() => {
  console.log('requestIdleCallback'); // requestIdleCallback在浏览器完全空闲下来才去执行
});
let promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    console.log('timer2');
  });
  resolve();
  console.log('promise2');
});
fn();
console.log('end');
promise.then(() => {
  console.log('then');
});
function fn() {
  console.log('fn');
}
// 主线程:start、promise2、fn、end
// 微任务:then
// 第一次执行结束,在下一次宏任务之前的间隔执行:requestAnimationFrame
// 执行第一个宏任务:timer1、promise1
// 执行第二个宏任务:timer2
// 浏览器空闲执行:requestIdleCallback

Promise 是宏任务(同步执行),但 Promise 的回调函数属于异步任务,会在同步任务之后执行(比如说 then、catch、finally)。 Promise 的回调函数不是正常的异步任务,而是微任务(microtask)。

代码示例(node 环境执行)

使用node filename.js的方式执行。

console.log('start');

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

  new Promise(() => {
    console.log('promise1');
  });

  setTimeout(() => {
    console.log('timer2');
  }, 20); // 100+20 < 200,timer2先于timer3执行;如果将20改为99,执行顺序将变得不确定。
}, 100);

setTimeout(() => {
  console.log('timer3');
}, 200);

setTimeout(() => {
  console.log('timer4');
}, 10);

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

  resolve();

  console.log('promise2');
});
promise.then(() => {
  console.log('then');
});
// nextTick微任务(node专有,总是先于then执行)
process.nextTick(() => {
  console.log('nextTick');
  setTimeout(() => {
    console.log('timer6');
  });
});

console.log('end');
// 主线程:start promise2 end
// 微任务:nextTick,then
// 宏任务:timer5
// 宏任务:timer6
// 宏任务:timer4
// 宏任务:timer1,promise1
// 宏任务:timer2
// 宏任务:timer3

setTimeout 的执行顺序与它的第二个参数相关,时间未到,回调函数不会被推入事件队列。

考虑到 JS 执行需要时间,将 timer2 的第二参数改为 99,执行结果将变得不确定。可能先 timer2,也可能先 timer3,多试几次就看到了。

特殊面试题

Promise.resolve()
  .then(() => {
    console.log(0);
    return Promise.resolve(4);
  })
  .then((res) => {
    console.log(res);
  });

Promise.resolve()
  .then(() => {
    console.log(1);
  })
  .then(() => {
    console.log(2);
  })
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(5);
  })
  .then(() => {
    console.log(6);
  });

// 0, 1, 2, 3, 4, 5, 6

Js 引擎为了让 microtask 尽快的输出,做了一些优化,连续的多个 then(3 个)如果没有 reject 或者 resolve 会交替执行 then,而不至于让一个堵太久完成用户无响应,不单单 v8 这样其他引擎也是这样,因为其实 promise 内部状态已经结束了。

扩展阅读

https://juejin.cn/post/6854573216212451336 https://juejin.cn/post/6945319439772434469

© 2022  Arvin Xiang
Built with ❤️ by myself