1.单线程

所谓的单线程,可以简单理解为做事情讲究先来后到,要做后面的事情,你得等前面的事情做完 —— 不管它需要多久。
既然如此,JS 引擎为何还要采取这种单线程的机制呢?
JS 主要是与用户互动,这个过程涉及到对 DOM 节点的操作,如果 JS 是多线程的,一个线程在节点上添加内容,一个线程却要删除这个 DOM 节点,到底要以哪个为准呢?所以这就是为什么 JS 从一出现就秉承着单线程的运行机制。
另外还要注意:

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

2.同步任务和异步任务

很显然,单线程会带来一个问题:就是代码执行的阻塞。比如:排在前面的任务如果耗时长,则后面的任务不得不一直等待它。 如果说耗时长是因为计算量大、CPU 一直忙着计算的话倒也还好,可事实是 —— 大部分时间浪费在了 IO 上(Ajax 从网络上获取数据),还有其他的如鼠标点击、setTimeout 等等。因此这里提出了同步任务和异步任务的概念。

在 JS 中,可以将同步和异步简单理解为执行顺序的问题。

2.1 同步(sync):

即上面所说的后面等待前面。同步对应了同步任务(synchronous),即可以按照正常顺序执行的任务,比如加载页面骨架等。

2.2 异步(async):

即把耗时长的任务挂起,先执行耗时短的,再回过头执行耗时长的。
异步对应了异步任务(asynchronous),即不适合按照正常顺序执行的任务,主要包括:

  • onclick 等事件绑定:当事件触发之后,回调函数会被添加到任务队列中
  • setTimeout/setInterval 等计时器:当浏览器完成计时之后,回调函数会被添加到任务队列中;
  • AJAX 请求:当网络请求完成返回之后,回调函数会被添加到任务队列中

3.事件循环

事件循环又叫 Event loop,它是由 JS 引擎的运行环境(浏览器或 Nodejs)提供的一种事件调度机制。

事件循环是实现异步的一种机制。一个线程中只有一个事件循环,我们将这个循环的每一次循环执行过程称之为一个 tick。 具体每一次循环是怎么执行的,后文会讲。

4.执行栈和任务队列

事件循环机制离不开执行栈和任务队列的相互配合。js中将同步任务放到主线程上执行,形成“执行栈”;异步任务则放到任务队列中。

任务队列的分类标准之一:

一个线程可以拥有多个任务队列。每一个任务队列都对应某一任务源,并包含了一堆来自该任务源的任务。任务源是什么?像setTimeout/Promise/DOM事件/AJAX 等都是任务源,来自同类任务源的任务我们称它们是同源的,比如 setTimeout 与 setInterval 就是同源的。

任务队列的分类标准之二:

在 ES6 中,我们用另一种方式对任务队列进行分类。
宏任务: 即 macro-task,包括整体 script 代码、setTimeout、setInterval、AJAX、用户I\O 等。宏任务会对应地进入宏任务队列中;
微任务: 即 micro-task,包括 Promise、process.nextTick(callback)(可以理解为 Nodejs 版的 setTimeOut)。微任务会对应地进入微任务队列中。

5.事件循环的具体运作过程

总的来说,事件循环的顺序,决定了 JS 代码执行的顺序。

  • 第一次循环开始

    • 宏任务:首先执行 <script> 包裹的整体代码。同步任务直接执行,异步任务分发到对应的任务队列中
    • 微任务:整体代码执行完,执行栈清空。开始读取微任务队列中的所有微任务,全部执行
  • 第一次循环结束,第二次循环开始

    • 宏任务:读取宏任务队列中的一个宏任务,执行
    • 微任务:读取微任务队列中的所有微任务,全部执行
  • 第二次循环结束,第三次循环开始

    • ……

最终,所有的队列都清空,执行栈也清空,事件循环正式结束。

PS:执行任务指的其实是执行这些任务指定的回调函数,并且要注意:若回调函数中又有宏任务,则该宏任务会被安排到下一轮循环中,不会在这一轮就执行。

6.事件循环的例子

下面通过三个由易到难的例子来理解上面所说的过程。

例1

setTimeout(() => {
    task()
}3000)

// 同步的睡眠函数
sleep(10000000)

分析:
跑一下代码,会发现控制台执行 task() 需要的时间远远超过 3 秒,这就说明我们有的人理解的"setTimeout 的第二个参数指定了多长时间后执行回调函数"的说法是错误的。
让我们来分析一下这个过程:

  • <script> 中的整段代码作为第一次循环的宏任务,进入主线程。即开启第一次事件循环;
  • 首先遇到了 setTimeout,将其回调函数 task() 进入 Event Table 并注册,同时浏览器开始计时;
  • 继续,遇到了 sleep 函数,这是一个同步的睡眠任务,所以直接执行。但是速度很慢,非常慢,而浏览器计时仍在继续;
  • 好了,3 秒终于到了,计时事件 setTimeout 总算完成,可以把 task() 放入任务队列了;
  • 但是主线程上的 sleep 太慢了,还没执行完,于是我们只好等着;
  • sleep 终于执行完了,执行栈清空,第一次循环的宏任务结束;
  • 读取微任务队列,但是没有微任务,所以第一次循环结束了;
  • 第二次循环开始,读取宏任务队列,刚好,里面有一个 setTimeout 对应的 task() 回调函数,压栈、令其进入主线程执行;
  • 执行栈清空了,任务队列也清空了,事件循环正式结束。

现在,我们知道 setTimeout 的回调函数是一开始就注册进 Event Table 的,但是那时并未进入任务队列 —— 要经过一定的时间,而这个时间由第二个参数来指定。也就是说,第二个参数指定的是“多长时间后将回调函数放入到任务队列中”。另外,即使回调函数已经进入队列,也得先等主线程的执行栈清空后才有可能轮到自己。

我们还经常遇到 setTimeout(fn,0)(或者干脆没有指定第二个参数)这样的代码,这是不是意味着可以立即执行呢?

不是。setTimeout(fn,0) 的意义在于,指定某个任务在主线程最早可得的空闲时间执行,意思就是注册进 Event Table 的同时就将任务放入队列中,只要主线程执行栈内的同步任务全部执行完成,且此时没有微任务队列,那么该任务就会马上压栈并执行。

例 2

setTimeout(function() {
    console.log('setTimeout');
})

new Promise(function(resolve) {
    console.log('promise');
    resolve()
}).then(function() {
    console.log('then');
})
console.log('console');

分析:

  • <script>中的整段代码作为第一个宏任务,进入主线程。即开启第一次事件循环;
  • 遇到 setTimeout,将其回调函数放入 Event Table 中注册,然后分发到宏任务队列中(第二个参数不设定时,默认延迟为 0);
  • 接下来遇到 new Promise、Promise,立即执行,输出: promise 。将 then 的回调函数分发到微任务队列中;
  • 遇到 console.log,立即执行,输出: console
  • 整体代码作为第一个宏任务执行结束,此时去微任务队列中查看有哪些微任务,结果发现了 then 的回调函数,然后将它推入主线程并执行,输出: then
  • 第一轮事件循环结束,第二轮事件循环开始;
  • 先从宏任务开始,去宏任务队列中查看有哪些宏任务,结果发现了 setTimeout 对应的回调函数,将它推入主线程并执行,输出:setTimeout
  • 然后去微任务队列中查看是否有事件,结果没有;
  • 此时第二轮事件循环结束;
  • 执行栈清空了,任务队列也清空了,事件循环正式结束。

例3

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

分析:

第一轮事件循环:

a) 整段 <script> 代码作为第一个宏任务进入主线程,即开启第一轮事件循环
b) 遇到 console.log,立即执行。输出:1
c) 遇到 setTimeout,将其回调函数放入 Event table 中注册,然后分发到宏任务队列中。我们将其标记为 setTimeout1
d) 遇到 process.nextTick,其回调函数放入 Event table 中注册,然后被分发到微任务队列中。记为 process1
e) 遇到 new Promise、Promise,立即执行;then 回调函数放入 Event table 中注册,然后被分发到微任务队列中。记为 then1。输出: 7
f) 遇到 setTimeout,将其回调函数放入 Event table 中注册,然后分发到宏任务队列中。我们将其标记为 setTimeout2

此时第一轮事件循环的宏任务结束,下表是第一轮事件循环的宏任务结束时各任务队列的情况:

宏任务队列微任务队列
第一轮事件循环(宏任务已结束)process1、then1
第二轮事件循环(未开始)setTimeout1
第三轮事件循环(未开始)setTimeout2

可以看到第一轮事件循环的宏任务结束后,微任务队列中还有两个微任务待执行,因此这两个事件会被推入主线程,然后执行

g)执行 process1,输出:6

h)执行 then1,输出:8

第一轮事件循环正式结束!

第二轮事件循环:

a)第二轮事件循环从宏任务 setTimeout1 开始。遇到 console.log,立即执行。输出: 2
b)遇到 process.nextTick,其回调函数放入 Event table 中注册,然后被分发到微任务队列中。记为 process2
c)遇到 new Promise,立即执行;then 回调函数放入 Event table 中注册,然后被分发到微任务队列中。记为 then2。输出: 5

此时第二轮事件循环宏任务结束,下表是第二轮事件循环的宏任务结束时各任务队列的情况:

宏任务事件队列微任务事件队列
第一轮事件循环(已结束)
第二轮事件循环(宏任务已结束)process2、then2
第三轮事件循环(未开始)setTimeout2

可以看到第二轮事件循环的宏任务结束后,微任务事件队列中还有两个微任务待执行,因此这两个微任务会被推入主线程,然后执行:

d) 执行 process2。输出:3
e) 执行 then2。输出:5

第二轮事件循环正式结束!

第三轮事件循环:

a) 第三轮事件循环从宏任务 setTimeout2 开始。遇到 console.log,立即执行。输出: 9
b) 遇到 process.nextTick,其回调函数放入 Event table 中注册,然后被分发到微任务队列中。记为 process3
c) 遇到 new Promise,立即执行;then 回调函数放入 Event table 中注册,然后被分发到微任务队列中。记为 then3。输出: 11

此时第三轮事件循环宏任务结束,下表是第三轮事件循环宏任务结束时各任务队列的情况:

宏任务事件队列微任务事件队列
第一轮事件循环(已结束)
第二轮事件循环(已结束)
第三轮事件循环(未开始)(宏任务已结束)process3、then3

可以看到第二轮事件循环宏任务结束后微任务队列中还有两个事件待执行,因此这两个事件会被推入主线程,然后执行

d) 执行 process3。输出:10
e) 执行 then3。输出:12

第二轮事件循环正式结束!

最后,执行栈清空,任务队列也清空,事件循环正式结束!

注意

  • 微任务队列中可能有多个微任务,每次查看微任务队列,都会把里面的微任务一次性清空;

  • 宏任务队列也可能有多个宏任务,但每次执行完一个宏任务之后,都会去清空一次微任务队列 —— 换句话说,在执行下一个宏任务之前,本次宏任务所产生的微任务必须先执行完。

参考:
https://segmentfault.com/a/1190000017970432
http://www.ruanyifeng.com/blog/2014/10/event-loop.html