spinlock和sleeplock锁的使用及其注意事项
锁的使用
本文默认读者有对应的编程经验,过多细节不再赘述
在写FrostVistaOS的时候,在 OS 初始化早期,由于调度器尚未启动、进程尚未建立,会导致依赖进程上下文的睡眠锁无法正常工作。所以,我打算还是专门写一篇博客用来讲解锁的使用,因为我的锁的编写借鉴了xv6的代码,所以,理论上与xv6的锁的使用是通用的。
spinlock
struct spinlock {
uint locked;
char *name;
struct cpu *cpu;
};
push_off和pop_off
void push_off(void)
{
int old = intr_get();
intr_off();
struct cpu *c = get_cpu();
if (c->noff == 0) {
c->intena = old;
}
c->noff++;
}
void pop_off(void)
{
if (intr_get()) {
// By default, this is paired with `push_off`, which disables
// interrupts; therefore, interrupts should still be disabled
// here.
panic("pop_off: interrupt enabled\n");
}
struct cpu *c = get_cpu();
if (c->noff < 1) {
panic("pop_off");
}
c->noff--;
if (c->noff == 0 && c->intena) {
intr_on();
}
}
push_off和pop_off是cpu级的控制,请牢记,下面将进行讲解
// Per-CPU state.
struct cpu {
int noff; // Record nesting depth
int intena; // Record the interrupt status before the first interrupt is
// disabled
};
我们当然很清楚的知道,锁是用来保护临界区的资源的,但是有没有想过另一种情况,当我们的在运行内核代码的时候,正好运行在临界区中,此时正持有锁,然后触发异常了,这种情况下,进入中断或异常处理程序,要是需要在中断或异常处理程序中,还需要获取锁,这个时候,在获取锁的时候,又会关中断,当处理完这个程序,释放掉中断或异常处理程序中的锁的时候,没有嵌套计数,就会开中断,导致返回到临界区的时候,可能会被中断打断,无法保护临界区。
当然,你也能会有些问题?
这两个申请的锁又不是同一个自旋锁,为什么要记录嵌套层数和中断关闭情况?
我们在上面提到了,spinlock是cpu级的锁控制,spinlock依赖记录当前的cpu的中断情况intena和嵌套情况noff,所以只要是在这个cpu上运行的程序,spinlock都会将其记录到cpu的结构体上,这样就保证了同一个cpu上,可以正常的处理中断的开启和关闭情况,以及锁的嵌套情况(锁一定不是同一个锁,那样就重入了,xv6不支持锁的重入)。
什么时候应该使用这两个函数?
我们可以看到push_off和pop_off的功能并不是很复杂,获取当前的中断情况,关中断,设置CPU的锁的情况。
这个两个函数本质是在需要临时禁用当前 CPU 的中断,并且该禁用操作可能发生嵌套 的时候使用。
所以,这里就有一个本质,那就是为了恢复外部的中断情况,而不是直接开中断,就像是holding检测是否持有锁,先通过关中断防止数据被意外的修改,在通过pop_off恢复外部的中断。
push_off和pop_off可以安全地关闭和开启中断,比直接使用intr_off和intr_on关闭和开启中断更加安全和方便。
sleep和wakeup
void sleep(void *chan, struct spinlock *lk)
{
struct Process *p = get_proc();
if (lk != &p->lock) {
acquire(&p->lock);
release(lk);
}
p->chan = chan;
p->state = SLEEPING;
sched();
p->chan = 0;
if (lk != &p->lock) {
release(&p->lock);
acquire(lk);
}
}
void wakeup(void *chan)
{
struct Process *p;
extern struct Process proc[64];
for (int i = 0; i < 64; i++) {
p = &proc[i];
acquire(&p->lock);
if (p != get_proc() && p->chan == chan &&
p->state == SLEEPING) {
p->state = RUNNABLE;
}
release(&p->lock);
}
}
在spinlock中的睡眠是需要依赖进程和cpu的,进程挂靠在cpu下面,所以这也就导致了,我遇到的问题,在OS启动之初,要是想使用bread读取文件系统,并加载文件,那就需要进程的运行,因为sleep是需要切换进程的,但是在初始化之初,没有进程可以切换,也就无法运行。
sleep是为了实现睡眠的功能,等待某个信号量,将信号放到当前进程的chan中,并使其睡眠,等待唤醒。
唤醒的机制也很简单,找对应的进程即可,并将其唤醒。
sleeplock
struct sleeplock {
int locked;
struct spinlock lock;
// struct spinlock {
// uint locked;
// char *name;
// struct cpu *cpu;
// };
char *name;
int pid;
};
如何理解加了一层嵌套的sleeplock
我们在上面可以看到sleeplock的具体实现,他是对spinlock锁的一个封装。
sleeplock的使用和spinlock的使用的根本区别在什么地方?
void acquire(struct spinlock *lk)
{
push_off();
if (holding(lk)) {
panic("acquire: already holding lock");
}
while (__sync_lock_test_and_set(&lk->locked, 1) != 0)
;
// Prevent reordering from causing data to be accessed before the lock
// is acquired
__sync_synchronize();
lk->cpu = get_cpu();
}
void acquiresleep(struct sleeplock *lk)
{
acquire(&lk->lock);
while (lk->locked) {
sleep(lk, &lk->lock);
}
lk->locked = 1;
lk->pid = get_proc()->pid;
release(&lk->lock);
}
void releasesleep(struct sleeplock *lk)
{
acquire(&lk->lock);
lk->locked = 0;
lk->pid = 0;
wakeup(lk);
release(&lk->lock);
}
首先,acquire会保证sleeplock的互斥,只能同时持有一个这样的锁。
其次是,通过使用
while (lk->locked) {
sleep(lk, &lk->lock);
}
来实现睡眠等待,当此处的锁已经被获取后,进行睡眠。
所以其他进程再次想要获取这个锁的时候,内部的&lk->lock即spinlock就已经被释放了,所以还可以正常的申请,不过进入后还是会因为已经被其他进程获取,自己进入睡眠。
这样也就可以保证,releasesleep可以正常的获取内部的锁,并释放,然后通知所有正在等待的锁。
锁与proc,cpu的关系
在这里实现的sleeplock是基于spinlock实现的。
在spinlock的实现中,
push_off和pop_off是依赖获取当前的cpu进行保存,中断情况和嵌套情况。因为CPU必定会在运转,所以push_off和pop_off,以及由此衍生出的holding, acquire, release都是可以正常的使用的。
而实现的sleep,需要CPU下挂载的进程(get_proc基于当前的cpu结构体获取他下面挂载的proc),因为还依赖于sched,所以sched所依赖的cpu记录的上下文context,和当前进程proc的上下文context,所以这整体就需要依赖调度器的运转。
所以由此衍生的acquiresleep也就需要调度器的运转。
但是wakeup因为是遍历进程数组所实现的,所以并不需要依赖其他的东西。releasesleep同理。
额外的内容
所以,回到开头,在OS初始化初期,系统环境尚未完全建立,我应该怎么解决要初始化文件系统,但是需要依赖调度器及环境的问题?
或许xv6给了一个很好的答案,使用非常简单的编译好的.S文件,写到运行环境里面作为第一个运行的程序,在这个程序中,实现文件系统的初始化等。
xv6的实现思路就是将SYS_exec的调用编写成一个数组initcode,然后修改context.ra,让这个ra指向我们其他初始化的函数,将我们那些需要完整的初始化完成,最后调用usertrapret返回U模式,实现完整的初始化流程