【问题标题】:Python native coroutines and send()Python 原生协程和 send()
【发布时间】:2016-03-31 20:36:54
【问题描述】:

基于生成器的协程有一个send() 方法,它允许调用者和被调用者之间的双向通信,并从调用者恢复生成的生成器协程。这是将生成器变成协程的功能。

虽然新的原生 async/await 协程为异步 I/O 提供了出色的支持,但我不知道如何使用它们获得与 send() 等效的功能。明确禁止在async 函数中使用yield,因此本机协程只能使用return 语句返回一次。尽管await 表达式为协程带来了新值,但这些值来自被调用者,而不是调用者,并且等待的调用每次都从头开始计算,而不是从中断处开始。

有没有办法从它停止的地方恢复返回的协程并可能发送一个新值? 如何使用原生协程模拟 David Beazley 的 Curious Course on Coroutines and Concurrency 中的技术?

我想到的一般代码模式类似于

def myCoroutine():
  ...
  while True:
    ...
    ping = yield(pong)
    ...

在调用者中

while True:
  ...
  buzz = myCoroutineGen.send(bizz)
  ...

编辑

我接受了 Kevin 的回答,但我注意到 PEP says

协程内部基于生成器,因此它们共享实现。与生成器对象类似,协程也有 throw() 、 send() 和 close() 方法。

...

throw() , send() 协程方法用于将值推送到类似 Future 的对象中。

所以显然原生协程确实有send()?如果没有yield 表达式来接收协程内部的值,它是如何工作的?

【问题讨论】:

  • 我仍然不清楚你想通过使用这个 Code Pattern 解决什么问题,或者为什么你不能只使用yield from generator-coroutines。
  • 我想看看如何移植问题链接到的 David Beazley 的代码,但这不是 async/await 的目的。

标签: python async-await coroutine


【解决方案1】:

在完成了 Beazley 的相同(我必须说非常棒)协程课程后,我问自己同样的问题 - 如何调整代码以使用 Python 3.5 中引入的本机协程?

事实证明可以对代码进行相对较小的更改。我假设读者熟悉课程材料,并以pyos4.py 版本为基础——第一个支持“系统调用”的Scheduler 版本。

提示:可在末尾的附录 A 中找到完整的可运行示例。

目标

目标是转如下协程代码:

def foo():
    mytid = yield GetTid()  # a "system call"
    for i in xrange(3):
        print "I'm foo", mytid
        yield  # a "trap"

... 进入原生协程并仍然像以前一样使用:

async def foo():
    mytid = await GetTid()  # a "system call"
    for i in range(3):
        print("I'm foo", mytid)
        await ???  # a "trap" (will explain the missing bit later)

我们希望在没有asyncio 的情况下运行它,因为我们已经有自己的事件循环来驱动整个过程 - 它是Scheduler 类。

等待对象

原生协程不能立即工作,以下代码会导致错误:

async def foo():
    mytid = await GetTid()
    print("I'm foo", mytid)

sched = Scheduler()
sched.new(foo())
sched.mainloop()
回溯(最近一次通话最后): ... mytid = 等待 GetTid() 类型错误:对象 GetTid 不能在“等待”表达式中使用

PEP 492 解释了可以等待什么样的对象。其中一个选项是“带有__await__ 方法的对象返回一个迭代器”

就像yield from,如果你熟悉的话,await 充当等待对象和驱动协程的最外层代码(通常是事件循环)之间的隧道。最好用一个例子来证明这一点:

class Awaitable:
    def __await__(self):
        value = yield 1
        print("Awaitable received:", value)
        value = yield 2
        print("Awaitable received:", value)
        value = yield 3
        print("Awaitable received:", value)
        return 42


async def foo():
    print("foo start")
    result = await Awaitable()
    print("foo received result:", result)
    print("foo end")

以交互方式驱动 foo() 协程产生以下结果:

>>> f_coro = foo()  # calling foo() returns a coroutine object
>>> f_coro
<coroutine object foo at 0x7fa7f74046d0>
>>> f_coro.send(None)
foo start
1
>>> f_coro.send("one")
Awaitable received: one
2
>>> f_coro.send("two")
Awaitable received: two
3
>>> f_coro.send("three")
Awaitable received: three
foo received result: 42
foo end
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

发送到f_coro 的任何内容都会向下传递到Awaitable 实例。同样,Awaitable.__await__() 生成的任何内容都会冒泡到发送值的最顶层代码。

整个过程对f_coro协程是透明的,不直接参与,也看不到值的上下传递。然而,当Awaitable 的迭代器用完时,它的return 值变成await 表达式的结果(在我们的例子中是42),这就是f_coro 最终恢复的地方。

请注意,协程中的await 表达式也可以链接。一个协程可以等待另一个协程等待另一个协程......直到整个链以 yield 结尾。

将值发送到协程本身

这些知识对我们有何帮助?好吧,在课程材料中,协程可以产生一个SystemCall 实例。调度器理解这些并让系统调用处理请求的操作。

为了让协程将SystemCall 传送到调度程序,SystemCall 实例可以简单地让自己,并且它将被引导到调度程序,如前所述部分。

因此,第一个需要的更改是将此逻辑添加到基础 SystemCall 类中:

class SystemCall:
    ...
    def __await__(self):
        yield self

SystemCall 实例设为可等待后,现在实际运行以下代码:

async def foo():
    mytid = await GetTid()
    print("I'm foo", mytid)

>>> sched = Scheduler()
>>> sched.new(foo())
>>> sched.mainloop()

输出:

我是 foo 没有 任务 1 终止

太好了,它不再崩溃了!

但是协程没有收到任务ID,而是得到了None。这是因为系统调用的handle()方法设置的值是Task.run()方法发送的:

# in Task.run()
self.target.send(self.sendval)

... 以SystemCall.__await__() 方法结束。如果我们要将值带入协程,系统调用必须返回它,使其成为协程中await表达式的值。

class SystemCall:
    ...
    def __await__(self):
        return (yield self)

使用修改后的SystemCall 运行相同的代码会产生所需的输出:

我是富1 任务 1 终止

同时运行协程

我们仍然需要一种暂停协程的方法,即拥有一个系统“陷阱”代码。在课程材料中,这是在协程中使用纯 yield 完成的,但尝试使用纯 await 实际上是语法错误:

async def foo():
    mytid = await GetTid()
    for i in range(3):
        print("I'm foo", mytid)
        await  # SyntaxError here

幸运的是,解决方法很简单。由于我们已经有工作的系统调用,我们可以添加一个虚拟的无操作系统调用,其唯一的工作是暂停协程并立即重新调度它:

class YieldControl(SystemCall):
    def handle(self):
        self.task.sendval = None   # setting sendval is optional
        self.sched.schedule(self.task)

在任务上设置sendval 是可选的,因为此系统调用不会产生任何有意义的值,但我们选择明确说明。

我们现在已经具备了运行多任务操作系统的一切条件!

async def foo():
    mytid = await GetTid()
    for i in range(3):
        print("I'm foo", mytid)
        await YieldControl()


async def bar():
    mytid = await GetTid()
    for i in range(5):
        print("I'm bar", mytid)
        await YieldControl()


sched = Scheduler()
sched.new(foo())
sched.new(bar())
sched.mainloop()

输出:

我是富1 我是酒吧 2 我是富1 我是酒吧 2 我是富1 我是酒吧 2 任务 1 终止 我是酒吧 2 我是酒吧 2 任务 2 终止

脚注

Scheduler 代码完全没有改变。

它。只是。有效。

这显示了原始设计的美妙之处,其中调度程序和在其中运行的任务没有相互耦合,我们能够在 Scheduler 不知道的情况下更改协程实现。即使是包装协程的Task 类也不必更改。

不需要蹦床。

pyos8.py 版本的系统中,实现了蹦床 的概念。它允许协程在调度器的帮助下将他们的一部分工作委托给另一个协程(调度器代表父协程调用子协程并将前者的结果发送给父协程)。

不需要这种机制,因为await(以及它的旧伙伴yield from)已经使这种链接成为可能,如开头所述。

附录 A - 一个完整的可运行示例(需要 Python 3.5+)

example_full.py
from queue import Queue


# ------------------------------------------------------------
#                       === Tasks ===
# ------------------------------------------------------------
class Task:
    taskid = 0
    def __init__(self,target):
        Task.taskid += 1
        self.tid = Task.taskid   # Task ID
        self.target = target        # Target coroutine
        self.sendval = None          # Value to send

    # Run a task until it hits the next yield statement
    def run(self):
        return self.target.send(self.sendval)


# ------------------------------------------------------------
#                      === Scheduler ===
# ------------------------------------------------------------
class Scheduler:
    def __init__(self):
        self.ready = Queue()   
        self.taskmap = {}        

    def new(self,target):
        newtask = Task(target)
        self.taskmap[newtask.tid] = newtask
        self.schedule(newtask)
        return newtask.tid

    def exit(self,task):
        print("Task %d terminated" % task.tid)
        del self.taskmap[task.tid]

    def schedule(self,task):
        self.ready.put(task)

    def mainloop(self):
         while self.taskmap:
            task = self.ready.get()
            try:
                result = task.run()
                if isinstance(result,SystemCall):
                    result.task  = task
                    result.sched = self
                    result.handle()
                    continue
            except StopIteration:
                self.exit(task)
                continue
            self.schedule(task)


# ------------------------------------------------------------
#                   === System Calls ===
# ------------------------------------------------------------
class SystemCall:
    def handle(self):
        pass

    def __await__(self):
        return (yield self)


# Return a task's ID number
class GetTid(SystemCall):
    def handle(self):
        self.task.sendval = self.task.tid
        self.sched.schedule(self.task)


class YieldControl(SystemCall):
    def handle(self):
        self.task.sendval = None   # setting sendval is optional
        self.sched.schedule(self.task)


# ------------------------------------------------------------
#                      === Example ===
# ------------------------------------------------------------
if __name__ == '__main__':
    async def foo():
        mytid = await GetTid()
        for i in range(3):
            print("I'm foo", mytid)
            await YieldControl()


    async def bar():
        mytid = await GetTid()
        for i in range(5):
            print("I'm bar", mytid)
            await YieldControl()

    sched = Scheduler()
    sched.new(foo())
    sched.new(bar())
    sched.mainloop()

【讨论】:

  • 这个答案实际上回答了问题,应该有更多的积分
  • 感谢您推荐 Beazley 的协程课程——太棒了!感谢您花时间解释异步/等待所需的适应!我必须说我的脑袋爆炸了,但我希望我在此过程中学到了一些东西:)
  • 一开始,我的脑袋也爆炸了(这就是我们喜欢 Dave 课程的原因),但是一旦你理解了这个想法,它就会成为个人编程工具箱中一个新的强大补充。 :)
【解决方案2】:

有没有办法从停止的地方恢复返回的协程并可能发送一个新值?

没有。

asyncawait 只是 yield from 的语法糖。当协程返回时(使用return 语句),就是这样。框架不见了。它是不可恢复的。这正是生成器一直以来的工作方式。例如:

def foo():
    return (yield)

你可以f = foo(); next(f); f.send(5),你会得到5。但是如果你再次尝试f.send(),它不起作用,因为你已经从框架中返回了。 f 不再是实时生成器。

现在,至于新的协程,据我所知,它似乎为事件循环和某些基本谓词(如asyncio.sleep())之间的通信保留了屈服和发送。协程将@​​987654321@ 对象生成到事件循环,一旦相关操作完成,事件循环将这些相同的未来对象发送回协程(它们通常通过call_soon() 和其他事件循环方法进行调度)。

您可以通过等待它们来生成未来的对象,但它不是像.send() 那样的通用接口。它专门供事件循环实现使用。如果你没有实现事件循环,你可能不想玩这个。如果您正在实现一个事件循环,您需要问自己为什么asyncio 中完美的实现不足以满足您的目的,并解释具体您要尝试什么在我们为您提供帮助之前做。

请注意,yield from 未被弃用。如果您想要完全不绑定到事件循环的协程,只需使用它即可。 asyncawaitspecifically designed for asynchronous programming with event loops。如果这不是你正在做的,那么asyncawait 是错误的工具。

还有一件事:

明确禁止在异步函数中使用yield,因此原生协程只能使用return 语句返回一次。

await 表达式 产生控制。 await something() 完全类似于 yield from something()。他们只是更改了名称,以便对不熟悉生成器的人更直观。


对于那些真正有兴趣实现自己的事件循环的人,here's some example code 展示了一个(非常简单的)实现。这个事件循环非常精简,因为它旨在同步运行某些专门编写的协程,就好像它们是普通函数一样。它没有提供您期望从真正的 BaseEventLoop 实现中获得的全方位支持,并且与任意协程一起使用也不安全。

通常,我会在我的答案中包含代码,而不是链接到它,但存在版权问题,这对答案本身并不重要。

【讨论】:

  • 框架不见了。不可恢复。 那么调用新特性协程是否正确?从历史上看,保存状态和恢复的能力一直是协程的定义特征。 这正是生成器一直以来的工作方式。我不明白。带有yield 的循环的行为完全正确
  • @DanielMahler:每次await 时,状态都会被保存和恢复。只是控件最终会传递回事件循环,这是您(通常)没有编写的代码。但是return 在新协程中的含义与在旧的生成器协程中的含义完全相同:拆除框架。
  • 好的,但是 yield 是使生成器运行的原因。我的问题实际上是关于原生协程完全替代生成器协程,因为它们正在被推广,但我不确定它们是否真的是协程。
  • @DanielMahler:没有人说它们是完全替代品。它们是specifically designed for asynchronous programming,这必然意味着您将屈服于事件循环而不是任意代码。
  • 是的,但是协程这个词在计算机科学中的意义可以追溯到 60 年代。我试图解决的问题是弄清楚如何使用 async/await 执行实际的协程。现在我知道我不应该这样做。
猜你喜欢
  • 2017-10-28
  • 2018-10-14
  • 2017-03-26
  • 2011-08-22
  • 2015-12-11
  • 2020-02-27
  • 1970-01-01
  • 2017-09-02
  • 2011-05-27
相关资源
最近更新 更多