在前面的系列文章中,我们深入分析了Linux内核的内存页回收与Swap机制、进程调度器(CFS与EEVDF),以及缺页异常与内存映射。这些子系统都面临一个共同的挑战:如何在多核并发环境下保护共享数据? 本文将全面解析Linux内核的同步机制,从底层原子操作一路走到RCU无锁读优化,帮你在生产环境中做出正确的技术选型。
一、为什么需要同步?从竞态条件说起
在SMP(对称多核)系统中,多个CPU核心可能同时访问同一块内核数据结构。经典的竞态场景包括:
- 并发修改链表:CPU0正在遍历链表,CPU1同时删除同一节点,导致use-after-free
- 读取-修改-写回非原子:两个CPU同时执行counter++,实际只加了一次
- 指令重排:编译器或CPU为了优化性能可能重排内存操作顺序,导致逻辑错误
Linux内核提供了分层的同步原语栈,从最底层的硬件原语到高层的无锁机制:
┌─────────────────────────────────────────────┐
│ RCU(读多写少,无锁读) │
│ Seqlock(轻量写优先) │
│ rwlock / rw_semaphore(读写分离) │
│ mutex(互斥锁,可睡眠) │
│ spinlock(自旋锁,忙等待) │
│ atomic_t / bitops(原子操作) │
│ barrier(内存屏障) │
│ Hardware CAS / LL-SC / Memory Ordering │
└─────────────────────────────────────────────┘
二、底层基石:原子操作与内存屏障
2.1 原子操作(Atomic Operations)
原子操作是不可中断的最小执行单元,Linux内核通过atomic_t和atomic64_t类型提供架构无关的原子语义:
// 原子变量定义与初始化
atomic_t ref_cnt = ATOMIC_INIT(1);
// 原子读取
int val = atomic_read(&ref_cnt);
// 原子设置
atomic_set(&ref_cnt, 2);
// 原子加减
atomic_inc(&ref_cnt); // ++ref_cnt
atomic_dec(&ref_cnt); // --ref_cnt
atomic_add(3, &ref_cnt); // ref_cnt += 3
// 原子比较并交换(CAS)- 无锁算法的基础
int expected = 1;
bool success = atomic_compare_exchange_strong(&ref_cnt, &expected, 0);
// 如果 ref_cnt == expected,则 ref_cnt = 0,返回 true
// 否则 expected = ref_cnt,返回 false
// 原子位操作
unsigned long flags = 0;
set_bit(3, &flags); // flags |= (1<<3)
clear_bit(3, &flags); // flags &= ~(1<<3)
test_and_set_bit(0, &flags); // 返回旧值并设置
change_bit(2, &flags); // 翻转位
// 无锁队列中经典的CAS循环
void list_push(atomic_list_head *head, struct node *new) {
do {
new->next = atomic_load(&head->next);
// 在load和CAS之间,可能有其他线程修改了head->next
} while (!atomic_compare_exchange_weak(&head->next, &new->next, new));
// CAS失败则重试,weak版本在伪失败时也返回false,更适合循环
}
ARM64架构下原子操作的底层实现:
// ARM64 atomic_add: ldadd(load-acquire + add + store-release)
ldadd w0, [x1]
// ARM64 CAS: ldaxr / stlxr pair(load-acquire-exclusive + store-release-exclusive)
// 如果中间有写入,stlxr返回1,需重试
casal w0, w2, [x1] // VMAv8.1+的CAS指令,一条指令完成
2.2 内存屏障(Memory Barrier / Fence)
现代CPU使用乱序执行(Out-of-Order)优化性能,但多核环境下可能导致跨核观察到的执行顺序不一致。内存屏障保证操作的有序性:
// Linux内核屏障层级(从弱到强)
// 1. 编译器屏障:仅阻止编译器重排
barrier(); // asm volatile("" ::: "memory")
// 2. 全内存屏障:阻止读写两侧的所有重排
mb(); // 读+写屏障(最强)
smp_mb(); // 多核间全屏障(单核下编译为barrier)
// 3. 读内存屏障
rmb(); // 所有读操作在barrier前后有序
smp_rmb(); // 多核间读屏障
// 4. 写内存屏障
wmb(); // 所有写操作在barrier前后有序
smp_wmb(); // 多核间写屏障
// 5. 获取-释放语义(Acquire-Release)
unsigned long flags_or_val = smp_load_acquire(ptr); // acquire: 之后的读写不能重排到前面
smp_store_release(ptr, new_val); // release: 之前的读写不能重排到后面
// 经典场景:双重检查锁定(DCL)
struct instance *get_instance(void) {
struct instance *inst = smp_load_acquire(&p_inst); // acquire读
if (!inst) {
spin_lock(&lock);
inst = p_inst;
if (!inst) {
inst = create_instance();
// 保证inst完全初始化后,才让其他线程看到非NULL的p_inst
smp_store_release(&p_inst, inst); // release写
}
spin_unlock(&lock);
}
return inst;
}
acquire-release配对原理:release-store保证之前的所有写入在存储之前完成;acquire-load保证之后的所有读取在加载之后执行。两者配合,其他线程看到新值时也能看到对应的所有写入。x86的TSO模型天然提供acquire-release(除StoreLoad),ARM64需要ldar/stlr指令显式保证。
三、自旋锁(Spinlock):最基础的忙等待锁
3.1 核心机制
自旋锁是最简单的内核同步原语:获取失败时循环等待(spin)而非睡眠。适用于临界区极短(几条指令)、不允许睡眠的场景(如中断上下文)。
// ARM64上spinlock底层实现(LDXR/STXR exclusive monitor机制)
static inline void arch_spin_lock(arch_spinlock_t *lock)
{
unsigned int tmp;
arch_spinlock_t lockval, newval;
asm volatile(
// 1. 等待本地exclusive monitor空闲
" prfm pstl1strm, %3\n" // 预取提示
"1: ldaxr %w0, %3\n" // 原子加载+exclusive
" add %w1, %w0, %w5\n" // 计算新值(+1表示获取锁)
" stxr %w2, %w1, %3\n" // 尝试存储(exclusive)
" cbnz %w2, 1b\n" // 失败则重试
// 2. 检查是否真的获得了锁(ticket机制)
" eor %w1, %w0, %w0, ror #16\n" // 比较owner与next
" cbz %w1, 3f\n" // 相等则获得锁
// 3. 排队等待(WFE低功耗自旋)
" sevl\n" // 防止错过event
"2: wfe\n" // 等待event(省电)
" ldaxrh %w2, %4\n" // 加载当前owner
" cmp %w1, %w2\n" // 轮到我了吗?
" bne 2b\n" // 没有则继续等待
"3:" // 获得锁,执行临界区
: "=&r" (lockval), "=&r" (newval), "=&r" (tmp), "+Q" (*lock)
: "Q" (lock->owner), "I" (1 << 16), "Q" (lock->tickets)
: "memory");
}
这段汇编展示了Linux内核自旋锁的三个关键设计:
- Exclusive Monitor:硬件级别的独占访问跟踪,LDXR标记地址为独占,STXR仅在标记有效时写入
- Ticket机制:高16位是"下一张票",低16位是当前叫号,保证FIFO公平性,避免旧版test-and-set的"thrashing"问题
- WFE低功耗等待:等待时进入低功耗状态,比纯忙等节能90%+
3.2 变体与场景
// 1. 普通自旋锁
spinlock_t lock;
spin_lock_init(&lock);
spin_lock(&lock);
// ... 临界区(不能睡眠!)
spin_unlock(&lock);
// 2. 中断安全自旋锁(关闭本地中断)
unsigned long flags;
spin_lock_irqsave(&lock, flags); // 保存中断状态并关中断
// ... 临界区
spin_unlock_irqrestore(&lock, flags);
// 3. 底半部安全自旋锁(关闭softirq/bh)
spin_lock_bh(&lock);
// ... 临界区
spin_unlock_bh(&lock);
// 4. 读写自旋锁
rwlock_t rwlock = RW_LOCK_UNLOCKED;
read_lock(&rwlock); // 多个读者可同时持有
read_unlock(&rwlock);
write_lock(&rwlock); // 写者独占
write_unlock(&rwlock);
// 5. 顺序锁(Seqlock)- 写优先,读者可能重试
seqlock_t seq_lock = SEQLOCK_UNLOCKED;
// 写者:
write_seqlock(&seq_lock);
// ... 写入共享数据
write_sequnlock(&seq_lock);
// 读者(无锁,但需检查序列号):
unsigned int seq;
do {
seq = read_seqbegin(&seq_lock);
// ... 读取共享数据
} while (read_seqretry(&seq_lock, seq));
3.3 自旋锁的陷阱
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 临界区内睡眠 | 自旋锁持有期间若触发调度,另一个CPU会死锁自旋 | 需要睡眠时用mutex,或用workqueue延迟 |
| 持有时间过长 | 其他CPU空转浪费能量,破坏实时性 | 缩短临界区,考虑RCU或per-cpu拆分 |
| 中断中重复获取 | 同一CPU在中断中spin_lock会导致死锁 | 使用spin_lock_irqsave关闭中断 |
| 递归获取 | spin_lock不支持递归(UNIQUE owner) | 使用mutex或重构避免递归 |
四、互斥锁(Mutex):可睡眠的重量级互斥
struct mutex 是Linux内核最通用的睡眠锁原语,适用于可能阻塞的临界区。
struct mutex {
atomic_long_t owner; // 0=空闲,非0=持有者task_struct指针
spinlock_t wait_lock; // 保护等待队列
struct list_head wait_list; // 等待队列(FIFO)
// 开启CONFIG_DEBUG_MUTEX后添加:调试信息字段
};
// 标准用法
struct mutex data_lock;
mutex_init(&data_lock);
mutex_lock(&data_lock); // 获取(可能睡眠)
// ... 临界区(可以睡眠、可以触发缺页)
mutex_unlock(&data_lock);
// 尝试获取(不睡眠)
if (mutex_trylock(&data_lock)) {
// ... 临界区
mutex_unlock(&data_lock);
} else {
// 锁已被占用,执行替代逻辑
}
// 带超时的获取(5.15新增)
int ret = mutex_lock_timeout(&data_lock, msecs_to_jiffies(1000));
if (ret == -ETIMEDOUT) { /* 超时处理 */ }
// 锁管理器(lockdep)检测死锁
mutex_lock(&lock_A);
mutex_lock(&lock_B); // lockdep检查:B是否可能在其他路径已经持有A?
// ... 临界区
mutex_unlock(&lock_B);
mutex_unlock(&lock_A);
Mutex与Spinlock选型:
- 临界区可能睡眠/阻塞 → Mutex
- 临界区极短(几条原子指令)、中断上下文 → Spinlock
- 读多写少 → rw_semaphore 或 RCU
- 写多读少、写操作很短 → Seqlock
五、RCU(Read-Copy-Update):读多写少的终极武
5.1 设计哲学
RCU的核心思想是分离读写路径,让读者完全无锁。读者不需要任何原子操作或内存屏障(在大多数架构上),性能接近无锁代码。代价是写操作延迟回收旧数据,这就需要grace period(宽限期)。
// RCU读侧临界区
struct my_node *find_node(int key) {
struct my_node *node;
rcu_read_lock(); // 标记读侧开始(仅是抢占点+屏障)
node = rcu_dereference(head); // 加载指针(acquire语义)
while (node && node->key != key) {
node = rcu_dereference(node->next);
}
// 不能在这里睡眠或切换任务!rcu_read_lock仅是标记
rcu_read_unlock(); // 标记读侧结束
return node;
5.2 写侧:复制更新与延迟回收
// RCU写侧:复制更新(Copy-on-Write模式)
void update_node(int key, int new_value) {
struct my_node *old, *new;
// 1. 创建新节点(复制旧数据+修改)
old = rcu_dereference_protected(head, lockdep_is_held(&update_mutex));
new = kmalloc(sizeof(*new), GFP_KERNEL);
*new = *old; // 复制
new->value = new_value; // 修改
// 2. 原子切换全局指针(读者看到新数据)
spin_lock(&update_mutex);
rcu_assign_pointer(head, new); // release语义存储
spin_unlock(&update_mutex);
// 3. 注册宽限期回调:所有现有读者完成后回收旧数据
call_rcu(&old->rcu_head, free_node);// 宽限期后回调free_node()
}
static void free_node(struct rcu_head *rh) {
struct my_node *old = container_of(rh, struct my_node, rcu_head);
kfree(old);
}
5.3 Grace Period:宽限期的本质
Grace period 是指从写侧更新指针开始,到所有在读侧临界区开始之前就存在的读者都离开读侧临界区为止的时间段。
时间线:
CPU0(读者): |rcu_read_lock|........读取旧节点.......|rcu_read_unlock|
CPU1(读者): |rcu_read_lock|.....读取旧节点.....|rcu_read_unlock|
CPU2(写者): |rcu_assign_pointer| ← 更新指针
↓
grace period开始
↓
grace period结束
↓
call_rcu 回调执行 → kfree(旧节点)
关键:grace period 内的读者在CPU0和CPU1都已退出读侧临界区后才结束
call_rcu注册的回调此时安全执行,因为不可能再有读者访问旧数据
grace period的两代实现:
- Classic RCU:等待所有CPU经过一次上下文切换(context switch),因为读者不能在内核态睡眠
- Tree RCU(CONFIG_PREEMPT_RCU):基于树的层次化追踪,两阶段grace period,避免每次GP都需要全QS扫描
- Sleepable RCU(SRCU,Linux 5.x+):允许读侧睡眠,使用计数器+等待队列替代context-switch检测,代价是读者需要原子操作
5.4 经典应用场景
场景1:路由表查找(路由子系统)
// 路由缓存使用RCU:查路由高频写路由低频
struct rtable *ip_route_output(struct net *net, ...) {
// 读侧:rcu_read_lock → 查fib_lookup → rcu_dereference结果 → rcu_read_unlock
// 零原子操作零内存屏障开销
}
场景2:进程描述符访问(find_task_by_pid)
// 任务哈希表:经常查(ps, kill, ptrace),少创建(fork)
struct task_struct *find_task_by_pid(pid_t pid) {
struct task_struct *task;
rcu_read_lock();
task = pid_hash_find(pid); // 读侧遍历
rcu_read_unlock();
return task;
}
// fork时:
rcu_assign_pointer(pid_hash[hash], new_task); // 插入
exit时:
call_rcu(&task->rcu, delayed_put_task_struct); // 延迟释放
场景3:文件描述符表(FD table)
// 文件描述符表:每次read/write/poll都要访问fd table,dup2才修改
// 使用RCU管理,PCB中的files_struct通过rcu_dereference读取
task->files = files → smp_store_release(写侧)
current->files → rcu_dereference + rcu_read_lock(读侧)
5.5 性能对比
| 原语 | 读者开销 | 写者开销 | 延迟影响 | 最大读者数 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Spinlock | 原子XCHG+屏障(~50 cycles) | 原子XCHG(~50 cycles) | 阻塞链 | 无限制 | ||||||||||||||||||||
| Mutex | atomic CAS+可能睡眠(~100~∞) | atomic CAS(~100 cycles) | 不可预测 | 1持有者 | ||||||||||||||||||||
| RWLock | 原子ADD+屏障(~30 cycles) | 原子XCHG(~50 cycles) | 写者饥饿 | 大量读者 | ||||||||||||||||||||
| SeqLock | :原子LD+序列号校验(~5 cycles)atomic ADD(~5 cycles) | 读者重试 | 大量读者 | |||||||||||||||||||||
RCU(经典)</td><td><strong>纯内存读取(0 cycles!)</strong></td><td>原子XCHG+call_rcu=</td><td>宽限期延迟</td><td>无限制</td></tr</tbody></table>
5.6 与前一篇文章的联系在缺页异常与内存映射一文中我们提到VMA红黑树管理。Linux内核的mm_struct中的VMA链表正是使用RCU保护读取的——每次缺页异常查找VMA时,通过 六、高级技巧:无锁数据结构与顺序锁6.1 Lock-Free队列(kfifo / lfq)Linux内核的
6.2 Seqlock:写优先的顺序锁Seqlock解决了读写锁中写者饥饿的问题。每次写操作更新序列号,读者通过校验序列号检测写冲突并重来。
6.3 Per-CPU变量消除跨核竞争的终极方案:每个CPU维护一份数据副本,需要时合并结果。
注意:per-cpu变量在抢占/中断安全性和统计准确性之间存在权衡。如果任务被抢占后迁移到另一CPU,per-cpu计数器就会"丢失"——内核通过 七、生产环境选型指南
性能调优实战
八、总结Linux内核的同步原语设计哲学体现了"没有银弹"的工程思维:
理解这些原语的底层实现(原子指令、内存屏障、exclusive monitor)不仅能让我们正确选型,更能帮助我们在面对复杂并发问题时设计出高效且正确的同步方案。在我们的系列文章中,从Swap机制的页面链表保护(Spinlock + LRU),到缺页异常中的VMA红黑树遍历(RCU),再到调度器中的rq锁(Spinlock)和CFS红黑树(rq->lock),可以说每个并发场景背后都有这些同步原语的影子。 系列文章回顾:
评论列表 共有 0 条评论 |

发表评论 取消回复