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写得越来越顺,程序跑得又快又稳。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录