【手写协程】带你从底层实现一个最小协程调度器

协程的抽象和实现

一件令人不快的事情是。。。

最令开发者不快的东西,莫过于一个难以理解的 黑盒 了,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 做的事情基本与上面的函数一样,第一步都只是做一个简单的操作,例如向操作系统请求资源。

异步回调

我们还注意到另一件事,这个函数内部定义了诸如 connectedresponded 这样的回调函数。

这表明在这个非阻塞 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)) # 不要在
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

高厉害

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值