2022开篇:事件循环Event Loop

288 阅读10分钟

1. 前言

这里我们先搞明白几个概念。

1.1 进程与线程

当我们打开手机上的微信app的时候就会开启了一个进程,当我们在微信里面进行各种操作(查看朋友圈,扫一扫...)此时就是在线程中完成的。

进程(Process):进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。程序运行时系统就会创建一个进程,并为它分配资源,然后把该进程放入进程就绪队列,进程调度器选中它的时候就会为它分配CPU时间,程序开始真正运行。

process.png

线程(thread):线程是程序执行时的最小单位,它是进程的一个执行流,是CPU调度和分派的基本单位。一个进程可以由很多个线程组成,线程间共享进程的所有资源,每个线程有自己的堆栈和局部变量。线程由CPU独立调度执行,在多CPU环境下就允许多个线程同时运行。同样多线程也可以实现并发操作,每个请求分配一个线程来处理。

thread.jpeg

1.2 浏览器内核

浏览器是一款应用程序,运行时会创建一个或多个进程,因此它是多进程的。在浏览器中每新建一个tab标签就创建独立的进程,浏览器内核属于浏览器多进程中的一种表现,负责完成浏览器的所有工作任务。

而浏览器内核是基于多线程来工作的:

  • GUI渲染线程

    • 负责渲染页面。解析 HTML,CSS 构成 DOM 树等,当页面重绘或者由于某种操作引起回流都会调起该线程。
    • JS 引擎线程是互斥的,当 JS 引擎线程在工作的时候,GUI 渲染线程会被挂起,GUI 更新被放入在任务队列中,等待 JS 引擎线程空闲的时候继续执行。
  • JS 引擎线程:

    • 单线程工作,负责解释执行 JavaScript 脚本代码。
    • 和 GUI 渲染线程互斥,JS 运行耗时过长就会导致页面阻塞。
  • 事件触发线程:

    • 当事件符合条件被触发时,该线程会把对应的事件回调函数添加到任务队列的队尾,等待 JS 引擎处理。
  • 定时器触发线程:

    • 浏览器定时计数器并不是由 JS 引擎计数的,阻塞会导致计时不准确。
    • 开启定时器触发线程来计时,计时完成后会被添加到任务队列中,等待 JS 引擎处理。
  • http 请求线程:

    • http 请求的时候会开启单独的请求线程。
    • 请求完成有结果了之后,将请求的回调函数添加到任务队列中,等待 JS 引擎处理。

2. JavaScript引擎

JavaScript 引擎是单线程的运行机制,也就是说每次只能执行一项任务,其他任务都得按照顺序排队等待被执行,只有当前的任务执行完成之后才会往下执行下一个任务。

虽然HTML5 中提出了 Web-Worker API,主要是为了解决页面阻塞问题,但是并没有改变 JavaScript 是单线程的本质。了解 Web-Worker

正是由于JS引擎是单线程机制,从而避免了在多线程下同时操作DOM引发的二义性,让浏览器引擎不知所措的情况。

但是,在实际开发中又避免不了会有一些异步操作。比如http请求。

由于http请求无法预知何时请求成功并返回结果,如果依旧仅仅沿用单线程机制就会导致这样的问题:请求结果在没有回来之前,JS引擎就会一致等待直到接收到结果,那么这段时间GUI渲染线程也无法工作一直被阻塞,进而导致页面无法和用户产生交互,大大的降低用户体验。

那么,如何解决这样的问题呢?答案就是今天要说的事件循环

3. 事件循环

JavaScript 事件循环机制分为浏览器和 Node.js 两种事件循环机制,两者的实现技术不一样,浏览器 Event Loop 是 HTML 中定义的规范,Node.js Event Loop 是基于 libuv 库实现。这里主要讲的是浏览器部分。

Javascript引擎 中有一个 main thread 主线程和 call stack 调用栈(执行栈)

  • 主线程:主要负责调度代码的执行。
  • 调用栈:一种后进先出的数据结构。

3.1 调用栈

看过上篇【你要的JavaScript执行上下文】的宝宝儿们都知道,当调用函数的时候,JS引擎会事先生成一个与这个函数对应的执行环境(context),又叫执行上下文。这个执行环境中存在着该函数的作用域,外层作用域的指向,参数,变量以及这个作用域的this值。 而当一系列函数被依次调用的时候,因为js是单线程的,同一时间只能执行一个函数,于是这些函数被排队在一个单独的地方。这个地方被称为执行栈。

3.2 同步与异步

在JavaScript中,通常情况下我们会将可执行代码分为同步的和异步的两种:

  • 同步代码:当JS引擎主线程执行该代码时,需要等待该代码运算后有返回值时,才会继续向下执行。
var a = 10; // 当执行到该代码时,必须等待a赋值完毕后,才会向下执行console.log()
console.log(a);
  • 异步代码:当JS引擎主线程执行该代码时,无需等待直接进入下一行代码的执行。
// 这里setTimeout调用是同步的,但是回调是异步的,即便setTimeout第二个参数为0.
// 因此,setTimeout执行后,不会等待回调执行,程序继续向下执行 a 的赋值操作(a = 10)
setTimeout(()=> {
  // 这个回调函数中的代码 是异步的
  // 因为 该回调为异步回调
  console.log(a);
}, 0); 
var a = 10; // 当执行到该代码时,必须等待a赋值完毕后,才会向下执行console.log()
console.log('同步');

3.3 宏任务与微任务

从上面例子中,我们发现同步代码优先执行完毕,之后才会执行异步代码。因此先是在控制台打印字符串"同步",随后才是打印出"10"。因此在表象上看,同步代码执行优先级高于异步代码。

但是呢,实际上JS引擎是有内部指定的一套规则来完成的。

首先,我们先将一段代码划分成“任务task”。任务又分为:

  • 宏任务 macro task
  • 微任务 micro task

而所谓的事件循环就是在宏任务与微任务之间来回切换,实现同步代码与异步代码的之间的切换执行。

而在宏任务中,包含一下几个方面:

  • 整个script标签下代码
  • 事件处理程序
  • setInterval & setTimeout的回调
  • 网络请求的回调
  • GUI渲染

宏任务里的代码 不是上来就一定会被执行,而是放在一个名为“宏任务队列”里排队,等待着被执行。

与宏任务不同,微任务有自己的队列,叫“微任务队列”。微任务也不立即执行,需要去队列中排队等待执行。

除了宏任务,剩下的就是微任务:

  • Promise对象,比如then & catch & finally方法中的回调
  • MutationObserver构造函数中的回调
  • Object.observe

3.4 Event Loop

事件循环就是在描述JS引擎如何协调宏任务与微任务之间切换执行的过程。

一个事件循环周期内,必然是从一个宏任务开始的。但是结束时可能是一个微任务也有可能是一个宏任务。

在事件循环中,每一个事件周期被称作一次“tick”。那么在整个事件循环周期内会有多次tick。一次tick是以一个宏任务执行开始,然后将所有微任务执行完毕后,在开始下一个tick。

虽然JS在执行时只能在一个主线程上被执行,但是各个宏任务或者微任务不一定是单线程的执行机制。比如说Ajax请求会创建一个独立线程去监听事件;一个setTimeout或者setInterval的调用同样也会创建一个独立线程去计时;一个DOM事件也是在一个独立线程中,监测用户行为。

下面是事件循环的描述

  • JS引擎在开始执行代码时,一定要先执行script标签下代码,即先执行一个宏任务
  • 代码在执行过程中,如果是简单的同步代码的话,就等待当前代码有返回值后继续向下执行即可;
  • 但是如果遇到了 事件绑定,此时一旦用户触发了该事件,事件的处理函数被压入 宏任务队列 中排队;
  • 如果遇到了 网络请求,此时一旦请求成功并数据响应回来了,事件的处理函数也会被压入 宏任务队列 中排队;
  • 如果遇到了 setInterval & setTimeout, 此时开始计时,一旦计时到了就会将回调函数压入 宏任务队列 中排队;
  • 如果遇到了 Promise对象,此时一旦Promise对象状态不再为Pending,对应的回调被压入 微任务队列 中排队;
  • 断言:代码执行到最后。此时JS引擎开始协调宏任务与微任务之间的执行;
  • 先从微任务队列中取出所有的任务依次(队列中先入先执行)执行完毕后,微任务队列为空时;
  • 再从宏任务队列里拿出一个任务执行,执行完毕后,
  • 在继续取出所有的微任务执行(如果有的话)
  • 接着,在取一个宏任务执行。如此反复,直到所有队列为空为止。
  • 那JS引擎干啥呀?老话说:“没活你就呆着,等着活儿来。”

eventloop.png

4. 面试真题演练

console.log(1);
setTimeout(function() {
    console.log(2);
})
var promise = new Promise(function(resolve, reject) {
    console.log(3);
    resolve();
})
promise.then(function() {
    console.log(4);
})
console.log(5);
  • 上面的示例中,整段代码作为宏任务进入主线程执行。
  • 遇到了 setTimeout ,就会等到过了指定的时间后将回调函数放入到宏任务队列中。
  • 遇到 Promise,将 then 中回调函数放入到微任务队列中。
  • 调用栈为空后,会去检测微任务队列中是否存在任务,存在就依次取出并执行。
  • 第一次tick结果打印为: 1,3,5,4。
  • 接着再到宏任务的任务队列中按顺序取出一个宏任务到栈中让主线程执行,那么在这次循环中的宏任务就是 setTimeout 注册的回调函数,执行完这个回调函数,发现在这次循环中并不存在微任务,就准备进行下一次事件周期。
  • 检测到宏任务队列中已经没有了要执行的任务,那么就结束事件循环。
  • 最终的结果就是 1,3,5,4,2。

5. 总结

事件循环中,JavaScript 引擎会把整个 script 代码当成一个宏任务执行,执行完成之后,再检测本次循环中是否存在微任务,存在的话就依次从微任务队列中读取执行完所有的微任务,再读取一个宏任务队列中的任务执行,再执行所有的微任务,如此循环。JS 的执行顺序就是每次事件周期中1个宏任务--队列所有微任务。

最后,大家读完这篇文章后能否总结出:“事件循环的意义到底是什么,或者说体现在哪些方面呢?”欢迎大家随时评论,一起探讨,一起进步。

生命不息,coding不止 -- 送给元旦还在努力coding的宝宝们。