JavaScript同步异步运行机制及事件循环

1,254 阅读8分钟

Hi~我是Tom,网上的分析同步异步纯粹概念的内容实在太多了,今天我们来结合实验的角度聊聊这个问题吧。

JavaScript单线程机制

​ 聊同步异步,那就得先从JavaScript是一门单线程语言说起。

​ 总所周知,JavaScript是一门单线程的脚本语言,所有事情必须按部就班的按照一个顺序依次执行下去,那么为什么JavaScript是一门单线程语言呢?这和他的运用场景有关和设计初衷有关,由于JavaScript的设计初衷是一门运行在客户端的脚本语言,方便浏览器与用户进行交互,通过JavaScript去操作DOM结构,那么DOM操作就必须按照一定的顺序来,如果有多线程同时操作一个DOM,那么情况会变得相当混乱。

​ 但是在处理器愈来愈强大的今天,单线程是有点大材小用,于是HTML5提出了web work标准,允许创建多个线程,但是次线程是无法操作DOM且必须完全由主线程指挥,所以其实本质上JavaScript依旧是单线程。

​ 同步异步的出现,就是为了减少一些由于单线程所引起的一些过长的时间等待,提高用户体验。

​ 本文主要采用计时器来进行实验性验证同步异步机制。

事件循环(Event Loop)

​ 当执行栈空闲的时候会去读取任务队列,把任务队列的事情搬到执行栈,在处理过程中如果有遇到异步代码再次挂起,执行栈空的时候再去任务队列拿,形成了一个回环,这就是事件循环 event loop,这里提到了执行栈和任务队列的概念,下文会讲到。

执行栈和任务队列(stack and heap)

​ 在JavaScript中,是一个单线程的执行过程,前一个事情没做完,后面的事情就会一直等着,但是有一些事情需要的执行时间很长(比如ajax请求),就会导致整个队列在那干等着,于是会,JavaScript创始人开放了执行栈和任务队列。

  • 一段JavaScript代码放进执行栈逐行解析,碰到异步代码,会将异步代码挂起,然后继续往下逐行解析。
    • 异步代码被挂起执行,当执行结束拿到结果的时候,会在任务队列放置一个事件。
  • 当执行栈空的时候,系统会去读取任务队列,将已经存在任务队列里面的代码全部拿进执行栈,当作同步代码继续执行。(这时候异步代码可以理解为变成同步代码,因为只有已经有结果的异步代码才会被放置到任务队列)
  • 执行栈执行这些“同步代码”(之前异步转同步的)的时候,如果又遇到了异步代码(比如定时器里面创建定时器),那么异步代码会继续挂起。

图示

假设我们这里有这么一段代码

console.log('tom')

var num = 10

num += 5

setTimeout(()=>{
    console.log('xx')
},1000)

引擎预解析得到

//var 存在变量提升,不过多深入
var num = undefined

console.log('tom')

num = 10

num = num + 5

setTimeout(()=>{
    console.log('xx')
},1000)

预解析完成,将得到的代码按顺序依次放进执行栈,然后从上往下执行代码

执行栈最关键的一点是,执行栈存放的永远都是同步代码,并且永远以此执行,即使涉及到Promise的微任务,我认为也是归纳到同步代码的范畴当中(个人解读,有待考证,欢迎留言),上面的代码的末尾存在一个定时器,定时器很多技术类文章会将其简单的归纳为是异步代码,这也不完全是错的,只不过是不过完整。

我认为定时器同步异步的特性完整解读为:

定时器的触发是同步的,定时器的回调函数是异步的

所以当代码在执行栈中执行到setTimeout的时候,定时器挂起,开始计时,执行栈继续往下执行

当我们规定的1000ms时间到的时候,定时器的回调压入任务队列的末尾。

当执行栈的代码全部执行完毕的时候(执行栈为空),再将任务队列的代码按顺序依次放进执行栈,依次执行。

定时器

这里聊一下,定时器被挂起的时候做的事情

  • 定时器

    • 当一个定时器被挂起的一瞬间,就开始计时了,当计时完毕的时候回调函数立马扔进任务队列
    • 执行栈执行完了,去任务队列拿进执行栈
    • 例子一:
    const timer = setTimeout(function(){
    	console.log('我是5s计时器,我的回调执行了')
    },5000)
    
    const timer = setTimeout(function(){
    	console.log('我是2s计时器,我的回调执行了')
    },2000)
    
    s
    ...   //这一段是计算量非常大的同步代码,计算完成需要费时10s(这段代码的实现在文章后面)
    
    
    //程序在运行 10s 的时候输出了(注意:是10s一到一瞬间,同时输出下面两句话)
    '我是2s计时器,我的回调执行了'
    '我是5s计时器,我的回调执行了'
    
    • 例子一我们得知:

      • 定时器是一旦被执行栈侦测到,就被挂起,并且开始计时,一旦计时完成立马把回调函数扔进任务队列。

      • 因为程序执行非常快,5s的定时器和2s的定时器几乎是同时被执行到并挂起开始计时的,2s的定时器因为时间比较短,会先计时完成扔进任务队列。

      • 而当执行栈执行完毕(例子里面是用了10s),会到任务队列拿代码块进执行栈,队列先入先出的原理,所以先执行的是2s定时器的回调函数,再执行的是5s的回调函数

      • 计时器不一定是可靠的,例子中2s和5s的计时器都没有按时执行回调,所以计时器的时间描述的是回调扔进任务队列的时间,而不是执行回调的时间,所以可以初步认为计时器回调的执行时间是无法预料的。

    • 例子二:

    const timer = setTimeout(function(){
    	console.log('我是5s的计时器,我的回调执行了')
        	setTimeout(function(){
                console.log('我是在5s计时器里面创建的计时器,我的回调执行了')
            },5000)
    },5000)
    
    ... //这是一段计算量非常大的同步代码,计算完成需要费时10s
    
    //程序在运行 10s 的时候输出:
    '我是5s的计时器,我的回调执行了'
    //时间又过了 5s
    '我是在5s计时器里面创建的计时器,我的回调执行了'
    
    • 例子二我们得知:
      • 当父计时器计时完成的时候被放进任务队列,此时子计时器还未创建。
      • 当执行栈执行完成的时候,到任务队列拿代码,父定时器的回调被执行,这时候子计时器才被创建
      • 可以理解为,异步的定时器计时完成回调扔进任务队列再被拿到执行栈的时候,这时候的回调函数的代码块已经是同步代码了,而回调里面的异步代码依旧是异步代码,该挂起的时候依旧要被挂起

那ajax呢?ajax代码也是一个异步代码,异步的时候发生了什么

  • 执行栈执行的时候碰到ajax代码,ok,被挂起,这时候ajax请求已经发送了出去,只不过还没有回应
  • 一旦有了回应,拿到响应体的时候,ajax执行的回调立马被扔进任务队列
  • 当执行栈执行完毕的时候,到任务队列拿代码的时候才会执行ajax响应回来的回调函数
  • 如果你有多个ajax请求呢?
    • 他们都会被挂起
    • 都是在各自挂起的一瞬间开始发送请求
    • 谁先收到响应,谁的回调先扔进任务队列

关于验证

如果你想实现例子中那个占用大量时间(例子中举例的是计算耗时10s)的同步代码,我推荐使用斐波那契数列去帮助你验证这个实验。

function Fibonacci(step){
	if(step==1){
		return 1
	}
	if(step==2){
		return 1
	}
	return Fibonacci(step-1) + Fibonacci(step-2)
}

console.time('Fibonacci')
//推荐使用43阶的斐波那契数列,性能耗时大约是4.3s(i7-8550U),不同设备,不同环境,性能耗时上具有差异。
console.log(Fibonacci(43))
//结合console.time和console.timeEnd去计算时间
console.timeEnd('Fibonacci')

总结

​ JavaScript执行栈执行的时候遇到异步代码的时候统统挂起,而这些异步代码不管被挂起多少个,也不管谁先挂起谁后挂起,只不过谁先响应,谁的回调就先压进任务队列。