锁是操作系统中实现进程同步的重要机制。

基本概念

临界区(Critical Section)是指对共享数据进行访问与操作的代码区域。所谓共享数据,就是可能有多个代码执行流并发地执行,并在执行中可能会同时访问的数据。

同步(Synchronization)是指让两个或多个进程/线程能够按照程序员期望的方式来协调执行的顺序。比如,让A进程必须完成某个操作后,B进程才能执行。互斥(Mutual Exclusion)则是指让多个线程不能够同时访问某些数据,必须要一个进程访问完后,另一个进程才能访问。

当多个进程/线程并发地执行并且访问一块数据,并且进程/线程的执行结果依赖于它们的执行顺序,我们就称这种情况为竞争状态(Race Condition)。

Xv6操作系统要求在内核临界区操作时中断必须关闭。如果此时中断开启,那么可能会出现以下死锁情况:A进程在内核态运行并拿下了p锁时,触发中断进入中断处理程序,中断处理程序也在内核态中请求p锁,由于锁在A进程手里,且只有A进程执行时才能释放p锁,因此中断处理程序必须返回,p锁才能被释放。那么此时中断处理程序会永远拿不到锁,陷入无限循环,进入死锁。

Xv6中实现了自旋锁(Spinlock)用于内核临界区访问的同步和互斥。自旋锁最大的特征是当进程拿不到锁时会进入无限循环,直到拿到锁退出循环。显然,自旋锁看上去效率很低,我们很容易想到更加高效的基于等待队列的方法,让等待进程陷入阻塞而不是无限循环。然而,Xv6允许同时运行多个CPU核,多核CPU上的等待队列实现相当复杂,因此使用自旋锁是相对比较简单且能正确执行的实现方案。

Xv6的Spinlock

Xv6中锁的定义如下

  1. // Mutual exclusion lock.
  2. struct spinlock {
  3. uint locked; // Is the lock held?
  4. // For debugging:
  5. char *name; // Name of lock.
  6. struct cpu *cpu; // The cpu holding the lock.
  7. uint pcs[10]; // The call stack (an array of program counters)
  8. // that locked the lock.
  9. };

核心的变量只有一个locked,当locked为1时代表锁已被占用,反之未被占用,初始值为0。

在调用锁之前,必须对锁进行初始化。

  1. void initlock(struct spinlock *lk, char *name) {
  2. lk->name = name;
  3. lk->locked = 0;
  4. lk->cpu = 0;
  5. }

最困难的地方是如何对locked变量进行原子操作占用锁和释放锁。这两步具体被实现为acquire()release()函数。(注意v7版本和v11版本的实现略有不同,本文使用的是v11版本)

acquire()函数

  1. // Acquire the lock.
  2. // Loops (spins) until the lock is acquired.
  3. // Holding a lock for a long time may cause
  4. // other CPUs to waste time spinning to acquire it.
  5. void acquire(struct spinlock *lk) {
  6. pushcli(); // disable interrupts to avoid deadlock.
  7. if(holding(lk))
  8. panic("acquire");
  9. // The xchg is atomic.
  10. while(xchg(&lk->locked, 1) != 0)
  11. ;
  12. // Tell the C compiler and the processor to not move loads or stores
  13. // past this point, to ensure that the critical section's memory
  14. // references happen after the lock is acquired.
  15. __sync_synchronize();
  16. // Record info about lock acquisition for debugging.
  17. lk->cpu = mycpu();
  18. getcallerpcs(&lk, lk->pcs);
  19. }

acquire()函数首先禁止了中断,并且使用专门的pushcli()函数,这个函数保证了如果有两个acquire()禁止了中断,那么也必须调用两次release()中的popcli()后中断才会被允许。然后,acquire()函数采用xchg指令来实现在设置locked为1的同时获得其原来的值的操作。这里的C代码中封装了一个xchg()函数,在xchg()函数中采用GCC的内联汇编特性,实现如下

  1. static inline uint xchg(volatile uint *addr, uint newval) {
  2. uint result;
  3. // The + in "+m" denotes a read-modify-write operand.
  4. asm volatile("lock; xchgl %0, %1" :
  5. "+m" (*addr), "=a" (result) :
  6. "1" (newval) :
  7. "cc");
  8. return result;
  9. }

其中,volatile标志用于避免gcc对其进行一些优化;第一个冒号后的"+m" (*addr), "=a" (result)是这个汇编指令的两个输出值;newval是这个汇编指令的输入值。假设newval位于eax寄存器中,addr位于rax寄存器中,那么gcc会翻译得到如下汇编指令

  1. lock; xchgl (%rdx), %eax

由于xchg函数是inline的,它会被直接嵌入调用xchg函数的代码中,使用的寄存器可能会有所不同。

下面我们来分析一下上面的指令的语义。·lock是一个指令前缀,它保证了这条指令对总线和缓存的独占权,也就是这条指令的执行过程中不会有其他CPU或同CPU内的指令访问缓存和内存。由于现代CPU一般是多发射流水线+乱序执行的,因此一般情况下并不能保证这一点。xchgl指令是一条古老的x86指令,作用是交换两个寄存器或者内存地址里的4字节值,两个值不能都是内存地址,他不会设置条件码。

那么,仔细思考一下就能发现,以上一条xchg指令就同时做到了交换locked和1的值,并且在之后通过检查eax寄存器就能知道locked的值是否为0。并且,以上操作是原子的,这就保证了有且只有一个进程能够拿到locked的0值并且进入临界区。

最后,acquire()函数使用__sync_synchronize为了避免编译器对这段代码进行指令顺序调整的话和避免CPU在这块代码采用乱序执行的优化。

release()函数

  1. // Release the lock.
  2. void release(struct spinlock *lk) {
  3. if(!holding(lk))
  4. panic("release");
  5. lk->pcs[0] = 0;
  6. lk->cpu = 0;
  7. // Tell the C compiler and the processor to not move loads or stores
  8. // past this point, to ensure that all the stores in the critical
  9. // section are visible to other cores before the lock is released.
  10. // Both the C compiler and the hardware may re-order loads and
  11. // stores; __sync_synchronize() tells them both not to.
  12. __sync_synchronize();
  13. // Release the lock, equivalent to lk->locked = 0.
  14. // This code can't use a C assignment, since it might
  15. // not be atomic. A real OS would use C atomics here.
  16. asm volatile("movl $0, %0" : "+m" (lk->locked) : );
  17. popcli();
  18. }

release函数为了保证设置locked为0的操作的原子性,同样使用了内联汇编。最后,使用popcli()来允许中断(或者弹出一个cli,但因为其他锁未释放使得中断依然被禁止)。

在Xv6中实现信号量

  1. struct semaphore {
  2. int value;
  3. struct spinlock lock;
  4. struct proc *queue[NPROC];
  5. int end;
  6. int start;
  7. };
  8. void sem_init(struct semaphore *s, int value) {
  9. s->value = value;
  10. initlock(&s->lock, "semaphore_lock");
  11. end = start = 0;
  12. }
  13. void sem_wait(struct semaphore *s) {
  14. acquire(&s->lock);
  15. s->value--;
  16. if (s->value < 0) {
  17. s->queue[s->end] = myproc();
  18. s->end = (s->end + 1) % NPROC;
  19. sleep(myproc(), &s->lock)
  20. }
  21. release(&s->lock);
  22. }
  23. void sem_signal(struct semaphore *s) {
  24. acquire(&s->lock);
  25. s->value++;
  26. if (s->value <= 0) {
  27. wakeup(s->queue[s->start]);
  28. s->queue[s->start] = 0;
  29. s->start = (s->start + 1) % NPROC;
  30. }
  31. release(&s->lock);
  32. }

上面的代码使用Xv6提供的接口实现了信号量,格式和命名与POSIX标准类似。这个信号量的实现采用等待队列的方式。当一个进程因信号量陷入阻塞时,会将自己放进等待队列并睡眠(18-22行)。当一个进程释放信号量时,会从等待队列中取出一个进程继续执行(29-33行)。

XV6操作系统代码阅读心得(三):锁的更多相关文章

  1. XV6操作系统代码阅读心得(一):启动加载、中断与系统调用

    XV6操作系统是MIT 6.828课程中使用的教学操作系统,是在现代硬件上对Unix V6系统的重写.XV6总共只有一万多行,非常适合初学者用于学习和实践操作系统相关知识. MIT 6.828的课程网 ...

  2. XV6操作系统代码阅读心得(四):虚拟内存

    本文将会详细介绍Xv6操作系统中虚拟内存的初始化过程. 基本概念 32位X86体系结构采用二级页表来管理虚拟内存.之所以使用二级页表, 是为了节省页表所占用的内存,因为没有内存映射的二级页表可以不用分 ...

  3. XV6操作系统代码阅读心得(二):进程

    1. 进程的基本概念 从抽象的意义来说,进程是指一个正在运行的程序的实例,而线程是一个CPU指令执行流的最小单位.进程是操作系统资源分配的最小单位,线程是操作系统中调度的最小单位.从实现的角度上讲,X ...

  4. XV6操作系统代码阅读心得(五):文件系统

    Unix文件系统 当今的Unix文件系统(Unix File System, UFS)起源于Berkeley Fast File System.和所有的文件系统一样,Unix文件系统是以块(Block ...

  5. 深入理解Java虚拟机阅读心得(三)

    Java中提倡的自动内存管理最终可以归结为自动化的解决两个问题: 给对象分配内存 回收分配给对象的内存 先说说回收这一方面的两个主要知识点 一.垃圾收集算法 1.标记-清理算法 首先标记出所有需要回收 ...

  6. <<梦断代码>>阅读笔记三

    看完了这最后三分之一的<梦断代码>,意味着这本软件行业的著作已经被我粗略地过了一遍. 在这最后三分之一的内容中,我深入了解了在大型软件项目的运作过程中存在的困难和艰辛.一个大型软件项目的成 ...

  7. 【原】FMDB源码阅读(三)

    [原]FMDB源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1. 前言 FMDB比较优秀的地方就在于对多线程的处理.所以这一篇主要是研究FMDB的多线程处理的实现.而 ...

  8. 【原】SDWebImage源码阅读(三)

    [原]SDWebImage源码阅读(三) 本文转载请注明出处 —— polobymulberry-博客园 1.SDWebImageDownloader中的downloadImageWithURL 我们 ...

  9. 看图写代码---看图写代码 阅读<<Audio/Video Connectivity Solutions for Virtex-II Pro and Virtex-4 FPGAs >>

    看图写代码 阅读<<Audio/Video Connectivity Solutions for Virtex-II Pro and Virtex-4 FPGAs >> 1.S ...

随机推荐

  1. 使用git上传项目到GitHub上

    之前的博客有<使用git拉取GitHub上的项目>的文章,那么现在说一下,如何上传项目到GitHub上. 1. Git的.gitignore 文档配置 因为项目中可能有很多的图片还有nod ...

  2. jQuery UI基本使用方法

    其实jQuery UI早就在我的学习计划中,只不过因为计划安排始终处于待命状态,最近项目要用到jQuery UI,就提前学习一下,也想能够封装自己的UI库,这样就不用老按照别人的套路走了,像使用jQu ...

  3. RBAC权限系统设计

    序言 RBAC表结构 用户表 角色表 权限表 用户角色(关系)表 角色权限(关系)表 资料 https://blog.csdn.net/ShrMuscles/article/details/80532 ...

  4. 常用的20个强大的 Sublime Text 插件

    作为一个开发者你不可能没听说过 Sublime Text.不过你没听说过也没关系,下面让你明白. Sublime Text是一款非常精巧的文本编辑器,适合编写代码.做笔记.写文章.它用户界面十分整洁, ...

  5. 【51NOD】1135 原根

    [题意]给定p,求p的原根g.3<=p<=10^9. [算法]数学 [题解]p-1= p1^a1 * p2^a2 * pk^ak,g是p的原根当且仅当对于所有的pi满足g^[ (p-1)/ ...

  6. 2017ACM暑期多校联合训练 - Team 8 1011 HDU 6143 Killer Names (容斥+排列组合,dp+整数快速幂)

    题目链接 Problem Description Galen Marek, codenamed Starkiller, was a male Human apprentice of the Sith ...

  7. Linux命令之uptime

    这是什么 uptime用来查看系统已经启动了多长时间了. 它显示的信息和w命令的头(第一行)是一样一样的. 举个栗子 举一个实际的应用场景: 比如发现服务器上的某些没有加入开机启动的服务挂了一片,这个 ...

  8. pycharm显示行号

    在PyCharm 里,显示行号有两种办法: 1,临时设置.右键单击行号处,选择 Show Line Numbers. 但是这种方法,只对一个文件有效,并且,重启PyCharm 后消失. 2,永久设置. ...

  9. Coursera在线学习---第三节.归一化处理(Normalize)

    一.归一化(也说标准化)作用 1)将有量纲特征转化为无量纲特征 2)能够加快收敛(主要指梯度下降法时) 二.Octave中计算          mean(A)   求解矩阵中每一列的均值 std(A ...

  10. ARP投毒攻击

    原理:通过分别伪装成客户机和服务器IP,将自己的MAC地址绑定在IP上,ARP错误的将IP解析为中间人MAC地址,从而来欺骗服务器网关和客户机,使信息必须通过客户机.