为了账号安全,请及时绑定邮箱和手机立即绑定

Python中用多进程处理SQLAlchemy数据库连接池的方法

标签:
Python 数据库

你正在使用 multiprocessing 在多个处理器上运行带有连接池的代码段,然后砰!你可能会遇到以下几种情况之一,你可能会经历这种情况之一。

  • 你看到一些奇怪的数据库错误,但这些错误在不使用多进程时不会出现!
  • 你的应用程序卡顿了!
  • 你看到一些奇怪的数据库错误,然后应用程序就卡住了!

你的调试本能会启动,你会检查活跃连接和CPU使用情况,却发现一切正常。一切都显得很轻松,一切都在悠哉游哉。

这时候,挫败感来了! 😑

Python中的多进程是怎么工作的?

快速回顾一下多进程,想象一下你有一台计算机,这台计算机的CPU有4个核心。一个核心被定义为“CPU(中央处理单元)中的独立处理单元,负责执行指令和进行计算任务”。

这意味着我们可以并行运行4个进程,也就是多进程!换句话说,这就是多进程处理!

主要的问题是,这些4个进程是怎么来的?

如果一个进程是由其父进程生成的,那么它就被称为子进程。

在这篇文章里,我们会讨论一下子进程!

在多进程处理中,如何创建子进程?

创建子进程的两种常用方式是“fork”或“spawn”。

在 fork 中,子进程 [子] 几乎是主程序的一个副本,包括锁的状态、文件描述符、整个内存(包括初始化的 Python 解释器、加载的模块以及内存中的对象)。需要指出的是,每个子进程都有自己的内存空间。

    # 创建一个子进程  

    import os  

    if fork(): # fork() 函数返回 0 表示是子进程  
      print("从父进程打印 {}".format(os.getpid()))  
    else:  
      print("从子进程打印 {}".format(os.getpid()))

Fork很快, 更具体来说,因为它从不重新加载整个应用程序到内存里。它“几乎”复制了父进程,而不是完全重新创建。

现在,说到“几乎”那部分。Fork不会复制主程序中的线程。这可能是你的应用程序在某个点卡住的原因。

我们可以通过一个例子来看理解它。

    import os  
    import threading  
    import multiprocessing  

    # 在父进程中  
    lock = threading.Lock()  
    lock.acquire()  

    def print_vamos():  
        print("正在尝试获取资源锁")  
        lock.acquire()  
        print("这段代码不会被执行!") # 这段代码不会被执行!  
        print("Vamos!")  
        lock.release()  
        print("释放了锁,太棒了!")  

    使用multiprocessing.Pool(2)作为pool:  
        pool.apply(print_vamos)  

    关闭pool  
    等待pool完成  

    释放锁

在上述示例中,父线程获取了CPU资源的锁权限,之后我们创建了两个子进程,几乎,这两个子进程会复制父进程中的所有内容,包括父进程的锁状态。

现在,当子线程尝试获取锁时,由于锁已经被另一个线程占用,它无法获取锁,所以它会等待锁被释放,但是锁不会被释放,因为获取锁的线程仍在运行!所以我们就遇到了一个“死锁”的情况。

所以,Fork 实际上并不安全! 分叉出的进程也可能变得很耗资源,因为父进程可以运行一些对内存要求很高的操作,这样的操作对于子进程来说并不需要。

    import multiprocessing  

    df = pd.read_csv("一个巨大的文件(例如:'A huge file')")  # 虽然子进程不需要它,但这个数据框会被复制到子进程中。  

    def print_vamos():  
      print("Vamosss!")  

    with multiprocessing.Pool(2) as pool:  # 使用multiprocessing.Pool(2) as pool:  
     pool.apply(print_vamos)

总之来说,Fork速度快,既不安全也不紧凑!

使用**spawn**创建子进程有助于解决这些“不安全的隐患”和“不够紧凑的问题”。

spawn 实质上几乎是从头重新运行程序,在此过程中几乎复制了父进程中的所有内容。简而言之,它执行了 fork() 后跟 execve() 系统调用,这使子进程作为新程序运行,从而移除所有复制的内容!这就解决了我们的问题,因为锁的状态不再由子进程继承!

所以,比如说spawn是安全、紧凑但比fork要慢。

注意: 父进程和子进程之间没有共享内存空间。子进程只继承了在 fork 时父进程的状态。因此,子进程所做的更改不会影响到其他进程或父进程的状态。

在本文中,我们将继续使用 fork 方法,因为我的情况需要一些初始化操作仅在父级初始化一次!

不过要注意的是,如果你是用 spawn 创建子进程,就不用管下面的内容了!😄

怎样正确地使用连接池来创建子进程?

参考文档

“需要注意的是,在使用连接池时,进而通过[create_engine()](https://docs.sqlalchemy.org/en/20/core/engines.html#sqlalchemy.create_engine)创建的[Engine](https://docs.sqlalchemy.org/en/20/core/connections.html#sqlalchemy.engine.Engine)时,不要把连接池中的连接分享给子进程。TCP连接以文件描述符的形式表示,通常可以在进程间共享,这会导致两个或多个完全独立的Python解释器实例同时访问同一个文件描述符。”

根据具体的驱动程序和操作系统,这里出现的问题从连接失效到被多个进程同时使用的套接字连接出错导致消息传递中断不等(后者更为常见).

我们来理解一下上面的内容。

当你复制连接池中的一个连接时会发生什么?

“TCP连接被表示为文件描述符(file descriptor),这些文件描述符通常跨进程边界有效,这意味着两个或多个完全独立的Python解释器实例将并发访问同一个文件描述符。”

关键词是文件描述符,在类 Unix 操作系统中,文件描述符是一个底层的整数句柄,用来表示打开的文件或套接字(包括 TCP 连接)。操作系统使用文件描述符来管理打开的文件和套接字。

如我们在上一节中讨论的,我们知道在执行fork时,子进程会复制“几乎”所有内容,包括文件描述符。 这意味着父进程中的所有连接也会被子进程继承。

为什么不要复制连接池中的连接?

“根据具体的驱动程序和操作系统,这里可能出现的问题包括无法工作的连接到多个进程同时使用的套接字连接,导致消息传递失败(这种情况通常更为常见)。 ”

分叉之后,父进程和子进程各自拥有独立的连接池状态。

对一个进程中连接池所做的更改不会反映到另一个进程中。这可能导致不一致的情况,因为两个进程都可能认为自己对同一组连接有控制权。

    from sqlalchemy import create_engine  
    import multiprocessing  
    import os  

    # 在父进程中创建一个连接池大小为5的引擎  
    engine = create_engine('sqlite:///example.db', pool_size=5)  

    def get_stuff():  
      '''  
      这里接收到的连接与父进程中创建的连接相同  
      '''  
      with engine.connect() as conn:  
            # 哇!你可能会开始看到一些奇怪的行为!  
            conn.execute(text("..."))  

    # 这里将创建一个新的连接  
    with engine.connect() as conn:  
            # 执行完成后,连接会被返回到连接池。  
            conn.execute(text("..."))  

    with multiprocessing.Pool(2) as pool:  
      pool.apply(get_stuff)  

这里有一个重要的点,文件句柄没有提供锁定功能。操作系统管理文件句柄,它只确保这些句柄指向正确的资源,在这里是指数据库。

所以,当一个文件描述符(代表一个TCP连接)被多个进程共享时,这些进程可以尝试并发地从同一个连接读或写入数据。

这会带来不同的问题,比如:

  • 无效连接:某些数据库驱动程序或网络协议可能会发现文件句柄被多个进程访问,并关闭连接,导致在尝试使用时出现错误。
  • 损坏的消息:当多个进程没有协调地从同一个TCP流中读取时,它们会不可预测地读取数据流的某些部分,导致交错或混乱的回复信息。

一个消息中断的例子,比如:

    # 进程A:
    sock.sendall(b"Request A") # 期望得到 "Hello"

    # 进程B:
    sock.sendall(b"Request B") # 期望得到 "World"

    # 进程A从服务器端读取响应的第一部分:
    response_a_part = sock.recv(3)  # 读取到 "Hel"

    # 在进程A完成读取其响应之前,进程B从同一个套接字读取:
    response_a_part = sock.recv(5)  # 读取到 "loWor"

    # 进程A尝试读取剩余的响应部分:
    response_a_rest = sock.recv(1024)  # 读取到剩余的 'ld'

最后啊,

  • response_a_part + response_a_rest 对进程 A 来说可能是这样的 "Helld" 而不是预期的 "Hello"
  • response_b_part 对进程 B 来说可能是这样的 "lowor" 而不是正确的 "World".
如何正确地复制一个连接池?

答案是不要在连接池中分叉连接哦! 如果有连接在池中,确保在分叉前妥善处理。我们可以使用engine.dispose(close=False)这个SQLAlchemy提供的方法来实现这一点。这里的close=False参数确保了活动连接能优雅地被移除。

    from sqlalchemy import create_engine, text  
    import multiprocessing  
    import os  

    # 在父进程中创建一个带有连接池的引擎  
    engine = create_engine('sqlite:///example.db', pool_size=5)  

    def initializer():  
        # 池中的连接会被优雅地移除!  
        engine.dispose(close=False)  

    def get_stuff():  

      # 创建一个新的连接。  
      with engine.connect() as conn:  
            '''   
            执行后,连接会被返回到子进程中的连接池中。  
            '''  
            conn.execute(text("..."))  

    with engine.connect() as conn:  
            # 执行后,连接会被返回到连接池中。  
            conn.execute(text("..."))  

    with multiprocessing.Pool(2, initializer=initializer) as pool:  
      pool.apply(get_stuff)  

现在,子进程的连接状态已经清理干净。当接收到进程的请求时,引擎会为该进程创建新的连接,这些连接仅限于该进程使用。

总结
  • 最安全的多进程处理数据库连接的方法是使用 spawn 方法创建子进程。
  • 如果使用 fork 方法,你需要确保使用 engine.dispose(close=False) 处理现有的任何连接,并且在分叉时确认父进程没有已获取的锁!
参考文献
点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消