线程基础:std::thread的创建与启动、线程的join与detach、传递参数给线程函数

好,咱们今天来聊聊C++并发编程里最基础、也最核心的东西——std::thread。说实话,我刚开始接触多线程那会儿,觉得这东西挺玄乎的。后来在项目里踩过几次坑,才慢慢摸清楚它的脾气。今天我就把这些年积累的经验,掰开了揉碎了讲给你听。

一、std::thread的创建与启动

创建一个线程,说白了就是告诉系统:“嘿,帮我开一条新的执行流,跑这个函数。”C++11开始,标准库给了我们std::thread这个利器。用法很简单,你只要把要执行的函数(或者可调用对象)传给线程构造函数就行。

#include <iostream>
#include <thread>

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

int main() {
    std::thread t(hello);  // 创建并启动线程
    t.join();              // 等待线程结束
    return 0;
}

你看,就这么几行代码,一个线程就跑起来了。线程一旦被创建,它会立刻开始执行你给它的函数。这里有个细节要注意:线程对象必须与线程的生命周期关联起来,否则程序会崩溃。嗯,这里说的关联,就是后面要讲的joindetach

我的习惯:我一般会在创建线程后,尽快决定是join还是detach。拖太久容易忘记,导致程序异常终止。我在项目中就见过同事因为忘记join,debug了一整个下午。

二、线程的join与detach

线程创建后,你必须做二选一:要么join,要么detach。没有第三条路。为什么?因为线程对象析构时,如果它仍然“可结合”(joinable),程序会调用std::terminate直接终止。说白了,就是C++强制你负责任地管理线程。

2.1 join:等待线程结束

join()的意思是:当前线程(比如主线程)停下来,等这个子线程跑完,再继续往下走。这是一种同步操作。

std::thread t(worker);
t.join();  // 主线程阻塞在这里,直到worker执行完毕

我在项目中遇到过这样的情况:主线程需要子线程的计算结果才能继续处理。这时候用join就非常合适。但要注意,join会让当前线程阻塞,如果子线程执行时间很长,主线程就卡住了。你想想看,如果UI线程里调了join,界面就会无响应,用户体验极差。

2.2 detach:让线程在后台运行

detach()则是另一种态度:“你走吧,我不等你了。”线程与线程对象分离,线程在后台独立运行,C++运行时库会负责回收它的资源。

std::thread t(background_worker);
t.detach();  // 线程在后台自由运行
// 注意:此时不能再调用t.join(),会抛异常
我曾经踩过的坑:有一次我detach了一个线程,这个线程里访问了主线程的局部变量。结果主线程函数返回了,局部变量被销毁,子线程还在访问那块内存……嗯,典型的悬空引用。所以detach的线程里,千万别碰主线程的局部变量,要么传值,要么用堆上的对象。

2.3 joinable判断

你可以用joinable()检查线程是否可以被join或detach。一个线程只能被join或detach一次,重复调用会抛异常。

if (t.joinable()) {
    t.join();
}

这个检查我建议养成习惯。尤其是在异常处理中,确保线程资源被正确释放。

三、传递参数给线程函数

线程函数往往需要参数。怎么传?直接跟在构造函数的第二个参数后面就行。但这里头有门道,我慢慢说。

3.1 基本传参方式

void print_sum(int a, int b) {
    std::cout << a + b << std::endl;
}

std::thread t(print_sum, 3, 4);
t.join();

参数会以值传递的方式拷贝到线程内部。这很重要,因为线程和主线程是并发的,你不能保证主线程的变量在线程执行期间还活着。

3.2 传递引用参数

如果你想让线程修改外部变量,或者传递大对象避免拷贝,就需要用引用。但直接传引用会编译失败,因为std::thread会忽略引用,仍然拷贝参数。解决办法是用std::ref包装。

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

int value = 0;
std::thread t(modify, std::ref(value));
t.join();
// value 现在是 42
避坑指南:我曾经在项目里直接传了引用,结果编译报错,我还纳闷了半天。后来才意识到std::thread默认是值传递。用std::ref显式告诉编译器:“我要传引用!”另外,要确保被引用的对象在线程执行期间一直有效,否则就是悬空引用。

3.3 传递成员函数指针

如果你想让线程执行某个类的成员函数,需要传递对象指针(或引用)和成员函数指针。

class Worker {
public:
    void run(int task_id) {
        std::cout << "Task " << task_id << std::endl;
    }
};

Worker w;
std::thread t(&Worker::run, &w, 1);
t.join();

这里&Worker::run是成员函数指针,&w是对象地址。线程内部会调用w.run(1)

3.4 传递unique_ptr等移动语义对象

有些对象只能移动,不能拷贝,比如std::unique_ptr。这时候需要用std::move显式转移所有权。

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

auto ptr = std::make_unique<int>(100);
std::thread t(take_ownership, std::move(ptr));
t.join();
// 此时ptr已经为空

你想想看,如果不用std::movestd::thread会尝试拷贝unique_ptr,但拷贝构造函数是被删除的,编译就会报错。所以移动语义在这里是必须的。

四、知识体系总览

下面这张图,我把本章的核心知识点串起来了。你可以对照着看,心里有个谱。

std::thread 创建与启动 join(等待) detach(分离) 值传递 std::ref 引用传递 成员函数指针 std::move 移动语义 注意事项 • 必须 join 或 detach,否则析构时 terminate • detach 后线程不能访问主线程局部变量 • 使用 joinable() 检查后再操作

五、总结与建议

好了,关于std::thread的基础知识,咱们就聊到这儿。我最后给你几个实用建议:

  • 优先用join:除非你非常清楚子线程的生命周期,否则joindetach安全得多。我个人的项目里,90%的情况都用join
  • 传参时注意生命周期:无论是传值、传引用还是传指针,都要确保参数在线程执行期间有效。这是并发编程里最常见的bug来源。
  • RAII管理线程:可以写一个包装类,在析构函数里自动joindetach,避免忘记。C++20的std::jthread就是干这个的,不过那是后话了。

线程基础就这些内容。你把这些搞明白了,后面学锁、条件变量、异步任务什么的,就会轻松很多。记住一句话:并发编程,细节是魔鬼。每个API背后都有它的设计哲学和陷阱,多写多练,慢慢就熟了。


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