c++_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. 普通函数指针
将普通函数的名称作为参数传入,线程启动后会执行该函数。
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;
}

案例解析
为什么用 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;
}

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++ 提供了以下两种主要解决方案:
方案 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;
}

方案 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;
}

容器操作的数据竞争陷阱
不仅是基本数据类型,标准库容器在多线程下也有严格的限制:
- 安全:多线程同时读写容器中不同元素的内容是安全的(例如 vec[0] = 1; 和 vec[1] = 2; 在不同线程中)。
- 不安全:多线程同时修改同一个元素,或者涉及容器大小的改变(如 push_back、erase),都会引发数据竞争。
- 特例:
std::vector<bool>是例外,由于它内部使用了位压缩,即使修改不同索引的元素也可能发生数据竞争,应尽量避免在多线程中使用。
更多推荐


所有评论(0)