JS中EventLoop事件循环机制
Posted 乐知鱼前端日志
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS中EventLoop事件循环机制相关的知识,希望对你有一定的参考价值。
1
背景须知
JS单线程
javascript语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。虽然多线程能提高效率,但是JavaScript不能有多个线程。
JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以只能选择一个主线程来执行代码,以防止冲突。虽然如今添加了webworker等新技术,但其依然只是主线程的子线程,并不能执行诸如I/O类的操作。长期来看,JS将一直是单线程,所以一切javascript版的"多线程"都是用单线程模拟出来的。
单线程的优劣势
1)优势
a. 降低处理复杂性,简化开发,例如不用考虑竞争机制等。
b. 作为用于预处理与用户互动的脚本语言,可以更加容易地处理状态同步的问题。
c. JS核心维护人员自身的理解与设计。
d. 越简单越容易推广,快速上手。
2)明显的劣势
并发处理能力,任务处于 I/O 等待状态,导致CPU处理资源的浪费。
为何为非阻塞?
因为单线程意味着任务需要排队,任务按顺序执行,如果一个任务很耗时,下一个任务不得不等待。所以为了避免这种阻塞,我们需要一种非阻塞机制。这种非阻塞机制是一种异步机制,即需要等待的任务不会阻塞主执行栈中同步任务的执行。
于是JavaScript语言将任务的执行模分成两种:同步任务和异步任务。通过事件循环处理任务。
a.同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
b.异步任务:不进入主线程、而进入任务队列(Task queue),只有任务通知主线程,某个任务可以执行了,该任务才会进入主线程执行。
2
事件循环
事件循环是指主线程重复从消息队列中取消息、执行的过程。
为了协调事件(event),用户交互(user interaction),脚本(script),渲染(rendering),网络(networking)等,用户代理(user agent)必须使用事件循环(event loops)。
有两类事件循环:一种针对浏览上下文(browsing context),还有一种针对worker(web worker)。
事件循环机制
一个事件循环有一个或者多个任务队列(task queues)。任务队列是task的有序列表,这些task是以下工作的对应算法:Events,Parsing,Callbacks,Using a resource,Reacting to DOM manipulation。
每一个任务都来自一个特定的任务源(task source)。所有来自一个特定任务源并且属于特定事件循环的任务,通常必须被加入到同一个任务队列中,但是来自不同任务源的任务可能会放在不同的任务队列中。
举个例子,用户代理有一个处理鼠标和键盘事件的任务队列。用户代理可以给这个队列比其他队列多3/4的执行时间,以确保交互的响应而不让其他任务队列饿死(starving),并且不会乱序处理任何一个任务队列的事件。
每个事件循环都有一个进入microtask检查点(performing a microtask checkpoint)的flag标志,这个标志初始为false。它被用来组织反复调用‘进入microtask检查点’的算法。
实际上,主线程只会做一件事情,就是从消息队列里面取消息、执行消息,再取消息、再执行。当消息队列为空时,就会等待直到消息队列变成非空。而且主线程只有在将当前的消息执行完成后,才会去取下一个消息。这种机制就叫做事件循环机制,取一个消息并执行的过程叫做一次循环。
a.消息队列:消息队列是一个先进先出的队列,它里面存放着各种消息。
b.消息就是注册异步任务时添加的回调函数。
事件循环的具体步骤
1. 同步任务直接放入到主线程执行,形成一个执行栈(execution context stack),异步任务(点击事件,定时器,ajax等)进入Event Table并注册函数,等待I/O事件完成或行为事件被触发。
2. 系统后台执行异步任务,如果某个异步任务事件(或者行为事件被触发),则将该任务添加到任务队列的末端,每个任务会对应一个回调函数进行处理。
3. 执行任务队列中的任务具体是在执行栈中完成的,当主执行栈中的同步任务全部执行完毕后,去读取任务队列,任务队列中的异步任务(即之前等待任务的回调结果)会塞入主执行栈,
4.异步任务被继续执行,是一个循环的过程,处理一个队列中的任务称之为tick,异步任务执行完毕后会再次进入下一个循环。
如何知道主线程执行栈为空呢?js引擎存在monitoring process进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先返回主线程。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动返回主线程。但是,由于存在后文提到的"定时器"功能,主线程要检查一下执行时间,某些事件必须要在规定的时间返回主线程。
用一张图展示这个过程:
导图要表达的内容用文字来表述的话:
同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入Event Table并注册函数。
当指定的事情完成时,Event Table(事件列表)会将这个函数移入Event Queue(事件队列)。
主线程内的任务执行完毕为空,会去Event Queue读取对应的函数,进入主线程执行。
上述过程会不断重复,也就是常说的Event Loop(事件循环)。
3
ES6事件循环
JS是有两个任务队列的,一个叫做宏任务Macrotask Queue(Task Queue),一个叫做微任务Microtask Queue。
Macrotask Queue:进行比较大型的工作,常见的有setTimeout,setInterval,用户交互操作,UI渲染等;
Microtask Queue:进行较小的工作,常见的有Promise,Process.nextTick;
这里注意:script(整体代码)即一开始在主执行栈中的同步代码本质上也属于macrotask,属于第一个执行的task
microtask和macotask执行规则:
macrotask按顺序执行,浏览器的ui绘制会插在每个macrotask之间
microtask按顺序执行,会在如下情况下执行:
每个callback之后,只要没有其他的JS在主执行栈中
每个macrotask结束时
事件循环+宏任务+微任务的执行顺序关系如图:
详解导图如下:
1. 检查Macrotask 队列是否为空,若不为空,则执行,若为空,则会跳转至microtask的执行步骤;
2. 从Macrotask队列中取首个任务推入执行栈执行,执行完后进入下一步;
3. 检查Microtask队列是否为空,若不为空,则进入下一步,否则开始新的事件循环;
4. 从Microtask队列取首个任务执行,执行完后,开始新的事件循环;
简单来讲,整体的js代码这个macrotask先执行,同步代码执行完后有microtask执行microtask,没有microtask执行下一个macrotask,如此往复循环。
4
实例详解
1、同步+异步Ajax简单版
上面是一段简易的ajax请求代码:
ajax进入Event Table,注册回调函数success。
同步任务console.log('同步执行')进入主线程执行栈,执行。
ajax事件完成,回调函数success进入Event Queue。
主线程执行完执行栈中的任务,从Event Queue读取回调函数success进入执行栈并执行。
2、setTimeout
setTimeout最关键的就是异步可以延时执行,我们经常这么实现延时多秒执行:
有时候明明写的延时3秒,实际却5,6秒才执行函数,这又咋回事啊?
先看一个例子:
根据前面我们的结论,setTimeout是异步的,应该先执行console.log这个同步任务,所以我们的结论是:
//执行console //task()
然后我们修改一下前面的代码:
乍一看其实差不多嘛,但我们把这段代码在chrome执行一下,却发现控制台执行task()需要的时间远远超过3秒,说好的延时三秒,为啥现在需要这么长时间啊?
这时候我们需要重新理解setTimeout的定义。我们先说上述代码是怎么执行的:
task()进入Event Table并注册,计时开始。
执行waitFn函数,很慢,非常慢,计时仍在继续。
3秒到了,计时事件timeout完成,task()进入Event Queue,但是waitFn也太慢了吧,还没执行完,只好等着。
waitFn终于执行完了,task()终于从Event Queue进入了主线程执行。
上述的流程走完,我们知道setTimeout这个函数,是经过指定时间后,把要执行的任务(本例中为task())加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。
我们还经常遇到setTimeout(fn,0)这样的代码,0秒后执行又是什么意思呢?是不是可以立即执行呢?
答案是不会的,setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。举例说明:
代码1的输出结果是:
//先执行这里 //执行啦
代码2的输出结果是:
//先执行这里 // ... 3s later // 执行啦
关于setTimeout要补充的是,即便主线程为空,0毫秒实际上也是达不到的。html5标准规定了setTimeout()的第二个参数的最小值(最短间隔),不得低于4毫秒,如果低于这个值,就会自动增加。在此之前,老版本的浏览器都将最短间隔设为10毫秒。另外,对于那些DOM的变动(尤其是涉及页面重新渲染的部分),通常不会立即执行,而是每16毫秒执行一次。这时使用requestAnimationFrame()的效果要好于setTimeout()。
需要注意的是,setTimeout()只是将事件插入了"任务队列",必须等到当前代码(执行栈)执行完,主线程才会去执行它指定的回调函数。要是当前代码耗时很长,有可能要等很久,所以并没有办法保证,回调函数一定会在setTimeout()指定的时间执行。
4.setInterval
上面说完了setTimeout,当然不能错过它的孪生兄弟setInterval。他俩差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。
唯一需要注意的一点是,对于setInterval(fn,ms)来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。这句话请读者仔细品味。
5.for+setTimeout
代码解析:
首先i=0时,满足条件,执行栈执行循环体里面的代码,发现是setTimeout,将其出栈之后把延时执行的函数交给Timer模块进行处理。
当i=1,2,3,4时,均满足条件,情况和i=0时相同,因此timer模块里面有5个相同的延时执行的函数。
当i=5的时候,不满足条件,因此for循环结束,console.log(new Date, i)入栈,此时的i已经变成了5。因此输出5。
此时1s已经过去,timer模块将5个回调函数按照注册的顺序返回给任务队列。
执行引擎去执行任务队列中的函数,5个function依次入栈执行之后再出栈,此时的i已经变成了5。因此几乎同时输出5个5。
因此等待的1s的时间其实只有输出第一个5之后需要等待1s,这1s的时间是timer模块需要等到的规定的1s时间之后才将回调函数交给任务队列。等执行栈执行完毕之后再去执行任务对列中的5个回调函数。这期间是不需要等待1s的。因此输出的状态就是:5 -> 5,5,5,5,5,即第1个 5 直接输出,1s之后,输出 5个5;
6.宏任务+微任务
这段代码作为宏任务,进入主线程。
先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)
接下来遇到了Promise,new Promise立即执行,then函数分发到微任务Event Queue。
遇到console.log(),立即执行。
好啦,整体代码script作为第一个宏任务执行结束,看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。
ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中setTimeout对应的回调函数,立即执行。
结束。
7.混合代码
第一轮事件循环流程分析如下:
整体script作为第一个宏任务进入主线程,遇到console.log,输出1。
遇到setTimeout,其回调函数被分发到宏任务Event Queue中。我们暂且记为setTimeout1。
遇到process.nextTick(),其回调函数被分发到微任务Event Queue中。我们记为process1。
遇到Promise,new Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1。
又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2。
宏任务Event Queue |
微任务Event Queue |
setTimeout1 |
process1 |
setTimeout2 |
then1 |
上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。
我们发现了process1和then1两个微任务。
执行process1,输出6。
执行then1,输出8。
好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:
首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2。new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2。
宏任务Event Queue |
微任务Event Queue |
setTimeout2 |
process2 |
then2 |
第二轮事件循环宏任务结束,我们发现有process2和then2两个微任务可以执行。
输出3。
输出5。
第二轮事件循环结束,第二轮输出2,4,3,5。
第三轮事件循环开始,此时只剩setTimeout2了,执行。
直接输出9。
将process.nextTick()分发到微任务Event Queue中。记为process3。
直接执行new Promise,输出11。
将then分发到微任务Event Queue中,记为then3。
宏任务Event Queue |
微任务Event Queue |
process3 |
|
then3 |
第三轮事件循环宏任务执行结束,执行两个微任务process3和then3。
输出10。
输出12。
第三轮事件循环结束,第三轮输出9,11,10,12。
整段代码,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)
5
进程与线程额外概念补充
进程与线程
进程是操作系统分配资源和调度任务的基本单位,线程是建立在进程上的一次程序运行单位,一个进程上可以有多个线程。
浏览器线程
浏览器引擎-在用户界面和呈现引擎之间传送指令(浏览器的主进程)
渲染引擎,也被称为浏览器内核(浏览器渲染进程)
一个插件对应一个进程(第三方插件进程)
GPU提高网页浏览的体验( GPU 进程)
浏览器渲染引擎
渲染引擎内部是多线程的,内部包含 ui 线程和 js 线程
js 线程 ui 线程 这两个线程互斥的,目的就是为了保证不产生冲突。
ui 线程会把更改的放到队列中,当 js 线程空闲下来的时候,ui 线程在继续渲染
js 单线程
js 是单线程,为什么呢?如果多个线程同时操作 DOM ,哪页面不会很混乱?这里所谓的单线程指的是主线程是单线程的,所以在 Node 中主线程依旧是单线程的。
webworker 多线程
它和 js 主线程不是平级的,主线程可以控制 webworker,但是 webworker不能操作 DOM,不能获取 document,window
其他线程
浏览器事件触发线程(用来控制事件循环,存放 setTimeout、浏览器事件、ajax 的回调函数)
定时触发器线程(setTimeout 定时器所在线程)
异步 HTTP 请求线程(ajax 请求线程)
单线程特点是节约了内存,并且不需要在切换执行上下文。而且单线程不需要管锁的问题,所谓 锁,在 java 里才有锁的概念,所以我们不用细研究
参考文章
https://juejin.im/post/59e85eebf265da430d571f89
https://www.jianshu.com/p/6e9f4eb7fdbb
·end·
—如果喜欢,快分享给你的朋友们吧—
我们一起愉快的探讨学习吧
以上是关于JS中EventLoop事件循环机制的主要内容,如果未能解决你的问题,请参考以下文章