最令开发者不快的东西,莫过于一个难以理解的 黑盒 了,JavaScript 提供的这一套协程解决方案就是这么一个东西,其中的 async function 从语法上类似 generator,却返回一个 Promise。我们还知道 co/generator、thunk 这些东西,这让我更迷惑了。
他们都做了什么?他们之间有何关系?JavaScript 的协程到底是怎么回事?
换句话说:
在 JavaScript 的处理异步逻辑的解决方案中,分为哪几个部分?每个部分做什么?他们有什么关系?他们是是如何工作的?
500 lines or less,这是一本非常赞的开源文章/书籍,它每一章会用大概 500 行或更少的代码,为读者展示一个完备的应用的简单实现。
你可以在这里找到这本书 -> aosabook/500lines
这是其中与协程相关的章节 -> A Web Crawler With asyncio Coroutines
这一章详细阐描述了,如何通过操作系统提供的底层并发能力 —— IO 多路复用器(selector),来实现一个单线程并发的爬虫。
在这篇文章中,作者实际上是为我们剖析了 asyncio 库的一种简单实现。
asyncio 是 Python 标准库的一部分,用来提供协程并发能力。
如果读者过去写过很多 JavaScript,那么当你刚接触 Python 的 asyncio 时,很大概率会感到迷惑。
你现在可以立刻停下阅读,去翻一番 asyncio 的文档,可以顺便跑一下文档中的示例代码。
我们发现,Python 也提供了 async/await 关键字,但与 JavaScript 有一定差别。
这个差别在于,JavaScript 的运行时会自动帮我们开启事件循环线程、而 Python 不会(这是引起 JavaScript 开发者迷惑的原因之一)。
其另一个表现是,在 Python 中直接调用一个 async function 是不允许的,因为 它不知道你要把这个协程放在哪个事件循环中执行,即未在某个事件循环上下文中调度,或未指定事件循环,就会在运行时抛出异常。
asyncio 更像是一个补丁,打在原有的 Python 的地基上,而 JavaScript 则是将协程完美融合了进去。
这让 Python 的 asyncio 给开发者提供了更大的操作空间,你可以手动运行一个事件循环,丢几个协程进去运行,然后在事件循环结束后再做一些其他你想做的任何事情 —— 协程的调度变成了可选项,作为整个 Python 程序的一部分。而不是像 JavaScript,只有一个默认开启的事件循环,而其中所有关于调度的逻辑都隐藏在 JavaScript 运行时下面,开发者什么都看不到。
我把协程调度器分为两部分,一部分是隐藏在 JavaScript 运行时下的,它为我们做的一些工作,我称它为下层调度逻辑,主要负责调度 IO 任务。
这些工作虽然在 JavaScript 语言层面被隐藏了,但在 Python 的 asycnio 暴露了一些接口供开发者使用。
另一部分是上层调度逻辑,负责封装异步接口、提供一种能力,来让开发者来编写可维护的异步控制流。
这部分逻辑负责调度任务,一个典型的实现包含这四部分:事件循环、事件队列、IO、以及 Timer。
其中 Timer 不是必要的,但在调度器的实现中,一般都会有有 Timer 的抽象/实现。
我们经常在八股中看到过 事件循环机制,它就包含在这部分逻辑中。
我们从一个异步 Socket 的工作过程开始:
def get(*, url, callback, asyncDone):
urlObj = urllib.parse.urlparse(url)
selector = Loop.getInstance().selector
sock = socket.socket()
sock.setblocking(False)
def connected():
selector.unregister(sock.fileno())
selector.register(sock.fileno(), EVENT_READ, responded)
sock.send(
f"""GET {urlObj.path if urlObj.path != '' else '/'}{'?' if urlObj.query != '' else '' + urlObj.query} HTTP/1.0\r\n\r\n"""
.encode('ascii')
)
responseData = bytes()
def responded():
nonlocal responseData
chunk = sock.recv(4096)
if chunk:
responseData += chunk
else:
selector.unregister(sock.fileno())
responseObj = Response(responseData.decode())
EventQueueManager.getCurrentEventQueue().pushCallback(
lambda: (callback(responseObj), asyncDone(responseObj))
)
nonlocal __stop
__stop = True
__stop = False
selector.register(sock.fileno(), EVENT_WRITE, connected)
try:
sock.connect(
(urlObj.hostname, urlObj.port if urlObj.port != None else 80)
)
except BlockingIOError:
pass
这段代码是对 HTTP GET 请求的简陋封装。
先暂时略去其中的细节,来分析一下它的工作过程。
我们注意到的第一件事是,这个函数从调用到返回的过程中,主要做的一件事就是简单地尝试连接远程服务器。
我们还看到第五行的 sock.setblocking(False)
,这表明该 HTTP 请求使用的是一个非阻塞 socket,这会导致对该函数的一次调用会非常迅速地结束。
考虑一个 JavaScript 的相似例子:
const fs = require('fs');
fs.readFile('./foo');
console.log('bar');
其中 readFile
做的事情基本与上面的函数一样,第一步都只是做一个简单的操作,例如向操作系统请求资源。
我们还注意到另一件事,这个函数内部定义了诸如 connected
、responded
这样的回调函数。
这表明在这个非阻塞 socket 的工作流程中,其请求是分阶段进行的,且在每个未结束的阶段均有对 selector
的调用:
selector.register(sock.fileno(), EVENT_WRITE, connected)
selector.unregister(sock.fileno())
selector.register(sock.fileno(), EVENT_READ, responded)
看起来像是在注册一些事件,这些事件被称为 IO 事件。
其中,selector
是操作系统为开发者提供的能力,是一种 IO 多路复用器,类似的还有 epoll
,它被广泛用于协程调度器的实现。它允许我们我们注册多个事件,然后在某个合适的地方,阻塞等待这些事件的发生。
这事实上就提供了 单线程并发 的能力,我们可以一次请求很多个(例如一百个)资源,注册在同一个 selector
上,然后一同等待事件的发生。一部分触发的事件处理结束后,可以 继续回去等待,直到所有事件被消费完毕。
实际上不断等待并消费事件,就是事件循环在做的事情。时间循环这个实体一般会负责将一个 IO 复用器暴露出去,让其他非阻塞 API 在其上注册事件,事件循环只管消费事件,从而实现单线程并发的调度。
就像下面这样:
while True:
if len(self.selector.get_map()) == 0:
return
events = self.selector.select() # 在此阻塞,直到事件触发
for callback, _ in events:
callback.data()
所以,上面按个实例函数的 selector
实际上来源于一个事件循环。
最后我们来纵观一下该任务由事件循环进行调度的整个执行过程:
首先尝试连接远端,然后注册一个 socket 可写(已连接,可发送数据)的 IO 事件到 connected
上。
当事件触发后,事件循环会拿到这个回调 connected
并执行。在 connected
中,取消了对可写事件的注册,并注册可读事件(已响应,可读取远端响应的数据)到 responded
上。
当事件触发后,事件循环会拿到 responded
并执行,我们最终将数据通过传入的 callback 参数,将远端响应的数据以回调的方式提供给调用方。
我们上面详细讲述了事件循环的最小实现,其中主要谈到了事件循环的一个重要任务 —— 维护 IO 事件。
读者可以立刻停下来,动手做一做,实现单线程的并发。
这里是一个参考,一个使用 Python 实现的下层调度逻辑的调度器 -> py-coroutine。
接下来,除了事件循环,我们还需要另一个重要实体 —— 事件队列。
在开头我们用作举例的函数中,callback 参数用于异步返回数据,而最后 responded
被回调后,并没有立刻调用,而是将这个调用过程封装为一个 lambda,扔到了事件队列中:
EventQueueManager.getCurrentEventQueue().pushCallback(
lambda: (callback(responseObj), asyncDone(responseObj)) # 不要在意后面的 asyncDone
)
为什么要这样做?
原因很简单,因为我们要 高并发。
一个事实是,IO 事件的回调执行不会太慢,一般都是在数据已经准备好、或 socket 文件已经可写时才执行,没有多余的操作,也没有计算密集的操作,就是简简单单的把数据拿到手,或把数据扔给操作系统底层去慢慢发送。
但另一个事实是,开发者的回调函数很大概率是计算密集的,也就是说,我们无法保证开发者传入的回调函数也会这么快。那么,为了在高并发场景下尽可能多地建立连接,接收数据,首要任务就是 —— 在一次事件循环中尽可能快速地消费掉所有 IO 事件。
而诸如 connected
、responded
这样的回调函数,都是属于非常快的 IO 事件的回调,在事件循环处理 IO 任务时被调度,我们一但在这些过程中跑了开发者提供的回调函数,事件循环就被(相对)长时间地阻塞了。
于是,我们在处理 IO 任务时,把这些可能是计算密集的过程先丢到队列里,等撑过这一轮循环,下次循环中我们再慢慢消费它们。
就像下面这样:
while True:
eventQueue = EventQueueManager.getCurrentEventQueue()
while cbk := eventQueue.getCallback():
if cbk:
self.__execTask(cbk)
# 下面是我们刚刚展示过的 IO 调度过程
if len(self.selector.get_map()) == 0:
return
events = self.selector.select() # 在此阻塞,直到事件触发
for callback, _ in events:
callback.data()
此外,在实践中我们还会遇到一些问题。
例如回调太多,饿死事件循环的 IO 任务,
或回调中又触发了其他被推到事件队列的回调,于是这一个任务占用了过多的资源等,
但我们先略过这些细枝末节,继续讨论下层调度逻辑的整体框架。
我们之前提到过,Timer 并不是必要的,它并不像事件循环、事件队列那样重要,而仅仅是一种任务的抽象,一种额外的功能。
不过,即便从抽象上看来 Timer 只是一种普通的任务,但 Timer 从功能上来讲仍然很重要,它在很多场景下会给开发者提供很大便利。
关于这一点,读者可以自行想象一下,JavaScript 没有 setTimeout
的话该怎么办?
下面来看一个 Timer 的实现:
class Timer:
def __init__(self, timeout: float, callback: Callable) -> None:
if not callable(callback):
return
self.timeout = timeout
self.end = time.time() + timeout
self.callback = callback
def isTimeout(self) -> bool:
return time.time() >= self.end
def getCallback(self):
return lambda: self.callback()
非常简单,isTimeout
用于查看是否到期,getCallback
用于返回构造时传入的回调。
计时器的构造函数不能直接暴露给开发者,因为 Timer 是抽象出来的任务,一个单独的 Timer 实例不会自动触发,它还需要事件循环来配合。
这些 Timer 需要统一管理,而对应的管理逻辑应该隐藏在抽象之下,所以我们下面来实现一个接口setTimeout
:
def setTimeout(timeoutms: float, callback: Callable) -> None:
_timerHeap.pushTimer(Timer(timeoutms / 1000, callback))
其中 _timerHeap
是一个使用最近时间最小堆实现的优先队列,顶堆可以在 O(1) 的时间里获取最小值,并在 O(logn) 的时间里修改内容。
当然,你也可以使用连续内存实现的 O(n) 的优先队列。
接下来,我们还需要在事件循环中对抽象的 Timer 做一些处理,它才能够起作用:
while True:
while (timer := timerHeap.peekTimer()) is not None and timer.isTimeout():
self.__execTask(timerHeap.popTimer().getCallback())
# 消费事件队列
eventQueue = EventQueueManager.getCurrentEventQueue()
while cbk := eventQueue.getCallback():
if cbk:
self.__execTask(cbk)
# IO 调度
if len(self.selector.get_map()) == 0:
return
events = self.selector.select() # 在此阻塞,直到事件触发
for callback, _ in events:
callback.data()
我们在一次时间循环的最开始检查堆顶的 Timer,并确认它是否到期,到期则拿出来执行掉,直到所有已到期的 Timer 都被执行掉。
接着,会注意到几个问题:
selector.select()
调用处死等事件触发,这很可能导致定时器不准。selector
上无等待中的 IO 事件后,事件循环会立刻停止,此时还可能存在未执行的 Timer。所幸我们可以为 selector
的阻塞等待设定超时时间,于是对于第一个问题,我们只需要将下一个 Timer 触发的时间作为超时时间即可,而对于第二个问题,可以简单的分情况来解决:
recentTimer = timerHeap.peekTimer()
timeout = recentTimer.end - time.time() if recentTimer is not None else MAX_TIMEOUT
if len(self.selector.get_map()) == 0:
time.sleep(max(timeout, 0)) # 阻塞线程
events = tuple()
else:
events = self.selector.select(timeout)
for callback, _ in events:
callback.data()
根据上面对下层调度逻辑中几个实体的讨论、抽象和实现,再结合这一节的内容,读者花上一些时间,应该就可以写出一个简单的协程调度器了。
协程的底层调度的核心是事件循环,它负责管理 IO 事件和 Timer 事件、操作事件队列、对异步 api 暴露 IO 复用器等工作。不过其中确实还有很多细节,这些细节可以在实践中慢慢遇到并解决掉。
我们前面提到了一个 Python 实现的协程调度器 -> py-coroutine。
使用它,我们可以写出这样的代码:
import time
from asynclib.core import Promise, LoopManager
from asynclib.asynchttp import get as asyncget
@LoopManager.asyncfun
def httpReq():
responseData = yield from Promise(
lambda resolve:
asyncget(
url='http://gaolihai.cool/doc/README.md',
callback=lambda response: resolve(response)
)
)
return responseData
@LoopManager.asyncfun
def asyncmain():
start = time.time()
print((yield from Promise.all([httpReq() for _ in range(10)])))
print(time.time() - start)
start = time.time()
for _ in range(10):
print((yield from httpReq()))
print(time.time() - start)
asyncmain()
值得注意的是,其中的 Promise 以及生成器属于上层调度逻辑部分,如果仅有我们刚刚所提及的几个实体的话,我们的代码大概像下面这样(其中 asyncget
返回 void,而不是 Promise):
from asynclib.asynchttp import get as asyncget
resList = []
for i in range(10):
asyncget('http://foo', lambda res: resList.append(res))
导入该包后,事件循环会在一个新线程自动开启。
这十次调用会迅速地完成,然后就会默默等待事件循环线程结束了。
我们通过 lambda 保存外部作用域的 resList
,从而保存结果,但我们不知道所有任务何时完成。
可以在主线程加一死循环,等待 resList
的长度变成十个后进行后续处理,或使用我们刚才提到的 Timer,每隔一段时间检查一次,这样较前面的方法来讲,性能的开销会小一些。
不过这都不优雅,在时序依赖比较复杂的任务面前还会面临回调地狱的问题。
所以,我们来讲讲 Promise 吧。
完整的上层调度逻辑可以在这个仓库找到 -> js-coroutine。
到此为止,我们已经在 Python 中实现了 JavaScript 为我们隐藏的那些东西 —— 事件循环、事件队列、IO 任务、Timer 等。
这很好地解释了 JavaScript 中的一个异步调用是如何工作的:
readFile('./foo', (_, data) => console.log('1'));
console.log('2');
// out: 2 1
下面,我们来说说在此之上的调度逻辑,它们包括:Promise、生成器、co、Thunk,基于这些概念/实体,我们要实现一个完整的异步控制方案。
Promise、生成器、co、Thunk 它们有什么关系?
async/await 是什么?
继续阅读下面的内容,有几点需要知晓:
Promise 和事件循环,它们互相之间是"无法感知"的。
因为我所说的"上层调度逻辑"和"下层调度逻辑",它们分别处于两个抽象层 —— 这也是为什么我从这里将它们分开。
事件循环不会意识到它在处理一个 Promise、或是生成器的某一步,而生成器也不知道(也不需要知道)它在被谁调度。
一个异步控制方案应该包括:生成器/(生成器的)执行器/通信器
前两者很好理解,最后一个 通信器 是我随便编的一个名词,它指的是一个在生成器和执行器之间互相传递消息的实体,它可以是对象,也可以是方法,它们需要配合一种"用法"来完成生成器的自动异步执行。
通信器可以是 Thunk,也可以是 Promise,它们可以为执行器提供 “异步调用何时结束” 的信息。
Promise 是一套完备的异步控制解决方案,是一种通信器。
想要实现 Promise A+ 规范比较复杂,但想要简单实现一个 Promise,仅包含一些诸如 then
的基本功能并不难。
这里有一篇非常赞的文章 实现 Promise,读者只需要挑个大块时间(例如几个小时),然后静下心来慢慢看,慢慢写,慢慢理解,然后就可以在不到一百行内完成一个功能基本完备的 Promise。
Thunk 这个概念其实有些难讲,它是一个已经被传入了部分参数的函数,这一特性让它能够充当通信器。
鉴于 JavaScript 开发者一定非常熟悉 Promise,但可能不太熟悉 Thunk,我们把它作为一个拓展实现,在文章最后展开讲解, 并将其适配到我们的执行器中。
协程调度的上层逻辑为我们封装了这样的异步控制方式:
async function() {
const result1 = await readFilePromises('./foo');
const result2 = await readFilePromises('./bar');
console.log(result1, result2);
}
这很像生成器。确实的,生成器的工作方式与协程非常类似。
上面的代码改写成生成器函数如下所示:
function*() {
const result1 = yield readFilePromises('./foo');
const result2 = yield readFilePromises('./bar');
console.log(result1, result2);
}
不过光有生成器可不够,还需要一个生成器的执行器(一个非常不错的社区实现被称为 co)。
在理解执行器之前,我们首先来观察一下生成器函数的使用方式:
function* genfun() {
yield 1;
yield 2;
yield 3;
return 4;
}
const gen = genfun();
gen.next(); // {value: 1, done: false}
gen.next(); // {value: 2, done: false}
gen.next(); // {value: 3, done: false}
gen.next(); // {value: 4, done: true}
gen.next(); // {value: undefined, done: true}
注意到,在调用生成器实例的 next
方法后,会进入生成器函数中,执行直到下一个 yield
处,然后返回其后的表达式。
next
方法其实还接收一个参数,该参数将作为上一个 yield
在生成器函数中的返回值。
于是我们发现,生成器内部和外部是可以交流的,通过 yield,可以把生成器内部的表达式值返回到外部,而外部也可以通过向 next
传入一个值,将外部的信息传递到内部。
根据这个想法,可以先写一个简单的生成器执行器,它的工作就是自动执行一个生成器:
export class Coroutine<T> {
private coroutine: Generator<any, T, any>;
public constructor(gen: Generator<any, T, any>) {
this.coroutine = gen;
}
execute(): void {
const next = (value?: any) => {
const result = this.coroutine.next(value);
if (!result.done) {
next(result.value);
}
}; next();
}
}
我们可以这样使用它:
function* genfun() {
const a = yield 1 + 2;
console.log(a);
}
new Coroutine(genfun()).execute(); // 3
注意到,生成器函数内的 yield
是由返回值的,它的返回值就是其后表达式的值,这是因为我们的生成器执行器将这个值通过 next
方法传了进去。
顺着这个思路,我们可以让这个生成器执行器能够处理 Promise 对象,等待一个 Promise 被 resolve 之后再将结果传入到 next
方法中,推进生成器执行。
这几乎是 async/await 了!
excute
方法应该这样改动:
execute(): void {
const next = (value?: any) => {
const result = this.coroutine.next(value);
if (!result.done) {
if(typeof result.value.then === 'function') { // thenable
result.value.then(data => { // 在 Promise/Thenable 结束后再调用 next
next(data);
});
} else {
next(result.value)
}
}
}; next();
}
现在,生成器中的 yield
后面可以跟一个 Promise 对象了:
function* genfun() {
const fileContent = yield readFilePromises('./foo');
console.log(fileContent);
}
new Coroutine(genfun()).execute(); // file content
再进一步,整个 excute
方法是否可以返回一个 Promise 呢,让该 Promise 内涵其下层生成器的返回值:
execute(): MPromise<T> {
return new MPromise((resolve, reject) => {
const next = (value?: any) => {
const result = this.coroutine.next(value);
if (result.done) {
resolve(result.value);
} else {
if (isThenable(result.value)) {
// promise instance
result.value.then(next, reject);
} else if (isGenerator(result.value)) {
// generator
new Coroutine(result.value).execute().then(next, reject);
} else if (Coroutine.isCoroutine(result.value)) {
// coroutine instance
result.value.execute().then(next, reject);
} else {
// value
next(result.value);
}
}
}; next();
});
}
在上面这个例子中,我们还额外处理了 yield
一个生成器实例、或一个 Coroutine
实例的情况。
现在它更像 async/await 了!yield
后可以跟一个 Promise,且执行时本身返回一个 Promise,这正是 async function 的表现。
function* genfun() {
const fileContent = yield readFilePromises('./foo');
return fileContent;
}
new Coroutine(genfun())
.execute()
.then(data => {
console.log(data);// file content
});
在开头我们提到的那个仓库中 -> js-coroutine,里面的生成器执行器适配了 Thunk。
不知道读者是否还记得,Thunk 是一个已经拥有某些参数的函数。
例如,对于 fun(param1, param2, param3)
这个函数,它的一个 Thunk 是 fun(param2, param3)
,注意该 Thunk 已经传入了第一个参数,当它被传完参数后,将会被执行。这很像科里化,对的,差不多就是那个意思。
不过,基于 JavaScript 的形如 readFile(filename, callback)
这样的异步 API,我们的 Thunk 应该只能被调用两次,第一次将传入 callback
之前的所有参数,第二次调用传入 callback
。
于是我们可以这样实现一个工具方法 toThunk
:
function toThunk(asyncFn) {
return (...args) => {
return (callback) => {
asyncFn(...args, callback);
};
};
}
我们可以这样使用它:
let thunk = toThunk(readFile);
thunk = thunk('./foo');
thunk((err, data) => /* do sth. */));
这有什么用呢?
我们注意到 thunk 的两步执行,第一步执行是为了传入参数,然后返回了一个要传入回调的函数,这很像 Promise 的 then 方法!
于是我们就可以尝试将 Thunk 适配到执行器中,适配 Thunk 后,生成器写起来应该像这样:
const readFileThunk = toThunk(readFile);
function* genfun() {
const fileContent = readFileThunk('foo');
return fileContent;
}
但问题在于,执行器如何识别一个 Thunk 呢?
我们可以把 Thunk 包装到一个对象中,让执行器去检查它的原型链就行了,我们来实现这个 Thunk 类:
export type ThunkCallback<T = any> = (error: any, value: T) => void;
export type ThunkFn<T = any> = (callback: ThunkCallback<T>) => void;
export class Thunk<T = any> {
public static toThunk<T>(
asyncFn: (
...argsAndCallback: (any | ThunkCallback<T>)[]
) => void
): (...args: any[]) => Thunk<T> {
return (...args: any[]): Thunk<T> => {
return new Thunk((callback: ThunkCallback<T>) => {
asyncFn(...args, callback);
});
}
}
public static isThunk(value: any): value is Thunk {
return value instanceof Thunk;
}
private constructor(public thunk: ThunkFn<T>) { }
}
在生成器执行器中怎么做呢:
execute(): MPromise<T> {
return new MPromise((resolve, reject) => {
const next = (value?: any) => {
const result = this.coroutine.next(value);
if (result.done) {
resolve(result.value);
} else {
if(/* ... */) {
// ... 其他通信器
} else if (Thunk.isThunk(result.value)) {
// thunk instance
result.value.thunk((err, value) => {
if (err) {
reject(err);
} else {
next(value);
}
})
} else {
// value
next(result.value);
}
}
}; next();
});
}
到此为止,我们的生成器执行器就适配了 Thunk。