04.并发和互斥.md

文章目录

    • 4.1 什么是并发
    • 4.2 互斥的实现
    • 4.3 硬件互斥
      • 4.3.1 中断禁用
      • 4.3.2 专用机器指令
        • 4.3.2.1 比较交换指令
        • 4.3.2.2 exchange指令
        • 4.3.3 使用机器指令完成互斥
    • 4.4 操作系统层面的软件互斥
      • 4.4.1 信号量
      • 4.4.2 二元信号量
      • 4.4.3 信号量的强弱
      • 4.4.4 信号量的实现
      • 4.4.4 管程
        • 4.4.4.1 管程的定义
        • 4.4.4.2 管程的组成
    • 4.5 linux 内核并发机制
      • 4.5.1 原子操作
      • 4.5.2 自旋锁
      • 4.5.3 信号量
      • 4.5.4 屏障
      • 4.5.5 信号处理
        • 4.5.5.1 信号的定义
        • 4.5.5.2 信号的传递和响应机制
        • 4.5.5.3 信号的产生

4.1 什么是并发

多个进程、线程对共享资源的操作构成了并发问题。并发问题使程序不能得到和单进程执行一样的预期的结果。
与并发相关的术语:

  1. 原子操作:一个函数或者动作由一个或者多个指令序列实现,对外是不可见的。也就是说没有其他进程可以看到中间状态,或者能够中断此操作。要么都执行要不都不执行。原子性保证了进程的隔离。
  2. 临界区:一段代码,在这段代码中将访问共享资源,每次只能有一个进程进入临界区
  3. 死锁:两个或以上的进程因为等待彼此完成一些任务陷入停滞
  4. 活锁:两个或以上的进程为影响其他进程而持续改变自己的状态但是不做有用功的工作情形
  5. 互斥:当一个进程在临界区工作,其他进程不能进入临界区的情形
  6. 竞争条件:多个线程或进程在读写一个共享数据时,结果依赖于他们执行的相对时间的情形
  7. 饥饿:一个进程尽管可以运行,但是调度被无限延迟的情况

4.2 互斥的实现

互斥的实现有三种,

  1. 用户程序层面,
  2. 硬件层面,
  3. 操作系统层面

  如果都放在用户程序层面来实现的话用户程序就会变得特别复杂。所以一般都是在操作系统层面实现,有些时候也可以在用户程序中使用机器指令(硬件层面)来实现。比如java的AQS锁。

4.3 硬件互斥

4.3.1 中断禁用

  在单核处理器中,并发进程不能重叠,所以可以使用中断禁用的方式在进程进入临界区之前禁用中断,这样该进程就不会被调度出去,临界区的访问就变成了原子性的。但是缺点也很明显,不支持多处理器,现在基本没法单独使用。

4.3.2 专用机器指令

  在硬件级别上,一个对存储器单元的访问排斥其他对该单元的访问,因此设计了一些处理器指令,用于保证两个动作的原子性。如,在一个取指令周期中对一个存储器读和写或者读和测试。这里介绍两个。

  1. 比较交换指令
  2. exchange指令
4.3.2.1 比较交换指令
bool compareAndSwap(int *word, int testval, int newval)
{
  int oldval;
  oldval=*word;
  if (oldval==testval){
  	*word=newval;
  	return oldval
  }
}

在进入临界区之前执行这个,假如成功进入临界区后再进行置位

4.3.2.2 exchange指令

这个用的更少一些

void exchange (int *register, int *memory)
{
int temp;
temp =*memory;
*memory = *register;
*register =temp;
}
4.3.3 使用机器指令完成互斥

1.使用比较和交换指令

/* program mutualexclusion */
const int n-*进程个数*1
int bolt;
bolt= 0;
void P(int i){
	while (true) {
		while (compare and swap (bolt, 0, 1) == 1)
					/*不做任何事*/
	    /*临界区/
	    bolt=0;
	   /*其余部分*/
	   }
}

void main()
{
	bolt=0;
	parbegin (P(1), P(2), ... P(n));
}

2.交换指令

/* program mutualexclusion */
int const n=*进程个数**;
int bolt;
void P(int i)
{
	while (true) {
	int keyi = 1;
	do exchange (keyi, bolt)
	while (keyi !=0);
	/*临界区*/
	bolt=0;
	/*其余部分*/
	}
}
void main()
{
	bolt=0;
	parbegin (P(1), P(2), ... P(n));
}

4.4 操作系统层面的软件互斥

操作系统层面的互斥一般常见的有三种

  1. 信号量
  2. 二元信号量
  3. 管程

4.4.1 信号量

  1. 信号量可以被初始化为一个非负数,若为正数,则等于发出semWait后可以继续执行的进程数
  2. semWait 操作使信号量减1,若值编程负数,则阻塞执行semWait的进程,否则调用进程继续执行
  3. semSignal 操作使信号加1,若值小于等于零,则被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) { 
	/*把进程p从队列中移除*/;
 	/*把进程p插入就绪队列*/ 
}

4.4.2 二元信号量

二元信号量相对于普通信号量来说值只能是0/1

  1. 一元信号量可以初始化为0或者1
  2. semWaitB将检查信号量的值,若为0,则进程执行semWaitB就会受阻。若值为1,则将值改为0,当前进程继续执行
  3. semSignalB操作检查是否有任何进程在该信号上面受阻。若有进程受阻,则唤醒所有的受阻进程,若没有进程受阻,则置为1

4.4.3 信号量的强弱

阻塞在信号量上的进程会被放在特定的队列中,进程应该按照什么顺序从队列中移出,如果是FIFO就是强信号量,否则是弱信号量。

4.4.4 信号量的实现

  我原来以为像这种上层的互斥实现都必须依赖硬件层面的原子操作来实现,实际上不是的,互斥仅仅通过用户软件也是可以实现的。比如Dekker算法或者Peterson算法,当然这些也会已有些处理开销。
当然很多时候还是会选择操作系统提供的互斥,因为开销更小,比如基于compareAndSwap的实现信号量。
cas返回的是旧的内存值。

seaWait(s)
{
	while (compare and swap(s.flag, 0 , 1)==1)
	/*不iT任何事*/;
	s.count--;
	if (s.count < 0) {
	/*该进程进入s.queue队列*/;
	/*阻塞该进裎(还将s.flag设置为0)*/;
	s.flag = 0;

}

semSignal (s)
{
	while (compare and swap(s.flag,0,1)==1)
	/*不做任何亊*/;
	s.count++;
	if(s.count<=0){
		/*从s.queue 队列中移出进程p*/
		/*进程P进入就绪队列*/
	}
	i.flag=0;
}

4.4.4 管程

  管程是一种程序设计语言结构,他提供的功能与信号量相同,但是更容易控制,因为他代表了一个操作的顺序集合。在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
这里也主要介绍MESA模型。

4.4.4.1 管程的定义

管程是这样定义的:
管程是由一个或者多个过程,一个初始化序列和局部数据组成的软件模块,其主要特点有:

  1. 局部数据变量只能被管程的过程访问,任何外部过程都不能访问
  2. 一个进程通过调用管程的一个过程进入管程
  3. 在任何时候,只能有一个进程在管程中执行,调用管程的任何其他进程都被阻塞,以等待管程可用。
  4. 正在管程中的线程可临时放弃管程的互斥访问,等待事件出现时恢复(这是最大的特点)
4.4.4.2 管程的组成
  1. 一个锁:控制管程代码的互斥访问
  2. 入口队列:每次只能有一个线程进入
  3. 0或多个条件变量:管理共享数据的并发访问

条件变量
条件变量是管程内的等待和并发机制
这些条件变量包含在管程中,只有通过管程才能访问。

  • 进入管程的线程因资源被占用而进入等待状态
  • 每个条件变量表示一种等待原因,对应一个等待队列

对条件变量可以有三种操作

1.wait(x)操作:
将自己阻塞在条件x等待队列中,随后醒来的时候需要重新进入管程,接着执行wait()后面的代码
即允许另外一个线程进入管程

2.notify()操作:
将等待队列中的一个线程唤醒
如果等待队列为空,这就相当于是一个空操作,这个是和计数信号量的一个最大的区别

3.notifyAll()操作:
将阻塞在这个条件上的线程全部唤醒

04.并发和互斥.md_第1张图片

4.5 linux 内核并发机制

4.5.1 原子操作

linux提供了很多保证对变量的原子操作的方式。这些操作可以用来避免简单的竞争条件,比如原子加一减一等,cas等

4.5.2 自旋锁

在同一时刻,只能有一个线程能够获得自旋锁,其他线程都要自旋等待。因为线程使用的是忙等待的方式,适合等待时间比较短的情况,如果在自旋的时候就拿到了锁,那么就不用进行上下文切换了,比较高效。

4.5.3 信号量

linux在用户级提供了和UNIX SVR4(就是前面介绍的,又增加了一些拓展)
linux内核内部还提供了一些信号量,不能直接被用户调用,下面就是这些信号量
linux提供了二元信号量和计数信号量,以及读写信号量。
二元信号量在linux中也被称为MUTEX(互斥信号量),

4.5.4 屏障

为了禁止指令重排序,linux提供了内存屏障的设置
内存屏障主要有:读屏障、写屏障、通用屏障、优化屏障几种。

操作 含义
rmb() 阻止跨过屏障对装载操作重排序,保证rmb()之前的代码没有任何读操作会穿越屏障,rmb()之后的任何读操作也不会穿越屏障
wmb() 阻止跨过屏障对存储操作重排序,保证wmb()之前的任何写操作不会穿越屏障,wmb()之后的任何写操作也不会穿越屏障
mb() 阻止跨过屏障对装载存储操作重排序,mb()=rmb()+wmb()
barrier() 阻止编译器跨过屏障对装载存储操作重排序,在知道处理器不会对程序重排序的时候有用
smp_rmb() 在SMIP上提供rmb(1)操作,在UP上提供barrier ()操作
smp_wmb() 在SMP上提供wmb(1)操作,在UP上提供barrier()操作
smp_mb 在SMP上提供mb()操作,在UP上提供barrier()操作

其实就是通过这里来对指令重排序来做干预的,也就是说内存屏障和指令重排序是一回事儿。
而且隐含的屏障其实又很多,几乎所有加锁的地方都隐含了屏障。
这里对内存屏障有比较详细的介绍

4.5.5 信号处理

这里的信号不是指上面的信号量的意思,是指操作系统中常见的一个进程给另一个进程发信号的情况。
比如linux中可能最常用的还是kill -9 pid这种。

4.5.5.1 信号的定义

  信号是用于向一个进程通知发生异步事件的机制。信号类似于硬件中断,但没有优先级,即内
核公平地对待所有信号。对于同时发生的信号,一次只给进程一个信号,而没有特定的次序。
进程间可以互相发送信号,内核也可在内部发送信号。

4.5.5.2 信号的传递和响应机制
  1. 传递机制:信号的传递是通过修改信号要发送到的进程所对应的进程表中的一个域来完成的。由于每个信号只保存为一位,因此不能对给定类型的信号进行排队。
  2. 响应机制:因为要干预原来的进程执行的流程,所以只有在进程被唤醒继续运行时,或进程准备从系统调用中返回时,才处理信号。进程可通过执行某些默认行为(如终止进程)、执行一个信号处理函数或忽略该信号来对信号做出响应。参考这里有更详细的介绍。
4.5.5.3 信号的产生

1 硬件方式
用户输入:比如在终端上按下组合键ctrl+C,产生SIGINT信号;
硬件异常:CPU检测到内存非法访问等异常,通知内核生成相应信号,并发送给发生事件的进程;
2 软件方式
通过系统调用,发送signal信号:kill(),raise(),sigqueue(),alarm(),setitimer(),abort()
kernel,使用 kill_proc_info()等
java,使用 Procees.sendSignal()等

关于信号分类等可以参考这里

可以看看这本书的信号量,互斥的介绍
https://book.douban.com/subject/24530911/

你可能感兴趣的:(重学操作系统,linux,操作系统,多线程)