第五章 并发性:互斥和同步

toc

相关术语

术语 解释
原子操作 一个或多个指令的序列,对外是不可分的,没有其他线程能看到其中间状态,此操作也不能被中断
临界区 指的是一个保护共用资源的程序片段,同一临界区不能被多个线程同时访问
临界资源 不可被多个线程同时访问的资源
死锁 两个或以上的线程,每个都在等其他线程做完某些事情而不能继续执行的情况
活锁 两个或以上的线程为了响应其他线程而持续改变自身状态但不做有用工作的情况
互斥 当一个线程在临界区访问共享资源时,其他线程不能进入该临界区的情况
条件竞争 多个线程读写同一共享数据时,结果依赖于他们执行的相对时间的情况
忙等待(自旋等待) 线程在得到临界区访问权限之前,只能继续执行测试变量的指令来得到访问权,除此之外不能做其他事情

一、硬件对互斥的支持

中断禁用

单处理器机器中可用此方法达成互斥,通过禁用中断的方式避免进程被中断,从而避免中断处理程序带来的竞争态。

/*关闭中断*/
/*临界区*/
/*开启中断*/

专用机器指令

CAS指令

cas(compare and swap)是一个原子指令,不接受中断,由比较和交换操作组成:

  • 比较操作使用一个测试值与一个目标内存单元中的值进行比较,如果内存单元值与测试值一直,则进行“交换”
  • 交换操作设置目标内存单元的值为一个新值
//版本一 总是返回目标内存的旧值。后续判断返回值与测试值是否相同,相同则表明目标内存单元已被更新
int compare_and_swap(int *word, int testval, int newval){
    int oldval;
    oldval = *word;    //取内存值的副本,这个很关键
    if(oldval == testval){
        *word = newval;
    }
    return oldval;
}
版本二 发生交换返回true,否则返回false
bool compare_and_swap(int *word, int testval, int newval){
    int oldval;
    oldval = *word;    //取内存值的副本,这个很关键
    if(oldval == testval){
        *word = newval;
        return true;
    }
    return false;
}

使用CAS构造临界区(达成互斥)的方法

int target = 0;    //所有线程均可访问
...
//每个线程内
while(!compare_and_swap(&target, 0, 1));    //版本二
/*临界区*/
target = 0;
/*非临界区*/
...

Exchange指令

Exchange原子的交换两个寄存器间、寄存器与内存间的内容。

void exchange(int register, int memory);

使用Exchange构造临界区(达成互斥)的方法

int target = 0;    //所有线程均可访问
...
//每个线程内
int key = 1;
do exchange(key, target)
while(0 != key);
/*临界区*/
target = 0;
/*非临界区*/
...

机器指令的缺点

  • 使用了忙等待:消耗CPU资源
  • 线程可能饥饿:线程选择是任意的,某些线程可能被无限期拒绝进入临界区
  • 可能死锁:单核心下,低优先级线程T1执行机器指令进入临界区随后被中断,CPU被分配给更高优先级线程T2,如果T2也意图访问同一临界区,那么将会造成死锁。T2等待T1退出临界区,T1等待调度

二、基于软件的并发同步机制

同步机制 说明
信号量 用于进程间传递信号的整数,仅有初始化、递减、递增三种操作,并都是原子的,递减操作可以用于阻塞一个线程,递增可以用于唤醒阻塞线程
二元信号量 只取0、1的信号量
互斥量 类似二元信号量,关键区别在于,加锁的线程与解锁的线程必须是同一线程
条件变量 一种数据类型,用于阻塞线程,直到特定条件为真
管程 在一个抽象数据类型中封装了变量、访问过程、初始化代码。管程的变量只能由管程自己的访问过程(访问临界区)来访问,每次只能有一个进程在其中执行,管程可以有一个线程等待队列
事件标志 作为同步机制的内存字。为标志中每个位关联不同事件,线程等待一个或多个事件时,通过测试标志中的一个或多个位是否设定,来决定线程阻塞或是唤醒
消息/信箱 两个进程用于交换信息的方法,可用于同步
自旋锁 一种互斥机制,锁变量变为可用之前无限循环测试锁是否可用

信号量

可以对信号量进行的操作

  • 初始化:信号量初始为非负数,表明了semWait操作后可立即继续执行的线程的数量
  • semWait:使信号量减1,如果减1后信号量值变为负数,则阻塞当前线程,否则继续执行,信号量的负值等于正在等待唤醒的线程个数
  • semSignal:使信号量加1,如果加1后信号量值大于或等于0,被semWait阻塞的线程则会被唤醒

信号量原语定义

struct semaphore{
    int count;
    queueType queue;
};

void semWait(semaphore& s){
    s.count--;
    if (s.count < 0){
        /*把当前线程推入队列*/
        /*阻塞当前线程*/
    }
}

void semSignal(semaphore& s){
    s.count++;
    if (s.count <= 0){
        /*从队列中移除一个线程*/
        /*将被移除的线程添加到就绪队列*/
    }
}

信号量的操作函数semWait、semSignal必须作为原子原语实现,后续说明实现方式
信号量分类:
信号量在实现时,使用了队列保存了阻塞的线程,根据线程移出顺序分为

  • 强信号量:采用FIFO方式,最先进队列的线程最先移出
  • 弱信号量:不规定线程从队列中的移出顺序

强信号量保证线程不会饥饿,弱信号量无法保证,强信号量也是操作系统提供的典型信号量形势
使用信号量构造临界区(达成互斥)的方法

semaphore s = 1;    //所有线程均可访问
...
semWait(s);
/*临界区*/
semSignal(s);
/*非临界区*/
...

需要保证semWait与semSignal间不会抛出异常也不会返回,假设后面所有相关代码均不抛出异常、不返回

有限缓冲区生产-消费问题

书中有一个利用信号量处理有限缓冲区生产消费问题的代码示例,我添加了注释

  • producer()与consumer()分别为生产者与消费者线程
  • 信号e表明了缓冲区空闲(剩余)大小
  • 信号s用于访问共享缓冲区访问控制,使共享资源在某一时刻仅被一个线程访问
  • 信号n表明缓冲区内数据个数
const int sizeofbufer = /*缓冲区大小*/;
semaphore s = 1, n = 0, e = sizeofbufer;
void producer(){
    while(true){
        produce();       //生产
        semaWait(e);    //e为缓冲区空闲空间大小,将向缓冲区添加数据,预减空闲计数,没有空闲空间则阻塞添加行为
        semaWait(s);    //配合semSignal(s),使共享缓冲区成为临界资源,保证某时刻仅有一个线程访问该资源
        append();         //往共享缓冲区添加数据
        semSignal(s);    //释放临界资源保护
        semSignal(n);    //n为缓冲区内数据个数,已向缓冲区添加数据,增加数据计数,唤醒阻塞在信号n的线程(消费线程)
    }
}

void consumer(){
    while(true){
        semaWait(n);    //n为缓冲区内数据个数,将取出缓冲区数据,预减数据计数,没有数据则阻塞消费行为
        semaWait(s);    //配合semSignal(s),使共享缓冲区成为临界资源,保证某时刻仅有一个线程访问该资源
        take();             //从共享缓冲区取出数据
        semSignal(s);    //释放临界资源保护
        semSignal(e);    //e为缓冲区空闲大小,已取出缓冲区数据,增加空闲计数,唤醒阻塞在信号e上的线程(生产线程)
        consume();        //正式消费
    }
}

信号量的实现

采用硬件的CAS指令实现互斥,来保证信号量操作函数semWait与semSignal的原子性

struct semaphore{
    int flag;
    int count;
    queueType queue;
};

void semWait(semaphore& s){
    while(!compare_and_swap(&s.flag, 0, 1));    //compare_and_swap为版本二
    s.count--;
    if (s.count < 0){
        /*把当前线程推入队列s.queue*/
        /*阻塞当前线程*/
    }
    s.flag = 0;        
}

void semSignal(semaphore& s){
    while(!compare_and_swap(&s.flag, 0, 1));    //compare_and_swap为版本二
    s.count++;
    if (s.count <= 0){
        /*从队列s.queue中移除一个线程*/
        /*将被移除的线程添加到就绪队列*/
    }
    s.flag = 0;        
}

管程

管程是由一个或多个过程、一个初始化序列和局部数据组成的软件模块。

  • 局部数据变量只能被管程的过程访问,任何外部过程都不能访问
  • 一个线程通过调用管程的一个过程进入管程
  • 任何时候,只能有一个线程在管程中执行,调用管程过程的任何其他线程都将被阻塞,直到管程可用(即支持互斥)
  • 当管程内的线程不满足条件时必须被阻塞并释放管程,使得其他线程可以进入管程构造对应条件,条件满足后,被阻塞线程需在挂起点重新进入管程(即支持同步)
    • 使用条件变量(注意不是信号量)达成同步
      1. cwait(c):等待信号,调用管程过程的线程在条件C上阻塞,管程被释放,可供其他线程使用
      2. csignal(c):发送信号,条件C达成,选择唤醒一个之前因为条件C阻塞的线程,没有阻塞在C上的线程,什么也不做

管程的优势

  • 管程拥有自己的互斥与同步机制,使用信号量时互斥与同步均由使用的程序员负责
  • 所有的同步机制都被限制在管程内部,易于验证同步的正确性、易于检查错误
  • 当管程被编写正确,所有线程对受保护资源的访问都是正确的;而使用信号量时,只有当所有访问受保护资源的线程都被编写正确,资源访问才是正确的

管程方案

书上提到了两种管程方案,Hoar与Lampson/Redell方案

  • Hoar
    • 发送信号的线程立即被阻塞并退出管程,进入入口队列或紧急队列
    • 等待信号的线程立即进入管程并恢复执行
  • Lampson/Redell
    • 发送信号的线程继续执行直到正常退出管程
    • 等待信号的管程将来在合适的时候进入管程恢复执行
    • 增加额外条件检查,判断是否符合继续执行条件
    • 在条件变量上增加计时器,等待超时则唤醒,随后进行额外条件检查
    • 可进行广播式通知,唤醒所有等待同一条件的线程,由额外条件检查判断是否继续执行

两种方案比较:

  • Hoar
    • 产生了三次额外的线程切换:阻塞发送信号线程、唤醒等待信号线程、后续恢复发送信号的线程
    • 信号相关调度必须非常可靠:调度程序需确保线程被唤醒前没有其他线程进入管程,以避免条件发生改变
    • 可能产生饥饿: 其他线程使条件满足,但在发送信号前失败,阻塞线程会一直阻塞
  • Lampson/Redell
    • 没有额外的线程切换
    • 不能保证等待线程进入管程之时条件仍然满足,必须重新检查条件,虽然导致至少多一次额外检查的性能消耗
    • 因为必要的额外条件检查,可以避免虚假唤醒
    • 不会存在hoar管程中的第三种问题

消息传递

消息传递可以通信也可以进行同步、互斥,可用于分布式系统中

信息传递原语

send(destination, message);    
receive(source, message);

信息传递分为发送与接收两个部分,均是 动作(地址,内容)形式

发送、接收形式

  • 无阻塞send:发送后继续执行。但需使用确认应答机制证实消息已被收到。无阻塞发送是最自然地发送方式
  • 阻塞send:发送后将阻塞直到完成信息投递,影响性能,并可能导致线程切换
  • 无阻塞receive:为接收到消息,需循环receive或配合通知机制使用
  • 阻塞receive:符合收到消息才继续执行的普遍期望,但存在永久阻塞的可能

寻址方式

  • 直接寻址:发送、接收时均需指定目标、源进程标识符
  • 间接寻址:使用称为信箱的共享队列临时保存消息,发送者、接收者通过队列进行通信

间接寻址方式解除了发、收者间的耦合,有了更多灵活性,发送者与接收者间关系可以是一对一、多对一、一对多或多对多

  • 一对一关系:双方建立了专用通信链接,增加的信箱相比直接寻址带来了异步、解耦、消峰填谷的优点
  • 多对一关系:典型的C/S交互模型,信箱被称为端口
  • 一对多关系:一般出现在广播情形
  • 多对多关系:C/S交互模型中,后端服务存在集群的情况
    信箱所有权归属:
    端口:通常归接收者所有,由接收者创建、同接收者销毁
    通用信箱:由创建者所有,随创建者销毁。由操作系统提供创建服务时,归系统所有,销毁需提供显示命令

消息格式

可变长消息典型格式

部分 额外解释

消息类型

目标ID
消息头 源ID

消息长度

控制信息 如消息数目、顺序号、优先级
消息体 消息内容 实际消息

排队原则

先进先出(FIFO)为基本原则,若有紧急消息,可指定消息优先级

有限缓冲区生产-消费问题

使用消息传递构造临界区(达成互斥)的方法

//box内有一个消息
...
message msg;
receive(box, msg);    //取走消息的线程进入临界区,其他阻塞
/*临界区*/
send(box, msg);
/*非临界区*/
...

书中使用消息传递方式,处理有限缓冲区生产消费问题例子,我添加了注释

  • mayProduce信箱内消息个数用于控制数据生产发送节奏
  • mayConsume信箱用于传递数据,并充当数据缓冲队列
  • 代码内不存在临界资源访问,仅用了消息来实现同步(Go语言牛逼),可以做到很高的并发度,多个(capacity个)生产者与消费者线程可完全并行,不存在某时刻仅有一个线程执行的情况
const int capacity = /*缓冲区大小*/
const int null = /*空消息*/

void producer(){
    message msg;
    while(true){
        receive(mayProduce, msg);    //从信箱mayProduce接收生产信号,没有信息阻塞,有则使可生产个数减少
        msg = produce();            //产生数据
        send(mayConsume, msg);        //向信箱mayConsume发送数据,随后数据个数增加
    }
}

void consumer(){
    message msg;
    while(true){
        receive(mayConsume, msg);    //从信箱mayConsume接收数据,没有信息阻塞,有则使数据个数减少
        consume(msg);                //消费数据
        send(mayProduce, null);        //向信箱mayProduce发送生产信号,随后可生产个数增加
    }
}

void main(){
    createMailBox(mayProduce);        //mayProduce信箱内消息个数代表可生产个数,用于控制数据生产发送节奏
    createMailBox(mayConsume);        //mayConsume信箱用于传递数据,并充当数据队列
    for(int i = 0; i < capacity; i++){
        send(mayProduce, null);        //将mayProduce信箱填capacity个消息,由于send与receive一一对应,数据队列大小也将为capacity
    }
    parbegin(producer, consumer);    //并行执行 producer、consumer
}

读者-写者问题

读写者定义在并发环境中,有读写两种线程操作共享数据,读线程只读、写线程只写。
读写者问题需满足条件如下:

  • 任意多的读线程可以同时读共享数据
  • 一次只有一个写线程可以写共享数据
  • 如果一个写线程正在写共享数据,禁止任何读线程读共享数据

读者优先

读线程拥有访问共享数据的优先权,当一个读线程开始访问共享数据时,只少只要有一个读线程正在执行读操作,就为读线程保留对共享数据的控制权

  • 信号x 用于保护临界资源readCount
  • 信号wsem 用来达成互斥(读写互斥、写写互斥),可理解为,可对共享数据进行读/写的线程数
    • 信号wsem的semWait操作与semSignal将读现场的读操作与写现场的写操作包含在了范围内,导致读写互斥、写写互斥
int readCount = 0;            //记录正在读共享数据的线程个数
semaphore x = 1, wsem = 1;
void reader(){
    semWait(x);                //与semSignal(x)配合,保护临界资源readCount,确保readCount上的操作、访问正确
    readCount++;            //将要执行读操作,预加读线程个数
    if(1 == readCount){        //判断是否是没有读线程时,将执行读的首个线程        
        semWait(wsem);        //首个读线程将要对共享数据进行读取,预减可写线程数,开启共享数据上的读写操作互斥
    }
    semSignal(x);            //释放临界资源readCount保护
    readUnit();                //读共享数据
    semWait(x);
    readCount--;            //读操作完成,预减读线程个数
    if(0 == readCount){        //判断是否已没有执行读操作的线程
        semSignal(wsem);    //已没有读线程,增加可写线程数,解除共享数据上的读写互斥,唤醒阻塞的写线程
    }
    semSignal(x);
}

void writer(){
    while(true){
        semWait(wsem);            //预减可读、可写线程数,开启共享数据上的读写互斥/写互斥
        writeUnit();            //写共享数据
        semSignal(wsem);        //增加可读、可写线程数,解除共享数据上的读写互斥/写写互斥,唤醒阻塞的读线程/写线程
    }
}

写者优先

读者优先存在写线程饥饿的可能,写线程通过增加额外的信号,解决了写线程饥饿的问题

int readCount = 0, writeCount = 0;
semaphore x = 1, y = 1, z = 1, wsem = 1, rsem = 1;
void reader(){
    while(true){
        semWait(z);                //配合semSignal(z)构造临界区,控制在信号rsem上排队的读线程数,降低写线程阻塞在rsem上概率
        semWait(rsem);            //用于给写线程控制读线程执行的机会,存在写线程时阻塞任何读
        semWait(x);                //配合semSignal(x)构造临界区,保护readCount
        readCount++;            //将执行读,预增读线程数量
        if(1 == readCount){        //判断是否没有读线程时,将执行读的首个线程  
            semWait(wsem);        //首个读线程将要对共享数据进行读取,预减可写线程数,开启共享数据上的读写操作互斥
        }
        semSignal(x);            //结束临界区
        semSignal(rsem);        
        semSignal(z);            //信号z信号rsem必须在readUnit前被发送,否则将不能满足“任意多的读线程可以同时读共享数据”
        readUnit();                //读共享数据
        semWait(x);
        readCount--;
        if(0 == readCount){        //判断是否已没有执行读操作的线程
            semSignal(wsem);    //已没有读线程,增加可写线程数,解除共享数据上的读写互斥,唤醒阻塞的写线程
        }
        semSignal(x);
    }
}

void writer(){
    while(true){
        semWait(y);                //配合semSignal(y)构造临界区,保护readCount
        writeCount++;            //预增写线程数量
        if(1 == writeCount){    //判断是否没有写线程时,将执行写的首个线程  
            semWait(rsem);        //首个写线程将要对共享数据进行读取,预减可读线程数,通过此信号阻塞任何读线程读
        }
        semSignal(y);            //结束临界区
        semWait(wsem);            //预减可读、可写线程数,开启共享数据上的读写互斥/写互斥
        writeUnit();            //写共享数据
        semSignal(wsem);        //增加可读、可写线程数,解除共享数据上的读写互斥/写写互斥,唤醒阻塞的读线程/写线程
        semWait(y);
        writeCount--;
        if(0 == writeCount){    //判断是否已没有执行写操作的线程
            semSignal(rsem);    //读操作完成,允许读线程继续执行,唤醒阻塞的读线程
        }
        semSignal(y);
    }
}




posted @ 2021-06-27 06:46  無雙  阅读(297)  评论(0编辑  收藏  举报