c++_std::thread多线程

std::thread介绍

std::thread 是创建和管理线程的核心类。它极大地简化了多线程编程,使得开发者无需再依赖特定操作系统的 API(如 POSIX 的 pthread 或 Windows 的 CreateThread)即可实现跨平台的并发编程。


创建与启动线程

准备

使用 std::thread 需要包含<thread>头文件。

#include <thread>

启动线程的核心机制

要启动一个线程,只需创建一个 std::thread 对象,并将可调用对象(Callable Object)传递给它的构造函数。一旦对象被构造,新线程就会立即在后台开始执行,无需显式调用类似 start() 的方法。

可调用对象通常有以下四种形式:

1. 普通函数指针
将普通函数的名称作为参数传入,线程启动后会执行该函数。

void task() {
    // 线程执行的代码
}
std::thread t(task); // 启动线程执行 task

2. Lambda 表达式
最推荐的现代 C++ 写法,语法简洁,且可以直接捕获外部变量。

std::thread t([]() {
    // 线程执行的代码
});

3. 函数对象(仿函数)
通过重载类的 operator() 运算符,将类的实例作为可调用对象传入。适用于需要携带状态的线程任务。

class Task {
public:
    void operator()() {
        // 线程执行的代码
    }
};
Task myTask;
std::thread t(myTask);

4. 类的成员函数
启动非静态成员函数时,必须额外传递该类的对象指针(或引用)作为第二个参数;静态成员函数则与普通函数用法相同。

class Worker {
public:
    void doWork() { /* 非静态成员函数 */ }
    static void staticWork() { /* 静态成员函数 */ }
};

Worker w;
std::thread t1(&Worker::doWork, &w);       // 传入对象指针
std::thread t2(&Worker::staticWork);       // 静态成员函数直接传入

向线程传递参数

传递方式

创建线程时,可以在可调用对象之后继续传递参数,这些参数会被自动转发给线程函数。

  • 默认按值传递(拷贝):为了保证线程安全,std::thread 默认会将参数拷贝到线程内部的私有栈中。
  • 传递引用:如果需要在线程中修改外部变量,必须使用 std::ref()std::cref() 进行包装,否则编译器会将其隐式拷贝,导致修改无法反映到外部变量。
  • 转移所有权:如果参数是不可拷贝的资源(如 std::unique_ptr),可以使用 std::move() 将资源的所有权移交给线程。

案例

#include <thread>
#include <iostream>
#include <memory>
using namespace std;
// 线程执行的函数:接收一个引用和一个智能指针
void process(int& val, std::unique_ptr<int> ptr) {
    std::cout << "【子线程】进入 process 函数" << std::endl;

    // 1. 通过引用修改外部的变量
    val++;
    std::cout << "【子线程】val 修改后的值为: " << val << std::endl;

    // 2. 使用转移过来的智能指针
    std::cout << "【子线程】ptr 指向的值为: " << *ptr << std::endl;

    std::cout << "【子线程】process 函数执行完毕" << std::endl;
}

int main() {
    int x = 10;
    auto ptr = std::make_unique<int>(100);

    std::cout << "【主线程】启动线程前,x 的值为: " << x << std::endl;

    // 启动线程:
    // std::ref(x) 告诉编译器:把 x 的引用传给子线程,而不是拷贝一份
    // std::move(ptr) 告诉编译器:把 ptr 的所有权转移给子线程(因为 unique_ptr 不能拷贝)
    std::thread t(process, std::ref(x), std::move(ptr));

    // 主线程等待子线程执行完毕
    t.join();

    // 验证子线程的修改是否生效
    std::cout << "【主线程】子线程结束后,x 的值变为: " << x << std::endl;

    return 0;
}

c++_std::thread多线程

案例解析

为什么用 std::ref(x)?
如果你直接写std::thread t(process, x, ...),std::thread会默认把 x 拷贝一份传给子线程。子线程里修改的是拷贝出来的副本,主线程里的 x 依然是 10。使用 std::ref(x) 才能真正做到“跨线程共享同一个变量”。
为什么用 std::move(ptr)?
std::unique_ptr 的设计原则是“独占所有权”,它禁止被拷贝。如果不加 std::move,编译器会直接报错。加上 std::move 后,主线程放弃了对这块内存的控制权,将其“移交”给了子线程。


std::thread生命周期管理

线程一旦启动,就必须在 std::thread 对象销毁前明确其生命周期,否则程序会直接调用 std::terminate() 崩溃。必须选择以下两种方式之一:

  • join()(阻塞等待):主线程在此处阻塞,直到子线程执行完毕。适用于需要等待子线程结果的场景。
  • detach()(分离线程):将子线程与主线程解绑,使其成为后台守护线程(Daemon Thread)。主线程不再关心它的执行状态,线程结束后由操作系统自动回收资源。
std::thread t(task);
t.join();   // 正确:等待线程结束
// 或者
t.detach(); // 正确:分离线程,后台运行

绝对禁止在对象析构前既不调用join() 也不调用 detach(),或者对同一个线程重复调用这两个方法。

使用案例

#include <thread>
#include <iostream>
#include <memory>
using namespace std;

// 定义第一个线程执行的函数
void ThreadFUnc1() {
    cout << "ThreadFunc1()---A\n" << endl;
    // 让当前线程休眠 2 秒,模拟耗时操作
    this_thread::sleep_for(chrono::seconds(2));
    cout << "ThreadFunc1()---B" << endl;
}

// 定义第二个线程执行的函数,接收两个参数
void ThreadFUnc2(int args, string sp) {
    cout << "ThreadFunc2()---A\n" << endl;
    // 让当前线程休眠 7 秒,模拟更长的耗时操作
    this_thread::sleep_for(chrono::seconds(7));
    cout << "ThreadFunc2()---B" << endl;
}

int main()
{
    // 创建并启动线程1,传入函数 ThreadFUnc1
    thread ths1(ThreadFUnc1);
    // 创建并启动线程2,传入函数 ThreadFUnc2 及其参数 10 和 "LS"
    thread ths2(ThreadFUnc2, 10, "LS");
    
    // 【关键点1】主线程在此处阻塞等待,直到 ths1 执行完毕(约等待2秒)
    ths1.join();
    cout << "join" << endl;
    
    // 【关键点2】将 ths2 与主线程分离。分离后,ths2 在后台独立运行,
    // 主线程不再关心它的执行状态,也不会等待它。
    ths2.detach();
    cout << "detach" << endl;
    
    // 【核心原因】主线程执行到这里后,main 函数结束,整个程序进程随之销毁。
    // 此时 ths2 内部的 7 秒休眠可能还没结束,更不用说执行后面的打印了。
    // 操作系统会强制回收所有资源,导致 ths2 被强行终止,所以 "---B" 无法打印。
    return 0;
}

c++_std::thread多线程


std::thread的数据竞争

在 C++ 多线程编程中,数据竞争(Data Race)是最核心、也是最危险的并发问题。
根据 C++ 标准定义:当两个或多个线程同时访问同一个内存位置,且其中至少有一个是写操作,同时这些访问又没有通过任何同步机制(如互斥锁或原子操作)进行排序时,就会发生数据竞争。

核心警告:一旦发生数据竞争,程序的行为是未定义的(Undefined Behavior)。这意味着程序可能会崩溃、得出错误的计算结果,甚至在某些测试中看起来正常,但在生产环境中引发灾难性故障。

典型的数据竞争示例

最经典的数据竞争场景:多个线程同时对全局变量 counter 进行自增操作。

#include <iostream>
#include <thread>

int counter = 0; // 共享变量

void increment() {
    for (int i = 0; i < 100000; ++i) {
        counter++;  //  危险:非原子操作,存在数据竞争
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();

    // 期望结果是 200000,但实际运行结果可能小于 200000
    std::cout << "计数器值: " << counter << std::endl;
    return 0;
}

c++_std::thread多线程

解决数据竞争方法

要消除数据竞争,必须保证对共享内存的访问是安全的。C++ 提供了以下两种主要解决方案:

方案 A:使用互斥锁(std::mutex)
互斥锁是最常用的同步工具。通过 std::lock_guard(RAII 风格),可以确保同一时刻只有一个线程能修改共享数据。

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0; // 共享变量
std::mutex mtx; // 声明互斥锁

void safeIncrement() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx); //  自动加锁,离开作用域自动解锁
        counter++;
    }
}

int main() {
    std::thread t1(safeIncrement);
    std::thread t2(safeIncrement);
    t1.join();
    t2.join();

    std::cout << "计数器值: " << counter << std::endl;
    return 0;
}

c++_std::thread多线程

方案 B:使用原子操作(std::atomic)
对于简单的计数器或状态标志,使用原子类型是更高效的选择。它能在硬件层面保证读-改-写操作的不可分割性。

#include <iostream>
#include <thread>
#include <mutex>
#include <atomic>

std::atomic<int> atomicCounter(0); // 声明原子变量


void atomicIncrement() {
    for (int i = 0; i < 100000; ++i) {
        atomicCounter++; // 线程安全的原子操作
    }
}

int main() {
    std::thread t1(atomicIncrement);
    std::thread t2(atomicIncrement);
    t1.join();
    t2.join();

    std::cout << "计数器值: " << atomicCounter << std::endl;
    return 0;
}

c++_std::thread多线程

容器操作的数据竞争陷阱

不仅是基本数据类型,标准库容器在多线程下也有严格的限制:

  • 安全:多线程同时读写容器中不同元素的内容是安全的(例如 vec[0] = 1; 和 vec[1] = 2; 在不同线程中)。
  • 不安全:多线程同时修改同一个元素,或者涉及容器大小的改变(如 push_back、erase),都会引发数据竞争。
  • 特例:std::vector<bool> 是例外,由于它内部使用了位压缩,即使修改不同索引的元素也可能发生数据竞争,应尽量避免在多线程中使用。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐