4

Javascript 运行机制

 2 years ago
source link: https://segmentfault.com/a/1190000041298004
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

1. 单线程的JavaScript

JavaScript是单线程的语言这,由它的用途决定的,作为浏览器的脚本语言,主要负责和用户交互,操作DOM。

假如JavaScript是多线程的,有两个线程同时操作一个DOM节点,一个负责删除DOM节点,一个在DOM节点上添加内容,浏览器该以哪个线程为标准呢?

所以,JavaScript的用途决定它只能是单线程的,过去是,将来也不会变。

HTML5的Web Worker允许JavaScript主线程创建多个子线程,但是这些子线程完全受主线程的控制,且不可操作DOM节点,所以JavaScript单线程的本质并没有发生改变。

2. 同步任务和异步任务

JavaScript是单线程语言,就意味着任务需要排队执行,只有前一个执行完成,后一个才可以执行。

如果前一个任务非常耗时呢?比如操作IO设备、网络请求等,后面的任务就会被阻塞,页面就会被卡住,甚至崩溃,用户体验非常差。

如果JavaScript的主线程在遇到这些耗时的任务时,将其挂起,先执行后面的任务,等挂起的任务有结果以后再回头执行,这样就可以解决耗时任务阻塞主线程的问题了。

于是,所有的任务就可以分为两种,同步任务和异步任务,同步任务放在主线程中执行,异步任务被挂起,不进入主线程执行(让主线程阻塞等待),当其有结果了,再放入主线程中执行。

3. 任务队列和Event Loop

3.1 任务队列

任务队列是一个事件队列,也可以理解成消息队列,当挂起的异步任务就绪以后就会在任务队列中放置相应的事件,表示该任务可以进入主线程中执行了。

任务队列中的事件,除了IO设备的事件,还有网络请求,鼠标点击、滚动等,只要为事件指定过回调函数,这些事件发生时就会进入任务队列,等待主线程来读取,然后执行相应的回调函数。

回调函数其实就是被挂起来的异步任务,比如:Ajax请求,请求成功或失败以后执行的回调函数就是异步任务。

任务队列是一个先进先出的数据结构,排在前面的事件,只要主线程一空,就会优先被读取。

3.2 Event Loop

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

4. 宏任务和微任务

异步任务可进一步划分为宏任务和微任务,相应的任务队列也有两种,分别为宏任务队列和微任务队列。

4.1 宏任务

setTimeout、setInterval、setImmediate会产生宏任务

4.2 微任务

requestAnimationFrame、IO、读取数据、交互事件、UI render、Promise.then、MutationObserve、process.nextTick会产生微任务

4.3 浏览器中的JavaScript脚本执行过程

4.3.1 过程描述

a. JavaScript脚本进入主线程, 开始执行

b. 执行过程中如果遇到宏任务和微任务,分别将其挂起,只有当任务就绪时将事件放入相应的任务队列

c. 脚本执行完成,执行栈清空

d. 去微任务队列依次读取事件,并将相应的回调函数放入执行栈运行,如果执行过程中遇到宏任务和微任务,处理方式同 b, 直到微任务队列为空

e. 浏览器执行渲染动作, GUI渲染线程接管,直到渲染结束

f. JS线程接管,去宏任务队列依次读取事件,并将相应的回调函数放入执行栈, 开始下一个宏任务的执行,过程为b -> c -> d -> e -> f, 如此循环

g. 直到执行栈、宏任务队列、微任务队列都为空,脚本执行结束

4.3.2 示例

4.3.2.1 示例一

// 脚本

console.log(1)

setTimeout(() => {
  console.log(2)
}, 0)

const p = new Promise((resolve) => {
  setTimeout(() => {
    console.log(3)
    resolve()
  }, 1000)
  console.log(4)
})

p.then(() => {
  console.log(5)
})

console.log(6)

执行过程

a. 脚本放入执行栈开始实行

b. 执行到console.log(1), 输入1

c. 执行到setTimeout,遇到宏任务,将其挂起,由于延时 0ms,将在 4ms后在宏任务队列产生一个定时事件, 我们叫定时A

d. 程序继续向下执行,执行new Promise(),并运行其参数,遇到第二个定时任务(宏任务),叫它定时B,并将其挂起,执行console.log(4), 输出4

e. 遇到微任务p.then(), 将其挂起

f. 向下执行遇到console.log(6), 输出6

g. 执行栈清空,读取微任务队列,发现为空,因为p.then()含没有就绪,它的就绪依赖与第一个定时任务(定时A)的执行

h. 执行栈为空,微任务队列为空,执行浏览器的渲染动作

i. 读取宏任务队列,读取第一个就绪的宏任务,为定时任务A,将其回调函数放入执行栈开始执行,执行console.log(2), 输入2

j. 执行栈清空,微任务队列为空,渲染

k. 开始执行下一个就绪的宏任务,定时任务B,并将其回调函数放入执行栈执行,执行console.log(3), 输出3,并执行resolve(), p.then()就绪,在微任务队列放入相应的事件

o. 执行栈清空,读取微任务队列,发现不为空,读取第一个就绪的事件,并将其对应的回调函数放入执行栈执行,执行console.log(5), 输出5

p. 执行栈清空,微任务队列为空,渲染,然后发现宏任务队列为空,本次脚本执行彻底结束

输出结果为: 1 4 6 2 3 5

4.3.2.2 示例二

async function async1 () {
  console.log('async1_1')
  await async2()
  console.log('async1_2')
}
async function async2 () {
  console.log('async2')
}
console.log('script start')
setTimeout(() => {
  console.log('setTimeout')
}, 0)
async1()
new Promise(resolve => {
  console.log('promise executor')
  resolve()
}).then(() => {
  console.log('promise then')
})
console.log('script end')

说明

函数前加async,实际上返回的是一个promise,比如这里的async2函数,返回的是一个立即resoved  promise

await会将后面的同步代码执行完成(async2),然后让出线程,将异步任务(Promise.then)挂起,这里的立即resolved promise,所以会在微任务队列添加一个事件,且排在下面的Promise.then之前

输出结果

如果上一个示例看懂了,再饥饿和该示例的说明信息,答案就呼之欲出了:

script start => async1_1 => async2 => promise executor => script end => async1_2 => promise then => setTimeout

4.3.3 外链

外链

4.3.4 总结

如果把JavaScript脚本也当作初始的宏任务,那么JavaScript在浏览器端的执行过程就是这样:

先执行一个宏任务, 然后执行所有的微任务

再执行一个宏任务,然后执行所有的微任务

...

如此反复,执行执行栈和任务队列为空

4.4 node.js中JavaScript脚本的执行过程

JavaScript脚本执行过程在node.js和浏览器中有些不同, 造成这些差异的原因在于,浏览器中只有一个宏任务队列,但是node.js中有好几个宏任务队列,而且这些宏任务队列还有执行的先后顺序,而微任务时穿插在这些宏任务之间执行的

4.4.1 执行顺序

  各个事件类型, 实行顺序自上而下
   ┌───────────────────────┐
┌─>│        timers         │<————— 执行 setTimeout()、setInterval() 的回调
│  └──────────┬────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
│  ┌──────────┴────────────┐
│  │     pending callbacks │<————— 执行由上一个 Tick 延迟下来的 I/O 回调
│  └──────────┬────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
│  ┌──────────┴────────────┐
│  │     idle, prepare     │<————— 内部调用(可忽略)
│  └──────────┬────────────┘     
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
|             |                   ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │ - (执行几乎所有的回调,除了 close callbacks 以
|  |                       |      |               |     及 timers 调度的回调和 setImmediate() 调度
|  |         poll          |<-----|   connections,|        的回调,在恰当的时机将会阻塞在此阶段)
│  │                       │      |               │ 
│  └──────────┬────────────┘      │   data, etc.  │ 
│             |                   |               | 
|             |                   └───────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
|  ┌──────────┴────────────┐      
│  │        check          │<————— setImmediate() 的回调将会在这个阶段执行
│  └──────────┬────────────┘
|             |<-- 先执行process.nextTick, 再执行MicroTask Queue 的回调
│  ┌──────────┴────────────┐
└──┤    close callbacks    │<————— socket.on('close', ...)
   └───────────────────────┘

4.4.2 示例

4.4.2.1 基本示例

console.log(1)

setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(() => {
    console.log('promise1')
  })
}, 0)

setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(() => {
    console.log('promise2')
  })
}, 0)

console.log(2)

这段代码在浏览器中的执行结果为:1 2 timer1 promise1 timer2 promise2

在node.js中的执行结果则为:1 2 timer1 timer2 promise1 promise2

4.4.2.2 setTimeout和setImmediate的顺序

它们两个顺序从上图看显而易见,timers队列在check队列执行运行,但是有个前提,事件已经就绪

setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

以上代码在node.js中的运行结果为:immediate timeout,原因如下:

在程序运行时timer事件未就绪,所以第一次去读timer队列时,队列为空,继续向下执行,在check队列读取到了就绪的事件,所以先执行immediate,再执行timeout,因为即使setTimeout的延时时间未 0,但是node.js一般会设置为 1ms, 所以,当node准备Event Loop的时间大于 1ms时,就会先输出timeout,后输出immediate,否则先输出immediate后输出timeout

const fs = require('fs')

// 读取文件
fs.readFile('xx.txt', () => {
  setTimeout(() => {
    console.log('timeout')
  })

  setImmediate(() => {
    console.log('immediate')
  })
})

以上代码的输出顺序一定为:immediate timeout, 原因如下:

setTimeout和setImmediate都写在I/O callback中,意味着处于poll阶段,然后是check阶段,所以,此时无论setTimeout就绪多快(1ms),都会优先执行setImmediate,本质上,从poll阶段开始执行,而不是一个Tick初始阶段。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK