2023-03-16 16:19阅读: 102评论: 0推荐: 0

MIT6.828_锁

JOS中的锁

JOS中只有自旋锁,用于大内核锁的实现:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
static inline void lock_kernel(void) { spin_lock(&kernel_lock); }

自旋锁结构如下:

copy
  • 1
  • 2
  • 3
  • 4
struct spinlock { unsigned locked; // Is the lock held? // 忽略调试属性 };

如果忽略调试信息,那么实际上spinlock的属性就只有一个无符号的4字节整数,它的初始值为0,表示还没有谁获得这个自旋锁。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
void __spin_initlock(struct spinlock *lk, char *name) { lk->locked = 0; }

接着看spin_lock这个函数的实现:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
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资源。

image-20230315211853928

最后是xchg函数本身是如何实现的:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
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即可。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
void spin_unlock(struct spinlock *lk) { xchg(&lk->locked, 0); }

最后,这里能够保证原子性的是硬件的xchg指令:

copy
  • 1
asm volatile("lock; xchgl %0, %1" ...

那么lock指令是什么呢,手册上说,lock指令是一个前缀,它将发送信号锁住总线。通过组合lock与其他指令,为一般指令加上原子性的保障,比如 ADD SUB等指令,在加上LOCK前缀后就能够实现原子加和原子减的功能。但是xchgl即时没有加上lock前缀,仍然具有原子性。

image-20230315213958298

xv6中的锁

自旋锁

xv6中的自旋锁的结构和JOS一样:

copy
  • 1
  • 2
  • 3
  • 4
struct spinlock { uint locked; // Is the lock held? // 忽略调试属性 };

对自旋锁的加锁和解锁分别由acquire和release完成:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
// 对自旋锁上锁 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(); // 尝试打开中断 }

有几个注意点:

为什么加锁前,需要先关中断?

如果不关中断,可能造成死锁。这个问题在课程的手册中有过指导:

image-20230330213142112

大意就是说:进程A在内核态执行iderw获得了idelock但没有关中断,若此时有个ide中断,那么进程A将被打断取执行中断处理程序,ide中断的处理程序又恰好需要获取idelock,而idelock被进程A获取还没有释放,因此ide中断的处理程序将自旋等待进程A释放锁。这里就产生了死锁:ide中断处理程序在等待进程A释放idelock,但是进程A直到中断处理程序结束后才能继续执行。

那为什么,JOS不需要关中断,没有产生这里的死锁问题呢?因为JOS的所有中断、异常都是通过中断门实现的,当进程通过中断门进入内核态时,会自动关上中断(即将EFLGS中的IF位置零)。

另外,XV6需要额外添加禁止内存乱序的指

即:

copy
  • 1
__sync_synchronize();

不像JOS,在spinlock的循环中添加了pause指令禁止了内存乱序

xv6的release在设置自旋锁的属性时,直接用的mov指令,这能保证原子性吗?

能的,x86的手册上上能够看到这一点:

image-20230315225708473

x86硬件的基本内存存取操作是能够保证原子性的,唯一要求是内存对齐,这里xv6的代码能够满足这一条件。

而且必须直接使用汇编指令mov,不能使用C语言语句进行赋值,因为即时硬件有原子性的保证,C语言编译器没有给我们这样的保证!见上面代码中老师写的注释。

睡眠锁

睡眠所sleeplock的结构如下所示:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
struct sleeplock { uint locked; // Is the lock held? struct spinlock lk; // spinlock protecting this sleep lock // 忽略与调试有关的字段 };

可以看到一个睡眠锁和自旋锁类似,有一个字段指示了这把锁有没有被获取。而且睡眠锁内部也包含个自旋锁结构,这个自旋锁,一方面保护了睡眠所的其他字段,使它们的操作得以原子化;另一方面,与进程的睡眠、唤醒有关。

睡眠锁的获取、释放操作如下所示:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
// 获取睡眠锁 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文件中:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
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 这个字段,该字段记录本进程正在等待什么资源。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
// 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数组类似:

copy
  • 1
  • 2
  • 3
  • 4
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函数:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
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。

Linux内核同步机制

信号量

内核版本:2.4

内核数据结构主要为semaphore,semaphore主要用于内核代码的同步。

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
struct semaphore { atomic_t count; int sleepers; wait_queue_head_t wait; #if WAITQUEUE_DEBUG long __magic; // debug相关 #endif };
  • count 就是指信号量中的“量”,当该值大于0时,表示还允许其他线程进入临界区
  • sleepers 表示有几个进程在本信号量上等候
  • wait则是一个等待队列的队列头,它串联所有等待进程的相应数据结构

它的具体定义如下:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
typedef struct __wait_queue wait_queue_t struct __wait_queue { unsigned int flags; #define WQ_FLAG_EXCLUSIVE 0x01 struct task_struct * task; struct list_head task_list; #if WAITQUEUE_DEBUG long __magic; long __waker; #endif };

其中tast就是该等待队列元素对应的一个进程描述符的指针,通过该指针可以完成相应的唤醒操作。而task_list则是一个链表头,通过它各个__wait_queue串联到semaphore的wait队列上,而在等待队列上的task_struct的state为task_uninterruptible,表示被阻塞不再被调度器调用执行。

image-20230502200906458

对信号量我们有两个操作down和up,结合上图,非常容易地猜想内核对信号量的的具体操作,简单来说:

  • down操作时,如果counter大于0,表示进程获取资源立刻返回,如果couter <= 0,表示资源已经被获取,则将本进程挂入等待队列,并将state设置为taks_uniterrupible, 最后调用schedule让出CPU资源
  • up操作时,要查看sleepers是否为0,如果非0表示有其他进程正在等待这个资源,那么本进程负责wakeup这些睡眠进程,即将它的statt改成task_running

此外,有一把全局自旋锁对信号量进行了保护,在对信号量操作前需要先取得这把自旋锁(在2.6内核中,使用局部自旋锁而不是全局自旋锁)。

注意这里讲的是内核使用的同步机制,用户态的同步机制也有一个信号量(属于进程间通信的一种),在linux2.6以后新加入了futex系列的系统调用,pthread库的mutex lock以及C++的std::thread都是使用futex实现的。futex全称为Fast User-space Mutex,通过用户和内核的协调来加快用户态的互斥操作,只有在必要时才会陷入内核空间。参考:Futex 简述 | 呆鸥 (foool.net)

mutex与优先级反转的解决

虽然信号量可以实现互斥量,但是为了减小开销,linux内核还是提供了mutex的实现,主要数据结构为:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
// 内核版本2.6 struct mutex { /* 1: unlocked, 0: locked, negative: locked, possible waiters */ atomic_t count; spinlock_t wait_lock; struct list_head wait_list; };

与信号量不同,mutex的count只能有0、1两个状态,且使用了局部自旋锁保护mutex锁,其他的操作与信号量类似,就不多说了。

什么是优先级反转?

通常涉及到三个进程,比如有A、B、C三个进程,它们的优先级从高到低为A > B > C。

假设C进程最先开始执行获取到了临界资源,然后A进程开始执行也要获取同一个临界资源,由于C进程已经获取了资源,那么A进程只能阻塞等待,这导致了搞优先级的A等待低优先级的C。

最坏的情况是,B进程这时候也开始执行,由于B进程的优先级高于C,所以当C的时间片耗尽时,B可以轻易地抢占C开始执行。此时进程A仍然得不到执行,因为进程C迟迟不释放临界资源,A一直处于睡眠态。而且B的优先级高于C,这就导致了进程C的执行时间显著下降,进一步延长了C在临界区的时间。这就导致了进程A由于得不到临界资源一直在阻塞,但进程B却能一直执行,发生了A的优先级看起来比B的优先级低的现象---(Unbounded)优先级反转现象。

image-20230502221530875

解决方法:优先级继承。如果高优先级要在互斥量上等待低优先级的进程,那么在睡眠之前会将低优先级进程的优先级临时提高到高优先级进程的优先级。此时,如果B进程现在开始运行,那么只能得到与进程A竞争情况下的CPU时间,也就是说C进程的执行时间不会被B进程完全挤兑,能够加快完成临界区操作唤醒A。

linux中的具体数据结构为,rt_mutex:

copy
  • 1
  • 2
  • 3
  • 4
  • 5
struct rt_mutex { spinlock_t wait_lock; struct plist_head wait_list; struct task_struct *owner; };

与普通mutex相同,wait_list链入了所有等待该互斥量的进程。但是与普通mutex不同的是,队列中的进程按照优先级排队,当等待队列发生变化时,需要将最高优先级的进程的优先级暂时赋予持有该锁的低优先级的进程(这有owner指针记录)。

本文作者:别杀那头猪

本文链接:https://www.cnblogs.com/HeyLUMouMou/p/17223065.html

版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。

posted @   别杀那头猪  阅读(102)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
💬
评论
📌
收藏
💗
关注
👍
推荐
🚀
回顶
收起