JavaScript 事件循环及异步原理(完全指北)
引言
最近面试被问到,JS
既然是单线程的,为什么可以执行异步操作?
当时脑子蒙了,思维一直被困在 单线程
这个问题上,一直在思考单线程为什么可以额外运行任务,其实在我很早以前写的博客里面有写相关的内容,只不过时间太长给忘了,所以要经常温习啊:(浅谈 Generator 和 Promise 的原理及实现)
- JS 是单线程的,只有一个主线程
- 函数内的代码从上到下顺序执行,遇到被调用的函数先进入被调用函数执行,待完成后继续执行
- 遇到异步事件,浏览器另开一个线程,主线程继续执行,待结果返回后,执行回调函数
其实 JS
这个语言是运行在宿主环境中,比如 浏览器环境
,nodeJs环境
- 在浏览器中,浏览器负责提供这个额外的线程
- 在
Node
中,Node.js
借助libuv
来作为抽象封装层, 从而屏蔽不同操作系统的差异,Node
可以借助libuv
来实现多线程。
而这个异步线程又分为 微任务
和 宏任务
,本篇文章就来探究一下 JS
的异步原理以及其事件循环机制
为什么 JavaScript
是单线程的
JavaScript
语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。这样设计的方案主要源于其语言特性,因为 JavaScript
是浏览器脚本语言,它可以操纵 DOM
,可以渲染动画,可以与用户进行互动,如果是多线程的话,执行顺序无法预知,而且操作以哪个线程为准也是个难题。
所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
在 HTML5
时代,浏览器为了充分发挥 CPU
性能优势,允许 JavaScript
创建多个线程,但是即使能额外创建线程,这些子线程仍然是受到主线程控制,而且不得操作 DOM
,类似于开辟一个线程来运算复杂性任务,运算好了通知主线程运算完毕,结果给你,这类似于异步的处理方式,所以本质上并没有改变 JavaScript
单线程的本质。
函数调用栈与任务队列
函数调用栈
JavaScript
只有一个主线程和一个调用栈(call stack
),那什么是调用栈呢?
这类似于一个乒乓球桶,第一个放进去的乒乓球会最后一个拿出来。
举个栗子:
function a() {
console.log("I'm a!");
};
function b() {
a();
console.log("I'm b!");
};
b();
执行过程如下所示:
第一步,执行这个文件,此文件会被压入调用栈(例如此文件名为
main.js
)call stack main.js
第二步,遇到
b()
语法,调用b()
方法,此时调用栈会压入此方法进行调用:call stack b()
main.js
第三步:调用
b()
函数时,内部调用的a()
,此时a()
将压入调用栈:call stack a()
b()
main.js
第四步:
a()
调用完毕输出I'm a!
,调用栈将a()
弹出,就变成如下:call stack b()
main.js
第五步:
b()
调用完毕输出I'm b!
,调用栈将b()
弹出,变成如下:call stack main.js
第六步:
main.js
这个文件执行完毕,调用栈将b()
弹出,变成一个空栈,等待下一个任务执行:call stack
这就是一个简单的调用栈,在调用栈中,前一个函数在执行的时候,下面的函数全部需要等待前一个任务执行完毕,才能执行。
但是,有很多任务需要很长时间才能完成,如果一直都在等待的话,调用栈的效率极其低下,这时,JavaScript
语言设计者意识到,这些任务主线程根本不需要等待,只要将这些任务挂起,先运算后面的任务,等到执行完毕了,再回头将此任务进行下去,于是就有了 任务队列
的概念。
任务队列
所有任务可以分成两种,一种是 同步任务(synchronous)
,另一种是 异步任务(asynchronous)
。
同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。
异步任务指的是,不进入主线程、而进入"任务队列"(task queue)
的任务,只有 "任务队列"
通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
所以,当在执行过程中遇到一些类似于 setTimeout
等异步操作的时候,会交给浏览器的其他模块进行处理,当到达 setTimeout
指定的延时执行的时间之后,回调函数会放入到任务队列之中。
当然,一般不同的异步任务的回调函数会放入不同的任务队列之中。等到调用栈中所有任务执行完毕之后,接着去执行任务队列之中的回调函数。
用一张图来表示就是:
上图中,调用栈先进行顺序调用,一旦发现异步操作的时候就会交给浏览器内核的其他模块进行处理,对于 Chrome
浏览器来说,这个模块就是 webcore
模块,上面提到的异步API,webcore
分别提供了 DOM Binding
、 network
、timer
模块进行处理。等到这些模块处理完这些操作的时候将回调函数放入任务队列中,之后等栈中的任务执行完之后再去执行任务队列之中的回调函数。
我们先来看一个有意思的现象,我运行一段代码,大家觉得输出的顺序是什么:
setTimeout(() => {
console.log('setTimeout')
}, 22)
for (let i = 0; i++ < 2;) {
i === 1 && console.log('1')
}
setTimeout(() => {
console.log('set2')
}, 20)
for (let i = 0; i++ < 100000000;) {
i === 99999999 && console.log('2')
}
没错!结果很量子化:
那么这实际上是一个什么过程呢?那我就拿上面的一个过程解析一下:
首先,文件入栈
开始执行文件,读取到第一行代码,当遇到
setTimeout
的时候,执行引擎将其添加到栈中。(由于字体太细我调粗了一点。。。)调用栈发现
setTimeout
是Webapis
中的API
,因此将其交给浏览器的timer
模块进行处理,同时处理下一个任务。
第二个
setTimeout
入栈同上所示,异步请求被放入
异步API
进行处理,同时进行下一个入栈操作:在进行异步的同时,
app.js
文件调用完毕,弹出调用栈,异步执行完毕后,会将回调函数放入任务队列:任务队列通知调用栈,我这边有任务还没有执行,调用栈则会执行任务队列里的任务:
上面的流程解释了浏览器遇到 setTimeout
之后究竟如何执行的,其实总结下来就是以下几点:
- 调用栈顺序调用任务
- 当调用栈发现异步任务时,将异步任务交给其他模块处理,自己继续进行下面的调用
- 异步执行完毕,异步模块将任务推入任务队列,并通知调用栈
- 调用栈在执行完当前任务后,将执行任务队列里的任务
- 调用栈执行完任务队列里的任务之后,继续执行其他任务
这一整个流程就叫做 事件循环(Event Loop)
。
那么,了解了这么多,小伙伴们能从事件循环上面来解析下面代码的输出吗?
for (var i = 0; i < 10; i++) {
setTimeout(() => {
console.log(i)
}, 1000)
}
console.log(i)
解析:
- 首先由于
var
的变量提升,i
在全局作用域都有效 - 再次,代码遇到
setTimeout
之后,将该函数交给其他模块处理,自己继续执行console.log(i)
,由于变量提升,i
已经循环10次,此时i
的值为10
,即,输出10
- 之后,异步模块处理好函数之后,将回调推入任务队列,并通知调用栈
- 1秒之后,调用栈顺序执行回调函数,由于此时
i
已经变成10
,即输出10次10
用下图示意:
现在小伙伴们是否已经恍然大悟,从底层了解了为什么这个代码会输出这个内容吧:
那么问题又来了,我们看下面的代码:
setTimeout(() => {
console.log(4)
}, 0);
new Promise((resolve) =>{
console.log(1);
for (var i = 0; i < 10000000; i++) {
i === 9999999 && resolve();
}
console.log(2);
}).then(() => {
console.log(5);
});
console.log(3);
大家觉得这个输出是多少呢?
有小伙伴就开始分析了,promise
也是异步,先执行里面函数的内容,输出 1
和 2
,然后执行下面的函数,输出 3
,但 Promise
里面需要循环999万次,setTimeout
却是0毫秒执行,setTimeout
应该立即推入执行栈, Promise
后推入执行栈,结果应该是下图:
实际上答案是 1,2,3,5,4
噢,这是为什么呢?这就涉及到任务队列的内部,宏任务和微任务。
宏任务和微任务
什么是宏任务和微任务
任务队列又分为 macro-task(宏任务)
与 micro-task(微任务)
,在最新标准中,它们被分别称为 task
与 jobs
。
macro-task(宏任务)
大概包括:script(整体代码)
,setTimeout
,setInterval
,setImmediate(NodeJs)
,I/O
,UI rendering
。micro-task(微任务)
大概包括:process.nextTick(NodeJs)
,Promise
,Object.observe(已废弃)
,MutationObserver(html5新特性)
- 来自不同任务源的任务会进入到不同的任务队列。其中
setTimeout
与setInterval
是同源的。
事实上,事件循环决定了代码的执行顺序,从全局上下文进入函数调用栈开始,直到调用栈清空,然后执行所有的micro-task(微任务)
,当所有的micro-task(微任务)
执行完毕之后,再执行macro-task(宏任务)
,其中一个macro-task(宏任务)
的任务队列执行完毕(例如setTimeout
队列),再次执行所有的micro-task(微任务)
,一直循环直至执行完毕。
解析
现在我就开始解析上面的代码。
第一步,整体代码
script
入栈,并执行setTimeout
后,执行Promise
:第二步,执行时遇到
Promise
实例,Promise
构造函数中的第一个参数,是在new
的时候执行,因此不会进入任何其他的队列,而是直接在当前任务直接执行了,而后续的.then
则会被分发到micro-task
的Promise
队列中去。第三步,调用栈继续执行宏任务
app.js
,输出3
并弹出调用栈,app.js
执行完毕弹出调用栈:第四步,这时,
macro-task(宏任务)
中的script
队列执行完毕,事件循环开始执行所有的micro-task(微任务)
:第五步,调用栈发现所有的
micro-task(微任务)
都已经执行完毕,又跑去macro-task(宏任务)
调用setTimeout
队列:第六步,
macro-task(宏任务)
setTimeout
队列执行完毕,调用栈又跑去微任务进行查找是否有未执行的微任务,发现没有就跑去宏任务执行下一个队列,发现宏任务也没有队列执行,此次调用结束,输出内容1,2,3,5,4
。
那么上面这个例子的输出结果就显而易见。大家可以自行尝试体会。
总结
- 不同的任务会放进不同的任务队列之中。
- 先执行
macro-task
,等到函数调用栈清空之后再执行所有在队列之中的micro-task
。 - 等到所有
micro-task
执行完之后再从macro-task
中的一个任务队列开始执行,就这样一直循环。 - 宏任务和微任务的队列执行顺序排列如下:
macro-task(宏任务)
:script(整体代码)
,setTimeout
,setInterval
,setImmediate(NodeJs)
,I/O
,UI rendering
。micro-task(微任务)
:process.nextTick(NodeJs)
,Promise
,Object.observe(已废弃)
,MutationObserver(html5新特性)
进阶举例
那么,我再来一些有意思一点的代码:
<script>
setTimeout(() => {
console.log(4)
}, 0);
new Promise((resolve) => {
console.log(1);
for (var i = 0; i < 10000000; i++) {
i === 9999999 && resolve();
}
console.log(2);
}).then(() => {
console.log(5);
});
console.log(3);
</script>
<script>
console.log(6)
new Promise((resolve) => {
resolve()
}).then(() => {
console.log(7);
});
</script>
这一段代码输出的顺序是什么呢?
其实,看明白上面流程的同学应该知道整个流程,为了防止一些同学不明白,我再简单分析一下:
首先,
script1
进入任务队列(为了方便起见,我把两块script
命名为script1
,script2
):第二步,
script1
进行调用并弹出调用栈:第三步,
script1
执行完毕,调用栈清空后,直接调取所有微任务:第四步,所有微任务执行完毕之后,调用栈会继续调用宏任务队列:
第五步,执行
script2
,并弹出:第六步,调用栈开始执行微任务:
第七步,调用栈调用完所有微任务,又跑去执行宏任务:
至此,所有任务执行完毕,输出 1,2,3,5,6,7,4
了解了上面的内容,我觉得再复杂一点异步调用关系你也能搞定:
setImmediate(() => {
console.log(1);
},0);
setTimeout(() => {
console.log(2);
},0);
new Promise((resolve) => {
console.log(3);
resolve();
console.log(4);
}).then(() => {
console.log(5);
});
console.log(6);
process.nextTick(()=> {
console.log(7);
});
console.log(8);
//输出结果是3 4 6 8 7 5 2 1
终极测试
setTimeout(() => {
console.log('to1');
process.nextTick(() => {
console.log('to1_nT');
})
new Promise((resolve) => {
console.log('to1_p');
setTimeout(() => {
console.log('to1_p_to')
})
resolve();
}).then(() => {
console.log('to1_then')
})
})
setImmediate(() => {
console.log('imm1');
process.nextTick(() => {
console.log('imm1_nT');
})
new Promise((resolve) => {
console.log('imm1_p');
resolve();
}).then(() => {
console.log('imm1_then')
})
})
process.nextTick(() => {
console.log('nT1');
})
new Promise((resolve) => {
console.log('p1');
resolve();
}).then(() => {
console.log('then1')
})
setTimeout(() => {
console.log('to2');
process.nextTick(() => {
console.log('to2_nT');
})
new Promise((resolve) => {
console.log('to2_p');
resolve();
}).then(() => {
console.log('to2_then')
})
})
process.nextTick(() => {
console.log('nT2');
})
new Promise((resolve) => {
console.log('p2');
resolve();
}).then(() => {
console.log('then2')
})
setImmediate(() => {
console.log('imm2');
process.nextTick(() => {
console.log('imm2_nT');
})
new Promise((resolve) => {
console.log('imm2_p');
resolve();
}).then(() => {
console.log('imm2_then')
})
})
// 输出结果是:?
大家可以在评论里留言结果哟~
JavaScript 事件循环及异步原理(完全指北)的更多相关文章
- c#封装DBHelper类 c# 图片加水印 (摘)C#生成随机数的三种方法 使用LINQ、Lambda 表达式 、委托快速比较两个集合,找出需要新增、修改、删除的对象 c# 制作正方形图片 JavaScript 事件循环及异步原理(完全指北)
c#封装DBHelper类 public enum EffentNextType { /// <summary> /// 对其他语句无任何影响 /// </summary> ...
- JavaScript是如何工作的:事件循环和异步编程的崛起 + 5种使用 async/await 更好地编码方式!
摘要: 深度理解JS事件循环!!! 原文:JavaScript是如何工作的:事件循环和异步编程的崛起+ 5种使用 async/await 更好地编码方式! 作者:前端小智 Fundebug经授权转载, ...
- 一篇文章图文并茂地带你轻松学完 JavaScript 事件循环机制(event loop)
JavaScript 事件循环机制 (event loop) 本篇文章已经默认你有了基础的 ES6 和 javascript语法 知识. 本篇文章比较细致,如果已经对同步异步,单线程等概念比较熟悉的读 ...
- JavaScript 事件循环
JavaScript 事件循环 事件循环 任务队列 async/await 又是如何处理的呢 ? 定时器问题 阻塞还是非阻塞 实际应用案例 拆分 CPU 过载任务 进度指示 在事件之后做一些事情 事件 ...
- JavaScript 事件委托的技术原理
如今的 JavaScript 技术界里最火热的一项技术应该是‘事件委托(event delegation)’了.使用事件委托技术能让你避免对特定的每个节点添加事件监听器:相反,事件监听器是被添加到它们 ...
- JavaScript事件委托的技术原理
如今的JavaScript技术界里最火热的一项技术应该是‘事件委托(event delegation)’了.使用事件委托技术能让你避免对特定的每个节点添加事件监听器:相反,事件监听器是被添加到它们的父 ...
- How Javascript works (Javascript工作原理) (四) 事件循环及异步编程的出现和 5 种更好的 async/await 编程方式
个人总结: 1.讲解了JS引擎,webAPI与event loop合作的机制. 2.setTimeout是把事件推送给Web API去处理,当时间到了之后才把setTimeout中的事件推入调用栈. ...
- JavaScript 是如何工作的: 事件循环和异步编程的崛起 + 5个如何更好的使用 async/await 编码的技巧 - 学习笔记
那么,谁会告诉 JS 引擎去执行你的程序?事实上,JS 引擎不是单独运行的 —— 它运行在一个宿主环境中,对于大多数开发者来说就是典型的浏览器和 Node.js.实际上,如今,JavaScript 被 ...
- 异步 JavaScript - 事件循环
简评:如果你对 JavaScript 异步的原理感兴趣,这里有一篇不错的介绍. JavaScript 同步代码是如果工作的 在介绍 JavaScript 异步执行之前先来了解一下, JavaScrip ...
随机推荐
- ASP.NET Core 2.2 基础知识(十六) SignalR 概述
我一直觉得学习的最好方法就是先让程序能够正常运行,才去学习他的原理,剖析他的细节. 就好像这个图: 所以,我们先跟着官方文档,创建一个 SignalR 应用: https://docs.microso ...
- 【分块】【常数优化】【Orz faebdc】洛谷 P1083 NOIP2012提高组 借教室
分块90分. By AutSky_JadeK [重点在下面] #include<cstdio> #include<cmath> using namespace std; #de ...
- 【矩阵乘法+快速乘】BZOJ2875-[NOI2012]随机数生成器
[题目大意] 已知Xn+1=(aXn+c) mod m,求Xn mod g. [思路] get到了longlong乘法的正确方法,快速乘.什么是快速乘呢? 简单来讲,快速幂就是模拟了二进制的竖式乘法. ...
- 《ggplot2:数据分析与图形艺术》,读书笔记
第1章 简介 1.3图形的语法 第2章从qplot开始入门 1.基本用法:qplot(x,y,data) x是自变量横轴,y是因变量纵轴,data是数据框 2.图形参数 colour=I(&quo ...
- JQuery提示$(...).on is not a function解决方法
版本太低了,引入较高的版本,如jquery-1.8.3.min.js
- PHP线程安全和非线程安全的区别
Windows版的PHP从版本5.2.1开始有Thread Safe.这两者不同在于何处?到底应该用哪种?这里做一个简单的介绍. 从2000年10月20日发布的第一个Windows版的PHP3.0.1 ...
- mq
同时每个 Broker 与NameServer 集群中的所有节点建立长连接,定时注册 Topic 信息到所有 NameServer 中. Producer 与 NameServer 集群中的其中一个节 ...
- Mysql -- Can’t connect to local MySQL server through socket ‘/var/lib/mysql/mysql.sock’解决方法
启动mysql 报错: ERROR 2002 (HY000): Can’t connect to local MySQL server through socket ‘/var/lib/mysql/m ...
- CAP 定理的含义
分布式系统(distributed system)正变得越来越重要,大型网站几乎都是分布式的. 分布式系统的最大难点,就是各个节点的状态如何同步.CAP 定理是这方面的基本定理,也是理解分布式系统的起 ...
- jquery修改ajax的header的字段origin方法,均被浏览器拒绝
一.方法一 $.ajax({ headers: { Origin: "http://targetIP" } }); 二.方法二 $.ajax({ beforeSend: funct ...