JS引擎和运行机制

1,042 阅读15分钟

进程和线程

我们要去了解JS的运行机制,首先我们得现有一些前置知识,那就是进程和线程的概念。一下摘自百度百科

  • 进程:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
  • 线程:是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

对于上面的概念,确实有点不好理解,通过网上查阅对于进程和线程一些比喻和描述,我觉得应该这样去理解它们。

  • 从包含关系来看是进程包含线程。
  • 我们把CPU看做是一个兵工厂,在这个兵工厂里面有许多个车间,每个车间的任务是不同的,有的造大炮,有的造坦克,还有的造飞机......那么我们可以把每个车间看作是一个进程,假设我们的电脑是单核CPU的,那么相对于我们的这个工厂电力有限,只能够使得一个车间进行运作,其他的车间只有等待当前车间任务完成,腾出电力才能运作。总结出来就是每一个CPU上同一时间只能运行一个进程。
  • 对于我们正常运行的进程,也就是我们比喻中的一个车间,在这个车间里面,有许许多多的工人,每一个工人负责具体任务也不一样,有的工人负责炼钢,有的工人负责铸钢,有的工人负责配置弹药......车间中的工人就相当于进程中的线程,但是由于工人们都在一个车间里面,一起使用这个车间里面的工具进行生产,对于我们的线程来说,所有线程都共享同一个进程的资源,如内存等。

JS运行机制

那么从上面我们大致了解了一下什么是进程和线程,现在来讨论JS,首先我们应该都知道JS是一门单线程的解释性语言,单线程就是指,JS的任务始终运行在一个线程里面,虽然有woker一类实现了所谓的“多线程”但是在底层依旧是在用单线程进行实现的。至于什么是解释性语言,JS不像C++、JAVA一样是编译成计算机可执行语言之后再执行的,它是动态编译的,也就是边编译边执行的。那么编译这项工作是由谁来完成呢:浏览器内核或者V8引擎(chrome和NodeJS环境下)。在编程成计算机可执行的语句后,要实现的具体功能,例如操作DOM对象、获取计算机操作系统信息这些功能又是由谁来提供的呢,答案是环境。下面这张图就描述了JS到具体功能实现的大体过程。

JS运行流程.jpg

通过这张图我们大致了解了JS的运行机制,现在我们来详细描述一下JS引擎和运行环境。

JS引擎

首先关于JS的引擎,大家应该都知道现在最著名的V8引擎了吧,(tips: 浏览器内核是由渲染引擎和JS引擎构成,想要了解细节的同学可以去查阅相关资料),V8引擎是现在chrome浏览器的JS引擎也是NodeJs的引擎。那么这个引擎究竟是何方神圣呢?其实就是一个堆和一个栈。说详细一点就是一个内存堆和一个调用栈,关于堆栈的一些描述大家可以先去看看我之前JavaScript异步机制 末尾对于堆栈的描述。那么下图就是JS引擎的一个模型图

JS引擎.jpg

在JS的调用栈中,说白了就是一个个函数的执行,这些函数包括全局的函数、通常意义上的函数和eval函数,在得到JS代码之后,首先会向栈底放入全局代码,然后遇到代码中的函数就会入栈这个函数并执行,基本类型的变量是直接存在栈里面的,引用类型的实际值是放在内存堆中,他在堆中的地址是存放在栈内的。在调用栈中就是实际代码逻辑执行的地方,代码会被划分成一个一个的可执行单元,依次入栈并执行。这些可执行单元就是“执行上下文”,关于具体的执行上下文我会在之后单独写一篇文章来详细描述。回到主题,那么执行栈是怎么执行代码呢?首先将全局代码入栈,在执行它的时候,遇到了其他函数,就将函数入栈并执行,如果发现函数调用了其他的函数,再入栈执行,没有调用了,就在执行完后开始退栈。以下面的代码为例子

function a () {
  console.log('this is print')
}
function b () {
  a()
}
b() // this is print
// ...其他代码

在调用栈中的执行过程如下

  • 首先是全局代码当做一个函数入栈,我先称其为global函数,此时调用栈栈底有一个函数global()
  • 在global()中发现有调用b(),此时将b()入栈
  • 在b()中发现有调用a(),此时将a()入栈
  • 在a()中发现调用了console.log()函数,将其入栈
  • 在a()中没有其他函数了,这个时候开始执行console.log()函数,输出完毕后,将其退栈
  • 在b()中没有其他函数,将a()退栈
  • 在global()中b()已经执行完毕,将其退栈
  • 此时例子中的代码执行完毕。

运行过程如下图所示:

执行栈运行.jpg 其实上面只是以一种比较简单的方式描述了JS函数在调用栈中的执行情况,下面我们将详细来讲解一下JS引擎具体的执行全过程过程。

JS引擎执行过程

JS引擎执行的三个阶段:

  • 语法分析阶段

    这个阶段是在JS代码加载完毕后最早开始执行,目的是为了检查JS代码的语法错误,如果检查到代码有语法错误,则抛出一个语法错误,若检查完毕且没有错误,则进入预编译阶段。如下面这段代码:

    function a () {
      console.log(11
    }
    a()
    //Uncaught SyntaxError: missing ) after argument list
    

    上面的代码中缺少右括号,在语法分析阶段被检查出来,抛出的一个语法错误。

  • 预编译阶段 在语法分析阶段检查完成之后,现在进入预编译阶段,此阶段要为下一阶段函数执行做好准备,那么我们先了解一下JS的运行环境,运行环境分为一下几种:

    1. 全局环境(JS代码加载完毕,通过语法分析阶段后,进入全局环境)
    2. 函数环境(函数调用时,进入函数环境,每个函数都有对应的函数环境)
    3. eval(不建议使用,有安全问题,不作详细说明)

    在上述的每一个环境中,每进入一个不同的环境,就会创建一个相应的执行上下文(Execution Context),在调用栈中的每一个个体可以理解为是一个执行上下文。在调用栈中,栈底永远是全局的执行上下文,栈顶是当前的执行上下文。所有我们可以把执行上下文和函数执行环境理解为等价的。创建执行上下文的时候,主要进行了以下三个事件:

    1. 创建变量对象VO(Variable Object)

    2. 建立作用域链(Scope Chain)

    3. 确定this指向

    创建变量对象VO

    创建变量对象要经过以下几步

    1. 创建arguments对象,检查当前上下文中的参数,并将其初始化,仅在函数环境中进行(非箭头函数),全局上下文没有此过程
    2. 检查当前上下文中的函数声明,按照代码顺序查找当前上下文中的函数声明,并且将其提前声明,在当前上下文的VO中,以函数名称建立一个属性,该属性的值为这个函数的堆内存指针。若有重复,则覆盖为最新的函数的指针。
    3. 检查当前上下文的变量声明,也是按照代码顺序进行查找,若找到,则在当前VO中,建立一个以该变量为名称的属性,并且初始化值为undefined ,如果已经存在则忽略该变量的声明。

    注意:window对象就是全局环境的变量对象,所有的变量和函数都是window的属性和方法。

    那么我们通过下面这个例子来看一下VO是怎么样的:

    function main(a, b) {
      var c = 10
      function sub() {
        console.log(c)
      }
    }
    main(1, 2)
    

    通过上面的描述,我们知道,这段代码的调用栈的最底层有一个全局执行上下文,在这我们关心main函数的的执行上下文,我们将其命名为mainEC,它的结构如下

    mainEc = {
      VO: { //变量对象
        arguments: { // arguments对象,真实情况不一定是对象,在浏览器中是类数组
          a: undefined, // 形参a
          b: undefined, // 形参b
          length: 2 // 参数长度为2
        },
        sub: <sub reference>, // sub函数及其指针
        c: undefined // 变量c
      },
      scopeChain: [], // 作用域链
      this: window // this指向
    }
    

    在此阶段,函数还没有执行,但是已经做好函数执行的准备工作,所以变量对象是无法访问的,在进入执行阶段后,变量对象中的变量属性就会被赋值,变量对象就会变成活动对象(Active Object),这些变量就可以进行访问了。

    建立作用域链

    在建立好VO后,下一步开始创建作用域链,作用域链以当前执行上下文的VO或AO和上一层上下文的AO组成,一直到全局上下文的AO,这么说可能不好理解,我们通过下面一段代码来进行理解

    var num = 10
    function main() {
      var a = 1
      function sub() {
        var b = 2 
        return a + b
      }
      sub()
    }
    main()
    

    抽象出来sub函数的执行上下文就是

    subEC = {
      VO: { // 变量对象
        b: undefined
      },
      scopeChain: [VO(subEC), AO(main), AO(global)], // 作用域链
      this: window // this指向
    }
    

    sub函数的执行上下文中的作用域链中,我们可以看到有三个元素,当然在执行的时候,VO会变为AO,但是一些变量在VO(subEC)中并没有找到,这个就会去AO(main)和AO(global)中进行查找,找到就能够正常执行该函数,找不到系统就会抛引用错误。

    那么我们就以上面的代码为例,来理解一下 闭包(Closure) 这个概念,其实我们上面的代码就含有闭包

    callStack.png

    我们可以看到,chrome浏览给出闭包是main函数,通过这一现象,结合之前说明的知识点,我理解的闭包是:

    1. 函数内部定义新函数

    2. 内部函数访问外部函数的变量(或者活动对象)

    3. 内部函数执行,外层函数就是闭包

    this指向

    根据上面所讲的,我们可以知道this是在预编译的时候就指定了的,在不同的运行环境下,this的指向会不同,this的具体指向可参考MDN中对于this的描述 ,我个人的理解就是,this指向的是当前的执行环境。

  • 执行阶段

在执行栈中处理的代码都是同步的,异步的代码呢,比如事件系统、网络IO、定时器等功能是谁提供的呢,答案是环境,之前也有说到,JS运行时环境提供给了它不同的能力,浏览器环境提供了操作DOM能力,DOM事件系统等;Node环境提供了文件系统,IO系统等能力,这些都是由JS运行时环境所暴露得API来提供的能力。这些API对一些异步任务进行处理,处理完毕后,将结果(成功或者失败)返回给任务队列,等待Event Loop调用如下图所示:

JS运行机制 2.jpg

现在来详细解释一下上面这一张比较宏观的JS运行机制.

运行时环境

从上面的这张图我们能够比较宏观的大致了解JS的运行机制,在JS引擎将代码解释并执行之后,当这段代码要实现某个功能的时候,例如操作DOM、监听用户事件等行为的时候,就需要运行环境通过暴露一些API来执行对应的功能,现在JS常见的运行环境有两个,一个是浏览器环境,一个是Node环境,这两个环境分别提供给了JS实现不同功能的能力,像Node环境提供给了JS可以实现读写文件系统、获得操作系统信息等能力。简单来说,在不同环境下的JS可以实现不同的能力和功能。

Event Loop

之前我们也有说到过JS是单线程的语言,并且它是异步的,控制JS异步的实现就要依赖大名鼎鼎的Event Loop了,怎么理解Event Loop呢,因为JS是单线程的原因,每次JS只能够去处理一个任务,其他的任务只能去排队等待,如果是同步任务的话比较好理解,按照代码书写的先后顺序执行即可,但是当遇到了异步任务,真实的任务执行顺序就与书写顺序没有直接关系了,在之前的文章里面大致描述了一下JS异步任务执行机制,现在我们从Event Loop的角度来,不同的异步具体是怎么调度的。首先,从宏任务和微任务的角度,JS的任务队列被分为了:

  1. 微任务队列
  2. 宏任务队列

但是这种划分对于了解他的具体调度机制是不够的,我再对其进行划分

  • 微任务队列

    1. NextTickTask Queue用于存放process.nextTick()回调的队列
    2. MircoTask Queue用于存放promise.then/.catch/.finally回调的队列
  • 宏任务队列

    1. 全局JS代码

    2. Timer Queue用于存放setTimeoutsetInterval回调的队列 该阶段用于检查,这两个定时器是否到了定时器规定的时间,如果时间到了则将其送入调用栈中执行,时间未到则移动到下一个循环

    3. Pengding I/O callbaks Queue存放用于检查和执行pending状态下的I/O操作回调的队列

      这个队列是将在pending状态的I/O(例如网络I/O和文件I/O等)的回调放入队列中,当这个I/O完成的时候,将回调送往调用栈中,这个结果可能是成功也有可能是失败

    4. Idle/prepare Queuelibuv库内部机制,我们不用关心

    5. Poll Queue轮询I/O状态的队列

      这是Event Loop中最重要的阶段,这个阶段是用于轮询是否有新的I/O,轮询的对象也是I/O设备,如果有那么则在Pengding I/O callbaks Queue中添加新的任务

    6. Check Queue用于执行setImmediate回调的队列

      检查setImmediate是否到达触发时机,机制同Timer阶段

    7. Close Queue用于关闭网络连接的队列

      执行关闭事件的回调,比如socket的close的回到

以上是任务队列的具体的执行机制,在宏任务队列里面只要还有队列没有被清空Event Loop也就会一直循环,直到所有任务都被清空,然后在每一次循环完Event Loop之后,环境都回去检测微任务队列是否为空,如果为空则开始进行Event Loop,但是一旦不为空,系统会优先执行微任务队列的任务,我们在之前也抽象出两个微任务队列,这两个队列中NextTickTask Queue拥有更高的优先级,所以在异步任务里面,porcess.nextTick()的回调是最早执行的,可以把它理解为他会在同步任务执行完之后就执行,也可以理解为他是异步任务里面最早开始执行的。

以上就是JS的运行机制,可能有一部分地方理解不正确或者不够完善,之后我也会慢慢进行补充。