线程本地上下文
主线位置:任务主线的
tokio::spawn第一步「从线程本地上下文取当前运行时的Handle」,以及block_on开头的「进入运行时」。
flowchart TB plain[普通线程:三层都空] -->|"Handle::enter"| l1["第 1 层:current 有句柄"] l1 -->|enter_runtime| l2["第 2 层:runtime = Entered"] l2 -->|set_scheduler| l3["第 3 层:scheduler 指针"] l1 -.->|"spawn、Handle::current 可用"| ok1[能 spawn] l2 -.->|"再 block_on 就 panic"| ok2[不能阻塞] l3 -.->|"schedule 走本地队列"| ok3[是 worker]
tokio::spawn(fut) 没有任何参数指明运行时,它要从某个地方找到「当前」运行时。tokio 的答案是一个 thread-local。这一章看这个 thread-local 里有什么,谁在什么时候往里写,以及两句最常见的 panic 是怎么判断出来的。
CONTEXT 里有什么
context.rs 定义了一个结构体和一个 thread-local:
struct Context {
/// Uniquely identifies the current thread
#[cfg(feature = "rt")]
thread_id: Cell<Option<ThreadId>>,
/// Handle to the runtime scheduler running on the current thread.
#[cfg(feature = "rt")]
current: current::HandleCell,
/// Handle to the scheduler's internal "context"
#[cfg(feature = "rt")]
scheduler: Scoped<scheduler::Context>,
#[cfg(feature = "rt")]
current_task_id: Cell<Option<Id>>,
/// Tracks if the current thread is currently driving a runtime.
/// Note, that if this is set to "entered", the current scheduler
/// handle may not reference the runtime currently executing. This
/// is because other runtime handles may be set to current from
/// within a runtime.
#[cfg(feature = "rt")]
runtime: Cell<EnterRuntime>,
#[cfg(any(feature = "rt", feature = "macros"))]
rng: Cell<Option<FastRand>>,
/// Tracks the amount of "work" a task may still do before yielding back to
/// the scheduler
budget: Cell<coop::Budget>,
// taskdump 用的 trace 字段,只在 Linux + tokio_unstable 下存在
// ...
}
tokio_thread_local! {
static CONTEXT: Context = const {
Context {
// ...
runtime: Cell::new(EnterRuntime::NotEntered),
// ...
budget: Cell::new(coop::Budget::unconstrained()),
// ...
}
}
}
tokio_thread_local! 在非 loom 构建下就是 std::thread_local!。初始值写在 const { } 里,所有字段都能在编译期构造,线程第一次访问时不用跑初始化代码。字段全是 Cell、RefCell 或裸指针,只在本线程读写,不需要任何同步。
问: 「当前运行时」为什么放在 thread-local 里,而不是一个全局 static?
答: 因为一个进程里可以同时有好几个运行时。#[tokio::test] 给每个测试各建一个 current_thread 运行时,cargo test 默认又是多线程并行跑测试,这些运行时同时活着;本页实验里也同时有 rt 和 rt2。全局 static 只放得下一个,跨线程读写还得加锁或用原子操作。thread-local 让每个线程各记各的:worker 记自己所属的运行时,测试线程记自己那个测试的运行时,读写都是普通的 Cell 操作。代价是它不会跟着线程走:自己 std::thread::spawn 出来的线程三层都是空的,要么把 Handle 传过去再 enter,要么改用 spawn_blocking。
| 字段 | 存什么 | 谁写 | 谁读 |
|---|---|---|---|
current | 当前运行时的 scheduler::Handle,加一个嵌套深度 | Handle::enter、enter_runtime | tokio::spawn、Handle::current、Sleep 和 TcpStream 的构造 |
runtime | EnterRuntime:Entered { allow_block_in_place } 或 NotEntered | enter_runtime、exit_runtime | 嵌套 block_on 检查、block_in_place、blocking_recv 之类 |
scheduler | 指向调度器内部 Context 的指针,空表示不在调度循环里 | set_scheduler | schedule:判断能不能放进本地队列 |
current_task_id | 正在 poll 的任务的 ID | 任务 poll 前后 | task::id()、task::try_id() |
rng | FastRand 状态 | enter_runtime 换种子 | select! 选分支的起点、watch 等 |
budget | 协作调度预算 | coop::budget | 叶子 future 的 poll_proceed,见协作式调度预算 |
thread_id | tokio 自己分配的线程编号,第一次用时才分配 | thread_id() | LocalSet 检查是不是在所属线程上 |
前三个字段是这一章的主角。它们是三层,一层比一层强:
| 线程 | current | runtime | scheduler |
|---|---|---|---|
自己 std::thread::spawn 的线程 | 空 | NotEntered | 空 |
上面那个线程调了 rt.enter() | 有 | NotEntered | 空 |
阻塞线程池里的线程(spawn_blocking) | 有 | NotEntered | 空 |
正在 block_on 的线程(block_on 传进去的 future 里) | 有 | Entered | current_thread 拿到 Core 时有,否则空 |
| multi_thread 的 worker 线程 | 有 | Entered | 有 |
阻塞线程那一行来自 spawn_thread:线程一启动就 let _enter = rt.enter();,只设第一层。worker 是阻塞线程池里的线程,所以先有第一层,再由 worker 的 run 调 enter_runtime 和 set_scheduler 补上后两层。
第一层 current 像胸前的工牌,写着你在替哪家公司干活(哪个运行时)。去别家帮忙,可以在外面再挂一张(Handle::enter),回来时一张张摘掉,露出原来那张(SetCurrentGuard 把旧句柄放回去)。
第二层 runtime 像「正在值班」:值着班就不能再开一个班(enter_runtime 看到 Entered 就 panic),不管新班是哪家公司的。
第三层 scheduler 像坐在自己的工位上:手边有自己的待办筐(Core 里的本地队列),新活直接放进去。不在工位上,或者活是别家公司的(Arc::ptr_eq 不相等),就只能投进那家公司的公共收件箱(注入队列)。
比方到这里为止:真工牌摘的先后无所谓,tokio 的工牌必须后挂的先摘,顺序错了直接 panic,见下面 SetCurrentGuard 的 Drop。
第一层:current 与 EnterGuard
current 字段的类型是 HandleCell:
pub(super) struct HandleCell {
/// Current handle
handle: RefCell<Option<scheduler::Handle>>,
/// Tracks the number of nested calls to `try_set_current`.
depth: Cell<usize>,
}
写入只有一个入口 Context::set_current:
pub(super) fn set_current(&self, handle: &scheduler::Handle) -> SetCurrentGuard {
let old_handle = self.current.handle.borrow_mut().replace(handle.clone());
let depth = self.current.depth.get();
assert!(depth != usize::MAX, "reached max `enter` depth");
let depth = depth + 1;
self.current.depth.set(depth);
SetCurrentGuard {
prev: old_handle,
depth,
_p: PhantomData,
}
}
新句柄 clone 一份放进去(一次 Arc 计数加一),旧句柄存进返回的 SetCurrentGuard。guard drop 时把旧句柄放回去。这是一个栈:嵌套地 enter 不同的运行时,退出时一层层恢复。
栈要求后进先出。guard 记下了自己入栈时的深度,drop 时对一下:
impl Drop for SetCurrentGuard {
fn drop(&mut self) {
CONTEXT.with(|ctx| {
let depth = ctx.current.depth.get();
if depth != self.depth {
if !std::thread::panicking() {
panic!(
"`EnterGuard` values dropped out of order. Guards returned by \
`tokio::runtime::Handle::enter()` must be dropped in the reverse \
order as they were acquired."
);
} else {
// Just return... this will leave handles in a wonky state though...
return;
}
}
*ctx.current.handle.borrow_mut() = self.prev.take();
ctx.current.depth.set(depth - 1);
});
}
}
如果不检查,先 drop 外层 guard 会把「外层之前的句柄」恢复回去,内层 guard 再 drop 又把外层的句柄放回来,最后线程上留着一个本该已经退出的运行时的句柄。深度对不上时直接 panic,比静默地留下错误状态好。
SetCurrentGuard 里的 PhantomData<SyncNotSend> 让它不能被移到别的线程:它要 drop 的是创建它的那个线程的 thread-local。
!Send 都是 1.29.0 才加的1.29.0 之前,SetCurrentGuard 的 Drop 不核对深度,直接把自己存的旧句柄放回去。2023 年 6 月的 issue #5756 给了一个复现:依次 enter rt1、rt2、rt3,先 drop rt2 的 guard,再 drop rt3 的,这时线程上的「当前运行时」变成了 rt2,之后的 tokio::spawn 把任务交给了一个已经 drop 的运行时。
#5772 加上了本节的深度检查。PR 说明的理由是:嵌套的 guard 要逆序 drop,但这个要求没有任何静态保证。它还说之后会用一个接受闭包的方法取代 Handle::enter();到 1.54.1,Handle::enter 仍在,也没有标记为 deprecated。同一版本的 #5766 去掉了 EnterGuard 的 Send:guard 在 drop 时改 thread-local,被移到别的线程就会改错线程。PR 承认这是破坏性改动,但认为因此编译不过的代码多半本来就有 bug。
公开 API 是 Handle::enter,返回的 EnterGuard 只是给 SetCurrentGuard 加了一个生命周期参数,保证 guard 活得不比 Handle 长:
pub struct EnterGuard<'a> {
_guard: context::SetCurrentGuard,
_handle_lifetime: PhantomData<&'a Handle>,
}
Handle::current() 和那句 panic
读第一层的函数是 with_current:
pub(crate) fn with_current<F, R>(f: F) -> Result<R, TryCurrentError>
where
F: FnOnce(&scheduler::Handle) -> R,
{
match CONTEXT.try_with(|ctx| ctx.current.handle.borrow().as_ref().map(f)) {
Ok(Some(ret)) => Ok(ret),
Ok(None) => Err(TryCurrentError::new_no_context()),
Err(_access_error) => Err(TryCurrentError::new_thread_local_destroyed()),
}
}
失败分两种:
Ok(None):thread-local 好好的,但里面没有句柄,即这个线程从没 enter 过任何运行时。错误信息是CONTEXT_MISSING_ERROR。Err(AccessError):线程正在退出,thread-local 已经被销毁,比如在别的 thread-local 的析构函数里调了tokio::spawn。错误信息是The Tokio context thread-local variable has been destroyed.
Handle::try_current 把这个 Result 原样交出去;scheduler::Handle::current 遇到 Err 就 panic!("{}", e);tokio::spawn 的 spawn_inner 也一样。Display 对 NoContext 输出的就是:
there is no reactor running, must be called from the context of a Tokio 1.x runtime
消息里说的是 reactor(反应器),实际检查的是整个运行时的句柄,和有没有开 IO 无关。
不只是 spawn 会查。很多类型在构造时就取当前句柄,然后一直拿着。Sleep::new_timeout 开头:
let handle = scheduler::Handle::current();
// Panic if the time driver is not enabled (backwards compat)
_ = handle.driver().time();
所以 rt.block_on(tokio::time::sleep(d)) 会 panic:sleep(d) 作为参数先求值,此时还没进 block_on,主线程上没有句柄。写成 rt.block_on(async { sleep(d).await }) 就没事,sleep 在 future 第一次被 poll 时才构造,那时已经在 block_on 里了。TcpStream::connect 之类返回 future 的函数不受影响,因为它们的函数体本身就在 async 里,要等 poll 才执行。
第二层:enter_runtime 与另一句 panic
current 只回答「用哪个运行时」,不回答「这个线程现在能不能阻塞」。rt.enter() 之后照样可以 rt.block_on(...),因为只设了第一层。第二层由 enter_runtime 管:
pub(crate) fn enter_runtime<F, R>(handle: &scheduler::Handle, allow_block_in_place: bool, f: F) -> R
where
F: FnOnce(&mut BlockingRegionGuard) -> R,
{
let maybe_guard = CONTEXT.with(|c| {
if c.runtime.get().is_entered() {
None
} else {
// Set the entered flag
c.runtime.set(EnterRuntime::Entered {
allow_block_in_place,
});
// Generate a new seed
let rng_seed = handle.seed_generator().next_seed();
// Swap the RNG seed
let mut rng = c.rng.get().unwrap_or_else(FastRand::new);
let old_seed = rng.replace_seed(rng_seed);
c.rng.set(Some(rng));
Some(EnterRuntimeGuard {
blocking: BlockingRegionGuard::new(),
handle: c.set_current(handle),
old_seed,
})
}
});
if let Some(mut guard) = maybe_guard {
return f(&mut guard.blocking);
}
panic!(
"Cannot start a runtime from within a runtime. This happens \
because a function (like `block_on`) attempted to block the \
current thread while the thread is being used to drive \
asynchronous tasks. If you are in an async function, use \
`.await` on the future instead of blocking on it."
);
}
它一次做三件事:
- 标记已进入。
runtime已经是Entered就 panic。这里没有深度计数,因为不允许嵌套。 - 换随机数种子。 从运行时的种子生成器取一个新种子换进
rng,退出时换回旧的。这样select!等用到的随机数由运行时的种子决定,设了Builder::rng_seed就可复现。 - 设当前句柄。 顺便调一次
set_current,所以enter_runtime包含了第一层。
调用它的地方:Runtime::block_on(两种调度器)、Handle::block_on、multi_thread worker 的 run。
为什么不允许嵌套?「进入运行时」的意思是这个线程正在驱动异步任务。在 worker 上,线程同时负责跑队列里的其他任务;在 current_thread 的 block_on 里,线程同时是唯一的调度线程。这时再 block_on 一个 future,线程就卡在内层的 park 上,外层的任务全都停摆,如果内层 future 恰好在等外层的某个任务,就是死锁。tokio 选择在入口直接拒绝。
第二个参数 allow_block_in_place。 multi_thread 的 block_on 和 worker 传 true,current_thread 的 block_on 传 false。block_in_place 在 maybe_move_runtime 里读它:在一个 Entered 但没有 worker 上下文的线程上,只有标志为 true 才允许,否则 panic can call blocking only when running on the multi-threaded runtime。LocalSet 被 poll 时(包括 run_until)会用 disallow_block_in_place 临时把它改成 false。
block_in_place 能在 worker 上阻塞,靠的是反向操作 exit_runtime:把 Core 交给别的线程之后,暂时把 runtime 设回 NotEntered,闭包结束再恢复。所以在 block_in_place 的闭包里可以 Handle::current().block_on(...)。
两个多线程运行时,各一个 worker,线程名分别是 rt-a 和 rt-b。name() 返回当前线程的名字。
rt_a.block_on(async {
println!("A: {}", tokio::spawn(async { name() }).await.unwrap());
{
let _g = rt_b.enter();
println!("B: {}", tokio::spawn(async { name() }).await.unwrap());
let r = panic::catch_unwind(panic::AssertUnwindSafe(|| rt_b.block_on(async { 1 })));
println!("C: rt_b.block_on is_err = {}", r.is_err());
}
println!("D: {}", tokio::spawn(async { name() }).await.unwrap());
});
提示:rt_b.enter() 改的是哪一层?rt_b.block_on 查的又是哪一层?答案在本页末尾。
不是 block_on 的阻塞
mpsc::Receiver::blocking_recv、Mutex::blocking_lock 这类同步原语的阻塞版本,不进运行时,只检查第二层。它们调 future::block_on:
pub(crate) fn block_on<F: Future>(f: F) -> F::Output {
let mut e = crate::runtime::context::try_enter_blocking_region().expect(
"Cannot block the current thread from within a runtime. This \
happens because a function attempted to block the current \
thread while the thread is being used to drive asynchronous \
tasks."
);
e.block_on(f).unwrap()
}
try_enter_blocking_region 在 Entered 时返回 None,于是 panic,措辞和上一句不同。thread-local 已销毁时它返回 Some,注释说既然不知道在不在运行时里,就宽松处理。
第三层:scheduler 指针
Scoped 是一个 Cell<*const T>。set_scheduler 在闭包执行期间把指针指向一个栈上的调度器 Context,闭包返回时恢复旧值。用裸指针而不是 Arc,是因为调度器 Context 就在调用者的栈帧上,生命周期由闭包保证,不需要引用计数。
读的一方是 with_scheduler:
pub(super) fn with_scheduler<R>(f: impl FnOnce(Option<&scheduler::Context>) -> R) -> R {
let mut f = Some(f);
CONTEXT.try_with(|c| {
let f = f.take().unwrap();
if matches!(c.runtime.get(), EnterRuntime::Entered { .. }) {
c.scheduler.with(f)
} else {
f(None)
}
})
.unwrap_or_else(|_| (f.take().unwrap())(None))
}
只有在 Entered 时才返回调度器上下文。block_in_place 调了 exit_runtime 之后,即使指针还在,with_scheduler 也返回 None,这个线程上的唤醒就会走注入队列,而不是往一个已经交出去的 Core 里塞。
调度器用它决定任务往哪放:schedule_task 先看「当前线程是不是这个运行时的 worker,而且手里有 Core」,是就放本地队列,否则放注入队列(见从 #[tokio::main] 开始)。current_thread 的 schedule 同理。注意它比较的是 Arc::ptr_eq:线程上是 A 运行时的 worker,唤醒的却是 B 运行时的任务,也要走 B 的注入队列。
实验:几种进入方式
src/bin/ctx_panics.rs,用一个只打印消息的 panic hook,把每种情况的 panic 原文打出来:
use std::panic;
use tokio::runtime::{Builder, Handle};
// 只打印 panic 的消息,不打印位置和 backtrace 提示
fn quiet_hook() {
panic::set_hook(Box::new(|info| {
let msg = info
.payload()
.downcast_ref::<String>()
.cloned()
.or_else(|| info.payload().downcast_ref::<&str>().map(|s| s.to_string()))
.unwrap_or_default();
println!(" panic: {msg}");
}));
}
fn main() {
quiet_hook();
println!("1. 运行时之外 Handle::try_current()");
match Handle::try_current() {
Ok(_) => println!(" ok"),
Err(e) => println!(" Err: {e} (is_missing_context = {})", e.is_missing_context()),
}
println!("2. 运行时之外 tokio::spawn");
let _ = panic::catch_unwind(|| {
tokio::spawn(async {});
});
let rt = Builder::new_multi_thread().worker_threads(1).enable_all().build().unwrap();
println!("3. rt.enter() 之后 Handle::current() 和 block_on");
{
let _guard = rt.enter();
let h = Handle::current();
println!(" current flavor = {:?}", h.runtime_flavor());
let v = rt.block_on(async { 1 + 1 });
println!(" block_on inside enter() -> {v}");
}
println!("4. 在 block_on 的 future 里再 block_on");
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
rt.block_on(async {
Handle::current().block_on(async {});
});
}));
println!("5. 在 spawn 出去的任务里 blocking_recv");
let r = rt.block_on(async {
tokio::spawn(async {
let (_tx, mut rx) = tokio::sync::mpsc::channel::<u8>(1);
let _ = rx.blocking_recv();
})
.await
});
println!(" JoinError is_panic = {}", r.unwrap_err().is_panic());
println!("6. spawn_blocking 线程里 Handle::current().block_on");
let v = rt.block_on(async {
tokio::task::spawn_blocking(|| Handle::current().block_on(async { "fine" })).await.unwrap()
});
println!(" -> {v}");
println!("7. sleep 在 block_on 之外构造");
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
rt.block_on(tokio::time::sleep(std::time::Duration::from_millis(1)));
}));
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
rt.block_on(async { tokio::time::sleep(std::time::Duration::from_millis(1)).await });
println!(" sleep built inside block_on: ok");
}));
println!("8. EnterGuard 乱序 drop");
let rt2 = Builder::new_current_thread().build().unwrap();
let _ = panic::catch_unwind(panic::AssertUnwindSafe(|| {
let g1 = rt.enter();
let g2 = rt2.enter();
drop(g1);
drop(g2);
}));
}
输出(release 构建):
1. 运行时之外 Handle::try_current()
Err: there is no reactor running, must be called from the context of a Tokio 1.x runtime (is_missing_context = true)
2. 运行时之外 tokio::spawn
panic: there is no reactor running, must be called from the context of a Tokio 1.x runtime
3. rt.enter() 之后 Handle::current() 和 block_on
current flavor = MultiThread
block_on inside enter() -> 2
4. 在 block_on 的 future 里再 block_on
panic: Cannot start a runtime from within a runtime. This happens because a function (like `block_on`) attempted to block the current thread while the thread is being used to drive asynchronous tasks. If you are in an async function, use `.await` on the future instead of blocking on it.
5. 在 spawn 出去的任务里 blocking_recv
panic: Cannot block the current thread from within a runtime. This happens because a function attempted to block the current thread while the thread is being used to drive asynchronous tasks.
JoinError is_panic = true
6. spawn_blocking 线程里 Handle::current().block_on
-> fine
7. sleep 在 block_on 之外构造
panic: there is no reactor running, must be called from the context of a Tokio 1.x runtime
sleep built inside block_on: ok
8. EnterGuard 乱序 drop
panic: `EnterGuard` values dropped out of order. Guards returned by `tokio::runtime::Handle::enter()` must be dropped in the reverse order as they were acquired.
对照上面的三层:
| 情况 | 第一层 | 第二层 | 结果 |
|---|---|---|---|
| 1、2:普通线程 | 空 | — | NoContext,spawn panic |
3:rt.enter() 后 block_on | 有 | NotEntered | 可以,enter 不阻止阻塞 |
4:block_on 里再 block_on | 有 | Entered | enter_runtime panic |
5:worker 上 blocking_recv | 有 | Entered | try_enter_blocking_region panic,panic 被任务接住变成 JoinError |
6:阻塞线程上 block_on | 有 | NotEntered | 可以,Handle::current() 也能用 |
7:sleep 在外面构造 | 空 | — | 构造时就 panic |
第 4 种情况是多线程运行时,block_on 的 future 跑在主线程上,不在 worker 上,照样 panic:判断依据是线程本地的 runtime 标志,不是「这是不是 worker 线程」。
动脑筋答案
完整程序(src/bin/two_runtimes.rs),panic hook 只打印消息的第一句:
use std::panic;
use std::thread;
use tokio::runtime::{Builder, Runtime};
fn name() -> String {
thread::current().name().unwrap_or("?").to_string()
}
fn rt(n: &str) -> Runtime {
Builder::new_multi_thread().worker_threads(1).thread_name(n).build().unwrap()
}
fn main() {
panic::set_hook(Box::new(|info| {
let msg = info.payload().downcast_ref::<&str>().map(|s| s.to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned()).unwrap_or_default();
println!(" panic: {}", &msg[..msg.find('.').unwrap_or(msg.len())]);
}));
let rt_a = rt("rt-a");
let rt_b = rt("rt-b");
rt_a.block_on(async {
println!("A: {}", tokio::spawn(async { name() }).await.unwrap());
{
let _g = rt_b.enter();
println!("B: {}", tokio::spawn(async { name() }).await.unwrap());
let r = panic::catch_unwind(panic::AssertUnwindSafe(|| rt_b.block_on(async { 1 })));
println!("C: rt_b.block_on is_err = {}", r.is_err());
}
println!("D: {}", tokio::spawn(async { name() }).await.unwrap());
});
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出都一样:
A: rt-a
B: rt-b
panic: Cannot start a runtime from within a runtime
C: rt_b.block_on is_err = true
D: rt-a
- A:
rt_a.block_on通过enter_runtime设了第一层,tokio::spawn取到的是rt_a,任务跑在rt-a的 worker 上。 - B:
rt_b.enter()只往第一层压了一个新句柄,tokio::spawn只看第一层,任务交给了rt_b。在rt_a的block_on里.await一个rt_b的JoinHandle没有问题,JoinHandle只需要一个 waker。 - C:
rt_b.block_on进enter_runtime,第二层已经是Entered(由rt_a.block_on设的),直接 panic。第二层只是一个标志,不记是哪个运行时设的,所以换一个运行时也不行。 - D:
_g在内层块结束时 drop,SetCurrentGuard把压栈前的rt_a句柄放回去,tokio::spawn又回到rt_a。
小结
CONTEXT是一个const初始化的 thread-local,字段全是Cell类,只在本线程读写。- 它分三层:
current(用哪个运行时,可嵌套,是个栈)、runtime(这个线程是否在驱动异步任务,不可嵌套)、scheduler(是否在调度循环里,决定任务进本地队列还是注入队列)。 Handle::enter只设第一层;enter_runtime设第一、二层并换随机数种子;worker 和拿到Core的 current_thread 线程再设第三层。- 「there is no reactor running」= 第一层为空。
spawn、Handle::current以及sleep这类在构造时取句柄的类型都会触发。 - 「Cannot start a runtime from within a runtime」=
enter_runtime发现第二层已设;blocking_recv之类走另一个检查,报「Cannot block the current thread from within a runtime」。 EnterGuard必须后进先出地 drop,guard 记着入栈深度,对不上就 panic。