异步Rust——将网络集成到我们自己的异步运行时中

318 阅读28分钟

在第 3 章中,我们构建了自己的异步运行时队列,说明了异步任务如何在异步运行时中执行。然而,我们只使用了基本的 sleepprint 操作。专注于简单的操作在开始时是有用的,但简单的 sleepprint 函数是有限的。在本章中,我们将在之前定义的异步运行时基础上构建,并集成网络协议,以便它们能够在我们的异步运行时中运行。

在本章结束时,你将能够使用 traits 将 hyper crate 集成到我们的运行时中,用于处理 HTTP 请求。这意味着你将能够利用这个例子,通过 traits 将其他第三方依赖集成到我们的异步运行时中,只需阅读该 crate 的文档。最后,我们将深入到底层,通过实现 mio crate 直接在我们的 futures 中轮询套接字。这将展示如何在异步运行时中对套接字的轮询、读取和写入进行细粒度控制。通过这些内容的学习和进一步的外部阅读,你将能够实现自己的自定义网络协议。

本章是最难理解的一章,如果你不打算将网络功能集成到自定义运行时中,它并非必需。如果你觉得难度较大,可以跳过本章,等读完本书的其他部分后再回来。将这部分内容放在本章,是因为它是建立在第 3 章的代码基础上的。

在继续本章之前,我们需要以下额外的依赖项,除了我们在第 3 章中使用的依赖项:

toml
复制代码
[dependencies]
hyper = { version = "0.14.26", features = ["http1", "http2", "client", "runtime"] }
smol = "1.3.0"
anyhow = "1.0.70"
async-native-tls = "0.5.0"
http = "0.2.9"
tokio = "1.14.0"

我们使用这些依赖项:

  • hyper
    这是一个快速且流行的 HTTP 实现。我们将使用它来发送 HTTP 请求。我们需要 client 特性来允许发送 HTTP 请求,并需要 runtime 特性来与自定义异步运行时兼容。
  • smol
    这是一个小巧且快速的异步运行时,特别适用于低开销的轻量级任务。
  • anyhow
    这是一个错误处理库,提供便捷的错误处理功能。
  • async-native-tls
    这是一个提供异步传输层安全(TLS)支持的库。
  • http
    这个库提供了用于处理 HTTP 请求和响应的类型。
  • Tokio
    我们在之前的章节中使用过这个库来演示我们的异步运行时,并将在本章中再次使用它。

正如你所看到的,我们将在本例中使用 hyper,这是为了给你一个与之前示例中使用的工具不同的工具集,并展示像 Tokio 这样的工具如何在其他常用库中作为层叠结构出现。然而,在编写代码之前,我们必须先介绍执行器和连接器。

理解执行器和连接器

执行器(Executor)负责运行 futures,直到它们完成。它是运行时的一部分,负责调度任务并确保任务在准备好时运行(或执行)。当我们将网络功能引入到我们的运行时中时,执行器变得至关重要,因为没有它,像 HTTP 请求这样的 futures 将被创建,但实际上永远不会被运行。

在网络中,连接器(Connector)是一个组件,负责在我们的应用程序和我们想连接的服务之间建立连接。它处理如打开 TCP 连接和在请求生命周期内保持连接等任务。

将 Hyper 集成到我们的异步运行时

现在你已经理解了执行器和连接器是什么,我们来看看在将像 hyper 这样的库集成到我们的异步运行时时,这些概念是如何不可或缺的。如果没有适当的执行器和连接器,我们的运行时将无法处理 hyper 所依赖的 HTTP 请求和连接。

如果我们查看 hyper 官方文档或各种在线教程,可能会得到这样的印象:我们可以通过以下代码使用 hyper crate 执行一个简单的 GET 请求:

use hyper::{Request, Client};

let url = "http://www.rust-lang.org";
let uri: Uri = url.parse().unwrap();

let request = Request::builder()
    .method("GET")
    .uri(uri)
    .header("User-Agent", "hyper/0.14.2")
    .header("Accept", "text/html")
    .body(hyper::Body::empty()).unwrap();

let future = async {
    let client = Client::new();
    client.request(request).await.unwrap()
};

let test = spawn_task!(future);
let response = future::block_on(test);
println!("Response status: {}", response.status());

然而,如果我们运行上述代码,我们会遇到以下错误:

thread '<unnamed>' panicked at 'there is no reactor
running, must be called from the context of a Tokio 1.x runtime'

这是因为 hyper 默认运行在 Tokio 运行时,而我们的代码中没有指定执行器。如果你打算使用 reqwest 或其他流行的库,你可能会遇到类似的错误。

为了解决这个问题,我们将创建一个自定义执行器,可以在我们自己构建的异步运行时中处理任务。接着,我们将构建一个自定义连接器来管理实际的网络连接,使我们的运行时能够无缝集成 hyper 和其他类似的库。

创建自定义执行器

第一步是将以下内容导入到我们的程序中:

use std::net::Shutdown;
use std::net::{TcpStream, ToSocketAddrs};
use std::pin::Pin;
use std::task::{Context, Poll};

use anyhow::{bail, Context as _, Error, Result};
use async_native_tls::TlsStream;
use http::Uri;
use hyper::{Body, Client, Request, Response};
use smol::{io, prelude::*, Async};

我们可以按如下方式构建自定义执行器:

struct CustomExecutor;

impl<F: Future + Send + 'static> hyper::rt::Executor<F> for CustomExecutor {
    fn execute(&self, fut: F) {
        spawn_task!(async {
            println!("sending request");
            fut.await;
        }).detach();
    }
}

这段代码定义了我们的自定义执行器及其 execute 函数的行为。在这个函数中,我们调用了 spawn_task 宏,并在其中创建了一个异步块,等待传入 execute 函数的 future。我们使用了 detach 函数;否则,通道将会关闭,任务超出作用域后就会被丢弃,我们的请求将不会继续。正如你在第 3 章中回顾到的,detach 会将任务的指针发送到一个循环中,直到任务完成,然后才会丢弃该任务。

现在我们有了一个自定义执行器,可以将其传递给 hyper 客户端。然而,我们的 hyper 客户端仍然无法发出请求,因为它期望连接由 Tokio 运行时管理。为了完全将 hyper 与我们的自定义异步运行时集成,我们还需要构建一个自定义的异步连接器,独立于 Tokio 来处理网络连接。

构建 HTTP 连接

在网络请求中,协议是明确定义并标准化的。例如,TCP 有三次握手过程,用于在通过该连接发送字节数据包之前建立连接。除非你有非常具体的需求,标准化的连接协议无法满足,否则从头实现 TCP 连接是没有任何益处的。在图 4-1 中,我们可以看到 HTTP 和 HTTPS 是应用层协议,运行在传输协议(如 TCP)之上。

image.png

在网络请求中,协议是明确定义和标准化的。例如,TCP 有一个三步握手过程,用于在通过该连接发送数据包之前建立连接。除非你有非常具体的需求,标准化的连接协议无法满足,否则从头实现 TCP 连接并没有任何好处。在图 4-1 中,我们可以看到 HTTP 和 HTTPS 是应用层协议,运行在像 TCP 这样的传输协议之上。

对于 HTTP,我们会发送一个主体、头部等内容。HTTPS 有更多步骤,因为在客户端开始发送数据之前,会检查并将证书发送给客户端。这是因为数据需要加密。考虑到这些协议中涉及的往返交互和等待响应,网络请求是异步编程的一个合理目标。我们无法去除网络中的步骤,否则就会失去安全性和确保连接建立的保障。然而,通过异步编程,我们可以在等待响应时释放 CPU,避免因网络请求而浪费 CPU 时间。

集成 Hyper 到我们的异步运行时

现在你理解了执行器和连接器的作用,我们来看看这些概念在将像 hyper 这样的库集成到我们的异步运行时时是如何必不可少的。如果没有适当的执行器和连接器,我们的运行时将无法处理 hyper 所依赖的 HTTP 请求和连接。

如果我们查看 hyper 的官方文档或各种在线教程,可能会得到这样的印象:我们可以通过以下代码使用 hyper crate 执行一个简单的 GET 请求:

use hyper::{Request, Client};

let url = "http://www.rust-lang.org";
let uri: Uri = url.parse().unwrap();

let request = Request::builder()
    .method("GET")
    .uri(uri)
    .header("User-Agent", "hyper/0.14.2")
    .header("Accept", "text/html")
    .body(hyper::Body::empty()).unwrap();

let future = async {
    let client = Client::new();
    client.request(request).await.unwrap()
};

let test = spawn_task!(future);
let response = future::block_on(test);
println!("Response status: {}", response.status());

然而,如果我们运行上面的代码,我们会遇到如下错误:

thread '<unnamed>' panicked at 'there is no reactor
running, must be called from the context of a Tokio 1.x runtime'

这是因为 hyper 默认在 Tokio 运行时上运行,而我们的代码中没有指定执行器。如果你打算使用 reqwest 或其他流行的库,你可能会遇到类似的错误。

为了解决这个问题,我们将创建一个自定义执行器,用于在我们自己构建的异步运行时中处理任务。然后我们将构建一个自定义连接器来管理实际的网络连接,使我们的运行时能够与 hyper 和其他类似库无缝集成。

创建自定义执行器

第一步是将以下内容导入我们的程序:

use std::net::Shutdown;
use std::net::{TcpStream, ToSocketAddrs};
use std::pin::Pin;
use std::task::{Context, Poll};

use anyhow::{bail, Context as _, Error, Result};
use async_native_tls::TlsStream;
use http::Uri;
use hyper::{Body, Client, Request, Response};
use smol::{io, prelude::*, Async};

我们可以按如下方式构建自定义执行器:

struct CustomExecutor;

impl<F: Future + Send + 'static> hyper::rt::Executor<F> for CustomExecutor {
    fn execute(&self, fut: F) {
        spawn_task!(async {
            println!("sending request");
            fut.await;
        }).detach();
    }
}

这段代码定义了我们的自定义执行器及其 execute 函数的行为。在这个函数中,我们调用了 spawn_task 宏,并在其中创建了一个异步块,等待传入 execute 函数的 future。我们使用了 detach 函数;否则,通道将会关闭,任务超出作用域后就会被丢弃,我们的请求将不会继续。正如你在第 3 章中回顾到的,detach 会将任务的指针发送到一个循环中,直到任务完成,然后才会丢弃该任务。

现在我们有了一个自定义执行器,可以将其传递给 hyper 客户端。然而,我们的 hyper 客户端仍然无法发出请求,因为它期望连接由 Tokio 运行时管理。为了完全将 hyper 与我们的自定义异步运行时集成,我们还需要构建一个自定义的异步连接器,独立于 Tokio 来处理网络连接。

构建 HTTP 连接

对于我们的连接器,我们将支持 HTTP 和 HTTPS,因此我们需要以下 enum

enum CustomStream {
    Plain(Async<TcpStream>),
    Tls(TlsStream<Async<TcpStream>>),
}

Plain 变体是一个异步 TCP 流。根据图 4-1,我们可以推断出 Plain 变体支持 HTTP 请求。Tls 变体表示 HTTPS,因为 HTTPS 只是 TCP 和 HTTP 之间的 TLS 层,这意味着我们的 Tls 变体支持 HTTPS。

接下来,我们可以使用这个自定义流的 enum 来实现 hyperService trait,以便为自定义连接器结构体提供支持:

#[derive(Clone)]
struct CustomConnector;

impl hyper::service::Service<Uri> for CustomConnector {
    type Response = CustomStream;
    type Error = Error;
    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;

    fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        . . .
    }

    fn call(&mut self, uri: Uri) -> Self::Future {
        . . .
    }
}

Service trait 定义了连接的 future。我们的连接是一个线程安全的 future,返回我们定义的流 enum,该流可以是一个异步 TCP 连接或一个封装在 TLS 流中的异步 TCP 连接。

我们可以看到,poll_ready 函数只是返回 Ready。这个函数是 hyper 用来检查服务是否准备好处理请求的。如果我们返回 Pending,任务会被轮询,直到服务变得准备好。如果我们返回一个错误,则表示服务无法继续处理请求。由于我们使用 Service trait 进行客户端调用,我们会始终返回 Ready。如果我们为服务器实现 Service trait,poll_ready 函数可能如下所示:

fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Error>> {
    Poll::Ready(Ok(()))
}

我们可以看到,poll_ready 函数返回了 Ready,表示未来已准备好。理想情况下,我们可以不去定义 poll_ready,因为我们的实现使得调用它变得多余。然而,poll_ready 函数是 Service trait 的要求。

接下来,我们可以继续定义 call 函数。只有在 poll_ready 返回 Ok 后,我们才能使用 call 函数。我们的 call 函数大致如下:

fn call(&mut self, uri: Uri) -> Self::Future {
    Box::pin(async move {
        let host = uri.host().context("cannot parse host")?;

        match uri.scheme_str() {
            Some("http") => {
                . . .
            }
            Some("https") => {
                . . .
            }
            scheme => bail!("unsupported scheme: {:?}", scheme),
        }
    })
}

我们记得 pinasync 块返回一个 future。因此,我们的 pin 的 future 将是异步块的返回语句。对于 HTTPS 块,我们通过以下代码构建一个 future:

let socket_addr = {
    let host = host.to_string();
    let port = uri.port_u16().unwrap_or(443);
    smol::unblock(move || (host.as_str(), port).to_socket_addrs())
        .await?
        .next()
        .context("cannot resolve address")?
};

let stream = Async::<TcpStream>::connect(socket_addr).await?;
let stream = async_native_tls::connect(host, stream).await?;
Ok(CustomStream::Tls(stream))

端口是 443,因为这是 HTTPS 的标准端口。我们然后将一个闭包传递给 unblock 函数,闭包返回套接字地址。unblock 函数在线程池中运行阻塞代码,这样我们可以将异步接口应用到阻塞代码上。在解析套接字地址时,我们可以释放线程去做其他事情。然后我们连接我们的 TCP 流,并将其连接到本地的 TLS。连接建立后,我们最终返回 CustomStream enum

对于 HTTP,我们的实现几乎一样。端口是 80 而不是 443,而且不需要 TLS 连接,结果是返回 Ok(CustomStream::Plain(stream))

现在,我们已经定义了 call 函数。如果我们此时尝试使用我们的流 enum 或连接结构体进行 HTTPS 调用,我们会遇到错误信息,提示我们尚未为我们的流 trait 实现 AsyncReadAsyncWrite Tokio traits。这是因为 hyper 需要这些 traits 的实现,以便使用我们的连接 enum

实现 Tokio AsyncRead Trait

AsyncRead trait 类似于 std::io::Read trait,但它与异步任务系统集成。在实现 AsyncRead trait 时,我们只需要定义 poll_read 函数,该函数返回一个 Poll 枚举作为结果。如果我们返回 Poll::Ready,表示数据已立即读取并放入输出缓冲区。如果返回 Poll::Pending,表示没有数据被读取到我们提供的缓冲区,并且当前 I/O 对象不可读,但未来可能变得可读。返回 Pending 会导致当前 future 的任务在对象变得可读时被安排重新调度。最终,Poll 枚举还可以返回 Ready,但带有错误,这通常是标准的 I/O 错误。

我们在此实现 AsyncRead trait 的代码如下:

impl tokio::io::AsyncRead for CustomStream {
    fn poll_read(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut tokio::io::ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        match &mut *self {
            CustomStream::Plain(s) => {
                Pin::new(s)
                    .poll_read(cx, buf.initialize_unfilled())
                    .map_ok(|size| {
                        buf.advance(size);
                    })
            }
            CustomStream::Tls(s) => {
                Pin::new(s)
                    .poll_read(cx, buf.initialize_unfilled())
                    .map_ok(|size| {
                        buf.advance(size);
                    })
            }
        }
    }
}

对于我们的其他流,处理方式基本相同;我们传入的是异步 TCP 流或带 TLS 的异步 TCP 流。然后,我们将该流固定,并执行流的 poll_read 函数,进行读取并返回一个 Poll 枚举,表示缓冲区因读取而增长的大小。一旦 poll_read 执行完毕,我们执行 map_ok,它接收一个 FnOnce(T),即只能调用一次的函数或闭包。

注意

map_ok 的上下文中,闭包的作用是通过 poll_read 返回的大小来推进缓冲区。这是每次读取时的单次操作,因此使用 FnOnce 是足够的并且是首选。如果闭包需要被调用多次,应该使用 FnFnMut。通过使用 FnOnce,我们确保闭包能够获取它捕获的环境的所有权,这为闭包提供了灵活性。这在异步编程中尤其有用,因为在这种编程模型下,所有权和生命周期必须小心管理。

map_ok 还引用了 poll_read 的结果。如果 Poll 结果是 Ready,但带有错误,则返回带错误的 Ready。如果 Poll 结果是 Pending,则返回 Pending。我们将上下文传递给 poll_read,以便在我们获得 Pending 结果时使用 waker。如果结果是 Ready 且结果是 Ok,则闭包被调用,并传递从 poll_read 获取的结果,然后从 map_ok 函数返回 Ready Ok。我们传递给 map_ok 函数的闭包会推进缓冲区。

在背后有很多复杂的处理,但基本上,我们的流被固定,然后对固定的流执行读取操作。如果读取成功,我们会推进缓冲区已填充区域的大小,因为读取的数据现在已经存入缓冲区。poll_read 中的轮询和 map_okPoll 枚举的匹配使得这个读取过程与异步运行时兼容。

现在我们可以异步地读取到缓冲区,但为了完成我们的 HTTP 请求,我们还需要进行异步写入操作。

实现 Tokio AsyncWrite Trait

AsyncWrite trait 类似于 std::io::Write,但它与异步任务系统交互。它异步地写入字节,和我们刚刚实现的 AsyncRead trait 一样,来自 Tokio。

在实现 AsyncWrite trait 时,我们需要以下的结构:

impl tokio::io::AsyncWrite for CustomStream {
    fn poll_write(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &[u8],
    ) -> Poll<io::Result<usize>> {
        . . .
    }
    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) 
        -> Poll<io::Result<()>> {
        . . .
    }
    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) 
        -> Poll<io::Result<()>> {
        . . .
    }
}

poll_write 函数应该不会让你感到意外,但请注意,我们还有 poll_flushpoll_shutdown 函数。这些函数都返回 Poll 枚举的变体,并接受上下文参数。因此,我们可以推测,所有这些函数都能够将任务挂起,等待被唤醒,检查 future 是否已准备好进行关闭、刷新或写入操作。

我们从 poll_write 函数开始:

match &mut *self {
    CustomStream::Plain(s) => Pin::new(s).poll_write(cx, buf),
    CustomStream::Tls(s) => Pin::new(s).poll_write(cx, buf),
}

我们在这里对流进行匹配,固定该流,并执行该流的 poll_write 函数。此时,poll_write 试图将缓冲区中的字节写入对象。如果写入成功,返回写入的字节数。如果对象尚未准备好进行写入,则会返回 Pending;如果返回 0,通常意味着该对象无法再接收字节。

在流的 poll_write 函数内部,会执行一个循环,获取 I/O 处理程序的可变引用。循环会重复尝试将字节写入底层 I/O,直到缓冲区中的所有字节都被写入。每次写入尝试都有一个结果,结果会被处理。如果写入的错误是 io::ErrorKind::WouldBlock,这意味着写入操作不能立即完成,循环将重复直到写入完成。如果结果是其他错误,循环会返回 Pending,表示 I/O 资源未准备好,需要稍后重新轮询。

现在我们已经实现了 poll_write,接下来定义 poll_flush 函数的主体:

match &mut *self {
    CustomStream::Plain(s) => Pin::new(s).poll_flush(cx),
    CustomStream::Tls(s) => Pin::new(s).poll_flush(cx),
}

这与我们的 poll_write 函数结构相同。然而,在这种情况下,我们调用的是流的 poll_flushflush 操作类似于写入,不同之处在于它确保缓冲区的所有内容立即到达目标。flush 的底层机制与写入的循环完全相同,只是 flush 函数会在循环中调用,而不是 write 函数。

接下来我们实现最终的函数,即 shutdown

match &mut *self {
    CustomStream::Plain(s) => {
        s.get_ref().shutdown(Shutdown::Write)?;
        Poll::Ready(Ok(()))
    }
    CustomStream::Tls(s) => Pin::new(s).poll_close(cx),
}

我们在实现自定义流的不同类型时略有不同。Plain 流直接进行关闭。一旦关闭,我们返回一个 Poll::Ready。然而,Tls 流本身是异步实现的。因此,我们需要固定它,以避免它在内存中被移动,因为它可能会被多次放入任务队列,直到轮询完成。我们调用 poll_close 函数,它将自己返回一个轮询结果。

现在,我们已经为我们的 hyper 客户端实现了异步读取和写入 traits。接下来我们要做的就是连接并运行 HTTP 请求,以测试我们的实现。

连接并运行我们的客户端

在本节中,我们将总结我们所做的工作并进行测试。我们可以通过以下方式创建我们的连接请求发送函数:

impl hyper::client::connect::Connection for CustomStream {
    fn connected(&self) -> hyper::client::connect::Connected {
        hyper::client::connect::Connected::new()
    }
}

async fn fetch(req: Request<Body>) -> Result<Response<Body>> {
    Ok(Client::builder()
        .executor(CustomExecutor)
        .build::<_, Body>(CustomConnector)
        .request(req)
        .await?)
}

现在,我们只需要在主函数中运行我们的 HTTP 客户端:

fn main() {
    Runtime::new().with_low_num(2).with_high_num(4).run();

    let future  = async {
        let req = Request::get("https://www.rust-lang.org")
                                         .body(Body::empty())
                                         .unwrap();
        let response = fetch(req).await.unwrap();

        let body_bytes = hyper::body::to_bytes(response.into_body())
                         .await.unwrap();
        let html = String::from_utf8(body_bytes.to_vec()).unwrap();
        println!("{}", html);
    };
    let test = spawn_task!(future);
    let _outcome = future::block_on(test);
}

到这里,我们的代码可以从 Rust 官网获取 HTML 代码。现在,我们可以说我们的异步运行时可以异步地与互联网通信,但如果我们需要接受请求呢?我们已经介绍了如何实现来自其他 crate 的 traits 来获得异步实现。那么接下来我们要做的就是进一步深入,直接使用 mio crate 来监听套接字中的事件。

介绍 mio

在实现与套接字的异步功能时,我们无法比 mio(metal I/O)更底层,除非直接调用操作系统。这个低级的非阻塞 I/O 库为创建高性能异步应用程序提供了构建块。它充当了操作系统异步 I/O 能力的薄层抽象。

mio crate 非常关键,因为它为其他更高级的异步运行时提供了基础,包括 Tokio。这些更高级的库抽象了复杂性,使它们更易于使用。mio crate 对于需要对 I/O 操作进行细粒度控制并希望优化性能的开发者非常有用。图 4-2 显示了 Tokio 是如何建立在 mio 上的。

image.png

之前在本章中,我们将 hyper 集成到我们的运行时中。为了完整了解整个过程,我们现在将探索 mio 并将其集成到我们的运行时中。在继续之前,我们需要在 Cargo.toml 中添加以下依赖项:

mio = { version = "1.0.2", features = ["net", "os-poll"] }

我们还需要这些导入:

use mio::net::{TcpListener, TcpStream};
use mio::{Events, Interest, Poll as MioPoll, Token};
use std::io::{Read, Write};
use std::time::Duration;
use std::error::Error;

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

MIO 与 HYPER

我们在本章中探索 mio 的方式并不是创建 TCP 服务器的最佳方法。如果你想创建一个生产级的服务器,应该采用类似于以下 hyper 示例的方式:

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));
    let listener = TcpListener::bind(addr).await?;
    loop {
        let (stream, _) = listener.accept().await?;
        let io = TokioIo::new(stream);
        tokio::task::spawn(async move {
            if let Err(err) = http1::Builder::new()
                .serve_connection(io, service_fn(hello))
                .await
            {
                println!("Error serving connection: {:?}", err);
            }
        });
    }
}

主线程等待传入数据,当数据到达时,会生成一个新的任务来处理这些数据。这样可以保持监听器准备好接受更多的传入数据。虽然我们在 mio 示例中会帮助你理解 TCP 连接轮询的工作原理,但在构建 Web 应用程序时,使用框架或库提供的监听器是最合适的方式。我们将讨论一些 Web 概念以便给我们的示例提供上下文,但 Web 开发的全面概述超出了本书的范围。

现在,我们已经铺好了所有基础,可以开始在 futures 中轮询 TCP 套接字了。

在 Futures 中轮询套接字

mio crate 旨在处理大量套接字(成千上万个)。因此,我们需要识别哪个套接字触发了通知。Token 可以帮助我们实现这一点。当我们将一个套接字注册到事件循环时,我们会为其分配一个 token,并在处理程序中返回该 tokentoken 是一个围绕 usize 的结构体元组。这是因为每个操作系统允许将一个指针大小的数据与一个套接字关联。因此,在处理程序中,我们可以通过一个映射函数将 token 作为键,将其与套接字进行映射。

mio 在这里没有使用回调,因为我们希望使用零成本抽象,token 是唯一能够实现这一点的方式。我们可以在 mio 之上构建回调、流和 futures。

使用 token 后,我们现在有以下步骤:

  1. 将套接字注册到事件循环中。
  2. 等待套接字就绪。
  3. 使用 token 查找套接字状态。
  4. 对套接字进行操作。
  5. 重复上述过程。

我们的简单示例省略了映射的需要,因此我们将通过以下代码定义我们的 token

const SERVER: Token = Token(0);
const CLIENT: Token = Token(1);

在这里,我们只需要确保 token 是唯一的。传递给 Token 的整数用于将其与其他 token 区分开来。现在,我们有了 token,接下来定义将要轮询套接字的 future:

struct ServerFuture {
    server: TcpListener,
    poll: MioPoll,
}

impl Future for ServerFuture {
    type Output = String;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        . . .
    }
}

我们使用 TcpListener 来接收传入的数据,并使用 MioPoll 来轮询套接字并在套接字可读时通知 future。在我们的 future poll 函数内部,我们可以定义事件并轮询套接字:

let mut events = Events::with_capacity(1);

let _ = self.poll.poll(
    &mut events,
    Some(Duration::from_millis(200))
).unwrap();

for event in events.iter() {
    . . .
}
cx.waker().wake_by_ref();
return Poll::Pending

poll 会将套接字中的事件提取到事件迭代器中。我们还设置了套接字轮询超时为 200 毫秒。如果套接字中没有事件,我们继续没有事件的处理并返回 Pending。我们将继续轮询直到我们得到一个事件。

当我们确实获得事件时,我们会循环处理它们。在上面的代码中,我们将容量设置为 1,但如果需要,我们可以增加容量来处理多个事件。在处理事件时,我们需要明确事件的类型。对于我们的 future,我们需要确保套接字是可读的,并且 tokenSERVER 这个 token

if event.token() == SERVER && event.is_readable() {
    let (mut stream, _) = self.server.accept().unwrap();
    let mut buffer = [0u8; 1024];
    let mut received_data = Vec::new();

    loop {
        . . .
    }

    if !received_data.is_empty() {
        let received_str = String::from_utf8_lossy(&received_data);
        return Poll::Ready(received_str.to_string())
    }
    cx.waker().wake_by_ref();
    return Poll::Pending
}

如果事件是可读的,这意味着套接字中有数据。如果事件正确,我们提取 TcpStream 并定义一个数据接收的集合 Vec(存储在堆上),使用缓冲区切片来执行读取操作。如果数据为空,我们返回 Pending,这样我们可以再次轮询套接字直到数据到达。然后,我们将数据转换为字符串并返回 Ready。这意味着我们在获得数据后,套接字监听器就完成了。

注意

如果我们希望套接字在程序的整个生命周期内持续被轮询,我们可以生成一个分离的任务,将数据传递到一个异步函数来处理数据,示例如下:

if !received_data.is_empty() {
    spawn_task!(some_async_handle_function(&received_data))
        .detach();
    return Poll::Pending;
}

在我们的循环中,我们从套接字读取数据:

loop {
    match stream.read(&mut buffer) {
        Ok(n) if n > 0 => {
            received_data.extend_from_slice(&buffer[..n]);
        }
        Ok(_) => {
            break;
        }
        Err(e) => {
            eprintln!("Error reading from stream: {}", e);
            break;
        }
    }
}

无论接收到的消息是否大于缓冲区大小,我们的循环都会继续提取所有字节,直到处理完所有字节并将它们添加到我们的 Vec 中。如果没有更多字节,我们可以停止循环并处理数据。

现在,我们有了一个 future,它会继续被轮询,直到从套接字接收到数据。接收到数据后,future 会终止。我们还可以让这个 future 持续轮询套接字。如果需要,我们可以使用这个持续轮询的 future 来跟踪成千上万的套接字。每个套接字可以有一个对应的 future,并将成千上万个 futures 启动到我们的运行时中。现在我们已经定义了 TcpListener 的逻辑,接下来可以处理客户端逻辑,通过套接字向我们的 future 发送数据。

通过套接字发送数据

对于我们的客户端,我们将在主函数中运行所有操作:

fn main() -> Result<(), Box<dyn Error>> {
    Runtime::new().with_low_num(2).with_high_num(4).run();
    . . .
    Ok(())
}

在主函数中,我们首先创建我们的监听器和客户端的流:

let addr = "127.0.0.1:13265".parse()?;
let mut server = TcpListener::bind(addr)?;
let mut stream = TcpStream::connect(server.local_addr()?)?;

我们的示例只需要一个流,但如果需要,可以创建多个流。我们通过 mio poll 注册我们的服务器,并使用服务器和 poll 来启动监听器任务:

let poll: MioPoll = MioPoll::new()?;
poll.registry()
    .register(&mut server, SERVER, Interest::READABLE)?;

let server_worker = ServerFuture{
    server,
    poll,
};
let test = spawn_task!(server_worker);

现在我们的任务正在不断地轮询 TCP 端口,等待传入的事件。接下来,我们创建另一个 poll,使用 CLIENT token 来处理可写事件。如果套接字没有满,我们就可以写入。如果套接字已满,它就不再可写,需要刷新。我们的客户端 poll 如下:

let mut client_poll: MioPoll = MioPoll::new()?;
client_poll.registry()
    .register(&mut stream, CLIENT, Interest::WRITABLE)?;

注意

使用 mio,我们还可以创建触发条件,当套接字可读或可写时触发:

.register(
    &mut server,
    SERVER,
    Interest::READABLE | Interest::WRITABLE
)?;

创建了 poll 之后,我们可以等待套接字变为可写,然后再写入它。我们使用以下的 poll 调用:

let mut events = Events::with_capacity(128);

let _ = client_poll.poll(
    &mut events,
    None
).unwrap();

请注意,None 表示没有超时。这意味着当前线程会被阻塞,直到 poll 调用返回事件。一旦有事件返回,我们就将一个简单的消息发送到套接字:

for event in events.iter() {
    if event.token() == CLIENT && event.is_writable() {
        let message = "that's so dingo!\n";
        let _ = stream.write_all(message.as_bytes());
    }
}

消息已经发送,因此我们可以阻塞线程,然后打印消息:

let outcome = future::block_on(test);
println!("outcome: {}", outcome);

运行代码时,可能会看到如下输出:

Error reading from stream: Resource temporarily unavailable (os error 35)
outcome: that's so dingo!

代码是正常工作的,但我们收到了一开始的错误。这可能是由于非阻塞 TCP 监听器导致的;mio 是非阻塞的。Resource temporarily unavailable 错误通常是因为套接字中没有可用数据。这种情况可能发生在 TCP 流创建时,但这并不是什么问题,因为我们在循环中处理这些错误,并返回 Pending,这样套接字会继续被轮询,如下所示:

use std::io::ErrorKind;
. . .
Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
    waker.cx.waker().wake_by_ref();
    return Poll::Pending;
}

注意

通过 mio 的轮询特性,我们实现了通过 TCP 套接字进行异步通信。我们还可以使用 mio 通过 UnixDatagram 在进程之间发送数据。UnixDatagram 是只能在同一台机器上发送数据的套接字。由于这一限制,UnixDatagram 更快、需要更少的上下文切换,并且不需要经过网络栈。

总结

我们终于让我们的异步运行时做了一些除了 sleepprint 之外的事情。在本章中,我们探索了 traits 如何帮助我们将第三方 crates 集成到我们的运行时中,并且我们通过 mio 实现了轮询 TCP 套接字。当涉及到让自定义异步运行时运行时,只要你能够访问 trait 文档,其他的障碍就不再是问题。如果你想更好地掌握到目前为止学到的异步编程知识,你已经能够创建一个基本的 Web 服务器,处理各种端点。尽管在 mio 中实现所有的通信会很困难,但仅仅将其用于异步编程要容易得多。hyperHttpListener 将涵盖协议的复杂性,让你可以专注于将请求作为异步任务传递,并处理响应。

在本书的学习旅程中,我们专注于异步编程,而非 Web 编程。因此,我们将继续深入探讨如何利用异步编程解决具体问题。我们将在第 5 章开始讨论协程。