《Tsinghua os mooc》第17~20讲 同步互斥、信号量、管程、死锁

第十七讲 同步互斥

  1. 进程并发执行

    • 好处1:共享资源。比如:多个用户使用同一台计算机。
    • 好处2:加速。I/O操作和CPU计算可以重叠(并行)。
    • 好处3:模块化。
      • 将大程序分解成小程序。以编译为例,gcc会调用cpp,cc1,cc2,as,ld。
      • 使系统易于复用和扩展。程序可划分成多个模块放在多个处理器上并行执行。
  2. 原子操作

    • 原子操作是指一次不存在任何中断或失败的操作。要么操作成功完成,或者操作没有执行,不会出现部分执行的状态。
    • 操作系统需要利用同步机制在并发执行的同时,保证一些操作是原子操作。
  3. 由于不是原子操作而带来错误的一个例子:并发创建新进程时的标识分配。如下面,标识分配用C语言表达是一个语句,翻译成机器指令后是4条机器指令。假设next_pid开始是100,有两个进程A和B,如果进程A执行完前2条机器指令后,CPU切换到进程B执行完4条机器指令,再切回A执行完后2条指令。那么,进程A和B分配到的new_pid都是101,而next_pid最后也被更新为101,显然出现了Bug。

// C code
new_pid = next_pid++

// Machine Code
LOAD next_pid Reg1
STORE Reg1 new_pid
INC Reg1
STORE Reg1 next_pid
  1. 利用两个原子操作实现一个锁(lock)
    • Lock.Acquire():在锁被释放前一直等待,然后获得锁;如果两个线程都在等待同一个锁,并且同时发现锁被释放了,那么只有一个能够获得锁。
    • Lock.Release():解锁并唤醒任何等待中的进程。
breadlock.Acquire();  // 进入临界区
if (nobread) {        // 临
  buy bread;          // 界
 }                    // 区
breadlock.Release();  // 退出临界区
  1. 进程的交互关系:相互感知程度

    • 相互不感知(完全不了解其它进程的存在):进程之间相互独立,一个进程的操作对其他进程的结果无影响
    • 间接感知(双方都与第三方交互,如共享资源):进程之间通过共享来协作,一个进程的结果依赖于共享资源的状态
    • 直接感知(双方直接交互,如通信):进程之间通过通信来协作,一个进程的结果依赖于从其他进程获得的信息
  2. 进程的交互关系

    • 互斥 ( mutual exclusion ) :一个进程占用资源,其它进程不能使用
    • 死锁(deadlock):多个进程各占用部分资源,形成循环等待
    • 饥饿(starvation):其他进程可能轮流占用资源,一个进程一直得不到资源
  3. 临界区(Critical Section)

    • 临界区(critical section):进程中访问临界资源的一段需要互斥执行的代码
    • 进入区(entry section):检查可否进入临界区的一段代码。如可进入,设置相应"正在访问临界区"标志
    • 退出区(exit section):清除“正在访问临界区”标志
    • 剩余区(remainder section):代码中的其余部分
entry section
   critical section
exit section
   remainder section
  1. 临界区的访问规则

    • 空闲则入:没有进程在临界区时,任何进程可进入
    • 忙则等待:有进程在临界区时,其他进程均不能进入临界区
    • 有限等待:等待进入临界区的进程不能无限期等待
    • 让权等待(可选):不能进入临界区的进程,应释放CPU(如转换到阻塞状态)
  2. 临界区的实现方法

    • 禁用中断(仅适用于单处理器)
    • 软件方法(复杂)
    • 更高级的抽象方法(单处理器或多处理器均可)
  3. 临界区的硬件实现方法:禁用硬件中断

    • 没有中断,没有上下文切换,因此没有并发。硬件将中断处理延迟到中断被启用之后,现代计算机体系结构都提供指令来实现禁用中断
    • 进入临界区:禁止所有中断,并保存标志
    • 离开临界区:使能所有中断,并恢复标志
    • 缺点:禁用中断后,进程无法被停止,整个系统都会为此停下来,可能导致其他进程处于饥饿状态;临界区可能很长,无法确定响应中断所需的时间(可能存在硬件影响)
    • 要小心地用
local_irq_save(unsigned long flags);
critical section
local_irq_restore(unsigned long flags);
  1. 临界区的软件实现方法之一:Peterson算法
    • 共享变量
    int turn;  // 表示该谁进入临界区
    boolean flag[];  // 表示进程是否准备好进入临界区
    
    • 代码实现
do {
    flag[i] = true;
    turn = j;
    while ( flag[j] && turn == j);
        CRITICAL SECTION

    flag[i] = false;

        REMAINDER SECTION
   } while (true);
  1. 临界区的软件实现方法之二(支持多个进程):Dekkers算法
flag[0]:= false; flag[1]:= false; turn:= 0;//or1

do {
    flag[i] = true;
    while flag[j] == true {
        if turn ≠ i {
            flag[i] := false
            while turn ≠ i { }
            flag[i] := true
        }
    }
    CRITICAL SECTION
    turn := j
    flag[i] = false;
    EMAINDER SECTION
   } while (true);
  1. 临界区的软件实现方法之三:N线程的软件方法(Eisenberg和McGuire)

    • 线程Ti要等待从turn到i-1的线程都退出临界区后访问临界区
    • 线程Ti退出时,把turn改成下一个请求线程
  2. 基于软件的解决方法的分析

    • 复杂:需要两个进程间的共享数据项
    • 需要忙等待:浪费CPU时间
  3. 临界区的更高级的抽象实现方法:操作系统提供更高级的编程抽象来简化进程同步,例如锁、信号量,而它们是基于硬件提供的同步原语来构建的,比如中断禁用、原子操作指令等。

  4. 锁是一个抽象的数据结构

    • 一个二进制变量(锁定/解锁)
    • Lock::Acquire():原子操作。锁被释放前一直等待,然后得到锁
    • Lock::Release():原子操作。释放锁,唤醒任何等待的进程
    • 使用锁来控制临界区访问
lock_next_pid->Acquire();
new_pid = next_pid++ ;
lock_next_pid->Release();
  1. 原子操作指令

    • 现代CPU体系结构都提供一些特殊的原子操作指令
    • 测试和置位(Test-and-Set )指令:从内存单元中读取值,测试该值是否为1(然后返回真或假),将内存单元值设置为1
    boolean TestAndSet (boolean *target)
    {
        boolean rv = *target;
        *target = true;
        return rv:
    }
    
    • 交换指令(exchange):交换内存中的两个值
    void Exchange (boolean *a, boolean *b)
    {
        boolean temp = *a;
        *a = *b;
        *b = temp:
    }
    
  2. 使用TS指令实现自旋锁(spinlock)

    • 如果锁被释放,那么TS指令读取0并将值设置为1,锁被设置为忙并且需要等待完成
    • 忙则等待。如果锁处于忙状态,那么TS指令读取1并将值设置为1,不改变锁的状态并且需要循环
    • 线程在等待的时候消耗CPU时间
    class Lock {
        int value = 0;
    }
    
    Lock::Acquire() {
       while (test-and-set(value))
          ; //spin
    }
    
    Lock::Release() {
        value = 0;
    }
    
  3. 无忙等待锁

    class Lock {
       int value = 0;
       WaitQueue q;
    }
    
    Lock::Acquire() {
       while (test-and-set(value)) {
          add this TCB to wait queue q;
          schedule();
       }
    }
    
    Lock::Release() {
       value = 0;
       remove one thread t from q;
       wakeup(t);
    }
    
  4. 原子操作指令锁的特征

    • 优点
      • 适用于单处理器或者共享主存的多处理器中任意数量的进程同步
      • 简单并且容易证明
      • 支持多临界区
    • 缺点
      • 忙等待消耗处理器时间
      • 可能导致饥饿、进程离开临界区时有多个等待进程的情况
      • 死锁:拥有临界区的低优先级进程请求访问临界区,而高优先级进程获得处理器并等待临界区

第十八讲 信号量与管程

  1. 自旋锁、互斥锁、条件变量、信号量

    • 自旋锁:一直尝试加锁,只要没有锁上,就不断尝试。
    • 互斥锁:尝试加锁,如果没有锁上,则让出CPU给其他进程使用,等到锁的状态发生变化时再唤醒该进程。涉及到上下文切换,因此操作开销比自旋锁大。
    • 条件变量:与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。条件变量是在多线程程序中用来实现“等待->唤醒”逻辑常用的方法。互斥量在允许或堵塞对临界区的访问上是很有用的,条件变量则允许线程由于一些未达到的条件而堵塞。通常条件变量和互斥锁同时使用,这种模式用于让一个线程锁住一个互斥量,然后当它不能获得它期待的结果时等待一个条件变量。最后另一个线程会向它发信号,使它可以继续执行。
    • 信号量:是一种更高级的同步机制,mutex可以说是semaphore在仅取值0/1时的特例。Semaphore可以有更多的取值空间,用来实现更加复杂的同步,而不单单是线程间互斥。信号量的主要用途是调度线程,具体而言就是:一些线程生产(increase)同时另一些线程消费(decrease),semaphore可以让生产和消费保持合乎逻辑的执行顺序。
    • 互斥锁,同步锁,临界区,互斥量,信号量,自旋锁之间联系是什么? - Tim Chen的回答 - 知乎
    • 如何理解互斥锁、条件锁、读写锁以及自旋锁? - 邱昊宇的回答 - 知乎
    • semaphore和mutex的区别? - 二律背反的回答 - 知乎
  2. 信号量 vs 软件同步

    • 信号量是操作系统提供的一种协调共享资源访问的方法。OS是管理者,地位高于进程。
    • 软件同步是平等线程间的一种同步协商机制。
  3. 信号量

    • 早期的操作系统的主要同步机制,现在已很少使用。
    • 信号量是一种抽象数据类型,由一个整形 (sem)变量和两个原子操作组成。
    • P():Prolaag (荷兰语尝试减少),sem减1,如sem<0, 进入等待, 否则继续
    • V():(Verhoog (荷兰语增加)),sem加1
    • 信号量是被保护的整数变量,初始化完成后,只能通过P()和V()操作修改,由操作系统保证,P/V操作是原子操作
    • P() 可能阻塞,V()不会阻塞
    • 通常假定信号量是“公平的”,线程不会被无限期阻塞在P()操作,假定信号量等待按先进先出排队
  4. 信号量的实现

classSemaphore {
    int sem;
    WaitQueue q;
}

Semaphore::P() {
    sem--;
    if (sem < 0) {
        Add this thread t to q;
        block(p);
    }
}

Semaphore::V() {
    sem++; 
    if (sem<=0) {
        Remove a thread t from q;
        wakeup(t);        
    }
}
  1. 信号量分类

    • 二进制信号量:资源数目为0或1
    • 资源信号量:资源数目为任何非负值
    • 两者等价,基于一个可以实现另一个
  2. 信号量的使用

    • 互斥访问:临界区的互斥访问控制
      • 每个临界区设置一个信号量,其初值为1
      • 必须成对使用P()操作和V()操作,P()操作保证互斥访问临界资源,V()操作在使用后释放临界资源,P/V操作不能次序错误、重复或遗漏
    • 条件同步:线程间的事件等待
      • 每个条件同步设置一个信号量,其初值为0
  3. 生产者-消费者问题

    • 一个或多个生产者在生成数据后放在一个缓冲区里
    • 单个消费者从缓冲区取出数据处理
    • 任何时刻只能有一个生产者或消费者可访问缓冲区
    • 可能存在竞争条件:假设只使用一个全局变量count来记录缓冲区的数据项数,再假设消费者刚将count的值读到寄存器时,CPU切换到生产者进程,生产者向缓冲区加入一个数据,count加1,然后唤醒消费者。然而消费者此时在逻辑上并未睡眠,所以wakeup信号丢失。当消费者下次运行时,它测试寄存器的值,发现count=0,于是睡眠。生产者迟早会填满整个缓冲区,然后睡眠。这样一来,两个进程都将永远睡眠下去。
  4. 用信号量解决生产者-消费者问题

Class BoundedBuffer {
    mutex = new Semaphore(1);  // 二进制信号量
    fullBuffers = new Semaphore(0);   // 资源信号量
    emptyBuffers = new Semaphore(n);  // 资源信号量
}

BoundedBuffer::Deposit(c) {
    emptyBuffers->P();
    mutex->P();
    Add c to the buffer;
    mutex->V();
    fullBuffers->V();
}

BoundedBuffer::Remove(c) {
    fullBuffers->P();
    mutex->P();
    Remove c from buffer;
    mutex->V();
    emptyBuffers->V();
}
  1. 使用信号量的困难

    • 读/开发代码比较困难,程序员需要能运用信号量机制
    • 容易出错,如使用的信号量已经被另一个线程占用、忘记释放信号量等
    • 不能够处理死锁问题
  2. 管程(monitor)

    • 采用面向对象方法,简化了线程间的同步控制。伍注:信号量在实现同步时需要P/V多个变量,而且P/V操作有严格顺序,否则可能出现死锁;而管程只需要调用一个封装的函数即可。
    • 任一时刻最多只有一个线程执行管程代码,这一特性使管程能有效地完成互斥。
    • 正在管程中的线程可临时放弃管程的互斥访问,等待事件出现时恢复。这需要引入条件变量以及相关的两个操作:wait和signal。当一个管程过程发现它无法继续运行时,它会在某个条件变量上执行wait操作。该操作导致调用进程自身堵塞,并且还将另一个以前等在管程之外的进程调入管程。另一个管程过程可以通过对其伙伴正在等待的一个条件变量执行signal,来唤醒正在睡眠的伙伴进程。
  3. 条件变量是管程内的等待机制。进入管程的线程因资源被占用而进入等待状态,每个条件变量表示一种等待原因,对应一个等待队列。

Class Condition {
    int numWaiting = 0;
    WaitQueue q;
}

Condition::Wait(lock){
    numWaiting++;
    Add this thread t  to q;
    release(lock);
    schedule(); //need mutex
    require(lock);
}

Condition::Signal(){
    if (numWaiting > 0) {
        Remove a thread t from q;
        wakeup(t); //need mutex
        numWaiting--;
    }
}
  1. 用管程实现生产者-消费者问题
classBoundedBuffer {
    …
    Lock lock;
    int count = 0;
    Condition notFull, notEmpty;
}

BoundedBuffer::Deposit(c) {
    lock->Acquire();
    while (count == n)
        notFull.Wait(&lock);
    Add c to the buffer;
    count++;
    notEmpty.Signal();
    lock->Release();
}

BoundedBuffer::Remove(c) {
    lock->Acquire();
    while (count == 0)
      notEmpty.Wait(&lock);
    Remove c from buffer;
    count--;
    notFull.Signal();
    lock->Release();
}
  1. 管程条件变量的释放处理方式

    • Hansen管程:让新唤醒的进程运行,而挂起通知进程。少切换、高效。主要用于真实OS和Java中。
    • Hoare管程:执行signal的进程必须立即退出进程。多切换、低效。概念上更简单,主要见于教材中。
  2. 哲学家问题的一个解法(没有死锁,可供多个哲学家就餐)

#define   N   5                     // 哲学家个数
semaphore fork[5];                  // 信号量初值为1
void philosopher(int   i)         // 哲学家编号:0 - 4
{
    while(TRUE)
    {
        think( );                   // 哲学家在思考
        if (i%2 == 0) {
            P(fork[i]);	            // 去拿左边的叉子
            P(fork[(i + 1) % N]);   // 去拿右边的叉子
        } else {
            P(fork[(i + 1) % N]);   // 去拿右边的叉子
            P(fork[i]);             // 去拿左边的叉子 
        }      
        eat( );                     // 吃面条中….
        V(fork[i]);		            // 放下左边的叉子
        V(fork[(i + 1) % N]);	    // 放下右边的叉子
    }
}
  1. 读者-写者问题

  2. 基本同步方法

第十九讲 实验七 同步互斥

  1. 哲学家就餐问题的底层支撑技术

第二十讲 死锁与进程通信

  1. 目前大多数操作系统不负责死锁处理,因其开销较大。
posted on 2019-03-04 15:55  whl1729  阅读(377)  评论(0编辑  收藏  举报