JOS中的锁
JOS中只有自旋锁,用于大内核锁的实现:
static inline void
lock_kernel(void)
{
spin_lock(&kernel_lock);
}
自旋锁结构如下:
struct spinlock {
unsigned locked; // Is the lock held?
// 忽略调试属性
};
如果忽略调试信息,那么实际上spinlock的属性就只有一个无符号的4字节整数,它的初始值为0,表示还没有谁获得这个自旋锁。
void
__spin_initlock(struct spinlock *lk, char *name)
{
lk->locked = 0;
}
接着看spin_lock这个函数的实现:
void
spin_lock(struct spinlock *lk)
{
// 忽略一些调试代码
// 不同于xv6,JOS没有在自旋锁中加入关中断的逻辑。可能也和中断们陷进门的区别有关?
while (xchg(&lk->locked, 1) != 0)
asm volatile ("pause");
}
仅仅是在一个while循环中调用xchg函数,判断它是否返回0,如果是则退出循环,表示已经锁上自旋锁,如果不是则一直检测。至于为什么调用pause指令,手册上说它会优化spin lock的while检测循环,一方面会避免内存乱序,另一方面也会节省CPU资源。
最后是xchg函数本身是如何实现的:
static inline uint32_t
xchg(volatile uint32_t *addr, uint32_t newval)
{
uint32_t result;
// The + in "+m" denotes a read-modify-write operand.
asm volatile("lock; xchgl %0, %1"
: "+m" (*addr), "=a" (result)
: "1" (newval)
: "cc");
return result;
}
这段内联汇编的意思就是使用xchg硬件指令,将addr指向的内存的值重置为newval(在这里就是1),并将addr原来的值赋给result并返回。
想象一下 ,如果spinlock本来就是被上锁,则表示addr所指向的值本来就是1,将它置为newval(就是1), 然后返回1,那么上层调用者就知道了这把自旋锁已经被其他线程获取了。如果spinglock本来没有上锁,那么addr所指向的值将被改成1,然后返回0,那么上层调用者就知道了是自己将spinglock的4字节整数设置成了1,就表示自己获得了这把锁,因此退出while循环。
spin_unlock函数则不需要while循环,直接调用xchg将lk->locked设置成0即可。
void
spin_unlock(struct spinlock *lk)
{
xchg(&lk->locked, 0);
}
最后,这里能够保证原子性的是硬件的xchg指令:
asm volatile("lock; xchgl %0, %1" ...
那么lock指令是什么呢,手册上说,lock指令是一个前缀,它将发送信号锁住总线。通过组合lock与其他指令,为一般指令加上原子性的保障,比如 ADD SUB等指令,在加上LOCK前缀后就能够实现原子加和原子减的功能。但是xchgl即时没有加上lock前缀,仍然具有原子性。
xv6中的锁
自旋锁
xv6中的自旋锁的结构和JOS一样:
struct spinlock {
uint locked; // Is the lock held?
// 忽略调试属性
};
对自旋锁的加锁和解锁分别由acquire和release完成:
// 对自旋锁上锁
void
acquire(struct spinlock *lk)
{
pushcli(); // 关中断防止死锁.
if(holding(lk)) // 检查本cpu是否已经获取了这个锁
panic("acquire");
// The xchg is atomic.
while(xchg(&lk->locked, 1) != 0)
;
// 禁止内存乱序
__sync_synchronize(); //不允许将这条语句之前的内存读写指令放在这条之后,也不允许将这条语句之后的内存读写指令放在这条指令之前
// 忽略一些调试相关的代码
}
// 对自旋锁解锁
void
release(struct spinlock *lk)
{
if(!holding(lk))
panic("release");
// 忽略一些调试相关的代码
// 禁止内存乱序
__sync_synchronize();
// 基本变量的原子性
// Release the lock, equivalent to lk->locked = 0.
// This code can't use a C assignment, since it might
// not be atomic. A real OS would use C atomics here.
asm volatile("movl $0, %0" : "+m" (lk->locked) : );
popcli(); // 尝试打开中断
}
有几个注意点:
为什么加锁前,需要先关中断?
如果不关中断,可能造成死锁。
造成的死锁并不是加锁顺序造成的。假设我们在加锁之前不关中断,那么一个程序在用户态执行时取得一把自旋锁后返回用户态继续运行,此时中断发生进入内核态,内核态程序又正好想要获取同一把内核锁,那么中断服务程序将一直自旋不会返回用户态,用户态程序得不到执行所以不会释放锁,这里就产生了死锁。
那为什么,JOS不需要关中断,没有产生这里的死锁问题呢?因为JOS的自旋锁被内核代码使用(大内核锁)。
XV6需要额外添加禁止内存乱序的指令。
即:
__sync_synchronize();
不像JOS,在spinlock的循环中添加了pause指令禁止了内存乱序
xv6的release在设置自旋锁的属性时,直接用的mov指令,这能保证原子性吗?
能的,x86的手册上上能够看到这一点:
x86硬件的基本内存存取操作是能够保证原子性的,唯一要求是内存对齐,这里xv6的代码能够满足这一条件。
而且必须直接使用汇编指令mov,不能使用C语言语句进行赋值,因为即时硬件有原子性的保证,C语言编译器没有给我们这样的保证!见上面代码中老师写的注释。
睡眠锁
睡眠所sleeplock的结构如下所示:
struct sleeplock {
uint locked; // Is the lock held?
struct spinlock lk; // spinlock protecting this sleep lock
// 忽略与调试有关的字段
};
可以看到一个睡眠锁和自旋锁类似,有一个字段指示了这把锁有没有被获取。而且睡眠锁内部也包含个自旋锁结构,这个自旋锁,一方面保护了睡眠所的其他字段,使它们的操作得以原子化;另一方面,与进程的睡眠、唤醒有关。
睡眠锁的获取、释放操作如下所示:
// 获取睡眠锁
void
acquiresleep(struct sleeplock *lk)
{
acquire(&lk->lk); // 首先获取睡眠锁中的自旋锁
while (lk->locked) { // 此时对locked字段的存取操作就是原子的了
sleep(lk, &lk->lk); // 如果已经被其他进程获取,则本线程投入睡眠。 sleep函数,在将进程投入睡眠前,会对自旋锁解锁
}
lk->locked = 1; // 获取睡眠锁
lk->pid = myproc()->pid; // 用于debug
release(&lk->lk); // 释放自旋锁
}
// 释放睡眠锁
void
releasesleep(struct sleeplock *lk)
{
acquire(&lk->lk); // 首先获取睡眠锁中的自旋锁
lk->locked = 0; // 释放睡眠锁
lk->pid = 0;
wakeup(lk); // 唤醒等待lk的进程
release(&lk->lk); // 释放自旋锁
}
可以看到,睡眠所不像自旋锁那么简单,由于睡眠所涉及到进程的睡眠和唤醒。所以其中的sleep和wakeup函数也是比较重要的。
sleep、wakeup在proc.c文件中:
void
sleep(void *chan, struct spinlock *lk)
{
struct proc *p = myproc();
if(p == 0)
panic("sleep");
if(lk == 0)
panic("sleep without lk");
// 1. 如果进程要等待的不是patablelock本身,则首先要获取patblelock
if(lk != &ptable.lock){
acquire(&ptable.lock);
release(lk); // 释放睡眠锁的子自旋锁
}
// 2.将本进程投入睡眠
p->chan = chan; // 在proc结构体中记录,本进程在那个“频道”上等待
p->state = SLEEPING; // 将进程状态改为Sleeping,表示本进程正在等待某种资源
sched(); // 调用sched将本进程调度走
// 3. 到这一步,表示本进程已经被其他进程唤醒,进程状态为Running
p->chan = 0;
// 重新获取睡眠锁的子自旋锁, 并释放ptablelock
if(lk != &ptable.lock){
release(&ptable.lock);
acquire(lk);
}
}
这里涉及到了ptablelock,有必要谈一谈。xv6使用Struct proc来抽象地描述一个任务,相当于PCB,其中与睡眠所有关的只有void* chan 这个字段,该字段记录本进程正在等待什么资源。
// xv6中的PCB结构
struct proc {
uint sz; // Size of process memory (bytes)
pde_t* pgdir; // Page table
char *kstack; // Bottom of kernel stack for this process
enum procstate state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
struct trapframe *tf; // Trap frame for current syscall
struct context *context; // swtch() here to run process
void *chan; // <<------ If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
// homework cpu alarm
int alarmticks;
int alarmticksLeft;
void (*alarmhandler)();
};
与JOS类似,xv6使用一个proc数组来统一管理系统中的进程,与JOS的env数组类似:
struct {
struct spinlock lock; // 自旋锁保护ptable
struct proc proc[NPROC]; // NPROC = 64, xv6最多只能有64个进程
} ptable;
但是xv6的ptable中还有一把自旋锁保证同步操作,但JOS就没有单独的自旋锁,因为JOS使用了一把大内核锁把整个内核都锁住了,简单粗暴。
再回头看sleep函数,首先第一步,我们就得获取ptable中的自旋锁,然后释放上层调用者穿过来的自旋锁。为什么要释放这个自旋锁呢?因为自旋锁在加锁后会关中断,而sleep函数的第二步骤就是使本进程投入睡眠,但是在睡眠之前需要把本CPU的中断打开,要不然很容易死锁。
sleep函数的第二步,就是在PCB中记录本进程正在等待什么资源;然后使本进程投入睡眠,操作方式与JOS一样,先改变自身状态为阻塞状态,然后调用sched手动唤醒调度器。
当本进程被唤醒时,进入sleep函数的第三步,释放ptablelock,然后重新获取上层调用者传入的自旋锁。
接着是释放睡眠锁函数releasesleep中的wakeup函数:
static void
wakeup1(void *chan)
{
struct proc *p;
// 循环遍历ptable, 查看哪个进程正在等待chan,将其进程状态有SLEEPING改成RUNNABLE
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
if(p->state == SLEEPING && p->chan == chan)
p->state = RUNNABLE;
}
void
wakeup(void *chan)
{
acquire(&ptable.lock);
wakeup1(chan);
release(&ptable.lock);
}
似乎没啥好说的,就是遍历ptable,然后查看那个proc结构的chan字段与上层调用者的传入参数相同,将该进程的状态由SLEEPING改成RUNNABLE。
标签:睡眠,struct,lock,void,lk,MIT6.828,自旋 From: https://www.cnblogs.com/HeyLUMouMou/p/17223065.html