第九课 锁

为什么要需要锁?

  1. 应用程序需要同时使用多个物理核来加速运行
  2. 因此内核必须支持并发调用系统调用
  3. 同时,可能出现并发地访问内核数据(buffer cache, processes)
  4. 锁帮助了正确地实现数据共享
  5. 但是锁的引入,也导致性能有一定的损失

第八天 Homework: 锁


锁的概述

  lock l
  acquire(l)
    x = x + 1 -- "critical section"
  release(l)
  1. 锁本身是一个对象
  2. 若多个线程同时尝试获取锁,只有一个线程会成功获得.
  3. 其他线程将会阻塞,直到锁被释放
  4. 通常一个大型项目中会有许多数据和对应的锁,各个线程使用并发的使用不同的数据以及不同的锁.这样项目可以并发地同时运行,从而节约了总运行时间.
  5. 注意,锁不是先天性的绑定在某个数据上的.这种对应关系是由程序员自己指定的.

(保守)何时需要锁

  1. 两个线程存在同时访问一个块内存的时候,并且至少有一个线程在写.
  2. 在访问共享内存前,请确保获得了正确的锁.

锁的作用

  1. 锁帮助避免无效的写入
  2. 锁帮助开发者创建一个多步的原子操作,隐藏了中间的临时状态

死锁

  1. 考虑经典的哲学家问题
  2. 经典解法: 对锁进行排序,所有调用必须按照同样的顺序获取锁,或者释放锁.

锁与模块封装性

  1. 锁的问题导致难以隐藏模块内部的细节
  2. 为了避免死锁,开发者必须知道模块内函数需要获取的锁.并且在调用模块前,获取这些锁.

锁和并发

  1. 锁限制了并发运行
  2. 为了获得并发的优势,开发者经常需要将数据分成更小的粒度,并用不同的锁来保护它们.
  3. 选择合适的锁粒度,是非常困难的.

锁的设计

  1. 从一个大锁开始,比如一整个模块一个锁
    • 减少了锁的总数,因此死锁的可能性比较小
    • 使用时,不需要记住太多锁的细节
  2. 通过测量性能,找到性能瓶颈
    • 大锁往往能够满足性能的要求,往往锁的某块并没有耗费太多的CPU时间.
  3. 如果锁的竞争导致了性能瓶颈,再考虑使用细粒度的锁

xv6中锁的应用

一个典型的应用: ide.c

场景分析

  1. ide.c中实现了对ide设备的操作,我们知道硬盘操作是一个比较慢的操作,因此需要一个idequeue来保存还未处理的请求.
  2. 本质上,类似于一个生产者消费者问题.
  3. 用户空间最终调用iderw()发起ide请求,并将请求放到idequeue中.同一时间,可能有多个请求被发起.
  4. 同一时间,只有idequeue队头的请求会被完成.
  5. ide硬件发起中断通知内核一次ide请求完成,内核调用ideintr()来处理当前完成的ide请求.并开始处理下一个请求.

临界区分析

  1. idequeue链表用于保存请求,必须是被保护的临界区.
  2. ide硬件设备同一时间只能处理一个请求,因此也是需要保护的临界区
  3. ide.c仅包含了一个锁idelock.因此这是一个比较大的锁,并没有实现更细的粒度.

锁的实现 V1

Q. 以下面的方式实现锁是否可行?为什么?

    struct lock { int locked; }
    acquire(l) {
      while(1){
        if(l->locked == 0){ // A
          l->locked = 1;    // B
          return;
        }
      }
    }

A: 显然是不行的,因此查询,修改locked的值,并不是一个原子操作.

原子操作

  1. 如下指令实现了对锁的原子操作.

    mov $1, %eax
    xchg %eax, addr
    
  2. 硬件模拟如下:

     lock addr globally (other cores cannot use it)
     temp = *addr
     *addr = %eax
     %eax = temp
     unlock addr
    
  3. 事实上,我们对锁的原子操作是由x86的硬件提供指令完成的.目前大多数体系结构都有类似的指令.

锁的实现 V2

  1. 锁的实际实现如下:

    acquire(l){
     while(1){
       if(xchg(&l->locked, 1) == 0){
         break
       }
     }
    }
    
  2. 如果锁已经被锁住了,那么xchg后仍然是1,所以将会继续循环.

  3. 如果锁没有被锁住,并且多个core在同时抢锁,那么只会有一个core能看到xchg返回0,而其余core仍然返回1.
  4. 这就是所谓的自旋锁,因为所有等待的core并没有让出CPU,而是在不停的循环尝试获得锁.

xv6 自旋锁的实现

  1. 头文件spinlock.h,源文件spinlock.c
  2. 注意阅读acquire()release()的实现
  3. acquire()时,为什么需要禁止中断?

指令重排

  1. 假设我们实现了一个本地的锁,用于在两个核之间保护一个临界区,模拟代码如下:

  Core A:          Core B:
    locked = 1
    x = x + 1      while(locked == 1)
    locked = 0       ...
                   locked = 1
                   x = x + 1
                   locked = 0
  1. 指令重排是指编译器或者CPU为了加速执行会将源码进行优化,在实际执行时,未必会按照源码实现的步骤执行.
  2. 比如上面core A的指令可能被实现为如下形式:
    locked = 1
    locked = 0
    x = x + 1
    
  3. 如果发生了指令重排,那么我们的锁完成没有起到保护临界区的目的.
  4. 所以在xv6spinlock锁的实现中,都有显式的使用内存屏障来组织指令重排的代码
  5. 如果使用了标准实现的锁,那么并不需要考虑指令重排和内存屏障的问题.
  6. 如果需要自己实现一个lock-free的代码,并且存在并发的情况,那么必须考虑指令重排的问题.

自旋锁

  1. 在自旋锁竞争的时候,CPU并不会被调度.因此存在一定的CPU空转浪费的问题.
  2. 那么为什么不在抢锁的时候,让出CPU呢?
  3. 自旋锁的使用原则:
    • 持有锁的线程必须很快完成工作,也就是说在临界区的时间要尽可能的短.
    • 在持有自旋锁的时候,线程不会被调度.
  4. 针对耗时比较久的临界区,系统另外提供了一种可以阻塞的锁.
    • 在抢锁的时候,线程可以睡眠,从而让出CPU
    • 因为存在线程切换,因此耗费也要大一些

建议

  1. 如非必要,不要共享数据.
  2. 从一个大锁开始调试
  3. 测试代码,找出性能瓶颈
  4. 针对性能瓶颈,设计更小粒度的锁
  5. 使用自动化工具,查找条件竞争

results matching ""

    No results matching ""