协程的抽象和实现
一件令人不快的事情是。。。
最令开发者不快的东西,莫过于一个难以理解的 黑盒 了,JavaScript 提供的这一套协程解决方案就是这么一个东西,其中的 async function 从语法上类似 generator,却返回一个 Promise。我们还知道 co/generator、thunk 这些东西,这让我更迷惑了。
他们都做了什么?他们之间有何关系?JavaScript 的协程到底是怎么回事?
换句话说:
在 JavaScript 的处理异步逻辑的解决方案中,分为哪几个部分?每个部分做什么?他们有什么关系?他们是是如何工作的?
第一步是 500 lines or less
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)) # 不要在

最低0.47元/天 解锁文章
6318

被折叠的 条评论
为什么被折叠?



