对于多进程,多进程可以并发或并行(多核系统)执行。这会带来的问题是,一个进程在另一个进程被执行时,可能只完成了部分工作,如可抢占的调度,或遇到cpu中断。因此,一个进程在它的指令流上的任何一个点都可能会中断,处理核被用于执行其他程序的指令。
考虑生产者-消费者模式中,使用一个共享变量cnt记录缓冲区中数据的个数。在生产者中,每向缓冲区增加一个消息,cnt++,在消费者中,每从缓冲区中读取一个数据,cnt--。cnt在生产者和消费者各自的程序中都正确运行,但是如果生产者和消费者进程并发进行,那么cnt的正确性就不能保证。因为不能保证并发中,生产者和消费者进程中cnt++和cnt--的执行顺序。因为不能保证修改cnt的低级语句(机器指令)的执行顺序。消费者程序中cnt--可能在生产者向缓冲区中增加一个消息后才被执行,从而引起了错误的cnt状态。
竞争条件:多个进程并发访问和操作统一数据并且执行结果与特定访问顺序有关,称为竞争条件。保证共享变量准确性的条件是,每次只有一个进程能操作该变量,因此可以操作该变量所有进程需要进行同步。分为进程同步和进程协调。
临界区:每个进程都有一段代码,称为该进程的临界区,进程在临界区内执行时,可能会修改公共变量,更新表,读写文件等。
临界区问题:设计一个协议协作进程。当一个进程在临界区内执行时,其他进程不允许在其各自的临界区内同时执行。因此该协议应保证一个进程在进入其临界区前,进行请求许可。在进程的代码中,实现这一请求的代码段被称为进入区,在临界区之后可以有退出区,而退出区之后的为剩余区。
临界区问题解决方案应满足:
互斥:当一个进程pi在其临界区内运行时,其他任何进程都不能在其各自的临界区内运行。这样保证了共享变量更新的准确性。
进步:如果当前没有进程在其临界区内执行,并且有进程请求进入临界区执行,那么只能选择那些不在剩余区内执行的进程可以参与选择,以确定下一个进入临界区的进程,并且这种选择不能无限推迟。即当前进程执行到临界区之后,如退出区或者剩余区,那么调度程序在所有请求进入临界区的进程中选择一个,因为只有进入区可以发送请求,而进入区一定在临界区之前,所以选择请求进入临界区的进程即保证了西祠进入临界区的进程不在剩余区内执行。
有限等待:从一个进程请求进入临界区到允许进程进入临界区的等待时间必须是有限的。
操作系统的临界区问题:因为操作系统可能有多个内核处于活动状态,因此操作系统的实现代码(内核代码)也可能出现竞争条件。对于抢占式内核和非抢占式内核,分别有不同的解决方法。
对于抢占式内核:需要设计,以确保内核不会产生竞争条件。因为抢占式内核更快,所以即便可能存在竞争条件,也要运行抢占,而设计方法以解决竞争条件。
对于非抢占式内核:非抢占式内核不会出现竞争条件,因为任何一个时间点,只有一个内核处于内核模式。其他进程不能在该内核进程执行时,抢占cpu执行。
Peterson解决方案是解决临界区问题的基于软件的解决方法,适用于两个进程交错执行临界区与剩余区。
Peterson方法要求两个进程共享两个数据:
变量 int turn 代表可以进入临界区的进程,每个时刻只有一个进程可以进入临界区。
变量 bool flag[2] 代表准备进入临界区的进程。因为只有两个进程,所以每个进程有一个bool型变量表示是否准备进入临界区。
使用Peterson解决方案进程的进入区:
进程Pi将flag[i]设为true,代表Pi准备进入临界区,同时将turn设为另一个进程j,代表如果同时请求进入临界区,那么让j进入临界区。同理,对于Pj,将flag[j]设为true,而将turn设为i。
这样,如果两个进程同时申请进入临界区,turn会被设为j,i的其中一个,因为不管turn被先设置为i还是j,一定会被重写为另一个值。变量turn的值决定了哪个进程进入临界区。
在Pi和Pj的临界区之后,剩余区之前,将flag[i]或flag[j]设为false,代表进程P已经执行完临界区,不再准备进入。
同步也可以通过硬件来解决,如在修改共享变量时,禁止中断出现。从而保证当前进程的指令流正确执行,并且不会被抢占。因为不允许被抢占,所以通常被非抢占式内核采用。
然而在多处理器系统中,这种方法并不适用。因为多处理器的中断禁止会很耗时间,因为消息需要传递到所有处理器。如果系统时钟是通过中断来更新的,那么问题更严重。
因此现代计算机系统提供了其他方法:通过原子检查和修改字的内容,或者交换两个字(不可中断)。指令test_and_set()和compare_and_swap()。原子指令如果同时发生在两个处理器上,那么它们的执行顺序是任意的。
互斥锁:Mutex Lock,用互斥锁来防止竞争条件,一个进程在进入临界区内时应得到锁,在退出临界区时应释放锁。acquire()用于得到锁,release()用于释放锁。每个互斥锁有一个布尔型变量aviailable,用于表示锁是否可用。如果锁可用,那么调用acquire()成功,并且将available取反,表示锁不可用。如果在锁不可用的时候,某个进程申请得到锁,那么它将被阻塞,直到锁被释放。
实现:因为互斥锁的acquire()和release()必须原子的执行,因此需要通过硬件机制来实现。
acquire()
{
while(!available)
;
available=false;
}
release()
{
available=true;
}
缺点:互斥锁的主要缺点是忙等待,当一个进程在临界区中时,其他进程必须循环调用acquire()。因此互斥锁也被称为自旋锁,因为进程在不断的旋转,直到锁可用。忙等待会浪费CPU时间,而这个时间原本可用于执行其他进程。
优点:由于进程在等待时,没有上下文切换,而上下文切换可能会浪费大量时间。因此当使用锁的时间较短时,互斥锁的作用较大。
信号量S:是一个整形变量,比互斥锁更具有鲁棒性。信号量除了初始化外只能通过两个标准原子操作:wait()和signal()来访问。wait()减少信号量的值,signal增加信号量的值。在这两个操作中,信号量整数值的修改应不可分割的执行,当一个进程修改信号量值时,没有其他进程能够同时修改同一信号量的值。另外,wait中的测试(S<=0)和修改S--也不能被中断。
wait(S)
{
while(S<=0)
;//忙等待
S--;
}
signal(S)
{
S++;
}
信号量分为计数信号量和二进制信号量。计数信号量的值不受限制,而二进制信号量的值只能是0或1。二进制信号量的作用类似于互斥锁。
计数信号量的作用:计数信号量可用于具有多个实例的某种资源。信号量的初值为可用资源数,当进程需要资源时,调用wait()并减少可用资源数,当进程释放资源时,调用signal(),增加可用资源。当信号量为0时,申请资源(即调用wait())的进程会被阻塞。
并发进程的执行顺序:信号量可用于控制并发进程的先后执行顺序,考虑当前有两个进程p1,p2并发执行,要求p2在p1执行之后再执行。那么可以定义一个信号量s,s的初值设为0,在p1程序中调用signal(s),在p2进程中调用wait(s)。那么只有当p1进程执行了,调用signal()函数增加s的计数后,s才为1,此时调用wait()的p2才不会被阻塞。因此p2会一直阻塞,直到p1执行,并增加信号量s的计数。
如上文的讨论,当进程调用wait()而信号量为0时,进程会忙等待,此时进程仍占用cpu,会浪费cpu时间。修改wait()和signal()的操作,当一个进程调用wait()而信号量为0时,它必须等待,即信号量为0时进程阻塞自己。阻塞操作将该进程放到一个与信号等待相关的队列中,而此时系统将该进程的状态切换为等待状态,从而可以经系统进程调度,从就绪进程队列中选择一个进程进行执行。这样cpu时间不会被浪费在忙等待上。
非忙等待(阻塞进程)信号量的实现:通过在信号量上附加一个进程链表,作为该信号量等待队列。所有等待该信号量的进程都被加到该队列中。等待信号量而被阻塞的进程,在其他进程调用signal()后应该被重新执行,通过wakeup()来实现,wakeup()将进程状态从等待转换为就绪,从而被系统调度放入就绪队列中。
忙等待移除效果:通过wait()和signal()并没有完全移除忙等待,而是将忙等待从进入区移动到了临界区,而且是在wait()和signal之间的临界区,通常这些区比较短,因而忙等待时间较小。但是如果临界区很长,那么即便采用了信号等待队列的方法,也不能有效减少忙等待时间。
阻塞进程信号量S实现:
struct Signal
{
int val;//信号量
ListNode* list;//链表
}
wait()实现:
wait(Signal* S)
{
S->val--;//减少信号量
if(S->val<0)
{
S->list.add(this process);//当前进程放入信号量的等待队列
block();//阻塞该进程
}
}
signal()实现:
signal(Singnal* S)
{
S->val++;
if(S->val<=0)
//信号量的定义 当信号量为负时,表示当前有进程在等待信号,且其绝对值为等待的进程数
//因此=0时,说明++之前为-1,也是有进程等待。而>0说明当前没有进程等待 也就不需要唤醒和选择
//进程从信号量的等待队列中移除。
{
S->list.remove(process P);//将进程P从S的等待队列移除
wakeup(P);//唤醒P P由等待状态->就绪状态
}
}
原子执行:同以时刻只存在一个原子,对信号量而言,原子操作意为,没有两个进程可以同时操作signal()和wait(),因为只要不是同时signal()和wait(),那么信号量的值就不会产生歧义。对于单处理器系统,可以通过禁止中断来实现原子操作,对于多处理器系统,要禁止所有处理器的中断。而对于不能完全依靠禁止中断来实现原子操作的系统,如SMP系统,应使用其他加锁技术,如使用test_and_set()以及compare_and_swap()的自旋锁。
死锁:两个或多个进程无限等待一个事件的发生,而该事件只能由等待这个事件发生的进程所产生。如事件为signal()。
如p1和p2共享信号量S1和S2,S1和S2初始值均为1,p1先调用wait(S1),再调用wait(S2),p2先调用wait(S2),再调用wait(S1)。那么当p1调用wait(S1)后,S1为0,当p2调用wait(S2)后,S2为0,此时p1调用wait(S2)将必须等待,直到进程p2执行了signal(S2)释放资源并增加S2的计数,同理p1执行wait(S1),此时它必须等待,直到P1使用signal(S1)释放资源并增加S1的计数。因为S1和S2各持有对方需要的资源而又没有释放,因此S1和S2都必须无限等待。即产生了死锁。
无限阻塞或饥饿:进程无限等待信号量,如果信号量的等待队列按照LIFO的方式来增加和删除进程,那么就可能出现这个问题。
如果一个较高优先级需要访问内核数据,而内核数据正在被一个较低优先级的进程访问,那么会出现调度问题。因为内核访问通常是通过锁来实现,因此此时优先级失效,较高优先级的进程必须等待较低优先级的进程完成执行。而如果允许较高优先级抢占,那么情况会更加复杂。
优先级反转:考虑三个进程p1,p2,p3优先级递增,当前p1正在访问资源R,而p3想要访问该资源R必须等待p1完成并释放锁,假设此时p2也要访问资源R,并且抢占了p1,先执行访问。那么具有较低优先级的p2影响了具有较高优先级的p3的等待时间,称为优先级反转。
优先级反转只会出现在具有两个以上优先级的系统中,因此解决方法之一是,限制优先级的数目小于等于2。然而这种方法对于绝大多数系统时不可行的。解决的方法是优先级继承。
优先级继承:正在访问资源的进程的优先级继承想要访问资源的更高优先级进程的优先级。即上面例子中,p3想要访问的资源R正在由p1访问,开始时p1的优先级与预设相同,当p3发出访问请求并被阻塞后,p1的优先级继承了p3的优先级,因此它的优先级高于p2,此时若p2再发出访问资源的请求,由于p1的优先级等于p3,因此p2的优先级低于p1的优先级,它也需要进入等待队列,同时又因为队列中p3的优先级高于p2的优先级,所以队列中p3位于队列首,其等待时间不会被具有较低优先级的p2所影响。
当p1执行完后,其继承的优先级将会复原。
生产者-消费者问题中的有界缓冲问题,采用信号量来解决。
struct Signal
{
int n;
int mutex=1;
int empty=n;
int full=0;
}
n为缓冲区大小,每个缓冲区可以存放一个数据项。信号量mutex用于互斥,初始化为1。信号量empty和full用于表示空的和满的缓冲区数量。每当生产者生产一个数据项,empty-1,full+1。注意生产者-消费者模型的对称性:生产者为消费者产生满的缓冲区,而消费者为生产者产生空的缓冲区。
生产者进程:
while(1)
{
/*生产一项数据*/
wait(empty);//若empty大于0 empty-1 否则阻塞
wait(mutex);//申请互斥锁
/*将生产的数据项添加到缓冲区中*/
signal(mutex);//释放互斥锁
signal(full);//full++
}
消费者进程:
while(1)
{
wait(full);//full大于0,full-- 否则阻塞
wait(mutex);
/*code of 从缓冲区内读取一个数据项*/
signal(mutex);
signal(empty);//empty++
}
读者作者问题:假设一个数据库为多个进程并发共享。有的进程需要读数据库,称为读者,有的进程需要写数据库,称为作家。如果多个进程只读数据库,那么不会产生问题,但是如果多个进程读写数据库,那么就会产生混乱。读者-作者问题包括多个变种,都与优先级有关。包括:
第一读者作者问题:读者不应等待,除非作者已经获取权限在使用数据。简言之,如果数据空闲,且读者和作者并发,那么优先执行读者进程。
第二读者-作者问题:作者不应等待,如果作者和读者并发,那么优先执行作者进程。
以上两种情况都可能产生饥饿,对于第一读者-作者问题,作者可能产生饥饿,因为可能一直有读者进程申请访问数据;对于第二读者-作者问题,读者可能产生饥饿,因为可能一直有作者进程请求访问数据。
2.2.1 第一读者-作者问题实现:
读写锁:在获取读写锁时,应当指定锁的类型为读还是写。当一个进程只希望共享数据时,可以申请读模式的读写锁,而当一个进程希望写数据时,应申请写模式的读写锁。多个进程并发获取读模式的读写锁,但是只有一个进程可以获取写模式的读写锁,作者进程需要互斥锁。
信号量实现:
semaphore rw_mutex=1;//写模式的读写锁
semaphore mutex=1;//读模式的读写锁 因为一次只有一个读者读数据
int read_count=0;//多少进程在读对象
作者进程实现:
while(1)
{
wait(rw_mutex);
/*写数据*/
signal(rw_mutex);
}
读者进程实现:
while(1)
{
wait(mutex);//申请更新读者数目
read_count++;//自身为读者 所以读者数目+1
if(read_count==1)//若当前为1 则更新前为0 说明没有其他读进程在等 当前进程获得读权限
wait(rw_mutex);//若有作家在写 则需要等
signal(mutex);// 变量read_count更新完 释放互斥锁
/*code of 执行读操作*/
wait(mutex);//执行完操作后 请求更新read_count
read_count--;//更新read_count 自身退出 所以读者数目-1
if(read_count==0)
signal(rw_mutex);
signal(mutex);
}
哲学家就餐问题:假设有n个哲学家在圆桌上,每个哲学家面前都有一根筷子,当哲学家饥饿时,他只可以从相邻的哲学家手中抢走筷子,若一个饥饿的哲学家拥有两根筷子,他就可以就餐,在他就餐后,他会把抢来的筷子还给原来的人。哲学家就餐问题是一个经典的同步问题,因为它代表了一种场景:如何在多个进程之间分配资源且不会产生死锁和饥饿。
解决方法是,用一个信号量代表一根筷子,这样能保证邻居不能同时进食。但是可能导致死锁,如每个哲学家都先拿起左手边的筷子,再都试图拿起右手边的筷子。补救措施有:
1.最多允许n-1个哲学家同时就餐,有n根筷子
2.只有当一个哲学家左右两根筷子都能用时,他才可以拿起它们
3.使用非对称解决方案,单号的哲学家先拿左手边,再拿右手边;双号的哲学家先拿右手边,再拿左手边。
管程(Monitor Type)是一种抽象数据类型(Abstract Data Type,ADT),提供一组由程序员定义的,在管程内互斥的操作。管程结构保证,每次只有一个进程在管程内处于活动状态。因此不需要明确编写同步约束,附加的同步约束可以由条件(condition)结构来实现。
condition结构:条件变量只有wait()和signal()可以调用,调用wait()意味着当前进程将会挂起,直到wait()的资源可用。signal()恢复挂起的进程,如果没有挂起进程的话,signal()不起作用,这与基本的信号量操作signal()不同,因为基本的signal()操作不会检查,只会修改信号量。因此使用条件变量的wait()时,不需要程序员考虑当前进程是否占用资源。
管程机制:假设当前条件变量x.signal()被一个进程p调用时,x上已经有挂起的进程q,即进程q在等待信号量x可用,而此时x.signal()被进程p调用。如果q允许重执行,那么p必须等待。此时可能有两种情况:
唤醒并等待:p等待直到q离开管程,或者等待另一个条件。
唤醒并继续:进程q等待,直到p离开管程或者等待另一个条件。
管程内进程的重启:如果多个进程都在条件变量x的挂起队列上,那么可以采用FCFS的方法确定当x可用时,下个执行的进程。条件等待:每个进程与一个变量c关联,c称为优先值。当执行x.signal()时,具有最小优先值的进程被选中执行。
原子变量:对原子变量执行的操作不会被中断,如果对数据x执行原子操作,那么保证同一时间内不会有多个进程同时访问x。
原子整数:Linux内核最简单的同步即为原子整数,原子整数在进行运算时不会被中断。在整数变量需要更新时,如计数器的值需要更新时,原子变量非常有效,同时,原子机制没有加锁的开销。
互斥锁:Linux提供互斥锁,用于保护内核中的临界区。申请锁mutex_lock(),释放锁mutex_unlock()。同wait()及signal()的操作。当一个任务进入临界区之前(为保证同时不超过一个进程处于临界区,需要在临界区之前),调用mutex_lock(),如果信号量不可用,该进程被阻塞等待。当锁的所有者使用mutex_unlock()时,等待该信号量的进程被唤醒。
Pthreads同步:Pthrad API只能被用户级别的程序调用,而不能用于任何内核。这个API为线程同步提供互斥锁,条件变量与读写锁。
互斥锁:互斥锁用于包含代码的临界区。线程在进入临界区之前获得锁,在离开临界区之后释放锁。
pthread_mutex_t mutex;//声明一个互斥锁
pthread_mutex_init(&mutex,nullptr);//初始化互斥锁,指针为指定互斥锁的参数
//某个进程的代码段
pthread_mutex_lock(&mutex);//申请锁 如果锁不可用将被阻塞
//临界区
{
//code
}
pthread_mutex_unlock(&mutex);//离开临界区 释放锁
信号量分为:
无名信号量:只能被同一个进程的线程所使用。
命名信号量:具有实际名称,可以被多个不相关的进程共享。
sem_t sem;//声明一个无名信号量
sem_init(&sem,0,1);//信号量指针 共享级别 初始值
//某程序的代码段
sem_wait(&sem);//进入临界区之前 获取锁
{
//临界区
}
sem_post(&sem);//离开临界区 释放锁
OpenMP实现同步:#pragma omp critical 指定后面的代码段为临界区。如果某个变量被多个进程共享,那么它可能带来竞争条件,通过openMP的上述语句,编译器会将其后的代码段{}提供互斥锁,保证每次只有一个线程在该区内可执行。
搜索树