在编程的世界里,"并发"、"异步"、"线程"、"并行编程"等术语层出不穷,它们既相互关联又各有区别。即使是经验丰富的开发者,也可能在选择合适的术语时出现混淆。本文旨在深入探讨这些术语之间的差异,特别是聚焦于JavaScript的异步编程模型。
在异步编程之前,首先需要理解什么是同步代码。如果曾经使用过任何流行的编程语言(如C、Java、C#、Python)编写过"Hello World"程序,那么就已经接触过同步代码了。同步代码的特点是按照编写的顺序逐行执行。
console.log('\nFirst Line\n');
console.log('\nSecond Line\n');
console.log('\nThird Line\n');
输出结果将是:
First Line
Second Line
Third Line
这就是同步代码的执行方式。
但是,在深入异步代码之前,让先详细了解一下JavaScript运行时是如何工作的。JavaScript运行时引擎(例如V8,Google的开源JavaScript引擎,用于Chrome和Chromium项目)中的一个重要组件是调用栈(Call Stack)。调用栈是一种栈数据结构,V8用它来存储当前正在执行的函数信息,以跟踪代码的执行流程。
function sayHello(name) {
console.log('\nHello, ' + name + '\n');
}
function greeting(name) {
return sayHello(name);
}
greeting('Adam');
在JavaScript运行时,首先会推送一个栈帧(称之为main),代表脚本本身或当前代码块。声明sayHello函数时,实际上并没有执行任何操作。声明greeting函数。运行第9行时,JavaScript运行时将greeting的地址推送到栈顶以供执行。在greeting内部,调用了sayHello函数,这意味着JavaScript必须将sayHello的地址也推送到调用栈中。再次,在sayHello函数内部,JavaScript运行时发现了console.log函数的调用,所以它也将这个函数推送到调用栈中以供执行。
在console.log函数返回后,JavaScript运行时从调用栈中弹出console.log的栈帧,并返回到调用栈中的上一个地址,即sayHello。JavaScript运行时完成执行sayHello函数后,从调用栈中弹出其栈帧。然后弹出greeting的栈帧。最后,弹出main的栈帧。
现在,让来谈谈异步代码以及为什么需要它。使用前面示例中的简单代码,不会发现执行这段代码有任何问题,程序将顺利运行并完成其任务。但是,如果有一些慢速/阻塞代码行呢?访问文件或数据库或从网络读取被认为是非常慢的操作,而且由于JavaScript在浏览器中运行的特性,慢速操作(例如http请求)可能会冻结网页,因此它被认为是阻塞代码。
异步部分就在这里发挥作用。因为JavaScript在浏览器中运行,它不能执行任何阻塞代码,它不应该等待阻塞代码执行完毕并冻结网页,否则用户在尝试浏览即使是简单的网页时也会获得糟糕的体验。所以,JavaScript的设计是非阻塞的,这意味着它不会冻结执行并等待阻塞代码行,而是使用事件和异步回调技术。
在这种方法中,异步回调提供了避免阻塞代码的解决方案。回调是一个注册为事件发生时执行的函数,JavaScript不会等待阻塞代码完成,而是继续执行下一行代码,并在事件发生时返回回调。
一个假的回调可能看起来像这样:
httpRequest('http://www.google.com/', function myCallBack() {
console.log('\nHey!!\n');
});
这意味着在完成httpRequest(阻塞代码)后,myCallBack函数将被执行(回调)。
简单吗?好的!但是,现在可以看到JavaScript可以同时做不止一件事,它可以执行主线程代码,同时也处理事件。这不是某种并发吗?不是说JavaScript是单线程的,一次只能做一件事吗?
在回答这个问题之前,再次深入JavaScript环境。让向介绍一些新朋友……请欢迎Event-Loop、消息队列和WebAPI。
如果想象JavaScript运行时环境,它可能是这样的: - WebAPI:它是浏览器负责外部API、DOM API和大多数阻塞IO代码(如访问文件、网络请求甚至计时器(setTimeout))的部分。它的工作是在浏览器触发特定事件时,获取注册的事件回调。 - 消息队列:它是一个简单的队列(FIFO),WebAPI的回调从这里来,并按顺序存储。 - Event Loop:它的工作是监视调用栈,如果为空,则将消息队列中的下一个任务推送到调用栈中以供执行。
也许这对是新的,阻塞操作如访问文件或网络并不是JavaScript运行时(V8)本身的一部分,甚至setTimeout,它来自托管环境,即Web浏览器(或NodeJS)。浏览器不仅仅是JavaScript运行时引擎。
对于这样的代码:
console.log('\nfirst\n');
setTimeout(function foo() {
console.log('\nsecond\n');
}, 1000);
console.log('\nthird\n');
输出将是:
first
third
second
这是因为注册了函数foo在1000ms后运行。所以运行时运行第一行,然后是第三行,然后在1000ms后它又回来执行setTimeout回调,这里是在调用栈和消息队列中发生的事情。
在setTimeout的情况下,WebAPI设置了一个1000ms的定时器,并等待这段时间被触发。一旦被触发,WebAPI将定时器回调函数foo入队到消息队列中以准备执行。消息队列是事件回调存储的地方,并按顺序准备执行。Event Loop部分就在这里发挥作用。
Event Loop不断监视调用栈,当调用栈为空时,Event Loop将消息队列顶部的任务推送到调用栈中以供执行。
可以通过以下方式模拟这个过程: - 初始栈帧开始执行代码。 - JavaScript运行时将console.log('first')推送到调用栈中以供执行,在输出中得到了"first"。 - 调用setTimeout设置一个回调函数foo和间隔3000ms的定时器。 - WebAPI创建这个定时器并等待3000ms。 - JavaScript继续运行代码并弹出setTimeout的栈帧。 - JavaScript移动到下一行并将console.log("third")推送到调用栈中,在输出中得到了"third"。 - JavaScript弹出顶部栈帧。 - JavaScript弹出主栈帧。 - 3000ms后,定时器事件被触发。 - WebAPI将定时器回调foo入队到消息队列中准备执行。 - Event Loop检查调用栈,如果为空,它将foo函数推送到调用栈中。 - 执行foo函数的主体,console.log('second')。 - 从调用栈中弹出console.log('second')。 - 弹出foo栈帧。
这就是JavaScript如何以单线程非阻塞设计处理并发的方式。