协程
这篇文章将阐述 协程(coroutine) 以及相关概念的含义,并给出和分析一个基于生成器的、使用 Python 实现的协程调度器示例,以及一个测试用的异步 HTTP 请求实现。
概念
进程、线程、协程
一个进程下可以有多个线程,一个线程又有多个协程,似乎就是一个从上到下的层次的结构。但这并不严谨,如果能深入协程的运行方式,就会发现协程与另外两者完全不同,因为比起由 OS 调度的进程和线程来说,协程的调度行为只是一个普通的程序。
这种普通来源于,协程的调度行为完全是在用户空间中进行的,如果将 OS 看做一个底层的抽象层,那么拥有协程的程序只是一个非常普通的程序。对 OS 来说,协程并不真实存在,在 OS 看来,一个拥有协程的线程与其他线程几乎没有任何区别。
虽然计算机总是在分层,每一层又对上层提供抽象,例如进程和线程就是操作系统对用户提供的抽象,而且现在很多现代编程语言(或者一些古老语言的新标准)都拥有了 native 的协程支持(例如最常见的语法糖 async、await),但这些支持的底层逻辑仍然运行在用户空间,与操作系统无关。
这种完全在用户空间进行的特性,是最主要的,协程与其他两者之间的区别。
协程的表现
协程常与进程、线程被一并提起,好像他们是同一种概念的不同层级,抽象来看确实如此。我们首先就以这样的角度去审视和感受协程的表现。
协程与一个函数类似,不过函数调用总从一个入口进入,从一个出口返回,而协程则不同。协程虽然类似一个函数,但在执行过程中可以中断,被调度去执行其他协程或普通函数,然后在未来某个时刻调度回来继续执行。
如果把协程作为一个黑箱,那么在运行时,它看起来就是一个独立调度的单位,但与进程、线程这种独立调度不同的是,协程挂起和恢复的位置总是固定的,而且是用户开发者显式指定的。
为了更好的理解这些特性,我们用线程作对比:
这里使用 js 作为描述语言,忽略 v8 引擎让 js 执行在单线程中。
let sum = 0;
function thread1() {
for(let _ in [...new Array(10000).keys()]) {
sum += 1;
}
}
function thread2() {
for(let _ in [...new Array(10000).keys()]) {
sum += 1;
}
}
启动 thread1 thread2 两个线程并反复执行,敏感的开发者可以轻易发现存在的问题 —— 对 sum 的访问在线程的并发环境下不是原子的,这会导致 sum 的最终值不一定是 10000 + 10000 = 20000 ,因为 OS 很可能在非原子操作的间隙调度线程,导致上下文变量在暂停和恢复时拥有错误的值。
但如果使用协程:
let sum = 0;
function coroutine1() {
for(let _ in [...new Array(10000).keys()]) {
await sth;
sum += 1;
}
}
function coroutine2() {
for(let _ in [...new Array(10000).keys()]) {
await sth;
sum += 1;
}
}
我们执行这两个协程,最终 sum 的值一定会停在 20000 ,这是因为,协程只会在一个被显式指定的位置被调度,在上面的例子中,就是在 await sth; 的位置被调度,这就可以保证每个原子操作都会完整地执行。
这里没有体现协程的执行顺序,这个将在后面的例子中说明
这就是协程的抽象表现,看起来让人有些费解,因为如果能够指定协程中被调度的位置,意味着调度器需要与协程沟通,知晓可以在哪个位置调度它,也就是说,调度器与每个协程都是耦合的。
OS 的并发问题就来源于 OS 不知道进程或线程内部的抽象结构,因为一个高级语言中的本该是原子操作的代码被编译到机器码后,就不再是一个原子操作了,它可能包含多条机器指令,这就意味着机器码丢失了高级语言的这些抽象信息,从而可能在连续的多条机器指令间隙被调度,造成并发环境下的逻辑混乱。
我们当然可以想象,如果能与 OS 沟通,让 OS 知道哪个位置的代码应该是原子的,那么就可以解决并发问题,而这个解决方案就是 基于信号量的进程同步,也可以说是 锁。
但这又与协程完全不同,OS 仅仅是执行锁的原子操作,而协程将可以指定几个调度点位,告诉调度器可以在哪个点位进行调度。
就好像把一个函数截成好几段,每执行一段后调度器就介入,并进行调度,将来还会从该位置恢复上下文。
除了可以把函数分段进行调度外,协程真正的威力主要来源于调度和异步 I/O 库的配合使用,我们可以让协程在 I/O 时允许被调度,从而充分利用 I/O 时间来运行其他 CPU 密集的代码。
这是如何做到的呢?
协程的调度
现在我们知道了:
- 协程的调度完全在用户空间中进行
- 协程只能从被显式声明的可调度的位置被调度
前者说明了协程的普通的性质,协程的调度器只是某一线程中的某一段无聊的逻辑,并没有什么特别的,而后者则是下面我们将要了解的问题。
如何实现这个调度器,才能让一个独立的调度单位与调度器沟通可调度的位置,并允许一个函数在调度位置中途离开,在将来恢复?
实际上目前大多数现代编程语言都提供了实现协程调度器的一个基本特性,那就是 —— 生成器 (generator)。
在 Python 中,这被成为生成器迭代器,由生成器迭代器函数返回:
任何一个拥有 yield 表达式的普通函数都将被解释器处理为一个生成器迭代器函数,该函数执行后将返回一个生成器迭代器:
def gen():
yield 1
yield 2
coro = gen()
coro.send(None)
coro.send(None)
每次执行 send 方法,将会把一个值传入这个迭代器内部,然后一直执行到下一个 yield 语句的位置,将该语句后的表达式返回值从刚才的 send 调用处返回。
这是不是很像协程呢?每一个 yield 语句就相当于协程的一次分段,在每个分段处,生成器迭代器总是将控制权交还出去,我们完全可以实现一个调度器,这个调度器调度很多生成器迭代器,每个生成器迭代器抽象上来说就是一个完全的协程。
协程的优势
协程的应用场景几乎只有一个,那就是 IO 密集的程序,例如典型的高 IO 场景 —— Web 后端服务,
-
开销小
- 协程调度的性能开销比线程调度小很多,协程切换迅速
- 携程调度的内存开销也比线程调度小很多
-
不需要考虑在并发环境下的临界资源
-
逻辑更简洁
- 相比线程来说,协程让从上下文推导代码逻辑变得更加简单
Threads make local reasoning difficult, and local reasoning is perhaps the most important thing in software development.
线程使得局部推理变得困难,而局部推理可能是软件开发中最重要的事情。
—— https://github.com/glyph/
Node.js 就是一个基于单线程非阻塞 I/O 模型开发的 JavaScript Runtime,只要实现得当,即便是单线程模型,也可以承载相当巨大的并发量。
举一个简单的例子,例如一个典型的爬虫程序:
import requests
for i in range(100):
resp = requests.get(url='http://gaolihai.cool/doc/README.md')
print('\n'.join([linebytes.decode() for linebytes in resp.iter_lines()]))
假设每次从请求到返回需要 100 s,假设其中发送请求花费了 5 ms,等待服务端响应,即等待 IO 结束花费了 95 ms,那么 100 次请求将花费 10s,而这 10 s 中只有 0.5 s 即发送请求的过程是必须占用 CPU 时间的,剩下 9.5 s 只是无意义的等待。这种模型被称为阻塞式 I/O 或同步 I/O。
而如果使用协程,我们可以这样实现:
from asynclib import asynchttp, Future
@asyncfun
def http():
responseData = yield from Future(
lambda resolve:
asynchttp.get(
url='http://gaolihai.cool/doc/README.md',
callback=lambda response: resolve(response)
)
)
print(responseData)
for i in range(100):
http()
asynchttp.get 是一个异步请求 api,即它只负责发送请求,所以还额外接收一个回调函数作为参数,该函数将在 IO 结束时由操作系统通知进程,然后进行回调。
那么这段程序只需要约 0.5 s 就能发送完所有请求,这可以称为并发请求,等待服务端响应可能需要 200 ms,最终在不到一秒内就完成了 100 次请求。这种模型被称为非阻塞式 I/O 或异步 I/O。
相比阻塞式 I/O,非阻塞式 I/O 在这种场景下显然具有很大优势。不过,如果我们使用线程,也能得到差不多的性能表现,只不过线程的开销相比协程要大很多,当并发数量过多时可能会在线程上下文切换中浪费太多性能,而过多的线程实例甚至会挤爆栈空间。
所以,协程本身并不会让代码执行更快,但如果配合异步 I/O 库,在高 I/O 场景下将具有非常大的性能优势。
另外,对于 CPU 密集的程序来说,更建议使用多进程,在多个核心上进行并行计算。
实现及原理
https://github.com/Drincann/py-coro-impl
协程调度的实现方式有很多,这里介绍一种使用事件循环、事件队列这种调度方式的协程实现。
事件循环和事件队列
一个典型的 Python 程序:
import requests
resp = requests.get(url='http://gaolihai.cool/doc/README.md')
print('\n'.join([linebytes.decode() for linebytes in resp.iter_lines()]))
一个典型的使用协程的程序:
from asynclib.core import Future, asyncRun, loop
from asynclib.asynchttp import get as asyncget
def http():
responseData = yield from Future(
lambda resolve:
asyncget(
url='http://gaolihai.cool/doc/README.md',
callback=lambda response: resolve(response)
)
)
print(responseData.decode('utf-8'))
asyncRun(http())
loop()
协程调度的典型流程是这样的:
现在有两个实体:
-
协程调度器 事件循环是调度器的实现方式 -
事件队列 存放所有待执行的事件
asyncRun 调用可以将一个协程圧入事件队列中,loop 是进入事件循环(也可称为调度器)的入口,loop 调用将会把线程的控制权交给协程调度器,该调度器将会在未来不断地从事件队列拉取协程或普通函数(可以称事件),然后执行和调度它们。
在调度和执行的过程中,这些事件还可能产生更多的事件,于是就会源源不断地执行下去。
事件队列使用一个阻塞队列实现,向外提供一个单例 eventQueue :
from queue import Queue
class __EventQueue:
def __init__(self) -> None:
self.__eventQueue = Queue()
def pushCallback(self, fn):
self.__eventQueue.put(fn, block=True)
def getCallback(self):
return self.__eventQueue.get(block=True)
eventQueue = __EventQueue()
一个最简单的事件循环实现:
当队列中没有元素时,eventQueue.getCallback() 调用将会阻塞在内部的 self.__eventQueue.get(block=True) 处,直到异步 I/O 库某个接口执行完毕后,向该队列中圧入一个事件,此时阻塞在此处的事件循环线程将被唤醒,然后执行事件。
def loop():
while True:
cbk = eventQueue.getCallback()
cbk()
asyncRun 的职责是向事件队列中圧入一个事件(或者说任务):
def asyncRun(gen):
eventQueue.pushCallback(gen)
到此为止,一个基本的框架就搭好了,下面我们来看,如何进行协程的调度。
生成器迭代器的自动执行
我们刚才提到,基于生成器迭代器(下文称协程)来实现协程,现在就需要一个协程的自动执行器。
在事件循环的实现中,如果拿到一个协程,肯定不能简单地调用它,我们需要一个执行器,进行这样一个非常关键的流程,这个流程会反复在协程和调度器之间转移控制权,从而自动地异步地将协程执行完毕:
协程的自动执行器将实现一个过程,该过程通过接收一个协程参数,并调用 send() 将控制权交给协程(相当于调度协程)。
在协程执行异步 api 时,再从 yield 处将控制权交还给调度器,转而执行事件队列中下一个任务。
当 I/O 结束时,由操作系统通知异步 api,然后 api 内部将会向事件队列圧入一个回调函数,当函数被事件循环取出和调用后,再次转移控制权给协程,持续驱动协程的异步执行,就这样一直来回递交控制权,直到协程完全执行完毕。
下面我们将创建两个新实体,然后修改事件循环的实现,让时间循环可以自动执行实践队列中的协程。
- 首先,
yield 后应该跟一个被称为 Future 或 Promise 的对象,该对象可以包装一个异步任务,从而描述一个未来的结果,即异步 I/O 在未来结束时的结果,该对象将会配合执行器进行持续转移控制权。 - 然后是执行器,执行器将作为一个类被实现,称
GeneratorExecutor 。 - 最后是在事件循环中,应该对协程调用执行器。
修改事件循环
首先修改时间循环的实现,其中 __GeneratorExecutor 是协程的执行器类。
from inspect import isgenerator, isgeneratorfunction
def loop():
while True:
cbk = eventQueue.getCallback()
if isgenerator(cbk):
self.__GeneratorExecutor(cbk)
elif isgeneratorfunction(cbk):
self.__GeneratorExecutor(cbk())
elif callable(cbk):
cbk()
else:
raise TypeError('...')
执行器和 Future
在执行器的实现中,执行器和 Future 是密不可分的,因为要驱动协程反复在执行器和协程之间转移控制权,必须有一个规定的接口,这个接口就是 Future ,我们规定协程在 yield 后必须跟一个 Future 对象。
class Future:
这个 Future 将被这样设计:
它与 JavaScript 的 Promise 类似,
-
Future 的构造函数接收一个任务参数,该参数是一个具有 resolve 参数的函数,在函数中调用异步任务,并在异步任务的回调中调用 resolve ,并传入异步任务的结果来通知 Future 对象。 def __init__(self, task=None):
self.callbacks: List[Callable[[Any], Any]] = []
self.value: Any = None
task(self.resolve)
-
允许在 Future 对象上添加回调函数,该回调函数将在异步任务结束(即 resolve 被调用后)由 Future 对象进行回调。 由于 resolve 被定义为异步任务结束,用来通知 Future 的回调,于是该函数具有一个参数,就是异步任务完成的返回值。 def addCallback(self, cbk: Callable[[Any], Any]):
if self.state == 'resolved':
cbk(self)
self.callbacks.append(cbk)
return self
def resolve(self, value: Any = None):
self.value = value
for cbk in self.callbacks:
cbk(self)
return self
下面来看执行器:
执行器的实现非常简单,它的构造函数接收一个协程,然后通过 __next 方法直接调度协程。
直到协程返回一个 Future 后,调度器拿到控制权,并在这个返回的 Future 上注册 __next 方法作为回调函数,这样就可以在该 Future 包装的异步任务执行完毕后自动回调执行器,交还控制权,并继续下一步的注册回调函数,就这样反复交换控制权,驱动协程执行直到结束。
class __GeneratorExecutor:
def __init__(self, coroutine):
self.coroutine = coroutine
self.__next(Future())
def __next(self, future: Future):
try:
nextFuture = self.coroutine.send(future.value)
except StopIteration:
return
nextFuture.addCallback(self.__next)
此外,每次执行器将控制权转移给协程时,将会把上一个 Future 对象的异步结果值传入协程,在协程内部来看,就好像是异步任务在 I/O 时阻塞,I/O 结束后从同步上下文中返回了异步任务的结果。
到此为止,一个最小功能的协程就实现好了,现在我们拥有:
下面我们简单实现一个异步 HTTP GET 请求来测试协程的行为。
异步 api 举例
下面我们要基于非阻塞 socket 实现一个简单的 HTTP GET 请求接口。
非阻塞 socket 拥有三个异步过程:
- 第一阶段是等待连接,即等待 socket 文件可写
- 发送请求后,第二阶段是等待服务端响应,即等待 socket 文件可读
由于是非阻塞 I/O,每一步都需要通过注册回调函数来处理,且每次 socket 文件状态改变后由 OS 通知,这一点通过 selector (I/O 多路复用)实现。
该接口应该接收两个参数:
- 要访问的 url
- 服务端响应后的回调函数 callback
实例化 socket 对象,并设定为非阻塞模式,
实例化 selector 对象,并监听 socket 文件的可写事件,此时还需要实现连接成功后的回调 connected
发送连接请求,连接成功后 connected 将会首先发送数据,然后注册可读事件,等待服务端响应,此时需要实现 responded 。
responded 将会连续接收数据直到遇到数据结束标志,到此一次从请求到响应的完整的 HTTP 请求就结束了,现在向事件队列圧入回调函数,通知调用者异步任务结束。
def get(*, url, callback):
urlObj = urllib.parse.urlparse(url)
selector = DefaultSelector()
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())
eventQueue.pushCallback(lambda: callback(responseData))
nonlocal __stop
__stop = True
__stop = False
def loop():
while True:
events = selector.select()
for event_key, event_mask in events:
cbk = event_key.data
cbk()
if __stop:
break
selector.register(sock.fileno(), EVENT_WRITE, connected)
try:
sock.connect(
(urlObj.hostname, urlObj.port if urlObj.port != None else 80)
)
except BlockingIOError:
pass
Thread(target=loop).start()
与之前的连起来讲,这个回调函数在将来被事件循环调用,如果它在一个协程中使用,将会 resolve 外层的 Future 。
随后 Future 依次调用注册在它身上的回调函数,其中一个就是执行器的 __next 方法,从而将控制权转移到执行器中。
再往后,执行器将会把异步任务的结果传入协程继续执行,从而让协程中的该异步任务带着执行器传入的任务结果从 yield 恢复执行。
测试
这段代码将会并发请求一段文本十次,尝试执行它并查看输出的结果。
def http():
responseData = yield Future(
lambda resolve:
get(
url='http://gaolihai.cool/doc/README.md',
callback=lambda response: resolve(response)
)
)
print(responseData.decode('utf-8'))
for i in range(10):
asyncRun(http())
loop()
更多功能
以上是一个协程库的最小实现,接下来还有更多功能,
- 优化 HTTP GET 请求接口,复用同一个
selector ,而不是每次创建线程。 - 自动调用
loop ,进入事件循环。 - 使用装饰器指定协程,不显式使用
asyncRun ,而是通过装饰器将对协程的直接调用委托给 asyncRun 。 - 文件系统的异步接口。
更多内容,移步 https://github.com/Drincann/py-coro-impl
|