第五章 同步和互斥
5.1 进程同步和进程互斥
进程同步和进程互斥是操作系统中处理多进程或多线程时非常重要的概念,它们用于控制多个进程或线程对共享资源的访问,以保证数据的一致性和系统的稳定性。
1.进程同步(Process Synchronization)
进程同步是指两个或多个进程在执行过程中,通过某些机制协调它们的工作次序,以实现正确的执行结果。在多道程序环境中,由于进程执行的并发性,可能会出现多个进程竞争同一资源的情况,如果没有适当的同步机制,可能会导致数据不一致或错误的结果。
进程同步的目的是:
防止多个进程同时访问共享资源而造成数据不一致。
控制不同进程之间的执行顺序,确保某些操作按照预定的顺序执行。
同步机制包括:
互斥锁(Mutex Locks)
信号量(Semaphores)
事件(Events)
条件变量(Condition Variables)
2.进程互斥(Process Mutual Exclusion)
进程互斥是指当多个进程访问共享资源时,通过某种机制保证每次只有一个进程能够访问该资源。互斥是进程同步的一个特例,它关注的是如何防止多个进程同时进入临界区(Critical Section),即访问共享资源的代码段。
进程互斥的目的是:
保证每次只有一个进程能够访问共享资源。
避免竞争条件(Race Conditions),即多个进程因执行顺序的不确定性而导致的错误结果。
实现互斥的常用方法有:
软件方法:Peterson算法、Dekker算法等。
硬件方法:通过特殊的硬件指令,如测试并设置(Test-and-Set)、交换(Swap)等。
3.举例说明
假设有两个进程P1和P2,它们都需要访问一个共享变量count,用于计数。
进程同步:如果需要P1在P2之前执行,或者在P2开始之前P1必须完成某些操作,那么就需要进程同步机制来协调它们的执行顺序。
进程互斥:当P1和P2都需要修改count时,为了避免它们同时修改导致count的值不正确,需要使用互斥机制确保在任一时刻只有一个进程能够修改count。
4.总结
进程同步和进程互斥是确保多进程或多线程程序正确执行的关键技术。同步机制确保了进程按照一定的顺序执行,而互斥机制则保证了在某一时刻只有一个进程能够访问共享资源。在多任务操作系统中,这两种机制都是必不可少的,以防止数据竞争和保证系统的一致性。
5.2进程互斥的软件实现方法
1.单标志法(Single Flag Method)
单标志法是最简单的互斥算法。在这种方法中,使用一个共享的变量作为标志,该标志用来指示临界区是否被占用。
算法步骤如下:
设置一个共享变量
turn
,初始化为0。进程在进入临界区之前,首先检查
turn
的值。如果
turn
的值为0,则进程可以进入临界区,并将turn
设置为1。进程执行完临界区代码后,将
turn
重新设置为0。
缺点:这种方法只允许一个进程访问临界区,而另一个进程永远无法进入。
2.双标志先检查(Double Flag with Preemption)
双标志先检查方法使用两个共享的标志变量,分别表示两个进程是否想要进入临界区。
算法步骤如下:
设置两个共享变量
flag[0]
和flag[1]
,分别初始化为FALSE。进程在尝试进入临界区之前,首先设置自己的标志为TRUE,然后检查另一个进程的标志。
如果另一个进程的标志为FALSE,则进程可以进入临界区。
进程执行完临界区代码后,将自己的标志重新设置为FALSE。
缺点:这种方法存在竞态条件,因为两个进程可能同时检查对方的标志,并且都发现对方的标志为FALSE,从而同时进入临界区。
3.双标志后检查(Double Flag with Postemption)
双标志后检查是对双标志先检查的改进,它要求进程在退出临界区时再次检查另一个进程的标志。
算法步骤如下:
设置两个共享变量
flag[0]
和flag[1]
,分别初始化为FALSE。进程在尝试进入临界区之前,首先设置自己的标志为TRUE。
进程检查另一个进程的标志,如果为FALSE,则可以进入临界区;否则,等待。
进程在临界区执行完毕后,将自己的标志设置为FALSE,并检查另一个进程的标志。如果另一个进程正在等待,则通知它。
缺点:这种方法可能导致饥饿,因为一个进程可能反复进入临界区,而另一个进程永远无法进入。
4.Peterson算法(Peterson’s Algorithm)
Peterson算法是一个经典的互斥算法,它使用两个标志变量和一个turn变量来实现互斥。
算法步骤如下:
设置两个共享变量
flag[0]
和flag[1]
,分别初始化为FALSE,表示两个进程都不想进入临界区。设置一个共享变量
turn
,用来指示是哪个进程的轮次。进程在尝试进入临界区之前,首先设置自己的标志为TRUE,并设置
turn
为另一个进程的编号。进程检查另一个进程的标志,如果为FALSE,则可以进入临界区;否则,等待直到另一个进程的标志变为FALSE。
进程在临界区执行完毕后,将自己的标志设置为FALSE。
Peterson算法解决了双标志先检查和双标志后检查的竞态条件问题,并且能够保证两个进程都不会饥饿。这是因为它确保了当一个进程想要进入临界区时,如果另一个进程已经在临界区或者也想要进入,那么它将等待,直到另一个进程完成并释放临界区。
5.3 进程互斥的硬件实现方法
1.中断屏蔽方法(Interrupt Masking)
中断屏蔽是一种硬件支持的互斥技术,它通过屏蔽中断来防止在执行临界区代码时发生上下文切换。
算法步骤如下:
当一个进程想要进入临界区时,它首先执行一个硬件指令来屏蔽所有中断。
进程执行临界区代码。
当进程离开临界区时,它执行另一个硬件指令来允许中断。
优点:这种方法简单且易于实现。
缺点:中断屏蔽可能会延迟对高优先级中断的响应,这在实时系统中可能是不可接受的。此外,如果多个处理器共享内存,这种方法可能不适用。
2.TestAndSet指令(Test-and-Set Lock)
TestAndSet是一种原子操作,通常由一个硬件指令实现,它同时测试一个标志的值并将其设置为TRUE。
算法步骤如下:
设置一个共享的标志变量lock,初始化为FALSE。
进程通过执行TestAndSet指令尝试进入临界区:
TestAndSet(&lock)会返回lock的旧值,并将lock设置为TRUE。
如果TestAndSet返回FALSE,表示临界区空闲,进程可以进入。
如果TestAndSet返回TRUE,表示临界区被占用,进程需要等待并重复执行TestAndSet。
进程执行完临界区代码后,将lock设置为FALSE以释放临界区。
优点:这种方法能够有效地实现互斥,并且适用于多处理器系统。
缺点:可能导致忙等(busy waiting),这在单处理器系统中会降低效率。
3.Swap指令(Exchange or Swap Lock)
Swap指令是一种原子操作,它交换两个变量的值。在互斥中,Swap通常用于交换一个进程的标志和共享锁变量的值。
算法步骤如下:
设置一个共享的标志变量lock,初始化为FALSE,以及一个局部变量key,初始化为TRUE。
进程通过执行Swap指令尝试进入临界区:
Swap(&lock, &key)会原子地交换lock和key的值。
如果key的值在Swap之后变为FALSE,表示进程获得锁并可以进入临界区。
如果key的值在Swap之后仍为TRUE,表示临界区被占用,进程需要等待并重复执行Swap。
进程执行完临界区代码后,将lock设置为FALSE以释放临界区。
优点:与TestAndSet类似,Swap也能有效地实现互斥,并适用于多处理器系统。
缺点:同样可能导致忙等,且在某些情况下可能比TestAndSet更复杂。
5.4互斥锁
互斥锁(Mutex)是计算机科学中用于同步的一种常见机制,它确保多个线程或进程不会同时访问共享资源。互斥锁提供了一种在多线程环境中控制对共享资源的访问的方法,从而防止竞争条件(race conditions)的发生。
以下是互斥锁的基本概念和操作:
1.基本概念
临界区:一个访问共享资源的代码段,这些资源不能被多个线程同时访问。
互斥:一种保证同一时刻只有一个线程可以进入临界区的机制。
2.互斥锁的操作
互斥锁通常具有以下操作:
初始化(Initialization):在程序开始时创建并初始化互斥锁。
锁定(Lock):当一个线程想要进入临界区时,它必须先锁定互斥锁。如果互斥锁已经被另一个线程锁定,则当前线程将被阻塞,直到互斥锁被解锁。
尝试锁定(Try Lock):尝试锁定互斥锁,如果锁已经被占用,则不会阻塞线程,而是立即返回。
解锁(Unlock):当线程完成对临界区的访问后,它必须解锁互斥锁,以便其他线程可以锁定并访问临界区。
销毁(Destroy):当互斥锁不再需要时,应将其销毁。
3.示例代码
以下是一个使用互斥锁的伪代码示例:
4.注意事项
死锁:不当使用互斥锁可能导致死锁,即两个或多个线程永久阻塞,每个线程都在等待另一个线程释放锁。
饥饿:如果线程优先级设置不当,可能导致某些线程长时间无法获得互斥锁,这种情况称为饥饿。
性能:频繁的锁定和解锁操作可能会带来性能开销,特别是在高并发和多处理器系统中。
互斥锁是并发编程中不可或缺的工具,正确地使用互斥锁可以确保数据的一致性和程序的正确性。在实现互斥锁时,可以使用前面提到的硬件实现方法,如TestAndSet或Swap指令,来确保操作的原子性。在许多操作系统和编程语言中,互斥锁都是通过标准库提供的。
5.5 信号量机制
在C++中,信号量(Semaphore)机制是一种同步原语,用于控制多个线程对共享资源的访问。信号量是一个整数变量,可以用来表示资源的可用数量。信号量主要有两种操作:
P
(也称为wait
或down
)和V
(也称为signal
或up
)。P
操作会减少信号量的值,如果信号量的值小于或等于0,则阻塞调用线程;V
操作会增加信号量的值,并可能唤醒等待的线程。在C++11及以后的版本中,信号量可以通过
<semaphore>
头文件中的std::counting_semaphore
和std::binary_semaphore
(C++20引入)来实现。以下是使用std::counting_semaphore
的一个示例:
在上面的示例中,我们定义了一个
std::counting_semaphore
对象sem
,其初始值为1,表示只有一个线程可以进入临界区。每个线程在执行任务之前都会调用acquire()
方法(即P操作),在完成任务之后会调用release()
方法(即V操作)。如果信号量的值小于1,则acquire()
调用将阻塞,直到另一个线程调用release()
。在C++20中,可以使用
std::binary_semaphore
,它是std::counting_semaphore
的一个特例,其计数只能是0或1,适用于二进制信号量的场景。使用std::binary_semaphore
的代码示例如下:
在这个示例中,
std::binary_semaphore
的使用与std::counting_semaphore
类似,只是它被限制为只能表示两种状态:可用(值为1)和不可用(值为0)。
5.6 生产者-消费者问题
生产者-消费者问题是计算机科学中一个经典的并发问题。它描述了一个场景,其中有一组生产者线程生成数据并将数据放入缓冲区,同时有一组消费者线程从缓冲区中取出数据并处理。生产者和消费者必须同步对缓冲区的访问,以避免数据竞争和其他并发问题。
以下是使用C++11及以上版本中的<mutex>
和<condition_variable>
头文件实现的生产者-消费者问题的示例:
在这个示例中,ProducerConsumerQueue
类封装了一个队列和同步机制。它使用一个互斥锁mtx
和一个条件变量cond_var
来同步对队列的访问。
produce
方法用于生产者线程,它将项目放入队列中。如果队列已满,生产者将等待直到队列中有空间。consume
方法用于消费者线程,它从队列中取出项目。如果队列为空,消费者将等待直到队列中有项目。
生产者线程通过producer
函数创建,并尝试生产指定数量的项目。消费者线程通过consumer
函数创建,并尝试消费指定数量的项目。
在main
函数中,我们创建了一个ProducerConsumerQueue
实例,并启动了生产者和消费者线程。线程通过join
函数等待完成。
这个示例展示了如何使用C++的线程和同步机制来解决生产者-消费者问题。在实际应用中,可能需要根据具体需求调整队列的大小、生产者和消费者的数量以及它们的行为。
5.7 吸烟者问题
吸烟者问题(Smokers’ Problem)是计算机科学中的一个并发算法问题,它描述了一个场景,其中三个吸烟者需要三种不同的原材料(例如烟草、纸和火柴)来制造香烟。每个吸烟者只拥有其中一种原材料,而另外两种原材料必须从外界获取。有一个供应商会轮流提供这三种原材料中的任意两种。吸烟者必须等待直到他们所缺少的那两种原材料都可用时,才能制造并抽掉香烟。
以下是吸烟者问题的基本组件:
供应商(Agent):负责提供两种原材料。
吸烟者(Smokers):每个吸烟者拥有一种原材料,并等待其他两种。
以下是使用伪代码来描述吸烟者问题的解决方案:
在这个伪代码中,P
操作表示等待(或减少)信号量的值,如果信号量的值为0,则进程会阻塞直到信号量变为大于0。V
操作表示信号量增加(或释放)。
每个吸烟者都在等待它们所缺少的原材料。当供应商放置了两种原材料后,相应的吸烟者可以获取这些原材料,制造香烟并抽掉它。这个过程会不断重复。
这个问题的解决方案需要确保以下条件:
任何时候,供应商只能放置两种原材料。
当一个吸烟者正在吸烟时,其他吸烟者不能干扰。
不会有原材料被浪费,即每种原材料都恰好被一个吸烟者使用。
这个问题的解决方案有多种变体,可以使用不同的同步机制,如互斥锁、条件变量、信号量等。在实际的操作系统课程或并发编程中,这个问题经常被用来展示如何使用同步原语来解决复杂的并发问题。
5.7 读者写者问题
读者写者问题是计算机科学中常见的一个并发问题,它涉及到如何允许多个读者(不修改数据的进程)和写者(可能修改数据的进程)访问同一数据资源,同时还要保持数据的一致性和完整性。
1.问题描述
读者(Readers):只读取数据,不会修改数据。多个读者可以同时读取数据而不会相互干扰。
写者(Writers):可能读取也可能写入数据。写者之间必须是互斥的,即同一时间只能有一个写者在写入数据。写者在写入时,不能有其他读者或写者在操作数据。
2.目标
允许多个读者同时读取:为了提高并发性,允许多个读者同时访问数据。
写者独占访问:为了保证数据的一致性,写者在写入数据时,其他读者或写者不能访问数据。
公平性:避免读者或写者饥饿,即确保所有读者和写者最终都能访问到数据。
3.解决方案
以下是几种常见的解决方案:
1. 读者优先
在这种策略下,读者不会因为写者而阻塞,但写者可能会因为读者而阻塞。
2. 写者优先
在这种策略下,写者不会因为读者而阻塞,但读者可能会因为写者而阻塞。
3. 读写公平
在这种策略下,尝试确保读者和写者都能公平地访问数据。
在实际应用中,根据具体需求选择合适的策略。上述伪代码展示了使用信号量来解决读者写者问题的基本方法。在实际编程中,可能会使用更高级的同步机制,如条件变量、互斥锁、读写锁等。
5.8 哲学家进餐问题
哲学家进餐问题(Dining Philosophers Problem)是一个经典的并发算法问题,由荷兰计算机科学家艾兹格·迪科斯彻(Edsger W. Dijkstra)在1965年提出。这个问题用来描述多个进程或线程在执行时可能发生的死锁问题。
问题描述
假设有五位哲学家围坐在一张圆桌旁,每位哲学家之间都有一根筷子。哲学家在思考时不需要筷子,但在进餐时需要同时拿起左右两边的筷子才能吃饭。如果每位哲学家都先拿起左边的筷子,然后再去拿右边的筷子,那么就会发生死锁,因为每位哲学家都在等待右边的那根筷子,而筷子永远不会被释放。
目标
设计一个算法,使得每位哲学家都能够交替地进行思考和进餐,同时避免死锁的发生。
解决方案
以下是几种解决哲学家进餐问题的方案:
1. 资源分级法
对筷子进行编号,规定哲学家只能先拿起编号较小的筷子,然后再去拿编号较大的筷子。这样,至少会有一位哲学家能够同时拿起两根筷子进餐,从而避免死锁。
2. 服务员方法
引入一个服务员的角色来控制筷子的分配。服务员确保在任何时候最多只有四位哲学家能够同时尝试拿起筷子。
3. 超时放弃法
哲学家尝试拿起筷子,如果在一定时间内没有成功,就放弃并重新开始思考。这样可以避免无限期地等待筷子。
以上只是哲学家进餐问题的一些基本解决方案的伪代码表示。在实际编程中,可能需要使用具体的同步原语(如互斥锁、信号量等)来实现这些方案,并且可能需要考虑更多细节来确保系统的稳定性和公平性。
5.9 管程
管程(Monitor)是一种同步机制,用于解决并发编程中的同步问题。它是由布鲁斯·阿姆斯特朗(Bruch Jay Armstrong)在1973年提出的概念。管程提供了一种高级的抽象,允许程序员定义一个封装了共享资源的数据结构和一组操作这些数据结构的过程(或方法),并且保证在同一时刻只有一个线程可以执行这些过程之一。
管程的主要特性
互斥访问:管程保证了共享资源在同一时间只能被一个线程访问,从而避免了并发访问导致的数据不一致问题。
条件同步:管程支持条件变量和相关的等待(wait)和通知(signal/notify)操作,允许线程在特定条件下挂起或被唤醒。
封装性:管程将共享资源和操作这些资源的过程封装在一起,隐藏了同步的细节,使得并发程序更容易理解和维护。
管程的基本组成
共享资源:需要被多个线程访问的数据结构或变量。
过程/方法:定义了对共享资源进行操作的方法,这些方法可以原子性地执行。
条件变量:用于线程间的同步,通常与等待和通知操作结合使用。
入口队列:当多个线程尝试进入管程时,它们将被放入入口队列中,按照某种策略(如FIFO)等待进入管程。
管程的操作
进入(Enter):线程请求进入管程时,如果管程空闲,则线程可以立即进入;如果管程已被占用,线程将被阻塞,直到管程空闲。
等待(Wait):线程在管程内部执行等待操作,释放管程的互斥锁,并挂起自己,直到被其他线程通过通知操作唤醒。
通知(Signal/Notify):线程在管程内部执行通知操作,唤醒一个或多个在条件变量上等待的线程。
退出(Exit):线程完成对共享资源的操作后,退出管程,释放互斥锁,允许其他线程进入。
示例
以下是一个使用管程解决生产者-消费者问题的简单示例:
在这个示例中,ProducerConsumerMonitor
是一个管程,它包含了共享资源count
和条件变量full
、empty
。producer
和consumer
是操作共享资源的过程,它们通过等待和通知操作来实现同步。
需要注意的是,不同的编程语言和操作系统可能提供了不同的管程实现。例如,Java语言中的synchronized
关键字和Object
类的wait()
、notify()
、notifyAll()
方法可以用来实现管程。
5. 10死锁
死锁(Deadlock)是并发控制中的一种情况,其中两个或多个线程永久性地阻塞,每个线程等待其他线程释放资源。由于线程在等待永远不会发生的条件,所以它们无法继续执行。这通常发生在多个线程竞争同一组资源时,每个线程已经持有一部分资源,但又等待获取其他线程持有的资源。
1.死锁的四个必要条件
死锁的发生通常依赖于以下四个条件同时成立,这些条件由艾兹格·迪科斯彻(Edsger Dijkstra)提出:
互斥条件:资源不能被多个线程同时使用。
持有和等待条件:线程至少持有一个资源,并且正在等待获取额外的资源,而该资源又被其他线程持有。
非抢占条件:线程持有的资源在未使用完毕前不能被其他线程强行抢占。
循环等待条件:存在一个线程与资源的循环等待链,每个线程都在等待下一个线程所持有的资源。
2.死锁示例
假设有两个线程A和B,以及两个资源R1和R2:
线程A持有资源R1,等待获取资源R2。
线程B持有资源R2,等待获取资源R1。
由于线程A和B都不愿意放弃它们已经持有的资源,并且都在等待对方持有的资源,因此它们陷入了死锁。
3.死锁的处理方法
处理死锁的方法主要有以下几种:
预防死锁:通过破坏死锁的四个必要条件之一来预防死锁的发生。例如,可以通过资源分配策略来避免循环等待条件。
避免死锁:在资源的动态分配过程中,避免系统进入不安全状态。银行家算法是一个著名的避免死锁的算法。
检测死锁:允许死锁发生,但通过检测来识别死锁,然后采取措施解除死锁。例如,可以通过资源分配图来检测循环等待条件。
解除死锁:当检测到死锁发生时,采取措施解除死锁。常用的解除死锁的方法有:
剥夺资源:从某个线程中剥夺资源,将其分配给其他线程。
终止线程:终止一个或多个线程,从而释放它们持有的资源。
4.死锁的解除策略
以下是一些常见的死锁解除策略:
资源剥夺:挂起一些进程,并抢占它们的资源,将这些资源分配给其他进程。
终止进程:强制终止一个或多个进程,从而释放它们持有的资源。
回滚操作:某些情况下,可以将进程回滚到安全状态,然后重新开始执行。
5.避免死锁的策略
资源有序分配:规定每个线程必须按照一定的顺序请求资源,从而避免循环等待。
请求资源时一次性分配:线程在开始执行前一次性请求所有需要的资源,要么全部满足,要么不执行。
限制线程持有资源的时间:通过设置超时时间,如果线程在规定时间内未能获得所有资源,则放弃已获得的资源。
死锁是并发编程中的一个复杂问题,解决死锁通常需要对系统的具体需求和环境有深入的理解。
Last updated