Operating System · Process Synchronization (操作系统 · 进程同步) (四)

Process Synchronization



前言

上一章我们介绍了操作系统中的进程及其调度。进程的引入在提高系统资源利用率,提高系统的吞吐量的同时,也使得系统变得更加复杂,如何对进程进行妥善地管理以避免他们对系统资源的无序争夺便变得十分重要。为了解决上述问题,我们引入了同步机制,该机制保证了多个进程之间可以有条不紊地运行。本章节,我们就来探究一下操作系统背后进程同步机制的工作原理和实现方式。


一、生产者-消费者问题(producer-consumer)

我们首先以经典的生产者-消费者问题为例,让大家能够直观地感受进程之间共用系统资源导致冲突的原因和过程。

什么是生产者-消费者问题呢?顾名思义,就是生产者生产产品,消费者消费产品的过程。具体点来说,我们假设有一群生产者进程在生产产品,其生产出来的产品会被存放在一个具有n个缓冲区的缓冲池中,消费者可以从任意一个缓冲区中取走产品完成消费动作。

显然生产者和消费者之间是以异步方式进行工作的,如果我们不做任何处理的话,当缓冲区为空的时候,消费者依然会执行从缓冲区取走产品的动作,即使缓冲区已经没有了产品;而消费者会不断地生产产品,即使缓冲池已经满了。上述过程可以用代码进行模拟:

#define BUFFER_SIZE 20
int in=0, out=0, counter=0;

// producer process
while(true)
{
	if(counter == BUFFER_SIZE)
	{
		sleep();
	}
	buffer[in] = product;
	in = (in + 1) % BUFFER_SIZE;
	counter++;
	if(counter == 1){
		wakeup(Consumer);
	}
}

//consumer process
while(true)
{
	if(counter == 0)
	{
		sleep();
	}
	product = buffer[out];
	out = (out + 1) % BUFFER_SIZE;
	counter--;
	if(counter == BUFFER_SIZE - 1){
		wakeup(Producer);
	}
}

上述代码看似没有问题,即当计数器等于缓冲池大小时,就让生产者消费等待;当计数器等于0时,就让消费者循环等待。但是当这两个程序并发执行的时候,还是会出差错,而出错的原因,就在这个共用的计数器counter身上, 如下图片所示:
请添加图片描述
我们假设counter的初始值为4,正常的逻辑是首先执行生产者进程,让counter++变为5, 对应的进行图片中生产者P的寄存器操作;之后执行消费者的进程,让counter–又变回4, 对应图片中消费者C的寄存器操作。但是,在实际运行时,由于两个线程之间存在线程切换的情况,因此很有可能生产者P的寄存器操作还没有执行完,线程就被切换到消费者进程了,如上图中可能的执行序列所示,在这种情况下,我们会发现最终寄存器的值变味了3,而不是原来的值4,显然这是错误的。上述问题我们称之为这两个线程之间不同步。

那么我们该如何解决线程不同步的问题呢?一种直观的想法是在任何一个进程对共享数据counter进行操作时,我们对该操作进行"上锁"。这样,如果有其他进程想要访问该资源,也会由于该资源被上了"锁"而无法进行访问。只有当前进程对该资源完成操作之后,“锁”才会被打开,里面的资源才能再被其他进程所使用。具体过程参考下图:
请添加图片描述
事实上,我们上锁的目的就是为了保护这个操作在结束之前免受其他进程的干扰,那么就要求我们把相关操作放入一个特殊的区域可以实现上述功能,这里我们就引入了临界区的概念。

二、临界区(Critical Section)

临界区就是一段特殊的代码区域,处于临界区的代码实现了多个进程对它进行互斥访问。而要进入临界区的时候,应该先对欲访问的资源进行检查,看他能否被正常访问,因此,我们需要在临界区的前面增加一段具有检查功能的代码,我们称之为进入区,相应的,我们也需要在后边加入一段代码用来退出临界区,这段代码被称为退出区代码。而在上述这三个区域之外的代码被称为剩余区。下面我们介绍三种经典的临界区尝试实现方法。

1,轮转法

请添加图片描述
轮转法很好理解,如上图所示,就是两个进程根据turn的值对临界区进行轮流操作,但是轮转法存在一个问题,假设在 P 0 P_{0} P0进程执行完毕后,如果进程 P 1 P_{1} P1被阻塞了,那么由于turn的值没有改变,进程 P 0 P_{0} P0便无法进入临界区,导致临界资源浪费的情况出现。

2,标记法

标记法借鉴于实际生活中买牛奶的问题。当妻子打开冰箱发现没有牛奶的时候,不会直接去买牛奶,而是会先查看丈夫是否留了纸条,如果丈夫没有留纸条,那么妻子就会留下纸条告知丈夫自己去买牛奶了,当买完牛奶之后,妻子会再把纸条拿走,这样就防止了重复买牛奶的情况发生。利用类似的留纸条的思想,我们可以写出标记法的临界区保护代码,如下图所示:
请添加图片描述
标记法虽然避免了两个进程重复利用资源的问题,但是也存在临界区资源闲置的问题。我们知道进程之间会进行交替执行,那么如果出现下图中的执行顺序的话,flag[0]和flag[1]会出现同时为true的情况,此时两个进程都无法进入临界区,造成资源浪费。
请添加图片描述

3,Peterson算法

Peterson算法结合了轮转法和标记法,示例代码如下所示:
请添加图片描述
分析以上代码可以得知,由于turn和标记flag的共同作用,两个进程同时阻塞的问题便不会存在了。

4,阻止进程调度

进程之间出现同步问题的本质原因就是由于进程调度,如果我们在当前进程进入临界区之间先暂时阻止进程调度,当前进程执行完临界区代码之后在开放进程调度,那么就可以实现进程之间的同步。我们可以通过关闭中断的方法’cli()’, 进而阻止进程调度。使用’sti()'再打开中断,使得CPU可以继续进行中断操作。

该方法对于多核CPU的计算机并不好使。我们知道中断的本质是对CPU中INTR寄存器的值的改变。当中断发生时,INTR寄存器中相应区域会被标记为1。CPU每执行完一条汇编指令便会查看INTR寄存器中的值。而cli()函数就是通过让CPU不看INTR寄存器中的值而避免了中断的发生。但是问题在于,每一个CPU都有其单独的INTR寄存器,因此对欲多核CPU的计算机,只关闭一个CPU的进程切换还是无法保证其他CPU不会执行进程切换。

通过设置临界区的方法我们就可以解决由于进程的切换而导致counter的数值错误的问题。但是除此之外,当存在两个或多个生产者进程时,还会出现一些生产者无法被唤起的问题。例如,假设我们有两个生产者进程P1和P2。当缓冲区满了之后,生产者P1和生产者P2都因sleep()而阻塞;消费者C执行一次循环之后,计数器的数值就会变成BUFFER_SIZE - 1, 这会唤醒P1;如果P1进程还没有被执行,那么当C执行下一次循环时,计数器的大小变成了BUFFER_SIZE - 2,此时P2却不能被及时地唤醒。

综上所述,单纯依靠counter无法保证生产者和消费者之间的进程同步,因此,我们还需要另外一个变量来处理进程同步问题,而不仅仅依靠counter发送信号,这个额外的变量我们称之为信号量

三、信号量机制(Semaphores)

1,信号量的定义

信号量机制是由荷兰学者Dijkstra于1965年提出来的,用来解决进程同步问题。那么什么是信号量呢?简单地说,信号量就是一种特殊整型变量,用来表示资源数目。程序可以根据这个信息决定该进程是睡眠还是被唤醒。假设我们用sem来表示信号量,那么刚才P1和P2进程阻塞的例子便可以通过下面的过程来唤醒:
请添加图片描述
如上图所示,在信号量机制中,用负数表示当前被阻塞进程资源的个数,用正数来表示可用进程资源的个数。当程序中有2个进程被阻塞时,信号量就变为-2。如此,在生产者消费者问题中,消费者便可以通过信号量的值对生产者进行唤醒操作,不会出现某个线程非正常阻塞的情况。

在直观了解了信号量的工作原理之后,我们来看一下信号量机制在代码层面是如何具体实现的。具体代码如下:

typedef struct{
	int value;
	PCB *queue;
}semaphore;

// consume resource
P(semaphore s)
{
	s.value--;
	if(s.value < 0)
	{
		sleep(s.queue);
	}
}

//produce resource
V(semaphore s)
{
	s.value++;
	if(s.value <= 0)
	{
		wakeup(s.queue);
	}
}

每执行一次P操作,表示当前进程请求一个单位的该类资源,使得系统中可供分配的该类资源数减少一个,因此s.value–。而当s.value<0时,表示该类资源已经分配完毕,因此进程会使用sleep操作进行自我阻塞,同时,该进程就会被添加到该信号量对应的阻塞队列中,进行等待。此时s.value表示在该信号量量表中已经阻塞的进程的数目。

类似地,每执行一次V操作,表示当前进程释放一个单位的该类资源,使得系统中可分配的该类资源数增加一个,因此s.value++。而当s.value<=0时,表示该信号量的队列中仍然存在等待该资源的进程被阻塞,因此要进行wakeup操作唤醒队列中第一个进程。

2,使用信号量

现在我们用信号量来解决生产者-消费者问题。这里我们以向文件中写入数据和从文件中读区数据为例。代码如下:

#define BUFFER_SIZE=20
char buffer[BUFFER_SIZE]={"\0"};

int fd=open("buffer.txt");
if(fd==-1){
	printf("cannot open the file.\n");
	return 1;
}

semaphore full;
semaphore empty;
semaphore mutex;

full.value = 0;
empty.value = BUFFER_SIZE;
mutex.value = 1;

Producer(item){
	P(empty);
	P(mutex);
	write(fd, item, sizeof(int));
	V(mutex);
	V(full);
}

int buffer[BUFFER_SIZE]={"\0"};
Consumer(){
	P(full);
	P(mutex);
	read(fd, buffer, sizeof(int));
	V(mutex);
	V(empty);
}

对于生产者,刚开始的时候要使用P测试函数检验缓冲区是否满了,也就是空闲缓冲区(empty)的大小是小于0,如果经历此次生产后empty.value<0,那么生产者就会进入阻塞状态,当前进程就会别放进empty.queue中去。而消费者通过V(empty)操作负责增加空闲缓冲区的个数,从而唤醒被阻塞的生产者进程。

类似地,对于消费者,刚开始的时候要先使用P测试函数检验已经生产出来的内容的个数,如果消费者消费之后,产品的个数full.value<0, 那么消费者就会进入阻塞状态,直到生产者通过(full)操作增加产品的个数,使得full.value>0, 从而唤醒消费者进程。

另外这里我们还引入了互斥信号量mutex,其初始值被设定为mutex.value=1,表示只允许一个进程访问该资源,而这正适合于文件读写操作,相当于我们用了一把抽象的“锁”,将共享资源锁了起来。


总结

本章我们探究了多进程操作系统下的进程同步机制,围绕生产者-消费者问题引出了临界区的概念和实现方式,其中Peterson算法很好地实现了两个进程之间互斥访问共享资源;最后,我们讲解了信号量机制及其实现方式,并基于此改进了生产者-消费者模型。但是,当多个进程要求同步的时候该如何做呢?著名的面包店算法便可以解决这个问题,该算法我们会再下一个章节进行介绍;此外,下一章还将讲解进程中的最后一块重要内容——死锁,我们将了解死锁的含义,为什么会产生死锁及其解决方案。

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值