嵌入式操作系统(3):同步和互斥

在这一节中线程和进程的调度其实本质是一样的,所以线程进程看做一个东西,都是调度的单位。

一、互斥

原子操作

所谓原子操作(Atomic Operation)是指不会被调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 Context switch。

PS:有时候甚至连条单条机器指令都不是原子的

1.1. 需要互斥的原因

由于在线程调度过程中,由于进行了上下文切换,所以程序中如果需要调用全局变量,则在对全局变量的操作的语句,在进行编译之后会拆分成数条机器代码(原子操作),在调度过程中会出现问题,比如以下代码:

new_pid = next_pid++;

其中next_pid为全局变量,将其编译成汇编代码得到:

load next_pid r1
store r1 new_pid 
inc r1
store r1 next_pid

若正常两线程执行该程序,输入next_pid = 100,输出的结果应该是:next_pid = 102, new_pid(1) = 100,new_pid(2) = 101。

但是如果在以下位置进行了上下文切换:

嵌入式操作系统(3):同步和互斥_第1张图片

执行步骤:

  1. 进程1中,把new_pid = 100,这时next_pid = 100没有进行自加,但是调度机制作用切换至进程2,切换时保护了现场r1 = 100。
  2. 进程2中,此时输入值为next_pid = 100, 正常输出值为:next_pid = 101, new_pid(1) = 100,出现错误。
  3. 返回进程1中,此时由于现场恢复r1 = 100,所以输出next_pid = 101,出现错误。

总结:上下文切换导致数据错误,并且错误是随机发生的。

由于上述产生的异常现象(称之为竞态条件Race Condition),这就是为什么要引入同步互斥这些机制的原因,就是要解决这种不确定性的问题。

1.2. 实现互斥

互斥(Mutual exclusion)是当一个进程处于临界区并访问共享资源时,没有其他进程会处于临界区并且访问任何相同的共享资源。

可以通过锁(Lock)来完成互斥操作

  • Lock.Acquire()            -------在锁被释放前一直等待,然后获得锁
  • Lock.Release()           -------解锁并唤醒任何等待中的进程

PS:这些锁操作为原子操作。

实现方法:

  1. 禁用硬件中断(不使用)
  2. 基于软件的解决方法(没有硬件保证的情况下没有真正的软件解决方案)
  3. 更高级的抽象(基于硬件原子操作的指令,在这三种方法中常用)

PS:在同步中互斥还可以通过信号量管道实现。

1.2.1. 基本概念

临界区(Critical section):是指进程中的一段需要访问共享资源并且当另一个进程处于相应代码区域时便不会被执行的代码区域。简单来说,就是访问共享资源的那段代码就是临界区。

死锁(Dead lock):为两个或以上的进程,在互相等待完成特定任务,而最终没法将自身任务进行下去。

饥饿(Starvation)一个可执行的进程,被调度器持续忽略,以至于虽然处于可执行状态却不被执行,这种状态为饥饿。

 

 

1.2.2. 临界区属性

  1. 互斥:同一个时间临界区中最多存在一个线程
  2. 前进(Progress):如果一个线程想要进入临界区,那么它最终会成功,不会一直的死等。
  3. 有限等待(waiting):如果一个线程i处于入口区,那么在i的请求被接受之前,其他线程进入临界区的时间是有限制的。如果是无限等待,就会出现饥饿状态,是Progress前进状态的一种进一步补充。
  4. 无忙等待(No-busy-waiting):如果一个进程在等待进入临界区,那么在它可以进入之前会被挂起,进入休眠。(可选属性,前三必有)

PS:和无忙等待对应的是忙等待(busy-waiting),表示的是程序在进行死循环大量占用CPU资源。可以利用休眠对其改善。

二、同步

同步概念:

合作的并发进程需要按先后次序执行。例如:一个进程的执行依赖于合作进程的消息或信号。当一个进程没有得到来自合作进程的消息或信号时需阻塞等待,直到消息或信号到达才唤醒。

举个栗子:

有临界缓冲区的生产者-消费者问题

  • 数个生产者产生的数据将放在一个Buffer内
  • 消费者每次从Buffer取出数据
  • 任意时间内只有一个消费者或者生产者对Buffer进行操作(互斥)
  • Buffer没数据消费者应该睡眠,Buffer数据满生产者应该睡眠(同步)

嵌入式操作系统(3):同步和互斥_第2张图片

其中有三个约束:

  1. 任意时间内只有一个消费者或者生产者对Buffer进行操作(互斥)
  2. Buffer为空,消费者等待生产者(同步)
  3. Buffer为满,生产者等待消费者(同步)

同步操作就是要解决这三方面的约束。

三、解决方法

设计过程中考虑问题:

  1. 死锁
  2. 忙等
  3. 效率
  4. 公平
  5. 饥饿

3.1. 基于硬件的原子操作(互斥)

基于硬件的原子操作,就是使用锁来编写临界区,很简单:

lock_next_pid -> Acquire();
...
Critical Section;
...
lock_next_pid -> Release();

实现Acquire()和Release()有两种方式:

  • Test-and-Set方式
  • 交换(exchange)方式

3.1.1. Test-and-Set方式

核心函数TestAndSet():

Boolean TestAndSet(Boolean *target){
    Boolean rv = *target;
    *target = TRUE;
    return rv;
}

特点:

  • 输入:target = 1,输出:返回值 = 1,target = 1;
  • 输入:target = 0,输出:返回值 = 0,target = 1;

实现(无忙等待)

class Lock{
    int value = 0;
    WaitQueue q;
};

Lock::Acquire(){
    while(TestAndSet(value)){
        add one thread t1 to q;  // 本次执行的线程 添加至等待队列
        schedule();
    }
}

Lock::Release(){
    value = 0;
    remove one thread t from q;  // 下一个执行的线程 移出等待队列
    wakeup(t);
}

请求分两种情况:

(1)若请求时无正在执行的线程,则value = 0;由于返回值为0,while循环会退出,与此同时value = 1,排斥其他线程进入临界区;接着执行临界区命令。

(2)若请求时有正在执行的线程,则value = 1;由于返回值为1,进入while循环,但此时value值不变;进入到循环后,将当前线程加入至等待队列q中,并且利用schedule()函数对该线程进行休眠,并且当前CPU会选择到下一个合适的线程去执行。直到前操作一个线程release了Lock,并且唤醒了该线程,才接着执行临界区命令。

3.1.2. Exchange方式

核心函数Exchange():

void Exchange(boolean *a, boolean *b){
    boolean temp = *a;
    *a = *b;
    *b = temp;
}

特点: 交换变量值

实现(忙等待)

int lock = 0;

Lock::Acquire(){
    int key = 1;
    while(key == 1){
        Exchange(lock, key);
    }
}

Lock::Release(){
    lock = 0;
}

Test-and-Set方式类似,请求分两种情况:

(1)若请求时无正在执行的线程,则lock = 0;交换后key = 0,while循环会退出,与此同时lock = 1,排斥其他线程进入临界区;接着执行临界区命令,结束后释放lock。

(2)若请求时有正在执行的线程,则lock = 1;交换后值不变,进入while循环;进入到循环后,忙等待。直到前操作一个线程release了Lock,结束忙等待,才接着执行临界区命令。

PS:此方法不能加入Test-and-Set方式中的唤醒机制,由于while循环必须得进一次。

3.2. ☆信号量(互斥+同步)

信号量(semaphore)为一个抽象的数据类型,可用于互斥+同步,由以下三部分组成:

  • 一个整型: sem   (PS:可以为二进制信号量,也可以为计数信号量)
  • 两个原子操作:P() & V() 加操作和减操作

核心思想:

只要满足同步条件就进入执行临界区,如果此时等待互斥条件继续执行,否则睡眠。

核心函数:

class Semaphore{
    Semaphore(int value){sem = value;};
    int sem;
    WaitQueue q;
}


p(){
    if(--sem < 0){
        add one thread t1 to q; 
        schedule();
    }
}

V(){
    if(++sem <= 0){
        remove one thread t from q; 
        wakeup(t);
    }
}

PV操作实现:PV操作为原子操作

  • P(): sem减一,如果sem < 0,等待,否则继续 ()
  • V(): sem加一,如果sem <= 0, 唤醒一个等待的P(进入时)

以消费者和生产者关系为例,进入P()时sem <= 0代表Buffer(没有数据/满数据)则需要等待,此时会将sem小于0。在正在执行的进程释放后,会由于++sem<=0知道有进程正在等待,唤醒进程。

以下使用mutex类名来表示互斥,以condition_name类名来表示同步。

3.2.1. 信号量实现互斥

实现互斥的方法和原子操作类似:

mutex = new Semaphore(1);

mutex->P();
...
Critical Sextion;
...
mutex->V();

在信号量实现互斥过程中,sem初值为1;

3.2.1. 信号量实现同步

每一个约束使用一个单独的信号量:

  1. 二进制信号量实现互斥
  2. 计数信号量FullBuffer实现判满
  3. 计数信号量EmptyBuffer实现判空

初值设置:

  1. mutex.sem = 1
  2. FullBuffer.sem = 0  
  3. EmptyBuffer.sem = n (能存n个数据)

PS:在任意时刻有关系 如下 FullBuffer.sem + EmptyBuffer.sem = n

以同步中举的列子进行分析

class BoundedBuffer{
    mutex = new Semaphore(1);
    FullBuffer = new Semaphore(0);
    EmptyBuffer = new Semaphore(n);
}

BoundedBuffer::Deposit(c){
    EmptyBuffer->P();
    mutex->P();
    Add data c to the buffer;
    mutex->V();
    FullBuffer->V();
}

BoundedBuffer::Remove(c){
    FullBuffer->P();
    mutex->P();
    Remove data c from the buffer;
    mutex->V();
    EmptyBuffer->V();
}

分析:

  • 消费者取的时候Buffer为空,则卡在FullBuffer->P();中,将当前进程睡在当前信号量上
  • 生产者存的时候Buffer为满,则卡在FullBuffer->P();中,将当前进程睡在当前信号量上

Critical srction 区间为入栈和出栈的操作,所以互斥操作出现在入栈出栈上下,与管程相区别。

3.3. ☆管程(互斥+同步)

管程 (monitor):是包含了一系列的共享变量和以及这些变量的一些操作的函数的组合和模块,由一下数个部分组成:

  • 条件变量
  • 等待操作:wait()   当某一个线程在管程中执行  某一个条件不满足时会进行wait操作,使自己睡眠,将自身的锁释放掉
  • 唤醒操作:signal()  当某一些条件得到满足  会唤醒一个条件变量对应的线程,让其继续执行
  • 唤醒操作:broadcast() 当某一些条件得到满足  会唤醒所有条件变量对应的线程,让其继续执行

核心思想:

只要满足互斥条件就进入执行临界区,如果满足同步条件就继续执行,否则睡眠。则Critical srction 区间为整个操作函数(模块化),与信号量相区别。

核心函数:

class Condition{
    int NumWaiting = 0; //代表等待中的进程数
    WaitQueue q;
};

Condition::Wait(lock){     // 不满足约束进入
    NumWaiting++;          // 等待进程数+1
    Add thread t1 to q;     
    release(lock);         // 释放锁进入休眠
    schedule();
    require(lock);         // 休眠完成得到锁
}

Condition::Signal(){
    if(NumWaiting > 0){
        Remove thread t from q;
        wakeup(t);
        NumWaiting--;
    }
}
  • wait: 和之前的加操作不一样,这条件执行情况是在满足互斥条件后进入其他约束不满足而执行。

以同步中举的列子进行分析

class BoundedBuffer{
    Lock lock;
    int count = 0;
    Condition notFull, notEmpty;
};

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

BoundedBuffer::Remove(c){
    lock->Acquire();   
    while(count == 0)      // 判空
        notEmpty.Wait(&lock);
    Remove thread t from buffer;
    count--;
    notFull.Signal();
    lock->Release();
}

使用while的原因:

在hansen-style下,进程在上一个进程的signal()被唤醒后没有马上对其进行执行,(由于后边还有release操作),这个时候可能存在多个被唤醒的进程,所以需要while在被唤醒后再判断自己是否抢到了该进程。

而相对应的Hoare-style下,进程在上一个进程的signal()被唤醒后直接调度回当前进程,不存在有多个进程被唤醒,所以此时while可以改变为if。

其中hansen-style和Hoare-style调度示意图如下所示

嵌入式操作系统(3):同步和互斥_第3张图片

 3.4. 总结

  1. 开发、调试很难。不确定性大
  2. 同步结构:锁、信号量、条件变量
  3. 必须遵循严格的程序设计标准

嵌入式操作系统(3):同步和互斥_第4张图片

四、实例应用

4.1. 读者-写者问题

问题背景:共享数据的访问,一方写一方读,读的时候不能写,写的时候不能读。则这个时候会产生优先级问题:

优先级:

  1. 读者优先:允许多名读者同时访问,特点为:写者需要在所有读者读完才能写,如果写的时候有读者来,需要在当前写进程写完后将锁让给所有等待的读者。
  2. 写者优先:允许多名读者同时访问,特点为:读者需要在所有写者写完才能读,如果读的时候有写者来,需要在当前所有度进程读完后,将锁让给一个等待的写者。 

4.1.1. 信号量实现读者优先

class W_R{
    mutex = new Semaphore(1);        // 保证读写操作是互斥的
    countmutex = new Semaphore(0);   // 保证Rcout的自加自减是互斥的
    Rcount = 1;                      // 用于计算读者数量
}

W_R::Write(){
    mutex->P();

    write;

    mutex->V();
}

W_R::Read(){
    countmutex->P();
    if(Rcount == 0)                  // 如果读者为空,判断是否有写者正在执行,与写者互斥
        mutex->P();                  // 如果有读者,则直接和读者一起阅读
    ++Rcount;
    countmutex->V();

    read;

    countmutex->P();
    --Rcount;
    if(Rcount == 0)                  // 如果读者为空,释放锁给写者
        mutex->V();                  // 如果读者不为空,不释放
    countmutex->V();
}

4.1.2. 管程实现写者优先

class W_R{
    AR = 0;        // active reader          
    AW = 0;        // active writer
    WR = 0;        // waiting reader
    WW = 0;        // waiting writer
    Condition okToRead;                
    Condition okToWrite;
    Lock lock;                         // while互斥
};

public W_R::Read(){
    StartRead();
    read;
    DoneRead();
}

public W_R::Write(){
    StartWrite();
    Write;
    DoneWrite();
}

private W_R::StartRead(){
    lock.Acquire();
    while((AW + WW) > 0){              // 如果有正在写或者正等待的写者,则进入休眠
        WR++;
        okToRead.wait(&lock);
        WR--;
    }
    AR++;
    lock.Release();
}

private W_R::DoneRead(){
    lock.Acquire();
    AR--;
    IF(AR == 0 && WW > 0)              // 如果没有正在读的读者并且有等待的写者,则释放锁
        okToWrite.signal(&lock);
    lock.Release();
}

private W_R::StartWrite(){
    lock.Acquire();
    while((AW + AR) > 0){              // 如果有正在执行的人,则进行休眠(同时只能一个人在写)        
        WW++;
        okToWrite.wait(&lock);
        WW--;
    }
    AW++;
    lock.Release();
}

private W_R::DoneWrite(){
    lock.Acquire();
    AW--;
    if(WW > 0)                        // 如果此时还有等待的写者,将锁交给下一个写者
        okToWrite.signal();
    else if(WR > 0)                   // 如果此时有等待的读者,唤醒所有的读者
        okToRead.broadcast(&lock);
    lock.Release();
}

4.2. 哲学家就餐问题

问题描述:桌子上游五把叉子,五个哲学家每个人需要左右两把叉子才能吃饭。如图所示

嵌入式操作系统(3):同步和互斥_第5张图片

解决方法:

#define N      5           // 哲学家数量
#define LEFT   (i-1+N)%N   // 右邻居标号
#define RIGHT  (i+1)%N     // 左邻居标号
#define THINK  0           // 思考状态
#define HUNGRY 1           // 饥饿状态
#define EATING 2           // 进餐状态
int state[N] = {THINK, THINK, THINK, THINK, THINK};// 状态数组

semaphore mutex;           // 互斥 
semaphore s[N];            // 同步 

void philosopher(int i){
    while(1){
        think();           // 思考
        take_forks(i);     // 拿起叉子
        eat();             // 吃饭
        put_forks(i);      // 放下筷子
    }
}

void take_forks(int i){
    mutex.P();
    state[i] = HUNGRY;     // 饥饿状态
    test_take_LR_forks(i); // 试图唤醒自己,如果此次唤醒不了则需要左邻居或者右邻居唤醒
    mutex.V();
    s[i].P();              // 想进入吃饭,但是没叉子进入休眠状态
}

void put_forks(int i){
    mutex.P();
    state[i] = THINKING;     
    test_take_LR_forks(LEFT); // 试图唤醒左邻居
    test_take_LR_forks(RIGHT);// 试图唤醒右邻居
    mutex.V();
}

void test_take_LR_forks(int i){ // 如果左边右边都有筷子,且处于饥饿状态就可以吃饭
    if(state[i] == HUNGRY && state[LEFT] != EATING && state[RIGHT] != EATING){
        state[i] = EATING; 
        s[i].V();          
    }
}

分析:

  1. 有数据结构来记录每个哲学家的状态(state),类似于建模中的一些仿真程序。
  2. 状态是一个临界资源,需要对其互斥
  3. 每一个哲学界吃饭之后应该唤醒左邻右舍(如果处于饥饿状态,并且叉子自身占用)

你可能感兴趣的:(操作系统,多线程,操作系统,c++)