有了一定JavaScript基础的朋友们一定知道,JS里有三座大山,分别指:原型与原型链,作用域与闭包,异步。在之前的JavaScript系列文章里,我就总结了JS前两座(原型、作用域),有兴趣的朋友可以翻阅之前的系列文章,而第三座(异步)也该做一个总结了,这篇文章的目的就是通过了解JS执行顺序及机制,来更好地理顺代码中的一些异步操作。

单线程的问题

JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事,不像诸如Java等多线程语言一样能同时进行多个任务和流程。

其中JS引擎中负责解释和执行JavaScript代码的线程只有一个,叫它主线程

但是实际上还存在其他的线程。例如:处理AJAX请求的线程、处理DOM事件的线程、定时器线程、读写文件的线程(例如在Node.js中)等等。这些线程可能存在于JS引擎之内,也可能存在于JS引擎之外,在此我们不做区分。不妨叫它们工作线程

但因单线程随之所带来的问题是,所有任务都需要排队,上一个任务没执行完,下一个任务就会一直等待,会导致任务执行阻塞。具体的例子是:在浏览器中,要向服务器发送 ajax 请求,http 通信有延迟,而且等待返回数据的时间也未知,那线程就无法处理其他任务了,页面可能长时间会无法接受响应。

另外,GUI渲染线程与JS引擎线程是互斥的,当 JavaScript 引擎执行时 GUI 线程会被挂起,直到 JS 程序执行完成,才会接着执行。因此如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。

所以 JS 用异步任务 (asynchronous callback) 去解决这些问题

同步与异步

写基础系列文章的过程中经常能碰到同步(Synchronous)与异步(Asynchronous)的概念,这也是初学JavaScript时容易搞混的特性之一。

两者容易搞混我觉得很大的原因出在Synchronous在国内被翻译成了「同步」,光看字面意思会认为「同步」就是「所有动作同时进行」,但事实上刚好相反

要快速理解同步异步 , 直接先看一段代码:

console.log('1');
console.log('2');

/*打印出
1
2
*/

这段代码的实现就叫做同步,也就是说按照顺序一步一步来处理,做完第一件事情之后,再去做下一件事情。

再来看另一段代码:

console.log('1');
setTimeout(function () {
console.log('2');
},1000)
console.log('3');

/*打印出
1
3
2
*/

这段代码的实现就叫做异步,也就是说不完全按照顺序去做,如果在函数A返回的时候,调用者还不能够得到预期结果,而是需要通过一定的手段等待一段时间去得到,这样也就不耽搁时间。

所以说JavaScript的同步代码比较好理解,而其优势之一是其如何处理异步代码。异步代码会被放入一个事件队列(下面会讲到),等到所有其他代码执行后才进行,而不会阻塞线程,接下来将重点介绍异步

异步过程是如何形成的?

简单的总结一下,一个异步过程通常是这样的:

  1. 主线程发起一个异步请求,相应的工作线程接收请求并告知主线程已收到(异步函数返回);
  2. 主线程可以继续执行后面的代码,同时工作线程执行异步任务;
  3. 工作线程完成工作后,通知主线程;
  4. 主线程收到通知后,执行一定的动作(调用回调函数)。

异步函数通常具有以下的形式:

A(argus..., callbackFn)

其中,函数A可以叫做异步过程的发起函数,也叫做异步任务注册函数。argus是这个函数需要的参数。callbackFn也是这个函数的参数,但是它比较特殊所以单独列出来。

所以,从主线程的角度看,一个异步过程包括下面两个要素:

  • 发起函数(或叫注册函数)A
  • 回调函数callbackFn

它们都是在主线程上调用的,其中注册函数用来发起异步过程,回调函数用来处理结果。

举一个栗子:

setTimeout(fn, 1000);

其中的setTimeout就是异步过程的发起函数,fn是回调函数。

注意:前面说的形式A(argus..., callbackFn)只是一种抽象的表示,并不代表回调函数一定要作为发起函数的参数,例如:

var request = new XMLHttpRequest();
request.open('GET', url);
request.send(); // 发起函数
request.onreadystatechange = xxx; // 添加回调函数

这种形式的发起函数和回调函数就是分离的。

任务队列

上面提到了异步任务完成后,会通知主线程,以 callback 的方式获取结果或者执行回调。但是如果当前的主线程是忙碌的,异步任务的信号无法接收到怎么办呢?所以还需要一个地方保存这些 callback,也就是任务队列(task queue)

那么这里就要具体提提JavaScript 异步执行的运行机制了,先用一张图来表现整个过程:

上图的完整过程就是:所有同步任务都在主线程上执行,形成一个执行栈,当执行栈遇到异步任务时(浏览器通常是调用 WebAPIs,常见的有 XMLHttpRequestsetTimeout,事件回调等),不会等待,而是继续执行往下执行。而异步任务就会以各种方式,把 callback 加入任务队列中。一旦执行栈中的所有同步任务执行完毕,栈内被清空了,就代表主线程空闲了,这个时候就会去任务队列中按照顺序读取一个 callback 任务放入到栈中执行。结束后栈内被清空,还会再去读取任务队列有没有任务,有就读取执行,一直循环读取-执行的操作,而这种循环的机制,就称之为事件循环(Event Loop)。

Event Loop(事件循环)

事件循环可不是像上面一样一句两句就能讲清的,并且暂时不讨论node.js的Event Loop执行机制,以下关于浏览器的Event Loop执行机制,那在完全了解 Event Loop 之前,还需了解一下宏任务与微任务,因为任务队列又分为macrotask(宏任务)与microtask(微任务),在 ES2015 中 macrotask 即指 Task,而 microtask 则是指代 Job。

  • 宏任务大概包括:script(整体代码), setTimeout, setInterval, setImmediate(Nodejs环境), I/O, UI rendering
  • 微任务大概包括: process.nextTick(Nodejs环境), Promise, Object.observe(已废弃), MutationObserver(html5新特性)。

关于macrotaskmicrotask的理解,得结合事件循坏的机制,下面这张图就可以说是介绍得很清楚了。

事件循环的顺序,决定了JavaScript代码的执行顺序,一次事件循环的步骤简单总结起来就是:

  1. 检查macrotask队列是否为空,非空则到步骤2,为空则到步骤3;
  2. 执行macrotask中的一个任务;
  3. 继续检查microtask队列是否为空,非空则到步骤4,否则到步骤5;
  4. 取出microtask中的任务执行,执行完成返回到步骤3;
  5. 检查是否需要进行视图更新,需要则进行更新,否则进行下一轮的事件循环;

上面这么多文字表述是不是有点晦涩难懂,那我们通过2个demo例子来逐步理解事件循环的具体顺序吧:

//demo1

console.log(1);

//setTimeout1
setTimeout(()=>{
console.log(2);
Promise.resolve().then(data => {
console.log(3);
});
});

//setTimeout2
setTimeout(()=>{
console.log(4);
});

Promise.resolve().then(data=>{
console.log(5);
});

// 1,5,2,3,4

我们来说明一下, JS引擎是如何执行这段代码的:

  1. 主线程上遇到console.log(1)同步代码,执行,输出’1’。
  2. 接着遇到setTimeout1,它的作用是在 0ms 后将回调函数放到宏任务队列中(这个任务在一次的事件循环中执行)。
  3. 接着遇到setTimeout2,它的作用是在 0ms 后将回调函数放到宏任务队列中(这个任务在下下一次的事件循环中执行)。
  4. 首先检查微任务队列, 即 microtask队列,发现此队列不为空,执行第一个promisethen回调,输出 ‘5’。
  5. 此时microtask队列为空,进入下一个事件循环, 检查宏任务队列,发现有 setTimeout1的回调函数,立即执行回调函数输出 ‘2’,检查microtask 队列,发现队列不为空,执行promisethen回调,输出’3’,microtask队列为空,进入下一个事件循环。
  6. 检查宏任务队列,发现有 setTimeout2的回调函数, 立即执行回调函数输出’4’。

再思考一下下面代码的执行顺序:

//demo2

console.log(1);

//setTimeout1
setTimeout(function () {
console.log(2);
}, 0);

//setTimeout2
setTimeout(function () {
console.log(3);
//setTimeout3
setTimeout(function () {
console.log(4);
});
Promise.resolve().then(function () {
console.log(5);
});
}, 200);

//Promise1
Promise.resolve().then(function () {
console.log(6);
}).then(function () {
console.log(7);
});

//Promise2
Promise.resolve().then(function () {
console.log(8);
});

console.log(9);

// 1,9,6,8,7,2,3,5,4

我们来说明一下, JS引擎是如何执行这段代码的:

  1. 首先顺序执行完主进程上的同步任务,第一句和最后一句的console.log
  2. 接着遇到setTimeout1,它的作用是在 0ms 后将回调函数放到宏任务队列中(这个任务在一次的事件循环中执行);
  3. 接着遇到setTimeout2,它的作用是在 200ms 后将回调函数放到宏任务队列中(这个任务在下下一次的事件循环中执行);
  4. 同步任务执行完之后,首先检查微任务队列, 即 microtask队列,发现此队列不为空,执行第一个promisethen回调,输出 ‘6’,然后执行第二个promisethen回调,输出’8’,由于第一个promise.then()的返回依然是promise,所以第二个.then()会放到microtask队列继续执行,输出 ‘7’;
  5. 此时microtask队列为空,进入下一个事件循环, 检查宏任务队列,发现有 setTimeout1 的回调函数,立即执行回调函数输出 ‘2’,检查microtask 队列,队列为空,进入下一次事件循环;
  6. 检查宏任务队列,发现有 setTimeout2 的回调函数, 立即执行回调函数输出’3’;
  7. 接着遇到setTimeout3,它的作用是在 0ms 后将回调函数放到宏任务队列中(这个任务在下一次的事件循环中执行),检查微任务队列,即 microtask 队列,发现此队列不为空,执行promisethen回调,输出’5’;
  8. 此时microtask队列为空,进入下一个事件循环,检查宏任务队列,发现有 setTimeout3 的回调函数,立即执行回调函数输出,输出’4’。至此代码执行结束。

面试题

这里再补一道面试题,如何执行这段代码的过程就不详细说明了,朋友们自行去体会:

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

// 1,7,6,8,2,4,3,5,9,11,10,12
// node环境下的事件监听依赖libuv驱动I/O库与前端环境不完全相同,输出顺序可能会有误差

延伸阅读

如果觉得文章对你有些许帮助,欢迎在我的GitHub博客点赞和关注,感激不尽!



JavaScript  

JavaScript 运行机制 Event Loop

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!