阻塞代码本质是抽象泄漏


这篇文章讨论了在编程中使用异步代码(async)与阻塞代码(blocking)的对比,特别是在Rust编程语言的上下文中。

  • 作者认为,尽管异步代码可能难以理解,但它提供了一种优雅且优于其他选择的编程模型。
  • 文章反驳了将异步代码视为“泄漏抽象”的观点,即异步代码的存在迫使整个程序的控制流必须适应它。

很多人说 async 是一种 "泄漏的抽象"?
这句话的意思是,由于程序中存在 async,你不得不弯曲程序的控制流来适应它。

如果你的程序中有 100 个文件,而其中一个文件使用了 async,那么你就必须用 async 来编写整个程序,或者采用波希米亚式、令人费解的黑客手段来控制它。

这里抽象泄露指的不是内存泄露,也就是如果你分配的内存没有释放,就会发生内存泄露。 无论是异步代码还是阻塞代码,本质上都不存在内存泄漏问题。


在这些讨论中,我经常看到鲍勃-尼斯特罗姆(Bob Nystrom)撰写的博文 "你的函数是什么颜色的?“What Color is Your Function?” ":

  • 这篇博文最初是针对 JavaScript 的回调而写的。 回调模型很难处理,它在 Rust 生态系统中经久不衰,我必须写一篇博文来讨论这个问题。
  • 他还提到了异步/等待(async/await)作为解决这一问题的潜在方案,不过他对这一方案并不满意,因为它仍然将生态系统分为异步和同步两半。

虽然这篇博文在谈到 JavaScript 和其他高级语言时可能是正确的,但我认为 Rust 突出特点决定了它并不符合。 事实上,我认为事实恰恰相反。 非同步代码(或 "阻塞 "代码)才是真正的泄漏抽象

从异步代码调用阻塞代码相对容易,而从阻塞代码调用异步代码则更复杂。

如何从异步代码调用阻塞代码,反之亦然。
让我们制作一个表格来描述如何从一种 "颜色 "调用另一种 "颜色 "的函数。 从阻塞代码调用阻塞代码不会有任何问题。 你也可以从异步代码中调用异步代码。 还有一种从阻塞代码调用异步代码的策略,我稍后会详细介绍。

并非所有代码都完全符合async/blocking 类别。一个臭名昭著的例子是 GUI 代码,它使用阻塞语义,但总体上表现得非常像async 代码,因为它不允许阻塞。但这是另一篇文章的主题。

当你编写一个async函数时,它会返回一个Future,它代表最终将被解析的值。你可以用Future 做很多事情:

  • 你可以让它与另一个 竞争,在执行器上生成它,以及执行任意数量的其他操作。

但是,更简单的操作之一就是等待Future完成。
通常,等待是通过阻塞当前线程来完成的。因此,通过在Future “阻塞”,我们可以有效地将async函数转换为同步调用。

async fn my_async_code() { /* ... */ }

fn my_main_blocking_code() {
    use futures_lite::future::block_on;
    block_on(my_async_code());
}

block_on 可以调用任何一个 Future,无论它是 !Send 还是 'static' 还是即将爆炸。 因此,同步代码可以调用任何异步函数。

block_on 是这样实现的:

pub fn block_on<T>(future: impl Future<Output = T>) -> T{
    // A Context with a Waker is needed to poll a Future.
    let waker = waker_that_blocks_current_thread();
    let mut context = Context::from_waker(&waker);

    std::pin::pin!(future);
// This used to require unsafe code, but doesn't anymore!

   
// Poll the future in a loop, blocking the thread while we wait.
    loop {
        match future.as_mut().poll(&mut context) {
            Poll::Ready(value) => return value,
            Poll::Pending => block_thread_until_waker_wakes_us(),
        }
    }
}

没那么简单?
当然没那么简单。 我相信熟悉从阻塞代码中调用异步代码的人现在一定在对着屏幕尖叫。

现在有相当多的异步板块都运行在 tokio 之上。 它们使用 tokio 的基元、tokio 的执行器和 tokio 的 I/O 语义。
因此,它们依赖于 tokio 的运行时在后台运行。

如果对依赖于 tokio 的 crate 尝试上述策略,运行时就会崩溃。
不必害怕。 我们可以启动 tokio 运行时,让它永远在后台安静地运行。 各种库都能接收并使用该运行时。

 main()初始化:

use std::{future, thread};

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

   
// Clone a handle to the runtime and send it to another thread.
    thread::spawn({
        let handle = rt.handle().clone();

       
// Run the handle on this thread, forever.
        move || handle.block_on(future::pending::<()>())
    });

   
// "Enter" the runtime and let it sit there.
    let _guard = rt.enter();

   
// Block on any futures.
    pollster::block_on(my_async_function());
}

对于应用程序中的任何 block_on 调用,运行时都已可用。

请注意,您需要在任何使用 tokio 基元的新线程上调用 enter()。

值得庆幸的是,你可以获得一个运行时的句柄(Handle),它可以发送给任何线程,而且还可以廉价克隆。
但其实也仅此而已。

一旦运行时在后台嗡嗡作响,tokio futures 就能正常工作了!

另外,block_on 和类似函数只有在支持 std 的平台上才能使用。 

有人建议使用tokio::main属性将函数转换 async为阻塞函数,然后从实际代码中调用该函数。例如:

#[tokio::main(flavor = "current_thread")]
async fn my_async_code() {
/* ... */ }

fn main() {
   
// tokio::main transparently converts my_async_code into a blocking function.
    my_async_code();
}

async使用 proc 宏将该函数转换为阻塞函数。

但是……千万不要这么做。这意味着,每次my_async_code调用时,它都会启动一个tokio运行时,运行代码,然后立即丢弃该运行时。对于经常调用的函数,这确实很麻烦。

此外,它还会使函数签名产生误导:它其实是一个阻塞函数!

异步函数更可推测:
async fn my_async_function() { /* ... */ }
这说明了什么? 我知道这个函数返回的 Future 不会阻塞。 我可以把它放在我选择的执行器中,或者让它与其他任何未来程序竞赛,而不用担心它会占用执行循环。 按照惯例,poll()可能会在接近 "即时 "的时间段内运行,然后退出,让其他程序接替它。 是的,现在有很多漏洞百出的期货程序。 但是格式良好的期货程序会很快完成。

fn my_function() { /* ... */ }
通过观察这个函数签名,你能知道它需要运行多长时间吗? 也许会立即完成。 也可能是从文件中读取,根据文件系统的不同,可能需要几微秒到几秒。 可能会阻塞网络套接字。 也许它在循环中处理大量数据,这意味着对于大型数据集,它可能会运行很长时间。 是的,你可以查看文档。 但文档通常不会提及上述任何行为,即使是标准库中的函数。 所有这些也都忽略了依赖于泛型/特性的行为。 不管这个函数有多完善,你都无法知道它将如何运行。

通常,在编写异步程序时,我必须格外确保在使用阻塞函数时不会意外阻塞,以免锁住整个事件循环。 在大多数情况下,这需要我阅读整个函数的代码,以了解可能出现的问题。 如果我不能确定它不会阻塞,我就需要将它封装在一个 Future 中,在自己独立的线程上运行。

use blocking::unblock;

fn my_blocking_function() { /* ... */ }

async fn my_async_main() {
    unblock(|| my_blocking_function()).await;
}

这种方法是有代价的。 至少需要分配阻塞任务的状态,以及一些原子操作来推送它,然后再从某个线程池的任务队列中弹出。 最坏的情况下,它会产生一个全新的线程。

相比之下,block_on 的代价通常是一个线程本地访问。 但是等等!unblock 会将函数发送到另一个线程运行。 因此,函数必须是 Send 和 "静态 "的。 如果函数依赖于某种线程不安全状态(如 RefCell),这种策略甚至不起作用。 如果函数需要引用某些数据,则可能需要用 Arc 对其进行封装。

use blocking::unblock;
use std::sync::{Arc, Mutex};

fn my_blocking_function(data: &mut Foo) { /* ... */ }

async fn my_async_main() {
    let data = Arc::new(Mutex::new(
/* ... */));
    unblock({
        let data = data.clone();
        move || my_blocking_function(&mut data.lock().unwrap())
    }).await;
}


我知道这是对 tokio 的 async/await 风格的常见抱怨,但反过来也一样糟糕。 请注意,您可以使用任何借用数据调用 block_on,不会有任何问题。

async fn my_async_code(foo: &mut Foo) { /* ... */ }

fn my_main_blocking_code() {
    use futures_lite::future::block_on;

    let mut data =
/* ... */;
    block_on(my_async_code(&mut data));
// This works!
}


为了避免这些问题,我经常需要将可能阻塞的代码分割成不同的部分。 这样做的好处是,我可以避免为每个函数解除阻塞的开销。

fn some_blocking_segment(mut data: Foo) {
    do_something(&mut data);
    data.postprocess();
    print_the_data(&data);
}

async fn my_async_main() {
    // This doesn't work if Foo is !Send.
    let data =
/* ... */;
    unblock(
        move || my_blocking_function(&mut data.lock().unwrap())
    }).await;
}

不过,这需要我将部分代码重新架构到这些分段中。 将更多的异步代码交织到这个子部分也变得很困难。 是的,我可以在 block_on 中调用异步函数,但我更喜欢在其上使用 .await。 你不觉得这看起来很......漏洞百出吗?

让我们来解决这个问题
我不喜欢在提出问题的同时却不提及可能的解决方案。 我在上文提到了文档;如果能有某种指示器显示函数已阻塞,那就更好了。

/// Does a thing.
/// 
/// Blocking
/// 
/// This function will block the first time it is called, as it is reading from
/// /dev/random to seed the random number generator.
fn my_blocking_function(data: &mut Foo) {
/* ... */ }

这将是一项艰巨的工作,而且我不认为这是一种可持续的方法。 如果你正在编写一个更高级的库,要检查你的依赖库的依赖库是否从套接字读取数据,这将是一个很大的要求。 从语言的角度来看,如果有某种 #[blocking] 属性来表示函数阻塞了,那就更好了,就像这样:

#[blocking]
fn my_blocking_function(data: &mut Foo) { /* ... */ }


或许还可以通过某种树状遍历来查看您是否从异步代码中调用了 #[阻塞] 函数,然后发出警告。 遗憾的是,我也不确定这是否可行。 有些函数可能会阻塞一次后就不再阻塞,或者只有在 Rust 编译器无法预测的特定情况下才会阻塞。 所以,我也不知道。 语言设计团队里有一些聪明人,也许他们有更好的主意。

总之

  • 异步代码根本不存在泄漏问题,它的泄漏主要是由于库的问题造成的。
  • 与此同时,阻塞代码本质才是泄漏的。

希望这篇文章对你有所帮助,并能消除你对将来使用异步代码的一些疑虑。