文章目录
- c++_std::thread多线程
- std::thread介绍
- 创建与启动线程
- 准备
- 启动线程的核心机制
- 可调用对象通常有以下四种形式:
- 向线程传递参数
- 传递方式
- 案例
- 案例解析
- std::thread生命周期管理
- 使用案例
- std::thread的数据竞争
- 典型的数据竞争示例
- 解决数据竞争方法
- 容器操作的数据竞争陷阱
c++_std::thread多线程
std::thread介绍
std::thread是创建和管理线程的核心类。它极大地简化了多线程编程,使得开发者无需再依赖特定操作系统的API(如 POSIX 的 pthread 或 Windows 的 CreateThread)即可实现跨平台的并发编程。
创建与启动线程
准备
使用std::thread需要包含<thread>头文件。
#include<thread>启动线程的核心机制
要启动一个线程,只需创建一个std::thread对象,并将可调用对象(Callable Object)传递给它的构造函数。一旦对象被构造,新线程就会立即在后台开始执行,无需显式调用类似start()的方法。
可调用对象通常有以下四种形式:
1. 普通函数指针
将普通函数的名称作为参数传入,线程启动后会执行该函数。
voidtask(){// 线程执行的代码}std::threadt(task);// 启动线程执行 task2. Lambda 表达式
最推荐的现代 C++ 写法,语法简洁,且可以直接捕获外部变量。
std::threadt([](){// 线程执行的代码});3. 函数对象(仿函数)
通过重载类的operator()运算符,将类的实例作为可调用对象传入。适用于需要携带状态的线程任务。
classTask{public:voidoperator()(){// 线程执行的代码}};Task myTask;std::threadt(myTask);4. 类的成员函数
启动非静态成员函数时,必须额外传递该类的对象指针(或引用)作为第二个参数;静态成员函数则与普通函数用法相同。
classWorker{public:voiddoWork(){/* 非静态成员函数 */}staticvoidstaticWork(){/* 静态成员函数 */}};Worker w;std::threadt1(&Worker::doWork,&w);// 传入对象指针std::threadt2(&Worker::staticWork);// 静态成员函数直接传入向线程传递参数
传递方式
创建线程时,可以在可调用对象之后继续传递参数,这些参数会被自动转发给线程函数。
- 默认按值传递(拷贝):为了保证线程安全,
std::thread默认会将参数拷贝到线程内部的私有栈中。 - 传递引用:如果需要在线程中修改外部变量,必须使用
std::ref()或std::cref()进行包装,否则编译器会将其隐式拷贝,导致修改无法反映到外部变量。 - 转移所有权:如果参数是不可拷贝的资源(如
std::unique_ptr),可以使用std::move()将资源的所有权移交给线程。
案例
#include<thread>#include<iostream>#include<memory>usingnamespacestd;// 线程执行的函数:接收一个引用和一个智能指针voidprocess(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;}intmain(){intx=10;autoptr=std::make_unique<int>(100);std::cout<<"【主线程】启动线程前,x 的值为: "<<x<<std::endl;// 启动线程:// std::ref(x) 告诉编译器:把 x 的引用传给子线程,而不是拷贝一份// std::move(ptr) 告诉编译器:把 ptr 的所有权转移给子线程(因为 unique_ptr 不能拷贝)std::threadt(process,std::ref(x),std::move(ptr));// 主线程等待子线程执行完毕t.join();// 验证子线程的修改是否生效std::cout<<"【主线程】子线程结束后,x 的值变为: "<<x<<std::endl;return0;}案例解析
为什么用 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::threadt(task);t.join();// 正确:等待线程结束// 或者t.detach();// 正确:分离线程,后台运行绝对禁止在对象析构前既不调用join()也不调用detach(),或者对同一个线程重复调用这两个方法。
使用案例
#include<thread>#include<iostream>#include<memory>usingnamespacestd;// 定义第一个线程执行的函数voidThreadFUnc1(){cout<<"ThreadFunc1()---A\n"<<endl;// 让当前线程休眠 2 秒,模拟耗时操作this_thread::sleep_for(chrono::seconds(2));cout<<"ThreadFunc1()---B"<<endl;}// 定义第二个线程执行的函数,接收两个参数voidThreadFUnc2(intargs,string sp){cout<<"ThreadFunc2()---A\n"<<endl;// 让当前线程休眠 7 秒,模拟更长的耗时操作this_thread::sleep_for(chrono::seconds(7));cout<<"ThreadFunc2()---B"<<endl;}intmain(){// 创建并启动线程1,传入函数 ThreadFUnc1threadths1(ThreadFUnc1);// 创建并启动线程2,传入函数 ThreadFUnc2 及其参数 10 和 "LS"threadths2(ThreadFUnc2,10,"LS");// 【关键点1】主线程在此处阻塞等待,直到 ths1 执行完毕(约等待2秒)ths1.join();cout<<"join"<<endl;// 【关键点2】将 ths2 与主线程分离。分离后,ths2 在后台独立运行,// 主线程不再关心它的执行状态,也不会等待它。ths2.detach();cout<<"detach"<<endl;// 【核心原因】主线程执行到这里后,main 函数结束,整个程序进程随之销毁。// 此时 ths2 内部的 7 秒休眠可能还没结束,更不用说执行后面的打印了。// 操作系统会强制回收所有资源,导致 ths2 被强行终止,所以 "---B" 无法打印。return0;}std::thread的数据竞争
在 C++ 多线程编程中,数据竞争(Data Race)是最核心、也是最危险的并发问题。
根据 C++ 标准定义:当两个或多个线程同时访问同一个内存位置,且其中至少有一个是写操作,同时这些访问又没有通过任何同步机制(如互斥锁或原子操作)进行排序时,就会发生数据竞争。
核心警告:一旦发生数据竞争,程序的行为是未定义的(Undefined Behavior)。这意味着程序可能会崩溃、得出错误的计算结果,甚至在某些测试中看起来正常,但在生产环境中引发灾难性故障。
典型的数据竞争示例
最经典的数据竞争场景:多个线程同时对全局变量 counter 进行自增操作。
#include<iostream>#include<thread>intcounter=0;// 共享变量voidincrement(){for(inti=0;i<100000;++i){counter++;// 危险:非原子操作,存在数据竞争}}intmain(){std::threadt1(increment);std::threadt2(increment);t1.join();t2.join();// 期望结果是 200000,但实际运行结果可能小于 200000std::cout<<"计数器值: "<<counter<<std::endl;return0;}解决数据竞争方法
要消除数据竞争,必须保证对共享内存的访问是安全的。C++ 提供了以下两种主要解决方案:
方案 A:使用互斥锁(std::mutex)
互斥锁是最常用的同步工具。通过std::lock_guard(RAII 风格),可以确保同一时刻只有一个线程能修改共享数据。
#include<iostream>#include<thread>#include<mutex>intcounter=0;// 共享变量std::mutex mtx;// 声明互斥锁voidsafeIncrement(){for(inti=0;i<100000;++i){std::lock_guard<std::mutex>lock(mtx);// 自动加锁,离开作用域自动解锁counter++;}}intmain(){std::threadt1(safeIncrement);std::threadt2(safeIncrement);t1.join();t2.join();std::cout<<"计数器值: "<<counter<<std::endl;return0;}方案 B:使用原子操作(std::atomic)
对于简单的计数器或状态标志,使用原子类型是更高效的选择。它能在硬件层面保证读-改-写操作的不可分割性。
#include<iostream>#include<thread>#include<mutex>#include<atomic>std::atomic<int>atomicCounter(0);// 声明原子变量voidatomicIncrement(){for(inti=0;i<100000;++i){atomicCounter++;// 线程安全的原子操作}}intmain(){std::threadt1(atomicIncrement);std::threadt2(atomicIncrement);t1.join();t2.join();std::cout<<"计数器值: "<<atomicCounter<<std::endl;return0;}容器操作的数据竞争陷阱
不仅是基本数据类型,标准库容器在多线程下也有严格的限制:
- 安全:多线程同时读写容器中不同元素的内容是安全的(例如 vec[0] = 1; 和 vec[1] = 2; 在不同线程中)。
- 不安全:多线程同时修改同一个元素,或者涉及容器大小的改变(如 push_back、erase),都会引发数据竞争。
- 特例:
std::vector<bool>是例外,由于它内部使用了位压缩,即使修改不同索引的元素也可能发生数据竞争,应尽量避免在多线程中使用。