当我们编写的程序使用了线程时,不管它是运行在多用户、多进程、多用户多进程系统上,我们通常会发现,程序中有一部分临界代码,我们需要确保只有一个进程(或线程)可以进入这个邻居代码并拥有对资源独占式的访问权。
要想编写通用的代码,以确保程序对某个特定的资源具有独占式的访问权是非常困难的。虽然有一个名为Dekker算法的解决方法,但这个算法依赖于“忙等待”或“自旋锁”。也就是说,一个进程要持续不断地运行以等待某个内存位置被改变。在像Linux这样的多任务环境中,人们并不愿意使用这种浪费CPU资源的处理方法。
荷兰计算机科学家Edsger Djjkstra提出的信号量概念是在并发编程领域迈出的重要一步。
信号量比较正式的定义是:它是一个特殊变量,只允许对它进行等待(wait) 和发送信号(signal)这两种操作。因为在Linux编程中,“等待”和“发送信号”都已具有特殊的含义,所以我们将用原先定义的符号来表示这两种操作。
这两个字母分别来自于荷兰语单词passeren(传递,就好像位于进入临界区域之前的检查点)和vrijgeven (给予或释放,就好像放弃对临界区域的控制权)。
最简单的信号量是只能取值0和1的变量,即二进制信号量。这也是信号量最常见的一种形式。 可以取多个正整数值的信号量被称为通用信号量。
PV操作的定义非常简单。假设有一一个信号量变量sv,则这两个操作的定义如下表所示。
P(sv) | V(sv) |
---|---|
如果sv的值大于0,就给它减去1;如果它的值等于0,就挂起该进程的执行 | 如果由其他进程因等待sv而被挂起,就让它恢复运行;如果没有进程因等待sv而被挂起,就给它加1 |
Linux 系统中的信号量接口经过了精心设计,它提供了比通常所需更多的机制。所有的Linux信号量函数都是针对成组的通用信号量进行操作,而不是只针对一个二进制信号量。乍看起来,这好像把事情弄得更复杂了,但在一个进程需要锁定多个资源的复杂情况中,这种能够对一组信号量进行操作的能力是一-个巨大的优势。
信号量函数的定义如下所示:
#include <sys/sem.h> int semctl(int sem_id, int sem_num, int command, ...); int semget(key_t key, int num_sems, int sem_flage); int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
semget函数的作用是创建一个新信号量或取得一个已有信号量的键:
int semget(key_t key, int num_sems, int sem_flage);
第一个参数key是整数值,不相关的进程可以通过它访问同一个信号量。程序对所有信号量的访问都是间接的,它先提供一个键,再由系统生成一个相应的信号量标识符。只有semget函数才直接使用信号量键,所有其他的信号量函数都是使用由senget函数返回的信号量标识符。
有一个特殊的信号量键值IPC_PRIVATE, 它的作用是创建一个只有创建者进程才可以访问的信号量,但这个键值很少有实际的用途。在创建新的信号量时,你需要给键提供一个唯一的非零整数。
参数key的作用很像一个文件名,它代表程序可能要使用的某个资源,如果多个程序使用相同的key值,它将负责协调工作。与此类似,由semget 函数返回的并用在其他共享内存函数中的标识符也与fopen返回的FILE*文件流很相似,进程需要通过它来访问共享文件。此外,类似于文件的使用情况,不同的进程可以用不同的信号量标识符来指向同一个信号量。
num_sems参数指定需要的信号量数目。它几乎总是取值为1。
sem_flags参数是一组标志,它与open函数的标志非常相似。它低端的9个比特是该信号量的权限,其作用类似于文件的访问权限。此外,它们还可以和值IPC_CREAT做按位或操作,来创建一个新信号量。即使在设置了IPC_CREAT标志后给出的键是一个已有信号量的键,也不会产生错误。如果函数用不到IPC_CREAT标志,该标志就会被悄悄地忽略掉。我们可以通过联合使用标志IPC_CREAT和IPC_EXCL来确保创建出的是-一个新的、唯一的信号量。 如果该信号量已存在,它将返回一个错误。
semget函数在成功时返回一个正数(非零)值,它就是其他信号量函数将用到的信号量标识符。如果失败,则返回-1。
semop函数用于改变信号量的值,它的定义如下所示:
int semop(int sem_id, struct sembuf *sem_ops, size_t num_sem_ops);
第一个参数sem_id是由semget返回的信号量标识符。第二个参数sem_ops是指向一个结构数组的指针,每个数组元素至少包含以下几个成员:
struct sembuf{ short sem_num; short sem_op; short sem_flg; }
第一个成员sem_num是信号量编号,除非你需要使用一组信号量,否则它的取值一般为0。 sem_op 成员的值是信号量在一次操作中需要改变的数值(你可以用一个非1的数值来改变信号量的值)。通常只会用到两个值,一个是-1,也就是P操作,它等待信号量变为可用;一个是+1,也就是V操作,它发送信号表示信号量现在已可用。
最后一个成员sem_flg通常被设置为SEM_UNDO。 它将使得操作系统跟踪当前进程对这个信号量的修改情况,如果这个进程在没有释放该信号量的情况下终止,操作系统将自动释放该进程持有的信号量。除非你对信号量的行为有特殊的要求,否则应该养成设置sem_flg为SEM_UNDO的好习惯。如果决定使用一个非SEM_UNDO的值,那就一定要注意保持设置的一致性, 否则你很可能会搞不清楚内核是否会在进程退出时清理信号量。
semop调用的一切动作都是一次性完成的, 这是为了避免出现因使用多个信号量而可能发生的竞争现象。semop的处理细节可以在手册页中找到。
semctl函数用来直接控制信号量信息,它的定义如下所示:
int semctl(int sem_id, int sem_num, int command, ...);
第一个参数sem_id是由senget返回的信号量标识符。sem_num参数是信号量编号,当需要用到成组的信号量时,就要用到这个参数,它一般取值为0,表示这是第一个也是唯一的一个信号量。comnand参数是将要采取的动作。如果还有第四个参数,它将会是一个union semun结构,根据X/OPEN规范的定义,它至少包含以下几个成员:
union semun{ int val; struct semid_ds *buf; unsigned short *array; }
虽然X/Open规范中指出,semun联合结构必须由程序员自己定义,但大多数Linux版本会在某个头文件(一般是sem.h)中给出该结构的定义。如果你发现确实需要自已来定义该结构,请查阅semctl的手册页,看手册中是否已给出了定义。如果有,我们建议使用手册中给出的定义,即使它与这里给出的定义不一致也应该如此。
semctl函数中的command参数可以设置许多不同的值,但只有下面介绍的两个值最常用。
semctl函数将根据cormand参数的不同而返回不同的值。对于SETVAL和IPC_RMID,成功时返回0,失败时返回-1。
上面的介绍可以看出,信号量的操作相当复杂。在编写临界区的多进程或多线程本身就是一件非常困难的事情,加上如此复杂的编程接口。
但大部分需要使用信号量解决的问题只需使用一个简单的二进制信号量即可。
我们将用程序sem1.c来试验信号量,该程序可以被多次调用。我们通过一个可选的参数来指定程序是负责创建信号量还是负责删除信号量。
我们用两个不同字符的输出来表示进入和离开临界区域。如果程序启动时带有一个参数,它将在进入和退出临界区域时打印字符X;而程序的其他运行实例将在进入和退出临界区域时打印字符O。因为在任一给定时刻, 只能有一个进程可以进入临界区域,所以字符X和O应该是成对出现的。
#include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <sys/sem.h> #include "semun.h" //sys/sem.h中未定义所需的 union semun时 static int set_semvalue(void); //初始化信号量 static void del_semvalue(void);//删除信号量 static int semaphore_p(void);//P() static int semaphore_v(void);//V() static int sem_id; //信号量标识符 int main(int argc, char* argv[]) { int i; int pause_time; char op_char = 'O'; srand((unsigned int)getpid()); sem_id = semget((key_t)1234, 1, 0666 | IPC_CREAT); if(argc > 1){ if(!set_semvalue()){ fprintf(stderr, "Failed to initialize semaphore\n"); exit(EXIT_FAILURE); } op_char = 'X'; sleep(2); } for(i = 0; i < 10; i++){ if(!semaphore_p()) exit(EXIT_FAILURE); printf("%c", op_char);fflush(stdout); pause_time = rand() % 3; sleep(pause_time); printf("%c", op_char);fflush(stdout); if(!semaphore_v()) exit(EXIT_FAILURE); pause_time = rand() % 2; sleep(pause_time); } 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(sem_id, 0, SETVAL, sem_union) == -1) return (0); return (1); } static void del_semvalue(void) { union semun sem_union; sem_union.val = 1; if(semctl(sem_id, 0, SETVAL, 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(sem_id, &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(sem_id, &sem_b, 1) == -1){ fprintf(stderr, "semaphore_v failed\n"); return (0); } return (1); }
union semun{ int val; struct semid_ds *buf; unsigned short *array; };
字符“O”和“X”分别代表程序的第一个和第二个调用实例。因为每个程序都在其进入和离开临界区域时打印一个字符,所以每个字符都应该成对出现。如你所见,字符O和X是成对出现的,这表明对临界区域的处理是正确的。如果这个程序在你的系统上不能正常工作,你可能需要在启动程序之前执行命令stty -tostop, 以确保产生tty输出的后台程序不会引发系统生成一个信号。
在程序的开始,我们用semget函数通过一个(随意选取的)键来取得一个信号量标识符。IPC_CREAT标志的作用是:如果信号量不存在,就创建它。
如果程序带有一个参数,它就负责信号量的初始化工作,这是通过set_semvalue函数来完成的,该函数是针对更通用的semctl函数的简化接口。程序还将根据是否带有参数来决定需要打印哪个字符。sleep函数的作用是,让我们有时间在这个程序实例执行太多次循环之前调用其他的程序实例。我们用的数srand和rand来为程序引入一些伪随机形式的时间分配。
接下来程序循环10次,在临界区域和非临界区域会分别暂停一段随机的时间。临界区域由semaphore_p和semaphore_v函数前后把守,它们是更通用的semop函数的简化接口。
删除信号量之前,带有参数启动的程序会进入等待状态,以允许其他调用实例都执行完毕。如果不删除信号量,它将继续在系统中存在,即使没有程序在使用它也是如此。在实际的编程中,我们需要特别小心,不要无意之中在执行结束之后还留下信号量未删除。它可能会在你下次运行此程序时引发问题,而且信号量也是一种有限的系统资源,需要节约使用。