Rust in Everything
基于 embassy-net 0.9.1

TCP 与 UDP 套接字

主线位置:跨任务主线的网络版本。应用任务 write,数据进 smoltcp 套接字的发送缓冲区,Runner 被叫醒、把它拼成帧交给驱动;对端的 Runner 收到帧,数据进接收缓冲区,叫醒在 read 里等着的任务。中间的「队列」是 smoltcp 的环形缓冲区,两头的叫醒都是 waker。

sequenceDiagram
  participant W as A 应用任务
  participant RA as A Runner
  participant RB as B Runner
  participant R as B 应用任务
  R->>RB: read 空 登记 rx_waker
  W->>RA: write 进 tx 缓冲 叫醒
  RA->>RA: egress 拼帧
  RA->>RB: 驱动 一帧
  RB->>RB: ingress 进 rx 缓冲
  RB->>R: rx_waker.wake
  R->>R: read 返回数据
  RB->>RA: 延迟 ACK
  RA->>W: tx_waker.wake 腾出空间

tcp.rs 950 行(最后 170 多行是给 embedded-nal-async 用的连接池),udp.rs 403 行,dns.rs 120 行。另外有 raw.rs(raw feature,收发整个 IP 包)和 icmp.rs(icmp feature,含一个 ping 工具),结构和 UDP 相同,本页不展开。实验都在 exp-embassy/net-std:两个栈 A(10.0.0.1)、B(10.0.0.2),以太网介质,中间是 驱动接口 一页的 PipeDriver,release,M2 Max、macOS 27、rustc 1.99.0,同机负载 2.5 到 5。

TcpSocket:一个槽号加一个 Stack

pub struct TcpSocket<'a> {
    io: TcpIo<'a>,
}

#[derive(Copy, Clone)]
struct TcpIo<'a> {
    stack: Stack<'a>,
    handle: SocketHandle,
}

TcpSocket::new 拿调用方给的两块缓冲区,mem::transmute 成 &'static mut [u8](smoltcp 的 SocketSet<'static> 要求),建一个 smoltcp 的 tcp::Socket 放进 SocketSet,记下槽号。TcpSocket<'a> 还带着缓冲区的 'a,所以缓冲区活得不比套接字短;套接字被 drop 时,Drop 把它从 SocketSet 里删掉,smoltcp 不再持有那两块内存。缓冲区可以在栈上、在 static 里,大小由调用方定,embassy-net 自己不分配。QEMU 上 size_of::<TcpSocket>() 是 8 字节:一个 Stack 指针、一个槽号。

所有操作都经过 TcpIo::with_mut:

    fn with_mut<R>(&self, f: impl FnOnce(&mut tcp::Socket, &mut Interface) -> R) -> R {
        self.stack.with_mut(|i| {
            let socket = i.sockets.get_mut::<tcp::Socket>(self.handle);
            let res = f(socket, &mut i.iface);
            i.waker.wake();
            res
        })
    }

借出 Inner 的 RefCell,操作 smoltcp 的套接字,然后不管做了什么都叫醒 Runner。写了数据,要 Runner 把它发出去;读走了数据,接收窗口变大了,可能要发窗口更新;读的时候没数据、登记了 waker 返回 Pending,也叫醒一次。上一页 echo 时间线里 142 µs 那次什么也没做的 poll 就是这么来的。代价是每次挂起的读写都多一次 Runner poll;好处是套接字这边不用判断「这次操作需不需要栈跑一轮」。

read 和 write 怎么挂起

read:

            self.with_mut(|s, _| match s.recv_slice(buf) {
                // Reading into empty buffer
                Ok(0) if buf.is_empty() => {
                    // ...
                    Poll::Ready(Ok(0))
                }
                // No data ready
                Ok(0) => {
                    s.register_recv_waker(cx.waker());
                    Poll::Pending
                }
                // Data ready!
                Ok(n) => Poll::Ready(Ok(n)),
                // EOF
                Err(tcp::RecvError::Finished) => Poll::Ready(Ok(0)),
                // Connection reset. TODO: this can also be timeouts etc, investigate.
                Err(tcp::RecvError::InvalidState) => Poll::Ready(Err(Error::ConnectionReset)),
            })

smoltcp 的 recv_slice 没数据时返回 Ok(0),对端关闭后返回 Err(Finished);embassy-net 把前者变成「登记 waker、挂起」,后者变成 POSIX 风格的 Ok(0)(注释里那句「smoltcp semantics around EOF are different」说的就是这个)。登记的是 smoltcp 套接字自己的 rx_waker,smoltcp 在把收到的数据放进接收缓冲区时叫醒它。write 对称:send_slice 返回 Ok(0)(发送缓冲区满)就登记 tx_waker,smoltcp 收到 ACK、从发送缓冲区里删掉已确认的字节时叫醒它。write 只保证数据进了缓冲区,flush 才等到对端确认(发送队列为空,FIN 或 RST 也发出去了)。

TcpSocket 实现了 embedded-io-async 的 Read、Write、ReadReady、WriteReady。split 拆出 TcpReader 和 TcpWriter,两者各持一份 TcpIo 的拷贝,一个任务读、一个任务写不冲突,因为读写登记在 smoltcp 的两个不同的 waker 槽里。

smoltcp 的这两个槽和 embassy-sync 的 WakerRegistration 不一样,它的 register 直接覆盖旧的 waker,不叫醒被换掉的那个,文档写的是「If another waker was previously registered, it is overwritten and will no longer be woken」。

accept 和 connect

embassy-net 没有监听器。accept 把这一个套接字设成 Listen,然后等它离开 Listen / SynSent / SynReceived;连接建立后,这个套接字就是这条连接。模块文档写得很直接:没有套接字在监听时进来的连接被拒绝;要同时接受多个连接,就建多个套接字,都去 accept。每个套接字占 StackResources 的一个槽,加上两块缓冲区,这是编译期就要定下的「最大并发连接数」。

connect 从 Inner 取一个本地端口,调 smoltcp 的 connect,然后等状态离开 SynSent / SynReceived:变成 Closed 或 TimeWait 返回 ConnectionReset,其他状态返回 Ok。ConnectError 有一个 TimedOut 变体,tcp.rs 里除了给它实现 embedded_io_async::Error 以外没有任何地方构造它。

连一个同网段但不存在的地址(polls arp,A 连 10.0.0.9):

connect still pending after 4 s, state SynSent
      0 ms A tx frame 42
      0 ms A runner poll
   1000 ms A tx frame 42
   1000 ms A runner poll
   2010 ms A tx frame 42
   2010 ms A runner poll
   3010 ms A tx frame 42
   3010 ms A runner poll

只有 ARP 请求,每秒一个,SYN 一次也没发出去:smoltcp 在知道对方 MAC 之前不发这个套接字的任何包,重传定时器根本没启动。默认没有超时,connect 永远挂着。加上 set_timeout(Some(2500 ms))(polls arp-timeout):

connect returned Err(ConnectionReset) after 3030 ms, state Closed

返回的是 ConnectionReset,不是 TimedOut;时刻是 3030 ms,不是 2500 ms。smoltcp 的 set_timeout 是「这么久没收到对方任何东西就关闭」,套接字自己的 poll_at 在 2500 ms 到期,但等邻居的套接字被 socket_meta 压住,poll_at 改报静默期结束的时刻(每秒一次),所以超时要等到下一个整秒才被检查到。要给 connect 一个准确的超时,用 embassy_time::with_timeout 包住它。

一次 TCP echo

net-std/src/bin/echo.rs:B 上一个任务 accept(7),读到什么写回什么;A connect 后发 hello、embassy,各等回显,再 close + flush:

[  25005 us] A: connect
[  25111 us] A: connected, local Some(Endpoint { addr: Ipv4(10.0.0.1), port: 1026 })
[  25131 us] B: accepted from Some(Endpoint { addr: Ipv4(10.0.0.1), port: 1026 })
[  25136 us] A: echo "hello" in 7 us
[  25145 us] A: echo "embassy" in 8 us
[  76995 us] A: close
[  77015 us] B: read 0 (peer closed)
[  77019 us] A: flushed, read 0 (FIN from B)
A: polls 14, receive Some 6 / None 14, transmit Some 8 / None 0, frames out 8, bytes out 444
B: polls 13, receive Some 8 / None 13, transmit Some 5 / None 0, frames out 6, bytes out 336

A 一共发了 8 帧:ARP 请求、SYN、握手的 ACK、两段数据、一个延迟 ACK(10 ms 后,上一页讲过)、FIN、对 B 的 FIN 的 ACK。B 发了 6 帧:ARP 应答、SYN-ACK、两段回显(顺带确认了 A 的数据,所以没有单独的 ACK)、FIN 的 ACK 和自己的 FIN。一次往返 7 到 8 µs,这是两个栈在同一个线程里互相调用的速度。

close() 只关发送方向:smoltcp 把 FIN 排在待发数据后面。B 的 read 返回 Ok(0) 后自己 close + flush,A 的 read 才返回 Ok(0)。

drop 不发任何包

net-std/src/bin/dropsock.rs:A 连上后直接 drop TcpSocket,不 close 也不 abort;B 先读 200 ms,再写一条,再读:

[15 ms] A: connected, dropping the socket
[22 ms] A: frames sent after drop: 0
[225 ms] B: read after A dropped its socket: Err(TimeoutError), state Established
[225 ms] B: write Ok(7)
[225 ms] B: read Ok(Err(ConnectionReset)), state Closed

Drop 只是把套接字从 SocketSet 里删掉,没有机会发 FIN 或 RST(那要等 Runner 下一次 poll,而套接字已经不在了)。B 毫不知情,一直是 Established;B 发数据过来,A 的栈找不到对应的套接字,回一个 RST,B 这才得到 ConnectionReset。abort 的文档特别提醒了这一点:RST 不是马上发的,「abort() callers should wait for a [flush()] call to complete before dropping」。正确的关法是 close()(或 abort())之后 flush().await,再 drop。

tcp::client 模块里给 embedded-nal-async 用的 TcpClient 是一个固定大小的连接池(N 份缓冲区,Cell<bool> 标记占用),它的连接在 Drop 里先 self.socket.close(),再归还缓冲区,然后字段 socket 被 drop、从 SocketSet 里删掉。close() 只是改了状态,FIN 还没来得及发,套接字就没了。同一个实验换成 TcpClient::connect 得到的连接(dropsock client),输出和上面一模一样:drop 后 0 帧,B 直到自己发数据才收到 RST。

📝NOTE
没有蠢问题

问: 上一页 B 开了 300 ms 的 keep-alive,1 s 里 B 发了 3 个探测包,A 只回了 1 个 ACK。另外两个丢了吗?

答: 没丢,A 收到了,是故意不回。keep-alive 探测包的序号是「已确认的序号减一」,带 1 字节垃圾数据(所以是 55 字节,不是 54),落在 A 的接收窗口外面。smoltcp 对窗口外的段回一个「challenge ACK」,challenge_ack_reply 里有一句 // Rate-limit to 1 per second max.:1 s 之内只回第一个。时间线里 295 ms 的探测得到了 ACK,604 ms 和 913 ms 的没有。两端都是 smoltcp 时,keep-alive 间隔设得比 1 s 短,多出来的探测包得不到回应。

吞吐:缓冲区、MTU 和驱动队列

net-std/src/bin/tput.rs <MTU> <缓冲区> <字节数> [队列深度]:A 往 B 写 200 MB,两端的接收、发送缓冲区都是同一个大小,B 读到够数后打印。帧在进程内只是 Vec 之间的拷贝,所以 MB/s 是「smoltcp 加 embassy 执行器在 M2 Max 上能跑多快」,不代表任何网卡;值得看的是帧数、ACK 数和 Runner poll 次数,它们在真硬件上一样。每种配置跑三次,表里是最快的一次(三次之间相差 8% 以内),帧数和 ACK 数三次相同或只差几个。

MTU 1514、队列深度 4,改缓冲区大小:

缓冲区MB/s数据帧平均帧长ACK 帧A 的 poll
102410111953121077195312390624
204812781710371223171037228050
4096197714648314199765697657
8192209914648114194882948831
16384220314648114193662236624
65535258613732815103433334333
65536222013732615103433334335
262144253713732515103433234332
  • 接收缓冲区就是 TCP 窗口。 1024 字节的缓冲区比一个最大段(1460 字节)还小,每段只能带 1024 字节,发完就得等 ACK:每个数据帧一个 ACK,A 每帧 poll 两次。到 4096,一个窗口能放下三段(平均帧长 1419 = 54 字节头 + 4096 / 3),到 65535 才几乎全是满长的段(1510 字节)。
  • ACK 数跟着 Runner poll 次数走。 缓冲区够大以后,ACK 帧数正好是数据帧数的四分之一。上一页说过,一次 poll 把驱动队列里的帧全部收完,再统一回一个 ACK;队列深度 4,就是 4 帧一个 ACK。
  • 65536 比 65535 慢 15%,帧数和 ACK 数一样多,65536 需要 TCP 窗口缩放选项(窗口字段只有 16 位);具体慢在哪里没有查。

缓冲区 65535,改 MTU(以太网 MTU,IP MTU 加 14):

MTUMB/s数据帧平均帧长ACK 帧
590143037536858693843
15142611137328151034333
4014354351877390812970
901439422441082456104

MTU 越大,帧越少,每帧的固定开销(一次驱动调用、一次 IP/TCP 头处理)摊得越薄。缓冲区只有 4096 时,MTU 从 4014 加到 9014 帧数还在降,但每帧最多只能带一个窗口的数据(平均帧长 2739、4149),ACK 帧数和数据帧一样多,收益小得多(2215、2552 MB/s)。

MTU 1514、缓冲区 65535,改驱动队列深度:

队列深度MB/sACK 帧A 的 poll
1171268666137335
219026866668668
425713433334333
16295285848584
64299061046104

队列深 16 时每 16 帧一个 ACK;到 64,ACK 数不再按比例下降,这时限制它的是窗口(65535 字节约 45 段)。在 Cortex-M 上每次 poll 都是实打实的指令(驱动接口 一页 QEMU 上一次 32 字节 echo 往返约 1.9 万条),驱动的描述符环(stm32 以太网驱动的 PacketQueue<TX, RX>)开得深一些,省下的是 poll 次数和 ACK。

UdpSocket

UdpSocket::new 要四块缓冲区:收、发各一个 PacketMetadata 数组(每个报文一项,记来源和长度)和一个字节缓冲区。元数据数组的长度就是最多能排几个报文,字节缓冲区是这些报文的内容加起来能有多大。bind(0) 从 Inner 取一个本地端口。recv_from、send_to 都只要 &self。

net-std/src/bin/udp.rs,A 发、B 收,B 的接收方向只有 2 个元数据槽:

send before bind: Err(SocketNotBound)
A bound to ListenEndpoint { addr: None, port: 1026 }
datagram 0: "ping" from Endpoint { addr: Ipv4(10.0.0.1), port: 1026 } after 8 us, A frames out so far 2
datagram 1: "ping" from Endpoint { addr: Ipv4(10.0.0.1), port: 1026 } after 1 us, A frames out so far 3
sent 5 x 100 B, B received [(0, 100), (1, 100)]
send 2000 B with 1024 B tx buffer: Err(PacketTooLarge)
send 3000 B, MTU 1514: Ok(())
B recv after 3000 B send: Err(TimeoutError), A frames out 8
recv 100 B into 4 B buffer: Err(Truncated)
next recv: Err(TimeoutError)
  • 第一个报文之前 A 先发了一个 ARP 请求(「frames out so far 2」)。smoltcp 在等邻居时把报文留在发送缓冲区里,ARP 应答回来后再发。
  • B 不读,A 连发 5 个:只有前 2 个进了 B 的接收缓冲区,后 3 个被 smoltcp 丢掉,两边都没有任何错误。UDP 没有流控,接收方的元数据槽数就是它能攒下的报文数。
  • 报文比整个发送缓冲区还大:poll_send_to 一开始就检查 payload_send_capacity() < buf.len(),返回 PacketTooLarge。
  • 报文放得进缓冲区、但比 MTU 大:send_to 返回 Ok(()),A 发出的帧数却没有增加,B 什么也没收到。smoltcp 发包时发现 IP 包超过 MTU,没开 proto-ipv4-fragmentation 就打一条 debug 日志(「Enable the proto-ipv4-fragmentation feature for fragmentation support.」),返回 Ok。embassy-net 没有转发这个 feature,也就是说默认配置下超过 MTU 的 UDP 报文被悄悄丢掉。发之前自己检查长度(IP MTU 1500 减 28 字节头,1472)。
  • 接收缓冲区太小:smoltcp 的 recv_slice 返回 Truncated,而且这个报文已经出队,下一次 recv_from 收不到它。
📝NOTE
冷知识:PacketTooLarge 以前是「等下去」

poll_send_to 开头那段检查是 #3804 加的(提交「don't infinite loop if udp::send methods receive a buffer too large to ever be sent」,2025-01;同一个 PR 里后来把新错误从 Truncated 改名为 PacketTooLarge),首次进入 embassy-net 0.7.1(仓库里没有 0.7.0 的 tag,CHANGELOG 把它记在「0.7」下)。之前,smoltcp 对放不下的报文返回 BufferFull,embassy-net 把它当成「暂时满」:登记发送 waker,返回 Pending。缓冲区再空也放不下这个报文,send_to 就永远等下去。在 net-pstd 里把这段检查关掉,300 字节的报文、256 字节的发送缓冲区:

== 0.9.1
send_to(300 B) with 256 B tx buffer: Err(PacketTooLarge)
A runner polls meanwhile: 0
== revert #3804 check
send_to(300 B) with 256 B tx buffer: still pending after 100 ms
A runner polls meanwhile: 1

在 0.9.1 的其余代码上,旧行为是安静地挂起,Runner 只被叫醒一次,没有忙转;提交标题里的「infinite loop」在这里没有复现。0.9.1 的检查用的是只读的 with,提前返回时连 Runner 也不叫醒(注释「Don't need to wake waker in with_mut if the buffer will never fit」)。

两个任务读同一个 UdpSocket

recv_from 只要 &self,一个 &'static UdpSocket 可以交给两个任务同时读。net-std/src/bin/udp2.rs:B 上一个 UdpSocket 绑 9000 端口,先 spawn 读者 1、再 spawn 读者 2,两个都在循环里 recv_from;A 每隔 20 ms 发一个报文,一共 4 个。

❗IMPORTANT
动脑筋:两个读者,四个报文

两个读者各收到几个?会不会有报文谁都没收到?提示:smoltcp 套接字的 register_recv_waker 覆盖旧的 waker 而不叫醒它;embassy-executor 一批任务里后进先出。答案在本页末尾。

DNS

Stack::dns_query 用 new 里建好的那个 smoltcp DNS 套接字:先看名字是不是 IP 字面量,是就直接返回;否则 start_query(最多 4 个查询同时进行,满了就等 dns_waker),再等结果。等结果的 future 被 drop 时,一个 OnDrop 守卫取消这次查询、腾出查询槽。dns::DnsSocket 只是把它包成 embedded-nal-async 的 Dns trait。net-std/src/bin/dns.rs:

literal: Ok([Ipv4(10.0.0.2)]) in 73 us
no server: Ok(Err(Failed))
silent server 10.0.0.2: Ok(Err(Failed)) after 15015 ms, A frames out 5

没有配置 DNS 服务器时立刻失败。服务器不回答时,smoltcp 的 DNS 套接字在 0、1、3、7 s 发查询(重发间隔从 RETRANSMIT_DELAY 1 s 开始翻倍),加上开头的 ARP 一共 5 帧;每个服务器的超时 RETRANSMIT_TIMEOUT 是 10 s,但超时只在下一次该重发时才检查,所以到 15 s 才发现超时、换下一个服务器、没有下一个,返回 Failed。错误类型只有 InvalidName、NameTooLong、Failed 三种,超时、服务器拒绝、名字不存在都是 Failed。smoltcp 的 DNS_MAX_RESULT_COUNT 默认是 1,dns_query 最多返回一个地址。

和 tokio 对照

tokio 的 TcpStream 背后是内核的套接字,缓冲区在内核里,大小由内核定;embassy-net 的缓冲区是调用方给的一块内存,大小就是窗口。tokio 里多个任务等同一个 socket 的读就绪时,ScheduledIo 有专门的读、写 waker 槽外加一条等待者链表(见 ScheduledIo 与就绪状态 里「两个任务等同一个 socket」的实验);smoltcp 每个方向只有一个槽,后来的覆盖先来的。

动脑筋答案

udp2 的输出:

[15 ms] reader 1 got [0]
[45 ms] reader 1 got [1]
[75 ms] reader 1 got [2]
[102 ms] reader 1 got [3]
  • 读者 1 收到全部 4 个,读者 2 一个也没收到,也没有报文丢失。
  • 两个读者在同一批里被第一次 poll,批内后进先出(见 状态与运行队列),后 spawn 的读者 2 先跑,recv_from 没数据,往 smoltcp 套接字的 rx_waker 里登记自己;接着读者 1 跑,登记自己,把读者 2 的 waker 覆盖掉。smoltcp 的 WakerRegistration::register 不叫醒被覆盖的 waker,读者 2 从此再没被 poll 过。
  • 报文到达时 smoltcp 叫醒读者 1,读者 1 读完、回到循环里重新登记,槽里还是它自己。读者 2 永远饿着。
  • 同样的事放在 embassy-sync 的 Channel 上是另一种结果:那里的 WakerRegistration 换人时叫醒旧的,两个接收方互相叫醒、忙转(见 Channel)。一个是饿死,一个是空转,都说明一个单槽 waker 只能服务一个等待者:一个 UdpSocket 只让一个任务去读。

小结

  • TcpSocket / UdpSocket 是 Stack 加一个 SocketSet 槽号,缓冲区由调用方给,生命周期被 transmute 成 'static 交给 smoltcp,Drop 时删掉槽。
  • 每次 with_mut 都叫醒 Runner;读写挂起时登记 smoltcp 套接字自己的 rx_waker / tx_waker,这两个槽覆盖旧 waker 而不叫醒它。
  • 没有监听器,一个套接字 accept 一条连接;connect 默认没有超时,邻居不存在时只发 ARP,set_timeout 到期返回的是 ConnectionReset,ConnectError::TimedOut 从不出现。
  • drop 套接字(包括 TcpClient 的连接)不发 FIN 也不发 RST;先 close / abort 再 flush().await。
  • 接收缓冲区就是窗口;一次 Runner poll 收完驱动队列再回一个 ACK,队列深度 4 时 ACK 是数据帧的四分之一。
  • UDP:超过发送缓冲区返回 PacketTooLarge(#3804,0.7.1);超过 MTU 返回 Ok 但被丢掉;接收槽满了后来的报文被丢掉;Truncated 的报文也没了。
  • dns_query 的错误只有三种,服务器不回答时 15 s 后 Failed,最多返回一个地址。

💬 关联讨论

发起讨论
加载中...