事件循环机制(Event Loop)

2,356 阅读8分钟

其实在平时项目中我们会用到很多定时器,之前不了解事件循环机制,就只知道使用定时器会有延迟,但不清楚具体原因是什么,所以总结出了这篇文章跟大家分享。

为什么要有事件循环机制?

最开始我以为这概念是js的东西,后来看各种文档,都没发现哪里有描述这个概念,偶然的机会看html规范文档的时候,发现是html提出的概念。 规范的原话是为了协调事件,用户交互,脚本,渲染,网络等,所以才引入事件的循环概念。

事件循环机制从整体上告诉了我们所写的JavaScript代码的执行顺序,顺序的整体总结就是:
同步任务 -> 本轮循环 -> 次轮循环

下面的图 展示了一个简单事件循环的处理流程(图是盗来的 仅供参考 不过分解读😏) image.png

  1. JavaScript的一大特点就是单线程,异步操作都是放到事件循环队列里面,等待主执行栈来执行的,并没有专门的异步执行线程。当然新标准中的web worker涉及到了多线程,我对它了解也不多,这里就不讨论了。

  2. JavaScript代码的执行过程中,除了依靠函数调用栈来搞定函数的执行顺序外,还依靠任务队列(task queue)来搞定另外一些代码的执行。 队列:先进先出 59958415f617d44cdb990d.png

  3. 一个线程中,事件循环是唯一的,但是任务队列可以有多个。

  4. 任务队列又分为macro-task(宏任务)micro-task(微任务)

  5. macro-task大概包括:script(整体代码),setTimeout,setInterval,setImmediate, I/O, UI rendering。

  6. micro-task大概包括:process.nextTick,Promise,Object.observe(已废弃), MutationObserver(html5新特性)。

  7. setTimeout/Promise等我们称之为任务源。而进入任务队列的是他们指定的具体执行任务。来自不同任务源的任务会进入到不同的任务队列。其中setTimeout与setInterval是同源的。

// setTimeout中的回调函数才是进入任务队列的任务
setTimeout(function() {
  console.log('123')
})
// setTimeout作为一个任务分发器,这个函数会立即执行,
// 而它所要分发的任务,也就是它的第一个参数,才是延迟执行。
  1. 其中每一个任务的执行,无论是macro-task还是micro-task,都是借助函数调用栈来完成。

js执行顺序之事件队列

  1. 事件队列就是一个函数队列(函数数组)每次执行耗时的操作(其他进程处理)都会提供一个回调函数,交给其他进程。
  2. 当其他进程实现完成耗时操作以后,回调函数连同处理的结果,作为参数会投递到函数队列中。
  3. js引擎,从队列中取一个函数执行。
  4. 执行完成后,再从队列中取一个函数执行,直到队列为空,那么就停止。
  5. 然后如果又有人将事件投递到队列中,js引擎又会运行起来。

打个比方 js引擎就好比一个公司的老板 老板做事情不需要自己亲自动手,将任务分配给相应的工作人员,然后将这个任务记录到工作日志中,如果有一个人把事情做完了,走到老板身边,如果老板正在处理一些事情,怎么办?

等待事件的结果 交给老板的秘书; 如果又有一个人完成,继续交给秘书(排序、汇报)。

可以用一张图来说明下流程: image.png

// 在js当中它的事件队列非常复杂(v8,这里不做赘述)

// 模拟一个数组
var queue = [
  [], // timer队列 专门用于存放timeout回调
  [], // pending poll队列(不是一个队列,这专门处理IO,很多队列,只是我们不需要考虑)可以简单理解为IO队列
  [], // check队列 专门用来存放setImmediate回调
  [] // close队列 用来存放close行为
]

// js引擎处理队列
var i = 0;
var len = queue.length
// 引擎内部的处理流程
while (true) { // 除非所有队列清空,否则一直执行下去;并且如果处于停止状态,一旦有事件入列,立即激活该循环。
  wihle (i < len) {
    var subqueue = queue[i]
    // 处理子队列,处理完该子队列
    i++
  }
  i = 0
}

先来举个简单的例子

看看这几行代码的输出顺序是什么?

setTimeout(function() {
  console.log('timer1')
}, 1000)
console.log('main process 1')
console.log('main process 2')
setTimeout(function() {
  console.log('timer2')
}, 0)
console.log('main process 3')
console.log('main process 4')
setTimeout(function() {
  console.log('timer3')
}, 0)
// main process 1 ~ 4
// timer2
// timer3
// 1s后输出 timer1
  1. setTimeout 执行是同步的,会将回调函数放入事件队列的timer 队列中,现在不会执行,并记录执行时间;
  2. 进入console.log系列,此时就是先执行,这里执行完成以后,启用事件队列中的代码;
  3. 进入事件队列后,优先开始运行timer队列,发现只有一个存在的函数,将其取出执行;
  4. 此时需要注意
  • timer 事件队列中的函数存在一个时间(1000ms)的范围,假如现在已过去900ms,不好意思,此时不会执行,还会继续遍历pending,roll,check,close...队列;

  • 等到这些队列遍历完,又会回调timer对象,如果此时发现时间已过去990ms,不好意思,此时还不会执行,还会继续遍历pending,roll,check,close...队列;

  • 等到这些队列遍历完,又会回调timer对象,如果此时发现时间已过去1000ms(有可能是1002ms),才会执行。

计时器会指定一个时间临界点, 在时间临界点后指定的回调可能会被执行, 但并不是在人们预想的时间执行. 计时器指定的回调函数会在时间到来后尽快的被安排执行. 这是因为操作系统的调度以及其他回调的执行会加重这种延迟.这就是为什么定时器会延迟执行的原因。

有图有真相 image.png

再来个简单的

加上我们熟悉的Promise

setTimeout(function() {
  console.log('timeout1');
})

new Promise(function(resolve) {
  console.log('promise1');
  for(var i = 0; i &lt; 1000; i++) {
    i == 99 && resolve();
  }
  console.log('promise2');
}).then(function() {
  console.log('then1');
})
console.log('global1');
  1. script任务执行时首先遇到了setTimeout,setTimeout为一个宏任务源,那么他的作用就是将任务分发到宏任务队列中

  2. 遇到Promise,Promise构造函数中的第一个参数,是在new的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,输出promise1、promise2,而后续的.then则会被分发到micro-task的Promise队列中去。

  3. 继续往下执行,输出了globa1,然后,全局任务就执行完毕了。

  4. 第一个宏任务执行完毕之后,就开始执行所有的可执行的微任务。这个时候,微任务中,只有Promise队列中的一个任务then1,因此直接执行就行了,执行结果输出then1,当然,他的执行,也是进入函数调用栈中执行的。

  5. 当所有的micro-tast执行完毕之后,表示第一轮的循环就结束了。这个时候就得开始第二轮的循环。第二轮循环仍然从宏任务macro-task开始

  6. 这个时候,我们发现宏任务中,只有在setTimeout队列中还要一个timeout1的任务等待执行。因此就直接执行输出timeout1

  7. 这个时候宏任务队列与微任务队列中都没有任务了,所以代码就不会再输出其他东西了,执行到此结束。

上图 image.png

最后呢来个稍微复杂点的

过于复杂也没什么实际意义😏 就这样能理解就刚刚好

console.log('global1');

setTimeout(function() {
    console.log('timeout1');
    new Promise(function(resolve) {
        console.log('timeout1_promise');
        resolve();
    }).then(function() {
        console.log('timeout1_then')
    })
})

new Promise(function(resolve) {
    console.log('promise');
    resolve();
}).then(function() {
    console.log('promise_then')
})

console.log('global2');

看上去乱七八糟好像有点乱,别急,慢慢一步一步来分析:

  1. 宏任务script首先执行。全局入栈。global1输出

  2. 执行过程遇到setTimeout。setTimeout作为任务分发器,将任务分发到对应的宏任务队列中。

  3. 执行遇到Promise。Promise的then方法会将任务分发到对应的微任务队列中,但是它构造函数中的方法会直接执行。因此,promise会第二个输出。 而.then则会被分发到微任务队列中去。

  4. 接着输出 global2,此时微任务队列里只有一个任务,执行并输出promise_then。当所有可执行的微任务执行完毕之后,这一轮循环就表示结束了。

  5. 下一轮循环继续从宏任务队列开始执行,此时宏任务队列里有setTimeout,输出timeout1,接着又遇到Promise输出timeout1_promise,timeout1_then被放进微任务队列里,执行此微任务输出timeout1_then,整个执行到此就结束了。

继续上图 image.png

需要注意的是,这里的执行顺序,或者执行的优先级在不同的场景里由于实现的不同会导致不同的结果,包括node的不同版本,不同浏览器等都有不同的结果。

好的,文章到这就完结了,可能会有些枯燥,哪里总结的不到位,还请联系我做修改。