Python:如何执行两个"聚合"函数(如sum)同时从同一个迭代器中提取它们

时间:2018-04-26 09:16:37

标签: python iterator async-await coroutine

想象一下,我们有一个迭代器,比如iter(range(1, 1000))。我们有两个函数,每个函数都接受一个迭代器作为唯一的参数,比如sum()max()。在SQL世界中,我们将其称为聚合函数。

有没有办法在不缓冲迭代器输出的情况下获得两者的结果?

为此,我们需要暂停和恢复聚合函数执行,以便为它们提供相同的值而不存储它们。也许是否有办法在没有睡眠的情况下使用异步事物来表达它?

2 个答案:

答案 0 :(得分:42)

让我们考虑如何将两个聚合函数应用于同一个迭代器,而该迭代器只能用一次。最初的尝试(为简便起见,将summax硬编码,但可以简单地推广为任意数量的聚合函数)可能看起来像这样:

def max_and_sum_buffer(it):
    content = list(it)
    p = sum(content)
    m = max(content)
    return p, m

此实现的缺点是,尽管两个函数都完全能够进行流处理,但它会立即将所有生成的元素存储在内存中。问题是预料到这种情况,并明确要求在不缓冲迭代器输出的情况下产生结果。可以这样做吗?

串行执行:itertools.tee

当然似乎是可能的。毕竟,Python迭代器是external,因此每个迭代器都已经能够挂起自身。提供一个将迭代器拆分为两个提供相同内容的新迭代器的适配器有多难?实际上,这正是itertools.tee的描述,它似乎非常适合并行迭代:

def max_and_sum_tee(it):
    it1, it2 = itertools.tee(it)
    p = sum(it1)  # XXX
    m = max(it2)
    return p, m

上面的方法产生了正确的结果,但是不能按照我们想要的方式工作。问题在于我们没有并行进行迭代。像summax这样的聚合函数永远不会挂起-每个函数都坚持在产生结果之前消耗所有迭代器内容。因此sum将在it1完全没有运行之前就耗尽max。单独使用it1时耗尽it2的元素将导致这些元素累积在两个迭代器之间共享的内部FIFO中。这在这里是不可避免的-因为max(it2)必须看到相同的元素,所以tee别无选择,只能累积它们。 (有关tee的更多有趣信息,请参考this post.

换句话说,此实现与第一个实现之间没有区别,除了第一个实现至少使缓冲是显式的。为了消除缓冲,summax必须并行运行,而不是一个接一个地运行。

线程:current.futures

让我们看看如果我们在单独的线程中运行聚合函数,仍然使用tee复制原始迭代器会发生什么情况:

def max_and_sum_threads_simple(it):
    it1, it2 = itertools.tee(it)

    with concurrent.futures.ThreadPoolExecutor(2) as executor:
        sum_future = executor.submit(lambda: sum(it1))
        max_future = executor.submit(lambda: max(it2))

    return sum_future.result(), max_future.result()

现在summax实际上并行运行(在the GIL允许的范围内),线程由出色的concurrent.futures模块管理。但是,它有一个致命的缺陷:要使tee不缓冲数据,summax必须以完全相同的速率处理它们的项目。如果一个甚至比另一个快一点,它们就会分开,tee将缓冲所有中间元素。由于无法预测每个对象的运行速度,因此缓冲量既不可预测,又有缓冲所有内容的最糟糕的情况。

为确保不发生缓冲,必须用自定义生成器替换tee,该生成器不缓冲任何内容并阻塞,直到所有使用者均观察到前一个值,然后再进行下一个操作。和以前一样,每个使用者都在其自己的线程中运行,但是现在调用线程正在忙于运行一个生产者,这个循环实际上在源迭代器上进行迭代并发出新值可用的信号。这是一个实现:

def max_and_sum_threads(it):
    STOP = object()
    next_val = None
    consumed = threading.Barrier(2 + 1)  # 2 consumers + 1 producer
    val_id = 0
    got_val = threading.Condition()

    def send(val):
        nonlocal next_val, val_id
        consumed.wait()
        with got_val:
            next_val = val
            val_id += 1
            got_val.notify_all()

    def produce():
        for elem in it:
            send(elem)
        send(STOP)

    def consume():
        last_val_id = -1
        while True:
            consumed.wait()
            with got_val:
                got_val.wait_for(lambda: val_id != last_val_id)
            if next_val is STOP:
                return
            yield next_val
            last_val_id = val_id

    with concurrent.futures.ThreadPoolExecutor(2) as executor:
        sum_future = executor.submit(lambda: sum(consume()))
        max_future = executor.submit(lambda: max(consume()))
        produce()

    return sum_future.result(), max_future.result()

对于概念上如此简单的内容,这是相当多的代码,但是对于正确的操作而言,这是必需的。

produce()遍历外部迭代器,并将项一次发送给消费者。它使用barrier(Python 3.2中添加的一种方便的同步原语)来等待所有消费者使用旧值,然后再用next_val中的新值覆盖旧值。新值实际准备就绪后,将广播conditionconsume()是一个生成器,它在产生的值到达时传输它们,直到检测到STOP为止。通过在循环中创建使用者并在创建障碍时调整其数量,可以并行运行任意数量的聚合函数来通用代码。

此实现的缺点是,它需要创建线程(可能通过将线程池设置为全局线程来缓解),并且在每次迭代通过时都要进行非常仔细的同步。这种同步破坏了性能-该版本比单线程tee慢近2000倍,比简单但不确定的线程版本慢475倍。

不过,只要使用线程,就无法避免某种形式的同步。要完全消除同步,我们必须放弃线程并切换到协作式多任务处理。问题是,是否可以暂停执行summax之类的普通同步功能以便在它们之间进行切换?

纤维:绿色

事实证明,greenlet第三方扩展模块完全启用了该功能。 Greenlets是fibers的实现,这是一种轻量级的微线程,可在彼此之间显式切换。这有点像Python生成器,它们使用yield来挂起,除了greenlet提供了一种更加灵活的挂起机制,允许人们选择谁来挂起 to

这使得将max_and_sum的线程版本移植到greenlets非常容易:

def max_and_sum_greenlet(it):
    STOP = object()
    consumers = None

    def send(val):
        for g in consumers:
            g.switch(val)

    def produce():
        for elem in it:
            send(elem)
        send(STOP)

    def consume():
        g_produce = greenlet.getcurrent().parent
        while True:
            val = g_produce.switch()
            if val is STOP:
                return
            yield val

    sum_result = []
    max_result = []
    gsum = greenlet.greenlet(lambda: sum_result.append(sum(consume())))
    gsum.switch()
    gmax = greenlet.greenlet(lambda: max_result.append(max(consume())))
    gmax.switch()
    consumers = (gsum, gmax)
    produce()

    return sum_result[0], max_result[0]

逻辑相同,但代码更少。和以前一样,produce会从源迭代器中获取值,但是它的send不会因为同步而烦恼,因为当所有内容都是单线程时,它并不需要这样做。取而代之的是,它明确地切换到每个消费者执行其操作,而消费者则忠实地切换回去。在遍历所有消费者之后,生产者准备好进行下一次迭代。

使用中间的单元素列表检索结果,因为greenlet不提供对目标函数返回值的访问权限(threading.Thread也不提供),因此我们在上面选择了concurrent.futures )。

不过,使用greenlet有一些缺点。首先,它们没有标准库,您需要安装greenlet扩展。然后,greenlet本质上是不可移植的,因为操作系统和编译器会not supported进行堆栈切换代码,并且可以将其视为骇客(尽管是extremely clever)。面向WebAssemblyJVMGraalVM的Python不太可能支持greenlet。这不是一个紧迫的问题,但是从长远来看,绝对是要记住的事情。

协程:asyncio

从Python 3.5开始,Python提供了本机协程。协程与greenlet不同,类似于生成器,协程不同于常规函数,必须使用async def来定义。协程不能轻易地从同步代码中执行,而必须由调度程序来处理,以使其完成。调度程序也称为事件循环,因为它的另一项工作是接收IO事件并将其传递给适当的回调和协程。在标准库中,这就是asyncio模块的作用。

在实现基于异步的max_and_sum之前,我们必须首先解决一个障碍。与greenlet不同,asyncio仅能暂停协程的执行,而不能暂停任意功能。因此,我们需要用功能相同的协程替换summax。这就像以明显的方式实现它们一样简单,只需将for替换为async for,使async iterator可以在等待下一个值到达时暂停协程:

async def asum(it):
    s = 0
    async for elem in it:
        s += elem
    return s

async def amax(it):
    NONE_YET = object()
    largest = NONE_YET
    async for elem in it:
        if largest is NONE_YET or elem > largest:
            largest = elem
    if largest is NONE_YET:
        raise ValueError("amax() arg is an empty sequence")
    return largest

# or, using https://github.com/vxgmichel/aiostream
#
#from aiostream.stream import accumulate
#def asum(it):
#    return accumulate(it, initializer=0)
#def amax(it):
#    return accumulate(it, max)

一个人可能会合理地询问提供一对新的聚合函数是否在作弊;毕竟,以前的解决方案都谨慎使用现有的summax内置程序。答案将取决于对问题的确切解释,但是我会认为允许使用新功能,因为它们绝不是针对当前任务的特定功能。它们执行与内置程序完全相同的操作,但是消耗异步迭代器。我怀疑这种功能在标准库中不存在的唯一原因是协程和异步迭代器是一个相对较新的功能。

这样一来,我们就可以将max_and_sum编写为协程了:

async def max_and_sum_asyncio(it):
    loop = asyncio.get_event_loop()
    STOP = object()

    next_val = loop.create_future()
    consumed = loop.create_future()
    used_cnt = 2  # number of consumers

    async def produce():
        for elem in it:
            next_val.set_result(elem)
            await consumed
        next_val.set_result(STOP)

    async def consume():
        nonlocal next_val, consumed, used_cnt
        while True:
            val = await next_val
            if val is STOP:
                return
            yield val
            used_cnt -= 1
            if not used_cnt:
                consumed.set_result(None)
                consumed = loop.create_future()
                next_val = loop.create_future()
                used_cnt = 2
            else:
                await consumed

    s, m, _ = await asyncio.gather(asum(consume()), amax(consume()),
                                   produce())
    return s, m

尽管此版本基于单线程内部协程之间的切换,就像使用greenlet的协程一样,但它看起来却有所不同。 asyncio不提供协程的显式切换,它基于await暂停/恢复原语进行任务切换。 await的目标可以是另一个协程,但也可以是抽象的“未来”,即一个值占位符,稍后将由其他一些协程填充。一旦等待的值变为可用,事件循环将自动恢复协程的执行,其中await表达式的计算结果为提供的值。因此,它没有produce切换到消费者,而是通过等待所有消费者观察到产值的未来来暂停自身。

consume()asynchronous generator,与普通生成器类似,不同之处在于它创建了一个异步迭代器,我们的集合协程已经准备好使用async for接受它。相当于__next__的异步迭代器称为__anext__,它是一个协程,它使耗尽异步迭代器的协程在等待新值到达时挂起。当运行中的异步生成器在await上挂起时,async for将其视为隐式__anext__调用的挂起。 consume()恰好在等待produce提供的值时执行此操作,并在它们可用时将其传输到聚合的协程中,例如asumamax。等待是通过next_val Future实现的,该Future携带it中的下一个元素。在consume()中等待未来的发展会暂停异步生成器,并与之一起聚合协程。

与greenlets的显式切换相比,此方法的优势在于,它可以更轻松地将彼此不认识的协程组合到同一事件循环中。例如,一个max_and_sum的两个实例可以并行运行(在同一线程中),或者运行一个更复杂的聚合函数,该函数调用进一步的异步代码来进行计算。

以下便利功能显示了如何从非异步代码运行以上内容:

def max_and_sum_asyncio_sync(it):
    # trivially instantiate the coroutine and execute it in the
    # default event loop
    coro = max_and_sum_asyncio(it)
    return asyncio.get_event_loop().run_until_complete(coro)

性能

测量和比较这些并行执行方法的性能可能会产生误导,因为summax几乎不执行任何处理,这会增加并行化的开销。像对待任何微基准一样对待它们,要加大量盐。话虽如此,让我们还是看看数字吧!

使用Python 3.6进行测量。函数仅运行一次并给定range(10000),通过在执行前后减去time.time()来测量其时间。结果如下:

  • max_and_sum_buffermax_and_sum_tee:0.66毫秒-两者几乎完全相同,而tee版本的速度更快。

  • max_and_sum_threads_simple:2.7毫秒。由于没有确定性的缓冲,因此计时几乎没有意义,因此这可能是在测量启动两个线程的时间以及Python内部执行的同步。

  • max_and_sum_threads:1.29 ,是迄今为止最慢的选项,比最快的选项慢约2000倍。这种可怕的结果很可能是由于在迭代的每个步骤执行的多个同步以及它们与GIL的交互所导致的。

  • max_and_sum_greenlet:25.5毫秒,与初始版本相比较慢,但比线程版本要快得多。通过足够复杂的聚合函数,可以想象在生产环境中使用此版本。

  • max_and_sum_asyncio:351毫秒,比greenlet版本慢14倍。这是一个令人失望的结果,因为异步协程比greenlet更轻巧,并且在它们之间进行切换比在纤维之间进行切换要更快。运行协程调度程序和事件循环的开销(在这种情况下,考虑到代码没有IO,这是过大的)可能会破坏此微基准测试的性能。

  • max_and_sum_asyncio使用uvloop:125毫秒。这是常规异步速度的两倍以上,但仍比greenlet慢近5倍。

PyPy下运行示例并不会带来明显的提速,实际上,即使运行了几次以确保JIT预热,大多数示例的运行也会稍慢一些。 asyncio函数要求rewrite不使用异步生成器(因为在撰写本文时,PyPy实现了Python 3.5),并且执行时间不到100毫秒。这与CPython + uvloop的性能相当,即更好,但与greenlet相比并没有戏剧性。

答案 1 :(得分:5)

如果它对您的聚合函数成立f(a,b,c,...) == f(a, f(b, f(c, ...))),那么您就可以循环浏览函数并一次馈入一个元素,每次将它们与上一个应用程序的结果结合起来,例如{{1 }}可以,例如像这样:

reduce

这比在列表中具体化迭代器并将列表中的聚合函数整体应用或使用def aggregate(iterator, *functions): first = next(iterator) result = [first] * len(functions) for item in iterator: for i, f in enumerate(functions): result[i] = f((result[i], item)) return result (基本上在内部做相同的事情)要慢得多(约10-20倍) ),但它的好处是不使用额外的内存。

不过,请注意,尽管这对于itertools.teesummin之类的函数非常有效,但不适用于其他聚合函数,例如查找迭代器的均值或中值元素,例如max。 (对于mean(a, b, c) != mean(a, mean(b, c)),您当然可以只获取mean并将其除以元素数,但是计算例如一次只取一个元素的中位数会更具挑战性。)