读完本文章,你会对JS异步有更深刻的理解,对于开发中各种异步方式的处理将更加的头脑清晰,那么,本文章的开始,先来为大家介绍JS异步的相关基础理论。
为什么JS是单线程的?
JavaScript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。
上面是摘自阮一峰老师博客中的一段话,很清楚的为大家解释了JS为什么是单线程的。那么问题来了,JS的单线程和其异步有什么关系呢?JS的单线程为什么和异步不产生矛盾呢?下面是小编根据上面引用段落带给大家的解释正是因为允许JS创建多个线程,所以我们子线程就可以用来进行异步的操作,而主线程用来干它本身的工作,且可以控制子线程,至于为什么JS作为单线程语言还可以进行多线程任务这个古老的问题,上面引用段落说“为了利用多核CPU的计算能力”,也就是所JS的宿主环境是多线程的比如浏览器,NodeJS。读到这里我想大家应该认识到了,为什么我会在文章一开始解释JS单线程了,大家是不是知道了我们常说的异步的来历和原因了吧。下面会做更详细的介绍。
什么是任务队列
我们经常在一些面试题,或者实际操作中,对各种同步异步方法混合在一起后的执行顺序不知所措,有的人甚至是完全凭感觉去感受它们的执行顺序,这是因为你不了解JS的任务队列,读完本段落,相信你可以对不同方法的执行顺序有清晰的理解。
毕竟JS是单线程的,即使是JS脚本可以创建多个子线程,但完全受控于主线程,那么真正执行起来,依然是要排队的。这就以为着只有前一个任务执行完毕,后面的任务才可以执行。如果前一个任务执行的很慢(比如Ajax请求),那么就会在CPU空闲的情况下,等待其执行完毕才能接着执行后面的任务。这不是浪费吗?所以JS的设计者意识到,我们主线程完全可以不去管这些IO设备的等待,可以先将等待挂起,先执行后面的任务,等IO设备等待完毕,运行出了结果,再去回过头执行刚才被挂起的任务,可能这段话有些难懂,本人自己的理解为,让那些执行很慢的IO设备去一边等待,先让后面的任务执行,等IO设备缓慢的执行完毕有了结果,再过来排队,就像是在食堂打饭,如果你排着队,轮到了你,而你不知道吃什么,那你就去一边想,让后面的人先打饭,等你想好吃什么了,再过来打饭。
于是,我们JS运行的任务,被分成了两种,一种是同步任务(synchronous)一种是异步任务(asynchronous),JS引擎在执行JS过程中,会按照不同的任务类别去执行,同步任务会被放在主线程执行,也就是,必须等到上一个任务执行完毕才会执行下一个任务,这一点同样不违背JS的单线程。异步任务会进入子线程,并在其有结果之后,向“任务队列”发一个信号(后文称事件),只有任务队列告诉主线程,某一个异步任务有结果了,可以执行了,那么这个异步任务才会进入主线程执行。所以我们有如下JS运行机制
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
上面的机制概括为,同步任务会在主线程中排队执行,异步任务会在子线程中执行,并当其有结果之后,在“任务队列”中放一个事件,只要主线程空了,就去读取"任务队列"
事件和回调函数
在上面的段落中,我们提到了异步任务会在“任务队列”中放置一个事件。这里的事件就是我们常说的事件比如(点击事件,Input事件等),一般的这些事件都会指定一个回调函数。那么事件和回调函数的运行机制到底是什么呢?
"任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
"任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
所谓"回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。
以上引用段落中的事件就是我们的异步任务,回调函数就是我们最前面说的,被主线程挂起来的代码。当异步任务进入执行栈执行的时候,才会被调用。
Event Loop
这里回到了我们的标题——事件循环,来历就是主线程从"任务队列"中读取事件,这个过程是循环不断的。
定时器
上文提到了“定时器”,因为在事件循环中,"任务队列"还可以放置定时事件,即指定某些代码在多少时间之后执行。这叫做"定时器"(timer)功能,也就是定时执行的代码。
这里关于定时器的基本语法将不再赘述,大家都知道定时器是一个异步任务,s所以对于下面的执行代码结果应该是没有异议的。
console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);
上面代码输出结果为1,3,2。
对于这个结果,我想大多数同学不用读我的文章都知道,当然,当你读了这篇文章后,会对其原理有了解。其中console.log(1)
和console.log(3)
是同步代码,中间的定时器为异步代码,所以要等到执行栈执行完同步任务,再去执行任务队列中的异步任务,但是这个任务被指定在1秒之后执行。
我们在看下面代码
setTimeout(function(){console.log(1);}, 0);
console.log(2);
通过上面的都讲解,不难得到其执行结果为2,1。
但HTML5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。那么我们为什么要写成setTimeout(fn,0)
这样呢?
setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。但它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。
也就是说setTimeout(fn,0)
会在主线程得到空闲时最早执行,但主线程的空闲对于setTimeout(fn,0)
来说是执行完所有同步任务,处理完所有任务队列中的事件。这时到执行栈读取任务队列事件开始执行任务队列中的事件的时候,setTimeout(fn,0)
会最先执行。
需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。
微任务与宏任务
以上是关于事件循环中,同步任务和异步任务的运行机制,我们已经有了很深刻的了解,关于异步任务,其中又分为了“宏任务”和“微任务”。关于宏任务和微任务,更多的详细内容,大家自行上网参考, 或期待小编后续文章,本段落只做应用层简单介绍。
- 宏任务一般是:包括整体代码script,setTimeout,setInterval、setImmediate。
- 微任务:原生Promise(有些实现的promise将then方法放到了宏任务中)、process.nextTick。
那么微任务和宏任务是干嘛的呢?看下面代码
setTimeout(()=>{
console.log('setTimeout1')
},0)
let p = new Promise((resolve,reject)=>{
console.log('Promise1')
resolve()
})
p.then(()=>{
console.log('Promise2')
})
亲手做实验的同学会发现输出结果为Promise1、Promise2、setTimeout1。
有同学一定会问,上面不是说setTimeout(fn,0)
会在任务队列的最早执行吗?这里不免有矛盾,在刚才讲定时器的时候,我们还没有接触Promise这样的异步,实际上,对于异步又会分为微任务和宏任务,微任务要先于宏任务执行,要比宏任务更早的进入执行栈,setTimeout(fn,0)
的优先可以理解为,微任务先于宏任务,而宏任务中又以setTimeout(fn,0)
最先。看下面示例图
这里,关于微任务和宏任务的介绍就到这里,其实微任务和宏任务的知识远不止这些,感兴趣的同学可以加深研究,本文只做抛砖引玉,让大家知道异步任务也分先后执行即可,这样满足一般开发已没有问题。
本文到此介绍,对文章内容有异议或者有作者讲解错误之处,望评论留言。
本文参考阮一峰网络日志《JavaScript 运行机制详解:再谈Event Loop》