Rust的异步编程这几年发展很快。2019年async/await语法稳定后,越来越多项目开始用Rust写异步服务。但Rust的异步和其他语言很不一样,生命周期、trait、Pin、Future这些概念混在一起,初学者很容易懵,编译器报错也经常看不懂。

我用Rust写异步项目一年多了,做过网络服务、数据处理工具,也踩了很多坑。从最开始被生命周期和Pin折磨,到后来慢慢理解Rust异步的设计思路,走了不少弯路。今天分享Rust异步编程的实战经验,从基础概念、运行时选择、实战代码到常见坑,希望能帮正在学Rust异步的朋友少走弯路。

以下内容基于Rust 1.56和Tokio 1.x,不同版本API可能有差异。

一、为什么要用Rust写异步

在说怎么写之前,先说说为什么要用Rust写异步,它和其他语言的异步有什么不同。

Rust异步的优势主要有几个:

第一,性能高。Rust是系统级语言,没有GC,内存占用小,CPU效率高。异步运行时比如Tokio性能非常好,能支撑高并发网络服务,和Go、Node.js比有明显优势。

第二,内存安全。Rust的所有权和生命周期系统在编译期保证内存安全,没有数据竞争。异步代码中最容易出问题的并发安全,Rust在编译期就能检查出来,不用像C++那样小心调试。

第三,零成本抽象。Rust的异步是基于Future trait的,不需要每个任务都有一个线程栈,内存开销小。大量并发任务时内存占用比线程模型低很多。

第四,生态成熟。Tokio、async-std这些运行时已经很成熟,hyper、reqwest、sqlx等库都支持异步,做网络服务和工具很方便。

但Rust异步也有劣势:学习曲线陡,概念多,编译器报错难懂,开发效率比Go、Python这些语言低。如果团队不熟悉Rust,开发成本会比较高。

所以要不要用Rust写异步,要看场景。如果是高性能网络服务、对内存和CPU敏感的工具,Rust异步很合适。如果是普通的业务系统,开发效率更重要,可能Go、Java更合适。

二、Rust异步的核心概念

Rust异步有几个核心概念,理解了这些才能写好异步代码。

Future trait

Future是Rust异步的核心,代表一个尚未完成的计算。它的定义大概是这样的:

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

Future有一个poll方法,运行时调用poll来推进Future的执行。poll返回Poll::Ready表示完成了,返回Poll::Pending表示还没完成,等事件来了再poll。

和其他语言不同,Rust的Future是惰性的,你创建一个Future它不会自动执行,只有被await或者交给运行时spawn才会开始执行。这一点很重要,很多初学者写了async函数调用但没await,发现什么都没发生,就是这个原因。

async/await语法

async fn会返回一个实现了Future的类型,函数体里的代码会被编译器编译成状态机。await会暂停当前Future的执行,等被await的Future完成后再继续。

async fn fetch_data() -> String {
    let response = reqwest::get("https://example.com").await.unwrap();
    response.text().await.unwrap()
}

这个函数返回impl Future<Output=String>,调用它不会执行,需要await或者spawn。

Pin

Pin是Rust异步里最让人困惑的概念之一。简单说,Pin保证一个值不会被移动,因为异步状态机里可能有自引用结构,移动了指针就失效了。所以Future的poll方法接收的是Pin<&mut Self>。

大部分时候你不需要手动处理Pin,async fn会自动处理。但当你要手动实现Future trait或者处理自引用结构时,就需要理解Pin。初学阶段可以先把Pin当成一个必须加的标记,不用深究其原理,用多了自然就理解了。

运行时(Runtime)

Rust标准库只定义了Future trait,没有提供异步运行时。运行时是第三方库提供的,最流行的是Tokio,其次是async-std。运行时负责调度任务、驱动IO、管理线程池等。

写异步程序必须选一个运行时,不能只用标准库。Tokio是目前最主流的,生态最好,大部分异步库都基于Tokio。

三、运行时选择:Tokio vs async-std

Rust异步运行时主要有两个选择:Tokio和async-std。

Tokio是目前最主流的异步运行时,由Tokio团队维护,发展最早生态最好。大部分异步库比如hyper、reqwest、tonic都基于Tokio。Tokio性能很好,功能丰富,支持多线程运行时、单线程运行时、定时器、同步原语等。

Tokio的特点是功能全、生态好、性能高,但API相对复杂一些,配置项多。适合做生产级的网络服务。

async-std是另一个运行时,目标是提供像标准库一样的异步API,设计更简洁。它把很多标准库的同步API都提供了异步版本,比如asyncstd::fs、asyncstd::net。学习曲线平缓一些。

但async-std生态不如Tokio,很多库只支持Tokio。而且async-std这几年发展慢一些,社区活跃度不如Tokio。

我的建议是:除非有特殊原因,否则选Tokio。生态好意味着你需要的库基本都有,遇到问题也容易找到解决方案。async-std适合个人项目或者学习用,生产项目建议Tokio。

四、实战:写一个简单的异步HTTP服务

下面用Tokio和hyper写一个简单的异步HTTP服务,展示Rust异步的基本用法。

首先在Cargo.toml里添加依赖:

[dependencies]
tokio = { version = "1", features = ["full"] }
hyper = { version = "0.14", features = ["full"] }

然后写服务代码:

use hyper::{Body, Request, Response, Server};
use hyper::service::{make_service_fn, service_fn};
use std::convert::Infallible;
use std::net::SocketAddr;

async fn handle_request(_req: Request<Body>) -> Result<Response<Body>, Infallible> {
    Ok(Response::new(Body::from("Hello, Rust async!")))
}

#[tokio::main]
async fn main() {
    let addr = SocketAddr::from(([127, 0, 0, 1], 3000));

    let make_svc = make_service_fn(|_conn| async {
        Ok::<_, Infallible>(service_fn(handle_request))
    });

    let server = Server::bind(&addr).serve(make_svc);

    println!("Server running on http://{}", addr);

    if let Err(e) = server.await {
        eprintln!("server error: {}", e);
    }
}

这个例子里,#[tokio::main]宏把main函数变成异步入口,自动初始化Tokio运行时。handle_request是异步函数,处理每个请求。hyper的Server是异步的,await它会一直运行直到出错。

这个服务能处理请求,但还比较简单。实际项目中还需要路由、数据库、配置等。路由可以用axum(Tokio团队出品,基于hyper),数据库用sqlx(异步数据库库),配置用config-rs。

五、实战:异步并发和任务调度

Rust异步的一个重要场景是并发执行多个任务。有几种方式:

join! 宏:同时await多个Future,等所有都完成。

use tokio::join;

async fn task1() -> i32 { 1 }
async fn task2() -> i32 { 2 }

#[tokio::main]
async fn main() {
    let (r1, r2) = join!(task1(), task2());
    println!("{} {}", r1, r2);
}

join!会并发执行两个任务,都完成后返回结果。注意join!里的Future是在当前任务里并发执行的,不会开新线程。

spawn:创建一个新的异步任务,在后台执行,返回JoinHandle。

use tokio::spawn;

#[tokio::main]
async fn main() {
    let handle = spawn(async {
        println!("task running");
        42
    });

    let result = handle.await.unwrap();
    println!("{}", result);
}

spawn会把任务交给运行时调度,可以在不同线程上执行。spawn的任务必须是'static的,也就是不能借用外部数据,因为任务可能比外部活得久。这是初学者经常遇到的编译错误。

select! 宏:同时await多个Future,哪个先完成就处理哪个,常用于超时和取消。

use tokio::select;
use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    select! {
        _ = sleep(Duration::from_secs(1)) => {
            println!("timeout");
        }
        _ = some_async_task() => {
            println!("task completed");
        }
    }
}

select!很强大,但也容易踩坑,比如分支里的Future被取消后要保证状态一致。

六、常见的坑和解决方案

Rust异步坑很多,分享几个我踩过的。

坑一:async函数里调用阻塞函数

异步运行时的线程数有限,如果你在async函数里调用阻塞函数(比如std::thread::sleep、同步的文件IO、阻塞的数据库查询),会占用线程,导致其他任务无法执行,整个运行时卡住。

解决方案:阻塞操作要用异步版本,比如tokio::time::sleep代替std::thread::sleep,tokio::fs代替std::fs,异步数据库库代替同步的。如果必须调用阻塞函数,用tokio::task::spawn_blocking,把阻塞操作放到专门的阻塞线程池里。

let result = spawn_blocking(|| {
    // 阻塞操作
    heavy_computation()
}).await.unwrap();

坑二:spawn的生命周期问题

tokio::spawn要求任务是'static的,不能借用外部变量。很多初学者想在spawn里用外部的引用,结果编译报错。

解决方案:把需要的数据clone进任务里,或者用Arc共享。不要试图传引用进去,因为任务可能比外部活得久。

let data = Arc::new(data);
let data_clone = data.clone();
spawn(async move {
    // 使用data_clone
});

坑三:锁的使用

异步代码里不要用std::sync::Mutex,因为它的lock是阻塞的,会占用线程。要用tokio::sync::Mutex,它的lock是异步的,await时不会阻塞线程。

但tokio::sync::Mutex也有讲究:不要在await之间持有锁,因为await可能切换任务,锁还被占着,其他任务拿不到锁,可能死锁或者性能差。尽量缩小锁的范围,或者用tokio::sync::RwLock。

// 不好的写法
let mut guard = mutex.lock().await;
some_async_op().await; // 持有锁跨await
drop(guard);

// 好的写法
{
    let mut guard = mutex.lock().await;
    // 只做同步操作
}
some_async_op().await;

坑四:Future不Send导致无法spawn

tokio::spawn要求任务是Send的,这样才能在不同线程间移动。如果Future里持有不Send的类型(比如Rc、RefCell、裸指针),就无法spawn。

解决方案:用Arc代替Rc,用tokio::sync::Mutex或者RwLock代替RefCell,避免在异步代码里用不Send的类型。

坑五:忘记await

Rust的Future是惰性的,调用async函数不await就不会执行。很多初学者写了async函数调用,忘了加await,结果什么都没发生,也不报错。

解决方案:调用async函数后一定要await,或者用spawn交给运行时执行。编译器会对未使用的Future给warning,注意看warning。

坑六:递归async函数

直接写递归async函数会有问题,因为async fn的大小是递归的,编译器无法确定大小,编译报错。

解决方案:用BoxFuture把Future装箱,或者用循环代替递归。

use futures::future::{BoxFuture, FutureExt};

fn recursive(n: i32) -> BoxFuture<'static, i32> {
    async move {
        if n <= 0 {
            0
        } else {
            recursive(n - 1).await + 1
        }
    }.boxed()
}

七、性能优化建议

Rust异步性能已经很好了,但还有一些优化点。

第一,选择合适的运行时配置。Tokio默认是多线程运行时,线程数等于CPU核数。如果是IO密集型任务,默认配置就好。如果是CPU密集型,可以调整workerthreads。也可以用单线程运行时(currentthread),减少线程切换开销,适合IO密集且任务数不多的场景。

第二,减少clone和Arc的使用。clone数据有开销,Arc的原子操作也有开销。能借用就借用,能移动就移动,减少不必要的clone。但spawn任务时必须clone或Arc,这是必要的。

第三,合理使用缓冲区。网络IO用带缓冲的读写,减少系统调用。Tokio的AsyncReadExt、AsyncWriteExt提供了BufReader、BufWriter。

第四,背压处理。高并发下要处理背压,不要无限制地spawn任务,否则内存会爆。用信号量(Semaphore)限制并发数,或者用channel做流量控制。

第五,避免在热路径上分配。频繁的堆分配会影响性能,尽量复用缓冲区,用bytes crate的Bytes代替Vec<u8>做网络数据传输,零拷贝。

八、写在最后

以上就是Rust异步编程的实战经验。从核心概念、运行时选择、实战代码到常见坑和性能优化,覆盖了入门到进阶的主要内容。

Rust异步确实比其他语言复杂,Pin、生命周期、Send这些概念需要时间理解。但一旦掌握了,你会发现Rust的异步设计很优雅,编译期能帮你抓住很多并发错误,写出来的代码性能高又安全。

学习Rust异步不要急于求成,先从简单的例子开始,理解Future和await的基本用法,再慢慢深入Pin、Send、运行时原理。遇到编译错误不要怕,仔细读错误信息,Rust的编译器错误提示其实很详细,按照提示改一般能解决。

2021年Rust异步生态已经很成熟了,Tokio 1.0稳定后API也稳定了,越来越多公司开始用Rust写高性能服务。如果你对系统编程和高性能感兴趣,Rust异步值得学习。

当然,Rust不是银弹,不是所有项目都适合用Rust。选择语言要根据项目需求和团队能力来。但多学一门语言,多一种思维方式,总是好的。

希望这篇文章能帮正在学Rust异步的朋友少踩坑。如果有问题或者不同的经验,欢迎在评论区交流。祝大家Rust写得越来越顺,程序跑得又快又稳。