前言
笔者最近参与一款AI产品的Python服务后端开发,该服务主要将一个算法pipeline的各个子步骤包装成为接口,由上层调用方串联调度整个Pipeline。
该服务的算法操作步骤都是耗时较长IO密集型操作,充分利用Python Ayncio异步IO框架的特性,Fastapi单Worker实现较高的并发(该服务的设计思路和压测情况可以参考文档:)
问题描述
然而,最近发现服务在开发环境突然存在严重的性能问题,并发量20~30左右即出现问题:
- 调用方报错:"请求EOF"
- 调用方报错:“Connection refuse”
- 服务容器报错OOM,容器重启
Py-Spy
py-spy 是 Python 程序的采样分析器,由于不是插桩法,因此在不修改代码或重启程序的情况下也可以对正在运行的 Python 进程进行性能分析
安装命令
pip install py-spy
py-spy dump命令,用于获取正在运行的 Python 进程的当前调用栈信息
py-spy dump -p <pid>
py-spy还支持生成火焰图,可视化地查看每个函数及其子函数耗时统计
py-spy top命令,用于了解 Python 程序中哪些函数消耗了最多的时间,支持监控Python进程下的多线程,本文定位问题正是基于py-spy top命令
py-spy top -p <pid>
py-spy命令类似linux的top命令,%OWN%Total较大的函数需要关注
解决问题
确定方向
一开始认为是可能是协程太多导致Asyncio过载无法承受长时间运行的原因,于是将Fastapi的worker数量提高到10,调用方重新运行一个PipeLine,并发量35约6k次调用,这样的并发量远远没有达到服务瓶颈,甚至可以说绰绰有余。
然而服务依然报错了,问题再次出现。
通过日志排查发现,报错EOF的接口的协程函数运行到了某个步骤后就没有继续,这说明协程在await交出线程使用权后,Ayncio事件循环(Event Loop)就一直没有继续调度这些协程。
这可能是因为某个协程函数运行时间太长,导致其他协程无法得到调度从而“饿死”
以下两点是本人的猜测
- 维持http连接的协程被创建后无法被调度,从而导致“Connect Refused”的情况
- 未完成的和新创建的协程函数,持续积压,占用内存,导致了OOM的情况
因此,排查方向确定为:找到阻塞CPU的协程!!!
排查过程
一开始认为是其他开发者无用了同步阻塞的库,例如Request,所以检查最近的提交,但是并没有发现这种情况
为了尽快确定问题,我在服务容器中安装了py-spy,同时启动Pipline程序持续调用本服务的算法接口,期间利用py-spy top命令持续监控Python进程的运行各个函数耗时,如下图
运行一段时间后,从上图种可以看到函数longest_common_substring运行时间很长,且此时GIL锁的达到100%时持续了数秒后,容器奔溃并重启
阻塞CPU的函数
longest_common_substring不是网络IO操作,是CPU密集型的操作,这一点从GIL100%可以看出
该函数时间复杂度是O(m×n),两层嵌套循环没有将小循环放到外层,且入参字符串相等时完全可以利python的==操作符直接返回最长字串
对该函数通过增加日志,重新测试,并获取日志,发现一次Pipeline中调用了数百次该函数,更严重的是入参的两个字符串长度越大,耗时越长,某次耗时达到了49.9秒,也就是说大量的协程函数在将近一分钟没有得到调度
问题解决
负责该模块的算法同事,用其他算法实现了字符串的相似度计算
重新测试后,服务的性能崩溃问题得到解决