Python/C++ 跨线程的引用计数
Python/C++ 跨线程的引用计数
Python的内存管理机制
在 Python 的底层,每一个对象都由一个 PyObject
结构体表示,而对对象的引用就是一个 PyObject*
指针。该结构体定义于头文件 Include/object.h
中,源码如下:
1 | typedef struct _object { |
除去宏 _PyObject_HEAD_EXTRA
(用于调试或内存布局优化),结构体中最核心的是两个字段:
ob_refcnt
:引用计数,用于跟踪当前对象被引用的次数;ob_type
:指向该对象的类型对象,包含了类型元信息和方法表。
Python 采用引用计数机制管理内存:
- 每当对象被一个新变量引用时,
ob_refcnt
加一; - 当引用解除时,
ob_refcnt
减一; - 一旦
ob_refcnt
减至 0,说明对象已不再被使用,Python 会立即触发销毁流程。
此时,Python 会调用该对象类型对应的 C 层析构函数 tp_dealloc
:
1 | (*Py_TYPE(op)->tp_dealloc)(op); |
这里 tp_dealloc
是对象类型在 C 层注册的销毁回调函数,用于释放对象占用的资源或内存。这种内存管理方式在 Python 语言内部是非常可靠的,引用生命周期清晰,回收具有确定性。然而,在 Python 与 C++ 混合编程场景中,特别是在 C++ 中启动后台线程(如通过 std::thread
)时,如果对象引用未被妥善保留,Python 可能在 C++ 线程执行期间就销毁了对象,从而引发悬空指针或访问非法内存等典型的内存错误。这类问题也常见于 PyQt 的 QThread
使用中,因为 Python 与 Qt 的生命周期模型并不一致。
Python/C++跨线程
一个示例
1 | // worker.cpp |
我们编写一个C++的模块,并通过pybind11把它打包成Python的模块。代码中定义了一个类 Worker
,它有一个构造函数,接收一个 std::string
类型的名字,赋值给 _name_
变量。定义了一个析构函数 ~Worker
,用于观察对象何时被销毁。定义了一个成员函数 start_in_background
,这个函数的作用是在一个独立线程中,延迟 2 秒后打印成员变量 _name
的值,不阻塞主线程。std::thread
是C++11 引入的标准线程类,表示一个真正的操作系统线程。 std::thread
的构造函数接受一个可调用对象,并在创建时立刻启动一个新线程。std::this_thread
是一个表示当前线程的命名空间,sleep_for
是其静态方法,用于让当前线程暂停指定时间。使用 detach()
让线程与主线程脱钩,独立运行。主线程不会等待这个线程完成,程序会立即失去对这个线程的控制权,无法再检查它的状态或安全终止它。Lambda 表达式用于生成一个匿名函数,它的的基本语法是:
1 | [capture](parameter_list) -> return_type { |
其中 [capture]
部分用于捕获外部变量,parameter_list
为像普通函数一样的参数列表,return_type
为可省略的返回类型,{}
函数体部分为具体要执行的代码,整个 Lambda 表达式返回一个函数对象。采用 pybind11 将 Worker
类绑定 Python。将这个 C++ 模块注册为 Python 中的 worker
模块,将 C++ 的 Worker
类暴露为 Python 中的类 Worker
。指定构造函数绑定,同时将成员函数暴露为 Python 方法。编写 cMakeLists.txt 用于编译这个 pybind11 模块。
1 | # cMakeLists.txt |
执行:
1 | cmake . -G Ninja -B build |
将 ninja 构建的模块放到和 run.py
相同的目录中,就可以调用它了:
1 | # run.py |
当运行 safe
部分的时候,得到的结果是:
1 | [C++] Worker safe done |
当运行 danger
部分的时候,得到的结果是:
1 | [C++] Worker destroyed: danger |
Python 内存提前回收
当运行 safe
版本版本的时候,w
是一个显式变量,引用计数 +1。之后,C++ 线程启动,进入 sleep。此时,w
仍存在,直到函数 run()
返回,垃圾回收机制才将其销毁。线程醒来后,访问 name_
安全。最后 Python 打印完后,调用类的析构函数来销毁对象,输出析构信息。
而当运行 danger
版本的时候:
1 | worker.Worker("Danger").start_in_background() |
在这句代码中,首先 Python 会临时创建一个 Worker
实例,它是一个绑定到 C++ 中 Worker
的 pybind11 封装对象。它实际上是一个 Python 层的 py::object
,里面包含了指向 C++ 中 Worker
对象的指针。在调用 start_in_background
后,这个 Worker
的 Python 引用就不再存在了。 Python 基于引用计数的垃圾回收机制发现这个对象没有任何引用了,于是就会销毁这个 py::object
对象。Pybind 会注册一个 C 层的 tp_dealloc
,用来销毁 Worker
对象。这个函数默认会调用 delete
(释放 C++ Worker
)并释放 Python 对象的包装结构体。这个时候,在start_in_background
中访问 this
指针,实际上访问的就是一个已经被释放的 C++ 内存地址,表现出的结果就是乱码。更细致一些,
worker.Worker("Danger")
创建的是一个py::object
,这个对象里封装了一个 C++Worker*
指针。当 Python 检测到
py::object
无引用时,它会触发析构流程,最终释放 C++ 层的对象(你写的~Worker()
)。而 C++ 线程中捕获的
this
,就是这个被释放掉的Worker
。
在 Qt 中也会出现类似的问题,我们编写一个简单的 PyQt 代码。在点击 button 后,触发 start_and_lose_reference
函数,创建 thread
和 worker
。启动线程后,worker.run()
延迟 2s 输出。但由于 thread
和 worker
是局部变量,在函数返回后就会被 Python 垃圾回收(GC)。Qt 后台线程仍然试图运行已经销毁的 Worker,程序立即崩溃。
1 | from PyQt5.QtWidgets import QApplication, QPushButton, QWidget, QVBoxLayout |
点击后,得到输出:
1 | [MainWindow] 创建 thread 和 worker |
此时,只需要在 Python 中保存引用,程序就可以正常运行了,因为此时 Python 不会将这部分内存垃圾回收。
1 | def start_and_lose_reference(self): |
C++的内存管理
Python 和 C++ 的内存管理对比
Python 靠自动引用计数+垃圾回收机制来自动进行内存管理,当引用计数归零时销毁对象内存。而 C++ 则没有垃圾回收机制,完全依赖于自己。C++ 中有两种对象,栈对象(stack object)和堆对象(heap object)。栈对象是在函数/作用域内直接通过声明创建的普通变量或对象,比如:
1 | void func() { |
栈对象存在于调用栈上,生命周期随着函数/作用域自动开始和结束,它会自动调用析构函数释放内存,不需要手动操作。因此,函数一定不要返回栈对象的地址,比如 return &obj
。因为 obj
是局部栈变量,函数执行完之后就被销毁了,地址变成悬空指针。而直接返回栈对象时,根据编译器的优化策略可能会有两种情况。理论上当没有优化的时候,返回时可能会进行一个拷贝构造或者移动构造过程,来返回一个新的对象,而局部对象在函数返回前一定会被析构。而实际中一般编译器会进行返回值优化 RVO(Return Value Optimization),编译器可能把 func()
中的 obj
优化重定向到 main()
的栈帧中,从而省掉拷贝/移动构造。也就是说在函数作用域内它仍然“存在”,但编译器背地里让这个对象被构造在了别处。
堆对象是通过 new
创建的对象:
1 | MyClass* p = new MyClass(); // p 是裸指针,指向堆对象 |
堆对象分配在堆内存区域,必须用 delete
手动释放。生命周期不跟随作用域,可跨越函数调用、线程、异步等,适合需要动态延长生命周期的对象。如果忘记了 delete
,这块内存永远不会返回给操作系统,就会发生内存泄露。内存泄露是从程序内部逻辑角度说的,在程序退出时,操作系统本身会清理进程的所有内存,包括所有栈、堆、和全局分配的内存,以及所有打开的文件描述符、线程、资源等。因此即使程序发生了内存泄露,连续运行 100 次程序,系统的内存也不会越来越少。对于小脚本或者一次性程序,内存泄露的确通常没什么大的影响。但是对于一些需要长期运行的程序,比如后台服务、游戏、图形程序、循环调用接口的任务、Web服务等,如果每次 new
一个对象却不 delete
,这些对象就会一直存在在内存中。久而久之,内存就会被吃光,导致程序卡顿、崩溃,甚至拖垮整个系统。
1 | void leak() { |
new
了一块内存,操作系统在堆上分配了内存。ptr
是一个局部变量,存在于栈中。当函数结束,ptr
就销毁了,再也没有变量能访问这块内存。这时,内存还在,但没人能找到了,不能释放也不能用。这就是典型的内存泄漏:对象内存在,引用没了,程序不再能访问。泄露的对象就像漂浮在内存之海上的垃圾一样,没有任何变量指向它,它占着空间,但没法处理它。
Python 和 C++ 中 delete
操作的意义是不一样的,对于 Python 来说,del
变量实际上代表的是删除了这个变量在当前作用域的绑定:
1 | a = 'Hello, World!' |
Python 的变量是 “名字” -> ”对象“ 的映射,删除名字后就无法通过这个名字访问对应变量。而这不等于对象被销毁,只有当对象的引用计数归零时才会触发销毁。但在 C++ 中,delete
的作用和 Python 中完全反过来:
1 | int* p = new int(42); |
delete p
只是释放了 p
指向的堆上的那块内存,但变量 p
这个指针本身还在栈上,并没有消失。因此 delete p
后,我们还是可以从语法上访问 p
,但是访问的是已经释放的内存,会导致输出乱码或者崩溃。
智能指针
裸指针是 T*
类型的指针,它直接指向某个内存地址。C++ 没有对裸指针的内存进行自动管理,需要自己手动释放内存。像上面所说的,不正确使用裸指针可能导致内存泄露或者未定义的行为。为了简化操作,避免裸指针常见的错误,C++11 引入了智能指针来自动管理内存。C++ 标准库提供了 std::unique_ptr
和 std::shared_ptr
两种智能指针。std::unique_ptr
独占所有权,只能有一个指针持有资源。当离开作用域时,std::unique_ptr
会自动释放资源。
1 | std::unique_ptr<MyClass> p1 = std::make_unique<MyClass>(); // 安全创建 |
std::shared_ptr
和 Python 的机制比较类似,允许多个指针共享同一个对象,自动管理引用计数。当所有的 shared_ptr
引用消失时,对象才会被销毁:
1 | std::shared_ptr<MyClass> p2 = std::make_shared<MyClass>(); |