65.9K
CodeProject 正在变化。 阅读更多。
Home

JavaScript:异步编程的同步解释。

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (16投票s)

2016年4月10日

CPOL

7分钟阅读

viewsIcon

43138

一个关于运行时实际发生情况的枯燥演讲……

如果你曾经问过 JavaScript 的定义……你会得到这样的答案

JavaScript 是一种单线程、非阻塞、异步、并发语言!

真的吗!……太棒了!

并发、异步、线程、并行编程以及许多这类术语……它们是一团糟。尽管你可以轻松使用其中一个术语来解释你的想法,但也很容易在正确的地方迷失使用正确的术语,即使是经验丰富的开发者也可能在选择正确的词语时不够准确。我在这里试图做的是更深入地理解这些术语之间的区别……重点是 JavaScript 的异步编程。

 

在异步之前……首先什么是同步代码?!!

如果你用任何流行的编程语言(如 C、Java、C#、Python)写过任何(Hello World)程序,这就是同步代码,代码按编写的顺序逐行执行。

code

Console.log('First Line');
Console.log('Second Line');
Console.log('Third Line');

输出

First Line
Second Line
Third Line

是的,正如预期的那样,你得到了这个结果……这就是同步代码。

但在继续异步代码之前,让我们更详细地了解一下 JavaScript 运行时对这些代码的实际处理。

JavaScript 运行时引擎

从代码执行的角度来看,我们关注的是任何 JavaScript 引擎(例如 V8,Google 的开源 JavaScript 引擎,用于 Chrome 和 Chromium 项目)的一个重要组件——调用栈。简单来说,调用栈是一种数据结构,V8 在其中存储有关当前正在执行的函数的信息,以跟踪代码的执行流程。

让我们分析一下这样的代码

function sayHello(name){
    console.log('Hello, ' + name);
}

function greeting(name){
    return sayHello(name);
}

greeting ('Adam');

  1. 最初,JavaScript 运行时会将一个堆栈帧(比如 main)推入栈顶,它代表脚本本身或当前代码块。
  2. 声明 sayHello 函数,这里什么都没有真正执行。
  3. 声明 greeting 函数。
  4. 通过运行第 9 行,JavaScript 运行时将 greeting 的地址推入栈顶执行。
  5. greeting 内部,我们调用了 sayHello 函数,这意味着 JavaScript 必须将 sayHello 的地址也推入调用栈
  6. 再次,在 sayHello 函数内部,JavaScript 运行时发现了对 console.log 函数的调用,因此它也将它推入调用栈执行。
  7. console.log 函数返回后,JavaScript 运行时会弹出 console.log 的堆栈帧,并返回到调用栈中的上一个地址,即 sayHello
  8. JavaScript 运行时完成 sayHello 函数的执行,并将其堆栈帧从调用栈中弹出。
  9. 弹出 greeting 的堆栈帧。
  10. 弹出 main 的堆栈帧。

 

那么,异步代码呢?为什么?

通过像前面例子一样简单的几行代码,我们无法发现执行此代码的任何问题,程序将顺利运行并完成其工作,但如果我们有一些慢/阻塞的代码行呢?

诸如访问文件、数据库或从网络读取等操作被认为非常慢,并且由于 JavaScript 在浏览器中运行的特性,慢速操作(例如 HTTP 请求)可能会冻结您的网页,因此被认为是阻塞代码。

这就是异步部分。因为 JavaScript 在浏览器中运行,所以它不能执行任何阻塞代码,它不应该等待并冻结网页,否则用户在浏览简单网页时也会获得糟糕的体验。因此,JavaScript 在设计上是非阻塞的,这意味着它不会冻结执行并等待阻塞的代码行,而是使用事件和异步回调技术。

事件和异步回调

在这种方法中,异步回调提供了一种避免阻塞代码的解决方案。回调是一个函数,当事件发生时,它会被注册以供执行。JavaScript 不会等待阻塞代码完成,而是继续执行下一行,并在事件触发时返回到回调。

一个伪回调会看起来像

httpRequest('http://www.google.com/',function myCallBack(){
    console.log('Hey!!');
});  

这意味着在完成 httpRequest (阻塞代码)后,将执行 myCallBack 函数(回调)。

简单吧……好的?!

好的,但现在我可以看到 JavaScript 可以同时做多件事情了,它可以执行主线程代码,也可以同时处理事件。这难道不是一种并发或类似的东西吗?!你不是说 JavaScript 是单线程的,而且它一次只能做一件事吗?!!

深入一步……

好吧,在回答这个问题之前,是时候再次深入了解 JavaScript 环境了。请允许我向您介绍新朋友……欢迎事件循环、消息队列和WebAPI

如果我们想象 JavaScript 运行时环境,它会是这样的


 

-WebAPI:它是浏览器负责外部 API、DOM API 以及大多数阻塞 I/O 代码(如访问文件、网络请求或计时器(setTimeout))的部分。它的工作是在浏览器触发特定事件时,取回注册的事件回调。

-消息队列:它是一个简单的队列(FIFO),来自WebAPI的回调会按顺序进入并存储在此。

-事件循环:它的工作是监视调用栈,如果为空,则将消息队列中的下一个任务推入调用栈以供执行。

这可能对您来说是新知识,像访问文件或网络这样的阻塞操作并不是 JavaScript 运行时(V8)本身的一部分,即使是 setTimeout,它也来自托管环境,即 Web 浏览器(或 NodeJS),浏览器比 JavaScript 运行时引擎的功能要多得多。

对于这样的代码

console.log('first');

setTimeout(function foo(){
    console.log('second');
}, 1000);

console.log('third');

输出

first
third
second

当我们将函数 foo 注册为在 1000 毫秒后运行,就会发生这种情况。因此,运行时会运行第一行,然后是第三行,1000 毫秒后,它会返回执行 setTimeout 回调,这就是在调用栈消息队列中正在发生的事情。

在 setTimeout 的情况下,WebAPI 设置一个 1000 毫秒的计时器,并等待这段时间被触发。一旦被触发,WebAPI 会将计时器的回调函数 foo 排队到消息队列中。

消息队列是事件的回调按顺序存储并准备执行的地方。这里就轮到事件循环了。

事件循环不断监视调用栈,当调用栈为空时,事件循环会将消息队列中的顶部任务推入调用栈以供执行。

我们可以通过以下方式模拟此过程


 

  1. 初始堆栈帧,用于开始执行代码。
  2. JavaScript 运行时将 console.log('first') 推入调用栈执行,我们在输出中得到“first”。
  3. 调用 setTimeout 设置一个带有回调函数 foo 和 3000 毫秒间隔的计时器。
  4. WebAPI 创建此计时器并等待 3000 毫秒。
  5. JavaScript 继续运行代码并弹出 setTimeout 的堆栈帧。
  6. JavaScript 进入下一行,并将  console.log("third") 推入调用栈,我们在输出中得到“third”。
  7. JavaScript 弹出顶部堆栈帧。
  8. JavaScript 弹出 main 堆栈帧。
  9. 3000 毫秒后,计时器事件触发。
  10. WebAPI 将计时器回调 foo 排队到消息队列中,以便准备执行。
  11. 事件循环检查调用栈,如果为空,则将 foo 函数推入调用栈
  12. 执行 foo 函数的主体,console.log('second').
  13. 调用栈中弹出 console.log('second')
  14. 弹出 foo 的堆栈帧。

这就是 JavaScript 如何通过事件循环和异步回调,在单线程非阻塞设计中处理并发的方式……

请注意

事件循环仅在调用栈为空时才将新任务推入调用栈,设置 setTimeout(function(){}, 0)(即零延迟情况)并不意味着它会立即触发,它会在它是消息队列中的下一个任务并且调用栈为空时执行,同样 setTimeout(function(){}, 1000) 并不保证在正好 1000 毫秒后执行,但它肯定保证在此之前不会执行。

 

结论

是的,JavaScript 本身是单线程的,它一次只能做一件事,而且它在设计上是非阻塞的,它通过事件循环和异步回调,在浏览器或托管环境的帮助下处理并发。

 

参考资料

 

© . All rights reserved.