从零开始的ARMv8操作系统内核实践 4 实现一个简单的自旋锁

Arm内存模型

在SMP,弱内存序的系统中, 为了实现多核间的同步,通常硬件需要实现两类硬件特性

  1. 互斥访问 2. 内存屏障

通过互斥访问, 可以实现核心间的同步; 通过内存屏障, 确保核心在解锁前对于内存的访问操作使得其他核心可见.

比如, 存在两个核心, 分别是Core0和Core1, Core0持有一个互斥锁, 写入数据后随即释放互斥锁, 而Core1在等待Core0持有的互斥锁, 在获得锁后读取Core0写入到内存的数据.

因为弱内存模型不保证对于核心间观察到的读写顺序是一致的, 所以即使Core0释放了锁后, Core1成功获得锁, 但由于缓存系统不一致的问题, Core1 可能仍然不能读取到 Core0 在解锁前的写入的数据.

Arm架构锁的实现

在通过锁实现核心间同步的时候, 通常会使用互斥地操作标志位实现.

在Arm架构上, 互斥访问有三类指令, 分别是

  1. Load Exclusive (LDXR): LDXR W|Xt, [Xn]
  2. Store Exclusive (STXR): STXR Ws, W|Xt, [Xn]
  3. Clear Exclusive access monitor
    Arm架构实现互斥访问的原理与乐观锁的实现类似.

首先, 对于内存上指定地址的数据作为锁的标志位, 执行互斥读(LDXR)操作, 将指定位置的数据读入到寄存器中, 同时, 在互斥监视器(Local Exclusive Monitor)上标记这个位置内存的物理地址被此核心互斥访问中.

然后, 对读入的数据做一些计算, 比如计算是否非零等等

最后, 执行互斥写回指令(STXR), 将数据写回指定位置.

互斥写回指令是这样的:
首先, 检查互斥监视器上对应地址的标志是否为当前核心, 如果是执行写入, 并将Ws置零; 否则取消写入, 将Ws置为非零值, 报告写入不成功

下面是一个简单自旋锁实现:

void acquire_spin_lock(struct spinlock *lock){
    while (lock->locked || 
    __atomic_test_and_set(&lock->locked, __ATOMIC_ACQUIRE)
    );
    __sync_synchronize(); // 对于汇编 dmb ish 发起一个内存屏障 保证可见性
}

对应的Arm汇编

acquire_spin_lock(spinlock*):        // @acquire_spin_lock(spinlock*)
        mov     w8, #1
.LBB0_1:                                // =>This Loop Header: Depth=1
        ldrb    w9, [x0]
        cbnz    w9, .LBB0_1
.LBB0_2:                                //   Parent Loop BB0_1 Depth=1
        ldaxrb  w9, [x0]
        stxrb   w10, w8, [x0]
        cbnz    w10, .LBB0_2
        cbnz    w9, .LBB0_1
        dmb     ish
        ret

代码逻辑:

首先, 检查标志位lock->locked, 若其已锁定, 开始自旋重复检查标志位; 如果还未锁定, 执行原子性的"testandset", 如果成功set, 代表此核心获得指定的自旋锁, 跳出循环, 执行"dmb ish"指令确保内存读写的可见性(见下面的"内存屏障章节"), 随后返回

内存屏障

前面提到, Armv8是一种弱内存模型(Weak Memory Ordering)架构,为了使CPU按照我们期望的顺序访问内存,我们需要使用显式的汇编指令提示CPU. 在Armv8平台上,共有三种屏障

  • ISB Instruction Synchronization Barrier 此指令会清除指令流水线,只有它前面的指令执行完成后,才会执行之后的指令
  • DMB Data Memory Barrier 此指令会阻止有关数据存储的指令重排,也就是说,所有在DMB之前的有关存取内存的指令(不包括取指令),对其他核心来说,都是可见的.
  • DSB Data Synchronization Barrier 与DMB类似,但是它的限制更为严格.不仅仅是数据存取的指令,在DSB之后的所有指令都要等待之前的数据存储完成.

One-way barriers

除了上面的三个指令, Armv8还提供两种单向屏障, 限制前面的指令被重排到后面, 或者后面的指令重排到前面. 这两类指令相对于上述的双向屏障, 限制更弱, 性能也就更强.

  • LDAR Load-acquire 对于相同的内存区域, 在此指令后的存取内存指令, 不得被重排至前面
  • STLR Store-release 对于相同的内存区域, 所有在此指令之前的内存访问指令, 不得被重排至后面
    自旋锁的实现
void acquire_spin_lock(struct spinlock *lock){
    while (lock->locked || 
    __atomic_test_and_set(&lock->locked, __ATOMIC_ACQUIRE)
    );
    __sync_synchronize();
}

对于上面提到的自旋锁, 还存在死锁的可能, 需要额外的设计.

当核心持有一个自旋锁时, 如果它再次进入中断, 而在中断处理程序中同样需要这把锁, 那么这个核心就会陷入死锁. 为了解决这个问题, 我们可以加入一个限制, 那就是: 当核心持有自旋锁的时候, 关闭中断

为了实现这个逻辑, 添加一对函数push_off pop_off

其中, push_off 标记当前核心持有锁的数量 +1 并关闭中断;
pop_off 标记当前核心持有锁的数量-1 如果核心不再持有自旋锁时, 开启中断

那么, 我们上锁的代码就要改为

void acquire_spin_lock(struct spinlock *lock){
    push_off();
    while (lock->locked || 
    __atomic_test_and_set(&lock->locked, __ATOMIC_ACQUIRE)
    );
    __sync_synchronize();
}

对应的, 解锁代码:

void release_spin_lock(struct spinlock *lock){
    if(!is_current_cpu_holding_spin_lock(lock)){
        panic("release_spin_lock: the lock (%s) held by %lu can't be released by %lu \n",lock->name,lock->cpu->cpuid,cpuid());
    }
    lock->cpu = NULL;
    __sync_synchronize();
    __sync_lock_release(&lock->locked);
    pop_off();
}
posted @ 2024-01-29 00:11  RiversJin  阅读(177)  评论(0)    收藏  举报