Bo2SS

Bo2SS

5 进程间通信

课程内容#

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 返回地址,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
  • 互斥量的基本操作:创建属性变量 👉 初始化互斥量 👉 加 / 解锁操作,详见代码演示 —— 共享内存 [互斥锁]

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
  • ❗ 出错如下错误的本质原因 [个人理解]
    • 图片
    • 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 队列,所以不会返回到用户态,从而不会有性能损耗
      • ② 加锁 —— 解锁 —— 发信号 [本文采用]
        • 优:保证 cond_wait 队列中的线程有可加锁的 mutex 使用
        • 缺:可能抢到 mutex 的低优先级的线程优先执行
      • 参考:条件变量 pthread_cond_signal、pthread_cond_wait——CSDN
  • 实现效果
    • 图片
    • 每个进程轮流计算 100 次
    • [PS] 即使是 usleep 也不能完全避免消息遗漏,可以使用共享内存中的一个变量记录信号的发出;此外,还可能有虚假唤醒的情况发生
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 密集型

Tips#

  • 查看 IPC 相关的资源信息命令:ipcs
  • 对于用户线程,一个进程中的一个线程崩溃,整个进程里的所有线程都会崩溃
  • 课前电影推荐:《与狼共舞》 1990

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。