异步和事件循环
JavaScript是一种同步的、阻塞的、单线程的语言。
在开启文章的主题前,我必须要说一下并行和并发这两个概念。
并行和并发
并行 Parallel
- do multiple things at once. (在同一时刻做很多操作)
对于 JavaScript 语言来说,并行可谓"只能远观而不可亵玩焉。。。" 为什么这么说呢,一般地,并行都是伴随着多线程的(或者说多线程是实现并行的最有效方法)。
并发 Concurrent
- do multiple things, not at once. (做很多操作但不在同一时刻)
如图:
异步 Asynchronous
有了以上的知识点,我想开始谈一下异步,因为异步在某种情况下能够体现并行和并发。
异步其实是一种描述语言(编程方法)。不过在说异步之前,还是要说一下同步(Synchronous),因为JS是单线程的,主要的代码在主线程(Main thread) 中运行,而同步的最大特点就是必须要等待上一个“任务”完成才能继续执行下一个“任务”,而这等待就意味着阻塞。
看以下代码:
//Synchronous
console.log("script start");
let before = new Date();
function syncRequest1(){
console.log("request1 send!");
// some computation 模拟阻塞,需要运行1s
let bf = new Date();
while(true){
if(new Date() - bf === 1000){
console.log("response1!");
break;
}
}
}
function syncRequest2(){
console.log("request2 send!");
let bf = new Date();
// some computation 模拟阻塞,需要运行2s
while(true){
if(new Date() - bf === 2000){
console.log("response2!");
console.log("同步代码耗时"+ (new Date() - before) + "ms");
break;
}
}
}
syncRequest1();
syncRequest2();
console.log("script end");
运行结果:
显然地, syncRequest2()
必须得等到 syncRequest1()
执行完毕后才能继续执行,而这些单纯的单线程任务完全可以利用一个带有 Call Stack
, Heap
的 执行模型
就可以完成,似乎还不需要队列,看着是这样的。
代码从开头运行,遇到 syncRequest1
就进栈,然后必须要等到函数执行完毕,才能出栈,继续调用 syncRequest2
函数。
这种阻塞的模式显然是不明智的,因此我要开始说异步这个概念了。还是以代码为开篇:
//Asynchronous
console.log("script start");
let before = new Date();
function asyncRequest1(){
console.log("request1 send");
//compute
setTimeout(() => {
console.log("response1!");
},1000); // 模拟运行,需要1s
}
function asyncRequest2(){
console.log("request2 send");
setTimeout(() => {
console.log("response2!");
console.log("耗时"+ (new Date() - before) + "ms");
},2000); // 模拟运行,需要2s
}
asyncRequest1();
asyncRequest2();
console.log("script end");
让我们来看下结果!
居然只是耗时 2000ms
! 而不是 1000+2000=3000ms
,这是为什么呢?原因很简单,就是异步。
看一下定义:异步指的是让 CPU 暂时搁置当前请求的响应,处理下一个请求,当通过轮询或其他方式得到回调通知后,开始运行。
意思是异步不会阻塞,异步任务(在这里就是setTimeout()
的回调函数)会“挂”起来,其他主线程的任务继续执行,而异步任务什么时候完成对于当前主线程的其他任务来说不可见,至于什么时候完成,肯定是在未来的某个时刻完成。说实话,这些其实还是很抽象,直接来图吧:
在这里我不会先说代码的执行顺序问题,因为这会关系到后面要说的事件循环(JS的执行机制),因此我要先说的是代码中关键的 setTimeout()
函数。
setTimeout()
来看一下!
//同步
(function(){
let before = new Date();
//模拟一些阻塞计算
console.log("开始计算,阻塞中!!!");
while(true){
if(new Date() - before === 2000){
console.log("计算完了!");
break;
}
}
console.log("上面终于执行完了,该我了!");
})();
//运行结果
// 开始计算,阻塞中!!!
// 计算完了! (2s后)
// 上面终于执行完了,该我了!
//异步
(function(){
//setTimeout设置一个1s后打印 "1000ms later..."的任务
console.log("开始计算!");
setTimeout(() => {
let before = new Date();
//将阻塞代码放到这里
while(true){
if(new Date() - before === 2000){
console.log("计算完了!");
break;
}
}
},0);
console.log("hhh,运行顺畅无阻!")
})();
//运行结果
// 开始计算!
// hhh,运行顺畅无阻!
// 计算完了! (2s后)
这段代码的关键是为什么 setTimeout()
中的任务不会立即执行而导致主线程阻塞,因为 setTimeout()
函数本身就被设计是异步的,这是很关键的;因此,这些异步是怎么样实现的的?
我们都知道,JavaScript是单线程、同步、阻塞的语言,一次只能执行一个操作;但是对我们理解很重要的是浏览器不是单线程的,浏览器是多线程的!!Web浏览器定义了一些函数和API,这些函数和API就是一些其他线程,使得允许我们当某些事件发生时不是按照同步方式,而是异步地调用函数。
因此,浏览器的多线程赋予了setTimeout()
(或者XMLHTTPRequest, DOM事件)的异步特性,这些都是一些对应的线程被挂起(异步特性),等待被推到消息队列(事件循环模型)。
所以说,setTimeout()
的定时器计算并不是主线程在计算,而是浏览器的计时器线程在计算。这种感觉是超纲了,是浏览器的实现问题,接下来我不会继续说这个,是时候将注意力放在 执行循序
上了。
事件循环 Event loop
当加入了异步任务后,之前的那个同步模型(上面那个栈、堆)明显解决不了,因此浏览器JS引擎实现了事件循环这个模型去负责执行代码、收集和处理事件以及执行队列中的子任务。
在这里我推荐看这个视频:What the heck is the event loop anyway? 来学习事件循环。
关于异步代码何时推到调用栈上,检查调用栈是否为空这些操作在原本的同步模型已经管理不了,所以浏览器添加了消息队列(微任务和宏任务队列)这种数据结构来管理,从而构成了所谓的事件循环。
如图:
总体上:
JS分为同步任务和异步任务
同步任务都在主线程上执行,形成一个
执行栈
一旦
执行栈
中的所有同步任务执行完毕(此时JS引擎空闲),系统就会读取任务队列
,将可运行的异步任务添加到可执行栈中,开始执行
任务分类
宏任务 Macrotask (task)
script
setTimeout()
,setInterval()
MessageChannel
postMessage
requestAnimationFrame()
I/O
Ajax
UI rendering
微任务 Microtask (jobs)
Promise.then
queryMicrotask()
MutationObserver()
可以看到浏览器实现的是宏任务,而JavaScript自己实现的则是微任务。
执行循环
这里强烈推荐看Tasks, microtasks, queues and schedules这篇文章,可以对着视频看 Jake Archibald: In The Loop。是同一个作者,贼强。
为此,我特地扒了他那个很直观的程序😂:
{{< snippet >}}
这个程序很直观的将执行步骤展现给我们,无论是宏任务还是微任务,亦或者是同步任务。
把这些总结为一张图:
这就是我对异步和事件循环所有理解了,个人理解有限,见谅。对于关于线程的概念,浏览器中的各种线程,可以查看参考的链接自行阅读。
Reference参考: