从头认识JavaScript的事件循环模型

2021/12/8 12:46:42

本文主要是介绍从头认识JavaScript的事件循环模型,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

1. JS的运⾏机制

介绍

众所周知JavaScript是⼀⻔单线程的语⾔,所以在JavaScript的世界中默认的情况下同⼀个时间节点只能做⼀件事 情,这样的设定就造成了JavaScript这⻔语⾔的⼀些局限性,⽐如在我们的⻚⾯中加载⼀些远程数据时,如果按照 单线程同步的⽅式运⾏,⼀旦有HTTP请求向服务器发送,就会出现等待数据返回之前⽹⻚假死的效果出现。因为 JavaScript在同⼀个时间只能做⼀件事,这就导致了⻚⾯渲染和事件的执⾏,在这个过程中⽆法进⾏。显然在实际 的开发中我们并没有遇⻅过这种情况。

关于同步和异步

同步(阻塞): 同步的意思是JavaScript会严格按照单线程(从上到下、从左到右的⽅式)执⾏代码逻辑,进⾏代码的解释和运 ⾏,所以在运⾏代码时,不会出现先运⾏4、5⾏的代码,再回头运⾏1、3⾏的代码这种情况。

接下来通过下列的案例升级⼀下代码的运⾏场景:

var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
while(d2-d1<2000){
 d2 = new Date().getTime()
}
//这段代码在输出结果之前⽹⻚会进⼊⼀个类似假死的状态
console.log(a+b)

这就导致了程序阻塞的出现,这也是为什么将同步的代码运⾏机制叫 做阻塞式运⾏的原因。

异步(⾮阻塞):

在上⾯的阐述中,我们明⽩了单线程同步模型中的问题所在,接下来引⼊单线程异步模型的介绍。异步的意思就是 和同步对⽴,所以异步模式的代码是不会按照默认顺序执⾏的。JavaScript执⾏引擎在⼯作时,仍然是按照从上到 下从左到右的⽅式解释和运⾏代码。在解释时,如果遇到异步模式的代码,引擎会将当前的任务“挂起”并略过。也 就是先不执⾏这段代码,继续向下运⾏⾮异步模式的代码,那么什么时候来执⾏同步代码呢?直到同步代码全部执 ⾏完毕后,程序会将之前“挂起”的异步代码按照“特定的顺序”来进⾏执⾏,所以异步代码并不会【阻塞】同步代码 的运⾏,并且异步代码并不是代表进⼊新的线程同时执⾏,⽽是等待同步代码执⾏完毕再进⾏⼯作。我们阅读下⾯ 的代码分析:

var a = 1
var b = 2
setTimeout(function(){
 console.log('输出了⼀些内容')
},2000)
//这段代码会直接输出3并且等待2秒左右的时间在输出function内部的内容
console.log(a+b)

⾮阻塞式运⾏的代码,程序运⾏到该代码⽚段时,执⾏引擎会将程序保存到⼀个暂存区,等待所有同步代码全部执 ⾏完毕后,⾮阻塞式的代码会按照特定的执⾏顺序,分步执⾏。这就是单线程异步的特点。

JavaScript的运⾏顺序就是完全单线程的异步模型:同步在前,异步在后。所有的异步任务都要等待当前的同步任 务执⾏完毕之后才能执⾏。

 

经典案例

var a = 1
var b = 2
var d1 = new Date().getTime()
var d2 = new Date().getTime()
setTimeout(function(){
 console.log('我是⼀个异步任务')
},1000)
while(d2-d1<2000){
 d2 = new Date().getTime()
}

console.log(a+b)

//这段代码在输出3之前会进⼊假死状态,'我是⼀个异步任务'⼀定会在3之后输出,

异步任务放在暂存区,等待同步任务执行完再执行,无论同步任务等待多久

 

JS的线程组成

在了解线程组成前要了解⼀点,虽然浏览器是单线程执⾏JavaScript代码的,但是浏览器实际是以多个线程协助操 作来实现单线程异步模型的,具体线程组成如下:

1. GUI渲染线程

2. JavaScript引擎线程

3. 事件触发线程

4. 定时器触发线程

5. http请求线程

6. 其他线程

按照真实的浏览器线程组成分析,我们会发现实际上运⾏JavaScript的线程其实并不是⼀个,但是为什么说 JavaScript是⼀⻔单线程的语⾔呢?因为这些线程中实际参与代码执⾏的线程并不是所有线程,⽐如GUI渲染线程为 什么单独存在,这个是防⽌我们在html⽹⻚渲染⼀半的时候突然执⾏了⼀段阻塞式的JS代码⽽导致⽹⻚卡在⼀半停 住这种效果。在JavaScript代码运⾏的过程中实际执⾏程序时同时只存在⼀个活动线程,这⾥实现同步异步就是靠 多线程切换的形式来进⾏实现的。是由多个线程组成的单线程,切换实现,同时只存在一个线程。

 

2. JavaScript的运⾏模型

 

 上图是JavaScript运⾏时的⼀个⼯作流程和内存划分的简要描述,我们根据图中可以得知主线程就是我们JavaScript 执⾏代码的线程,主线程代码在运⾏时,会按照同步和异步代码将其分成两个去处,如果是同步代码执⾏,就会直 接将该任务放在⼀个叫做“函数执⾏栈”的空间进⾏执⾏,执⾏栈是典型的【栈结构】(先进后出),程序在运⾏的 时候会将同步代码按顺序⼊栈,将异步代码放到【⼯作线程】中暂时挂起,【⼯作线程】中保存的是定时任务函 数、JS的交互事件、JS的⽹络请求等耗时操作。当【主线程】将代码块筛选完毕后,进⼊执⾏栈的函数会按照从外 到内的顺序依次运⾏,运⾏中涉及到的对象数据是在堆内存中进⾏保存和管理的。【1】(知识点,深浅拷贝,基本数据类型和引用数据类型)当执⾏栈内的任务全部执⾏完毕 后,执⾏栈就会清空。执⾏栈清空后,“事件循环”就会⼯作,“事件循环”会检测【任务队列】中是否有要执⾏的任 务,那么这个任务队列的任务来源就是⼯作线程,程序运⾏期间,⼯作线程会把到期的定时任务、返回数据的http 任务等【异步任务】按照先后顺序插⼊到【任务队列】中,等执⾏栈清空后,事件循环会访问任务队列,将任务队 列中存在的任务,按顺序(先进先出)放在执⾏栈中继续执⾏,直到任务队列清空。

【1】引申知识数据类型

      数据分为基本数据类型(String, Number, Boolean, Null, Undefined,Symbol(new in ES 6)和 引用数据类型(统称为 Object 类型,细分的话有:Object Array Date RegExpFunction… )。

  • 基本数据类型的特点:直接存储在栈(stack)中的数据

  • 引用数据类型的特点:存储的是该对象在栈中引用,真实的数据存放在堆内存里

      引用数据类型在栈中存储了指针,该指针指向堆中该实体的起始地址。当解释器寻找引用值时,会首先检索其在栈中的地址,取得地址后从堆中获得实体。

 

从代码⽚段开始分析

function task1() {
  console.log('第⼀个任务')
}
function task2() {
  console.log('第⼆个任务')
}
function task3() {
  console.log('第三个任务')
}
function task4() {
  console.log('第四个任务')
}
task1()
setTimeout(task2, 1000)
setTimeout(task3, 500)
task4()
      

 

 

 如上图,在上述代码刚开始运⾏的时候我们的主线程即将⼯作,按照顺序从上到下进⾏解释执⾏,此时执⾏栈、⼯ 作线程、任务队列都是空的,事件循环也没有⼯作。接下来我们分析下⼀个阶段程序做了什么事情。

 

 结合上图可以看出程序在主线程执⾏之后就将任务1、4和任务2、3分别放进了两个⽅向,任务1和任务4都是⽴即 执⾏任务所以会按照1->4的顺序进栈出栈(这⾥由于任务1和2是平⾏任务所以会先执⾏任务1的进出栈再执⾏任务 4的进出栈),⽽任务2和任务3由于是异步任务就会进⼊⼯作线程挂起并开始计时,并不影响主线程运⾏,此时的 任务队列还是空置的。

 

 我们发现同步任务的执⾏速度是⻜快的,这样⼀下执⾏栈已经空了,⽽任务2和任务3还没有到时间,这样我们的事 件循环就会开始⼯作等待任务队列中的任务进⼊,接下来就是执⾏异步任务的时候了。

 

 

我们发现任务队列并不是⼀下⼦就会将任务2和任务三⼀起放进去,⽽是哪个计时器到时间了哪个放进去,这样我 们的事件循环就会发现队列中的任务,并且将任务拿到执⾏栈中进⾏消费,此时会输出任务3的内容。

 

 到这就是最后⼀次执⾏,当执⾏完毕后⼯作线程中没有计时任务,任务队列的任务清空程序到此执⾏完毕。

 

总结

我们通过图解之后脑⼦⾥就会更清晰的能搞懂异步任务的执⾏⽅式了,这⾥采⽤最简单的任务模型进⾏描绘复杂的 任务在内存中的分配和⾛向是⾮常复杂的,我们有了这次的经验之后就可以通过观察代码在⼤脑中先模拟⼀次执 ⾏,这样可以更清晰的理解JS的运⾏机制。

 

关于执⾏栈

function task1(){
 console.log('task1执⾏')
 task2()
 console.log('task2执⾏完毕')
}
function task2(){
  console.log('task2执⾏')
 task3()
 console.log('task3执⾏完毕')
}
function task3(){
 console.log('task3执⾏')
}
task1()
console.log('task1执⾏完毕')

 

 操作流程(先进后出)

 

 第⼀次执⾏的时候调⽤task1函数执⾏到console.log的时候先进⾏输出,接下来会遇到task2函数的调⽤会出现下⾯ 的情况:

 

 执⾏到此时检测到task2中还有调⽤task3的函数,那么就会继续进⼊task3中执⾏,如下图:

 

 在执⾏完task3中的输出之后task3内部没有其他代码,那么task3函数就算执⾏完毕那么就会发⽣出栈⼯作。

 

 此时我们会发现task3出栈之后程序运⾏⼜会回到task2的函数中继续他的执⾏。接下来会发⽣相同的事情。

 

 再之后就剩下task1⾃⼰了,他在task2销毁之后输出task2执⾏完毕后他也会随着出栈⽽销毁。

 

 当task1执⾏完毕之后它随着销毁最后⼀⾏输出,就会进⼊执⾏栈执⾏并销毁,销毁之后执⾏栈和主线程清空。这 个过程就会出现123321的这个顺序,⽽且我们在打印输出时,也能通过打印的顺序来理解⼊栈和出栈的顺序和流 程。

 

关于递归

阶乘

function compute(n){
  if(n === 1) return 1
  return n * compute(n-1)
}

如 果了解了执⾏栈的执⾏逻辑后,递归函数就可以看成是在⼀个函数中嵌套n层执⾏,那么在执⾏过程中会触发⼤量 的栈帧堆积,如果处理的数据过⼤,会导致执⾏栈的⾼度不够放置新的栈帧,⽽造成栈溢出的错误。所以我们在做 海量数据递归的时候⼀定要注意这个问题。

关于执⾏栈的深度:

执⾏栈的深度根据不同的浏览器和JS引擎有着不同的区别,我们这⾥就Chrome浏览器为例⼦来尝试⼀下递归的溢 出:

var i = 0;
function task(){
 i++
 console.log(`递归了${i}次`)
 task()
}
task()

递归会在执行栈中叠加,以至于溢出

 



这篇关于从头认识JavaScript的事件循环模型的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程