深入理解JavaScript的异步编程

在编程的世界里,"并发"、"异步"、"线程"、"并行编程"等术语层出不穷,它们既相互关联又各有区别。即使是经验丰富的开发者,也可能在选择合适的术语时出现混淆。本文旨在深入探讨这些术语之间的差异,特别是聚焦于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如何以单线程非阻塞设计处理并发的方式。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485