Tokio 在同步上下文中执行异步代码

发布时间 2023-11-05 17:12:20作者: 那阵东风

spawn 说起

Tokio 库中有两个同名的量, 它们都叫 spawn, 但是却有着显著的区别:

其中一个是 tokio::runtime::Runtime 结构体的方法 (method), 另一个是 tokio::task 模块的一个函数, 同时也是你使用 tokio::spawn 时直接使用的那个. 从这个特征来看, 两者使用的方法是截然不同的, 但背后的原理却有相似之处.

显式绑定运行时的 spawn 方法

首先看 Runtime 结构体的方法 Runtime::spawn. 这个方法的目的是将一个实现了 Send trait 的 Future 对象送入给定的运行时中 (常常是一个线程池), 然后由对应的运行时执行器反复 poll 这个 Future 直到它执行完毕. 被送到执行器的 Future 会被立刻开始执行, 直到执行结束, 或者首次遇到 .await. 如下面的例子:

use std::time::Duration;

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.spawn(async move {
        println!("Printing in a future (L1).");
        tokio::time::sleep(Duration::from_secs(1)).await;
        println!("Printing in a future (L2).");
    });

    println!("Runtime terminated.");
}

我们先通过 Runtime::new() 创建了一个新的运行时, 随后 rt.spawn 会使用传入的 Future 创建新的异步任务并开始执行. 但是, 取决于操作系统对于多个线程的调度方式不同, 有可能在程序输出 Printing in a future (L1). 之前就已经结束了, 也有可能分别输出了:

Printing in a future (L1).
Runtime terminated.

但是一定不会输出的是 Printing in a future (L2)., 因为当这个 Future 执行遇到 .await 时, 它的执行就被暂停了. 在等待这 1 秒钟的过程中, 程序一定已经结束执行, 因此第二个语句不会被执行.

绑定当前运行时的 spawn 函数

tokio::task::spawn 或者 tokio::spawn 函数的调用逻辑来看, 当我们调用它的时候, 会执行:

tokio::runtime::Handle::current();

这个方法被用来获取当前上下文中运行时的 handle, 从而向对应的 scheduler 中添加新的 Task. 听起来非常简单, 我们把上例中的运行时构造步骤进行更改, 并将 rt.spawn 替换为函数调用 tokio::spawn:

use std::time::Duration;

fn main() {
    let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap();

    etokio::spawn(async move {
        println!("Printing in a future (L1).");
        tokio::time::sleep(Duration::from_secs(1)).await;
        println!("Printing in a future (L2).");
    });

    println!("Runtime terminated.");
}

执行这段代码, 出现了这样的运行时错误:

thread 'main' panicked at 'there is no reactor running, must be called from the context of a Tokio 1.x runtime', src/main.rs:8:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

查看堆栈跟踪信息, 可以发现问题就出在调用 tokio::runtime::scheduler::Handle::current 的过程中. 这段错误信息也说明了 Tokio 在尝试将 Future 添加到某个 scheduler 时出现了上下文中找不到带有 Tokio 运行时的问题. 所以, 我们需要一种方法将当前的线程和新创建的 Tokio 关联起来. Tokio 为每一个 Runtime 实例都提供了一个 enter 方法. 这个方法内部会尝试将本线程的静态 Tokio Context 实例关联到本当前 Runtime 之上:

let _enter_guard = rt.enter();

需要注意的是, 我们不能直接使用 _ 作为变量名. 使用 _ 相当于 rt.enter() 的返回值直接丢弃了, 等价于这个用法:

let _enter_guard = rt.enter();
drop(_enter_guard);

因此, 后续代码执行时仍然无法通过 Handle::current 捕获当前的运行时 handle, 故同样的错误还会出现.

再聊聊 block_on

刚才我们聊到了 Tokio 中 Runtime 的 spawn 方法可以用来在绑定的运行时 scheduler 上产生新的 Future, 并立即开始执行直到阻塞或结束. 但其实 Runtime 上还有另外一个方法也非常重要, 那就是 block_on.

block_on 方法有个重要的特征, 就是当它开始执行时, 一定会阻塞当前的线程, 直到执行完毕后退出. 我们回顾刚才的例子:

use std::time::Duration;

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    let _enter_guard = rt.enter();

    tokio::spawn(async move {
        println!("Printing in a future (L1).");
        tokio::time::sleep(Duration::from_secs(1)).await;
        println!("Printing in a future (L2).");
    });

    println!("Runtime terminated.");
}

如果想让 Future 中两个输出语句 (L1 和 L2) 都能够输出, 显然程序不可以在遇到 .await 后结束执行. 由于我们使用了多线程的运行时, 最显而易见的方法就是阻塞主线程, 避免它意外退出. 最简单直接的方法就是使用 rt.block_on 方法:

let rt = tokio::runtime::Runtime::new().unwrap();

rt.block_on(async move {
	println!("Printing in a future (L1).");
	tokio::time::sleep(Duration::from_secs(1)).await;
	println!("Printing in a future (L2).");
});

println!("Runtime terminated.");

执行上述程序, 我们得到了正确的输出结果:

Printing in a future (L1).
Printing in a future (L2).
Runtime terminated.

但是怎样证明 rt.block_on 方法阻塞了当前线程的同时, 还有其他的线程在执行任务呢?

use std::time::Duration;

fn main() {
    println!("Main thread: {:?}", std::thread::current().id());

    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.spawn(async move {
        println!(
            "[{:?}] Printing in a future (L0).",
            std::thread::current().id()
        );
    });

    rt.block_on(async move {
        println!(
            "[{:?}] Printing in a future (L1).",
            std::thread::current().id()
        );
        tokio::time::sleep(Duration::from_secs(1)).await;
        println!(
            "[{:?}] Printing in a future (L2).",
            std::thread::current().id()
        );
    });

    println!("Runtime terminated.");
}

我们在执行 rt.block_on 来阻塞当前线程之前, 先调用 rt.spawn 方法新增了一个异步任务. 其次, 在主线程, block_onFuturespawnFuture 里面, 我们分别输出当前的线程号 (ThreadId). 执行结果如下:

Main thread: ThreadId(1)
[ThreadId(1)] Printing in a future (L1).
[ThreadId(7)] Printing in a future (L0).
[ThreadId(1)] Printing in a future (L2).
Runtime terminated.

可以看到, block_on 的线程 (1) 就是主线程 (1), 而 spawnFuture 在完全不同的另一个线程 (7) 中执行. 反复执行多次, 可以看到主线程始终不变 (1), 但 spawn 线程却每次都发生变化. 这个实验同时说明了以下几个问题:

  1. Runtime::new() 方法会创建多线程 scheduler 来执行代码;
  2. Runtime::block_on() 方法会阻塞所在的线程执行;
  3. Runtime::spawn() 方法会在以类似线程池的方式来创建异步任务, 然后由不同的线程来获取执行.

除了 Runtime 上的 block_on 方法, 还有另外两种: Handle::block_ontokio::task::LocalSet::block_on. 它们的区别又是什么呢?

Handle::block_on 方法

Handle::block_onRuntime::block_on 的差异与当前的运行时类型密切相关. Runtime 的类型包括两种: 当前线程执行多线程执行. 其中, 多线程执行方式下, Handle::block_onRuntime::block_on 没有差别, 但是在当前线程执行时, 有着巨大的差异, 并且直接决定了我们的程序能否正常执行.

当前线程执行方式下, Future 中涉及 IO 相关或是计时器相关的阻塞都不会在 Handle::block_on 中执行, 它会一直阻塞永不退出. 相比之下, Runtime::block_on 则没有任何限制, 可以正确阻塞线程, 也可以完成 IO 和计时器相关的阻塞操作. 因此, 如果可能, 尽量避免使用 Handle::block_on. 比如下面的这个例子就很好说明了这个问题:

use std::time::Duration;

macro_rules! tid {
    () => {
        std::thread::current().id()
    };
}

fn main() {
    println!("Main thread: {:?}", std::thread::current().id());

    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();
    let rt = tokio::runtime::Runtime::new().unwrap();

    rt.spawn(async move {
        println!("[{:?}] From future (1).", tid!());
        tokio::time::sleep(Duration::from_secs(1)).await;
        println!("[{:?}] From future (1), after sleep 1 second.", tid!());
    });

    // rt.block_on(async move {
    //     println!("[{:?}] From future (2)", tid!());
    //     tokio::time::sleep(Duration::from_secs(1)).await;
    //     println!("[{:?}] From future (2), after sleep 1 second.", tid!());
    // });
    
    rt.handle().block_on(async move {
        println!("[{:?}] From future (2)", tid!());
        tokio::time::sleep(Duration::from_secs(1)).await;
        println!("[{:?}] From future (2), after sleep 1 second.", tid!());
    });

    println!("Runtime terminated.");
}

通过分别注释 rt.handle().block_onrt.block_on 的部分, 可以明确观察到上述现象.

LocalSet::block_on 方法

聊到这个方法, 就不能不谈多线程方式下的运行时了. 早前我们探究 spawn 时已经证实, Tokio 确实会采用线程池来执行这些任务. 但是有的情况下, 我们的任务没有实现 Send trait, 也就是不允许安全地在不同线程之间转移. 所以为了解决这个问题, 就需要使用 LocalSet::block_on 方法了, 参见文档.

小结

Rust 中使用 Tokio 在同步环境中执行异步 Future 是一个非常常见的操作, 甚至于我们熟悉的 #[tokio::main] 过程宏也是进行了这样的绑定. 理解同步上下文中启用异步 Runtime 的逻辑非常重要, 应该多做演练.


参考资料

  1. https://docs.rs/tokio/latest/tokio/runtime/struct.Runtime.html#method.spawn
  2. https://docs.rs/tokio/latest/tokio/task/struct.LocalSet.html#method.block_on
  3. https://docs.rs/tokio/latest/tokio/runtime/index.html
  4. https://docs.rs/tokio/latest/tokio/task/fn.spawn.html
  5. https://docs.rs/tokio/latest/tokio/runtime/index.html