You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
async fn/block 要支持跨 await point 的引用,但这会导致自引用结构,用 move 实现状态转换的话可能会出现野指针,所以状态机也需要解决这个问题。
只有跨 yield point 的变量才需要保存在状态机里,也就是在某 yield point 之前创建,但在它之后还会使用的变量,其它的保存在栈上就行。为了解决状态转换时的 move 开销和自引用结构的安全问题,Rust 会为每个变量分配固定的空间,变量可以被不同状态使用,而不会发生 move。为了避免状态机大小膨胀,新的变量会复用不再使用的变量的空间,需要注意的是,实现了 Drop 的变量会被保存到最后才被释放,可以用 block 提前 drop 来避免状态机变大。
Concretely, for pinned data you have to maintain the invariant that its memory will not get invalidated or repurposed from the moment it gets pinned until when drop is called. Only once drop returns or panics, the memory may be reused.
Rust 异步编程稳定蛮久了,但我只偶尔用一下,对其中许多概念都不清楚,就学习下。
基本概念
按我之前其他语言的经验,异步编程核心是 event loop,基本模型都是单个 / 多个线程 / 进程阻塞在
epoll
这种 I/O 多路复用的系统调用,有事件就绪一般就在当前线程直接处理了,长时间的工作会扔到单独的线程池里执行,防止阻塞 event loop。Rust 把这一整套模型拆成了多个部分:如果以 one loop per thread + thread pool 模型来对比的话,event loop 线程既是 Executor 也是 Reactor;Waker 就是通过
epoll_wait
返回的事件找到对应的 Task 并执行;每个套接字都是 Future,比如监听套接字的 Future 逻辑是接收新连接再 spawn 到 Executor,每个连接的 Future 逻辑是处理请求再返回响应;thread pool 也是 Executor,每个长时间执行的任务也是 Future。Waker 使得 Executor 和 Future、Reactor 解耦不再绑定,就可以灵活组合不同的实现。async/.await
刚开始用 Future 的时候还是 combinator-based,各种 callback、clone,体验极差,async/.await 稳定后体验就好多了。async fn/block 返回的 Future 是用
Generator
实现的,内部实现为状态机,await point 就对应 yield point,不过是根据Future::poll
的结果来决定要不要 yield。为了实现 zero-cost,状态机的实现至关重要:enum
来实现,能够解决大小问题,但如果有些数据存活于状态机的整个生命周期,在状态转换时就会频繁 move,这部分的开销不能忽视。只有跨 yield point 的变量才需要保存在状态机里,也就是在某 yield point 之前创建,但在它之后还会使用的变量,其它的保存在栈上就行。为了解决状态转换时的 move 开销和自引用结构的安全问题,Rust 会为每个变量分配固定的空间,变量可以被不同状态使用,而不会发生 move。为了避免状态机大小膨胀,新的变量会复用不再使用的变量的空间,需要注意的是,实现了
Drop
的变量会被保存到最后才被释放,可以用 block 提前 drop 来避免状态机变大。Pin
async fn/block 创建的 Future 通常会是自引用结构,编译器只能保证它生成的代码不会有内存安全问题,但用户代码有可能会 move Future 导致内存不安全。move 自引用结构导致内存不安全的原因是新引用仍然指向了旧地址,如果把自引用结构的地址固定下来的话,比如用
Box
,move 就不会有问题,因为 moveBox
只是 move 指针而不是指针指向的内容,但是用户能通过Box<T>
获取&mut T
,再用mem::replace/swap
之类的方法或者*Box
还是能够 move 自引用结构,并不能完全解决该问题,而且Box
会带来额外开销,不符合 zero-cost。为了解决这个问题,Rust 通过类型系统 (Pin
+Unpin
) 杜绝了在 safe Rust 中 move 导致自引用结构内存不安全的可能,完整的Future
trait 实现如下:不是所有类型都需要防止 move,只有
T: !Unpin
才需要,对于T: Unpin
类型而言,Pin
是零开销的,等价于&mut T
。几乎所有类型都是Unpin
,想实现!Unpin
可以用PhantomPinned
,async fn/block
创建的 Future 均是!Unpin
,但只有调用过Future::poll
后才需要 pin 住它,因为 Generator 是惰性的,在初始状态时不可能是自引用结构。Pin
不是不允许 move,poll 过的 Future 也可以 move,比如在支持 work-stealing 的 Executor 里,很有可能会发生 poll 过的 Future 在线程间 move 的情况。Pin
想要保证的是:Pin
也是种智能指针,movePin
不会有问题,但无法在 safe Rust 里通过Pin<T: !Unpin>
获取&mut T
,只能通过Pin::get_unchecked_mut
,这是 unsafe 的,不在编译器保障之内,需要用户来保证安全性。在 safe Rust 里构造Pin<T: !Unpin>
需要用Box::pin
(注意,Pin
关注的是P::Target
是不是Unpin
而不是指针),也就是 pin 在堆上,安全的原因是 pinned 对象地址已经固定了,safe Rust 里已经不会再有内存安全问题,但这不是 zero-cost(Executor 要执行不同的 Future 就要用 trait object,这本身就需要Box
,所以这点通常不会带来额外开销),所以 Rust 也提供了 unsafe 的Pin::new_unchecked
,能通过引用 pin 在栈上,不安全的原因是还可以 move 原先的变量,所以一般会用创建出来的Pin<T>
把原先变量 shadow 掉,而且对象地址和栈帧绑定,当从当前栈帧 move 出去时也会有安全问题。Waker
Executor 执行 Future 传入的 Waker 是和 root-Future(Task) 绑定的,中间的 Future 只需要把 Waker 不断往下传直到 leaf-Future,因为只有 leaf-Future 才会阻塞,才需要注册 Waker 到 Reactor。因为 Task 可能会在线程间移动,poll 相同的 Task 可能会传入不同的 Waker,所以每次 poll 时都需要重新注册 Waker 到 Reactor,而 Reactor 可能会和 Executor 在不同的线程,更新 Waker 会有很多 race,比如在更新 Waker 时,Reactor 用旧 Waker 唤醒了,这时就可以用
AtomicWaker
,它高效妥善地处理的相关逻辑,既能解决 race,还能提供 happend-before 关系。Waker(
RawWaker
) 是用 vtable 实现的,而不是用 trait object,可能有这几个原因:Clone
,这就不符合 object-safety 了,就不能用 trait object,只能用 vtable 来实现动态分发。Waker 通知 Executor 执行 Future 的实现和 Executor 如何管理 Task 有关,比如:
Arc
封装,wake 直接发送Arc<Task>
就行。因为这种实现比较常见,而创建 Waker 又比较麻烦,所以future-rs
提供了ArcWake
,只要给Arc<Task>
实现该 trait 就能作为 Waker 使用。async-global-executor
async-global-executor
是async-std
的 Runtime 实现,主要用到了smol
的几个 subcrates:async-task
:Task 的抽象,用于实现 Executor。async-executor
:基于async-task
实现的 Executor。async-io
:async I/O 组件和 Reactor 的实现。async-task
Task 是 stateful Future,
async-task
提供了通用的 Task 实现,用起来很简单:Runnable
封装了future
,隐藏了 Waker 和 poll Future 的实现,只要调用Runnable::run
即可。Waker 的实现和 Executor 如何管理 Task 有关,所以需要 Executor 提供schedule
,Waker 唤醒就是调用该方法,通常会实现为把Runnable
发送到 task queue 里。Task<F::Output>
是用于获取Runnable
结果的 Future。async-task
解决了以下几个问题:内存分配:Executor 需要用类似
Box<dyn Future>
方式保存不同类型的 Future,所以至少需要一次内存分配。async-task
为每个 Task 分配了一块连续的内存,保存了所有信息,Runnable
、Waker
和Task
只需要保存这块内存的指针即可,只需要一次内存分配。因为 Future 和它的结果不会同时存在,所以会共用内存。Future 结果获取:有些 Runtime 只支持
Future::Output = ()
的 Future,需要调用方自己用futures::channel::oneshot
之类的发送结果,async-task
内置了Task<F::Output>
用于获取结果,不需要额外的内存分配。Task 生命周期和状态管理:
Runnable
、Waker
和Task
共享了一块内存且各属于不同的部分,可能发生很多竞争问题,比如 Task 已经在 task queue 里了又被 Waker 唤醒了,可能会导致 poll 已经完成的 Future 或者不同的线程同时 poll 了相同的 Future;如果实现为 Task 正在 poll 就不会唤醒,那又有可能丢失唤醒,导致 Task 永远不会完成;获取 Future 结果时也可能出现丢失唤醒的情况,比如 Future 在Task
检查结果和注册 Waker 之间完成了。async-task
使用state
字段记录了 Task 当前状态和引用数量,每次状态转换后都会检查可能发生竞态条件的状态,比如说当 Waker 唤醒时发现 Future 处于RUNNING
状态 (正在被 poll),就只会设置SCHEDULED
状态,不会调用schedule
函数;当 Future poll 执行完成后发现设置了SCHEDULED
,就会调用schedule
函数。async-executor
async-executor
是基于async-task
实现的 Executor 组件库,它提供了 work-stealing multi-threadedExecutor
和 thread-localLocalExecutor
,LocalExecutor
基于Executor
实现,通过类型系统限制了 Future 只能在单个线程运行。async-executor
不是开箱即用的,需要每个 Executor 线程都 block_on 在Executor::run
,各 Runtime 可以基于它实现符合自己特点的 Executor。Executor 实现的基本模型就是不断运行 task queue 里的 Task,spawn 和 wake 是往 queue 里扔 Task。
对于多线程 Executor,有多种实现选择,比如:
async-executor
的 work-stealing 实现如下:queue: ConcurrentQueue<Runnable>
, Task 直接扔到 global queue 中;每个 block_on 在Executor::run
的线程 (Runner
) 各有一个 local queue,会加入到local_queues: RwLock<Vec<Arc<ConcurrentQueue<Runnable>>>>
来实现 work-stealing。Runner
按照local -> global -> random sibling
的顺序处理 Task,一次性会偷其他队列里一半的 Task。Runner
,但同一时间只会有一个Runner
处于 notified 状态,该状态的Runner
会从其他 queue 里偷任务,发现 Task 后会变为 woken 状态再唤醒下一个Runner
,所以当 Task 很多时会一个个唤醒Runner
而不是立刻唤醒所有的。以上图为例,global queue 里有多个 Task,但只唤醒了一个
Runner
。第一个Runner
从 global queue 里偷了 4 个 Task 后唤醒下一个,下一个偷了 2 个后再唤醒下一个,最后一个发现 global queue 为空时,从第一个的 local queue 中偷了一半 Task。对于任务无优先级且执行都很快的 Executor 来说,评价标准大概就是性能和调度公平性,性能包括吞吐和单个任务的延迟,调度公平性决定 .50 .99 max 延迟的差距,负载均衡也包含在这两点里了。
async-executor
这种每次只唤醒一个Runner
的方式能减少竞争,对 cache 友好,从而提高性能,各个Runner
也会在不断 steal 的过程中实现负载均衡。假如负载完全均衡的话,很可能很多Runner
同时消耗完了 local queue 里的任务,这时都会去其他 queue 里偷任务,对于这种情况下的竞争,async-executor
会随机选择 sibling queue 为起点来减少 steal 的竞争,但无法减少 global queue 上的竞争,一种优化方式是限制同时 steal 的Runner
个数或者限制在单个 queue 上 steal 的个数。至于调度公平性,最理想的当然是 FIFO,async-executor
有可能会出现后到的请求被先处理的情况,因为一次偷一个 batch,且 steal 顺序是先 global 再 sibling,后唤醒的Runner
会先处理 global queue 里后到的 Task。不过 local queue 容量上限是 512,一次也偷不了多少,而且 work-stealing 也能缓解这种问题,所以应该还好。如果真要优化的话,还可以在减小 batch、调整 steal 顺序上做文章,这需要压测来验证效果。async-io
async-io
提供了异步 I/O 和 Timer 的实现。实现异步 I/O 的策略比较特殊,不是为标准库中各种类型的同步 I/O 接口都提供对应的异步版本,这样工作量太大也难以维护,而是提供了异步 adapterAsync
,它可以封装任意支持 non-blocking I/O 的类型,并实现了异步 I/O 最基础的功能,包括设置为 non-blocking、readable/writable 通知,再搭配上read_with
/write_with
方法就可以为各种同步 I/O 接口实现对应的异步版本。对于异步 I/O 来说,只有Future
trait 接口的话用起来非常不方便,而且异步 I/O 需求是非常普遍的,所以也需要有类似标准库中的Read
/Write
trait,有了标准的 trait 后就能实现各种工具集了,但是目前并没有统一的AsyncRead
/AsyncWrite
trait,tokio
和futures
分别提供了自己的 trait 和工具集,有一定的割裂。Async
实现的是futures
的AsyncRead
/AsyncWrite
,所以可以用futures
提供的工具集AsyncReadExt
/AsyncWriteExt
。Async
实现很简单,就是不断读 / 写直到返回EWOULDBLOCK
,然后异步等待可读 / 可写,根据接口的不同可实现为 .await 如果不是 leaf-future 或者 poll 如果是的话。Reactor
实现也很简单,Linux 下面用的是 epoll + LT + oneshot,它是 lazy-init 的,第一次用到时会创建一个async-io
线程来驱动它,也就是调用epoll_wait
之类的等待事件就绪。创建Async
时会把 fd 添加 (EPOLL_CTL_ADD
) 到兴趣列表里,阻塞时就会关注 (EPOLL_CTL_MOD
) 对应的事件并把 Waker 保存到Reactor
里,事件就绪时就会用 Waker 来唤醒对应的 Future。Timer 实现也很简单,用BTreeMap
保存所有 Timer 触发的时间和对应的 Waker,最近的 Timer 触发时间就是epoll_wait
的超时时间。Reactor
除了可以由async-io
驱动,还可以用block_on
,block_on
其实就是只支持单个 Future 的 Executor,不过async-io
提供的会在传入的 Future 阻塞时来处理 I/O 事件,不过每个进程只有一个Reactor
,用了锁也只有一个线程能调用epoll_wait
,不清楚为啥会这样设计。async-io
为了更通用,用的是 LT + oneshot,epoll_ctl
的开销不会小。虽然只有一个 event loop 线程,但只用来等待 I/O 事件就绪和调用 Waker 唤醒 Future,单线程应该也可以支撑很高的并发,相比多个 event loop 线程且事件就绪时直接调用对应的 callback 的方式,感觉async-io
的方式性能会差一点,毕竟是用 Waker 通知 Executor 执行 Future,而不是直接执行 Future。不过确实抽象的更好,Reactor 只做等待和通知的工作,而 event loop 既是 Reactor 也是 Executor。async-global-executor
async-global-executor
默认由async-io
和async-executor
构建,实现就是多个线程async_io::block_on
在Executor::run
上,每个线程会跑 2 个 Executor:一个是 globalExecutor
,一个是LocalExecutor
,从而支持spawn
和spawn_local
。除此之外还提供了spawn_blocking
,blocking
也是用async-task
实现的 Runtime,功能是处理长时间执行的任务防止阻塞 Executor,实现就是提供 async 接口的线程池。总结
Rust 异步编程概念很多,我感觉最有意思的设计是 Waker,它连接了 Executor 和 Future、Reactor。关于 Rust async 优缺点的讨论有很多,即使 Rust 提供了 async/.await,但相比阻塞式编程而言,还是非常困难和容易出错的,而且 async 是传染性的。async 带来的性能提升随着操作系统的不断优化也越来越小了,现在线程的 context swtich 开销已经很小了,而 async 要实现的非常好才能不影响性能。
context-switch
对比了 Rust async 和 Linux thread 的 context switch 和 memory 开销,这里、这还有sled
作者关于 async 的讨论。https://youjiali1995.github.io/rust/async/
The text was updated successfully, but these errors were encountered: