RFC-0109:快速 UDP 套接字

RFC-0109:快速 UDP 套接字
状态已接受
区域
  • Netstack
说明

使用 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 语义,客户端希望系统在处理传入/传出数据包时,针对其他客户端发起的更改提供同步 sendrecv 行为。具体而言,客户端希望这些更改应用于所有尚未收到的载荷,而不应用于所有已发送的载荷。

在当前实现中,同步行为是有保证的,因为:

  1. Netstack 负责所有相关的数据包处理。
  2. 客户端通过同步 FIDL 调用发送和接收载荷。
  3. 客户端通过设置套接字选项来请求更改数据包处理,这些选项也以同步 FIDL 调用的形式实现。

通过 zircon 套接字传输替换同步 FIDL 调用(不确认 I/O)会破坏这些语义。下面介绍了由此产生的问题和解决方案。

发送路径

问题

发送路径上,客户端通过设置套接字选项来修改载荷的处理方式。由于 setsockopt 是通过同步 FIDL 实现的,因此选项的新值可能会应用于在调用之前排队的软件包。例如:

  1. 客户端将 IP_TOS 设置为某个值 A,同步更新 Netstack 中的状态。
  2. 客户端调用 sendto,该方法会将载荷排入 Zircon 套接字。
  3. 客户端将 IP_TOS 设置为某个值 B。
  4. Netstack 会将载荷出列,并通过 IP_TOS=B 将其发送到网络上。
解决方案

修改用于设置与发送路径相关的套接字选项的 FIDL 处理程序,以阻塞直到 zircon 套接字被 Netstack 的出队 goroutine 排空。之后,处理程序会修改相关状态并返回到客户端。由于在调用 setsockopt 之前发送的所有载荷都已从 Zircon 套接字出列到 Netstack 中,因此这些载荷都不会使用新设置进行处理。

接收路径

问题

在接收路径上,客户端可以通过设置套接字选项来请求提供有关载荷及其传送的辅助数据的控制消息。同样,由于 setsockopt 是同步的,因此存在偏差空间。例如:

  1. Netstack 将载荷排入 Zircon 套接字。
  2. 客户端设置 IP_RECVTOS
  3. 客户端将载荷出列,并将其返回给用户,但不包含 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;
    }
  });
};

在客户端中,缓存当前请求的一组控制消息,并使用该组消息通过套接字过滤由网络堆栈传递的控制消息状态,具体操作步骤如下:

+-----------------------------------------+
| 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 套接字复制内存时增加开销。我们认为,这种权衡是可以接受的,原因有以下两点:

  1. 我们可能会支持的控制消息大小约为 100 字节。 假设 MTU 为 ~1500 字节,则复制开销小于 10%。
  2. 绝大多数控制消息状态都是“按数据包”的,这意味着 Netstack 会将这些状态与每个数据包一起保存在内存中,并在数据包排入套接字队列后释放该内存。因此,系统的总体内存消耗量不应增加。

此外,我们还将使用微基准来跟踪相关指标,并在每次添加新的控制消息时运行该微基准。如果结果表明这种权衡不再值得,我们可以恢复到慢速路径(无论如何,我们都需要保留慢速路径,以便支持 ICMP 数据报)。

序列化协议

通过 Zircon 套接字执行 I/O 的最简单方法是定义一个 FIDL 表来保存 UDP 载荷及其元数据,并使用 FIDL at rest 对其进行序列化。此方法的缺点是,它会强制发送者和接收者将载荷和元数据序列化到临时缓冲区中,从而保证至少有一个载荷和元数据副本。

由于矢量化套接字读取和写入可能很快就会推出,因此最好构建一种能够利用矢量化 API 避免该复制的协议。

协议

sendrecv 元数据定义 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;
};

Describe 中返回给客户端的 fuchsia.io/NodeInfo 中,指定用于接收包含序列化元数据表的字节的缓冲区的大小:

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;
  };
};

Let:

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 at restSendMsgMeta 进行反序列化。

接收路径的工作方式完全对称。

uint32_t tx_meta_buf_size = fidl::MaxSizeInChannel<fuchsia_posix_socket::wire::SendMsgMeta, fidl::MessageDirection::kSending>();

它用于计算发送方向上消息大小的界限,前提是消息中不存在未知字段。Netstack 可以安全地假设 RecvMsgMeta 中的所有字段都是已知的,因为 Netstack 本身会序列化该消息。它可以假设 SendMsgMeta 中的所有字段都是已知的,因为:

  1. 客户端的 ABI 修订版本限定了客户端序列化表中的所有字段。
  2. 如果平台不支持客户端的 ABI 修订版本,则会拒绝运行该客户端。
  3. 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 中提供两个元素:

  1. 表示路由表的已知状态;由所有套接字共享,并在路由表发生任何更改时失效。
  2. 表示特定套接字的已知状态;当套接器发生任何可能改变其行为的更改时(例如调用 bindconnectsetsockopt(..., SO_BROADCAST, ...)setsockopt(..., SO_BINDTODEVICE, ...) 等),此状态会失效。

性能

SOCK_DGRAM 套接字的吞吐量预计会增加大约一倍;此估计值基于 https://fxbug.dev/42094952 之后出现的性能回归。

CPU 利用率预计会下降,但降幅未知。

工效学设计

此变更对人体工程学没有重大影响,因为下游用户不会直接使用此处显示的接口。

向后兼容性

通过以下方式保持 ABI 兼容性:最初保持 fuchsia.posix.socket/Provider.DatagramSocket 不变,并将新功能实现为 DatagramSocket2。在完成必要的 ABI 过渡后,将 DatagramSocket2 重命名为 DatagramSocket 并移除之前的实现。在另一次 ABI 过渡之后,移除了 DatagramSocket2

安全注意事项

此提案对安全性没有影响。

隐私注意事项

此提案不会影响隐私权。

测试

现有单元测试涵盖了受影响的功能。

文档

除了此处显示的 FIDL 文档注释之外,无需提供任何文档。

缺点、替代方案和未知因素

此提案通过在用户空间中构建机制来解决此动机。另一种可能性是在内核中构建此机制。

将其转换为内核的草图:

  1. 对于每个 zx::socket 端点,内核都会维护从 SocketAdddresmax_size 的映射。
  2. 我们会添加一些 zx_socket_add_route / zx_socket_remove_route 系统调用,以便在对等端点上修改该映射。
  3. 我们会添加一些 zx_socket_send_to / zx_socket_receive_from 系统调用,用于消耗/提供地址。

如果用户空间使用不在映射中的地址调用 zx_socket_send_to,操作会失败,用户空间需要向网络堆栈发送同步消息,请求将相应路由添加到 zx::socket。如果该请求失败,则地址操作也会失败并显示错误。

优点

在内核方法中,发送 UDP 数据包(在快速情况下)是单个系统调用 (zx_socket_send_to),而不是两个系统调用 (zx_object_wait_manyzx_socket_write)。

由于用户空间方法可能存在优化,因此这可能不是专业版。我们发现,我们总是使用 zx_object_wait_manytime::infinite_past,因此可以优化操作,使其在不进行系统调用的情况下完成工作,前提是使用原子操作来维护必要的状态。这可能还需要将句柄表放在 vDSO 中,但情况可能并非如此,也可能无法实现。

对于具有运行时的客户端,一种替代方法是使用 zx_object_wait_async 而不是 wait_many 来维护本地缓存,从而使快速路径避免额外的系统调用。

我们还避免了对静态 FIDL 的依赖以及 FIDL 中固有的额外数据复制,因为消息载荷已纳入系统调用中,可以直接将载荷复制到最终目的地。

缺点

在内核方法中,当路由表发生更改时,没有明显的方法可以实现 O(1) 路由取消。如上所述,我们可以向 zx_socket_remove_route 添加一个标志来移除所有路由(这可能也是我们希望实现的),但网络堆栈需要针对每个套接字发出 zx_socket_remove_route

我们可以做得非常精细,让 zx_socket_add_route 接受用于取消的 eventpair,但这有点过于复杂了。

将这些结构烘焙到内核中,也会给平台带来另一个协议演进模型,从而增加成本;现在,特定 FIDL 类型与系统调用之间的耦合会更加紧密,但它们不会自动保持同步。

未来工作

使用 eventpair 来指示客户端缓存的有效性会在 sendrecv 路径上产生额外的系统调用。可以通过使用 VMO 来指示有效性,从而消除此系统调用。在这种设计中,eventpair 在逻辑上被替换为映射到客户端地址空间中的 VMO 的偏移量。随后,客户端可以通过简单地读取 VMO 来检查有效性。

未知

此外,还有一个问题是关于如何处理 SendControlData。也许需要将此作为 zx_socket_send_to 的一个额外参数,或者作为操作的一个标志。