更通俗易懂的解释 linux 信号量是什么怎么用?
简介
代码临界区
在多个程序共享资源的情况下, 常常会因为多个程序同时访问一个共享资源而引发一系列问题。为了防止这一问题, 引入了代码临界区的概念。
代码临界区是指操作系统在处理时不可分割的代码。在上述情况下, 即指: 在任一时刻, 只能有一个程序访问临界区并执行代码。
信号量提供了这样一种访问机制。简单来说, 信号量的功能就是用来协调进程对共享资源的访问。
工作原理
程序对信号量 s 的访问都是原子操作, 且只允许它进行 P (占用) 和 V (释放) 信号量两种操作。最常见的信号量取值范围是 0 或 1, 即二进制信号量, 可以取多个正整数的信号量被称为通用信号量。
P(s)和V(s)操作是这样的:
- P(s): 如果s的值大于零, 就给它减1; 如果它的值为零, 就挂起该进程;
- V(s): 如果有其他进程因s被占用而被挂起, 就让它恢复运行, 如果没有, 就给它加1.
例子: 如图所示, p1 和 p2 两个进程共享信号量s, p1进程执行了P(s)操作, 它将得到信号量, 并可以进入临界区, 使s减1。而 p2 进程将被阻止进入临界区, 因为当它试图执行P(s)时, s为0, 它会被挂起以等待。当p1 进程离开临界区域并执行V(s)释放信号量, 这时 p2 进程可以恢复执行
经典问题
哲学家进餐问题
桌子上有五个餐盘, 5 个餐叉, 餐叉分布在餐盘的两边。 现在有5 个哲学家, 哲学家可以思考或进食, 但是进食时必须同时使用盘子两边的餐叉。
philosophy(i){
repeat{
think();
eat();
}
}
哲学家之间的进食行为属于互斥行为, 共享的资源是餐叉。当一个哲学家进食时, 由于他必须使用餐盘两边的叉子, 那么与该哲学家相邻的另外两个哲学家因为缺少一个餐叉无法进食。
- fork[5]: 信号量集[0…4]
- 对于第 i 个哲学家:
- 其右手边的餐叉为: i
- 其左手边的餐叉为: (i+1)%5
- 初始化信号量集 fork[] 为 1: init(fork[i],1)
philosophy(i){
repeat{
think();
P(fork[i]);
P(fork[(i+1)%5];
eat();
V(fork[i]);
V(fork[(i+1)%5];
}
}
算法规定: 每个哲学家先拿起右手边的餐叉, 再拿起左手边的餐叉。这时, 如果 5 个哲学家同时开始进食, 每个哲学家都拿起了右手边的餐叉, 这意味着每个哲学家左手边的餐叉都被另一个哲学家占用, 这样导致没有一个哲学家能吃上饭。这就导致了死锁。
我们进一步思考一下。当所有哲学家同时想要进食时, 会造成死锁, 那么 4 个呢?进一步分析, 当 4 个哲学家同时拿起他们右边的餐叉, 必定有一个哲学家左手边的餐叉是空闲的, 那么这位哲学家就可以进食, 其他三位则需要等待资源, 当这位哲学家吃完放下餐叉, 则等待中的哲学家能够进食。
所以不妨限制同一时间可以发起进食请求的哲学家数量。使同一时间, 桌子边上想要吃饭的哲学家最多只能为 4 人。
- demande: 信号量, 初始化为 4: init(demande,4)
则上面的算法修正后就避免了死锁问题:
philosophy(i){
repeat{
think();
P(demande);
P(fork[i]);
P(fork[(i+1)%5];
V(demande);
eat();
V(fork[i]);
V(fork[(i+1)%5];
}
}
作者-读者问题
作者-读者之间的关系, 概括地来说, 即为: 读写互斥, 写写互斥, 读读并发。
算法 1
假设现在有一个文件:
- 若作者 a 正在写文件, 另一个作者 b 想要修改, 则加入文件等待队列, 直到 a 完成写操作并释放资源
- 若作者 a 正在写文件, 读者 A 想要修改, 则加入文件等待队列, 直到 a 完成写操作并释放资源
- 若读者 A 正在读文件, 作者 a 想要修改, 则加入文件等待队列; 需要注意的是, 当所有读者都完成读操作时, 才释放文件资源
我们设定:
- file: 信号量, 初始为 1: init(file,1)
- nb_reader: 正在读文件的读者数量, 初始为 0
- mutex: 信号量, 初始为 1: init(mutex,1) 控制多个读者之间的转换
author(i){
P(file);
write();
V(file);
}
reader(i){
P(mutex);
nb_reader = nb_reader + 1;
if(nb_reader == 1){
P(file);
}
V(mutex);
read()
P(mutex);
nb_reader = nb_reader - 1;
if(nb_reader == 0){
V(file);
}
V(mutex);
}
注意-1: 等待队列只是指请求发生的先后顺序, 并不意味着请求被处理的顺序。实际上, 当等待的请求获得它们所需的资源后, 即可被执行。
比如说, 假设读者 A 正在阅读, 作者 a 发起写请求, 由于需要的 file 资源被读者 A 占用, 所有作者 a 等待; 此时, 若另一个读者 B 发起读请求, 由于 mutex 资源闲置, 所以读者 B 可以进行读操作。
注意-2 假设读者 A 正准备释放文件资源, 同时读者 B (新到来的读请求) 和作者 a 都在等待资源, 由于读者 B 等待的 mutex 资源后于作者 a 等待的 file 资源释放, 所以会先处理作者 a 的请求。
算法 2
在上面的算法中, 如果一个读请求后于一个写请求来到, 但是却有可能先于读请求操作。如果我们想要读写请求按到来的先后顺序进行, 则可以在上述算法的基础上, 添加一个 order 信号量。
- order: 信号量, 初始为 1: init(order, 0)
author(i){
P(order);
P(file);
write();
V(file);
V(order);
}
reader(i){
P(order);
P(mutex);
nb_reader = nb_reader + 1;
if(nb_reader == 1){
P(file);
}
V(mutex);
V(order);
read()
P(mutex);
nb_reader = nb_reader - 1;
if(nb_reader == 0){
V(file);
}
V(mutex);
}
这种情况下, 若有读者正在读, 且有作者正在等待, 由于 order 资源被等待的作者占用 (作者等待 file 资源) , 而后面来到的读请求需要等待 ordre 资源,所以后来到的读请求不会先于写请求被处理。
算法 3
在算法 1 中, 假设作者 a 释放资源, 同时读者 A 和作者 b 都在等待资源, 由于读者 A 和作者 b都在等待 file 资源, 因此无法得知谁的请求先被处理。假设为了保证为读者服务的质量, 要求在此情况下, 要让读请求优先于写请求被处理。在算法 1 的基础上进行改动:
我们设定一个新的信号量, 对该情况下的写请求进行阻碍:
- writebar: 信号量, 初始为 1: init(writebar,1)
author(i){
P(writebar);
P(file);
write();
V(file);
V(writebar);
}
reader(i){
P(mutex);
nb_reader = nb_reader + 1;
if(nb_reader == 1){
P(file);
}
V(mutex);
read()
P(mutex);
nb_reader = nb_reader - 1;
if(nb_reader == 0){
V(file); // V(writebar);
}
V(mutex);
}
注意-3: writebar 资源释放可以在 author 中, 也可以在 reader 中, 只要保证后于 file 资源释放就可以了。根据算法 3, 假设作者 a 正准备释放文件资源, 同时读者 A 和作者 b 都在等待资源, 由于读者 A 等待的 file 资源先于作者 b 等待的 writebar 资源释放, 所以会先处理读者 A 的请求。
算法 4
假设现在我们要求读操作也优先于写操作: 即如果一个读请求发生, 则它被加入到等待队列, 直到没有进行或等待的写操作。
- fifo: 信号量, 初始化为 1: init(fifo, 1)
- nb_author: 正在执行的作者的数量
- mutex2: 信号量, 初始化为 1: init(fifo, 1); 作者之间进行转换
author(i){
P(mutex2);
nb_author = nb_author + 1;
if(nb_author == 1){
P(fifo);
}
V(mutex2);
P(file);
write();
V(file);
P(mutex2);
nb_author = nb_author - 1;
if(nb_author == 0){
V(fifo);
}
V(mutex2);
}
reader(i){
P(fifo);
P(mutex);
nb_reader = nb_reader + 1;
if(nb_reader == 1){
P(file);
}
V(mutex);
P(fifo);
read()
P(mutex);
nb_reader = nb_reader - 1;
if(nb_reader == 0){
V(file); // V(writebar)
}
V(mutex);
}
若作者 a 正在执行写操作,其他作者可以增加 nb_author, 并等待 file 权限;当所有等待队列中的写操作执行完毕,假设作者 n 正在释放 fifo 资源(即便有一个新的写请求,因为它需要的 mutex2 资源后于 fifo 释放), 正在等待的读者会获取 fifo 权限。
生产者-消费者问题
生产者-消费者问题中既存在互斥关系,又存在同步关系。
- 互斥:生产者向缓冲区内添加数据,消费者读取缓冲区内的数据。两者对缓冲区的操作互斥,一方修改缓冲区时,另一方不能修改。
- 同步:缓冲区里面数据不为空,消费者才能读取数据;生产者向缓冲添加数据,缓冲区才不为空。
假设生产者想大小为 BUFFER_SIZE 的缓冲区添加整型数据,消费者按照数据被生产的顺序从缓冲区内进行读取。生产者和消费者同时进行(并行执行)。
- BUFFER_SIZE:缓冲区大小
- product :信号量,初始化为 N; 控制生产者生产
- consuption:信号量,初始化为 0; 控制消费者消费
prod(i){
P(product);
produce(tampon[ip])
ip = (ip + 1) % BUFFER_SIZE
V(consuption);
}
cons(i){
P(consuption);
consume(tampon[ic])
ic = (ic + 1) % BUFFER_SIZE
V(product);
}
- 生产者至少生产一次后,才会释放消费者所需的 consuption 资源
- ip 和 ic 分别控制生产者和消费者在缓冲区的位置
Linux 信号量接口
Linux提供了一组信号量接口来对信号进行操作, 这些函数都是用来对成组的信号量值进行操作。它们的执行需要声明头文件:
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
semget()
作用: 创建一个新信号量或取得一个已有信号量
原型:
int semget(key_t key, int num_sems, int sem_flags);
- 返回值:
- 成功: 信号量集标识符 shm_id
- 出错: -1, 错误原因存于error中
- 传入参数:
- key:
0(IPC_PRIVATE)
: 建立新信号量集对象大于0的32位整数
: 视参数sem_flgs来确定操作, 通常要求此值来源于ftok返回的IPC键值
- num_sems: 创建信号量集中信号量的个数, 该参数只在创建信号量集时有效
- sem_flags:
0
: 取信号量集标识符, 若不存在则函数会报错IPC_CREAT
: 当sem_flgs&IPC_CREAT为真时, 如果内核中不存在键值与key相等的信号量集, 则新建一个信号量集; 如果存在这样的信号量集, 返回此信号量集的标识符IPC_CREAT|IPC_EXCL
: 如果内核中不存在键值与key相等的信号量集, 则新建一个消息队列; 如果存在这样的信号量集则报错- sem_flgs参数为模式标志参数, 使用时需要与
IPC对象存取权限
(如0600) 进行|运算来确定信号量集的存取权限
- key:
- 错误代码:
- EACCESS: 没有权限
- EEXIST: 信号量集已经存在, 无法创建
- EIDRM: 信号量集已经删除
- ENOENT: 信号量集不存在, 同时semflg没有设置IPC_CREAT标志
- ENOMEM: 没有足够的内存创建新的信号量集
- ENOSPC: 超出限制
semop()
-
作用: 对信号量集标识符为semid中的一个或多个信号量进行P操作或V操作
-
原型:
int semop(int sem_id, struct sembuf *sem_opa, size_t num_sem_ops);
-
返回值:
- 成功: 信号量集标识符
- 出错: -1, 错误原因存于error中
-
传入参数:
-
sem_id
: 信号量集标识符 -
num_sem_ops
: 进行操作信号量的个数, 即 sem_opa 结构变量的个数, 需大于或等于1。最常见设置此值等于1, 只完成对一个信号量的操作 -
sem_opa
指向进行操作的信号量集结构体数组的首地址-
semnum
: 信号量集合中的信号量编号, 0代表第1个信号量 -
val
:
- 若val>0进行V操作信号量值加val, 表示进程释放控制的资源
- 若val<0进行P操作信号量值减val
- 若(semval-val)<0 (semval为该信号量值) , 则调用进程阻塞, 直到资源可用;
- 若设置 IPC_NOWAIT 不会睡眠, 进程直接返回 EAGAIN 错误
-
flag
:
- 0 设置信号量的默认操作
- IPC_NOWAIT 设置信号量操作不等待
- SEM_UNDO 选项会让内核记录一个与调用进程相关的UNDO记录, 如果该进程崩溃, 则根据这个进程的UNDO记录自动恢复相应信号量的计数值
struct sembuf { short semnum; short val; short flag; };
-
-
-
错误代码:
- E2BIG: 一次对信号量个数的操作超过了系统限制
- EACCESS: 权限不够
- EAGAIN: 使用了IPC_NOWAIT, 但操作不能继续进行
- EFAULT: sops指向的地址无效
- EIDRM: 信号量集已经删除
- EINTR: 当睡眠时接收到其他信号
- EINVAL: 信号量集不存在,或者semid无效
- ENOMEM: 使用了SEM_UNDO, 但无足够的内存创建所需的数据结构
- ERANGE: 信号量值超出范围
semctl()
作用: 直接控制信号量信息
原型:
int semctl(int sem_id, int sem_num, int command, union semun arg);
-
返回值:
- 成功: 大于或等于0
- 出错: -1, 错误原因存于error中
-
传入参数:
-
sem_id
: 信号量集标识符 -
sem_num
: 信号量集数组上的下标, 表示某一个信号量 -
command
:- IPC_STAT: 从信号量集上检索semid_ds结构, 并存到semun联合体参数的成员buf的地址中
- IPC_SET: 设置一个信号量集合的semid_ds结构中ipc_perm域的值, 并从semun的buf中取出值
- IPC_RMID: 从内核中删除信号量集合
- GETALL: 从信号量集合中获得所有信号量的值, 并把其整数值存到semun联合体成员的一个指针数组中
- GETNCNT: 返回当前等待资源的进程个数
- GETPID: 返回最后一个执行系统调用semop()进程的PID
- GETVAL: 返回信号量集合内单个信号量的值
- GETZCNT: 返回当前等待100%资源利用的进程个数
- SETALL: 与GETALL正好相反
SETVAL
: 用联合体中val成员的值设置信号量集合中单个信号量的值
-
arg
:-
val
: SETVAL用的值 -
semid_ds: IPC_STAT、IPC_SET用的semid_ds结构
-
array: SETALL、GETALL用的数组值
-
seminfo: 为控制IPC_INFO提供的缓存
union semun { short val; struct semid_ds* buf; unsigned short* array; struct seminfo *buf; }arg;
-
-
-
错误代码:
- EACCESS: 权限不够
- EFAULT: arg指向的地址无效
- EIDRM: 信号量集已经删除
- EINVAL: 信号量集不存在, 或者semid无效
- EPERM: 进程有效用户没有cmd的权限
- ERANGE: 信号量值超出范围
应用
以下代码用 C 语言实现,并在 Linux 环境下运行。
Linux 信号量实现
初始化一个数量为 5 的信号量集,定义信号量集初始化、删除、更改信号量集中某个信号量的值,以及P(s)和 V(s)操作:
// semaph.h
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <time.h>
#define N_SEM 5 // 信号量集中信号量的数目
int init_semaphore(void); // 初始化信号量集
int detruire_semaphore(void); // 删除信号量集
int val_sem(int, int); // 更改信号量集中某个信号量的值
int P(int);
int V(int);
// semaph.c
# include "semaph.h"
static int semid = -1; // 信号量集标识符
// struct sembuf {short semnum; short val; short flag;};
static struct sembuf
sops_p = {-1, -1, 0},
sops_v = {-1, 1, 0};
union semun
{
int val;
};
int init_semaphore(){
int i;
union semun arg0;
if(semid != -1)
{
fprintf(stderr, "init_semaphore: Semaphores deja initialises\n");
return -1;
}
if( (semid = semget(IPC_PRIVATE, N_SEM, 0600)) == -1)
{
fprintf(stderr, "init_semaphore/semget: Echec %d\n", errno);
return -2;
}
arg0.val = 0;
for(i = 0; i < N_SEM; i++)
if( (semctl(semid, i, SETVAL, arg0)) == -1)
{
fprintf(stderr, "init_semaphore/semctl: Echec\n");
return -2;
}
return 0;
}
int detruire_semaphore(){
int i;
int retour;
if(semid == -1)
{
fprintf(stderr, "detruire_semaphore: semaphore inexistant\n");
return -1;
}
retour = semctl(semid, 0, IPC_RMID, 0);
semid = -1;
return retour;
}
int val_sem(int sem, int val){
union semun arg0;
if(semid == -1)
{
fprintf(stderr, "val_sem: Semaphore inexistant\n");
return -1;
}
if(sem < 0 || sem >=N_SEM){
fprintf(stderr, "val_sem: Num de semaphore inexistant\n");
return -2;
}
arg0.val = val;
return semctl(semid, sem, SETVAL, arg0);
}
int P(int sem){
if(semid == -1)
{
fprintf(stderr, "P: Sémaphore inexistant\n");
return -1;
}
if(sem < 0 || sem >=N_SEM)
{
fprintf(stderr, "P: Numéro de sémaphore inexistant\n");
return -2;
}
sops_p.sem_num = sem;
return semop(semid, &sops_p, 1);
}
int V(int sem){
if(semid == -1)
{
fprintf(stderr, "V: Semaphore inexistant\n");
return -1;
}
if(sem < 0 || sem >=N_SEM)
{
fprintf(stderr, "V: Num de semaphore inexistant\n");
return -2;
}
sops_v.sem_num = sem;
return semop(semid, &sops_v, 1);
}
生产者-消费者
该问题使用上述创建的semaph.h
和semaph.c
。
- 创建一个父子进程,子进程作为生产者,向一个大小为 5 的缓冲区内添加 int 数据。父进程作为消费者,按照数据添加顺序读取缓冲区内数据
- 当缓冲区数据为空时,消费者无法进行消费;当缓冲区数据为满时,生产者无法添加数据
- 该问题采用上面提到的生产者-消费者问题算法
共享内存创建缓冲区
// sharemem.h
#include <sys/time.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define SHMSZ 5 * sizeof(int)
int createshm(void);
int* bindshm(int);
// sharemem.c
#include "sharemem.h"
int createshm(void){
int shmid;
key_t key;
key = 2017;
shmid = shmget(key, SHMSZ, IPC_CREAT|0666);
if (shmid < 0){
perror("sharemem.c/createshm: shmget\n");
exit(1);
}
printf("Creer Shmid = %d\n", shmid);
return shmid;
}
int* bindshm(int shmid){
int *shm;
if((shm=shmat(shmid,NULL,0)) == (int*)-1){
perror("sharemem.c/bindshm: Echec\n");
exit(1);
}
return shm;
}
主程序
// prod-conso.c
#include "semaph.h"
#include "sharemem.h"
#include <wait.h>
#define BUFFER_SIZE 5
int main(int argc, char *argv[])
{
int shmid = createshm();
int *tampon = bindshm(shmid);
init_semaphore();
val_sem(2,BUFFER_SIZE);
val_sem(3,0);
int i = 0 ;
switch (fork()) {
case -1:
perror("prod-conso/fork: Echec\n");
break;
case 0:
for(i;i<10;i++){
P(2);
tampon[i%BUFFER_SIZE] = i + 1;
printf("produit: %d \n",tampon[i%BUFFER_SIZE]);
V(3);
usleep(((rand()%(100-20))+20)*1000);
}
break;
default:
for(i;i<10;i++){
usleep(((rand()%(100-20))+20)*1000);
P(3);
printf("consomme: %d \n",tampon[i%BUFFER_SIZE]);
V(2);
}
}
wait(NULL);
detruire_semaphore();
shmdt(tampon);
shmctl(shmid,IPC_RMID,0);
return 0;
}
程序执行和检测
为了运行主程序,我们需要使用之前创建的两个库文件: semaph.c
和 sharemem.c
使用ar命令将两个库文件集合成单一的备存文件
// 将程序预处理,编译,和汇编, 生成.o的obj文件
gcc -c semaph.c
gcc -c sharemem.c
// 合成单一的备存文件
ar rvs libsempv.a semaph.o sharemem.o
// 查看 libsempv.a
nm -s libsempv.a
// 生成可执行文件
gcc -o prod-conso prod-conso.c -L. libsempv.a
//执行文件
./prod-conso
);
P(3);
printf(“consomme: %d \n”,tampon[i%BUFFER_SIZE]);
V(2);
}
}
wait(NULL);
detruire_semaphore();
shmdt(tampon);
shmctl(shmid,IPC_RMID,0);
return 0;
}
#### 程序执行和检测
为了运行主程序,我们需要使用之前创建的两个库文件: `semaph.c` 和 `sharemem.c`
使用ar命令将两个库文件集合成单一的备存文件
// 将程序预处理,编译,和汇编, 生成.o的obj文件
gcc -c semaph.c
gcc -c sharemem.c
// 合成单一的备存文件
ar rvs libsempv.a semaph.o sharemem.o
// 查看 libsempv.a
nm -s libsempv.a
// 生成可执行文件
gcc -o prod-conso prod-conso.c -L. libsempv.a
//执行文件
./prod-conso
文件执行结果:
[[外链图片转存中...(img-eVVpdhgh-1585228746003)]](https://yanyanie.com/2019/05/16/base-linux-sem/prod-conso.png)
-------------E N D-------------