信号量简单了解
信号量的作用
信号量通常并不是直接用来实现进程间的数据通信,而是主要用于同步和互斥,即协调进程或线程对共享资源的访问顺序,防止竞争条件发生。信号量本身并不传递数据,而是用于控制什么时候可以访问共享资源。
-
互斥(Mutual Exclusion):
- 信号量可以用来确保在同一时刻只有一个进程可以访问某个共享资源。通过使用二进制信号量(或互斥锁),可以避免多个进程同时读写共享数据,从而保证数据的一致性。
-
进程同步(Process Synchronization):
- 信号量可以用于协调多个进程的执行顺序。例如,一个进程在完成某个任务后,可以通过释放信号量来通知其他进程开始执行,从而实现进程之间的同步。
-
资源计数(Resource Counting):
- 计数信号量可以用于管理有限的资源,比如数据库连接、线程池等。通过信号量,可以跟踪可用资源的数量,并在资源被占用时阻止进程继续请求资源。
信号量的分类(按机制进行分类)
用户态进程使用的信号量分为System V信号量和POSIX信号量。
System V信号量(System V Semaphores):
一个 System V 信号对应的是一个信号量集合,可以包含多个信号量。
- 这种信号量实现基于System V IPC(Inter-Process Communication)机制。
- 使用
semget
、semop
和semctl
等系统调用进行操作。 - 支持多种操作,包括创建、获取、操作和控制信号量。
- 这种类型的信号量在设计上更为复杂,适合于进程间的同步,很少很少用于线程间的同步。
- System V信号量是较早期的IPC机制之一,对于需要维护或扩展遗留系统的项目,可能会使用System V信号量。至于SystemV信号量的接口有多难用呢?下面给将会有详细的讲解。😭😭😭
POSIX信号量(POSIX Semaphores):
一个POSIX 信号量只表示一个单独的信号量。
- 这是一种更为现代的信号量实现,遵循POSIX标准。
- 使用
sem_init
、sem_wait
、sem_post
和sem_destroy
等函数进行操作,且这些API更加简单容易理解。 - 可以在进程间或线程间使用,进一步分为命名信号量和未命名信号量。命名信号量可以用于不同进程间的同步,未命名信号量则适合线程间的同步。
- 相比于System V信号量,POSIX信号量的接口更简单易用,如果你是在一个现代系统上开发,特别是涉及线程或需要跨平台的兼容性,POSIX信号量是首选。它简单、灵活,适用于大多数进程和线程同步的场景。
尽管 System V 信号量 在某些方面(如:一个信号量集合可同时管理一块资源的多方面权限,支持对多个信号量的原子操作等)提供了更强大的控制机制,但对于大多数场景而言,POSIX 信号量 已经足够满足需求。POSIX 信号量更简单、轻量,并且在实际编程中常常更加易用和高效。
System V信号量集合
信号量集合的理解
每个信号量标识符(semid
)实际对应一个信号量集合,而不是单一的信号。一个信号量集合包含一个或多个信号量,这些信号量可以在一个集合中一起分配、操作和管理。
System V 信号量集合设计的目的是为了在一个信号量组中管理多个信号量,多个信号量方便对一组资源进行管理和操作。对于复杂的应用场景,可能需要对多个相关资源进行同步和互斥控制,而信号量集合使得可以将这些信号量捆绑在一起,进行集中管理,可能更加方便。
对多个信号量整体进行的原子操作:对信号量集合中的多个信号量的修改(如加锁或解锁)在一个不可分割的操作中完成。换句话说,在执行这些操作的过程中,其他进程或线程不能干预这些操作,这确保了数据的一致性和完整性,要么操作完全成功,要么完全失败,不会出现部分完成的状态。
信号量集合工作原理和流程(重要!!!必看!!!)
先设立一个🍒实际场景🍒让我们可以更容易说明:
比如有一个共享内存块,用于进程A和进程B之间进行通信,但是我们必须要保证某个进程在向共享内存写的过程,另一个进程此时不能对这块内存进行写操作。同时一个进程对某块内存进行读操作的时候,另一个进程不能对这个共享内存进行写操作。简单来说,我们要保证避免的情况就是:
- 某个进程先对共享内存进行写的时候,保护不让另一个进程对共享内存进行读/写
- 某个进程先对共享内存进行读操作的时候,保护不让另一个进程对共享内存进行写
System V信号量大致使用过程
-
共享资源的标识 (信号量创建) :信号量集合的若干信号量可以被视为几个整型变量,这些变量用于标识共享资源(如共享内存)的占用状态。每个信号量对应一个资源的某种状态。
-
初始化信号量:使用
semctl
函数可以设置信号量的初始值。例如,可以将一个信号量的值设置为1,表示该资源是可用的。将其值设置为0则表示该资源被占用。 -
对共享资源操作之前先进行询问,申请成功后及时变更对应权限,退出不用后也及时改变对应权限:(对信号量值进行操作)
当一个进程想要向共享内存进行写操作,就要先查询控制这个共享内存写权限的信号量,然后才能决定是否可以操作。
查询到可以的话才能写,并且及时变更写权限对应信号量。表示我正在用,也告诉了其他进程现在不能用,如果想用得等一下或者待会再来。
如果查询到不可以的话,就可以选择阻塞排队等待,或者选择先离开待会再来询问。
当用完这个资源之后,并且及时变更写权限对应信号量,表示我不用了,释放了这个资源,也告诉了其他进程现在你们可以用了。
信号量的创建,初始化,信号量的操作,分别对应我们下面要讲到的semget,semctl,semop三个函数。
联系一下生活实际,就拿二进制信号量来举例吧,一个二进制信号量就相当于是一间厕所的一把锁。
当你想要执行上厕所操作时,你肯定需要执行查询操作。
如果查询发现有人,那就不能直接上厕所,需要等待或者稍后再来;
查询发现没人,你就可以直接上厕所,并且进去厕所之后要上锁,表示告诉别人我正在使用厕所,你们不能进来;
当你上完厕所之后,你一定会将厕所的锁打开,表示自己用完了并且释放这个资源,其他人可以来使用了。
除非你是个sb😓,你选择上完厕所翻墙出来,这样这个资源永远得不到释放,大家都不能使用了。还有就是你在上厕所之前需要提前检查一下这个资源,没人用你再用,不询问而破门而入,你也是个sb😓。毕竟编程的时候通过信号量上的这个锁相当于是一个透明的锁,防君子不妨小人😇。
如何理解这个约束是透明的呢?使用System V信号量(或任何类型的信号量)进行锁定时,并没有强制物理隔绝机制来确保每个进程或线程在使用资源时,其他进程不能访问这个资源。信号量的约束主要依赖于进程的自觉和约定,如果你不遵守使用共享资源先询问再使用,用完及时释放这个约定,你仍然可以直接对该共享资源进行使用,但是这样一来,我们的信号量便失去了它的意义,造成共享资源的滥用。
所以编程的时候,访问带信号量的共享资源,我们必须遵循先询问再使用,用完及时释放这个约定!!!
信号量分类(按实际用途进行分类)
如果按照我们常用的功能来划分,信号量通常有两种类型:
-
计数信号量(Counting Semaphore):
- 计数信号量可以取任意非负值,表示可用资源的数量。
- 当一个进程请求资源时,信号量的值减一;当资源被释放时,信号量的值加一。
- 如果信号量的值为0,表示没有可用的资源,进程将被阻塞,直到其他进程释放资源。
-
二进制信号量(Binary Semaphore):
- 二进制信号量只取0或1的值,可以被视为一个锁。
- 它用于实现互斥,确保在任意时刻只有一个进程可以访问特定的共享资源。
简单来说:
- 二进制信号量:值只能为0或1,专门用于互斥和同步,确保一次只有一个进程或线程访问资源。
- 计数信号量:值可以大于1,用于控制多个资源的访问。例如,允许多个进程或线程同时访问一定数量的资源(如数据库连接池中的连接)。
在大多数情况下,使用信号量对资源进行标识时,常用的是二进制信号量,特别是在进程间同步或线程同步的场景中。二进制信号量的值只有0和1,类似于一个锁的机制,用于控制对共享资源的访问。
System V信号量相关函数
semget
函数作用:创建一个信号量集合或获取一个已存在的信号量集合的标识符
头文件:#include <sys/ipc.h> #include <sys/sem.h>
函数原型:int semget(key_t key, int nsems, int semflg);
参数:
-
key_t key
:- 这是信号量集合的唯一标识符。您可以使用
ftok
函数来生成这个key
,确保其唯一性。 key
用于区分不同的信号量集合。
- 这是信号量集合的唯一标识符。您可以使用
-
int nsems
:- 这是信号量集合中要创建的信号量的数量。
nsems
必须大于零,并且通常最大数量受限于系统配置(通常一个信号量集合最多为 256 个信号量)。
-
int semflg
:- 这个参数用于控制信号量集合的创建和访问行为。它通常是一个位掩码,可以结合使用以下标志:
IPC_CREAT
:如果信号量集合不存在,则创建一个新集合。IPC_EXCL
:如果使用此标志创建信号量集合,并且集合已经存在,则调用失败,IPC_EXCL
是为了确保key对应的 IPC 对象必须是当前进程创建的是,如果不是的话则报错。(一般我们用到IPC_EXCL比较少,因为通常情况下我们并不需要保证这个IPC对象必须是当前进程创建的)0666
:信号量集合的权限(以八进制表示),指定了哪些用户可以访问该集合(如读和写权限)。
- 这个参数用于控制信号量集合的创建和访问行为。它通常是一个位掩码,可以结合使用以下标志:
返回值:
- 成功:返回信号量集合的标识符(
semid
),这是一个非负整数。 - 失败:返回
-1
,并且设置errno
以指示错误原因。
semctl
函数作用:更改信号量集合中信号属性或删除一个信号量集合
头文件:#include <sys/ipc.h> #include <sys/sem.h>
函数原型:int semctl(int semid, int semnum, int cmd, ...);
参数:
-
int semid
:- 这是信号量集合的标识符,通常由
semget
函数返回。
- 这是信号量集合的标识符,通常由
-
int semnum
:- 要操作的特定某个信号量的索引。
- 对于集合中的信号量,索引从 0 开始,范围是
0
到nsems - 1
,其中nsems
是集合中信号量的数量。 - 当操作某个单个信号量时,
semnum
是必需的;当对整个信号量集合进行操作(如GETALL
、SETALL
)和IPC_RMID
时,semnum
会被忽略,此时我们一般将这个字段置为0。
-
int cmd
:- 操作命令,用于指定要执行的操作,常见的命令包括:
GETVAL
:获取指定信号量的当前值。SETVAL
:设置指定信号量的值。GETALL
:获取信号量集合中所有信号量的值。SETALL
:设置信号量集合中所有信号量的值。IPC_RMID
:删除信号量集合。
- 操作命令,用于指定要执行的操作,常见的命令包括:
- ...处参数:
- 对于 GETVAL和 IPCRMID 命令,可以省略这个参数(但不能传递NULL),对应其他cmd命令字,需要提供一个union semun类型的变量。(这个变量中保存要设置的信息或即将得到的信息)
返回值:
- 成功:返回与所执行的命令相关的结果。例如,GETVAL 返回信号量的当前值,GETALL,SETVAL , SETALL 和IPCRMID返回 0。
- 失败:返回
-1
,并且设置errno
以指示错误原因。
semctl使用示例(复习semctl时先看总结部分)
使用GETVAL获取指定信号量的值
信号量的值实际上是 整数类型 (int
),但是设置和获取信号量时使用的数据类型略有差异:
设置时用一个int型变量承接semctl返回值就行,且此时semctl只需要三个参数。
int semid = semget(ftok("semfile", 65), 1, IPC_CREAT | 0666); // 创建一个信号量集合
if (semid == -1) {
perror("semget failed");
return 1;
}// 获取信号量集合中第 0 个信号量的值
int semval = semctl(semid, 0, GETVAL);
使用SETVAL设置指定信号量的值
semctl
函数在执行 SETVAL
命令时,第四个参数必须是 union semun
类型,而不能是一个直接的整数值。 SETVAL
需要通过 union semun
的 val
成员来传递要设置信号量的值,而不能直接传递一个整数,属于硬性规定了,所以需要我们定义一个union semun类型的变量,然后填充里面的val 成员。
除此之外,第二个很麻烦的点是:必须手动定义 union semun
,系统通常不会自动提供它。说实话看到这里的时候我觉得多少有点离谱😨😨😨,所以我们还必须提前定义出union semun类型
通过这里SETVAL的步骤,想必你已经能看出来 System V信号量集合使用起来是多么麻烦!💢💢💢
// 定义 union semun
union semun {
int val; // 用于 SETVAL
struct semid_ds *buf; // 用于 IPC_STAT 和 IPC_SET
unsigned short *array; // 用于 GETALL 和 SETALL
struct seminfo *__buf; // 用于 IPC_INFO
};
int main() {
key_t key = ftok("semfile", 65);
int semid = semget(key, 1, 0666 | IPC_CREAT);
union semun arg;
arg.val = 1; // 定义一个union semun类型变量,并设置信号量的值为 1
// 正确的调用方式
if (semctl(semid, 0, SETVAL, arg) == -1) {
perror("semctl SETVAL failed");
return 1;
}
}
使用GETALL获取所有信号量的值
GETALL 命令可以获取整个信号量集合中所有信号量的值。获取到的值会存储在union semun 联合体中unsigned short 类型的数组字段中,数组的大小应与信号量集合的大小相同。
使用GETALL时,也需要我们手动来定义union semun
,并且声明对应类型的变量,且填充unsigned short *array这个字段。再一次感慨,好麻烦!!!💢💢💢
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
// 定义 union semun
union semun {
int val;
struct semid_ds *buf;
unsigned short *array; // 用于 GETALL 和 SETALL
struct seminfo *__buf;
};
int main() {
// 创建一个信号量集合(包含 3 个信号量)
key_t key = ftok("semfile", 65); // 生成键值
int semid = semget(key, 3, 0666 | IPC_CREAT); // 创建3个信号量
if (semid == -1) {
perror("semget failed");
return 1;
}
// 准备获取信号量集合中的所有值
unsigned short semvals[3]; // 数组大小应与信号量集合的大小匹配
union semun arg;
arg.array = semvals; // 指向数组的指针
// 使用 semctl 获取所有信号量的值
if (semctl(semid, 0, GETALL, arg) == -1) {
perror("semctl GETALL failed");
return 1;
}
// 打印获取到的信号量值
printf("Semaphore values:\n");
for (int i = 0; i < 3; i++) {
printf("Semaphore %d value: %d\n", i, semvals[i]);
}
return 0;
}
使用SETALL设置所有信号量的值
当需要一次性初始化或更改所有信号量时使用,直接为信号量集合中的所有信号量设置新的值。
步骤依旧麻烦,不必多言😫😫😫。
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <string.h>
// 定义 union semun
union semun {
int val; // 用于 SETVAL
struct semid_ds *buf; // 用于 IPC_STAT 和 IPC_SET
unsigned short *array; // 用于 GETALL 和 SETALL
struct seminfo *__buf; // 用于 IPC_INFO
};
int main() {
// 创建一个信号量集合(包含 3 个信号量)
key_t key = ftok("semfile", 65); // 生成键值
int semid = semget(key, 3, 0666 | IPC_CREAT); // 创建 3 个信号量
if (semid == -1) {
perror("semget failed");
return 1;
}
// 准备设置信号量集合中的所有值
unsigned short semvals[3] = {1, 2, 3}; // 要设置的信号量值
union semun arg;
arg.array = semvals; // 指向数组的指针
// 使用 semctl 设置所有信号量的值
if (semctl(semid, 0, SETALL, arg) == -1) {
perror("semctl SETALL failed");
return 1;
}
printf("Semaphore values set to:\n");
for (int i = 0; i < 3; i++) {
printf("Semaphore %d value: %d\n", i, semvals[i]);
}
return 0;
}
使用IPCRMID删除信号量集合
使用IPC_RMID
命令删除信号量集合时,也是将信号量集合表标识为待删除,直到没有任何进程使用这个IPC对象
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/sem.h>
int main() {
key_t key = ftok("semfile", 65);
int semid = semget(key, 1, 0666 | IPC_CREAT);
// 删除信号量集合
if (semctl(semid, 0, IPC_RMID) == -1) {
perror("semctl IPC_RMID failed");
return 1;
}
printf("Semaphore set removed successfully\n");
return 0;
}
总结
补充:当我们创建一个新的信号量集合的时候,一定需要用semctl对集合中的每个信号量赋初始值。
我们可以看到对于列举出的5种常见的cmd命令字来说,除了GETVAL和IPCRMID,使用其他的命令字类型的时候,都需要我们:
- 先手动定义出union semun,
- 并且创建对应联合体变量,
- 填写变量中的合适字段
关于union semun联合体的格式,直接将标准格式抄下来即可
// 定义 union semun
union semun {
int val; // 用于 SETVAL
struct semid_ds *buf; // 用于 IPC_STAT 和 IPC_SET
unsigned short *array; // 用于 GETALL 和 SETALL
struct seminfo *__buf; // 用于 IPC_INFO
};
再一次感慨,好麻烦!💢💢💢💢💢💢💢💢💢💢。
semop
函数作用:对信号量集合中的信号量执行操作(加锁或解锁)
头文件:#include <sys/ipc.h> #include <sys/sem.h>
函数原型:int semop(int semid, struct sembuf *sops, size_t nsops);
参数:
-
int semid
:- 这是信号量集合的标识符,通常由
semget
函数返回。
- 这是信号量集合的标识符,通常由
-
struct sembuf *sops:
- 指向一个
sembuf
结构体数组首元素的指针(也就是需要传递数组名),其中每个结构体定义了对一个信号量的操作
- 指向一个
-
size_t nsops:
-
要执行的操作数量,即
sops
数组中的元素个数。
-
返回值:
- 成功时:返回
0
。 - 失败时:返回
-1
,并设置errno
以指示错误类型。
sembuf结构体
struct sembuf 用于描述对单个信号量的操作,其定义如下:
struct sembuf {
unsigned short sem_num; // 信号量在信号量集合中的索引
short sem_op; // 操作的类型:可以是正数、负数或零
short sem_flg; // 操作标志
};
sem_num
:要操作的信号量的索引,从 0 开始。sem_op
:信号量的操作:- 正数:表示释放信号量(V 操作),使信号量的值增加。
- 负数:表示等待信号量(P 操作),使信号量的值减少。如果信号量的值不足以执行该操作,调用将阻塞,直到信号量的值变得足够。
- 零:用于测试信号量的值,但不改变其值。
sem_flg
:操作标志,可以是以下值的组合:IPC_NOWAIT
:如果无法立即执行操作,则返回错误,而不是阻塞。SEM_UNDO
:如果进程因退出而未能执行 V 操作,系统将自动执行 V 操作以释放信号量。0
:表示使用默认行为,没有特别的操作标志。这意味着:如果信号量的值不足以执行 P 操作,调用将阻塞,直到信号量的值变得足够。
信号量的使用示例
示例介绍
在本例子中共有两个进程,进程A和进程B,进程A循环不断向一块共享内存中写入1-9,每次只写入一个数字,且每次写入都会将上一次写入的数字覆盖,进程B不断向从共享内存中每次读取数字并且输出,每次也是只读取一个数字。
我们现在想要实现一个效果:A进程每次将一个新的数字写入之后,B进程将数字读出并输出,随后A进程接着向共享内存中写入新的数字,一直循环往复。也就是说AB交叉写入读出,每次A进程必须等待B进程将上一次数字读取之后再进行新的写入。
不能出现B进程还没有将上一个数字读取,A进程就写入了下一个数字(B进程读取慢于A进程写入的节奏)。
也不能出现B进程连续读取了多次相同的数字后,A进程才进行第二次写入(B进程读取快于A进程写入的节奏)。
要想不采取任何措施,来实现理想的AB进程读写相互耦合(A的写入速度正好匹配B的读取速度),这是非常困难的一件事情。对此,我们可以引入信号量,在这里我们用信号量来实现互斥,确保在任意时刻只有一个进程可以访问特定的共享资源,我们这里使用的信号量从功能上来看是二进制信号量。
下面我们将分别展示一下不使用信号量和使用信号量的读写结果。
共享内存不配合信号量使用
sem_a.c:
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <string.h>
#include <strings.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#define SHMSIZE 128
#define PATHNAME "./"
#define PROJ_ID 1
int main() {
key_t k = ftok(PATHNAME, PROJ_ID); // 获取一个唯一标识符key
if (k == -1) {
perror("ftok error");
return 1;
}
int shmid = shmget(k, SHMSIZE, IPC_CREAT | 0666); // 创建共享内存
if (shmid == -1) {
perror("shmget error");
return 1;
}
printf("shmid : %d\n", shmid);
char *shmaddr =(char *)shmat(shmid, NULL,0);
int i=0;
while(1){
i++;
i=i%10;
shmaddr[0]=i;
}
return 0;
}
sem_b.c:
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
#include <unistd.h>
#include <string.h>
#include <strings.h>
#include <errno.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#define SHMSIZE 128
#define PATHNAME "./"
#define PROJ_ID 1
int main() {
key_t k = ftok(PATHNAME, PROJ_ID); // 获取一个唯一标识符key
if (k == -1) {
perror("ftok error");
return 1;
}
printf("key : %d\n", k);
int shmid = shmget(k, SHMSIZE, IPC_CREAT | 0666); // 获取共享内存的shmid
if (shmid == -1) {
perror("shmget error");
return 1;
}
printf("shmid : %d\n", shmid);
char *shmaddr =(char *)shmat(shmid, NULL,0);
while(1){
fprintf(stderr,"%d",shmaddr[0]);//使用fprintf输出到标准错误流是为了每次可以不换行地立即输出
}
return 0;
}
运行结果:
我们可以显而易见看出来没有达到我们上面说的这种效果,这里面发生了很多次sem_a.c进程向共享内存写入刷新了好几次数字,然后 sem_b.c才读取出来;或者发生了sem_a.c进程还未更新共享内存中的内容,sem_b.c进程反复读了多次共享内存中数字,连续输出了多次相同内容。
共享内存+信号量使用
这里代码懒得写了,System V信号量的代码用起来好麻烦,要写一堆代码且不好记忆,现在用System V信号量用的很少了,并且一般只有进程间中的信号量控制才会用到,线程间资源同步互斥还是得用POSIX信号量,这种使用起来更加简单。大家简单了解一下System V信号量的方式就好了,感兴趣的读者可以自己写一下代码。最终实现的效果就是:
POSIX信号量比System V信号量好用多了,在下面这篇文章中,有使用Systrm V共享内存+POSIX信号量实现这个功能的完整代码和讲解: