尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
c++实现LinkBlockedQueue的问题
前言「LinkBlockedQueue」这个名字并不是 C 标准库里的类型它更接近 Javajava.util.concurrent.LinkedBlockingQueue的直译一个用链表节点存放元素、带容量上限、在队满和队空时阻塞调用者的队列。C 标准库提供了std::queue、std::deque、std::condition_variable但没有现成的阻塞队列工程里通常要自己拼一个。本文讨论的就是这个拼装过程中真正容易出问题的地方。一个常见误解是「链表版不过是把std::deque换成std::list其余照抄」。实际并非如此链表版要自己维护头尾指针与元素计数节点的分配时机影响异常安全锁的粒度决定能不能让生产者和消费者真正并行关闭语义稍有不慎就会出现永久等待。这些问题与容器选择无关换成std::deque一样存在只是链表把它们暴露得更明显。本文按「语义 → 节点结构与不变式 → 单锁实现 → 两锁取舍与关闭」的顺序展开。示例以 C17 为基准代码只按标准语义推演本机没有 C/C 工具链未做编译或运行验证。一、阻塞队列到底要保证什么在写任何代码之前先把契约写清楚。一个可用的有界阻塞队列至少要有四条语义有界容量在构造时确定元素个数永远不超过容量。队满阻塞push在队列满时等待直到有空位或队列被关闭。队空阻塞pop在队列空时等待直到有元素或队列被关闭。可关闭关闭后不再接受新元素但允许消费者把剩余元素取完然后让所有等待者退出。第四条最容易被忽略。很多实现只做了前三条于是程序退出时消费者永远卡在wait上进程无法正常结束。关闭不是一个附加功能而是阻塞队列生命周期的一部分。链表实现在语义上与其他容器实现没有区别差别在于「元素放在哪里」和「如何计数」。链表用一个个独立节点保存元素入队时分配节点、出队时释放节点std::deque则用分段连续存储扩容时搬运指针。前者没有整体扩容的停顿但每次入队都有一次分配节点里还要多存一个指针。关注点链表节点实现deque 实现单次入队分配每个元素一次节点分配摊还到分段扩容元素内存分散缓存局部性差分段连续局部性较好容量控制依靠外部计数同样依靠外部计数头尾操作改指针O(1)改索引/指针O(1)迭代器稳定性插入不失效两端插入可能使迭代器失效因此选择链表并不是为了「更快」而是为了「入队时不做整体搬迁、节点地址稳定」。是否值得要看具体场景。二、节点结构与不变式链表队列的核心不是push和pop的代码而是几条必须始终成立的不变式。先定义节点// 适用于 C17struct Node {explicit Node(const T v) : value(v) {}explicit Node(T v) : value(std::move(v)) {}T value;Node* next nullptr;};一个稳妥的做法是引入哨兵节点dummy head。队列初始时head_和tail_都指向同一个哨兵哨兵不保存有效元素。这样做的收益是空队列与非空队列的代码路径统一push永远只需修改tail_-nextpop永远只需看head_-next不必到处判断「队列是不是空的」。需要维持的不变式有三条head_始终指向哨兵head_-next是第一个有效元素没有元素时为nullptr。tail_指向最后一个节点队列为空时tail_ head_。size_与有效节点数严格相等。第三条是链表实现里最常见的错误来源。size_不是装饰品它同时承担两个职责判断是否达到容量、判断是否为空。任何一条修改链表的路径都必须同步修改size_而且必须在持锁状态下修改。// 适用于 C17#include condition_variable#include cstddef#include mutex#include stdexcept#include utilitytemplate typename Tclass LinkedBlockingQueue {public:explicit LinkedBlockingQueue(std::size_t capacity): capacity_(capacity), head_(new Node()), tail_(head_) {if (capacity_ 0) {delete head_;throw std::invalid_argument(capacity must be positive);}}~LinkedBlockingQueue() {Node* p head_;while (p) {Node* next p-next;delete p;p next;}}LinkedBlockingQueue(const LinkedBlockingQueue) delete;LinkedBlockingQueue operator(const LinkedBlockingQueue) delete;private:std::size_t capacity_;std::size_t size_ 0;Node* head_; // 哨兵Node* tail_;bool closed_ false;mutable std::mutex mutex_;std::condition_variable not_empty_;std::condition_variable not_full_;};构造函数里有一个细节先分配哨兵再检查容量失败时把哨兵释放掉。如果先检查容量、后分配哨兵逻辑上更顺但成员初始化列表已经把head_建好了顺序反过来反而要处理半构造状态。两种写法都可以关键是不能泄漏。三、单锁实现先分配再进锁最简单的同步方案是「一把互斥量保护所有状态」。链表的所有指针、size_、closed_都在同一把锁下修改条件变量的谓词也只读这些状态。它的并发度不高——生产者与消费者不能同时修改队列——但正确性最容易保证。先看push。这里有一个影响异常安全的关键决定节点应该在加锁之前分配。// 适用于 C17bool push(const T value) {Node* node new Node(value); // 先分配失败则直接抛出std::unique_lockstd::mutex lock(mutex_);not_full_.wait(lock, [this] {return closed_ || size_ capacity_;});if (closed_) { // 已关闭拒绝入队lock.unlock();delete node;return false;}tail_-next node;tail_ node;size_;lock.unlock();not_empty_.notify_one();return true;}如果把分配放进锁内new抛异常时锁会随unique_lock析构释放本身没有问题但分配可能耗时较长持锁分配会拉长临界区而且一旦分配失败等待中的线程已经被唤醒又发现没有空位需要重新判断。先分配、后进锁把「可能失败且与共享状态无关」的操作移到临界区外是更清晰的做法。注意if (closed_)分支谓词被唤醒并不代表一定能入队。队列可能在等待期间被关闭此时必须释放刚分配的节点并返回失败而不是硬塞进去。pop的对称写法如下。这里用std::optionalT表达「可能没有值」避免用哨兵值区分空结果// 适用于 C17std::optionalT pop() {std::unique_lockstd::mutex lock(mutex_);not_empty_.wait(lock, [this] {return closed_ || head_-next ! nullptr;});if (head_-next nullptr) { // 已关闭且已取空return std::nullopt;}Node* old head_-next;head_-next old-next;if (head_-next nullptr) {tail_ head_; // 队列变空尾指针回到哨兵}--size_;T value std::move(old-value);lock.unlock();delete old; // 节点在锁外释放not_full_.notify_one();return value;}有三个容易看漏的点tail_ head_的复位只在「取走最后一个元素」时发生。漏掉它tail_会指向已释放的节点下一次push直接写野指针。节点在锁外delete。析构可能触发用户类型的析构函数放在锁内会延长临界区还可能因为析构函数回调队列而重入死锁。通知在unlock之后。持锁通知是合法的但被唤醒的线程会立刻抢锁失败形成「刚醒又睡」的无效唤醒先解锁再通知更高效。关闭操作要唤醒所有等待者因为生产者与消费者可能同时挂在不同的条件变量上// 适用于 C17void close() {{std::lock_guardstd::mutex lock(mutex_);closed_ true;}not_empty_.notify_all();not_full_.notify_all();}bool closed() const {std::lock_guardstd::mutex lock(mutex_);return closed_;}close里同样先解锁再通知理由与上面相同。四、两锁设计与关闭顺序的取舍Java 的LinkedBlockingQueue用的是两把锁putLock保护尾指针takeLock保护头指针元素计数用原子变量维护。这样生产者与消费者可以真正并行。C 版本能不能照搬可以但要付出代价。难点在计数。size_同时被两个临界区读写必须改成std::atomicstd::size_t并且「判断是否满/空」与「修改链表」必须构成一个整体。两把锁各自只能保护自己那一端容量判断却需要看到另一端的状态于是需要更复杂的握手在入队后若发现入队前队列为空还要去通知消费者在出队后若发现出队前队列是满的还要去通知生产者。这些二次检查一旦遗漏就会出现「元素已经入队消费者却还在睡」的丢唤醒。因此本文的单锁实现更适合作为默认选择临界区覆盖的是几次指针赋值和计数自增长度有限只有在压测确认锁竞争确实是瓶颈时才值得引入两锁版本。这与「链表一定比数组快」的直觉相反——同步策略对吞吐的影响通常远大于容器本身。关闭顺序则是另一个高频问题。阻塞队列析构时如果还有线程阻塞在pop上那么析构函数释放互斥量的同时等待线程仍持有对该互斥量的引用行为未定义。正确的顺序永远是生产者停止投递并调用close()消费者在pop()返回nullopt后自然退出主线程join所有工作线程最后才允许队列对象析构。把close和join写进队列的析构函数里是行不通的析构函数不知道有多少线程在等待也无权替调用者决定何时结束线程。常见坑点❌ 用if (queue.empty()) cv.wait(lock);而不是带谓词的wait。✅ 用cv.wait(lock, pred)。条件变量存在虚假唤醒if只判断一次醒来后可能条件仍不成立继续访问空队列。❌ 修改队列状态时不持锁只给notify_one加锁。✅ 对链表指针、size_、closed_的所有读写都放在同一把互斥量下。通知本身不需要锁但状态修改必须持锁否则谓词读取到撕裂状态。❌push里先加锁再new Node(value)分配抛异常后立刻notify却没有元素入队。✅ 先分配节点再进锁分配失败时直接抛出队列状态未变无需通知。❌ 取走最后一个元素后忘记把tail_复位到哨兵。✅ 出队后若head_-next nullptr立即令tail_ head_否则下一次入队会写入已释放节点。❌ 在持锁状态下delete旧节点或调用用户类型的析构函数。✅ 把节点摘下、更新计数、取出值之后先解锁再释放节点。析构函数里若回调队列持锁释放会直接死锁。❌close()只notify_one()认为「唤醒一个就够」。✅ 生产者与消费者可能同时挂在not_full_和not_empty_上关闭必须对两个条件变量都notify_all()。❌ 队列析构时仍有线程阻塞在pop指望析构函数「顺便」把线程叫醒。✅ 先close()让pop返回nullopt再join所有线程最后析构队列。析构与等待并发是未定义行为。❌ 关闭后push仍把元素塞进队列理由是「消费者还能取」。✅ 在谓词和入队分支里都检查closed_谓词允许因关闭而返回入队分支在关闭时释放节点并返回失败。总结链表阻塞队列的难点不在链表而在同步契约。把下面几条固定下来实现就不会跑偏要点做法漏掉后的症状空队列表示哨兵节点head_到处判空分支爆炸元素计数锁内维护size_容量失效或提前阻塞阻塞等待带谓词的wait虚假唤醒后访问空队列异常安全节点先分配、后进锁唤醒后无元素状态不一致尾指针复位取空时tail_ head_写入已释放节点节点释放锁外delete临界区过长、重入死锁关闭语义两个条件变量都notify_all线程永久阻塞生命周期close→join→ 析构未定义行为一句话概括先写清楚「满、空、关闭」三种状态的迁移再写链表指针的拼接指针写错通常立刻崩溃状态迁移写错则会安静地挂死后者更难查。本文所有示例均为按标准语义的推演未经编译或运行验证。
RELATED

相关推荐

个人AI智能体:自主代理、记忆图谱与工具调度的技术本质

个人AI智能体:自主代理、记忆图谱与工具调度的技术本质

1. 从“个人AI智能体”这个词开始,先搞清楚我们到底在聊什么最近刷信息流时,“字节、腾讯加速个人AI智能体研发”这个说法反复出现,但很多人点进去发现通稿里全是“布局”“加码”“战略投入”这类空转词,看完反而更迷糊&#xff…

📅 2026/10/20 21:37:25
log_softmax值的正负号不能直接读概率?揭秘深度学习中的数值陷阱

log_softmax值的正负号不能直接读概率?揭秘深度学习中的数值陷阱

1. 一个被无数人误解的“直觉”:log_softmax输出值的正负号,真的能直接读出概率大小吗?刚接触PyTorch或TensorFlow做分类任务时,我见过太多人对着log_softmax的输出发呆——模型预测出一个-0.23,另一个是-1.87&#xf…

📅 2026/10/20 21:37:25
AI安全与芯片供应链的技术协同机制解析

AI安全与芯片供应链的技术协同机制解析

我无法根据您提供的输入内容生成符合要求的博文。原因如下:输入中缺少关键必要字段:按照您设定的严格输入格式,必须包含以下四部分:项目标题: [标题] 项目正文: [通常比较零散、不完整的原始描述,可是任意领域内容] 关…

📅 2026/10/20 21:37:25
MORE NEWS

更多资讯

📰

AI重做终端的关键:算力盘点、模型压缩与端边云协同落地

终端设备每天在你手上、桌上、产线上、车里转,但要论真正被AI“重做”过的,其实少得可怜。大多数所谓AI终端,无非是装了个能聊天的助手,或是加了个人脸解锁,离“好用”还有很长一段距离。我自己这几年帮几家公司做过端…

📰

Camera驱动DMA深度解析:从Cache一致性到vb2落地

做Linux camera驱动的人,大概率都经历过这样的场景:sensor点亮了、寄存器配置对着手册调过三轮、MIPI链路上波形看着也正常,可一开流画面要么花成一片,要么每隔几帧卡一下。我第一次带摄像头项目时,也被这个现象困了整…

📰

技能系统化设计:从能力建模到模块化复用的工程实践

1. 从“skills”这个词说起:它到底指什么“skills”这个词最近在技术社区和职场讨论里出现的频率明显高了起来。表面上看它只是英文里“技能”的复数形式,但在实际语境中,它已经变成了一个高度浓缩的概念——既可以指一个人掌握的技术栈组合&…

📰

智能资源调度AI引擎实战:预测、决策与Kubernetes落地

这几年帮团队做AI应用的落地,我碰到的架构师里十有八九被同一个问题折磨过:GPU买了不少,机器跑不满;线上推理一抖动,资源立刻不够用;训练任务跑到一半,被临时任务挤掉。这些问题本质上是同一个—…

📰

大模型学习全景:从Token、注意力到微调与部署

最近总有人问我,LLM 到底该怎么学。市面上的资料要么是某个模型的讲解,要么是某篇论文的精读,看完总觉得隔着一层纱,形不成自己的体系。这篇文章是“深入学习 LLM”系列的第一篇,定位就是一个 Overview——先把整张地图…

📰

紫微斗数排盘实战:易排盘V3.0核心功能与参数配置解析

简介:易排盘紫微斗数 V3.0 是一款运行于 Windows 平台的紫微斗数排盘软件,面向命理学研究者、传统术数爱好者及需要系统排盘的入门用户,涵盖三合、飞星、四化三种盘式与太阴、冬至、节气等多种排盘方法,解决从星曜布局到四化推演的…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬