不论你是javascript新手还是老鸟,不论是面试求职,还是日常开发工作,我们经常会遇到这样的情况:给定的几行代码,我们需要知道其输出内容和顺序。
Event Loop
这个概念相信大家或多或少都了解过,但是有一次被一个小伙伴问到它具体的原理的时候,感觉自己只知道个大概印象,于是计划着写一篇文章,用输出倒逼输入,让自己重新学习这个概念,同时也能帮助更多的人理解它。
先简单介绍几个相关概念:
资源分配
的最小单位(是能拥有资源和独立运行的最小单位)。比如你正在运行的浏览器,它会有一个进程调度
的最小单位(线程是建立在进程的基础上的一次程序运行单位,一个进程中可以有多个线程)。线程是负责执行代码的
从头执行到尾
,一行一行执行,如果其中一行代码报错,那么剩下代码将不再执行。同时容易代码阻塞
。运行的环境不同
,各线程独立
,互不影响
,避免阻塞。JavaScript语言的一大特点就是单线程
,也就是说,同一个时间只能做一件事
,代码执行是同步并且阻塞
的。
那么,为什么JavaScript不能有多个线程呢?这样能提高效率啊。
JavaScript原因:
JavaScript的单线程,与它的用途
有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动
,以及操作DOM
。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
为了利用多核CPU的计算能力,HTML5
提出Web Worker标准,允许JavaScript脚本创建多个线程,但是子线程完全受主线程控制,且不得操作DOM
。所以,这个新标准并没有改变JavaScript单线程的本质。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。
比如我们想浏览新闻,但是新闻包含的超清图片加载很慢,难道我们的网页要一直卡着直到图片完全显示出来。
如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢
(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。
同样,先介绍接个相关概念:
堆(heap):
一个大部分非结构化
的内存区域利用完全二叉树维护
的一组数据,堆分为两种,一种为最大堆
,一种为最小堆
,将根节点最大的堆叫做最大堆或大根堆,根节点最小的堆叫做最小堆或小根堆线性
数据结构,相当于一维数组
,有唯一后继LIFO(Last In, First Out)
的数据结构,特点即后进先出
。是限定仅在表尾
进行插入
或删除
操作的线性表。后进先出
的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。仅在某一端(表尾)进行插入或删除
操作的线性表执行栈(stack)/ 调用栈(call stack):
待执行的函数
运行同步代码
。执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。队列(Queue):
只允许
在表的前端(front)
进行删除
操作,而在表的后端(rear)
进行插入
操作。和栈一样,队列是一种操作受限制的线性表
先进先出(FIFO—first in first out)
,队列的数据元素又称为队列元素。在队列中插入
一个队列元素称为入队
,从队列中删除
一个队列元素称为出队
。因为队列只允许在一端插入,在另一端删除
本质上当然是个队列
,是一个先进先出
的数据结构
"任务队列"
是一个事件的队列(也可以理解成消息的队列),里面存放异步任务
,除了IO设备的事件
以外,还包括一些用户产生的事件
(比如鼠标点击
、页面滚动
等等)
异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。(所谓"回调函数"(callback)
,就是那些会被主线程挂起来的代码
)
主线程读取"任务队列",就是读取里面有哪些事件
于是,从广义上讲,所有任务可以分成两种,一种是同步任务(synchronous)
,另一种是异步任务(asynchronous)
。
同步任务
:指的是在主线程上
排队执行的任务,只有前一个任务执行完毕
,才能执行后一个任务
异步任务
:指的是不进入主线程
,而进入"任务队列"(task queue)
的任务,只有"任务队列"(task queue)通知主线程,某个异步任务可以执行
了,该任务才会进入主线程
执行。具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。
上图可以用下面文字来解释:
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
主线程不断重复上面的第三步。也就是常说的Event Loop(事件循环)
(1)首先判断JS是同步和异步任务,同步的进入主线程,异步的进入Event Table并注册函数
(2)异步任务在Event table中注册函数,当满足触发条件后,会将这个函数推入Event queue
(3)主线程内的任务执行完毕为空(此时JS引擎空闲),会去Event Queue读取对应的函数,进入主线程执行
上述过程会不断重复,也就是常说的Event Loop(事件循环)
除了广义上的定义,我们可以将异步任务
进行更精细的定义,分为宏任务
与微任务
:
宏任务:
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
MessageChannel
setImmediate (Node.js 环境,浏览器暂时不支持)
(macro)Task
可以理解是每次执行栈执行的代码
就是一个宏任务
(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得JS内部(macro)Task
与DOM
任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:
(macro)Task -> 渲染 -> (macro)Task -> ······
微任务:
Promise.then
Object.observe (已废弃;`proxy`代替)
MutationObserver
process.nextTick (Node.js 环境)
microtask
可以理解是在当前 task 执行结束后立即执行
的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染
。也就是说,在某一个macrotask执行完
后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)
。
JS运行机制:
在事件循环中,每进行一次循环
操作称为 tick
,每一次 tick 的任务处理模型是比较复杂的,但关键步骤
如下:
注意:
执行栈在执行完同步任务
后,查看执行栈是否为空
,如果执行栈为空,就会去检查微任务(microTask)队列是否为空
,如果为空的话,就执行宏任务(Task/macro Task)
,否则就一次性执行完所有微任务。
每次单个宏任务
执行完毕后,检查微任务(microTask)队列
是否为空,如果不为空的话,会按照先入先出
的规则全部执行完微任务(microTask)
后,设置微任务(microTask)队列为null
,然后再执行宏任务
,如此循环。
(案例来源:Tasks, microtasks, queues and schedules )
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');
第一次执行:
Tasks(宏任务):run script、 setTimeout callback
Microtasks(微任务):Promise then
JS stack(执行栈): script
Log(控制台打印): script start、script end
执行完同步代码
,然后将宏任务(Tasks)
和微任务(Microtasks)
划分到各自队列中。
第二次执行:
Tasks:run script、 setTimeout callback
Microtasks:Promise2 then
JS stack: Promise2 callback
Log: script start、script end、promise1、promise2
执行宏任务(即script)
后,检测到本次宏任务
执行过程中产生的微任务(Microtasks)
队列中不为空
,执行Promise1
,执行完成Promise1
后,调用Promise2.then
,放入微任务(Microtasks)
队列中,再执行Promise2.then
第三次执行:
Tasks:setTimeout callback
Microtasks:
JS stack: setTimeout callback
Log: script start、script end、promise1、promise2、setTimeout
这时第一次宏任务(Tasks)
和其产生的微任务(Microtasks)
已经执行完毕,所以此时微任务(Microtasks)
队列为空
,当微任务(Microtasks)队列中为空时,继续执行下个宏任务(Tasks)
,执行setTimeout callback
,打印日志。
四次执行:
Tasks:setTimeout callback
Microtasks:
JS stack:
Log: script start、script end、promise1、promise2、setTimeout
清空Tasks队列和JS stack
gif图来源 : 掘金
console.log('script start')
async function async1() {
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')
关于async/await
,之前我也写过一篇博客,可以参考JS学习笔记——异步回调中Async Await和Promise区别
async/await
在底层转换成了 promise
和 then
回调函数,是 promise 的语法糖。
每次我们使用 await
,解释器都创建一个 promise 对象
,然后把剩下的 async
函数中的操作放到 then
回调函数中。
async/await 的实现,离不开 Promise。从字面意思来理解,async
是“异步”
的简写,而 await
是 async wait
的简写,可以认为是等待异步方法执行完成
。
打印结果(当前谷歌浏览器版本: 94.0.4606.81(正式版本)
)
script start
async2 end
Promise
script end
async1 end
promise1
promise2
setTimeout
本篇博客参考文章: