跳到主要内容

内存与队列

ax-net 的数据面内存模型以有界队列和明确拷贝边界为核心。当前实现不承诺端到端 zero-copy;它优先保证嵌入式/unikernel 场景下的内存上限、锁边界和协议栈所有权清晰。RX 方向从驱动 buffer 进入 Router 队列,再进入 smoltcp socket buffer,最终复制到用户 buffer;TX 方向从用户 buffer 写入 smoltcp socket buffer,再进入 Router TX buffer、设备 TX queue,最后交给驱动发送。

核心源码:

源码职责
consts.rssocket buffer、Router packet buffer、设备 RX/TX queue 容量
router.rsBoundedPacketQueueQueuedPacketRouter.rx_buffer / tx_buffer、RX/TX worker
service.rsService::poll() 中的 RX drain、smoltcp poll、TX dispatch 顺序
device/driver.rsrd-net buffer 适配、VecRxBuffer / VecTxBuffer、RX prefetch
device/ethernet.rsEthernet 解封装、ARP pending packet、driver TX buffer 写入
tcp.rsudp.rsraw.rs用户 buffer 与 smoltcp socket buffer 之间的收发拷贝

源码表把常量、队列实现、协议 buffer 和 driver adapter 分别定位到其所有者。理解这些入口后,下面的总体模型可以用所有权域解释内存,而不是按文件或函数调用顺序罗列复制点。

1. 总体模型

数据面把 packet 所有权划分到用户缓冲区、smoltcp socket buffer、串行 Router 核心、设备 Worker 队列和驱动 ring。每次跨域都由明确的复制或拥有型队列消息完成,下面的架构图用于识别容量配置、背压和统计分别发生在哪一层。

内存域典型对象所有者生命周期
Driver bufferNetRxBuffer / NetTxBufferrd_net::RxQueue / TxQueue具体网卡驱动或 RdNetDriver单个收发操作或驱动队列周期
Router queue / packet bufferQueuedPacketRxPacketTxPacketRouter.rx_bufferRouter.tx_bufferRouter / device workerpacket 在设备 worker 与 net-poll worker 之间流转期间
smoltcp socket bufferTCP SocketBuffer、UDP/raw PacketBuffer全局 SocketSet 中的具体 socketsocket 生命周期内固定分配
用户 buffersyscall 传入的 Read / Write / IoBufMut调用者线程单次 send() / recv() 调用

总体关系如下:

ax-net packet 内存与所有权边界

图中的 IP 数据面 queue 都是有界队列。真实设备 RX 先由设备 worker 入队,再由当前 poll owner drain;真实设备 TX 先 dispatch 到 per-device TX queue,再由设备 TX worker 送入驱动。普通用户线程只读写 socket buffer 并请求 poll;UDP 析构会临时取得 required ownership 以同步排空 egress。

关键原则:

  • 设备 worker 不持有 ServiceSocketSet 锁。
  • PollOwnership 保证同一时刻只有一个线程推进 smoltcp Interface;通常是 net-poll worker,UDP close flush 是例外 owner。
  • Router queue 使用 inline [u8; STANDARD_MTU],避免每包堆分配。
  • shared RX queue 满时 worker 保留最多 16 项本地批次并重试;TX queue/ARP pending 等其它有界队列按各自策略丢弃并计数。
  • loopback 普通 TX 直接注入 Router.rx_buffer,少一次队列 hop。

内存域列表说明同一个 packet 在不同阶段由不同对象拥有,不能用 driver ring 容量代表协议缓冲区容量。容量常量将把这些域的默认上限和实例化范围具体化。

2. 容量常量

默认容量集中在 consts.rs,共同约束协议缓冲区、共享 RX queue、每设备 TX queue 和 Worker 批处理的内存上限。这些常量的单位并不完全相同,维护时必须区分字节、packet 数与 metadata slot,避免用单一乘法错误估算总占用。

pub const STANDARD_MTU: usize = 1500;

pub const TCP_RX_BUF_LEN: usize = 64 * 1024;
pub const TCP_TX_BUF_LEN: usize = 64 * 1024;
pub const UDP_RX_BUF_LEN: usize = 64 * 1024;
pub const UDP_TX_BUF_LEN: usize = 64 * 1024;
pub const RAW_RX_BUF_LEN: usize = 64 * 1024;
pub const RAW_TX_BUF_LEN: usize = 64 * 1024;

pub const SOCKET_BUFFER_SIZE: usize = 64;
pub const LISTEN_QUEUE_SIZE: usize = 512;
pub const DEVICE_RX_QUEUE_SIZE: usize = 256;
pub const DEVICE_TX_QUEUE_SIZE: usize = 128;
pub const ETHERNET_MAX_PENDING_PACKETS: usize = 128;

常量定义给出编译期上限,但实际预算还取决于它被全局、每设备或每 socket 实例化的次数。下表按所有权范围换算默认量级,并明确未包含的 metadata,避免把理论 data buffer 大小当作完整 heap 占用。

常量作用范围默认内存预算
SOCKET_BUFFER_SIZERouter.rx_bufferRouter.tx_buffer 的 packet metadata 槽位;每个 data buffer 是 STANDARD_MTU * SOCKET_BUFFER_SIZERX 约 96 KiB,TX 约 96 KiB
DEVICE_RX_QUEUE_SIZE所有真实设备共享的 device-to-Router RX queue256 × 1500B,约 384 KiB
DEVICE_TX_QUEUE_SIZE每个真实设备独立的 TX queue每设备 128 × 1500B,约 192 KiB
TCP_RX_BUF_LEN / TCP_TX_BUF_LEN每个 TCP socket 的 smoltcp byte buffer每连接约 128 KiB
UDP_RX_BUF_LEN / UDP_TX_BUF_LEN每个 UDP socket 的 smoltcp packet data buffer每 socket 约 128 KiB,外加 metadata
RAW_RX_BUF_LEN / RAW_TX_BUF_LEN每个 raw socket 的 smoltcp packet data buffer每 socket 约 128 KiB,外加 metadata
ETHERNET_MAX_PENDING_PACKETSARP 解析期间暂存的待发送 Ethernet frame每 Ethernet device 至少 128 × 1514B,约 189 KiB,另加 metadata
LISTEN_QUEUE_SIZETCP ListenTable 每个 listen 端口的 accept/SYN 预创建队列容量每 listen 端口 512 项

DEVICE_RX_QUEUE_SIZE 有意大于 SOCKET_BUFFER_SIZE。前者吸收设备 RX worker 与 net-poll worker 调度之间的短 burst;后者是 smoltcp-facing 的单轮 packet buffer。APK index 下载、TCP slow start 或 QEMU user networking burst 都可能在短时间内产生超过 64 个 MTU packet 的入站积压,因此 RX worker 共享队列需要更大的缓冲。

3. RX 内存路径

RX 从真实设备到用户态 recv() 依次经过 driver buffer、设备 Worker 本地 batch、共享 RX queue、Router.rx_buffer 和协议 socket buffer。流程图标出每次复制及所有权转移,尤其用于解释共享队列拥塞为何由 Worker 保留本地 packet 后重试。

流程图按对象展示 RX 所有权域,下面的调用链则把这些域对应到具体 Rust buffer 类型和函数入口。两种视图结合后,可以区分 driver adapter 的首次复制、Worker inline queue 的第二次复制和 smoltcp 对 socket payload 的存储。

NIC / virtqueue / rd-net RX memory
-> NetRxBuffer / VecRxBuffer
-> EthernetDevice::recv()
-> device_rx_worker local PacketBuffer<InterfaceId>
-> BoundedPacketQueue<RxPacket> (QueuedPacket inline copy)
-> Router.rx_buffer (PacketBuffer<InterfaceId>)
-> smoltcp Interface::poll()
-> TCP/UDP/raw socket RX buffer in SocketSet
-> socket recv()
-> user Write / IoBufMut

调用链把总体 RX 图映射到实际 buffer 类型,并显示用户 socket 之前至少存在 driver、Worker queue 和 Router 三个所有权转换。驱动接收边界从第一个转换开始说明何时必须复制和释放底层 buffer。

3.1 驱动接收边界

RdNetDriverrd-net 的 RX queue 适配成 EthernetDriver::receive()。当前适配层是 copy-based:

rd_net::RxQueue::receive()
-> VecRxBuffer { data: Vec<u8> }
-> EthernetDevice::recv()

RX_PREFETCH_TARGET = 1,只允许一个小的预取窗口,避免在 driver adapter 中形成新的大缓存层。EthernetDevice::recv() 解析 Ethernet frame:

  • ARP frame:更新 neighbor / pending packet 状态,不进入 smoltcp socket。
  • IPv4 frame:校验链路层目标后,把 IP payload 写入调用方提供的 PacketBuffer<InterfaceId>
  • 其它 frame:忽略或返回没有可交付 packet。

驱动接收边界中的复制会在释放 driver buffer 前完成,使后续处理不依赖 DMA/ring 生命周期。RX Worker 随后把这些拥有型 packet 组织成本地 batch,并处理共享队列背压。

3.2 RX Worker 队列

device_rx_worker 用一个本地 PacketBuffer 暂存从 Device::recv() 得到的 IP packet:

let mut rx_buffer = PacketBuffer::new(
vec![PacketMetadata::EMPTY; DEVICE_RX_WORKER_BATCH],
vec![0u8; STANDARD_MTU * DEVICE_RX_WORKER_BATCH],
);

DEVICE_RX_WORKER_BATCH = 16,所以单个 RX worker 一轮最多先从设备搬 16 个 packet 到本地 PacketBuffer/VecDeque,再逐个复制到共享 RX queue。这个 batch 属于每个 worker 的持久任务状态,是容量 16 的有界背压,不是无界全局 backlog。

随后把 packet 复制进共享 RX queue:

local PacketBuffer slice
-> QueuedPacket { bytes: [u8; STANDARD_MTU], len }
-> RxPacket { interface_id, bytes }
-> RouterQueues::rx.push()

共享 RX queue 是 Arc<BoundedPacketQueue<RxPacket>>,所有非 loopback 设备共用一个队列。队列项保存 ingress InterfaceIdRouter::poll() drain 时才从 IP header 生成包含 traffic-class 的 RxMetadata。队列满时:

  • 未入队项及其 L2 frame 长度留在 worker 的 local_batch
  • 打印 "{ifname}: RX queue is full, delaying packet"
  • 调用 request_poll()yield_now(),给 poll owner 机会 drain backlog,然后重试。

Worker 列表强调本地 batch 在共享队列满时仍由当前任务拥有,因而可以安全 yield 后重试。进入共享队列后,packet 的下一任所有者是 Router::poll(),它负责构造协议 metadata。

3.3 Router 接收缓冲区

Service::poll() 首先调用 Router::poll(),把共享 RX queue drain 到 smoltcp-facing Router.rx_buffer

while !self.rx_buffer.is_full() {
let Some(packet) = self.queues.rx.pop() else {
break;
};
let bytes = packet.bytes.as_slice();
snoop_tcp_packet(bytes, sockets);
snoop(packet.interface_id, bytes);
let metadata = rx_metadata(packet.interface_id, bytes);
let Ok(dst) = self.rx_buffer.enqueue(bytes.len(), metadata) else {
break;
};
dst.copy_from_slice(bytes);
}

这一步又发生一次 copy:QueuedPacket 的 inline bytes 复制到 Router.rx_buffer。随后 smoltcp Interface::poll() 通过 Router::receive() 获取 RxToken 并解析 IP/TCP/UDP/raw,最后写入具体 socket 的 RX buffer:

  • TCP:写入 TCP socket 的 byte stream RX buffer。
  • UDP:写入 UDP packet buffer 和 metadata。
  • raw:写入 raw packet buffer;connected peer 不匹配时,raw.rs 可把 packet 暂存到 deferred_rx

Router 接收步骤把 ingress 身份和 IP traffic class 转为 smoltcp-facing metadata,并在同一串行 poll 中交给 socket。用户接收只观察协议 socket buffer,不需要了解前面的设备队列或 Router slot。

3.4 用户接收边界

用户执行 recv() 时,不直接接触 Router queue。IP socket 从 smoltcp socket buffer 复制到 syscall 提供的用户 buffer:

TCP recv:
smoltcp TCP SocketBuffer
-> socket.recv(|buf| dst.write(buf))
-> user buffer

UDP recv:
smoltcp UDP PacketBuffer
-> socket.recv() / socket.peek()
-> dst.write(payload)
-> user buffer

raw recv:
smoltcp raw PacketBuffer or deferred_rx / loopback_rx
-> parse/filter
-> dst.write(payload)
-> user buffer

阻塞等待由 GeneralOptions::recv_poller_with() 处理:如果 socket RX buffer 为空,当前调用注册 waker 并等待;等待期间协议推进仍由 net-poll worker 完成。

4. TX 内存路径

TX 从用户态 send() 写入协议 socket buffer 开始,经 smoltcp 生成 IP packet、Router.tx_buffer 选路、per-device TX queue 和 Ethernet framing 后进入驱动。流程中的队列边界让协议核心不阻塞在硬件发送,但队列满时会形成明确的 drop 统计。

TX 图强调 route dispatch 与 ARP pending 的分支,下面的调用链进一步标出实际 packet 容器从用户 IoBufNetTxBuffer 的变化。队列中的 QueuedPacket 拥有独立 inline bytes,因此 driver 发送失败不会引用已经释放的 smoltcp buffer。

user Read / IoBuf
-> smoltcp TCP/UDP/raw socket TX buffer
-> smoltcp Interface::poll()
-> Router.tx_buffer
-> Router::dispatch()
-> per-device BoundedPacketQueue<TxPacket> (QueuedPacket inline copy)
-> device_tx_worker
-> EthernetDevice::send()
-> NetTxBuffer / VecTxBuffer
-> driver transmit / rd-net TX queue

TX 调用链对应图中的主分支,ARP unresolved 时还会在 EthernetDevice 内增加 pending 所有权。用户发送边界只负责把数据交给 socket buffer,不承诺 packet 已进入 Router 或 driver。

4.1 用户发送边界

socket send() 只写协议 socket buffer,并请求 net-poll worker:

send()
-> socket.can_send()
-> socket.send(|buffer| src.read(buffer))
-> request_poll()

TCP 的用户 bytes 进入 TCP TX byte buffer。UDP/raw 发送会申请一个 packet-sized smoltcp buffer,然后把用户 payload 写进去;UDP MSG_MORE corking 会在 socket 层暂存第一次 send 的 endpoint/source,最终 flush 时一次性写入 smoltcp UDP packet buffer。

4.2 Router 发送缓冲区

net-poll worker 执行 Interface::poll() 时,smoltcp 根据 TCP/UDP/raw socket 状态生成完整 IP packet。Router::transmit() 返回 TxTokenTxToken::consume() 把 packet 写入 Router.tx_buffer

fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
f(self
.0
.enqueue(len, TX_INTERFACE_PLACEHOLDER)
.expect("This was checked before creating the TxToken"))
}

这里的 metadata 使用内部占位 InterfaceId(0),因为真实出接口必须等 IP header 生成后才能按 (dst, src) 查 route table。

4.3 设备发送队列

Router::dispatch()Router.tx_buffer 取完整 IP packet:

  • loopback:直接复制到 Router.rx_buffer
  • IPv4 limited broadcast:复制到所有非 loopback device 的 TX queue。
  • 普通单播:解析 src/dst,调用 select_route_for_source(dst, src),把 packet 复制到选中设备的 tx_queue

普通 Ethernet TX 入队形态:

Router.tx_buffer packet slice
-> QueuedPacket { bytes: [u8; STANDARD_MTU], len }
-> TxPacket { next_hop, bytes }
-> DeviceHandle.tx_queue

per-device TX queue 满时,当前 packet 丢弃并 warning。TX queue 是每个真实设备独立的,避免一个慢设备阻塞其它接口的发送 backlog。

4.4 驱动发送边界

device_tx_worker 从 per-device queue 取 TxPacket,持有设备锁调用 Device::send(next_hop, packet)。对 Ethernet 设备而言:

TxPacket IP payload
-> EthernetDevice::send(next_hop)
-> neighbor cache / ARP
-> alloc_tx_buffer(frame_len)
-> copy Ethernet header + IP payload
-> driver.transmit(tx_buf)

如果 ARP 未解析,EthernetDevice 会把 IP packet 暂存在 pending_packets,发送 ARP request,等 ARP reply 后再 flush。pending_packets 也是有界 PacketBuffer,上限由 ETHERNET_MAX_PENDING_PACKETS 控制。

RdNetDriver::alloc_tx_buffer() 按请求帧长创建 VecTxBuffertransmit() 在提交前把短帧补齐到 Ethernet 最小帧长 ETH_ZLEN = 60。因此当前普通 Ethernet TX 至少包含两次 copy:用户 buffer 到 smoltcp socket buffer、Router/设备队列到 driver TX buffer;不同协议还可能有额外的协议封装 copy。

5. Loopback 内存路径

loopback 是普通 socket TX 的特殊快速路径,Router::dispatch() 选择 InterfaceId::LOOPBACK 后直接把 IP packet 注入协议侧 RX buffer。它跳过设备 Worker、Ethernet header 和驱动 ring,因此统计口径与真实网卡不同,也不会消耗 per-device queue 容量。

loopback 图省略了真实设备域,下面的调用链用具体函数名说明 packet 如何在同一 Router 内从 TX buffer 回到 RX buffer。虽然路径更短,它仍需要后续 Interface::poll() 才会把数据交付接收 socket。

user send()
-> smoltcp socket TX buffer
-> Router.tx_buffer
-> Router::dispatch()
-> inject_loopback_rx_direct()
-> Router.rx_buffer
-> smoltcp Interface::poll()
-> peer socket RX buffer
-> user recv()

这个路径不进入 DeviceHandle.tx_queue,也不进入共享 RouterQueues::rx。它仍会把 IP packet 从 Router.tx_buffer 复制到 Router.rx_buffer,但避免了早期实现中的 to_vec() 分配和额外 RX queue hop。inject_loopback_rx_direct() 在写入 rx_buffer 前调用 snoop_tcp_packet(),因此 loopback TCP SYN 能在同一轮 poll 中预创建 accept child socket。

send_on_device() 的 loopback 分支仍可能使用共享 RX queue,这是控制面指定设备发送路径;普通 socket loopback TX 走 direct injection。

6. 满队列背压

当前普通 Ethernet 数据面不把 Router queue 满直接映射为用户态 EAGAIN,因为 socket send 成功只表示数据进入协议缓冲区。共享 RX queue 和 per-device TX queue 分别采用重试与丢弃策略,维护容量或错误传播时必须保持两种语义可区分。

满的位置行为用户可见性
smoltcp socket TX buffer 满send() 返回 WouldBlock 或阻塞等待直接可见
smoltcp socket RX buffer 满smoltcp 按协议窗口/丢包策略处理间接可见
shared RX queue 满RX worker 保留本地 batch,warning,request poll + yield 后重试形成有界背压;不在此处增加 rx_dropped
Router.rx_buffer 满停止 drain,下一轮继续;直接注入失败时丢包 warningTCP 通过重传恢复,UDP/raw 可能丢包
per-device TX queue 满丢弃出站 packet,warningTCP 通过重传恢复,UDP/raw 可能丢包
ARP pending queue 满丢弃等待 ARP 的出站 packet,warning连接建立或首包可能超时/重传
driver TX buffer 分配失败Device::send() 返回失败,packet 已离开 Router queue协议层后续重传或应用超时

这种策略与很多嵌入式协议栈一致:内部队列保持有界,不把所有链路层瞬时拥塞都反馈到已完成的 socket send 调用。TCP 正确性依赖重传和窗口控制;UDP/raw 本身允许丢包。

7. 内存预算示例

内存预算示例以两个 Ethernet 设备和默认容量估算 Router 与 Worker 可见的静态上限,不包含驱动自身 ring、DMA descriptor、socket payload 和 allocator 元数据。它用于比较配置量级,而不是承诺运行期一定预分配或占用该数值。

项目估算
Router.rx_buffer64 * 1500 ≈ 96 KiB
Router.tx_buffer64 * 1500 ≈ 96 KiB
shared RX queue256 * 1500 ≈ 384 KiB
per-device TX queue2 * 128 * 1500 ≈ 384 KiB
ARP pending packets2 * 128 * 1514 ≈ 379 KiB
每条 TCP 连接RX 64 KiB + TX 64 KiB
每个 UDP/raw socketRX 64 KiB + TX 64 KiB + metadata

实际内存还包括 metadata、每设备最多 16 项 RX local batch、VecDeque 元素开销、socket 对象、route/DNS/interface registry、Unix/vsock buffer 和驱动队列。特别地,Unix datagram/seqpacket 使用 async_channel::unbounded(),不受上述 Router queue 常量限制。调整常量时应按“共享一次”“每设备”“每 socket”“每连接”分别乘算。

包长度、背压与网卡统计口径

统计图说明复制次数与计数口径是两个不同问题:Ethernet 字节数按实际二层帧累计,loopback 则按 IP packet 累计。RX 队列短暂满不会立即增加 drop,而 TX queue 或 ARP pending 的有界失败会进入对应丢弃统计。

8. 模型边界

当前实现选择有界复制队列和单协议核心,尚未提供零拷贝、page loan 或跨设备 scatter-gather 等更复杂的内存模型。下面的边界用于防止文档把驱动内部 DMA 优化误写成端到端零拷贝,也为后续设计评审明确需要新增的所有权契约。

  • 端到端 zero-copy。
  • DMA buffer 直接挂入 smoltcp socket buffer。
  • RSS / 多队列 NIC / per-queue poll。
  • Linux sk_buff 类动态链式 backlog。
  • MSG_ZEROCOPYio_uring send/recv path。

如果后续要实现 zero-copy,需要同时改造 rd-net buffer ownership、EthernetDevice frame 封装、Router queue 生命周期和 smoltcp token/socket buffer 接口。单独把某个队列改成 Arc<[u8]> 只能减少局部 copy,不能形成完整 zero-copy 数据面。