原文: Node.js Async Await Tutorial – With Asynchronous JavaScript Examples
当你第一次学习JavaScript时,最难理解的概念之一是该语言的异步处理(asynchronous processing)模式。对于我们大多数人来说,学习异步编程看起来差不多是这样的:
如果你第一次使用async时不是这样,请认为自己是个天才。
尽管它很难学,但如果你想使用JavaScript和Node.js构建网络应用程序和服务器,异步编程是至关重要的,因为JS代码默认是异步的。
异步编程基础知识
那么,究竟什么是异步处理模型,或“非阻塞I/O”模型(如果你是Node.js用户,你可能已经听说过)?
这里有一个很长的,请不要看的描述:在异步处理模型中,当你的应用程序引擎与外部各方(如文件系统或网络)进行交互时,它不会等到从这些各方得到一个结果,才做下一步。相反,它继续进行后续的任务,只有在得到结果的信号后才回到之前的外部各方进行处理。
为了理解Node.js的默认异步处理模型,我们来看看一个假设的圣诞老人工作室。在任何工作开始之前,圣诞老人将不得不阅读来自世界各地的孩子们的每一封可爱的信。
然后,他将弄清所要求的礼物,将物品名称翻译成精灵语,然后将指令传递给我们每个勤劳的精灵,他们有不同的专长:红色的擅长木制玩具,蓝色的擅长填充玩具,绿色的精通做机器人玩具。
今年,由于COVID-19大流行,只有一半的圣诞老人的精灵可以到他的车间来帮忙。不过,因为他很聪明,圣诞老人决定不等每个精灵准备完礼物(也就是同步工作),而是继续翻译和传递他那堆信中的指示。
诸如此类......
当他正准备读另一封信时,红色精灵通知圣诞老人,他已经完成了准备好了第一份礼物。然后,圣诞老人从红色精灵手中接过礼物,并把它放在一边。
然后他继续翻译和传递下一封信。
由于他只需要包装一个预先制作好的飞行机器人,绿精灵可以迅速完成准备工作并将礼物交给圣诞老人。
经过一整天艰苦的异步工作,圣诞老人和精灵们设法完成了所有的礼物准备工作。由于他改进了异步工作模式,尽管受到大流行病的重创,圣诞老人的工作室还是在创纪录的时间内完成了工作。
这就是异步或非阻塞I/O处理模型的基本思想。现在让我们看看它在Node.js中是如何具体完成的。
Node.js的事件循环
你可能听说过Node.js是单线程的。然而,确切地说,只有Node.js中的事件循环是单线程的,它与后台C++工作线程池交互。Node.js的处理模式有四个重要组成部分:
- 事件队列(Event Queue): 在程序中声明的任务,或通过回调 callbacks从处理线程池中返回。(在我们的圣诞老人工作室里,相当于给圣诞老人的那堆信)
- 事件循环(Event Loop): Node.js的主线程,促进事件队列和工人线程池进行操作--包括异步和同步。(这里是圣诞老人 🎅)
- (后台线程池)Background thread pool: 这些线程做任务的实际处理,这可能是I/O阻塞(例如调用和等待外部API的响应)。(这些是我们车间里勤奋的精灵 🧝🧝♀️🧝♂️。)
你可以将这种处理模式可视化,如下所示:
图片来源:c-sharpcorner.com
让我们看看一个实际的代码片段,看看这些代码的作用:
console.log("Hello");
https.get("https://httpstat.us/200", (res) => {
console.log(`API returned status: ${res.statusCode}`);
});
console.log("from the other side");
如果我们执行上面这段代码,我们会在标准输出中得到这个结果:
Hello
from the other side
API returned status: 200
那么Node.js引擎是如何执行上述代码片段的呢?它从调用栈中的三个函数开始:
然后,“Hello” 被打印到控制台,相应的函数调用从堆栈中删除。
然后,对https.get
的函数调用(即对相应的URL进行获取请求)被执行,并被委托给工人线程池,并附加一个回调。
对console.log
的下一个函数调用被执行,from the other side
被打印到控制台。
现在网络调用已经返回了一个响应,然后回调函数的调用将进入回调队列(callback queue)中。请注意,这一步可能发生在紧接着的上一步之前(即 从另一边
得到打印),尽管通常情况下不是这样的。
然后回调被放在我们的调用栈(call stack)中:
然后我们会在控制台(console)看到 “API返回状态:200”,像这样:
通过促进回调队列(callback queue)和调用栈(call stack),Node.js中的事件循环以异步方式有效地执行我们的JavaScript代码。
JavaScript和Node.js的同步编程的发展历史 async/await
现在你对异步执行和Node.js事件循环的内部工作有了很好的理解,让我们深入了解JavaScript中的async/await。我们将看看它是如何工作的,从最初的回调驱动(callback-driven)的实现到最新闪目耀人的async/await关键字。
JavaScript中的回调(Callbacks)
OG处理JavaScript引擎的异步性的方法是通过回调。回调基本上是在同步或I/O阻塞操作结束后执行的函数。
这种模式的一个直接例子是内置的setTimeout
函数,它将在执行回调之前等待一定数量的毫秒。
setTimeout(2000, () => {
console.log("Hello");
});
虽然将回调加到阻塞操作上很方便,但这种模式也带来了一些问题:
- 回调地狱(Callback hell)
- 控制反转(不是好的那种!)。
什么是回调地狱?
让我们再看看圣诞老人和他的精灵们的例子。为了准备一份礼物,圣诞老人的工作室必须进行几个不同的步骤(每个步骤都要用setTimeout
模拟不同的时间):
function translateLetter(letter, callback) {
return setTimeout(2000, () => {
callback(letter.split("").reverse().join(""));
});
}
function assembleToy(instruction, callback) {
return setTimeout(3000, () => {
const toy = instruction.split("").reverse().join("");
if (toy.includes("wooden")) {
return callback(`polished ${toy}`);
} else if (toy.includes("stuffed")) {
return callback(`colorful ${toy}`);
} else if (toy.includes("robotic")) {
return callback(`flying ${toy}`);
}
callback(toy);
});
}
function wrapPresent(toy, callback) {
return setTimeout(1000, () => {
callback(`wrapped ${toy}`);
});
}
这些步骤需要按照特定的顺序进行:
translateLetter("wooden truck", (instruction) => {
assembleToy(instruction, (toy) => {
wrapPresent(toy, console.log);
});
});
// 结果是“wrapped polished wooden truck”
由于我们这样做,在这个过程中增加更多的步骤将意味着把内部的回调推到右边,并最终进入回调地狱,如图所示:
回调看起来是有顺序的,但有时执行的顺序并不遵循你屏幕上显示的内容。有了多层嵌套的回调,你很容易失去对整个程序流程的大局观,产生更多的错误,或者只是在写代码时变得更慢。
那么你如何解决这个问题呢?简单地将嵌套的回调模块化为命名的函数,你将有一个很好的左对齐的程序,很容易阅读。
function assembleCb(toy) {
wrapPresent(toy, console.log);
}
function translateCb(instruction) {
assembleToy(instruction, assembleCb);
}
translateLetter("wooden truck", translateCb);
控制反转(Inversion of Control)
回调模式的另一个问题是,你并没有决定高阶函数如何执行你的回调。他们可能在函数的结尾处执行,这是传统的做法,但他们也可能在函数的开始处执行,或者多次执行。
基本上,你只能任由依赖关系所有者摆布,而且你可能永远不知道他们什么时候会破坏你的代码。
为了解决这个问题,作为一个依赖性用户,你能做的并不多。然而,如果你自己曾经是一个依赖关系的所有者,请一定要:
- 坚持传统的回调签名,将错误作为第一个参数
- 只在高阶函数的末尾执行一次回调
- 记录绝对需要的任何不合常规的东西,并始终以向后兼容为目标
JavaScript中的Promises
Promises 是为了解决上述回调的问题而创建的。Promises确保了JavaScript用户:
- 坚持用他们的签名
resolve
和reject
函数进行特定的约定。 - 将回调函数链接到一个排列整齐的、自上而下的流程。
我们之前关于圣诞老人工作室准备礼物的例子可以用这样的promises来重写:
function translateLetter(letter) {
return new Promise((resolve, reject) => {
setTimeout(2000, () => {
resolve(letter.split("").reverse().join(""));
});
});
}
function assembleToy(instruction) {
return new Promise((resolve, reject) => {
setTimeout(3000, () => {
const toy = instruction.split("").reverse().join("");
if (toy.includes("wooden")) {
return resolve(`polished ${toy}`);
} else if (toy.includes("stuffed")) {
return resolve(`colorful ${toy}`);
} else if (toy.includes("robotic")) {
return resolve(`flying ${toy}`);
}
resolve(toy);
});
});
}
function wrapPresent(toy) {
return new Promise((resolve, reject) => {
setTimeout(1000, () => {
resolve(`wrapped ${toy}`);
});
});
}
顺利地进行了一系列的步骤:
translateLetter("wooden truck")
.then((instruction) => {
return assembleToy(instruction);
})
.then((toy) => {
return wrapPresent(toy);
})
.then(console.log);
// This would produce the exact same present: wrapped polished wooden truck
然而,promises 也不是没有问题的。我们链条中每个节点的数据都有不同的作用域,只能访问从紧邻的前一个步骤或父作用域传递过来的数据。
例如,我们的礼物包装步骤可能想使用翻译步骤的数据:
function wrapPresent(toy, instruction) {
return Promise((resolve, reject) => {
setTimeout(1000, () => {
resolve(`wrapped ${toy} with instruction: "${instruction}`);
});
});
}
这倒是一个典型的 "内存共享 "的线程问题。为了解决这个问题,我们应该使用Promise.all
和"通过通信共享数据,而不是通过共享数据进行通信",而不是使用父级范围内的变量。
translateLetter("wooden truck")
.then((instruction) => {
return Promise.all([assembleToy(instruction), instruction]);
})
.then((toy, instruction) => {
return wrapPresent(toy, instruction);
})
.then(console.log);
// This would produce the present: wrapped polished wooden truck with instruction: "kcurt nedoow"
JavaScript中的Async/Await
最后但绝对不是最不重要的,最靓的仔是async/await。它非常容易使用,但也有一些风险。
Async/await解决了Promise的内存共享问题,把所有的东西都放在同一个范围内。我们之前的例子可以很容易地改写成这样:
(async function main() {
const instruction = await translateLetter("wooden truck");
const toy = await assembleToy(instruction);
const present = await wrapPresent(toy, instruction);
console.log(present);
})();
// This would produce the present: wrapped polished wooden truck with instruction: "kcurt nedoow"
然而,尽管用async/await写异步代码很容易,但也很容易犯错误,造成性能漏洞。
现在让我们把我们的例子圣诞老人工作室的场景本地化,以包装礼物并把它们装到雪橇上。
function wrapPresent(toy) {
return Promise((resolve, reject) => {
setTimeout(5000 * Math.random(), () => {
resolve(`wrapped ${toy}`);
});
});
}
function loadPresents(presents) {
return Promise((resolve, reject) => {
setTimeout(5000, () => {
let itemList = "";
for (let i = 0; i < presents.length; i++) {
itemList += `${i}. ${presents[i]}\n`;
}
});
});
}
你可能犯的一个常见错误是这样执行步骤:
(async function main() {
const presents = [];
presents.push(await wrapPresent("wooden truck"));
presents.push(await wrapPresent("flying robot"));
presents.push(await wrapPresent("stuffed elephant"));
const itemList = await loadPresents(presents);
console.log(itemList);
})();
但是,圣诞老人是否需要 等待
每件礼物被逐一包装好后再装车?当然不需要。礼物应该是同时包装的。你可能会经常犯这个错误,因为你很容易写出await
,而没有考虑到这个关键字的阻塞性(blocking nature)。
为了解决这个问题,我们应该把礼物包装的步骤捆绑在一起,一次性执行:
(async function main() {
const presents = await Promise.all([
wrapPresent("wooden truck"),
wrapPresent("flying robot"),
wrapPresent("stuffed elephant"),
]);
const itemList = await loadPresents(presents);
console.log(itemList);
})();
以下是一些建议的步骤,以解决你的Node.js代码中的并发性能问题:
- 在你的代码中找出有多个连续等待的hotspots
- 检查它们是否相互依赖(即一个函数使用另一个函数返回的数据)
- 用
Promise.all
使独立的函数调用同时进行
打包(文章,而不是圣诞礼物😂)
祝贺你读到了本文的结尾,我已经尽力让这篇文章变得更短。这篇文章,但JavaScript中的异步话题实在是太广泛了。
下面是一些关键的收获:
- 将你的JavaScript回调模块化以避免回调地狱
- 坚持使用JS回调的惯例
- 在使用promises的时候,通过
Promise.all
来共享数据 - 要注意async/await代码的性能影响
We ❤️ JavaScript :)