JS - 你所不知道的EventLoop

Posted 总在落幕后

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JS - 你所不知道的EventLoop相关的知识,希望对你有一定的参考价值。


前言


   

    javascript 是一门单线程的脚本语言(这里我们不考虑worker),或者说只有一个主线程,也就是它一次只能执行一段代码。那么例如像onClick 注册的回调函数、必不可少的ajax等等异步操作又是如何执行的呢?答案是:EventLoop。


正文


    

    event loop是一个执行模型,在不同的地方有不同的实现。浏览器和NodeJS基于不同的技术实现了各自的Event Loop。

  • 浏览器的Event Loop是在html5的规范中明确定义。

  • NodeJS的Event Loop则是基于libuv实现的。

    下面来具体讲讲浏览器和node环境下的event loop



浏览器的EventLoop

      

    宏任务和微任务

    首先来了解下宏任务和微任务,异步任务分为task(宏任务,也可称为macroTask)和microtask(微任务,也叫jobs)两类。

    Task:

  • setTimeout

  • setInterval

  • setImmediate (Node独有)

  • requestAnimationFrame (浏览器独有)

  • I/O

  • UI rendering (浏览器独有)

  • script标签


    MicroTask:

  • process.nextTick (Node独有)

  • Promise

  • MutationObserver

    当满足执行条件时,task和microtask会被放入各自的队列中,等待放入执行线程执行,我们把这两个队列称为Task Queue(也叫Macrotask Queue)和Microtask Queue。


    基本流程:

    文字版:

  1. 从任务队列task queue选择中最先进入的任务执行,如果队列为空,则执行microtask queue。

  2. 检查该task是否注册了microtask queue,如果有,则按注册顺序依次执行microtask。如果没有则继续下一步。

  3. 一些其他操作,例如界面渲染。

  4. 重复以上步骤。

        这样的一次流程称为event loop的一次循环,即是一次tick。


    流程图:

    例子

    下面我们通过一个例子来具体分析分析:

<script> console.log('---------script-1---------')
const box = document.querySelector('.box') const observer = new MutationObserver(function (mutationsList, observer) { console.log('MutationObserver') }); observer.observe(box, { attributes: true, childList: true, subtree: true }); box.setAttribute('name', 'rename')
setTimeout(() => { console.log('setTimeout--1') new Promise(resolve => resolve()).then(() => { console.log('in setTimeout promise then') }) })
new Promise((resolve, reject) => { setTimeout(() => { console.log('in Promise setTimeout') }) console.log('in promise') resolve() }).then(() => { console.log('in promise then') })
setTimeout(() => { console.log('setTimeout--2') })</script><script>  console.log('---------script-2---------')</script>


    首先执行第一个script:   

  1. console.log同步代码直接输出

  2. 注册了一个MutationObserver,添加到当前task的microtask queue

  3. setTimeout定时器,往task queue中添加了一个宏任务

  4. new Promise,首先执行里面的同步代码,添加定时器和console.log,再在当前task的microtask queue中添加一个promise的microtask

  5. setTimeout定时器,在往task queue中添加了一个宏任务


    执行完后的示意图如下:

JS - 你所不知道的EventLoop


    第一个task中的同步任务执行完成,查看当前task的microtask queue,不为空,则依次执行队列中的microtask。

  1. 执行MutationObserver的回调函数

  2. 执行Promise.then的回调函数


    microtask queue执行完毕,script1的task从执行栈弹出,script2压入栈执行。输出console.log。script2 中没有注册异步任务,循环后执行下一个task - setTimeout1。

  1.  输出同步代码console.log

  2. 往自身的microtask queue中添加一个promise的microtask


      此时的示意图如下:

JS - 你所不知道的EventLoop

    接着执行自身的microtask queue,promise.then注册的函数,控制台输出in setTimeout promise then。

    执行完其他操作后,进入下一轮循环,执行在promise中注册的定时器,该task中没有microtask,继续下一轮循环,执行setTimemout2。执行完后,js线程空闲等他其他操作。

    让我们看看控制台中的输出

    与我们分析的一致。美滋滋!大家可以再重新回忆想想写写,加深印象,同时也可以添加worker,async / await等代码考考自己。下面为大家介绍node中的event loop。

    

Node环境下的EventLoop


    阅读下文有点刺激,请读者自身携带c语言技能

    node的event Loop是基于libuv 库实现的。我们直冲libuv,看看主要函数uv_run(只保留mode=UV_RUN_DEFAULT 默认轮询模式)。

int uv_run(uv_loop_t* loop, uv_run_mode mode) { int timeout; int r; int ran_pending;
r = uv__loop_alive(loop); if (!r) uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) { uv__update_time(loop); uv__run_timers(loop); // 这个阶段执行timer(setTimeout、setInterval)的回调
ran_pending = uv__run_pending(loop); uv__run_idle(loop); uv__run_prepare(loop);
timeout = 0; if (mode == UV_RUN_DEFAULT) timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout); // 获取新的I/O事件, 适当的条件下node将阻塞在这里 uv__run_check(loop); // 执行 setImmediate() 的回调    uv__run_closing_handles(loop); // 执行close事件的callback,例如socket.on("close",func)
r = uv__loop_alive(loop); if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT) break; }
/* The if statement lets gcc compile it to a conditional store. Avoids * dirtying a cache line. */ if (loop->stop_flag != 0) loop->stop_flag = 0;
return r;}

    

    while循环中就是我们的eventLoop。用文字描述描述eventLoop的步骤如下:


  1. timers:执行满足条件的setTimeout、setInterval回调。

  2. I/O callbacks:是否有已完成的I/O操作的回调函数,来自上一轮的poll残留。

  3. idle,prepare:可忽略

  4. poll:等待还没完成的I/O事件,会因timers和超时时间等结束等待。

  5. check:执行setImmediate的回调。

  6. close callbacks:关闭所有的closing handles,一些onclose事件。

  7. 重复以上步骤。

    

    流程图版

    我们从代码分析:

    首先是uv__loop_alive函数。该函数用于判断当前的event loop是否活跃,需要继续执行下去。需要满足以下三个条件之一:

  • 有未处理的handles(例如tcp server这样long-lived objects

  • 有未处理的request

  • 有未处理的closing_handles

static int uv__loop_alive(const uv_loop_t* loop) { return uv__has_active_handles(loop) || uv__has_active_reqs(loop) || loop->closing_handles != NULL;}// loop->closing_handles 相当于loop.closing_handles

    如果event loop活跃,即有未处理完的事件,则进入while循环。接着更新时间变量,进入timer阶段。这个会阶段执行timer(setTimeout、setInterval)的回调。

uv__update_time(loop);uv__run_timers(loop);

    与浏览器不同,libuv的定时器是以小根堆的数据结构存储的,heap_node就是从小根堆上获取的最先过期的定时器,如果定时器的handle回调函数的时间小于一次循环的时间,那么便会执行该回调函数。请注意这里是个for循环,所以,如果有多个定时器同时到期且没有break,那么这次循环都会执行他们的回调。

void uv__run_timers(uv_loop_t* loop) {  ... for (;;) { heap_node = heap_min(timer_heap(loop)); if (heap_node == NULL) break;
handle = container_of(heap_node, uv_timer_t, heap_node); if (handle->timeout > loop->time) break;    ... handle->timer_cb(handle); }}

    接着是 I / O callback

ran_pending = uv__run_pending(loop);

    在该阶段我们会检查是否有已完成的I/O操作的回调函数,处理来自上一轮的poll残留。即w->cb(loop, w, POLLOUT);

static int uv__run_pending(uv_loop_t* loop) { QUEUE* q; QUEUE pq; uv__io_t* w;
if (QUEUE_EMPTY(&loop->pending_queue)) return 0;
QUEUE_MOVE(&loop->pending_queue, &pq);
while (!QUEUE_EMPTY(&pq)) { q = QUEUE_HEAD(&pq); QUEUE_REMOVE(q); QUEUE_INIT(q); w = QUEUE_DATA(q, uv__io_t, pending_queue); w->cb(loop, w, POLLOUT); }
return 1;}

    紧接着是idle,prepare。这两种是node内部特定的阶段,我们可以忽略。

    Idle阶段在每次循环迭代中运行一次给定的回调,而且执行顺序是在prepare句柄之前。会执行一些低优先级的任务,或是执行零超时轮询,而不是阻塞I/O。

    prepare阶段则是在I/O轮询之前,执行的一次特定的回调。

uv__run_idle(loop);uv__run_prepare(loop);

    然后开启我们的I / 0  轮询。必要的时候node会阻塞在这个阶段。

timeout = 0;if (mode == UV_RUN_DEFAULT)timeout = uv_backend_timeout(loop);
uv__io_poll(loop, timeout)

    首先会通过uv_backend_timeout设置超时时间。满足以下条件之一,超时将会被设为0,这意味着此时poll阶段不会被阻塞。

  •  stop_flag不为0,eventloop未结束

  • 有未处理的handles 或是 request

  • 有未处理的idle_handles(node内部的低优先任务) 和 pending_queue(loop中等待执行的事件队列)

  • 有close_handles未处理

int uv_backend_timeout(const uv_loop_t* loop) { if (loop->stop_flag != 0) return 0;
if (!uv__has_active_handles(loop) && !uv__has_active_reqs(loop)) return 0;
if (!QUEUE_EMPTY(&loop->idle_handles)) return 0;
if (!QUEUE_EMPTY(&loop->pending_queue)) return 0;
if (loop->closing_handles) return 0;
return uv__next_timeout(loop);}

    在poll阶段我们会获取并执行几乎所有 I/O 事件回调,它会同步执行所有 poll 队列中的所有回调函数,然后结束 poll 阶段

    注意,当结束poll阶段或是在poll阶段时poll queue 为空。那么会有以下情况:

  • 如果没有setImmediate的回调执行,而timer队列不为空,则会去判断是否有timer超时,如果有则会回到timer阶段执行回调。

  • 如果有setImmediate的回调执行,那么就会进入check阶段。


uv__run_check(loop); // 执行 setImmediate() 的回调uv__run_closing_handles(loop); // 执行close事件的callback,例如socket.on("close",func)

    loop的最后将会执行check阶段,执行我们的setImmediate() 的回调,最后执行close事件的回调。

   process.nextTick本身并不是事件循环的一部分,他是属于微任务,在优先级在高于promise,setImmediate等。这里不做详细介绍,以及关于setTimeout(function(){},0)和setImmediate()。谁先执行的问题。可以参考node官网上的 https://nodejs.org/zh-cn/docs/guides/event-loop-timers-and-nexttick/ 。

    

    vue中也有一个nextTick,我们可以从 src/core/util/next-tick.js 看到降级顺序:promise -> MutationObserver -> setImmediate -> setTimeout。优先使用promise在下一次事件循环时,执行该回调函数,如果上一次循环进行了页面渲染,那么,我们就可以访问渲染后的DOM和更新过后的数据。


    最后给大家讲一个经典例子

setTimeout(()=>{ console.log('timer1')  Promise.resolve().then(function() {    console.log('promise1')  })}, 0) setTimeout(()=>{  console.log('timer2')  Promise.resolve().then(function() {    console.log('promise2')  })}, 0)

 结果如下:

    浏览器环境:time1,promise1,time2,promise2

    node11以下:time1,time2,promise1,promise2

    node11及以上:time1,promise1,time2,promise2

   在 node 11 版本中,node 下 Event Loop 已经与浏览器趋于相同。我们可以用浏览器的微任务和宏任务解释,11版本前的timer,由于到期时间相近,会在timer阶段合并执行。所以打出time1后,打印time2。



结束语


    

    以上就是为大家讲解的event Loop,小编水平有限,欢迎大家指正,感谢支持~


以上是关于JS - 你所不知道的EventLoop的主要内容,如果未能解决你的问题,请参考以下文章

js 你所不知道的一面

Js高级---你所不知道的正则表达式的神秘!

Js高级---你所不知道的ES6的规范!

你所不知道的charCodeAt与codePointAt(了解js字符串的码元与码点)

JavaScript你所不知道的困惑

关于setInterval()你所不知道的地方