彻底弄懂javascript执行机制

在当我们写完js代码执行的时候,其内部的执行机制非常复杂,但主要会经历三个过程:语法分析、预编译、真正执行。这其中,在真正执行这一过程中是最庞杂的。此篇幅就是为真正执行这一过程的分析而诞生。

我记得我当时研究js的执行机制,是因为一个问题:

js是一个单线程语言,是否代表着参与js执行过程的线程只有一个?

答案是否定的。在我回答为什么之前我们需要去了解一些知识点。这是我为了解决这个问题去百度的线程知识点。

进程与线程

  • 进程 进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。在早期面向进程设计的计算机结构中,进程是程序的基本执行实体;在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。
  • 线程 线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。在UnixSystemV及SunOS中也被称为轻量进程(lightweightprocesses),但轻量进程更多指内核线程(kernelthread),而把用户线程(userthread)称为线程。 线程是独立调度和分派的基本单位。线程可以为操作系统内核调度的内核线程,如Win32线程;由用户进程自行调度的用户线程,如Linux平台的POSIX Thread;或者由内核与用户进程,如Windows 7的线程,进行混合调度。 同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)。 一个进程可以有很多线程,每条线程并行执行不同的任务。

以上是摘自百度百科,因为我再总结也没有百度总结的好啊。但是上边的这些是给非人类看的。也就是看不懂的话——俗称“废话”。

对于我们来说,无需把所有都记住,因为知识是学不完的,要只学自己需要的。 需要的是要对进程和线程的有个基本认识:

  1. 进程和线程都是计算机系统中重要概念。
  2. 进程是操作系统结构的基础,是程序的实体,是系统资源分配的最小单位。
  3. 线程是cpu调度的最小单位,也是程序执行的最小单位。
  4. 进程和线程也有着不可分割的联系。进程是线程的容器。也就是说进程中可以包含若干线程。

那么回到一开始的问题上来,刚才说答案是否定的,原因是什么?

我们通常所说的js基本是依赖于浏览器的。浏览器也是我们前端打交道最多的。我们说浏览器是计算机系统上的一个软件。进程是程序的实体,是系统资源分配的最小单位。也就是说当你启动浏览器时,其实就是启动了一个浏览器进程。进程是线程的容器,一个进程下可以并发包含多个线程。浏览器可以做的事情远远不止一件事,除了可以解释执行js代码,还可以渲染页面、异步执行、计时器/定时器、事件监听等。所以这肯定不仅仅是一个线程就可以做到的。所以参与js执行过程的线程不止一个。

既然js执行过程参与的线程不止一个,那么参与的线程有哪些呢?

参与的有4个线程,其中JS引擎线程只负责执行js,其他三个线程进行配合。值得一提的是,GUI渲染线程负责渲染html元素,但是在js引擎线程执行脚本的时候,GUI渲染引擎是被挂起的。

  • JS引擎线程: 也称为JS内核,负责解析执行Javascript脚本程序的主线程(例如V8引擎)
  • 事件触发线程: 归属于浏览器内核进程,不受JS引擎线程控制。主要用于控制事件(例如鼠标,键盘等事件),当该事件被触发时候,事件触发线程就会把该事件的处理函数推进事件队列,等待JS引擎线程执行
  • 定时器触发线程:主要控制计时器setInterval和延时器setTimeout,用于定时器的计时,计时完毕,满足定时器的触发条件,则将定时器的处理函数推进事件队列中,等待JS引擎线程执行。 注:W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms。
  • HTTP异步请求线程:通过XMLHttpRequest连接后,通过浏览器新开的一个线程,监控readyState状态变更时,如果设置了该状态的回调函数,则将该状态的处理函数推进事件队列中,等待JS引擎线程执行。 注:浏览器对通一域名请求的并发连接数是有限制的,Chrome和Firefox限制数为6个,ie8则为10个。

总结一下,都是javascript线程在运行js脚本,其他三个线程都是进行在满足条件是将执行函数推入事件队列中,等待JS引擎主线程执行。

执行阶段

为了更好的理解执行过程,需要引用个例子(英文原版),这个例子非常经典,建议英文基础好的阅读,非常不错的文章。

console.log("script start");

setTimeout(function () {
    console.log("setTimeout");
}, 0)

Promise.reslove().then(function() {
    console.log("promise1");
}).then(function() {
    console.log("promise2");
})

console.log("script end");

这里直接按照执行过程划分代码,这里只简单描述一下过程。

  1. 宏任务(macro-task),宏任务又按执行顺序分为同步任务和异步任务

    • 同步任务

      console.log("script start"); console.log("script end");

    • 异步任务

      setTimeout(function () { console.log("setTimeout"); }, 0)

  2. 微任务(micro-task)

    Promise.resolve().then(function () { console.log("promise1"); }).then(function () { console.log("promise2"); })

在js引擎的执行过程中,进入执行阶段,代码的执行顺序如下:

宏任务(同步任务)---> 微任务 ---> 宏任务(异步任务)

输出结果为:

"script start"
"script end"
"promise1"
"promise2"
"setTimeout"

在ES6或Node的环境中,JS的任务分为两种,分别是宏任务(macro-task)微任务(micro-tsk),在最新的ECMAScript中,微任务称为jobs,宏任务称为task,他们的执行顺序如上。很多人对上面的执行顺序分析不是很理解,那么我们接下来继续对上面例子进行详细分析。

宏任务

宏任务(macro-task)可分为同步任务和异步任务。

  • 同步任务指的是在JS引擎上按顺序执行的任务,只有前一个任务执行完毕后,才能执行后一个任务,形成一个栈(stack,这个栈可以理解为函数调用栈) 。
  • 异步任务指的是不直接进入JS引擎主线程,而是满足条件时,相关的线程将异步任务推进任务队列(task queue),等待JS引擎主线程上的任务全部执行完毕,空闲时读取这个任务队列执行的任务,例如异步Ajax、Dom事件、setTimeout等。

理解宏任务中的同步任务和异步任务的执行顺序,那么就相当于理解了JS异步任务执行机制——事件轮询(Event Loop)

事件轮询

事件轮询可以理解为由三个部分组成,分别是:

  • 主线程执行栈
  • 异步任务等待触发
  • 任务队列

任务队列(task queue)就是以队列的数据结构对事件任务进行管控,特点是先进先出,后进后出。

这里直接引用一张著名的图片(参考自Philip Roberts的演讲《Help, I’m stuck in an event-loop》),帮助我们理解:

在JS引擎主线程执行过程中:

  • 首先执行宏任务的同步任务,在主线程上形成一个执行栈,可以理解为函数调用栈。
  • 当执行栈中的函数调用到一些异步的API(例如Ajax、Dom事件、setTimeout等),则会开启对应线程(Http异步请求线程,事件触发线程和定时器触发线程)进行监控和控制。
  • 当JS引擎主线程上的任务执行完毕,则会读取任务队列中的事件,将任务队列中的事件任务推进主线程中,按任务队列顺序执行。
  • 当JS引擎主线程上的任务执行完毕之后,则会再次读取任务队列中的事件任务,如此循环反复,这就是事件轮询(Event Loop)。

如果还是不理解,那么我们再拿上面的例子进行详细的分析,该例子中宏任务的代码部分是:

console.log("script start");

setTimeout(function () {
    console.log("setTimeout");
}, 0)

console.log("script end");

执行过程如下:

  1. JS引擎主线程按代码自上而下顺序依次解释执行,当执行console.log("script start");,JS引擎主线程任务该任务是同步任务,所以立刻执行输出script start,然后向下执行。
  2. JS引擎主线程执行到setTimeout(function () {console.log("setTimeout")}, 0),JS引擎主线程认为setTimeout是异步任务API,则向浏览器内核进程申请开启定时器线程进行计时和控制setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么计时器到4ms时,定时器线程就把该回调处理函数推进任务队列中等待主线程执行,然后JS引擎主线程继续向下解释执行。
  3. JS引擎主线程执行到console.log("script end");,JS引擎主线程认为该任务是同步任务,所以立即执行输出script end。
  4. JS引擎主线程的任务执行完毕(输出script start和script end)后,主线程变为空闲,则开始读取任务队列中的事件任务,将该任务队列里的事件任务推进主线程中,按照任务队列顺序执行,最终输出setTimeout,所以输出顺序为script start --> script end --> setTimeout。

以上就是JS引擎执行宏任务的整个过程。

理解了宏任务中的执行过程后,跟lotoze放飞一下,拓展性思考几个问题:

setTimeout和setInterval都是异步任务的定时器,需要添加到任务队列中等待JS引擎主线程空闲时间读取任务队列执行,那么使用setTimeout实现setInterval,会有区别吗?

答案是有区别的。不妨思考一下:

  • setTimeout实现setInterval只能通过递归来实现。
  • setTimeout是到了指定的时间就把事件推到任务队列中,只有当任务队列中的setTimeout事件任务被主线程执行后,才能继续再次到了指定时间的时候把事件推到任务队列中,依次反复,那么setTimeout的事件执行肯定比指定时间要久,具体相差多少跟代码执行事件有关。
  • setInterval则是每次都精确的间隔一段时间就向任务队列推入一个事件,无论上一个setInterval事件是否已经执行,所以有可能存在setInterval的事件任务累积,导致setInterval的代码重复连续执行好多次,影响页面性能。

lotoze我在不久以前对于seTimeout和setInterval的认知还停留在setTimeout只执行一次,setInterval可以按照时间间隔循环不停的执行。但是现在却有了很多新的认识。

  1. 使用setTimeout实现计时器功能比setInterval要好一些,但setInterval的事件任务累积也是有办法解决的,最有效的办法就是加锁。
  2. setTimeout模拟setInterval最本质的区别是setTimeout必须在任务队列中的一个setTimeout事件任务被JS引擎主线程执行完后下一个才能再推进任务队列中来,而setInterval无论你任务队列中有没有setInterval的事件任务或者有没有已经执行完毕了在指定时间到了都会再次推进任务队列中来,造成任务累积。
  3. 如果不考虑浏览器兼容性问题,使用requestAnimationFrame是更好的选择。

高频率触发事件(例如滚动监听、input输出)触发频率过高会影响页面性能,甚至造成页面卡顿,我们是否可以利用计时器的原理来进行优化呢?

是可以的。我们可以利用setTimeout实现计时器的原理,对高频率的事件监听进行优化,实现点在于多个事件合并成一个,这就是防抖节流。这里我会在后面的文章中去详细讲解一下实现方式,这里就不多说啦。

微任务

微任务是在ES6和Node环境中出现的一个任务类型,如果不考虑ES6和Node环境的话,我们只需要理解宏任务中事件轮询的执行过程就足够了,但是到了ES6和Node环境,就需要理解掺杂了微任务的执行顺序了。 微任务(micro-task0)的API主要有:Promise、process.nextTick。

这里直接引用一张流程图帮助我们理解一下:

在宏任务中执行的任务有两种,分别是同步任务异步任务,因为异步任务会在满足触发条件时才会推进任务队列(task queue),然后等待主线程上的任务执行完毕,再读取任务队列中的任务事件,最后推进主线程执行,所以这里将异步任务即任务队列看做是新的宏任务,执行过程如上图所示:

  1. 执行宏任务中的同步任务,执行结束。
  2. 检查是否存在可执行的微任务,有的话执行所有的微任务,然后读取任务队列的异步任务事件,推进主线程形成新的宏任务。没有的话则读取任务队列的异步任务事件,推进主线程形成新的宏任务。
  3. 执行新宏任务(任务队列中异步任务),再检查是否存在可执行的微任务,如此反复循环。

这就是加入微任务后的详细事件轮询,如果还没有理解,那么再次对一开始的例子做一个全面的分析以及补充:

console.log("script start");

setTimeout(function () {
    console.log("setTimeout");
}, 0)

Promise.reslove().then(function() {
    console.log("promise1");
}).then(function() {
    console.log("promise2");
})

console.log("script end");

执行过程:

  1. 代码块先通过语法分析,通读代码,看一看有没有低级的语法错误,如果有则抛出错误,如果没有则直接进入预编译阶段。需要注意的是JS代码是解释一行执行一行。
  2. 预编译过程是对代码真正执行的前一刻准备工作,在这一过程中会生成**GO(Global Object)以及一些AO(Active Object)**对象,形成彼此联系的作用域链。
  3. 程序进入执行阶段,当JS引擎主线程按代码自上而下顺序依次解释执行,当执行console.log("script start");,JS引擎主线程任务该任务是同步任务,所以立刻执行输出script start,然后向下执行。
  4. JS引擎主线程执行到setTimeout(function () { console.log("setTimeout") }, 0),JS引擎主线程认为setTimeout是异步任务API,则向浏览器内核进程申请开启定时器线程进行计时和控制setTimeout任务。由于W3C在HTML标准中规定setTimeout低于4ms的时间间隔算为4ms,那么计时器到4ms时,定时器线程就把该回调处理函数推进任务队列中等待主线程执行,然后JS引擎主线程继续向下解释执行。
  5. JS引擎主线程执行到Promise.resolve().then(function () { console.log("promise1") }).then(function () { console.log("promise2") });,JS引擎主线程认为Promise这是一个微任务(micro-task),就把该任务划分为微任务,等待执行。
  6. JS引擎主线程执行到console.log("script end");,JS引擎主线程认为该任务是同步任务,所以立即执行输出script end。
  7. 主线程上的宏任务执行完毕,则开始检查是否存在可执行的微任务, 检测到一个Promise微任务,那么立刻执行,输出promise1和promise2。
  8. 微任务执行完毕,主线程开始读取任务队列中的定时器任务setTimeout,推入主线程形成新宏任务,然后在主线程中执行,输出setTimeout。

最后的输出结果是:

"script start"
"script end"
"promise1"
"promise2"
"setTimeout"

怎么样?你是否懂了?

评论 抢沙发

表情
  1. #1

    来自北京朝阳的用户 8天前
    感谢分享,希望作者继续出文章,多交流分享。

  2. #2

    来自山西太原的用户 9天前
    比我还好 一年的数据库 一年的后端 一点的前端 现在搞得什么都没有学号学精

  3. #3

    来自北京朝阳的用户 9天前
    学习开始

  4. #4

    来自江苏玄武的用户 18天前
    优秀。转发

  5. #5

    来自河北石家庄的用户 21天前
    很棒,很棒

  6. #6

    来自山东济南的用户 21天前
    涨知识了

  7. #7

    来自福建福州的用户 23天前
    满满的干货,受益匪浅!如果排版再弄好看点,可能点赞数更高