JavaScript 的事件循环模型

421 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第8天,点击查看活动详情

介绍

众所周知 JavaScript 是一门单线程的语言,所以在 JavaScript 的世界中默认的情况下同一个时间节点只能做一件事情,这样的设定就造成了 JavaScript 这门语言的一些局限性。

比如在我们的页面中加载一些远程数据的时候如果按照单线程同步的方式运行那么一旦有 HTTP 请求向服务器发送那么就会出现等待数据返回之前网页假死的效果出现。

因为 JavaScript 在同一个时间只能做一件事,这就导致了页面渲染,和事件的执行在这个过程中无法进行了。显然在实际的开发中我们并没有遇见过这种情况。

关于同步和异步

基于以上的描述我们知道了在 JavaScript 的世界中应该存在一种解决方案来处理单线程造成的诟病。这就是同步【阻塞】和异步【非阻塞】执行模式的出现。

同步(阻塞)

同步的意思是 JavaScript 会严格按照单线程,从上到下,从左到右执行代码的逻辑进行代码的解释和运行,所以在运行代码的时候不会出现先运行 4,5 行的代码再回头运行 1,3,行的代码这种情况。比如下列操作。

var a = 1
var b = 2
var c = a + b
//这个例子总 c 一定是 3 不会出现先执行第三行然后在执行第二行和第一行的情况
console.log(c)

那么在同步代码中就会出现如下的情况:

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)

当我们按照顺序执行上面代码的时候,我们的代码在解释执行到第四行的时候还是正常的速度执行,但是在下一行他就会进入一个特别大的循环中。

因为 d2 和 d1 在行级间的时间差仅仅是毫秒内的差别,所以在执行到 while 循环的时候 d2-d1 一定比 2000 小,那么这个循环会执行到什么时候呢?

由于每次循环的时候 d2 都会获取一次当前的时间发生变化知道 d2-d1==2000 的时候这个时候也就是正好过了 2 秒的时间我们的程序才能跳出循环,进而再输出 a+b 的结果。

那么这段程序的实际执行时间至少是 2 秒以上。这就导致了程序阻塞的出现,这也是为什么将同步的代码运行机制叫做阻塞式运行。

异步(非阻塞):

在上面的阐述中我们明白了单线程同步模型中的问题所在,接下来引入单线程异步模型的介绍,异步的意思就是和同步对立,所以异步模式的代码就不是按照默认顺序执行的。

JavaScript 引擎在工作的时候仍然是按照从上到下从左到右的方式解释和运行代码,但是他在解释的时候如果遇到异步模式的代码会将当前的任务“挂起”并略过,也就是先不执行这段代码,继续向下运行非异步模式的代码,直到什么时候执行他呢?

直到同步代码全部执行完毕之后程序会将之前刮起的异步代码按照“特定的顺序”来进行一次执行,所以异步代码并不会阻塞同步代码的运行,并且异步代码并不是会进入新的线程同时执行而是等待同步代码执行完毕在进行工作。我们阅读下面的代码分析:\

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

这段代码的 setTimeout 定时任务规定了 2 秒之后执行一些内容,在运行当前程序的时候执行到 setTimeout 的时候并不会直接执行内部的回调函数而是会先将内部的函数在另外一个位置(具体是什么位置下面会介绍)保存起来,然后继续执行下面的 console.log 进行输出,输出之后代码执行完毕然后等待大概 2 秒左右之前保存的函数再执行。

通俗的讲:

通俗的讲同步和异步的关系就是这样的:

【同步的例子】:比如我们在核酸检测站进行核酸检测这个流程就是同步的,每个人必须按照来的时间先后进行排队,而核酸检测人员会按照排队人的顺序严格的进行逐一检测,在第一个人没有检测完成前第二个人就得无条件等待,这个就是一个阻塞流程。

如果排队过程中第一个人在检测时出了问题比如棉签断了需要换棉签这样这个更换时间就会追加到这个人身上,直到他顺利的检测完毕,第二个人才能轮到,如果在检测中间棉签没有了,或者是录入信息的系统崩溃了,整个队列就进入无条件挂起状态所有人都做不了了。

【异步的例子】:还是结合生活中,当我们进餐馆吃饭的时候,这个场景就属于是一个完美的异步流程,每一桌来的客人会按照他们来的顺序进行点单。

假设只有一个服务员的情况点单必须按照先后顺序,但是服务员不需要等第一桌客人点好的菜出锅上菜之后就可以直接去收集第二桌第三桌客人的需求,这样可能在十分钟之内服务员就将所有桌的客人点菜的菜单统计出来并且发送给了后厨。

之后的菜也不会按照点餐顾客的课桌顺序,因为后厨收集到菜单之后可能有 1,2,3 桌的客人都点了锅包肉,那么他可能会先一次出三份锅包肉,。

这样锅包肉在上菜的时候 1,2,3 桌的客人都能得到,并且其他的菜也会乱序的逐一上菜,这个过程就是异步的。

如果按照同步的默认在饭店点菜就会出现饭店在第一桌客人上满菜之前第二桌之后的客人就只能等待连单都不能点的状态。

总结:

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()
}
//这段代码在输出 3 之前会进入假死状态,'我是一个异步任务'一定会在 3 之后输出
console.log(a+b)

观察上面的程序我们实际运行之后就会感受到单线程异步模型的执行顺序了,并且这里我们会发现 setTimeout 设置的时间是 1000 毫秒但是在 while 的阻塞 2000 毫秒的循环之后并没有等待 1 秒而是直接输出了我是一个异步任务,这是因为 setTimout 的时间计算是从 setTimeout()这个函数执行时开始计算的。

JS 的线程组成

上面我们通过几个简单的例子大概了解了一下 JS 的运行顺序,那么为什么是这个顺序,这个顺序的执行原理是什么样的,我们应该如何更好更深的探究真相呢?这里需要介绍一下浏览器中一个 Tab 页面的实际线程组成:

  1. GUI 渲染线程
  2. JavaScript 引擎线程
  3. 事件触发线程
  4. 定时器触发线程
  5. http 请求线程
  6. 其他线程

按照真实的浏览器线程组成分析,我们会发现实际上运行 JavaScript 的线程其实并不是一个,但是为什么说 JavaScript 是一门单线程的语言呢?

因为这些线程中实际参与代码执行的线程并不是所有线程,比如 GUI 渲染线程为什么单独存在,这个是防止我们在 html 网页渲染一半的时候突然执行了一段阻塞式的 JS 代码而导致网页卡在一半停住这种效果。

在 JavaScript 代码运行的过程中实际执行程序时同时只存在一个活动线程,这里实现同步异步就是靠多线程切换的形式来进行实现的。

所以我们通常分析的时候将上面的细分线程归纳为下列两条线程:

  1. 主线程:这个线程用了执行页面的渲染,JavaScript 代码的运行,事件的触发等等
  2. 工作线程:这个线程是在幕后工作的,用来处理异步任务的执行来实现非阻塞的运行模式

2. JavaScript 的运行模型

JavaScript 的事件循环模型.png

上图是 JavaScript 运行时的一个工作流程和内存划分的简要描述,我们根据图中可以得知主线程就是我们 JavaScript 执行代码的线程主线程代码在运行的时候会按照同步和异步代码分成两个去处。

如果是同步代码执行就会直接将该任务放在一个叫做“函数执行栈”的空间进行执行,执行栈是典型的栈结构(先进后出),程序在运行的时候会将同步代码按顺序入栈,将异步代码放到工作线程中暂时挂起,工作线程中保存的就是定时任务函数,JS 的交互事件,JS 的网络请求等耗时操作。

当主线程将代码块筛选完毕后进入执行栈的函数会按照从外到内的顺序依次运行,运行中涉及到的数据是在堆内存中进行获取的。

当执行栈内的任务全部执行完毕之后执行栈就会清空,执行栈清空之后“事件循环”就会工作,他会检测任务队列中是否有要执行的任务,那么这个任务队列的任务来源就是工作线程。

在主线程执行的过程中,工作线程中就会把到期的定时任务,返回数据的 http 任务等异步任务按照先后顺序插入到任务队列中,等执行栈清空之后事件循环会访问任务队列将任务队列中存在的任务放在执行栈中继续执行知道任务队列清空。

从代码片段开始分析

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

刚才的文字阅读可能在大脑中很难形成一个带动画的图形界面来帮助我们分析 JavaScript 的实际运行思路,接下来我们将这段代码肢解之后详细的研究一下。

按照字面分析:

按照字面分析,我们创建了四个函数代表 4 个任务,函数本身都是同步代码,在执行的时候会按照 1,2,3,4 进行解析,解析过程中我们发现任务 2 和任务 3 被 setTimeout 进行了定时托管,这样就只能先运行任务 1 和任务 4 了,当任务 1 和任务 4 运行完毕之后 500 毫秒后运行任务 3,1000 毫米后运行任务 2。

那么他们在实际运行时又是经历了怎么样的流程来运行的呢?

图解分析:

JavaScript 的事件循环模型2.png

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

JavaScript 的事件循环模型3.png

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

JavaScript 的事件循环模型4.png

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

JavaScript 的事件循环模型5.png

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

JavaScript 的事件循环模型6.png

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

总结

我们通过图解之后脑子里就会更清晰的能搞懂异步任务的执行方式了,这里才用了最简单的任务模型进行描绘复杂的任务在内存中的分配和走向是非常复杂的,我们有了这次的经验之后就可以通过观察代码在大脑中先模拟一次执行,这样可以更清晰的理解 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 执行
task2 执行
task3 执行
task3 执行完毕
task2 执行完毕
task1 执行完毕
*/

那么这种嵌套函数在执行栈中的操作流程是什么样的呢?

JavaScript 的事件循环模型7.png

第一次执行的时候调用 task1 函数执行到 console.log 的时候先进行输出,加下来会遇到 task2 函数的调用会出现下面的情况

JavaScript 的事件循环模型8.png

执行到此时检测到 task2 中还有调用 task3 的函数,那么就会继续进入 task3 中执行,如下图

JavaScript 的事件循环模型9.png

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

JavaScript 的事件循环模型10.png

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

JavaScript 的事件循环模型11.png

再之后就剩下 task1 自己了,他在 task2 销毁之后输出 task2 执行完毕后他也会随着出栈而销毁。

JavaScript 的事件循环模型12.png

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

关于递归

关于上面的执行栈执行逻辑清楚之后我们就顺便提一下递归,递归函数是项目开发时经常涉及到的场景,我们经常会在未知深度的树形结构或者其他合适的场景中使用递归,那么递归在面试中也会经常被问到风险问题,如果了解了执行栈的执行逻辑之后递归函数就可以看成是在一个函数中嵌套 n 层执行,那么在执行过程中会触发大量的栈帧堆积,如果处理的数据过大会会导致执行栈的高度不够放置新的栈帧而造成栈溢出的错误。所以我们在做海量数据递归的时候一定要注意这个问题。

关于执行栈的深度:

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

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

task()

我们发现在递归了 11378 次之后会提示超过栈深度的错误,也就是我们无法在 Chrome 或者其他浏览器做太深层的递归操作。

递归打印.png

如何跨越递归限制

发现问题之后我们再考虑如何能通过技术手段跨越递归的限制,我们可以将代码做如下更改,这样就不会出现递归问题了。

var i = 0;
 
function task(){
  i++
  console.log(`递归了${i}次`)
  //使用异步任务来阻止递归的溢出
  setTimeout(function(){
     task()
  },0)
}

task()

我们发现只是做了一个小小的改造,这样就不会出现溢出的错误了。这是为什么呢?

在了解原因之前我们先看控制台的输出,结合控制台输出我们发现确实超过了界限也没有报错。

递归打印2.png

图解原因:

这个原因是因为我们这里使用了异步任务去调用递归中的函数,那么这个函数在执行的时候就不只使用栈进行执行了。

先看没有异步流程时候的执行图例:

递归打印图解原因.png

再看有了异步任务的递归:

异步任务的递归.png

有了异步任务之后我们的递归就不会叠加栈帧了,因为放入工作线程之后该函数就结束了可以出栈销毁,那么在执行栈中就永远都是只有一个任务在运行这样就防止了栈帧的无限叠加。

总结

关于事件循环模型今天就介绍到这里,其实详细介绍的话还应该介绍 JS 的宏任务和微任务,并且在 NodeJS 中的事件循环模型和浏览器中是不一样的,本文是以浏览器的事件循环模型为基础进行介绍,由于篇幅关系加核心主题关系,宏任务和微任务系列后续会在 Promise 篇章中单独进行介绍。