C++11 标准库对并发编程进行了系统性重构,引入了 <thread>、<mutex>、<condition_variable> 等核心头文件,让开发者无需依赖平台特定的 pthread 或 Win32 API,即可编写可移植的多线程代码。在此之前,C++ 开发者通常需要借助 Boost.Thread 或者直接调用操作系统底层 API,不仅代码可移植性差,而且容易因平台差异引入难以排查的 Bug。本文将从 std::thread 的创建与管理讲起,逐步深入到互斥量的各种类型、条件变量的正确使用方式,以及经典的生产者-消费者模式的完整实现,帮助读者建立扎实且实用的 C++ 并发编程基础。
std::thread:线程的创建与管理
四种可调用对象的线程创建方式
std::thread 的构造函数接受任意可调用对象(Callable),这一设计使得线程的启动方式非常灵活。可调用对象包括普通函数、Lambda 表达式、成员函数以及函数对象(Functor)。无论选择哪种方式,std::thread 的构造函数都会将可调用对象复制到线程的内部存储中,然后在新创建的线程上调用它。
#include <iostream>
#include <thread>
#include <functional>
// 1. 普通函数
void plain_function(int x) {
std::cout << "Plain function: " << x << "\n";
}
// 2. Lambda 表达式
void create_lambda_thread() {
std::thread t([](int x) {
std::cout << "Lambda: " << x << "\n";
}, 42);
t.join();
}
// 3. 函数对象(Functor)
class Functor {
public:
void operator()(int x) const {
std::cout << "Functor: " << x << "\n";
}
};
// 4. 成员函数
class Worker {
public:
void do_work(int id) {
std::cout << "Member: worker " << id << "\n";
}
};
int main() {
// 普通函数
std::thread t1(plain_function, 1);
// Lambda
std::thread t2([](int x) {
std::cout << "Lambda main: " << x << "\n";
}, 2);
// 函数对象
std::thread t3(Functor{}, 3);
// 成员函数:需要传递对象实例作为首参数
Worker w;
std::thread t4(&Worker::do_work, &w, 4);
// 或使用 std::ref 传递对象引用
std::thread t4b(&Worker::do_work, std::ref(w), 4);
t1.join(); t2.join(); t3.join(); t4.join(); t4b.join();
return 0;
}
成员函数绑定是一个常见易错点。std::thread 在内部会解引用所有指针参数并创建副本,因此第一个参数必须是对象实例(传值、传指针或传 std::ref),随后才是成员函数的参数。如果你传递的是一个临时对象,std::thread 会在新线程中持有该对象的副本,从而保证对象的生命周期足够长。但如果误传了即将销毁的局部对象指针,就会制造悬空引用隐患。值得特别注意的是,函数对象在传入时会被复制一次,如果你的函子内部持有大量数据,这种复制开销不容忽视。对于无需状态的可调用对象,优先考虑普通函数或 Lambda;需要状态时,则需要权衡复制成本与可读性两者之间的关系。
参数传递的语义陷阱
std::thread 构造函数对所有额外参数默认执行按值传递并复制,即使原参数本身是引用类型。C++ 这样设计的初衷是保护参数的线程安全性,因为若参数源自于调用者的栈帧,而调用者线程在子线程尚未使用前就已退出栈帧,那么引用就会指向无效内存。
若你确实需要在子线程中修改调用者作用域的变量,必须显式使用 std::ref 或 std::cref 包装引用参数。若需要传递独占资源,则应使用 std::move 实现转移语义,避免不必要的深拷贝。
#include <iostream>
#include <thread>
#include <string>
#include <memory>
void modify_string(std::string& s) {
s += " modified";
}
void take_ownership(std::unique_ptr<int> p) {
std::cout << "Owned: " << *p << "\n";
}
void accept_const_ref(const std::string& s) {
std::cout << "Received: " << s << "\n";
}
int main() {
std::string msg = "hello";
// 错误:编译器会拒绝,因为线程内部创建的临时副本无法绑定到非常量左值引用
// std::thread t1(modify_string, msg);
// 正确:使用 std::ref 显式传递引用
std::thread t1(modify_string, std::ref(msg));
t1.join();
std::cout << msg << "\n"; // 输出: hello modified
// 常量引用可以绑定到临时对象,但仍然是按值复制的副本
std::thread t1b(accept_const_ref, msg);
t1b.join();
// 正确:使用 std::move 转移所有权
auto ptr = std::make_unique<int>(100);
std::thread t2(take_ownership, std::move(ptr));
t2.join();
// ptr 在此已为空,所有权已转移至线程内部
return 0;
}
另一个容易忽略的细节是传递 C 风格数组。由于数组在作为参数时会退化为指针,std::thread 只会复制指针本身而不会复制数组内容。如果数组是局部变量且线程需要异步访问,必须改用 std::array、std::vector 或 std::string 等能够安全按值复制的容器类型。对于大型缓冲区,最佳实践是在主线程中进行一次 std::move 转移或者直接使用堆分配的智能指针类型,这样既能保证数据安全传递,又能避免栈拷贝带来的性能瓶颈。
线程生命周期:join 与 detach
每个 std::thread 对象在销毁时其底层线程必须处于已 join 或已 detach 的状态,否则析构函数会调用 std::terminate() 导致程序异常终止。这一设计是强制的,因为未决的线程如果任其悬置,将造成资源泄漏;如果强行销毁,又会导致未定义行为。C++ 标准库选择了最保守的策略:直接终止进程,迫使开发者显式处理线程生命周期。
#include <thread>
#include <iostream>
void background_task() {
std::cout << "Running in background\n";
}
int main() {
std::thread t(background_task);
if (t.joinable()) {
t.join(); // 阻塞等待线程结束,回收资源
// t.detach(); // 或分离,让线程在后台独立运行
}
return 0;
}
join() 会阻塞当前线程,直到被关联的线程执行完毕。这意味着如果你在一个线程上调用 join(),当前线程将暂停执行,纯粹等待目标线程的完成。这种方式非常适合需要确定子线程执行结果后再继续主逻辑的场景。detach() 则完全不同,它将线程与 std::thread 对象解绑,使线程转为守护线程在后台独立运行。被 detach() 的线程不再受原对象的控制,也无法再通过任何句柄与之交互。这意味着你必须确保被分离的线程具备完整的自给自足的执行环境,不能访问任何可能先于线程结束而被销毁的资源,例如栈局部变量或者主线程中创建但随后离开作用域的临时对象。
在实际项目中,join() 的使用频率远高于 detach(),因为大多数并发任务最终都需要汇聚结果。detach() 更适合那些真正可以"发射后不管"的后台守护任务,比如后台日志刷新线程或者心跳检测线程。
RAII 线程包装器
由于 C++11 的 std::thread 必须在析构前进行 join 或 detach,任何提前返回路径、异常抛出路径或者复杂的分支逻辑中都极易遗漏这一操作。手动管理线程生命周期不仅容易出错,而且会增加代码的维护成本。最稳健的解决方案是使用 RAII 模式包装 std::thread。下面是一个基于《C++ 并发编程实战》一书中的经典实现:
#include <thread>
class ThreadGuard {
std::thread& t;
public:
explicit ThreadGuard(std::thread& th) : t(th) {}
~ThreadGuard() {
if (t.joinable()) {
t.join();
}
}
ThreadGuard(const ThreadGuard&) = delete;
ThreadGuard& operator=(const ThreadGuard&) = delete;
};
在 C++20 中,标准库直接提供了 std::jthread,它在析构时自动调用 join(),并且额外支持通过 std::stop_token 实现合作式取消。如果你的编译器已经支持 C++20,强烈推荐直接使用 std::jthread,它彻底消除了因忘记 join 而导致的程序终止问题。值得一提的是,std::jthread 的移动语义也意味着你可以将它安全地存放在标准容器中,这对实现线程池等高级结构非常有帮助。
线程标识与并发容量
#include <thread>
#include <iostream>
int main() {
std::thread t([]() {
std::cout << "Thread ID: " << std::this_thread::get_id() << "\n";
});
std::cout << "Hardware concurrency: "
<< std::thread::hardware_concurrency() << "\n";
t.join();
return 0;
}
hardware_concurrency() 返回系统当前支持的并发线程数,通常等于逻辑处理器核心数。这个数字主要用于指导线程池的容量设置:如果线程数远超硬件并发能力,频繁的上下文切换反而会降低整体吞吐量;如果线程数过少,则无法充分利用多核资源。std::this_thread::get_id() 获取当前执行线程的唯一标识符,类型为 std::thread::id。该标识符支持比较操作和流输出,可用于日志追踪、调试以及建立线程与资源的映射关系。此外,<thread> 头文件还提供了 std::this_thread::sleep_for() 和 std::this_thread::yield(),前者让线程休眠指定时长,后者提示调度器当前线程愿意让出 CPU 给其他线程。在高并发争用场景下,恰当使用 yield() 可以减少自旋锁的 CPU 占用,但过度使用则会导致频繁的上下文切换开销。
常见错误警示
以下几种错误在多线程 C++ 代码中反复出现,需要特别警惕:
分离后访问栈变量:将
detach()与局部变量引用结合是最危险的错误之一。主线程函数返回后,栈帧被回收,后台线程继续访问这些地址时将产生未定义行为。这种 Bug 往往具有时间上的不确定性,可能在某些运行中下正常执行,在另一些时刻则直接崩溃。忘记 join 导致 terminate:如果
std::thread对象在析构前既没有调用join()也没有调用detach(),标准库会直接调用std::terminate()结束整个进程。使用前述 RAII 包装器或者 C++20 的std::jthread可以从根本上消除此类失误。拷贝非线程安全的参数:向线程传递标准容器或者自定义类的引用时,若原对象正在被其他线程修改,将引发数据竞争。正确做法是在启动线程前完成数据的深拷贝,或者使用
std::move完成所有权的唯一转移。主线程先于子线程退出:当
main函数返回后,所有仍在运行的detach线程会被强制终止,这可能导致部分任务处理到一半就中断。对于有数据持久化或网络通信需求的任务,务必在主线程退出前确保所有子线程已完成或已优雅停止。
互斥量类型:从基本锁到读写锁
std::mutex:最基本的互斥量
std::mutex 提供独占式的互斥访问机制,同一时刻最多只有一个线程能够持有该锁。通过 lock() 和 unlock() 进行手动加锁和解锁虽然在语法层面简单直接,但实际上隐藏了巨大的风险。
#include <mutex>
std::mutex mtx;
int shared_counter = 0;
void increment() {
mtx.lock();
++shared_counter; // 临界区
mtx.unlock();
}
然而,只要临界区内的任何代码抛出异常,或者中间出现了 return 语句,后续的 unlock() 就永远不会执行,造成资源永久锁定。更严重的是,如果该锁被另一个线程以阻塞方式获取,整个程序将陷入死锁。即使当前代码看似没有异常可能,未来的维护和扩展也完全可能在临界区内加入新的函数调用,而这些调用的内部实现可能抛出意料之外的异常。因此,永远避免直接调用 mutex.lock() 和 mutex.unlock(),优先选择 RAII 锁定包装器。这一原则不仅是为了当前的代码正确性,更是为了代码的可维护性和防御性编程。
std::lock_guard:简单可靠的 RAII 锁
std::lock_guard 是 std::mutex 最轻量的 RAII 包装器。它在构造时调用 lock() 加锁,在作用域结束时析构并自动调用 unlock()。由于其实现仅占用一个数据成员(即对互斥量的引用),生成代码的效率与手动调用完全等价,没有运行时额外开销。
#include <mutex>
std::mutex mtx;
int data = 0;
void safe_modify() {
std::lock_guard<std::mutex> lock(mtx);
++data; // 离开作用域自动解锁
}
std::lock_guard 的设计目标就是极简和安全——它甚至连显式的 unlock() 接口都没有公开。这种限制恰恰消除了"提前解锁后因提前返回忘记重新上锁"的错误可能性。但它也因此丧失了灵活性:不支持延迟加锁、不支持条件变量等待、不支持在作用域结束前提前解锁。如果你的代码逻辑需要这些能力,就必须升级到 std::unique_lock。
std::unique_lock:灵活的通用锁包装
std::unique_lock 提供了比 std::lock_guard 丰富得多的接口集合:支持延迟加锁(std::defer_lock)、支持尝试加锁(try_lock())、支持限时加锁(try_lock_for() / try_lock_until()),支持显式提前解锁(unlock()),以及配合条件变量使用的 wait() 系列函数。这些灵活性带来的代价是对象内部需要维护一个标志位记录当前是否持有锁,因此 sizeof(std::unique_lock) 通常大于 sizeof(std::lock_guard)。
#include <mutex>
std::mutex mtx;
int shared_value = 0;
void flexible_locking() {
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// 此时未加锁,可以在此执行上下文校验、日志记录等非关键准备工作
prepare_something();
lock.lock(); // 显式加锁
++shared_value; // 临界区
lock.unlock(); // 显式提前解锁
// 非关键区操作(如 I/O 发送日志),避免长时间持有锁
send_log_async();
lock.lock(); // 再次加锁
validate_state(); // 再次进入临界区
} // 析构时自动解锁(若仍处于锁定状态)
std::unique_lock 是条件变量等待操作的唯一合法搭档,因为条件变量在等待期间需要临时释放互斥量,让其他线程能够进入临界区修改条件状态,然后在被唤醒后重新获取锁。这一"释放-等待-重新获取"的循环无法由 std::lock_guard 实现,因为它的锁一旦获取就无法释放。在实际编码中,只要涉及到条件变量,就应当毫不犹豫地使用 std::unique_lock。
std::shared_mutex:读写锁(C++14/17)
在许多并发场景中,读操作的频率远高于写操作。使用独占锁意味着即使所有线程都在读取同一数据,它们也必须串行执行,这严重浪费了并行性。std::shared_mutex(C++17,<shared_mutex>)提供了一种读写分离的锁机制:多个读者可以同时获取共享所有权,而写者则独占访问。
#include <shared_mutex>
#include <vector>
#include <string>
class ConfigCache {
mutable std::shared_mutex mtx;
std::vector<std::string> config_data;
public:
void update_config(const std::string& new_entry) {
std::unique_lock lock(mtx); // 独占写锁
config_data.push_back(new_entry);
}
std::string query(size_t index) const {
std::shared_lock lock(mtx); // 共享读锁(C++14 起)
return (index < config_data.size()) ? config_data[index] : "";
}
};
需要注意的是,读写锁的适用场景有明确的先决条件:读操作远多于写操作,且单次读写持续时间较长。如果临界区非常短暂,读写锁更复杂的内部实现反而可能引入额外的开销,不如简单的 std::mutex 高效。此外,如果写者频繁到来且读者长时间持有共享锁,写者会被饿死(starvation)。在极端公平性要求的环境中,可能需要自定义的调度策略或者退回到独占锁。
std::timed_mutex:限时获取锁
有时线程不希望无限期地阻塞在锁上,而是希望在超过一定时间后放弃获取并进行降级处理。std::timed_mutex 提供了 try_lock_for() 和 try_lock_until() 两个限时获取接口。
#include <mutex>
#include <chrono>
std::timed_mutex tmtx;
void try_lock_with_timeout() {
if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
// 在 100ms 内成功获取锁
process_critical_section();
tmtx.unlock();
} else {
// 超时,执行回退逻辑:丢弃任务、缓存到队列、返回错误等
handle_timeout_fallback();
}
}
std::timed_mutex 适合实现非阻塞的并发控制策略,例如在高负载系统中若关键资源暂时不可用,可以先处理其他任务而非一直等待。结合 std::unique_lock 的限时加锁能力,可以构建出健壮的限时等待模式,避免系统因某个锁的争用而全局停滞。
条件变量:线程间的精确协作
核心概念
条件变量(Condition Variable)是多线程编程中最精妙的协调原语之一。它解决的问题非常具体:线程 A 需要等待某个条件成立,而线程 B 负责在合适的时候修改该条件并通知等待者。如果没有条件变量,线程 A 只能用自旋的方式反复检查条件,这种"忙等待"策略会浪费大量 CPU 周期。
C++ 标准库提供两个版本:std::condition_variable(必须与 std::unique_lock<std::mutex> 配合使用,内部实现可以利用底层平台相关优化,性能更优)和 std::condition_variable_any(可与任何满足 BasicLockable 接口的对象配合,通用性更强但通常伴随着轻微的性能损失)。在绝大多数情况下,应当优先使用 std::condition_variable。
条件变量的正确工作模式涉及三个参与者:互斥量保护共享条件的读写,一个线程在等待前获取锁并检查条件,若条件不满足则在线程进入睡眠前释放锁;另一个线程在修改条件后获取锁并发送通知;被唤醒的线程在返回前重新获取锁。
等待的两种形式与虚假唤醒
#include <mutex>
#include <condition_variable>
std::mutex mtx;
std::condition_variable cv;
bool ready = false;
void worker() {
std::unique_lock<std::mutex> lock(mtx);
// 形式一:低效,可能因虚假唤醒而提前返回
// cv.wait(lock);
// 形式二:正确,使用谓词防止虚假唤醒
cv.wait(lock, [] { return ready; });
// 条件满足,继续执行
perform_work();
}
void notifier() {
{
std::lock_guard<std::mutex> lock(mtx);
ready = true;
}
cv.notify_one(); // 或 notify_all()
}
虚假唤醒(Spurious Wakeup) 是一个多线程编程中非常关键的底层现象。POSIX 线程和 Windows 线程在实现条件变量时,出于效率或信号中断等原因,被 wait 的线程有时会在没有人调用 notify 的情况下自行醒来。如果不加检查地认为条件已满足并继续执行,将直接导致逻辑错误。因此,永远使用带谓词的 wait 重载,该重载在底层会循环检查条件,在条件确实成立后才返回调用者。
一个常见的实现错误是:先调用 notify,后修改条件标志。由于通知和条件修改之间没有同步,等待线程可能在条件尚未修改时就被唤醒,醒来后检查条件发现仍然不满足,于是再次进入等待——但这一轮等待可能再也不会被人唤醒了(因为通知已经提前发出过了)。正确的顺序永远是:先在锁的保护下修改条件,再释放锁,最后调用通知。
notify_one 与 notify_all 的选择
通知策略的选择直接影响并发程序的吞吐量和正确性:
notify_one():唤醒一个等待线程。适用于只有一个线程能够继续处理资源的情况,例如单一生产者、单一消费者的队列。使用notify_one()可以减少线程从无意义地全部唤醒并竞争锁带来的开销。notify_all():唤醒所有等待线程。适用于条件变化后可能有多个线程都能投入工作的情况,例如线程池的任务队列有新的任务到来,或者某个全局状态从"不可用"变为"可用",同时等待的多个消费者都可以开始处理。被唤醒的线程们会竞争互斥量,未抢到锁的线程会自动重新进入等待状态。
作为默认策略,通常推荐优先使用 notify_all(),因为它的语义更保守、更不容易遗漏。只有在经过严格的性能测试和分析后,确认 notify_one() 确实能显著提升性能且不影响正确性时,才应当将其作为优化手段引入。特别是在多生产者或多消费者场景中,若消费者数量多于任务数,部分消费者被唤醒后可能发现资源已被其他消费者获取,此时使用 notify_one() 可能导致本应有更多工作的消费者得不到通知。这种情况下 notify_all() 反而更简单可靠。
生产者-消费者模式:完整实现
生产者-消费者模式是多线程编程中最具代表性的协同问题之一。典型的应用场景包括:网络服务器中接收线程(生产者)将请求放入队列,工作线程池(消费者)从队列中取出并处理请求;日志系统中主线程产生日志消息,后台线程异步写入磁盘文件。
下面的实现展示了一个有界阻塞队列,它使用双条件变量实现生产者与消费者在队列状态变化时的精确睡眠与唤醒,避免了无意义的自旋消耗。
#include <queue>
#include <mutex>
#include <condition_variable>
#include <optional>
#include <iostream>
#include <thread>
// 线程安全的有界阻塞队列
template <typename T>
class BlockingQueue {
public:
explicit BlockingQueue(size_t capacity) : capacity_(capacity) {}
// 生产者:入队,队列满时阻塞等待
void push(T value) {
std::unique_lock<std::mutex> lock(mtx_);
not_full_.wait(lock, [this] { return queue_.size() < capacity_; });
queue_.push(std::move(value));
not_empty_.notify_one();
}
// 消费者:出队,队列空时阻塞等待
T pop() {
std::unique_lock<std::mutex> lock(mtx_);
not_empty_.wait(lock, [this] { return !queue_.empty(); });
T value = std::move(queue_.front());
queue_.pop();
not_full_.notify_one();
return value;
}
// 优雅关闭:唤醒所有等待线程,让消费者有机会退出
void shutdown() {
std::lock_guard<std::mutex> lock(mtx_);
shutdown_ = true;
not_empty_.notify_all();
not_full_.notify_all();
}
// 支持优雅关闭的 pop 变体
std::optional<T> try_pop() {
std::unique_lock<std::mutex> lock(mtx_);
not_empty_.wait(lock, [this] { return !queue_.empty() || shutdown_; });
if (queue_.empty()) {
return std::nullopt; // 表示队列已关闭,消费者应退出
}
T value = std::move(queue_.front());
queue_.pop();
not_full_.notify_one();
return value;
}
bool is_shutdown() const {
std::lock_guard<std::mutex> lock(mtx_);
return shutdown_;
}
private:
std::queue<T> queue_;
std::mutex mtx_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
size_t capacity_;
bool shutdown_ = false;
};
// 使用示例:单生产者单消费者
int main() {
BlockingQueue<int> queue(10);
std::thread producer([&queue]() {
for (int i = 0; i < 20; ++i) {
queue.push(i);
std::cout << "Produced: " << i << "\n";
}
queue.shutdown();
});
std::thread consumer([&queue]() {
while (auto val = queue.try_pop()) {
std::cout << "Consumed: " << *val << "\n";
}
std::cout << "Consumer exiting gracefully.\n";
});
producer.join();
consumer.join();
return 0;
}
此实现包含以下核心设计决策:
双条件变量分离:
not_empty_处理消费者等待队列非空、not_full_处理生产者等待队列非满。这种分离使得生产者唤醒只触发可能等待的消费者,反之亦然,避免了全员唤醒带来的惊群效应。谓词等待:所有
wait调用均提供谓词 lambda,即使因虚假唤醒而醒来,也会重新检查条件,不满足则继续等待,确保逻辑绝对正确。优雅关闭机制:
shutdown()在互斥量保护下修改关闭标志,然后唤醒所有线程。消费者的try_pop()在每次醒来时同时检查队列非空或已关闭两个条件,这样就能够在生产者完成所有工作并调用shutdown()后干净地退出循环,不会发生无限阻塞。移动语义优化:
std::move贯穿入队和出队全过程,对于持有大量数据的复杂对象(如图像数据、网络请求包)可以避免多次深拷贝。
若将此单生产者-单消费者模型扩展到多生产者-多消费者场景,push() 和 try_pop() 的接口本身无需改动,但应当注意 notify_one() 在某些边界条件下可能导致某些生产者或消费者被遗漏。例如,两个生产者同时入队且队列恰好从满变为不满时,只调用一次 notify_one() 只能唤醒一个可能在等待的生产者,而另一个继续等待的生产者需要等待下一次通知。在多生产者的场景中,可以考虑在从满状态变为非满状态时调用 notify_all() 来确保公平性。
线程安全实践指南
优先使用 RAII 锁定包装器
绝对避免裸调用 mutex.lock() / mutex.unlock()。即使当前的代码逻辑非常简单直观,未来维护中添加异常抛出点、提前返回语句或新的分支路径时,手动管理锁状态引入死锁的概率会指数级上升。std::lock_guard 和 std::unique_lock 不仅是零代价抽象,更是防御性编程的基石。在任何代码审查中,看到裸的 lock() / unlock() 都应当视为需要修正的重度警告。
避免在持有锁期间执行耗时操作
I/O 操作(磁盘读写、网络通信)、复杂计算、调用外部不可控的回调函数,这些操作都可能花费毫秒甚至秒级别的时间。锁保护的应当是最小化的共享状态访问临界区,其余工作应在无锁状态下进行。
void bad_practice() {
std::lock_guard<std::mutex> lock(mtx);
auto data = shared_data;
write_to_disk(data); // 危险:I/O 期间一直持有锁,其他线程全部阻塞
}
void good_practice() {
std::vector<int> data_copy;
{
std::lock_guard<std::mutex> lock(mtx);
data_copy = shared_data; // 仅在锁内快速复制数据
}
write_to_disk(data_copy); // 无锁状态下执行 I/O
}
这种"先复制再处理"的策略有时被称为"锁外拷贝模式"(Copy-Out Pattern)。它的代价是一次数据复制的开销,但换来的并发度提升通常远超复制成本。唯一需要注意的是,如果共享数据结构非常庞大,复制本身也可能消耗大量时间,此时可能需要考虑使用引用计数智能指针(如 std::shared_ptr)来减少实际数据的拷贝量,但引用计数的原子操作虽然比锁轻量,也并非完全免费。
锁顺序一致性防止死锁
当多个线程需要同时获取多个锁时,死锁的经典条件就是循环等待。打破这一条件的最有效方法,是在任何代码路径中以严格相同的顺序获取所有互斥量。
struct Account {
std::mutex mtx;
int balance = 0;
};
std::mutex global_lock_order_mtx;
void transfer(Account& from, Account& to, int amount) {
// 按内存地址排序,保证全局一致的加锁顺序
Account* first = &from;
Account* second = &to;
if (first > second) {
std::swap(first, second);
}
std::lock_guard<std::mutex> lock1(first->mtx);
std::lock_guard<std::mutex> lock2(second->mtx);
from.balance -= amount;
to.balance += amount;
}
C++11 还提供了 std::lock(mtx1, mtx2, ...),这是一个无死锁的多锁获取工具。它内部使用一种避免死锁的算法(常见的实现是同时尝试锁定所有互斥量,失败时释放已获取的锁并重新开始),确保不会因为加锁顺序不同而产生死锁。获取成功后,锁仍然需要被管理,因此需要配合 std::adopt_lock 参数将已锁定的互斥量转交给 std::lock_guard 或 std::unique_lock:
std::lock(mtx_a, mtx_b);
std::lock_guard<std::mutex> lock_a(mtx_a, std::adopt_lock);
std::lock_guard<std::mutex> lock_b(mtx_b, std::adopt_lock);
这种方式非常优雅:既避免了手写排序逻辑的繁琐,又享受到了 RAII 自动管理带来的安全性。在需要同时获取两个以上互斥量的复杂场景中,std::lock() 应当成为首选方案。
线程安全初始化
许多单例对象或者重量级资源在程序运行期间只需要被初始化一次。C++11 规定,静态局部变量的初始化是线程安全的,由编译器自动插入同步机制。这是实现双重检查锁定(Double-Checked Locking)模式最简洁的方式:
class ExpensiveResource {
public:
static ExpensiveResource& getInstance() {
static ExpensiveResource instance; // C++11 起保证线程安全
return instance;
}
private:
ExpensiveResource() { /* 耗时初始化 */ }
// ...
};
如果你的初始化逻辑比较复杂(例如需要根据运行时配置选择不同的实现),或者初始化过程本身需要访问外部资源并可能失败,则可以使用 std::call_once 进行显式的一次性初始化控制:
#include <mutex>
#include <memory>
std::once_flag init_flag;
std::unique_ptr<ExpensiveResource> resource_ptr;
void init_resource() {
resource_ptr = std::make_unique<ExpensiveResource>();
resource_ptr->load_config_from_file("config.json");
}
void access_resource() {
std::call_once(init_flag, init_resource);
// 初始化完成后,安全使用 resource_ptr
resource_ptr->do_work();
}
std::call_once 保证初始化函数在多个线程竞争时只被执行一次,且其他线程会阻塞等待初始化完成后才继续执行。这比手写 std::mutex 加布尔标志的方式更安全、更高效,因为标准库的实现通常针对这一特定场景进行了优化,避免了额外的分支预测失败开销。
小结
C++11 及后续标准提供的并发设施为开发者搭建了一套强大而简洁的抽象体系。掌握 std::thread 的生命周期管理、理解可调用对象的绑定方式与参数传递语义,是编写正确多线程代码的基石。在此基础上,学会利用 std::lock_guard 和 std::unique_lock 进行安全可靠的互斥访问,理解条件变量在精准协作中扮演的关键角色,就能够独立实现经典的生产者-消费者模型,并在其基础上扩展出线程池、异步任务调度等更复杂的功能。
并发编程的本质是在性能、正确性和可维护性之间寻找平衡。始终牢记以下基本原则:最小化锁的粒度与持有时间、在任何场合保持全局一致的锁顺序、优先使用编译器和标准库提供的线程安全保证而非自行发明轮子。这些原则不仅能帮助你在当前项目中写出健壮的并发代码,更能让你在阅读和理解大型 C++ 项目(如数据库引擎、游戏服务器、高频交易系统)的并发架构时,具有更深刻的洞察力和更强的工程判断力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。