你可以使用Queue https://en.wikipedia.org/wiki/Queue_(abstract_data_type)#Examples数据结构作为基础并在子类中添加特殊行为。 AQueue
有两个方法的众所周知的接口enqueue()
(add新项目结束)和dequeue()
(remove第一项)。在你的情况下dequeue()
等待异步任务。
特殊行为:
- 每次新任务(例如
fetch('url')
) gets enqueued, this.dequeue()
被调用。
- What
dequeue()
does:
- 如果队列为空 ➜
return false
(跳出递归)
- 如果队列繁忙 ➜
return false
(上一个任务未完成)
- 否则 ➜ 删除first从队列中取出任务并运行它
- 任务“完成”(成功或有错误)➜ 递归调用
dequeue()
(2.),直到队列为空..
class Queue {
constructor() { this._items = []; }
enqueue(item) { this._items.push(item); }
dequeue() { return this._items.shift(); }
get size() { return this._items.length; }
}
class AutoQueue extends Queue {
constructor() {
super();
this._pendingPromise = false;
}
enqueue(action) {
return new Promise((resolve, reject) => {
super.enqueue({ action, resolve, reject });
this.dequeue();
});
}
async dequeue() {
if (this._pendingPromise) return false;
let item = super.dequeue();
if (!item) return false;
try {
this._pendingPromise = true;
let payload = await item.action(this);
this._pendingPromise = false;
item.resolve(payload);
} catch (e) {
this._pendingPromise = false;
item.reject(e);
} finally {
this.dequeue();
}
return true;
}
}
// Helper function for 'fake' tasks
// Returned Promise is wrapped! (tasks should not run right after initialization)
let _ = ({ ms, ...foo } = {}) => () => new Promise(resolve => setTimeout(resolve, ms, foo));
// ... create some fake tasks
let p1 = _({ ms: 50, url: '❪????❫', data: { w: 1 } });
let p2 = _({ ms: 20, url: '❪????❫', data: { x: 2 } });
let p3 = _({ ms: 70, url: '❪????❫', data: { y: 3 } });
let p4 = _({ ms: 30, url: '❪????❫', data: { z: 4 } });
const aQueue = new AutoQueue();
const start = performance.now();
aQueue.enqueue(p1).then(({ url, data }) => console.log('%s DONE %fms', url, performance.now() - start)); // = 50
aQueue.enqueue(p2).then(({ url, data }) => console.log('%s DONE %fms', url, performance.now() - start)); // 50 + 20 = 70
aQueue.enqueue(p3).then(({ url, data }) => console.log('%s DONE %fms', url, performance.now() - start)); // 70 + 70 = 140
aQueue.enqueue(p4).then(({ url, data }) => console.log('%s DONE %fms', url, performance.now() - start)); // 140 + 30 = 170
互动演示:
完整代码演示:https://codesandbox.io/s/async-queue-ghpqm?file=/src/index.js https://codesandbox.io/s/async-queue-ghpqm?file=/src/index.js您可以在控制台和/或开发工具的“性能”选项卡中尝试并观察结果。这个答案的其余部分是基于它的。
Explain:
enqueue()
返回一个new Promise
,这将是resolved(或拒绝)稍后的某个时刻. This Promise
可用于处理您的响应async
任务 Fn.
enqueue()
实际上push()
an Object
进入队列,即holds任务 Fn 和控制方式对于返回的 Promise。
自从展开的回Promise
瞬间。开始运行,this.dequeue()
每次我们将新任务加入队列时都会被调用。
和一些性能指标() https://developer.mozilla.org/en-US/docs/Web/API/Performance/measure添加到我们的task
,我们得到了队列的良好可视化:
(*.gif animation)
-
1st row是我们的队列实例
- 新入队
tasks
有一个“❚❚等待..”时期(3nd row) (可能< 1ms
如果队列为空`)
- 在某些时候,它会出列并“▶运行..”一段时间(2nd row)
日志输出(控制台.表() http://console.table):
解释:
第一名task
is enqueue()
d at 2.58ms
就在队列初始化之后。
由于我们的队列是空的,所以就像没有❚❚ waiting
(0.04ms
➜ ~40μm
)。
任务运行时间13.88ms
➜ 出队
Class Queue
is 只是一个包装纸对于本地人Array
Fn´s!
您当然可以在一堂课中实现这一点。我只是想表明,您可以从已知的数据结构中构建您想要的内容。有一些充分的理由不使用Array
:
- A
Queue
数据结构由一个定义界面两个公共方法。使用Array
可能会诱使其他人使用本机Array
其上的方法就像.reverse()
,...这会打破定义 https://en.wikipedia.org/wiki/Queue_(abstract_data_type)#Examples.
-
enqueue()
and dequeue()
比push()
and shift()
- 如果您已经有一个已实施的
Queue
类,您可以从中扩展(可重用代码)
- 您可以更换该物品
Array
in class Queue
通过另一个数据结构:A“双向链表 https://adrianmejia.com/data-structures-time-complexity-for-beginners-arrays-hashmaps-linked-lists-stacks-queues-tutorial/#Queue-implemented-with-a-Doubly-Linked-List“这会减少代码复杂度 for Array.shift()
从 O(n) [线性] 到 O(1) [常数]。 (➜ 比原生数组 Fn 更好的时间复杂度!)(➜ 最终演示)
代码限制
This AutoQueue
类不限于async
功能。它处理anything,可以这样称呼await item[MyTask](this)
:
-
let task = queue => {..}
➜ sync功能
-
let task = async queue => {..}
➜ async功能
-
let task = queue => new Promise(resolve => setTimeout(resolve, 100)
➜ new Promise()
注意:我们已经用以下方式调用我们的任务await
, where await
wraps将任务的响应转化为Promise
。
编号 2。(async函数),总是返回一个Promise
就其本身而言,并且await
调用只是包装一个Promise
进入另一个Promise
,效率稍低。
Nr 3. 没问题。返回的承诺将not被包裹await
这是异步函数的执行方式:(source https://exploringjs.com/es2016-es2017/ch_async-functions.html#_async-functions-are-started-synchronously-settled-asynchronously)
- 异步函数的结果始终是 Promise
p
。该 Promise 是在开始执行异步函数时创建的。
- 尸体被处决。执行可以通过返回或抛出永久完成。或者可以通过await暂时结束;在这种情况下,执行通常会稍后继续。
- 承诺
p
被返回。
以下代码演示了其工作原理:
async function asyncFunc() {
console.log('asyncFunc()'); // (A)
return 'abc';
}
asyncFunc().
then(x => console.log(`Resolved: ${x}`)); // (B)
console.log('main'); // (C)
// Output:
// asyncFunc()
// main
// Resolved: abc
您可以依赖以下顺序:
- (A) 行:异步函数同步启动。异步函数的 Promise 通过 return 来解析。
- (C) 行:继续执行。
- 第 (B) 行:Promise 解析通知异步发生。
阅读更多: ”可调用值 https://exploringjs.com/impatient-js/ch_callables.html”
阅读更多: ”异步函数 https://exploringjs.com/es2016-es2017/ch_async-functions.html"
性能限制
Since AutoQueue
仅限于处理one task 在另一个之后,它可能会成为我们应用程序的瓶颈。限制因素有:
-
每次任务:➜ 新产品的频率
enqueue()
d tasks.
-
每个任务的运行时间➜ 阻塞时间
dequeue()
直到任务完成
1.每次任务
这是我们的责任!我们可以得到当前的大小queue
随时:size = queue.size
. Your outer脚本需要一个“故障转移”案例来稳定增长的队列(检查“Stackedwait
次”部分)。
您想避免像这样的“队列溢出”,其中平均值/平均值waitTime
随着时间的推移而增加。
+-------+----------------+----------------+----------------+----------------+
| tasks | enqueueMin(ms) | enqueueMax(ms) | runtimeMin(ms) | runtimeMax(ms) |
| 20 | 0 | 200 | 10 | 30 |
+-------+----------------+----------------+----------------+----------------+
- ➜ Task
20/20
等待195ms
直到执行开始
- ➜ 从我们上一个任务随机入队开始,又需要另一个任务+ ~232ms,直到所有任务都解决了。
2. 每个任务的运行时间
这个就比较难对付了。 (等待一个fetch()
无法改进,需要等待 HTTP 请求完成)。
也许你的fetch()
任务依赖于彼此的响应,较长的运行时间会阻塞其他任务。
但我们可以做一些事情:
-
也许我们可以缓存响应➜ 减少下一次排队的运行时间。
-
也许我们fetch()
来自 CDN 并有一个我们可以使用的替代 URI。在这种情况下我们可以返回一个new Promise
从我们的task
将在下一个之前运行task
is enqueue()
d. (参见“错误处理”):
queue.enqueue(queue => Promise.race(fetch('url1'), fetch('url2')));
-
也许你有某种“长轮询 https://javascript.info/long-polling" 或周期性 ajaxtask
每 x 秒运行一次,无法缓存。即使您无法减少运行时间本身,您也可以记录运行时间,这将为您提供一个近似值。下一次运行的估计。也许可以将长时间运行的任务交换到其他队列实例。
均衡AutoQueue
什么是“高效”Queue
? - 你的第一个想法可能是这样的:
最有效率的Queue
处理大多数tasks
在最短的时间内?
既然我们无法改善我们的task
运行时,我们可以降低等待时间时间?该示例是一个queue
with zero (~0ms
) 任务之间的等待时间。
提示:为了比较我们的下一个示例,我们需要一些base不会改变的统计数据:
+-------+----------------+----------------+------------------+------------------+
| count | random fake runtime for tasks | random enqueue() offset for tasks |
+-------+----------------+----------------+------------------+------------------+
| tasks | runtimeMin(ms) | runtimeMax(ms) | msEnqueueMin(ms) | msEnqueueMax(ms) |
| 200 | 10 | 30 | 0 | 4000 |
+-------+----------------+----------------+------------------+------------------+
Avg. task runtime: ⇒ (10ms + 30ms) / 2 = 20ms
Total time: ⇒ 20ms * 200 = 4000ms ≙ 4s
➜ We expect our queue to be resolved after ~4s
➜ For consistent enqueue() frequency we set msEnqueueMax to 4000
-
AutoQueue
最后完成dequeue()
after ~4.12s
(^^ 请参阅工具提示)。
- Which is
~120ms
longer than our expected 4s
:
提示:每个任务后都有一个小的“日志”块~0.3ms
,我在那里构建/推送一个Object
带有日志标记到全局“数组”console.table()
记录在最后。这解释了200 * 0.3ms = 60ms
..失踪者60ms
未跟踪(您会看到任务之间的小间隙)->0.3ms
/task 用于我们的测试循环,并且可能会因打开开发工具而出现一些延迟,..
我们稍后再讨论这些时间安排。
我们的初始化代码queue
:
const queue = new AutoQueue();
// .. get 200 random Int numbers for our task "fake" runtimes [10-30]ms
let runtimes = Array.from({ length: 200 }, () => rndInt(10, 30));
let i = 0;
let enqueue = queue => {
if (i >= 200) {
return queue; // break out condition
}
i++;
queue
.enqueue(
newTask({ // generate a "fake" task with of a rand. runtime
ms: runtimes[i - 1],
url: _(i)
})
)
.then(payload => {
enqueue(queue);
});
};
enqueue(queue); // start recurion
我们递归地enqueue()
我们的下一个任务,就在上一个任务完成之后。您可能已经注意到analogy to a typical Promise.then()
链子,对吧?
提示:我们不需要Queue
如果我们已经know的顺序和总数tasks
按顺序运行。我们可以使用一个Promise
链并得到相同的结果。
有时我们在脚本开始时并不知道所有后续步骤。
..你可能需要更多灵活性,以及next我们要运行的任务取决于前一个任务的响应task
。 - 也许您的应用程序依赖于 REST API(多个端点),并且您的并发 API 请求数被限制为最多 X 个。我们无法向 API 发送来自您应用程序各处的请求。你甚至不知道下一个请求何时收到enqueue()
d(例如 API 请求由以下条件触发)click()
事件?..
好的,对于下一个示例,我稍微更改了初始化代码:
我们现在入队 200 个任务randomly在[0-4000ms]期间内。 - 公平地说,我们将范围缩小了30ms
(最大任务运行时间)至 [0-3970ms]。现在我们的随机填充队列有机会留在里面4000ms
limit.
我们可以通过开发工具性能登录得到什么:
- Random
enqueue()
leads to a big number of "waiting" tasks.
这是有道理的,因为我们首先将所有任务排入队列~4000ms
,它们必须以某种方式重叠。检查表输出我们可以验证:Maxqueue.size
is 22
当时的任务170/200
已排队。
- Waiting tasks are not evenly distributed. Right after start there are even some idle section.
由于随机性enqueue()
它不太可能得到0ms
我们的第一个任务的偏移量。~20ms
每个任务的运行时间导致stacking随着时间的推移效果。
- We can sort tasks by "wait ms" (see screen): Longest waiting time was
>400ms
.
之间可能存在某种关系queue.size
(柱子:sizeOnAdd
) and wait ms
(参见下一节)。
- Our
AwaitQueue
completed last dequeue()
~4.37s
after its initialization (check tooltip in "performance" tab). An average runtime of 20,786ms / task
(expected: 20ms
) gives us a total runtime of 4157.13ms
(expected: 4000ms
≙ 4s
).
我们仍然有“Log”块和执行程序。我们的测试脚本本身的时间~120ms
. Still ~37ms
更长?从一开始就总结所有闲置的“间隙”,解释了缺失的情况~37ms
回到我们最初的“定义”
最有效率的Queue
处理大多数tasks
在最短的时间内?
假设:除了随机偏移之外,tasks
get enqueue()
d 在前面的示例中,both队列处理了相同的号码 of task
s (平均数相等运行)内同一时间段。既没有waiting排队时间task
也不queue.size
影响总运行时间。两者的效率相同吗?
Since a Queue
就其本质而言,缩小了我们编码的可能性,最好不要使用Queue
如果我们谈论高效的代码(每次任务)。
队列可以帮助我们拉直异步环境中的任务转换为同步模式。这正是我们想要的。 ➜“运行unknown中的任务顺序a row".
如果你发现自己问这样的问题:“如果一个新的task
已排入队列filled排队,我们必须的时间wait对于我们的结果来说,是通过其他人的运行时间来增加的。这样效率就低了!”
那么你就做错了:
- 您可以将彼此没有依赖性(以某种方式)的任务排入队列(逻辑或编程依赖性),或者存在不会增加脚本总运行时间的依赖性。 - 无论如何,我们必须等待其他人。
Stacked wait
times
我们看到了一个高峰wait
的时间461.05ms
在任务运行之前。如果我们能够预测,那不是很好吗?wait
在我们决定将任务排入队列之前,时间是多少?
首先我们分析我们的行为AutoQueue
上课时间较长。
(重新发布屏幕)
我们可以根据什么构建图表console.table()
output:
旁边wait
的时间task
,我们可以看到随机的[10-30ms]runtime
和 3 条曲线,代表当前queue.size
,记录在时间atask
..
- .. is
enqueued()
- ..开始运行。 (
dequeue()
)
- ..任务完成(就在下一个任务之前)
dequeue()
)
另外 2 次运行进行比较(相似趋势):
- 图表运行 2:https://i.stack.imgur.com/NGB3K.png https://i.stack.imgur.com/NGB3K.png
- 图表运行 3:https://i.stack.imgur.com/NaQ39.png https://i.stack.imgur.com/NaQ39.png
我们能找到彼此之间的依赖关系吗?
如果我们能够找到任何记录的图表线之间的关系,它可能会帮助我们理解queue
随着时间的推移而表现(➜不断充满新任务)。
Exkurs:什么是关系?
我们正在寻找一个方程projects the wait ms
curve onto3 之一queue.size
记录。这将证明两者之间的直接依赖关系。
对于上次运行,我们更改了启动参数:
-
任务数:200
➜ 1000
(5x)
-
msEnqueueMax:4000ms
➜ 20000ms
(5x)
+-------+----------------+----------------+------------------+------------------+
| count | random fake runtime for tasks | random enqueue() offset for tasks |
+-------+----------------+----------------+------------------+------------------+
| tasks | runtimeMin(ms) | runtimeMax(ms) | msEnqueueMin(ms) | msEnqueueMax(ms) |
| 1000 | 10 | 30 | 0 | 20000 |
+-------+----------------+----------------+------------------+------------------+
Avg. task runtime: ⇒ (10ms + 30ms) / 2 = 20ms (like before)
Total time: ⇒ 20ms * 1000 = 20000ms ≙ 20s
➜ We expect our queue to be resolved after ~20s
➜ For consistent enqueue() frequency we set msEnqueueMax to 20000
(interactive chart: https://datawrapper.dwcdn.net/p4ZYx/2/ https://datawrapper.dwcdn.net/p4ZYx/2/)
我们看到了同样的趋势。wait ms
随着时间的推移而增加(没什么新鲜的)。自从我们3queue.size
底部的线被绘制到同一个图表中(Y 轴有ms
规模),它们几乎看不见。快速切换到对数刻度以进行更好的比较:
(interactive chart: https://datawrapper.dwcdn.net/lZngg/1/ https://datawrapper.dwcdn.net/lZngg/1/)
两条虚线为queue.size [on start]
and queue.size [on end]
几乎彼此重叠,一旦队列变空,最后就会下降到“0”。
queue.size [on add]
看起来非常相似wait ms
线。这就是我们所需要的。
{queue.size [on add]} * X = {wait ms}
⇔ X = {wait ms} / {queue.size [on add]}
仅此一点在运行时对我们没有帮助,因为wait ms
新排队任务未知(尚未运行)。所以我们还有 2 个未知变量:X
and wait ms
。我们需要另一种关系来帮助我们。
首先,我们打印新的口粮{wait ms} / {queue.size [on add]}
进入图表(浅绿色)及其平均值(浅绿色水平虚线)。这非常接近20ms
(avg. run ms
我们的任务),对吗?
切换回linear
Y 轴并将其“最大比例”设置为80ms
以便更好地了解它。 (暗示:wait ms
现在超出了视口)
(interactive chart: https://datawrapper.dwcdn.net/Tknnr/4/ https://datawrapper.dwcdn.net/Tknnr/4/)
回到我们任务的随机运行时间(点云)。我们仍然有“总平均值”20.72ms
(深绿色水平虚线)。我们还可以计算之前任务的平均值在运行时(例如,任务 370 入队➜ 任务 [1,.., 269] = 平均运行时间的当前平均运行时间是多少)。但我们甚至可以更精确:
我们排队的任务越多,它们对总“平均运行时间”的影响就越小。因此,让我们计算一下“平均运行时间”last例如50tasks
。这导致了一致的影响每个任务的“平均运行时间”为 1/50。 ➜ 峰值运行时间变得直线,并考虑趋势(向上/向下)。 (深绿色水平路径曲线紧邻我们的 1. 方程中的浅绿色)。
我们现在可以做的事情:
-
We can 排除 X
来自我们的第一个方程(浅绿色)。 ➜X
可以用“之前的平均运行时间”来表示n
例如50 个任务(深绿色)。
我们的新方程仅取决于在运行时、入队时已知的变量:
// mean runtime from prev. n tasks:
X = {[taskRun[-50], .. , taskRun[-2], taskRun[-1] ] / n } ms
// .. replace X in 1st equation:
⇒ {wait ms} = {queue.size [on add]} * {[runtime[-50], .. , runtime[-2], runtime[-1] ] / n } ms
-
我们可以在图表中绘制一条新的图表曲线,并检查它与记录的相比有多接近wait ms
(橙子)
(interactive chart: https://datawrapper.dwcdn.net/LFp1d/2/ https://datawrapper.dwcdn.net/LFp1d/2/)
结论
我们可以预测wait
考虑到我们的任务的运行时间可以通过某种方式确定,因此在任务入队之前。因此,它在将相同类型/功能的任务排队的情况下效果最好:
使用案例:一个AutoQueue
实例充满了 UI 组件的渲染任务。渲染时间可能不会对聊天产生太大影响(与fetch()
)。也许您在地图上渲染 1000 个位置标记。每个标记都是一个类的实例,具有render()
Fn.
Tips
-
Queues
用于各种任务。 ➜ 实施专用Queue
不同类型逻辑的类变体(不要在一个类中混合不同的逻辑)
- 选择所有
task
s that might被排队到相同的AutoQueue
例如(现在或将来),它们可能会被阻止所有其他人.
- An
AutoQueue
不会提高运行时间,最多也不会降低。
- 使用不同的
AutoQueue
不同的实例Task
types.
- Monitor the size of your
AutoQueue
, particular ..
- ..在大量使用时(频繁使用
enqueue()
)
- .. on long or unknown
task
运行时间
- Check your error handling. Since errors inside your
tasks
will just reject
their returned promise on enqueue (promise = queue.enqueue(..)
) and will not stop the dequeue process. You can handle errors..
- .. 在你的任务中 ➜ `try{..} catch(e){ .. }
- ..就在它之后(在下一个之前)➜
return new Promise()
- ..“异步”➜
queue.enqueue(..).catch(e => {..})
- ..“全局”➜ 内部的错误处理程序
AutoQueue
class
- 取决于您的实施
Queue
你可能会看queue.size
. An Array
充满了 1000 个任务,其效率不如我在最终代码中使用的“双向链表”这样的去中心化数据结构。
- 避免递归地狱。 (使用就可以了
tasks
that enqueue()
其他) - 但是,调试一个程序并不有趣AutoQueue
where tasks
是动态的enqueue()
e 由其他人在async
环境..
- 乍一看一个
Queue
可能会解决一个problem(在一定的抽象层次上)。然而,在大多数情况下,它会缩小现有的灵活性。它为我们的代码添加了一个额外的“控制层”(在大多数情况下,这是我们想要的),同时,我们签署了一份合同以接受严格的规则Queue
。即使解决了问题,也可能不是最好的解决方案。
添加更多功能[基本]
-
停止“自动dequeue()
" on enqueue()
:自从我们的AutoQueue
类是通用的,不限于长时间运行HTTP requests(),你可以enqueue()
任何必须按顺序运行的函数,甚至3min
运行函数,例如“存储模块的更新”,..您不能保证,当您enqueue()
循环 100 个任务,上一个添加的任务尚未完成dequeued()
.
您可能想阻止enqueue()
从打电话dequeue()
直到全部添加完毕。
enqueue(action, autoDequeue = true) { // new
return new Promise((resolve, reject) => {
super.enqueue({ action, resolve, reject });
if (autoDequeue) this.dequeue(); // new
});
}
..然后打电话queue.dequeue()
在某个时刻手动。
-
控制方法: stop
/ pause
/ start
您可以添加更多控制方法。也许您的应用程序有多个模块,所有这些模块都试图fetch()
页面加载时有资源。一个AutoQueue()
工作原理就像Controller
。您可以监视有多少任务正在“等待..”并添加更多控件:
class AutoQueue extends Queue {
constructor() {
this._stop = false; // new
this._pause = false; // new
}
enqueue(action) { .. }
async dequeue() {
if (this._pendingPromise) return false;
if (this._pause ) return false; // new
if (this._stop) { // new
this._queue = [];
this._stop = false;
return false;
}
let item = super.dequeue();
..
}
stop() { // new
this._stop = true;
}
pause() { // new
this._pause = true;
}
start() { // new
this._stop = false;
this._pause = false;
return await this.dequeue();
}
}
-
转发响应:您可能想要处理一个的“响应/值”task
in the next任务。不保证我们的prev.任务还没有完成,当我们入队时2nd任务。
因此,最好存储上一个的响应。类中的任务并将其转发给下一个:this._payload = await item.action(this._payload)
错误处理
在 a 内抛出错误task
Fn 拒绝返回的承诺enqueue()
并且不会停止出队过程。您可能想在下一步之前处理错误task
开始运行:
queue.enqueue(queue => myTask() ).catch({ .. }); // async error handling
queue.enqueue(queue =>
myTask()
.then(payload=> otherTask(payload)) // .. inner task
.catch(() => { .. }) // sync error handling
);
自从我们的Queue
is dump,并且只是await
为了我们的任务得到解决(item.action(this)
),没有人阻止你返回 a new Promise()
从当前运行的task
Fn。 - 它将在下一个任务出队之前解决。
You can throw new Error()
内部任务 Fn 并在“外部”/运行后处理它们:queue.enqueue(..).catch()
。
您可以轻松地在内部添加自定义错误处理dequeue()
调用的方法this.stop()
清除 ”on hold“(排队的)任务..
您甚至可以从任务函数内部操作队列。查看:await item.action(this)
调用与this
并允许访问Queue
实例。 (这是可选的)。有一些用例task
Fn 应该不能。
添加更多功能[高级]
...达到文本限制:D
more: https://gist.github.com/exodus4d/6f02ed518c5a5494808366291ff1e206 https://gist.github.com/exodus4d/6f02ed518c5a5494808366291ff1e206
阅读更多
- Blog: "带有回调、Promise 和异步的异步递归 https://blog.scottlogic.com/2017/09/14/asynchronous-recursion.html"
- Book: "可调用值 https://exploringjs.com/impatient-js/ch_callables.html"
- Book: "异步函数 https://exploringjs.com/es2016-es2017/ch_async-functions.html"