内核中的侵入式链表,slub对象池,CAS

侵入式链表

定义

复制代码
 struct list_head {
     struct list_head *next, *prev;
 };

不存业务数据 ,只有指针。把这个小结构体嵌入你的业务结构体里面

复制代码
 struct student {
     char name[32];
     int age;
     struct list_head node;   // 嵌入链表节点
 };

普通非侵入链表(std::list):

复制代码
 Node{ data, next } → 要单独new节点

侵入式:业务对象本身就自带链表挂载点,不需要额外分配节点内存

container_of 魔法

已知 struct list_head *node,如何拿到外层 struct student

复制代码
 #define container_of(ptr, type, member) ({                      \
     const typeof( ((type *)0)->member ) *__mptr = (ptr);    \
     (type *)( (char *)__mptr - offsetof(type,member) );})

逻辑:

  1. 计算 member 在结构体里面的字节偏移;

  2. 把 list_head 指针向前回退这个偏移,得到整个结构体起始地址。

使用示例:

复制代码
 struct list_head *p = ...;
 struct student *s = container_of(p, struct student, node);

一个结构体可以嵌入多个 list_head,同时挂到多条不同链表。

比如 task_struct 同时挂:进程全局链表、就绪链表、僵尸链表。

RCU + list_head + 写端自旋锁 ------ 内核 MPMC

重点:内核很少手写 CAS MPMC 无锁链表(Michael‑Scott)。

纯 CAS 无锁链表痛点:ABA 问题、节点内存回收极其难搞。

内核选择一套工程上更稳妥的方案:读无锁,写加锁,RCU 解决内存回收

RCU 思想简述

RCU (Read‑Copy‑Update):读拷贝更新。

  1. 读端:不加锁,直接遍历侵入式链表 list_head

  2. 写端(增、删、改)拿一把自旋锁 spinlock_t

  3. 删除节点时:不立刻 free 内存

  4. RCU 等待:所有正在读这条链表的 CPU 全部退出 RCU 读临界区之后,才调用回调释放旧节点。

RCU 帮你解决两大噩梦:ABA 问题 + 内存回收

复制代码
 spinlock_t lock;
 struct list_head head;
 ​
 // 【读路径:完全不加锁】
 void read_list(void)
 {
     rcu_read_lock();          // 进入RCU读临界区
     struct list_head *pos;
     list_for_each_rcu(pos, &head) {
         struct student *s = container_of(pos, struct student, node);
         // 访问s的数据
     }
     rcu_read_unlock();        // 退出读临界区
 }
 ​
 // 【写路径:加锁修改链表】
 void del_entry(struct student *s)
 {
     spin_lock(&lock);
     list_del_rcu(&s->node);   // RCU版本删除,只是把节点摘出链表,不释放内存
     spin_unlock(&lock);
 ​
     // 等待所有读端全部离开临界区,再释放内存
     kfree_rcu(s, rcu);
 }

关键点:

  1. 读端没有锁,遍历速度极高;大量读少写场景(路由表、设备列表)性能爆炸;

  2. 写端必须串行(spinlock);

  3. list_del_rcu() 只是从链表摘掉;内存要延后由 RCU 机制释放;

  4. 在读临界区里面,不能 sleep!RCU 读端不允许休眠。

适用场景

读极多,写很少的数据结构:内核路由表、netfilter 规则、设备链表。

对比用户态无锁:

用户态写 MPMC CAS 无锁链表,需要自己实现 Hazard Pointer 做内存回收,极其复杂。

内核直接用 RCU 硬件原语,避开这些坑。

MPSC

MPSC 多生产者,单消费者(多写一读)

入队:多个生产者用 CAS 竞争修改头指针;

出队:只有一个消费者,不需要 CAS 竞争出队。

这是 CAS 用得最舒服场景。

很多 MPSC 无锁队列(moodycamel)就是这个路子。

✅ 多写靠 CAS 竞争;读只有一个,没有读竞争。

模型 线程模型 是否用 CAS 主要痛点
SPSC 单写单读 1 写 1 读 不需要 CAS 只要内存屏障,最简单
SPMC 单写多读 1 写 N 读 可以用 CAS CAS 解决索引竞争,但 FIFO 顺序会乱,不能做消费队列
MPSC 多写单读 N 写 1 读 大量用 CAS 入队 CAS 竞争;出队无竞争,体验很好
MPMC 多写多读 N 写 N 读 大量用 CAS CAS 只解决指针更新;还要处理 ABA、内存回收,实现极复杂

CAS (Compare‑And‑Swap,比较并交换)

CAS 是 CPU 提供的原子硬件指令,是几乎所有无锁编程的基石。

原子:整个操作不可被 CPU 中断,要么全部做完,要么完全不做,不会卡在中间状态。

无阻塞:线程不会陷入内核休眠,用户态完成,没有系统调用开销。

伪代码

复制代码
 // addr:要修改的内存地址
 // expect:预期旧值
 // newval:想要写入的新值
 bool cas(volatile int *addr, int expect, int newval)
 {
     // 硬件原子完成下面两步,中间不会被别的CPU打断
     if (*addr == expect) {
         *addr = newval;
         return true;   // 修改成功
     } else {
         return false;  // 值已经被别人改了,什么都不改
     }
 }

通俗一句话:

我认为内存现在是 expect,如果确实是,就改成 newval;否则不干,返回失败。

无阻塞:线程不会陷入内核休眠,用户态完成,没有系统调用开销。

CAS 三大经典问题

1. ABA 问题(最出名)

内存从 A → B → 又变回 A。

CAS 只看 "值是不是等于 expect",识别不出中间发生过变化。

举例:

  1. 线程 1 读到变量值 = A,准备执行 CAS (A, B)

  2. 线程 2 把变量改成 B,再改回 A

  3. 线程 1 执行 CAS,看到还是 A,CAS 成功。

    但实际上数据已经被改动过一轮,逻辑已经错了。

在指针场景下尤其危险:

节点地址 A 被删除、free,又新 malloc 刚好拿到同样地址 A,CAS 误认为没变。

解决办法:

  • 版本号:带上计数器,每次修改版本 + 1;比较 (ptr, version) 二元组。

  • Hazard Pointer / RCU:不让内存被释放回收,从根源避免地址复用。

2.循环自旋开销(活锁)

多个线程不停抢 CAS,不断失败,CPU 空转。

没有阻塞,但是 CPU 占满。

高竞争场景 CAS 性能会暴跌,甚至不如自旋锁 /mutex。

工程缓解手段:

  1. CAS 自旋几次之后加入短暂退避(pause 指令 / 小 sleep),减少 CPU 争抢。

  2. 不要在极高冲突下硬上 MPSC 无锁,高冲突改用自旋锁队列。

3. 只能保证单个内存地址原子操作

CAS 只能原子操作一个内存位置

不能原子同时修改两个独立变量。

比如同时修改 ab,一条 CAS 做不到。

x86 有 CMPXCHG8B / CMPXCHG16B,可以一次操作 16 字节,也有限。

slub对象池

slab/slub 就是内核专用对象池 ,专门管理大量固定大小、频繁分配释放 的小内核对象(task_structinodedentry、socket、网络包描述符)。

底层是伙伴系统 (buddy) 给它提供整页;slub 把一页切分成 N 个一模一样大小的对象,做成对象池子,避免频繁向伙伴系统申请页面,减少内存碎片,提升多核并发性能。

现代 Linux 默认 SLUB;老的 SLAB 已逐步淘汰;SLOB 给极小嵌入式设备用。

SLUB 三级分配路径(由快到慢)

第 1 级:Per‑CPU 本地 slab(快速路径,无锁)

当前 CPU 的kmem_cache_cpu保存活跃 slab page 和 freelist 空闲链表。

  • 分配:直接从本 CPU freelist 取对象;不需要锁

  • 释放:对象归还到本 CPU 的 freelist。

绝大多数热路径分配释放都命中这里,性能极高。

第 2 级:Per‑CPU partial 链表

本地活跃 slab 全部用光,freelist 为空。

从该 CPU 自己的 partial 链表拿一个部分空闲 slab page,作为新的本地活跃 slab,继续分配。依然尽量不碰全局锁。

第 3 级:NUMA 节点全局 partial 链表(慢速路径,需要锁)

CPU 自己的 partial 也空了。

访问 NUMA 节点的kmem_cache_node,全局 partial 链表,拿别人剩下的 partial slab;这里会加锁。

兜底路径

全局也没有 partial slab,调用伙伴系统 buddy allocator,申请全新物理页,切割出新 slab,再分配对象。

释放对象逻辑反过来:优先归还本 CPU 本地 slab;slab 全部空闲之后,满足条件就把 page 还给 buddy,真正释放物理内存。

SLUB 设计哲学:热路径尽量无锁;实在有竞争的冷路径,用锁,而不是硬上复杂无锁 CAS 算法。

相关推荐
酷可达拉斯2 小时前
Linux操作系统-tcpdump抓包定位网络问题实战
linux·运维·服务器·网络·tcpdump
byte轻骑兵2 小时前
BlueZ 5.x 整体架构总览:用户态 + 内核态分层设计核心逻辑
linux·架构·bluez·电脑蓝牙·嵌入式蓝牙
ltl10 小时前
大多数「无锁」代码其实不是无锁的
linux
ltl10 小时前
Linux 内核的内存屏障:一个让我调了三天的 bug
linux
Lonely 净土12 小时前
Rocky Linux 安装教程
linux·运维·服务器
李白你好16 小时前
Windows 离线 Linux 提权辅助查询工具
linux
Lust Dusk17 小时前
记一次Linux应急响应题目解析
linux·运维·服务器·网络·安全·网络安全
ShineWinsu17 小时前
对于Linux:五种IO模型以及非阻塞IO的详细解析
linux·c++·面试·io·阻塞·非阻塞·fcntl
千千寰宇17 小时前
[Linux/Ollama] Termux : 一款运行在 Android 平台上的开源终端模拟器与 Linux 环境应用、支持在手机上基于Ollama部署LLM
linux