Fork me on GitHub

JS-EventLoop

JavaScript 事件循环
进程与线程
单线程的非阻塞脚本语言
同步任务和异步任务

参考:
一次弄懂 Event Loop(彻底解决此类面试问题)

进程与线程

进程是程序在执行过程中分配和管理资源的基本单位,
线程是 CPU 调度和分配的基本单位,它可与同属于一个进程的其他线程共享进程所拥有的全部资源。
进程和线程都是一个时间段的描述,是 CPU 工作时间段的描述,不过是颗粒大小不同。
一个进程可以包含多个线程。
单个 CPU 总是运行一个进程,其他进程处于非运行状态。
一个 CPU 的一个时间点上运行的可能是一个进程上的不同线程。
可以简单的比喻:线程=车厢,进程=火车。

JavaScript 是单线程的非阻塞脚本语言

什么是单线程

主程序只有一个线程,即同一时间片段内主程序只能执行单个任务。

为什么 JavaScript 是单线程

因为 JavaScript 的执行环境是浏览器。主要用途是与用户互动,以及操作 DOM。
例如,操作 Dom 的时候,如果是多线程的操作一个 Dom,一个向其添加事件,而另一个删除了这个 dom,这时无法处理。

单线程意味着什么

单线程意味着,JavaScript 代码在执行的任何时候,都只有一个主线程来处理所有的任务。

如何解决单线程带来的性能问题

异步。所以 JavaScript 是非阻塞,意味着当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如 I/O 事件)的时候,主线程会挂起(pending)这个任务,然后异步任务返回结果的时候再根据一定规则去执行相应的回调。
所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

宏任务和微任务

  • 宏任务(MacroTask/Task)由宿主(Node、浏览器)发起的

    • script 全部代码、
    • setTimeout、
    • setInterval、setImmediate(浏览器暂时不支持,只有 IE10 支持,具体可见 MDN)
    • I/O、
    • postMessage、
    • UI Rendering。
    • requestAnimationFrame

      特性:

      1. 宏任务所处的队列就是宏任务队列

      2. 第一个宏任务队列中只有一个任务:执行主线程上的 JS 代码;如果遇到上方的异步任务,会创建出一个新的宏任务队列,存放这些异步函数执行完成后的回调函数。

      3. 宏任务队列可以有多个

      4. 宏任务中可以创建微任务,但是在宏任务中创建的微任务不会影响当前宏任务的执行。

  • 微任务(MicroTask/jobs)JavaScript 自身(JS 引擎)发起

    • Process.nextTick(Node 独有)、
    • Promise()、
    • await、
    • MutationObserver(html5 新特性)
    • Object.observe(废弃)、

      特性:

      1. 微任务所处的队列就是微任务队列

      2. 在上一个宏任务队列执行完毕后,如果有微任务队列就会执行微任务队列中的所有任务

      3. new promise((resolve)=>{ 这里的函数在当前队列直接执行 }).then( 这里的函数放在微任务队列中执行 )

      4. 微任务队列上创建的微任务,仍会阻碍后方将要执行的宏任务队列

      5. 由微任务创建的宏任务,会被丢在异步宏任务队列中执行

      setTimeout/Promise 等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。 所以 setTimeout 和 Promise 是立即执行的,setTimeout 的第一个参数-回调函数,和 Promise 的 then 方法才会放到任务队列中。

浏览器中的 Event Loop

执行栈/函数调用栈(call stack)

同步代码的执行,按照顺序添加到执行栈中
执行栈
执行栈1

任务队列/事件队列

异步代码的执行。遇到异步事件,主线程不会等待,而是将事件挂起,继续执行栈中的其他任务。当异步任务返回结果,将他放到事件队列中,被放入事件队列不会立刻执行起回调,而是等待当前执行栈中所有任务都执行完毕,主线程空闲状态,主线程会去查找事件队列中是否有任务,如果有,则取出排在第一位的事件,并把这个事件对应的回调放到执行栈中,然后执行其中的同步代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('start')
setTimeout(function() {
console.log('1000s-setTimeout')
}, 1000)
setTimeout(function() {
console.log('500s-setTimeout')
}, 500)

console.log('end');
//start
//end
//500s-setTimeout
//1000s-setTimeout

同步任务和异步任务

同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务
异步任务: 异步任务是指不进入主线程,而进入任务队列的任务,只有任务队列通知主线程,某个异步任务可以执行了,该任务才会进入主线程。

事件循环顺序

  1. script(整体代码)开始第一次循环,之后全局上下文进入函数调用栈。
  2. 如果遇到微任务,先将异步任务交给对应的线程,当异步任务满足条件需要执行时,异步代码压入微任务队列中,同理宏任务进入宏任务队列;
  3. 调用栈清空(只剩全局),然后从微任务队列的队头依次取出微任务并执行,如果遇到其他宏任务和微任务,按照上一步执行,直到清空微任务队列
  4. 取出队头的宏任务执行,执行步骤同 2-3,一直循环下去。
  • 例子 1
1
2
3
4
5
6
7
8
9
10
11
12
13
console.log('start')

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

Promise.resolve().then(function() {
console.log('promise1')
}).then(function() {
console.log('promise2')
})

console.log('end')

步骤(盗图,懂了,就不想画了)
执行栈
重点,因为例子中的 setTimeout 的延迟是 0,所以是立刻入队(宏任务队列)的,如果是1000就是 1s 后入队,如果宏任务队列内没有其他任务,并且主程序空闲(没有其他宏任务,微任务清空),就会执行,这样就是延迟 1s 执行。否则主程序还在执行其他任务,即使 1s 后入队,延迟时间会大于 1s。

  • 例子 2

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    console.log('script start')

    async function async1() {
    await async2()
    console.log('async1 end')
    }
    async function async2() {
    console.log('async2 end')
    }
    async1()

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

    new Promise(resolve => {
    console.log('Promise')
    resolve()
    })
    .then(function() {
    console.log('promise1')
    })
    .then(function() {
    console.log('promise2')
    })

    console.log('script end')
    //新 script start - async2 end - Promise - script end - async1 end - promise1 - promise2
    //旧 script start - async2 end - Promise - script end - promise1 - promise2 - async1 end

首先 async/await 在底层转换成了 promise 和 then 回调函数,是 promise 的语法糖。

1
2
3
4
async function async1() {
await async2()
console.log('async1 end')
}

简化理解:
老版浏览器

1
2
3
4
5
6
7
8
9
10
function f(){
return new Promise((resolve, reject) => {
async2();
// Promise.resolve() 将代码插入微任务队列尾部
// resolve 再次插入微任务队列尾部
resolve(Promise.resolve())
}).then(() => {
console.log('async1 end')
})
}

这样await后面返回还是Promise的话,Promise.resolve()先进入微任务队列,这样函数async1就会挂起,代码接着执行下面的Promisepromise1promise2进入微任务队列。最后这一轮微任务队列清空输出promise1promise2resolve(Promise.resolve())执行, console.log('async1 end')才进入微任务队列。
新版浏览器

1
2
3
4
5
6
function f(){
return Promise.resolve(async2())
.then(()=>{
console.log('async1 end')
})
}

比老版减少了一次 Promise

Node 中的 Event Loop

node

分为 6 个阶段

    1. 定时器检测阶段(timers)
      执行setTimeoutsetInterval中到期的callback,并且是由 poll 阶段控制的。同样,在 Node 中定时器指定的时间也不是准确时间,只能是尽快执行。

      例子

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      const fs = require('fs');

      function someAsyncOperation(callback) {
      // Assume this takes 95ms to complete
      fs.readFile('/a.js', callback);
      }

      const timeoutScheduled = Date.now();

      setTimeout(() => {
      const delay = Date.now() - timeoutScheduled;

      console.log(`${delay}ms have passed since I was scheduled`);
      }, 100);

      // do someAsyncOperation which takes 95 ms to complete
      someAsyncOperation(() => {
      const startCallback = Date.now();

      // do something that will take 10ms...
      while (Date.now() - startCallback < 10) {
      // do nothing
      }
      });

    理论上setTimeout的回调函数是100ms后执行,但是,当进入事件循环时,它有一个空队列(fs.readFile()尚未完成),因此定时器将等待剩余毫秒数,当到达 95ms 时,fs.readFile()完成读取文件并且其需要 10 毫秒完成回调添加到轮询队列并执行。
    当回调结束时,队列中不再有回调,因此事件循环将看到已达到最快定时器的阈值,然后回到 timers 阶段以执行定时器的回调。
    在此示例中,您将看到正在调度的计时器与正在执行的回调之间的总延迟将为 105 毫秒左右。
    node

    1. I/O事件回调阶段(I/O callbacks)/pending callback
      上一轮循环中少数的callback会放在这一阶段执行
    1. 闲置阶段(idle, prepare):仅在内部使用
    1. 轮询阶段(poll)
      用于等待还未返回的 I/O 事件,比如服务器的回应、用户移动鼠标等等。
      这个阶段的时间会比较长。如果没有其他异步任务要处理(比如到期的定时器),会一直停留在这个阶段,等待 I/O 请求返回结果。
    • 回到timer阶段执行回调
    • 执行 I/O 回调

      并且在进入该阶段时如果没有设定了 timer 的话,会发生以下两件事情

    • 如果 poll 队列不为空,会遍历回调队列并同步执行,直到队列为空或者达到系统限制

    • 如果 poll 队列为空,则会发生以下两种情况之一:
      • 如果有setImmediate()回调需要执行,则会立即停止执行poll阶段并进入执行 check 阶段以执行回调。
      • 如果没有setImmediate()回到需要执行,poll 阶段将等待 callback 被添加到队列中,然后立即执行。
        当然设定了 timer 的话且 poll 队列为空,则会判断是否有 timer 超时,如果有的话会回到 timer 阶段执行回调。
    1. 检查阶段(check)
      此阶段允许人员在 poll 阶段完成后立即执行回调。
      如果 poll 阶段闲置并且 script 已排队 setImmediate(),则事件循环到达 check 阶段执行而不是继续等待。

      setImmediate()实际上是一个特殊的计时器,它在事件循环的一个单独阶段运行。它使用 libuv API 来调度在 poll 阶段完成后执行的回调。

      通常,当代码被执行时,事件循环最终将达到 poll 阶段,它将等待传入连接,请求等。
      但是,如果已经调度了回调 setImmediate(),并且轮询阶段变为空闲,则它将结束并且到达 check 阶段,而不是等待 poll 事件。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      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')

      如果 node 版本为 v11.x, 其结果与浏览器一致。执行一个宏任务之后都去清空微任务队列。

      1
      2
      3
      4
      5
      6
      7
      start
      end
      promise3
      timer1
      promise1
      timer2
      promise2

      如果 v10 版本上述结果存在两种情况:

    • 如果 time2 定时器已经在执行队列中了,先清空完 timer 队列再清空执行微任务

      1
      2
      3
      4
      5
      6
      7
      start
      end
      promise3
      timer1
      timer2
      promise1
      promise2

    timer - 如果 time2 定时器没有在执行对列中,执行结果为(没看到这种情况,我的理解是 timer1 执行的很快,timer2 还没进入队列),执行结果跟 v11.1 效果一样

    1. 关闭事件回调阶段(close callbacks)
      执行 close 事件

setImmediate() 的 setTimeout()的区别

  • setImmediate()设计用于在当前 poll 阶段完成后 check 阶段执行脚本 。
  • setTimeout() 安排在经过最小(ms)后运行的脚本,在 timers 阶段执行。
    例子

    1
    2
    3
    4
    5
    6
    7
    8
    setTimeout(() => {
    console.log('timeout');

    }, 0);

    setImmediate(() => {
    console.log('immediate');
    });

    执行定时器的顺序将根据调用它们的上下文而有所不同。 如果从主模块中调用两者,那么时间将受到进程性能的限制。
    如果在 I/O周期内移动两个调用,则始终首先执行立即回调:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    const fs = require('fs');

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

其结果可以确定一定是immediate => timeout
主要原因是在 I/O 阶段读取文件后,事件循环会先进入 poll 阶段,发现有 setImmediate 需要执行,会立即进入 check 阶段执行 setImmediate 的回调。
然后再进入timers阶段,执行setTimeout,打印timeout

Process.nextTick()

process.nextTick()虽然它是异步 API 的一部分,但未在图中显示。这是因为从技术上讲,它不是事件循环的一部分。它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microTask 执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
let bar;
setTimeout(() => {
console.log('setTimeout');
}, 0)

setImmediate(() => {
console.log('setImmediate');
})
function someAsyncApiCall(callback) {
process.nextTick(callback);
}

someAsyncApiCall(() => {
console.log('bar', bar); // 1
});

bar = 1;

对于以上代码,大家可以发现无论如何,永远都是先把 nextTick 全部打印出来。

面试题

微任务中创建微任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Promise.resolve().then(function F1() {
console.log('promise1')
Promise.resolve().then(function F4() {
console.log('promise2');
Promise.resolve().then(function F5() {
console.log('promise4');
}).then(function F6() {
console.log('promise?');
})
}).then(function F7() {
console.log('promise5');
})
}).then(function F2() {
console.log('promise3');
}).then(function F3() {
console.log('promise6');
})
  1. 首先执行宏任务,script,遇到 promise,将微任务放入微任务队列【F1】,
  2. 当前宏任务执行完毕,开始清空微任务队列,执行 F1,输出promise1,将 F4 放入任务队列【F4】(剩下的 then 需要等到 F4 执行完毕才入队),所以 F1 执行完了,F2 入队【F4,F2】,
  3. 继续清空微任务,执行 F4,输出promise2,F5 入队【F2,F5】,同理上,F4 执行完毕,F7 入队【F2,F5,F7】,
  4. 执行 F2,输出promise3,F2 执行完,F3 入队【F5,F7,F3】,
  5. 执行 F5,输出promise4,F5 执行完,F6 入队【F7,F3,F6】,
  6. 执行 F7,输出promise5
  7. 执行 F3,输出promise6,
  8. 执行 F6,输出promise?,

微任务队列中创建的宏任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
new Promise((resolve) => {
console.log('new Promise(macro task 1)');
resolve();
}).then(() => {
// 微任务1
console.log('micro task 1');
setTimeout(() => {
// 宏任务3
console.log('macro task 3');
}, 0)
})

setTimeout(() => {
// 宏任务2
console.log('macro task 2');
}, 1000)

console.log('========== Sync queue(macro task 1) ==========');

微任务创建宏任务
重点:定时器是过多少毫秒之后才会加到事件队列里
task 2 改为立即执行,那么它会在 macro task 3 之前执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
new Promise((resolve) => {
console.log('new Promise(macro task 1)');
resolve();
}).then(() => {
// 微任务1
console.log('micro task 1');
setTimeout(() => {
// 宏任务3
console.log('macro task 3');
}, 1000)
})

setTimeout(() => {
// 宏任务2
console.log('macro task 2');
}, 2000)

console.log('========== Sync queue(macro task 1) ==========');

微任务创建宏任务

宏任务中创建微任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 宏任务队列 1
setTimeout(() => {
// 宏任务队列 2.1
console.log('timer_1');
setTimeout(() => {
// 宏任务队列 3
console.log('timer_3')
}, 0)
new Promise(resolve => {
resolve()
console.log('new promise')
}).then(() => {
// 微任务队列 1
console.log('promise then')
})
}, 0)

setTimeout(() => {
// 宏任务队列 2.2
console.log('timer_2')
}, 0)

console.log('========== Sync queue ==========')

// 执行顺序:主线程(宏任务队列 1)=> 宏任务队列 2 => 微任务队列 1 => 宏任务队列

宏任务中创建微任务

-------------本文结束感谢阅读-------------