基本概念
堆,栈、队列
-
堆(Heap)堆是一种数据结构,是利用完全二叉树维护的一组数据,堆分为两种:- 一种为
最大堆,根节点最大的堆叫做最大堆或大根堆 - 一种为
最小堆,根节点最小的堆叫做最小堆或小根堆
堆是线性数据结构,相当于一维数组,有唯一后继 - 一种为
-
栈(Stack)栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据时从栈顶开始弹出栈在计算机科学中是限定仅在表尾进行插入或删除操作的线性表 -
队列(Queue)队列的特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,和栈一样队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头队列中没有元素时称为空队列,队列的数据元素又称为队列元素在
队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO—first in first out)
进程与线程
本质上来说两个名词都是 CPU 工作时间片的一个描述,官方的说法是:
进程是CPU资源分配的最小单位线程是CPU调度的最小单位
进程描述了 CPU 在运行指令及加载和保存上下文所需的时间,放在应用上来说就代表了一个程序;线程是进程中的更小单位,描述了执行一段指令所需的时间
一个
进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线,一个进程的内存空间是共享的,每个线程都可用这些共享内存
把这些概念拿到浏览器中来说,当打开一个 Tab 页时其实就是创建了一个进程,一个进程中可以有多个线程,比如渲染线程、JS 引擎线程、HTTP 请求线程等,当发起一个请求时其实就是创建了一个线程,当请求结束后该线程可能就会被销毁
多进程与多线程:
-
多进程:在同个时间里同个计算机系统中允许两个或两个以上的进程处于运行状态。多进程带来的好处是明显的,如可以听歌的同时打开编辑器敲代码,编辑器和听歌软件的进程之间丝毫不会相互干扰 -
多线程:程序中包含多个执行流,即在一个程序中可以同时运行多个不同的线程来执行不同的任务,即允许单个程序创建多个并行执行的线程来完成各自的任务
浏览器内核是多线程的,在内核控制下各线程相互配合以保持同步,一个浏览器通常由以下常驻线程组成:
-
GUI 渲染线程:主要负责页面的渲染,解析 HTML、CSS,构建 DOM 树、布局和绘制等,当界面需要重绘或由于某种操作引发回流时将执行该线程。该线程与 JS 引擎线程互斥,当执行 JS 引擎线程时 GUI 渲染被挂起,当任务队列空闲时 JS 引擎才会去执行 GUI 渲染 -
JS 引擎线程:该线程主要负责处理 JS 脚本、执行代码,也主要负责执行准备好待执行的事件,即如定时器计数结束或异步请求成功并正确返回时,将依次进入任务队列,等待 JS 引擎线程的执行;当然该线程与 GUI 渲染线程互斥,当 JS 引擎线程执行 JS 脚本时间过长,将导致页面渲染的阻塞 -
定时器触发线程:负责执行异步定时器一类函数的线程,如:setTimeout、setInterval,主线程依次执行代码时,遇到定时器会将定时器交给该线程处理,当计数完毕后事件触发线程会将计数完毕后的事件加入到任务队列的尾部,等待 JS 引擎线程执行 -
事件触发线程:主要负责将准备好的事件交给 JS 引擎线程执行,如setTimeout定时器计数结束、ajax等异步请求成功并触发回调函数或用户触发点击事件时,该线程会将整装待发的事件依次加入到任务队列的队尾,等待 JS 引擎线程的执行 -
异步 http 请求线程:负责执行异步请求一类函数的线程,如:Promise、axios、ajax等,主线程依次执行代码时,遇到异步请求会将函数交给该线程处理,当监听到状态码变更,若有回调函数,事件触发线程会将回调函数加入到任务队列的尾部,等待 JS 引擎线程执行
执行栈
为了让 JS 运行起来,要完成两部分工作(当然实际比这复杂的多)
JavaScript Engine(执行引擎): 编译并执行 JS 代码,完成内存分配、垃圾回收等JavaScript Runtime(执行环境):为 JS 提供一些对象或机制,使它能够与外界交互
JS 是单线程即只有一个主线程(JS 中其实是没有线程概念的,所谓的单线程也只是相对于多线程而言)。主线程有一个栈也称为调用栈(执行栈),所有的任务都会放到调用栈中等待主线程来执行
可以把执行栈认为是一个存储函数调用的栈结构,遵循
先进后出的原则
当开始执行 JS 代码时,首先会执行一个 main 函数然后执行代码,根据先进后出的原则,后执行的函数会先弹出栈;当使用递归时因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现爆栈的问题
任务队列
JS 是单线程,单线程就意味着所有任务需要排队,前一个任务结束才会执行后一个任务,若前一个任务耗时很长则后一个任务就不得不一直等着
若排队是因为计算量大使得 CPU 忙不过来倒也算了,但很多时候 CPU 是闲着的,因为 I/O 设备很慢(如 Ajax 操作)不得不等着结果出来后再往下执行。JS 语言的设计者意识到这时主线程完全可以不管 I/O 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 I/O 设备返回了结果再回过头把挂起的任务继续执行下去
所有任务可以分成两种:
同步任务(synchronous):指的是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务异步任务(asynchronous):指的是不进入主线程、而进入任务队列的任务,只有任务队列通知主线程某个异步任务可以执行了,该任务才会被调入主线程执行
具体来说,异步执行的运行机制如下(同步执行也是如此,因为它可以被视为没有异步任务的异步执行):
- 所有
同步任务都在主线程上执行,形成一个执行栈(execution context stack) 主线程之外,还存在一个任务队列,只要异步任务有了运行结果,就在任务队列之中放置一个事件- 一旦
执行栈中的所有同步任务执行完毕,系统就会读取任务队列,看看里面有哪些事件,那些对应的异步任务结束等待状态,进入执行栈,开始执行 主线程不断重复上面的第三步
只要主线程空了,则就会去读取
任务队列,这就是 JS 的运行机制,这个过程会不断重复
任务队列是一个先进先出的数据结构,排在前面的事件优会先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,任务队列上第一位的事件就自动进入主线程。但是由于存在后文提到的定时器功能,主线程首先要检查一下执行时间,某些事件只有到了规定的时间,才能返回主线程
任务队列是一个事件的队列(也可理解成消息的队列),I/O 设备完成一项任务,就在任务队列中添加一个事件,表示相关的异步任务可以进入执行栈了,主线程读取任务队列,就是读取里面有哪些事件
任务队列中的事件,除了 I/O 设备的事件以外,还包括一些用户产生的事件(如鼠标点击、页面滚动等),只要指定过回调函数,这些事件发生时就会进入任务队列,等待主线程读取
所谓
回调函数(callback)就是指那些会被主线程挂起来的代码,异步任务必须指定回调函数,当主线程开始执行异步任务就是执行对应的回调函数
浏览器中的 Event Loop
Event Loop 的基本概念
Event Loop 即事件循环,是指浏览器或 Node 的一种解决 JS 单线程运行时不会阻塞的一种机制,即经常使用异步的原理
这里的异步准确的说应该叫浏览器的 Event Loop 或说是 JS 运行环境的 Event Loop,因为 ECMAScript 中没有 Event Loop,Event Loop 是在 HTML Standard 定义的,在 JS 引擎中(以 V8 为例)只是实现了 ECMAScript 标准而并不关心什么 Event Loop,即 Event Loop 是属于 JavaScript Runtime 的,是由宿主环境提供的(如浏览器)
Event Loop 也是 JS 并发模型(Concurrency Model)的基础,是用来协调各种事件、用户交互、脚本执行、UI 渲染、网络请求等的一种机制,即 Event Loop 只不过是实现异步的一种机制而已
在执行和协调各种任务时,Event Loop 会维护自己的任务队列,任务队列又分为:
-
宏任务(macrotask)队列(在ES6中macrotask称为task)一个
Event Loop有一个或多个宏任务队列,这是一个先进先出(FIFO)的有序列表,存放着来自不同任务源的任务,有如下规定:- 来自相同任务源的任务,必须放在同一个任务队列中
- 来自不同任务源的任务,可放在不同的任务队列中
- 同个任务队列内的任务是按顺序执行的
- 对于不同的任务队列,浏览器会进行调度,允许优先执行来自特定任务源的任务,如:鼠标、键盘事件、网络请求都有各自的任务队列,当两者同时存在时浏览器可优先从用户交互相关的任务队列中挑选任务并执行,如这里的鼠标、键盘事件,从而保证流畅的用户体验
宏任务的任务源非常宽泛,如ajax的onload、click事件,基本上我们经常绑定的各种事件都是宏任务任务源,还有数据库操作(IndexedDB),需注意的是:setTimeout、setInterval、setImmediate也是宏任务任务源。总结来说常见的宏任务有:- setTimeout、setInterval、setImmediate
- DOM manipulation(DOM 操作)
- User interaction(用户交互)
- Networking(网络请求)
- History traversal(History API 操作)
- script(整体脚本代码)
- I/O 操作、UI 渲染等等...
-
微任务(microtask)队列(在ES6中microtask称为jobs)微任务队列与宏任务类似,也是一个有序列表,不同之处在于一个Event Loop只有一个微任务队列在
HTML标准中并没有明确规定微任务任务源,通常认为有以下几种:- process.nextTick(Node 独有的一种机制)
- new Promise().then(回调)
- Object.observe(废弃)
- MutationObserver(html5 新特性)
在 Promises/A+ Note 3.1 中提到了
then、onFulfilled、onRejected的实现方法,但Promise本身属于平台代码,由具体实现来决定是否使用Microtask,因此在不同浏览器上可能会出现执行顺序不一致的问题。不过目前的共识是用Microtask来实现事件队列
Event Loop 分为两种:
-
一种存在于 Browsing Context(浏览器上下文) 中
Browsing Context是指一种用来将 Document 展现给用户的环境。如浏览器中的tab、window或iframe等,通常都包含Browsing Context-
每个用户代理必须至少有一个浏览器上下文
Event Loop,但每个单元的相似源浏览器上下文至多有一个Event Loop -
Event Loop总是具有至少一个浏览器上下文,当一个Event Loop的浏览器上下文全都销毁时Event Loop也会销毁,一个浏览器上下文总有一个Event Loop去协调它的活动
-
-
还有一种在 Worker 中
Worker是指一种独立于UI脚本、可在后台执行脚本的API,常用来在后台处理一些计算密集型的任务,Worker的Event Loop相对简单一些(一个worker对应一个Event Loop,worker 进程模型管理Event Loop的生命周期)
每个线程都有自己的 Event Loop,浏览器可有多个 Event Loop,Browsing Context 和 web workers 是相互独立的;所有同源的 Browsing Context 可以共用 Event Loop,这样就可以相互通信
Event Loop 过程解析
在事件循环中,每进行一次循环操作称为一个 tick,一个完整的 Event Loop 过程,可以概括为以下阶段
-
一开始执行栈为空,
microtask 队列为空,macrotask 队列里有且只有一个 script 脚本(整体代码,宏任务) -
script 脚本被推入执行栈,对同步任务创建执行上下文(Execution Context),按顺序进入执行栈并执行(参考 Calling scripts) -
对于
异步任务,与上面步骤相同,同步执行这段代码,异步任务则进入到Event Table并注册相对应的回调函数,由其他线程相应API来执行具体的异步操作,完成后会将相应的回调函数分发到相应的任务队列中其他线程是指:尽管 JS 是单线程的,但浏览器内核是多线程的,它会将 GUI 渲染、定时器触发、HTTP 请求等工作交给专门的线程来处理。另外在 Nodejs 中,异步操作会优先由 OS 或第三方系统提供的异步接口来执行,然后才由线程池处理
-
主线程内的任务执行完毕为空,会去任务队列读取对应的回调函数,进入主线程执行怎么知道
主线程执行栈为空?JS 引擎存在
monitoring process进程,会持续不断检查主线程执行栈是否为空,一旦为空就会去任务队列那里检查否有等待被调用的函数 -
重复以上步骤
Event Loop 的进程模型
-
选择当前要执行的
任务队列,选择任务队列中最先进入的任务,若任务队列为空即null,则执行跳转到微任务(MicroTask)的执行步骤 -
将事件循环中的任务设置为已选择任务
-
执行任务
-
将事件循环中当前运行任务设置为
null -
将已经运行完成的任务从任务队列中删除
-
microtasks步骤:进入microtask检查点 -
更新渲染(Update the rendering)
-
若这是一个 worker event loop,但没有任务在 task 队列中,且 WorkerGlobalScope 对象的 closing 标识为 true,则销毁 event loop,中止这些步骤,然后进行定义在 Web workers 章节的 run a worker
-
返回第一步...
Event Loop 会不断循环上面的步骤,概括说来:
Event Loop会不断循环的去取任务队列中最老的一个任务推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务- 执行完
microtask队列里的任务,有可能会渲染更新(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60Hz的频率更新视图)
当某个宏任务执行完后,会查看微任务队列是否为空,若不空先执行微任务队列中的所有任务,若是空的则会读取宏任务队列中排在最前的任务,执行宏任务的过程中遇到微任务则依次加入微任务队列,待执行栈空后再次读取微任务队列里的任务,依次类推
宏任务是一个一个执行的;微任务是一队一队执行的,因此在处理微任务队列这一步时会逐个执行队列中的任务并把它出队,直到队列被清空
事件循环、宏任务、微任务的关系如图所示:
MicroTasks 检查点(Microtask Checkpoint)
-
当执行一个
microtask checkpoint,若microtask checkpoint的flag(标识)为false,设置microtask检查点标志为true -
Microtask queue handling:若Event Loop的microtask队列为空,直接跳到第八步(Done) -
在
microtask队列中选择最老的一个任务 -
将上一步选择的任务设为
Event Loop的 currently running task -
运行选择的任务
-
将
Event Loop的 currently running task 变为null -
将前面运行的
microtask从microtask队列中删除,然后返回到第二步(Microtask queue handling) -
Done:每个 environment settings object 它们的 responsible event loop 就是当前的Event Loop,会给environment settings object发一个 rejected promises 的通知 -
将
microtask checkpoint的flag设为flase
microtask checkpoint 所做的就是执行 microtask 队列里的任务,什么时候会调用 microtask checkpoint 呢?
- 当上下文执行栈为空时,执行一个
microtask checkpoint - 在
Event Loop的第六步(Microtasks: Perform a microtask checkpoint)执行checkpoint,即在运行宏任务之后,更新渲染之前
执行栈在执行完同步任务后查看执行栈是否为空,若执行栈为空就会去检查微任务队列是否为空,若微任务队列为空的话则执行宏任务,否则就一次性执行完所有微任务
每次单个宏任务执行完毕后会检查微任务队列是否为空,若不为空的话会按照先入先出的规则全部执行完微任务后,设置微任务队列为 null,然后再执行宏任务,如此循环...
Event Loop 中的 Update the rendering(更新渲染)
规范允许浏览器自己选择是否更新视图,即可能不是每轮事件循环都去更新视图而是只在有必要的时候才更新视图
规范定义在一次循环中,Update the rendering 会在第六步 Microtasks: Perform a microtask checkpoint 后运行
渲染的基本流程:
- 处理
HTML标记并构建DOM树 - 处理
CSS标记并构建CSSOM树, 将DOM与CSSOM合并成一个渲染树 - 根据渲染树来布局,以计算每个节点的几何信息
- 将各个节点绘制到屏幕上
渲染树的一个重要组成部分是CSSOM树,绘制会等待css样式全部加载完成才进行,所以css样式加载的快慢是首屏呈现快慢的关键点
可以看到若任务队列若有大量的任务等待执行时,将 dom 的动作变为微任务(microtask)而不是宏任务(task)能更快的将变化呈现给用户
验证更新渲染(Update the rendering)的时机:
不同机子测试可能会得到不同的结果,这取决于浏览器 cpu、gpu 性能以及它们当时的状态
在一轮 Event Loop 中多次修改同一 dom,只有最后一次会进行绘制
渲染更新(Update the rendering)会在 Event Loop 中的宏任务和微任务完成后进行,但并不是每轮 Event Loop 都会更新渲染,这取决于是否修改了 dom 和浏览器觉得是否有必要在此时立即将新状态呈现给用户,若在一帧的时间内(时间并不确定,因为浏览器每秒的帧数总在波动,16.7 ms 只是估算并不准确)修改了多处 dom,浏览器可能将变动积攒起来只进行一次绘制,这是合理的
若希望在每轮 Event Loop 都即时呈现变动,可以使用 requestAnimationFrame
同步简简单单就可以完成了,为啥要异步去做这些事?
对于一些简单的场景,同步完全可以胜任,若得对 dom 反复修改或进行大量计算时,使用异步可以作为缓冲,优化性能
例子
-
例 1
let data = []; $.ajax({ url:www.javascript.com, data:data, success:() => { console.log('发送成功!'); } }) console.log('代码执行结束');ajax进入Event Table注册回调函数success- 执行
console.log('代码执行结束') ajax事件完成,回调函数success进入Event Queue主线程从Event Queue读取回调函数success并执行
-
例 2
setTimeout(() => { task(); }, 3000) console.log('先执行这里'); setTimeout(() => { console.log('执行啦'); }, 0); // 先执行这里 // 执行啦 console.log('先执行这里'); setTimeout(() => { console.log('执行啦'); }, 3000); // 先执行这里 // 3s later ... // 执行啦setTimeout是经过指定时间后把要执行的任务(本例中为 task )加入到任务队列中,又因为 JS 是单线程,任务要一个一个执行,若前面任务用时太久则只能等着,导致真正的延迟时间远远大于3秒-
task 进入
Event Table并注册,计时开始 -
3 秒到了,计时事件完成,task 进入到任务队列中等着
-
task 从任务队列进入了主线程执行
经常遇到
setTimeout(fn, 0)这样的代码,0毫秒后执行又是什么意思呢?是不是可以立即执行呢?- 不会的,
setTimeout(fn, 0)的含义是指定某个任务在主线程最早可得的空闲时间执行,即不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行
对于
setTimeout,注意:即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4ms,小于4ms按照4ms处理,但每个浏览器实现的最小间隔都不同,而setInterval最小时间间隔为10mssetInterval是循环的执行,对于执行顺序来说setInterval会每隔指定的时间将注册的函数置入任务队列,若前面的任务耗时太久则同样需要等待。唯一需要注意的一点是,对于setInterval(fn, ms)来说是每过ms会有fn进入任务队列,一旦setInterval的回调函数fn执行时间超过了延迟时间ms,则就完全看不出来有时间间隔了 -
-
例 3
setTimeout(function() { console.log('setTimeout'); }) new Promise(function(resolve) { console.log('promise'); }).then(function() { console.log('then'); }) console.log('console'); // promise // console // setTimeout- 这段代码(整体代码
script 脚本)作为第一个宏任务进入主线程 - 先遇到
setTimeout,将其回调函数注册后分发到宏任务队列(注册过程与上同,下文不再描述) - 接下来
Promise,new Promise立即执行,then函数分发到微任务队列 - 遇到
console.log()立即执行 script 脚本作为第一个宏任务执行结束,此时看看有哪些微任务?发现了then在微任务队列里,执行- ok,第一轮事件循环结束了
- 开始第二轮循环,要从
宏任务开始,发现了宏任务队列中setTimeout对应的回调函数,立即执行 - 结束
- 这段代码(整体代码
-
例 4
Promise.resolve().then(() => { console.log('Promise1'); setTimeout(() => { console.log('setTimeout2'); }, 0) }) setTimeout(() => { console.log('setTimeout1'); Promise.resolve().then(() => { console.log('Promise2'); }) }, 0) // Promise1 // setTimeout1 // Promise2 // setTimeout2- 一开始执行栈的
script属于宏任务,执行完毕会去查看微任务队列中是否有微任务,promiset.then存在一个微任务,然后执行微任务队列中的所有任务输出Promise1,同时会产生一个宏任务 setTimeout2 - 然后去查看
宏任务队列,宏任务setTimeout1在setTimeout2前,先执行宏任务 setTimeout1,输出setTimeout1 - 在执行
宏任务 setTimeout1时会生成微任务 Promise2,放入微任务队列中,接着先去清空微任务队列中的所有任务,输出Promise2 - 清空完
微任务队列中的所有任务后,就又会去宏任务队列取一个,这回执行的是setTimeout2
- 一开始执行栈的
-
例 5
console.log('1'); setTimeout(function() { console.log('2'); process.nextTick(function() { console.log('3'); }) new Promise(function(resolve) { console.log('4'); resolve(); }).then(function() { console.log('5'); }) }) process.nextTick(function() { console.log('6'); }) new Promise(function(resolve) { console.log('7'); resolve(); }).then(function() { console.log('8'); }) setTimeout(function() { console.log('9'); process.nextTick(function() { console.log('10'); }) new Promise(function(resolve) { console.log('11'); resolve(); }).then(function() { console.log('12'); }) }) // 1,7,6,8,2,4,3,5,9,11,10,12 // 请注意 node 环境下的事件监听依赖 libuv 与前端环境不完全相同,输出顺序可能会有误差- 第一轮事件循环
script 整体代码作为第一个宏任务进入主线程,遇到console.log输出1- 遇到
setTimeout,其回调被分到宏任务队列中,记为setTimeout1 - 遇到
process.nextTick(),其回调被分到微任务队列中,记为process1 - 遇到
Promise,new Promise立即执行,输出7,then被分发到微任务队列中,记为then1 - 又遇到了
setTimeout,其回调被分到宏任务队列中,记为setTimeout2 - 此时发现了
process1和then1两个微任务,因此执行process1输出6,执行then1输出8
- 第一轮事件循环正式结束
- 第二轮事件循环从
setTimeout1宏任务开始- 首先输出
2 - 接下来遇到
process.nextTick(),将其分到微任务队列中,记为process2 new Promise立即执行输出4,then也分到微任务队列中,记为then2- 第二轮事件循环
宏任务结束,发现有process2和then2两个微任务可执行,分别执行输出3和5
- 首先输出
- 第二轮事件循环结束
- 第三轮事件循环开始,此时只剩
setTimeout2了,执行,直接输出9- 将
process.nextTick()分到微任务队列中,记为process3 - 直接执行
new Promise,输出11 - 将
then分到微任务队列中,记为then3 - 第三轮事件循环
宏任务执行结束,执行两个微任务process3和then3,分别输出10和12
- 将
- 第一轮事件循环
-
例 6
console.log('script start'); setTimeout(function() { console.log('setTimeout'); }, 0); Promise.resolve().then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }); console.log('script end'); // script start // script end // promise1 // promise2 // setTimeout- 执行同步代码,将
宏任务和微任务划分到各自队列中- 宏任务:script、setTimeout callback
- 微任务:Promise then
- 执行栈:script
- console.log:script start、script end
- 执行
宏任务 script后,检测到微任务队列中不为空,执行Promise1,执行完成Promise1后调用Promise2.then,放入微任务队列中,再执行Promise2.then- 宏任务:run script、setTimeout callback
- 微任务:Promise2.then
- 执行栈:Promise2 callback
- console.log:script start、script end、promise1、promise2
- 当
微任务队列中为空时,执行宏任务,执行setTimeout callback,打印结果- 宏任务:setTimeout callback
- 微任务:空
- 执行栈:setTimeout callback
- console.log:script start、script end、promise1、promise2、setTimeout
- 清空
任务队列和JS 执行栈- 宏任务:空
- 微任务:空
- 执行栈:空
- console.log:script start、script end、promise1、promise2、setTimeout
- 执行同步代码,将
-
例 7
async/await在底层转换成了Promise和then回调函数,即这是Promise的语法糖,async/await的实现离不开Promise从字面意思来理解
async是异步的简写,而await是async wait的简写,可认为是等待异步方法执行完成当在函数前使用
async时,使得该函数返回的是一个Promise对象,可见async只是一个语法糖,只是帮助返回一个Promise而已,在async/await中,在await出现之前,其中的代码也是立即执行的async function test() { return 1; // async 的函数会在这里帮隐式使用 Promise.resolve(1) } // 等价于下面的代码 function test() { return new Promise(function(resolve, reject) { resolve(1); }) }await表示等待,是右侧「表达式」的结果,这个表达式的计算结果可以是Promise对象的值或一个函数的值(即没有特殊限定),且只能在带有async的内部使用实际上
await是一个让出线程的标志,当遇到await时会从右往左执行,await后的表达式会先执行一遍,然后阻塞函数内部处于它后面的代码当
await执行完毕后将await后面的代码加入到微任务队列中,然后就会跳出整个async函数去执行该函数外部的同步代码,当外部同步代码执行完毕,再回到该函数内部执行剩余的代码,且当await执行完毕后会先处理微任务队列的代码console.log('script start'); async function async1() { console.log('async1 start'); await async2(); console.log('async1 end'); } async function async2() { console.log('async2 end'); } async1(); setTimeout(function() { console.log('setTimeout'); }, 0) new Promise(resolve => { console.log('Promise'); resolve(); }).then(function() { console.log('promise1'); }).then(function() { console.log('promise2'); }) console.log('script end'); // script start // async1 start // async2 end // Promise // script end // async1 end // promise1 // promise2 // setTimeout-
首先,事件循环从
宏任务队列开始,这时宏任务队列中只有一个script (整体代码)任务,当遇到任务源时则会先分发任务到对应的任务队列中去 -
遇到
console.log同步代码先执行并打印script start,输出后script任务继续往下执行,调用async1时async函数中在await之前的代码是立即执行的,所以会立即输出async1 start -
遇到
await时会将await后的表达式执行一遍,所以这里紧接着执行async2,输出async2 end且返回一个Promise,然后将await后面的代码即console.log('async1 end')加入到微任务队列中,接着跳出async1函数来执行其后面的代码 -
接来下遇到
setTimeout,其作为一个宏任务源则会将其任务分发到对应的宏任务队列 -
script任务继续往下执行,遇到Promise实例,由于Promise中的函数是立即执行的,后序的then回调会被分发到微任务队列中,所以会先输出Promise,然后执行resolve且将promise1和promise2分别分配到对应微任务队列中 -
script任务继续往下执行,最后只有一句输出了script end,至此全局任务执行完毕 -
在
script任务执行完后会去检查微任务队列是否为空,若不为空则依次执行里面的微任务直至清空微任务队列 -
此时
微任务队列中Promise队列中有 3 个任务async1 end、promise1和promise2,因此按先后顺序分别输出,到这里所有的微任务执行完毕后,表示第一轮的循环结束了 -
第二轮循环依旧从
宏任务开始,此时宏任务队列中只有一个setTimeout,取出直接输出即可,至此整个流程结束
-
Node 中的 Event Loop
Node 也是单线程的 Event Loop,但是它的运行机制不同于浏览器环境
Node 的 Event Loop 是基于 libuv,而浏览器的 Event Loop 则是在 html5 的规范中明确定义的
Node 采用 V8 作为 JS 的解析引擎,而 I/O 处理方面使用了自己设计的 libuv
libuv是一个基于事件驱动的跨平台抽象层,封装了不同操作系统一些底层特性,对外提供统一的API,事件循环机制也是它里面的实现libuv 使用
异步、事件驱动的编程方式,核心是提供 I/O 的事件循环和异步回调libuv 的
API包含有时间、非阻塞的网络、异步文件操作、子进程等等
Node 运行机制
V8 引擎解析JS 脚本- 解析后的代码调用
Node API libuv库负责Node API的执行,它将不同的任务分配给不同的线程,形成一个Event Loop,以异步的方式将任务的执行结果返回给V8 引擎V8 引擎再将结果返回给用户
Node 事件循环
Node 的 Event Loop 分为 6 个阶段,它们会按照顺序反复运行。每当进入某个阶段时都会从对应的回调队列中取出回调函数去执行,当队列为空或执行的回调函数数量达到系统设定的阈值,就会进入下一阶段,Node 事件循环的大致顺序如下:外部输入数据 –> 轮询阶段(poll) –> 检查阶段(check) –> 关闭事件回调阶段(close callback) –> 定时器检测阶段(timer) –> I/O 事件回调阶段(I/O callbacks) –> 闲置阶段(idle, prepare) –> 轮询阶段(按照该顺序反复运行)…
6个阶段:
注意:下面六个阶段都不包括 process.nextTick,
日常开发中的绝大部分异步任务都是在 timers、poll、check 这 3 个阶段处理的
-
timerstimers阶段会执行setTimeout和setInterval回调且是由poll阶段控制的。在timers阶段其实使用一个最小堆而不是队列来保存所有的元素,其实也可以理解,因为timeout的callback是按照超时时间的顺序来调用的,并不是先进先出的队列逻辑为什么
timers阶段在第一个执行阶梯上其实也不难理解:在Node中定时器指定的时间也是不准确的,而这样就能尽可能的准确了,让其回调函数尽快执行。以下是官方文档的解释例子:- 当进入
事件循环时,它有一个空队列(fs.readFile()尚未完成),因此定时器将等待剩余毫秒数,当到达95ms时,fs.readFile()完成读取文件且其完成需要10 毫秒的回调被添加到轮询队列并执行 - 当回调结束时队列中不再有回调,因此
事件循环将看到已达到最快定时器的阈值,然后回到timers阶段以执行定时器的回调。在此示例中,将看到正在调度的计时器与正在执行的回调之间的总延迟将为105 毫秒
const fs = require('fs'); function someAsyncOperation(callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } const timeoutScheduled = Date.now(); setTimeout(() => { const delay = Date.now() - timeoutScheduled; console.log(`${delay}ms have passed since I was scheduled`); }, 100); // do someAsyncOperation which takes 95 ms to complete someAsyncOperation(() => { const startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { // do nothing } }); - 当进入
-
pending callbackspending callbacks阶段其实是I/O的callbacks阶段(上一轮循环中有少数的I/O callback会被延迟到这一轮的这一阶段执行),如一些TCP的error回调等。如若TCP socket ECONNREFUSED在尝试connect时receives,则某些* nix系统希望等待报告错误,这将在pending callbacks阶段执行 -
idle, prepare:idle, prepare阶段内部实现,仅在内部使用 -
pollpoll是一个至关重要的阶段,这一阶段中系统会做两件事情:- 执行
I/O回调 - 处理轮询队列中的事件,回到
timers阶段执行回调
当
Event Loop进入到poll阶段且timers阶段没有任何可执行的 任务时(即没有定时器回调),将会有以下两种情况:- 若
poll队列不为空,会遍历回调队列并同步执行,直到队列为空或达到系统限制 - 若
poll队列为空则会发生以下两种情况:- 若有
setImmediate回调需要执行则会立即停止执行poll阶段并进入执行check阶段以执行回调 - 若没有
setImmediate回调需要执行,则poll阶段将等待回调被加入到队列中再立即执行回调,这里同样会有个超时时间设置防止一直等待下去,这也是为什么说poll阶段可能会阻塞的原因
- 若有
当然设定了
timer的话且poll队列为空,则会判断是否有timer超时,若有话则会回到timer阶段执行回调 - 执行
-
checkcheck阶段在poll阶段之后,setImmediate的回调会被加入check队列中,它是一个使用libuv API的特殊的计数器通常当代码被执行时,事件循环最终将达到
poll阶段,它将等待传入连接、请求等。但若已调度了回调setImmediate且轮询阶段(poll阶段)变为空闲,则poll阶段将结束且到达check阶段,开始check阶段的执行 -
close callbacks:close callbacks阶段执行close事件,若一个socket或事件处理函数突然关闭/中断(如socket.destroy()),则这个阶段就会发生close的回调执行,否则它会通过process.nextTick()发出 -
例子console.log('start'); setTimeout(() => { console.log('timer1'); Promise.resolve().then(function() { console.log('promise1'); }); }, 0) setTimeout(() => { console.log('timer2'); Promise.resolve().then(function() { console.log('promise2'); }) }, 0) Promise.resolve().then(function() { console.log('promise3'); }) console.log('end'); // start // end // promise3 // timer1 // timer2 // promise1 // promise2- 一开始执行栈执行
script(这属于宏任务),依次打印出start和end,并将2个timer依次放入timer队列,Promise.then加入微任务队列(这与浏览器端的一致) - 执行完毕后,去执行
微任务,所以打印出promise3 - 然后进入
timers阶段执行timer1的回调函数,打印timer1,并将promise回调放入微任务队列,同样的步骤执行timer2,打印timer2并将promise回调放入微任务队列(注意:这点跟浏览器端相差比较大:timers阶段有几个setTimeout/setInterval都会依次执行,而浏览器端是每执行一个宏任务后就去执行微任务)
- 一开始执行栈执行
Node 与浏览器的 Event Loop 差异
浏览器:microtask的任务队列是每个macrotask执行完之后执行Node:microtask会在Event Loop的各个阶段之间执行,即一个阶段执行完毕,就会去执行microtask队列的任务
注意:node 版本更新超过 10 后,Event Loop 运行原理发生了变化,一旦执行一个阶段里的一个宏任务(setTimeout、setInterval 和 setImmediate)就立刻执行
微任务队列,这点就跟浏览器端一致,详见(timers: run nextTicks after each immediate and timer)
setImmediate VS setTimeout
二者非常相似,区别主要在于调用时机不同:
setImmediate设计用于在当前poll阶段完成后即check阶段执行setTimeout设计在poll阶段为空闲时且设定时间到达后执行,但它在timers阶段执行
执行定时器的顺序将根据调用它们的上下文而有所不同。若从主模块中调用两者,则时间将受到进程性能的限制,有些情况下定时器的执行顺序其实是随机的,如对于以下代码来说,setTimeout 可能执行在前也可能执行在后
- 首先
setTimeout(fn, 0) === setTimeout(fn, 1),这是由源码决定的(因为实际上Node做不到0秒,最少也要1毫秒) - 进入
事件循环也是需要成本的,若在准备时花费了大于1ms的时间,则在timers阶段就会直接执行setTimeout回调 - 若准备时间花费小于
1ms,则setImmediate回调先执行了
setTimeout(() => {
console.log('setTimeout')
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
但当二者在异步 I/O callback 内部调用时,总是先执行 setImmediate 再执行 setTimeout
主要原因是在 I/O 阶段读取文件后事件循环会先进入 poll 阶段,发现有 setImmediate 需要执行则会立即进入 check 阶段执行 setImmediate 的回调,然后再进入 timers 阶段去执行 setTimeout,打印 timeout
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0)
setImmediate(() => {
console.log('immediate');
})
})
// immediate
// timeout
所以与 setTimeout 相比,使用 setImmediate 的主要优点是:若在 I/O 周期内安排了任何计时器,则 setImmediate 将始终在任何其他计时器之前执行,而与存在多少计时器无关
setTimeout的优先级高于setIImmediate
process.nextTick
这个函数虽然是异步 API 的一部分,但从技术上讲它是独立于 Event Loop 之外的,它有一个自己的队列
process.nextTick 方法将 callback 添加到 nextTick 队列,当每个阶段完成后若存在 nextTick 队列,就会清空队列中的所有回调函数且优先于其他 微任务 执行(本质上属于 microtask)
process.nextTick 方法可以在当前执行栈的尾部 -- 下一次 Event Loop(主线程读取任务队列)之前 -- 触发回调函数,即它指定的任务总是发生在所有异步任务之前
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(function() {
console.log('promise1');
})
}, 0)
process.nextTick(() => {
console.log('nextTick');
process.nextTick(() => {
console.log('nextTick');
process.nextTick(() => {
console.log('nextTick');
process.nextTick(() => {
console.log('nextTick');
})
})
})
})
// nextTick
// nextTick
// nextTick
// nextTick
// timer1
// promise1
preocess.nextTick优先级高于promise.then
process.nextTick 和setImmediate的一个重要区别:多个 process.nextTick 语句总是在当前执行栈一次执行完,多个 setImmediate 可能则需要多次 loop 才能执行完。事实上,这正是 Nodejs 10.0 版添加 setImmediate 方法的原因,否则像下面这样的递归调用 process.nextTick,将会没完没了,主线程根本不会去读取任务队列
process.nextTick(function fn() {
process.nextTick(fn);
});
另外,由于 process.nextTick 指定的回调函数是在本次 Event Loop 触发,而 setImmediate 指定的是在下次 Event Loop 触发,所以很显然,前者总是比后者发生得早,而且执行效率也高(因为不用检查任务队列)
最后来看个例子
console.log('1');
setTimeout(function() {
console.log('2');
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5');
})
process.nextTick(function() {
console.log('3');
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8');
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12');
})
})
// 1
// 7
// 6
// 8
// 2
// 4
// 9
// 11
// 3
// 10
// 5
// 12