多个进程、线程对共享资源的操作构成了并发问题。并发问题使程序不能得到和单进程执行一样的预期的结果。
与并发相关的术语:
互斥的实现有三种,
如果都放在用户程序层面来实现的话用户程序就会变得特别复杂。所以一般都是在操作系统层面实现,有些时候也可以在用户程序中使用机器指令(硬件层面)来实现。比如java的AQS锁。
在单核处理器中,并发进程不能重叠,所以可以使用中断禁用的方式在进程进入临界区之前禁用中断,这样该进程就不会被调度出去,临界区的访问就变成了原子性的。但是缺点也很明显,不支持多处理器,现在基本没法单独使用。
在硬件级别上,一个对存储器单元的访问排斥其他对该单元的访问,因此设计了一些处理器指令,用于保证两个动作的原子性。如,在一个取指令周期中对一个存储器读和写或者读和测试。这里介绍两个。
bool compareAndSwap(int *word, int testval, int newval)
{
int oldval;
oldval=*word;
if (oldval==testval){
*word=newval;
return oldval
}
}
在进入临界区之前执行这个,假如成功进入临界区后再进行置位
这个用的更少一些
void exchange (int *register, int *memory)
{
int temp;
temp =*memory;
*memory = *register;
*register =temp;
}
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));
}
操作系统层面的互斥一般常见的有三种
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插入就绪队列*/
}
二元信号量相对于普通信号量来说值只能是0/1
阻塞在信号量上的进程会被放在特定的队列中,进程应该按照什么顺序从队列中移出,如果是FIFO就是强信号量,否则是弱信号量。
我原来以为像这种上层的互斥实现都必须依赖硬件层面的原子操作来实现,实际上不是的,互斥仅仅通过用户软件也是可以实现的。比如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;
}
管程是一种程序设计语言结构,他提供的功能与信号量相同,但是更容易控制,因为他代表了一个操作的顺序集合。在管程的发展史上,先后出现过三种不同的管程模型,分别是:Hasen 模型、Hoare 模型和 MESA模型。其中,现在广泛应用的是 MESA 模型,并且 Java 管程的实现参考的也是 MESA 模型。
这里也主要介绍MESA模型。
管程是这样定义的:
管程是由一个或者多个过程,一个初始化序列和局部数据组成的软件模块,其主要特点有:
条件变量
条件变量是管程内的等待和并发机制
这些条件变量包含在管程中,只有通过管程才能访问。
对条件变量可以有三种操作
1.wait(x)操作:
将自己阻塞在条件x等待队列中,随后醒来的时候需要重新进入管程,接着执行wait()后面的代码
即允许另外一个线程进入管程
2.notify()操作:
将等待队列中的一个线程唤醒
如果等待队列为空,这就相当于是一个空操作,这个是和计数信号量的一个最大的区别
3.notifyAll()操作:
将阻塞在这个条件上的线程全部唤醒
linux提供了很多保证对变量的原子操作的方式。这些操作可以用来避免简单的竞争条件,比如原子加一减一等,cas等
在同一时刻,只能有一个线程能够获得自旋锁,其他线程都要自旋等待。因为线程使用的是忙等待的方式,适合等待时间比较短的情况,如果在自旋的时候就拿到了锁,那么就不用进行上下文切换了,比较高效。
linux在用户级提供了和UNIX SVR4(就是前面介绍的,又增加了一些拓展)
linux内核内部还提供了一些信号量,不能直接被用户调用,下面就是这些信号量
linux提供了二元信号量和计数信号量,以及读写信号量。
二元信号量在linux中也被称为MUTEX(互斥信号量),
为了禁止指令重排序,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()操作 |
其实就是通过这里来对指令重排序来做干预的,也就是说内存屏障和指令重排序是一回事儿。
而且隐含的屏障其实又很多,几乎所有加锁的地方都隐含了屏障。
这里对内存屏障有比较详细的介绍
这里的信号不是指上面的信号量的意思,是指操作系统中常见的一个进程给另一个进程发信号的情况。
比如linux中可能最常用的还是kill -9 pid
这种。
信号是用于向一个进程通知发生异步事件的机制。信号类似于硬件中断,但没有优先级,即内
核公平地对待所有信号。对于同时发生的信号,一次只给进程一个信号,而没有特定的次序。
进程间可以互相发送信号,内核也可在内部发送信号。
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/