侵入式链表
定义
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) );})
逻辑:
-
计算
member在结构体里面的字节偏移; -
把 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):读拷贝更新。
-
读端:不加锁,直接遍历侵入式链表 list_head;
-
写端(增、删、改)拿一把自旋锁
spinlock_t; -
删除节点时:不立刻 free 内存;
-
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);
}
关键点:
-
读端没有锁,遍历速度极高;大量读少写场景(路由表、设备列表)性能爆炸;
-
写端必须串行(spinlock);
-
list_del_rcu()只是从链表摘掉;内存要延后由 RCU 机制释放; -
在读临界区里面,不能 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 读到变量值 = A,准备执行 CAS (A, B)
-
线程 2 把变量改成 B,再改回 A
-
线程 1 执行 CAS,看到还是 A,CAS 成功。
但实际上数据已经被改动过一轮,逻辑已经错了。
在指针场景下尤其危险:
节点地址 A 被删除、free,又新 malloc 刚好拿到同样地址 A,CAS 误认为没变。
解决办法:
-
版本号:带上计数器,每次修改版本 + 1;比较
(ptr, version)二元组。 -
Hazard Pointer / RCU:不让内存被释放回收,从根源避免地址复用。
2.循环自旋开销(活锁)
多个线程不停抢 CAS,不断失败,CPU 空转。
没有阻塞,但是 CPU 占满。
高竞争场景 CAS 性能会暴跌,甚至不如自旋锁 /mutex。
工程缓解手段:
-
CAS 自旋几次之后加入短暂退避(pause 指令 / 小 sleep),减少 CPU 争抢。
-
不要在极高冲突下硬上 MPSC 无锁,高冲突改用自旋锁队列。
3. 只能保证单个内存地址原子操作
CAS 只能原子操作一个内存位置。
不能原子同时修改两个独立变量。
比如同时修改 a 和 b,一条 CAS 做不到。
x86 有 CMPXCHG8B / CMPXCHG16B,可以一次操作 16 字节,也有限。
slub对象池
slab/slub 就是内核专用对象池 ,专门管理大量固定大小、频繁分配释放 的小内核对象(task_struct、inode、dentry、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 算法。