多进程协作优势
- 功能模块化,避免重复造轮子
- 增强模块间的隔离,提供更强的安全保障
- 提高应用的容错能力
系统内核为两个进程映射共同的内存区域
问题:做好同步
发送者
接收者
问题
- 一直轮询,导致资源浪费
- 如果固定一个检查时间,时延长
▲ 操作系统在通信过程中不干预数据传输
❓ 共享内存和基于共享内存的消息传递有什么区别??
内核对用户态程序提供接口如Send
和Receive
。进程可以直接使用这些接口,将消息传递给另一个进程,不需要共享内存和轮询
过程
- 通过特定的内核接口建立一个通信连接
- 通过
Send
和Receive
接口进行消息传递 - (这里建立通信连接的过程和通过内核建立共享内存相似,更多的时抽象意义的建立连接)
共享内存和操作系统辅助传递的对比
共享内存可以实现理论上的零拷贝,而操作系统辅助传递需要两次内存拷贝(用户->内核->用户)
操作系统辅助传递的优势:
- 抽象更简单,操作系统可以保证每一次通信接口的调用都是一个消息被发送或接收,并且可以较好的支持变长的消息,而内存共享则需要用户态软件封装
- 安全性保障更强,不会破坏发送者和接收者进程的内存隔离性
- 在多方通讯时,多个进程共享内存区域复杂且不安全,操作系统复制可以避免此问题
定义:
-
通信的进程以放需要显示的标识另一方
-
进程拥有一个唯一标识
- Send(P, message): 给P进程发送一个消息
- Recv(Q, message): 从Q进程接收一个消息
连接
- 直接通信下的连接的建立是自动的 (通过标识)
- 一个连接唯一地对应一对进程
- 一对进程之间也只会存在一个连接
- 连接可以是单向的,但是在大部分情况下是双向的
例子
- 信号
定义
- 间接通信需要经过一个中间的信箱完成通信
- 每个信箱有自己唯一的标识符
- 发送者往 “信箱”发送消息,接收者从“信箱”读取消息
连接
- 进程间连接的建立发生在共享一个信箱时
- 每对进程可以有多个连接 (共享多个信箱)
- 连接同样可以是单向或双向的
- Send(M, message): 给信箱M发送一个消息
- Recv(M, message): 从信箱M接收一个消息
间接进程间通信的操作
- 创建一个新的信箱
- 通过信箱发送和接收消息
- 销毁一个信箱
例子
- 管道
问题
信箱共享导致多接收者均收到消息,P1负责发送消息, P2、P3负责接收消息 ,当一个消息发出的时候,谁会接收到最新的消息呢?
- 让一个连接(信箱)只能被最多两个进程共享,避免该问题
- 同一时间,只允许最多一个进程在执行接收信息的操作
- 让消息系统任意选择一个接收者 (需要通知发送者谁是最终接收者)
阻塞
- 阻塞通常被认为是同步通信
- 阻塞的发送/接收: 发送者/接收者一直处于阻塞状态,直到消息发 出/到来
- 同步通信通常有着更低时延和易用的编程模型
非阻塞
- 非阻塞通常被认为是异步通信
- 发送者/接收者不等待操作结果,直接返回
- 异步通信的带宽一般更高
▲ Send(A, message, Time-out)
- 两个特殊的超时选项: ① 一直等待(阻塞);②不等待(非阻塞)
- 避免由通信造成的拒接服务攻击等
特点
- 单向通信
- 内核中有缓冲区,当缓冲区满时,阻塞
- 一个管道有且只能有两个端口: 一个负责输入 (发送数据),一个负 责输出 (接收数据)
- 传输数据不带类型,即字节流
- 基于Unix的文件描述符使用
下一次写在data[nwrite++]
,下一次读在data[nread++]
要用lock
,写的人在写的时候要锁住,读的人不能读(spin住)
如果缓冲区满了,就叫醒reader,自己sleep
消息写完了也要再叫醒reader一次
读完了也要叫醒写者
- 信道(Channel)是等待和通知的媒介
- 一个进程可以通过sleep接口将自己等待在一个信道上
- 另外一个进程可以通过wakeup将等待在某个信道上的进 程唤醒
优点
设计和实现简单,针对简单通信场景十分有效
缺点
- 缺少消息的类型,接收者需要对消息内容进行解析
- 缓冲区大小预先分配且固定
- 只能支持单向通信
- 只能支持最多两个进程间通信
特点
- 消息队列: 以链表的方式组织消息
- 任何有权限的进程都可以访问队列,写入或者读取
- 支持异步通信 (非阻塞)
- 消息的格式: 类型 + 数据
- 类型:由一个整型表示,具体的意义由用户决定
- 消息队列是间接消息传递方式
- 通过共享一个队列来建立连接
例子
发送者
key = ftok("./msgque", 11);
msgid = msgget(key, 0666 | IPC_CREAT);
message.mesg_type = 1;
msgsnd(msgid, &message, sizeof(message), 0);
接收者
key = ftok(”./msgque", 11);
msgid = msgget(key, 0666 | IPC_CREAT);
msgrcv(msgid, &message, sizeof(message), 1, 0);
msgctl(msgid, IPC_RMID, NULL);
消息传递
- 基本遵循FIFO (First-In-First-Out)先进先出原则
- 消息队列的写入:增加在队列尾部
- 消息队列的读取:默认从队首获取消息
允许按照类型查询
Recv(A, type, message)
- 类型为0时返回第一个消息 (FIFO)
- 类型有值时按照类型查询消息 ,如type为正数,则返回第一个类型为type的消息
- 缓存区设计
- 消息队列: 链表的组织方式,动态分配资源,可以设置很大的上限
- 管道: 固定的缓冲区间,分配过大资源容易造成浪费
- 消息格式
- 消息队列: 带类型的数据
- 管道: 数据 (字节流)
- 连接上的通信进程
- 消息队列: 可以有多个发送者和接收者
- 管道: 两个端口,最多对应两个进程
- 消息的管理
- 消息队列: FIFO + 基于类型的查询
- 管道: FIFO
IPC设计问题
▲ IPC设计可以看成将需要处理的数据发送到另一个进程,所以会有以下两个问题
- 控制流转换: 调用者进程快速通知被调用者进程
- 控制流转换需要下陷到内核
- 内核系统为了保证公平等,会在内核中根据情况进行调度(调用者和被调用者之间可能会执行多个不相关进程)
- 数据传输: 将栈和寄存器参数传递给被调用者进程
- 经过内核的传输有(至少)两次拷贝
- 慢: 拷贝本身的性能就不快 (内存指令)
- 不可扩展: 数据量增大10x,时延增大10x
LRPC的基本原则
▲ 将另一个进程处理数据的代码拉到当前的进程,避免了控制流切换和数据传输
- 简化控制流切换,让客户端线程执行服务端代码
- 简化数据传输,共享参数栈和寄存器
- 简化接口,减少序列化开销
- 优化并发,避免共享的全局数据结构
参数栈
- 系统内核为每一对LRPC连接预先分配好一个参数栈A-stack
- A-stack被同时映射在调用者进程和被调用者进程地址空间
- 调用者进程只需要将参数准备到A-stack即可
- 不需要额外内存拷贝
寄存器
- 普通的上下文切换: 保存当前寄存器状态 → 恢复切换到的进程寄存器状态
- LRPC迁移进程: 直接使用当前的通用寄存器
- 类似函数调用中用寄存器传递参数
- 客户端进程会优先使用寄存器,在寄存器不够的情况下用参数栈
执行栈
- 执行栈不共享哦
- 是执行服务端代码用的 E-stack
服务描述符
- 内核为通信的服务端提供一个服务的抽象
- 所有支持客户端调用的服务端进程需要将自己的处理函数等信息注册到服务描述符中
- 在系统内核中为每个服务描述符提供两个资源:参数栈,连接记录
- 参数栈:被同时映射到调用者和被调用者进程
- 连接记录:返回地址
绑定对象
- 当客户端申请和一个服务端建立连接时,内核会分配参数栈和连接记录,并返回给客户进程一个绑定对象
- ▲ 绑定对象的获得意味着客户端和服务端成功建立了连接
- 内核将参数栈交给客户端进程,作为一个绑定成功的标志
- 在通信过程中,通过检查A-stack来判断调用者是否正确发起通信
- 内核验证绑定对象的正确性,并找到正确的服务描述符
- 内核验证参数栈和连接记录
- 检查是否有并发调用 (可能导致A-stack等异常)
- 将调用者的返回地址和栈指针放到连接记录中
- 将连接记录放到线程控制结构体中的栈上 (支持嵌套LRPC调用)
- 找到被调用者进程的E-stack (执行代码所使用的栈)
- 将当前线程的栈指针设置为被调用者进程的运行栈地址
- 将地址空间切换到被调用者进程中
- 执行被调用者地址空间中的处理函数
为什么需要将栈分成参数栈和运行栈
LRPC中控制流转换的主要要开销来自哪?
进出内核,切换页表
不考虑多线程的情况下,共享参数栈安全吗
对于以下四个场景,请从“使用阻塞的消息传递进行进程间的直接通讯”、“使用非阻塞的消息传递进行进程间的直接通讯”、“使用信箱的方式进行进程间进行间接通讯”、“通过轮询共享内存的方式进行进程间的通讯”中选择最合适的进程间通信方法
a) 电商网站中的反向代理进程希望通过进程间通信的方式将收到的用户请求转发给一系列服务进程,使得某服务进程空闲后即可处理该请求。
b) 电商网站中的服务进程希望通过进程间通信的方式从锁服务(Lock Service)进程中获取一把锁,从而进入临界区(Critical Section)执行商品购买逻辑。
c) 电商网站中的服务进程希望通过进程间通信的方式,将包含用户请求执行结果的网络包通过用户态网络驱动服务进程,以尽可能低的时延发送出去。
d) 电商网站中的服务进程希望通过进程间通信的方式将一条用户购买记录发送给后台推荐分析进程。
a)使用“信箱”的方式进行进程间进行间接通讯,因为该通信为单一发送者,多接收者,且在发送时接收者并不确定。
b)使用阻塞的消息传递进行进程间的直接通讯,因为进出临界区执行需要确保已经成功获取到锁,后续操作需要在该进程间通信完成之后才能执行。
c)通过轮询共享内存的方式进行进程间的通讯,因为该操作希望延迟尽可能低,因此可以让接收者轮询共享内存,确认发送者在发出进程间通信请求后,接收者可以尽可能快的收到这一请求并进行处理。
d)使用非阻塞的消息传递进行进程间的直接通讯,因为用户请求的继续处理不依赖于后台推荐分析进程的分析结果,所以无需阻塞,可以使用非阻塞的方式进行进程间通信。
在
xv6
的管道线(PIPE)实现中,pipe这一结构体中的lock这一属性的作用是什么?为什么在sleep函数中存在放锁与拿锁操作,而在wake函数中却没有?
struct pipe中的lock是为了确保在不同进程同时访问pipe时,不会产生数据竞争问题
在sleep操作时,需要进行放锁与拿锁操作,以确保sleep操作的原子性。
对于放锁操作:一方面,当进入sleep函数时,调用者线程应当持有锁,以防止对调用者线程的wakeup操作在调用者线程真正进入sleep状态之前到来所导致的后续无人再次唤醒调用者线程问题;另一方面,调用者线程又不能再持有锁的状态下进入sleep状态,否则会产生死锁(调用者线程需要等待其他线程唤醒才能解锁,而其他线程则需要等调用者线程放锁才能进行唤醒)。
对于拿锁操作:由于调用者线程在调用sleep函数之前已经持有锁,因此,在sleep函数执行之后,仍应该将调用者线程恢复至持有锁的状态。
在wakeup操作时,由于不存在线程状态转化的问题,即调用wakeup的线程在调用完成后仍然处于可以被执行的状态,因此无需在wakeup函数内进一步进行复杂的锁操作。
对于基于共享内存的进程间通信而言,存在一个常见的安全性问题:Time-to-check to time-to-use(
TOCTOU
)。请查阅外部资料,简要说明这一问题的含义。
TOCTOU指一类由竞争条件所导致的软件bug,通常是由在检查内存状态是否合法和使用检查结果的之间,共享同一内存的恶意线程修改内存内容所导致的。
举例而言,假设存在发送者(S)和接受者(R)使用如下结构体通信
在接受者端,R会先检查length是否小于MAX_LENGTH,在从buf中依次拷贝length个字节到其本地内存中,以待后续处理。这是,若S在R检查length后,拷贝开始之前对length的值进行了修改,则能够触发一个TOCTOU的攻击,覆写掉R的本地内存
对于课程中所介绍的轻量级进程间通信(LIPC),请回答以下问题:
(1)为什么要将栈分成参数栈与执行栈两种?
(2)LRPC中控制流转换的主要要开销来自哪?
(3)不考虑多线程的情况下,共享参数栈安全吗?
(1)LRPC使用参数栈进行参数的传递,而执行栈则仅用来实际执行接受者的代码,从而防止发送者通过参数对接受者进行攻击。
(2)参照LRPC的论文,主要的性能开销来自于地址空间的切换。此外,如进入内核态、内核内部的检查等操作也会产生一定的性能开销。
(3)否,因为LRPC是同步的进程间调用,因此,在不考虑多线程的前提下,只有被调用者进程才能够访问到参数栈,不存在TOCTOU攻击的可能性。
在进程间通信的实现中,通常需要采用某种命名机制来确定某个进程间通信的目标进程(如xv6例子中的nread/nwrite指针)。这类命名机制的设计是否会成为影响进程间通信性能的决定性因素?为什么?
在大部分情况下,命名机制的选择与实现并不会决定进程间通信的性能。这主要是由于通常而言,命名机制只被用在进程间通信连接的创建过程中,而进程间通信的主要开销则来自于通信所需求的数据拷贝过程。当然,对于如生命周期极短的进程间通信等特殊场景而言,明明机制的实现会主导进程间通信的性能