结合JS运行机制,理解Event Loop

846 阅读14分钟

前言

这几在看Vue的源码与其相关的博客,看到了关于Vue异步更新策略nextTick的诸多文章,奈何功力不够深厚,看的是有点蒙蔽。主要原因是这些个模块,需要对JS的一些运行机制和Event Loop(事件循环)有一定的了解,于是决定再一次深入的去了解这些知识。

在后来几天的学习中,当下就是总结了这几个蒙蔽点(你可先尝试自我回答一下):

  • JS作为一个单线程语言,是如何实现并发的执行(定时器,http请求)任务的?
  • 什么是主线程/call stack(执行栈)?
  • 什么是task queue(任务队列)?
  • 什么是(task/macrotask)宏/(microtask)微事件?
  • 所谓的事件循环,在浏览器运行层面来说,究竟是什么?
  • 每次事件循环,都干了什么事情?

当时在学习的过程中,我就如下图这样的感觉:

带着上面这些个问题,于是开始这几天的展开式的学习,从Event Loop的概念,在到浏览器层面的实际运行原理。

本篇参考诸多文章,借鉴了里面的很多原话,都在文章的末尾都一一列出了。

渲染进程

我猜大部分做前端的,都知道Event Loop(事件循环)的概念。但是很多人,对它的了解非常的片面。要想知道这个概念究竟是什么,就要浏览器是如何运行的说起。

首先,浏览器是多进程执行的,但是对于我们研究最重要的,就是浏览器多个进程中的渲染进程,在浏览器的运行中,每一个页面都有独立渲染进程。这个进程分别由如下几个线程在工作:

  • GUI渲染线程
  • JS引擎线程
  • 事件触发线程
  • 定时器触发线程
  • 异步http请求线程

上面这几个线程,保证我们整个页面(应用)的完整运行。

JS引擎线程

JS引擎线程负责解析Javascript脚本,运行代码,V8引擎就是在这个线程上运行的。

  • 这条线程,也就是在事件循环中,咱们常说的主线程和call stack(执行栈)。所有的任务(函数),最终都会进入这里来执行。
  • 只要执行栈空了,它就会不断的去访问task queue(任务队列),一旦任务队列中有可以执行的函数,就会压入栈内执行。
  • 一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程的,所有JS是单线程的

现在出现了两个词:call stack(执行栈),task queue(任务队列),这里先来解释一下,什么是执行栈。

call stack(执行栈)

栈是一个先进后出的数据结构,所有的函数都会逐一的进入到这里面执行,一旦执行完毕就会退出这个栈。

    function fun3() {
        console.log('run');
        throw Error('err');
    }

    function fun2() {
        fun3();
    }

    function fun1() {
        fun2();
    }

    fun1();

这里我特意在fun3抛出了一个异常,我们来看一下浏览器的输出:

上面这列出来的一个个函数,就是一个执行栈。这里我用一个更详细的图解来表示一下执行栈的运行过程:

上面这个图解,是对执行栈运行过程的分布演示。这个执行栈,就是我们JS真正运行的地方,函数的调用会在这里形成一个调用栈,在里面是一个个执行的,必须得等到栈顶的函数执行完毕退出,才能继续执行下面的函数。一旦这个栈为空,它就会去task queue(任务队列)看有没有待执行的任务(函数)。

那么我们常说的任务队列,究竟又是一个啥玩意呢?

事件触发线程

首先,这里还要强调一下上面的提到的,一个页面的运行,是需要多个线程配合支持的。

咱们常说的任务队列,就是由这个事件触发线程来维护的。当时,我看到这个就蒙蔽了……尼玛,JS不是单线程吗?这条事件触发线程是怎么回事?

JS的确是还是单线程执行的。这个事件触发线程属于浏览器而不是JS引擎,这是用来控制事件循环,并且管理着一个任务队列(task queue),然而对本身的JS程序,没有任何控制权限,最终任务队列里的函数,还是得交回执行栈去执行。

task queue(任务队列)

那么这个线程维护的这个task queue究竟是干嘛的呢?

上面在说call stack(执行栈)的时候,咱们提到了,一旦执行栈里面被清空了,它就会来看任务队列中是否有需要执行的任务(函数)。这个任务队列可能存放着延期执行的回调函数,类似setTimeout,setInterval(并不是说setTimeout和setInterval在这里面,而是他们的回调函数),还可能存放着Ajax请求结果的回调函数等等。

这里先看下具体代码:

    console.log('1');

    setTimeout(() => {
        console.log('2');
    }, 1000);

    $.ajax(/*.....*/)

现在我们来图解一下,整个运行过程(图画的比较丑,别建议):

  • 第一步,console.log方法进入执行栈,执行完毕后退出。

  • 第二步,执行setTimeout方法。大家知道延迟,是需要去读数的(你可以理解为计时),当到了时间,就让回调进入到任务队列里面,去等待执行。然而,这个读数的工作是谁在做呢?首先肯定不是JS引擎线程在做,因为执行栈一次只能执行一个任务,如果在执行栈中去读数,必然会造成阻塞,所以渲染进程中,有专门的定时器触发线程来负责读数,到了时间,就把回调交给任务队列。

  • 第三步,发起Ajax请求,请求的过程也是在其他线程并行执行(http请求线程)的,请求有了结果以后,回调函数加入事件触发线程的任务队列。

所以,现在应该明白call stack(执行栈),task queue(任务队列)是怎么一个工作状态了吧。这里说一句不专业的话,但是你可以这么去理解:

在浏览器环境下的JS程序运行中,其实并不是单线程去完成所有任务的,如定时器的读数,http的请求,都是交给其他线程去完成,这样才能保证JS线程不阻塞。

定时器触发线程

上面我们提到在执行setTimeoutsetInterval的时候,如果让JS引擎线程去读数的话,必然会造成阻塞。这也是不符合实际需求的,所以这件读数的事情,浏览器把它交给了渲染进程中的定时器触发线程。

一旦,代码中出现timer类型API,就会交给这个线程去执行,这样JS引擎线程,就可以继续干别的事情。等到时间一到,这个线程就会将对应的回调,交给事件触发线程所维护的task queue(任务队列)并加入其队尾,一旦执行栈为空,就会拿出来执行。

但是这里要提一点,就算执行栈为空也不一定能马上执行这个回调,因为task queue(任务队列)中可能还有很多的待执行函数,所以定时器只能让它到了时间的加入到task queue中,但不一定能够准时的执行。

异步http请求线程

这个线程就是专门负责http请求工作的。简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到任务队列,等待js引擎线程来执行。

GUI渲染线程

这个线程要重点说一下。首先这个GUI渲染线程和JS引擎线程是互斥的,说白了就是这两个同一时间,只能有一个在运行,JS引擎线程会阻塞GUI渲染线程,这也是为什么JS执行时间长了,会导致页面渲染不连贯的原因。

  • 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和Rendert树
  • JS负责操作DOM对象,GUI负责渲染DOM(最耗费性能的地方),GUI线程会在每次循环中,合并所有的UI修改,也是浏览器对渲染的性能优化。
  • GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。

小结

通过上面的这些理论,脑海里应该大致知道浏览器层面的JS是如何去工作了的吧。现在应该可以回答一开上面提出的部分问题了:

  • JS作为一个单线程语言,是如何实现并发的执行(定时器,http请求)任务的?答案:因为浏览器提供了定时器触发线程和异步Http请求线程,来分担这些会造成主线程阻塞的工作。
  • 什么是主线程/call stack(执行栈)?答案:执行栈是JS引擎线程(主线程)中的一个先进后出执行JS程序的地方。一次只允许一个函数在执行,一旦栈被清空,将会轮询任务队列,将任务队列中的函数逐一压如栈内执行
  • 什么是task queue(任务队列)?答案:任务队列是在事件触发线程中,一个存放异步事件回调的地方。当定时器任务,异步请求任务在其他线程执行完毕时,就会将加入队列的队尾,然后被执行栈逐一执行。

OK,现在已经解决三个问题,接下来我们继续解决剩下的三个问题。这个三个问题,就是从JS事件循环机制的角度来研究了。

Event Loop(事件循环)

我相信大部分搞前端的,都应该知道这玩意。但是,我发现并不是每个人都能说清楚这个东西。彻底了解这个,对于我们处理开发中许多异步问题和阅读源码,是很多有帮助的。

首先,我们先开看一张图(此图出自于Event Loop的规范和实现):

我觉得如果你看完了上面渲染进程相关知识,在看这个图,应该是能理解百分之70了吧,剩下百分之是因为里面出现了microtask queue(微任务队列)和Promise,mutation observer的相关字眼。

我觉得,在开始了解Event Loop之前,有必要提出两个问题:

  • 为什么要有Event Loop
  • Event Loop的每一个循环,干了些什么事?

宏/微任务

针对上面给出这个图出现的一个新词microtask,来展开进行学习。

首先,先来看一段代码:

   setTimeout(() => {
        console.log(1);
    });

    Promise.resolve().then(() => {
        console.log(2);
    });

    console.log(3);

输出的结果:3,2,1

在还没有接触的Event Loop之前,看到这个结果的时候,说实话,我是很懵逼的。

OK,到这里,我们需要先知道两个概念:task(任务),microtask(微任务);

这里提一点,网上很多博客说到了一个macrotask(宏任务)其实跟这个task(任务)是一个东西。你可以参考换一下HTML5规范的文档,里面甚至没有macrotask这个词,“宏”这个概念,只是为了更好区分任务和微任务的关系。

通过仔细阅读文档得知,这两个概念属于对异步任务的分类,不同的API注册的异步任务会依次进入自身对应的队列中,然后等待Event Loop将它们依次压入执行栈中执行。

task主要包含主代码setTimeoutsetIntervalsetImmediateI/OUI交互事件

microtask主要包含Promiseprocess.nextTickMutaionObserver

这里提一点:Promise的then方法,会将传入的回调函数,加入到microtask queue中。

然后接下来,你需要知道Event Loop的每个循环的流程:

  • 执行一次最旧的task
  • 然后检测microtask(微任务),直到所有微任务清空为止
  • 执行UI render(JS引擎线程被挂起等待,GUI渲染线程开始运行)

现在带着渲染进程的知识,结合这个流程,来捋一遍上面代码:

  1. 第一轮循环,主代码是一个task,于是它进入JS引擎线程中的执行栈开始执行。
  2. setTimeout方法被调用,也进入执行栈,将一个延时的异步事件交给了定时器触发线程去读数,然后它马上退出执行栈。
  3. Promise.resolve()进入执行栈,返回一个Promise,退出执行栈。
  4. then()方法进入执行栈,将一个函数加入了microtask queue(微任务队列),退出执行栈。
  5. console.log(3)进入执行栈,输出3,退出执行栈。
  6. 此时,主代码已经执行完毕,第一个task,退出执行栈。
  7. 然后,执行栈去看microtask queue,发现一个()=>{ console.log(2) }函数,压入执行栈,输出2,退出执行栈。
  8. 此时,microtask queue被清空,切到GUI线程,看是有需要变动UI的,第一轮循环完毕。
  9. 第二轮循环。在第一轮循环的代码执行中,setTimeout发起的定时器是在定时器触发线程并发进行,读数完毕,回调交给事件触发线程中的task queue。所以,此时任务队列中有一个待执行的task
  10. 执行栈将这个task压入执行栈执行,输出1,然后退出执行栈。
  11. 整个Event Loop,继续重复上面的流程执行。

Event Loop的不断循环,保证了我们的JS代码同步和异步代码的有序执行。

现在回答一下上面提出的两个问题:

  1. 因为Javascript设计之初就是一门单线程语言,因此为了实现主线程的不阻塞,Event Loop这样的方案应运而生。
  2. task=>microtask=>GUI

重点说一下microtask(微任务)

ES6新引入了Promise标准,同时浏览器实现上多了一个microtask微任务概念。在浏览器上,主要有两个微任务API:

  • Promise.then
  • mutation observer

第一个大家应该都熟悉,第二个呢,我之前也不知道,是后来再看Vue的nextTick源码中看到的,有兴趣的同学可以去了解一下这个API。

这里主要说一下微任务和宏任务的不同点,和相同点。

不同点:

  • 宏任务:
    • 异步的任务是需要在其他线程上去执行的,主要是为了保证主线程不阻塞。
    • 宏任务的回调函数,是先保存在任务队列中的,也就是事件触发线程上。
    • 一次循环,只执行一个task
  • 微任务:
    • 微任务它不是异步任务,它会直接将回调函数加入microtask queue(微任务队列)。
    • 每次前一个task执行完毕,然后所有的microtask都要被执行完。

相同点:

  1. 他们的回调函数,都不会本轮循环中立即执行。
  2. 没有回调函数,它们都将失去意义。

现在再来把最开始提出的后三个问题回顾一下,应该有一个大致的概念了吧。

最后

其实本来是想写,结合Event Loop来理解Vue的异步批量更新以及nextTcik的,但是后面发现Event Loop这块写的太多了,于是就分开写了。。。

但是,我相信你看完上面的全部内容,在面试的时候,或者碰到异步相关问题的时候,都应该能够应付了。其实这个Event Loop中还有一些用户交互事件没详细讲到,有兴趣的可以自行研究一下。

Tips:如果有错误或者有歧义的地方,可以在直接指出。

本篇参考的资料:

「硬核JS」一次搞懂JS运行机制(这篇博客中,说到关于浏览器进程和线程的知识,讲解的非常详细,同时对Event Loop也是总结的非常好)

Event Loop的规范和实现(这个主要讲Event Loop,也是非常的通俗易懂,里面的许多案例值得参考)