5、同步与互斥:SMP环境下的自旋锁实现、读写锁、RCU机制、内存屏障

各位同学,咱们今天聊点硬核的。多核环境下,同步与互斥是绕不开的坎儿。单核时代,关个中断就能保平安。到了SMP,两个核同时抢资源,关中断只能管住当前核,另一个核照样冲进来。怎么办?

我当年第一次在双核Cortex-A9上移植RT-Thread时,就栽在这个坑里。一个全局变量被两个核同时修改,数据乱得一塌糊涂。从那以后,我对同步机制就格外上心。

5.1 自旋锁:SMP下的“轻量级门卫”

自旋锁,说白了就是让CPU在那“原地转圈”等锁。它不睡眠,不调度,就死等。这在SMP环境下特别有用——因为锁的持有时间通常极短,睡眠再唤醒的开销反而更大。

核心原则:自旋锁的持有时间必须极短。如果你要在锁里做复杂操作,请用信号量或互斥量。

RT-Thread的自旋锁实现,底层依赖原子操作。我给你们看个简化版:

/* RT-Thread 自旋锁结构 */
struct rt_spinlock {
    volatile rt_ubase_t lock;  /* 0: 未锁定, 1: 已锁定 */
};

/* 获取自旋锁 */
void rt_spin_lock(struct rt_spinlock *lock)
{
    while (1) {
        /* 原子交换:尝试将lock设为1,并返回旧值 */
        if (rt_hw_atomic_swap(&lock->lock, 1) == 0) {
            /* 旧值为0,说明锁是空闲的,我们拿到了 */
            break;
        }
        /* 没拿到锁,继续转圈 */
        /* 这里可以插入CPU暂停指令,减少功耗 */
        rt_hw_cpu_relax();
    }
}

/* 释放自旋锁 */
void rt_spin_unlock(struct rt_spinlock *lock)
{
    /* 内存屏障:确保之前的写操作对其他核可见 */
    rt_hw_memory_barrier();
    lock->lock = 0;
}

嗯,这里要注意:rt_hw_cpu_relax() 是个好习惯。我在项目中见过有人不加这个,结果CPU跑满100%,功耗飙升。加上后,ARM的WFI指令能让核心在等待时省电。

我的经验:在循环等待中插入 __asm__ volatile("yield");__asm__ volatile("nop");,对多核系统的公平性有好处。我曾经在一个8核平台上,不加yield导致某个核一直抢不到锁,活活饿死。

5.2 读写锁:读多写少的场景利器

你想想看,很多场景下,读操作远多于写操作。比如一个配置表,99%的时间都在查,只有1%的时间在改。如果用自旋锁,所有读操作之间也要互斥,这太浪费了。

读写锁就是为这个场景设计的。多个读者可以同时进入临界区,但写者必须独占。

/* RT-Thread 读写锁结构 */
struct rt_rwlock {
    volatile rt_int32_t readers;  /* 读者计数,-1表示写者占用 */
    struct rt_spinlock spin;      /* 保护readers的自旋锁 */
};

void rt_rwlock_read_lock(struct rt_rwlock *lock)
{
    rt_spin_lock(&lock->spin);
    if (lock->readers >= 0) {
        lock->readers++;  /* 读者计数加1 */
        rt_spin_unlock(&lock->spin);
    } else {
        /* 有写者在写,需要等待 */
        rt_spin_unlock(&lock->spin);
        /* 这里通常用信号量或条件变量等待,简化起见略过 */
    }
}

void rt_rwlock_read_unlock(struct rt_rwlock *lock)
{
    rt_spin_lock(&lock->spin);
    lock->readers--;
    if (lock->readers == 0) {
        /* 最后一个读者离开,可以唤醒等待的写者 */
    }
    rt_spin_unlock(&lock->spin);
}

避坑指南:我曾经在一个项目中,读写锁的读者优先策略导致写者被无限期阻塞。后来改成了写者优先,才解决问题。RT-Thread的读写锁默认是写者优先的,这点设计得很明智。

5.3 RCU机制:无锁读的终极方案

RCU(Read-Copy-Update),这名字听着玄乎,其实思路很简单:读操作完全不加锁,写操作先复制一份副本,修改完后再“原子地”替换指针。旧版本的数据要等所有读者都离开后才能释放。

为什么能做到无锁读?因为读操作只读取指针,而指针的更新是原子的。只要读者在读的那一刻拿到了正确的指针,数据就是一致的。

/* RCU 链表节点示例 */
struct rcu_node {
    int data;
    struct rcu_node *next;
    struct rcu_head rcu;  /* 用于延迟回收 */
};

/* 读操作:完全无锁 */
struct rcu_node *rcu_read_lookup(struct rcu_node *head)
{
    struct rcu_node *p;
    rcu_read_lock();  /* 标记进入RCU读临界区 */
    p = head->next;   /* 原子读取指针 */
    /* 此时可以安全使用p指向的数据 */
    rcu_read_unlock();
    return p;
}

/* 写操作:复制-修改-替换-延迟回收 */
void rcu_update(struct rcu_node *head, int new_data)
{
    struct rcu_node *old, *new;
    
    old = head->next;
    new = malloc(sizeof(struct rcu_node));
    *new = *old;          /* 复制旧数据 */
    new->data = new_data; /* 修改新数据 */
    
    /* 原子替换指针 */
    rcu_assign_pointer(head->next, new);
    
    /* 延迟回收旧节点 */
    call_rcu(&old->rcu, rcu_node_free);
}

关键点:RCU的读临界区不能睡眠,不能阻塞。因为RCU依赖“所有读者都离开”来判断旧数据是否安全。如果读者睡着了,旧数据就永远释放不了。

我在一个网络协议栈里用过RCU来管理路由表。路由表读操作极多,写操作极少。用RCU后,读性能提升了近3倍。但代价是代码复杂度增加了不少,而且调试起来特别痛苦——因为数据结构的生命周期变得很复杂。

5.4 内存屏障:看不见的同步基石

说到这,我得提一个更底层的东西——内存屏障。没有它,自旋锁、读写锁、RCU全都不靠谱。

为什么会这样?因为现代CPU和编译器会乱序执行。你写的代码是A=1; B=2;,但CPU可能先执行B=2再执行A=1。在单核上这没问题,但在多核上,另一个核看到的内存顺序可能和你想象的不一样。

屏障类型 作用 ARM指令
DMB(数据内存屏障) 确保屏障前的所有内存访问在屏障后的内存访问之前完成 DMB
DSB(数据同步屏障) 等待所有内存访问完成,常用于上下文切换 DSB
ISB(指令同步屏障) 清空流水线,确保后续指令从内存重新读取 ISB

RT-Thread里封装了几个宏:

/* 通用内存屏障 */
#define rt_hw_memory_barrier()    __asm__ volatile("dmb" ::: "memory")

/* 写屏障:确保之前的写操作对其他核可见 */
#define rt_hw_write_barrier()     __asm__ volatile("dmb st" ::: "memory")

/* 读屏障:确保之后的读操作读到最新值 */
#define rt_hw_read_barrier()      __asm__ volatile("dmb ld" ::: "memory")

我的习惯:在自旋锁的释放操作前,一定要加写屏障。在获取锁后,一定要加读屏障。这样能保证:拿到锁的核看到的数据是最新的,释放锁的核写的数据对其他核可见。

我曾经在一个项目中,自旋锁的实现忘了加内存屏障。结果两个核虽然通过原子操作保证了“谁拿到锁”,但拿到锁后读到的数据却是旧的。那个bug查了我整整两天,最后用逻辑分析仪抓总线才发现是内存序的问题。

5.5 总结与选择建议

好了,咱们捋一捋这四种机制怎么选:

  • 自旋锁:保护短临界区,不能睡眠的场景。比如中断处理函数、调度器内部。
  • 读写锁:读多写少,且读操作不频繁的场景。比如配置表、状态查询。
  • RCU:读极多、写极少,且读操作不能阻塞的场景。比如路由表、文件系统dentry缓存。
  • 内存屏障:所有同步机制的基石。写驱动、写锁实现时必用。

最后提醒一句:能用现成的锁就别自己造轮子。RT-Thread已经提供了完善的自旋锁、读写锁、信号量、互斥量。除非你像我一样有特殊需求(比如极致性能优化),否则直接用现成的更安全。

下一章,咱们聊聊中断管理在SMP下的变化。中断亲和性、核间中断、中断负载均衡——这些在单核时代根本不用操心的问题,到了多核全成了坑。到时候我给你们讲讲我在一个4核平台上调中断亲和性的血泪史。