C++多线程编程:从入门到精通,构建高性能并发应用,非常详细,强烈建议收藏!!!
在当今多核处理器普及的时代,单线程程序已无法充分榨取硬件性能。多线程编程应运而生,它赋予程序同时处理多个任务的能力,从而显著提升应用的响应速度、吞吐量和资源利用率。然而,并发的强大力量也伴随着复杂性:数据竞争、死锁、活锁、饥饿等问题层出不穷,对开发者提出了更高的要求。
C++11及后续标准(如C++14, C++17, C++20)为多线程编程提供了强大而完善的标准库支持,使得C++开发者能够更安全、高效地编写并发程序。本文旨在为您提供一份全面、深入且实用的C++多线程编程指南,助您从原理到实践,驾驭并发编程的艺术。
一、多线程基础:std::thread的初探
1. 进程与线程:并发的基石
在深入多线程之前,理解进程与线程的本质区别至关重要:
- 进程(Process):操作系统分配资源(如内存、文件句柄)的基本单位。每个进程拥有独立的内存空间,进程间通信(IPC)相对复杂。
- 线程(Thread):CPU调度执行的基本单位,是进程内部的一个执行流。同一进程内的所有线程共享进程的内存空间和资源,但每个线程拥有独立的栈、程序计数器和寄存器。线程间通信更高效,但需谨慎处理共享数据。
核心区别速览:
| 特性 | 进程 | 线程 |
|---|---|---|
| 资源 | 独立,开销大 | 共享进程资源,开销小 |
| 调度 | 操作系统调度 | CPU调度 |
| 通信 | IPC机制 | 共享内存,更高效 |
| 健壮性 | 独立性强,一个崩溃不影响其他 | 一个崩溃可能影响整个进程 |
2. 为什么选择多线程?
- 提高响应速度:对于GUI应用或网络服务,多线程可以避免主线程阻塞,保持用户界面的响应性或服务的可用性。
- 提高吞吐量:在多核处理器上,多个线程可以并行执行,从而提高单位时间内完成的任务数量。
- 提高资源利用率:当一个任务需要等待I/O操作时,其他线程可以继续执行CPU密集型任务,充分利用CPU资源。
- 简化程序设计:将复杂任务分解为多个独立子任务,每个子任务由一个线程处理,使程序结构更清晰。
3. std::thread:C++11的线程利器
std::thread是C++标准库中用于创建和管理线程的核心类。它封装了底层线程操作,提供了简洁的API。
基本用法:
std::thread的构造函数接受一个可调用对象(函数、函数对象、Lambda表达式)作为线程的入口点,以及可选的参数。线程在std::thread对象创建时即开始执行。
#include <iostream>
#include <thread>
#include <chrono> // 用于std::this_thread::sleep_for
#include <string>
#include <vector>
// 1. 使用普通函数作为线程入口
void simple_thread_func() {
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Hello from simple_thread_func!\n";
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
// 2. 使用Lambda表达式作为线程入口,并捕获外部变量
void lambda_thread_example() {
int shared_val = 0;
// 注意:Lambda捕获变量时,如果以值捕获,则在新线程中是拷贝;如果以引用捕获,则需注意生命周期问题。
std::thread t_lambda([&shared_val]() {
for (int i = 0; i < 3; ++i) {
shared_val++; // 修改外部变量
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Lambda thread: shared_val = " << shared_val << "\n";
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
});
t_lambda.join(); // 等待lambda线程完成
std::cout << "[Main Thread] Lambda thread finished, final shared_val: " << shared_val << "\n";
}
// 3. 线程传参:注意拷贝与引用
void thread_with_args(int id, std::string& msg) {
// msg 是引用传递,会修改原始字符串
msg += " (modified by thread)";
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Thread " << id << ": " << msg << "\n";
}
int main_thread_basic_usage() {
std::cout << "[Main Thread] Main thread starts.\n";
// 创建并启动线程
std::thread t1(simple_thread_func);
// 传参示例:std::ref 用于引用传递,否则默认是值拷贝
std::string original_msg = "Hello from main!";
std::thread t2(thread_with_args, 42, std::ref(original_msg));
// 主线程继续执行其他任务
std::cout << "[Main Thread] Main thread doing other work...\n";
std::this_thread::sleep_for(std::chrono::milliseconds(150));
// 等待子线程完成:join()会阻塞当前线程直到子线程结束
t1.join();
t2.join();
std::cout << "[Main Thread] Original message after t2: " << original_msg << "\n";
std::cout << "[Main Thread] Main thread ends.\n\n";
lambda_thread_example(); // 演示Lambda线程示例
return 0;
}线程生命周期管理:join()与detach()
std::thread对象在析构时,如果其关联的线程仍然可joinable(即线程仍在运行且未被join或detach),程序会调用std::terminate终止。因此,必须显式管理线程的生命周期:
join():阻塞当前线程,直到std::thread对象关联的线程执行完毕。join()后,线程资源被回收,std::thread对象不再关联任何线程。detach():将std::thread对象与它关联的线程分离。分离后,线程作为后台线程独立运行,其生命周期不再受std::thread对象控制。当后台线程执行完毕时,其资源由C++运行时库自动回收。detach()后,std::thread对象也变为不可joinable。
重要提示:std::thread对象是可移动(Movable)但不可拷贝(Non-copyable)的。
这意味着你可以通过std::move转移线程所有权,但不能直接拷贝。
#include <iostream>
#include <thread>
#include <vector>
#include <utility> // For std::move
void worker_task() {
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Worker task running.\n";
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
int main_thread_lifecycle() {
std::cout << "[Main Thread] Starting thread lifecycle example.\n";
std::thread t_initial(worker_task);
// std::thread t_copy = t_initial; // 编译错误:std::thread 不可拷贝
std::thread t_moved = std::move(t_initial); // 所有权转移
// 此时 t_initial 不再关联任何线程,t_moved 关联了原来的线程
if (!t_initial.joinable()) {
std::cout << "[Main Thread] t_initial is no longer joinable (ownership moved).\n";
}
// 使用 vector 管理多个线程
std::vector<std::thread> threads_vec;
for (int i = 0; i < 3; ++i) {
// emplace_back 直接在容器内构造线程对象,避免拷贝
threads_vec.emplace_back(worker_task);
}
// 等待所有线程完成
if (t_moved.joinable()) {
t_moved.join();
}
for (std::thread& t : threads_vec) {
if (t.joinable()) {
t.join();
}
}
std::cout << "[Main Thread] All threads joined. Example finished.\n";
return 0;
}二、线程同步机制:共享数据的守护者
多线程共享数据是并发编程的常见场景,也是数据竞争(Data Race)的温床。当多个线程同时读写同一块内存区域且至少有一个是写操作时,若不加控制,程序行为将不可预测。线程同步机制正是为了解决这一问题,确保共享数据的正确性和一致性。
1. 互斥量(Mutex):最基本的锁
互斥量(Mutual Exclusion)是C++中最基础的同步原语,用于保护共享资源,确保在任何时刻只有一个线程可以访问被保护的代码段(临界区)。
原理:
互斥量像一把独占的锁。线程在访问临界区前必须先“锁定”互斥量。如果互斥量已被其他线程锁定,当前线程将被阻塞,直到锁被释放。访问完成后,线程必须“解锁”互斥量。
std::mutex:C++11提供的标准互斥量。
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx_counter; // 全局互斥量
long long global_counter = 0;
void increment_counter_mutex() {
for (int i = 0; i < 100000; ++i) {
mtx_counter.lock(); // 锁定互斥量
global_counter++; // 访问临界区
mtx_counter.unlock(); // 解锁互斥量
}
}
int main_mutex_basic() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter_mutex);
}
for (std::thread& t : threads) {
t.join();
}
std::cout << "Final global_counter (mutex): " << global_counter << std::endl; // 预期输出 1000000
return 0;
}RAII风格的锁管理:std::lock_guard与std::unique_lock
直接使用lock()和unlock()容易因忘记解锁或异常导致死锁。C++推荐使用RAII(Resource Acquisition Is Initialization)机制的锁管理类,确保锁的自动释放。
std::lock_guard<std::mutex>:- 特点:构造时自动锁定,析构时自动解锁。简单、高效,适用于简单的临界区保护。
- 限制:不可移动、不可拷贝,不支持手动解锁。
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx_guard_example;
long long global_counter_guard = 0;
void increment_counter_guard() {
for (int i = 0; i < 100000; ++i) {
std::lock_guard<std::mutex> lock(mtx_guard_example); // 构造时锁定,离开作用域时自动解锁
global_counter_guard++;
}
}
int main_lock_guard() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter_guard);
}
for (std::thread& t : threads) {
t.join();
}
std::cout << "Final global_counter (lock_guard): " << global_counter_guard << std::endl;
return 0;
}std::unique_lock<std::mutex>:- 特点:比
std::lock_guard更灵活。支持手动锁定/解锁、延迟锁定(std::defer_lock)、尝试锁定(try_lock)、所有权转移等。 - 开销:略高于
std::lock_guard。
- 特点:比
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>
std::mutex mtx_unique_example;
long long global_counter_unique = 0;
void increment_counter_unique() {
for (int i = 0; i < 100000; ++i) {
std::unique_lock<std::mutex> lock(mtx_unique_example); // 构造时锁定
global_counter_unique++;
// lock.unlock(); // 可以手动解锁
// lock.lock(); // 也可以再次锁定
}
}
int main_unique_lock() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter_unique);
}
for (std::thread& t : threads) {
t.join();
}
std::cout << "Final global_counter (unique_lock): " << global_counter_unique << std::endl;
return 0;
}死锁及其避免:
死锁是并发编程中的经典问题:两个或多个线程在竞争资源时,每个线程都持有部分资源并等待获取对方持有的资源,导致所有线程都无法继续执行。
避免死锁的策略:
- 固定加锁顺序:所有线程都按照相同的顺序获取多个互斥量。
- 使用
std::lock():std::lock(mtx1, mtx2, ...)可以原子性地锁定多个互斥量,并自动处理死锁。它会尝试锁定所有互斥量,如果不能全部锁定,则会释放已获得的锁,然后重新尝试。 - 避免嵌套锁:尽量避免在一个已持有锁的临界区内再次尝试获取另一个锁。
- 使用
std::unique_lock的try_lock或defer_lock:尝试非阻塞地获取锁,如果失败则可以执行其他操作或等待。
#include <iostream>
#include <thread>
#include <mutex>
std::mutex mtx_A;
std::mutex mtx_B;
// 潜在死锁的函数:线程1先锁A再锁B,线程2先锁B再锁A
void thread_func_deadlock_A_B() {
std::lock_guard<std::mutex> lock_a(mtx_A);
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 制造竞争条件
std::lock_guard<std::mutex> lock_b(mtx_B);
std::cout << "Thread A-B: Acquired both locks.\n";
}
void thread_func_deadlock_B_A() {
std::lock_guard<std::mutex> lock_b(mtx_B);
std::this_thread::sleep_for(std::chrono::milliseconds(10)); // 制造竞争条件
std::lock_guard<std::mutex> lock_a(mtx_A);
std::cout << "Thread B-A: Acquired both locks.\n";
}
// 使用 std::lock 避免死锁
void thread_func_safe_lock_1() {
// std::lock 会尝试同时锁定所有互斥量,避免死锁
std::lock(mtx_A, mtx_B);
// 使用 std::adopt_lock 告知 lock_guard 互斥量已被锁定
std::lock_guard<std::mutex> lock_a(mtx_A, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(mtx_B, std::adopt_lock);
std::cout << "Thread Safe 1: Acquired both locks.\n";
}
void thread_func_safe_lock_2() {
std::lock(mtx_B, mtx_A);
std::lock_guard<std::mutex> lock_b(mtx_B, std::adopt_lock);
std::lock_guard<std::mutex> lock_a(mtx_A, std::adopt_lock);
std::cout << "Thread Safe 2: Acquired both locks.\n";
}
int main_deadlock_and_safe_lock() {
std::cout << "--- Potential Deadlock Example ---\n";
std::thread t_dl1(thread_func_deadlock_A_B);
std::thread t_dl2(thread_func_deadlock_B_A);
t_dl1.join();
t_dl2.join();
std::cout << "\n--- Safe Lock Example with std::lock ---\n";
std::thread t_safe1(thread_func_safe_lock_1);
std::thread t_safe2(thread_func_safe_lock_2);
t_safe1.join();
t_safe2.join();
return 0;
}2. 条件变量(Condition Variable):线程间的协作
条件变量(std::condition_variable)通常与互斥量一起使用,用于实现线程间的等待和通知机制。一个线程可以等待某个条件成立,而另一个线程可以在条件成立时通知等待的线程。
原理:
条件变量允许线程在满足特定条件前挂起,并在条件满足时被唤醒。它通过wait()方法释放互斥量并阻塞,通过notify_one()或notify_all()唤醒等待线程。
std::condition_variable:
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
std::mutex mtx_cv_example;
std::condition_variable cv_example;
std::queue<int> data_buffer; // 共享数据缓冲区
bool producer_done = false;
// 生产者线程
void producer_cv() {
for (int i = 0; i < 5; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(500));
std::unique_lock<std::mutex> lock(mtx_cv_example);
data_buffer.push(i); // 生产数据
std::cout << "[Producer] Produced: " << i << std::endl;
cv_example.notify_one(); // 通知一个等待的消费者
}
std::unique_lock<std::mutex> lock(mtx_cv_example);
producer_done = true;
cv_example.notify_all(); // 生产结束,通知所有消费者
}
// 消费者线程
void consumer_cv(int id) {
while (true) {
std::unique_lock<std::mutex> lock(mtx_cv_example);
// wait()的第二个参数是谓词(predicate),用于检查条件是否满足
// 虚假唤醒(Spurious Wakeup)可能发生,因此必须在循环中检查条件
cv_example.wait(lock, [&]{
return !data_buffer.empty() || producer_done;
});
if (data_buffer.empty() && producer_done) {
std::cout << "[Consumer " << id << "] No more data, exiting.\n";
break;
}
int data = data_buffer.front();
data_buffer.pop();
std::cout << "[Consumer " << id << "] Consumed: " << data << std::endl;
// lock.unlock(); // 可以在处理数据时解锁,提高并发度
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
}
int main_condition_variable() {
std::thread prod_t(producer_cv);
std::thread cons_t1(consumer_cv, 1);
std::thread cons_t2(consumer_cv, 2);
prod_t.join();
cons_t1.join();
cons_t2.join();
std::cout << "[Main Thread] Condition variable example finished.\n";
return 0;
}3. 递归互斥量(std::recursive_mutex):可重入的锁
std::recursive_mutex允许同一个线程多次锁定它而不会造成死锁。每次锁定都需要对应一次解锁。它适用于一个函数内部需要多次加锁,或者一个加锁的函数调用了另一个也需要加锁的函数的情况。
原理:
std::recursive_mutex内部维护一个计数器和拥有者线程ID。当线程首次锁定它时,计数器置1并记录线程ID;同一线程再次锁定,计数器递增。只有当计数器减为0时,锁才真正释放。
使用场景:
- 可重入函数:当一个函数可能在持有锁的情况下,再次调用自身或调用另一个需要相同锁的函数时。
注意:虽然方便,但滥用可能掩盖设计问题。通常应优先考虑重构代码以避免递归加锁。
#include <iostream>
#include <thread>
#include <mutex>
std::recursive_mutex rec_mtx_example;
void recursive_lock_func(int count) {
if (count <= 0) {
return;
}
std::lock_guard<std::recursive_mutex> lock(rec_mtx_example);
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Locked, count: " << count << std::endl;
recursive_lock_func(count - 1);
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Unlocked, count: " << count << std::endl;
}
int main_recursive_mutex() {
std::thread t1(recursive_lock_func, 3);
std::thread t2(recursive_lock_func, 2);
t1.join();
t2.join();
return 0;
}4. 定时互斥量(std::timed_mutex):带超时的锁
std::timed_mutex是std::mutex的扩展,提供了尝试在指定时间内锁定互斥量的功能,避免了无限期等待。
原理:
它提供了try_lock_for()和try_lock_until()方法。这些方法会在尝试获取锁失败时,等待一段指定的时间,如果在这段时间内成功获取锁则返回true,否则返回false。
使用场景:
- 避免无限期阻塞:当线程不能无限期等待锁时,可以尝试在一定时间内获取锁,如果超时则执行其他逻辑。
- 资源竞争不激烈:在资源竞争不激烈但又需要避免长时间阻塞的场景。
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>
std::timed_mutex timed_mtx_example;
void task_with_timeout_lock() {
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Trying to acquire lock...\n";
// 尝试在1秒内获取锁
if (timed_mtx_example.try_lock_for(std::chrono::seconds(1))) {
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Acquired lock.\n";
std::this_thread::sleep_for(std::chrono::seconds(2)); // 模拟持有锁的时间
timed_mtx_example.unlock();
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Released lock.\n";
} else {
std::cout << "[Thread ID: " << std::this_thread::get_id() << "] Could not acquire lock within 1 second.\n";
}
}
int main_timed_mutex() {
std::thread t1(task_with_timeout_lock);
std::thread t2(task_with_timeout_lock);
t1.join();
t2.join();
return 0;
}5. 读写互斥量(std::shared_mutex):读写分离锁
std::shared_mutex(C++17引入)允许多个线程同时读取共享资源(共享锁),但只允许一个线程写入共享资源(独占锁)。这在读多写少的场景下能显著提高并发性能。
原理:
std::shared_mutex维护两种锁:
- 共享锁(Shared Lock):多个线程可以同时持有,用于读取操作。
- 独占锁(Exclusive Lock):只有一个线程可以持有独占锁,用于写入操作。当有线程持有独占锁时,其他任何线程都不能获取共享锁或独占锁;当有线程持有共享锁时,其他线程可以继续获取共享锁,但不能获取独占锁。
std::shared_lock和std::unique_lock配合std::shared_mutex:
std::shared_lock<std::shared_mutex>:用于获取共享锁(读锁)。std::unique_lock<std::shared_mutex>:用于获取独占锁(写锁)。
使用场景:
- 读多写少的数据结构:例如缓存、配置信息等,大部分操作是读取,少量操作是修改。
#include <iostream>
#include <thread>
#include <shared_mutex> // C++17
#include <vector>
#include <string>
std::shared_mutex sh_mtx_example;
std::string shared_data_rw = "Initial Data";
void read_data_sh(int id) {
for (int i = 0; i < 3; ++i) {
std::shared_lock<std::shared_mutex> lock(sh_mtx_example); // 获取共享锁 (读锁)
std::cout << "[Reader " << id << "] Reading: " << shared_data_rw << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
void write_data_sh(int id, const std::string& new_data) {
std::unique_lock<std::shared_mutex> lock(sh_mtx_example); // 获取独占锁 (写锁)
std::cout << "[Writer " << id << "] Writing...\n";
shared_data_rw = new_data;
std::this_thread::sleep_for(std::chrono::milliseconds(200));
std::cout << "[Writer " << id << "] Finished writing.\n";
}
int main_shared_mutex() {
std::vector<std::thread> readers;
for (int i = 0; i < 5; ++i) {
readers.emplace_back(read_data_sh, i);
}
std::thread writer1(write_data_sh, 1, "Data from Writer 1");
std::thread writer2(write_data_sh, 2, "Data from Writer 2");
for (std::thread& t : readers) {
t.join();
}
writer1.join();
writer2.join();
return 0;
}6. 自旋锁(Spinlock):忙等待的锁
自旋锁是一种特殊的互斥锁,当线程尝试获取锁而锁已被占用时,它不会立即阻塞,而是会循环检查锁是否可用,直到获取到锁。它适用于临界区非常小、加锁时间极短的场景。
原理:
自旋锁通过忙等待(busy-waiting)来避免线程上下文切换的开销。它通常基于原子操作(如CAS)实现。
C++标准库中没有直接提供std::spinlock,但可以通过std::atomic_flag实现一个简单的自旋锁:
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
// 简单的自旋锁实现
class Spinlock {
public:
void lock() {
// test_and_set 如果 atomic_flag 之前是 false,则设置为 true 并返回 false
// 如果之前是 true,则设置为 true 并返回 true
// 循环直到成功将 flag 从 false 设置为 true (即获取到锁)
while (flag.test_and_set(std::memory_order_acquire)) {
// 忙等待,可以加入 std::this_thread::yield() 优化,避免过度消耗CPU
// std::this_thread::yield();
}
}
void unlock() {
flag.clear(std::memory_order_release); // 释放锁,将 flag 设置为 false
}
private:
std::atomic_flag flag = ATOMIC_FLAG_INIT; // 初始化为 false
};
Spinlock spin_lock_example;
long long global_counter_spinlock = 0;
void increment_counter_spinlock() {
for (int i = 0; i < 100000; ++i) {
spin_lock_example.lock();
global_counter_spinlock++;
spin_lock_example.unlock();
}
}
int main_spinlock() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_counter_spinlock);
}
for (std::thread& t : threads) {
t.join();
}
std::cout << "Final global_counter (spinlock): " << global_counter_spinlock << std::endl;
return 0;
}使用场景:
- 临界区极短:加锁时间比线程上下文切换时间还短的场景。
- 内核态:操作系统内核中常用,因为内核态上下文切换开销更大。
优缺点:
- 优点:避免了上下文切换开销,在竞争不激烈且临界区极短时性能优异。
- 缺点:
- 忙等待:在多核CPU上,如果锁被长时间占用,会持续消耗CPU资源,造成CPU浪费。
- 不公平:无法保证公平性,可能导致某些线程饥饿。
- 不适用于单核CPU:在单核CPU上,自旋锁会导致死锁,因为持有锁的线程无法释放CPU。
三、原子操作与内存模型:细粒度同步与并发秩序
互斥量和条件变量是重量级的同步机制。在某些场景下,我们只需要对单个变量进行原子操作,或者需要更细粒度地控制内存访问顺序,这时可以使用原子操作和内存模型。
1. 原子操作(Atomic Operations):不可分割的操作
原子操作是指在多线程环境下,一个操作要么完全执行,要么完全不执行,不会被任何其他线程的操作中断。C++11引入了std::atomic模板类,提供了对基本数据类型的原子操作支持。
原理:
原子操作通常通过硬件指令(如CPU的CAS指令)或编译器优化来实现,确保操作的不可中断性。这意味着即使在多核处理器上,对std::atomic变量的读写操作也是原子的,不会发生数据竞争。
std::atomic<T>:
#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
std::atomic<int> atomic_val(0); // 原子变量
void increment_atomic_val() {
for (int i = 0; i < 100000; ++i) {
atomic_val++; // 原子递增操作,等价于 atomic_val.fetch_add(1)
}
}
int main_atomic_basic() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment_atomic_val);
}
for (std::thread& t : threads) {
t.join();
}
std::cout << "Final atomic_val: " << atomic_val << std::endl; // 预期输出 1000000
return 0;
}比较-交换(CAS)操作:compare_exchange_weak与compare_exchange_strong
std::atomic还提供了CAS操作,这是实现无锁(Lock-Free)数据结构的基础。
compare_exchange_weak:可能发生虚假失败(Spurious Failure),即即使值相等也可能返回false,通常用于循环中。compare_exchange_strong:保证只有在值不相等时才返回false。
#include <iostream>
#include <atomic>
#include <thread>
std::atomic<int> current_value(0);
void update_value_cas(int new_val) {
int expected = current_value.load(); // 读取当前值
// 循环直到成功将 current_value 从 expected 更新为 new_val
while (!current_value.compare_exchange_weak(expected, new_val)) {
// 如果交换失败,expected 会被自动更新为 current_value 的最新值,继续尝试
std::cout << "[Thread ID: " << std::this_thread::get_id()
<< "] CAS failed, expected: " << expected
<< ", current: " << current_value.load() << ", retrying...\n";
}
std::cout << "[Thread ID: " << std::this_thread::get_id()
<< "] Successfully updated value to " << new_val << std::endl;
}
int main_atomic_cas() {
std::thread t1(update_value_cas, 10);
std::thread t2(update_value_cas, 20);
t1.join();
t2.join();
std::cout << "Final value (CAS): " << current_value.load() << std::endl;
return 0;
}使用场景:
- 计数器与标志位:实现线程安全的计数器和布尔标志。
- 无锁数据结构:构建高性能的无锁队列、栈等数据结构。
注意:无锁编程非常复杂且容易出错,通常只在对性能有极致要求且经过严格测试的场景下使用。
2. 内存模型(Memory Model):并发的秩序
C++内存模型定义了在多线程环境下,对内存操作的可见性和顺序性规则。它解决了编译器优化和CPU乱序执行可能导致的问题,确保不同线程对共享内存的访问行为符合预期。
内存序(Memory Order):
std::atomic操作可以指定不同的内存序,以控制操作的可见性和顺序性。理解内存序是理解并发底层行为的关键。
memory_order_relaxed:最宽松的内存序,只保证原子性,不保证任何同步或顺序。适用于不依赖其他操作结果的计数器。memory_order_acquire:获取语义。读操作,保证该操作及之后的所有读写操作不会被重排到该操作之前。它“获取”了在它之前所有release操作的内存效果。memory_order_release:释放语义。写操作,保证该操作及之前的所有读写操作不会被重排到该操作之后。它“释放”了在它之前所有操作的内存效果,使其对acquire操作可见。memory_order_acq_rel:获取-释放语义。兼具获取和释放的特性,用于读-改-写操作。memory_order_seq_cst:顺序一致性。最严格的内存序,保证所有线程看到的操作顺序一致。这是std::atomic的默认内存序,也是最安全但开销最大的选项。
示例:Acquire-Release同步
#include <iostream>
#include <thread>
#include <atomic>
std::atomic<bool> flag(false);
std::atomic<int> value(0);
void producer_memory_order() {
value.store(100, std::memory_order_release); // 释放语义:保证此前的写入(value=100)对消费者可见
flag.store(true, std::memory_order_release); // 释放语义:标记数据已准备好
std::cout << "[Producer] Data and flag set.\n";
}
void consumer_memory_order() {
while (!flag.load(std::memory_order_acquire)) { // 获取语义:保证此后的读取能看到生产者释放的内存效果
std::this_thread::yield(); // 避免忙等待,让出CPU
}
std::cout << "[Consumer] Flag is true, value = " << value.load(std::memory_order_acquire) << std::endl;
}
int main_memory_order() {
std::thread t_prod(producer_memory_order);
std::thread t_cons(consumer_memory_order);
t_prod.join();
t_cons.join();
return 0;
}理解内存模型的意义:
内存模型是并发编程中非常高级且复杂的概念。正确使用内存序可以避免不必要的同步开销,但错误使用则可能导致难以调试的并发错误。对于大多数应用,使用默认的顺序一致性(memory_order_seq_cst)通常是安全的。只有在对性能有极致要求时,才需要深入理解并谨慎使用其他内存序。
四、异步任务与并发工具:更高级的抽象
除了直接管理线程和使用同步原语外,C++标准库还提供了一些更高级的并发抽象,它们可以帮助我们更方便地执行异步任务和管理并发操作的结果。
1. std::async:异步执行的便捷之道
std::async是一个函数模板,它可以在后台异步地执行一个函数,并返回一个std::future对象,通过这个std::future对象可以获取异步操作的结果。
原理:
std::async可以根据指定的启动策略(Launch Policy)来决定是立即创建一个新线程执行任务,还是延迟到std::future对象被访问时才执行。它简化了线程的创建和管理,并提供了获取返回值的机制。
启动策略:
std::launch::async:强制在新线程中异步执行任务。std::launch::deferred:延迟执行任务,直到std::future的get()或wait()被调用时才在当前线程执行。std::launch::async | std::launch::deferred(默认):由C++运行时库决定。
#include <iostream>
#include <future>
#include <chrono>
// 模拟耗时任务
int heavy_computation(int a, int b) {
std::cout << "[Task] Calculating...\n";
std::this_thread::sleep_for(std::chrono::seconds(2));
return a + b;
}
int main_async_example() {
std::cout << "[Main Thread] Starting async task.\n";
// 默认启动策略,可能异步也可能延迟
std::future<int> result_fut = std::async(heavy_computation, 10, 20);
std::cout << "[Main Thread] Doing other work...\n";
std::this_thread::sleep_for(std::chrono::seconds(1));
// 获取异步任务的结果,get()会阻塞直到结果可用
int sum = result_fut.get();
std::cout << "[Main Thread] Async task result = " << sum << std::endl;
// 强制异步执行
std::future<int> forced_async_fut = std::async(std::launch::async, heavy_computation, 30, 40);
std::cout << "[Main Thread] Forced async task result = " << forced_async_fut.get() << std::endl;
// 延迟执行
std::future<int> deferred_fut = std::async(std::launch::deferred, heavy_computation, 50, 60);
std::cout << "[Main Thread] Deferred task created. Will execute on get().\n";
std::cout << "[Main Thread] Deferred task result = " << deferred_fut.get() << std::endl; // 此时才执行
std::cout << "[Main Thread] All async tasks finished.\n";
return 0;
}2. std::packaged_task:任务与结果的分离
std::packaged_task是一个模板类,它将一个可调用对象包装起来,并将其结果存储在一个std::future对象中。它允许你将任务与未来的结果分离,并手动控制任务的执行。
原理:
std::packaged_task封装了一个任务,并提供get_future()方法获取关联的std::future。你可以将std::packaged_task对象传递给std::thread或线程池来执行。
#include <iostream>
#include <future>
#include <thread>
int calculate_factorial(int n) {
int res = 1;
for (int i = 1; i <= n; ++i) {
res *= i;
}
return res;
}
int main_packaged_task() {
// 1. 创建一个 packaged_task,封装 calculate_factorial 函数
std::packaged_task<int(int)> task(calculate_factorial);
// 2. 获取与任务关联的 future 对象
std::future<int> result_fut = task.get_future();
// 3. 将任务移动到一个新线程中执行
std::thread t(std::move(task), 5); // 任务被移动到新线程
// 4. 等待线程完成
t.join();
// 5. 从 future 中获取结果
std::cout << "Factorial of 5 is: " << result_fut.get() << std::endl; // 输出 120
return 0;
}使用场景:
- 线程池:将任务封装后放入队列,由工作线程取出执行。
- 异步任务管理:需要将任务和其结果的获取分离,并手动控制任务执行方式时。
3. std::future和std::promise:异步结果的传递
std::future和std::promise是C++11中用于在不同线程之间传递异步操作结果的机制。std::promise用于设置一个值或异常,而std::future用于获取这个值或异常。
原理:
std::promise是“生产者”端,它可以在某个时刻设置一个值或异常。std::future是“消费者”端,它可以在稍后获取这个值或异常。当std::promise设置值时,std::future就变为“就绪”状态,等待std::future的线程会被唤醒。
#include <iostream>
#include <future>
#include <thread>
void producer_promise_future(std::promise<int>&& prom) {
try {
// 模拟一些计算
int result = 123;
// 设置结果到 promise
prom.set_value(result);
std::cout << "[Producer] Result set to " << result << std::endl;
} catch (...) {
// 如果发生异常,设置异常到 promise
prom.set_exception(std::current_exception());
}
}
void consumer_promise_future(std::future<int>&& fut) {
try {
// 从 future 获取结果,get() 会阻塞直到结果可用
int value = fut.get();
std::cout << "[Consumer] Received result = " << value << std::endl;
} catch (const std::exception& e) {
std::cerr << "[Consumer] Caught exception: " << e.what() << std::endl;
}
}
int main_promise_future() {
// 创建 promise 对象
std::promise<int> prom;
// 获取与 promise 关联的 future 对象
std::future<int> fut = prom.get_future();
// 启动生产者线程,将 promise 移动给它
std::thread prod_t(producer_promise_future, std::move(prom));
// 启动消费者线程,将 future 移动给它
std::thread cons_t(consumer_promise_future, std::move(fut));
prod_t.join();
cons_t.join();
std::cout << "[Main Thread] Promise/Future example finished.\n";
// 演示异常传递
std::promise<void> prom_ex;
std::future<void> fut_ex = prom_ex.get_future();
std::thread t_prod_ex([&]() {
try {
throw std::runtime_error("Something went wrong in producer!");
} catch (...) {
prom_ex.set_exception(std::current_exception());
}
});
std::thread t_cons_ex([&]() {
try {
fut_ex.get();
} catch (const std::exception& e) {
std::cerr << "[Consumer (Exception)] Caught exception: " << e.what() << std::endl;
}
});
t_prod_ex.join();
t_cons_ex.join();
return 0;
}使用场景:
- 线程间结果传递:一个线程计算出结果后,传递给另一个等待该结果的线程。
- 异步操作的返回值:作为
std::async和std::packaged_task的底层机制。 - 实现一次性事件:当某个事件发生后,通知所有等待的线程。
五、总结与最佳实践:驾驭并发的艺术
C++多线程编程是现代软件开发中不可或缺的技能,它能够帮助我们充分利用多核处理器的优势,构建高性能、高响应的应用程序。然而,并发编程也充满了挑战,需要开发者对线程的生命周期、数据同步、内存模型以及高级并发工具都有深入的理解。
多线程编程的常见挑战:
- 数据竞争(Data Race):多个线程同时访问和修改共享数据,导致结果不可预测。
- 死锁(Deadlock):多个线程相互等待对方释放资源,导致所有线程都无法继续执行。
- 活锁(Livelock):线程不断地尝试获取资源,但由于其他线程的干扰而一直失败,导致无法完成任务。
- 饥饿(Starvation):某个线程由于优先级低或资源分配不公平,长时间无法获取所需资源而无法执行。
- 性能问题:过度同步可能导致性能下降,而同步不足则可能导致正确性问题。
C++多线程编程最佳实践:
- 优先使用高级抽象:尽可能使用
std::async、std::packaged_task和std::future等高级并发工具,它们简化了线程管理和结果获取,减少了出错的可能性。 - 谨慎使用互斥量:
- RAII原则:始终使用
std::lock_guard或std::unique_lock来管理互斥量,确保锁的正确释放。 - 最小化临界区:只在真正需要保护共享数据时才锁定互斥量,并且临界区应尽可能小,以提高并发度。
- 固定加锁顺序:如果需要获取多个互斥量,所有线程都应遵循相同的加锁顺序,或使用
std::lock()来避免死锁。
- RAII原则:始终使用
- 正确使用条件变量:
- 始终与互斥量配合:条件变量必须与互斥量一起使用。
- 检查条件:
wait()方法必须配合Lambda表达式进行条件检查,以避免虚假唤醒。
- 理解内存模型:对于高性能场景或无锁编程,需要深入理解C++内存模型和不同的内存序,但对于大多数应用,默认的顺序一致性是安全的。
- 避免共享状态:最好的同步是避免同步。如果可能,尽量设计无共享状态的线程,或者通过消息传递等方式进行通信,而不是直接共享内存。
- 异常安全:确保多线程代码在发生异常时能够正确释放资源,避免死锁或资源泄露。
- 测试与调试:并发问题往往难以复现和调试,需要充分的测试和借助专业的并发调试工具。
常见问题
Q1: std::thread和std::async有什么区别?
std::thread是更底层的线程管理工具,你需要手动管理线程的生命周期(join()或detach()),并且无法直接获取线程函数的返回值。std::async是更高级的抽象,它会自动管理线程的生命周期(通过std::future的析构),并且可以方便地获取异步任务的返回值或异常。对于简单的异步任务,std::async通常是更推荐的选择。
Q2: 什么是虚假唤醒(Spurious Wakeup)?如何避免?
虚假唤醒是指条件变量的wait()方法在没有收到notify_one()或notify_all()通知的情况下被唤醒。这是操作系统调度的一种优化,是允许发生的。为了避免因此导致的问题,必须始终在循环中检查条件变量所等待的条件。例如:while (!condition) { cv.wait(lock); } 或 cv.wait(lock, []{ return condition; });。
Q3: std::mutex和std::atomic有什么区别?什么时候用哪个?
std::mutex:是一种互斥锁,用于保护临界区,确保同一时间只有一个线程访问共享资源。它会阻塞线程,涉及上下文切换,开销相对较大。适用于保护复杂数据结构或多个变量的访问。std::atomic:提供对单个变量的原子操作,确保操作的不可分割性。它通常通过硬件指令实现,不会阻塞线程,开销较小。适用于对单个变量(如计数器、标志位)的简单、频繁操作,或构建无锁数据结构。
选择:对于复杂的数据结构或需要保护多个相关变量的场景,使用std::mutex。对于单个变量的简单原子操作,且对性能有较高要求时,可以考虑std::atomic。
Q4: 什么是内存屏障(Memory Barrier)?
内存屏障(或内存栅栏)是一种CPU指令,用于强制CPU或编译器在执行特定操作时遵循严格的顺序。它确保在屏障之前的内存操作在屏障之后的内存操作之前完成。在C++中,内存屏障的概念通过std::atomic的内存序(memory_order)来体现和控制,例如memory_order_acquire和memory_order_release就隐含了内存屏障的作用。
Q5: 如何避免多线程中的饥饿问题?
饥饿问题通常发生在资源分配不公平或优先级反转的情况下。避免饥饿的策略包括:
- 公平锁:使用支持公平性策略的锁(C++标准库的锁默认不保证公平性,但有些第三方库提供)。
- 优先级继承/反转协议:在实时系统中,通过优先级继承或优先级天花板协议来解决优先级反转问题。
- 避免长时间持有锁:尽量缩短临界区,减少线程等待时间。
- 资源轮询或超时机制:使用
try_lock_for等带超时机制的锁,避免线程无限期等待。
希望这篇博文能帮助您深入理解C++多线程编程,并在您的软件开发旅程中提供有益的指导。祝您在构建高性能、高并发的C++应用道路上越走越远!
