前言
“诡异”是我对 JavaScript 异步机制的第一印象,这里的诡异打了双引号,并没有任何贬义。
跟很多前端不一样,我是一名 Java 的开发者,当我刚开始接触 JavaScript 的时候,我有一种既熟悉又陌生的感觉,刚上手觉得很熟悉,越了解,越陌生,他们两个的关系就真的如:雷锋和雷峰塔的区别一样。
最让我陌生的莫过于是,就是 JavaScript 的异步机制了。
我带着原来 Java 并发和多线程的经验,尝试去理解的时候,让我摸不着头脑的是:单线程异步,我满脑子的问号,因为我的经验告诉我,单线程怎么可能可以异步嘛!
我阅读了很多关于 JavaScript 异步的文章深入理解后,我脑子里的问号变成了感叹号,实在太巧妙了。但更让我觉得非常意外的是,大家好像对 JavaScript 的这种异步机制都习以为常,好像大家都是“这不是理所当然的吗?”,我心里就想,难道只有我才觉得诡异吗?
我曾经也想把这种疑惑写下来,但好像写着写着,最后都变成了科普式 what,why,how 的方式来写,我很难形容出这种诡异的感觉,后来发现也有不少人有我这样的迷惑,他们大多都来自别的多线程并发的语言体系。
我意识到或许应该先以初学者的试错的方式来展开这个讨论。
于是我决定,把我之前碰到的那种诡异的感觉,以及我当时怀疑,并接着探索和发现真相的这种过程,记录下来,这才是我学习的故事嘛。
所以这篇文章,并不是要说明谁优谁劣,而是想让大家了解一下,别的语言体系的人是怎么理解这一过程的。
sleep 和 setTimeout
首先让我对 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);
}
}
假设:setTimeout 是在别的线程完成的
好了,基于我对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 Runtime
网上有非常多的 JavaScript 的运行原理的文章,我是通过 SessionStack 整个系列加上自己的总结来理解的。
例如:他们有一张图基本囊括了浏览器环境的 JavaScript Runtime
setTimeout的机制居然是运行在 JavaScript 引擎以外的东西,它是属于整个 JavaScript Runtime 整套体系下面,不仅仅是 JavaScript 引擎中。
整个浏览器的 JavaScript Runtime 分为:
- JavaScript Engine,Chrome 的引擎就是 V8
- Web APIs,
DOM的操作,AJAX,Timeout等实际上调用的都是这里提供的 - Callback Queue,回调的队列,也就是刚刚所有的
Web APIs里面的回调函数,实际上都是放在这里排队的 - EventLoop,事件循环,可以说它是整个 JavaScript 运行的核心
Call Stack 调用栈
Call Stack是JavaScript Engine中最重要的一个环节,贯穿着整个函数调用的过程,但Call Stack本身不是什么新鲜东西,它本来就是一种很常见的数据结构,也是程序运行基本原理的重要组成部分。我这里就不再赘述它的基本概念了,大家可以看 MDN 里面的更详细的解释 Call Stack。
我这里关注的是,JavaScript 之所以称之为单线程,最重要的就是它只有一个Call Stack,是的,像 Java 这类语言中,多线程就会有多个Call Stack。
整个 JavaScript 都是围绕单个Call Stack来展开。
Web APIs
让我非常意外的是,原来setTimeout等之类的所有异步的操作其实都归属于Web APIs里面。
JavaScript 的异步机制之所以巧妙,就是JavaScript Engine是单线程的,但是JavaScript Runtime就是浏览器并不是单线程的,所有异步的操作的确是通过浏览器在别的线程完成,但是Callback本身还是在Call Stack中完成的。
Callback Queue 回调函数的队列
简单理解,Callback Queue 就是所有 Web APIs 在别的线程里面执行完之后,放到这里排队的一个队列。
例如,setTimeout的时间计算是在别的线程完成的,等到时间到了之后,就把这个callback放在队列里面排队。
EventLoop 事件循环
终于要来到大名鼎鼎的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 是并发的
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
这个时候再来回到Why,就是为什么 JavaScript 要选用这么一种方法来实现异步?
- 实际上在
GUI的开发领域(例如:Android,GTK,QT),单线程 + 消息队列是非常常见的做法,而 JavaScript 就是为了GUI开发而诞生的,只是它运行在浏览器上 - 为了更简单更易用,JavaScript 把单线程贯彻到这个语言解释层面上,避免了很多因为多线程并发中产生的锁问题,虽然现在有
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