怎么理解JavaScript异步机制的"诡异"

1,463 阅读12分钟

前言

“诡异”是我对 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 的操作,AJAXTimeout 等实际上调用的都是这里提供的
  • Callback Queue,回调的队列,也就是刚刚所有的 Web APIs 里面的回调函数,实际上都是放在这里排队的
  • EventLoop,事件循环,可以说它是整个 JavaScript 运行的核心

Call Stack 调用栈

Call StackJavaScript 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');

我现在可以这么理解了:

  1. 先把console.log('Hi')压入栈
  2. 引擎把栈内的console.log('Hi)拿出来执行
  3. 打印Hi,弹出栈
  4. 接下来把setTimeout压入栈
  5. 引擎把栈内的setTimeout拿出来执行,这里的执行只是,调用了Web APIs,并把回调cb1和时间参数5000传递过去,然后弹出栈
  6. Web APIs立马开启了一个定时器,并开始计时
  7. 计时的同时,把console.log('Bye')压入栈内
  8. 引擎把栈内的console.log('Bye')拿出来执行
  9. 打印Bye,弹出栈
  10. 定时器还没结束,因此一直在等待,等到时间够了,Web APIscb1放到队列里面排队
  11. 由于队列里面只有一个,而且栈内现在也是空的,EventLoop在下一次循环立马就把cb1压入了栈内
  12. 接下引擎把cb1分解,把里面的console.log('cb1')也压入栈内
  13. 引擎根据先进后出的顺序,先处理console.log('cb1')
  14. 打印cb1,弹出栈
  15. 处理回调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 要选用这么一种方法来实现异步?

  1. 实际上在GUI的开发领域(例如:Android,GTK,QT),单线程 + 消息队列是非常常见的做法,而 JavaScript 就是为了GUI开发而诞生的,只是它运行在浏览器上
  2. 为了更简单更易用,JavaScript 把单线程贯彻到这个语言解释层面上,避免了很多因为多线程并发中产生的锁问题,虽然现在有worker可以操作多线程,但是本质并没有改变
  3. 上面说过,线程是有独立的Call Stack,这些都是有成本的,在计算资源匮乏的那个年代,JavaScript 的单线程或许能更好的节省计算机的资源

的确最开始的 JavaScript 是处理一些很简单的表单,但是现在 JavaScript 已经诞生了二十多年了,现在依然还有非常强劲的生命力,证明 JavaScript 的机制在一定程度上它是非常优秀的。

总结

回到了刚刚第一个错误的假设:实际上 JavaScript 的单线程异步机制,就是把多线程的实现隐藏起来了,它的异步还是还是通过多线程实现的

现在看起来,这个结论有一部分是正确的,因为的确,Web APIs 是在别的线程中完成的。

但事情已经很不一样了,我是按照以前 Java 多线程并发的经验去理解的,而现在我真正的理解了 JavaScript 异步机制之后,对我的冲击还是挺大的。

这种感觉就像是:曾经你以为全世界的豆花都是甜的,也理应是甜的,然而突然有一天,有人告诉你,豆花也有咸的。但是冲击我的不是,甜还是咸,而是我原来对豆花的概念重新认识。

我曾经是把异步和多线程划上了等号,这是我原来的固有认识。

参考文献:

探秘JS的异步单线程

GUI为什么不设计为多线程

How JavaScript works: an overview of the engine, the runtime, and the call stack

How JavaScript works: Event loop and the rise of Async programming + 5 ways to better coding with async/await