Linux 内核同步机制与锁优化深度实战

Linux 内核同步机制与锁优化深度实战

从原语实现到生产级优化,覆盖 spinlock、mutex、rwlock、seqlock、semaphore 内部原理,lockdep 死锁检测,内存屏障,percpu 原子操作,缓存伪共享,NUMA 感知策略。


一、为什么锁是内核最复杂的子系统

Linux 内核运行在真正的并发环境中:多核 CPU、中断、软中断、tasklet、内核抢占、SMP、NUMA 拓扑、异步 I/O 完成事件——所有这些可能同时访问同一块共享数据。一个看似无害的写操作,如果在错误的时机被打断、被另一个核并发修改、或者被编译器乱序,都会在凌晨三点引发"幽灵 bug"。

内核同步的难度不仅来自并发源的多样性,更来自性能与正确性的永恒博弈:锁太细,开销剧增;锁太粗,系统串行化;锁错了,死锁或活锁可能数周才触发一次。


二、自旋锁 Spinlock:短临界区的王者

2.1 内部实现

Linux 内核的 spinlock 从最早期的简单 xchg/test-and-set,演化到 MCS 锁优化版本,再到 PREEMPT_RT 下的 rtmutex 替代实现。

// 简化版 MCS spinlock 核心逻辑
struct mcs_spinlock {
    struct mcs_spinlock *next;
    int locked;  // 1 = 获得锁
    int count;   // 用于嵌套
};

// 入锁:原子交换将自己加入队尾
void mcs_spin_lock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
    struct mcs_spinlock *prev;
    node->locked = 0;
    node->next = NULL;
    prev = xchg(lock, node);  // 原子交换队尾指针
    if (likely(prev == NULL)) {
        // 无等待者,直接获得锁
        return;
    }
    // 有前驱,设置前驱的 next 指针并等待
    prev->next = node;
    smp_wmb();  // 保证 next 写入对后继可见
    while (!READ_ONCE(node->locked))
        cpu_relax();  // PAUSE 指令降低功耗
}

// 出锁:唤醒后继
void mcs_spin_unlock(struct mcs_spinlock **lock, struct mcs_spinlock *node)
{
    struct mcs_spinlock *next = READ_ONCE(node->next);
    if (likely(next == NULL)) {
        // 可能无后继,尝试原子解锁
        if (likely(cmpxchg_relaxed(lock, node, NULL) == node))
            return;
        // 后继正在入锁中,等它完成链接
        next = xchg(&node->next, NULL);
        while (!next)
            cpu_relax();
    }
    smp_store_release(&next->locked, 1);
}

核心思想:每个等待者自旋在自己的局部变量上(node->locked),而非全局变量。这样只有一个缓存行(队尾指针)被反复读,避免缓存行 bounce。

2.2 Ticket Spinlock 与 MCS 的区别

特性 Ticket Spinlock MCS Spinlock Queued Spinlock (qspinlock)
等待方式 自旋在 owner counter 自旋在本地 locked 三级队列 + 本地 MCS node
缓存行争用 高 低 低
公平性 FIFO FIFO 队头 FIFO,队尾可扩展
ARM 支持 旧版 需要额外实现 原生支持

ARM64 上的 qspinlock 采用三级队列:tail_idx → tail_cpu → tail_qnode,减少缓存行传播开销。

2.3 PREEMPT_RT 下的 rtmutex

标准内核中,spinlock 在持有期间会关抢占。PREEMPT_RT 项目中,spinlock 被替换为基于 rtmutex 的可睡眠锁:

PREEMPT_RT: spin_lock() → rt_mutex_acquire() (可睡眠)
Standard:   spin_lock() → preempt_disable() + qspinlock (忙等)

关键区别:rtmutex 支持优先级继承(Priority Inheritance)。当高优先级任务持有锁时,临时提升其优先级,避免中优先级任务造成的优先级反转。

2.4 使用场景和陷阱

// ✅ 正确:保护极短临界区(< 100ns)
spin_lock(&irp->lock);
list_add_tail(&new_ctx, &irp->queue);
spin_unlock(&irp->lock);

// ❌ 错误:spinlock 内调用睡眠函数
spin_lock(&lock);
kmalloc(size, GFP_KERNEL); // 分配失败会触发内存回收,可能睡眠!
spin_unlock(&lock);

核心守则:spinlock 保护的临界区内绝不能调用可能睡眠的函数(kmalloc GFP_KERNEL、copy_from_user、mutex_lock、down 等)。


三、互斥锁 Mutex:通用睡眠锁的标准实现

3.1 数据结构

struct mutex {
    atomic_long_t       owner;     // 持有者的 task_struct 指针 + 标志位
    raw_spinlock_t      wait_lock; // 保护等待队列
    struct list_head    wait_list; // 等待任务的队列
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq;  // 乐观自旋优化
#endif
};

owner 字段编码了两个信息:低两位是标志位(MUTEX_FLAG_WAITERS = 1,MUTEX_FLAG_PICKUP = 2),其余位是持有者的指针。通过原子操作快速判断锁是否被持有。

3.2 获取流程

mutex_lock() 
  ↓
trylock_fast()     —— atomic_try_cmpxchg_acquire() 快速路径
  ↓ 失败
mutex_lock_slowpath()
  ↓
osq_lock()         —— 乐观自旋:检查持有者是否在运行,若是则自旋等待
  ↓ 持有者正在运行,快速释放
__mutex_lock_or_spin()
  ↓
wait_list加入 → schedule() 让出CPU
  ↓ 被唤醒
从 wait_list 摘除,标记 PICKUP 标志

乐观自旋优化:当检测锁持有者正在某个 CPU 上运行时(通过 task_on_cpu()),新请求者不会立即切换到睡眠,而是自旋等待。因为持有者通常会在几微秒内释放锁。实测在高竞争场景下,乐观自旋可以减少 30-50% 的上下文切换。

3.3 释放流程

mutex_unlock()
  ↓
atomic_set(&lock->owner, 0) 或 xchg 清除标志
  ↓
如果没有 WAITERS 标志 → 快速返回
  ↓
有等待者 → 从 wait_list 取出第一个任务 → wake_up_q()
  ↓ 如果 PICKUP 标志已设(优化了唤醒延迟)

3.4 避免死锁的策略

内核 mutex 通过三种机制避免死锁:

  1. 锁定顺序约定:代码审查 + lockdep 检查。全局锁的获取顺序被严格约定。

  2. 不可重入:mutex 不允许同一线程重复获取(与 recursive mutex 不同),避免简单的自死锁。

  3. lockdep 验证器:编译期静态分析,运行时追踪所有锁的获取顺序,一旦发现潜在的死锁循环立即报告。


四、读写锁 RWLock 与读写信号量

4.1 RWLock 的问题:写者饥饿

早期内核中 rwlock 被广泛批评:当读者高并发持续持有锁时,写者可能永远无法获得锁。在 64 位系统上,rwlock 使用 32 位计数器(24 位读者计数 + 1 位写者锁),当所有 CPU 上都有读者在忙等时,写者的原子操作需要等待 2^24 个读者全部释放后才能生效。

4.2 rw_semaphore 的队列化解决方案

现代内核推荐 rw_semaphore 代替 rwlock,它在内核配置了 CONFIG_RWSEM_SPIN_ON_OWNER 后具备乐观自旋:

// rw_semaphore 获取写锁
void down_write(struct rw_semaphore *sem)
{
    // 尝试快速获取
    if (likely(atomic_long_try_cmpxchg_acquire(&sem->count, 
                                                 0, RWSEM_WRITER_LOCKED)))
        return;

    // 慢路径:进入等待队列
    rwsem_down_write_failed(sem);
}

公平模式(公平读写):Linux 5.x 后引入了 rwsem 的公平模式(类似 qspinlock),写者在注册等待后,后续读者会被"阻塞在门前",避免写者被饿死。

4.3 Per-CPU 读写锁:rwlock 的最佳替代

当"读多写少"模式明确时,percpu_rwlock 或 RCU 是更好的选择:

struct percpu_rwlock {
    atomic_t __percpu *read_lock;  // 每 CPU 的读计数器
    atomic_t            write_lock;
};

// 获取读锁:只操作本 CPU 缓存行
void percpu_read_lock(struct percpu_rwlock *rw)
{
    this_cpu_inc(*rw->read_lock);
    smp_mb();  // 内存屏障确保写者见
}

// 获取写锁:等待所有 CPU 读计数归零
void percpu_write_lock(struct percpu_rwlock *rw)
{
    atomic_set(&rw->write_lock, 1);
    smp_mb();
    for_each_online_cpu(cpu) {
        while (per_cpu(*rw->read_lock, cpu))
            cpu_relax();
    }
}

优势:读者间完全无竞争(各自 CPU 独立),写者代价 O(n_cpu)。实测读性能比普通 rwlock 高 5-10 倍。


五、SeqLock:读者无锁的黑科技

5.1 设计哲学

seqlock 的核心思想:让时间戳充当锁,让读者检测写入冲突。

struct seqlock_t {
    seqcount_t seq;        // 序列号计数器
    spinlock_t lock;       // 仅保护写者
};

// 读者路径
unsigned seqbegin;
do {
    seqbegin = read_seqcount_begin(&lock->seq);
    /* 读取共享数据 */
} while (read_seqcount_retry(&lock->seq, seqbegin));

// 写者路径
write_seqlock(&lock->lock);
/* 修改共享数据 */
write_sequnlock(&lock->lock);

5.2 内部原语

序列号在写者持有期间为奇数,正常为偶数。读者循环检测序列号变化:

// 读者开始:读取序列号
static inline unsigned read_seqcount_begin(seqcount_t *s)
{
    unsigned ret;
    do {
        ret = smp_load_acquire(&s->sequence);
        if (unlikely(ret & 1))  // 写者在操作中
            cpu_relax();
    } while (unlikely(ret & 1));
    return ret;
}

// 读者重试:检查序列号变化
static inline int read_seqcount_retry(seqcount_t *s, unsigned start)
{
    smp_rmb();
    return unlikely(s->sequence != start);  // 被写者中断过
}

5.3 典型应用场景

seqlock 特别适用于读极多、写极少、数据一致性可以"重来一次"的场景:

// 内核时间子系统:jiffies_64 的更新
u64 get_jiffies_64(void)
{
    u64 seq, ret;
    do {
        seq = read_seqbegin(&jiffies_lock);
        ret = jiffies_64;
    } while (read_seqretry(&jiffies_lock, seq));
    return ret;
}

// 网络包统计:TCP 字节计数
static inline u64 tcp_clock_ns(void)
{
    struct tk_base *base = ...
    u64 val;
    unsigned seq;
    do {
        seq = raw_read_seqcount_begin(&base->seq);
        val = base->clock;
    } while (raw_read_seqcount_retry(&base->seq, seq));
    return val;
}

限制:如果读者处理时间过长,写者会饥饿(读者永远重试),因此读者临界区必须极短(通常 < 1000 cycles)。


六、信号量 Semaphore 与完成量 Completion

6.1 计数信号量:资源池管理

当需要允许N 个并发访问同一资源池时,使用 semaphore:

struct semaphore {
    raw_spinlock_t     lock;
    unsigned int       count;    // 可用资源数
    struct list_head   wait_list;
};

void down(struct semaphore *sem)  // P 操作
{
    unsigned long flags;
    spin_lock_irqsave(&sem->lock, flags);
    if (likely(sem->count > 0)) {
        sem->count--;
        spin_unlock_irqrestore(&sem->lock, flags);
    } else {
        // 加入等待队列,睡眠
        ...
        spin_unlock_irqrestore(&sem->lock, flags);
        schedule();
    }
}

void up(struct semaphore *sem)   // V 操作
{
    ...
    // 唤醒 wait_list 中的第一个等待者
}

与 mutex 的对比:

特性 mutex semaphore
所有者 严格所有者追踪 无 ("释放者)"
递归获取 禁止 允许
资源计数 1(独占) N(共享池)
乐观自旋 有 无
声明宏 DEFINE_MUTEX DEFINE_SEMAPHORE

6.2 Completion:事件通知的信号量

当线程需要"等待某个事件发生"时使用 completion,而不仅仅是获取资源:

struct completion {
    unsigned int  done;
    wait_queue_head_t wait;
};

// 等待方(消费者)
wait_for_completion(struct completion *c);

// 发送方(生产者)
complete(struct completion *c);

completion 在内核中的典型用法:模块卸载等待、I/O 完成通知、kthread_park 唤醒。

与 semaphore 的语义差异:completion 的 complete() 只会唤醒一个等待者(或全部,如果调用 complete_all()),而 semaphore 的 up() 只唤醒一个等待者。completion 是"信号已发出",semaphore 是"资源可用"——前者更适合一次性事件。

6.3 SRCU:可睡眠的 RCU

SRCU(Sleepable RCU)是一种特殊的同步原语,允许读者在持有期间睡眠(但代价更高):

调用链:
srcu_read_lock()  // 递增本 CPU 读者计数(preempt 保护)
sleepable_code()  // 可睡眠
srcu_read_unlock() // 递减读者计数
synchronize_srcu() // 等待现有读者完成,然后回调

SRCU 在内核中使用场景较少(如 vcpu 阻塞),但比 RCU 更灵活。其内部使用 percpu 计数器 + 全局 epoch 追踪来实现所有权验证。


七、原子操作与内存屏障

7.1 原子操作原语

// 基本原子操作
atomic_t v = ATOMIC_INIT(0);
atomic_inc(&v);          // 原子的 v += 1
atomic_add(2, &v);       // 原子的 v += 2
atomic_sub_return(1, &v); // 原子的 v -= 1,返回新值
atomic_cmpxchg(&v, old, new); // CAS:如果 v == old,则 v = new

// 64 位 atomic64
typedef struct { s64 counter; } atomic64_t;
atomic64_inc(&v);

// 位级别操作
set_bit(3, &flags);
clear_bit(3, &flags);
test_and_set_bit(3, &flags);  // 返回旧值并设置
test_and_clear_bit(3, &flags);

// 无 LOCK 前缀的版本(单向屏障)
__set_bit(3, &flags);        // 无序保证

7.2 内存屏障的四个层次

// Linux 内核的四层屏障:

// 1. 编译器屏障:阻止编译器重排
barrier();

// 2. SMP 内存屏障:CPU 级别的全屏障(读+写)
smp_mb();       // 保证 mb 前后的读写不乱序
smp_rmb();      // 读屏障:保证之前的读在之后的读之前完成
smp_wmb();      // 写屏障:保证之前的写在之后的写之前完成

// 3. 获取/释放语义(C11 memory_order)
smp_load_acquire(&ptr);   // 获取语义:之后的读写不被重排到此之前
smp_store_release(&ptr, v); // 释放语义:之前的读写不被重排至此之后

// 4. I/O 屏障(设备层面)
mmiowb();    // MMIO 写屏障
wmb();       // 包含 DMA 顺序

为什么需要内存屏障:

现代 CPU(尤其是 ARM64/RISC-V)采用弱内存模型(Weak Memory Model)。CPU 会为了性能乱序执行指令。例如:

// CPU 线程 A
a = 1;
flag = 1;

// CPU 线程 B
while (!flag) {}  // 等待 flag
print(a);         // 可能打印 0!

B 可能先看到 flag=1,然后才看到 a=1。插入屏障即可解决:

// CPU 线程 A
a = 1;
smp_wmb();          // 保证写入顺序
flag = 1;

7.3 原子操作的内存序选择

// 宽松序——无同步,仅保证原子性
atomic_fetch_add_explicit(&v, 1, memory_order_relaxed);

// 获取序——和 Release 配对使用
int old = atomic_fetch_add_explicit(&v, 1, memory_order_acquire);

// 释放序——和 Acquire 配对使用
atomic_store_explicit(&ptr, new_val, memory_order_release);

// 全序——默认组合,最高开销
atomic_fetch_add(&v, 1);  // = memory_order_seq_cst

最佳实践:追求性能的自定义无锁结构体使用 relaxed/acquire/release;默认场景使用 seq_cst。在 Linux 内核中,smp_load_acquire 和 smp_store_release 被大量用于链表、引用计数等场景。


八、Per-CPU 变量与本地原子操作

8.1 Per-CPU 计数器:统计的终极武器

// 动态分配 per-cpu 变量
static DEFINE_PER_CPU(long, event_count);
// 等价于:
// static __section(".data..percpu") long event_count;

// 操作(关抢占保护,无需额外锁)
this_cpu_inc(event_count);    // 本 CPU += 1
this_cpu_add(event_count, n); // 本 CPU += n
raw_cpu_add(event_count, n);  // 无 preempt 保护(慎用)

// 读取所有 CPU 的总和
long sum = 0;
int cpu;
for_each_online_cpu(cpu)
    sum += per_cpu(event_count, cpu);

应用场景:网络包计数器(netstat 的 RX/TX bytes)、调度器运行队列长度统计、软中断分发计数、NUMA 节点间统计。

8.2 本地原子操作:Local Atomic vs Per-CPU

// local atomic:作用于当前 CPU,不需要 preempt 保护
local_inc(&my_counter);
local_dec(&my_counter);
local_cmpxchg(&my_counter, old, new);

// 与 atomic 的区别
local_inc(&v);   // 仅保证本 CPU 原子性,无 LOCK 前缀
atomic_inc(&v);  // 全局原子,多核间同步,有 LOCK 或 LSE 指令
// 例:ARM64 上 local_inc 是普通 str+adds,atomic_inc 是 stadd 指令
local_inc 的优势:无 LOCK 前缀,无数 MESI 协议开销,性能高 10-100 倍。

8.3 读取所有 CPU 总和的优化

逐 CPU 遍历统计时,32 位计数器可能溢出。内核使用序列号技巧保证一致性:

// /proc/net/snmp 中的典型模式
int cpu;
unsigned int tmp;
long sum = 0;

for_each_possible_cpu(cpu) {
    tmp = *per_cpu_ptr(seq->private->ext, cpu);
    sum += tmp;
    tmp ^= 0xffffffffu;
    tmp += 1;
    if ((int)tmp < 0)
        sum += 0x100000000LL;
    /* 处理 32-bit 环绕与总和 */
}

九、Reference Counting 与 RCU 回调

9.1 kref 与 refcount_t

struct kref {
    refcount_t refcount;
};

void kref_init(struct kref *kref)
{
    refcount_set(&kref->refcount, 1);
}

void kref_get(struct kref *kref)
{
    refcount_inc(&kref->refcount);
}

int kref_put(struct kref *kref, void (*release)(struct kref *kref))
{
    if (refcount_dec_and_test(&kref->refcount)) {
        release(kref);
        return 1;  // 已释放
    }
    return 0;
}

refcount_t vs atomic_t:refcount_t 是改进版本,增加了溢出检测(不会回绕到 0 或负数)——CVE 研究中 kernel UAF 漏洞的常见根源就是 refcount 错误。

9.2 kref 的饱和递增

// 当 refcount 从 0 增加时(引用零对象的尝试)
bool refcount_inc_not_zero(refcount_t *r)
{
    unsigned int new, val = atomic_read(&r->refs.counter);
    do {
        if (unlikely(!val))
            return false;
        // 检查溢出
        if (unlikely(val == UINT_MAX))
            return false;
        new = val + 1;
    } while (!atomic_try_cmpxchg_relaxed(&r->refs.counter, &val, new));
    return true;
}

9.3 RCU 的现代用法与局限

// 经典 RCU 读取
rcu_read_lock();
ptask = rcu_dereference(g_task_ptr); /* 必须是 rcu_dereference */
if (ptask)
    do_something(ptask);
rcu_read_unlock();

// 更新:原子替换 + 等待回调
new_task = kmalloc(...);
spin_lock(&g_lock);
old_task = rcu_dereference_protected(g_task_ptr, lockdep_is_held(&g_lock));
rcu_assign_pointer(g_task_ptr, new_task);
spin_unlock(&g_lock);
synchronize_rcu();  /* 等待现有读者完成 */
kfree(old_task);

十、Lockdep:死锁检测之神

3.1 Lockdep 的核心能力

Lockdep 在编译期创建一个 锁依赖图,运行时验证:

- 获取顺序:A→B 和 B→A 不能在同一执行路径中出现
- 中断上下文:锁在进程上下文获取后,不能在 IRQ 上下文再次获取(除非使用 _irqsave)
- 递归获取:非递归锁不被允许
- 锁类别验证:同一锁类的不同实例必须遵循固定顺序

关键输出:当检测到潜在死锁、不安全的使用场景(如 mutex 在中断上下文持有)时,lockdep 会打印详细的信息,包括锁的获取路径和调用栈。

3.2 Lockdep 的类分级管理

内核使用 lock_class_key 为每个锁分配类别键:

// 静态初始化键
static struct lock_class_key my_lock_key;

// 初始化锁时分配类别
spinlock_t my_lock;
spin_lock_init(&my_lock);

// 或者使用 DEFINE 宏
DEFINE_SPINLOCK(my_lock); // 自动分配类

// 显式声明类别
lockdep_set_class(&my_lock, &my_lock_key);

3.3 Lockdep 的典型案例分析

======================================================
[ INFO: possible circular dependency detected ]
------------------------------------------------------
kworker/u16:0/45 is trying to acquire lock:
00000000decd8e6a (&dev->lock){+.+.}, at: driver_lock+0x3c/0x80

but task is already holding lock:
00000000a38e2e3f (&common->mutex){+.+.}, at: common_op+0x42/0xa0

backtrace of second lock:
mutex_lock+0x6e/0xa0
common_op+0x42/0xa0
subsystem_handler+0x25/0x60

根因分析:A 路径持有 mutex 后尝试获取 dev->lock;B 路径持有 dev->lock 后尝试获取 mutex。当两个路径在两个 CPU 上并行执行时,导致死锁。

解决方案:统一获取顺序(总是先 mutex 后 dev->lock),或使用 lockdep 的 lock_acquire_exclusive 声明互斥排序规则。

3.4 Lockdep 的生产部署

// /proc/lockdep_stats 中查看统计
// 关键指标:
// - held lock count: 当前持有的锁数
// - lock depth: 最大锁嵌套深度
// - lock class count: 注册的总锁类

// 运行时开关(不要在生产环境禁用!)
static int __init disable_lockdep(char *str)
{
    disable_lockdep_flag = 1;
    return 0;
}
__setup("nolockdep", disable_lockdep);

性能开销:lockdep 增加约 10-20% 内核开销。建议开发/测试环境总是启用;生产环境可以关闭,但 CI 中必须运行全量 lockdep 测试。


十一、缓存伪共享与缓存行优化

11.1 什么是 False Sharing

当两个 CPU 修改同一缓存行(cache line,通常 64 字节)内的不同变量时,会发生缓存伪共享:CPU A 的写导致整个缓存行无效,CPU B 被迫从内存重新加载,即使它们实际不共享任何数据。

缓存行(64 字节):
| counter_A | counter_B | flag | padding |
  CPU 0 写   CPU 1 写
  → 反复 bounce

11.2 检测 False Sharing

# 方法1:perf c2c(cache-to-cache)
perf c2c record -a -- sleep 10
perf c2c report -c pid,tid,iaddr

# 关注指标:
# - Hitm (Hit Modified): 其他 CPU 持有该缓存行的修改副本
# - LLC Miss: 末级缓存未命中

# 方法2:LBR + 采样
perf record -e LLC-load-misses -a -- sleep 10
perf report --sort=dso,symbol

perf c2c 输出样例:

 Cacheline  Data Shared  Samples  Load Hitm  Store Hitm
 0x7f3b800a2c40              120        85          0
   50.0%  udp_recvmsg+0x3f0 (ksrc/linux/net/ipv4/udp.c)
   30.0%  udp_sendmsg+0x280 (ksrc/linux/net/ipv4/udp.c)
   20.0%  tcp_recvmsg+0x1c0 (ksrc/linux/net/ipv4/tcp.c)

如果 Hitm > 5% of LLC misses,通常说明存在伪共享问题。

11.3 消除 False Sharing 的技术

方案1:对齐填充(padding)

struct my_stats {
    atomic_t counter_a;      // 占 4 字节
    char     pad[60];        // 填充到 64 字节边界
    atomic_t counter_b;      // 占 4 字节
    char     pad2[60];       // 填充到 64 字节边界
};

方案2:缓存行对齐分配(编译期)

// 定义缓存行对齐的结构体
struct aligned_counter ____cacheline_aligned_in_smp {
    atomic_t value;
};

struct aligned_counter counters[NR_CPUS];

方案3:L1 缓存行对齐(防御 CPU 预取器)

struct padded_var {
    atomic_t value;
} ____cacheline_aligned;    // 对齐到 L2/L3 边界

// 或显式填充到 128 字节(覆盖相邻预取)
struct padded_var {
    atomic_t value;
    char     pad[124];      // 64(本行) + 64(预取邻居)
} ____cacheline_aligned;

11.4 内核中的实际案例

案例1:网络包统计

// 旧版(存在伪共享)
struct netns_mib {
    long   tcp_in_segs;
    long   tcp_out_segs;
    long   udp_in_datagrams;
    long   udp_out_datagrams;
    // ... 120+ 计数器全在少数几个缓存行
};

// 新版(每缓存行独立)
struct pcpu_dstats {
    atomic64_t rx_packets;
    atomic64_t tx_packets;
    // 独占一个缓存行
} ____cacheline_aligned_in_smp;

案例2:调度实体

struct cfs_rq {
    struct load_weight load;
    unsigned long runnable_weight;
    // ...
    struct rb_root_cached tasks_timeline;
    struct sched_entity *curr;

    // 拆分到同一cfs_rq在不同CPU上操作的热点字段
} ____cacheline_aligned;

十二、NUMA 感知同步策略

12.1 NUMA 对锁性能的影响

在 NUMA 系统中,远程内存访问延迟是本地访问的 2-5 倍。传统 ticket spinlock 的队尾指针可能位于另一个 NUMA 节点,频繁的跨节点缓存一致性协议(cache coherency)导致性能灾难。

12.2 内核 NUMA 感知锁的实现

MCS Spinlock 的队列化已经天然缓解 NUMA 问题——每个等待者自旋在本地内存。但 rwlock 中仍存在 NUMA 不友好路径:

// 危险的 NUMA 场景
rwlock_t global_lock;  // 锁变量在 Node 0

// Node 1 上的 CPU 持续尝试获取锁
// 每次获取失败 → 锁变量从 Node 0 跨 QPI/UPI 传输
// 性能:吞吐量下降 5-10 倍

12.3 优化策略:分片锁(Sharding)

// 将一个大锁拆分为 N 个小锁,每个 CPU 组使用不同锁
#define LOCK_NS 64

struct per_lock_ns {
    spinlock_t lock;
    struct list_head list;
} ____cacheline_aligned;

static struct per_lock_ns sharded_locks[LOCK_NS];

void sharded_add(struct elem *e, int key)
{
    int ns = key & (LOCK_NS - 1);
    spin_lock(&sharded_locks[ns].lock);
    list_add(&e->list, &sharded_locks[ns].list);
    spin_unlock(&sharded_locks[ns].lock);
}

核心思路:冲突的锁获取被隔离在不同锁实例上。如果 key 均匀分布,锁竞争减少 N 倍。

12.4 Per-CPU 锁 + 经典锁的混合设计

struct percpu_rw_semaphore_cpu {
    struct rw_semaphore brw;
};

struct percpu_rw_semaphore {
    struct percpu_rw_semaphore_cpu *prim;
    unsigned int sig;
};

// 读路径:无读者间竞争
// 写路径:原子关闭所有 CPU 的信号量,然后同步等待

典型应用:percpu_rw_semaphore 在 Linux 内核中用于 mmap_sem(内存映射锁),将 O(N_cpu) 的读性能提升到了接近无锁。


十三、无锁编程与 Hazard Pointer

13.1 Hazard Pointer 的实现

struct hp_record {
    struct hp_record *next;
    atomic_ptr_t  hp[MAX_HP];  // 每个线程的危险指针数组
    unsigned int  active;
};

struct hp_chunk {
    int nr_records;
    struct hp_record records[0];
};

// 使用:保护指针在 RCU-like 场景中的安全释放
void protect(struct hp_record *rec, int i, void *ptr)
{
    atomic_store_explicit(&rec->hp[i], ptr, memory_order_release);
    smp_wmb();  // 保护指针可见性
    if (atomic_load(&global_ptr) != ptr) {
        atomic_store(&rec->hp[i], NULL);
        throw;
    }
}

void retire(void *ptr)
{
    // 推迟释放,直到没有线程引用它
    add_to_retire_list(ptr);
    if (retire_list_size > R) {
        scan_and_free(thread_priv->chunk);
    }
}

13.2 RCU 在更高层面的替代

对于更简单的场景,无锁编程的规则是:

  1. 单写者-多读者:使用 seqlock + 快照模式
  2. 多写者-少读者:使用 rcu_assign_pointer + kfree_rcu
  3. CAS 链表:引用霍华德·旺(Howard)的论文 Unreality 技巧
  4. 树结构:使用 epoch-based reclamation

关键:无锁代码的正确性证明极为困难,应优先使用内核已有原语(RCU、seqlock、percpu)。


十四、锁调试与性能分析

14.1 Lockstat:锁竞争统计

# 开启锁统计
echo 1 > /proc/sys/kernel/lock_stat

# 运行测试后查看
cat /proc/lock_stat

# 关键输出段:
# class name  con-bounces  waittime-total  holdtime-total
# &inode->i_mutex  1.3M         8.4s            1.2s
# &mm->mmap_sem    890K        12.6s            3.4s

14.2 Ftrace 锁追踪

# 追踪锁获取事件
echo 1 > /sys/kernel/debug/tracing/events/lock/lock_acquire/enable
echo 1 > /sys/kernel/debug/tracing/events/lock/lock_release/enable
cat /sys/kernel/debug/tracing/trace_pipe

14.3 性能调优决策树

锁竞争严重?
├── 是 → 性能分析(lock_stat / perf c2c)
│       ├── 缓存线争用(false sharing)→ 对齐优化
│       ├── 同一锁被不同 CPU 频繁争抢 → 分片锁/sharded lock
│       ├── 写者饥饿(rwlock)→ rw_semaphore 公平模式 或 seqlock
│       └── 锁顺序不一致 → lockdep 检测 🔥
└── 否 → 当前锁策略是否安全?
        ├── lockdep 报告问题?→ 修复 锁顺序/中断上下文 使用
        └── 是否可简化为 RCU?→ 极度读多写少 → RCU + call_rcu

十五、生产环境最佳实践与检查清单

15.1 锁选型速查表

场景 推荐原语 替代 绝对不要
短临界区,中断上下文 spinlock irqsave spinlock bh mutex, semaphore
短临界区,进程上下文 spinlock mutex rwlock(写饥饿)
可能睡眠的临界区 mutex rw_semaphore(独享锁) spinlock
读多写少,热点统计 seqlock RCU rwlock
写多读少,状态机 rw_semaphore(公平) spinlock rwlock
时间读取(无状态) seqlock RCU(rcu_dereference) spinlock
资源池计数 semaphore mutex + 计数 atomic(释放者问题)
事件等待 completion semaphore(count=1) spin锁 busy-wait
每 CPU 计数器 percpu 变量 local 原子 全局 atomic

15.2 代码审查要点

  1. spinlock 边界检查:用 might_sleep() 和 might_fault() 标注可能睡眠的函数,spinlock 内调用时产生警告
  2. 中断上下文使用规则:spin_lock_irqsave() vs spin_lock_irq() ——如果不知当前 IRQ 状态,总是用 irqsave 变体
  3. 递归获取:非递归 mutex 不允许递归获取;递归场景用 semaphore 或 lockdep 声明子类别
  4. 锁粒度:热路径上的锁,临界区越小越好;长临界区应考虑 RCU 或 seqlock
  5. NUMA 感知:分片策略前用 perf c2c 确认 cache-line bounce 位置
  6. lockdep 零警告:任何 lockdep WARNING 等于潜在漏洞,CI 阶段不允许通过

15.3 关键内核配置选项

CONFIG_DEBUG_SPINLOCK=y       # spinlock 调试(未初始化锁检测)
CONFIG_DEBUG_MUTEXES=y        # mutex 调试(递归、死锁)
CONFIG_PROVE_LOCKING=y        # lockdep 依赖图验证
CONFIG_LOCK_STAT=y            # 锁统计(lock_stat)
CONFIG_LOCKDEP=y              # 核心基础设施
CONFIG_DEBUG_ATOMIC_SLEEP=y   # 原子上下文中睡眠检测

六、总结

Linux 内核同步子系统从最初简单的 cli/sti 中断控制,发展到今天包含 spinlock/mutex/rw_semaphore/seqlock/completion/RCU/lockdep 在内的完整矩阵。理解这些原语的内部实现是内核开发的基本功,而正确选择和部署它们,则是在高性能与正确性之间精细权衡的艺术。

三个原则始终贯穿: - 正确性优先:lockdep 是你的朋友,不是负担 - 测量再优化:perf c2c + lock_stat 不可跳过 - 简化原语选择:能用 percpu 就不用 global atomic → 能用 RCU 就不用 seqlock → 能用 seqlock 就不用 spinlock → 能用 mutex 就不用 semaphore

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部