“诡异”是我对 JavaScript 异步机制的第一印象,这里的诡异打了双引号,并无任何贬义。javascript
跟不少前端不同,我是一名 Java 的开发者,当我刚开始接触 JavaScript 的时候,我有一种既熟悉又陌生的感受,刚上手以为很熟悉,越了解,越陌生,他们两个的关系就真的如:雷锋和雷峰塔的区别同样。前端
最让我陌生的莫过因而,就是 JavaScript 的异步机制了。java
我带着原来 Java 并发和多线程的经验,尝试去理解的时候,让我摸不着头脑的是:单线程异步,我满脑子的问号,由于个人经验告诉我,单线程怎么可能能够异步嘛!面试
我阅读了不少关于 JavaScript 异步的文章深刻理解后,我脑子里的问号变成了感叹号,实在太巧妙了。但更让我以为很是意外的是,你们好像对 JavaScript 的这种异步机制都习觉得常,好像你们都是“这不是理所固然的吗?”,我内心就想,难道只有我才以为诡异吗?segmentfault
我曾经也想把这种疑惑写下来,但好像写着写着,最后都变成了科普式 what,why,how 的方式来写,我很难形容出这种诡异的感受,后来发现也有很多人有我这样的迷惑,他们大多都来自别的多线程并发的语言体系。浏览器
我意识到或许应该先以初学者的试错的方式来展开这个讨论。bash
因而我决定,把我以前碰到的那种诡异的感受,以及我当时怀疑,并接着探索和发现真相的这种过程,记录下来,这才是我学习的故事嘛。session
因此这篇文章,并非要说明谁优谁劣,而是想让你们了解一下,别的语言体系的人是怎么理解这一过程的。数据结构
首先让我对 JavaScript 的异步产生好奇的第一印象就是 setTimeout了,你们都知道它是用来延时一段时间才执行的做用。多线程
但在相似 Java 这种语言体系里,咱们用的是 sleep,就是把当前程序的线程主动的休眠(阻塞),以达到等待一段时间的做用。
例如,先打印Start
,隔1秒再打印First
,最后在程序结束打印End
。
先打印 Start
隔一秒打印 First
而后立马打印 End
复制代码
在 Java 里面是这么实现的。
public class SleepDemo {
public static void main(String[] args) {
System.out.println("Start");
try {
Thread.sleep(1000); // 整个程序会阻塞在这个位置
System.out.println("First"); // 接下来打印 First
} catch (Exception e) {
System.out.println(e);
}
System.out.println("End"); // 紧接着就会打印 End
}
}
复制代码
然而我刚开始使用 JavaScript 的时候,当我想相似的功能时,我发现竟然没有 sleep,它只有一个叫 setTimeout()
的相似东西。
因而我写下这一段。
console.log('Start');
setTimeout(() => console.log('First'), 1000);
console.log('End');
复制代码
我就遇到了不少人刚开始学 JavaScript 遇到的经典问题了。
Start
End
First
复制代码
根据个人经验,我很快就改正过来了:
console.log('Start');
setTimeout(() => {
console.log('First');
console.log('End');
}, 1000);
复制代码
个人第一直觉是,setTimeout
不就是 Java 里面的 Timer
嘛。
Java 的话,大概就是这个感受:
import java.util.Timer;
import java.util.TimerTask;
public class TimerDemo {
public static void main(String[] args) {
System.out.println("Start");
Timer timer = new Timer();
timer.schedule(new TimerTask(){ // Timer 实际是开启了一个线程
@Override
public void run() {
System.out.println("First");
System.out.println("End");
}
}, 1000);
}
}
复制代码
好了,基于我对Timer
的理解和对setTimeout
的猜测,我对 JavaScript 的异步机制的初步假设是:
JavaScript 的单线程异步:实际上就是把多线程的实现隐藏起来了,它的异步仍是仍是经过多线程实现的
就如 Java 虽然说无指针,但仍是到处用到了指针一个道理同样。
按照这个假设,由于setTimeout
是在另一个线程里面执行的,那么,若是主线程被阻塞了,应该也不会影响其余线程的代码。
为了证实这个假设,我开始作一些小实验。
因为启动一个线程,确定会耗费必定时间,因此我须要在启动一个新线程以后,在主线程上增长一些阻塞,这样才能够把 First
出如今 End
前面,为了更直观的看到前后顺序,我增长了时间间隔。
例如在 Java,就是这样实现的:
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
public class BlockDemo {
public static void main(String[] args) {
long startTime = new Date().getTime();
System.out.println("Start");
Timer timer = new Timer();
timer.schedule(new TimerTask(){
@Override
public void run() {
System.out.println("First: " + (new Date().getTime() - startTime));
}
}, 0);
for (long i = 0; i < 10e8; i++) {}
System.out.println("End: " + (new Date().getTime() - startTime));
}
}
复制代码
程序运行结果:
Start
First: 1
End: 1267
复制代码
Java 代码的运行结果是符合我预期的,因而我也尝试了如下的 JavaScript 代码:
const startTime = new Date().getTime();
console.log('Start');
setTimeout(() => {
console.log('First: ' + (new Date().getTime() - startTime));
}, 0); // 我这里设成了0,为的就是让 First 出如今 End 前面
for (let i = 0; i < 10e8; i++) {} // 这里只会小小的阻塞一下主线程
console.log('End: ' + (new Date().getTime() - startTime));
复制代码
若是按照个人假设,主线程被阻塞一小段时间,而setTimeout
并不会被影响到。
但结果竟然是这样的:
Start
End: 827
First: 829
复制代码
我一开始以为,多是多线程并行不肯定性引发的,可是,我尝试了不少次,并且把阻塞运算量再调大不少倍,除了后面的时间数值变化,打印的顺序却丝毫没有改变,我懵逼了。
经过上述实验,我有理由能够相信:End
必定会出如今 First
以后。
个人假设错了,所以我有必要深挖一下 JavaScript 异步机制的秘密到底是什么?
网上有很是多的 JavaScript 的运行原理的文章,我是经过 SessionStack 整个系列加上本身的总结来理解的。
例如:他们有一张图基本囊括了浏览器环境的 JavaScript Runtime
setTimeout
的机制竟然是运行在 JavaScript 引擎之外的东西,它是属于整个
JavaScript Runtime
整套体系下面,不只仅是 JavaScript 引擎中。
整个浏览器的 JavaScript Runtime
分为:
DOM
的操做,AJAX
,Timeout
等实际上调用的都是这里提供的Web APIs
里面的回调函数,实际上都是放在这里排队的Call Stack
是JavaScript Engine
中最重要的一个环节,贯穿着整个函数调用的过程,但Call Stack
自己不是什么新鲜东西,它原本就是一种很常见的数据结构,也是程序运行基本原理的重要组成部分。我这里就再也不赘述它的基本概念了,你们能够看 MDN 里面的更详细的解释 Call Stack。
我这里关注的是,JavaScript 之因此称之为单线程,最重要的就是它只有一个Call Stack
,是的,像 Java 这类语言中,多线程就会有多个Call Stack
。
整个 JavaScript 都是围绕单个Call Stack
来展开。
让我很是意外的是,原来setTimeout
等之类的全部异步的操做其实都归属于Web APIs
里面。
JavaScript 的异步机制之因此巧妙,就是JavaScript Engine
是单线程的,可是JavaScript Runtime
就是浏览器并非单线程的,全部异步的操做的确是经过浏览器在别的线程完成,可是Callback
自己仍是在Call Stack
中完成的。
简单理解,Callback Queue
就是全部 Web APIs
在别的线程里面执行完以后,放到这里排队的一个队列。
例如,setTimeout
的时间计算是在别的线程完成的,等到时间到了以后,就把这个callback
放在队列里面排队。
终于要来到大名鼎鼎的EventLoop
,在前端的 EventLoop
的地位已经差很少和 Java中GC
同样成为各种面试必考知识点。
EventLoop
的做用就是,经过一个循环,不断的把Callback Queue
里面的callback
压进了JavaScript Engine
中的Call Stack
中执行。
因为 JavaScript Engine
只有单线程,因此一旦里面的函数发生了阻塞,运算量过大,就会堵塞,后面的全部操做都会等待。
怎么理解这张图呢?我用了geekartt
做者给出的一个案例(我做了一些优化):
这个银行,只有一个柜员,而这个柜员就是 JavaScript引擎
,用户要办理的事项就是程序代码,柜员她会把这些事项分解为一个个任务小纸条(函数
),插入一个插纸针(Call Stack
)上,而后她先把最上面的那个取出来处理,若是这个任务须要外部协做,她会立马发送给外部处理(Web APIs
),外部处理完后,会把要反馈的任务,自动投进一个排队的箱子(Callback Queue
)里面,而后,这个箱子会不断的检测柜员的插纸针(EventLoop
),一旦发现插纸针是空的,它就立马把箱子里的任务小纸条,插到柜员的插纸针上。
插纸针(Call Stack
),长这个样,最早插入的纸条最后才处理,典型的先进后出:
这里我用了SessionStack
里面setTimeout
中的例子:
console.log('Hi');
setTimeout(function cb1() {
console.log('cb1');
}, 5000);
console.log('Bye');
复制代码
我如今能够这么理解了:
console.log('Hi')
压入栈console.log('Hi)
拿出来执行Hi
,弹出栈setTimeout
压入栈setTimeout
拿出来执行,这里的执行只是,调用了Web APIs
,并把回调cb1
和时间参数5000
传递过去,而后弹出栈Web APIs
立马开启了一个定时器,并开始计时console.log('Bye')
压入栈内console.log('Bye')
拿出来执行Bye
,弹出栈Web APIs
把cb1
放到队列里面排队EventLoop
在下一次循环立马就把cb1
压入了栈内cb1
分解,把里面的console.log('cb1')
也压入栈内console.log('cb1')
cb1
,弹出栈cb1
,弹出栈下面有个更直观的图:
Web APIs
中的操做都是别的浏览器线程完成的,为了证实这一点,我从新作一次验证。
我仍是回到刚刚的例子里面,增长了时间输出,证实:定时器的运算是否是与for
循环是同一时间执行的
const startTime = new Date().getTime();
console.log('Start');
setTimeout(() => {
console.log('First: ' + (new Date().getTime() - startTime));
}, 1000); // 能够在这里尝试0和1000或者其余时间的区别
for (let i = 0; i < 10e8; i++) {}
console.log('End: ' + (new Date().getTime() - startTime));
复制代码
若是setTimeout
的时间参数为0,在个人PC下是这样的,因此我预计在个人PC下,for
循环的运算时间大概是800毫秒,因此下面的时间要大于800毫秒便可。
Start
End: 827
First: 829
复制代码
若是setTimeout
的时间参数为1000,大概是这样的:
Start
End: 821
First: 1008
复制代码
程序结果是符合预期的。
这个时候再来回到Why
,就是为何 JavaScript 要选用这么一种方法来实现异步?
GUI
的开发领域(例如:Android,GTK,QT),单线程 + 消息队列是很是常见的作法,而 JavaScript 就是为了GUI
开发而诞生的,只是它运行在浏览器上worker
能够操做多线程,可是本质并无改变Call Stack
,这些都是有成本的,在计算资源匮乏的那个年代,JavaScript 的单线程或许能更好的节省计算机的资源的确最开始的 JavaScript 是处理一些很简单的表单,可是如今 JavaScript 已经诞生了二十多年了,如今依然还有很是强劲的生命力,证实 JavaScript 的机制在必定程度上它是很是优秀的。
回到了刚刚第一个错误的假设:实际上 JavaScript 的单线程异步机制,就是把多线程的实现隐藏起来了,它的异步仍是仍是经过多线程实现的
如今看起来,这个结论有一部分是正确的,由于的确,Web APIs
是在别的线程中完成的。
但事情已经很不同了,我是按照之前 Java 多线程并发的经验去理解的,而如今我真正的理解了 JavaScript 异步机制以后,对个人冲击仍是挺大的。
这种感受就像是:曾经你觉得全世界的豆花都是甜的,也理应是甜的,然而忽然有一天,有人告诉你,豆花也有咸的。可是冲击个人不是,甜仍是咸,而是我原来对豆花的概念从新认识。
我曾经是把异步和多线程划上了等号,这是我原来的固有认识。
How JavaScript works: an overview of the engine, the runtime, and the call stack