Table of Contents
上一篇《Dragonball VMM 侧 Vsock 设计与实现》从代码走读的角度对 Dragonball hybrid vsock 进行了解读,但是没有建立一个更宏观的视角——为什么要这么设计?各个模块之间的关系是什么?这篇文章将尝试站在更高层次解答这些问题。
从 Host 读取数据
响应 Epoll 事件
Hybrid Vsock 是一个事件驱动的模型,其中最核心的当属 VsockEpollHandler 这个模块了。在 VirtioDevice 被激活的时候,它被启用。它的作用是接收来自 EPOLL 的信号,根据 event 调用对应的回调函数。
这里 RXQ_EVENT 和 TXQ_EVENT 分别标示 virtqueue 的信号,每个 virtqueue 都有一样的信号,没什么特别的。这里需要大讲特讲的是 BACKEND_EVENT,也就是 vsock muxer(复用器)的核心。
Vsock muxer 的一次基本调用路径是
- 用户 connect/write 到一个 UDS/stream。
- 触发 EPOLL,被 VsockEpollHandler 截获信号。
- VsockEpollHandler 识别到是由后端发送的信号,调用对应的回调函数 notify_backend_event()。
接下来的章节都会省略 VsockEpollHandler 的响应过程,需要在心中记住的是:任何流入到 listener 的事件都是首先需要被 VsockEpollHandler 处理和响应的。
响应 Backend Event
回调函数 notify_backend_event() 是 backend 事件响应的承担者,是整个流程的核心,它做了三件事。
- 调用了 VsockMuxer::notify(): 更新 fd 和 conn 的状态机 ,但是不负责读取和处理数据。
- 调用了 process_tx(): 尝试处理 tx queue 的数据,目的是为 driver 腾出空间,不是我们关注的重点,不再赘述。
- 调用了 VsockEpollHandler::process_rx(): 根据最新的状态机,将数据复制到 virtqueue 中。
数据流动概览
下图展示的是从用户到 virtqueue 数据流动图。尽管省略了非常多的细节,我希望你能从中了解到 Epoll、VsockMuxer、VscokConnection 之间的关系。
最上面的 fds 被分为了 backend 和 stream 两组。Backend 是指后端 fd,比如 vsock muxer 常以 UDS 的形式存在,用户尝试 connect 那个特定的 UDS,vsock muxer accept 之后就会产生一个对应的 stream fd,数据实际上通过 stream fd 传输到 virtqueue 上。
结合上一张图,有 stream fd 发送过来信号之后,notify_backend_event() 回调函数会被调用,它触发了 vsock muxer 内部的一系列调用。它会从 vsock muxer 的 rxq 队列中 pop 出一个 ConnRx 元素,该元素包含一个 key,这个 key 又唯一对应了 vsock connection。最终,vsock connection 负责最终从 stream 中读取数据。
这张图表示 vsock connection 的对应的 stream 有流入数据(EPOLLIN)时,数据是如何被写入到 virtqueue 中的。
为什么 vsock muxer 要搞一个 rxq 来处理 vsock connection?
Virtio 数据传送单位是以包(pkt)为单位的。Vsock 包对应的 op 有多种:
- Request: 发送连接建立请求(类似与 TCP 的三次握手的发送 SYN 包的过程)。
- Rw: 连接已经建立,pkt 包含的是 raw data。
- Response: 对端允许建立连接,返回对 request 请求的应答包。
- Rst: 连接重置。
- ...
每个 pkt 的用途是唯一且确定的,也就是只能有一个 op。但是一个 connection 可能同时有多个 op 想要发送,比如一个 connection 还没有初始化,同时有新的数据进来,此时它想要发送的 op 包括 Request 和 Rw。Vsock connection 需要先发送 Request 建立连接,确认连接建立完毕后再发送 Rw 包写入数据。
Vsock connection 内置了一个名为 pending_rx 的 bitset,它注明了需要发向对端的全部 op。每发送一个 pkt 可能会消耗掉一个 op。如果此时 pending_rx 还不为空(比如第二步还有一个 Rw),那么 rxq 就不应该删除该 ConnRx,反之则可以删除(比如第三个)。
Rxq 中 ConnRx 表示对应的 vsock connection 至少有一个待发送的 pkt。
更新 fd 和 conn 的状态
Vsock muxer 的 notify() 是处理 fd 与 vsock connection 的状态变换的主要逻辑。
Vsock muxer 有两个字段,他们建立起了两个重要的映射
- listener_map 字段,它负责建立 fd 与 epoll listener 的映射关系,也就是说明 fd 与 listener 是一一对应的关系。
- conn_map 字段建立了 key 和 vsock connection 的映射关系,一个 key 确定了 cid 和 port,唯一确定了一个 vsock 连接。
每个 fd 被监控时,都有一个对应的 listener,同时 listener 也表示 fd 的不同状态。
- EpollListener::Backend
- EpollListener::LocalStream
- EpollListener::Connection
- ...
EpollListener::Backend 对应的 fd 的类型是 backend fd(参见“概览”的那张图)。以 UDS 为例,当有 host 的 app(user)connect UDS 的时候,vsock muxer 执行 accept 并产生一个 stream,它的 fd 的 listener 被设置为 LocalStream。
Stream fd 进入 LocalStream 状态说明等待用户的输入,以明确需要连接的端口。用户向 stream 写入 "connect {port}\n" 后,LocalStream 捕获到后会创建一个 vsock connection 实例。
Vsock connection 实例的创建过程有两个事情需要特别留意:
- 在创建的时候 pending_rx 被设置了 Request bit,表明这是一个 host-initialized 连接。同时该连接的 key 会被添加到 rxq 中,准备发送 pkt 到对端,它将在 process_rx() 被调用的时候发送。
- 第 4 步 stream fd 被移动到 vsock connection 名下了,对 stream 的读取、写入操作都是通过 connection 完成。
此时,vsock connection 已经被初始化,知道对端的 cid 和 port 了。Request 请求处于准备发送的阶段,此时连接并没有真正建立!(回顾这个章节的名字,vsock muxer 的 notify() 不发送数据,只更新 fd 和 conn 的状态机)
用户向 stream 写入数据(raw data),负责响应的 listener 是 Connection。它主要做的事情是向 vsock connection 的 pending_rx 写入 Rw bit(透传到 VsockConnection::notify() 实现的)。如果 vsock muxer 的 rxq 没有包含该 key,则会插入该 key。再次强调,数据并没有被发到 virtqueue 中,只更新 fd 和 conn 的状态机。
特别需要说明的一点是:用户写入数据的 stream 就是 vsock connection 内的 stream,通过 listener_map 将 fd 转换为对应的 listener。Listener 内部含有一个 key,再根据该 key 从 conn_map 找到对应的 vsock connection。
复制数据到 Virtqueue
Process_rx() 它从 rx queue(一种 virtqueue)的 desc table 中取出可用的 buffer, 将 vsock pkt 填充数据后存到 buffer 中,并最终添加到 used vring 以供 driver 消费。关于标准 virtio 流程请参见《I/O 虚拟化(一):Virtqueue 介绍》和《I/O 虚拟化(二):Virtio 协议代码走读》。
Vsock 发送 pkt 相关逻辑都集中于 vsock muxer 的 recv_pkt(),它的职责是遍历 rxq 并将 pkt 放到 rx queue 中。Rxq 的 item 有两种类型
- ConnRx: 由 vsock connection 决定发送什么 pkt,填充什么数据。
- RstPkt: 向对端直接发送一个 Rst pkt,通常表示发生了异常错误,比如对端传过来了一个 type 不为 STREAM 的包(目前仅支持流式 vsock),又或者对端发来的 pkt 请求了一个不存在的 port。
上图演示了 VsockMuxer 和 VsockConnection 之间的交互过程。现在 rxq 待发送队列有两个 items(⚠️ 理论上 Rw 和 Request 不会同时出现在 pending_rx 里,这里只是说明 rxq 和 connection 的工作原理):
- Iteration 0: 取出了 ConnRx(key0),通过 conn_map 找到 vsock connection,然后调用 connection 的 recv_pkt()。注意红色字体,connection 的 pending_rx 有 Request 和 Rw 两个 bits 被置 1 了。这些标志位本身是先后顺序的,Request 的优先级大于 Rw(如果连接还没建立,和谈发送数据呢?),这次 pkt 的 op 被设置为 request,尝试建立 vsock 连接。由于 key0 connection 的 pending_rx 还有标志位没有被消费,因此 ConnRx(key0) 并不会从 rxq 中移除(如第二个子图的蓝色字所示)。
- Iteration 1: 处理 Rw 标志位,connection 从 stream 中读取 raw data 保存在 pkt 的 buffer 中,将 pkt 的 op 设置为 Rw,放入 rx queue 中。由于 key0 的 pending_rx 标志位已经被清空,因此被 pop 出 rxq。
- Iteration 2: 遇到 RstPkt,则直接构造一个 Rst 包到 tx queue。同时会调用 remove_connection() 销毁该 connection。
小结
这一章节介绍了数据从用户手中传递到 virtqueue 的流动过程。有两个非常重要的组件:
- VsockMuxer 是一个大管家,它知道每个 fd 对应的 listener 是什么,也能根据 key 找到对应的 connection,它负责协调当前时间下哪个 connection 发送数据。
- VsockConnection 则专注与数据的传递,它是 stream 的读写的最终执行者,在大部分它负责填充 vsock pkt,它可以被认为是 host app 与 virtqueue 数据交换的实际承担者。
写入数据到 Host
这里面相当一部分是“从 host 读取数据”的反逻辑,因此只介绍一些特殊的机制。
VsockConnection 的写入缓冲
VsockConnection 的字段有一个 tx_buf 格外碍眼,为什么没有一个对称的 rx_buf?
Vsock connection 是以类 TCP 的方式运行的,这表示要保证对端发送的数据能被接收到。向 driver 发送数据时,如果 driver 已经无法处理更多的数据,我们只需要不再从 stream 取数据就好了。但是从 driver 接收数据时,如果 stream 无法处理更多数据,数据已经传递到 Dragonball 这里了,为了保证数据不丢失,只能设置一个缓冲区,等可用后(stream 发出了 EPOLLOUT 信号)再将数据写入 stream 中。这是一个保障 tx 数据不丢失的机制。
事实上,tx_buf 只是一个环形缓存区,它没什么特别的!
Vsock 连接状态机
Vsock connection 是一个类 TCP 连接,因此 Dragonball 内部维护了一个状态机,ConnState支持的状态包括:
- LocalInit: Host-initialized vsock 的初始状态。
- PeerInit
- Established: 连接建立成功,传输数据的状态。
- LocalClosed
- PeerClosed
- Killed
建立 host-initialized 连接状态机的迁移是从 LocalInit -> Established。首先,用户向 stream 输入请求建立连接的字符串之后,vsock connection 会被默认的初始化为 LocalInit 状态。然后,会向 driver 发送 request pkt(VsockConnection::recv_pkt() 负责填充的数据,还记得吗?)。等待 driver 返回 response pkt 之后,状态机变化为 Established,表明连接已经建立了。
状态机改变之后会产生一些副作用(side effects),比如变为 Established 之后要向 stream 发送 "OK {port}\n" 字符串。
VsockMuxer::apply_conn_mutation() 方法是产生副作用的方法之一,分为三个步骤:保存之前的状态,改变 connection 状态或感兴趣的 evset,产生副作用。
图上绿色的模块都是来自 VsockConnection 的方法,recv_pkt() 和 send_pkt() 是发送和接收来自 driver 的数据,notify() 则可能改变 evset。
这里说的副作用主要是以下三点:
- LocalInit -> Established 时向 stream 发送 "OK {port}\n" 字符串(如上图)。
- Connection 有待发送数据,将 ConnRx(key) 插入到 rxq 中。
- 根据新的 evset 移除 listener/更新 evset/新增 listener。比如如果对端没有 buffer 剩余空间了,这时候需要将 EPOLLIN 从 host stream 的 evset 中移除(即阻塞 host stream 的写入,由 notify() 完成),等待 credit 更新后再重新接收 EPOLLIN 信号。