惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
DataBreaches.Net
IT之家
IT之家
博客园_首页
博客园 - 【当耐特】
V
V2EX
Apple Machine Learning Research
Apple Machine Learning Research
G
Google Developers Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Recent Announcements
Recent Announcements
F
Fortinet All Blogs
GbyAI
GbyAI
腾讯CDC
H
Hackread – Cybersecurity News, Data Breaches, AI and More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
I
InfoQ
H
Help Net Security
T
Tailwind CSS Blog
B
Blog RSS Feed
Martin Fowler
Martin Fowler
人人都是产品经理
人人都是产品经理
The Cloudflare Blog
博客园 - 叶小钗
雷峰网
雷峰网
量子位

博客园 - GerJCS

续啃《代码随想录》的《内存池项目》 —— 上一篇是关于分离写法衍生的思考,这篇开始逐行代码死磕追问豆包 + 无尽思考实践串联 《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池) 《编程指北》动手写 C++ shared_ptr 章节开始(没钱买书,靠无尽追问豆包,反复思考质疑,一路拓展出很多东西,包括手写了个内存池,因为始终感觉没什么东西都是枯燥的概念对比,用Linux实践malloc又看不到RES回落,于是不知不觉摸索到了内存池) 续啃《编程指北 C++》智能指针(牵扯无穷无尽的其他知识) 续啃:编程指北 C++ (从 RAII开始的,RAII这一小节,学了将近两个月,RAII 的内容早在之前就主动追问豆包搞懂了,这节主要是自己无尽追问探索出很多其他知识,后来发现其实堪比精啃 CSAPP & APUE 等圣书) 编程指北的 C++ 续:啃操作系统 项目:从零开始做一个HTTP服务器(准备篇 —— 无尽弯路错路) C++:继续上一篇文章学CGI编程:乱码问题、误入歧途学了CGI编程(其实只是安装劝退因祸得福)、通过知乎直答及时更改学习方向、找到方向(花了2天) C++:网页网站、互联网、服务器、(web)服务器、(http)服务器、超文本、大型数据中心、云服务器、访问网页发生的流程、URL、DNS、域名、网址链接、IP地址、路由器、CGI架构图、万维网、tomcat、servlet、apache、学C++意义、动态静态请求、脚本、编码、Editplus、乱码 C++:多线程、进程、std::thread、互斥锁、reference_wrapper()、lambda()、cv.wait()、ref、条件变量、原子操作 、看书的意义(内存管理seq和宽松这需要看看)、线程局部存储、死锁、线程间通信(future/promise)、execution库并行 C++:#define等宏预处理器、中断信号处理 C++:命名空间、模版 C++:异常处理、二维数组/三维数组/对象的动态内存分配与delete释放 C++:数据抽象、数据封装、接口(之前做测试的时候听他们说接口始终不理解)、文件和流 C++:基本之前都深入问过豆包了,没啥新东西:类和对象、get()set()、公有私有保护、作用域解析运算符::、继承、函数重载、多态(从这开始不帖回答了,只放豆包回答链接)、虚函数 C++:结构体、vector、队列、链表、哈希表、映射map、简述回顾O(logn)复杂度、哈希表和桶排序里的“桶”、set C++ 基本的输入输出、缓冲机制、cerr引发的超长折磨、同步问题 C++:数组、字符串、指针和引用、时间戳、覆盖静态存储区问题 C++:三角函数、随机数、数论线性同余(待研究)、math库函数、配置C++20、C++20的number、GCC版本和C++版本区别、M_PI、条件编译、头文件和命名空间和库函数区别、控制台/终端/命令行/cmd/bash lambda后续,实在受不了了,太痛苦了,全网找不到答案,详见最后,搜“诡异”,一个lambda整整搞了6天 菜鸟教程:运算符、指针和引用、(从刷算法题到现在目前为止最难啃的)Lambda、第一次主动了解学习new。对我来说这篇博客写的异常痛苦,不亚于刷过的最难算法题,最异常痛苦的是豆包的回答是错的,亏我还追着问了他整整3天关于lambda的事 菜鸟教程:存储类 菜鸟教程:修饰符、静态非静态、构造函数、类相关的杂七杂八的知识
啃内存池前思考(从分离写法衍生出的思考:类/模版类型、强弱...
GerJCS · 2026-08-21 · via 博客园 - GerJCS

比如说生产者先拿到锁,task_queue里入队,出作用域释放锁,cv.notify_one唤醒,注意唤醒那块必须消费者走到wait阻塞处,唤醒才生效,卡在lock处的消费者接收不到唤醒信号,所以唤醒失败但代码没错,因为此时消费者会拿到锁,进入wait理应等待唤醒,可是唤醒失败,可是要知道他只有空的时候才释放锁等唤醒,此时有数据不会等,直接读,然后循环之后,比如消费者拿到锁,那就wait释放锁,然后生产者拿到锁继续往复。

这就是线程同步并发编程的知识,也是unique_lock可手动解锁、灵活托管锁生命周期,条件变量wait会自动释放传入的unique_lock,二者以此完成搭配。

std::condition_variable:本身不带计数器,必须强制搭配 mutex 使用,让线程阻塞休眠,靠外部变量 / 队列做条件判断,等待某个条件成立,被别的线程唤醒再继续跑,容易出现虚假唤醒,必须带上条件判断

但这附近的所有知识点也打通了底层硬件原语到 C++ 标准库再到工程应用整条链路,是原理层面的理解,堪比《C++ 并发编程实战》 完整覆盖 std::atomic、各类原子操作、compare_exchange 强弱差异、内存序 / 内存屏障、自旋锁实现,辅助理解底层:《深入理解计算机系统》 用来理解 CPU 缓存、指令重排序,搞懂内存屏障诞生的根源。

错误代码如下(死妈豆包以及任何大模型AI的现状:狗逼玩意毫无任何逻辑全部回答都是无脑概率拼凑,对错毫无负责的能力,全是胡乱概率拼凑): 

查看代码
#include <atomic>
#include <thread>
#include <iostream>

struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}
};

// 单生产者单消费者无锁队列
class LockFreeQueue {
private:
    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;
public:
    LockFreeQueue() {
        Node* dummy = new Node(0);
        head_ = dummy;
        tail_ = dummy;
    }

    void push(int val) {
        Node* new_node = new Node(val);
        Node* prev_tail = tail_.load();
        while (!tail_.compare_exchange_weak(prev_tail, new_node)) {
            prev_tail = tail_.load();
        }
        prev_tail->next = new_node;
    }

    bool pop(int& out) {
        Node* h = head_.load();
        Node* next = h->next;
        if (next == nullptr)
            return false;
        if (head_.compare_exchange_weak(h, next)) {
            out = next->val;
            return true;
        }
        return false;
    }
};

LockFreeQueue queue;

void produce() {
    for (int i = 1; i <= 5; ++i) {
        queue.push(i);
    }
}

void consume() {
    int val;
    while (true) {
        if (queue.pop(val)) {
            std::cout << val << std::endl;
            if (val == 5) break;
        }
    }
}

int main() {
    std::thread t1(produce);
    std::thread t2(consume);
    t1.join();
    t2.join();
}

原先:mutex + condition_variable,靠锁实现互斥,线程拿不到锁会进入内核阻塞,低并发更快;

无锁版本:没有 mutex,依靠 CAS 原子指令循环尝试修改指针;竞争失败只是自旋重试,不主动陷入内核。,高并发、线程频繁入队出队,无锁队列优势显现,减少线程内核阻塞开销。

插入个代码技巧 —— 哑节点

哑节点(dummy node):队列初始化时,预先创建一个不存储有效业务数据的节点。

作用:规避头尾指针相等时的边界判断,简化 CAS 并发逻辑,没有哑节点,空队列、只有一个元素的场景需要额外判断,代码更容易出并发 bug。

初始状态:head、tail 都绑哑节点,哑节点 next=nullptr。

如果没有哑节点:队列为空时 head 和 tail 都是 nullptr;第一个入队、出队要写特殊 if 判断,代码极易出错。

有哑节点:队列永远至少保留这个节点,空队列、有数据队列可以统一一套逻辑,省去一堆空指针分支。

哑节点自身不存储业务数据,它的 next 指向队列第一个有效节点。

head 永远指向哑节点,有效数据全部在 head->next。 队列空:head->next == nullptr

查看代码
//无哑节点
//无哑结点: head本身就是第一个有效结点,head->next指向后继结点
struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}//val 存放业务数据,next 存放下一个节点的地址
};

Node* head;
Node* tail;


// 入队
void push(int val) {
    Node* n = new Node(val);
    if (tail == nullptr) // 队列为空,特殊分支
        head = tail = n;
    else {
        tail->next = n;
        tail = n;
    }
}

// 出队
bool pop(int& out) {// 引用参数,把出队拿到的值拷贝带回调用方,不用函数返回值承载数据
    if (head == nullptr) // 队列为空,特殊分支
        return false;
    Node* del = head;
    out = head->val;//取出数据,哑节点后移
    head = head->next;
    if (head == nullptr) // 取出最后一个元素,tail也要置空,额外分支
        tail = nullptr;
    delete del;
    return true;
}
==============================================================
// 带哑节点版本
//有哑结点(哨兵d): d为哑结点,head 永远存储哑结点 d 的地址,d->next才指向第一个有效结点,此时真正的头结点不再是head,而是d->next,也就是head->next
struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}
};

Node* head;
Node* tail;

// 初始化:创建哑节点d
void init() {
    Node* d = new Node(0);
    head = d;
    tail = d;
}

// 入队
void push(int val) {
    Node* n = new Node(val);
    tail->next = n;
    tail = n;
}

// 出队
bool pop(int& out) {
    // 空队列:head == tail
    if (head == tail)
        return false;
    Node* del = head;
    out = head->val;
    head = head->next;
    delete del;
    return true;
/*
队列:d (哑节点) → Node1 (val=10) → nullptr
pop 执行:
head 从 d 移到 Node1,删除旧 d
此时队列:Node1 → nullptr
Node1 原先的 val=10 就是本次取出的数据,之后 Node1 充当哑节点,它的 val 不再参与业务读取
*/
}

这里代码是单生产单消费已经是俩线程了,但豆包的说法是,不可以多线程其实指的是多个生产多个消费会有问题,当然那个更复杂基本不用会,而内存池就是多生产消费,所以学完这个还要啃那个。 

没任何约束,生产者、消费者同时读写同一个共享变量 / 链表节点,会出现数据竞争:消费者读到半更新、残缺的节点;生产者刚挂好节点,指针还没更新,消费者看不到新元素;重复读取、丢失元素、程序崩溃(野指针),所以使用了std::atomiccompare_exchange_weak作为同步机制。

当看不懂代码的时候,豆包的解释又看不懂,我的提示词:

不是,你这个就没解释你想干啥。你该起手第一步就讲这是干啥用的,你别直接说这是各种结论的,什么什么 CAS, 我真的不是,我真的没法串联上。你就能不能从最原始的开始讲,比如说你想干嘛,你想写啥,然后此时你想遇到什么功能?然后你再引出 CAS, 你别直接上来就各种什么CAS,我连 CAS 它是怎么来的都不知道! 

目标:我们想要实现一个队列

队列功能:往末尾放数据(push)、从头部拿数据(pop)。 正常多线程场景难题: 如果两个线程同时修改队列,链表指针会乱掉,数据丢失、程序崩溃。

普通人第一想法,加互斥锁 mutex,任何线程想要 push 或者 pop,先抢到锁。 抢到锁,才能修改队列;操作完释放锁。 别的线程只能原地等待,等锁释放才有机会操作,但缺点: 线程抢不到锁的时候,操作系统会把这个线程暂停,放到等待队列。等锁释放,操作系统再唤醒线程。 暂停、唤醒线程都要操作系统介入,存在开销。如果线程频繁收发数据,大量时间浪费在内核调度上。

于是人们思考:能不能不用锁,让多个线程安全操作共享数据,避开操作系统的线程阻塞开销? 这就是无锁队列的出发点。

核心难题:没有锁,怎么保证多个线程修改同一个变量不会乱?

举个最简单例子:有一个全局指针 tail,所有人都可以读取和修改。 线程 1 读取 tail 地址;线程 2 同时读取 tail 地址。 两个线程拿着同一个地址各自修改,最后必然互相覆盖,数据出错。

我们想要一种操作: 读取变量,对比一下变量有没有发生变化。如果变量和我刚刚读到的值一样,就把新值写进去;如果不一样,说明别人中途改过了,本次写入直接放弃。 整套动作必须一次性完成,不能被别的线程打断。 这个硬件提供的一次性操作,名字就叫 CAS。

现在你先记住 CAS 的通俗含义: 先对比,相等才写入;不等就不写入,整套动作不可拆分。 不需要理解 CPU 底层,只记住这个行为,那段错误代码里:

void push(int val) {
    Node* new_node = new Node(val);
    Node* prev_tail = tail_.load();
    while (!tail_.compare_exchange_weak(prev_tail, new_node)) {
        prev_tail = tail_.load();//狗逼死妈玩意豆包,就算有while这句话也绝对的冗余
    }
    prev_tail->next = new_node;
}

我们想要完成的目标:把新节点接到队列尾部,更新尾指针 tail。

设想理想操作顺序:

  1. 读出当前队列尾部节点 prev_tail

  2. 把 tail 改成新节点

  3. 旧尾节点的 next 指向新节点

问题来了:第 2 步、第 3 步是分开的两行代码。两行代码中间,别的线程有机会插进来执行代码。

我们先用单生产者场景(只有一个线程调用 push)模拟运行流程:

  1. 创建新节点 new_node

  2. 读取现在队列尾节点,保存到 prev_tail

  3. 执行 CAS:检查 tail 现在是不是等于 prev_tail,如果相等:把 tail 更新成 new_node,CAS 成功,跳出循环

  4. 将旧尾节点 prev_tail->next = new_node,链表拼接完成。

因为只有一个生产者线程,不存在其他线程同时修改 tail。CAS 几乎一次成功,中间不会有人干扰。整个流程稳定,队列正常工作。

重点模拟:如果开两个生产者线程,为什么会崩

初始状态:tail 指向节点 A,A->next 是空。 线程 A、线程 B 同时执行 push。

危险的时序步骤:

  1. 线程 A 读取 prev_tail = A

  2. 线程 B 读取 prev_tail = A

  3. 线程 A CAS 成功,tail 变成 Node1

  4. 线程 B CAS 成功,tail 变成 Node2

  5. 线程 B 运行:A->next = Node2

  6. 线程 A 恢复运行:A->next = Node1

最后结果:A 节点的 next 被线程 A 覆盖,Node2 彻底从链表消失,永久丢失。 根源:CAS 修改 tail 和 链表拼接是两步独立操作。两个线程拿到同一个旧尾节点时,后续赋值会互相覆盖,这就解释清楚:这份代码只能单线程调用 push,不能多生产者。

那单生产单消费就没问题了吗?其实依旧有问题!

无多生产,所以没必要用CAS,不会有人同时改动tail尾节点,那就等价于

①Node* new_node = new Node(val);

②Node* prev_tail = tail_.load();

③tail_ = new_node;

④prev_tail->next = new_node;

可是当③执行完,CPU 发生线程切换,生产者暂停,还没执行prev_tail->next = new_node,消费者if (next_node == nullptr) return false;,认为队列空,丢失这条数据。所以应该调换③④顺序。哎真的无尽痛苦啊!!!被豆包误人子弟了!!!这个代码根本没任何顺序问题!!!就算④没执行就消费者pop了,为空返回push生产者,还会接着执行next指针更新,下次pop就读到了!!!

只有多生产才有问题,且无论是否调换顺序都有问题,就算调换成先④next后③tail:

①new 完节点,执行完②tail_.load()拿到旧的 prev_tail 本地副本,随即发生线程切换;新一轮的 push拿到CPU使用权,完整跑完入队,更新了全局 tail。旧 push 切回,拿着早已过期的本地 prev_tail 执行prev_tail->next = new_node,直接覆盖新 push 刚接入的节点,丢失数据。 不是全局 tail 旧,是线程本地保存的 prev_tail 快照过期!不调换也是一样!!!

且再次往深了思考,③④不调换时候,当先执行①②③,此时pop空返回,新push进来,完整压入任务,此时本该返回执行上次的最后一行代码④,结果pop拿到CPU,那么直接发现依旧是空,只要不执行④,就无穷压入任务也拿不到任务,一直空。

Q:是ABA吗?

A:不是!ABA 通俗解释:线程读到变量值是 A,中途切走;别的线程把它改成 B,又改回 A;切回来的线程看见还是 A,误以为状态没变,实际中间发生过改动。你的当前 SPSC demo 场景不涉及 ABA,你的 bug 是线程持有过期 tail 本地副本,覆盖新接入节点,属于读取旧快照带来的时序错误,没有 A‑B‑A 的地址轮回。

思考了这么多咋写到简历体现

至此了解顺序对SPSC无任何影响,记录下之前【认为只有先next再tail顺序才可以时候】的思考吧:

我思考既然容易断链,那把判断while (!tail_.compare_exchange_weak (prev_tail, new_node)) {改成while (!prev_tail->next.compare_exchange_weak (??, new_node)) {

其实CAS自旋毫无意义,去掉while,直接顺序调换就行,但现在得知顺序都不用调换了,可是之前哪知道啊,傻乎乎想了好多好多~~~~(>_<)~~~~

另外说一嘴,我真的痛苦万分真的服了!!我突然发现狗逼狗逼豆包给的while里那个prev_tail = tail_.load()没任何意义冗余!!!!!!

就算prev_tail从普通指针改成原子类型,补全期望值:

Node* prev_tail = tail_.load();
while (!prev_tail->next.compare_exchange_weak(nullptr, new_node)){//compare_exchange_weak第一个形参是Node*&非const左值引用,atomic<Node*>的模板参数是Node*,compare_exchange_weak第一个参数的引用类型追随模板参数,于是推导得到Node*&,nullptr属于临时值,不能绑定该引用,直接无法编译,但逻辑思路是对的
    // CAS失败,必须重新拉取最新尾节点,CAS失败说明prev_tail->next不再是nullptr,代表别的线程已经把新节点挂到该节点的next上,此时prev_tail已经不再是真正的尾节点,需要tail_.load()读取最新的尾指针,拿到新的prev_tail
    prev_tail = tail_.load();
}
tail_.store(new_node);

解释: prev_tail->next 作为队列尾节点时,它的后继必然是nullptr,所以预期值expected初始为nullptrcompare_exchange_weak语义:如果prev_tail->next == expected(nullptr),就把它修改为new_node

其实考虑这么多都是错的不考的但也因祸得福,学到了CAS的思想。 

首先之前说到CAS模拟fetch_add自增大材小用,CAS就是用来搞无锁数据结构的,比如无锁队列场景就是生产者消费者,而SPSC 只有一个线程写 tail,不存在竞争,CAS 自然派不上用场,CAS是在多生产多消费(MPMC)才用的,不是只要叫无锁队列就一定要写 CAS!!!

无锁(lock-free)标准定义是不使用互斥锁,线程之间同步依靠原子操作,原子操作分为两类:

  • 普通原子读写(load/store)

  • CAS 类原子读改写

无锁 ≠ 必须使用 CAS。单纯依靠原子 load/store、配合内存序实现并发安全的数据结构,同样属于无锁结构。

我的思考是那带CAS就是无锁数据结构了吗?其实也不是,此文搜“思考对比自旋锁 VS 互斥锁” ,是自定义自旋锁与 std::mutex 并发性能对比测试代码,对比自定义自旋锁和标准互斥锁在多线程竞争累加变量时的运行耗时:

  1. 只有原子变量实现自旋锁,不存在任何数据结构,也就不是无锁数据结构

  2. 是否是无锁,判定标准不是有没有链表 / 队列这类数据结构,而是算法是否依赖锁(CAS实现自旋锁、mutex 都属于锁机制)。

  3. CAS 是原子操作,CAS 本身不是锁;可以用 CAS 实现锁(自旋锁),也能用 CAS 实现无锁算法。

  4. 区分两条边界:

    • 自旋锁:利用 CAS 实现的锁机制 → 属于有锁并发

    • SPSC/MPMC 无锁队列:依靠原子操作(原子读写或 CAS)、不使用任何锁 → 无锁并发

核心结论: 有无数据结构无关;重点看代码逻辑是用原子操作实现锁,还是完全抛弃锁实现并发安全。 

再看 pop 函数:

bool pop(int& out) {
    Node* h = head_.load();
    Node* next = h->next;
    if (next == nullptr)
        return false;
    if (head_.compare_exchange_weak(h, next)) {
        out = next->val;
        return true;
    }
    return false;
}

目标:把 head 向后移动,取出 head 下一个节点的数据。 同样逻辑: 多个消费者同时执行 pop 时,多个线程读取同一个 head。 一个线程成功移动 head,另一个线程继续操作旧 head,会重复读取数据或者访问无效内存。 所以 pop 也只能单线程运行。

或者另一种情况:

pop 读取完 next=null,准备执行 return false,另一个线程立刻完成 push,链表新增节点,pop 不会重新读取 head 和 next,直接返回 false,这就是单次快照。

再分析,CAS 缺乏循环,竞争失败直接退出:

  1. 线程 1 执行 h = head_.load(),h = 哨兵节点 A

  2. 线程 1 执行 next = h->next,next = 节点 B

  3. 线程 2 抢先执行 pop,CAS 把全局 head_从哨兵节点 A 更新为节点 B

  4. 线程 1 执行head_.compare_exchange_weak(h, next) 此时全局 head_已经不是哨兵节点 A,CAS 执行失败,函数直接 return false。

对比带锁队列: 带锁队列:线程抢不到锁,操作系统直接暂停线程,产生开销。 这份无锁队列:只有一个生产者、一个消费者。没有锁,线程不会被操作系统挂起。线程循环不断重试 CAS,一直保持运行。 在持续高频收发数据场景,省去线程阻塞唤醒开销,速度更快。

局限也很清晰: 只允许一写一读,不能多个线程同时放数据、同时取数据。一旦突破这个限制,链表结构会损坏。

这也直接体现了队列整体操作,无法原子。

且prev_tail->next = new_node; 又无法放入while,CAS 只是更新 tail,链表赋值依旧是独立操作。

至此支离破碎的分析了很多收获很大。

所以push的顺序没问题!!但无奈一直被豆包误导学了错误的,就索性放上更改顺序的吧,加了内存序:

查看代码
#include <mutex>
#include <atomic>
#include <thread>
#include <iostream>

struct Node {
    int val;
    Node* next;
    Node(int v) : val(v), next(nullptr) {}
};

// 单生产者单消费者无锁队列
class SPSCLockFreeQueue {
private:
    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;
public:
    SPSCLockFreeQueue() {
        Node* dummy = new Node(0);
        head_.store(dummy, std::memory_order_relaxed);//增加了参数
        /*
        seq_cst:全局全序屏障,CPU 需要同步所有核心缓存,开销更大
        relaxed:仅保证原子性,无读写同步屏障;release 保证本线程前面所有写入对其他线程 acquire 可见
        重排只改动底层机器指令,不会调换C++源码两行
        */
        tail_.store(dummy, std::memory_order_relaxed);//...
    }

    void push(int val) {
        Node* new_node = new Node(val);
        Node* t = tail_.load(std::memory_order_relaxed);//细微的机器指令可以【不影响代码的正确性为前提】做调整,比默认的好
        t->next = new_node;//这个三行完全可以直接tail_=new_node啊,我感觉多此一举其实不然,看下面解释
        tail_.store(new_node, std::memory_order_release);
        /*
        release禁止 CPU 把t->next = new_node 重排到tail_.store 的后面
        如果重排发生:先更新tail_,再写next。消费者线程看到新 tail,但next还是空,链表直接损坏
        */
    }

    bool pop(int& out) {
        Node* h = head_.load(std::memory_order_acquire);
        /*
        acquire加载和生产者的release配对
        pop加载head用acquire,配对push的release,保证push内next节点写入对pop线程可见
        */
        Node* next_node = h->next;
        if (next_node == nullptr) 
            return false;
        out = next_node->val;
        head_.store(next_node, std::memory_order_relaxed);//再次说明这里没内存序约束会对无依赖的做重排,而就算用release,release也只是跨线程和acquire匹配的,根本无法保证他在本线程是最后,依旧可以前面的跑后面去!具体此文搜“e毫无意义,多线程里我”
        return true;
    }
    
    ~SPSCLockFreeQueue() {
        Node* cur = head_.load(std::memory_order_relaxed);
        while(cur != nullptr) {
            Node* del = cur;
            cur = cur->next;
            delete del;
        }
    }
    //拷贝构造:禁用对象拷贝构造
    SPSCLockFreeQueue(const SPSCLockFreeQueue&) = delete;

    //拷贝赋值:禁用对象拷贝赋值
    SPSCLockFreeQueue& operator=(const SPSCLockFreeQueue&) = delete;

    //移动构造:禁用对象移动构造
    SPSCLockFreeQueue(SPSCLockFreeQueue&&) = delete;

    //移动赋值:禁用对象移动赋值
    SPSCLockFreeQueue& operator=(SPSCLockFreeQueue&&) = delete;
    
    
/*
1. const SPSCLockFreeQueue&:const 左值引用,拷贝版本的参数
2. SPSCLockFreeQueue&&:右值引用,移动语义的参数
3. operator=:赋值运算符重载
4. 返回值SPSCLockFreeQueue&:返回自身引用,支持链式赋值
5. =delete:告诉编译器,不要生成这个函数,禁止使用
*/
};

/*
关键约束:
1. 必须保证只有一个线程调用 push,只有一个线程调用 pop;
2. 代码不处理节点回收,正式项目需要解决内存回收问题;
3. 操作顺序:先赋值tail节点->next,再更新 tail,杜绝链表断裂
*/



SPSCLockFreeQueue queue;

std::mutex cout_mtx;

void produce() {
    for (int i = 1; i <= 5; ++i) {
        queue.push(i);
        {
            std::lock_guard<std::mutex> lock(cout_mtx);//没这个会黏在一起,比如:push: pop: 22            
            std::cout << "push: " << i << "\n";
        }
    }
}

void consume() {
    int val;
    while (true) {
        if (queue.pop(val)) {
            {
                std::lock_guard<std::mutex> lock(cout_mtx);
                std::cout << "pop: " << val << "\n";
            }
            if (val == 5) break;
        }
    }
}
/*
Q:为啥cout里一个是val一个是i?
A:流程串: 
produce:i=1,调用 push (1) → 把 1 存入 Node 的 val 成员,节点入队列 
消费者 consume 调用 pop (val) 
pop 拿到链表下一个节点,把节点内保存的数字赋值给参数 out(也就是 consume 里的 val 变量) 
consume 拿到这个 val,打印输出,判断 val 等于 5 就退出循环
val 来源:push 存入节点里的数值,pop 把节点里的值拷贝出来给到 consume 的局部变量 val。

数字1、2存于链表各个Node节点内部,依靠head_、tail_指针维护链表顺序,永远从链表头部依次取出

out就是consume里的val,传入pop的是变量的引用,pop把节点的值写入out,外部就能拿到该值,起初传入要饭碗,pop把out装入碗里
*/

int main() {
    std::thread t1(produce);
    std::thread t2(consume);
    t1.join();
    t2.join();
}

几个问题:

Q:一般都是push1~5然后pop1~5,为啥5个一组?

A:CPU轮询巧合+生产者连续拿到CPU时间片,一口气push完1‑5,之后才切到消费者,于是一次性pop1‑5。

Q:输出加锁后不会出现  push: pop: 22 ,每次都是完整单独的输出,但居然会出现先pop1

A:queue.push和queue.pop分别和各自下面的cout不是原子组合,生产者 push (1) 跑完,消费者调度抢占,先执行 pop 拿到数据输出 pop:1,属于线程调度时序,不是 bug。

可以把打印语句放queue.push和queue.pop之前,是针对日志打印乱序的工程妥协方案,适合调试,不改动无锁队列核心逻辑。明确不能为了日志顺序,污染无锁队列高性能逻辑。绝对不要为了打印顺序,给无锁队列加锁,直接废掉无锁的性能收益。

面试官潜台词:考察你能不能分清「业务逻辑 bug」和「观测日志的显示 bug」,会不会为了表象,破坏底层组件性能。

回答:“pop 先输出是 OS 线程调度导致日志时序错乱,队列的数据流转本身是正确的。我不会修改无锁队列内核去适配打印,会把打印前置加锁做调试;生产环境改用专业日志库,依靠日志时间。

Q:压入弹出都完事了,为啥还要析构?

A:pop 只会移动 head_指针,不会 delete 节点。 pop 把哑节点向后挪,旧 head 节点直接丢弃,内存没释放,只是链表逻辑上看不见,堆内存还占着。

哪怕 5 个数据全部 pop 完毕: head_与 tail_会重新指向最初那个 dummy 哑节点,所有曾经 pop 出来的旧节点仍然在堆上,内存泄漏。 析构就是把这些逻辑已经弹出、但内存没释放的节点 + dummy 节点一起 delete 掉。

这个如果说线程没结束,对象先销毁会发生:正在运行的线程继续访问已销毁的队列对象,触发野指针程序崩溃,所以要写join等线程结束自动析构。

Q:关于禁止拷贝赋值

A:拷贝赋值不delete,当你写赋值拷贝语句的时候,编译器会自动隐式生,然后结合原子类型直接报错,而写了delete会告诉程序员别写拷贝赋值。

Q:为啥不拷贝赋值?

A:此文搜“为整条语句原子,直接删除该”

且就算不禁止,真的发生赋值比如:

class Queue{
    int* ptr; //👉这就是内部裸指针,ptr存一块堆内存的地址
};

int* ptr,裸指针变量,放在 Queue 类里面,叫内部裸指针。ptr 记录一块 new 出来堆内存的门牌号。

Queue q1; q1.ptr 指向堆上一块内存(地址 0x500)。

如果允许拷贝:Queue q2 = q1;,q2.ptr 直接抄成 0x500。q1、q2 的 ptr,同时都指向同一块 0x500 堆内存。

对象销毁,q1 先跑析构:delete ptr;,把 0x500 内存还给系统。

之后 q2 销毁,又执行一遍delete ptr;

注意:Queue q2 = q1; 是浅拷贝,只复制指针变量(门牌号),不复制背后堆内存。 两个对象指针指向同一块堆内存,析构都 delete → 双重释放崩溃。

深拷贝:拷贝的时候,新开辟一块堆内存,把旧内存的数据复制过去。 新对象拿到全新内存地址,各自管自己的内存,析构各删各的,不会双重释放。

总结:

  • 默认拷贝 = 浅拷贝,裸指针类不处理就会踩坑。

  • 不是禁止拷贝,是不能用编译器默认生成的拷贝。

  • 要么自己写深拷贝,要么直接=delete禁用拷贝。

无锁队列这种,内存结构复杂,做深拷贝难度极大,所以直接 delete 禁用。

科普:

  • lock-free:自旋锁抢不到锁,线程留在用户态不停循环,不求助操作系统、不暂停线程。

也叫乐观锁:依靠CAS做版本(解决ABA的)校验,假设冲突很少,不加锁,冲突就重试,fetch_add、无锁队列都是乐观锁思路。

  • mutex:抢不到锁,进入内核,线程暂停,让出CPU。

 也叫悲观锁:代表mutex,默认认为一定会发生竞争,直接加锁排他访问。

知道了CAS和fetch_add,引入个test_and_set

  • test_and_set :专属服务自旋锁

  • CAS:搞自旋锁大材小用,适合无锁队列

  • fetch_add:多用于原子计数,统计队列元素数量

先看你已经掌握的两个原子操作: CAS:传入预期旧值、新值。只有内存当前值等于预期旧值,才把内存改成新值;整个操作原子完成。修改不匹配时,内存不会发生任何改动。

fetch_add:给内存上的值做原子加法,返回加法执行之前的旧值。它是无条件执行,加法一定会生效。

现在看一类实际需求:我手上只有 1bit 布尔标记,我想要无条件把这一 bit 置 1,同时拿到置 1 之前原来的值

CAS实现这件事:你需要反复传入预期值,判断当前是不是 0,再写成 1,要写判断分支。 用fetch_add实现这件事:做加 1 会改变整个数值,不适合单纯只置 1 的布尔标记场景。

C++ 标准库提供std::atomic_flag,不能直接读取内部状态,不能赋值,仅允许test_and_set()clear()操作,其中test_and_set(),就是专门对应这个 “无条件置 1,返回修改前旧值” 的原子语义。

test_and_set()执行逻辑:

  1. 无条件把atomic_flag内部的布尔位设置为true

  2. 原子返回修改发生前,这个位原本的值。

两种运行情况:

  • 标记原来为false:调用test_and_set(),标记强行置true,返回false

  • 标记原来为 true:调用test_and_set(),标记强行置true,返回true

查看代码
std::atomic_flag spin_lock = ATOMIC_FLAG_INIT;//固定语法,ATOMIC_FLAG_INIT是初始化宏,用来把std::atomic_flag初始成未置位false状态

// 获取锁:循环调用test_and_set(),抢不到就空转
while(spin_lock.test_and_set());

// 临界区:多线程竞争访问共享变量
count++;//处于自旋锁保护的临界区内,锁保证同一时刻仅一个线程进入,因此count++不需要原子修饰

// 释放锁
spin_lock.clear();

clear()用来原子释放锁,把标志位改回false

 科普语法:

 代码里为啥语法上搞了三行这个东西:

Node* t = tail_.load(std::memory_order_relaxed);

t->next = new_node;

tail_.store(new_node, std::memory_order_release);

先看代码

#include <atomic>
#include<iostream>
struct Node{int x;};

int main()
{
    std::atomic<Node*> at;
    Node real_node;   // 真实节点
    at = &real_node;  // 给原子变量存入有效节点地址

    Node* p = at.load();
    p->x = 1;
    std::cout<<p->x<<std::endl;
}

验证 std::atomic<Node*> 这个对象本身能不能直接用 -> 去访问它内部存的那个指针所指向节点的成员。

std::atomic<int> a a 是 std::atomic 模板实例出来的类对象,对象内部只存放一个普通 int 值

std::atomic<int> a;
a.store(10);
int x = a.load();

内部存的就是单纯整数 10,把 int 包起来,实现原子读写。

对比: std::atomic<Node*>:对象内部存Node * 指针(地址) 

std::atomic<T>,尖括号填 T,实例出来的对象内部就保存一份 T 类型的数据。

 可以做的操作:

  • .store(val):把 val 存入原子对象内部

  • .load():读出内部保存的值

  • =赋值、隐式转换(部分支持)

  • 还有compare_exchange_weakcompare_exchange_strongCAS 操作。

不能直接拿内部存的数据用.->去访问,必须先 load 拿出来,再操作。

分清两个完全不同的结构体:

Node 结构体:

struct Node{
    int val;
    Node* next;
};

Node 对象:内存里有int val + Node* next两个成员。这里的 next 是存放在 Node 内部的指针,指向另一个 Node。

std::atomic<Node*>at ,atstd::atomic模板实例出来的另一个独立类的对象,不是 Node 对象。 std::atomic<Node*>at这个对象内部,只保存一个成员:Node*(仅仅是一个指针变量)。 它没有 val,没有 next,根本不是节点。

  • Node:真正的节点,存 val、next。

  • std::atomic<Node*> 对象 at:包装器,只存一个 Node * 地址,用来原子读写这个地址,不含节点的数据。

普通Node* ptrptr->x合法,std::atomic<Node*> atat->x编译报错,不支持。

at.x写法无论是否是原子类型都肯定错,atstd::atomic<Node*>对象,没有 x 成员,就算普通类型指针也没x成员。

标准std::atomic<T*>没有重载operator->

原因:如果提供->tail_->next会等价于:

  1. 原子读取出指针

  2. 访问成员next

两步不是原子,中间别的线程可以修改原子内部指针,产生隐蔽 bug,标准直接不提供这个重载。

->包含两步:解引用 + 访问成员。 tail_->next等价于(*tail_).next

t拿到tail_保存的指针地址,t->next访问的就是该内存地址对应节点的next成员,等同于对tail_所指向节点的next访问(即tail_(解引用后)的next),因为存的是同一个节点的内存地址。