由setTimeout深刻JavaScript执行环境的异步机制

问题背景

在一次开发任务中,须要实现以下一个饼状图动画,基于canvas进行绘图,但因为对于JS运行环境中异步机制的不了解,因此遇到了一个棘手的问题,始终没法解决,以后在与同事交流以后才恍然大悟。问题的根节在于经典的JS定时器异步问题,因此在解决问题以后,又经过了大量的资料阅读扩展和一段时间的实战总结,如今对JS运行环境中异步机制作一个较为深刻的分析。html

setTimeout.gif-55.9kB

上图中为最终想要实现的效果,使得各扇形部分能够同时画出并闭合圆形。点击此处查看代码清单。以前遇到的问题是没有将myLoop做为一个函数抽离出来,而将其中的全部逻辑,包括定时器都写在了for循环中,这样虽然扇形角度、哨兵变量等的计算均正确,但圆形始终没法闭合,非常郁闷。这里我只是想借此问题来引入JS运行环境中对于异步机制理解的重要性,大可没必要关心canvas画图的实现过程,让你们明白对异步的理解会牵扯到业务逻辑执行的准确性,并不是只是用于浮于纸面的面试题之上。至于为何将定时器的逻辑放在一个函数中就执行正常,而直接写入for循环就没法达到预期,看过下文的详细分析后,这个问题便会迎刃而解。node


深刻异步

关于异步的深刻,这里基于现有的知识水平作尽量详尽准确的分析。你们能够从一篇博客进一步了解牛人之间对于异步理解的争论。一位是技术博客红人阮一峰老师,一位是国内Node技术的开山鼻祖朴灵老师,都是我持续关注的两位偶像。事情发生的比较早了,这里只给出一个文章连接,其中在阮老师的博文中附带了大量朴灵老师的批注,读过以后定会受益不浅,也会激发出你对技术外的一些思考。面试

同步与异步

首先来讲明同步与异步两个概念。ajax

f1()
f2()

对于JavaScript语言的执行方式,执行环境会支持两种模式,一种是同步执行,一种是异步执行。如上面两个方法,同步执行就是调用f1以后,等待返回结果,再执行f2。异步是调用f1后,经过一系列其余的操做才能够获得预期的结果,好比网络IO、磁盘IO等,在线程执行这些其余操做的同时,程序还能够往下执行,继续调用f2,不用等待f1的结果返回再执行f2。编程

咱们知道,大部分的脚本和编程语言都是同步编程,开发者对于同步编程的执行逻辑也比较容易理解。那么为何对于JS的执行要常常用到异步编程,这应该要追溯到最初JS适用的宿主环境--浏览器。canvas

因为用于浏览器,因此操做DOM的JS只能使用单线程,不然没法保证DOM操做的安全性(好比一个线程将另外一个线程正在使用的某个DOM删掉)。又由于使用单线程,同步执行代码的话,若是遇到耗时较长的操做,那么浏览器将会长时间失去响应,用户体验及其很差。但若是将耗时较长的任务,好比ajax请求异步执行,那么客户端的渲染便不会受到耗时任务的阻塞。vim

对于服务器端,JS异步执行更为重要,由于执行环境是单线程的,若是同步执行全部并发请求,那么对于客户端的响应将会极其迟钝,服务器性能急剧降低,这时必须使用异步模式来处理大量并发请求,不像Java、PHP等语言是经过多线程来解决并发问题。这点在如今高并发司空见惯的网络环境中,反而成为了JS的优点,使得Node在短期内进入主流视野,成为DIRT应用1的最佳解决方案。浏览器

实现异步的机制

在说实现异步的机制以前,首先须要搞清楚两个概念,分别是JavaScript的执行引擎执行环境。咱们常说Google的V8虚拟机即是JavaScript的执行引擎,除此以外Safari的JavaScript Core、FireFox的SpiderMonckey都属于Engine。而上述的浏览器和Node等便属于JavaScript的执行环境,是Runtime。前者Engine是去实现ECMAScript标准,后者Runtime是去实现异步的具体机制。因此咱们今天讲的JS异步机制都是在说JS执行环境的异步机制,与V8这样的执行引擎并没有关系,主要是由各大浏览器厂商去作实现。安全

关于实现异步的方式,有咱们接下来要详细介绍的Event Loop,还有轮询、事件等。所谓轮询,就是你在收银台付款以后,不停的问服务员你的饭菜作好了吗。所谓事件,就是你在付款以后,不用不停的问服务员,服务员在作好饭菜以后会主动告诉你。而大部分的执行环境都是经过Event Loop去实现异步机制,因此下面重点来说解Event Loop。服务器

Event Loop

Event Loop的实现逻辑以下图。每当程序启动后,内存会被分为堆(heap)和栈(stack)两部分,其中栈中即是主线程的执行逻辑所需内存,咱们根据这块内存的特殊做用,抽象的将其叫作执行栈。在栈中的代码会调用各类WebAPI,好比对DOM的操做,ajax请求,建立定时器等。这些操做会产生一些事件,而事件又会关联相应的handle(也就是注册时的callback),将须要执行的handle按照队列的结构放入callback queue(event queue)中。当执行栈中的代码执行完毕后,主线程会读取callback queue,依次执行其中的回调函数,而后进入下一轮的事件循环,执行清空新产生的事件回调函数。因而可知,在执行栈中的代码老是在callback queue以前执行

bg2014100802.png-22.4kB

图片转引自Philip Roberts的演讲《Help, I'm stuck in an event-loop》

setTimeout()和setInterval()两个定时器中回调的执行逻辑即是典型的Event Loop机制。类似的,程序在跑完执行栈中的代码后,事件循环会不停的检查系统时间是否到达预设的时间点,每当到达预设的时间点时,就会产生一个timeout事件,并将其放入callback queue,等待下轮Event loop执行。但在实际应用中,有可能执行栈中的代码耗时过长,这样在执行完执行栈中的代码后,再去执行callback queue中由setTimeout()产生的回调时就不能保证在预期的时间点执行,因此JS中的定时器并不总能保证其精准性。而在详细了解其特性原理后,咱们能够在编程应用层面作一些优化,尽可能使定时器中回调函数的执行时间点与咱们预期保持一致。因为setTimeout()与setInterval()在本质上是一致的,因此在下面的实例分析一节中咱们将会以setTimeout()来作关于异步机制的分析。

异步编程

关于异步编程个人理解是,在JS执行环境所提供的异步机制之上,在应用编码层面上实现总体流程控制的异步风格。具体地,咱们能够用相似setTimeout()中的回调函数的形式进行异步编程,或者用相似事件驱动的发布/订阅模式,或者用ES6为咱们提供的异步编程的统一接口Promise实现,再或者能够尝试最新最酷的ES7中Async/Await方案,还有一些像Node社区提供的异步流控库Step等。这里只是为你们明确异步编程这个概念范畴,具体用法再也不深刻。


实例分析

这一节中我将会举出多例来分析,请你们结合上述理论细细体会JS中的同步与异步。首先咱们从一个经典的JS异步面试题开始,而后逐渐深刻。

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
}
 
console.log(new Date, i);

上述代码片断的运行结果应该是,先当即输出一个5,而后在1秒之后同时输出五个5。程序开始执行后,首先执行执行栈中的同步代码,几乎同时建立了5个定时器,而后继续执行第7行的同步代码。这样,首先在控制台输出一个5,而后在1s之后,5个定时器同时产生5个timeout事件放入callback queue,Event loop依次执行队列中的回调函数,这里由于闭包的特性,每个定时器的回调都与其定义上下文,for循环中的i变量作了绑定,而i的值已变为5,因此同时输出五个5。

若是如今提出一个新需求,要求程序运行后,先当即输出一个5,而后在1s之后同时输出0,1,2,3,4,如何改造上述代码?

//方法一
for (var i = 0; i < 5; i++) {
    (function(j) {  
        setTimeout(function() {
            console.log(new Date, j);
        }, 1000);
    })(i);
}
 
console.log(new Date, i);

//方法二
function output (i) {
    setTimeout(function() {
        console.log(new Date, i);
    }, 1000);
};
 
for (var i = 0; i < 5; i++) {
    output(i);  
}
 
console.log(new Date, i);

上面给出的两种方法其实都是一种思路,都是利用JS中,函数做用域做为一个独立的做用域,来保存一个局部的上下文环境,并经过闭包的特性使其与setTimeout中的回调函数作绑定。只不过第一种方法是利用IIFE2来实现,第二种方法是经过定义一个函数,再来逐个调用实现。看到这里,应该想到对于篇首问题背景一节中所提到的问题便与此处一模一样。

接下来咱们进一步深刻,提出一个新的需求。如何在代码执行时,当即输出 0,以后每隔1s依次输出 1,2,3,4,循环结束后在大概第5秒的时候输出5?

由于前边每隔1s输出的0,1,2,3,4是五个定时器输出的,也就是五个异步操做,那么咱们是否是能够把此次的需求抽象为:在一系列异步操做完成(每次循环都产生了 1 个异步操做)以后,再作其余的事情。如今熟悉ES6的同窗应该想到了Promise。

const tasks = []; // 这里存放异步操做的 Promise
const output = (i) => new Promise((resolve) => {
    setTimeout(() => {
        console.log(new Date, i);
        resolve();
    }, 1000 * i);
});
 
// 生成所有的异步操做
for (var i = 0; i < 5; i++) {
    tasks.push(output(i));
}
 
// 异步操做完成以后,输出最后的 i
Promise.all(tasks).then(() => {
    setTimeout(() => {
        console.log(new Date, i);
    }, 1000);
});

若是你熟悉ES7中的Async/Await,那么也能够尝试用这种方案解决。

// 模拟其余语言中的 sleep,实际上能够是任何异步操做
const sleep = (timeountMS) => new Promise((resolve) => {
    setTimeout(resolve, timeountMS);
});
 
(async () => {  // 声明即执行的 async 函数表达式
    for (var i = 0; i < 5; i++) {
        await sleep(1000);
        console.log(new Date, i);
    }
 
    await sleep(1000);
    console.log(new Date, i);
})();

这里须要着重注意的是浏览器对Async/Await标准的支持,若是你的浏览器不在如下所支持版本当中,那么能够升级浏览器或使用babel转译处理。

此处输入图片的描述

能把上边这一系列的实例理解到位,相信对JS中异步的这个概念会一些新的体会。下面这个实例会更加细化的考察一下异步代码中回调的执行时机。

let a = new Promise(
  function(resolve, reject) {
    console.log(1)
    setTimeout(() => console.log(2), 0)
    console.log(3)
    console.log(4)
    resolve(true)
  }
)
a.then(v => {
  console.log(8)
})
 
let b = new Promise(
  function() {
    console.log(5)
    setTimeout(() => console.log(6), 0)
  }
)
 
console.log(7)

这里首先来明确一点,Promise是ES6中为异步编程所提供的一套API标准,其自己是同步的。因此咱们在new一个Promise对象的时候,其所执行的构造器中的逻辑是同步的。由此得知,上述代码片断先从上到下依次执行同步代码,输出1,3,4,5,7。而后是先执行then中的异步代码仍是先执行setTimeout中的回调代码?这里须要记住前者要比后者先进入执行栈执行,因此后边输出8,2,6。这是由于当即resolved的Promise是在本轮事件循环的末尾执行,相似于node中的process.nextTick方法,它能够在当前"执行栈"的尾部,下一次Event Loop(主线程读取"任务队列")以前,触发回调函数。setTimeout(fn, 0)则是在当前"任务队列"的尾部添加事件,也就是说,它指定的任务老是在下一轮次Event Loop时执行,这与node中的setImmediate方法很像。

最后咱们来讲一个关于setInterval优化的例子。咱们知道setTimeout中的回调触发是不许确的,主要缘由是因为在须要执行回调时,可能执行栈中的代码尚未执行完,没法将CPU资源及时的调度给callback queue中的回调执行。而setInterval也会存在一些问题,好比时间间隔可能会跳过,
时间间隔可能小于定时器设定的时间。发生这类状况其实也是因为其余的程序占用长时间的CPU时间片引发,如下面代码片断为例:

function click() { 
    // code block1... 
    setInterval(function() { 
        // process ... 
    }, 200); 
    // code block2 ...
}

若是process中的代码执行时间过长,占用了超过400ms,那么此时JS执行环境就会跳过中间一次时间间隔,由于callback queue中只容许有一份process代码存在,因此也会产生触发时机不精准的状况。

为了不这种状况的出现,咱们能够利用递归的方式进行优化处理,如下提供两种写法,可是建议使用第一种写法。由于第二种写法中,在严格模式下,第5版 ECMAScript (ES5) 禁止使用 arguments.callee()。当一个函数必须调用自身的时候, 避免使用 arguments.callee(), 经过要么给函数表达式一个名字,要么使用一个函数声明参见MDN解释

// 写法一
    setTimeout(function bar (){ 
        // processing
        foo = setTimeout(bar, 1000); 
    }, 1000);
    
    // 写法二
    setTimeout(function(){ 
        // processing 
        foo = setTimeout(arguments.callee, interval); 
    }, interval);
    
    clearTimeout(foo) // 中止循环

  1. Data-Intensive Real-Time 这里指数据密集、实时交互类应用。
  2. Immediately Invoked Function Expression:声明即执行的函数表达式。
相关文章
相关标签/搜索