一些概念:
临界区 | 互斥量 | 信号量(信号灯) | 事件
使用有限缓冲方案的的消费者-生产者模型,不能正确的并发执行,保证不了进程间同步,比如counter++/counter--语句:
/* producer */
while(1)
{
while(counter == BUFFER_SIZE)
; // 缓冲区满,等待
buffer[in] = nextProduced;
in = (in + 1) % BUFFER_SIZE;
counter++;
}
/* consumer */
while(1)
{
while(counter == 0)
; //缓冲区空,等待
nextConsumed = buffer[out];
out = (out + 1) % BUFFER_SIZE;
counter--;
}
临界区:(可以理解为必须保证原子性,即实现进程间同步的代码部分)每个进程有一个代码段,在该区域可以修改共同变量、更新共享的文件等。每一时刻只能允许一个进程进入(满足这一要求的代码称为进入区,entry section),之后还可有退出区 exit section,其他代码为剩余区 remainder section。临界区必须满足的三项要求:互斥、有空让进(即前进原则:临界区空,那些不在剩余区的进程参与决策,不能无限延迟)、有限等待。
实现同步功能的几种方式:纯软件实现(会造成忙等待busy waiting,即等待进程都在无限循环,浪费了CPU时钟,也叫自旋锁spinlock,自旋锁的一点优点是可以避免上下文切换,这在等待时间较短时比较有用,可以保证内核不被多个进程抢占,可以参考这里和这里这里这里)、硬件原子指令、信号量
两进程的进入区和退出区代码(依赖软件实现):
// initial section
// ... ...
boolean flag[2];
int turn;
flag[0] = flag[1] = false;
do{
// entry section
flag[i] = true;
turn = j; //因为先申请进入临界区进程的turn值会被后申请的改写,这样反写可以使得先申请进入临界区的进程有可以首先进入。保证了“有限等待”原则
while(flag[j] == true && turn == j) ;//陷入循环等待
// critical section
// ... ...
// exit section
flag[i] = false;
// remainder section
// ... ...
}while(1);
多进程的进入区和退出区代码(又叫做
面包店算法,思想是
排队号码+进程id 来决定谁进入):
// initial,其中N+1表示进程id,共N个进程(没有0号进程)
// entering[i]为真,表示进程i正在获取它的排队号码
boolean entering[N+1];
// number[i]表示进程i的排队号码,为0时表示未参加排队,既不想获取共享资源。该值没有上限
int number[N+1];
for(int i = 0; i <= N; i++)
{
entering[i] = false;
number[i] = 0;
}
/*假设不使用entering数组,那么就可能会出现这种情况:设进程i的优先级高于进程j(即i<j),
两个进程获得了相同的排队登记号(number数组的元素值相等)。进程i在写number[i]之前,
被优先级低的进程j抢先获得了CPU时间片,这时进程j读取到的number[i]为0,因此进程j进入了临界区.
随后进程i又获得CPU时间片,它读取到的number[i]与number[j]相等,且i<j,因此进程i也进入了临界区。
这样,两个进程同时在临界区内访问,可能会导致数据腐烂(data corruption)。
算法使用了entering数组变量,使得修改number数组的元素值变得“原子化”,解决了上述问题。
*/
lock(integer i)
{
// entering实现原子操作(纯软件实现)
entering[i] = true;
number[i] = 1 + max(number, N); //每次获取排队号码都是原来的最大值加1
entering[i] = false;
for(int j = 1; j <= N; j++)
{
while(entering[j]); //等待正在获取排队号码的进程
while((number[j]!=0) && (number[j],j)<(number[i],i));//等待排队号码比当前进程的小,或者相同号码进程优先权高(id小)的进程进入临界区
}
}
unlock(integer i)
{
number[i] = 0;
}
Thread(integer i)
{
while(true)
{
lock(i);//entry section
//critial section
// ... ...
unlock(i);//exit section
//remainder section
// ... ...
}
}
同步硬件:许多系统提供特殊硬件指令以允许原子的(如同不可中断一样)检查和修改或交换某个地址的内容,可以借助这些指令实现互斥。
信号量:信号量S是个整数变量,除了初始化外,只能通过两个标准原子操作wait(P,表示测试)和signal(V,表示增加)来访问。一种克服忙等待的wait和signal的实现方式:当进程执行wait操作时发现信号量非正时必须等待,但该进程不是忙等而是阻塞自己。阻塞操作将一个进程放入到与信号量相关的等待队列中,该进程的状态被切换为“等待”。该进程在其他进程执行signal操作后被wakeup重新执行。具体如下:
typedef struct
{
int value;
struct process *L;
}semaphore; //每个信号都有一个整数值和一个进程链表
void wait(semaphore S)
{
S.value --;
if(S.value < 0) //不是忙等待,而是阻塞
add this process to S.L;
block(); //阻塞
}
void signal(semaphore S)
{
S.value ++;
if(S.value <= 0) //如果value为负数,那么其绝对值就是等待该信号量的进程数
remove a process P from S.L;
wakeup(P);
}
实现wait和signal原子操作的方式,单CPU可以简单的禁止中断,多CPU必须由硬件提供特殊的原子指令。实际操作系统可能会使用二进制信号量(只有0和1),因为更容易实现。
另外,信号量的使用很不灵活,假如不正确使用的话很容易出现计数错误和各种类型的错误。为了解决这些问题,有一些高级语言构造,比如:临界区域、管程等
饥饿(无限期阻塞):进程在信号量内无穷等待,比如进程链表按照LIFO顺序来添加和删除进程。
经典同步问题:1)生产者-消费者的有限缓冲模型;2)读者-作者问题(只读和只写,书上的例子是一对多问题,容易造成作者饥饿);3)哲学家吃饭问题(书上的例子容易造成死锁)
Ref: 《操作系统概念》