Starlette 是一个轻量级 ASGI 框架/工具包,非常适合在 Python 中构建异步 Web 服务。
——Tom Christie,Starlette 创建者
预览
上一章简要介绍了开发者在编写一个新的 FastAPI 应用程序时最先会遇到的内容。本章重点介绍 FastAPI 底层的 Starlette 库,尤其是它对异步处理的支持。在概览 Python 中多种“同时做更多事情”的方式之后,你会看到较新的 async 和 await 关键字如何被整合进 Starlette 和 FastAPI。
Starlette
FastAPI 的许多 Web 代码都基于 Starlette 包,该包由 Tom Christie 创建。它既可以作为一个独立的 Web 框架使用,也可以作为其他框架的库使用,比如 FastAPI。像其他任何 Web 框架一样,Starlette 负责处理常见的 HTTP 请求解析和响应生成。它类似于 Werkzeug,也就是 Flask 底层所依赖的那个包。
但它最重要的特性,是对现代 Python 异步 Web 标准 ASGI 的支持。到目前为止,大多数 Python Web 框架,比如 Flask 和 Django,都是基于传统的同步 WSGI 标准。由于 Web 应用程序经常需要连接到慢得多的代码,例如数据库、文件和网络访问,ASGI 避免了基于 WSGI 的应用程序中的阻塞和忙等待。
因此,Starlette 以及使用它的框架,是最快的 Python Web 包,甚至可以与 Go 和 Node.js 应用程序竞争。
并发的类型
在深入 Starlette 和 FastAPI 提供的异步支持细节之前,先了解我们可以用来实现并发的多种方式会很有帮助。
在并行计算中,一个任务会在同一时间被分散到多个专用 CPU 上执行。这在图形和机器学习这类“数字计算密集型”应用程序中很常见。
在并发计算中,每个 CPU 会在多个任务之间切换。有些任务比其他任务耗时更长,而我们希望减少所需的总时间。读取文件或访问远程网络服务,实际上比在 CPU 中运行计算慢上成千上万倍,甚至数百万倍。
Web 应用程序会做大量这种慢工作。我们如何让 Web 服务器,或者任何服务器,运行得更快?本节会讨论一些可能性,从系统范围的方法一直讲到本章的重点:FastAPI 对 Python async 和 await 的实现。
分布式计算和并行计算
如果你有一个非常大的应用程序——大到在单个 CPU 上运行会气喘吁吁——你可以把它拆成多个部分,并让这些部分运行在单台机器中的多个独立 CPU 上,或者运行在多台机器上。你可以用非常、非常多种方式来做到这一点。如果你确实有这样的应用程序,你可能已经知道其中不少方法。管理所有这些部分,比管理单个服务器更加复杂,也更加昂贵。
本书关注的是可以放在单台机器上的小型到中型应用程序。而这些应用程序可以混合同步代码和异步代码,并由 FastAPI 很好地管理。
操作系统进程
操作系统,或者 OS,因为打字很累,会调度资源:内存、CPU、设备、网络等等。它运行的每个程序,都会在一个或多个进程中执行自己的代码。操作系统为每个进程提供受管理、受保护的资源访问,包括它们何时可以使用 CPU。
大多数系统使用抢占式进程调度,不允许任何进程独占 CPU、内存或其他任何资源。操作系统会根据自身设计和设置,不断挂起和恢复进程。
对开发者来说,好消息是:这不是你的问题!但坏消息是,好消息通常都有坏消息相伴:即使你想改变它,也做不了太多。
对于 CPU 密集型 Python 应用程序,通常的解决方案是使用多个进程,并让操作系统管理它们。Python 为此提供了一个 multiprocessing 模块。
操作系统线程
你也可以在单个进程中运行控制线程。Python 的 threading 包可以管理这些线程。
通常建议在程序属于 I/O 密集型时使用线程,在 CPU 密集型时使用多个进程。但线程编程很棘手,并且可能引发难以发现的错误。在《Introducing Python》中,我把线程比作在鬼屋中飘来飘去的幽灵:独立而不可见,只能通过它们造成的影响被察觉。嘿,谁移动了那个烛台?
传统上,Python 将基于进程和基于线程的库分开。开发者必须学习其中一种库的神秘细节,才能使用它们。一个较新的包叫作 concurrent.futures,它提供了更高层级的接口,让这些东西更容易使用。
正如你将看到的,通过较新的异步函数,你可以更容易地获得线程带来的好处。FastAPI 还会通过线程池为普通同步函数,也就是 def 而不是 async def,管理线程。
绿色线程
一种更神秘的机制由绿色线程提供,比如 greenlet、gevent 和 Eventlet。这些是协作式的,而不是抢占式的。它们类似于操作系统线程,但运行在用户空间,也就是你的程序中,而不是运行在操作系统内核中。它们通过 monkey-patching 标准 Python 函数,也就是在标准 Python 函数运行时修改它们,来让并发代码看起来像普通的顺序代码:当它们会因为等待 I/O 而阻塞时,就会交出控制权。
操作系统线程比操作系统进程“更轻”,也就是使用更少内存;绿色线程又比操作系统线程更轻。在一些基准测试中,所有异步方法通常都比它们的同步对应方法更快。
注意
读完本章之后,你可能会想知道哪个更好:gevent 还是 asyncio?我认为并不存在适用于所有用途的单一偏好。绿色线程实现得更早,使用了来自多人游戏 Eve Online 的一些想法。本书重点介绍 Python 的标准 asyncio,它被 FastAPI 使用,比线程更简单,并且表现良好。
回调
交互式应用程序的开发者,比如游戏和图形用户界面的开发者,可能很熟悉回调。你编写函数,并把它们与某个事件关联起来,比如鼠标点击、按键或时间。这一类别中突出的 Python 包是 Twisted。它的名字反映了现实:基于回调的程序有点“内外颠倒”,很难跟踪。
Python 生成器
像大多数语言一样,Python 通常按顺序执行代码。当你调用一个函数时,Python 会从它的第一行运行到结尾,或者运行到 return。
但在 Python 生成器函数中,你可以在任意位置停止并返回,然后稍后再回到那个位置。诀窍是 yield 关键字。
在《辛普森一家》的某一集中,Homer 把车撞上了一座鹿雕像,随后有三句台词。示例 4-1 定义了一个普通 Python 函数,把这些台词作为列表返回,并让调用者遍历它们。
示例 4-1 使用 return
>>> def doh():
... return ["Homer: D'oh!", "Marge: A deer!", "Lisa: A female deer!"]
...
>>> for line in doh():
... print(line)
...
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!
当列表相对较小时,这完全可行。但如果我们要获取《辛普森一家》所有剧集中所有台词呢?列表会占用内存。
示例 4-2 展示了生成器函数如何逐行发出台词。
示例 4-2 使用 yield
>>> def doh2():
... yield "Homer: D'oh!"
... yield "Marge: A deer!"
... yield "Lisa: A female deer!"
...
>>> for line in doh2():
... print(line)
...
Homer: D'oh!
Marge: A deer!
Lisa: A female deer!
我们不是遍历普通函数 doh() 返回的列表,而是在遍历生成器函数 doh2() 返回的生成器对象。实际的迭代,也就是 for...in,看起来是一样的。Python 从 doh2() 返回第一个字符串,但会记录它所在的位置,以便下一次迭代继续执行,如此反复,直到这个函数没有更多台词。
任何包含 yield 的函数都是生成器函数。既然已经具备回到函数中间并恢复执行的能力,那么下一节看起来就是一个很自然的改造方向。
Python async、await 和 asyncio
Python 的 asyncio 特性是在多个版本中逐步引入的。你至少在运行 Python 3.7,因为从这个版本开始,async 和 await 变成了保留关键字。
下面的示例展示了一个只有在异步运行时才好笑的笑话。你应该自己运行两个示例,因为时机很重要。
首先,运行不好笑的示例 4-3。
示例 4-3 无聊
>>> 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?
Timing!
你会看到问题和答案之间有三秒钟的间隔。哈欠。
但异步版本的示例 4-4 就有点不同。
示例 4-4 好笑
>>> import asyncio
>>>
>>> async def q():
... print("Why can't programmers tell jokes?")
... await asyncio.sleep(3)
...
>>> async def a():
... print("Timing!")
...
>>> async def main():
... await asyncio.gather(q(), a())
...
>>> asyncio.run(main())
Why can't programmers tell jokes?
Timing!
这一次,答案应该会在问题之后立刻跳出来,然后是三秒钟的沉默——就像一个程序员在讲这个笑话一样。哈哈!咳咳。
注意
我在示例 4-4 中使用了 asyncio.gather() 和 asyncio.run(),但调用异步函数有多种方式。使用 FastAPI 时,你不需要使用这些。
运行示例 4-4 时,Python 是这样想的:
执行 q()。好吧,现在只执行第一行。
行吧,你这个懒惰的异步 q(),我已经设好秒表了,三秒后再回来找你。
与此同时,我会运行 a(),马上打印答案。
没有其他 await 了,所以回到 q()。
无聊的事件循环!我就坐在这里,啊——盯着看,直到剩下的三秒结束。
好了,现在完成了。
这个例子使用 asyncio.sleep() 来模拟一个需要花费一些时间的函数,很像读取文件或访问网站的函数。你需要在那个可能把大部分时间花在等待上的函数前面加上 await。而那个函数本身的 def 前面也需要有 async。
注意
如果你用 async def 定义一个函数,那么它的调用者必须在调用它之前加上 await。并且调用者本身也必须被声明为 async def,它的调用者也必须 await 它,如此一直向上。
顺便说一句,即使一个函数内部并不包含对另一个异步函数的 await 调用,你也可以把它声明为 async。这没什么坏处。
FastAPI 和异步
经过刚才那场翻山越岭的长途旅行之后,让我们回到 FastAPI,以及为什么这些内容很重要。
因为 Web 服务器会花大量时间等待,所以可以通过避免其中一些等待来提升性能——换句话说,就是并发。其他 Web 服务器会使用前面提到的许多方法:线程、gevent 等等。FastAPI 之所以是最快的 Python Web 框架之一,原因之一就是它通过底层 Starlette 包的 ASGI 支持,以及自己的一些发明,整合了异步代码。
注意
单独使用 async 和 await 并不会让代码运行得更快。事实上,由于异步设置的开销,它可能会稍微慢一点。async 的主要用途是避免对 I/O 的长时间等待。
现在,让我们看看前面的 Web 端点调用,并看看如何把它们变成异步。
在 FastAPI 文档中,将 URL 映射到代码的函数被称为路径函数。我也把它们称为 Web 端点,你已经在第 3 章中见过它们的同步示例。现在让我们写一些异步版本。和前面的示例一样,目前我们只使用数字和字符串这样的简单类型。第 5 章会介绍类型提示和 Pydantic,我们需要它们来处理更复杂的数据结构。
示例 4-5 重新访问上一章中的第一个 FastAPI 程序,并将其改为异步。
示例 4-5 一个害羞的异步端点(greet_async.py)
from fastapi import FastAPI
import asyncio
app = FastAPI()
@app.get("/hi")
async def greet():
await asyncio.sleep(1)
return "Hello? World?"
要运行这段 Web 代码,你需要一个像 Uvicorn 这样的 Web 服务器。
第一种方式是在命令行运行 Uvicorn:
$ uvicorn greet_async:app
第二种方式如示例 4-6 所示,当示例代码作为主程序而不是模块运行时,在代码内部调用 Uvicorn。
示例 4-6 另一个害羞的异步端点(greet_async_uvicorn.py)
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")
当作为独立程序运行时,Python 会把它命名为 main。那个 if __name__... 东西就是 Python 只在它作为主程序被调用时运行它的方式。是的,它很丑。
这段代码会在返回它那怯生生的问候之前暂停一秒。它与使用标准 sleep(1) 函数的同步函数之间唯一的区别在于,在异步示例中,Web 服务器可以在这段时间里处理其他请求。
使用 asyncio.sleep(1) 是在模拟一个真实世界中可能需要一秒钟的函数,比如调用数据库或下载网页。后续章节会展示这样的调用示例:从这个 Web 层调用 Service 层,再从 Service 层调用 Data 层,并真正把这段等待时间用在实际工作上。
当 FastAPI 收到对 URL /hi 的 GET 请求时,会自己调用这个异步的 greet() 路径函数。你不需要在任何地方添加 await。但对于你自己创建的其他任何 async def 函数定义,调用者必须在每次调用前加上 await。
注意
FastAPI 会运行一个异步事件循环,用来协调异步路径函数,并为同步路径函数运行一个线程池。开发者不需要知道那些棘手细节,这是一个很大的优点。例如,你不需要像前面那个独立的、非 FastAPI 的笑话示例中那样运行 asyncio.gather() 或 asyncio.run() 这类方法。
直接使用 Starlette
FastAPI 并不像暴露 Pydantic 那样大量暴露 Starlette。Starlette 更像是在引擎室里嗡嗡运转的机械装置,让这艘船平稳运行。
但如果你好奇,也可以直接使用 Starlette 来编写 Web 应用程序。上一章中的示例 3-1 可能会写成示例 4-7 这样。
示例 4-7 使用 Starlette:starlette_hello.py
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),
])
使用下面的命令运行这个 Web 应用程序:
$ uvicorn starlette_hello:app
在我看来,FastAPI 添加的内容让 Web API 开发容易得多。
插曲:打扫 Clue 豪宅
你拥有一家小型房屋清洁公司,非常小,只有你自己。你一直靠拉面过活,但刚刚拿到一份合同,这份合同终于能让你吃得起更好的拉面了。
你的客户买了一座旧宅邸,建造风格类似桌游 Clue,并且很快想在那里举办一场角色派对。但这个地方乱得惊人。如果 Marie Kondo 看到这个地方,她可能会做以下事情:
尖叫
作呕
逃跑
以上全部
你的合同包含速度奖金。你如何才能在最短经过时间内,把这个地方彻底打扫干净?最好的方法原本是拥有更多的 Clue Preservation Units,也就是 CPU,但这里只有你。
所以你可以尝试以下方法之一:
在一个房间里做完所有事情,然后去下一个房间,依此类推。
在一个房间里做某项特定任务,然后去下一个房间,依此类推。比如在厨房和餐厅擦银器,或者在台球室擦台球。
这些方法的总耗时会不同吗?也许会。但更重要的,可能是考虑在某个步骤中你是否必须等待相当长的时间。一个例子可能就在脚下:清洗地毯和给地板打蜡后,它们可能需要干上几个小时,才能把家具搬回去。
所以,下面是你对每个房间的计划:
清洁所有静态部分,比如窗户等。
把房间里的所有家具搬到大厅。
清除地毯和/或硬木地板上多年的污垢。
执行以下两种选择之一:
等待地毯或地板蜡变干,但挥手告别你的奖金。
现在就去下一个房间,重复上述流程。最后一个房间完成后,把家具搬回第一个房间,如此继续。
等待变干的方法是同步方式,如果时间不是问题,并且你需要休息一下,它可能是最好的。第二种方式是异步的,可以节省每个房间的等待时间。
让我们假设你选择异步路径,因为钱。你让这座旧破房子闪闪发光,并从感激的客户那里拿到了奖金。后来的派对非常成功,除了以下几个问题:
一个毫无梗感的客人扮成了 Mario。
你给舞厅的舞池打了太多蜡,一个微醺的 Plum 教授穿着袜子在上面滑来滑去,直到他冲向一张桌子,并把香槟洒在 Scarlet 小姐身上。
这个故事的寓意:
需求可能相互冲突,并且/或者很奇怪。
估算时间和工作量可能取决于许多因素。
任务排序可能既是艺术,也是科学。
当一切完成时,你会感觉很棒。嗯,拉面。
回顾
在概览了提升并发性的多种方式之后,本章进一步展开了使用 Python 近期关键字 async 和 await 的函数。它展示了 FastAPI 和 Starlette 如何同时处理普通的旧式同步函数,以及这些新的异步炫酷函数。
下一章会介绍 FastAPI 的第二条支柱:Pydantic 如何帮助你定义数据。