4 异步、并发和Starlette

本章关注FastAPI的底层Starlette库,尤其是它对异步处理的支持。在概述了Python中“同时做更多事情”的多种方法后,您将看到Python中较新的async和await关键字是如何融入Starlette和FastAPI的。

4.1 Starlette

FastAPI 的大部分网络代码都基于 Tom Christie 创建的 Starlette 软件包。它既可以作为网络框架单独使用,也可以作为其他框架(如 FastAPI)的库使用。与其他网络框架一样,Starlette处理所有常见的HTTP请求解析和响应生成。它类似于Flask的基础软件包Werkzeug。

但它最重要的功能是支持现代Python异步网络标准: ASGI。到目前为止,大多数 Python 网络框架(如 Flask 和 Django)都是基于传统的同步WSGI标准。由于网络应用程序经常连接到速度慢得多的代码(如数据库、文件和网络访问),ASGI避免了基于WSGI应用程序的阻塞和繁忙等待。

因此,Starlette和使用它的框架是速度最快的Python Web包,甚至可与Go和Node.js应用程序相媲美。

4.2 并发类型

在并行计算中,任务会同时分布在多个专用CPU上。这在图形和机器学习等“数字计算”应用中很常见。

在并发计算中,每个CPU在多个任务之间切换。有些任务比其他任务耗时更长,我们希望缩短所需的总时间。读取文件或访问远程网络服务要比在CPU中运行计算慢数千到数百万倍。

网络应用程序就承担了大量这样的慢速工作。如何让网络服务器或任何服务器运行得更快?本节将讨论从全系统到本章重点的一些可能性:FastAPI对Python的async和await的实现。

4.2.1 分布式和并行计算

大的应用程序--在单个 CPU 上运行会非常吃力--您可以将它分解成多个部分,让这些部分在单台机器或多台机器的不同CPU上运行。这样做的方法有很多很多,如果你有这样一个应用程序,你已经知道了其中的一些方法。管理所有这些部分比管理单个服务器更加复杂和昂贵。

我们重点关注的是可以安装在单个服务器上的中小型应用程序。这些应用程序可以混合使用同步和异步代码,由FastAPI进行很好的管理。

4.2.2 操作系统进程

操作系统调度资源:内存、CPU、设备、网络等。操作系统运行的每个程序都在一个或多个进程中执行代码。操作系统为每个进程提供受管理、受保护的资源访问权限,包括它们何时可以使用CPU。

大多数系统使用抢占式进程调度,不允许任何进程占用CPU、内存或其他资源。操作系统会根据其设计和设置不断暂停和恢复进程。

对于开发人员来说,好消息是:这不是你的问题!但坏消息(通常似乎与好消息如影随形)是:即使你想改变,也无能为力。

对于CPU密集型的Python应用程序,通常的解决方案是使用多个进程,让操作系统来管理它们。Python 为此提供了multiprocessing模块。

4.2.3 操作系统线程

可以在单个进程中运行控制线程。Python 的threading可以管理这些线程。

当程序受I/O约束时,通常建议使用线程,而当程序受CPU约束时,则建议使用多进程。但线程编程起来很棘手,可能会导致难以发现的错误。

传统上,Python将基于进程的库和基于线程的库分开。开发人员必须学习其中任何一个库的神秘细节才能使用它们。最近一个名为 concurrent.futures 的软件包提供了更高级别的接口,使它们更容易使用。

正如您将看到的,使用较新的异步函数,您可以更轻松地获得线程的优势。FastAPI 还通过线程池管理普通同步函数(def,而非 async def)的线程。

4.2.4 Green线程(协程)

绿色线程(如 greenlet、gevent 和 Eventlet)是一种更为神秘的机制。这些线程是合作式的(不是抢占式的)。它们类似于操作系统线程,但运行在用户空间(即你的程序)而非操作系统内核。它们通过对标准Python函数进行 “猴子修补”(在运行过程中修改标准 Python 函数),使并发代码看起来像正常的顺序代码:当它们阻塞等待I/O时,就会放弃控制。

操作系统线程比操作系统进程更“轻”(使用更少内存),而绿色线程比操作系统线程更轻。在某些基准测试中,所有异步方法通常都比同步方法快。

你可能会想知道gevent和asyncio孰优孰劣?我认为并没有一个适用于所有用途的单一偏好。协程是在前面实现的(使用了多人游戏 Eve Online 的思想)。本书以Python的标准asyncio为特色,FastAPI使用的asyncio比线程更简单,性能也很好。

4.2.5 回调(Callbacks)

游戏和图形用户界面等交互应用程序的开发人员可能对回调并不陌生。您可以编写函数,并将它们与鼠标点击、按键或时间等事件关联起来。这类Python软件包中的佼佼者是Twisted。它的名字反映了这样一个现实:基于回调的程序有点 “内向外”,很难跟上。

4.2.6 Python生成器(Generators)

在Python生成器中,您可以从任意点停止并返回,然后再回到该点。其中的诀窍就是yield关键字。

让我们看一个简单的for循环:

In [1]: def doh():
   ...:     return ["Homer: D'oh!", "Marge: A deer!", "Lisa: A female deer!"]
   ...: 

In [2]: for line in doh():
   ...:     print(line)
   ...: 
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!

当列表相对较小的时候,这种方法非常有效。列表会占用内存, 可以改用生成器:

In [3]: def doh2():
   ...:     yield "Homer: D'oh!"
   ...:     yield "Marge: A deer!"
   ...:     yield "Lisa: A female deer!"
   ...: 

In [4]: for line in doh2():
   ...:     print(line)
   ...: 
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!

我们迭代的不是普通函数 doh() 返回的列表,而是生成器函数 doh2() 返回的生成器对象。实际的迭代(for...in) 看起来是一样的。Python 会从 doh2() 返回第一个字符串,但会在下一次迭代时跟踪它的位置,以此类推,直到函数用完对话为止。

任何包含yield的函数都是生成器函数,它能返回函数并恢复执行的能力。

4.2.7 Python async、await 和 asyncio

import time

def q():
    print("Why can't programmers tell jokes?")
    time.sleep(3)

def a():
    print("Timing!")

def main():
    q()
    a()

main()

上面程序在打印"Why can't programmers tell jokes?"后会停顿3s。

async实例

In [5]: import asyncio

In [6]: async def q():
   ...:     print("Why can't programmers tell jokes?")
   ...:     await asyncio.sleep(3)
   ...: 

In [7]: async def a():
   ...:     print("Timing!")
   ...: 

In [8]: async def main():
   ...:     await asyncio.gather(q(), a())
   ...: 

In [9]: asyncio.run(main())
Why can't programmers tell jokes?
Timing!
  • 执行q()
  • 已经设置了秒表,三秒钟后回来
  • 同时运行 a(),立即打印
  • 没有其他等待,所以回到 q()。
  • 无聊的事件循环!我会坐在这里盯着剩下的三秒钟。
  • 好的,现在我完成了。

asyncio.sleep()用于需要一定时间的函数,就像读取文件或访问网站的函数一样。你将await放在可能花费大部分时间等待的函数前面。该函数需要在其def前加上async。

调用者必须在调用该函数前加上await。调用者本身也必须声明 async def,而且调用者必须一直等待它。

顺便说一句,即使一个函数不包含对另一个异步函数的await调用,你也可以将它声明为异步函数。这样做也无妨。

参考资料

4.3 FastAPI和异步

由于网络服务器需要花费大量时间等待,因此可以通过避免部分等待来提高性能,换句话说,就是并发。其他网络服务器使用了许多前面提到的方法:线程、gevent 等。FastAPI是速度最快的 Python Web 框架之一,其中一个原因就是它通过底层Starlette包的ASGI支持和一些自己的发明,将异步代码融入其中。

使用async和await本身并不会使代码运行得更快。事实上由于async设置的开销,运行速度可能会稍慢一些。async 的主要用途是避免I/O的长时间等待。

现在,让我们看看之前的网络端点调用,看看如何让它们成为异步。

from fastapi import FastAPI
import asyncio

app = FastAPI()

@app.get("/hi")
async def greet():
    await asyncio.sleep(1)
    return "Hello? World?"

运行:

$ uvicorn greet_async:app

第二种方法:

from fastapi import FastAPI
import asyncio
import uvicorn

app = FastAPI()

@app.get("/hi")
async def greet():
    await asyncio.sleep(1)
    return "Hello? World?"
    
if __name__ == "__main__":
    uvicorn.run("greet_async_uvicorn:app")

FastAPI 在接收到 URL /hi 的 GET 请求时,会自行调用 async greet() 路径函数。您不需要在任何地方添加 await。但对于您定义的任何其他异步定义函数,调用者必须在每次调用前添加await。

FastAPI 运行异步事件循环来协调异步路径函数,并为同步路径函数运行一个线程池。开发人员不需要知道这些棘手的细节,这是一大优点。例如,您不需要运行asyncio.gather() 或 asyncio.run() 等方法,就像前面的(独立、非 FastAPI)笑话示例一样。

4.4 直接使用Starlette

FastAPI并不像Pydantic那样公开Starlette。Starlette 在很大程度上是引擎室中嗡嗡作响的机器,它能保证船只平稳运行。

不过,如果您好奇,可以直接使用Starlette编写网络应用程序:

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route

async def greeting(request):
    return JSONResponse('Hello? World?')

app = Starlette(debug=True, routes=[
    Route('/hi', greeting),
])

运行此网络应用

$ uvicorn starlette_hello:app

在我看来,FastAPI的添加使得网络API的开发变得更加容易。

4.5 小结

在概述了提高并发性的方法后,本章扩展了使用最新Python关键字async和await的函数。它展示了FastAPI和Starlette如何处理普通的同步函数和这些新的异步函数。