为什么asyncio不总是使用执行器?

8
我需要发送大量的HTTP请求,等待所有请求返回后,程序才能继续执行。这似乎是asyncio的完美应用场景。我有点天真地将对requests的调用封装在一个async函数中,并将其传递给asyncio。但是这种方式行不通。
在网上搜索后,我找到了两种解决方案:
  • 使用像aiohttp这样的库,专门为与asyncio配合使用而设计。
  • 将阻塞代码包装在一个对run_in_executor的调用中。
为了更好地理解这一点,我编写了一个小型基准测试。服务器端是一个flask程序,在回答请求之前需要等待0.1秒。
from flask import Flask
import time

app = Flask(__name__)


@app.route('/')
def hello_world():
    time.sleep(0.1) // heavy calculations here :)
    return 'Hello World!'


if __name__ == '__main__':
    app.run()

客户是我的基准。
import requests
from time import perf_counter, sleep

# this is the baseline, sequential calls to requests.get
start = perf_counter()
for i in range(10):
    r = requests.get("http://127.0.0.1:5000/")
stop = perf_counter()
print(f"synchronous took {stop-start} seconds") # 1.062 secs

# now the naive asyncio version
import asyncio
loop = asyncio.get_event_loop()

async def get_response():
    r = requests.get("http://127.0.0.1:5000/")

start = perf_counter()
loop.run_until_complete(asyncio.gather(*[get_response() for i in range(10)]))
stop = perf_counter()
print(f"asynchronous took {stop-start} seconds") # 1.049 secs

# the fast asyncio version
start = perf_counter()
loop.run_until_complete(asyncio.gather(
    *[loop.run_in_executor(None, requests.get, 'http://127.0.0.1:5000/') for i in range(10)]))
stop = perf_counter()
print(f"asynchronous (executor) took {stop-start} seconds") # 0.122 secs

#finally, aiohttp
import aiohttp

async def get_response(session):
    async with session.get("http://127.0.0.1:5000/") as response:
        return await response.text()

async def main():
    async with aiohttp.ClientSession() as session:
        await get_response(session)

start = perf_counter()
loop.run_until_complete(asyncio.gather(*[main() for i in range(10)]))
stop = perf_counter()
print(f"aiohttp took {stop-start} seconds") # 0.121 secs

因此,使用 asyncio 的直观实现不能处理阻塞的io代码。但是如果正确使用 asyncio,它与特殊的 aiohttp 框架一样快。关于协程和任务的文档并没有真正提到这一点。只有当你阅读loop.run_in_executor()时,它才会说:
# File operations (such as logging) can block the
# event loop: run them in a thread pool.

我对这种行为感到惊讶。asyncio的目的是加速阻塞io调用。为什么需要另一个包装器run_in_executor来实现这一点呢?

aiohttp的整个卖点似乎在于支持asyncio。但是据我所见,只要将其包装在执行程序中,requests模块就可以完美地工作。是否有避免包装某些内容的原因?


1
asyncio 的目的不是为了总体加速,而是为了减少延迟。你们两种方法都能做到这一点,但执行器可能需要更多的资源。 - Klaus D.
执行器基于线程。asyncio使用非阻塞套接字,因此它可以使用一个线程请求多个,但requests不行。 - KC.
1个回答

16
但据我所见,请求模块可以完美地工作 - 只要您将其包装在执行器中。是否有避免包装执行器的原因?在执行器中运行代码意味着在操作系统线程中运行它。 aiohttp和类似的库允许仅使用协同程序而无需OS线程运行非阻塞代码。
如果您没有太多工作量,那么OS线程和协程之间的差异并不显著,特别是与瓶颈 - I / O操作相比。但是一旦您有很多工作要做,您就会注意到由于昂贵的上下文切换,OS线程的性能相对较差。
例如,当我将您的代码更改为time.sleep(0.001)range(100)时,我的机器显示:
asynchronous (executor) took 0.21461606299999997 seconds
aiohttp took 0.12484742700000007 seconds

这种差异只会随着请求数量的增加而增加。

asyncio的目的是加速阻塞io调用。

不,asyncio的目的是提供一种方便的方式来控制执行流程。asyncio允许您选择如何工作流程-基于协程和操作系统线程(当您使用执行器时)或纯协程(像aiohttp一样)。

aiohttp的目的是加速事情,并且它如上所示应对了任务 :)


4
Asyncio协程并非真正的绿色线程,因为绿色线程是具有堆栈的。带有完整堆栈允许它们在任意位置切换并避免函数颜色问题,但每个绿色线程比协程/纤程更重量级。Python实现绿色线程的一个例子是greenlet模块以及基于它的gevent事件循环。 - user4815162342
@user4815162342 感谢您的澄清!我修改了答案。 - Mikhail Gerasimov
@MikhailGerasimov,感谢您对aiohttps性能的详细解释,我给你点赞:) 不过我还有一些概念上的问题,目前正在更新我的问题。 - lhk
我已经更新了我的问题。我不理解asyncio和aiohttp之间的交集。Asyncio有非阻塞协程而没有OS线程?这听起来像是一个巨大的特性。这是asyncio的一部分吗?如果是,为什么不是默认的呢?如果不是,那么aiohttp如何基于asyncio(async/await是一种语言特性,不是asyncio的直接组成部分)? - lhk
嗯,我正在重新考虑这个问题。你已经回答了我的执行器问题(操作系统线程不是免费的)。我觉得我的重新表述的问题实际上是另一个问题。所以我要提出一个新问题。 - lhk
1
@lhk 是的,asyncio具有非阻塞协程而无需OS线程,这是一个巨大的特性。Aiohttp基于asyncio,因为它依赖于asyncio在原始async/await之上构建的抽象。请参见此问题的答案,特别是此答案,以深入了解该主题。 - user4815162342

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接