【问题标题】:Start asyncio event loop in separate thread and consume queue items在单独的线程中启动异步事件循环并使用队列项
【发布时间】:2020-12-30 12:41:34
【问题描述】:

我正在编写一个 Python 程序,它同时运行从队列中获取的任务,以学习 asyncio

通过与主线程(在 REPL 中)交互,将项目放入队列中。 每当一个任务被放入队列时,它应该被立即消费并执行。 我的方法是启动一个单独的线程并将队列传递给该线程内的事件循环。

任务正在运行,但只是按顺序运行,我不清楚如何同时运行这些任务。我的尝试如下:

import asyncio
import time
import queue
import threading

def do_it(task_queue):
    '''Process tasks in the queue until the sentinel value is received'''
    _sentinel = 'STOP'

    def clock():
        return time.strftime("%X")

    async def process(name, total_time):
        status = f'{clock()} {name}_{total_time}:'
        print(status, 'START')
        current_time = time.time()
        end_time = current_time + total_time
        while current_time < end_time:
            print(status, 'processing...')
            await asyncio.sleep(1)
            current_time = time.time()
        print(status, 'DONE.')

    async def main():
        while True:
            item = task_queue.get()
            if item == _sentinel:
                break
            await asyncio.create_task(process(*item))

    print('event loop start')
    asyncio.run(main())
    print('event loop end')


if __name__ == '__main__':
    tasks = queue.Queue()
    th = threading.Thread(target=do_it, args=(tasks,))
    th.start()

    tasks.put(('abc', 5))
    tasks.put(('def', 3))

任何能指出我同时运行这些任务方向的建议都将不胜感激!
谢谢

更新
谢谢 Frank Yellin 和 cynthi8!我根据您的建议对 main() 进行了改革:

  • asyncio.create_task 之前删除了await - 修复了并发性
  • 添加了 wait while 循环,以便 main 不会过早返回
  • 使用了Queue.get()的非阻塞模式

程序现在按预期运行 ????

更新 2
user4815162342 提供了进一步的改进,我在下面注释了他的建议。

'''
Starts auxiliary thread which establishes a queue and consumes tasks within a
queue.
    
Allow enqueueing of tasks from within __main__ and termination of aux thread
'''
import asyncio
import time
import threading
import functools

def do_it(started):
    '''Process tasks in the queue until the sentinel value is received'''
    _sentinel = 'STOP'

    def clock():
        return time.strftime("%X")

    async def process(name, total_time):
        print(f'{clock()} {name}_{total_time}:', 'Started.')
        current_time = time.time()
        end_time = current_time + total_time
        while current_time < end_time:
            print(f'{clock()} {name}_{total_time}:', 'Processing...')
            await asyncio.sleep(1)
            current_time = time.time()
        print(f'{clock()} {name}_{total_time}:', 'Done.')

    async def main():
        # get_running_loop() get the running event loop in the current OS thread
        # out to __main__ thread
        started.loop = asyncio.get_running_loop()
        started.queue = task_queue = asyncio.Queue()
        started.set()
        while True:
            item = await task_queue.get()
            if item == _sentinel:
                # task_done is used to tell join when the work in the queue is 
                # actually finished. A queue length of zero does not mean work
                # is complete.
                task_queue.task_done()
                break
            task = asyncio.create_task(process(*item))
            # Add a callback to be run when the Task is done.
            # Indicate that a formerly enqueued task is complete. Used by queue 
            # consumer threads. For each get() used to fetch a task, a 
            # subsequent call to task_done() tells the queue that the processing
            # on the task is complete.
            task.add_done_callback(lambda _: task_queue.task_done())            

        # keep loop going until all the work has completed
        # When the count of unfinished tasks drops to zero, join() unblocks.
        await task_queue.join()

    print('event loop start')
    asyncio.run(main())
    print('event loop end')

if __name__ == '__main__':
    # started Event is used for communication with thread th
    started = threading.Event()
    th = threading.Thread(target=do_it, args=(started,))
    th.start()
    # started.wait() blocks until started.set(), ensuring that the tasks and
    # loop variables are available from the event loop thread
    started.wait()
    tasks, loop = started.queue, started.loop

    # call_soon schedules the callback callback to be called with args arguments
    # at the next iteration of the event loop.
    # call_soon_threadsafe is required to schedule callbacks from another thread 
    
    # put_nowait enqueues items in non-blocking fashion, == put(block=False)
    loop.call_soon_threadsafe(tasks.put_nowait, ('abc', 5))
    loop.call_soon_threadsafe(tasks.put_nowait, ('def', 3))
    loop.call_soon_threadsafe(tasks.put_nowait, 'STOP')

【问题讨论】:

    标签: python concurrency queue task python-asyncio


    【解决方案1】:

    正如其他人指出的那样,您的代码的问题在于它使用了一个阻塞队列,该队列在等待下一个项目时会停止事件循环。然而,所提出的解决方案的问题在于它引入了延迟,因为它必须偶尔休眠以允许其他任务运行。除了引入延迟之外,它还可以防止程序进入睡眠状态,即使队列中没有项目。

    另一种方法是切换到asyncio queue,它专为与 asyncio 一起使用而设计。这个队列必须在运行循环中创建,所以你不能将它传递给do_it,你必须检索它。此外,由于它是一个 asyncio 原语,它的 put 方法必须通过 call_soon_threadsafe 调用,以确保事件循环注意到它。

    最后一个问题是您的main() 函数使用另一个繁忙循环来等待所有任务完成。这可以通过使用Queue.join 来避免,对于这个用例,explicitly designed

    以下是您的代码,该代码适用于包含上述所有建议,process 函数与您的原始代码保持不变:

    import asyncio
    import time
    import threading
    
    def do_it(started):
        '''Process tasks in the queue until the sentinel value is received'''
        _sentinel = 'STOP'
    
        def clock():
            return time.strftime("%X")
    
        async def process(name, total_time):
            status = f'{clock()} {name}_{total_time}:'
            print(status, 'START')
            current_time = time.time()
            end_time = current_time + total_time
            while current_time < end_time:
                print(status, 'processing...')
                await asyncio.sleep(1)
                current_time = time.time()
            print(status, 'DONE.')
    
        async def main():
            started.loop = asyncio.get_running_loop()
            started.queue = task_queue = asyncio.Queue()
            started.set()
            while True:
                item = await task_queue.get()
                if item == _sentinel:
                    task_queue.task_done()
                    break
                task = asyncio.create_task(process(*item))
                task.add_done_callback(lambda _: task_queue.task_done())
            await task_queue.join()
    
        print('event loop start')
        asyncio.run(main())
        print('event loop end')
    
    if __name__ == '__main__':
        started = threading.Event()
        th = threading.Thread(target=do_it, args=(started,))
        th.start()
        started.wait()
        tasks, loop = started.queue, started.loop
    
        loop.call_soon_threadsafe(tasks.put_nowait, ('abc', 5))
        loop.call_soon_threadsafe(tasks.put_nowait, ('def', 3))
        loop.call_soon_threadsafe(tasks.put_nowait, 'STOP')
    

    注意:与您的代码无关的问题是它等待create_task() 的结果,这使create_task() 的用处无效,因为它不允许在后台运行。 (这相当于立即加入你刚刚开始的线程 - 你可以这样做,但它没有多大意义。)这个问题在上面的代码和你对问题的编辑中都得到了解决。

    【讨论】:

    • 感谢您的回答,这比我的要好得多。我试图对代码进行最小的更改,而不是从头开始重写。
    • @FrankYellin 谢谢。在正常情况下,我支持对 OP 的代码进行最小的更改,但在这种情况下,我认为非阻塞 get 方法存在严重缺陷(尽管它确实提供了快速修复)。我希望从main() 内部提供队列和循环更容易,最大的变化基本上是将它们偷运出去。
    • 感谢您改进代码@user4815162342!我现在正在注释代码以在解释中发表评论,并将更新我的帖子。只有lambda 声明对我来说不是很清楚;您是否使用此构造来吞下task_done 方法的参数以适应add_done_callback 签名?
    • @user1330734 是的,这是lambda 的典型用法。我本可以将task_queue 发送到process 并在完成后调用task_queue.task_done() - 但这会使process() 稍微复杂化,并且看起来process 需要任务队列(否则它不需要) .另一种选择是将task_queue.task_done(注意缺少括号)作为回调发送到process,以便在完成时调用like this,但它仍然需要更改process(),因为我已经想避免这种情况了对其余大部分代码进行了更改。
    • 这段代码仍然存在一个问题,如果队列已满,则在不可预知的时间内无法读取哨兵。所以没有办法可靠地阻止它。
    【解决方案2】:

    您的代码有两个问题。

    首先,await 不应位于 asyncio.create_task 之前。这可能是导致您的代码同步运行的原因。

    然后,一旦您使代码异步运行,您需要在main 中的 while 循环之后执行一些操作,以便代码不会立即返回,而是等待所有作业完成。另一个stackoverflowanswer推荐:

    while len(asyncio.Task.all_tasks()) > 1:  # Any task besides main() itself?
        await asyncio.sleep(0.2)
    

    或者,Queue 的一些版本可以跟踪正在运行的任务。

    【讨论】:

    • 谢谢弗兰克,它现在正在工作,如果您有更好的实践建议,请查看我的更新。
    【解决方案3】:

    另外一个问题:

    如果 queue.Queue 为空,get() 默认阻塞并且不返回哨兵字符串。 https://docs.python.org/3/library/queue.html

    【讨论】:

    • 感谢 cynthi8,我已将 Queue.get() 更新为非阻塞。
    猜你喜欢
    • 1970-01-01
    • 2021-03-16
    • 1970-01-01
    • 2020-02-14
    • 1970-01-01
    • 1970-01-01
    • 2013-09-10
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多