异步Rust——定制 Tokio

598 阅读23分钟

在本书中,我们使用 Tokio 作为示例,不仅因为它已经被广泛使用,而且它也具有简洁的语法,通过一个宏就能让你快速运行异步示例。很可能,如果你曾在异步 Rust 代码库中工作过,你会遇到过 Tokio。然而,迄今为止,我们只使用这个 crate 来构建标准的 Tokio 运行时,并将异步任务发送到该运行时中。在这一章中,我们将定制我们的 Tokio 运行时,以便能够精细控制任务在多个线程上的处理方式。我们还将测试在异步运行时中对线程状态的非安全访问是否真的安全。最后,我们将介绍如何在异步运行时完成后启用优雅的关闭操作。

在本章结束时,你将能够配置一个 Tokio 运行时来解决你的特定问题。你还将能够指定异步任务在哪个线程上独占处理,这样你的任务可以依赖于线程特定的状态,从而可能减少使用锁来访问数据的需求。最后,你将能够指定程序在收到 Ctrl-C 或 kill 信号时如何关闭。因此,让我们开始构建 Tokio 运行时。

跳过本章不会影响你对本书其他内容的理解,因为本章的内容主要讲解如何根据需要使用 Tokio。这一章并未引入新的异步理论。

构建一个运行时

在第3章中,我们通过实现自己的任务生成函数展示了任务是如何在异步运行时中处理的。这让我们对任务的处理方式有了更多控制。我们之前的 Tokio 示例仅仅使用了 #[tokio::main] 宏。虽然这个宏对于实现简单的异步示例非常有用,但仅仅实现 #[tokio::main] 并不能提供对异步运行时实现方式的太多控制。为了探索 Tokio,我们可以从设置一个我们可以选择调用的 Tokio 运行时开始。为了配置我们的运行时,我们需要以下依赖项:

tokio = { version = "1.33.0", features = ["full"] }

我们还需要以下结构体和特性:

use std::future::Future;
use std::time::Duration;
use tokio::runtime::{Builder, Runtime};
use tokio::task::JoinHandle;
use std::sync::LazyLock;

为了构建我们的运行时,我们可以依赖 LazyLock 进行懒加载,这样我们的运行时只会被定义一次,就像我们在第3章构建运行时时所做的那样:

static RUNTIME: LazyLock<Runtime> = LazyLock::new(|| {
    Builder::new_multi_thread()
        .worker_threads(4) // 工作线程数
        .max_blocking_threads(1) // 阻塞任务最大线程数
        .on_thread_start(|| {
            println!("thread starting for runtime A");
        })
        .on_thread_stop(|| {
            println!("thread stopping for runtime A");
        })
        .thread_keep_alive(Duration::from_secs(60)) // 阻塞线程保持活动的超时
        .global_queue_interval(61) // 全局队列间隔
        .on_thread_park(|| {
            println!("thread parking for runtime A");
        })
        .thread_name("our custom runtime A") // 线程名称
        .thread_stack_size(3 * 1024 * 1024) // 线程栈大小
        .enable_time() // 启用时间驱动
        .build()
        .unwrap()
});

我们从中获得了很多配置选项,包括以下属性:

  • worker_threads: 处理异步任务的线程数。
  • max_blocking_threads: 分配给阻塞任务的线程数。阻塞任务不允许切换,因为它没有 await 或者需要较长的 CPU 计算时间。通常 CPU 密集型任务或同步任务被称为阻塞任务。使用此参数可以限制可用于阻塞任务的线程数。
  • on_thread_start/stop: 当工作线程启动或停止时触发的函数。如果你想构建自己的监控工具,这些函数很有用。
  • thread_keep_alive: 阻塞线程的超时设置。超过超时限制的阻塞任务将被取消。
  • global_queue_interval: 新任务获取调度器关注的时间间隔。一个 tick 表示调度器轮询任务,看看它是否可以运行或需要等待。此参数控制调度器多久检查一次任务队列。
  • on_thread_park: 当工作线程进入停车状态时触发的函数。工作线程通常在没有任务可处理时进入停车状态。
  • thread_name: 设置运行时创建的线程名称。
  • thread_stack_size: 为每个工作线程分配的栈内存大小。
  • enable_time: 启用 Tokio 的时间驱动。

现在我们已经构建并配置了运行时,我们可以定义如何调用它:

pub fn spawn_task<F, T>(future: F) -> JoinHandle<T>
where
    F: Future<Output = T> + Send + 'static,
    T: Send + 'static,
{
    RUNTIME.spawn(future)
}

我们不必非得使用这个函数,因为我们可以直接调用运行时。但值得注意的是,函数签名基本上与第3章中的 spawn_task 函数相同。唯一的区别是,我们返回的是一个 Tokio 的 JoinHandle,而不是 Task

现在我们知道如何调用我们的运行时,我们可以定义一个基本的 future

async fn sleep_example() -> i32 {
    println!("sleeping for 2 seconds");
    tokio::time::sleep(Duration::from_secs(2)).await;
    println!("done sleeping");
    20
}

然后我们运行我们的程序:

fn main() {
    let handle = spawn_task(sleep_example());
    println!("spawned task");
    println!("task status: {}", handle.is_finished());
    std::thread::sleep(Duration::from_secs(3));
    println!("task status: {}", handle.is_finished());
    let result = RUNTIME.block_on(handle).unwrap();
    println!("task result: {}", result);
}

我们生成任务,并使用我们运行时的 block_on 函数等待任务完成。我们还会定期检查任务是否完成。运行这段代码,我们将得到以下输出:

thread starting for runtime A
thread starting for runtime A
sleeping for 2 seconds
thread starting for runtime A
thread parking for runtime A
thread parking for runtime A
spawned task
thread parking for runtime A
task status: false
thread starting for runtime A
thread parking for runtime A
done sleeping
thread parking for runtime A
task status: true
task result: 20

虽然这个输出比较长,但我们可以看到,运行时开始创建工作线程,并在所有工作线程创建完成之前就启动了我们的异步任务。因为我们只发送了一个异步任务,所以我们也能看到空闲的工作线程被停放。当我们得到任务的结果时,所有的工作线程都已经停放。可以看到,Tokio 在停放线程方面相当积极。这是有用的,因为如果我们创建了多个运行时,但不是一直使用其中一个,那个未使用的运行时会迅速停放其线程,从而减少资源的使用。

现在我们已经介绍了如何构建和定制 Tokio 运行时,我们可以重新创建第3章中构建的运行时:

static HIGH_PRIORITY: LazyLock<Runtime> = LazyLock::new(|| {
    Builder::new_multi_thread()
        .worker_threads(2)
        .thread_name("High Priority Runtime")
        .enable_time()
        .build()
        .unwrap()
});

static LOW_PRIORITY: LazyLock<Runtime> = LazyLock::new(|| {
    Builder::new_multi_thread()
        .worker_threads(1)
        .thread_name("Low Priority Runtime")
        .enable_time()
        .build()
        .unwrap()
});

这给我们带来了图7-1所示的布局。

image.png

我们在第3章中构建的具有两个队列和任务偷取的运行时,与我们在这里构建的两个 Tokio 运行时之间的唯一区别,是高优先级运行时的线程不会从低优先级运行时中偷取任务。此外,高优先级运行时有两个队列。这些差异并不是很明显,因为线程在同一运行时中偷取任务,因此只要我们不在乎任务处理的精确顺序,它们实际上就相当于只有一个队列。

我们还必须承认,当没有异步任务需要处理时,线程会被停放。如果我们的线程数超过了 CPU 核心数,操作系统会在这些线程之间管理资源分配和上下文切换。仅仅添加更多线程而超过核心数,并不会导致线性速度提升。然而,如果我们为高优先级运行时分配三个线程,为低优先级运行时分配两个线程,我们仍然可以有效地分配资源。如果低优先级运行时没有任务需要处理,那两个线程将会被停放,而高优先级运行时的三个线程将会获得更多的 CPU 配额。

现在我们已经定义了我们的线程和运行时,我们需要以不同的方式与这些线程进行交互。通过使用本地池(local pools),我们可以更好地控制任务的流动。

使用本地池处理任务

通过本地池(local pools),我们可以更好地控制处理异步任务的线程。在我们探讨本地池之前,需要包含以下依赖项:

tokio-util = { version = "0.7.10", features = ["full"] }

我们还需要以下导入:

use tokio_util::task::LocalPoolHandle;
use std::cell::RefCell;

使用本地池时,我们将异步任务与特定的池绑定。这意味着我们可以使用没有实现 Send 特性的结构体,因为我们确保任务始终在特定线程上运行。然而,由于我们确保异步任务在特定线程上运行,我们无法利用任务偷取机制,也无法像标准的 Tokio 运行时那样直接获得性能。

为了查看异步任务如何通过本地池映射处理,我们首先需要定义一些本地线程数据:

thread_local! {
    pub static COUNTER: RefCell<u32> = RefCell::new(1);
}

每个线程将访问它自己的 COUNTER 变量。接着,我们定义一个简单的异步任务,该任务会让线程阻塞一秒,增加它所在线程的 COUNTER 值,然后打印出 COUNTER 和数字:

async fn something(number: u32) -> u32 {
    std::thread::sleep(std::time::Duration::from_secs(3));
    COUNTER.with(|counter| {
        *counter.borrow_mut() += 1;
        println!("Counter: {} for: {}", *counter.borrow(), number);
    });
    number
}

通过这个任务,我们可以看到本地池的配置如何处理多个任务。

在我们的 main 函数中,我们仍然需要一个 Tokio 运行时,因为我们依然需要等待已生成的任务:

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let pool = LocalPoolHandle::new(1);
    . . .
}

我们的 Tokio 运行时使用的是 current_thread 风味。当前版本提供两种风味:CurrentThreadMultiThreadMultiThread 选项允许任务跨多个线程执行,而 CurrentThread 则在当前线程上执行所有任务。另一个风味,MultiThreadAlt,也声称能在多个线程上执行任务,但它是不稳定的。因此,我们实现的运行时将在当前线程上执行所有任务,而本地池只有一个线程。

现在我们已经定义了我们的池,我们可以使用它来生成任务:

let one = pool.spawn_pinned(|| async {
    println!("one");
    something(1).await
});
let two = pool.spawn_pinned(|| async {
    println!("two");
    something(2).await
});
let three = pool.spawn_pinned(|| async {
    println!("three");
    something(3).await
});

现在我们有了三个句柄,可以等待这些句柄并返回这些任务的结果总和:

let result = async {
    let one = one.await.unwrap();
    let two = two.await.unwrap();
    let three = three.await.unwrap();
    one + two + three
};
println!("result: {}", result.await);

运行代码,我们会看到以下输出:

one
Counter: 2 for: 1
two
Counter: 3 for: 2
three
Counter: 4 for: 3
result: 6

我们的任务是按顺序处理的,最高的 COUNTER 值为 4,意味着所有任务都在一个线程中处理。现在,如果我们将本地池的大小增加到 3,我们将得到以下输出:

one
three
two
Counter: 2 for: 1
Counter: 2 for: 3
Counter: 2 for: 2
result: 6

所有三个任务在生成后立即开始处理。我们还可以看到每个任务的 COUNTER 值为 2。这意味着我们的三个任务被分配到三个线程上处理。

我们还可以专注于特定的线程。例如,我们可以将任务分配到索引为 0 的线程:

let one = pool.spawn_pinned_by_idx(|| async {
    println!("one");
    something(1).await
}, 0);

如果我们将所有任务都分配到索引为 0 的线程,我们会得到如下输出:

one
Counter: 2 for: 1
two
Counter: 3 for: 2
three
Counter: 4 for: 3
result: 6

即使我们池中有三个线程,我们的输出与单线程池相同。如果我们将标准的 sleep 替换为 Tokio 的 sleep,我们会看到以下输出:

one
two
three
Counter: 2 for: 1
Counter: 3 for: 2
Counter: 4 for: 3
result: 6

由于 Tokio 的 sleep 是异步的,我们的单个线程可以同时处理多个异步任务,但 COUNTER 的访问是在睡眠之后进行的。我们可以看到,COUNTER 的值是 4,这意味着虽然我们的线程同时处理了多个异步任务,但这些任务从未跨越到其他线程。

通过本地池,我们可以精细控制将任务发送到哪个线程进行处理。尽管我们牺牲了任务偷取机制,但我们可能希望使用本地池来获得以下优势:

  • 处理非 Send 的 future:如果 future 无法在线程间传递,我们可以使用本地线程池进行处理。
  • 线程亲和性:通过确保任务在特定线程上执行,我们可以利用该线程的状态。一个简单的例子是缓存。如果我们需要计算或从服务器等其他资源获取值,我们可以将其缓存在线程中。然后,该线程中的所有任务都可以访问该值,因此发送到该线程的任务无需重新获取或计算该值。
  • 线程本地操作的性能:可以通过互斥锁和原子引用计数器在线程间共享数据。然而,线程同步会带来一些开销。例如,当多个线程都在获取锁时,这并不是免费的。如图7-2所示,如果我们有一个标准的 Tokio 异步运行时,并且 counterArc<Mutex<T>>,那么每次只有一个线程能够访问 counter

image.png

其他三个线程将不得不等待才能访问 Arc<Mutex<T>>。将计数器的状态保持在每个线程本地,能够避免线程等待互斥锁,从而加速处理过程。然而,每个线程中的本地计数器并不能提供完整的全局视图。这些计数器无法知道其他线程中计数器的状态。获取计数器的完整状态的一种方法是,发送一个异步任务,将计数器获取到每个线程,最后将每个线程的结果进行合并。我们将在“优雅的关闭”中介绍这种方法。在处理 CPU 密集型任务时,线程内本地数据的访问也有助于优化 CPU 缓存的数据。

安全访问非 Send 资源

有时,数据资源可能不是线程安全的。将该资源保留在一个线程中,并将任务发送到该线程进行处理,是规避这个问题的一种方式。

警告:
我们在本书中已经提到过,阻塞任务可能会阻塞线程。然而,必须强调的是,阻塞对我们本地池的影响可能更为显著,因为我们没有任务偷取机制。使用 Tokio 的 spawn_blocking 函数可以防止这种情况。

到目前为止,我们已经能够通过使用 RefCell 在异步任务中访问线程的状态。RefCell 允许我们在运行时通过 Rust 检查借用规则来访问数据。然而,在借用 RefCell 中的数据时,这种检查会带来一些开销。我们可以移除这些检查,并通过使用不安全代码仍然安全地访问数据,接下来我们将在下一节中探索这一点。

使用线程数据进行不安全操作

为了移除对线程数据可变借用的运行时检查,我们需要将数据包装在 UnsafeCell 中。这意味着我们直接访问线程数据,而不进行任何检查。然而,我知道你可能在想,如果我们使用 UnsafeCell,那是不是很危险?潜在的确是的,所以我们必须小心,确保我们是安全的。

如果我们考虑我们的系统,实际上只有一个线程在处理异步任务,这些任务不会转移到其他线程。我们必须记住,尽管这个单线程可以通过轮询同时处理多个异步任务,但它每次只能主动处理一个异步任务。因此,我们可以假设,当我们的一个异步任务正在访问并处理 UnsafeCell 中的数据时,其他异步任务不会访问该数据,因为 UnsafeCell 本身不是异步的。然而,我们需要确保在引用数据的作用域内没有 await,否则我们的线程可能会在任务仍持有数据引用的情况下切换到另一个任务。

我们可以通过将一个 HashMap 暴露给数千个异步任务并在这些任务中增加键的值来测试这一点。为了运行此测试,我们需要以下导入:

use tokio_util::task::LocalPoolHandle;
use std::time::Instant;
use std::cell::UnsafeCell;
use std::collections::HashMap;

然后我们定义线程状态:

use std::cell::UnsafeCell;
use std::collections::HashMap;

thread_local! {
    pub static COUNTER: UnsafeCell<HashMap<u32, u32>> = UnsafeCell::new(HashMap::new());
}

接下来,我们定义一个异步任务,通过不安全代码来访问和更新线程数据:

async fn something(number: u32) {
    tokio::time::sleep(std::time::Duration::from_secs(number as u64)).await;
    COUNTER.with(|counter| {
        let counter = unsafe { &mut *counter.get() };
        match counter.get_mut(&number) {
            Some(count) => {
                let placeholder = *count + 1;
                *count = placeholder;
            },
            None => {
                counter.insert(number, 1);
            }
        }
    });
}

我们在任务中加入了 Tokiosleep,通过数字调整异步任务的处理顺序。然后我们获取数据的可变引用并执行操作。注意 COUNTER.with 块,它用于访问数据。这个块不是异步块,这意味着我们在访问数据时不能使用 await 操作。我们在访问不安全的数据时,不能进行上下文切换。我们在 COUNTER.with 块中使用不安全代码直接访问数据并增加计数。

完成测试后,我们需要打印线程状态。为了实现这一点,我们需要将一个异步任务传递给线程来执行打印操作,代码如下:

async fn print_statement() {
    COUNTER.with(|counter| {
        let counter = unsafe { &mut *counter.get() };
        println!("Counter: {:?}", counter);
    });
}

现在我们有了一切,只需要在我们的主异步函数中运行代码。首先,我们设置本地线程池,这里使用的是一个线程,并且有 100,000 个从 1 到 5 的序列:

let pool = LocalPoolHandle::new(1);
let sequence = [1, 2, 3, 4, 5];
let repeated_sequence: Vec<_> = sequence.iter()
					                    .cycle()
					                    .take(5000)
					                    .cloned()
					                    .collect();

这将生成 50 万个异步任务,带有不同的 Tokio sleep 时长,我们将在这个单线程中处理这些任务。接着,我们遍历这些数字,启动任务,并调用我们的异步函数两次,以便任务在执行时让线程在每个函数之间和每个函数内部进行上下文切换:

let mut futures = Vec::new();
for number in repeated_sequence {
    futures.push(pool.spawn_pinned(move || async move {
        something(number).await;
        something(number).await
    }));
}

我们非常鼓励线程在处理任务时进行多次上下文切换。由于任务数量庞大且 sleep 时长各异,这将导致计数中出现不一致的结果,如果我们在访问数据时发生了冲突。最后,我们循环遍历句柄,等待它们完成,并打印计数:

for i in futures {
    let _ = i.await.unwrap();
}

let _ = pool.spawn_pinned(|| async {
    print_statement().await
}).await.unwrap();

最终的结果应该是如下所示:

Counter: {2: 200000, 4: 200000, 1: 200000, 3: 200000, 5: 200000}

无论我们运行多少次,计数总是相同的。在这里,我们没有进行诸如比较和交换等原子操作,也没有多次尝试以处理不一致的情况。我们也不需要在获取数据的可变引用之前检查是否有其他可变引用。我们在这个上下文中的不安全代码是安全的。

我们现在可以使用线程的状态来影响我们的异步任务。然而,如果我们的系统关闭会发生什么呢?我们可能希望有一个清理过程,以便在我们重新启动运行时时可以重新创建我们的状态。这就是优雅关闭的用武之地。

优雅的关闭

在优雅关闭中,我们捕捉程序正在关闭的时机,以便在程序退出之前执行一系列过程。这些过程可以是向其他程序发送信号、存储状态、清理事务,或是任何在程序退出之前你想做的事情。

我们首先探讨的一个话题是 Ctrl-C 信号。通常,当我们通过终端运行一个 Rust 程序时,可以通过按下 Ctrl-C 来停止程序,促使程序退出。然而,我们可以使用 tokio::signal 模块来覆盖这个预先定义的退出行为。为了真正证明我们已经覆盖了 Ctrl-C 信号,我们可以构建一个简单的程序,要求程序在退出之前必须接受三次 Ctrl-C 信号。我们可以通过构建以下背景异步任务来实现这一点:

async fn cleanup() {
    println!("cleanup background task started");
    let mut count = 0;
    loop {
        tokio::signal::ctrl_c().await.unwrap();
        println!("ctrl-c received!");
        count += 1;
        if count > 2 {
            std::process::exit(0);
        }
    }
}

接下来,我们可以运行我们的背景任务,并使用以下主函数使程序无限循环:

#[tokio::main]
async fn main() {
    tokio::spawn(cleanup());
    loop {
    }
}

运行我们的程序时,如果我们按下 Ctrl-C 三次,我们将看到以下输出:

cleanup background task started
^Cctrl-c received!
^Cctrl-c received!
^Cctrl-c received!

我们的程序直到接收到三次信号才退出。现在,我们可以在自己设定的条件下退出程序。然而,在继续之前,让我们在背景任务的循环中添加一个阻塞的睡眠,在等待 Ctrl-C 信号之前,修改后的循环如下:

loop {
    std::thread::sleep(std::time::Duration::from_secs(5));
    tokio::signal::ctrl_c().await.unwrap();
    . . .
}

如果我们再次运行程序,并在 5 秒未结束前按下 Ctrl-C,程序将退出。通过这种方式,我们可以推测,程序只有在直接等待信号时,才会按我们预期处理 Ctrl-C 信号。为了绕过这个限制,我们可以启动一个线程来管理异步运行时。然后使用主线程的其余部分来监听我们的信号:

#[tokio::main(flavor = "current_thread")]
async fn main() {
    std::thread::spawn(|| {
        let runtime = tokio::runtime::Builder::new_multi_thread()
            .enable_all()
            .build()
            .unwrap();

        runtime.block_on(async {
            println!("Hello, world!");
        });
    });
    let mut count = 0;
    loop {
        tokio::signal::ctrl_c().await.unwrap();
        println!("ctrl-c received!");
        count += 1;
        if count > 2 {
            std::process::exit(0);
        }
    }
}

现在,无论我们的异步运行时在处理什么,主线程都能准备好处理我们的 Ctrl-C 信号,但我们的状态又该如何处理呢?在清理过程中,我们可以提取当前的状态,然后将其写入文件,以便在程序再次启动时加载这些状态。写入和读取文件是很简单的,所以我们将重点关注从我们在上一节中创建的所有独立线程中提取状态。与上一节的主要区别在于,我们将把任务分发到四个独立的线程上。首先,我们可以将本地线程池包装在懒加载中:

static RUNTIME: LazyLock<LocalPoolHandle> = LazyLock::new(|| {
    LocalPoolHandle::new(4)
});

我们需要定义一个异步任务,从线程中提取状态:

fn extract_data_from_thread() -> HashMap<u32, u32> {
    let mut extracted_counter: HashMap<u32, u32> = HashMap::new();
    COUNTER.with(|counter| {
        let counter = unsafe { &mut *counter.get() };
        extracted_counter = counter.clone();
    });
    return extracted_counter
}

我们可以将这个任务发送到每个线程,这样就可以以非阻塞的方式汇总整个系统的计数总数(见图 7-3)。

image.png

我们可以通过以下代码实现图7-3中所描述的过程:

async fn get_complete_count() -> HashMap<u32, u32> {
    let mut complete_counter = HashMap::new();
    let mut extracted_counters = Vec::new();
    for i in 0..4 {
        extracted_counters.push(RUNTIME.spawn_pinned_by_idx(||
            async move {
                extract_data_from_thread()
        }, i));
    }
    for counter_future in extracted_counters {
        let extracted_counter = counter_future.await
                                  .unwrap_or_default();
        for (key, count) in extracted_counter {
            *complete_counter.entry(key).or_insert(0) += count;
        }
    }
    return complete_counter
}

我们调用 spawn_pinned_by_idx 来确保我们仅将一个 extract_data_from_thread 任务发送到每个线程。

现在,我们已经准备好运行系统,使用以下主函数:

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let _handle = tokio::spawn( async {
        . . .
    });
    tokio::signal::ctrl_c().await.unwrap();
    println!("ctrl-c received!");
    let complete_counter = get_complete_count().await;
    println!("Complete counter: {:?}", complete_counter);
}

我们通过 tokio::spawn 启动任务来增加计数:

let sequence = [1, 2, 3, 4, 5];
let repeated_sequence: Vec<_> = sequence.iter().cycle()
                                               .take(500000)
                                               .cloned()
                                               .collect();
let mut futures = Vec::new();
for number in repeated_sequence {
    futures.push(RUNTIME.spawn_pinned(move || async move {
        something(number).await;
        something(number).await
    }));
}
for i in futures {
    let _ = i.await.unwrap();
}
println!("All futures completed");

现在我们的系统准备就绪。如果我们运行程序,直到看到所有任务都完成,再按下 Ctrl-C,我们会看到如下输出:

Complete counter: {1: 200000, 4: 200000, 2: 200000, 5: 200000, 3: 200000}

因为我们知道使用 spawn_pinned_by_idx 函数将每个提取任务只发送到一个线程,并且我们的总计数与所有任务在单线程中运行时的总计数相同,所以我们可以得出结论:数据提取是准确的。如果我们在任务完成之前按下 Ctrl-C,我们应该会看到类似以下的输出:

Complete counter: {2: 100000, 3: 32290, 1: 200000}

我们在程序完成之前退出,并且得到了当前的状态。如果需要,我们可以在退出之前写出当前状态。

虽然我们的代码在按下 Ctrl-C 时可以实现清理,但这种信号并不总是关闭系统的最实用方法。例如,我们可能有一个在后台运行的异步系统,这样我们的终端就不会被程序所绑定。我们可以通过向系统发送 SIGHUP 信号来关闭程序。为了监听 SIGHUP 信号,我们需要以下导入:

use tokio::signal::unix::{signal, SignalKind};

然后,我们可以用以下代码替换主函数底部的 Ctrl-C 代码:

let pid = std::process::id();
println!("The PID of this process is: {}", pid);
let mut stream = signal(SignalKind::hangup()).unwrap();
stream.recv().await;
let complete_counter = get_complete_count().await;
println!("Complete counter: {:?}", complete_counter);

我们打印出进程的 PID,这样就知道发送信号时应该使用哪个 PID,命令如下:

kill -SIGHUP <pid>

运行 kill 命令时,你应该会得到与按 Ctrl-C 时类似的结果。现在,我们可以说,你已经知道如何定制 Tokio,配置运行时、运行任务以及在适当时优雅地关闭运行时。

总结

在本章中,我们详细讨论了如何设置 Tokio 运行时以及它的配置如何影响运行时的操作。通过这些设置,我们真正掌控了运行时的工作线程数量、阻塞线程数量,以及在接受新任务进行轮询之前执行的 ticks 数量。我们还探索了在同一个程序中定义不同的运行时,以便选择将任务发送到哪个运行时。请记住,当 Tokio 运行时的线程未被使用时,它们会被停放,因此如果一个运行时不被持续使用,我们不会浪费资源。

接着,我们通过本地池控制了任务由线程如何处理。我们甚至测试了在 Tokio 运行时中对线程状态的非安全访问,证明在任务中访问线程状态是安全的。最后,我们讨论了优雅的关闭。尽管我们不需要编写自己的模板代码,Tokio 仍然提供了高度灵活的运行时配置能力。

我们确信,在你的异步 Rust 开发生涯中,你将会遇到一个使用 Tokio 的代码库。现在,你应该能够舒适地定制 Tokio 运行时,并管理异步任务的处理方式。在第8章中,我们将实现演员模型(actor model),以模块化的方式解决异步问题。