通过简单示例了解 AsyncIO 和线程顺序

Posted

技术标签:

【中文标题】通过简单示例了解 AsyncIO 和线程顺序【英文标题】:Understanding AsyncIO and order of thread with simple example 【发布时间】:2020-06-10 15:22:12 【问题描述】:

我有这个代码:

import asyncio

async def part1():
    print('1')
    await asyncio.sleep(2)
    print('5')


async def part2():
    print('2')
    await asyncio.sleep(2)
    print('6')


async def main():
    p1 = await part1()
    p2 = await part2()
    print('3')
    print('4')

asyncio.run(main())

当我运行它时,我希望打印出来

1
2
3
4
5
6

我理解的线程应该是这样的:

    启动 main() 函数 启动 part1() 函数 打印 1 当它等待 2 秒时,它应该在 p2 = await part2() 这条线上继续 启动 part2() 函数 打印 2 当它等待 2 秒时,它应该在 main() "print('3')" 的行上继续,然后 “打印('4')” part1() 函数结束它的睡眠,所以它打印 5 part2() 函数结束睡眠,因此打印 6

但是,它打印的是:

1
5
2
6
3
4

并等待 async.sleep(2) 的全部时间

我在这里错过了什么?

谢谢!

【问题讨论】:

你仍然只在一个线程中运行。当main 命中await part1() 时,它会暂停直到part1() 返回它。在那之前它不能继续它的“命令列表”。您设计async/await 模式的方式实际上并没有做任何事情,因为您还没有创建任何单独的任务和/或线程来运行它们。 嗯,我理解的方式是,当我通过 async.run() 调用 main() 时,它会创建一种“包装器”或对象来跟踪线程并尝试保持计算当异步函数空闲时运行。由于 asyncio 模块不是并行的,而是并发的,我知道我不需要创建任何其他任务或线程,因为重点是让它全部在一个线程中运行,不是吗?如果我错了,请纠正我,感谢您的输入!另外,您能否指出如何在不使用 Threading 模块(仅使用 asyncio)的情况下修改代码? 我开始了评论回复,但它开始变成了答案,所以我只是这样做了:) 在 Python 中引用“线程”可能会变得非常模糊,我可能经常错误地使用该术语关于 Python。我倾向于将“线程”视为一个单独的“执行空间”,await 关键字管理哪些空间在给定时间点具有控制权。 【参考方案1】:

回应您的评论:

... [T]我理解的方式是,当我通过asyncio.run() 调用main() 时,它会创建一种“包装器”或对象来跟踪线程并尝试保持计算在何时运行异步函数处于空闲状态

(小免责声明 - 我在异步方面的所有经验几乎都在 C# 中,但每个中的 await 关键字似乎在行为上非常匹配)

您的理解或多或少是正确的 - asyncio.run(main()) 将在单独的(背景)“线程”中启动 main()

(注意:我在这里忽略了 GPL 的细节和 python 的单线程。为了解释,“单独的线程”就足够了。)

误解来自于您认为 await 的工作方式、实际工作方式以及您如何安排代码。除了在介绍它的PEP 之外,我还没有找到关于 Python 的 await 工作原理的充分描述:

await 与 yield from 类似,暂停 read_data 协程的执行,直到 db.fetch awaitable 完成并返回结果数据。

另一方面,C# 的 await 有更多的 documentation/explanation 与之关联:

应用 await 关键字时,它会暂停调用方法并将控制权交还给调用者,直到等待的任务完成。

在您的情况下,您已经在“中间输出”(3 和 4)之前进行了 2 次等待,这两个等待都将控制权返回给 asycnio.run(...),直到它们有结果。

这是我使用的代码,它给了我你正在寻找的结果:

import asyncio

async def part1():
    print('1')
    await asyncio.sleep(2)
    print('5')


async def part2():
    print('2')
    await asyncio.sleep(2)
    print('6')


async def part3():
    print('3')
    print('4')

async def main():
    t1 = asyncio.create_task(part1())
    t2 = asyncio.create_task(part2())
    t3 = asyncio.create_task(part3())
    await t1
    await t2
    await t3

asyncio.run(main())

你会注意到我把 your main 变成了 my part3 并创建了一个新的main。在新的main 中,我为每个部分(1、2 和 3)创建了一个单独的 awaitable Task。然后,我依次await他们。

t1 运行时,它会在第一次打印后命中await。这暂停 part1 在那个时候直到等待完成。在此之前,程序控制权将返回给调用者 (main),类似于 yield 的工作方式。

t1 处于“暂停”状态(等待)时,main 将继续并启动t2t2t1 做同样的事情,所以t3 的启动将在不久之后进行。 t3 没有 await-ing,所以它的输出立即发生。

此时,main 正在等待其子 Tasks 完成。 t1 首先是 await-ed,所以它会先返回,然后是 t2。最终结果是(test.py 是我放入的脚本):

~/.../> py .\test.py
1
2
3
4
5
6

【讨论】:

“这与 UI(想想 Tkinter)运行 GUI 事件循环的方式非常相似。” - 并非如此。 Tkinter 不在单独的线程中运行事件循环。 是的,不好的比较。我把它拿出来了。 抱歉耽搁了,正忙于其他项目,没有收到有关此答案的任何通知,但这个很棒!非常感谢!【参考方案2】:
    在等待 2 秒时,它应该会继续

这是一个误解。 await 正好相反,它应该继续(运行那个特定的协程)直到结果完成。这就是“等待”中的“等待”。

如果要继续,可以使用:

    # spawn part1 and part2 as background tasks
    t1 = asyncio.create_task(part1())
    t2 = asyncio.create_task(part2())
    # and now await them while they run in parallel
    p1 = await t1
    p2 = await t2

实现相同效果的更简单方法是通过gather 实用函数:

    p1, p2 = asyncio.gather(part1(), part2())

请注意,像这样修改的代码仍然不会输出1 2 3 4 5 6,因为在任务完成之前不会执行最终打印。结果,实际输出将是1 2 5 6 3 4

【讨论】:

以上是关于通过简单示例了解 AsyncIO 和线程顺序的主要内容,如果未能解决你的问题,请参考以下文章

一个有趣的小例子,带你入门协程模块-asyncio

为啥 asyncio 单线程 速度还能那么快

Asyncio 协议Protocol 与 传输Transport

线程和 asyncio:任务已销毁,但处于挂起状态

asyncio的简单了解

我啥时候应该在常规线程上使用 asyncio,为啥?它是不是提供性能提升?