32. 内存泄漏常见模式:多线程中的内存泄漏
多线程编程,说白了就是让程序同时干好几件事。听起来很爽对吧?但代价就是——内存管理变得异常复杂。我见过太多项目,单线程跑得稳稳的,一上多线程就开始漏内存,而且漏得神不知鬼不觉。
为什么会这样?因为线程之间共享内存,谁分配、谁释放,边界变得模糊。今天我就带你看看多线程场景下最常见的几种内存泄漏模式,以及怎么治它们。
32.1 线程创建了,但没人等它结束
这是最基础也最容易犯的错误。你创建了一个线程,扔给它一个任务,然后就不管了。线程跑完了,但它的资源没人回收。
std::thread 对象在析构前如果没有调用 join() 或 detach(),程序会直接崩溃。但如果你用了 detach(),线程资源虽然不会导致崩溃,却可能造成隐式泄漏。
我个人习惯是,能用 std::jthread(C++20)就尽量用。它会在析构时自动 join(),省心不少。
// 错误示范:detach 后线程资源泄漏
void bad_example() {
std::thread t([]{
// 做一些工作
std::this_thread::sleep_for(std::chrono::seconds(1));
});
t.detach(); // 线程资源无法回收
} // t 析构,但底层线程句柄泄漏
// 正确做法:使用 jthread
void good_example() {
std::jthread t([]{
std::this_thread::sleep_for(std::chrono::seconds(1));
});
// 析构时自动 join
}
32.2 线程函数中抛出的异常
嗯,这里要注意。线程函数里如果抛了异常,而你没有在内部捕获,整个程序可能会终止。但更隐蔽的是——异常导致资源没释放。
我在项目中遇到过这样一个案例:一个后台线程负责处理网络数据,每次处理前都 new 一块缓冲区。正常情况下处理完就 delete。但有一次网络数据格式异常,中间抛了个 std::runtime_error,结果缓冲区就永远没人管了。
// 有问题的代码
void thread_func() {
char* buffer = new char[1024];
// 处理数据...
if (data_is_bad) {
throw std::runtime_error("bad data"); // 泄漏!
}
delete[] buffer;
}
// 修复方案:RAII 包装
void thread_func() {
std::vector<char> buffer(1024); // RAII,自动释放
// 处理数据...
if (data_is_bad) {
throw std::runtime_error("bad data"); // 安全
}
}
new 和 delete。
32.3 共享数据的生命周期混乱
这是多线程内存泄漏的「重灾区」。两个线程共享一个对象,一个线程在用,另一个线程把它删了。或者,两个线程都以为对方会释放,结果谁都没释放。
说白了,就是所有权不明确。你想想看,一个对象被多个线程访问,到底谁负责它的销毁?
// 典型问题:裸指针共享
struct Data {
int value;
};
void thread_a(Data* d) {
// 使用 d
std::this_thread::sleep_for(std::chrono::seconds(2));
delete d; // 线程 A 释放
}
void thread_b(Data* d) {
// 使用 d
std::this_thread::sleep_for(std::chrono::seconds(1));
delete d; // 线程 B 也释放!double free!
}
解决方案?用 std::shared_ptr。它内部是线程安全的引用计数,最后一个持有者自动释放。
void thread_a(std::shared_ptr<Data> d) {
std::this_thread::sleep_for(std::chrono::seconds(2));
// 使用 d,离开作用域时引用计数减1
}
void thread_b(std::shared_ptr<Data> d) {
std::this_thread::sleep_for(std::chrono::seconds(1));
// 使用 d,离开作用域时引用计数减1
// 最后一个离开的线程会触发 delete
}
void start_threads() {
auto data = std::make_shared<Data>();
std::thread t1(thread_a, data);
std::thread t2(thread_b, data);
t1.join();
t2.join();
}
32.4 线程局部存储(TLS)泄漏
这个比较隐蔽。每个线程有自己的局部存储,但如果线程退出了,而 TLS 里的资源没清理,就会泄漏。而且线程越多,泄漏越严重。
我记得有一次排查一个服务端程序,跑了三天内存就爆了。查来查去,发现是线程池里的线程用了 thread_local 变量,每个线程分配了一块大缓冲区,但线程退出时没释放。虽然线程池会复用线程,但某些情况下线程会被销毁重建,那些缓冲区就永远留在内存里了。
// 有问题的 TLS
thread_local char* tls_buffer = nullptr;
void worker() {
if (!tls_buffer) {
tls_buffer = new char[1024 * 1024]; // 1MB
}
// 使用缓冲区...
// 线程退出时,tls_buffer 泄漏!
}
// 修复方案:用智能指针包装
thread_local std::unique_ptr<char[]> tls_buffer;
void worker() {
if (!tls_buffer) {
tls_buffer = std::make_unique<char[]>(1024 * 1024);
}
// 使用缓冲区...
// 线程退出时,unique_ptr 自动释放
}
32.5 回调与闭包中的隐式泄漏
这个坑我踩过好几次。你把一个 lambda 传给另一个线程,lambda 里捕获了 this 指针或者某个对象的引用。如果那个对象已经销毁了,lambda 还在执行,轻则访问野指针,重则内存泄漏。
更麻烦的是循环引用。两个对象互相持有 shared_ptr,谁也释放不了谁。
// 循环引用导致泄漏
struct A;
struct B;
struct A {
std::shared_ptr<B> b_ptr;
~A() { std::cout << "A destroyed\n"; }
};
struct B {
std::shared_ptr<A> a_ptr;
~B() { std::cout << "B destroyed\n"; }
};
void cycle_leak() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->b_ptr = b;
b->a_ptr = a;
// 离开作用域,a 和 b 都不会被销毁!
}
// 修复:其中一个用 weak_ptr
struct B {
std::weak_ptr<A> a_ptr; // 弱引用,不增加计数
~B() { std::cout << "B destroyed\n"; }
};
unique_ptr 表示独占,用 shared_ptr 表示共享,用 weak_ptr 打破循环。别用裸指针在线程间传递所有权。
32.6 知识体系总览
下面这张图总结了多线程内存泄漏的常见模式,以及对应的解决方案。你可以把它当作一个检查清单。
32.7 实战建议
说了这么多,总结几条我自己的经验:
- 能用
std::jthread就别用std::thread。C++20 的这个特性就是用来解决线程生命周期问题的。 - 线程函数里别裸
new。所有动态分配都用智能指针或容器管理。 - 共享数据用
shared_ptr,但要小心循环引用。发现两个对象互相持有,立刻用weak_ptr打断。 - 定期跑内存检测工具。我习惯每次提交代码前用 AddressSanitizer 跑一遍,能抓到 90% 的泄漏。
多线程内存泄漏,说白了就是「谁负责释放」这个问题没搞清楚。只要把所有权关系理清楚,用 RAII 和智能指针把责任绑定到生命周期上,大部分问题都能避免。别怕,多踩几次坑,你就知道该怎么写了。