• 同步互斥的实现


    一、临界区

    1.定义:临界区指的是一个访问共用资源(例如:共用设备或是共用存储器)的程序片段,而这些共用资源又无法同时被多个线程访问的特性。当有线程进入临界区段时,其他线程或是进程必须等待,有一些同步的机制必须在临界区段的进入点与离开点实现,以确保这些共用资源是被互斥获得使用。

    2.临界区中存在的属性:

    • 互斥:同一时间临界区中最多存在一个线程;
    • Progress:如果一个线程想要进入临界区,那么它最终会成功(如果无限等待,处于饥饿状态,不妥);
    • 有限等待:如果一个线程i处于入口区,那么在i的请求被接受之前,其他线程进入临界区的时间是有限制的;
    • 无忙等待(可选):如果一个进程在等待进入临界区,那么它可以进入之前会被挂起。

    二、原子操作

    1. 原子操作是指一次不存在任何中断或失败的操作

    • 要么操作成功完成
    • 或者操作没有执行
    • 不会出现部分执行的状态

    2. 操作系统需要利用同步机制在并发执行的同时,保证一些操作是原子操作。

    三、管理临界区的方法

    1. 禁用硬件中断

    采用引荐中断需要考虑时钟中断:时钟中断是控制进程调度的手段之一

    • 没有中断,没有上下文切换,因此没有并发。硬件将中断处理延迟到中断被启用之后;大多数现代计算机体系结构都提供指令来完成。
    • 进入临界区,则禁用中断。
    • 离开临界区,则开启中断。

    但是,存在如下问题:

    • 一旦中断被禁用,线程就无法被停止;整个系统都会为你停下;可能导致其他线程处于饥饿状态。
    • 如临界区可以任意长,则无法限制响应中断所需的时间

     

    2. 基于软件的解决方法

    例子:假设有两个线程,T0和T1。Ti的通常结构为:

    1 do{
    2      enter section            //进入区域
    3      critical section          //临界区
    4      exit section              //离开区域
    5      reminder section      //提醒区域
    6 }while(1);

    线程可能共享一些共有的变量来同步他们的行为。下面设计一种方法,能在有限时间内实现退出/进入页区。

    算法前置知识与考虑

    • 共享变量,先初始化
    • int turn = 0 ;
    • turn == i    //表示Ti进入临界区
    • 对于Thread Ti ,代码表示如下:
    do{
          while(turn != i );    //如果turn不是i,死循环;直到turn是i,跳出循环
          critical section        //执行临界区代码
          turn = j;              //turn赋为j,退出循环
          reminder section
    }while(1);

    上述代码满足互斥,即不可能两个线程同时进入临界区。但不满足process,比如T1执行完进入临界区代码后,不再进入临界区程序,转去执行其他任务。而T2执行完临界区代码后,想再次进入临界区,而发现自己在退出临界区时,
    把turn赋值为了1,因此再执行进入临界区代码时,由于turn=1而不是2,会执行死循环而不能进入临界区。此方法的特点就是必须T1和T2交替执行来改变turn值,才能满足process。

    因此再考虑其他方法:

    对于有线程0、线程1的情况:

    • int flag[2]; flag[0] = flag[1] = 0
    • flag[i] = 1    //如果等于1,则线程Ti进入临界区
    • 对于Thread Ti,代码如下:
    1 do{
    2       while (flag[j] == 1);    //如果另一个进程想进来,此进程先谦让一下,自己先循环着
    3       flag[i] = 1;    //如果别的进程未准备,则自己赋成1,表示自己要进入临界区
    4       critical section
    5       flag[i] = 0;
    6       reminder section
    7 }while(1);

    该方法没有实现互斥,如T1执行完前两条代码后,上下文切换到T2,T2执行完前两条代码后 flag[1] == flag[2] ==0 ,所以两个线程都能进入临界区,不满足互斥。

    考虑将flag[i] = 1前置,代码如下

    1 do{
    2        flag[i] = 1; 
    3        while (flag[j] == 1);  
    4        critical section
    5        flag[i] = 0;
    6        reminder section
    7 }while(1);

    此方法满足互斥,但可能出现死锁,
    如T1执行完前两条代码后,上下文切换到T2,T2执行完前两条代码后 flag[1] == flag[2] ==1,两个进程都会进入死循环,所以两个线程都不能进入临界区。

     

    2.1 正确的解决办法(Peterson算法

    满足进程Pi和Pj之间互斥的经典的基于软件的解决方法(1981年),Use two shared data items(用上了turn和flag)。

    int turn; // 指示该谁进入临界区
    boolean flag[]; // 指示进程是否准备好进入临界区

    Code for ENTER_CRITICAL_SECTION

    1 flag[i] = TRUE;
    2 turn = j;
    3 while(flag[j] && turn == j);

    Code for EXIT_CRITICAL_SECTION

    flag[i] = FALSE;

    对于进程Pi的算法:

    1 do {
    2     flag[i] = TRUE;
    3     turn = j;
    4     while (flag[j] && turn == j);
    5     CRITICAL SECTION
    6     flag[i] = FALSE;
    7     REMAINDER SECTION
    8 } while (TRUE);

    上述算法能够满足互斥、前进、有限等待三种特性。可以用反证法来证明。

     

    2.2 更为复杂的dekker算法
    dekker算法的实现如下。

    flag[0] := false flag[1] := false := 0 // or 1
    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;
    REMAINDER SECTION
    } while (TRUE);

     

    针对多进程的Eisenberg and McGuire’s Algorithm

     

     

    基本思路:对于i进程,如果前面有进程,那么i进程就等待;对于i后面的进程,则等待i。这整体是一种循环。

     

    2.3 针对多进程的Bakery算法
    N个进程的临界区:

    • 进入临界区之前,进程接受一个数字;
    • 得到的数字最小的进入临界区;
    • 如果进程Pi和Pj收到相同的数字,那么如果i小于j,Pi先进入临界区,否则Pj先进入临界区;
    • 编号方案总是按照枚举的增加顺序生成数字。

    总结

    • Dekker算法(1965):第一个针对双线程例子的正确解决方案;
    • Bakery算法(Lamport 1979):针对n线程的临界区问题解决方案。
    • 算法是复杂的:需要两个进程间的共享数据项;
    • 需要忙等待(死循环):浪费CPU时间;
    • 没有硬件保证的情况下无真正的软件解决方案:Peterson算法需要原子的LOAD和STORE指令。

    3. 方法3:更高级的抽象

    • 硬件提供了一些同步原语,中断禁用、原子操作指令等。
    • 操作系统提供更高级的编程抽象来简化进程同步,例如:锁、信号量,用硬件原语来构建

    3.1 锁

    锁是一个抽象的数据结构

    • 一个二进制变量(锁定/解锁)
    • Lock::Acquire(),锁被释放前一直等待,然后等到锁
    • Lock::Release(),释放锁,唤醒任何等待的进程

    3.2 原子操作指令

    • 现代CPU体系结构都提供一些特殊的原子操作指令
    • 测试和置位指令(Test-and-Set)指令
    1. 从内存单元中读取值
    2. 测试该值是否为1(是1则返回真,否则返回假)
    3. 内存单元值设置为1
    1 boolean TestAndSet (boolean *target)
    2        {
    3           boolean rv = *target;
    4           *target = true;
    5           return rv;
    6         }
    • 交换指令(exchange)

      交换内存中的两个值

    1 void Exchange (boolean *a, boolean *b)
    2               {
    3                   boolean temp = *a;
    4                   *a = b;
    5                   *b = temp;
    6               }

    3.3 使用TestAndSet指令实现自旋锁(spinlock)

     3.4 忙等待与无忙等待锁

     在锁处于忙状态时,while循环会消耗CPU资源

     在无忙等待情况下,锁处于忙状态时,可通过上下文切换,将等待的线程挂到等待序列,而不进行循环,从而可以释放CPU资源。但这也要分情况讨论,因为上下文切换本身就要消耗不少资源,因此,当临界区比较短时,while循环的CPU资源消耗小于上下文切换,则应该设置为忙等锁,若临界区较长,while循环所消耗的CPU资源比上下文切换要大时,应该设置为无忙等锁。

    3.5 原子操作指令锁的特征

    • 优点
    1. 适用于单处理器或者共享驻村的多处理器中任意数量的进程同步。
    2. 简单并且容易证明
    3. 支持多临界区
    • 缺点
    1. 忙等待消耗处理器时间
    2. 可能导致饥饿,进程离开临界区时有多个等待进程的情况。
    3. 死锁,拥有临界区的低优先级进程,请求访问临界区的高优先级进程获得处理器并等待临界区。通俗来说就是,低优先级的进程已经进入了临界区,而此时高优先级的进程请求访问临界区,并且高优先级的大佬已经拥有了CPU的控制权,导致了在临界区的低优先级进程不能执行下一步,而未进入临界区的高优先级进程被锁住。

    四、同步方法总结

    1. 锁是一种高级的同步抽象方法

    • 互斥可以使用锁来实现
    • 需要硬件支持

    2. 常用的三种同步实现方法

    • 禁用中断(仅限于单处理器)
    • 软件方法(复杂)
    • 原子操作指令(单处理器或多处理器均可)



      

  • 相关阅读:
    8进阶、M语言对Simulink模型的自动化操作及配置
    7进阶、Simulink回调函数
    6基础、Simulink模型保存为图片
    PCAN-USB
    Unix-Linux编程实践 学习点滴
    学一点吧
    VM中ubuntu已经正确配置了静态IP仍无法上网
    Linux在向磁盘新建文件的时候在系统层面的四步操作
    如何更有人缘?
    我的第一个javascript网页作业
  • 原文地址:https://www.cnblogs.com/cjsword/p/12194448.html
Copyright © 2020-2023  润新知