我们现在已经来到了本书的倒数第二章。因此,我们需要专注于理解异步在系统中的交互方式。为此,我们将完全使用标准库构建一个异步服务器,不依赖任何第三方库。这将巩固你对异步编程基础的理解,以及它如何融入软件系统的整体架构。随着时间的推移,包、编程语言和 API 文档都会发生变化。虽然理解当前的异步工具很重要,我们在全书中都涵盖了这些内容,但理解异步编程的基础将使你能够轻松阅读新出现的文档、工具、框架和语言。
在本章结束时,你将能够构建一个多线程的 TCP 服务器,接受传入的请求,并将这些请求发送到异步执行器进行异步处理。因为我们只使用标准库,你还将能够构建自己的异步执行器,接受任务并持续轮询直到完成。最后,你还将能够实现这个异步功能到一个客户端,客户端向服务器发送请求。这将使你能够自信地构建基本的异步解决方案,使用最少的依赖来解决轻量级问题。现在,让我们开始设置这个项目的基础。
设置基础
对于我们的项目,我们将使用四个工作区,在根目录的 Cargo.toml 中定义如下:
[workspace]
members = [
"client",
"server",
"data_layer",
"async_runtime"
]
我们之所以有四个工作区,是因为这些模块有一定程度的交叉使用。客户端和服务器将分开,以便分别调用。服务器和客户端都将使用我们的异步运行时,因此它们需要分开。data_layer 只是一个消息结构体,用于序列化和反序列化自身。由于客户端和服务器都会引用数据结构,因此它需要放在一个单独的工作区。我们可以在 data_layer/src/data.rs 中编写我们的模板代码,代码如下:
use std::io::{self, Cursor, Read, Write};
#[derive(Debug)]
pub struct Data {
pub field1: u32,
pub field2: u16,
pub field3: String,
}
impl Data {
pub fn serialize(&self) -> io::Result<Vec<u8>> {
// 序列化逻辑
}
pub fn deserialize(cursor: &mut Cursor<&[u8]>) -> io::Result<Data> {
// 反序列化逻辑
}
}
serialize 和 deserialize 函数使我们能够通过 TCP 连接发送 Data 结构体。如果我们需要更复杂的结构体,可以使用 serde,但本章的核心目的是编写自己的序列化逻辑,因为我们整个应用都不依赖外部库。请放心,这是我们唯一需要编写的非异步模板代码。serialize 函数如下所示:
let mut bytes = Vec::new();
bytes.write(&self.field1.to_ne_bytes())?;
bytes.write(&self.field2.to_ne_bytes())?;
let field3_len = self.field3.len() as u32;
bytes.write(&field3_len.to_ne_bytes())?;
bytes.extend_from_slice(self.field3.as_bytes());
Ok(bytes)
我们为每个数字使用 4 个字节,另加一个 4 字节的整数来指示字符串的长度,因为字符串的长度可能会变化。
对于我们的 deserialize 函数,我们传入具有正确容量的数组和向量,将字节数组读取并转换为适当的格式:
// 初始化字段的缓冲区,使用适当大小的数组
let mut field1_bytes = [0u8; 4];
let mut field2_bytes = [0u8; 2];
// 从游标中读取前两个字段(4 字节和 2 字节)
cursor.read_exact(&mut field1_bytes)?;
cursor.read_exact(&mut field2_bytes)?;
// 将字节数组转换为适当的数据类型(u32 和 u16)
let field1 = u32::from_ne_bytes(field1_bytes);
let field2 = u16::from_ne_bytes(field2_bytes);
// 初始化缓冲区以读取第三个字段的长度(4 字节长)
let mut len_bytes = [0u8; 4];
cursor.read_exact(&mut len_bytes)?;
// 将长度字节转换为 usize
let len = u32::from_ne_bytes(len_bytes) as usize;
// 初始化一个具有指定长度的缓冲区来保存第三个字段的数据
let mut field3_bytes = vec![0u8; len];
cursor.read_exact(&mut field3_bytes)?;
// 将第三个字段的字节转换为 UTF-8 字符串,或
// 如果无法转换,则返回错误
let field3 = String::from_utf8(field3_bytes)
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))?;
// 返回结构化的数据
Ok(Data { field1, field2, field3 })
完成数据层逻辑后,我们在 data_layer/src/lib.rs 文件中将 Data 结构体设为公共:
pub mod data;
数据层完成后,我们可以继续进行更有趣的部分:从标准库开始构建我们的异步运行时。
构建我们的标准异步运行时
为了构建我们服务器的异步组件,我们需要按以下顺序准备以下组件:
- Waker:唤醒未来以便重新恢复执行
- Executor:处理未来直到完成
- Sender:异步未来,允许异步发送数据
- Receiver:异步未来,允许异步接收数据
- Sleeper:异步未来,允许任务异步休眠
鉴于我们需要 waker 来帮助 executor 重新唤醒任务以便再次轮询,因此我们将从构建 waker 开始。
构建我们的 Waker
在本书中,我们在实现 Future trait 时多次使用了 waker,并且我们直观地知道 wake 或 wake_by_ref 函数是必须的,以允许未来任务再次被轮询。因此,waker 是我们的显而易见的第一步,因为 futures 和 executor 将会处理 waker。为了构建我们的 waker,我们从在 async_runtime/src/waker.rs 文件中进行以下导入开始:
use std::task::{RawWaker, RawWakerVTable};
接着,我们构建我们的 waker 表:
static VTABLE: RawWakerVTable = RawWakerVTable::new(
my_clone,
my_wake,
my_wake_by_ref,
my_drop,
);
我们可以随意命名这些函数,只要它们具有正确的函数签名。RawWakerVTable 是一个虚拟函数指针表,RawWaker 指向它并通过它来执行生命周期中的操作。例如,当对 RawWaker 调用 clone 函数时,将调用 RawWakerVTable 中的 my_clone 函数。我们将尽可能保持这些函数实现简单,但可以看到如何利用 RawWakerVTable。例如,具有静态生命周期并且是线程安全的数据结构,可以通过与 RawWakerVTable 中的函数交互来跟踪我们系统中的 wakers。
我们从 clone 函数开始。这个函数通常会在轮询函数时被调用,因为我们需要克隆 waker 的原子引用,并在 executor 中将其包装到上下文中,然后传递给正在被轮询的未来任务。我们的 clone 实现如下:
unsafe fn my_clone(raw_waker: *const ()) -> RawWaker {
RawWaker::new(raw_waker, &VTABLE)
}
wake 和 wake_by_ref 函数在未来任务应该重新被轮询时被调用,因为等待中的未来已经准备好。在我们的项目中,我们将通过轮询未来而不依赖 waker 来检查它们是否已准备好,因此我们可以定义一个简单的实现:
unsafe fn my_wake(raw_waker: *const ()) {
drop(Box::from_raw(raw_waker as *mut u32));
}
unsafe fn my_wake_by_ref(_raw_waker: *const ()) {
}
my_wake 函数将原始指针转换回一个 Box,并将其丢弃。这是合理的,因为 my_wake 应该消费 waker。my_wake_by_ref 函数不做任何事情。它与 my_wake 函数相同,但不消费 waker。如果我们想要尝试通知 executor,可以在这些函数中设置一个 AtomicBool 为 true。然后我们可以设计某种形式的 executor 机制,在轮询未来之前检查 AtomicBool,因为检查 AtomicBool 比轮询未来的计算开销要小。我们还可以有另一个队列,用于发送任务准备就绪的通知,但对于我们的服务器实现,我们将坚持在轮询之前不做检查。
当我们的任务完成或被取消时,我们不再需要轮询任务,此时 waker 会被丢弃。我们的 drop 函数如下:
unsafe fn my_drop(raw_waker: *const ()) {
drop(Box::from_raw(raw_waker as *mut u32));
}
这里,我们将 Box 转换回原始指针并丢弃它。
创建我们的 Waker
我们现在已经定义了所有的 waker 函数。接下来,我们只需要一个函数来创建 waker:
pub fn create_raw_waker() -> RawWaker {
let data = Box::into_raw(Box::new(42u32));
RawWaker::new(data as *const (), &VTABLE)
}
我们传入一些虚拟数据,并使用对函数表的引用来创建 RawWaker。我们可以看到通过这种原始方法获得的定制性。例如,我们可以有多个 RawWakerVTable 定义,并且可以根据传入的参数构建不同的函数表。在我们的 executor 中,我们可以根据我们正在处理的未来类型来改变输入。我们还可以传入 executor 正在持有的数据结构的引用,而不是简单的 u32 数字 42。数字 42 并没有特别的意义,它只是作为一个示例来展示数据的传递。
尽管我们构建了一个基本的 waker,我们仍然可以欣赏到自己构建 waker 所带来的强大定制性。考虑到我们需要使用 waker 来执行 executor 中的任务,我们现在可以继续构建我们的 executor。
构建我们的 Executor
从高层次来看,我们的 executor 将会消费 futures,将它们转化为任务,以便由我们的 executor 执行,返回一个句柄,并将任务放入队列中。定期地,我们的 executor 还会轮询队列中的任务。我们将把 executor 放在 async_runtime/src/executor.rs 文件中。首先,我们需要以下导入:
use std::{
future::Future,
sync::{Arc, mpsc},
task::{Context, Poll, Waker},
pin::Pin,
collections::VecDeque
};
use crate::waker::create_raw_waker;
在开始编写我们的 executor 之前,我们需要定义一个 task 结构体,这个结构体将会在 executor 中传递:
pub struct Task {
future: Pin<Box<dyn Future<Output = ()> + Send>>,
waker: Arc<Waker>,
}
当查看 Task 结构体时,可能会觉得有些不对劲,你是对的。我们在 Task 结构体中的 future 返回的是 (),但是我们希望能够运行返回不同数据类型的任务。如果我们的运行时只能返回一个数据类型,那将非常糟糕。你可能会想需要传入一个泛型参数,导致如下代码:
pub struct Task<T> {
future: Pin<Box<dyn Future<Output = T> + Send>>,
waker: Arc<Waker>,
}
然而,使用泛型时,编译器会查看 Task<T> 的所有实例,并为每个 T 的变化生成结构体。此外,我们的 executor 需要 T 泛型参数来处理 Task<T>。这将导致为每个 T 的变化生成多个 executor,这将变得很混乱。相反,我们将 future 包装在一个异步块中,获取 future 的结果,并通过通道发送该结果。因此,我们的所有任务返回 (),但我们仍然可以从 future 中提取结果。我们将在 executor 的 spawn 函数中看到这一实现。以下是我们的 executor 的框架:
pub struct Executor {
pub polling: VecDeque<Task>,
}
impl Executor {
pub fn new() -> Self {
Executor {
polling: VecDeque::new(),
}
}
pub fn spawn<F, T>(&mut self, future: F) -> mpsc::Receiver<T>
where
F: Future<Output = T> + 'static + Send,
T: Send + 'static,
{
. . .
}
pub fn poll(&mut self) {
. . .
}
pub fn create_waker(&self) -> Arc<Waker> {
Arc::new(unsafe { Waker::from_raw(create_raw_waker()) })
}
}
Executor 的 polling 字段是我们放置已生成任务并等待轮询的地方。
注意
请注意我们在 Executor 中的 create_waker 函数。记住,我们的 Executor 只在一个线程上运行,每次只能处理一个 future。如果我们的 Executor 包含一个数据集合,我们可以通过 create_raw_waker 函数将一个引用传递进去,只要我们已经配置了 create_raw_waker 来处理它。由于一次只处理一个 future,因此我们可以安全地在这些函数中使用 unsafe 访问数据集合,因为每次只有一个 future 会被轮询,避免了多个可变引用的冲突。
一旦任务被轮询,如果任务仍然是挂起状态,我们将把任务重新放回轮询队列以供再次轮询。为了最初将任务放入队列中,我们使用 spawn 函数:
pub fn spawn<F, T>(&mut self, future: F) -> mpsc::Receiver<T>
where
F: Future<Output = T> + 'static + Send,
T: Send + 'static,
{
let (tx, rx) = mpsc::channel();
let future: Pin<Box<dyn Future<Output = ()> + Send>> = Box::pin(
async move {
let result = future.await;
let _ = tx.send(result);
}
);
let task = Task {
future,
waker: self.create_waker(),
};
self.polling.push_back(task);
rx
}
我们使用通道返回一个句柄,并将 future 的返回值转换为 ()。
注意
如果你不喜欢暴露内部通道,可以为 spawn 任务返回以下 JoinHandle 结构体:
pub struct JoinHandle<T> {
receiver: mpsc::Receiver<T>,
}
impl<T> JoinHandle<T> {
pub fn await(self) -> Result<T, mpsc::RecvError> {
self.receiver.recv()
}
}
你可以使用以下 await 语法来获取返回的句柄:
match handle.await() {
Ok(result) => println!("Received: {}", result),
Err(e) => println!("Error receiving result: {}", e),
}
现在,我们将任务放入了我们的轮询队列,我们可以使用 Executor 的 poll 函数来轮询它:
pub fn poll(&mut self) {
let mut task = match self.polling.pop_front() {
Some(task) => task,
None => return,
};
let waker = task.waker.clone();
let context = &mut Context::from_waker(&waker);
match task.future.as_mut().poll(context) {
Poll::Ready(()) => {}
Poll::Pending => {
self.polling.push_back(task);
}
}
}
我们从队列的前端弹出任务,将 waker 的引用包装到 context 中,并将其传递给 future 的 poll 函数。如果 future 已准备好,我们什么都不做,因为我们通过通道发送结果,future 会被丢弃。如果 future 仍然挂起,我们将它重新放回队列。
我们的异步运行时的骨架现在已经完成,我们可以开始运行异步代码。在构建其余的 async_runtime 模块之前,我们应该绕道先运行一下我们的 executor。你不仅应该对看到它工作感到兴奋,而且还可能想要试着自定义 futures 的处理方式。现在是时候熟悉一下我们的系统是如何工作的了。
运行我们的 Executor
运行我们的异步运行时非常简单。在我们的 async_runtime 模块的 main.rs 文件中,我们导入以下内容:
use std::{
future::Future,
task::{Context, Poll},
pin::Pin
};
mod executor;
mod waker;
我们需要一个基本的 future 来追踪我们的系统如何运行。我们在整本书中都使用了 CountingFuture,因为它是一个非常简单的 future,根据状态返回 Pending 或 Ready。作为快速参考(希望你现在可以凭记忆写出这段代码),CountingFuture 如下所示:
pub struct CountingFuture {
pub count: i32,
}
impl Future for CountingFuture {
type Output = i32;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.count += 1;
if self.count == 4 {
println!("CountingFuture is done!");
Poll::Ready(self.count)
} else {
cx.waker().wake_by_ref();
println!("CountingFuture is not done yet! {}", self.count);
Poll::Pending
}
}
}
我们定义了我们的 futures、executor,生成 futures,然后通过以下代码运行它们:
fn main() {
let counter = CountingFuture { count: 0 };
let counter_two = CountingFuture { count: 0 };
let mut executor = executor::Executor::new();
let handle = executor.spawn(counter);
let _handle_two = executor.spawn(counter_two);
std::thread::spawn(move || {
loop {
executor.poll();
}
});
let result = handle.recv().unwrap();
println!("Result: {}", result);
}
我们生成一个线程并运行一个无限循环,不断轮询 executor 中的 futures。在这个循环运行时,我们等待其中一个 future 的结果。在我们的服务器中,我们将正确地实现 executor,使其能够在程序的整个生命周期内持续接收 futures。这个简单的实现输出如下:
CountingFuture is not done yet! 1
CountingFuture is not done yet! 1
CountingFuture is not done yet! 2
CountingFuture is not done yet! 2
CountingFuture is not done yet! 3
CountingFuture is not done yet! 3
CountingFuture is done!
CountingFuture is done!
Result: 4
我们可以看到它工作正常!我们使用仅标准库构建了一个异步运行时!
注意
记住,我们的 waker 实际上什么也不做。我们仍然在 executor 队列中轮询我们的 futures。如果你注释掉 CountingFuture 中 poll 函数的 cx.waker().wake_by_ref(); 这一行,你将得到完全相同的结果,这与像 smol 或 Tokio 这样的运行时不同。这告诉我们,已建立的运行时使用 waker 仅轮询那些需要被唤醒的 futures。这意味着,已建立的运行时在轮询时更加高效。
现在我们的异步运行时已经在运行,我们可以继续完成其余的异步流程。我们可以从创建一个发送者开始。
构建我们的 Sender
在通过 TCP 套接字发送数据时,我们必须允许我们的 executor 在连接当前被阻塞时切换到另一个异步任务。如果连接没有被阻塞,我们可以将字节写入流。在 async_runtime/src/sender.rs 文件中,我们首先导入以下内容:
use std::{
future::Future,
task::{Context, Poll},
pin::Pin,
net::TcpStream,
io::{self, Write},
sync::{Arc, Mutex}
};
我们的 sender 本质上是一个 future。在 poll 函数中,如果流被阻塞,我们将返回 Pending;如果流没有被阻塞,我们就将字节写入流。我们的 sender 结构体在此定义:
pub struct TcpSender {
pub stream: Arc<Mutex<TcpStream>>,
pub buffer: Vec<u8>,
}
impl Future for TcpSender {
type Output = io::Result<()>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
. . .
}
}
我们的 TcpStream 被包装在 Arc<Mutex<T>> 中。我们使用 Arc<Mutex<T>> 使得我们可以将 TcpStream 传递给 Sender 和 Receiver。一旦我们通过流发送完字节,我们将希望使用一个 Receiver future 来等待响应。
对于 TcpSender 结构体中的 poll 函数,我们首先尝试获取流的锁:
let mut stream = match self.stream.try_lock() {
Ok(stream) => stream,
Err(_) => {
cx.waker().wake_by_ref();
return Poll::Pending;
}
};
如果我们无法获取锁,我们返回 Pending,这样我们就不会阻塞 executor,并且任务会被放回队列中,稍后再次轮询。一旦我们成功获取锁,就将其设置为非阻塞模式:
stream.set_nonblocking(true)?;
set_nonblocking 函数使流的 write、recv、read 或 send 函数立即返回结果。如果 I/O 操作成功,结果将是 Ok。如果 I/O 操作返回 io::ErrorKind::WouldBlock 错误,意味着流被阻塞,I/O 操作需要重试。我们处理这些 I/O 操作结果如下:
match stream.write_all(&self.buffer) {
Ok(_) => {
Poll::Ready(Ok(()))
},
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
cx.waker().wake_by_ref();
Poll::Pending
},
Err(e) => Poll::Ready(Err(e))
}
我们现在已经定义了一个 sender future,接下来可以继续构建我们的 receiver future。
构建我们的 Receiver
我们的 receiver 将等待流中的数据,如果流中没有可读取的字节,它会返回 Pending。为了构建这个 future,我们在 async_runtime/src/receiver.rs 文件中导入以下内容:
use std::{
future::Future,
task::{Context, Poll},
pin::Pin,
net::TcpStream,
io::{self, Read},
sync::{Arc, Mutex}
};
既然我们要返回字节,显然我们的 receiver future 将采取以下形式:
pub struct TcpReceiver {
pub stream: Arc<Mutex<TcpStream>>,
pub buffer: Vec<u8>,
}
impl Future for TcpReceiver {
type Output = io::Result<Vec<u8>>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
. . .
}
}
在 poll 函数中,我们像在 sender future 中一样获取流的锁,并将其设置为非阻塞:
let mut stream = match self.stream.try_lock() {
Ok(stream) => stream,
Err(_) => {
cx.waker().wake_by_ref();
return Poll::Pending;
}
};
stream.set_nonblocking(true)?;
接下来,我们处理流的读取:
let mut local_buf = [0; 1024];
match stream.read(&mut local_buf) {
Ok(0) => {
Poll::Ready(Ok(self.buffer.to_vec()))
},
Ok(n) => {
std::mem::drop(stream);
self.buffer.extend_from_slice(&local_buf[..n]);
cx.waker().wake_by_ref();
Poll::Pending
},
Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => {
cx.waker().wake_by_ref();
Poll::Pending
},
Err(e) => Poll::Ready(Err(e))
}
我们现在已经拥有了所有的异步功能来启动我们的服务器。然而,还有一个基本的 future 我们可以构建,允许同步代码也具备异步特性:sleep future。
构建我们的 Sleep
我们之前已经讨论过这个内容,因此希望你能自己实现这个功能。不过,为了参考,我们的 async_runtime/src/sleep.rs 文件将包含我们的 sleep future。我们导入以下内容:
use std::{
future::Future,
pin::Pin,
task::{Context, Poll},
time::{Duration, Instant},
};
我们的 Sleep 结构体如下所示:
pub struct Sleep {
when: Instant,
}
impl Sleep {
pub fn new(duration: Duration) -> Self {
Sleep {
when: Instant::now() + duration,
}
}
}
我们的 Sleep future 实现了 Future trait:
impl Future for Sleep {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let now = Instant::now();
if now >= self.when {
Poll::Ready(())
} else {
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
现在,我们的异步系统已经具备了我们所需要的一切。为了将我们的异步运行时组件公开,我们在 async_runtime/src/lib.rs 文件中添加了以下代码:
pub mod executor;
pub mod waker;
pub mod receiver;
pub mod sleep;
pub mod sender;
现在,我们可以将我们的组件导入到服务器中。考虑到所有功能都已经完成,我们可以使用这些组件来构建我们的服务器。
构建我们的服务器
为了构建我们的服务器,我们需要之前编写的 data 和 async 模块。为了安装这些模块,Cargo.toml 文件中的依赖项如下所示:
[dependencies]
data_layer = { path = "../data_layer" }
async_runtime = { path = "../async_runtime" }
[profile.release]
opt-level = 'z'
我们在这里通过 opt-level 进行二进制大小的优化。
整个服务器的代码可以放在 main.rs 文件中,该文件需要以下导入:
use std::{
thread,
sync::{mpsc::channel, atomic::{AtomicBool, Ordering}},
io::{self, Read, Write, ErrorKind, Cursor},
net::{TcpListener, TcpStream}
};
use data_layer::data::Data;
use async_runtime::{
executor::Executor,
sleep::Sleep
};
现在我们已经具备了所有需要的内容,我们可以编写接收请求的代码。
接受请求
我们可以让主线程监听传入的 TCP 请求。然后,主线程将请求分发到三个线程和执行器中,如图 10-1 所示。
我们还希望在线程没有请求需要处理时让它们进入休眠状态。为了与线程通信以便停车,我们为每个线程使用一个 AtomicBool:
static FLAGS: [AtomicBool; 3] = [
AtomicBool::new(false),
AtomicBool::new(false),
AtomicBool::new(false),
];
每个 AtomicBool 代表一个线程。如果 AtomicBool 为 false,说明线程没有被停车;如果为 true,则我们的路由器知道该线程已经被停车,需要在发送请求之前唤醒它。
现在我们有了 FLAGS,我们需要处理每个线程的传入请求。在每个线程内部,我们创建一个执行器,并尝试从该线程的通道接收消息。如果通道中有请求,我们就在该执行器上生成任务。如果没有传入的请求,我们检查是否有任务在等待轮询。如果没有任务,则线程将 FLAG 设置为 true 并停车。如果有任务需要轮询,我们就在循环结束时轮询任务。我们可以通过以下宏实现这个过程:
macro_rules! spawn_worker {
($name:expr, $rx:expr, $flag:expr) => {
thread::spawn(move || {
let mut executor = Executor::new();
loop {
if let Ok(stream) = $rx.try_recv() {
println!(
"{} Received connection: {}",
$name,
stream.peer_addr().unwrap()
);
executor.spawn(handle_client(stream));
} else {
if executor.polling.len() == 0 {
println!("{} is sleeping", $name);
$flag.store(true, Ordering::SeqCst);
thread::park();
}
}
executor.poll();
}
})
};
}
在我们的宏中,我们接受线程的名称(用于日志记录),接收传入请求的通道的接收器,以及通知系统线程停车的标志。
注意
你可以在《Writing Complex Macros in Rust》 by Ingvar Stepanyan 中阅读更多关于创建复杂宏的内容。
我们可以在主函数中组织接受请求的过程:
fn main() -> io::Result<()> {
. . .
Ok(())
}
首先,我们定义将用于向线程发送请求的通道:
let (one_tx, one_rx) = channel::<TcpStream>();
let (two_tx, two_rx) = channel::<TcpStream>();
let (three_tx, three_rx) = channel::<TcpStream>();
然后,我们为请求的处理创建线程:
let one = spawn_worker!("One", one_rx, &FLAGS[0]);
let two = spawn_worker!("Two", two_rx, &FLAGS[1]);
let three = spawn_worker!("Three", three_rx, &FLAGS[2]);
现在,我们的执行器正在各自的线程中运行,并等待我们的 TCP 监听器发送请求。我们需要保持并引用线程句柄和线程通道的发送器,以便我们能够唤醒并向各个线程发送请求。我们可以通过以下代码与线程交互:
let router = [one_tx, two_tx, three_tx];
let threads = [one, two, three];
let mut index = 0;
let listener = TcpListener::bind("127.0.0.1:7878")?;
println!("Server listening on port 7878");
for stream in listener.incoming() {
. . .
}
现在我们只需要处理传入的 TCP 请求:
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let _ = router[index].send(stream);
if FLAGS[index].load(Ordering::SeqCst) {
FLAGS[index].store(false, Ordering::SeqCst);
threads[index].thread().unpark();
}
index += 1; // 循环切换线程索引
if index == 3 {
index = 0;
}
}
Err(e) => {
println!("Connection failed: {}", e);
}
}
}
一旦我们收到 TCP 请求,我们就会将 TCP 流发送到一个线程,检查线程是否被停车,并在需要时唤醒该线程。接下来,我们将索引移动到下一个线程,以便我们能够在所有线程之间均匀分配请求。
那么,在将请求发送到执行器之后,我们如何处理这些请求呢?我们处理它们。
处理请求
在处理请求时,我们回顾一下我们在 executor 中调用的 handle_stream 函数。我们的异步 handle 函数如下所示:
async fn handle_client(mut stream: TcpStream) -> std::io::Result<()> {
stream.set_nonblocking(true)?;
let mut buffer = Vec::new();
let mut local_buf = [0; 1024];
loop {
. . .
}
match Data::deserialize(&mut Cursor::new(buffer.as_slice())) {
Ok(message) => {
println!("Received message: {:?}", message);
},
Err(e) => {
println!("Failed to decode message: {}", e);
}
}
Sleep::new(std::time::Duration::from_secs(1)).await;
stream.write_all(b"Hello, client!")?;
Ok(())
}
这应该看起来与我们在异步运行时中构建的 sender 和 receiver futures 类似。在这里,我们为 1 秒钟添加了异步 sleep,这是为了模拟工作正在进行。它还将确保我们的异步功能正常工作。如果异步没有正常工作并且我们发送 10 个请求,那么总时间将超过 10 秒。
在循环内部,我们处理传入的流:
match stream.read(&mut local_buf) {
Ok(0) => {
break;
},
Ok(len) => {
buffer.extend_from_slice(&local_buf[..len]);
},
Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
if buffer.len() > 0 {
break;
}
Sleep::new(std::time::Duration::from_millis(10)).await;
continue;
},
Err(e) => {
println!("Failed to read from connection: {}", e);
}
}
如果发生阻塞,我们会引入一个小的异步 sleep,这样执行器将会把请求处理放回队列中,去轮询其他的请求处理。
现在,我们的服务器已经完全正常运行。我们只剩下的最后一部分是编写我们的客户端。
构建我们的异步客户端
由于我们的客户端也依赖于相同的模块,因此客户端的 Cargo.toml 也将包含以下依赖项:
[dependencies]
data_layer = { path = "../data_layer" }
async_runtime = { path = "../async_runtime" }
在 main.rs 文件中,我们需要以下导入:
use std::{
io,
sync::{Arc, Mutex},
net::TcpStream,
time::Instant
};
use data_layer::data::Data;
use async_runtime::{
executor::Executor,
receiver::TcpReceiver,
sender::TcpSender,
};
为了发送数据,我们实现了来自异步运行时的发送和接收 future:
async fn send_data(field1: u32, field2: u16, field3: String)
-> io::Result<String> {
let stream = Arc::new(Mutex::new(TcpStream::connect("127.0.0.1:7878")?));
let message = Data { field1, field2, field3 };
// 发送数据
TcpSender {
stream: stream.clone(),
buffer: message.serialize()?,
}.await?;
// 接收数据
let receiver = TcpReceiver {
stream: stream.clone(),
buffer: Vec::new(),
};
String::from_utf8(receiver.await?).map_err(|_|
io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8")
)
}
现在我们可以调用我们的异步 send_data 函数 4,000 次,并等待所有的句柄:
fn main() -> io::Result<()> {
let mut executor = Executor::new();
let mut handles = Vec::new();
let start = Instant::now();
// 执行 4000 次请求
for i in 0..4000 {
let handle = executor.spawn(send_data(
i, i as u16, format!("Hello, server! {}", i)
));
handles.push(handle);
}
// 创建一个线程不断轮询执行器
std::thread::spawn(move || {
loop {
executor.poll();
}
});
println!("Waiting for result...");
// 等待所有结果
for handle in handles {
match handle.recv().unwrap() {
Ok(result) => println!("Result: {}", result),
Err(e) => println!("Error: {}", e),
};
}
// 打印耗时
let duration = start.elapsed();
println!("Time elapsed in expensive_function() is: {:?}", duration);
Ok(())
}
现在我们的测试已经准备好了。如果你在不同的终端中运行服务器和客户端,你会看到所有的打印信息显示请求正在被处理。整个客户端过程大约需要 1.2 秒,这意味着我们的异步系统正在运行。
就这样!我们已经构建了一个没有任何第三方依赖的异步服务器!
总结
在本章中,我们仅使用标准库构建了一个相当高效的异步运行时。部分性能得益于我们用 Rust 编写了服务器。另一个因素是,异步模块提高了 I/O 绑定任务(如连接)的资源利用率。当然,我们的服务器不会像 Tokio 等运行时那样高效,但它是可用的。
我们不建议你开始从项目中移除 Tokio 和 Web 框架,因为许多 crate 都是为了与像 Tokio 这样的运行时进行集成而构建的。这样做会导致失去与第三方 crate 的大量集成,且你的运行时效率也不会那么高。然而,你也可以在程序中同时使用其他运行时。例如,我们可以使用 Tokio 通道将任务发送到我们自定义的异步运行时。这意味着 Tokio 可以在等待我们自定义异步运行时中的任务完成时,处理其他异步任务。此方法在你需要像 Tokio 这样的已建立运行时来处理标准异步任务(如传入请求),但又有特定需求需要以特殊方式处理任务时非常有用。例如,你可能已经构建了一个键值存储,并阅读了最新的计算机科学论文,了解如何处理其上的事务。然后你想在自定义异步运行时中直接实现这些逻辑。
我们可以得出结论,构建我们自己的异步运行时既不是最佳选择,也不是最糟的选择。了解已建立的运行时并能够构建自己的运行时,是两者的最佳结合。拥有这两种工具,并知道何时以及如何使用它们,远比成为某种工具的传道者要好。然而,你需要在解决问题之前练习这些工具。我们建议你继续通过自定义异步运行时来推动自己在各种项目中的应用。围绕 waker 实验不同类型的数据是探索多种方法的好起点。
测试是探索和完善你方法的重要部分。测试使你能够获得关于异步代码和实现的深入、直接反馈。在第 11 章中,我们将介绍如何测试你的异步代码,这样你就可以继续探索异步概念和实现。