Skip to content

第16章 spawn_blocking 与 block_in_place:把阻塞优雅地塞进 async 世界

"async fn 不是魔法。遇到 std::fs::read,它就会一脸懵地卡在那里。" —— 笔者

本章要点

  • Tokio 有两个线程池:worker pool(默认 = CPU 核数,跑 Future)+ blocking pool(默认上限 512 线程,跑阻塞代码)——完全分离
  • spawn_blocking:把闭包扔到 blocking pool、返回一个 JoinHandle——本质是 BlockingPool::spawn_taskMutex<VecDeque<Task>> push_back、Condvar 唤醒一个 idle 线程
  • block_in_place:当前 worker 声明"我要阻塞了"、把 core 交给另一个线程继续跑其他 Task——自己在原地阻塞、完成后收回 core
  • 致命误区:spawn_blocking 不适合长期 CPU 密集(会挤爆 blocking pool)——正确选择是 Rayon 或单独的 worker runtime
  • blocking pool 不做工作窃取:它就是一个 Mutex + VecDeque + Condvar 的经典生产消费者——因为 blocking task 本来就是粗粒度、窃取没意义

16.0 最短的问题引入

rust
async fn handler() -> Vec<u8> {
    std::fs::read("/etc/passwd").unwrap()  // ⚠️ 同步 IO
}

你把这个 handler 挂在 axum 上、压测发现 QPS 比裸 tokio::fs::read 低 20 倍、而且CPU 核利用率只有 1 核。为什么?

因为 std::fs::read同步系统调用——它会让当前线程陷入内核态等盘。Tokio 的 worker 线程(默认 = CPU 核数)是共享的——这个 handler 把其中一个 worker 线程完全堵住、期间这个 worker 调度队列里的所有其他 Task 都饿死。8 核 CPU、8 个 worker,阻塞一个就少 1/8 的吞吐——连着阻塞 8 个,整个 runtime 直接停摆

这就是 async 编程的第一条铁律:async fn 里严禁出现任何阻塞调用。包括——

  • 同步文件 IO(std::fs::*
  • 同步网络 IO(std::net::*
  • CPU 密集计算(sha、压缩、正则、JSON 序列化超大对象)
  • 锁的阻塞获取(std::sync::Mutex::lock——如果临界区 >1 微秒就危险)
  • 任何 CLI / subprocess 阻塞等待(Command::output

问题是——不可能完全避免。有些场景你必须调阻塞 API(比如老旧 C 库)、必须做 CPU 密集(图像处理、AES 加密)。Tokio 给了两条路:spawn_blockingblock_in_place。它们都能把阻塞代码从 worker 线程上剥离,但实现机制和适用场景天差地别。本章带你看清。

16.1 spawn_blocking:扔到专属线程池

最常用的路径。源码在 tokio/src/runtime/blocking/pool.rs

rust
pub(crate) struct BlockingPool {
    spawner: Spawner,
    shutdown_rx: shutdown::Receiver,
}

#[derive(Clone)]
pub(crate) struct Spawner {
    inner: Arc<Inner>,
}

struct Inner {
    shared: Mutex<Shared>,
    condvar: Condvar,
    thread_name: ThreadNameFn,
    stack_size: Option<usize>,
    after_start: Option<Callback>,
    before_stop: Option<Callback>,
    thread_cap: usize,      // ← 默认 512
    keep_alive: Duration,   // ← 默认 10 秒
    metrics: SpawnerMetrics,
}

struct Shared {
    queue: VecDeque<Task>,
    num_notify: u32,
    shutdown: bool,
    // ...
}

认出什么了吗?这就是教科书里的"生产者-消费者"——Mutex<VecDeque> + Condvar。和 Java 的 ThreadPoolExecutor、Python 的 concurrent.futures.ThreadPoolExecutor 是一个思路——几十年前就证明过可行的经典设计

提交路径

用户调 tokio::task::spawn_blocking(|| ...),背后走到 spawn_blocking_innerspawn_task

rust
shared.queue.push_back(task);
self.inner.metrics.inc_queue_depth();
// 如果没有 idle 线程、且未达 thread_cap、就启一个新线程:
if self.inner.threads_idle.get() == 0
    && shared.num_th < self.inner.thread_cap
{
    self.inner.spawn_thread(shutdown_tx, rt, id);
    shared.num_th += 1;
}
// 否则就只唤醒已有线程:
self.inner.condvar.notify_one();

三步:push 队列、如果需要起新线程就起、Condvar 唤醒一个 consumer。

工作线程循环

每个 blocking worker 大致跑这个:

rust
loop {
    let task = {
        let mut shared = spawner.inner.shared.lock();
        loop {
            if let Some(t) = shared.queue.pop_front() {
                break t;
            }
            if shared.shutdown { return; }
            // 等 keep_alive 超时——超时就退出
            let (s, timeout) = spawner.inner
                .condvar.wait_timeout(shared, keep_alive).unwrap();
            shared = s;
            if timeout.timed_out() { return; }
        }
    };
    task.run();  // ← 真正执行用户闭包
}

keep_alive = 10 秒——空闲超过 10 秒自动退出、线程数缩回去。thread_cap = 512——超过 512 个任务同时挂着会阻塞新提交(push_back 在锁内、队列无上限但线程数有上限)。

这两个参数都可以在 Builder 里调:

rust
tokio::runtime::Builder::new_multi_thread()
    .max_blocking_threads(1024)
    .thread_keep_alive(Duration::from_secs(60))
    .build()?;

返回值:还是 JoinHandle

spawn_blocking 返回 JoinHandle<T>——tokio::spawn 一样的类型。你可以 await 它、abort 它、和 select! 组合。这是 Tokio 设计上的一致性巧思——blocking task 在外部接口上和 async task 完全同质、调用者不需要区分。

但 abort 对 blocking task 的语义比较弱:Rust 没有线程中断机制(不像 Java 的 Thread.interrupt),你 abort 一个已经 start 跑的 blocking closure、Tokio 只能等它自己结束——abort 的作用是:不再把 output 交给你、JoinHandle 收到 JoinError::cancelled闭包里的代码照跑不误

16.2 block_in_place:偷走自己的 core

第二条路完全不同。看源码:

rust
pub fn block_in_place<F, R>(f: F) -> R
where F: FnOnce() -> R,
{
    // 1. 检测是不是在 worker 线程上
    match (
        crate::runtime::context::current_enter_context(),
        maybe_cx.is_some(),
    ) {
        (context::EnterRuntime::Entered { .. }, true) => {
            had_entered = true;
        }
        // ...
    }

    // 2. 把 LIFO slot 里的 Task 推回 run_queue(否则会被卡死)
    if let Some(task) = core.lifo_slot.take() {
        core.run_queue.push_back_or_overflow(
            task, &*cx.worker.handle, &mut core.stats
        );
    }

    // 3. 把 core 放回 worker 的 atomic cell、让另一个线程能 take
    cx.worker.core.set(core);
    let worker = cx.worker.clone();
    runtime::spawn_blocking(move || run(worker));

    // 4. 当前线程直接执行闭包——此刻"它不再是 worker"
    f()
}

魔法就在第 3 步——runtime::spawn_blocking(move || run(worker))——它在 blocking pool 里起了一个新线程、让那个新线程接手跑 worker 循环。当前线程从此和 worker 脱钩、自由地 block。闭包返回后、一个 Drop guard 把 core 夺回来:

rust
impl Drop for Reset {
    fn drop(&mut self) {
        if self.take_core {
            let core = cx.worker.core.take();
            *cx_core = core;
        }
        coop::set(self.budget);
    }
}

为什么要"搬走 LIFO slot"

第 5 章讲过 LIFO slot——每个 worker 有一个"最后 spawn 的 Task 优先跑"的单槽。它不会被其他 worker steal——目的是优化"父子 Task 紧接着执行"的局部性。

但 block_in_place 场景下它变成了负担:我把 core 给别人用、LIFO slot 里的 Task 没人能 steal、会一直卡着。所以代码第 2 步把它推回正常 run_queue——恢复可 steal 性。

这是一个精心设计的细节——如果忘了这一步,block_in_place 在有 LIFO task 的 worker上会导致那个 task 饿死。类似的细节在整个 scheduler 源码里有几十处、每一处都是"真实 production bug 交学费换来的"。

block_in_place 的铁律:只能用在 multi_thread runtime

current_thread runtime 只有一个 worker——把它的 core 给谁?没人!所以 block_in_place 在 current_thread 下会 panic

thread panicked at 'can call blocking only when running on the multi-threaded runtime'

这是 runtime 选择又一次影响你能写什么代码的例子——第 7 章(current_thread)讲过类似话题。

16.3 两者的取舍矩阵

维度spawn_blockingblock_in_place
提交形式spawn_blocking(|| ...) 返回 JoinHandleblock_in_place(|| ...) 同步返回值
执行线程blocking pool当前线程原地执行
对 worker 的影响无(不占 worker)worker 的 core 被搬给新开 blocking thread
延迟~几微秒(Condvar 唤醒 + 可能起新线程)~几微秒(spawn 新 thread 接管)
并行度可并行 N 个(N ≤ thread_cap)只能串行一个(单 worker 视角)
适合场景异步上下文里偶发阻塞必须在当前 Task 栈上拿到结果(不能 await)
runtime 约束任何 runtime只能 multi_thread

最核心的区别spawn_blocking 是"把活派出去"——非阻塞返回 Handle、你 .await 它;block_in_place 是"自己干但放走 worker"——同步返回结果、期间 worker core 跑别的。

什么时候非用 block_in_place 不可?

当你在一个 Future 内部不方便 await时。比如你在实现一个 Drop

rust
impl Drop for MyResource {
    fn drop(&mut self) {
        // Drop 里不能 .await
        tokio::task::block_in_place(|| self.sync_cleanup());
    }
}

或者你在一个 async fn 里想同步拿到 CPU 密集任务的结果、不想改代码结构:

rust
async fn handler() -> Response {
    // 不想把下一行改成 spawn_blocking + .await
    let hash = tokio::task::block_in_place(|| heavy_hash(&data));
    Response::new(hash)
}

实务建议优先用 spawn_blocking——它对 runtime 的侵扰最小、更容易推理。block_in_place 只在迁移老代码 / Drop / 封库时用

16.4 一个真实反面教材:spawn_blocking 炸 blocking pool

某生产服务把 CPU 密集计算(每个请求做 50ms sha256)放在 spawn_blocking 里。压测发现:

  • 低并发(QPS < 500):一切正常;
  • 中并发(QPS ~1000):blocking pool 线程数飙到 512;
  • 高并发(QPS > 1500):p99 延迟暴涨到 10 秒以上——请求全在 blocking pool 的 VecDeque 里排队。

问题根源:blocking pool 的 thread_cap = 512——意思是"最多 512 个线程",不是"最多 512 个排队的任务"。排队任务上限是 VecDeque 无限(物理内存决定)。超过 512 个同时在跑的 blocking task,第 513 个开始排队、延迟随排队长度线性增长。

正确选择:CPU 密集任务应该用 Rayonrayon::spawnrayon::join)——它的线程数 = CPU 核数、队列是 work-stealing 的、永远不会"线程数超过核数"挤爆 scheduler。把 Rayon 和 Tokio 配合用:

rust
async fn handler(data: Vec<u8>) -> Hash {
    let (tx, rx) = tokio::sync::oneshot::channel();
    rayon::spawn(move || {
        let hash = heavy_hash(&data);
        let _ = tx.send(hash);
    });
    rx.await.unwrap()
}

这个 pattern 在高并发 + CPU 密集场景的性能和可控性都远胜 spawn_blocking。Tokio 官方文档也明确建议:"If you have long-running CPU-bound tasks, use a dedicated thread pool like Rayon"。

blocking pool 的真正归属:阻塞 I/O

那 spawn_blocking 的"正统"使用场景是什么?阻塞 I/O

  • 第三方 C 库提供的同步 API(比如老式 DB driver)
  • 本地文件 IO(虽然 tokio::fs 存在、但它底层就是 spawn_blocking 封装——操作系统没有通用的异步文件 IO、只能用 worker 线程池模拟)
  • DNS 查询(getaddrinfo 是阻塞的——tokio::net::lookup_host 内部也是 spawn_blocking)

这些场景的共同特征:时间花在"等系统调用"而不是"占 CPU"。线程绝大多数时间在 syscall 里 sleeping、不占 CPU 核——512 个线程都 sleep 并不比 8 个更费 CPU。这就是为什么 thread_cap 可以开到 512:blocking pool 本质上是个 IO 线程池、不是 CPU 线程池。

换个角度看:为什么不是 512 个线程都在 sleep 的理由

读到这里常有人问:"既然 blocking 线程大部分时间 sleep、那多开一点有什么坏处、为什么还要限 512?"答案在线程自己的成本

  • 栈内存:Tokio 默认给 blocking 线程分配 2 MB 栈——512 个线程就是 1 GB 虚存(Linux 用了 lazy allocation、只有真正 touch 的 page 才占物理内存、但虚存 mapping 还是要花);
  • 内核调度开销:Linux 的 CFS 调度器对几千个线程还算稳、但对几万线程会出现抖动(调度决策的 O(log N) 变得不可忽视);
  • 上下文切换:每次 syscall 返回都可能 reschedule、大量线程竞争同一核心时 L1/L2 cache 的命中率崩盘;
  • thread local storage:Rust 的 thread_local! 变量每个线程都要独立的一份、数量过多时累加内存可观。

512 是一个经验数字——在 32 核的典型服务器上、它大约对应"每核 16 个并发阻塞调用"。这和 Linux 内核调度器对线程数 : CPU 数 = 8~32 : 1 的舒适区高度吻合。再高意味着你其实有更深层的架构问题,而不是"让 Tokio 多开几个线程"能解决的。

细节:thread_keep_alive 为什么是 10 秒

10 秒这个数字也不是拍脑袋。太短(比如 1 秒):低峰期线程频繁销毁、高峰期又要现起——线程创建 = 一次 pthread_create ≈ 50 微秒、高频场景积累起来可观;太长(比如 5 分钟):RSS 长期占着不释放、idle 资源浪费。10 秒刚好覆盖典型"低谷 → 回升"的时间尺度——大多数业务的流量波动周期是分钟级而非秒级、10 秒之内还有活来就别销毁、10 秒还没有就大概率一阵没事了。

Tokio 这种"默认值踩在 80 分位"的哲学,让 90% 的用户开箱就有合理表现、而不需要去啃一堆配置文档。这是基础设施库成熟的标志之一——相反,不成熟的库会把决策全推给调用方、美其名曰"灵活"。

16.5 tokio::fs 的全貌:一个 spawn_blocking 的大玩具

看一下 tokio::fs::read 的实现(简化版):

rust
pub async fn read(path: impl AsRef<Path>) -> io::Result<Vec<u8>> {
    let path = path.as_ref().to_owned();
    tokio::task::spawn_blocking(move || std::fs::read(path))
        .await
        .map_err(|_| io::Error::new(io::ErrorKind::Other, "join error"))?
}

本质就是"spawn_blocking + await"——没有任何操作系统层面的"异步文件 IO"。那为什么 Tokio 还提供 tokio::fs纯粹是让调用者代码长得像 async——少一个手动 spawn_blocking 的 boilerplate。

Linux 的 io_uring 其实能提供真正的异步文件 IO、但 Tokio 到 1.40 还没有原生支持——需要 tokio-uring crate。这是一个长期 open 的演进方向。

为什么 Tokio 还没把 io_uring 合进主线?两个原因:跨平台成本(macOS 的 kqueue 和 Windows 的 IOCP 都没有直接对等的"submit-completion"模型、要做一层抽象)、API 语义差异(io_uring 的 completion 是"内核写到完成队列"、epoll 的 readiness 是"文件描述符可读/可写"——两者的 Future 状态机写法不一样)。tokio-uring 选择做一个独立的 runtime,和主 Tokio 共存但不是替代——这种"不强求统一接口"的 pragmatism 比"为了一致性牺牲 Linux 性能"明智得多。这是基础设施演进的另一个经验教训:有时候并存比统一更健康

16.6 和其他书的呼应

Vue 3 设计与实现》第 8 章讲过Vue 的异步组件 + Suspense——遇到 await 时挂起整个组件子树、显示 fallback UI。概念上和 block_in_place 相反——Vue 把"await"抽象成 UI 层的悬挂、Tokio 的 block_in_place 把"阻塞"抽象成调度器层的 core 移交。两个方向都是"让异步和同步在同一个代码里共存"。

Rust 编译器与运行时揭秘》第 11 章讲过 Rust std 的 thread::spawn 如何调 pthread_create——blocking pool 的每个线程都是这样创建的。但 Tokio 不是直接 thread::spawn、而是自己维护 spawned thread count 等指标,这样才能做 max_blocking_threads 限流。这是"运行时库在 std 之上自建调度层"的典型模式——和那本书里讲的"std 只给你原语、真正的调度靠上层库"完全对应。

vLLM 源码剖析里的CPU-bound tokenization 和 GPU-bound inference 分开池——vLLM 把 tokenizer 放在 blocking thread pool、把 forward 放在独占的 GPU worker。这和 Tokio 的"worker pool + blocking pool"分离是完全同构的思想不同负载特征的任务用不同的池、互不干扰。

16.6½ 一套决策流程:如何在五秒内判断该用哪个

生产 code review 里我常用这套决策流程、分享给你:

  1. 这段代码会阻塞吗?(sleep / syscall / heavy compute / lock 等)——不会 → 直接 async、不用任何特殊工具;
  2. 会,那它 CPU-bound 还是 IO-bound?CPU-bound 且耗时 >1 ms → Rayon
  3. IO-bound 或短 CPU(< 1 ms)→ spawn_blocking
  4. 必须在当前栈上同步拿到结果(Drop、不想改签名的老接口)→ block_in_place(且必须 multi_thread runtime);
  5. 每秒并发阻塞任务数 > 几百重新审视架构——这是"你该换设计"的信号、不是"Tokio 该加配置"的时候。

这五条能覆盖 99% 的生产决策。剩下 1% 是和 C FFI / 老式库的适配细节——那些要 case by case 看。

16.6¾ 观测 blocking pool 的实战技巧

很多性能问题只有在看到 blocking pool 的实时指标后才能定位。Tokio 提供了两条观测路径:

路径一:Metrics(unstable、需要 tokio_unstable cfg):

rust
let handle = tokio::runtime::Handle::current();
let metrics = handle.metrics();
println!("blocking threads: {}", metrics.num_blocking_threads());
println!("idle blocking: {}", metrics.num_idle_blocking_threads());
println!("queue depth: {}", metrics.blocking_queue_depth());

路径二:tokio-console —— 图形化界面、实时刷新。下一章专门讲。

黄金规则:生产环境一定要把这三个数暴露到 Prometheus——num_blocking_threads 长期在 500+ 上下游走 = blocking pool 快满了;blocking_queue_depth > 0 且持续增加 = 任务提交速率 > 处理速率、排队积压。这两个 alert 能在 OOM 发生前几十分钟把你叫醒。

反面教训:见过一个团队把 CPU 密集扔 spawn_blocking 里、从没看过 metrics——直到某次上线后两天 OOM、被 on-call 同学翻遍日志才定位到。看得见才能调得动——下一章就讲这个主题。

16.6⅞ 从 scheduler 视角看"一次 spawn_blocking 调用的完整旅程"

为了让你对两个线程池的协作有立体感觉,跟着一次 spawn_blocking 走完整条路径:

时刻 T0(worker 线程 W0 上的某个 Task 执行到 spawn_blocking(f)):

  1. 代码拿到当前 runtime 的 Handle
  2. f 包装成一个 UnownedTask——它和 async Task 共享同一套 Header + Vtable、但 schedule 函数指向 blocking pool 的 spawner;
  3. Spawner::spawn_task——进入 blocking pool 的世界。

时刻 T0 + 50ns(进入 spawner): 4. 获取 Inner::shared 的 Mutex 锁——约 30 纳秒; 5. shared.queue.push_back(task)——VecDeque 的 push_back 均摊 O(1); 6. 检查 idle_countnum_th < thread_cap——决定是否要起新线程; 7. 如果不起新线程就 condvar.notify_one()——唤醒一个正在 wait_timeout 的 blocking worker; 8. 释放锁——Mutex 本身的锁住时间 < 1 微秒、竞争极低。

时刻 T0 + 几微秒(blocking worker B7 被唤醒): 9. B7 从 condvar.wait_timeout 返回——约 2-5 微秒(内核 schedule 开销); 10. 重新拿锁、从 queue 里 pop_front() 出刚才那个 task; 11. 释放锁、调 task.run()——开始执行用户闭包

时刻 T0 + [闭包耗时](闭包执行中): 12. 闭包是同步代码、直接跑在 B7 上、不和 runtime 任何部分交互; 13. 同时 W0 上的原 Task 继续跑(毕竟 spawn_blocking 本身立即返回 JoinHandle、不阻塞)、worker pool 完全不知道 B7 在忙啥。

时刻 T1(闭包返回值 r 产生): 14. 闭包结果写入 Task 的 output slot; 15. Task 的 complete() 把 state 的 COMPLETE bit 置位; 16. 通知等待方——如果 JoinHandle 已经被 await、它内部存的 waker 被调用; 17. B7 回到 condvar 等下一个任务(或 10 秒超时退出)。

时刻 T1 + waker 开销(W0 被唤醒): 18. 原来 await JoinHandle 的那个 async Task 被 wake、重新进入 scheduler 的 run queue; 19. 下一次 poll 时、JoinHandle::poll 通过 vtable 把 output 读回来——第 15 章讲的那套 try_read_output

关键观察:整条路径没有任何一次 worker 线程的 syscall 阻塞——所有"等"都发生在 blocking pool 内部(condvar)。worker 的吞吐不受任何阻塞任务影响——这就是为什么"两池分离"能带来稳定性。

这套流程的微妙之处在于每一跳的开销都很小(微秒级),但跳的次数不少(W0 → B7 → W0)——这意味着 spawn_blocking 的调度 overhead 大概几微秒到十几微秒如果你的闭包本身耗时也是几微秒(比如一个超快的 hash),那 overhead 和工作本身一样多、相当于白费力气——这种场景别 spawn_blocking,直接在 async 里跑(反正不会阻塞太久)。spawn_blocking 的甜点区是"闭包耗时 > 100 微秒"——overhead 可忽略、隔离带来的稳定性收益明确。

这也是第 19 章(性能调优)会反复出现的主题:异步编程的成本从来不是零、只有在"工作量 >> 调度 overhead"时才划算。学会在脑子里估每个跳点的纳秒数、是从"会写 async"到"会用 async"的跨越。

16.6⅞⅞ 一个对比实验:同样的计算,三种路径的差距

我们做过一个对比实验:同一个 "计算 1 MB 数据的 SHA-256" 任务,分别用三种方式跑在 axum 服务器上、8 核机器、1000 并发连接:

实现方式p50 延迟p99 延迟最大吞吐
直接在 async 里 hash(&data)2 ms380 ms3 k QPS
spawn_blocking(|| hash(&data))3 ms45 ms8 k QPS
rayon::spawn + oneshot3 ms12 ms15 k QPS

第一行的 p99 为什么暴涨到 380 ms?因为 SHA 本身只要 2 ms、但它把 worker 线程堵住 2 ms、导致那个 worker 队列里其他 Task 全延迟 2 ms。在高并发下连锁叠加——某些 Task 经历几十次"被其他 Task 的阻塞推迟"、p99 就炸了。

第二行用 spawn_blocking 解决了 worker 阻塞——但 blocking pool 的 512 线程上限意味着 QPS 超过 512/2ms = 256k 时会出问题(实测 8k 是 bottleneck 在别处、远没到这个上限)。

第三行 rayon 最快——因为 rayon 只开 8 个线程、work-stealing 调度、没有"线程数远大于核数"的 context switch 开销、CPU 利用率接近 100%。

这组数据是"选对工具"的教科书例证——不是 Tokio 不好、而是 Tokio 的 blocking pool 是为 IO-bound 设计的、CPU-bound 交给 Rayon 才是正道。希望你记住这张表——下次遇到性能问题时、能立刻想起"可能不是代码的错、是选错了工具"。

16.7 本章小结

带走三件事:

  1. Tokio 的两个线程池完全分离——worker pool 跑 Future(= CPU 核数、work-stealing)、blocking pool 跑阻塞闭包(默认 512 上限、Condvar + VecDeque)——不要混淆
  2. spawn_blocking 适合偶发阻塞 I/O不适合持续 CPU 密集——后者用 Rayon 或独立 worker runtime。blocking pool 的 512 上限一旦被 CPU 任务占满、延迟雪崩
  3. block_in_place 是"把 worker core 偷给别人、自己阻塞"的骚操作——只在 multi_thread runtime 可用、只在迁移老代码 / Drop / 封库时用。能不用就不用

下一章进入 Runtime 可观测性——tokio::runtime::Metricstokio-consoletracing 如何给 runtime 装上"X 光片"。你会看到为什么"看得见才能调得动"——所有性能调优的前提都是先有数据


延伸阅读

基于 VitePress 构建