如果你已经对 Javascript 很熟悉了,那么相信你一定知道它是一门“单线程”语言。
那你知道单线程意味着什么吗?
Javascript 在 V8 引擎上运行,V8 引擎具有内存堆和调用堆栈的特性。
JavaScript 是单线程的,意味着每次只执行一条语句。
在深入探讨在单线程上运行意味着什么之前,我想先解释一下基本的术语。
你首先需要了解一种叫做栈(stack) 的数据结构,它具有后进先出的特性。
同步 (sync) 执行通常是指代码按顺序执行。在同步编程中,程序会逐行执行,一次一行。每次调用函数时,程序执行都会等待,直到这个函数返回(return),然后再继续执行下一行代码。
举个例子,你正在给某人打电话,你需要等他接听之后可以和他们说话。在对方接通电话之前,你不会做任何其他事情。
这应该很好理解。
我们来下看下面的例子:
const one() => { const two() => { console.log('4'); } two(); } one();
在这段代码中,函数调用栈会发生什么?
它会像下面这样:
函数调用栈的工作是填充指令并在指令执行时弹出指令。
Javascript 虽然是一种单线程语言,但也可以是非阻塞的
单线程是指它只有一个函数调用栈,并且这个函数调用栈永远都会先运行栈顶的函数。
在上面的代码中,函数是按顺序运行的。
如果我们有一个功能需要做繁重的工作怎么办?我们应该让用户一直等到这个过程结束吗?
再来看另一个例子:
const one() { console.log("Hello"); } const two () { for(i=0; i<= 100000000000000000000000; i++){ } } const three(){ console.log("World"); } one(); two(); three();
在这个例子中,如果我们的 two 函数必须循环遍历很久的时间,这是否意味着 three 函数必须等到 two() 被执行?从技术上讲,是的!
在我们的例子中,它可能没什么意义,但如果我们必须在实际项目中实现类似的逻辑时,那么在这个过程完成之前用户可能无法做任何事情。
异步 (async) 执行是指不按照它在代码中出现的顺序来实际运行。在异步编程中,程序不会等待当前任务完成,而是可以继续执行下一个任务。
举个例子:你打电话给某人,在等待他接电话的同时,你也可以做点别的事情。
不同的语言有不同的方式来实现异步,最流行的是通过多线程。
Java 就是通过创建一个子线程来实现多线程,这个子线程拥有自己的函数调用栈,可以单独执行,然后再和父线程合并。
但是,这可能会遇到死锁问题,可以通过各种死锁预防机制来处理。
具体的内容不在本文讨论范围内。
我们关心的是在 Javascript 中实现异步,我们来看看它是如何做到的。
运行下面的代码,看看会发生什么。
console.log('1'); setTimeout(()=> { console.log('2') }, 3000); console.log('3');
你会先看到 1 和 3,短暂延迟后,才可以看到 2。为什么会这样?
简而言之,Javascript 中的异步实现是通过函数调用栈、回调队列和 Web API 以及事件循环来完成的。
上面我有讲到,函数调用栈的工作是检查栈顶部的指令并执行它。如果有像 setTimeout() 这样的异步任务需要额外的时间来执行,那么函数调用栈会将它弹出并把它发送到 Web API。
事件循环的工作是不断检查是否发生了某种事件,比如鼠标单击或键盘敲击,然后把它发送给函数调用栈。当然,用户的鼠标单击会比图像加载这种任务具有更高的执行优先级。
在 Javascript 中,所有指令都放在函数调用栈上。当函数调用栈碰到 setTimeout 时,引擎会把它视为 Web API 指令并把它弹出,并将其发送到 Web API。一旦 Web API 完成执行,它会重新被达回调队列。
引擎检查调用堆栈是否为空。如果它是空的,那么我们检查回调队列中包含指令 setTimeout。回调队列把它发送到函数调用栈并执行指令。
具体的流程可以参照下图:
我们再来看另一种情况,当你发送了一个 API 请求时。例如,你的网站需要从服务器获取一张图像。在图像返回之前如果网页不能加载其他内容,那将是一个糟糕的用户体验。
当函数调用栈发现它需要获取图像时,它会把这个函数弹出,并把它发送到 Web API 并继续执行剩下的函数。对图像请求的回调事件会存储在回调队列中。
当函数调用栈为空时,持续运行的事件循环会查看回调队列中是否有任务。
执行这个过程时,JavaScript 不用考虑程序是运行在具有多少个核心的 CPU 上。所以 V8 实现 JavaScript 异步只需要一个调用堆栈就够了。