2、线程管理基础:创建线程的多种方式、线程的join与detach、线程参数传递、线程ID与句柄

好,咱们正式开始聊多线程。这一章是地基,地基不牢,后面全是坑。

我记得刚入行那会儿,第一次用 std::thread 时,觉得这东西真简单——new 一个线程,传个函数进去,完事。结果呢?程序跑着跑着就崩了,连个像样的错误信息都没有。后来才明白,线程管理远不止「创建」这么简单。你想想看,线程什么时候结束?参数怎么传?要不要等它?这些问题不搞清楚,写出来的代码就是定时炸弹。

这一节,咱们就把这些基础掰开揉碎,一个一个讲透。

2.1 创建线程的多种方式

创建线程,说白了就是告诉系统:「嘿,帮我开一条新的执行流,跑这段代码。」C++11 之后,标准库给了我们 std::thread,用法很灵活。

2.1.1 用函数创建线程

最直接的方式,传一个普通函数进去:

#include <iostream>
#include <thread>

void hello() {
    std::cout << "Hello from thread!" << std::endl;
}

int main() {
    std::thread t(hello);
    t.join();
    return 0;
}

嗯,这里要注意:t.join() 不能少。为什么?后面会讲。

2.1.2 用可调用对象创建线程

除了普通函数,你还可以传一个函数对象(仿函数):

class Task {
public:
    void operator()() const {
        std::cout << "Task running..." << std::endl;
    }
};

int main() {
    Task task;
    std::thread t(task);
    t.join();
    return 0;
}

我个人习惯用 lambda 表达式,代码更紧凑:

std::thread t([]{
    std::cout << "Lambda thread" << std::endl;
});
t.join();

你看,三种方式,本质都一样——告诉线程「入口函数」是什么。选哪种?看场景。如果逻辑简单,lambda 最方便;如果逻辑复杂,拆成函数或类更清晰。

2.2 线程的 join 与 detach

这是新手最容易踩坑的地方。我见过太多人问:「为什么我的程序崩溃了?」答案往往就出在 joindetach 上。

2.2.1 join:等待线程结束

join() 的意思是:主线程在这里等着,直到子线程跑完。说白了就是同步点。

std::thread t(worker);
t.join();  // 主线程阻塞,直到 worker 结束
// 到这里,t 肯定已经结束了

为什么要 join?因为 std::thread 的析构函数会检查线程是否还「可 join」。如果线程还在运行,而你直接销毁了 std::thread 对象,程序会调用 std::terminate(),直接崩掉。

警告: 一个线程只能 join 一次。多次 join 会抛出 std::system_error。调用 join 前,可以用 joinable() 检查一下。

2.2.2 detach:让线程自由

detach() 正好相反——把线程从 std::thread 对象上「分离」出去。分离后,线程在后台独立运行,你再也管不了它了。

std::thread t(worker);
t.detach();  // 线程独立运行,t 不再关联任何线程
// 注意:此时不能再调用 t.join()

我曾经在一个网络服务项目里用过 detach。每个客户端连接来了,开一个线程处理,然后 detach 掉。听起来挺美,对吧?但后来发现一个问题:如果主线程退出了,detach 的线程还在跑,程序会强制结束它们。这就导致一些资源没来得及释放。

经验之谈: detach 适合「即发即忘」的任务,比如日志写入、后台监控。但一定要确保线程不会访问已经销毁的资源。我建议:能用 join 就别用 detach,除非你非常清楚后果。

2.3 线程参数传递

给线程传参数,看起来简单,其实暗藏玄机。

2.3.1 基本传参

std::thread 的构造函数是可变参数模板,你可以在函数名后面直接跟参数:

void print(int a, const std::string& s) {
    std::cout << a << ": " << s << std::endl;
}

int main() {
    std::thread t(print, 42, "hello");
    t.join();
    return 0;
}

参数会被「按值」拷贝到线程内部。为什么?因为线程可能比传参的变量活得久,如果传引用,变量销毁了怎么办?

2.3.2 传递引用

如果你非要传引用,必须用 std::ref 包装一下:

void modify(int& x) {
    x = 100;
}

int main() {
    int val = 0;
    std::thread t(modify, std::ref(val));
    t.join();
    std::cout << val << std::endl;  // 输出 100
    return 0;
}

不用 std::ref 会怎样?编译器会报错,因为 std::thread 默认按值传递,它会把 int& 退化成 int,然后试图拷贝,结果类型不匹配。

核心要点: 线程参数默认按值传递。想传引用?用 std::ref。想传指针?直接传,但要保证指针指向的对象在线程执行期间一直有效。

2.3.3 传递智能指针

传递 std::unique_ptr 时,必须用 std::move,因为它是独占所有权:

void take_ownership(std::unique_ptr<int> p) {
    std::cout << *p << std::endl;
}

int main() {
    auto p = std::make_unique<int>(42);
    std::thread t(take_ownership, std::move(p));
    t.join();
    // 此时 p 已经为空
    return 0;
}

传递 std::shared_ptr 则简单,直接传,内部会拷贝一份:

void share_ptr(std::shared_ptr<int> p) {
    std::cout << *p << std::endl;
}

int main() {
    auto p = std::make_shared<int>(42);
    std::thread t(share_ptr, p);  // 引用计数 +1
    t.join();
    return 0;
}

2.4 线程 ID 与句柄

每个线程都有一个唯一的 ID,可以用来标识、比较、存储。

2.4.1 获取线程 ID

std::this_thread::get_id() 获取当前线程的 ID:

void print_id() {
    std::cout << "Thread ID: " 
              << std::this_thread::get_id() 
              << std::endl;
}

int main() {
    std::thread t(print_id);
    std::cout << "Main thread ID: " 
              << std::this_thread::get_id() 
              << std::endl;
    t.join();
    return 0;
}

输出类似这样:

Main thread ID: 140735233697536
Thread ID: 123145302511616

ID 的类型是 std::thread::id,可以比较、可以输出、可以存到容器里。我在调试日志里经常用它来区分不同线程的输出。

2.4.2 线程句柄

std::thread 对象本身就是一个句柄。它管理着底层操作系统线程的生命周期。你可以通过 native_handle() 获取底层句柄(比如 POSIX 的 pthread_t),但一般不推荐,除非你要做平台相关的操作。

std::thread t(worker);
auto handle = t.native_handle();  // 获取底层句柄
// 可以用 handle 调用 pthread 相关函数
t.join();
提示: 尽量用标准库接口,别碰 native_handle。跨平台代码一旦用了它,移植性就没了。我只有在调试底层问题时才用它。

2.5 知识体系总览

这一节的内容,我用一张图帮你理清脉络:

线程管理基础知识体系 std::thread 创建线程 普通函数 函数对象 Lambda 表达式 生命周期管理 join() - 等待结束 detach() - 分离运行 joinable() - 状态检查 参数传递 按值传递(默认) std::ref 传引用 std::move 传所有权 标识与句柄 get_id() - 线程ID native_handle() - 底层句柄

这张图把四个核心知识点串起来了。创建线程是入口,生命周期管理是核心,参数传递是细节,ID与句柄是辅助。你写代码时,心里要有这张图。

2.6 避坑指南

最后,分享几个我踩过的坑:

  • 忘记 join 或 detachstd::thread 析构时如果还 joinable,直接 terminate。我建议在 RAII 包装类里自动 join,比如 std::jthread(C++20)。
  • 传临时变量的指针:线程还没开始跑,变量已经销毁了。用值传递或 std::shared_ptr 最安全。
  • 多次 join:每个线程只能 join 一次。用 joinable() 检查一下再 join,养成习惯。
  • detach 后访问主线程局部变量:主线程退出了,局部变量就没了。detach 的线程访问它们,未定义行为。

嗯,这一节的内容就这些。线程管理基础,说白了就是搞清楚「什么时候创建、什么时候等待、什么时候分离、怎么传参」。把这些搞定了,后面的并发编程才有底气。


公众号:蓝海资料掘金营,微信deep3321