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

4
我现在正在使用Python实现一个数据订阅器,它订阅了一个数据发布器(实际上是一个ZeroMQ发布器套接字),并在有新消息时得到通知。在我的订阅者中,收到消息后会将其转储到数据处理器中。当处理完成后,订阅者也将得到处理器的通知。由于数据处理器是用C++编写的,所以我必须使用简单的C++模块扩展Python代码。
以下是我的数据订阅者的简化可运行代码示例。代码中,模块proc表示处理器,订阅了上的ZeroMQ套接字,设置回调,通过调用将接收到的消息发送到处理器。
#!/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 设置一个新的工作线程处理来自订阅者的消息,并使主线程等待工作线程退出。

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

代码有问题吗?我怀疑 GIL 干扰了消息处理中 pthread 的并发性,或者 gevent 循环饿死了。关于这个问题或如何调试它的任何提示都将不胜感激!

2个回答

12

全局解释器锁(GIL)本身不会阻止线程被调度。Python C API 不会到处注入自己到 pthread library 中。这既有好处也有坏处。

好处是你可以在 C 或 C++ 扩展中同时进行多个操作。

坏处是你可能会意外地违反 GIL 规则。

GIL 的规则(大致如下):

  1. 当 Python 调用你的代码时,可以假设你的线程具有 GIL。当非 Python 调用你的代码时,不可以做出这种假设。
  2. 如果没有明确文档指定,必须获取 GIL 才能调用 Python/C API 的任何部分,包括 Python/C API 拥有的 所有 东西,甚至简单的引用计数宏如 Py_INCREF()Py_DECREF()
  3. 只要执行在 C 或 C++ 函数内部,GIL 不会自动释放自己。如果你不需要 GIL,需要手动释放它。特别是当你调用类似于 pthread_join()select() 这样的阻塞函数时,它不会自动释放自己,这意味着你会阻塞整个解释器。
这些规则的正式版本在此处指定。请特别注意“非Python创建的线程”部分,它与您正在尝试做的事情有关。
阅读您的代码,看起来您未能在procThread()函数中获取GIL,并且在调用pthread_join()之前未能释放它。可能还存在其他问题,但这些是我最明显的问题。

嗨@Kevin,非常感谢你的时间。我对这个主题非常陌生,也不知道该往哪里走。更糟糕的是,我在网上没有找到任何有关如何在c扩展中操作GIL的运行示例。您介意展示一些有关操作GIL的示例代码/更改吗? - Summer_More_More_Tea
@Summer_More_More_Tea:我提供的Python/C API文档中有很多代码示例。你能解释一下你不理解的部分吗? - Kevin
谢谢,问题已解决。答案中的两个要点(procThread和pthread_join)起到了关键作用。此外,一旦执行pthread_join,gevent.spawn将被阻塞。我的解决方案是将setupMqAndReceive调度为一个线程.Thread而不是gevent.Greenlet,在pthread_join之前释放GIL,并在执行Python回调时在procThread中确保GIL。 - Summer_More_More_Tea
这是一个很好的解释,它解释了为什么我的代码会阻塞。因为这种情况,我真的不喜欢做任何不是 [tag:c] 的事情。但无论如何,现在我知道我必须尝试什么来修复它,已经浪费了超过12个小时。 - Iharob Al Asimi

2
这是我对问题的解决方案以及我对Python线程和pthread本地线程的理解。
Python线程虽然受到GIL的保护,但实际上它们是系统线程。唯一使它们不同的是,在运行时,Python线程受到GIL的保护。由threading.Thread产生的线程是Python线程,所有在这些线程中运行的代码都会自动受到GIL的保护。如果原生线程与Python线程共存,并且Python线程即将运行阻塞语句(例如I/O、Thread.join、sleep等),则必须使用Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS释放Python线程中的GIL。
而在Python之外生成的其他线程,例如通过pthread库生成的线程,在执行Python代码时应该显式地使用Python C API PyGILState_EnsurePyGILState_Release获取GIL(根据我的经验,对于纯C/C++代码,不需要获取Python GIL)如Kevin的回答中所述。
更新后的代码可以在GitHub中找到。
如果有任何误解,请给我留言。谢谢大家!

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接