12.12 使用生成器代替线程

问题

你想使用生成器(协程)替代系统线程来实现并发。这个有时又被称为用户级线程或绿色线程。

解决方案

要使用生成器实现自己的并发,你首先要对生成器函数和 yield 语句有深刻理解。yield 语句会让一个生成器挂起它的执行,这样就可以编写一个调度器, 将生成器当做某种“任务”并使用任务协作切换来替换它们的执行。

考虑下面两个使用简单的 yield 语句的生成器函数:

# Two simple generator functions
def countdown(n):
    while n > 0:
        print('T-minus', n)
        yield
        n -= 1
    print('Blastoff!')

def countup(n):
    x = 0
    while x < n:
        print('Counting up', x)
        yield
        x += 1

这些函数在内部使用 yield 语句,下面是一个实现了简单任务调度器的代码:

TaskScheduler 类在一个循环中运行生成器集合——每个都运行到碰到 yield 语句为止。 运行这个例子,输出如下:

到此为止,我们实际上已经实现了一个“操作系统”的最小核心部分。 生成器函数就是任务,而 yield 语句是任务挂起的信号。 调度器循环检查任务列表直到没有任务要执行为止。

实际上,你可能想要使用生成器来实现简单的并发。 那么,在实现 actor 或网络服务器的时候你可以使用生成器来替代线程的使用。

下面的代码演示了使用生成器来实现一个不依赖线程的 actor:

完全弄懂这段代码需要更深入的学习,但是关键点在于收集消息的队列。 本质上,调度器在有需要发送的消息时会一直运行着。 计数生成器会给自己发送消息并在一个递归循环中结束。

下面是一个更加高级的例子,演示了使用生成器来实现一个并发网络应用程序:

这段代码有点复杂。不过,它实现了一个小型的操作系统。 有一个就绪的任务队列,并且还有因 I/O 休眠的任务等待区域。 还有很多调度器负责在就绪队列和 I/O 等待区域之间移动任务。

讨论

在构建基于生成器的并发框架时,通常会使用更常见的 yield 形式:

使用这种形式的 yield 语句的函数通常被称为“协程”。 通过调度器,yield 语句在一个循环中被处理,如下:

被传给 send() 的值定义了在 yield 语句醒来时的返回值。 因此,如果一个 yield 准备在对之前 yield 数据的回应中返回结果时,会在下一次 send() 操作返回。 如果一个生成器函数刚开始运行,发送一个 None 值会让它排在第一个 yield 语句前面。

除了发送值外,还可以在一个生成器上面执行一个 close() 方法。 它会导致在执行 yield 语句时抛出一个 GeneratorExit 异常,从而终止执行。 如果进一步设计,一个生成器可以捕获这个异常并执行清理操作。 同样还可以使用生成器的 throw() 方法在 yield 语句执行时生成一个任意的执行指令。 一个任务调度器可利用它来在运行的生成器中处理错误。

最后一个例子中使用的 yield from 语句被用来实现协程,可以被其它生成器作为子程序或过程来调用。 本质上就是将控制权透明的传输给新的函数。 不像普通的生成器,一个使用 yield from 被调用的函数可以返回一个作为 yield from 语句结果的值。

最后,如果使用生成器编程,要提醒你的是它还是有很多缺点的。 特别是,你得不到任何线程可以提供的好处。例如,如果你执行 CPU 依赖或 I/O 阻塞程序, 它会将整个任务挂起知道操作完成。为了解决这个问题, 你只能选择将操作委派给另外一个可以独立运行的线程或进程。 另外一个限制是大部分 Python 库并不能很好的兼容基于生成器的线程。 如果你选择这个方案,你会发现你需要自己改写很多标准库函数。

你不可能自己去实现一个底层的协程调度器。 不过,关于协程的思想是很多流行库的基础, 包括 gevent,greenlet, Stackless Python 以及其他类似工程。

Last updated

Was this helpful?