操作系统多线程基础 - 一种简单的理解角度

目录

-

- 1. 一些概念

- 2. 两种经典的软件解法

        - 严格轮换

        - 著名的PETERSSON解法

- 3. 原子操作

- 4. 睡眠唤醒 -> 信号量、互斥量 -> 管程

    - 引例:睡眠与唤醒

    - 信号量、互斥量

    - 信号量实现的生产者消费者模型

    - 管程

 

 

1. 一些概念

  • 临界区,对共享内存进行访问的程序片段称为临界区。
  • 忙等待的互斥(自旋等待),自旋锁,基于忙等的锁

2. 两种经典的软件解法

严格轮换

 1// 进程a

2while(TRUE){

3    while(turn != 0);      /* 循环测试turn,看其值何时变为0 */

4    critical_region();     /* 进入临界区 */

5    turn = 1;              /* 让给下一个进程处理 */

6    noncritical_region();  /* 离开临界区 */

7}

8// 进程b

9while(TRUE){

10    while(turn != 1);      /* 循环测试turn,看其值何时变为1 */

11    critical_region();     /* 进入临界区 */

12    turn = 0;              /* 让给下一个进程处理 */

13    noncritical_region();  /* 离开临界区 */

14}

一个阻塞,两个都阻塞,上述未表明的关键是,进去临界区的代码如何实现。

著名的PETERSSON解法

 1#define FALSE 0

2#define TRUE1

3#define N 2                       /* 进程数量 */

4

5int turn;                         /* 现在轮到谁? */

6int interested[N];                /* 所有值初始化为0 (FALSE) */

7

8void enter_region(int process){   /* 进程是0或1 */

9    int other;                    /* 其他进程号 */

10    other = 1 - process;          /* 另一方进程 */

11    interested[process] = TRUE;   /* 表示所感兴趣 */

12    turn = process;

13    while(turn == process && interested[other] == TRUE);/* 空循环 */

14}

15

16void leave_region(int process){

17    interested[process] = FALSE;  /* 表示离开临界区 */

18}

 

3. 原子操作

类似的指令都是用硬件保证原子性,例如,几个命令之间屏蔽中断,由于几个语句都是非常快的,所以整体非常快。

  • 测试并设置锁 Test and Set Lock (TSL)
  • 获取并增加 Fetch-and-Increment
  • 交换 Swap
  • 比较并交换 Compare-and-Swap (CAS)
  • 加载链接/条件存储 Load-linked / Store-Conditional LL/SC

避免忙等问题,现在大部分都是采用睡眠唤醒机制。还有更多基于上述指令实现的复杂指令。

  • 睡眠与唤醒(线程挂起)

然后基于睡眠和唤醒构建的例如。

  • 信号量与互斥量

再次,基于信号量和互斥量构建的的。

  • 管程

4. 睡眠唤醒 -> 信号量、互斥量 -> 管程

理解信号量、互斥量概念非常重要,因为大部分机制都是由这些东西实现的。

那么,什么是信号量、互斥量,它们的本质到底是什么?

引例:睡眠与唤醒

 1#define N 100                             /* 缓冲区中的槽数量 */

2int count = 0;                            /* 缓冲区中的数据项数目 */

3

4// 生产者

5void producer(void){

6    int item;

7

8    while(TRUE){                          /* 无限循环 */

9        item = produce_item()             /* 产生下一新数据项 */

10        if(count == N) sleep();           /* 如果缓冲区满了,就进入休眠状态 */

11        insert_item(item);                /* 将新数据放入缓冲区中 */

12        count = count + 1;                /* 缓冲区数据项计数器+1 */

13        if(count == 1) wakeup(consumer);  /* 缓冲区不为空则唤醒消费 */

14    }

15}

16

17// 消费者

18void consumer(void){

19    int item;

20

21    while(TRUE){                          /* 无限循环 */

22        if(count == 0) sleep();           /* 如果缓冲区是空的,则进入休眠 */

23        item = remove_item();             /* 从缓冲区中取出一个数据项 */

24        count = count - 1                 /* 将缓冲区的数据项计数器-1 */

25        if(count == N - 1) wakeup(producer); /* 缓冲区不满,则唤醒生产者? */

26        consumer_item(item);              /* 打印数据项 */

27    }

28}

这是生产者消费者的典型实现,但是它是不安全的。为什么?考虑一种情况:

假如从 if(count == N) 这里被中断了,此时生产者为睡眠,而切换到消费者,消费者执行,消费了一个,count=N-1,则调用wakeup,问题是,生产者并没有睡眠,于是wakeup丢失。这明显就出问题了,比如,回到生产者,生产者被睡眠了,而此时,若N-1=0,则消费者也睡眠。

有些复杂,问题到底出在哪里?其实非常简单,核心问题是,count的判断和睡眠,不是原子操作。——判断操作之后可以中断的话,是引起各种问题的根源,你看,原子操作中就是将测试操作原子化的。

所以,解决并发问题的本质——增加原子操作范围,不够就增,不够就再增,就这么简单。

信号量、互斥量

为了改进上述生产者,消费者模型,引入信号量和互斥量。

其实都是由原子操作构成的,简单得很。

  • down:可以用sleep来表示,如果此刻信号量大于0,则将其值-1,如果=0,则进入进程进行睡眠;
  • up:可以用wakeup来表示,up操作会使信号量+1,如果因为多个进程睡眠而无法完成先去的down操作,系统会选择一个进程唤醒并完成down操作,但信号量值仍是0,取而代之的是睡眠进程数量减1;

来自 <https://zhuanlan.zhihu.com/p/109971253>

总之,信号量和互斥量都是原子化的测试+赋值+睡眠+唤醒。它们的实现就如同所有的原子操作一样。

信号量原理:

检查数值、修改变量值以及可能发生的休眠或者唤起操作是原子性的,通常将up和down作为系统调用来实现;

当执行以下操作时,操作系统暂时屏蔽全部中断:检查信号量、更新、可能发生的休眠或者唤醒,这些操作需要很少的指令,因此中断不会造成影响;

如果是多核CPU,信号量同时会被保护起来,通过使用TSL或者XCHG指令确保同一个时刻只有一个CPU对信号量进行操作。

来自 <https://zhuanlan.zhihu.com/p/109971253>

 

信号量实现的生产者消费者模型

 

 1#define N 100                   /* 缓冲区中的槽数目 */

2typedef int semaphore;          /* 信号量是一种特殊的整型数据 */

3semaphore mutex = 1;            /* 控制对临界区的访问 */

4semaphore empty = N;            /* 计数缓冲区的空槽数目 */

5semaphore full = 0;             /* 计数缓冲区的满槽数目 */

6

7void producer(void){

8

9  int item;  

10

11  while(TRUE){                  /* TRUE是常量1 */

12    item = producer_item();     /* 产生放在缓冲区中的一些数据 */

13    down(&empty);               /* 将空槽数目-1  */

14    down(&mutex);               /* 进入临界区  */

15    insert_item(item);          /* 将新数据放入缓冲区中 */

16    up(&mutex);                 /* 离开临界区 */

17    up(&full);                  /* 将满槽数目+1 */

18  }

19}

20

21void consumer(void){

22

23  int item;

24

25  while(TRUE){                  /* 无限循环 */

26    down(&full);                /* 将满槽数目-1 */

27    down(&mutex);               /* 进入临界区 */

28    item = remove_item();       /* 从缓冲区取出数据项 */

29    up(&mutex);                 /* 离开临界区 */

30    up(&empty);                 /* 将空槽数目+1 */

31    consume_item(item);         /* 处理数据项 */

32  }

33}

此时又有一个非常经典的问题——为什么有了信号量,还需要互斥量?它们的本质有什么区别?

有了上一节提到的理解,这里就非常简单了,你只需要考虑一下,若没有mutex互斥量,会如何?会导致down操作结束后,会被中断!此时又会出现类似上面的测试之后被中断的问题!

所以啊,为什么要有信号量和互斥量呢,其实信号量是对资源的计数,而互斥量则是资源为1的信号量,用来实现互斥区,即——修改资源的时候,我需要mutex来保证只有一个线程可以进去!

自此,核心概念结束,在我的理解中,管程,PV操作等,都是基于这些核心概念搞定的。

管程

只需要看一下概念就可以理解了,这个东西是一种封装的线程安全区域,是对原始机制的一种抽象。

管程是程序、变量和数据结构等组成的集合,构成一个特殊模块或者软件包,进程可以调用管程中的程序,但是不能在管程之外声明的过程中直接访问管程内的数据结构

来自 <https://zhuanlan.zhihu.com/p/109971253>

 

看吧,核心就是多了程序,变量,数据结构,以及性质,只有一个线程在内。

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值