System V IPC信号量和POSIX信号量详解

1.System V IPC信号量

1.信号量原语

多个进程同时访问系统上某个资源时,如同时写一个数据库的某条记录,或同时修改某个文件,就需要考虑进程同步问题,以确保任一时刻只有一个进程可以拥有对资源的独占式访问。通常,进程对共享资源的访问的代码只是很短的一段**,但这段代码引发了进程之间的竞态条件,我们称这段代码为关键代码区,或临界区,对进程同步,就是确保任一时刻只有一个进程能进入关键代码段。**

Dekker算法和Peterson算法试图从语言本身(不需要内核支持)解决进程同步问题,但它们依赖于忙等待,即进程要持续不断地等待某个内存位置状态的改变,这种方式的CPU利用率太低,不可取。

Dijkstra提出的信号量(Semaphore)是一种特殊的变量,它只能取自然数值且只支持两种操作:等待(wait)和信号(signal)。但在Linux/UNIX中,等待和信号都已经具有特殊含义,所以对信号量的这两种操作更常用的称呼是P、V操作,这两个字母来自荷兰语单词passeren(传递,就好像进入临界区)和vrijgeven(释放,就好像退出临界区)。

假设有信号量SV,对它的P、V操作含义如下:

  • P(SV),如果SV的值大于0,就将它减1,如果SV的值为0,则挂起进程的执行。
  • V(SV),如果有其他进程因为等待SV而挂起,则唤醒之,如果没有,则将SV加1。

信号量的取值可以是任何自然数,但最常用的、最简单的信号量是二进制信号量,它只能取0或1两个值,我们仅讨论二进制信号量。使用二进制信号量同步两个进程,以确保关键代码段的独占式访问的例子:

img

上图中,当关键代码段可用时,二进制信号量SV的值为1,进程A和B都有机会进入关键代码段,如果此时进程A执行了P(SV)操作将SV减1,则进程B再执行P(SV)操作就会被挂起,直到进程A离开关键代码段,并执行V(SV)操作将SV加1,关键代码段才重新变得可用。

不能使用普通变量来模拟二进制信号量,因为所有高级语言都没有一个原子操作可以同时完成以下两步操作:检测变量是否为true/false,如果是则将它设置为false/true。

Linux信号量的API定义在sys/sem.h头文件中,主要包括3个系统调用:semgetsemopsemctl。它们被设计为操作一组信号量,即信号量集,而不是单个信号量。

2.semget 系统调用

semget系统调用创建一个新的信号量集,或获取一个已经存在的信号量集。

1
2
#include <sys/sem.h>
int semget(key_t key, int num_sems, int sem_flags);
参数

key:键值,用来标志全局唯一的信号量集,要通过信号量通信的进程需要使用相同的键值来创建/获取该信号量。

num_sems:指定要创建/获取的信号量集中信号量的数目,如果是创建信号量,该值必须指定,如果是获取已经存在的信号量,该值可以设置为0。

sem_flags:指定一组标志,低端的9个bite是信号量的权限,格式和含义与openmode参数一致。此外,它可以和IPC_CREAT标志做按位或运算以创建新的信号量集。还可以联合使用IPC_CREATIPC_EXCL标志确保创建新的、唯一的信号量集,如果这时候该信号量集已经存在,semget返回错误并设置errno为EEXIST

返回值

semget成功返回一个正整数,也就是信号量集的标识符,失败返回-1并设置errno。

如果用semget创建一个新的信号量集,与之相关的内核数据结构体semid_ds将被创建并初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct semid_ds {
struct ipc_perm sem_perm; /* 信号量操作权限 */
unsigned long int sem_nsems; /* 该信号量集中的信号量数目 */
time_t sem_otime; /* 最后一次调用 semop 的时间 */
time_t sem_ctime; /* 最后一次调用 semctl 的时间 */
/* 省略其他填充字段 */
}:

struct ipc_perm{
key_t key; /* 键值 */
uid_t uid; /* 所有者的用户id */
gid_t gid; /* 所有者的组id */
uid_t cuid; /* 创建者的用户id */
git_t cgid; /* 创建者的组id */
mode_t mode; /* 访问权限 */
/* 省略其他填充字段 */
};

img

3.semop 系统调用

semop系统调用改变信号量的值,即执行P、V操作,在讨论semop函数前,先介绍与每个信号量关联的一些重要的内核变量:

1
2
3
4
unsigned short semval; 			/* 信号量的值 */
unsigned short semzcnt; /* 等待信号量变为0的进程数量 */
unsigned short semncnt; /* 等待信号量值增加的进程数量 */
pid_t sempid; /* 最后一次执行 semop 操作的进程ID */

semop函数对信号量的操作实际就是改变上述内核变量的操作,该函数定义如下:

1
2
#include <sys/sem.h>
int semop(int sem_id, struct sembuf* sem_ops, size_t num_sem_ops);
参数

sem_idsemget调用返回的信号量集标识符,指定被操作的目标信号量集。

sem_ops:指向一个 sembuf 类型结构体的数组:

1
2
3
4
5
6
struct sembuf
{
unsigned short int sem_num;
short int sem_op;
short int sem_flg;
};
  • sem_num:信号量集中信号量的编号,0代表信号量集的第一个信号量,以此类推。
  • sem_op:指定操作类型,可选值:正整数,0、负整数,同时受到 sem_flg的影响。op>0执行V操作,op小于0执行P操作
    • 通常P操作值为 - 1,V操作值为 1
  • sem_flg:可选值为IPC_NOWAIT,SEM_UNDO,0
    • IPC_NOWAIT代表非阻塞操作SEM_UNDO代表撤销操作0代表阻塞操作
    • sem_flg设置为IPC_NOWAIT时,如果信号量操作(如sem_op中的减法操作使得信号量的值小于 0)不能立即执行,操作不会阻塞等待信号量状态改变,而是立即返回一个错误,错误码通常为EAGAIN。这种方式适用于不希望进程在信号量操作上长时间阻塞的场景,例如在一些对实时性要求较高的应用中,当获取不到信号量时可以先去执行其他任务。
    • sem_flg设置为SEM_UNDO时,系统会记录信号量操作,以便在进程异常终止时自动撤销(调整)信号量的值,以避免信号量状态被错误地锁定或者资源无法释放的情况。例如,如果一个进程对信号量进行了P操作(减操作)获取资源后异常终止,没有来得及进行V操作(加操作)释放资源,设置了SEM_UNDO的信号量系统会自动进行适当的调整,保证信号量状态的正确性。
    • sem_flg为 0 时,信号量操作会按照正常的阻塞方式执行。对于P操作(sem_op为负数),如果信号量的值不够减,进程会被阻塞,直到信号量的值满足操作要求(例如其他进程进行了V操作增加了信号量的值)。这种方式在需要确保资源按照顺序被访问和操作,且允许进程等待资源可用的场景下非常有用,比如在经典的生产者 - 消费者模型中,消费者进程等待生产者生产出产品(通过信号量控制),此时使用阻塞式操作可以保证消费者在没有产品时等待,直到生产者生产出产品后再继续执行。

num_sem_ops:指定要执行的操作个数,即sem_ops数组中元素的个数。semop函数对sem_ops数组参数中的每个成员按数组顺序依次执行操作,且该过程是原子操作,以避免别的进程在同一时刻按不同顺序对该信号集中的信号量执行semop函数导致的竞态条件。

返回值

semop成功返回0,失败返回-1并设置errno。

sem_op值的不同操作规则:

  • sem_op 大于 0 时,表示进程要增加信号量的值。操作要求调用进程对被操作信号量集拥有写权限。若设置了 SEM_UNDO 标志,系统将更新进程的 semadj 变量。
  • sem_op 等于 0 时,表示这是一个 “等待 0” 操作。操作要求调用进程对被操作信号量集拥有读权限。如果信号量的值为 0,调用立即成功;如果不是 0,则操作失败或阻塞进程直到信号量变为 0。在这种情况下,当 IPC_NOWAIT 标志被指定时,操作立即返回一个错误,并设置 errnoEAGAIN。若未指定 IPC_NOWAIT 标志,信号量的 semncnt 值加 1,进程将被投入睡眠直到满足特定条件。
  • sem_op 小于 0 时,表示对信号量值进行减操作,即期望获得信号量。操作要求调用进程对被操作信号量集拥有写权限。如果信号量的值 semval 大于或等于 sem_op 的绝对值,操作成功,调用进程立即获得信号量,并且系统将该信号量的 semval 值减去 sem_op 的绝对值。若设置了 SEM_UNDO 标志,则系统将更新进程的 semadj 变量。

4.semctl 系统调用

semctl系统调用允许调用者对信号量进行直接控制:

1
2
#include <sys/sem.h>
int semctl(int sem_id, int sem_num, int command, ...);

参数:

  • sem_id参数是由semget调用返回的信号量集标识符,用于指定被操作的信号量集。
  • sem_num参数指定被操作的信号量在信号量集中的编号。
  • command参数指定要执行的命令,有些命令需要调用者传递第 4 个参数。

第四个参数可以自定义,但是系统给出了推荐的定义格式:

1
2
3
4
5
6
7
union semun
{
int val;// 用于SETVAL命令
struct semid_ds *buf;// 用于IPC_STAT和IPC_SET命令
unsigned short *array;// 用于GETALL和SETALL命令
struct seminfo *__buf;// 用于IPC_INFO命令
};
1
2
3
4
5
6
7
8
struct seminfo
{
int semmap;// Linux内核没有使用
int semmni;// 系统最多可以拥有的信号量集数目
int semmns;// 系统最多可以拥有的信号量数目
int semmnu;// Linux内核没有使用
int semmsl;// 一个信号量集最多允许包含的信号量数目
};

返回值:

  • semctl成功时的返回值取决于command参数,失败时返回 - 1,并设置errno

image-20241220111936954

注意事项

GETNCNTGETPIDGETVALGETZCNTSETVAL操作中,操作的是单个信号量,此时sem_num参数指定单个信号量在信号量集中的编号。而其他操作针对的是整个信号量集,此时sem_num参数被忽略。

5.特殊键值 IPC_PRIVATE

semget的调用者可以给其key参数传递一个特殊键值IPC_PRIVATE(其值为0),这样无论该信号量是否已存在,semget函数都将创建一个新信号量,使用该键值创建的信号量并非像它的名字声称的那样是进程私有的,其他进程,尤其是子进程,也有方法来访问这个信号量,所以semget函数的man手册的BUGS部分上说,使用名字IPC_PRIVATE有些误导(历史原因),应称为IPC_NEW

6.信号量实现进程间通信

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <string.h>
#include <unistd.h>

// 定义链表节点结构体
typedef struct ListNode {
int data;
struct ListNode *next;
} ListNode;

// 定义信号量操作结构体
union semun {
int val;
struct semid_ds *buf;
unsigned short int *array;
struct seminfo *__buf;
};

// 声明全局的链表头指针
ListNode *head = NULL;

// 信号量操作函数
void semaphore_op(int semid, int sem_num, int op) {
struct sembuf sem_b;
sem_b.sem_num = sem_num;
sem_b.sem_op = op;
sem_b.sem_flg = 0;
if (semop(semid, &sem_b, 1) == -1) {
perror("semop");
exit(1);
}
}

// 生产者函数
void producer(int semid) {
ListNode *head = NULL; // 链表头指针
int item = 0;
while (1) {
// 申请节点内存
ListNode *new_node = (ListNode *)malloc(sizeof(ListNode));
if (new_node == NULL) {
perror("malloc");
exit(1);
}
new_node->data = item++;

//P操作 获取空闲缓冲区信号量(这里表示链表插入的空位)
semaphore_op(semid, 0, -1);

// 将新节点插入链表头部(简单实现,可按需改为其他插入方式)
new_node->next = head;
head = new_node;
printf("生产了一个产品\n");
// V操作 增加产品信号量(表示链表中有新数据可供消费)
semaphore_op(semid, 1, 1);

sleep(1);
}
}

// 消费者函数
void consumer(int semid) {
while (1) {
semaphore_op(semid, 1, -1); // P操作 获取产品信号量(链表中有数据才可消费)

// 取出链表头节点进行消费
ListNode *node_to_consume = NULL;
if (head!= NULL) {
node_to_consume = head;
head = head->next;
printf("Consumer: %d\n", node_to_consume->data);
free(node_to_consume); // 释放消费完的节点内存
}
printf("消费了一个产品\n");
semaphore_op(semid, 0, 1); // V操作 增加空闲缓冲区信号量(链表腾出空位)

sleep(2);
}
}

int main() {
// 创建信号量集
int semid = semget(IPC_PRIVATE, 2, 0666 | IPC_CREAT);
if (semid == -1) {
perror("semget");
return 1;
}
union semun arg;
arg.val = 1;
// 初始化空闲缓冲区信号量(初始有1个空位可插入链表节点)
if (semctl(semid, 0, SETVAL, arg) == -1) {
perror("semctl");
return 1;
}
arg.val = 0;
// 初始化产品信号量(初始链表无数据可供消费)
if (semctl(semid, 1, SETVAL, arg) == -1) {
perror("semctl");
return 1;
}

pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
// 子进程为消费者
consumer(semid);
} else {
// 父进程为生产者
producer(semid);
}

// 删除信号量集
if (semctl(semid, 0, IPC_RMID) == -1) {
perror("semctl");
return 1;
}

return 0;
}

1. 数据结构定义

定义了ListNode结构体来表示链表的节点,包含一个int类型的数据成员用于存放生产者生产的数据,以及一个指向下一个节点的指针成员。

2. 信号量操作相关部分

  • union semun结构体:用于给semctl函数传递参数,根据不同的命令可以传递不同类型的值,在这里主要用于初始化信号量的值。
  • semaphore_op函数:封装了semop函数,用于对信号量进行操作。它接受信号量集标识符、信号量编号以及操作值作为参数,构造struct sembuf结构体并调用semop函数来执行信号量操作,操作失败时会输出错误信息并终止程序。

3. 生产者逻辑

  • producer函数中,首先定义了链表头指针head,并在循环中不断生产数据。每次生产时,先通过malloc函数申请一个新的链表节点内存空间,将数据存入节点。
  • 然后通过semaphore_op函数获取空闲缓冲区信号量(这里代表链表中可插入新节点的空位),接着将新节点插入到链表头部(简单实现了链表插入操作,实际可根据需求调整插入逻辑),再通过semaphore_op函数增加产品信号量,表示链表中有新的数据可供消费者消费,最后通过sleep函数模拟生产过程的时间间隔。

4. 消费者逻辑

  • consumer函数中,通过循环不断尝试消费数据。首先通过semaphore_op函数获取产品信号量,只有当链表中有数据(信号量值大于等于 1)时才能继续执行。
  • 接着取出链表头节点,将其数据打印出来模拟消费过程,然后释放该节点占用的内存空间,最后通过semaphore_op函数增加空闲缓冲区信号量,表示链表腾出了一个空位可供生产者插入新节点,同样通过sleep函数模拟消费过程的时间间隔。

5. 主函数部分

  • main函数中,通过semget函数创建包含两个信号量的信号量集,分别用于控制空闲缓冲区(链表插入空位)和产品(链表中可消费的数据)。
  • 使用semctl函数结合union semun结构体来初始化这两个信号量的初始值。
  • 通过fork函数创建子进程,子进程执行consumer函数作为消费者,父进程执行producer函数作为生产者。
  • 最后在程序结束时,通过semctl函数删除信号量集,释放相关系统资源。

这样就通过 System V IPC 信号量实现了一个基于链表作为共享数据结构的生产者 - 消费者模型,确保了生产者和消费者对链表的并发访问是安全有序的。

运行结果:

image-20241220115519889

2.POSIX信号量

1.概述

在Linux上,信号量API有两组,一组是System V IPC信号量(信号量),另一组是我们要讨论的POSIX信号量。这两组接口很相似,且语义完全相同,但不保证能互换。

进化版的互斥锁(1 –> N)

由于互斥锁的粒度比较大,如果我们希望在多个线程间对某一对象的部分数据进行共享,使用互斥锁是没有办

法实现的,只能将整个数据对象锁住。这样虽然达到了多线程操作共享数据时保证数据正确性的目的,却无形中导

致线程的并发性下降。线程从并行执行,变成了串行执行。与直接使用单进程无异。

信号量,是相对折中的一种处理方式,既能保证同步,数据不混乱,又能提高线程并发

POSIX信号量函数的名字都以sem_开头,不像大多线程函数那样以pthread_开头。常用的POSIX信号量函数如下:

1
2
3
4
5
6
#include <semaphore.h>
int sem_init(sem_t* sem, int pshared, unsigned int value); /* 初始化一个未命名信号量 */
int sem_destory(sem_t* sem); /* 销毁信号量 */
int sem_wait(sem_t* sem); /* 以原子操作的方式将信号量的值减1 */
int sem_trywait(sem_t *sem); /* 相当于sem_wait函数的非阻塞版本 */
int sem_post(sem_t *sem); /* 以原子操作的方式将信号量的值加1 */

上图中函数的第一个参数sem指向被操作的信号量。

  • sem_init函数用于初始化一个未命名信号量(POSIX信号量API支持命名信号量,但本书不讨论)。pshared参数指定信号量类型,如果值为0,就表示这个信号量是当前进程的局部信号量,否则该信号量可以在多个进程间共享。value参数指定信号量的初始值。初始化一个已经被初始化的信号量将导致不可预期的结果。
  • sem_destroy函数用于销毁信号量,以释放其占用的内核资源,销毁一个正被其他线程等待的信号量将导致不可预期的结果。
  • sem_wait函数以原子操作的方式将信号量的值减1,如果信号量的值为0,则sem_wait函数将被阻塞,直到这个信号量具有非0值。
  • sem_trywait函数与sem_wait函数类似,但它始终立即返回,而不论被操作的信号量是否具有非0值,相当于sem_wait函数的非阻塞版本。当信号量非0时,sem_trywait函数对信号量执行减1操作,当信号量的值为0时,该函数返回-1并设置errno为EAGAIN
  • sem_post函数以原子操作的方式将信号量的值加1,当信号量的值从0变为1时,其他正在调用sem_wait等待信号量的线程将被唤醒。

上图中的函数成功时返回0,失败则返回-1并设置errno。

2.对应函数

1.总览

1
2
3
4
5
6
7
8
 #include<semaphore.h>
sem_t sem;
sem_init 函数
sem_destroy 函数
sem_wait 函数
sem_trywait 函数
sem_timedwait 函数
sem_post 函数

以上 6 个函数的返回值都是:成功返回 0, 失败返回-1,同时设置 errno。(注意,它们没有 pthread 前缀)

sem_t 类型,本质仍是结构体。但应用期间可简单看作为整数,忽略实现细节(类似于使用文件描述符)。

规定信号量 sem 不能 < 0。

信号量基本操作:

sem_wait:

1.信号量大于 0,则信号量– (类比 pthread_mutex_lock)

2.信号量等于 0,造成线程阻塞

对应

sem_post: 将信号量++,同时唤醒阻塞在信号量上的线程 (类比 pthread_mutex_unlock)

但,由于 sem_t 的实现对用户隐藏,所以所谓的++、–操作只能通过函数来实现,而不能直接++、–符号。

信号量的初值,决定了占用信号量的线程的个数。

2.初始化和销毁

初始化一个信号量

1
int sem_init(sem_t *sem, int pshared, unsigned int value);

参 1:sem 信号量

参 2:pshared 取 0 用于线程间;取非 0(一般为 1)用于进程间

参 3:value 指定信号量初值

sem_destroy 函数

销毁一个信号量

1
int sem_destroy(sem_t *sem);

3.PV操作主要函数

sem_wait 函数

给信号量加锁 –

1
int sem_wait(sem_t *sem);

sem_post 函数

给信号量解锁 ++

1
int sem_post(sem_t *sem);

sem_trywait 函数

尝试对信号量加锁 – (与 sem_wait 的区别类比 lock 和 trylock)

1
int sem_trywait(sem_t *sem);

sem_timedwait 函数

限时尝试对信号量加锁 –

1
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

参 2:abs_timeout 采用的是绝对时间。

1
2
3
4
5
6
定时 1 秒:
time_t cur = time(NULL); 获取当前时间。
struct timespec t; 定义 timespec 结构体变量 t
t.tv_sec = cur+1; 定时 1
t.tv_nsec = t.tv_sec +100;
sem_timedwait(&sem, &t); 传参

3.实现生产者消费者

流程

image-20241219212728460

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/*信号量实现 生产者 消费者问题*/

#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <stdio.h>
#include <semaphore.h>

#define NUM 5

int queue[NUM]; //全局数组实现环形队列
sem_t blank_number, product_number; //空格子信号量, 产品信号量

void *producer(void *arg)
{
int i = 0;

while (1) {
sem_wait(&blank_number); //生产者将空格子数--,为0则阻塞等待
queue[i] = rand() % 1000 + 1; //生产一个产品
printf("----Produce---%d\n", queue[i]);
sem_post(&product_number); //将产品数++

i = (i+1) % NUM; //借助下标实现环形
sleep(rand()%1);
}
}

void *consumer(void *arg)
{
int i = 0;

while (1) {
sem_wait(&product_number); //消费者将产品数--,为0则阻塞等待
printf("-Consume---%d\n", queue[i]);
queue[i] = 0; //消费一个产品
sem_post(&blank_number); //消费掉以后,将空格子数++

i = (i+1) % NUM;
sleep(rand()%3);
}
}

int main(int argc, char *argv[])
{
pthread_t pid, cid;

sem_init(&blank_number, 0, NUM); //初始化空格子信号量为5, 线程间共享 -- 0
sem_init(&product_number, 0, 0); //产品数为0

pthread_create(&pid, NULL, producer, NULL);
pthread_create(&cid, NULL, consumer, NULL);

pthread_join(pid, NULL);
pthread_join(cid, NULL);

sem_destroy(&blank_number);
sem_destroy(&product_number);

return 0;
}

3.两者区别与联系

System V IPC信号量和POSIX信号量的区别与联系-CSDN博客