FastAPI 异步代码、并发和并行
作者:麦克煎蛋 出处:https://www.cnblogs.com/mazhiyong/ 转载请保留这段声明,谢谢!
我们这里探讨下关于异步代码、并行和并发的一些概念。
一、初探
1、如果我们使用必须用await调用的第三方库,例如:
results = await some_library()
那么我们就要用async def
来定义路径操作函数:
@app.get('/') async def read_results(): results = await some_library() return results
注意:我们在基于async def
定义的函数内部才能使用await。
2、如果第三方库不支持使用await,那么我们就用def定义路径操作函数即可。
@app.get('/') def results(): results = some_library() return results
3、如果我们的应用不需要与第三方通讯,那么就用async def
来定义路径操作函数。
4、如果我们不知道怎么做,那么就用def
来定义路径操作函数。
无论上述哪种情况,FastAPI都会执行异步工作并且速度极快。
但如果我们遵循一些操作规范,将会带来一些性能上的优化。
现代版本的Python通过使用"协程"来实现对"异步代码"的支持,在语法上表现为async和await的使用。
我们以下重点讲述的内容为:
- 异步代码
async
和await
- 协程
二、异步代码
异步代码通常表示,开发语言有一种方式用来通知计算机(应用)在代码的某个地方,必须等待某些事件在其他地方完成。
这里的某些事件我们称之为"slow-file"。在等待"slow-file"完成的这个时间段内,计算机可以执行一些别的任务。
然后计算机(应用)一旦有机会就会返回,比如它需要再次等待,或者它完成了在这个地方的所有其他任务。
接下来会检查所有等待的任务是否已经完成,或者继续执行应当要完成的任务。
然后它会从等待任务中取走第一个任务继续执行。
这里等待的某些事件通常指的是,相对程序计算或者内存操作比较耗时的I/O操作,例如
- 网络通讯
- 硬盘文件读写
- 远程 API 操作
- 数据库操作
- 其他耗时操作
之所以被称为"异步"是因为计算机(应用)没必要为了"同步"等待"slow-file"完成而什么事情都不做,那样的话只能等待取到任务结果后才能继续工作。
实际上,作为一个异步系统,一旦某些事件完成,这个事件会等待一会以便计算机(应用)返回获取结果,然后利用执行结果继续工作。
对于同步系统来说,通常也称之为"顺序模型",因为在切换执行一个不同任务的时候,计算机(应用)严格遵循序列中的步骤,即使有些步骤包含了等待。
2.1 并发汉堡
以上讨论的异步代码有时候也称之为"并发",这与"并行"是不同的。
"并发"和"并行"都意味着"不同的事情或多或少在相同的时间发生",但它们的细节是非常不同的。
你和你的朋友去吃快餐,你排队的时候收银员按顺序为在你之前的顾客点餐。 轮到你的时候,你为自己和朋友点了两份新潮的汉堡。 然后你付钱。 然后收银员告诉厨师以便他准备你的汉堡(虽然他可能正在为其他顾客准备汉堡)。
收银员给你的订单号。
在等待取餐的时候,你和朋友挑选一个桌子坐下,然后你们交流了很长时间(制作新潮的汉堡比较耗时)。
在和朋友愉快交流的时候,时不时的,你会看一下柜台是否显示了你的订单号。
在某个时间终于轮到你了,你去柜台取回你的汉堡,然后返回到座位和你的朋友分享。
---------------------------------------------------------------------------
在这个故事里,你可以把自己想象成计算机(应用)。
当你排队的时候你是空闲的,没有做什么有效的工作。但队伍是很快的,因为收银员仅仅是收银和下单。
轮到你的时候,你做了一些有效的工作,你查看菜单和决定点菜内容,然后支付并检查支付结果,同时确认返回的订单内容是正确的。
然后,虽然你还没得到汉堡,但是你和收银员之间的工作处于"暂停"状态,因为你不得不等待汉堡制作完成。
虽然你离开了收银台,带着一个号码返回到了桌子旁,不过你可以把你的注意力切换到你的朋友身上,继续你们的交流"工作"。
然后你有了一些"有效的"工作,那就是增进你和朋友之间的感情。
当收银员说汉堡准备好了并且把你的订单号放在显示屏上的时候,你并不会立刻跳起来冲过去,因为你知道没人偷你的汉堡,这是你的订单号,别人有别人的订单号。
因此你会等待你的朋友讲完她的故事(结束当前的工作),然后微笑着告诉她你要去取汉堡。
然后你来到柜台(继续你最开始的任务),取到汉堡,感谢收银员,然后返回到座位。现在终于结束了与柜台之间的交互任务。
按照顺序,现在开启了一个新的任务,"吃汉堡",但前一个任务"取汉堡"已经完成了。
2.2 并行汉堡
现在我们来看一下什么是并行汉堡。
你和你的朋友去获取并行快餐。
你排队的时候,同时有几个(暂且认为有8个)收银员在为顾客下单,这几个收银员同时也是厨师。
每个在你前面的顾客必须等待取到汉堡才能离开柜台,因为这8个收银员在为下一个顾客服务之前,必须立刻去准备好当前顾客的汉堡。
轮到你的时候,你下单两个新潮的汉堡。
你完成支付。
然后收银员去厨房制作。
你在柜台前等待着,这样就不会有别人能取走你的汉堡,因为并没有根据订单号取货。
这样在你和你的朋友忙于不让别人插队和取走你的汉堡的时候,你们并没有多余的精力进行交流。
这是一种"同步"工作,你和收银员(厨师)之间处于同步状态。你必须在那里等到收银员(厨师)制作完成汉堡然后交给你,否则别人就可能会取走你的汉堡。
经过在柜台前的长时间等待后,收银员(厨师)终于带着你的汉堡回来了。
你取到汉堡然后返回餐桌和朋友一起就餐。
你享用汉堡。完成你的汉堡任务。
因为在柜台前大量的等待时间,你并没有与你的朋友有很好的交流。
---------------------------------------------------------------------------
在并发汉堡的场景里,你是一个带有两个处理器(你和你的朋友)的计算机(应用),同时在柜台前等待了很长时间。
快餐店有8个处理器(收银员/厨师)。与之相比并发快餐店只有两个处理器(一个收银员,一个厨师)。
但最终,并发汉堡的体验仍然不是最好的。
下面是一个与汉堡相同的并行故事。
直到最近,大部分的银行还是有多个收银员和一个长长的队伍。
所有的收银员都是处理完当前顾客的所有事情后,才会开始服务下一位。
你不得不在队伍里长时间等待,否则你就会失去你的机会。
2.3 汉堡结论:
在"与朋友一起吃快餐汉堡"的场景里,因为有许多时间在等待,这就为并发系统带来了更多意义。
这也是大多数web应用的常见情景。 许多许多用户都在等待通过他们不太好的网络来发送请求,然后又等待请求结果的返回。 这种单次的"等待"虽然是毫秒级的,但把它们加起来,最终就导致了大量的等待。
这也是在web应用中使用异步代码的实际意义
许多流行的Python框架(包含Flask和Django)是在Python的新异步特性存在之前创建的,因此它们对异步特性的支持并不像最新的特性那么给力。
异步特性造就了NodeJS的流行,同时也是Go语言的立足所在。现在我们通过FastAPI框架也能获得同样的性能水平。
2.4 并发比并行更好吗?
不是这样的,这并不是这个故事的寓意所在。
并发与并行是不同的。只有在某些特定的场景下(比如包含大量的等待)它是较好一些的。
通常对于web应用来说,并发是比并行更好一些的。但这并不是全部。
想象一下下面这个小故事:
你要打扫一个又大又脏的房间!
对了!这才是全部的故事内容!
在这个房间的所有地方,不存在需要等待的事情,只有大量的工作需要完成。
你可以像汉堡范例那样按顺序执行,先打扫起居室,然后打扫厨房,但因为不存在等待,只是不停的打扫和打扫,因此打扫的顺序不会有任何影响。
无论是否按照顺序或者不按照顺序(并发)打扫,你需要完成的全部工作量是相同的,你所花费的全部工时也是相同的。
但是在这种情况下,如果你能带来8个人(以前叫收银员/厨师,现在叫清洁工人),每个人(和你一起)各自负责打扫房间的一块区域,你们就能并行的完成所有的工作,并且更加快速。
这个时候,每一个清洁者(包括你自己)就是一个处理器,各自完成各自负责的工作。
因为大部分的执行时间被实际工作所占据,并且在计算机中实际工作是被CPU所完成的,我们通常称这类问题为"CPU bound",也称之为计算密集型。
---------------------------------------------------------------------------
计算密集型的操作通常涉及到复杂的数学计算。
例如多媒体的处理、机器视觉、机器学习或者深度学习等
我们可以这样简单理解并发与并行:
你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。
你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。
你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。
并发的关键是你有处理多个任务的能力,不一定要同时。
并行的关键是你有同时处理多个任务的能力。
2.5 并发 + 并行: web和机器学习
借助FastAPI,我们通常可以在web开发中充分利用并发的优势。
但对于计算密集型的工作(比如机器学习)我们也可以利用到并行和多处理器的优势。
尤其考虑到Python是数据计算、机器学习以及深度学习的主要语言,这也使得FastAPI非常适用于数据计算/机器学习的web API和应用开发。
三、async
and await
现代版本的Python用一种非常直观的方式来定义异步代码。看起来就像通常的"顺序"代码在某一时刻进行"等待"操作。
当有一个操作需要等待执行结果的时候,代码范例如下:
burgers = await get_burgers(2)
关键之处在于使用了await。这里告诉Python必须等待get_burgers(2)执行完,才能把结果存储到burgers中。
借助上述语法,Python将会在这个期间内去执行别的操作(比如接收新的请求)。
await必须在一个支持异步特性的函数内进行使用,也就是说必须是用async def
声明
的函数:
async def get_burgers(number: int): # Do some asynchronous stuff to create the burgers return burgers
而不是用def声明的函数:
# This is not asynchronous def get_sequential_burgers(number: int): # Do some sequential stuff to create the burgers return burgers
借助async def
,Python知道在这样的函数内,必须要注意await表达式。它可以在结果返回前,"暂停"这个函数的执行,先去执行别的任务。
当你调用async def
函数的时候,你也必须等待(await)它,否则函数将不会执行。
# This won't work, because get_burgers was defined with: async def burgers = get_burgers(2)
因此,当我们使用的第三方库声明要使用await调用的时候,我们必须要创建一个基于async def声明的路径操作函数,例如:
@app.get('/burgers') async def read_burgers(): burgers = await get_burgers(2) return burgers
相关技术细节
我们注意到了await操作必须是在async def
函数内使用,async def
函数也必须在另一个async def
函数内使用。
这就像鸡生蛋和蛋生鸡的问题,我们怎么调用第一个async函数呢?
在FastAPI框架内部我们不用担心这个问题,因为第一个函数就是我们的路径操作函数,而FastAPI框架会正确处理这个问题。
在FastAPI框架外关于 async/await 的详细使用,我们可以参考文档 check the official Python docs
四、关于协程
对于async def
函数返回的操作,我们有一个非常花式的术语称之为"协程"。
Python知道这个操作可以像函数一样启动和完成,并且也可以在内部暂停执行,只要存在await操作。
通过async
和 await
完成的异步代码通常称为"协程",这是相对比于Go语言的主要特性,其称之为"Goroutines"。
五、其他技术细节
5.1 路径操作函数
当直接通过def声明一个路径操作函数的时候,它会运行在一个外部的线程池里并且处于等待状态,而不是被直接调用(这样往往会阻塞住整个server)。
如果我们以前使用的是与上述工作方式不同的异步框架,并且习惯了直接定义def函数来获取微小的性能提升(也许是100纳秒),请注意在FastAPI中
这种效果是完全不同的。在这种情况下,我们最好用async def来声明函数除非路径操作函数在执行I/O阻塞操作。
无论在哪种情况下,FastAPI都会比你以前所用的框架表现更好一些(或者至少是持平)。
5.2 依赖项
如果依赖项函数是def而不是async def定义的,那么它也运行在外部的线程池中。
你可能有多个依赖项和子依赖项相互依赖,一些是async def定义的,一些是def定义的。这仍然会正常工作。def定义的会在外部线程中被调用。
5.3 工具函数
其他直接调用的工具类函数,无论是async def定义或者是def定义,FastAPI不会影响你调用的方式。
这与FastAPI为你调用的函数是截然不同的:包括了路径操作函数和依赖项函数。
如果你的工具类函数是def定义的,那么它会被直接调用,而不会运行在任何线程池中;如果是async def定义的,那么当你调用的时候应当使用await操作。
参考文章:
https://fastapi.tiangolo.com/async/