基于生成器的协同程序看似无限递归

时间:2017-10-02 16:31:57

标签: python python-3.x recursion generator coroutine

以下内容摘自David Beazley关于发电机的幻灯片(here对任何感兴趣的人)。

定义了一个Task类,它包含一个生成期货的生成器Task类,完整(没有错误处理),如下:

class Task:
    def __init__(self, gen):
        self._gen = gen

    def step(self, value=None):
        try:
            fut = self._gen.send(value)
            fut.add_done_callback(self._wakeup)
        except StopIteration as exc:
            pass

    def _wakeup(self, fut):
        result = fut.result()
        self.step(result)

在一个例子中,还定义了以下递归函数:

from concurrent.futures import ThreadPoolExecutor
import time

pool = ThreadPoolExecutor(max_workers=8)

def recursive(n):
   yield pool.submit(time.sleep, 0.001)
   print("Tick :", n)
   Task(recursive(n+1)).step()

以下两个案例:

  1. 从Python REPL中,如果我们定义这些(或者如果我们将它们放在一个文件中,则导入它们),然后使用以下命令跳转启动递归:

    Task(recursive(0)).step()
    

    它开始打印,看起来已经超过了递归限制。它显然并没有超过它,打印堆栈级别表明它在整个执行过程中保持不变。还有其他事情我不太明白。

    注意 :如果您执行此操作,则需要终止python进程。

  2. 如果我们将所有内容(Taskrecursive)放在一个文件中:

    if __name__ == "__main__":
        Task(recursive(0)).step()
    

    然后使用python myfile.py运行它,它会停在7max_workers的数量,似乎)。

  3. 我的问题是它是如何超越递归限制的,为什么它根据你执行它的方式表现不同?

    行为出现在Python 3.6.2和Python 3.5.4上(我猜其他人也在3.63.5家庭中。)

2 个答案:

答案 0 :(得分:11)

您显示的recursive生成器实际上不会以导致系统递归限制问题的方式递归。

了解为何需要注意recursive生成器代码何时运行。与普通函数不同,只是调用recursive(0)并不会导致它立即运行其代码并进行额外的递归调用。相反,调用recursive(0)会立即返回生成器对象。只有当您send()生成代码时代码才会运行,并且只有在您send()之后再次启动它才能启动另一个调用。

让代码在代码运行时检查调用堆栈。在顶层,我们运行Task(recursive(0)).step()。这按顺序完成了三件事:

  1. recursive(0)此调用立即返回生成器对象。
  2. Task(_)创建了Task对象,其__init__方法存储了对第一步中创建的生成器对象的引用。
  3. _.step()调用任务上的方法。这是行动真正开始的地方!让我们来看看通话中发生的事情:

    • fut = self._gen.send(value)这里我们实际上通过向它发送值来启动生成器运行。让我们更深入,看看生成器代码运行:
      • yield pool.submit(time.sleep, 0.001)这会安排在另一个线程中完成的事情。我们不会等待它发生。相反,我们会得到一个Future,我们可以使用fut.add_done_callback(self._wakeup)在完成后收到通知。我们立即回到上一级代码。
    • _wakeup()我们要求在未来准备就绪时调用我们的step方法。这总是立即返回!
    • time.sleep方法现在结束。没错,我们已经完成了(目前)!这对于您的问题的第二部分非常重要,稍后我将对此进行讨论。
  4. 我们的调用已结束,因此如果我们以交互方式运行,控制流将返回REPL。如果我们作为脚本运行,则解释器将到达脚本的末尾并开始关闭(我将在下面更详细地讨论)。但是,线程池控制的其他线程仍然在运行,并且在某些时候,其中一个线程将执行我们关心的一些事情!让我们看看那是什么。

  5. 当计划的函数(Future)运行完毕后,它运行的线程将调用我们在Task._wakup()对象上设置的回调。也就是说,它会在我们之前创建的Task对象上调用Future(我们在顶层不再引用该对象,但result = fut.result()保留了引用所以它还活着)。让我们来看看方法:

    • None存储延期通话的结果。在这种情况下,这是无关紧要的,因为我们从不查看结果(无论如何都是self.step(result))。
    • fut = self._gen.send(value)再一步!现在我们回到我们关心的代码。让我们看看它这次做了什么:
      • yield再次发送给生成器,因此它接管了。它已经产生了一次,所以这次我们在print("Tick :", n)之后开始:
        • Task(recursive(n+1)).step()这很简单。
        • step()这是事情变得有趣的地方。这条线就像我们开始的那样。因此,像以前一样,这将运行上面列出的逻辑1-4(包括它们的子步骤)。但是,当recursive()方法返回时,它不会返回到REPL或结束脚本,而是返回到此处。
        • StopIteration生成器(原始生成器,而不是我们刚刚创建的新生成器)已到达终点。因此,就像任何到达其末尾代码的生成器一样,它会引发StopIteration
      • try / except块捕获并忽略step()_wakup()方法结束。
    • Task方法也会结束,因此回调已完成。
  6. 最终也会调用先前回调中创建的Task.step的回调。所以我们回过头来,一遍又一遍地重复步骤5(如果我们以交互方式运行)。
  7. 上面的调用堆栈解释了为什么交互式案例永远打印。主线程返回到REPL(如果你可以看到其他线程的输出,你可以用它来做其他事情)。但是在池中,每个线程从其自己的作业的回调中调度另一个作业。当下一个作业完成时,它的回调会安排另一个作业,依此类推。

    那么为什么在将代码作为脚本运行时,只能获得8个打印输出?答案在上面的第4步中暗示。当以非交互方式运行时,主线程在第一次调用ThreadPoolExecutor后返回脚本末尾。这会提示解释器尝试关闭。

    concurrent.futures.thread模块(其中定义了concurrent.futures.thread)有一些奇特的逻辑,当程序在执行程序仍处于活动状态时关闭时,它会尝试很好地清理。它应该停止任何空闲线程,并在当前作业完成时发出任何仍在运行的信号。

    清理逻辑的确切实现以非常奇怪的方式与我们的代码交互(可能有也可能没有错误)。结果是第一个线程不断给自己做更多的工作,而产生的额外工作线程在它们产生后立即退出。当执行程序启动了它想要使用的线程数时,第一个工作程序最终退出(在我们的例子中为8)。

    根据我的理解,这里是事件的顺序。

    1. 我们导入(间接)atexit模块,它使用_python_exit告诉解释器在解释器关闭之前运行一个名为ThreadPoolExecutor的函数。
    2. 我们创建一个sleep,其最大线程数为8.它不会立即生成其工作线程,但每次调度作业时都会创建一个,直到它全部为8。
    3. 我们安排了第一份工作(在上一个清单的第3步深层嵌套部分中)。
    4. 执行程序将作业添加到其内部队列,然后通知它没有最大数量的工作线程并启动一个新线程。
    5. 新线程将作业从队列中弹出并开始运行。但是,_python_exit调用比其他步骤花费的时间要长得多,所以线程会在这里停留一段时间。
    6. 主线程完成(它已到达上一个列表中的第4步)。
    7. 解释器调用_shutdown函数,因为解释器想要关闭。该函数在模块中设置一个全局None变量,并将None发送到执行程序的内部队列(每个线程发送一个None,但只有到目前为止创建了一个线程,所以它只发送一个time.sleep)。然后它阻塞主线程,直到它知道的线程退出。这会延迟解释器的关闭。
    8. 工作线程对Future的调用返回。它调用在其作业None中注册的回调函数,该作业调度另一个作业。
    9. 与此列表的第4步一样,执行程序将作业排队,并启动另一个帖子,因为它还没有所需的号码。
    10. 新线程尝试从内部队列中获取作业,但从步骤7获取_shutdown值,这是可以完成的信号。它看到None全局已设置,因此退出。在此之前,它会向队列添加另一个None
    11. 第一个工作线程完成其回调。它会查找一个新作业,并在步骤8中找到它自己排队的那个作业。它开始运行作业,就像在步骤5中一样,需要一段时间。
    12. 但是没有其他事情发生,因为第一个工作者是当前唯一的活动线程(主线程被阻塞等待第一个工作者死亡,另一个工作者自行关闭)。
    13. 我们现在重复步骤8-12几次。第一个工作线程将第三个到第八个作业排队,执行程序每次都产生一个相应的线程,因为它没有完整的集合。但是,每个线程立即死亡,因为它从作业队列中获取None而不是要完成的实际作业。第一个工作线程最终完成所有实际工作。
    14. 最后,在第8个工作之后,某些工作方式不同。这次,当回调计划另一个作业时,不会产生额外的线程,因为执行者已经知道它已经启动了所请求的8个线程(它不知道7个已经关闭)。
    15. 所以这一次,内部作业队列头部的_python_exit被第一个工作人员(而不是实际工作)接收。这意味着它会关闭,而不是做更多的工作。
    16. 当第一个工作程序关闭时,主线程(等待它退出)最终可以解除阻塞,None功能完成。这使得解释器可以完全关闭。我们已经完成了!
    17. 这解释了我们看到的输出!我们获得了8个输出,所有输出都来自同一个工作线程(第一个产生的线程)。

      我认为在该代码中可能存在竞争条件。如果步骤11发生在步骤10之前,事情可能会中断。如果第一个工人从队列中取出ThreadPoolExecutor而另一个新产生的工人得到了真正的工作,则交换角色(第一个工人会死,另一个会完成剩下的工作) ,在这些步骤的后续版本中禁止更多的竞争条件)。但是,一旦第一个工人死亡,主线程就会被解除阻塞。由于它不知道其他线程(因为它们在等待线程列表时它们不存在),它将过早地关闭解释器。

      我不确定这场比赛是否有可能发生。我猜它不太可能,因为新线程启动和从队列中获取作业之间的代码路径长度比现有线程完成回调的路径要短得多(在排队新工作之后的部分)然后在队列中寻找另一个工作。

      我怀疑,当我们将代码作为脚本运行时,_shutdown让我们干净地退出是一个错误。除了执行程序自己的self._shutdown属性之外,排队新作业的逻辑应该检查全局ThreadPoolExecutor标志。如果是这样,在主线程完成后尝试排队另一个作业会引发异常。

      您可以通过在with声明中创建# create the pool below the definition of recursive() with ThreadPoolExecutor(max_workers=8) as pool: Task(recursive(0)).step() 来复制我认为更健全的行为:

      step()

      主线程从exception calling callback for <Future at 0x22313bd2a20 state=finished returned NoneType> Traceback (most recent call last): File "S:\python36\lib\concurrent\futures\_base.py", line 324, in _invoke_callbacks callback(self) File ".\task_coroutines.py", line 21, in _wakeup self.step(result) File ".\task_coroutines.py", line 14, in step fut = self._gen.send(value) File ".\task_coroutines.py", line 30, in recursive Task(recursive(n+1)).step() File ".\task_coroutines.py", line 14, in step fut = self._gen.send(value) File ".\task_coroutines.py", line 28, in recursive yield pool.submit(time.sleep, 1) File "S:\python36\lib\concurrent\futures\thread.py", line 117, in submit raise RuntimeError('cannot schedule new futures after shutdown') RuntimeError: cannot schedule new futures after shutdown 调用返回后很快就会崩溃。它看起来像这样:

      {{1}}

答案 1 :(得分:0)

让我们从 7号开始。这就是您已经提到的工人数量,标记为 [0..7] 任务类需要以函数标识符的形式传递recursive

Task(recursive).step(n) 

而不是

Task(recursive(n)).step()

这是因为,递归函数需要在pool环境中调用,而在当前情况下recursive在主线程本身中进行评估。 time.sleep是当前代码中唯一一个在任务池中计算的函数。

代码主要问题的关键方面是递归。池中的每个线程都依赖于内部函数,将执行的上限设置为可用的工作者数量。该功能无法完成,因此新的功能无法执行。因此,它在达到递归限制之前终止。