零、释义
一、背景
- 本篇文章基于一个BUG的排查和解决过程,试图还原在某些场景下多进程编程的【陷阱】,达到前车之鉴的效果。
- 程序基于python,但结论和道理适用于所有语言
二、BUG问题表现
最近的一段提示工程相关的python代码,在不同操作系统的情况下,表现不一样
- 在macos系统与linux系统的单进程、macos系统的多进程情况下均可以正常运行:
在linux的多进程情况下会卡在与milvus交互的地方,如下图
三、假设
- milvus服务端导致(磁盘满了、内存满了、服务繁忙等)
- 网络异常导致
- 操作系统导致
- 连接milvus所用的底层调用包导致
四、假设验证和BUG排查思路
❌【milvus服务端导致】
- 单独测试了milvus的读写,服务本身没有问题,milvus所在服务器也是健康状态,不存在资源缺乏的情况。排除1
❌【网络异常导致】
- telnet端口通,ping通且稳定,网络是OK的。排除2
✅【操作系统导致】✅【连接milvus所用的底层调用包导致】
- 初步判断为多进程导致的问题,那么为何macos中的多进程正常,linux系统的多进程就有问题呢?
排查思维链(Chain-of-Thought)
于是翻阅python关于多进程模块的官方文档,直到看到了这样一段话
- python多进程在不同操作系统,默认启动子进程的方式是不一样的,在windows和macos上,默认使用【spawn】,而在linux上,默认是用【fork】,那么问题很有可能出在这两种不同的启动方式上。
- 本着控制变量法的debug方式,我在linux上将子进程的启动方式指定为了【spawn】,✅问题解决,程序成功运行
至此,虽然表面上问题解决了, 但我对解决此BUG的收获只有:【spawn】大法好,对其他稍深层次的细节一无所知,遗留有一些关键问题:
- spawn是什么
- fork是什么
- 为什么针对此BUG,spawn可以,fork不行
- 如果我们偏要用fork来做,行不行,怎么做?
于是,又回过头仔细看了官方文档介绍以及 python官方issue讨论区,(如下图)
spawn与fork概念如下
- spawn:从头构建一个子进程,父进程的数据等拷贝到子进程空间内,拥有自己的Python解释器,所以需要重新加载一遍父进程的包,因此启动较慢,由于数据都是自己的,安全性较高
- fork:除了必要的启动资源外,其他变量,包,数据等都继承自父进程,并且是copy-on-write的,也就是共享了父进程的一些内存页,因此启动较快,但是由于大部分都用的父进程数据,所以是不安全的进程
- fork有可能导致不安全的进程,是因为fork用到copy-on-write技术,会继承父进程的数据和堆栈,由此导致一些不安全的问题。
那么针对此BUG,具体是哪个地方导致了不安全呢?
- 既然是milvus连接出了错,那先从连接下手,排查发现,
首先,主进程所在文件在import模块的时候,其中一个模块(文件)发起了一次milvus的连接,如下图
然后,主进程开始启动子进程(fork),子进程调用langchain的milvus模块,langchain中milvus连接初始化的代码是这样写的
- 子进程在上图中的步骤2的时候卡住,经排查是因为子进程根本没有连上milvus,但是步骤1明明已经判断过,如果没有连接,则创建。
再进一步看看connections.has_connection("default")这个函数,如下图
函数会判断self._connected_alias变量中是否有记录,进一步看看这个变量怎么来的
- 在连接milvus时,程序维护一个self._connected_alias变量来记录是否存在连接,connections.has_connection("default")函数只是去self._connected_alias中检查是否有连接记录,
- 至此发现问题关键所在,父进程在第一次连接milvus的时候,程序在self._connected_alias变量中记录了连接信息,当fork子进程的时候,self._connected_alias变量被一并继承给了子进程,而当子进程使用connections.has_connection("default")函数判断与milvus的连接状态的时候,发现了从父进程继承过来的self._connected_alias变量的已连接信息,于是判断为已有连接,导致子进程在实际没有连接milvus的情况下直接加载milvus的数据,引发错误。
五、解决方案
解决方案1
方案
- 采用spawn方式启动子进程
优点
- 简单粗暴,子进程和父进程独立,数据隔离,进程安全
- 拓展和维护相对方便,不用担心类似的BUG
不足
- spawn方式,会老老实实地copy父进程的数据(即使不需要),比较占内存空间,启动会慢一些
解决方案2
方案
采用fork方式启动子进程,需要对代码做如下修改
如果可以删除主进程中连接milvus的代码
- 将milvus连接工作都放到子进程中做
如果不能删除主进程中连接milvus的代码
- 在子进程判断与milvus是否已连接的时候,不采用connections.has_connection("default")函数,而是查看本进程自身的套接字连接,避免来自父进程继承脏数据的污染,需要新增have_socket函数,做法如下
def have_socket(): have_socket = False process_netstat = psutil.Process(os.getpid()) for _socket in process_netstat.connections(): if _socket.raddr.port == MILVUS_PORT: have_socket = True return have_socket if not have_socket(): connections.connect(**connection_args)
优点
- 采用fork,子进程启动快,通过优化代码逻辑,避免进程不安全的情况
不足
- 后续的代码拓展和维护都要注意代码逻辑,避免类似BUG
六、总结
- 写多线程/多进程代码的时候,需要注意具体代码逻辑,避免继承的脏数据导致线程/进程不安全
- 对于资源约束不大,性能要求不高的场景,多进程一律用spawn
七、号外
【python开发组消息】将spawn在所有平台上设置为默认选项已经提上日程 ,计划3.14版本正式上线
【fork的优点和应用场景】fork也不是一无是处,对于只读数据需要共享的情况,还是非常省内存资源,
- 比如编写模型预测的并发服务,fork只加载1份模型到内存,而spawn会加载N份,gunicorn的-preload参数就是基于fork的copy-on-write技术,达到模型只加载一次的目的
In general, fork is bad, but it's also convenient and people rely on it to prepare data in a main process and then "duplicate" the process to inherit cooked data. -Victor Stinner
版本信息
- python3.11.4
- langchain==0.0.146
References
- Python crashes on macOS after fork with no exec
- multiprocessing's default posix start method of 'fork' is broken: change to 'spawn’
- Multiprocessing causes Python to crash and gives an error may have been in progress in another thread when fork() was called
- 机器学习模型API多进程内存共享
- 写时复制
- https://docs.python.org/3/library/multiprocessing.html
- https://discuss.python.org/t/switching-default-multiprocessin...
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。