JS运行机制
引题
- JS是单线程语言
- Event Loop是JS的执行机制。深入了解JS的执行,就等于深入了解JS里的event loop(JS执行机制是一样的,但运行机制可能不一样 nodejs中)
JS单线程的原因
- JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
- 所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
- 为了利用多核CPU的计算能力,HTML5提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM。所以,这个新标准并没有改变JavaScript单线程的本质。实则为“多 子线程”
使用异步的原因
- 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
- 如果JS中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。对于用户而言,阻塞就意味着"卡死",这样就导致了很差的用户体验
浏览器线程
JS的运行环境主要是浏览器,以大家都很熟悉的Chrome的内核为例,他不仅是多线程,而是多进程的:
上图只是一个概括分类,意思是Chrome有这几类的进程和线程,并不是每种只有一个,比如渲染进程就有多个,每个选项卡都有自己的渲染进程。有时候我们使用Chrome会遇到某个选项卡崩溃或者没有响应的情况,这个选项卡对应的渲染进程可能就崩溃了,但是其他选项卡并没有用这个渲染进程,他们有自己的渲染进程,所以其他选项卡并不会受影响。这也是Chrome单个页面崩溃并不会导致浏览器崩溃的原因,而不是像老IE那样,一个页面卡了导致整个浏览器都卡。
对于前端工程师来说,主要关心的还是渲染进程,下面来分别看下里面每个线程是做什么的。
GUI线程
GUI线程就是渲染页面的,他解析HTML和CSS,然后将他们构建成DOM树和渲染树就是这个线程负责的。
JS引擎线程
这个线程就是负责执行JS的主线程,前面说的"JS是单线程的"就是指的这个线程。大名鼎鼎的Chrome V8引擎就是在这个线程运行的。需要注意的是,这个线程跟GUI线程是互斥的(这也就解释了js执行时会阻塞页面的渲染)。互斥的原因是JS也可以操作DOM,如果JS线程和GUI线程同时操作DOM,结果就混乱了,不知道到底渲染哪个结果。这带来的后果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。
定时器线程
setTimeout和setInterval运行在这里,只是一个计时的作用,跟JS主线程根本不在同一个地方。并不会真正执行时间到了的回调,当时间到了定时器线程会将这个回调事件给到事件触发线程。
事件触发线程
事件触发线程将已经满足了触发条件的事件加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行。
异步HTTP请求线程
这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行。
总结
JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程执行完主线任务后从任务队列取出任务事件继续执行。
任务队列
同步任务和异步任务
- 同步任务:在主线程排队支持的任务,前一个任务执行完毕后,执行后一个任务,形成一个执行栈,线程执行时在内存形成的空间为栈,进程形成堆结构,这是内存的结构。执行栈可以实现函数的层层调用。注意不要理解成同步代码进入栈中,按栈的出栈顺序来执行。
- 异步任务:其回调函数被线程挂起,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
事件和回调函数
- 事件:"任务队列"是一个事件的队列(也可以理解成消息的队列),异步任务条件被满足,就在"任务队列"中添加一个事件,表示相关的异步任务可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。
只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。主要事件:异步任务事件、定时器
- "回调函数"(callback),就是那些会被主线程挂起来的代码。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。但是,由于存在后文提到的"定时器"功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程。
微任务和宏任务
其实事件队列里面的事件还可以分两类:宏任务和微任务。微任务拥有更高的优先级,当事件循环遍历队列时,先检查微任务队列,如果里面有任务,就全部拿来执行,执行完之后再执行一个宏任务。执行每个宏任务之前都要检查下微任务队列是否有任务,如果有,优先执行微任务队列。
常见宏任务:
script (可以理解为外层同步代码)、setTimeout/setInterval、
setImmediate(Node.js)、I/O、UI事件、postMessage
常见微任务:
Promise、Async、process.nextTick(Node.js)、
Object.observe、MutaionObserver
Event Loop
总体机制
上图中,主线程运行的时候,产生堆(heap)和执行栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
异步执行机制
- 所有同步任务都在主线程上执行,形成一个执行栈。
- 主线程之外,还有一个“任务队列”,只要异步任务有了运行结果,就在“任务队列”之中放置一个事件。
- 一旦“执行栈”中的所有同步任务执行完毕了,系统就会读取“任务队列”,看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的三步。(事件循环)
Event Loop核心执行机制
- 整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为两部分:“同步任务”、“异步任务”;
- 同步任务会直接进入主线程依次执行;
- 异步任务会再分为宏任务和微任务;
- 宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
- 微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
- 当主线程内的任务执行完毕,主线程为空时,会先检查微任务的Event Queue,如果有任务,就全部执行。如果没有微任务才执行下一个宏任务;
- 上述过程会不断重复,这就是Event Loop事件循环;
注意:
- 每一个EventLoop执行一次宏任务,微任务出现则当前EventLoop内执行完,出现宏任务则加入任务队列下一EventLoop内执行
- 微任务队列全部执行完会重新渲染一次
- 每个宏任务执行完都会重新渲染一次
- requestAnimationFrame处于渲染阶段,不在微任务队列,也不在宏任务队列
定时器
定时器功能主要由setTimeout()和setInterval()这两个函数来完成,它们的内部运行机制完全一样,区别在于前者指定的代码是一次性执行,后者则为反复执行。以下主要讨论setTimeout()。
定时器执行机制
满足定时的时间----》任务队列注册事件----》
主线程执行栈清空+其任务队列之前的任务已完成----》执行回调
setTimeout(fn,0)的含义是,指定某个任务在主线程最早可得的空闲时间执行,也就是说,尽可能早得执行。它在"任务队列"的尾部添加一个事件,因此要等到同步任务和"任务队列"现有的事件都处理完,才会得到执行。
node.js里面setTimeout(fn, 0)会被强制改为setTimeout(fn, 1)
HTML 5里面setTimeout最小的时间限制是4ms
setInterval(fn,ms)来说,不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。一旦setInterval的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。
NodeJS的EventLoop
Node.js是运行在服务端的js,虽然他也用到了V8引擎,但是他的服务目的和环境不同,导致了他API与原生JS有些区别,他的Event Loop还要处理一些I/O,比如新的网络连接等,所以与浏览器Event Loop也是不一样的。Node的Event Loop是分阶段的,如下图所示:
- timers: 执行setTimeout和setInterval的回调
- pending callbacks: 执行延迟到下一个循环迭代的 I/O 回调(异步任务事件)
- idle, prepare: 仅系统内部使用
- poll: 检索新的 I/O 事件(异步任务事件);执行与 I/O (异步任务事件)相关的回调。事实上除了其他几个阶段处理的事情,其他几乎所有的异步都在这个阶段处理。
- check: setImmediate在这里执行
- close callbacks: 一些关闭的回调函数,如:socket.on('close', ...)
注意:每个阶段都有一个自己的先进先出的队列,只有当这个队列的事件执行完或者达到该阶段的上限时,才会进入下一个阶段。在每次事件循环之间,Node.js都会检查它是否在等待任何一个I/O(异步任务事件)或者定时器,如果没有的话,程序就关闭退出了。
Poll阶段
poll阶段的后面并不一定每次都是check阶段,poll队列执行完后,如果没有setImmediate但是有定时器到期,他会绕回去执行定时器阶段:
setImmediate和setTimeout
异步流程中的setImmediate和setTimeout
console.log('outer');
setTimeout(() => {
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
}, 0);
结果:
- 流程分析:
- 外层是一个setTimeout,所以执行他的回调的时候已经在timers阶段了
- 处理里面的setTimeout,因为本次循环的timers正在执行,所以他的回调其实加到了下个timers阶段
- 处理里面的setImmediate,将它的回调加入check阶段的队列
- 外层timers阶段执行完,进入pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
- 到了check阶段,发现了setImmediate的回调,拿出来执行
- 然后是close callbacks,队列时空的,跳过
- 又是timers阶段,执行我们的console
同步任务中的setImmediate和setTimeout
console.log('outer'); setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); });
结果:
- 外层同步代码一次性全部执行完,遇到异步API就塞到对应的阶段
- 遇到setTimeout,虽然设置的是0毫秒触发,但是被node.js强制改为1毫秒,塞入times阶段
- 遇到setImmediate塞入check阶段
- 同步代码执行完毕,进入Event Loop
- 先进入times阶段,检查当前时间过去了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
- 跳过空的阶段,进入check阶段,执行setImmediate回调
关键就在这个1毫秒,如果同步代码执行时间较长,进入Event Loop的时候1毫秒已经过了,setTimeout执行,如果1毫秒还没到,就先执行了setImmediate。每次我们运行脚本时,机器状态可能不一样,导致运行时有1毫秒的差距,一会儿setTimeout先执行,一会儿setImmediate先执行。但是这种情况只会发生在还没进入timers阶段的时候。
其他例子
例一:在readFile的回调
var fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); }); });
这里setTimeout和setImmediate在readFile的回调里面,由于readFile回调是I/O操作,他本身就在poll阶段,所以他里面的定时器只能进入下个timers阶段,但是setImmediate却可以在接下来的check阶段运行,所以setImmediate肯定先运行,他运行完后,去检查timers,才会运行setTimeout。
例二:在setImmediate的回调里面
console.log('outer'); setImmediate(() => { setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); }); });
原因跟写在最外层差不多,因为setImmediate已经在check阶段了,里面的循环会从timers阶段开始,会先看setTimeout的回调,如果这时候已经过了1毫秒,就执行他,如果没过就执行setImmediate。
async promise nextTick顺序
promise和nextTick
同时有nextTick和Promise的时候,nextTick先执行
原因:nextTick的队列比Promise队列优先级更高。
new Promise(function(resolve){
console.log("promise before");
resolve();
}).then(function(){
console.log("promise operating")
});
process.nextTick(()=>{
console.log('nextTick')
})
代码运行结果如下:
promise和async
同时有promise和async的时候,promise先执行
原因:
async 函数中可能会有 await 表达式,这会使 async 函数暂停执行,等待(await)表达式中的 Promise 解析完成后继续执行 async 函数并返回解决结果。
async function async1() {
console.log('async1 start');
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2')
}
async1();
new Promise(function(resolve){
console.log("promise before");
resolve();
}).then(function(){
console.log("promise operating")
});
代码运行结果如下:
代码可以解释为:
在函数async1中,执行await(由于async2是async标记的函数,所以默认返回promise对象),发现resolve(),然后放入队列。
接着执行下方的new Promise中的resolve(),输出promise operating,再回来执行了输出async1 end。
async function async1(){
console.log('async1 start')
async2(resolve => {
console.log('async2');
resolve();
}).then( _ => {
console.log( 'async1 end ')
})
}
流程:
- 执行async1函数,输出async1 start。
- 执行await async2函数,async2函数输出async2,返回promise对象。
promise对象放入到回调队列,await让出线程(此promise没收到resolve之前,async1无法向下继续执行)。
- 执行new Promise,由于promise是立即执行函数,输出promise before。
其中的resolve()【promise resolve】被放在回调队列。
- 执行回调队列中,执行async2返回的promise对象,又产生resolve()。
resolve()【async2 resolve】被放入回调队列。这里不输出任何值。
- 执行回调队列中,resolve()【promise resolve】回调,输出promise operating。
- 执行回调队列中,resolve()【async2 resolve】回调,执行
- async1继续执行,输出async1 end。
(promise收到resolve()【async2 resolve】,async1执行,对应第二步)。
setTimeout和setImmediate到底谁先执行,本文让你彻底理解Event Loop
JavaScript 运行机制详解:再谈Event Loop