JavaScript的运行机制

一、为什么JavaScript是单线程?   JavaScript的特点就是单线程,也就是说同一时间只能做一件事情,前面的任务没做完,后面的任务只能处于等待状态,(这就跟生活中的例子:排队买票一样,一...

一、为什么JavaScript是单线程?

  JavaScript的特点就是单线程,也就是说同一时间只能做一件事情,前面的任务没做完,后面的任务只能处于等待状态,(这就跟生活中的例子:排队买票一样,一个一个排队按顺序来)。这就产生了一个问题:为什么JavaScript不能是多线程的呢?多线程可以提高多核CPU的利用率,从而提高计算能力啊,这与浏览器的用途是息息相关的,也可以说是浏览器的用途直接决定了JavaScript只能是单线程。

  假如说JavaScript是多线程,我们可以试想一下,如果某一时刻一个线程给某个DOM节点添加内容,另一个线程在删除这个DOM节点,这个时候浏览器听那一个线程的?这样会让程序变得非常复杂,而且完全没有必要。同时这也说明JavaScript的单线程与它的用途有关,浏览器的主要用途是操作DOM、与用户完成互动、添加一些交互行为,如果是多线程将会产生非常复杂的问题。所以JS从一开始起为了避免复杂的问题产生,JavaScript就是单线程的,单线程已经成为JavaScript的核心,而且今后也不会改变。

  在多核CPU出现以来,这给单线程带来了非常的不便,不能够充分发挥CPU的作用。为了提高现代多核CPU的计算能力,HTML5提出了Web Worker标准,允许JavaScript脚本创建多个线程,但是,这个本质并没有改变JavaScript单线程的本质。

  什么是单线程与多线程,这个问题值得我们思考?

    单线程:一个进程中只有一个线程在执行,如果把进程比作一间工厂,线程比作一个工人,所谓单线程就是这个工厂中只有一个工人在工作

    多线程:一个进程中同时有多个线程在执行,好比这个工厂中有多个工人在一起协同工作

    进程:CPU资源分配的最小单位,一个程序要想执行就需要CPU给这个程序分配相应的资源出来,用完之后再收回去。例如:内存的占用,CPU给这个程序提供相应的计算能力。

    线程:CPU调度的最小单位,一个程序可以理解为有N个任务组成的集合,某一时刻执行那个任务就需要线程的调度

    图解:

    

    注意点:有图可知:工厂空间是共享的,说明一个进程中可以存在一个或多个线程,工厂资源是共享的,说明一个进程的内存空间可以被该进程中所有线程共享,多个进程之间相互独立,例如:听音乐的时候,不会影响到敲代码,歌词是不会出现在代码编辑器中的。

      多进程:在同一时间里,同一台计算机系统中允许两个或两个以上的进程处于运行状态,其实现在的计算机开机状态下就是多个进程在运行,打开任务管理器就可以看到那些进程在运行。多进程带来的好处是非常明显的,可以充分利用CPU的资源,而且电脑同时可以干很多件事还互相不影响。例如:在使用编辑器写代码的时候,还可以使用QQ音乐听歌。编辑器和QQ音乐之间完全不会影响。以Chrome为例:每打开一个tab就就相当于开启了一个进程,每个tab之间是完全不会影响。

  上面提到HTML5的Web Worker可以改善单线程的不便,了解Web Worker需要注意以下几点:

  • 同源限制

    分配给Worker子线程运行的脚本文件,必须与主线程的脚本文件同源。

  • DOM限制

    Worker线程与主线程不一样,无法读取主线程所在网页的DOM对象,无法使用 document 、 window 、 parent 等对象,但是可以使用 Navigator 对象、 location 对象

  • 全局对象限制

    Worker 的全局对象 WorkerGlobalScope ,不同于网页的全局对象 Window ,很多接口拿不到,理论上Worker不能使用 console.log 

  • 通信联系

    Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成。

  • 脚本限制

    Worker 线程不能执行alert()方法和confirm()方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求。

  • 文件限制

    Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本,必须来自网络。

二、浏览器的渲染流程

  在理解渲染原理之前,先了解浏览器内核构成是非常有必要的,具体内容看下面:

    浏览器工作方式:浏览器内核是通过取得页面内容、整理信息(应用CSS)、计算和组合最终输出可视化的图像结果。

    浏览器内核是多线程的,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:

    1. GUI渲染线程
    2. JavaScript引擎线程
    3. 定时触发器线程
    4. 事件触发线程
    5. 异步Http请求线程

    GUI渲染线程:

    • 主要负责页面的渲染,解析HTML、CSS,构建DOM树,布局和绘制等。
    • 当界面需要重绘或者由于某种操作引发回流时,将执行该线程。
    • 该线程与JS引擎线程互斥,当执行JS引擎线程时,GUI渲染会被挂起,当任务队列空闲时,主线程才会去执行GUI渲染,这也是为什么js解析脚本的时候会阻塞界面的渲染。

    JS引擎线程

    • 该线程当然是主要负责处理 JavaScript脚本,执行代码。
    • 也是主要负责执行准备好待执行的事件(异步事件),即定时器计数结束,或者异步请求成功并正确返回时,I/O读取文件等等将依次进入任务队列,等待 JS引擎线程的执行。
    • 该线程与 GUI渲染线程互斥,当 JS引擎线程执行 JavaScript脚本时间过长,将导致页面渲染的阻塞。

    定时触发器线程

    • 负责执行异步定时器一类函数的线程,如: setTimeout,setInterval。
    • 主线程依次执行代码时,遇到定时器,会将定时器交给该线程处理,当计数完毕后,事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待JS引擎线程执行。

    事件触发线程

    • 主要负责将准备好的事件交给 JS引擎线程执行。例如:setTimeout定时器计数结束, ajax等异步请求成功并触发回调函数,或者用户触发点击事件时,该线程会将相应的事件依次加入到任务队列的队尾,等待 JS引擎线程的执行。

    异步http请求线程

    • 负责执行异步请求一类函数的线程,如: Promise,axios,ajax等。
    • 主线程依次执行代码时,遇到异步请求,会将函数交给该线程处理,当监听到状态码变更,如果有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待JS引擎线程执行。

三、任务队列

  之前初步了解了浏览器的内核构成,提到了很多异步事件,那异步事件如何执行呢?这就跟任务队列有关了。现在来谈谈什么是任务队列,为什么需要任务队列?

  单线程也就意味着所有任务都需要排队,只有在前一个任务结束之后,才会执行后一个任务,否则后面的任务只能处于等待状态。如果某个任务执行需要很长的时间,后面的任务就都需要等待着,这会造成非常大的交互影响,给用户有一种页面加载卡顿的现象,影响用户体验!

  如果等待是因为CPU忙不过来也可以理解,大多数情况并不是CPU忙不过来的原因,而是文件I/O的读取,网络请求、鼠标点击事件等这些操作需要花费非常长的时间,只有等这些操作返回结果之后才能往下执行。

  为了解决这个问题,JavaScript的开发者也很快的意识到,脚本文件的执行,完全可以不用管那些非常耗时的I/O设备,异步请求,完全可以挂起等待中的任务,执行排在后面的位置,等I/O操作返回结果之后,再回过头执行挂起的任务。

  根据上面的理解可以将任务分为两种:同步任务(synchronize task)、异步任务(asynchronize task)。

    • 同步任务:在主线程(JS引擎线程)进行排队的任务,只有在前一个任务结束之后,才开始执行后一个任务。
    • 异步任务:不进入主线程,进入任务队列(task queue)的任务,只有在任务队列通知主线程任务队列中的某个任务可以进入主线程了,该任务才会进入主线程执行,也可以将异步任务理解为:有注册函数和回调两部分组成,注册函数负责发起异步过程,回调函数用来负责处理的结果。

  浏览器的运行机制

    • 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)
    • 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
    • 一旦"执行栈"中的所有同步任务执行完毕,系统就会去"任务队列"找找看,看看里面有哪些事件,那些对应的异步任务,已经结束等待状态,得到的相应的结果,得到结果的任务结束等待状态立即进入执行栈,开始执行。
    • 主线程不断重复上面的第三步的操作,也就形成了一个事件环(Event Loop)

四、事件和回调函数

  任务队列是一个事件队列(也可以理解为:消息队列),异步操作如:I/O设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面的事件。

  任务队列中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列,等待主线程读取。

  所谓"回调函数"(callback),就是那些会被主线程挂起来的实现该任务的代码,主线程干开始运行的时候是不会执行的。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数代码。

  任务队列是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器功能“”,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。

五、浏览器中的事件循环(Event Loop)

  主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)

  为了更好的理解事件循环,请看下面的图:

        

   执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。

   Micro Task 和  Macro Task: 

     浏览器端事件循环的异步队列有两种:macro(宏任务)队列、micro(微任务)队列,macro队列可以有多个,micro队列只能有一个。

     常见的Macro-Task: setTimeout、setInterval、script、I/O操作、UI渲染

     常见的Micro-Task: new Promise.then()  MutationObserve

   现在再拿之前的图来解释任务执行的流程,进一步加深理解:

    图解:

      

    执行流程:

    1. 一开始执行栈为空,执行栈可以理解为“先进后出”的栈结构,micro任务队列为空,macro任务队列中只有一个script 脚本,整体的js代码。
    2. 全局上下文(script整体代码)被推入执行栈,代码开始执行,判断是同步任务还是异步任务,如果是异步任务通过对一些接口的调用可以产生新的macro任务和micro任务,同步代码执行完毕,script脚本被移除macro任务队列,这个过程可以理解为macro-task的执行和出队。
    3. 上一步我们出队的是一个 macro-task,这一步我们处理的是 micro-task。 但需要注意的是:当 macro-task 出队时,任务是 一个一个执行的;而 micro-task 出队时,任务是 一队一队执行的。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。
    4. 执行渲染操作,更新界面。
    5. 检查是否存在 Web worker 任务,如果有,则对其进行处理。
    6. 上述过程循环往复,直到两个队列都清空。

   总结一下:当某个宏任务执行完后,会查看是否有微任务队列。如果有,先执行微任务队列中的所有任务,如果没有,会读取宏任务队列中排在最前的任务,执行宏任务的过程中,遇到微任务,依次加入微任务队列。栈空后,再次读取微任务队列里的任务,依次类推。

    代码演示:

 1 <script>
 2       Promise.resolve().then(() => { // Promise.then()是属于micro-task
 3         console.log('micro-task1');
 4         setTimeout(() => { // setTimeout是属于macro-task
 5           console.log('macro-task1');
 6         }, 0)
 7       })
 8       setTimeout(() => {
 9         console.log('macro-task2');
10         Promise.resolve().then(() => {
11           console.log('micro-task2');
12         })
13         console.log('macro-task3');
14       })
15       console.log('同步任务');
16 </script>

   运行结果为:同步任务--->micro-task1 --->macro-task2--->macro-task3--->micro-task2--->macro-task1

     1.代码开始执行,判断是同步任务还是异步任务,检测到有同步任务(属于宏任务)存在,先输出“同步任务”

     2. 同步任务执行完去查看是否有微任务队列存在,上面代码的微任务队列为:promise.resolve().then(),开始执行微任务,输出micro-task1

     3.执行微任务的过程中发现有宏任务setTimeout()存在,将其添加到宏任务队列,微任务执行完毕开始执行宏任务,由于macro-task2所在的宏任务早于macro-task1,因此先执行macro-task2所在的宏任务,输出macro-task2

     4.输出macro-task2之后发现存在微任务micro-task2,将其添加到微任务队列,接着输出macro-task3

     5.宏任务执行完毕,接着开始执行微任务输出:micro-task2

     6.微任务执行完毕,接着执行macro-task1所在的宏任务,输出:macro-task1

     7.执行完毕宏任务,此时的macro-task队列和micro-task队列已空,程序停止。

六、Node.js中的事件循环

  Node.js 不是一门语言也不是框架,它只是基于 Google V8 引擎的 JavaScript 运行时环境,同时结合 Libuv 扩展了 JavaScript 功能,使之支持 io、fs 等只有语言才有的特性,使得 JavaScript 能够同时具有 DOM 操作(浏览器)和 I/O、文件读写、操作数据库(服务器端)等能力,是目前最简单的全栈式语言。

  目前Node.js在大部分领域都占有一席之地,尤其是 I/O 密集型的,比如 Web 开发,微服务,前端构建等。不少大型网站都是使用 Node.js 作为后台开发语言的,用的最多的就是使用Node.js做前端渲染和架构优化,比如 淘宝 双十一、去哪儿网的 PC 端核心业务等。另外,有不少知名的前端库也是使用 Node.js 开发的,比如,Webpack 是一个强大的打包器,React/Vue 是成熟的前端组件化框架。

  Node.js通常被用来开发低延迟的网络应用,也就是那些需要在服务器端环境和前端实时收集和交换数据的应用(API、即时聊天、微服务)。阿里巴巴、腾讯、Qunar、百度、PayPal、道琼斯、沃尔玛和 LinkedIn 都采用了 Node.js 框架搭建应用。

  Node.js 编写的包管理器 npm 已成为开源包管理了领域最好的生态,直接到2017年10月份,有模块超过47万,每周下载量超过32亿次,每个月有超过700万开发者使用npm。

  当然了,Node.js 也有一些缺点。Node.js 经常被人们吐槽的一点就是:回调太多难于控制(俗称回调地狱)。但是,目前异步流程技术已经取得了非常不错的进步,从Callback、Promise 到 Async函数,可以轻松的满足所有开发需求。

  至于其他的特性这里附一篇很值得一看的文档:https://cnodejs.org/topic/5ab3166be7b166bb7b9eccf7

  Node中的事件循环机制完全和浏览器的是不同的,Node.js采用V8作为js的解析引擎,而I/O处理方面使用了自己设计的libuv,libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现。

  

  Node.js的运行机制如下:

    1.V8引擎解析JavaScript脚本

    2.解析后的代码,调用Node API

    3.libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎

    4.V8引擎再将结果返回给用户

  EventLoop的六个阶段

    libuv引擎中的事件循环分为 6 个阶段,它们会按照顺序反复运行。每当进入某一个阶段的时候,都会从对应的回调队列中取出函数去执行。当队列为空或者执行的回调函数数量到达系统设定的阈值,就会进入下一阶段。

    

    从上面的图片可以大致看出node的事件循环的顺序为:外部输入阶段--->轮询阶段(poll)--->检查阶段--->关闭事件回调阶段(close callback)--->定时器检查阶段(timer)--->I/O回调阶段(I/O callback)--->闲置阶段(idle,prepare)--->轮询阶段 ,按照上面的顺序循环反复执行。

    timer阶段:这个阶段执行setTimeout或setInterval回调,并且是有poll阶段控制的。同样在Node.js中定时器指定的时间也不是非常准确,只能是尽快执行。

    I/O callbacks阶段:处理一些上一轮循环中少数未执行的I/O回调

    idle,prepare阶段:进node内部使用

    poll阶段:获取新的I/O事件,适当的条件下node将阻塞在这里

    check阶段:setImmediate()回调函数的执行

    close callbacks阶段:执行socket的close事件回调。

    注意点:上面的6个阶段都是不包括process.nextTick(),在日常的开发中我们使用最多的就是:timer  poll  check这三个阶段,绝大多数的异步操作都是在这三个阶段 完成的。

  timer阶段:这个阶段执行setTimeout或setInterval回调,并且是有poll阶段控制的。同样在Node.js中定时器指定的时间也不是非常准确,只能是尽快执行。

  poll阶段:

    1.这个阶段是至关重要的阶段,系统会做两件事情。一是回到timer阶段执行回调,二是执行I/O回调

    2.如果在进入该阶段的时候没有设置timer,会发生两件事情:

      2.1.如果poll队列不为空,会遍历回调队列并同步执行,直到队列为空或达到系统限制

      2.2.如果poll阶段为空,会发生下面两件事:

        2.2.1:如果有setImmediate()回调需要执行,poll阶段会停止,进入到check阶段执行回调

        2.2.2:如果没有setImmediate()回调需要执行,会等到回调被加入队列中并立即执行回调,这里同样有个超时限制防止一致等待下去

    3.如果设置了timer且poll队列为空,则会判断是否有timer超时,如果有的话回到timer阶段执行回调。

  check阶段:setImmediate()的回调会被加入到check队列中,从event  loop的阶段图可以看出,check阶段的执行是在poll阶段之后的。

  microTask和macroTask:

   1.常见的micro-task: process.nextTick()  Promise.then()

   2.常见的macro-task: setTimeout、setIntevaral、setImmeidate、script、I/O操作

   3.先分析一段代码示例:

console.log('start');
setTimeout(() => {
    console.log('time1');
    Promise.resolve().then(() => {
        console.log('promise1');
    })
}, 0)
setTimeout(() => {
    console.log('time2');
    Promise.resolve().then(() => {
        console.log('promsie2');
    })
}, 0)
Promise.resolve().then(() => {
    console.log('promise3');
})
console.log('end');
// Node中的打印结果:start--->end--->promise3--->time1--->timer2--->promise1--->promise2
// 浏览器中的打印结果:start--->end--->promise3--->time1--->promise1--->time2--->promise2

    4.node打印结果分析:

      1.先执行同步任务(执行macro-task),打印输出:start  end

      2.执行micro-task任务:输出promise3,这一点跟浏览器的机制差不多

      3.进入timer阶段执行setTimeout(),打印输入time1,并将promise1放入micro-task队列中,执行timer2打印time2,并且将promise2放入到micro队列中,这一点跟浏览器的差别比较大,timer阶段有几个setTimeout/setIntever就执行几个,而不像浏览器一样执行完一个macro-task之后立即执行一个micro-task

   5.setTimeout()和setImmediate()非常相似,区别主要在调用的实际不同

      5.1:setTimeout()设置在poll阶段为空闲时且定时时间到后执行,但它们在timer阶段执行

      5.2:setImmediate()设置在poll阶段完成时执行,即check阶段执行

      5.3:实例分析:

1 setImmediate(() => {
2     console.log('setImmediate');
3 })
4 setTimeout(() => {
5     console.log('setTimeout');
6 }, 0)

      5.4:上面的代码执行,返回结果是不确定的,有可能先执行setTimeout() ,有可能先执行setImmediate(),首先 setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的 进入事件循环也是需要成本的,如果在准备时候花费了大于 1ms 的时间,那么在 timer 阶段就会直接执行 setTimeout 回调,如果准备时间花费小于 1ms,那么就是 setImmediate 回调先执行了,可以把setTimeout的第二个参数设置为:1 ,2,3,4....看看运行结果

      5.5:当二者写在I/O读取操作的回调中时,总是先执行setImmediate(),因为I/O回调是写在poll阶段,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了

 1 const fs = require('fs')
 2 fs.readFile(__filename, (err, data) => {
 3     setTimeout(() => {
 4         console.log('setTimeout');
 5     }, 0)
 6     setImmediate(() => {
 7         console.log('setImmediate');
 8     })
 9 })
10 console.log(__filename); // 打印当前文件所在的路径

   6.process.nextTick():这个函数其实是独立于 Event Loop 之外的,它有一个自己的队列,当每个阶段完成后,如果存在 nextTick 队列,就会清空队列中的所有回调函数,并且优先于其他 microtask 执行。也就是说它指定的任务队列,都是在所有异步任务之前发生

 1 console.log('start')
 2 setTimeout(() => {
 3     console.log('time1');
 4     Promise.resolve().then(() => {
 5         console.log('promise1');
 6     })
 7 })
 8 Promise.resolve().then(() => {
 9     console.log('promise2');
10 })
11 process.nextTick(() => {
12     console.log('nextTick1');
13     process.nextTick(() => {
14         console.log('nextTick2');
15         process.nextTick(() => {
16             console.log('nextTick3');
17         })
18     })
19 })
20 console.log('end');
21 // 运行结果:start --->end --->nextTick1 --->nextTick2 --->nextTick3 --->promise2 --->time1 --->promise1

七、浏览器中Event Loop和Node.js中的差别

  重点:浏览器环境下,microtask的任务队列是每个macrotask执行完之后执行。而在Node.js中,microtask会在事件循环的各个阶段之间执行,也就是一个阶段执行完毕,就会去执行microtask队列的任务。

  图解:

  

  代码演示:

console.log('start');
setTimeout(() => {
    console.log('timer1');
    Promise.resolve().then(() => {
        console.log('promise1');
    });
})
setTimeout(() => {
    console.log('timer2');
    Promise.resolve().then(() => {
        console.log('promise2');
    })
}, 0)
console.log('end');
// 浏览器模式下输出:start ---> end ---> timer1 ---> promise1 ---> timer2 ---> promise2
// node环境下面:start ---> end ---> timer1 ---> timer2 ---> promsie1 ---> promise2

 八、总结

 浏览器和node环境下、micro-task队列的执行时机不同:

  浏览器端,微任务在事件循环的各个阶段执行。

  Node端,微任务是在macro-task执行完毕执行。

  • 发表于 2019-07-11 12:20
  • 阅读 ( 152 )
  • 分类:网络文章

条评论

请先 登录 后评论
不写代码的码农
小编

篇文章

作家榜 »

  1. 小编 文章
返回顶部
部分文章转自于网络,若有侵权请联系我们删除