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那里检查是否有等待被调用的函数。


"任务队列"是一个先进先出的数据结构,排在前面的事件,优先返回主线程。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动返回主线程。但是,由于存在后文提到的"定时器"功能,主线程要检查一下执行时间,某些事件必须要在规定的时间返回主线程。


用一张图展示这个过程:


JS中EventLoop事件循环机制



导图要表达的内容用文字来表述的话:

  • 同步和异步任务分别进入不同的执行"场所",同步的进入主线程,异步的进入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结束时


    事件循环+宏任务+微任务的执行顺序关系如图:

    JS中EventLoop事件循环机制

    JS中EventLoop事件循环机制


    详解导图如下:

    1. 检查Macrotask 队列是否为空,若不为空,则执行,若为空,则会跳转至microtask的执行步骤;

    2. 从Macrotask队列中取首个任务推入执行栈执行,执行完后进入下一步;

    3. 检查Microtask队列是否为空,若不为空,则进入下一步,否则开始新的事件循环;

    4. 从Microtask队列取首个任务执行,执行完后,开始新的事件循环;

    简单来讲,整体的js代码这个macrotask先执行,同步代码执行完后有microtask执行microtask,没有microtask执行下一个macrotask,如此往复循环。


    4

    实例详解


    1、同步+异步Ajax简单版


    JS中EventLoop事件循环机制


    上面是一段简易的ajax请求代码:

    • ajax进入Event Table,注册回调函数success

    • 同步任务console.log('同步执行')进入主线程执行栈,执行。

    • ajax事件完成,回调函数success进入Event Queue。

    • 主线程执行完执行栈中的任务,从Event Queue读取回调函数success进入执行栈并执行。


    2、setTimeout


    setTimeout最关键的就是异步可以延时执行,我们经常这么实现延时多秒执行:


    JS中EventLoop事件循环机制


    有时候明明写的延时3秒,实际却5,6秒才执行函数,这又咋回事啊?


    先看一个例子:


    JS中EventLoop事件循环机制


    根据前面我们的结论,setTimeout是异步的,应该先执行console.log这个同步任务,所以我们的结论是:

    //执行console //task()


    然后我们修改一下前面的代码:


    JS中EventLoop事件循环机制


    乍一看其实差不多嘛,但我们把这段代码在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)的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。举例说明:


    JS中EventLoop事件循环机制


    代码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


    JS中EventLoop事件循环机制


    代码解析:

    1. 首先i=0时,满足条件,执行栈执行循环体里面的代码,发现是setTimeout,将其出栈之后把延时执行的函数交给Timer模块进行处理。

    2. 当i=1,2,3,4时,均满足条件,情况和i=0时相同,因此timer模块里面有5个相同的延时执行的函数。

    3. 当i=5的时候,不满足条件,因此for循环结束,console.log(new Date, i)入栈,此时的i已经变成了5。因此输出5。

    4. 此时1s已经过去,timer模块将5个回调函数按照注册的顺序返回给任务队列。

    5. 执行引擎去执行任务队列中的函数,5个function依次入栈执行之后再出栈,此时的i已经变成了5。因此几乎同时输出5个5。

    6. 因此等待的1s的时间其实只有输出第一个5之后需要等待1s,这1s的时间是timer模块需要等到的规定的1s时间之后才将回调函数交给任务队列。等执行栈执行完毕之后再去执行任务对列中的5个回调函数。这期间是不需要等待1s的。因此输出的状态就是:5 -> 5,5,5,5,5,即第1个 5 直接输出,1s之后,输出 5个5;


    6.宏任务+微任务



    • 这段代码作为宏任务,进入主线程。

    • 先遇到setTimeout,那么将其回调函数注册后分发到宏任务Event Queue。(注册过程与上同,下文不再描述)

    • 接下来遇到了Promisenew 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

    • 遇到Promisenew Promise直接执行,输出7。then被分发到微任务Event Queue中。我们记为then1

    • 又遇到了setTimeout,其回调函数被分发到宏任务Event Queue中,我们记为setTimeout2

    宏任务Event Queue

    微任务Event Queue

    setTimeout1

    process1

    setTimeout2

    then1

    • 上表是第一轮事件循环宏任务结束时各Event Queue的情况,此时已经输出了1和7。

    • 我们发现了process1then1两个微任务。

    • 执行process1,输出6。

    • 执行then1,输出8。

    好了,第一轮事件循环正式结束,这一轮的结果是输出1,7,6,8。那么第二轮时间循环从setTimeout1宏任务开始:

    • 首先输出2。接下来遇到了process.nextTick(),同样将其分发到微任务Event Queue中,记为process2new Promise立即执行输出4,then也分发到微任务Event Queue中,记为then2

    宏任务Event Queue

    微任务Event Queue

    setTimeout2

    process2


    then2

    • 第二轮事件循环宏任务结束,我们发现有process2then2两个微任务可以执行。

    • 输出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

    • 第三轮事件循环宏任务执行结束,执行两个微任务process3then3

    • 输出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事件循环机制的主要内容,如果未能解决你的问题,请参考以下文章

    js事件循环机制(Event Loop)

    前端中的事件循环eventloop机制

    js的事件循环(Eventloop) 机制

    对javascript EventLoop事件循环机制不一样的理解

    [nodejs基础]eventloop机制图解

    简述JavaScript事件循环EventLoop