32. 内存泄漏常见模式:多线程中的内存泄漏

多线程编程,说白了就是让程序同时干好几件事。听起来很爽对吧?但代价就是——内存管理变得异常复杂。我见过太多项目,单线程跑得稳稳的,一上多线程就开始漏内存,而且漏得神不知鬼不觉。

为什么会这样?因为线程之间共享内存,谁分配、谁释放,边界变得模糊。今天我就带你看看多线程场景下最常见的几种内存泄漏模式,以及怎么治它们。

32.1 线程创建了,但没人等它结束

这是最基础也最容易犯的错误。你创建了一个线程,扔给它一个任务,然后就不管了。线程跑完了,但它的资源没人回收。

注意: 在 C++ 中,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");  // 安全
    }
}
我的建议: 在线程函数里,尽量用 RAII 管理所有资源。智能指针、容器、锁守卫,这些是你的好朋友。别裸 newdelete

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 知识体系总览

下面这张图总结了多线程内存泄漏的常见模式,以及对应的解决方案。你可以把它当作一个检查清单。

多线程内存泄漏常见模式 多线程内存泄漏 线程生命周期管理不当 线程函数异常安全 共享数据生命周期混乱 线程局部存储泄漏 回调与闭包循环引用 解决方案 ✅ 使用 std::jthread 自动管理线程生命周期 ✅ 线程函数内使用 RAII(智能指针、容器、锁守卫) ✅ 用 shared_ptr 管理共享对象,用 weak_ptr 打破循环 ✅ TLS 使用 unique_ptr 或智能指针包装,避免裸指针 ✅ 避免在 lambda 中捕获裸 this 指针 ✅ 使用 std::enable_shared_from_this ✅ 线程池中注意线程销毁时的资源清理 ✅ 定期使用 Valgrind / ASan 检测泄漏

32.7 实战建议

说了这么多,总结几条我自己的经验:

  • 能用 std::jthread 就别用 std::thread。C++20 的这个特性就是用来解决线程生命周期问题的。
  • 线程函数里别裸 new。所有动态分配都用智能指针或容器管理。
  • 共享数据用 shared_ptr,但要小心循环引用。发现两个对象互相持有,立刻用 weak_ptr 打断。
  • 定期跑内存检测工具。我习惯每次提交代码前用 AddressSanitizer 跑一遍,能抓到 90% 的泄漏。
一个小技巧: 如果你在排查一个诡异的内存泄漏,先看看是不是多线程相关的。把线程数改成 1,如果泄漏消失了,那基本就是多线程的问题。我曾经用这个方法定位过一个隐藏了两周的 bug。

多线程内存泄漏,说白了就是「谁负责释放」这个问题没搞清楚。只要把所有权关系理清楚,用 RAII 和智能指针把责任绑定到生命周期上,大部分问题都能避免。别怕,多踩几次坑,你就知道该怎么写了。