信号量

几个关键名词

原子操作

  原子操作:一个或多个指令的序列,对外是不可分的,即没有其他进程可以看到其中间状态或者中断此操作。

互斥与同步

  互斥:是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法限制访问者对资源的访问顺序,即访问是无序的。 

  同步:是指在互斥的基础上(大多数情况),通过其它机制实现访问者对资源的有序访问。在大多数情况下,同步已经实现了互斥,特别是所有写入资源的情况必定是互斥的。少数情况是指可以允许多个访问者同时访问资源。

临界资源与临界区

  临界资源就是一次只允许一个进程访问的资源,一个进程在使用临界资源的时候,另一个进程是无法访问的,操作系统也不能够中途剥夺正在使用者的使用权利,即临界资源是不可剥夺性资源。

  临界区(critical section)就是进程中访问临界资源的那段程序代码。注意,临界区是程序代码,不是内存资源了。这是临界资源与临界区的区别。

  临界区的使用原则(也即同步机制应遵循的准则,注意与死锁产生的四个条件相区分开来)可总结为十六字诀:“空闲让进,忙则等待,有限等待,让权等待”下:

  1)空闲让进:临界资源空闲时一定要让进程进入,不发生“互斥礼让”行为。

  2)忙则等待:临界资源正在使用时外面的进程等待。

  3)有限等待:进程等待进入临界区的时间是有限的,不会发生“饿死”的情况。

  4)让权等待:进程等待进入临界区时应该放弃CPU的使用。

死锁与活锁

  死锁:两个或两个以上的进程因其中的每个进程都在等待其他进程做完某些事情而不能继续执行。

  活锁:两个或两个以上的进程为了响应其他进程中的变化而持续改变自己的状态但不做有用的工作。

饥饿

  饥饿:指一个可运行的进程尽管能继续执行,但被调度器无限期地忽视,而不能被调度执行的情况。

信号量

  信号量是用于进程间传递信号的一个整数值。在信号量上只有三种操作可以进行,初始化、递减和增加。这三种操作都是原子操作。递减操作可以用于阻塞一个进程,增加操作可以用于解除阻塞一个进程。信号量也称为计数信号量或一般信号量。

信号量的类型定义

  每个信号量至少须记录两个信息:信号量的值和等待该信号量的进程队列。它的类型定义如下:

struct semaphore
{
    int count;
    queueType queue;
};

  其中,count表示信号量的值;queue是进程控制块,是操作系统为每个进程建立的数据结构。

  当s.count >= 0时,s.queue为空;

  当s.count < 0时,s.count的绝对值为s.queue中等待进程的个数。

PV原语

  对一个信号量可以进行两种原语操作,即 P 操作和 V 操作:

void P(semaphore s)
{
    s.count--;
    if(s.count < 0)
    {
        // 把当前进程插入到队列当中
        // 阻塞当前进程
    }
}

void V(semaphore s)
{
    s.count++;
    if(s.count <= 0)
    {
        // 把进程 P 从队列当中移除
        // 把进程 P 插入到就绪队列
    }
}

  当s.count初值为1时,信号量相当于mutex。

  P 操作和 V 操作是不可中断的程序段,称为原语。如果将信号量看做共享变量,则 PV 操作为其临界区,多个进程不能同时进行,一般用硬件保证。一个信号量只能置一次初值,以后只能对之进行 P 操作或 V 操作。由此也可以看到,信号量机制必须有公共内存,不能用于分布式操作系统,这是它最大的弱点。

 

  《操作系统:精髓与设计原理》给出了一个关于信号量机制的例子(图5.5):

  进程A B C依赖于进程D的结果,

  1)A 正在运行,B、C 和 D 就绪,信号量为 1,表示D的一个结果可用。当 A 执行 P 操作后,信号量减为 0,A 能继续执行,随后它加入就绪队列。

  2)B 正在运行,最终执行 P 操作,并被挂起。

  3)D 被允许执行。

  4)当 D 完成一个新结果后,执行 V 操作,允许 B 移到就绪队列中。

  5)D 加入就绪队列,C 开始执行,当它执行 P 指令时被挂起。

  6)A 和 B 运行,且被挂起在这个信号量上,允许 D 恢复执行。

  7)当 D 有一个结果后,执行 V 操作,把 C 移到就绪队列中,随后的 D 循环将解除 A 和 B 的挂起状态。

  

信号量用于互斥

  下边程序给出了一种使用信号量 s 解决互斥问题的方法:

const int n = // 进程数
semaphore s = 1;
void Proc(int i)
{
    while(true)
    {
        P(s);
        // 临界区
        V(s);
        // 其他部分
    }
}

void main()
{
    parbegin(Proc(1), Proc(2), ..., Proc(n));
}

  设有 n 个进程,用 Proc(i) 表示,所有的进程都需要访问共享资源。每个进程进入临界区前执行 P 操作,如果 s 的值为负,则进程被挂起;如果值为 1,则 s 被减为 0,进程立即进入临界区;由于 s 不再为正,因而其他任何进程都不能进入临界区。

  信号量一般初始化为 1,这样第一个执行 P 操作的进程可以立即进入临界区,并把 s 的值置为 0。接着任何试图进入临界区的其他进程,都将发现第一个进程忙,因此被阻塞,把 s 的值置为 -1。可以有任意数目的进程试图进入,每个不成功的尝试都会使 s 的值减 1,当最初进入临界区的进程离开时,s 增加 1,一个被阻塞的进程(如果有的话)被移除出等待队列,并置于就绪状态。这样,当操作系统下一次调度时,它可以进入临界区。

  当进程数为 3 时,一个示意图如下:

  

生产者与消费者问题

  生产者-消费者问题是经典的同步互斥问题,具体表现为:

  1)两个进程对同一个内存资源进行操作,一个是生产者,一个是消费者。

  2)生产者往共享内存资源填充数据,如果区域满,则等待消费者消费数据。

  3)消费者从共享内存资源取数据,如果区域空,则等待生产者填充数据。

  4)生产者的填充数据行为和消费者的消费数据行为不可在同一时间发生。

  

  生产者-消费者之间的同步关系表现为缓冲区空,则消费者需要等待生产者往里填充数据,缓冲区满则生产者需要等待消费者消费。两者共同完成数据的转移或传送。生产者-消费者之间的互斥关系表现为生产者往缓冲区里填充数据的时候,消费者无法进行消费,需要等待生产者完成工作,反之亦然。

  下边分几种情况讨论:

  1)一个生产者,一个消费者,公用一个缓冲区
  定义两个同步信号量:
  empty——表示缓冲区是否为空,初值为1。
  full——表示缓冲区中是否为满,初值为0。

 1 /******* 生产者进程 *******/
 2 while(TRUE)
 3 {
 4     // 生产一个产品;
 5     P(empty);
 6     // 产品送往Buffer;
 7     V(full);
 8 }
 9 
10 /******* 消费者进程 *******/
11 while(True)
12 {
13     P(full);
14     // 从Buffer取出一个产品;
15     V(empty);
16     // 消费该产品;
17 }

  2)一个生产者,一个消费者,公用n个环形缓冲区
  定义两个同步信号量:
  empty——表示缓冲区是否为空,初值为n。
  full——表示缓冲区中是否为满,初值为0。
  设缓冲区的编号为1~n-1,定义两个指针in和out,分别是生产者进程和消费者进程使用的指针,指向下一个可用的缓冲区。

 1 /******* 生产者进程 *******/
 2 while(TRUE)
 3 {
 4     // 生产一个产品;
 5     P(empty);
 6     // 产品送往buffer(in);
 7     in=(in+1)mod n;
 8     V(full);
 9 }
10 
11 /******* 消费者进程 *******/
12 while(TRUE)
13 {
14     P(full);
15     // 从buffer(out)中取出产品;
16     out=(out+1)mod n;
17     V(empty);
18     // 消费该产品;
19 }

  3)一组生产者,一组消费者,公用n个环形缓冲区
  在这个问题中,不仅生产者与消费者之间要同步,而且各个生产者之间、各个消费者之间还必须互斥地访问缓冲区。
  定义四个信号量:
  empty——表示缓冲区是否为空,初值为n。
  full——表示缓冲区中是否为满,初值为0。
  mutex1——生产者之间的互斥信号量,初值为1。
  mutex2——消费者之间的互斥信号量,初值为1。
  设缓冲区的编号为1~n-1,定义两个指针in和out,分别是生产者进程和消费者进程使用的指针,指向下一个可用的缓冲区。

 1 /******* 生产者进程 *******/
 2 while(TRUE)
 3 {
 4     // 生产一个产品;
 5     P(empty);
 6     P(mutex1);
 7     // 产品送往buffer(in);
 8     in=(in+1)mod n;
 9     V(mutex1);
10     V(full);
11 }
12 
13 /******* 消费者进程 *******/
14 while(TRUE)
15 {
16     P(full)
17     P(mutex2);
18     // 从buffer(out)中取出产品;
19     out=(out+1)mod n;
20     V(mutex2);
21     V(empty);
22     // 消费该产品;
23 }

  需要注意的是无论在生产者进程中还是在消费者进程中,两个 P 操作的次序不能颠倒。应先执行同步信号量的P操作,然后再执行互斥信号量的P操作,否则可能造成进程死锁。

信号量用于进程间通信

  信号量与其他进程间通信方式不大相同,它主要提供对进程间共享资源访问控制机制。相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志。除了用于访问控制外,还可用于进程同步。

  详细的可参考IBM的文章Linux环境进程间通信(四)。

参考资料

  《操作系统:精髓与设计原理》

  信号量

  用信号量解决进程的同步与互斥探讨

  信号量与PV操作

  【Windows】用信号量实现生产者-消费者模型

  c++11中信号量(semaphore)的实现