计算机技术学习札记

操作系统 2:信号与信号量

信号

信号是内核向进程发出的消息,用来通知进程「系统中发生了某种类型的事件」。信号可以是内核自身产生,也可以由某一个进程产生,籍由内核发送到另一个进程。信号分为不同的种类,每种信号都由一个整数 ID 来表示。在 *nix 系统中,使用 kill -l 查看所有信号列表:

信号的接收

当目的进程被内核强制以某种方式对信号的发送做出反应时,它就接收了这个信号。进程可能有下面的「反应」:

  • 忽略信号:什么也不做。

  • 终止 / 停止:终止或挂起当前进程。还可能转储内存到文件以供调试。

  • 执行一个信号处理程序。

类似中断的处理,内核有一套信号的处理与阻塞机制。在每个进程的上下文中,内核维护有挂起和阻塞向量——当信号 \(k\) 被传输来时,挂起向量第 \(k\) 位拉高;当信号被接收时拉低。而阻塞位拉高位的信号,则信号不会被接收(但也不会消失),直到阻塞取消,信号才会被处理。

特别地,信号不会排队(多个同类信号到来时,后来者被丢弃),而内核默认阻塞与当前正在处理信号同类的信号。

信号处理程序的设置

我们使用 signal() 函数设置某个信号的处理例程。函数原型是:

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

例如,signal(SIGINT, foobar) 将在程序收到 SIGINT 时,调用函数 foobar()

安全的信号处理

下面是编写信号处理程序(函数)的一些注意事项。

  • 处理程序尽可能简单。

  • 在处理程序中,只使用异步信号安全的函数。

  • 保存和恢复 errno

  • 阻塞所有信号。

  • volatile 声明全局变量。

信号量

信号量是一种具有非负整数值的全局变量,它有两种操作 PV

  • P(s):如果 s 非零,就将 s 减 1,然后立即返回。此操作是原子的,即它要么不做要么必须做完。如果 s 是零,则阻塞当前进程,直到 s 因为一个 V 操作增加。

    或者,参考下面的定义:

    wait(semaphore_t *s) {
      atomic s->value--;
      if (s->value < 0) {
        add current process to s->list;
        block();
      }
    }
    
  • V(s):将 s 加 1。此操作是原子的。同时,如果 s 此时正造成某个进程阻塞,那么将那个进程唤醒。

    或者,参考下面的定义:

    signal(semaphore_t *s) {
      atomic s->value++;
      if (s->value <= 0) {
        remove process p from s->list;
        wakeup(p);
      }
    }
    

对于值总是 0 或 1 的信号量,称为二元信号量。以提供互斥操作为目的的二元信号量称为互斥锁。以资源计数为目的的信号量称为计数信号量。

信号量的使用

引入信号量的目的是将每个共享变量与一个信号量联系起来,利用信号量非负的性质,将临界区「包」起来,保证多个进程不能同时占有共享变量。P 操作相当于「加锁」,而 V 操作相当于「解锁」。

例如,有共享变量

volatile int cnt = 0;

现在有多个进程都可能写入它。我们可以为这个变量定义和初始化一个互斥锁:

sem_t mutex;
sem_init(&mutex, 0, 1 /* initial value */ );

当需要操作 cnt 时,用 PV 操作加锁、解锁:

P(&mutex);
cnt++;
V(&mutex);

死锁

死锁是一种因多个进(线)程循环等待资源造成无法执行的恶性局面。例如:

甲:我有故事给我酒
乙:我有酒给我故事

结果甲一直在等酒,乙一直在等故事,双方僵持。

  • 互斥使用:至少有一个资源的使用是互斥的。

  • 不可抢占:资源只能自愿放弃。

  • 持有和等待:进程必须占有资源,然后再申请。

  • 循环等待:在资源分配图中存在一个环路。