#coroutine #stackless #concurrency #state-machine #no-alloc

无std cocoro

对Rust无栈协程更类型安全的实现

1个不稳定版本

0.1.0 2024年5月15日

#621 in 异步

自定义许可

75KB
842

cocoro crate 展示了与 coroutine_trait 功能保护的 std::ops::Coroutine 不同的Rust协程方法。

协程是一个状态机,可以逐个消耗“输入”值,并且每次“产出”一个值或“返回”一个最终值。协程可以任意多次产出,但只能返回一次。

在这个crate中,核心协程特性看起来像这样

pub trait SuspendedVisitor<Y, R, I, N>
where
    N: Coro<Y, R, I>,
{
    type Out;
    fn on_yield(self, y: Y, next: N) -> Self::Out;
    fn on_return(self, r: R) -> Self::Out;
}

pub trait Suspended<Y, R, I> {
    type Next: Coro<Y, R, I>;
    fn visit<X>(
        self,
        visitor: impl SuspendedVisitor<Y, R, I, Self::Next, Out = X>,
    ) -> X;
}

pub trait Coro<Y, R, I = ()>: Sized {
    type Next: Coro<Y, R, I>;
    type Suspend: Suspended<Y, R, I, Next = Self::Next>;
    fn resume(self, input: I) -> Self::Suspend;
}

注意以下与 std::ops::Coroutine 的区别

  • resume 方法以值的形式接受 self,而不是以固定独占引用的形式。
  • "产出"和"返回"的类型是泛型参数,而不是关联类型。
  • resume 方法返回一个包装协程状态的 Suspend 类型,该类型为“访问者”提供一个可以再次恢复的协程句柄。

Suspended 特性可以被视为枚举的抽象。实际上,许多协程实现将使用crate提供的以下枚举

pub enum Suspend<Y, R, N> {
    Yield(Y, N),
    Return(R),
}

YieldReturn 变体被导入到crate的根命名空间中,因此它们可以在不使用 Suspend:: 前缀的情况下使用。

此外,Coro 特性提供了一些默认的组合器,这些组合器对于使用 Iterator 的人来说应该很熟悉,例如

  • map_yield 使用 FnMut 转换产出的值
  • map_return 使用 FnOnce 转换返回值
  • flatten 将返回另一个协程的协程简化为单个协程

此crate不尝试使用花哨的宏或代码转换,让您能够像编写过程函数一样编写协程,就像coroutine_trait功能所做的那样。相反,它设计为函数式风格,其中生成的元素和返回的元素通过组合子管道进行转换,强调类型安全。

示例

一个基本的计数器

这是一个产生连续整数且永不返回的协程

use cocoro::{Coro, Suspended, Yielded};

struct Counter(i32);
impl Coro<i32, (), ()> for Counter {
    type Next = Self;
    type Suspend = Yielded<i32, Self>;
    fn resume(self, _: ()) -> Self::Suspend {
        Yielded(self.0, Counter(self.0 + 1))
    }
}

let mut counter = Counter(0);
for _ in 0..10 {
    let Yielded(n, next) = counter.resume(());
    println!("{}", n);
    counter = next;
}

请注意,Counter结构是不可变的,并且通过构造一个新的Counter实例从resume方法返回下一个状态。

因为Next关联类型是Self,所以我们能够就地修改counter变量。然而,在下一个示例中,我们将看到一个产生具有与其自身不同类型的状态的协程。

此外,我们可以无可争议地匹配Yielded结构,因为协程的Suspend关联类型在编译时是已知的,并且对用户是透明的。更普遍地说,当一个协程的Suspend关联类型在编译时未知(例如,因为它没有由函数参数上的界限或由协程来源的函数的返回类型上的impl返回类型约束)时,Suspended特质提供了一个visit方法,该方法可以转换为Suspend枚举以进行模式匹配,或直接使用SuspendedVisitor进行访问。

更常见的是使用辅助函数和组合子来创建协程,而不是直接实现Coro特质。

使用yield_with函数,我们可以使用闭包完成与上面相同的事情

use cocoro::{yield_with, Coro, Void, Yield};
let mut i = 0;
let _: Option<Void> = yield_with(|()| {
    i += 1;
    i
})
.take(10)
.for_each(|n| {
    println!("{}", n);
});

静态大小的倒计时

use cocoro::{Coro, Returned, Suspend, Suspended, Void, Yielded};

struct Three;
struct Two;
struct One;
#[derive(Debug, PartialEq, Eq)]
struct Blastoff;

impl Coro<i32, Blastoff, ()> for Three {
    type Next = Two;
    type Suspend = Yielded<i32, Self::Next>;
    fn resume(self, _: ()) -> Self::Suspend {
        Yielded(3, Two)
    }
}

impl Coro<i32, Blastoff, ()> for Two {
    type Next = One;
    type Suspend = Yielded<i32, Self::Next>;
    fn resume(self, _: ()) -> Self::Suspend {
        Yielded(2, One)
    }
}

impl Coro<i32, Blastoff, ()> for One {
    type Next = Blastoff;
    type Suspend = Yielded<i32, Self::Next>;
    fn resume(self, _: ()) -> Self::Suspend {
        Yielded(1, Blastoff)
    }
}

impl Coro<i32, Blastoff, ()> for Blastoff {
    type Next = Void;
    type Suspend = Returned<Blastoff>;
    fn resume(self, _: ()) -> Self::Suspend {
        Returned(Blastoff)
    }
}

let countdown = Three;
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Returned(blastoff) = countdown.resume(());
println!("{:?}!", blastoff);

这展示了如何使用Next关联类型将不同类型的协程链接在一起,只要它们都具有相同的输入和输出类型(YRI)。

Suspended上的as_yieldas_return辅助方法提供了方便地获取产生值或返回值Option

注意的另一件事:可以使用Void协程作为Next类型来静态地表示协程不会再次产生。因为Void类型是不可实例化的,所以不可能从其Next类型为Void的协程中产生。

可以使用闭包编写相同的示例

use cocoro::{from_fn, Coro, Returned, Suspended, Void, Yielded};
#[derive(Debug, PartialEq, Eq)]
struct Blastoff;
#[rustfmt::skip]
let countdown = from_fn(|_| {
    Yielded(3, from_fn(|_| {
    Yielded(2, from_fn(|_| {
    Yielded(1, from_fn(|_| {
    Returned(Blastoff) })) })) }))
});
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Yielded(n, countdown) = countdown.resume(());
println!("{}", n);
let Returned(blastoff) = countdown.resume(());
println!("{:?}!", blastoff);

这更加紧凑,但按照rustfmt想要的格式化方式看起来可能非常难以接近。尽管如此,这个例子表明您可以使用普通闭包定义具有静态、类型安全状态机的协程,并证明在编译时通过无可争议的模式匹配保留了这个状态信息。

通常情况下,您会在from_fn协程中使用枚举Suspend,而不是YieldedReturned结构体,特别是在协程使用运行时信息来决定是产生还是返回时。

理论

这个crate的主要动机是展示Rust类型系统如何提供编译时构建的正确性保证,即标准库协程或其他状态机(如IteratorFuture)通过合同暗示但无法在编译时强制执行:它们在返回后永远不会再次产生。大多数设计选择都源于此,包括最终强调函数式组合子,这对于添加从过程gen块中无法获得的缺失表达能力非常有帮助。gen块是Pin的原因,如果在cocoro协程中使用,将强制执行一个resume方法来可变借用(固定)引用,而不是通过值接受self,这是“可能消费”协程的唯一方法,在产生时将其传回调用者,在返回时丢弃它。

因为cocoro协程是用组合子和手动impl实现的,而不是用gen块或变换代码的宏(如宏)这样的语法糖,所以该特性被设计成可以与其他特性互操作,并使用函数式编程模式,使它们与过程式对应的表达式一样丰富。

cocoro协程是对产生类型和返回类型的函子。组合子map_yieldmap_return对应于将协程作为函子进行理论上的map操作。

协程也是输入类型的反变函子,因此您可以使用一个函数contramap_input进行contramap_input,该函数接受不同的输入类型并返回原始输入类型,并获得一个使用contramap函数的输入类型作为其输入类型的新的协程。

组合子flatten对应于返回类型上的单子上的join操作。有了它和map_return,可以实现组合子flat_map,对应于单子上的bind操作。

为了完成单子公理,使用return操作与JustReturn包装结构体实现,这是一个可以接受任何作为输入的协程,永远不会产生,并始终返回它所构建的值的协程。这两个结构体和flat_map组合子共同遵守返回类型上的函子的单子定律。

在生成的类型上没有针对函子实现的单子,但可以将 just_yield 结构体视为对生成的类型上的 应用函子pure 操作。同时,zip() 组合子是一个应用函子 lifta2 函数可以派生的操作。

将协程的操作形式化为成熟的函数式编程概念,是表明 cocoro 协程在理论上合理,并且可以用功能程序员熟悉的各种方式进行使用的一种方式。这包括熟悉具有类似特性的其他类型,如 IteratorResult 的 Rust 程序员。

常见问题解答

为什么叫 cocoro 呢?

我更多的是想到一个双关语:“coro”是“coroutine”的缩写,而“co”作为前缀表示“一起”或“与”,以表明其互补性和可能对 Rust 标准库协程的从属关系。作为双关语的另一层,"cocoro" 听起来像“kokoro”,这是日语中“心”的意思,附带所有与思维、精神和核心相关的联想。

我还隐约地指向了“co-”作为数学双关语前缀的想法,特别是在范畴论的情况下。尽管协程在严格意义上不是常规的范畴对偶,但人们可以构想“co-routine”作为“routine”的对偶,因此“co-coroutine”是某种……常规的东西。

但这个名字恰好可以在 crates.io 上使用。

为什么 resume() 方法消耗 self

std::ops::Coroutine 特性中,resume 方法以 Pin<&mut Self> 作为接收器类型。这样做是为了能够编写 gen,其中语言会从过程代码中合成状态机。这些状态机可能包括指向块内其他局部变量的局部变量,使得在创建了引用之后,类型是自我引用的不可移动的。为了强制执行这一点,生成器需要一个 Pin<&mut Self> 来确保生成器在内存中“固定”,并且不能被移动。对于更知名且更稳定的 Future 特性也是如此,可以使用 async 块构建自我引用的实现。

cocoro 库不需要支持自我引用的类型,因为它不试图描述 gen 块的状态机。相反,协程是手工编写的或由非挂起组合子和闭包组成的。

好的,但为什么不用 &mut self 作为 resume() 的接收器呢?

这是一个实验,尝试在类型系统中表达在类型合同(如 IteratorFuture,以及是的,std::ops::Coroutine)中隐含的东西。合同规定,一旦你到达迭代器/未来/coroutine的“末尾”(即它返回 None/Ready/Complete),就不能再次调用 next()/poll()/resume()。一些实用函数,如 Iterator::fuse(),试图添加运行时安全层,以更具体地定义违反合同时会发生什么。

但是 cocoro 选择了一种不同的方法:在编译时强制执行此合同。方法 resume() 消耗 self 并返回一个只能通过 Yield 获取协程的 Suspended 枚举。这使得在 Return 之后无法再次调用 resume(),因为 Return 变体不包含要恢复的协程,并且调用 resume() 已消耗了协程。

std::ops::Coroutine 相比,性能如何?

cocoro 协程被设计得尽可能轻量。但 std::ops::Coroutine 协程也是如此,而且是由那些在编写高性能 Rust 代码方面更加投入和经验丰富的人所写。

cocoro 协程是“无栈”的,并且避免分配,就像 std::ops::Coroutine 协程一样。cocoro 协程的一个潜在好处是,标准库协程被转换为可能表示为 Rust 枚举的状态机(见不稳定手册),这意味着状态机的每个迭代都可能包含一个分支。另一方面,cocoro 协程支持静态上已知将确定性推进到特定状态的协程,正如在 just_yield()just_return() 函数中看到的那样。这样的协程消除了这些点的分支,这可能导致代码更快。

然而,当 cocoro 协程恢复和暂停时,它们会在其状态表示的数据周围移动,这与必须固定在内存中的标准库协程相反。这意味着在 cocoro 协程生成的代码中可能有复制操作,而在标准库协程生成的代码中没有。我通常预计编译器会优化这些复制操作,但我还没有进行任何基准测试来确认这一点。

许多基本的 cocoro 组合子是尾递归的,可以被编译器优化为循环,但Rust并不保证在一般情况下进行尾调用优化。

为什么返回一个关联类型而不是一个 Suspend 枚举呢?

这是允许 cocoro 协程选择静态确定性状态机的秘密配方。协程的 Next 关联类型是协程的下一个状态类型,如果每个协程都必须返回一个枚举,编译器将不得不在匹配结果时在该枚举的标记上插入一个检查。

特别是,这允许 just_yield() 协程返回一个 Yielded 结构体而不是 Suspend 枚举的 Yield 变体,并且 just_return() 协程返回一个 Returned 结构体而不是 Suspend 枚举的 Return 变体。这三种类型 SuspendYieldedReturn 都实现了 Suspended 特性,所以它们都是根据运行时是否已知的分支返回协程的有效选项。

为什么产生和返回类型是泛型类型参数?

在Rust中,一个特质的“输入”类型是泛型类型,“输出”类型是关联类型,如 FnOnce 特质所示。这种特质可以泛型化输入类型,即对任意许多可能的输入类型实现特质是重要的。这就是允许 Fn 系列特质与引用类型一起工作,这些实际上是泛型化省略了生命周期参数的高阶类型。

特质 Coro 不仅泛型化输入类型,还泛型化产生和返回类型。这是为了允许实现 Coro 的类型为任意许多输入、产生和返回类型实现特质。例如,忽略其输入的协程可以泛型化所有 I 输入类型,永不产生的协程可以泛型化所有 Y 产生类型,永不返回的协程可以泛型化所有 R 返回类型。

当您使用 just_yield() 创建协程时,可以将该协程注入任何接受任何返回类型协程的函数,或从使用 impl Coro 返回类型语法的函数返回它。

这意味着在某些地方需要提供类型注解,如果没有 YieldReturn 关联类型,则不需要。您会在示例中经常看到 .yields::<Void>().returns::<Void>(),以提供单个函数体内代码段所需的类型注解。但在实际代码中,您很可能会通过具有 impl Coro 边界(指定预期的产生、返回和输入类型)的函数返回或消耗协程,这些边界引导类型检查器从无限多种实现中选择一个特定的 Coro 特性实现。

依赖关系

约45KB