【问题标题】:Void pointer pointing to the same address指向同一个地址的空指针
【发布时间】:2019-05-10 18:20:52
【问题描述】:

问题

指向 cdef 类的空指针指向相同的内存地址,而不强制 python 引用计数器。

说明

我有一个简单的类,我想通过将其转换为 void 指针来将其存储在 cpp 向量中。但是,在打印指针指向的内存地址后,它会在第二次迭代后重复,除非我通过将新对象添加到列表中来强制增加引用计数器。有人可以在没有执行引用计数器的情况下为什么内存循环回来吗?

# distutils: language = c++
# distutils: extra_compile_args = -std=c++11
from libcpp.vector cimport vector
from libc.stdio cimport printf

cdef class Temp:
    cdef int a
    def __init__(self, a):
        self.a = a


def f():
    cdef vector[void *] vec
    cdef int i, n = 3
    cdef Temp tmp
    cdef list ids = []
    # cdef list classes  = [] # force reference counter?
    for i in range(n):
        tmp = Temp(1)
        # classes.append(tmp)
        vec.push_back(<void *> tmp)
        printf('%p ', <void *> tmp)
        ids.append(id(tmp))
    print(ids)
f()

哪些输出:

[140137023037824, 140137023037848, 140137023037824]

但是,如果我通过将引用计数器添加到类列表来强制引用计数器:

[140663518040448, 140663518040472, 140663518040496]

【问题讨论】:

  • “通过将其转换为 void 指针” - 不要! void* 几乎总是错误的
  • cdef Temp tmp 声明了一个堆栈分配的对象,将其转换为 void* 是额外错误的!而且由于您在每次迭代中“替换”对象,这就是您的结果的来源(它重用相同的内存位置)
  • 有没有其他方法可以将 cdef 类转换为指针?具体来说,我的目标是在定义的不同类上强制执行线程安全并行性。但是,除非我直接停止 gil,否则我不能直接使用 python 对象。将它转换为 void 指针是我可以从网上搜到的唯一其他选择。
  • 我不是很明白这个问题,但是如果你想使用指针,那么你必须使用指针,例如:cdef Temp* tmp = new Temp(1)
  • 不幸的是,它只适用于 c++ 类,而事实并非如此。主要问题是我当前的实现是从 python 代码转换为 c++ 结构的类(在 cython 中没有本机 c++ 代码)。为了利用多线程支持,要么必须完全重写类,要么将它们转换为我为了安全访问的指针。由于缺乏在线示例,我认为我遇到的问题非常小众。

标签: c++ python-3.x memory-management cython void-pointers


【解决方案1】:

这个答案变得很长,所以有一个内容的快速概述:

  1. 对观察到的行为的解释
  2. 避免问题的简单方法
  3. 更系统化和 c++ 典型的解决方案
  4. 说明“nogil”模式下多线程代码的问题
  5. 为 nog​​il 模式扩展 c++ 典型解决方案

对观察到的行为的解释

与 Cython 的交易:只要您的变量是 object 类型或从它继承(在您的情况下为 cdef Temp),cython 就会为您管理引用计数。只要您将其转换为 PyObject * 或任何其他指针 - 引用计数就是您的责任。

显然,对创建对象的唯一引用是变量tmp,一旦你将它重新绑定到新创建的Temp-对象,旧对象的引用计数器变为0,它是被破坏 - 向量中的指针变得悬空。但是,可以重复使用相同的内存(很有可能),因此您始终会看到相同的重复使用地址。

简单的解决方案

您如何进行引用计数?例如(我使用PyObject * 而不是void *):

...
from cpython cimport PyObject,Py_XINCREF, Py_XDECREF    
...
def f():
    cdef vector[PyObject *] vec
    cdef int i, n = 3
    cdef Temp tmp
    cdef PyObject *tmp_ptr
    cdef list ids = []
    for i in range(n):
        tmp = Temp(1)
        tmp_ptr = <PyObject *> tmp
        Py_XINCREF(tmp_ptr)   # ensure it is not destroyed
        vec.push_back(tmp_ptr)
        printf('%p ', tmp_ptr)
        ids.append(id(tmp))

    #free memory:
    for i in range(n):
        Py_XDECREF(vec.at(i))
    print(ids)

现在所有对象只有在显式调用 Py_XDECREF 后才会保持活动状态和“死亡”。

C++ 典型解决方案

上面不是很典型的c++做事方式,我宁愿介绍一个自动管理引用计数的包装器(不像std::shared_ptr):

...
cdef extern from *:
    """
    #include <Python.h>
    class PyObjectHolder{
    public:
        PyObject *ptr;
        PyObjectHolder():ptr(nullptr){}
        PyObjectHolder(PyObject *o):ptr(o){
           Py_XINCREF(ptr);
        }
        //rule of 3
        ~PyObjectHolder(){
            Py_XDECREF(ptr);
        }
        PyObjectHolder(const PyObjectHolder &h):
            PyObjectHolder(h.ptr){}
        PyObjectHolder& operator=(const PyObjectHolder &other){
            Py_XDECREF(ptr);
            ptr=other.ptr;
            Py_XINCREF(ptr);
            return *this;
        }
    };
    """
    cdef cppclass PyObjectHolder:
        PyObjectHolder(PyObject *o)

...
def f():
    cdef vector[PyObjectHolder] vec
    cdef int i, n = 3
    cdef Temp tmp
    cdef PyObject *tmp_ptr
    cdef list ids = []
    for i in range(n):
        tmp = Temp(1)
        vec.push_back(PyObjectHolder(<PyObject *> tmp)) # vector::emplace_back is missing in Cython-wrappers
        printf('%p ', <PyObject *> tmp)
        ids.append(id(tmp))
   print(ids) 
   # PyObjectHolder automatically decreases ref-counter as soon 
   # vec is out of scope, no need to take additional care

值得注意的事情:

  1. PyObjectHolder 在拥有 PyObject 指针后立即增加 ref-counter,并在释放指针后立即减少它。
  2. 三法则意味着我们还必须注意复制构造函数和赋值运算符
  3. 我已经省略了 c++11 的 move-stuff,但您也需要注意它。

nogil 模式的问题

然而有一件非常重要的事情:你不应该使用上述实现发布 GIL(即,将其导入为 PyObjectHolder(PyObject *o) nogil,但当 C++ 复制向量和类似内容时也会出现问题) -因为否则Py_XINCREFPy_XDECREF 可能无法正常工作。

为了说明这一点,让我们看一下下面的代码,它释放 gil 并并行执行一些愚蠢的计算(整个魔法单元在答案末尾的列表中):

%%cython --cplus -c=/openmp 
...
# importing as nogil - A BAD THING
cdef cppclass PyObjectHolder:
    PyObjectHolder(PyObject *o) nogil

# some functionality using a lot of incref/decref  
cdef int create_vectors(PyObject *o) nogil:
    cdef vector[PyObjectHolder] vec
    cdef int i
    for i in range(100):
        vec.push_back(PyObjectHolder(o))
    return vec.size()

# using PyObjectHolder without gil - A BAD THING
def run(object o):
    cdef PyObject *ptr=<PyObject*>o;
    cdef int i
    for i in prange(10, nogil=True):
        create_vectors(ptr)

现在:

import sys
a=[1000]*1000
print("Starts with", sys.getrefcount(a[0]))
# prints: Starts with 1002
run(a[0])
print("Ends with", sys.getrefcount(a[0]))
#prints: Ends with 1177

我们很幸运,程序没有崩溃(但可能!)。然而,由于竞态条件,我们最终导致内存泄漏 - a[0] 的引用计数为 1177 但只有 1000 个引用(sys.getrefcount 内的 +2 个引用)活着,所以这个对象永远不会被破坏。

使PyObjectHolder 线程安全

那该怎么办?最简单的解决方案是使用互斥锁来保护对 ref-counter 的访问(即每次调用 Py_XINCREFPy_XDECREF 时)。这种方法的缺点是它可能会显着降低单核代码的速度(例如,参见this old article,关于旧的尝试用类似互斥的方法替换 GIL)。

这是一个原型:

%%cython --cplus -c=/openmp 
...
cdef extern from *:
    """
    #include <Python.h>
    #include <mutex>

    std::mutex ref_mutex;

    class PyObjectHolder{
    public:
        PyObject *ptr;
        PyObjectHolder():ptr(nullptr){}
        PyObjectHolder(PyObject *o):ptr(o){
            std::lock_guard<std::mutex> guard(ref_mutex);
            Py_XINCREF(ptr);
        }
        //rule of 3
        ~PyObjectHolder(){
            std::lock_guard<std::mutex> guard(ref_mutex);
            Py_XDECREF(ptr);
        }
        PyObjectHolder(const PyObjectHolder &h):
            PyObjectHolder(h.ptr){}
        PyObjectHolder& operator=(const PyObjectHolder &other){
            {
                std::lock_guard<std::mutex> guard(ref_mutex);
                Py_XDECREF(ptr);
                ptr=other.ptr;
                Py_XINCREF(ptr);
            }
            return *this;
        }
    };
    """
    cdef cppclass PyObjectHolder:
        PyObjectHolder(PyObject *o) nogil
    ...

现在,运行从上面截取的代码会产生预期/正确的行为:

import sys
a=[1000]*1000
print("Starts with", sys.getrefcount(a[0]))
# prints: Starts with 1002
run(a[0])
print("Ends with", sys.getrefcount(a[0]))
#prints: Ends with 1002

但是,正如@DavidW 所指出的,使用std::mutex 仅适用于openmp 线程,而不适用于Python 解释器创建的线程。

这是互斥解决方案将失败的示例。

首先,将 nogil-function 包装为 def-function:

%%cython --cplus -c=/openmp 
...
def single_create_vectors(object o):
    cdef PyObject *ptr=<PyObject *>o
    with nogil:
         create_vectors(ptr)

现在使用threading-module 来创建

import sys
a=[1000]*10000  # some safety, so chances are high python will not crash 
print(sys.getrefcount(a[0]))  
#output: 10002  

from threading import Thread
threads = []
for i in range(100):
    t = Thread(target=single_create_vectors, args=(a[0],))
    threads.append(t)
    t.start()
for t in threads:
    t.join()

print(sys.getrefcount(a[0]))
#output: 10015   but should be 10002!

使用std::mutex 的替代方法是使用Python 机器,即PyGILState_STATE,这将导致代码类似于

...
PyObjectHolderPy(PyObject *o):ptr(o){
    PyGILState_STATE gstate;
    gstate = PyGILState_Ensure();
    Py_XINCREF(ptr);
    PyGILState_Release(gstate);
}
...

这也适用于上面的threading-example。然而,PyGILState_Ensure 的开销太大了——对于上面的例子,它比互斥锁解决方案慢大约 100 倍。一种更轻量级的 Python 机器解决方案也意味着更多的麻烦。


列出完整的线程不安全版本:

%%cython --cplus -c=/openmp 

from libcpp.vector cimport vector
from libc.stdio cimport printf
from cpython cimport PyObject  
from cython.parallel import prange

import sys

cdef extern from *:
    """
    #include <Python.h>

    class PyObjectHolder{
    public:
        PyObject *ptr;
        PyObjectHolder():ptr(nullptr){}
        PyObjectHolder(PyObject *o):ptr(o){
            Py_XINCREF(ptr);
        }
        //rule of 3
        ~PyObjectHolder(){
            Py_XDECREF(ptr);
        }
        PyObjectHolder(const PyObjectHolder &h):
            PyObjectHolder(h.ptr){}
        PyObjectHolder& operator=(const PyObjectHolder &other){
            {
                Py_XDECREF(ptr);
                ptr=other.ptr;
                Py_XINCREF(ptr);
            }
            return *this;
        }
    };
    """
    cdef cppclass PyObjectHolder:
        PyObjectHolder(PyObject *o) nogil


cdef int create_vectors(PyObject *o) nogil:
    cdef vector[PyObjectHolder] vec
    cdef int i
    for i in range(100):
        vec.push_back(PyObjectHolder(o))
    return vec.size()

def run(object o):
    cdef PyObject *ptr=<PyObject*>o;
    cdef int i
    for i in prange(10, nogil=True):
        create_vectors(ptr)

【讨论】:

  • 非常感谢这个例子!我在 c/c++ 方面的技能是有限的,但这让我对正确转换我的其他一些 python 代码所必需的东西有了更多的了解。这可能是一个远景,但你推荐这种方法来使用 prange 索引到单独的类中。我正在寻找类似于 multiprocessing.Pool 处理它的单独进程。我似乎真的无法在 cython 文档中找到正确的在线搜索词/在 cython 文档中。
  • @GlobalTraveler 很难说,不知道你想做什么。但是,如果您计划发布 GIL(即 prange),则不应使用上述实现 - 请参阅我的上次编辑。
  • 你能详细说明最后一部分吗?最终目标是创建 N 个类,其中 N 是线程数,以便我可以在 prange 下的线程安全问题中使用它们。仅查看/使用的范围内的对象不会被删除。
  • @GlobalTraveler 我已将解决方案扩展为线程安全的。如果您知道引用计数器不会被更改,那么您就不需要线程安全 - 但有时要确保引用计数器不被触及并不容易。
  • @DavidW 你是对的,我添加了一个互斥锁失败的示例(使用threading-module)。
【解决方案2】:

您的对象最终位于同一地址的事实是巧合。您的问题是,当对它们的最后一个 python 引用消失时,您的 python 对象会被破坏。如果你想让 python 对象保持活跃,你需要在某个地方保存对它们的引用。

在您的情况下,由于 tmp 是对您在循环中创建的 Temp 对象的唯一引用,因此每次重新分配 tmp 时,它之前引用的对象都会被销毁。这会在内存中留下恰好大小合适的空白空间,以方便地保存在循环的下一次迭代中创建的 Temp 对象,从而导致您在指针中看到的交替模式。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2019-09-07
    • 1970-01-01
    • 2016-08-11
    • 1970-01-01
    • 1970-01-01
    • 2013-05-09
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多