应用程序级无栈特性

简介

PyPy 可以向其用户语言公开类似于 Stackless Python 中的功能:以**大规模并发风格**编写代码的能力。(它不再提供以无递归深度限制的方式运行的能力,但可以通过间接方式实现相同的效果。)

此功能基于称为 continulet 的自定义原语。应用程序代码可以直接使用 Continulets,或者可以编写(完全在应用程序级别)更友好的接口。

目前 PyPy 在 Continulets 之上实现了 greenlets。它还实现了(近似于)tasklets 和 channels,模拟了 Stackless Python 的模型。

Continulets 非常轻量级,这意味着 PyPy 应该能够处理包含大量 Continulets 的程序。但是,由于实现限制,使用 --gcrootfinder=shadowstack 编译的 PyPy 每个活动 Continulet 至少消耗一页物理内存(4KB),在 32 位系统上消耗 500KB 虚拟内存,在 64 位系统上消耗 1MB 虚拟内存。此外,此功能目前仅在 x86 和 x86-64 CPU 上可用;对于其他 CPU,您需要在 rpython/translator/c/src/stacklet/ 中添加一小段自定义汇编代码。

理论

基本思想是,在任何时间点,程序恰好运行一个帧栈(或者在多线程情况下,每个线程运行一个帧栈)。要查看栈,从顶层帧开始,沿着 f_back 链向下跟踪,直到到达底层帧。从其中一个帧的角度来看,它有一个指向另一个帧的 f_back(除非它是底层帧),并且它本身被另一个帧指向(除非它是顶层帧)。

Continulets 背后的理论是将上一句话作为“正常情况”的定义。诀窍是,正常情况比只有一个栈更复杂:您始终只有一个栈,但您也可以额外拥有一个或多个分离的帧循环,这样,沿着 f_back 链跟踪时,您会进入一个循环。但请注意,这些循环确实是完全分离的:顶层帧(当前正在运行的帧)始终是那个没有被其他任何帧作为 f_back 指向的帧,并且它始终是结束于底层帧的栈的顶部,而不是这些额外循环的一部分。

如何创建这样的循环?基本操作是取两个帧并置换它们的f_back - 也就是说交换它们。你可以置换任何两个f_back 而不违反“正常情况”的规则。例如,假设f 是堆栈中某个中间帧,你将它的f_back 与顶层帧的f_back 进行置换。然后,你从正常堆栈中移除了所有中间帧,并将它们变成了一个独立的循环。通过再次执行相同的置换,你可以恢复原始情况。

在实践中,在 PyPy 中,你不能更改任意帧的f_back,而只能更改存储在continulets 中的帧。

Continulets 在内部使用stacklets 实现。Stacklets 更加原始(它们实际上是一次性延续),但这种想法只适用于 C,不适用于 Python。Continulets 的基本思想是在任何时间点都拥有一个完整的有效堆栈;这对于正确传播异常(并且似乎也提供了有意义的回溯)非常重要。

应用程序级接口

Continulets

一个翻译后的 PyPy 默认包含一个名为_continuation 的模块,它导出类型continulet。来自该模块的continulet 对象是一个容器,用于存储“一次性延续”。它扮演着你可以在堆栈中插入的额外帧的角色,并且它的f_back 可以更改。

要创建一个 continulet 对象,请使用可调用对象和可选的额外参数调用continulet()

稍后,当你第一次switch() 到 continulet 时,可调用对象将使用相同的 continulet 对象作为第一个额外参数被调用。此时,存储在 continulet 中的一次性延续指向switch() 的调用者。换句话说,你拥有一个看起来完全正常的帧堆栈。但是,当再次调用switch() 时,这个存储的一次性延续将与当前延续交换;这意味着switch() 的调用者将被挂起,其延续存储在容器中,并且将恢复 continulet 对象中的旧延续。

最原始的 API 实际上是“permute()”,它只是置换存储在两个(或多个)continulets 中的一次性延续。

更详细地说明

  • continulet(callable, *args, **kwds):创建一个新的 continulet。就像生成器一样,这只会创建它;callable 只有在第一次切换到它时才会被实际调用。它将按如下方式调用

    callable(cont, *args, **kwds)
    

    其中cont 是同一个 continulet 对象。

    请注意,实际上是cont.__init__() 绑定了 continulet。也可以通过显式调用continulet.__new__() 来创建一个尚未绑定的 continulet,并仅通过显式调用cont.__init__() 来绑定它。

  • cont.switch(value=None, to=None): 如果 continulet 尚未启动,则启动它。否则,将当前的 continuation 存储在 cont 中,并激活目标 continuation,即之前存储在 cont 中的 continuation。请注意,目标 continuation 本身之前被另一个对 switch() 的调用挂起;此较旧的 switch() 现在将看起来已返回。 value 参数是传递到目标并由目标的 switch() 返回的任何对象。

    如果给出了 to,它必须是另一个 continulet 对象。在这种情况下,执行“双重切换”:它如上所述切换到 cont,然后立即再次切换到 to。这与直接切换到 to 不同:当前 continuation 存储在 cont 中,来自 cont 的旧 continuation 存储在 to 中,然后我们才从 to 中的旧 continuation 恢复执行。

  • cont.throw(type, value=None, tb=None, to=None): 与 switch() 类似,只是在切换完成之后,立即在目标中引发给定的异常。

  • cont.is_pending(): 如果 continulet 处于挂起状态,则返回 True。当它未初始化(因为我们调用了 __new__ 而不是 __init__)或已完成(因为 callable() 已返回)时,它为 False。当它为 False 时,continulet 对象为空,无法切换到它。

  • permute(*continulets): 一个全局函数,它对给定 continulet 参数中存储的 continuation 进行排列。主要用于理论研究。在实践中,使用 cont.switch() 比使用 permute() 更容易且更高效;后者本身不会更改当前正在运行的帧。

Genlets

_continuation 模块还公开了 generator 装饰器

@generator
def f(cont, a, b):
    cont.switch(a + b)
    cont.switch(a + b + 1)

for i in f(10, 20):
    print i

此示例打印 30 和 31。与使用常规生成器相比,唯一的优势是生成器本身不受限于必须全部在语法上出现在同一函数中的 yield 语句。相反,我们可以传递 cont,例如传递给嵌套的子函数,并从那里调用 cont.switch(x)

generator 装饰器也可以应用于方法

class X:
    @generator
    def f(self, cont, a, b):
        ...

Greenlets

Greenlets 在 lib_pypy/greenlet.py 中的 continulet 之上实现。请参阅 greenlets 的官方文档

请注意,与 CPython greenlets 不同,此版本不会出现 GC 问题:如果程序“忘记”了未完成的 greenlet,它将在下次垃圾回收时始终被回收。

未实现的功能

以下功能(存在于 PyPy 的一些过去的 Stackless 版本中)目前不再受支持

  • 协程(可以在应用程序级别重写)
  • 在不同线程中继续执行 continulet(但如果它“足够简单”,则可以将其序列化并在另一个线程中反序列化)。
  • 自动无限堆栈(到目前为止必须模拟
  • 支持除 x86 和 x86-64 之外的其他 CPU

我们也不包含 Stackless Python 中的任何最新 API 添加,例如 set_atomic()。欢迎贡献。

递归深度限制

您可以使用 continulets 来模拟 Stackless Python 和支持 stackless 的旧版 PyPy 中存在的无限递归深度。

诀窍是在“早期”启动一个 continulet,即在递归深度非常低的时候,并在“稍后”切换到它,即在递归深度很高的时候。示例

from _continuation import continulet

def invoke(_, callable, arg):
    return callable(arg)

def bootstrap(c):
    # this loop runs forever, at a very low recursion depth
    callable, arg = c.switch()
    while True:
        # start a new continulet from here, and switch to
        # it using an "exchange", i.e. a switch with to=.
        to = continulet(invoke, callable, arg)
        callable, arg = c.switch(to=to)

c = continulet(bootstrap)
c.switch()


def recursive(n):
    if n == 0:
        return ("ok", n)
    if n % 200 == 0:
        prev = c.switch((recursive, n - 1))
    else:
        prev = recursive(n - 1)
    return (prev[0], prev[1] + 1)

print recursive(999999)     # prints ('ok', 999999)

请注意,如果您在运行此示例时按下 Ctrl-C,则回溯将使用到目前为止的所有 recursive() 调用构建,即使这比 C 栈中可能容纳的数量还要多。这些帧在 C 栈的意义上是“重叠”的;更准确地说,它们根据需要从 C 栈中复制出来并复制到 C 栈中。

(上面的示例还利用了以下一般“指南”来帮助新手编写 continulets:在 bootstrap(c) 中,只调用 c 上的方法,而不是另一个 continulet 对象上的方法。这就是为什么我们写了 c.switch(to=to) 而不是 to.switch(),这会导致状态混乱。但这只是一个指南;一般来说,我们建议使用其他接口,如 genlets 和 greenlets。)

Stacklets

Continulets 在内部使用 stacklets 实现,stacklets 是用于“一次性延续”的通用 RPython 级别的构建块。有关它们的更多信息,请参阅 C 源代码中的文档 rpython/translator/c/src/stacklet/stacklet.h

模块 rpython.rlib.rstacklet 是上述函数的薄包装器。关键是 new() 和 switch() 始终返回一个新的 stacklet 句柄(或一个空的句柄),并且 switch() 还会额外消耗一个。忽略返回的句柄或使用多次是没有意义的。请注意,stacklet.c 是在假设用户知道这一点的情况下编写的,因此不会进行额外的检查;如果您不使用像 PyPy 的 '_continuation' 模块这样的包装器,这很容易导致难以理解的崩溃。

可组合性理论

尽管协程的概念并不新鲜,但它们并没有被普遍集成到主流语言中,或者只是以有限的形式集成(例如 Python 中的生成器和 C# 中的迭代器)。我们可以争论说,造成这种情况的一个可能原因是它们在程序复杂性增加时无法很好地扩展:它们在小型示例中看起来很有吸引力,但需要显式切换的模型(例如通过命名目标协程)不能自然地组合。这意味着使用协程来完成两个无关目的的程序可能会遇到由意外交互引起的冲突。

为了说明这个问题,请考虑以下示例(使用理论上的 coroutine 类简化的代码)。首先,协程的简单用法

main_coro = coroutine.getcurrent()    # the main (outer) coroutine
data = []

def data_producer():
    for i in range(10):
        # add some numbers to the list 'data' ...
        data.append(i)
        data.append(i * 5)
        data.append(i * 25)
        # and then switch back to main to continue processing
        main_coro.switch()

producer_coro = coroutine()
producer_coro.bind(data_producer)

def grab_next_value():
    if not data:
        # put some more numbers in the 'data' list if needed
        producer_coro.switch()
    # then grab the next value from the list
    return data.pop(0)

每次调用 grab_next_value() 都返回一个值,但如果需要,它会切换到生产者函数(并返回)以使其有机会在其中放入更多数字。

现在考虑用协程重新实现 Python 的生成器

def generator(f):
    """Wrap a function 'f' so that it behaves like a generator."""
    def wrappedfunc(*args, **kwds):
        g = generator_iterator()
        g.bind(f, *args, **kwds)
        return g
    return wrappedfunc

class generator_iterator(coroutine):
    def __iter__(self):
        return self
    def next(self):
        self.caller = coroutine.getcurrent()
        self.switch()
        return self.answer

def Yield(value):
    """Yield the value from the current generator."""
    g = coroutine.getcurrent()
    g.answer = value
    g.caller.switch()

def squares(n):
    """Demo generator, producing square numbers."""
    for i in range(n):
        Yield(i * i)
squares = generator(squares)

for x in squares(5):
    print x       # this prints 0, 1, 4, 9, 16

这两个示例都具有吸引人的优雅性。但是,它们不能组合。如果我们尝试编写以下生成器

def grab_values(n):
    for i in range(n):
        Yield(grab_next_value())
grab_values = generator(grab_values)

如果程序的行为与预期不符,原因如下。执行 grab_values() 的生成器协程调用 grab_next_value(),这可能会切换到 producer_coro 协程。到目前为止,这都正常工作,但从 data_producer() 切换回 main_coro 会进入错误的协程:它在主协程中恢复执行,而不是它来自的协程。我们期望 data_producer() 切换回 grab_next_values() 调用,但后者位于 wrappedfunc 中创建的生成器协程 g 中,而 data_producer() 代码完全不知道它。相反,我们实际上切换回了主协程,这会使 generator_iterator.next() 方法感到困惑(它恢复了,但不是作为对 Yield() 的调用的结果)。

因此,协程的概念是 *不可组合* 的。相反,连续体的原始概念是可组合的:如果您在其之上构建两个不同的接口,或者有一个程序在两个部分中使用两次相同的接口,那么假设这两个部分独立工作,这两个部分的组合仍然可以工作。

对该主张的完整证明需要仔细的定义,但让我们只声称这个事实是正确的,因为以下观察:连续体的 API 要求程序在进行 switch() 时,必须明确地操作一些连续体。它将当前的延续与存储在该连续体中的延续进行混洗,但不会对外部产生影响。因此,如果程序的一部分有一个连续体对象,并且没有将其公开为全局对象,那么程序的其余部分就不能意外地影响存储在该连续体对象中的延续。

换句话说,如果我们将连续体对象视为本质上是一个可修改的 f_back,那么它只是 callable() 的框架与其父框架之间的链接——只要它们没有显式地操作连续体对象,它就不能被无关的代码任意更改。通常,callable() 的框架(通常是局部函数)及其父框架(切换到它的框架)都属于同一个类或模块;因此,从这个角度来看,连续体是两个局部框架之间纯粹的局部链接。没有意义去拥有一个允许从外部操作此链接的概念。