Rust in Everything
基于 embassy-net 0.9.1

Stack 与 Runner

主线位置:中断主线和定时器主线在网络栈里的交汇点。网卡中断叫醒驱动登记的 waker,那个 waker 属于 Runner 任务;Runner 被 poll,让 smoltcp 收帧、处理、发帧,最后问 smoltcp「下一次什么时候再叫我」,把答案交给时间驱动(schedule_wake),任务挂起,执行器睡下去。

sequenceDiagram
  participant Dr as 驱动
  participant Run as Runner 任务
  participant In as RefCell Inner
  participant Sm as smoltcp
  participant Tm as 时间驱动
  Dr->>Run: 中断 wake
  Run->>In: borrow_mut
  In->>Sm: Interface::poll
  Sm->>Dr: receive 直到 None
  Sm->>Dr: transmit 发完为止
  In->>Dr: link_state
  In->>Sm: poll_at
  Sm-->>In: 下次时刻
  In->>Tm: Timer::at 一次 poll
  Run-->>Run: Pending

Stack、Runner、new 和它们用到的配置类型都在 embassy-net/src/lib.rs(990 行)里。本页的 std 实验在 exp-embassy/net-std:platform-std 执行器上建两个栈 A(10.0.0.1)和 B(10.0.0.2),中间接一个手写的驱动 PipeDriver(驱动接口 一页讲它怎么写),驱动在每个方法里计数、记时间戳。Inner::poll 每次恰好调一次 link_state,所以「link_state 被调了几次」就是「Runner 被 poll 了几次」。看 smoltcp 的 poll_at 返回值和复原 0.9.0 的行为用的是 exp-embassy/net-pstd,它依赖一份放在 exp-embassy/net-patched/embassy-net 的 0.9.1 拷贝,多了两个默认关闭的 feature:一个在 Inner::poll 末尾调一个外部钩子报告 poll_at,一个复原下文 #5870 改掉的那一行。都是 release 构建,M2 Max、macOS 27、rustc 1.99.0,同机负载 2.5 到 5。

new:一个 RefCell,两个句柄

pub struct Runner<'d, D: Driver> {
    driver: D,
    stack: Stack<'d>,
}

#[derive(Copy, Clone)]
pub struct Stack<'d> {
    inner: &'d RefCell<Inner>,
}

pub(crate) struct Inner {
    pub(crate) sockets: SocketSet<'static>, // Lifetime type-erased.
    pub(crate) iface: Interface,
    /// Waker used for triggering polls.
    pub(crate) waker: WakerRegistration,
    /// Waker used for waiting for link up or config up.
    state_waker: WakerRegistration,
    hardware_address: HardwareAddress,
    next_local_port: u16,
    link_up: bool,
    // ...
}

Stack 只是一个引用,Copy,可以随便传给多个任务,QEMU 上量出来是 4 字节。所有状态在 Inner 里:smoltcp 的 Interface(IP 地址、路由、ARP 缓存)、SocketSet(套接字槽)、两个 embassy-sync 的 WakerRegistration。waker 存的是 Runner 任务的 waker,谁想让栈「现在就跑一轮」就叫醒它;state_waker 给等链路、等地址的任务用。Runner 多拿着一样东西:驱动。驱动不在 Inner 里,所以 Stack 和套接字的类型里都没有驱动类型参数。

Inner 用的是 RefCell,不是 RawMutex。这意味着 Stack 不是 Send,所有用到同一个栈的任务都得在同一个执行器上。在 std 上让编译器检查 Stack<'static>: Send:

error[E0277]: `RefCell<embassy_net::Inner>` cannot be shared between threads safely
   --> src/bin/sendcheck.rs:4:17
    |
  4 |   assert_send::<embassy_net::Stack<'static>>();
    |                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `RefCell<embassy_net::Inner>` cannot be shared between threads safely
...
note: required because it appears within the type `Stack<'static>`

同一个执行器里任务不会互相抢占,RefCell 的借用总在一次 poll 之内结束,所以不需要锁。代价是用同一个栈的任务不能分散在两个执行器上:InterruptExecutor 的 start 返回的是 SendSpawner,它只收参数都是 Send 的任务(见 Spawner),带 Stack 参数的任务没法从线程模式那边 spawn 过去。

new 的签名:

pub fn new<'d, D: Driver, const SOCK: usize>(
    mut driver: D,
    config: Config,
    resources: &'d mut StackResources<SOCK>,
    random_seed: u64,
) -> (Stack<'d>, Runner<'d, D>) {

它做五件事:

  1. 问驱动要硬件地址,按地址的种类定 smoltcp 的介质(以太网、裸 IP、802.15.4),建 Interface。
  2. 在 resources.sockets 里写 SOCK 个空的 SocketStorage,再用一个 transmute_slice 把 &'d mut [..] 变成 &'static mut [..] 交给 SocketSet。生命周期被擦掉了,但 Stack<'d> 还带着 'd,借用检查照样管得住。
  3. 开了 dns feature 就先往 SocketSet 里加一个 DNS 套接字,占掉一个槽。
  4. 按 random_seed 定第一个本地端口:random_seed % (65535 - 1025) + 1025。实验里种子是 1,A 的第一个连接用的就是 1026 端口。
  5. 把 Inner 写进 resources.inner,返回指向它的 Stack 和拿着驱动的 Runner。

StackResources<SOCK> 是三块 MaybeUninit:套接字槽数组、RefCell<Inner>、DNS 查询槽(4 个)。const fn new() 什么也不写,可以直接放进 StaticCell 或 static。在 QEMU 上(thumbv7em,features tcp、medium-ethernet、proto-ipv4,没有 DNS)size_of::<StackResources<2>>() 是 1144 字节。

📝NOTE
冷知识:Stack 以前带着驱动类型,run 是它自己的方法

0.5.0 之前是 pub struct Stack<D: Driver>,里面两个 RefCell:socket: RefCell<SocketStack> 和装着驱动的 inner: RefCell<Inner<D>>。Stack::new 要 &'static mut StackResources,后台任务调 stack.run(),套接字的构造函数是 TcpSocket::new<D: Driver>(stack: &'a Stack<D>, ..)。每个用到网络的函数都得写出驱动的具体类型,栈本身还得放进一个 StaticCell 才能借给多个任务。#3329(提交「net: refactor to simplify lifetimes/generics.」,首次进入 embassy-net 0.5.0)把驱动挪进新的 Runner,把两个 RefCell 合成一个,Stack 变成现在这个 Copy 的引用。CHANGELOG 0.5 记作「Stack is now a thin handle that implements Copy+Clone」「The run() method has been moved to a new Runner struct」,还写了一句后果:原来要两个 StaticCell,现在只要一个。

run:一个永远 Pending 的 poll_fn

Runner::run:

    pub async fn run(&mut self) -> ! {
        poll_fn(|cx| {
            self.stack.with_mut(|i| i.poll(cx, &mut self.driver));
            Poll::<()>::Pending
        })
        .await;
        unreachable!()
    }

没有循环,没有 .await 在中间。每次任务被叫醒,执行器 poll 这个 poll_fn 一次,它借出 RefCell 调一次 Inner::poll,返回 Pending。怎么再被叫醒,全看 Inner::poll 里把 cx 交给了谁:

    fn poll<D: Driver>(&mut self, cx: &mut Context<'_>, driver: &mut D) {
        self.waker.register(cx.waker());
        // ...
        let timestamp = instant_to_smoltcp(Instant::now());
        let mut smoldev = DriverAdapter {
            cx: Some(cx),
            inner: driver,
            medium,
            tx_exhausted: false,
        };
        self.iface.poll(timestamp, &mut smoldev, &mut self.sockets);
        let tx_exhausted = smoldev.tx_exhausted;

        // Update link up
        let old_link_up = self.link_up;
        self.link_up = driver.link_state(cx) == LinkState::Up;
        // ...(DHCP、SLAAC、apply_static_config)

        if let Some(poll_at) = self.iface.poll_at(timestamp, &mut self.sockets)
            && !tx_exhausted
        {
            let t = pin!(Timer::at(instant_from_smoltcp(poll_at)));
            if t.poll(cx).is_ready() {
                cx.waker().wake_by_ref();
            }
        }
    }

同一个 cx 被交了四次:

  1. self.waker:套接字那边每次操作都会叫醒它(见 TCP 与 UDP 套接字)。
  2. 驱动的 receive / transmit:smoltcp 通过 DriverAdapter 调驱动,驱动在返回 None 时登记这个 waker,等有帧到、有空位时叫醒它。
  3. link_state:链路状态变化时叫醒。
  4. Timer::at(poll_at):smoltcp 自己的定时器(重传、延迟 ACK、ARP 重试、DHCP 重试、keep-alive)最早的那一个。

smoltcp 的 poll:先收完,再发完

Interface::poll 的主体:

        // Process ingress while there's packets available.
        loop {
            match self.socket_ingress(device, sockets) {
                PollIngressSingleResult::None => break,
                PollIngressSingleResult::PacketProcessed => {}
                PollIngressSingleResult::SocketStateChanged => res = PollResult::SocketStateChanged,
            }
        }

        // Process egress.
        loop {
            match self.poll_egress(timestamp, device, sockets) {
                PollResult::None => break,
                PollResult::SocketStateChanged => res = PollResult::SocketStateChanged,
            }
        }

socket_ingress 每次调一次驱动的 receive,拿到一帧就交给 ARP / IP / TCP / UDP 处理;驱动返回 None 才停。也就是说驱动里排着多少帧,Runner 这一次 poll 就处理多少帧,中间不让出。smoltcp 的文档把这叫作「DoS warning」,建议在意的人改用 poll_ingress_single;embassy-net 用的是整个 poll。下一页量吞吐时会看到它的另一面:驱动队列越深,一次 poll 收的帧越多,回的 ACK 越少。

poll_egress 遍历所有套接字,每个有东西要发的套接字调一次驱动的 transmit;驱动没空位就停。embassy-net 对 smoltcp 返回的 PollResult 一律不看。

poll_at 变成一个 Timer

Interface::poll_at 对每个套接字问一次 poll_at,取最小值。每个套接字的回答是三种之一:PollAt::Now(有东西要马上发,换算成时刻 0)、PollAt::Time(t)(某个定时器在 t 到期)、PollAt::Ingress(只等收包,不参与取最小)。全是 Ingress 时返回 None,Runner 就不设任何定时器。

Some(t) 的时候,Inner::poll 在栈上建一个 Timer::at(t),poll 一次,函数返回时它就被 drop 了。它还能起作用,是因为 定时器队列 一页讲过的那条规则:Timer 被 drop 时什么也不做,任务头里那个队列节点照样留在队列里,到点叫醒 Runner 任务。Runner 用 drop 不取消这一点当作「设一个闹钟」,每次 poll 重新登记一遍;集成队列每个任务只有一个节点、只记最早的时刻,正好就是「下一次最晚什么时候来 poll 我」。

📝NOTE
没有蠢问题

问: if t.poll(cx).is_ready() { cx.waker().wake_by_ref(); } 什么时候成立?poll_at 是 Now(时刻 0)的时候吗?

答: 从来不成立。embassy-time 0.5.1 的 Timer::poll 先看 self.yielded_once && self.expires_at <= Instant::now(),一个刚建的 Timer 的 yielded_once 是 false,所以第一次 poll 永远是登记 schedule_wake、返回 Pending,到期时刻在过去也一样(「timers always yield at least once」,2020-12-30 起就是这样,比 embassy-net 这段代码还早)。poll_at 是 Now 时,真正让 Runner 马上再跑一轮的是时间驱动:它收到一个已经过去的时刻,立刻叫醒任务。在打了钩子的拷贝里数过,下面忙转实验的 22711 次 poll 里,这个分支一次也没进去。时间驱动叫醒得多快,Now 就多快:std 驱动是闹钟线程,几微秒;模板里的 1 kHz SysTick 驱动要等下一个 tick,最多 1 ms。

实测:Runner 什么时候醒

net-std/src/bin/echo.rs:A 连 B 的 7 端口,发 hello、embassy 两条,各等回显,50 ms 后关闭。从 connect 开始的时间线(runner poll 一行在 link_state 里记,所以同一次 poll 收发的帧排在它前面):

       88 us A tx frame 42
       88 us A runner poll
       91 us B rx frame 42
       91 us B tx frame 42
       91 us B runner poll
       92 us A rx frame 42
       93 us A tx frame 66
       93 us A runner poll
       96 us B rx frame 66
      103 us B tx frame 66
      103 us B runner poll
      104 us A rx frame 66
      105 us A tx frame 54
      105 us A runner poll
...
      140 us A rx frame 61
      140 us A runner poll
      142 us A runner poll
    12648 us A tx frame 54
    12648 us A runner poll
    12648 us B rx frame 54
    12651 us B runner poll
    52003 us A tx frame 54
...
  • 42 字节的两帧是 ARP 请求和应答:A 第一次往 10.0.0.2 发东西,邻居表里没有它的 MAC,SYN 先等着。66 字节的是 SYN 和 SYN-ACK(带选项),54 字节的是不带数据的 ACK。整个握手 20 微秒,两个 Runner 各 poll 三次。
  • 142 µs 那次 A 的 poll 什么也没做:应用任务读写套接字时叫醒了它,见下一页。
  • 140 µs 到 12648 µs 之间两个栈都没有 poll。12648 µs 那个 54 字节帧是 A 对 B 回显数据的延迟 ACK:smoltcp 收到数据后不马上回 ACK,等 ACK_DELAY_DEFAULT(10 ms)看能不能捎带在数据里。这 10 ms 就是 Timer::at(poll_at) 定下的闹钟,数据在 140 µs 前后到达,ACK 在 12648 µs 发出,晚到的约 2.5 ms 是 std 时间驱动的误差。

同样的程序在 net-pstd 里跑(开 trace feature),每次 Inner::poll 结束时打印 poll_at - now:

       67 us A poll_at +1000000 us
       71 us B poll_at None
       72 us A poll_at +1000000 us
       73 us B poll_at +1000000 us
       74 us A poll_at None
       77 us B poll_at None
      137 us A poll_at +1000000 us
      140 us B poll_at +10000 us
      143 us B poll_at +1000000 us
      143 us A poll_at +10000 us
...
    15144 us A poll_at None
    15145 us B poll_at None
    60163 us A poll_at +1000000 us
...
    60174 us A poll_at +10000000 us
  • A 发出 ARP 请求后是 +1 s:smoltcp 给等邻居的套接字一个 DISCOVERY_SILENT_TIME(1 s)的静默期,1 s 内不再发 ARP。
  • SYN、SYN-ACK、数据发出后是 +1 s:重传定时器,初始 RTO 和最小 RTO 都是 1000 ms(RTTE_MIN_RTO)。
  • 收到数据后是 +10 ms:延迟 ACK。
  • 连接建好、数据都确认了,变成 None:没有定时器,只等收包。
  • 关闭后 A 进 TIME-WAIT,+10 s(CLOSE_DELAY)。
❗IMPORTANT
动脑筋:一条空闲连接,1 秒里 Runner 醒几次

net-std/src/bin/polls.rs established:A 连上 B,不收不发,等 10 ms 后数 1 s 里两个 Runner 各被 poll 了几次、各发了几帧。看上面的 poll_at 序列:连接建好以后 poll_at 是 None。答案是几?答案在本页末尾。

同一个程序的其他几种情况(每种跑一次;ARP 那两行看 4 s):

情况1 s 里 A / B 的 poll 次数发出的帧
两个栈都没有套接字0 / 00
B 上一个 accept 在等的套接字0 / 00
已连接,B 开 300 ms keep-alive4 / 4B 3 帧,A 1 帧
A 连 10.0.0.9(没有这台机器)每秒 1 次每秒 1 个 ARP 请求

没有事情的时候,Runner 一次也不醒,执行器可以一直睡在 WFE 里。连一台不存在的主机,A 每隔约 1 s 发一次 ARP,SYN 一次也没发出去,connect 4 s 后还是 SynSent;这种情况下 connect 永远不会自己返回,下一页接着讲。keep-alive 那一行里 A 只回了 1 个 ACK,原因也在下一页。

发送队列满了:0.9.1 修掉的忙转

DriverAdapter::transmit 记下驱动这一次是不是返回了 None,Inner::poll 末尾 tx_exhausted 为真时不设定时器,只等驱动在有空位时叫醒自己。为什么要这样:驱动发送队列满了,smoltcp 发不出去,套接字里还有数据,poll_at 就是 Now;如果照常设一个时刻 0 的定时器,时间驱动立刻叫醒 Runner,Runner 再试,驱动还是满的,再设时刻 0……

net-pstd/src/bin/exhaust.rs:驱动队列深度 1,A 连上 B 后让 B 暂停收帧(PipeDriver 里 B 方向的 Port 设成 paused,A 的帧进得去、出不来),A 往套接字里写 16 KB,等 100 ms,数 A 的 Runner 被 poll 了几次,然后恢复,确认 20000 字节都送到:

== revert
wrote 16384 bytes into the socket, B paused
during 100 ms stall: A runner polls 23297, transmit None 23297, frames queued 1, wall 100 ms
server got 20000 bytes at 115 ms
...
== none
wrote 16384 bytes into the socket, B paused
during 100 ms stall: A runner polls 1, transmit None 1, frames queued 1, wall 110 ms
server got 20000 bytes at 124 ms

revert 是把 && !tx_exhausted 改回 0.9.0 的样子(另外两次是 20299、18612 次),none 是原样的 0.9.1(三次都是 1 次)。0.9.0 的行为下,100 ms 里 Runner 被 poll 两万次左右,每次 transmit 都返回 None,CPU 一直在转;0.9.1 下只有 1 次,之后它睡到驱动腾出空位。

📝NOTE
冷知识:一个布尔值换来的是「睡着」

tx_exhausted 是 #5870 加的(提交「Track if driver tx is exhausted」,2026-04-16),也是 embassy-net 0.9.1 唯一的改动,CHANGELOG 记作「Avoid busy looping if network driver's TX buffer is exhausted」。源码只加了 12 行、删了 2 行:DriverAdapter 加一个 tx_exhausted 字段,transmit 里写 self.tx_exhausted = token.is_none(),Inner::poll 设定时器的条件加上 && !tx_exhausted。之前的五年里,这个条件只有 if let Some(poll_at) = ...。0.9.1 从此依赖 Driver::transmit 文档里的那条约定:返回 None 时「wake cx.waker() when space becomes available」。0.9.0 不靠它,代价是忙转;在 0.9.1 上,一个没做到这条约定的驱动会让 Runner 一直睡着。

配置:静态地址、DHCP 和 state_waker

Config 是 IPv4、IPv6 各一份;IPv4 的 ConfigV4 三选一:None、Static(StaticConfigV4)、Dhcp(DhcpConfig)。new 和 Stack::set_config_v4 都走 Inner::set_config_v4 再走 apply_static_config:

  • 静态:直接把地址、网关、DNS 服务器写进 smoltcp 的 Interface 和 DNS 套接字,叫醒 state_waker。new 返回时 is_config_up() 就已经是 true。
  • DHCP:往 SocketSet 里加一个 smoltcp 的 DHCPv4 套接字(又占一个槽)。之后每次 Inner::poll,链路是通的就问它 socket.poll(),得到 Configured 就把租到的地址当成「静态配置」写进去,得到 Deconfigured 就清掉。

StaticConfigV4 允许 3 个 DNS 服务器,smoltcp 的 DNS_MAX_SERVER_COUNT 默认是 1,多出来的被截掉,打一条 warn。

进程内的 DHCP

net-std/src/bin/dhcp.rs:B 用静态地址,上面跑一个 UdpSocket 绑 67 端口,用 smoltcp 的 DhcpRepr 解析请求、拼 OFFER / ACK,回给 255.255.255.255:68;A 用 Config::dhcpv4(Default::default()),B 先跑 10 ms,再 spawn A 的 Runner:

[   11844 us] A: spawn runner, config_v4 = None
[   11903 us] server: Discover xid dc9072b2 from Endpoint { addr: Ipv4(0.0.0.0), port: 68 }
[   11917 us] server: sent Offer
[   11922 us] server: Discover xid 293122d0 from Endpoint { addr: Ipv4(0.0.0.0), port: 68 }
[   11923 us] server: sent Offer
[   11926 us] server: Request xid 293122d0 from Endpoint { addr: Ipv4(0.0.0.0), port: 68 }
[   11927 us] server: sent Ack
[   11929 us] A: config up after 0 ms: Some(StaticConfigV4 { address: Cidr { address: 10.0.0.100, prefix_len: 24 }, gateway: Some(10.0.0.2), dns_servers: [] })

A 一上来发了两个 DISCOVER,事务号不同,第一个的 OFFER 被 A 丢掉了。原因在 Inner::poll 的顺序:第一次 poll 时 iface.poll 先跑,DHCP 套接字处在 Discovering 状态,发出第一个 DISCOVER;然后才调 link_state,链路从 false 变成 true,这段代码随即 socket.reset():

                if self.link_up {
                    if old_link_up != self.link_up {
                        socket.reset();
                    }

reset 换了一个新的事务号,下一次 poll 发第二个 DISCOVER。每次上电都是这样,多一个来回而已,不影响结果。

再跑一次,让服务器不理前两个 DISCOVER:

[   15041 us] server: Discover xid dc9072b2 from Endpoint { addr: Ipv4(0.0.0.0), port: 68 }
[   15047 us] server: ignoring it
[   15049 us] server: Discover xid 293122d0 from Endpoint { addr: Ipv4(0.0.0.0), port: 68 }
[   15051 us] server: ignoring it
[10020793 us] server: Discover xid 01577618 from Endpoint { addr: Ipv4(0.0.0.0), port: 68 }
...
[10020840 us] A: config up after 10005 ms: Some(StaticConfigV4 { address: Cidr { address: 10.0.0.100, prefix_len: 24 }, gateway: Some(10.0.0.2), dns_servers: [] })
...
A: polls 5, receive Some 2 / None 5, transmit Some 4 / None 0, frames out 4, bytes out 1228

DISCOVER 的重试间隔是 smoltcp RetryConfig 的默认值 discover_timeout: Duration::from_secs(10),这 10 s 里 A 的 Runner 只 poll 了几次。两次运行的事务号一模一样(dc9072b2、293122d0):smoltcp 用 random_seed 初始化 Interface 里的随机数发生器,TCP 初始序号、DHCP 事务号都从它取,embassy-net 又用同一个种子定第一个本地端口。种子写成常数的程序,每次上电这些值都一样,放到同一个网段的几块板子上会撞在一起。smoltcp 的 Config::random_seed 文档写着「It is strongly recommended that the random seed is different on each boot」;仓库里的示例都是从硬件随机数发生器取(rng.next_u64())。

两个任务一起等

Stack::wait 是 wait_config_up、wait_link_up 等四个方法共用的实现:条件不满足就 state_waker.register(cx.waker()),返回 Pending。state_waker 是 embassy-sync 的 WakerRegistration,只有一个槽,换人时把旧的叫醒(见 等待队列)。两个任务同时等,就是 Channel 一页里多个发送方互相叫醒的同一件事。net-std/src/bin/waiters.rs:A 用 DHCP,没有服务器,spawn 1 个或 2 个任务 wait_config_up(),数 1 s 里每个 future 被 poll 了几次:

1 waiter(s): wait_config_up polls in 1 s [2, 0], runner polls 2
2 waiter(s): wait_config_up polls in 1 s [14338296, 14338296], runner polls 2

一个任务等:1 s 里被 poll 2 次(第一次,加上链路变成 up 时的一次唤醒)。两个任务等:各 1434 万次,执行器一刻不停。Runner 自己只 poll 了 2 次,忙的是两个等待的任务。只让一个任务等配置,其他任务从它那里拿结果(比如一个 Signal 或 Watch)。

StackResources 不够时

SOCK 是 smoltcp SocketSet 的槽数,所有套接字共用:用户的 TCP、UDP、raw、ICMP 套接字各占一个,dns feature 开着时 DNS 占一个,用 DHCP 时 DHCP 再占一个。wait_config_up 的文档示例里写的是「provisioning space for 3 sockets here: one for DHCP, one for DNS, and one for your code」。槽满了,SocketSet::add 直接 panic,embassy-net 没有返回错误的版本。net-std/src/bin/toofew.rs(dns 和 dhcpv4 feature 都开着):

== zero
new() with StackResources<0>

thread 'main' (4882481) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/smoltcp-0.13.1/src/iface/socket_set.rs:83:42:
adding a socket to a full SocketSet
...
== one
new() with StackResources<1> ok; creating a TcpSocket

thread 'main' (4882486) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/smoltcp-0.13.1/src/iface/socket_set.rs:83:42:
adding a socket to a full SocketSet
...
== count
socket 0
socket 1
socket 2
socket 3

thread 'main' (4882496) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/smoltcp-0.13.1/src/iface/socket_set.rs:83:42:
adding a socket to a full SocketSet
...
== reuse
round 0: TcpSocket created, dropping it
round 1: TcpSocket created, dropping it
round 2: TcpSocket created, dropping it
exit 0
  • StackResources<0>:DNS 套接字在 new 里就加不进去。
  • StackResources<1>:new 成功(DNS 占了唯一的槽),第一个 TcpSocket::new panic;Config::dhcpv4 加 StackResources<1> 也是在 new 里 panic。
  • StackResources<4>:能建 3 个 UdpSocket,第 4 个 panic(打印「socket 3」之后)。
  • 套接字被 drop 时它的 Drop 调 sockets.remove,槽空出来,下一个可以接着用。

panic 发生在建套接字的那一行,而不是编译期。一个服务端要同时接受 N 个连接,就要 N 个 TcpSocket(下一页),SOCK 至少是 N 加上 DNS 和 DHCP。

和 tokio 对照

tokio 的网络 I/O 由运行时的 I/O 驱动统一处理:一个线程在 epoll_wait / kevent 里等内核告诉它哪个 fd 就绪,再叫醒对应的任务(见 反应器),TCP 协议在内核里。embassy-net 里没有内核,协议栈就在 Runner 这个普通任务里跑;Runner 等的「事件」只有四种来源,都是 waker:驱动、套接字、链路状态、smoltcp 的定时器。

动脑筋答案

polls established 的输出(两次运行都是 1 次;第二次打开了时间线):

established: in 1 s, A polled 1 times, B polled 1 times, frames A->B 0, B->A 0
   995498 us A runner poll 0
   995499 us B runner poll 0
  • 两个 Runner 各被叫醒 1 次,什么帧也没发。时刻是测量窗口开始后约 995 ms,也就是 SYN 和 SYN-ACK 发出后约 1 s。
  • 这一次来自握手时登记的那个 1 s 闹钟:A 发 SYN、B 发 SYN-ACK 时,poll_at 都是 +1 s(重传定时器,A 那边还有 ARP 静默期,也是 1 s),Runner 用 Timer::at 把它交给了集成定时器队列。握手完成后 poll_at 变成 None,Runner 不再设定时器,但已经登记的节点不会被取消,1 s 到了照样叫醒任务。
  • 被叫醒后 poll_at 还是 None,Runner 什么也没登记,之后就再也不醒了。这是集成队列「drop 不取消」的代价:一次虚假唤醒;换来的是 Runner 不需要保存任何定时器对象。

小结

  • new 把 smoltcp 的 Interface、SocketSet 和两个 waker 槽装进 StackResources 里的一个 RefCell<Inner>;Stack 是 4 字节的 Copy 引用,不是 Send;Runner 拿着驱动。
  • Runner::run 是一个永远 Pending 的 poll_fn;Inner::poll 把 cx 交给 Inner.waker、驱动、link_state 和一个当场 drop 的 Timer::at(poll_at)。
  • smoltcp 的 poll 先收完驱动里所有的帧,再发完所有能发的;poll_at 为 None 时 Runner 不设定时器,空闲时实测一次也不醒。
  • Timer::at(..).poll() 第一次永远 Pending,那个 wake_by_ref 分支是死代码;Now 靠时间驱动对过去时刻的立即唤醒。
  • 0.9.1(#5870)在驱动 transmit 返回 None 时不设定时器;复原旧代码后 100 ms 里 poll 两万次左右。
  • DHCP 上电时发两个 DISCOVER(先发、后 reset),重试 10 s;random_seed 决定事务号和本地端口。
  • state_waker 只有一个槽,两个任务同时 wait_config_up 时每秒互相叫醒上千万次。
  • StackResources<N> 的槽满了直接 panic,DNS 和 DHCP 各占一个。

💬 关联讨论

发起讨论
加载中...