多进程fork的陷阱

零、释义

一、背景

  • 本篇文章基于一个BUG的排查和解决过程,试图还原在某些场景下多进程编程的【陷阱】,达到前车之鉴的效果。
  • 程序基于python,但结论和道理适用于所有语言

二、BUG问题表现

  • 最近的一段提示工程相关的python代码,在不同操作系统的情况下,表现不一样

    • 在macos系统与linux系统的单进程、macos系统的多进程情况下均可以正常运行:
    • 在linux的多进程情况下会卡在与milvus交互的地方,如下图

三、假设

  1. milvus服务端导致(磁盘满了、内存满了、服务繁忙等)
  2. 网络异常导致
  3. 操作系统导致
  4. 连接milvus所用的底层调用包导致

四、假设验证和BUG排查思路

  1. ❌【milvus服务端导致】

    1. 单独测试了milvus的读写,服务本身没有问题,milvus所在服务器也是健康状态,不存在资源缺乏的情况。排除1
  2. ❌【网络异常导致】

    1. telnet端口通,ping通且稳定,网络是OK的。排除2
  3. ✅【操作系统导致】✅【连接milvus所用的底层调用包导致】

    1. 初步判断为多进程导致的问题,那么为何macos中的多进程正常,linux系统的多进程就有问题呢?
    2. 排查思维链(Chain-of-Thought)

      1. 于是翻阅python关于多进程模块的官方文档,直到看到了这样一段话

      2. python多进程在不同操作系统,默认启动子进程的方式是不一样的,在windows和macos上,默认使用【spawn】,而在linux上,默认是用【fork】,那么问题很有可能出在这两种不同的启动方式上。
      3. 本着控制变量法的debug方式,我在linux上将子进程的启动方式指定为了【spawn】,✅问题解决,程序成功运行
      4. 至此,虽然表面上问题解决了, 但我对解决此BUG的收获只有:【spawn】大法好,对其他稍深层次的细节一无所知,遗留有一些关键问题:

        1. spawn是什么
        2. fork是什么
        3. 为什么针对此BUG,spawn可以,fork不行
        4. 如果我们偏要用fork来做,行不行,怎么做?
      5. 于是,又回过头仔细看了官方文档介绍以及 python官方issue讨论区,(如下图)

      6. spawn与fork概念如下

        1. spawn:从头构建一个子进程,父进程的数据等拷贝到子进程空间内,拥有自己的Python解释器,所以需要重新加载一遍父进程的包,因此启动较慢,由于数据都是自己的,安全性较高
        2. fork:除了必要的启动资源外,其他变量,包,数据等都继承自父进程,并且是copy-on-write的,也就是共享了父进程的一些内存页,因此启动较快,但是由于大部分都用的父进程数据,所以是不安全的进程
      7. fork有可能导致不安全的进程,是因为fork用到copy-on-write技术,会继承父进程的数据和堆栈,由此导致一些不安全的问题。
      8. 那么针对此BUG,具体是哪个地方导致了不安全呢?

        1. 既然是milvus连接出了错,那先从连接下手,排查发现,
        2. 首先,主进程所在文件在import模块的时候,其中一个模块(文件)发起了一次milvus的连接,如下图

        3. 然后,主进程开始启动子进程(fork),子进程调用langchain的milvus模块,langchain中milvus连接初始化的代码是这样写的

        4. 子进程在上图中的步骤2的时候卡住,经排查是因为子进程根本没有连上milvus,但是步骤1明明已经判断过,如果没有连接,则创建。
        5. 再进一步看看connections.has_connection("default")这个函数,如下图

        6. 函数会判断self._connected_alias变量中是否有记录,进一步看看这个变量怎么来的

        7. 在连接milvus时,程序维护一个self._connected_alias变量来记录是否存在连接,connections.has_connection("default")函数只是去self._connected_alias中检查是否有连接记录,
        8. 至此发现问题关键所在,父进程在第一次连接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

  1. Python crashes on macOS after fork with no exec
  2. multiprocessing's default posix start method of 'fork' is broken: change to 'spawn’
  3. Multiprocessing causes Python to crash and gives an error may have been in progress in another thread when fork() was called
  4. 机器学习模型API多进程内存共享
  5. 写时复制
  6. https://docs.python.org/3/library/multiprocessing.html
  7. https://discuss.python.org/t/switching-default-multiprocessin...

你可能感兴趣的:(多进程fork的陷阱)