Future和Rust异步编程

姊妹篇: Rust并发编程

Rust异步系列1 认识Future

futures是官方提供的工具包, Rust异步系列2 Futures工具包(上)

Rust异步系列3 Futures工具包(下)





为啥异步Rust令人头秃

为啥异步Rust令人头秃

这是一篇关于异步Rust的文章,稍微有些旧了。文章主要讨论了异步Rust的一些问题,并且提供了一些见解。虽然内容有些局限,但对于理解异步编程的复杂性,还是有些帮助的。这是视频的链接,如果有兴趣的话,可以去看原文:为啥异步Rust令人头秃

作者在文章中提到,2017年他曾说异步Rust编程是一场灾难和一团糟。到2021年,随着Rust生态系统中越来越多的项目采用异步编程,Rust编程整体也变得更加复杂。异步编程本身存在一些固有的挑战,比如有名的文章《你的函数是什么颜色的》提到了函数之间的各种限制和调用的复杂性。

作者进一步分析了一些Rust设计中的因素,使得异步Rust编程变得更加复杂。他强调,这并不是设计者能力不足的问题,而是Rust的特定设计与异步编程不太兼容。这表明,异步编程在Rust中有一些固有的、难以避免的复杂性。

异步编程的挑战

作者想创建一个简单的函数,它可以在后台执行一些工作,完成后再通过另一个函数通知我们结果。Rust中的函数是一等公民,意味着可以像传递对象一样传递函数。在异步编程中,这种特性看似是优势,但实际上却会带来不少麻烦。

例如,作者想传递一个函数给异步操作 do_work_and_then,这个函数会执行异步任务,并在完成后返回结果。然而,Rust的异步函数会立即返回,并在后台执行任务,而不会阻塞主线程的控制流。这意味着可以在异步任务进行时,主线程继续执行其他操作,比如睡眠两秒。

然而,当作者尝试运行这个程序时,遇到了类型不匹配的问题:Rust期望的是函数指针,但实际传递的是闭包。闭包附带了数据,这导致了编译器无法正确处理。Rust中的闭包类型是编译器自动生成的,不能直接命名,这使得处理变得复杂。

Rust中的闭包

闭包是Rust中一种神奇的存在,它的类型是编译时生成的,无法被命名。Rust通过trait系统来约束闭包的使用,闭包可以实现三种trait:FnFnMutFnOnce。在某些场景下,闭包可能会导致复杂的生命周期和所有权问题。

特别是,当闭包引用了其他对象的数据时,Rust的所有权和生命周期系统会要求开发者确保数据在闭包使用期间不会被销毁或移动。这增加了编程的复杂性,尤其是在异步任务中传递闭包时。

闭包的放射性

作者使用了”放射性”来形容闭包的复杂性:一旦闭包包含引用或可变引用,它会带来一系列复杂的副作用。Rust的借用检查器(borrow checker)会严格检查这些引用,确保它们的生命周期和所有权规则不会被违反。这让闭包的使用变得非常困难。

为了避免这种复杂性,作者建议使用所有权传递(move)来处理闭包。这样,闭包会复制所有必要的数据,而不是引用它们,避免生命周期和所有权问题。然而,这也带来了性能开销。

Rust中的泛型与类型推断

在Rust中,泛型和trait约束是处理闭包的主要手段。如果闭包的类型不能被明确指定,开发者需要使用泛型来约束它的行为。这虽然解决了一些问题,但也增加了代码的复杂性。

在文章中,作者进一步探讨了Rust中泛型的使用,特别是如何通过trait来指定闭包的行为。虽然这种方式提供了灵活性,但处理起来并不简单。

异步泛型编程的挑战

Rust的异步编程模型依赖于Future对象,而这些对象通常包含闭包。由于闭包的复杂性,Rust的异步编程变得更加困难。Rust的编译器会严格检查异步任务中的所有权和生命周期问题,这让开发异步代码变得非常复杂。

虽然Rust的生态系统已经提供了诸如 async 函数和 await 关键字来简化异步编程,但这些工具并没有完全解决异步编程的固有问题。特别是在处理复杂的异步任务时,开发者仍然需要面对大量的类型推断、生命周期管理和所有权问题。

作者的结论

作者最终得出结论,Rust的异步编程虽然强大,但也非常复杂。特别是对于初学者来说,很多细节都非常难以掌握。作者还提到,Rust的编译器虽然非常严格,但这并不能完全解决异步编程的根本问题。

他还提出了一个疑问:是否真的有必要使用异步编程?在某些场景下,使用多进程模型可能是一个更简单的解决方案。而异步编程的复杂性,尤其是在Rust中,可能并不总是值得的。

总结

这篇文章其实讨论了Rust异步编程中的很多常见问题,包括闭包的复杂性、所有权和生命周期的管理、泛型的使用以及异步任务的处理。虽然Rust在性能和安全性方面有很多优势,但其异步编程模型确实存在一些固有的挑战,这让开发者在实际使用中可能会感到非常头疼。

作者最后提到,他仍然喜欢Rust,但在面对这些复杂性时,他也感到有些失望。他提出了一个关于异步编程的开放性问题:是否有更好的方法来处理这些复杂性,而不是依赖于Rust目前的异步编程模型?

为啥Rust异步长这样(上)

这些限制在一定程度上是可以的啊,是没毛病的啊,很多情况下是可以接受的,但是对于大规模并发程序啊就行不通了,解决方案是什么,是使用非阻塞IO接口,并在单个操作系统进程上调度许多并发的操作

语言以某种方式将工作划分为任务啊,并将这些任务调度到线程上.在Rust中就是async/await

抢占式 vs 协作式


有栈协程 vs 无栈协程 (靠continuation或状态机)

一篇关于Rust语言异步机制的文章,作者思考了Rust中异步等待语法的演变历史,以及为什么Rush选择使用无障碍携程方法来实现用户空间并发。视频中还讨论了协作式调度和抢占式调度的选择,以及携程的定义和使用。作者认为,对于Rust来说,选择无障碍携程方法是必要的,因为它可以实现更高效的并发操作。

Future存储退出时的状态,是个状态机(类似于有栈协程的堆栈,保存退出时的信息,为了之后能够重新执行这些信息是必要的)

函数着色问题…rust有这个问题,go没有




为啥Rust异步长这样(下)

原来pin的设计理念是这样的

好的,我会按照您的要求,对内容进行整理,使其变得合理通顺,同时不会遗漏或省略任何内容。以下是整理后的内容:

继续来看一下Rust异步的简史,或者说它的背景知识。谈到Future了,当Iron TM和Alex Crichton需要替换绿色线程的时候,他们首先复制了许多其他语言中使用的API。这个API后来被称为Future或Promise。这类API基于一种称为延续传递风格(Continuation-Passing Style,CPS)的技术。在这种风格中,Future会将一个回调(callback)作为额外的参数,称为continuation,并在Future完成时将其作为最后一个操作调用。

这就是大多数语言中定义这种抽象的方式。大多数语言的async/await语法也都被编译成这种CPS风格。在Rust中,这种API看起来像这样:

1
2
3
4
trait Future {
type Output;
fn poll(&mut self) -> Poll<Self::Output>;
}

Future会执行一次,然后完成,因为它是最后要做的事情。

Iron TM和Alex Crichton尝试了这种方法,但正如Iron TM在一篇发人深省的博文中写的,他们很快遇到了一个问题:即使用CPS太频繁会导致堆的分配。他举了一个join的例子:join获取两个futures并且同时并发地运行它们。continuation需要有两个子continuation共享所有权,因为不管哪个最后完成都要执行,它最终需要引用计数和分配才能实现。这在Rust中是不可接受的。

相反,他们检查了C程序员倾向于如何实现异步编程。在C中,程序员通过构建状态机来处理非阻塞的I/O。他们想要一种Future的定义,可以编译成C程序员手动编写的那种状态机。经过一番实验,他们采用了所谓的基于就绪状态(readiness-based)的方法。

这里就讲到了Future的实现方式。Future不是存储continuation的。continuation是单独的,比如说你把状态,或者后面要做的事情,比如处理错误,处理后续的事情,这些都是continuation,但Future不是存储这个的。而是由某个外部执行程序来处理。当Future挂起(suspend)时,它会存储一个方法来唤醒。该执行程序会成为一个方法,当它准备好再次轮询时,就会执行这个方法。通过这种方式反转控制,他们不再需要存储Future完成时的回调。这就允许将Future表述为一个单个的状态机。

你就看它就行了,轮询它就行了。他们在此接口之上构建了一套组合器库。所有这些都编译成了单个状态机。所有后面要做的事情,有没有完成,就一个状态机就完事了。对外来看的话,就是一个状态机。

从基于CPS的方法切换到外部驱动程序,将一组组合器编译成单个状态机,甚至这两个API的确切规范,所有这些听起来都很熟悉。如果我们看了上一节的话,从continuation到poll轮询的转变,与2013年对迭代器的转变完全相同。再一次,正是Rust处理具有生命周期的结构体,并将状态从外部借用的能力,使它能够以不违反内存安全的方式将Future最优地表示为状态机。

这种将单个状态机从更小的组件构建出来的模式,无论是用于迭代器还是Future,都是Rust的工作方式的关键部分。它是Rust中一个非常关键的特征。它几乎自然而然地从语言中脱颖而出,就好像是Rust的其中一种风格一样。

我在这里暂停一下,强调一下迭代器和Future之间的一个区别。像zip这样的,将两个迭代器交错在一起的组合器,在基于回调的方法中是不可能的,除非你的语言对协程有某种原生支持,并且你正在其之上构建。另一方面,如果你想交错两个Future,像join这样的基于continuation的方法可以支持,但它会带来一些运行时的开销。这就解释了为什么外部迭代器在其他语言中很常见,而Rust将这种转换应用到Future上后就会独树一帜。

在最初的迭代器中,Future库的设计遵循这样的一个原则:用户将以构建迭代器的相同方式构建Future。对低级库作者将使用Future trait,而编写应用程序的用户将使用Future库提供的一组组合器,从更简单的组件构建更复杂的Future。

非常不幸的是,当用户尝试这种方法的时候,他们立即遇到了令人沮丧的编译错误。问题在于,启动的Future需要逃逸周围的上下文,因此无法从这个上下文借用状态。Future必须拥有其所有状态。这对Future的组合构成问题,因为该状态经常需要在构成Future的一系列操作中共享。比如调用对象的上一个异步方法,然后是另一个,将编写如下:

1
foo.bar().and_then(|result| foo.baz(result))

问题在于,foo在bar()方法和传递给baz()中都被借用了。本质上用户想做的是在bar()中存储这个状态,而and_then()是通过连接Future组合器形成的。这通常会导致令人困惑的借用检查错误。

最容易理解的解决方法是将这个状态存储在Arc和Mutex中,但这并非是零成本的,需要付出代价。更重要的是,随着系统的复杂性增长,它会变得非常笨重和尴尬。

尽管Future在最初的实验中显示出了出色的基准测试结果,但这个限制的结果是用户无法使用它们构建复杂系统。

这就是我介入这个故事的地方。2017年末,很明显Future生态系统因糟糕的用户体验而未能启动。Future项目的最终目标一直是实现所谓的无栈协程转换,其中使用async和await语法运算符的函数可以转换为计算Future的函数,避免用户手动编写Future。Alex Crichton已经开发了一个基于宏的实现,以实现作为库,但几乎没有获得任何关注。所以这个时候就需要做出一些改变了。

Alex Crichton的宏的最大问题之一是,如果用户尝试在await点之后保留对Future状态的借用,它会产生错误。这实际上与用户在Future组合器中遇到借用问题相同。在新语法中再次出现了Future在等待时不可能持有对其自身状态的借用,因为这需要将其编译为自引用结构体,而Rust不支持这个功能。

有趣的是与绿色线程进行比较。我们已经解释了Future编译为状态机的方式之一,是说状态机是一个完美大小的堆栈。与绿色线程不一样,绿色线程必须增长,然后用来容纳任何线程堆栈可能具有的未知大小的状态。这是绿色线程的问题。编译的Future就像手动实现使用栈一样,大小正好与需要的一样大。因此我们在运行时不会遇到增长此栈的问题。

这就是Future的问题,Future基本上就是很小的,就那么大。但是这个栈被表示为一个结构体,而在Rust中移动结构体总是安全的。这意味着即使我们不需要在执行Future时移动它,根据Rust的规则,我们必须能够移动它。移动(move)是安全的,是可以的。那么这个时候我们就不要去移动它,但是你必须能够去移动它,因为Rust的规则就是这样的。

因此我们在绿色线程中遇到的栈指针问题在新系统中再次出现。不过这一次我们有一个优势,那就是我们不需要能够移动Future,我们只需要表达Future是不可移动的。我们只需要告诉编译器说Future是不能移动的就可以了。

最初实现这一点的尝试是试图定义一个新的特征,称为Pin trait。它将用于可以移动它们的API中排除协程。这遇到了我之前记录的一些相互兼容性问题。

我对async/await的论点有三个要点:

  1. 我们需要语言中的async/await语法,以便用户可以使用类似协程的函数构建复杂的Future。
  2. async/await语法需要支持这些函数编译为自引用的结构体,以便用户可以在协程中使用借用。
  3. 这个特性需要尽快投入使用。

这三点的结合促使我寻找Pin trait之外的替代解决方案,一种可以在不进行任何重大破坏性语言更改的情况下实现的解决方案。

我最初的实现计划比我们最终使用的要糟糕得多。我建议我们将poll方法标记为unsafe,并包含一个不变量,即一旦你开始轮询一个Future,就不能再移动它。这很简单,可以立即实现,并且非常暴力。它会使每一个手写的Future都变得不安全,并引入一个难以验证的要求。编译器无法提供这种帮助。这本身就是说编译器对unsafe的代码是不验证的,是你自己来承担这个问题的。这是Rust编译器的规则。它很可能会在最终遇到一些健壮性问题,并肯定会引起极大的争议。

因此Aaron Turon的几句话让我走上了一个更好的API方向。这将使我们能够以更细腻的方式强制执行所需的不变量。这最终将演变成Pin类型。Pin类型本身已经引起了一些争议,但我认为它对当时我们正在考虑的其他选项来说是不可否认的改进,因为它是针对性的,可以强制执行的,并且可以在短时间内就可以交付的解决方案。

回顾过去,Pin方式存在两种问题。一个是向后兼容性。由于各种原因,一些已经存在的接口,比如说Iterator和Drop,应该支持不可移动的类型。这限制了语言进一步发展的选择。这是其中的一个问题。

另一个问题是对最终用户的暴露。我们意图编写正常异步Rust的用户永远不必处理Pin类型。在大多数情况下事实确实如此,但有一些值得注意的例外。几乎所有这些都可以通过一些语法改进来解决。唯一一个真正糟糕的,对我个人来说也很尴尬的,只是你需要Pin一个Future trait的对象来等待它。这是一个非强制性错误。现在修复它将是一个破坏性的变化。async/await的其他角色都是语法上的。我不会在这篇已经很长的文章中去讨论它们了。

好,我们来再看这个组织考量。为什么我们现在的Rust异步是这样的呢?那是有一个原因是组织上的考虑。我探索所有这些历史的原因,是为了证明Rust的一系列事实将我们不可避免地引向了一个特定的设计空间。

第一个原因是Rust没有运行时。这使得绿色线程成为不可行的解决方案,因为Rust既需要支持嵌入(包括嵌入到其他应用程序中和嵌入式系统上运行),这两个都需要,又不能执行运行时之外的所需的内存管理。

第二个原因是Rust具有将协程编译为高度可优化的状态机的自然能力,同时仍然是内存安全的。我们不仅将其用于Future,也用到了迭代器上。

但这个历史也还有另一面。为什么我们要用用户空间并发推出一个运行时系统呢?为什么要有Future和async/await呢?这个论点通常采用两种形式。一方面你有一种习惯于手动管理用户空间并发的人,使用类似于poll的接口。这些人有时候会嘲笑async/await语法是网络上的垃圾。另一方面一些人只是说”哎呀我不需要它”,并建议使用更简单的操作系统并发,比如线程和阻塞I/O。

使用没有用户空间并发性设置的语言(比如C)实现高性能网络服务等,通常使用手工编写的状态机来实现它们。这正是Future设计为编译成的东西。它无需手工编写状态机。协程转换的整个目的就是编写指令式代码,就好像你的函数从未挂起一样,但是编译器在需要时生成状态转换以挂起它。

这个好处并非微不足道。最近一个CVE(导致漏洞出现)最终是由未能识别的状态转换期间需要保存的状态引起的。在手工实现状态机的时候,很容易犯这种逻辑错误。如果你在识别状态机转换期间需要保存状态时没搞成的话,就会被黑客利用。所以这个好处是非常有意义的。

Rust提供async/await语法的目的是提供一个可以避免这些错误且具有相同性能表现的功能。鉴于我们提供的控制水平和缺乏内存管理的运行时,我们认为使用C或C++编写的此类系统完全属于我们的目标受众。Rust可以给这些人用。

2018年初,Rust项目承诺在当年发布一个新的版本。理论上面出现了一些语法问题,还决定利用这次版本发布来使Rust的异步准备好投入生产。

async团队主要由编译器黑客和类型理论家组成。怪不得这个类型搞得这么好。但我们对营销也有一点基本的概念,尽管是比较偏技术的,但是对营销也有一点概念。并认识到这次版本发布是一个让用户关注产品的机会。因为这个版本的发布很重要,大家会关注它。

然后我向Aaron Turon建议,我们应该专注于四个基本的用户故事(用户场景)。这些故事或者说场景看起来是Rust的增长机会。这些是什么呢?嵌入式系统、WebAssembly、命令行接口和网络服务。这四个场景非常适合用于Rust。也就是Rust是基于这四个场景为导向开始做相应的引导的。

这番话促成了领域工作组的创建。因为这是事实嘛,对吧?是他们只关注特定领域的跨职能小组。与控制某些技术和组织疆域的现有团队形成对比的话,Rust项目中的工作组概念从那时候已经发生了变化,在很大程度上已经失去了这种针对特定领域的意义。但是我离题了。

事实上最后没有这些领域。而是其中那些领域,你可以看到async/await工作是由网络服务工作组推进的。该小组最终被称为仅仅是异步工作组,并以此名称存续至今。但是我们非常清楚,鉴于其缺乏运行时,Rust也可以大大服务其他领域。这个时候发现确实特别是嵌入式系统,我们设计此功能时都考虑了这两种用例。也就是说这两种场景:一种是网络服务,一种是嵌入式系统。其实这个可以看出来,async主要是谁?是网络服务这边。那就是说最初考量它的时候,那么可以看到它在内存上面的优化,就是尤其在空间比较小,然后非常安全的情况,那都是为嵌入式系统来考量的。就基本上它主要是这四季的时候都考虑到了这两种场景。

尽管通常没有说出口,也没有明说,但Rust的成功需要的是行业采用。你要出一个产品,都有行业去用它,这样即使Mozilla不再愿意为这种实验性新语言提供资金,它也能继续获得支持,因为行业上有应用了。

很明显,短期内实现行业采用最有可能的途径是什么?是网络服务。靠看准了网络服务,尤其是当时迫使他们使用C/C++编写的那些性能要求高的服务。这种用例就完美契合了Rust的定位。那么这些系统需要高度的控制来达到性能要求,但避免可利用的内存错误至关重要,因为他们会暴露在网络上。就是说它的内存安全至关重要,对谁来讲?网络。网络它的安全要求,所以你可以看到Rust之所以要求安全,跟它的起初的使用场景非常非常的有关系。

网络服务的另一个优势是什么?是软件行业的这个分支具有灵活性,并愿意迅速采用像Rust这样的新技术。其他领域,比如说嵌入式、WebAssembly和命令行,从长期来看是可行的机会,但他们被视为不那么乐意采用新技术。嵌入式依赖于尚未被广泛采用的新平台Web Assembly,或者是不是特别有利可图的工业应用Command line,因此无法为Rust带来资金。

我以Rust的生存取决于这一功能的假设为前提,孜孜不倦地开发async/await。其实我们可以看到为什么互联网行业可以增长迅速,非常快速,因为他们愿意采用这种崭新的新技术。但是那种很多的工业场景,或者哪些场景呢?因为它有很多很多年的惯性,而且害怕出现什么问题的时候,它就不太愿意采用它。那长期来看它肯定是可行的。也是为什么可以看到工业4.0推进并没有像互联网这么快捷一样。所以说网络服务是一个非常好的变革的领域。是它的一个优势。

在这方面async/await取得了巨大的成功。许多最杰出的Rust基金会赞助商,尤其是那些支付开发人员工资的赞助商,都依赖async/await来用Rust编写高性能网络服务,作为他们为Rust提供资金辩护的主要原因之一。

将async/await用于嵌入式系统和内核编程也是一个日益增长的兴趣领域,有着非常美好的未来。async/await如此成功,最常见对其的抱怨是生态系统过于围绕它,而不是我们常用的一些普通的Rust。就是有很多抱怨为什么一直在async/await上面使劲花力气去做更新,而一些常用的功能反倒是没有那么受重视。原因可能是在抱怨这个地方。

所以他后面讲的,我不知道该对那些宁愿只使用线程和阻塞I/O的用户说什么。就是只愿意用操作系统提供的默认方式说什么。当然我认为有很多系统使用这种方法也是合理的。你要这么用也是合理的,有些地方对吧。而且Rust语言并没有阻止他们这样做。Rust没有让你不要这么搞,你可以用那种方式。

他们似乎反对的是crates.io上面的生态系统,就是crates.io上面有很多生态,尤其是用于编写网络服务的生态系统,都是围绕使用async/await来构建的。因为async/await对于网络服务的生态系统是非常非常好的一个地方。

偶尔我会看到使用async/await的cargo cult方式编写的库。这个什么意思呢?就是说什么叫cargo cult,就是为了要用async/await而去用async/await。就是说”我就是为了契合这种方式,这种方式编库”。就是偶尔会看到这种方式。就是说”诶,我必须是不是就我的并发,是不是就要用async呢?”他说有些情况是不需要用的,但是你是为了这个感觉比较像的。毕竟Rust就是这样用的。去这么用的话,那么这个就叫做cargo cult。

但是在大多数情况下,似乎可以安全地假设库的作者实际上是希望执行非阻塞I/O,并获得用户空间并发的性能优势。那么他至少他是这样做的,是没人能够控制。就是控制其他人决定做什么,就是没人都都都是自己来决定的。

事实是什么?大多数在crates.io上发布网络相关库的人都想使用async Rust,无论是出于商业原因还只是出于兴趣。我希望在非异步上下文中更容易使用这些库,比如说通过poll-like API引入标准库。但对于那些抱怨在网上免费发布代码的人,与他们没有完全相同用例或者场景的人,我不知道说什么好。我也不知道说什么了。

他后面讲的”To be continued”,未完待续的。他在里面讲的,其实他也是虽然是非常客套了一下,事实上就是说,虽然我坚称对于Rust来说没有其他的选择,但我并不认为async/await是任何语言的正确替代品。特别是我认为有一种语言可以提供Rust提供的相同可靠性保证,但对运行时表示的控制较少。它使用栈协程而不是无栈协程。我甚至认为,如果这种语言以一种可以将协程用于迭代和并发的方式支持协程,那么这种语言可以完全没有生命周期,同时仍然消除因别名可变性而引起的错误。

如果你阅读了Graydon Hoare的笔记,你可以看到这些是Graydon Hoare最初的目标。他在Rust的改变路线成为一种可以与C和C++竞争的系统语言之前,他是这样构想的。其实Graydon Hoare他自己在这个日记里面也说了,就是说如果他也像Python的作者或者是说还有其他的那种作者一样,从一而终就一直影响着这个语言的发展的话,他认为就是说Rust可能就会没有未来。他有一篇文章这样讲的。就是说如果他一直在把持着Rust的话,可能Rust不会像今天这么成功。他个人是这样讲。有时间也可以看,我也会看一下那个,把那个文章发一下。

我认为如果有这种语言,Rust的用户会非常乐意使用它。我理解他们为什么不喜欢处理底层细节固有的复杂性。过去,这些用户会抱怨各种各样的字符串类型。现在他们更有可能抱怨。我希望也有一种为这种用例提供与Rust相同保证的语言,但问题不在于Rust。他的讲就说如果你想用那种很普通的Rust,他之前讲的就是没有这种异步的拉扯,他也希望有这种类似这种语言。

尽管我相信async/await是Rust的正确方法,但我认为对于当今生态系统的状况感到不满也是比较合理的。我们于2019年发布了一个MVP,Tokio于2020年发布了一个1.0版本。从那个时候起,事情比任何参与者希望的都要停滞。在下篇博客中,我想讨论当今async生态系统状况,以及我认为该项目可以做什么来改善用户的体验。但这已经是迄今为止发布最长的博客文章了,所以现在我就不得不就此打住了。

所以这篇文章其实还没有写完,但后面应该还会有。我们看看他后面会讲哪些事情。但是从这篇文章我们基本上可以看到,Rust这个async/await是怎么进化过来的,它为什么是长成今天这个样子。这就是我们比较好理解了。

所以想要完整搞清楚pin的引入的背景, 就要了解rust异步编程的前世今生来龙去脉

网络服务, 嵌入式系统, 都考虑到了~


程序员令狐壹冲: Rust异步编程

基于官方发布的教程书: https://rust-lang.github.io/async-book/

这书看着还没写完..但是最后一次提交是9个月前…

代码: https://github.com/rust-lang/async-book


杨旭: Rust Async 异步编程(完结)

也是基于官方的这本书



Rust异步工作原理

这篇仅供参考吧,讲的一般般

两个核心是Future和Executor

Rust异步工作原理

这篇文章是之前翻译的,一直摆在桌面上没去看。今天把它拿来读一下,讲的是Rust异步工作原理。内容其实与之前”异步Rust的理解之路”讲的问题如出一辙,但这里讲得更加简洁。到最后你可以看出它有点软文的性质。

Rust异步系统正在蓬勃发展。如果你的应用在IO上比较繁重,只需简单使用异步就可以了,一切就会高效运行。你可以使用异步函数,它们可以在后台运行,同时CPU可以执行其他有用的任务。学会使用TOKYO runtime来让它发挥作用,一切看起来就像魔法一样。幸运的是计算机还没有通过魔法工作,所以我们尝试简化一下以更好地理解。

异步函数在Rust中的快速版本是什么呢?它们只是常规函数的语法糖,但不是直接返回值,而是给你一个复杂的状态机实现future trait。例如:

1
2
3
async fn foo() -> i32 {
// ...
}

实际上被解糖为:

1
2
3
fn foo() -> impl Future<Output = i32> {
// ...
}

编译器生成适当的类型和实现。这很有用,但仍然过于复杂。我将专注于我认为最重要的两个组件:future trait和executor(执行器)。最后展示我的最小化的执行器实现,可以用来运行你的异步代码。

让我们先从future trait开始。虽然future trait对于异步Rust的工作方式至关重要,但它相当简单。这就是它的全部内容:

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

Output关联类型表示future最终将会返回的类型。poll方法将检查异步过程是否已经完成。如果完成就返回Poll::Ready,如果还没有完成就返回Poll::Pending。Pin<&mut Self>的存在有点复杂,所以在这个文章中会忽略它。

关键的是future本身不执行任何操作。它应该快速返回Poll::Pending或Poll::Ready,以便程序可以继续检查其他的future,或者回到休眠状态。future的实际工作在其他地方完成,不在当前的线程上完成,而是在其他的线程或task上完成。

poll方法的另一个有趣的地方是它接受一个Context参数。Context的目的是提供一个Waker的引用,稍后可以用来唤醒执行器。我们将在接下来讨论这个。

所以异步函数被转换为实现Future trait的类型,但最终必须有东西是异步的。为了说明这一点,我们来看一个简单的future:它不返回任何东西,但会启动一个线程,并在它启动的线程完成后再完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, atomic::{AtomicBool, Ordering}};
use std::task::{Context, Poll};
use std::thread;

pub struct ThreadFuture {
completed: Arc<AtomicBool>,
}

impl Future for ThreadFuture {
type Output = ();

fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.completed.load(Ordering::SeqCst) {
Poll::Ready(())
} else {
let waker = cx.waker().clone();
let completed = self.completed.clone();

if completed.load(Ordering::SeqCst) == false {
thread::spawn(move || {
// 模拟一些工作
thread::sleep(std::time::Duration::from_secs(1));
completed.store(true, Ordering::SeqCst);
waker.wake();
});
}

Poll::Pending
}
}
}

这里有一堆样板代码,但非常简单:

  1. 检查我们是否已经完成
  2. 如果没有,检查我们是否已经开始异步计算
  3. 如果没有,在后台启动一个线程
  4. 在那个线程中完成计算,标记我们已完成,然后唤醒执行器
  5. 返回一个pending状态
  6. 最后,如果我们已经完成,就返回ready状态

现在我们来看执行器。执行器是异步的另一个拼图。执行器主要需要poll这个future,如果没有事情做的话就去睡觉,并提供一个合适的Waker来唤醒它。许多实现(比如TOKYO)提供了大量额外的功能,但归根结底它只需要完成以下步骤:

[这里是一个执行器工作流程的序列图]

当用户提供一个future时,执行器会开始轮询它,直到它最终返回一个值。这种轮询不是忙等的循环。相反,线程会等待一个唤醒信号。这个信号可以来自任何线程,甚至是一个简单的C风格信号处理程序,并且通常会与操作的基础异步过程相关。

或者在这个流程图形式中是这样的:
[这里是另一个执行器工作流程的流程图]

对于执行器的实现,信号的来源并不重要。只要它引起了唤醒,就应该再次轮询future。

有了这个知识,我们可以构建一个基本的执行器。实际上我们不必这么做。在标准库文档中,你可以找到一个有意设计得有点错误的执行器实现。它只实现了这个功能。我可以写一个正确的视线执行器,就像我为这个视线future所做的那样,但恰好我已经做了,并且作为一个crate发布了。

我们来看看那个crate的介绍。它叫做”boba”执行器。这篇文章的主要目的是介绍boba 1.0版本。boba是一个安全的、极简主义的future执行器,基于上述理念。它非常极简,只有84行代码(包括注释)。它的整个公共API如下所示:

1
pub fn block_on<F: Future>(future: F) -> F::Output;

代码相对直接。如果你还在努力理解异步执行的概念,建议你去看看。

人们会问为什么要使用这个,而不是一个功能齐全的异步框架。主要原因是编写与执行器无关的异步代码的测试,以及在大部分同步代码中使用异步库。可以将对block_on的调用嵌套起来,其中你从异步代码中调用一些同步代码,然后使用boba调用异步代码。在许多情况下这样做对性能仍然是非常好的。确保在可能的情况下对异步函数调用使用.await。

先前的成果:
boba并不是第一个极简的future执行器。许多crate实现了相同的东西。我承认我所知道的那些,并解释为什么我认为boba可能是一个更好的选择:

  1. poll: 提供了与boba crate大致相同的实现。有一个小的unsafe代码使用,这在rust 1.68之后可以移除。它使用扩展trait提供它的阻塞功能。它最近还获得了一组可选的过程宏,允许你以一种方式注释异步函数或测试,以便它们可以正常执行。

  2. block-on: 稍微更极简一点,所以使用动态分配来减少代码大小。

  3. futures-executor: 它提供了几个执行器,以及使处理future更简单的工具。它的block_on和executor与boba API类似,尽管对它的调用不能有意去嵌套。它也是一个相当大的crate。

  4. smol_executor: 与boba API相同,但早于futures crate,因此必须使用unsafe rust进行执行器的实现。它根据GNU通用公共许可证授权,这可能使它不适合许多应用。它的版本控制方案有点奇怪,但可以接受。

  5. async-std/runtime和tokio/runtime: 这些和sophia executor都提供了更多的一般执行器框架,而不仅仅是执行future。如果你需要更多的话,它们是有意义的,但对于简单的future执行,boba应该就足够了。

如果这还不能说服你,那么boba是这些中唯一已经迁移到或超过了1.0.0版本的。这应该让那些对0.ver挑剔的粉丝感到高兴。

未来:
考虑到标准库异步Rust的当前状态,我认为boba完成使命也到位了。它执行future,而且做得很高效。截至目前,没有可能对它的工作方式进行重大改进了。但所有这些仍然可能会变。未来的Rust肯定会有一个更广泛的异步标准库。我希望boba能够支持它,但现在这就是全部了。

结论:
Rust的异步编程依赖于执行器。它们会持续检查future,直到它们完成。如果某个future还在等待结果,执行器会先处理其他任务,或者休息一下再回来继续检查。这个过程会一直持续,直到所有的future都已经处理完毕。

我希望这样的理解能让你对异步编程的底层工作方式有一个直观的理解。理想情况下你并不需要深究这些细节,但如果遇到抽象层不够清晰的情况,现在你至少知道它是怎么运作的。另外,推荐探索一下boba。

这就是它的所有内容了。其实这基本上也就讲了一下异步执行的过程。这个图画得也挺好的,就是执行的一个过程。实际上我们只管用像同步一样的方式把程序写好,然后在其中需要异步的地方写上.await就好了。至于它底层到底是怎么做的,有时候真的我们不需要太关心。但是我们知道它基本上是这样做的,我们知道对于我们的性能,尤其是在资源利用上面,是个非常好的提升,就可以了。

其实只管用就行了,可以不用了解得那么深

Future本身不执行任何操作,只快速返回是pending还是ready





异步Rust的体验很糟糕

这篇不错~ 尤其可以看下评论区

要先达到完成并发的条件,然后才能并行

以下是整理后的内容,我尽量保留了原文的所有信息,并使其更加通顺:

异步Rust的体验很糟糕

这篇文章讨论了Rust语言中异步编程的一些问题。虽然标题称异步Rust是一门糟糕的语言,但作者并非要完全贬低Rust,而是探讨在Rust中进行异步编程可能面临的挑战。

首先,我们需要理解为什么需要异步编程。在2023年,即使是手机也有8个核心处理器。如果我们想充分发挥机器性能,超过12%的利用率,就需要同时使用多个核心。另外,当等待缓慢的操作完成时,我们希望runtime能继续执行其他任务,而不是干等。在计算机时间中,发送一条消息到互联网或打开一个文件都需要花费很长时间,在这期间我们完全可以同时执行数百万个其他任务。

为了解决这些问题,我们需要依赖并行性和并发性。并行性指在多个CPU上同时运行一套代码,可以提高整体性能。并发性是指将问题分解为相互独立的部分,让它们可以独立运行,从而提高效率。这两者是不同的概念,但它们之间存在联系。我们通常将程序分解成并发的部分,而这些部分可以进行并行处理。

构建并发系统的最简单方法之一是将代码分成多个进程。操作系统是一个精巧而高效的并发机制,让每个进程都以为自己独享了整个计算机。但是,进程间的通信并不廉价,因为大多数实现都需要将数据复制到操作系统的内存,然后再从这个内存中取回。

互斥锁并发被视为有害

为了避免这些开销,我们可以使用线程。线程共享相同的内存,不需要进行昂贵的数据复制。但是,线程间的同步需要使用互斥锁、条件变量和信号量等工具,这可能导致死锁等问题。

托尼·霍尔在1978年的论文中建议使用队列或通道将线程连接起来。这种方法有很多优点:线程享受类似进程的隔离,不共享内存;每个线程有明显的输入和输出通道,易于理解和调试;通道是同步的,如果通道空,接收者会等待,如果通道满,发送者会等待。

然而,对于需要大量并发处理的问题,如C10K问题(一个连接数万个并发用户的web服务器),线程可能不足以解决问题。为此,一些编程语言提供了用户空间任务创建和管理的并发模式,不依赖操作系统的帮助。

Rust采用了async/await模型来解决这个问题。在这种模型中,标记为async的函数不会阻塞,而是立即返回一个Future或Promise,可以等待其产生结果。Rust中的Future非常小而且速度快,这要归功于它们是协作调度的,以及无栈设计。

然而,Rust试图在向程序员承诺完全的低级控制的同时,提供这种抽象。这两者之间存在根本性的紧张关系。Rust试图在编译时静态验证程序中每个对象和引用的生命周期,但Future/Promise是相反的,它们可以将代码和引用的数据分解成成千上万个小片段,可以在任何时间、任何线程上运行。

这导致了一些问题。例如,一个客户端读取数据的Future应该只在该客户端的socket有数据时可读,但没有任何生命周期注释可以告诉我们何时发生这种情况。为了解决这些问题,开发者可能需要使用Arc(原子引用计数)来管理跨多个线程的动态生命周期。但过度使用Arc可能导致类似垃圾回收的问题,而且失去了垃圾回收带来的好处。

由于Rust协程是无栈的,编译器将每个协程转换成一个状态机。这导致任何递归的异步函数都变成了递归定义的类型,当用户尝试从函数内部调用自己时,会遇到难以理解的错误。

综上所述,这使得异步Rust与普通Rust有很大不同,有更多的陷阱,更难理解和学习。它迫使用户要么深入理解这些抽象实际是如何工作的,编写复杂的代码来处理它们,要么在代码中随处添加Arc、Pin、’static等奇怪的东西,然后祈祷一切顺利。

作者认为,也许Rust不是用于大规模并发、用户空间软件的好工具。对于99%的项目来说,可能不需要这种高度并发。例如,对于需要访问位于互联网另一端的文件的情况,NFS就可以解决问题。

与其他语言不同,Rust语言中没有为Future提供运行时,而是将这个任务委托给了第三方库,比如Tokio。这对于用户来说,Rust的构建工具cargo和生态系统为开发者提供了在特定情况下选择更合适的替代方案的自由。

作者提到,可以想象在一个理想的世界中,Tokio已经被内置到原生Rust中,所有相同的规则都可以正常使用。你可以通过命令运行,整个与运行时在Future完成时主线程会打破这一根链条,但你不应该广泛地这么做。

作者还讨论了其他一些问题,并将这些问题抛给了GPT(大型语言模型)来总结。GPT的总结大致如下:

  1. 并发需求背景:文章讨论了为什么需要并发编程。

  2. 并发和并行的区别:文章解释了这两个概念的不同。

  3. 并发模型的演变:作者提到了历史上不同的并发模型,包括多进程和多线程,以及它们存在的问题。

  4. 异步Rust的挑战:作者讨论了在Rust中进行异步编程可能遇到的问题。异步编程允许函数立即返回一个Future或Promise,不会阻塞,但这带来了更大的复杂性。

  5. 使用Arc的问题:Arc是解决生命周期问题的方法,但它主要解决的是编译问题,可能会导致内存管理的问题,尤其是在大规模应用中。

  6. 异步编程的复杂性:作者强调了异步编程在Rust中的复杂性,以及与传统Rust编程相比,它需要更多的学习和努力来理解和处理。

  7. 结论:文章最后讨论了异步编程在特定应用中的价值,以及Rust是否适合进行大规模并发的用户空间编程。作者认为对于大多数项目(他甚至说99%),Rust提供的工具已经足够,但在需要高度并发的特定场景中,可能才需要这种高级的工具和模型。

总的来说,这篇文章提供了对Rust异步编程的一些思考,强调了其挑战和复杂性,同时也提出了对并发编程的不同方法和模型的思考。它鼓励我们在编程时要更加谨慎,同时认识到Rust并不适合所有类型的并发应用。

这篇文章读起来很有意思,特别是它讨论的Rust异步编程中生命周期和编译时验证之间的矛盾。这似乎是Rust异步编程面临的一个核心挑战。
最后,作者建议,虽然并非每个程序都可以表示为有向无环图,但Hoare模型(使用通道的方式)是一个很好的默认选择。在Linux中,每个线程都有一个4K的控制块,线程间的切换需要通过操作系统调度程序进行,这比普通的函数调用开销更大。

总的来说,这篇文章提供了对Rust异步编程的一些思考,强调了其挑战和复杂性,同时也提出了对并发编程的不同方法和模型的思考。它鼓励我们在编程时要更加谨慎,同时认识到Rust并不适合所有类型的并发应用。



是要自己实现一个:

Rust粗略讲实现异步运行时



为什么选择Async而非线程

这篇文章是关于异步编程的,作者是John Nana,发布于2024年3月24日。文章的标题是”为什么选择async/await,而非线程”。

作者首先提出了一个常见问题:既然线程可以完成所有async/await能做的事,而且更简单,为什么有人会选择async/await呢?这是在Rust社区经常看到的问题。

作者理解这个问题的原因。Rust是一种底层语言,不会隐藏协程的复杂性,这与Go等语言相反。在Go中,协程是默认的,程序员甚至不需要考虑它。聪明的程序员会尽量避免复杂性,所以当我们看到async/await的额外复杂性时,会质疑它的必要性,特别是考虑到存在操作系统线程这一合理的替代方案。

接下来,作者通过一次”脑洞之旅”来比较async/await与线程。

首先是背景知识:在C语言中,代码通常是线性的,一个任务接一个任务地运行。但有时我们需要同时运行多个任务,网络服务器就是一个典型的例子。

作者给出了一个线性代码编写的网络服务器示例,指出如果handle_client函数需要几毫秒的时间来处理请求,且有两个客户端同时尝试连接,就会遇到严重问题:第二个客户端必须等待第一个客户端的handle_client函数运行完才能连接。如果有200万个并发客户端,队列末端的客户端可能要等待几分钟才能得到服务,这种方式的可扩展性很差。

为了解决这个问题,人们引入了线程。操作系统可以通过保存寄存器值和程序堆栈到内存中,来停止一个程序,运行另一个程序,然后恢复之前的程序。这本质上允许在同一个CPU上运行多个线程或进程。

作者给出了使用线程的代码示例,解释了如何通过线程避免之前的问题:服务器可以为每个客户端启动一个新线程来处理请求,这样多个客户端就可以并行处理了。线程特别适用于具有数十个CPU核的生产级Web服务器,因为操作系统可以真正同时运行这些线程。

然而,程序员希望将这种并发性从操作系统空间带到用户空间。用户空间并发性有多种模型,如事件驱动编程、actor模型和协程。Rust选择了async/await模型。

简单来说,async/await允许将程序编译成一组可以独立运行的状态机。Rust提供了创建这些状态机的机制。作者给出了使用async/await重写之前网络服务器代码的例子,解释了async函数如何返回状态机,以及await如何将另一个状态机包含为当前运行状态机的一部分。

作者指出,这种用户空间并发确实增加了复杂性。使用线程时,不需要处理执行器、任务、状态机等概念。对于99%的程序,我们可能不需要涉及任何类型的用户空间并发。那么为什么还要使用async/await呢?

作者认为,Rust的一个最大优势是可组合性。Rust提供了一组可以嵌套构建、组合和扩展的抽象。作者举例说明了Rust的Iterator特征如何让简单的操作变得强大和灵活。

然后,作者讨论了async/await如何将这种可组合性应用于I/O绑定的函数。他给出了一个例子,说明如何轻松地为一个函数添加超时功能,只需要将现有代码包装在一个async块中,并与另一个future组合即可。这种方法的一个额外好处是它可以适应任何类型的流。

相比之下,作者展示了在线程模型中实现相同功能的困难。通常,你无法在阻塞代码中中断read和write系统调用。虽然TCP流提供了设置读写超时的函数,但直接使用它们可能会导致问题。作者给出了一个复杂的解决方案,但承认这是一个相对”黑客”的方法,需要额外的系统调用和抽象。

作者认为,在生产环境中看到这样的代码时,他会问为什么不使用async/await来解决这个问题。他经常遇到同步代码需要大量修改才能使用的情况,而使用async/await重写可能更容易。

接下来,作者列举了async/await成功的案例:

  1. HTTP生态系统将async/await作为主要的运行时机制,包括客户端。

  2. Tower库展示了async/await的强大之处,允许轻松添加超时、限速、负载均衡等功能。

  3. Macroquad游戏引擎使用async/await来简化游戏开发。

作者认为,async Rust被诟病的主要原因不是编程本身的问题,而是异步编程的优势没有被广泛传播,导致许多人对它存在误解。

他批评了Rust异步编程书籍中对async/await和操作系统线程的比较,认为这种比较过于简单化。作者认为,社区过分强调了async/await的微小性能优势,而轻视了它在语义层面的强大之处。

作者反对将async Rust视为一种特殊情况下的奇怪方法,而应该将其视为一种强大的编程模型,能够表达无法用同步Rust轻易表达的复杂并发模式。

最后,作者呼吁不要试图让async Rust完全模仿同步Rust的使用方式,而是应该拥抱两者之间的差异,强调async Rust在表达复杂并发模式方面的优势。他认为,我们应该使用语义方面的理由,而不是技术方面的原因,来论证async/await的价值。




待看:

Async Rust理解之路(一)

谷歌Rust教程之并发(1)

谷歌Rust教程之并发(2)


番外:

原子之音: 2024 Rust现代实用教程

gitlab.com/yzzy/rust_project