Python/C++ 跨线程的引用计数

Python/C++ 跨线程的引用计数

Python的内存管理机制

在 Python 的底层,每一个对象都由一个 PyObject 结构体表示,而对对象的引用就是一个 PyObject* 指针。该结构体定义于头文件 Include/object.h 中,源码如下:

1
2
3
4
5
typedef struct _object {
_PyObject_HEAD_EXTRA
Py_ssize_t ob_refcnt;
struct _typeobject *ob_type;
} PyObject;

除去宏 _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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// worker.cpp
#include <pybind11/pybind11.h>
#include <thread>
#include <iostream>
#include <chrono>

namespace py = pybind11;

class Worker {
public:
Worker(std::string name) : name_(std::move(name)) {}

~Worker() {
std::cout << "[C++] Worker destroyed: " << name_ << std::endl;
}

void start_in_background() {
std::thread([this]() {
std::this_thread::sleep_for(std::chrono::seconds(2));
std::cout << "[C++] Worker " << name_ << " done\n";
}).detach();
}

private:
std::string name_;
};


PYBIND11_MODULE(worker, m) {
py::class_<Worker>(m, "Worker")
.def(py::init<std::string>())
.def("start_in_background", &Worker::start_in_background);
}

我们编写一个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
2
3
[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
2
3
4
5
6
7
8
9
# cMakeLists.txt
cmake_minimum_required(VERSION 3.12)
project(worker)

set(CMAKE_CXX_STANDARD 14)

find_package(pybind11 REQUIRED)

pybind11_add_module(worker worker.cpp)

执行:

1
2
cmake . -G Ninja -B build
ninja -C build

将 ninja 构建的模块放到和 run.py 相同的目录中,就可以调用它了:

1
2
3
4
5
6
7
8
9
10
11
12
13
# run.py
import worker
import time

def run():
worker.Worker('danger').start_in_background()
# w = worker.Worker('safe')
# w.start_in_background()
time.sleep(3)
print('[Python] Python 进程结束')

if __name__ == '__main__':
run()

当运行 safe 部分的时候,得到的结果是:

1
2
3
[C++] Worker safe done
[Python] Python 进程结束
[C++] Worker destroyed: safe

当运行 danger 部分的时候,得到的结果是:

1
2
[C++] Worker destroyed: danger
[C++] Worker [Python] Python 进程结束

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 函数,创建 threadworker。启动线程后,worker.run() 延迟 2s 输出。但由于 threadworker 是局部变量,在函数返回后就会被 Python 垃圾回收(GC)。Qt 后台线程仍然试图运行已经销毁的 Worker,程序立即崩溃。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
from PyQt5.QtWidgets import QApplication, QPushButton, QWidget, QVBoxLayout
from PyQt5.QtCore import QObject, QThread
import sys
import time

class Worker(QObject):
def run(self):
time.sleep(2)
print("[Worker] Finished")

class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("PyQt 崩溃演示")
self.setGeometry(100, 100, 300, 150)

self.layout = QVBoxLayout()
self.button = QPushButton("启动线程(可能崩)")
self.layout.addWidget(self.button)
self.setLayout(self.layout)

self.button.clicked.connect(self.start_and_lose_reference)

def start_and_lose_reference(self):
print("[MainWindow] 创建 thread 和 worker")
worker_thread = QThread()
worker_instance = Worker()
worker_instance.moveToThread(worker_thread)

worker_thread.started.connect(worker_instance.run)
worker_thread.start()

# ❌ 没有保存 thread 和 worker 的引用
print("[MainWindow] 函数结束,worker 即将被销毁")

app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec_())

点击后,得到输出:

1
2
3
[MainWindow] 创建 thread 和 worker
[MainWindow] 函数结束,worker 即将被销毁
QThread: Destroyed while thread is still running

此时,只需要在 Python 中保存引用,程序就可以正常运行了,因为此时 Python 不会将这部分内存垃圾回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def start_and_lose_reference(self):
# 🧹 先清理旧线程(如果还活着)
if hasattr(self, 'worker_thread') and self.worker_thread.isRunning():
print("[MainWindow] 清理上一次线程")
self.worker_thread.quit()
self.worker_thread.wait()

print("[MainWindow] 创建新的 thread 和 worker")
self.worker_thread = QThread()
self.worker_instance = Worker()
self.worker_instance.moveToThread(self.worker_thread)

self.worker_thread.started.connect(self.worker_instance.run)
self.worker_thread.start()

C++的内存管理

Python 和 C++ 的内存管理对比

Python 靠自动引用计数+垃圾回收机制来自动进行内存管理,当引用计数归零时销毁对象内存。而 C++ 则没有垃圾回收机制,完全依赖于自己。C++ 中有两种对象,栈对象(stack object)和堆对象(heap object)。栈对象是在函数/作用域内直接通过声明创建的普通变量或对象,比如:

1
2
3
4
5
void func() {
int a = 10; // 栈变量
std::string s = "hi"; // 栈对象
MyClass obj = MyClass(); // 栈对象
}

栈对象存在于调用栈上,生命周期随着函数/作用域自动开始和结束,它会自动调用析构函数释放内存,不需要手动操作。因此,函数一定不要返回栈对象的地址,比如 return &obj。因为 obj 是局部栈变量,函数执行完之后就被销毁了,地址变成悬空指针。而直接返回栈对象时,根据编译器的优化策略可能会有两种情况。理论上当没有优化的时候,返回时可能会进行一个拷贝构造或者移动构造过程,来返回一个新的对象,而局部对象在函数返回前一定会被析构。而实际中一般编译器会进行返回值优化 RVO(Return Value Optimization),编译器可能把 func() 中的 obj 优化重定向到 main() 的栈帧中,从而省掉拷贝/移动构造。也就是说在函数作用域内它仍然“存在”,但编译器背地里让这个对象被构造在了别处。

堆对象是通过 new 创建的对象:

1
2
3
MyClass* p = new MyClass(); // p 是裸指针,指向堆对象
// 使用 ptr...
delete ptr; // 如果不写这句,它就将永远不会释放内存(泄露)

堆对象分配在堆内存区域,必须用 delete 手动释放。生命周期不跟随作用域,可跨越函数调用、线程、异步等,适合需要动态延长生命周期的对象。如果忘记了 delete ,这块内存永远不会返回给操作系统,就会发生内存泄露。内存泄露是从程序内部逻辑角度说的,在程序退出时,操作系统本身会清理进程的所有内存,包括所有栈、堆、和全局分配的内存,以及所有打开的文件描述符、线程、资源等。因此即使程序发生了内存泄露,连续运行 100 次程序,系统的内存也不会越来越少。对于小脚本或者一次性程序,内存泄露的确通常没什么大的影响。但是对于一些需要长期运行的程序,比如后台服务、游戏、图形程序、循环调用接口的任务、Web服务等,如果每次 new 一个对象却不 delete,这些对象就会一直存在在内存中。久而久之,内存就会被吃光,导致程序卡顿、崩溃,甚至拖垮整个系统。

1
2
3
4
5
void leak() {
MyClass* ptr = new MyClass(); // ✅ 有指针指向对象
// ❌ 没有 delete ptr;
// 函数结束后,ptr 是局部变量,会消失
}

new 了一块内存,操作系统在堆上分配了内存。ptr 是一个局部变量,存在于栈中。当函数结束,ptr 就销毁了,再也没有变量能访问这块内存。这时,内存还在,但没人能找到了,不能释放也不能用。这就是典型的内存泄漏:对象内存在,引用没了,程序不再能访问。泄露的对象就像漂浮在内存之海上的垃圾一样,没有任何变量指向它,它占着空间,但没法处理它。

Python 和 C++ 中 delete 操作的意义是不一样的,对于 Python 来说,del 变量实际上代表的是删除了这个变量在当前作用域的绑定:

1
2
3
4
5
a = 'Hello, World!'
b = a
del a
print(b) # Hello, World!
print(a) # NameError: name 'a' is not defined

Python 的变量是 “名字” -> ”对象“ 的映射,删除名字后就无法通过这个名字访问对应变量。而这不等于对象被销毁,只有当对象的引用计数归零时才会触发销毁。但在 C++ 中,delete 的作用和 Python 中完全反过来:

1
2
3
int* p = new int(42);
delete p;
std::cout << *p; // 悬空指针访问,未定义行为

delete p 只是释放了 p 指向的堆上的那块内存,但变量 p 这个指针本身还在栈上,并没有消失。因此 delete p 后,我们还是可以从语法上访问 p,但是访问的是已经释放的内存,会导致输出乱码或者崩溃。

智能指针

裸指针是 T* 类型的指针,它直接指向某个内存地址。C++ 没有对裸指针的内存进行自动管理,需要自己手动释放内存。像上面所说的,不正确使用裸指针可能导致内存泄露或者未定义的行为。为了简化操作,避免裸指针常见的错误,C++11 引入了智能指针来自动管理内存。C++ 标准库提供了 std::unique_ptrstd::shared_ptr 两种智能指针。std::unique_ptr 独占所有权,只能有一个指针持有资源。当离开作用域时,std::unique_ptr 会自动释放资源。

1
2
std::unique_ptr<MyClass> p1 = std::make_unique<MyClass>(); // 安全创建
// 作用域结束后,p1 自动释放内存,无需手动 delete

std::shared_ptr 和 Python 的机制比较类似,允许多个指针共享同一个对象,自动管理引用计数。当所有的 shared_ptr 引用消失时,对象才会被销毁:

1
std::shared_ptr<MyClass> p2 = std::make_shared<MyClass>();