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>) {
它做五件事:
- 问驱动要硬件地址,按地址的种类定 smoltcp 的介质(以太网、裸 IP、802.15.4),建
Interface。 - 在
resources.sockets里写SOCK个空的SocketStorage,再用一个transmute_slice把&'d mut [..]变成&'static mut [..]交给SocketSet。生命周期被擦掉了,但Stack<'d>还带着'd,借用检查照样管得住。 - 开了
dnsfeature 就先往SocketSet里加一个 DNS 套接字,占掉一个槽。 - 按
random_seed定第一个本地端口:random_seed % (65535 - 1025) + 1025。实验里种子是 1,A 的第一个连接用的就是 1026 端口。 - 把
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 字节。
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
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 被交了四次:
self.waker:套接字那边每次操作都会叫醒它(见 TCP 与 UDP 套接字)。- 驱动的
receive/transmit:smoltcp 通过DriverAdapter调驱动,驱动在返回None时登记这个 waker,等有帧到、有空位时叫醒它。 link_state:链路状态变化时叫醒。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 我」。
问: 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)。
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 / 0 | 0 |
B 上一个 accept 在等的套接字 | 0 / 0 | 0 |
| 已连接,B 开 300 ms keep-alive | 4 / 4 | B 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 次,之后它睡到驱动腾出空位。
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::newpanic;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 各占一个。