C++ 并发编程最佳实践详解
目录
引言
1. 优先选择高层抽象 (std::async)
场景:从子线程计算一个数的平方并返回结果
2. 始终使用 RAII 锁
场景:一个可能抛出异常的函数需要访问共享资源
3. 用条件变量进行同步
场景:一个线程等待另一个线程准备好数据
4. 警惕死锁
场景:从一个银行账户向另一个账户转账,需要同时锁定两个账户
5. 明智地使用原子操作
场景:实现一个多线程共享的计数器
反例:何时不能用 std::atomic?
引言
您在 Canvas 中看到的五条结论是经验丰富的 C++ 开发者总结出的并发编程黄金法则。遵守它们可以帮助您写出更安全、更高效、更易于维护的并发代码。本篇文档将通过具体的代码场景,深入剖析每一条法则背后的“为什么”。
1. 优先选择高层抽象 (std::async
)
结论:“尽可能使用 std::async
、std::future
等工具,它们比手动管理 std::thread
更简单、更安全。”
解读:核心问题是如何从子线程获取计算结果。手动管理 std::thread
来实现这一点,代码会非常繁琐,且容易出错。std::async
将这一切封装好了。
场景:从子线程计算一个数的平方并返回结果
❌ 错误示范:手动管理 std::thread
和共享数据
#include <iostream>
#include <thread>
#include <mutex>void calculate_square(int x, int& result, std::mutex& mtx) {// 模拟耗时计算std::this_thread::sleep_for(std::chrono::milliseconds(500));// 使用互斥锁保护共享变量的写入std::lock_guard<std::mutex> lock(mtx);result = x * x;
}int main() {int result = 0;std::mutex mtx;// 为了获取一个结果,我们不得不引入共享变量和互斥锁std::thread t(calculate_square, 10, std::ref(result), std::ref(mtx));t.join(); // 必须等待线程结束// 手动访问结果std::cout << "手动管理线程,计算结果: " << result << std::endl;return 0;
}
问题:为了传递一个简单的结果,我们被迫引入了共享变量 result
和互斥锁 mtx
,代码显得笨重且复杂。
✅ 正确示范:使用 std::async
#include <iostream>
#include <future>int calculate_square_async(int x) {// 模拟耗时计算std::this_thread::sleep_for(std::chrono::milliseconds(500));return x * x;
}int main() {// 一行代码启动异步任务,并获得一个用于取回结果的 futurestd::future<int> result_future = std::async(std::launch::async, calculate_square_async, 10);std::cout << "主线程可以做其他事情..." << std::endl;// 当需要结果时,调用 get()。它会自动等待任务完成。int result = result_future.get();std::cout << "使用 async,计算结果: " << result << std::endl;return 0;
}
优势:代码干净、意图明确。没有手动管理的线程、没有共享变量、没有互斥锁,std::async
完美地表达了“我需要一个后台任务的返回值”这个意图。
2. 始终使用 RAII 锁
结论:“std::scoped_lock
和 std::lock_guard
是保护共享数据的首选,它们能避免忘记解锁和异常安全问题。”
解读:手动调用 lock()
和 unlock()
最大的风险是,如果在锁定期间发生异常,unlock()
就不会被执行,导致锁永远无法被释放(死锁)。RAII 风格的锁利用了 C++ 的作用域机制,保证锁一定会被释放。
场景:一个可能抛出异常的函数需要访问共享资源
❌ 错误示范:手动 lock()
和 unlock()
#include <iostream>
#include <mutex>
#include <stdexcept>std::mutex mtx;void risky_operation(bool should_throw) {mtx.lock(); // 手动上锁std::cout << "进入临界区..." << std::endl;if (should_throw) {std::cout << "发生错误,即将抛出异常!" << std::endl;throw std::runtime_error("Something went wrong");// mtx.unlock() 将永远不会被执行!}std::cout << "操作成功完成。" << std::endl;mtx.unlock(); // 手动解锁
}int main() {try {risky_operation(true);} catch (const std::exception& e) {std::cerr << "捕获到异常: " << e.what() << std::endl;}// 此时 mtx 仍然是锁定的状态std::cout << "主线程尝试再次获取锁..." << std::endl;if (mtx.try_lock()) { // 尝试获取锁std::cout << "成功获取锁。" << std::endl;mtx.unlock();} else {std::cout << "获取锁失败,程序已死锁!" << std::endl;}return 0;
}
问题:异常导致 unlock()
被跳过,互斥锁被永久锁定。
✅ 正确示范:使用 std::lock_guard
(RAII)
#include <iostream>
#include <mutex>
#include <stdexcept>std::mutex mtx_raii;void safe_risky_operation(bool should_throw) {std::lock_guard<std::mutex> lock(mtx_raii); // 构造时自动上锁std::cout << "进入临界区..." << std::endl;if (should_throw) {std::cout << "发生错误,即将抛出异常!" << std::endl;throw std::runtime_error("Something went wrong");}std::cout << "操作成功完成。" << std::endl;
} // 当 lock 对象离开作用域时 (无论是正常结束还是因为异常),析构函数会自动解锁int main() {try {safe_risky_operation(true);} catch (const std::exception& e) {std::cerr << "捕获到异常: " << e.what() << std::endl;}// 此时 mtx_raii 已经被自动解锁std::cout << "主线程尝试再次获取锁..." << std::endl;if (mtx_raii.try_lock()) {std::cout << "成功获取锁。" << std::endl;mtx_raii.unlock();} else {std::cout << "获取锁失败!" << std::endl;}return 0;
}
优势:无论函数如何退出,锁的释放都得到了保证。代码更简洁,也更健壮。
3. 用条件变量进行同步
结论:“避免使用共享标志位进行忙等待,std::condition_variable
更高效。”
解读:“忙等待”(Busy-Waiting)指一个线程在一个循环里不断检查某个条件是否满足。这会持续消耗 CPU 时间,非常低效。条件变量则能让等待的线程进入“睡眠”状态,不消耗 CPU,直到被其他线程唤醒。
场景:一个线程等待另一个线程准备好数据
❌ 错误示范:使用 atomic
标志位进行忙等待
#include <iostream>
#include <thread>
#include <atomic>
#include <chrono>std::atomic<bool> data_ready(false);
std::string shared_data;void data_preparer() {std::this_thread::sleep_for(std::chrono::seconds(1));shared_data = "数据已准备好";data_ready.store(true);
}void data_waiter() {std::cout << "等待者:正在忙等待数据..." << std::endl;while (!data_ready.load()) {// 这个循环会持续空转,浪费 CPUstd::this_thread::sleep_for(std::chrono::milliseconds(10)); // 稍作妥协,但仍是轮询}std::cout << "等待者:收到了数据 -> " << shared_data << std::endl;
}int main() {std::thread t1(data_preparer);std::thread t2(data_waiter);t1.join();t2.join();return 0;
}
问题:data_waiter
线程在 while
循环中空转,浪费了大量的 CPU 周期。
✅ 正确示范:使用 std::condition_variable
#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>bool data_ready_cv = false;
std::string shared_data_cv;
std::mutex mtx_cv;
std::condition_variable cv;void data_preparer_cv() {std::this_thread::sleep_for(std::chrono::seconds(1));{std::lock_guard<std::mutex> lock(mtx_cv);shared_data_cv = "数据已准备好";data_ready_cv = true;} // 释放锁std::cout << "准备者:已发送通知。" << std::endl;cv.notify_one(); // 通知一个等待的线程
}void data_waiter_cv() {std::cout << "等待者:进入等待状态(睡眠)..." << std::endl;std::unique_lock<std::mutex> lock(mtx_cv);// wait 会让线程睡眠,直到 data_ready_cv 为 true 且被唤醒cv.wait(lock, [] { return data_ready_cv; });std::cout << "等待者:被唤醒并收到了数据 -> " << shared_data_cv << std::endl;
}int main() {std::thread t1(data_preparer_cv);std::thread t2(data_waiter_cv);t1.join();t2.join();return 0;
}
优势:data_waiter_cv
线程在调用 cv.wait
后会立即进入休眠状态,完全不占用 CPU。直到 data_preparer_cv
调用 cv.notify_one()
,它才会被唤醒。这是最高效的等待方式。
4. 警惕死锁
结论:“当需要锁定多个互斥锁时,坚持使用 std::scoped_lock
或保证全局一致的加锁顺序。”
解读:死锁最经典的成因是:两个线程试图以相反的顺序获取两个相同的锁。std::scoped_lock
(C++17) 能一次性、安全地锁定多个互斥锁,内部实现了避免死锁的算法。
场景:从一个银行账户向另一个账户转账,需要同时锁定两个账户
❌ 错误示范:按不同顺序锁定互斥锁
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>struct Account {int balance;std::mutex mtx;
};void transfer(Account& from, Account& to, int amount) {// 线程1: transfer(acc1, acc2, 10);// 线程2: transfer(acc2, acc1, 20);std::lock_guard<std::mutex> lock_from(from.mtx); // T1 锁 acc1, T2 锁 acc2std::this_thread::sleep_for(std::chrono::milliseconds(10));std::lock_guard<std::mutex> lock_to(to.mtx); // T1 等待 acc2, T2 等待 acc1 -> 死锁!from.balance -= amount;to.balance += amount;std::cout << "转账 " << amount << " 完成" << std::endl;
}int main() {Account acc1{100}, acc2{50};// 并发执行两个方向相反的转账std::thread t1(transfer, std::ref(acc1), std::ref(acc2), 10);std::thread t2(transfer, std::ref(acc2), std::ref(acc1), 20);t1.join();t2.join();std::cout << "acc1 余额: " << acc1.balance << ", acc2 余额: " << acc2.balance << std::endl;return 0; // 程序很可能无法执行到这里
}
问题:两个线程以相反的顺序请求锁,形成了循环等待,导致死锁。
✅ 正确示范:使用 std::scoped_lock
#include <iostream>
#include <thread>
#include <mutex>struct AccountSafe {int balance;std::mutex mtx;
};void safe_transfer(AccountSafe& from, AccountSafe& to, int amount) {// std::scoped_lock 会以一种无死锁的方式同时锁定两个互斥锁std::scoped_lock lock(from.mtx, to.mtx);from.balance -= amount;to.balance += amount;std::cout << "转账 " << amount << " 完成" << std::endl;
}int main() {AccountSafe acc1{100}, acc2{50};std::thread t1(safe_transfer, std::ref(acc1), std::ref(acc2), 10);std::thread t2(safe_transfer, std::ref(acc2), std::ref(acc1), 20);t1.join();t2.join();std::cout << "acc1 余额: " << acc1.balance << ", acc2 余额: " << acc2.balance << std::endl;return 0;
}
优势:无论传入参数的顺序如何,std::scoped_lock
都能保证以一种安全、确定的内部顺序来锁定互斥锁,从而从根本上消除了死锁的可能性。
5. 明智地使用原子操作
结论:“std::atomic
是性能优化的利器,但仅适用于简单的原子操作。复杂的逻辑仍需互斥锁。”
解读:对于像 counter++
这样非常简单的操作,使用互斥锁的开销(涉及操作系统内核)相对较大。std::atomic
使用特殊的 CPU 指令来完成,速度快得多。但它只能保护对单个变量的单个操作。
场景:实现一个多线程共享的计数器
❌ 潜在的低效示范:使用互斥锁保护简单计数器
#include <iostream>
#include <thread>
#include <vector>
#include <mutex>long long counter_mutex = 0;
std::mutex mtx_counter;void increment_with_mutex() {for (int i = 0; i < 100000; ++i) {std::lock_guard<std::mutex> lock(mtx_counter);counter_mutex++;}
}
// 这段代码是正确的,但在高竞争环境下可能不是最高效的。
✅ 正确且高效的示范:使用 std::atomic
#include <iostream>
#include <thread>
#include <vector>
#include <atomic>std::atomic<long long> atomic_counter(0);void increment_with_atomic() {for (int i = 0; i < 100000; ++i) {atomic_counter.fetch_add(1); // 这是一个原子操作,比锁更轻量}
}int main() {std::vector<std::thread> threads;for (int i = 0; i < 10; ++i) {threads.emplace_back(increment_with_atomic);}for (auto& t : threads) {t.join();}std::cout << "原子计数器最终值: " << atomic_counter << std::endl;return 0;
}
优势:对于这种简单的计数场景,std::atomic
的性能远超于使用互斥锁。
反例:何时不能用 std::atomic
?
当你需要保证多个操作的原子性时,必须用互斥锁。
struct Stats {// 我们希望对 a 和 b 的更新是“捆绑”的int a;int b;
};Stats stats;
std::mutex stats_mtx;void update_stats() {// 错误的做法:使用两个 atomic// std::atomic<int> atomic_a, atomic_b;// atomic_a++; // 这步是原子的// atomic_b--; // 这步也是原子的// 但这两步之间可能被其他线程打断!外部观察者可能看到一个不一致的状态。// 正确的做法:用一个锁保护整个复合操作std::lock_guard<std::mutex> lock(stats_mtx);stats.a++;stats.b--;
}
结论:std::atomic
用于单变量的简单操作,std::mutex
用于保护一个代码块(临界区)内的复合操作。