[操作系统] 进程间通信:system V 信号量
文章目录
- 并发编程
- 互斥(Mutex)
- 同步(Synchronization)
- 关系与区别:
- 信号量
- 什么是信号量?
- 理解信号量
- 如何使用?
- 信号量本身就是共享资源
- 二元信号量
- 信号量和通信有什么关系
- 信号量的接口和系统调用
- 信号量的基本概念回顾
- 信号量的相关系统调用
- (1)创建或获取一个信号量集合
- (2)信号量操作(P/V操作)
- (3)设置信号量的值
- 信号量的典型使用流程
- 进程间同步的示例
- 父子进程同步
- OS对信号量的管理和组织
并发编程
共享内存和消息队列在通信的时候都有一个前提,就是看到同一份资源。所以看到同一份资源为通信提供了前提条件,但是看到同一份资源进行通信仍然存在问题,例如“没有保护机制”,这样会使得和我们想象逻辑接收到的数据不一致!这个由多个执行流(进程)能看到的同一份公共资源就叫做共享资源。
为什么会得到的数据不一致?
- 共享资源没有被保护起来
- 各自的代码都访问了这个没有保护的共享资源
被保护的共享资源叫做临界资源,进程中涉及到共享资源的程序段叫做临界区。
保护共享资源的方法就是保护临界区代码,约束临界区代码从而保护临界资源。
怎么通过约束代码来保护临界资源?为什么是约束代码而不是直接保护资源?
如图,引入锁的机制。
假设有多个进程要访问共享资源,那么这些进程中执行访问的代码部分就是临界区。当他们要访问时就要申请锁,谁申请到了锁就进行加锁,之后其他进程的临界区代码会被卡住,只有申请锁成功的临界区代码可以执行。然后改临界区代码执行完成后再进行解锁,其他进程临界区代码再继续进行申请锁,如此循环。
在任何时候,只允许一个执行流(进程)访问资源,这种保护机制就叫互斥。
还有一种保护机制叫做同步。
互斥(Mutex)
- 互斥是确保在任何时刻,只有一个执行流(线程或进程)能够访问某个临界资源的机制。也就是说,互斥确保多个执行流不会同时对共享资源进行修改,从而避免数据冲突和不一致。
- 互斥的核心是排他性:某个资源在同一时刻只能被一个执行流访问。
- 在编程中,互斥通常通过锁(如
mutex
)来实现。当一个线程需要访问临界资源时,它会请求锁,获取锁后,其他线程必须等待,直到这个线程释放锁。
例子:
lock(); // 获取锁
counter++; // 临界区 后文信号量中会指出,计数器
unlock(); // 释放锁
同步(Synchronization)
- 同步是确保多个执行流按照一定的顺序访问资源的机制。同步关注的是执行流之间的顺序性,也就是确保某些操作按规定的顺序进行,而不是乱序操作。
- 同步并不一定要求互斥,它更关注多个执行流之间的协作。在某些情况下,即使多个执行流可以同时访问某个资源,仍然需要同步它们的操作顺序。
例子:
Semaphore s = 1; // 初始化信号量// 线程A执行任务
wait(s); // 等待信号量
taskA(); // 执行任务A
signal(s); // 释放信号量// 线程B执行任务
wait(s); // 等待信号量
taskB(); // 执行任务B
signal(s); // 释放信号量
关系与区别:
- 关系:
互斥和同步通常会同时使用,但它们的作用是不同的。互斥解决的是资源独占问题,确保临界资源在同一时刻只被一个执行流访问;而同步则确保多个执行流按照顺序进行操作。
在很多情况下,互斥和同步是协同工作的。比如,在一个多线程程序中,互斥保证线程对共享资源的独占访问,而同步保证线程之间按正确的顺序执行。
- 区别:
- 互斥强调的是“独占”,即确保同一时刻只有一个执行流能够访问临界资源。常见的实现方式是锁(如
mutex
)。 - 同步强调的是“顺序”,即多个执行流按照预定的顺序执行,而不仅仅是保证独占性。常见的实现方式包括信号量(Semaphore)、条件变量(Condition variable)等。
- 互斥强调的是“独占”,即确保同一时刻只有一个执行流能够访问临界资源。常见的实现方式是锁(如
所以对于临界资源也叫做互斥资源。
所以说,所谓的对共享资源进行保护,本质上就是对访问共享资源的代码进行保护。
还有一个细节问题,锁本身也是共享的,要被进程竞争申请,在所有进程都在申请锁的时候如何保护锁的安全?
申请锁的时候必须是原子的,保持原子性!
也就是说当一个进程开始申请锁的时候其他进程就不能申请了,一旦开始申请,就已经锁定。
信号量
什么是信号量?
信号量本质上就是计数器!
信号量作为计数器,用来表明临界资源中,资源的数量还剩多少。
理解信号量
与其他system V通信方式不同,信号量的机制是将共享资源初始化成若干个子资源。
可以将子资源假设为电影院的位置,电影院在售卖电影票的时候最怕的两个问题就是:
- 票卖多了,客户没有位置坐
- 票的座位号卖重复了,没有办法坐
所以需要通过一系列机制来约定规则,从而使每个位置对应的票独立。
这个机制也就是信号量的机制。
- 不要访问同一个子资源
- 不要放入过多的进程进来访问资源
想要访问临界资源,必须先买票预定位置。信号量描述临界资源中资源数量的多少。所有进程如果想访问临界资源中的一小块,就必须先申请信号量。
所以,进程访问资源前,先申请信号量,本质是:对资源的预定机制!
这样就可以保证并发访问不会出现问题!
资源给你了,等你随时访问,没有人再跟你竞争。
如何使用?
信号量本身就是共享资源
struct sem
{// lock// int count; 计数// task_struct *waitqueue;
}
- 当要进行申请资源的时候:
sem--
,P操作 - 当要进行退出时:
sem++
,V操作
计数器使用PV操作来完成资源的预定机制。
当count不为0,说明还有资源,可以进行申请,为0则说明没有资源了,阻塞挂起。
二元信号量
当信号量只有1或者0两种状态的时候,叫做二元信号量。也就是使用一整块资源,也就是互斥!区分于上述所提的多元信号量。
信号量和通信有什么关系
每个进程都会先看到同一个信号量,从信息量的角度看,通信不仅仅是数据传输,还包括进程间的状态传递(比如通过信号量),进行消息的通知。UNIX System V 提供的信号量机制(P和V操作)是一种优雅的解决方案,可以实现进程间的同步和互斥,从而“移动”相同的信息量(状态信息)。
当一个进程退出的时候,会进行V操作,计数器++,传递状态使得等待队列的进程进入执行。
信号量的接口和系统调用
信号量的基本概念回顾
- 信号量是一个计数器,用于控制对共享资源的访问。
- 常用于进程同步(synchronization)和互斥(mutual exclusion)。
- 信号量的主要类型:
- 二进制信号量(Binary Semaphore):仅能取 0 或 1,类似于互斥锁。
- 计数信号量(Counting Semaphore):取值范围大于 1,可用于控制多个资源的访问。
信号量的相关系统调用
信号量的操作主要通过以下几个系统调用完成:
(1)创建或获取一个信号量集合
int semget(key_t key, int nsems, int semflg);
- 功能:创建或获取一个信号量集合(semaphore set)。
- 参数:
key_t key
:信号量的键值(key),用于唯一标识一个信号量集合。int nsems
:信号量集合中的信号量数量。int semflg
:标志位,常见的值包括:IPC_CREAT
:如果不存在,则创建新信号量。IPC_EXCL
:与IPC_CREAT
一起使用,若信号量已存在,则返回错误。
- 返回值:
- 成功时返回信号量标识符(semid),用于用户层后续操作。
- 失败返回
-1
并设置errno
。
(2)信号量操作(P/V操作)
int semop(int semid, struct sembuf *sops, size_t nsops);
- 功能:执行 P(等待) 或 V(信号) 操作。
- 参数:
int semid
:信号量集合的 ID。struct sembuf *sops
:信号量操作数组。**size_t nsops**
:操作数组中的元素个数。
- 返回值:
- 成功返回
0
,失败返回-1
并设置errno
。
- 成功返回
struct sembuf
** 结构体**
struct sembuf {unsigned short sem_num; /* 信号量编号 */short sem_op; /* 操作值(+1 释放(V操作),-1 请求(P操作),0 等待) */short sem_flg; /* 操作标志,例如 IPC_NOWAIT */
};
sem_op
取值:- -1(P操作):尝试获取信号量,若不可用则阻塞。
- +1(V操作):释放信号量,唤醒等待的进程。
- 0(等待 0):等待信号量变为 0。
(3)设置信号量的值
int semctl(int semid, int semnum, int cmd, ...);
- 功能:用于控制信号量,如设置初值、获取状态等。
- 参数:
int semid
:信号量集合 ID。int semnum
:要操作的信号量编号。int cmd
:控制命令。...
可选参数,根据cmd
决定是否传递union semun
结构。
- 常见的
cmd
命令:SETVAL
:设置单个信号量的值。IPC_RMID
:删除信号量集合。GETVAL
:获取某个信号量的值。IPC_STAT
:获取信号量信息。
union semun
** 结构体**
union semun {int val; /* SETVAL 时使用 */struct semid_ds *buf; /* IPC_STAT/IPC_SET 时使用 */unsigned short *array; /* GETALL/SETALL 时使用 */
};
信号量的典型使用流程
- 创建信号量:
int semid = semget(ftok("/tmp", 'A'), 1, IPC_CREAT | 0666);
- 初始化信号量值(仅需在创建时执行):
信号量的初始值是在创建之后再调用smctl
进行初始化。
union semun arg;
arg.val = 1; // 设置信号量初值为 1
semctl(semid, 0, SETVAL, arg);
- P 操作(等待资源):
struct sembuf p = {0, -1, SEM_UNDO};
semop(semid, &p, 1);
- 执行临界区代码:
// 临界区代码
- V 操作(释放资源):
struct sembuf v = {0, 1, SEM_UNDO};
semop(semid, &v, 1);
- 删除信号量(程序结束时清理资源):
semctl(semid, 0, IPC_RMID);
进程间同步的示例
父子进程同步
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/sem.h>
#include <unistd.h>int main() {key_t key = ftok("/tmp", 'A'); // 生成 keyint semid = semget(key, 1, IPC_CREAT | 0666); // 创建信号量union semun arg;arg.val = 0; // 初始化为 0,表示资源不可用semctl(semid, 0, SETVAL, arg);pid_t pid = fork();if (pid == 0) {// 子进程printf("子进程等待信号量...\n");struct sembuf p = {0, -1, 0}; // P 操作semop(semid, &p, 1);printf("子进程获得信号量,继续执行\n");exit(0);} else {sleep(2);printf("父进程释放信号量...\n");struct sembuf v = {0, 1, 0}; // V 操作semop(semid, &v, 1);wait(NULL);semctl(semid, 0, IPC_RMID); // 删除信号量}return 0;
}
运行结果:
子进程等待信号量...
(2秒后)
父进程释放信号量...
子进程获得信号量,继续执行
OS对信号量的管理和组织
- OS内部存在大量的信号量集合!:
- 操作系统内部需要同时管理许多信号量(或更准确地说,是信号量_集合_,也称为信号量数组)。
- 管理策略:先描述,再组织:
- 操作系统首先需要定义一种数据结构来详细描述每一个信号量集合的属性,然后将这些描述信息有效地组织起来,以便进行查找、管理和维护。
- 为了“描述”一个信号量集合,操作系统使用了内核数据结构
**struct semid_ds**
。这个结构体包含了关于一个特定信号量集合的所有元数据信息。
**struct semid_ds**
:
#include <sys/ipc.h> // 需要包含它以定义 struct ipc_perm
#include <time.h> // 需要包含它以定义 time_tstruct semid_ds {struct ipc_perm sem_perm; /* Ownership and permissions - 包含所有权和权限信息 */time_t sem_otime; /* Last semop time - 最后一次调用 semop() 的时间 */time_t sem_ctime; /* Last change time - 最后一次改变该结构体信息的时间(例如通过 semctl 的 IPC_SET 或 IPC_RMID) */unsigned long sem_nsems; /* No. of semaphores in set - 此信号量集合中信号量的数量 *//* 以下可能是 Linux 特有的或内部使用的字段,不一定在所有 POSIX 系统中都存在 */// unsigned long __unused1;// unsigned long __unused2;
};
struct ipc_perm sem_perm
: 这是一个嵌套的结构体,包含了与IPC(进程间通信)对象相关的通用权限和所有权信息。time_t sem_otime
: 最后一次执行semop
操作(改变信号量值)的时间。time_t sem_ctime
: 最后一次改变该结构体信息(例如权限变更)的时间。unsigned long sem_nsems
: 这个信号量集合中包含的信号量(semaphores)的数量。这对应ipcs -s
输出中的nsems
列。
- 权限与标识信息(
struct ipc_perm
**): **
#include <sys/types.h> // 需要包含它以定义 key_t, uid_t, gid_tstruct ipc_perm {key_t __key; /* Key supplied to semget(2) or other IPC get functions - 创建或获取 IPC 对象时提供的键 */uid_t uid; /* Effective UID of owner - 所有者的有效用户 ID */gid_t gid; /* Effective GID of owner - 所有者的有效组 ID */uid_t cuid; /* Effective UID of creator - 创建者的有效用户 ID */gid_t cgid; /* Effective GID of creator - 创建者的有效组 ID */unsigned short mode; /* Permissions - 访问权限位 (e.g., 0666) */unsigned short __seq; /* Sequence number - 序列号,用于内部跟踪 *//* 以下可能是内部使用的或用于填充的字段 */// unsigned long __unused1;// unsigned long __unused2;
};
struct semid_ds
内部的sem_perm
成员(类型为struct ipc_perm
)存储了更具体的权限和标识信息**key_t key**
: 创建或获取该信号量集合时使用的键(key)。这是用户空间用来标识和访问IPC资源(如信号量、共享内存、消息队列)的一种方式。它对应ipcs -s
输出中的key
列。uid_t uid
,gid_t gid
: 信号量集合的所有者的有效用户ID和组ID。uid_t cuid
,gid_t cgid
: 信号量集合的创建者的有效用户ID和组ID。unsigned short mode
: 访问权限位(例如读、写权限)。这对应ipcs -s
输出中的perms
列。unsigned short seq
: 序列号,用于区分被删除后又重新创建的同key
的IPC对象。
- 组织方式:
- 操作系统内核会维护一个全局的数据结构(可能是数组、链表或哈希表),用来存放所有当前存在的信号量集合的
semid_ds
描述符。 - 每个信号量集合在内核中都有一个唯一的标识符,即
semid
(Semaphore ID),对应ipcs -s
输出的semid
列。当用户通过semget
等系统调用使用key
创建或获取信号量集合时,内核会分配或查找对应的semid_ds
结构,并返回其semid
给用户进程。后续的操作(如semop
、semctl
)通常使用这个semid
来指定要操作的信号量集合。
- 操作系统内核会维护一个全局的数据结构(可能是数组、链表或哈希表),用来存放所有当前存在的信号量集合的
- 操作与控制(
semctl
等):int semctl(int semid, int semnum, int cmd, ...);
函数。这是一个用于控制信号量的系统调用。- 它接收
semid
来指定目标信号量集合。 cmd
参数指定要执行的操作,例如IPC_STAT
就是用来获取信号量集合的状态信息,内核会把对应的的semid_ds
结构体的内容拷贝给用户提供的缓冲区(可变参数传入的自己创建的semid_ds
)。其他cmd
可以用来设置权限、获取/设置信号量值等。ipcrm -s <semid>
命令则用于从系统中删除指定的信号量集合。ipcs -s <semid>
查询
总结:
操作系统通过定义struct semid_ds
数据结构来详细“描述”每一个信号量集合(包含权限、所有者、信号量数量、操作时间以及唯一标识key
等信息)。然后,操作系统在内核中将这些semid_ds
结构组织起来(通常使用semid
作为内部索引),形成一个所有信号量集合的清单。通过semget
, semop
, semctl
, ipcrm
等系统调用,用户进程可以基于key
或semid
来创建、操作和销毁这些信号量集合,而内核则利用这些结构体信息来执行相应的管理和控制。