| RFC-0109:快速 UDP 套接字 | |
|---|---|
| 状态 | 已接受 |
| 区域 |
|
| 说明 | 使用 zircon 套接字实现网络 UDP 套接字数据传输。 |
| Gerrit 更改 | |
| 作者 | |
| 审核人 | |
| 提交日期(年-月-日) | 2021-06-17 |
| 审核日期(年-月-日) | 2021-06-25 |
摘要
使用 zircon 套接字实现网络数据报套接字数据传输。 使用客户端缓存的网络状态实现客户端参数验证。
设计初衷
提高数据报套接字吞吐量并降低 CPU 利用率。
在 https://fxbug.dev/42094952 之前,数据报网络套接字是使用 zircon 套接字实现的;客户端会使用 zx_socket_write 发送数据,并使用 zx_socket_read 接收数据。如果调用应用提供了元数据(例如目标地址),则会使用最小协议来携带这些元数据。
这种方法被弃用,因为在某些情况下,它不会向调用方提供错误信号。假设某个应用希望向网络堆栈没有路由的远程发送载荷;为了符合面向 Linux 的第三方软件的预期,实现必须返回错误,以表明未代表调用方执行任何操作。
当前实现是在
fuchsia.posix.socket/DatagramSocket.{Recv,Send}Msg.
中定义的。由于支持 FIDL 响应,因此发送和接收数据都需要进行多次上下文切换。忽略 FIDL 序列化:
+-----------------------------+-------------------------+
| Client | Netstack |
+-----------------------------+-------------------------+
| `zx_channel_call(...)` | |
| | |
| | `zx_channel_read(...)` |
| | `zx_channel_write(...)` |
| | |
| (`zx_channel_call` returns) | |
+-----------------------------+-------------------------+
反向操作(接收数据)看起来非常相似。将其与未确认的 I/O 进行比较:
+-------------------------+------------------------+
| Client | Netstack |
+-------------------------+------------------------+
| `zx_channel_write(...)` | |
+-------------------------+------------------------+
| `zx_channel_write(...)` | `zx_channel_read(...)` |
+-------------------------+------------------------+
虽然可以使用 zircon 通道和 FIDL 实现未确认的 I/O,但它不会提供反压,并且可能会导致内存失控增长。因此,我们建议 使用 zircon 套接字:
+------------------------+-----------------------+
| Client | Netstack |
+------------------------+-----------------------+
| `zx_socket_write(...)` | |
+------------------------+-----------------------+
| `zx_socket_write(...)` | `zx_socket_read(...)` |
+------------------------+-----------------------+
设计
元数据验证
利用未确认的 I/O 预先假定数据报套接字元数据(例如目标地址)可以在本地计算机上完全验证,而无需与网络交互,并且此验证的结果可以在与套接字的多次交互中缓存(以便分摊验证费用)。
请注意,此假设对于 IPPROTO_ICMP 套接字尤其不成立 - 它们的载荷会经过有效性检查 - 因此,我们将保留现有的基于 FIDL 的协议,并在性能不重要且需要深度验证的情况下使用该协议。
从现有类型中提取 FIDL 以便重复使用,并重命名 DatagramSocket 以使其更清晰:
protocol BaseDatagramSocket {
compose BaseSocket;
/// Retrieves creation information from the socket.
GetInfo() -> (Domain domain, DatagramSocketProtocol proto) error fuchsia.posix.Errno;
}
protocol SynchronousDatagramSocket {
compose BaseDatagramSocket;
/// Receives a message from the socket.
RecvMsg(bool want_addr, uint32 data_len, bool want_control, RecvMsgFlags flags) -> (fuchsia.net.SocketAddress? addr, bytes data, RecvControlData control, uint32 truncated) error fuchsia.posix.Errno;
/// Sends a message on the socket.
SendMsg(fuchsia.net.SocketAddress? addr, bytes:MAX data, SendControlData control, SendMsgFlags flags) -> (int64 len) error fuchsia.posix.Errno;
}
使用验证函数定义新的 FIDL 协议:
/// Matches the definition in //zircon/system/public/zircon/types.h.
const uint32 ZX_WAIT_MANY_MAX_ITEMS = 64;
/// Describes an intent to send data.
type SendMsgArguments = table {
/// The destination address.
///
/// If absent, interpreted as the method receiver's connected address and
/// causes the connected address to be returned in [`SendMsgBoardingPass.to`].
///
/// Required if the method receiver is not connected.
1: to fuchsia.net.SocketAddress;
}
/// Describes a granted approval to send data.
type SendMsgBoardingPass = resource table {
/// The validated destination address.
///
/// Present only in response to an unset
/// [`SendMsgArguments.to`].
1: to fuchsia.net.SocketAddress;
/// Represents the validity of this structure.
///
/// The structure is invalid if any of the elements' peer is closed.
/// Datagrams sent to the associated destination after invalidation will be
/// silently dropped.
2: validity vector<zx.handle:<EVENTPAIR, zx.RIGHTS_BASIC>>:ZX_WAIT_MANY_MAX_ITEMS;
/// The maximum datagram size that can be sent.
///
/// Datagrams exceeding this will be silently dropped.
3: maximum_size uint32;
}
protocol DatagramSocket {
compose BaseDatagramSocket;
/// Validates that data can be sent.
///
/// + request `args` the requested disposition of data to be sent.
/// - response `pass` the constraints sent data must satisfy.
/// * error the error code indicating the reason for validation failure.
SendMsgPreflight(SendMsgArguments args) -> (SendMsgBoardingPass pass) error fuchsia.posix.Errno;
};
定义要在 zircon 套接字上发送的 FIDL 结构:
/// A datagram received on a network socket, along with its metadata.
type RecvMsgPayload = table {
1: from fuchsia.net.SocketAddress;
2: control RecvControlData;
3: datagram bytes:MAX;
};
/// A datagram to be sent on a network socket, along with its metadata.
type SendMsgPayload = table {
1: args SendMsgArguments;
2: control SendControlData;
3: datagram bytes:MAX;
};
尝试发送数据的客户端遵循以下(水平压缩)图表所示的过程:
+--------------------------------+ +---------------------------+ +----------------+
| cache.getSendMsgBoardingPass() | - Present -> | checkPeerClosed(validity) | +- ZX_OK -> | Return success |
+--------------------------------+ +---------------------------+ | +----------------+
| ^ ^ | | |
| | | | | +------------------------------+
| +------+ +----------------------------------+ | | | socket.write(SendMsgPayload) | - != ZX_OK -----+
| | Send | +- Success -> | cache.storeSendMsgBoardingPass() | | | +------------------------------+ |
| +------+ | +----------------------------------+ | | ^ |
| +--------------------+ | | | |
+- Absent -> | SendMsgPreflight() | +- (ZX_OK, ZX_SIGNAL_PEER_CLOSED) -+ +- (ZX_ERR_TIMED_OUT) -+ | |
+--------------------+ | | +- No -+ |
| ^ | +-----------------------------------+ | | |
| | +-> | cache.removeSendMsgBoardingPass() | | +---------------------+ |
| | +-----------------------------------+ +-> | size > maximum_size | |
| | | +---------------------+ |
| | | +--------------+ | |
| +----------+ | | <-------------------------------------- Yes -+ |
| | Return error | |
+- Failure ------------------> | | <---------------------------------------------------------------+
+--------------+
其中,客户端的 cache“实现”了 SendMsgPreflight;它大致是从 fuchsia.net.SocketAddress 到 (vector<zx::eventpair>, maximum_size) 的映射。
请注意,此策略形成的缓存最终是一致的;失效可能会交错客户端的有效性检查及其载荷到达网络堆栈。对于交付语义为尽力而为的数据报套接字,这是可以接受的。
保证同步行为
背景
根据 POSIX 语义,客户端希望系统针对其他客户端发起的对传入/传出数据包处理的更改提供同步 send 和 recv 行为。具体而言,客户端希望这些更改适用于尚未收到的所有载荷,而不适用于已发送的任何载荷。
在当前实现中,同步行为是有保证的,因为:
- Netstack 负责所有相关的数据包处理。
- 客户端通过同步 FIDL 调用发送和接收载荷。
- 客户端通过设置套接字选项来请求更改数据包处理,这些选项也作为同步 FIDL 调用实现。
将同步 FIDL 调用替换为通过 zircon 套接字传输的未确认 I/O 会破坏这些语义。由此产生的问题和解决方案如下所述。
发送路径
问题
在发送路径上,客户端通过设置
套接字选项来修改载荷的处理方式。由于 setsockopt 是通过同步 FIDL 实现的,因此该选项的新值可能会应用于在调用之前排队的数据包。例如:
- 客户端将
IP_TOS设置为某个值 A,同步更新 Netstack 中的状态。 - 客户端调用
sendto,该调用会在 zircon 套接字中将载荷排队。 - 客户端将
IP_TOS设置为某个值 B。 - Netstack 将载荷出列,并使用
IP_TOS=B将其发送到网络上。
解决方案
修改用于设置与发送路径相关的套接字选项的 FIDL 处理程序,以阻止该处理程序,直到 Netstack 的出列 goroutine 排空 zircon 套接字为止。之后,处理程序会修改相关状态并返回给客户端。由于在调用 setsockopt 之前发送的所有载荷都已从 zircon 套接字出列到 Netstack 中,因此它们都不会使用新设置进行处理。
接收路径
问题
在接收路径上,客户端可以通过设置套接字选项来请求控制消息,这些消息提供
有关载荷及其交付的辅助数据。
同样,由于 setsockopt 是同步的,因此存在偏差的空间。例如:
- Netstack 将载荷排队到 zircon 套接字中。
- 客户端设置
IP_RECVTOS。 - 客户端将载荷出列,并将其返回给用户,而不包含
IP_TOS控制消息。
解决方案
在 Netstack 中,将每个载荷与派生任何受支持的控制消息所需的最少状态一起排队。
定义一个 FIDL 方法来检索当前请求的控制消息集:
protocol DatagramSocket {
compose BaseDatagramSocket;
/// Returns the set of requested control messages.
///
/// - response `cmsg_set` the set of currently requested control messages.
RecvMsgPostflight() -> (struct {
cmsg_set @generated_name("RequestedCmsgSet") table {
/// Represents the validity of this structure.
///
/// The structure is invalid if the peer is closed.
1: validity zx.handle:<EVENTPAIR, zx.RIGHTS_BASIC>;
/// Identifies whether the `IP_RECVTOS` control message is requested.
2: ip_recvtos bool;
}
});
};
在客户端中,缓存当前请求的控制消息集,并使用该集根据以下过程过滤 Netstack 通过套接字传递的控制消息状态:
+-----------------------------------------+
| socket.read() -> (Payload, RecvMsgMeta) | -----------> ZX_OK
+-----------------------------------------+ |
| |
| +-----------------------------+ |
| | cache.getRequestedCmsgSet() | <---+
| +-----------------------------+
| | |
| | |
| | |
| Absent <--------------------+ +-----------------------------> Present
| | |
| | +-----------------------+ |
| | | Return Payload, cmsgs | +---------------------------+ |
| | +-----------------------+ | checkPeerClosed(validity) |<-----+
| | ^ +---------------------------+
| | | | | ^
| | | | | |
| | (ZX_ERR_TIMED_OUT) <-----------+ | |
| | | |
| | | |
| | +--(ZX_OK, ZX_SIGNAL_PEER_CLOSED) <-----+ |
| | | |
| | | |
| | | +--------------------------------+ |
| | +---> | cache.removeRequestedCmsgSet() | |
| | +--------------------------------+ |
| | | |
| | | |
| | +---------------------+ | |
| +--> | RecvMsgPostflight() | <---+ |
| +---------------------+ |
| | | |
| | | +------------------------------+
| +-------Failure <--+ +--> Success --> | cache.storeRequestedCmsgSet()|
| | +------------------------------+
| |
| |
| | +--------------+
| +--> | |
| | Return error |
+-----> | |
+--------------+
将控制消息状态添加到每个载荷会在将内存复制到 zircon 套接字和从 zircon 套接字复制内存时增加开销。我们认为这是一个可以接受的权衡,原因如下:
- 我们可能会支持的控制消息可以容纳大约 100 字节。 假设 MTU 为大约 1500 字节,则复制开销小于 10%。
- 绝大多数控制消息状态都是“按数据包”的,这意味着 Netstack 会将它们与每个数据包一起保存在内存中,并在数据包排队到套接字后释放该内存。因此,系统的总体内存消耗不应增加。
此外,我们将使用微基准来跟踪相关指标,并在添加新控制消息时运行该微基准。如果结果表明这种权衡不再值得,我们可以恢复到慢速路径(无论如何,我们都需要保留该路径以支持 ICMP 数据报)。
序列化协议
通过 zircon 套接字执行 I/O 的最简单方法是定义一个 FIDL 表来保存 UDP 载荷及其元数据,并使用 静态 FIDL 对其进行序列化。此方法的缺点是,它会强制发送方和接收方将载荷和元数据序列化到临时缓冲区中,从而保证至少有一个载荷和元数据的副本。
由于 矢量化套接字 读取和写入 可能很快就会可用,因此最好构建一个可以利用矢量化 API 避免复制的协议。
协议
为 send 和 recv 元数据定义 FIDL 表:
/// Metadata used when receiving a datagram payload.
type RecvMsgMeta = table {
1: fuchsia.net.SocketAddress from;
2: RecvControlData control;
};
/// Metadata used when sending a datagram payload.
type SendMsgMeta = table {
1: SendMsgArguments args;
2: SendControlData control;
};
在 fuchsia.io/NodeInfo 中返回给客户端的
Describe 中,指定用于接收包含
序列化元数据表的字节的缓冲区的大小:
type NodeInfo = strict resource union {
// Other variants omitted.
9: datagram_socket resource struct {
/// See [`fuchsia.posix.socket.DatagramSocket`] for details.
socket zx.handle:<SOCKET, zx.rights.TRANSFER | zx.RIGHTS_IO | zx.rights.WAIT | zx.rights.INSPECT>;
/// Size of the buffer used to receive Tx metadata.
tx_meta_buf_size uint64;
/// Size of the buffer used to receive Rx metadata.
rx_meta_buf_size uint64;
};
};
假设:
tx_meta_bytes = fidl_at_rest::serialize(SendMsgMeta)
tx_meta_size = len(tx_meta_bytes)
发送载荷时,客户端会构建以下缓冲区并将其排队:
( 2 ) (tx_meta_size) (tx_meta_buf_size - tx_meta_size)
+--------------+-----------------+----------------------------------+---------+
| tx_meta_size | tx_meta_bytes | Padding | Payload |
+--------------+-----------------+----------------------------------+---------+
Netstack:
- 分配一个缓冲区,其中包含
2 + tx_meta_buf_size + max_payload_size字节的空间,并将消息出列到该缓冲区中。 - 将前两个字节解释为
uint16,用于标识保存序列化元数据的缓冲区区域的大小(以字节为单位)。 - 使用静态 FIDL 反序列化
SendMsgMeta。
接收路径以完全对称的方式工作。
uint32_t tx_meta_buf_size = fidl::MaxSizeInChannel<fuchsia_posix_socket::wire::SendMsgMeta, fidl::MessageDirection::kSending>();
该方法计算消息在发送方向上的大小界限,假设不存在未知字段。Netstack 可以安全地假设 RecvMsgMeta 中的所有字段都是已知的,因为 Netstack 本身会序列化该消息。它可以假设 SendMsgMeta 中的所有字段都是已知的,因为:
- 客户端的 ABI 修订版本 限定了 客户端序列化表中的所有字段集。
- 如果平台不支持客户端的 ABI 修订版,则会拒绝运行客户端。
- Netstack 将始终与平台一起构建。在整个现有系统中,我们一直默默地依赖于此假设。在此,我们明确指出这一点。
实现
将新实现添加到 fuchsia.io/NodeInfo:
resource union NodeInfo {
/// The connection composes [`fuchsia.posix.socket/DatagramSocket`].
N: DatagramSocket datagram_socket;
};
/// A [`NodeInfo`] variant.
// TODO(https://fxbug.dev/42154392): replace with an anonymous struct inline.
resource struct DatagramSocket {
zx.handle:<SOCKET, zx.RIGHTS_BASIC | zx.RIGHTS_IO> socket;
};
将
fuchsia.posix.socket/Provider.DatagramSocket
的返回值类型更改为变体:
/// Contains a datagram socket implementation.
resource union DatagramSocketImpl {
1: DatagramSocket datagram_socket;
2: SynchronousDatagramSocket synchronous_datagram_socket;
}
…并更改行为,以便在参数允许的情况下(即调用方未请求 ICMP 套接字)返回 DatagramSocket。
初始实现预计会在每个 SendMsgBoardingPass.validity 中提供两个元素:
- 表示路由表的已知状态;由所有套接字共享,并在对路由表进行任何更改时失效。
- 表示特定套接字的已知状态;在对可能更改套接字行为的套接字进行任何更改时失效,例如调用
bind、connect、setsockopt(..., SO_BROADCAST, ...)、setsockopt(..., SO_BINDTODEVICE, ...)等。
性能
SOCK_DGRAM 套接字的吞吐量预计会大约翻一番;此
估计基于
https://fxbug.dev/42094952 之后出现的性能回归。
CPU 利用率预计会降低,但具体降幅未知。
工效学设计
此更改对工效学设计没有重大影响,因为下游用户不会直接使用此处提供的接口。
向后兼容性
通过最初保持
fuchsia.posix.socket/Provider.DatagramSocket
不变并将新功能实现为 DatagramSocket2,来保留 ABI 兼容性。
在必要的 ABI 转换之后,将 DatagramSocket2 重命名为
DatagramSocket 并移除之前的实现。在另一次 ABI 转换之后,移除 DatagramSocket2。
安全注意事项
此提案对安全性没有影响。
隐私注意事项
此提案对隐私没有影响。
测试
现有单元测试涵盖了受影响的功能。
文档
除了此处提供的 FIDL 文档注释之外,无需提供任何文档。
缺点、替代方案和未知因素
此提案通过在用户空间中构建机制来解决设计初衷。 另一种可能性是在内核中构建此机制。
将其转换为内核的草图:
- 对于每个
zx::socket端点,内核都会维护从SocketAdddres到max_size的映射。 - 我们将添加一些
zx_socket_add_route/zx_socket_remove_route系统调用,用于修改对等端点上的该映射。 - 我们将添加一些
zx_socket_send_to/zx_socket_receive_from系统调用,用于使用/提供地址。
如果用户空间使用映射中不存在的地址调用 zx_socket_send_to,则操作将失败,并且用户空间需要向 Netstack 发送同步消息,以请求将该路由添加到 zx::socket。如果该请求失败,则地址操作将失败并显示错误。
优点
在内核方法中,发送 UDP 数据包(在快速情况下)是单个系统调用 (zx_socket_send_to),而不是两个系统调用 (zx_object_wait_many、zx_socket_write)。
由于可以对用户空间方法进行优化,因此这可能不是优点。意识到我们始终使用 time::infinite_past 进行 zx_object_wait_many,我们可以优化操作以在没有系统调用的情况下完成其工作,前提是使用原子操作维护必要的状态。这可能还需要句柄表位于 vDSO 中,但情况可能并非如此,也可能无法实现。
对于具有运行时的客户端,一种替代方案是使用 zx_object_wait_async 而不是 wait_many 来维护本地缓存,从而使快速路径避免额外的系统调用。
我们还避免了对 静态 FIDL 以及 FIDL 中固有的 额外数据复制的依赖,因为消息载荷已内置到 系统调用中,这些调用可以直接将载荷复制到最终目标。
缺点
在内核方法中,当路由表发生更改时,没有明显的方法来执行 O(1) 路由取消。如上所述,我们可以向 zx_socket_remove_route 添加一个标志来移除所有路由(无论如何,这可能是可取的),但 Netstack 需要在每个套接字上发出 zx_socket_remove_route。
我们可以变得非常花哨,让 zx_socket_add_route 接受用于取消的事件对,但这会变得非常复杂。
就向平台引入另一个协议演变模型而言,将这些结构内置到内核中也很昂贵;我们现在将在特定 FIDL 类型和系统调用之间建立更紧密的耦合,而这些类型和系统调用不会自动保持同步。
后续工作
使用事件对来指示客户端缓存的有效性会在 send 和 recv 路径上产生额外的系统调用。可以通过改用 VMO 来指示有效性,从而消除此系统调用。在此类设计中,事件对在逻辑上被映射到客户端地址空间中的 VMO 的偏移量所取代。随后,客户端可以通过简单地读取 VMO 来检查有效性。
未知
还有一个问题是如何处理 SendControlData。或许这需要作为 zx_socket_send_to 的额外参数,或者作为操作的标志。