JS的运行机制
大家都知道JavaScript是一门单线程的语言,在一个时间下只做一件事。
至于为什么是单线程呢,其实是与用途又关系的。因为JavaScript作为游览器脚本语言,它的主要用途是与用户进行交互,以及操作DOM。如果,它是一个多线程,那一个线程删除了一个DOM,另一个线程在这个DOM上增加内容或修改内容。那这时候该怎么渲染?
因此,从一诞生,JavaScript就是单线程的,是这个语言的核心特征。
当然,HTML5提出了Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程受主线程控制且不可操作DOM,这个标准并没有更改单线程的本质。
同步与异步
单线程的特征,会造成一些问题,比如:当我们请求服务器接口时,那在等待数据返回之前,页面就无法进行任何操作,会出现假死的状态。但是实际上,我们并没有遇见这种情况。
这是因为我们通过异步(非阻塞)执行模式解决单线程造成的问题。
同步(阻塞)
说到异步,我们就能自然其然地想到同步(阻塞)。
什么叫同步?就好像我们去做核酸排队,大白按照排队顺序依次给我们检测,后面的人只能等前面的人检测好了才能检测。如果大白突然走了,那么整个队伍后续的人都不能检测了。 我们可以看如下代码:
let a = 1;let b = 2;let c = a + b;console.log(c); // 3let t1 = new Date().getTime();let t2 = new Date().getTime();while (t2 - t1 < 2000) { t2 = new Date().getTime();}console.log('大约2秒后打印')
上述代码会从上依次执行,到while
时会进入循环,等待大约2秒后,才会跳出循环再执行最后一行的打印。这就导致了阻塞的出现。 阻塞的特点就是:遇到消耗时间的代码片段,必须要等耗时的代码执行完毕,才能继续执行后面的代码。
异步(非阻塞)
异步与同步对立,它不会阻塞程序的运行。当程序在运行的时候,遇到了异步模式的代码,引擎会把异步的任务挂起来并先略过,然后继续执行非异步的代码。当同步代码执行完了,再把刚刚挂起来的异步代码按照特定顺序进行执行。 我们可以看看如下代码:
let a = 1let b = 2setTimeout(function(){ console.log('2秒后输出')},2000)console.log(a+b)
程序会首先输出3
,然后等待2秒后输出2秒后输出
。因为在程序执行的时候,碰到了setTimeout时不会直接执行内部的回调函数,而是先将内部的函数挂起来,继续执行下面的同步代码,同步代码执行完毕后,等待大概2秒再回过头来执行刚刚挂起来的函数。
异步可以想象成,你要去超市买菜,你不可能想到了做一盘菜去超市买一次菜。你会先把需要做的菜列出来,然后去超市买菜。从货架拿菜的时候,不一定是列出来的第一个菜,但是都是列出来的那些菜。
同步异步总结
JavaScript的运行顺序就是严格的单线程的异步模式:同步在前,异步在后。异步任务需要等待当前的同步任务执行完成后才开始执行。
JS线程组成
虽然游览器是单线程执行JavaScript代码的,但是游览器有多个线程协助操作来实现单线程异步模型。
GUI渲染线程
JavaScript引擎线程
事件触发线程
定时器触发线程
http请求线程
其他线程
在JavaScript代码运行的过程中实际执行程序时同时只存在一个活动线程,实现同步异步就是靠多线程切换的形式来实现。
上面的细分线程可以归纳为两条线程:
【主线程】:执行页面的渲染,js代码的运行,事件的触发等
【工作线程】:在幕后工作,用来处理异步任务的执行来实现非阻塞的运行模式
JavaScript事件循环机制
上述中,我们将线程分为了主线程和工作线程,主线程的代码在运行的时候,碰到了同步代码,就放入执行栈中去执行,碰到了异步代码,则放入工作线程中暂时挂起。
执行栈中负责执行代码,遵从先进后出的原则,执行函数时,会按照从外到内的顺序依次运行,可能会涉及到对象的数据存储在堆内存中。
工作线程中挂起的任务都是一些异步的任务,比如网络请求、定时函数、交互事件等等。
当执行栈中的任务全部执行完毕后,执行栈清空了,事件循环就会开始工作,它会检测消息队列中是否有需要执行的任务,这个任务来源工作线程中挂起的任务,工作线程会把到期的异步任务按照顺序插入到消息队列里。如果有需要执行的任务,则会按照先进先出的顺序防盗执行栈中执行,直到消息队列也被清空。
function f1() { console.log("f1被执行"); } function f2() { console.log("f2被执行"); } function f3() { console.log("f3被执行"); setTimeout(() => { console.log(123); setTimeout(() => { console.log(1234); }, 0); }, 0); } function f4(fn) { fn(); console.log("f4被执行"); } function f5() { console.log("f5被执行"); } function f6() { console.log("f5被执行"); } setTimeout(() => { f5(); }, 0); setTimeout(() => { f6(); }, 1000); f1(); f2(); f4(f3); // f1被执行->f2被执行->f3被执行->f4被执行->f5被执行->123->1234->f6被执行
如上面代码所示,定义了5个函数,主程序在执行的时候首先碰到了2个异步的任务,分别是0秒后执行f5和1秒后执行f6,主程序会把这2个异步任务挂起来(即放入到工作线程中),继续执行下面代码。
然后首先f1函数进入了执行栈执行,f1函数执行完毕后出栈。
再把f2函数放入执行栈执行,f2函数执行完毕后出栈。
再把f4函数放入执行栈中执行,执行f4函数的时候发现它内部执行了f3函数,这时候再把f3函数放到执行栈中去执行,f3执行的时候发现一个打印123的定时器,然后会把定时器放入工作线程中,f3函数执行完毕后出栈了,再继续执行f4函数后面的部份。
当主线程的同步代码全部执行完毕后,这时候事件循环机制就会起来了工作了,不停的从消息队列中读取是否存在需要执行的任务,如果存在就放到执行栈中去执行。
接下来就是执行异步任务的时候,现在我们有3个异步任务要执行,f5需要0秒,f6需要1秒,打印123的任务也需要0秒。
虽然f5和打印123的任务时间相同,但是f5比打印123的任务优先进入工作线程,所以f5会优先进入到消息队列。
这时候事件循环机制发现消息队列里有任务了,就会把f5取出来放到执行栈中执行然后出栈。
然后打印123的任务进入执行栈,执行的时候发现还有一个异步任务打印1234,会把这个异步任务放入到工作线程挂起,然后执行完出栈。
异步任务打印1234所需时间是0秒,会比f6优先进入消息队列,再被事件循环机制取出执行。
f6进入消息队列后也一样。直到消息队列清空。
执行栈:按照后进先出的顺序执行进入执行栈的任务,执行后出栈。
工作线程:存放挂起的异步任务,首先按照时间顺序,哪个任务时间到了就把该任务放到消息队列,如果有相同的时间,则按照进入工作线程的顺序,依次进入消息队列。
事件循环机制:等待同步代码执行完毕后,不断地从消息队列(先进先出)中取任务,然后放入执行栈执行。
关于执行栈(补充)
从上述内容可知,当我们运行单层的函数时,执行栈会执行函数,然后出栈销毁。然后下一个再进栈执行,然后出栈销毁,依次反复。 但是如果有嵌套调用时,执行栈中就会堆积栈帧。
function test1(fn) { fn() console.log('test1')}function test2() { test3() console.log('test2')}function test3() { console.log('test3')}test1(test2) // test3 -> test2 -> test1
如上所示,当程序执行的时候,test1会进入到执行栈中执行,然后执行的时候发现test1调用了fn,fn即传入的test2,这是执行栈会把test2放入到栈顶并执行,然后test1会停顿;
执行test2的时候会发现test2中调用了test3,然后会把test3放入到栈顶,根据先进后出的原则优先执行test3;
test3执行完毕后会出栈,然后继续执行test2,test2执行完毕后再出栈,继续执行test1。
这时我们就会想到了递归。递归函数就可以看成一个函数中嵌套了N层执行,执行过程中会触发大量的栈帧堆积,但是执行栈是有深度的,过大的栈帧堆积会造成栈溢出。
栈的深度
栈的深度会根据不同的游览器和JS引擎有不同的区别。
如下所示,我们发现在递归了11390次之后就提示超过栈深度的错误了。
let i = 0; function test() { i++; console.log(i); test(); } test();
如何跨越递归限制
这时候我们可以探究一下如何跨越递归限制呢?
之前我们说过,事件循环机制会在执行栈中的程序执行完毕后启用,不停地从消息队列读取消息,再放到执行栈中执行。执行栈在执行的时候如果碰到了异步任务,会把异步任务放到工作线程挂起,然后继续执行后续的代码。
因此我们是不是可以把递归的执行函数放入一个异步任务里?这样子执行栈就不会堆积栈帧,每次只从消息队列取任务执行,然后将递归调用的任务放入工作线程,继续执行后续的同步代码,同步代码执行完毕后,再读取消息队列中到期的异步任务放入执行栈中执行,依次反复。
let i = 0; function test() { i++; console.log(i); setTimeout(()=>{ test(); },0) } test();
针不戳呀,但是不能保证运行速度。
事已至此,至于宏任务和微任务,后面再说!
原文:https://juejin.cn/post/7097518995620200485