C++线程安全队列:从互斥锁到条件变量的生产者-消费者模式实现
1. 项目概述:为什么我们需要一个线程安全队列?
在C++多线程编程里,数据共享是个绕不开的坎。想象一下,你有一个生产者线程在源源不断地生成数据(比如从网络接收数据包),同时有多个消费者线程需要处理这些数据。如果直接把数据塞进一个普通的std::queue里,不出几秒钟,程序大概率就会崩溃或者产生难以追踪的诡异结果。原因很简单,std::queue本身不是线程安全的,多个线程同时进行push和pop操作,会导致内部数据结构(比如指针)的竞争条件,这就是数据竞争的典型场景。
线程安全队列(Thread-Safe Queue)就是为了解决这个问题而生的。它本质上是一个封装了同步原语(如互斥锁std::mutex和条件变量std::condition_variable)的队列容器。它的核心目标就一个:保证在任何时候,无论有多少个线程并发访问,队列的push(入队)和pop(出队)操作都是原子的、有序的,且不会破坏数据完整性。这不仅仅是“能用”,更是构建健壮、高效并发系统的基石。无论是实现一个高性能的网络服务器、一个并行计算框架,还是一个需要后台任务调度的桌面应用,一个设计良好的线程安全队列都是核心组件之一。
我自己在开发一个日志收集系统时就深有体会。日志产生非常频繁,如果每个日志写入都直接操作文件或数据库,I/O会成为巨大瓶颈。我的解决方案就是用一个线程安全队列作为缓冲区:主线程快速将日志条目push进队列,然后由一个独立的消费者线程从队列中wait_and_pop,批量写入磁盘。这样,生产者和消费者解耦,主线程不会被慢速I/O阻塞,系统的吞吐量和响应速度得到了质的提升。接下来,我就把自己从零设计并实现一个C++11/14风格的线程安全队列的完整过程、踩过的坑和优化心得分享出来。
2. 核心设计思路与方案选型
设计一个线程安全队列,远不止是给std::queue套个锁那么简单。你需要考虑接口设计、性能瓶颈、异常安全,甚至未来向无锁队列演进的可能性。我的设计目标是:接口清晰易用、保证强线程安全、在常规场景下性能达标,并且代码健壮,能处理好边界情况。
2.1 锁方案 vs. 无锁方案
这是首先要做的抉择。网络热词里提到了“dpdk无锁队列实现”,无锁队列通过原子操作(如CAS)实现同步,避免了线程阻塞,在极高并发、争用激烈的场景下性能优势明显。但是,无锁算法的实现极其复杂,容易出错,调试困难,并且对于“多生产者-多消费者”这种通用模型,一个正确且高效的无锁队列实现本身就是一项艰巨的任务。
对于绝大多数应用场景,包括高性能网络服务,基于锁的线程安全队列已经足够优秀,并且是更稳妥、更易维护的选择。C++11标准库提供的std::mutex(互斥锁)和std::condition_variable(条件变量)是构建这类队列的利器。它们经过了充分优化和测试,能提供可靠的同步语义。因此,我决定采用“互斥锁+条件变量”的经典方案,先实现一个健壮、可用的版本,这也是业界最普遍的做法。无锁方案可以作为性能优化到极致时的备选,但不应该成为初版设计的负担。
2.2 接口设计:模仿与扩展标准库
一个好的类库,其接口应该符合用户的直觉。std::queue的接口非常简洁:push,pop,front,back,empty,size。我们的线程安全队列应该在此基础上,提供线程安全的版本,并增加多线程编程所需的特定操作。
我设计的核心接口如下:
void push(T new_value): 线程安全的入队操作。bool try_pop(T& value): 非阻塞出队。如果队列为空,立即返回false;否则取出队首元素到value,并返回true。void wait_and_pop(T& value): 阻塞出队。如果队列为空,则调用线程被阻塞(睡眠),直到有数据被push进来,然后取出数据。std::shared_ptr<T> wait_and_pop():wait_and_pop的另一个版本,返回一个shared_ptr,避免拷贝开销,也更安全。std::shared_ptr<T> try_pop(): 同理,try_pop的智能指针版本。bool empty() const: 判断队列是否为空。注意:这个操作在并发环境下意义有限,因为在你判断empty()之后,队列状态可能已经改变。它更多用于辅助或非关键判断。size_t size() const: 获取队列大小。同样存在“瞬间状态”的问题。
这里的关键是区分了阻塞(wait_and_pop) 和非阻塞(try_pop) 两种出队模式。生产者-消费者模式中,消费者通常使用wait_and_pop,在没有数据时安心等待,避免忙等待(busy-waiting)消耗CPU。而在一些需要尝试性获取数据或具有超时逻辑的场景,try_pop就非常有用。提供智能指针版本的pop,是为了高效地传递可能包含大量数据的对象,或者对象本身拷贝代价很高的情况。
2.3 数据结构与锁的粒度
内部容器我选择std::queue<T>。当然,你也可以用std::deque或std::list,但std::queue作为适配器,接口清晰,符合FIFO(先进先出)的队列语义,是最自然的选择。
锁的粒度是需要精心考虑的。一个粗暴的做法是:每个公有成员函数都用一个std::lock_guard锁住整个函数。这能保证线程安全,但可能会影响并发度。更精细的做法是:
- 使用一个互斥锁(
mutable std::mutex mut)来保护整个内部队列(std::queue<T> data_queue)的所有访问。 - 使用一个条件变量(
std::condition_variable data_cond)用于在队列为空时阻塞消费者线程。
这里mut被声明为mutable,是因为empty()和size()这两个const成员函数内部也需要加锁来读取状态,mutable关键字允许在const函数中修改互斥锁(加锁/解锁操作改变了互斥锁的内部状态,但对队列对象的逻辑状态没有影响)。
注意:有同学可能会想用更细粒度的锁,比如入队锁和出队锁分开。对于
std::queue这样的结构,其push和pop操作都可能修改头尾指针,使用两把锁很容易导致死锁或状态不一致,实现复杂度激增。在绝大多数情况下,一把锁保护整个队列是简单且正确的选择。性能瓶颈往往不在于锁的粒度,而在于锁的争用频率。如果争用成为问题,首先应该考虑的是优化任务划分或使用多个队列(分片),而不是冒险实现一个脆弱的细粒度锁队列。
3. 核心实现细节与代码解析
下面,我将结合代码,逐部分拆解实现。我会使用C++14的语法,因为它能让我们写出更简洁安全的代码(如std::make_unique)。
3.1 类的基本骨架与数据成员
#include <queue> #include <mutex> #include <condition_variable> #include <memory> // for std::shared_ptr template<typename T> class threadsafe_queue { private: mutable std::mutex mut; // 互斥锁,mutable用于const成员函数 std::queue<T> data_queue; // 内部数据队列 std::condition_variable data_cond; // 条件变量 public: threadsafe_queue() = default; threadsafe_queue(const threadsafe_queue& other) { std::lock_guard<std::mutex> lk(other.mut); data_queue = other.data_queue; } threadsafe_queue& operator=(const threadsafe_queue&) = delete; // 禁止赋值,简化实现 void push(T new_value); bool try_pop(T& value); std::shared_ptr<T> try_pop(); void wait_and_pop(T& value); std::shared_ptr<T> wait_and_pop(); bool empty() const; size_t size() const; };要点解析:
- 模板类:
template<typename T>使其能容纳任意类型的数据。 - 拷贝构造函数:我们提供了线程安全的拷贝构造。注意,它锁定了源对象(
other) 的互斥锁,防止在拷贝过程中other被修改。这是一个很好的实践,保证了拷贝操作的原子性视图。 - 删除赋值运算符:我选择禁止赋值操作(
= delete)。因为实现一个线程安全的、异常安全的拷贝赋值运算符非常棘手(需要考虑自赋值、锁两个对象等)。在并发环境下,移动语义或直接传递指针/引用是更清晰的选择。这简化了代码,避免了潜在的错误。 - 数据成员:一个锁、一个队列、一个条件变量,结构清晰。
3.2 入队操作push的实现
push操作相对简单,它需要:
- 加锁保护队列。
- 将数据压入队列。
- 通知一个正在等待的消费者线程(如果有的话)。
template<typename T> void threadsafe_queue<T>::push(T new_value) { // 1. 构造数据。可能在锁外进行,如果T的构造很昂贵的话。 // 2. 加锁,保护共享数据。 std::lock_guard<std::mutex> lk(mut); // 3. 将数据压入内部队列。 data_queue.push(std::move(new_value)); // 使用移动语义,避免不必要的拷贝 // 4. 通知一个等待的线程。通知操作不需要持有锁,事实上在锁释放前通知更好。 data_cond.notify_one(); }关键细节与心得:
std::lock_guard: 使用std::lock_guard在函数作用域内自动加锁,函数返回或异常时自动解锁,保证了异常安全(RAII原则)。- 移动语义:
data_queue.push(std::move(new_value))。这里使用了移动语义。调用者传递的new_value是一个右值引用(如果传临时对象)或者我们主动将其转为右值。这避免了对于大型对象的一次深拷贝,提升了性能。这是C++11/14带来的重要优化手段。 notify_one()vsnotify_all(): 这里使用data_cond.notify_one()。它只唤醒一个正在wait的消费者线程。这通常是正确的,因为一个数据项只需要一个消费者来处理。如果你有多个消费者线程且希望它们都能被通知(例如在实现“广播”模式时),可以使用notify_all(),但要注意这可能引发“惊群效应”(thundering herd),导致不必要的上下文切换。在典型的生产者-消费者模型中,notify_one()是更高效的选择。
3.3 阻塞出队wait_and_pop的实现
这是队列的核心,也是最体现同步机制的地方。消费者线程调用wait_and_pop时,如果队列为空,它应该被挂起,直到有数据可用。
template<typename T> void threadsafe_queue<T>::wait_and_pop(T& value) { std::unique_lock<std::mutex> lk(mut); // 使用条件变量的wait方法。lambda表达式是等待条件。 data_cond.wait(lk, [this]{ return !data_queue.empty(); }); // 当wait返回时,锁已被重新获取,且队列保证非空。 value = std::move(data_queue.front()); // 移动赋值,高效 data_queue.pop(); } template<typename T> std::shared_ptr<T> threadsafe_queue<T>::wait_and_pop() { std::unique_lock<std::mutex> lk(mut); data_cond.wait(lk, [this]{ return !data_queue.empty(); }); // 创建shared_ptr,指向队列前端元素。 std::shared_ptr<T> res(std::make_shared<T>(std::move(data_queue.front()))); data_queue.pop(); return res; // 返回指针,无需锁,因为数据已取出。 }关键细节与心得:
std::unique_lock: 为什么这里用std::unique_lock而不是std::lock_guard?因为std::condition_variable::wait操作需要暂时释放锁并将线程挂起,当被唤醒时再重新获取锁。std::unique_lock提供了更灵活的锁管理(lock,unlock,try_lock),而std::lock_guard在构造时锁定,析构时解锁,期间不能手动操作,因此不适用于条件变量。- 带条件的等待:
data_cond.wait(lk, predicate)。这是C++11条件变量的标准用法。这里的predicate是一个可调用对象(这里用了lambda),它返回bool。wait的内部逻辑相当于:
使用这个“条件循环”是为了防止虚假唤醒(spurious wakeup)。操作系统可能在没有其他线程调用while (!predicate()) { lk.unlock(); // 线程在此处挂起,等待通知... // 被 notify_one/all 唤醒后... lk.lock(); }notify的情况下唤醒等待的线程。通过检查条件(队列非空),我们可以确保被唤醒时条件真正满足。这是编写健壮条件变量代码的黄金法则。 - 异常安全: 在
wait返回后,我们持有锁,队列非空。data_queue.front()和data_queue.pop()是noexcept或基本保证的。如果T的移动赋值或移动构造抛出异常,异常会传播出去,但队列状态保持不变(元素未被成功取出),这仍然是安全的。调用者需要处理这种异常。 - 智能指针版本的优势: 第二个版本直接返回
std::shared_ptr<T>。这样做有几个好处:(1) 避免了返回前对T的拷贝(如果T不可移动或移动代价高);(2) 所有权清晰,资源管理自动化;(3) 返回值时锁已经释放,不会将锁的持有时间延长到函数体外,减少了锁的争用时间。这是更推荐的做法,尤其是在T对象较大时。
3.4 非阻塞出队try_pop的实现
try_pop的逻辑更直接:检查队列,有数据就取走,没有就立即返回。
template<typename T> bool threadsafe_queue<T>::try_pop(T& value) { std::lock_guard<std::mutex> lk(mut); if(data_queue.empty()) return false; value = std::move(data_queue.front()); data_queue.pop(); return true; } template<typename T> std::shared_ptr<T> threadsafe_queue<T>::try_pop() { std::lock_guard<std::mutex> lk(mut); if(data_queue.empty()) return std::shared_ptr<T>(); // 返回空指针 std::shared_ptr<T> res(std::make_shared<T>(std::move(data_queue.front()))); data_queue.pop(); return res; }关键细节与心得:
- 锁的持有时间:
try_pop非常快,因为它不涉及线程挂起。使用std::lock_guard足矣。 - 返回值设计: 第一个版本通过输出参数
value返回数据,并通过bool返回值指示成功与否。第二个版本通过返回空或非空的shared_ptr来指示状态。后者更符合C++的现代风格,也避免了默认构造T对象的开销(如果T没有默认构造函数,第一个版本甚至无法编译)。
3.5 辅助接口empty和size
template<typename T> bool threadsafe_queue<T>::empty() const { std::lock_guard<std::mutex> lk(mut); return data_queue.empty(); } template<typename T> size_t threadsafe_queue<T>::size() const { std::lock_guard<std::mutex> lk(mut); return data_queue.size(); }重要提醒: 这两个函数在并发编程中用处有限。因为你调用empty()得到true后,可能立刻就有另一个线程push了数据。所以,你不能依赖empty()的结果来做后续的决策(比如“如果队列空就做别的事”)。它们的主要用途是监控、调试或者在不那么严格的场景下提供参考信息。真正的同步应该依赖wait_and_pop或循环检查try_pop。
4. 高级话题与性能优化考量
实现一个基础可用的线程安全队列后,我们可以思考如何让它更强大、更高效。
4.1 支持移动语义的push
我们之前的push接受的是T new_value,这会调用一次拷贝或移动构造。我们可以提供右值引用版本的重载,让接口更高效。
template<typename T> void threadsafe_queue<T>::push(T&& new_value) { std::lock_guard<std::mutex> lk(mut); data_queue.push(std::move(new_value)); // 这里new_value是右值引用 data_cond.notify_one(); }这样,用户可以直接queue.push(MyData{...}),避免一次额外的移动构造。编译器会根据实参类型自动选择调用哪个push。
4.2 使用std::shared_ptr内部存储
这是一个重要的优化技巧。我们之前存储的是T对象本身。这意味着在push时,数据从用户处拷贝或移动到队列内部;在pop时,又从队列内部移动或拷贝到用户处。如果T对象很大,这两次数据移动可能成为瓶颈。
我们可以修改内部队列,存储std::shared_ptr<T>。
template<typename T> class threadsafe_queue_ptr { private: mutable std::mutex mut; std::queue<std::shared_ptr<T>> data_queue; std::condition_variable data_cond; public: threadsafe_queue_ptr() = default; void push(T new_value) { // 在锁外构造shared_ptr std::shared_ptr<T> data(std::make_shared<T>(std::move(new_value))); std::lock_guard<std::mutex> lk(mut); data_queue.push(data); data_cond.notify_one(); } std::shared_ptr<T> wait_and_pop() { std::unique_lock<std::mutex> lk(mut); data_cond.wait(lk, [this]{ return !data_queue.empty(); }); std::shared_ptr<T> res = data_queue.front(); data_queue.pop(); return res; // 返回指针的拷贝,只增加引用计数,成本极低 } // ... 其他接口类似,都操作shared_ptr };优势:
- 锁外构造: 昂贵的对象构造(
std::make_shared<T>(...))可以在锁外完成,显著减少了锁的持有时间,提高了并发性能。 - 零拷贝传递:
pop操作返回的是shared_ptr,只涉及引用计数的原子递增(成本很低),完全避免了大型对象的拷贝或移动开销。 - 所有权清晰: 智能指针自动管理生命周期,当所有消费者都处理完数据后,对象自动销毁。
权衡: 增加了堆内存分配的开销(make_shared)。但对于中型及以上对象,或者构造/析构成本高的对象,这个优化通常是净收益。这也是很多高性能框架(如某些消息队列库)内部采用的方式。
4.3 超时等待wait_for_pop
有时我们不想无限期等待。C++条件变量提供了wait_for和wait_until方法,我们可以实现一个带超时的pop。
template<typename T> bool threadsafe_queue<T>::wait_for_pop(T& value, const std::chrono::milliseconds& timeout) { std::unique_lock<std::mutex> lk(mut); // 使用wait_for,返回bool表示条件是否满足(即是否超时) if(data_cond.wait_for(lk, timeout, [this]{ return !data_queue.empty(); })) { value = std::move(data_queue.front()); data_queue.pop(); return true; // 成功获取 } return false; // 超时 }这个功能在实现带有心跳、轮询或需要响应外部中断的消费者时非常有用。
5. 实战应用示例与测试
理论说再多,不如跑个例子。下面是一个经典的多生产者-多消费者模型测试。
#include <iostream> #include <vector> #include <thread> #include <chrono> #include <atomic> threadsafe_queue<int> queue; std::atomic<int> producer_count{0}; std::atomic<int> consumer_count{0}; void producer(int id, int num_items) { for(int i = 0; i < num_items; ++i) { int value = id * 1000 + i; // 产生一个唯一值 queue.push(value); producer_count.fetch_add(1, std::memory_order_relaxed); // 模拟不稳定的生产速度 std::this_thread::sleep_for(std::chrono::milliseconds(10 + (rand() % 50))); } std::cout << "Producer " << id << " finished.\n"; } void consumer(int id) { while(true) { std::shared_ptr<int> value_ptr = queue.wait_and_pop(); if(value_ptr) { int value = *value_ptr; consumer_count.fetch_add(1, std::memory_order_relaxed); std::cout << "Consumer " << id << " got: " << value << std::endl; // 模拟处理时间 std::this_thread::sleep_for(std::chrono::milliseconds(20 + (rand() % 80))); } // 在实际应用中,需要有终止机制,比如收到一个特殊的“毒丸”信号。 // 这里为了简单,让消费者一直运行。 } } int main() { const int num_producers = 3; const int num_consumers = 2; const int items_per_producer = 5; std::vector<std::thread> producers, consumers; // 启动消费者 for(int i = 0; i < num_consumers; ++i) { consumers.emplace_back(consumer, i); } // 启动生产者 for(int i = 0; i < num_producers; ++i) { producers.emplace_back(producer, i, items_per_producer); } // 等待所有生产者结束 for(auto& t : producers) { t.join(); } // 等待一段时间,让消费者处理完队列剩余数据 std::this_thread::sleep_for(std::chrono::seconds(2)); // 注意:这个示例中消费者线程是死循环,实际需要更优雅的停止机制。 // 可以通过向队列推送一个特殊值(如nullptr或特定标记)来通知消费者退出。 std::cout << "Total produced: " << producer_count.load() << std::endl; std::cout << "Total consumed: " << consumer_count.load() << std::endl; // 强制结束消费者线程(仅示例,不推荐) // std::terminate()... return 0; }这个例子展示了多个生产者线程异步生产数据,多个消费者线程阻塞等待并消费数据。std::atomic计数器用于验证生产消费的数量是否匹配(在生产者结束后,等待一段时间,理论上应该匹配)。你需要一个更完善的机制来优雅地停止消费者线程,例如推送一个“结束信号”。
6. 常见陷阱、调试技巧与进阶思考
即使有了上面的实现,在多线程世界里依然危机四伏。下面是我踩过或见过的坑。
6.1 死锁与锁的持有时间
问题: 在wait_and_pop中,我们使用了std::unique_lock并在条件变量上等待。这是安全的。但要千万避免在持有锁的情况下调用可能阻塞或等待用户输入的函数,或者去获取另一把锁(如果设计中有多把锁)。这很容易导致死锁。
心得: 锁的持有时间应尽可能短。像我们之前提到的“内部存储shared_ptr”优化,就是把昂贵的对象构造移到了锁外。任何不需要在锁保护下进行的操作(比如数据预处理、日志记录、调用不操作共享资源的函数),都应该在锁外完成。
6.2 条件变量的虚假唤醒与条件判断
问题: 我已经强调过,一定要用wait的重载版本,它接受一个条件谓词(lambda)。绝对不要写成下面这样:
// 错误!可能因虚假唤醒导致访问空队列。 if(data_queue.empty()) { data_cond.wait(lk); } value = data_queue.front(); // 如果虚假唤醒,队列可能仍为空,front()行为未定义! data_queue.pop();正确的做法就是使用data_cond.wait(lk, [this]{ return !data_queue.empty(); });。这个习惯能避免很多灵异bug。
6.3 拷贝控制成员(构造函数、析构函数)的线程安全
问题: 我们实现了拷贝构造函数,并锁定了源对象的锁。但是,如果有多个线程同时拷贝同一个队列对象呢?我们的拷贝构造是线程安全的吗?是的,因为每个拷贝操作都会锁住源对象的mut,所以对同一个源对象的多次拷贝是串行化的,不会破坏源对象的数据。但是,如果两个线程一个在拷贝源对象,另一个在向源对象push数据,这也是安全的,因为push也会锁同一个mut。
析构函数: 我们没有显式定义析构函数。编译器生成的默认析构函数会依次析构成员:data_cond,data_queue,mut。这里有一个隐藏的雷区:如果还有线程在队列上等待(即阻塞在data_cond.wait),而此时队列对象被销毁了,那么那些等待的线程将等待在一个已经被销毁的条件变量上,导致未定义行为(通常是程序崩溃)。
解决方案: 这是一个对象生命周期管理问题。确保所有使用队列的线程在队列销毁前都已结束。通常可以通过线程间的同步机制来实现,例如使用一个std::atomic<bool>标志位,消费者线程定期检查,当标志位被设置时退出循环;或者使用“毒丸”模式,向队列推送一个特殊值通知消费者退出。在确保所有消费者线程都退出后,再安全地销毁队列。
6.4 性能瓶颈分析与优化方向
当你的应用性能遇到瓶颈,怀疑是队列导致时,可以按以下思路排查和优化:
锁争用: 使用性能分析工具(如
perf,vtune)查看mutex的争用情况。如果生产者和消费者都非常频繁,一把大锁可能成为瓶颈。- 优化1: 换用更快的锁。
std::mutex是通用锁,在Linux下通常是pthread_mutex的封装。可以尝试使用std::shared_mutex(C++17)如果读写模式允许,或者使用平台特定的轻量级自旋锁(如std::atomic_flag实现的自旋锁)在锁持有时间极短的场景下。但要注意自旋锁在争用激烈时浪费CPU。 - 优化2: 分片(Sharding)。创建多个队列,让不同的生产者-消费者对使用不同的队列。例如,根据任务类型或来源ID哈希到不同的队列。这能从根本上减少争用。
- 优化3: 向无锁队列迈进。这是终极方案,但复杂度高。可以考虑使用成熟的第三方无锁队列库(如
moodycamel::ConcurrentQueue),它提供了丰富的API和优秀的性能。
- 优化1: 换用更快的锁。
内存分配: 频繁的
push/pop可能导致频繁的内存分配/释放(尤其是std::queue底层deque的动态扩展)。- 优化: 使用对象池或预分配内存的队列。例如,可以使用
boost::circular_buffer(固定容量循环缓冲区)作为底层容器,或者自己实现一个基于数组的环形缓冲区队列。这能消除动态内存分配的开销,但队列容量会有上限。
- 优化: 使用对象池或预分配内存的队列。例如,可以使用
缓存友好性:
std::queue(默认基于deque)存储的数据可能在内存中不连续,影响CPU缓存效率。- 优化: 对于小型、简单的数据(如
int,double),可以考虑使用std::vector模拟的环形缓冲区,保证数据在内存中连续存储。
- 优化: 对于小型、简单的数据(如
6.5 如何优雅地关闭队列与停止线程
这是实际工程中必须解决的问题。一个常见的模式是“毒丸”(Poison Pill)法:
- 在队列中存储
std::shared_ptr<T>或std::variant<T, QuitSignal>。 - 定义一个特殊的“毒丸”值(比如一个空的
shared_ptr,或者一个特定类型的QuitSignal对象)。 - 当需要停止所有消费者时,生产者(或主线程)向队列中推送与消费者数量相等的“毒丸”。
- 消费者线程在
pop到“毒丸”时,跳出处理循环,安全结束。 - 确保所有消费者线程
join后,再销毁队列。
// 简化的毒丸法示例 class threadsafe_queue_with_quit : public threadsafe_queue<std::shared_ptr<MyData>> { public: void quit() { push(nullptr); // 推送空指针作为毒丸 } }; // 消费者线程 void consumer_func(threadsafe_queue_with_quit& queue) { while(true) { auto data = queue.wait_and_pop(); if(!data) { // 收到毒丸 break; } // ... 处理数据 } }实现一个正确、高效、健壮的线程安全队列,是掌握C++并发编程的重要里程碑。它涉及了互斥锁、条件变量、移动语义、智能指针、异常安全、接口设计等多个核心知识点。从最基本的加锁队列开始,理解其每一行代码背后的同步逻辑,然后逐步思考优化(锁粒度、存储方式、无锁化),最终能根据具体应用场景选择合适的变体,这才是一个资深C++工程师该有的能力路径。希望这篇长文能帮你彻底吃透这个主题,在实际项目中少走弯路。
