课程内容#
IPC—— 进程间通信
—— 共享内存 ——#
- 父亲生孩子之前约定好共享的数据位置
- 相关接口:shm*
shmget#
分配 System V 类型的共享内存段 [段式内存]
[PS] System V 的通信方式,还在沿用;其启动方式已经被摈弃
- man shmget
- 原型
- 返回值类型:int
- 形参类型:key_t,size_t,int
- ipc→进程间通信
- 描述
- 传入的 key 和返回的 id 是联系在一起的
- 新的共享内存段的大小,对应整数页大小 [上取整]
- 创建共享内存需要有标志:IPC_CREAT
- 如果没有 IPC_CREAT,会检查用户对 key 对应段的访问权限
- 注意:还需要通过至少 9 个 bit 明确权限,如 0600,前面的 0 不能少
- 返回值
- 成功,返回一个有效的内存标识符 [id];错误,返回 - 1 并设置 errno
- 通过 key 拿到 id 后,如何找到对应的地址呢?shmat 👇
shmat、shmdt#
共享内存操作 [attach,附着;detach,分离]
- man shmat
- 原型
- 注意 shmat 的返回值为:void *
- 描述
- shmat
- 将 id 指定的共享内存段附着到调用进程自己的地址空间
- 每一个进程都认为自己用的是一块独立的连续内存 [虚拟内存技术]
- shmaddr 明确附着地址:
- NULL:系统自动附着 [常用]
- 否则,附着自动下取整的地址 [shmflg 中定义了 SHM_RND]或者手动保证页对齐的地址
- 成功的调用会更新共享内存的结构体 shmid_ds
- shm_nattach:附着数量。共享内存对应的真实物理空间可能被多个进程附着
- shmdt
- 是 shmat 的相反操作
- 操作的必须是当前被附着的地址
- shmat
- 返回值
- 成功,shmat 返回地址,shmdt 返回 0
- 错误,返回 - 1,并设置 errno
- id 在整个系统中是唯一的,同一个 id 一定对应同一块物理内存
- 【注意】相同的 id 通过 shmat 返回的地址不同,这是因为在不同的进程中表现为独立的地址空间 [虚拟内存的概念]
- 话说回来,shmget 通过 key 拿到 id,shmat 通过 id 拿到内存地址,但【key】又是如何得到的呢?👇
ftok#
将一个路径名和一个项目标识符转换为 System V IPC 的 key
- man ftok
- 原型
- 需要一个路径名和一个 int 类型变量完成转换
- 描述
- 文件需要存在,并可访问
- proj_id 有至少 8 个有效位,不能是 0
- 相同的文件名和 proj_id 对应相同的返回值
- 所以固定的传入参数,返回的 key 是固定的
- 返回值
- 成功,返回 key;失败,返回 - 1,并设置 errno
shmctl#
共享内存的控制
- man shmctl
- 原型
- cmd:命令,int 类型。可预测是一些大写的宏定义
- 描述
- 可以看到 shmid_ds 结构体的细节信息
- 一些命令
- IPC_STAT:拷贝 shmid_ds 结构体信息到 buf 中 [需有读权限]
- IPC_SET:修改 shmid_id 结构体
- IPC_RMID:标记段将被销毁
- 只有当共享内存段没有附着时,段才会被真正的销毁
- 不需要 buf 变量,设为 NULL 即可
- [PS] 必须检查是否真的被销毁,否则可能会有残留 [可以通过返回值]
- IPC_INFO [Linux 特有,为了兼容性,尽量避免使用]
- 返回值
- 一般地:0,成功 [除了 INFO、STAT];-1,出错
—— 线程相关 ——#
来自线程库 [pthread]
互斥量、条件变量
[PS] 多线程、高并发,使用共享内存时都需要考虑互斥
pthread_mutex_*#
【互斥量】操作 mutex
- man pthread_mutex_init 等
- lock:使用 lock 时,会判断是否已加锁,若加了锁,则会挂起直到解锁 [可能发生阻塞的地方]
- init:动态初始化方式,需要用到属性变量
- 属性接口:pthread_mutexattr_*
- init:初始化
- 初始化函数中,attr 是一个传出参数,返回值为 0
- setpshared:设置进程间共享
- pshared 变量为 int 类型,实际上就是一个标志位
- 0:进程间私有;1:进程间共享 → 分别对应宏 PTHREAD_PROCESS_PRIVATE、PTHREAD_PROCESS_SHARED
- init:初始化
- 互斥量的基本操作:创建属性变量 👉 初始化互斥量 👉 加 / 解锁操作,详见代码演示 —— 共享内存 [互斥锁]
pthread_cond_*#
【条件变量】控制条件
- man pthread_cond_init 等
- 结构很像 mutex
- 条件变量是一种同步设备,允许线程挂起释放处理器资源,直到满足某种条件
- 基本操作:发送条件信号,等待条件信号
- ⭐必须关联一个互斥量 mutex,来避免竞争条件:可能在一个线程准备好等待条件之前,另一个线程就已经发出信号了,从而导致消息遗漏
- init:初始化可以使用属性变量 [但实际上在 Linux 线程实现中,已忽略属性变量]
- signal:只会重启正在等待的线程中的1个;如果没有等待中的线程,就啥事没有
- broadcast:则会重启所有等待中的线程
- wait
- ① 解锁 mutex 并等待条件变量激活 [真正的原子操作]
- ② 在 wait 返回前 [线程重启之前 / 等到了信号],会重新将 mutex 上锁
- [PS]
- 在调用 wait 的时候,互斥量必须是上锁状态
- 等待挂起的时候,不会消耗 CPU
- ❗ 图中黄框所在段
- 保证在 wait 解锁 mutex-> 准备等待期间 [原子操作],其他线程不会发信号
- ❓ 那这个时候这个 mutex 已经解锁了,所以这个原子操作可能还需要用到一个类似互斥量的家伙
- ❓ 感觉上 wait 可以先准备好等待 -> 再解锁 mutex,这是正常逻辑,而上面解锁了,又进行一个原子操作,那先解锁有什么讲究呢
代码演示#
共享内存 [未加锁]#
5 个进程进行 1~10000 的累加和演示
- ❗ 这里是子孩子直接用从父进程那继承的共享内存地址 share_memory [虚拟的地址]
- 可见,不同进程中的相同虚拟地址都是指向同一片共享内存的 [物理内存]
- 如果子孩子通过继承来的 shmat 和 id 获得共享地址,会对应子进程中新的虚拟地址,但是同样指向同一片共享内存,原继承的共享地址 share_memory 同样可用
- ❗ 如何在使用完共享内存后实现真正的销毁
- 使用 shmdt 分离所有进程中附着的共享内存 [经试验,这一步没有也可以,因为进程结束会自动分离]
- 再使用 shmctl 配合 IPC_RMID 销毁共享内存段 [移除 shmid]
- [PS]
- 每个进程会算一个附着,当附着为 0 时,shmctl 才会真正销毁内存段
- 也可以去掉 shmget 中的 IPC_EXCL 标志,不检测是否已存在,但不是根本方法
- 如果不进行上述的 shmctl 操作
- 第一次执行没问题,代表成功创建了共享内存段
- 但第二次执行显示文件已存在
- 说明上次执行后,共享内存并没有自动销毁,第二次执行还是使用相同的 key 创建共享内存 [每次相同的 key 对应的 shmid 不相同]
- [探索过程]
- ipcs:展示 IPC 相关的资源信息
- 可以看到未销毁的共享内存,还有消息队列、信号量
- 以及它们的 key、id、权限 perms
- 也可以看到它们的 nattch 都为 0,说明已经没有附着了
- ipcrm:删除 IPC 资源
- 参数的使用通过 --help 查看
- 可通过 key 或者 id 移除资源
- 手动删除资源再运行程序:
ipcs | grep [owner] | awk '{print $2}' | xargs ipcrm -m && ./a.out
- ipcs:展示 IPC 相关的资源信息
- ❗ 出错如下错误的本质原因 [个人理解]
- 1~10000 的正确累加和,应为 50005000
- 多个进程同时操作共享内存 [或者] 一个进程的读写操作未完全完成,另一个进程开始新的操作了。如:
- 一个进程刚对 now++,写入内存,此时另一个进程拿到新的 now 再 ++,再用加了 2 次的 now 和旧 sum 相加
- 也就是 now++ 和 sum 累加两个操作不是原子操作了
- 但一般 CPU 速度太快了,所以很大概率进行的就是原子操作 [now++ 和 sum 累加写入内存],很小概率出错
- 多核比单核更容易出现计算错误,因为一个处理器只能同时运行一个进程
- [PS]
- 设置权限时 0600 的第一个 0 表示使用 8 进制
- usleep (100):让进程休眠 100ms,让一个进程计算一次就阻塞,让其它进程继续,单纯为了体现分工
- 头文件根据 man 手册自行添加
共享内存 [互斥量]#
在内存中更高效的锁,类似文件锁 [可前往例子] 的思想
- 创建互斥量:创建属性变量👉初始化互斥量
- 互斥量在进程间使用的两个条件
- ① 互斥量变量放在共享内存中,共享给每个进程,从而控制每个进程
- ② 在父进程中创建互斥量时,创建的属性变量设置进程共享 [默认只在线程间起作用]
- 但现在一些内核可能不需要做此操作也可以,不过为了兼容性,建议设置一下 ❗
- [PS]
- 计算完累加和后,也要记得解锁
- 系统中最慢的操作是 IO 操作,所以在 printf 前解锁,让互斥量更早开放,会更高效
- fflush 可以手动刷新缓冲区,避免输出混乱
共享内存 [条件变量]#
每个进程轮流算 100 次
- cond 条件变量的初始化,类似 mutex 互斥量
- Linux 线程的实现,其实不需要 attr 属性变量;mutex 同
- 对于单核机器,每次在发送条件信号之前,需要 usleep 一会,保证子进程准备好 wait,等待信号。否则,
- 对于父进程,其优先执行,发送信号,此时还没轮到子进程运行,就会错过信号
- 对于子进程之间,也类似,发信号前,先让其他子进程先运行到 wait 状态 [实际上 usleep 也不能保证]
- ❗ 注意
- wait 前要有加锁的 mutex
- 每次操作完记得解锁 + 发信号操作 [100 次计算 / 10000 的累加完成]
- 发信号操作的顺序有两种:
- ① 加锁 —— 发信号 —— 解锁
- 缺:等待线程由于收到信号从内核中被唤醒 [→用户态],但是因为没有可加锁的 mutex,很遗憾又 [从用户态] 回到内核空间,直到有可加锁的 mutex
- 两次上下文切换 [内核态和用户态之间] 损耗性能。
- 优:
- 保证了线程的优先级 [❓]
- 并且在 Linux 线程实现中,有 cond_wait 队列和 mutex_lock 队列,所以不会返回到用户态,从而不会有性能损耗
- 缺:等待线程由于收到信号从内核中被唤醒 [→用户态],但是因为没有可加锁的 mutex,很遗憾又 [从用户态] 回到内核空间,直到有可加锁的 mutex
- ② 加锁 —— 解锁 —— 发信号 [本文采用]
- 优:保证 cond_wait 队列中的线程有可加锁的 mutex 使用
- 缺:可能抢到 mutex 的低优先级的线程优先执行
- 参考:条件变量 pthread_cond_signal、pthread_cond_wait——CSDN
- ① 加锁 —— 发信号 —— 解锁
- 实现效果
- 每个进程轮流计算 100 次
- [PS] 即使是 usleep 也不能完全避免消息遗漏,可以使用共享内存中的一个变量记录信号的发出;此外,还可能有虚假唤醒的情况发生
- 综合解决方法,参考虚假唤醒 && 消息遗漏——CSDN
if (!count) { // ->消息遗漏 [还没有在wait状态的]
pthread_mutex_lock(&lock);
while (condition_is_false) { // ->虚假唤醒 [同时唤醒多个]
pthread_cond_wait(&cond, &lock);
}
//...
pthread_mutex_unlock(&lock);
}
简易聊天室#
- chat.h
- 用户名 + 消息 + 使用共享内存的标配
- 1.server.c
- 逻辑与前面的代码演示基本类似;注意后面的清空操作,主要为了数据安全
- 2.client.c
- 关注第 41 行的 while 循环的作用:防止客户端抢锁,导致服务端收不到 signal
- 还存在隐患:可能某客户端抢不到锁,发生阻塞
- [注意] gcc 编译 2.client.c 时,一定要加 - lpthread;否则不会报错,但运行时,服务端收不到信号
- 效果演示
- 左为服务端,右为两个客户端
附加知识点#
- 有亲缘关系的进程:父子之间、兄弟之间
思考点#
- 进程之间抢着算 VS. 每个进程各算一百次,哪种方式更高效?
- 后者更高效,往极端想,一个进程计算全部的更高效
- 这涉及到 CPU 的吞吐量问题,这种累加属于 CPU 密集型,而不是 IO 密集型
- 可参考 4 高级进程管理 ——处理器约束型和 IO 约束型进程
Tips#
- 查看 IPC 相关的资源信息命令:ipcs
- 对于用户线程,一个进程中的一个线程崩溃,整个进程里的所有线程都会崩溃
- 课前电影推荐:《与狼共舞》 1990
- 体验不同文化,有很多安静、美好的场景
- https://pan.baidu.com/s/1hqxmTAYyd3iCOyGdCTPmYw
- 提取码:yqed