无论是线程级还是进程级的并发,开销都很大.

Non-Blocking I/O

在 IO 密集型任务中,大部分线程处于阻塞状态,占用内存和 CPU 资源.所以一个 straightforward 方法就是少开点线程,用少量线程处理大量任务:当 socket 连接暂时没有数据的时候,线程不阻塞等待,而是去处理其他处于 ready 状态的任务.

一种解决办法是,我们反复调用系统轮询,直到 IO 就绪,例如 Linux 的 epoll、多路复用、kqueue 等等.但代价是比较浪费 CPU 资源.

协程

用户态线程(有时称为绿色线程),完全在用户态实现的类线程.

分为有栈协程,无栈协程.(是否发生上下文切换)

异步

异步就是上面所述的 idea.协程的核心想法和异步是差不多的,也是实现异步的一种方式.除了协程外,还包括回调函数、Javascript Promise、Reactive Programming 等等.

Rust 中的异步编程:无栈协程

future 代表了一个可能尚未计算完的值,是实现了 trait Future 的类型.Rust 中与异步相关的两个关键词

  • async 把返回值变成 future
  • await 等待某个 future 计算完成

我们接着来看 Future trait 的具体实现:

trait Future
1
2
3
4
5
6
7
8
9
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

pub enum Poll<T> {
Ready(T),
Pending,
}

它有一个方法 poll() 作用就是尝试推进 Future,如果计算已完成,那么返回 Ready(T);否则没完成,返回 Pending.对于调用者来说,就是不断 poll() 尝试推进 future 直到返回 Ready(T)

对于 async, await 关键字,实际上 Rust 是自动将其转化为了实现了 Futurestruct,所以其实是语法糖.

How Does Async Inform Completion

在异步 IO 时我们提到,当操作真正完成的时候,操作系统通过某种机制来通知应用程序.那么是什么机制呢?这个就和 poll() 方法的 context 参数有关了.

执行器在调用 poll() 的时候,会提供 Context,其中包含一个 waker.当 Future 需要等待的时候,它需要保存这个 waker 并在条件满足的时候调用 waker.wake()

我们以 Rust tokio 为例.tokio 包含两个核心组件:

  • Executor 包含一个就绪队列,执行所有就绪任务,负责调度任务.
  • Reactor 负责监听 IO 时间,驱动 Waker 唤醒

总体流程类似于

  1. 注册 IO 事件。IO 方法检查是否有数据就绪:如果否,就在 Reactor 中注册一个 Interest 并与当前任务的 Waker 关联起来,Reactor 在 Linux 上通过 epoll_ctl 将需要 IO 操作的 fd 告诉 OS.最后返回 Pending
  2. 等待事件Reactor 内部通过 epoll_wait() 阻塞或者轮询,当 fd 就绪时,OS 控制该方法返回.
  3. 触发唤醒
  4. 任务被再次调度

基于完成的异步:io_uring