c++多线程初识
C++ 多线程编程入门指南
本文是讲解多线程的一些概念和知识,我下一篇文章将会讲解开源的多线程库tbb,tbb就不需要我们去了解多线程的概念只需要会调函数即可,我们能更关注业务代码的实现而不需要管多线程的开销和调用。
目录
- 初识线程 (Thread)
- 1.1 线程究竟是什么?
- 1.2 为何需要线程?
- 1.3 进程与线程的核心区别
- 1.4 C++ 线程与操作系统线程
- 在 C++ 中创建线程
- 2.1 使用函数指针
- 2.2 使用 Lambda 表达式 (C++11 及以后)
- 2.3 使用函数对象 (Functor)
- C++
std::thread
类及其常用操作- 3.1
std::thread
的基本操作 (join
,detach
) - 3.2 获取线程 ID
- 3.3 让线程“小憩”片刻 (
sleep_for
)
- 3.1
- 线程的生命周期与状态 (概念)
- 4.1 线程的主要生命阶段
- 4.2 理解状态转换的意义
- 4.3 观察线程的生命周期行为
- 多线程的挑战:线程安全 (重点)
- 5.1 什么是线程安全?
- 5.2 线程不安全的根源:竞态条件
- 5.3 保证线程安全的关键概念
- 5.3.1 原子性 (Atomicity)
- 5.3.2 互斥访问 (Mutual Exclusion) - 使用 Mutex
- 5.3.3 可见性与顺序性 (简述)
- 总结与后续学习
1. 初识线程 (Thread)
在我们深入代码之前,先来建立对线程的基本理解。
1.1 线程究竟是什么?
想象一下你在做饭,可能同时在切菜、烧水、看食谱。每一项独立的任务流,都可以看作一个“执行流”。在计算机程序中,线程 (Thread) 就是这样一个独立的执行流。
一个程序(称为进程)可以包含一个或多个线程。每个线程都可以独立地、按顺序地执行分配给它的代码指令。当一个程序有多个线程时,从宏观上看,这些线程似乎在“同时”运行,这就是所谓的并发 (Concurrency)。如果你的计算机有多个 CPU 核心,那么这些线程甚至可能真正在物理上同时运行,这称为并行 (Parallelism)。
我们通常运行的 main
函数本身就运行在一个主线程上。
1.2 为何需要线程?
在现代软件开发中,多线程几乎是必备技能,主要原因有:
-
提升性能:
- 充分利用多核 CPU: 现代 CPU 普遍是多核心的。单线程程序同一时刻只能利用一个核心,而多线程程序可以将计算任务分解到多个线程,让它们在不同核心上并行执行,显著提高处理速度。
- 应对计算密集型任务: 对于需要大量计算的任务(如视频编码、科学计算),多线程可以有效缩短执行时间。
-
改善响应性:
- 避免阻塞用户界面 (UI): 在图形界面程序中,如果某个耗时操作(如网络请求、文件读写)放在主线程(UI 线程)执行,会导致界面卡死,用户体验极差。将这些耗时操作放到单独的线程中执行,可以让 UI 线程保持流畅,及时响应用户操作。
- 处理 I/O 等待: 当一个线程等待网络数据或磁盘读写完成时(I/O 操作),CPU 其实是空闲的。使用多线程,可以在一个线程等待 I/O 时,让其他线程继续执行计算任务,提高 CPU 利用率。
-
更轻量的并发: 相较于为每个任务创建一个独立的进程,线程是更轻量级的选择。
- 创建与销毁开销小: 创建和销毁线程通常比进程快得多。
- 上下文切换快: 操作系统在线程间切换执行权(调度)比在进程间切换更快。
- 资源共享方便: 同一进程下的线程共享相同的内存地址空间,数据共享和通信更直接(但也带来了线程安全问题,后面会讲)。
虽然线程池和协程等技术进一步优化了并发性能,但理解线程是掌握这些高级技术的基础。
1.3 进程与线程的核心区别
特性 | 进程 (Process) | 线程 (Thread) |
---|---|---|
资源单位 | 操作系统分配资源的基本单位 | 操作系统调度执行的基本单位 |
包含关系 | 一个进程可以包含一个或多个线程 | 线程必须存在于某个进程内 |
内存空间 | 进程间独立,不共享内存 | 同一进程内的线程共享相同的内存地址空间 |
资源开销 | 较大(创建、销毁、切换) | 较小(创建、销毁、切换) |
健壮性 | 一个进程崩溃通常不影响其他进程 | 一个线程崩溃可能导致整个进程崩溃 |
通信 | 复杂(需要 IPC 机制,如管道、套接字等) | 简单(直接读写共享内存,但需注意同步) |
简单来说,进程像是独立的工厂,拥有自己的资源;线程则像是工厂里的工人,共享工厂的资源,协同完成任务。
1.4 C++ 线程与操作系统线程
线程是操作系统层面提供的核心概念。不同的操作系统(如 Windows, Linux, macOS)都提供了各自的线程 API(如 Windows API 的 CreateThread
, Linux 的 POSIX Threads (pthreads) 库的 pthread_create
)。
C++ 标准库从 C++11 开始,提供了跨平台的线程支持,主要通过 <thread>
头文件中的 std::thread
类。std::thread
类是对底层操作系统线程 API 的一层抽象和封装。使用 std::thread
,你的 C++ 代码可以在不同平台上编译运行,而无需关心底层操作系统的具体实现细节,大大提高了代码的可移植性。
2. 在 C++ 中创建线程
C++11 引入的 std::thread
让创建和管理线程变得非常方便。创建一个新线程,本质上就是告诉 std::thread
对象要去执行哪个函数(或可调用对象)。
启动线程后,新线程会开始执行指定的函数,而原来的线程(比如 main
线程)会继续执行 std::thread
构造函数之后的代码。
重要: 创建线程后,通常需要决定如何处理它:
join()
: 等待该线程执行结束。主线程会在此阻塞,直到被join
的线程完成任务。这是确保线程资源被正确回收的常用方式。detach()
: 将线程与std::thread
对象分离,让它在后台独立运行。分离后,主线程无法再与之交互(如join
),线程结束后资源由操作系统回收。需要谨慎使用,确保分离的线程不会访问在其生命周期结束后可能失效的资源(比如栈上变量的引用或指针)。
在 std::thread
对象销毁(析构)之前,必须调用 join()
或 detach()
中的一个,否则程序会异常终止 (std::terminate
)!
以下是几种常见的创建线程的方式:
2.1 使用函数指针
最基本的方式是传递一个普通函数的指针给 std::thread
。
#include <iostream>
#include <thread>
#include <chrono> // For std::this_thread::sleep_for// 线程要执行的函数
void thread_task(int id, int delay_ms) {std::cout << "线程 " << id << " 开始执行..." << std::endl;std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); // 模拟工作std::cout << "线程 " << id << " 执行完毕。" << std::endl;
}int main() {std::cout << "主线程开始。" << std::endl;// 创建新线程 t1,执行 thread_task 函数,并传递参数 1 和 1000std::thread t1(thread_task, 1, 1000);// 创建新线程 t2,执行 thread_task 函数,并传递参数 2 和 500std::thread t2(thread_task, 2, 500);std::cout << "主线程创建完线程,等待它们结束..." << std::endl;// 等待线程 t1 结束t1.join();std::cout << "线程 1 已 join。" << std::endl;// 等待线程 t2 结束t2.join();std::cout << "线程 2 已 join。" << std::endl;std::cout << "主线程结束。" << std::endl;return 0;
}
测试用例: 编译并运行上述代码。你会看到主线程和两个子线程的输出交错出现,并且线程 2 会比线程 1 先结束(因为它睡眠时间短)。主线程会等待两个子线程都结束后才最终退出。
2.2 使用 Lambda 表达式 (C++11 及以后)
Lambda 表达式提供了一种简洁的内联方式来定义线程要执行的代码块。
#include <iostream>
#include <thread>
#include <vector>
#include <string>int main() {std::vector<std::string> messages = {"消息 A", "消息 B", "消息 C"};std::vector<std::thread> threads; // 用于存储线程对象std::cout << "主线程:使用 Lambda 创建线程..." << std::endl;int thread_id_counter = 0;for (const auto& msg : messages) {// 创建线程,执行 Lambda 表达式threads.emplace_back([thread_id_counter, msg]() { // 捕获 id 和 msgstd::cout << "线程 " << thread_id_counter << ": 正在处理 '" << msg << "'" << std::endl;// 模拟一些工作...std::this_thread::sleep_for(std::chrono::seconds(1));std::cout << "线程 " << thread_id_counter << ": 处理完毕 '" << msg << "'" << std::endl;});thread_id_counter++;}std::cout << "主线程:等待所有 Lambda 线程结束..." << std::endl;for (auto& t : threads) {t.join(); // 等待每个线程结束}std::cout << "主线程:所有 Lambda 线程已结束。" << std::endl;return 0;
}
注意: Lambda 表达式的捕获列表 ([]
) 很重要,它决定了哪些外部变量可以在 Lambda 内部访问。
2.3 使用函数对象 (Functor)
函数对象是重载了 operator()
的类的对象。它可以像函数一样被调用,也可以包含状态。
#include <iostream>
#include <thread>// 定义一个函数对象 (Functor)
class Worker {
private:int id;
public:// 构造函数,初始化 IDWorker(int worker_id) : id(worker_id) {std::cout << "Worker " << id << " 已创建。" << std::endl;}// 重载 operator(),这是线程实际执行的代码void operator()() const { // 使用 const 确保不修改对象状态 (如果需要修改则去掉 const)std::cout << "Worker " << id << " 开始工作..." << std::endl;// 模拟工作std::this_thread::sleep_for(std::chrono::milliseconds(800));std::cout << "Worker " << id << " 完成工作。" << std::endl;}
};int main() {std::cout << "主线程开始。" << std::endl;Worker worker1(101); // 创建 Worker 对象Worker worker2(102);// 创建线程 t1,执行 worker1 对象(调用其 operator())std::thread t1(worker1);// 创建线程 t2,也可以直接传入临时对象std::thread t2(Worker(103));// 或者使用 std::ref 传递引用 (如果 Functor 需要被修改或避免拷贝)// std::thread t3(std::ref(worker2));std::cout << "主线程等待 Workers 结束..." << std::endl;t1.join();t2.join();// if (t3.joinable()) t3.join();std::cout << "主线程结束。" << std::endl;return 0;
}
3. C++ std::thread
类及其常用操作
std::thread
对象是管理线程的主要工具。
3.1 std::thread
的基本操作 (join
, detach
)
我们在创建线程时已经接触了 join()
和 detach()
,这里再强调一下:
-
t.join()
: 调用join()
的线程(例如主线程)会阻塞,直到线程t
执行完成。一个线程只能被join
一次。join
可以保证:- 子线程的计算结果在
join
返回后是可用的。 - 子线程使用的资源(在其作用域内)在其结束后、主线程继续前被清理。
std::thread
对象可以被安全销毁。
- 子线程的计算结果在
-
t.detach()
: 将线程t
与std::thread
对象t
分离。t
会在后台独立运行,主线程不再能控制或等待它。std::thread
对象t
此时不再代表任何线程,可以被安全销毁。分离的线程结束后,其资源由操作系统负责回收。使用detach
需要特别小心,确保分离后的线程不会访问在其生命周期结束后可能失效的数据(例如,指向主线程栈上变量的指针或引用)。 -
t.joinable()
: 返回一个布尔值,判断std::thread
对象t
是否代表一个可汇合(joinable)的线程。如果线程已经被join
或detach
,或者t
是默认构造的(不代表任何线程),则返回false
。通常在调用join
或detach
前检查此状态。
3.2 获取线程 ID
每个正在运行的线程都有一个唯一的标识符 (ID)。
t.get_id()
: 获取std::thread
对象t
所代表的线程的 ID。std::this_thread::get_id()
: 获取当前正在执行这段代码的线程的 ID。
线程 ID (std::thread::id
) 可以用于日志记录、调试或作为某些数据结构的键(例如 std::map
)。
#include <iostream>
#include <thread>
#include <sstream> // 用于构造字符串void print_thread_id(const std::string& message) {std::stringstream ss;ss << message << " - 当前线程 ID: " << std::this_thread::get_id() << std::endl;std::cout << ss.str(); // 使用 stringstream 保证输出原子性,防止多线程交错
}int main() {std::thread t1(print_thread_id, "来自线程 t1");std::cout << "主线程 ID: " << std::this_thread::get_id() << std::endl;std::cout << "线程 t1 的 ID: " << t1.get_id() << std::endl;print_thread_id("来自主线程");if (t1.joinable()) {t1.join();}return 0;
}
3.3 让线程“小憩”片刻 (sleep_for
)
有时需要让当前线程暂停执行一段时间,例如模拟耗时操作、等待某个条件满足(虽然通常有更好的同步机制)、或者避免 CPU 占用过高。
std::this_thread::sleep_for(duration)
: 让当前线程阻塞指定的时间段duration
。duration
使用<chrono>
库中的时间单位指定。
#include <iostream>
#include <thread>
#include <chrono> // 包含时间相关的库int main() {std::cout << "主线程:准备睡 2 秒..." << std::endl;auto start_time = std::chrono::high_resolution_clock::now();// 让当前线程 (主线程) 睡眠 2 秒std::this_thread::sleep_for(std::chrono::seconds(2));// 也可以用其他单位:milliseconds, microseconds, nanoseconds 等// std::this_thread::sleep_for(std::chrono::milliseconds(500));auto end_time = std::chrono::high_resolution_clock::now();std::chrono::duration<double, std::milli> elapsed = end_time - start_time;std::cout << "主线程:睡醒了!实际睡眠时间约: " << elapsed.count() << " 毫秒" << std::endl;return 0;
}
注意: sleep_for
只能保证线程至少睡眠指定的时间,实际睡眠时间可能略长,因为线程何时被重新调度取决于操作系统。它是一种阻塞操作。
4. 线程的生命周期与状态 (概念)
虽然 C++ 标准库不像 Java 那样提供一个直接查询线程精确状态(如 BLOCKED
, WAITING
)的方法,但理解线程在其生命周期中经历的主要阶段和概念上的状态转换仍然非常重要。
4.1 线程的主要生命阶段
一个 std::thread
对象关联的线程大致会经历以下阶段:
- 非执行 (Non-executing) / 初始化:
std::thread
对象被创建,但尚未关联到实际的操作系统线程开始执行(或者线程已经结束/分离)。默认构造的std::thread
对象就处于此状态。 - 可运行 (Runnable) / 运行中 (Running): 线程已经启动 (
std::thread
构造函数成功返回后),并且正在执行其任务代码,或者在操作系统的就绪队列中等待 CPU 时间片。这是线程执行任务的主要状态。 - 阻塞 (Blocked) / 等待 (Waiting): 线程暂时停止执行,因为它在等待某个事件发生。这可能是:
- 等待 I/O 操作完成 (如读写文件、网络通信)。
- 等待互斥锁 (Mutex) (当尝试锁定的互斥锁已被其他线程持有时)。
- 等待条件变量 (Condition Variable) (等待其他线程发出通知)。
- 显式睡眠 (
std::this_thread::sleep_for
)。 - 等待另一个线程结束 (
join()
)。
当等待的事件发生后,线程会重新回到可运行状态,等待被调度执行。
- 终止 (Terminated): 线程的任务函数执行完毕(正常返回或抛出未捕获的异常),线程执行结束。一旦线程终止:
- 如果它曾被
join()
,那么调用join()
的线程将解除阻塞。 - 如果它曾被
detach()
,其资源最终由操作系统回收。 - 关联的
std::thread
对象(如果还存在且未被 join/detach)不再代表活动线程。
- 如果它曾被
流程图 (概念性生命周期):
graph TDA[创建 std::thread 对象] --> B{启动线程执行};B --> C[可运行 / 运行中];C --> D{任务是否完成?};D -- No --> E{是否遇到阻塞? (I/O, Lock, Sleep, Join...)};E -- Yes --> F[阻塞 / 等待];F --> G{阻塞条件解除?};G -- Yes --> C;E -- No --> C;D -- Yes --> H[终止];subgraph Legenddirection LRStart --> End;endstyle A fill:#f9f,stroke:#333,stroke-width:2pxstyle H fill:#ccf,stroke:#333,stroke-width:2px
4.2 理解状态转换的意义
理解线程可能进入阻塞/等待状态至关重要:
- 性能考量: 过多的线程阻塞(尤其是争抢锁导致的阻塞)会降低程序的并发度。
- 死锁 (Deadlock): 两个或多个线程相互等待对方持有的资源,导致所有相关线程都无法继续执行。这是多线程编程中需要极力避免的问题。
- 资源利用: 当线程阻塞时,它不消耗 CPU 时间(除了上下文切换的开销),这使得其他可运行线程有机会执行。
4.3 观察线程的生命周期行为
我们可以通过 join()
和 is_joinable()
来间接观察线程是否还在运行或已经结束。
#include <iostream>
#include <thread>
#include <chrono>void long_task() {std::cout << " 长任务线程 (" << std::this_thread::get_id() << ") 开始..." << std::endl;std::this_thread::sleep_for(std::chrono::seconds(3));std::cout << " 长任务线程 (" << std::this_thread::get_id() << ") 结束。" << std::endl;
}int main() {std::cout << "主线程 (" << std::this_thread::get_id() << ") 启动子线程..." << std::endl;std::thread worker(long_task);// 检查线程是否可 join (即是否还在运行或刚结束但未被 join/detach)while (worker.joinable()) {std::cout << "主线程:子线程还在运行... 等待 500ms" << std::endl;std::this_thread::sleep_for(std::chrono::milliseconds(500));// 注意:在实际应用中,这种轮询检查通常不是最佳实践,// 使用 join() 或条件变量等同步机制更好。这里仅为演示。}// 循环结束后,意味着 worker 不再 joinable,但我们不知道是 join 了还是 detach 了// 所以通常直接 joinstd::cout << "主线程:尝试 join 子线程..." << std::endl;// 如果上面循环结束后线程还未被 join, 这里会等待它结束// 如果循环能退出说明线程可能已经结束, join 会立即返回 (理论上)// 实际上,更好的做法是直接 join,让 join 来阻塞等待// 这里因为上面的循环可能在 joinable() 变为 false 后才退出,所以再次判断是安全的if(worker.joinable()) { // 严谨起见再检查一次,虽然上面的循环保证了它可能不是joinable的worker.join();} else {// 如果因为其他原因(比如已经被detach了),这里会知道std::cout << "主线程:子线程已经无法 join (可能已 detach 或 join 过)。" << std::endl;}// 如果上面没有 if(worker.joinable()) 包裹,且上面循环判断没问题,直接调用 join()// worker.join();std::cout << "主线程:子线程已结束。" << std::endl;return 0;
}
修改说明:上面的示例代码中的 while(worker.joinable())
轮询方式仅用于演示线程生命周期的概念,实际开发中应直接使用 worker.join()
来等待线程结束,或者使用更高级的同步机制。已在注释中说明并调整了后续 join 的逻辑。
5. 多线程的挑战:线程安全 (重点)
多线程带来了性能和响应性的提升,但也引入了一个核心挑战:线程安全 (Thread Safety)。
5.1 什么是线程安全?
简单来说,如果一段代码在被多个线程同时或交错执行时,其行为仍然符合预期(就像在单线程环境中按顺序执行一样),并且不会导致数据损坏、不一致或程序崩溃,那么我们就称这段代码是线程安全的。
反之,如果多线程执行可能导致非预期结果或错误,那么代码就是线程不安全的。
5.2 线程不安全的根源:竞态条件
线程不安全问题通常源于竞态条件 (Race Condition)。当多个线程并发地访问(读/写)同一个共享资源(如内存中的变量、文件、设备等),并且至少有一个访问是写操作时,如果最终的结果取决于这些线程执行的时序和交错顺序(而这种顺序通常是不可预测的,由操作系统调度决定),就发生了竞态条件。
经典示例:多线程计数器
#include <iostream>
#include <thread>
#include <vector>// 共享的计数器变量 (非线程安全)
int counter = 0;// 每个线程执行的任务:将计数器增加 10000 次
void increment_task() {for (int i = 0; i < 10000; ++i) {// 这看似简单的一行,实际包含三个步骤:// 1. 读取 counter 的当前值到 CPU 寄存器// 2. 在寄存器中将值加 1// 3. 将寄存器中的新值写回 counter 内存地址counter++;}
}int main() {const int num_threads = 10;std::vector<std::thread> threads;std::cout << "启动 " << num_threads << " 个线程增加计数器..." << std::endl;for (int i = 0; i < num_threads; ++i) {threads.emplace_back(increment_task);}for (auto& t : threads) {t.join();}// 预期结果:num_threads * 10000 = 100000std::cout << "预期 Counter 值: " << num_threads * 10000 << std::endl;std::cout << "实际 Counter 值: " << counter << std::endl; // 很可能小于预期值!return 0;
}
为什么结果可能不正确?
counter++
操作并非原子性的。假设两个线程 T1 和 T2 同时执行 counter++
,当 counter
为 5 时:
- T1 读取
counter
(值为 5) 到寄存器。 - 上下文切换,T2 开始执行。
- T2 读取
counter
(值仍为 5) 到寄存器。 - T2 在寄存器中加 1 (寄存器值为 6)。
- T2 将 6 写回
counter
(内存中counter
变为 6)。 - 上下文切换,T1 恢复执行。
- T1 在其寄存器中加 1 (寄存器值变为 6,它是基于旧值 5 计算的)。
- T1 将 6 写回
counter
(内存中counter
仍为 6)。
两个线程都执行了 counter++
,但 counter
只增加了 1!这就是竞态条件导致的数据丢失。
5.3 保证线程安全的关键概念
为了解决竞态条件,确保线程安全,我们需要理解并运用以下概念:
5.3.1 原子性 (Atomicity)
原子操作是指一个不可被中断的操作。要么完全执行成功,要么完全不执行,不会出现执行到一半被其他线程打断的情况。
对于非常简单的操作(如单个内置类型的读、写、增/减),C++ 提供了原子类型 std::atomic<T>
(需要包含 <atomic>
头文件)。std::atomic
保证了对其封装的值的操作是原子性的,从而避免了竞态条件。
修正后的计数器 (使用 std::atomic
)
#include <iostream>
#include <thread>
#include <vector>
#include <atomic> // 包含原子类型头文件// 使用原子类型 std::atomic<int>
std::atomic<int> atomic_counter = 0;// 线程任务:原子地增加计数器
void atomic_increment_task() {for (int i = 0; i < 10000; ++i) {// atomic_counter++ 是一个原子操作atomic_counter++;}
}int main() {const int num_threads = 10;std::vector<std::thread> threads;std::cout << "启动 " << num_threads << " 个线程原子地增加计数器..." << std::endl;for (int i = 0; i < num_threads; ++i) {threads.emplace_back(atomic_increment_task);}for (auto& t : threads) {t.join();}std::cout << "预期 Counter 值: " << num_threads * 10000 << std::endl;std::cout << "实际 Atomic Counter 值: " << atomic_counter << std::endl; // 结果正确!return 0;
}
std::atomic
对于简单的计数、标志位等非常有用。但对于更复杂的操作(涉及多个变量或步骤),我们需要其他同步机制。
5.3.2 互斥访问 (Mutual Exclusion) - 使用 Mutex
当我们需要保护一段包含多条指令的代码(称为临界区 (Critical Section)),确保同一时刻只有一个线程能执行它时,就需要使用互斥量 (Mutex)。
Mutex 就像一个锁。线程在进入临界区之前必须先获取 (lock) 这个锁。如果锁已被其他线程持有,则当前线程会被阻塞,直到锁被释放。线程执行完临界区代码后,必须释放 (unlock) 该锁,以便其他等待的线程可以获取它。
C++ 标准库提供了 std::mutex
(在 <mutex>
头文件中)。
使用 Mutex 保护临界区流程图:
修正后的计数器 (使用 std::mutex
)
#include <iostream>
#include <thread>
#include <vector>
#include <mutex> // 包含互斥量头文件int counter_mutex = 0; // 共享计数器
std::mutex mtx; // 创建一个互斥量实例void mutex_increment_task() {for (int i = 0; i < 10000; ++i) {// --- 进入临界区前加锁 ---mtx.lock(); // 尝试获取锁,如果锁被占用则阻塞// --- 临界区开始 ---counter_mutex++; // 现在这个操作是线程安全的// --- 临界区结束 ---mtx.unlock(); // 释放锁,让其他线程可以获取// --- 离开临界区 ---}
}int main() {const int num_threads = 10;std::vector<std::thread> threads;std::cout << "启动 " << num_threads << " 个线程使用 Mutex 增加计数器..." << std::endl;for (int i = 0; i < num_threads; ++i) {threads.emplace_back(mutex_increment_task);}for (auto& t : threads) {t.join();}std::cout << "预期 Counter 值: " << num_threads * 10000 << std::endl;std::cout << "实际 Mutex Counter 值: " << counter_mutex << std::endl; // 结果正确!return 0;
}
RAII 锁管理:std::lock_guard
和 std::unique_lock
手动调用 lock()
和 unlock()
存在风险:如果在临界区内发生异常或提前 return
,可能导致锁没有被释放,造成死锁。
C++ 推荐使用 RAII (Resource Acquisition Is Initialization) 技法来管理锁。std::lock_guard
和 std::unique_lock
就是为此设计的:
std::lock_guard<std::mutex> lock(mtx);
: 在构造时自动获取mtx
锁,在其作用域结束时(对象析构)自动释放锁。简单易用,是首选。std::unique_lock<std::mutex> lock(mtx);
: 功能更强大,也遵循 RAII,但允许更灵活的锁操作(如手动lock/unlock
、延迟加锁、与条件变量配合使用等)。
使用 std::lock_guard
的示例:
#include <mutex>std::mutex mtx_guard;
int counter_guard = 0;void guard_increment_task() {for (int i = 0; i < 10000; ++i) {// 在构造时自动加锁std::lock_guard<std::mutex> lock(mtx_guard);// --- 临界区 ---counter_guard++;// --- 临界区结束 ---} // lock 对象在此处析构,自动释放锁
}
// main 函数与之前类似,调用 guard_increment_task 即可
使用 std::lock_guard
是管理互斥锁的推荐方式,能有效避免忘记解锁的问题。
5.3.3 可见性与顺序性 (简述)
除了原子性和互斥访问,线程安全还涉及到:
- 可见性 (Visibility): 一个线程对共享变量的修改,何时能被其他线程看到?由于 CPU 缓存、编译器优化等原因,一个线程的修改可能不会立即对其他线程可见。
- 顺序性 (Ordering): 代码的实际执行顺序可能因为编译器优化或 CPU 的乱序执行而与源代码中的顺序不同。
std::atomic
和 std::mutex
(及其 RAII 包装器) 不仅保证了原子性/互斥性,它们内部也包含了必要的内存屏障 (Memory Barrier/Fence)。这些屏障可以:
- 确保可见性: 强制将缓存中的修改写回主内存(或使其他核心的缓存失效),让其他线程能看到最新的值。例如,
mutex.unlock()
会确保 unlock 前的所有写入对后续lock()
该 mutex 的线程可见。 - 限制重排序: 防止编译器或 CPU 将关键操作(如原子操作、锁操作)内外的代码进行不恰当的重排,保证了必要的执行顺序。
对于初学者,正确使用 std::atomic
和 std::mutex
(及 std::lock_guard
) 通常足以处理大部分可见性和顺序性问题。深入理解 C++ 内存模型是进阶话题。
6. 总结与后续学习
本教程带你了解了 C++ 多线程的基础知识:
- 线程的概念、优势以及与进程的区别。
- 使用
std::thread
创建线程的三种主要方式(函数指针、Lambda、Functor)。 std::thread
的核心操作join()
和detach()
的用法与区别。- 线程的生命周期概念和通过
sleep_for
让线程暂停。 - 多线程带来的核心挑战——线程安全问题,以及竞态条件的产生原因。
- 解决线程安全问题的关键:原子性 (
std::atomic
) 和互斥访问 (std::mutex
与std::lock_guard
)。
掌握这些基础是进行 C++ 并发编程的第一步。
后续可以学习的内容:
- 更深入的同步原语:
- 条件变量 (
std::condition_variable
):用于线程间的等待和通知,实现更复杂的协作。 - 信号量 (
std::counting_semaphore
- C++20):控制同时访问特定资源的线程数量。 - 读写锁 (
std::shared_mutex
- C++17):允许多个读线程并发访问,但写线程独占访问。
- 条件变量 (
std::async
,std::future
,std::promise
: 更高层次的异步任务抽象,方便获取线程执行结果和处理异常。- 线程池 (Thread Pool): 管理一组可复用线程,避免频繁创建和销毁线程的开销。虽然标准库没有直接提供,但可以自己实现或使用第三方库。
- C++ 内存模型: 深入理解原子操作的内存顺序 (
memory_order
)、happen-before 关系等,用于编写高度优化的无锁 (lock-free) 代码(这非常复杂,需谨慎)。 - 死锁的检测与避免。
- 并行算法 (C++17
<execution>
): 利用标准库算法(如std::sort
,std::for_each
)的并行版本。
多线程编程充满挑战但也极具威力。不断实践、理解原理、注意细节是成为多线程高手的必经之路。祝你学习顺利!