使用多线程 C++ 扩展时是不是需要注意 Python GIL?

Posted

技术标签:

【中文标题】使用多线程 C++ 扩展时是不是需要注意 Python GIL?【英文标题】:Does Python GIL need to be taken care when work with multi-thread C++ extension?使用多线程 C++ 扩展时是否需要注意 Python GIL? 【发布时间】:2016-05-29 19:21:08 【问题描述】:

我现在正在用 Python 实现一个数据订阅者,它订阅一个数据发布者(实际上是一个 ZeroMQ 发布者套接字),并且一旦提供任何新消息就会得到通知。在我的订阅者中,消息在收到后被转储到数据处理器。完成后,订阅者还将收到处理器的通知。由于数据处理器是用 C++ 编写的,因此我必须使用一个简单的 C++ 模块来扩展 Python 代码。

以下是我的数据订阅者的简化可运行代码示例。代码main.py,其中模块proc代表处理器,订阅localhost:10000上的ZeroMQ套接字,设置回调,并通过调用proc.onMsg将接收到的消息发送给处理器。

#!/bin/python
# main.py

import gevent
import logging
import zmq.green as zmq

import pub 
import proc

logging.basicConfig( format='[%(levelname)s] %(message)s', level=logging.DEBUG )

SUB_ADDR = 'tcp://localhost:10000'

def setupMqAndReceive():
    '''Setup the message queue and receive messages.
    '''
    ctx  = zmq.Context()
    sock = ctx.socket( zmq.SUB )
    # add topics
    sock.setsockopt_string( zmq.SUBSCRIBE, 'Hello' )

    sock.connect( SUB_ADDR )

    while True:
        msg = sock.recv().decode( 'utf-8' )
        proc.onMsg( msg )

def callback( a, b ):
    print( '[callback]',  a, b ) 

def main():
    '''Entrance of the module.
    '''
    pub.start()
    proc.setCallback( callback )
    '''A simple on-liner
    gevent.spawn( setupMqAndReceive ).join()
    works. However, the received messages will not be
    processed by the processor.
    '''
    gevent.spawn( setupMqAndReceive )
    proc.start()

模块proc 被简化为导出三个函数:

setCallback设置回调函数,当消息处理完毕后,可以通知我的订阅者; onMsg 被订阅者调用; start 设置一个新的工作线程来处理来自订阅者的消息,并使主线程加入等待工作线程退出。

完整版本的源代码可以在githubhttps://github.com/more-more-tea/python_gil 上找到。然而,它并没有按我的预期运行。一旦添加了处理器线程,订阅者就无法在 gevent 循环中接收来自发布者的数据。如果我简单地删除数据处理器模块,订阅者 gevent 循环可以接收来自发布者的消息。

代码有什么问题吗?我怀疑 GIL 会干扰消息处理器中 pthread 的并发性,或者 gevent 循环处于饥饿状态。任何有关该问题或如何调试它的提示将不胜感激!

【问题讨论】:

【参考方案1】:

全局解释器锁本身不会阻止线程被调度。 Python C API 不会到处乱跑,将自己注入到 pthread 库中。这有好有坏。

这很好,因为您实际上可以在 C 或 C++ 扩展中一次执行多项操作。

这很糟糕,因为您可能会不小心违反 GIL 规则。

GIL 的规则(大致)如下:

    当您的代码从 Python 调用时,您可能会假设您的线程具有 GIL。当您的代码被任何非 Python 调用时,您可能不会做出这种假设。 除非另有明确说明,否则您必须先拥有 GIL,然后才能调用 Python/C API 的任何部分。这包括 Python/C API 拥有的所有内容,甚至包括引用宏 Py_INCREF()Py_DECREF() 之类的简单内容。 在 C 或 C++ 函数中执行时,GIL 不会自动释放自身。如果您不需要 GIL,则需要手动执行此操作。特别是当你调用像pthread_join()select()这样的阻塞函数时,它不会自动释放自己,这意味着你阻塞了整个解释器。

这些规则的正式版本指定为here。密切关注“非 Python 创建的线程”部分;这正是你想要做的事情。

阅读您的代码,您似乎未能在procThread() 函数中获取GIL,并且在调用pthread_join() 之前也未能释放它。可能还有其他问题,但这些对我来说是最明显的。

【讨论】:

嗨@Kevin,非常感谢您抽出宝贵的时间。我对这个话题真的很陌生,没有方向去哪里。更糟糕的是,我没有在网上找到任何关于如何在 c 扩展中操作 GIL 的运行示例。您介意展示一些关于操作 GIL 的示例代码/更改吗? @Summer_More_More_Tea:我链接的 Python/C API 文档有许多代码示例。你能解释一下你不明白的地方吗? 谢谢,问题解决了。两分钱(procThread 和 pthread_join)指出了答案。一旦 pthread_join 被执行, gevent.spawn 也会被阻塞。我的解决方案是安排setupMqAndReceive在threading.Thread而不是gevent.Greenlet中运行,在pthread_join之前释放GIL,并在执行Python回调时在procThread中确保。 这是一个很好的解释,它解释了为什么我的代码被阻塞了。由于这些事情,我真的不喜欢做任何不是c 的事情。但无论如何,现在我知道我必须尝试什么才能解决这个问题,损失了 12 多个小时。【参考方案2】:

这是我对这个问题的解决方案以及我对 Python 线程和 pthread 原生线程的理解。

Python 线程虽然受 GIL 保护,但实际上是系统线程。唯一让它们不同的是,在运行时,Python 线程受 GIL 保护。 threading.Thread 产生的线程是 Python 线程,在这些线程中运行的所有代码都会自动受到 GIL 的保护。如果本地线程与 Python 线程共存并且 Python 线程即将运行阻塞语句,则 Python 线程中的 GIL 必须使用 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 释放,例如I/O、Thread.join、睡眠等

当其他线程在 Python 世界之外产生时,例如通过 pthread 库,在执行 Python 代码(对于纯 C/C++ 代码,根据我的经验无需获取 Python GIL)时,应使用 Python C API PyGILState_EnsurePyGILState_Release 显式获取 GIL,如 Kevin 的回答中所述。

更新后的代码可以在GitHub上找到。

如果有任何误解,请给我评论。谢谢大家!

【讨论】:

以上是关于使用多线程 C++ 扩展时是不是需要注意 Python GIL?的主要内容,如果未能解决你的问题,请参考以下文章

C++ 多线程std::thread 详解

C++ 多线程std::thread 详解

使用 MinGW 的 C++ 多线程

在C++中使用openmp进行多线程编程

从头认识多线程-4.3 ThreadLocal使用时需要注意的地方

C++并发与多线程 3_线程传参数详解,detach 注意事项