为什么需要锁
多核处理器同时操作共享数据,当缺乏有效的同步机制时,不同CPU可能在同一时刻读写相同数据,从而引发并发安全问题。
单核处理器,当前线程与前一个线程的交错执行也可能导致并发安全问题 。
以kalloc.c中回收物理页kfree
为例说明:
假定有两个 CPU 同时运行至第 4 行代码,此时两个 CPU 的回收页指针r
均指向空闲页链表头节点kmem.freelist
。具体过程如下:
- 第一个 CPU 执行第 5 行代码:将
kmem.freelist
指向该 CPU 当前回收的页,完成指针更新后,空闲页链表头节点变更为第一个 CPU 回收的页。 - 第二个 CPU 执行第 5 行代码:同样将
kmem.freelist
指向其自身回收的页。由于缺乏并发保护机制,第二个 CPU 的操作会直接覆盖第一个 CPU 更新后的指针值,导致kmem.freelist
最终指向第二个 CPU 回收的页,而第一个 CPU 回收的页因失去链表引用而 “丢失”,无法被系统正确管理和复用。
void kfree(void *pa) {
// ...
r = (struct run*)pa;
r->next = kmem.freelist;
kmem.freelist = r;
}
这段代码中,r是每个线程临时创建的私有变量,但kmem.freelist是共有变量。
并发条件下竞争条件的产生主要源于两种场景:
- 多核 CPU 环境下多个线程真正并行执行
- 即使在单核 CPU 中,进程调度也会导致线程执行的交叉与切换
若缺乏对共享变量的同步机制,临界区代码的交叉执行极易导致数据状态不一致,进而引发不可预期的执行结果。
临界区代码的执行需满足原子性。原子性在本质层面体现为操作的不可分割性,即一段代码或操作在其执行过程中不会被中断,也不会被部分观测;它要么完整地执行完毕,要么完全不执行,对外不会呈现任何中间状态。
为实现原子性,可从以下两个层面解决并发安全问题:
- 硬件层面:依托 CPU 提供的硬件原子指令,这类指令在执行过程中不会被中断,能够直接从硬件层面保障原子性
- 软件层面:通过锁机制间接实现原子性。即使当前持有锁的进程被调度切断,由于其他线程无法进入该临界区,仍能保证临界区内的操作不会受到并发干扰,从而在逻辑上具备原子执行的语义
锁的使用
死锁
死锁产生的一个关键原因是多个线程以不同顺序获取多个锁,导致线程间相互持有对方所需资源并阻塞等待。
例如,线程 A 先获取锁 X 再获取锁 Y,线程 B 先获取锁 Y 再获取锁 X,若两者同时执行到获取第二个锁时,就会陷入互相等待的僵局。
为此,避免死锁的一个很好的办法就是规定多个线程必须按照同样的顺序获取多个锁。
- xv6 创建一个文件需要同时持有目录的锁、新文件的 inode 的锁、磁盘块缓冲区的锁、磁盘驱动器的 vdisk_lock 和调用进程的 p->lock。为了避免死锁,文件系统代码总是按照上一句提到的顺序获取锁
- lab8 - buffer cache 在寻找交换 LRU 缓存块时,按索引从小到大依次获取哈希桶的锁
然而,在某些场景下,线程需要根据当前程序的运行状态进一步确定是否需要申请锁,申请哪个锁,因此固定顺序策略在此无法实施。
这类场景暴露了固定顺序策略的刚性缺陷:它假定锁集合和顺序是静态可知的,但实际并发系统中,锁依赖可能随数据或控制流动态变化。因此,需要结合更灵活的死锁预防机制或检测机制来应对。
细化锁的粒度
详见 lab6 - hash table 和 lab8 - memory allocator 这两个实验,都是通过减小锁住的临界区范围,减少锁竞争,从而提高运行效率。
xv6设计了两种锁:自旋锁和睡眠锁
自旋锁
xv6 自旋锁结构体(spinlock.h)
struct spinlock {
uint locked; // Is the lock held:held-1
char *name; // Name of lock
struct cpu *cpu; // The cpu holding the lock
};
locked
:锁是否被持有cpu
:持有该锁的CPU对象地址
xv6 获取自旋锁的代码(spinlock.c/acquire)
void acquire(struct spinlock *lk) {
if(holding(lk))
panic("acquire");
push_off(); // disable interrupts to avoid deadlock.
// On RISC-V, sync_lock_test_and_set turns into an atomic swap:
// a5 = 1
// s1 = &lk->locked
// amoswap.w.aq a5, a5, (s1)
while(__sync_lock_test_and_set(&lk->locked, 1) != 0);
// Tell the C compiler and the processor to not move loads or stores
// past this point, to ensure that the critical section's memory
// references happen strictly after the lock is acquired.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();
lk->cpu = mycpu();
}
xv6 获取自旋锁的代码(spinlock.c/release)
void release(struct spinlock *lk) {
if(!holding(lk))
panic("release");
lk->cpu = 0;
// Tell the C compiler and the CPU to not move loads or stores
// past this point, to ensure that all the stores in the critical
// section are visible to other CPUs before the lock is released,
// and that loads in the critical section occur strictly before
// the lock is released.
// On RISC-V, this emits a fence instruction.
__sync_synchronize();
// Release the lock, equivalent to lk->locked = 0.
// This code doesn't use a C assignment, since the C standard
// implies that an assignment might be implemented with
// multiple store instructions.
// On RISC-V, sync_lock_release turns into an atomic swap:
// s1 = &lk->locked
// amoswap.w zero, zero, (s1)
// 等价于__sync_lock_test_and_set(&lk->locked, 0), 将0写入lk->locked
__sync_lock_release(&lk->locked);
pop_off();
}
获取自旋锁
让我们先看一个直观但存在缺陷的获取自旋锁的实现方式:
void acquire(struct spinlock *lk) {
for (;;) {
if (lk->locked == 0) {
lk->locked = 1;
break;
}
}
}
这个实现看似合理,但在多核处理器环境下会引发严重的竞态条件。
具体来说,当多个 CPU 同时检测到锁处于释放状态(lk->locked == 0
),它们可能都会同时进入临界区,并执行获取锁的操作(lk->locked == 1
),此时多个 CPU 获取锁,违反了锁的互斥性原则。
其根本原因是,该实现方式,将如下获取锁的流程拆分执行了:
- 读取锁状态(load)
- 条件判断(compare)
- 设置锁状态(store)
RISC-V 的原子交换指令 amoswap a r
将以下三个子操作组合为一个不可分割的原子操作:
- 读取内存地址
a
处变量的值 - 将寄存器
r
中的值写入该内存地址 - 返回交换前内存地址
a
处变量的值
xv6通过调用 __sync_lock_test_and_set()
,即 amoswap
替代普通读写操作:
- 读取
lk->locked
字段的值 - 将 1 写入
lk->locked
- 返回写入前,
lk->locked
字段的值
具体到锁获取的逻辑:
- 执行指令的返回值为0,交换前
lk->locked
值为0,此时锁未被持有,成功获取锁 - 执行指令的返回值为1,交换前
lk->locked
值为1,此时锁已经被持有,持续执行上述指令直到返回值为1
指令重排序
为什么获取锁后还要执行__sync_synchronize()
?
由于现代处理器对原代码重排序提高指令并行度,处理器通过乱序执行来重叠指令执行周期,你所看到的代码执行顺序与实际可能不符。
为了确保临界区的所有修改在释放锁之前完成,xv6 在释放锁前(lk->locked=0)设置内存屏障,确保临界区的代码不会被编译器优化到释放锁后执行。
同理,在获取锁前(awswap指令)也需要设置内存屏障,确保临界区代码不会在获取锁之前执行。
内核与中断交互潜在死锁问题
自旋锁和中断的相互作用带来了一个潜在的危险,这里以内核线程 sys_sleep
和中断clockintr
为例:
- 内核线程执行
sys_sleep
,成功获取tickslock
- 此时发生定时器中断,CPU 暂停当前线程,执行
clockintr
clockintr
尝试获取tickslock
,发现锁已被持有,阻塞等待获取锁- 但此时
tickslock
永远不会被释放,因为只有sys_sleep
可以释放它,但sys_sleep
不会运行直到中断结束返回
注意,这里不能让tickslock
成为睡眠锁,中断上下文不能进入睡眠状态
嵌套锁死锁问题
对此,xv6 采取了比较保守的策略,在获取任何类型的锁前都禁止中断
但是简单地在获取锁后关中断/释放锁前开中断,在嵌套锁的情形下还是存在死锁风险:
- 函数 A 获取 lock1,关中断
- 函数 A 在持有 lock1 时,调用函数B
- 函数 B 获取 lock2,关中断
- 函数 B 释放 lock2,开中断,返回函数A
- 函数A 持有 lock1,但是此时处于开中断状态,如果此时一个中断到来,中断处理程序尝试获取
lock1
,就会立即陷入之前讨论的死锁困境
xv6 采用了一种精巧的机制来安全处理嵌套锁与中断的交互问题,仅在 CPU 不持有任何锁时开中断:
- 获取锁之前,调用
push_off
,记录当前中断状态并禁用中断 - 释放锁之后,调用
pop_off
,根据嵌套深度决定是否恢复中断,当且仅当锁的嵌套计数降为零时,pop_off
才会将中断状态恢复至最外层临界区开始之前的状态
void push_off(void) {
int old = intr_get();
intr_off();
if(mycpu()->noff == 0)
mycpu()->intena = old;
mycpu()->noff += 1;
}
void pop_off(void) {
struct cpu *c = mycpu();
if(intr_get())
panic("pop_off - interruptible");
if(c->noff < 1)
panic("pop_off");
c->noff -= 1;
if(c->noff == 0 && c->intena)
intr_on();
}
睡眠锁
自旋锁在等待锁的过程会持续占用CPU循环检测锁状态,导致CPU在锁竞争期间不断执行无效的循环指令,耗费大量时钟周期用于无意义的等待,而非处理有价值的任务。
睡眠锁在自旋锁的基础上,改造了获取锁的方式,在未获取锁会让出CPU,直到锁释放时会被唤醒。
xv6 睡眠锁在自旋锁之上增加 locked
字段(sleeplock.h)
struct sleeplock {
uint locked; // Is the lock held?
struct spinlock lk; // spinlock protecting this sleep lock
};
xv6 获取睡眠锁的代码:sleeplock.c/acquiresleep
void acquiresleep(struct sleeplock *lk) {
acquire(&lk->lk);
while (lk->locked) {
sleep(lk, &lk->lk);
}
lk->locked = 1;
lk->pid = myproc()->pid;
release(&lk->lk);
}
xv6 释放睡眠锁的代码:sleeplock.c/releasesleep
void releasesleep(struct sleeplock *lk) {
acquire(&lk->lk);
lk->locked = 0;
lk->pid = 0;
wakeup(lk);
release(&lk->lk);
}
// Wake up all processes sleeping on chan.
// Must be called without any p->lock.
void
wakeup(void *chan) {
struct proc *p;
for(p = proc; p < &proc[NPROC]; p++) {
if(p != myproc()){
acquire(&p->lock);
if(p->state == SLEEPING && p->chan == chan) {
p->state = RUNNABLE;
}
release(&p->lock);
}
}
}
获取睡眠锁