进程和线程
我们要去了解JS的运行机制,首先我们得现有一些前置知识,那就是进程和线程的概念。一下摘自百度百科
- 进程:进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
- 线程:是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
对于上面的概念,确实有点不好理解,通过网上查阅对于进程和线程一些比喻和描述,我觉得应该这样去理解它们。
- 从包含关系来看是进程包含线程。
- 我们把CPU看做是一个兵工厂,在这个兵工厂里面有许多个车间,每个车间的任务是不同的,有的造大炮,有的造坦克,还有的造飞机......那么我们可以把每个车间看作是一个进程,假设我们的电脑是单核CPU的,那么相对于我们的这个工厂电力有限,只能够使得一个车间进行运作,其他的车间只有等待当前车间任务完成,腾出电力才能运作。总结出来就是每一个CPU上同一时间只能运行一个进程。
- 对于我们正常运行的进程,也就是我们比喻中的一个车间,在这个车间里面,有许许多多的工人,每一个工人负责具体任务也不一样,有的工人负责炼钢,有的工人负责铸钢,有的工人负责配置弹药......车间中的工人就相当于进程中的线程,但是由于工人们都在一个车间里面,一起使用这个车间里面的工具进行生产,对于我们的线程来说,所有线程都共享同一个进程的资源,如内存等。
JS运行机制
那么从上面我们大致了解了一下什么是进程和线程,现在来讨论JS,首先我们应该都知道JS是一门单线程的解释性语言,单线程就是指,JS的任务始终运行在一个线程里面,虽然有woker一类实现了所谓的“多线程”但是在底层依旧是在用单线程进行实现的。至于什么是解释性语言,JS不像C++、JAVA一样是编译成计算机可执行语言之后再执行的,它是动态编译的,也就是边编译边执行的。那么编译这项工作是由谁来完成呢:浏览器内核或者V8引擎(chrome和NodeJS环境下)。在编程成计算机可执行的语句后,要实现的具体功能,例如操作DOM对象、获取计算机操作系统信息这些功能又是由谁来提供的呢,答案是环境。下面这张图就描述了JS到具体功能实现的大体过程。
通过这张图我们大致了解了JS的运行机制,现在我们来详细描述一下JS引擎和运行环境。
JS引擎
首先关于JS的引擎,大家应该都知道现在最著名的V8引擎了吧,(tips: 浏览器内核是由渲染引擎和JS引擎构成,想要了解细节的同学可以去查阅相关资料),V8引擎是现在chrome浏览器的JS引擎也是NodeJs的引擎。那么这个引擎究竟是何方神圣呢?其实就是一个堆和一个栈。说详细一点就是一个内存堆和一个调用栈,关于堆栈的一些描述大家可以先去看看我之前JavaScript异步机制 末尾对于堆栈的描述。那么下图就是JS引擎的一个模型图
在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()已经执行完毕,将其退栈
- 此时例子中的代码执行完毕。
运行过程如下图所示:
其实上面只是以一种比较简单的方式描述了JS函数在调用栈中的执行情况,下面我们将详细来讲解一下JS引擎具体的执行全过程过程。
JS引擎执行过程
JS引擎执行的三个阶段:
-
语法分析阶段
这个阶段是在JS代码加载完毕后最早开始执行,目的是为了检查JS代码的语法错误,如果检查到代码有语法错误,则抛出一个语法错误,若检查完毕且没有错误,则进入预编译阶段。如下面这段代码:
function a () { console.log(11 } a() //Uncaught SyntaxError: missing ) after argument list上面的代码中缺少右括号,在语法分析阶段被检查出来,抛出的一个语法错误。
-
预编译阶段 在语法分析阶段检查完成之后,现在进入预编译阶段,此阶段要为下一阶段函数执行做好准备,那么我们先了解一下JS的运行环境,运行环境分为一下几种:
- 全局环境(JS代码加载完毕,通过语法分析阶段后,进入全局环境)
- 函数环境(函数调用时,进入函数环境,每个函数都有对应的函数环境)
- eval(不建议使用,有安全问题,不作详细说明)
在上述的每一个环境中,每进入一个不同的环境,就会创建一个相应的执行上下文(Execution Context),在调用栈中的每一个个体可以理解为是一个执行上下文。在调用栈中,栈底永远是全局的执行上下文,栈顶是当前的执行上下文。所有我们可以把执行上下文和函数执行环境理解为等价的。创建执行上下文的时候,主要进行了以下三个事件:
-
创建变量对象VO(Variable Object)
-
建立作用域链(Scope Chain)
-
确定this指向
创建变量对象VO
创建变量对象要经过以下几步
- 创建arguments对象,检查当前上下文中的参数,并将其初始化,仅在函数环境中进行(非箭头函数),全局上下文没有此过程
- 检查当前上下文中的函数声明,按照代码顺序查找当前上下文中的函数声明,并且将其提前声明,在当前上下文的VO中,以函数名称建立一个属性,该属性的值为这个函数的堆内存指针。若有重复,则覆盖为最新的函数的指针。
- 检查当前上下文的变量声明,也是按照代码顺序进行查找,若找到,则在当前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) 这个概念,其实我们上面的代码就含有闭包
我们可以看到,chrome浏览给出闭包是
main函数,通过这一现象,结合之前说明的知识点,我理解的闭包是:-
函数内部定义新函数
-
内部函数访问外部函数的变量(或者活动对象)
-
内部函数执行,外层函数就是闭包
this指向
根据上面所讲的,我们可以知道this是在预编译的时候就指定了的,在不同的运行环境下,this的指向会不同,this的具体指向可参考MDN中对于this的描述 ,我个人的理解就是,this指向的是当前的执行环境。
-
执行阶段
在执行栈中处理的代码都是同步的,异步的代码呢,比如事件系统、网络IO、定时器等功能是谁提供的呢,答案是环境,之前也有说到,JS运行时环境提供给了它不同的能力,浏览器环境提供了操作DOM能力,DOM事件系统等;Node环境提供了文件系统,IO系统等能力,这些都是由JS运行时环境所暴露得API来提供的能力。这些API对一些异步任务进行处理,处理完毕后,将结果(成功或者失败)返回给任务队列,等待Event Loop调用如下图所示:
现在来详细解释一下上面这一张比较宏观的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的任务队列被分为了:
- 微任务队列
- 宏任务队列
但是这种划分对于了解他的具体调度机制是不够的,我再对其进行划分
-
微任务队列
NextTickTask Queue用于存放process.nextTick()回调的队列MircoTask Queue用于存放promise.then/.catch/.finally回调的队列
-
宏任务队列
-
全局JS代码
-
Timer Queue用于存放setTimeout、setInterval回调的队列 该阶段用于检查,这两个定时器是否到了定时器规定的时间,如果时间到了则将其送入调用栈中执行,时间未到则移动到下一个循环 -
Pengding I/O callbaks Queue存放用于检查和执行pending状态下的I/O操作回调的队列这个队列是将在pending状态的I/O(例如网络I/O和文件I/O等)的回调放入队列中,当这个I/O完成的时候,将回调送往调用栈中,这个结果可能是成功也有可能是失败
-
Idle/prepare Queuelibuv库内部机制,我们不用关心 -
Poll Queue轮询I/O状态的队列这是Event Loop中最重要的阶段,这个阶段是用于轮询是否有新的I/O,轮询的对象也是I/O设备,如果有那么则在
Pengding I/O callbaks Queue中添加新的任务 -
Check Queue用于执行setImmediate回调的队列检查
setImmediate是否到达触发时机,机制同Timer阶段 -
Close Queue用于关闭网络连接的队列执行关闭事件的回调,比如socket的
close的回到
-
以上是任务队列的具体的执行机制,在宏任务队列里面只要还有队列没有被清空Event Loop也就会一直循环,直到所有任务都被清空,然后在每一次循环完Event Loop之后,环境都回去检测微任务队列是否为空,如果为空则开始进行Event Loop,但是一旦不为空,系统会优先执行微任务队列的任务,我们在之前也抽象出两个微任务队列,这两个队列中NextTickTask Queue拥有更高的优先级,所以在异步任务里面,porcess.nextTick()的回调是最早执行的,可以把它理解为他会在同步任务执行完之后就执行,也可以理解为他是异步任务里面最早开始执行的。
以上就是JS的运行机制,可能有一部分地方理解不正确或者不够完善,之后我也会慢慢进行补充。