进程间通信简介(六)——信号量

        当我们编写的程序使用了线程时,不管它是运行在多用户系统上、多进程系统上,还是运行在多用户进程系统上,我们通常会发现,程序中存在着一部分临界代码,我们需要确保只有一个进程(或一个执行线程)可以进入这个临界代码并拥有对资源独占式的访问权。比如多个程序试图在同一时间更新某个数据库,数据就可能会遭到破坏。两个不同的程序要求不同的用户向数据库输入数据,这本身没有问题,但对数据库进行更新的那部分代码可能就有问题。这部分真正执行数据更新的代码需要独占式地执行,它们被称为临界区域。它们通常只在一个大型程序中占据一小段的代码。

        为了防止出现因多个程序同时访问一个共享资源而引发的问题,需要有一种方法可以通过生成并使用令牌来授权,在任一时刻只能有一个执行线程访问代码的临界区域(线程可以通过互斥量或信号量来控制对临界区域的访问)。这里将介绍信号量的方法来规避这一问题(注意,这里的信号量函数比用于线程的信号量函数更通用)。

        要想编写通用的代码,以确保程序对某个特定的资源具有独占式的访问权是非常困难的,虽然有一个叫Dekker算法的解决方法,但这个算法依赖于“忙等待”或“自旋锁”。也就是说,一个进程要持续不断地运行以等待某个内存位置被改变。在像Linux这样的多任务环境中,人们并不愿意使用这种浪费CPU资源的处理方法。但如果硬件支持独占式访问(一般是通过特定的CPU指令的形式),那么情况就变得简单多了。一个硬件支持的例子就是,用一条指令以原子方式访问并增加寄存器的值,在这个读取/增加/写入操作执行的过程中不会有其它指令(甚至一个中断)发生。我们可能还见过另一种解决方法是,使用带O_EXCL标志的open函数来创建锁文件,它提供了原子化的文件创建方法。它允许一个进程通过获取一个令牌(即新创建的文件)来取得成功。这个方法比较适合于处理简单的问题,但对于更复杂的例子,它显得比较杂乱且缺乏效率。所以荷兰计算机科学家Edsger Dijkstra提出的信号量概念在并发编程领域迈出了很重要的一步。

        信号量的原理是一种数据操作锁的概念,它本身不具备数据交换的功能,而是通过控制其他的信号资源(如文件、外部设备等)来实现进程间的通信。信号量本身不具备数据传输的功能,它只是一种外部资源的标识。

        信号量的一个更正式的定义是:它是一个特殊变量,只允许对它进行等待(wait)和发送信号(signal)这两种操作。因为在Linux编程中,“等待”和“发送信号”都已具有特殊的含义,所以我们将用原先定义的符号来表示这两种操作。

  • P(信号量变量):用于等待。
  • V(信号量变量):用于发送信号。
        这两个字母分别来自于荷兰单词passeren(传递,就好像位于进入临界区域之前的检查点)和vrijgeven(给予或释放,就好像放弃对临界区域的控制权)。在与信号量关联的内容中,你可能还会看到术语“开(up)”和“关(down)”,它们取自开、关信号标志的用法。

6.1 信号量的概念

        信号量(Semaphore),有时也被称为信号灯,是在多进程环境下使用的一种设施,它负责协调各个进程,以保证它们能够正确、合理地使用公共资源。信号量分为单值和多值两种,前者只能被一个进程获得,后者可以被若干个进程获得。
        以一个停车场为例,假设停车场只有三个车位,一开始三个车位都是空的。这时如果同时来了五辆车,看门人允许其中三辆直接进入,然后放下车栏,剩下的车则必须在入口等待,在此之后的车也都不得不在入口处等待。突然,有一辆车离开停车场,看门人得知后打开车栏,放入外面的一辆进去,如果又离开两辆,则又可以放入两辆,如此反复。在这个停车场系统中,车位是公共资源,每辆车好比一个进程,看门人所起的就是信号量的作用。
        信号量,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,进程必须获得一个信号量;一旦该关键代码段完成了,那么该进程必须释放信号量。其它想进入该关键代码段的进程必须等待直到第一个进程释放信号量。
        抽象地讲,信号量的特性如下:信号量是一个非负整数(车位数),所有通过它的进程/线程(车辆)都会将整数减1(通过它当然是为了使用公共资源),当该整数值为零时,所有试图通过它的进程都将处于等待状态。在信号量上我们定义两种操作:Wait(等待)和Release(释放)。当一个进程调用Wait操作时,它要么得到资源然后将信号量减1,要么一直等下去(指放入阻塞队列),直到信号量大于等于1时。Release(释放)实际上是在信号量上执行加1操作,对于车辆离开停车场,该操作之所以叫做“释放”是因为释放了由信号量守护的资源。
        事实上,在信号量的实际应用中,是不能单独定义一个信号量的,而只能定义一个信号量集,其中包含一组信号量,同一个信号量集中的信号量使用同一个引用ID,这样的设置是为了多个资源或同步操作的需要。每个信号量集都有一个与之对应结构,其中记录了信号量集的各种信息,该结构的定义如下:
struct semid_ds
{
	struct ipc_perm sem_perm;	/* opeation permission struct */
	struct sem *sem_base;		/* ptr to first semaphore in set */
	unsigned short sem_nsems;	/* numbers of semaphores in set */
	time_t sem_otime;		/* last semop time */
	time_t sem_ctime;		/* last change time */
};
        sem结构记录了一个信号量的信息,其定义如下:
struct sem
{
	unsigned short semval;		/* semaphore value, always >= 0 */
	pid_t sempid;			/* pid for last operation */
	unsigned short semncent;	/* numbers of processes awaiting semval > currval */
	unsigned short semzcnt;		/* numbers of processes awaiting semval = 0 */
};
        接下来用一个简单的理论性的例子来继续阐明信号量的工作原理。假设有两个进程proc1和proc2,这两个进程都需要在其执行过程中的某一时刻对一个数据库进行独占式的访问。我们定义一个二进制信号量sv,该变量的初始值为1,两个进程都可以访问它。要想对代码中的临界区域进行访问,这两个进程都需要执行相同的处理步骤,事实上,这两个进程可以只是同一个程序的两个不同执行实例。两个进程共享信号量变量sv。一旦其中一个进程执行了P(sv)操作,它将得到信号量,并可以进入临界区域。而第二个进程将被阻止进入临界区域,因为当它试图执行P(sv)操作时,它会被挂起以等待第一个进程离开临界区域并执行V(sv)操作释放信号量。需要的伪代码对两个进程都是相同的,如下所示:
semaphore sv = 1;

loop forever {
P(sv);
critical code section;
V(sv);
nocritical code section;
}
        这段代码相当简单,这是因为PV操作的功能非常强大,下图显示了PV操作是如何把守代码中的临界区域的。

6.2 信号量集的相关操作

6.2.1 创建或打开信号量集

        使用函数semget可以创建或者获得一个信号量集ID,原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semget (key_t key, int nsems, int semflg);
        若成功返回信号量集ID,否则返回-1。
        此函数可以用于创建一个新的信号量集,或打开已存在的信号量集。
        参数key是整数值,不相关的进程可以通过它访问同一个信号量。程序对所有信号量的访问都是间接的,它首先提供一个键,再由系统生成一个相应的信号量标识符。只有semget函数才直接使用信号量键,所有其它的信号量函数都是使用semget函数返回的信号量标识符。有一个特殊的信号量键值IPC_PRIVATE,它的作用是创建一个只有创建者进程才可以访问的信号量,但这个键值很少有实际的用途。在创建新的信号量时,需要给键提供一个唯一的非零整数。
        参数nsems指定需要的信号量数目,几乎总是取值为1。
        参数semflg是一组标志,它与open函数的标志非常相似。它低端的9个比特是该信号量的权限,其作用类似于文件的访问权限。此外,它们还可以和值IPC_CREAT做按位或操作来创建一个新的信号量。即使在设置了IPC_CREAT标识后给出的键是一个已有信号量的键也不会产生错误。如果函数用不到IPC_CREAT标志,该标志就会被悄悄地忽略掉。我们可以通过联合使用标志IPC_CREAT和IPC_EXCL来确保创建出的是一个新的、唯一的信号量。如果该信号量已存在,它将返回一个错误。
        另外,当semget成功创建一个新的信号量集时,它相应的semid_ds结构被初始化。ipc_perm结构中的成员被设置为相应的值,sem_nsems设置为函数参数nsems的值,sem_otime被设置为0,sem_ctime设置为系统当前时间。

6.2.2 对信号量集的操作

        函数semop用以操作一个信号量集,原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semop (int semid, struct sembuf *sops, size_t num_sem_ops);
        若成功返回0,否则返回-1。semop调用的一切动作都是一次性完成的,这是为了避免出现因使用多个信号量而可能发生的竞争现象。
        第一个参数semid是由semget返回的信号量标识符。第二个参数sops是指向一个结构数组的指针,每个数组元素至少包含以下几个成员:
struct sumbuf
{
	short sem_num;
	short sem_op;
	short sem_flg;
};
        sem_num是信号量编号(其值是一个从0到相应的信号量集的资源总数,即ipc_perm.sem_nsems之间的整数),除非你需要使用一组信号量,否则它的取值一般为0。sem_op成员的值是信号量在依次操作中需要改变的数值(你可以用一个非1的数值来改变信号量的值)。通常只会用到两个值,一个是-1,也就是P操作,它等待信号量变为可用;一个是+1,也就是V操作,它发送信号表示信号量现在已可用。最后一个成员sem_flg通常被设置为SEM_UNDO。它将使得操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程持有的信号量。除非你对信号量的行为有特殊的要求,否则应该养成设置sem_flg为SEM_UNDO的好习惯。如果决定使用一个非SEM_UNDO的值,那就一定要注意保持设置的一致性,否则你很可能会搞不清楚内核是否会在进程退出时清理信号量。
        这里再仔细讲讲sembuf结构中的成员sem_op的取值问题,它的取值及相应的操作说明如下:
  • sem_op > 0:表示进程对资源使用完毕,释放相应的资源数,并将sem_op的值加到信号量的值上。
  • sem_op = 0:进程阻塞直到信号量的相应值为0,当信号量已经为0,函数立即返回。如果信号量的值不为0,则依据sem_flg的IPC_NOWAIT位决定函数动作。sem_flg指定IPC_NOWAIT,则semop函数出错返回EAGAIN。sem_flg没有指定IPC_NOWAIT,则将信号量的semncnt值减1,然后进程挂起直到下述情况发生。信号量值为0,将信号量的semncnt值减1,函数semop成功返回;此信号量被删除(只有超级用户或创建用户进程拥有此权限),函数semop出错返回EIDRM;进程捕捉到此信号,并从信号处理函数返回,在此情况下将信号量的semncnt值减1,函数semop出错返回EINTR。
  • sem_op < 0:其取sem_op的绝对值的资源,如果相应的资源数可以满足请求,则将该信号量的值减去sem_op的绝对值,函数成功返回。当相应的资源数不能满足请求时,这个操作与sem_flg有关。sem_flg指定IPC_NOWAIT,则semop函数出错返回EAGAIN。sem_flg没有指定IPC_NOWAIT,则该信号量的semncnt值加1,然后进程挂起直到下述情况发生。当相应的资源数可以满足请求时,该信号的值减去sem_op的绝对值,成功返回;此信号量被删除(只有超级用户或创建用户进程拥有此权限),函数semop出错返回EINRM;进程捕捉到信号,并从信号处理函数返回,在此情况将此信号量的semncnt值减1,函数semop出错返回EINTR。

6.2.3 信号量集的控制

        和共享内存的控制一样,信号量集也有自己的专属控制函数semctl,原型如下:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int semctl (int semid, int semnum, int cmd, ...);
        若成功返回大于等于0(当semctl的操作为GET操作时返回相应的值,其余返回0),否则返回-1,并设置错误变量errno。
        参数semid是由semget返回的信号量标识符。参数semnum是信号量编号,当需要用到成组的信号量时,就要用到这个参数,它一般取值为0,表示这是第一个也是唯一的一个信号量。参数cmd是将要采取的动作。如果还有第四个参数,它将会是一个union semun结构,根据X/OPEN规范的定义,它至少包含以下几个成员:
union semun
{
	int val;
	struct semid_ds *buf;
	unsigned short *array;
};
        虽然X/Open规范中指出,semun联合结构必须由程序员自己定义,但大多数Linux版本会在某个文件(一般是sem.h)中给出该结构的定义。如果你发现确实需要自己来定义该结构,可以查阅semctl的手册页,看手册中是否已给出了定义。如果有,建议使用手册中给出的定义。
        表6-2-3-1中,cmd参数指定表中十种命名中的一种,使其在semid指定的信号量集合上执行此命名。其中有五条命令是针对一个特定的信号量值的,它们用semnum指定该集合的一个成员。semnum值在0和nsems - 1之间(包括0和nsems - 1)。
表6-2-3-1 cmd的取值及含义
取值含义
GETALL获得信号量集semid中信号量的个数,并将该值赋值给无符号短整数arg.array
GETVAL获得信号量集semid中semnum所指定信号量的值semval
GETNCNT获得信号量集semid中等待给定信号量锁的进程数目,即semid_ds结构中sem.semncnt的值
GETPID获得信号量集semid中最后一个使用semop函数的进程ID,即semid_ds结构中sem.sempid的值
GETZCNT获得信号量集semid中等待信号量称为0的进程数目,即semid_ds结构中sem.semzcnt的值
IPC_RMID删除信号量集。此操作只能由具有超级用户的进程或信号量拥有者的进程执行,这个操作会影响到正在使用信号量集的进程
IPC_SET按照参数arp.buf指向的结构体中的值设置此信号量集的sem_perm.uid、sem_perm.gid及sem_perm.mode的值。此操作只能由具有超级用户的进程或信号量拥有者的进程执行
IPC_STAT获得该信号量的semid_ds结构,保存在arg.buf指向的缓冲区
SETALL以arg.array的值设置信号量集semid中信号量的个数
SETVAL以arg.val的值设置信号量集semid中semnum所指定信号量的值semval。其作用是在信号量第一次使用之前对它进行设置
        常用的值已用红色标注。
        接下来演示使用信号量在不同进程间通信。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

#include <sys/sem.h>

#include "semun.h"

static int set_semvalue(void);
static void del_semvalue(void);
static int semaphore_p(void);
static int semaphore_v(void);

static int semid;

int main(int argc, char **argv)
{
        int i;
        int ptime;
        char opch = 'O';

        srand((unsigned int)getpid());
        semid = semget((key_t)1234, 1, 0666 | IPC_CREAT);
        if (argc > 1)
        {
                if (!set_semvalue())
                {
                        fprintf(stderr, "failed to initialize semaphore\n");
                        exit(EXIT_FAILURE);
                }
                opch = 'X';
                sleep(2);
        }

        for (i = 0; i < 10; i++)
        {
                if (!semaphore_p())
                        exit(EXIT_FAILURE);
                printf("%c", opch);
                fflush(stdout);
                ptime = rand() % 3;
                sleep(ptime);
                printf("%c", opch);
                fflush(stdout);

                if (!semaphore_v())
                        exit(EXIT_FAILURE);
                ptime = rand() % 2;
                sleep(ptime);
        }

        printf("\n%d - finished\n", getpid());
        if (argc > 1)
        {
                sleep(10);
                del_semvalue();
        }

        exit(EXIT_SUCCESS);
}

static int set_semvalue(void)
{
        union semun sem_union;

        sem_union.val = 1;
        if (semctl(semid, 0, SETVAL, sem_union) == -1)
                return 0;
        return 1;
}

static void del_semvalue(void)
{
        union semun sem_union;

        if (semctl(semid, 0, IPC_RMID, sem_union) == -1)
                fprintf(stderr, "failed to delete semaphore\n");
}

static int semaphore_p(void)
{
        struct sembuf sem_b;

        sem_b.sem_num = 0;
        sem_b.sem_op = -1;
        sem_b.sem_flg = SEM_UNDO;
        if (semop(semid, &sem_b, 1) == -1)
        {
                fprintf(stderr, "semaphore_p failed\n");
                return 0;
        }
        return 1;
}

static int semaphore_v(void)
{
        struct sembuf sem_b;

        sem_b.sem_num = 0;
        sem_b.sem_op = 1;
        sem_b.sem_flg = SEM_UNDO;
        if (semop(semid, &sem_b, 1) == -1)
        {
                fprintf(stderr, "semaphore_v failed\n");
                return 0;
        }
        return 1;
}
        编译并运行:
$ ./sem_com 1 &
[1] 11740
$ ./sem_com
OOXXOOXXOOXXOOXXOOOOXXOOXXOOXXOOXXOOXXXX
11741 - finished
11740 - finished
$
        注意,字符“X”和“O”分别代表第一个和第二个调用实例。因为每个程序都在其进入和离开临界区域时打印一个字符,所以每个字符都应该成对出现。如你所见,字符O和X都是成对出现的,这表明对临界区域的处理是正确的。如果这个程序在你的系统上不能正常工作,你可能需要在启动程序之前执行命令stty -tostop,以确保产生tty输出的后台程序不会引发系统生成一个信号。


整理自 《Linux程序设计第4版》、《Linux C编程从初学到精通》。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值