1. console.log(1);
  2. setTimeout(function () {
  3. console.log(2);
  4. }, 0);
  5. console.log(3);
  6. // 1 3 2
  7. console.log("A");
  8. while (1) {}
  9. console.log("B");
  10. // A
  11. // while同步,永远不会执行到B(相当于一个死循环)、
  12. console.log("A");
  13. setTimeout(function () {
  14. console.log("B");
  15. }, 0);
  16. while (1) {}
  17. // A
  18. // 因为setTimeout是一个异步队列,在同步任务完成之前,任何的异步队列是不会被响应的
  19. for (var i = 0; i < 4; i++) {
  20. setTimeout(function () {
  21. console.log(i);
  22. }, 1000);
  23. }
  24. // 4 4 4 4
  25. // settimeout是异步执行,1000ms后往任务队列里面添加一个任务,只有主线上的全部执行完,才会执行任务队列里的任务,当主线执行完成后,i是4,所以此时再去执行任务队列里的任务时,i全部是4了。
  26. // 对于打印4次是:每一次for循环的时候,settimeout都执行一次,但是里面的函数没有被执行,而是被放到了任务队列里面,等待执行,for循环了4次,就放了4次,当主线程执行完成后,才进入任务队列里面执行

JS 的单线程的概念

概念

JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

一定时间内只能执行一项任务,不能执行多项任务,为了要执行的代码,就有一个 javascript 任务队列。基于这一概念,JS 执行任务时分为两种模式:同步和异步。

“同步模式”是指后一个任务必须等待前一个任务完成后再执行,前一个任务加载时会阻塞后面程序的进行;“异步模式”不一定按顺序执行任务,所以不会阻塞程序的运行。

注意

Web Worker标准的提出,为了利用多核CPU的计算能力,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以没有改变JavaScript单线程的本质。

大白话总结

同一时间只能做一件事情

任务队列

JS 任务队列中,有两类任务队列:宏任务队列(macro tasks)和微任务队列(micro tasks)。宏任务队列可以有多个,微任务队列只有一个。那么什么任务,会分到哪个队列呢?

  • 宏任务:script脚本执行(全局任务), setTimeout, setInterval, setImmediate定时事件, I/O操作, UI rendering渲染等。
  • 微任务:node中process.nextTick, Promise回调, Object.observer, Dom变化监听MutationObserver.

JS运行机制 - 图1
JS 运行整体流程

解释一下上图:

  • 同步和异步任务分别进入不同的执行”场所”,同步的进入主线程,异步的进入 Event Table 并注册函数。
  • 当指定的事情完成时,Event Table 会将这个函数移入 Event Queue
  • 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的 Event Loop (事件循环)。

那主线程执行栈何时为空呢?js引擎存在 monitoring process 进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去 Event Queue 那里检查是否有等待被调用的函数。

Event Loop

浏览器的 Event Loop 遵循的是 HTML5 标准,NodeJs 的 Event Loop 遵循的是 libuv

浏览器

  1. 取一个宏任务来执行。执行完毕后,下一步
  2. 取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空,执行下一步
  3. 更新 UI 渲染

Event Loop 会无限循环执行上面 3 步,这就是 Event Loop 的主要控制逻辑。其中,第 3 步(更新 UI 渲染)会根据浏览器的逻辑,决定要不要马上执行更新。毕竟更新 UI 成本大,所以,一般都会比较长的时间间隔,执行一次更新。

从执行步骤来看,我们发现微任务,受到了特殊待遇!我们代码开始执行都是从 script(全局任务)开始,所以,一旦我们的全局任务(属于宏任务)执行完,就马上执行完整个微任务队列。

代码举例

  1. Promise.resolve().then(() => {
  2. console.log(1);
  3. });
  4. // 宏任务
  5. setTimeout(() => {
  6. console.log(2);
  7. }, 0);
  8. var s = new Date();
  9. while (new Date() - s < 50); // 阻塞50ms
  10. Promise.resolve().then(() => {
  11. console.log(3);
  12. });
  13. new Promise(function (resolve, reject) {
  14. console.log(4);
  15. resolve();
  16. }).then(function () {
  17. console.log(5);
  18. });
  19. process.nextTick(function () {
  20. console.log(6);
  21. });
  22. console.log(7);

代码解释

第一轮:主线程开始执行,遇到 Promise.then() 的回调函数丢到微任务队列中,再继续执行,遇到setTimeout ,将 setTimeout 的回调函数丢到宏任务队列中,(加 50ms 的阻塞,是因为 **setTimeout****delayTime** 最少是 4ms. 为了避免认为 **setTimeout** 是因为 4ms 的延迟而后面才被执行的,我们加了 50ms 阻塞),再继续执行,遇到 Promise.then() 的回调函数丢到微任务队列中,再继续执行,在往下执行 new Promise 立即执行,输出4,then 的回调函数丢到微任务队列中,再继续执行,遇到process.nextTick,同样将回调函数扔到为任务队列,再继续执行,输出7;
当所有同步任务执行完成后看有没有可以执行的微任务,发现有 then 函数和 nextTick 两个微任务,先执行哪个呢?process.nextTick 指定的异步任务总是发生在所有异步任务之前,因此先执行process.nextTick 输出6然后执行第一个 then 函数输出1,同理输出3和输出5,第一轮执行结束;
第二轮:从宏任务队列开始,发现 setTimeout 回调,输出2执行完毕。

因此结果是: 4 7 6 1 3 5 2

面试回答

  • 首先 js 是单线程运行的,在代码执行时,通过任务进入执行栈并判断任务类型来保证代码的有序执行。
  • 在执行同步代码的时候,如果遇到了异步事件,挂起该任务,继续执行执行栈中的其他任务
  • 当同步事件执行完毕后,将异步事件对应的回调加入到与当前执行栈中不同的另一个任务队列中等待执行。
  • 任务队列可以分为宏任务队列和微任务队列,如果当前执行栈中的事件执行完毕后,判断微任务队列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。
  • 当微任务队列中的任务都执行完成后再去判断宏任务对列中的任务。

    NodeJs

  1. 初始化 Event Loop
  2. 执行您的主代码。这里同样,遇到异步处理,就会分配给对应的队列。直到主代码执行完毕。
  3. 执行主代码中出现的所有微任务:先执行完所有 nextTick(),然后在执行其它所有微任务。
  4. 开始 Event Loop

NodeJs 的 Event Loop 分 6 个阶段执行:

  1. ┌───────────────────────────┐
  2. ┌─>│ timers
  3. └─────────────┬─────────────┘
  4. ┌─────────────┴─────────────┐
  5. pending callbacks
  6. └─────────────┬─────────────┘
  7. ┌─────────────┴─────────────┐
  8. idle, prepare
  9. └─────────────┬─────────────┘ ┌───────────────┐
  10. ┌─────────────┴─────────────┐ incoming:
  11. poll │<─────┤ connections,
  12. └─────────────┬─────────────┘ data, etc.
  13. ┌─────────────┴─────────────┐ └───────────────┘
  14. check
  15. └─────────────┬─────────────┘
  16. ┌─────────────┴─────────────┐
  17. └──┤ close callbacks
  18. └───────────────────────────┘

以上的 6 个阶段,具体处理的任务如下:

  • timers: 这个阶段执行 setTimeout()setInterval() 设定的回调
  • pending callbacks(I/O callbacks):会执行除了timer,close,setImmediate之外的事件回调
  • idle, prepare: 仅系统内部使用
  • poll: 轮训,不断检查有没有新的 I/O callback 事件,在适当的条件下会阻塞在这个阶段,主要分为如下两个步骤:
    • 先查看 poll queue 中是否有事件,有任务就按先进先出的顺序依次执行回调
    • queue 为空时,会检查是否有 setImmediate()callback,如果有就进入 check 阶段执行这些 callback。但同时也会检查是否有到期的 timer,如果有,就把这些到期的 timercallback按照调用顺序放到 timer queue 中,之后循环会进入 timer 阶段执行 queue 中的 callback
  • check: 执行 setImmediate() 设定的回调
  • close callbacks: 执行比如 socket.on('close', ...) 的回调

每个阶段执行完毕后,都会执行所有微任务(先 nextTick,后其它),然后再进入下一个阶段。

理解 process.nextTick() 您可能已经注意到 process.nextTick() 在关系图中没有显示,即使它是异步 API 的一部分。这是因为 process.nextTick() 在技术上不是事件循环的一部分。相反,无论事件循环的当前阶段如何,都将在当前操作完成后处理 nextTickQueue。这里的一个操作被视作为一个从 C++ 底层处理开始过渡,并且处理需要执行的 JavaScript 代码。

回顾上面关系图,任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前得到解决。这可能会造成一些糟糕的情况, 因为它允许您通过进行递归 process.nextTick() 来“饿死”您的 I/O 调用,阻止事件循环到达 轮询 阶段。

Links