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

协程的抽象和实现

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

最令开发者不快的东西,莫过于一个难以理解的 黑盒 了,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)) # 不要在意后面的 asyncDone
)

为什么要这样做?

原因很简单,因为我们要 高并发

一个事实是,IO 事件的回调执行不会太慢,一般都是在数据已经准备好、或 socket 文件已经可写时才执行,没有多余的操作,也没有计算密集的操作,就是简简单单的把数据拿到手,或把数据扔给操作系统底层去慢慢发送。

但另一个事实是,开发者的回调函数很大概率是计算密集的,也就是说,我们无法保证开发者传入的回调函数也会这么快。那么,为了在高并发场景下尽可能多地建立连接,接收数据,首要任务就是 —— 在一次事件循环中尽可能快速地消费掉所有 IO 事件。

而诸如 connectedresponded 这样的回调函数,都是属于非常快的 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 只是一种普通的任务,但 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()

Promise 在哪?

根据上面对下层调度逻辑中几个实体的讨论、抽象和实现,再结合这一节的内容,读者花上一些时间,应该就可以写出一个简单的协程调度器了。

协程的底层调度的核心是事件循环,它负责管理 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生成器coThunk,基于这些概念/实体,我们要实现一个完整的异步控制方案。

Promise生成器coThunk 它们有什么关系?

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
});

额外的通信器支持 —— Thunk

在开头我们提到的那个仓库中 -> 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。

你可能感兴趣的:(教程,Python,JS,协程,javascript,python)