基本概念

信号量是什么?

信号量(semaphore)是一种用于不同进程间或一个给定进程的不同线程间同步手段的原语。

也就是说,信号量用于进程间,或者线程间同步的。

三种类型信号量

信号量主要有三种类型,都可以用于进程或线程间同步:

  • Posix有名(named)信号量:使用Posix IPC名字标识;
  • Posix基于内存(memory-based)的信号量(又称,无名信号量 unnamed semaphore):放在单个进程的内存中,可用于线程间同步;放在共享内存中,可用于进程间同步;
  • System V信号量:内核维护;

对于使用者来说,它们并没有本质区别,而是接口不同导致的使用方式不同。Posix定义了新的接口,便于移植、使用;System V Release 4(简称SVR4)也是Unix众多系统实现之一。

Posix IPC名字标识
用来标识Posix有名信号量的路径名,可能是真正路径名(对应真实文件路径),也可能不是。
Posix IPC名字的要求:

  • 必须符合已有路径名规则(最多由PATH_MAX 字节构成,包括末尾null byte);
  • 如果以"/"开头,那么这些函数的不同调用将访问同一个队列。如果不是,那么效果取决于实现;
  • 名字中额外的"/"的解释由实现定义;

为了便于移植,使用"/"开头的Posix IPC名字,通常选择"/tmp/"作为路径目录,对应文件名不包含任何"/"。当然,程序必须对该目录具有读写权限。而对于有的系统没有"/tmp/"的情况,可以通过创建自定义函数px_ipc_name()(参见px_ipc_name.c),来解决这个问题。

二值信号量

二值信号量(binary semaphore):指值为0或1的信号量。值为1,代表资源可用(未被占用);值为0,代表资源不可用(已被占用)。

下面2个图分别展示了由2个进程使用的System V二值信号量,Posix有名二值信号量。

Linux Posix信号量、System V信号量,生产者与消费者问题应用_初值

Linux Posix信号量、System V信号量,生产者与消费者问题应用_信号量_02

System V信号量的维护是在内核中,Posix信号量的维护则不一定。因此,第一幅图更贴切System V二值信号量。
Posix有名信号量可能与文件系统中的路径名对应的名字来标识的,实际实现时,路径可能只是起到一个标识作用,信号量的值(如,0或1)不一定存放在该文件中,可能存放在内核的某处。

计数信号量

计数信号量(counting semaphore):指值为0和某个限制值(>= 32767 for Posix信号量)之间的信号量。信号量的值,代表着资源的可用数量。

信号量上的操作

一个进程可以在某个信号量上执行3种操作:

  1. 创建(create):创建一个信号量,要求调用者指定信号量初值。如,二值信号量初值为0或1;

  2. 等待(wait):操作会测试该信号量的值,如果其值 <= 0,那么就阻塞等待,等到值 >= 1时,就将它-1。这个操作最初称为P操作,也称为递减,或上锁(lock)

  3. 挂出(post):操作会将信号量的值+1。这个操作最初称为V操作,也成为递增解锁发信号(signal)

二值信号量与互斥锁

二值信号量可用于进程/线程互斥,类似于互斥锁。下面给出解决互斥问题的互斥锁和信号量:

初始化互斥锁mutex;                        初始化信号量sem为1;
pthread_mutex_lock(&mutex);              sem_wait(&sem);                // 等待
临界区                                    临界区
pthread_mutex_unlock(&mutex);            sem_post(&sem);                // 挂出

信号量、互斥锁、条件变量的区别

  1. 互斥锁必须总是由给它上锁的线程解锁,信号量的挂出却不必由执行过它等待操作的同一线程执行。

  2. 互斥锁要么被锁住,要么被解开(二值状态,相当于二值信号量)。

  3. 信号量有一个与之关联的状态(信号量的计数值),信号量的挂出操作总是被记住。然而,当向一个条件变量发送信号时,如果没有线程等待在该条件变量上,那么该信号将丢失。
    这是什么意思?
    比如,对于信号量,有个信号量sem_t s = 0, 线程P1: sem_wait(&s) ,表明P1在等待信号量s。线程P2: sem_post(&s),表明P2挂出信号量s。不论P1是否在等待s,P2都可以将s信号量值+1。
    对于条件变量,如果没有任何线程等待条件变量,线程P2发送signal信号也不会唤醒任何线程,信号将丢失。

既然互斥锁和条件变量也能实现线程/进程间的同步,为何还提供信号量?
Posix.1基本原理一文提到,

提供信号量的主要目的是提供一种进程间同步方式。这些进程可能共享也可能不共享(某个)内存区。这两者都是已经广泛使用多年的同步范式。每组原语都特别适合特定的问题。

也就是说,信号量的意图在于进程间同步,互斥锁和条件变量的意图在于线程间的同步。不过,它们都可以应用在进程间同步、线程间同步。应该使用适合具体应用的那组原语。

有名信号量与无名信号量

下图比较了这两类信号量使用的函数
Linux Posix信号量、System V信号量,生产者与消费者问题应用_#include_03

有名信号量使用函数

sem_open、sem_close、sem_unlink函数

sem_open

sem_open 创建一个新的有名信号量,或打开一个已存在的有名信号量。有名信号量既可用于线程间同步,又可用于进程间的同步。

#include <semaphore.h>

sem_t *sem_open(const char *name, int oflag, ... /* mode_t mode, unsigned int value */);

参数

  • name Posix IPC名称标识。
  • oflag 可以是0、O_CREAT或O_CREAT|O_EXCL。如果指定了O_CREAT,那么第三个参数(mode)、第四个参数(value)是需要的。
  • mode 参数指定权限位。
  • value 参数指定信号量的初始值。但该初值 <= SEM_VALUE_MAX( >= 32767)。二值信号量的初值通常为1,计数信号量的初值 > 1。

返回值
成功时,返回一个指向sem_t类型的指针。该指针随后用作sem_close、sem_wait、sem_trywait、sem_post及sem_getvalue的参数。
失败时,返回NULL。Posix早期草案指定使用SEM_FAILED(-1),即#define SEM_FAILED ((sem_t *)(-1))

注意:当打开某个有名信号量时,oflag参数并没有指定O_RDONLY、O_WRONLY或O_RDWR标志,但都要求对某个已存在的信号量具有读访问和写访问权限,这样对其调用sem_open才能成功。

sem_close

sem_close 关闭一个有名信号量。

#include <semaphore.h>

int sem_close(sem_t *sem);

一个进程终止时,内核对其海打开着的所有有名信号量自动执行关闭动作,而不论是进程自愿终止的(调用exit, _exit, main函数return),还是非自愿终止(收到Unix终止信号)。

sem_unlink

关闭一个有名信号量并没有将它从系统删除。即使当前没有坚持打卡某个信号量,其值仍然保持着。

使用sem_unlink将有名信号量从系统中删除:

#include <semaphore.h>

int sem_unlink(sem_t *sem);

每个信号量都有一个引用计数器记录当前的打开次数(就像文件一样),sem_unlink类似于文件I/O的unlink函数:当引用计数 > 0时,name就能从文件系统中删除,然而其信号量的析构(不同于将它的名字从文件系统中删除)要等到最后一个sem_close发生为止。

sem_wait、sem_trywait

sem_wait 测试所指定信号量的值,如果该值 > 0,那就-1并立即返回。 如果该值 == 0,调用线程就被置于休眠状态中,直到该值变为 > 0时,再-1,函数随后返回。

sem_wait和sem_trywait区别:当指定信号量值为0时,前者会将调用线程置于休眠状态,后者不会让线程休眠,而是直接返回EAGAIN错误。

如果被某个信号中断,sem_wait可能过早返回,返回的错误为EINTR。

#include <semaphore.h>

int sem_wait(sem_t *sem);
int sem_trywait(sem_t *sem);

sem_post、sem_getvalue

sem_post
当一个线程使用完信号量时,应该调用sem_post挂出信号量。sem_post函数将信号量值+1,然后唤醒正在等待该信号量的任意线程。

sem_getvalue
当需要读取信号量的值时,调用sem_getvalue,由valp指向的整数返回信号量的值。

#include <semaphore.h>

int sem_post(sem_t *sem);
int sem_getvalue(sem_t *sem, int *valp);

成功返回0,失败返回-1。

无名信号量使用函数

sem_init, sem_destroy

sem_init 初始化无名信号量
sem_destroy 销毁无名信号量

#include <semaphore.h>

int sem_init(sem_t *sem, int shared, unsigned int value);

int sem_destroy(sem_t *sem);

参数

  • sem 指向基于内存信号量变量;
  • shared 可选取值PTHREAD_PROCESS_PRIVATE(0,信号量用于线程间的同步),PTHREAD_PROCESS_SHARED(信号量用于进程间同步);
  • value 信号量初值;

返回值
成功,返回0;失败,返回-1.

sem_open 与sem_init主要区别:

  1. sem_open 需要Posix IPC名称标识参数,但不需要类似于shared的参数或PTHREAD_PROCESS_SHARED的属性,因为有名信号量总是可以在不同进程间共享。
  2. 对于给定信号量,sem_init只能初始化信号量一次,多次初始化结果未定义。而sem_open可以对同一信号量调用多次。

当不需要使用与有名信号量关联的名字时,可改用基于内存的信号量(无名信号量);彼此无亲缘关系的不同进程需要使用信号量时,通常使用有名信号量。名字就是各个进程标识信号量的手段。

生产者-消费者问题

生产者-消费者问题是线程、进程同步中的一个经典问题,也称为有界缓冲区(bounded buffer)问题。

一个或多个生产者(线程或进程)创建着一个个的数据条目,然后这些条目由一个或多个消费者(线程或进程)处理。数据条目在生产者和消费者之间是使用某种类型的IPC传递的。

Unix管道就是这样的问题:一端写入内容,另一端取出并处理内容。

最简单的生产者-消费者模型:
Linux Posix信号量、System V信号量,生产者与消费者问题应用_#include_04

处理这个模型问题的伪代码:

生产者                           消费者
信号量get初始化为0;              
信号量put初始化为1;

for (; ;) {                      for (; ;) {
    sem_wait(&put);                  sem_wait(&get);
    把数据放入缓冲区                   取出并处理缓冲区中的数据
    sem_post(&get);                  sem_post(&put);
}                                }

单个生产者-单个消费者问题

将共享缓冲区用作环形缓冲区,生产者填写最后一项buff[NBUFF-1]后,回到开头填写第一项buff[0],消费者也是这个访问顺序。

环形缓冲区3个约束条件:

  1. 当缓冲区为空时,消费者不能试图从其中去除一个条目;
  2. 当缓冲区为满时,生产者不能试图往其中放置一个条目;
  3. 共享变量可能描述缓冲区的当前状态(下标、计数、链表指针等),因此生产者和消费者的所有缓冲区都必须保护起来,以避免竞争状态;

接下来使用信号量方案,展现3种不同类型的信号量:

  1. 名为mutex的二值信号量,保护2个临界区:一个是往共享缓冲区插入数据条目(生产者执行);一个是从共享缓冲区移走数据条目(消费者执行)。用作互斥锁的二值信号量初值1(也可以使用互斥锁pthread_mutex_t替代);
  2. 名为nempty的计数信号量,统计共享缓冲区中的空槽位数。初值为缓冲区的槽位数;
  3. 名为nstored的计数信号量,统计共享缓冲区中的满槽位数(已填写数据条目)。初值0;

下图展示了初始化后的缓冲区和2个计数信号量的状态
Linux Posix信号量、System V信号量,生产者与消费者问题应用_互斥锁_05

过程示例:
生产者把0~(NLOOP-1) 存放到共享环形缓冲区中(i.e. buff[0]=0, buff[1]=1,...);
消费者从该缓冲区取出这些整数,并验证它们是否正确(buff[i] % NBUFF == i?),如有错误,就输出到标准输出上。

生产者放置了3个条目到缓冲区,消费者从中取出1个条目后,缓冲区和计数信号量的状态,如下图所示:
Linux Posix信号量、System V信号量,生产者与消费者问题应用_#include_06

main.c 数据结构定义,测试程序

#include <stdio.h>
#include <stdlib.h>
#include <semaphore.h>
#include <sys/fcntl.h>
#include <pthread.h>

#define   FILE_MODE (S_IRUSR|S_IWUSR|S_IRGRP|S_IROTH)

#define NBUFF 10
#define SEM_MUTEX "mutex"
#define SEM_NEMPTY "nempty"
#define SEM_NSTORED "nstored"

void *produce(void *), *consumer(void *);

int nitems; /* read-only by producer and consumer */
struct {
    int buff[NBUFF];
    sem_t mutex, nempty, nstored;
} shared;
int main(int argc, char *argv[])
{
    pthread_t tid_produce, tid_consumer;
    if (argc != 2) {
        fprintf(stderr, "Usage: prodcons2 <#items>\n");
        exit(1);
    }

    nitems = atoi(argv[2]);

    sem_init(&shared.mutex, 0, 1);
    sem_init(&shared.nempty, 0, NBUFF);
    sem_init(&shared.nstored, 0, 0);

    pthread_create(&tid_produce, NULL, produce, NULL);
    pthread_create(&tid_consumer, NULL, consumer, NULL);

    pthread_join(tid_produce, NULL);
    pthread_join(tid_consumer, NULL);

    sem_destroy(&shared.mutex);
    sem_destroy(&shared.nempty);
    sem_destroy(&shared.nstored);
    return 0;
}

main.c 生产者,消费者线程

void *produce(void *arg)
{
    int i;
    for (i = 0; i < nitems; ++i) {
        sem_wait(&shared.nempty); /* wait for at least 1 empty slot */
        sem_wait(&shared.mutex);
        shared.buff[ i % NBUFF] = i; /* store i into circular buffer */

        sem_post(&shared.mutex);
        sem_post(&shared.nstored);  /* 1 more stored item */
    }

    return NULL;
}

void *consumer(void *arg)
{
    int i;
    for (i = 0; i < nitems; ++i) {
        sem_wait(&shared.nstored); /* wait for at least 1 stored item */
        sem_wait(&shared.mutex);

        /* check if data stored in buffer is right */
        if (&shared.buff[i % NBUFF] != i) {
            printf("buff[%d] = %d\n", i, shared.buff[i % NBUFF]);
        }

        sem_post(&shared.mutex);
        sem_post(&shared.nempty);
    }

    return NULL;
}
参考

UNPv2 第10章~第11章