Javascript|Javascript 运行机制

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)

【Javascript|Javascript 运行机制】执行过程
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函数,返回的是一个立即resovedpromise
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初始阶段。

    推荐阅读