Rust语言开源杂志(2021)

The roots aren't deep but the seeds are planted!

logo

为了丰富 Rust 社区成员的学习文化生活而特别推出此刊!

如何贡献?

为月刊做贡献有下面几种方式:

  1. 修正错误:查看每月文章,如果发现错误,可以直接提交 PR 修正。
  2. 补充内容:查看当月对应目录下的markdown文件,如果有标题,但内容空缺,欢迎补充,同样是发 PR。
  3. 新增内容:如果你在当月有想要投稿的文章,可以直接发PR,文章为 markdown 格式,放到当月对应目录下。

PDF 下载

2021 上半年集合 PDF: 下载

如何订阅本刊 RSS ?

  1. 订阅地址:https://rustmagazine.github.io/rust_magazine_2021/rss.xml

  2. 复制订阅地址到你最喜欢的订阅工具开始订阅。


发刊渠道

graph TD
    A[RustMagazine] -->|每月最后一天| B(发刊)
    B --> C{阅读渠道}
    C --> |GitHub Page| D[GitHub]
    C -->|Rustcc| E[Rust中文论坛/公众号]
    C -->|Rust视界| F[Telegram]
    C -->|掘金| G[技术社区]
    C -->|语雀| H[在线文档]

编辑小组

编辑招募条件

  • 热爱 Rust 语言
  • 有时间参与编辑文章
  • 有学习的心态

有意者请联系。

发刊渠道

支持公司和高校

感谢以下公司和高校大力支持 RustMagazine并贡献内容,排名不分先后。

  • 华为
  • PingCAP
  • 蚂蚁集团
  • 溪塔科技
  • 国汽智控
  • 清华大学
  • 期待更多

特别感谢

  • Rustcc 中文社区
  • 《Rust 日报》小组全体成员
  • 《Rust 唠嗑室》组织者和全体参与者

许可

cc

本电子杂志采用「署名-非商业性使用-禁止演绎 4.0 国际 (CC BY-NC-ND 4.0)许可协议」进行许可,非商业性转载请注明出处,其他需求请与我们联系。

创刊寄语

作者:张汉东

The roots aren't deep but the seeds are planted!

自从2015515Rust 1.0 稳定版发布以来,Rust发展已经经历了五个半年头。

头三个年头,Rust发展是缓慢的。直到Rust 2018 Edition发布开始,Rust便逐渐开始走入各大企业。我在2018年发起《Rust日报》之初,全球范围内应用Rust的动态还是寥寥无几,我还在发愁去哪里寻找Rust的动态。但是到了2020年底,《Rust日报》已经不再为Rust动态而发愁,几乎每天都会有新的项目和文章冒出来,覆盖了Rust应用领域的方方面面。足以见证Rust的发展趋势。

然而,这些Rust动态,有90%都是出自国外社区。其实近两年,Rust在国内也陆陆续续有一些公司开始采用,国内也逐渐看了一些优秀的Rust学习原创文章。在去年 RustChinaConf2020大会上,我们也看到了很多国内公司和开源社区的个人项目。然而,目前国内各大应用Rust的公司和Rust社区都还缺乏很多原创的精品输出,或者,有很多精品输出,还被隐藏在互联网信息洪流中,未被我们发现。

在当前的这种背景环境下,办一份电子杂志的想法就由此诞生了。所以,各位Rustacean们,《Rust中文精选》今天创刊了!

Rust中文精选》的目标就是要连接公司、社区、高校和个体,挖掘更多国内的Rust领域的精品原创内容,让大家沟通有无。《Rust中文精选》将是永久开源和非商业化的。

rustmagzine_connect

Rust中文精选》每月最后一天正式发刊,提供三种阅读方式:

  • 在线阅读。大家可以通过我们指定的渠道找到本刊的阅读入口,点击链接可直接在线阅读。暂时是通过GitHub Page,回头找时间在 Gitee 上面发布。
  • 本地阅读。你也可以通过本刊源码仓库直接获取杂志,在本地执行mdbook build && mdbook watch --open 就可阅读。
  • PDF 电子版。可以通过GitHub仓库和指定渠道下载。

本刊内容希望涵盖且不限于以下内容:

  • Rust 本月简报。 从《Rust 日报》中摘录和整理本月的亮点。
  • Rust in Production。介绍 Rust 在企业内的一些生产实践和心得。
  • 开源项目。从应用到实现原理和细节,介绍 Rust 的一些优秀开源项目。
  • Rust 语言。 分享 Rust 语言概念、技巧、设计模式、工程实践等心得经验。
  • Rust 编译器。分享 Rust 编译器整体架构、实现细节、贡献。
  • Rust Security。分享 Rust 语言及生态中的一些安全漏洞诞生的原因和解决方案。
  • 游戏开发。分享 Rust 游戏开发和学习的方方面面。
  • 操作系统。分享 Rust 操作系统开发和学习的方方面面。
  • 嵌入式 Rust。分享 Rust 嵌入式开发中的点点滴滴。
  • 分布式开发。分享 Rust 在分布式领域的实践。
  • 网络开发。分享 Rust 网络开发领域的方方面面。
  • 云原生。分享 Rust 在云原生领域的实践。
  • 前端开发。 分享 RustWebAssembly 在前端的应用实践。
  • 图形化开发。分享 Rust 在 图形化开发方面的实践。
  • 大数据/人工智能。分享 Rust 在 大数据和人工智能开发方面的实践。
  • Rust 算法。用 Rust 刷算法是什么体验?

希望大家能踊跃投稿和参与编辑。可以直接给本刊GitHub仓库发PR,哪怕不一定能被入选,也可以发出你的声音!每个月一期,意味着你每个月精下心来创作一份精品文章,用来总结你在公司的实践和学习Rust的心得。

也希望越来越多的公司参与到期刊内容建设中来,对公司而言,不仅仅是经验分享,更是一种技术文化输出。

在这浮躁的年代,希望这份期刊能帮助你找回技术的初心和野望。

一月刊

logo

本月社区动态简报

精选自《Rust日报》

RustChinaConf 2020 专题

Rust in Production

学习园地

嵌入式 Rust 专题

操作系统专题

Rust Security 专题

Rust 编译器专题

本月简报:官方动态

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 专题编辑:张汉东

Rust 1.49 稳定版发布

2020年最后一天,Rust 1.49 稳定版发布了。稳定版 Rust 发布周期为六周一次。

值得关注的更新:

  • aarch64-unknown-linux-gnu 升级为Tier 1
  • aarch64-apple-darwinaarch64-pc-windows-msvc 得到Tier 2 级别的支持。
  • 单元测试中线程中的print输出将会被捕获,默认不会在控制台打印出来了。如果不需要捕获,需要添加--nocapture参数。
  • union支持 impl Drop trait 了 支持使用ref关键字让解构的字段不再被move 而是被借用。
#[derive(Debug)]
struct Person {
    name: String,
    age: u8,
}

fn main(){
    let person = Person {
    name: String::from("Alice"),
    age: 20,
    };

    // `name` is moved out of person, but `age` is referenced.
    let Person { name, ref age } = person;
    println!("{} {}", name, age);
}

https://blog.rust-lang.org/2020/12/31/Rust-1.49.0.html

Rust 将不再支持 Windows XP

目标i686-pc-windows-msvc和x86_64-pc-windows-msvc是个怪胎。它们对Windows 7+有Tier 1支持,但对Windows XP也有Tier 3支持。这是Firefox需要在XP上运行时的遗留问题。然而在他们放弃XP支持后的几年里,尽管偶尔会有修复,但大多都是任由它过期了。

因此有人建议,正式放弃这个Tier 3支持状态,可以更好地反映出目前对XP的实际支持程度,不再让一个Tier 1目标背负着实际上不支持Tier 3目标的担忧。

只要LLVM和他们的链接器仍然支持XP目标,移除官方的XP支持不会阻止任何人编译到XP(减去std)。

对Windows 7以上的目标的影响将是移除工作区和一个支持XP的运行时兼容性层。此外,还有可能使用更现代的API,而这些API可能曾经因为兼容性问题而被避免。

如果在未来,有人积极支持XP,那么最好的办法是为此创建一个新的目标。这可以从其自身的优点出发,而且它的开发不会影响到一级平台的开发。

官方团队接受了该建议。

https://github.com/rust-lang/compiler-team/issues/378

Rustup 宣布发布 1.23.0 版本

官方发布 1.23.0 版本,其中最激动人心的改变就是支持 Apple M1 设备。大家可以安心的买 M1 了!

原文链接

Rust 官方知名开发者陆续入职巨头科技公司

Niko Matsakis,Esteband K 入职 Amazon

Niko Matsakis 入职 Amazon 担任 Rust 团队的技术主管。

Niko的博客链接: https://smallcultfollowing.com/babysteps/blog/2020/12/30/the-more-things-change/

Esteband K 入职 Amazon 研究 Rust 编译器和相关工具。

Twitter 链接:https://mobile.twitter.com/ekuber/status/1345218814087053312

Patrick Walton 入职 Facebook

Patrick Walton 将领导 Facebook 的新 Rust 团队,致力于为 Rust 社区改善其编译器和生态。

Twitter 链接:https://twitter.com/pcwalton/status/1345094455712333824

futures-rs 0.3.9 发布

  • pin-project这个crate替换成了pin-project-lite, 在--no-default-features的情况下大幅提高了编译速度.
  • 增加了几个新的API方法
    • stream::repeat_with
    • StreamExt::unzip
    • sink::unfold
    • SinkExt::feed

链接:https://github.com/rust-lang/futures-rs/releases/tag/0.3.9

Rust 异常处理小组的工作范围是什么?

该小组的主要重点是继续进行小组成立前的错误处理相关工作。为此而努力系统地解决与错误处理相关的问题,以及消除阻碍RFC停滞不前的障碍。

在小组成立的最初几次会议上,制定了一些短期和长期目标,这些目标主要围绕下面三个主题:

  • 使 Error trait 在生态中应用更加普及。
  • 提升错误处理的开发体验。
  • 编写更多的关于错误处理的学习资源。

下面具体来说。

建立统一的标准Error trait。

Error trait 从 1.0开始就存在了,并暴露了两个方法。Error::descriptionError::cause。由于它最初的构造,由于一些原因,它太过拘谨。Failure crate通过导出Fail trait解决了Error trait的许多缺点。

在这一点上,加强std::error::Error trait,使其可以作为Error trait被整个Rust社区采用,自2018年8月RFC 2504被合并以来,一直是一个持续的过程。

这个过程还涉及稳定许多Error trait API和crates,截至本文撰写时,这些API和crates只在Nightly使用。这些包括backtrace和chain方法,这两种方法对于处理错误类型非常有用。如果你有兴趣关注或贡献这项工作,请看一下这个问题。

另一个相关的举措是将Error trait迁移到核心,这样它就可以更广泛地用于不同的场景(比如在FFI或嵌入式上下文中)。

增加通过回溯(backtrace)类型进行迭代的能力

到目前为止,backtrace 类型只实现了DisplayDebug特征。这意味着使用回溯类型的唯一方法是打印出来,这不是很理想。一个能够提供迭代堆栈框架的迭代器API将使用户能够控制他们的反向跟踪如何被格式化,这是一个必要的步骤,将std::backtrace::Backtrace支持添加到像color-backtrace这样的箱子中。

在研究了如何解决这个问题的策略后,我们发现回溯箱已经有了一个框架方法,可以很好地实现Iterator API。在std中公开一个相同的方法应该是一个相对简单的考验。

我们已经为此开了一个[PR](https://github.com/rust-lang/rust/pull/78299),如果有人想看的话,可以去看看。

通用成员访问

目前,当我们想要获取一些与错误相关的额外上下文时,需要调用一些特定的方法来获取该上下文。例如,如果要查看一个错误的回溯,我们会调用回溯方法: let backtrace = some_error.backtrace();。这种方法的问题是,它不可能支持在std之外定义的类型。即使是存在于std内的类型,也需要定义一个方法来访问每个各自的类型,这使得事情变得很麻烦,而且更难维护。

顾名思义,通用成员访问,当它得到实现时,是一种类型无关的方法,可以从Error trait对象中访问不同的上下文。这有个类比示例,当你要把一个字符串解析成一个数字的时候,用这样的方法。


#![allow(unused)]
fn main() {
let ten = "10".parse::<i32>();
}

或者通过迭代器来collect生成的内容时:


#![allow(unused)]
fn main() {
use std::collections::HashSet;

let a_to_z_set = ('a'..='z').collect::<HashSet<_>>();
}

跟上面用法类似,您可以通过指定错误的类型ID来访问某个上下文片段。


#![allow(unused)]
fn main() {
let span_trace = some_error.context::<&SpanTrace>();
}

这可以用来获取与错误相关的其他上下文,如错误的回溯、错误的来源、状态码、替代的格式化表示(如&dyn Serialize)。

这个功能将使我们计划在以后添加的其他功能成为可能,比如提供一种方法来报告程序中错误来源的所有位置,以及提供一种除了显示和调试之外的更一致的错误报告格式。

Jane在推动这些想法上做了很多工作。你可以查看相关的RFC

编写一本Rust错误处理最佳实践的书

最后但并非最不重要的一点是,围绕创作The Rust Error Book的团队引起了很多兴趣。 本书的目的是根据各自的用例来整理和交流不同的错误处理最佳实践。 这可能包括FFI用例,或有关从程序返回错误代码的最佳实践。

这是一项持续不断的工作,在接下来的几周和几个月中将会看到许多进步!

脚注

  • Error::description方法只支持字符串片段,这意味着创建包含附加上下文的动态错误信息是不直接的。这个方法被弃用,改用Display
  • Error::cause方法,现在被称为Error::source,并没有强制要求错误具有 "静态生命周期",这意味着 downcasting 错误源是不可能的,这使得使用动态错误处理程序来处理错误变得更加困难。

Rustdoc 性能提升

有两个PR明确地旨在提高rustdoc的性能:

  • Rustdoc:缓存已解析的链接#77700。该PR将文档生成的链接的时间缩短了 90%
  • 不要在文档内链接中寻找覆盖实现(blanket-impls)#79682。因为它从来没有起过作用,并且已经引起了严重的性能问题。

Rustdoc 团队还清理了一些技术债务。比如 jyn514 不久前注意到,Rustdoc中的大部分工作都是重复的: 实际上有三种不同的抽象语法树(ast)!一个用于doctree,一个用于clean,还有一个是编译器使用的原始HIR。Rustdoc花费了大量的时间在它们之间进行转换。大部分的速度改进来自于完全去掉部分AST。

文章里也介绍了Rustdoc的工作原理:

  • 运行编译器的某些部分以获得需要的信息。
  • 删除编译器提供的不需要的信息(例如,如果一个项目是doc(hidden),就不需要它)。这一部分有很多话要说,也许会再写一篇博文来详细介绍。
  • doctree pass,它在编译器的某些项目上添加了一些rustdoc需要的额外信息。
  • clean pass将编译器类型转换为rustdoc类型:基本上,它将所有内容都转换为 "可打印 "内容。
  • 渲染(render)通证,然后生成所需的输出(HTML 或,在Nightly,JSON)

更多内容: https://blog.rust-lang.org/inside-rust/2021/01/15/rustdoc-performance-improvements.html

Nightly的Reference已上线Const Generics的文档

Const Generics 计划在1.50版进入stable,官方今天在nightly的Reference上已更新好相关文档。

链接:https://doc.rust-lang.org/nightly/reference/items/generics.html#const-generics

Nightly Edition Guide 文档增加了 Rust 2021 Edition 小节

内容还在逐步更新,可以先关注。

链接: https://doc.rust-lang.org/nightly/edition-guide/rust-next/index.html

RFC 2945 : "C unwind" ABI 支持相关情况

官方 FFI-Unwind 项目工作组已经将 RFC 2945 合并了。该 RFC 描述了对 "C unwind" ABI 的支持。

RFC 概要:

引入了一个新的ABI字符串“C-unwind”,以支持从其他语言(如c++)到Rust框架的unwind,以及从Rust到其他语言的unwind。此外,当unwind操作以“non Rust”、“non C-unwind”ABI到达Rust函数边界时,我们为之前未定义的有限几种情况定义了行为。作为该规范的一部分,我们引入了术语“Plain Old Frame”(POF)。POF帧不会挂起析构函数,可以轻松地释放析构函数。这个RFC没有定义被外部异常展开的Rust框架中的catch unwind行为。

引入动机:

有些Rust项目需要跨语言展开以提供所需的功能。 一个主要的例子是Wasm解释器,包括LucetWasmer项目。

还有一些现有的Rust crate(尤其是围绕libpng和libjpeg C库的包装器)会在C帧之间出现混乱。 这种展开的安全性取决于Rust的展开机制与GCCLLVMMSVC中的本机异常机制之间的兼容性。 尽管使用了兼容的展开机制,但是当前的rustc实现假定“extern C”函数无法展开,这允许LLVM在这种展开构成未定义行为的前提下进行优化。

之前已经在其他RFC(包括#2699和#2753)上讨论了对此功能的需求。

RFC 2945: https://github.com/rust-lang/rfcs/blob/master/text/2945-c-unwind-abi.md

现在 FFI-unwind 工作组正在为C-unwind ABI 指定新的行为(覆盖之前的未定义的行为),RFC 2945 实现PR

然而,在起草 "C unwind " RFC 时,工作组发现围绕longjmp和类似函数的现有保证可以改进。虽然这与unwind并没有严格的关系,但它们有着密切的联系:它们都是 non-local 的控制流机制,防止函数正常返回。由于Rust项目的目标之一是让Rust与现有的C系语言互操作,而这些控制流机制在实践中被广泛使用,工作组认为Rust必须对它们有一定程度的支持。

这篇博文将解释该问题。如果你有兴趣帮助指定这种行为,欢迎参与!

官方博文地址:https://blog.rust-lang.org/inside-rust/2021/01/26/ffi-unwind-longjmp.html

Rust Playground 支持 vim 模式

Rust Playground vim 模式,可以通过输入 :w 回车运行编译,非常棒的使用体验。

本月简报 | 社区热点

Async-std v1.9.0 发布

这个版本发布了稳定的 async_std::channel 子模块,并引入了 tokio v1.0 的功能,同时,移除了不赞成使用的sync::channel类型。


#![allow(unused)]
fn main() {
use async_std::channel;

let (sender, receiver) = channel::unbounded();

assert_eq!(sender.send("Hello").await, Ok(()));
assert_eq!(receiver.recv().await, Ok("Hello"));
}

链接,https://github.com/async-rs/async-std/releases/tag/v1.9.0

Deno in 2020

一直很火热的 Deno 官方最近发布了 Deno 的大事记表。 其中 1 月份进行了将 libdeno 替换成 rusty_v8 的工作。之前是使用 libdeno(C++ 写的) 来进行绑定 V8 的操作。现在替换成 Rust 原生实现的 rusty_v8。并且 rusty_v8 是一个单独的 Rust crate。

The Rust on Raspberry Pi Pico Charity Live Stream

在树莓派上写 Rust 是一种怎样的体验?最近一位国外友人就尝试这么做了,并且进行了直播。具体详情可以戳此链接。 Rust 在嵌入式开发领域还是有非常大的潜力的。

想要看更多关于 Rust 的流媒体视频,可以关注这个项目 awesome-rust-streaming

Sequoia PGP 发布 1.0 版本

2018 年,三位 GnuPG 开发者开始着手开发 Sequoia,这是 OpenPGP 在 Rust 中的实现版本。OpenPGP 是一种非专有协议,为加密消息、签名、私钥和用于交换公钥的证书定义了统一标准。

通过官方博客可以看出团队对当前版本对于安全性的思考和对未来下一步的规划。

Firecracker

Firecracker 是一种开源虚拟化技术,专门用于创建和管理安全的,多租户容器和基于功能的服务。

项目地址

Rust GUI 编程介绍

Rust GUI 方面的介绍以及目前 Rust GUI 库的现阶段状况

原文链接

Facebook 使用 Rust 的简单介绍

该 twitter 快速的介绍了 Rust 在 facebook 中的使用历程:

2017 年开始应用于一个资源控制项目,后来证明性能和稳定性都比 C++好。 之后,更多的工程师开始使用 Rust 在各种项目中,例如 Diem,Hack,Mononoke。 在 dev tools 中证明 Rust 可行之后, 开始在后端和手机应用中使用 Rust 很多工程师来自 python 和 javascript 语言,Rust 的强类型和高性能让这些工程师不再挣扎于运行时的 bug。 为了让 Rust 更广泛的使用,设立了一个专门的 Rust 小组来支持其他的工程师在不同的项目中使用 Rust。 该小组同时在 Rust 社区中也非常活跃,贡献代码。

原文链接

时隔一年 tower 终于发布新版本啦

Tower 是一个模块化和可重用组件库,用于构建健壮的网络客户端和服务器。上一个版本 0.3.1 版本是 2020 年 1 月 17 发布的,新版本 0.4.0 是 2021 年 1 月 7 号发布的,这个版本包含了大量改动,包括使用了 tokio 1.0,将所有的中间件转移到了 tower crate,改造,添加了中间件 API。

不过这次变更并没有核心 Service 或者 Layer trait,所以新版本还是依赖着 tower- service 0.3 和 tower- layer 0.3,因此新版本是兼容使用这两个 crate 的库的。更多发布细节请移步下面的链接。

原文链接

项目地址

Rust Search Extension 1.1.0 发布

Rust Search Extension 发布了最新版,同时也突破了 500 个 star,感谢大家的支持!这个版本主要功能如下:

  • ! 搜索改成了 docs.rs,!! 改成了 crates.io。
  • 给 Rust 仓库的 release 页面增加了目录菜单。
  • Rust 标准库文档页面和源码页面所有 "since" 和 "issue" 标签分别会链接到仓库的 release 页面对应的版本和 GitHub 对应的 issue 页。

为什么 2021 年将成为系统程序员的 Rust 年?

Gartner 今天的一篇博文报道了“Rust”:近年来,Rust 获得了很多粉丝,并且有充分的理由。Rust 旨在成为满足系统编程需求的 C++ 的可靠替代品。

原文链接

Open Source Security, Inc.宣布为Rust的GCC前端提供资金

Open Source Security, Inc.宣布为Rust的GCC前端提供资金 开源安全公司(Open Source Security,Inc)自豪地宣布,它为Rust的GCC前端的全职和公共开发工作提供了资金。在此博客文章中,作者将详细介绍我们参与的动机以及公众将因这项努力而获得的利益。

原文链接 : https://opensrcsec.com/open_source_security_announces_rust_gcc_funding

Rust GUI框架的全调研

这篇文章对几乎目前Rust社区较为流行的GUI框架做了整体的调研,druid和iced表现还不错。

Redox OS 最近公布了2020年的财务明细。

主要的收入是通过捐赠,包括Patreon网站,paypal,和接收到的一些比特币和以太坊。 《Redox OS Summer of Code》是主要的支出预算,其他网站服务的支出,包括亚马逊的EC2,Jenkins服务器,Gitlab服务。

开源项目的明细能做到公开、明细还是非常值得社区学习的。

原文链接: https://www.redox-os.org/news/finances-2020/

Rust 官方团队 Wesley Wiser 宣布入职微软

From Twitter:

Wesley Wiser: I'm very pleased to announce that I will be joining @Microsoftto work on the @rustlang compiler team they are forming!

Wesley Wiser 在 twitter 宣布:加入微软,并且为「微软组织的Rust编译器团队」工作。Wesley Wiser 在 2020 年 12 月刚出任 官方 Rust 编译器团队 co-Leader。

到目前为止,编译器的三大 Leader :Niko 和 Felix 去了亚马逊,Wesley Wiser 去了微软。之前 Facebook 也在招人组建 Rust 编译器团队,侧面反应出大厂们对 Rust 正在做战略布局。

原文链接:https://twitter.com/wesleywiser/status/1354896012113022984

本月简报 | 推荐项目

「微软」Rust for Windows

这个仓库是 1 月 20 日微软发布的官方 Win32 API crate。

过去用 rust 为 Windows 开发应用程序时,若要调用 Win32 API,必须使用 winapi-rs 这样的 wrapper 库,此类库需要社区去人工维护和 Win32 API 的绑定。 为了改善这点,微软通过 win32metadata 项目来加强对 C/C++ 以外的编程语言的支持(相关链接), 其中就包括对 rust 的支持。

现在已经有使用该库实现的扫雷程序, 除此之外,也有微软工程师发布了一些示例项目

Czkawka

Czkawka 是一个多平台的空间清理应用,可用于找出系统中的重复的文件、空文件夹、临时文件等。

项目采用 gtk3/gtk-rs 开发 GUI 部分, 同时也提供 CLI 程序。

czkawka

Artichoke

Artichoke 是一个由 rust 开发的 ruby 实现,可以将 ruby 代码编译至 WebAssembly。

当前 Artichoke 依然依赖于 mruby backend,在与 mruby 进行 FFI 交互的同时,改进某些 Kernel 和库函数的实现。例如 regex 部分就是由 rust 实现的。

作者表示在未来会开发出一个纯 rust 的实现。

linfa

linfa 是一个机器学习的框架和工具集,其设计参照了 python 的 scikit-learn 库。

关于 rust 在机器学习方面的生态系统,可以参考 arewelearningyet

async-trait-static

async-trait-static 是一个用于在 trait 中声明 async 方法的库,可以在 no_std 下使用。

由于 rustc 的限制,要在 trait 中写出 async 方法是很困难的。 针对这个问题,dtolnay 实现了 async-trait,将 async fn 的返回类型转化为 Pin<Box<dyn Future>>

async-trait-static 则采用了 GAT 来实现这个功能,无需用到 trait object。

当前 rust 的 GAT 依然不够完善,因此该库还是有些功能是缺失的。

regexm

regexm 是一个用于对正则表达式进行模式匹配的库:

fn main() {
    let text1 = "2020-01-01";
    regexm::regexm!(match text1 {
        r"^\d{4}$" => println!("y"),
        r"^\d{4}-\d{2}$" => println!("y-m"),
        // block
        r"^\d{4}-\d{2}-\d{2}$" => {
            let y_m_d = "y-m-d";
            println!("{}", y_m_d);
        }
        _ => println!("default"),
    });
}

swc

swc 是一个 typescript/javascript 的 transpiler,在运行速度上,单核比 babel 快 4 倍,4 核比 babel 快 70 倍,同时也具有 treeshaking 的功能。

swc 被用于 deno 项目中,用于类型擦除。 swc 的作者是一名 97 年的大二学生,如今已经获得了 Deno 官方的顾问合同。

rlink-rs

国产项目

rlink-rs是基于rust实现的流式计算引擎,用来作为Apache Flink的替代方案。

相对于在线业务,rlink-rs更关注海量数据的离线流式处理场景,提升吞吐能力、降低资源消耗。其特点是针对exactly once提供计算和输出两种语义;基于特殊的exactly once输出语义,结合rust内存管理模型,实现大部分场景的全内存计算,解决state和checkpoint引起的重量级IO操作。

rlink-rs的目标是成为一个计算驱动引擎,允许基于DAG定制你自己的计算流程、实现自己的计算语义。

目前状态:主要针对flink流计算这块做对比。已经实现基本窗口计算流程。

希望能从社区得到关于流引擎设计方面的帮助:

1.因为rust语言不如Java动态语言可以反射,在用户api上不那么优雅。 2.只是想在语义上实现类似flink的api,实现上还是想走一条新的路线,毕竟flink有历史包袱,它的实现我们不需要100%参考。

Rapier 2021的路线图

Rapier 是一个完全免费的开源物理引擎,可用于游戏,动画和机器人,完全使用 Rust 编程语言编写。 它着重于性能,可移植性和跨平台确定性(可选)。

Rapier 团队希望到2021年年底,Rapier 具有游戏物理引擎所期望的所有功能,实现流行的 C++ 物理引擎,比如:Box2d,Bullet Physics 和 PhysX 等同等的功能, 但是不打算在 GPU 上支持运行物理仿真。

2021 路线图链接:https://www.dimforge.com/blog/2021/01/01/physics-simulation-with-rapier-2021-roadmap/

Psst:使用Rust和Druid构建的第三方Spotify客户端

Psst 是一款GUI的快速Spotify客户端,不带Electron,内置Rust。

Druid是一个原生Rust GUI库,支持Windows,macOS,Linux,之前是xi-editor的一部分。

slotmap: 1.0 released

slotmap 提供了三种 map 的实现, SlotMap, HopSlotMap 和 DenseSlotMap.

增加,删除,查询均为O(1)复杂度,而且额外开销非常低. 非常适合存储需要稳定和安全引用的 objects, 例如游戏中的 entities, graph 中的 nodes.

Rust 的 WebDriver库

Thirtyfour是一个用于Rust的Selenium / WebDriver库,用于自动化网站UI测试。

它支持完整的W3C WebDriver规范。经过Chrome和Firefox的测试,尽管任何与W3C兼容的WebDriver都可以使用。

webrtc.rs

用 Rust 重写 Pion WebRTC (http://Pion.ly)。目前 v1.0 仍然处于开发中,欢迎开源贡献者提PR。

Rust中的科学计算

这篇文章中作者分享了在课余时间用Rust重写生物膜仿真过程中遇到的问题。

由于crates.io上找不到SciPy的代替品,作者自己实现了一个bacon-sci。

shadow-rs 0.5.14 支持自定义钩子

shadow-rs是一个使得程序能在运行时读取到编译过程中信息的库,这些信息包括:

  • Cargo.toml 中的项目版本
  • 依赖信息
  • git commit
  • 编译中用到的Rust工具链
  • build类型,debug版还是release版

之前想要增加加自定义信息会很麻烦,在0.5.14支持了自定义钩子后就容易多啦。

Ballista:分布式计算平台

Ballista 用 Rust 实现的概念验证分布式计算平台,使用 Apache Arrow 作为内存模型。它建立在一种体系结构之上,这种体系结构允许将其他编程语言作为一级公民进行支持,而不需要为序列化付出代价。

德国亚琛工业大学研究项目:RustyHermit 介绍

相关链接:

RustyHermit 是一个 Unikernel(我理解这就是 Unique-Kernel 的缩写,独立内核?)。 Unikernel 被认为是有可能改变未来云生态格局的技术。

Unikernel是使用libOS(library os)构建的具有专门用途的单地址空间机器镜像。为了支撑程序的运行,开发者从模块栈中选择最小的类库集合,构建对应的OS。类库和应用代码、配置文件一起构建成固定用途的镜像,可以直接运行在hypervisor或者硬件上而无需Linux或者Windows这样的操作系统。所以,也有人称它为下一代容器技术。

Unikernel 其最大的卖点就是在,没有用户空间与内核空间之分,只有一个连续的地址空间。这样使得 Unikernel 中只能运行一个应用,而且对于运行的应用而言,没有硬件抽象可言,所有的逻辑,包括应用逻辑和操作硬件的逻辑,都在一个地址空间中。

但是目前 Unikernel 仍然出于研究阶段。

RustyHermit 是依赖于 libhermit-rs(库操作系统)实现的。

这两个项目都出自 亚琛工大,有意思的是,它们都是基于著名的 Rust实现操作系统教程phil-opp 衍生实现的。

用 Rust 编写现代操作系统

仓库链接

Theseus 是从Rust编写的新操作系统,尝试使用新颖的OS结构,更好的状态管理以及如何将OS职责(如资源管理)转移到编译器中。

我们一直在不断改进操作系统,包括其故障恢复能力,以提供更高的系统可用性而没有冗余,以及更轻松,更随意的实时演进和运行时灵活性。尽管仍然是一个不完整的原型,但我们认为These修斯将对高端嵌入式系统或边缘数据中心环境很有用。请参阅我们的已发表论文,以获取有关These修斯的设计原理和实现理念的更多信息,以及我们避免状态泄漏现象或尽可能减轻其影响的目标。

Evcxr: A Rust REPL 的解决方案

并且它还包含了 Jupyter Kernel 指南

该项目挂在 Google 的 GitHub 组织下。

Findomain: 可提供子域监视服务

该服务可提供:目录模糊处理/端口扫描/漏洞发现(使用Nuclei),等等。

允许您使用多个顶级工具(OWASP Amass,Sublist3r,Assetfinder和Subfinder)监视目标域,并在出现新的子域时将警报发送到Discord,Slack,Telegram,电子邮件或推送通知(Android / iOS / Smart Watch / Desktop)。

您唯一要做的就是使用您的电子邮件地址(如果适用)或/和webhooks / Telegram聊天信息配置文件,然后将域放入另一个文件中。

一旦完成,您便拥有了一个完全自动化的子域监视服务,可以让您 包含最新发现的新子域,主机IP,HTTP状态,HTTP网站的屏幕快照,开放端口,子域CNAME等。 您所有的数据都安全地保存在关系数据库中,您可以随时请求转储数据。

Weylus:让你的平板电脑用作电脑上的图形平板/触摸屏

特点:

  • 用平板电脑控制鼠标
  • 将屏幕镜像到平板电脑上

上述功能在所有操作系统上都可以使用,但WeylusLinux上效果最好。Linux上的其他功能有:

  • 支持手写笔/笔(支持压力和倾斜)。

  • 多点触控。用支持多点触控的软件试试,- 比如Krita,你就会知道了。

  • 捕捉特定的窗口,并只对其进行绘制。

  • 更快的屏幕镜像

  • 硬件加速视频编码

  • 平板电脑作为第二屏幕

  • 仓库链接

本月简报:学习资源

🎈Rust Design Patterns Book

非官方好书系列, 再次安利! Rust Design Patterns Book. 作者最近更新了很多东西。

看下翻译的中文引言吧。

引言

设计模式

在开发程序中,我们必须解决许多问题。一个程序可以看作是一个问题的解决方案。它也可以被看作是许多不同问题的解决方案的集合。所有这些解决方案共同解决一个更大的问题。

在Rust中的设计模式

有许多问题的形式是相同的,由于事实上,rust不是面向对象设计,模式不同于其他面向对象程序设计语言,虽然细节是不同的,因为他们有相同的形式,他们可以解决使用相同的基本方法。

设计模式是解决编写软件时常见问题的方法。

反模式是解决这些相同问题的方法。

然而,尽管设计模式给我们带来了好处,反模式却带来了更多的问题。

惯用法,是编码是要遵守的指南,他们是社区的社区规范,你可以破他们,但如果你这样做,你应该有一个很好的理由。

TODO: 说明为什么Rust是一个有点特殊功能要素,类型系统,借用检查。

book 链接

🎈异步书翻译更新啦

这次翻译新增了第八章-关于生态的叙述(@EthanYuan) 以及第九章http服务器项目(@huangjj27), 欢迎来指正错误或贡献~

english book link

中文翻译链接

🎈Manning的Rust新书《Refactoring to Rust》

这本书正在MEAP阶段,目前才更新了3章,感兴趣的同学可以看看。

Refactoring to Rust

🎈Rust 书籍宝库

glynnormington整理了网络上大部分有关rust的mdbook,有官方的,也有非官方的。值得注意的一点是大家关注的rust宏小册很多人以为一直没有更新,但是其实有另一个团队重新在原来的基础上,更新了新的版本,目前已收录到该书库中。

Rust 书籍宝库

Read More on reddit

🎈使用Rust 编写一门语言

有关使用Rust编程语言制作称为Eldiro的编程语言的系列文章。

原文链接

Rust 错误处理: python 同学专用

本文是python同学专用,介绍了python日常中的错误处理以及如何在rust中达到类似效果和最佳实践。

原文链接

🎈其他语言调用Rust - C++

作者选择Rust作为运行时库的实现语言,并且希望使同一库可用于不同的编程语言。

最初,选择从对三种语言的支持开始:

  • Rust:因为这是我们的实现语言。
  • C ++:这是我们熟悉的低级语言,仍然是嵌入式设备领域中最成熟的语言之一。
  • JavaScript / TypeScript:因为它是一种非常流行的动态语言。

img

Rust库(也称为板条箱) 分为两部分,共享实现板条箱和精简惯用的API条板箱。

对于JavaScript,我们使用Neon公开API。Neon使我们能够方便地编写JavaScript API和创建NPM包。

C ++部分更具挑战性。

原文链接

🎈使用 Rust 创建一个模拟器: part 1

这个系列中,作者会通过神经网络和遗传算法制作一个进化模拟器。

作者首先会介绍神经网络和遗传算法是如何工作的,然后会使用Rust来实现他们,并且编译成WebAssembly,下图是一个预览图。

教程地址: https://pwy.io/en/posts/learning-to-fly-pt1/

🎈Rust陷阱: repr(transparent)

repr(transparent)可以让类似struct Foo(i32)和i32有同样的内存分布方式。他作用范围非常具体,只能有一个非 0 size 的字段。

本文章介绍了如何使用repr(transparent)以及一些陷阱。

原文链接:https://jack.wrenn.fyi/blog/semver-snares-transparent/

🎈Unsafe Rust:该如何或何时使用它

本文包含了以下内容:

  • 关于 Unsafe Rust 的五点迷思
  • 什么时候不该用 Unsafe 的代码
  • 处理未初始化的内存
  • 内部可变性
  • 内在动机
  • 内联汇编
  • FFi
  • 编写Unsafe Rust时候应该使用的工具

原文链接:https://blog.logrocket.com/unsafe-rust-how-and-when-not-to-use-it/

🎈Mozilla: 如何导出 Rust 组件给 Kotlin

Mozilla应用服务平台这个仓库中提供了一个login组件可以很好地展示这个示例。

概要:

假设你已经的组件在./src/目录下编写了一个不错的Rust核心代码。

首先,你需要将Rust API扁平化为一组FFI绑定,通常是在 ./ffi/ 目录下。使用 ffi_support crate来帮助实现这个功能,这将涉及到在核心Rust代码中实现一些特性。

接下来,你需要编写消耗FFIKotlin代码,通常是在./android/目录下。这段代码应该使用JNA通过共享库加载编译后的Rust代码,并将其作为一个漂亮的安全且易于使用的Kotlin API暴露出来。

似乎我们很可能在这里提供一个有用的模板来让你入门。但我们还没有这样做。

最后,将你的包添加到android-components repo中。

文章还回答了一些导出过程中的问题。

原文链接:https://github.com/mozilla/application-services/blob/020a3eb831da8cd9d21978e3d1fb7af3a6ffcfea/docs/howtos/exposing-rust-components-to-kotlin.md

入门教程:用Rust写一个todo应用

在这篇教程里,作者依照javscript的传统,教你用Rust写一个todo应用。 你会学到:

  • Rust中的错误处理
  • Option的使用
  • Struct和impl
  • 终端输入输出
  • 文件操作
  • 所有权和借用
  • 模式匹配
  • 迭代器和闭包
  • 使用外部crate

链接:https://www.freecodecamp.org/news/how-to-build-a-to-do-app-with-rust/

🎈LibHunt: 根据reddit 被提及状态展示 rust 库的热度

LibHunt根据reddit上大家提及到库的热度来排序出一些热门的rust库.

对于调研阶段的同学来说,是一个很好的工具.

libhunt的主页地址: https://www.libhunt.com/lang/rust

🎈用 Rust 实现一个 Rest Client

这是 Zero To Production In Rust 的这本书中的一个示例。在本文,作者演示了:

  • 如何使用reqwests来写一个REST API client。
  • 如何来使用wiremock来进行测试。

原文链接: https://www.lpalmieri.com/posts/how-to-write-a-rest-client-in-rust-with-reqwest-and-wiremock/

🎈太素OS:基于 RISCV 架构的 Rust 系统内核实现(中文)教程和源码

构建于QEMU 之上,适合学习

【译】Async/Await(二)—— Futures

新的文章翻译来啦。

来自:公众号:「Rust 碎碎念」,翻译 by:Praying

Rust Programming Language: The Ultimate Guide

这篇文章中作者从伪代码出发,一步步教你实现一个爱情计算器。

作者称这是线上最通俗易懂的Rust入门指南,你怎么认为呢?快来试试吧。

链接:https://masteringbackend.com/posts/rust-programming-the-ultimate-guide

Rust: Initial thoughts

作者分享了自己刚开始学Rust的一些想法和与其它语言的对比。

原文链接

关于Future::join设计的思考

这篇文章中作者分享了关于如何将Future::{try_}join{try_}join!以一种更一致的形式加入标准库中的思考,以及对于const-eval可能起到的作用的讨论。

原文链接

Rust 教程: 从头开始学 Rust

Rust越来越被更多的人喜爱,很多小伙伴也想入坑。这篇教程可以帮助零基础的小伙伴了解 Rust。

img

原文链接

ref vs & in variables

帖子讨论了ref和&的使用,哪个使用更好。

原文链接

在Rust中包装错误

在开发时错误处理是必须,有时错误处理非常糟糕,文章中提高了warp Error提高体验。

原文链接

on reddit

本月简报 | Rust 唠嗑室本月汇总

《Rust 唠嗑室》第 16 期 - tensorbase 高性能数据仓库

时间: 2021/01/05 20:30-21:30

主讲人:金明剑

内容:金明剑老师在 RustChinaConf2020 上分享了《基于 Rust 构建高性能新型开源数据仓库》,很多人感兴趣 Tensorbase 的技术内幕,这次唠嗑室一起来聊 Tensorbase。

查看回放

扩展资料

RustChinaConf2020 大会合集


《Rust 唠嗑室》第 17 期 - 用 Rust 写 Protobuf 扩展

时间: 2021/01/19 20:30-21:30

主讲人:宁志伟

内容

Protocol Buffers (简称 Protobuf ) ,是 Google 出品的序列化框架,与开发语言无关,和平台无关。具有体积小,速度快,扩展性好,与 gRPC 搭配好,支持的语言多等特点,是目前应用最广泛的序列化框架。

CITA-Cloud 是一个以区块链技术为基础,融合云原生技术的柔性集成开放平台。区块链部分提供了非常灵活的微服务架构,可以适应各种各样的企业应用场景。

CITA-Cloud 计划提供一个框架,方便用户自定义交易和区块等核心数据结构。使用 Protobuf 的扩展能力,用户只需用 Protobuf 描述数据结构,框架会自动生成相关代码,得到一个定制的区块链。

这次主要来聊聊 Protobuf 扩展的原理,以及 Rust 已有的相关的库。最后通过一个 Demo 展示如何使用 Rust 来写 Protobuf 扩展。

查看回放

扩展资料

CITA-Cloud

CITA-Cloud 文档

cita_cloud_proto

Dropbox-pb-jelly

rust-protobuf

Prost

Demo 代码

RustChinaConf2020 精选 | JIT 开发实践

说明:本文为视频演讲文字版,编者听录的时候可能会出现一些误差,欢迎指正。

后期编辑: 大海,编程爱好者,对技术充满热情。

讲师:

周鹤洋wasmer核心开发者,南航2018级本科生,主要掌握编译/OS/VM/微架构等技术,2017年开始使用Rust.

视频地址:https://www.bilibili.com/video/BV1Yy4y1e7zR?p=18

JIT技术含义及应用场合

​ JIT技术全名为 Just-In-Time compilation,翻译为"即时编译",是在运行期进行编译的方法,是将源代码或更常见的字节码到机器码的转换,然后直接执行的方法。JIT技术主要应用在各种语言的虚拟机上。在其他场合,比如动态链接器,会在运行之前动态重启程序,对它进行链接; 在linux 内核中, ebpf技术和5.10版本最新引入的static calls机制都使用了类似JIT的机制。

​ 以虚拟机(VM)为例来简单介绍下JIT技术的应用。VM技术,可以大致分为三类,简单的解释器,优化的解释器和即时编译。简单的解释器,类似wasmi,由于对标准的实现非常好,导致没有资源去做优化,没有为运行效率做优化。其次是优化解释器,比如CPython,wasm3,BEAM(erlang 解释器). 而第三种则包括绝大多数高性能运行时虚拟机,JVM,CLR,V8,LuaJIT,Wasmer,Wasmtime.

​ 虚拟机主要应用于当我们需要执行的目标代码格式与机器指令格式不一致时,需要翻译处理的情况。然而当出现我们无法直接静态地翻译到目标机器指令的特性,比如说动态特性( javascript的一些动态约束),硬件层面难以实现的沙盒特性,比如WebAssembly的内存隔离, 不同的指令集,比如从riscv动态编译到aarch64或者x86-64指令集情况下,我们就需要使用二进制翻译器去进行Jit编译。

​ jit的优点很明显,可以让程序更效率地运行,可以① 动态优化代码②高效支持语言动态特性和安全要求③ 在一些特殊场合比如static call机制和动态链接器,支持运行环境的初始化操作来避免运行时的大量开销。

我们现在从动态优化方面来讲述jit相对传统静态编译的关键点。

图1 不同引擎在各JIT优化级别所采用的编译器后端

​ 如图1所示,以JavaScriptCore,V8,Wasmer三个引擎为例,他们均实现了用户可以自由选择后端的操作或者在运行时自动在不同后端间切换的方式,使得可以支持编译优化从低优化级别切换到高优化级别,并且经过未经优化代码的时候,再切换回去的操作。

​ 这里动态优化的流程是我们通过不断Profile,追踪运行状态,去编译优化等级更高的代码,同时编译开销变大,也会做deoptimize操作,当优化的代码做一些错误的假设时,我们就需要回滚。

而用来实现动态切换优化级别的主要技术是OSR技术 ,即栈上替换(on-stack replacement).

图2 切换优化等级:OSR

​ 让我们来看看OSR技术的简易流程。如图2所示,调用栈出现了左边的假想情况时,函数Baz代码优化从解释执行提升到 jit级别1 时, 运行时就会触发函数baz的编译,一旦编译完成,则会发生调用栈的重构,使得 原调用栈中所有函数Baz的记录映射到Jit级别1 的堆栈结构上,使得在原来状态基础之上,以Jit级别1的机器码上继续运行。代价 是 提升了计算的复杂度。

​ 我曾经的一个工作, 在wasmer中实现的OSR技术。 OSR入口动态加载Image,在OSR退出的时候把image提取出来(从调用栈到wasm抽象表示,回到另一种优化等级的wasmer调用栈内的结构。(8:39)

图3 动态切换机制实例随时间变化的性能曲线

​ 图3则是我当时项目benchmark的表现。在图3中,singlepass是我编写的编译最快,运行最慢的后端。llvm是优化等级最高的后端。红线为使用LLVM后端的性能曲线,蓝线为前面2s左右使用singlepass后端,后面使用llvm后端的性能曲线。

​ 如果我们直接用llvm编译的话,我们就需要在程序执行之前,在测试程序中等待2s左右。如果我们引入动态切换机制,在程序启动时可以先使用编译快,但执行满的引擎去做执行,当优化等级高的编译器准备好之后,就动态地切换执行流,得到二者的平衡。红线和蓝线后面没有重合,只是由于我们在蓝线上针对一些做了一些额外操作,性能理论上还是一样的。

​ **我要介绍的第二钟动态优化技术 是 内联缓存inline caching。**我了解到有两种典型的用例。

  1. 一些动态语言中的method lookup (方法查找)

    for (let x of list){
        document.write(x); // method lookup
    }
    

    ​ 其中write函数是可以被动态重写的,但是这种情况发生的概率非常小,所以说我们在运行时可以假设它不变,去编译生成机器码。当假设不成立的时候,回滚。本来需要从哈希表中查找该方法,对缓存不友好,运行速度慢。

    ​ 所以我们可以直接对该指令映射为一个缓存槽(slot),把write函数对应的某些标记和write函数地址写入,检查运行条件是否符合,符合就可以直接执行,避免哈希表查找的开销,否则进行回滚。

  2. RISC-V二进制翻译

    图4 RISC-V 二进制翻译代码示例

​ 在RISC-V当中,主要有访存指令和跳转指令会涉及到较大的内存结构查找开销。

​ ① 对于全系统模拟的访存指令(load/store),需要在内存管理单元钟进行tlb lookup,用软件实现非常慢,遍历4层页表。或者在一些高层次结构的模拟时,在b-tree结构去查找内存空间,效率也很低。

​ 对于这种指令,我们可以对指令关联一个缓存槽,当该指令第一次需求查表的时候,将查表预期的虚拟地址范围和真实物理地址 写入到缓存槽中,以后每次执行到该指令时,我们就直接用缓存信息直接提取内存信息即可。

​ ② 如图4,jalr指令,间接跳转指令的例子。对于这种指令,除了需要mmu lookup , 还需要查找Jit 翻译,即被翻译后的字节码(translation lookup),共两层查找。 而内联缓存技术就可以消除这两层查找的开销。

让我来介绍一下关于内联缓存我所做的简单应用吧。 rvjt-aa64项目 是我所完成的riscv到aarch64的jit引擎(rvjit-aa64)

图5 rvjit-aa64 快速路径(从内存加载数据)

​ 图5展示了访存指令的快速路径,可以看见我们分配了关于上界和下界的两个缓存槽。检查目标虚拟地址是否位于预期界限当中,如果在范围内,就直接加载,不用回滚到解释执行了。否则就走慢速路径,执行查表处理。

图6 rvjit-aa64慢速路径(从内存加载数据)

​ 图6展示了访存指令的慢速路径。当发生load/store miss时,我们就会针对地址addr进行查表, 检查读写权限和相关信息,如果可以的话就将其写入缓存槽内,下次就可以快速执行。

接下来我来介绍有关内存安全方面的内容。

​ 我们知道rust作为一个以安全性著称的语言,保证safe代码内存安全。所以我们就需要在运行时通过动态的机制确保内存安全。

​ 我以空指针检查和访问越界检查为例来介绍Jit如何确保内存安全。

​ ①空指针检查:

​ 比如在java,c#这类有空指针的语言中,我们会遇到一个很常见的情况。当引用为空的时候,我们不应该对它解引用并且成功。我们应该检查它是否为空,如果为空,应该产生异常而非解引用。一个显而易见的方法是if (a == null){ throw Exception(...)},但这样开销很大。如同下面代码所展示的,在mov指令前需要插入cmpje指令,就会增加额外的分支预测的开销。

1: 1 cmp $0, %rdi
2: je null_pointer_exception 
3: mov %rdi,16(rsp)
...

null_pointer_exception:
	call host_npe_handler
	...

​ 所以我们可以尝试一些别的方法。利用硬件trap机制,访问空指针时,从第三行mov指令直接trap到sigsegv异常(以Linux为例),从而让硬件去检查我们的指针有效性。

​ ②访问越界检查

​ 对于webassembly中线性内存访问 的处理也可以使用trap机制,比如wasmer和wasmtime的处理方法是,直接分配6GB的虚拟地址空间,只对其中有webassembly分配的区域去做映射。一旦访问到存在映射区域以外的区域时,就会抛出异常,被sigsegv处理器捕获。这样是以慢速路径中的时间增加为代价去换取快速路径上的开销,因为慢速路径钟加入了sigsegv异常处理机制,而快速路径则不再需要界限判断。

​ 当然具体的细节会复杂一些,比如wasmer中一段代码,采用Unix信号处理同步异常.调用low level的system api去绑定,关联这些异常信号到处理器上,处理器会分发,然后进一步找出路径.

最后我们来介绍一下linux kernel中运用到jit方法的一些技术。

​ ①比如ebpf,是一种允许用户代码安全接入内核的机制. 他有interpreter和jit两种实现方式.大多数主流架构都是用Jit实现.

图7 Retpoline, Static call机制处理间接跳转的简要代码

​ ②linux 5.10引入的static call机制。 在此之前,为了缓解 spectre 系列漏洞,特别是spectre v2 漏洞,我们会采用retpoline技术.

依赖于RSB(Return Stack Buffer), 它的目的是所有间接调用不经过分支目标缓存(Branch Target Buffer),这样保证攻击无法生效.

​ 为方便大家理解Retpoline原理,我这里参考了retpoline: 原理与部署一文来作原理的解释。如图7所示,jmp指令通过rax值进行间接跳转,在original方式下,CPU会询问indirect branch preditor。如果有攻击者之前训练过该分支,就会导致CPU跳转执行特定代码。而retpoline机制阻止CPU的投机执行。在Retpoline方式下,

​ ①执行call L2后,会将lfence地址压栈,并填充到Return Stack Buffer(RSB),然后跳转到L2位置。

​ ②mov %rax, (%rsp)指令将间接跳转地址(*%rax)放到栈顶,此时栈顶地址和RSB中地址不同。

​ ③此时对于ret指令如果CPU投机执行时,会使用第一步中放入RSB中的地址,而lfencejmp L1指令会导致一个死循环。

​ ④CPU发现内存栈上的返回地址和RSB投机地址不同,所以投机执行终止,跳转到*%rax

​ 这样Retpoline机制就避免了CPU的投机执行。

​ 但是在 linux内核中我们发现,有很多pattern的间接调用目标是一定的,比如虚表所以我们会把它装化成两次直接调用,第二次直接调用代码使用jit重写,如图7 _trampoline所示, 这样我们消除了spectre v2的可能性, 而且也减少了间接调用的开销(因为使用了直接调用)

在我的项目中是否应该使用jit?

图8 wasm引擎执行效率与工程复杂度的表现

​ 如图8所示,wasm3虽然是一个解释器,但是相较于Wasmer,LLVM(最好的wasmer jit实现)性能低了10倍,对于解释器来说,是一个非常好的性能表现。并且wasm3的工程复杂度也低了许多。

​ 考虑到 执行效率与工程复杂性的关系, 工程复杂性低,意味着出现的Bug数量少, 项目代码也就更安全.。所以对于安全要求高的话,就需要 谨慎考虑jit.

​ 在今年linux内核中 ebpf jit发现了两个LPE bug(CVE-2020-8835, CVE-2020-27194), 即使在使用开发人员众多的linux内核中,较小的语言ebpf当中仍出现了比较严重的bug, 这说明Jit编译器工程复杂度很高,需要团队巨大的资源支持维护.

用rust实现jit的体验

​ 使用过程宏 处理汇编很方便, 编写一些Low level的jit体验非常好. rust语言作为源语言去实现目标语言的编译,无法保证其语言之外的安全性,这可以说是一种局限性吧.rust语言相对于c和c++还是比较有优势的.

提问环节

问题1: (猜测: 图3使用的Benchenmark使用了hashmap吗?)

回答: 我使用的Benchmark用hashmap会慢50%左右, 因为hashmap对缓存不友好。

问题2: 在jit空指针检查中,将普通的软件判断替换成trap,变成硬件中断,会提升效率吗?

回答: trap可以类比为rust当中panic,比如数组越界等,在绝大多数情况下都会执行快速路径,当程序出现bug才会执行trap路径.

问题3: 有关内存越界的问题,如果a内存和b内存相邻,a内存已经被映射了,此时越界访问到b内存,这个检查是否失效?

回答: 因为我们在内联缓存中,存储了上界与下界的缓存。我们会对访问的内存比较上界和下界,如果越界就排除在外了。对于这里的比较开销,我们经过一层的比较,对缓存是友好的,并且相较于查表,开销很大。

问题4: wasmer如何兼容x86和arm指令集?

回答: 我们使用的编译器后端singlepass和llvm后端都是支持arm指令的.

补充: 通过硬件来提高jit的性能

wasmer 在运行时会做一些检查,比如跳转时要查询某个表,然后在表中找到目标地址,然后跳转.这样我们就需要在代码中做分支处理. 如果在risc-v Physical Memory Protection (PMP)的扩展下,就可以在一些情况避免上面查表的开销. 而苹果m1 中兼容x86的机制,通过硬件上添加一个x86 的total store ordering (TSO)开关去使用x86内存顺序,提升模拟效率. 同时我们可以看到,arm指令集这几个版本也引入了支持javascript-operations 的一些指令,可以使得我们常用的一些jit目标语言提高执行效率。

参考文章:

  1. Terenceli retpoline: 原理与部署
  2. Sergio De Simone How x86 to arm64 Translation Works in Rosetta 2

RustChinaConf2020 精选 | Rust 异步与并发

说明:本文为视频演讲文字版,编者听录的时候可能会出现一些误差,欢迎指正。

讲师:赖智超 - Onchain 区块链架构师

视频地址:https://www.bilibili.com/video/BV1Yy4y1e7zR?p=14

后期编辑:李冬杰,阿里巴巴淘系技术部,花名齐纪。

————————

自我介绍

自我介绍

大家好,今天我跟大家分享一下 Rust 的异步模型,以及实现这个模型时面临的一些并发方面的挑战。首先介绍一下 Rust 在我们公司的应用情况,我们公司在区块链是布局比较早的,现在大概成立有四年多了,目前我们公司主要还是 golang 为核心的技术栈,但是在 Rust 方面我们也在积极探索,有一些应用的实践。首先我们的区块链支持 wasm 虚拟机,使用 Rust 基于 cranelift/wasmtime 实现了 JIT 的版本,目前已经运行了一年多了。有了 wasm 虚拟机的支持后,我们也在智能合约和配套的工具链上下了功夫,目前团队智能合约开发首选 Rust,它具有开发效率高和迭代速度快的优点,前些天统计我们使用 Rust 开发的智能合约代码已经上 10 万了。还有密码学库,我们也是用的 Rust。

  1. 区块链 wasm JIT 虚拟机:基于 cranelift/wasmtime;
  2. 智能合约开发库和配套的工具链:目前合约开发都首选 Rust,开发效率高,迭代速度快;
  3. 密码学库;

同步任务多线程池

同步任务多线程池

为了讲解异步编程模型,我们先来看一看大家都比较熟悉的同步任务多线程池的实现,一个比较典型的实现如 PPT 左图所示,有一个全局的队列(Global Task Queue),由用户调用 spawn 把任务压到全局队列,全局队列关联着一个或者多个 worker 线程,每个工作线程都会轮询的从全局队列中把任务拿出来执行,用代码实现也比较简单。


#![allow(unused)]
fn main() {
use std::thread;
use crossbeam::channel::{unbounded, Sender};
use once_cell::sync::Lazy;

type Task = Box<dyn FnOnce() + Send + 'static>;

static QUEUE: Lazy<Sender<Task>> = Lazy::new(|| {
    let (sender, reciver) = unbounded::<Task>();
    for _ in 0..4 {
        let recv = reciver.clone();
        thread::spawn(|| {
            for task in recv {
                task();
            }
        })
    }
    sender
});

fn spawn<F>(task: F) where F: FnOnce() + Send + 'static {
    QUEUE.send(Box::new(task)).unwrap();
}
}

首先我们在第5行代码定义了什么叫做同步任务,因为同步任务的话只需要执行一次就行了,所以是 FnOnce(),因为这个任务是从用户线程 push 到全局队列,跨线程到工作线程,所以需要有Send约束和 static 生命周期,然后封装到 Box 中。第 8 行构建了一个并发的队列,起了 4 个线程,每个线程拿到队列的接收端,然后在一个循环中执行 task,当然执行 task 的过程可能会 panic,这里为了演示我就没有处理。第17行 sender 就保存着在全局静态变量 QUEUE 上,当用户调用 spawn时,拿到 QUEUE 调用 send 方法,将任务 push 到队列中。

异步任务的多线程

异步任务的多线程


#![allow(unused)]
fn main() {
type Task = Box<dyn FnMut() -> bool + Send + 'static>;
}

接下来我们看一下异步任务的多线程池,首先定义不能立即完成,需要多次执行的任务为异步任务,因此 FnOnce() 就不满足了,需要使用 FnMut ,它返回的结果是个布尔值,表示是否执行完任务。但是这样定义就有个问题,如果这个函数没有被工作线程执行完,工作线程就不知道接下来该怎么办了,如果一直等着直到这个任务能够执行,全局队列中的其他任务就不能被执行;直接扔掉这个任务也不行。因此Rust的设计用了一个很巧妙的办法,Exector 就不关心这个任务什么时候好,在执行的时候创建一个 Waker,然后告诉 task,“如果你什么时候好了,可以通过 Waker 把它重新放到全局队列里去” 以便再次执行,这样的话 Task 的定义就多出了 Waker 参数,如下所示:


#![allow(unused)]
fn main() {
type Task = Box<dyn FnMut(&Waker) -> bool + Send + 'static>;
}

这样异步任务执行没有 ready 的时候,可以将拿到 Waker 注册到能监控任务状态的 Reactor 中,如 ioepoll、timer 等,Reactor 发现任务 ready 后调用 Waker 把任务放到全局队列中。

异步任务的多线程 Executor

异步任务的多线程 Executor

在Rust中,对于异步计算的标准定义是Future trait


#![allow(unused)]
fn main() {
pub enum Poll<T> {
    Ready(T),
    Pending,
}

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

poll 方法返回的是一个枚举类型 Poll,它和返回布尔值是类似的,只不过语义会更清晰一些,如果没好的话就返回一个 Pending,好了的话就返回一个 Ready。标准库里用的不是&mut self,而是Pin<&mut Self>,因为 30 分钟讲不完,所以在这里先跳过。下面就是整个异步任务多线程的模型图:

异步任务的多线程 Executor

首先用户通过 spawn 函数把异步任务 push 到全局队列里去,然后工作线程会拿到 task 执行,并且创建一个 Waker,传给执行的 Future,如果任务执行完成了,那就 ok 了;如果没执行完成,Future 负责把 Waker 注册到 Reactor 上面,Reactor 负责监听事件,收到事件后会把 Waker 唤醒,把 task 放到全局队列中,这样下次其他线程可以拿到这个 task 继续执行,这样循环重复直到任务执行完毕。

Waker 接口的要求

Waker 使用方

Waker 在这个过程中充当着十分重要的角色,我们来看一下 Waker 的接口需要满足哪些要求:


#![allow(unused)]
fn main() {
impl Waker {
    pub fn wake(self);
}

impl Clone for Waker;

impl Send for Waker;

impl Sync for Waker;
}

对于使用方的要求,首先 Waker 本身是唤醒的功能,所以它要提供一个 wake 方法。异步任务可能会关心多个事件源,比如说定时器、IO,也就是说 Waker 可能对应不同的 Reactor,因为 Futurepoll 的时候只是传了一个 Waker,现在要把 Waker 注册到多个 Reactor 上,就需要 clone。然后 ExecutorWaker 可能不在一个线程里面,Waker 需要跨线程发送到 Reactor 上面,所以也就需要一个 Send 的约束。最后多个事件源可能同时调用这个 Waker,这里就存在并发调用的问题,要满足并发调用的话就需要实现Sync约束。这是对 Waker 使用方的要求。

Waker 提供方


#![allow(unused)]
fn main() {
impl Waker {
    pub unsafe fn from_raw(waker: RawWaker) -> Waker
}

pub struct RawWaker {
    data: *const (),
    vtable: &'static RawWakerTable,
}

pub struct RawWakerTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ())
}
}

不同的 Executor 有不同的内部实现,而 Waker 又是一个公共统一的 API。有的Executor有一个全局队列,有的是一个线程局部队列,有的 Executor 可能只支持单个 task 的执行,因此他们的唤醒机制是完全不一样的。要构造统一的 Waker 必然涉及多态,Rust 中是采用自定义虚表的方式实现的,通过 RawWaker 来构造 WakerRawWaker 有个数据字段,和一个静态的虚表,不同的 Executor 就是要把这些虚表中的方法全部实现,

Waker 实现需要考虑的并发问题

Waker 实现需要考虑的并发问题

Waker 在实现上可能会有一些并发上的问题,我们先说第一个问题,wake 调用之间的并发,需要保证只将任务push执行队列一次。如果有两(多)个 Reactor 同时执行 Waker::wake 的话,两个 Reactor 都成功把任务 push 到全局队列里去,如果第一次push的让线程 A 拿到了,第二次pushed让线程 B 拿到了,线程 A 和 B 现在同时调用poll,因为 poll 本身 Self 参数是 &mut self 的,也就是说是互斥的,这样就会造成线程安全问题。

第二个问题,wake 调用和 poll 之间的并发,一个任务正在执行poll,但是之前调用poll的时候把已经Waker注册到一个 Reactor 中,这个 Reactor 突然好了,现在它调用Waker::wake试图把任务push到并发队列里去,如果push能成功的话,那么另一个线程从队列里取到任务,并尝试调用poll,而当前这个任务又在poll 的过程中,因此会导致和上面一样的并发问题。

async-task 完美的解决了这些并发问题,并且它提供了十分优雅的 API,我把源码解析放在了知乎上面,大家有兴趣可以看一下。

异步任务多线程 Executor

异步任务多线程 Executor

如果用 async-task 处理这个问题,代码应该是这样的:


#![allow(unused)]
fn main() {
use std::thread;
use crossbeam::channel::{unbounded, Sender};
use once_cell::sync::Lazy;
use async_task;

static QUEUE: Lazy<Sender<async_task::Task<()>>> = Lazy::new(|| {
    let (sender, reciver) = unbounded::<Task>();
    for _ in 0..4 {
        let recv = reciver.clone();
        thread::spawn(|| {
            for task in recv {
                task();
            }
        })
    }
    sender
});

fn spawn<F, R>(future: F) -> async_task::JoinHandle<R, ()> 
where 
    F: Future<Output = R> + Send + 'static,
    R: Send + 'static,
{
    let schedule = |task| QUEUE.send(task).unwrap();
    let (task, handle) = async_task::spawn(future, schedule, ());
    task.schedule();
    handle
}
}

可以看到和之前的同步任务多线程池相比,工作线程的代码基本一致,spawn 函数有一些区别。使用 async_task 很简单实现了异步任务多线程池的处理。

Future 和 Reactor 之间的并发

Future 和 Executor 之间的并发

Future 如果poll的时候没有好的话,它负责把 Waker 注册到 Reactor 里去,这里面会有一个 Waker 过期的问题。第一次调用 poll 和第二次调用 poll 时,Executor 传的 Waker 可能不是同一个,只有最新的 Waker 能把 task 唤醒,老的 Waker 就唤不醒,这样导致的问题是每次 poll 的时候都要把 waker 更新到 Reactor 里,以确保能够唤醒 task。

比如上图中的例子,Future 同时对两个事件感兴趣,对应着两个 ReactorFuturepoll 的时候需要向 Reactor1 注册 waker,也要向 Reactor2 注册 waker,当它下次 poll 的时候每次都要把两个 waker 更新,那么现在问题来了,Futurepoll 执行在 Executor 线程,Reactor 执行在 Reactor 线程,一个线程往里面写,另一个线程试图从里面读,并发问题就出现了。为了处理这个问题,最简单的方式就是加一把锁,每个 Reactor 都要加锁解锁,这个操作本身就比较复杂,比较耗时。

AtomicWaker

AtomicWaker 完美处理了这个问题,它通过单生产者多消费者的模式,将 waker 放到 AtomicWaker 里面,AtomicWaker 被多个 Reactor 共享,Waker只需要更新一次,所有 Reactor 就能拿到最新的 waker

Future 的可组合性

Future 的可组合性

异步任务本身是可以组合的,比如发起一个 HTTPS 请求涉及查询 DNS 拿到 IP,建立 TLS 链接,发送请求数据,拿到响应数据,过程中的每一步都是异步任务,把这些异步任务组合到一起就是一个大的异步任务。 Future本身设计也是可组合的,比如下面的代码:


#![allow(unused)]
fn main() {
future1
    .map(func)
    .then(func_return_future)
    .join(future2);
}

因为 Future 要执行的话必须发到 Executor 里面,因此上面的代码还没有发到 Executor 里面去,所以它本身是没有执行的。上面的代码等于:


#![allow(unused)]
fn main() {
Join::new(
    Then::new(
        Map::new(future1, func), 
        func_return_future
    ), 
    future2
);
}

它是一个声明式的,最终会产生一个结构体,是一个如上图所示的树形结构,当整个任务丢到 Executor 里去执行的时候,poll 方法 Future 的树根结点开始,执行到叶子节点,最底层的叶子节点 futrue 是专门跟 Reactor 打交道的,所以大部分开发者是不需要关心 Reactor 的,因此可能对 Reactor 概念可能了解不多。

当一个叶子节点没好的时候,它会把传下来的 waker 注册到 Reactor 里面去。当Reactor 发现任务可以继续推进了,会调用 waker 把 任务 放入到全局队列中,某个线程拿到任务后,会重新从根节点 poll。以上就是整个的执行过程。

JoinN 组合的效率

JoinN 组合的效率

上面的 Future 组合模型涉及到一个 JoinN 组合的效率问题,问题是怎么产生的呢?waker 只用于唤醒整个task,但是没有携带任何唤醒信息,比如 task 是怎么被唤醒的。JoinN 负责把多个 Future 组合在一起同时并发的执行,Join4 把 4 个 Future 组合,每次 poll 的时候挨个去执行子 Future,如果没有好的话就会注册到 Reactor 里面,假设第二个突然就好了,下一次 poll 时,Join4 并不知道自己为什么被唤醒了,只能挨个再遍历一遍 Future,但其实第一、三、四都是浪费掉的。

Waker 的拦截和包装

怎么解决这个问题呢?futures-rs 里面有一个 FuturesUnordered 专门处理这个事情,可以管理成千上万个子 Future,它内置了一个并发队列,维护已经 ready 的子 Future。当 Executorpoll 整个任务的时候,它只遍历并发队列,挨个拿出来执行,执行的时候并不是把 waker 原封不动的传下去,而是进行了一次包装拦截:wake调用的时候,它会先把 Future 添加到自己的ready队列里面去,再去通知Executor的全局队列,Executor 下次再 poll 的时候直接从内置的并发队列去执行 Future,这样能达到效率最大化。

异步任务之间的同步

异步任务之间的同步

传统多个线程之间也有同步的需求,比如说锁。异步任务之间也不可能是完全隔离的,它们之间可能做一些消息的交互,我们比较一下线程和 Task 之间的区别:

线程Task
睡眠thread::parkreturn Pending
唤醒thread::unparkWaker::wake
获取方式thread::current()poll的参数

线程如果想暂停工作可以调用 thread::park,task想暂停工作可以直接 return Pending;线程可以通过 thread::unpark 唤醒,task 需要调用 Waker::wake;获取方式上,线程直接调用 thread::current,task 是通过 poll 的参数拿到 waker

异步任务之间的同步 Mutex

异步任务之间的同步 Mutex

Mutex 数据结构里面有一个数据字段,表示要锁的数据,一个 locked 原子变量表示有没有被锁住,还有一个等待队列,异步任务想拿锁却没有拿到,它就只能进入等待队列里面,等着别人去通知它。先看一下拿锁的过程,如果 waker 拿到锁之前 locked 是 false,表示拿锁成功了,如果没拿到失败了的话,就只能等,把 waker 丢到等待队列里。拿到锁的任务想释放这把锁的时候,把 locked 改成 false,并从等待队列中拿一个 waker 出来,去唤醒相应的task。

这里跟大家讲一个很多人误区的地方,很多人认为异步任务里面是必须要用异步锁的,同步锁有阻塞就不行,这是不对的。大部分的等待队列的实现都是用了同步锁,也就是说 Mutex 也不是完全异步的,它本身有个同步锁在里面。如果你在应用里面只是想保护一段数据,对共享的数据做点加减操作,那么应该用 std 里面的同步锁,因为用异步锁的话,更新内部的等待队列需要加同步锁,这个开销可能比你直接用同步锁更新共享数据还要复杂很多。

那么什么时候用异步锁呢?在保护 IO 资源的时候,当你的锁需要跨越多个 .await,时间差的比较大的时候,那应该优先使用异步锁。

异步任务之间的同步 Oneshot

异步任务之间的同步 Oneshot

Oneshot 是做什么事情的呢?它负责在两个线程之间传递一个数据,一个 task 在执行,另一个 task 在等待,前者执行完会通过 Oneshot 把数据传递给后者。图上所示就是 Oneshot 的数据结构,state 中纪录了很多元信息,比如数据是否已经写了,sender 是否应析构掉了,TxWaker 是否已经存了,RxWaker 是否已经存了,receiver 是否已经 drop 掉了。

发送端发送数据的时候,首先在修改state前, data是完全由 sender 自由访问的,写完 data 后把 state 状态改掉,表示这个 data 已经写完了。然后把接收端的 RxWaker 取出来然后唤醒,唤醒之后 task 下次执行就可以把数据拿到了。如果 sender 没有发送数据,现在要把它析构掉,析构时要注意接收端还在一直等,因此 sender 析构是也要把 state 修改掉,把相关的 RxWaker 唤醒,通知 reciver 不要再等了。

接收端的实现是一个 Future,它本身在 poll 的时候会读取 state,如果有数据那就说明发送端数据已经写完了,直接读取数据。如果没有数据的话就要等待,把它的 waker 存在 OneshotRxWaker 里面,同时也更新相应的 state,表示接收端的 RxWaker 已经存在。接收端在 drop 的时候,也要通知 sender,表示“我现在对你的数据没有兴趣了,你可以不用继续计算下去",所以接受端在 drop 的时候也要修改 state,从 Oneshot 里面拿到发送端的 TxWaker,把发送端唤醒。

异步任务之间的同步 WaitGroup

异步任务之间的同步 WaitGroup

接下来讲一下我自己实现的 WaitGroup,它在 golang 里面是非常常见的。它可以构造出多个子任务,等待所有的子任务完成后,再继续执行下去,下面是一个演示代码:


#![allow(unused)]
fn main() {
use waitgroup::WaitGroup;
use async_std::task;

async {
    let wg = WaitGroup::new();
    for _ in 0..100 {
        let w = wg.worker();
        task::spawn(async move {
            drop(w);
        });
    }
    wg.wait().await;
}
}

首先先构造一个 WaitGroup,然后创建 100 个 worker,在每个任务执行完后,只要把 worker drop 掉,就说明任务已经完成了。然后 WaitGroup 等到所有的子任务完成后继续执行。下面介绍一下它的实现,其实比较简单:


#![allow(unused)]
fn main() {
struct Inner {
    waker: AtomicWaker,
}

impl Drop for Inner {
    fn drop(&mut self) {
        self.waker.wake();
    }
}

pub struct Worker {
    inner: Arc<Inner>,
}

pub struct WaitGroup {
    inner: Weak<Inner>
}

impl Future for WaitGroup {
    type Output = ();

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        match self.inner.upgrade() {
            Some(inner) => {
                inner.waker.register(cx.waker());
                Poll::Pending
            }
            None => Poll::Ready(())
        }
    }
}
}

注意到如果某一个 worker 完成了 task,它并不需要去唤醒 WakerWaitGroup 只关心所有任务都结束了,只需要让最后一个 worker 去唤醒 waker。什么时候是最后一个 worker 呢?我们可以借用标准库里的 ArcArc 是一个共享引用,当所有的 Arc 强引用都销毁的时候,就会析构内部的数据,只要在 Arc 包装的数据的 drop 方法里面把 waker 唤醒就可以了。

WaitGroup 持有一个弱引用,所有的 Worker 都持有强引用,WaitGrouppoll 的时候试图把弱引用升级成强引用,如果升级失败了,说明所有的强引用都没了,也就是任务都执行完了,就可以返回 Ready。如果升级成功了,说明现在至少还有一个强引用,那就把 waker 注册到 AtomicWaker 里面。这里有一个边界条件,在升级结束的瞬间,所有的 worker 全部 drop 掉了,这时还不会调用 wake,因为在升级成功时,会产生一个临时的强引用 inner,这时更新waker后,在这个临时的强引用销毁的时候调用 drop,然后调用 waker.wake() 把任务唤醒,因此不会丢失通知。整个过程就完整了。

生产实践 |「译」1password 的 Rust 实践


英文原文

Rust已经风靡编程语言界。自2015年发布1.0版本以来,它一直是最受喜爱的编程语言之一,拥有一批忠实的开发者和贡献者。

为何 Rust 在软件开发者中会如此受宠?为了解答这个疑问,我们踏上了一段关于 Rust 软件开发的新旅程。我们将采访一些在重要项目中使用 Rust 的技术人员。这些重要项目涉及但不限于手机应用、服务程序、初创公司的最小可行化产品。

在本系列的第一期中,我们采访了 1Password 的工程副总裁 Michael Fey。他们为什么选择 Rust 做开发?Rust 给安全软件带来了哪些好处?如果你想使用 Rust 开发类似的软件,应该关注哪些库?如果你想知道这些问题的答案,请继续阅读。

你能给我们介绍下关于公司和你的一些情况吗?

1Password 是一款已经被数百万人和70,000家企业采用的优秀的密码管理软件,用于保护他们的敏感数据。它支持主流浏览器、桌面和移动设备. 它能帮助你记住所有你没有必要去记住的密码。

我是 1Password 客户端开发的工程副总裁。如果您曾在 Mac、Windows PC、iPhone、iPad、Android 手机、平板电脑或浏览器中使用过1Password,那么您就使用了我们团队开发的软件。从2004年开始,我们就专注于打造这款软件。这是一款体验绝佳的安全产品,为此我们感到非常自豪。

你能谈谈 1Password 的技术栈吗?你们的代码中有多大一部分是用 Rust 编写的?

我们在 1Password 中使用Rust已经有好几年了。我们的 Windows 团队是这项工作的领头羊。Windows版的1Password 7 中大约 70% 的代码是用 Rust 编写的。我们还在2019年底把 1Password Brian (一种浏览器填充逻辑的引擎) 从 Go 移植到 Rust,然后把 Rust 编译为 WebAssembly,最后再部署到浏览器插件中。这样我们就可以利用到 WebAssembly 的速度和性能。

它们得益于产品采用了Rust,在过去几年我们取得了巨大成功。现在我们正在对几乎整个产品线进行重写,Rust 在其中扮演主要角色。我们正在使用 Rust 创建一个headless 1Password 应用: 把所有的业务逻辑、加密解密、数据库访问、服务器通信等统统包裹到一个薄薄的 UI 层中,然后作为原生应用部署到系统中。

1Password 采用 Rust 的原因是什么,是看中它的高性能或类型/内存安全吗?

最初吸引我们使用 Rust 的主要原因之一是内存安全; Rust 可以增强我们对保护客户数据安全的信心,这无疑让我们兴奋不已。不过,除了内存安全之外,我们对Rust生态系统的喜爱还有很多。没有传统的运行时是一个显著的性能优势;例如,我们不再担心垃圾收集器的性能开销。Rust提供了一种 "程序正确性 "的形式和许多针对运行时未定义行为的保证。强类型系统在编译时会强制保证这些规则。仔细地将应用逻辑与Rust的强类型规则对齐,使API难以被误用。同时,因为不需要对约束和不变量进行运行时检查,所以可以写出简洁的代码。在程序执行之前,编译器就可以保证: 不存在无效的运行时代码路径, 不会因此产生程序异常。因为运行时状态验证更少,所以写出的代码会更干净、更高效、更内聚、质量也更高。与其他语言相比,Rust 很少需要运行时调试。如果能编译通过,你就可以相当确定它不会表现出未定义行为。它可能不是你想要的,但它会是 "正确的"

Rust 的另一个非常强大却常被忽视的特性是程序化宏系统[1]。它使我们能够编写一种工具:可以自动将 Rust 中定义的类型与我们的客户端语言 (Swift、Kotlin和 TypeScript) 共享。这种工具的输出会自动处理序列化/反序列化过程。这意味着客户端开发人员在与 Rust 库交互时,可以继续使用他们选择的语言进行编程,同时又可以消除使用 FFI 进行 JSON 解析的烦恼。除了上述这些益处,我们还能获得每一种目标语言在编译期类型检查的好处。我们已经把这个工具集成到持续集成服务器中,这意味着对Rust模型的改变会导致客户端应用程序的编译失败,而这些失败情况会在代码评审中被发现。

这个工具已经成为我们开发过程中不可或缺的组成部分,让我们的进度比以前快得多。一旦我们的类型在Rust中被定义,我们就能立即在客户端语言中生成等价类型。这使我们的开发人员能够专注于解决问题。而不必去捋模版代码,再使用 FFI 进行通信

Rust对开发像1Password这样以安全为中心的应用程序的支持(库和其他)有多好?

对于实现安全软件的大部分基础组件来说,那是绰绰有余的。有两个大型的、突出的密码学平台( ringRust Crypto 组),它们提供了丰富的功能。正如我在前面提到的,用 Rust 编写程序会让你对内存的使用充满信心,也让你更难意外引入与内存相关的漏洞。还有一个很好的系统,用来跟踪Rust crates中不时出现的漏洞:RustSec 数据库。它是由其他 Rust 开发者提供的社区资源,并且经常更新。此外,Rust 和 Cargo 还包含了 batteries-included 测试框架。这意味着你总是有一种容易的方式来编写单元测试套件,以保证关键代码(比如加密函数)的正确性。

如果存在 Rust 原生安全库,那当然是最理想的 (而且它们会及时出现) 。如果没有也不必担心,我们还有其他选项:使用C语言或原生平台库中的一些东西。在我们的Rust代码中,我们将这一点发挥得淋漓尽致,比如调用生物识别解锁的原生实现(Touch ID、Face ID、Windows Hello)和特定平台的设置实现(比如苹果平台上的NSUserDefaults)。

其中有什么特别的Rust库是你想介绍一下的吗?

当然有。1Password 使用了 Tokio、Hyper/Reqwest、Ring 和Neon。得益于这些 Rust 库,我们才能完成这个雄心勃勃的项目。你也应该看看我们在 crates.io 上的 密码规则解析器 。它主要基于苹果支持的规范。他们的工具和文档可以在 这里 找到。

在用 Rust 开发 1Password 的过程中,遇到的最大挑战是什么?

我们团队中的许多人都是Rust的新手,他们经历了典型的学习曲线,这与它的内存管理和所有权模型有关。我们还发现编译时间很长;我们的CPU和风扇肯定会受到锻炼。😄

你对结果满意吗?

绝对满意

你有什么关键的心得想跟我们的观众分享吗?

如果你是Rust的新手,请从小处着手,并在此基础上进行改进。我们在刚开始的时候进行了大量的实验,试图找到基于Rust的最佳解决方案。当你的实验成功后,回顾一下你过去使用其他语言的工作方式,看看你的代码能否从Rust的理念中获益。

如果你是1Password的新用户,今天就可以通过这个链接注册,家庭和个人账户第一年可以节省50%的费用。如果你正在做一个开源项目,你可以免费获得一个1Password Teams账户。请前往我们的 GitHub 仓库了解更多信息。

附录

[1] 指的是typeshare. 它的功能是把一些用rust 写的结构体生成为其他语言的结构体,比如下面的rust 的一个struct


#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Debug)]
struct Teacher {
    name: String,
    age: u64,
    id: u64,
}
}

转化为typescript的变成如下:

export interface Teacher {
    name: string;
    age: number;
    id: number;
}

它同时支持typescript,swift,java. 它把Rust写的struct生成了团队其他中定义各语言结构体的规范。所以该规范也只是1password团队内部定义domain层的规范。不一定适合其他团队。

[2] 另一款开源的密码管理器bitwarden. 也有rust 实现后台bitwarden_rs. 有兴趣可以进一步阅读。


译者简介:

柴杰,中国科学技术大学集成电路工程专业,在读硕士研究生。兴趣与专长为虚拟内存系统、分布式系统。

审校:

  • 严炳(ryan),算法,大数据开发从业者,喜欢和有开源精神的人一起共事。

溪塔科技: 用Rust写Protobuf扩展

作者: 宁志伟

本文为《Rust 唠嗑室》第 17 期 - 《用 Rust 写 Protobuf 扩展》的文字版本。


Protobuf

Protocol Buffers (简称 Protobuf ) ,是 Google 出品的序列化框架,与开发语言无关,和平台无关。具有体积小,速度快,扩展性好,与 gRPC 搭配好,支持的语言多等特点,是目前应用最广泛的序列化框架。

使用场景一般是在微服务架构中,用来定义微服务之间的 gRPC 接口,以及相关的参数/返回值等数据结构的定义。

通过官方的编译器 protoc 以及相应的插件可以方便的生成不同语言的实现代码。这样不同的微服务可以使用不同的开发语言,同时还能顺利进行交互。

CITA-Cloud中的Protobuf

CITA-Cloud 采用了微服务架构,因此也采用了 ProtobufgRPC 的组合。

但是因为 Protobuf 语言无关的特性和广泛的应用,使得其具有抽象和通用的特点。因此也可以把 Protobuf 当作一种建模语言来使用,参见文章

CITA-Cloud 目前是在协议中直接把交易和区块等数据结构固定下来的。但是最近的思考发现,其中的很多字段都是为了实现某种应用层面的协议而存在的。比如交易中的 nonce 字段就是为了实现应用层面的去重协议。

因此,后续计划提供一个框架,方便用户自定义交易和区块等核心数据结构,以及相关的处理函数。但是 Protobuf 通常只能生成数据结构,以及相关的 get/set 等模式比较固定的代码,如果要生成复杂的成员函数,就需要一些扩展能力。

Protobuf扩展

Protobuf 的扩展能力可以分为两种: Protobuf 本身的扩展和 Protobuf 插件。

Protobuf 其实是个标准的编译器架构。我们可以把 .proto 文件视作源码,官方的 protoc 编译器可以对应到编译器前端。

protoc 接收一个或者一批 .proto 文件作为输入,解析之后输出一种中间描述格式,对应编译器中的 IR

但是有意思的是,这种中间描述格式是二进制的,其结构依旧由 Protobuf 本身描述。详细可以参见descriptor.proto

Protobuf 插件可以对应到编译器后端,接收中间描述格式,解析其中的信息,据此生成具体语言的代码。

这里其实有个非常有意思的问题。插件在解析中间描述格式的数据时,因为这种格式是由 descriptor.proto 描述的,所以得先有个插件能把 descriptor.proto 生成开发插件所使用的开发语言的代码。

上面的话有点绕,举个具体的例子。比如我想用 Rust 实现一个插件,假如目前还没有 Protobuf 相关的 Rust 库,那就没办法用 Rust 代码来解析 descriptor.proto 对应的中间描述格式的数据,也就没法实现插件了。

这个问题其实就对应编译器里的自举问题。比如,想用 Rust 来写 Rust 编译器,那么一开始就是个死结了。解决办法也很简单,最开始的 Rust 编译器是用 Ocaml 实现的,然后就可以用 Rust 来写 Rust 编译器,实现编译器的 Rust 代码用前面 Ocaml 实现的版本去编译就可以解决自举问题了。

Protobuf 这里也是同样的,官方提供了 Java/Go/C++/Python 等版本的实现,可以先用这些语言来过渡。

另外一种扩展方式是 Protobuf 本身提供了语法上的扩展机制。这个功能可以对应到编程语言提供的宏等元编程功能。

Protobuf 这个扩展能力有点类似AOP,可以方便的在已经定义的 Message 中增加一些成员。

更有意思的是,前面提到过,所有的 .proto 文件,经过 protoc 之后,会被转换成由 descriptor.proto 对应的中间描述格式。而 descriptor.proto 中的 Message 也同样支持上述扩展功能,因此可以实现一种类似全局 AOP 的功能。

通过扩展 descriptor.proto 中的 Message ,可以实现给所有的 Message 都加一个 option 这样的操作。

Rust中相关的库

dropbox 实现了一个 Protobuf pb-jelly,它就是用 Python 来实现生成 Rust 代码部分的功能。具体实现其实比较简单,就是在拼 Rust 代码字符串。

rust-protobuf是一个实现比较完整的 Protobuf 库,支持 gRPC 和相关的扩展能力。其中实现分为两部分,生成数据结构 Rust 代码的插件和生成 gRPC 相关代码的插件。具体实现封装的稍微好了一点,但是基本上还是在拼 Rust 代码字符串。

prost是一个比较新的 Protobuf 库实现。功能上有点欠缺,不支持扩展。库本身只支持生成数据结构的Rust 代码。生成 gRPC 相关代码的功能在tonic-build里,这个有点奇怪。

但是 prost 采用了很多新的技术。前面提到,插件只会生成数据结构相关的 get/set 等模式比较固定的代码, prost 实现了一个 derive 来自动给数据结构增加这些成员函数,这样生成的 Rust 代码就大大简化了,参见例子

这也跟编译器架构能对应上:一个选择是把编译器后端做的很复杂,直接生成所有的代码,运行时比较薄;另外一个选择是编译器后端做的很简单,生成的代码也简单,但是运行时比较厚重。

另外 gRPC 相关的代码比较复杂, tonic-build 在生成的时候用了quote库,提供类似 Rust 代码语法树上的 sprintf 方法的功能,不管是便利性还是代码的可读性都比之前两个库好很多。

后续计划

后续计划使用 Protobuf 及其扩展能力,实现一个框架,不但用来描述交易和区块等核心数据结构,也以一种可配置的方式生成一些比较复杂的相关代码。

最重要的第一步就是要能解析出 Protobuf 扩展相关的信息,因为正常的 .proto 文件只能用于描述数据结构,扩展的 option 是唯一可以赋值的地方。

目前实现了一个proto_desc_printer,可以解析中间描述格式,特别是其中的扩展信息。

后续可以在这个基础上去做代码生成部分的工作,这里可以从 prost 吸取很多好的经验。


作者简介:

宁志伟

溪塔科技首席架构师 首个微服务架构区块链CITA 首席架构师,区块链+云原生框架 CITA-Cloud 设计者。前阿里巴巴、华为技术专家,超过 10 年分布式系统架构设计,编程语言和虚拟机方面工作经验。

后期编辑:

丁 烁(Jarvib Ding),Rust 爱好者。

建造者模式(Builder)

概述

构建者模式是一种设计模式,提供一种灵活的解决方案,已解决面向对象程序设计中的各种对象创建问题。Builder设计模式的目的是将复杂对象的构造与其表示分离开来。是"是四人帮"设计模式之一[wiki]。建造者模式是一种创建型设计模式,使你能够分步骤创建复杂对象。该模式允许你使用相同的创建代码生成不同类型和形式的对象。

定义:Builder设计模式的目的是将复杂对象的构造与其表示分离开来。通过这样做,同样的构造过程可以创建不同的表示。

历史

假如有一个复杂的对象,需要对其进行构造时需要对诸多成员变量和嵌套对象进行繁杂的初始化工作。有时这些初始化代码通常深藏于一个包含众多参数且让人看不懂的构造函数中;或者这些代码散落在客户端代码的多个位置。

  1. 例如,创建一个房子,不同种类的房子有不同的风格,为每一种类型的房子创建一个子类,这可能会导致程序变得过于复杂。
  2. 或者无需生成子类,但是需要创建一个包括所有可能参数的超级构造函数,并用它来控制房屋对象的创建。这样虽然可以避免生成子类,但是会造成当拥有大量输入参数的构造函数不是每次都要全部用上。通常情况下,绝大部分的参数都没有使用,这使得对于构造函数的调用十分不简洁。

建造者模式 的使用

建造者模式建议将对象构造的代码从产品类中抽取出来,并将其放在一个名为生成器的独立对象中。每次创建对象时,都需要通过生成器对象执行一系列步骤。重点在于无需调用所有步骤,而只需调用创建特定对象配置所需的那些步骤。

适用场景

  • 使用建造者设计模式可以避免“重叠构造函数”的出现。
    • 假设复杂函数中有十几个可选参数,那么调用这些函数会非常不方便,因此需要重载这个构造函数,新建几个只有较少参数的简化版本。
    • 建造者设计模式让你可以分步骤生成对象,而且允许你仅适用必须的步骤。
  • 当使用代码创建不同形式的产品时,可使用生成器模式
    • 如果你需要创建各种形式的产品,他们的制造过程相似且仅有细节上的差异,此时可使用生成器模式。
    • 基本生成器接口中定义了所有可能的制造步骤,具体生成器将实现这些步骤来制造特定形式的产品。
  • 使用构造者模式构造其他复杂对象
    • 构造者模式让你能分步骤构造产品,你可以延迟执行某些步骤而不会影响最终产品。

优点

  • 可以分步骤创建对象,暂缓创建步骤或者递归运行创建步骤。
  • 生成不同形式的产品,你可以复用相同的制造代码
  • 单一职责原则,可以将复杂构造代码从产品的业务逻辑中分离出来。

缺点

由于该模式需要新增多个类,因此代码整体复杂程度会有所增加。

描述

通过使用构建者助手创建一个对象。

例子

fn main() {
    let foo = Foo {
        bar: String::from("Y"),
    };
    let foo_from_builder = FooBuilder::new().name(String::from("Y")).build();
    println!("foo = {:?}", foo);
    println!("foo from builfer = {:?}", foo_from_builder);
}

#[derive(Debug, PartialEq)]
pub struct Foo {
    // lots of complicated fields
    bar : String,
}

pub struct FooBuilder {
    // Probably lots of optional fields.
    bar: String,
}

impl FooBuilder {
    pub fn new() -> Self {
        // set the minimally required fields of Foo.
        Self {
            bar: String::from("x"),
        }
    }

    pub fn name(mut self, bar: String) -> FooBuilder {
        // set the name on the builder iteself,
        // and return the builder by value.
        self.bar = bar;
        self 
    }
    // if we can get away with not consuming the builder here, that is an 
    // advantage. It means we can use the FooBuilder as a template for constructing many Foo.
    pub fn build(self) -> Foo {
        // Create a Foo from Foo the FooBuilder, applying all settings in FooBuilder to Foo. 
        Foo { bar: self.bar }
    }
}
// Rust 编程之道. P234
struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

struct CircleBuilder {
    x: f64,
    y: f64,
    radius: f64,
}

impl Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * (self.radius * self.radius)
    }
    fn new() -> CircleBuilder {
        CircleBuilder {
            x: 0.0, y: 0.0, radius: 1.0,
        }
    }
}

impl CircleBuilder {
    fn x(&mut self, coordinate: f64) -> &mut CircleBuilder {
        self.x = coordinate;
        self
    }
    fn y(&mut self, coordinate: f64) -> &mut CircleBuilder {
        self.y = coordinate;
        self
    }
    fn radius(&mut self, radius: f64) -> &mut CircleBuilder {
        self.radius = radius;
        self
    }

    fn build(&self) -> Circle {
        Circle {
            x: self.x, y: self.y, radius: self.radius,
        }
    }
}

fn main() {
  let c = Circle::new().x(1.0).y(2.0).radius(2.0).build();
  println!("area = {:?}", c.area());
  println!("c.x = {:?}", c.x);
  println!("c.y = {:?}", c.y);
}

动机

当你需要许多不同的构造函数或者当构造有副作用时,这种方法有用。

优点

将构造方法与其他方法分离。

防止构造函数的扩散

可用于单次初始化以及更加复杂的构造。

缺点

比直接创建结构对象或简单的的构造函数更复杂。

讨论

这种模式在Rust(以及简单对象)中比在其他许多语言中更常见,这是因为Rust缺乏重载。由于你只能使用给定名称的单个方法,因此在Rust中使用多个构造函数要比C++、Java或其他语言好。

这种模式通常用于构建器对象本身就很有用的地方,而不仅仅是一个构建器。例如:std::process::Command 是Child的构建器。在这种情况下,不使用T和TBuilder的命名模式。

该示例通过值获取并返回生成器。接受并返回构建器作为可变引用通常更符合人体工程学(并且更有效)。


#![allow(unused)]
fn main() {
let mut fb = FooBuilder::new();
fb.a();
fb.b();
let f = fb.builder();
}

以及FooBuilder::new().a().b().builder()样式。

参见

项目中的使用

Tokio 中的建造者模式 Struct tokio::runtime::Builder


#![allow(unused)]
fn main() {
pub struct Builder {
    /// Runtime type
    kind: Kind,

    /// Whether or not to enable the I/O driver
    enable_io: bool,

    /// Whether or not to enable the time driver
    enable_time: bool,

    /// The number of worker threads, used by Runtime.
    ///
    /// Only used when not using the current-thread executor.
    worker_threads: Option<usize>,

    /// Cap on thread usage.
    max_blocking_threads: usize,

    /// Name fn used for threads spawned by the runtime.
    pub(super) thread_name: ThreadNameFn,

    /// Stack size used for threads spawned by the runtime.
    pub(super) thread_stack_size: Option<usize>,

    /// Callback to run after each thread starts.
    pub(super) after_start: Option<Callback>,

    /// To run before each worker thread stops
    pub(super) before_stop: Option<Callback>,

    /// Customizable keep alive timeout for BlockingPool
    pub(super) keep_alive: Option<Duration>,
}

pub fn new_current_thread() -> Builder // 设置current thread 类型
//Returns a new builder with the current thread scheduler selected.
//Configuration methods can be chained on the return value.

pub fn new_multi_thread() -> Builder // 设置 multi thread 类型
//This is supported on crate feature rt-multi-thread only.
//Returns a new builder with the multi thread scheduler selected.
//Configuration methods can be chained on the return value.

pub fn enable_all(&mut self) -> &mut Self
// Enables both I/O and time drivers.
// Doing this is a shorthand for calling enable_io and enable_time individually. If additional components are added to Tokio in the future, enable_all will include these future components.

pub fn worker_threads(&mut self, val: usize) -> &mut Self // 设置的runtime 用于工作的线程数
// Sets the number of worker threads the Runtime will use.
// This should be a number between 0 and 32,768 though it is advised to keep this value on the smaller side.

pub fn max_blocking_threads(&mut self, val: usize) -> &mut Self // 设置生成的用于阻塞操作的线程最大数
//Specifies limit for threads spawned by the Runtime used for blocking operations.
//Similarly to the worker_threads, this number should be between 1 and 32,768.
//The default value is 512.
//Otherwise as worker_threads are always active, it limits additional threads (e.g. for blocking annotations).

pub fn thread_name(&mut self, val: impl Into<String>) -> &mut Self // 设置线程的名字
//Sets name of threads spawned by the Runtime's thread pool.
//The default name is "tokio-runtime-worker".

// .....

pub fn build(&mut self) -> Result<Runtime> // 构造出tokio中的runtime结构
//Creates the configured Runtime.
//The returned Runtime instance is ready to spawn tasks.

//etc..

//example
// build runtime
let runtime = Builder::new_multi_thread()
                .worker_threads(4)
                .thread_name("my-custom-name")
                .thread_stack_size(3 * 1024 * 1024)
                .build()
                .unwrap();
}

从Builder的build函数可以知道Builder结构是Runtime的辅助结构体用来帮助构造Runtime的。

Futures 中的建造者设计模式 Struct futures::executor::ThreadPoolBuilder


#![allow(unused)]
fn main() {
/// A general-purpose thread pool for scheduling tasks that poll futures to
/// completion.
///
/// The thread pool multiplexes any number of tasks onto a fixed number of
/// worker threads.
///
/// This type is a clonable handle to the threadpool itself.
/// Cloning it will only create a new reference, not a new threadpool.
///
/// This type is only available when the `thread-pool` feature of this
/// library is activated.
#[cfg_attr(docsrs, doc(cfg(feature = "thread-pool")))]
pub struct ThreadPool {
    state: Arc<PoolState>,
}

/// Thread pool configuration object.
///
/// This type is only available when the `thread-pool` feature of this
/// library is activated.
#[cfg_attr(docsrs, doc(cfg(feature = "thread-pool")))]
pub struct ThreadPoolBuilder {
    pool_size: usize,
    stack_size: usize,
    name_prefix: Option<String>,
    after_start: Option<Arc<dyn Fn(usize) + Send + Sync>>,
    before_stop: Option<Arc<dyn Fn(usize) + Send + Sync>>,
}


struct PoolState {
    tx: Mutex<mpsc::Sender<Message>>,
    rx: Mutex<mpsc::Receiver<Message>>,
    cnt: AtomicUsize,
    size: usize,
}

enum Message {
    Run(Task),
    Close,
}

impl ThreadPoolBuilder {
    /// Create a default thread pool configuration.
    ///
    /// See the other methods on this type for details on the defaults.
    pub fn new() -> Self {
        Self {
            pool_size: cmp::max(1, num_cpus::get()),
            stack_size: 0,
            name_prefix: None,
            after_start: None,
            before_stop: None,
        }
    }

    /// Set size of a future ThreadPool
    ///
    /// The size of a thread pool is the number of worker threads spawned. By
    /// default, this is equal to the number of CPU cores.
    ///
    /// # Panics
    ///
    /// Panics if `pool_size == 0`.
    pub fn pool_size(&mut self, size: usize) -> &mut Self {
        assert!(size > 0);
        self.pool_size = size;
        self
    }

    /// Set stack size of threads in the pool, in bytes.
    ///
    /// By default, worker threads use Rust's standard stack size.
    pub fn stack_size(&mut self, stack_size: usize) -> &mut Self {
        self.stack_size = stack_size;
        self
    }

    /// Set thread name prefix of a future ThreadPool.
    ///
    /// Thread name prefix is used for generating thread names. For example, if prefix is
    /// `my-pool-`, then threads in the pool will get names like `my-pool-1` etc.
    ///
    /// By default, worker threads are assigned Rust's standard thread name.
    pub fn name_prefix<S: Into<String>>(&mut self, name_prefix: S) -> &mut Self {
        self.name_prefix = Some(name_prefix.into());
        self
    }

    /// Execute the closure `f` immediately after each worker thread is started,
    /// but before running any tasks on it.
    ///
    /// This hook is intended for bookkeeping and monitoring.
    /// The closure `f` will be dropped after the `builder` is dropped
    /// and all worker threads in the pool have executed it.
    ///
    /// The closure provided will receive an index corresponding to the worker
    /// thread it's running on.
    pub fn after_start<F>(&mut self, f: F) -> &mut Self
        where F: Fn(usize) + Send + Sync + 'static
    {
        self.after_start = Some(Arc::new(f));
        self
    }

    /// Execute closure `f` just prior to shutting down each worker thread.
    ///
    /// This hook is intended for bookkeeping and monitoring.
    /// The closure `f` will be dropped after the `builder` is droppped
    /// and all threads in the pool have executed it.
    ///
    /// The closure provided will receive an index corresponding to the worker
    /// thread it's running on.
    pub fn before_stop<F>(&mut self, f: F) -> &mut Self
        where F: Fn(usize) + Send + Sync + 'static
    {
        self.before_stop = Some(Arc::new(f));
        self
    }
	
  	// 从ThreadBuilder的create函数可以看到ThreadPoolBuilder根据配置采纳数创建ThreadPool, 是ThreadPool的辅助结构体 
    /// Create a [`ThreadPool`](ThreadPool) with the given configuration.
    pub fn create(&mut self) -> Result<ThreadPool, io::Error> {
        let (tx, rx) = mpsc::channel();
        let pool = ThreadPool {
            state: Arc::new(PoolState {
                tx: Mutex::new(tx),
                rx: Mutex::new(rx),
                cnt: AtomicUsize::new(1),
                size: self.pool_size,
            }),
        };

        for counter in 0..self.pool_size {
            let state = pool.state.clone();
            let after_start = self.after_start.clone();
            let before_stop = self.before_stop.clone();
            let mut thread_builder = thread::Builder::new();
            if let Some(ref name_prefix) = self.name_prefix {
                thread_builder = thread_builder.name(format!("{}{}", name_prefix, counter));
            }
            if self.stack_size > 0 {
                thread_builder = thread_builder.stack_size(self.stack_size);
            }
            thread_builder.spawn(move || state.work(counter, after_start, before_stop))?;
        }
        Ok(pool)
    }
}
}

从ThreadBuilder的create函数可以看到ThreadPoolBuilder根据配置采纳数创建ThreadPool, 是ThreadPool的辅助结构体

Surf中的建造者设计模式

/// Request Builder
///
/// Provides an ergonomic way to chain the creation of a request.
/// This is generally accessed as the return value from `surf::{method}()`,
/// however [`Request::builder`](crate::Request::builder) is also provided.
///
/// # Examples
///
/// ```rust
/// use surf::http::{Method, mime::HTML, Url};
/// # #[async_std::main]
/// # async fn main() -> surf::Result<()> {
/// let mut request = surf::post("https://httpbin.org/post")
///     .body("<html>hi</html>")
///     .header("custom-header", "value")
///     .content_type(HTML)
///     .build();
///
/// assert_eq!(request.take_body().into_string().await.unwrap(), "<html>hi</html>");
/// assert_eq!(request.method(), Method::Post);
/// assert_eq!(request.url(), &Url::parse("https://httpbin.org/post")?);
/// assert_eq!(request["custom-header"], "value");
/// assert_eq!(request["content-type"], "text/html;charset=utf-8");
/// # Ok(())
/// # }
/// ```
///
/// ```rust
/// use surf::http::{Method, Url};
/// # #[async_std::main]
/// # async fn main() -> surf::Result<()> {
/// let url = Url::parse("https://httpbin.org/post")?;
/// let request = surf::Request::builder(Method::Post, url).build();
/// # Ok(())
/// # }
/// ```

pub struct RequestBuilder {
    /// Holds the state of the request.
    req: Option<Request>,
    /// Hold an optional Client.
    client: Option<Client>,
    /// Holds the state of the `impl Future`.
    fut: Option<BoxFuture<'static, Result<Response>>>,
}

impl RequestBuilder {
    /// Create a new instance.
    ///
    /// This method is particularly useful when input URLs might be passed by third parties, and
    /// you don't want to panic if they're malformed. If URLs are statically encoded, it might be
    /// easier to use one of the shorthand methods instead.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// use surf::http::{Method, Url};
    ///
    /// let url = Url::parse("https://httpbin.org/get")?;
    /// let req = surf::RequestBuilder::new(Method::Get, url).build();
    /// # Ok(()) }
    /// ```
    pub fn new(method: Method, url: Url) -> Self {
        Self {
            req: Some(Request::new(method, url)),
            client: None,
            fut: None,
        }
    }

    pub(crate) fn with_client(mut self, client: Client) -> Self {
        self.client = Some(client);
        self
    }

    /// Sets a header on the request.
    ///
    /// # Examples
    ///
    /// ```
    /// let req = surf::get("https://httpbin.org/get").header("header-name", "header-value").build();
    /// assert_eq!(req["header-name"], "header-value");
    /// ```
    pub fn header(mut self, key: impl Into<HeaderName>, value: impl ToHeaderValues) -> Self {
        self.req.as_mut().unwrap().insert_header(key, value);
        self
    }

    /// Sets the Content-Type header on the request.
    ///
    /// # Examples
    ///
    /// ```
    /// # use surf::http::mime;
    /// let req = surf::post("https://httpbin.org/post").content_type(mime::HTML).build();
    /// assert_eq!(req["content-type"], "text/html;charset=utf-8");
    /// ```
    pub fn content_type(mut self, content_type: impl Into<Mime>) -> Self {
        self.req
            .as_mut()
            .unwrap()
            .set_content_type(content_type.into());
        self
    }

    /// Sets the body of the request.
    ///
    /// # Examples
    ///
    /// ```
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// use serde_json::json;
    /// let mut req = surf::post("https://httpbin.org/post").body(json!({ "any": "Into<Body>"})).build();
    /// assert_eq!(req.take_body().into_string().await.unwrap(), "{\"any\":\"Into<Body>\"}");
    /// # Ok(())
    /// # }
    /// ```
    pub fn body(mut self, body: impl Into<Body>) -> Self {
        self.req.as_mut().unwrap().set_body(body);
        self
    }

    /// Set the URL querystring.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # use serde::{Deserialize, Serialize};
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// #[derive(Serialize, Deserialize)]
    /// struct Index {
    ///     page: u32
    /// }
    ///
    /// let query = Index { page: 2 };
    /// let mut req = surf::get("https://httpbin.org/get").query(&query)?.build();
    /// assert_eq!(req.url().query(), Some("page=2"));
    /// assert_eq!(req.url().as_str(), "https://httpbin.org/get?page=2");
    /// # Ok(()) }
    /// ```
    pub fn query(mut self, query: &impl Serialize) -> std::result::Result<Self, Error> {
        self.req.as_mut().unwrap().set_query(query)?;

        Ok(self)
    }

    /// Submit the request and get the response body as bytes.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// let bytes = surf::get("https://httpbin.org/get").recv_bytes().await?;
    /// assert!(bytes.len() > 0);
    /// # Ok(()) }
    /// ```
    pub async fn recv_bytes(self) -> Result<Vec<u8>> {
        let mut res = self.send().await?;
        Ok(res.body_bytes().await?)
    }

    /// Submit the request and get the response body as a string.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// let string = surf::get("https://httpbin.org/get").recv_string().await?;
    /// assert!(string.len() > 0);
    /// # Ok(()) }
    /// ```
    pub async fn recv_string(self) -> Result<String> {
        let mut res = self.send().await?;
        Ok(res.body_string().await?)
    }

    /// Submit the request and decode the response body from json into a struct.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # use serde::{Deserialize, Serialize};
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// #[derive(Deserialize, Serialize)]
    /// struct Ip {
    ///     ip: String
    /// }
    ///
    /// let uri = "https://api.ipify.org?format=json";
    /// let Ip { ip } = surf::get(uri).recv_json().await?;
    /// assert!(ip.len() > 10);
    /// # Ok(()) }
    /// ```
    pub async fn recv_json<T: serde::de::DeserializeOwned>(self) -> Result<T> {
        let mut res = self.send().await?;
        Ok(res.body_json::<T>().await?)
    }

    /// Submit the request and decode the response body from form encoding into a struct.
    ///
    /// # Errors
    ///
    /// Any I/O error encountered while reading the body is immediately returned
    /// as an `Err`.
    ///
    /// If the body cannot be interpreted as valid json for the target type `T`,
    /// an `Err` is returned.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// # use serde::{Deserialize, Serialize};
    /// # #[async_std::main]
    /// # async fn main() -> surf::Result<()> {
    /// #[derive(Deserialize, Serialize)]
    /// struct Body {
    ///     apples: u32
    /// }
    ///
    /// let url = "https://api.example.com/v1/response";
    /// let Body { apples } = surf::get(url).recv_form().await?;
    /// # Ok(()) }
    /// ```
    pub async fn recv_form<T: serde::de::DeserializeOwned>(self) -> Result<T> {
        let mut res = self.send().await?;
        Ok(res.body_form::<T>().await?)
    }
		
  	// 从build函数可以知道最后RequestBuilder是Request的辅助结构体,用来构造返回Request
  	// 这个函数返回的是Request
    /// Return the constructed `Request`.
    pub fn build(self) -> Request {
        self.req.unwrap()
    }

    /// Create a `Client` and send the constructed `Request` from it.
    pub async fn send(mut self) -> Result<Response> {
        self.client
            .take()
            .unwrap_or_else(Client::new_shared_or_panic)
            .send(self.build())
            .await
    }
}

从build函数可以知道最后RequestBuilder是Request的辅助结构体,用来构造返回Request

Reqwest中的建造者设计模式


#![allow(unused)]
fn main() {
/// A request which can be executed with `Client::execute()`.
pub struct Request {
    method: Method,
    url: Url,
    headers: HeaderMap,
    body: Option<Body>,
    timeout: Option<Duration>,
}

/// A builder to construct the properties of a `Request`.
///
/// To construct a `RequestBuilder`, refer to the `Client` documentation.
#[must_use = "RequestBuilder does nothing until you 'send' it"]
pub struct RequestBuilder {
    client: Client,
    request: crate::Result<Request>,
}

impl Request {
    /// Constructs a new request.
    #[inline]
    pub fn new(method: Method, url: Url) -> Self {
        Request {
            method,
            url,
            headers: HeaderMap::new(),
            body: None,
            timeout: None
        }
    }

    /// Get the method.
    #[inline]
    pub fn method(&self) -> &Method {
        &self.method
    }

    /// Get a mutable reference to the method.
    #[inline]
    pub fn method_mut(&mut self) -> &mut Method {
        &mut self.method
    }

    /// Get the url.
    #[inline]
    pub fn url(&self) -> &Url {
        &self.url
    }

    /// Get a mutable reference to the url.
    #[inline]
    pub fn url_mut(&mut self) -> &mut Url {
        &mut self.url
    }

    /// Get the headers.
    #[inline]
    pub fn headers(&self) -> &HeaderMap {
        &self.headers
    }

    /// Get a mutable reference to the headers.
    #[inline]
    pub fn headers_mut(&mut self) -> &mut HeaderMap {
        &mut self.headers
    }

    /// Get the body.
    #[inline]
    pub fn body(&self) -> Option<&Body> {
        self.body.as_ref()
    }

    /// Get a mutable reference to the body.
    #[inline]
    pub fn body_mut(&mut self) -> &mut Option<Body> {
        &mut self.body
    }

    /// Get the timeout.
    #[inline]
    pub fn timeout(&self) -> Option<&Duration> {
        self.timeout.as_ref()
    }

    /// Get a mutable reference to the timeout.
    #[inline]
    pub fn timeout_mut(&mut self) -> &mut Option<Duration> {
        &mut self.timeout
    }

    /// Attempt to clone the request.
    ///
    /// `None` is returned if the request can not be cloned, i.e. if the body is a stream.
    pub fn try_clone(&self) -> Option<Request> {
        let body = match self.body.as_ref() {
            Some(ref body) => Some(body.try_clone()?),
            None => None,
        };
        let mut req = Request::new(self.method().clone(), self.url().clone());
        *req.timeout_mut() = self.timeout().cloned();
        *req.headers_mut() = self.headers().clone();
        req.body = body;
        Some(req)
    }

    pub(super) fn pieces(self) -> (Method, Url, HeaderMap, Option<Body>, Option<Duration>) {
        (self.method, self.url, self.headers, self.body, self.timeout)
    }
}

impl RequestBuilder {
    pub(super) fn new(client: Client, request: crate::Result<Request>) -> RequestBuilder {
        let mut builder = RequestBuilder { client, request };

        let auth = builder
            .request
            .as_mut()
            .ok()
            .and_then(|req| extract_authority(&mut req.url));

        if let Some((username, password)) = auth {
            builder.basic_auth(username, password)
        } else {
            builder
        }
    }

    /// Add a `Header` to this Request.
    pub fn header<K, V>(self, key: K, value: V) -> RequestBuilder
    where
        HeaderName: TryFrom<K>,
        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
        HeaderValue: TryFrom<V>,
        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>, 
    {
        self.header_sensitive(key, value, false)
    }

    /// Add a `Header` to this Request with ability to define if header_value is sensitive.
    fn header_sensitive<K, V>(mut self, key: K, value: V, sensitive: bool) -> RequestBuilder
    where
        HeaderName: TryFrom<K>,
        <HeaderName as TryFrom<K>>::Error: Into<http::Error>,
        HeaderValue: TryFrom<V>,
        <HeaderValue as TryFrom<V>>::Error: Into<http::Error>,
    {
        let mut error = None;
        if let Ok(ref mut req) = self.request {
            match <HeaderName as TryFrom<K>>::try_from(key) {
                Ok(key) => match <HeaderValue as TryFrom<V>>::try_from(value) {
                    Ok(mut value) => {
                        value.set_sensitive(sensitive);
                        req.headers_mut().append(key, value);
                    }
                    Err(e) => error = Some(crate::error::builder(e.into())),
                },
                Err(e) => error = Some(crate::error::builder(e.into())),
            };
        }
        if let Some(err) = error {
            self.request = Err(err);
        }
        self
    }

    /// Add a set of Headers to the existing ones on this Request.
    ///
    /// The headers will be merged in to any already set.
    pub fn headers(mut self, headers: crate::header::HeaderMap) -> RequestBuilder {
        if let Ok(ref mut req) = self.request {
            crate::util::replace_headers(req.headers_mut(), headers);
        }
        self
    }

    /// Enable HTTP basic authentication.
    pub fn basic_auth<U, P>(self, username: U, password: Option<P>) -> RequestBuilder
    where
        U: fmt::Display,
        P: fmt::Display,
    {
        let mut header_value = b"Basic ".to_vec();
        {
            let mut encoder = Base64Encoder::new(&mut header_value, base64::STANDARD);
            // The unwraps here are fine because Vec::write* is infallible.
            write!(encoder, "{}:", username).unwrap();
            if let Some(password) = password {
                write!(encoder, "{}", password).unwrap();
            }
        }

        self.header_sensitive(crate::header::AUTHORIZATION, header_value, true)
    }

    /// Enable HTTP bearer authentication.
    pub fn bearer_auth<T>(self, token: T) -> RequestBuilder
    where
        T: fmt::Display,
    {
        let header_value = format!("Bearer {}", token);
        self.header_sensitive(crate::header::AUTHORIZATION, header_value, true)
    }

    /// Set the request body.
    pub fn body<T: Into<Body>>(mut self, body: T) -> RequestBuilder {
        if let Ok(ref mut req) = self.request {
            *req.body_mut() = Some(body.into());
        }
        self
    }

    /// Enables a request timeout.
    ///
    /// The timeout is applied from when the request starts connecting until the
    /// response body has finished. It affects only this request and overrides
    /// the timeout configured using `ClientBuilder::timeout()`.
    pub fn timeout(mut self, timeout: Duration) -> RequestBuilder {
        if let Ok(ref mut req) = self.request {
            *req.timeout_mut() = Some(timeout);
        }
        self
    }

    /// Sends a multipart/form-data body.
    ///
    /// ```
    /// # use reqwest::Error;
    ///
    /// # async fn run() -> Result<(), Error> {
    /// let client = reqwest::Client::new();
    /// let form = reqwest::multipart::Form::new()
    ///     .text("key3", "value3")
    ///     .text("key4", "value4");
    ///
    ///
    /// let response = client.post("your url")
    ///     .multipart(form)
    ///     .send()
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    #[cfg(feature = "multipart")]
    pub fn multipart(self, mut multipart: multipart::Form) -> RequestBuilder {
        let mut builder = self.header(
            CONTENT_TYPE,
            format!("multipart/form-data; boundary={}", multipart.boundary()).as_str(),
        );

        builder = match multipart.compute_length() {
            Some(length) => builder.header(CONTENT_LENGTH, length),
            None => builder,
        };

        if let Ok(ref mut req) = builder.request {
            *req.body_mut() = Some(multipart.stream())
        }
        builder
    }

    /// Modify the query string of the URL.
    ///
    /// Modifies the URL of this request, adding the parameters provided.
    /// This method appends and does not overwrite. This means that it can
    /// be called multiple times and that existing query parameters are not
    /// overwritten if the same key is used. The key will simply show up
    /// twice in the query string.
    /// Calling `.query([("foo", "a"), ("foo", "b")])` gives `"foo=a&foo=b"`.
    ///
    /// # Note
    /// This method does not support serializing a single key-value
    /// pair. Instead of using `.query(("key", "val"))`, use a sequence, such
    /// as `.query(&[("key", "val")])`. It's also possible to serialize structs
    /// and maps into a key-value pair.
    ///
    /// # Errors
    /// This method will fail if the object you provide cannot be serialized
    /// into a query string.
    pub fn query<T: Serialize + ?Sized>(mut self, query: &T) -> RequestBuilder {
        let mut error = None;
        if let Ok(ref mut req) = self.request {
            let url = req.url_mut();
            let mut pairs = url.query_pairs_mut();
            let serializer = serde_urlencoded::Serializer::new(&mut pairs);

            if let Err(err) = query.serialize(serializer) {
                error = Some(crate::error::builder(err));
            }
        }
        if let Ok(ref mut req) = self.request {
            if let Some("") = req.url().query() {
                req.url_mut().set_query(None);
            }
        }
        if let Some(err) = error {
            self.request = Err(err);
        }
        self
    }

    /// Send a form body.
    pub fn form<T: Serialize + ?Sized>(mut self, form: &T) -> RequestBuilder {
        let mut error = None;
        if let Ok(ref mut req) = self.request {
            match serde_urlencoded::to_string(form) {
                Ok(body) => {
                    req.headers_mut().insert(
                        CONTENT_TYPE,
                        HeaderValue::from_static("application/x-www-form-urlencoded"),
                    );
                    *req.body_mut() = Some(body.into());
                }
                Err(err) => error = Some(crate::error::builder(err)),
            }
        }
        if let Some(err) = error {
            self.request = Err(err);
        }
        self
    }

    /// Send a JSON body.
    ///
    /// # Optional
    ///
    /// This requires the optional `json` feature enabled.
    ///
    /// # Errors
    ///
    /// Serialization can fail if `T`'s implementation of `Serialize` decides to
    /// fail, or if `T` contains a map with non-string keys.
    #[cfg(feature = "json")]
    pub fn json<T: Serialize + ?Sized>(mut self, json: &T) -> RequestBuilder {
        let mut error = None;
        if let Ok(ref mut req) = self.request {
            match serde_json::to_vec(json) {
                Ok(body) => {
                    req.headers_mut()
                        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
                    *req.body_mut() = Some(body.into());
                }
                Err(err) => error = Some(crate::error::builder(err)),
            }
        }
        if let Some(err) = error {
            self.request = Err(err);
        }
        self
    }

    /// Disable CORS on fetching the request.
    ///
    /// # WASM
    ///
    /// This option is only effective with WebAssembly target.
    ///
    /// The [request mode][mdn] will be set to 'no-cors'.
    ///
    /// [mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Request/mode
    pub fn fetch_mode_no_cors(self) -> RequestBuilder {
        self
    }
  
 
		// 从RequestBuilder的build函数可以知道,RequestBuilder是用来帮助构造Request的辅助结构体
    /// Build a `Request`, which can be inspected, modified and executed with
    /// `Client::execute()`.
    pub fn build(self) -> crate::Result<Request> {
        self.request
    }

    /// Constructs the Request and sends it to the target URL, returning a
    /// future Response.
    ///
    /// # Errors
    ///
    /// This method fails if there was an error while sending request,
    /// redirect loop was detected or redirect limit was exhausted.
    ///
    /// # Example
    ///
    /// ```no_run
    /// # use reqwest::Error;
    /// #
    /// # async fn run() -> Result<(), Error> {
    /// let response = reqwest::Client::new()
    ///     .get("https://hyper.rs")
    ///     .send()
    ///     .await?;
    /// # Ok(())
    /// # }
    /// ```
    pub fn send(self) -> impl Future<Output = Result<Response, crate::Error>> {
        match self.request {
            Ok(req) => self.client.execute_request(req),
            Err(err) => Pending::new_err(err),
        }
    }

    /// Attempt to clone the RequestBuilder.
    ///
    /// `None` is returned if the RequestBuilder can not be cloned,
    /// i.e. if the request body is a stream.
    ///
    /// # Examples
    ///
    /// ```
    /// # use reqwest::Error;
    /// #
    /// # fn run() -> Result<(), Error> {
    /// let client = reqwest::Client::new();
    /// let builder = client.post("http://httpbin.org/post")
    ///     .body("from a &str!");
    /// let clone = builder.try_clone();
    /// assert!(clone.is_some());
    /// # Ok(())
    /// # }
    /// ```
    pub fn try_clone(&self) -> Option<RequestBuilder> {
        self.request
            .as_ref()
            .ok()
            .and_then(|req| req.try_clone())
            .map(|req| RequestBuilder {
                client: self.client.clone(),
                request: Ok(req),
            })
    }
}
}

从RequestBuilder的build函数可以知道,RequestBuilder是用来帮助构造Request的辅助结构体。

参考链接:

https://docs.rs/tokio/1.1.0/tokio/runtime/struct.Builder.html

https://docs.rs/reqwest/0.11.0/src/reqwest/async_impl/request.rs.html#36-39

https://github.com/http-rs/surf/blob/31315743b91ff003231183c1ec5a3cd2b698c58a/src/request_builder.rs

https://docs.rs/futures/0.3.12/futures/executor/struct.ThreadPoolBuilder.html

关于 io_uring 与 Rust 的思考

作者:王徐旸


io_uring 是 Linux 5.x 时代加入的一套全新的异步机制,被钦定为 Linux 异步的未来。

本文将探讨在 Rust 中安全封装 io_uring 的一系列设计问题,并提出一些可能的解决方案。

io_uring 的工作方式

io_uring 分为两个队列,提交队列 SQ (Submission Queue) 和完成队列 CQ (Completion Queue)。提交队列存放正在等待执行的异步任务,完成队列存放完成事件。

io_uring 的结构由内核分配,用户态通过 mmap 拿到相关结构的内存访问权限,这样就能让内核态与用户态共享内存,绕过系统调用双向传递数据。

概念工作流程具有三个阶段

  1. 准备:应用程序获取一些提交队列项 SQE (Submission Queue Entry),将每个异步任务分别设置到每个 SQE 中,用操作码、参数初始化。
  2. 提交:应用程序向 SQ 中推入一些需要提交的 SQE,通过一次系统调用告诉内核有新的任务,或者让内核不停轮询来获取任务。
  3. 收割:应用程序从 CQ 中取得一些完成队列事件 CQE (Completion Queue Event),通过 user_data 识别并唤醒应用程序中的线程/协程,传递返回值。

epoll 是 Reactor 模型的实现,而 io_uring 是 Proactor 模型的实现。

这意味着基于 epoll 设计的程序难以直接迁移到 io_uring。

问题 1: 改变异步模型并不是一件容易的事,除非以部分性能为代价抹平差异。

问题 2: io_uring 需要较高版本的内核,现阶段,应用程序不得不考虑在没有 io_uring 高版本特性时要怎么回退 (fallback)。

io_uring 的约束

在阻塞同步模型和非阻塞同步模型(如 epoll)中,用户态 IO 操作是一锤子买卖,无需担心生存期。

但 io_uring 是 Proactor,是非阻塞异步模型,对资源的生存期有所约束。

以 read 为例,它有 fd 和 buf 两个资源参数,当准备 IO 操作时,我们需要把 fd、buf 指针和 count 填入 SQE,并且保证在内核完成或取消该任务之前,fd 和 buf 都必须有效

fd 意外替换

fd = 6, buf = 0x5678;
准备 SQE;
close fd = 6;
open -> fd = 6;
提交 SQE;
内核执行 IO;

在提交 SQE 之前,应用程序“不小心”关闭又打开了文件,这将导致 IO 操作意外地被执行到一个完全无关的文件上。

栈内存 UAF

char stack_buf[1024];
fd = 6, buf = &stack_buf;
准备 SQE;
提交 SQE;
函数返回;
内核执行 IO;

内核执行的 IO 会操作已被释放的栈上内存,出现“释放后使用”(use-after-free) 漏洞。

堆内存 UAF

char* heap_buf = malloc(1024);
fd = 6, buf = heap_buf;
准备 SQE;
提交 SQE;
执行其他代码出错;
free(heap_buf);
函数返回错误码;
内核执行 IO;

内核执行的 IO 会使用已被释放的堆上内存,又一个 UAF 漏洞。

移动后使用


#![allow(unused)]
fn main() {
struct Buf<T>(T);
let mut buf1: Buf<[u8;1024]> = Buf([0;1024]);
fd = 6, buf = buf1.0.as_mut_ptr();
unsafe {
    准备 SQE;
}
提交 SQE;
let buf2 = Box::new(buf1);
内核执行 IO;
}

当内核执行 IO 时,buf1 已被移动,指针失效。出现“移动后使用”的漏洞,本文称为 UAM 漏洞。

取消后使用


#![allow(unused)]
fn main() {
async fn foo() -> io::Result<()> {
    let mut buf1: [u8;1024] = [0;1024];
    fd = 6, buf = buf1.as_mut_ptr();
    unsafe {
        准备 SQE;
    }
    提交 SQE;
    bar().await
}
}

Rust 的 async 函数会生成无栈协程,栈变量保存在一个结构体中。如果这个结构体被析构,底层的叶 Future 就会被析构,同时取消异步操作。

然而析构函数是同步的,当协程析构时,内核仍然可能正在占用缓冲区来执行 IO。如果不做处理,就会出现 UAF 漏洞。

关闭后使用


#![allow(unused)]
fn main() {
准备 SQE;
提交 SQE;
io_uring_queue_exit(&ring)
???
}

内核在 io_uring_queue_exit 之后会立即取消正在执行的 IO 吗?

// TODO: 找到答案

如果会立即取消,那么用户态程序也无法得到取消事件,无法唤醒任务或释放资源。

如果不会立即取消,那么内核对资源的占用会超出 io_uring 实例的生存期,带来更加麻烦的问题。

这似乎说明 io_uring 实例必须为 static 生存期,与线程本身活得一样长。或者采取某种引用计数的方式,推迟 exit 时机。

具有 Rust 特色的 io_uring

Rust 的底线是内存安全,不允许出现内存安全漏洞或数据竞争。Rust 的所有权规则为此提供了很好的保障。

迁移所有权

“迁移所有权” 是本文中自行创造的概念,它表示要进行某个操作就必须放弃对参数的所有权,把参数的所有权“迁移”到其他地方。

当使用 io_uring 时,相当于内核持有资源的所有权。用户态必须放弃对资源的控制权,除非它可以安全地并发操作。IO 操作完成或取消时,内核占用的所有资源会被返还给用户态。

但内核不可能真的去持有所有权,实际上是由异步运行时来存储这些资源,并模拟出“迁移所有权”的模型。

BufRead trait 表示一个包含内部缓冲区的可读取类型。BufReader<File> 是一个典型用法。

BufReader<File> 可以匹配 io_uring 的工作模式。

准备 fd, buf
准备 SQE
提交 SQE
等待唤醒
拿到返回值
回收 fd, buf
暴露 buf 的共享引用

问题 3: 当 Future 被取消时,buf 仍然被内核占用,BufReader<File> 处于无效状态。再次进行 IO 时,它只能选择死亡。

想象这样一个底层 Future


#![allow(unused)]
fn main() {
pub struct Read<F, B>
where
    F: AsRawFd + 'static,
    B: AsMut<[u8]> + 'static,
{
    fd: F,
    buf: B,
    ...
}
}

buf 可以是 [u8; N],也满足 AsMut<[u8]> + 'static,但它不能被取指针传递给 io_uring。

buf 在这个 Future 被析构时失效,不满足 io_uring 的约束。

修复方案有两种:在准备 SQE 之前就把 fd 和 buf 都移动到堆上,或者限制 buf 为可安全逃逸的缓冲区类型。

堆分配

如果要在准备 SQE 之前确保 fd 和 buf 不会被析构,只能堆分配了。

这样 fd 和 buf 在 IO 操作完成或取消之前就不会被移动或析构,保证了有效性。


#![allow(unused)]
fn main() {
pub struct Read<F, B>
where
    F: AsRawFd + 'static,
    B: AsMut<[u8]> + 'static,
{
    state: ManualDrop<Box<State<F, B>>>
}
}

然而,大部分时候 buf 都是指向堆上动态大小缓冲区的智能指针,为指针本身去堆分配是不太值得的,要提高效率必须以某种方式实现自定义分配器。

逃逸

通常的“逃逸分析”是分析对象的动态范围,如果对象有可能离开函数作用域,就把它分配到堆上。

本文提出的“逃逸”是指让结构体成员逃脱析构,转移到一个稳定的地方。

可安全逃逸的缓冲区类型在移动时不会改变缓冲区的内存地址。

[u8;N] 在移动时完全改变了缓冲区的地址范围,而 Box<[u8]>Vec<u8> 不会改变。

SmallVec<[u8;N]> 在容量不大于 N 时会把数据存储在栈上,过大时存储在堆上。

Box<[u8]>Vec<u8> 作为缓冲区可以安全逃逸,[u8;N]SmallVec<[u8;N]> 不可以。

如果限制 buf 为可安全逃逸的缓冲区类型,那么在最理想的情况下,进行 IO 操作时不需要系统调用,不需要额外的堆分配,缓冲区由调用者控制,几乎完美。

问题 4: 如何在不传染 unsafe 的情况下表达这种约束?

定义一个 unsafe trait 自然省事,但无法对所有符合条件的缓冲区通用,还可能受孤儿规则影响,让用户必须去写 newtype 或 unsafe。

可以意识到,这里的“安全逃逸”和 Pin 的概念有某种相关,有没有办法联系起来?

Send

io_uring 的收割可以由本线程做,也可以由一个专门的驱动线程做。

目前 SQ 不支持多线程提交,全局共享需要上锁。io_uring 更匹配每个线程自带一个 ring 的实现。

考虑这样一个 Future,当它析构时,里面的资源会逃逸到堆上。


#![allow(unused)]
fn main() {
pub struct Read<F, B>
where
    F: AsRawFd + 'static,
    B: EscapedBufMut + 'static,
{
    fd: F,
    buf: B,
    ...
}
}

如果由全局驱动线程做最终析构,那么资源就会从当前线程转移到驱动线程,这需要资源满足 Send。

如果由本线程做最终析构,那么资源不需要转移,可以不满足 Send。

问题 5: 收割和析构策略也会影响 API 的泛型约束,如何设计合适的 API?

拷贝

缓冲区必须能在 Future 析构之后保持有效,这意味着我们无法把临时的 &mut [u8]&[u8] 传入 io_uring,无法做原地读取或写入。

而 epoll 可以等待 fd 可读或可写后,再原地读取或写入。

无论如何,把缓冲区放在堆上这一步是不可避免的,区别在于缓冲区是由异步类型本身来控制还是由调用者来控制。

让调用者来控制缓冲区,能避免额外拷贝,但会加大安全审查的难度,必须限制传入的缓冲区具有良好的行为。

异步类型内置缓冲区,会增加额外拷贝,但安全性由库的作者保证,减小了出现漏洞的可能性。

问题6: io_uring 加大了实现用户态零拷贝的难度。

生态

uring-sys: liburing 的绑定。

iou:Rust 风格的低层 io_uring 接口。

ringbahn:实验性的 io_uring 高层封装

maglev:实验性的 io_uring 异步驱动/运行时

总结

划个重点

问题 1: epoll 是 Reactor 模型的实现,而 io_uring 是 Proactor 模型的实现。改变异步模型并不是一件容易的事,除非以性能为代价抹平差异。

问题 2: io_uring 需要较高版本的内核,现阶段,应用程序不得不考虑在没有 io_uring 高版本特性时要怎么回退 (fallback)。

问题 3: 当 Future 被取消时,buf 仍然被内核占用,异步类型可能处于无效状态。再次进行 IO 时,它只能选择死亡。

问题 4: 如果选择限制 buf 为可安全逃逸的缓冲区类型,如何在不传染 unsafe 的情况下表达这种约束?

问题 5: 收割和析构策略也会影响 API 的泛型约束,如何设计合适的 API?

问题 6: io_uring 加大了实现用户态零拷贝的难度。

如果不考虑最高性能,我们有各种方案来封装一个能用的 io_uring 库。

如果不考虑通用,我们可以在自己的程序中谨慎地用 io_uring,锁死类型。

Rust 对安全、性能、通用的追求给封装 io_uring 带来了较高的难度。

ringbahn 的设计思路是其中一种可能的方向。社区还需要探索什么才是最完美的设计。

扩展阅读

Efficient IO with io_uring

AIO 的新归宿:io_uring

Go 与异步 IO - io_uring 的思考

Notes on io-uring

Ringbahn: a safe, ergonomic API for io-uring in Rust

Ringbahn II: the central state machine

Ringbahn III: A deeper dive into drivers

feature requests: submit requests from any thread


本文首发于知乎专栏 「Rust 日常

作者简介:

王徐旸,大三学生,2018 年开始学习和使用 Rust 语言,造轮子爱好者。

GitHub ID: Nugine

学习园地 | 「译」 GraphQL in Rust

译者序

Roman Kudryashov(博客)是一名来自莫斯科的资深后端开发人员,在日常工作中用Rust/Java/Kotlin来完成服务的持久层,微服务之间的集成等工作。在Async-graphql的开发过程中给予了非常多的帮助,然后根据这些经验总结出来这篇入门教程(英文原版)。

译者老油条(孙黎),Async-graphql库作者,连续创业者,处女座码农,之前日常工作由C++和Golang完成,两年前一个偶然的机会邂逅Rust语言,并不由自主的爱上了它,之后再也没有碰过其它编程语言,工作中用Rust语言完成所有的事情,是不折不扣的Rust语言狂热粉丝。Rust是我这么多年编程生涯中真正遇到的完美编程语言,无GC,并发安全以及类似Python等脚本语言才提供的高级语法,让我产生给它做一些力所能及的贡献的想法,nvgXactor是刚学Rust不久之后的小试牛刀,而Async-graphql是Rust 1.39异步稳定之后的产物。

学习Rust的过程很艰辛,需要保持一颗修行的心,当你能够越过那一座座阻碍在面前的高山,也许才能够发现它真正的美好。

目录

在今天的文章中,我将描述如何使用Rust及其生态系统创建GraphQL后端服务。 本文提供了创建GraphQL API时最常见任务的实现示例。最后,将使用Apollo Server和Apollo Federation将三个微服务组合为一个端点。 这使客户端可以同时从任意数量的源中获取数据,而无需知道哪些数据来自哪个源。

介绍

概览

在功能方面,所描述的项目与我上一篇文章中所描述的非常相似,但是现在它是使用Rust编写的。 该项目的架构如下所示:

architecture

架构的每个组件都回答了在实现GraphQL API时可能出现的几个问题。整个模型包括有关太阳系中的行星及其卫星的数据。该项目具有多模块结构,并包含以下模块:

在Rust中有两个库来创建GraphQL后端:JuniperAsync-graphql,但是只有后者支持Apollo Federation,因此我在项目中选择了它(Juniper中的Federation支持存在未解决的问题)。 这两个库都遵循代码优先方法。

同样,PostgreSQL用于持久层实现,JWT用于认证,而Kafka用于消息传递。

技术栈

下表总结了该项目中使用的主要技术栈:

类型名字网站代码仓库
语言Rustlinklink
GraphQL服务端库Async-graphqllinklink
GraphQL网关Apollo Serverlinklink
Web框架Actix-weblinklink
数据库PostgreSQLlinklink
消息队列Apache Kafkalinklink
容器编排Docker Composelinklink

另外还有一些需要依赖的Rust库:

类型名字网站代码仓库
ORMDiesellinklink
Kafka客户端rust-rdkafkalinklink
密码哈希库argonauticalinklink
JWTjsonwebtokenlinklink
测试Testcontainers-rslinklink

开发工具

要在本地启动项目,你只需要Docker Compose。 如果没有Docker,可能需要安装以下内容:

实现

清单1. 根Cargo.toml指定三个应用和一个库:

Cargo.toml

[workspace]
members = [
    "auth-service",
    "planets-service",
    "satellites-service",
    "common-utils",
]

让我们从planets-service开始。

依赖库

这是Cargo.toml:

清单2. Cargo.toml

[package]
name = "planets-service"
version = "0.1.0"
edition = "2018"

[dependencies]
common-utils = { path = "../common-utils" }
async-graphql = "2.4.3"
async-graphql-actix-web = "2.4.3"
actix-web = "3.3.2"
actix-rt = "1.1.1"
actix-web-actors = "3.0.0"
futures = "0.3.8"
async-trait = "0.1.42"
bigdecimal = { version = "0.1.2", features = ["serde"] }
serde = { version = "1.0.118", features = ["derive"] }
serde_json = "1.0.60"
diesel = { version = "1.4.5", features = ["postgres", "r2d2", "numeric"] }
diesel_migrations = "1.4.0"
dotenv = "0.15.0"
strum = "0.20.0"
strum_macros = "0.20.1"
rdkafka = { version = "0.24.0", features = ["cmake-build"] }
async-stream = "0.3.0"
lazy_static = "1.4.0"

[dev-dependencies]
jsonpath_lib = "0.2.6"
testcontainers = "0.9.1"

Async-graphql是GraphQL服务端库,Actix-web是Web服务框架,而Async-graphql-actix-web提供它们之间的集成。

核心功能

我们转到main.rs

清单3. main.rs

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    dotenv().ok();
    let pool = create_connection_pool();
    run_migrations(&pool);

    let schema = create_schema_with_context(pool);

    HttpServer::new(move || App::new()
        .configure(configure_service)
        .data(schema.clone())
    )
        .bind("0.0.0.0:8001")?
        .run()
        .await
}

这里,使用lib.rs中定义的功能配置环境和HTTP服务器:

清单4. lib.rs


#![allow(unused)]
fn main() {
pub fn configure_service(cfg: &mut web::ServiceConfig) {
    cfg
        .service(web::resource("/")
            .route(web::post().to(index))
            .route(web::get().guard(guard::Header("upgrade", "websocket")).to(index_ws))
            .route(web::get().to(index_playground))
        );
}

async fn index(schema: web::Data<AppSchema>, http_req: HttpRequest, req: Request) -> Response {
    let mut query = req.into_inner();

    let maybe_role = common_utils::get_role(http_req);
    if let Some(role) = maybe_role {
        query = query.data(role);
    }

    schema.execute(query).await.into()
}

async fn index_ws(schema: web::Data<AppSchema>, req: HttpRequest, payload: web::Payload) -> Result<HttpResponse> {
    WSSubscription::start(Schema::clone(&*schema), &req, payload)
}

async fn index_playground() -> HttpResponse {
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(playground_source(GraphQLPlaygroundConfig::new("/").subscription_endpoint("/")))
}

pub fn create_schema_with_context(pool: PgPool) -> Schema<Query, Mutation, Subscription> {
    let arc_pool = Arc::new(pool);
    let cloned_pool = Arc::clone(&arc_pool);
    let details_batch_loader = Loader::new(DetailsBatchLoader {
        pool: cloned_pool
    }).with_max_batch_size(10);

    let kafka_consumer_counter = Mutex::new(0);

    Schema::build(Query, Mutation, Subscription)
        .data(arc_pool)
        .data(details_batch_loader)
        .data(kafka::create_producer())
        .data(kafka_consumer_counter)
        .finish()
}
}

这些函数执行以下操作:

  • index - 处理GraphQL查询和变更
  • index_ws - 处理GraphQL订阅
  • index_playground - 提供Graph Playground IDE
  • create_schema_with_context - 使用可在运行时访问的全局上下文数据(例如数据库连接池)创建GraphQL模式

查询和类型定义

让我们考虑如何定义查询:

清单5. 定义查询


#![allow(unused)]
fn main() {
#[Object]
impl Query {
    async fn get_planets(&self, ctx: &Context<'_>) -> Vec<Planet> {
        repository::get_all(&get_conn_from_ctx(ctx)).expect("Can't get planets")
            .iter()
            .map(|p| { Planet::from(p) })
            .collect()
    }

    async fn get_planet(&self, ctx: &Context<'_>, id: ID) -> Option<Planet> {
        find_planet_by_id_internal(ctx, id)
    }

    #[graphql(entity)]
    async fn find_planet_by_id(&self, ctx: &Context<'_>, id: ID) -> Option<Planet> {
        find_planet_by_id_internal(ctx, id)
    }
}

fn find_planet_by_id_internal(ctx: &Context<'_>, id: ID) -> Option<Planet> {
    let id = id.to_string().parse::<i32>().expect("Can't get id from String");
    repository::get(id, &get_conn_from_ctx(ctx)).ok()
        .map(|p| { Planet::from(&p) })
}
}

每个查询都使用repository从数据库获取数据并将获得的记录转换为GraphQL DTO(这使我们可以保留每个结构的单一职责)。 可以从任何GraphQL IDE访问get_planetsget_planet查询,例如:

清单6. 查询示例

{
  getPlanets {
    name
    type
  }
}

Planet对象定义如下:

清单7. GraphQL类型定义


#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
struct Planet {
    id: ID,
    name: String,
    planet_type: PlanetType,
}

#[Object]
impl Planet {
    async fn id(&self) -> &ID {
        &self.id
    }

    async fn name(&self) -> &String {
        &self.name
    }

    /// From an astronomical point of view
    #[graphql(name = "type")]
    async fn planet_type(&self) -> &PlanetType {
        &self.planet_type
    }

    #[graphql(deprecation = "Now it is not in doubt. Do not use this field")]
    async fn is_rotating_around_sun(&self) -> bool {
        true
    }

    async fn details(&self, ctx: &Context<'_>) -> Details {
        let loader = ctx.data::<Loader<i32, Details, DetailsBatchLoader>>().expect("Can't get loader");
        let planet_id = self.id.to_string().parse::<i32>().expect("Can't convert id");
        loader.load(planet_id).await
    }
}
}

在这里,我们为每个字段定义一个Resolver。另外,在某些字段中,指定了描述(Rust文档注释)和弃用原因。 这些将显示在GraphQL IDE中。

解决N+1问题

如果Planetdetails函数的实现是直接从数据库中查询对应idplanet对象则将导致N+1问题,如果你发出这样的请求:

清单8: 可能消耗过多资源的GraphQL请求的示例

{
  getPlanets {
    name
    details {
      meanRadius
    }
  }
}

这将对每个plant对象的details字段执行单独的SQL查询,因为details是与planet关联的类型,并存储在其自己的表中。

但借助Async-graphqlDataLoader实现,可以将Resolver定义如下:


#![allow(unused)]
fn main() {
async fn details(&self, ctx: &Context<'_>) -> Result<Details> {
    let data_loader = ctx.data::<DataLoader<DetailsLoader>>().expect("Can't get data loader");
    let planet_id = self.id.to_string().parse::<i32>().expect("Can't convert id");
    let details = data_loader.load_one(planet_id).await?;
    details.ok_or_else(|| "Not found".into())
}
}

data_loader是通过以下方式定义的应用程序范围的对象:

清单10. DataLoader定义


#![allow(unused)]
fn main() {
let details_data_loader = DataLoader::new(DetailsLoader {
    pool: cloned_pool
}).max_batch_size(10)
}

DetailsLoader的实现:

_清单11. DetailsLoader定义


#![allow(unused)]
fn main() {
pub struct DetailsLoader {
    pub pool: Arc<PgPool>
}

#[async_trait::async_trait]
impl Loader<i32> for DetailsLoader {
    type Value = Details;
    type Error = Error;

    async fn load(&self, keys: &[i32]) -> Result<HashMap<i32, Self::Value>, Self::Error> {
        let conn = self.pool.get().expect("Can't get DB connection");
        let details = repository::get_details(keys, &conn).expect("Can't get planets' details");

        Ok(details.iter()
            .map(|details_entity| (details_entity.planet_id, Details::from(details_entity)))
            .collect::<HashMap<_, _>>())
    }
}
}

此方法有助于我们防止N+1问题,因为每个DetailsLoader.load调用仅执行一个SQL查询,返回多个DetailsEntity

接口定义

GraphQL接口及其实现通过以下方式定义:

清单12. GraphQL接口定义


#![allow(unused)]
fn main() {
#[derive(Interface, Clone)]
#[graphql(
    field(name = "mean_radius", type = "&CustomBigDecimal"),
    field(name = "mass", type = "&CustomBigInt"),
)]
pub enum Details {
    InhabitedPlanetDetails(InhabitedPlanetDetails),
    UninhabitedPlanetDetails(UninhabitedPlanetDetails),
}

#[derive(SimpleObject, Clone)]
pub struct InhabitedPlanetDetails {
    mean_radius: CustomBigDecimal,
    mass: CustomBigInt,
    /// In billions
    population: CustomBigDecimal,
}

#[derive(SimpleObject, Clone)]
pub struct UninhabitedPlanetDetails {
    mean_radius: CustomBigDecimal,
    mass: CustomBigInt,
}
}

在这里你还可以看到,如果该对象没有任何复杂Resolver的字段,则可以使用SimpleObject宏来实现。

自定义标量

这个项目包含两个自定义标量定义的示例,两者都是数字类型的包装器(因为由于孤儿规则,你无法在外部类型上实现外部特征)。包装器的实现如下:

清单 13. 自定义标量: 包装BigInt


#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct CustomBigInt(BigDecimal);

#[Scalar(name = "BigInt")]
impl ScalarType for CustomBigInt {
    fn parse(value: Value) -> InputValueResult<Self> {
        match value {
            Value::String(s) => {
                let parsed_value = BigDecimal::from_str(&s)?;
                Ok(CustomBigInt(parsed_value))
            }
            _ => Err(InputValueError::expected_type(value)),
        }
    }

    fn to_value(&self) -> Value {
        Value::String(format!("{:e}", &self))
    }
}

impl LowerExp for CustomBigInt {
    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
        let val = &self.0.to_f64().expect("Can't convert BigDecimal");
        LowerExp::fmt(val, f)
    }
}
}

清单 14. 自定义标量: 包装BigDecimal


#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct CustomBigDecimal(BigDecimal);

#[Scalar(name = "BigDecimal")]
impl ScalarType for CustomBigDecimal {
    fn parse(value: Value) -> InputValueResult<Self> {
        match value {
            Value::String(s) => {
                let parsed_value = BigDecimal::from_str(&s)?;
                Ok(CustomBigDecimal(parsed_value))
            }
            _ => Err(InputValueError::expected_type(value)),
        }
    }

    fn to_value(&self) -> Value {
        Value::String(self.0.to_string())
    }
}
}

前一个示例还支持使用指数表示大数。

定义变更(Mutation)

变更定义如下:

清单 15. 定义变更


#![allow(unused)]
fn main() {
pub struct Mutation;

#[Object]
impl Mutation {
    #[graphql(guard(RoleGuard(role = "Role::Admin")))]
    async fn create_planet(&self, ctx: &Context<'_>, planet: PlanetInput) -> Result<Planet, Error> {
        let new_planet = NewPlanetEntity {
            name: planet.name,
            planet_type: planet.planet_type.to_string(),
        };

        let details = planet.details;
        let new_planet_details = NewDetailsEntity {
            mean_radius: details.mean_radius.0,
            mass: BigDecimal::from_str(&details.mass.0.to_string()).expect("Can't get BigDecimal from string"),
            population: details.population.map(|wrapper| { wrapper.0 }),
            planet_id: 0,
        };

        let created_planet_entity = repository::create(new_planet, new_planet_details, &get_conn_from_ctx(ctx))?;

        let producer = ctx.data::<FutureProducer>().expect("Can't get Kafka producer");
        let message = serde_json::to_string(&Planet::from(&created_planet_entity)).expect("Can't serialize a planet");
        kafka::send_message(producer, message).await;

        Ok(Planet::from(&created_planet_entity))
    }
}
}

Mutation.create_planet输入参数需要定义以下结构:

清单 16: 定义输入类型


#![allow(unused)]
fn main() {
#[derive(InputObject)]
struct PlanetInput {
    name: String,
    #[graphql(name = "type")]
    planet_type: PlanetType,
    details: DetailsInput,
}
}

create_planetRoleGuard保护,可确保只有具有Admin角色的用户才能访问它。要执行变异,如下所示:

mutation {
  createPlanet(
    planet: {
      name: "test_planet"
      type: TERRESTRIAL_PLANET
      details: { meanRadius: "10.5", mass: "8.8e24", population: "0.5" }
    }
  ) {
    id
  }
}

你需要从auth-service获得JWT,并指定Authorization作为HTTP请求的标头(稍后将对此进行描述)。

定义订阅(Subscription)

在上面的Mutation定义中,你可以看到在planet创建过程中发送了一条消息:

清单 18. 发送消息到Kafka


#![allow(unused)]
fn main() {
let producer = ctx.data::<FutureProducer>().expect("Can't get Kafka producer");
let message = serde_json::to_string(&Planet::from(&created_planet_entity)).expect("Can't serialize a planet");
kafka::send_message(producer, message).await;
}

使用者可以通过监听Kafka订阅将事件通知给API客户端:

清单 19. 订阅定义


#![allow(unused)]
fn main() {
pub struct Subscription;

#[Subscription]
impl Subscription {
    async fn latest_planet<'ctx>(&self, ctx: &'ctx Context<'_>) -> impl Stream<Item=Planet> + 'ctx {
        let kafka_consumer_counter = ctx.data::<Mutex<i32>>().expect("Can't get Kafka consumer counter");
        let consumer_group_id = kafka::get_kafka_consumer_group_id(kafka_consumer_counter);
        let consumer = kafka::create_consumer(consumer_group_id);

        async_stream::stream! {
            let mut stream = consumer.start();

            while let Some(value) = stream.next().await {
                yield match value {
                    Ok(message) => {
                        let payload = message.payload().expect("Kafka message should contain payload");
                        let message = String::from_utf8_lossy(payload).to_string();
                        serde_json::from_str(&message).expect("Can't deserialize a planet")
                    }
                    Err(e) => panic!("Error while Kafka message processing: {}", e)
                };
            }
        }
    }
}
}

订阅可以像查询(Query)和变更(Mutation)一样使用:

清单 20. 订阅使用例子

subscription {
  latestPlanet {
    id
    name
    type
    details {
      meanRadius
    }
  }
}

订阅的URL是ws://localhost:8001

集成测试

查询和变更的测试可以这样写:

清单 21. 查询测试


#![allow(unused)]
fn main() {
#[actix_rt::test]
async fn test_get_planets() {
    let docker = Cli::default();
    let (_pg_container, pool) = common::setup(&docker);

    let mut service = test::init_service(App::new()
        .configure(configure_service)
        .data(create_schema_with_context(pool))
    ).await;

    let query = "
        {
            getPlanets {
                id
                name
                type
                details {
                    meanRadius
                    mass
                    ... on InhabitedPlanetDetails {
                        population
                    }
                }
            }
        }
        ".to_string();

    let request_body = GraphQLCustomRequest {
        query,
        variables: Map::new(),
    };

    let request = test::TestRequest::post().uri("/").set_json(&request_body).to_request();

    let response: GraphQLCustomResponse = test::read_response_json(&mut service, request).await;

    fn get_planet_as_json(all_planets: &serde_json::Value, index: i32) -> &serde_json::Value {
        jsonpath::select(all_planets, &format!("$.getPlanets[{}]", index)).expect("Can't get planet by JSON path")[0]
    }

    let mercury_json = get_planet_as_json(&response.data, 0);
    common::check_planet(mercury_json, 1, "Mercury", "TERRESTRIAL_PLANET", "2439.7");

    let earth_json = get_planet_as_json(&response.data, 2);
    common::check_planet(earth_json, 3, "Earth", "TERRESTRIAL_PLANET", "6371.0");

    let neptune_json = get_planet_as_json(&response.data, 7);
    common::check_planet(neptune_json, 8, "Neptune", "ICE_GIANT", "24622.0");
}
}

如果查询的一部分可以在另一个查询中重用,则可以使用片段(Fragment)

清单 22. 查询测试(使用片段)


#![allow(unused)]
fn main() {
const PLANET_FRAGMENT: &str = "
    fragment planetFragment on Planet {
        id
        name
        type
        details {
            meanRadius
            mass
            ... on InhabitedPlanetDetails {
                population
            }
        }
    }
";

#[actix_rt::test]
async fn test_get_planet_by_id() {
    ...

    let query = "
        {
            getPlanet(id: 3) {
                ... planetFragment
            }
        }
        ".to_string() + PLANET_FRAGMENT;

    let request_body = GraphQLCustomRequest {
        query,
        variables: Map::new(),
    };

    ...
}
}

要使用变量,你可以通过以下方式编写测试:

清单 23. 查询测试(使用片段和变量)


#![allow(unused)]
fn main() {
#[actix_rt::test]
async fn test_get_planet_by_id_with_variable() {
    ...

    let query = "
        query testPlanetById($planetId: String!) {
            getPlanet(id: $planetId) {
                ... planetFragment
            }
        }".to_string() + PLANET_FRAGMENT;

    let jupiter_id = 5;
    let mut variables = Map::new();
    variables.insert("planetId".to_string(), jupiter_id.into());

    let request_body = GraphQLCustomRequest {
        query,
        variables,
    };

    ...
}
}

在这个项目中,Testcontainers-rs库用于准备测试环境,创建一个临时PostgreSQL数据库。

GraphQL客户端

你可以使用上一部分中的代码段来创建外部GraphQL API的客户端。另外,有一些库可用于此目的,例如graphql-client,但我还没有使用它们。

API安全

GraphQL API有一些不同程度的安全威胁(请参阅此清单以了解更多信息),让我们考虑其中的一些方面。

限制查询的深度和复杂度

如果Satellite对象容纳planet字段,则可能有以下查询:

清单 24. 昂贵查询的例子

{
  getPlanet(id: "1") {
    satellites {
      planet {
        satellites {
          planet {
            satellites {
              ... # 更深的嵌套!
            }
          }
        }
      }
    }
  }
}

为了使这样的查询无效,我们可以指定:

清单 25. 限制查询深度和复杂度的例子


#![allow(unused)]
fn main() {
pub fn create_schema_with_context(pool: PgPool) -> Schema<Query, Mutation, Subscription> {
    ...

    Schema::build(Query, Mutation, Subscription)
        .limit_depth(3)
        .limit_complexity(15)

    ...
}
}

请注意,如果你指定深度或复杂度限制,则API文档可能不能在GraphQL IDE中显示,这是因为IDE尝试执行具有相当深度和复杂度的自省查询。

认证

使用argonauticajsonwebtoken库在auth-service中实现此功能。 前一个库负责使用Argon2算法对用户的密码进行哈希处理。身份验证和授权功能仅用于演示,请针对生产用途进行更多研究。

让我们看看登录的实现方式:

清单 26. 实现登录


#![allow(unused)]
fn main() {
pub struct Mutation;

#[Object]
impl Mutation {

    async fn sign_in(&self, ctx: &Context<'_>, input: SignInInput) -> Result<String, Error> {
        let maybe_user = repository::get_user(&input.username, &get_conn_from_ctx(ctx)).ok();

        if let Some(user) = maybe_user {
            if let Ok(matching) = verify_password(&user.hash, &input.password) {
                if matching {
                    let role = AuthRole::from_str(user.role.as_str()).expect("Can't convert &str to AuthRole");
                    return Ok(common_utils::create_token(user.username, role));
                }
            }
        }

        Err(Error::new("Can't authenticate a user"))
    }
}

#[derive(InputObject)]
struct SignInInput {
    username: String,
    password: String,
}
}

你可以在utils模块中查看verify_password函数的实现,在common_utils模块中查看create_token函数的实现。如你所料,sign_in函数将颁发JWT,该JWT可进一步用于其他服务中的授权。

要获得JWT,你需要执行以下变更:

清单 27. 获取JWT

mutation {
  signIn(input: { username: "john_doe", password: "password" })
}

使用 john_doe/password ,将获得的JWT用于在进一步的请求中,可以访问受保护的资源(请参阅下一节)。

鉴权

要请求受保护的数据,你需要以Authorization:Bearer $ JWT格式向HTTP请求中添加标头。 index函数将从请求中提取用户的角色,并将其添加到查询数据中:

清单 28. 角色提取


#![allow(unused)]
fn main() {
async fn index(schema: web::Data<AppSchema>, http_req: HttpRequest, req: Request) -> Response {
    let mut query = req.into_inner();

    let maybe_role = common_utils::get_role(http_req);
    if let Some(role) = maybe_role {
        query = query.data(role);
    }

    schema.execute(query).await.into()
}
}

以下属性应用于先前定义的create_planet变更:

清单 29. 使用字段守卫


#![allow(unused)]
fn main() {
#[graphql(guard(RoleGuard(role = "Role::Admin")))]
}

这个守卫自身实现如下:

清单 30. 守卫实现


#![allow(unused)]
fn main() {
struct RoleGuard {
    role: Role,
}

#[async_trait::async_trait]
impl Guard for RoleGuard {
    async fn check(&self, ctx: &Context<'_>) -> Result<()> {
        if ctx.data_opt::<Role>() == Some(&self.role) {
            Ok(())
        } else {
            Err("Forbidden".into())
        }
    }
}
}

这样如果你未指定角色,则服务器将返回Forbidden的消息。

定义枚举

GraphQL枚举可以通过以下方式定义:

清单 31. 定义枚举


#![allow(unused)]
fn main() {
#[derive(SimpleObject)]
struct Satellite {
    ...
    life_exists: LifeExists,
}

#[derive(Copy, Clone, Eq, PartialEq, Debug, Enum, EnumString)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub enum LifeExists {
    Yes,
    OpenQuestion,
    NoData,
}
}

日期处理

Async-graphql支持chrono库中的日期/时间类型,因此你可以照常定义以下字段:

清单 32. 日期字段定义


#![allow(unused)]
fn main() {
#[derive(SimpleObject)]
struct Satellite {
    ...
    first_spacecraft_landing_date: Option<NaiveDate>,
}
}

支持ApolloFederation

satellites-service的目的之一是演示如何在两个(或多个)服务中解析分布式GraphQL实体Planet),然后通过Apollo Server对其进行访问。

Plant类型之前是通过planets-service定义的:

清单 33. planets-service里定义Planet类型


#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
struct Planet {
    id: ID,
    name: String,
    planet_type: PlanetType,
}
}

另外,在planets-service中,Planet类型是一个实体:

_清单 34. Planet实体定义


#![allow(unused)]
fn main() {
#[Object]
impl Query {
    #[graphql(entity)]
    async fn find_planet_by_id(&self, ctx: &Context<'_>, id: ID) -> Option<Planet> {
        find_planet_by_id_internal(ctx, id)
    }
}
}

satellites-servicePlanet对象扩展了satellites字段:

清单 35. satellites-servicePlant对象的扩展


#![allow(unused)]
fn main() {
struct Planet {
    id: ID
}

#[Object(extends)]
impl Planet {
    #[graphql(external)]
    async fn id(&self) -> &ID {
        &self.id
    }

    async fn satellites(&self, ctx: &Context<'_>) -> Vec<Satellite> {
        let id = self.id.to_string().parse::<i32>().expect("Can't get id from String");
        repository::get_by_planet_id(id, &get_conn_from_ctx(ctx)).expect("Can't get satellites of planet")
            .iter()
            .map(|e| { Satellite::from(e) })
            .collect()
    }
}
}

你还应该为扩展类型提供查找函数(此处只是创建了Planet的新实例):

清单 36. Planet对象的查找函数


#![allow(unused)]
fn main() {
#[Object]
impl Query {

    #[graphql(entity)]
    async fn get_planet_by_id(&self, id: ID) -> Planet {
        Planet { id }
    }
}
}

Async-graphql生成两个附加查询(_service_entities),这些查询将由Apollo Server使用。这些查询是内部查询,也就是说Apollo Server不会公开这些查询。当然,具有Apollo Federation支持的服务仍可以独立运行。

ApolloServer

Apollo Server和Apollo Federation可以实现两个主要目标:

  • 创建单个端点以访问由多个服务提供的GraphQL API

  • 从分布式服务创建单个GraphQL模式

也就是说即使你不使用联合实体,前端开发人员也可以使用单个端点而不是多个端点,使用起来更加的方便。

还有一种创建单个GraphQL模式的方法,即模式缝合,但是我没有使用这种方法。

该模块包括以下代码:

清单 37. 元信息和依赖

{
  "name": "api-gateway",
  "main": "gateway.js",
  "scripts": {
    "start-gateway": "nodemon gateway.js"
  },
  "devDependencies": {
    "concurrently": "5.3.0",
    "nodemon": "2.0.6"
  },
  "dependencies": {
    "@apollo/gateway": "0.21.3",
    "apollo-server": "2.19.0",
    "graphql": "15.4.0"
  }
}

_清单 38. Apollo Server定义

const {ApolloServer} = require("apollo-server");
const {ApolloGateway, RemoteGraphQLDataSource} = require("@apollo/gateway");

class AuthenticatedDataSource extends RemoteGraphQLDataSource {
    willSendRequest({request, context}) {
        if (context.authHeaderValue) {
            request.http.headers.set('Authorization', context.authHeaderValue);
        }
    }
}

let node_env = process.env.NODE_ENV;

function get_service_url(service_name, port) {
    let host;
    switch (node_env) {
        case 'docker':
            host = service_name;
            break;
        case 'local': {
            host = 'localhost';
            break
        }
    }

    return "http://" + host + ":" + port;
}

const gateway = new ApolloGateway({
    serviceList: [
        {name: "planets-service", url: get_service_url("planets-service", 8001)},
        {name: "satellites-service", url: get_service_url("satellites-service", 8002)},
        {name: "auth-service", url: get_service_url("auth-service", 8003)},
    ],
    buildService({name, url}) {
        return new AuthenticatedDataSource({url});
    },
});

const server = new ApolloServer({
    gateway, subscriptions: false, context: ({req}) => ({
        authHeaderValue: req.headers.authorization
    })
});

server.listen({host: "0.0.0.0", port: 4000}).then(({url}) => {
    console.log(`🚀 Server ready at ${url}`);
});

如果以上代码可以简化,请随时与我联系以进行更改。

apollo-service中的授权工作如先前Rust服务所述(你只需指定Authorization标头及其值)即可。

如果采用Federation规范,则可以将用任何语言或框架编写的应用程序作为下游服务添加到Apollo Server。这个文档中提供了提供此类支持的库列表。

在实现此模块时,我遇到了一些限制:

数据库交互

持久层是使用PostgreSQL和Diesel实现的。如果你不在本地使用Docker,你应该在每个服务的文件夹中运行diesel setup。这将创建一个空数据库,然后将应用Migrations创建表和插入数据。

运行和API测试

如前面所述,对于在本地启动项目,你有两个选择。

  • 使用Docker Compose (docker-compose.yml)

    这里也有两个选择

    • 开发模式 (使用本地生成的镜像)

      docker-compose up

    • 生产模式 (使用已发布的镜像)

      docker-compose -f docker-compose.yml up

  • 不使用Docker

    cargo run启动每个服务,然后启动Apollo Server:

    • 进入 apollo-server 目录
    • 定义 NODE_ENV 环境变量, 例如set NODE_ENV=local(Windows)
    • npm install
    • npm run start-gateway

apollo-server成功运行应该输出以下信息:

清单 39. Apollo Server启动日志

[nodemon] 2.0.6
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,json
[nodemon] starting `node gateway.js`
Server ready at http://0.0.0.0:4000/

你可以在浏览器中打开http://localhost:4000,并使用内置的Playground IDE。

playground

在这里你可以执行下游服务中定义的查询、变更和订阅。另外,这些服务也都有自己的Playground IDE。

订阅测试

要测试订阅是否正常工作,可以在GraphQL IDE中打开两个Tab,第一个请求如下。

清单 40. 订阅请求

subscription {
  latestPlanet {
    name
    type
  }
}

第二个请求指定如上所述的Authorization标头,并执行这样的变更。

清单 41. 变更请求

mutation {
  createPlanet(
    planet: {
      name: "Pluto"
      type: DWARF_PLANET
      details: { meanRadius: "1188", mass: "1.303e22" }
    }
  ) {
    id
  }
}

订阅的客户端会收到Plant创建的通知。

subscription-demo

CI/CD

CI/CD是使用GitHub Actions(workflow)配置的,它可以运行应用程序的测试,构建它们的Docker镜像,并在Google Cloud Platform上部署它们。

你可以在这里试试已部署的服务。

注意:生产环境下,为了防止更改初始数据,密码与前面指定的不同。

结论

在这篇文章中,我考虑了如何解决在Rust中开发GraphQL API时可能出现的最常见问题。此外,我还展示了如何将使用Rust开发的GraphQL微服务API结合起来,以提供统一的GraphQL接口。在这样的架构中,一个实体可以分布在几个微服务之间,它是通过Apollo Server、Apollo Federation和Async-graphql库来实现的。项目的源代码在GitHub上。如果你发现文章或源代码中有任何错误,欢迎联系我。谢谢阅读!

有用的链接

图解 Rust 所有权与生命周期

作者:肖猛

后期编辑:高宪凤


作者简介:

肖猛

二十年从桌面到云端到嵌入式的软件架构经验,跨通讯、游戏、金融、智能网联汽车多个行业,领域系统分析专家、全栈软件架构专家。

目前致力于智能驾驶基础软件开发。历任吉利亿咖通自动驾驶软件平台总监,国汽智控自动驾驶软件研发总监。对在汽车领域推广 Rust 技术栈有浓厚兴趣,并有实际的量产实践。


1.引言

所有权生命周期是  Rust  语言非常核心的内容。其实不仅仅是 Rust 有这两个概念,在C/C++ 中也一样是存在的。而几乎所有的内存安全问题也源于对所有权和生命周期的错误使用。只要是不采用垃圾回收来管理内存的程序语言,都会有这个问题。只是 Rust 在语言级明确了这两个概念,并提供了相关的语言特性让用户可以显式控制所有权的转移与生命周期的声明。同时编译器会对各种错误使用进行检查,提高了程序的内存安全性。

所有权和生命周期其涉及的语言概念很多,本文主要是对梳理出与“所有权与生命周期”相关的概念,并使用  UML 的类图表达概念间的关系,帮助更好的理解和掌握。

图例说明

本文附图都是 UML 类图,UML 类图可以用来表示对概念的分析。表达概念之间的依赖、继承、聚合、组成等关系。图中的每一个矩形框都是一个语义概念,有的是抽象的语言概念,有的是 Rust 库中的结构和 Trait

所有图中使用的符号也只有最基础的几个。图 1 对符号体系做简单说明,主要解释一下表达概念之间的关系的符号语言。

图 1UML 符号

依赖关系:

依赖是 UML 中最基础的关系语义。 以带箭头的虚线表示,A 依赖与 B 表达如下图。直观理解可以是 A “看的见” B,而 B 可以对 A 一无所知。比如在代码中 结构体 A 中有 结构体 B 的成员变量,或者 A 的实现代码中有 B 的局部变量。这样如果找不到 BA 是无法编译通过的。

关联关系:

一条实线连接表示两个类型直接有关联,有箭头表示单向"可见",无箭头表示相互之间可见。关联关系也是一种依赖,但是更具体。有时候两个类型之间的关联关系太复杂,需要用一个类型来表达,叫做关联类型,如例图中的 H.

聚合与组成:

聚合与组成都是表示的是整体和部分的关系。差别在于“聚合”的整体与部分可以分开,部分可以在多个整体之间共享。而“组成”关系中整体对部分有更强的独占性,部分不能被拆开,部分与整体有相同的生命周期。

继承与接口实现:

继承与接口实现都是一种泛化关系,C 继承自 A,表示 A 是更泛化的概念。UML 中各种关系语义也可以用 UML 自身来表达,如图 2:“关联”和“继承”都是“依赖”的具体体现方式。

图 2用 UML表达UML自身

总图

图 3 是本文的总图,后续各节分局部介绍。

图 3Rust 所有权与生命周期总图

2.所有权与生命周期期望解决的问题

我们从图中间部分开始看起,所谓“所有权”是指对一个变量拥有了一块“内存区域”。这个内存区域,可以在堆上,可以在栈上,也可以在代码段,还有些内存地址是直接用于 I/O 地址映射的。这些都是内存区域可能存在的位置。

在高级语言中,这个内存位置要在程序中要能被访问,必然就会与一个或多个变量建立关联关系(低级语言如汇编语言,可以直接访问内存地址)。也就是说,通过这一个或多个变量,就能访问这个内存地址。

这就引出三个问题:

  1. 内存的不正确访问引发的内存安全问题
  2. 由于多个变量指向同一块内存区域导致的数据一致性问题
  3. 由于变量在多个线程中传递,导致的数据竞争的问题

由第一个问题引发的内存安全问题一般有 5 个典型情况:

  • 使用未初始化的内存
  • 对空指针解引用
  • 悬垂指针(使用已经被释放的内存)
  • 缓冲区溢出
  • 非法释放内存(释放未分配的指针或重复释放指针)
图 4变量绑定与内存安全的基本概念

这些问题在 C/C++ 中是需要开发者非常小心的自己处理。 比如我们可以写一段 C++ 代码,把这五个内存安全错误全部犯一遍。

#include <iostream>

struct Point {
	int x;
	int y;
};

Point* newPoint(int x,int y) {
	Point p { .x=x,.y=y };
	return &p; //悬垂指针
}

int main() {
	int values[3]= { 1,2,3 };
	std::cout<<values[0]<<","<<values[3]<<std::endl; //缓冲区溢出

	Point *p1 = (Point*)malloc(sizeof(Point));
	std::cout<<p1->x<<","<<p1->y<<std::endl; //使用未初始化内存

	Point *p2 = newPoint(10,10); //悬垂指针
	delete p2; //非法释放内存

	p1 = NULL;
	std::cout<<p1->x<<std::endl; //对空指针解引用
	return 0;
}

这段代码是可以编译通过的,当然,编译器还是会给出警告信息。这段代码也是可以运行的,也会输出信息,直到执行到最后一个错误处“对空指针解引用时”才会发生段错误退出。

Rust 的语言特性为上述问题提供了解决方案,如下表所示:

问题解决方案
使用未初始化的内存
编译器禁止变量读取未赋值变量
对空指针解引用
使用 Option 枚举替代空指针
悬垂指针
生命周期标识与编译器检查
缓冲区溢出
编译器检查,拒绝超越缓冲区边界的数据访问
非法释放内存
语言级的 RAII 机制,只有唯一的所有者才有权释放内存
多个变量修改同一块内存区域
允许多个变量借用所有权,但是同一时间只允许一个可变借用
变量在多个线程中传递时的安全问题
对基本数据类型用 Sync 和 Send 两个 Trait 标识其线程安全特性,即能否转移所有权或传递可变借用,把这作为基本事实。再利用泛型限定语法和 Trait impl 语法描述出类型线程安全的规则。编译期间使用类似规则引擎的机制,基于基本事实和预定义规则为用户代码中的跨线程数据传递做推理检查。

3.变量绑定与所有权的赋予

Rust 中为什么叫“变量绑定”而不叫“变量赋值"。我们先来看一段 C++ 代码,以及对应的 Rust 代码。

C++:

#include <iostream>
 
int main()
{
	int a = 1;
	std::cout << &a << std::endl;   /* 输出 0x62fe1c */
	a = 2;
	std::cout << &a << std::endl;   /* 输出 0x62fe1c */
}

Rust:

fn main() {
	let a = 1;
	println!("a:{}",a);     // 输出1
	println!("&a:{:p}",&a); // 输出0x9cf974
	//a=2;                  // 编译错误,不可变绑定不能修改绑定的值
	let a = 2;              // 重新绑定
	println!("&a:{:p}",&a); // 输出0x9cfa14地址发生了变化
	let mut b = 1;          // 创建可变绑定
	println!("b:{}",b);     // 输出1
	println!("&b:{:p}",&b); // 输出0x9cfa6c
	b = 2;
	println!("b:{}",b);     // 输出2
	println!("&b:{:p}",&b); // 输出0x9cfa6c地址没有变化
	let b = 2;              // 重新绑定新值
	println!("&b:{:p}",&b); // 输出0x9cfba4地址发生了变化
}

我们可以看到,在 C++ 代码中,变量 a 先赋值为 1,后赋值为 2,但其地址没有发生变化。Rust 代码中,a 是一个不可变绑定,执行a=2动作被编译器拒绝。但是可以使用 let 重新绑定,但这时 a 的地址跟之前发生了变化,说明 a 被绑定到了另一个内存地址。b 是一个可变绑定,可以使用b = 2重新给它指向的内存赋值,b 的地址不变。但使用 let 重新绑定后,b 指向了新的内存区域。

可以看出,"赋值" 是将值写入变量关联的内存区域,"绑定" 是建立变量与内存区域的关联关系,Rust 里,还会把这个内存区域的所有权赋予这个变量。

不可变绑定的含义是:将变量绑定到一个内存地址,并赋予所有权,通过该变量只能读取该地址的数据,不能修改该地址的数据。对应的,可变绑定就可以通过变量修改关联内存区域的数据。从语法上看,有 let 关键字是绑定, 没有就是赋值。

这里我们能看出 RustC++ 的一个不同之处。C++ 里是没有“绑定”概念的。Rust 的变量绑定概念是一个很关键的概念,它是所有权的起点。有了明确的绑定才有了所有权的归属,同时解绑定的时机也确定了资源释放的时机。

所有权规则:

  • 每一个值都有其所有者变量
  • 同一时间所有者变量只能有一个
  • 所有者离开作用域,值被丢弃(释放/析构)

作为所有者,它有如下权利:

  • 控制资源的释放
  • 出借所有权
  • 转移所有权

4.所有权的转移

所有者的重要权利之一就是“转移所有权”。这引申出三个问题:

  1. 为什么要转移?
  2. 什么时候转移?
  3. 什么方式转移?

相关的语言概念如下图。

图 5所有权转移

为什么要转移所有权? 我们知道,C/C++/Rust 的变量关联了某个内存区域,但变量总会在表达式中进行操作再赋值给另一个变量,或者在函数间传递。实际上期望被传递的是变量绑定的内存区域的内容,如果这块内存区域比较大,复制内存数据到给新的变量就是开销很大的操作。所以需要把所有权转移给新的变量,同时当前变量放弃所有权。所以归根结底,转移所有权还是为了性能。

所有权转移的时机总结下来有以下两种情况:

  1. 位置表达式出现在值上下文时转移所有权
  2. 变量跨作用域传递时转移所有权

第一条规则是一个精确的学术表达,涉及到位置表达式,值表达式,位置上下文,值上下文等语言概念。它的简单理解就是各种各样的赋值行为。能明确指向某一个内存区域位置的表达式是位置表达式,其它的都是值表达式。各种带有赋值语义的操作的左侧是位置上下文,右侧是值上下文。

当位置表达式出现在值上下文时,其程序语义就是要把这边位置表达式所指向的数据赋给新的变量,所有权发生转移。

第二条规则是“变量跨作用域时转移所有权”。

图上列举出了几种常见的跨作用域行为,能涵盖大多数情况,也有简单的示例代码

  • 变量被花括号内使用
  • match 匹配
  • if let 和 While let
  • 移动语义函数参数传递
  • 闭包捕获移动语义变量
  • 变量从函数内部返回

为什么变量跨作用域要转移所有权?在 C/C++ 代码中,是否转移所有权是程序员自己隐式或显式指定的。

试想,在 C/C++ 代码中,函数 Fun1 在栈上创建一个 类型 A 的实例 a, 把它的指针 &a 传递给函数 void fun2(A* param) 我们不会希望 fun2 释放这个内存,因为 fun1 返回时,栈上的空间会自动被释放。

如果 fun1 在堆上创建 A 的实例 a, 把它的指针 &a 传递给函数 fun2(A* param),那么关于 a 的内存空间的释放,fun1fun2 之间需要有个商量,由谁来释放。fun1 可能期望由 fun2 来释放,如果由 fun2 释放,则 fun2 并不能判断这个指针是在堆上还是栈上。归根结底,还是谁拥有 a 指向内存区的所有权问题。 C/C++ 在语言层面上并没有强制约束。fun2 函数设计的时候,需要对其被调用的上下文做假定,在文档中对对谁释放这个变量的内存做约定。这样编译器实际上很难对错误的使用方式给出警告。

Rust 要求变量在跨越作用域时明确转移所有权,编译器可以很清楚作用域边界内外哪个变量拥有所有权,能对变量的非法使用作出明确无误的检查,增加的代码的安全性。

所有权转移的方式有两种:

  • 移动语义-执行所有权转移
  • 复制语义-不执行转移,只按位复制变量

这里我把 ”复制语义“定义为所有权转移的方式之一,也就是说“不转移”也是一种转移方式。看起来很奇怪。实际上逻辑是一致的,因为触发复制执行的时机跟触发转移的时机是一致的。只是这个数据类型被打上了 Copy 标签 trait, 在应该执行转移动作的时候,编译器改为执行按位复制。

Rust 的标准库中为所有基础类型实现的 Copy Trait

这里要注意,标准库中的

 impl<T: ?Sized> Copy for &T {}

为所有引用类型实现了 Copy, 这意味着我们使用引用参数调用某个函数时,引用变量本身是按位复制的。标准库没有为可变借用 &mut T 实现“Copy” Trait , 因为可变借用只能有一个。后文讲闭包捕获变量的所有权时我们可以看到例子。

5.所有权的借用

变量拥有一个内存区域所有权,其所有者权利之一就是“出借所有权”。

与出借所有权相关的概念关系如图 6

图 6出借所有权

拥有所有权的变量借出其所有权有“引用”和“智能指针”两种方式:

  • 引用(包含可变借用和不可变借用)

  • 智能指针

    • 独占式智能指针 Box<T>
    • 非线程安全的引用计数智能指针 Rc<T>
    • 线程安全的引用计数智能指针 Arc<T>
    • 弱指针 Weak<T>

引用实际上也是指针,指向的是实际的内存位置。

借用有两个重要的安全规则:

  1. 代表借用的变量,其生命周期不能比被借用的变量(所有者)的生命周期长
  2. 同一个变量的可变借用只能有一个

第一条规则就是确保不出现“悬垂指针”的内存安全问题。如果这条规则被违反,例如:变量 a 拥有存储区域的所有权,变量 ba 的某种借用形式,如果 b 的生命周期比 a 长,那么 a 被析构后存储空间被释放,而 b 仍然可以使用,则 b 就成为了悬垂指针。

第二条是不允许有两个可变借用,避免出现数据一致性问题。

Struct Foo{v:i32}
fn main(){
    let mut f = Foo{v:10};
    let im_ref = &f;        // 获取不可变引用
    let mut_ref = & mut f;  // 获取可变引用
    //println!("{}",f.v);
    //println!("{}",im_ref.v);
    //println!("{}",mut_ref.v);
}

变量 f 拥有值的所有权,im_ref 是其不可变借用,mut_ref 是其可变借用。以上代码是可以编译过去的,但是这几个变量都没有被使用,这种情况下编译器并不禁止你同时拥有可变借用和不可变借用。最后的三行被注释掉的代码(6,7,8)使用了这些变量。打开一行或多行这些注释的代码,编译器会报告不同形式的错误:

开放注释行编译器报告
6正确
7第 5 行错误:不能获得 f 的可变借用,因为已经存在不可变借用
8正确
6, 7第 5 行错误:不能获得 f 的可变借用,因为已经存在不可变借用
6,8第 6 行错误:不能获得 f 的不可变借用,因为已经存在可变借用

对"借用" 的抽象表达

Rust 的核心包中有两个泛型 traitcore::borrow::Borrowcore::borrow::BorrowMut,可以用来表达"借用"的抽象含义,分别代表可变借用和不可变借用。 前面提到,“借用”有多种表达形式 (&T,Box<T>,Rc<T> 等等),在不同的使用场景中会选择合适的借用表达方式。它们的抽象形式就可以用 core::borrow::Borrow 来代表. 从类型关系上, Borrow 是"借用" 概念的抽象形式。从实际应用上,某些场合我们希望获得某个类型的“借用”,同时希望能支持所有可能的“借用”形式,Borrow Trait 就有用武之地。

Borrow 的定义如下:

pub trait Borrow<Borrowed: ?Sized> {
    fn borrow(&self) -> &Borrowed;
}

它只有一个方法,要求返回指定类型的引用。

Borrow 的文档中有提供例子

use std::borrow::Borrow;

fn check<T: Borrow<str>>(s: T) {
    assert_eq!("Hello", s.borrow());
}

fn main(){
    let s: String = "Hello".to_string(); 
    check(s);

    lets: &str = "Hello"; 
    check(s);
}

check 函数的参数表示它希望接收一个 “str”类型的任何形式的“借用”,然后取出其中的值与 “Hello”进行比较。

标准库中为 String 类型实现了 Borrow<str>,代码如下

impl Borrow<str> for String{
    #[inline]
    fn borrow(&self) -> &str{
        &self[..]
    }
}

所以 String 类型可以作为 check 函数的参数。

从图上可以看出,标准库为所有类型 T 实现了 Borrow Trait, 也为 &T 实现了 Borrow Trait

代码如下 ,这如何理解。

impl<T: ?Sized> Borrow<T> for T {
    fn borrow(&self) -> &T { // 是 fn borrow(self: &Self)的缩写,所以 self 的类型就是 &T
        self
    }
}

impl<T: ?Sized> Borrow<T> for &T {
    fn borrow(&self) -> &T {
        &**self
    }
}

这正是 Rust 语言很有意思的地方,非常巧妙的体现了语言的一致性。既然 Borrow<T> 的方法是为了能获取 T 的引用,那么类型 T&T 当然也可以做到这一点。在 Borrow for T 的实现中,

fn borrow(&self)->&Tfn borrow(self: &Self)->&T 的缩写,所以 self 的类型就是 &T,可以直接被返回。在 Borrow for &T 的实现中,fn borrow(&self)->&Tfn borrow(self: &Self)->&T 的缩写,所以 self 的类型就是 &&T, 需要被两次解引用得到 T, 再返回其引用。

智能指针 Box<T>,Rc<T>,Arc<T>,都实现了 Borrow<T> ,其获取 &T 实例的方式都是两次解引用在取引用。Weak<T> 没有实现 Borrow<T>, 它需要升级成 Rc<T> 才能获取数据。

6.生命周期参数

变量的生命周期主要跟变量的作用域有关,在大部分程序语言中都是隐式定义的。Rust 中能显式声明变量的生命周期参数,这是非常独特的设计,其语法特性在其他语言也是不太可能见到的。以下是生命周期概念相关的图示。

图 7生命周期

生命周期参数的作用

生命周期参数的核心作用就是解决悬垂指针问题。就是让编译器帮助检查变量的生命周期,防止出现变量指向的内存区域被释放后,变量仍然可以使用的问题。那么什么情况下会让编译器无法判断生命周期,而必须引入一个特定语法来对生命周期进行标识?

我们来看看最常见的悬垂指针问题,函数以引用方式返回函数内部的局部变量:

struct V{v:i32}
 
fn bad_fn() -> &V{  //编译错误:期望一个命名的生命周期参数
    let a = V{v:10};
    &a
}
let  res = bad_fn();

这个代码是一个典型的悬垂指针错误,a 是函数内的局部变量,函数返回后 a 就被销毁,把 a 的引用赋值给 res ,如果能执行成功,res 绑定的就是未定义的值。

但编译器并不是报告悬垂指针错误,而是说返回类型 &V 没有指定生命周期参数。C++ 的类似代码编译器会给出悬垂指针的警告(警告内容:局部变量的地址被返回了)。

那我们指定一个生命周期参数看看:

fn bad_fn<'a>() -> &'a V{
    let a = V{v:10};
    let ref_a = &a;
    ref_a   //编译错误:不能返回局部变量的引用
}

这次编译器报告的是悬垂指针错误了。那么编译器的分析逻辑是什么?

首先我们明确一下 'a 在这里的精确语义到底是什么?

函数将要返回的引用会代表一个内存数据,这个数据有其生命周期范围,'a 参数是对这个生命周期范围提出的要求。就像 &V 是对返回值类型提的要求类似,'a 是对返回值生命周期提的要求。编译器需要检查的就是实际返回的数据,其生命是否符合要求。

那么 'a 参数对返回值的生命周期到底提出了什么要求?

我们先区分一下"函数上下文"和“调用者上下文”,函数上下文是指函数体内部的作用域范围,调用者上下文是指该函数被调用的位置。上述的悬垂指针错误其实并不会影响函数上下文范围的程序执行,出问题的地方是调用者上下文拿到一个无效引用并使用时,会出现不可预测的错误。

函数返回的引用会在“调用者上下文”中赋予某个变量,如:

let res = bod_fn();

res 获得了返回的引用, 函数内的 ref_a 引用会按位复制给变量 res (标准库中 impl<T: ?Sized> Copy for &T {} 指定了此规则)res 会指向 函数内 res_a 同样的数据。为了保证将来在调用者上下文不出悬垂指针,编译器真正要确保的是 res 所指向的数据的生命周期,不短于 res 变量自己的生命周期。否则如果数据的生命周期短,先被释放,res 就成为悬垂指针。

可以把这里的 'a 参数理解为调用者上下文中接收函数返回值的变量 res 的生命周期,那么 'a 对函数体内部返回引用的要求是:返回引用所指代数据的生命周期不短于 'a ,也就是不短于调用者上下文接收返回值的变量的生命周期。

上述例子中函数内 ref_a 指代的数据生命周期就是函数作用域,函数返回前,数据被销毁,生命周期小于调用者上下文的 res, 编译器根据 返回值的生命周期要求与实际返回值做比较,发现了错误。

实际上,返回的引用或者是静态生命周期,或者是根据函数输入的引用参数通过运算变换得来的,否则都是这个结果,因为都是对局部数据的引用。

静态生命周期

看函数

fn get_str<'a>() -> &'a str {
    let s = "hello";
    s
}

这个函数可以编译通过,返回的引用虽然不是从输入参数推导,不过是静态生命周期,可以通过检查。

因为静态生命周期可以理解为“无穷大”的语义,实际是跟进程的生命周期一致,也就是在程序运行期间始终有效。

Rust 的字符串字面量是存储在程序代码中,程序加载后在代码空间,始终有效。可以通过一个简单试验验证这一点:

let s1="Hello";
println!("&s1:{:p}", &s1);//&s1:0x9cf918

let s2="Hello";
println!("&s2:{:p}",&s2);//&s2:0x9cf978
//s1,s2是一样的值但是地址不一样,是两个不同的引用变量

let ptr1: *const u8 = s1.as_ptr();
println!("ptr1:{:p}", ptr1);//ptr1:0x4ca0a0

let ptr2: *const u8 = s2.as_ptr();
println!("ptr2:{:p}", ptr2);//ptr2:0x4ca0a0

s1,s2 的原始指针都指向同一个地址,说明编译器为 "Hello" 字面量只保存了一份拷贝,所有引用都指向它。

get_str 函数中静态生命周期长于返回值要求的'a,所以是合法的。

如果把 get_str 改成

fn get_str<'a>() -> &'static str

即把对返回值生命周期的要求改为无穷大,那就只能返回静态字符串引用了。

函数参数的生命周期

前面的例子为了简单起见,没有输入参数,这并不是一个典型的情况。大多数情况下,函数返回的引用是根据输入的引用参数通过运算变换而来。比如下面的例子:

fn  remove_prefix<'a>(content:&'a str,prefix:&str) -> &'a str{
    if content.starts_with(prefix){
        let start:usize = prefix.len();
        let end:usize = content.len();
        let sub = content.get(start..end).unwrap();
        sub
    }else{
        content
    }
}
let  s = "reload";
let sub = remove_prefix(&s0,"re");
println!("{}",sub); // 输出: load

remove_prefix 函数从输入的 content 字符串中判断是否有 prefix 代表的前缀。 如果有就返回 content 不包含前缀的切片,没有就返回 content 本身。

无论如何这个函数都不会返回前缀 prefix ,所以 prefix 变量不需要指定生命周期。

函数两个分支返回的都是通过 content 变量变换出来的,并作为函数的返回值。所以 content 必须标注生命周期参数,编译器要根据 content 的生命周期参数与返回值的要求进行比较,判断是否符合要求。即:实际返回数据的生命周期,大于或等于返回参数要求的生命周期。

前面说到,我们把返回参数中指定的生命周期参数 'a 看做调用者上下文中接收返回值的变量的生命周期,在这个例子中就是字符串引用 sub那么输入参数中的 'a 代表什么意思 ?

这在 Rust 语法设计上是一个很让人困惑的地方,输入参数和输出参数的生命周期都标志为 'a ,似乎是要求两者的生命周期要求一致,但实际上并不是这样。

我们先看看如果输入参数的生命周期跟输出参数期待的不一样是什么情况,例如下面两个例子:

fn echo<'a, 'b>(content: &'b str) -> &'a str {
    content //编译错误:引用变量本身的生命周期超过了它的借用目标
}
fn longer<'a, 'b>(s1: &'a str, s2: &'b str) -> &'a str {
    if s1.len() > s2.len()
        { s1 }
    else
        { s2 }//编译错误:生命周期不匹配
}

echo 函数输入参数生命周期标注为 'b , 返回值期待的是 'a .编译器报错信息是典型的“悬垂指针”错误。不过内容似乎并不明确。编译器指出查阅详细信息 --explain E0312 ,这里的解释是"借用内容的生命周期与期待的不一致"。这个错误描述就与实际的错误情况是相符合的了。

longer 函数两个参数分别具有生命周期 'a'b , 返回值期待 'a ,当返回 s2 时,编译器报告生命周期不匹配。把 longer 函数中的生命周期 'b 标识为比 'a 长,就可以正确编译了。

fn longer<'a, 'b: 'a>(s1: &'a str, s2: &'b str) -> &'a str {
    if s1.len() > s2.len()
        { s1 }
    else
        { s2 }//编译通过
}

回到我们前面的问题,那么输入参数中的 'a 代表什么意思 ?

我们知道编译器在函数定义上下文中所做的生命周期检查就是要确保”实际返回数据的生命周期,大于或等于返参数要求的生命周期“。当输入参数给出与返回值一样的生命周期参数 'a 时,实际上是人为地向编译器保证:在调用者上下文中,实际给出的函数输入参数的生命周期,不小于将来用于接收返回值的变量的生命周期。

当有两个生命周期参数 'a 'b , 而 'b 大于 'a,当然 也保证了在调用者上下文 'b 代表的输入参数生命周期也足够长。

在函数定义中,编译器并不知道将来实际调用这个函数的上下文是怎么样的。生命周期参数相当是函数上下文与调用者上下文之间关于参数生命周期的协议。

就像函数签名中的类型声明一样,类型声明约定了与调用者之间输入输出参数的类型,编译器编译函数时,会检查函数体返回的数据类型与声明的返回值是否一致。同样对与参数与返回值的生命周期,函数也会检查函数体中返回的变量生命周期与声明的是否一致。

前面说的是编译器在“函数定义上下文的生命周期检查”机制,这只是生命周期检查的一部分,还有另一部分就是“调用者上下文对生命周期的检查”机制。两者检查的规则如下:

函数定义上下文的生命周期检查:

函数签名中返回值的生命周期标注可以是输入标注的任何一个,只要保证由输入参数推导出来的返回的临时变量的生命周期,比函数签名中返回值标注的生命周期相等或更长。这样保证了调用者上下文中,接收返回值的变量,不会因为输入参数失效而成为悬垂指针。

调用者上下文对生命周期的检查:

调用者上下文中,接收函数返回借用的变量 res ,其生命周期不能长于返回的借用的生命周期(实际是根据输入借用参数推导出来的)。否则 res 会在输入参数失效后成为悬垂指针。

前面 remove_prefix 函数编译器已经校验合格,那么我们在调用者上下文中构建如下例子

let res: &str;
{
    let s = String::from("reload");
    res = remove_prefix(&s, "re") //编译错误:s 的生命周期不够长
}
println!("{}", res);

这个例子中 remove_prefix 被调用这一行,编译器会报错 “s 的生命周期不够长”。代码中的 大括号创建了一个新的词法作用域,导致 res 的生命周期比大括号内部的 s 更长。这不符合函数签名中对生命周期的要求。函数签名要求输入参数的生命周期不短于返回值要求的生命周期。

结构体定义中的生命周期

结构体中有引用成员时,就会有潜在的悬垂指针问题,需要标识生命周期参数来让编译器帮助检查。

struct G<'a>{ m:&'a str}
 
fn get_g() -> () {
    let g: G;
    {
        let  s0 = "Hi".to_string();
        let  s1 = s0.as_str();              //编译错误:借用值存活时间不够长
        g = G{ m: s1 };
    }
    println!("{}", g.m);
}

上面的例子中,结构体 G 包含了引用成员,不指定生命周期参数是无法编译的。函数 get_g 演示了在使用者上下文中如何出现生命周期不匹配的情况。

结构体的生命周期定义就是要保证在一个结构体实例中,其引用成员的生命周期不短于结构体实例自身的生命周期。否则如果结构体实例存活期间,其引用成员的数据先被销毁,那么访问这个引用成员时就构成了对悬垂指针的访问。

实际上结构体的生命周期参数可以和函数生命周期参数做类比,成员的生命周期相当函数的输入参数的生命周期,结构体整体的生命周期相当函数返回值的生命周期。这样所有之前对函数生命周期参数的分析一样可以适用。

如果结构体有方法成员会返回引用参数,方法同样需要填写生命周期参数。返回的引用来源可以是方法的输入引用参数,也可以是结构体的引用成员。在做生命周期分析的时候,可以把“方法的输入引用参数”和“结构体的引用成员”都看做普通函数的输入参数,这样前面对普通函数参数和返回值的生命周期分析方法可以继续套用。

泛型的生命周期限定

前文说过生命周期参数跟类型限定很像,比如在代码

fn longer<'a>(s1:&'a str, s2:&'a str) -> &'a str
 
struct G<'a>{ m:&'a str }

中,'a 出现的位置参数类型旁边,一个对参数的静态类型做限定,一个对参数的动态时间做限定。'a 使用前需要先声明,声明的位置与模板参数的位置一样,在 <> 括号内,也是用来放泛型的类型参数的地方。

那么,把类型换成泛型可以吗,语义是什么?使用场景是什么?

我们看看代码例子:

use std::cmp::Ordering;

#[derive(Eq, PartialEq, PartialOrd, Ord)]
struct G<'a, T:Ord>{ m: &'a T }
 
#[derive(Eq, PartialEq, PartialOrd, Ord)]
struct Value{ v: i32 }
 
fn longer<'a, T:Ord>(s1: &'a T, s2: &'a T) -> &'a T {
    if s1 > s2 { s1 } else { s2 }
}
 
fn main(){
    let v0 = Value{ v:12 };
    let v1 = Value{ v:15 };
    let res_v = longer(&v0, &v1);
    println!("{}", res_v.v);//15
     
    let g0 = G{ m: &v0 };
    let g1 = G{ m: &v1 };
    let res_g = longer(&g0, &g1);//15
    println!("{}", res_g.m.v);
}

这个例子扩展了 longer 函数,可以对任何实现了 Ord trait 的类型进行操作。 Ord 是核心包中的一个用于实现比较操作的内置 trait. 这里不细说明。longer 函数跟前一个版本比较,只是把 str 类型换成了泛型参数 T, 并给 T 增加了类型限定 T:Ord.

结构体 G 也扩展成可以容纳泛型 T,但要求 T 实现了 Ord trait.

从代码及执行结果看,跟 把 T 当成普通类型一样,没有什么特别,生命周期参数依然是他原来的语义。

但实际上 "&'a T" 还隐含另一层语义:如果 T 内部含有引用成员,那么其中的引用成员的生命周期要求不短于 T 实例的生命周期。

老规矩,我们来构造一个反例。结构体 G 内部包含一个泛型的引用成员,我们将 G 用于 longer 函数,但是让 G 内部的引用成员生命周期短于 G。代码如下:

fn  main(){
    let v0 = Value{ v:12 };
    let v1_ref: &Value;      // 将 v1 的引用定义在下面大括号之外,有意延长变量的生命周期范围
    let res_g: &G<Value>;

    {
        let v1 = Value{ v:15 };
        v1_ref = &v1;      //编译错误:v1的生命周期不够长。
        let res_v = longer(&v0,v1_ref);
        println!("{}",res_v.v);
    }

    let g0 = G{ m:&v0 };
    let g1 = G{ m:v1_ref };   // 这时候 v1_ref 已经是悬垂指针
    res_g = longer(&g0, &g1);
    println!("{}", res_g.m.v);
}

变量 g1 自身的生命周期是满足 longer 函数要求的,但是其内部的引用成员,生命周期过短。

这个范例是在“调用者上下文”检查时触发的,对泛型参数的生命周期限定比较难设计出在“函数定义或结构体定义上下文”触发的范例。毕竟 T 只是类型指代,定义时还没有具体类型。

实际上要把在 “struct G<'a,T>{m:&'a T}中,T 的所有引用成员的生命周期不短于'a ”这个语义准确表达,应该写成:

struct G<'a,T:'a>{m:&'a T}

因为 T:'a 才是这个语义的明确表述。但是第一种表达方式也是足够的(我用反证法证明了这一点)。所以编译器也接受第一种比较简化的表达形式。

总而言之,泛型参数的生命周期限定是两层含义,一层是泛型类型当做一个普通类型时一样的含义,一层是对泛型内部引用成员的生命周期约束。

Trait 对象的生命周期

看如下代码

trait Foo{}
struct Bar{v:i32}
struct Qux<'a>{m:&'a  i32}
struct Baz<'a,T>{v:&'a T}
 
impl Foo for Bar{}
impl<'a> Foo for Qux<'a>{}
impl<'a,T> Foo for Baz<'a,T>{}

结构体 Bar,Qux,Baz 都实现了 trait Foo, 那么 &Foo 类型可以接受这三个结构体的任何一个的引用类型。

我们把 &Foo 称为 Trait 对象。

Trait 对象可以理解为类似其它面向对象语言中,指向接口或基类的指针或引用。其它OO语言指向基类的指针在运行时确定其实际类型。Rust 没有类继承,指向 trait 的指针或引用起到类似的效果,运行时被确定具体类型。所以编译期间不知道大小。

RustTrait 不能有非静态数据成员,所以 Trait 本身就不会出现引用成员的生命周期小于对象自身,所以 Trait 对象默认的生命周期是静态生命周期。我们看下面三个函数:

fn check0() -> &'static Foo { // 如果不指定 'static , 编译器会报错,要求指定生命周期命参数, 并建议 'static
    const b:Bar = Bar{v:0};
    &b
}
fn check1<'a>() -> &'a Foo { //如果不指定 'a , 编译器会报错
    const b:Bar = Bar{v:0};
    &b
}
fn check2(foo:&Foo) -> &Foo {//生命周期参数被省略,不要求静态生命周期
    foo
}
fn check3(foo:&'static Foo) -> &'static Foo {
    foo
}
fn main(){
    let bar= Bar{v:0};
    check2(&bar);                               //能编译通过,说明 chenk2 的输入输出参数都不是静态生命周期
    //check3(&bar);                          //编译错误:bar的生命周期不够长
    const bar_c:Bar =Bar{v:0};
    check3(&bar_c);                         // check3 只能接收静态参数
}

check0check1 说明将 Trait 对象的引用作为 函数参数返回时,跟返回其他引用类型一样,都需要指定生命周期参数。函数 check2 的生命周期参数只是被省略了(编译器可以推断),但这个函数里的 Trait 对象并不是静态生命周期,这可以从 main 函数内能成功执行 check2(bar) 分析出来,因为 bar 不是静态生命周期.

实际上在运行时,Trait 对象总会动态绑定到一个实现了该 Trait 的具体结构体类型(如 Bar,Qux,Baz 等),这个具体类型的在其上下文中有它的生命周期,可以是静态的,更多情况下是非静态生命周期 'a ,那么 Trait 对象的生命周期也是 'a.

结构体或成员生命周期Trait 对象生命周期
Foo'static
Bar'a'a
Qux<'a>{m:&'a str}'a'a
Baz<'a,T>{v:&'a T}'a'a
fn qux_update<'a>(qux: &'a mut Qux<'a>, new_value: &'a i32)->&'a Foo {
    qux.v = new_value;
    qux
}

let value = 100;
let mut qux = Qux{v: &value};
let new_value = 101;
let muted: &dyn Foo = qux_update(& mut qux, &new_value);
qux_update 函数的智能指针版本如下:
 
fn qux_box<'a>(new_value: &'a  i32) -> Box<Foo +'a> {
    Box::new(Qux{v:new_value})
}
 
let new_value = 101;
let boxed_qux:Box<dyn Foo> = qux_box(&new_value);

返回的智能指针中,Box 装箱的类型包含了引用成员,也需要给被装箱的数据指定生命周期,语法形式是在被装箱的类型位置增加生命周期参数,用 "+" 号连接。

这两个版本的代码其实都说明一个问题,就是 Trait 虽然默认是静态生命周期,但实际上,其生命周期是由具体实现这个 Trait 的结构体的生命周期决定,推断方式跟之前叙述的函数参数生命周期并无太大区别。

7.智能指针的所有权与生命周期

如图 6,在 Rust 中引用和智能指针都算是“指针”的一种形态,所以他们都可以实现 std::borrow::Borrow Trait。一般情况下,我们对栈中的变量获取引用,栈中的变量存续时间一般比较短,当前的作用域退出时,作用域范围内的栈变量就会被回收。如果我们希望变量的生命周期能跨越当前的作用域,甚至在线程之间传递,最好是把变量绑定的数据区域创建在堆上。

栈上的变量其作用域在编译期间就是明确的,所以编译器能够确定栈上的变量何时会被释放,结合生命周期参数生命,编译器能找到绝大部分对栈上变量的错误引用。

堆上变量其的内存管理比栈变量要复杂很多。在堆上分配一块内存之后,编译器无法根据作用域来判断这块内存的存活时间,必须由使用者显式指定。C 语言中就是对于每一块通过 malloc 分配到的内存,需要显式的使用 free 进行释放。C++中是 new / delete。但是什么时候调用 freedelete 就是一个难题。尤其当代码复杂,分配内存的代码和释放内存的代码不在同一个代码文件,甚至不在同一个线程的时候,仅仅靠人工跟踪代码的逻辑关系来维护分配与释放就难免出错。

智能指针的核心思想是让系统自动帮我们决定回收内存的时机。其主要手段就是“将内存分配在堆上,但指向该内存的指针变量本身是在栈上,这样编译器就可以捕捉指针变量离开作用域的时机。在这时决定内存回收动作,如果该指针变量拥有内存区的所有权就释放内存,如果是一个引用计数指针就减少计数值,计数为 0 就回收内存”。

RustBox<T> 为独占所有权指针,Rc<T>为引用计数指针,但其计数过程不是线程安全的,Arc<T>提供了线程安全的引用计数动作,可以跨线程使用。

我们看 Box<T> 的定义

pub struct Box<T: ?Sized>(Unique<T>);
pub struct Unique<T: ?Sized>{
    pointer: *const T,
    _marker: PhantomData<T>,
} 

Box 本身是一个元组结构体,包装了一个 Unique<T>Unique<T>内部有一个原生指针。

(注:Rust 最新版本的 Box 实现还可以通过泛型参数指定内存分配器,让用户可以自己控制实际内存的分配。还有为什么通过 Unique多层封装,这涉及智能指针实现的具体问题,这里不详述。)

Box 没有实现 Copy Trait,它在所有权转移时会执行移动语意。

示例代码:

Struct Foo {v:i32}
fn inc(v:& mut Foo) -> &Foo {//省略了生命周期参数
    v.v = v.v + 1;
    v
}
//返回Box指针不需要生命周期参数,因为Box指针拥有了所有权,不会成为悬垂指针
fn inc_ptr(mut foo_ptr:Box<Foo>) -> Box<Foo> {//输入参数和返回参数各经历一次所有权转移
    foo_ptr.v = foo_ptr.v + 1;
    println!("ininc_ptr:{:p}-{:p}", &foo_ptr, &*foo_ptr);
    foo_ptr
}
fn main() {
    let foo_ptr1 = Box::new(Foo{v:10});
    println!("foo_ptr1:{:p}-{:p}", &foo_ptr1, &*foo_ptr1);
    let mut foo_ptr2 = inc_ptr(foo_ptr1);
    //println!("{}",foo_ptr1.v);//编译错误,f0_ptr所有权已经丢失
    println!("foo_ptr2:{:p}-{:p}", &foo_ptr2, &*foo_ptr2);
     
    inc(foo_ptr2.borrow_mut());//获得指针内数据的引用,调用引用版本的inc函数
    println!("{}",foo_ptr2.v);
}

inc 为引用版本,inc_ptr 是指针版本。改代码的输出为:

foo_ptr1:0x8dfad0-0x93a5e0
in inc_ptr:0x8df960-0x93a5e0
foo_ptr2:0x8dfb60-0x93a5e0
12

可以看到 foo_ptr1 进入函数 inc_ptr 时,执行了一次所有权转移,函数返回时又执行了一次。所以三个 Box<Foo> 的变量地址都不一样,但是它们内部的数据地址都是一样的,指向同一个内存区。

Box 类型自身是没有引用成员的,但是如果 T 包含引用成员,那么其相关的生命周期问题会是怎样的?

我们把 Foo 的成员改成引用成员试试,代码如下:

use std::borrow::BorrowMut;
struct Foo<'a>{v:&'a mut i32}
    fn inc<'a>(foo:&'a mut Foo<'a>) ->&'a Foo<'a> {//生命周期不能省略
    *foo.v=*foo.v + 1; // 解引用后执行加法操作
    foo
}
fn inc_ptr(mut foo_ptr:Box<Foo>) -> Box<Foo> {//输入参数和返回参数各经历一次所有权转移
    *foo_ptr.v = *foo_ptr.v + 1; / 解引用后执行加法操作
    println!("ininc_ptr:{:p}-{:p}", &foo_ptr, &*foo_ptr);
    foo_ptr
}
fn main(){
    let mut value = 10;
    let foo_ptr1 = Box::new(Foo{v:& mut value});
    println!("foo_ptr1:{:p}-{:p}", &foo_ptr1, &*foo_ptr1);
    let mut foo_ptr2 = inc_ptr(foo_ptr1);
    //println!("{}",foo_ptr1.v);//编译错误,f0_ptr所有权已经丢失
    println!("foo_ptr2:{:p}-{:p}", &foo_ptr2, &*foo_ptr2);
     
    let foo_ref = inc(foo_ptr2.borrow_mut());//获得指针内数据的引用,调用引用版本的inc函数
    //println!("{}",foo_ptr2.v);//编译错误,无法获取foo_ptr2.v的不可变借用,因为已经存在可变借用
    println!("{}", foo_ref.v);
}

引用版本的 inc 函数生命周期不能再省略了。因为返回 Foo 的引用时,有两个生命周期值,一个是Foo 实例的生命周期,一个是 Foo 中引用成员的生命周期,编译器无法做推断,需要指定。但是智能指针版本 inc_ptr 函数的生命周期依然不用指定。Foo 的实例被智能指针包装,生命周期由 Box 负责管理。

如果 Foo 是一个 Trait ,而实现它的结构体有引用成员,那么 Box<Foo> 的生命周期会有什么情况。示例代码如下:

trait Foo{
    fn inc(&mut self);
    fn value(&self)->i32;
}

struct Bar<'a>{v:&'a mut i32}

impl<'a> Foo for Bar<'a> {
    fn inc(&mut self){
        *(self.v)=*(self.v)+1
    }
    fn value(&self)->i32{
        *self.v
    }
}

fn inc(foo:& mut dyn Foo)->& dyn Foo {//生命周期参数被省略
    foo.inc();
    foo
}

fn inc_ptr(mut foo_ptr:Box<dyn Foo>) -> Box< dyn Foo> {//输入参数和返回参数各经历一次所有权转移
    foo_ptr.inc();
    foo_ptr
}

fn main() {
}

引用版本和智能指针版本都没生命周期参数,可以编译通过。不过 main 函数里是空的,也就是没有使用这些函数,只是定义编译通过了。我先试试使用引用版本:

fn main(){
    let mut value = 10;
    let mut foo1= Bar{v:& mut value};
    let foo2 =inc(&mut foo1);
    println!("{}", foo2.value());  // 输出 11
}

可以编译通过并正常输出。再试智能指针版本:

fn main(){
    let mut value = 10;
    let foo_ptr1 = Box::new(Bar{v:&mut value});   //编译错误:value生命周期太短
    let mut foo_ptr2 = inc_ptr(foo_ptr1); //编译器提示:类型转换需要value为静态生命周期
}

编译失败。提示的错误信息是 value 的生命周期太短,需要为 'static 。因为 Trait 对象( Box< dyn Foo>)默认是静态生命周期,编译器推断出返回数据的生命周期太短。去掉最后一行 inc_ptr 是可以正常编译的。

如果将 inc_ptr 的定义加上生命周期参数上述代码就可以编译通过。修改后的 inc_ptr 如下:

fn inc_ptr<'a>(mut foo_ptr:Box<dyn Foo+'a>) -> Box<dyn Foo+'a> {
    foo_ptr.inc();
    foo_ptr
}

为什么指针版本不加生命周期参数会出错,而引用版没有生命周期参数却没有问题?

因为引用版是省略了生命周期参数,完整写法是:

fn inc<'a>(foo:&'a mut dyn Foo)->&'a dyn Foo {
    foo.inc();
    foo
}

8. 闭包与所有权

这里不介绍闭包的使用,只说与所有权相关的内容。闭包与普通函数相比,除了输入参数,还可以捕获上线文中的变量。闭包还支持一个 move 关键字,来强制转移捕获变量的所有权。

我们先来看 move 对输入参数有没有影响:

//结构 Value 没有实现Copy Trait
struct Value{x:i32}
 
//没有作为引用传递参数,所有权被转移
let mut v = Value{x:0};
let fun = |p:Value| println!("in closure:{}", p.x);
fun(v);
//println!("callafterclosure:{}",point.x);//编译错误:所有权已经丢失
 
//作为闭包的可变借用入参,闭包定义没有move,所有权没有转移
let mut v = Value{x:0};
let fun = |p:&mut Value| println!("in closure:{}", p.x);
fun(& mut v);
println!("call after closure:{}", v.x);
 
//可变借用作为闭包的输入参数,闭包定义增加move,所有权没有转移
let mut v = Value{x:0};
let fun = move |p:& mut Value| println!("in closure:{}", p.x);
fun(& mut v);
println!("call after closure:{}", v.x);

可以看出,变量作为输入参数传递给闭包时,所有权转移规则跟普通函数是一样的,move 关键字对闭包输入参数的引用形式不起作用,输入参数的所有权没有转移。

对于闭包捕获的上下文变量,所有权是否转移就稍微复杂一些。

下表列出了 10 多个例子,每个例子跟它前后的例子都略有不同,分析这些差别,我们能得到更清晰的结论。

首先要明确被捕获的变量是哪个,这很重要。比如例 8 中,ref_vv 的不可变借用,闭包捕获的是 ref_v ,那么所有权转移的事情跟 v 没有关系,v 不会发生与闭包相关的所有权转移事件。

明确了被捕获的变量后,是否转移所有权受三个因素联合影响:

  1. 变量被捕获的方式(值,不可变借用,可变借用)
  2. 闭包是否有 move 限定
  3. 被捕获变量的类型是否实现了 "Copy" Trait

是用伪代码描述是否转移所有权的规则如下:

if 捕获方式 == 值传递 {
    if 被捕获变量的类型实现了 "Copy"
        不转移所有权 // 例 :9
    else
        转移所有权 // 例 :1
    }
}
else { // 捕获方式是借用
    if 闭包没有 move 限定
        不转移所有权 // 例:2,3,6,10,12
    else { // 有 move
        if 被捕获变量的类型实现了 "Copy"
            不转移所有权 // 例: 8
        else
            转移所有权 // 例: 4,5,7,11,13,14
    }
}

先判断捕获方式,如果是值传递,相当于变量跨域了作用域,触发转移所有权的时机。move 是对借用捕获起作用,要求对借用捕获也触发所有权转移。是否实现 "Copy" 是最后一步判断。 前文提到,我们可以把 Copy Trait 限定的位拷贝语义当成一种转移执行的方式。Copy Trait 不参与转移时机的判定,只在最后转移执行的时候起作用。

  • 例 1 和(例 2、例 3) 的区别在于捕获方式不同。
  • (例 2、例 3) 和例 4 的区别在于 move 关键字。
  • 例 6 和例 7 的区别 演示了 move 关键字对借用方式捕获的影响。
  • 例 8 说明了捕获不可变借用变量,无论如何都不会转移,因为不可变借用实现了 Copy.
  • 例 8 和例 11 的区别就在于例 11 捕获的 "不可变借用"没有实现 "Copy" Trait 。
  • 例 10 和例 11 是以“不可变借用的方式”捕获了一个“可变借用变量”
  • 例 12,13,14 演示了对智能指针的效果,判断逻辑也是一致的。

C++11 的闭包需要在闭包声明中显式指定是按值还是按引用捕获,Rust 不一样。Rust 闭包如何捕获上下文变量,不取决与闭包的声明,取决于闭包内部如何使用被捕获的变量。实际上编译器会尽可能以借用的方式去捕获变量(例,除非实在不行,如例 1.)

这里刻意没有提及闭包背后的实现机制,即 Fn,FnMut,FnOnce 三个 Trait。因为我们只用闭包语法时是看不到编译器对闭包的具体实现的。所以我们仅从闭包语法本身去判断所有权转移的规则。

9.多线程环境下的所有权问题

我们把前面的例 1 再改一下,上下文与闭包的实现都没有变化,但是闭包在另一个线程中执行。

let v = Value{x:1};
let child = thread::spawn(||{  // 编译器报错,要求添加 move 关键字
    let p = v;
    println!("inclosure:{}",p.x)
});
child.join();

这时,编译器报错,要求给闭包增加 move 关键字。也就是说,闭包作为线程的入口函数时,强制要求对被捕获的上下文变量执行移动语义。下面我们看看多线程环境下的所有权系统。

前面的讨论都不涉及变量在跨线程间的共享,一旦多个线程可以访问同一个变量时,情况又复杂了一些。这里有两个问题,一个仍然是内存安全问题,即“悬垂指针”等 5 个典型的内存安全问题,另一个是线程的执行顺序导致执行结果不可预测的问题。这里我们只关注内存安全问题。

首先,多个线程如何共享变量?前面的例子演示了启动新线程时,通过闭包捕获上下文中的变量来实现多个线程共享变量。这是一个典型的形式,我们以这个形式为基础来阐述多线程环境下的所有权问题。

我们来看例子代码:

//结构 Value 没有实现Copy Trait
struct Value{x:i32}
 
let v = Value{x:1};
let child = thread::spawn(move||{
    let p = v;
    println!("in closure:{}",p.x)
});
child.join();
//println!("{}",v.x);//编译错误:所有权已经丢失

这是前面例子的正确实现,变量 v 被传递到另一个线程(闭包内),执行了所有权转移

//闭包捕获的是一个引用变量,无论如何也拿不到所有权。那么多线程环境下所有引用都可以这么传递吗?
let v = Value{x:0};
let ref_v = &v;
let fun = move ||{
    let p = ref_v;
    println!("inclosure:{}",p.x)
};
fun();
println!("callafterclosure:{}",v.x);//编译执行成功

这个例子中,闭包捕获的是一个变量的引用,Rust 的引用都是实现了 Copy Trait,会被按位拷贝到闭包内的变量 p.p 只是不可变借用,没有获得所有权,但是变量 v 的不可变借用在闭包内外进行了传递。那么把它改成多线程方式会如何呢?这是多线程下的实现和编译器给出的错误提示:

let  v:Value = Value{x:1};
let ref_v = &v;           // 编译错误:被借用的值 v0 生命周期不够长
let child = thread::spawn(move||{
    let p = ref_v;
    println!("in closure:{}",p.x)
});                                // 编译器提示:参数要求 v0 被借用时为 'static 生命周期
child.join();

编译器的核心意思就是 v 的生命周期不够长。当 v 的不可变借用被传递到闭包中,并在另一个线程中使用时,主线程继续执行, v 随时可能超出作用域范围被回收,那么子线程中的引用变量就变成了悬垂指针。 如果 v 为静态生命周期,这段代码就可以正常编译执行。即把第一行改为:

const  v:Value = Value{x:1};

当然只能传递静态生命周期的引用实际用途有限,多数情况下我们还是希望能把非静态的数据传递给另一个线程。可以采用 Arc<T>来包装数据。 Arc<T> 是引用计数的智能指针,指针计数的增减操作是线程安全的原子操作,保证计数的变化是线程安全的。

//线程安全的引用计数智能指针Arc可以在线程间传递
let v1 = Arc::new(Value{x:1});
let arc_v = v1.clone();
let child = thread::spawn(move||{
    let p = arc_v;
    println!("Arc<Value>in closure:{}",p.x)
});
child.join();
//println!("Arc<Value>inclosure:{}",arc_v.x);//编译错误,指针变量的所有权丢失

如果把上面的 Arc<T> 换成 Rc<T> ,编译器会报告错误,说"Rc<T> 不能在线程间安全的传递"。

通过上面的例子我们可以总结出来一点,因为闭包定义中的 move 关键字,以闭包启动新线程时,被闭包捕获的变量本身的所有权必然会发生转移。无论捕获的变量是 "值变量"还是引用变量或智能指针(上述例子中 v,ref_v,arc_v 本身的所有权被转移)。但是对于引用或指针,它们所指代的数据的所有权并不一定被转移。

那么对于上面的类型 struct Value{x:i32}, 它的值可以在多个线程间传递(转移所有权),它的多个不可变借用可以在多个线程间同时存在。同时 &ValueArc<Value> 可以在多个线程间传递(转移引用变量或指针变量自身的所有权),但是 Rc<T> 不行。

要知道,Rc<T>Arc<T> 只是 Rust 标准库(std)实现的,甚至不在核心库(core)里。也就是说,它们并不是 Rust 语言机制的一部分。那么,编译器是如何来判断 Arc 可以安全的跨线程传递,而 Rc 不行呢?

Rust 核心库 的 marker.rs 文件中定义了两个标签 Trait:

pub unsafe auto trait Sync{}
pub unsafe auto trait Send{}

标签 Trait 的实现是空的,但编译器会分析某个类型是否实现了这个标签 Trait.

  • 如果一个类型 T实现了“Sync”,其含义是 T 可以安全的通过引用可以在多个线程间被共享。
  • 如果一个类型 T实现了“Send”,其含义是 T 可以安全的跨线程边界被传递。

那么上面的例子中的类型,Value&ValueArc<Value> 类型一定都实现了“SendTrait. 我们看看如何实现的。

marker.rs 文件还定义了两条规则:

unsafe impl<T:Sync + ?Sized> Send for &T{}
unsafe impl<T:Send + ?Sized> Send for & mut T{}

其含义分别是:

  • 如果类型 T 实现了“Sync”,则自动为类型 &T 实现“Send”.
  • 如果类型 T 实现了“Send”,则自动为类型 &mut T 实现“Send”.

这两条规则都可以直观的理解。比如:对第一条规则 T 实现了 “Sync”, 意味则可以在很多个线程中出现同一个 T 实例的 &T 类型实例。如果线程 A 中先有 &T 实例,线程 B 中怎么得到 &T 的实例呢?必须要有在线程 A 中通过某种方式 send 过来,比如闭包的捕获上下文变量。而且 &T 实现了 "Copy" Trait, 不会有所有权风险,数据是只读的不会有数据竞争风险,非常安全。逻辑上也是正确的。那为什么还会别标记为 unsafe ? 我们先把这个问题暂时搁置,来看看为智能指针设计的另外几条规则。

impl <T:?Sized>!marker::Send for Rc<T>{}
impl <T:?Sized>!marker::Sync for Rc<T>{}
impl<T:?Sized>!marker::Send for Weak<T>{}
impl<T:?Sized>!marker::Sync for Weak<T>{}
unsafe impl<T:?Sized+Sync+Send>Send  for Arc<T>{}
unsafe impl<T:?Sized+Sync+Send>Sync for Arc<T>{}

这几条规则明确指定 Rc<T>Weak<T> 不能实现 “Sync”和 “Send”。

同时规定如果类型 T 实现了 “Sync”和 “Send”,则自动为 Arc<T> 实现 “Sync”和 “Send”。Arc<T> 对引用计数增减是原子操作,所以它的克隆体可以在多个线程中使用(即可以为 Arc<T> 实现”Sync”和“Send”),但为什么其前提条件是要求 T 也要实现"Sync”和 “Send”呢。

我们知道,Arc<T>实现了 std::borrow,可以通过 Arc<T>获取 &T 的实例,多个线程中的 Arc<T> 实例当然也可以获取到多个线程中的 &T 实例,这就要求 T 必须实现“Sync”。Arc<T> 是引用计数的智能指针,任何一个线程中的 Arc<T>的克隆体都有可能成为最后一个克隆体,要负责内存的释放,必须获得被 Arc<T>指针包装的 T 实例的所有权,这就要求 T 必须能跨线程传递,必须实现 “Send”。

Rust 编译器并没有为 Rc<T>Arc<T> 做特殊处理,甚至在语言级并不知道它们的存在,编译器本身只是根据类型是否实现了 “Sync”和 “Send”标签来进行推理。实际上可以认为编译器实现了一个检查变量跨线程传递安全性的规则引擎,编译器为基本类型直接实现 “Sync”和 “Send”,这作为“公理”存在,然后在标准库代码中增加一些“定理”,也就是上面列举的那些规则。用户自己实现的类型可以自己指定是否实现 “Sync”和 “Send”,多数情况下编译器会根据情况默认选择是否实现。代码编译时编译器就可以根据这些公理和规则进行推理。这就是 Rust 编译器支持跨线程所有权安全的秘密。

对于规则引擎而言,"公理"和"定理"是不言而喻无需证明的,由设计者自己声明,设计者自己保证其安全性,编译器只保证只要定理和公理没错误,它的推理也没错误。所以的"公理"和"定理"都标注为 unsafe,提醒声明着检查其安全性,用户也可以定义自己的"定理",有自己保证安全。反而否定类规则 (实现 !Send!Sync)不用标注为 unsafe , 因为它们直接拒绝了变量跨线程传递,没有安全问题。

当编译器确定 “Sync”和 “Send”适合某个类型时,会自动为其实现此。

比如编译器默认为以下类型实现了 Sync

  • [u8] 和 [f64] 这样的基本类型都是 [Sync],

  • 包含它们的简单聚合类型(如元组、结构和名号)也是[Sync] 。

  • "不可变" 类型(如 &T)

  • 具有简单继承可变性的类型,如 Box 、Vec

  • 大多数其他集合类型(如果泛型参数是 [Sync],其容器就是 [Sync]。

用户也可以手动使用 unsafe 的方式直接指定。

下图是与跨线程所有权相关的概念和类型的 UML 图。

图 8与多线程相关的所有权转移

编辑简介:

高宪凤(.nil?),软件开发工程师,Rust 语言爱好者,喜欢有计划、有条理、有效率的工作,热爱开源文化,愿意为 Rust 中文社区的发展尽绵薄之力。

嵌入式领域的Rust语言

作者:洛佳


k210芯片

Rust语言是二十一世纪的语言新星。Rust被人广泛承认的一点,就是因为它能运行在多样的目标上, 从桌面和服务器设备,到资源有限的嵌入式设备。

我们可以用适合来评价一门语言和技术。Rust非常适合开发嵌入式应用,它是一种和C相仿的、 能应用于嵌入式设备开发的编程语言。

操作系统都是从裸机设备开始运行的,Rust语言的这一点也意味着,它能很好地用于编写操作系统。 无论是应用层还是内核本身,Rust都是极富竞争力、值得投入时间的技术选项。

裸机上的Rust语言

开发裸机应用时,通常希望使用的语言速度快、可靠性强。此外们还希望语言的生态较好, 有利于提高生产效率,而且适用范围较广。Rust语言能满足以上的要求,适合裸机应用的开发。

运用在裸机场合时,Rust语言拥有许多优点。除了效率和安全,Rust还将传统上不用于裸机开发的编程技术引入到裸机, 让开发者有更多的选择,更灵活、高效地编写裸机应用代码。

二十一世纪的裸机编程语言

在这个互联网全面普及、性价比设备应用更广的时代,安全和可靠性成为一门语言必须考虑的因素。 Rust语言采用移动语义,拥有严格的代数类型系统以及生命周期、所有权模型; 相比传统的编程语言,这些模型能在合适的时候释放所用资源,减少漏洞的出现。 此外,通过语义检查,Rust能在编译期有效寻找内存和线程安全问题,降低开发和测试的负担。

Rust语言是的运行效率高、开发效率好、适用范围广。作为一门编译型语言,它直接编译输出到汇编代码, 通常公认裸机的Rust语言性能在C语言级别,拥有较高的运行效率。 Rust语言的开发效率很高,文档完善、编译器提示有帮助,能节省软件开发所需的时间。 它能应用在多个平台和指令集中,这包括裸机平台;处理核、操作系统厂家还可以提供自己的编译目标, 无需厂家自己重新开发、提供工具链。

Rust语言出彩的地方在于,它向嵌入式平台引入了大量新的编程技术。 这包括了闭包、过程宏等传统上用于函数式编程的技术,和多态、虚函数表等面向对象语言的技术。 新编程技术的引入,扩充了开发者的选择。即使彻底理解Rust的编程概念有一定难度,但这些易用的新技术, 让开发者只需阅读实例代码,便可快速进入开发状态。这些新技术的引入,是嵌入式平台从未有过的, Rust能提高开发者的工作效率,降低平台间迁移的学习时间和成本。

裸机上的过程宏

传统用于嵌入式平台的编程,我们加快开发速度使用的宏,常常基于语法字符串的替换和修改。 Rust语言扩充了宏的概念,提出了基于语法树的“过程宏”编程方法,让宏语法更容易使用、编写更方便。

“过程宏”是接收Rust代码作为输入,操作这些代码,然后产生另一些代码的过程。 它和字符串的替换不同,是从语法树到语法树的替换。开发一个过程宏,可以使用简单的定义过程, 或者有工作量的属性宏定义过程。简单的定义中,我们编写代码,给出宏的输入有哪些,要翻译到哪些输出代码, 这样就完成了一个宏的定义。属性宏定义则允许完成语法树分析、代码生成甚至代码优化的过程, 就需要编写专门的“属性宏库”,借用Rust编译器的一部分,完成宏代码的转化和输出。

过程宏是基于语法树的分析过程,借助“树”的结构我们能理解它的一些特点。因为Rust语法树的子树也是Rust代码, 所以宏的定义内也可以完成语法分析,这就为代码编辑器的提示和补全提供了便利。 一个语法项目不可能同时属于两颗不是亲子关系的子树,因为如果属于两颗子树,将和语法树的树根产生环, 就和语法树的定义相违背,所以语法项目都是独立的,宏内代码的解析不会影响外界代码的解析。

这样的独立性也就是“卫生宏”思想的提出,Rust的过程宏可以理解为代码的“内部展开”,不影响代码的上下文。 正因为Rust过程宏产生完整的语法子树,它的定义不需要额外的界符,因此只需要满足Rust语法就可以了。

在过程宏的定义之外,Rust语言提供了大量便于嵌入式开发的标签。“align”标签定义内存对齐的方式, “link_section”标签给定代码要链接到的段或区。这样,过程宏可以包装各种各样的标签, Rust语言的用户可以方便地使用,而不需要深入宏了解代码的具体要求。 Rust语言定义的过程宏可以导出到包外,给其它的库使用,这有利于嵌入式Rust生态的搭建和共享。 Rust语言宏灵活的特性,让宏在更多的领域有可用之处,更好地服务嵌入式平台的开发工作。

嵌入式中的模块化编程

Rust语言拥有很好的模块化编程概念。传统平台的Rust语言中,社区总结出了“模块-包-项目”的模型。 这个模型也适用于嵌入式平台,增加协作开发的效率,更好地共享生态。

Rust的模块化编程分为模块、包、项目三级。模块是Rust语言可见性分划的最小单位, 语言中提供了专门的关键字,来区分不同模块的代码和可见性,是由Rust语言本身确定的。 在Rust语法中,“mod”是定义模块的关键字,“pub”是定义可见性的关键字。

包是Rust项目的二进制目标,这个等级是由Rust工具链给定的。每个包有版本号、作者和许可协议等元数据, 要依赖和使用的库也要登记到包中,以便共同编译。库的特性有点像传统语言的条件编译, 也是以包为单位规定的,每个包使用的库可以开启不同的特性,但库在同一个包中开启的特性是相同的。

“项目”这一层并非由Rust语言给定;人们开发软件时,发现一个解决方案中包含多个二进制目标是非常好的, 总结之后就出现了项目的抽象模型。项目由核心和外围包组成,或者是功能相近的一组包, 它通常由同一个团队组织和维护,可以在项目上添加扩展。项目在习惯上由核心包到功能包,以依赖的形式构成。 实践中,“项目”可以放在同一个工作空间里,以统一管理和发布编译版本。

Rust将模块化编程引入到嵌入式开发中,也可以方便地编写测试和性能检测代码。 模块化编程能提高Rust嵌入式开发者的工作效率,适应现代化嵌入式软件的需求。

搭建Rust嵌入式生态

生态是软件不可或缺的一部分。从编译器到软件支持,嵌入式Rust目前已经拥有良好的基础生态。 此外,操作系统内核也是嵌入式编程的重要部分,嵌入式Rust和内核开发也有较好的相容度。

截至2020年12月的嵌入式生态架构图

你的架构和指令集

嵌入式Rust的应用支持分为两个部分:一个是目标处理核的支持,一个是芯片外设的支持。

针对目标处理核,首先我们要编译Rust到这个指令集架构。Rust语言提供丰富的编译目标, 主流的编译目标都有很好的支持;此外,如果有自主研发的指令集架构,可以为Rust添加自己的编译目标。 编译完成后,还需要编写微架构支持库和微架构运行时。微架构运行时提供最小的启动代码实现, 能搭建一个适合Rust代码运行的环境。微架构支持库简单包装汇编代码,允许应用代码操作寄存器、运行特殊的指令, 作为编译器系统的补充。这之后,Rust对这个指令集架构的代码运行支持就完成了。

嵌入式应用定义了各有特点的中断控制器,有些是指令集架构定义的,有些是芯片设计厂家自己定义的。 嵌入式Rust要支持这些中断控制器,需要在微架构运行时中添加处理和封装部分,或者作为通用架构的补充, 在专用架构的支持库中添加专有架构的中断运行时。架构虽然定义了标准,但基地址、中断数量等配置可能相互不同。 这些元数据配置可以放在外设访问库的中断部分,和架构支持库共同构成中断控制器的支持。

目标的处理核定义了调试接口和闪存烧写算法,我们需要在调试器软件中编写这些算法。 社区通用的软件“probe-rs”是很好的调试器实现,可以替代OpenOCD,作为非常好的Rust语言调试软件。 如果自己的操作系统有软件调试接口,可以添加操作系统调试器的载荷,共同完成调试软件的部分。 只要处理器厂商实现了调试接口,提供相关的文档,配套的Rust软件可以尽快完成,方便各种技术的开发者调试和使用。

嵌入式生态的标准

起初嵌入式开发者会为每个芯片都编写一次代码。随着生态的发展,大家认识到,需要提供一个基本的抽象, 大家都围绕着抽象去编写,就能剩下大量外设反复操作的时间。embedded-hal就是这样的标准, 它是Rust语言的嵌入式外设抽象,支持大量的片内和片外外设,包括传感器等,很好地扩充了嵌入式的生态。

embedded-hal是统一的Rust语言标准,它是针对外设功能本身的抽象,是抽象的集合,具体实现由实现库去完成。 它的扩展性很好,比如“SPI-GPIO扩展器”外设输入SPI接口抽象,输出GPIO的抽象,很多模块都是抽象到抽象的过程, 就可以方便的极联、衔接和嵌套,整合更多的项目;这就非常容易为新的芯片编写支持库。

市场上海量的芯片都支持embedded-hal标准。K210、GD32V和BL602系列的芯片都提供很好的embedded-hal实现库。 要编写embedded-hal标准的支持库,只需要机器生成外设库,然后编写中间层库,就能完成对此标准的原厂支持。

Rust与操作系统内核

操作系统也是嵌入式应用。常见的操作系统如按是否包含虚拟内存区分,有不含虚拟内存的实时系统, 和包含虚拟内存传统操作系统。基于微架构的支持库和运行时库,操作系统内核可以很方便地编写。

社区中提供了大量成熟的操作系统运行时。 如rCore系列操作系统是第一个基于RISC-V架构的完整Rust操作系统,尤其适合教学使用。 RTIC框架是中断驱动的异步实时系统,完全针对应用使用Rust的宏语法生成,拥有极高的效率。 Tock系统是针对微处理器的安全实时系统,已经用于手表、智能路标和加密狗等产品。

针对操作系统和应用程序开发,Rust是适合编写硬件驱动的语言。 如果使用有产权的代码,可以以混合链接的形式,与Rust代码联合编译为二进制使用。 系统模块、插件和动态链接库等等都能受益于Rust语言内存安全的特性,适合现在对安全敏感的开发需求。

物联网系统要求嵌入式的操作系统能够连上网络。Rust嵌入式社区也在探索射频连接的技术标准, 包括蓝牙、WiFi等硬件标准。smoltcp是社区提供的非常好的TCP协议栈实现,它可以代替lwip, 在嵌入式系统领域高效、安全地完成网络传输。搭配缓冲区和协议库,物联网操作系统就可以连上网了。

RustSBI:新型操作系统引导软件

我们在开发操作系统内核时,有的内核直接运行在裸机上,有的还依托于一个运行环境。 在RISC-V上,“SBI”就是这样的运行环境。它除了引导启动内核,还将常驻后台,提供操作系统需要的实用功能。

RISC-V标准中,“SBI”意味着“操作系统二进制接口”,运行在其上的操作系统会通过环境调用“ecall”指令, 陷入到二进制接口的实现中,由其调用具体硬件的实现功能。这种实现被称作“SBI实现”,社区常用的实现有开源的OpenSBI。 RustSBI是鹏城实验室“rCore代码之夏-2020”活动提出的SBI实现,它是全新的操作系统引导软件。

实现与模块组成

RustSBI由几个功能模块组成。硬件环境接口实现了RISC-V SBI v0.2版本的接口,能运行支持此版本的操作系统。 硬件运行时则是SBI实现运行在裸机环境的必要模块,它将由硬件启动,开始运行所有的RustSBI模块。 SBI的初始化完成后,将进入引导启动模块,这里将发挥SBI标准“引导启动”的功能,最终启动操作系统内核。 另外,兼容性模块能完成硬件到硬件间的支持,能模拟旧版硬件不存在的指令、寄存器,进一步延长操作系统的生命周期。

去年12月,RustSBI的0.1版本在深圳的Rust中国社区2020年年会上发布。使用目前最新的0.1.1版本, RustSBI已经支持大量SBI标准提出的功能,支持大量自定义的扩展功能;完全使用安全的Rust语言编写,提高开发效率。 开发Rust语言的操作系统内核,可以统一编译工具链。另外,RustSBI已经被RISC-V组织收录入RISC-V SBI标准, 它的实现编号为4。

RustSBI是一个库,它以库的形式设计的初衷是,便于平台开发者“积木”式地引入库的模块,为自己的硬件目标开发SBI支持。 虽然RustSBI提供了QEMU、K210平台的参考实现,但应用开发者不应当将自己的目标也加入参考实现中, 而是在自己的仓库里引用RustSBI的模块,可以选择参考这些实现的内容,最终完成完全可控的开发过程。 这两个平台的使用范围较广,参考实现也会长期维护,以发现RustSBI本身可能的少量问题,并及时修补完善。

为什么用Rust开发RustSBI呢?我们认为,相比使用C语言,嵌入式Rust的生态圈在协调发展阶段,它容易支持新硬件, Rust语言较强的编译约束也提高了硬件代码的安全性。

硬件到硬件的兼容性

RISC-V是快速更迭的指令集规范。我们为新版RISC-V硬件编写软件,会遇到与旧版硬件不兼容的情况。 硬件和硬件之间的兼容性,也能通过软件完成——这是RustSBI提供的功能与亮点之一。

RustSBI实现的硬件兼容性,是靠捕获指令异常完成的。例如,K210平台实现的是1.9.1版本的RISC-V特权级标准, 它规定了旧版的页表刷新指令;而目前最新的1.11版标准,规定的是新版的刷新指令。为新标准编写的操作系统内核, 使用新版刷新指令,会因为K210硬件无法找到新版指令,抛出非法指令异常。这个非法指令异常被RustSBI捕获, 它解析后,发现是新版的页表刷新指令,便直接在硬件上运行旧版的指令,完成指令的页表刷新功能。

这种硬件兼容性,目前能支持新增的指令和寄存器。一切情况下,指令、寄存器在仍然存在,但新版中修改了它们的功能和意义。 只靠RustSBI软件本身,就不足以提供兼容性支持了。如果RISC-V芯片实现提供特定的兼容性外设, 比如这个外设能拦截特定CSR寄存器的访问指令,就可以在功能修改的寄存器访问时,产生一个可供软件捕获的中断。 这样的外设设计之后,使用RustSBI软件,将能支持功能修改的指令和寄存器,将进一步提升操作系统内核的硬件兼容性。

兼容旧硬件,也是兼容未来新硬件的过程。未来的RISC-V标准快速发展,将与目前的硬件标准产生一定的差异; 在硬件不变的前提下,未来软件能对当前的硬件兼容,就能延长软件的生命周期。 或许,我们未来升级RISC-V上的操作系统,只需要更换硬件中的RustSBI固件,就能完美兼容最新标准的操作系统了。 升级原有系统的硬件也非常容易,替换RustSBI固件就能达到升级效果。

另外,硬件兼容性也意味着实现硬件上缺少的指令集。当这些指令集运行时,就会陷入到软件中,由RustSBI软件模拟这些指令, 最终返回,这个过程应用软件不会有感知。当然,这种软件模拟过程可以满足正确性,效率不如新版的硬件, 但临时运行一个新版的软件、体验新版的指令集还是足够的。当模拟指令的过程多到影响性能时,也就是硬件该升级的时候了。

RustSBI与嵌入式Rust生态

在RustSBI的实现中,多次使用“embedded-hal”的实现完成编写过程。“embedded-hal”是Rust嵌入式的外设规范, 它对大量厂家的外设提供了软件支持。只要厂家的硬件支持“embedded-hal”,只需要编写部分抽象接口代码, RustSBI支持就可以快速地开发完成。

硬件处理核和SoC系统的开发也受益于设计好的RustSBI软件架构。“RustSBI很快速地实现了仿真环境的双核测试,” 华中科技大学的社区贡献者车春池说,“这能为处理核提供丰富的测试环境,在开发高性能RISC-V处理核中非常重要。”

无论硬件和软件,我们都乐于看到各个应用领域积极互动,嵌入式Rust生态的发展过程得到加快。 “embedded-hal”本是裸机外设的标准,RustSBI将这个标准运用在引导软件上,能加速裸机外设的开发和建设, 也能更快适配SBI标准到平台上。

借这个项目,我们很高兴能参与嵌入式领域Rust语言的建设,希望这些微小的技术更新和迭代,最终能回馈到我们美好的生活中去。


作者简介:

洛佳

华中科技大学网络空间安全学院本科生,热爱操作系统、嵌入式开发。RustSBI项目作者,3年Rust语言开发经验,社区活跃贡献者。目前致力于向产业、教学等更多的领域推广Rust语言。

Rust写操作系统 | 清华 rCore OS 教程介绍

编辑:张汉东

rCore OS 教程简介

众所周知,清华大学的操作系统课程是国家级精品课程。清华大学也是是国内首个使用 Rust 进行操作系统教学的高校。目前,陈渝教授和他的学生吴一凡正在编写新的操作系统教材。该教材相关的文档都是网络公开的,教程地址:https://rcore-os.github.io/rCore-Tutorial-Book-v3/

这本教程旨在一步一步展示如何 从零开始 用 Rust 语言写一个基于 RISC-V 架构的类 Unix 内核。值得注意的是, 本项目不仅支持模拟器环境(如 Qemu/terminus 等),还支持在真实硬件平台 Kendryte K210 上运行。

该教程目前已经发布了近 20 万字,每一章都是一个能完整运行的内核。目前已经完成了前四章分别可以让内核能在裸机打印字符、支持系统调用和特权级切换、任务切换和虚拟存储。后面还会依次支持进程、进程间通信和数据持久化,代码已经写完,有待更新教程文档。陈渝教授和吴一凡也希望能够通过该教程吸引更多对 Rust 和 OS 感兴趣的读者,可以在教程的基础上自己从头实现一遍或者能做一些拓展,也能提供一些反馈,让教程的质量越来越高。

以教程目前的内容进度,正是大家从零开始学习编写操作系统的最佳时期。

为什么要学习操作系统?

一名程序员的绝大部分工作都是在操作系统上面进行的。学习操作系统,深入了解操作系统原理,是每个合格的程序员必须要经历的。

很多人学习 Rust 语言感到很吃力,基本上就是因为操作系统基础知识薄弱造成的。

通过自己实现一个操作系统,可以让你对操作系统的理解不仅仅是停留在概念上。而且用 Rust 实现操作系统,对于 Rust 爱好者来说,更有意思。

希望社区的朋友可以根据该教程实现自己的操作系统,如果需要交流,可以联系我(张汉东),我们可以一起建立学习小组,并且可以直接向陈渝教授和吴一凡反馈学习中的问题。

以下内容节选自rCore OS 教程第零章

目前常见的操作系统内核都是基于C语言的,为何要推荐Rust语言?

没错,C语言就是为写UNIX而诞生的。Dennis Ritchie和KenThompson没有期望设计一种新语言能帮助高效简洁地开发复杂的应用业务逻辑,只是希望用一种简洁的方式抽象出计算机的行为,便于编写控制计算机硬件的操作系统,最终的结果就是C语言。

C语言的指针的天使与魔鬼,且C语言缺少有效的并发支持,导致内存和并发漏洞成为当前操作系统的噩梦。

Rust语言具有与C一样的硬件控制能力,且大大强化了安全编程。从某种角度上看,新出现的Rust语言的核心目标是解决C的短板,取代C。所以用Rust写OS具有很好的开发和运行的体验。

用 Rust 写 OS 的代价仅仅是学会用 Rust 编程。

目前常见的CPU是x86和ARM,为何要推荐RISC-V?

没错,最常见的的CPU是x86和ARM,他们已广泛应用在服务器,台式机,移动终端和很多嵌入式系统中。它们需要支持非常多的软件系统和应用需求,导致它们越来越复杂。

x86的向过去兼容的策略确保了它的江湖地位,但导致其丢不掉很多已经比较过时的硬件设计,让操作系统疲于适配这些硬件特征。

x86和ARM都很成功,这主要是在商业上,其广泛使用是的其CPU硬件逻辑越来越复杂,且不够开放,不能改变,不是开源的,提高了操作系统开发者的学习难度。

从某种角度上看,新出现的RISC-V的核心目标是灵活适应未来的AIoT场景,保证基本功能,提供可配置的扩展功能。其开源特征使得学生都可以方便地设计一个RISC-V CPU。

写面向RISC-V的OS的代价仅仅是你了解RISC-V的Supevisor特权模式,知道OS在Supevisor特权模式下的控制能力。

清华大学为何要写这本操作系统书?

现在国内外已有一系列优秀的操作系统教材,例如 William Stallings 的《Operating Systems Internals and Design Principles》,Avi Silberschatz、Peter Baer Galvin 和 Greg Gagne 的《Operating System Concepts》,Remzi H. Arpaci-Dusseau 和 Andrea C. Arpaci-Dusseau 的《Operating Systems: Three Easy Pieces》等。然而,从我们从2000年以来的教学实践来看,某些经典教材对操作系统的概念和原理很重视,但还有如下一些问题有待改进:

原理与实践脱节:缺乏在操作系统的概念/原理与操作系统的设计/实现之间建立联系的桥梁,导致学生发现操作系统实现相关的实验与操作系统的概念相比,有较大的鸿沟。

缺少历史发展的脉络:操作系统的概念和原理是从实际操作系统设计与实现过程中,从无到有逐步演进而产生的,有其发展的历史渊源和规律。但目前的大部分教材只提及当前主流操作系统的概念和原理,有“凭空出现”的感觉,学生并不知道这些内容出现的前因后果。

忽视硬件细节或用复杂硬件:很多教材忽视或抽象硬件细节,是的操作系统概念难以落地。部分教材把 x86 作为的操作系统实验的硬件参考平台,缺乏对当前快速发展的RISC-V等体系结构的实验支持,使得学生在操作系统实验中可能需要花较大代价了解相对繁杂的x86硬件细节,影响操作系统实验的效果。

这些问题增加了学生学习和掌握操作系统的难度。我们想通过尝试解决上面三个问题,来缓解学生学习操作系统的压力,提升他们的兴趣,让他们能够在一个学期内比较好地掌握操作系统。为应对“原理与实践脱节”的问题,我们强调了实践先行,实践引领原理的理念。MIT教授 Frans Kaashoek等师生设计实现了基于UNIX v6的xv6教学操作系统用于每年的本科操作系统课的实验中,并在课程讲解中把原理和实验结合起来,在国际上得到了广泛的认可。这些都给了我们很好的启发,经过十多年的实践,对一个计算机专业的本科生而言,设计实现一个操作系统(包括CPU)有挑战但可行,前提是实际操作系统要小巧并能体现操作系统的核心思想。这样就能够让学生加深对操作系统原理和概念的理解,能让操作系统原理和概念落地。

为应对“缺少历史发展的脉络”的问题,我们重新设计操作系统实验和教学内容,按照操作系统的历史发展过程来建立多个相对独立的小实验,每个实验体现了操作系统的一个微缩的历史,并从中归纳总结出操作系统相关的概念与原理,并在教学中引导学生理解这些概念和原理是如何一步一步演进的。

为应对“忽视硬件细节或用复杂硬件”的问题,我们在硬件(x86, ARM, MIPS, RISC-V等)和编程语言(C, C++, Go, Rust等)选择方面进行了多年尝试。在2017年引入了RISC-V CPU作为操作系统实验的硬件环境,在2018年引入Rust编程语言作为开发操作系统的编程语言,使得学生以相对较小的开发和调试代价能够用Rust语言编写运行在RISC-V上的操作系统。而且方便和简化了让操作系统的概念和原理形象化,可视化的过程。学生可以吧操作系统的概念和原理直接对应到程序代码、硬件规范和操作系统的实际执行中,加强了学生对操作系统内涵的实际体验和感受。

所以本书的目标是以简洁的RISC-V CPU为底层硬件基础,根据上层应用从小到大的需求,按OS发展的历史脉络,逐步讲解如何设计并实现满足这些需求的“从小到大”的多个“小”操作系统。并在设计实现操作系统的过程中,逐步解析操作系统各种概念与原理的知识点,对应的做到有“理”可循和有“码”可查,最终让读者通过主动的操作系统设计与实现来深入地掌握操作系统的概念与原理。

在具体撰写过程中,第零章是对操作系统的一个概述,让读者对操作系统的历史、定义、特征等概念上有一个大致的了解。后面的每个章节体现了操作系统的一个微缩的历史发展过程,即从对应用由简到繁的支持的角度出发,每章会讲解如何设计一个可运行应用的操作系统,满足应用的阶段性需求。从而读者可以通过对应配套的操作系统设计实验,了解如何从一个微不足道的“小”操作系统,根据应用需求,添加或增强操作系统功能,逐步形成一个类似UNIX的相对完善的“小”操作系统。每一步都小到足以让人感觉到易于掌控,而在每一步结束时,你都有一个可以工作的“小”操作系统。另外,通过足够详尽的测试程序 ,可以随时验证读者实现的操作系统在每次更新后是否正常工作。由于实验的代码规模和实现复杂度在一个逐步递增的可控范围内,读者可以结合对应于操作系统设计实验的进一步的原理讲解,来建立操作系统概念原理和实际实现的对应关系,从而能够通过操作系统实验的实践过程来加强对理论概念的理解,通过理论概念来进一步指导操作系统实验的实现与改进。

在你开始阅读与实践本书讲解的内容之前,你需要决定用什么编程语言来完成操作系统实验。你可以用任何你喜欢的编程语言和你喜欢的CPU上来实现操作系统。我们推荐的编程语言是Rust,我们推荐的CPU是RISC-V。

Rust生态安全漏洞总结系列 | Part 1

作者:张汉东 后期编辑:张汉东

本系列主要是分析RustSecurity 安全数据库库中记录的Rust生态社区中发现的安全问题,从中总结一些教训,学习Rust安全编程的经验。


作为本系列文章的首篇文章,我节选了RustSecurity 安全数据库库中 2021 年 1 月份记录的前五个安全漏洞来进行分析。

img

01 | Mdbook XSS 漏洞 (RUSTSEC-2021-0001)

img

正好《Rust 中文精选(RustMagazine)》也用了 mdbook,不过读者朋友不用害怕,本刊用的 mdbook 是修补了该漏洞的版本。

该漏洞并非 Rust 导致,而是生成的网页中 JS 函数使用错误的问题。

漏洞描述:

问题版本的 mdBook 中搜索功能(在版本0.1.4中引入)受到跨站点脚本漏洞的影响,该漏洞使攻击者可以通过诱使用户键入恶意搜索查询或诱使用户进入用户浏览器来执行任意JavaScript代码。

漏洞成因分析:

XSS的漏洞主要成因是后端接收参数时未经过滤,导致参数改变了HTML的结构。而mdbook中提供的js函数encodeURIComponent会转义除'之外的所有可能允许XSS的字符。 因此,还需要手动将'替换为其url编码表示形式(%27)才能解决该问题。

修复 PR 也很简单。

02 | 暴露裸指针导致段错误 (RUSTSEC-2021-0006)

img

该漏洞诞生于第三方库cache,该库虽然已经两年没有更新了,但是它里面出现的安全漏洞的警示作用还是有的。该库问题issue中说明了具体的安全漏洞。

该安全漏洞的特点是,因为库接口中将裸指针(raw pointer) 公开了出来,所以该裸指针可能被用户修改为空指针,从而有段错误风险。因为这个隐患是导致 Safe Rust 出现 UB,所以是不合理的。

以下代码的注释分析了漏洞的产生。

use cache;


/**

    `cache crate` 内部代码:

    ```rust
    pub enum Cached<'a, V: 'a> {
        /// Value could not be put on the cache, and is returned in a box
        /// as to be able to implement `StableDeref`
        Spilled(Box<V>),
        /// Value resides in cache and is read-locked.
        Cached {
            /// The readguard from a lock on the heap
            guard: RwLockReadGuard<'a, ()>,
            /// A pointer to a value on the heap
            // 漏洞风险
            ptr: *const ManuallyDrop<V>,
        },
        /// A value that was borrowed from outside the cache.
        Borrowed(&'a V),
    }

    ```
**/
fn main() {
    let c = cache::Cache::new(8, 4096);
    c.insert(1, String::from("test"));
    let mut e = c.get::<String>(&1).unwrap();

    match &mut e {
        cache::Cached::Cached { ptr, .. } => {
            // 将 ptr 设置为 空指针,导致段错误
            *ptr = std::ptr::null();
        },
        _ => panic!(),
    }
    // 输出:3851,段错误
    println!("Entry: {}", *e);
}

启示:

所以,这里我们得到一个教训,就是不能随便在公开的 API 中暴露裸指针。值得注意的是,该库处于失去维护状态,所以这个漏洞还没有被修正。

03 | 读取未初始化内存导致UB (RUSTSEC-2021-0008)

img

该漏洞诞生于 bra 库。该库这个安全漏洞属于逻辑 Bug 。因为错误使用 标准库 API,从而可能让用户读取未初始化内存导致 UB。

披露该漏洞的issue。目前该漏洞已经被修复。

以下代码注释保护了对漏洞成因对分析:


#![allow(unused)]
fn main() {
// 以下是有安全风险的代码示例:
impl<R> BufRead for GreedyAccessReader<R>
    where
        R: Read,
{
    fn fill_buf(&mut self) -> IoResult<&[u8]> {
        if self.buf.capacity() == self.consumed {
            self.reserve_up_to(self.buf.capacity() + 16);
        }

        let b = self.buf.len();
        let buf = unsafe {
            // safe because it's within the buffer's limits
            // and we won't be reading uninitialized memory
            // 这里虽然没有读取未初始化内存,但是会导致用户读取
            std::slice::from_raw_parts_mut(
                self.buf.as_mut_ptr().offset(b as isize),
                self.buf.capacity() - b)
        };

        match self.inner.read(buf) {
            Ok(o) => {
                unsafe {
                    // reset the size to include the written portion,
                    // safe because the extra data is initialized
                    self.buf.set_len(b + o);
                }

                Ok(&self.buf[self.consumed..])
            }
            Err(e) => Err(e),
        }
    }

    fn consume(&mut self, amt: usize) {
        self.consumed += amt;
    }
}
}

GreedyAccessReader::fill_buf方法创建了一个未初始化的缓冲区,并将其传递给用户提供的Read实现(self.inner.read(buf))。这是不合理的,因为它允许Safe Rust代码表现出未定义的行为(从未初始化的内存读取)。

在标准库Read trait 的 read 方法文档中所示:

您有责任在调用read之前确保buf已初始化。 用未初始化的buf(通过MaybeUninit <T>获得的那种)调用read是不安全的,并且可能导致未定义的行为。 https://doc.rust-lang.org/std/io/trait.Read.html#tymethod.read

解决方法:

read之前将新分配的u8缓冲区初始化为零是安全的,以防止用户提供的Read读取新分配的堆内存的旧内容。

修正代码:


#![allow(unused)]
fn main() {
// 修正以后的代码示例,去掉了未初始化的buf:
impl<R> BufRead for GreedyAccessReader<R>
    where
        R: Read,
{
    fn fill_buf(&mut self) -> IoResult<&[u8]> {
        if self.buf.capacity() == self.consumed {
            self.reserve_up_to(self.buf.capacity() + 16);
        }

        let b = self.buf.len();
        self.buf.resize(self.buf.capacity(), 0);
        let buf = &mut self.buf[b..];
        let o = self.inner.read(buf)?;

        // truncate to exclude non-written portion
        self.buf.truncate(b + o);

        Ok(&self.buf[self.consumed..])
    }

    fn consume(&mut self, amt: usize) {
        self.consumed += amt;
    }
}
}

启示:

该漏洞给我们对启示是,要写出安全的 Rust 代码,还必须掌握每一个标准库里 API 的细节。否则,逻辑上的错误使用也会造成UB

04 | 读取未初始化内存导致UB (RUSTSEC-2021-0012)

img

该漏洞诞生于第三方库[cdr-rs]中,漏洞相关issue中。

该漏洞和 RUSTSEC-2021-0008 所描述漏洞风险是相似的。

cdr-rs 中的 Deserializer::read_vec方法创建一个未初始化的缓冲区,并将其传递给用户提供的Read实现(self.reader.read_exact)。

这是不合理的,因为它允许安全的Rust代码表现出未定义的行为(从未初始化的内存读取)。

漏洞代码:


#![allow(unused)]
fn main() {
fn read_vec(&mut self) -> Result<Vec<u8>> {
    let len: u32 = de::Deserialize::deserialize(&mut *self)?;
    // 创建了未初始化buf
    let mut buf = Vec::with_capacity(len as usize);
    unsafe { buf.set_len(len as usize) }
    self.read_size(u64::from(len))?;
    // 将其传递给了用户提供的`Read`实现
    self.reader.read_exact(&mut buf[..])?;
    Ok(buf)
}
}

修正:


#![allow(unused)]
fn main() {
fn read_vec(&mut self) -> Result<Vec<u8>> {
    let len: u32 = de::Deserialize::deserialize(&mut *self)?;
    // 创建了未初始化buf
    let mut buf = Vec::with_capacity(len as usize);
    // 初始化为 0;
    buf.resize(len as usize, 0);
    self.read_size(u64::from(len))?;
    // 将其传递给了用户提供的`Read`实现
    self.reader.read_exact(&mut buf[..])?;
    Ok(buf)
}
}

启示:同上。

05 | Panic Safety && Double free (RUSTSEC-2021-0011)

img

该漏洞诞生于ocl库,漏洞相关issue。该库已经处于不再维护状态,但是这个漏洞背后的成因需要引起我们重视。

该库中使用了ptr::read,并且没有考虑好Panic Safety的情况,所以会导致双重释放(double free)。

以下两段代码是漏洞展示,注意注释部分都解释:


#![allow(unused)]
fn main() {
//case 1
macro_rules! from_event_option_array_into_event_list(
    ($e:ty, $len:expr) => (
        impl<'e> From<[Option<$e>; $len]> for EventList {
                fn from(events: [Option<$e>; $len]) -> EventList {
                    let mut el = EventList::with_capacity(events.len());
                    for idx in 0..events.len() {
                    // 这个 unsafe 用法在 `event.into()`调用panic的时候会导致双重释放
                        let event_opt = unsafe { ptr::read(events.get_unchecked(idx)) };
                        if let Some(event) = event_opt { el.push::<Event>(event.into()); }
                    }
                    // 此处 mem::forget 就是为了防止 `dobule free`。
                    // 因为 `ptr::read` 也会制造一次 drop。
                    // 所以上面如果发生了panic,那就相当于注释了 `mem::forget`,导致`dobule free`
                    mem::forget(events);
                    el
                }
        }
    )
);

// case2

impl<'e, E> From<[E; $len]> for EventList where E: Into<Event> {
    fn from(events: [E; $len]) -> EventList {
        let mut el = EventList::with_capacity(events.len());
        for idx in 0..events.len() {
        // 同上
            let event = unsafe { ptr::read(events.get_unchecked(idx)) };
            el.push(event.into());
        }
        // Ownership has been unsafely transfered to the new event
        // list without modifying the event reference count. Not
        // forgetting the source array would cause a double drop.
        mem::forget(events);
        el
    }
}
}

以下是一段该漏洞都复现代码(我本人没有尝试过,但是提交issue都作者试过了),注意下面注释部分的说明:

// POC:以下代码证明了上面两个case会发生dobule free 问题

use fil_ocl::{Event, EventList};
use std::convert::Into;

struct Foo(Option<i32>);

impl Into<Event> for Foo {
    fn into(self) -> Event {
        /*
        根据文档,`Into <T>`实现不应出现 panic。但是rustc不会检查Into实现中是否会发生恐慌,
        因此用户提供的`into()`可能会出现风险
        */
        println!("LOUSY PANIC : {}", self.0.unwrap()); // unwrap 是有 panic 风险

        Event::empty()
    }
}

impl Drop for Foo {
    fn drop(&mut self) {
        println!("I'm dropping");
    }
}

fn main() {
    let eventlist: EventList = [Foo(None)].into();
    dbg!(eventlist);
}

以下是 Fix 漏洞的代码,使用了ManuallyDrop,注意注释说明:


#![allow(unused)]
fn main() {
macro_rules! from_event_option_array_into_event_list(
    ($e:ty, $len:expr) => (
        impl<'e> From<[Option<$e>; $len]> for EventList {
            fn from(events: [Option<$e>; $len]) -> EventList {
                let mut el = ManuallyDrop::new(
                    EventList::with_capacity(events.len())
                );

                for idx in 0..events.len() {
                    let event_opt = unsafe {
                        ptr::read(events.get_unchecked(idx))
                    };

                    if let Some(event) = event_opt {
                        // Use `ManuallyDrop` to guard against
                        // potential panic within `into()`.
                        // 当 into 方法发生 panic 当时候,这里 ManuallyDrop 可以保护其不会`double free`
                        let event = ManuallyDrop::into_inner(
                            ManuallyDrop::new(event)
                            .into()
                        );
                        el.push(event);
                    }
                }
                mem::forget(events);
                ManuallyDrop::into_inner(el)
            }
        }
    )
);

}

启示:

在使用 std::ptr 模块中接口需要注意,容易产生 UB 问题,要多多查看 API 文档。

Rustc Dev Guide 中文翻译启动

作者:张汉东


Rust编译器开发指南(Rustc Dev Guide) 的中文翻译已经启动。因为原项目还在变动期,为了翻译方便,所以此翻译项目组织结构就不和原项目保持一致了。

志愿者招募要求:

  • 热爱 Rust,对 Rust 已经有一定了解
  • 想深入了解 Rust 编译器
  • 想为 Rust 编译器做贡献
  • 业余时间充足

如何参与

  1. 认领感兴趣到章节
  2. 找到对应到 markdown 文件
  3. 直接发 PR
  4. 或者帮忙审校别人的 PR

Q & A:

  1. 如何避免每个人翻译上的冲突呢,需要提前pr说翻译哪一章节吗?

    其实没必要怕冲突,对于参与翻译的来说,翻译本身也是一次学习过程,是有收获的。了解编译器工作原理对理解 Rust 概念也有帮助的。如果同一篇有多个翻译,那我这边选翻译更好的就可以了。

    这个项目倡导参与者自组织,但为了更加方便大家协作,还是来设置一个规则避免大家冲突。为了大家认领方便,特别创建了认领打卡的 issues,都去这里打一下卡:【翻译认领】避免翻译冲突,来此打卡

    如果你想发一个自己专属的「认领issue」也没问题,可以给该issue打上「已认领」标签。开一个独立的issue好处是可以有一个专属的地方讨论你翻译章节内容里的各种问题。

  2. 为什么要翻译 《Rust 编译器开发指南》 ?

    年初的时候,我立下一个五年的 Flag : 五年内要为 Rust 语言发 1000 个 PR。

    然后社区里的朋友就帮我做了一个计算:五年 1000 个,那么每年 200 个,那么一天就得 0.5 个。也有朋友说,Rust 的 PR 每次 Review 周期都很长,就算你能一年提 200 个 PR,官方也不可能给你合并那么多。

    这样的计算,确实很有道理。这个目标,确实很难完成。但其实这个 Flag 我并没有打算个人完成,而是想推动社区对 Rust 感兴趣对朋友一起完成。如果五年内,我能推动 1000 个人参与,那么每个人只提交一个 PR,那么这个 1000 个 PR 的 Flag 就轻松完成了。

    所以,翻译 《Rust 编译器开发指南》就成了我完成这个 Flag 的第一步。希望大家踊跃参与。

图解 Rust 编译器与语言设计 | Part 1 :Rust 编译过程与宏展开

作者:张汉东


说明

《图解 Rust 编译器与语言设计》系列文章特点:

  1. 重在图解。图解的目的,是为了帮助开发者从整体结构、语义层面来掌握 Rust 编译器与语言设计。
  2. 边实践边总结,不一定会每月都有,但争取吧。
  3. 希望是众人合力编写,我只是抛砖引玉。硬骨头,一起啃。

引子

想必读者朋友们都已经看到了 《Rust 日报》里的消息:微软、亚马逊、Facebook等巨头,都在组建自己的 Rust 编译器团队,都在战略性布局针对 Rust 语言。并且 Rust 基金会也已经进入了最后都流程,由此可以猜想,这些巨头很可能已经加入了基金会。

我在 RustChinaConf 2020 年大会分享《Rust 这五年》中盘点了 Rust 这五年多都发展,虽然 Rust 势头很好,但大部分贡献其实都是国外社区带来的,国内社区则是处于学习和观望的状态,等待着所谓的杀手级应用出现来引领 Rust 的“走红”。为什么国内社区不能为 Rust 多做点实质性的贡献呢?

因此,2020 新年到来的时候,我立下一个五年的 Flag : 五年内要为 Rust 语言发 1000 个 PR。

然后社区里的朋友就帮我做了一个计算:五年 1000 个,那么每年 200 个,那么一天就得 0.5 个。也有朋友说,Rust 的 PR 每次 Review 周期都很长,就算你能一年提 200 个 PR,官方也不可能给你合并那么多。

这样的计算,确实很有道理。这个目标,确实很难完成。但其实这个 Flag 我并没有打算个人完成,而是想推动社区对 Rust 感兴趣对朋友一起完成。如果五年内,我能推动 1000 个人参与,那么每个人只提交一个 PR,那么这个 1000 个 PR 的 Flag 就轻松完成了。

所以,为了完成这个 Flag ,我把未来五年划分成三个阶段:

  1. 第一阶段:2021 年。该阶段的目标是「上道」。
  2. 第二阶段:2022 ~ 2023 年。该阶段的目标是「进阶」。
  3. 第三阶段:2024 ~ 2025 年。该阶段目标是「达标」。

也就是说,今年是想要「上道」的一年。那么要达成这个目标,我做了以下计划:

  1. 组织社区力量来翻译官方的《Rust 编译器开发指南》。
  2. 组织 Rust 编译器小组,开始为 Rust 语言做点贡献,并且将在此过程中自己的学习和经验沉淀为《图解 Rust 编译器与语言设计》系列文章。

通过这两份文档,希望可以帮助和影响到更多的人,来为 Rust 语言做贡献。

我知道,编译器作为程序员的三大浪漫之一,水很深。你也可能会说,人家搞编译器的都是 PL 出生,一般人哪有那种本事。诚然如你所想,编译器很难。但幸亏,难不等于不可能。不会,我们可以学。况且,也不是让你从零开始去实现一个 Rust 编译器。

为 Rust 语言做贡献,并不是 KPI 驱动,而是兴趣驱动。可能你看完了编译原理龙书虎书鲸书三大经典,也可能你实现过自己的一门语言。但其收获可能永远也比不上实际参与到 Rust 这样一个现代化语言项目中来。

所以,《图解 Rust 编译器与语言设计》系列文章,不仅仅会记录我自己学习 Rust 编译器的沉淀,还会记录你的沉淀,如果你愿意投稿的话。在这浮躁的世界,给自己一片净土,找回技术初心。


图解 Rust 编译过程

对于学习,我通常习惯先从整体和外围下手,去了解一个东西的全貌和结构之后,再逐步深入细节。否则的话,很容易迷失到细节中。

所以,必须先来了解 Rust 编译过程。如下图:

rust-complie-process

上图中间部分为 Rust 代码的整体编译过程,左右两边分别为过程宏和声明宏的解释过程。

Rust 语言是基于 LLVM 后端实现的编程语言。在编译器层面来说,Rust编译器仅仅是一个编译器前端,它负责从文本代码一步步编译到LLVM中间码(LLVM IR),然后再交给LLVM来最终编译生成机器码,所以LLVM就是编译后端。

Rust 语言编译整体流程

  1. Rust 文本代码首先要经过「词法分析」阶段。

将文本语法中的元素,识别为对 Rust 编译器有意义的「词条」,即token

  1. 经过词法分析之后,再通过语法分析将词条流转成「抽象语法树(AST)」。

  2. 在得到 AST 之后,Rust 编译器会对其进行「语义分析」。

一般来说,语义分析是为了检查源程序是否符合语言的定义。在 Rust 中,语义分析阶段将会持续在两个中间码层级中进行。

  1. 语义分析 HIR 阶段。

HIR 是抽象语法树(AST)对编译器更友好的表示形式,很多 Rust 语法糖在这一阶段,已经被脱糖(desugared)处理。比如 for 循环在这个阶段会被转为loopif let 被转为match,等等。HIR 相对于 AST 更有利于编译器的分析工作,它主要被用于 「类型检查(type check)、推断(type inference)」。

  1. 语义分析 MIR 阶段。

MIR 是 Rust 代码的中级中间代表,基于 HIR 进一步简化构建。MIR 是在RFC 1211中引入的。

MIR 主要用于借用检查。早期在没有 MIR 的时候,借用检查是在 HIR 阶段来做的,所以主要问题就是生命周期检查的粒度太粗,只能根据词法作用域来进行判断,导致很多正常代码因为粗粒度的借用检查而无法通过编译。Rust 2018 edition 中引入的 非词法作用域生命周期(NLL)就是为来解决这个问题,让借用检查更加精细。NLL 就是因为 MIR 的引入,将借用检查下放到 MIR 而出现的一个术语,这个术语随着 Rust 的发展终将消失。

MIR 这一层其实担负的工作很多,除了借用检查,还有代码优化、增量编译、Unsafe 代码中 UB 检查、生成LLVM IR等等。关于 MIR 还需要了解它的三个关键特性:

  • 它是基于控制流图(编译原理:Control Flow Graph)的。
  • 它没有嵌套表达式。
  • MIR 中的所有类型都是完全明确的,不存在隐性表达。人类也可读,所以在 Rust 学习过程中,可以通过查看 MIR 来了解 Rust 代码的一些行为。
  1. 图中没有画出来的,还有一个从 HIR 到 MIR 的一个过渡中间代码表示 THIR(Typed HIR) 。THIR 是对 HIR 的进一步降级简化,用于更方便地构建 MIR 。在源码层级中,它属于 MIR 的一部分。

  2. 生成 LLVM IR 阶段。LLVM IRLLVM中间语言。LLVM会对LLVM IR进行优化,再生成为机器码。

后端为什么要用 LLVM ?不仅仅是 Rust 使用 LLVM,还有很多其他语言也使用它,比如 Swift 等。 LLVM 的优点:

  • LLVM后端支持的平台很多,我们不需要担心CPU、操作系统的问题(运行库除外)。
  • LLVM后端的优化水平较高,我们只需要将代码编译成LLVM IR,就可以由LLVM后端作相应的优化。
  • LLVM IR本身比较贴近汇编语言,同时也提供了许多ABI层面的定制化功能。

Rust 核心团队也会帮忙维护 LLVM,发现了 Bug 也会提交补丁。虽然LLVM有这么多优点,但它也有一些缺点,比如编译比较慢。所以,Rust 团队在去年引入了新的后端 Cranelift ,用于加速 Debug 模式的编译。Rust 编译器内部组件 rustc_codegen_ssa 会生成后端无关的中间表示,然后由 Cranelift 来处理。从2021年1月开始,通过rustc_codegen_ssa 又为所有后端提供了一个抽象接口以实现,以允许其他代码源后端(例如 Cranelift),这意味着,Rust 语言将来可以接入多个编译后端(如果有的话)。

以上是 Rust 整体编译流程。但 Rust 语言还包含来强大的元编程:「宏(Macro)」,宏代码是如何在编译期展开的呢?请继续往下看。

Rust 宏展开

Rust 本质上存在两类宏:声明宏(Declarative Macros)过程宏(Procedural Macros) 。很多人可能搞不清楚它们的差异,也许看完这部分内容就懂了。

声明宏

回头再看看上面的图右侧部分。我们知道,Rust 在最初解析文本代码都时候会将代码进行词法分析生成词条流(TokenStream)。在这个过程中,如果遇到了宏代码(不管是声明宏还是过程宏),则会使用专门的「宏解释器(Macro Parser)」 来解析宏代码,将宏代码展开为 TokenStream,然后再合并到普通文本代码生成的 TokenSteam 中。

你可能会有疑问,其他语言的宏都是直接操作 AST ,为什么 Rust 的宏在 Token 层面来处理呢?

这是因为 Rust 语言还在高速迭代期,内部 AST 变动非常频繁,所以无法直接暴露 AST API 供开发者使用。而词法分析相对而言很稳定,所以目前 Rust 宏机制都是基于词条流来完成的。

那么声明宏,就是完全基于词条流(TokenStream)。声明宏的展开过程,其实就是根据指定的匹配规则(类似于正则表达式),将匹配的 Token 替换为指定的 Token 从而达到代码生成的目的。因为仅仅是 Token 的替换(这种替换依然比 C 语言里的那种宏强大),所以你无法在这个过程中进行各种类型计算。

过程宏

声明宏非常方便,但因为它只能做到替换,所以还是非常有局限的。所以后来 Rust 引入了过程宏。过程宏允许你在宏展开过程中进行任意计算。但我们不是说,Rust 没有暴露 AST API 吗?为什么过程宏可以做到这么强大?

其实,过程宏也是基于 TokenSteam API的,只不过由第三方库作者 dtolnay 设计了一套语言外的 AST ,经过这一层 AST 的操作,就实现了想要的结果。

没有什么问题不是可以通过加一层解决的,如果解决不了那就加两层。

dtolnay 在社区内被誉为最佳 API 设计天才。他创造了不少库,比如 Serde,是 Rust 生态中被应用最多的一个库。

话说回来。过程宏的工作机制就如上面图中左侧展示的那样。主要是利用三个库,我称之为 「过程宏三件套」:

  1. proc_macro2。该库是对 proc_macro 的封装,是由 Rust 官方提供的。
  2. syn。该库是 dtolnay 实现的,基于 proc_macro2 中暴露的 TokenStream API 来生成 AST 。该库提供来方便的 AST 操作接口。
  3. quote。该库配合 syn,将 AST 转回 TokenSteam,回归到普通文本代码生成的 TokenSteam 中。

过程宏的整个过程,就像是水的生态循环。 蒸汽从大海(TokenSteam)中来,然后通过大雨(Syn),降到地上(Quote),形成涓涓细流(proc_macro2::TokenStream)最终汇入大海(TokenSteam)。

理解过程宏的展开原理,将有助于你学习过程宏。

小结

本篇文章主要介绍了 Rust 代码的编译过程,以及 Rust 宏代码的展开机制,学习这些内容,将有助于你深入理解 Rust 的概念。不知道这篇内容是否激发起你对 Rust 编译器对兴趣呢?编译器是一个深坑,让我们慢慢挖掘它。

感谢阅读。

二月刊

logo

发刊通告

本月社区动态简报

精选自《Rust日报》

Rust 问答精选

Rust in Production

学习园地

WASM 专题

游戏专题

操作系统与网络编程专题

Rust 编译器专题

二月发刊通告

时光易逝,转眼二月即将过去,春暖花开的三月即将到来。过年的余味犹在,但我们不得不继续踏上征途。

《 RustMagazine 中文精选 》2021 年第二期发布了,后续也期待大家投稿。

本刊 mdbook 模版功能改进

mdbook 模版功能新增:

  • 增加评论功能。评论会自动同步到 RustMagazine GitHub 仓库 与文章同名的 issues 下(文章下有评论就自动创建)。
  • 增加画图功能。利用 mermaid 来画图。参考:mermaid 在线使用指南

画图示例:

graph TD
    A[RustMagazine] -->|每月最后一天| B(发刊)
    B --> C{阅读渠道}
    C --> |GitHub Page| D[GitHub]
    C -->|Rustcc| E[Rust中文论坛/公众号]
    C -->|Rust视界| F[Telegram]
    C -->|掘金| G[技术社区]
    C -->|语雀| H[在线文档]

欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

上期(一月刊)访问数据统计小结

浏览量:

  • 网页浏览量 :3,678
  • 唯一身份浏览量 :2,889

读者访问最多时段:

  • 每天上午 8点 到 下午 6点。
  • 周四 和 周五 阅读量相对更多。

读者分布地区排名:

  • 中国
  • 北美(美国/加拿大)
  • 澳洲

一月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《图解 Rust 所有权》,作者:肖猛
  • 《用 Rust 写操作系统 | rCore 教程介绍》,作者:清华大学
  • 《RustChinaConf2020 精选 | Rust 异步开发》,作者:赖智超
  • 《关于 io_uring 与 Rust 的思考》,作者:王徐旸
  • 《图解 Rust 编译器 | Part 1》,作者:张汉东

阅读量最低为:

  • 《Rust 生态安全漏洞总结系列 | Part 1》,作者:张汉东
  • 《Rustc Dev Guide 中文翻译启动》,作者:张汉东

简报关注分类依次为:

  • Rust 官方动态
  • 学习资源
  • 推荐项目
  • 社区热点
  • Rust 唠嗑室

读者阅读渠道依次为:

  • 直接访问
  • GitHub
  • 百度
  • ⾕歌
  • rustcc
  • 其他

本月简报 | Rust官方动态

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:张汉东

官宣,Rust基金会正式成立!

基金会初创白金成员包括:

AWS,Google, HUAWEI(华为), Microsoft, Mozilla

官网地址:https://foundation.rust-lang.org/

相关阅读:

Rust 1.50 稳定版发布

关于 Rust 1.50 详细解读,请跳转自 解读 Rust 1.50 稳定版 一文阅读。

Rust语言团队二月份第一次会议

Rust 语言团队2月3号第一次召开了规划会议,并总结了会议纪要。从今以后,语言团队计划每个月的第一个星期三举行这样的会议。

举行规划会议的目的:检查我们正在进行的项目的状态,计划本月剩余时间的design meeting。

本次会议的主要内容:

  1. async foundations: 异步基础

continued progress on polish, new traits (继续改进优化新的trait)

making plans to stabilize async functions in traits (制定稳定Trait中async函数的规划)

working on a vision document that lays out a multi-year vision for how async I/O should look/feel in Rust (编写一份愿景文档规划未来几年Rust 异步IO的愿景)

  1. const generics 常量泛型

  2. rfc 2229 ("minimal closure capture") continued progress on the implementation, things are going well

we will likely add a capture! macro to use for migration; it would force the capture of a particular local variable (and not some subpath of it)

链接:https://blog.rust-lang.org/inside-rust/2021/02/03/lang-team-feb-update.html

会议纪要:https://github.com/rust-lang/lang-team/blob/master/design-meeting-minutes/2021-02-03-Planning-Meeting.md#project-updates-and-discussion

关于 Const Generics MVP 你需要知道的

自从最初的 const 泛型 RFC 被接受以来已有3年多的时间了,Rust beta 现已提供 const 泛型的第一个版本! 它将在1.51 版本中提供,该版本预计将于2021年3月25日发布。Const泛型是Rust最受期待的功能之一。

什么是常量泛型

常量泛型功能在 解读 Rust 1.50 稳定版 一文中也有介绍。

一个典型的示例:


#![allow(unused)]
fn main() {
struct ArrayPair<T, const N: usize> {
    left: [T; N],
    right: [T; N],
}

impl<T: Debug, const N: usize> Debug for ArrayPair<T, N> {
    // ...
}
}

其中,[T; N]就是常量泛型的应用。

即将在 1.51 稳定版发布的 const 泛型是一个受限制的版本,换句话说,此版本是 const 泛型的 MVP(最小可行产品)版本。因为做一个通用版本的 const 泛型十分复杂,目前还在完善中。

MVP 版本限制如下:

  1. 目前唯一可以用作 const 泛型参数类型的类型是整数(即有符号和无符号整数,包括isizeusize)以及charbool的类型。 这已经可以涵盖 const 泛型的主要用例,即对数组进行抽象。 将来会取消此限制,以允许使用更复杂的类型,例如&str和 用户定义的类型。

  2. const 参数中不能有复杂的泛型表达式。当前,只能通过以下形式的 const 参数实例化 const 参数:

    • 一个独立的常量参数。
    • 一个字面量。
    • 一个没有泛型参数的具体常量表达式(用{}括起来)。 示例:
    
    #![allow(unused)]
    fn main() {
    fn foo<const N: usize>() {}
    
    fn bar<T, const M: usize>() {
        foo::<M>(); // ok: `M` 是常量参数
        foo::<2021>(); // ok: `2021` 是字面量
        foo::<{20 * 100 + 20 * 10 + 1}>(); // ok: 常量表达式不包括泛型
        
        foo::<{ M + 1 }>(); // error: 常量表达式包括泛型参数 `M`
        foo::<{ std::mem::size_of::<T>() }>(); // error: 常量表达式包括泛型参数 `T`
        
        let _: [u8; M]; // ok: `M` 是常量参数
        let _: [u8; std::mem::size_of::<T>()]; // error: 常量表达式包括泛型参数 `T`
    }
    }
    

标准库内部利用常量泛型的改进

伴随常量泛型在 1.51 稳定的还有 array::IntoIter ,它允许通过值而不是通过引用来迭代数组,从而解决了一个重大缺陷。 尽管仍然存在必须解决的向后兼容性问题,但仍在继续讨论是否可以直接为数组实现IntoIterator的可能性。 IntoIter::new是一种临时解决方案,可大大简化数组的处理。

还有很多 API 在基于常量泛型改进,但还不会在 1.51 中稳定。


#![allow(unused)]
fn main() {
use std::array;
fn needs_vec(v: Vec<i32>) {
   // ...
}

let arr = [vec![0, 1], vec![1, 2, 3], vec![3]];
for elem in array::IntoIter::new(arr) {
   needs_vec(elem);
}
}

未来计划

  1. 解决默认参数和常量泛型位置冲突的问题。

Rust 目前的泛型参数必须按特定顺序排列:生命周期(lifetime),类型(type),常量(const)。 但是,这会在尝试将默认参数与const参数一起使用时造成困难。为了使编译器知道哪个泛型参数,任何默认参数都必须放在最后。 接下来将解决这个问题。

  1. 为自定义类型支持常量泛型

从理论上讲,要使一个类型有效作为const参数的类型,我们必须能够在编译时比较该类型的值。所以在 const泛型 RFC 中引入了结构相等的概念:本质上,它包括任何带有#[derive(PartialEq,Eq)]且其成员也满足结构相等的类型。

  1. 为复杂类型支持常量泛型

Nightly Rust 提供了一个feature(const_evaluatable_checked),该特性门启用了对 const 泛型的复杂表达式支持。

目前的困难:


#![allow(unused)]
fn main() {
// 下面代码中两个表达式中的`N+1`是不同的,如果需要将它们看作相同,则需要检查的方法。这是面对复杂表达式中的一个难点。
fn foo<const N: usize>() -> [u8; N + 1] {
   [0; N + 1]
}

// 还需要处理常量泛型操作中存在的潜在错误的方法
// 如果没有办法在此处限制M的可能值,则在计算`0-1`时(在声明时未捕获),调用`generic_function::<0>()`会导致错误,因此对于下游用户可能会意外失败。
fn split_first<T, const N: usize>(arr: [T; N]) -> (T, [T; N - 1]) {
   // ...
}

fn generic_function<const M: usize>(arr: [i32; M]) {
   // ...
   let (head, tail) = split_first(arr);
   // ...
}

}

原文: https://blog.rust-lang.org/2021/02/26/const-generics-mvp-beta

Rust 错误处理工作组计划将Error trait迁移至 core 模块

如果迁移之后,在no_std模式下也可以使用Error trait了。

链接:https://github.com/rust-lang/rust/pull/77384#issuecomment-772835929

本月简报 |社区热点

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:张汉东

CURL 支持 RUSTLS

Rustls 是一个用 Rust 写的现代 TLS(安全传输协议) 库。目前已经被纳入了为 CURL 的一个 backend

curl 对以下这些 features 都有一些可替换的 backends :

  • International Domain Names
  • Name resolving
  • TLS
  • SSH
  • HTTP/3
  • HTTP content encoding
  • HTTP

https://daniel.haxx.se/blog/2021/02/09/curl-supports-rustls/

Rust 全栈框架 MoonZoon 计划

这是 Seed 作者新开的一个项目,目标是做一个纯 Rust 的全栈框架。

  • NO Javascript
  • NO CSS
  • NO HTML
  • NO REST
  • NO GraphQL
  • NO SQL
  • NO Analysis Paralysis
  • NO Wheel Reinventing
  • NO Passwords*

目标比较大,目前是草案阶段,感兴趣的可以关注参与。

个人看法:Rust 其实并不需要全栈框架。对于上面的一堆 NO XXX ,个人理解应该是指这个框架不太限定用户去使用什么,想用啥可以用啥,给予最大自由。

VSCode 修补了关于 Rust 工作流中的一个怪异的 bug

最新的VSCode版本中有一个 PR,以防止提示弹出窗口过度滚动。 以前你将鼠标悬停在符号上来阅读相应文档,如果继续向下滚动至底部,则滚动将继续并将从文档窗口弹出。 现在,此问题已得到解决。🎉

https://www.reddit.com/r/rust/comments/lgccv5/ysk_vscodes_most_recent_update_fixed_a_quirk_in/

Google资助项目以使用新的Rust组件保护Apache Web服务器的安全

根据ZDNet报道,由Google资助并由Internet Security Research Group领导的Apache Web服务器将设置为接收新的基于Rust的mod_ssl模块(以将Apache HTTP Web服务器项目的关键组件从容易出错的C编程语言移植到一种更安全的替代品Rust中),该模块将基于 Rustls ; 开发了Rust开源库,以 替代基于C的OpenSSL项目。

阅读原文: https://www.zdnet.com/article/google-funds-project-to-secure-apache-web-server-project-with-new-rust-component/

rust-analyzer 内部体系结构文档更新!

rust-analyzer是一个用于IDE的实验性Rust编译器前端。

阅读原文: https://github.com/rust-analyzer/rust-analyzer/blob/master/docs/dev/architecture.md

微软的Rust课程将在下月开课

据几天前的消息微软正在组建一支Rust团队。现在,微软 Reactor 将在3月份将举办两次Rust课程,以下是课程预告。

课前准备:

不需要具有 Rust 经验,但是如果您有使用其他编程语言的经验会更佳。

适合人群:

该研讨会面向想要学习 Rust 的开发人员。不需要具有 Rust 经验,不过如果您有使用其他编程语言的经验会帮助你更快的学习 Rust 语言。

参与本次分享,你将收获:

如果您想更熟悉更多的 Rust 相关知识,包括:变量,数据类型,函数,集合类型和控制流,则应该参加此研讨会。

主办方:

微软 Reactor 上海 是微软为构建开发者社区而提供的一个社区空间。

原文:https://mp.weixin.qq.com/s/TS3R8MNF_t09HmYNHMMHTg

CoreOS 的rpm-ostree用Rust重写部分功能

rpm-ostree 是一个CoreOS上的包管理器,最近使用Rust重写部分功能。该团队说更多氧化项目(比如/etc/{passwd,group})正在进行中。

链接:https://github.com/coreos/rpm-ostree/releases/tag/v2021.2

《Rust用于web开发的2年后感悟》

原文地址:https://kerkour.com/blog/rust-for-web-development-2-years-later/

大约2年前,我开始使用Rust开发Web服务(JSON API),我认为是时候可以摆脱先入为主的观念并分享我学到的知识了。

偏见:

  • Rust代码很丑陋:Rust是显式的。不可否认。但是,当我编写代码时,我的IDE可以帮到我很多,而不必按下那么多键。当我阅读代码时,这种明确性真是太棒了!没有隐藏的惊喜,没有奇怪的事情。
  • 内存管理令人分心:实际上呢,没有。我没有使用那么多的词法生命周期,而是使用了智能指针。是的,因此我理解了Box,Rc和Arc之间的差异,与之同时和Node.JS、Golang语言相比,我的生产率没有因此受到影响。
  • 编译器很麻烦:一开始是的。但是几个月后,我能够立即理解错误,并能立刻解决这些错误。今天,我真的没有花太多时间在编译器上。相反,它成为了我最好的朋友,尤其是在重构大部分代码或升级依赖项时。
  • 缓慢的编译时间:我给这个说明。在Node.JS或Golang中,一个中等大小的服务的Docker image大约需要3到10分钟来构建和部署,在Rust中大约需要30分钟。
  • 生态系统还不存在:不可否认,的确是这样。缺少一些组件,例如官方的Stripe和AWS开发工具包,但是社区确实很活跃,并构建了所有这些缺少的组件。

我特别值得点赞的几件事

  • 静态链接非常简单:创建小的Docker images 一件令人愉快的事情。。
  • Rust会让你成为一个更好的程序员:Rust很复杂,如果你不了解它的详细工作原理,它不会放过你。掌握它需要时间和耐心,但是一旦你这样做了,你就会学到很多你永远不会像以前那样接近编程的东西。在学习Tokio的工作原理时,我了解了Golang的运行时是如何工作的。(心智模型学习)
  • 一旦它编译,通常它就可以正常工作:这是关于Rust我最喜欢的地方。当我的程序编译时,它按我的计划工作。注意:只要记住不要阻塞事件循环,编译器就会处理剩下的事情。您不再需要花时间为语言的怪癖编写测试。
  • Rust具有很高的生产力:由于Rust是多种范式,因此在编写复杂的业务逻辑时,由于其功能方面,它的确非常出色。

当前我正在使用的一些crates

  • actix-web 用于HTTP层.
  • sqlx 用于数据库PostgreSQL.
  • rusoto AWS接口服务(S3、SQS、SES)
  • tera 用于电子邮件模板
  • thiserror 用于错误类型处理
  • sentry 用于错误监控

结论

Rust非常适合用于web开发,在此我强烈建议尝试一下。

取得成功是一次漫长的旅程,但完全值得,即使您不是每天都在使用它,也一定会通过学习它而成为一名更好的程序员,如果失去了,那就重新去发现编程的乐趣🤗。

一句话总结:Rust生而平静。凌晨3点不再有不好的惊喜,因为依赖项更新了它的API使得不再有bug。没有更多恼人的配置自动缩放或什么。而且响应时间非常短,您的用户因此会爱上您的产品。

本月简报 | 推荐项目

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:杨楚天(yct21)

Rust-SQLite

Rust-SQLite (SQLRite) 是一个 SQLite clone。SQLRite 有很完善的文档,代码质量非常高,而且有非常丰富的单元测试用例。

Tauri

Tauri 是一个桌面应用开发框架,包含了 JavaScript API,可以结合各种主流前端框架进行开发。

有 Twitter 网友分享, 他把自己的 Electron 写的应用迁移至 Rust 的 Tauri, 内存使用从 300M 降低至 6M,二进制大小从 195M 降至 7M。

RustPython

RustPython 是用 Rust 实现的 Python 3(CPython >= 3.8.0) 解释器。 RustPython 可以将 Python 嵌入到 Rust 程序中;也可以将 RustPython 编译为 WebAssembly,这样开发者可以在浏览器中运行其 Python 代码。此外,RustPython 也包含一个实验性的 JIT 编译器。

Thirtyfour

Thirtyfour 是一个 Selenium WebDriver 客户端,可以用于自动化 UI 测试。Thirtyfour 完全支持 W2C WebDriver spec,可以搭配 tokio 或者 async-std 使用。

Lunatic

Lunatic 是一个服务端的 WebAssembly 运行时,有以下特点:

  • 受到 Erlang 的启发,有一个抢占式调度的运行时, 生成占用资源极少的用户态线程。
  • 借助 wasm 虚拟机,保证隔离和安全性。
  • 会在未来完全兼容 WASI

Postage

Postage 是一个异步通道库,提供了丰富的通道集,并在 Sink/Stream 上有很多实用的组合子,方便了异步程序的开发。

作者同时也是 tab 的作者。

RustSBI

RustSBI 是洛佳老师开发的一个 RISC-V SBI 实现,支持常见的硬件核心和模拟器,能够引导启动符合 RISC-V SBI 标准的操作系统,包括 Linux、rCore 等。

Similar

similar 是一个现代化的 diff 库,借鉴了 pijul 实现的耐心排序算法,并结合了 Myer 的 diff 算法。

tantivy

tantivy 是一个全文搜索引擎库, 类似于 Apache Lucene。

xh

xh 是一个 Httpie clone。

ht

meio

meio 是一个异步 actor 框架,其设计受 Erlang/OTP 启发,并可以很好地结合 rust 中的异步生态系统使用。作者正在尝试使其能 WebAssembly 兼容。

message-io

message-io 是一个是事件驱动的消息库,可轻松快速地构建网络应用程序。message-io 可以管理和处理套接字数据流,以便向用户提供简单的事件消息 API。作为通用网络管理器,它允许你遵循一些规则来实现自己的协议,而繁琐的异步和线程管理则由 message-io 帮你管理。

Cranelift

Cranelift 是用 Rust 编程语言实现的代码生成器,旨在成为快速的代码生成器,其输出以合理速度运行的机器代码。 如今,它被用于包括 Wasmtime 和 Wasmer 在内的几种不同的 WebAssembly 运行时中,并且还可以作为 Rust 调试编译的替代后端。

Voyager

voyager 是一个用 Rust 实现的爬虫库。

Starlight

Starlight 是一个 JavaScript 的运行时,其设计重点放在运行速度上,已经通过了 2k+test262 测试。Starlight 比 Boa(另一个Rust写的JS引擎)更快,其目标是和V8一样快。

Lettre

Lettre 是一个可以用于发送 email 的库。

Optic:使用实际流量来记录和测试您的API

说明:

  • Optic观察开发流量并了解您的API行为
  • Optic通过将流量与当前规范相区别来检测API更改
  • Optic为每个拉取请求添加准确的API更改日志

Rust Web 模板项目

前些日子 Rust 不适合 Web 一文引起了热议,今天就有热心群友推荐了一个 Rust Web 模板项目:

  • 使用 .env 文件管理环境变量
  • 使用 diesel 来处理数据库迁移
  • 配合 cargo-watch 监控开发时程序修改,方便调试
  • 支持 cargo-tarpaulin 做测试覆盖率

termchat:一个终端聊天软件

最近Clubhouse因为Elon Musk突然大火,使用termchat可以在终端进行聊天。

Yatta: 用于 Windows10 的 BSP 平铺窗口管理器

作者最近因为从之前的mac环境由于一些原因需要切换到windows环境下工作,但是没有找到之前使用mac时的桌面分割工具(窗口排放管理工具),于是自己花了几天,研究了不少其它类似的工具,捣鼓出了这个。

nlprule,Rust 实现的 NLP 库

nlprule 使用 LanguageTool 中的资源为NLP实现了基于规则和查找的方法。

firestorm: 代码分析器

作者扎克·伯恩斯发布了这款侵入式代码分析器。“火旋风”分析器能帮助代码作者测试Rust代码的性能;它能分析项目中的时间敏感部分,输出到时间轴图、合并的火焰图或其它的表现形式。这是一款侵入式分析器,也就意味着在代码编写的过程中,用户就需要使用分析器提供的宏,帮助分析器的记录过程。项目文档指出,这款分析器能通过编译特性来启用或禁用;未被启用时,所有的记录操作都被编译为空操作,这将不会影响生产程序的运行性能。

我们常用的性能分析器,常常基于系统提供的“perf”指令,它就像是一个调试器,在合适的时候暂停进程,读取此时所有的线程和有关信息,从间隔的采样过程记录,从而得到运行性能输出。这种采样不需要重新添加和编译代码,但较可能漏掉时间短的函数。合理使用侵入式代码分析器,可以精细记录运行性能的细节,也能更少地影响待测程序的运行性能。

friestorm 分析器已经在GitHub上开源,并配有丰富的使用文档。

rkyv 0.4:共享指针和自定义序列化程序

大家好,大约又工作了一个月,RKYV0.4终于推出了新特性和重大变化。

如果你还没听说过的话,rkyv是一个针对Rust的零拷贝反序列化框架,类似于Cap'n Proto和FlatBuffers。它主要是为游戏开发而构建的,但也适用于广泛的其他应用程序。

文章链接,https://www.reddit.com/r/rust/comments/lniraj/rkyv_04_shared_pointers_and_custom_serializers/

rg3d 游戏引擎

在过去的三个月中,rg3d 和 rusty-editor取得了很多重要的功能和改进。并开始使用引擎制作了新游戏,Station lapetus,一款 Sci-Fi 3D射击游戏。

近3个月的进展报告: https://rg3d.rs/general/2021/02/26/progress.html

LAM: Actor模式的VM

LAM,针对 WebAssembly和 Native 的 Actor VM。

访谈链接: https://notamonadtutorial.com/lam-an-actor-model-vm-for-webassembly-and-native-d7939362e1b8

项目链接: https://abstractmachines.dev/

本月简报 | 学习资源

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:苏胤榕(DaviRain)

使用 Rust 创建一个模拟器

这是 Learning to Fly: Let's create a simulation in Rust!。

在这一系列的文章中,作者会从头到尾带领大家使用 Rust 实现一个基本 feed-forward 的神经网络。

链接

使用Rust和WebAssembly创建爆炸性的Markdown编辑器

摘录: 让我们快速准备WebAssembly的开发环境

Rust通常cargo使用命令构建,但是WebAssembly有一个叫做wasm-pack的工具,它可以很方便地完成很多事情,所以让我们安装它。

链接

Improving texture atlas allocation in WebRender

作者花费大量篇幅解读了如何改进WebRender中Texture atlas分配的问题。

链接

新书:《Black Hat Rust》

《Black Hat Rust》是一本基于Rust编程语言深入研究攻击性、安全性的书。最终出版预计2021年7月,书篇预估320页。如果你是一名安全的从业者,应该会对此书非常感兴趣。

链接

Emacs 配置 Rust 开发环境

喜欢使用 Emacs 的小伙伴如果想开发 Rust,可以参考这篇文章进行详细的设置。

链接

Rust 知识精炼

该文是作者将自己的所学的 Rust 知识整理到这里,感兴趣的同学的可以看一下。

链接

exercism[.]io:一个在线编程训练的平台

exercism[.]io 是一个在线编程训练平台支持Rust语言。

链接

【视频】1Password 开发者炉边谈话:介绍 Rust 宏

视频链接

比较 Rust async 与 Linux 线程上下文切换时间

作者写了一些代码,试图比较 Linux 线程上下文切换所需时间和Rust async任务调度切换所需时间及其各自在使用时的内存使用总量,并且还做出了总结。

Github

使用 Tokio 直接构建 Actors

本文使用Tokio直接构建 Actors,而不是使用任何现有的actor库。

感兴趣的同学可以阅读一下。

链接

Rust 从零到生产: 可维护的测试套件的骨架和原则

这是 <<Rust 从零到生产>> 系列的第七章 part 1.

该章节主要侧重于测试,整个书基本上都是使用 test-driven的方式来编写新的功能。当代码变的庞大之后,一个良好的测试框架可以更好的支撑更复杂的特性和日渐增多的测试用例。

链接

For the Love of Macros

宏是一种超越 more power的存在,他赋予了我们超越源代码的抽象能力,但是,同时,你也会放弃表层语法。例如,在一个拥有强大的宏的语言中,重命名基本上是不太可能 100% 工作的。

本文尽力探索Rust中宏的使用方式, 目的是为了找到一种不放弃源代码推断的解决方案。

链接

使用Rust从零重写一个SQLite

作者计划使用Rust重新复制一个SQLite数据库,目前正在进行中。

SQLite有很完善的文档,代码质量非常高,而且有非常丰富的单元测试用例,终于有人尝试使用Rust重写一个SQLite了,感兴趣的朋友可以一起参与!

Github

链接

微软的员工发布的Windows用户Rust视频

主要介绍怎样在Windows平台使用windows-rs这个crate构建Rust程序。

链接

如何使用 webassembly 构建一个 telnet 聊天服务器

相信有大批的人喜欢 terminals这种审美, 作者也是其中之一。

作者使用 webassembly + Rust 构建了一个 telnet 聊天服务器。 你可以使用下面的命令来尝试一下。

# US
> telnet lunatic.chat
# EU
> telnet eu.lunatic.chat

链接

EasyRust 现在有视频了

EasyRust 是一个非常好的 Rust 入门教程,现在,他不仅有文档,还有视频了。

下面是第一期视频,未来至少还有 70 期。想学习的小伙伴可以跟着视频了解一下。

EasyRust地址

油管视频

经典 Rust 面试题六道

在电报群由 @wayslog 提出的六道面试题目,wayslog 老师称之为“经典六道”:

  1. RwLock 对想要在多线程下正确使用,T的约束是?

  2. 如下代码:

    trait A{ fn foo(&self) -> Self; }
    Box<Vec<dyn A>>;
    

    是否可以通过编译?为什么?

  3. Clone与 Copy 的区别是什么?

  4. deref 的被调用过程?

  5. Rust里如何实现在函数入口和出口自动打印一行日志?

  6. Box<dyn (Fn() + Send +'static)> 是什么意思?

@wayslog 提供的答案:

  1. The type parameter T represents the data that this lock protects. It is required that T satisfies Send to be shared across threads and Sync to allow concurrent access through readers。
  2. 不可以,参考object safe 三条规则。
  3. Copy是marker trait,告诉编译器需要move的时候copy。Clone表示拷贝语义,有函数体。不正确的实现Clone可能会导致Copy出BUG。
  4. Deref 是一个trait,由于rust在调用的时候会自动加入正确数量的 * 表示解引用。则,即使你不加入*也能调用到Deref。
  5. 调用处宏调用、声明时用宏声明包裹、proc_macro包裹函数、邪道一点用compiler plugin、llvm插桩等形式进行。(Go:我用snippet也行)
  6. 一个可以被Send到其他线程里的没有参数和返回值的callable对象,即 Closure,同时是 ownershiped,带有static的生命周期,也就说明没有对上下文的引用。

读者们又会几道呢~

讨论链接

Rust for web development

本篇blog作者是今年七月要出的rust新书Black Hat Rust的作者,在两年前作者就已经开始尝试用Rust去进行web开发,这篇blog谈的是他开发的一些感受,一些经验,同时提到了他开发中用到了哪些crate。

链接

笨方法学习Rust所有权机制

为了真正了解Rust,我们需要了解其关键的区别于其它语言的特性: 所有权。本篇blog用了笨方法的方式来讲解Rust的所有权。

链接

好文推荐:《Rust和LoRa》

Drogue IoT 是一个试图将可重用和高效的组件引入嵌入式Rust的团队,本文讲述了“如何在Rust中开始使用LoRa“。

ps: LoRa是一种低功率远程无线协议

阅读原文: https://blog.drogue.io/rust-and-lora/

Repo: https://github.com/drogue-iot/drogue-device

Rust 循环优化

1 2 3

Cranelift 代码生成入门

Cranelift 是用 Rust 编程语言编写的代码生成器,旨在成为快速的代码生成器,其输出以合理速度运行的机器代码。如今,它被用于包括 Wasmtime 和 Wasmer 在内的几种不同的 WebAssembly 运行时中,并且还可以作为 Rust 调试编译的替代后端。

更多见博客原文:https://blog.benj.me/2021/02/17/cranelift-codegen-primer/

Cranelift 仓库地址:https://github.com/bytecodealliance/wasmtime/tree/main/cranelift#cranelift-code-generator

Rtic book

RTIC 框架 是中断驱动的异步实时系统,完全针对应用使用Rust的宏语法生成,拥有极高的效率。

RTIC Book :https://rtic.rs/0.5/book/en/by-example.html

国外 Rust 咨询公司 Ferrous System 的嵌入式课程资料

链接:https://embedded-trainings.ferrous-systems.com/preparations.html

本月简报 | Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 18 期 - 剖析 Rust 的引用

时间: 2021/02/02 20:30-21:30

主讲人:舒乐之(Andy)

一网网络工程师,2018 年开始写 Rust,参与 ImmuxDB 不可变数据库和 ImmuxCompute 计算引擎的设计开发;曾用 C 开发比特币节点 tinybtc;曾任 Matters Lab 首席工程师,Web 前后端都写过。

内容

这次的主要内容,是从零开始,解释 Rust 中「引用」的概念,以及一批与引用相关的概念:地址、指针、借用、切片、智能指针、胖指针、裸指针、所有权、生命周期、作用域等。

还会谈到一些关于 Rust 引用的问题,比如:

  • 生命周期与作用域的关系是什么?
  • 为什么 str 不会单独出现,总是以要靠引用(比如&str)使用?
  • Vec 有一个 into_boxed_slice()方法 —— boxed slice 是什么,与 Vec 有什么区别?
  • RefCellCellUnsafeCell 的区别是什么?什么时候用什么?

查看回放

扩展资料

  • 官方文档

    • https://doc.rust-lang.org/stable/reference/types/pointer.html
    • https://doc.rust-lang.org/stable/reference/types/function-pointer.html
    • https://doc.rust-lang.org/nomicon/ownership.html
    • https://github.com/rust-lang/rfcs/blob/master/text/2094-nll.md
    • http://rust-lang.github.io/rfcs/1558-closure-to-fn-coercion.html
    • https://prev.rust-lang.org/en-US/faq.html#ownership
    • https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
  • 博客

    • http://smallcultfollowing.com/babysteps/blog/2014/05/13/focusing-on-ownership/
    • https://ricardomartins.cc/2016/06/25/interior-mutability-thread-safety
    • https://limpet.net/mbrubeck/2019/02/07/rust-a-unique-perspective.html
    • https://internals.rust-lang.org/t/function-pointers-are-inconsistent-with-other-language-features/12439

Rust 牛年春晚

时间:2021/02/14 16:00 - 24:00

----------> Rust 牛年春晚 <----------

P1【4 点场】 Rust1.50 最新改动讲解

嘉宾:张汉东

张汉东老师以一段 Rust 宏代码开启欢乐的 Rust 牛年春晚。随后汉东老师着重讲解了这次 Rust1.50 版本更新的主要内容。这次更新主要包括: 语言级特性编译器标准库稳定的 APICargo 相关其他兼容性提示几个方面。

查看回放

扩展资料

  1. 暖场代码
macro_rules! m {
    ($($s:stmt)*) => {
        $(
            { stringify!($s); 1 }
        )<<*
    };
}

fn main() {
    print!(
        "{}{}{}",
        m! { return || true },
        m! { (return) || true },
        m! { {return} || true },
    );
}
  1. Rust 1.50 稳定版发布解读
----------> Rust 牛年春晚 <----------

P2【5 点场】 Delay-Timer 分享

嘉宾:炮炮

Delay-Timer 是一个类似于管理周期性任务的库,目前支持同步、异步任务进行周期化交付,支持一些任务在调度过程中动态添加和动态提交任务的操作。炮炮老师分享了开发过程中的心路历程。

查看回放

扩展资料

  1. 暖场代码
fn main() {
    let a = 4;
    println!("{},{}", --a, --a);
}
  1. Delay-Timer
----------> Rust 牛年春晚 <----------

P3【5 点场】Libra 代码分析讲解

嘉宾:Shara

Libra Facebook 开发的一个 Rust 区块链项目,它的使命是为全球数十亿人建立一个简单的全球货币和金融基础设施。Share 老师分享了分析 Libra 代码的思路。

查看回放

扩展资料Libra

----------> Rust 牛年春晚 <----------

P4【6 点场】Rust 开发嵌入式烂苹果

嘉宾:王 Mono

王老师现场撸代码,使用 Rust 一步一步完成开发嵌入式烂苹果。

查看回放

扩展资料

  1. 暖场代码
trait Trait {
    fn f(self);
}

impl<T> Trait for fn(T) {
    fn f(self) {
        print!("1");
    }
}

impl<T> Trait for fn(&T) {
    fn f(self) {
        print!("2");
    }
}

fn main() {
    let a: fn(_) = |_: u8| {};
    let b: fn(_) = |_: &u8| {};
    let c: fn(&_) = |_: &u8| {};
    a.f();
    b.f();
    c.f();
}
  1. Longan 文档
----------> Rust 牛年春晚 <----------

P5【8 点场】来自 go 社区大佬的视角

嘉宾:云喝酒

Go 和 Rust 作为两门新生语言,Go 的开发者人数大约是 Rust 的64倍。几位来自 Go 社区大佬以不同的视角一起聊聊。

查看回放

扩展资料

  1. Cloubhouse
----------> Rust 牛年春晚 <----------

P6【9 点场】程序员的吉他课

嘉宾:MiskoLee

MiskoLee 老师现场教授弹吉他,妥妥的程序员吉他速成班。

查看回放

----------> Rust 牛年春晚 <----------

P7【9 点场】SNMP 项目介绍

嘉宾:Robin

SNMP 是专门设计用于在 IP 网络管理网络节点(服务器、工作站、路由器、交换机及HUBS等)的一种标准协议,它是一种应用层协议。 SNMP 使网络管理员能够管理网络效能,发现并解决网络问题以及规划网络增长。通过 SNMP 接收随机消息(及事件报告)网络管理系统获知网络出现问题。Robin 老师分享 SNMP 在自己工作中实际应用。

查看回放

----------> Rust 牛年春晚 <----------

P8【10 点场】Maya-rs 分享

嘉宾:JungWoo

在 Maya 中运用 Rust 实现噪声效果的案例。原理:使用 Rust 调用 Python API,然后再将结果给到 Python API。

查看回放

扩展资料

  1. Maya PolyNoise
  2. Maya帮助
----------> Rust 牛年春晚 <----------

P9【10 点场】关于数据库研究和开发的一些话

嘉宾:金明剑

金明剑老师结合自己实际经验聊了聊对 Rust 的理解,既有深度又有广度。

查看回放

----------> Rust 牛年春晚 <----------

P10【11 点场】wasm 与 rust 及 vitejs-rs 分享

嘉宾:夏歌&lencx

夏歌老师根据自己整理的 WebAssembly 生态图,对其整体状况进行简单介绍。

Lencx 老师现场演示,通过一个标准的 Vite 脚手架开始项目,集成进 Rust,最后打包生成 Wasm 项目。

查看回放

扩展资料

  1. https://github.com/second-state/tencent-tensorflow-scf
  2. https://mtc.nofwl.com/tech/post/wasm-start.html#rust
  3. https://vitejs.dev/

知乎 Rust 圆桌年话专题问答精选

编辑:张汉东

在牛年春节期间,我在知乎发起 Rust 语言圆桌年话 | 关于 Rust 语言基金会成立,你有什么想说的呢?


关于 Rust 语言基金会成立,你有什么想说的呢?

@韩朴宇:

链接:https://www.zhihu.com/question/443595816/answer/1734191236
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

华为在创始成员中既惊讶又感到正常,因为并没有听说华为在rust项目上的投资(其他4个成员公司存在大量的Rust项目组成员),但是华为也有Rust写的产品,比如StratoVirt。StratoVirt 是华为的企业级Linux操作系统EulerOS的开源版openEuler旗下的一个项目,是一个基于Linux KVM的虚拟机StratoVirt兼容QEMU的QMP API,同时支持x86和鲲鹏arm处理器,并且使用virtio半虚拟化设备接口。除了华为的StratoVirt, 还有一些Rust编写的虚拟机。最早的应该是Google的crosvm (cros是ChromeOS的缩写),这个虚拟机管理器是为了在ChromeOS上运行一个单独的Linux虚拟机而设计的(即Crostini 计划)。

ChromeOS是一个类似于Android的系统,其系统分区是只读的,使用A/B分区的方式无缝升级,并且使用单独的用户数据分区。但是不同于Android高度定制化的用户空间,ChromeOS的用户空间就是用Gentoo Linux的包管理器Portage编译出来的,因此ChromeOS是一个标准的GNU/Linux系统。但是Google认为直接在该系统上运行任意的Linux程序会损害ChromeOS的安全性,因此在ChromeOS上运行一个轻量级虚拟机来运行一个命令行版的ChromeOS, 该系统可以运行LXC容器,默认的容器是Debian。Google认为这样套娃下来,既可以运行普通的Linux程序,又不会产生安全性问题。crosvm的特色是实现了一个基于virtio的Wayland总线,可以将虚拟机的Wayland/Xwayland程序的窗口直接穿过虚拟机的界限绘制到主系统的Wayland合成器上。使用最广的应该是AWS的 firecracker-microvm/firecracker ,AWS已经将其用于生成环境。此外还有Intel的 cloud-hypervisor/cloud-hypervisor,不仅支持x64, 而且像前3者一样也支持ARM64,而且还支持Windows 10。Rust在KVM上的生态离不开rust-vmm项目,该项目提供了对KVM api的绑定,该项目带起了整个Rust虚拟机的生态。

@iyacontrol:

链接:https://www.zhihu.com/question/443595816/answer/1723079060
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

首先恭喜Rust有了好爸爸,而且不止一个。而且可以预见不久的未来,IBM、阿里云、腾讯云等大厂也会加入进来。有了这么多的好爸爸的加持,小伙伴们可以放心大胆地用Rust了,不用再担心Rust被砍掉了。通过基金会的成员来看,除了亲爸爸Mozilla,其他member大多都和云有关系。可以得出两点:Rust 的安全性和不差的性能,适合用来写一些偏底层的软件,比如各种运行时。而且也得到了大家一致的认可。Rust 将在云原生领域大放异彩了。目前来看,很有可能和Golang相互配合,Rust负责底层部分,Go负责中间部分,共同服务上层各种语言的应用。另外,感谢Mozilla的不为五斗米折腰,没有让Rust走了Java的路。如果Rust卖给类似于甲骨文的公司,那么Rust的前景就不好说了。

@最帅的物理课代表:

链接:https://www.zhihu.com/question/443595816/answer/1734618924
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

虽然我是老华为黑粉了,但是其实很开心能看到华为在创始人名单之列。rust语言是很有前途的语言,这几乎是业界共识。华为有自研的容器项目,采用rust语言编写,这是一个很有意义的作品,比hm系列高到不知道哪里去。我们能通过这些看到华为的决心和勇气。同时这也很能带动国内的其他互联网企业,一起为rust投入更多精力,也给全球的rust社区添砖加瓦。我国的互联网发展和欧美一些国家一直都有较大的差距。但是众所周知,我们的传统艺能就是弯道超车。

还有很多回答,可以去知乎查看。


您对 2021 年的 Rust 语言有哪些期待?

@韩朴宇:

链接:https://www.zhihu.com/question/438833112/answer/1673155747
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我在rustbuild(即src/bootstrap)上提过几个pr,因此说几个和rustc相关的(或者说和语言无关的工程问题)。

  1. cranelift以及rustc_codegen_cranelift可以大大加速debug build,test,proc_macro和build.rs的速度,结合jit模式,可以实现以接近cargo check的速度同时检查语法错误,借用检查错误和逻辑错误。目前cg_clif已经进入rust仓库,在SYSV abi,Windows ABI,原子操作,内联汇编,SIMD上还有一些问题。cg_clif是由一位开发者bjorn3单枪匹马写出来的,很厉害。另外新的asm!内联汇编宏不再使用llvm_asm的语法,就是因为有朝一日rustc会集成上全功能的rust编写的后端。由Inline Assembly project group开发

  2. std aware cargo也就是 cargo -Z build-std,这个功能在优化二进制大小上很有用,在操作系统开发上是必需品。由std Aware Cargo working group负责。

  3. core::error::Error, core::io::Errorbacktrace支持这是Error handling project group的工作重点,目前已有demo可用。有了这个wasm,嵌入式和操作系统开发也可以用常用的错误处理库了。

  4. chalk 。trait 系统的改进全靠这个,包括GATtraits working group负责为什么我的期待都有working group,因为这就是rust项目的治理方式,没有working group的东西就肯定是没戏的,至少一年内是如此。比如取一个稳定的abi,作为rust abi和c++ abi的子集和C abi的超集,已经吵了好几年了,估计今年也是没戏。

@Nugine:

链接:https://www.zhihu.com/question/438833112/answer/1672070201
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

min const generics 将于 1.51 稳定,大约3月底,可以解锁一些较为常规的设计方法。

GAT 仍然是我最期待的有生之年的特性,它与 async trait, monad 之类的东西相关,能派生出很多魔法设计。

async-std 1.8tokio 1.0,希望更多常用的库不再犹豫,赶紧1.0。

希望 tracing 加快速度到 0.2,异步上下文追踪就指望它了。

生态中很多常见领域都已经有了至少一两个占主导地位的库,但还需要打磨。希望做到商业级、工业级可用。

希望 2021 Rust 多出一些杀手级产品,最好是国产的。

@dontpanic:

链接:https://www.zhihu.com/question/438833112/answer/1673710125
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

我比较没出息,我只想要糖…… 最想要的几个:

  1. arbitrary_self_types(p.s 这个例子并不是必须使用arbitrary self types,使用 associate function可以有同样的效果,参见评论区)真的好用,已经离不开了。

目前能用做 Self 类型的,只有 self/&self/&mut self/Box<Self>/Rc<Self> 等几个类型。 Arbitrary self types 允许使用任意 Deref 到 Self 的类型用作 self。有什么用呢?比如,我想扩展下面的


#![allow(unused)]
fn main() {
Base:trait Derived {
    fn foobar(&self);
}

struct Base<T: Derived> {
    ext: T,
}

impl<T: Derived> Base<T> {
    fn foo(&self) {
        self.ext.foobar();
    }

    fn bar(&self) {
        println!("bar!");
    }
}

struct DerivedImpl {
    base: Weak<RefCell<Base<DerivedImpl>>>,
}

impl Derived for DerivedImpl {
    fn foobar(&self) {
        self.base.upgrade().unwrap().borrow().bar();
        println!("foobar!");
    }
}
}

这样的实现就会强制 base 必须以使用 Rc 的方式使用,并且要小心多次 BorrowMut(很容易发生,要么就需要 Derived 提供 interior mutability)。或者也可以在 trait Derived 的函数参数里面把 base 传进去,但是有点 verbose。当然也可以说这种设计不够 rust idiomatic...不过有了 Arbitrary self types 之后,世界就清爽了。

首先实现一下deref/deref_mut


#![allow(unused)]
fn main() {
impl<T: Derived + 'static> Deref for Base<T> {
    type Target = T;

    #[inline(always)]
    fn deref(&self) -> &T {
        &self.ext
    }
}

impl<T: Derived + 'static> DerefMut for Base<T> {
    #[inline(always)]
    fn deref_mut(&mut self) -> &mut T {
        &mut self.ext
    }
}
然后 Derived 可以直接:trait Derived : 'static + Sized {
    fn foobar(self: &mut Base<Self>);
}

struct DerivedImpl {
}

impl Derived for DerivedImpl {
    fn foobar(self: &mut Base<Self>) {
        self.bar();  // !!!!!
        println!("foobar!");
    }
}
}

多了 'static + Sized,但也可以接受。

  1. let_chains_2,啥也不说了,羡慕 Swift。

  2. 标准库里面有很多 unstable 的函数,经常会一用上来发现还是 unstable 需要开 feature。自己的项目随便开开倒是无所谓,但生产环境必定要谨慎的多。希望能够尽快 stable,比如 drain_filter。

longfangsong:

链接:https://www.zhihu.com/question/438833112/answer/1674659637
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。    

其他答主说的已经很好了,尤其是语言设计上的(GAT什么的大家都久等了),我再补充几点:

语言设计:

  1. 看Rust Internals的时候看到的一个感觉有点意思的idea:更细粒度的unsafe。
  2. 看到前面有人说的enumerate variant as type,我想要的一个和这个比较像的一个糖是 Typescript 那样的 (untagged) union type,目前我习惯是用enum_dispatch crate来部分模拟这个特性。

工具方面的:

  1. IDE支持,CLion 现在index不了编译时生成的代码(即使开了RA也一样)。vsc可以但是RA有时会莫名其妙地hang住。
  2. 能不能修修cargo[patch] 只认repo的url而不管rev的问题,即cargo#7497
  3. 求编译能再快一点……编译产物能再多复用一点……

社区建设方面:

  1. 现在感觉很多还没有入门rust的人都被它“传言中”的难度吓到了,实际上rust也就是一门普通的语言,可能所有权检查、强制性的线程安全这些特性确实是别的语言没有的,但掌握这些特性其实也不比掌握指针之类的概念困难,还有其实很多看着很长很可怕的写法(Option<Rc<RefCell>>>)虽然第一眼看上去劝退实际上却更合理更可读(分离了是否可空、是否多个所有者、是否可变三个关注点,相比其他某些语言一个指针走天下其实更容易分析)。其实宣传的时候可以更多的去给新人一种rust并不难的印象,可以更好地壮大社区。
  2. 有没有入口可以给rust foundation捐钱啊(x

还有很多回答,可以去知乎查看。


还有很多精彩的问题等待你的探索和回答:

华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景

作者:(俞一峻、Amanieu d'Antras、Nghi D. Q. Bui) / 后期编辑:张汉东


本文结构

  • 可信编程 -- 华为引领 Rust 语言开发的实践和愿景
    • Rust带来的创新
    • Rust在华为的初步推进
    • 华为对 Rust 社区的贡献
    • 配置华为的端到端 Rust 工具链
      • tokei
      • cargo-geiger
    • 通过深度代码学习研究 Rust
    • 结论

Rust带来的创新

StackOverflow 的调查表明, 自 2015 年以来,Rust 一直是开发者最爱的编程语言。

img

学术界对于Rust也越来越重视,在编程语言和软件工程顶会上发表的关于Rust的论文正逐年增加。

img

不仅如此,《自然》杂志2020年尾的文章《Why Scientists are Turning to Rust》中也强调:科学家极为推崇Rust。

img

Rust在华为的初步推进

华为的目标是引领通信系统软件向安全可信演进,其中 Rust 语言正在发挥很大的作用。

例如,我们希望通过部分 C/C++ 代码的迁移,在保证高性能的同时,拥有更高的安全性。在此过程中, 我们为开发者提供一套自动化工具支持:基于开源的 C2Rust 转译工具, 首先从 C 代码生成 Rust 代码, 然后通过源到源变换工具自动重构。

在华为内部我们还基于 Actor 的并发编程模式开发了 Rust 库,方便程序员充分利用 Rust的语言特性, 例如async/await等。

以华为代表的通信系统软件的开发以 C/C++ 代码为主, 这些 Rust 库将使 C/C++ 到 Rust 的迁移更加顺畅。 作为业界领先公司和 Rust基金会 的创始成员,华为致力于 Rust 在通信软件行业,并将持续为 Rust 社区做出贡献。

华为对Rust社区的贡献

我们为 Rust 社区贡献了许多重要的功能特性。例如,我们最近为 Rust 编译器提交了一系列代码,使得 Rust 编译目标可以支持ARM AArch64 32位大端变体ILP32芯片组, 用于我们的通信产品中。 这些改进使得我们和友商可以在这些常用网络硬件架构上执行Rust 原生程序。这些代码已经通过我们的 Rust 专家Amanieu d'Antras 提交给了 LLVM 编译器, libc 库, 以及 Rust 编译器等开源社区。

这些对 Rust 编译器的更改引入了新的端到端交叉编译目标,针对定制硬件构建 Rust 产品变得更容易,只需要简单的命令,比如:


#![allow(unused)]
fn main() {
cargo build --target aarch64_be-unknown-linux-gnu
cargo build --target aarch64-unknown-linux-gnu_ilp32
cargo build --target aarch64_be-unknown-linux-gnu_ilp32
}

华为在中国 Rust 社区方面也走在前列,战略支持 12月26日至27日 在 深圳 举办了第一届 Rust China Conf 大会,并推行多项 社区活动,包括为中国的开发者提供 Rust教程 和 Rust编码规范。

配置华为的端到端Rust工具链

Rust社区中有几种端到端的工具,我们已经开始从开发人员和工具的交互中获取信息。

这里有一些例子:

tokei

由于可信编程项目通常涉及多个编程语言,我们采用了tokei作为多语言代码复杂性度量工具,识别多达200种编程语言。例如,开源的 Fuchhia 项目涉及了多种编程语言,下面的统计信息显示有多少行不同语种的代码:

img

C、C++、Rust 代码在 Fuchhia 项目的占比,可以绘制成如下演进图:

img

为了在大型项目中满足处理多种编程语言的场景需求,我们提交代码到tokei支持识别编程语言的批处理。

cargo-geiger

为了提高安全性,我们经常想知道有多少代码已经被 Rust 编译器检查过。幸运的是,通过统计"Unsafe"项目,如fnexprstructimpltrait及其在各相关库实现中的出现次数, cargo-geiger几乎做到了这点。

img

不过,统计数字中并没有反映安全性,所以没办法展现Rust项目总体上取得了多少进展的比例。因此,我们 提交了代码,在改进的 cargo-geiger 计数器报告中提供 Rust 项目的安全检查比率。这个代码采纳后,我们的产品团队现在每天定期都在使用这个工具,一份典型的报告能够更容易理解哪些代码库还没被 Rust 编译器完全检查到。

img

通过深度代码学习研究 Rust

随着 Rust 开源社区代码的发展和革新,初学者需要学习掌握Rust最佳的实践,其包括但不限于 Rust 语言本身。把统计机器学习的方法应用到源代码数据上,也称为大代码,正被全世界的软件工程研究团队关注:类似于 图像处理和自然语言处理中的机器学习问题,这些问题都需要通过深度神经网络(deep neural networks DNN)提取大量的特征,Big Code 可能同样需要去训练DNN来反映程序的统计特性,所以也称为"深度代码学习"。

在这方面,华为与英国开放大学新加坡管理大学进行技术合作,在现在最先进的“跨语言”深度代码学习基础上进行优化研究。

例如,最初的深度代码学习方法应用于北京大学编程课程收集到的104个算法类的5.2万个C/C++程序。对此数据集,树基卷积神经网络(TBCNN)算法分类准确率达到94%(AAAI'16)。最近的 SOTA 在语句级使用抽象语法树 (ICSE '19)准确率达到98%。近期我们同英国开放大学和新加坡管理大学在树基胶囊网络的合作研究进展推动了SOTA进一步提高,达到98.4%的准确率(AAAI'21)。

早些时候我们已经使用跨语言的数据集表明,对一种编程语言的深度代码学习模型也适用于另一种编程语言。例如,从GitHub 爬取的数据集 Rosetta Code,从 Java 到 C 语言,可以获得86%的算法分类准确度 (SANER'19),在Java到C#的跨语言API映射 问题也能发挥重要作用(ESEC/FSE'19)。这些统计语言模型在软件工程中可以应用于很多方面,比如代码分类、代码搜索、代码推荐、代码摘要、方法名称预测、代码克隆检测等等(ICSE'21)。

为了进一步研究分析 Rust 项目,我们向 Rust 解析器项目tree-sitter和 XML序列化 quick-xml等项目提交了代码,通过 Rust 程序的抽象语法树来训练深度代码学习模型。研究的初步结果很有希望,算法检测任务在 Rust代码上的精度高达85.5%。随着工具链的改进,这个比例还有望进一步提升。

在 IDE 上的原型是在Visual Studio Code IDE上,我们开发扩展插件,使得程序员可以得到合适的算法推荐和可解释性的帮助。

img

结论

综上所述,华为可信开源软件工程实验室正在开展的 Rust 工作为程序员提供智能化端到端 IDE 工具链,以期最大限度地提高代码的安全性和性能。走向可信编程远景的旅程刚刚开始,我们希望与 Rust社区 和 Rust基金会深度合作,引领电信软件产业的可信革新。


作者简介:

俞一峻: 可信编程首席专家/华为可信软件工程开源实验室/华为爱尔兰研究所

Amanieu d'Antras: 可信编程首席专家/华为可信软件工程开源实验室/华为爱尔兰研究所

Nghi D. Q. Bui: 可信编程首席专家/华为可信软件工程开源实验室/华为爱尔兰研究所

PingCAP | TiKV 高性能追踪的实现解析

作者:钟镇炽 / 后期编辑:张汉东


前言

本文为 PingCAP Observability 团队研发工程师钟镇炽在 Rust China Conf 2020 大会上所做演讲 《高性能 Rust tracing 库设计》的更详细文本,介绍了对性能要求非常苛刻的分布式 KV 数据库 TiKV 如何以不到 5% 的性能影响实现所有请求的耗时追踪 。另可点击 https://www.bilibili.com/video/BV1Yy4y1e7zR?p=22 查看演讲视频。

背景

系统的可观测性 (Observability) 通常由三个维度组成:日志 (Logging)、指标 (Metrics) 和追踪 (Tracing),它们之间的关系如下:

1

  • 日志:离散的错误信息和状态信息。
  • 指标:记录和呈现可聚合的数据。
  • 追踪:单个请求的一系列事件。

TiKV 实现了完备的日志和指标系统,但缺失了追踪,导致在诊断 TiKV 和 TiDB 问题时会遇到以下困难:

  • 观测数据之间的没有关联:只有熟悉请求链路上每个操作对应什么监控指标的同学才能完整追溯和诊断问题。
  • 请求抖动难以追溯:TiKV 节点往往同时处理不同模式的业务,零星请求的性能抖动无法体现在 AVG / P99 / MAX 等监控指标中,从而无法诊断抖动原因。

追踪可以有效解决上述场景中遇到的问题。以下详细介绍 TiKV 中高性能追踪的实现。追踪功能在 TiKV 中尚为实验性特性,需要特定代码分支开启,感兴趣的同学可以关注 GitHub issue Introduce tracing framework (#8981)

基本概念

追踪(Trace)呈现系统中的一个请求的执行路径。例如追踪一个 SQL 语句从 TiDB 到 TiKV 的执行全过程后可以得到下图:

2

从图中可以直观看到 SQL 语句“INSERT INTOtVALUES (1), (2), (3);” 有趣的信息:

  • TiDB 处理这个请求时依次进行了 compile、plan、execute 三个步骤
  • TiDB 在 execute 阶段调用了 TiKV 的 Prewrite RPC 和 Commit RPC
  • 请求共耗时 5ms

图中每个方框代表一个事件,称之为 Span。每个 Span 包含:

  • 事件名称
  • 事件起始时间戳和结束时间戳

Span 之间有层级,可以构成父子关系或先后关系,如下图所示:

3

实现

本文所有性能测试结果,若特别说明测试环境,均在以下平台完成:

CPU: Intel Core i7-8700 Linux distros: Ubuntu 20.04 Linux kernel: 5.4 Memory: 32G Disk: NVMe SSD

TiKV 使用 Rust 编写。Rust 生态中有几个现成的追踪库,分别是 tokio-tracing, rustracingopen-telemetry,它们都兼容 OpenTracing 规范,但性能不够理想,引入后会降低 TiKV 50% 以上性能。TiKV 目前的实现能将性能的影响控制在 5% 以内。这主要来自于单个 Span 追踪收集仅耗时 20ns

4

以下具体介绍 TiKV 如何在 20ns 内完成单个 Span 追踪和收集。

计时

计时在追踪中是高频操作,每个 Span 都需要取两次时间戳,分别代表事件的起始和结束时刻,因此计时的性能会很大程度上影响追踪的性能。

追踪库采用的计时方式通常需要能满足以下要求:

  • 获取的时间戳单调递增
  • 高性能
  • 高精度

std::Instant

Rust 原生提供以下两种计时方式:

  • std::SystemTime::now()
  • std::Instant::now()

其中第一种方式获取的是当前系统时间,它可能受用户手动调整、NTP 服务修正等原因的影响,获取到的时间戳并不提供单调递增的保证,因此不能采用。

大多数 Rust 社区的追踪库采取了第二种方式,可以取得单调递增的、纳秒精度的时间戳。但它的性能不够理想,取两次时间需要 50ns,这是社区追踪库性能较低的原因之一。

Coarse Time

若仅从高性能的角度出发来寻找计时方案,可使用 Coarse Time,它牺牲了一定的精度换取高性能。在 Linux 环境下,以 CLOCK_MONOTONIC_COARSE 作为时间源参数,通过 clock_gettime 系统调用可获取 Coarse Time。Rust 社区也提供了库 coarsetime 获取 Coarse Time:


#![allow(unused)]
fn main() {
coarsetime::Instant::now()
}

Coarse Time 性能很高,在测试环境下完成两次调用仅需要 10ns。它的精度取决于 Linux 的 jiffies 配置,默认精度为 4ms

低精度的计时对于短耗时请求的追踪会产生让人困惑的结果。如下图所示,从观测的角度来看已经损失了相当一部分的细节信息:

5

当然在多数情况下,Coarse Time 仍是快速计时的首选。一方面是它在 Linux 系统下开箱即用,获取方便。另一方面,4ms 精度对大部分应用来说是可以接受的。

尽管如此,作为追踪功能的开发者,我们不希望限制用户的场景,例如对于 KvGet 请求,4ms 在要求高的场景中已足够作为异常的抖动需要追溯了,因此有必要支持微秒乃至纳秒级别精度的追踪。同时,性能作为核心出发点,也不能被牺牲掉。幸运的是,这个问题是有解的,它便是接下来要介绍的 TSC。

TSC

TiKV 采用 Time Stamp Counter (TSC) 寄存器进行高精度高性能计时。TSC 寄存器在现代 x86 架构的 CPU 中已经存在很久了,最早可以追溯到 2003 年推出的奔腾处理器。它记录了 CPU 供电重设后到当前时刻所经过的 CPU 时钟周期数。在 CPU 时钟周期速率相同的条件下,经过测量和换算即可用于高精度计时。

TSC 可以同时满足单调递增、高精度和高性能的需求。在我们的测试环境中取两次 TSC 仅需 15ns。在实际情况中,随着处理器的不断发展,TSC 寄存器积累了相当多历史遗留问题会对其正确性造成影响,需要修正。

TSC 速率

TSC 递增速率由 CPU 频率决定。现代化 CPU 可能会动态调节频率节省能耗,导致 TSC 递增速率不稳定

6

另外,一些 CPU 在休眠状态时不会递增 TSC

7

比较现代的 x86 架构 CPU 提供了特性确保 TSC 递增速率的稳定性。在 Linux 下可以通过 /proc/cpuinfo 中的 CPU flag 来检查 TSC 速率是否稳定:

  • constant_tsc: TSC 将以固定的额定标称频率而非瞬时频率递增
  • nonstop_tsc: TSC 在 CPU 休眠状态下仍持续递增

以上 TSC 速率的稳定性保证仅对单个 CPU 核心有效,在多核情况下还需处理 TSC 同步问题。

TSC 多核同步

x86 架构 CPU 没有提供 TSC 寄存器在所有核心上的一致性保证,这会导致计时存在问题。下图展示了某台 2020 年生产的搭载了当时最新 x64 CPU 的笔记本上 TSC 测量情况。可以看到,16 个核心中有一个核心 CPU 0 的 TSC 值存在偏差。

8

在追踪中,完整的计时操作会读取两次时间戳,分别代表事件的始末。由于操作系统的线程调度,这两个时间戳的读取可能发生在不同的核心上。若我们简单地以 TSC 值差值进行计时,会在多核 TSC 不同步的情况下造成耗时计算的偏差

举个例子:

  1. t1 时刻,线程在 Core 1 上运行,读取了较大的 tsc1
  2. 操作系统将线程从 Core 1 调度至 Core 2
  3. t2 时刻,线程在 Core 2 上运行,读取了较小的 tsc2

9

此时计算的 TSC 差值甚至成为了负数,无法换算为耗时。

为了解决这个问题,TiKV 会同步各个核心的原始 TSC 值,计算出 TSC 值在各个核心的偏移量,使用同步过后的 TSC 值用于计算耗时。具体算法为在各个核心上任取两次 TSC 和物理时间,以物理时间作为 x 轴、核心上的 TSC 作为 y 轴计算截距,差值即为各个核心的 TSC 偏移,如下图所示:

10

在计算初始 TSC 偏移时,需要确保取两次 TSC 的过程全都同一核心上执行。在 Linux 中可以通过系统调用 sched_setaffinity 设置线程的亲核性,将线程固定到某个核心上运行:


#![allow(unused)]

fn main() {
fn set_affinity(cpuid: usize) -> Result<(), Error> {
   use libc::{cpu_set_t, sched_setaffinity, CPU_SET};
   use std::mem::{size_of, zeroed};
 
   let mut set = unsafe { zeroed::<cpu_set_t>() };
   unsafe { CPU_SET(cpuid, &mut set) };
 
   // Set the current thread's core affinity.
   if unsafe {
       sched_setaffinity(
           0, // Defaults to current thread
           size_of::<cpu_set_t>(),
           &set as *const _,
       )
   } != 0
   {
       Err(std::io::Error::last_os_error().into())
   } else {
       Ok(())
   }
}

}

有了各个核心的 TSC 偏移值后,在计时阶段只需获取当前执行线程所在的 CPU 及 TSC 值,即可计算出同步后的 TSC 值。需要注意的是,当前执行所在的 CPU 及当前的 TSC 值需要在一条指令中同时获取,避免其中插入操作系统的线程调度导致计算错误。这可以通过 RDTSCP 指令实现。它可以帮助我们原子性地获取原始 TSC 值和 CPU ID。

Rust 代码如下:


#![allow(unused)]

fn main() {
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
fn tsc_with_cpuid() -> (u64, usize) {
   #[cfg(target_arch = "x86")]
   use core::arch::x86::__rdtscp;
   #[cfg(target_arch = "x86_64")]
   use core::arch::x86_64::__rdtscp;
 
   let mut aux = std::mem::MaybeUninit::<u32>::uninit();
   let tsc = unsafe { __rdtscp(aux.as_mut_ptr()) };
   let aux = unsafe { aux.assume_init() };
 
   // IA32_TSC_AUX are encoded by Linux kernel as follow format:
   //
   // 31       12 11      0
   // [ node id ][ cpu id ]
   (tsc, (aux & 0xfff) as usize)
}


}

上文描述的高精度计时的逻辑已经提取成一个独立的 Rust 社区库 minstant,可供相似需求的其他项目直接使用。

Span 收集

Span 可能在各个线程上产生,最终要收集起来汇聚成一个追踪,因此需要跨线程的 Span 收集机制。Span 的收集也是追踪库的一个常见性能瓶颈点

一般有以下方式进行线程安全的 Span 收集:

  • Arc<Mutex<Vec<Span>>>
  • std::sync::mpsc::Receiver<Span>
  • crossbeam::channel::Receiver<Span>

这几种常见的收集方式中 crossbeam channel 是最优的,发送和收集一次 Span 的耗时约为 40ns。为了在提升性能,TiKV 采用了与上述不同的方式收集 Span:同一线程上 Span 仅在线程本地无竞争地收集、最终汇集各个线程上已经收集好的一批 Span 到全局收集器

Local Span

TiKV 为每个线程维护一个线程本地结构 LocalSpanLine,负责 LocalSpan 的产生和存储。再由另外一个线程本地结构 LocalCollector,负责驱动 LocalSpanLine 和收集 LocalSpan。这三者之间的关系和各自的职责如下图。

11

由于 LocalSpan、LocalSpanLine 和 LocalCollector 均是线程本地的,它们之间的交互均不需要线程间的同步和互斥,也不会破坏内存缓存,因此性能极高。LocalSpan 的收集是简单的 Vec::push 操作,平均耗费仅为 4ns

另外,在构造 Span 依赖关系时,利用线程本地的特性可以很方便地实现隐式上下文的机制,用户无需修改函数签名来手动传递追踪上下文,大大降低了对现有代码的侵入性。

下面我们来深入了解关于 LocalSpan 产生和收集的实现细节。

首先,LocalSpanLine 维护了一个容器 SpanQueue,用于装载正在进行的或者已经完成的 LocalSpan。“正在进行”意味着 LocalSpan 所指示的事件开始时间已知,而结束时间未知。这些 LocalSpan 均存储在 SpanQueue 内部的 Vec 结构。

除此之外,上文提到我们利用隐式上下文来构造 LocalSpan 之间的父子依赖关系,这个过程实际上依赖于 SpanQueue 维护的一个变量 next_parent_id

接下来我们将通过一些例子对整个过程进行更为详细的展开。

假设这样一个 foo 事件,于 09:00 产生,持续至 09:03


#![allow(unused)]
fn main() {
09:00  foo +
09:01      |
09:02      |
09:03      +
}

初始状态下,SpanQueue 为空,next_parent_id 记为 root。那么在 foo 发生的时刻,即 09:00,SpanQueue 会去完成以下几个步骤:

  • 新增一条记录,填写事件名称 foo,起始时间 09:00,留空结束时间
  • next_parent_id 的值赋给 foo 的 parent
  • next_parent_id 更新为 foo
  • 向外部返回 index 的值 0,用以接收事件结束的通知,进而完成后续结束时间的回填

12

在 foo 结束的时刻,即 09:03,用户提交 index,向 SpanQueue 通知 foo 事件结束,于是 SpanQueue 开始回填工作:

  • 通过 index 索引到 foo 事件所在记录
  • 将结束时间回填为 09:03
  • next_parent_id 更新为该记录的 parent

13

以上的例子描述了单个事件的记录过程,很简单也很有效。而实际上多个事件的记录也仅仅只是上述过程的重复。比如下面的过程,foo 事件包含了两个子事件:bar 和 baz。


#![allow(unused)]

fn main() {
09:00  foo +
09:01      | bar +
09:02      |     |
09:03      |     +
09:04      |
09:05      | baz +
09:06      |     |
09:07      |     +
09:08      +


}

正如上文所述,SpanQueue 除了记录各个事件的起始和结束时间,还需要记录各个事件之间的父子依赖关系。这个例子中,foo 发生时 SpanQueue 的存储内容和上文没有区别。而在 bar 发生时,SpanQueue 设置 bar 的 parent 为当前的 next_parent_id 值,即 foo,同时将 next_parent_id 更新为 bar:

14

在 bar 结束时,会按照上面提到的回填步骤,更新 bar 记录的结束时间以及 next_parent_id 变量:

15

重复以上步骤,最终 SpanQueue 以一种高效的方式,完整记录了这三个事件的信息:

16 17

将这些记录串连起来,最终形成如下的 Trace 树状结构:

18

Normal Span

虽然 LocalSpan 的记录比较高效,但是由于其本身基于线程本地的实现方式,使得灵活性不足。比如在异步场景下,一些 Span 的产生和结束发生在不同的线程,线程本地的实现就不再能发挥作用。

针对上述问题,TiKV 保留了前文最开始所描述的线程安全的 Span 记录方式,即采用 crossbeam channel 每次进行单个 Span 的收集,这样的 Span 下文称之为 NormalSpan。

从实现的角度看,NormalSpan 的信息不会记录在线程本地的容器当中,而是由相应的变量自行维护,以便于跨线程的移动。同时,NormalSpan 之间的父子关系不再由线程本地隐式构建,而需由用户手动指定。

但是,NormalSpan 和 LocalSpan 并非完全隔离,TiKV 通过以下的交互方式将这两者联系起来:从 LocalCollector 收集而来的一组 LocalSpan,可以挂载在 NormalSpan 上作为子树,如下图所示。同时,挂载的数量不受限制,通过允许进行多对多的挂载方式,TiKV 在一定程度上支持了对 batch 场景的追踪,这是社区中大部分追踪库没有覆盖到的。

19

上述实现方式形成了 Span 收集的快慢两条路径。它们共同合作,完成对某个请求的执行路径信息的记录:

  • LocalSpan 不可跨越线程但记录高效,通过批量收集 LocalSpan 然后挂载至普通 Span 的方式,让追踪的开销变得非常低。
  • 普通 Span 的记录相对较慢,不过它可以跨线程传递,使用起来比较灵活。

使用方法

TiKV 中的高性能追踪的逻辑已提取成一个独立的库 minitrace-rust,可直接在各种项目中使用,步骤如下:

  • 请求到达时,创建对应根 Span;
  • 请求执行路径上,使用 minitrace-rust 提供的接口记录事件的发生;
  • 请求完成时,收集执行路径上产生的所有 Span。

根 Span 的创建和收集

一般在一个请求开始的时候可以创建根 Span。在 minitrace-rust 中用法如下:


#![allow(unused)]
fn main() {
for req in listener.incoming() {
   let (root_span, collector) = Span::root("http request");
   let guard = root_span.enter();
   my_request_handler(req);
}
}

Span 基于 Guard 实现了自动在作用域结束后结束 Span,而无需手工标记 Span 的终止。除了返回根 Span 外,Span::root(event) 还返回了一个 CollectorCollector 与根 Span 一一对应。在请求完成时,可调用 Collectorcollect 方法,从而完成对执行路径上产生的所有 Span 的收集。如下所示。


#![allow(unused)]
fn main() {
let (root_span, collector) = Span::root("http request");
let guard = root_span.enter();
 
handle_http_request(req);
 
drop((guard, root_span));
let spans = collector.collect();
}

事件记录

比较推荐使用 minitrace-rust 提供的 tracetrace_async 宏进行函数级别的事件记录。通过上述方式为单个函数记录的执行信息如下:

  1. 调用的发生时刻
  2. 调用的返回时刻
  3. 直接(或间接)调用者的引用
  4. 直接(或间接)调用的子函数的引用

例如,追踪两个同步函数 foobar,通过添加 trace(event) 作为这两个函数的 attribute,即可记录函数的执行信息。如下所示。


#![allow(unused)]
fn main() {
#[trace("foo")]
fn foo() -> u32 {
   bar();
   42
}
 
#[trace("bar")]
fn bar() { }
}

最终记录下来的信息,包括这两个函数各自的起始和完成时刻,以及函数调用关系:foo 调用了 bar

20

对于异步函数的记录,步骤略有不同。首先须将 trace 替换成 trace_async,如下所示。


#![allow(unused)]

fn main() {
#[trace_async("foo async")]
async fn foo_aysnc() -> u32 {
   bar_async().await;
   42
}
 
#[trace_async("bar async")]
async fn bar_async() {
   yield_now().await;
}

}

另外还需要关键的一步:将 Task 用 minitrace-rust 提供的 Future 适配器 in_span 进行包装,从而将该 Future 与某个 Span 绑定起来。

Task,在 Rust 异步语境中,特指被 spawn 至某个 executor 的 Future,也称根 Future。例如以下的 foo_async 就是一个 Task:


#![allow(unused)]
fn main() {
executor::spawn(
   foo_async()
);
}

假设要追踪 foo_async 这样一个 Task,并且与一个由 Span::from_local_parent(event) 创建的 Span 进行绑定,那么,相关的应用代码将如下所示。


#![allow(unused)]
fn main() {
executor::spawn(
   foo_async().in_span(Span::from_local_parent("Task: foo_async"))
);
}

下图为该 Task 追踪的结果:

21

结语

TiKV 作为底层 KV 数据库,对其增加观测性功能天然有着与普通业务程序完全不一样的性能要求,非常具有挑战性。除了追踪以外,TiKV 及其上层 SQL 数据库 TiDB 也还有其他富有挑战性的观测性需求。PingCAP 的 Observability 团队专注于这类观测难题的解决与功能实现,感兴趣的同学可投递简历到 hire@pingcap.com 加入我们,或加入 Slack channel #sig-diagnosis 参与技术讨论。

蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考

作者:evenyag / 后期编辑:张汉东


错误处理并非一件容易的事情,尽管在使用 Rust 时,有编译器不厌其烦地督促我们,基本不存在漏掉错误不处理的情况了,但这并不意味着错误处理这件事情变简单了。这里也记录一下我使用 Rust 一段时间后,对于错误处理的一些思考,包含大量主观看法,欢迎读者拍砖。

不可恢复错误和可恢复错误

使用 Rust 的人都知道, Rust 错误处理的手段主要分为两种,对于不可恢复的错误(unrecoverable error),可以通过 panic 来直接中断程序的执行,而对于可恢复的错误(recoverable error),一般会返回 Result 。至于什么时候使用 panic ,什么时候使用 Result ,官方提供了一些指导意见,很多文章对这块都有讨论,相信不少人在这上面是能达成共识的,因此本文在这块也不做过多展开。

错误处理中最麻烦的,还是处理可恢复的错误。

Error 类型

在进行错误处理,首先,你得把自己 Error 类型给定义了。我认为,对于一个新项目来说,定义好自己的 Error 类型甚至是属于最先要做的几件事情之一。即便一开始不做,等到你写到了第一个 Result 时,你也不得不考虑了。定义 Error 类型是一个可简单,可复杂的事情,毕竟在 Result<T, E> 里,E 其实可以塞任何东西。如果你胆子够大,甚至可以直接把 String 作为 Error 来使用,还能带上一定的错误信息。


#![allow(unused)]
fn main() {
fn make_string_err() -> Result<(), String> {
    Err(format!("Oh, string is not {}", 1))
}

fn string_err_example() -> Result<(), String> {
    make_string_err()?;
    Ok(())
}
}

String 甚至可以转为来使用 Box<dyn Error>


#![allow(unused)]
fn main() {
fn string_box_err() -> Result<(), Box<dyn std::error::Error>> {
    Err(format!("Oops, {}", 1))?;
    Ok(())
}
}

不过这种错误处理方式过于简单粗暴,而错误一旦转为了 String ,就丧失了大部分可编程性,上层想要针对某些类型的错误做针对性的处理就会变得非常困难 —— 唯一的手段估计就只剩下字符串匹配了。

更多的时候,我们可能会想要把错误定义为一个 Enum 或者 Struct ,并实现 Error 等相关的 trait 。这是个体力活,如果你还需要处理 std 或者第三方库抛出来的 Error ,还需要手工实现一大堆 From 来为自己的 Error 实现相应的转换规则。这样下去,还没等 Error 类型定义完,写代码的热情就已经冷却了。

这些工作太枯燥了,就应该交给工具库去做!而当你去找 Rust 相关的错误处理库(严格来说,可能称为错误管理或者错误定义库更合适)时,就会发现, Rust 的错误处理库也太多了,而且以后可能会更多,这对于有选择困难症的来说简直是灾难。后面我也会从早期到近期挑选出一些比较有代表性的错误处理库,谈下我对他们的理解和在错误处理上的一些看法。当然,由于不是每个库我都使用过,所以也难免理解存在偏颇,欢迎大家指正

quick-error

在我刚接触 Rust 时,市面上的错误处理库还没有现在多,或者说我对 Rust 错误处理还不如现在了解,挑选库的过程反而比较简单。由于当时 tikv 已经挺有名气了,于是我直接打开 tikv 的项目,发现它在使用 quick-error ,就决定跟着它用了。当时我的需求也很简单,就是希望有个工具库帮我把定义错误的这些 boilerplate code 给包掉,而 quick-error 也正如其名,能够比较麻利地帮我把 Error 类型定义出来。而 Rust 最早的错误处理库基本上也就只帮你干这样的事情,因此其实更像是错误定义库(如今 quick-error 也不仅仅只能帮你定义错误了,不过也是后话了)。

例如下面就是个使用 quick-error 的例子,定义了一个 Error 类型,并且自动实现了 From<io::Error>


#![allow(unused)]
fn main() {
quick_error! {
    #[derive(Debug)]
    pub enum MyError {
        Io(err: io::Error) {
            from()
            display("I/O error: {}", err)
            source(err)
        }
        Other(descr: &'static str) {
            display("Error {}", descr)
        }
    }
}
}

丢失上下文

然而,仅仅只是把 Error 定义出来只不过是刚刚踏入了错误处理的门,甚至可以说定义 Error 也只是错误处理那一系列 boilerplate code 的一小部分而已。单纯见到错误就往上抛并不难,而且 Rust 还提供了 ? 运算符来让你可以更爽地抛出错误,但与之相对的,直接上抛错误,就意味着丢弃了大部分错误的上下文,也会给时候定位问题带来不便。

例如有类似下面的代码,使用了刚刚在上面定义的 Error 类型,而 eat()/drink()/work()/sleep() 中任意一个都有可能抛出 io::Error 的函数。那么当 daily() 出错时,你拿到的最终信息可能只是个 "I/O error: failed to fill whole buffer" ,而到底是哪里出的错,为什么出错了呢?不知道,因为错误来源丢失了。


#![allow(unused)]
fn main() {
fn daily() -> Result<(), MyError> {
    eat()?;
    drink()?;
    work()?;
    sleep()?;
    Ok(())
}
}

丢失错误源头这种问题在 Rust 里还是很容易发生的,也是 Rust 错误处理里较恼人的一件事。当然,很大的原因还是在于错误提供没有 backtrace (现在也尚未 stable)。为了避免出现类似的问题,遇到错误时就需要注意保存一些调用信息以及错误的现场,概况下来,就是两样东西

  • 调用栈,或者说 backtrace
  • 错误的上下文,如关键入参

严格来说, backtrace 也属于上下文的一部分,这里分开提更多是考虑到两者在实现层面是有所区分的。有 backtrace 自然方便,但 backtrace 也并不能解决所有问题:

  • 光靠 backtrace 其实只能回答哪里出了错的问题,而回答不了为什么出错的
  • 一些预期内时常会抛错误的代码路径也不宜获取 backtrace

反过来,通过在日志里打印或者在 Error 类型中追加上下文信息,其实是能反过来推断出调用链路的,使得排查问题不强依赖 backtrace。我在 Rust 里进行的错误处理时做得最多的事情就是,考虑这个地方适不适合打印错误日志:

  • 如果适合,打下错误日志和相关信息,继续抛错误
  • 不适合,考虑错误直接抛上去了后续是否方便定位问题
    • 如果不方便,还会把 error 和上下文信息 format 下得到新的 error message ,然后产生个新的错误抛出去

这种方式虽说能解决问题,不过并不认为是一种最佳实践,更称不上优雅,光是打印日志和补充错误信息,就得写不少代码,更不提日志和错误信息里有不少内容可能还是相互重复的。

error-chain 和 failure

有没有办法更方便地将错误的上下文信息放到 Error 里面呢?早期的 error-chain 库在这方面做了不少尝试,其中 chaining errors 模式有点类似 golang 中的 errors.Wrap() ,允许用户通过 chain_err() 将错误或者可转换为错误的类型(如 String)不断地串联起来。


#![allow(unused)]
fn main() {
let res: Result<()> = do_something().chain_err(|| "something went wrong");
}

除此之外,这个库还提供了 ensure!bail! 等工具宏以及 backtrace 功能,这些我认为对后来错误处理库的发展都是由一定启发作用的。不过 error-chain 文档里那一大坨宏定义,各种概念以及说明,对于刚接触 Rust 的人还是比较劝退的。

到了 failure 库, chain_err() 的模式改为了通过 context() 来携带错误的上下文信息。


#![allow(unused)]
fn main() {
use failure::{Error, ResultExt};

fn root() -> Result<(), Error> {
    a().context("a failed")?;
    b().context("b failed")?;
    Ok(())
}
}

如今错误处理库也基本沿用了 context() 这一 api 命名,甚至 context() 已经成为了 Rust 风格错误处理的一部分。

尽管我也考虑过使用这两个库替换掉自己项目里在用的 quick-error ,不过,一旦项目变庞大后,这种替换错误处理库以及错误处理风格的工作就多少有点工作量抵不上收益了。另一方面, error-chain 和 failure 作为出现得比较早的错误处理库,更多起到探索和过渡的作用,他们当初需要解决的问题在 std 的 Error trait 的演进下,很多也都不复存在了(起码在 nightly 上是这样),因此他们的演进也基本走到尽头了。包括 failure 的开发后来也逐渐停滞,现在已经是处于 deprecated 的状态了,项目维护者也都推荐用一些更新的错误处理库。

thiserror + anyhow

对于一些新的错误处理库,目前社区里较为主流的建议可能是组合使用 thiserror 和 anyhow 这两个库。其中 thiserror 可以看作是定义 Error 的一个工具,它只帮你生成一些定义 Error 的代码,别的什么都不做,相当纯粹。

而 anyhow 则为你定义好了一个 Error 类型,基本可以看作是一个 Box<dyn Error> ,同时还提供了一些如 context 等扩展功能,用起来更加无脑。

use anyhow::{Context, Result};

fn main() -> Result<()> {
    ...
    it.detach().context("Failed to detach the important thing")?;

    let content = std::fs::read(path)
        .with_context(|| format!("Failed to read instrs from {}", path))?;
    ...
}

除此之外, anyhow 的 Error 只占用一个指针大小的栈空间,相应的 Result 的栈空间占用也会变小,在一些场景下也比较有用。

这两个库的作者 dtolnay 建议,如果你是在开发库,则用 thiserror ,而如果是开发应用则使用 anyhow 。这在实践时遇到的一个问题就是所谓库和应用的边界有时候并没有那么清晰:对一个多模块的应用来说,本质上也可以看作是由若干个库构成的,而这些模块或者"库"之间,也可能是有层级关系的。对于这些模块,使用 anyhow 就存在以下问题

  • 需要使用 anyhow 专门提供的 Error 类型,可能直接将 anyhow::Error 暴露到库的 api 上
  • 调用方拿到的不是明确的错误类型
  • 无法对 anyhow::Error 做 pattern match
  • 更近一步,应用也不保证不会有处理具体错误的需求

本质上, anyhow::Error 库提供的 Error 类型,更类似一种 Report 类型,适合汇报错误,而不适合处理具体的错误。如果使用 thiserror ,就失去了便利的 context 功能,用起来相对没那么方便,而作者看上去也不打算支持这一点。总的看下来, thiserror + anyhow 的组合方案还是存在一定局限性,似乎用起来并没有那么顺手。

snafu

而 snafu 的方案,则让我看到 context 也是可以和具体的 Error 类型比较优雅地结合起来。不妨看下 snafu 官方的例子


#![allow(unused)]
fn main() {
use snafu::{ResultExt, Snafu};
use std::{fs, io, path::PathBuf};

#[derive(Debug, Snafu)]
enum Error {
    #[snafu(display("Unable to read configuration from {}: {}", path.display(), source))]
    ReadConfiguration { source: io::Error, path: PathBuf },

    #[snafu(display("Unable to write result to {}: {}", path.display(), source))]
    WriteResult { source: io::Error, path: PathBuf },
}

type Result<T, E = Error> = std::result::Result<T, E>;

fn process_data() -> Result<()> {
    let path = "config.toml";
    let configuration = fs::read_to_string(path).context(ReadConfiguration { path })?;
    let path = unpack_config(&configuration);
    fs::write(&path, b"My complex calculation").context(WriteResult { path })?;
    Ok(())
}

fn unpack_config(data: &str) -> &str {
    "/some/path/that/does/not/exist"
}
}

上面的例子就体现出 snafu 的一些特点:

  • 基于 context selector 的 context 方案
    • 同样是 io::Error , snafu 可以通过不同的 context 返回不同的 enum variant ,同时还能带上一些错误相关信息
    • 比起为 Error 直接实现 From<io::Error> 要更有意义,毕竟我们更希望拿到的错误告诉我是 read configuration 出错了,还是 write result 出错了,以及出错的文件 path 是哪个
    • 本质上是把 context 的类型也提前定义了
  • 产生的 Error 就是我们自己定义的 Error,无需依赖 snafu 提供的 Error 类型
  • 这里其实还有一个隐含的好处,就是这个 Error 是可以做 pattern match 的

关于 snafu 和错误处理, influxdb_iox 其实总结了一份他们错误处理的 style guide ,我觉得很有参考价值,里面也提到了 snafu 的一些设计哲学

  • 同样的底层错误可以根据上下文不同而转换为不同的领域特定错误,例如同样是 io 错误,根据上层业务语义的不同能够转换为不同的业务错误
  • 在库和应用的场景下都同样好用
  • 模块级别的 Error 类型,每个模块都应该定义一个,甚至多个自己专用的错误类型

而这些设计哲学,我认为也是错误处理里比较好的实践。其中,关于 Error 类型应该做到模块级别还是做到 crate 级别(全局),可能会有较多争议,也值得发散开来聊聊。

模块级 Error 类型与全局 Error 类型

先摆观点,我认为 Error 类型尽量做到模块级别是更好的,甚至部分函数有专门的 Error 类型也不过分,但是也要摆一个事实,那就是我自己的代码里这一点做得也还不够好。

所以,这里还是要提一下全局 Error 类型的一些好处,起码包括

  • 方便做一套全局的错误码,而且类型参数不合法就是比较常见的错误
  • 不需要花太多精力定义 Error 类型,很多 enum variant 可以共用,Result<T, Error> 也只需要定义一份,,这也是全局 Error 类型最大的优势

但是,全局 Error 类型也存在相应的缺陷

  • 所有用到了 Error 类型的模块,其实通过 Error 类型间接和其他模块耦合了,除非你的 Error 类型只想用 anyhow::Error 这样的类型
  • 即使来源 Error 相同,上下文也不同,定义到一个 enum variant 里面不见得合适
  • 更容易出现 Error 抛着抛着不知道哪来的情况

而模块级的 Error 类型则看上去也更符合一个模块化的 crate 应有的设计

  • 不存在共用 Error 类型导致的间接耦合
  • 更加内聚,每个模块可以专心处理自己的错误, match 错误的范围也大大减少
  • 即使不依赖 backtrace ,错误本身也能明确反映出了层次关系和链路

当然,模块级的 Error 类型也并非没有缺点,例如

  • 定义 Error 的工作会变多,做全局的错误码会麻烦些,可能需要在上层做一次转换
  • 模块层次过深的话,或者一些模块的 Error 字段较多,由于 Rust enum 的特点,越上层的 Error 类型就会越大(std::mem::size_of::()),像 snafu 同样也会有这样的问题

总结

错误处理可能不存在最佳方案一说,更多还是要结合实际场景。即便是谈到错误处理库,我要是大喊一声 snafu 是 Rust 最好的错误处理库,相信社区里肯定也会有一堆人跳出来反对我。而实际上 snafu 也存在自身的缺点,例如 Error 定义的工作量相对大(需要定义各种 context), Error 类型体积可能会比较大等。

总的来说,错误处理一直是一件麻烦的事。我觉得能做到错误的现场可追溯,就已经算错误处理做得不错了的。经过几年的发展, Rust 的错误处理库初步发展出了 context 和 backtrace 两种记录错误上下文的手段,同时也更加强大和易用了,但我认为目前他们尚未发展到终态,也尚未出现一个库独大的局面。如果说现在我新起个项目或者模块,需要选择一个错误处理库的话,我可能会先尝试下 snafu 。

关于我们

我们是蚂蚁智能监控技术中台的时序存储团队,我们正在使用 Rust 构建高性能、低成本并具备实时分析能力的新一代时序数据库,欢迎加入或者推荐,联系人 jiachun.fjc@antgroup.com

参考

  • https://blog.yoshuawuyts.com/error-handling-survey/
  • https://www.ncameron.org/blog/migrating-a-crate-from-futures-0-1-to-0-3/
  • https://zhuanlan.zhihu.com/p/225808164
  • https://nick.groenen.me/posts/rust-error-handling/
  • https://doc.rust-lang.org/book/ch09-00-error-handling.html
  • https://github.com/tikv/rfcs/pull/38#discussion_r370581410
  • https://github.com/shepmaster/snafu/issues/209
  • https://github.com/rust-lang/project-error-handling/issues/24
  • https://github.com/rust-lang/rust/issues/53487
  • https://github.com/rust-lang/rfcs/blob/master/text/2504-fix-error.md
  • https://zhuanlan.zhihu.com/p/191655266
  • https://docs.rs/snafu/0.6.10/snafu/guide/philosophy/index.html
  • https://doc.rust-lang.org/src/std/error.rs.html#48-153
  • https://github.com/facebook/rocksdb/blob/00519187a6e495f0be0bbc666cacd9da467a6c1e/include/rocksdb/status.h#L34
  • https://github.com/tailhook/quick-error/issues/22
  • https://github.com/dtolnay/anyhow
  • https://github.com/dtolnay/thiserror
  • https://github.com/tailhook/quick-error
  • https://github.com/rust-lang-nursery/failure
  • https://github.com/rust-lang-nursery/error-chain

Rust中的错误传递和日志记录

作者:楼智豪 / 后期编辑:张汉东


简介以及背景

在Rust代码的编写过程中,开发者也需要关注错误处理和日志记录的过程,程序能够及时反馈信息,保证程序的正常运行。 本文分两部分,第一部分讲述如何进行错误传递和处理,第二部分讲述应该如何记录日志。

错误处理

以前在使用C进行错误处理时,通常采用的是函数传递错误码的方式,而对于Rust而言这种方式显得有些古老。

首先,Rust当中的错误处理基于两个特性,Result和Error。


#![allow(unused)]
fn main() {
pub enum Result<T, E> {
    /// Contains the success value
    Ok(T),
    /// Contains the error value
    Err(E),
}
}

Result是Rust提供的一个枚举类,它里面应当包含,程序成功运行时返回的值T,或者是程序运行失败时返回的错误类型E。如果一个函数,它的返回值是一个Result,那么就表明,它有可能失败并返回一个错误类型,需要我们来处理这个Result。

Rust在标准库中提供了一个trait,sdt::error::Error,目前错误处理都是基于这个trait来进行,一个结构体/枚举如果实现了这个trait,那么我们认为,它就是一个错误类型。


#![allow(unused)]
fn main() {
//为自定义的结构体实现Error的trait,该trait要求同时实现Display和Debug
//Error tarit包含多个方法,但通常情况下impl的时候除了source方法其他无需重写
pub trait Error: Debug + Display {
    //如果该错误类型中包含了底层的错误Err,那么source方法应该返回Some(err),如果没有返回None。不重写则默认为None
    fn source(&self) -> Option<&(dyn Error + 'static)>;
    //type_id():该方法被隐藏
    fn type_id(&self, _: private::Internal) -> TypeId;
    //backtrace():返回发生此错误的堆栈追溯,目前为unstable,默认禁用,且占用大量内存,性能很差
    fn backtrace(&self) -> Option<&Backtrace>;
    //description():已废弃,改使用Display
    fn description(&self) -> &str;
    //cause():已废弃,改使用source()
    fn cause(&self) -> Option<&dyn Error>;
}
}

错误传递的背景在于,在开发过程中,可能各个模块自身都定义了一个错误类型,那么当这些模块需要一起使用时,不同错误类型的结构体应该如何转换和处理,如何传递。

方式一:自定义错误类型

  • 自定义错误类型,并且通过From trait进行转换
  • ?来传递错误,自动执行类型转换

#![allow(unused)]
fn main() {
impl Error for MyError {

}

/// MyError属于当前自定义的枚举,其中包含了多种错误类型
/// MyError也包含了从下层传递而来的错误类型,统一归纳
#[derive(Debug)]
pub enum MyError {
    BadSchema(String, String, String),
    IO(io::Error),
    Read,
    Receive,
    Send,
}

//实现Display
impl fmt::Display for MyError {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        match self {
            MyError::BadSchema(s1, s2, s3) => {
                write!(f, "BadSchema Error:{}, {}, {}", s1, s2, s3)
            }
            MyError::IO(e) => {
                write!(f, "IO Error: {}", e)
            }
            MyError::Read => {
                write!(f, "Read Error")
            }
            MyError::Receive => {
                write!(f, "Receive Error")
            }
            MyError::Send => {
                write!(f, "Send Error")
            }
        }
    }
}
}

在定义MyError时,其中包括了多种错误类型,有当前模块产生的错误(比如Read, Receive, Send),也有从下层模块传递上来的错误,比如IO(io::Error),针对从下层传递而来的这种错误,我们需要将它归纳到自己的MyError中,统一传递给上层。为了实现这个目的,我们就需要实现From 方法,当我们为一个错误类型的转换实现了From方法,就可以使用?来进行自动转换。如下所示


#![allow(unused)]
fn main() {
impl From<io::Error> for MyError {
    fn from(err: io::Error) -> MyError {
        MyError::IO(err)
    }
}
}

#![allow(unused)]
fn main() {
//这两个示例是相同的
fn test_error() -> Result<i32, MyError> {
    let s = std::fs::read_to_string("test123.txt")?;
    Ok(s)
}

fn test_error2() -> Result<String, MyError> {
    let s = match std::fs::read_to_string("test123.txt") {
        Ok(s)=>{
            s
        }
        Err(e)=>{
            return Err(MyError::from(e));
        }
    };
    Ok(s)
}
}

注意在示例一当中?的作用,它等效于示例二中的match,意思是

  • 该函数的返回值是一个Result<T,Error>,需要进行处理。

  • 如果该函数运行正确,那么返回T,上述的示例中返回String

  • 如果该函数运行失败,返回了一个错误类型Error,这里返回了io::Error, 并且因为我们实现了From方法,io::Error被自动转换成了MyError::IO(io::Error),然后程序在此处直接return,不再继续往下走。

    注意From的类型转换是通过?来隐式调用的,如果不使用?而是直接return一个错误,它是不会自动进行类型转换的。

方式二 : 使用trait Object传递错误

  • 不定义自己的类型,而直接使用 Box<dyn Error> 来统一错误类型。

  • ?来传递错误,自动把Error转换成 Box<dyn Error>


#![allow(unused)]
fn main() {
fn test_error() -> Result<i32, Box<dyn Error>> {
    let s = std::fs::read_to_string("test123.txt")?;
    let n = s.trim().parse::<i32>()?;
    Ok(n)
}
}

在上面这个示例中,可以看到,我们返回了一个Box,他是一个trait Object,意思是泛指一个具有Error trait的结构体/枚举类型。我们之所以可以这么写,其实是因为Rust本身为Box实现了From方法,所以可以实现自动转换。

上述代码中,第一行和第二行分别返回了io:Error和ParseIntError,都可以被转换成Box。这种方式的好处在于开发者不用再一个个地去定义错误类型,编写From方法,坏处在于丢失了具体的错误类型的信息,如果要对于不同的错误类型进行不同处理,就会遇到麻烦。

虽然Rust提供的downcast方法可以将Box重新还原成具体的结构体,但是e.downcast::<MyError>();这种形式的调用也还是需要预先知道结构体类型,所以使用起来还是有困难。

对比

方式优点缺点
自定义错误类型可以统一错误类型,方便上层用户对不同的错误类型采取不同的措施需要进行各式的类型转换,较为繁琐
Box<dyn Error>Error可以直接透传,不需要在乎具体的类型丢失了结构体类型信息,但是也可以通过downcast把trait object转换回具体的结构体

结论:综合以上两种方式的优缺点以及各方给出的意见,得出结论如下

  • 如果是编写一个库,那么最好采取方式一,因为我们需要给上层用户传递具体的错误类型,来方便他们进行处理。
  • 如果是编写一个完整的应用程序,所有错误都在自身内部进行处理了,不需要传递给其他人,那么可以考虑采取方式二

其他:第三方库

anyhow :专门为错误处理设计的第三方库


#![allow(unused)]
fn main() {
use anyhow::Result;

fn get_cluster_info() -> Result<ClusterMap> {
    //从std::io::Error转换成了anyhow::Error
    let config = std::fs::read_to_string("cluster.json")?;
    let map: ClusterMap = serde_json::from_str(&config)?;
    Ok(map)
}

}

#![allow(unused)]
fn main() {
match root_cause.downcast_ref::<DataStoreError>() {
    //从anyhow::Error转换成自定义的DataStoreError
    Some(DataStoreError::Censored) => Ok(),
    None => Err(error),
}
}

anyhow这个库可以把用户自定义的,所有实现了std::Error trait的结构体,统一转换成它定义的anyhow::Error。这样用户在传递错误的过程中就使用的是统一的一个结构体,不用自定义各种各样的错误。

论用法,其实anyhow和第二种trait Object方法是类似的,但是有几点不同

  • anyhow::Error 的错误是Send, Sync'static
  • anyhow::Error 保证backtrace方法可用,即便你的底层Error没有提供backtrace
  • anyhow::Error是一个机器字长,而Box<dyn Error>是两个机器字长

thiserror :提供便捷的派生宏的第三方库

前面有提到,一个自定义的MyError结构体,需要实现很多内容,Error trait,Display,Debug以及各种From函数,手动编写可能较为麻烦,而thiserror这个库则提供了过程宏来简化这个过程


#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("data store disconnected")]
    Disconnect(#[from] io::Error),
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader {
        expected: String,
        found: String,
    },
    #[error("unknown data store error")]
    Unknown,
    #[error("Utf data store error")]
    Utf{
        #[from]
        source: Utf8Error,
        backtrace: Backtrace
    },
    #[error(transparent)]
    Other(#[from] anyhow::Error)
}
}
  • 在我们自定义的结构体前加上#[derive(Error)],就可以自动impl Error

  • #[error("invalid header (expected {expected:?}, found {found:?})")]这条语句代表如何实现Display,后面的字符串就代表Display会输出的字符,同时支持格式化参数,比如这条语句里的expected就是代表结构体里面的元素。如果是元组则可以通过.0或者.1的方式来表示元素

  • #[from]表示会自动实现From方法,将对应的子错误进行转换

    • #[from]有两种写法,第一种就是Disconnect(#[from] io::Error)这样,自动将io::Error转换成DataStoreError::Disconnect,简单的结构体嵌套
    • 第二种写法是Utf { #[from] source: Utf8Error, backtrace: Backtrace }这种,这种格式有且只能有两个字段,sourcebacktrace,不能有其他字段。它会自动将Utf8Error转换成DtaStoreError::Utf,并且自动捕获原错误中的backtrace方法
  • #[source]表示将这个结构体字段的值作为source方法的返回值,如果字段本身的名称就是source的话就不用加#[source]而会自动应用。而backtrace方法则会自动寻找结构体里类型为std::backtrace::Backtrace的字段来作为返回值。

    
    #![allow(unused)]
    fn main() {
    #[derive(Error, Debug)]
    pub struct MyError {
        msg: String,
        #[source]  // optional if field name is `source`
        source: anyhow::Error,
        backtrace: Backtrace,  // automatically detected
    }
    }
    
  • #[error(transparent)]表示将源错误的source方法和Display方法不加修改直接应用到DataStoreError::Other

日志记录

log库


#![allow(unused)]
fn main() {
error!(target: "yak_events", "Commencing yak shaving for {:?}", yak);
// target默认为当前crate的名称
warn!( "hello world");
info!( "hello world");
debug!( "hello world");
trace!( "hello world");
}
  • 记录当前crate的名字、文件名路径、行号、文本信息

日志门面库

通过定义统一的接口,使用统一的日志记录方式,可以在多个日志框架中灵活切换,可以让开发者不必关心底层的日志系统。如果你是Rust库的开发者,自然不期望自己的框架绑定某个具体日志库,而是只使用log门面日志库,由使用者自行决定日志库。

graph TD
	应用程序-->log
	log-->具体的日志系统
	具体的日志系统-->env_logger
	具体的日志系统-->pretty_env_logger
	具体的日志系统-->log4rs
	具体的日志系统-->slog-stdlog
	具体的日志系统-->...

#![allow(unused)]
fn main() {
struct SimpleLogger {};

impl log::Log for SimpleLogger {};

log::set_logger(SimpleLogger);
}

使用方式:调用set_logger方法绑定底层的日志系统,然后用户只需调用error!、log!这几个宏,其余的如何写入日志的问题则交给系统自己去做。

开源库如何记录日志

下面列出了一些开源库使用了什么日志工具,以及它们是如何记录日志的。

可以得到结论,绝大部分开源库都在使用log这个日志门面库,而且日志记录的方式,通常是直接写入字符串信息,以及调用Error的Display方法进行写入。

  • ivanceras / diwata —用于PostgreSQL的数据库管理工具 : 使用log库

    
    #![allow(unused)]
    fn main() {
    debug!("ERROR: {} ({})", msg, status);
    }
    
  • habitat—由Chef创建的用于构建,部署和管理应用程序的工具:使用log库

    
    #![allow(unused)]
    fn main() {
      match server::run(args) {
          Err(err) => {
              error!("Launcher exiting with 1 due to err: {}", err);
              process::exit(1);
          }
          Ok(code) => {
              let level = if code == 0 { Level::Info } else { Level::Error };
              log!(level, "Launcher exiting with code {}", code);
              process::exit(code);
          }
      } 
    }
    
  • kytan —高性能对等VPN :使用log库

    
    #![allow(unused)]
    fn main() {
    warn!("Invalid message {:?} from {}", msg, addr);
    }
    
  • Servo —原型Web浏览器引擎 : 使用log库和gstreamer库

    
    #![allow(unused)]
    fn main() {
    gst_element_error!(src, CoreError::Failed, ["Failed to get memory"]);
    // 引用C动态库,采取错误码方式传递u32
    }
    
  • wezterm — GPU加速的跨平台终端仿真器和多路复用器 :使用log库

    
    #![allow(unused)]
    fn main() {
    log::error!("not an ioerror in stream_decode: {:?}", err);
    }
    
  • nicohman / eidolon —适用于linux和macosx的无Steam和drm的游戏注册表和启动器:使用log库

    
    #![allow(unused)]
    fn main() {
    error!("Could not remove game. Error: {}", res.err().unwrap());
    }
    
  • Mio - Mio是一个用于Rust的,快速的底层I/O库:使用log库

    
    #![allow(unused)]
    fn main() {
    if let Err(err) = syscall!(close(self.kq)) {
                error!("error closing kqueue: {}", err);
            }
    }
    
  • Alacritty —跨平台,GPU增强的终端仿真器:使用log库

    
    #![allow(unused)]
    fn main() {
    if let Err(err) = run(window_event_loop, config, options) {
            error!("Alacritty encountered an unrecoverable error:\n\n\t{}\n", err);
            std::process::exit(1);
        }
    }
    
  • 最后,关于Error的Display方法具体应当输出什么内容,这里可以参考std::io::Error的内容(这里的io::Error并不是一个trait,而是一个实现了std::error::Error的trait的具体类型,是一个结构体)

    
    #![allow(unused)]
    fn main() {
    impl ErrorKind {
      pub(crate) fn as_str(&self) -> &'static str {
          match *self {
              ErrorKind::NotFound => "entity not found",
              ErrorKind::PermissionDenied => "permission denied",
              ErrorKind::ConnectionRefused => "connection refused",
              ErrorKind::ConnectionReset => "connection reset",
              ErrorKind::ConnectionAborted => "connection aborted",
              ErrorKind::NotConnected => "not connected",
              ErrorKind::AddrInUse => "address in use",
              ErrorKind::AddrNotAvailable => "address not available",
              ErrorKind::BrokenPipe => "broken pipe",
              ErrorKind::AlreadyExists => "entity already exists",
              ErrorKind::WouldBlock => "operation would block",
              ErrorKind::InvalidInput => "invalid input parameter",
              ErrorKind::InvalidData => "invalid data",
              ErrorKind::TimedOut => "timed out",
              ErrorKind::WriteZero => "write zero",
              ErrorKind::Interrupted => "operation interrupted",
              ErrorKind::Other => "other os error",
              ErrorKind::UnexpectedEof => "unexpected end of file",
          }
      }
    }
    }
    

slog库:结构化日志

这里还要提到一个库,slog,意为structured log,结构化日志。前面提到的日志都是非结构化日志,直接记录一段话,没有具体的格式。如果程序的日志数量比较小,那么非结构化日志是可以满足要求的,如果日志的数量很大,那么非结构化的日志就会带来诸多问题,就比如,格式多种多样,难以进行查询和解析。

何为结构化日志,就是具有明确具体结构的日志记录形式,最主要的是具有key-value的键值对的形式,典型的是使用json来记录日志,一个json条目就是一条日记,每个字段就是一个键值对。


#![allow(unused)]
fn main() {
debug!(log, "Here is message"; key1 => value1, key2 => value2);
}
//传统的非结构化日志
DEBUG 2018-02-05 02:00:45.541 [file:src/main.rs][line:43] CPU OVerload in location 100,ThreadId is 123456,MemoryUsage is 0,ThreadId is 234567,MemoryUsage is 0

//结构化日志
{
    "Timestamp": "2018-02-05 02:00:45.541",
    "Severity": "Debug",
    "File": "src/main.rs",
    "Line": "43",
    "Message": "Memory overflow",
    "Info": {
        "ThreadId": "123456",
        "MemoryUsage": "0",
        "ThreadId": "234567",
        "MemoryUsage": "0",
    }
}

日志是维测能力的一个重要方面,也是调试的重要工具。 传统上非结构化的字符串,很难进行后续的二次分析,日志的相关处理也很麻烦。目前结构化日志日趋流行,使用结构化日志,使日志文件具有机器可读性和更高级的功能,以易于解析的结构化格式编写日志文件。这意味着日志分析工具可以轻松地获取结构化日志数据,这也使处理和查询日志更容易,并且分析日志更快,针对特定的条目进行过滤和跟踪分析。

非结构化的日志查询,往往就是搜索关键字,速度慢,准确性差,容易查询出其他不相关的内容,效率低下。而目前的许多json分析工具,支持使用sql语言对条目进行查询;Google Cloud的提供的结构化日志的服务还内置了日志解析工具,提供图形化界面解析日志,定义了日志查询语言来进行查询。

最后,结构化日志可以帮助降低日志的存储成本,因为大多数存储系统上,结构化的键值数据比非结构化的字符串有更高的压缩率。


作者介绍:

楼智豪

任职于华为技术有限公司嵌入式软件能力中心,本文章仅代表作者个人观点,不代表公司意见。

新年新人新气象 | Rust 学习笔记

作者:李大狗(李骜华)/ 后期编辑: 张汉东


本系列所有源码:

https://github.com/leeduckgo/Rust-Study

新年新目标

打算在 2021 年学习一门新的编程语言,Rust 是一个很好的标的,一方面它及其具备实用性;另一个方面它也能让我们在更高的层面上理解计算机。

本系列将是我从Rust小学生开始的Rust学习过程全记录。

话不多说,我们开整。

由于是一门新的语言(相对 Java),所以传统的到网上去找一本好的入门教材的方法失效了。

那我们就来康康 Rust 能做什么有趣的事情,有什么有趣的Repo。

Substrate(Polkadot公链)、Libra(Facebook链)、WeDPR(FISCO BCOS 隐私保护组件)都是用 Rust 写的,不过评估一下,这些 Repo 的难度太高了,不适合用来作为语言入门。

后来发现 Rust 在 WebAssembly 方面目前进展很不错:

WebAssembly是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作。

简而言之

对于网络平台而言,WebAssembly具有巨大的意义——它提供了一条途径,以使得以各种语言编写的代码都可以以接近原生的速度在Web中运行。在这种情况下,以前无法以此方式运行的客户端软件都将可以运行在Web中。

image-20210212094819556

所以,Rust 的学习路线就这么定下来了,从wasm开始!

检索实例

既然确定了目标,那么可以开始检索相应的实例。这个实例有两个条件:

  • 光有文章是不行的,必须配套相应的的源码
  • 这个源码必须足够简洁,适合用来入门

经过一番检索,最后找到了这个:

项目代码:

https://github.com/RodionChachura/rust-js-snake-game/

运行地址:

https://rodionchachura.github.io/rust-js-snake-game/

教程地址:

https://geekrodion.com/blog/rustsnake

git clone 下来,运行了试试,的确可以。

但感觉不是我想要的,因为前端代码的内容太多了。

然后打开官方教程:

https://developer.mozilla.org/zh-CN/docs/WebAssembly/Rust_to_wasm

看到:

Rust 和 WebAssembly 有两大主要用例:

  • 构建完整应用 —— 整个 Web 应用都基于 Rust 开发!
  • 构建应用的组成部分 —— 在现存的 JavaScript 前端中使用 Rust。

目前,Rust 团队正专注于第二种用例,因此我们也将着重介绍它。对于第一种用例,可以参阅 yew 这类项目。

Yep,感觉我需要的是yew

Yew 的探索之旅

首先找到 yew的官网:

Yew is a modern Rust framework for creating multi-threaded front-end web apps with WebAssembly.

https://github.com/yewstack/yew

找到它官方的例子:

https://yew.rs/docs/zh-CN/getting-started/build-a-sample-app

结果,运行报错……

cargo-web is not compatible with web-sys.

遇到问题,第一时间,当然是到官方Repo里去检索啦,然后就搜到这么一条 Issue:

https://github.com/yewstack/yew/issues/1081

image-20210212102606760

建议使用 trunk,妥~

Trunk 的探索之旅

跳转到 Trunk Repo:

https://github.com/thedodd/trunk

发现里面有examples,于是直接 clone 下来运行:

image-20210212103935572

执行没问题,很好!

但是只有一个简单的实例,没法基于这个进行学习,怎么办?

我们回到 yew 的 Repo 里面,看下有没啥实例。

https://github.com/yewstack/yew/tree/master/examples

Examples 很多,也都能跑通,赞:

image-20210212104228167

魔改出 Base64 Encoder!

在入门一个新的计算机技术的时候,千万不要一开始就从0到1!因为从0到1的难度对新手来说太高。最开始应该先去魔改一个已有的项目。

我选择的是todomvc,原始是长这样:

image-20210212104717518

目的是把它修改成一个 Base64-Encoder:

image-20210212104823485

Ok,那我们来看看原始代码:


#![allow(unused)]
fn main() {
......
    fn view(&self) -> Html {
        let hidden_class = if self.state.entries.is_empty() {
            "hidden"
        } else {
            ""
        };
        html! {
            <div class="todomvc-wrapper">
                <section class="todoapp">
                    <header class="header">
                        <h1>{ "todos" }</h1>
                        { self.view_input() }
                    </header>
                    <section class=classes!("main", hidden_class)>
                        <input
                            type="checkbox"
                            class="toggle-all"
                            id="toggle-all"
                            checked=self.state.is_all_completed()
                            onclick=self.link.callback(|_| Msg::ToggleAll)
                        />
                        <label for="toggle-all" />
                        <ul class="todo-list">
                            { for self.state.entries.iter().filter(|e| self.state.filter.fits(e)).enumerate().map(|e| self.view_entry(e)) }
                        </ul>
                    </section>
                    <footer class=classes!("footer", hidden_class)>
                        <span class="todo-count">
                            <strong>{ self.state.total() }</strong>
                            { " item(s) left" }
                        </span>
                        <ul class="filters">
                            { for Filter::iter().map(|flt| self.view_filter(flt)) }
                        </ul>
                        <button class="clear-completed" onclick=self.link.callback(|_| Msg::ClearCompleted)>
                            { format!("Clear completed ({})", self.state.total_completed()) }
                        </button>
                    </footer>
                </section>
                <footer class="info">
                    <p>{ "Double-click to edit a todo" }</p>
                    <p>{ "Written by " }<a href="https://github.com/DenisKolodin/" target="_blank">{ "Denis Kolodin" }</a></p>
                    <p>{ "Part of " }<a href="http://todomvc.com/" target="_blank">{ "TodoMVC" }</a></p>
                </footer>
            </div>
        }
    }
}
......
}

挺好,这个就是前端部分了,我们把它删减一下:


#![allow(unused)]
fn main() {
    fn view(&self) -> Html {
        let hidden_class = if self.state.entries.is_empty() {
            "hidden"
        } else {
            ""
        };
        html! {
            <div class="todomvc-wrapper">
                <h1>{ "encode/decode" }</h1>
                { self.view_input() }
                <section class=classes!("main", hidden_class)>
                    <ul class="todo-list">
                        { for self.state.entries.iter().filter(|e| self.state.filter.fits(e)).enumerate().map(|e| self.view_entry(e)) }
                    </ul>
                </section>
            </div>
        }
    }
}

我们可以看到,输入的逻辑在view_input()这个地方,于是我们找到那个函数:


#![allow(unused)]
fn main() {
fn view_input(&self) -> Html {
        html! {
            // You can use standard Rust comments. One line:
            // <li></li>
            <input
                class="new-todo"
          			// 改掉replaceholder
                placeholder="What needs to be encode/decode?"
                value=&self.state.value
                oninput=self.link.callback(|e: InputData| Msg::Update(e.value))
                onkeypress=self.link.batch_callback(|e: KeyboardEvent| {
                    if e.key() == "Enter" { Some(Msg::Add) } else { None }
                })
            />
            /* Or multiline:
            <ul>
                <li></li>
            </ul>
            */
        }
    }
}

再找到Msg::Add


#![allow(unused)]
fn main() {
fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::Add => {
                //info!("add things");
                let description = self.state.value.trim();
                let description_handled = format!("{}: {}", description, encode(description.to_string()));

                if !description.is_empty() {
                    let entry = Entry {
                        description: description_handled,
                        completed: false,
                        editing: false,
                    };
                    //info!("{}", entry.description);
                    self.state.entries.push(entry);
                }
                self.state.value = "".to_string();
            }
......
}

这个时候,我想先调试一下,因此需要把一些数据打印出来。

这个时候,首先想到的是print大法:


#![allow(unused)]
fn main() {
println!("Input: {}", val);
}

但是,在trunk serve命令中,println!这个函数失效了!

trunkyew的 Repo 中进行检索,均未找到解决方案。

但是随即发现yew有 Discord Chatroom,于是乎进去搜索聊天记录。

image-20210212105847278

Yummy,这里提到只要使用wasm-logger即可。

https://crates.io/crates/wasm-logger

在项目里添加wasm-logger

......
// in the first of main.rs
#[macro_use] extern crate log;
......
fn main() {
		// init wasm logger!
    wasm_logger::init(wasm_logger::Config::default());
    yew::start_app::<Model>();
}

调用试试看:


#![allow(unused)]
fn main() {
fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::Add => {
                info!("add things");
......
}

妥了!

image-20210212110203302

接下来找到Rust Base64 的库,调用之(修改的地方用new标出了):


#![allow(unused)]
fn main() {
......
use base64::{encode, decode};
......
fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {
            Msg::Add => {
                // new
                info!("add things");
                let description = self.state.value.trim();
                // new
                let description_handled = format!("{}: {}", description, encode(description.to_string()));

                if !description.is_empty() {
                    let entry = Entry {
                      	// new
                        description: description_handled,
                        completed: false,
                        editing: false,
                    };
                  	// new
                    info!("{}", entry.description);
                    self.state.entries.push(entry);
                }
                self.state.value = "".to_string();
            }
}

运行之。

Okay,Base64-Encoder就做好了!

效果:

encoder

Cargo.toml最后长这样:


#![allow(unused)]
fn main() {
[package]
name = "encoder"
version = "0.1.0"
authors = ["Denis Kolodin <deniskolodin@gmail.com>"]
edition = "2018"

[dependencies]
strum = "0.20"
strum_macros = "0.20"
serde = "1"
serde_derive = "1"
yew = { path = "./packages/yew" }
yew-services = { path = "./packages/yew-services" }

log = "0.4.6"
wasm-logger = "0.2.0"
base64 = "0.13.0"
}

生成 ETH 公私钥与地址

本系列所有源码:

https://github.com/leeduckgo/Rust-Study

本篇是 Rust 学习笔记的第二篇。在第一篇里,我们魔改出了一个 Encoder,现在我们继续延续我们的魔改之路,挑战一个难度+1的Repo:

Rust library for generating cryptocurrency wallets

https://github.com/AleoHQ/wagyu

魔改目标 0x1:

抽取 Repo 中以太坊私钥、公钥、地址生成的部分,打印到控制台中。

但在魔改之前,笔者首先要对上一篇文章稍作补充,总结一下上篇文章中所涉及的知识点。

上篇文章中所涉及的知识点

  • 变量的赋值
  • format!函数(连接字符串)
  • 库的添加与使用,以wasm-logger为例
  • trunk 与 yew 结合,让Rust程序 wasm 化,使其在浏览器中可访问

跑一遍 wagyu

首先要验证这个库符合我们的需求,所以按照 Repo 中的 Readme,采用源码的方式跑一遍。

# Download the source code
git clone https://github.com/AleoHQ/wagyu
cd wagyu

# Build in release mode
$ cargo build --release
./target/release/wagyu

成功:

image-20210213091719643

在这个过程里,我们学习到了 cargo 的更多用法:

$ cargo run # 直接执行
$ cargo build # build 出 debug 版本,可执行文件在 ./target/debug 目录下
$ cargo build --release # build 出 正式版本(release version),可执行文件在 ./target/release 下

研究 wagyu 代码

首先喵一眼目录结构:

.
├── AUTHORS
├── Cargo.lock
├── Cargo.toml
├── LICENSE-APACHE
├── LICENSE-MIT
├── README.md
├── bitcoin
├── ethereum
├── model
├── monero
├── target
├── zcash
└── wagyu
      ├── cli
      │   ├── bitcoin.rs
      │   ├── ethereum.rs
      │   ├── mod.rs
      │   ├── monero.rs
      │   ├── parameters
      │   └── zcash.rs
      ├── lib.rs
      └── main.rs

我们可以看到,主入口是wagyu

wagyumain.rs中,会对cli目录下的子模块进行调用,进而对和cli平级的子模块进行调用。

其代码如下:

fn main() -> Result<(), CLIError> {
    let arguments = App::new("wagyu")
        .version("v0.6.3")
        .about("Generate a wallet for Bitcoin, Ethereum, Monero, and Zcash")
        .author("Aleo <hello@aleo.org>")
        .settings(&[
            AppSettings::ColoredHelp,
            AppSettings::DisableHelpSubcommand,
            AppSettings::DisableVersion,
            AppSettings::SubcommandRequiredElseHelp,
        ])
        .subcommands(vec![
            BitcoinCLI::new(),
            EthereumCLI::new(),
            MoneroCLI::new(),
            ZcashCLI::new(),
        ])
        .set_term_width(0)
        .get_matches();

    match arguments.subcommand() {
        ("bitcoin", Some(arguments)) => BitcoinCLI::print(BitcoinCLI::parse(arguments)?),
        ("ethereum", Some(arguments)) => EthereumCLI::print(EthereumCLI::parse(arguments)?),
        ("monero", Some(arguments)) => MoneroCLI::print(MoneroCLI::parse(arguments)?),
        ("zcash", Some(arguments)) => ZcashCLI::print(ZcashCLI::parse(arguments)?),
        _ => unreachable!(),
    }
}

我们再进入wagyu > cli > ethereum.rs目录下,发现里面有个简单的函数:


#![allow(unused)]
fn main() {
    pub fn new<R: Rng>(rng: &mut R) -> Result<Self, CLIError> {
        let private_key = EthereumPrivateKey::new(rng)?;
        let public_key = private_key.to_public_key();
        let address = public_key.to_address(&EthereumFormat::Standard)?;
        Ok(Self {
            private_key: Some(private_key.to_string()),
            public_key: Some(public_key.to_string()),
            address: Some(address.to_string()),
            ..Default::default()
        })
    }
}

很好,就拿这个改造了!

复制必要文件到新项目

  1. 新建项目
$ cargo new hello-crypto-rust

或者直接把上一个项目复制一份。

  1. wagyuCargo.toml中的必要内容复制过来

#![allow(unused)]
fn main() {
[dependencies]
log = "0.4"
pretty_env_logger = "0.3"

wagyu-ethereum = { path = "./ethereum", version = "0.6.3" }
wagyu-model = { path = "./model", version = "0.6.3" }

arrayvec = { version = "0.5.1" }
base58 = { version = "0.1" }
clap = { version = "~2.33.1" }
colored = { version = "1.9" }
digest = { version = "0.9.0" }
either = { version = "1.5.3" }
failure = { version = "0.1.8" }
hex = { version = "0.4.2" }
lazy_static = { version = "1.4.0" }
rand = { version = "0.7" }
rand_core = { version = "0.5.1" }
safemem = { version = "0.3.3" }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
tiny-keccak = { version = "1.4" }

[profile.release]
opt-level = 3
lto = "thin"
incremental = true

[profile.bench]
opt-level = 3
debug = false
rpath = false
lto = "thin"
incremental = true
debug-assertions = false

[profile.dev]
opt-level = 0

[profile.test]
opt-level = 3
incremental = true
debug-assertions = true
debug = true
}
  1. ethereummodel两个文件夹复制到hello-crypto-rust目录下

此时的文件目录是这个样子的:

.
├── Cargo.lock
├── Cargo.toml
├── ethereum
├── model
├── src
└── target

补充代码

  1. 补充lib.rs文件

src目录下新建lib.rs文件,内容:


#![allow(unused)]
fn main() {
pub extern crate wagyu_ethereum as ethereum;
pub extern crate wagyu_model as model;
extern crate pretty_env_logger;
}

作用是加载外部 crate,更详细的说明可见:

https://wiki.jikexueyuan.com/project/rust-primer/module/module.html

  1. 编写main.rs文件。

首先引用必要的外部模块:


#![allow(unused)]
fn main() {
use rand::{rngs::StdRng};
use rand_core::SeedableRng;
use hello_crypto_rust::ethereum::{EthereumPrivateKey, EthereumFormat};
use hello_crypto_rust::model::{PrivateKey, PrivateKeyError, AddressError, PublicKeyError, PublicKey};

#[macro_use] extern crate log;
}

然后我们编写主函数:

fn main(){
    pretty_env_logger::init();  // 初始化 pretty_env_logger 模块
    new(); //调用new函数
}

new()函数:


#![allow(unused)]
fn main() {
pub fn new() -> Result<EthereumPrivateKey, CreateError> {
    let rng = &mut StdRng::from_entropy();
    let private_key = EthereumPrivateKey::new(rng)?;
    info!("priv: {}", private_key.to_string());
    let public_key = private_key.to_public_key();
    info!("pub: {}", public_key.to_string());
    let address = public_key.to_address(&EthereumFormat::Standard)?;
    info!("addr: {}", address.to_string());
    Ok(private_key)
}
}

我们这里使用了相对于println!更高级的输出方式,通过log输出。

这里有个关键的语法糖——?,用于错误处理。

把 result 用 match 连接起来会显得很难看;幸运的是,? 运算符可以把这种逻辑变得 干净漂亮。? 运算符用在返回值为 Result 的表达式后面,它等同于这样一个匹配 表达式:其中 Err(err) 分支展开成提前返回的 return Err(err),而 Ok(ok) 分支展开成 ok 表达式。

—— https://rustwiki.org/zh-CN/rust-by-example/std/result/question_mark.html

两个等价的函数,一个使用了?,一个没有:


#![allow(unused)]
fn main() {
fn not_use_question_mark() {
    let a = 10;                                                   // 把这里改成 9 就会报错.
    let half = halves_if_even(a);
    let half = match half {
        Ok(item) => item,
        Err(e) => panic!(e),
    };
    assert_eq!(half, 5);
}


fn use_question_mark<'a >() -> Result<i32, &'a str> {              // 这里必须要返回Result
    let a = 10;
    let half = halves_if_even(a)?;                     // 因为?要求其所在的函数必须要返回Result
    assert_eq!(half, 5);
    Ok(half)                                                                   
}
}

然后,我们定义一下枚举类型CreateError,里面会囊括AddressErrorPrivateKeyErrorPublicKeyError


#![allow(unused)]
fn main() {
pub enum CreateError {
    AddressError(AddressError),
    PrivateKeyError(PrivateKeyError),
    PublicKeyError(PublicKeyError)
}

impl From<AddressError> for CreateError {
    fn from(error: AddressError) -> Self {
        CreateError::AddressError(error)
    }
}

impl From<PrivateKeyError> for CreateError {
    fn from(error: PrivateKeyError) -> Self {
        CreateError::PrivateKeyError(error)
    }
}

impl From<PublicKeyError> for CreateError {
    fn from(error: PublicKeyError) -> Self {
        CreateError::PublicKeyError(error)
    }
}
}

Try It!

实现成功:

image-20210213095659766

本篇所涉及的知识点

  • cargo 的更多用法
  • lib.rs的用法
  • 函数与函数返回值
  • pretty_env_logger的用法
  • 枚举类型,以CreateError为例

作者简介:

李大狗(李骜华),上海对外经贸大学区块链技术与应用研究中心副主任、柏链教育 CTO、FISCO BCOS(微众银行区块链框架)区块链认证讲师、5 年区块链工程师、北京大学硕士。 研究领域包括:区块链系统、共识机制、智能合约、区块链应用、数字身份等。

「译」使用 Rust 实现命令行生命游戏

译者:m1zzx2


原文:

介绍

你好!如果你看到了这篇文章,说明你对Rust感兴趣,并且想学习或者了解它。我早在2020年6月就编写了我的第一个Rust教程Rust + Actix + CosmosDB (MongoDB) tutorial api。这次,我将尝试介绍Rust的CLI。为了让这次的介绍更有趣,使用了Official Rust WebAssembly教程来实现“生命游戏”,来增强用户的交互逻辑。

虽然这是个新手教程,但是我仍然强烈建议你通过了官方的新手教程后再来做这个。 rustlings tutorial

可以在我的github仓库中找到“最终”代码

创造Universe

开始吧! 在创建一些新的项目像 new cli-game-of-life (或者 cargo init 如果你已经在一个正确的目录里面)之后。 使用你喜欢的编辑器打开它,目前要忽略main.rs。我们先要创建一个逻辑模块,所以继续创建一个src/game.rs文件。和前面说的一样,我将使用和wasm官方教程一样的逻辑来讲解,如果你之前做过它,你就会对它非常熟悉。让我们在游戏Universe里面来定义一个游戏单元格的枚举。

#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Cell {
    Dead = 0,
    Alive = 1,
}

derive 声明会告诉编译器提供(Copy, Clone, Debug, Eq, PartialEq)的基本实现,所以我们可以给单元分配枚举值并且比较他们。

注意: 我们也可以用bool值来实现一样的功能,不过使用enum可以具有更好的可读性,两者占用的内存是相等的。

我们的游戏Universe定义如下:

pub struct Universe {
    width: u32,
    height: u32,
    cells: Vec<Cell>,
}

好了现在我们开始实现游戏的函数了。让我们从一个方便的构造函数开始,这个构造函数将会设置Universe的大小,并初始化Cells的初始值。set_cells函数将会接受一个cells坐标,并把对应坐标的Cell设置成Alive状态。

impl Universe {
    pub fn new(width: u32, height: u32) -> Universe {
        Universe {
            width: width,
            height: height,
            cells: vec![Cell::Dead; (width * height) as usize],
        }
    }

    pub fn set_cells(&mut self, cells: &[(u32, u32)]) {
        for (row, col) in cells.iter().cloned() {
            let idx = self.get_index(row, col);
            self.cells[idx] = Cell::Alive;
        }
    }

    fn get_index(&self, row: u32, column: u32) -> usize {
        (row * self.width + column) as usize
    }
}

get_index 函数是一个辅助函数,它会把Universed的坐标翻译成cells数组对应的下标。

接下来,我们会实现Display特性,方便打印当前游戏的状态。

use std::fmt;

impl fmt::Display for Universe {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        for line in self.cells.as_slice().chunks(self.width as usize) {
            for &cell in line {
                let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
                write!(f, "{}", symbol)?;
            }
            write!(f, "\n")?;
        }

        Ok(())
    }
}

Perfect! Now we have something to run. Head over to your main.rs and replace all with the following content: 非常完美!现在我们需需要定义一个启动函数: 挑转到main.rs 用下面的内容替换main.rs的内容:

mod game;

fn main() {
    let mut game = game::Universe::new(5, 5);
    game.set_cells(&[(2, 1), (2, 2), (2, 3)]);
    print!("{}", game);
}

运行 cargo run之后 ,代码顺利的跑起来了,但是它实际上没有做什么,因此我们需要新增一个tick函数:

pub fn tick(&mut self) {
    let mut next = self.cells.clone();
    for row in 0..self.height {
        for col in 0..self.width {
            let idx = self.get_index(row, col);
            let cell = self.cells[idx];
            let live_neighbours = self.live_neighbour_count(row, col);
            next[idx] = match (cell, live_neighbours) {
                (Cell::Alive, x) if x < 2 => Cell::Dead,
                (Cell::Alive, 2) | (Cell::Alive, 3) => Cell::Alive,
                (Cell::Alive, x) if x > 3 => Cell::Dead,
                (Cell::Dead, 3) => Cell::Alive,
                (otherwise, _) => otherwise,
            };
        }
    }
    self.cells = next;
}

fn live_neighbour_count(&self, row: u32, column: u32) -> u8 {
    let mut count = 0;
    for delta_row in [self.height - 1, 0, 1].iter().cloned() {
        for delta_col in [self.width - 1, 0, 1].iter().cloned() {
            if delta_row == 0 && delta_col == 0 {
                continue;
            }

            let neighbour_row = (row + delta_row) % self.height;
            let neighbour_col = (column + delta_col) % self.width;
            let idx = self.get_index(neighbour_row, neighbour_col);
            count += self.cells[idx] as u8;
        }
    }

    count
}

该代码直接来自WASM锈皮书,它将Conway的《生命游戏》规则应用到我们的宇宙中,同时还要注意边缘包裹,以使我们的宇宙看起来像是循环的(请参见风味3)。 在使用刻度之前,我们需要准备终端以显示动画游戏Universe。 让我们现在就跳进去!

P.S. -您可以在我的GitHub上找到本章的源代码

这段代码来自wasm rust book ,它把ConWay的 Conway's Game Of Life 的规则应用到我们的universe中,它也会注意边界条件,让我们的universe看起来是循环运动的。看第三章

在我们使用tick函数之前,我们需要准备用终端去展示Universe 的界面,让我们来进入这个操作吧!

P.S -你们可以在这里找到本章的源代码

绘制游戏Universe

为了让终端输入输出,我们将会使用Crossterm crate包,因此我们需要把它添加进我们的Cargo.toml文件里面:

[dependencies]
crossterm = "0.19.0"

这个工具箱里面有很多方便的函数来操作终端,并且它是跨平台的,我们不需要担心任何平台的区别。大多数crossterm指令是容易理解的,因为他们被分进了不同的模块,就像cursor:Hide 就是和它的字面意思的一样,隐藏光标。

use crossterm::{
    cursor::{Hide, MoveTo, Show},
    event::{poll, read, Event},
    execute,
    style::{Color, Print, ResetColor, SetForegroundColor},
    terminal::{Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
    Result,
};
use std::io::stdout;
use std::time::Duration;

接下老,我们的main函数需要被填充成这个样子:

fn main() -> Result<()> {
    let mut game = game::Universe::new(5, 5);
    game.set_cells(&[(2, 1), (2, 2), (2, 3)]);
    execute!(
        stdout(),
        EnterAlternateScreen,
        SetForegroundColor(Color::Magenta),
        Hide
    )?;

    loop {
        if poll(Duration::from_millis(500))? {
            match read()? {
                Event::Key(_) => break,
                _ => {}
            }
        } else {
            execute!(
                stdout(),
                Clear(ClearType::All),
                MoveTo(0, 0),
                Print(&game),
                Print("Press enter to exit...")
            )?;
            game.tick();
        }
    }
    execute!(stdout(), ResetColor, Show, LeaveAlternateScreen)?;
    Ok(())
}

好的让我们拆解一下在做的事情:

  1. main函数现在返回了Result类型。这能让用户随时退出。
  2. 我们在execute!宏里面设置临时终端,它的第一个参数是std::io::Writer(这个case里面的输入)类,后面的参数是一些命令。
  3. 在这个循环里面,我们用poll去读取用户的输入,这样不会阻塞execution去绘画终端。当用户输入回车按钮时,这个循环就会退出,如果用户在500ms内没有输入,我们将会根据tick计算的状态重新绘画Universe。
  4. 循环结束以后,我们就会离开这个临时终端。 现在我们可以跑脚本cargo run 了。 你将会看到水平线和垂直线相互交替出现,但是输入enter,游戏没有停止。我们需要修改代码来实现这个功能。

和Universe交互

我们只能处理回车的原因是,默认的输入是在按下回车后处理的。通常,你的输入都准备好之后,在按下会车触发,这才有意义。但是在我们的需求里面,我们希望和一个键交互。这意味着我们需要启用raw mode. 新的代码会被改成这样:


// add required imports:
use terminal::{disable_raw_mode, enable_raw_mode};

// add this line at the very begining of the main() function:
enable_raw_mode()?;

// replace code block when poll returns true, the match statement, with following:

if let Event::Key(KeyEvent { code, .. }) = read()? {
    match code {
        KeyCode::Esc => {
            break;
        }
        _ => {}
    }
}

// finaly disable raw mode at the end of the function before returning Ok(()):
disable_raw_mode()?;

添加循环退出功能是很重要的,因为raw mode模式下,会禁用ctrl+c退出的方式。 现在你可以运行这个代码了,但是你会发现输出的格式都是乱的,这是因为raw mode不会处理换行符。现在我们需要将光标显示在正确的位置。这意味着我们不能用Display 特征来显示了。取而代之的,我们会遍历Universe,把每一行分别打印出来,向Universe中添加新方法:

pub fn row_as_string(&self, row: u32) -> Option<String> {
    if row < self.height {
        let mut row_string = String::new();
        let start = self.get_index(row, 0);
        let end = self.get_index(row, self.width);
        let line = &self.cells[start..end];
        for &cell in line {
            let symbol = if cell == Cell::Dead { '◻' } else { '◼' };
            row_string.push(symbol);
        }
        Some(row_string)
    } else {
        None
    }
}

如果该行和Universe大小一致,我们返回整行作为一个字符串,否则,返回None. 在我们的main.rs中,从crossterm队列中添加新的导入,请排队!宏类似于执行,但需要手动刷新。如果要有条件地构建输出,这将非常方便。让我们看看它如何进行。首先在main()函数的开头初始化一个新变量:

let mut stdout = stdout();

现在,可以把stdout()替换为我们的新名称,我们需要用以下代码替换整个循环:

loop {
    if poll(Duration::from_millis(500))? {
        if let Event::Key(KeyEvent { code, .. }) = read()? {
            match code {
                KeyCode::Esc => {
                    break;
                }
                _ => {}
            }
        }
    } else {
        queue!(stdout, Clear(ClearType::All))?;
        let mut i = 0;
        while let Some(line) = game.row_as_string(i) {
            queue!(stdout, MoveTo(0, i as u16), Print(line))?;
            i += 1;
        }

        queue!(
            stdout,
            MoveTo(0, (i + 1) as u16),
            Print("Press Esc to exit...")
        )?;
        stdout.flush()?;
        game.tick();
    }
}

按键处理逻辑不会改变,所有的更改都在else里面:

  1. 我们把execute!替换成 queue! 宏。

  2. 遍历Universe的每一行,queue! 会直接打印结果,你会看到返回Option 有多方便!我们不需要任何额外的处理,这个代码看起来会很干净。

  3. 在所有文本都准备好之后,我们调用 flush() 刷新到输出。

接受参数

使用std :: env :: args函数可以非常简单的接受参数.但是我想展示一些依赖外部包 clap的方法。有三种配置clap的方式:

  • 'Builder Pattern'
  • yaml配置
  • 宏 'Builder Pattern'是我最喜欢的一种方式,它可以动态扩展输入的参数,并提供一些检查。对于像这样的简单项目,将配置放在main.rs中是完全可以的,随着项目复杂度的增长,可能湖考虑把配置放在单独的文件里面,可以有更好的可读性。首先Cargo.toml添加依赖:
clap = "2.33.3"

接下来更新我们的main.rs文件:

use clap::{crate_version, App, Arg};

//below code goes at the beginning of main() function:
  let matches = App::new("CLI Game Of Life")
        .version(crate_version!())
        .author("jbarszczewski")
        .about("Simple implementation of Conway's Game Of Life in Rust.")
        .after_help("Have fun!")
        .arg(
            Arg::with_name("INPUT")
                .help("Sets the input file to configure initial state of game")
                .short("i")
                .long("input")
                .takes_value(true),
        )
        .arg(
            Arg::with_name("DELAY")
                .help("Sets the delay between game ticks. Value is in miliseconds")
                .short("d")
                .long("delay")
                .takes_value(true)
                .default_value("500"),
        )
        .get_matches();

clap包会创建两个子命令(除非你覆盖了它们):

  • help (-h or --help)
  • version (-V --version) That's why we provide basic info about the app. You may notice crate_version! macro, this will grab the version number from your Cargo.toml file so you don't need to manually update it. Then we add two arguments, INPUT and DELAY, with some description how to use it. Build your app with cargo build (you will find binary in /target/debug directory) and run like this ./cli-game-of-life -h which will print out help page:

CLI Game of Life 0.2.0
jbarszczewski
Simple implementation of Conway's Game of Life in Rust.

USAGE:
    cli-game-of-life [OPTIONS]

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -d, --delay <DELAY>    Sets the delay between game ticks. Value is in miliseconds [default: 500]
    -i, --input <INPUT>    Sets the input file to configure initial state of game

Have fun!

现在,可以写代码获取你输入的值:

if let Some(input) = matches.value_of("INPUT") {
    println!("A config file was passed: {}", input);
}

value_of() 将会返回 Option 因此你可以输入的数据是否存在而采取相应的行动,我们把DELAY设置成了默认值,也就是说无论有没有解析到输入,我们都会可以采取行动.现在我们不会使用value_of(), 在这个项目里面,我们会使用flag接受参数。默认情况下,所有的clap参数都是flag,这也就是为什么我们在描述INPUT和DELAY的时候,需要添加take_values()方法。因为flag并不会有值,当我们使用它们,像在这种场景:

if matches.is_present("TEST") {
    println!("TEST!");
}

这里有太多的可能的配置,所以我只建议你用到配置的时候才去看文档

好的,我们通过配置,已经能让我们的应用接受参数了,但是他们不会做任何处理,接下来将会做一些处理。

控制速度

让我们使用DELAY参数,现在我们的游戏hard-code了500ms作为刷新下一个状态的频率,动态地改变它是很简单的,首先,我们需要去读并且解析(Duration::from_millis() accept u64)我们输入的参数:

let delay:u64 = matches.value_of("DELAY").unwrap().parse().unwrap();

我们的第一个unwrap(返回空,将会抛出panic),来检查输入是否为空,第二个unwrap(如果返回Err,将会抛出panic)来检查输入是不是一个合法的int, panic时候,我们希望程序退出。如果你想定制第二个错误,你需要写下面的逻辑:

let delay: u64 = match matches.value_of("DELAY").unwrap().parse() {
    Ok(val) => val,
    Err(e) => {
        println!("Error parsing DELAY argument: {}", e);
        500
    }
};

然后我们可以吧poll 函数里面的500换成delay变量。如果你想测试脚本是否正确运行,你需要执行这样的脚本: ./cli-game-of-life -d 200(记住这个值是毫秒) 这里有个小问题。由于处理的方式,我们需要在delay ms后,才展示屏幕上面的内容,如果delay5秒,那么程序开始的5秒不会有任何输出。我们可以用"drawing"修复它, 代码:

loop {
    queue!(stdout, Clear(ClearType::All))?;
    let mut i = 0;
    while let Some(line) = game.row_as_string(i) {
        queue!(stdout, MoveTo(0, i as u16), Print(line))?;
        i += 1;
    }

    queue!(
        stdout,
        MoveTo(0, (i + 1) as u16),
        Print("Press Esc to exit...")
    )?;
    stdout.flush()?;
    if poll(Duration::from_millis(delay))? {
        if let Event::Key(KeyEvent { code, .. }) = read()? {
            match code {
                KeyCode::Esc => {
                    break;
                }
                _ => {}
            }
        }
    }

    game.tick();
}

定义Universe

现在是使用INPUT参数的时候了,这个参数制定了universe的配置路径,文件将会是下面这种格式:

5
5
00000
00100
00010
01110
00000

第一行代表Universe的行数,第二行代表Universe的列数,接下来就是描述Universe每个格子的详情,0代表死,1代表或者。现在这里有两个地方你可以放置配置文件:

  1. 项目的根目录,一些文件像是Cargo.toml就在这个里面,并且你能通过脚本cargo run -- -i INPUT跑你的应用。使用cargo运行之后的内容,都可以作为参数传递给你的项目。
  2. ./target/debug. 这意味着您需要在每次更改后重新构建,然后执行/debug/cli-game-of-life -i starship。 在本次教程里面,建议使用第一种方式,因为它更方便。上面的配置在“Game of Life”中称为starship pattern,因此我们将文件命名为一样的,然后继续下一步 我们将会读取这个文件,首先需要导入一个新的依赖:
use std::fs::File;
use std::io::{BufRead, BufReader};

下面是解析文件的函数,返回game::Universe::

fn create_game_from_file(path: &str) -> game::Universe {
    let file = File::open(path).unwrap();
    let mut reader = BufReader::new(file);
    let mut line = String::new();
    let mut rows_number = 0;
    if let Ok(success) = reader.read_line(&mut line) {
        if success > 0 {
            rows_number = line.trim().parse().unwrap();
            line.clear();
        } else {
            panic!("Rows number not detected!");
        }
    };
    let mut cols_number = 0;
    if let Ok(success) = reader.read_line(&mut line) {
        if success > 0 {
            cols_number = line.trim().parse().unwrap();
            line.clear();
        } else {
            panic!("Columns number not detected!");
        }
    };
    let mut game_universe = game::Universe::new(cols_number, rows_number);
    let mut row = 0;
    let mut live_cells = Vec::<(u32, u32)>::new();
    loop {
        match reader.read_line(&mut line) {
            Ok(0) => break,
            Ok(_) => {
                let mut col = 0;
                for char in line.chars() {
                    match char {
                        '1' => live_cells.push((row, col)),
                        _ => {}
                    }
                    col += 1;
                }
            }
            _ => break,
        }

        line.clear();
        row += 1;
    }
    game_universe.set_cells(&live_cells);
    game_universe
}

这看起来很长而且有一定重构的空间,但是比较容易理解:

  1. 打开文件,写入BufReader。
  2. 创建变量line读取每一行。
  3. 尝试去解析行数和列数。
  4. 创建新的 Universe。
  5. 遍历剩余行,解析cell,写入vector。
  6. 调用game_universe.set_cell方法,把vector的值写入对象,然后返回。

我们需要做的最后一件事情就是让我们的新的函数得到使用,在main函数里面删除初始化游戏的逻辑,并且把我们新的代码放在解析DELAY变量后面:

let mut game = match matches.value_of("INPUT") {
    Some(path) => create_game_from_file(path),
    None => {
        let mut default_game = game::Universe::new(5, 5);
        default_game.set_cells(&[(2, 1), (2, 2), (2, 3)]);
        default_game
    }
};

这个逻辑很简单:我们尝试读取INPUT参数,如果一个通过了,我们接下来调用create_game_from_file方法,如果没通过,我们然后默认的universe。

现在我们可以调用cargo run -- -i starship并且享受美景!你可以使用更大的场地,类似15*15, 并且由于我们不校验参数,所以不需要在每行最后输入0。

总结

希望您喜欢本教程,多谢您的阅读!


译者介绍:

m1zzx2 ,Rust 初学者,知乎工程师。

「译」使用 Tokio 实现 Actor 系统

译者:Matrixtang

原文:https://ryhl.io/blog/actors-with-tokio/


本文将不使用任何 Actors 库(例如 Actix ) 而直接使用Tokio实现 Actors 系统。事实上这甚至是更容易的,但是还是有一些细节需要注意:

  1. tokio::spawn 的调用位置。
  2. 使用带有run方法的结构体还是裸函数。
  3. Actor 的 Handle 函数。
  4. 背压( Backpressure ) 和 有界信道。
  5. 优雅的关闭。

本文概述的技术适用于任何执行器,但为简单起见,我们仅讨论Tokio。与Tokio教程中的 spawningchannel chapters章节有一些重叠, 当然啦,我建议也阅读这些章节。

​ 在讨论如何编写 Actor 之前,我们需要知道 Actor 是什么。Actor 背后的基本思想是产生一个独立的任务,该任务独立于程序的其他部分执行某些工作。 通常,这些参与者通过使用消息传递信道与程序的其余部分进行通信。 由于每个 Actor 独立运行,因此使用它们设计的程序自然是并行的。 Actor 的一个常见用法是为 Actor 分配你要共享的某些资源的专有所有权,然后让其他任务通过与 Actor 通信来间接访问彼此的资源。 例如,如果要实现聊天服务器,则可以为每个连接生成一个任务,并在其他任务之间路由一个聊天消息的主任务。 十分有用,因为主任务可以避免必须处理网络IO,而连接任务可以专门处理网络IO。

实现

​ Actor 分为两部分:任务和handle。 该任务是独立生成的Tokio任务,实际上执行 Actor 的职责,而 handle 是一种允许你与该任务进行通信的结构。

​ 让我们考虑一个简单的 Actor 。 Actor 在内部存储一个计数器,该计数器用于获取某种唯一ID。 Actor 的基本结构如下所示:


#![allow(unused)]
fn main() {
use tokio::sync::{oneshot, mpsc};

struct MyActor {
    receiver: mpsc::Receiver<ActorMessage>,
    next_id: u32,
}
enum ActorMessage {
    GetUniqueId {
        respond_to: oneshot::Sender<u32>,
    },
}

impl MyActor {
    fn new(receiver: mpsc::Receiver<ActorMessage>) -> Self {
        MyActor {
            receiver,
            next_id: 0,
        }
    }
    fn handle_message(&mut self, msg: ActorMessage) {
        match msg {
            ActorMessage::GetUniqueId { respond_to } => {
                self.next_id += 1;

                // The `let _ =` ignores any errors when sending.
                // `let _ =` 忽略了发送的任何 error
                // This can happen if the `select!` macro is used
                // to cancel waiting for the response.
                // 当 `select!` 宏被用到时将会停止接受响应
                let _ = respond_to.send(self.next_id);
            },
        }
    }
}

async fn run_my_actor(mut actor: MyActor) {
    while let Some(msg) = actor.receiver.recv().await {
        actor.handle_message(msg);
    }
}
}

现在我们有了 Actor 本身,我们还需要一个与 actor 配套的handle 。 handle 是其他代码段可以用来与 actor 对话的对象,也是让 Actor 存活的原因。

以下是 handle 的实现:


#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct MyActorHandle {
    sender: mpsc::Sender<ActorMessage>,
}

impl MyActorHandle {
    pub fn new() -> Self {
        let (sender, receiver) = mpsc::channel(8);
        let actor = MyActor::new(receiver);
        tokio::spawn(run_my_actor(actor));
        // 译者提醒: 注意 tokio::spawn 的位置
        Self { sender }
    }

    pub async fn get_unique_id(&self) -> u32 {
        let (send, recv) = oneshot::channel();
        let msg = ActorMessage::GetUniqueId {
            respond_to: send,
        };

        // Ignore send errors. If this send fails, so does the
        // recv.await below. There's no reason to check for the
        // same failure twice.
        // 忽略发送 error 。如果它发送失败, 将会执行下方的 recv.await
        // 检测同样的错误两次是没有道理的。
        let _ = self.sender.send(msg).await;
        recv.await.expect("Actor task has been killed")
    }
}
}

full example

让我们仔细看一下本示例中的不同部分。

ActorMessage. ActorMessage 枚举定义了我们可以发送给 Actor 的消息类型。 通过使用这个枚举,我们可以拥有许多不同的消息类型,并且每种消息类型都可以具有自己的参数集。我们通过oneshot信道向 sender 返回值 , 而这种信道只允许发送一条消息。

在上面的示例中,我们在 actor 结构的 handle_message 方法中的枚举上进行了匹配,但这不是构造此方法的唯一办法。 也可以在 run_my_actor 函数的枚举中进行匹配。 然后,此匹配项中的每个分支都可以在 actor 对象上调用各种方法,例如 get_unique_id

发送消息时出错 在处理信道时,并非所有错误都是致命( fatal )的。 因此,该示例有时使用 let _ = 来忽略错误。 通常,如果 receiver 被丢弃,那在信道上的 send 操作将失败。 在我们的示例中,此操作的第一个实例是 actor 中我们响应已发送的消息的那行 。


#![allow(unused)]
fn main() {
let _ = respond_to.send(self.next_id);)
}

这将发生在接收方不再需要操作的结果的情形下,例如 发送消息的任务可能已被杀死。

关闭Actor 我们可以通过查看接收消息是否失败来决定何时关闭 Actor 。 在我们的示例中,这发生在以下 while 循环中:


#![allow(unused)]
fn main() {
while let Some(msg) = actor.receiver.recv().await {
    actor.handle_message(msg);
}
}

当所有发送到receiversender 都被丢弃时,我们就知道将不会再收到其他信息了,因此可以关闭 Actor 。 当这种情况发生时,调用.recv()将返回 None ,并且由于它与模式Some(msg)不匹配,while 循环将退出并且函数会返回。

结构体的 run 方法

​ 我上面给出的示例使用的顶层函数并未在任何结构上定义,因为我们将其作为 Tokio 任务产生 ,但是许多人发现直接在 MyActor 结构体中定义 run方法并且启动更加自然。 也不是不行,但是我举这个使用顶层函数的示例的原因是,使用这种方法就可以避免很多由生命周期而产生的问题了。 为了说清楚这种问题,我准备了一个例子,说明不熟悉该模式的人经常会想到什么。


#![allow(unused)]
fn main() {
impl MyActor {
    fn run(&mut self) {
        tokio::spawn(async move {
            while let Some(msg) = self.receiver.recv().await {
                self.handle_message(msg);
            }
        });
    }

    pub async fn get_unique_id(&self) -> u32 {
        let (send, recv) = oneshot::channel();
        let msg = ActorMessage::GetUniqueId {
            respond_to: send,
        };

        // Ignore send errors. If this send fails, so does the
        // recv.await below. There's no reason to check for the
        // same failure twice.
        let _ = self.sender.send(msg).await;
        recv.await.expect("Actor task has been killed")
    }
}

... and no separate MyActorHandle
}

这个示例存在两个问题:

  1. tokio::spawnrun 方法中被调用。
  2. Actor 和 handle 其实是一个结构体。

导致问题的第一个原因是,因为tokio :: spawn函数要求参数为 'static'。那就意味着新任务必须拥有完整的所有权,这就导致了该方法借用了self,所以它无法将 self 的所有权交给新任务。

第二个问题是,因为Rust强制实施了单一所有权原则。 如果将 actor 和 handle 都合并为同一个结构体,则(至少从编译器的角度来看)将使每个handle 都可以访问 actor 的任务所拥有的全部字段。 例如, next_id 应仅由 actor 任务拥有,而且不应该让任何 handle 直接访问。

也就是说,有一个通过解决以上两个问题,变得可行的版本。代码如下:


#![allow(unused)]
fn main() {
impl MyActor {
    async fn run(&mut self) {
        while let Some(msg) = self.receiver.recv().await {
            self.handle_message(msg);
        }
    }
}

impl MyActorHandle {
    pub fn new() -> Self {
        let (sender, receiver) = mpsc::channel(8);
        let actor = MyActor::new(receiver);
        tokio::spawn(async move { actor.run().await });

        Self { sender }
    }
}
}

该函数与顶层函数相同。 请注意,严格来讲,可以编写tokio :: spawnrun内的那种 , 但是我并不推荐。

actor 的 其他变体

​ 我在本文中的示例使用了参与者使用消息的请求-响应模型(request-response),但是这不是必须的。 在本节中,我将给你一些使用其他方式的例子,给你一些启发。

不对消息回应

​ 在之前的示例中我们介绍了一种使用oneshot信道发送对消息响应的方式,但是并不总是需要响应。在这些情况下,仅在消息枚举中不包含 oneshot 信道是没有问题的。当信道中有空间时,这甚至可以让你在处理完消息之前就返回。 但是仍应确保使用有界信道,以保证在该信道中等待的消息数不会无限增长。在某些情况下,这意味着仍然需要由一个异步函数来处理发送操作,用于处理等待信道需要更多空间的情况。 但是,还有一种替代方法可以使send操作成为异步的。即使用 try_send 方法,并通过简单地杀死 Actor 来处理发送失败的情况。这在 Aoctor 管理 TcpStream 时,用于转发发送到连接中的任何消息的情况下是很有用的。这种情况下,如果无法继续向 TcpStream 写入 ,则可直接关闭连接。

多个handle共享一个 Actor

​ 如果需要从不同的地方向 actor 发送消息,则可以使用多个 handle 来强制某些消息只能从某些地方发送。 当使用这种方法时,你仍然可以在内部重复使用相同的 mpsc 通道,并使用其中包含所有可能的消息类型的枚举。 如果你不得不想要为此使用单独的信道,则 actor 可以使用 tokio::select! 来一次性冲多个信道中接受信息。


#![allow(unused)]
fn main() {
loop {
    tokio::select! {
        Some(msg) = chan1.recv() => {
            // handle msg
        },
        Some(msg) = chan2.recv() => {
            // handle msg
        },
        else => break,
    }
}
}

需要注意的是在信道关闭时的处理方式,因为在这种情况下,它们的 recv 方法会立即返回 None 。 幸运的是,tokio :: select! 宏允许您通过提供 Some(msg) 来处理这种情况。 如果仅关闭一个信道,则该分支将被禁用,另外一个信道依旧是可用的。 当两者都关闭时,else分支运行并使用break退出循环。

Actors 间发送信息

​ 让 Actor 将消息发送给其他 Actor 也是可行的。 为此,只需为一个 Actor 提供其他 Actor 的 handle 即可。 当Actor 形成了循环时,需要上点心,因为为了保持彼此的 handle 存活,防止 Actor 被关闭最后一个 sender 不会被丢弃。 为了处理这种情况,您可以让一个 actor 具有两个带有独立的mpsc通道的 handle ,tokio :: select!会被用在下面这个示例里 :


#![allow(unused)]
fn main() {
loop {
    tokio::select! {
        opt_msg = chan1.recv() => {
            let msg = match opt_msg {
                Some(msg) => msg,
                None => break,
            };
            // handle msg
        },
        Some(msg) = chan2.recv() => {
            // handle msg
        },
    }
}

}

如果 chan1 关闭,即使chan2仍然打开,上述循环也将退出。 如果 chan2 是 Actor 循环的一部分,则这会中断该循环并让 Actor 关闭。

只需要简单的在循环里调用 abort 就可以了。

多个 Actors 共享一个 handle

​ 就像每个 Actor 可以共享多个 handle 一样,每个 handle 也可以共享多个 Actors 。 最常见的示例是在处理诸如 TcpStream之类的连接时,通常会产生两个任务:一个用于读,一个用于写。 使用此模式时,需要将读和写入任务变得尽可能简单——它们的唯一工作就是执行IO。 读任务会将接收到的所有消息发送给其他任务,通常是另一个 Actor ,而写任务会将接收到的所有消息转发给连接。 这种模式非常有用,因为它把与执行IO相关的复杂性隔离开来,这意味着其他程序部分可以假装将某些内容立即写入连接,尽管实际的写入其实是在 Actor 处理消息后进行的。

当心循环

​ 我已经在Actors 间发送信息 标题下讨论了一些关于循环的问题,在此我讨论了如何关闭循环的Actors。但是,如何关闭并不是循环可能导致的唯一问题,因为这种循环还会产生死锁,循环中的每个 Actor 都在等待下一个 Actor 接收消息,但是下一个 Actor 直到它的下一个Actor接收到消息才会接收到该消息,依此类推。 为避免这种死锁,必须确保循环的信道容量都不受限。这样做的原因是有界信道上的 send 方法不会立即返回,而具有立即返回send 方法的信道是不记入这种循环,因为这种send方法是不会产生死锁的。 当心,这意味着oneshot 信道也不会产生死锁,因为它们也有 立即返回的 send 方法。还要当心,如果使用的是 try_send 而不是send来发送消息,那么这也不是死锁循环的一部分。

感谢 matklad指出循环和死锁的问题。


译者简介:

Matrixtang,Rust/cpp 程序员,对编译相关领域感兴趣,不会 pwn 的安全爱好者。

解读 Rust 1.50 稳定版

作者:张汉东 / 后期编辑: 张汉东


2021 年 2 月 11 号,Rust 1.50 稳定版发布。1.50 版更新包括:

  • 语言级特性
  • 编译器
  • 标准库
  • 稳定的 API
  • Cargo 相关
  • 其他
  • 兼容性提示

以下挑一些重点讲解。

语言级特性

常量泛型 [CONST; N] 进一步得到完善:

  • 常量泛型数组实现了 ops::Indexops::IndexMut
  • 值重复的常量数组[x; N]现在支持 常量值作为 x ,无论 x 是否实现 Copy

Rust 有一种内置数组类型[T; LEN],但是这个 LEN一直无法支持泛型,所以这类数组就沦为了二等公民。比如 [0,0,0][0,0,0,0]不是同一个类型。所谓一等公民应该是不管数组长度如何,至少可以用同一个类型表示。为了提升这个数组类型,就引入了常量泛型的支持。[CONST; N] 是从 1.38 版本开始筹划,在 Rust 1.38~1.46 版本内,引入了一个std::array::LengthAtMost32来限制默认[T; LEN]的长度不能超过 32 。到 Rust 1.47 版本,首次在内部引入了 [CONST; N] 的支持。

直到 Rust 1.50 版本,进一步对[CONST; N] 功能进行了完善。

对常量泛型数组实现了 ops::Indexops::IndexMut

fn second<C>(container: &C) -> &C::Output
where
    C: std::ops::Index<usize> + ?Sized,
{
    &container[1]
}

fn main() {
    let array: [i32; 3] = [1, 2, 3];
    assert_eq!(second(&array[..]), &2); // 之前必须转成切片才可以
    assert_eq!(second(&array), &2); // 现在直接传引用就可以了
}

值重复的常量数组[x; N]现在支持 常量值作为 x :

fn main() {
    // 这行代码是不允许的,因为`Option<Vec<i32>>` 没有实现 `Copy`。
    let array: [Option<Vec<i32>>; 10] = [None; 10];

  	// 但是,现在改成 `const` 定义就可以了
    const NONE: Option<Vec<i32>> = None;
    const EMPTY: Option<Vec<i32>> = Some(Vec::new());

    // 虽然没有实现`Copy`,但是现在可以重复`const`的值了。
    let nones = [NONE; 10];
    let empties = [EMPTY; 10];
}

这样写起来可能比较麻烦,但是在随后 RFC 2920: inline const 功能稳定后,就可以写成下面这种形式了:

fn main() {
    // 这行代码是不允许的,因为`Option<Vec<i32>>` 没有实现 `Copy`。
    let array: [Option<Vec<i32>>; 10] = [None; 10];

    // 虽然没有实现`Copy`,但是现在可以重复`const`的值了。
    let nones : [Option<Vec<i32>>; 10] = [const {None}; 10];
    let empties : [Option<Vec<i32>>; 10]  = [const {Some(Vec::new())}; 10];
}

其实可以 Rust 本可以做到下面这种形式:

fn main() {
    // 这行代码是不允许的,因为`Option<Vec<i32>>` 没有实现 `Copy`。
    let array: [Option<Vec<i32>>; 10] = [None; 10];

    // 虽然没有实现`Copy`,但是现在可以重复`const`的值了。
    let nones : [Option<Vec<i32>>; 10] = [None; 10];
    let empties : [Option<Vec<i32>>; 10]  = [Some(Vec::new()); 10];
}

上面NoneSome(Vec::new())可以自动被编译器提升为常量,但这样可能为用户带来困扰,对于一些不能被自动提升为常量的类型,还需要用户去学习一大堆常量提升规则,并且使用 const fn等功能来定义常量。倒不如显示地加一个 const 块表达式来直接标注更好。

另外,关于#![feature(min_const_generics)]将在 Rust 1.51 中稳定,预计 2021-03-25

将共用体(union)中ManualDrop类型字段的分配视为安全

// Rust 1.49 新增特性,允许 union 中使用 ManuallyDrop
use core::mem::ManuallyDrop;

union MyUnion {
    f1: u32,
    f2: ManuallyDrop<String>,
}

fn main() {
    let mut u = MyUnion { f1: 1 };

    // These do not require `unsafe`.
    u.f1 = 2;
    u.f2 = ManuallyDrop::new(String::from("example"));
}

Union 类型 中 CopyManuallyDrop的字段不会调用析构函数,所以不必加 unsafe块。

进一步,当 Drop 一个 Union 类型的时候,需要手工去实现 Drop。因为 共用体 本身的特性,它不会知道该 drop 哪个字段才是安全的,所以才需要字段都是 CopyManuallyDrop的。

#![feature(untagged_unions)]
use std::mem::ManuallyDrop;
use std::cell::RefCell;

union U1 {
    a: u8
}

union U2 {
    a: ManuallyDrop<String>
}

union U3<T> {
    a: ManuallyDrop<T>
}

union U4<T: Copy> {
    a: T
}

// 对于 ManuallyDrop 之外的 非 Copy 类型,目前还是 unstable,需要 `#![feature(untagged_unions)]` 特性门支持。
union URef {
    p: &'static mut i32,
}

// RefCell 没有实现 Drop ,但是它是非 Copy 的
union URefCell { // field that does not drop but is not `Copy`, either
    a: (RefCell<i32>, i32),
}

fn generic_noncopy<T: Default>() {
    let mut u3 = U3 { a: ManuallyDrop::new(T::default()) };
    u3.a = ManuallyDrop::new(T::default()); // OK (assignment does not drop)
    
}

fn generic_copy<T: Copy + Default>() {
    let mut u3 = U3 { a: ManuallyDrop::new(T::default()) };
    u3.a = ManuallyDrop::new(T::default()); // OK
    
    let mut u4 = U4 { a: T::default() };
    u4.a = T::default(); // OK
}

fn main() {
    let mut u1 = U1 { a: 10 }; // OK
    
    u1.a = 11; // OK

    let mut u2 = U2 { a: ManuallyDrop::new(String::from("old")) }; // OK
    u2.a = ManuallyDrop::new(String::from("new")); // OK (assignment does not drop)

    let mut u3 = U3 { a: ManuallyDrop::new(0) }; // OK
    u3.a = ManuallyDrop::new(1); // OK

    let mut u3 = U3 { a: ManuallyDrop::new(String::from("old")) }; // OK
    u3.a = ManuallyDrop::new(String::from("new")); // OK (assignment does not drop)
    
}

编译器

  • 添加对armv5te-unknown-linux-uclibcgnueabi目标的内置支持。 基于ARMv5TE指令集的,你可以认为是ARM处理器,但实际上已经有原来intel的很多技术在里面进行了修改。
  • 在ARM Mac上添加对Arm64 Catalyst的支持。苹果很快将发布基于ARM64的Mac,macOS应用将使用在ARM上运行的Darwin ABI。 该PR增加了对ARM Macs上Catalyst应用程序的支持:为darwin ABI编译的iOS应用程序。
  • 修复 FreeBSD 上的链接问题。在FreeBSD上,有时会出现一个问题,即使基本系统中包含lld,由于 Rust 未找到链接程序,链接 Rust 程序也会失败。 这似乎主要影响裸机/交叉编译,例如wasm构建和arm / riscv裸机工作(例如,尝试编译时)。 在Linux和其他操作系统上,启用了用于构建 Rust 的完整工具,因此没有链接问题。 如果使用这些选项正确构建了 Rust,则此PR应该可以在FreeBSD上启用完整的功能。

除了这三个,还有其他 target 支持,查看Platform Support 页面

标准库

proc_macro::Punct增加 PartialEq<char>

用于在宏中判断特殊标点符号更加方便。比如:


#![allow(unused)]
fn main() {
// ...
else if let TokenTree::Punct(ref tt) = tree {
  if tt.as_char() == '$' {
    after_dollar = true;
    return None;
  }
  // ...
 if p.as_char() == '>' { 
  // ...
if tt.as_char() == '=' { 
}

Unix 平台优化:Option<File> 大小等价于 File

在Unix平台上,Rust 的文件仅由系统的整数文件描述符组成,并且它永远不会为-1! 返回文件描述符的系统调用使用-1表示发生了错误(检查errno),因此-1不可能是真实的文件描述符。 从Rust 1.50开始,此niche(特定生态场景)被添加到类型的定义中,因此它也可以用于布局优化。 因此,Option <File>现在将具有与File本身相同的大小!

兼容性变更

过期 compare_and_swap 方法

推荐使用 compare_exchangecompare_exchange_weak。过期这个cas方法一方面是为了和 cppcompare_exchange_strongcompare_exchange_weak 对应,另一方面也是为了避免使用这个cas在 arm 架构下产生不必要的指令,因为有 cas 的时候,很多人可能会直接使用 cas,从而在 ARM 下产生不必要的指令。

ARM 架构实现LL/SC对(load-linked/store-conditional) ,可以基于它们实现 cas。Load-linked(LL) 运算仅仅返回指针地址的当前变量值,如果指针地址中的内存数据在读取之后没有变化,那么 Store-conditional(SC)操作将会成功,它将LL读取 指针地址的存储新的值,否则,SC将执行失败。

通过LL/SC对实现的CAS并不是一个原子性操作,但是它确实执行了原子性的CAS,目标内存单元内容要么不变,要么发生原子性变化。由于通过LL/SC对实现的CAS并不是一个原子性操作,于是,该CAS在执行过程中,可能会被中断。因此C++11标准中添入两个compare_exchange原语: compare_exchange_weakcompare_exchange_strong。即使当前的变量值等于预期值,这个弱的版本也可能失败,比如返回false。可见任何weak CAS都能破坏CAS语义,并返回false,而它本应返回true。而Strong CAS会严格遵循CAS语义。

何种情形下使用Weak CAS,何种情形下使用Strong CAS呢?通常执行以下原则:

倘若CAS在循环中(这是一种基本的CAS应用模式),循环中不存在成千上万的运算(循环体是轻量级和简单的),使用compare_exchange_weak。否则,采用强类型的compare_exchange_strong

因此,Rust 标准库过期 cas 方法,就是为了让开发者可以根据场景来判断使用 强还是弱的 cas 语义。而 标准库里的cas方法则只是对 compare_exchange 的包装,而 Rust 中 compare_exchange 对应 强CAS 语义,所以容易被滥用。

放弃对所有 cloudabi target 的支持

包括:

  • aarch64-unknown-cloudabi
  • armv7-unknown-cloudabi
  • i686-unknown-cloudabi
  • x86_64-unknown-cloudabi

因为 CloudABI 不再被维护了,可以考虑 WASI 了,WASI 的一些概念就是受到 CloudABI 的启发,现在算是 CloudABI 的接班人了。

解读 Rust 2021 Edition RFC

作者/编辑:张汉东


目前 Rust 2021 Edition 正在讨论中,RFC 3085 目前已经取代了 RFC 2052 成为新的 RFC。

Edition 在RFC 2052中提出,Rust在2018年发布了第一个 Edition版本。这项工作在许多方面都是成功的,但也带来了一些困难的教训。 RFC 3085 为 2021 Edition 提出了不同的模型。 需要注意的是,目前该 RFC 还未合并。

「2021 Edition 模型」讨论的关键点包括:

  • Edition 用于将语言引入更改,否则可能会破坏现有代码,例如引入新关键字。
  • Edition 永远不允许分裂生态系统。 我们只允许不同版本的 crate 进行互操作的更改。
  • Edition 以其出现的年份命名(例如,Rust 2015,Rust 2018,Rust 2021)。
  • 发布新 Edition 时,我们还会发布工具以自动执行 crate 的迁移。 可能需要进行一些手动操作,但是这种情况很少见。
  • Nightly 工具链提供对即将发布的 Edition 的“预览”访问权限,以便我们可以随时进行针对将来 Edition 的工作。
  • 我们维护一个《Edition 迁移指南》,其中提供了有关如何迁移到下一 Edition 的指南。
  • 只要有可能,都应使新功能适用于所有 Edition

该RFC旨在确立 Edition 的高级用途,并描述RFC对最终用户的感觉。 它有意避免进行详细的策略讨论,这些讨论将由相应的子团队(编译器,lang,开发工具等)来解决。

目标与设计原则

顺序代表优先级

  1. Edition不能分裂生态系统。

最重要的一条规则是:一个Edition中的 crate 可以与其他Edition中编译的 crate 无缝地互操作。不管Edition如何,所有 Rust 代码最终都会在编译器中编译为相同的内部 IR。

  1. Edition 迁移应该很方便且尽最大可能自动化完成。

在发布新Edition的同时也会发布一些工具帮助自动升级Edition。并且维护《Edition迁移指南》以便手动迁移之需。

  1. 由用户来控制何时使用新的Edition

  2. Edition 注定是要被使用的。目标是看到所有Rust用户都采用新Edition

  3. Rust 应该感觉像是一种语言,而非被 Edition 分割为多种“方言”。

Edition向 Rust 引入了向后不兼容的更改,从而又增加了 Rust 开始感觉像具有多种方言的语言的风险。 我们想要避免人们进入 Rust 项目的经历,并对给定的代码含义或可以使用的功能种类感到不确定。 这就是为什么我们更喜欢基于年份的版本(例如Rust 2018,Rust 2021),这些版本将许多更改组合在一起,而不是细粒度的选择加入; 可以简洁地描述基于年份的版本,并确保当您进入代码库时,相对容易地确定可以使用哪些功能。

一些背景

Rust 2018版在 RFC 2052中被描述为一个“集结点”,不仅引入了一些迁移,而且还是许多其他更改(例如更新本书,实现连贯的新API集等)的目标。这在很多方面都很有帮助,但在其他方面却是有害的。 例如,在是否有必要升级到新Edition以使用其功能方面存在一定的困惑(尚不清楚该困惑是否具有除困惑之外的其他负面影响)。 这也是组织本身将所有内容整合在一起的压力。 它与「火车模型」相反,后者旨在确保我们具有“低压力”发布。

相反,2021版故意是“低调”事件,其重点仅在于介绍已进行了一段时间的一些迁移,惯用法lint和其他工作。 我们没有将其与其他无关的更改进行协调。 这并不是说我们永远不应该再发布“集结点”。 但是,目前,我们在工作中并没有一整套协调一致的变化,我们需要将这些变化汇总在一起。

但是,由于此更改,Rust 2018的一项好处可能会丢失。 有一定比例的潜在Rust用户可能对Rust感兴趣,但兴趣不足以跟进每个Edition并跟踪发生了什么变化。 对于这些用户,一篇博客文章列出了Rust 2018以来发生的所有令人振奋的事情,足以说服他们尝试一下Rust。 我们可以通过发布回顾过去几年的回顾来解决这个问题。 但是,我们不必将此回顾与Edition联系在一起,因此,此RFC中未对此进行描述。

小结

通过以上内容,我想你应该对目前官方的 Rust 2021 Edition 工作内容有所了解。目前该 RFC 还在持续且激烈的讨论中,更多内容可以移步该 RFC 的 PR中参看。

在官方的 Edition Guide 文档中,已经增加了 Next Edition 可能发布的功能集合,感兴趣可以自行关注。

前端入门 | Rust 和 WebAssembly

作者: 陈鑫(lencx) / 后期编辑:张汉东


Wasm是什么?

MDN官方文档是这样给出定义

WebAssembly(为了书写方便,简称Wasm)是一种新的编码方式,可以在现代的网络浏览器中运行 - 它是一种低级的类汇编语言,具有紧凑的二进制格式,可以接近原生的性能运行,并为诸如C / C ++等语言提供一个编译目标,以便它们可以在Web上运行。它也被设计为可以与JavaScript共存,允许两者一起工作。

对于网络平台而言,WebAssembly具有巨大的意义——它提供了一条途径,以使得以各种语言编写的代码都可以以接近原生的速度在Web中运行。在这种情况下,以前无法以此方式运行的客户端软件都将可以运行在Web中。

WebAssembly被设计为可以和JavaScript一起协同工作——通过使用WebAssembly的JavaScript API,你可以把WebAssembly模块加载到一个JavaScript应用中并且在两者之间共享功能。这允许你在同一个应用中利用WebAssembly的性能和威力以及JavaScript的表达力和灵活性,即使你可能并不知道如何编写WebAssembly代码。


环境安装及简介

1. Rust

一门赋予每个人
构建可靠且高效软件能力的语言。

安装

# macOS
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 其他安装方式
# https://forge.rust-lang.org/infra/other-installation-methods.html

常用命令

# 版本更新
rustup update

# 查看版本
cargo --version

# 构建项目
cargo build

# 运行项目
cargo run

# 测试项目
cargo test

# 为项目构建文档
cargo doc

# 将库发布到 crates.io
cargo publish
# nightly rust
rustup toolchain install nightly

rustup toolchain list

rustup override set nightly

2. Node.js

Node.js是基于Chrome的V8 JavaScript引擎构建的JavaScript运行时

3. wasm-pack

用于构建和使用您希望与JavaScript,浏览器或Node.js互操作的Rust生成的WebAssembly。

安装

# macOS
curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

# 其他安装方式
# https://rustwasm.github.io/wasm-pack/installer

常用命令

# 创建
# https://rustwasm.github.io/docs/wasm-pack/commands/new.html
wasm-pack new <name> --template <template> --mode <normal|noinstall|force>

# 构建
# https://rustwasm.github.io/docs/wasm-pack/commands/build.html
wasm-pack build
  [--out-dir <out>]
  [--out-name <name>]
  [--<dev|profiling|release>]
  [--target <bundler|nodejs|web|no-modules>]
  [--scope <scope>]
  [mode <normal|no-install>]

# 测试
# https://rustwasm.github.io/docs/wasm-pack/commands/test.html
wasm-pack test

# 发包
# https://rustwasm.github.io/docs/wasm-pack/commands/pack-and-publish.html
# npm pack
wasm-pack pack
# npm publish
wasm-pack publish

4. Vite

下一代前端工具

vite-plugin-rsw:vite插件,简称Rsw - 集成wasm-pack的CLI

  • 支持rust包文件热更新,监听src目录和Cargo.toml文件变更,自动构建
  • vite启动优化,如果之前构建过,再次启动npm run dev,则会跳过wasm-pack构建
# 在vite项目中安装
npm i -D vite-plugin-rsw
# or
yarn add -D vite-plugin-rsw

5. create-xc-app

脚手架 - ⚡️在几秒钟内创建一个项目!维护了多种项目模板。

# 根据命令行提示,输入项目名称,选择模板初始化项目
# template: `wasm-react` or `wasm-vue`
npm init xc-app

xc-app

快速开始

  • 在原有vite项目中使用,只需安装配置vite-plugin-rsw插件即可。
  • 新项目可以使用vite提供的@vitejs/app初始化项目,然后安装配置vite-plugin-rsw
  • 或者使用脚手架create-xc-app初始化项目,模板包含wasm-reactwasm-vue,会定期更新维护相关版本依赖。

项目结构

# 推荐目录结构
[my-wasm-app] # 项目根路径
|- [wasm-hey] # npm包`wasm-hey`
|    |- [pkg] # 生成wasm包的目录
|    |    |- wasm-hey_bg.wasm # wasm文件
|    |    |- wasm-hey.js # 包入口文件
|    |    |- wasm-hey_bg.wasm.d.ts # ts声明文件
|    |    |- wasm-hey.d.ts # ts声明文件
|    |    |- package.json
|    |    `- ...
|    |- [src] # rust源代码
|    | # 了解更多: https://doc.rust-lang.org/cargo/reference/cargo-targets.html
|    |- [target] # 项目依赖,类似于npm的`node_modules`
|    | # 了解更多: https://doc.rust-lang.org/cargo/reference/manifest.html
|    |- Cargo.toml # rust包管理清单
|    `- ...
|- [@rsw] # npm 组织包
|     |- [hey] # @rsw/hey, 目录结构同`wasm-hey`
|     `- ...
|- [node_modules] # 前端的项目包依赖
|- [src] # 前端源代码(可以是vue, react, 或其他)
| # 了解更多: https://nodejs.dev/learn/the-package-json-guide
|- package.json # `npm`或`yarn`包管理清单
| # 了解更多: https://vitejs.dev/config
|- vite.config.ts # vite配置文件
| # 了解更多: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html
|- tsconfig.json # typescript配置文件
` ...

乍一看,可能会觉得目录有点复杂,其实它就是一个标准的基于vite前端项目,然后,在根路径下去添加我们需要构建的wasm包(一个rust crate会对应生成一个wasm包,可单独发布到npm上)

创建Wasm包

# 两种方式创建

# 1.
# 如果报错,可查看:https://github.com/rustwasm/wasm-pack/issues/907
wasm-pack new <name>

# 2.
# name可以是npm组织
# 例:cargo new --lib @rsw/hello
# 需要手动配置Cargo.toml
cargo new --lib <name>

wasm-pack new cargo new

项目配置

以react项目为例

Step1: 配置Vite插件 - vite.config.ts

import reactRefresh from '@vitejs/plugin-react-refresh';
import { defineConfig } from 'vite';
import ViteRsw from 'vite-plugin-rsw';

export default defineConfig({
  plugins: [
    reactRefresh(),
    // 查看更多:https://github.com/lencx/vite-plugin-rsw
    ViteRsw({
      // 支持开发(dev)和生产模式(release)
      // 生产模式会对wasm文件的体积进行优化
      mode: "release",
      // 如果包在`unLinks`和`crates`都配置过
      // 会执行,先卸载(npm unlink),再安装(npm link)
      // 例如下面会执行
      // `npm unlink wasm-hey rsw-test`
      unLinks: ['wasm-hey', 'rsw-test'],
      // 项目根路径下的rust项目
      // `@`开头的为npm组织
      // 例如下面会执行:
      // `npm link wasm-hey @rsw/hey`
      // 因为执行顺序原因,虽然上面的unLinks会把`wasm-hey`卸载
      // 但是这里会重新进行安装
      crates: ["wasm-hey", "@rsw/hey"],
    }),
  ],
})

Step2: 配置Rust项目清单 - wasm-hey/Cargo.toml

# ...

# https://github.com/rustwasm/wasm-pack/issues/886
# https://developers.google.com/web/updates/2019/02/hotpath-with-wasm
[package.metadata.wasm-pack.profile.release]
wasm-opt = false

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
crate-type = ["cdylib", "rlib"]

[profile.release]
opt-level = "s"

[dependencies]
wasm-bindgen = "0.2.70"

Step3: 添加Rust代码 - wasm-hey/src/lib.rs


#![allow(unused)]
fn main() {
use wasm_bindgen::prelude::*;

// Import the `window.alert` function from the Web.
#[wasm_bindgen]
extern "C" {
    fn alert(s: &str);
}

// Export a `greet` function from Rust to JavaScript, that alerts a hello message.
#[wasm_bindgen]
pub fn greet(name: &str) {
    alert(&format!("Hello, {}!", name));
}
}

Step4: React项目中调用Wasm方法 - src/App.tsx

import React, { useEffect } from 'react';
import init, { greet } from 'wasm-hey';

import logo from './logo.svg';
import './App.css';

function App() {
  useEffect(() => {
    // wasm初始化,在调用`wasm-hey`包方法时
    // 必须先保证已经进行过初始化,否则会报错
    // 如果存在多个wasm包,则必须对每一个wasm包进行初始化
    init();
  }, [])

  const handleHey = () => {
    // 调用greet方法
    greet('wasm');
  }

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>Hello WebAssembly!</p>
        <p>Vite + Rust + React</p>
        <p>
          <button onClick={handleHey}>hi wasm</button>
        </p>
        <p>Edit <code>App.tsx</code> and save to test HMR updates.</p>
      </header>
    </div>
  )
}

export default App

常见问题汇总

Rsw插件

  • 插件内部是通过npm link的形式实现的wasm包安装,在一些极端场景下会出现,找不到依赖的安装包,导入的包不存在等错误,可以根据提示路径删除其link的文件,重新启动npm run dev可以解决。
  • npm link命令会把包link到全局环境,如果在多个项目使用相同wasm包名,可能会导致报错,解决办法,在全局npm的node_modules中删除该包即可。推荐不同项目使用不同wasm包名避免此类异常。
  • 插件是处于Vite开发模式下运行构建,所以至少执行过一次npm run dev,生成wasm包之后,再执行npm run build,否则也会报错,到不到.wasm文件之类的。
  • 插件API可以配置需要卸载的包(仅限于之前通过插件配置crates中rust项目)

前端

// init是wasm实例的初始化方法
// 在调用其他方法之前,必须先调用一次init方法,否则会报错
// init会请求`.wasm`文件并且返回一个`Promise`
import init, { greet } from 'wasm-test';

// -----------------------------------------

// 调用init方法,有两种方式

// 1.
// 在react,vue3中可以将其抽离为`hook`组件,
// 在进入生命周期时调用
init();

// 在调用过init方法之后,可以单独调用greet方法
greet('wasm');

// 2.
// 在初始化之后直接调用方法
init()
  .then(wasm => wasm.greet('wasm'));

相关链接



作者简介:

陈鑫(lencx)

{折腾 ⇌ 迷茫 ⇌ 思考]ing,在路上...

  • 公众号:浮之静
  • Blog: https://mtc.nofwl.com
  • GitHub: https://github.com/lencx

实践案例 | 使用 Bevy 游戏引擎制作炸弹人

作者:Cupnfish / 后期编辑:张汉东


目录

引擎简介

Bevy 是一款由Rust语言构建且简单明了的数据驱动的游戏引擎,永远开源免费!

它的设计目标如下:

  • 功能:提供完整的2D和3D功能集
  • 简单:对于新手来说很容易上手,但是对于高级用户来说非常灵活
  • 以数据为中心:使用实体组件系统范式的面向数据的体系结构
  • 模块化:只使用你需要的。替换掉你不喜欢的东西
  • 快速:应用逻辑应该快速运行,并且在可能的情况下并行运行
  • 高效:变更应该能够快速编译…等待不是有趣的

官网了解更多: https://bevyengine.org/

前言

Rusty BomberMan是著名的BomberMan小游戏的bevy复刻版。虽然说是复刻,但实际上和原本游戏长得完全不一样,原因是原版游戏的美术资源没搞到,所以另找了一些美术资源,十分感谢opengameart.org这些美术资源。

Changed: 1. 修正了之前刚体类型使用场景 2. 添加了目录,方便直接跳转想要阅读的内容。 3. 末尾加上了本人联系方式。 4. 原Rapier部分拆分成两个部分,更方便查阅。 5. 修正部分语句不通顺的地方。

开发动机

开发这个游戏的起因是当时我正在逛reddit,正好看到了@rgripper发帖想找人一起写bevy项目,抱着学习、实践的心态,我和他联系之后一拍即合,随即开始了这个项目。

Rust 开发环境推介

开发中使用最新版rust(建议nightly版本,bevy官网的快速开发迭代有推介用这个)。

开发环境推介 vscode + rust-analyzer(建议安装最新发布版,尽量别用nightly版本,我喜欢自己下载源码编译。) + Tabline(可选),或者Clion + IntelliJ Rust。 前者可能需要自己折腾,后者开箱即用,不过Clion不是免费的。

编译速度

bevy的官网中有提到其编译速度很快,其中0.4版本发布的时候,由于添加了动态链接的feature,增量编译的编译速度确实快了几倍,但是需要进行一系列的配置。

rust本身的编译速度实在不能说快,但在使用bevy进行开发迭代过程中,配置好快速编译的开发环境后,增量编译的速度令人十分满意。

我笔记本的配置是:

  • 处理器 Intel(R) Core(TM) i5-8400 CPU @ 2.80GHz 2.81 GHz
  • 机带 RAM 16.0 GB (15.9 GB 可用)

在开启动态链接的feature进行编译的情况下,每次增量编译的时间大概2.5秒左右,加入其它大型依赖之后,比如bevy_rapier,增量编译的速度会变长,但是仍然在可接受范围内,约3.5秒。在这次开发过程中,项目编译速度我很满意,开发体验十分良好。

那么如何搭建一个快速编译的开发环境呢?

官网里有详细的介绍了如何搭建一个快速开发环境:https://bevyengine.org/learn/book/getting-started/setup/ (在最后的Enable Fast Compiles (Optional)部分)

在搭建环境的过程中,可能会出现一些奇怪的问题,比如这个:

error: process didn't exit successfully: `target\debug\bevy_salamanders.exe` (exit code: 0xc0000139, STATUS_ENTRYPOINT_NOT_FOUND)

解决方法是把该游戏项目下的.cargo/config.toml文件中这行改了:

#before: 
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-Zshare-generics=y"]
#after:
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
rustflags = ["-Zshare-generics=off"]

改了之后如果还有类似的奇怪错误,可以试着把.cargo这个文件夹直接删除,只使用动态链接就行,动态链接对编译速度提升是远远大于切换linker的。还有其它奇怪的没法解决的错误的话,那可以去提issue了。

除此之外,每次运行的时候带一个--features bevy/dynamic也很麻烦,我喜欢在cargo.toml内部添加两个bevy,平时开发的时候注释掉另一个,直到要发布最终版本的时候才替换成另一个,大概像这样:

bevy = { version="0.4", features = ["dynamic"] }
# bevy = "0.4" 

下面的这个平时注释掉,只有当要发布最终版的时候,才把上面的注释掉,切换成下面的这个。平时开发过程中基本是直接cargo run就可以了。

Query filter

Bevy内部提供了不少查询过滤器,0.4版本更新之后也更好用,易读性得到了提高。

大致用法如下:


#![allow(unused)]
fn main() {
fn movement_system(
    query:Query<(要查询的组件),(查询的过滤器)>,
    mut example_query:Query<&mut Transform,With<Player>>
){
    for item in query.iter(){
        // 对查询内容进行操作
    }
    for mut transform in example_query.iter_mut() {
        // 就和迭代器一样使用
    }
}
}

常见的过滤器有With<T>,Without<T>,Added<T>,Changed<T>,Mutated<T>Or<T>,其中MutatedAddedChanged的集合,也就是说新添加的和改变了的都可以用Mutated来查到,而Added只查询新添加的组件,Changed只查询已经存在的组件中更改过的组件,这里面Or又比较特殊,使用其它几个过滤器基本都是减小查询范围,而使用Or却可以扩大过滤的范围,比如查询玩家和生物的位置与速度,就可以这样定义查询:


#![allow(unused)]
fn main() {
    Query<(&Transform,&Speed),Or<(With<Player>,With<Creature>)>>
}

查询多于一个组件的时候需要用括号括起来,将多个组件作为一个元组进行参数传递,同样多个过滤器也以元组的形式传参。当然使用到Or,通常会和Option一起使用,比如既想查询玩家和生物的位置和速度,还想专门查询玩家专属的组件,玩家的力量,就可以这样写查询器:


#![allow(unused)]
fn main() {
    Query<(&Transform,&Speed,Option<&PlayerPower>),Or<(With<Player>,With<Creature>)>>
}

这样查询出来的结果带有PlayerPower的肯定是玩家,使用惯用的rust方式处理option就可以了。

QuerySet

当一个system中的查询相互冲突时,编译后运行会触发一个panicxxx has conflicting queries。这个时候就需要QuerySet来帮助我们了。

关于心智负担,我个人观点是写这部分代码时,完全不用带着审视的目光去查看所有的查询,只有在发生这种panic的时候,再去审视相关代码,将冲突的部分替换成QuerySet就可以了,正好对应rust中诸如所有权、生命期等情况。

Note:关于哪些情况属于查询冲突,其实很好判断,在同一系统,多次可能查到同一结果的查询中,存在对组件的可变引用查询,那这个查询就是冲突的。

比如以下两个查询:


#![allow(unused)]
fn main() {
fn position(
    mut q0: Query<(&Transform, &mut Point)>,
    q1: Query<&Transform, Or<(With<Point>, With<Head>)>>,
){
    ...
}
}

同时查询了TransformPoint,并且,q1很有可能查到q0的结果,但是因为重复查询的组件Transform没有可变引用,所以这两个查询放在一个系统内,并不会发生冲突。

而以下两个查询:


#![allow(unused)]
fn main() {
fn position(
    mut q0: Query<(&mut Transform, &Point)>,
    q1: Query<&Transform, Or<(With<Point>, With<Head>)>>,
){
    ...
}
}

因为重复查询的组件Transform是有可变引用的,所以会发生冲突。

发生查询冲突之后,就是QuerySet大展身手的地方了。

考虑以下两个组件:


#![allow(unused)]
fn main() {
pub struct Head;

pub struct Point {
    pub pre: Entity,
}
}

假设我们需要写一个系统,让每一个点的位置根据前一个实体的位置而改变,可以有以下系统:


#![allow(unused)]
fn main() {
fn position(
    mut q0: Query<(&mut Transform, &Point)>,
    q1: Query<&Transform, Or<(With<Point>, With<Head>)>>,
) {
    ...
}
}

我们甚至没有给这个系统实现任何功能,直接添加到App中运行的话,就会直接触发查询冲突。

而使用QuerySet的话,也十分简单:


#![allow(unused)]
fn main() {
fn position(
    points_query: QuerySet<(
        Query<(&mut Transform, &Point)>,
        Query<&Transform, Or<(With<Point>, With<Head>)>>,
    )>,
) {
    ...
}
}

在没有实现任何内容的情况下添加到App中运行,能够正常运行。使用起来也十分方便,只需要将之前的查询以元组的形式当作泛型传到QuerSet中即可。

那实现具体的内容呢? 如果不使用QuerySet我们实现的内容看起来应该是这样的:


#![allow(unused)]
fn main() {
fn position(
    q0: Query<(&mut Transform, &Point)>,
    q1: Query<&Transform, Or<(With<Point>, With<Head>)>>,
) {
    for (mut transform, point) in q0.iter_mut() } {
        if let Ok(pre_transform) = q1.get(point.pre)  {
            *transform = Transform::from_translation(
                pre_transform.translation - Vec3::new(1.0, 1.0, 0.0)
            );
        }
    }
}
}

那么使用QuerySet之后,我们的内容应该是这样的:


#![allow(unused)]
fn main() {
fn position(
    mut points_query: QuerySet<(
        Query<(&mut Transform, &Point)>,
        Query<&Transform, Or<(With<Point>, With<Head>)>>,
    )>,
) {
    for (mut transform, point) in points_query.q0_mut().iter_mut() {
        if let Ok(pre_transform) = points_query.q1().get(point.pre) {
            *transform = Transform::from_translation(
                pre_transform.translation - Vec3::new(1.0, 1.0, 0.0) * 30.0,
            )
        } else {
            warn!("not find right transform!");
        }
    }  
}
}

我们还没有运行我们的代码,rust-analyzer就已经给我们报错了,我们在q0_mut()这里将points_query&mut引用传了进去,按照借用规则,后续不能再把points_query的指针借用出去了,所以在这里我们就需要使用unsafe了。

添加unsafe之后我们的代码变成这样:


#![allow(unused)]
fn main() {
fn position(
    mut points_query: QuerySet<(
        Query<(&mut Transform, &Point)>,
        Query<&Transform, Or<(With<Point>, With<Head>)>>,
    )>,
) {
    // Safety: 一般调用unsafe时,情况复杂的需要写下相关注释
    for (mut transform, point) in unsafe { points_query.q0().iter_unsafe() } {
        if let Ok(pre_transform) = points_query.q1().get(point.pre)  {
            *transform = Transform::from_translation(
                pre_transform.translation - Vec3::new(1.0, 1.0, 0.0) * 30.0,
            )
        } else {
            warn!("not find right transform!");
        }
    }
}
}

bevy几乎所有的unsafe都贴心的写出了Safety,使用这部分api时的内存安全由使用者来保证,而使用者只需要判断自己的调用情况是否符合Safety的要求,就能判断这个调用是否满足内存安全。比如该处的Safety要求就是这样的:

This allows aliased mutability. You must make sure this call does not result in multiple mutable references to the same component

我们已经能够明确,我们的两次查询,不会造成查询结果中,存在同一个组件的多个包含可变引用的引用,所以在这里调用该unsafe函数是Safety的!

当你把借用的问题处理好之后,再次运行我们的App,就一切如你所愿了。

谈谈QuerySet的体验,因为rust-analyzer对过程宏生成的Api支持不是很友好,对类似由宏生成的Api的代码补全体验可以说是很糟糕。而且出于减少总编译时间的考虑,这部分的过程宏只预备了五个参数的位置,也就说说除了q0q4多出q4的部分,这个过程宏是没有预先生成相关函数的。当然我相信在实际应用的过程中,很少有出现这么极端的查询情况。总得来说掌握这个Api的使用并不难,而且在生产过程中也很实用。

Event

0.4版本的bevy的event有个十分不好用的地方,看以下示例:


#![allow(unused)]
fn main() {
pub fn game_events_handle(
    game_events: Res<Events<GameEvents>>,
    mut events_reader: Local<EventReader<GameEvents>>,
) -> Result<()> {
    ...
}
}

可能只看函数参数并不能感受到哪里不好用,可是如果你注意到这是一个事件处理系统,传递进来的参数居然同时需要EventsEventReader,并且使用的时候是这样的:


#![allow(unused)]
fn main() {
    for event in events_reader.iter(&game_events) {
        match event {
            ...
        }
    }
}

没错,EventReader不是一个真正的迭代器,在调用iter()的时候需要传递一个该事件的引用,这在使用的过程中感受到多余。

好在EventReader在即将要发布的0.5版本当中已经得到了改善,在这个PR合并之后,EventReader的调用已经变成了这样:


#![allow(unused)]
fn main() {
pub fn game_events_handle(
    // 不再需要多余的Events作为EventReader参数
    // game_events: Res<Events<GameEvents>>,
    // mut events_reader: Local<EventReader<GameEvents>>,
    // 不再需要指定Local,EventReader在Bevy中已经变成了更高级别的API
    mut events_reader: EventReader<GameEvents>,
) -> Result<()> {
    // 变得更像真实的迭代器
    for event in events_reader.iter() {
        match event {
            ...
        }
    }
}
}

需要注意的是,不仅仅是EventReader变成了更高级别的API(即成为真正的系统参数),Events也同样不再需要在其外部套一个ResMut了,写系统时直接写Events<T>作为参数。

可以这样改动的原因:之前的Events是作为Resource使用的,也就是说存在ResResMut两种状态。其中Res<Events<T>>只有给旧版的EventReader当作参数的存在意义,但是新版的EventReader已经不再需要这个参数,Res版本的Events失去了其存在意义,因此相对于ResMut<Events<T>>,索性改成了Events<T>,减少了用户API层面的复杂性。

Timer

bevy现版本的Timer是个值得争议的地方,先来看看具体用法:


#![allow(unused)]
fn main() {
// 定义一个动画计时器组件:
pub struct Animation(pub Timer);
// 作为Player实体的组件添加到Player中:
#[derive(Bundle)]// 使用Bundel派生宏可以将多个组件打包到一块,bevy官方指南也推介这样做,性能上似乎也比直接使用with更好
pub struct PlayerBundle {
    player: Player,
    animation: Animation,
    ...//省略了其它组件
}
// 初始化PlayerBundle
impl Default for PlayerBundle {
    fn default() -> Self {
        Self {
            player: Player,
            animation: Animation(Timer::from_seconds(0.3, true)),
            ...//省略了其它组件
        }
    }
}
// Timer 在实例化的时候需要提供两个参数,一个是计时器计时的时间,另一个是该计时器是否重复计时。
// 查询计时器进行相关修改:
fn player_animation(
    time: Res<Time>,// 使用计时器时必须用到时间去tick计时器
    mut query: Query<(&mut Animation,&Player)>,
) {
    for (mut animation,player) in query.iter_mut(){
        animation.0.tick(time.delta_seconds());
        // animation.0是因为我们将Timer包裹在了Animation下
        if animation.0.just_finished() {
            ...// 相关操作
        }
    }
}
}

以上基本就是计时器在使用时的流程,现在来回答几个问题。

  • 为什么要使用一个结构体去包裹已有的计时器?

大家应该注意到我们没有直接将计时器作为组件附加到Player上,而是通过一个结构体去包裹计时器之后再附加到Player上,这样做的其中一个原因是我们的Player实体可能需要不止一个计时器,所以我们需要给每个计时器不同的标识。

  • 为什么在调用计时器的finished()等相关计时API之前需要先调用tick(time.delta_seconds())?

bevy的计时器本身相当于一个保存有当前时间量的结构体,本身没有时间流动的概念,只有tick的时候告诉它已经过去了多少时间,它才会把过去了多少时间加到它本身保存的状态上。

Timer比较有争议的地方就是使用计时器时不能十分容易的给它添加标识,需要在计时器外部套一个结构体,目前有些PR提出了给Timer增加一个泛型的位置的想法,我个人不是很喜欢这种实现,理由很多,比如@cart大大的理由就是bvey内部有不少不需要特殊标识的计时器,如果添加泛型之后需要这样写:Timer<()>,相对于之前的Timer来说,实在是太丑了。

出了标识的问题,还有目前的计时器使用的f32类型,应该替换成时间更常用的Duration,刚刚提到的PR在这个方面就已经完成了。

system的链接与代码复用

之前Events部分有个系统例子和其它常规例子不一样:


#![allow(unused)]
fn main() {
pub fn game_events_handle(
    mut events_reader: EventReader<GameEvents>,
) -> Result<()> {
    ...
}
}

它拥有一个Result返回值,如果直接将这个系统添加到App中,会被rust-analyzer直接报错,因为bevy不支持带有返回值的系统。

那如何让带有返回值的系统添加到App中去呢?当然是处理掉它的返回值,bevy给我们提供了一个fn chain(self, system: SystemB)函数,调用的时候大概像下面这样:


#![allow(unused)]
fn main() {
    .add_system(game_events_handle.system().chain(error_handler.system()))
}

它可以‘无限续杯’,只要你愿意,你可以无限chain下去。

那如何写一个可以chain的系统呢?考虑以下系统


#![allow(unused)]
fn main() {
pub fn head_translation(query: Query<&Transform, With<Head>>) -> Option<Vec3> {
    query.iter().map(|transform| transform.translation).next()
}
}

该系统返回一个Option<Vec3>,因此能够处理该返回值的系统应该要带有一个In<Option<Vec3>>的参数:


#![allow(unused)]
fn main() {
pub fn head_translation_handle(come_in: In<Option<Vec3>>) -> Option<Vec3> {
    if let In(Some(vec)) = come_in {
        Some(vec + Vec3::new(1.0, 1.0, 0.0) * 30.0)
    } else {
        None
    }
}
}

出于教学目的,这里没有直接处理本不需要再返回出去的Option<Vec3>,而是为了验证多次链接是否有用:


#![allow(unused)]
fn main() {
pub fn body_point_translation_handle(
    come_in: In<Option<Vec3>>,
    mut query: Query<&mut Transform, With<BodyPoint>>,
) {
    if let In(Some(vec)) = come_in {
        for mut transform in query.iter_mut() {
            transform.translation = vec;
        }
    }
}
}

没错,在每次链接的时候,你可以添加新的参数,这种设计大大增加代码的灵活性,同时也提高了代码复用率。

这是bevy中我很喜欢的一个功能,既实用又灵活。虽然在本次项目中用到的地方不多,基本都用来做错误处理了,但是我相信在一个大型项目中,这种功能够充分发挥出它的优势,大概就是bevy中各处都彰显着类似这样设计的人体工程学,因此大家才为之感到兴奋。

当然上面的代码可能有些地方让有强迫症的人感到不适,比如传出来的结果为啥是Option的,这样如果这个系统返回None的时候仍然一直在游戏中运行会不会很占资源?确实是会有这方面的考虑,所以现在已经有PR提出了异步系统的概念,如果真的实现出来的话,应该来大大减缓这种情况,编写出来的代码估计也会好看一些。

如何实现游戏的不同状态

我们的项目中实现了一个完整的游戏流程,包括开始游戏的菜单界面,游戏内部的暂停,玩家被炸弹炸死或者被生物触碰时的失败,以及玩家找到下一关的入口之后的胜利。如果有体验过我们的游戏,会发现关卡基本没有设计,仅仅只是实现了游戏中各种道具的效果,包括第一关与第二关的区别也仅仅是多了几只怪。作为游戏而言,我是对这部分的实现是很不满意的,但是作为体验、学习bevy而言,我觉得收获良多。我甚至还保留了一个随机的关卡实现接口,只不过没有真的去实现,roguelike的相关算法此前我都没有什么经验,只希望下一个项目能够在这方面得到提升。

回到正题,为了实现这样一个完整的游戏流程,我参考了Kataster的相关代码,将游戏整体流程放在了AppState这个枚举体内:


#![allow(unused)]
fn main() {
pub enum AppState {
    StartMenu,
    Game,
    Temporary,
}
}

看上去我们的游戏有StartMenuGameTemporary三个状态,实际上只需要考虑前两个状态就好了,Temporaty这个状态只是为了方便修复游戏中的一个小bug而已。

通常构建一个游戏的状态需要以下四个步骤:

1.将我们的游戏状态以资源的方式添加到游戏中:


#![allow(unused)]
fn main() {
app.add_resource(State::new(AppState::StartMenu))
// 添加游戏状态资源时,需要特意指明初始化的状态,比如这里就指明了创建好的状态加载到游戏开始菜单的状态下
}

2.初始化StateStage


#![allow(unused)]
fn main() {
// 接上第一步的部分
    .add_stage_after(// 此处也很灵活,可以按照自己的喜好来
        stage::UPDATE,// target,你可以把你的状态放到你想放的任何已有状态下
        APP_STATE_STAGE,// name,名字也很灵活,可以自己取,这里是const APP_STATE_STAGE: &str = "app_state";
        StateStage::<AppState>::default(),// 这里就挺固定了,需要将你的游戏状态枚举作为StateStage的一个泛型,以便初始化。
    )
}

3.处理stage


#![allow(unused)]
fn main() {
// 紧接上一步
    .stage(APP_STATE_STAGE, |stage: &mut StateStage<AppState>| {
        // 通过这个闭包,可以给我们游戏的不同状态添加系统
        stage
            // start menu
            // on_state_enter用来设置进入该State时调用的系统,通常用来加载资源。
            .on_state_enter(AppState::StartMenu, start_menu.system())
            // on_state_update用来设置该State下游戏更新时调用的系统。
            .on_state_update(AppState::StartMenu, button_system.system())
            // on_state_exit用来设置退出该State时调用的系统,通常用来清楚屏幕,更新相关游戏数据之类的。
            .on_state_exit(AppState::StartMenu, exit_ui_despawn.system())
            // in game
            .on_state_enter(AppState::Game, setup_map.system()))
            // 类似于on_state_update,不过可以同时设置多个。
            .update_stage(AppState::Game, |stage: &mut SystemStage| {
                stage
                // 以下的方法都不是SystemStage自带的,而是在我们游戏项目的各个模块下通过自定义trait给SystemStage实现的,只是为了方便管理各个模块。
                // 这部分设计是有缺陷的,一般来说physics系统中的其中一部分是需要提前加载的,不然会造成现版本中出现查询错误的小bug
                    .physics_systems()
                    .player_systems()
                    .bomb_systems()
                    .buff_systems()
                    .creature_systems()
                    .portal_systems()
            })
            .on_state_exit(AppState::Game, exit_game_despawn.system())
            .on_state_enter(AppState::Temporary, jump_game.system())
    });
}

4.处理游戏状态跳转


#![allow(unused)]
fn main() {
// 另外构建一个处理游戏状态的跳转的系统
pub fn jump_state(
    mut app_state: ResMut<State<AppState>>,
    input: Res<Input<KeyCode>>,
    mut app_exit_events: ResMut<Events<AppExit>>,
) -> Result<()> {
    // 使用模式匹配能够很清晰的将我们游戏状态跳转进行处理
    match app_state.current() {
        AppState::StartMenu => {
            if input.just_pressed(KeyCode::Return) {
                // set_next这个方法就是从当前状态跳转到指定状态
                app_state.set_next(AppState::Game)?;
                // game_state是原来处理游戏状态下的各种状态的,比如暂停、胜利、失败等,和app_state大同小异,因此此处都省略了,如果感兴趣可以直接看这部分源码,放到了src/events下
                // game_state.set_next(GameState::Game)?;
            }
            if input.just_pressed(KeyCode::Escape) {
                // 这个事件是bevy内置的事件,用来退出应用
                app_exit_events.send(AppExit);
            }
        }
        AppState::Game => {
            if input.just_pressed(KeyCode::Back) {
                app_state.set_next(AppState::StartMenu)?;
                // game_state.set_next(GameState::Invalid)?;
                map.init();
            }
        }
        AppState::Temporary => {}
    }
    Ok(())
}
}

通过以上四个步骤,就能够为你的游戏添加上不同的状态,现在我们来谈一下第三步,其实这部分很有可能在之后的版本中被新的调度器取代,但那还是久远之后的事,到那时需要新的blog去探讨。

Rapier简短笔记

rapier作为物理引擎,它的内容十分丰富,本项目所涉及的内容,仅仅是其中的一小部分,本文也只是从中挑出了一些有意义的进行记录。如果想要深入学习rapier,我的建议是先看官方文档,然后再去discordbevy_rapier群组去交流学习。

rapier的常用组件有两个,一个是刚体(RigidBody),一个是碰撞体(Collider)。bevy中的每一个实体,只能有一个刚体,而碰撞体可以有多个,比如角色的头、胳膊、腿,这些部分都可以使用单独一个碰撞体来表示。

创建刚体的方法很简单:


#![allow(unused)]
fn main() {
// 创建一个运动学刚体,不受外部力影响,但是能单向影响动态刚体,需要通过专门设置其位置,常用于移动平台,如电梯
RigidBodyBuilder::new_kinematic()
.translation(translation_x, translation_y)
// 创建一个静态刚体,不受任何外部力的影响,常用于墙体等静态物体
RigidBodyBuilder::new_static()
.translation(translation_x, translation_y)        
// 创建一个动态刚体,受外部力的影响,常用于玩家控制的角色、游戏中的怪物等
RigidBodyBuilder::new_dynamic()        
.translation(translation_x, translation_y)        
.lock_rotations()// (可选)让刚体锁定旋转    
.lock_translations()// (可选)让刚体锁定位置
}

创建刚体时需要明确指定其位置,因为bevy_rapier内部有一个系统专门用于转换刚体的位置和实体的Transform,相当于我们不再需要去管理实体中的Transform,只需要通过刚体来管理该实体的速度、位置、旋转、受力等就可以。

创建碰撞体的方法也很简单:


#![allow(unused)]
fn main() {
// 碰撞体实际上就是定义参与碰撞计算的形状,rapier有多种选择,因为我们的游戏项目中只用到两种,所以只谈这两类
// 矩形,设置的时候需要提供它的半高和半宽
ColliderBuilder::cuboid(hx, hy)
// 圆形,设置的时候需要提供半径
ColliderBuilder::ball(radius)
}

note:矩形碰撞体构建需要提供的参数是半高和半宽,而不是整高和整宽。

对于单一碰撞体的直接讲刚体和碰撞体作为组件插入到已有实体即可:


#![allow(unused)]
fn main() {
fn for_player_add_collision_detection(
    commands: &mut Commands,
    query: Query<
        (Entity, &Transform),
        (
            With<Player>,
            Without<RigidBodyBuilder>,
            Without<ColliderBuilder>,
            Without<RigidBodyHandleComponent>,
            Without<ColliderHandleComponent>,
        ),
    >,
) {
    for (entity, transform) in query.iter() {
        let translation = transform.translation;
        commands.insert(
            entity,
            (
                create_dyn_rigid_body(translation.x, translation.y),
                create_player_collider(entity),
            ),
        );
    }
}
}

如果只是单个碰撞体和刚体的组合,则用这种方法插入即可,但如果是多个碰撞体和单个刚体的组合,则稍微有所不同,详情可以看这里

我们的游戏当中使用的是动态加载,也就是在所有地图资源加载之后,再给没有加上刚体和碰撞体的实体插入相应的刚体和碰撞体。

比如上面给出的例子,可能大家会对查询的过滤器感到奇怪。因为我们是给没有刚体构建器和碰撞体构建器的实体插入刚体和碰撞体,所以再过滤器中有 Without<RigidBodyBuilder>Without<ColliderBuilder>并不让人奇怪。让人奇怪的地方是后两条过滤器Without<RigidBodyHandleComponent>Without<ColliderHandleComponent>,这两条实际上是因为bevy_rapier内部有一个负责转换构建器(Builder)到句柄组件(HandleComponent)的系统,当我们给实体插入构建器之后,该系统就会通过一些内部的方法将其转换为句柄组件。所以为了防止我们查询到的结果当中存在已经插入过句柄组件的实体,所以需要再加入这条过滤。

仅仅添加这些并不足以让物理引擎在我们的游戏里面运行起来,主要原因是现在的bevy_rapier仍然是作为一个外部crate引入到我们的游戏项目中,在将来如果集成到了bevy主体的物理引擎中,则不再需要以下操作。


#![allow(unused)]
fn main() {
// 在app中添加物理引擎插件
    app
    ...// 初始化其它资源和添加其它插件
        .add_plugin(RapierPhysicsPlugin)
}

这样简单设置之后,我们的游戏中就成功的启用了物理引擎。

通过Rapier来实现碰撞过滤

还有一件事需要特别记录一下,在我们的游戏中,生物是可以互相碰撞的,那么如何实现这种效果呢?只需要在创建碰撞器的时候指明解算组或者碰撞组即可。


#![allow(unused)]
fn main() {
    ColliderBuilder::cuboid(HALF_TILE_WIDTH, HALF_TILE_WIDTH)
        // 用户数据,可以插入一些自定义的数据,但是只能以u128格式插入,通常用来插入实体,有了实体之后可以通过查询来获取该实体的其它组件
        .user_data(entity.to_bits() as u128)
        // 解算组,可以通过设定一个交互组(InteractionGroups)来让该碰撞器在该组规则下进行力的解算
        .solver_groups(InteractionGroups::new(WAY_GROUPS, NONE_GROUPS))
        // 碰撞组,同样设定交互组之后,让该碰撞器在该组规则下进行碰撞解算
        .collision_groups(InteractionGroups::new(WAY_GROUPS, NONE_GROUPS))
}

在更进一步谈论解算组和碰撞组的区别之前,我们需要了解交互组的构建规则,交互组new的时候需要提供两个参数,第一个参数是设定该碰撞体属于哪一组,需要的参数类型是一个u16,第二个参数是设定该碰撞体和哪些组的碰撞体会产生交互,参数同样是一个u16

对于第二个参数,设定和单个碰撞体交互倒是挺好理解,但如果设定和多个碰撞体交互又该怎么设置呢?这正是参数的类型设定为u16的妙处,举个例子:


#![allow(unused)]
fn main() {
const CREATURE_GROUPS: u16 = 0b0010;
const PLAYER_GROUPS: u16 = 0b0001;
const WALL_GROUPS: u16 = 0b0100;
const WAY_GROUPS: u16 = 0b1000;
const NONE_GROUPS: u16 = 0b0000;
}

以上常量皆是我们这次游戏中用到的交互组变量,而0b0011表示的就是生物组和玩家组两个组,而这个数就是用CREATURE_GROUPSPLAYER_GROUPS通过&运算出来的。

至于解算组和碰撞组的区别,解算组解算的就是受力状况,与之交互的组都会参与到受力解算中。而碰撞组是管理碰撞事件的,碰撞事件可以通过Res<EventQueue>进行接收处理。

还有user_data也是一个比较常用的,通常是在碰撞体插入的时候将该实体传入到碰撞体构建器当中,通过这个数据,可以使用以下命令获得实体:


#![allow(unused)]
fn main() {
let entity = Entity::from_bits(user_data as u64);
}

user_data又从哪里来呢?从碰撞事件中我们会获得一个索引,该索引可以通过Res<ColliderSet>的get方法获取器user_data,这方面比较繁琐,也是我认为目前bevy_rapier当中最不好用的部分。

除此之外,如果你就此运行你的游戏,你会发现你的角色也好,画面中的其它动态刚体,除了你设定的之外,还会收到一个重力,这完全不符合你俯视2d游戏的初衷,所以我们需要将该重力给修改为零。

当前版本是通过添加这样一个系统来修改物理引擎的重力的:


#![allow(unused)]
fn main() {
fn setup(
    mut configuration: ResMut<RapierConfiguration>,
) {
    configuration.gravity = Vector::y() * 0.0;
}
}

将这个系统添加到startup_system()只需要在每次游戏启动之前运行一次就行。

多平台支持

我们的游戏这次除了支持正常的桌面端平台以外,还做了wasm的支持,其中因为bevy的声音在wasm没有得到支持继而没有实现声音以外,总算是没什么遗憾。做完游戏之后发给小伙伴们玩了一下,都在问我有没有手机版本的。bevy的支持计划里面是有移动端的,而且就从桌面端迁移到移动端上要做出的改变来说是很少的,再说我们尚未支持的移动端之前,来看看我们是如何支持wasm版本的。

bevy的渲染后端用的是wgpu,虽然原生的wgpu渲染后端已经支持编译到wasm了,但是由于某些原因居然没有给bevy实装上,我们能够参考的已有的bevywasm版本项目基本上都是基于bevy_webgl2这个crate。

添加wasm支持也十分方便,除了需要添加常规的html之类的文件,还需要做如下改动:


#![allow(unused)]
fn main() {
// 添加webgl2的插件,添加这个插件之前需要关闭bevy的wgpu的feature
    #[cfg(target_arch = "wasm32")]
    app.add_plugins(bevy_webgl2::DefaultPlugins);
    #[cfg(not(target_arch = "wasm32"))]
    app.add_plugins(DefaultPlugins);
}

关闭wgpu的feature:

[features]
# 这部分是native和wasm都会用到的bevy的feature
default = [
  "bevy/bevy_gltf",
  "bevy/bevy_winit",
  "bevy/bevy_gilrs",
  "bevy/render",
  "bevy/png",
]
# 这部分是native会用到的wgpu的feature
native = [
  "bevy/bevy_wgpu",
  "bevy/dynamic"# (可选,开发的时候提高增量编译速度,编译真的十分快!)
]
# 这部分是wasm支持会用到的webgl2的feature
web = [
  "bevy_webgl2"
]

基本上这样就设置好了,其余的设置是跟html有关的,需要稍微丢丢的wasm开发的知识。关于编译的时候用到的cargo make等工具链如何使用,同样是在那一丢丢的wams开发的知识里面学习。关于如何部署到github的page服务上,这个我是完全不会的,我们游戏的这部分部署是有我的搭档@rgripper完成的。

对于移动端的支持,以安卓为例,如果不考虑触屏啊,按钮之类的,官方其实给了示例的,在桌面端的基础上迁移起来也十分方便。除了基本的安卓开发环境的搭配(这部分可以详情看cargo mobile的READEME里面讲的十分详情),只需要做出下面这种改动,即可支持移动端,甚至如果以后修复了wgpu对wasm端的支持,应该同样也只是需要下面这种修改,即可对多端支持:

// 对,就是添加这个过程宏之后,编译的时候使用对应平台的编译指令即可打包到相应平台
#[bevy_main]
fn main() {
    App::build()
        .insert_resource(Msaa { samples: 2 })
        .add_plugins(DefaultPlugins)
        .add_startup_system(setup.system())
        .run();
}

日志

bevy内建了日志系统,使用起来也十分方便,同时也能和rust生态中的其它日志crate配合在一起使用,对于后续测试和收集数据有很重要的作用。

这次项目中我们并没有深入使用日志功能,也没有和外部的日志crate深度结合使用,只是当作println!调试的时候用,所以这部分就不再探讨。

碎碎念

这是本文的最后一个部分,也是谈谈开发下来的一些感受,上面基本是干货居多,感受这种东西并不是每个人的愿意看,所以也不愿意放在前面叨扰大家。总得来说做完整个项目总结之后,发现自己之前走了不少弯路,甚至有些地方都用错了(比如前几个版本中的切换游戏状态,受参考的源代码影响也用了一堆if-else,当时自己看的时候也是一头雾水的,改成match之后清晰明了),在这个项目之前,rust对于我来说只是刷题、刷教程趁手的工具,虽然学到了不少的知识,但总觉得缺乏自己的实践。但这样一趟走下来,实践经验确实增长不少,最重要的是还交到了@rgripper这样的好朋友,果然github是个大型在线交友平台,哈哈哈。

使用bevy的开发体验在我这里被区分为两个部分,但总得来说是十分有趣的。

而这个分界点就是在游戏里加入rapier前后,加入之前和加入之后是两种完全不同的开发体验。

其中最主要原因还是因为自己之前没有使用过物理引擎,有不少生涩的词汇在开发中需要接触和学习,加上bevy_rapier当中不少接口放到bevy实际开发中体验并不良好,所以造成了使用rapier之后开发速率下降、开发心情糟糕等情况。

当然对于最终我们的游戏中使用了rapeir这件事,我觉得是很值得的,在这样一个小游戏中使用物理引擎这件事并不值得。但如果是为了学习这个物理引擎,那就是值得的,而且也确实涨了不少知识(在这部分真的十分感谢rapier的作者@Sébastien Crozet,在他的discord群组里,基本上大家问的问题都得到了解决,也很感谢群组里帮助我们提出思路的各个网友)。

谈一下本次开发中的遗憾,游戏没有加入音频算一个遗憾,这部分的工作早先是由我的搭档去完成的,但是因为bevy的一些原因,导致音频部分对wasm支持很差,所以我们放弃了。地图没有细致的去设计以及没有随机地图的支持这算两个遗憾。小怪的ai因为我们连个人此前都没写过游戏,因此对这方面不熟悉,导致有时候小怪会傻傻站着,和卡了bug一样,这也算一个。在游戏基本写完的时候bevy_tilemap发布了,并且还有一个游戏动图,我们没能在一个网格游戏当中用到这种crate,也算是一个遗憾。游戏的资产加载没有专门做成一个状态,导致在网络差的情况下,网页版的游戏很有可能出现这个issue所说的游戏主体出现了但是游戏资产没有加载进来的诡异情况,这也算是一个遗憾。


作者介绍:

Cupnfish,目前青岛某大学大四在校生一名。大二的时候因为自己主力语言是 python 和 C#(后面上课还学了Java,虽然很早之前就学过C,但不是很喜欢,刚接触指针的时候可懵逼了),所以很想学一门底层语言,当时看知乎不少关于Rust的讨论,对Rust产生了一些兴趣,恰好18年初张汉东老师的Rust编程之道正好上架,下单之后随即入坑Rust。2020年初疫情期间GAMES101课程在B站有录播,通过闫令琪老师的课程算是入门计算机图形学,同时期学了Wgpu,很想以后工作能从事 Rust 游戏开发,不过目前看来社区还得发展两三年。知乎上有不少人对Rust图形化编程方面呈悲观态势,起初只有Amethyst的时候我确实也很同意他们的观点,但是bevy给了rust社区中很多人希望,bevy不仅仅是想用Rust来做游戏引擎,同时也在鼓励使用Rust来编写游戏,这是区别于Amethyst等游戏引擎的,同时我想说,就目前bevy的ECS部分的Api来看,bevy做到了!这是梦想中的Rust,你几乎很少会用到生命期之类的Rust中一切繁琐的东西,bevy带给你的Rust开发体验是前所未有的,当然现在它仍然还很弱小,需要大家的呵护、照顾,它有很大的潜力,但同时也需要社区进行各方面的支持。

你可以通过以下方式联系到我,无论是进行技术讨论,还是项目合作,都可以直接和我联系:

  • 邮箱:pointu@foxmail.com
  • QQ:760280519

Linux 全新异步接口 io_uring 的 Rust 生态盘点

作者:施继成@DatenLord / 后期编辑:张汉东


io_uring 无可置疑是近两年内核圈最火的话题之一,作为风头正劲的 Linux 异步 I/O 接口,其野心更大,不仅仅想将 Linux 的 I/O 操作全面异步化,还希望将所有Linux系统调用异步化

Rust 作为一门系统级编程语言,兼具安全和高性能的特点,大家也一定是想使用Rust语言 “尝鲜” io_uring。然而遗憾的是 io_uring 作者 Jens Axboe 仅仅维护一个C语言的库。用户想要用Rust调用,一方面还需要自己进行一些封装,另一方面 C 语言的接口还是太底层,想在 Rust 的异步框架中使用仍有许多工作要做。

好消息是已经有一些 Rust 语言封装的 io_uring 库出现在 github 上,今天让我们来挑选一些使用人数较多(通过star数目来判断)的库进行分析,看看是否可以给大家使用 io_uring 带来便利。

Tokio io-uring

Tokio 是 github 上 Star 数目最多的异步框架,那么他们团队封装的io_uring lib如何呢?通过阅读代码不难发现,该 io_uring 库完全撇弃了 C 语言的 liburing 库,自己在 io_uring 系统调用上从零开始封装了一层,实现了submission queue,completion queue 和 submitter。

上述的三层抽象比 C 语言的封装稍微高层一些,但仍然需用户将 request 放到submission queue上,将 response 从 completion queue 上取下,和同步读写方式区别巨大,且和 Rust 现有的异步 I/O 框架的设计相去甚远。以下是一个简单的样例代码:


#![allow(unused)]
fn main() {
let mut ring = IoUring::new(256)?;
let (submitter, mut sq, mut cq) = ring.split();

let mut accept = AcceptCount::new(listener.as_raw_fd(), token_alloc.insert(Token::Accept), 3);

// put request on the submission queue
accept.push_to(&mut sq);

// submit the request
match submitter.submit_and_wait(1) {
    Ok(_) => (),
    Err(ref err) if err.raw_os_error() == Some(libc::EBUSY) => (),
    Err(err) => return Err(err.into()),
}

// get complete events from the completion queue
for cqe in &mut cq {
    ...
}
}

该 io_uring 库的优缺点分列如下:

优点:

  1. 纯 Rust 封装,安全性更好。
  2. 比 C 语言库封装高层,使用起来接口更加简单。

缺点:

  1. 维护成本更高,需要根据kernel的更新手动追加新 feature,包括新数据结构。
  2. 封装还不够彻底,暴露了底层实现的两个队列,用户使用难度较高。

Spacejam rio

该 io_uring 库在 github 上的 star 数目在写稿时已经达到了 590 个,该库的作者还创建了 sled 嵌入式数据库。由于 sled 数据库也使用了这个 io_uring 库,所以我们有理由相信, rio 是一个经过实际项目验证的库,其更友好的用户接口更是降低了用户的使用难度。

通过下面的简单示例,大家可以很容易感受到接口的易用性:


#![allow(unused)]
fn main() {
/// Read file example
let ring = rio::new().expect("create uring");
let file = std::fs::open("file").expect("openat");
let data: &mut [u8] = &mut [0; 66];
let completion = ring.read_at(&file, &mut data, at);

// if using threads
completion.wait()?;

// if using async
completion.await?
}

rio 同时提供了针对 thread 和 async 两种编程模型的接口,在提供便利性的同时大大降低了使用者的约束,可以自由选择喜欢的编程模型。

然而这个库是 unsoundness 的,即有可能被错误或者恶意使用。并且根据作者在 issue 里面的回复,作者并不会对此进行修复。这将使得基于该库构建的软件都不安全。

该 io_uring 库的优缺点分列如下:

优点:

  1. 接口丰富且使用简单。
  2. 有实际使用的项目验证。

缺点:

  1. Unsoundness,安全性不佳。

ringbahn

ringbahn 的作者是 withoutboats, Rust 语言的核心开发者之一。该库由三个抽象层组成,第一层为 C 语言 libfuse 的 Rust 封装, 名称为 uring-sys;第二层为 Submission Queue 和 Completion Queue 等数据结构的封装,名称为 iou;最后一层则封装了Rust 异步编程的接口。

不难看出,ringbahn 从设计上考虑了更多,从接口易用性到安全性都更加优秀。以下为拷贝文件的示例:


#![allow(unused)]
fn main() {
/// Copy File from props.txt to test.txt
futures::executor::block_on(async move {
    let mut input:  File = File::open("props.txt").await.unwrap();
    let mut output: File = File::create("test.txt").await.unwrap();
    let mut buf = vec![0; 1024];
    let len = input.read(&mut buf).await.unwrap();
    output.write(&mut buf[0..len]).await.unwrap();
    output.flush().await.unwrap();
});

}

该库也并非完美无缺,它也具有下列缺陷:

  1. 并发不友好,在 Submission Queue 上有一把大锁,每个提交任务的线程都会被串行化。
  2. 读写操作会导致内存在用户态被拷贝,对于大数据量的操作而言,多余的内存拷贝会带来明显的性能下降。之所以要进行内存拷贝,是为了保证传给内核的memory buffer不会被用户态异步修改,保证安全性。

作者也在 Readme 文件中说明了最上层的 ringbahn 封装只是一次尝试,并不适合在正式生产上使用。

DatenLord ring-io

基于上述讨论,我们团队 Datenlord 也实现了自己的 io_uring Rust lib, 名称是 ring-io。现阶段的实现吸取了 Tokio io-uring 和 iou 的经验,同样实现了Submission Queue 和 Completion Queue 的抽象。具体的实现细节请参见王徐旸同学写的文章

现阶段的实现也具有下列问题:

  1. 暴露了一些unsafe接口,提醒用户某些操作需要注意,和内核的错误交互会带来无法预知的结果。
  2. 抽象层偏低,使用起来不方便。

接下去,我们会针对一些特定的 buffer 类型实现异步 I/O 接口,方便用户的使用,且暴露 safe 的接口。在实现的过程中,我们也会将高效考虑在内,避免不必要的内存拷贝。和ringbahn 的方法不同,我们保证内存安全的方式为 Rust 提供的内存所有权转移,即用户在发送 I/O 请求之后就不在拥有 buffer 的所有权,直到 request 返回所有权才被归还。具体的实现细节我们会在下一篇文章中进行讨论,这里先给出设计的架构图:

io uring architecture

  • SQ submitter 负责将用户 Task 发送来的 I/O 请求通过 io_uring 发送到 kernel。
  • CQ collector 负责将 kernel 完成任务的返回结果返回给用户。
  • User Task 会 block 在各自的 channel 上,直到 I/O 任务完成,User Task 才会被重新调度。

总结

虽然 io_uring 非常火爆,国内外也有很多团队进行了 Rust 封装,但是仍然没有一个完美的方案,同时解决了安全性、高性能和易用性的问题。

大家可以根据自己的情况选择一个符合需求的库,当然更希望大家积极贡献社区,提出自己的想法,创建出更好用、更安全和更快的 io_uring 库。

DatenLord

DatenLord 是用 Rust 实现的新一代开源分布式存储,面向云原生场景提供高性能存储解决方案。

一方面,在当今的硬件架构下,CPU 和 GPU 的计算的速度远远超过 IO 的速度,即便现在 NVMe SSD 的 IO 速度已经比从前机械硬盘的速度有了百倍的提升,网络的速度也有至少百倍提升,但还是常常碰到IO跟不上计算速度的问题,导致计算等待数据,降低了计算的性能。

另一方面,操作系统的 IO 模型已经很久没有发生大的变化,仍然是以内核为主体来执行IO任务,这样的方式带来不少额外的开销,诸如数据拷贝、系统调用引起的阻塞以及进程上下文切换等等。

为了提高 IO 性能,DatenLord 采用绕过内核 (bypass Kernel) 的方式,主要在用户态实现 IO 功能,避免内核执行 IO 任务带来的额外开销,从而实现高性能分布式存储。

io_uring | 用 Rust 实现基于 io_uring 的异步随机读文件

作者:迟先生(skyzh)/ 后期编辑:张汉东


本文介绍了 io_uring 的基本使用方法,然后介绍了本人写的异步读文件库的实现方法,最后做了一个 benchmark,和 mmap 对比性能。

TL;DR

一句话总结:在 skyzh/uring-positioned-io 中,我包装了 Tokio 提供的底层 io_uring 接口,在 Rust 中实现了基于io_uring 的异步随机读文件。你可以这么用它:


#![allow(unused)]
fn main() {
ctx.read(fid, offset, &mut buf).await?;
}

io_uring 简介

io_uring 是一个由 Linux 内核的提供的异步 I/O 接口。它于 2019 年 5 月在 Linux 5.1 中面世,现在已经在各种项目中被使用。 比如:

  • RocksDB 的 MultiRead 目前就是通过 io_uring 做并发读文件。
  • Tokio 为 io_uring 包装了一层 API。在 Tokio 1.0 发布之际,开发者表示今后会通过 io_uring 提供真正的异步文件操作 (见 Announcing Tokio 1.0)。 目前 Tokio 的异步文件操作通过开另外的 I/O 线程调用同步 API 实现。
  • QEMU 5.0 已经使用 io_uring (见 ChangeLog)。

目前关于 io_uring 的测试,大多是和 Linux AIO 对比 Direct I/O 的性能 (1) (2) (3)io_uring 通常能达到两倍于 AIO 的性能。

随机读文件的场景

在数据库系统中,我们常常需要多线程读取文件任意位置的内容 (<fid>, <offset>, <size>)。 经常使用的 read / write API 无法完成这种功能(因为要先 seek,需要独占文件句柄)。 下面的方法可以实现文件随机读。

  • 通过 mmap 直接把文件映射到内存中。读文件变成了直接读内存,可以在多个线程中并发读。
  • pread 可以从某一位置 offset 开始读取 count 个字节,同样支持多线程并发读。

不过,这两种方案都会把当前线程阻塞住。比如 mmap 后读某块内存产生 page fault,当前线程就会阻塞;pread 本身就是一个阻塞的 API。 异步 API (比如 Linux AIO / io_uring) 可以减少上下文切换,从而在某些场景下提升吞吐量。

io_uring 的基本用法

io_uring 相关的 syscall 可以在 这里 找到。liburing 提供了更易用的 API。 Tokio 的 io_uring crate 在此基础之上,提供了 Rust 语言的 io_uring API。下面以它为例, 介绍 io_uring 的使用方法。

要使用 io_uring,需要先创建一个 ring。在这里我们使用了 tokio-rs/io-uring 提供的 concurrent API, 支持多线程使用同一个 ring。


#![allow(unused)]
fn main() {
use io_uring::IoUring;
let ring = IoUring::new(256)?;
let ring = ring.concurrent();
}

每一个 ring 都对应一个提交队列和一个完成队列,这里设置队列最多容纳 256 个元素。

通过 io_uring 进行 I/O 操作的过程分为三步:往提交队列添加任务,向内核提交任务 [注1], 从完成队列中取回任务。这里以读文件为例介绍整个过程。

通过 opcode::Read 可以构造一个读文件任务,通过 ring.submission().push(entry) 可以将任务添加到队列中。


#![allow(unused)]
fn main() {
use io_uring::{opcode, types::Fixed};
let read_op = opcode::Read::new(Fixed(fid), ptr, len).offset(offset);
let entry = read_op
            .build()
            .user_data(user_data);
unsafe { ring.submission().push(entry)?; }
}

任务添加完成后,将它提交到内核。


#![allow(unused)]
fn main() {
assert_eq!(ring.submit()?, 1);
}

最后轮询已经完成的任务。


#![allow(unused)]
fn main() {
loop {
    if let Some(entry) = ring.completion().pop() {
        // do something
    }
}
}

这样一来,我们就实现了基于 io_uring 的随机读文件。

注 1: io_uring 目前有三种执行模式:默认模式、poll 模式和内核 poll 模式。如果使用内核 poll 模式,则不一定需要调用提交任务的函数。

利用 io_uring 实现异步读文件接口

我们的目标是实现类似这样的接口,把 io_uring 包装起来,仅暴露给开发者一个简单的 read 函数。


#![allow(unused)]
fn main() {
ctx.read(fid, offset, &mut buf).await?;
}

参考了 tokio-linux-aio 对 Linux AIO 的异步包装后,我采用下面方法来实现基于 io_uring 的异步读。

  • 开发者在使用 io_uring 之前,需要创建一个 UringContext
  • UringContext 被创建的同时,会在后台运行一个(或多个)用来提交任务和轮询完成任务的 UringPollFuture。 (对应上一章节中读文件的第二步、第三步操作)。
  • 开发者可以从 ctx 调用读文件的接口,用 ctx.read 创建一个 UringReadFuture。在调用 ctx.read.await 后:
    1. UringReadFuture 会创建一个固定在内存中的对象 UringTask,然后把读文件任务放进队列里,将 UringTask 的地址作为 读操作的用户数据。UringTask 里面有个 channel。
    2. UringPollFuture 在后台提交任务。
    3. UringPollFuture 在后台轮询已经完成的任务。
    4. UringPollFuture 取出其中的用户数据,还原成 UringTask 对象,通过 channel 通知 UringReadFuture I/O 操作已经完成。

整个流程如下图所示。

uring

这样,我们就可以方便地调用 io_uring 实现文件的异步读取。这么做还顺便带来了一个好处:任务提交可以自动 batching。 通常来说,一次 I/O 操作会产生一次 syscall。但由于我们使用一个单独的 Future 来提交、轮询任务,在提交的时候, 队列里可能存在多个未提交的任务,可以一次全部提交。这样可以减小 syscall 切上下文的开销 (当然也增大了 latency)。 从 benchmark 的结果观察来看,每次提交都可以打包 20 个左右的读取任务。

Benchmark

将包装后的 io_uringmmap 的性能作对比。测试的负载是 128 个 1G 文件,随机读对齐的 4K block。 我的电脑内存是 32G,有一块 1T 的 NVMe SSD。测试了下面 6 个 case:

  • 8 线程 mmap。 (mmap_8)
  • 32 线程 mmap。 (mmap_32)
  • 512 线程 mmap。 (mmap_512)
  • 8 线程 8 并发的 io_uring。(uring_8)
  • 8 线程 32 并发的 io_uring。即 8 个 worker thread, 32 个 future 同时 read。(uring_32)
  • 8 线程 512 并发的 io_uring。(uring_512)

测试了 Throughput (op/s) 和 Latency (ns)。

casethroughputp50p90p999p9999max
uring_8104085.7771077705383166109183246416310588314973666
uring_32227097.613569183571428692127301111491332188914336132
uring_512212076.516050544719734213521119194783482555170035433481
mmap_8109697.8702574455878971107021204211178782318522047
mmap_32312829.53428971884100336178914419955440821455129932
mmap_512235368.9890904751255642932652661594674450029659156095218

发现 mmap 吊打 io_uring。嗯,果然这个包装做的不太行,但是勉强能用。下面是一分钟 latency 的 heatmap。每一组数据的展示顺序是先 mmap 后 io_uring

mmap_8 / uring_8 waterfall_mmap_8 waterfall_uring_8

mmap_32 / uring_32 waterfall_mmap_32 waterfall_uring_32

mmap_512 / uring_512 waterfall_mmap_512 waterfall_uring_512

Throughput-2

p50 Latency (ns)

一些可能的改进

  • 看起来现在 io_uring 在我和 Tokio 的包装后性能不太行。之后可以通过对比 Rust / C 在 io_uring nop 指令上的表现来测试 Tokio 这层包装引入的开销。
  • 测试 Direct I/O 的性能。目前只测试了 Buffered I/O。
  • 和 Linux AIO 对比。(性能不会比 Linux AIO 还差吧(痛哭
  • 用 perf 看看现在的瓶颈在哪里。目前 cargo flamegraph 挂上去以后 io_uring 没法申请内存。(占个坑,说不定能出续集
  • 目前,用户必须保证 &mut buf 在整个 read 周期都有效。如果 Future 被 abort,会有内存泄漏的问题。 futures-rs 的类似问题见 https://github.com/rust-lang/futures-rs/issues/1278 。Tokio 目前的 I/O 通过两次拷贝(先到缓存,再给用户)解决了这个问题。
  • 或许可以把写文件和其他操作也顺便包装一下。

作者简介:

迟先生(skyzh),上海交通大学大三学生,SJTUG 镜像站维护者,沉迷写 Rust。

如何为 Rust 语言做贡献 | Part 1

作者:CrLF0710(野喵)/ 后期编辑:张汉东


引文

如果你想成为 Rust 贡献者,那看这系列文章会很有帮助。

本系列文章主要是给大家介绍一下如何为 Rust Project (即 Rust 语言本身)做贡献。

随着时间的推移,Rust Project 也在不断的演化,本文有效范围仅限于当前发表的时间点(2021.02)。

接下来就随我一起熟悉 Rust Project 吧。

熟悉 Rust Project

简单来说 Rust Project 的主要目标就是设计、开发、维护Rust这门编程语言。

Rust Project 主要由下列三部分构成:

  1. 第一部分是现有的技术积累,包括设计文档、代码仓库、文档教程和技术讨论的积淀。
  2. 第二部分是 Rust 的项目组织及其延伸,包括整个 Rust 开发者社区。
  3. 第三部分是 Rust 的配套资产(如 CI、服务器、域名,乃至于商标)和会议活动等等。

熟悉 Rust 代码仓库

Rust 语言的设计文档、代码仓库、文档教程都是存储在Github上的rust-lang这个组织下的。其中rust-lang/rust这个仓库是主入口。

感兴趣的话,我们可以用git来直接下载一份下来。注意它是使用了git submodule的,相关联的仓库也都是需要的。

代码仓库大概分成六部分:

  • 编译器源码:位于compiler/目录下,由五十多个crate构成。另外还有它会用到的llvm,位于src/llvm-project目录下。
  • 内置库源码:位于library/目录下,有十几个crate。我们平时会使用的core, alloc, std, test这些都在其中。
  • 其他开发工具:位于src/librustdoc/, src/tools/目录下,包括我们平时使用的rustdoc, cargo, miri, clippy 等等工具
  • 文档书架:位于src/doc/目录下,包括官方的the book, reference, nomicon等等的教程和参考文档。
  • 测试用例集:位于src/test/目录下,大部分是编译器的测试用例,也有少量一些rustdoc和其他工具的测试用例。
  • 部署工具和CI脚本:位于src/bootstrap, src/build_helper, src/ci, .github/ 这几个地方,这些是用来自动化编译一套完整的rust工具链的。

编译一套 Rust 工具链

下载好了rust源码之后,我们来试着自己编译一份rust工具链吧!

首先要在你的机器上准备这些东西:python3, ninja, cmake,还有一套c++编译器(g++或者windows下用visual studio)。第一个是用来执行编译脚本的,后两个则是用来编译llvm的。

准备好了之后,把rust目录里的config.toml.example拷贝一份,名叫config.toml。其中大部分内容都不用修改,但是我建议可以把增量编译启用,就是找到其中的#incremental = false这一行,去掉前面的#并且把后面的false改成true。

其他配置选项参考如下,具体作用在配置文件中有注释说明:


#![allow(unused)]
fn main() {
compiler-docs = false
submodules = false
configure-args = []
debug = true
codegen-units = 0
default-linker = "cc"
channel = "nightly"
}

构建Rust的三个阶段:

Rust 是⼀个⾃举的编译器,需要通过旧的编译器来构建最新的版本。所以⼀般是分阶段来完成:

  1. Stage0 阶段。下载最新beta版的编译器,这些x.py会⾃动完成。你也可以通过修改配置⽂件来使⽤其他版本的Rust。
  2. Stage1 阶段,使⽤Stage0阶段下载的beta版编译器来编译从Git仓库⾥下载的代码。最终⽣成Stage1版编译器。但是为了对其优化,还需要进⾏下⼀阶段。
  3. Stage2,⽤Stage1版编译器继续对源码进⾏编译,以便⽣成Stage2版编译器。

理论上,Stage1Stage2编译器在功能上是相同的,但实际上还有些细微的差别。

官⽅推荐的具体构建流程如下:

  1. ./x.py check ,先执⾏此命令,检查编译器是否可以构建。
  2. ./x.py build -i --stage 1 ,进⾏Stage 0Stage 1阶段的构建,最终构建完成Stage1的编译器。
  3. ./x.py build --stage 2 compiler/rustc,在Stage1基础上进⾏增量式构建,最终编译出Stage2的编译器。

整个过程是有点慢的,不考虑一开始的下载部分,编译时间随你的硬件配置不等,一般在20到60分钟左右。其中大约有一半的时间是在编译llvm。好在llvm只要编译一次,后续如果没有版本变化是不需要重新编译的。(config.toml里有个选项在版本变化的时候也不重新编译llvm)另外记得硬盘剩余空间要保证30G以上哦。

然后将其加到Rustup⼯具链中:


#![allow(unused)]
fn main() {
// your-target-tripe 类似:aarch64-apple-darwin/x86_64-apple-darwin 等。
> rustup toolchain link stage2 build/{your-target-tripe}/stage2
}

到此为⽌,准备⼯作就已经做好了。

对这个话题感兴趣的可以继续读读官方准备的书籍Guide to Rustc Development,里面有更多的讲解。这本书中文社区也在组织翻译Guide to Rustc Development 中文版,欢迎大家参与。

一起成为 Rust Contributor 吧

接下来,让我们试着为 Rust 项目来做点事情。Rust Project是非常欢迎大家参与的,参与的门槛是非常的低。

对于想参与贡献的新手来说,可以从比较轻松的任务做起。由此,我来试着难度从低到高列出一些比较适合新手来做的事情。

No.1 改进标准库文档

Rust 的每个标准库函数都在旁边有markdown语法的文档描述。对这一部分的调整改进是门槛最低的。可以多读读标准库的文档,顺便检查每个条目(item)和关联条目的文档描述是否足够的清晰。(特别是标注着Experimental的那些,往往会存在改进空间。)对于没有示例(Example)的部分,可以补充示例。对于标注了unsafe关键字的部分,可以检查下安全性(Safety)一节是否清晰的描述了使用时的约束条件。

No.2 改进语言参考手册

Rust 有一个相对冷门的资源叫The Rust Language Reference,是语言的规格说明的雏形,实际上能做的事情相当多。但是因为人手有限,进度不是很快。对于新手,有很多参加编辑性修改的机会。实质性修改门槛会稍微高一点,需要对语言有比较全面深刻的了解。但是因为是有老手帮助review,对新人来说也是不错的提升自己的机会。缺点是review周期可能会相对较长。

No.3 重构、清理、增加测试用例类任务

Rust里很多地方都有小型的重构、清理任务(而且很多都是故意留给新人练习的),包括rustc,rustdoc,cargo,chalk,polonius之类的地方都会有。可以多关注一下E-easy,E-mentor,E-needs-test这些标签下的问题条目,也不要忘了多去逛逛cargo,chalk等等的单独仓库。

No.4 完善编译器的诊断和代码质量检测

在编译器这一侧,最适合初学者学习的工作有两项,一个是诊断(diagnostics),负责编译报错信息的完善,尽可能推断出用户的原本意图,并给出更好的错误提示。另一个就是代码质量检测(lint)。代码质量检测检查的是代码中那些不违反基本规则的那些写法,它们是可配置的,编译器可以配置为允许,警告,拒绝和严禁的形式进行响应。Guide to Rustc Development中有专门的一节进行讲解,可做的事情也是非常多的。对于一些非常具体情况的检测和反馈,也可以放到clippy这个专门的检测工具中。可以多关注一下A-Diagnostics, A-suggestion-diagnostics, A-lint这些标签下的问题条目,以及clippy仓库中的问题条目。

Rust PR 流程:从提交到合并

要提交修改只要在GitHub上 fork 官方的rust仓库,把修改提交到自己的fork仓库里,然后建一个PR(Pull Request)就可以了。

接下来我来试着讲讲提交之后会发生的事情。感兴趣可以了解下,不感兴趣也可以跳过。

PR CI 阶段

官方rust仓库有好几个自动交互机器人。我们首先会接触到的是一个叫rust-highfive的机器人。它负责欢迎新人,并且如果你的 PR 里没写由谁来review的话(格式是r? @XXX),它会自动把我们的PR随机分配给它觉得合适的人来review。分配的方法是它会看你修改了仓库里哪些文件,然后在相应的负责人员列表里随机分配。并且给你的 PR 加上一个S-waiting-for-review的标签,表示正在等待review的状态。同时 PR CI 会开始运行,如果你的修改有格式问题(没有执行rustfmt之类的)、编译或者单元测试不通过,就会被 PR CI 拦下来,告诉你编译失败。你可以继续调整。

官方 Reviewer 审阅

接下来几天之内往往就会有官方 Reviewer 来审阅我们的修改了。Reviewer 都是官方某个团队的正式成员。因为 PR 都是公开的,在这期间,其他成员、社区爱好者也有可能会帮忙审阅你的代码,帮我们提出修改意见之类的。Reviewer 看了之后也可能要求我们修改。他们会把 PR 状态改成S-waiting-for-author。还有一种情况是这段时间里代码更新导致了合并冲突。机器人会来留言告诉你有合并冲突。这个时候你需要执行一个gitrebase操作,完成对合并冲突的解决,然后更新你的 PR 分支。

很多 PR 会在这一阶段停留一段时间,官方有一个小的分类处理工作组(T-release/WG-triage),会定期来检查各个 PR 的状态。对于等待作者处理的 PR,15 天左右会留言确认状态;如果 30 天左右没有响应,会留言并关闭 PR。对于等待review的 PR,会在 15 天左右整理成报告,部分会通知 reviewer 确认审阅进度。

PR 合并

Reviewer 觉得你的提交ok了之后就会进入下一阶段了。Reviewer 会给另一个名叫bors的机器人发指令标识审阅通过(@bors r+)。这个命令有俩参数,一个是优先级(p),优先级高的会在排在队列靠前的位置。一个是是否参与批量合并(rollup)。如果你的贡献足够微小,Reviewer 会把rollup设置为always,永不参与单独测试合并。相反如果你的贡献可能会带来编译性能影响之类的,Reviewer 会把rollup设置为never,永不参与批量测试合并,这样万一以后需要revert的话会比较方便。

接下来就是测试合并阶段了。Bors机器人管理着一个PR队列Bors机器人会按照队列的顺序一次一个 PR 来先合并,再测试,通过后推送远端分支并更新关闭相应的 PR。对于那些rollup=always的 PR,bors是不会合并的。官方的一些成员会轮流负责Rollup工作,每次控制Bors机器人来产生一个8~12个 PR 构成的一个高优先级的批量合并的 PR 加到队列里,由bors来测试合并。

小结

这次我们从一个开发者的视角,了解了参与rust项目所需要的一些基本知识和切入点,下一次我们会介绍一下项目组的总体结构以及如何参与一些更大型的工作。到时见!


作者介绍:

CrLF0710,C++程序员/ Rust业余爱好者/ Rust Team版本发布团队分类处理工作组(负责参与 Rust Project 的issues 和 PR 分类管理)成员。

业余时间写些Rust代码,也对rustc, cargo, chalk, rustup, rustbook等都做过一些代码贡献。偶尔在知乎Rust主题专栏《学一点Rust又不会怀孕》上写一些文章。

三月刊

logo

发刊通告

本月社区动态简报

精选自《Rust日报》

Rust in Production

学习园地

【系列】透过 Rust 探索系统的本原

Rust 编译器专题

发刊通告

编辑:张汉东

三月发刊通告

三月,万物复苏,万象更新。不知不觉,三月的最后一天到了,《 RustMagazine 中文精选 》2021 年第三期发布了!

每次发布月刊,都会体会到时光流逝的无情。毕竟一年才十二个月,所以月刊一年一共才十二期,现在第三期已经发布了。更重要的是,当月刊发布的时候,也意味着三月即将过去。为了迎接四月的到来,大地作了太多的准备,你呢?你的四月又将为什么样的目标做准备呢?无论如何,加油吧!

社区协作项目动态介绍

介绍两个新创建的协作项目:

  1. Star Rust。该项目用于记录 Rust 开源生态中的明星项目。不同于 awesome-rust ,该项目侧重于记录明星项目,及其介绍、架构、应用、源码解读。
  2. Real World Rust Design Pattern。该项目用于挖掘 Rust 开源生态中知名项目的设计模式。

以上项目是需要社区大家一起完成的,如果你是一个喜欢学习并且输出的人,并且对上面项目感兴趣,欢迎大家一起做贡献。

将来这些内容,也会摘录到本刊中。

**【活动预告】2021.04.10 北京 Rust Meetup **

报名链接 : http://hdxu.cn/ZxJjK

该活动相关议题内容也请关注下期月刊。

上期(二月刊)访问数据统计小结

用户数

  • 总用户数 1670 (同比上升 268%)
  • 30天活跃用户数 464

浏览量:

  • 网页浏览量 :11,410 (同比上升 349.8%)
  • 唯一身份浏览量 :7,410

读者分布地区排名:

  • 中国 (同比上升 212%)
  • 中国香港(同比上升 369%)
  • 中国台湾(同比上升 257%)
  • 新加坡(同比上升 467%)
  • 北美(美国/加拿大)(同比上升 296%)

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

简报关注分类依次为:

热度基本和一月刊相差无几:

  • 学习资源
  • Rust 官方动态
  • 推荐项目
  • 社区热点
  • Rust 唠嗑室

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

Rust官方动态

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑: 张汉东

建立 Async Rust 的共同愿景

2021年3月18日·Niko Matsakis 代表 Async Foundations Working Group

异步基础工作组 认为 Rust 能够成为最热门的选择之一为构建分布式系统,从嵌入式设备到基础云服务。无论他们将其用于什么,我们都希望所有开发人员都喜欢使用 Async Rust。为了实现这一点,我们需要将 Async Rust 移至目前的“MVP”状态之外,并使所有人都可以使用它。

我们正在开展合作,为 Async Rust 构建共享的 愿景文档我们的目标是让整个社区参与到集体的想象中:我们如何才能使使用异步 I/O 的端到端体验不仅是一种务实的选择,而且是一种快乐的选择?

点此阅读该文中文翻译

Rust 1.51 稳定版发布

$ rustup update stable

该版本主要是带来了 :

  1. Const Generics MVP : https://blog.rust-lang.org/2021/02/26/const-generics-mvp-beta.html
  2. 顺便 std::array::IntoIter 也稳定了

#![allow(unused)]
fn main() {
pub struct IntoIter<T, const N: usize> {
    data: [MaybeUninit<T>; N],
    alive: Range<usize>,
}

impl<T, const N: usize> IntoIter<T, N> {

}
}
  1. 新的 cargo crate 依赖管理机制。 具体查看 RFC 2957。 简单来说,通过设置 resolver="2" 来告诉 cargo 启用新的解析 features 方法,从而解决当前因为cargo 默认合并features带来的问题。概述:
  • 对于 dev dependencies: 当包(package)作为常规依赖项和开发依赖项共享时,仅当当前构建包含开发依赖项时,才启用开发依赖项features
  • Host Dependencies :当包作为 常规依赖 和 构建依赖或proc-macro共享时,用于常规依赖的features 将独立于构建依赖或proc-macro。
  • Target Dependencies: 当包在构建图中多次出现,并且其中一个实例是特定于目标的依赖项时,仅当当前正在构建目标时,才启用特定于目标的依赖项的features。

不过这样可能会导致编译时间加长(因为可能多次编译同一个crate),更详细内容可以看 Cargo Guide 的 "Feature Resolver" 小节。


#![allow(unused)]
fn main() {
[package]
resolver = "2"
Or if you're using a workspace
[workspace]
resolver = "2"
}
  1. 针对 MacOS 平台对 Debug 模式构建时间做了优化。去掉了之前通过 dsymutil 工具将debug信息收集到.dSYM目录下的方式,而使用新的方式,从而减少debuginfo的构建时间,并显着减少所使用的磁盘空间量。但还期待macOS 用户的更多构建报告。

#![allow(unused)]
fn main() {
[profile.dev]
split-debuginfo = "unpacked"
}

这样设置就可以启用新的行为

  1. 稳定了很多 API ,就不细说了。值得一提的是 task::Wake现在稳定了。

https://blog.rust-lang.org/2021/03/25/Rust-1.51.0.html

Rust 2021 Edition 计划10月21号发布

Rust 采用每六周一个小版本和每三年一个 Edition 版本的方式来迭代更新。相比于 2018 Edition,2021 Edition 会是一个相对小的版本,官方计划于 2021年10月21号(1.56)正式发布。目前并没有完全确定下来哪些功能将纳入 2021 Edition,但有部分特性是已经确定好的了,这些特性包括:

Prelude 加入新的 traitTryFrom / TryInto, FromIterator

更 ergonomic 的闭包变量捕获规则。

现在的闭包变量捕获非常严格,就算你只引用了单个 struct 的字段,它也会把整个 struct 捕获进来。新的规则会做到尽量小范围的捕获变量,比如下面两个例子在 2018 Edition 编译不通过,但是 2021 Edition 是可以的:


#![allow(unused)]
fn main() {
let _a = &mut foo.a;
|| &mut foo.b; // (Edition 2018) Error! cannot borrow `foo`

let _a = &mut foo.a;
move || foo.b; // (Edition 2018) Error! cannot move `foo`
改善 or 模式匹配
// 以前需要这么写的或规则匹配:
Some(Enum::A) | Some(Enum::B) | Some(Enum::C) | Some(Enum::D) => ..

// 2021 Edition 之后可以写成这样了!
Some(Enum::A | Enum::B | Enum::C | Enum::D) => ..
}

统一 macro_rules 定义的宏的默认可见性,移除#[macro_export]#[macro_use] 宏: Rust 所有类型可见性默认都是私有,只有加 pub 或 pub($PATH) 才能修改为公开可见,而 macro_rules 定义的宏却不是这样的,你需要使用 #[macro_export] 才能让这个宏公开。从 2021 Edition 开始,macro_rules 定义的宏默认为私有,同样需要加 pub 或 pub($PATH) 才能修改可见性。#[macro_export] #[macro_use] 这两个宏就没什么用了,直接移除。

链接

Rust 编译器后端升级为 LLVM 12

链接

gloo: 一个官方的 rustwasm 项目寻找 maintainer

gloo 是 rustwasm 下的一个官方项目 (801星) , 由于作者不能再维护, 所以在寻找一个maintainer. 感兴趣的小伙伴可以尝试联系一下.

原始issue地址

Miri运行在wasm上!

现在已经有方法可以将miri编译到wasm了。

issue

社区热点

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:张汉东

华为 | openEuler 社区在 3 月 29 日正式成立了 Rust SIG

近日,openEuler 社区正式成立了 Rust SIG 组织。在维护 openEuler 操作系统内 Rust 工具链相关软件包的同时,也致力于将上游 Rust 社区优秀开源项目引入到 openEuler 操作系统中。openEuler 社区未来会持续和国内 Rust 社区和相关厂商通力合作,推动 Rust 语言在国内的发展,吸引更多的开发者关注和使用 Rust 语言。

欢迎订阅 rust@openeuler.org 邮件列表,参与到社区讨论中。

订阅方法:

https://openeuler.org/zh/community/mailing-list/ 在这个页面找到 Rust SIG,然后进去有 SubScribe 按钮,输入你到邮箱订阅。然后会收到一封邮件,你回复这封邮件即可。

Niko | 我们的 AWS Rust 团队将如何为 Rust 未来的成功做出贡献

自今年年初以来,AWS Rust 团队一直在起草我们的章程和宗旨。 章程和宗旨是 AWS 团队用来定义我们的范围和优先事项的框架。 章程告诉你的团队该做什么,宗旨告诉你的团队将如何做到这一点。 由于我们的团队宗旨一直是公开和透明运作的,我们想与您分享我们的章程和宗旨,我们希望您知道我们在做什么。

起草我们的章程很容易。 这只是一句话:AWS Rust 团队致力于让 Rust 为其所有用户提供高效、可靠的服务。 说得够多了! 然而,撰写这些宗旨需要更多的工作。

等等,AWS 有个 Rust 小组?

是的! 事实上,至少从 2017 年开始,AWS 就在多项服务中使用 Rust。 例如,用 Rust 编写的 Firecracker 于 2018 年推出,提供支持 AWS Lambda 和其他无服务器产品的开源虚拟化技术。 最近,AWS 发布了用 Rust 编写的基于 Linux 的容器操作系统 Bottlerocket ,Amazon Elastic Compute Cloud(Amazon EC2) 团队使用 Rust 作为新的 AWS Nitro 系统组件(包括 Nitro Enclaves 等敏感应用程序)的首选语言。 随着在 AWS 中采用 Rust 的增长,我们对 Rust 项目和社区的投资也在增加。 2019年,AWS 宣布赞助 Rust 项目。 2020年,AWS 开始打造 Rust 维护者和贡献者团队,2021年,AWS 联合其他 Rust 用户和 Rust 项目发起了 Rust 基金会。 AWS Rust 团队首先找出了如何最好地与 AWS 和更广泛的开源社区建立联系。 我们知道,我们希望在公开的环境下运作,并成为整个社会的一份子。 与此同时,我们知道我们想要充分利用在 AWS 工作的机会。 起草章程和宗旨是我们找到两者兼顾的方法和过程的一部分。

点此阅读该文的中文翻译

Rust for Linux 相关

linux-next 分支现在已被合并。

Linus Torvalds 讨论 Rust 适合Linux 的场景

关键内容:

  1. Coreutils 的 Rust 版本已经被 Mozilla 主管 Sylvestre Ledru 移植到了 Linux 。有了这些,Ledru启动了Linux并运行了最受欢迎的Debian软件包。
  2. Linux Rust的支持者并不是: “提议将Linux内核重写为Rust“ 。 他们只是专注于向可能编写新代码的世界迈进。
  3. Rust支持的三个潜在方面是:利用内核中的现有API,体系结构支持,以及处理Rust和C之间的应用程序二进制接口(ABI)兼容性。
  4. Linus 目前到态度是: 等待和观察。 他对 Rust for Linux 是感兴趣的,就个人而言,他绝不会排挤 Rust,但Linus 认为目前这个事情是那些对 Rust 抱有强烈兴趣的人推动的(Linus比较冷静),他想看看最终 Rust 在实践中如何发挥作用。
  5. linux 认为 Rust 可能的场景:Rust的主要首要目标似乎是驱动程序,仅是因为在那里可以找到许多不同的可能目标,并且内核的这些各个部分相当小且独立。这可能不是一个非常有趣的目标。对某些人来说,但这是显而易见的。
  6. Kroah-Hartman 的观点:“一切都归结为如何将用C编写的内核核心结构和生存期规则之间的交互映射到Rust结构和生存期规则中”
  7. 尽管几乎可以肯定不会很快看到Linux从C 迁移到Rust,但是接下来的几年估计会非常有趣: 引入基于 Rust 的用户空间程序/ 驱动程序/ 基于 Rust 的 内核迁移到 Linux 等。

相关链接合集,排序规则:最上面的是最新的

Linux Kernel's Preliminary Rust Code Seeing 64-bit POWER Support https://www.phoronix.com/scan.php?page=news_item&px=Linux-Kernel-Rust-PPC64LE

https://www.phoronix.com/scan.php?page=news_item&px=Rust-Hits-Linux-Next

https://www.zdnet.com/article/linus-torvalds-on-where-rust-will-fit-into-linux/

https://lore.kernel.org/rust-for-linux/CANiq72nbNxpps+p4wYp03ncrbGH9FFoTfHQZwg_vGdPO41eGmQ@mail.gmail.com/t/

https://git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/rust?id=c77c8025525c36c9d2b9d82e4539403701276a1d

Linux 基金会 和 RISCV 基金会 共同推出的 免费 RISCV 课程

课程发布在 edx.org 上,包括两个课程:

  • Introduction to RISC-V (LFD110x)
  • Building a RISC-V CPU Core (LFD111x)

https://www.zdnet.com/article/linux-foundation-risc-v-international-launch-free-risc-v-training-classes/

Rust and LLVM in 2021

作者是 Rust 的核心团队成员, 之前就职于 Mozilla, 现就职于 Facebook. 写过最初的基于 LLVM 的 Rust 代码生成器, 以及很多 Rust 相关的工作.

该 keynote 讲述的是 Rust 中 LLVM 相关工作:

新的特性. 将LLVM 的提升带到 Rust 中. LLVM 相关的提升和修复. 未来的挑战. 对于 Rust 编译器层面感兴趣的小伙伴可以深入了解.

keynote地址

Rust版coreutils现在可以用来跑Debian啦

现在可以用Rust版的Coreutils (cp, chmod, ls, rm, tail, install..) 来运行Debian啦。

curl 工具一半的漏洞都是关于 C 语言的错误

作者对这一问题进行了分析,并提到一个观点,如果用 Rust 来写 curl 的话,这些漏洞会减少一半。

链接

Rust 和 C 速度比较

Rust 和 C 的编程风格差异很大,但两者开发的程序在运行速度和内存使用情况上大致相同。语言在理论上可以实现什么,但在实践中如何使用它们之间有很大的区别。作者总结了Rust 和 C 各自在哪些地方会更快。

简而言之

  • Rust 可以在必要时以足够底层的方式对其进行优化,使其达到与 C 一样的性能;
  • Rust 拥有更高层次的抽象,便捷的内存管理和丰富的第三方库;
  • Rust 最大的潜力在于无畏并发(fearless concurrency)能力。

文章链接

GitHub Action 将 Rust warning 转为 review comments

Rust Action 可以在出发执行后,将 Rust check 的 warning 转为 code review 的 comments。

ReadMore

INTELLIJ RUST CHANGELOG #143

为类似函数的程序宏提供初步支持。现在,插件可以扩展这种程序性宏调用;因此,它们自动获得声明性宏已经具备的一些功能:高亮显示、名称解析、有限的代码完成、意图等。

gif

Veloren 0.9

一款开源多人RPG游戏,今天发布了!会在3月20日格林威治时间18:00发布在公共服务器上!

《Veloren》是一款多人体素RPG游戏。它的灵感来自《魔方世界》、《塞尔达传说:荒野之息》、《矮人要塞》和《我的世界》等游戏。

Veloren是完全开源的,使用GPL 3授权。它使用原始图形,音乐和其他资产社区创建的资产。它的开发社区和用户社区都是受贡献者驱动的:开发者、玩家、艺术家和音乐家一起开发游戏。

文章链接

Actix Actor Framework v0.11 出來了

~40% 的效能改善,升级到 Tokio v1

Actix 0.11 Change Log

知乎| 搜索引擎研发(Rust) 工程师

岗位职责

  • 负责搜索引擎平台架构建设,优化系统稳定性,设计良好的架构支持业务快速迭代
  • 抽象通用的搜索引擎部署方案,用于快速支持各大垂直搜索引擎
  • 参与知乎搜索业务优化

任职要求:

  • 有扎实的编程能力,有良好的数据结构和算法基础
  • 良好的团队合作精神,较强的沟通能力
  • 熟悉 Linux 开发环境,熟悉 Go/Rust 语言,熟悉网络编程、多线程编程
  • 熟悉搜索引擎,对 Elasticsearch、Kubernetes 有使用经验者优先
  • 有高可靠分布式系统架构设计经验者优先

知乎搜索Rust 开源项目: https://github.com/zhihu/rucene

联系邮箱:

zl 【at】 zhihu 点 com

蚂蚁集团校招开启:Rust 实习生看过来

@2021.11.1~2022.10.31毕业的应届生可看 ,要推荐的可以找我咨询 ,也可直接联系。

招聘部门:

部门相关信息看下面链接:

https://mp.weixin.qq.com/s/9t6_RrgSujrosDVphlzebg

3.27 号 深圳 Rust Meetup 视频和资料

活动PPT和现场视频链接:

B 站:

本月简报 | 推荐项目

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:杨楚天

lens-rs

lens-rs 是一个 lens 的 rust 实现。

tinyvm

tinyvm 是一个堆栈字节码虚拟机的最小化可用实现。这个 VM 可以运行一个简单的图灵完备的指令集。核心代码只有 250 行,并且有大量注释。

maple

maple 是一个用 WASM 实现的响应式 DOM 库,没用到虚拟 DOM,而是在渲染过程中细粒度响应式地更新 DOM。

byo-linker

byo-linker 是一个极简的链接器,用于帮助理解链接器的实现方法。

rs_pbrt

rs_pbrt 是经典书籍 Physically Based Rendering: From Theory to Implementation 的 rust 实现。

flume

flume 是一个 mpmc 的 channel 库,其用法和 std::sync::mpsc 基本一致,代码里没包含任何 unsafe

ferris-fetch

ferris-fetch 可以用于获取 rust 工具链以及操作系统的信息。

Station Iapetus

Station Iapetus 是一个用 rg3d 开发的第三人称射击游戏,仍处于早期开发阶段。

Veloren

Veloren 是一个像素风的多人 RPG 游戏,其灵感来自《魔方世界》、《塞尔达传说:荒野之息》、《矮人要塞》和《我的世界》等游戏。

mlc

mlc 可以用于检查 html 和 markdown 中的无效链接。

Kamu

img

Kamu 是 Open Data Fabric 的 rust 实现。

MiniWASM

MiniWASM 是一个极简的 Rust WebAssembly 的项目模版。

rkyv

rkyv 是一个反序列框架,作者号称框架的速度比 serde_json 还要快。

ter

ter 是一个 cli 程序,可以用类似自然语言的命令去做一些文字处理工作,例如过滤或者替换。

ipipe

ipipe 是一个跨平台的命名管道库。

Gloo

Gloo 是一个模块化的工具箱库,可以用于 Wasm 项目的开发。

aws-lambda-rust-runtime

aws-lambda-rust-runtime 是一个AWS Lambda Functions 的 runtime。

其中包括:

  • lambda-runtime crate 用于提供 AWS Lambda 的 runtime
  • lambda-http crate 用来写 AWS Lambda 的 API 网关代理事件

synth

synth 是一个声明式的数据生成器,其主要特性有:

  • 数据即代码
  • 导入已有数据
  • 数据自动推导
  • 不限定特定数据库
  • 语义化数据类型

TiFS

TiFS 是一个基于 TiKV 的分布式 POSIX 文件系统,具有分区容限和严格的一致性。

一个基于 wasm+rust+simd 技术栈实现的音乐合成器

链接

InfluxDB IOx: 基于Apache Arrow 开发的新的 InfluxDB 核心

InfluxDB是一个开源时间序列数据库

目前频繁开发中,正处于项目早期,感兴趣的可以及早关注

Speedy2D: 兼容 OpenGL (ES) 2.0+ 的图像库

Speedy2D 是一个拥有硬件加速, 简单易上手的 API的图像库, 可以方便的绘制 各种形状, 图像 和 文本.

目标:

  • 最简单的 Rust API 来创建 window, 渲染图像和文本, 处理输入.
  • 兼容任意带有 OpenGL 2.0+ 和 OpenGL ES 2.0+ 的设备
  • 非常快

idcard-cn v0.0.1

过去的一周时间小编翻看了一些基于 Rust 的身份证识别库(如 https://crates.io/crates/rust-idcard ),基本上只提供了身份证证件号码和其他文本信息的读取,而缺少其他根据《中华人民共和国身份证法》需要提供的指纹和照片信息的读取。于是小编决定将这些信息结构化,并且统一为信息完全的特质库,并且提供了一些相应符合生活常识的类型对读取的身份信息进行处理

Qovery Engine - Rust库,可在云服务上自动化部署应用程序

Qovery Engine是一个开源抽象层库,仅需几分钟,它就可以轻松地在AWS,GCP,Azure和其他云提供商上部署应用程序。Qovery引擎是用Rust编写的,并利用Terraform,Helm,Kubectl和Docker来管理资源。

  • 零基础架构管理: Qovery Engine为您初始化,配置和管理您的Cloud帐户。
  • 支持多个云:Qovery Engine可以在AWS,GCP,Azure和任何云提供商上使用。
  • 在Kubernetes之上: Qovery Engine在更高的抽象级别上利用了Kubernetes的功能。
  • Terraform和Helm: Qovery Engine使用Terraform和Helm文件来管理基础结构和应用程序部署。
  • 强大的CLI:使用提供的Qovery Engine CLI在您的Cloud帐户上无缝部署您的应用程序。
  • Web界面: Qovery通过qovery.com提供Web界面。

Linfa : Rust写的统计学习综合工具箱

cargo-quickinstall 0.2.0版本发布

[cargo-quickinstall (https://crates.io/crates/cargo-quickinstall)] 有点类似于Homebrew的Bottles(二进制包)概念,但用于 Rust。

示例:

cargo quickinstall ripgrep

作者认为:在此之前,通常将二进制文件托管在Bintray(homebrew serves)上,但是该服务正在逐步淘汰,因此需要更换它。

Rust编写的清理应用程序的 Czkawka 3.0.0发布

完全用Safe Rust 和 gtk 实现,且跨平台,多功能应用程序,可查找重复项,空文件夹,相似图像等。

GraphGate 0.3.2 发布.

用 Rust 实现的GraphQL API网关。

为什么要用Rust来实现?

​Rust是我最喜欢的编程语言。它既安全又快速,很适合开发API网关。

libretranslate-rs

一个可以替代谷歌翻译的自由/开源软件(Rust编写!),使用的是 libretranslate.com的 API。

tide-acme:通過Let's Encrypt自動獲得HTTPS證書

Let's Encrypt 是個很常用的免費ssl證書服務

作者結合了tide與Let's Encrypt做了一個自動取得證書給tide使用的範例

CleanIt: Rust实现的可以通过 gRPC 控制 Roomba 系列扫地机器人的框架

还在开发中。

发现 Roomba系列机器人吸尘器 是中国广东的公司。

task-stream 一个能运行在no_std的全局异步任务spawner

task-stream是一个全局任务spawner,可以在no_std中运行。

它提供了用于异步任务的spawner,以及异步延迟函数。

它是为库作者设计的。 在第三方库中,可以生成子任务,而无需关心执行程序主程序使用的子任务。

Shipyard 0.5了

這是一個ECS框架 速度比上一版增加快了2倍

本月简报 | 学习资源

  • 来源:Rust日报
  • 作者:Rust日报小组
  • 后期编辑:苏胤榕(DaviRain)

Rust 常见疑问汇总

Rust tg 群 「Rust 众」总结了这份 Rust 常见疑问汇总。

本期摘录:

  1. 如何在特质(trait)里添加异步函数?

目前 Rust 不支持在特质里直接添加异步函数,但可以使用 async-trait 这个库来实现。这个库会将异步函数改写为返回 Pin<Box<dyn Future>> 的普通函数以绕过目前语言层面的限制,但也因此有堆分配以及动态分发这两个额外的代价,所以不会被直接添加到 Rust 语言中。

在特质里不支持使用异步函数是由于异步函数本质上是一个返回 impl Future<Output = T> 的函数,而目前 Rust 的类型系统还无法表达在特质的方法的返回类型上使用 impl Trait。有两个已经通过的 RFC 旨在解决这一问题:RFC 1598 泛型关联类型和 RFC 2071 impl Trait 存在类型,但它们的编译器支持还在实现中。

  1. 为什么 Rust 生成的程序体积比较大?如何最小化程序体积?

有多个因素使得 Rust 在默认情况下有着相对较大的程序体积,包括了单态化、调试符号、标准库等。一般来说,Rust 偏向于为性能优化而非更小的体积。

通常使用发布模式编译(--release),以及(在 Linux 和 macOS 下)使用 strip 删除符号信息可以在一定程度上缩小程序体积。更多方法可以参考 Minimizing Rust Binary Size,对这一问题有较完整的介绍。

欢迎贡献:

更多阅读: https://rust-zh.github.io/faq/

C++ to Rust - or how to render your mindset

作者打算使用 Rust 重新实现 C++教程 <<Ray tracing in One Weekend>>,本文目标人群是对于 Rust 感兴趣的,或者对图像渲染感兴趣的人。

通过本教程,最终会得到下面的预览图。

整个过程, 作者既给出了C++代码, 也给出了Rust代码,对于熟悉 C++的同学,可以更加清晰的了解两种语言的不同。

链接

rust-algorithms 用 Rust 写算法的教科书

作者 @EbTech 是应用数学出生,因此本书提供的算法主要包括应用数学(傅里叶变换等)的算法以及图的算法使用 Rust 的实现。

仓库

回顾异步Rust

parity 工程师 tomaka 这篇博文,总结了他在日常开发中遇到的 Rust 异步的一些关键问题,值得一读。

阅读

Rust: 提防转义序列 \n

serde是在Rust生态系统最流行的crate,一个用于高效地序列化和deseri一个LIZING数据结构。它支持多种数据格式,包括JSON,YAML,MessagePack等。与许多其他(反)序列化器不同,它不使用运行时反射,而是使用Rust的引人注目的特征系统。这使Serde异常有效,因为数据结构本质上知道如何序列化或反序列化自身,并且它们通过实现SerializeorDeserialize特性来实现。幸运的是,这要归功于大多数类型,这要归功于derive宏。您可能会像我一样遇到陷阱,在这篇博客文章中,作者想特别谈一谈:转义序列。

ReadMore

bevy-physics-weekend 项目

这个项目是使用 Rust 编程语言和 Bevy 游戏引擎对 Game Physics in a Weekend这本书的实践。这对我来说是一个学习练习,以便更好地理解如何使用 Bevy 和数学库 glam 来实现物理引擎。项目

地址

Rust 优化技巧

如果您希望用 Rust 编写速度更快的代码,那么有好消息!Rust 让编写快速代码可以变得非常容易。对零成本抽象的关注、缺乏隐式装箱和静态内存管理意味着,即使是 naïve 代码也往往比其他语言中的同类代码要快,当然也比任何同等安全的语言的代码要快。但是,也许像大多数程序员一样,您在整个编程生涯中都不必担心机器的任何细节,而现在您想要更深入地了解并找出重写的 Python 脚本的真正原因。 Rust 的运行速度快了 100 倍,并使用了十分之一的内存。毕竟,它们都做相同的事情并在相同的 CPU 上 运行,对吧?

因此,这里有一个优化指南,针对那些知道如何编程,但可能不知道代码如何 CPU 上映射到真实的 1 和 0 的人。我将尝试编写关于优化 Rust 代码的实用技巧,并解释为什么它比替代方法更快,最后我们将以 Rust 标准库中的一个案例研究作为结束。

原文地址

用Rust给我的操作系统写乒乓(Pong)

我们上一讲结束了为我们的操作系统编写图形驱动程序和事件驱动程序。我们还添加了几个系统调用来处理绘图原语以及处理键盘和鼠标输入。现在我们将使用这些动画来制作简单的pong游戏。就像《hello world》是对所有编程语言的测试,《pong》也是对我们所有图形和事件系统的测试。

文章链接

Rust Style Guidelines

rust-guidelines 收集了用于编写Rust代码的新出现的原理,约定,抽象和最佳实践。

Docs

GitHub

避免使用Rust进行单线程内存访问错误

在本文中,我们将研究单线程C ++中的几种内存访问错误,以及Rust如何防止我们犯这些错误。我写了一篇简短的文章,展示了C ++中的内存访问错误以及Rust如何阻止我们访问这些错误。目录:Returning references to temporariesShort lifetimesReferenes to container contentsTricky lifetime extensions

阅读原文

Rust Web开发人员路线图

超详细 Rust Web 开发人员的路线图。

github

Rust 中返回引用的各种策略

本文总结了常见的返回引用的几种方式,强烈推荐。

原文链接

Learning Rust: Structs and Traits

这是一系列学习 Rust 视频的第三部分,Structs and Traits, 内容相对比较简单, 有喜欢看视频学习 Rust 的小伙伴可以翻墙看一下。

油管视频

教程:如何在Rust中实现一个迭代器

这篇教程中你将会学到如何为一个树结构实现迭代器。

阅读更多

【博客】Rust 命名空间

关于Rust中命名空间的内容讲解。

链接

我是如何使用 Rust 大幅提高笔记软件的性能的?

Giganotes 是作者开发的一个笔记软件,作者尝试使用 Rust 来提高软件的性能,并取得了很好的成效。

链接

learn wgpu系列教程终于完全升级到了0.7版本!

wgpu 创建渲染管道的方式已经进行了改进。大多数属于自己的字段都被分组为结构,例如 MultisampleState 。这意味着简单的管道更容易创建,因为我们可以对我们不关心的字段使用Default::Default()None

Read More 

Github 

教你如何用Rg3d制作一个射击游戏!

教你如何用Rg3d制作一个射击游戏系列教程更新第二章了!

Rg3d是一个使用Rust编写的游戏引擎,渲染后端用的是OpenGl的Rust绑定:glutin ,说到这个教程出现的原因,很心酸的想贴上这段话:

I have a simple question for the community: what stops you from using the engine? The lack of tutorials, immaturity, different approaches (no-ecs for example, or maybe you have your reason you want to share? I need to know what should be done first to make engine more newcomer-friendly. I have ~10 years of experience in gamedev and some things may be not obvious to me, so I need your help.

消息是Rg3d的作者在Discord上发布的,时间是2月16日的时候,发布之后有不少人表达了自己的想法,同时现在你看到的教程,也正是通过这次调查得到大家反馈之后才出的。作者本人在Discord上对大家的提问回复都很及时,Rust社区本身不是很大,同时Rust游戏社区就更小了,但是仍然有很多人对此不断耕耘,所以如果你对游戏开发很有兴趣,同时也是Rust厨的话,一直还没有去尝试过使用Rust开发游戏的你,一定要去感受一下使用Rust开发游戏!

Read More

Rust 异步不是有色函数!

本篇blog名字是Rust's async isn't f#@king colored!

本篇blog衍生自15年的一篇著名blog:What Color is Your Function?,在这篇blog种将编程语言的函数调用区分为不同的颜色,特别指出多种语言种的异步调用和同步函数是两种不同的颜色,在编写程序的时候会给程序员造成一些痛苦。而文中所说go、java之类的则不属于这类语言,详细的可以去看看原文。

而本篇blog也就沿着上面提到的这篇blog讨论了Rust异步编程种是否存在同样的问题。结论已经被标题出卖了,Rust异步不是有色函数!

What Color is Your Function?

Rust's async isn't f#@king colored!

Rust的异步是有颜色, 但没什么大不了

语言设计领域的一个争议点是 有色函数, 即将函数分为异步函数和非异步函数。这个术语来源于2015年一篇名为《What Color is Your Function?》的文章, 它用颜色来比喻JavaScript和其他带有显式异步函数的语言中的同步和异步函数之间常常令人痛苦的不匹配。

Rust 中,也有很多人讨论这个问题, 比如前几天有一片文章 Rust’s async isn’t f#@king colored!

这里作者将通过原始的定义和实践,来表达自己的观点: Rust 是有颜色的。

原文链接

看到Toml文件,真是相见恨晚

有位作者在Reddit发表了一篇帖子:I really love TOML files

“我没在使用Rust之前,并不了解Toml文件。现在我想用它来做任何事。” 这不就是传说中的:相见恨晚嘛。

为什么Toml文件会被人喜爱?下面有人评论: “对于许多简单的配置,它们真的很酷! 它们没有YAML陌生性,也没有JSON的字符串性,并且它们大多是扁平的,几乎没有级别缩进。”

详细链接

使用rg3d编写的射击游戏

用Rust写游戏:神枪在手,说抖不抖。

本教程是系列教程的后续部分,我们将使用rg3d游戏引擎制作3D射击游戏。

详细链接

Rust 和 C 速度比较

Rust 和 C 的编程风格差异很大,但两者开发的程序在运行速度和内存使用情况上大致相同。语言在理论上可以实现什么,但在实践中如何使用它们之间有很大的区别。作者总结了Rust 和 C 各自在哪些地方会更快。

简而言之

  • Rust 可以在必要时以足够底层的方式对其进行优化,使其达到与 C 一样的性能;
  • Rust 拥有更高层次的抽象,便捷的内存管理和丰富的第三方库;
  • Rust 最大的潜力在于无畏并发(fearless concurrency)能力。

文章链接

为什么 Rust 和 Go 一起更好?

简单,性能和开发者的生产力,使得 Go 成为创建面向用户应用服务的理想语言。更好的控制粒度使得 Rust 成为底层操作的理想语言。这并不意味着非得二选一,反而两者一起使用时会具有很高的灵活性和性能。

本文讨论了 Rust 和 Go 的优缺点,以及如何互相补充支持。

文章链接

使用 Rust 写一个 Postgres SQL 打印器: Part I

这是作者pg-pretty项目项目的一系列文章第一篇。

作者不能忍受非格式化的代码,对于大型项目来说,统一风格可以消除很多理解障碍。但是作者没有找到一个很好的 Postgres SQL 风格打印器,所以打算自己动手写一个。

原文链接

Crust of Rust: The Drop Check

这是 Crust of Rust 系列视频的最新一期: The Drop Check, 相信很多小伙伴对 Drop check 都很感兴趣,可以翻墙看一下。

油管视频

《Rust vs. Go》:为什么它们一起会更好

来自 Jonathan Turner and Steve Francia 的 blog,虽然其他人可能认为 Rust 和 Go 是有竞争力的编程语言,但 Rust 和 Go 团队都不这么认为。恰恰相反,我们的团队非常尊重其他人正在做的事情,并将这些语言视为对整个软件开发行业现代化状态的共同愿景的赞美。

注 Steve Francia【@spf13】 是隔壁 go 社区大佬, 更多请看

如何使用 Rust 发邮件

在 Rust 中发送电子邮件可以通过两种方式实现:使用 SMTP 服务器或使用带有 API 的第三方服务,如 AWS SES 或 Sendgrid。

详细实现请看

构建Async Rust的共同愿景

近期,Rust官方博客推出了一篇文章,《Building a shared vision for Async Rust》:构建Async Rust的共同愿景。

Rust Async基金会工作组认为,Rust可以成为构建分布式系统(从嵌入式设备到基础云服务)的最受欢迎选择之一。不管他们用它做什么,我们都希望所有的开发者都喜欢使用Async-Rust。为了实现这一点,我们需要将Async Rust从现在的“MVP”状态转移出去,尽可能让每个人都能使用。

文章地址

Rust 的 零大小类型(ZST) 的神奇应用

Rust 中有 零大小类型的概念,简称 ZST (Zero-Sized Types). 这些类型不会在他们的布局上包含任何的信息。 但是这并不意味着他们不重要,本文将深入解释 ZST 的设计理念和应用。 感兴趣的小伙伴可以看一下。

原文链接

lens-rs 指路

来自 脚趾头 的知乎投稿。作者之前使用 Rust 实现了 lens, 本文主要说明如何来把玩这个库 len-rs

原文链接

使用 Rust 构建 OpenStreetMap 应用: Part VI

使用 Rust 构建 OpenStreetMap 应用 的最新章节, 结合之前的内容, 本章结束会完成一个基本的应用。

原文链接

使用 Rust Iterator 的一些技巧

作者总结了 Rust Iterator 相关的几条技巧,感觉还不错。

链接

Pin and suffering

Cool bear系列的最新文章,可以让你深入的了解 Rust 中的 async

原文链接

2021年去哪里学习 Rust

2021 年了,去哪里学习 Rust 呢?

本文总结列出了一系列的 Rust 学习资料,想学习的 Rust 的小伙伴可以看看。

原文链接

Rusts Module System Explained

本文详细的介绍了 Rust 模块系统,从为什么需要模块系统? 到如何使用的非常详细,希望对模块系统深入掌握的同学可以看看。

原文链接

LibP2p 指南

这个教程展示如何使用Rust和出色的libp2p库构建一个非常简单的点对点应用程序。

阅读

示例代码

指南: 写一个 微ECS ( Entity Component System)系统

通过编写一个简单的 ECS 系统来学习什么是 ECS 。

阅读

【系列文章】学会飞行:使用 Rust / 神经网络 / 遗传算法 来模拟进化

Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust唠嗑室》第19期 - 启动 OpenRA-rs 项目+乱聊

时间:2021/03/02 20:30-21:30

主讲人:Mike

内容

OpenRA 是开源重制版红警,不过目前已经实现的部分只是红警1,红警2尚未完成。目前OpenRA是用C#写的。我们来憧憬一下,如果OpenRA用Rust重新实现会怎样?

如果做,那就基于Rust最热的bevy游戏引擎来做。对Rust游戏开发感兴趣的都来出出主意吧。

我们会先启动一个学习型的项目,openra-rs内部分享甚至会成为一个专门的视频系列。敬请期待。

查看回放

扩展资料

  • https://github.com/OpenRA/OpenRA

《Rust唠嗑室》第20期 - 软件选型方法,以Rust为例

时间:2021/03/16 20:30-21:30

主讲人:Andy

内容:软件选型方法

跟大家聊聊软件选型的方法,内容:

  1. 基础模型:天上不会掉馅饼定理、丑小鸭定理、康威定律、包线;
  2. 抽象:函数、对象、类型、Trait;
  3. 架构:服务端开源组件的取舍;
  4. 实例:ImmuxDB从v1到v2的架构调整。

本次演示使用Rust作示例。

查看回放

扩展资料

  • https://immux.cn
  • https://github.com/immux/immux

华为 | 基于Rust的下一代虚拟化平台-StratoVirt

作者: 徐飞 / 后期编辑: 张汉东


StratoVirt是什么

Strato,取自stratosphere,意指地球大气层中的平流层,大气层可以保护地球不受外界环境侵害,而平流层则是大气层中最稳定的一层;类似的,虚拟化技术是操作系统平台之上的隔离层,既能保护操作系统平台不受上层恶意应用的破坏,又能为正常应用提供稳定可靠的运行环境;以Strato入名,寓意为保护openEuler平台上业务平稳运行的轻薄保护层。同时,Strato也承载了项目的愿景与未来: 轻量、灵活、 安全和完整的保护能力。

StratoVirt是计算产业中面向云数据中心的企业级虚拟化平台,实现了一套架构统一支持虚拟机、容器、Serverless三种场景,在轻量低噪、软硬协同、安全等方面具备关键技术竞争优势。StratoVirt在架构设计和接口上预留了组件化拼装的能力和接口,StratoVirt可以按需灵活组装高级特性直至演化到支持标准虚拟化,在特性需求、应用场景和轻快灵巧之间找到最佳的平衡点。

为什么选择Rust

在项目成立初期,我们调研了业界成熟基于C语言开发的虚拟化软件-QEMU,统计了在过去十几年中QEMU的CVE问题,发现其中有将近一半是因为内存问题导致的,例如缓冲区溢出、内存非法访问等等。如何有效避免产生内存问题,成为我们在编程语言选型方面的重要考虑。因此,专注于安全的Rust语言进入我们视线。

  • Rust语言拥有强大的类型系统、所有权系统、借用和生命周期等机制,不仅保证内存安全,还保证并发安全,极大的提升软件的质量。在支持安全性的同时,具有零成本抽象特点,既提升代码的可读性,又不影响代码的运行时性能。
  • Rust语言拥有强大的软件包管理器和项目管理工具-Cargo
    • Cargo能够对项目的依赖包进行方便、统一和灵活的管理。项目所有的依赖包都定义在Cargo.toml文件中,开发者可以按需使用来自Rust官方仓库crates.io的各类功能包。
    • Cargo集成了完整的代码管理工具,例如项目创建(cargo new)、构建(cargo build)、清理(cargo clean)、测试(cargo test)、运行(cargo Run)等等。
    • Cargo在代码静态扫描方面提供相应的工具,能够进一步提升开发者编码风格和代码质量。
      • cargo fmt:使用符合rust-lang定义的Rust代码风格来规范Rust代码。
      • cargo check:可以对本地项目库和所有依赖进行编译检查,它会通过对项目进行编译来执行代码检查。
      • cargo clippy:一个Rust语言的lint工具集合包,包含了超过350种lint规则。

StratoVirt的优势

StratoVirt是openEuler最稳定、最坚固的保护层。它重构了openEuler虚拟化底座,具有以下六大技术特点。

  • 强安全性与隔离性
    • 采用内存安全语言Rust编写, 保证语言级安全性;
    • 基于硬件辅助虚拟化实现安全多租户隔离,并通过seccomp进一步约束非必要的系统调用,减小系统攻击面;
  • 轻量低噪
    • 轻量化场景下冷启动时间<50ms,内存底噪<4M;
  • 高速稳定的IO能力
    • 具有精简的设备模型,并提供了稳定高速的IO能力;
  • 资源伸缩
    • 具有ms级别的设备伸缩时延,为轻量化负载提供灵活的资源伸缩能力;
  • 全场景支持
    • 完美支持X86和Arm平台:X86支持VT,鲲鹏支持Kunpeng-V,实现多体系硬件加速;
    • 可完美集成于容器生态,与Kubernetes生态完美对接,在虚拟机、容器和serverless场景有广阔的应用空间;
  • 扩展性
    • 架构设计完备,各个组件可灵活地配置和拆分;
    • 设备模型可扩展,可扩展PCIe等复杂设备规范,实现标准虚拟机演进;

StratoVirt的架构

StratoVirt核心架构自顶向下分为三层:

  • OCI兼容接口:兼容qmp协议,具有完备的OCI兼容能力。
  • BootLoader:抛弃传统的BIOS + GRUB启动模式,实现了更轻更快的BootLoader,并达到极限启动时延。
  • MicroVM:充分利用软硬协同能力;精简化设备模型;低时延资源伸缩能力;

StratoVirt源码目录解析主要分为四部分:

  • address_space:地址空间模拟,实现地址堆叠等复杂地址分配模式。
  • boot_loader:内核引导程序,实现快速加载和启动功能。
  • device_model:仿真各类设备,可扩展,可组合。
  • machine_manager:提供虚拟机管理接口,兼容QMP等常用协议,可扩展。

当前StratoVirt开源代码中实现的是轻量化虚拟机模型,是能实现运行业务负载的最小的设备集合。因此LightMachine是StratoVirt最重要的顶层数据结构,它的逻辑上分为CPU模拟管理,地址空间管理,IO设备模拟管理(包括中断控制器和bus数据结构中管理各类仿真设备,例如virtio设备,serial设备等),如下图右侧所示:

首先,我们先看一下address_space地址空间模拟实现功能:

  • 内存地址空间通过Region组成树形层次关系,支持地址堆叠和优化级。
  • 通过快速映射算法形成扁平地址空间(Flat View)。
  • 通过设置Listener监听地址空间变化,执行相关回调函数。

其次,我们再看一下CPU模拟实现功能:

  • 基于KVM暴露接口实现虚拟CPU的硬件加速。
  • 通过ArchCPU结构隐藏体系架构(aarch64和x86_64)差异,具体实现位于体系架构相关目录中。
  • Arc反向索引该CPU所属的LightMachine虚拟机对象,使得后续在虚拟机内扩展设备时,CPU可访问该对象。

最后,我们再看一下IO设备模拟功能:

轻量化虚拟机的主要设备均通过VirtioMMIO协议实现,下图右侧是VirtioMmioDevice的通用数据结构。

在IO设备初始化阶段,通过VirtioMMIO协议协商前后端都可以访问的virtio queue、中断事件以及通知事件等等。当前端VM有IO请求时,将请求数据写入virtio queue中,通过通知事件告知后端StratoVirt;后端监听通知事件发生时,读取virtio queue中的请求数据,根据请求数据进行IO处理,IO请求处理完成后,并以中断事件方式通知前端VM。

StratoVirt未来

StratoVirt的发展路标为,通过一套架构,支持轻量虚拟机和标准虚拟机两种模式:

  • 轻量虚拟机模式下,单虚机内存底噪小于4MB,启动时间小于50ms,且支持ms级时延的设备极速伸缩能力,当前已经开发完毕,2020年9月已经在openEuler社区开源;
  • 标准虚拟机模式下,可支持完整的机器模型,启动标准内核镜像,可以达成Qemu的能力,同时在代码规模和安全性上有较大优势。

关注我们

StratoVirt当前已经在openEuler社区(openEuler是一个开源、免费的Linux发行版平台,将通过开放的社区形式与全球的开发者共同构建一个开放、多元和架构包容的软件生态体系)开源。在未来的一段时间我们将开展一系列主题的分享,让大家更加详细的了解StratoVirt实现,非常期待您的围观和加入!

项目地址:https://gitee.com/openeuler/stratovirt

项目wiki:https://gitee.com/openeuler/stratovirt/wikis

项目交流:virt邮件列表或是提交一个issue

华为 | Rust 科学计算多维数组运算库的分析与实践

作者: 李原 / 后期编辑: 张汉东

此文来自于 3.27号 深圳 Meetup 大会 3月27日活动PPT和现场视频链接: https://disk.solarfs.io/sd/6e7b909b-133c-49f7-be0f-a51f65559665


介绍

Rust ndarray是一个由Rust官方团队中资深科学计算专家bluss开发的开源项目,实现了基于rust的矩阵和线性运算。目标是在Rust中建立类似于numpy和openblas的科学计算社区。它是机器视觉、数据挖掘、生物信息等多类科学计算库的基础,社区中的主要用户为一些相关技术的高校或者研究所。笔者参与该开源项目的整体规划为面向社区中的各种场景,打通ndarray的南向技术栈,利用编译器、并行化、软硬件协同等技术实现功能、性能的突破,为整个Rust科学计算生态打下扎实的底座。

而ndarray目前来自于社区的需求有嵌入式环境的适配、基础机制的完善以及空间利用率、运算性能上的提升等。为了nostd、灵活步长、广播机制及并行计算等。下面具体展开介绍。

no_std化

首先是ndarray的no_std化工作,它主要解决ndarray在嵌入式环境下的适配问题。std是Rust标准库的简称,由核心库和其他一些功能模块组成。其中核心库包含了类型、指针、同步、内存管理等语言核心功能,其余部分则包含了文件管理、操作系统适配、线程管理、网络等非核心或硬件架构相关功能。Rust编译器在编译和生成最终二进制文件(rlib)时会默认将标准库全部包含进去。而no_std就是指让编译器在编译时不主动引入标准库,而是由编程人员按需引入相关功能模块。这一机制主要是用在嵌入式开发中,除了标准库在无操作系统的裸机环境下可能无法编译的因素外,更是因为在嵌入式环境中,文件存储占用的资源是非常宝贵的,为了降低成本必须要尽可能得节省空间。no_std环境下,每个生成的rlib文件会比std环境下生成的节省200kb左右的空间。而一个项目一般会依赖多个rlib文件,所以可以从总体上节省很多资源。

除了这种方法,还可以通过将整个标准库编译成动态链接库的方法,在有多个rlib存在时使他们链接到同一个动态链接库,也能显著得降低空间占用。这里我们只分享第一种,也就是no_std的方法。

我们要让一个Rust库支持no_std环境主要做的事有两件:

第一件事是解决自身对std的依赖。

主要方法有三条:

  1. 在使用语言核心功能时,使用核心库代替标准库
  2. 当需要使用核心库没有的功能时,手动引入额外功能模块
  3. 使用条件编译进行功能裁剪。

Rust中的条件编译主要由开发者自定义的feature实现,通过在程序的各个部分添加属性,判断不同的feature类型实现条件编译。

第二件事是解决依赖库对std的依赖。主要方法有两个,首先肯定是修改依赖模块,让其也实现no_std化,技术实现上和第一步相同,但面对的问题会呈递归式增加,因为要实现依赖模块的no_std化,就还要实现依赖模块的依赖模块的no_std化,以此类推。所以这里一般是采用第二种方法,也就是使用Cargo的条件引入功能,相信做过Rust开发的人都知道每个项目都有一个Cargo.toml文件,就是通过修改这个配置文件,让Cargo根据不同的feature判断是否引入no_std的替代版本。

用ndarray的no_std化来举例说明。ndarray作为一个开源项目,对no_std的需求,其实也是来源于社区用户。这一需求在去年被RustCV社区(一个专门从事于用rust开发计算机视觉算法的开源社区)的owner提出,他的想法是将ndarray应用于嵌入式环境下的机器人芯片上,从而在机器人上搭载基于ndarray开发的各种CV算法。笔者也参与了相关的issue讨论并承担了这个任务。所做的工作和上面讲的步骤可以一一对应。这里有一个小技巧,就是在项目的lib.rs文件里加入这么一句use core as std就能很方便地在整个项目中用核心库代替标准库,而不用修改所有use std的语句。除了核心库之外,ndarray还大量使用了标准库alloc模块中的Vec、Slice等功能,因此需要在lib.rs中手动引入alloc模块。而对于无法通过单个模块导入的浮点数计算功能,比如求对数、指数函数等,就通过加入属性来实现条件编译,只有在std环境下才编译带有该属性的程序实体。这里程序实体在狭义上就是指各个函数,因为和C的基于宏的条件编译不同,Rust的条件编译是基于属性的,所以无法在函数内部像C一样通过使用宏而选择编译各条语句,而是根据属性的不同判断带有这个属性的程序实体是否要被编译。

对于ndarray的各依赖模块,其中矩阵乘法模块是专门对其进行了no_std化。而其他的库,如BLAS、Serde序列化、rayon多线程,都使用了Cargo的条件引入功能,在no_std环境下要么引入相应的no_std版本,要么使其不可用。

灵活步长

接下来介绍多维数组中步长的使用。这里需要首先介绍一下ndarray中多维数组的内存模型。该内存模型包含数据,数据指针,维度和步长四个部分。它和numpy一个显著的区别就是使用静态维度,也就是1至6维的维度和步长全部由固定长度的数组表示,这是因为Rust语言本身的特性,之后会继续展开。当然ndarray本身也是支持动态可扩展的数组作为维度的。静态维度的数组运算速度比动态维度要快很多,但是缺点是不同维度之间的交互逻辑比较不便和复杂。

而步长顾名思义,就是每一列的相邻索引位置在内存中的距离,它决定了指针遍历数组的顺序。ndarray的重要功能之一,就是可以通过不同的步长表示,表达出物理结构相同,而逻辑结构不同的数组,这样做最大的好处,就是可以节省新建数组的时间和空间开销,在数据量大的应用场景,比如各种大数据应用、生物信息研究中,这样的好处无疑是巨大的。 而在某些场景下,步长的不同也会显著地影响算法和程序运行的效率。

最经典的例子便是C风格数组和Fortune风格数组的区别。C风格数组的最后一列上的元素在内存上是相邻的,第一列上的元素是内存上相隔最远。而Fortune刚好相反,第一列上的元素在内存上相邻,最后一列最远。这两种不同风格的数组排布,在不同的运算场景下,效率会有巨大的差异,因为数组遍历时的空间连续性,对访问的速度会有显著的影响,如果运算逻辑是以第一列为优先,那一定是Fortune排布更快,反之则是C风格更快。另一个角度来理解,在内存排布相同的情况下,C风格的数组和Fortran风格的数组在逻辑结构上互为转置。

在上述基础上,负步长的定义和作用便油然而生。即当我们想以相反的顺序访问数组的某一列时,只需要将该列的步长调整为原来的负值即可,而不用重新申请内存空间存放顺序相反的数据。例如我们想求一张图片的翻转,因为图片数据一般是长、宽、RGB三个维度组成的数组,所以只需将水平轴的步长改为原来的相反数,便可以得到翻转后的图片数据而不用复制一张同样大小的图片。而非连续步长也可以理解为一种方便的切片表示方法,他通过让指针在内存中跳跃而非依次遍历的方式得到原来数组的切片。这在神经网络训练的提取特征点场景中使用相当广泛。而更为特殊的还有零步长的形式,相当于是将某一切片复制了许多份。这是之后要讲的广播特性的实现基础。

而要实现这些步长使用方法,主要要解决下面三个问题:即步长的合法性、连续性判断以及寻址算法。合法性保证了在自定义步长时的程序是安全的,其中最重要的就是保证指针在依据步长在内存中移动时,不能在不同的坐标指向相同的内存,否则会造成读写错误。连续性是指该步长表示下的数组在内存排布上是否是连续的。如果是连续的,那么在复制和遍历时就能当做一整块内存来处理,效率会快很多很多。另外还有寻址算法,这一部分也很重要,因为如果寻址错误会导致访问到数组数据之外的内存数据,造成程序漏洞。具体的计算分为计算指针位置和计算寻址时的偏移量两部分,在此不再展开。

广播特性

在多维数组运算中,广播是一个极其重要的概念,它定义了不同维度的数组之间的交互逻辑。举个最简单的例子,一个二维数组和一维数组相乘时,将一维数组重复多次,就扩展成了一个二维数组,再将两个二维数组对应元素相乘,就得到了想要的结果。这样的运算是很常见的,比如地理上计算多个地标到原点的距离、数据挖掘聚类时的离散度计算等。而当多个不同维度的数组进行运算时,广播机制会按照类似的规则将每个数组扩展到同样的维度长度,然后再进行运算,在机器学习中常常会计算两个一维输入之间的协方差矩阵,那么就需要用到这样的机制。

ndarray社区早在六年前就提出要实现广播机制,但直到21年都没有人解决它。其实这并不是因为广播不重要,而是由于Rust语言本身的语法限制问题。

具体来说,当两个数组进行广播时,是无法确定返回值的类型的——之前说过ndarray采用的都是静态维度,也就是长度固定的数组,比如一维就是[usize;1],二维就是[usize;2]。对Rust来说,[usize;1]和[usize;2]是不同的类型,这是出于对内存安全的考虑。另外还有不固定长度的usize数组。而不固定长度的usize数组不能作为返回值,因为它的空间大小是运行时确定而非编译时确定,而Rust要求函数的参数、返回值大小都必须是编译时确定的,这是为了保证函数调用时程序堆栈的大小确定。所以n维数组作为返回值时它的维度也必须是确定的——要么是1,要么是2、3。。。或者聪明一点,和第一个输入值的维度相同,或者和第二个输入值的维度相同。这在广播里是不够的,因为它要返回的是两个输入维度之间的较长者,这个逻辑听起来很简单,但对编译器来说根本做不到,因为其并没有在函数声明中进行推断的功能。C语言是没有这个问题的,因为C中根本就没有静态维度的概念,不管是多长的数组,都只是一个地址的引用而已。其他的动态语言类似Java,Python也没有这个问题,因为它们的所有对象几乎都是引用类型,这也导致了它们在每次访问对象时都会进行一次解引用,效率当然就没有C和Rust那么快。那Rust能不能想C一样返回地址的引用?也不可以,这是因为Rust作为一门内存安全语言有所有权的限制,在广播这个函数内创建的n维数组,是不能返回它的地址的,因为它的所有权在函数结束时就消亡了。那返回所有权呢?更不行,因为刚才说了,它的大小无法在编译时确定。

所以这个问题有没有解决方法呢?之前说过ndarray也是支持动态数组作为维度的,动态数组是指如vec,box等类型,它们使用智能指针使得虽然他们持有的内存空间动态变化,但是本身大小是固定的,所以能作为返回类型。笔者也在社区中提出过在广播中使用动态数组作为返回值的维度,但马上就被owner否决掉了,因为Rust里的动态数组运行效率太过缓慢。

但是万事都有解决的途径, 如果我们让编译器不用自己执行这个判断两个输入数组之中哪个维度更大的过程,而是直接告诉它应该返回什么大小维度的数组,那么广播就可以实现。具体来说,维度有零维到六维加上动态维度8种类型,如果我们写一个宏,为它们之间8*8=64种交互的情况都分别实现一个广播函数,那么在每一种情况中返回维度大小都是能够确定的。这种方法理论上是可行的,但实现起来却会遇到更大的问题。试想一下,如果一个函数需要使用两个数组的广播,此时数组维度是不确定的,那么它该调用哪个广播函数?难道再将这个函数为64种情况全部都实现一遍吗?显然不现实。

但是这个问题可以通过Rust语法中特有的聚合类型来解决。我们把数组间广播的实现放在一个自定义的trait里,这个trait用一个泛型参数来代表将要与trait实体进行广播的数组,并且在内部用一个聚合类型Output表示进行广播后输出的数组。然后使用宏为所有类型的数组都实现该trait。这样,我们在进行任意维度的数组间的广播时,只需要在where语句中添加一个限定条件,即数组实现了该trait,就能使用广播特性。实际上对Rust编译器有深入了解的人应该知道,编译器在单态化过程中会将所有泛型展开成具体的类型,每一次展开都会生成单独的一份代码。所以这种方法和上面那种方法其实在本质上是一样的。不过目前Rust编译器团队正在尝试多态化的实现,可以在编译过程中为类似的函数只生成一份代码,有兴趣的话可以自行研究。

但是这样的方法会限定广播间的数组必须含有同样的数据类型,这样的限制有点严格,而且函数声明看起来也过于冗长。所以在此思路上,我们再进行简化,只为数组的维度实现该trait,并且将该trait命名为DimMax,顾名思义就是两个维度之间的较大者。

那么,还能不能再简化,把where语句中额外的限定条件也去掉?即为两个任意长度的维度D1和D2实现该trait。这听起来很美好,但是又会再次受到Rust语法的限制——聚合类型也必须在定义时就确定——要么是手动确定,要么来自于输入值中的其他聚合类型。但我个人觉得这个问题可以解决——能不能修改编译器的特征实现机制,让其变得更聪明一点,比如在进行trait实现的编译过程中,允许进行静态常量的计算。因为静态维度的长度肯定是一个常量。所以在编译时对该常量进行计算,比如获取两个常量之间的最大值,然后获得一个确定的聚合类型,应该也是可行的。当然这只是我个人目前的猜想,能不能实现还需要对Rust编译器进行更深入的研究。不过目前还是可以在某些常见的情况下省去该限制,即相同维度间进行广播以及和与零维进行广播,在这两种情况下广播结果的维度都是本身,所以可以直接添加到对维度的定义中,在这两种特定情况下就能避免添加where语句的限制。

并行计算加速

最后,我想分享一下ndarray在并行计算方面的现状及发展。ndarray目前使用rayon库在部分场景下实现了多线程并行加速。rayon是一个基于迭代器实现的多线程并行库。它的核心思想是将一个迭代器拆成数个不同方向的子迭代器,同时将迭代的任务分配到各个子迭代器上,再用work-stealing算法分配到多个线程实现并行化。它要求迭代器必须满足以下三点:1.可以按从前向后和从后向前两个方向进行迭代2.可以随时求出剩余元素的个数3.可以从中间索引分割成两个互斥的,和父迭代器相同性质的子迭代器,如图所示。

ndarray在数组的单个元素遍历、按某一特定维度遍历、以及多数组运算对应元素操作时按元素遍历这几种场景下实现了多线程加速。其后又增加了Lanes迭代器并行。Lanes直译过来是泳道,如果我们将一个N维的数组去掉某一维,看成一个N-1维的数组,那这个数组的每一个元素就变成了一个一维向量,这个向量就叫做泳道。它的主要应用场景是在二维以上的矩阵乘法中,此时结果矩阵中的每一个元素都是两条泳道的向量积,这种计算在文本分类、自然语言处理等深度学习场景中也是很常见的。

除了多线程,还有另一个重要的并行加速方法,就是simd(Single instruction, multiple data)单指令多数据加速。即在一条机器指令的执行期间执行多个数据的计算操作。举个简单的例子,arm架构下的vaddq_s32指令,就可以在一条指令执行时间内,计算两个128位向量的和,每个向量各包含4个32位整数,因此相比于普通的循环加法要快了4倍。不同的硬件架构都有相适配的simd指令集,比如x86架构的avx、avx512、SSE指令集,arm的neon、asimd指令集等等。想要通过simd指令给ndarray中的n维数组运算加速,需要三个步骤:1.让Rust标准库支持各种架构simd指令。这个正是官方的simd工作组在做的事情。这是一项工作量很大的事,光是支持arm架构代码量就在10w行以上,x86更是接近20w行,还不包括一些要对编译器的修改。2.基于各种指令实现通用运算的simd加速。比如诞生于OpenCV的universal intrinsic,它提供了诸如向量点乘、矩阵乘法、距离计算、排序等多种通用计算接口及它们的实现,可以通过这些接口将计算转换成simd向量的计算以实现算法的加速。但universal intrinsic对一般开发者而言并不好用,因为它不能自动生成适配cpu向量寄存器长度的simd指令,需要用户手动来选择,因此可能造成simd性能利用不全或者因指令不适配而导致程序崩溃。这就引出了第3个步骤,也就是cpu的simd配置的自动检测和simd指令的自动适配,让所有simd指令对用户透明化。最后,还有潜在的第4个步骤,也就是编译器的自动向量化,使所有的运算都能通过编译器自动生成simd指令。这是一个极为艰深的方向,目前有很多LLVM的成员在研究这方面的实现,但也是困难重重,读者感兴趣的话可以尝试研究。

这里再介绍一下Rust标准库中的stdarch仓库,这个库作为标准库的一部分为所有Rust开发者提供了各种常见硬件架构的simd指令集,由官方的simd工作组和库团队成员负责开发和维护,但目前除了x86平台的各种特性在去年年末刚刚稳定(stable)之外,其他架构都还处于unstable状态,因此整个simd特性还不能在稳定的Rust版本中使用,也需要各方开发者前来贡献。stdarch和Rust编译器、LLVM都有密切的联系。stdarch负责对不同架构、不同版本的指令集进行模块分类、封装和测试,并提供给用户相应的函数接口。底层的汇编实现和汇编优化是在Rust编译器和LLVM中,因此stdarch需要对编译器和LLVM的实现进行封装。这里分为两种情况,一种是各架构通用的simd接口,例如加减乘除、位运算等,这些指令会在编译器的代码生成部分静态调用LLVM的相关接口进行实现,再由stdarch使用extern “platform-intrinsics”关键字进行引入和封装。另一种是各架构提供的专用指令,例如x86的vcomi指令、arm的vsli指令等,一般是针对特定的计算场景提供,比如vsli代表向左位移再插入相应元素。这种情况下需要通过静态链接的形式调用llvm中的相关实现并进行封装。而stdarch中提供的接口依然是区分架构和向量寄存器长度的,而我提到的simd透明化或者说usimd,就是在此基础上向用户屏蔽掉硬件差异,以提供更通用的计算接口。

此文主要由一些n维数组运算库ndarray的具体问题及技术解决展开,引出一些对Rust语言、以及科学计算领域技术的延伸和思考。希望对大家的Rust学习和开发有所帮助。

华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型

作者: 王辉 / 后期编辑: 张汉东

此文来自于 3.27号 深圳 Meetup 大会 3月27日活动PPT和现场视频链接: https://disk.solarfs.io/sd/6e7b909b-133c-49f7-be0f-a51f65559665


基于TVM Rust Runtime和WASM沙箱运行AI模型

说明

本文介绍了一种WASM与TVM在AI领域的结合方案:依托TVM端到端深度学习编译全栈的能力,将AI框架训练好的模型编译成WASM字节码,然后在运行时环境中通过Wasmtime进行图加载进而实现模型的无缝迁移部署。

图解TVM和WASM技术

TVM与Rust运行时

TVM Framework

作为Apache基金会的顶级开源项目,TVM是用于深度学习领域的一个全栈编译器,旨在高效地在任何硬件平台进行模型的编译优化和部署工作。通过统一的中间表示层(包括Relay和Tensor IR两层),TVM可将AI框架训练的模型编译成与后端硬件架构无关的计算图表达,然后基于统一运行时实现不同环境下的计算图加载和执行操作。

为实现上述的图加载执行操作,TVM制定了一套抽象的运行时接口,并根据不同的运行时环境提供多种编程语言的接口实现(包括C++、Python、Rust、Go及Javascript等),本文主要介绍TVM Rust运行时的接口定义。TVM Rust运行时接口主要包含tvm_rttvm_graph_rt两个crate,前者完全实现了TVM runtime API的Rust接口,而后者则具体实现了TVM graph运行时的Rust版本;本文着重针对tvm_graph_rt的接口实现展开介绍。

  • 结构体定义

    | 结构体名称 | 功能介绍 | | :--------- | :------- | | DLTensor | Plain C Tensor object, does not manage memory. | | DsoModule | A module backed by a Dynamic Shared Object (dylib). | | Graph | A TVM computation graph. | | GraphExecutor | An executor for a TVM computation graph. | | SystemLibModule | A module backed by a static system library. | | Tensor | A n-dimensional array type which can be converted to/from tvm::DLTensor and ndarray::Array. Tensor is primarily a holder of data which can be operated on via TVM (via DLTensor) or converted to ndarray::Array for non-TVM processing. |

  • 枚举定义

    | 枚举名称 | 功能介绍 | | :------- | :------- | | ArgValue | A borrowed TVMPODValue. Can be constructed using into() but the preferred way to obtain a ArgValue is automatically via call_packed!. | | RetValue | An owned TVMPODValue. Can be converted from a variety of primitive and object types. Can be downcasted using try_from if it contains the desired type. | | Storage | A Storage is a container which holds Tensor data. |

  • 常量定义

  • trait定义

WASM与WASI

WASM Introduction

WebAssembly技术(WASM)是一个基于二进制操作指令的栈式结构的虚拟机,其可以被编译为机器码,进而更快、高效地执行本地方法和硬件资源;当然凭借WASM强大的安全和可移植特性,其不仅可以嵌入浏览器增强Web应用,也可以应用于服务器、IoT等场景。

由于浏览器领域天然具备屏蔽后端硬件平台的特点,WASM技术本身不需要考虑浏览器后端的运行时环境;但是面向非Web领域必须要针对不同的操作系统进行适配和兼容(文件读写、时钟同步、中断触发等),针对这种情况WASM社区提出了一套全新的WASI标准(WASM System Interface)。正如WASM是面向逻辑机层面的汇编语言一样,WASI是一套面向逻辑操作系统的标准接口,目的是为了实现WASM平台在不同操作系统间的无缝迁移运行。针对WASI标准的详细解读,请查阅此博文

方案介绍

前期调研

当前业界针对WASM技术在AI领域已经有了比较多的探索:TF.js社区基于WASM编译传统手写算子提升执行速度;TVM社区基于WASM编译模型用于浏览器领域的模型推理;还有利用WASM可移植性解决算子库与硬件设备不兼容的问题(详见XNNPACK)等等。

方案设计

之前我们团队分享了WASM与AI领域结合的初步思路(详见此处),正如TF.js和TVM社区开展的探索工作,我们发现WASM具有的可移植性天然地解决了AI模型在全场景落地的问题:针对传统深度学习框架定义的模型,用户在不同硬件环境上进行模型训练/推理时必须要进行额外的定制化开发工作,甚至需要单独开发一套推理引擎系统。

那么如何利用WASM的可移植性实现硬件环境的统一呢?以MindSpore深度学习框架为例,如果我们把MindSpore模型分别从宏观和微观的角度来分析,宏观来看它就是一张基于MindSpore IR定义的计算图,微观来看它是一系列MindSpore算子的集合。那么我们就可以尝试分别从计算图和算子的维度将WASM与深度学习框架进行结合,也就是提出WASM计算图WASM算子库这两种概念。

  • WASM计算图

    WASM计算图,顾名思义就是将训练好的模型(包括模型参数)编译成WASM字节码,然后在Runtime环境中通过WASM Runtime加载便可直接进行模型推理,借助WASM的可移植性可以实现任何环境下的模型推理工作:

    • Web领域通过Emscripten工具将WASM字节码加载到JS Runtime中进而在浏览器中执行;
    • 非Web领域通过Wasmtime工具加载WASM字节码到系统环境中执行。 对于WASM计算图这种情况,由于训练好的模型(和参数)都是提前保存在系统环境中,因此需要引入WASI接口与系统资源进行交互,进而完成离线加载模型的操作。所以在选择WASM Runtime的时候需要选择支持WASI(WASM System Interface)标准的工具(例如Wasmtime),或者也可以像TVM社区那样简单粗暴地直接对Emscripten进行WASI扩展。
  • WASM算子库

    WASM算子库相对来说比较好理解,就是把单个算子编译成WASM字节码,然后对上层框架提供一种封装好的算子调用接口。但是和传统手写算子的调用方式不同,框架需要通过一种类似于动态链接的方式来加载WASM算子,但考虑到当前WASM本身不支持动态链接的方式,因此需要提前将所有编译好的WASM算子进行整合,然后对框架层提供算子库的调用接口。

通过对上述两种思路进行分析比较,同时在借鉴了TVM社区已有工作的情况下,我们决定首先从WASM计算图这条路开始进行深入探索,最大程度地利用TVM全栈编译的能力快速实现方案的原型。

方案实现

TVM Graph Solution
  • WASM图编译

    如上图所示,我们可以利用TVM Relay的Python接口直接把模型编译成graph.o的可执行文件,但是需要注意的是生成的graph.o文件无法直接被WASM runtime模块识别,必须首先要通过TVM的Rust runtime加载然后通过Rust编译器把图中所示的WASM Graph Builder模块直接编译成WASM字节码(即图中的wasm_graph.wasm文件)。为什么非得要经过这一步繁琐的转换呢?主要是因为graph.o文件中包含了Relay和TVM IR的原语,我们无法直接将这些原语转换成WASM的原语,具体转换的步骤这里就不做赘述了。

  • WASM图加载

    图加载阶段(由上图看来)似乎是非常简单的,但是实际情况要复杂地多。首先,WASM的运行时针对WASM IR定义了一整套汇编层面的用户接口,这对于上层应用开发者来说是极度不友好的;其次,WASM当前只支持整数类型(例如i32、u64等)作为函数参数,这就导致深度学习领域的张量类型无法通过原生方式传入;更别说还要增加thread、SIMD128这些高级特性的支持等等。

    当然每个新领域的探索都离不开各种各样的问题,而且解决问题本身就是技术/科研人员的本职工作,所以我们没有寄希望于WASM社区而是主动尝试解决这些问题:既然WASM没有面向上层用户的高级API,我们就根据自己的需求开发一套;虽然WASM不支持传入Struct或Pointer,我们可以通过Memory机制将数据提前写入到WASM内存中然后将内存地址转成i32类型作为函数参数。虽然有些改动有点“反人类”,但是它可以清晰地展示出我们的思路和想法,这就已经足够了。

由于篇幅有限,此处附上项目实现的完整代码,欢迎感兴趣的大佬进行交流讨论。

如下展示的是项目整体的codebase:

wasm-standalone/
├── README.md
├── wasm-graph      // WASM图生成模块
│   ├── build.rs    // build脚本
│   ├── Cargo.toml  // 项目依赖包
│   ├── lib         // 通过TVM Relay API编译生成的计算图的存放目录
│   │   ├── graph.json
│   │   ├── graph.o
│   │   ├── graph.params
│   │   └── libgraph_wasm32.a
│   ├── src         // WASM图生成模块源代码
│   │   ├── lib.rs
│   │   ├── types.rs
│   │   └── utils.rs
│   └── tools       // Relay Python API编译脚本的存放目录
│       ├── build_graph_lib.py
└── wasm-runtime    // WASM图生成模块
    ├── Cargo.toml
    ├── src         // WASM图生成模块源代码
    │   ├── graph.rs
    │   ├── lib.rs
    │   └── types.rs
    └── tests      // WASM图生成模块测试用例
        └── test_graph_resnet50

为了让大家对该方案有一个更形象具体的理解,我们准备了一个简单的原型:通过TVM Relay API将基于ONNX生成的ResNet50模型编译成wasm_graph_resnet50.wasm文件,然后在运行时环境中通过Wasmtime加载WASM完成模型推理功能(具体操作流程详见此处)。

未来规划

TVM社区联动

正如前面所说的,该方案仍处于试验阶段,因此我们会和TVM社区一起共同探索更多可能性,目前初步规划的特性有:

  • 支持基于SIMD128的数据并行处理;
  • 进一步完善TVM社区的Rust runtime API模块,使其能原生支持WASM Memory特性的对接;
  • 基于WASM后端的AutoTVM优化;
  • 更多网络支持。

WASM算子库

当前我们只是针对WASM计算图这个方向进行了深入探索,但如果要是将WASM技术与深度学习框架(比如MindSpore)相结合的话,WASM算子库的方向可能会释放更大的潜能。这里首先列举几个更适合WASM算子库的场景:

  • 很多深度学习框架本身已经定义了自己的IR以及编译流水线,只有WASM算子库可以无缝地与这些框架的图编译层相融合;
  • WASM计算图只能用于模型推理,而WASM算子库可以适用于模型训练/验证/推理等场景;
  • 在可移植性这个层面,WASM计算图无法提供其内部算子的一致性保证,而WASM算子库真正实现了端边云全场景中算子的可移植性。
TVM Kernel Solution

如上图所示,我们计划从WASM算子库这个层面梳理出一套端到到的集成方案(优先覆盖上述几个场景),真正实现WASM技术在AI领域全场景的结合。

加入我们

为了更好地推动Rust编程语言生态在AI领域的落地,我们发起了一个叫Rusted AI的非商业性组织,任何对Rust和AI技术感兴趣的开发者均可申请加入,社区当前提供如下几种交流渠道:

  • Rusted AI微信群:欢迎添加小助手的微信(微信号:mindspore0328,备注:Rusted AI),认证通过后小助手会将您拉进Rusted AI讨论群
  • GitHub Teams:社区当前依托GitHub Teams提供公开讨论的渠道,由于GitHub Teams仅对组织成员开放,请以邮件形式发送个人GitHub IDwanghui71leon@gmail.com,认证通过后即可参与社区话题讨论
  • 生态众筹项目:近期社区发布了awesome-rusted-ai众筹项目,用于记录所有与Rust语言和AI领域联动相关的开源项目

蚂蚁集团 CeresDB 团队 | Rust CPU Affinity 初探

作者:Ruihang Xia / 后期编辑:张汉东

Brief

在看 Apache Cassandra 的时候了解到 ScyllaDB 能在完全兼容它的情况下性能提升很多,通过进一步了解接触到了 thread per core 这种架构,这篇文章从一个简单的 cache 结构出发,实现了三个不同的方案,并对它们进行比较,最后给出了在这个过程中学习到的一些东西。

Thread Per Core 简单来说就是将应用的每一个线程绑定到一个计算核心上,通过 sharding 的方式将计算拆解分配到对应的核上。这是一种 shared nothing 的方式,每个核单独持有计算所需要的数据,独立完成计算任务,从而避免掉多余的线程同步开销。同时每个核心和工作线程一一对应,减少上下文切换的开销。

waynexia/shard-affinity 中,我分别用普通的不做限制调度、local set 给计算任务分组以及 绑定任务、核心与线程三种方式实现同一个目的的 cache 结构。这三种实现分别对应 shard-affinity/load/src 目录下的 threading-rs, local_set-rsaffinity-rs 三个文件。接下来将对这三种方法实现方法进行分析。下文提到的原始代码都在这个仓库里面,为了简洁进行了部分省略。

Cache

假设我们有一个类似 Map<Id, Data> 的结构,它缓存了我们所需要的数据,请求分为对它进行 append() 或者 get(),通过读写锁进行线程同步并提供内部可变性,对外暴露 &self 的接口。


#![allow(unused)]
fn main() {
pub struct CacheCell {
    items: RwLock<Map<Id, RwLock<Item>>>,
}

impl CacheCell {
    pub fn get(&self, id: Id, size: usize) -> Option<Bytes>{}
    pub fn append(&self, id: usize, bytes: Bytes) {}
}
}

首先为了能让多个任务在同时操作 cache 的时候仍能得到符合预期的结果,我们可以使用 lock-free 的结构,或者对它加上一把锁将并发的操作串行化。而我们发现对不同的 id 进行的操作并不会互相影响。所以可以将线程同步所影响的结构粒度变小,以这个 cache 所参考的 gorilla in-memory data structure 为例,将 id 分为进行分组,由对应的 cell 进行管理。将锁的粒度变小,以支持更高的并发操作。

图一,from Gorilla paper Fig.7: Gorilla in-memory data structure.

选这个结构作为实例有两个原因,首先这是一个实际生产系统所使用的结构,比较有实际意义;并且它比较简单易于实现,而且本身就已经对 id 进行了 sharding,方便进行后续的使用。

Threading

先来看比较常见的做法,拿若干个 cache 放一起合成一个 Vec<Cache>>,根据每次请求的 id 路由到对应的 cache 进行操作。


#![allow(unused)]
fn main() {
impl ThreadingLoad{
    pub fn append(&self, id: Id, bytes: Bytes) {
        self.shards[route_id(id)].append(id, bytes);
    }
}
}

而在使用的时候,则是开一个多线程的 tokio runtime,向里面 spawn 不同 id 的请求。


#![allow(unused)]
fn main() {
let rt = Builder::new_multi_thread().build();
let load = ThreadingLoad::new();

rt.spawn(async move {
    let id = random::<usize>();
    load.append(id, bytes);
})
}

在这之后,由 tokio 去调度任务执行,完成之后给我们结果,我们不用关心这个任务具体是怎样被调度的,产生的计算发生在哪个核上。而且我们底下的所有结构都付出了代价让它们 SendSync,也不用去担心一个对象同时被多个东西操作会出现奇怪的结果。

LocalSet

这里是使用 tokio 的 LocalSet 来实现的。它能将指定的任务绑在同一个线程上进行执行。这样子带来的好处就是我们可以使用 !Send 的东西了。

具体来说,由上面我们知道不同的 id 之间的操作不会互相影响,所以能够将锁粒度变小。同样的,不同 id 的任务计算所需要用到的数据也不会重叠,也就是避免了一份数据可能被多个内核同时访问的场景,从而不需要考虑我们的修改对其他内核的可见性。基于这一点,之前付出的性能代价来给数据实现 SendSync 也可以被节省下来。比如引用计数可以从 Arc 变成 Rc,或者说所有为了保证可见性所加的指令屏障都可以去掉。

从实现来看,在我的这台有十六个逻辑核心的设备上,将所有的 shards 分给15个线程进行管理,另外一个来进行任务的分发,任务分发线程与其余每个线程之间都有一个 channel 来进行任务的传输。这里分发的任务有两种:


#![allow(unused)]
fn main() {
enum Task {
    Append(Id, Bytes, oneshot::Sender<()>),
    Get(Id, usize, oneshot::Sender<()>),
}
}

每个里面包含对应任务所需要的参数,以及一个用于通知任务完成的 channel。每次请求到来时,任务分发线程组装好所需要的参数,根据 id 发送给对应的执行线程,之后等待执行结果。


#![allow(unused)]
fn main() {
pub async fn append(&self, id: Id, bytes: Bytes) {
    let (tx, rx) = oneshot::channel();
    let task = Task::Append(id, bytes, tx);
    self.txs[route_id(id)].send(task).unwrap();

    rx.await.unwrap()
}
}

Affinity

在上面的实现中,我们只是将一组任务所需要的数据和计算绑在了一起,避免线程同步的开销。在运行中核心之间负载不均衡的时候,能够观察到明显的操作系统调度的行为。这样子只减少了开始提到的两种开销中的一种,上下文切换的开销仍然还在。操作系统的调度很多时候并不能明白应用的行为,所以在第三种方法中我们将每个线程与核绑定起来,或者是说告诉操作系统要去如何调度我们的线程。

线程的分配和上面 LocalSet 方案一样,将 shards 分配到除了一个分发线程之外的其余线程中,并每个线程绑一个核。通过 core_affinity crate 来设置 cpu affinity


#![allow(unused)]
fn main() {
let core_ids = core_affinity::get_core_ids().unwrap();
core_affinity::set_for_current(_);
}

#![allow(unused)]
fn main() {
for core_id in core_ids {
    thread::spawn(move || {
        core_affinity::set_for_current(core_id);
    });
}
}

除了设置了 cpu affinity 之外,还有其他地方与上一种方案不同。首先这里在 channels 中分发的是已经构造好的 future,而不是分发参数之后再构造;其次这里的 runtime 是一个简单的 FIFO 队列;最后每个线程的 caches 通过 thread local storage 的方式存储。


#![allow(unused)]
fn main() {
self.runtime.spawn(route_id(id), async move {
    thread_local! (static SHARD:AffinityShard = AffinityShard::new() );

    SHARD.with(|shard| {
        shard.append(id, bytes);
    });

    tx.send(()).unwrap();
});
}

这些区别只是单纯展现实现差异,并且由于 cache 内部的内存还是采用的默认分配器从堆上分配,这里的 TLS 实际上也没有起到什么作用,后文会继续提到这个点。

在这种情况下,每个计算线程可以在一定程度上简化成一个单线程模型进行考虑,整个系统也变成了非抢占式、协作的调度,利用 rust 的 coroutine 由任务自己在需要等待资源的时候通过 await yield 出来。除了之前提到的那些方面之外相信还有许多其他可以开发的空间。

以及这种 affinity 的方案也是一个能很好的在应用侧进行 NUMA 实践的场景,结合前面提到的 TLS,另一种方法就是使用一个感知 NUMA 的内存分配器。不过我的设备并不支持 NUMA,所以没有进行进一步的测试。

Test

shard_affinity/src 下有三个 binary 代码文件,分别是对三种情况进行的一个简单的测试。工作负载的参数可以在 shard_affinity/src/lib.rs 下看到。在我的环境下,三个方案以 128 并发分别进行 1024 次写以及 4096 次读 16KB 的数据耗时如下。为了让数据集中,将 id 的范围设置到了 0 至 1023.

图二,本地进行测试结果。纵坐标为延时(毫秒),越低越好。

可以看到,local set 和 affinity 两种方案的表现并不如 threading 的好。初步分析时在 local set 和 affinity 两种方案下都是由一个线程做入口进行任务生成和分发,即多出了额外的任务路由开销,在测试的时候能看到 cpu 的负载也是一高多底,而且由于模拟的任务单个执行时间都比较短,路由线程也会更先到达瓶颈。

在将工作线程数都调整为 8 (逻辑核心数量的一半)之后,可以看到 threading 和 affinity 的差别有所减小。对于目前仍然存在的 gap,通过 flamegraph 分析可能是 affinity 需要对每个任务收发请求和结果带来的.

图三,调整 worker 数量之后的结果。纵坐标为延时(毫秒),越低越好。

由于所有的内存数据,即状态都被预先分散到各个核上,因此对 sharding 的方案也有要求。当 affinity 由于热点等原因出现负载不均衡时,进行 re-balance 一般会是一个比较耗时的操作,灵活性这方面不如 threading 模式。此外计算的分布方法也很重要,比如目前由一个线程向其他线程分发的方式就在测试中出现了问题。考虑到实际的系统计算负载的组成更加复杂,如何很好的分散计算任务也是需要慎重决定的。

Others

在 affinity 的实现中,为了展示大部分组件都是手造的简单模型。而 thread per core 其实已经有许多优秀的框架能够简化在这种架构下开发的难度,比如开头提到的 scylladb 所使用的框架 seastar,这篇文章的写作过程中也参考了它们的很多文档。rust 也有类似的框架 glommio,这是一个比较新的库,前不久刚放出第一个比较正式的 release。

在 thread per core 架构下,除了应用的逻辑需要发生变化,许多常用的组件也都要产生改动,为了一般多线程场景设计的那些向线程同步付出了代价的结构如使用了 Arc 的地方是不是可以换成 Rc 等,这些都是需要考虑的。也希望能围绕这个发展出很好的生态。

Conclusion

在简单的对比过不同方法的实现和性能之后,从我的观点来看 thread per core 是一个非常值得尝试的方法,它能够在某种程度上简化开发时所考虑的场景,也很适合目前动辄几十上百核的服务器,而且也有 scylladb 这种成熟的实践。不过这个对于已经基本成型的系统来说所需要作的改动比较大。我们期望 thread per core 带来的提升是通过减小同步开销以及提高的缓存命中率实现更低的延时以及更平稳的性能,而且这些改动所能带来的提升与增加的复杂度,工作量和风险性相比则需要进行权衡。

关于我们

我们是蚂蚁智能监控技术中台的时序存储团队,我们正在使用 Rust 构建高性能、低成本并具备实时分析能力的新一代时序数据库,欢迎加入或者推荐,目前我们也正在寻找优秀的实习生,也欢迎广大应届同学来我们团队实习,请联系:jiachun.fjc@antgroup.com

DatenLord | 用 Rust实现 RDMA

作者:王璞 / 后期编辑:张汉东


RDMA是常用于高性能计算(HPC)领域的高速网络,在存储网络等专用场景也有广泛的用途。RDMA最大的特点是通过软硬件配合,在网络传输数据的时候,完全不需要CPU/内核参与,从而实现高性能的传输网络。最早RDMA要求使用InfiniBand (IB)网络,采用专门的IB网卡和IB交换机。现在RDMA也可以采用以太网交换机,但是还需要专用的IB网卡。虽然也有基于以太网卡用软件实现RDMA的方案,但是这种方案没有性能优势。

RDMA在实际使用的时候,需要采用特定的接口来编程,而且由于RDMA在传输数据的过程中,CPU/内核不参与,因此很多底层的工作需要在RDMA编程的时候自行实现。比如RDMA传输时涉及的各种内存管理工作,都要开发者调用RDMA的接口来完成,甚至自行实现,而不像在socket编程的时候,有内核帮忙做各种缓存等等。也正是由于RDMA编程的复杂度很高,再加上先前RDMA硬件价格高昂,使得RDMA不像TCP/IP得到广泛使用。

本文主要介绍我们用Rust对RDMA的C接口封装时碰到的各种问题,并探讨下如何用Rust对RDMA实现safe封装。下面首先简单介绍RDMA的基本编程方式,然后介绍下采用Rust对RDMA的C接口封装时碰到的各种技术问题,最后介绍下后续工作。我们用Rust实现的RDMA封装已经开源,包括rdma-sysasync-rdma,前者是对RDMA接口的unsafe封装,后者是safe封装(尚未完成)。

RDMA编程理念

先首先简要介绍下RDMA编程,因为本文重点不是如何用RDMA编程,所以主要介绍下RDMA的编程理念。RDMA的全称是Remote Direct Memory Access,从字面意思可以看出,RDMA要实现直接访问远程内存,RDMA的很多操作就是关于如何在本地节点和远程节点之间实现内存访问。

RDMA的数据操作分为“单边”和“双边”,双边为send/receive,单边是read/write,本质都是在本地和远程节点之间共享内存。对于双边来说,需要双方节点的CPU共同参与,而单边则仅仅需要一方CPU参与即可,对于另一方的CPU是完全透明的,不会触发中断。根据上述解释,大家可以看出“单边”传输才是被用来传输大量数据的主要方法。但是“单边”传输也面临这下列挑战:

  1. 由于RDMA在数据传输过程中不需要内核参与,所以内核也无法帮助RDMA缓存数据,因此RDMA要求在写入数据的时候,数据的大小不能超过接收方准备好的共享内存大小,否则出错。所以发送方和接收方在写数据前必须约定好每次写数据的大小。

  2. 此外,由于RDMA在数据传输过程中不需要内核参与,因此有可能内核会把本地节点要通过RDMA共享给远程节点的内存给交换出去,所以RDMA必须要跟内核申请把共享的内存空间常驻内存,这样保证远程节点通过RDMA安全访问本地节点的共享内存。

  3. 再者,虽然RDMA需要把本地节点跟远程节点共享的内存空间注册到内核,以防内核把共享内存空间交换出去,但是内核并不保证该共享内存的访问安全。即本地节点的程序在更新共享内存数据时,有可能远程节点正在访问该共享内存,导致远程节点读到不一致的数据;反之亦然,远程节点在写入共享内存时,有可能本地节点的程序也正在读写该共享内存,导致数据冲突或不一致。使用RDMA编程的开发者必须自行保证共享内存的数据一致性,这也是RDMA编程最复杂的关键点。

总之,RDMA在数据传输过程中绕开了内核,极大提升性能的同时,也带来很多复杂度,特别是关于内存管理的问题,都需要开发者自行解决。

RDMA的unsafe封装

RDMA的编程接口主要是C实现的rdma-core,最开始我们觉得用Rust的bingen可以很容易生成对rdma-core的Rust封装,但实际中却碰到了很多问题。

首先,rdma-core有大量的接口函数是inline方式定义,至少上百个inline函数接口,bindgen在生成Rust封装时直接忽略所有的inline函数,导致我们必须手动实现。Rust社区有另外几个开源项目也实现了对rdma-core的Rust封装,但是都没有很好解决inline函数的问题。此外,我们在自行实现rdma-core的inline函数Rust封装时,保持了原有的函数名和参数名不变。

其次,rdma-core有不少宏定义,bindgen在生成Rust封装时也直接忽略所有的宏定义,于是我们也必须手动实现一些关键的宏定义,特别是要手动实现rdma-core里用宏定义实现的接口函数和一些关键常量。

再有,rdma-core有很多数据结构的定义用到了union,但是bindgen对C的union处理得不好,并不是直接转换成Rust里的union。更严重的是rdma-core的数据结构里还用到匿名union,如下所示:

struct ibv_wc {
    ...
	union {
		__be32		imm_data;
		uint32_t	invalidated_rkey;
	};
    ...
};

由于Rust不支持匿名union,针对这些rdma-core的匿名union,bindgen在生成的Rust binding里会自动生成union类型的名字,但是bindgen自动生成的名字对开发者很不友好,诸如ibv_flow_spec__bindgen_ty_1__bindgen_ty_1这种名字,所以我们都是手动重新定义匿名union,如下所示:

#[repr(C)]
pub union imm_data_invalidated_rkey_union_t {
    pub imm_data: __be32,
    pub invalidated_rkey: u32,
}

#[repr(C)]
pub struct ibv_wc {
    ...
    pub imm_data_invalidated_rkey_union: imm_data_invalidated_rkey_union_t,
    ...
}

再次,rdma-core里引用了很多C的数据结构,诸如pthread_mutex_tsockaddr_in之类,这些数据结构应该使用Rust libc里定义好的,而不是由bindgen再重新定义一遍。所以我们需要配置bindgen不重复生成libc里已经定义好的数据结构的Rust binding。

简单一句话总结下,bindgen对生成rdma-core的unsafe封装只能起到一半作用,剩下很多工作还需要手动完成,非常细碎。不过好处是,RDMA接口已经稳定,此类工作只需要一次操作即可,后续几乎不会需要大量更新。

RDMA的safe封装

关于RDMA的safe封装,有两个层面的问题需要考虑:

  • 如何做到符合Rust的规范和惯例;
  • 如何实现RDMA操作的内存安全。

首先,关于RDMA的各种数据结构类型,怎样才能封装成对Rust友好的类型。rdma-core里充斥着大量的指针,绝大多数指针被bindgen定义为*mut类型,少部分定义为*const类型。在Rust里,这些裸指针类型不是Sync也不是Send,因此不能多线程访问。如果把这些裸指针转化为引用,又涉及到生命周期问题,而这些指针指向的数据结构都是rdma-core生成的,大都需要显式的释放,比如struct ibv_wq这个数据结构由ibv_create_wq()函数创建,并由ibv_destroy_wq()函数释放:

struct ibv_wq *ibv_create_wq(...);

int ibv_destroy_wq(struct ibv_wq *wq);

但是用Rust开发RDMA应用的时候,Rust代码并不直接管理struct ibv_wq这个数据结构的生命周期。进一步,在Rust代码中并不会直接修改rdma-core创建的各种数据结构,Rust代码都是通过调用rdma-core的接口函数来操作各种RDMA的数据结构/指针。所以对Rust代码来说,rdma-core生成的各种数据结构的指针,本质是一个句柄/handler,这个handler的类型是不是裸指针类型并不重要。于是,为了在Rust代码中便于多线程访问,我们把rdma-core返回的裸指针类型都转换成usize类型,当需要调用rdma-core的接口函数时,再从usize转换成相应的裸指针类型。这么做听上去很hack,但背后的原因还是很显而易见的。进一步,对于在rdma-core中需要手动释放的资源,可以通过实现Rust的Drop trait,在drop()函数中调用rdma-core相应的接口实现资源自动释放。

其次,关于RDMA的内存安全问题,这部分工作尚未完成。目前RDMA的共享内存访问安全问题在学术界也是个热门研究课题,并没有完美的解决方案。本质上讲,RDMA的共享内存访问安全问题是由于为了实现高性能网络传输、绕过内核做内存共享带来的,内核在内存管理方面做了大量的工作,RDMA的数据传输绕过内核,因此RDMA无法利用内核的内存管理机制保证内存安全。如果要把内核在内存管理方面的工作都搬到用户态来实现RDMA共享内存访问安全,这么做的话一方面复杂度太高,另一方面也不一定有很好的性能。

在实际使用中,人们会对RDMA的使用方式进行规约,比如不允许远程节点写本地节点的共享内存,只允许远程节点读。但即便是只允许远程读取,也有可能有数据不一致的问题。比如远程节点读取了共享内存的前半段数据,本地节点开始更新共享内存。假定本地节点更新的数据很少而远程节点读取的数据很多,因此本地节点更新的速度比远程节点读取的速度快,导致有可能本地节点在远程节点读后半段数据前更新完毕,这样远程节点读取的是不一致的数据,前半段数据不包括更新数据但是后半段包括更新数据。远程节点读到的这个不一致的数据,既不是先前真实存在的某个版本的数据,也不是全新版本的数据,破坏了数据一致性的保证。

针对RDMA内存安全问题,一个常见的解决方案是采用无锁(Lock-free)数据结构。无锁数据结构本质上就是解决并发访问下保证内存安全问题,当多个线程并发修改时,无锁数据结构保证结果的一致性。针对上面提到的远程读、本地写的方式,可以采用Seqlock来实现。即每块RDMA的共享内存空间关联一个序列号(sequence number),本地节点每次修改共享内存前就把序列号加一,远程节点在读取开始和结束后检查序列号是否有变化,没有变化说明读取过程中共享内存没有被修改,序列号有变化说明读取过程中共享内存被修改,读到了有可能不一致的数据,则远程节点重新读取共享内存。

如果要放宽对RDMA的使用规约,即远程节点和本地节点都可以读写共享内存的场景,那么就需要采用更加复杂的算法或无锁数据结构,诸如Copy-on-WriteRead-Copy-Update等。内核中大量使用Copy-on-Write和Read-Copy-Update这两种技术来实现高效内存管理。这方面的工作有不少技术难度。

后续工作

下一步在完成对RDMA的safe封装之后,我们规划用Rust实现对RDMA接口函数的异步调用。因为RDMA都是IO操作,非常适合异步方式来实现。

对RDMA接口函数的异步处理,最主要的工作是关于RDMA的完成队列的消息处理。RDMA采用了多个工作队列,包括接收队列(RQ),发送队列(SQ)以及完成队列(CQ),这些队列一般是RDMA的硬件来实现。其中发送队列和接收队列的功能很好理解,如字面意思,分别是存放待发送和待接收的消息,消息是指向内存中的一块区域,在发送时该内存区域包含要发送的数据,在接收时该内存区域用于存放接收数据。在发送和接收完成后,RDMA会在完成队列里放入完成消息,用于指示相应的发送消息或接收消息是否成功。用户态RDMA程序可以定期不定期查询完成队列里的完成消息,也可以通过中断的方式在CPU收到中断后由内核通知应用程序处理。

异步IO本质上都是利用Linux的epoll机制,由内核来通知用户态程序某个IO已经就绪。对RDMA操作的异步处理,方法也一样。RDMA是通过创建设备文件来实现用户态RDMA程序跟内核里的RDMA模块交互。在安装RDMA设备和驱动后,RDMA会创建一个或多个字符设备文件,/dev/infiniband/uverbsN,N从0开始,有几个RDMA设备就有几个uverbsN设备文件。如果只有一个那就是/dev/infiniband/uverbs0。用户态RDMA程序要实现针对RDMA完成队列的异步消息处理,就是采用Linux提供的epoll机制,对RDMA的uverbsN设备文件进行异步查询,在完成队列有新消息时通知用户态RDMA程序来处理消息。

关于RDMA的封装,这块工作我们还没有完成,我们打算把RDMA的safe封装以及对RDMA的共享内存管理都实现,这样才能方便地使用Rust进行RDMA编程,同时我们欢迎有感兴趣的朋友一起参与。

建立 Async Rust 的共同愿景

译者:NiZerin

原文链接:https://blog.rust-lang.org/2021/03/18/async-vision-doc.html


2021年3月18日·Niko Matsakis 代表 Async Foundations Working Group

异步基础工作组 认为 Rust 能够成为最热门的选择之一为构建分布式系统,从嵌入式设备到基础云服务。无论他们将其用于什么,我们都希望所有开发人员都喜欢使用 Async Rust。为了实现这一点,我们需要将 Async Rust 移至目前的“MVP”状态之外,并使所有人都可以使用它。

我们正在开展合作,为 Async Rust 构建共享的 愿景文档我们的目标是让整个社区参与到集体的想象中:我们如何才能使使用异步 I/O 的端到端体验不仅是一种务实的选择,而且是一种快乐的选择?

愿景文件始于现状...

“视觉文档”以一连串字符开头。每个角色都取决于由其背景决定的特定 Rust 值(例如,性能,生产率等);这种背景也告诉了他们使用 Rust 时所带来的期望。

让我向您介绍一个角色,格蕾丝(Grace) 。作为一名经验丰富的 C 开发人员,Grace 习惯了高性能和控制能力,但是她喜欢使用 Rust 获得内存安全性的想法。这是她的传记:

Grace 从事 C 和 C++ 的编写已经有很多年了。她习惯于破解许多底层细节,以哄骗自己的代码获得最大的性能。她还经历了由于 C 中的内存错误而导致的史诗般的调试会话。她对 Rust 感兴趣:她喜欢这样的想法:获得与 C 相同的控制和性能,但又从内存安全性中获得了生产力上的好处。她目前正在尝试将 Rust 引入她正在使用的某些系统中,并且她还在考虑将 Rust 用于一些新项目。

对于每个角色,我们都会编写一系列“现状”故事 ,描述他们在尝试实现目标时面临的挑战(通常以戏剧性的方式失败!)。这些故事不是虚构的。它们是对使用 Async Rust 的人们的真实体验的综合,这是通过访谈,博客文章和推文向我们报告的。为了给您一个想法,我们目前有两个示例:一个示例,其中Grace必须调试她编写的自定义未来 ,而另一个示例中,Alan(来自GC语言的程序员)遇到堆栈溢出并必须调试原因

编写“现状”故事有助于我们弥补知识的诅咒 :从事 Async Rust 工作的人们往往是 Async Rust 的专家。我们已经习惯了提高生产效率所需的解决方法 ,并且我们知道一些小技巧可以帮助您摆脱困境。这些故事可帮助我们评估所有剪纸对仍在学习中的人所产生的累积影响。这为我们提供了我们需要确定优先级的数据。

然后告诉我们我们将如何对其进行更改

当然,愿景文档的最终目标不仅是告诉我们我们现在在哪里,而且还要告诉我们我们要去往何处以及如何到达那里。一旦我们在现状故事方面取得了良好进展,下一步将是开始集思广益地讨论“光明的未来” 的故事。

闪亮的未来故事讲述了异步世界在未来2或3年后会是什么样。通常,他们将重播与“现状”故事相同的场景,但结局会更好。例如,也许格蕾丝(Grace)可以使用调试工具,该工具能够诊断卡住的任务并告诉她阻止任务的未来类型,因此她不必遍历日志。也许编译器可以警告Alan有关可能的堆栈溢出的信息,或者(更好的是)我们可以调整设计以select首先避免出现此问题。这个想法是雄心勃勃的,并且首先将重点放在我们要创建的用户体验上;我们将找出整个过程中的步骤(如果需要的话,还可以调整目标)。

让整个社区参与

异步愿景文档提供了一个论坛,在该论坛上,Async Rust 社区可以为 Async Rust 用户规划出色的整体体验。Async Rust 的设计初衷是不具有“一刀切”的思维方式,我们也不想改变这种状况。我们的目标是为端到端体验建立一个共同的愿景,同时保留我们已建立的松散耦合,面向探索的生态系统。

我们用于编写愿景文档的过程鼓励积极协作和“积极的总和”思考。它从集思广益期开始,在此期间,我们旨在收集尽可能多的“现状”和“光明的未来”故事。这个头脑风暴期持续了六个星期,直到四月底。在前两个星期(直到2021-04-02),我们仅收集“现状”故事。之后,我们将接受“现状”和“光明的未来”这两个故事,直到头脑风暴期结束为止。最后,帽从头脑风暴时期,我们将选择优胜者奖项,如“最幽默的故事”或“必须扶持贡献者”。

头脑风暴期结束后,工作组负责人将开始着手将各种故事和光明的未来汇编成一个连贯的草案。该草案将由社区和 Rust 团队进行审查,并根据反馈进行调整。

想帮忙?

如果您想帮助我们编写愿景文档,我们很乐意为您贡献自己的经验和愿景!目前,我们专注于创建现状故事。我们正在寻找人们撰写 PR 或谈论他们在问题或其他方面的经验。如果您想开始使用,请查看有关现状故事的模板 -它具有打开 PR 所需的所有信息。另外,您可以查看“如何实现愿景” 页面,其中详细介绍了整个愿景文档过程。

我们的 AWS Rust 团队将如何为 Rust 未来的成功做出贡献

译者:NiZerin

原文链接:How our AWS Rust team will contribute to Rust’s future successes


自今年年初以来,AWS Rust 团队一直在起草我们的章程和宗旨。 章程和宗旨是 AWS 团队用来定义我们的范围和优先事项的框架。 章程告诉你的团队该做什么,宗旨告诉你的团队将如何做到这一点。 由于我们的团队宗旨一直是公开和透明运作的,我们想与您分享我们的章程和宗旨,我们希望您知道我们在做什么。

起草我们的章程很容易。 这只是一句话:AWS Rust 团队致力于让 Rust 为其所有用户提供高效、可靠的服务。 说得够多了! 然而,撰写这些宗旨需要更多的工作。

等等,AWS 有个 Rust 小组?

是的! 事实上,至少从 2017 年开始,AWS 就在多项服务中使用 Rust。 例如,用 Rust 编写的 Firecracker 于 2018 年推出,提供支持 AWS Lambda 和其他无服务器产品的开源虚拟化技术。 最近,AWS 发布了用 Rust 编写的基于 Linux 的容器操作系统 Bottlerocket ,Amazon Elastic Compute Cloud(Amazon EC2) 团队使用 Rust 作为新的 AWS Nitro 系统组件(包括 Nitro Enclaves 等敏感应用程序)的首选语言。 随着在 AWS 中采用 Rust 的增长,我们对 Rust 项目和社区的投资也在增加。 2019年,AWS 宣布赞助 Rust 项目。 2020年,AWS 开始打造 Rust 维护者和贡献者团队,2021年,AWS 联合其他 Rust 用户和 Rust 项目发起了 Rust 基金会。 AWS Rust 团队首先找出了如何最好地与 AWS 和更广泛的开源社区建立联系。 我们知道,我们希望在公开的环境下运作,并成为整个社会的一份子。 与此同时,我们知道我们想要充分利用在 AWS 工作的机会。 起草章程和宗旨是我们找到两者兼顾的方法和过程的一部分。

我们的宗旨

在 AWS,开发人员对每件事都起草宗旨。 它们是传达团队、项目或其他类型的一种有效方式。 作为 AWS 的新手,我们中的一位(Niko)刚刚开始学习 Rust,他真的很着迷。 你可能会开始看到它们出现在各式各样的地方。 下面的每个原则都包含了一个核心信念或原则,这些信念或原则将指导我们团队的决策。 它们特定于我们的团队,帮助我们专注于交付价值。 这些宗旨不是用来写了就忘的。 它们在日常运营中被积极的使用,帮助指导我们找出如何解决权衡问题的方法。

宗旨0:我们是一个 AWS 团队。

我们是 AWS 团队。 我们主导了用于在云中构建运营服务的工具和开发机制。 我们利用我们与 AWS 服务的近在咫尺来收集帮助我们改进 Rust 的见解。

Rust 一直受益于它是一种“实践者”的语言。 起初,Rust 使用 Servo 项目来指导它;浏览器有非常苛刻的性能要求,因此将语言推向了许多有趣的方向。 随着 Rust 采用率的增长,由此产生的反馈帮助将 Rust 扩展到越来越多的领域。 为此,我们希望该团队充分利用 AWS 提供的功能。 在 AWS,Rust 被用于提供各种服务,例如 Amazon Simple Storage Service(Amazon S3)、Amazon Elastic Compute Cloud(Amazon EC2)、Amazon CloudFront 等。 我们可以与这些团队密切合作,了解哪些工作做得很好,哪些需要改进,然后将这些经验带回 Rust。 我们还可以与正在部署 Rust 内置系统的 AWS 客户合作,了解他们的需求。 这个宗旨还有另一个关键点。 作为一个 AWS 团队,我们有一个重点。 我们将自己的角色--与 Rust 社区中的其他人一起--视为帮助改进 Rust for the Cloud。 这是我们最了解的。 我们也很高兴看到 Rust 在所有其他领域都在增长,但我们认为最好是其他人在这方面发挥带头作用,由我们的团队担任辅助角色。

宗旨1:我们是一个开放团队。

我们是一个开放团队。 分享和协作我们的设计可以提高我们团队的质量和价值,包括 AWS。

我们是 AWS 团队,但我们也是 Rust 的贡献者,这一宗旨意味着我们将本着开放和透明的精神运营。 例如,Niko 计划在想法完全成型之前继续在他的 BaySteps 博客上发布想法,他指望 Rust 社区继续在这些想法上探索。

宗旨2:我们帮助 Rust 团队兑现承诺。

我们帮助 Rust 团队兑现承诺。 我们和 Rust 团队的使命一样,不仅要使系统编程高效、安全和多产,而且要让新的开发者能够轻易上手。

我们热爱 Rust,因为它专注于使人们能够构建具有强大安全保证的高性能、并发系统。

  • 一种系统级语言
  • 快速、并发、安全

然而,除了它的技术属性之外,Rust 的另一个核心价值对我们来说非常重要:可访问性。 我们所说的可访问性,是指积极寻找进入壁垒,并拆除它们。 有时这些障碍是技术性的,而另一些时候,这些障碍是社会障碍。 无论哪种方式,我们都认同 Rust 的信念,即向更广泛的开发者开放系统编程。

宗旨3:我们支持我们所在的社区。

我们支持我们所在的社区。 我们做自己份内的“必要的事情”,比如对问题进行分类,整理积压工作,指导其他贡献者并让他们参与进来,参与设计讨论,以及修复错误。

开源需要的不仅仅是程序员。 有很多工作要做;这并不总是很有趣,但很重要。 这一宗旨与第一个宗旨(“我们是AWS团队”)相辅相成。 在讨论这一原则时,我们提到了我们在云方面拥有第一手专业知识,但我们还希望其他领域能够起到带头作用。 这一宗旨是说,我们将帮助支持领导这项工作的人们,无论是通过审查、指导,还是仅仅通过参与讨论和提出我们的两点意见。

宗旨4:我们帮助连接 AWS 和 Rust 生态系统。

我们帮助连接 AWS 和 Rust 生态系统。 我们帮助 AWS 团队驾驭 All Things Rust,并促进他们积极参与他们所依赖的项目。

我们的部分工作是将其他 AWS 团队与 Rust 生态系统和 Rust 项目联系起来。 我们希望所有使用 Rust 的 AWS 开发人员都能参与维护和改进我们使用的库或编译器本身。 这一努力将会带来大量的好处。 当然,这将有助于维持库,并将确定优化或其他改进的机会,从而使我们的 AWS 服务受益。 双赢。

宗旨5:我们专注于我们最了解的事情;我们不会尝试做每件事。

我们专注于我们最了解的事情;我们不会尝试做每一件事。 我们的团队包括 Rust 编译器、语言设计和 Tokio 堆栈方面的领导者,这些都是我们能够产生最大影响的领域。

这篇关于比较优势的文章有一个主题--我们的团队将专注于我们最擅长的事情,并支持其他人做同样的事情。 不过,这一原则的要点是强调句子中的“焦点”一词。 我们很容易把自己分散得太细,无法提供高价值,我们真的希望我们的团队避免这种情况。

除非你知道更好的…

传统上,每条宗旨都以“除非你知道更好的信条”开头。 这个想法是,宗旨总是随着环境的变化而变化。 这些是我们目前的宗旨,我们希望随着我们更多地了解 AWS 和 Rust 社区合作的最佳方式,它们会不断发展。

no_std 环境下的可执行文件

作者: 吴翱翔@pymongo / 后期编辑: 张汉东

原文: no_std binary(static link)

由于作者身边只有 linux 操作系统的设备,所以本文内容仅探讨 Rust/C/C++ 在 linux 操作系统下 no_std 的可执行文件

本文更多探讨的是编译生成纯静态链接没有动态链接的 no_std 可执行文件,不仅连 Rust 的标准库也不用,连操作系统自带的 C 标准库也不用的环境

推荐这个 Making our own executable packer(linux) 系列文章:

在介绍Rust如何编译运行 no_std 的可执行文件之前,先看看汇编和 C/C++ 是如何编译 no_std 的可执行文件

汇编语言编译可执行文件

x86 汇编主要有两种语法,一是 Unix 的 AT&T syntax,另一个则是 windows 的 Intel syntax

由于 AT&T 有贝尔实验室,而 Unix 操作系统和 C 语言都是贝尔实验室发明的,所以 linux 的 gcc 和 as 都用 AT&T 汇编语法

如果想用 Intel 汇编语法可以用 llvm 或 nasm 工具

rustc 生成的汇编默认是 Intel 语法,可以传入 llvm 参数让 rustc 生成 AT&T 语法的汇编代码

rustc --emit asm -C llvm-args=-x86-asm-syntax=att main.rs

以这个网站GNU Assembler Examples 介绍的第一段汇编代码为准

编译运行这段代码有两个方法:

gcc -c s.s && ld s.o && ./a.out

或者用as工具(GNU assembler (GNU Binutils))

as s.s && ld s.o && ./a.out

可以用 ldd 工具校验编译生成的可执行文件是不是 statically linked (没有引入任何动态链接库)

汇编的劣势在于代码跟硬件架构绑定,gcc 编译这段汇编代码时加上-m32参数指定生成32位的可执行文件时就会报错

C 编译 no_std 可执行文件

用 gcc 或 clang 的 -nostdlib参数很容易生成无动态链接库的可执行文件

[w@w-manjaro temp]$ echo "int main(){return 0;}" > main.c && gcc -nostdlib main.c && ldd ./a.out
/usr/bin/ld: warning: cannot find entry symbol _start; defaulting to 0000000000001000
        statically linked

C 在 no_std 的环境下程序的入口函数名字不能是 main,要改成 _start

[w@w-manjaro temp]$ echo "int _start(){return 0;}" > main.c && gcc -nostdlib main.c && ldd ./a.out
        statically linked

当然也可以让 gcc 加上-m32参数生成32位的可执行文件

注意在 mac 或 windows 上用gcc 或 clang 的 -nostdlib参数可能会报错

$ clang -nostdlib c.c
ld: dynamic main executables must link with libSystem.dylib for architecture x86_64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

根据苹果开发者文档,Apple does not support statically linked binaries on Mac OS X

可能 macOS 要用特殊的 ld 工具或稍微复杂点的方法才能编译纯静态链接的可执行文件,不过这不在本文的探讨范围内了

Rust 编译 no_std 可执行文件


#![allow(unused)]
#![no_std]
#![no_main]
#![feature(lang_items,asm)]

fn main() {
/// entry_point/start_address of process, since the linker looks for a function named `_start` by default
#[no_mangle]
extern "C" fn _start() -> ! {
    exit(0); // macOS: illegal hardware instruction
}

fn exit(code: isize) -> ! {
    unsafe {
        asm!(
            "syscall",
            in("rax") 60, // exit
            in("rdi") code,
            options(noreturn)
        );
    }
}

#[lang = "eh_personality"]
extern "C" fn eh_personality() {}

#[panic_handler]
fn my_panic(_info: &core::panic::PanicInfo) -> ! {
    loop {}
}
}

源码在我这个仓库,linux 下的编译方法:

rustc -C link-arg=-nostartfiles main.rs

或者将以下两行写到.cargo/config.toml

[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]

如果只是编译 no_std 环境下的 动态链接库(cdylib),则不需要加上述 rustc 参数

用 Rust 写智能合约 | Hello, Ink!

作者:李大狗(李骜华)/ 后期编辑: 张汉东


什么是 WASM 智能合约?

以往,我们谈到智能合约,都是基于 EVM 的 Solidity 智能合约。

目前,随着智能合约技术的发展,出现了一种新的可能性:WASM 智能合约,

WASM 并非一门新的编程语言,而是一种全新的底层二进制语法。

WASM(WebAssembly)是一种新的字节码格式,是一种全新的底层二进制语法,它所编译的代码指令体积小,可移植,加载快并兼容WEB的全新格式。WASM可以支持C/C++/RUST/GO等多种语言编写合约后编译出节码,且不同语言有附带丰富的底层标准库可供调用。

WASM 的优势:

作为一种全新的字节码格式,WASM通过自身的创新和优化,使得在使用其对所支持的语言进行编写后的代码指令具有体积小,可以在运存,硬盘存储,带宽占有上得到更多的优化,在节省了区块链网络资源,也明显的提升了网络传输效率。

在智能合约上使用WASM,也将拥有以上特点,最明显的方面就是占用资源更少,运行合约更快速和稳定,并且网络传输信息更加高效。这可以使得区块链网络上部署更多的智能合约,也可以使得用户在使用智能合约时能获得更好的体验感。

——WASM智能合约优势分析:https://zhuanlan.zhihu.com/p/344347968

从目前的趋势上来看,Substrate、ETH 2.0等公链与多家联盟链,均表示将支持 WASM 智能合约。

可以用什么语言编写 WASM 智能合约?

Wasm 扩展了智能合同开发者可用的语言系列,包括 Rust、C/C++、C#、Typescript、Haxe 和 Kotlin。这意味着你可以用你熟悉的任何语言编写智能合约。

从适配性上来说,Rust 语言目前与 WASM 智能合约的适配性更好,工具链更全,而且写出来的智能合约更加安全。

所以,本系列将以 Subtrate 上的 Ink! 智能合约为例,开始 WASM 智能合约的 101 课程。

本文对 Ink! 官方教程有所参考:

https://substrate.dev/substrate-contracts-workshop

Rust 环境配置

1. Rust 环境配置

在 MacOS 或者 Ubuntu 等 Linux 操作系统上,我们可以通过一行命令很容易的安装 Rust:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

除此之外还要安装nightly版本:

rustup install nightly

Windows 下的安装,请参考:

https://forge.rust-lang.org/infra/other-installation-methods.html

2. 将 Rust 添加到环境中

将如下语句添加到~/.bashrc~/.zshrc中:

export PATH=~/.cargo/bin:$PATH

然后:

source ~/.bashrc # source ~/.zshrc

3. 换源

通过设置如下环境变量,我们把 Rust 源切换到国内:

export RUSTUP_DIST_SERVER=https://mirrors.ustc.edu.cn/rust-static
export RUSTUP_UPDATE_ROOT=https://mirrors.ustc.edu.cn/rust-static/rustup

~/.cargo/config文件中写入如下内容:

[source.crates-io]
registry = "https://github.com/rust-lang/crates.io-index"
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"

Ink! 环境配置

在配置了基本的 Rust 环境后,我们可以配置 Ink! 所需的开发环境了。

# for substrate
rustup component add rust-src --toolchain nightly
rustup target add wasm32-unknown-unknown --toolchain stable
# for canvas node 
cargo install canvas-node --git https://github.com/paritytech/canvas-node.git --tag v0.1.4 --force --locked
# for ink!CLI
cargo install cargo-contract --vers 0.10.0 --force --locked

我们还要安装/升级binaryen,Binaryen 是 WebAssembly 的编译器。

Mac 上安装:

# for mac
brew upgrade binaryen # 如果没安装用 brew install

Linux 上安装:

创建一个 ink! 项目

执行如下命令:

cargo contract new flipper

创建完成后进入文件夹:

cd flipper/

合约项目目录结构:

flipper
|
+-- lib.rs                <-- Contract Source Code
|
+-- Cargo.toml            <-- Rust Dependencies and ink! Configuration
|
+-- .gitignore

合约测试

cargo +nightly test

一切顺利的话会输出如下结果:

$ cargo +nightly test
    running 2 tests
    test flipper::tests::it_works ... ok
    test flipper::tests::default_works ... ok

    test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

合约编译

cargo +nightly contract build

image-20210323213148674

如果顺利的话,目录下会生成target/ink文件夹,里面包含如下文件:

image-20210323205600627

其中,flipper.contract 是部署时要用的合约文件,可以视为solidity合约中的bin文件。

metadata.json是元数据,可以视为solidity合约中的abi文件。

合约部署

通过canvas启动一个本地运行的开发节点!

canvas --dev --tmp

An image of the terminal starting a Substrate node

打开如下网址,会这个页面会自动连接本地启动的开发节点:

image-20210323210306845

上传flipper.contract这个文件:

Contracts code page for deploying Flipper

一路点击进行部署:

image-20210323210730452

image-20210323210747989

image-20210323210811846

合约调用

点击Execute

image-20210323210928445

选择get():bool函数,点击「调用」:

image-20210323211004303

返回调用结果:

image-20210323211027286

Flipper 源码解读


#![allow(unused)]
fn main() {
// Copyright 2018-2020 Parity Technologies (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#![cfg_attr(not(feature = "std"), no_std)]

use ink_lang as ink;

#[ink::contract]
pub mod flipper {
    #[ink(storage)]
    pub struct Flipper {
        value: bool,
    }

    impl Flipper {
        /// Creates a new flipper smart contract initialized with the given value.
        #[ink(constructor)]
        pub fn new(init_value: bool) -> Self {
            Self { value: init_value }
        }

        /// Creates a new flipper smart contract initialized to `false`.
        #[ink(constructor)]
        pub fn default() -> Self {
            Self::new(Default::default())
        }

        /// Flips the current value of the Flipper's bool.
        #[ink(message)]
        pub fn flip(&mut self) {
            self.value = !self.value;
        }

        /// Returns the current value of the Flipper's bool.
        #[ink(message)]
        pub fn get(&self) -> bool {
            self.value
        }
    }

    #[cfg(test)]
    mod tests {
        use super::*;

        #[test]
        fn default_works() {
            let flipper = Flipper::default();
            assert_eq!(flipper.get(), false);
        }

        #[test]
        fn it_works() {
            let mut flipper = Flipper::new(false);
            assert_eq!(flipper.get(), false);
            flipper.flip();
            assert_eq!(flipper.get(), true);
        }
    }
}
}

1. cfgcfg_attr的使用

cfg是 Rust 中的特殊属性, 它允许我们编译基于标志的代码并传递给编译器。

在本合约中,我们可以看到:

#[cfg(test)]

这个标识意味着下面的代码是单元测试。

2. impl 关键字

Implement some functionality for a type.

为一种类型做函数实现。

标准的模板是:

struct Example {
    number: i32,
    # 许多变量……
}

impl Example {
    fn boo() {
        println!("boo! Example::boo() was called!");
    }

    fn answer(&mut self) {
        self.number += 42;
    }
		# 许多函数……
}

套用到本合约中,首先我们定义本合约的struct

pub struct Flipper {
	value: bool, # 其中包含一个变量 value
}

然后对struct进行补充实现:

impl Flipper {
	……
}

3. #[ink(constructor)]#[ink(message)]

#[ink(constructor)]表示这行语句函数是合约的构造函数,相当于solidity合约中的constructor

https://docs.soliditylang.org/en/v0.7.2/contracts.html#constructor

#[ink(message)]表示这行语句下面的函数是合约的普通函数,如例子中的get函数:

/// Returns the current value of the Flipper's bool.
#[ink(message)]
pub fn get(&self) -> bool {
	self.value
}

作者简介:

李大狗(李骜华),上海对外经贸大学区块链技术与应用研究中心副主任、柏链教育 CTO、FISCO BCOS(微众银行区块链框架)区块链认证讲师、5 年区块链工程师、北京大学硕士。 研究领域包括:区块链系统、共识机制、智能合约、区块链应用、数字身份等。

蓄水池算法改进 - 面向抽奖场景保证等概率性

作者:huangjj / 后期编辑:张汉东

免责声明:禁止任何个人或团体使用本文研究成果用于实施任何违反中华人民共和国法律法规的活动 如有违反,均与本文作者无关


正文

在我们通常遇到的抽奖场景,于年会时将所有人的编号都放到箱子里面抽奖,然后每次抽出中奖者 决定奖项。而在这过程中,因为先抽中者已经确定了奖项,然后不能够参与后续的奖项的抽奖;而后 续参与抽奖的人员则其实会以越来越低的概率参与抽奖:

例:在上述场景中共有 \( n \) 人参与抽取 \( m ( \lt n) \) 个奖项,

抽取第一个奖项概率为: \( { m \over n } \)

那么因为抽了第一个奖项,剩下 \( n - 1 \) 人参与 \( m - 1 \) 个奖项,被抽中的概率 为 \( m - 1 \over n - 1 \)。 那么 \( m \lt n \Rightarrow -m \gt -n \Rightarrow mn - m \gt nm - n \Rightarrow m(n-1) \gt n(m - 1) \Rightarrow { m \over n } \gt { m - 1 \over n - 1 }\), 即如果前面的奖项没有抽到,后面抽到奖项的概率会更低。

因此,在人数 \( n \) 大于奖项数 \( m \) 的时候,我们通过以越来越低的概率干涉前面 已经取得取得奖项的结果,来保证先参与抽奖的人中奖的概率随着人数的增多中奖的概率也变低, 最后中奖的概率为 \( m \over n \)。但是在实际场景中,\( m \) 个奖项可能不仅相同 (如划分了一二三等奖),因此对于蓄水池算法的改进提出了新的要求:

  • 不论人数多少(当还是要保证有人来参与抽奖 \( n \gt 1\) )所有人获得特定奖项的概率相同
  • 每当新来一人参与抽奖时,如果他没有中奖,可以即场告知未中

算法描述与等概率性证明

我们分两种情况讨论:

  • 一种是当人数不足以覆盖所有的奖项的场景( \(n \lt m \) ),
  • 另外一种是当抽奖人数远大于所有奖项加起来的数目。( \( n \gt m \))。

然后我们再回来看看能不能找到一种很方便的方法桥接两种情况。

同时,我们假设 \( m \) 个奖项两两互不相同。

抽奖人数不足时( \(n \lt m \) )

因为当人数不足时,所有参与者都能抽奖,因此我们要保证每个人获得特定奖项的概率为 \( 1 \over m \)。 算法描述:

记 \( Choosen \) 为容量为 \( m \) 的数组, \( Choosen[k] (1 \le k \le m) \) 表示第 k 个奖项的当前占有情况, 初始值为 \( None \),

\( Players \) 为参与参与抽奖的人的序列

  1. 令 \( i := 1 \),当 \( i \le n \) 时,做如下操作:
    • 产生随机数 \( r_1 (1 \le r_1 \le i) \)
    • 如果 \( r_1 \lt i \),\( Choosen[i] := Choosen[r_1] \)
    • \( Choosen[r_1] := Players[i] \)
    • \( i := i + 1 \)
  2. 当 \( i \le m \) 时,做如下操作:
    • 产生随机数 \( r_2 (1 \le r_2 \le i) \)
    • 如果 \( r_2 \lt i \):
      • \( Choosen[i] := Choosen[r_2] \)
      • \( Choosen[r_2] := None \)
    • \( i := i + 1 \)

等概率性证明

我们先证明,在填入中奖者的第 \( k (1 \le k \le m) \) 轮过程中,能够保证对于前 \( k \) 个奖项中的每一个奖项,每一位中奖者抽中其中第 \( i (1 \le i \le k) \) 个奖项的概率为 \(1 \over k \),证明如下:

我们采用数学归纳法来证明:

  1. 奠基:当 \( k = 1 \) 时,易知该中奖者一定会抽中第一个奖项,前一个奖项中只有第一个 选项,所以此时每一位中奖者抽中第 \( k = 1 \) 的概率为 \( 1 = { 1 \over 1 } = { 1 \over k } \);
  2. 归纳:
    • 假设当 \(k = j (1 \le j \lt m) \)时,每一位抽奖者抽中第 \( i (1 \le i \le j) \)的概率为 \( 1 \over j \)
    • 当 \( k = j + 1 \), 有:
      • 第 \( j + 1 \) 位抽奖着抽中任意第 \( i' (1 \le i' \le j + 1) \) 个奖项的概率为 \( 1 \over { j + 1 } \) (假设产生的随机数 \( r_1、r_2 \) 足够的均匀);
      • 对于前 \( j \) 位抽奖者,每一位都有 \( 1 \over { j + 1 } \),的概率将自己的奖项更换位第 \( j + 1 \)个奖项;
      • 对于前 \( j \) 位抽奖者,每一位依然占有原有第 \( i' \) 个奖项的概率为:

\[ \begin{equation} \begin{aligned} P\{前 j 位抽奖者 j + 1 轮中仍然持有 i' \} & = P\{前 j 位抽奖者j轮已经持有 i' \} \cdot P\{第 j + 1 位抽奖者没有抽中 i' \} \\ & = P\{前 j 位抽奖者j轮已经持有 i' \} \cdot (1 - P\{第 j + 1 位抽奖者抽中 i' \}) \\ & = \frac{1}{j} \cdot (1 - \frac{1}{j+1}) \\ & = \frac{1}{j} \cdot \frac{j}{j+1} \\ & = \frac{1}{j + 1} \\ & = \frac{1}{k} \\ \end{aligned} \label{1.1} \tag{1.1} \end{equation} \]

由上,可知每一轮迭代之后,前 \( k \) 个奖项对于已经参与的 \( k \)中奖者来说抽中的概率均等,为 \( 1 \over k \), 故到了第 \( n \) 轮操作后,我们可以通过不断填充 \( None \)值来稀释概率,最后达到 \( 1 \over m \) 的等概率性。

特殊地,当 \( n == m \) 时,每个抽奖者抽到特定奖项的概率也为 \(1 \over n \)。

抽奖人数足够多时( \(n \gt m \) )

类似地,当 \(n \gt m \)时,对于每一个抽奖序号 \( k \gt m \) 的抽奖者,我们生成随机数 \( r_3(1 \le r_3 \le n) \),并且在 \( r_3 \le m \) 的时候,替换对应原本占有奖项的抽奖者;可以证明在这种情况下,能保证每个人抽到特定奖项的概率为 \(1 \over n \)1

整合后的算法

记 \( Choosen \) 为容量为 \( m \) 的数组, \( Choosen[k] (1 \le k \le m) \) 表示第 \( k \) 个奖项的当前占有情况, 初始值为 \( None \),

\( replaced \) 为原本已经中奖,但是被人替换的抽奖者

\( Players \) 为参与参与抽奖的人的序列,每次只能获取一个 \( player \)

记 \( n := 0 \)为当前参与抽奖的人数

  1. 在抽奖结束前,每次遇到一个新的 \( player \) 执行以下操作:
    • \( placed := None \)
    • \( n := n + 1 \)
    • 产生随机数 \( r (1 \le r \le n) \)
    • 如果 \( r \le m \):
      • \( replaced := Choosen[r] \)
      • \( Choosen[r] := player \)
    • 如果 \( r \lt n \) 并且 \( n \le m \):
      • \( Choosen[n] := replaced \)
  2. 在抽奖结束时,如果 \( n \lt m \), 执行以下操作:
    • \( i := n \)
    • 当 \( i \lt m \)时,重复执行以下操作:
      • \( i := i + 1 \)
      • 产生随机数 \( r_2 (1 \le r_2 \le i) \)
      • 如果 \( r_2 \lt i \):
        • \( Choosen[i] := Choosen[r_2] \)
        • \( Choosen[r_2] := None \)

程序实现

Rust

作者偏好 Rust 编程语言,故使用 Rust 实现。

特质(trait)

Rust 中的特质(trait) 是其用于复用行为抽象的特性,尽管比起 Java 或 C# 的接口 (Interface)更加强大,但在此文中, 熟悉 Java/C# 的读者把特质视作接口就可以了。

建模与实现

如下所示:

extern crate rand;
use rand::random;
use rand::seq::SliceRandom;
use rand::thread_rng;

trait ReservoirSampler {
    // 每种抽样器只会在一种总体中抽样,而总体中所有个体都属于相同类型
    type Item;

    // 流式采样器无法知道总体数据有多少个样本,因此只逐个处理,并返回是否将样本纳入
    // 样本池的结果,以及可能被替换出来的样本
    fn sample(&mut self, it: Self::Item) -> (bool, Option<Self::Item>);

    // 任意时候应当知道当前蓄水池的状态
    fn samples(&self) -> &[Option<Self::Item>];
}

struct Lottery<P> {
    // 记录当前参与的总人数
    total: usize,

    // 奖品的名称与人数
    prices: Vec<Price>,

    // 当前的幸运儿
    lucky: Vec<Option<P>>,
}

#[derive(Clone, Debug)]
struct Price {
    name: String,
    cap: usize,
}

impl<P> ReservoirSampler for Lottery<P> {
    type Item = P;

    fn sample(&mut self, it: Self::Item) -> (bool, Option<Self::Item>) {
        let lucky_cap = self.lucky.capacity();

        self.total += 1;

        // 概率渐小的随机替换
        let r = random::<usize>() % self.total + 1;
        let mut replaced = None;
        if r <= lucky_cap {
            replaced = self.lucky[r - 1].take();
            self.lucky[r - 1] = Some(it);
        }

        if self.total <= lucky_cap && r < self.total {
            self.lucky[self.total - 1] = replaced.take();
        }

        (r <= lucky_cap, replaced)
    }

    fn samples(&self) -> &[Option<Self::Item>] {
        &self.lucky[..]
    }
}

impl<P: Debug> Lottery<P> {
    fn release(self) -> Result<Vec<(String, Vec<P>)>, &'static str> {
        let lucky_cap = self.lucky.capacity();

        if self.lucky.len() == 0 {
            return Err("No one attended to the lottery!");
        }

        let mut final_lucky = self.lucky.into_iter().collect::<Vec<Option<P>>>();
        let mut i = self.total;
        while i < lucky_cap {
            i += 1;

            // 概率渐小的随机替换
            let r = random::<usize>() % i + 1;
            if r <= lucky_cap {
                final_lucky[i - 1] = final_lucky[r - 1].take();
            }
        }
        println!("{:?}", final_lucky);

        let mut result = Vec::with_capacity(self.prices.len());
        let mut counted = 0;
        for p in self.prices {
            let mut luck = Vec::with_capacity(p.cap);

            for i in 0 .. p.cap {
                if let Some(it) = final_lucky[counted + i].take() {
                    luck.push(it);
                }
            }

            result.push((p.name, luck));
            counted += p.cap;
        }

        Ok(result)
    }
}

// 构建者模式(Builder Pattern),将所有可能的初始化行为提取到单独的构建者结构中,以保证初始化
// 后的对象(Target)的数据可靠性。此处用以保证所有奖品都确定后才能开始抽奖
struct LotteryBuilder {
    prices: Vec<Price>,
}

impl LotteryBuilder {
    fn new() -> Self {
        LotteryBuilder {
            prices: Vec::new(),
        }
    }

    fn add_price(&mut self, name: &str, cap: usize) -> &mut Self {
        self.prices.push(Price { name: name.into(), cap });
        self
    }

    fn build<P: Clone>(&self) -> Lottery<P> {
        let lucky_cap = self.prices.iter()
            .map(|p| p.cap)
            .sum::<usize>();

        Lottery {
            total: 0,
            prices: self.prices.clone(),
            lucky: std::vec::from_elem(Option::<P>::None, lucky_cap),
        }
    }
}

fn main() {
    let v = vec![8, 1, 1, 9, 2];
    let mut lottery = LotteryBuilder::new()
        .add_price("一等奖", 1)
        .add_price("二等奖", 1)
        .add_price("三等奖", 5)
        .build::<usize>();


    for it in v {
        lottery.sample(it);
        println!("{:?}", lottery.samples());
    }

    println!("{:?}", lottery.release().unwrap());
}

优点

  • 流式处理,可以适应任意规模的参与人群
  • 在保证每一位抽奖者都有相同的概率获得特定奖项的同时,还能保证每一个抽奖者的获得的奖项均不相同

缺点

  • 所有参与抽奖的人都必须依次经过服务器处理,因为需要获知准确的总人数来保证等概率性。 一个改进的方法是,在人数足够多的时候,将总人数用总人数的特定数量级替代(给后续参加者的 一点点小福利——但是因为总人数足够多,所以总体中奖概率还是很低),在客户端完成中奖的选定
  • 等概率性完全依赖随机数 r 生成。 因为奖品初始化时不需要考虑打乱顺序,因此如果在 随机这一步被技术破解,使得抽奖者可以选择自己能获取的奖项,则会破坏公平性。改进方案是, 在 release 的时候再一次对奖品顺序进行随机的打乱。
  • 这种抽奖方式还限定了每人只能抽取一次奖品,否则会出现一个人占有多个奖项的情况。
1

可以参考博主以前的博客

下一步可能展开的工作

目前所有抽奖者都按照相等的概率抽奖,而在一些场景下可能按照一些规则给与某些抽奖者优惠 (例如绩效越高的员工中奖概率越大),因此下一步可能考虑如何按照权重赋予每位抽奖者各自的 中奖概率。

致谢

感谢茶壶君(@ksqsf)一语惊醒梦中人,清楚明确地表达了需求; 感谢张汉东老师 (@ZhangHanDong)老师提点了之后可以开展研究的方向; 感谢在这次讨论中提供意见的其他 Rust 社区的朋友,谢谢你们!

作者介绍

huangjj,Rust 爱好者,公众号:坏姐姐日常入门 Rust。

「Rust入门系列」Rust 中使用 MySQL

作者:张军军 / 后期编辑:张汉东

这个系列的文章,我计划给大家讲解如何在Rust中使用Mysql作为存储,先从简单的开始,然后在后面展示如何在开发Web api中使用。

数据表

本次我会使用一张订单表order。订单表的具体schema如下。

CREATE TABLE `student` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) NOT NULL,
  `age` int(11) NOT NULL,
  `id_card` varchar(128) NOT NULL,
  `last_update` date NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 插入测试数据
insert into student (name, age, id_card, last_update) values ('张三', 23, '123456789X', CURRENT_DATE());
insert into student (name, age, id_card, last_update) values ('李四', 24, '8382353902', CURRENT_DATE())

创建应用程序


#![allow(unused)]
fn main() {
cargo new mysql-test-01
}

由于要使用Mysql的驱动,所以添加依赖到Cargo.toml


#![allow(unused)]
fn main() {
[dependencies]
mysql = "*" // 通配符*表示可以使用任何版本,通常会拉取最新版本
chrono = "0.4"
}

在这里,我使用chrono来处理日期和时间列。具体 可以参考 https://docs.rs/chrono/0.4.19/chrono/

开始

在main.rs中导入命名空间


#![allow(unused)]
fn main() {
use mysql::*;
use mysql::prelude::*;
use chrono::prelude::*; // 用来处理日期
}

获取Mysql连接

fn main() {
    let url = "mysql://root:password@localhost:3306/MYDB";
    let pool = Pool::new(url).unwrap(); // 获取连接池
    let mut conn = pool.get_conn().unwrap();// 获取链接
}

先跑一下,确保可以打开一个连接


#![allow(unused)]
fn main() {
cargo run
}

第一次下载和编译所有依赖,可能需要一点点时间,看到命令行编译过去了,表示和数据库已经打通了。

流式查询

流式查询,其实结果数据是逐行读取的。 好处就是,整个数据永远不会存储在内存中,如果要读取大量数据,使用query_iter很好。


#![allow(unused)]
fn main() {
 conn.query_iter("select * from student")
        .unwrap()
        .for_each(|row| {
            let r: (i32, String, i32, String, NaiveDate) = from_row(row.unwrap());
            println!("{}, {},{},{}, {:?}", r.0, r.1, r.2, r.3, r.4);
        });
}

上面代码中的row的类型是mysql_common::row::Row,这种类型把数据以字节的形式存储。所以这里需要把低级的字节转换成我们想要的类型比如i32,String等,这里我使用了from_row。注意,转换后的数据以元组的形式返回,其中每一项和选择列的顺序相同。

聚合查询结果

其实, 可以将查询结果收集到Vec中。 Vec中的每个元素都是一个元组。


#![allow(unused)]
fn main() {
// 输出到Vec
let res: Vec<(i32, String, i32, String, NaiveDate)> =
	conn.query("select * from student").unwrap();
for r in res {
    println!("{}, {},{},{}, {:?}", r.0, r.1, r.2, r.3, r.4);
}
}

query函数已经将字节转换为选择的数据类型,因此不需要再转换了。 需要注意的就是,这里必须明确元组的数据类型。 否则,编译器没办法做转换。

结果到结构体

使用元组也可以。 但是我们实际写代码时,数据表列数多,最普遍的做法就是定义一个结构体。比如这里叫Student, 然后,可以使用query_map将查询结果映射到Student对象。这里 不需要置顶元组的数据类型,编译器会自动推导字段类型根据Student类型


#![allow(unused)]
fn main() {
struct Student {
    id: u64,
    name: String,
    age: u16,
    id_card: String,
    last_changed_on: NaiveDate,
}

let res = conn.query_map(
    "select * from student",
    |(id, name, age, id_card, update)| Student {
        id: id,
        name: name,
        age: age,
        id_card: id_card,
        last_changed_on: update,
    },
).expect("Query failed.");

for i in res {
    println!(
        "{}, {},{},{}, {:?}",
        i.id, i.name, i.age, i.id_card, i.last_changed_on
    )
}
}

单条数据查询

查询特定数据行,可能会出现下面几种情况

  • 找到,返回实际数据
  • 没有找到行
  • 发生错误

所以,使用query_first函数返回的是Option的结果。 需要将其解包两次才可以获取实际的行数据。


#![allow(unused)]
fn main() {
 // 条件查询,查询单个数据
let res = conn.query_first("select * from student where name = '张三'")
.map(
    // Unpack Result
    |row| {
        row.map(|(id, name, age, id_card, update)| Student {
            id: id,
            name: name,
            age: age,
            id_card: id_card,
            last_changed_on: update,
        })
    },
);

match res.unwrap() {
    Some(student) => println!(
        "{}, {},{},{}, {:?}",
        student.id, student.name, student.age, student.id_card, student.last_changed_on
    ),
    None => println!("Sorry no student found."),
}
}

命名参数的使用


#![allow(unused)]
fn main() {
 let res = conn
        .exec_first(
            "select * from student where name = :name",
            params! {
                "name" => "李四"
            },
        )
        .map(
            // Unpack Result
            |row| {
                row.map(|(id, name, age, id_card, update)| Student {
                    id: id,
                    name: name,
                    age: age,
                    id_card: id_card,
                    last_changed_on: update,
                })
            },
        );
}

总结

  • 经常使用的时间处理库:chrono
  • 流式查询使用: query_iter
  • 输出到Vec使用:query
  • 映射到结构体使用: query_map
  • 获取单条数据使用:query_first
  • 命名参数查询使用:exec_first

「系列」设计模式之工厂模式

作者:苏胤榕(DaviRain) / 后期编辑:张汉东


创建型设计模式 之 工厂模式

工厂方法模式 (虚拟构造函数,Virtual Constructor, Factory Method)

意图

工厂方法模式是一种创建型设计模式,其在父类中提供一个创建对象的方法,允许子类决定实例化对象的类型。在Rust中的实现就是提供一个抽象的trait,结构体实现该trait。

问题

假如你正在开发一款应用,最初的版本只能处理的业务逻辑只有单一的一个,比如开始只有简单的邮寄个人信的业务。而后面随着业务的扩大,需要增加邮寄公司订单业务。

如果代码其余部分与现有的类已经存在耦合关系,那么向程序中添加新类其实没有那么容易。

如果以后需要在程序支持另一种新的业务类型,很可能需要再次对这些代码进行大幅修改。

最后,你将不得不编写纷繁复杂的代码,根据不同的业务类,在应用中进行不同的处理。

解决方案

工厂方法模式建议使用特殊的工厂方法代替对象构造函数的直接调用。对象的创建仍然通过new运算符,只是该运算符改在工厂中调用。工厂方法返回的对象通常被称作“产品”。

虽然看似很简单,我们只是改变了程序中调用构造函数的位置。但是我们可以在子类中重写工厂方法,从而改变其创建产品的类型。(这里的话在Rust中是有新的结构体实现抽象的trait)仅当这些产品具有共同的基类或者接口时,子类才能返回不同类型的产品,同时基类中的工厂方法还应该将其返回类型声明为这一共有接口。

工厂方法模式结构

  • 产品将会对接口进行声明。对于所有由创建者及其子类构建的对象,这些接口都是通用的。
  • 具体产品是产品接口的不同实现。
  • 创建者类声明返回产品对象的工厂方法。该方法的返回对象必须与产品接口相匹配。你可以将工厂方法声明为抽象方法,强制要求每个子类以不同的方式实现该方法。或者,也可以在基础工厂方法中返回默认产品类型。注意。尽管它的名字是创建者,但他最主要的职责并不是创建产品。一般来说,创建者类包含一些与产品相关的核心业务逻辑。工厂方法将这些逻辑处理从具体产品类中分离出来。

示例结构图

代码

enum ProductType {
    Product1,
    Product2,
}

// 定义接口
trait Product {
    fn show(&self);
}

// 工厂模式
trait Factory {
    fn make_product(&self, product_type : ProductType) -> Box<dyn Product>;
}

struct ConcreteProduct1(String);
struct ConcreteProduct2(String);

impl Product for ConcreteProduct1 {
    fn show(&self) {
        println!("red color, {}", self.0);
    }
}

impl Product for ConcreteProduct2  {
    fn show(&self) {
        println!("blue color, {}", self.0);
    }
}

struct SimpleFactory;

impl SimpleFactory {
    fn new() -> Self {
        Self
    }
}

impl Factory for SimpleFactory {
    fn make_product(&self, color_type : ProductType) -> Box<dyn Product> {
        match color_type {
            ProductType::Product1 => Box::new(ConcreteProduct1("blue".to_string())),
            ProductType::Product2 => Box::new(ConcreteProduct2("red".to_string())),
        }
    }
}

fn main() {
    let factory =  SimpleFactory::new();
    let product = factory.make_product(ProductType::Product1);
    product.show();
    let product = factory.make_product(ProductType::Product2);
    product.show();
}

工厂方法模式适合应用的场景

  • 当你在编写代码的过程中,如果无法预知对象确切类别及其依赖关系时,可使用工厂方法
    • 工厂方法将创建产品的代码和实际使用产品的代码分离,从而能在不影响其他代码的情况下扩展产品创建部分代码
  • 如果你希望用户能扩展你软件库或架构的内部组件,可使用工厂方法
    • 通过将需要实现的共同特性的接口特性抽象为trait, 当有新的结构体时,将该结构体实现拥有共同特性的trait。从而实现新组件的假如,而不会破坏别的代码结构。
  • 如果你希望复用现有对象对象来节省系统资源,而不是每次都重新创建对象,可使用工厂方法。

实现方法

  • 让所有的产品都遵循统一trait接口,该接口必须声明对所有产品都有意义的方法
  • 在工厂trait中添加一个工厂方法,该方法的返回类型都必须遵循通用的产品接口(返回的是由Box包裹起来的trait对象)
  • 在创建者代码中找到对于产品构造函数的所有引用,将它们依次替换为对于工厂方法的调用。,同时将创建产品的代码移入工厂方法。
  • 为工厂方法中的每种产品编写一个结构体,然后将该结构体实现抽象出来的统一trait,并将基本方法中的相关创建代码移动到工厂方法中。
  • 如果代码经过上述移动之后,基础工厂方法中已经没有任何代码,你可以将其转变为抽象trait方法。如果基础工厂方法中还有其他语句,你可以将其设置为该方法的默认行为。

工厂方法模式优缺点

  • 优点
    • 你可以避免创建者和具体产品之间的紧密耦合
    • 单一职责原则,你可以将产品创建代码放在程序的单一位置,从而使得代码更容易维护
    • 开闭原则,无需更改现有客户端代码你就可以在程序中引入新的产品类型。
  • 缺点
    • 应用工厂方法模式需要引入许多新的子类,代码可能会因此变得更复杂。最好的情况是将该模式引入创建者类的现有层次结构中。

「译」数据操作:Rust vs Pandas

译者:pi-pi-miao / 后期编辑:张汉东

原文:Data Manipulation: Pandas vs Rust


Rust requires a lot more work compared to Pandas, but, Rust is way more flexible and performant.

与 pandas 相比,rust 需要做更多的工作,但是 rust 使用起来更灵活,更出色

介绍

pandas 是 python 的主要数据分析包,但是由于很多原因,如果没有使用 numpty 等工具的话,原生 python 在数据分析等方面性非常差,pandas 是由 Wes McKinney 开发的,并且将这些操作封装到漂亮的 api 中,方便 python 开发者使用其进行数据分析

rust 因为具有出色的数据性能,这也是为什么 rust 不需要像 pandas 那样进行 api 的包装

我相信在 rust 进行数据操作的方法是构建一堆数据结构,但是我可能理解错了,如果是这样的话,请告诉我

下面是我的经验和推理用来比较 rust 和 pandas

数据

性能基准是在这个非常随机的数据集上完成的:这里,它提供了大约160,000行/ 130列,总大小为 150Mb 的数据,这个数据集的大小对应于我经常遇到的数据集类型,这就是我选择这个数据集的原因,他并不是世界上最大的数据集,更多的学习应该在更大的数据集上进行

已经合并将使用另一个随机数据集已经完成 这里, theWDICountry.csv

1、读取和即时数据

[pandas]

在 pandas 读取和即时数据非常简单,默认情况会处理很多数据质量问题

import pandas as pd

path = "/home/peter/Documents/TEST/RUST/terrorism/src/globalterrorismdb_0718dist.csv"
df = pd.read_csv(path)

[rust] 读取 CSV 文件

对于 rust 来说,管理质量差的数据是非常乏味的,在有些数据集中,有些字段是空的,有些行格式不好,有些没有使用 utf-8 编码

要打开 csv,我使用了 csv crate ,它不但能解决上面所有的问题,所以读取可以使用 csv


#![allow(unused)]
fn main() {
let path = "/home/peter/Documents/TEST/RUST/terrorism/src/foo.csv"
let mut rdr = csv::Reader::from_path(path).unwrap();
}

由于格式化质量差,我的使用如下


#![allow(unused)]
fn main() {
use std::fs::File;    
use encoding_rs::WINDOWS_1252;
use encoding_rs_io::DecodeReaderBytesBuilder;

// ...

    let file = File::open(path)?;
    let transcoded = DecodeReaderBytesBuilder::new()
        .encoding(Some(WINDOWS_1252))
        .build(file);
    let mut rdr = csv::ReaderBuilder::new()
        .delimiter(b',')
        .from_reader(transcoded);
}

[参考]https://stackoverflow.com/questions/53826986/how-to-read-a-non-utf8-encoded-csv-file*

[rust]即时数据

为了实现数据的即时化,我使用Serde 将我的数据序列化和反序列化

要使用 Serde,需要对数据进行 struct 化,使用 struct 是我的代码遵循基于模型的编程范式,每个字段都有一个定义好的类型,它还能让我能在 struct 之上实现 trait 和方法

然而,我想要的数据有130列...而且它看起来没有办法自动生成 struct的 定义,为了避免手动定义,我必须构建自己的结构生成器


#![allow(unused)]
fn main() {
fn inspect(path: &str) {
    let mut record: Record = HashMap::new();

    let mut rdr = csv::Reader::from_path(path).unwrap();

    for result in rdr.deserialize() {
        match result {
            Ok(rec) => {
                record = rec;
                break;
            }
            Err(e) => (),
        };
    }
    // Print Struct
    println!("#[skip_serializing_none]");
    println!("#[derive(Debug, Deserialize, Serialize)]");
    println!("struct DataFrame {{");
    for (key, value) in &record {
        println!("    #[serialize_always]");

        match value.parse::<i64>() {
            Ok(n) => {
                println!("    {}: Option<i64>,", key);
                continue;
            }
            Err(e) => (),
        }
        match value.parse::<f64>() {
            Ok(n) => {
                println!("    {}: Option<f64>,", key);
                continue;
            }
            Err(e) => (),
        }
        println!("    {}: Option<String>,", key);
    }
    println!("}}");
}
}

生成的 struct 如下


#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use serde_with::skip_serializing_none;

#[skip_serializing_none]
#[derive(Debug, Clone, Deserialize, Serialize)]
struct DataFrame {
    #[serialize_always]
    individual: Option<f64>,
    #[serialize_always]
    natlty3_txt: Option<String>,
    #[serialize_always]
    ransom: Option<f64>,
    #[serialize_always]
    related: Option<String>,
    #[serialize_always]
    gsubname: Option<String>,
    #[serialize_always]
    claim2: Option<String>,
    #[serialize_always]

    // ...
}

skip_serializing_none : 避免在 csv 中出现空字段的错误

serialize_always : 固定写入 csv 的时候的字段的数量

现在我有了自己的结构体,我使用 serde 序列化来填充结构体的向量


#![allow(unused)]
fn main() {
 let mut records: Vec<DataFrame> = Vec::new();

    for result in rdr.deserialize() {
        match result {
            Ok(rec) => {
                records.push(rec);
            }
            Err(e) => println!("{}", e),
        };
    }
}

这生成了我的向量结构体,赞

一般来说,在使用rust的时候,你不应该期望像使用 python 那样流畅的工作

结论

在读取/实例化数据的时候,pandas轻而易举的赢得了rust的csv

2、过滤

[pandas]

pandas 的过滤方法有很多种,对我来说最常见的方法是


#![allow(unused)]
fn main() {
df = df[df.country_txt == "United States"]
df.to_csv("python_output.csv")
}

[rust]

要在 rust 中使用过滤,可以参考 rust 的向量文档

有一大堆向量的过滤方法,有狠多还是 nightly 的特性,这些特性在发布的时候非常适合数据操作,对于这个用例我使用了 retain 方法,因为它完全符合我的需求


#![allow(unused)]
fn main() {
  records.retain(|x| &x.country_txt.unwrap() == "United States");
    let mut wtr =
        csv::Writer::from_path("output_rust_filter.csv")?;

    for record in &records {
        wtr.serialize(record)?;
    }
}

pandas 和 rust 的最大区别是 rust 过滤使用了闭包(比如 python 中的 lambda 函数)而 pandas 过滤式基于列的 pandas API,这意味着 rust 可以制造更复杂的过滤器,在我看来这也增加了可读性

性能

时间内存(Gb)
pandas3.0s2.5 Gb
rust1.6s 🔥 -50%1.7 Gb 🔥 -32%

即使我们使用 pandas 的 api 来过滤,我们也可以使用 rust 获得更好的性能

结论

在过滤这方面,rust 更快,并且性能更好

3、分组

[pandas]

分组式 python 中使用 pipline 的重要组成部分,如下:

df = df.groupby(by="country_txt", as_index=False).agg(
    {"nkill": "sum", "individual": "mean", "eventid": "count"}
)
df.to_csv("python_output_groupby.csv")

[rust]

对于分组 感谢: David Sanders 分组恶意使用下面


#![allow(unused)]
fn main() {
use itertools::Itertools;


// ...

#[derive(Debug, Deserialize, Serialize)]
struct GroupBy {
    country: String,
    total_nkill: f64,
    average_individual: f64,
    count: f64,
}

// ... 

    let groups = records
        .into_iter()
        // .sorted_unstable_by(|a, b| Ord::cmp(&a.country_txt, &b.country_txt))
        .group_by(|record| record.country_txt.clone())
        .into_iter()
        .map(|(country, group)| {
            let (total_nkill, count, average_individual) = group.into_iter().fold(
                (0., 0., 0.),
                |(total_nkill, count, average_individual), record| {
                    (
                        total_nkill + record.nkill.unwrap_or(0.),
                        count + 1.,
                        average_individual + record.individual.unwrap_or(0.),
                    )
                },
            );
            lib::GroupBy {
                country: country.unwrap(),
                total_nkill,
                average_individual: average_individual / count,
                count,
            }
        })
        .collect::<Vec<_>>();
    let mut wtr =
        csv::Writer::from_path("output_rust_groupby.csv")
            .unwrap();

    for group in &groups {
        wtr.serialize(group)?;
    }
}

虽然这个解决方案不像 pandas 那样优雅,但是为这种场景提供了更好的灵活性

我认为除了 sum and fold 之外,更多的 reduction 方法将会大大提高 rust 中 map-reduce 式操作的开发体验。

性能

时间内存(Gb)
pandas2.78s2.5 Gb
rust2.0s🔥 -35%1.7Gb🔥 -32%

结论:

虽然性能更好的是 rust,我建议在 map-reduce 方法使用 pandas,因为它似乎更合适。

4、Mutation

[pandas]

在 pandas 身上做 mutation 的方法有很多,我通常为了性能和功能风格做下面的方式

df["computed"] = df["nkill"].map(lambda x: (x - 10) / 2 + x ** 2 / 3)
df.to_csv("python_output_map.csv")

[rust]

rust 在 mutation 可以使用 iter


#![allow(unused)]
fn main() {
   records.iter_mut().for_each(|x: &mut DataFrame| {
        let nkill = match &x.nkill {
            Some(nkill) => nkill,
            None => &0.,
        };

        x.computed = Some((nkill - 10.) / 2. + nkill * nkill / 3.);
    });

    let mut wtr = csv::Writer::from_path(
        "output_rust_map.csv",
    )?;
    for record in &records {
        wtr.serialize(record)?;
    }
}

性能

时间内存(Gb)
pandas12.82s4.7Gb
rust1.58s🔥 -87%1.7Gb🔥 -64%

在我看来 mutation 就是 pandas 和 rust 的区别所在,pandas 在这方面表现非常糟糕

结论

rust 天生适合 mutation 操作

5. Merge

[python]

一般来说 merge 操作在 python 中式非常高效的


#![allow(unused)]
fn main() {
df_country = pd.read_csv(
    "/home/peter/Documents/TEST/RUST/terrorism/src/WDICountry.csv"
)

df_merge = pd.merge(
    df, df_country, left_on="country_txt", right_on="Short_Name"
)
df_merge.to_csv("python_output_merge.csv")
}

[rust]

对于 rust 的 struct 来说这是一个棘手的部分,对我来说解决合并的办法式添加一个嵌套字段,这里包含我们要合并的另一个结构体,我首先为新数据创建一个新的结构体和新的堆


#![allow(unused)]
fn main() {
#[skip_serializing_none]
#[derive(Clone, Debug, Deserialize, Serialize)]
struct DataFrameCountry {
    #[serialize_always]
    SNA_price_valuation: Option<String>,
    #[serialize_always]
    IMF_data_dissemination_standard: Option<String>,
    #[serialize_always]
    Latest_industrial_data: Option<String>,
    #[serialize_always]
    System_of_National_Accounts: Option<String>,
    //...

// ...

    let mut records_country: Vec<DataFrameCountry> = Vec::new();
    let file = File::open(path_country)?;
    let transcoded = DecodeReaderBytesBuilder::new()
        .encoding(Some(WINDOWS_1252))
        .build(file);
    let mut rdr = csv::ReaderBuilder::new()
        .delimiter(b',')
        .from_reader(transcoded); 

    for result in rdr.deserialize() {
        match result {
            Ok(rec) => {
                records_country.push(rec);
            }
            Err(e) => println!("{}", e),
        };
    }
}

然后,我将这个新结构与前面的结构克隆到一个惟一的特定字段上。


#![allow(unused)]

fn main() {
impl DataFrame {
    fn add_country_ext(&mut self, country: Option<DataFrameCountry>) {
        self.country_merge = Some(country)
    }
}

//...

    for country in records_country {
        records
            .iter_mut()
            .filter(|record| record.country_txt == country.Short_Name)
            .for_each(|x| {
                x.add_country_ext(Some(country.clone()));
            });
    }
    let mut wtr =
        csv::Writer::from_path("output_rust_join.csv")
            .unwrap();
    for record in &records {
        wtr.serialize(record)?;
    }
}

为了方便和更好的可比性,我复制了数据,但是如果您能够管理它,可以传递引用。

好了!🚀

除此之外,嵌套结构在 CSV 中还不能序列化 对于 rust 这里

所以我必须把它改写成:


#![allow(unused)]
fn main() {
impl DataFrame {
    fn add_country_ext(&mut self, country: Option<DataFrameCountry>) {
        self.country_ext = Some(format!("{:?}", country))
    }
}
}

最后我们归并

性能

时间内存(Gb)
pandas22.47s11.8Gb
rust5.48s🔥 -75%2.6 Gb🔥 -78%

结论

Rust 可以通过嵌套结构体的方式来实现和 pandans 一样的 merge 功能这并不是真正的一对一比较,在这种情况下,这将取决于您的用例。

最后的结论

这次比较之后,我的收获如下

​ 使用 pandas 的时候,可以 使用小的 csv(<1M行),进行简单的操作数据清理

​ 使用 rust 的时候,你可以进行复杂的操作,内存大或者耗时的 piplines,可以自定义构建函数,扩展软件

rust 和 pandas 相比,rust 提供了非常好的灵活性,以及 rust 比 pandas 可以使用多线程的能力,可以并行操作,我相信 rust 可以解决 pandas 不能解决的问题

此外在任何平台上( web,安卓或者嵌入式 )上运行 rust 也是 pandas 无法做到的,并且 rust 也可以为尚未解决的挑战提供了新的解决方案

性能

性能表也给了我们更加深入了解 rust 的期望,我相信对于大数据处理方面,rust 会提高2-50倍的性能提升,随着时间的推移,rust 比着 python 内存使用量会大大的减少

免责声明

在很多方面,pandas 可以被优化,但是优化式有代价的,无论使硬件(例如集群 Cluster #Dask, GPU #Cudf),还是依赖于这些优化包的可靠性和维护。

我非常喜欢使用原生 rust 的原因是,rust 不需要额外的硬件,也不需要额外的软件包,此解决方案不需要额外的抽象层,这使得 rust 在很多方面更加直观

代码库

Git repository

点击这里

「译」Unsafe Rust 的取舍

译者: ( MATRIXKOONiZerin ) / 后期编辑: 张汉东

原文:Unsafe Rust: How and when (not) to use it


在本中,我将说明您需要了解的有关unsafe Rust的所有信息。我将专注于以下几个方面来讲解。

  • 有关于unsafe Rust代码的误解
  • 什么时候不使用unsafe代码
  • 处理未初始化的内存
  • 不可处理的异常
  • 内在机制
  • 内联汇编
  • 接口外部功能
  • 编写unsafe Rust代码的工具

关于unsafe Rust代码的几个误解

在解释如何以及何时使用unsafe Rust(或不使用)之前,我想先说明一些关于 Rust 中不安全代码的常见误解。

###误解1:所有 Rust 代码都不安全 不是的。 区别是微妙的,但是安全的 Rust 代码不能违反安全保证,只要编译器和它所构建的不安全代码都没有任何允许这种情况发生的bug。 因此,与其他低级语言不同,在其他低级语言中,代码的安全性取决于每一行代码和编译器实现,您可以显著减少需要审核错误的攻击面。

RustBelt 项目从数学上证明,如果你有一部分安全代码和一部分不安全代码来保护它的不变量,那么只要不安全代码不允许,安全代码就不能破坏保证。

顺便说一句,不变量是一种条件,它不会被某一类型的所有方法或模块的所有函数改变。

从统计上讲,在 Crates.io 上发现的 Rust 代码中,只有不到1%是不安全的代码-这可能不到现有代码的50%,但仍然应该是一个足够有代表性的样本-而且许多项目里没有任何不安全的代码行。

误解2:它依赖于标准库代码,其中包含许多不安全因素

是的,标准库有比普通项目更多的不安全代码,这是意料之中的,因为它提供的许多抽象不能在Safe Rust中有效地实现,或者根本不能实现。 此外,我们可以放心,标准库已经收到了更多的审查,因此比您的项目更值得信赖。

这并不是说它是完美的-毕竟,过去也发现了很多错误。 尽管如此,在验证和模糊标准库的大部分方面还是做了很好的工作,这使得所有的 Rust 代码都更加安全。

误解3:一旦我们编写了 unsafe 代码,就感觉打开了地狱之门

不是的。 Rustonomicon 仔细地列出了不安全代码授予您的额外权限,以换取您保证维护该部分代码中的安全常量。 例如,您可以:

  • 取消引用原始指针
  • 调用unsafe函数(包括C函数、编译器内部函数和原始分配器)
  • 实现unsafetraits
  • 改变静态变量
  • 访问联合体内的成员

然而,即使是那些看起来可能是安全的代码,也可能会产生你应该注意的后果:

  • 原始指针不能保证指向它们声称要指向的数据的有效实例,因此取消对它们的引用可能会导致空域(很可能是分段错误),或者更糟糕的是,会导致类型混乱,在这种情况下,您破坏了数据,破坏了程序的状态,可能会造成不利的影响。
  • 调用unsafe函数有使用不符合其安全要求的参数调用它们的风险,这可能会造成可利用的漏洞。
  • 为未能维护其不变式的类型实现unsafe traits 还可能导致调用方无意中导致其安全要求失败,并可能造成不利的影响。
  • 在另一个线程中观察静态变量时对其进行改变可能会导致数据竞争和读取异常,这意味着读取的值不再需要与旧值或新值相关。 根据读取的类型和使用方式的不同,这也可能导致未定义的行为,可能会造成不利的影响。
  • 访问联合体的字段可能会让您将数据解释为它们不表示有效实例的类型,或者观察未初始化的数据(如果类型的长度不同,一个字段可以包括另一个字段的填充),这两种情况都会导致未定义的行为和可能被利用的漏洞。

因此,尽管不安全代码并不像某些人所说的那样可怕,但需要小心安全地处理它。 然后,您可以在不安全代码的基础上编写安全代码。

误解4:只要不会有人意外地使用参数调用我的安全代码,从而导致下面的不安全代码失败,我就没问题

不是的。 一旦您在不安全的代码之上提供了一个安全接口,您的代码要么无论如何都会维护安全常量,要么您的代码是不健全的。

有些人对不健全的感觉非常强烈,没有理由大发雷霆。 这仍然是一个错误,你应该公开和冷静地解决它。 如果错误可以通过更仔细的设计来解决,那就去做吧。 同时,您可以公开声明您的代码不健全,用户需要格外小心,以免违反安全常量。 如果你想出了一个合理的设计,就把所有发布的不完善的版本都撤回,并报告漏洞。

误解5:我试过运行它,它运行得很好

未定义行为的问题不在于它会直接失败。 事实上,它可能永远不会失败。 在您将代码投入生产之前,它也可能一直正常工作,在这一点上,它可能会灾难性地失败。 或者,它可能会起作用,直到黑客尝试它,并手工制作正确的输入,以破解您的不健全的代码。 现在,您的所有用户都在其PC上安装了密码勒索特洛伊木马程序。

即使多次运行它,也不能保证下一次就能正常工作。 正如哥伦比亚号航天飞机灾难所显示的那样,仅仅因为它成功了135次并不意味着它在第136次尝试中就不会失败。

###但如果这只发生在内存泄漏时呢? 内存泄漏无法可靠地避免,并且本身不会对内存安全构成任何危险-尽管操作系统可能会停止您的程序,或者在您耗尽可用内存时简单地崩溃,但这在最坏的情况下会导致拒绝服务。 因此,它被认为超出了内存安全保障的范围,mem::forget成为一个安全函数。 如果您的代码依赖于一些不泄漏的值来保证安全性,那么在某些时候,泄漏可能就会发生,安全保证的损失将由您承担。

请注意,这个误解是如此常见,直到Rust1.0之后,它才最终允许安全代码泄漏。 这种不安全来源的解决方案通常是泄漏放大-在尝试不安全操作之前,泄漏所有可能由泄漏导致的可观察到的损坏状态,然后将所有东西重新组合在一起。 这样,泄漏会变得更大,有时甚至更大,但至少不会破坏内存安全。

好的,我明白了。 一旦我的项目中有一行不安全的代码,所有其他代码都必须小心,以免违反安全规则

因此,通常的做法是在模块中创建不安全的代码。 这通常不打算从外部使用,但有时它可以是公开的,因为如果人们想要承担随后的责任以换取性能(或其他东西),他们可能会在代码中使用不安全的方法。

下一步是编写另一个模块,该模块将使用前面提到的不安全代码提供安全接口。 这个模块应该是允许所有其他用例的最低抽象,如果您愿意的话,它是核心功能。 省略所有可以通过构建此安全代码来实现的内容。 这是需要仔细审核的部分。

最后,在您的安全API之上编写您想让人们使用的实际接口。 因为您处于安全的 Rust 区域,所以这段代码不需要太多关注。 如果您在核心接口实现上做得很好,编译器将维护其所有保证。

既然我们已经消除了与unsafe Rust 代码相关的误解,在进入实际代码之前,我们只有一件事要讨论。

什么时候不该用 unsafe

通常, unsafe 实际上是在追求性能的过程中使用的。但是,正如我在 How to write CRaP Rust code 中所写的那样,即使只是作为要测试的快照和作为基准进行基准测试,也应该始终运行较慢但是安全的版本。

只是因为使用 unsafe 的代码有时可以更快,并不意味着一定要这样做。合理的评估情况, 在保持原有速度或者更快的时候, 就应该继续使用 safe 版本的代码。

例如,在尝试加快 Benchmark Game 条目之一作为练习时,我想通过使用数组而不是 Vec 来减少内存分配,这需要一些不安全的代码来处理未初始化的数据。但是,事实证明改用数组的版本要比基于 Vec 的版本慢,因为这个我就没继续下去了。克里夫·比佛尔( Cliff L. Biffle )在 Benchmark Game 一书中也谈到了类似的经历。 使用 unsafe 不仅会让编译器减少对代码的安全性的保证,也减少了要做的检查,因此实际上可能会禁用某些优化以避免破坏代码。因此,在切换到 unsafe 之前,请务必先进行评估,进尽量保持 safe 的代码。

好的,让我们开始吧!

处理未初始化的内存

当 Rust 版本更新到1.0.0时,标准库有一个 unsafe 函数来获取未初始化的内存: std::mem::uninitialized (还有 std::mem::zeroed(),但是两者之间的唯一区别是 后者将返回的内存区域用0初始化)。

人们普遍认为这是个坏主意,现在该函数已经处于废弃 (deprecated) 状态,建议使用std::mem::MaybeUnit 类型代替。 未初始化的麻烦的原因是,该值可能会在恐慌 (panic)或在之前返回时隐式删除。 例如:


#![allow(unused)]
fn main() {
let x = std::mem::uninitialized();
this_function_may_panic();
mem::forget(x);
}

如果 this_function_may_panic 函数实际上 panic 了,则x会在我们调用 forget 之前就被 drop 。但是,删除未初始化的值是未定义的行为,并且由于删除通常是隐式的,因此很难避免这种情况。因此, MaybeUninit 被设计为能够处理潜在的未初始化数据。该类型永远不会自动 drop (如 std::mem::ManuallyDrop ),编译器知道该类型可能未初始化,并且具有许多函数来正确处理未初始化的数据。

让我们回顾一下。我们可能不会调用 std::ptr::read 去读未初始化内存。我们甚至不能引用它( & 或者 &mut ),因为引用规则要求引用的值必须是引用类型的有效实例,而未初始化的数据不是这种情况(除了使用 MaybeUninit<_> ,因为这显然不需要初始化)。

因此,我们也不应该 drop,因为这会创建一个可变的引用(注意,fn drop(&mut self)) 。我们可以将其转换为其他允许储存未初始化数据的类型( 创建一个未初始化的数组还是最简洁的方法)或用 std :: ptr :: write 到从 as_mut_ptr() 方法获得的指针,又或者使用 MaybeUninit 等等。请注意,即使类型未初始化,我们也可以分配给 MaybeUninit ,因为类型不会 drop

举例来说,假设我们要使用一个函数创建一个值数组。数组的没有实现 Copy 或没有const 的 initializer ,或者 LLVM 由于某种原因无法优化 double 。这种时候就用 unsafe


#![allow(unused)]
fn main() {
use std::mem::{MaybeUninit, transmute};

unsafe {
    // first part: initialize the array. This is one of the rare cases where
    // directly calling `assume_init` is OK, because an array of
    // `MaybeUninit` may contain uninitialized data.
    
    // 第一部分: 初始化这个数组。这种就是几种特殊情况之一,直接
    // 调用 `assume_init` 是可行的,因为 `MaybeUninit` 可以放未初		// 始化数组
    let mut array: [MaybeUninit<MyType>; 256] =
        MaybeUninit::uninit().assume_init();

    // second part: initialize the data. This is safe because we assign
    // to a `MaybeUninit`, which is exempt from `drop`.
    
    //  第二部分: 初始化数据。因为 `Maybeuninit` 避免了 `drop`
    for (i, elem) in array.iter_mut().enumerate() {
        *elem = MaybeUninit::new(calculate_elem(i));
    }

    // third part: transmute to the initialized array. This works because
    // `MaybeUninit<T>` is guaranteed to have the same Layout as `T`.
    
   // 第三部分: 转换成初始化过的数组。这是可行的,因为 `MaybeUninit<T>` 保证了数据填充是和 `T` 一致的
    transmute::<_, [MyType; 256]>(array)
}
}

如果任何 calculate_elem(_) 调用失败,则将删除整个 MaybeUninits 数组。 因为 MaybeUninit 不会 drop ,所以现在所有的数据都将泄漏。

为了避免这种情况,我们应该加入额外的移动语义的部分:


#![allow(unused)]
fn main() {
use std::mem::{forget, MaybeUninit, transmute};

// first extra part: We need a "guard" that drops all *initialized* elements
// on drop

// 加入的第一个额外部分: 我们需要一个 "guard"(守护) 来销毁所有初始化过的元素

struct Guard<'a> {
    // a mutable ref to access the array from
    array: &'a mut [MaybeUninit<MyType>; 256],
    // the index until which all elements are initialized
    index: usize,
    // 一个可变引用允许用索引访问,使得所有元素都被初始化
}

impl Drop for Guard<'_> {
    // drop all elements that are initialized
    // 销毁所有被初始化的元素
    fn drop(&mut self) {
        for i in 0..self.index {
            unsafe {
                std::ptr::drop_in_place(self.array[i].as_mut_ptr());
            }
        }
    }
}

unsafe {
    let mut array: [MaybeUninit<MyType>; 256] =
        MaybeUninit::uninit().assume_init();

    // second extra part: here we initialize the guard. From here on, it
    // borrows our array mutably. All access will be done through the guard
    // (because the borrow checker won't let us access `array` directly
    // while it's mutably borrowed).
    
    // 加入的第二个额外部分: 这里我们用 `&mut array` 初始化了 `guard`。
    // (当使用可变借用时,借用检查器并不让我们直接访问数组)
    let mut guard = Guard { array: &mut array, index: 0 };
    for i in 0..256 {
        guard.array[guard.index] = MaybeUninit::new(calculate_elem(i));
        // update the index so `drop` will include the newly created element.
        // 更新索引,使得包含新创建的元素
        guard.index += 1;
    }
    // third extra part: forget the guard to avoid dropping the initialized
    // elements and also end the borrow.
    
    // 加入的第三部分: 调用 `forget` ,销毁 `guard`,防止内存泄漏
    forget(guard);

    transmute::<_, [MyType; 256]>(array)
}
}

如果你认为只是为了初始化数组居然就需要这么多步骤,你是对的。同样,在这一点上,一定要衡量对性能的影响。当然不是和 Vec <MyType> 比。

无论如何,这显示了处理未初始化数据的规范方法:标志一个不变量(“在index 之前的都应该不可变 ”),对其进行维护(“写入一个元素后索引增加”),你可以从中受益——在这种情况下,panic 时也不会发生泄漏。

这种方法也适用于其他数据结构,但那里通常使用不同的不变量。例如,Vecresize 操作前, 将内存拷贝一份放在后备内存里,并将长度设置为零,并在操作完成后将其恢复(发生泄漏时,泄漏的内存其实是比 vec 长度要大的)。这确保了 drop 不会用在未初始化或已经释放的内存上。

取决于不同的数据机构,这个不变量将变的晦涩难懂。例如,可以使用一个 bit set 对初始化的元素进行编码,因此需要比普通数组多八分之一的内存,但允许随机访问任意元素。在这种情况下,将是“初始化和 bit set 一一对应”。 Rust的 HashMaps 基本上是这样做的。

译者注: bit set 其实就是位示图法

不可变的转换

Rust关于引用的规则非常严格——在每个给定的时间点可以读取或写入多少变量。 但是有时候我们确实需要稍微改变规则。

为此,Rust为内部可变性提供了一种(显然得用 unsafe)类型,它可以使用 get(&self)方法从不可变的借项中获得一个可变的指针(当然不是引用)。 还有一个get_mut(&mut self) 的方法,该方法向内容返回可变的借用。

这意味着编译器将假定所有 UnsafeCell 中的内容都被引用了。 标准库在顶层提供了许多安全抽象,特别是 CellRefCellRwLockMutex 和各种Atomic*类型。

译者注: Atomic* 的意思是以Atomic开头的类型 , 这是正则表达式的一种

例如,AtomicBool 的定义如下(为简洁起见删除了注释):


#![allow(unused)]
fn main() {
pub struct AtomicBool {
    v: UnsafeCell<u8>,
}
}

当然,该实现必须确保避免数据竞争(是指在非线程安全的情况下,多线程对同一个地址空间进行写操作),通过 LLVM 内部函数使用实际的原子操作来完成的。 我还没有检查即将到来的 Cranelift 后端的功能,但它似乎也有某种类似的实现。 同样,在直接使用 UnsafeCell 之前,请检查是否有任何 safe 的替代品可用,虽然确定 unsafe 是否会给你带来足够的性能提升(或其他好处)。

使用CPU的特有指令集

Rust的标准库在std:: arch 模块中有按CPU类型分类的固有函数。它们都被定义为 unsafe 的,主要是因为它们可能未在你的CPU上实现。 幸运的是,有一种规范的方法可以确保你在编译时或运行时拥有相应的CPU。

假设你以"标准"的方式编写了算法,看了生成的汇编,觉得编译器生成的代码不够好。是时候掏出"屠龙刀"了。 但基本上就是在 Rust 里编写汇编了(因为 arch 内部函数是和不同架构一一对应的)。 如上所述,你需要确保用户的平台和你的一致。 以下代码显示了在编译时和运行时检查 CPU是否支持的方法:


#![allow(unused)]
fn main() {
// compile time check 编译时检查
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
mod simd {
    fn calculate() -> .. {
        // this is a std library macro that allows us to safely access CPU
        // features by doing runtime detection
        // 这里是标准库的一个宏,通过它我们可用进行CPU的编译器检查
        
        if is_x86_feature_detected!("avx2") {
            // code using avx2 intrinsics here
        } else if is_x86_feature_detected!("sse2")
            // code using sse2 intrinsics here
        } else {
            // code using no special features, using e.g. avx2 intrinsics
            // would be UB!
        }
    }
}

#[cfg(not(any(target_arch = "x86", target_arch = "x86_64")))]
mod simd {
    // fallback code here
}
}

此示例仅具有针对x86和x86_64架构的专用代码,以及各种运行时检测到的CPU功能。 如果你希望程序在其他平台(例如ARM-NEON)上使用SIMD内部函数,则需要添加另一个 #[cfg] 声明。不用多说,最后你就得写上不少代码。

除了可用性之外,一些指令还要考虑内存对齐。 为了简化 bit ,内存对齐告诉我们有些地址的最后几位必须为零。 例如,一个32位值的对齐方式可能为 4 ,这意味着其地址的最后两位应为零。 请参阅库文档以获取详细信息,并参考 上一章 以获取适当的帮助。

内联汇编

假设你正在编写内核,并且需要使用堆栈指针来处理一些"奇怪"的事情,或者其他不得不需要用汇编的地方。 Rust有两个 FFI 接口:一个是C语言,另一个是汇编。 不幸的是,这既不稳定又不安全,所以你就得使用nightly 版本来编译#[feature(asm)]unsafe 的块。 显然,Rust无法检查你在汇编中所做的事情。 使用内联汇编的细节不在本文讨论范围之内。 请查看 Rust Book chapter 或者 RFC text。 就本文而言,不需要你熟悉 FFI

与其他语言交互

假如你有非常庞大的 C 代码库,并希望将其移至 Rust,不用多说,这是一项艰巨的任务。你可以先使用外部函数接口来重写代码库的一小部分,然后逐个模块进行重写,直到整个程序都用 Rust 编写,然后你就可以扔掉C了(😀)(顺带一说 librsvg 就是这样做的)。或者在 C ++ 中使用 Rust。

无论如何,你都得在安全的,充满温暖的 Rust 世界与艰难而又无情的世界之间架起一座桥梁。既然外面的世界很危险,显然你得用 unsafe 与之交互。 首先,请确保你拥有正确的接口,以免你在调试时踩坑。 bindgen(用于从 Rust 访问 C )和cbindgen(用于从 C 访问 Rust)的工具箱就显得非常有用。 如果你从 C ( 使用C接口的C++ ) 访问 Rust,请注意对象的生命周期,并将Rust对象的生存期保留在Rust代码中-也就是说,让Rust删除它们; 而对于 C 的指针,就让 C 处理它们。众所周知,Rust 有非常特殊的管理生命周期的方法,记住你的需求。

另一方面,如果将 C((或是C++ 使用了 extern ) 包装在Rust中,则会发现 C 库通常还需要考虑数据的生命周期。一旦有了绑定,就尝试考虑类型的生命周期的问题。 unofficial patterns book 有这样一章 instructive chapter

如果与 C++ 交互,需要使用 cxx 。但是,请注意,与通常的 bindings generators 不同,cxx 不会用 unsafe 标记你的函数 !我在这里的看法是,绑定机制部分是在 Rust 中构建的,一部分是在 C++ 中构建的,它是否是 safe 的,这取决于你审查 C++ 的代码。你可能仍然希望将结果接口包装在一个友好的接口中,并且不会以不安全的方式被滥用。

编写 unsafe rust 的工具

正如我们所见,编写 unsafe Rust比 safe 的 Rust 需要多做许多工作,因为这样将不再依靠编译器检查。 因此,要踏上这条道路,最好先"带上"下面的这些工具:

Miri

Miri Rustc 的 MIR (中级中间语言),这是 Rust 在移交给 LLVM 或者Cranelift之前用来优化程序的中间表示,是一种解释器。 你可以用 rustup 运行 rustup component add miri 安装它。 它跑在 cargo miri 上-例如, cargo miri test 将在解释器中运行测试。 Miri 采用了很多可以检测未定义行为的技术,例如访问未初始化的数据,并会指出问题所在。 但是,它只会在被执行的代码上检测未定义行为,因此不能完整的覆盖代码。

Clippy and Rust lints

Rust的官方 lints (静态分析工具) 中有很多对编写 unsafe 有帮助的 lint。 至少,missing_safety_docs棉绒将帮助您将所有不安全方法的要求记录在案。 另外,Rust编译器默认情况下不会激活所有棉绒; 致电rustc -W help将显示最新列表。

Prusti

Prusti 仍在开发中(目前在更新到最新的稳定Rust时存在一些问题,因此最新的稳定版本针对某些2018 Rust编译器),但是这个工具非常有前景,可让你在数学上验证给定特定条件的代码的安全性。

基本上,你可以用数学证明代码中某些不变量是 "真的" 保持不变的,这对于必须支持不安全代码的不变量的安全抽象是理想的。 有关更多信息,请参见用户指南

Fuzzers

Rust Fuzz Book 列出了许多可与Rust一起使用的 Fuzzer (模糊测试器)。目前,可使用的Fuzzercargo-fuzz/libfuzzerAmerican Fuzzy Lop / afl.rs的。两者都将为的代码创建大量的测试输入,并运行它以查找触发崩溃的某种组合。

译者注: 这个地方的 Fuzzer 是安全测试里面常用的模糊测试工具, 常见的就是上文提到的AFL 。而这种随机生成的测试样例被用于保证代码覆盖率, 每当遇到 crash (崩溃的样例) 并会记录下, 提示此处可能有漏洞。

为了检测未初始化内存的使用,libdiffuzz是一个侵入式内存分配器,它将使用不同的值来初始化每个内存分配。通过运行两次代码并比较结果,可以确定未初始化内存的哪一部分是造成了问题。更不错的是,memory sanitizer 是每天更新的(tracking lssue列出了各种memory sanitizer及其在各个平台上的支持),会监测每一次对未初始化内存的读取,哪怕没有造成任何问题。

尽管从统计学上讲, Fuzzer 比普通的属性测试更有可能找到代码路径,但不能保证他们会在任何时间后找到特定的代码路径。比如说我曾经遇到的标记化函数的 bug,就是由我在互联网上的一个随机文档中发现的一个 unicode 宽的空格触发的,当在运行了数十亿个测试案例的模糊测试一周后也并未发现。Rust fuzz 在 trophy case 上展示了不少没被 Fuzzing 到的 bug。如果你找到一个同样的 bug ,请添加它。

译者注: Fuzzer 不保证100%的代码覆盖率, 因为种子是随机的

rutenspitz 是一个过程宏,非常适合对状态代码(例如数据结构)进行模型测试。模型测试意味着您拥有一个 “模型” ,即一个简单但缓慢的版本,可以对要确保的行为进行建模,然后使用它来测试不安全的实现。然后它将生成一系列操作,以测试相等关系是否成立。如果你遵循了我的上述建议,则应该已经有一个安全的实施方案可以进行测试。

LogRocket: Full visibility into production Rust apps

(广告时间到) 调试 Rust 应用程序是艰辛的,当我们遇到难以重现的问题时更是如此。 如果希望监视和跟踪 Rust 应用程序的性能,自动显示错误以及跟踪缓慢的网络请求和加载时间,请尝试 LogRocket

LogRocket 就像Web应用程序中的 DVR ,实际上记录了 Rust 应用程序中发生的所有事情。 它可以汇总并报告问题发生时应用程序所处的状态,而不用猜测为什么会发生问题。 LogRocket 还会监视应用程序的性能,报告诸如客户端CPU负载,客户端内存使用情况等指标。

走一条 rust debug 现代化之路吧! 点击即用.

「译」基于 Rust 用 Bevy 实现节奏大师游戏

译者:suhanyujie/ 后期编辑:张汉东

原文链接:https://caballerocoll.com/blog/bevy-rhythm-game/

本文相关:

Rust中文社群线上学习室 | 使用 Bevy 实现节奏大师游戏 ⁣>

Rust 中文社群 飞书群 邀请你加入:

  • 对话群: https://applink.feishu.cn/TeLAcbDR
  • 话题群:https://applink.feishu.cn/TeLD868w

正文

在这个教程中,我们基于 Rust 使用 Bevy 引擎实现一个节奏大师游戏。目的是展现如何用 Bevy 实现一些东西,特别是一些更高级的功能,如着色器,状态,和音频。

如果你想在进入学习之前看看最终的代码,你可以在这里找到仓库,并且下面是一个游戏视频:

视频资源

这款游戏很简单:箭头飞过屏幕,玩家必须在正确的时间内按下正确的方向键才能让箭头消失。如果玩家成功地做到了这一点,他们将获得积分。否则,箭头会旋转着掉下来。箭头会有不同的速度,每个箭头颜色不同。游戏还有一个选择歌曲的菜单,以及一个简单的地图制作器来帮助创建歌曲地图。

Bevy

Bevy 是一个数据驱动的游戏引擎。它使用起来非常简单,令人愉悦。它使用 ECS 来管理游戏实体及其行为。

Bevy 有一个很受欢迎的社区,所以如果你对本教程有任何疑问,可以查阅 Bevy book,浏览[示例]](https://github.com/bevyengine/bevy/tree/master/examples),或者加入官方的 Discord 进行提问。

如果你发现教程中存在错误,请在这里开一个 Issue,我会修正它。

前期准备

在本教程中,你需要熟悉 Rust。你不必成为专家,我们不会使用任何的黑魔法。虽然不是必须的,但强烈建议你去了解一下 ECS 的工作原理。

如果你想阅读一些更简单的教程,我建议你阅读基于 Rust,使用 Bevy 实现贪吃蛇,或者 Bevy 实现国际象棋教程,可以详细了解基础知识。

此外,我们将在本教程中使用着色器和 GLSL。这两种知识不是必须的,因为我会提供要使用的代码,但了解 GLSL 会使你可以修改更多的东西,并让游戏真正属于你自己的。

如果你之前从未使用过着色器,可以参考下面这些推荐链接开始学习:

创建一个项目

和往常一样,我们使用 cargo new bevy_rhythm && cd bevy_rhythm 创建一个空 Rust 项目。你现在可以打开该 crate 项目。并用你喜欢的编辑器打开 Cargo.toml,把 bevy 加入到依赖项中:


#![allow(unused)]
fn main() {
[package]
name = "bevy_rhythm"
version = "0.1.0"
authors = ["You <your@emailhere.com>"]
edition = "2018"

See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
bevy = "0.4"
}

快速编译

我建议你启用快速编译,以确保开发过程不会太烦躁。以下是我们需要准备的:

  • 1.LLD 链接器:普通链接器会有点慢,所以我们把其换成 LLD 链接器进行加速:
    • Ubuntu: sudo apt-get install lld
    • Arch: sudo pacman -S lld
    • Windows: cargo install -f cargo-binutils and rustup component add llvm-tools-preview
    • MacOS: brew install michaeleisel/zld/zld
  • 2.为该项目启用 Rust 的 nightly 版本:rustup 工具链安装 nightly 版,并且在项目目录中设置 rustup 为 nightly 进行启用。
  • 3.把这个文件的内容拷贝到 bevy_rhythm/.cargo/config 中。

以上就是所有要准备的事情了,现在运行游戏来编译所有的库。编译完成后,你应该在命令行中看到 Hello, world!

注意:如果你看到游戏性能很差,或者看到加载资源很慢,你可以用 cargo run --release 的编译模式下运行。编译时间可能会稍长一些,但游戏运行会更加流畅!

开始

任何 Bevy 游戏的第一步都是增加小段示例代码来启动应用的。打开 main.rs,并将已有的 main 函数替换为下面的内容:

use bevy::{input::system::exit_on_esc_system, prelude::*};

fn main() {
    App::build()
        // 抗锯齿设置 samples 为 4
        .add_resource(Msaa { samples: 4 })
        // 设置 WindowDescriptor 资源修改标题和窗口大小
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_plugins(DefaultPlugins)
        .add_system(exit_on_esc_system.system())
        .run();
}

如果你使用 cargo run 运行程序,你会看到一个空白窗口:

这一步设置 Bevy App,添加默认插件。这将包括转换、输入、窗口等游戏运行所需的元素。如果你不需要这些功能, Bevy 是模块化的,你可以选择只开启你需要的功能。我们要新增这些插件,所以需要使用 add_pluginsDefaultPlugins

我们还添加了两个资源:MsaaWindowDescriptor,分别用于配置 anti-aliasing,以及窗口大小和标题。最后,我们添加了 Bevy 的 exit_on_esc_system,它的作用是按下 esc 键时关闭游戏。

Bevy 中的 ECS

下面是 ECS 如何在 Bevy 中工作的介绍。如果你已经知道它是如何工作的,可以跳过本节。这和我们的游戏无关,我将使用 Bevy book 中的例子来说明它是如何运作的。你不需要复制这里的代码,只需读懂它即可。

Bevy 的 ECS 是 hecs 的一个分支版本。它使用 Rust 结构体作为组件,不需要添加宏或其他复杂的东西。例如:


#![allow(unused)]
fn main() {
// 有两个字段的结构体组件
struct Position { 
    x: f32,
    y: f32
}

// 元组组件
struct Name(String);

// 我们甚至可以使用标记组件
struct Person;
}

Systems are just normal Rust functions, that have access to Querys: >这个“系统”中可以使用正常的 Rust 函数,访问 Querys


#![allow(unused)]
fn main() {
fn set_names(mut query: Query<(&Position, &mut Name), With<Person>>) {
    for (pos, mut name) in query.iter_mut() {
        name.0 = format!("position: ({}, {})", pos.x, pos.y);
    }
}
}

一次查询可以访问组件中所有实体。在前面的示例中,query 参数允许我们迭代包括 Person 组件在内以及 PositionName 等组件实体。因为我们用 &mut Name 替代 &Name,所以可以对实体进行修改。如果对 &Name 类型的该值进行修改,Rust 会报错。

有时候我们想要只在游戏开始时运行一次的机制。我们可以通过“启动系统”来做到这一点。“启动系统”和“普通系统”完全一样,唯一的区别是我们将如何把它加到游戏中,这会在后面进行详细讲解。下面是一个使用 Commands 生成一些实体的“启动系统”:


#![allow(unused)]
fn main() {
fn setup(commands: &mut Commands) {
    commands
        .spawn((Position { x: 1., y: 2. }, Name("Entity 1".to_string())))
        .spawn((Position { x: 3., y: 9. }, Name("Entity 2".to_string())));
}
}

Bevy 也有资源的概念,它可以保存全局数据。例如,内置的 Time 资源给我们提供游戏中的当前时间。为了在“系统”中使用这类资源,我们需要用到 Res


#![allow(unused)]
fn main() {
fn change_position(mut query: Query<&mut Position>, time: Res<Time>) {
    for mut pos in query.iter_mut() {
        pos.x = time.seconds_since_startup() as f32;
    }
}
}

我们自定义资源也很简单:


#![allow(unused)]
fn main() {
// 一个简单的资源
struct Scoreboard {
    score: usize,
}

// 另一个资源,它实现了 Default trait
#[derive(Default)]
struct OtherScore(f32);
}

我们有两种方法初始化资源:第一种是使用 .add_resource 并提供我们需要的结构体,另一种是实现了 DefaultFromResources.init_resource

下面我们如何把它们加到游戏中:

fn main() {
    App::build()
        // 新增资源的第一种方法
        .add_resource(Scoreboard { score: 7 })
        // 第二种方法,通过 Default 的初始化加载资源
        .init_resource::<OtherScore>()

        // 增加“启动系统”,游戏启动时只会运行一次
        .add_startup_system(setup.system())
        // 增加一个“普通系统”,每一帧都会运行一次
        .add_system(set_names.system())
        .add_system(change_position.system())
        .run();
}

Another cool thing Bevy has are Plugins, which we've already seen when we used DefaultPlugins in the previous section. Plugins allow us to wrap features that belong together, which then let's us enable and disable them together easily. Plugins also provide organization, which is the main purpose we'll be creating our own in this tutorial. >Bevy 还有一个很酷的东西是插件,我们在上一节使用 DefaultPlugins 时看到了。插件可以让我们将一些特性包装在一起,这可以让我们很容易地启用和禁用它,插件也提供了组织功能,这也是我们在这篇教程中自定义插件地主要功能点。

如果有些东西不清楚,不用担心,我们会在后面更详细地解释所有内容。

增加系统设置

每个游戏都需要一个相机来渲染对象,所以我们将从如何添加一个生成相机的“启动系统”开始。因为这是一款 2D 游戏,所以我们要使用 Camera2dBundle

use bevy::{input::system::exit_on_esc_system, prelude::*};

fn main() {
    App::build()
        // 设定[抗锯齿](https://cn.bing.com/search?q=%E7%BB%98%E5%88%B6+%E6%8A%97%E9%94%AF%E9%BD%BF&qs=n&form=QBRE&sp=-1&pq=%E7%BB%98%E5%88%B6+%E6%8A%97%E9%94%AF%E9%BD%BF),samples 参数值为 4
        .add_resource(Msaa { samples: 4 })
        // 设定 WindowDescriptor 资源,定义我们需要的标题和窗口大小
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_startup_system(setup.system()) // <--- New
        .add_plugins(DefaultPlugins)
        .add_system(exit_on_esc_system.system())
        .run();
}

fn setup(commands: &mut Commands) {
    commands.spawn(Camera2dBundle::default());
}

bundle 是组件的集合。在本例中,Camera2dBundle 将创建一个包含 CameraOrthographicProjectionVisibleEntitiesTransformGlobalTransform 的 实体。其中大部分是我们玩游戏时不需要用到的,所以我们使用抽象的 Camera2dBundle 添加组件。

注意:我们还可以使用一个元组代替 bundle 来添加所有组件:


#![allow(unused)]
fn main() {
fn setup(commands: &mut Commands) {
    commands.spawn((Camera::default(), OrthographicProjection::default(), VisibleEntities::default(), Transform::default(), GlobalTransform::default()));
}
}

这段代码实际上还不能运行,因为我们还需要在 camera 和投影组件中设置一些字段,但我觉得它明确地体现了使用 bundle 和元组来添加结构是很相似的。

加载精灵

在这部分中,我们会添加一些“精灵”,让它们四处移动。为此,我们需要创建一个 assets 目录,我们将存储一些图像字体文件。目录中有两个子文件夹,图像和字体。你可以点击前面提到的链接,从 GitHub 仓库下载。

你的资源目录应该如下所示:

assets
├── fonts
│   └── FiraSans-Bold.ttf
└── images
    ├── arrow_blue.png
    ├── arrow_border.png
    ├── arrow_green.png
    └── arrow_red.png

我们将使用带颜色的箭头来表示不同速度的箭头,并使用带边框的箭头来标记目标区域。

有了这些静态资源,我们就可以开始编写一些游戏动画了。我们将创建一个 arrows.rs 文件,它将包含生成,移动,清除箭头等相关操作。首先要做的是为“箭头精灵”保留资源,这样我们就不必在每次创建箭头时重新加载它们:


#![allow(unused)]
fn main() {
use bevy::prelude::*;

/// 为箭头保留材料和资源
struct ArrowMaterialResource {
    red_texture: Handle<ColorMaterial>,
    blue_texture: Handle<ColorMaterial>,
    green_texture: Handle<ColorMaterial>,
    border_texture: Handle<ColorMaterial>,
}
impl FromResources for ArrowMaterialResource {
    fn from_resources(resources: &Resources) -> Self {
        let mut materials = resources.get_mut::<Assets<ColorMaterial>>().unwrap();
        let asset_server = resources.get::<AssetServer>().unwrap();

        let red_handle = asset_server.load("images/arrow_red.png");
        let blue_handle = asset_server.load("images/arrow_blue.png");
        let green_handle = asset_server.load("images/arrow_green.png");
        let border_handle = asset_server.load("images/arrow_border.png");
        ArrowMaterialResource {
            red_texture: materials.add(red_handle.into()),
            blue_texture: materials.add(blue_handle.into()),
            green_texture: materials.add(green_handle.into()),
            border_texture: materials.add(border_handle.into()),
        }
    }
}
}

通过实现 FromResources trait,在我们调用 .init_resource::<ArrowMaterialResource>() 时,Bevy 会管理并初始化资源,在进程中加载图片。

如你所看到的,实际的资源加载是 Handle<ColorMaterial> 而不是 ColorMaterials。这样,当我们创建箭头实例时,我们可以使用对应的 handle,并且它们将复用已存在的资源,而不是每个都各自独有一份。

生成并移动箭头

我们接下来要做的是生成箭头并在屏幕上移动它们。我们从实现每秒生成一个箭头的“系统”开始。箭头会包含一个名为 Arrow 的空(结构体)组件:


#![allow(unused)]
fn main() {
/// 箭头组件
struct Arrow;

/// 跟踪何时生成新箭头
struct SpawnTimer(Timer);

/// 生成箭头
fn spawn_arrows(
    commands: &mut Commands,
    materials: Res<ArrowMaterialResource>,
    time: Res<Time>,
    mut timer: ResMut<SpawnTimer>,
) {
    if !timer.0.tick(time.delta_seconds()).just_finished() {
        return;
    }

    let transform = Transform::from_translation(Vec3::new(-400., 0., 1.));
    commands
        .spawn(SpriteBundle {
            material: materials.red_texture.clone(),
            sprite: Sprite::new(Vec2::new(140., 140.)),
            transform,
            ..Default::default()
        })
        .with(Arrow);
}
}

在这个系统中,我们使用了 Timer,这是 Bevy 中执行每隔 x 秒重复操作的最佳方式。我们使用 newtype 模式进行封装,这样我们能够把 SpawnTimer 与其他的定时器区分开。我们需要使用形如 .add_resource(SpawnTimer(Timer::from_seconds(1.0, true))) 的调用方式进行初始化,调用稍后会进行。将 true 作为参数值传递表示计时器结束时会再次重复执行。

要使用计时器,我们必须手动调用它的 tick 方法,入参 time 是距离上次调用所间隔的时间差,然后我们可以使用 just_finished 来查看定时器是否完成。实际上我们所做的是提前检查定时器是否完成来确保 spawn_arrows 系统每秒只运行一次。

系统的其余部分将创建一个 Transform 组件,我们将其添加到箭头组件中,它会返回 SpriteBundle 从而生成箭头,并给箭头实体一个来自 ArrowMaterialResource 的红色纹理。我们使用 Commands 中的 with 方法添加了 Arrow 组件。这样,我们创建的实体将拥有所有的 SpriteBundleArrow 组件。

注意:这个系统只是临时的,并且它会被在某个特定时间内生成箭头的东西所覆盖。

现在,我们生成的那些箭头就在那了,我们需要用另一个系统让它们向右移动:


#![allow(unused)]
fn main() {
/// 箭头前移
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, _arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * 200.;
    }
}
}

move_arrows 使用 Query 来获取所有带有 TransformArrow 组件的实体,并通过增加 x 坐标值来将它们向右移动一点点。我们还使用了 Time::delta_seconds() 来根据当前帧到上一帧的时间来增加距离。

我们把这些 ArrowMaterialResourceSpawnTimer 等系统连接到一个插件中:


#![allow(unused)]
fn main() {
pub struct ArrowsPlugin;
impl Plugin for ArrowsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app
            // 初始化资源
            .init_resource::<ArrowMaterialResource>()
            .add_resource(SpawnTimer(Timer::from_seconds(1.0, true)))
            // 增加 system
            .add_system(spawn_arrows.system())
            .add_system(move_arrows.system());
    }
}
}

我们现在可以将 main.rs 改为如下内容:

use bevy::{input::system::exit_on_esc_system, prelude::*};

mod arrows;
use arrows::ArrowsPlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin) // <--- New
        .run();
}

fn setup(commands: &mut Commands) {
    commands.spawn(Camera2dBundle::default());
}

我们需要做的只是增加 .add_plugin(ArrowsPlugin),这样所有的系统和资源就被正确地集成在 arrows.rs 中。

如果你运行程序,你会看到箭头在屏幕上飞舞:

视频资源

类型和常量

我们在上一节中对一些值硬编码了。因此我们需要重新使用它们,我们要新建一个小模块来保存我们的常量。创建一个名为 consts.rs 的文件,并添加以下内容:


#![allow(unused)]
fn main() {
/// 箭头移动的速度
pub const BASE_SPEED: f32 = 200.;

/// 箭头生成时的 X 坐标值,应该在屏幕之外
pub const SPAWN_POSITION: f32 = -400.;

/// 箭头应该被正确点击时的 X 坐标值
pub const TARGET_POSITION: f32 = 200.;

/// 点击箭头时的容错间隔
pub const THRESHOLD: f32 = 20.;

/// 箭头从刷出到目标区域的总距离
pub const DISTANCE: f32 = TARGET_POSITION - SPAWN_POSITION;
}

其中一些常数稍后才会用到。在 main.rs 中增加 mod consts,以导入模块使其可用。我们可以在 arrows.rs 中的 spawn_arrowsmove_arrows 替换掉对应硬编码的值。


#![allow(unused)]
fn main() {
use crate::consts::*;

fn spawn_arrows(
    commands: &mut Commands,
    materials: Res<ArrowMaterialResource>,
    time: Res<Time>,
    mut timer: ResMut<SpawnTimer>,
) {
    if !timer.0.tick(time.delta_seconds()).just_finished() {
        return;
    }

    let transform = Transform::from_translation(Vec3::new(SPAWN_POSITION, 0., 1.));
    commands
        .spawn(SpriteBundle {
            material: materials.red_texture.clone(),
            sprite: Sprite::new(Vec2::new(140., 140.)),
            transform,
            ..Default::default()
        })
        .with(Arrow);
}

/// 箭头前移
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, _arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * BASE_SPEED;
    }
}
}

现在我们的箭头在屏幕上移动,但他们都面向相同的方向、相同的速度移动,且颜色相同。为了能够区分它们,我们将创建两个不同的枚举,一个用于表示方向(上、下、左、右),一个表示速度(慢、中、快)。

注意:我们把它叫做 Directions 而非 Direction,因为后者是一个 Bevy 枚举。通过给它取一个稍微不一样的名字,防止混淆带来的麻烦。

让我们创建一个 types.rs 文件,并把上面提到的枚举值放于其中:


#![allow(unused)]
fn main() {
use crate::consts::*;
use bevy::input::{keyboard::KeyCode, Input};
use core::f32::consts::PI;

#[derive(Copy, Clone, Debug, PartialEq)]
pub enum Directions {
    Up,
    Down,
    Left,
    Right,
}
impl Directions {
    /// 检查相应的方向键是否被按下
    pub fn key_just_pressed(&self, input: &Input<KeyCode>) -> bool {
        let keys = match self {
            Directions::Up => [KeyCode::Up, KeyCode::D],
            Directions::Down => [KeyCode::Down, KeyCode::F],
            Directions::Left => [KeyCode::Left, KeyCode::J],
            Directions::Right => [KeyCode::Right, KeyCode::K],
        };

        keys.iter().any(|code| input.just_pressed(*code))
    }

    /// 返回此方向的箭头的旋转角度
    pub fn rotation(&self) -> f32 {
        match self {
            Directions::Up => PI * 0.5,
            Directions::Down => -PI * 0.5,
            Directions::Left => PI,
            Directions::Right => 0.,
        }
    }

    /// 返回此方向的箭头的 y 坐标值
    pub fn y(&self) -> f32 {
        match self {
            Directions::Up => 150.,
            Directions::Down => 50.,
            Directions::Left => -50.,
            Directions::Right => -150.,
        }
    }
}
}

首先,我们添加 Directions 枚举。并且已经实现了三种不同的方法。

key_just_pressed,用于检查被按下的方向键。我已经决定增加 D, F, J, K 作为可能的键,因为我键盘上的方向键比较小。如果你是 FPS 玩家,你可以使用 W, S, A, D,或者 VIM 世界的 K, J, H, L 来替代它们。

注意:如果你不太习惯使用迭代器,下面是用传统的方法实现 key_just_pressed


#![allow(unused)]
fn main() {
/// 检查与方向对应的按键是否被按下
pub fn key_just_pressed(&self, input: &Input<KeyCode>) -> bool {
    match self {
        Up => input.just_pressed(KeyCode::Up) || input.just_pressed(KeyCode::D),
        Down => input.just_pressed(KeyCode::Down) || input.just_pressed(KeyCode::F),
        Left => input.just_pressed(KeyCode::Left) || input.just_pressed(KeyCode::J),
        Right => input.just_pressed(KeyCode::Right) || input.just_pressed(KeyCode::K),
    }
}
}

rotation 表示我们需要将“箭头精灵”旋转多少度以将其指向正确的方向。y 表示箭头的 y 坐标值。我决定把箭头的顺序调整为 Up, Down, Left, Right,但如果你喜欢其他顺序,你可以自己修改。


#![allow(unused)]
fn main() {
#[derive(Copy, Clone, Debug)]
pub enum Speed {
    Slow,
    Medium,
    Fast,
}
impl Speed {
    /// 返回箭头移动的实际速度
    pub fn value(&self) -> f32 {
        BASE_SPEED * self.multiplier()
    }
    /// Speed 乘数
    pub fn multiplier(&self) -> f32 {
        match self {
            Speed::Slow => 1.,
            Speed::Medium => 1.2,
            Speed::Fast => 1.5,
        }
    }
}
}

接下来,我们添加了 Speed 枚举。我们实现了两个方法:一个是乘法,它表示箭头应该相对于 BASE_SPEED 所移动的距离;另一个是 value,它是执行乘法运算得到的值。

这是一部分代码,我不希望特别复杂!接下来要添加的类型是 ArrowTimeSongConfig。前者记录何时生成一个箭头,以及它的方向和速度。第二个将保存所有箭头实体的列表:


#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug)]
/// 跟踪记录箭头应该在什么时候生成,以及箭头的速度和方向。
pub struct ArrowTime {
    pub spawn_time: f64,
    pub speed: Speed,
    pub direction: Directions,
}

#[derive(Debug)]
pub struct SongConfig {
    pub arrows: Vec<ArrowTime>,
}
}

我们的 ArrowTime 有个问题。在内部,我们需要知道箭头什么时候生成,但在生成它时,我们希望指定应该在什么时候点击它。因为每个箭头都有不同的速度,所以仅仅减去几秒是不够的。为了解决这个问题,我们要创建一个 new 函数,包含 click_timespeeddirection,并设置相应的 spawn_time


#![allow(unused)]
fn main() {
impl ArrowTime {
    fn new(click_time: f64, speed: Speed, direction: Directions) -> Self {
        let speed_value = speed.value();
        Self {
            spawn_time: click_time - (DISTANCE / speed_value) as f64,
            speed,
            direction,
        }
    }
}
}

为了进行测试,我们将创建一个函数,它返回硬编码的 SongConfig,其中包含了不同的速度和方向的箭头:


#![allow(unused)]
fn main() {
pub fn load_config() -> SongConfig {
    SongConfig {
        arrows: vec![
            ArrowTime::new(1., Speed::Slow, Directions::Up),
            ArrowTime::new(2., Speed::Slow, Directions::Down),
            ArrowTime::new(3., Speed::Slow, Directions::Left),
            ArrowTime::new(4., Speed::Medium, Directions::Up),
            ArrowTime::new(5., Speed::Fast, Directions::Right),
        ],
    }
}
}

最后,我们可以进入 main.rs 并将 setup 系统修改成下方所示:


#![allow(unused)]
fn main() {
mod types;

fn setup(commands: &mut Commands) {
    let config = types::load_config();

    commands
        .spawn(Camera2dBundle::default())
        .insert_resource(config);
}
}

注意:我们使用 insert_resource 替代 add_resourceinit_resource,因为后者是 AppBuilder,前者是用在 Commands 中。

如果我们现在运行游戏,没有任何变化,但仍然是能运行的,这很棒!我们进入 arrows.rs 文件,修改它使它能根据 SongConfig 中的列表生成箭头。

定时生成箭头

现在我们有了一个要生成的箭头列表,我们可以删除所有定时器的内容,并修改 spawn_arrows 系统来检查每一帧刷出的箭头。

我们可以想到的第一个实现是循环遍历 SongConfig 中的所有箭头,并检查哪些箭头应该在当前帧中生成。这是可行的,但我们会在每一帧都循环遍历一个可能会很大的数组。我们硬编码的只有 5 个箭头,这不成问题,但一整首歌的情况下,箭头可能会超过 1000 个,就算电脑很快,玩家也不希望游戏让它们的 CPU “热”起来。

相反,我们将假设 SongConfig 中的箭头是有序的。我们需要在歌曲开始前将它们进行排序,这很简单。了解了这一点,我们只能先检查列表中的第一个箭头,如果它应该被生成出来,我们也会检查下一个箭头,一次类推,直到我们到达那个不需要再生成的箭头为止。由于箭头是有序的,如果一个箭头不需要生成,那么其后的箭头也无需生成。在这之后,我们需要移除列表中已经被生成的箭头。

我们还需要给 Arrow 新增 SpeedDirections 字段:


#![allow(unused)]
fn main() {
// 在顶部
use crate::types::*;

/// “精灵实体”上的组件
struct Arrow {
    speed: Speed,
    direction: Directions,
}

/// 生成箭头
fn spawn_arrows(
    commands: &mut Commands,
    mut song_config: ResMut<SongConfig>,
    materials: Res<ArrowMaterialResource>,
    time: Res<Time>,
) {
    // 我们得到了从启动到当前的时间(secs)以及到最后一次迭代的时间(secs_last),这样我们就可以检查是否有箭头应该在这个窗口中生成。

    // 歌曲在启动后 3 秒开始,所以减去 3 秒。
    let secs = time.seconds_since_startup() - 3.;
    let secs_last = secs - time.delta_seconds_f64();

    // 计数器用于计算列表中产生和删除箭头数量
    let mut remove_counter = 0;
    for arrow in &song_config.arrows {
        // 列表是有序的,所以我们遍历检查直到第一个不满足条件为止
        // 检查箭头是否应该在当前帧和下一帧之间的时间点生成
        if secs_last < arrow.spawn_time && arrow.spawn_time < secs {
            remove_counter += 1;

            // 根据速度得到与之匹配的箭头素材(纹理)
            let material = match arrow.speed {
                Speed::Slow => materials.red_texture.clone(),
                Speed::Medium => materials.blue_texture.clone(),
                Speed::Fast => materials.green_texture.clone(),
            };

            let mut transform =
                Transform::from_translation(Vec3::new(SPAWN_POSITION, arrow.direction.y(), 1.));
            // 按一定的方向旋转箭头
            transform.rotate(Quat::from_rotation_z(arrow.direction.rotation()));
            commands
                .spawn(SpriteBundle {
                    material,
                    sprite: Sprite::new(Vec2::new(140., 140.)),
                    transform,
                    ..Default::default()
                })
                .with(Arrow {
                    speed: arrow.speed,
                    direction: arrow.direction,
                });
        } else {
            break;
        }
    }

    // 移除列表中生成的箭头
    for _ in 0..remove_counter {
        song_config.arrows.remove(0);
    }
}
}

上面这段代码,我们来分析一下它。

在“系统”开始时,我们先获取游戏已经开始多久了,以及“系统”最后一次运行的时间点。我们使用 delta_seconds_f64 来获取,它返回自最后一次游戏更新以来的时间。有了这两个值,我们就能知道该生成哪个箭头。因为 Bevy 不会每纳秒都更新(不代表所有的游戏引擎),所以如果只是简单地检查 spawn_time 是否等于当前时间会导致我们跳过需要处理的箭头。例如,我们可能有一个箭头,它刷出的时间被设为 3.0。Bevy 可以在 2.99 时运行这个“系统”,然后 3.01 时运行一次。由于箭头被指定为在 3.0 时生成,它就与运行“系统”的时间不匹配,导致它永远不会生成。

我们换个方法,在“系统”开始时检查当前时间和最后结束时的时间,对于上面的举例,在第二次运行该“系统”时,就会有 secs = 3.01 以及 secs_last = 2.99,因为我们的箭头产生的时间超过 secs_last,但小于下一帧的 secs,所以能够生成。大功告成!

有了这个,我们可以对 move_arrows 做一下小修改,让它兼顾速度的影响,可以使用我们之前创建的 Speed::value() 方法:


#![allow(unused)]
fn main() {
/// 把箭头向前移动
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * arrow.speed.value();
    }
}
}

很酷,现在每个箭头都显示了正确的颜色,并以相应的速度移动:

视频资源

增加目标区域箭头

现在我们将使用 border_texture 去创造目标箭头,以便玩家能够知道何时应该按下按键。为此,我们将创建另一个“启动系统”,setup_target_arrows 以及一个标记组件,TargetArrow


#![allow(unused)]
fn main() {
struct TargetArrow;

fn setup_target_arrows(commands: &mut Commands, materials: Res<ArrowMaterialResource>) {
    use Directions::*;
    let directions = [Up, Down, Left, Right];

    for direction in directions.iter() {
        let mut transform =
            Transform::from_translation(Vec3::new(TARGET_POSITION, direction.y(), 1.));
        transform.rotate(Quat::from_rotation_z(direction.rotation()));
        commands
            .spawn(SpriteBundle {
                material: materials.border_texture.clone(),
                sprite: Sprite::new(Vec2::new(140., 140.)),
                transform,
                ..Default::default()
            })
            .with(TargetArrow);
    }
}
}

为了创建四个箭头,我们创建了一个有四个方向值的数组,然后循环调用 border_texture 和空的 TargetArrow 组件。

不要忘记在 ArrowsPlugin 中添加 setup_target_arrows 作为“启动系统”:


#![allow(unused)]
fn main() {
pub struct ArrowsPlugin;
impl Plugin for ArrowsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ArrowMaterialResource>()
            .add_startup_system(setup_target_arrows.system())
            .add_system(spawn_arrows.system())
            .add_system(move_arrows.system());
    }
}
}

好了,我们现在把“目标区域箭头”准备好了。

视频资源

按键按下时清除箭头

现在我们有了目标箭头,我们接下来要实现一个“系统”,它的作用是,当箭头刷出时,并且如果在特定的阈值内,用户点击了正确的操作键,箭头就会消失。我们将创建一个名为 despawn_arrows 的新“系统”:


#![allow(unused)]
fn main() {
/// 用户在箭头到达尽头前按下正确的按键,箭头消失。
fn despawn_arrows(
    commands: &mut Commands,
    query: Query<(Entity, &Transform, &Arrow)>,
    keyboard_input: Res<Input<KeyCode>>,
) {
    for (entity, transform, arrow) in query.iter() {
        let pos = transform.translation.x;

        // 检查按下按键时,是否是在特定的阈值内
        if (TARGET_POSITION - THRESHOLD..=TARGET_POSITION + THRESHOLD).contains(&pos)
            && arrow.direction.key_just_pressed(&keyboard_input)
        {
            commands.despawn(entity);
        }

        // 当箭头离开屏幕时,箭头消失
        if pos >= 2. * TARGET_POSITION {
            commands.despawn(entity);
        }
    }
}
}

我们使用 Query 来查询所有实现了 TransformArrow 的实体。我们在查询中添加了 Entity,这样可以访问实体的“id”,然后我们可以在 Commands::despawn() 中根据它来消除实体。然后我们循环所有箭头,并检查 x 坐标值是否在点击的阈值内,如果是,则消除箭头。还有第二个检查,当箭头被错过离开屏幕时,它在最后也会被消除。它是在 x 坐标值大于等于 2. * TARGET_POSITION 时消除。

记得用 .add_system(despawn_arrows.system()) 将“系统”添加到 ArrowsPlugin 中,这样,运行游戏时,当我们斜着看的时候,也可以将其视为一种游戏!

增加基础 UI

在这一节中,我们将实现一些基本的 UI,目前只是显示了歌曲中的当前时间。我们会把它保存在 ui.rs 中:


#![allow(unused)]
fn main() {
use bevy::prelude::*;

fn setup_ui(
    commands: &mut Commands,
    asset_server: ResMut<AssetServer>,
    mut color_materials: ResMut<Assets<ColorMaterial>>,
) {
    let font = asset_server.load("fonts/FiraSans-Bold.ttf");
    let material = color_materials.add(Color::NONE.into());

    commands
        // 时间文本节点
        .spawn(NodeBundle {
            style: Style {
                position_type: PositionType::Absolute,
                position: Rect {
                    left: Val::Px(10.),
                    top: Val::Px(10.),
                    ..Default::default()
                },
                ..Default::default()
            },
            material: material.clone(),
            ..Default::default()
        })
        .with_children(|parent| {
            parent
                .spawn(TextBundle {
                    text: Text {
                        value: "Time: 0.0".to_string(),
                        font: font.clone(),
                        style: TextStyle {
                            font_size: 40.0,
                            color: Color::rgb(0.9, 0.9, 0.9),
                            ..Default::default()
                        },
                    },
                    ..Default::default()
                })
                .with(TimeText);
        });
}

struct TimeText;
}

在这个系统中,我们使用了父子关系模式(parenting),使得子实体可以相对于父实体进行转换。当我们把子实体加到父实体中后,给它一个合适的命名 with_children,它的参数是一个闭包,闭包接受一个类似于 Commands 的结构体类型 ChildBuilder 参数。在这个例子中,我创建了一个 NodeBundle 作为父实体,并将 TextBundle 作为子实体添加到其中。我们使用类似于 css 风格的 Style 组件让父节点坐落在屏幕的左上角。我们给文本实体增加了 TimeText 标记组件,这样我们就可以查询它,并且可以在任意帧中修改它。

现在,我们可以添加一个“系统”,它可以在每一帧中更新文本:


#![allow(unused)]
fn main() {
fn update_time_text(time: Res<Time>, mut query: Query<(&mut Text, &TimeText)>) {
    // 歌曲在实时启动 3 秒后开始
    let secs = time.seconds_since_startup() - 3.;

    // 在歌曲开始播放前不做任何处理
    if secs < 0. {
        return;
    }

    for (mut text, _marker) in query.iter_mut() {
        text.value = format!("Time: {:.2}", secs);
    }
}
}

该系统使用内置的 Time 资源,以及具有 TextTimeText 的组件的实体查询。之后,我们只需要循环遍历它们并更新文本值。在实际情况中,应该只有一个实体能匹配上查询,所以我们可以只需获取第一个实体并完成此次操作,但无论如何我还是倾向于使用循环。这样,如果将来我们决定创建多个“系统”,我们就不必修改其中的代码了。

我们通过创建一个插件来完成该代码文件的编写:


#![allow(unused)]
fn main() {
pub struct UIPlugin;
impl Plugin for UIPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_startup_system(setup_ui.system())
            .add_system(update_time_text.system());
    }
}
}

现在,进入 main.rs,把 CameraUiBundle 加到 setup “系统”中,并导入插件:

use bevy::{input::system::exit_on_esc_system, prelude::*};

mod arrows;
use arrows::ArrowsPlugin;
mod consts;
mod types;
mod ui;
use ui::UIPlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin) // <--- 新代码
        .run();
}

fn setup(commands: &mut Commands) {
    let config = types::load_config();

    commands
        .spawn(Camera2dBundle::default())
        .spawn(CameraUiBundle::default()) // <--- 新代码
        .insert_resource(config);
}

CameraUiBundleCamera2dBundle 很类似,但对于 UI 元素。如果不显式地添加它,文本就不会显示。因为我们之前已经添加了它,现在可以运行游戏,在屏幕上可以看到华丽地文字:

视频资源

增加得分

在本节中,我们将创建得分系统,以便于玩家能过够在每次玩耍后看到自己的表现。为此,我们打开另一个文件 score.rs。在其中,我们将创建一个新的资源来记录分数以及正确的箭头和失败的箭头数量:


#![allow(unused)]
fn main() {
use crate::consts::*;

#[derive(Default)]
pub struct ScoreResource {
    corrects: usize,
    fails: usize,

    score: usize,
}

impl ScoreResource {
    /// 增加合适的次数值以及得分
    pub fn increase_correct(&mut self, distance: f32) -> usize {
        self.corrects += 1;

        // 根据按下的按键的及时性获取一个 0 到 1 的值
        let score_multiplier = (THRESHOLD - distance.abs()) / THRESHOLD;
        // 最少增加 10 分,最多不超过 100 分。
        let points = (score_multiplier * 100.).min(100.).max(10.) as usize;
        self.score += points;

        points
    }

    /// 统计失败的次数
    pub fn increase_fails(&mut self) {
        self.fails += 1;
    }

    // Getters

    pub fn score(&self) -> usize {
        self.score
    }
    pub fn corrects(&self) -> usize {
        self.corrects
    }
    pub fn fails(&self) -> usize {
        self.fails
    }
}
}

ScoreResource 是一个简单的结构体,它有三个 usize 类型的私有字段。我们没有将字段设计成公有,而是设计成成员属性的 getter 和 setter。通过这种方式,增加合适的箭头数量的唯一方法是通过 increase_correct,它也能增加积分,我们需要保证有了这个方法后不会又编写另一个类似功能的方法。在这款游戏中,我们不需要这样,因为我们只需在一个地方增加分数,但对于其他更大的项目而言,这种做法更让我们有信心维护,它不会造成意料之外的漏洞。

我们把这个资源添加到 main.rs,并加上下面的引入代码:


#![allow(unused)]
fn main() {
mod score;
use score::ScoreResource;
}

使用下面的代码替换 main 函数:

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .init_resource::<ScoreResource>() // <--- New
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .run();
}

完成之后,我们就能使用“系统”上的资源了。也就是说,我们对 arrows.rs 文件中的 despawn_arrows 系统做一些调整,这样,当箭头消失时,就会触发调用增加积分方法:


#![allow(unused)]
fn main() {
use crate::ScoreResource;

/// 当它们到达终点时,正确点击了按钮,就会消除箭头
fn despawn_arrows(
    commands: &mut Commands,
    query: Query<(Entity, &Transform, &Arrow)>,
    keyboard_input: Res<Input<KeyCode>>,
    
    // 新代码
    mut score: ResMut<ScoreResource>,
) {
    for (entity, transform, arrow) in query.iter() {
        let pos = transform.translation.x;

        // 检查箭头是否是在阈值内点击的
        if (TARGET_POSITION - THRESHOLD..=TARGET_POSITION + THRESHOLD).contains(&pos)
            && arrow.direction.key_just_pressed(&keyboard_input)
        {
            commands.despawn(entity);

            // 新代码
            let _points = score.increase_correct(TARGET_POSITION - pos);
        }

        // 离开屏幕时,箭头消失
        if pos >= 2. * TARGET_POSITION {
            commands.despawn(entity);

            // 新代码
            score.increase_fails();
        }
    }
}
}

改动很简单,我们增加 mut score: ResMut<ScoreResource> 作为系统的参数,以便我们可以编辑得分,我们添加了一个 increase_correct 方法,它会帮助我们增加积分,并且还有一个 increase_fails 方法,用于表示箭头离开屏幕消失时,积分增加失败。

现在,拥有一个得分系统很不错,但如果玩家无法看到自己的表现,那就没啥价值了!我们需要在 UI 模板中加一些东西,以显示分数:


#![allow(unused)]
fn main() {
use crate::ScoreResource;

// 新代码
struct ScoreText;
fn update_score_text(score: ChangedRes<ScoreResource>, mut query: Query<(&mut Text, &ScoreText)>) {
    for (mut text, _marker) in query.iter_mut() {
        text.value = format!(
            "Score: {}. Corrects: {}. Fails: {}",
            score.score(),
            score.corrects(),
            score.fails()
        );
    }
}

pub struct UIPlugin;
impl Plugin for UIPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_startup_system(setup_ui.system())
            .add_system(update_time_text.system())
            .add_system(update_score_text.system()); // <--- 新代码
    }
}
}

update_score_text 中,我们使用 ChangedRes,而非普通的 Res。它们的区别在于后者会在每一帧都会运行一次,而 ChangedRes 只会在资源发生改变时才会运行。这很酷,因为分数不会再每一帧里都发生变化,所以这样可以节省一些开销,只需在需要时才更新文本。然后,它在具有 ScoreText 组件的实体上设置文本值(和 TimeText 一样,应该只有一个,但为什么要限制)。

我们还要修改 setup_ui 中的一些东西,在第二次产生 NodeBundleTextBundle 时,使用 ScoreText 组件:


#![allow(unused)]
fn main() {
fn setup_ui(
    commands: &mut Commands,
    asset_server: ResMut<AssetServer>,
    mut color_materials: ResMut<Assets<ColorMaterial>>,
) {
    let font = asset_server.load("fonts/FiraSans-Bold.ttf");
    let material = color_materials.add(Color::NONE.into());

    commands
        // Time 文本节点
        .spawn(NodeBundle {
            style: Style {
                position_type: PositionType::Absolute,
                position: Rect {
                    left: Val::Px(10.),
                    top: Val::Px(10.),
                    ..Default::default()
                },
                ..Default::default()
            },
            material: material.clone(),
            ..Default::default()
        })
        .with_children(|parent| {
            parent
                .spawn(TextBundle {
                    text: Text {
                        value: "Time: 0.0".to_string(),
                        font: font.clone(),
                        style: TextStyle {
                            font_size: 40.0,
                            color: Color::rgb(0.8, 0.8, 0.8),
                            ..Default::default()
                        },
                    },
                    ..Default::default()
                })
                .with(TimeText);
        })
        
        // 新代码
        .spawn(NodeBundle {
            style: Style {
                position_type: PositionType::Absolute,
                position: Rect {
                    left: Val::Px(10.),
                    bottom: Val::Px(10.),
                    ..Default::default()
                },
                ..Default::default()
            },
            material,
            ..Default::default()
        })
        .with_children(|parent| {
            parent
                .spawn(TextBundle {
                    text: Text {
                        value: "Score: 0. Corrects: 0. Fails: 0".to_string(),
                        font,
                        style: TextStyle {
                            font_size: 40.0,
                            color: Color::rgb(0.8, 0.8, 0.8),
                            ..Default::default()
                        },
                    },
                    ..Default::default()
                })
                .with(ScoreText);
        });
}
}

我已经打算把这个文本设置在屏幕的左下角,但如果你想练习,你可以尝试把它设置在左上角时间文本的下面。

试试吧!运行游戏,看看我们的成果如何:

视频资源

你可以随心所欲地为 UI 增减东西!我们在这里所做的是比较基础地展示文本。

从配置文件中加载数据

目前我们游戏中的箭头是硬编码的。目前这一切都还好,但我们希望玩家能创作自己的歌曲。我们不会通过制作自定义文件格式或任何花哨的东西使配置复杂化,所以我们将通过 TOMLserde 库,来使用经过试用和测试的 TOML 格式。这两个 crate 将帮助我们非常容易地实现 SongConfig 结构的 TOML 序列化和反序列化。

Cargo.toml 文件加入以下内容:

toml = "0.5.8"
serde = "1.0.118"
serde_derive = "1.0.118"

我们现在可以编辑 types.rs 文件,并且导入准备好的类型和反序列化格式,向 DirectionsSpeed 类型中增加 DeserializeSerialize trait 实现声明:


#![allow(unused)]
fn main() {
use bevy::prelude::*;

use serde_derive::{Deserialize, Serialize};
use std::fs::File;
use std::io::prelude::*;

#[derive(Copy, Clone, Debug, PartialEq, Deserialize, Serialize)]
pub enum Directions {
    Up,
    Down,
    Left,
    Right,
}
#[derive(Copy, Clone, Debug, Deserialize, Serialize)]
pub enum Speed {
    Slow,
    Medium,
    Fast,
}
}

现在,我们有个小问题。我们的 ArrowTime 结构体有 spawn_time 字段,但是我们想在 TOML 文件中写入点击时间,所以我们不能直接在 Serde 中使用 ArrowTimeSongConfig。我们会通过创建两个新结构体来解决这个问题,ArrowTimeTomlSongConfigToml,它们对应的数据将会被包含在 TOML 文件中:


#![allow(unused)]
fn main() {
#[derive(Deserialize, Debug)]
struct SongConfigToml {
    pub name: String,
    pub filename: String,
    pub arrows: Vec<ArrowTimeToml>,
}

#[derive(Deserialize, Serialize, Debug)]
pub struct ArrowTimeToml {
    pub click_time: f64,
    pub speed: Speed,
    pub direction: Directions,
}
}

name 字段用于存储歌曲的名称,filename 是音频文件的路径,arrowsArrowTimeTomls 列表。ArrowTimeTomlArrowTime 的字段大部分一样,不同的是前者有 click_time,后者没有,取而代之的是 spawn_time

我们也会把 ArrowTime::new 的入参改为 ArrowTimeToml 类型:


#![allow(unused)]
fn main() {
impl ArrowTime {
    fn new(arrow: &ArrowTimeToml) -> Self {
        let speed_value = arrow.speed.value();
        Self {
            spawn_time: arrow.click_time - (DISTANCE / speed_value) as f64,
            speed: arrow.speed,
            direction: arrow.direction,
        }
    }
}
}

让我们在 SongConfig 加几个字段,用来保存名称和音频:


#![allow(unused)]
fn main() {
pub struct SongConfig {
    pub name: String,
    pub song_audio: Handle<AudioSource>,
    pub arrows: Vec<ArrowTime>,
}
}

我们用 Handle<AudioSource> 保存音频,当我们把 SongConfigToml 转换为 SongConfig 时,我们会使用 AssetServer 加载它。

最后,我们将修改 load_config 来从文件中加载 SongConfig


#![allow(unused)]
fn main() {
pub fn load_config(path: &str, asset_server: &AssetServer) -> SongConfig {
    // 打开文件并读取内容
    let mut file = File::open(format!("assets/songs/{}", path)).expect("Couldn't open file");
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .expect("Couldn't read file into String");

    // 使用 toml 和 Serde 进行解析
    let parsed: SongConfigToml =
        toml::from_str(&contents).expect("Couldn't parse into SongConfigToml");

    // 处理箭头
    let mut arrows = parsed
        .arrows
        .iter()
        .map(|arr| ArrowTime::new(arr))
        .collect::<Vec<ArrowTime>>();
    // 根据 spawn_time 对箭头排序
    arrows.sort_by(|a, b| a.spawn_time.partial_cmp(&b.spawn_time).unwrap());

    // 加载音频歌曲,并进行处理
    let song_audio = asset_server.load(&*format!("songs/{}", parsed.filename));

    SongConfig {
        name: parsed.name,
        song_audio,
        arrows,
    }
}
}

只有几行代码,但是很直接:先打开文件并读取文件的内容,使用 toml 库中的 from_str 方法解析文件内容,然后修改 ArrowTimeTomls 数组为 ArrowTimes 数组,我们使用 AssetServer::load 加载歌曲音频,然后返回新构建的 SongConfig

注意:AssetServer::load 将在 assets 文件夹中搜索文件。File::open 不会从根目录开始查找,所以我们需要手动地将 assets 加到路径前缀中。

我们还需要修改 main.rs 中的 setup “系统”,修改 load_config 的调用方式,把 AssetServer 作为参数:


#![allow(unused)]
fn main() {
fn setup(commands: &mut Commands, asset_server: Res<AssetServer>) {
    let config = types::load_config("test.toml", &asset_server);

    commands
        .spawn(Camera2dBundle::default())
        .spawn(CameraUiBundle::default())
        .insert_resource(config);
}
}

我们将在 assets 中创建一个 songs 文件夹,可以在其中保存所有的歌曲文件和对应的音频。现在,我们将创建一个名为 test.toml 的占位文件。你可以随意修改 arrows 以获得更详细的内容,现在只做一些简单测试:

name = "Test song"
filename = "audio.mp3"

arrows = [
    { click_time = 1.00, speed = "Slow", direction = "Up" },
    { click_time = 3.00, speed = "Slow", direction = "Down" },
    { click_time = 5.00, speed = "Fast", direction = "Left" },
    { click_time = 5.00, speed = "Slow", direction = "Right" },
    { click_time = 7.00, speed = "Slow", direction = "Up" },
    { click_time = 8.00, speed = "Medium", direction = "Up" },
    { click_time = 9.00, speed = "Slow", direction = "Left" },
    { click_time = 10.00, speed = "Slow", direction = "Right" },
    { click_time = 10.50, speed = "Medium", direction = "Right" },
    { click_time = 11.00, speed = "Slow", direction = "Up" },
    { click_time = 11.00, speed = "Slow", direction = "Down" },
]

现在,(合法地)下载你最喜欢的歌曲,将其放在 assets/songs 中,并将其命名为 audio.mp3

你的 assets 目录应该如下方所示:

assets
├── fonts
│   └── FiraSans-Bold.ttf
├── images
│   ├── arrow_blue.png
│   ├── arrow_border.png
│   ├── arrow_green.png
│   └── arrow_red.png
└── songs
    ├── audio.mp3
    └── test.toml

现在运行游戏,应该和上一节没有太大不同,只是你得到的箭头是根据外部文件配置加载的!如果你问我的话,我觉得相当酷 :)。

播放音频

你可能注意到,在上一节中,我们做了一些加载歌曲的逻辑,但当我们玩游戏时,歌曲还是不能播放。现在,我们来实现播放!为此,我新建了一个文件,audio.rs,其中只含有一个“系统”:


#![allow(unused)]
fn main() {
audio.rs
use crate::types::SongConfig;
use bevy::prelude::*;

fn start_song(audio: Res<Audio>, time: Res<Time>, config: Res<SongConfig>) {
    // 歌曲将在实时的 3 秒后开始播放
    let secs = time.seconds_since_startup();
    let secs_last = secs - time.delta_seconds_f64();

    if secs_last <= 3. && 3. <= secs {
        audio.play(config.song_audio.clone());
    }
}

pub struct AudioPlugin;
impl Plugin for AudioPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_system(start_song.system());
    }
}
}

start_song 使用 Audio 资源,在进入游戏 3 秒后开始播放歌曲。如你所看到的,我们使用了与“生成箭头”相同的方法。

注意:我们本可以复用 Timer,但当我们制作一个菜单来选择歌曲时,会带来一定的复杂度。何况尝试使用定时器重写,是个很不错的练习方式!

main.rs 中,我们添加以下内容:


#![allow(unused)]
fn main() {
// main.rs
mod audio;
use audio::AudioPlugin;
}

main 函数中,在所有插件加载的最后,添加 .add_plugin(AudioPlugin)。现在运行游戏应该会让歌曲播放了,因为计时器在运行!

至此,我们完成了游戏核心实现。你可以自由地在此基础上构建你自己地东西,但我建议你再往后看看,因为我们将致力于让游戏更加✨漂亮✨。

美化失败的箭头

首先,我们可以改进失败箭头的外观。目前,它们只是飞向远处。我们希望给玩家一些暗示,提醒他们那个箭头失败了。

我们要做的是让箭头在穿过目标区域后,“脱离”那条线。为了实现这一点,我们在 arrows.rs 中的 move_arrows 函数中加点东西:


#![allow(unused)]
fn main() {
/// 箭头前移
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * arrow.speed.value();

        // 新加代码
        let distance_after_target = transform.translation.x - (TARGET_POSITION + THRESHOLD);
        if distance_after_target >= 0.02 {
            transform.translation.y -= time.delta_seconds() * distance_after_target * 2.;
        }
    }
}
}

我们所做的是获取目标到目标区域箭头符号的 x 坐标距离差,如果是正的,意味着它已经移动到目标区域外,我们就在它的 y 坐标减去一点,这样它就会下降。通过 time.delta_seconds() * distance_after_target,我们让每一帧的下降因子变大,这会让箭头以弧线的形式下降。2. 只是一个特定的常量,使弧线更好看(我觉得是),你可以根据你自己的意愿调整它!

效果见下方链接的视频:

视频资源

很好,我们再给它加点效果。我们让箭头在下降时收缩并旋转:


#![allow(unused)]
fn main() {
/// 箭头前移
fn move_arrows(time: Res<Time>, mut query: Query<(&mut Transform, &Arrow)>) {
    for (mut transform, arrow) in query.iter_mut() {
        transform.translation.x += time.delta_seconds() * arrow.speed.value();

        let distance_after_target = transform.translation.x - (TARGET_POSITION + THRESHOLD);
        if distance_after_target >= 0.02 {
            // 一旦箭头穿过目标区域,则开始下落
            transform.translation.y -= time.delta_seconds() * distance_after_target * 2.;

            // 根据箭头地距离改变下降因子(比例)
            let scale = ((100. - distance_after_target / 3.) / 100.).max(0.2);
            transform.scale = Vec3::splat(scale);

            // 根据距离和速度旋转箭头
            transform.rotate(Quat::from_rotation_z(
                -distance_after_target * arrow.speed.multiplier() / 460.,
            ));
        }
    }
}
}

这是一串充满魔力的数字和公式,我在经过多次不同的尝试得出的结论。我建议你试试其它内容!

我们将其分析一下:首先,我们使用一个随着箭头移动而减小的公式来获得一个比例。然后,使用 max 来确保比例至少为 0.2。之后,我们使用 Transform::rotate 来旋转箭头。对于旋转,我们使用 Speed::multiplier,如果箭头的速度更快,就会旋转地更快。下面是所有这些效果组合在一起的样子:

视频资源

太酷了!再次强调,你可以随时即兴发挥,添加其他逻辑,让它更加酷炫。游戏有一半的乐趣来自于制作你喜欢的花哨特效!

着色器背景

接下来我们要做的是替换灰色背景。选择之一是使用 ClearColor 资源,以静态颜色作为背景。这里是一个使用示例。这种方式很简单,我们只需要在 main 函数中加上 .add_resource(ClearColor(Color::rgb(0.5, 0.5, 0.9))),缺点是只能将背景改为一个平面颜色,我们希望看到更加生动的内容。着色器可以帮助我们!

我们将在所有元素下面制作一个窗口大小的精灵,我们将添加着色器材料。这样我们会有一个背景,也就是设置一个着色器作为背景。

当我们用着色器添加一些其他东西时,我们创建一个名为 shaders 的文件夹,用于存放相关文件。我们先打开 shaders/mod.rs


#![allow(unused)]
fn main() {
use bevy::{
    prelude::*,
    reflect::TypeUuid,
    render::{
        pipeline::{PipelineDescriptor, RenderPipeline},
        render_graph::{base, RenderGraph},
        renderer::RenderResources,
        shader::{ShaderStage, ShaderStages},
    },
    window::WindowResized,
};

mod background;
use background::*;
}

现在,我们只添加了一些导入,声明了 background 模块,接下来就创建这个模块:


#![allow(unused)]
fn main() {
use super::*;

pub struct Background;
pub fn setup_background(
    commands: &mut Commands,
    mut pipelines: ResMut<Assets<PipelineDescriptor>>,
    mut shaders: ResMut<Assets<Shader>>,
    window: Res<WindowDescriptor>,
) {
    // 创建一个新的着色器管道
    let pipeline_handle = pipelines.add(PipelineDescriptor::default_config(ShaderStages {
        vertex: shaders.add(Shader::from_glsl(
            ShaderStage::Vertex,
            include_str!("background.vert"),
        )),
        fragment: Some(shaders.add(Shader::from_glsl(
            ShaderStage::Fragment,
            include_str!("background.frag"),
        ))),
    }));

    commands
        .spawn(SpriteBundle {
            render_pipelines: RenderPipelines::from_pipelines(vec![RenderPipeline::new(
                pipeline_handle,
            )]),
            transform: Transform::from_scale(Vec3::new(
                window.width + 10.,
                window.height + 10.,
                1.,
            )),
            ..Default::default()
        })
        .with(Background);
}
}

在这个文件中,我们添加了一个“启动系统”,它首先创建了 PipelineDescriptor,其中包含顶点和 fragment 着色器。这些都是用 include_str 宏从文件中添加进来的。然后我们会创建一个带有 RenderPipelines 组件的 SpriteBundle,并将我们创建的管道描述符传入。最后,我们添加了一个 Background 标记组件。

我们正在使用 WindowDescriptor 资源来得到屏幕宽度和高度,这样就可以进行正确的转换。如果玩家将窗口变大,会出现一个小问题,因为我们的背景大小不变,导致后面的灰色背景被显示出来!为了解决这个问题,我们添加另一个“系统”:


#![allow(unused)]
fn main() {
/// 当窗口大小变化时,背景大小跟着改变
pub fn update_background_size(
    mut event_reader: Local<EventReader<WindowResized>>,
    events: Res<Events<WindowResized>>,
    mut background: Query<(&mut Transform, &Background)>,
) {
    for event in event_reader.iter(&events) {
        for (mut transform, _) in background.iter_mut() {
            transform.scale = Vec3::new(event.width, event.height, 1.);
        }
    }
}
}

它监听 WindowResized 事件,该事件在每次调整窗口大小时会提供新的窗口宽高。

正如你注意到的,在 Bevy 中有一种易于使用且优雅的模式。事件也不例外。要使用一个事件,我们需要添加一个 Event<T> 资源和一个 Local<EventReader<T>> 作为参数。然后我们就可以通过事件资源来使用 EventReader::iter,该事件资源将给我们提供需要处理的事件。

实际使用着色器时是使用 Rust 的 include_str 宏添加的,它将以字符串的形式添加文件内容。首先,我们创建 background.vert

#version 450

layout(location = 0) in vec3 Vertex_Position;
layout(location = 1) in vec3 Vertex_Normal;
layout(location = 2) in vec2 Vertex_Uv;

layout(location = 1) out vec2 v_Uv;

layout(set = 0, binding = 0) uniform Camera {
    mat4 ViewProj;
};
layout(set = 1, binding = 0) uniform Transform {
    mat4 Model;
};

void main() {
    v_Uv = Vertex_Uv;
    gl_Position = ViewProj * Model * vec4(Vertex_Position, 1.0);
}

我们在这里只需做一件特殊的事是添加 v_Uv(纹理的 uv 坐标)作为输出,这样,我们就可以在 fragment 着色器中使用它,现在我们在 background.frag 中创建它:

// shaders/background.frag
#version 450

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;

layout(location = 0) out vec4 o_Target;

void main() {
    o_Target = vec4(v_Uv, 0.1, 1.0);
}

在这个着色器中,我们只返回基于背景的 uv 坐标的简单颜色。

我们现在需要注册这些创建的“系统”。我们在 shaders/mod.rs 中添加 ShaderPlugin


#![allow(unused)]
fn main() {
// shaders/mod.rs
pub struct ShadersPlugin;
impl Plugin for ShadersPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_startup_system(setup_background.system())
            .add_system(update_background_size.system());
    }
}
}

现在我们可以在 main.rs 中导入它:

mod shaders;
use shaders::ShadersPlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .init_resource::<ScoreResource>()
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .add_plugin(AudioPlugin)
        .add_plugin(ShadersPlugin) // <--- New
        .run();
}

运行游戏你可以看到下方链接视频中展示的效果:

视频资源

使用时间着色器

继续,我们会有一些奇特的场景,酷!理想情况下,我们希望游戏背景随着时间有一些变化。

Bevy 没有(至少现在没有)添加时间和分辨率到着色器中作为输入,所以我们将不得不手动添加它们。希望这点能在 Bevy 中尽快得到改善。

我们再次打开 shaders/mod.rs文件,并增加以下代码:


#![allow(unused)]
fn main() {
#[derive(RenderResources, Default, TypeUuid)]
#[uuid = "0320b9b8-b3a3-4baa-8bfa-c94008177b17"]
/// 将资源传递给着色器
pub struct ShaderInputs {
    time: f32,
    resolution: Vec2,
}

/// 在每一帧中,更新 ShaderInputs 中的时间
fn update_time(time: Res<Time>, mut nodes: Query<&mut ShaderInputs>) {
    let time = time.seconds_since_startup();
    for mut node in nodes.iter_mut() {
        node.time = time as f32;
    }
}

/// 如果窗口大小发生改变,更新 ShaderInputs 的分辨率
fn update_resolution(
    mut event_reader: Local<EventReader<WindowResized>>,
    events: Res<Events<WindowResized>>,
    mut background: Query<&mut ShaderInputs>,
) {
    for event in event_reader.iter(&events) {
        for mut node in background.iter_mut() {
            node.resolution = Vec2::new(event.width / event.height, 1.);
        }
    }
}

/// 在渲染图形时,添加 ShaderInputs 作为一个 edge
fn setup_render_graph(mut render_graph: ResMut<RenderGraph>) {
    render_graph.add_system_node("inputs", RenderResourcesNode::<ShaderInputs>::new(true));
    render_graph
        .add_node_edge("inputs", base::node::MAIN_PASS)
        .unwrap();
}
}

我们正在创建一个新的 ShaderInputs 结构体,将其作为渲染图形边添加到 setup_render_graph 中,并将其加到“启动系统”中。update_timeupdate_resolution 是两个负责更新 ShaderInputs 组件值的系统。注意在 update_resolution 中我们是通过监听 WindowResized 事件来实现,而非更新每一帧。

现在,用以下代码替换 ShaderPlugin 中的实现,添加所有系统和资源:


#![allow(unused)]
fn main() {
pub struct ShadersPlugin;
impl Plugin for ShadersPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_asset::<ShaderInputs>() // <--- 新代码
            .add_startup_system(setup_render_graph.system()) // <--- 新代码
            .add_system(update_time.system()) // <--- 新代码
            .add_system(update_resolution.system()) // <--- 新代码
            .add_startup_system(setup_background.system())
            .add_system(update_background_size.system());
    }
}
}

我们现在要向之前创建的背景实体添加 ShaderInputs 组件,提供初始值:


#![allow(unused)]
fn main() {
// shaders/background.rs
pub fn setup_background(
    commands: &mut Commands,
    mut pipelines: ResMut<Assets<PipelineDescriptor>>,
    mut shaders: ResMut<Assets<Shader>>,
    window: Res<WindowDescriptor>,
) {
    // 创建新的着色器管道
    let pipeline_handle = pipelines.add(PipelineDescriptor::default_config(ShaderStages {
        vertex: shaders.add(Shader::from_glsl(
            ShaderStage::Vertex,
            include_str!("background.vert"),
        )),
        fragment: Some(shaders.add(Shader::from_glsl(
            ShaderStage::Fragment,
            include_str!("background.frag"),
        ))),
    }));

    commands
        .spawn(SpriteBundle {
            render_pipelines: RenderPipelines::from_pipelines(vec![RenderPipeline::new(
                pipeline_handle,
            )]),
            transform: Transform::from_scale(Vec3::new(
                window.width + 10.,
                window.height + 10.,
                1.,
            )),
            ..Default::default()
        })
        .with(Background)
        // New
        .with(ShaderInputs {
            time: 0.,
            resolution: Vec2::new(window.width / window.height, 1.),
        });
}
}

这些值在添加一些东西后,现在可以在着色器上使用了:

// shaders/background.frag
#version 450

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;

layout(location = 0) out vec4 o_Target;

// New
layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
// New
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};

void main() {
    o_Target = vec4(v_Uv, abs(sin(time)), 1.0);
}

基本上,我们必须对 ShaderInputs 的每个字段增加 uniform,它包含 binding 对应增加的值,以及形如 ShaderInputs_$name 的名字,其中的 $name 是字段名。现在我们可以使用着色器内部的变量了!

现在看起来应该如下方链接视频所示:

视频资源

就个人而言,我选择了以下配置的着色器作为背景:

#version 450

#define TWO_PI 6.28318530718

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;
layout(location = 0) out vec4 o_Target;

layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};

vec3 hsb2rgb(in vec3 c){
    vec3 rgb = clamp(abs(mod(c.x*6.0+vec3(0.0,4.0,2.0),
                             6.0)-3.0)-1.0,
                     0.0,
                     1.0 );
    rgb = rgb*rgb*(3.0-2.0*rgb);
    return c.z * mix( vec3(1.0), rgb, c.y);
}

float wave_sin(in float x) {
    float amplitude = 0.5;
    float frequency = 1.0;
    float y = sin(x * frequency);
    float t = 0.01*(-time*50.0);
    y += sin(x * frequency * 2.1 + t)*4.5;
    y += sin(x * frequency * 1.72 + t*1.121)*4.0;
    y += sin(x * frequency * 2.221 + t*0.437)*5.0;
    y += sin(x * frequency * 3.1122+ t*4.269)*2.5;
    y *= amplitude*0.06;
    return y;
}
float wave_cos(in float x) {
    float amplitude = 0.5;
    float frequency = 2.0;
    float y = cos(x * frequency);
    float t = 0.01*(-time*30.0);
    y += cos(x * frequency * 2.1 + t)*4.5;
    y += cos(x * frequency * 1.72 + t*1.121)*4.0;
    y += cos(x * frequency * 2.221 + t*0.437)*5.0;
    y += cos(x * frequency * 3.1122+ t*4.269)*2.5;
    y *= amplitude*0.06;
    return y;
}
vec2 wave(in vec2 v) {
    return vec2(wave_sin(v.x), wave_cos(v.y));
}

void main() {
    vec2 uv = wave(v_Uv);
    vec3 color = hsb2rgb(vec3(uv.x + sin(uv.y), 0.7, 1.0));

    o_Target = vec4(color,1.0);
}

它移动周围的颜色,产生好看的波浪,效果如下方链接视频所示:

视频资源

现在轮到你玩它了,找到你喜欢的东西。如果你不太理解着色器,你可以尝试对上面的着色器做一些小修改,你也可以去 Shadertoy 查找一些资料。例如,下面是一个 shader 配置,它由 Danilo Guanabara 转换自 Shadertoy:

// shaders/background.frag
#version 450

// Creation, by Silexars (Danilo Guanabara)
// From https://www.shadertoy.com/view/XsXXDn

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;
layout(location = 0) out vec4 o_Target;

layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};

void main() {
    vec3 c;
    vec2 r = resolution;
    float l,z=time;
    for(int i=0;i<3;i++) {
        vec2 uv,p = v_Uv; // / r;
        uv = p;
        p -= 0.5;
        p.x *= r.x/r.y;
        z += 0.07;
        l = length(p);
        uv += p/l*(sin(z)+1.)*abs(sin(l*9.0-z*2.0));
        c[i] = (0.01)/length(abs(mod(uv,1.0)-0.5));
    }
    o_Target = vec4(c/l,time);
}

效果如下方链接视频所示:

视频资源

美化点击动画

我们之前已经为失败的箭头添加了有趣动画,但当成功命中箭头时,我们啥也没做。它就这样消失了,这有点让人失望。我们将这一点进行改进。

我们将有四个不同的“精灵”,每个精灵在每个目标区域箭头下都有一个着色器。然后,每当正确命中箭头时,相应的精灵下的着色器就会启动动画,动画持续一段时间后,再消失。

注意:这个如果用技术实现会比较复杂,但这样可以展示很多东西。实现这一点有个捷径是在每次正确点击箭头时创建一个精灵,然后几秒钟后删除掉。

打开 shaders/target_arrows.rs 文件。我们为这些精灵添加一个组件(我把它叫做“普通目标箭头”),它只是指示目标箭头的方向和位置:


#![allow(unused)]
fn main() {
pub struct TargetArrowSparkle {
    direction: Directions,
}
}

我们再添加另一条边到渲染图中,并将另一个结构体作为参数传递给着色器。这将保留最近一次正确命中箭头的时间,以及对应得分:


#![allow(unused)]
fn main() {
// shaders/target_arrows.rs
#[derive(RenderResources, TypeUuid)]
#[uuid = "c9400817-b3a3-4baa-8bfa-0320b9b87b17"]
pub struct TimeSinceLastCorrect {
    last_time: f32,
    points: f32,
}
}

请注意,当我们向目标箭头添加 TimeSinceLastCorrect 组件时,每个组件都有自己的值,这些值是不共享的,所以我们需要单独设定它们。

现在,我们添加一个“启动系统”用于创建精灵:


#![allow(unused)]
fn main() {
// shaders/target_arrows.rs
use super::*;
use crate::consts::*;
use crate::types::Directions::{self, *};

pub fn setup_target_arrows(
    commands: &mut Commands,
    mut pipelines: ResMut<Assets<PipelineDescriptor>>,
    mut shaders: ResMut<Assets<Shader>>,
    mut render_graph: ResMut<RenderGraph>,
    window: Res<WindowDescriptor>,
) {
    // 创建一个新的着色器管道
    let pipeline_handle = pipelines.add(PipelineDescriptor::default_config(ShaderStages {
        vertex: shaders.add(Shader::from_glsl(
            ShaderStage::Vertex,
            include_str!("target_arrows.vert"),
        )),
        fragment: Some(shaders.add(Shader::from_glsl(
            ShaderStage::Fragment,
            include_str!("target_arrows.frag"),
        ))),
    }));

    // 把 TimeSinceLastCorrect 加到渲染图中
    render_graph.add_system_node(
        "last_time",
        RenderResourcesNode::<TimeSinceLastCorrect>::new(true),
    );
    render_graph
        .add_node_edge("last_time", base::node::MAIN_PASS)
        .unwrap();

    let directions = [Up, Down, Left, Right];
    for direction in directions.iter() {
        // z 值不同,所以它们不会重叠
        let z = match direction {
            Up => 0.3,
            Down => 0.4,
            Left => 0.5,
            Right => 0.6,
        };

        let mut transform =
            Transform::from_translation(Vec3::new(TARGET_POSITION, direction.y(), z));
        transform.scale = Vec3::new(300., 300., 1.);
        commands
            .spawn(SpriteBundle {
                render_pipelines: RenderPipelines::from_pipelines(vec![RenderPipeline::new(
                    pipeline_handle.clone(),
                )]),
                transform,
                visible: Visible {
                    is_transparent: true,
                    ..Default::default()
                },
                ..Default::default()
            })
            .with(TargetArrowSparkle {
                direction: *direction,
            })
            .with(TimeSinceLastCorrect {
                last_time: 3.,
                points: 0.5,
            })
            .with(ShaderInputs {
                time: 0.,
                resolution: Vec2::new(window.width / window.height, 1.),
            });
    }
}
}

这个系统就像是 setup_target_arrowssetup_render_graphsetup_background 的混合体。我们首先创建一个 PipelineDescriptor,然后添加 TimeSinceLastCorrect 作为渲染图的边,最后我们创建一个存放所有方向的数组,然后迭代它,创建 4 个精灵组,并添加 TargetArrowSparkleTimeSinceLastCorrectShaderInputs 组件。

我们把 last_time 设为 3 秒进行测试。这样,当时间达到三秒时,动画就开始了。当我们设置好所有内容后,我们会将其更改为负值,因为我们希望箭头在被正确点击时触发。

我们还需要为这个着色器创建新文件:

#version 450

layout(location = 0) in vec3 Vertex_Position;
layout(location = 1) in vec3 Vertex_Normal;
layout(location = 2) in vec2 Vertex_Uv;

layout(location = 1) out vec2 v_Uv;

layout(set = 0, binding = 0) uniform Camera {
    mat4 ViewProj;
};
layout(set = 1, binding = 0) uniform Transform {
    mat4 Model;
};

void main() {
    v_Uv = Vertex_Uv;
    gl_Position = ViewProj * Model * vec4(Vertex_Position, 1.0);
}

vertex 着色器的实现基本上和 shaders/background.vert 一样。更有趣的是 shaders/target_arrows.frag

# shaders/target_arrows.frag
#version 450

#define TWO_PI 6.28318530718

layout(location = 0) in vec4 v_Position;
layout(location = 1) in vec2 v_Uv;
layout(location = 0) out vec4 o_Target;

layout(set = 2, binding = 0) uniform ShaderInputs_time {
    float time;
};
layout(set = 2, binding = 1) uniform ShaderInputs_resolution {
    vec2 resolution;
};
layout(set = 3, binding = 0) uniform TimeSinceLastCorrect_last_time {
    float last_time;
};
layout(set = 3, binding = 1) uniform TimeSinceLastCorrect_points {
    float points;
};

float interval(in float a, in float b, in float val) {
    return step(a, val) * smoothstep(1.0 - b - 0.1, 1.0 - b, 1. - val);
}

float circle(in vec2 uv, in float _radius){
    vec2 dist = uv - vec2(0.5);
    return 1.0 - smoothstep(_radius - (_radius * 0.01),
                            _radius + (_radius * 0.01),
                            dot(dist, dist) * 4.0);
}

float smoothcircle(in vec2 _st, in float s){
    vec2 dist = _st-vec2(0.5);
    return 4. * dot(dist,dist) / (s);
}

void main() {
    // 0. when the circle shouldn't be shown
    float alpha = interval(last_time, last_time + 0.6, time);

    // Circle radius
    float radius = time - last_time;
    // 0. for not in circle, 1. for circle
    // float circle = circle(v_Uv, radius) * (1. - circle(v_Uv, radius - 0.1));
    float circle = smoothcircle(v_Uv, radius) * smoothcircle(v_Uv, radius) * circle(v_Uv, radius);

    // rgb(92, 175, 29);
    vec3 colorMin = vec3(0.36078431373,0.6862745098,0.1137254902);
    // rgb(255, 255, 6);
    vec3 colorMax = vec3(1.,1.,0.02352941176);

    // Get color according to points
    vec3 color = mix(colorMin, colorMax, points);

    o_Target = vec4(color * circle, circle * alpha);
}

这个着色器有点复杂,但简而言之,它的作用是创建一个半径随时间增加的圆。圆圈在 last_time 后存在 0.6 秒。我们把值设为 3 来添加 TimeSinceLastCorrect,并且和 ShaderInputs 一样,每个字段的绑定值都会增加。圆形的颜色根据点的不同而有所变化。

我们还需要把 setup_target_arrows 加到 ShaderPlugin 中:


#![allow(unused)]
fn main() {
// shaders/mod.rs
mod target_arrows;
use target_arrows::*;

pub struct ShadersPlugin;
impl Plugin for ShadersPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.add_asset::<ShaderInputs>()
            .add_asset::<TimeSinceLastCorrect>()
            .add_startup_system(setup_render_graph.system())
            .add_system(update_time.system())
            .add_system(update_resolution.system())
            .add_startup_system(setup_background.system())
            .add_system(update_background_size.system())
            .add_startup_system(setup_target_arrows.system()); // <--- New
    }
}
}

现在运行游戏,将看到如下面链接视频所展示的效果:

视频资源

如你所看到的,就在歌曲开始后,第 3 秒时,所有的圆圈开始变大,约过半秒后它们就消失了。太好了,这意味这着色器和定时器都正常工作了!我们仍然缺少一些东西来更新一些值,所以我们添加一个“系统”,用于当箭头被正确的按下时,更新 last_time 值。在此之前,我们使其默认值为负的:


#![allow(unused)]
fn main() {
// shaders/target_arrows.rs
.with(TimeSinceLastCorrect {
    last_time: -10.,
    points: 0.,
})
}

现在如果你运行这个游戏,圆圈就不会出现了。

之前,我们已经看到了如何侦听事件,但我们仍然没有看到硬币的另一面。我们现在就准备探索一下。我们将创建一个正确点击箭头时发生的事件。我们在 arrows.rs 文件中的 despawn_arrows 中产生这个事件:


#![allow(unused)]
fn main() {
// arrows.rs
/// 事件结构体
pub struct CorrectArrowEvent {
    pub direction: Directions,
    pub points: usize,
}

/// 当他们到达目标区域时,正确点击按钮,箭头就会消失
fn despawn_arrows(
    commands: &mut Commands,
    query: Query<(Entity, &Transform, &Arrow)>,
    keyboard_input: Res<Input<KeyCode>>,
    mut score: ResMut<ScoreResource>,
    mut correct_arrow_events: ResMut<Events<CorrectArrowEvent>>,
) {
    for (entity, transform, arrow) in query.iter() {
        let pos = transform.translation.x;

        // Check if arrow is inside clicking threshold
        if (TARGET_POSITION - THRESHOLD..=TARGET_POSITION + THRESHOLD).contains(&pos)
            && arrow.direction.key_just_pressed(&keyboard_input)
        {
            commands.despawn(entity);

            let points = score.increase_correct(TARGET_POSITION - pos);

            // 新代码
            
            // 发送事件
            correct_arrow_events.send(CorrectArrowEvent {
                direction: arrow.direction,
                points,
            });
        }

        // 当箭头离开屏幕时消除它们
        if pos >= 2. * TARGET_POSITION {
            commands.despawn(entity);
            score.increase_fails();
        }
    }
}
}

我们首先要做的是创建一个新的 CorrectArrowEvent 结构体,它用来表示我们的事件。对于 despawn_arrows,我们添加了 ResMut<Events<CorrectArrowEvent>> 参数,这样我们就能通过 send 方法发送事件。为了发送一个事件,我们需要传入一个 CorrectArrowEvent 结构体,它携带箭头的方向以及玩家的得分。

现在我们需要把 .init_resource::<Events<CorrectArrowEvent>>() 添加到 ArrowsPlugin,我们已经准备好了。很简单,对吧?

现在我们要在 shaders/target_arrows.rs 中添加一个“系统”,它负责更新“目标区域箭头”中的 last_time


#![allow(unused)]
fn main() {
// shaders/target_arrows.rs
pub fn correct_arrow_event_listener(
    time: Res<Time>,
    mut correct_event_reader: Local<EventReader<CorrectArrowEvent>>,
    correct_events: Res<Events<CorrectArrowEvent>>,
    mut query: Query<(&TargetArrowSparkle, &mut TimeSinceLastCorrect)>,
) {
    for event in correct_event_reader.iter(&correct_events) {
        for (arrow, mut last_correct) in query.iter_mut() {
            if arrow.direction == event.direction {
                last_correct.last_time = time.seconds_since_startup() as f32;
                last_correct.points = event.points as f32 / 100.;
            }
        }
    }
}
}

它通过监听事件,寻找与目标方向相关的箭头精灵,并更新其中的 last_timepoints 值。

把最后一个“系统”加到 ShaderPlugin.add_system(correct_arrow_event_listener.system())。现在如果你运行游戏,当你正确点击箭头时,就会看到圆圈效果:

视频资源

这就是这个游戏中我们要做的所有着色工作。和以往一样,你可以随便修改代码,添加更多效果,进行实验!

增加状态

在下一节,我们将制作一个非常简单的歌曲选择菜单。为此,我们将在一些状态值上下手,这就需要修改一些地方。为了创建一个状态,我们需要新建一个新的枚举,并将其包装成 State 的资源加到游戏代码中。然后,我们可以使用 on_state_updateon_state_enteron_state_exit 等方法为每个系统分配特定的状态。

我们开始吧。首先,打开 consts.rs,添加 state 枚举:


#![allow(unused)]
fn main() {
/// Stage for our systems
pub const APP_STATE_STAGE: &str = "app_state_stage";

/// States
#[derive(Clone, PartialEq, Eq, Hash)]
pub enum AppState {
    Menu,
    Game,
    MakeMap,
}
}

AppState 将代表我们游戏的三个模式:歌曲选择菜单,游戏和(尚未实现的)地图制作模式。

我们,还添加了一个字符串用于表示我们的系统的阶段。现在我们进入 main.rs 中,添加 State 以及更新后的新阶段两个资源:

// main.rs
use crate::consts::*;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_resource(State::new(AppState::Menu)) // <--- 新代码
        .add_stage_after( // <--- 新代码
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .add_plugin(AudioPlugin)
        .add_plugin(ShadersPlugin)
        .run();
}

现在游戏不会有任何变化,因为我们的“系统”仍然以普通的方式加入。为了改变这一点,我们将从修改 arrows.rs 中的 ArrowsPlugin 入手:


#![allow(unused)]
fn main() {
// arrows.rs
pub struct ArrowsPlugin;
impl Plugin for ArrowsPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ArrowMaterialResource>()
            .init_resource::<Events<CorrectArrowEvent>>()
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::Game,
                setup_target_arrows.system(),
            )
            .on_state_update(APP_STATE_STAGE, AppState::Game, spawn_arrows.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, move_arrows.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, despawn_arrows.system());
    }
}
}

我们必须把 add_startup_system替换为 on_stage_enter,将 add_system 替换为 on_stage_update。对于这些函数,我们必须传入“系统”运行的阶段和状态。因为我们想要所有这些运行在 Game 状态,就是我们使用的那个。

现在我们看看 ui.rs


#![allow(unused)]
fn main() {
// ui.rs
use crate::consts::*;

pub struct UIPlugin;
impl Plugin for UIPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.on_state_enter(APP_STATE_STAGE, AppState::Game, setup_ui.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, update_time_text.system())
            .on_state_update(APP_STATE_STAGE, AppState::Game, update_score_text.system());
    }
}
}

audio.rs 中的代码:


#![allow(unused)]
fn main() {
// audio.rs
use crate::consts::*;

pub struct AudioPlugin;
impl Plugin for AudioPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.on_state_update(APP_STATE_STAGE, AppState::Game, start_song.system());
    }
}
}

我们已经修改了所有与 Game 状态相关的“系统”,所以如果你现在运行游戏,除了看到动画背景外,什么也不会发生,因为我们要从 Menu 开始,但是我们还没有相关的“系统”。

添加基础菜单

我们现在将制作一个带有按钮的菜单,它可以让我们选择一首歌曲或进入游戏地图制作模式。我们将它保存在一个新的文件 menu.rs 中。我们新建一个资源来保存对应的素材:


#![allow(unused)]
fn main() {
use crate::consts::*;
use bevy::prelude::*;

struct ButtonMaterials {
    none: Handle<ColorMaterial>,
    normal: Handle<ColorMaterial>,
    hovered: Handle<ColorMaterial>,
    pressed: Handle<ColorMaterial>,
    font: Handle<Font>,
}

impl FromResources for ButtonMaterials {
    fn from_resources(resources: &Resources) -> Self {
        let mut materials = resources.get_mut::<Assets<ColorMaterial>>().unwrap();
        let asset_server = resources.get_mut::<AssetServer>().unwrap();

        ButtonMaterials {
            none: materials.add(Color::NONE.into()),
            normal: materials.add(Color::rgb(0.15, 0.15, 0.15).into()),
            hovered: materials.add(Color::rgb(0.25, 0.25, 0.25).into()),
            pressed: materials.add(Color::rgb(0.35, 0.75, 0.35).into()),
            font: asset_server.load("fonts/FiraSans-Bold.ttf"),
        }
    }
}
}

这看起来很标准。接下来,我们将创建一个“系统”来构建菜单元素。


#![allow(unused)]
fn main() {
// menu.rs
struct MenuUI;
fn setup_menu(commands: &mut Commands, button_materials: Res<ButtonMaterials>) {
    commands
        .spawn(NodeBundle {
            style: Style {
                size: Size::new(Val::Percent(100.), Val::Percent(100.)),
                display: Display::Flex,
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::FlexStart,
                justify_content: JustifyContent::FlexStart,
                ..Default::default()
            },
            material: button_materials.none.clone(),
            ..Default::default()
        })
        .with(MenuUI)
        .with_children(|parent| {
            // 生成新按钮
            parent
                .spawn(ButtonBundle {
                    style: Style {
                        size: Size::new(Val::Px(350.0), Val::Px(65.0)),
                        margin: Rect::all(Val::Auto),
                        justify_content: JustifyContent::Center,
                        align_items: AlignItems::Center,
                        ..Default::default()
                    },
                    material: button_materials.normal.clone(),
                    ..Default::default()
                })
                .with_children(|parent| {
                    parent.spawn(TextBundle {
                        text: Text {
                            value: "Play".to_string(),
                            font: button_materials.font.clone(),
                            style: TextStyle {
                                font_size: 20.0,
                                color: Color::rgb(0.9, 0.9, 0.9),
                                ..Default::default()
                            },
                        },
                        ..Default::default()
                    });
                });
        });
}
}

这看起来非常类似于 ui.rs 中的 setup_ui。但结构类似于 NodeBundle > ButtonBundle > TextBundle

我们还要创建一个删除所有按钮的系统,这样我们就可以在离开菜单时运行它。如果不这样做,菜单按钮会一直停留在游戏屏幕上。


#![allow(unused)]
fn main() {
// menu.rs
fn despawn_menu(commands: &mut Commands, query: Query<(Entity, &MenuUI)>) {
    for (entity, _) in query.iter() {
        commands.despawn_recursive(entity);
    }
}
}

给这个系统实现插件:


#![allow(unused)]
fn main() {
// menu.rs
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ButtonMaterials>()
            .on_state_enter(APP_STATE_STAGE, AppState::Menu, setup_menu.system())
            .on_state_exit(APP_STATE_STAGE, AppState::Menu, despawn_menu.system());
    }
}
}

把它添加到 main.rs 中,导入它并在 main 函数中增加 .add_plugin(MenuPlugin) 调用:

// main.rs
mod menu;
use menu::MenuPlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_resource(State::new(AppState::Menu))
        .add_stage_after(
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .add_plugin(AudioPlugin)
        .add_plugin(ShadersPlugin)
        .add_plugin(MenuPlugin) // <--- 新代码
        .run();
}


fn setup(commands: &mut Commands) {
    commands
        .spawn(Camera2dBundle::default())
        .spawn(CameraUiBundle::default());
}

我们还要更改 setup,不再是 SongConfig 资源,因为我们会在玩家点击按钮选择歌曲时添加它。

现在运行游戏会显示下面这样的按钮:

目前,单击按钮并将鼠标悬停在按钮上会发现按钮什么也没有干,所以我们需要让菜单能根据需要有所反应。首先,我们将添加一个系统,根据按钮的交互改变颜色:


#![allow(unused)]
fn main() {
// menu.rs
fn button_color_system(
    button_materials: Res<ButtonMaterials>,
    mut query: Query<
        (&Interaction, &mut Handle<ColorMaterial>),
        (Mutated<Interaction>, With<Button>),
    >,
) {
    for (interaction, mut material) in query.iter_mut() {
        match *interaction {
            Interaction::Clicked => {
                *material = button_materials.pressed.clone();
            }
            Interaction::Hovered => {
                *material = button_materials.hovered.clone();
            }
            Interaction::None => {
                *material = button_materials.normal.clone();
            }
        }
    }
}
}

这里我们使用的是 Interaction 组件,它和 ButtonBundle 一起。它有三个不同的变体,ClickedHoveredNone。分别表示:单机按钮,悬停在按钮上,不做任何事。我们将匹配按钮的所有可能的值,从而做出不同的反应。将 MenuPlugin 加到游戏中,运行游戏,观察鼠标悬停、点击或移开时按钮的颜色是如何变化的。

视频资源

优化菜单

我们还需要两个东西:在文件夹中显示歌曲列表菜单,以及正式开始游戏的按钮。我们从第一点开始,在 menu.rs 中增加一个方法:


#![allow(unused)]
fn main() {
// menu.rs
use std::fs::read_dir;

pub fn get_songs() -> Vec<String> {
    let paths = read_dir("assets/songs").unwrap();

    let mut vec = vec![];
    for path in paths {
        let path = path.unwrap().path();

        if "toml" == path.as_path().extension().unwrap() {
            vec.push(
                path.as_path()
                    .file_stem()
                    .unwrap()
                    .to_str()
                    .unwrap()
                    .to_string(),
            );
        }
    }
    vec
}
}

这个函数使用 read_dir 获取 songs 目录中的文件,并将 .toml 后缀文件路径追加到数组中。

现在我们可以从 setup_menu 内部调用这个函数,来为 get_songs 得到的每个文件增加按钮。首先,我们创建一个枚举组件加到按钮中:


#![allow(unused)]
fn main() {
// menu.rs
enum MenuButton {
    MakeMap,
    PlaySong(String),
}
impl MenuButton {
    fn name(&self) -> String {
        match self {
            Self::MakeMap => "Make map".to_string(),
            Self::PlaySong(song) => format!("Play song: {}", song),
        }
    }
}
}

枚举的第一个变体 MakeMap 用于进入地图制作模式(如果实现了)。另一个变体 PlaySong 用于开始特定的歌曲游戏。


#![allow(unused)]
fn main() {
// menu.rs
fn setup_menu(commands: &mut Commands, button_materials: Res<ButtonMaterials>) {
    // 制作按钮列表
    let mut buttons: Vec<MenuButton> = get_songs()
        .iter()
        .map(|name| MenuButton::PlaySong(name.clone()))
        .collect();
    buttons.push(MenuButton::MakeMap);

    commands
        .spawn(NodeBundle {
            style: Style {
                size: Size::new(Val::Percent(100.), Val::Percent(100.)),
                display: Display::Flex,
                flex_direction: FlexDirection::Column,
                align_items: AlignItems::FlexStart,
                justify_content: JustifyContent::FlexStart,
                ..Default::default()
            },
            material: button_materials.none.clone(),
            ..Default::default()
        })
        .with(MenuUI)
        .with_children(|parent| {
            // 将所有按钮以子按钮的方式加入
            for button in buttons {
                // 生成新按钮
                parent
                    .spawn(ButtonBundle {
                        style: Style {
                            size: Size::new(Val::Px(350.0), Val::Px(65.0)),
                            margin: Rect::all(Val::Auto),
                            justify_content: JustifyContent::Center,
                            align_items: AlignItems::Center,
                            ..Default::default()
                        },
                        material: button_materials.normal.clone(),
                        ..Default::default()
                    })
                    .with_children(|parent| {
                        parent.spawn(TextBundle {
                            text: Text {
                                value: button.name(),
                                font: button_materials.font.clone(),
                                style: TextStyle {
                                    font_size: 20.0,
                                    color: Color::rgb(0.9, 0.9, 0.9),
                                    ..Default::default()
                                },
                            },
                            ..Default::default()
                        });
                    })
                    .with(button);
            }
        });
}
}

我们已替换了 with_children 的内容,来循环遍历按钮列表,从而创建按钮。

注意:我们设置按钮的方式有点菜,所以如果你有很多按钮显示的话,它会看起来很奇怪!添加一个滚动条或者其他改善方式就留给读者作为练习了。

效果如下图所示:

现在我们要让按钮可用。为此,我们添加另一个“系统”来监听点击事件:


#![allow(unused)]
fn main() {
// menu.rs
use crate::types::load_config;

fn button_press_system(
    commands: &mut Commands,
    asset_server: Res<AssetServer>,
    query: Query<(&Interaction, &MenuButton), (Mutated<Interaction>, With<Button>)>,
    mut state: ResMut<State<AppState>>,
) {
    for (interaction, button) in query.iter() {
        // 在这一帧中检测按钮是否被点击
        if *interaction == Interaction::Clicked {
            match button {
                // 如果地图制作按钮被点击,改变模式
                MenuButton::MakeMap => state
                    .set_next(AppState::MakeMap)
                    .expect("Couldn't switch state to MakeMap"),
                // 如果它是一个播放歌曲按钮,加载对应配置,插入资源,然后改变态模式
                MenuButton::PlaySong(song) => {
                    let config = load_config(&*format!("{}.toml", song), &asset_server);
                    commands.insert_resource(config);
                    state
                        .set_next(AppState::Game)
                        .expect("Couldn't switch state to Game")
                }
            };
        }
    }
}
}

在这个系统中,我们循环遍历每个按钮,并检查它们是否处于点击状态。如果是,我们会匹配按钮的类型,执行相应的逻辑。对于 MakeMap,我们只需使用 set_next 改变状态。对于 PlaySong,用我们创建的 SongConfig 函数来加载选定歌曲的 SongConfig,在将状态更改为 Game 之前,我们使用 insert_resource 添加歌曲。

最后,我们应该把这个系统添加到 MenuPlugin,设置成 Menu 状态更新时运行:


#![allow(unused)]
fn main() {
// menu.rs
pub struct MenuPlugin;
impl Plugin for MenuPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ButtonMaterials>()
            .on_state_enter(APP_STATE_STAGE, AppState::Menu, setup_menu.system())
            .on_state_update(
                APP_STATE_STAGE,
                AppState::Menu,
                button_color_system.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::Menu,
                button_press_system.system(),
            )
            .on_state_exit(APP_STATE_STAGE, AppState::Menu, despawn_menu.system());
    
}
}

现在运行游戏,我们会看到按钮正常工作,开始游戏:

视频资源

但有个大问题!当我们开始游戏时,时间在跑了,箭头却没有显示!因为我们使用 time_since_startup 来检查何时生成箭头,当我们进入 Game 状态时,值已经过了第一个箭头的生成时间,所以不会出现,其它箭头也不会出现。为了解决这个问题,我们将在后面制作一个包装器,这样我们就可以在进入 Game 模式时重置它。

时间系统封装

我们的时间包装器非常类似于 Bevy 的时间资源实现,不同的是它需要在我们进入 GameMakeMap 状态时重置时间系统。复制所有代码只是为了改善一些糟糕的东西,但这会让我们在未来做其他工作时带来方便,比如暂停。这也是一个了解 Bevy 源码的好机会。

此外,通过同时拥有一个正常的时间资源和我们自己包装的版本,可以让我们使用正常的时间资源,以及其他需要控制时间的场景。例如,我们要继续为游戏背景使用正常时间,因为我们希望它在所有状态下都能工作。

打开一个新文件, time.rs


#![allow(unused)]
fn main() {
use crate::consts::*;
use bevy::{
    prelude::*,
    utils::{Duration, Instant},
};

pub struct ControlledTime {
    delta: Duration,
    last_update: Option<Instant>,
    delta_seconds_f64: f64,
    delta_seconds: f32,
    seconds_since_startup: f64,
    startup: Instant,
}
impl Default for ControlledTime {
    fn default() -> Self {
        Self {
            delta: Duration::from_secs(0),
            last_update: None,
            startup: Instant::now(),
            delta_seconds_f64: 0.0,
            seconds_since_startup: 0.0,
            delta_seconds: 0.0,
        }
    }
}
}

这里我们添加了一个与 Bevy 的 time 相同的结构体,使用相同的 Default 实现,我们将其称为 ControlledTime

现在,添加我们想要的方法,它来自于这个资源,此外我们还会添加一个 reset_time 函数,它将时间设置为 0:


#![allow(unused)]
fn main() {
// time.rs
impl ControlledTime {
    pub fn reset_time(&mut self) {
        self.startup = Instant::now();
        self.seconds_since_startup = 0.0;
    }

    pub fn update(&mut self) {
        let now = Instant::now();
        self.update_with_instant(now);
    }

    pub fn update_with_instant(&mut self, instant: Instant) {
        if let Some(last_update) = self.last_update {
            self.delta = instant - last_update;
            self.delta_seconds_f64 = self.delta.as_secs_f64();
            self.delta_seconds = self.delta.as_secs_f32();
        }

        let duration_since_startup = instant - self.startup;
        self.seconds_since_startup = duration_since_startup.as_secs_f64();
        self.last_update = Some(instant);
    }

    /// 当前标记和最后一次标记的时间差是 [`f32`] 秒
    #[inline]
    pub fn delta_seconds(&self) -> f32 {
        self.delta_seconds
    }

    /// 当前标记和最后一次标记的时间差是 [`f64`] 秒
    #[inline]
    pub fn delta_seconds_f64(&self) -> f64 {
        self.delta_seconds_f64
    }

    /// 启动后的时间,以秒为单位
    #[inline]
    pub fn seconds_since_startup(&self) -> f64 {
        self.seconds_since_startup
    }
}
}

考虑到这一点,我们需要一个能够更新时间的“系统”:


#![allow(unused)]
fn main() {
// time.rs
pub fn update_time(mut time: ResMut<ControlledTime>) {
    time.update();
}
}

并且有一个系统对时间进行重置


#![allow(unused)]
fn main() {
// time.rs
pub fn reset_time_when_entering_game(mut time: ResMut<ControlledTime>) {
    time.reset_time();
}
}

我们还会添加一个插件来把它们放在一起:


#![allow(unused)]
fn main() {
// time.rs
pub struct TimePlugin;
impl Plugin for TimePlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<ControlledTime>()
            .on_state_update(APP_STATE_STAGE, AppState::Game, update_time.system())
            .on_state_update(APP_STATE_STAGE, AppState::MakeMap, update_time.system())
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::Game,
                reset_time_when_entering_game.system(),
            )
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::MakeMap,
                reset_time_when_entering_game.system(),
            );
    }
}
}

我们在 GameMapMaker 执行期间设置了 update_time,并且 reset_time_when_entering_game 在这两种模式下都会执行。

跟其它插件一样,我们在 main.rs 中添加:

// main.rs
mod time;
use time::TimePlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_resource(State::new(AppState::Menu))
        .add_stage_after(
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .add_plugin(AudioPlugin)
        .add_plugin(ShadersPlugin)
        .add_plugin(MenuPlugin)
        .add_plugin(TimePlugin) // <--- New
        .run();
}

我们需要做的最后一件事就是用 ControlledTime 代替 Time

首先是 ui.rs,我们只需改变 update_time_text 中的 time 参数:


#![allow(unused)]
fn main() {
// ui.rs
use crate::time::ControlledTime;

fn update_time_text(time: Res<ControlledTime>, mut query: Query<(&mut Text, &TimeText)>) {
    [...]
}
}

audio.rs 文件也一样,将 Time 替换为 ControlledTime


#![allow(unused)]
fn main() {
// audio.rs
use crate::time::ControlledTime;

fn start_song(audio: Res<Audio>, time: Res<ControlledTime>, config: Res<SongConfig>) {
    [...]
}
}

最后是 arrows.rs 文件,要修改的地方多一些:


#![allow(unused)]
fn main() {
// main.rs
use crate::time::ControlledTime;

/// Spawns arrows
fn spawn_arrows(
    commands: &mut Commands,
    mut song_config: ResMut<SongConfig>,
    materials: Res<ArrowMaterialResource>,
    time: Res<ControlledTime>,
) {
    [...]
}

/// Moves the arrows forward
fn move_arrows(time: Res<ControlledTime>, mut query: Query<(&mut Transform, &Arrow)>) {
    [...]
}
}

现在运行游戏,可以看到菜单和游戏正常工作了:

视频资源

太棒了!

添加简单的地图制作模式

在本节中,我们添加一个场景模式来帮助我们给歌曲创建地图。我们想要的是当歌曲播放时,我们何时按下按键,并将它们保存到一个文件中。

我们打开一个新文件 map_maker.rs,我们从添加资源和“系统”开始:


#![allow(unused)]
fn main() {
use crate::time::ControlledTime;
use crate::consts::*;
use crate::types::{
    ArrowTimeToml,
    Directions::{self, *},
    Speed,
};
use bevy::{
    app::AppExit,
    input::{keyboard::KeyCode, Input},
    prelude::*,
};
use serde_derive::Serialize;
use std::fs::File;
use std::io::prelude::*;

#[derive(Serialize, Debug, Default)]
/// 跟踪按键被按下的时间
struct Presses {
    arrows: Vec<ArrowTimeToml>,
}

/// 保存被按下的键
fn save_key_presses(
    time: Res<ControlledTime>,
    keyboard_input: Res<Input<KeyCode>>,
    mut presses: ResMut<Presses>,
) {
    let directions = [Up, Down, Left, Right];
    for direction in directions.iter() {
        if direction.key_just_pressed(&keyboard_input) {
            presses.arrows.push(ArrowTimeToml {
                click_time: time.seconds_since_startup(),
                speed: Speed::Slow,
                direction: *direction,
            });
        }
    }
}
}

我们大量添加需要增加的东西,我们创建 Presses 资源,它保存了一个 ArrowTimeToml 列表,以及一个当方向键被按下时添加到该列表的“系统”,并循环所有方向的按键。

我们还需要一个系统来监听 AppExit 事件,并将 ArrowTimeToml 列表保存到文件中:


#![allow(unused)]
fn main() {
// map_maker.rs
fn save_to_file_on_exit(
    mut event_reader: Local<EventReader<AppExit>>,
    events: Res<Events<AppExit>>,
    presses: Res<Presses>,
) {
    for _event in event_reader.iter(&events) {
        let text = toml::to_string(&*presses).expect("Couldn't convert to toml text");

        let mut file = File::create("map.toml").expect("Couldn't open map.toml");
        file.write_all(text.as_bytes())
            .expect("Couldn't write to map.toml");
    }
}
}

我们得做点什么来提高这个模式的易用性。当玩家按下一个按键时,相应的方向会有箭头出现在屏幕上。我们将添加两个系统,一个生成箭头,一个切换箭头的可见性:


#![allow(unused)]
fn main() {
// map_maker.rs
struct MapMakerArrow(Directions);

/// Creates map maker arrows
fn setup_map_maker_arrows(
    commands: &mut Commands,
    mut materials: ResMut<Assets<ColorMaterial>>,
    asset_server: ResMut<AssetServer>,
) {
    let border_handle = materials.add(asset_server.load("images/arrow_border.png").into());

    let directions = [Up, Down, Left, Right];
    for direction in directions.iter() {
        let y = match direction {
            Up => 150.,
            Down => 50.,
            Left => -50.,
            Right => -150.,
        };

        let mut transform = Transform::from_translation(Vec3::new(0., y, 1.));
        transform.rotate(Quat::from_rotation_z(direction.rotation()));
        commands
            .spawn(SpriteBundle {
                material: border_handle.clone(),
                sprite: Sprite::new(Vec2::new(140., 140.)),
                transform,
                ..Default::default()
            })
            .with(MapMakerArrow(*direction));
    }
}

/// 根据是否按下对应的键来切换可见性
fn toggle_map_maker_arrows(
    mut query: Query<(&mut Visible, &MapMakerArrow)>,
    keyboard_input: Res<Input<KeyCode>>,
) {
    for (mut visible, arrow) in query.iter_mut() {
        visible.is_visible = arrow.0.key_pressed(&keyboard_input);
    }
}
}

第一个“系统”非常类似于 spawn_target_arrows,它只是创建精灵,并添加我们刚刚声明的 MapMakerArrow 组件。第二个系统是 toggle_map_maker_arrows,根据箭头对应的方向键是否被按下来设置箭头的可见性。我们通过设置精灵的 Visible 中的 is_visible 字段来做到这一点。

这里有一个问题,我们目前给 Directions 声明的 key_just_pressed 方法使用了 just_pressed,这只会在按键被按下的第一帧时才会生效。我们希望玩家按下按键,箭头就立即显示,所以我们添加了另一种 pressed 方法,它可以实现我们想要的:


#![allow(unused)]
fn main() {
// types.rs
impl Directions {
    [Other methods...]

    /// 检查是否按下与当前方向相同的方向键
    pub fn key_pressed(&self, input: &Input<KeyCode>) -> bool {
        let keys = match self {
            Directions::Up => [KeyCode::Up, KeyCode::D],
            Directions::Down => [KeyCode::Down, KeyCode::F],
            Directions::Left => [KeyCode::Left, KeyCode::J],
            Directions::Right => [KeyCode::Right, KeyCode::K],
        };

        keys.iter().any(|code| input.pressed(*code))
    }
}
}

这样,我们的 toggle_map_maker_arrows 系统就可以正常工作了!我们还要给所有的歌曲地图实现一个插件:


#![allow(unused)]
fn main() {
// map_maker.rs
pub struct MapMakerPlugin;
impl Plugin for MapMakerPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<Presses>()
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::MakeMap,
                setup_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                toggle_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_key_presses.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_to_file_on_exit.system(),
            );
    }
}
}

要想让它运行起来,我们还需要在 main.rs 中加上“系统”的调用代码:

// main.rs
mod map_maker;
use map_maker::MapMakerPlugin;

fn main() {
    App::build()
        // Set antialiasing to use 4 samples
        .add_resource(Msaa { samples: 4 })
        // Set WindowDescriptor Resource to change title and size
        .add_resource(WindowDescriptor {
            title: "Rhythm!".to_string(),
            width: 800.,
            height: 600.,
            ..Default::default()
        })
        .add_resource(State::new(AppState::Menu))
        .add_stage_after(
            stage::UPDATE,
            APP_STATE_STAGE,
            StateStage::<AppState>::default(),
        )
        .init_resource::<ScoreResource>()
        .add_startup_system(setup.system())
        .add_system(exit_on_esc_system.system())
        .add_plugins(DefaultPlugins)
        .add_plugin(ArrowsPlugin)
        .add_plugin(UIPlugin)
        .add_plugin(AudioPlugin)
        .add_plugin(ShadersPlugin)
        .add_plugin(MenuPlugin)
        .add_plugin(TimePlugin)
        .add_plugin(MapMakerPlugin) // <--- 新增代码
        .run();
}

现在,我们可以运行游戏来看看地图制作模式是否能正常工作:

视频资源

请记住,在游戏终端中使用 ESC 键退出,而不是 Ctrl+C 键,这样才能保存文件成功。

这是我们得到的一个文件示例:

[[arrows]]
click_time = 1.04939044
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 1.658164574
speed = "Slow"
direction = "Down"

[[arrows]]
click_time = 2.191576505
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 2.558483463
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 2.858588189
speed = "Slow"
direction = "Left"

[[arrows]]
click_time = 3.4904190330000002
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 3.9252477949999998
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 4.240984206
speed = "Slow"
direction = "Left"

[[arrows]]
click_time = 4.62353972
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 4.97381796
speed = "Slow"
direction = "Up"

[[arrows]]
click_time = 5.308837329
speed = "Slow"
direction = "Left"

现在我们可以将它添加到 assets/songs 目录下,添加 namefilename 字段,这样就有了歌曲的工作地图!

我们需要做的最后一件事是在地图制作模式下播放歌曲,否则它就显得有点鸡肋。我们简单实现一下,并且给使用的歌曲路径硬编码,这样可以让教程简短一些(如果还算短的话)。我们将使用路径 assets/map_maker_song.mp3 中的歌曲。玩家必须在地图制作器中修改文件路径来更换歌曲。每个人都可以实现一些自己的“系统”,以更容易地选择地图制作器中使用的歌曲。

在地图制作器中播放歌曲

为了让音乐进入地图制作器,我们先要添加一个资源来保存 Handle<AudioSource>。我们要为该资源实现 FromResources,这样可以在开始时就加载它,当把它加载到地图制作器中时,它就准备好可以玩了:


#![allow(unused)]
fn main() {
struct MapMakerAudio(Handle<AudioSource>);
impl FromResources for MapMakerAudio {
    fn from_resources(resources: &Resources) -> Self {
        let asset_server = resources.get_mut::<AssetServer>().unwrap();
        let audio = asset_server.load("map_maker_song.mp3");
        Self(audio)
    }
}
}

这一次我决定使用一个元组结构体来处理资源,因为我们只有一个字段。FromResources 实现了静态资源服务器,它可以加载音频资源。

在那之后,我们要创建一个新“系统”来进行播放音频,我们将把它设置为进入 MakeMap 的状态时执行:


#![allow(unused)]
fn main() {
// map_maker.rs
fn start_song(audio: Res<Audio>, map_maker_audio: Res<MapMakerAudio>) {
    audio.play(map_maker_audio.0.clone());
}
}

我们要做的最后一件事是将这两个资源加到插件中:


#![allow(unused)]
fn main() {
// map_maker.rs
pub struct MapMakerPlugin;
impl Plugin for MapMakerPlugin {
    fn build(&self, app: &mut AppBuilder) {
        app.init_resource::<Presses>()
            .init_resource::<MapMakerAudio>() // <--- 新代码
            .on_state_enter(APP_STATE_STAGE, AppState::MakeMap, start_song.system()) // <--- 新代码
            .on_state_enter(
                APP_STATE_STAGE,
                AppState::MakeMap,
                setup_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                toggle_map_maker_arrows.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_key_presses.system(),
            )
            .on_state_update(
                APP_STATE_STAGE,
                AppState::MakeMap,
                save_to_file_on_exit.system(),
            );
    }
}
}

找一个音频文件,并将其放到 assets/map_maker_song.mp3 中,如果你运行游戏,进入地图制作模式时,应该可以听到音频播放了!

至此,我们的游戏教程就结束了。和往常一样,你可以随意尝试,修改一些东西,让它成为你的东西!如果你有任何的改进,请在 Twitter 标记我,这样我就能看到了!

下一步

如果你还没想好要做什么样的二次开发,以下提供一些可以尝试的想法:

  • 1.添加必须在特定的时间内保持状态的箭头。
  • 2.改进地图制作器,增加选择歌曲的功能。
  • 3.给游戏增加一个游戏结束画面。
  • 4.增加一种歌曲播放完后,回到菜单的方式
  • 5.创建一个可以改变点击阈值的“系统”,可以让玩家在困难模式时选择简单模式,玩家很轻松则切换到困难模式。

「译」Arenas in Rust

译者:MATRIXKOO / 后期编辑:张汉东

原文: Arenas in Rust


Rust 中的 Arenas 内存池

最近有一些关于 Rust 中 Arenas 的讨论,我想我应该写一篇关于这个文章。

在 Rust 中,Arenas 并不是那种 "典型的" 问题,所以很少有人了解它们。只会在各种用例的应用程序中有看到它们。一般来说,你只需要调包就行了,没必要为它使用 unsafe 。因此无需专门去学会写它,但这些知识也不是无用的,尤其是对有使用 Arenas 的人来说更是如此 。

此外,我在实现自引用(self-referential)的 Arenas 时会涉及到一系列非常lifetime操作 ,我以前完全没写过。

我主要是为了写一些很酷的生命周期效应而写的,但是我认为有必要写一篇针对所有 Rustaceans 的介绍。如果你已经知道什么是 Arenas,并且想看到一些很炫酷的 lifetime 技巧,则可以直接跳到这里阅读。

什么是 arena ?

Arenas 本质上是一种将预期生命周期相同的内存进行分组的模式。比如说有时需要在某个生命周期内分配一堆对象,之后这些对象将被全部销毁。每次都调用系统分配器效率低下,更可取的是为对象预分配一堆内存,并在完成处理后立即将其全部清理干净。 >译者注: 没错就是 cache

从广义上讲,使用 Arenas 有以下两个原因:

首先,如上所述,使用它主要目标可能是减轻内存消耗。例如,在游戏或应用程序中,可能存在大量需要逐帧分配,使用完立即销毁的情况。特别是在游戏开发中,这非常普遍,而内存压力是游戏开发人员比较关心的事情。使用 Arenas,可以轻松地分配一个 Arena,在每个帧中将其填满,并在帧结束后将其清空。缓存局部性还能其他好处:可以确保大多数每帧对象在帧期间(可能比其他对象使用得更多)位于缓存中,因为它们是相邻分配的。

另一个原因可能是用于编写自引用的数据,例如带有环的复杂图,使用此方法数据可以一次全部清除。例如,在编写编译器时,type信息可能将需要引用其他类型或者其他数据,从而导致复杂的,可能是 type 的有环图。一旦推导出类型,就不需要专门销毁它了,因此可以使用一个 Arenas 来存储所有计算出的类型信息,当类型信息无关紧要时,就可以直接将他们一次性清楚。使用这种模式可以让代码不必担心自引用位是否会 “提前” 释放,它可以保证,如果存在一个 Ty ,则它的寿命与其他所有 Ty 一样长,并且可以直接引用它们。

译者注: 不会导致空引用

这两个目标不一定是相互联系的:可以使用一个 Arenas来同时实现两个目标。但是,也可以拥有一个禁止使用自引用类型的 Arenas(有得有失,你能得到一些好处)。在本文的稍后部分,我将实现一个允许自引用类型但对减轻内存分配压力影响不大的 Arenas,主要是为了易于实现。通常,如果要为编写用于自引用类型 的 Arenas,则可以使其同时减少分配器压力,但可能会有所取舍。

在 Rust 中如何使用 arena ?

一般来说,要使用 arena ,只需要调包。 我简单的搜索一下现有的 Arenas 实现,在这里。下面我会介绍两个我已经了解的库,但我只是挂 "二" 漏万。

要指出的是,如果只需要环结构,而不必使用 arenas ,那么出色的 petgraph 通常就足够了。 slotmap 也很不错; 它是类似地图的数据结构,可用于基于分代索引的自引用数据。

Bumpalo

Bumpalo 是一种快速的 bump allocator[1],它允许异构的内容,并且仅在不关心析构函数运行的情况下才允许循环。

参见: [1]https://blog.codingnow.com/2013/11/bump_pointer_allocator.html


#![allow(unused)]
fn main() {
use bumpalo::Bump;

// (example slightly modified from `bumpalo` docs)

// Create a new arena to bump allocate into.
let bump = Bump::new();

// Allocate values into the arena.
let scooter = bump.alloc(Doggo {
    cuteness: u64::max_value(),
    age: 8,
    scritches_required: true,
});

// Happy birthday, Scooter!
scooter.age += 1;
}

每次对 Bump::alloc() 的调用都会返回对已分配对象的可变引用。 这可以分配不同的对象,它们甚至可以相互引用 (不用环的话,借用检查就会强制这样做) 。 默认情况下,它不会在其内容上调用析构函数。 但是,可以使用 bumpalo::boxed(或Nightly上的自定义分配器)来实现这个效果。 可以类似地使用 bumpalo::collections来获取 bumpalo 支持的向量和字符串。 bumpalo::boxed 将不允许自引用。 x

typed-arena

[typed-arena](https://docs.rs/typed-arena/)是一个 areana 分配器,它只能存储单一类型的对象,但是就可以循环引用:


#![allow(unused)]
fn main() {
// Example from typed-arena docs

use std::cell::Cell;
use typed_arena::Arena;

struct CycleParticipant<'a> {
    other: Cell<Option<&'a CycleParticipant<'a>>>,
}

let arena = Arena::new();

let a = arena.alloc(CycleParticipant { other: Cell::new(None) });
let b = arena.alloc(CycleParticipant { other: Cell::new(None) });

// mutate them after the fact to set up a cycle
a.other.set(Some(b));
b.other.set(Some(a));
}

bumpalo 不同的是,typed-arenaarena 本身超出范围时,就会使用析构函数 >你或许想知道,析构函数在引用数据上的安全性–毕竟,无论哪一个变量被第二次销毁,析构函数都会读到悬挂的引用。 我们将在文章的后面对此进行介绍,但这与 drop 检查有关,特别是如果尝试自引用时,则 arena 元素 本身允许的唯一显式析构函数将是带有适当标记类型的析构函数。

实现一个支持自引用的 arena

写自引用代码是很有趣的,因为 Rust 非常警惕自引用数据。 但是 areana 可以让你清楚地将“我不在乎此对象”和“可以删除此对象”阶段分开,以允许自引用和循环类型出现。

人们很少需要实现自己的 arena,Bumpalo和Typedarena涵盖了大多数使用场景,实在没办法的话不妨先在 crates.io上 找一下。 但是,如果你的确需要直接实现的话,或者对具体的生命周期细节感兴趣,那么本节适合你。

在以下规则中实现输入条目为Entry的竞技场Arena的关键:

  • ArenaEntry 都应具有生命周期参数:Arena <'arena>Entry <'arena>
  • Arena 方法都应将 Arena <'arena> 接收为 &'arena 自身,即其自身类型为&'arena Arena <'arena>
  • Entry几乎应该始终以 &'arena Entry <'arena> 的形式传递(为此创建别名非常有用)
  • 使用内部可变性; Arena上的 &mut self 将使所有代码停止编译。 如果使用 unsafe的可变性,请确保 RefCell <Entry <'arena >> 具有 PhantomData

从生命周期的角度来看基本上就是这样,剩下的全部就是确定所需的 API 。 掌握了以上规则,只要确保定义区域与所需的保证一起使用,就不必了解底层生命周期的状况。

让我们看一个 实现,然后剖析其工作原理。

### 实现 我的库 elsa 在其中一个示例中使用 100% safe 的代码实现了一个 arena 。由于 elsa :: FrozenVec 要求其内容位于间接引用之后,因此该 arena 无法节省分配,并且它不是通用的,但这是一种合理的方式来说明生命周期的工作方式,而无需陷入 使用 unsafe 带来的麻烦之中。

该示例实现了 Person <'arena> 类型的 ArenaArena <'arena> 。目标是实现某种可能有环的有向社交图。


#![allow(unused)]
fn main() {
use elsa::FrozenVec;

struct Arena<'arena> {
    people: FrozenVec<Box<Person<'arena>>>,
}
}

elsa::FrozenVec是类似于 Vec 的仅支持追加内容的抽象,可让你调用push 而不需要传入可变的引用,这是只使用 safe 的一个实现。

每个 Person <'arena> 都有一个他们关注的人的列表,但也跟踪他们关注的人:


#![allow(unused)]
fn main() {
struct Person<'arena> {
    pub follows: FrozenVec<PersonRef<'arena>>,
    pub reverse_follows: FrozenVec<PersonRef<'arena>>,
    pub name: &'static str,
}

// following the rule above about references to entry types
type PersonRef<'arena> = &'arena Person<'arena>;
}

这个生命周期 arena 其实是 “arena本身的生命周期”。 从这开始事情就变得奇怪起来了:通常,如果一个有生命周期参数,则调用者可以选择其中的内容。 不必只是说“这是对象本身的生命周期”,调用者通常可以根据需要实例化 arena <'static> 或为某个 'a 实例化 Arena <'a> 。 但是在这里,我们声明 “ 'arenaarena 自身的生命周期" ; 很明显,一定有东西不太对。

这是我们实际实现的地方:


#![allow(unused)]

fn main() {
impl<'arena> Arena<'arena> {
    fn new() -> Arena<'arena> {
        Arena {
            people: FrozenVec::new(),
        }
    }
    
    fn add_person(&'arena self, name: &'static str,
                  follows: Vec<PersonRef<'arena>>) -> PersonRef<'arena> {
        let idx = self.people.len();
        self.people.push(Box::new(Person {
            name,
            follows: follows.into(),
            reverse_follows: Default::default(),
        }));
        let me = &self.people[idx];
        for friend in &me.follows {
            // We're mutating existing arena entries to add references,
            // potentially creating cycles!
            // 把每一个元素都加上了 引用 , 很有可能导致循环引用
            friend.reverse_follows.push(me)
        }
        me
    }

    fn dump(&'arena self) {
        // code to print out every Person, their followers, and the people who follow them
        // 打印出 `Person` ,他们的关注者 ,和关注的人
    }
}

}

注意 add_person中的&'arena self

此处的很好的实现了,“如果A 关注了 B,然后B又关注A” 这种通常需要分开处理的情况,但这仅是示例。

最后,我们可以像这样使用 arena

fn main() {
    let arena = Arena::new();
    let lonely = arena.add_person("lonely", vec![]);
    let best_friend = arena.add_person("best friend", vec![lonely]);
    let threes_a_crowd = arena.add_person("threes a crowd", vec![lonely, best_friend]);
    let rando = arena.add_person("rando", vec![]);
    let _everyone = arena.add_person("follows everyone", vec![rando, threes_a_crowd, lonely, best_friend]);
    arena.dump();
}

在这种情况下,所有 “可变性” 都发生在 arena 本身的实现中,但是此代码可能会将元素直接添加到 follows/reverse_follows 列表中,或者 Person 可能具有用于其他类型链接的 RefCells

生命周期是如何工作的

那么这是如何工作的呢? 如前所述,在 Rust 中使用这样的抽象,调用者通常可以自由地根据其处理方式来设置生存期。 例如,如果 HashMap<K,&'a str>,则 'a 将根据你尝试插入的内容的生存期进行调整。

当构造 Arena 时,其生命周期确实仍然不受限制,我们可以通过检查以下强制约束生命周期的代码来进行测试。 (依然是能过编译的)


#![allow(unused)]
fn main() {
let arena: Arena<'static> = Arena::new();
}

当你想做点什么的时候,就会停止工作:


#![allow(unused)]
fn main() {
let arena: Arena<'static> = Arena::new();
let lonely = arena.add_person("lonely", vec![]);
}
error[E0597]: `arena` does not live long enough
  --> examples/mutable_arena.rs:5:18
   |
4  |     let arena: Arena<'static> = Arena::new();
   |                -------------- type annotation requires that `arena` is borrowed for `'static`
5  |     let lonely = arena.add_person("lonely", vec![]);
   |                  ^^^^^ borrowed value does not live long enough
...
11 | }
   | - `arena` dropped here while still borrowed

add_person 方法以某种方式强制将 Arenaarena 参数设置为自己的生命周期,从而对其进行约束(并且无法用类型注释将其强制约束为其他任何值)。 这是与 add_person&'arena 自签名(即 self 是 &'arena Arena <'self> )的巧妙互动,以及'Arena in Arena <'arena>不变的生命周期

通常,在 Rust 程序中,生命周期具有"伸缩性"。 以下代码可以通过编译:


#![allow(unused)]
fn main() {
// ask for two strings *with the same lifetime*
// 要求具有相同 生命周期的 string 
fn take_strings<'a>(x: &'a str, y: &'a str) {}

// string literal with lifetime 'static
// 要求具有 `'static' 生命周期的 ` string literal`  
let lives_forever = "foo";
// owned string with shorter, local lifetime
// 要求具有 `local` 生命周期
let short_lived = String::from("bar");

// still works!
// 能跑通
take_strings(lives_forever, &*short_lived);
}

在这段代码中,Rust 很高兴地注意到,虽然 live_forever&* short_lived 具有不同的生命周期,但假装 life_forevertake_strings 函数的有效期内具有较短的生命周期是完全可以接受的。这只是引用,使用生命周期长的引用也适用于生命周期短的情况。

事实是,这种伸缩性并非对所用的生命周期都一样!nomicon chapter on subtyping and variance 一章详细说明了为什么会这样,但一般的经验法则是,大多数生命周期都是“紧缩的” (更专业的说法是 协变的 ),就像上面的&a str 中的一样,但是如果涉及某种形式的可变性,它们是不可变的,也称为“不变式”。如果使用的是函数类型,则具有 弹性的 生命周期 (即抗变的),但是这种情况很少见。

我们的 Arena <'arena> 使用内部可变性(通过 FrozenVec)使' arena不变。 让我们再次看一下两行代码。当编译器看到下面代码的第一行时,便会构建 arena,我们将其生命周期称为“ a”。此时 Arena 类型是 Arena <'?> ,其中的'?由表示形式表示,但生命周期不受限制。


#![allow(unused)]
fn main() {
let arena = Arena::new(); 
let lonely = arena.add_person("lonely", vec![]);
}

让我们把生命周期写清楚一点


#![allow(unused)]
fn main() {
let arena = Arena::new(); // type Arena<'?>, lives for 'a

// explicitly write the `self` that gets constructed when you call add_person
// 显式写出 在调用 add_person 时的构建函数
let ref_to_arena = &arena; // type &'a Arena<'?>
let lonely = Arena::add_person(ref_to_arena, "lonely", vec![]);
}

还记得我之前列出的第二条规则吗?

  • Arena方法都应将Arena <'arena>接收为&'arena自身,即其自身类型为&'arena Arena <'arena> 我们遵循这条规则;

add_person 的签名是 fn add_person(&'arena self)。这意味着 ref_to_arena 的生存期必须与 &'arena Arena <'arena> 模式匹配。目前,它的生命周期是&'a Arena <'?>,表示 '?强制与'a相同,即 arena 变量本身的生存期。如果生命周期是可变的,则编译器可以压缩其他生存期来适配它,但它是不变的,并且不受限制的生存期被强制转变成一个确切的生命周期。

通过这个巧妙的技巧,我们可以强制编译器将 Arena <'arena> 的生存期参数设置为其实例的生存期。

在此之后,其余的工作就非常简单了。 Arena <'arena> 拥有类型为 Person <'arena>的元素,也就是说:“Person 被允许引用具有 'arena 生命周期的元素, 例如 Arena "。

type PersonRef <'arena> =&'arena Person <'arena>是“引用在 Arena 中并允许从其中引用对象 Person 的引用的便捷缩写。

析构器如何工作

到目前为止,我还没有讨论存在析构函数的情况下如何保证安全。 如果 Arena 具 有循环引用,并且编写了一个析构函数读取去这些循环引用,那么在销毁的过程中就会导致悬垂引用。

这是 rust 十分模糊的地方。 除了“明确析构器巧妙地改变借用检查行为”,你没有什么需要必须去了解的。 但是了解这里的机制对建立一个更好的心智模型更有帮助。

如果将以下代码添加到 arena 示例中:


#![allow(unused)]
fn main() {
impl<'arena> Drop for Person<'arena> {
    fn drop(&mut self) {
        println!("goodbye {:?}", self.name);
        for friend in &self.reverse_follows {
            // potentially dangling!
            println!("\t\t{}", friend.name);
        }
    }
}
}

报错:

error[E0597]: `arena` does not live long enough
  --> examples/mutable_arena.rs:5:18
   |
5  |     let lonely = arena.add_person("lonely", vec![]);
   |                  ^^^^^ borrowed value does not live long enough
...
11 | }
   | -
   | |
   | `arena` dropped here while still borrowed
   | borrow might be used here, when `arena` is dropped and runs the destructor for type `Arena<'_>`

析构函数的存在在自引用数据的生命周期内巧妙地更改了借用检查器的行为。准确的规则是十分 tricky的,并在 nomicon中进行了解释,但实际上发生的是,在Person <'arena>上存在自定义析构函数后,'person arena(因为是Arena)的 ''arena变成了一个“在销毁时观测到的的生命周期"。然后在借用检查期间将其考虑在内---知道作用域末尾隐式调用drop()能够读取'arena的数据,Rust 做出了适当的结论,由于销毁本身是可变的操作,在销毁之后,调用drop() 读取内容是可行的 。

当然,需要问的一个合理问题是,如果析构函数不允许用 'arena '“包装”数据,应该如何在 arena 中存储例如 Box 或者 FrozenVec之类的东西呢?

原因是 Rust 知道 Box::Drop 由于不清楚 Person 是什么,也不会去试图知道,而无法检查 Person.follows的内容。

当然凡事都有例外,由于析构器可以调用指定的 trait 方法(或者特化的方法)来告诉如何读取Person的内容,如果有一个随机的泛型类型提供了这种方法,就可以再次巧妙地更改借用检查器的行为。 stdlib 类型和其他自定义数据结构通过转义填充#[may_dangle](也称为“ eyepatch” 毕竟析构器 "看不到" 生命周期)来实现这种目的,声明不会从生命周期或通用参数中读取自定义析构函数。

这也适用于诸如 typed-arena 之类的库;如果需要创建循环引用,则将无法在为放置在 arena 上的类型上编写自定义析构函数。但是只要避免创建循环引用就可以使用 typed-arena 编写自定义析构函数了;因此将无法使用内部可变性来使一个 arena 指向另一个 arena

感谢 Mark CohenNika Layzell 审阅了这篇文章的草稿。

「译」用Rust编写LLVM的玩具编译器

译者:iamazy / 后期编辑:张汉东

原文:A Toy Front-End for LLVM, written in Rust


正文

我目前的副业是用 Rust 编写一个可以将代码转换成 LLVM IR 的编译器。LLVM 的 API 对于新手来说有点令人生畏,而且没有很多有关的教程(有限的教程大多数还是基于 C++ 的,如何使用 Rust 做同样的事并不总是那么明确)。我希望当我准备做一件事情时,有人可以手把手的教我,这也是我要写这篇文章的原因。

对于 Rust,与 LLVM 的接口交互的最佳选择是使用 llvm-sys。互联网上的一些热心朋友在这里托管了一些关于 llvm-sys 的文档。当然,你还应该去查看 LLVM 的官方指南,因为它可以帮助你理解 LLVM 是如何“思考”的。这篇文章基本上是 LLVM 官方指南的 Rust 翻译。

你可以从这里获取最终的代码

搭建开发环境

对于新手,使用 LLVM 开发有一个可以复用的方式:

# `curl` is just so we can next install Rust
sudo apt-get -y install clang curl llvm-3.8-dev
curl https://sh.rustup.rs -sSf | sh

# The `llvm-sys` crate expects something called `llvm-config` on your PATH.
sudo ln -s /usr/bin/llvm-config-3.8 /usr/bin/llvm-config

如果你是在 Ubuntu 上执行上面的语句(你可能需要执行 apt-get update ),则没有任何问题。如果不是,你需要使用下面的 Vagrantfile 文件在 Vagrant Box 中运行上述语句。

Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-16.04"
end

你可以从执行 cargo init llvm-example --bin 开始,并将下面(从 llvm-sys 中拷贝)的代码写入 src/main.rs 中:

//! Construct a function that does nothing in LLVM IR.

extern crate llvm_sys as llvm;

use std::ptr;

fn main() {
    unsafe {
        // Set up a context, module and builder in that context.
        let context = llvm::core::LLVMContextCreate();
        let module = llvm::core::LLVMModuleCreateWithName(b"nop\0".as_ptr() as *const _);
        let builder = llvm::core::LLVMCreateBuilderInContext(context);

        // Get the type signature for void nop(void);
        // Then create it in our module.
        let void = llvm::core::LLVMVoidTypeInContext(context);
        let function_type = llvm::core::LLVMFunctionType(void, ptr::null_mut(), 0, 0);
        let function = llvm::core::LLVMAddFunction(module, b"nop\0".as_ptr() as *const _, function_type);

        // Create a basic block in the function and set our builder to generate
        // code in it.
        let bb = llvm::core::LLVMAppendBasicBlockInContext(context, function,b"entry\0".as_ptr() as *const _);
        llvm::core::LLVMPositionBuilderAtEnd(builder, bb);

        // Emit a `ret void` into the function
        llvm::core::LLVMBuildRetVoid(builder);

        // Dump the module as IR to stdout.
        llvm::core::LLVMDumpModule(module);

        // Clean up. Values created in the context mostly get cleaned up there.
        llvm::core::LLVMDisposeBuilder(builder);
        llvm::core::LLVMDisposeModule(module);
        llvm::core::LLVMContextDispose(context);
    }
}

并在你的 Cargo.toml 文件中:

[package]
name = "llvm-example"
version = "0.1.0"
authors = ["Ulysse Carion <ulysse@ulysse.io>"]

[[bin]]
name = "main"

[dependencies]
llvm-sys = "0.2"

你可以获得:

vagrant@vagrant:/vagrant$ cargo run
   Compiling llvm-example v0.1.0 (file:///vagrant)
     Running `target/debug/main`
; ModuleID = 'nop'

define void @nop() {
entry:
  ret void
}

完美!现在我们可以开始编写自己的东西了。

一段不太平凡的程序

首先,让我们编译一个程序,该程序通过从 main 函数中返回一个整数来简单的设置一个返回码。

下面是我使用的方式(我们有时候需要使用一个解析器,所以我先添加了 peg 库):

#![feature(plugin)]
#![plugin(peg_syntax_ext)]

extern crate llvm_sys as llvm;

use std::ffi::CString;
use std::fs::File;
use std::io::Read;
use std::ptr;

fn main() {
    let mut input = String::new();
    let mut f = File::open("in.ex").unwrap();
    f.read_to_string(&mut input).unwrap();

    let parsed_input = parser::program(&input).unwrap();

    unsafe {
        codegen(parsed_input);
    }
}

peg! parser(r#"
    #[pub]
    program -> String
        = i:int_literal "\n" { i }

    int_literal -> String
        = [0-9]+ { match_str.to_owned() }
"#);

unsafe fn codegen(input: String) {
    let context = llvm::core::LLVMContextCreate();
    let module = llvm::core::LLVMModuleCreateWithName(b"example_module\0".as_ptr() as *const _);
    let builder = llvm::core::LLVMCreateBuilderInContext(context);

    // In LLVM, you get your types from functions.
    let int_type = llvm::core::LLVMInt64TypeInContext(context);
    let function_type = llvm::core::LLVMFunctionType(int_type, ptr::null_mut(), 0, 0);
    let function = llvm::core::LLVMAddFunction(module, b"main\0".as_ptr() as *const _, function_type);

    let entry_name = CString::new("entry").unwrap();
    let bb = llvm::core::LLVMAppendBasicBlockInContext(context, function, entry_name.as_ptr());
    llvm::core::LLVMPositionBuilderAtEnd(builder, bb);

    // The juicy part: construct a `LLVMValue` from a Rust value:
    let int_value: u64 = input.parse().unwrap();
    let int_value = llvm::core::LLVMConstInt(int_type, int_value, 0);

    llvm::core::LLVMBuildRet(builder, int_value);

    // Instead of dumping to stdout, let's write out the IR to `out.ll`
    let out_file = CString::new("out.ll").unwrap();
    llvm::core::LLVMPrintModuleToFile(module, out_file.as_ptr(), ptr::null_mut());

    llvm::core::LLVMDisposeBuilder(builder);
    llvm::core::LLVMDisposeModule(module);
    llvm::core::LLVMContextDispose(context);
}

它起作用了!测试一下:

vagrant@vagrant:/vagrant$ cat in.ex
42
vagrant@vagrant:/vagrant$ cargo run
     Running `target/debug/main`
vagrant@vagrant:/vagrant$ lli-3.8 out.ll ; echo $?
42

有点酷哦!顺便提一下,下面是 out.ll 文件的内容:

; ModuleID = 'example_module'

define i64 @main() {
entry:
  ret i64 42
}

算术

让我们添加对数字的加减乘除操作的支持。为了实现这个,我们需要扩展我们的语法。我们引入一个枚举来代表 AST(抽象语法树)。


#![allow(unused)]
fn main() {
pub enum Expr {
    Add(Box<Expr>, Box<Expr>),
    Sub(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Div(Box<Expr>, Box<Expr>),
    Literal(String),
}
}

并扩展语法:


#![allow(unused)]
fn main() {
// `product` and `sum` are that way to get operator precedence
peg! parser(r#"
    use super::Expr;

    #[pub]
    program -> Expr
        = e:expression "\n" { e }

    expression -> Expr
        = sum

    sum -> Expr
        = a:product _ "+" _ b:sum { Expr::Add(Box::new(a), Box::new(b)) }
        / a:product _ "-" _ b:sum { Expr::Sub(Box::new(a), Box::new(b)) }
        / product

    product -> Expr
        = a:int_literal _ "*" _ b:product { Expr::Mul(Box::new(a), Box::new(b)) }
        / a:int_literal _ "/" _ b:product { Expr::Div(Box::new(a), Box::new(b)) }
        / int_literal

    int_literal -> Expr
        = [0-9]+ { Expr::Literal(match_str.to_owned()) }

    _ = " "*
"#);
}

接下来,可以提交代码。你可以指定诸如 “addtmp” 的字符串,这些字符串将被用作 IR 中对应“寄存器”名称的一部分。


#![allow(unused)]
fn main() {
// When you write out instructions in LLVM, you get back `LLVMValueRef`s. You
// can then use these references in other instructions.
unsafe fn codegen_expr(context: LLVMContextRef, builder: LLVMBuilderRef, expr: Expr) -> LLVMValueRef {
    match expr {
        Expr::Literal(int_literal) => {
            let int_type = llvm::core::LLVMInt64TypeInContext(context);
            llvm::core::LLVMConstInt(int_type, int_literal.parse().unwrap(), 0)
        },

        Expr::Add(lhs, rhs) => {
            let lhs = codegen_expr(context, builder, *lhs);
            let rhs = codegen_expr(context, builder, *rhs);

            let name = CString::new("addtmp").unwrap();
            llvm::core::LLVMBuildAdd(builder, lhs, rhs, name.as_ptr())
        },

        Expr::Sub(lhs, rhs) => {
            let lhs = codegen_expr(context, builder, *lhs);
            let rhs = codegen_expr(context, builder, *rhs);

            let name = CString::new("subtmp").unwrap();
            llvm::core::LLVMBuildSub(builder, lhs, rhs, name.as_ptr())
        },

        Expr::Mul(lhs, rhs) => {
            let lhs = codegen_expr(context, builder, *lhs);
            let rhs = codegen_expr(context, builder, *rhs);

            let name = CString::new("multmp").unwrap();
            llvm::core::LLVMBuildMul(builder, lhs, rhs, name.as_ptr())
        },

        Expr::Div(lhs, rhs) => {
            let lhs = codegen_expr(context, builder, *lhs);
            let rhs = codegen_expr(context, builder, *rhs);

            let name = CString::new("divtmp").unwrap();
            llvm::core::LLVMBuildUDiv(builder, lhs, rhs, name.as_ptr())
        },
    }
}
}

现在,你可以执行 10 * 4 + 20/2 - 8 之类的程序!如果你问我,那可真是太酷了。

变量

我们将采用简单的方式并假设程序不会执行任何烦人的操作,如引用未定义的变量等。我们只将变量存储在寄存器中,并将它们存在 HashMap<String, LLVMValueRef> 中,之所以有用是因为运行该程序只有这一种方式。

我们扩展语言和解析器:


#![allow(unused)]
fn main() {
pub enum Expr {
    Literal(String),
    Ref(String),
    Assign(String, Box<Expr>),
    Add(Box<Expr>, Box<Expr>),
    Sub(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Div(Box<Expr>, Box<Expr>),
}

peg! parser(r#"
    use super::Expr;

    #[pub]
    program -> Vec<Expr>
        = e:(expression ** "\n") "\n" { e }

    expression -> Expr
        = i:identifier _ "=" _ s:sum { Expr::Assign(i, Box::new(s)) }
        / sum

    sum -> Expr
        = a:product _ "+" _ b:sum { Expr::Add(Box::new(a), Box::new(b)) }
        / a:product _ "-" _ b:sum { Expr::Sub(Box::new(a), Box::new(b)) }
        / product

    product -> Expr
        = a:ref_or_literal _ "*" _ b:product { Expr::Mul(Box::new(a), Box::new(b)) }
        / a:ref_or_literal _ "/" _ b:product { Expr::Div(Box::new(a), Box::new(b)) }
        / ref_or_literal

    ref_or_literal -> Expr
        = i:identifier { Expr::Ref(i) }
        / int_literal

    identifier -> String
        = [a-zA-Z]+ { match_str.to_owned() }

    int_literal -> Expr
        = [0-9]+ { Expr::Literal(match_str.to_owned()) }

    _ = " "*
"#);
}

然后为这两个新的表达式添加支持:


#![allow(unused)]
fn main() {
unsafe fn codegen_expr(context: LLVMContextRef, builder: LLVMBuilderRef, names: &mut HashMap<String, LLVMValueRef>, expr: Expr) -> LLVMValueRef {
    match expr {
        // ...

        Expr::Ref(name) => {
            *names.get(&name).unwrap()
        },

        Expr::Assign(name, expr) => {
            let new_value = codegen_expr(context, builder, names, *expr);
            names.insert(name, new_value);
            new_value
        },
    }
}
}

并迅速的在 codegen 函数中更新:


#![allow(unused)]
fn main() {
let zero = llvm::core::LLVMConstInt(int_type, 0, 0);

let mut names = HashMap::new();
let mut return_value = zero; // return value on empty program
for expr in input {
    return_value = codegen_expr(context, builder, &mut names, expr);
}
llvm::core::LLVMBuildRet(builder, return_value);
}

现在让我们来一探究竟:


#![allow(unused)]
fn main() {
vagrant@vagrant:/vagrant$ cat in.ex
a = 3
b = 76
a + b
vagrant@vagrant:/vagrant$ cargo run
     Running `target/debug/main`
vagrant@vagrant:/vagrant$ cat out.ll
; ModuleID = 'example_module'

define i64 @main() {
entry:
  ret i64 79
}
}

If

在使用 if 关键字的时候遇到一些麻烦。让 if 起作用的最简单的方式就是将所有的变量存储在堆栈上。并让 LLVM 做一些优化。在 LLVM 中,你可以通过 alloca 指令创建一个栈变量,并使用 load/store 进行读写。

为了实现这个,我们通过添加新的解析规则再一次扩展了语言和语法。


#![allow(unused)]
fn main() {
expression -> Expr
    = if_expression
    / i:identifier _ "=" _ s:expression { Expr::Assign(i, Box::new(s)) }
    / sum

if_expression -> Expr
    = "if" _ e:expression _ "{\n" _ then_body:statements _ "}" _ "else" _ "{\n" _ else_body:statements _ "}" {
        Expr::If(Box::new(e), then_body, else_body)
    }
}

并在 AST 节点上添加了一个新的类型:


#![allow(unused)]
fn main() {
pub enum Expr {
    Literal(String),
    Ref(String),
    Assign(String, Box<Expr>),
    Add(Box<Expr>, Box<Expr>),
    Sub(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Div(Box<Expr>, Box<Expr>),
    If(Box<Expr>, Vec<Expr>, Vec<Expr>),
}
}

最后,完成关于 if 表达式的代码:


#![allow(unused)]
fn main() {
unsafe fn codegen_expr(context: LLVMContextRef, builder: LLVMBuilderRef, func: LLVMValueRef, names: &mut HashMap<String, LLVMValueRef>, expr: Expr) -> LLVMValueRef {
    match expr {
        // ...

        Expr::If(condition, then_body, else_body) => {
            let condition_value = codegen_expr(context, builder, func, names, *condition);
            let int_type = llvm::core::LLVMInt64TypeInContext(context);
            let zero = llvm::core::LLVMConstInt(int_type, 0, 0);

            // `is_nonzero` is a 1-bit integer
            let name = CString::new("is_nonzero").unwrap();
            let is_nonzero = llvm::core::LLVMBuildICmp(builder, llvm::LLVMIntPredicate::LLVMIntNE, condition_value, zero, name.as_ptr());

            // It's fine to create blocks first, and then fill them in later.
            let entry_name = CString::new("entry").unwrap();
            let then_block = llvm::core::LLVMAppendBasicBlockInContext(context, func, entry_name.as_ptr());
            let else_block = llvm::core::LLVMAppendBasicBlockInContext(context, func, entry_name.as_ptr());
            let merge_block = llvm::core::LLVMAppendBasicBlockInContext(context, func, entry_name.as_ptr());

            llvm::core::LLVMBuildCondBr(builder, is_nonzero, then_block, else_block);

            llvm::core::LLVMPositionBuilderAtEnd(builder, then_block);
            let mut then_return = zero;
            for expr in then_body {
                then_return = codegen_expr(context, builder, func, names, expr);
            }
            llvm::core::LLVMBuildBr(builder, merge_block);

            llvm::core::LLVMPositionBuilderAtEnd(builder, else_block);
            let mut else_return = zero;
            for expr in else_body {
                else_return = codegen_expr(context, builder, func, names, expr);
            }
            llvm::core::LLVMBuildBr(builder, merge_block);

            // Position the builder so that it's ready to work on the next
            // expression.
            llvm::core::LLVMPositionBuilderAtEnd(builder, merge_block);
            zero
        }
    }
}
}

代码有点多,但是完成了你所期待的事情。现在,你可以像这样来运行程序:

a = 1
if a {
    a = 42
} else {
    a = 13
}
a

上述代码对应的 IR 如下所示:

; ModuleID = 'example_module'

define i64 @main() {
entry:
  %a = alloca i64
  store i64 1, i64* %a
  %a1 = load i64, i64* %a
  %is_nonzero = icmp ne i64 %a1, 0
  br i1 %is_nonzero, label %entry2, label %entry3

entry2:                                           ; preds = %entry
  store i64 42, i64* %a
  br label %entry4

entry3:                                           ; preds = %entry
  store i64 13, i64* %a
  br label %entry4

entry4:                                           ; preds = %entry3, %entry2
  %a5 = load i64, i64* %a
  ret i64 %a5
}

然而,我们还没有结束。目前,我们的 “if” 表达式的返回结果始终为 zero(见上述 codegen_expr 函数中 If 分支的返回值)。而我们想要的正好与其相反,如果我们执行了 “then” 路径,则 if 的求值结果应该为 then_return,否则返回 else_return。

你如何使用 LLVM 跟踪它执行了哪个分支?通过使用 “phi” 节点。你给 phi 指令一个 (block, value) 对,该 phi 节点将会返回与先前执行的块相对应的值。

我们可以这样结束 if。注意,我们必须更新 then_block 和 else_block,因为这是我们在 “then/else” 分支中需要的最后一个块,并且前面的 then_block 是 “then/else” 的第一个块。


#![allow(unused)]
fn main() {
// This is mostly the same code as before, just note the new calls to
// `LLVMGetInsertBlock`.

llvm::core::LLVMPositionBuilderAtEnd(builder, then_block);
let mut then_return = zero;
for expr in then_body {
    then_return = codegen_expr(context, builder, func, names, expr);
}
llvm::core::LLVMBuildBr(builder, merge_block);
let then_block = llvm::core::LLVMGetInsertBlock(builder);

llvm::core::LLVMPositionBuilderAtEnd(builder, else_block);
let mut else_return = zero;
for expr in else_body {
    else_return = codegen_expr(context, builder, func, names, expr);
}
llvm::core::LLVMBuildBr(builder, merge_block);
let else_block = llvm::core::LLVMGetInsertBlock(builder);

// Insert the phi node
llvm::core::LLVMPositionBuilderAtEnd(builder, merge_block);
let phi_name = CString::new("iftmp").unwrap();
let phi = llvm::core::LLVMBuildPhi(builder, int_type, phi_name.as_ptr());

let mut values = vec![then_return, else_return];
let mut blocks = vec![then_block, else_block];

llvm::core::LLVMAddIncoming(phi, values.as_mut_ptr(), blocks.as_mut_ptr(), 2);
phi
}

然后,你就得到了一个令人惊叹的编译器:

vagrant@vagrant:/vagrant$ cat in.ex
a = 1
b = 0
c = if a {
    if b {
        11
    } else {
        40
    }
} else {
    if b {
        10
    } else {
        20
    }
}
c + 2
vagrant@vagrant:/vagrant$ cargo run
     Running `target/debug/main`
vagrant@vagrant:/vagrant$ lli-3.8 out.ll ; echo $?
42

太酷了!下面是我们提供的示例输入程序的 IR:

; ModuleID = 'example_module'

define i64 @main() {
entry:
  %a = alloca i64
  %b = alloca i64
  %c = alloca i64
  store i64 1, i64* %a
  store i64 0, i64* %b
  %a1 = load i64, i64* %a
  %is_nonzero = icmp ne i64 %a1, 0
  br i1 %is_nonzero, label %entry2, label %entry3

entry2:                                           ; preds = %entry
  %b5 = load i64, i64* %b
  %is_nonzero6 = icmp ne i64 %b5, 0
  br i1 %is_nonzero6, label %entry7, label %entry8

entry3:                                           ; preds = %entry
  %b10 = load i64, i64* %b
  %is_nonzero11 = icmp ne i64 %b10, 0
  br i1 %is_nonzero11, label %entry12, label %entry13

entry4:                                           ; preds = %entry14, %entry9
  %iftmp16 = phi i64 [ %iftmp, %entry9 ], [ %iftmp15, %entry14 ]
  store i64 %iftmp16, i64* %c
  %c17 = load i64, i64* %c
  %addtmp = add i64 %c17, 2
  ret i64 %addtmp

entry7:                                           ; preds = %entry2
  br label %entry9

entry8:                                           ; preds = %entry2
  br label %entry9

entry9:                                           ; preds = %entry8, %entry7
  %iftmp = phi i64 [ 11, %entry7 ], [ 40, %entry8 ]
  br label %entry4

entry12:                                          ; preds = %entry3
  br label %entry14

entry13:                                          ; preds = %entry3
  br label %entry14

entry14:                                          ; preds = %entry13, %entry12
  %iftmp15 = phi i64 [ 10, %entry12 ], [ 20, %entry13 ]
  br label %entry4
}

请注意:这些块具有以下的模式:不包含第一个条目,它们三个为一组,第一个是 “then” 分支,然后是 “else” 分支,最后是 “merge” 块(带有可识别的 phi 指令)。每一次我们遇到 “if” 表达式时都会在 main 后面附加三个新块。因为要在 AST 中递归查询三元组,所以块的三元组是有序的。

这就是我想要分享的全部内容!希望在这一点上你可以有足够的实力来决定你的命运。

透过 Rust 探索系统的本原:并发篇

作者:陈天 / 后期编辑:NiZerin

原文链接:https://mp.weixin.qq.com/s/9g0wVT-5PpmXRoKJZo-skA


rust 是一门非常优秀的语言,我虽然没有特别正式介绍过 rust 本身,但其实已经写了好多篇跟 rust 相关的文章:

我打算写一个系列,讲讲如果透过 rust 来更好地探索系统的本原。我不知道我能写多少,也许就这一篇,也许很多篇,不管怎样,每篇都会介绍独立的概念。这个系列并不会介绍大量的 rust 代码,因此其内容对非 rust 程序员也有好处。

这一篇我们讲并发。几年前我曾经写过一篇介绍并发概念的文章:concurrency,大家感兴趣可以看看。这篇我们从更加务实的角度,以一个简单的字典服务器程序的迭代为引子,把并发中涉及的概念和解决方法串起来。

v1:循环处理

我们的字典服务器监听 8888 端口,在服务器端维护一个 KV db(使用 hash map)。客户端可以插入(更新)一个 key 和相关的 value,也可以查询一个 key,获得对应的 value。嗯,就像 redis 服务器一样,只不过比 redis 简单十万八千倍。

这个需求很简单,我们马上可以想到:

  1. 监听 8888 端口
  2. 写一个死循环,不断 accept socket,然后对 socket 里收到的数据进行处理。

但这样是串行服务,我们只有处理完上一个 socket 的数据,才有机会处理下一个 socket,吞吐量非常有限。显然,我们需要改进。

v2:多线程处理

接下来我们需要解决串行服务的瓶颈。一个方法是 accept 之后,将新的 socket 放入一个线程里执行,于是主线程不会被阻塞住,可以继续 accept 后续的 socket。这样,每个 client 过来的请求都可以独立地处理。

可是,这带来了一个显而易见的问题:我们的 KV db 成为了一个共享状态,它在多个线程之间共享数据。这是并发处理的第一种范式:共享状态的并发(Shared-State Concurrency)。

既然引入了共享状态,那么我们需要在访问它的时候做妥善的保护 —— 这个访问和操作共享状态的代码区域叫临界区(Critical Section)。如果你还记得操作系统课程的内容,你会知道,最基本的操作是使用互斥量(Mutex)来保护临界区。

互斥量本质是一种二元锁。当线程获得锁之后,便拥有了对共享状态的独占访问;反之,如果无法获得锁,那么将会在访问锁的位置阻塞,直到能够获得锁。在完成对共享状态的访问后(临界区的出口),我们需要释放锁,这样,其它访问者才有机会退出阻塞状态。一旦忘记释放锁,或者使用多把锁的过程中造成了死锁,那么程序就无法响应或者崩溃。rust 的内存安全模型能够避免忘记释放锁,这让开发变得非常轻松,并且最大程度上解决了(不同函数间)死锁问题。

但任何语言的任何保护都无法避免逻辑上的死锁,比如下面这个显而易见的例子:

use std::sync::Mutex;

fn main() {
   let data = Mutex::new(0);
   let _d1 = data.lock();
   let _d2 = data.lock(); // deadlock now
}

互斥锁往往锁的粒度太大,在很多场景下效率太低。于是我们在此基础上分离了读写的操作,产生了读写锁(RwLock),它同一时刻允许任意数量的共享读者或者一个写者。读写锁的一个优化是顺序锁(SeqLock),它提高了读锁和写锁的独立性 —— 写锁不会被读锁阻塞,读锁也不会被写锁阻塞。,但写锁会被写锁阻塞。

读写锁适用于读者数量远大于写者,或者读多写少的场景。在我们这个场景下,读写的比例差别可能并不是特别明显,从 Mutex 换到 RwLock 的收益需要在生产环境中具体测试一下才能有结论。

v3:锁的优化

但即使我们无法通过使用不同实现的锁来优化对共享状态访问的效率,我们还是有很多方法来优化锁。无论何种方法,其核心思想是:尽可能减少锁的粒度。比如,对数据库而言,我们可以对整个数据库管理系统加锁,也可以对单个数据库的访问加锁,还可以对数据表的访问加锁,甚至对数据表中的一行或者一列加锁。对于我们的 KV db 而言,我们可以创建 N 个 hashmap(模拟多个数据库),然后把 Key 分散到这 N 个 hashmap 中,这样,不管使用什么锁,其粒度都变成之前的 1/N 了。

新的 KV db 的定义,以及添加 / 访问数据的代码:


#![allow(unused)]
fn main() {
use std::collections::{hash_map::DefaultHasher, HashMap};
use std::hash::{Hash, Hasher};
use std::sync::{Arc, RwLock};

struct KvDb(Arc<Vec<RwLock<HashMap<String, Vec<u8>>>>>);

impl KvDb {
    pub fn new(len: usize) -> Self {
        let mut dbs: Vec<RwLock<HashMap<String, Vec<u8>>>> = Vec::with_capacity(len);
        for _i in 0..len {
            dbs.push(RwLock::new(HashMap::new()))
        }
        Self(Arc::new(dbs))
    }

    pub fn insert(&self, k: &str, v: Vec<u8>) {
        let dbs = self.0.clone();
        let mut writer = dbs[(self.hash(k) % dbs.len()) as usize].write().unwrap();
        writer.insert(k.into(), v);
    }

    pub fn get(&self, k: &str) -> Vec<u8> {
        let dbs = self.0.clone();
        let reader = dbs[(self.hash(k) % dbs.len()) as usize].read().unwrap();
        reader.get(k).unwrap().to_owned()
    }

    fn hash(&self, k: &str) -> usize {
        let mut hasher = DefaultHasher::new();
        k.to_owned().hash(&mut hasher);
        hasher.finish() as usize
    }
}
}

rust 里面的 dashmap 提供了一个类似思路的高并发访问的 hashmap。

v4:share memory by communicating

前面的迭代不管怎么优化都跳脱不出同一种思路:Shared-state concurrency,或者说:communicate by share memory。这种方法限制很少,非常灵活,适用于任何并发场景,因而它是所有并发方案的基石。然而,灵活度带来的问题就是容易出错,需要额外的约定和限制来避免一些问题的产生。

那么,有没有办法把并发的需求抽象出来,设计一些更高级的数据结构和使用方法,把锁的使用隐藏起来?

当然有。

其中最有效最优雅的方法是消息传递(message passing)。我们把问题的两端分别定义成生产者和消费者。KvDb 的客户端是生产者,它们提交请求(update / get),而 KvDb 的服务器是消费者,它接受请求,返回处理的结果。连接两端的是一个消息通道(channel)。我们可以根据消息通道的两端的使用情况,将其进一步细分成几种访问模型:

  • spsc:单生产者单消费者(Single producer single consumer)。这是最简单的访问模型,它可以用锁(RwLock)来实现并发,也可以通过一个 ring buffer 实现无锁(lock-free)并发。rust 的标准库没有 spsc 的实现,但第三方库,如 tokio,提供了 oneshot channel。当然我们也可以封装 VecDeque 来模拟 spsc。

  • mpsc:多生产者单消费者( Multiple producer single consumer)。这是最典型的并发使用模型,大部分的客户端/服务器实现都能用 mpsc 模型来处理。rust 标准库里有 std::mpsc::channel 来处理 mpsc 模型。

  • spmc:单生产者多消费者(Single producer multiple consumer)。这可能是使用最少的消息模型。rust 标准库里没有对应的实现,也鲜有第三方库单独实现它。Jonhoo 做了一个 crate bus ,是 spmc broadcast channel,它是单个生产者对所有消费者的广播。

  • mpmc:多生产者多消费者( Multiple producer Multiple consumer)。mpmc 是最复杂的情况,可以用来实现之前的几种模式。但因为 spsc / mpsc 有很多使用场景,所以一般我们不会用 mpmc 来模拟。rust 标准库里没有 mpmc 的实现,但 crossbeam 实现了高效的 mpmc channel。

使用消息通道的思路,我们可以进一步迭代我们的 KvDb —— 在处理 socket 的线程和处理 state 的线程之间建立一个 mpsc channel:

这种方式是否更高效?不见得。但从并发处理的角度来看,它结构上更清晰,不容易出错。

使用消息传递来处理并发的思路是如此重要,以至于两门非常有影响力的语言将其内置在语言的运行时里,成为语言的一部分:

golang 内建了 channel,使用 goroutine 和 channel 来处理并发。其语言的核心思想是:

Do not communicate by sharing memory; instead, share memory by communicating.

而 erlang 内建了 actor model,让 sendreceive 成为其最基本的六个函数之一。两个 actor(process)之间唯一的交流方式就是找到对方的 pid,然后发送消息。

v5:协程(async/await or 异步处理)

我们在使用多线程做并发处理时,使用的是操作系统的调度能力。这样的好处是,我们无需自己再做一个调度器,进行复杂的调度处理;坏处是,操作系统处理线程的调度需要复杂的上下文切换,其中包括用户态和内核态的切换,所以它的效率不够高,尤其是如果我们需要大量的随用随抛的「线程」时。

然而,「现代」的应用程序因为复杂程度越来越高,所以其并发程度也越来越高,大量的操作都涉及随用随抛的「线程」。如果我们用操作系统线程来实现这些「线程」,会大大拖累系统的整体效率,甚至会触及操作系统的限制(/proc/sys/kernel/threads-max)。

因而,「现代」的编程语言都有协程的支持 —— 在 golang 里是 goroutine,在 erlang 里是 process,在 python 里是 coroutine,在 rust 里是 future。它们可以以一个更小的粒度在用户态进行并发处理,代价是用户态需要一个调度器。golang / erlang 在语言层面的运行时提供了这个调度器,而 rust 需要引入相关的库。这些语言的用户态调度器的实现都大同小异:

  • 使用 N 个操作系统线程(一般来说 N= 硬件线程的数量)

  • 每个线程上维护若干个队列,保存不同状态下的异步任务。当 ready 队列有任务时,执行该任务,直到其再度挂起或者执行完毕。所以每个异步任务本身要避免没有 IO 或系统调用的大量纯计算工作(computation intensive),如果有这样的工作,那么要主动 yield。

  • 如果某个线程上没有待执行的任务,它可以去其它线程上「偷」任务(work stealing scheduler)。

  • 如果某个线程上正在运行的任务被阻塞(比如执行 syscall),那么一般而言调度器会把队列里的其它任务交给没有阻塞的线程(golang),或者把阻塞操作交给其它专门的线程处理。

无论从 v3 还是 v4 版本,我们都很容易把一个多线程的实现变成多协程的实现。对于 rust 而言,就是引入 async / await:

  • 把相应的函数变成 async 函数,这样函数的返回值会变成一个 Future

  • 在调用 async 函数的地方,添加 .await 来处理 async 的状态机。

  • 在使用 spawn 的地方,使用 tokio 或者 async_std 对应的 spawn,来创建一个协程。

  • 在入口函数,引入 executor,比如使用宏 #[tokio::main]

对于我们的 kv server,因为协程处理的流程图和线程处理类似(内部机制大不一样),所以这里我就不附图了。

One more thing:线程和协程间的同步

在一个复杂的系统里,线程和协程可能会同时出现。我们用线程做计算密集的事情,而用协程做 IO 密集的事情,这样系统可以达到最好的吞吐能力。遗憾的是,很多以协程为卖点的语言,如 erlang 和 golang,你所面临的环境是受控的(某种意义上说,这也是优势 - don't make me think),只能创建协程,而不能创建线程。所以无法做这样的优化。而另一些语言,如 Python,Scala,虽然同时支持线程和协程,两者混合使用要么效率不高,要么没有很好的库,用起来很别扭(我并没有 scala 经验,关于 akka 和 thread 混用的别扭只是道听途说)。

而 Rust 处理得很优雅 — tokio::sync 提供了在同步和异步线程之间使用 channel 同步的工具。你甚至感觉不到你的数据在不同的 runtime 间穿梭。其实站在操作系统的角度想想也能释然:管它是线程和协程,在操作系统层面都是线程,只不过协程是运行在某些线程上的受那些线程独立调度的数据结构而已。所以,线程和协程间的同步,归根结底,还是线程之间的同步问题。而线程间同步的手段,我们都可以使用,只不过在这种场景下,channel 是最好(最舒服)的选择。

所以,我们可以在系统启动时(或者服务器启动时),在普通的线程和 tokio 管理的线程(Runtime)间创建好一个 channel,然后在各自的上下文中处理流入流出 channel 的数据,如下图所示:

本文中我们提到的这个 KV store 的例子太简单,并不涉及同步线程和异步线程之间的同步,我举个其它例子。上篇文章《从微秒到纳秒》讲了如何使用多线程来处理不同 repo 下的事件的写入。下图是之前文章里的主流程:

在这个流程的基础上,我们需要添加一个新的功能:当日志文件 rotate 时,我们发一个消息出去,由一组 uploader 线程负责把刚刚关闭封存的日志文件传输到 S3。

Rust 下和 S3 打交道的库是 Rusoto,Rusoto 是全异步的处理,因而我们需要一个 Tokio runtime 来处理异步的任务。我们可以在 Server.start 接口来处理 Runtime 的创建,然后创建 channel,把 rx 交给 Tokio runtime 下运行的一个死循环的异步任务,这个任务从 rx 里取数据,然后 spawn 新的异步任务将 file 上传到 S3 对应 bucket 的 key 下。而 channel 的 tx 端则传给每个 repo 的 LoggerWriter,这样,LoggerWriter 在做 rotation 的时候,就可以通过 tx 发送要上传给 S3 的本地文件名 file,以及上传到 S3 的对象的 key。如下图所示:

整个流程同样看上去不容易实现,但最终添加的也就是二十行代码而已(不计入 S3 具体上传的代码)。

透过 Rust 探索系统的本原:安全篇

作者:陈天 / 后期编辑:NiZerin

原文链接:https://mp.weixin.qq.com/s/HCHYr5sWnEG_qOGE3hfNnQ


安全是我的老本行,隔一段时间不拉出来谈一谈就不舒服。我个人觉得:做应用不谈安全都是在耍流氓。

按照 CISSP[1] 的定义,安全有八大领域:

本文只关注 Communication and Network Security 中 TCP/IP 范畴下的 Session Layer Security,也就是 TCP/UDP 层之上的安全方案:

目前业界首选的方案是 TLS[2]。在所有流行的应用层解决方案中,都离不开 TLS。

在 p2p 领域,TLS 并不那么受待见,大家似乎更喜欢和 TLS 提供了同等安全水平,但更加去中心化(不需要 CA[3])的 noise protocol[4]。我去年写过一篇关于 Noise protocol 的文章:Noise 框架:构建安全协议的蓝图

本文围绕 TLS 和 Noise protocol,以及它们在 Rust 下的使用场景,谈谈我们如何做安全的系统和应用。

安全的本质

很多人谈到安全,首先想到的是加密解密。加解密只解决了安全范畴的机密性(confidentialilty)的问题,但它没有触及另外两个重要的范畴:完整性(integrity)和可用性(availability)。我们简单讲一下这三个概念:

  • 机密性:信息在使用和传输的过程中以密文保存,只有授权的用户才可以获取到明文。

  • 完整性:信息在使用和传输的过程中,不会被非法授权和破坏。

  • 可用性:合法用户对信息的访问不会被拒绝。

为了保证可用性,我们会提供服务的高可用性(防止 DoS 以及服务故障),做数据冗余和灾备处理(防止数据丢失),监控,故障转移等等。

为了保证完整性,我们会使用哈希算法,数字签名来保证数据的一致性。

为了保证机密性,我们会使用对称和非对称加密来保证在传输途中,以及在数据载体上的机密性。机密性往往需要完整性作为先决条件;而完整性不一定需要机密性作为先决条件。

下图阐述了安全领域涉及机密性和完整性的主要算法:

注意,这里的一些算法是泛称,而非具体某种算法。比如:sha3 算法族下面除了 keccak 以外,还有 blake2,blake3 等其他算法;而 ECC 算法下面,属于签名算法的有 Ed25519,Ed448 等,属于非对称加密的有 x25519,x448 等。

如果你看了我前几周的文章,大概对我介绍的《胖客户端,廋服务器》有些印象。文章中我提到了服务端把用户端的事件写入到事件日志中,客户端可以 clone / pull 这些事件日志,在本地生成相应的状态。那么问题来了,客户端怎么知道 clone 下来的事件日志是未经第三方篡改的事件日志呢?很简单,我们只需对日志文件做个 hash,然后用服务器的私钥对这个 hash 做一个签名,把签名附带在文件头上。这样客户端收到文件后,用服务器的公钥验证这个签名即可。这样,只要服务器的私钥不泄露,这个签名就可以验证文件的完整性。在比特币的世界里,每个区块的完整性都由打包该区块的矿工的签名来保证,而整个链的完整性则由哈希链和中本聪共识(最长链)保证。

进一步的,如果我们用户的私有 repo 下的所有事件日志都只有用户自己才能访问,其他人(包括服务端应用)都无法访问,那么我们可以用用户的公钥来加密 repo 的所有事件日志。

DH 算法:互联网安全的基石

当我们需要保证存储在媒介上的信息的安全性时,一切都简单而直观;但当我们需要保证在网络传输中的实时信息的安全性时,我们就面临着巨大的挑战。

这其中第一个挑战就是:每个连接使用什么密钥来加密数据?

我们知道,在网络传输中,非对称加密的效率不高,不适合大量数据的加密,而对称加密则需要双方共享密钥,才能正常通讯。因此,我们需要一种手段,在不安全的网络信道中,只传输部分信息,通过这部分信息 + 本地的私有信息,协商出来双方一致的共享密钥。第三方即便获得明文传输的信息,也无法推导出密钥。如果这样的手段行得通,那么,我们就可以在网络通讯的握手过程,生成每个 session 独立的共享密钥(session key),后续的通讯都通过这个(这对)密钥来加密完成。这个协商的过程就是 DH 算法(Diffie-Hellman Key Exchange Algorithm)[5](对算法细节感兴趣的可以去看 wikipedia):

DH 算法是 TLS 以及 Noise protocol 的基石。如果没有它,我们就不会有目前这样一个繁荣且安全的互联网世界。

在 Rust 下,如果你需要直接使用 DH 算法,可以使用 dalek 出品的 x25519-dalek[6]。它是使用 curve25519 [7] 的 ECDH(Elliptic Curve Diffie-Hellman) 的实现。代码如下:


#![allow(unused)]
fn main() {
use rand_core::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey};
let alice_secret = EphemeralSecret::new(OsRng);
let alice_public = PublicKey::from(&alice_secret);
let bob_secret = EphemeralSecret::new(OsRng);
let bob_public = PublicKey::from(&bob_secret);
let alice_shared_secret = alice_secret.diffie_hellman(&bob_public);
let bob_shared_secret = bob_secret.diffie_hellman(&alice_public);
assert_eq!(alice_shared_secret.as_bytes(), bob_shared_secret.as_bytes());
}

你也许会问:我又不去实现 TLS 或者类似的加密协议,而我自己的网络传输都靠 TLS 保护着呢,DH 对我来说有什么用呢?

我能想到的一个场景是文件加密。在本文开头,我说:

进一步的,如果我们用户的私有 repo 下的所有事件日志都只有用户自己才能访问,其他人(包括服务端应用)都无法访问,那么我们可以用用户的公钥来加密 repo 的所有事件日志。

这个方案的缺点是效率太低 — 如果需要加密的文件有几个 G,非对称加密显然不好。但我们又没法用对称加密:毕竟我们不能跟每个人都预先共享一组密钥(共享密钥本身又存在安全风险)。这时,我们可以用 DH 算法生成一个只对这个文件有效的密钥,加密文件,然后在文件头提供必要的信息即可:

  1. 生成一个临时公钥对

  2. 用私钥和用户的公钥算 DH key

  3. 用 DH key 作为 AES[8] 或者 ChachaPoly[9] 的密钥,加密文件

  4. 在文件头添加临时生成的公钥

这样,在解密端,用户可以用自己的私钥和文件中携带的公钥算出 DH key,然后解密之。

如果大家对这个思路感兴趣,可以参考我用 Noise protocol 做的类似的解决方案:tyrchen/conceal[10]。

除了 x25519-dalek 外,ristretto255-dh[11] 也值得一试,它是 zcash 实现的 Ristretto255[12] DH 算法。

TLS:不仅仅是 HTTP 的安全防线

作为 HTTPS 的安全协议的唯一选择,相信大家对 TLS(以及它的前身 SSL)有一定的了解 — 起码知道:

  • 服务端需要安装经过合法 CA 签署的证书(certificate)。如果你配过 nginx,你还会知道,证书和证书的私钥一般都是 PEM [13] 格式存储在文件系统的。一般来说,除了配置自己的证书外,还需要配置整个服务器证书链以便客户端验证。

  • 客户端在连接服务器时,会获取服务器证书,然后通过操作系统受信的根证书来验证服务器的证书以及签署该证书的 CA,以及签署该 CA 的上一级 CA 等形成的整个证书链可以由某个根证书验证。

  • 客户端验证了服务器的证书后,会跟服务器交互建立一个安全信道,保证之后的传输的安全。这个过程是 TLS 握手。

  • 之后整个 HTTP 协议交互的过程,都被这个安全信道保护起来(说人话就是加密了),第三方无法嗅探到内部的交互,也无法破坏其完整性。

如果你经常调试(或者逆向工程)HTTPS,你大概还知道:

  • 通过 Charles Proxy 这样的工具,可以做 Man-In-The-Middle[14],来截获加密的数据。使用 Charles Proxy 时,需要在操作系统级「信任」其根证书,这是证书验证的流程所决定的。一旦某个根证书被系统信任,那么它可以签署任何域名的证书,这样第三方就可以伪装成目标服务器,terminate 客户端到服务器的任何 TLS 流量,然后再伪装成客户端,向实际服务器发送数据。所以,不要轻易信任来路不明的根证书。

  • 如果要避免 Charles Proxy 等工具做 Man-In-The-Middle,你可以使用 certificate pinning。

你大概率不知道:

  • TLS 支持 client certificate - 也就是说不光客户端可以验证服务器是否是我要连的服务器;服务器也可以验证客户端是否是我(的 CA)签署的客户端。

  • 客户端验证服务器时,除了可以通过系统的根证书验证,也可以预置一个 CA 证书来验证服务器的证书是否由该 CA 签署。

证书是个什么鬼?

我们这里所说的证书,是 PKI 体系下的 X.509 证书[16]。X.509 证书用于证明一个公钥的身份。我说我是 domain.com 的合法服务器,何以见得?我生成一对私钥和公钥,通过其签署一个 CSR(Certificate Signing Request [17]),里面通过 CN(Common Name)声索我对 *.domain.com 的占有。一般一个 CSR 包含这些信息:

随后我把 CSR 提交给一个由某个根证书签署的 CA,由其签名并发回给我。这样,任何应用通过 HTTPS 连接 domain.com 时就可以正常通讯。

在 letsencrypt[18] 成为主流之前,证书是个几乎相当于特许经营的好生意。像 godaddy 这样的家伙,一个证书可以卖上百美金一年,简直如同抢钱。证书作为一门生意,极大地破坏了互联网的安全性,很多小的玩家不想支付每年的证书费用,干脆就避免使用 HTTPS。letsencrypt 的出现,几乎摧毁了各大吃相难看的 CA 的生意。Letsencrypt 自动化了证书的申请流程,只要你能把某个域名指向你的服务器,让 letsencrypt 验证到你请求的域名就是你拥有的域名,可以立即签署一个有效期是 3 个月的免费证书。至于证书的有效期为啥不能更长,这个,根本不是技术原因,我猜是来自做垂死挣扎的既得利益者们的压力。

能不能自己做 CA?

CA 机构是 internet 的不可或缺,却相对脆弱的一环。Letsencrypt 只是解决了证书收费的问题,不过没有解决 CA 机构本身的脆弱性 — 任何一个中心化的,可以签署证书,被数亿设备信任的机构都是有安全风险的,因为黑客随时盯着这些机构的漏洞。一旦一个CA 被攻陷,黑客们可以伪造出成千上万的域名的服务器证书。

有没有可能一个应用的客户端和服务器使用自己的 CA,绕过任何 CA 机构?

当然可以。你可以生成自己的 CA cert(自签名),然后用 CA key 签名 Server cert。你的客户端在启动 TLS 连接时,信任你自己的 CA cert 即可。你甚至还可以通过你的 CA 给每个客户端签名,让服务器也同时验证客户端是你信任的客户端。如下图所示:

一个新的客户端在注册新用户/登录时,服务器会从 CA 获取证书,连同用户登录获得的 token 一同返回给客户端。之后,客户端访问任何服务端的 API(除 auth 之外),都需要提供 client cert 供服务器验证,这样,额外增加安全性,并且,可以杜绝 Charles Proxy 这样的中间人。

当然这样的手段只适合客户端代码由你自己控制(比如 iOS/android/OSX/Linux/Windows app)。你无法让你的服务器证书通过浏览器的安全验证(因为证书不在系统根证书的信任列表中),因而,任何使用浏览器访问你的服务器的用户将无法使用你的服务。

如果你对这样的方案感兴趣,可以看看我的 crate: tyrchen/cellar[19]。它借鉴比特币分层钱包[20]的设计,可以从一个 passphrase 衍生出确定的分层密码/密钥/证书。生成的证书可以被应用在 TLS 应用中,比如:tyrchen/mobc-tonic[21](我做的一个 grpc client connection pool)。

下面是我通过 celllar 生成的 CA 证书(注意 CA: TRUE):

以及该 CA 签署的服务器证书(注意 CA: FALSETLS Web Server Authentication):

以及该 CA 签署的客户端证书(注意 CA: FALSE 以及 TLS Web Client Authentication):

将 TLS 应用在 HTTP 之外

TLS 可以保护我们的 HTTP 应用,其中包括 REST/GraphQL/Websocket API,以及 gRPC API。虽然目前 HTTP 是几乎绝大多数互联网应用使用的协议,但还有大量的其它基于 TCP 层的协议。如果你要保证这些协议的安全性,使用 TLS 是一个简单直接的选择。

然而,理解和使用好 OpenSSL 库不是一件容易的事情。十多年前,我曾经用 C 语言和老旧的 OpenSSL (0.9.x)打过交道,那体验相当不好。Python / Erlang 有不错的 OpenSSL 的封装,在应用中使用 TLS 比较舒服自然。如果你熟悉的语言没有很好的库去包装 OpenSSL,那么,在应用中使用 TLS 就不那么容易。

在 Rust 里,除了 OpenSSL 的封装,我们还有 Rustls[22]。它是一个经过了 security auditing[23] 的 TLS 安全裤,性能比 OpenSSL 更好,且理论上更加安全(没有遗留的历史问题,没有 TLS1.1 及以下的不安全代码,没有遗留的不安全的加密算法,比如 RC4,3DES)。

Rustls 虽然比 OpenSSL 容易使用,但成功建立起 TLS 连接,还需要更多对 TLS 细节的理解。为此,我做了一个 crate:tokio-tls-helper[24],可以让你通过简单的配置,创建 TLS connector (client) 和 acceptor (server)。

比如客户端使用自定义的 CA cert 以及通过自定义 CA 签署的 client cert:


#![allow(unused)]
fn main() {
domain = "localhost"

[cert]
pem = """-----BEGIN CERTIFICATE-----
MIIBeTCCASugAwIBAgIBKjAFBgMrZXAwNzELMAkGA1UEBgwCVVMxFDASBgNVBAoM
C0RvbWFpbiBJbmMuMRIwEAYDVQQDDAlEb21haW4gQ0EwHhcNMjEwMzE0MTg0NTU2
WhcNMzEwMzEyMTg0NTU2WjA3MQswCQYDVQQGDAJVUzEUMBIGA1UECgwLRG9tYWlu
IEluYy4xEjAQBgNVBAMMCURvbWFpbiBDQTAqMAUGAytlcAMhAAzhorM9IPsXjBTx
ZxykGl5xZrsj3X2XqKjaAVutnf7po1wwWjAUBgNVHREEDTALgglsb2NhbGhvc3Qw
HQYDVR0OBBYEFD+NqChBZDOs5FMgefHJSIWiRTHXMBIGA1UdEwEB/wQIMAYBAf8C
ARAwDwYDVR0PAQH/BAUDAwcGADAFBgMrZXADQQA9sligQcYGaBqTxR1+JadSelMK
Wp35+yhVVuu4PTL18kWdU819w3cVlRe/GHt+jjlbk1i22TvfO5AaNmdxySkO
-----END CERTIFICATE-----"""

[identity]
key = """-----BEGIN PRIVATE KEY-----
MFMCAQEwBQYDK2VwBCIEIArjJtHm3xb4aX3fsGHpuB8dD3yzcLxWcPCqy7AGtTG5
oSMDIQD/38MZBnYuyitIGU3ltOGwwDwtB6KYag4rL1zsSGTzYg==
-----END PRIVATE KEY-----"""

[identity.cert]
pem = """-----BEGIN CERTIFICATE-----
MIIBZDCCARagAwIBAgIBKjAFBgMrZXAwNzELMAkGA1UEBgwCVVMxFDASBgNVBAoM
C0RvbWFpbiBJbmMuMRIwEAYDVQQDDAlEb21haW4gQ0EwHhcNMjEwMzE0MTg0NTU2
WhcNMjEwOTEwMTg0NTU2WjAyMQswCQYDVQQGDAJVUzEQMA4GA1UECgwHYW5kcm9p
ZDERMA8GA1UEAwwIYWJjZDEyMzQwKjAFBgMrZXADIQD/38MZBnYuyitIGU3ltOGw
wDwtB6KYag4rL1zsSGTzYqNMMEowFAYDVR0RBA0wC4IJbG9jYWxob3N0MBMGA1Ud
JQQMMAoGCCsGAQUFBwMCMAwGA1UdEwQFMAMBAQAwDwYDVR0PAQH/BAUDAwfgADAF
BgMrZXADQQCKhph1Z3g6E+EULUi5yIROSXmMxWjzi+L1OmqNh9ANJlrQwlcfwq0G
8JbfGVwq1sotEI83mv42mWkSSX98uysO
-----END CERTIFICATE-----"""
}

有了这个配置,客户端可以生成 ClientTlsConfig,然后生成 connector,在建立好 TCP stream 后,直接调用 connector.connect(stream) 就可以将 TCP 连接升级为 TLS 连接,之后可以在其之上进行应用层的协议:


#![allow(unused)]
fn main() {
let msg = b"Hello world\n";
let mut buf = [0; 12];

let config: ClientTlsConfig = toml::from_str(config_file).unwrap();
let connector = config.tls_connector(Uri::from_static("localhost")).unwrap();

let stream = TcpStream::connect(addr).await.unwrap();
let mut stream = connector.connect(stream).await.unwrap();
info!("client: TLS conn established");

stream.write_all(msg).await.unwrap();

info!("client: send data");

let (mut reader, _writer) = split(stream);

reader.read_exact(buf).await.unwrap();

info!("client: read echoed data");
}

服务端的使用也很简单:配置相同的 CA cert,以及服务器的 server/key:


#![allow(unused)]
fn main() {
[identity]
key = """-----BEGIN PRIVATE KEY-----
MFMCAQEwBQYDK2VwBCIEII0kozd0PJsbNfNUS/oqI/Q/enDiLwmdw+JUnTLpR9xs
oSMDIQAtkhJiFdF9SYBIMcLikWPRIgca/Rz9ngIgd6HuG6HI3g==
-----END PRIVATE KEY-----"""

[identity.cert]
pem = """-----BEGIN CERTIFICATE-----
MIIBazCCAR2gAwIBAgIBKjAFBgMrZXAwNzELMAkGA1UEBgwCVVMxFDASBgNVBAoM
C0RvbWFpbiBJbmMuMRIwEAYDVQQDDAlEb21haW4gQ0EwHhcNMjEwMzE0MTg0NTU2
WhcNMjIwMzE0MTg0NTU2WjA5MQswCQYDVQQGDAJVUzEUMBIGA1UECgwLRG9tYWlu
IEluYy4xFDASBgNVBAMMC0dSUEMgU2VydmVyMCowBQYDK2VwAyEALZISYhXRfUmA
SDHC4pFj0SIHGv0c/Z4CIHeh7huhyN6jTDBKMBQGA1UdEQQNMAuCCWxvY2FsaG9z
dDATBgNVHSUEDDAKBggrBgEFBQcDATAMBgNVHRMEBTADAQEAMA8GA1UdDwEB/wQF
AwMH4AAwBQYDK2VwA0EAy7EOIZp73XtcqaSopqDGWU7Umi4DVvIgjmY6qbJZP0sj
ExGdaVq/7MOlZl1I+vY7G0NSZWZIUilX0CoOkrn0DA==
-----END CERTIFICATE-----"""


[client_ca_root]
pem = """-----BEGIN CERTIFICATE-----
MIIBeTCCASugAwIBAgIBKjAFBgMrZXAwNzELMAkGA1UEBgwCVVMxFDASBgNVBAoM
C0RvbWFpbiBJbmMuMRIwEAYDVQQDDAlEb21haW4gQ0EwHhcNMjEwMzE0MTg0NTU2
WhcNMzEwMzEyMTg0NTU2WjA3MQswCQYDVQQGDAJVUzEUMBIGA1UECgwLRG9tYWlu
IEluYy4xEjAQBgNVBAMMCURvbWFpbiBDQTAqMAUGAytlcAMhAAzhorM9IPsXjBTx
ZxykGl5xZrsj3X2XqKjaAVutnf7po1wwWjAUBgNVHREEDTALgglsb2NhbGhvc3Qw
HQYDVR0OBBYEFD+NqChBZDOs5FMgefHJSIWiRTHXMBIGA1UdEwEB/wQIMAYBAf8C
ARAwDwYDVR0PAQH/BAUDAwcGADAFBgMrZXADQQA9sligQcYGaBqTxR1+JadSelMK
Wp35+yhVVuu4PTL18kWdU819w3cVlRe/GHt+jjlbk1i22TvfO5AaNmdxySkO
-----END CERTIFICATE-----"""
}

服务端同样可以通过配置生成 ServerTlsConfig,然后生成 acceptor,之后正常使用 TCP listener accept 一个 TCP stream 后,就可以通过 acceptor.accept(stream) 把 TCP 连接升级为 TLS。这个过程配合客户端的 connector.connect(stream),共同完成前面所说的 DH 过程,协商出来 session key,然后开始加密/解密应用层的数据:


#![allow(unused)]
fn main() {
let config: ServerTlsConfig = toml::from_str(config_file).unwrap();
let acceptor = config.tls_acceptor().unwrap();
let listener = TcpListener::bind(addr).await.unwrap();
tokio::spawn(async move {
    loop {
        let (stream, peer_addr) = listener.accept().await.unwrap();
        let stream = acceptor.accept(stream).await.unwrap();
        info!("server: Accepted client conn with TLS");

        let fut = async move {
            let (mut reader, mut writer) = split(stream);
            let n = copy(&mut reader, &mut writer).await?;
            writer.flush().await?;
            debug!("Echo: {} - {}", peer_addr, n);
        }

        tokio::spawn(async move {
            if let Err(err) = fut.await {
                error!("{:?}", err);
            }
        });
    }
});
}

Noise Protocol:狂野西部的守护者

如果你没看过我之前的文章,大概率 Noise Protocol 对你来说是个陌生的名字。如果你搭过各种各样的梯子,你也许使用过 Wireguard[25],那么恭喜你,你已经在不知不觉中使用 Noise Protocol 了 — 因为 Wireguard 在安全层使用了 Noise Protocol。我曾经写过一篇文章:Wireguard:简约之美,介绍了 Wireguard 这个非常牛逼的 VPN 工具。

因为之前的关于 Wireguard 和 Noise protocol 的文章对 Noise Protocol 本身已经有足够丰富的介绍,这里我就不再赘述 Noise Protocol 的细节。

如果说 TLS 是出身高贵的正规军,那么 Noise Protocol 就是出身草根的土八路。但二者其实互相借鉴,互相学习。TLS 1.3 和 Noise Protocol 的主要区别有两个:

  1. 在身份验证方面二者走上了不同的道路(TLS 1.3 使用证书,而 Noise Protocol 完全不使用)

  2. 通讯过程中使用的算法一个走协商(TLS)一个走预配置(Noise)

走协商还是走配置这跟协议的使用场景有关。TLS 关注的是大量不同版本的标准客户端(比如 Firefox)和服务器之间的交互,两端支持的算法族可能有不小的出入,协商出双方都能接受的算法是最优的选择,这样可以支持尽可能广的应用场景;而 Noise Protocol 关注的是定制的客户端和服务器之间的交互,因而两端可以通过预配置来确定使用的算法。比如 WireGuard 使用 Noise_IKpsk2_25519_ChaChaPoly_BLAKE2s,那么客户端和服务端都需要:

  • Curve 25519 做 ECDH

  • CharChaPoly 做加密

  • Blake2s 做哈希

  • 两端使用 pre-shared key 进一步保证安全性

因为避免使用证书这样天然具有中心化的东西,Noise Protocol 在 p2p 领域走出了新的天地。从最简单的 NN(双方都没有固定公钥)KK(双方都知道对方的固定公钥),到最复杂的 XX(双方都有固定公钥,通过网络加密传输给对方),Noise Protocol 定义了 12 种协商模式,再辅以不同的哈希和加密算法,可以组合出上百种安全方案,总有一种适合你:

在 Rust 下,snow[26] 是 Noise Protocol 的非常成熟的实现,而 libp2p 则使用了 snow 来实现 libp2p 协议的安全层。

下面是使用 snow 在 TCP 协议之上建立加密信道的实例。我们可以看到,仅需额外的几行代码就可以将你的网络应用打造得非常安全:

  • 创建 snow Builder

  • 在建立连接后发送和接收不超过 3 个 Noise protocol 协议报文

  • 协议握手完成后,使用 noise.into_transport_mode() 将 snow 状态机切换到传输模式

  • 之后收到报文后调用 noise.read_message() 解密,发送报文前调用 noise.write_message() 加密即可。

服务器:


#![allow(unused)]
fn main() {
let params: NoiseParams = "Noise_XX_25519_ChaChaPoly_BLAKE2s".parse().unwrap();
let builder: Builder<'_> = Builder::new(params.clone());
let static_key = builder.generate_keypair().unwrap().private;
let mut noise = builder
    .local_private_key(&static_key)
    .build_responder()
    .unwrap();

// wait on client's arrival
println!("Listening on 0.0.0.0:9999");
let (mut stream, _) = TcpListener::bind("0.0.0.0:9999").unwrap().accept().unwrap();

// <- e
noise
    .read_message(&recv(&mut stream).unwrap(), &mut buf)
    .unwrap();

// -> e, ee, s, es
let len = noise.write_message(&[0u8; 0], &mut buf).unwrap();
send(&mut stream, &buf[..len]);

// <- s, se
noise
    .read_message(&recv(&mut stream).unwrap(), &mut buf)
    .unwrap();

// transition the state machine to transport mode sinc handshake is complete.
let mut noise = noise.into_transport_mode().unwrap();
while let Ok(msg) = recv(&mut stream) {
    let len = noise.read_message(&msg, &mut buf).unwrap();
    println!("client said: {}", String::from_utf8_lossy(&buf[..len]));
}

println!("connection closed");
}

客户端:


#![allow(unused)]
fn main() {
let params: NoiseParams = "Noise_XX_25519_ChaChaPoly_BLAKE2s".parse().unwrap();
let builder: Builder<'_> = Builder::new(params.clone());
let static_key = builder.generate_keypair().unwrap().private;
let mut noise = builder
    .local_private_key(&static_key)
    .build_initiator()
    .unwrap();

// connect to server
let mut stream = TcpStream::connect("127.0.0.1:9999").unwrap();
println!("connected!");

// -> e
let len = noise.write_message(&[], &mut buf).unwrap();
send(&mut stream, &buf[..len]);

// <- e, ee, s, es
noise
    .read_message(&recv(&mut stream).unwrap(), &mut buf)
    .unwrap();

// -> s, se
let len = noise.write_message(&[], &mut buf).unwrap();
send(&mut stream, &buf[..len]);

let mut noise = noise.into_transport_mode().unwrap();
println!("Session established...");

// send secure data
for _ in 0..10 {
    let len = noise.write_message(b"HACK THE PLANET", &mut buf).unwrap();
    send(&mut stream, &buf[..len]);
}
}

因为 snow 的所有操作都直接操作内存的 buffer,所有的 IO 都是由你创建的 TCP stream 完成,所以 snow 可以很好地在同步或者异步模式下工作。

贤者时刻

连接千万条,安全第一条。网络不加密,亲人两行泪。- 鲁迅:不是我说的

如何为 Rust 语言做贡献 | Part 2

作者:CrLF0710(野喵)/ 后期编辑:张汉东


大家好,上次讲了如何利用空闲时间参与Rust Project,做一些技术上的参与。这一次呢,我要讲的是如何更加系统的参与Rust Project,相应的,这里讲的内容也需要参与者相应的投入更多的时间、以及更高的门槛。

在开始正式参与之前,我们有必要了解一下Rust Project的组织架构,也就是Rust Team。

Rust 官方团队

Rust Team 由十个Team 构成。每个Team习惯上称为T-XYZ,比如权限最高的Team是核心团队,习惯上写成T-Core。

十个Team(除T-Core外排名不分先后)分别是T-Core, T-Lang, T-Libs, T-Compiler, T-Devtools, T-Crates.io, T-Infra, T-Release, T-Community 和 T-Moderation。所有团队的成员是常驻人员,其实也都是志愿者,成员的选拔主要来自于同一团队其他已有成员的认同。接下来让我们来逐一对各个团队做个介绍。

首先是核心团队T-Core,它的职责是管理Rust Project的总体方向,管理各个Team的领导职责,管理跨团队事务。它是一个没有团队负责人的团队,而且讨论频道是不对外部公众公开的。它出现最多的地方就是,大家会看到那些刊发重大决策的博文都是以核心团队的名义发表的。它的成员往往是其他团队的团队负责人等。

接下来我想首先介绍的是语言设计团队T-Lang,它的职责是设计Rust这门语言。在Rust RFCs被接受的RFC中,语言设计类的RFC到目前为止是占绝对多数的。他们的职责就是讨论语言的未来更改,并且帮忙参与一部分语言特性的实现(参见稍后提到的“项目组”)。平时大家笑谈的“语言律师”就是这些人——对于你的一个设计想法,他们可以飞快地在脑中与语言其他已有特性进行交叉验证,指出你的设计不足……在我的经验里,多多围观他们的讨论对你更深地理解Rust是非常有好处的。

然后是库设计团队T-Libs,它的职责是设计维护Rust的内置库。要注意内置库不止是标准库std,还有core, alloc, test等等。库设计团队的成员们往往都是一些知名rust库的作者,他们在设计API上都是非常有经验的,关注的是接口的易用性等等。实际上Rust的内置库其实从整体上是已经趋于“收敛”了,很难看到较大块的增加,一般都是在一些已有的类型上增加一些小的接口和辅助函数等等。这些接口的稳定化就是由T-Libs来把关的。

接下来是编译器维护团队T-Compiler,它的职责是维护编译器的主体代码。T-Compiler是所有团队中人员最多的团队,以T-Compiler的名义提交的代码占的比重也是最大的。所有已经实现的语言特性相关的代码都是移交给他们来维护的,而对编译器的各种bug的修复、各种诊断帮助信息的用户体验逻辑维护、对编译器的性能优化等等,都是由T-Compiler来进行。这里的任务从简单的“调整一个内部判断条件”,到困难的“实现一个下一代的借用检查器和特质查询系统”,能做的事情有很多很多。

接下来是开发工具团队T-Devtools,它其实是由一堆小团队组成,职责是负责维护cargo、rustdoc、rustfmt、rustup等各种小的程序。每个程序的维护团队其实是相互独立的。这里其实有非常多的贡献机会。

然后是Crates.io团队T-Crates.io,它负责开发维护crates.io网站的前后端,大家上传的开源Rust包都可以集中放到这个网站里。

接下来是运维团队T-Infra,它负责维护Rust的各个网站、crater、CI、域名解析、CDN连接等等的正常运转。

然后是版本发布团队T-Release。与任务导向的T-Compiler不同,T-Release负责按照Rust的发布周期对各个发布版本执行回归测试,定位回归测试中发现的问题、联系T-Compiler进行评估修复。

然后是社区团队T-Community。负责组织Rust相关的活动和会议、管理网站内容、进行社区投票等等。它基本上就可以看作是最近新成立的 Rust 基金会的前身,区别在于新成立的Rust基金会在原来的基础上增加了财务和法务等等的新的责任。

最后是风纪管理T-Moderation。主要负责维持各种交流频道的秩序,维护《行为准则》,管理必要的账号封禁事项等等。

Rust 官方团队里的非正式成员

虽然听起来团队成员数量还不少,但是实际上要推动rust的健康发展,人员力量很容易捉襟见肘。Rust的策略是吸纳更多的非正式成员进来完成工作。其中活跃的人在某种意义上也可以看作是正式成员的候补。

对非正式成员的组织有三种形式:项目组,工作组和社区组。这个划分是之前讨论的,还在试行阶段没有变成正式决策。

项目组(project group)是临时的。例如某个团队想要完成一个新功能的设计与实现,那么就会由该团队的一个成员发起一个项目组,由这个正式成员来带队。这个正式成员负责组织人手和会议,对相关的功能进行推进。

工作组(working group)是较长期的。例如某个团队决定需要一群人来固定推进一些事情,那么同样也会由该团队的一个成员发起一个工作组。这个正式成员负责组织人手和会议,完成既定的任务。

社区组(community group)之前叫做领域工作组,曾经也是工作组概念里的一部分。领域工作组是2017~2018年左右官方推进的一个概念,希望针对一些比较有前景的领域,各自集合一些人来定期讨论,做一些事情,比如写一些代码之类的。第一批成立了四个:网络、嵌入式、WebAssembly、命令行界面。后来又成立了第五个:游戏开发。实践证明,社区工作组这个想法执行起来并不太好,甚至导致了官方和社区的一些矛盾和摩擦。网络领域工作组解散后,官方的态度倾向于将这些领域工作组改编为社区组,不再属于官方团队的一部分。目前是在向这个方向行动,还没有完成。

在这里我们主要关注项目组和工作组。一般来说门槛都是相当低的,一般只需要你在相应小组的频道里简单自我介绍一下,然后按时去参加会议就可以了。但是由于时区的缘故,例会时间往往在北京时间午夜之后,会议语言也一般都是英语。如果你是比较技术导向的,比如希望推进rust的某个特性工作的进展,不妨找到感兴趣的项目组报名参与一下。

Rust 官方工作交流平台

Rust官方交流平台是用一个叫Zulip的软件,网址是rust-lang.zulipchat.com。可以用github登录。这东西有点介于IM与论坛之间,登录之后它有一些频道(称为Stream)。各个团队一般都至少有一个。很多团队也根据需要会为下面的工作组、领域建多个频道。在每个频道里面,任何人都可以建主题,有点像论坛发帖的感觉,然后大家在感兴趣的主题下讨论。它也有手机端app,但是目前还不是很好用;推荐使用网页版或者桌面App。Stream需要订阅才会显示,刚登录的时候显示不全,记得去所有频道面板中订阅自己想关注的频道。

有的团队喜欢开视频会议,它们一般会选Zoom。如果需要协同编辑,往往用Dropbox Papers或者HackMD。大部分都是可以公开访问的,个别的需要申请权限。

在交流时要稍微注意一点的是,关于称呼。欧美有很多人对性别平权、少数群体之类的比较在意。稍微注意下He/She两个词的使用千万不要用错,如果不确定,就用They就好了。

话说,官方 Zulip 里有一个中文频道 t-community/l10n/zh,不过蛮冷清的。大家感兴趣的话可以来聊关于翻译的话题啊。

四月刊

logo

发刊通告

Rust 资讯

Rust 与 学术

Rust in Production

学习园地

新手入门

语言特性与技巧

Rust 与 设计模式

【主题专栏】Rust 探索系统本原

【主题专栏】如何为 Rust 做贡献

开源项目巡礼

Rust vs Security

发刊通告

编辑:张汉东

四发刊通告

五一到了,祝大家劳动节快乐!《 RustMagazine 中文精选 》2021 年第四期发布了!

第四期的内容组织结构进行了初次改版,希望可以形成一个固定的结构,容纳更多内容,更加方便协作。

每次发布月刊,都会体会到时光流逝的无情。当月刊发布的时候,也意味着一个月已经过去了。

无论如何,加油吧!

本刊动态

(GitHub Page)现在已经支持 Rss 订阅

  1. 订阅地址:https://rustmagazine.github.io/rust_magazine_2021/rss.xml

  2. 复制订阅地址到你最喜欢的订阅工具开始订阅。

语雀平台自动支持邮件订阅。

上期(三月刊)访问数据统计小结

用户数

  • 新用户新增 2162

浏览量:

  • 网页浏览量 : 10,334

读者分布地区排名:

  • 亚洲,新用户增加 1,535
  • 美国,新用户增加 134
  • 欧洲,新用户增加 13

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

Rust 资讯来源汇总

编辑:张汉东


大家一定好奇 Rust 资讯从哪里可以获取呢?那么本文就对 Rust 资讯信息来源做一次汇总。欢迎补充!

国外

  • Rust Weekly,每周三更新一次,汇总了 Rust 生态圈的一些动态,还包括了一些知名开源项目的 Issues 信息等。
  • Reddit Rust 频道,每天都会有 Rust 新项目、技术讨论等帖子,可以每天关注。
  • Awesome Rust Weekly,和 Rust Weekly 类似,也是每周发一次信息聚合,但风格有点差别,都可以关注下。
  • Read Rust,不定时更新一些 Rust 信息,并且对这些信息做了分类。
  • GitHub Rust Trending,GitHub Rust 趋势榜。
  • Twitter rustlang tag,在 Twitter 里搜索 rustlang 就可以看到很多 Rust 相关动态。

国内

  • Rust 日报,每天收集汇总当天的 Rust 动态。
  • Rust 视界,Rust 日报前身,是一个 telegram channel ,现在不定时分享一些Rust 动态。
  • 芽之家,Rust Weekly 翻译,也有其他的一些Rust 动态翻译。如果想参与翻译贡献的,也可以去这里贡献:https://github.com/zzy/this-week-in-rust-zh-cn
  • 知乎, 知乎上也有很多 Rust 讨论和文章,可以搜索。

Rust官方动态

编辑: 张汉东


Rust 2021 Edition 发布计划

【官宣】Rust 2021 Edition 计划

Rust 基金会 董事成员概要介绍

从 2021.03.18 到 2021.04.22 ,Rust 基金会官网依次介绍了十位董事(目前一共11位)会成员,这里依次列出简要介绍:

Rust Foundation Board

Bobby Holley

供职于 Mozillian Firefox CTO 办公室,从事技术战略和协调工作。在 Rust 基金会担任 会员董事 (Member Director)。

Tyler Mandry

供职于 Google Fuchsia操作系统。于2020年初开始领导Rust on Fuchsia团队。为 Rust 贡献了很多, 例如 async/await 编译器优化,指导工作(如基于源代码的代码覆盖率)并领导异步基金会工作组。在 Rust 基金会担任 项目董事(Project Director),负责工程质量相关。

Mark Rousskov

在 Rust 基金会担任 项目董事(Project Director),Rust Core 团队代表。 Mark 认为 Rust 基金会的意义在于:定义Rust的价值观和目标:开发可持续,有效的模型在全球范围内进行开放式合作,在众多社区之间建立桥梁。

Nell Shamrell-Harrington

供职于 微软,Rust Async Foundations 工作组成员,现在是 Rust 基金会 会员董事(Member Director)。Nell 曾经是 Mozilla Rust团队的一员。在2020年8月裁员之后,有机会加入了 微软,成为开源计划办公室的首席软件工程师。

他说,

“微软对待开源的态度真正发生了转变。微软理解开源软件对于其作为企业的成功以及对我们客户的成功至关重要。此外,微软还了解到,为了使我们所依赖的项目获得成功,我们必须以身作则的开源公民的身份行事,并与开源社区进行有意义的互动。这包括作为Rust基金会的赞助者捐款。它还包括致力于Rust项目的工程工作,以使其不仅对Microsoft更好,而且对全世界所有Rustaceans也更好。微软很高兴有机会与社区互动并回馈社区,并期望与Rust的维护者在编译器,核心工具,文档等方面紧密合作”。

“在 Rust 基金会部分职责是代表Microsoft对Rust社区的利益,但对我来说,代表和维护Rust的维护者和社区对Microsoft的利益对我来说更为重要。理解和平衡这些需求并找到共同点并不总是容易的,但这是Rustaceans在RFC流程中每天都会做的事情。我们有很多机会相互学习,相互构建,设计以前从未考虑过的解决方案。”

Florian Gilcher

在 Rust 基金会担任 项目董事(Project Director),Rust Core 团队代表。Rust Berlin,RustFest和OxidizeConf 会议的创始人之一。曾经他还是一名活跃的 Ruby 开发者,领导开发 Padrino 框架。第一次写 Rust 代码是 2013 年。2015年以来,专业地教授 Rust 并共同维护开源培训材料。2018年,我全力以赴并共同创立了 Ferrous Systems,这家公司致力于通过传播知识,实践并改善 Rust 本身来帮助行业采用 Rust。

侯培新

供职于 华为。在 Rust 基金会中担任 会员董事(Member Director)。华为选择 Rust 语言是因为 Rust 语言默认内存安全,并且应用范围匹配 C/Cpp 领域。华为作为 ICT 基础设施供应商,性能和安全性基本上是Rust的两个基本基准,所以希望投资 Rust 语言并在产品中得到广泛使用。华为作为唯一战略赞助。

他说到:

“对于华为,我们将在社区中投资的领域包括数值计算,机器人技术,虚拟化等项目。作为迄今为止中国唯一的铂金创始成员,我们还希望与所有合作伙伴一起推广 Rust,这可能包括建立本地基础架构,例如 crates.io 和本地 CI,以实现更好的访问和可用性,将更多文档翻译成中文并推广更多活动。最后但并非最不重要的一点是,我们渴望在欧盟,北美拥有更多的Rust人才加入我们的行列。”

Jane Lusby

Jane 是 Rust 错误处理项目组的负责人,以及 Clippy 和 wg-traits 团队成员。她在 Rust 基金会中担任 项目董事(Project Director),负责项目协作。

Shane Miller

Shane 目前领导 AWS 的 Rust平台团队。大约 30年以来,她一直在创造自己独特的职业道路。她曾经是一名高中辍学生,Smalltalk首席工程师,大学数学系,零售业务所有者,政治顾问,工程经理以及 AWS 的首席技术项目经理和高级工程经理。

她在 基金会担任 会员董事 (Member Director)。

她说到:

“ 作为基金会董事会的一名董事,我设想基金会将成为一个为Rust项目维护者提供我自己的AWS团队支持的组织。当我们消除计算,存储和生产力工具的维护人员的自付费用时,我们将使Rust真正可访问。我们还可以提供对资源的访问,例如领导力和沟通培训,这些资源可以帮助Rust的维护者发展自己和团队。

我对基金会感到兴奋的是,它为我提供了一种以不同方式为我们的社区做出贡献的机制。我有大量交付大规模软件的经验。在回到工程经理职位之前,我是AWS的首席技术项目经理。我启动了涵盖组织和地区的服务。我知道将复杂性和规模相结合要取得成功是多么困难,并且我期待着帮助我们的社区简化其中的一些复杂性。

用塞思·戈德温(Seth Godwin)的话说:“我们为谋生做什么?我们要做的是尝试改变一切。 我们试图找到一种现状,即困扰我们的事情,需要改进的事情,需要改变的渴望的事情,然后我们进行更改。我们试图做出重大的永久性重要改变。”

让我们建立社区和技术,使我们所有人永存。 ”

Josh Stone

在 基金会中担任 项目董事(Project Director),负责可靠性领域。他目前是 Rust 发布团队,同时也是安全响应工作组和编译器团队贡献者的成员,重点是LLVM工作组。

他说到: “ 我希望这些角色为我做好了担任可靠性项目主管的准备。我们一直希望Rust能够“正常运行”,因此您可以自由更新到最新版本,而不必担心性能下降。我们的跟踪记录很好,但是当然并不完美,我将寻找跟踪和改进它的方法。诸如crates.io和docs.rs之类的服务也很重要,不仅在正常运行时间上而且在广泛的可访问性上也是如此。我在这种基础架构方面的经验较少,但是我绝对愿意提出建议。 ”

Lars Bergstrom

供职于 Goolge ,是 Android 平台编程语言的工程总监,他的团队目前在使用 Cpp/Java/Kotlin/Rust。在性能最敏感的代码中提高内存安全性,对Android至关重要,既要确保用户安全,又要减少紧急安全更新的次数。目前在基金会担任 会员董事(Member Director)。

在加入 Goolge 之前,一直在 Mozilla 工作(2013年开始),从事 Servo 浏览器引擎开发。

他说到:

“ 希望基金会能够帮助Rust围绕许多个人和公司正在开展的活动发展新的合作关系,从而使整个社区受益。例如,大多数公司在内部对每个 Crate 的导入/更新进行附加的安全性检查,许可证/代码验证和性能分析,但如今这些信息并未在外部共享。此外,虽然理想的是用Rust编写新项目,但对于我们大多数人来说,我们需要将Rust集成到现有系统中-许多系统都是C++,并且每个系统都使用一组独特的功能,这些功能对与Rust进行有效集成提出了挑战。跨公司合作,我们可以构建适用于整个行业的解决方案。最后,对于许多关键系统(例如设备驱动程序)来说,Rust开发人员可以访问的正式验证还可以帮助我们消除软件系统中除内存安全性之外的更多问题。 ”

Facebook 加入了 Rust 基金会

其他基金会成员一样,Facebook致力于维持和发展Rust开源生态系统和社区。

Facebook的开源生态系统负责人,现任Rust Foundation的董事会主席Joel Marcey说:“ Facebook自2016年以来就接受了Rust,并在开发的各个方面都使用了Rust,从源代码控制到编译器。” “我们正在加入Rust基金会,以帮助贡献,改进和发展这种语言,这对我们和全球开发人员都变得如此宝贵。我们期待与其他基金会成员和Rust社区一起参与,使Rust成为系统编程及其他领域的主流选择语言。”

Facebook将于2021年加强对Rust的内部开发人员支持。除了公司内各个团队编写Rust代码外,现在还有一个专门的Rust团队,主要负责公司内部Rust开发的发展,包括开源贡献。加入Rust和基于Rust的项目,并与Rust社区互动。

https://developers.facebook.com/blog/post/2021/04/29/facebook-joins-rust-foundation/

Rust 基金会新增四个银牌会员

  • zama,为数据科学和AI构建开源同态加密解决方案。 从加密库到机器学习框架,他们始终使用Rust。
  • Ferrous Systems,通过提供培训和服务来改善Rust的生态系统,并雇用Rust编译器的维护者。 它还通过为嵌入式空间提供重要的生态系统工具(例如knurling )和共维护库(例如nrf-hal)来启用Rust on Embedded。 他们目前的标志性项目是二茂铁,以在安全关键型空间中启用Rust。
  • Tag1Consulting,是一家全球技术咨询公司,是支持他们依赖的开源项目的热心倡导者。 他们依靠Rust进行一些项目,例如goose.rs,这是一个高度可扩展的负载测试工具,其灵感来自基于Python的Locust Framework。
  • CleverClound,是一个自Rust成立以来就一直存在于Rust社区中的IT自动化平台。 他们自豪地为许多Rust开源项目做出了贡献,例如nom,sozu和许多其他项目。

https://foundation.rust-lang.org/posts/2021-04-29-membership-update/

Rust 编译器(Compiler)团队 4 月份计划

2021 年 4 月 15 日, Felix Klock on 代表 Rust 编译器团队发表了博文 Rust Compiler April Steering Cycle,对于 Rust 编译器的 4 月份研讨会议,进行日程安排。

以下是原文内容:

  • 2021 年 4 月 9 日,4 月第 2 个星期五,Rust 编译器团队召开了关于编译器方向周期(steering cycle)的 4 月份计划会议。
  • 每到第 4 个星期五,Rust 编译器团队就将决定:在未来的三个星期五,如何使用预定的指导和设计会议时间。
  • 2021 年 4 月 23 日,星期五,我们将举行一次会议,讨论的主题是:为编译器贡献者提出一套指导原则。
  • 2021 年 4 月 30 日,星期五,我们将召开一次会议,讨论每周的编译器性能分类过程。

此两个会议,将在格林尼治标准时间下午 2-3 点举行,直播地址为 T-compiler/meetings zulip stream。

Niko 退出 Rust 核心团队,将专注于 Rust 语言设计

Niko Matsakis 正在逐步退出 Rust 核心团队,他将集中精力带领 Rust 语言团队。多年来,Niko Matsakis 为 Rust 项目做了大量工作。从一开始,Niko 就是核心团队的一员,并在 Rust 的治理过程中,发挥了关键作用。关于 Niko 将为专注的新焦点带来什么特性,Rust 团队都很期待!

Niko 在其博客说到:

“ 我计划将所有精力都集中在作为语言设计团队负责人和AWS Rust Platform团队技术负责人的角色上。

我希望能做更多的产品计划,例如异步愿景文档,以帮助Rust为其未来建立一致的愿景。我还希望继续研究扩展lang团队,改进RFC流程并帮助团队正常运作的方法。 ”

https://smallcultfollowing.com/babysteps/blog/2021/04/02/my-shiny-future/

Rust GAT 离稳定又进了一步

#81823 Bug 已被修复,目前阻碍 GAT 只剩下 三个 Bug 了。

https://github.com/rust-lang/rust/issues/44265

头脑风暴进行中:Async Rust 的未来熠熠生辉

Niko Matsakis 代表 Async 基金会工作组,于 2021 年 4 月 14 日发布文章 Brainstorming Async Rust’s Shiny Future

3 月 18 日,我们宣布开始为 Async Rust 构建共享的愿景文档的过程。从那时起,我们已经接收到了 24 个“现状”故事。在开启状态的 PR(open PRs)中,还有 4 个故事;在过去的几周里,Ryan Levick 和我还主持了 10 多个协作写作会议。

阅读:

rustc_codegen_cranelift 进展报告

  • 删除了对旧式Cranelift后端的支持
  • 原子操作是使用本机原子指令实现的,而不是使用全局锁来模拟的
  • 使用MinGW交叉编译到Windows
  • 在CI上运行rustc测试套件

后面的挑战:

  1. Windows对MSVC工具链的支持

Cranelift尚不支持COFF / PE目标文件的TLS。 这意味着与使用pthread密钥实现TLS的MinGW不同,当前无法为MSVC进行编译。

  1. SIMD

很多平台指令支持仍未实现。

https://bjorn3.github.io/2021/04/13/progress-report-april-2021.html

Miri 现支持运行 doctests

Miri 是一个实验性的 Rust MIR 解释器。它可以运行 Rust 二进制文件,对其进行测试,可以检查出某些未定义的行为 ref。

支持 doctests 之后,cargo miri test 执行的测试与 cargo test 执行的测试就保持一致了。

https://github.com/rust-lang/miri/pull/1757

编译器团队迎来新成员 Aaron Hill

自2017年创立以来,Aaron Hill(@ Aaron1011)一直在为编译器的许多不同部分做出贡献。最近,Aaron一直致力于发现和修复增量系统中的错误和正确性问题,清理以及对宏扩展和卫生的改进, 以及其他错误修复。 除编译器外,Aaron还实现了对Miri中展开,生成自动特征文档以及将来在Cargo中不兼容报告的支持。

https://blog.rust-lang.org/inside-rust/2021/04/26/aaron-hill-compiler-team.html

Rustup 1.24.1 发布

Rustup 1.24.0 发布之后,发现了因为代码疏漏导致cargo fmt无法使用,从而回滚到 1.23.1 。现在 1.24.1 修复了这个 Bug 。

https://blog.rust-lang.org/2021/04/29/Rustup-1.24.1.html

介绍 TurboWish

TurboWish 是编译器团队Leader Pnkfelix 及其领导的 Amazon Web Services团队 计划中的一套工具的总称,用于了解Rust程序的动态行为。 他们希望这些工具特别专注于洞察有关程序性能特征。

目标描述:

  • Profile Production Code:合并TurboWish框架的开销很低:可以将其合并到生产代码中,而不会产生过多的维护负担,也不会产生显着的性能开销。

  • Domain-specific Feedback:框架和应用程序可以为专用度量提供数据,这些度量特定于其内部体系结构。

  • Understand Hidden Costs and Connections:tokio之类的框架使编写异步代码变得容易,因为它们在抽象后隐藏了许多细节(例如Rust编译器生成的生成器代码,或tokio运行时管理的任务队列)。 TurboWish公开了那些隐藏的细节,使开发人员可以将它们与其他程序事件相关联。它还暴露了人类通常必须手工重建的联系(例如,从未来到资源再到可能产生僵局的未来链),从而使人们可以直接从Rust的所有权模型中查看对象图中资源的持有方式。

  • Framework Agnostic:Rust的许多用户都使用tokio,但并非全部。 async-std和fuschia_async是用于异步编程的其他框架。 TurboWish可以为任何此类框架提供价值(尽管在担保时也可以提供特定于框架的功能)。对于我们的初始发行版,我们可以只专注于tokio,但是如果tokio证明是成功的,则期望与其他人集成。

  • EC2 Instance Type Agnostic:如果我们使用任何特定于OS的功能(例如dtrace探针),则无论实例类型如何,它们将在所有EC2 AL2实例上可用。 (特别是,我们不能要求访问CPU性能计数器。)

  • Part 1: http://blog.pnkfx.org/blog/2021/04/26/road-to-turbowish-part-1-goals/

  • Part 2: http://blog.pnkfx.org/blog/2021/04/27/road-to-turbowish-part-2-stories/

社区热点

编辑:张汉东


【RFC】Rust : Linux 内核的第二语言

以下是 RFC 的一些摘要:

We believe that, even today, the advantages of using Rust outweighs the cost.

内核中支持 Rust 去编写驱动和一些类似 “叶子” 模块,而不打算用 Rust 重写内核核心和内核子系统。

在 Linux 内核中对 使用 Rust 的期望:

  1. 用 Rust 新编写的代码能降低内存安全/ 数据竞争 / 逻辑 Bug 的风险
  2. 维护人员对 重构和 接受模块补丁 更有信心
  3. 新的驱动程序和模块变得更容易编写
  4. 使用新的现代的语言,吸引更多的人参与内核开发
  5. 通过利用Rust工具,建立的文档指南。 例如,要求记录所有公共API,安全前提条件,“Unsafe”块和类型不变式。

Rust与C相比也有缺点:

  • 围绕内核开发的 C 语言工具有很多年的积累,但是,如果内核 Rust 使用量上来,也会有相应工具。
  • Rust 基于 LLVM 的单一实现。目前通过第三方的努力来改善这种情况,比如 GCC 前端,基于 Cranelift 的 rust 后端,以及减少 bootstrapping chain 的 mrustc。
  • 未标准化。虽然尚不清楚标准化是否会对内核有利,但在任何情况下都有几点可以最小化此问题:Rust稳定性承诺,广泛的文档,WIP参考,详细的RFC等。
  • 编译速度太慢。
  • 目前需要一些 nightly 功能,目标是一年内帮助 rustc 稳定这些功能,或者是,找到替代的稳定的功能。在这里维护相关报告:https://github.com/Rust-for-Linux/linux/issues/2
  • 其他。

「 我们认为Rust很可能会就像C一样,成为系统编程的重要组成部分」

关键设计选择

  1. Rust内核模块需要一些共享代码,即 通过配置选项(CONFIG_RUST)启用。这使得 单个模块更小。此支持包括:
    • Rust 标准库。当前包含了 alloc 和 core,未来可能仅有 core 的子集。
  • 包装内核API 的 安全抽象 API,目的是为了让 Rust 编写的模块中尽可能减少 Unsafe 代码的数量。
  • 其他。 例如“模块!”过程宏,编译器内置函数,生成的绑定和帮助程序等。
  1. 用Rust编写的模块永远不要直接使用C内核API。在内核中使用Rust的全部要点是,我们开发了安全的抽象,因此模块更易于推理,因此更易于查看,重构等 。此外,通过“ bindgen”(正式的Rust工具)即时生成与内核C端的绑定。使用它可以避免在Rust方面更新绑定。

  2. 在Rust代码库中,大多数文档都是在Markdown中与源代码一起编写的。我们遵循此约定,因此尽管在Documentation / rust /中有一些常规文档,但大多数实际文档都在源代码本身中。

强制执行自动代码格式,使用 clippy/ fmt 等。例如,这意味着4个空格用于缩进,而不是制表符。我们认为重要的是保持格式自动化。

  1. 为避免将GPL符号公开为非GPL(甚至间接),将内核中的所有Rust支持符号导出为GPL。

目前状态

目前提供的 Rust 支持是实验性的,因为还有很多细节需要完善,比如内核API需要安全抽象等。但是,现在可以编写一些模块等原型了。

目前支持 Clang 构建内核,支持 x86_64,arm64和ppc64le等衍生架构,以及 RISC-V等。

上游计划

与往常一样,尽早进入主线是解决所有遗漏细节的最佳方法,但是,我们何时提交它们取决于我们收到的有关此RFC的反馈以及高层维护者的总体看法。

我们希望得到反馈的另一个主题是 kernel crate: https://rust-for-linux.github.io/docs/kernel/

相关链接

https://lkml.org/lkml/2021/4/14/1023

仓库地址:https://github.com/Rust-for-Linux/linux

Linus 对 Rust 进内核给出了珍贵的指导意见


So I replied with my reactions to a couple of the individual patches, but on the whole I don't hate it.

HOWEVER.

I do think that the "run-time failure panic" is a fundamental issue.

I may not understand the ramifications of when it can happen, so maybe it's less of an issue than I think it is, but very fundamentally I think that if some Rust allocation can cause a panic, this is simply fundamentally not acceptable.

Allocation failures in a driver or non-core code  and that is by definition all of any new Rust code can never EVER validly cause panics. Same goes for "oh, some case I didn't test used 128-bit integers or floating point".

So if the Rust compiler causes hidden allocations that cannot be caught and returned as errors, then I seriously think that this whole approach needs to be entirely NAK'ed, and the Rust infrastructure - whether at the compiler level or in the kernel wrappers - needs more work.

So if the panic was just some placeholder for things that _can_ be caught, then I think that catching code absolutely needs to be written, and not left as a to-do.

And if the panic situation is some fundamental "this is what the Rust compiler does for internal allocation failures", then I think it needs more than just kernel wrapper work - it needs the Rust compiler to be *fixed*.

Because kernel code is different from random user-space system   tools. Running out of memory simply MUST NOT cause an abort.  It needs to just result in an error return.

I don't know enough about how the out-of-memory situations would be triggered and caught to actually know whether this is a fundamental problem or not, so my reaction comes from ignorance, but basically the rule has to be that there are absolutely zero run-time "panic()" calls. Unsafe code has to either be caught at compile time, or it has to be handled dynamically as just a regular error.

With the main point of Rust being safety, there is no way I will ever accept "panic dynamically" (whether due to out-of-memory or due to anything else - I also reacted to the "floating point use causes dynamic panics") as a feature in the Rust model.

           Linus

详细:https://lkml.org/lkml/2021/4/14/1099

Google: Linux 内核中使用 Rust

Google 发布的这篇博文中宣布支持 Rust ,并且还给出一个驱动开发示例来对比 Rust 和 C 。

https://security.googleblog.com/2021/04/rust-in-linux-kernel.html

字节码联盟成为非营利组织并且迎来新成员

字节码联盟(Bytecode Alliance)成为了 501(c)(6) 非营利组织,并且有新成员加入 Arm, DFINITY Foundation, Embark Studios, Google, Shopify, 和 University of California at San Diego 。

一直以来的工作:

  1. 推动 nanoprocess 并推动标准化,包括 WASI/ Interface Types/Module Linking等。
  2. Cranelift WebAssembly编译器创建了一个新的代码生成后端,重点关注性能和安全性。这个新的后端是由Fastly,Mozilla,Intel和Arm之间紧密合作创建的,现在是Wasmtime和Lucet的默认后端,并且Fastly最近对该技术进行了全面的安全评估,并为 Compute @ Edge 切换到了这个新后端。
  3. 改进了协作流程;例如,引入了RFC流程来提高可见性,并能够集中讨论我们核心项目的重大变化

未来社区治理

我们欢迎新成员加入字节码联盟。 在加入新成员的同时,在接下来的几个月中,我们将为技术指导委员会(TSC)和董事会全面定义章程,作为引导阶段的一部分。 在此期间结束时,我们将组织理事会和TSC选举,以全面建立Bytecode Alliance的开放治理模型。 更多细节将在不久的将来出现。

https://bytecodealliance.org/articles/bytecode-alliance-update

Rust 在过去一年中是增长最快的语言之一

SlashData指出,Rust和Lua是过去12个月中增长最快的两个编程语言社区,尽管其基础比Python低。

Rust并未得到广泛使用,但今年它的社区走出了Mozilla的阴影,并创建了自己的Rust Foundation,该基金会得到了Amazon Web Services,Microsoft Azure,Google和Mozilla的支持。

Google力求Rust成为Linux内核的第二种语言,并使用它为Android操作系统编写新代码,因为它的内存安全性保证有助于避免内存错误。 开发人员还赞赏Rust的Crates软件包管理系统。

https://www.zdnet.com/article/programming-languages-javascript-has-most-developers-but-rust-is-the-fastest-growing/

整理了一下 Rust 尾调用优化的状态

tco

起因是 reddit 上面看到一篇帖子,作者说他看到 Clang 加了一个 [[clang::musttail]] 属性,可以保证编译器对尾调用的优化,意味着 LLVM后端 目前应该也支持了尾调用优化的保证,那 Rust 是不是就离支持 尾调用优化不远了呢?

https://www.reddit.com/r/rust/comments/my6k5i/are_we_finally_about_to_gain_guaranteed_tail/

参考:

https://blog.reverberate.org/2021/04/21/musttail-efficient-interpreters.html

https://dev.to/seanchen1991/the-story-of-tail-call-optimizations-in-rust-35hf

Rust OSDev 发布 2021 年 3 月简讯

Rust OSDev 组织是 Blog OS 系列文章作者创建,维护着一些 OS 相关项目。

GCC Rust 发布第四期月报

GCC Rust成为了 Google Summer of Code 2021 指导组织的一员,吸引了来自世界各地的一些对编译器开发感兴趣的学生。

此编译器的长期目标是前端可以与GCC分离,并成为自己的项目(同时保持GCC版权分配),这就是为什么我们使用rust-gcc.cc分支的原因 go-gcc.cc通用抽象。

更多:

mrustc 支持 Rust 1.39.0

  1. mrustc 是一个 基于 Cpp 的 Rust 编译器的替代实现,最终目标是成为一个单独的重新实现。
  2. 目前官方 rustc 版本为 1.51.0 。

https://github.com/thepowersgang/mrustc/

Flott 月报发布

Flott 是基于 Rust 实现的运动控制软件的工具包。

Flott的目的是通过提供库来简化运动控制软件的开发,这些库可以抽象出通常用于运动控制的硬件,例如电动机,编码器以及其他传感器和执行器。

上个月看到了通用步进电机接口步进器(Stepper)的新版本。 还发布了Stepper Terminal,这是一个基于Stepper的新应用程序,用于从计算机上的命令行控制步进电机。

社区雄文:Rust 是为专业程序员准备的

专业的程序员通过创作和维护可解决问题的软件来创造价值。 (专业程序员还有其他重要的方式可以交付价值,但是本文是关于编程的。)

程序员依靠各种工具来编写软件。 可以说,最重要和最重要的工具选择是编程语言。

在这篇文章中,作者将阐明为什么他相信Rust对于软件专业人员来说是一种非常引人注目的编程语言选择。 简而言之,他希望说服你学习和部署Rust。

https://gregoryszorc.com/blog/2021/04/13/rust-is-for-professionals/

Tokio 发布 io_uring 设计方案

该 RFC 提出了一个新的异步Rust运行时,它由io-uring作为新的 Crate 支持:tokio-uring。 该API的目的是尽可能接近惯用的Tokio,但在必要时会有所偏离,以提供对io-uring功能的完全访问权限。 它还将与现有的Tokio库兼容。 运行时将使用隔离的每核线程模型,并且许多类型将是!Send

https://github.com/tokio-rs/tokio-uring/pull/1

Starlark -- Facebook 用 Rust 重新实现了 Starlark 这门编程语言

Starlark 是 Google 开发的类似于 Python3 的一门编程语言,主要用在 Google 自己的构建工具 Bazel 和 Facebook 的构建工具 Buck 中。Starlark 目前有三种实现,一种是 Go,一种是 Java,另外一种就是现在这个项目,使用 Rust 实现。Rust 的初版是 Google 实现的,现在给了 Facebook。Rust 实现包括了完整的 parser,evaluator,linter 和 debugger。对编程语言感兴趣的朋友可以看看。

enso 编程语言发布 2.0

enso 是一种可视化编程语言,同时也支持支持 Java、Python、R、JavaScript。使用 Rust 和 Java 编写。

https://github.com/enso-org/enso

42 个在生产环境使用 Rust 的公司

https://kerkour.com/blog/rust-in-production-2021/

Google 提供资金,ISRG 联合 Rustls 作者和 GoogleOSS 对 Rustls 进行改进

改进包括:

  1. 强制执行免恐慌(no-panic policy)策略,以消除在C语言边界上使用Rustls时出现未定义行为的可能性。
  2. 改进C API,以便Rustls可以更轻松地集成到现有的基于C的应用程序中。将C API合并到主要的Rustls存储库中。
  3. 添加对验证主题备用名称扩展中包含IP地址的证书的支持。
  4. 使基于客户端输入来配置服务器端连接成为可能。

目前已经完成了curl和Apache httpd 中集成 rustls,未来希望在 Let's Encrypt 中 用 Rustls 取代 OpenSSL 和其他不安全的TLS库

https://www.abetterinternet.org/post/preparing-rustls-for-wider-adoption/

寻找 Rust 工作是一种痛

Reddit 上面有人发帖问,有没有区块链之外的Rust工作?

看来 Rust 工作不好找,不只是 国内的问题。

评论区有人回复摘录:

70%的 Rust 职位是很区块链相关的,他之前找过一个 非区块链 Rust 后端工作,但是不久之后就变成 Go 了。。。

如果每个人都只是寻找Rust工作,而不是将现有公司代码转换为Rust,那么就不会有Rust工作。

还有人直接在评论区招聘,视频流/webrtc 相关职位

有人一直在寻找 Rust 工作,并为此付出一些代价。比如专门去寻找适合 一个人开发的 工作,这样就有机会选择 Rust ,最终让他找到了工作机会,一家视频游戏公司,围绕unity开发一些工具,最终目标是从他们的工作流中干掉unity

也有一个电力行业的人说他们会在今年夏天发布一些 Rust 职位

还有一家 科学仪器设计和生产商在使用 Rust ,其中一种仪器在每个实验中收集了几Tb的图像数据,这些数据使用rust编写的代码进行处理和分析。

如果您对计算机视觉/零售结帐感兴趣,请访问https://standard.ai。在过去的几年中,很大一部分生产堆栈已从python / cython迁移到rust。 尽管一些后端和研究团队将长期使用python,但目前它在很大程度上是使用 Rust 的。

有人直接发了 JD ,是完全远程的工作,国内也可以看看。 https://www.showseeker.com/backend-developer

如有任何问题,请联系官方发贴电

相比国外,国内 Rust 岗位虽然不多,但也是有的。

https://www.reddit.com/r/rust/comments/n05zhh/looking_for_a_rust_job_is_pain/

用Rust重写Tensorbord部分组件带来了100x到400x的速度提升

相比原先Python和C++混合,使用大量的锁和跨语言交互的数据读取器, 在使用了Rust编写的专用进程进行数据读取后,性能提升了100x到400x。

Rust 生态贡献者 排名

https://www.johndbeatty.com/rustaceanrank.html

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【活动预告】2021.05.15 北京线下 WebAssembly Meetup **

现在征集议题 : 议题申请


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 21 期 - Local Native 分布式应用开发

时间: 2021/03/30 20:30-21:30

主讲人:Yi Wang

内容:详解 Rust 实现的跨平台去中心的应用 Local Native

  • demo 网络书签信息管理工具 Local Native
  • 技术讲解
  • 未来的目标
  • demo 姐妹项目 Fastxt
  • Q&A

查看回放

扩展资料


《Rust 唠嗑室》第 22 期 - 关于 Rust io_uring 异步接口实现的思考

时间: 2021/04/13 20:30-21:30

主讲人:施继成

内容:关于 Rust io_uring 异步接口实现的思考

io_uring 接口在 Linux 中被用于高效的异步 I/O 操作,但是使用 liburing 的简单封装十分不友好,我们探索了一种实现方法,一方面能够简化接口的使用,另外一方面避免不必要的内存拷贝保证效率。借此机会和大家分享一下实现过程中的思考,也聆听大家的建议,不断改进优化实现方法。

查看回放

扩展资料


《Rust 唠嗑室》第 23 期 - Rust From A Haskeller's View

时间: 2021/04/27 20:30-21:30

主讲人:火锅 boy 大冬冬

内容:Rust From A Haskller's view

从资深 Haskell 程序员的视角,试图了解 Rust 这门年轻的语言,除了从技术的角度和 Haskell 进行对比之外,也分享了关于两个语言社区发展的思考和展望

查看回放


【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR 话题群:https://applink.feishu.cn/TeLD868w

第三期讨论主题: Rust 语言中级中间语言 MIR 的功用

  1. 介绍 MIR 与 MIRI
  2. 和论文的两位第一作者共同阅读2021年新出炉的论文:《 SafeDrop:通过静态数据流分析检测 Rust 程序中的内存释放错误》

参考资料:

  1. https://rustc-dev-guide.rust-lang.org/mir/dataflow.html
  2. https://github.com/rust-lang/miri
  3. https://arxiv.org/pdf/2103.15420

查看回放

第四期 讨论主题:

  1. Rust 今天新合并的 RFC : try-trait
  2. Lunatic: 一个类似于 Erlang 的 容错 WASM 运行时

查看回放

第五期 讨论主题:

  1. 语言特性: 《Rust 标准库 trait 之旅》
  2. 领域项目:通过WebSocket实时共享应用程序状态框架 Aper

参考资料:

  1. https://github.com/pretzelhammer/rust-blog/blob/master/posts/tour-of-rusts-standard-library-traits.md
  2. https://github.com/aper-dev/aper
  3. https://aper.dev/guide/

查看回放


🔥🔥🔥🔥 Rust MeetUp 🔥🔥🔥🔥

【线下】Rust MeetUp 本月汇总

  • 来源:Rust 活动
  • 上传者:MikeTang
  • 后期编辑:高宪凤

Rust Meetup 北京站

时间: 2021/04/10

地点:北京中关村创业大街

【P1】 构建安全高性能的网络应用

嘉宾:陈天

查看回放

【P2】 异步化 OS : 使用 async/await 提升 10 倍性能

嘉宾:蚂蚁集团 田洪亮

查看回放

【P3】 Rust和高性能隐私计算

嘉宾:数牍科技

查看回放

【P4】 用Rust实现RDMA编程

嘉宾:王璞 @ Datenlord

查看回放

【P5】 统一相似的异步和同步代码

嘉宾:吕国立

查看回放

【P6】 Unicode标识符简介

嘉宾:CRLF0710

查看回放

【P7】 线性类型映射世界

嘉宾:jolestar

查看回放


Rust Meetup 成都站

时间: 2021/04/27

地点:成都天府软件园

【P1】 The Future of Embedded System Programming

嘉宾:张奕

查看回放

【P2】 亚马逊教你轻松开发安全隔离的虚拟机

嘉宾:孙华

查看回放

【P3】 从Rust在金融系统的应用到通用事件门溯源模型

嘉宾:张宇

查看回放

【P4】 md5 编码器 live coding

嘉宾:吴翱翔

查看回放

本月招聘

编辑: 张汉东


知乎:搜索后端研发工程师

岗位职责

  1. 负责知乎搜索业务的后端开发工作
  2. 负责知乎搜索服务在线系统架构建设,优化系统稳定性、性能和吞吐量
  3. 负责对应数据的索引建设及更新,优化索引数据的一致性及更新效率

任职要求

  1. 有扎实的编程能力,有良好的数据结构和算法基础
  2. 良好的团队合作精神,较强的沟通能力
  3. 熟悉 Linux 开发环境,熟悉 Go 语言,熟悉网络编程、多线程编程
  4. 熟悉搜索引擎,对 Elasticsearch、Kubernetes 有使用经验者优先
  5. 有高可靠分布式系统架构设计经验者优先
  6. 熟悉 Rust 语言优先

联系方式

zl @ zhihu.com


蚂蚁集团:

蚂蚁集团:系统/安全开发工程师(社招/校招/实习生均可)

蚂蚁集团安全计算团队致力于基于TEE、内核、虚拟化等技术为蚂蚁数据和业务打造安全计算环境。

职位亮点

  1. 这里有领先业界的技术等你探索,也有业界少有的技术难题等你攻克;
  2. 这里有一帮技术大牛(知名开源项目创始人、顶级会议论文作者)等你来碰撞智慧的火花;
  3. 这是一个让技术梦想变成现实、用技术改变世界、让技术人快速成长的平台!

职位描述

负责云环境下的高安全、高可靠的底层支撑软件和产品研发。本岗位P6/P7/P8/P9都有需求。

职位要求

  1. 大学本科或以上学历,计算机及相关专业,三年以上相关工作经验;
  2. 对底层系统技术有比较深入的理解,有底层软件的开发经验;
  3. 熟悉C或C++编程,有Rust语言的经验优先;
  4. 对底层软件或安全技术有浓厚的兴趣,并具备快速学习的能力。

联系方式

闫守孟,shoumeng.ysm@antgroup.com

时序数据库研发工程师

岗位职责

CeresDB 作为高性能、低成本并具有实时分析能力的新一代分布式时序数据库,已经应用在蚂蚁内部诸多场景中。我们面临着海量数据存储、访问和分析的技术挑战,急需对此方向感兴趣的朋友加入。

您将参与时序数据库 CeresDB 存储引擎的研发,包括但不限于:

  1. 核心存储引擎的研发
  2. 存储与计算分离架构的研发
  3. 引擎层面对于实时计算和分析框架的研发
  4. 软硬件结合技术的探索和应用

任职要求

  1. 扎实的计算机基础,有良好的数据结构和算法基础。
  2. 掌握 C/C++/Rust/Go 语言中的至少一门,并有意愿学习和使用 Rust 语言。
  3. 熟悉 Linux 系统编程,有良好的编码习惯,追求卓越的工程实践,易于沟通,并具有持续学习的能力。
  4. 良好的团队合作精神,较强的沟通能力。
  5. 熟悉 Rust 语言优先,有过时序数据库研发经验优先。

联系方式

jiachun.fjc@antgroup.com


飞书团队:

Rust 开发工程师

职位描述

1、开发跨平台高性能native客户端核心组件; 2、负责沟通及创作工具的系统设计, 实现, 优化和演进; 3、研究分析主流 IM,优化实现方案,改进产品功能; 4、负责设计和优化 IM 协议、弱网通信、推送、存储、网络并发、并行计算、加密以及安全等; 5、保证工程质量和开发效率。

职位要求

1、计算机相关专业,有较为扎实的计算机基础知识,深入理解数据结构、算法、网络和操作系统知识; 2、熟悉 C/C++,一年以上 Rust 开发经验,有实际 Rust 项目经验或 Rust 相关开源项目贡献经验优先; 3、对代码规范、性能和效率追求极致,乐于探索底层细节和实现原理; 4、认真负责,沟通高效,有良好的团队合作意识; 5、较强的学习能力,熟练阅读英文技术文档优先;

联系方式

wx: newpants629,或者直接在内推链接投递简历:https://job.toutiao.com/s/eB1j29f


PingCAP:

SQL 引擎自动调优算法工程师

工作职责

  1. 负责 TiDB 自动调优,提升 TiDB 在各种运行环境和工作负载下的使用体验,包括但不限于:优化器代价模型、执行引擎并发和资源管理、存储引擎缓存配置、事务隔离级别等的自动调优;
  2. 根据业务负载,给出业务优化建议,如增删索引、收集统计信息、调整集群拓扑等。

任职资格

  1. 具备数据库的设计与开发经验;
  2. 掌握常用算法和数据结构;
  3. 掌握至少一门编程语言,如:C/C++/Java/Go/Rust;
  4. 良好的分析与解决问题能力;
  5. 良好的沟通与协作能力;
  6. 良好的英文书面能力,能流畅阅读英文论文,用英文编写技术文档。

加分项:

  1. 具备云原生数据库自动调优经验;
  2. 有良好的英文口语能力,能流畅沟通交流。

职位申请

Infra研发工程师

工作职责数据库存储了大量核心的商业数据,对正确性、可靠性要求非常高。开发一款数据库难,测试一款数据库也非常有挑战。TiDB 有大量的测试用例。如何管理、高效运行这些测试用例,对提升测试效率意义重大。

我们希望构建一套强大的测试平台和工具包,能高效利用资源自动化跑完测试,并反馈测试结果。同时为测试同事提供一套简单易用的开发包,帮助他们降低测试自动化的难度,提高测试效率。

工作职责

1.开发 TiDB 的测试基础设施,包括测试用例管理系统,测试用例运行系统以及测试资源管理系统,帮助测试同事管理、运行测试用例,并返回测试结果; 2.开发 TiDB 的系统测试平台,包括负载管理、资源隔离、错误检测、测试记录管理等功能; 3.开发和维护云上的测试基础设施,降低云上测试的复杂度,节约成本; 4.负责 DBaaS 业务的 API 测试、K8s operator 测试以及压力和稳定性测试; 5.分解上述平台软件需求,完成技术选型和技术设计,发布高质量的工具产品。

任职资格

1.热爱编程,希望自动化一切低效的工作; 2.掌握一门后端语言,理解该语言特性,golang、rust 更佳; 3.熟悉并实践过常用的数据结构、算法、协议; 4.有一定软件架构能力,了解微服务拆分,分布式架构设计及常用中间件; 5.具有良好的工程素养和较强的学习能力,认真负责,有较强的问题定位和推动能力; 6.熟悉 K8s 加分,深入理解分布式技术加分,对测试、软件质量有较高热情加分。

职位申请


Octopus Network : Rust工程师

Octopus Network 致力于将 Web3.0 分布式应用落地化,积极探索 Web3.0 与 Web2.0 的结合,带给用户与当前互联网同样或相似的使用体验。公司已获得数百万美元的VC投资。网址:https://oct.network。

职责:

  • 使用 Rust 构建一整套 Web3.0 技术栈
  • 完善现有的开发框架
  • 解决一些细节技术问题

要求:

  • 有相当程度的 Rust 经验(1~3年经验)
  • 对 Rust 的 crates 依赖机制、cargo 工具、编译选项等使用相当熟悉
  • 对 Rust 的 no_std 编程有过研究
  • 有网络编程、存储系统、密码学背景知识

加分项:

  • 对 WebAssembly 的原理和实践有经验者优先
  • 对 Blockchain 的原理和实践有经验者优先
  • 英语听说写能力好者优先
  • 对社区建设有经验,在Rust社区中有过贡献者优先
  • 对知名 Rust 开源项目(包括但不仅限于密码学、p2p通信、blockchain、分布式存储等项目)有过贡献者优先
  • 有加密领域实践经验者优先

工作形式:

  • 全远程

待遇:

同行业上等水平,100K$-150K$ total package, tax free。优质待遇诚邀优秀人才。

联系:

  • Email:mike@oct.network
  • vx:daogangtang

北京数牍科技:

Rust研发工程师

北京 | 1- 5年

工作职责: 1、负责多方计算平台PaaS平台的设计、开发、测试和优化。 2、负责系统架构设计、优化,提升系统性能和开发效率。 3、负责技术预研和技术难点攻关,保障系统可用性、稳定性和可扩展性。 任职要求: 1、计算机相关专业本科及以上学历,具有两年以上Rust/C++开发经验。 2、严密的思维逻辑性,体系性的思考方式,良好的语言表达能力。 3、有从事分布式计算或存储的开发经验。

Rust 算法工程师 北京 | 1- 5年 工作职责: 1、负责实现安全多方计算算法的实现。 2、负责实现多方机器学习算法的实现。 3、理解分布式架构下的多方安全计算场景,对算法进行分布式改造。 4、对现有算法进行持续的迭代、优化。 任职要求: 1、计算机相关专业本科及以上学历,具有两年以上Rust/C++开发经验。 2、严密的思维逻辑性,体系性的思考方式,良好的语言表达能力。 加分项: 3、具有机器学习算法的相关背景,特别是federated learning。 4、熟悉常见机器学习框架中的一种,例如scikit-learn、tensorflow。 5、有一定密码学算法基础,例如同态加密、不经意传输等。 6、有一定的英文阅读能力。

**联系方式 **

zhangkm @ sudoprivacy.com


【上海】吉利集团-数字科技板块招聘

Base上海,工程师25-40k 架构师35-50k

岗位职责:

1.负责吸收海外先进的区块链原创技术,进行本地化改造及优化落地;

2.负责与国内合作伙伴进行区块链技术合作,支持区块链应用的场景落地;

3.负责区块链底层架构设计及关键技术选型、关键技术实现;

4.从事区块链及产品的设计和研发工作,研究区块链的协议,运行机制和底层实现以及核心代码开发;

5.搭建基于区块链的底层架构,能实现公链、侧链、私有链及多种逻辑,供应用层调度使用,直至提供云服务输出;

6.组织制定和实施技术决策和技术方案,组织架构设计与升级优化;

7.能够对虚拟机引擎(wasm、EVM、V8等)进行性能优化和改造

任职要求:

1.本科及以上学历,计算机、软件工程、数学或信息技术相关专业;3年以上工作经验;

2.精通Rust/C++/Haskell等开发语言中的一种或多种,具有良好的编程能力和编程习惯;

3.熟悉各种数据结构和算法,对密码学、安全协议和加密算法、数字签名、数字证书有研究;

4.熟悉Hyperledger Fabric、Ethereum、IPFS等相关开源项目中的一种或多种,有研究和贡献经验优先;

5.熟悉Linux、分布式、智能合约、共识机制和算法,有大型分布式系统研发经验,具备高性能、高可用、可扩展系统的体系化建设能力;

6.精通一种或几种主流数据库的原理与使用,包括传统关系型数据库和NoSQL等;

7.熟练应用软件工程和系统设计的能力,对计算机系统、网络通信、信息安全和应用系统架构等有全面的认识;

8.优秀的跨团队沟通能力和协作能力

联系方式

简历发送 Song.Shang@geely.com

微信: shangsongwww


上海息未信息科技: Rust开发工程师

10-15K·13薪

职位描述

1、参与公司硬件终端业务系统开发工作,编写设计技术文档,核心代码开发,保证代码质量; 2、根据开发进度和任务分配,能够独立处理和完成任务; 3、分析并解决系统开发及运行维护过程中的问题,对系统性能进行调优; 4、参与业务需求讨论,参与技术方案的制定。参与系统架构设计与技术调研,并给出合理方案;

岗位要求:

1、计算机、软件、电子信息、通信等相关专业本科及以上学历,一年以上研发相关工作经验; 2、熟悉c++、rust开发经验优先; 3、能熟练使用设计模式,在工作中深刻理解和使用单元测试及重构的能力, 4、能独立完成中小型项目的系统分析、设计、架构,并主导完成详细设计和编码的任务,确保项目的进度和质量; 5、具备良好的沟通表达能力和学习能力、逻辑分析能力和问题解决能力,具有团队协作精神和追求卓越的精神。

公司介绍

上海息未信息科技有限公司专注于航天及航空训练装备的研发生产,是由国内顶级基金投资的科技企业,主要服务于中国航天、军队以及航空公司。员工数量在20人左右,同时具备机械结构设计、单片机开发、虚拟现实开发的能力。企业正处于高速上升期,具有非常好的成长空间。

论文鉴赏 | 使用 Rust 进行安全系统编程

译者: MATRIXKOO / 编辑:张汉东

论文原文: https://cacm.acm.org/magazines/2021/4/251364-safe-systems-programming-in-rust/fulltext#FNA

编者按:

该文章发布于 ACM通讯,2021年4月。

作者是 德国马克斯·普朗克软件系统研究所(Max Planck institute for sofetware system)的 Ralf Jung 等。Ralf Jung 同时也是 Rust 官方团队致力于改进 Miri 以提供 Unsafe Rust 中 UB 检查。同时 Rust 安全模型形式验证项目 Rust Belt 的发起者。


关键观点

  • Rust 是第一个受行业支持的在高级语言带来的安全性和由较低级别的 系统编程 (system programming) 语言提供的资源管控之间做出长远平衡的语言。

  • 它以强大的类型系统解决了这一挑战,这种基于所有权和原则的类型系统静态地防止了共享状态的改变。这种方法使许多常见的系统编程的漏洞在编译时被检测到。

  • 有许多数据类型的实现从根本上取决于处于共享的可变状态,因此不能依靠 Rust 的严格要求进行类型检查所有权。为了支持这样的数据类型,Rust 明智的拥抱 unsafe, 使用 safe 的 API 封装在其中的 unsafe 代码。

  • 作为RustBelt project 的一部分, 健壮的 semantic type 证明技术,以及先进的 separation logic 和机器证明技术使得我们能够为 Rust 建立严格可靠的基础。

译者注: RustBelt project: https://plv.mpi-sws.org/rustbelt/#project

一个致力于用形式化证明来保证 Rust 安全性的项目


编程语言设计在两个看似不可调和的愿望之间长期存在着矛盾对立。

编程语言设计在两个看似不可调和的愿望之间长期存在着矛盾对立。

  • 安全 ( safe )。 我们想要强类型系统来排除静态地消除大量错误。 我们要自动内存管理。我们想要数据封装, 这样我们就可以对私有变量执行不变的对象的表示形式,并确保它们将不会被不受信任的代码破坏。
  • 控制 (control )。 至少对于 Web浏览器,操作系统,或游戏引擎这样的 系统编程 (system programming) 程序,约束它们性能或资源是一个重要的问题,我们想了解数据的字节级表示。 我们想要用底层语言 (low-level programming) 的编程技术优化我们程序的时间和空间的使用。 我们希望在需要时使用 裸机

然而,按照传统的看法,鱼和熊掌不能兼得。 Java 之类的语言使我们极大的安全保证,但代价是牺牲对底层的控制。结果,对于许多系统编程应用程序,唯一现实的选择是使用一种像 C 或 C++ 提供细粒度的语言控制资源管理。 但是,获得这种控制需要很高的成本。例如,微软最近报告说,他们修复的 70% 安全漏洞都归因于内存安全违规行为 33,并且都是能被强类型系统排除的问题。同样,Mozilla 报告指出,绝大多数关键 他们在Firefox中发现的错误是内存有关的16 。如果可以以某种方式两全其美: 安全系统编程的同时对底层有控制权...

键入 Rust 。由Mozilla赞助,并在过去十年中由众多不同的贡献者社区积极开发,Rust 支持许多从现代 C ++ 派生的常见的底层编程习惯用法和 API 。 但是,与C ++不同,Rust 通过强大的静态类型系统来强制安全地使用这些 API 。

特别的,像 Java 一样,Rust 可以保护程序员免受内存安全性问题的侵害(例如,use-after-free的错误)。 但是 Rust 更进一步地捍卫程序员免受其他主流语言无法阻止的其他更隐蔽的异常现象的影响。 例如,考虑数据竞争:对共享内存的不同步访问(至少其中之一是写操作)。 即使数据争用有效地构成了并发代码的未定义(或弱定义)行为,大多数安全语言(例如 Java 和 Go )都允许它们的存在,并且它们是并发错误的可靠来源35。与之不同的是,Rust 的类型系统在编译时就能探测到数据竞争。

Rust 一直在稳步普及,以至于现在许多主要的工业软件供应商(例如 Dropbox ,Facebook ,Amazon和 Cloudflare )都在内部使用 Rust,并且 Rust 在 过去五年的 Stack Overflow 的 最受欢迎的 编程语言列表中排名第一。 微软的安全响应中心团队最近宣布,它正在积极探索对 Microsoft 使用 Rust 可能性,以减少系统软件中的安全漏洞。 8,25

Rust 的设计深深地吸取了关于安全系统编程的学术研究的精髓。特别是,与其他主流语言相比,Rust 设计的最大特色在于采用了所有权类型系统(在学术文献中通常称为仿射子结构类型系统36)。所有权类型系统通过限制对象的应用, 可用于在程序执行的任何给定点进行变异来帮助程序员实施较低级编程的安全模式。 但是,Rust 至少用两种新颖而令人兴奋的方式超越了先前工作的所有权类型系统:

  1. Rust采用了借用 (borrowing)生命周期(lifetime)的机制,这使得表达常见的C ++风格的习惯用法变得容易得多,并确保了它们的安全使用。
  2. Rust还提供了丰富的 API 集(例如,用于并发抽象,高效的数据结构和内存管理),它们通过支持比 Rust 的核心类型系统更灵活的别名和突变组合,从根本上扩展了语言的功能。相应地,这些 API 不能在 Rust 的安全片段内实现:相反,它们在内部使用了该语言潜在的 unsafe C 风格的特性,是以一种声称不会干扰 Rust 的语言级别安全性保证的方式进行安全封装。

Rust 的设计的这些方面不仅对其成功至关重要,它们还提出了有关其语义和安全性保证的基本研究问题,从而使编程语言社区才刚刚开始探索。 在本文中,我们首先为读者提供了Rust 编程语言的鸟瞰图,重点介绍了 Rust 的一些基本功能,这些功能使它与同时代产品脱颖而出。其次,我们描述了 RustBelt初步进展,该项目是由欧洲研究委员会(ERC)资助的正在进行中的项目,其目的是为 Rust 的安全性主张提供第一个正式的(并经过机器检查的)基础。通过这个项目,我们希望激发计算机科学研究领域的其他成员开始更加关注 Rust ,并为这种突破性语言的发展做出贡献。

动机: C++ 中的无效指针

为了演示在系统编程语言中常见的那种内存安全问题,让我们考虑一下

Figure 1. Use-after-free bug in C++ and how the bug is prevented in Rust.

在第一行中,该程序创建一个整数的 std::vector(可增长数组)。 v 的初始内容(两个元素1011)存储在内存中的缓冲区中。在第二行中,我们创建一个指向该缓冲区的指针 vptr。具体来说,它指向第二个元素(当前值为 11 )的存储位置。现在,vvptr 都指向同一个缓冲区(重叠的部分)。我们说这两个指针是 混叠 的。在第三行中,我们将新元素 pushv 的末尾。在缓冲区v中,将元素12 添加到11之后。如果没有更多空间可容纳其他元素,将分配一个新的缓冲区,并将所有现有元素移到上方。让我们假设这就是这里发生的情况。为什么这种情况很有趣?因为vptr仍然指向旧缓冲区。换句话说,在v中添加一个新元素已经将vptr变成了悬空的指针。这是可能的,因为两个指针都是引用:通过指针(v)进行的操作通常也会影响其所有引用(vptr)。 [图1]可视化了整个情况。

vptr 现在是一个悬空指针,在第四行中存在一个问题。在这里,我们从vptr 解引用,并且由于它是一个悬空指针,所以这是一个 use-after-free 错误。

实际上,这种问题很普遍,以至于它的一个实例拥有自己的名称:迭代器无效,这是指迭代器(通常在内部用指针实现)无效的情况,因为迭代所遍历的数据结构是可变的在迭代过程中。最常见的情况是在一个循环中遍历某个容器数据结构,然后间接但偶然地调用一个使数据结构发生突变的操作。注意,实际上,对改变数据结构的操作的调用(在我们的示例的第3行中为 push_back)可能深深地嵌套在几层抽象的后面。特别是当重构代码或添加新功能时,通常几乎无法确定是否推送到某个 vector是否会使程序中其他位置的指针失效,不存在 use-after-free

**与垃圾回收语言进行比较。**诸如Java ,Go 和 OCaml 之类的语言可避免使用垃圾回收的使用后释放的错误:仅当程序无法使用内存时才释放内存。因此,不能有悬空的指针,也不没有use-after-free。 垃圾回收存在的问题是,为了提高效率,此类语言通常不允许内部 指针(即,指针放入数据结构)。例如,Java中的数组 int [] 的表示方式类似于 C++ 中的 std::vector <int>(Java中的数组不能增长)。但是,与C ++不同,一个Java数组只能获取设置元素,而不能对其进行引用。为了使元素本身可寻址,它们必须是单独的对象,然后可以将对它们的引用存储在数组中--也就是说,需要将元素装箱。为了安全,牺牲了性能和对存储器布局的控制。 最重要的是,垃圾回收甚至不能正确解决迭代器无效的问题。在Java中遍历集合时对其进行改变不会导致指针悬空,但可能会导致在运行时抛出 ConcurrentModificationException 。类似地,尽管 Java 确实防止了由于空指针滥用引起的安全漏洞,但它通过引发 空指针异常 (NullPointerException) 的运行时检查来做到这一点。在这两种情况下,其结果显然都比 C++程序的相应未定义行为更好。这仍然有很多不足之处:我们希望从一开始(编译期)就防止错误发生,而不是交付不正确的代码然后在运行时才检测到问题。

Rust解决指针无效的方法。在 Rust 中,编译器会静态检测到迭代器无效和空指针滥用等问题,它们会导致编译时错误,而不是运行时异常。为了解释它是如何工作的,请在[图1]底部参考我们C ++示例的 Rust转换。 像在 C++ 版本中一样,内存中有一个缓冲区,而 vptr 指向该缓冲区的中间(导致引用); push可能会重新分配缓冲区,这导致 vptr 变成悬空的指针,并导致第4行中的 use-after-free。

但是这一切都没有发生。相反,编译器显示错误:一次不能多次借用 (borrowing) 可变的变量v 我们将很快回到借用 (borrowing),但是关键思想(Rust 通过这种机制在存在指向数据结构的指针的情况下实现内存安全的机制)已经在这里变得显而易见:类型系统强制执行该规则(我们将在以后介绍)不会有多个可变引用。在并发的上下文中,这个原理听起来应该很熟悉,并且 Rust 确实使用这个方法来确保不存在数据竞争。但是,正如我们被 Rust 编译器拒绝的示例所示,引用和可变性的不受限制的组合即使对于顺序程序也是造成灾难的原因:在第3行中,vptrv引用(v被认为是指向它的所有内容,与vptr 重叠),我们正在使用一个可变引用,这将导致第4行出现的内存访问错误。

所有权和借用 (borrowing)

Rust防止不可控制的引用的核心机制是所有权。 Rust 中的内存始终具有唯一的所有者,如示例2中所示。

Figure 2. Rust example: Moving ownership.

在这里,我们构造与第一个示例类似的 v ,然后将其传递给 consume。在操作上,就像在C ++中一样,参数是按值传递的,但是副本是浅复制的—指针被复制,但它们的指针不会重复。这意味着 v w指向内存中的相同缓冲区。

如果程序同时使用 vw 两者就会出现这个问题,但是在第6行尝试这样做的时候会导致编译时错误。这是因为 Rust 认为 v 的所有权作为调用的一部分已经移动到 consume 上,这意味着 consume 可以用 w 来做任何想要的事情,并且调用者可能不再访问这个 Vec 的内存。

资源管理。 Rust的所有权不仅可以防止内存错误,而且还构成了 Rust 的内存管理方法(更广泛地说,是资源管理的核心。当拥有内存的变量(例如,Vector 的内存中的缓冲区的 Vec <T>类型的变量)超出作用域的时候,我们可以确定不再需要该内存了,因此编译器可以在那时自动释放内存。为此,就像在C++中一样,编译器透明地插入destructor 调用。例如,在 consume 函数中,实际上没有必要显式调用析构函数方法(drop)。我们可以将该函数的主体保留为空,并且它将自身自动释放 w

因此,Rust程序员几乎不必担心内存管理:尽管缺少垃圾收集器,但它基本上是自动的。此外,内存管理也是 静态的(在编译时确定)这一事实产生了极大的好处:它不仅有助于降低最大的内存消耗,而且还可以在反应式系统中(例如 web 服务器 )提供良好的 最坏情况latency 。最重要的是,Rust 的方法超出了内存管理的范围:文件描述符,套接字,锁,句柄等其他资源都使用相同的机制处理,因此Rust程序员不必担心关闭文件或释放锁。C++以 RAII(资源获取即初始化)的形式为使用析构函数进行自动资源管理的方式开了先河; 31 Rust 中的主要区别在于类型系统可以静态地确保资源在销毁后不再使用。


**可变的引用。**严格的所有权准则既令人愉悦又简单,但是使用起来并不方便。通常,人们想 暂时 地向某个函数提供数据,在函数允许之后后将其返回。例如,我们希望v.push(12) 赋予 push 来使 v改变的特权,但是我们不希望它consume vector v

在Rust 中,这是通过 借用 (borrowing) 实现的,它从 region types 的先前工作中获得了很多启发。13,34

函数add_ something接受类型为 &mut Vec<i32>的参数,该参数表示对Vec<i32>可变引用 。在操作上,这就像C ++中的引用一样,即 Vec通过引用传递。 在类型系统中,这被解释为向自调用者的Vecadd_something 借用 (borrowing) 所有权。

f3.jpg

Figure 3. Rust example: Mutable references.

函数 add_something 展示了格式化过的借用 (borrowing)看起来是什么样子。 为了搞清楚为什么编译器在拒绝前面的指针无效示例,而这段代码却可以通过,我们必须引入另一个概念:lifetimes。 就像在现实生活中一样,借用 (borrowing)某物时,可以通过事先就可以借用 (borrowing)多久达成共识,来防止误解。 因此,当创建引用时,会为其分配一个生存期,并以完整的引用类型形式记录下来:&'a mut T表示生存期 'a。编译器会确保引用( v, 在我们的示例中)仅在该生命周期内被使用,并且直到生命周期结束,引用对象才被再次使用。

在我们的例子中,生存期(全部由编译器推断)分别持续到 add_somethingVec::push 结束。 在之前借用 (borrowing)的生命周期尚未结束之前,v不会被使用。

相比之下,[图4] 显示了从[图1]推断出的上一个示例的生命周期。vptr 借用 (borrowing)生命周期的'a 从第2行开始,一直持续到第4行。vptr 因为在第4行中被使用了,因此不能变得更短了。这意味着在第3行中,v 使用了借出的 ,这是错误的。

f4.jpg

Figure 4. Use-after-free example with inferred reference lifetime.

总结一下:每当通过值传递某些东西时(如在 consume 中),Rust 会将其解释为所有权转移。 当某些变量引用传递时(如在 add_something 中),Rust 将此解释为在特定生命周期内借用 (borrowing)。


**共享引用 (shared reference)。**遵循我们可以可变引用不可共享的原则,可变引用是 unique pointers:它们不允许引用。 为了完善这种规则,Rust 提供了第二种引用,称为 共享引用 (shared reference) ,写为&Vec<i32>&'a Vec <i32> ,允许多个引用但不能改变。 共享引用 (shared reference)的一种主要用例是在多个线程之间共享只读数据,如图5所示。

f5.jpg

Figure 5. Rust example: Shared references.

在这里,我们创建一个共享引用 (shared reference) vptr ,指向(并借用 (borrowing)) v [1]。此处的竖线表示不带任何参数的 closure(有时也称为匿名函数或 lambda )。这些闭包被传递给 join ,这是 Rust 版本的 并行组合( parallel composition ):它需要两个闭包,并行地运行两个闭包,等待两个闭包完成,然后返回两个结果。当join返回时,借用 (borrowing)结束,因此我们可以再次对v 进行修改。

就像可变引用一样,共享引用 (shared reference)也存在着生命周期。深入代码背后,Rust 的编译器使用生命周期来跟踪两个线程之间临时共享 v 的时间。在生存期结束后(第5行),v的原始所有者重新获得了完全控制权。此处的主要区别在于,允许多个共享引用 (shared reference)在同一生存期内共存,只要它们仅用于 读取 而不是用于 写入 即可。将示例中的两个线程之一更改为||v.push(12),就可以看到这条规则的实现: 编译器会给出一条错误,告诉我们不能同时具有可变引用和对 Vec 的共享引用 (shared reference)。的确,该程序在读取线程和 v.push(12) 的线程之间存在致命的数据争用,因此,编译器能静态的检测到此类情况是很重要的。

共享引用 (shared reference)在顺序执行的代码中也很有用;例如,在对 vecshared iterator 进行遍历时,我们仍然可以传一个 vec 共享引用 (shared reference)到其他函数里。但是在本文中,我们将重点讨论为实现并发而使用共享引用 (shared reference)。


**总结。**为了获得安全性,Rust 类型系统强制执行以下原则:共享不可变,可变不共享。拥有 T 类型的值意味着变量完全 拥有 它。可以用可变引用(&mut T)或共享引用 (shared reference)(&T) 去对 T 进行引用。

safe API 放宽 Rust 的严格所有权规则

Rust的核心所有权原则具有足够的灵活性,可以解决许多底层编程的习惯用法。但是对于实现某些数据结构而言,它可能过于严格。例如,引用态无法被更改,就不可能实现双向链接列表,因为每个节点都被其下一个和上一个节点引用 。

Rust 对这个问题采取了一种不寻常的方法。比起允许其类型系统复杂化以解决不遵守该数据类型的数据结构实现,或者引入动态检查来在运行时强制执行安全检查,Rust 允许通过开发 safe 的API 来放宽其所有权准则-API通过安全地控制引用的可变状态的使用来扩展语言的表达能力。尽管这些 API 的实现不遵循 Rust 严格的所有权原则(我们将在后面再讲),但 API 本身却严格利用了 Rust 的所有权和借用 (borrowing)机制以确保它们整体上保持 Rust 一致的的安全性。现在让我们看几个例子。


共享可变状态 Rust 的共享引用 (shared reference)允许多个线程同时读取共享数据。 但是仅 读取 数据的线程只是事情的一半,接下来我们将看到 Mutex API 如何是实现跨线程安全地共享可变 mutable 状态。 乍一看,这似乎与我们到目前为止对 Rust 的安全性所说的一切相矛盾:Rust 的所有权准则要点不是要 防止 共享状态的改变吗? 的确,但是我们将看到如何使用Mutex 充分限制这种改变,以免破坏内存或线程安全。 现在来看图6。

f6.jpg

Figure 6. Rust example: Shared mutable state.

我们再次使用结构化并发和共享引用 (shared reference),但是现在将 vec 包装在 Mutex 中:变量 mutex_v 的类型为 Mutex<Vec<i32>> Mutex 的的关键操作是 lock。它将一直阻塞直到获得独占锁为止。当变量超出范围时,锁将由 v 的析构函数隐式释放。最终,如果第一个线程设法先获取锁,则该程序将打印 [10,11,12],如果第二个线程则获取到了, 就会打印 [10,11]

为了弄清楚示例程序的类型检查方式,让我们仔细研究一下lock。它(差不多(实际类型是 被LockResult<…> 包装起来的类型,这也是为什么使用了 unwrap )的类型为 fn(&'a Mutex <T>)-> MutexGuard <'a,T> 可以使用对互斥锁的共享引用 (shared reference)来调用,这就是 Rust 允许我们在两个线程上调用锁定的原因:两个闭包都捕获&Mutex<Vec<i32>>,并与&i32类型的 vptr 一样,在我们的第一个并发示例中,两个线程可以同时使用该引用。实际上,lock 获取一个共享而不是可变的引用是至关重要的;否则,两个线程将无法尝试同时获取该锁,并且一开始就不需要锁。

lock 的返回类型,即 MutexGuard <'a,T>,基本上与&'a mut T' 相同:它给予对存储在锁中的T 的独占访问权限。此外,当超出范围时,它会自动释放锁(在 C++世界中被称为RAII 33)。

在我们的示例中,这意味着两个线程 临时 都具有对该 vec 的独占访问权,并且它们都有可变引用,这说明了一个事实-由于锁正确地实现了互斥,因此它们永远不会同时具有可变引用,因此保持了可变引用的唯一性。换句话说,Mutex 可以安全地提供引用状态的改变,因为它实现了运行时检查,确保不会在改变时产生可变引用。


可变计数 我们已经看到,共享引用 (shared reference)提供了一种在程序中不同部分方之间共享数据的方法。但是,共享引用 (shared reference)具有 静态确定 的生存期,并且当该生存期结束时,数据将再次被唯一拥有。这与结构化并行机制(如上一示例中的 join )配合得很好,但不适用于 非结构化 的并行机制,在这种情况下,会产生独立运行于父进程的进程。

在 Rust 中,在这种情况下共享数据的典型方法是使用 原子引用计数 指针: Arc<T> 是指向 T 的指针,它会计算存在多少指向T 的指针,并当最后一个指针销毁时 (引用计数归零) 释放 T(并释放其相关资源)。(这可以看作是轻量级实现垃圾回收的一种形式)由于数据是共享的,因此我们无法从Arc<T>中获取&mut T,但是我们可以获得&T。 (在这种情况下,编译器确保在引用生存期内不会破坏 Arc<T>

f7.jpg

Figure 7. Rust example: Reference counting.

我们首先创建一个指向我们通常 vec Arcarc_v2是通过clone arc_v1获得的,这意味着引用计数增加了一个,但是数据本身不会增加。 然后我们起一个使用 arc_v2 的线程; 即使我们在此编写的函数返回后,这个线程仍在后台运行。 因为这是非结构化的并行,所以我们必须显式地将 arc_v2 移动(即转移其所有权)到另一个线程中运行的闭包中。 Arc 是一个 智能指针(类似于C ++中的 shared_ptr),因此我们可以像使用&Vec<i32> 一样使用它。尤其是在第3行和第4行中,我们可以打印出索引为1 的元素的值。当 arc_v1arc_v2 超出作用域时,它们的析构函数将被隐式调用,最后 Arc 会销毁 vec


线程安全

在这个关于 Rust 的简短介绍中 ,Rc <T> 是我们最后一个要谈论的类型。 Rc <T> 是一个引用计数类型,它与 Arc <T> 非常相似,但是关键区别在于Arc < T> 使用原子 (获取和添加指令)来更新引用计数,而 Rc <T> 使用非原子操作。结果,Rc <T>可能更快,但不是线程安全的。 Rc<T> 类型在复杂的顺序执行的代码中很有用,在这种情况下,共享引用 (shared reference)的强制执行的静态作用域不够灵活,或者无法静态预测对对象的最后一个引用被销毁的时间,对象无法在应该被回收时被销毁。

由于Rc<T>不是线程安全的,因此我们需要确保程序员在应该使用 Arc<T> 时不要混用使用Rc<T>。这很重要:如果我们采用我们前面的 Arc 示例,并用 Rc 替换了所有Arc,这样程序就会产生数据竞争,可能会过早分配内存或根本不分配内存。但是,非常值得注意的是,Rust 编译器依然能够捕获这种错误。 Rust 使用了一种叫做Send 的 trait :这是一种类型的属性,只有当类型 T 的元素可以安全地发送到另一个线程时,类型 T 才能使用它。类型Arc <T>Send 的,但是Rc<T> 不是。 joinspawn都要求它们运行的闭包捕获的所有内容都具有 Send ,因此,如果我们在闭包中捕获非Send 类型的 Rc <T> 的变量,将导致编译失败。

Rust对 Send 的使用证明了强静态类型的限制有时会有更强大的表现力。与 C++ 的智能引用计数指针 std::shared_ptr 更是如此,因为 std::shared_ptr 使用了原子指令。(更准确地说,在Linux上,如果程序使用 pthreads,它使用的任何代码可能产生线程,就会使用原子指令。 ),因为具有更有效的非线程安全变体(如Rc )被认为过于冒险。相比之下,Rust的send 允许人们 无畏编码26),Rust 是一种将线程安全的数据结构(例如 Arc)和非线程安全的数据结构(例如Rc)同时包含在中的语言,同时以模块化的方式确保不会错误地使用两者。

安全封装 unsafe

我们已经看到了诸如 ArcMutex 这样的类型如何使 Rust 程序 安全地使用诸如引用计数和共享引用 (shared reference)之类。但是,这有一个问题:这些类型实际上不能在 Rust 中实现。或更确切地说,它们不能在 safe Rust 中实现:编译器会因为可能违反引用规则而拒绝执行 Arc 。实际上,它甚至会拒绝使用 Vec 来访问可能未初始化的内存。出于效率原因,Vec 手动管理底层缓冲区并跟踪其初始化部分。当然, Arc 的实现实际上并没有违反引用规则,而 Vec 实际上并没有访问未初始化的内存,但是这些推断对于编译器来说太过于细微了。

为了解决这个问题,Rust 提供了一个 后门 :Rust 不仅包含我们到目前为止讨论的安 Safe Rust,而且还提供了一些 unsafe 功能,例如 C 风格的非受限指针。编译器不能保证这些功能的安全性(内存安全性和/或线程安全性),因此它们仅在标记有 unsafe 关键字的语法块内可用。这样,可以确保不会 偶然 离开 Safe Rust

我们希望激发计算机科学研究领域的其他成员开始更加关注Rust,并为这种突破性语言的发展做出贡献。


例如,Arc 的实现使用 unsafe 来实现在 Safe Rust 中无法表达的模式:没有明确所有者的共享引用 (shared reference),由线程安全引用计数进行管理。 对弱(weak)引用的支持使情况更加复杂:弱引用 不能使引用对象保持存活,但可以通过原子方式检查其有效性,并升级为完整的Arc 。 Rust编译器根本无法静态验证当引用计数达到零时释放内存实际上是安全的。


unsafe 块的替代品 可以将 ArcVec 之类的东西转换到语言层面。例如,Python 和 Swift 具有内置的引用计数,Python 具有与 Vec 等效的内置的 list。但是,这些语言功能是在 C 或 C++中实现的,因此它们实际上没有比 unsafe Rust 实现更安全。除此之外,将不安全的操作限制为语言内置的实现还严重限制了灵活性。例如,Firefox 使用 Rust 库实现了 Arc 的变体,但不支持弱引用,从而提高了代码的空间使用率和性能。语言是否应该为任何内置类型的设计空间中的每个可能的位置提供原语?

避免 unsafe 另一种选择是使类型系统具有足够的表达力,以实际能够验证诸如 Arc 之类的类型的安全性。但是,由于此类数据结构的安全性的可能性很小(实际上是 Arc 和简化的变体,其中一些已被用作最近几份正式验证论文中的主要案例研究9,[12],(#12),18,但这基本上只能被有形式的通用定理证明经历和具有足够背景知识的研究人员使用。开发者和种定理证明社区还有很大的距离。


安全抽象 相反,Rust选择允许程序员在必要时灵活地编写不安全的代码,尽管期望它应该被 safe 的 API 封装。安全封装意味着,无论使用诸如 ArcVec 之类的 Rust API 都是通过 unsafe 代码实现的,这些 API 的用户都不会受到影响:只要用户在 safe 片段中编写类型正确的代码,由于 Rust 在 API 的实现中使用了不安全的代码,他们永远都观察不到异常行为。这与 C++ 形成鲜明对比,C++的弱类型系统缺乏甚至无法强制 安全使用 API 的能力。结果,像shared_ptrvector 之类的 C++ API 容易被滥用,导致引用计数错误和迭代器无效,这些错误在Rust中都不会出现。

编写不安全代码的能力就像 Rust 程序员用来使类型系统更有用而又不会将其变成定理证明器的杠杆一样,我们确信这是 Rust 成功的关键。 Rust 社区正在开发一个有着安全可用的高性能库的生态系统,让程序员可以在它们之上构建安全而高效的应用程序。

但是,当然天下没有免费的午餐:Rust 库的作者必须以某种方式确保,他们会非常谨慎,在使用 unsafe 的时候不会破坏 Rust 的安全保证。一方面,这比C/C++ 要好得多,因为绝大多数 Rust代码 是用该语言的 safe 编写的,因此 Rust 的 攻击面 要小得多。另一方面,当 unsafe 变得不可或缺的时候,程序员知道自己是否足够 谨慎 远非显而易见。

因此,为了保持对 Rust 生态系统安全性的信心,我们真心希望有一种形式化的方法,可以正式的验证使用安全地封装在安全API 的 unsafe 的背后的行为。 这正是 RustBelt 项目的目标。

RustBelt: Rust 基础设施的保卫者

验证 Rust 的安全性的主要挑战是考虑 safeunsafe 之间的相互作用。 要了解为什么这具有挑战性,让我们简要地看一下验证编程语言安全性的标准技术,即所谓的syntactic approach1437 使用该技术,安全性是由基于大量数学推导给出正式结构的类型检查器的 syntactic typing judgment 来表示的。


Theorem 1*( Syntactic type soundness ) 如果程序 e 在 syntactic typing judgment 后是 well-typed 的,则 e 是安全的。

well-typed 参见 Type safety

不幸的是,这个定理对于我们的目的来说太弱了,因为它仅在语法上谈论安全程序,从而排除了使用 不安全代码的程序。 例如,if true {e} else {crash()} 在语法上不是 well-typed,但由于从未执行crash(),所以它仍然是安全的。


解决方案: Semantic type soundness.

Semantic type soundness

为了说明 safeunsafe 之间的相互作用,我们改用称为 Semantic type soundness 的技术,该技术根据程序的行为来表示安全性,而不是使用固定的推理规则集。 Semantic type soundness 的关键要素是 logical relation(逻辑关系),该逻辑关系为每个 API 分配了 safety contract 。 它表示如果API 中每个方法的输入均符合其指定的类型,则输出也是如此。使用形式验证中的技术,可以证明 API 的实现满足指定的 safety contract ,如[图8]。

f8.jpg

Figure 8. Semantic type soundness.

对于 safeunsafe 组合的程序进行推理, Semantic type soundness 是理想的选择。 对于任何使用 unsafe 代码块的库(例如 Arc Mutex Rc 和 Vec),都必须手动证明该实现满足 safety contract 。 例如:

Theorem 2. Arc satisfies its safety contract.

对于程序的 safe 代码块,验证是自动的。 以下定理表达了这一点,该定理说,如果将组件写入 Rust 的 safe 代码块中,则它通过构造满足其 safety contract

Theorem 3 (Fundamental theorem). 如果 组件 e 在语法上是 well-typed的 ,则 e 满足 safety contract

综上所述,如果“unsafe 的只出现是在经过手动验证可以满足 safety contract 的库中,则这表示 Rust 程序是安全的。

**使用 Iris logic 来解码 safety contract ** Semantic type soundness 是一种古老的技术,至少可以追溯到米尔纳(1978年)关于类型健全性的开创性论文,28,但将其扩展到像 Rust 这样的工业现代语言被证明是一个艰巨的挑战。实际上,在开发“"step-indexed Kripke logical relations”(SKLR)模型3,5 之前,将其扩展到具有可变状态和高阶函数的语言仍然是一个悬而未决的问题。2,4,作为基础证明代码项目的一部分。即便如此,使用 SKLR 模型直接编码的safety contract 的验证仍然是非常繁琐,低级且难以维护的。

RustBelt 项目中,我们以 Iris 的最新工作为基础19,20,21,23,(一个证明框架,用于更高阶,并发,命令式程序,使用 Coq proof assistant实现)。1 Iris提供了一种更高级的语言来编码和使用 SKLR 模型,从而使我们能够扩展此类模型,以处理Rust等复杂的语言。特别是基于separation logic29, 30, Hoare逻辑的扩展15 专门针对指针操作程序的模块化推理,并以所有权概念为中心。这为我们提供了一种理想的语言,可用于在Rust中建模所有权类型的语义。

Iris扩展了传统的 separation logic,并增加了一些对 Rust 至关重要的附加功能:

  • Iris 支持用户定义的 ghost state:定义自定义逻辑资源的能力,这些逻辑资源对于证明程序的正确性十分有用,但并不直接对应于其物理状态下(译者注: 指内存)的任何内容。 Iris 用户定义的 ghost state 使我们能够验证诸如 Arc 之类的库的健全性,这些库的所有权并不对应于物理所有权(例如,两个单独拥有的 Arc<T> 可能在同一款内存下)-- 一种称为 fictional separation 的现象。1011

    通过(在Iris内)派生了一个新的,特定于领域的lifetime logic,它还使我们能够以更高的抽象水平来思考 Rust 的借用 (borrowing)和生命周期。

  • Iris支持 impredicative invariants :程序状态上的不变式,可能会循环引用其他不变式的存在。32 impredicative invariants 构建核心类型系统(例如递归类型和闭包)中起着至关重要的作用。

Rust 的复杂性要求我们对语义的健全性证明进行machine-checked, 因为这太繁琐且容易出错,无法手动进行证明。 幸运的是,Iris带有丰富的 separation-logic tactics ,这些策略是根据标准 Coq 策略制定的,因此可以让 Coq用户 经过 time-tested 的方式熟悉的开发 machine-checked 的语义完整性证明。 22,24

结论和展望

在本文中,我们给出了 Rust 的鸟瞰图,展示了 Rust 的核心概念,例如借用 (borrowing),生存期以及封装在 safe API 中的 unsafe 代码。这些功能帮助 Rust 第一个受行业支持的在高级语言带来的安全性和由较低级别的系统编程语言提供的资源管控之间做出长远平衡的语言。

为了研究 Rust 的安全声明,我们描述了语义类型健全性的证明技术,这使我们能够开始在 RustBelt 项目中为 Rust 构建严格的基础。有关Rust 和 RustBelt 的更多详细信息,请向感兴趣的读者可以去阅读我们的POPL'18论文18 和第一作者的博士学位论文。17

我们还有很多工作要做。尽管 RustBelt 最近为Rust 考虑 从C++继承来的宽松内存并发模型( relaxed-memory concurrency model )9,当还没有涵盖许多其他 Rust 功能和 API,例如其 trait 系统,该系统非常复杂以至于会出现很多微妙的 bug 。 7,尽管验证unsafe rust 库的健全性目前需要形式语义的深厚背景,但我们希望最终开发出可以直接交给程序员的形式化方法。

最后,尽管 RustBelt 专注于为 Rust 本身打好安全基础,但我们很高兴看到其他研究项目(特别是 Prusti 6RustHorn 27)正开始探索一个令人激动的正交方向:即Rust 的强类型系统有可能用作简化系统代码形式验证的有力工具。

References

1.The Coq proof assistant, 2019; https://coq.inria.fr/.

2. Ahmed, A., Appel, A.W., Richards, C.D., Swadi, K.N., Tan, G. and Wang, D.C. Semantic foundations for typed assembly languages. TOPLAS 32, 3 (2010).

3. Ahmed, A.J. Semantics of types for mutable state. Ph.D. thesis, Princeton University, 2004.

4. Appel, A.W. Foundational proof-carrying code. LICS, 2001.

5. Appel, A.W. and McAllester, D. An indexed model of recursive types for foundational proof-carrying code. TOPLAS 23, 5 (2001).

6. Astrauskas, V., Müller, P., Poli, F. and Summers, A.J. Leveraging Rust types for modular specification and verification. PACMPL 3 (OOPSLA), 2019.

7. Ben-Yehuda, A. Coherence can be bypassed by an indirect impl for a trait object, 2019; https://github.com/rust-lang/rustissues/57893.

8. Burch, A. Using Rust in Windows. Blog post, 2019; https://msrc-blog.microsoft.com/2019/11/07/using-rust-in-windows/.

9. Dang, H.-H., Jourdan, J.-H., Kaiser, J.-O. and Dreyer, D. RustBelt meets relaxed memory. PACMPL 4 (POPL), 2020.

10. Dinsdale-Young, T., Dodds, M., Gardner, P., Parkinson, M.J. and Vafeiadis, V. Concurrent abstract predicates. ECOOP, 2010.

11. Dinsdale-Young, T., Gardner, P. and Wheelhouse, M.J. Abstraction and refinement for local reasoning. VSTTE, 2010.

12. Doko, M. and Vafeiadis, V. Tackling real-life relaxed concurrency with FSL++. ESOP 10201, LNCS, 2017.

13. Grossman, D., Morrisett, G., Jim, T., Hicks, M., Wang, Y. and Cheney, J. Region-based memory management in Cyclone. PLDI, 2002.

14. Harper, R. Practical Foundations for Programming Languages (2nd Ed.). Cambridge University Press, 2016.

15. Hoare, C.A.R. An axiomatic basis for computer programming. Commun. ACM 12, 10 (1969).

16. Hosfelt, D. Implications of rewriting a browser component in Rust. Blog post, 2019; https://hacks.mozilla.org/2019/02/rewriting-a-browser-component-in-rust/.

17. Jung, R. Understanding and Evolving the Rust Programming Language. Ph.D. thesis, Universität des Saarlandes, 2020; https://people.mpi-sws,org/~jung/thesis.html.

18. Jung, R., Jourdan, J.-H., Krebbers, R. and Dreyer, D. RustBelt: Securing the foundations of the Rust programming language. PACMPL 2 (POPL), 2018.

19. Jung, R., Krebbers, R., Birkedal, L. and Dreyer, D. Higher-order ghost state. ICFP, 2016.

20. Jung, R., Krebbers, R., Jourdan, J.-H., Bizjak, A., Birkedal, L. and Dreyer, D. Iris from the ground up: A modular foundation for higher- order concurrent separation logic. JFP 28 (2018).

21. Jung, R., Swasey, D., Sieczkowski, F., Svendsen, K., Turon, A., Birkedal, L. and Dreyer, D. Iris: Monoids and invariants as an orthogonal basis for concurrent reasoning. POPL, 2015.

22. Krebbers, R., Jourdan, J.-H., Jung, R., Tassarotti, J., Kaiser, J.-O, Timany, A., Charguéraud, A. and Dreyer, D. MoSeL: A general, extensible modal framework for interactive proofs in separation logic. PACMPL 2 (ICFP), 2018.

23. Krebbers, R., Jung, R., Bizjak, A., Jourdan, J., Dreyer, D. and Birkedal, L. The essence of higher-order concurrent separation logic. ESOP, 2017.

24. Krebbers, R., Timany, A. and Birkedal, L. Interactive proofs in higher-order concurrent separation logic. POPL, 2017.

25. Levick, R. Why Rust for safe systems programming. Blog post, 2019; https://msrc-blog.microsoft.com/2019/07/22/why-rust-for-safe-systems-programming/.

26. Matsakis, N. and Turon, A. Rust in 2016, 2015. Blog post; https://blog.rust-lang.org/2015/08/14/Next-year.html.

27. Matsushita, Y., Tsukada, T. and Kobayashi, N. RustHorn: CHC-based verification for Rust programs. ESOP, 2020.

28. Milner, R. A theory of type polymorphism in programming. J. Computer and System Sciences 17, 3 (1978).

29. O'Hearn, P.W., Reynolds, J.C. and Yang, H. Local reasoning about programs that alter data structures. CSL, 2001.

30. O'Hearn, P.W. Resources, concurrency, and local reasoning. Theoretical Computer Science 375, 1–3 (2007).

31. Stroustrup, B. The C++ Programming Language. Addison-Wesley, 2013.

32. Svendsen, K. and Birkedal, L. Impredicative concurrent abstract predicates. ESOP, 2014.

33. Thomas, G. A proactive approach to more secure code. Blog post, 2019; https://msrc-blog.microsoft.com/2019/07/16/a-proactive-approach-to-more-secure-code/.

34. Tofte, M. and Talpin, J. Region-based memory management. Information and Computation 132, 2 (1997).

35. Tu, T., Liu, X., Song, L. and Zhang, Y. Understanding real-world concurrency bugs in Go. ASPLOS, 2019.

36. Walker, D. Substructural type systems. Advanced Topics in Types and Programming Languages. B.C. Pierce, Ed. MIT Press, Cambridge, MA, 2005.

37. Wright, A.K. and Felleisen, M. A syntactic approach to type soundness. Information and Computation 115, 1 (1994).

论文鉴赏 | SafeDrop:通过静态数据流分析检测Rust程序的内存释放错误

编辑: 张汉东

论文原文:https://arxiv.org/abs/2103.15420

编者按:

该论文是由复旦大学四位同学共同编写,其中两位共同第一作者 崔莫寒 和 陈澄钧 也参与了 4 月 9 号 RustFriday 飞书群线上沙龙 第三期的活动。回放地址:https://www.bilibili.com/video/BV1nU4y1h7NN/

Rust 中文社群 飞书群 邀请你加入:https://applink.feishu.cn/TeLAcbDR

论文中提到的 SafeDrop 工具还未开源,等工具得到进一步完善就会开源。

本文也不是全文翻译,只是摘录关键部分。欢迎观看沙龙视频回放。


介绍

Rust是一种新兴的编程语言,旨在防止内存安全错误。但是,Rust的当前设计还会带来副作用,这可能会增加内存安全问题的风险。特别是,它采用OBRM(基于所有权的资源管理),并在没有垃圾收集器的情况下自动执行未使用资源的自动重新分配。因此,它可能错误地处理已回收的内存并导致使用后释放或两次释放的问题。在本文中,我们研究了无效的内存分配问题,并提出了SafeDrop,一种静态的路径敏感型数据流分析方法来检测此类错误。

我们的方法通过遍历控制流图并提取每个数据流的所有别名,来迭代地分析 Rust Crate的每个API。为了保证精度和可伸缩性,我们利用改进的Tarjan算法来实现可伸缩的路径敏感分析,以及基于缓存的策略,以实现高效的过程间分析。我们的实验结果表明,我们的方法可以成功地检测出所有此类问题的现有CVE,而误报的数量有限。与原始编译时间相比,分析开销从1.0%到110.7%不等。我们进一步将工具应用于多个真实的Rust Crate,并发现8个涉及无效内存释放问题的Rust Crate。

OBRM(基于所有权的资源管理)模型假定在创建所有者时应分配资源,并在其所有者超出有效范围后进行释放。理想情况下,即使程序遇到异常,该模型也应能够防止指针悬空和内存泄漏。

但是,我们观察到,现实中Rust Crate的许多关键错误都与这种自动释放方案相关,例如,它可能会错误地丢弃一些仍在使用的缓冲区并招致 use-after-free 的错误(例如CVE-2019-16140),或者可能会错误地丢弃dangling pointers并导致 double free(例如CVE-2019-16144)。

通常,内存释放错误是由 Unsafe Rust 代码触发的。在Rust中, Unsafe Rust 的API是必需的,以提供对实现细节的底层控制和抽象。但是,滥用 Unsafe Rust 的API可能会使基于所有权的资源管理系统的健全性失效,并可能导致不确定的行为。例如, Unsafe Rust 的API可能导致共享别名的内存回收,而删除一个实例将导致其余别名的指针悬空。此外,Rust中的内部 Unsafe Rust 允许仅在内部具有不安全代码的函数可以被称为安全函数,内部可能存在内存安全问题。当前的Rust编译器对 Unsafe Rust 代码的内存安全风险一无所知,只是假设开发人员应负责使用它们。内存安全性是Rust提倡的最重要的功能,如果可能的话,降低这种风险非常重要。

针对此问题,本文提出了 SafeDrop,这是一种静态路径敏感的数据流分析方法,用于自动检测内存安全性错误释放机制。 Safe-Drop反复分析Rust Crate的每个API,并仔细检查Rust MIR(中级中间表示)中的每个drop语句是否可以安全启动。由于别名分析的潜在问题是可以决定的,因此它很难解决,因此我们采用了几种设计来提高我们方法的可扩展性,同时又不牺牲太多精度。我们的方法采用路径相遇(MOP)方法,并基于改进的Tarjan算法提取每个函数的所有有价值的路径,这对于消除具有相同别名关系的循环中的冗余路径是有效的。对于每个路径,我们以对流量敏感的方式提取所有别名的集合,并相应地分析每个dropstatement的安全性。当遇到函数调用时,我们在被调用者上递归执行SafeDrop,并分析参数与返回值之间的别名关系。为了避免重复分析,我们缓存并重用每个函数获得的别名结果。

MIR 介绍

因为 SafeDrop 是基于 MIR 来分析,所以先了解下 MRI 相关。

Rust 的编译过程中会产生 MIR (中级中间语言),MIR 语法格式:


#![allow(unused)]
fn main() {
BasicBlock := {Statement} Terminator
Statement := LValue = RValue | StorageLive(Value)
    | StorageDead(Value) | ...
LValue := LValue | LValue.f | *LValue | ...
RValue := LValue | move LValue
    | & LValue | & mut LValue
    | * LValue | * mut LValue
    | ...
Terminator := Goto(BB) | Panic(BB)
    | Return | Resume | Abort
    | If(Value, BB0, BB1)
    | LVALUE = (FnCall, BB0, BB1)
    | Drop(Value, BB0, BB1)
    | SwitchInt(Value, BB0, BB1, BB2, ...)
    | ...
}

MIR是基于控制流图(Control Flow Graph,CFG)的有向无环图,主要包含了上面所说的几个语法元素:

  • BasicBlock,由基本语句和终止符组成。
  • Statement,基本语句,就是每一行 Rust 代码转译为 MIR 的对应代码
  • LValue && RValue, 分别对应位置表达式和值表达式。
  • Terminator,终止符,BasicBlock 的出口,包含各种形式的跳转,比如 Return/Resume/Abort/IF/Drop等等

SafeDrop 基于 MIR 分析

1

如图所示。左侧为代码,右侧为该代码生成的 MIR。

SafeDrop 会依赖 MIR 进行静态路径分析。代码左边从 字符串 S 得到了指向堆的指针 ptr,然后通过Vec::from_raw_parts又创建了一个新的对该堆内存有所有权的变量,相当于现在同一片堆内存,有了两个指针。然后注释了 mem::forget(s),代码就会发生 double free 的问题。但是解开mem::forget(s)的注释,又会发生use-after-free的问题。

这段代码比较简单,其实用 Miri 也可以检测出来这两种 UB。但是 SafeDrop 检测机制和 Miri 还是有所区别的。

SafeDrop 检测 MIR 中的 调用路径,会发现 drop(_0)drop(_1)调用了两次,就会发现问题所在。同样,如果把 mem::forget(s)注释打开,也会检测到 drop 后又使用的情况,让 UB 无所遁形。

形式化定义问题

经过一些实验,SafeDrop 团队将 Unsafe Rust 中的遇到的一些常见问题做了形式化定义:

  • 使用中释放缓存区(Dropping buffers in use)

    如果该算法错误地释放了一些缓冲区,这些缓冲区将在以后访问,则它将导致容易受到内存安全问题(包括释放后使用和双重释放)的指针的困扰。

  • 释放无效指针(Dropping invalid pointers)。 如果无效指针悬空,则丢弃指针将导致double free;否则,指针将丢失。 如果无效指针指向包含指针类型的未初始化内存,则删除该指针可能会递归删除其嵌套指针,并导致无效的内存访问。

SafeDrop 中检测无效内存位置的典型模式

  • UAF: use after free;
  • DF: double free;
  • IMA: invalid memory access

SafeDrop 检测无效内存位置遵循下面 7 种模式:

  • Pattern 1: GetPtr() -> UnsafeConstruct() -> Drop() -> Use() => UAF
  • Pattern 2: GetPtr() -> UnsafeConstruct() -> Drop() -> Drop() => DF
  • Pattern 3: GetPtr() -> Drop() -> UnsafeConstruct() -> Use() => UAF
  • Pattern 4: GetPtr() -> Drop() -> UnsafeConstruct() -> Drop() => DF
  • Pattern 5: GetPtr() -> Drop() -> Use() => UAF
  • Pattern 6: Uninitialized() -> Use() => IMA
  • Pattern 7: Uninitialized() -> Drop() => IMA

检测方法

整体架构

3

上图为 SafeDrop 的工作流程图解。将「通过对路径敏感的数据流分析方法」集成到名为SafeDrop的编译器中进行分析。它输入每个功能的MIR并输出潜在的无效内存警告。

SafeDrop的关键步骤:

  • 路径提取:SafeDrop采用“ meet-over-path ”方法来实现路径敏感性。由于函数的路径可能是无限的,因此我们采用基于Tarjan 算法的新颖方法来合并冗余路径并生成生成树。 此步骤将遍历此树并最终枚举所有有价值的路径。
  • 别名分析:SafeDrop 对字段敏感。 此步骤分析每个数据流的变量和复合类型的字段之间的别名关系。 SafeDrop 也是跨程序的,对上下文不敏感。 它缓存并重用在返回值和参数之间获得的被调用者的别名关系。
  • 无效的 drop 检测:基于之前建立的别名集,此步骤为每个数据流搜索有潜在风险的 drop 模式并记录可疑代码段。

上面三个步骤的具体算法在论文中有详细描述,对算法细节感兴趣可以自行阅读论文。

该团队已经把 SafeDrop 和 Rust编译器v1.52 进行了集成(非官方动作),并且可以由诸如 rustc 和 cargo之类的命令行工具使用。

Miri vs SafeDrop

Rust Miri 是Rust MIR的实验解释器,它可以运行二进制文件,Cargo 项目的测试套件并检测一些未定义的行为。

Miri的功能比 SafeDrop 更广泛。Miri支持检查越界内存访问,对齐内存访问不足以及违反某些基本类型不变式的情况。

但是,Miri是一种动态分析方法,无法跟踪无法展开分析和库分析的程序的所有有价值的路径。 相反,SafeDrop是一种静态方法,可以在编译时分析每个函数的所有有价值的路径。

未来挑战

  • 增强性能。
  • 降低检测误差。
  • 对 Rust 标准库进行检测。
  • 开源
  • 其他

学界动态

编辑:张汉东


2021全国大学生计算机系统能力大赛操作系统设计大赛 启动

全国大学生计算机系统能力大赛操作系统设计大赛(以下简称“大赛”)是由教育部高等学校计算机类专业教学指导委员会和系统能力培养研究专家组共同发起,以学科竞赛推动专业建设和计算机领域创新人才培养体系改革、培育我国高端芯片及核心系统的技术突破与产业化后备人才为目标,面向高校大学生举办的全国性大赛。

大赛旨在选拔未来我国计算机系统相关领域的设计、分析、优化与应用人才,激发学生的想象力、创新力和工程实践能力并培养其团队协作精神,以赛促学、以赛促教,为高质量专业人才搭建交流、展示、合作的开源社区平台,让参与大赛的学生、指导教师、学校和企业都能从中获得补益,助力我国高校与企业在操作系统领域的产学研合作健康快速发展。

本次比赛除了学生之外,还针对非学生兴趣人员设立了兴趣小组,感兴趣的朋友可以联系,在本文下评论就可以。

本次比赛内核赛包含了清华大学 Rust 实现的 rCore 为指南。

PLCT Lab: 从零开始的RISC-V模拟器开发 公开课

本课程是由中科院研究所推出的在线公开课,虽然和 Rust 没有直接关系,但也算是 Rust 应用领域的一个前沿方向,比如有公司使用 Rust 和 RISC-V 实现 语言虚拟机。

华为 | StratoVirt 地址空间管理-基于Rust的实现与优化

作者:杨晓鹤 / 后期编辑: 张汉东

上一篇: 华为 | 基于Rust的下一代虚拟化平台-StratoVirt


StratoVirt是开源在openEuler社区的轻量级虚拟化平台,具备轻量低噪、强安全性的行业竞争力。StratoVirt进程运行在用户态,在虚拟机启动之前, StratoVirt会完成启动之前的准备工作,包括虚拟机内存初始化、CPU寄存器初始化、设备初始化等。其中,内存初始化工作和虚拟机的地址空间管理, 都是由StratoVirt的地址空间管理模块AddressSpace完成。如下是StratoVirt地址空间管理模块的组成,以及其在StratoVirt中的位置。

stratovirt
├── address_space
│   ├── Cargo.toml
│   └── src
│       ├── address.rs
│       ├── address_space.rs
│       ├── host_mmap.rs
│       ├── lib.rs
│       ├── listener.rs
│       └── region.rs
├── boot_loader
├── Cargo.lock
├── Cargo.toml
├── cpu
├── devices
├── machine_manager
├── micro_vm
├── src
│   └── main.rs
├── sysbus
├── util
└── virtio

StratoVirt 地址空间模块整体设计

image-20210425104007596

上图中的主要结构含义如下:

  • AddressSpace

    地址空间:为地址空间模块的管理结构, 负责整个虚拟机的地址空间管理

  • Region

    image-20210425152818160

    代表一段地址区间,根据这段地址区间的使用者,可以分为以下类型:

    1. RAM: 虚拟机内存使用该段地址区间。

    2. IO: 虚拟机设备使用该段地址区间。

    3. Container : 作为容器使用,可以包含多个子Region。如描述PCI总线域的地址管理就可以使用类型为ContainerRegion,它可以包含PCI总线域下的PCI设备使用的地址区间。该类型的Region可以帮助管理并区分存储器域、PCI总线域的地址管理。

地址空间模块的设计,采用树状结构和平坦视图结合的方案。通过树状结构可以快速的了解到各个Region之间的拓扑结构关系。这种分层、分类的设计, 可以管理并区分存储器域与PCI总线域的地址管理,并形成与PCI设备树相呼应的树状管理结构。对于平坦视图FlatView,则是根据这些Region的地址范围和优先级属性形成的线性视图。在通过地址空间管理结构AddressSpace访问设备或者内存时, 使用平坦视图FlatView可以更加方便快捷地找到对应的Region

在树状拓扑结构中,每个Region都会对应一个优先级priority属性,如果低优先级的Region占用的地址区间和高优先级的Region占用的地址区间重叠,则低优先级的Region 的重叠部分,将会被覆盖,即在平坦视图FlatView中不可见。

树状拓扑结果的更新,很大可能会带来平坦视图FlatView的更新。一些设备或者模块需要获取最新的平坦视图,并相应的执行一些操作。例如Vhost设备,需要将平坦视图中的全部内存信息同步到内核Vhost模块,以便通过共享内存方式完成消息通知的流程。另外,我们也需要将已经分配并映射好的虚拟机物理地址和宿主机虚拟地址信息注册到KVM模块,这样可以借助硬件辅助虚拟化加速内存访问的性能。基于以上需求,我们引入上图中的地址空间监听函数链表,该链表在平坦视图FlatView更新后被依次调用,可以方便的完成信息同步。该链表允许其他模块添加一个自定义的监听回调函数。

地址空间优化

作为StratoVirt的基础模块和访问密集型的模块, 地址空间模块不仅要满足接口易用性、功能鲁棒性,而且需要不断优化性能。 如下是迭代过程中的几个优化点。

拓扑结构更新优化

地址空间管理模块提供向树状拓扑结构中添加和删除Region的接口,并设定AddressSpace结构负责管理整个数据结构并生成更新后的FlatView结构。

添加子Region的方式为, 调用Region结构的add_subregion接口,注意父Region必须是Container类型。这样会带来一个问题,如果向树状结构中的某个Region中添加或者删除子Region,并引起树状结构的拓扑发生变化,负责生成并更新平坦视图的FlatViewAddressSpace结构体如何得知已经发生变化呢?

image-20210425140726741

最简单的实现为在Region结构中添加成员并指向自己所属的AddressSpace,如上图所示。熟悉Rust语言的同学应该知道,这种实现方式会引入资源相互引用的问题,导致AddressSpaceRegion两者因相互引用而在生命周期结束时无法释放内存资源的问题。因此,在地址空间模块的树状结构中,所有 Region对自己所属的AddressSpace的指针都使用std::sync::Weak类指针,Weak指针不会增加所指向对象的引用计数,可确保在生命周期结束时对应结构的析构和资源释放。


#![allow(unused)]
fn main() {
pub struct Region {
    region_type: RegionType,
    priority: Arc<AtomicI32>,
    size: Arc<AtomicU64>,
    offset: Arc<Mutex<GuestAddress>>,
    mem_mapping: Option<Arc<HostMemMapping>>,
    ops: Option<RegionOps>,
    io_evtfds: Arc<Mutex<Vec<RegionIoEventFd>>>,
    space: Arc<RwLock<Weak<AddressSpace>>>,
    subregions: Arc<RwLock<Vec<Region>>>,
}
}

锁优化

锁粒度最小化

为增加接口的易用性,地址空间模块的设计必须保证多线程安全性,作为主要接口的AddressSpace结构体如下。可以看到,AddressSpace的关键成员都以Arc<Mutex<..>>的方式保证了多线程共享的安全性。


#![allow(unused)]
fn main() {
pub struct AddressSpace {
    root: Region,
    flat_view: ArcSwap<FlatView>,
    listeners: Arc<Mutex<Vec<Box<dyn Listener>>>>,
    ioeventfds: Arc<Mutex<Vec<RegionIoEventFd>>>,
}
}

地址空间空间管理在设计时将锁的粒度最小化,目的是降低多线程数据竞争带来的影响。

锁性能优化


#![allow(unused)]
fn main() {
// AddressSpace优化前结构
pub struct AddressSpace {
    root: Region,
    flat_view: Arc<RwLock<FlatView>>,
    listeners: Arc<Mutex<Vec<Box<dyn Listener>>>>,
    ioeventfds: Arc<Mutex<Vec<RegionIoEventFd>>>,
}
}

对于地址空间管理模块的关键数据结构,代表平坦视图的FlatView具有重要作用。其一,在树状拓扑结构发生变化时,例如添加和删除Region,会引起平坦视图FlatView发生变化,因此应该获取AddressSpaceflat_view成员的写锁,用于更新平坦视图;其二,设备访问内存、VCPU退出到StratoVirt访问设备,都要通过AddressSpaceflat_view成员,获取读锁,找到对应的Region,然后进行读写操作。

但是,使用Rust读写锁RwLock仍然存在连个问题:其一,经过测试,Rust读写锁的性能比互斥锁差。而读写锁和互斥锁的性能均比原子类型差;其二,在某些场景下,地址空间管理模块需要实现函数可重入的支持,即 在持有FlatView读锁的情况下,仍可以对树状拓扑结构和平坦视图FlatView更新(例如,PCI bar空间更新,需要通过AddressSpace访问设备寄存器来设置地址,并将确定好地址的PCI bar空间添加到AddressSpace中)。

基于上述问题和场景要求,使用Rust中的读写锁和互斥锁都会带来问题。而RCU锁不但能满足多读者、少写者的并发情况,而且能允许读写同时进行的要求。通过自行实现和已有第三方库的调研,最终我们选用arc_swap第三方库的RCU-like的机制,不但可以满足可重入性的要求,而且通过地址空间模块访问内存的性能可以提升20%以上。


#![allow(unused)]
fn main() {
pub struct AddressSpace {
    root: Region,
    flat_view: ArcSwap<FlatView>,
    listeners: Arc<Mutex<Vec<Box<dyn Listener>>>>,
    ioeventfds: Arc<Mutex<Vec<RegionIoEventFd>>>,
}
}

关注我们

StratoVirt当前已经在openEuler社区(openEuler是一个开源、免费的Linux发行版平台,将通过开放的社区形式与全球的开发者共同构建一个开放、多元和架构包容的软件生态体系)开源。在未来的一段时间我们将开展一系列主题的分享,让大家更加详细的了解StratoVirt实现,非常期待您的围观和加入!

项目地址:https://gitee.com/openeuler/stratovirt

项目wiki:https://gitee.com/openeuler/stratovirt/wikis

项目交流:virt邮件列表或是提交一个issue

华为 | Rust 编译后二进制大小和常用优化方式

作者: 周紫鹏 / 后期编辑:张汉东


背景介绍

Rust编译后的可执行文件大小一直是大家谈论比较多的问题,对于嵌入式单板空间有限的场景下,太大的可执行文件往往是不可接受的。当前的项目也经常会因为几K的可执行文件增大而进行优化。

本篇文章对比Rust和C语言可执行文件大小和组成,并尝试提供一些有效的优化方式。Rust选择的是Tokio v1.5.0作为测试对象,C语言则选择公司内部某项目组模块作为测试对象。

Rust生成二进制类型介绍

Rust支持生成多种格式的动态库和静态库,在Cargo.toml文件中,新增[lib]段指定crate-type就可以进行配置。


#![allow(unused)]
fn main() {
[lib]
crate-type = ["dylib"]
}
  • [crate_type = "bin"]

    生成可执行文件,crate中必须要有main函数作为入口,如果crate中已经有main函数,其实不需要在toml文件中显示指定。生成的可执行文件中,会包含所有Rust相关的库和依赖。也就是生成的可执行文件可以在没有安装Rust环境的机器上运行。

  • [crate_type = "lib"]

    生成一个Rust库,但是具体的形态会根据不同的编译器来生成对应的lib库,生成的库是给rustc使用的,所以这个库的形式也会跟着rustc的变化而变化。

  • [crate_type = "dylib"]

    生成一个动态的Rust库(Linux 上为 .so,MacOS 上为 .dylib, Windows 上为 .dll),生成的动态库可以作为其他库或者可执行文件的依赖库。该动态库会包含Rust的一些特定段,如.rustc等。

  • [crate_type = "staticlib"]

    生成一个静态库(Linux\MacOS 上为 .a,Windows 上为 .lib),Rust编译器不会链接staticlib生成的静态库,因为该静态库会包含Rust库和依赖的第三方库,一般适合作为独立的Rust库实现提供给第三方,和bin的区别是,没有携带main函数。

  • [crate_type = "cdylib"]

    C类型的动态库,与 dylib 类似,也会生成 .so, .dylib 或 .dll 文件,但是生成的为C-ABI格式的二进制,可以提供给C语言作为FFI调用。

  • [crate_type = "rlib"]

    Rust lib文件,由于当前Rust的二进制格式是不稳定的,所以当前Rust还是使用源码集成一起编译的方式来进行构建,当前没有办法通过Cargo.toml的方式依赖编译好的SO、*.rlb或者.a。rlib作为Rust编译生成的中间二进制文件,会携带很多Rust语言相关的信息,最终是作为rustc的输入。在编译的过程中,可以在target\release\deps下看到依赖的三方库被编译成rlib。

  • [crate_type = "proc-macro"]

    不会产生特定类型的库文件,Rust过程宏使用需要独立的crate,其他库通过依赖指定的proc-macro库进行使用。

本次分析主要以dylib库方式进行,避免引入第三方库依赖的影响。

可执行文件组成

Tokio v1.5.0中tokio模块的代码(NBNC)有36,473行,使用tokei工具进行统计的结果。

tokio\tokio\Cargo.toml文件中添加crate-type = ["dylib"],指定编译结果为动态库形式。

  • 使用cargo build --release编译

    生成的libtokio.so大小为5,385,736字节,每个段的分布如下。第二列为段名称,第三列为段大小,最后一列为每千行代码包含的二进制大小。段的大小单位都为字节。

[Nr]Section NameSection SizeSection Size / KLOC
[ 1].hash12,496347
[ 2].gnu.hash12,928359
[ 3].dynsym50,3761,399
[ 4].dynstr194,0405,390
[ 5].gnu.version4,198117
[ 6].gnu.version_r2567
[ 7].rela.dyn59,6161,656
[ 8].rela.plt481
[ 9].init261
[10].plt481
[11].plt.got160
[12].text689,51719,153
[13].fini90
[14].rodata31,222867
[15].eh_frame_hdr38,6601,074
[16].eh_frame179,8684,996
[17].gcc_except_table28,468791
[18].tdata562
[19].tbss2116
[20].init_array80
[21].fini_array80
[22].data.rel.ro31,304870
[23].dynamic57616
[24].got5,008139
[25].data1685
[26].bss1604
[27].comment170
[28].rustc3,318,06092,168
[29].debug_aranges1284
[30].debug_info682
[31].debug_abbrev361
[32].debug_line1975
[33].debug_str1073
[34].debug_ranges1284
[35].symtab185,5925,155
[36].strtab539,04714,974
[37].shstrtab34210

从表格中可以看到,release中仍然存在调试相关信息,包括符号表信息。针对调测信息,我们对SO进一步进行strip。

  • strip

    strip命令可以将29到37的调测信息段删除,删除之后的libtokio.so大小为4,659,816,仍有4.5M左右的大小。

  • .rustc段

    .rustc段大概占了整体大小的60%,关于.rustc段的作用是这样的,由于动态库dylib采用Rust ABI,目前这个ABI尚不稳定,需要.rustc这一节来附加额外的版本控制信息,在最终的可执行文件中不会存在rustc段。可以通过strip libtokio.so -R .rustc将.rustc段删除,删除之后的大小为1,341,680大小为1.3M 左右。

  • 各段占比以及和C的对比

tokio数据C语言数据
序号段大小每千行大小百分比百分比每千行大小
[ 1].hash12,4963470.93%1.94%270.hash
[ 2].gnu.hash12,9283590.96%2.25%313.gnu.hash
[ 3].dynsym50,3761,3993.75%7.26%1,010.dynsym
[ 4].dynstr194,0405,39014.46%6.01%836.dynstr
[ 7].rela.dyn59,6161,6564.44%5.53%769.rela.dyn
[12].text689,51719,15351.39%50.09%6,965.text
[14].rodata31,2228672.33%8.34%1,159.rodata
[15].eh_frame_hdr38,6601,0742.88%1.87%261.eh_frame_hdr
[16].eh_frame179,8684,99613.41%10.05%1,397.eh_frame
[17].gcc_except_table28,4687912.12%
[22].data.rel.ro31,3048702.33%0.01%2.data.rel.ro
[24].got5,0081390.37%1.56%217.got
3704213,198

表格中C采用-O2优化等级,并通过strip之后的数据。按照经验值来看,每千行C代码编译出的二进制大小大概在13K左右。从表格对比来看,Rust编译出来的可执行文件大概是C语言的3倍。最主要增大点在.text段和.dynstr段。其中tokio比C多了.gcc_except_table段,该段和try-catch-finally 控制流块的异常相关,部分信息用于处理异常,其他信息用于清除代码(即:在展开堆栈时调用对象析构函数)。

  • .dynsym

    这一节存储的是关于动态链接的符号表,每一个表项占24字节,tokio总共有2099个动态符号,相比较于C,Rust会存在更多的库函数、数据结构和异常处理等。

    //Rust dynsym符号表
    Symbol table '.dynsym' contains 2099 entries:
       Num:    Value          Size Type    Bind   Vis      Ndx Name
         0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
         1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN3std3net3tcp9TcpStream
         2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN3std2fs8DirEntry9file_
         3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN4core3fmt3num53_$LT$im
         4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN4core3fmt3num53_$LT$im
         5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN51_$LT$$RF$std..fs..Fi
         6: 0000000000000000     0 OBJECT  GLOBAL DEFAULT  UND _ZN3std10std_detect6detec
         7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN3std3sys4unix6thread6T
         8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN4core3fmt3num52_$LT$im
         9: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND pipe2@GLIBC_2.9 (2)
        10: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN91_$LT$std..io..cursor
        11: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN74_$LT$std..fs..DirEnt
        12: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN4core6option13expect_f
        13: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN3std3net4addr12SocketA
        14: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN3std4path4Path5_join17
        15: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND _ZN4core3fmt3num53_$LT$im
        ....
    
    //C dynsym符号表
       Num:    Value          Size Type    Bind   Vis      Ndx Name
         0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND 
         1: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND free@GLIBC_2.2.5 (2)
         2: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __isoc99_fscanf@GLIBC_2.7 (3)
         3: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND puts@GLIBC_2.2.5 (2)
         4: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND clock_gettime@GLIBC_2.17 (4)
         5: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fclose@GLIBC_2.2.5 (2)
         6: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND printf@GLIBC_2.2.5 (2)
         7: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __assert_fail@GLIBC_2.2.5 (2)
         8: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@GLIBC_2.2.5 (2)
         9: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND feof@GLIBC_2.2.5 (2)
        10: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
        11: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND memcpy@GLIBC_2.14 (5)
        12: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND malloc@GLIBC_2.2.5 (2)
        13: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND fopen@GLIBC_2.2.5 (2)
    
  • .dynstr

    dynstr段用来存储dysym符号表中的符号,本次测试使用的是rustc 1.48.0,组名规则为legacy,类似于C++的组名规则,符号名中间会加上crate、mod、struct等信息,想比于C语言的组名要大很多。

    当前nightly版本支持了新的组名规则,V0规则,新的规则会删除符号最后的哈希值,但是组名之后的符号仍然是很长的。

    //Rust 字符串表
    String dump of section '.dynstr':
      [     1]  libstd-f14aca24435a5414.so
      [    1c]  _ITM_deregisterTMCloneTable
      [    38]  __gmon_start__
      [    47]  _Jv_RegisterClasses
      [    5b]  _ITM_registerTMCloneTable
      [    75]  _ZN58_$LT$std..io..error..Error$u20$as$u20$core..fmt..Debug$GT$3fmt17heb882e9e5723aaeaE
      [    cd]  _ZN244_$LT$std..error..$LT$impl$u20$core..convert..From$LT$alloc..string..String$GT$$u20$for$u20$alloc..boxed..Box$LT$dyn$u20$std..error..Error$u2b$core..marker..Send$u2b$core..marker..Sync$GT$$GT$..from..StringError$u20$as$u20$core..fmt..Display$GT$3fmt17h0381a183d16c0bdbE
      [   1e0]  _ZN3std2rt19lang_start_internal17h73711f37ecfcb277E
      [   214]  _ZN56_$LT$std..io..Guard$u20$as$u20$core..ops..drop..Drop$GT$4drop17h17ecb6f4aa594fe8E
      [   26b]  _ZN4core6result13unwrap_failed17he7cdc7a46f93cfbeE
      [   29e]  _ZN3std2fs11OpenOptions4read17hb9e61755aa4c5dd0E
    
    
    //C 字符串表
    String dump of section '.dynstr':
      [     1]  libc.so.6
      [     b]  fopen
      [    11]  puts
      [    16]  __assert_fail
      [    24]  printf
      [    2b]  feof
      [    30]  __isoc99_fscanf
      [    40]  memcpy
      [    47]  fclose
      [    4e]  malloc
      [    55]  clock_gettime
    
  • .text段

    最后再打开看看最大头的代码段。.text段大概也是C的三倍左右大小,通过汇编指令打开查看,Rust比C多出点在异常处理、调用栈、析构函数、泛型实例化、Vec,Result,Box,String,Map等结构的处理、运行时边界校验等。

优化方式

上述我们只是用cargo build --release的方式进行了代码的优化,当然Rust编译器还提供了不同的优化手段。本节还是基于tokio,介绍常用的二进制优化手段。

优化手段二进制大小(字节)
debug模式编译22,287,016
release模式编译5,385,736
strip之后大小4,659,816
strip libtokio.so -R .rustc1,341,680
codegen-units = 11,046,768
panic = 'abort'未测试
Optimize libstd with Xargo未测试

cargo支持的性能和二进制大小优化选项可以参见这里

  • codegen-units

    其中codegen-units = 1优化效果比较明显。该选项用来将crate分割成多个代码生成单元,当生成多个代码单元时,LLVM会并行的来处理,减少编译的时间。如果将codegen-units设置为1的时候,可以提升代码的运行速度,和减少生成的可执行文件,但是会大大增加编译的时间开销。在仅使用release时tokio编译时间为25s,在设置codegen-units = 1的时候,编译时间为39s,大概增加了**60%**的时间。默认情况下全量编译设置的值为16,增量编译下设置的值为256。

  • min-sized-rust

    该仓中介绍了几种常用的优化方式,但是尝试使用opt-level = 'z' lto = true两个选型对tokio最终生成的二进制并没有影响,当然这两个选项对性能有一定的提升。

    Jemalloc在1.32版本已经被删除。

    panic = 'abort'添加之后编译失败,正常Rust在panic的时候,会记录调用栈,如果改为panic='abort'之后,将会直接退出,而不会打印异常信息。

    其他优化手段,如重新编译libstd、#![no_std]不使用标准库,也没有在本次测试范围内。

结论

Rust由于其组名规则和语言特性等原因,在使用了各种优化之后,编译出来的二进制大小大概是C语言的三倍左右,主要增大在代码段和动态符号表上。但是Rust语言比C的表达能力更强,同样的功能下,可以使用更少于C的代码量来实现,所以其二进制的增大还是可以接受。

蚂蚁集团 | 异步化OS:利用 async/await 带来 10 倍性能提升

作者:蚂蚁集团 田洪亮 / 编辑: 张汉东


Rust是第一个对异步I/O提供语言级支持的系统编程语言。自async/await特性在2019年稳定以来,Rust的异步编程生态已经有了长足了发展,比如async运行时有流行的Tokioasync_std两大选择。最近,Rust官方博客又连续两个月发布了展望async/await未来的博客(3月4月)。async/await特性在社区的受认可和重视程度可见一斑。

那么,如何在我们自己的项目中有效使用async/await特性呢?更进一步,如何搭建属于自己项目的异步架构、运行时和软件栈呢?

本文将分享作者在蚂蚁集团的开源项目Occlum (https://github.com/occlum/occlum)中收获的实战经验。基于async/await特性,我们以一种名为“以异步为中心”的新设计对Occlum做了架构升级,并取得了大幅性能提升的理想效果。

具体来说,我们在实践中遇到并解决了如下一些async/await相关问题:

  • 是否值得为一个项目引入async/await?
  • 如何在no_std环境使用async/await?
  • 如何重构大量老代码以适应async/await?
  • 如何充分利用异步优化机会来提升性能?

如果你对这些Rust问题或对OS感兴趣,那来听我们的故事吧。

(长文预警,强烈建议阅读前准备充足的水和食物~)

让数据可用而不可见

My safe bet: "In 10 years, most chips will have secure enclave capabilities.” — UC Berkeley华人教授Dawn Song

任何故事都有一个时代背景,而我们的是机密计算(Confidential Computing)。

机密计算是一种新兴的安全技术,其目标是保护内存中正在使用中数据的机密性和完整性——即便攻击者来自特权级软件(比如OS)也不例外!

这是一个非常强的安全保证,因为传统上来讲,软件栈是按照特权级分层的(特权从低到高):应用 --> OS --> 虚拟机管理器 --> 固件。应用的安全性依赖于其下的所有特权软件的安全性。但机密计算颠覆了这一传统的安全模型!

那么,机密计算是如何做到这一点的呢?这就要引出机密计算的硬件基础了——可信执行环境(Trusted Execution Environment,简称TEE)。

TEE是由可信硬件(通常是CPU)提供的私有内存区域,能保证其中的代码和数据的安全,即任何TEE外的软硬件都无法窃取或者篡改TEE中的内容。该安全保证是硬件通过访问控制、内存加密等手段实现的。

因此,利用TEE的硬件安全能力,我们就可以实现机密计算的安全保证。

机密计算被广泛应用于公有云安全、隐私保护的机器学习、多方安全计算、区块链等应用场景。

所有的主流体系架构都已经推出了它们的TEE实现:ARM TrustZoneARM CCAAMD SEVIntel SGXIntel TDXRISC-V Keystone等等。除了CPU以外,GPU以及AI加速器的硬件厂商也在积极筹划引入TEE能力。这也是为什么UC Berkeley的Dawn Song教授几年前就预测,几乎所有芯片都将拥有TEE能力!

目前适用于数据中心的、最成熟、经过最多测试和研究的TEE技术非Intel SGX莫属。在SGX中,TEE被称为Enclave(飞地)。就本文目的而言,我们可以忽略TEE和Enclave的差异,认为两者是同义词即可。

让天下没有难用的TEE

Enpowering everyone to run every app inside TEEs. ——Occlum的项目目标

我们故事的主角是开源项目Occlum(https://github.com/occlum/occlum),它可以将一个未经修改的Linux应用直接放在TEE中运行,从而大大降低使用TEE和采用机密计算的门槛。简而言之,项目使命就是“让天下没有难用的TEE”!

IMAGE 图. Occlum的架构图

从架构图可以看到,Occlum本质上是一个面向TEE的OS,向TEE中的可信应用提供Linux兼容的系统调用。Occlum中包含内存管理、进程管理、线程调度、进程间通信、文件和网路I/O等功能。

TEE总是依附于一个普通主机环境运行的(也就是Host OS,通常是Linux)。因此,TEE OS可以在必要的时候请求Host OS的服务,完成某些功能。但因为Host OS是不可信的,因此TEE OS需要确保这种请求是安全的,比如I/O需要做加密解密,防止Host OS窃取或者篡改隐私数据。

出于安全和性能考虑,Occlum的核心部分采用Rust语言开发。

Occlum项目历经三年,已经发展为目前业界最好的开源TEE OS之一。下面列出了部分重要里程碑:

  • 2019年3月,项目正式开源;
  • 2020年3月,国际顶级学术会议ASPLOS‘20录用Occlum的论文;
  • 2020年5月,阿里云公布开源项目Inclavare Containers,采用Occlum作为后端引擎;
  • 2020年9月,微软Ignite大会的Azure Confidential Computing在官方文档推荐Occlum;
  • 2020年10月,Linux基金会下属的Confidential Computing Consortium组织接收Occlum为旗下开源项目之一。

TEE的代价

There ain't no such thing as a free lunch. ——诺贝尔经济学奖得主Milton Friedman

No free lunch的道理谁都懂,这当然也适用于安全。具体到TEE的话,就是TEE有一个几乎无法避免的硬件开销——TEE切换。

我们以Intel SGX为例说明(如下图所示)。我们知道CPU有很多执行模式;最为人熟知的,就是用户态(Ring 3)和内核态(Ring 0)。CPU在这两种模式之间做转换,就是用户-内核模式切换。在引入SGX之后,又多了一种模式,即TEE态(也就是Enclave态)。也因此多了一种额外的模式切换,即TEE切换。

IMAGE 图. Intel CPU的模式切换,包括Enclave切换

出于安全原因,TEE切换时,CPU要做更多的安全检查,更彻底的状态重置(比如寄存器、cache、TLB等等)。这最终导致TEE切换的开销要远高于传统的模式切换开销。实际上,根据我们的实际测试,TEE切换的开销是用户-内核切换的60倍以上!

而Occlum的原实现(异步化之前)在执行调度和I/O等系统调用时会触发TEE切换。因此,当TEE中的可信应用程序频繁请求Occlum做调度和I/O操作时,用户会观测到比较大的性能下降。

那怎么解决这一性能瓶颈呢?

想必你已经猜到了。对,这就是异步化!

异步OS之旅——Let's Go!

Asynchrony, in computer programming, refers to the occurrence of events independent of the main program flow and ways to deal with such events... Doing so provides a degree of parallelism. ——维基百科关于“异步”的词条

对于Occlum来说,异步化对性能有两方面的提升效果:

  1. 避免TEE切换. 我们可以通过使用(不可信的)共享缓冲区与Host OS通信,来发起异步I/O的请求和接受异步I/O的完成,这样就可以避免为了I/O而触发TEE切换。
  2. 提高OS并行性. 如维基百科所描述的,异步的好处就是增加并行性,而并行性的增加通常意味着性能提升。实际上,增加OS并行性可以帮助掩盖TEE除切换以外的其他硬件相关性能开销(篇幅所限,无法详细介绍)。

IMAGE 图. 以异步为中心的设计

异步化是一个系统工程。我们提出了一个“以异步为中心的设计”,在四个层面(如上图所示)对Occlum做了相应的异步化改造。接下来,我们从底向上分四个小节依次介绍。

第1站:语言层

A language that doesn’t affect the way you think about programming is not worth knowing. — Alan Perlis

异步化会引入额外的代码复杂度,这是为什么大家通常都认为异步编程是困难的。

但幸运的是,我们是Rust开发者。Rust的async/await使得异步代码可以用同步代码的方式表达,这大大降低了编写异步代码的头脑负担。更难得的是,Rust对async/await实现,确实达到了零开销抽象(Zero-Cost Abstraction)的设计目标,在运行时只会带来几乎可以忽略不计的开销。

采用Rust的async/await特性,是我们实现异步化的语言层基础。

更多关于Rust的async/await以及异步编程,见The Async Book,这里不再赘述。

第2站:调度层

运行异步代码依赖于执行器。在异步化的Occlum中,这个执行器就是调度层。

我们的调度层兼具两个职责,分别由Rust异步化的目标与OS语意的功能所决定:

  1. 调度TEE OS中的协程。我们知道Rust的异步代码的执行单元是任务,或者说协程。一般来说,异步Rust程序中协程的调度是由一个异步运行时负责的,比如Tokio或async_std。但我们的TEE OS中没有现成的异步运行时可用,因此必须由我们自己实现。协程都是我们的OS代码,因此协作式调度就足够了。
  2. 调度TEE OS上的用户线程。作为TEE OS,Occlum必须要为其上的可信应用提供进程和线程这两种OS资源。而且,因为可信应用的代码可以由任何语言编写,可能执行任意复杂的代码逻辑,因此Occlum有必要实现抢占式调度。

为了同时承担上述两个职责,我们在TEE中实现了一种M:N:P的调度模型(如下图所示)。

IMAGE 图. 异步化Occlum的调度层

在该调度模型中,处于中心地位的是TEE OS协程(数量记为N),他们具有内存占用少、启动延迟低、上下文切换快等协程的典型优点。这些协程是运行在Host OS的线程之上的(数量记为P),P的数量决定了硬件层面的并行性和CPU资源占用,可由用户在运行时指定。TEE OS协程既可以用于执行OS任务,也可以运行应用任务,而后者就是TEE OS的用户线程(数量记为M,且有M<=N)。我们实现了TEE的中断处理,因此可以强迫用户线程放弃CPU,实现抢占式调度。

相比Occlum原先的调度层(限于篇幅而没有介绍),异步化改造之后的调度层具有下面两方面的优势:

  1. 完全TEE内调度,不会引发TEE切换,因此调度相关的性能大幅提升;
  2. 引入了非常轻量的协程,这使得TEE OS可以以低成本和开销创建协程来增加OS的并行性,从而提高TEE OS的整体性能。

第3站:I/O层

有了调度层,异步代码已经可以执行了,我们的下一步就是要支持异步I/O。

在异步化的Occlum中,我们引入了Linux的异步I/O机制io_uring,这是一个Linux的非常新的特性,首次合入是在5.1版,目前仍在快速发展和迭代中。基于io_uring之后,Occlum的底层I/O就是如下图所示的样子了。

![IMAGE](resources/BD85FC29BE588B9641746B91752E34D7.jpg =581x367)

我们认为io_uring与Occlum的异步化架构可以说天作之合,理由有二:

  1. io_uring支持基于polling模式的I/O. Polling模式使得Occlum和Linux之间的通信只需要访问共享内存(即I/O的submission queue和completion queue),不需要做TEE切换,因此避免TEE切换的开销。
  2. io_uring提供真正的异步I/O接口. io_uring的API是真正的异步I/O:Occlum在发起I/O之后,并不需要等待该I/O的完成即可继续发起其他I/O(I/O并行性)或者执行其他代码逻辑(代码并行性)。

io_uring被认为是目前Linux提供的最快的I/O机制(无论是做网络还是磁盘I/O),而且其性能还在持续提升。但io_uring在接口上与传统I/O差异巨大,这要求应用做大幅修改和适配才能用上io_uring。而Occlum可以让TEE中的可信应用透明地获得io_uring的好处,“免费”获得性能提升。

第4步:内核层

我们的终极目标是,提升Occlum的系统调用的性能。

为此,我们提出了两类异步优化技巧:积极执行(Eager Execution)和承诺执行(Promised Execution)。

积极执行是一种在用户真正发起系统调用之前、就提前实施某种高耗时操作的异步优化技巧。由于高耗时操作已经提前发起执行,当用户向Occlum请求系统调用时,该高耗时操作很可能已经完成,这样Occlum可以直接返回结果。这就大大降低了用户可见的系统调用开销,也就是延迟。

积极执行主要适用于read类的系统调用,比如readrecvmsgaccept等系统调用。我们可以在用户发起这些系统调用之前,提前用io_uring发起相应的异步I/O,然后把返回结果暂存起来。取决于具体的系统调用,暂存结果的数据结构可能是文件系统的page cache、已连接socket的接收缓冲区、或监听socket的backlog等等。

承诺执行是一种延后高耗时操作执行、不等高耗时操作完成就提前返回系统调用结果的异步优化技巧。由于避免了对高耗时操作的等待,用户可见的系统调用延迟被大幅降低。承诺执行的关键在于对高耗时操作的延后执行不能影响相关系统调用的语意。

承诺执行主要适用于write类的系统调用,比如writesendmsg等系统调用。当收到这类系统调用之后,我们可以先把这类数据暂存起来,然后就可以立刻返回该系统调用了。在未来某个合适的时间点,我们通过io_uring真正发起相应的I/O。取决于具体的系统调用,暂存结果的数据结构可能是文件系统的page cache、或已连接socket的发送缓冲区等等。

不管是积极执行、还是承诺执行,我们都通过异步的方法,降低了用户可感知的系统调用延迟,从而提高了应用程序的性能。

终点站:性能评估

为了评估“以异步为中心”设计的性能优化效果,我们比较了异步化Occlum(Next-Gen Occlum,简称NGO)和Linux在调度、网络I/O、文件I/O等方面的性能。结果显示(下图的三张图),异步化Occlum在这三个方面都达到了Linux的性能水平。而相对于异步化之前,Occlum的性能取得了显著的10X以上的提升(虽然图表中没有展示)。

IMAGE 图. 调度性能(其中NGO表示异步化Occlum)

IMAGE 图. 网络I/O性能(其中NGO表示异步化Occlum)

IMAGE 图. (非加密)文件I/O性能(其中NGO表示异步化Occlum)

旅程的尾声

对读到这里的读者,我想向你表示感谢,谢谢你的兴趣和耐心。希望我们在Rust的异步编程方面的探索和实践对你也能有所启发和帮助。

如果你对Rust编程、底层软件、系统安全或机密计算等方面感兴趣,欢迎联系我(email: tate.thl@antgroup.com,WeChat ID:tatetian),期待与你相遇。

蚂蚁集团 | Trait 使用及实现分析

作者:Jiacai Liu / 后期编辑:张汉东


在 Rust 设计目标中,零成本抽象是非常重要的一条,它让 Rust 具备高级语言表达能力的同时,又不会带来性能损耗。零成本的基石是范型与 trait,它们可以在编译期把高级语法编译成与高效的底层代码,从而实现运行时的高效。这篇文章就来介绍 trait,包括使用方式与两个常见问题的分析,在问题探究的过程中来阐述其实现原理。

使用方式

Trait 的主要作用是用来抽象行为,类似于其他编程语言中的「接口」,这里举一示例阐述 trait 的主要使用方式:


#![allow(unused)]
fn main() {
trait Greeting {
    fn greeting(&self) -> String;
}

struct Cat;
impl Greeting for Cat {
    fn greeting(&self) ->String {
        "Meow...".to_string()
    }
}

struct Dog;
impl Greeting for Dog {
    fn greeting(&self) ->String {
        "Bark...".to_string()
    }
}
}

在上述代码中,定义了一个 trait Greeting,两个 struct 实现了它,根据函数调用方式,主要两种使用方式:

  • 基于范型的静态派发
  • 基于 trait object 的动态派发

范型的概念比较常见,这里着重介绍下 trait object 的定义

A trait object is an opaque value of another type that implements a set of traits. The set of traits is made up of an object safe base trait plus any number of auto traits.

比较重要的一点是 trait object 属于 Dynamically Sized Types(DST),在编译期无法确定大小,只能通过指针来间接访问,常见的形式有 Box<dyn trait> &dyn trait 等。


#![allow(unused)]
fn main() {
fn print_greeting_static<G: Greeting>(g: G) {
    println!("{}", g.greeting());
}
fn print_greeting_dynamic(g: Box<dyn Greeting>) {
    println!("{}", g.greeting());
}

print_greeting_static(Cat);
print_greeting_static(Dog);

print_greeting_dynamic(Box::new(Cat));
print_greeting_dynamic(Box::new(Dog));

}

静态派发

在 Rust 中,范型的实现采用的是单态化(monomorphization),会针对不同类型的调用者,在编译时生成不同版本的函数,所以范型也被称为类型参数。好处是没有虚函数调用的开销,缺点是最终的二进制文件膨胀。在上面的例子中, print_greeting_static 会编译成下面这两个版本:


#![allow(unused)]
fn main() {
print_greeting_static_cat(Cat);
print_greeting_static_dog(Dog);
}

动态派发

不是所有函数的调用都能在编译期确定调用者类型,一个常见的场景是 GUI 编程中事件响应的 callback,一般来说一个事件可能对应多个 callback 函数,而这些 callback 函数都是在编译期不确定的,因此范型在这里就不适用了,需要采用动态派发的方式:


#![allow(unused)]
fn main() {
trait ClickCallback {
    fn on_click(&self, x: i64, y: i64);
}

struct Button {
    listeners: Vec<Box<dyn ClickCallback>>,
}
}

impl trait

在 Rust 1.26 版本中,引入了一种新的 trait 使用方式,即:impl trait,可以用在两个地方:函数参数与返回值。 该方式主要是简化复杂 trait 的使用,算是范型的特例版,因为在使用 impl trait 的地方,也是静态派发,而且作为函数返回值时,数据类型只能有一种,这一点要尤为注意!


#![allow(unused)]
fn main() {
fn print_greeting_impl(g: impl Greeting) {
    println!("{}", g.greeting());
}
print_greeting_impl(Cat);
print_greeting_impl(Dog);

// 下面代码会编译报错
fn return_greeting_impl(i: i32) -> impl Greeting {
    if i > 10 {
        return Cat;
    }
    Dog
}

// | fn return_greeting_impl(i: i32) -> impl Greeting {
// |                                    ------------- expected because this return type...
// |     if i > 10 {
// |         return Cat;
// |                --- ...is found to be `Cat` here
// |     }
// |     Dog
// |     ^^^ expected struct `Cat`, found struct `Dog`
}

常见问题

向上转型(upcast)

对于 trait SubTrait: Base ,在目前的 Rust 版本中,是无法将 &dyn SubTrait 转换到 &dyn Base 的。这个限制与 trait object 的内存结构有关。

Exploring Rust fat pointers 一文中,该作者通过 transmute 将 trait object 的引用转为两个 usize,并且验证它们是指向数据与函数虚表的指针:

use std::mem::transmute;
use std::fmt::Debug;

fn main() {
    let v = vec![1, 2, 3, 4];
    let a: &Vec<u64> = &v;
    // 转为 trait object
    let b: &dyn Debug = &v;
    println!("a: {}", a as *const _ as usize);
    println!("b: {:?}", unsafe { transmute::<_, (usize, usize)>(b) });
}

// a: 140735227204568
// b: (140735227204568, 94484672107880)

从这里可以看出:Rust 使用 fat pointer(即两个指针) 来表示 trait object 的引用,分布指向 data 与 vtable,这和 Go 中的 interface 十分类似。

img


#![allow(unused)]
fn main() {
pub struct TraitObjectReference {
    pub data: *mut (),
    pub vtable: *mut (),
}

struct Vtable {
    destructor: fn(*mut ()),
    size: usize,
    align: usize,
    method: fn(*const ()) -> String,
}
}

尽管 fat pointer 导致指针体积变大(无法使用 Atomic 之类指令),但是好处是更明显的:

  1. 可以为已有类型实现 trait(比如 blanket implementations

  2. 调用虚表中的函数时,只需要引用一次,而在 C++ 中,vtable 是存在对象内部的,导致每一次函数调用都需要两次引用,如下图所示:

    img

如果 trait 有继承关系时,vtable 是怎么存储不同 trait 的方法的呢?在目前的实现中,是依次存放在一个 vtable 中的,如下图:

img

可以看到,所有 trait 的方法是顺序放在一起,并没有区分方法属于哪个 trait,这样也就导致无法进行 upcast,社区内有 RFC 2765 在追踪这个问题,感兴趣的读者可参考,这里就不讨论解决方案了,介绍一种比较通用的解决方案,通过引入一个 AsBase 的 trait 来解决:

trait Base {
    fn base(&self) {
        println!("base...");
    }
}

trait AsBase {
    fn as_base(&self) -> &dyn Base;
}

// blanket implementation
impl<T: Base> AsBase for T {
    fn as_base(&self) -> &dyn Base {
        self
    }
}

trait Foo: AsBase {
    fn foo(&self) {
        println!("foo..");
    }
}

#[derive(Debug)]
struct MyStruct;

impl Foo for MyStruct {}
impl Base for MyStruct {}

fn main() {
    let s = MyStruct;
    let foo: &dyn Foo = &s;
    foo.foo();
    let base: &dyn Base = foo.as_base();
    base.base();
}

Object safety

在 Rust 中,并不是所有的 trait 都可用作 trait object,需要满足一定的条件,称之为 object safety 属性。主要有以下几点:

  1. 函数返回类型不能是 Self(即当前类型)。这主要因为把一个对象转为 trait object 后,原始类型信息就丢失了,所以这里的 Self 也就无法确定了。

  2. 函数中不允许有范型参数。主要原因在于单态化时会生成大量的函数,很容易导致 trait 内的方法膨胀。比如

    
    #![allow(unused)]
    fn main() {
    trait Trait {
        fn foo<T>(&self, on: T);
        // more methods
    }
    
    // 10 implementations
    fn call_foo(thing: Box<Trait>) {
        thing.foo(true); // this could be any one of the 10 types above
        thing.foo(1);
        thing.foo("hello");
    }
    
    // 总共会有 10 * 3 = 30 个实现
    }
    
  3. Trait 不能继承 Sized。这是由于 Rust 会默认为 trait object 实现该 trait,生成类似下面的代码:

    
    #![allow(unused)]
    fn main() {
    trait Foo {
        fn method1(&self);
        fn method2(&mut self, x: i32, y: String) -> usize;
    }
    
    // autogenerated impl
    impl Foo for TraitObject {
        fn method1(&self) {
            // `self` is an `&Foo` trait object.
    
            // load the right function pointer and call it with the opaque data pointer
            (self.vtable.method1)(self.data)
        }
        fn method2(&mut self, x: i32, y: String) -> usize {
            // `self` is an `&mut Foo` trait object
    
            // as above, passing along the other arguments
            (self.vtable.method2)(self.data, x, y)
        }
    }
    }
    

    如果 Foo 继承了 Sized,那么就要求 trait object 也是 Sized,而 trait object 是 DST 类型,属于 ?Sized ,所以 trait 不能继承 Sized。

    对于非 safe 的 trait,能修改成 safe 是最好的方案,如果不能,可以尝试范型的方式。

总结

本文开篇就介绍了 trait 是实现零成本抽象的基础,通过 trait 可以为已有类型增加新方法,这其实解决了表达式问题,可以进行运算符重载,可以进行面向接口编程等。希望通过本文的分析,可以让读者更好的驾驭 trait 的使用,在面对编译器错误时,能够做到游刃有余。

参考

关于我们

我们是蚂蚁智能监控技术中台的时序存储团队,我们正在使用 Rust 构建高性能、低成本并具备实时分析能力的新一代时序数据库,欢迎加入或者推荐,目前我们也正在寻找优秀的实习生,也欢迎广大应届同学来我们团队实习,请联系:jiachun.fjc@antgroup.com

知乎 | 基于 SIMD 指令优化 Rucene

作者:知乎搜索技术团队 / 后期编辑:张汉东


Rucene,是知乎搜索技术团队基于 Lucene、使用 Rust 语言重写的一套搜索引擎核心库。对外,Rucene 负责知乎搜索和推荐两大核心业务的召回;对内,Rucene 支撑了知乎公司级的 Logging 服务。2019 年,我们已将 Rucene 开源到 Github,项目地址:https://github.com/zhihu/rucene。

SIMD,即 Single Instruction Multiple Data,单指令多数据,通俗来讲,就是借助现代 CPU 架构中提供的扩展指令集,比如 SSEAVX 等,通过一条 CPU SIMD 指令,一次对多个数据进行并行存取和计算。

目前,在中文互联网上,很少有文章针对具体业务介绍 SIMD 优化的,具体到 Rust 语言,就更是少之又少。本文基于知乎搜索团队对 Rucene 的一些优化实践,总结下如何利用 SIMD 指令优化 Rust 程序。

利用 SIMD 优化程序,大体有三个主要途径:一是让编译器去做优化,这是一种尽力而为的行径,也依赖于编译器具体实现(笔者在调研 SIMD 过程中了解到,Java 中要使用 SIMD 需要按照一些约定来编写程序,然后可以让 JVM 去尽量做 SIMD 优化,对 Java 中使用 SIMD 感兴趣的同学可以参考下 Lucene 最新版本中关于这个话题的讨论); 二是使用第三方库,借助别人封装好的库,通过简单调用,针对一些特定场景做 SIMD 优化,比如 simdjsonRust 中的 faster 库等;三是直接手撕 SIMD 指令,针对具体的业务特点,有时很难直接调用封装库来使用 SIMD,这种情况只能自己编写调用 SIMD 指令的程序。接下来,本文将从这三个方面做下介绍。

Rust 编译器参数调优

我们翻阅了 Rust 官方提供的 rustccargo 的相关文档,没有发现和 SIMD 优化相关的内容,但在这个过程中,我们发现有三个编译参数,通过合理配置,可以明显提升 Rucene 的性能,其使用方式如下所示,每个参数的具体含义可以参考官方文档,这里不做详细介绍。

#使用方式一:配置 Cargo.toml
[profile.release]
lto=true
opt_level=3
codegen_units=1

#使用方式二:通过环境变量传递给 cargo
CARGO_PROFILE_RELEASE_LTO=true \
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \
cargo build --release

使用这三个参数配置有两点需要注意:一是编译过程会变得非常慢,我们的解决方法是使用第二种方式,仅在镜像发布构建时使用;二是并不是所有程序都会有效,我们的一些简单服务使用这三个参数没有任何效果,反而增加了编译时间。因此,当你的 Rust 程序相对复杂的时候,我们建议你尝试使用这三个参数来优化程序性能。

使用 faster 库优化 Embbeding 计算

2020 年,我们尝试在引擎端做 Embbeding 召回提权,当时灰度上线后,P95 上涨明显,我们用 faster 库做向量计算优化后,P95 上涨控制在了可接受范围。大家不用关心什么是 Embbeding,需要解决的问题就是优化向量内积计算。以下第一段代码是普通的内积计算,第二段代码是使用 faster 利用 SIMD 指令的编写方式。随着向量维度的增加,SIMD 的方式性能优势会非常明显。faster 库的具体使用,大家可以参考相关文档。

// 简单的内积计算示例
fn main() {
    let mut score = 0.0;
    let dots1: Vec<f32> = vec![1.04177, 0.28162, 2.02021];
    let dots2: Vec<f32> = vec![1.59189, 1.94172, 1.02021];
    for i in 0..dots1.len() {
        score += dots1[i] * dots2[i];
    }
    println!("score={}", score);
}
use faster::{f32s, IntoSIMDRefIterator, IntoSIMDZip, SIMDZippedIterator};
// 使用 faster SIMD 库优化向量内积计算
fn main() {
    let dots1: Vec<f32> = vec![1.04177, 0.28162, 2.02021];
    let dots2: Vec<f32> = vec![1.59189, 1.94172, 1.02021];
    let score = (dots1.simd_iter(f32s(0.0)), dots2.simd_iter(f32s(0.0)))
        .zip()
        .simd_reduce(f32s(0.0), |acc, (a, b)| acc + a * b)
        .sum();
    println!("score = {}", score);
}

使用 SIMD 指令优化倒排链的解压缩性能

Rucene 的高效检索基于倒排索引,倒排索引中的倒排链按文档 ID 升序排列,128 个文档 ID 组成一个压缩的 block。搜索引擎提供在线检索服务时,大量 block 的解压操作是引擎的一个主要性能瓶颈点。接下来,我们撇开搜索引擎,将问题描述的更直白一些。

一个 block 中存 128 个整数,升序,以前 8 个数为例: 1,3,8,15,19,31,58,100,…

相邻数字相减,差值称为 delta,第一个数减去上一个 block 的最后一个数,假使这是第一个块,第一个数减去 0,得到以下 delta 序列: 1,2,5,7,4,12,27,42,…

假使 128 个 delta 之中 42 是最大值,其二进制表示 101010,共计 6 个有效位,那么128 个 delta 值全部用 6 个 bit 位存储。

原先的存储方案:一个挨着一个串行存储 1 2 5 7 4 12 27 42 … 000001 000010 000101 000111 000100 001100 011011 101010 ...

SIMD 存储方案:并行存储,前 4 个数存储在紧邻的 4 个 i32 的低 6 位,后 4 个数存储在这 4 个i32 的第 7~第 12 个 bit 位上

→ 第一个 i32 ← | –> 第二个 i32 ← | –> 第三个 i32 ← | –> 第四个 i32 ← | 000001 000100 ... 000010 001100 ... 000101 011011 ... 000111 101010 ... ...... 1 4 2 12 5 27 7 42 …

下面的示意图应该会更清晰些

4 个 i32 作为一个存储单元,第一个数存储在第一个 i32 的低六位,第二个数存储在第二个 i32 的低六位,第三个数存储在第三个 i32 的低六位,第四个数存储在第四个 i32 的低六位,第五个数存储在第一个 i32 的第二个六位,依次类推。1 至 32 的位宽都可以用 4n 个 i32 存下,1 位的位宽需要 4 个 i32,32 位的位宽需要 128 个 i32 存储。数据是对齐的,不存在 4 个 i32 中有些 i32 存满了,有些没存满的情况,非常适合并行操作。

解决完如何存储,剩下的解压操作就相对简单了。用一条 SIMD 加载指令读取 4 个 i32,用 SIMD 的按位与操作,取 4 个 i32 的低 6 位,获取前 4 个整数值,然后,4 个 i32 同时右移 6 个 bit,再做按位与操作,又获取 4 个整数值,依此类推。下图列出示例代码片段和 SIMD 优化中用到的几个主要指令。详细 SIMD 指令介绍可以查看 Rust 标准库文档或 Intel 的官方指令集文档,具体的代码实现可以查看 Rucene 的对应 MR

MR:https://github.com/zhihu/rucene/commit/6629d2f7971bdc7ff113dbfa627b291bbfb257e9


#![allow(unused)]
fn main() {
//解压前4个i32的伪代码:
mask = _mm_set1_epi32(0b111111)                      // 设置掩码
values = _mm_lddqu_si128(block_ptr)                  // 加载4个i32
new_values = _mm_and_si128(values, mask)             // 还原4个i32
}

#![allow(unused)]
fn main() {
//主要用到的sse指令(共10条):
_mm_set1_epi32        // 4个i32设为同一个值
_mm_lddqu_si128       // 加载4个i32到寄存器
_mm_storeu_si128      // 存储4个i32到内存
_mm_and_si128         // 128位按位与操作 
_mm_or_si128          // 128位按位或操作
_mm_slli_epi32        // 4个i32同时左移指定位数
_mm_srli_epi32        // 4个i32同时右移指定位数
_mm_sub_epi32         // 4个i32对另外4个i32做减法操作
_mm_add_epi32         // 4个i32对另外4个i32做加法操作
_mm_cvtsi128_si32     // 取4个i32中最右边的一个
}

优化效果

我们先后上线了两个优化版本:一个是部分解压 + 编译器参数调优版本,这个版本,单就编译器参数调优这一点,大概有 10% 的性能提升;第二个是 SIMD 的优化版本,这个版本在第一个优化基础上,大概又有 15% 的性能提升。总体来看,benchmark 测试,性能提升明显,引擎 Merger P95 总体下降 30%+。以下是一些性能表现截图:

版本说明:

Rucene-stdbaseRucene 基线版本 Rucene-partial: 按需解压 + 编译器参数调优版本 Rucene-simdnewSIMD 优化版本

求交操作性能对比

求或操作性能对比

按需解压 + 编译器参数调优 P95 降 20%+

img

SIMD 版本上线前 P95

img

SIMD 版本上线后 P95

img

SIMD 版本上线 P95 再降 15% 左右(PS:图中抖动是附带上线的其他 MR 所致)

总结

本文结合程序中使用 SIMD 技术的三种可能途径,概述了知乎搜索技术团队在 Rucene 优化中落地 SIMD 优化的一些实践经验。我们自身对 CPU 指令集相关技术的了解也比较有限,也是摸着石头过河,文中如有谬误之处,欢迎读者批评指正。

参考资料

  1. 基于 SIMD 指令的 PFOR-DELTA 解压和查找:https://zhuanlan.zhihu.com/p/63662886
  2. 索引压缩算法New PForDelta简介以及使用SIMD技术的优化:https://yq.aliyun.com/articles/563081
  3. SIMD应用:https://www.zhihu.com/market/pub/119608271/manuscript/1102576357040644096Intel
  4. intrinsics guide:https://software.intel.com/sites/landingpage/IntrinsicsGuide/

招聘信息

知乎搜索技术团队目前在招前端、后端、搜索引擎开发人员,欢迎感兴趣的同学踊跃投递简历,邮箱 wangjingjing@zhihu.com。

Datenlord | io_uring Rust 异步库实现方法

作者:施继成 / 后期编辑:张汉东


简介

io_uring 是 Linux 最新的异步 I/O 接口,采用两个用户和内核共享的 ring buffer 进行交互,性能优于之前的接口且限制更少。虽然 io_uring 仍然处于开发迭代中,但是基本的 I/O 接口已经基本定型,作为高效系统语言的 Rust 则成为使用该接口的不二之选。现在已经有许多针对 io_uring 的 Rust 封装,但是有的存在soundness问题,有的存在性能问题,都不是安全高效 I/O 的好选项。我们团队(DatenLord)也进行了自己的尝试,本文就是介绍我们的 io_uring 异步库实现方法。

Rust 现有异步模式

Rust 的异步库都有自己的异步 I/O 实现方法,但是内部原理大同小异,都是 Reactor 模式,如下图所示:

reactor

Worker 线程将关注的 fd 注册到 Epoll 等待队列中,Reactor 线程通过 Epoll wait 等待可以进行操作的 fd,当有 fd 可以操作时,Reactor 线程通知 Worker 线程进行真正的 I/O 操作。在此过程中,Reactor 线程仅仅起到等待和通知的作用,并不真正进行 I/O 操作,并且这里的 I/O 接口仍然是同步 I/O 接口。这种模式就好比请人帮忙烧开水,但是泡茶的过程还是自己亲自来。

Reactor 模式中,内存 buffer 一直在用户的控制下,在进行真正的 I/O 操作发生前,随时可以cancel正在等待的请求,因此Reactor 模式中不存在内存data race的情况,接口也就顺势使用了 reference,及借用机制。接口示例如下:


#![allow(unused)]
fn main() {
fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> ImplFuture<'a, Result<usize>>
}

io_uring Rust 底层封装

io_uring 的官方库只有 C 语言版本及 liburing,因此 Rust 异步封装之前必须有一套可用的 Rust 底层封装。这一层封装大家有不同的选择:有的选择自己从头实现,如 tokio 的 io-uring;我们的选择则是复用 liburing,先进行一层binding,然后在进行一层面向对象的封装,抽象出 SQ,CQ 和 Register 等,这一层抽象借鉴的 tokio 的 io-uring。前一种方法对实现具有更强的控制力,后一种方法则降低了维护成本。无论哪一种方法,最终的目的和效果是一样的——搭建脚手架,为异步封装扫平障碍。

io_uring 异步模式

io_uring 和 Rust 现有异步模型不同,该异步操作是由操作系统完成的,并不需要用户线程参与,该工作方式非常适合 Proactor 模式。下图为 Proactor 模式示意图:

proactor

根据图中所示,异步操作由 Proactor 线程完成,更准确说是由 Proactor 线程提交 I/O 任务给内核,等内核完成了 I/O 操作再讲结果通知给 Worker 线程。和 Reactor 模式相比,Proactor 为用户完成了更多的任务,这就好比请人帮忙把烧水和泡茶的活一起干了,直接喝茶就行。

io_uring Proactor 设计

在决定了采用 Proactor 模式来完成 io_uring 之后,我们还需要考虑 io_uring 自己的特性。io_uring 在设计的时候只考虑了一个线程一个io_uring实例,因此无论是内核接口还是libfuse的封装接口都不易实现多线程并发访问。基于这个考虑,有两个方法解决,第一个方法为 io_uring 操作上锁,也就是间接的将多线程并发操作串行化;第二个方法为只用单线程进行 io_uring 操作,其他任务给该线程提交任务。ringbahn 采用了第一种方法,我们采取了第二种方法。第二种方法的好处在于,可以将连续的多个 I/O 操作一次提交,在繁忙的系统中能够提高性能。

下图为我们的架构设计:

architect

在我们的设计中,所有的Worker Task通过全局的channel向 Submitter Task 提交 I/O 任务,当没有 I/O 任务时Submitter Task 会在等待在该 channel 上,而当请求繁忙时 Submitter Task 会打包多个任务一次性提交。Completer Thread 会收取 ring 上完成的任务,并且唤醒等待这些任务的 Worker Task。

单个 io_uring 实例同时处理的 I/O 请求数目是有上限的,如果完成的任务不及时接收则会出现丢失的情况,因此我们维护了一个全局计数器来统计正在被处理的 I/O 请求数目,当数目达到上限时则会挂起 Worker Task 让其等待。

内存安全

Rust 语言的内存安全要求不能出现 data race 和 use after free 的情况,而 io_uring 的使用模型则存在现在的风险。操作系统会异步地操作内存 buffer,这块 buffer 如果被用户同步操作则会出现 data race 的情况。因此被Proactor 线程占用的内存必须独占,否则任何被取消的 I/O 操作都会导致内存被用户态同时使用。

为了达到上述目的,Reactor 的基于 引用(reference) 的接口不能被使用,需要采用新的接口,如下所示:


#![allow(unused)]
fn main() {
pub async fn read(
    fd: RawFd,
    buffer: Vec<u8>,
    count: usize,
    offset: isize,
) -> (io::Result<usize>, (RawFd, Vec<u8>)) 
}

在该接口中用户会在 I/O 操作过程中交出 buffer 所有权,在任务完成时返还 buffer 所有权。

总结

现有的 Rust 异步 I/O 模型(Reactor)其实分为两步,第一步由操作系统通知用户哪些 fd 可以使用,第二步再由用户完成 I/O 操作,操作系统仅仅负责通知,真正干活的还是用户自己。区别于该 I/O 模型,io_uring 的 I/O 模型为 Proactor,所有的异步 I/O 请求都是操作系统来完成,用户仅仅需要发送请求和接收结果。

我们 DatenLord 团队在充分考虑了 io_uring 特点和 Rust 语言的需求后实现了一个 io_uring 的异步库。这个库同时照顾到性能和易用性,方便用户迁移现有代码,同时该库不依赖于任何一部框架,可以和绝大多数已知的异步框架一起使用。此链接为该库的代码地址,欢迎大家提交 PR 和 issue,帮助进一步完善功能。

PingCAP | 使用 Rust FFI 心得

作者:骆迪安(AndyLok)/ 后期编辑:张汉东

本文是对 深圳 3.27 Rust Meetup 来自 骆迪安(AndyLok)分享的简单摘录, 对内容感兴趣可以直接观看视频


2

在 TiKV 里为什么需要 FFi ?

3

TiKV 是一个支持事务,也支持强一致性,低延迟(比HBase 延迟低十倍左右),分布式的数据存储。

4

整体架构如上图所示,每个 TiKV 是一个独立的 Server,多个 TiKV 节点用于处理分布式数据。客户端会通过 RPC 和 TiKV 进行通信。

5

客户端支持多种语言。但是客户端代码核心其实只有一个版本,就是 Rust 版本。其他语言是通过 FFi 来调用 Rust 版本的库,实现多语言客户端支持。

接下来看 Python 客户端。

12

为什么不直接使用 Python 实现 Client ?

14 20

有几个原因:

  1. 因为 TiKV Client 很复杂。如上图里面的结构所示,底层是和服务器沟通的 RPC,上面还有缓存,再往上是两种访问模式:RawKV 和 支持事务模块(要求强一致,用于OLTP场景,比如银行)。代码量现在包含 1.8w行代码。
  2. 正确性很难。现在有集成测试、单元测试和一致性测试、混沌测试。这些只能跑在 Rust 相关组件上。
  3. 需要做到代码只需编写一次,就可以应用到其他语言。

所以就使用了 FFi 。

21

那么现在 TiKV Client 的架构是怎么样的?

22

最左边是 Rust Client 的 API,和它对比的是 Cpp Client 和 Python Client。

25

现在是一个洋葱结构。最里面红色部分是那1.8w的Rust 代码。

Cxx 是一个安全沟通 Rust 和 Cpp 的库。在 Rust 这边会封装一个安全的 C-ABI,然后在 Cpp 那边把 C-ABI 转换为 Cpp ,就形成一个胶水层。 最后再把这个胶水层封装为一个让用户使用的库。

这两种颜色实际对应两个静态库,然后把它们链接在一起使用。

其他语言客户端和这个 Cpp 客户端结构完全一致。唯一的区别是 Java 和 Nodejs 客户端需要动态库。

26

现在展示 FFi 的一些细节。

30

如图,如果想在 Cpp 里调用 左边的 impl.rs 中的 foo 函数,该怎么做?

我们可以引入 cxx,该库提供了一个过程宏 #[cxx::bridge]来帮助我们自动生成 Cpp 里的头文件。

31

然后编译左边 Rust 代码,会生成一个静态库。

32

然后使用 Cpp 编译工具把 Cpp 代码编译得到.o文件,然后和Rust编译生成的静态库合在一起,最终生成一个新的静态库。这样 Cpp 那边就可以调用 Rust 代码了。

同理,Cpp Client 的机制是类似的。

接下来看一下 Cxx 在做 FFi 的时候考虑的重点。

34

FFi 比较常见的问题就是内存分配问题。比如,想把Rust 里的 String 传递给 Cpp 来使用。如果 Cpp 去析构这个 String,那么就会引发段错误。因为 Cpp 不知道这个String是哪来的,它会当作自己的。

36

如何解决这个问题?

Rust 这边只传一个引用到 Cpp,Cpp 这边调用 Rust 这边定义的析构函数 Callback。

反过来也一样。谁分配谁负责释放,通过callback函数调用。

Cxx 这个库会帮你来做这些。

38

Cxx 库还定义了其他类型。

39

这是从 Cpp Client 里提取出的代码。

首先,在 Rust 这边定义了一个事务 Get 的请求。这个请求就是简单的通过 Key 获取一个 Value 。它返回的值是一个 Optional 的。很有可能这个值是不存在的,所以这里定义了一个 Struct ,添加一个字段来表明这个值是否存在。

41

然后 Cxx 会自动生成 Cpp 的头文件,对应关系如图。

47

用户可以这样去使用。

Cpp 里的 transaction_get函数会调用 Rust里对应的函数。

Cpp 这边会把一个 String 的引用传过去,并没有所有权。此时是一个 cxx::CxxString 引用。

然后 Rust 这边会通过transaction.inner.get(key)调用底层 Rust 实现,然后返回一个 Rust String(Vec),传给 Cpp 。

经过 Cxx 的包装,开发起来还是极方便的,很爽。

48

那么 Python Client 怎么样呢?

49

通过 PyO3 库来帮助轻松绑定 Rust 库。

编者按:PyO3,命名形式像化学表达式,取自「氧化」之意。这和 Rust 有「锈」这层意思有关。

基本原理和 Cpp Client 类似。

53

同样是一个 CET 请求。但是这里 Rust 返回的是一个 异步Future 对象,但是 Python 的异步模型是 Python Coroutine。所以需要一个 PyCoroutine的类把 Future 包起来。

57

Python 的 Goroutine 调用 await 之后会返回 Awaitable,然后再调用 awaitable的iter得到迭代器,然后进行迭代,直到异步任务完成。

Python 里只要调用方法符合这三步,因为 Duck Type 的设计,就都可以用 Pyhton 都 await 来调用。

所以就可以模仿这个过程。

61

第一步,在生成 Coroutine 的时候,要生成一个配对的 Future。但不去执行它。右边 PyGoroutine 也只是一个包装。它们俩通过 Channel 沟通,PyGoroutine 这边拿着 Reciver。

然后,当 Python 那边调用一个 await 的时候,就会通知 Rust 这边,把 Future 放到 tokio 运行时来跑,真正开始干活。

当 Python 调用 iter 的时候,则什么都不干。因为对于这个模型来说,iter没有用。

当 Python 调用 next 的时候,则会尝试从 channel Reciver 中拿结果。假如 Rust 里 poll 没有完成,那么 Reciver 里就没有值,但是 Python 的 next 会一直轮询,直到有值。

62

那么 TiKV Client 未来准备做什么?

65

目前 Java Client 和 Node Client 是正在测试阶段。

Java Client 会调用 JNI ,而 JNI 可能会给 FFI 带来不确定的因素,比如 延时/ GC。

虽然都套用同一套 Rust 实现,但是还需要测试去保证正确性。然后准备在 Client 之上再构建一套统一的测试代码,这样就可以达到 核心代码是同一套,测试代码是同一套。


作者简介:

骆迪安,目前为 PingCAP 分布式存储工程师,负责 TiKV 事务模块的开发,同时也负责 TiKV 社区管理和运营。对 Rust 社区和函数式编程感兴趣。

Netwarp | 从 Libp2p-rs 与 IPFS

作者:刀哥@netwarp / 编辑: 张汉东

本文是对 深圳 3.27 Rust Meetup 来自 NetWarp 的刀哥分享的简单摘录, 对内容感兴趣可以直接观看视频


1

Libp2p 主要为 IPFS 和 Filecoin 提供服务,是一个协议集。

Libp2p 分为网络层、路由层和交换层。网络层核心概念是 PeerId(节点标识),路由层负责发现 PeerId的地址,交换层则是其上的各种应用。

Libp2p 被认为是一种基础设施,不仅仅是为 IPFS 使用,还可以给其他应用使用。

2

概念介绍:

  • IPFS(InterPlanetary File System,星际文件系统): 旨在创建持久且分布式存储和共享文件的网络传输协议。它是一种内容可寻址的对等超媒体分发协议。在IPFS网络中的节点将构成一个分布式文件系统。
  • Filecoin:一个云存储市场、运行加密货币协议的区块链系统。
  • IPFS提供分布式内容存储; Filecoin是激励层。

IPFS 的愿景看上去很有野心,但实际上它们有些问题,后续会深入讲一下原因。

Filecoin 到底是不是激励层,我认为这种提法不是很准确,后面会稍微提到一点。

3

今天主要话题是从Libp2p 开始,先来看一下 Libp2p 的一些现有实现。

  • Go-libp2p: 比较成熟,功能完整。是ipfs/file-coin项目的基石。
  • Rust-libp2p
    • ParityTech 开源的rust实现,在polkadot和substrate项目中
    • 泛型过分抽象,晦涩难懂
    • 基于 poll 方式的状态机嵌套,代码繁琐
  • 其他

Go-libp2p,我们做过深入的代码研究,我认为它们的实现是非常完整的,各种功能很全,代码质量非常好。

我们做的工作和 Rust-libp2p 相关的,这个代码已经使用在波卡等项目中。

但是这个 Rust 代码实现的非常复杂,泛型参数使用的非常非常多,抽象层次很难理解。比如基础网络部分有十个泛型参数。这并不是智商的问题,而是耐心的问题。如果你想把它搞清楚会很花时间。

4

而且这份代码在几年前就开始做了,代码里还没有使用 aysnc/await 现在的语法,所以比较旧。

Rust-Lip2p 的作者也承认代码非常复杂,但这个也是有原因的。所以,为了简化这个实现,我们重新做了 libp2p-rs,当然也重用了 Rust-libp2p 的一些实现,但最重要的是使用了现在的 async/await 的语法。

基本设计原则 1

我们在实现 Libp2p-rs 的时候,遵循了一些原则:

  1. 不使用 Unsafe。Unsafe 写不好,会有很大的风险。
    • 底层网络功能由异步运行库提供
    • Libp2p 的应用逻辑暂时看不到有 Unsafe 的必要
  2. 除非绝对必要,不手工实现任何 Future。因为 Pin 这个概念比较难以理解,比如 Pin 在栈上和 Pin 在堆上有什么区别。
    • Async 帮助实现 GenFuture
  3. 尽可能不使用 future combinator书写代码
    • 代码逻辑更简单易读
    • Combinator等效于await: a.and_then(b) ➔ a.await; b.await;
  4. 尽可能减少泛型参数以及Trait关联参数的使用
    • 代码可读性问题
    • 参数的限定极为复杂
    • 基于静态派发的多态,使用上诸多不变(参见rust-libp2p 中 SelectUpgrade<A, B>。泛型参数需要具体化,实现同一Trait的不同类型具体化后是不同的数据类型。)

基本设计原则2:

  • 尽可能使用<dyn Trait>动态派发实现多态,避免写泛型参数
    • 典型例子:Swarm子流支持同种协议,协议需要实现 ProtocolHandler Trait,通过 tait object 动态派发
    • 代码篇幅减少,易于理解
  • 尽量推迟引入异步运行库
    • Libp2p-core部分 不引入 运行库的依赖
      • Yamux/Mplex需要实现任务循环,作为一个BoxFuture暴露至Swarm,由Swarm调用 task::spwan()来执行
      • Wstransport, TcpTransport 必须使用task与socket。通过 Transport trait 的封装,不必放入 core
    • 在 Swarm以及各种协议的实现正式引入运行库,使用任务/协程完成连接与子流的管理

基本设计原则3 :

  • 减少 Mutex<>之类同步原语的使用
    • 使用不当容易造成协程死锁
    • 使用mpsc::channel,通过消息机制解决所有权问题
    • Swarm功能主体运行在一个协程任务上下文,通过channel接收内部或外部事件
    • 原则上,处理消息不做耗时操作,不可以 .await

基本设计原则4 :

  • 通过 async-trait加持,在 Trait 中定义 async 方法
    • Trait 实现多态,支持异步方法的Trait可提供更为强大的抽象能力

Rust 异步目前还不太成熟,还不支持 async trait,只能使用 async-trait 库来达成目的。

而且目前 Rust 还不支持 async drop。目前我们的解决办法是实现了一个 引用计数器 GC。

9

编者按:

4.23号 libp2p-rs 已经发布了 0.3 版本,使用 AsyncRead & Asyncwrite来替换 ReadEx & WriteEx & SplitEx;简化了 Kad/DHT 的实现逻辑。

详细也可以看这里: libp2p-rs v0.3.0 版本介绍

11 12 13 14 15


作者介绍:

刀哥,互联网老司机,在 NetWarp 负责分布式存储。

Google | 在 Android 平台使用 Rust

翻译:budshome / 编辑:张汉东

原文:https://security.googleblog.com/2021/04/rust-in-android-platform.html

译文转载自:https://blog.budshome.com/budshome/android-tuan-dui-xuan-bu-android-kai-yuan-xiang-mu-(aosp),yi-zhi-chi-rust-yu-yan-lai-kai-fa-android-xi-tong-ben-shen


对于 Android 来说,如果代码是用 C/C++ 编写的,并且在解析不可信的输入,那么它应该包含在一个严格受约束和特殊的沙箱中。但沙盒的开销昂贵:需要引入新进程,消耗额外的开销,并且由于 IPC 和额外的内存使用,而引入了延迟机制。沙箱技术,并不能消除代码中的漏洞,它的效率,会随着高 bug 密度而降低,从而允许攻击者将多个漏洞链接在一起。像 Rust 这样的内存安全语言,通过两种方式帮助 Android 克服这些限制:降低了代码中 bug 的密度,从而提高了当前沙盒的有效性;减少了 Android 的沙箱技术需求,允许引入更安全、资源更轻的新功能。

Android 平台中,代码的正确性,是每个版本 Android 系统的安全性、稳定性,及其质量的重中之重。C/C++ 语言中的内存安全漏洞,仍然是最难解决的错误来源。我们投入了大量的精力和资源来检测、修复和缓解这类 bug,这些努力有效地防止了大量 bug 进入 Android 系统。然而,尽管做出了这些努力,内存安全漏洞仍然是稳定性问题的主要原因。并且,在 Android 系统高严重性的安全漏洞中,其始终占据大约 70% 的比例

除了正在进行的即将进行的改进内存错误检测的工作之外。首当其冲地,我们正在加大力度防止它们。内存安全类编程语言,是防止内存错误的最经济有效方法。除了像 Kotlin 和 Java 这样的内存安全语言之外,我们很高兴地宣布:Android 开源项目(AOSP),现在支持 Rust 编程语言来开发 Android 操作系统本身。

系统级编程

Java 和 Kotlin 等受监管/托管类语言,是 Android 应用开发的最佳选择。这些语言是为易于使用、可移植性,以及安全性而设计的。Android 运行时(ART),代表开发者管理内存。Android 操作系统广泛使用 Java,有效地保护了大部分 Android 平台不受内存缺陷的影响。不幸的是,对于操作系统的底层,Java 和 Kotlin 不是一个选项。

较低级别的操作系统,需要系统级编程语言,如 C、C++,以及 Rust。这些语言的设计目标是控制性和可预测性。它们提供对底层系统资源和硬件资源的访问。它们占用资源较少,并且具有更可预测的性能特征。

对于 C/C++,开发人员需要负责管理内存生命周期。不幸的是,这样做很容易出错,特别是在复杂的多线程代码中。

沙箱技术(sandboxing)的极限

C/C++ 语言,不提供相同的安全保证,需要强大的手动隔离。所有 Android 进程,都是基于沙箱技术(sandboxing)的,我们遵循 规则 2(译注:是指 Android 开发中关于 App 沙箱的规则限制,下同),以决定功能是否需要额外的隔离和剥离。规则 2 很简单:给定三个选项,开发人员只能选择三个选项中的两个。

对于 Android 来说,这意味着:如果代码是用 C/C++ 编写的,并且在解析不可信的输入,那么它应该包含在一个严格受约束和特殊的沙箱中。虽然遵守规则 2,在降低安全漏洞的严重性和可访问性方面是有效的,但它确实有局限性。沙盒的开销昂贵:需要引入新进程,消耗额外的开销,并且由于 IPC 和额外的内存使用,而引入了延迟机制。沙箱技术,并不能消除代码中的漏洞,它的效率,会随着高 bug 密度而降低,从而允许攻击者将多个漏洞链接在一起。

像 Rust 这样的内存安全语言,通过两种方式帮助我们克服这些限制:

  • 降低了代码中 bug 的密度,从而提高了当前沙盒的有效性。
  • 减少了我们的沙箱技术需求,允许引入更安全、资源更轻的新功能。

那么,现有的 C++ 呢?

当然,引入一种新的编程语言,并不能解决现有 C/C++ 代码中的问题。即使我们重新调整了 Android 团队中每个软件工程师的工作方向,重写数千万行代码,也是很难解决的。

上文中,对 Android 平台中内存安全漏洞的历史分析(从它们第一次引入时,就已经测量过),表明了为什么我们的内存安全语言工作,最关注的是新开发,而不是重写成熟的 C/C++ 代码。我们的大多数内存错误都发生在新的,或最近修改的代码中,大约 50% 的错误发生在不到一年的时间里。

比较稀疏的老旧内存错误,可能会让一些人感到惊讶,但我们发现旧代码并不是我们最迫切需要改进的地方。随着时间的推移,软件缺陷会被发现并修复,因此我们预计正在维护的,但未积极开发的代码中,其缺陷数量会随着时间的推移而减少。正如减少 bug 的数量和密度,可以提高沙盒的有效性一样,它也可以提高 bug 检测的有效性。

检测的局限性

通过健壮的测试、清理(sanitization),以及模糊测试(fuzzing ),进行 bug 检测,对于提高所有软件(包括用 Rust 编写的软件)的质量和正确性至关重要。最有效的内存安全检测技术,其一个关键限制是:为了检测到错误状态,必须在代码中实际触发错误状态。即使在具有出色的 test/fuzz 覆盖的代码库中,这也会导致许多错误未被发现。

另一个限制是,bug 检测比 bug 修复扩展得更快。在一些项目中,检测到的 bug 并不总是得到修复。错误修复是一个漫长而昂贵的过程。

这些步骤都很昂贵,缺少其中任何一个,都可能导致某些或所有用户无法对 bug 进行调度。对于复杂的 C/C++ 代码库,通常只有少数人能够开发和检查修复,即使花费大量的精力来修复错误,有时修复后也不完全正确

当错误相对较少时,bug 检测最有效,并且可以给予它们紧急性和优先级。我们从改进 bug 检测中,获益的能力要求我们优先考虑:防止引入新的 bug。

优先性任务

Rust 对一系列语言特性,进行了现代化的设计和开发,从而提高了代码的正确性:

  • 内存安全——通过编译器和运行时检查的组合,以强制执行内存安全。
  • 数据并行——防止数据争用。这使得开发者能够轻松地编写高效、线程安全的代码,这也催生了 “Rust 无畏并行(Fearless Concurrency)”的口号。
  • 更具表现力的类型系统——有助于防止逻辑编程错误(例如:newtype 包装、包含内容的枚举变量等)。
  • 默认情况下,引用和变量在是不可变的——帮助开发人员遵循最小特权的安全原则,仅当他们真正希望引用或变量可变时,才将其标记为可变。尽管 C++ 有一定的特点,但它往往不经常使用,也不一致。相比之下,Rust 编译器通过为从不突变的可变值提供警告,来帮助避免不必要的可变注释。
  • 在标准库中,有更好的错误处理方式——在结果中,包装可能失败的调用,这会导致编译器要求用户检查失败原因,甚至是没有返回所需值的函数。这可以防止诸如 Rage Against the Cage 漏洞之类的 bug,该漏洞即是由未处理的错误导致的。
  • 初始化赋值——要求在使用前,初始化所有变量。未初始化的内存漏洞一直是 Android 平台上 3-5% 比例的安全漏洞的根本原因。在 Android 11 中,我们开始在 C/C++ 中自动初始化内存,以减少这个问题。但是,初始化为零并不总是安全的,特别是对于返回值这样的情况,这可能成为错误处理的新来源。Rust 要求每个变量在使用前,都初始化为其类型的合法成员,避免了无意中初始化为不安全值的问题。类似于 C/C++ 的编译器 Clang,Rust 编译器知道初始化要求,并且避免了多次初始化的任何潜在性能开销。
  • 更安全的整数处理——默认情况下,对 Rust 调试和构建,启用溢位清理(overflow sanitization),鼓励程序员指定一个 wrapping_add(如果他们真的希望溢位计算),或 saturating_add(如果他们不希望溢位计算)。我们打算为 Android 平台中的所有构建,都启用溢位清理。此外,所有整数类型转换,都是显式强制转换:当分配给变量或尝试对其他类型执行算术运算时,开发人员不能在函数调用期间,意外地强制转换。

未来计划

为 Android 平台添加一种新的编程语言,是一项艰巨的任务。有需要维护的工具链,以及依赖项。也必须有更新的测试基础设施和工具,以及需要培训的开发人员。在过去的 18 个月里,我们一直在为 Android 开源项目添加 Rust 支持。我们有几个早期采用者项目,我们将在未来几个月内分享。将其扩展到更多的操作系统,是一个多年的项目。请继续关注,我们将在这个博客上发布更多更新。

MeiliSearch | 开源搜索引擎

  • 原文作者:Gints Dreimanis
  • 翻译/编辑:张汉东

原文:https://serokell.io/blog/rust-in-production-meilisearch


在这次采访中,我与MeiliSearch的联合创始人兼首席运营官 Thomas Payet 进行了交谈。 MeiliSearch 是一个开源搜索引擎,在撰写本文时,它是 GitHub 上最受瞩目的Rust项目之一。我们谈论MeiliSearch,以及 Rust 及其生态系统如何帮助他们创建它。

进一步阅读以了解他们在 Rust 方面的经验,并发现启动自己的开源项目的技巧。

能否向我们介绍一下您的公司以及您的角色?

MeiliSearch 是一个开源搜索 API。由于存在计算机,因此搜索一直是一个问题。如今,大多数人习惯使用Google或Amazon来获得出色的搜索结果,但是如果您没有这些大型高科技公司的能力,要提供这样的用户体验可能会面临挑战。在MeiliSearch,我们正在开发一种开源技术,我们认为该技术可以帮助许多开发人员立即提供出色的搜索体验。

我是 MeiliSearch 背后的公司 Meili 的联合创始人之一(公司三岁)。我的角色是使团队保持同步,以确保每个人都可以在我们公司内尽最大努力,同时遵循相同的目标。我们大多数人都是开发人员,因为我可能是技术水平较低的人之一,所以我是第一个改变角色以支持组织发展的人之一。

MeiliSearch代码库的哪些部分是用Rust编写的?

MeiliSearch 是一个由引擎以及针对不同语言和框架的多个SDK和库组成的项目,以简化核心引擎的使用。该引擎是100%用Rust编码的,并且我们与社区一起以8种不同的语言维护了20多个集成。

我们试图不成为任何特定技术的倡导者;在内部,我们将只为工作选择最佳工具。 :)

您是如何决定在项目中使用Rust的? Rust的任何优势(例如速度或类型/内存安全性)是否影响选择?

当然,速度和安全性是Rust的主要论点。

搜索引擎的两个关键要素是性能和相关性。只要您的算法和逻辑都不错,就可以使用任何语言进行关联。就今天的性能而言,您可以选择C ++,Rust或Go。

在以前的工作中,我们在Go中构建了一个搜索引擎,并且在设计面向性能的程序时,我们很快了解了垃圾收集语言的弊端。在运行基准测试时,我们发现了一些变慢的情况,然后我们很快意识到,正是常规的垃圾收集使整个系统变慢了。 ^^

对于C++,从长远来看,对于我们来说,维护C++代码库似乎更加困难。另外,自从第一个稳定版本发布以来,我们的CTO kero就一直是Rust的开发人员。

最后,我们从未做出使用Rust的“决定”,因为在我们看来这很明显,这就是我们完成这项工作所需要的工具!

Rust在哪里最适合使用,在堆栈中何处不足?

Rust生态系统是现代的,在实际的GitHub贡献流程中,它很容易用作日常语言。我们对使用Rust进行构建感到非常有信心,因为它消除了其他语言可能带来的许多不确定性。我们可以专注于我们面临的其他挑战,例如设计和性能。

强大的CS研究人员社区实现了底层库,以实现我们经常会从中受益的最新技术。另一方面,我们进行了多次迭代以找到适合的HTTP框架,最终选择了actix-web,这最适合我们的用例。

您发现有什么特别的Rust库在开发过程中非常有用并且想提及吗?

有很多,但这里只推荐三个:

  • 来自BurntSushi的fst。它使我们可以在单词词典中进行存储和搜索,并且性能非常好。
  • Levenshtein-automaton-来自fulmicoton。我们使用它从具有特定Levenshtein规则的字典中快速获取单词。
  • roaring-rs,我们用于优化设置操作。

用Rust开发MeiliSearch时最大的挑战是什么?

在MeiliSearch路线图中,我们希望提供一些分布式系统功能,例如高可用性和分片。

我们非常嫉妒Hashicorp的Go Raft库。我希望在Rust中也有类似的东西,但到目前为止,我们还没有找到这样的Crate可以帮助我们使用共识算法轻松构建MeiliSearch集群。

自从两年半前我们开始该项目以来,我们就已经想到了这一点,并且我们正在尝试集成不同的Raft实现库,但是我们发现还没有一个能够满足我们的期望。这是我们在路线图中面临的最大挑战之一。

我在MeiliSearch GitHub个人资料中看到了一个名为milli的项目。这是MeiliSearch的未来吗?您想谈谈吗?

当然。 ^^

随着我们不断壮大的团队和项目,我们正在不断学习。去年,我们意识到,为了简化外部贡献者的入门工作和开发新功能,我们需要重新架构整个代码库,以使其更具可读性和可维护性。

我们的首席技术官kero在大约一年前(2020年8月)开始研究这种新的引擎迭代,我们计划在未来几个月内发布它。

我们在内部面临一些困难,难以在新的引擎迭代上确定工作的优先级,同时又要保持MeiliSearch的最新稳定版本。不过,我们迫不及待要正式发布它。性能提升令人难以置信!

Milli存储库托管核心引擎库的第4次迭代,但是使用该库使之成为可用二进制文件的代码将在MeiliSearch存储库中发布。

据我了解,MeiliSearch是完全开源的。您如何支持它?

我们在担任路易威登的承包商的同时启动了该项目。在与他们合作的同时,我们开发了大多数项目。在某个时候,我们意识到我们必须从风投那里筹集资金,以专注于开源项目。

我们认为,可以通过围绕MeiliSearch开展业务来使MeiliSearch可持续发展,该业务将以云产品(SaaS)的形式出售MeiliSearch并根据业务许可向具有特定需求的公司出售特定功能。

您对想要启动自己的开源Rust项目(可以是技术性的也可以是非技术性的)的人有什么建议?

开发和维护开源项目可以很快成为整个团队的全职工作。我建议任何想要开始维护开源项目以寻找合作伙伴的人,因为还有很多其他事情要做:您将不得不编写文档,文稿指南,回答问题并提出要求!

与您可能永远都不会见面的陌生人一起工作真是太好了,但我担心如果您一个人做,它很快就会变得不知所措。

学习资源

编辑:张汉东


Rust Cheat Sheet 速查

内容包含:The Book BK, Rust by Example EX, Std Docs STD, Nomicon NOM, Reference REF。

https://cheats.rs/

微软出 Rust 新手教程了

微软给想学习 Rust 的朋友提供了一份新手教程, 手把手带你走出 Rust 第一步.

https://docs.microsoft.com/en-us/learn/paths/rust-first-steps/

Rust 标准库的 trait 引导大全

Rust 标准库提供了大量的 Trait,每个 Trait 的功能是什么?怎样区分功能有点类似的 Trait 的使用场景?这篇博客非常详细的介绍了标准库中各种内置 Trait,非常值得阅读。

https://github.com/pretzelhammer/rust-blog/blob/master/posts/tour-of-rusts-standard-library-traits.md

面向 Rust 初学者的错误处理指南

https://dev.to/seanchen1991/a-beginner-s-guide-to-handling-errors-in-rust-40k2

Easy Rust - learn to program in Rust with simple English

Easy Rust 的作者用简单的英语词汇录制了一系列 Rust 教程的视频,发布在了 YouTube。每集 5 到 10 分钟,总共 186 集,23 个小时。

https://www.youtube.com/playlist?list=PLfllocyHVgsRwLkTAhG0E-2QxCf-ozBkk

Rust中的异步流(Part 1)- Future,缓冲和难理解的编译错误

https://gendignoux.com/blog/2021/04/01/rust-async-streams-futures-part1.html

Kafka和Rust入门 Part 1

https://dev.to/abhirockzz/getting-started-with-kafka-and-rust-part-1-4hkb

如何构建最精简的 Rust Docker 镜像

First-Class I/O

即执行 I/O 的函数,可作为参数或返回值在程序中传递,如 File。

纯函数式编程是一种巧妙的技巧,用以证明你无需可变即可编码,而 Rust 则是一种更加巧妙的技巧,用以表明你可以进行可变。

First-Class I/O是广泛应用的有用概念,例如面向能力的安全性如何帮助实现无共享链接。 增量应用也很有用,例如io-stream 或cap-std 库如何帮助程序的各个部分高效地和惯用地进行协作。

https://blog.sunfishcode.online/first-class-io/

盘点使用 Safe Rust 实现的 标记 GC

本文盘点了近几年基于 Safe Rust 实现的 跟踪/标记类 GC 库。

https://manishearth.github.io/blog/2021/04/05/a-tour-of-safe-tracing-gc-designs-in-rust/

Rust 机器学习列表

有点 awesome-rust-ml 的意思。

https://github.com/e-tony/best-of-ml-rust

使用 Rust + Lunatic + WebAssembly 构建 TelNet Chat Server

https://www.hackernoon.com/how-i-used-rust-lunatic-to-build-a-telnet-chat-server-with-webassembly-rb3l33cg

将 Actor 和 Async/Await 连接起来 Part 1

该文作者受官方异步基础组愿景文档影响,也写了一篇关于如何将他实现的 Stakker Actor 库和异步连接起来的一些思考。

Stakker 被设计为分层放置在用户喜欢使用的任何事件循环之上。 它旨在最大程度地利用Rust的编译时检查和优化。

【系列】使用 Warp 进行 REST Api 开发

https://dev.to/rogertorres/series/12179

Rust 库的错误管理

如何提供易于理解,易于管理且足够具体的错误,以使用户能够易于处理?文章里给出一种方案。

http://www.tglman.com/posts/rust_lib_error_management.html

libp2p 教程: 使用 Rust 构建一个点对点应用

在这个教程里,作者会使用 libp2p 来构建一个简单的 peer-to-peer 菜谱应用.

在这个菜谱应用会有以下基本功能:

  • 创建菜谱.
  • 发布菜谱.
  • 列出本地菜谱.
  • 列出网络上发现的其他菜谱.
  • 列出一个节点上的菜谱.
  • 列出所有已知节点上所有的菜谱.

https://blog.logrocket.com/libp2p-tutorial-build-a-peer-to-peer-app-in-rust/

编写 *-sys crate 帮助 Rust 程序使用 C 库

这篇文章简要解释了什么是 *-sys crate ,以及如何利用构建脚本以一次性完成 *-sys crate 的创建。

https://kornel.ski/rust-sys-crate

为什么Rust字符串看起来很难

对于常规语言来说,字符串的理解对于大多数人来说不是难事。当新手开始接触Rust时,字符串&str,String会让他们丈二和尚摸不着头脑。本文详细介绍了Rust中字符串使用困难的原因。

https://www.brandons.me/blog/why-rust-strings-seem-hard

用Rust构建小型滴灌系统

作者用Rust编写了一个给绿植的灌溉功能的小型系统。非常有意思的是,用的还是国内厂商的电磁阀。如果你对树莓派、硬件电路感兴趣的话,不要错过它。

https://github.com/kitallis/WAP

嵌入式系统中使用 std

估计很多有人也有这个疑问, 我们能在嵌入式系统编程中使用标准库 std 吗?

作者同样有这个疑问, 但是他找到了自己的答案, 有同样困扰的小伙伴可以参考一下.

http://blog.timhutt.co.uk/std-embedded-rust/index.html

[Rust] Github Actions 最好的实践

在一个相当大的项目中,作者用 GitHub Actions 来完成一些重复性的工作,主要的两个改进就是:合并多个任务,使用 sccahe 提高测试速度

  • GitHub workflows 矩阵
  • 包含和排除规则
  • 使用 sccahe 优化Rust编译速度
  • 检验 sccache 结果

https://www.fluvio.io/blog/2021/04/github-actions-best-practices/

Rust 实现的一款有意思的迷宫游戏

挺好玩的 。。。

基于 Rust 实现,并可以导出 wasm

https://ldjam.com/events/ludum-dare/48/$242669

你可以关注 https://github.com/Healthire ,他不止用 Rust 做了这一个游戏

Rust 中如何使用 gRPC

本文介绍了 如何在Rust中创建gRPC服务器和客户端。 出于可见性目的,客户端还将是Telegram机器人。

https://romankudryashov.com/blog/2021/04/grpc-rust/

一个 actix-web 中使用 tokio-tracing 的示例

https://github.com/LukeMathWalker/tracing-actix-web

Rust 中可能实现 移动构造函数(Move Constructors) 吗?

移动构造函数(Move Constructors) 是 Cpp 11 引入的一个特性,它允许开发者通过拿其他已存在对象来构造新的对象,从而避免深拷贝导致的效率问题。

如果 Rust 里也支持,那么也可以同样减少深拷贝效率问题,实现就地构造对象。 本文作者探讨了如何安全地实现这个特性,作者为此也实现了一个库 moveit

moveit 中充分利用了 Pin/Unpin 来实现该特性,除了 Rust 还支持 Cpp 的移动构造函数,以备 FFi 时候方便。虽然实现了 Unsafe trait ,但作者指明了 Safety 边界。

【译】Rust 的 Result 类型基础

  • 原文链接:https://medium.com/@JoeKreydt/a-primer-on-rusts-result-type-66363cf18e6a
  • 原文作者:Joe Kreydt
  • 译者:suhanyujie
  • 后期整理:张汉东

Result 类型是 Rust 中处理错误的常用方法类型,它比较灵活;应该是非常灵活!

对于那些正在学 Rust 的人来讲,Result 可能不太直观,你可以通过阅读它的标准库文档来了解如何使用是个不错的方法。如果你想迫切的学会它,也是可以的,但如果你只是用它处理错误或者使用某个返回 Result 类型的函数(很多人都这样做),你可能体会不到它的妙处。

为了节省大家的时间,我打算使用英语来解释 Rust 的 Result 类型。

Result 是什么?

参考Rust 权威指南

“Result 表达的是错误的可能性。通常错误是用来解释某种任务执行失败的原因。”

用朴素的英语解释

Result 是一个函数返回的类型,它可以是 Ok,也可以是 Err。如果是 Ok,则表示函数按照预期执行完成。如果是 Err,则该函数出现了错误。

Result 用来做什么?

根据 Rust 权威指南

Result 类型是对计算过程中可能出现的结果的表示方式。按照惯例,如果一个结果是预期的 Ok,那么另一个结果则是意料之外的,即 Err

请再直观点

函数返回了值。这些值具有特定的数据类型。函数可以返回 Result 类型的结果。Result 类型根据函数是否按预期执行而变化。然后,程序员可以编写一些代码,如果函数按预期执行则返回 A,如果遇到异常,则返回 B。

不处理 Result,则产生异常

error[E0308]: mismatched types
  --> main.rs:20:26
   |
20 |     let my_number: f64 = my_string.trim().parse(); //.unwrap();
   |                          ^^^^^^^^^^^^^^^^^^^^^^^^ expected f64, found enum `std::result::Result`
   |
   = note: expected type `f64`
              found type `std::result::Result<_, _>`
error: aborting due to previous error
For more information about this error, try `rustc --explain E0308`.
compiler exit status 1

报错信息中关键的部分是,“expected f64, found enum.”,类似的场景中,可能还会有:

- “expected u32, found enum”
- “expected String, found enum”
- “expected [insert type here], found enum”

如果你得到一个类似上面的错误,那是因为需要你处理函数返回的 Result 类型数据

类型为 Error 的 Result 的程序

use std::io::{stdin, self, Write};
fn main(){
    let mut my_string = String::new();
    print!(“Enter a number: “);
    io::stdout().flush().unwrap();
    stdin().read_line(&mut my_string)
        .expect(“Did not enter a correct string”);
    let my_number: f64 = my_string.trim().parse();
    println!(“Yay! You entered a number. It was {:?}”, my_num);
}

在这个程序中,它提示用户输入一个数字。然后将输入作为字符串读入并存储下来。我们想要的是一个数值类型,不是 String,所以我们需要使用 parse() 函数将其转换为一个 64 位浮点数(f64)。

如果用户输入的是一个数字,那么 parse() 函数将其转换为 f64 没什么大问题。但我们仍然会得到一个错误。

发生错误是因为 parse() 函数不只是将 String 转换为数字并返回。相反,它接受字符串,将其转换为数字,然后返回 Result 类型。Result 类型需要被解包才能得到我们需要的数值。

用 Unwrap() 或 Expect() 修复错误

转换后的数字可以可以通过在 parse() 后面附加调用 unwrap() 函数将数字从 Result 中“解包”出来,类似于这样:


#![allow(unused)]
fn main() {
let my_number: f64 = my_string.trim().parse().unwrap();
}

unwrap() 函数可以看出 Result 中类型,可能是 Ok,也可能是 Err。如果 Result 中包裹的类型是 Ok,那么 unwrap() 则返回它的值。如果 Result 中的类型是 Errunwrap() 则会让程序崩溃。

你也可以用 expect() 函数像下方这样来处理 Result:


#![allow(unused)]
fn main() {
let my_number: f64 = my_string.trim().parse().expect(“Parse failed”);
}

expect() 的工作方式类似于 unwrap(),假如 Result 是 Errexpect() 将会使程序崩溃并且将其中的字符串内容 —— “Parse failed.”展示在标准输出中。

使用 unwrap() 和 expect() 的缺点

当我们使用 unwrap()expect() 函数时,如果遇到错误,程序会发生崩溃。如果错误发生的几率非常小,这也许可以容忍,但在某些情况下,错误发生的概率会比较大。

在上面的示例中,用户可能输入错误,输入的不是数值(可能是字母或者特殊符号)。我们并不想每次用户输入错误的内容程序就发生崩溃。相反,我们应该提示用户应该输入数字。这种场景下,Result 就非常有用,尤其是当它与一个模式匹配的表达式相结合的时候。

用匹配表达式修复错误

use std::io::{stdin, self, Write};
fn main(){
    let mut my_string = String::new();
    print!(“Enter a number: “);
    io::stdout().flush().unwrap();
    let my_num = loop {
        my_string.clear();
        stdin().read_line(&mut my_string)
            .expect(“Did not enter a correct string”);
        match my_string.trim().parse::<f64>() {
            Ok(_s) => break _s,
            Err(_err) => println!(“Try again. Enter a number.”)
        }
    };
    println!(“You entered {:?}”, my_num);
}

如果你问我怎么实现,上面就是示例代码!

前面提到的不优雅的实现和优雅的实现方式的不同点是在循环体内部。我们可以分解一下。

代码分析

在 loop 之前,我们提示用户输入一个数字。接着我们声明 my_num。

我们将循环体中返回的值(用户的输入,它将从字符串转换为数字)赋给 my_num:


#![allow(unused)]
fn main() {
let my_num = loop {
}

在循环体中,我们阻塞等待用户输入。然后接收用户的输入,在这个过程中我们有三个问题要解决。

  • 1.我们需要确定用户输入的是数字而非其他的字符,一个词或者一个字母。
  • 2.Rust 中的 read_line() 函数能够以字符串的类型拿到用户的输入。我们需要将其转换为浮点数。
  • 3.如果用户没有输入数字,我们需要清理变量,并提示和等待用户再次输入。

在第三部分问题(清理 my_string 变量)在循环体内的第一行就已经实现了:


#![allow(unused)]
fn main() {
my_string.clear();
}

下一步,我们接收用户的输入:


#![allow(unused)]
fn main() {
stdin().read_line(&mut my_string)
    .expect(“Did not enter a correct string”);
}

read_line() 函数返回一个 Result 类型。我们使用 expect() 函数处理它。在这种情形下是完全没问题的,因为 read_line() 出错的几率非常小。用户通常只能在终端输入一个字符串,而这正是 read_line() 所需要处理的。

通过 read_line() 把用户输入的字符串返回并存在 my_string 变量中。

重要部分

现在我们已经将输入的字符串存在 my_string 中,我们需要将其转换为浮点数。使用 parse() 函数可以实现,然后将浮点数结果返回。所以我们有不止 Result 的类型需要处理,但这一次,我们很可能会出现一个错误。如果用户输入的是非数字, parse() 将会返回一个错误类型的 Result(Err)。如果发生这种情况,我们不希望程序崩溃。而是希望提示用户没有输入正确的数字,请再试一次。为此,我们需要写好调用 parse() 成功时的逻辑,还要写好调用失败时的逻辑。类似于逐个处理匹配表达式可能的结果。

分析匹配表达式


#![allow(unused)]
fn main() {
match my_string.trim().parse::<f64>() {
    Ok(_s) => break _s,
    Err(_err) => println!(“Try again. Enter a number.”)
}
}

首先,我们使用 match 关键字来声明匹配表达式。然后,我们提供与表达式匹配的可能的值。这个值就是下面所示:


#![allow(unused)]
fn main() {
my_string.trim().parse::<f64>()
}

这段代码接收 my_string 参数,它将用户输入的内容保存下来,并提供给 trim() 函数。trim() 函数会删除掉字符串两侧可能存在的额外空行或空格。我们之所以需要 trim() 是因为 read_line() 函数在输入中附加了一个额外的空行,这会导致转换会出现异常。然后将清理了空格字符的 my_string 传递到 parse() 函数中,该函数会尝试将其转换为浮点数。

如果 parse() 成功地将 my_string 转换为数字,则返回 Ok。在这个情况下,我们可以得到浮点数。如果用户输入的不是数字,那么 parse() 将无法正常完成转换,它会返回 Err。

在匹配表达式的花括号(主体)中,我们根据 parse() 返回的类型告诉计算机怎么做:


#![allow(unused)]
fn main() {
Ok(_s) => break _s,
Err(_err) => println!(“Try again. Enter a number.”)
}

如果结果是 Ok,则表示 parse() 能够转换该类型。这时,我们调用一个 break,停止循环,并返回存储在 Ok 中的值,这个值会被放在 _s 变量中。

如果结果是 Errparse() 无法完成转换。这时,我们会告诉用户“重试一次。输入一个数字”。由于我们不调用 break,所以循环重新开始。

如果必须用一句话解释 Result,那就是:如果一个函数返回 Result,一个匹配表达式可以根据结果是 Ok 还是 Err 来执行不同的代码。

在你的函数中使用 Result

既然你已经了解了处理 Result 的方法,那么你可能会希望在你自己创建的函数中使用它。

我们先看一个例子。

fn main(){
    let my_num = 50;
    
    fn is_it_fifty(num: u32) -> Result<u32, &’static str> {
        let error = “It didn’t work”;
        if num == 50 {
            Ok(num)
        } else {
            Err(error)
        }
    }
    match is_it_fifty(my_num) {
        Ok(_v) => println!(“Good! my_num is 50”),
        Err(_e) => println!(“Error. my_num is {:?}”, my_num)
    }
}

这个程序检查 my_num 的值。如果值为 50,则表示成功;如果不是,则表示错误。

这段代码的主体是 is_it_fifty() 函数。它是有返回结果的声明式函数。我们逐行看其中的代码。

首先,我们声明 my_num 并给它赋值。然后,我们声明 is_it_fifty() 函数:


#![allow(unused)]
fn main() {
fn is_it_fifty(num: u32) -> Result<u32, &’static str> {
}

在我们的声明中,我们指定该函数接收一个名为 num 的参数,其类型是 32 位无符号整数类型(u32)。接下来,我们指定函数的返回值类型。表示函数会返回一个结果,类型是 u32 或字串(&'static str)

然后,我们编写 is_it_fifty() 的函数体。


#![allow(unused)]
fn main() {
let error = “It didn’t work”;
if num == 50 {
    Ok(num)
} else {
    Err(error)
}
}

函数体中的代码是一个 if else 表达式。它用于判断传入的参数。

如果值是 50,那么函数将返回 Ok 的 Result。Ok 中将会包含传递给函数的值(num)。

如果参数不是 50,函数将返回 Err 的 Result。Err 会包含错误变量的值,也即 “It didn’t work.”

无论何时使用该函数,都必须处理它返回的 Result。在我们的程序中,与大多数 Rust 程序一样,是通过一个匹配表达式完成的。我在之前已经描述过部分匹配表达式。

Result 类型可以使用 unwrap()expect() 来处理 —— 前面也已经解释过。

总结

Result 是一个函数的返回类型,它表示函数执行是否成功。

Rust 的许多内置函数都是返回 Result 类型,如果是这样的话,就没有办法避开它。如果一个函数返回 Result,它必须要被妥善处理。

处理 Result 常用的方法是使用 unwrap() 和 _expect() 函数以及匹配表达式。

可以从自己定义的函数中返回 Result。这是处理错误的好办法。

关于 Rust 的 Result 类型,你需要知道的就这些了,但是如果想了解更多信息,或者想知道我从哪儿收集的这些信息,可以参考下方的资源列表。

资源

  • https://doc.rust-lang.org/std/result/
  • https://doc.rust-lang.org/1.2.0/book/match.html
    • 查看 matching on enums 部分
  • https://doc.rust-lang.org/1.30.0/book/first-edition/error-handling.html
  • https://doc.rust-lang.org/rust-by-example/flow_control/match.html
  • https://blog.jonstodle.com/things-i-enjoy-in-rust-error-handling/
  • https://stevedonovan.github.io/rust-gentle-intro/6-error-handling.html
  • https://doc.rust-lang.org/book/ch03-03-how-functions-work.html
  • https://doc.rust-lang.org/std/result/enum.Result.html#method.expect

常见问题汇总

来源:https://rust-zh.github.io/faq/

欢迎贡献:

更多阅读: https://rust-zh.github.io/faq/


本期摘录:

如何同时等待多个 Future?

如果想要等待多个 Future 都完成后返回,对于固定数量的 Future 可以使用 futures 所提供的 joinjoin3join4 等函数,或者 tokio 所提供的 join! 宏,将多个 Future 合并为一个进行等待。对于不定数量的 Future,比如有一个 Vec,则可以使用 futuresjoin_all 函数。

若要在数个 Future 中第一个错误发生时就返回,则可以使用它们对应的 try_jointry_join3try_join4try_join_all 等函数以及 try_join! 宏。

如果想要在多个 Future 中的第一个完成后就返回,可以使用 futures 的 selectselect_allselect_ok 函数或 tokio 的 select! 宏。

需要注意的是,上面这些函数和宏在每次被轮询的时候,都会轮询里面每一个未完成的 Future,因此当其所带的 Future 很多时可能会有性能问题。

使用 futures 提供的 FuturesOrderedFuturesUnordered 这两个结构可以解决这一问题。它们是为管理大量的 Future 而设计的,只会去轮询收到了唤醒通知的 Future。它们将这些 Future 聚合成一个 Stream 逐个返回里面 Future 的结果,其中前者会按照输入的 Future 的顺序返回,而后者则是以任意顺序(可以近似看作按照完成顺序)返回。

# 新创建的空 Vec<T> 的指针为何指向1248等地址?

Vec 的容量为0时,没有合法的操作会向其指针指向的位置进行读取和写入,进行任何读写之前都必然会有一次内存分配,因此这个初始的指针并不需要是一个有效的指针。这也使得创建 Vec 本身没有进行实际内存分配的必要,既省去了内存分配的开销,也让创建容器的操作可以在常量上下文中使用。

而因为 Vec 需要能被作为[切片]使用,由于切片对数据指针的要求,它的指针的地址需要是非空并且正确对齐的,因而简单起见便选择了类型的对齐的大小作为这个无效指针指向的地址。

如果有一个 trait Foo: Base,如何将一个 &dyn Foo 转换到 &dyn Base

Rust 目前不直接提供这种转换,如果需要转换可以使用一个中间[特质]来实现,如


#![allow(unused)]
fn main() {
trait Base {
    // ...
}

trait AsBase {
    fn as_base(&self) -> &dyn Base;
}

impl<T: Base> AsBase for T {
    fn as_base(&self) -> &dyn Base { self }
}

trait Foo: AsBase {
    // ...
}
}

不支持的主要原因是在[特质对象]的[虚表]中没有相应的数据指向另一个特质的虚表,而不提供相应数据的原因可能是由于这很容易产生过多无用的虚表,进而导致二进制体积的膨胀。

更多关于这一话题的讨论可以参考 RFC 2765 以及 Traits, dynamic dispatch and upcasting

语言技巧

编辑:张汉东


返回多态类型

use rand::{thread_rng, Rng};

/// This is the trait that every die needs to implement to be... well... "rollable", right?
pub trait Rollable {
    /// Roll the die
    fn roll() -> Self;
    /// Get the value from the latest roll
    fn val(&self) -> u8;
}

/// A generic function to roll a given die.
pub fn roll<T: Rollable>() -> T {
    Rollable::roll() // <- Note that here `Rollable` is the current type for a given call!
}

/// A D6 die (6 faces): a roll will give you a `u8` in the `1..=6` range.
#[derive(Debug)]
pub struct D6(u8);

impl Rollable for D6 {
    fn roll() -> D6 {
        D6 {
            0: thread_rng().gen_range(1..=6),
        }
    }
    fn val(&self) -> u8 {
        self.0
    }
}

/// A D8 die (8 faces): a roll will give you a `u8` in the `1..=8` range.
#[derive(Debug)]
pub struct D8(u8);

impl Rollable for D8 {
    fn roll() -> D8 {
        D8 {
            0: thread_rng().gen_range(1..=8),
        }
    }
    fn val(&self) -> u8 {
        self.0
    }
}

#[derive(Debug)]
struct Fake100(u8);

impl Rollable for Fake100 {
    fn roll() -> Fake100 {
        Fake100 { 0: 100 } // <- forces it to roll 100
    }
    fn val(&self) -> u8 {
        self.0
    }
}

fn main() {
    // let's roll a D6
    let r: D6 = roll();
    println!("{:?}", r); // D6(3)

    // let's roll a D8
    let r: D8 = roll();
    println!("{:?}", r); // D8(3)

    println!("I bet I'll get a 100 this time!");
    let d: Fake100 = roll();
    println!("Look what I got: {}!", d.val()) // <- yeah this will always be 100
}

也支持类型推断:

fn try_dodge_attack(d6: D6, d8: D8) -> bool {
    d6.val() + d8.val() > 10
}

fn main() {
    let escaped = try_dodge_attack(roll(), roll());
    println!(
        "{}",
        match escaped {
            true => "You dogded!",
            false => "Ouch! The attack hit you!",
        }
    );
}

来源

一个零开销链表的实现

下面代码实现了一个 持久性/不变性(Persistent / Immutable )的单向链表(Singly-linked)。


#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum List<'a, T> {
    Node { data: T, next: &'a List<'a, T> },
    Tail,
}

impl<T> Default for List<'_, T> {
    fn default() -> Self {
        List::Tail
    }
}

impl<'a, T> List<'a, T> {
    pub fn add(&'a self, data: T) -> Self {
        List::Node { data, next: self }
    }

    pub fn rev_iter(&'a self, f: impl Fn(&'a T)) {
        if let List::Node { data, next } = self {
            next.rev_iter(&f);
            f(data);
        }
    }

    pub fn try_rev_iter<E, F>(&'a self, f: F) -> Result<(), E>
    where
        F: Fn(&'a T) -> Result<(), E>,
    {
        if let List::Node { data, next } = self {
            next.try_rev_iter(&f)?;
            f(data)?;
        }
        Ok(())
    }
}

pub struct ListIter<'a, T>(&'a List<'a, T>);

impl<'a, T> IntoIterator for &'a List<'a, T> {
    type Item = &'a T;

    type IntoIter = ListIter<'a, T>;

    fn into_iter(self) -> Self::IntoIter {
        ListIter(self)
    }
}

impl<'a, T> Iterator for ListIter<'a, T> {
    type Item = &'a T;

    fn next(&mut self) -> Option<Self::Item> {
        match self.0 {
            List::Node { data, next } => {
                self.0 = next;
                Some(data)
            }
            List::Tail => None,
        }
    }
}


#[derive(Debug, Clone, PartialEq)]
pub enum Value {
    Num(f64),
    Bool(bool),
    String(String),
}

#[derive(PartialEq)]
pub enum ValueKind {
    Num,
    Bool,
    String,
}

impl Value {
    pub fn kind(&self) -> ValueKind {
        match self {
            Value::Num(_) => ValueKind::Num,
            Value::Bool(_) => ValueKind::Bool,
            Value::String(_) => ValueKind::String,
        }
    }
}

#[derive(Debug, Clone)]
pub enum Expr {
    Value(Value),
    Variable(String),
    UnExpr(UnExprKind, Box<Expr>),
    BinExpr(BinExprKind, Box<(Expr, Expr)>),
    Define(String, Box<(Expr, Expr)>),
    IfThenElse(Box<(Expr, Expr, Expr)>),
}

#[derive(Debug, Copy, Clone)]
pub enum UnExprKind {
    Not,
    Neg,
}

#[derive(Debug, PartialEq, Copy, Clone)]
pub enum BinExprKind {
    // Arithmetic
    Add,
    Sub,
    Mul,
    Div,

    // Logic
    And,
    Or,
    Equals,
    NotEquals,
}

type Variables<'a> = List<'a, (String, Value)>;

pub fn eval(vars: &Variables<'_>, expr: Expr) -> Option<Value> {
    match expr {
        Expr::Value(val) => Some(val),

        Expr::Variable(var) => vars
            .into_iter()
            .find(|&(v, _)| *v == var)
            .map(|(_, val)| val.clone()),

        Expr::UnExpr(kind, expr) => {
            eval_unary(kind, vars, *expr)
        }

        Expr::BinExpr(kind, exprs) => {
            eval_binary(kind, vars, exprs.0, exprs.1)
        }

        Expr::Define(name, exprs) => {
            let value = eval(vars, exprs.0)?;
            let vars = vars.add((name, value));
            eval(&vars, exprs.1)
        }

        Expr::IfThenElse(exprs) => {
            if let Value::Bool(b) = eval(vars, exprs.0)? {
                eval(vars, if b { exprs.1 } else { exprs.2 })
            } else {
                None
            }
        }
    }
}

fn eval_unary(
    kind: UnExprKind,
    vars: &Variables<'_>,
    expr: Expr,
) -> Option<Value> {
    let val = eval(vars, expr)?;
    match (kind, val) {
        (UnExprKind::Not, Value::Bool(b)) => {
            Some(Value::Bool(!b))
        }
        (UnExprKind::Neg, Value::Num(n)) => Some(Value::Num(-n)),
        _ => None,
    }
}

fn eval_binary(
    kind: BinExprKind,
    vars: &Variables<'_>,
    lhs: Expr,
    rhs: Expr,
) -> Option<Value> {
    let lhs = eval(vars, lhs)?;

    match kind {
        BinExprKind::Add => {
            if let Value::Num(lhs) = lhs {
                if let Value::Num(rhs) = eval(vars, rhs)? {
                    return Some(Value::Num(lhs + rhs));
                }
            }
            None
        }
        BinExprKind::Sub => {
            if let Value::Num(lhs) = lhs {
                if let Value::Num(rhs) = eval(vars, rhs)? {
                    return Some(Value::Num(lhs - rhs));
                }
            }
            None
        }
        BinExprKind::Mul => {
            if let Value::Num(lhs) = lhs {
                if let Value::Num(rhs) = eval(vars, rhs)? {
                    return Some(Value::Num(lhs * rhs));
                }
            }
            None
        }
        BinExprKind::Div => {
            if let Value::Num(lhs) = lhs {
                if let Value::Num(rhs) = eval(vars, rhs)? {
                    return Some(Value::Num(lhs / rhs));
                }
            }
            None
        }

        BinExprKind::And => {
            if let Value::Bool(lhs) = lhs {
                if !lhs {
                    return Some(Value::Bool(false));
                }
                if let Value::Bool(rhs) = eval(vars, rhs)? {
                    return Some(Value::Bool(rhs));
                }
            }
            None
        }
        BinExprKind::Or => {
            if let Value::Bool(lhs) = lhs {
                if lhs {
                    return Some(Value::Bool(true));
                }
                if let Value::Bool(rhs) = eval(vars, rhs)? {
                    return Some(Value::Bool(rhs));
                }
            }
            None
        }
        BinExprKind::Equals => {
            let rhs = eval(vars, rhs)?;
            if lhs.kind() == rhs.kind() {
                Some(Value::Bool(lhs == rhs))
            } else {
                None
            }
        }
        BinExprKind::NotEquals => {
            let rhs = eval(vars, rhs)?;
            if lhs.kind() == rhs.kind() {
                Some(Value::Bool(lhs != rhs))
            } else {
                None
            }
        }
    }
}
}

来源

RFC 介绍 | try-trait v2

编辑: 张汉东

编者按:

RFC 3058 try_trait_v2 被合并了,这意味着,? 操作符的行为在设计上已经趋于稳定,只等待它的实现。

在 RustFriday 飞书群线上沙龙 第四期 也讲过这个 RFC ,可以观看录播:https://www.bilibili.com/video/BV1xy4y147Ve/

Rust 中文社群 飞书群 邀请你加入:https://applink.feishu.cn/TeLAcbDR


背景介绍

目前 Rust 允许通过 ? 操作符可以自动返回的 Result<T, E>Err(e) ,但是对于 Ok(o) 还需要手动包装。

比如:


#![allow(unused)]
fn main() {
fn foo() -> Result<PathBuf, io::Error> {
    let base = env::current_dir()?;
    Ok(base.join("foo"))
}
}

那么这就引出了一个 术语: Ok-Wrapping 。很明显,这个写法不够优雅,还有很大的改进空间。

因此 Rust 官方成员 withoutboats 开发了一个库 fehler,引入了一个 throw 语法。

用法如下:


#![allow(unused)]
fn main() {
#[throws(i32)]
fn foo(x: bool) -> i32 {
    if x {
        0
    } else {
        throw!(1);
    }
}

// 上面foo函数错误处理等价于下面bar函数

fn bar(x: bool) -> Result<i32, i32> {
    if x {
        Ok(0)
    } else {
        Err(1)
    }
}
}

通过 throw 宏语法来帮助开发者省略 Ok-wrapping 和 Err-wrapping 的手动操作。这个库一时在社区引起了一些讨论。它也在促进着 Rust 错误处理体验提升。

于是错误处理就围绕着 Ok-wrapping 和 Err-wrapping 这两条路径,该如何设计语法才更加优雅为出发点。

try块 和 try trait 的区别

当前 Nightly Rust 中也提供了一个 try 块语法,要使用 #![feature(try_blocks)]

用法如下:


#![allow(unused)]

#![feature(try_blocks)]
fn main() {
use std::path::PathBuf;

fn foo() -> Result<PathBuf, std::io::Error> {
    try {
        let base = std::env::current_dir()?;
        base.join("foo")
    }
}
}

try 块在 Ok 情况下自动 Ok-wrapping 返回 Ok(PathBuf),而问号操作符返回 Err(io::Error)。所以,这个 try 块语法 和 try trait 是相互配合的。

所以:

  • try 块 (try-block)是控制 Ok-wrapping
  • try trait 是控制问号操作符的行为 Err-wrapping

try-trait RFC 导读

经过很久很久的讨论,try-trait-v2 RFC 被合并了,意味着一个确定的方案出现了。

在这个方案中,引入了一个新类型:ControlFlow


#![allow(unused)]
fn main() {
enum ControlFlow<B, C = ()> {
    /// Exit the operation without running subsequent phases.
    Break(B),
    /// Move on to the next phase of the operation as normal.
    Continue(C),
}

impl<B, C> ControlFlow<B, C> {
    fn is_break(&self) -> bool;
    fn is_continue(&self) -> bool;
    fn break_value(self) -> Option<B>;
    fn continue_value(self) -> Option<C>;
}
}

ControlFlow 中包含了两个值:

  • ControlFlow::Break,表示提前退出。但不一定是Error 的情况,也可能是 Ok
  • ControlFlow::Continue,表示继续。

还引入了一个新的trait:FromResidual


#![allow(unused)]
fn main() {
trait FromResidual<Residual = <Self as Try>::Residual> {
    fn from_residual(r: Residual) -> Self;
}
}

Residual 单词有 「剩余」之意,因为 要把 Result / Option/ ControlFlow 之类的类型,拆分成两部分(两条路径),用这个词就好理解了。

Try trait 继承自 FromResidual trait :


#![allow(unused)]
fn main() {
pub trait Try: FromResidual {
    /// The type of the value consumed or produced when not short-circuiting.
    type Output;

    /// A type that "colours" the short-circuit value so it can stay associated
    /// with the type constructor from which it came.
    type Residual;

    /// Used in `try{}` blocks to wrap the result of the block.
    fn from_output(x: Self::Output) -> Self;

    /// Determine whether to short-circuit (by returning `ControlFlow::Break`)
    /// or continue executing (by returning `ControlFlow::Continue`).
    fn branch(self) -> ControlFlow<Self::Residual, Self::Output>;
}

pub trait FromResidual<Residual = <Self as Try>::Residual> {
    /// Recreate the type implementing `Try` from a related residual
    fn from_residual(x: Residual) -> Self;
}
}

所以,在 Try trait 中有两个关联类型:

  • Output,如果是 Result 的话,就对应 Ok-wrapping 。
  • Residual,如果是 Result 的话,就对应 Err-wrapping 。

所以,现在 ? 操作符的行为就变成了:


#![allow(unused)]

fn main() {
match Try::branch(x) {
    ControlFlow::Continue(v) => v,
    ControlFlow::Break(r) => return FromResidual::from_residual(r),
}

}

然后内部给 Rusult 实现 Try


#![allow(unused)]
fn main() {
impl<T, E> ops::Try for Result<T, E> {
    type Output = T;
    type Residual = Result<!, E>;

    #[inline]
    fn from_output(c: T) -> Self {
        Ok(c)
    }

    #[inline]
    fn branch(self) -> ControlFlow<Self::Residual, T> {
        match self {
            Ok(c) => ControlFlow::Continue(c),
            Err(e) => ControlFlow::Break(Err(e)),
        }
    }
}

impl<T, E, F: From<E>> ops::FromResidual<Result<!, E>> for Result<T, F> {
    fn from_residual(x: Result<!, E>) -> Self {
        match x {
            Err(e) => Err(From::from(e)),
        }
    }
}
}

再给 Option 实现 Try


#![allow(unused)]
fn main() {
impl<T> ops::Try for Option<T> {
    type Output = T;
    type Residual = Option<!>;

    #[inline]
    fn from_output(c: T) -> Self {
        Some(c)
    }

    #[inline]
    fn branch(self) -> ControlFlow<Self::Residual, T> {
        match self {
            Some(c) => ControlFlow::Continue(c),
            None => ControlFlow::Break(None),
        }
    }
}

impl<T> ops::FromResidual for Option<T> {
    fn from_residual(x: <Self as ops::Try>::Residual) -> Self {
        match x {
            None => None,
        }
    }
}
}

再给 Poll 实现 Try :


#![allow(unused)]
fn main() {
impl<T, E> ops::Try for Poll<Result<T, E>> {
    type Output = Poll<T>;
    type Residual = <Result<T, E> as ops::Try>::Residual;

    fn from_output(c: Self::Output) -> Self {
        c.map(Ok)
    }

    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        match self {
            Poll::Ready(Ok(x)) => ControlFlow::Continue(Poll::Ready(x)),
            Poll::Ready(Err(e)) => ControlFlow::Break(Err(e)),
            Poll::Pending => ControlFlow::Continue(Poll::Pending),
        }
    }
}

impl<T, E, F: From<E>> ops::FromResidual<Result<!, E>> for Poll<Result<T, F>> {
    fn from_residual(x: Result<!, E>) -> Self {
        match x {
            Err(e) => Poll::Ready(Err(From::from(e))),
        }
    }
}

impl<T, E> ops::Try for Poll<Option<Result<T, E>>> {
    type Output = Poll<Option<T>>;
    type Residual = <Result<T, E> as ops::Try>::Residual;

    fn from_output(c: Self::Output) -> Self {
        c.map(|x| x.map(Ok))
    }

    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        match self {
            Poll::Ready(Some(Ok(x))) => ControlFlow::Continue(Poll::Ready(Some(x))),
            Poll::Ready(Some(Err(e))) => ControlFlow::Break(Err(e)),
            Poll::Ready(None) => ControlFlow::Continue(Poll::Ready(None)),
            Poll::Pending => ControlFlow::Continue(Poll::Pending),
        }
    }
}

impl<T, E, F: From<E>> ops::FromResidual<Result<!, E>> for Poll<Option<Result<T, F>>> {
    fn from_residual(x: Result<!, E>) -> Self {
        match x {
            Err(e) => Poll::Ready(Some(Err(From::from(e)))),
        }
    }
}
}

再给 ControlFlow 实现 Try :


#![allow(unused)]
fn main() {
impl<B, C> ops::Try for ControlFlow<B, C> {
    type Output = C;
    type Residual = ControlFlow<B, !>;

    fn from_output(c: C) -> Self {
        ControlFlow::Continue(c)
    }

    fn branch(self) -> ControlFlow<Self::Residual, C> {
        match self {
            ControlFlow::Continue(c) => ControlFlow::Continue(c),
            ControlFlow::Break(b) => ControlFlow::Break(ControlFlow::Break(b)),
        }
    }
}

impl<B, C> ops::FromResidual for ControlFlow<B, C> {
    fn from_residual(x: <Self as ops::Try>::Residual) -> Self {
        match x {
            ControlFlow::Break(r) => ControlFlow::Break(r),
        }
    }
}
}

这就实现了 错误类型转换 大统一。

我在 2017 年给官方提过一个 Issue: why havn't implemented Error trait for std::option::NoneError ?,是因为当时引入了 NoneError,但没有个 NoneError 实现 Error trait,所以无法在 Result 和 Option 之间无缝转换。

现在如果这个 RFC 实现,Result/Option 之间可以无缝转换,而完全不需要 NoneError 了,也许 NoneError就可以移除了。甚至在写异步 poll 方法的时候,也会变得非常简单了。

最后再看一个示例:


#![allow(unused)]
fn main() {
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
#[repr(transparent)]
pub struct ResultCode(pub i32);
impl ResultCode {
    const SUCCESS: Self = ResultCode(0);
}

use std::num::NonZeroI32;
pub struct ResultCodeResidual(NonZeroI32);

impl Try for ResultCode {
    type Output = ();
    type Residual = ResultCodeResidual;
    fn branch(self) -> ControlFlow<Self::Residual> {
        match NonZeroI32::new(self.0) {
            Some(r) => ControlFlow::Break(ResultCodeResidual(r)),
            None => ControlFlow::Continue(()),
        }
    }
    fn from_output((): ()) -> Self {
        ResultCode::SUCCESS
    }
}

impl FromResidual for ResultCode {
    fn from_residual(r: ResultCodeResidual) -> Self {
        ResultCode(r.0.into())
    }
}

#[derive(Debug, Clone)]
pub struct FancyError(String);

impl<T, E: From<FancyError>> FromResidual<ResultCodeResidual> for Result<T, E> {
    fn from_residual(r: ResultCodeResidual) -> Self {
        Err(FancyError(format!("Something fancy about {} at {:?}", r.0, std::time::SystemTime::now())).into())
    }
}

}

【官宣】Rust 2021 Edition 计划

翻译/编辑:张汉东

原文: The Plan for the Rust 2021 Edition


我们很高兴地宣布,Rust语言的第三版次(Edition) Rust 2021 edition 计划于今年10月发布。 Rust 2021 包含许多细微的变化,但仍有望在实践中对 Rust 产生很大的影响。

译注:

这里把 Edition 译为 「版次」,是特意为了在中文中和 「版本」区别开来。

一般情况下,「版次」代表 Edition,而「版本」特指语义化版本。如果不做这样的区分,都用「版本」就会很乱。

为什么不用「版」呢,因为「版」对应发行版本,对应 Stable/Nightly/Beta 发行版。

什么是「版次( Edition)」?

Rust 1.0 的发布确立了 “无停滞的稳定性” 作为 Rust 交付的核心。从 1.0 发行版开始,Rust 的规则是,一旦某个功能在稳定版(Stable)上发布,我们将致力于在所有将来的发行版中都支持该功能。

但是,有时候在 Rust 的语法层面中进行一些小的更改,版次是有用的,否则这些更改将无法向后兼容。最明显的例子是引入一个新的关键字,它会使变量等现有名称无效。即使这样的更改不会“感觉到”向后不兼容,它们仍然有可能破坏现有代码。如果要进行此类更改,人们会很快发现现有程序停止编译。

版次(Edition)是我们用来把这种不可能变成可能的机制。当我们希望发布一个向后不兼容的功能时,我们会将其作为新的Rust 版次的一部分发布。版次是可选的(Opt-in),因此,现有的 Crate 除非将其明确迁移到新版次,否则不会看到这些更改。Cargo 创建的新的 Crate 始终默认使用最新版次。

版次不会分裂生态系统

版次的最重要规则是,一个版次中的 Crate 可以与其他版次中编译的 Crate 无缝地互操作。这确保了迁移到较新版次的决定是 Crate 可以做出的“私人”决定,而不影响其他人,除了它影响所需的 rustc 语义版本(version)之外(类似于使用任何新功能)。

Crate 互操作性的要求对我们在一个版次中可以进行的更改种类有一定的限制。通常,一个版次中发生的更改往往是“很薄的一层”。不管版次如何,所有 Rust 代码最终都会在编译器中编译为相同的内部表示形式。

译注:版次(Edition)之间的差异,最终会在 MIR 层面消除。

版次迁移很容易,而且很大程度上是自动化的

我们的目标是使 Crate 轻松升级到新版次。每当我们发布新版次时,我们也会发布工具来自动进行迁移。工具不一定是完美的:它可能无法涵盖所有​​极端情况,并且仍然可能需要手动更改。该工具尽力避免对语义的更改,这些更改可能影响代码的正确性或性能。

除工具外,我们还维护一个《版次迁移指南(Edition Migration Guide)》,其中涵盖了版次中的更改。该指南将描述更改,并提供指向人们可以在其中了解更多信息的指南。它还将涵盖人们应注意的任何极端情况或细节。该指南既可以作为该版次的概述,也可以作为人们在使用自动化工具时遇到问题的快速疑难解答参考。最终版次列表将成为 Rust 2021 的一部分。所有这些摘要总结如下。

Rust 2021 计划进行哪些更改?

在过去的几个月中,Rust 2021工作组已经就新版次中包含的内容提出了许多建议。 我们很高兴宣布最终候选名单。每个功能都必须满足两个条件才能进入此清单。

首先,它们必须得到相应 Rust 团队的批准。

第二,它们的实现必须考虑周全,以使我们确信,它们能按计划的里程碑及时完成。

增补 Prelude

标准库的 Prelude 是一个模块,该模块包含了标准库中其他每个模块必须自动导入的所有内容。它包含了常用的语言项(Item),比如 OptionVecdropClone

Rust编译器会优先处理任何手动导入的项(Item),使其优先于 Prelude 中的项(Item),以确保在 Prelude 中添加的内容不会破坏任何现有代码。例如,如果您有一个名为 example 的 Crate 或 模块,其中包含pub struct Option ;,则使用example::*;。这样就能明确引用 example 中的Option,而不是标准库中的Option

但是,在 Prelude 中添加 trait 可以以微妙的方式破坏现有代码。比如,x.try_into() ,在使用MyTryInto trait 中的方法进行调用时,如果还导入了stdTryInto,则这个调用可能会变得模棱两可,并且无法编译,因为它提供了具有相同名称的方法。这就是我们尚未将TryInto添加到 Prelude 的原因,因为有很多代码会破坏这种方式。

作为解决方案,Rust 2021 将使用新的 Prelude。除了以下三个新增功能外,其余与当前的功能相同:

仍然需要等待库团队(Library team)来批准这三条,但应该很快批准。

默认 Cargo Feature 解析器(Resolver)

从Rust 1.51.0开始,Cargo 支持了可选的新的 Feature 解析器,可以通过Cargo.toml中的resolver ="2" 激活该功能。

从 Rust 2021 开始,这将是默认设置。 也就是说,在Cargo.toml中写入edition ="2021" 会暗含 resolver ="2"

新的 Feature 解析器不再合并所有请求的功能,这些功能将以多种方式依赖于 Crate。 有关详细信息,请参见 Rust 1.51 的公告

数组(Array)支持 IntoIterator

Rust 1.53之前,只有对数组的引用才实现 IntoIterator。 这意味着您可以遍历&[1、2、3]&mut [1、2、3],但不能直接遍历[1、2、3]


#![allow(unused)]
fn main() {
for &e in &[1, 2, 3] {} // Ok :)

for e in [1, 2, 3] {} // Error :(
}

这是一个长期存在的问题,但是解决方案并不像看起来那样简单。仅添加trait实现会破坏现有代码。 array.into_iter()现在已可编译,由于方法调用语法的工作原理,该函数隐式调用(&array).into_iter()。添加trait实现将改变含义。

通常,我们将这种类型的破坏(breakage)(添加trait实现)分类为“轻微(minor)”和“可接受(acceptable)”。但是在这种情况下,有太多的代码会被它破坏。

多次建议“仅在 Rust 2021 中为数组实现IntoIterator”。但是,这根本不可能。您不能在一个版次中存在trait实现,而在另一个版次中则不能存在,因为版次可以混合使用。

因此,我们决定在所有版次中添加trait实现(从Rust 1.53.0开始),但添加一个小技巧以避免在Rust 2021之前损坏。在 Rust 2015 和 2018 代码中,编译器仍将解析array.into_iter()(&array).into_iter(),就好像trait实现不存在一样。这仅适用于.into_iter()方法调用语法。它不会影响任何其他语法,例如[1、2、3]中的eiter.zip([1、2、3])。这些将开始在所有版次中使用。

遗憾的是,这需要上述小技巧以避免破损,但我们对这种如何将两个版次之间的差异保持在最低限度的解决方案感到非常满意。

闭包中不相关的捕获

闭包(Closure) 会自动从上下文捕获其引用的任何内容。 例如,|| a + 1会自动从周围的上下文中捕获对a的引用。

当前,即使仅使用一个字段,也将影响整个结构。 例如,|| a.x +1捕获对a的引用,而不仅仅是a.x。 在某些情况下,这是一个问题。 当结构的某个字段已被借用(可变)或移出时,其他字段将无法再用于闭包中,因为这将捕获整个结构,而该结构不再可用。


#![allow(unused)]
fn main() {
let a = SomeStruct::new();

drop(a.x); // Move out of one field of the struct

println!("{}", a.y); // Ok: Still use another field of the struct

let c = || println!("{}", a.y); // Error: Tries to capture all of `a`
c();
}

从 Rust 2021 开始,闭包将仅捕获其使用的字段。 因此,以上示例在 Rust 2021 中可以很好地进行编译。

此新行为仅在新版次中才被激活,因为它可以更改字段的 drop 顺序。 对于所有版次更改,都可以进行自动迁移。 Cargo fix --edition将能够更新与此相关的闭包。 也可以通过在闭包插入 let _ =&a; 来强制闭包像以前一样捕获整个结构。

Panic 宏的一致性

panic!()宏是 Rust 中最常见的宏之一。 但是,它有一些微妙的惊喜,我们不能仅仅因为向后兼容而进行更改。


#![allow(unused)]
fn main() {
panic!("{}", 1); // Ok, panics with the message "1"
panic!("{}"); // Ok, panics with the message "{}"
}

panic!()宏仅在使用多个参数调用时才使用字符串格式。当使用单个参数调用时,它甚至不会查看该参数。


#![allow(unused)]
fn main() {
let a = "{";
println!(a); // Error: First argument must be a format string literal
panic!(a); // Ok: The panic macro doesn't care
}

(它甚至接受诸如panic!(123)之类的非字符串,这是罕见的,很少有用。)

隐式格式参数将被稳定时,这尤其是一个问题。 该功能将使println!("hello {name}") 成为 println!(" hello {}",name)的简写形式。 但是,panic!("hello {name}")不能按预期工作,因为panic!()不会将单个参数作为格式字符串处理。

为了避免这种混乱的情况,Rust 2021 提供了更一致的panic!()宏。 新的panic!()宏将不再接受任意表达式作为唯一参数。 就像println!()一样,它将始终将第一个参数作为格式字符串处理。

另外,Rust 2021 中的core::panic!()std::panic!()相同。当前,这两者之间存在一些历史差异,当打开或关闭#![no_std]时,这是很明显的。

保留语法

为了将来为某些新语法腾出空间,我们决定为前缀的标识符和文字保留语法:prefix#identifierprefix" string"prefix'c'prefix#123,其中prefix可以是任何标识符。 (除了已经具有含义的含义,例如b''r“”。)

这是一个重大变化,因为宏当前可以接受hello"world",它们将被视为两个单独的标记:hello"world"。 (自动)修复很简单。 只需插入一个空格:hello "world"

除了将它们转换为标记化错误外,RFC 尚未将含义附加到任何前缀。 为特定的前缀分配含义留给将来的建议,由于现在保留了这些前缀,因此不会破坏更改。

这些是您将来可能会看到的一些新前缀:

f""是格式字符串的简写形式。 例如,f"hello {name}"是等效的format_args!()调用的简写形式。

c""z""用于以N结尾的C字符串。

k#keyword允许编写当前版次中尚不存在的关键字。 例如,虽然async2015 edition 中不是关键字,但使用此前缀可以使我们在2015 edition中接受k#async,而不必等待2018 editionasync保留为关键字。

代码质量检查(Lint)

使用 Rust 2021,许多现有的 Lint 正成为 Crate 中的硬错误,在旧版次中,这些 Lint 将仍然是警告。

  • bare_trait_objects:在Rust 2021中,必须使用dyn关键字来标识“ trait 对象”。
  • ellipsis_inclusive_range_patterns:Rust 2021中包含范围模式的...语法将是一个硬错误; 新语法为..=,与表达式一致。

我们可能会在此列表中添加更多Lint。

macro_rules 中的 或(Or) 模式

从 Rust 1.53.0 开始,模式(pattern)被扩展以支持|用于嵌套在模式中的任何位置。例如,现在可以写Some(1 | 2)代替Some(1) | Some(2)。由于以前根本不允许这样做,所以这不是一个重大变化。

但是,此更改也会影响macro_rules。这样的宏可以使用:pat片段说明符接受模式。当前,:pat不匹配|,因为在 Rust 1.53 之前,并非所有模式(在所有嵌套级别)都可以包含|。接受像A | B这样的模式的宏,例如match!()使用类似$($_:pat)|+的东西。因为我们不想破坏任何现有的宏,所以我们没有将 Rust 1.53.0 中的:pat的含义更改为包括|

相反,我们将在 Rust 2021 中进行该更改。在新版本中,:pat片段说明符将匹配A | B

由于有时仍然希望匹配不带|的单个模式变量,因此添加了指定的片段:pat_param以保留较旧的行为。该名称旨在表示使用这种模式的主要用于闭合参数。

就是说,到目前为止,我们的工作如期进行,许多困难的部分已经解决,这要归功于所有为 Rust 2021 做出贡献的人们。

接下来是什么?

2021 版次的计划里程碑如下:

  • ✅ 今天:功能集已最终确定。
  • 🚧 5月17日:在 Nightly 中完成实现,包括迁移(正在进行中)
  • ⌛ 6月15日:《版次迁移指南》和其他文档完成
  • ⌛ 7月1日:呼吁进行公开测试
  • ⌛ 9月1日:先在 Nightly 中稳定 2021 版次
  • ⌛ 10月21日:随着Rust 1.56.0的发布,稳定 2021 版次。

在撰写本文时,我们正在按时完成这些截止日期,并且不会预见任何问题。 但是,Rust是一个由志愿者运行的项目。 我们优先考虑在 Rust 上工作的每个人的个人福祉,而不是我们设定的任何截止日期和期望。 这可能意味着如果需要的话,会延迟版次的发布,或者放弃一项事实证明过于困难或压力太大而无法及时完成的功能。

如果您想继续,可以在 Rust 2021 Edition 电子表格项目板上跟踪状态。

感谢阅读!

用 rustc 源码实现拼写错误候选词建议

作者: 吴翱翔@pymongo / 后期编辑:张汉东

原文: 用 rustc 源码实现拼写错误候选词建议


最近想给一个聊天应用的聊天消息输入框加上拼写错误检查,毕竟 word, keynote 等涉及文本输入的软件都有拼写错误检查和纠错功能

于是想到开发中经常用的 rustup, cargo, rustc 不就内置了拼写错误时纠错建议的功能么?

在 rustup 输入错误的单词时例如 rustup dog,此时 rustup 就会提示把 dog 改成 doc

[w@w-manjaro ~]$ rustup dog
error: The subcommand 'dog' wasn't recognized
        Did you mean 'doc'?

字符串的编辑距离

rustup 的拼写纠错建议的实现

Did you mean 的关键词全文搜索 rustup 源码,找到出处在 src/cli/error.rs


#![allow(unused)]
fn main() {
fn maybe_suggest_toolchain(bad_name: &str) -> String {
    let bad_name = &bad_name.to_ascii_lowercase();
    static VALID_CHANNELS: &[&str] = &["stable", "beta", "nightly"];
    lazy_static! {
        static ref NUMBERED: Regex = Regex::new(r"^\d+\.\d+$").unwrap();
    }

    if NUMBERED.is_match(bad_name) {
        return format!(
            ". Toolchain numbers tend to have three parts, e.g. {}.0",
            bad_name
        );
    }

    // Suggest only for very small differences
    // High number can result in inaccurate suggestions for short queries e.g. `rls`
    const MAX_DISTANCE: usize = 3;

    let mut scored: Vec<_> = VALID_CHANNELS
        .iter()
        .filter_map(|s| {
            let distance = damerau_levenshtein(bad_name, s);
            if distance <= MAX_DISTANCE {
                Some((distance, s))
            } else {
                None
            }
        })
        .collect();
    scored.sort();
    if scored.is_empty() {
        String::new()
    } else {
        format!(". Did you mean '{}'?", scored[0].1)
    }
}
}

damerau_levenshtein 其实就是描述两个字符串之间的差异,damerau_levenshtein 距离越小则两个字符串越接近

该函数的将输入的错误单词跟正确的候选词挨个计算 damerau_levenshtein 距离,

最后排序下 damerau_levenshtein 距离输出最小的候选词

rustup的 damerau_levenshtein 来自 strsim 库,除了 rustup, darling 等知名库也导入了 strsim 库

查阅维基百科的 damerau_levenshtein 词条后发现 damerau_levenshtein 的同义词是 levenshtein_distanceedit_distance

用 rustc 源码竟然过了算法题

rustc 源码会尽量不用第三方库,所以我猜测 rustc 不会像 rustup 那样用 strsim 源码,那就看看 rustc 的实现会不会更好

在 Rust 的 github 仓库中搜索edit distance关键字能找到Make the maximum edit distance of typo suggestions 的 commit

typo 就是单词拼写错误的意思,本文也会将单词拼写错误简称为 typo

顺着这个 commit 的改动在 find_best_match_for_name 函数内调用了 lev_distance 函数去计算两个字符串的编辑距离

edit_distance 是个动态规划算法或字符串算法的经典问题,果然 leetcode 上有 edit_distance 的算法题

我拿 rustc 源码的 lev_distance 函数在 leetcode上通过 edit_distance 一题

用 strsim 的相关函数也能通过编辑距离这题,但是运行耗时 4ms 会比 rustc 源码运行耗时 0ms 慢点

原因是 strsim 的 edit_distance 算法动态规划的空间复杂度是 O(n^2),而 rustc 的实现空间复杂度是 O(n)

edit_distance 算法

从 rustc 源码的 lev_distance 函数签名 fn lev_distance(a: &str, b: &str) -> usize 来看

输入的是两个字符串 a 和 b, 返回值表示 a 和 b 的 edit_distance

edit_distance 表示从字符串 a 修改成 b 或从字符串 b 修改成 a 至少需要的操作(插入/删除/替换一个字母)次数

例如一个拼写错误的单词 bpple 需要一次替换操作,将第一个字母 b 替换成 a 才能变成 apple

所以字符串 bppleapple 之间的 edit_distance 就是 1

以下是一段 edit_distance 的二维数组 dp 状态的实现,可以结合代码注释进行理解,详细的推断和动态规划状态转移方程可以看 leetcode 的官方题解


#![allow(unused)]
fn main() {
/// 从字符串word1修改成word2至少需要多少次操作(replace/insert/delete)
#[allow(clippy::needless_range_loop)]
fn edit_distance_dp(word1: String, word2: String) -> i32 {
    let (word1, word2) = (word1.into_bytes(), word2.into_bytes());
    let (word1_len, word2_len) = (word1.len(), word2.len());
    // # dp[i][j]表示word1[..i]至少需要多少次操作(replace/insert/delete)替换成B[..j]
    // 很容易想到的其中一种状态转移的情况: 如果word1[i]==word2[j],那么dp[i][j]==dp[i-1][j-1]
    let mut dp = vec![vec![0; word2_len+1]; word1_len+1];
    for i in 0..=word1_len {
        // 需要i次删除操作才能让word1[..i]修改成空的字符串word2[..0]
        dp[i][0] = i;
    }
    for j in 0..=word2_len {
        // 需要j次插入操作才能让空字符串word1[..0]修改成word2[..j]
        dp[0][j] = j;
    }
    for i in 1..=word1_len {
        for j in 1..=word2_len {
            if word1[i-1] == word2[j-1] {
                dp[i][j] = dp[i-1][j-1];
            } else {
                // dp[i-1][j-1] + 1: word1[i-1]和word2[i-2]不同,所以替换次数+1,
                //                   如果dp的决策层选择replace操作,dp[i][j]总共操作数等于dp[i-1][j-1]+1
                // d[i-1][j]表示往word1末尾插入word2[j],dp[i][j-1]表示word1删掉末尾的字母让word1和word2更接近
                dp[i][j] = dp[i-1][j-1].min(dp[i-1][j]).min(dp[i][j-1]) + 1;
            }
        }
    }
    dp[word1_len][word2_len] as i32
}
}

由于 rustc 源码为了性能选用了一位数组存储动态规划的状态,用到了很多状态压缩、滚动数组之类的技巧,较难理解,本文就不对 rustc 的动态规划 edit_distance 算法做解释


引用 rustc 编辑距离的函数

rustc 动态链接库?

考虑到 rustc 源码的 lev_distance 会比 strsim 库性能略微好点,所以就直接调 rustc 源码的 lev_distance 就行了

当我尝试在代码中加入 extern crate rustc 时就出现以下报错:

error[E0462]: found staticlib rustc instead of rlib or dylib

然后 rustc 会提示找到个类似的静态链接库文件

/home/w/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib/librustc-nightly_rt.asan.a

然后我试着用 nm 命令去读取库文件的函数符号表

$ nm -D librustc-nightly_rt.tsan.a
...
sanitizer_linux_s390.cpp.o:
nm: sanitizer_linux_s390.cpp.o: no symbols

sanitizer_mac.cpp.o:
nm: sanitizer_mac.cpp.o: no symbols

sanitizer_netbsd.cpp.o:
nm: sanitizer_netbsd.cpp.o: no symbols
...

发现里面有一个 sanitizer_netbsd.cpp 的文件,网上搜索得知这是 llvm 的源文件

所以这些 librustc-nightly_rt 开头的库全是 llvm 相关的静态链接库,并不是 rustc 的库

rustc-ap-rustc_span

我相信我编译过很多像 rust-analyzer, racer 等静态分析的库,说不定电脑本地的 cargo 缓存就有 rustc 源码的 lev_distance.rs

果然发现 rustc-ap-rustc_span 这个 crate 就有 lev_distance 函数

再参考 StackoverFlow 的问题 How to use rustc crate? 和 racer 源码后发现

而以 rustc-ap-rustc_ 命名开头的库都是由 Rust 官方团队的 alexcrichton 定期从 rustc 源码中同步代码并发布到 crates.io 中

为了进一步验证带rustc-ap前缀的库是不是从 rustc 源码导出的,再看看很可能用到部分 rustc 源码的 rust-analyzer

[w@w-manjaro rust-analyzer]$ grep -r --include="*.toml" "rustc-ap" .
./crates/syntax/Cargo.toml:rustc_lexer = { version = "714.0.0", package = "rustc-ap-rustc_lexer" }

果然发现 rust-analyzer 用到了 rustc-ap-rustc_lexer 这个库,毕竟 rust-analyzer 是做静态分析的,跟编译器的部分功能有点重合很正常

其实像 rust-analyzer 和 racer 等静态分析工具都会用到 rustc-ap-rustc_* 这样命名开头的 rustc 编译器组件库

我参考 racer 源码可以在 Cargo.toml 中这么引入 rustc_span,进而使用 rustc_span 的 lev_distance 函数

rustc_span = { package="rustc-ap-rustc_span", version="714.0.0" }

rustc-dev component

阅读 rustup component 相关文档得知,rustc-dev 组件包含了 rustc 的动态链接库和源码(方便静态分析)

rustup component add rustc-dev

然后就可以使用 rustc 编译器的各种组件


#![allow(unused)]
#![feature(rustc_private)]
fn main() {
extern crate rustc_span;
}

rust-analyzer 对 rustc 静态分析

然后在 Cargo.toml 中加入以下内容,

[package.metadata.rust-analyzer]
rustc_private = true

然后 rust-analyzer 能对 rustc API 的使用进行静态分析

然后参考 rust-analyzer 的这两个 #6714, #7589

想让 rust-analyzer 对 rustc 函数的使用进行静态分析,需要设置 rustc 源码的路径:

"rust-analyzer.rustcSource": "/home/w/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/rustc-src/rust/compiler/rustc_driver/Cargo.toml"

rustc-dev component 会提供 rustc-src 也就是 rustc 源码

目前 rust-analyzer 还不支持 extern crate test 的静态分析,但我看 rust-src component 提供了 test crate 的源码:

/home/w/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/test/Cargo.toml

所以 rust-analyzer 和 intellij-rust 将来有望支持 test crate 的静态分析

不过像 libc 虽然 rustup 每个 toolchain 都装了 libc 的 rlib 类型的动态链接库,可惜 rust-src component 没有包括 libc 源码

所以用 extern crate libc 的方式引入 toolchain 自带的 libc 还是不能做静态分析的


语料库

拼写错误候选词建议需求的实现思路可以是: 对常用英语单词的每个单词跟拼写错误的单词去计算编辑距离,取编辑距离最近的 5 个单词作为获选词建议

字符串间编辑距离的算法可以直接用 rustc 源码的 lev_distance,常用英语单词表则需要一个语料库

/usr/share/dict/words

mac 和树莓派的 raspbian 系统都在 /usr/share/dict/words 存放英语语料库,用于系统预装的记事本等应用进行拼写错误检查

像 ubuntu_desktop 或 raspbian 这种带图形桌面环境的 linux 发行版一般会在 /usr/share/dict/words 内置语料库

如果没有找到语料库,可以通过 sudo apt install wbritishsudo pacman -S words 进行安装

除了用操作系统自带的语料库,还可以选用 github 的 english-words 仓库作为语料库


拼写错误检查器 trait

为了方便更换语料库存储的数据结构,需要先对语料库的行为抽象出一个 trait,便于重构或复用代码


#![allow(unused)]
fn main() {
pub trait TypoSuggestion: Sized + Default {
    const MAX_EDIT_DISTANCE: usize = 1;
    const NUMBER_OF_SUGGESTIONS: usize = 5;
    fn insert(&mut self, word: String);
    fn read_os_dictionary(&mut self) {
        /** OS_DICTIONARY_PATH
        macos/raspbian: os built-in diction
        ubuntu: sudo apt install wbritish
        archlinux: sudo pacman -S words
        */
        const OS_DICTIONARY_PATH: &str = "/usr/share/dict/words";
        use std::io::{BufRead, BufReader};
        let word_file = BufReader::new(std::fs::File::open(OS_DICTIONARY_PATH).unwrap());
        for word in word_file.lines().flatten() {
            self.insert(word)
        }
    }
    /// return type Self must bound Sized
    fn new() -> Self {
        let mut typo_checker = Self::default();
        typo_checker.read_os_dictionary();
        typo_checker
    }
    fn is_typo(&self, word: &str) -> bool;
    fn typo_suggestions(&self, word: &str) -> Vec<String>;
}
}

trait TypoSuggestion 核心就两个函数: fn is_typo() 判断输入的单词是否在语料库中, fn typo_suggestions() 如果输入的单词拼写错误才返回若干个最相似的候选词建议

Vec 实现候选词建议

既然操作系统语料库是个每行都是一个单词的文本文件,很容易想到用 Vec<String> 去存储每个单词,我将这个实现命名为: VecTypoChecker


#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct VecTypoChecker {
    words: Vec<String>,
}

impl TypoSuggestion for VecTypoChecker {
    fn insert(&mut self, word: String) {
        self.words.push(word);
    }

    fn is_typo(&self, word: &str) -> bool {
        !self.words.contains(&word.to_string())
    }

    fn typo_suggestions(&self, word: &str) -> Vec<String> {
        let input_word = word.to_string();
        if !self.is_typo(&input_word) {
            return vec![];
        }
        let mut suggestions = vec![];
        for word in self.words.iter() {
            let edit_distance = rustc_span::lev_distance::lev_distance(&input_word, word);
            if edit_distance <= Self::MAX_EDIT_DISTANCE {
                suggestions.push(word.clone());
            }
            if suggestions.len() > Self::NUMBER_OF_SUGGESTIONS {
                break;
            }
        }
        suggestions
    }
}
}

VecTypoChecker 的测试代码如下:


#![allow(unused)]
fn main() {
#[test]
fn test_typo_checker() {
    let typo_checker = VecTypoChecker::new();
    let input_word = "doo";
    println!(
        "Unknown word `{}`, did you mean one of {:?}?",
        input_word,
        typo_checker.typo_suggestions(input_word)
    );
}
}

测试代码的输出结果示例:

Unknown word doo, did you mean one of ["boo", "coo", "dao", "do", "doa", "dob"]?

VecTypoChecker 的时间复杂度

is_typo 要遍历整个数组判断输入单词是否在单词表里,显然时间复杂度是 O(n)

假设单词表中平均单词长度为 k,输入单词的长度为 L,typo_suggestions 的时间复杂度则要 O(n*L*k)

valgrind 和 memusage 测量堆内存使用

其实用数组去存储语料库的每个单词的内存利用率是很低的,很多单词都是重复部分很多

先用 wc 和 du 命令查看操作系统单词表的收录的单词数和占用硬盘空间大小

[w@w-manjaro ~]$ wc -l /usr/share/dict/words

123115 /usr/share/dict/words

[w@w-manjaro ~]$ du -h `readlink -f /usr/share/dict/words`

1.2M /usr/share/dict/american-english

那 12 万个单词 1.2M 的单词文件以数组的数据结构在内存中需要占用多少空间呢?

由于 Rust 标准库的 std::mem::size_of 只能测量栈上的空间,标准库没有测量智能指针在堆上占用空间的方法

所以只能借助可执行文件的内存分析工具 valgrind --tool=massifmemusage


#![allow(unused)]
fn main() {
#[test]
fn test_vec_typo_checker() {
    let _ = VecTypoChecker::new();
}
}

在 memusage 工具内运行上述单元测试,测试内只进行将操作系统语料库读取成 Vec<String> 的操作

memusage cargo test test_vec_typo_checker

这里只关注 memeusage 输出结果的堆内存峰值信息:

Memory usage summary: heap total: 4450158, heap peak: 4409655, stack peak: 8800

VecTypoChecker::new() 过程的堆内存峰值 大约是 4.2 MB,可能有些 Rust内部对象 堆内存使用会影响结果

所以我效仿称重是要「去皮」的操作,让 memusage 测量一个 Rust 空函数的运行时堆内存峰值,空函数的堆内存峰值是 2-3 kb

Rust 其它的一些堆内存使用相比 VecTypoChecker::new() 的 4.2 MB 小到可以忽略不计

Trie 前缀树/字典树

1.2M 大约 12 万个单词用数组去存储大约需要 4.2M 的堆空间,显然不是很高效

例如 doc, dot, dog 三个单词,如果用 Vec 数组去存储,大约需要 9 个字节

但是如果用"链表"去存储,这三个单词链表的前两个节点 'd' 和 'o' 可以共用,这样只需要 5 个链表节点大约 5 个字节的内存空间

这样像链表一样共用单词的共同前缀的数据结构叫 trie,广泛用于输入法,搜索引擎候选词,代码自动补全等领域

前缀树的数据结构

正好 leetcode 上也有 Implement Trie (Prefix Tree) 这种实现 trie 的算法题


#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct TrieTypoChecker {
    children: [Option<Box<Self>>; 26],
    is_word: bool
}
}

解读下前缀树数据结构的 children: [Option<Box<Self>>; 26] 字段

26 表示当前节点往下延伸一共能扩展出 26 个小写字母,用 Option 表达了某个小写字母的子节点是否存在

用 Box 是因为参考了 Rust 单链表的实现,我们希望树的节点能分配到堆内存上,否则编译器会报错 recursive type has infinite size

想更深入探讨 Rust 链表相关问题的读者可以自行阅读 too-many-lists 系列文章

前缀树的 is_word 字段表示从根节点到当前节点的路径能组成一个单词

如果没有这个 is_word 标注,那么插入一个 apple 单词时,无法得知 apple 路径上的 app 是不是也是一个单词

#[derive(Default)]的目的是方便创建一个子节点全为 None 的前缀树节点

前缀树的路径压缩

实际生产环境中前缀树实现会比上述实现要复杂得多,要考虑类似「并查集」的「路径压缩

例如有个单词是aaaaa,那么插入到前缀树就会形成深度为 5 层的树

树的深度过深不够"饱满",这样内存利用率不高,需要把树 压扁 (路径压缩)

前缀树的插入


#![allow(unused)]
fn main() {
impl TypoSuggestion for TrieTypoChecker {
    fn insert(&mut self, word: String) {
        let mut curr_node = self;
        for letter in word.into_bytes().into_iter().map(|ch| (ch - b'a') as usize) {
            curr_node = curr_node.children[letter].get_or_insert_with(|| Box::new(Self::default()))
        }
        curr_node.is_word = true;
    }
}
}

但上述前缀树的插入方法,在读取操作系统的自带的单词表时会 panicked at 'attempt to subtract with overflow'

原因是操作系统的单词表中除了小写字母还有大写字母和单引号

为了简便我们把单词表中的大写字母转为小写再去掉除小写字母以外的字符,这样就能把单词表转为前缀树


#![allow(unused)]
fn main() {
fn insert(&mut self, word: String) {
    let word = word
        .into_bytes()
        .into_iter()
        .map(|ch| ch.to_ascii_lowercase())
        .filter(|ch| matches!(ch, b'a'..=b'z'))
        .collect::<Vec<u8>>();
    let mut curr_node = self;
    for letter in word.into_iter().map(|ch| (ch - b'a') as usize) {
        curr_node = curr_node.children[letter].get_or_insert_with(|| Box::new(Self::default()))
    }
    curr_node.is_word = true;
}
}

再写个构造前缀树并读取操作系统单词表的测试用例,跟数组的实现对比下空间复杂度


#![allow(unused)]
fn main() {
#[test]
fn test_trie_typo_checker() {
    let _ = TrieTypoChecker::new();
}
}

memusage cargo test test_trie_typo_checker

memusage 测试结果显示,前缀树存储 12 万 个单词只需要花 784 kb 的堆内存空间

相比单词表磁盘文件占用 1.2M 硬盘空间,用前缀树存储只 700 多 kb 确实有「压缩」的效果

相比用数组存储单词表消耗 4.2M 内存,前缀树在空间复杂度上大约有 80% 的提升

再写一个性能测试对比数组和前缀树读取单词表的时间复杂度


#![allow(unused)]
#![feature(test)]
fn main() {
extern crate test;
use typo_checker::{TypoSuggestion, VecTypoChecker, TrieTypoChecker};

#[bench]
fn bench_vec_read_dictionary(bencher: &mut test::Bencher) {
    bencher.iter(|| {
        VecTypoChecker::new();
    });
}

#[bench]
fn bench_trie_read_dictionary(bencher: &mut test::Bencher) {
    bencher.iter(|| {
        TrieTypoChecker::new();
    });
}
}

benchmark 的运行结果:

     Running unittests (target/release/deps/bench-c073956b9e337dbe)

running 2 tests
test bench_trie_read_dictionary ... bench:  39,724,024 ns/iter (+/- 2,954,476)
test bench_vec_read_dictionary  ... bench:  11,928,761 ns/iter (+/- 386,083)

发现前缀树插入 12 万个单词比数组快 3 倍,而且前缀树插入单词时还有「去重」的功能,数组插入单词想去重还要额外的性能开销

小结: 前缀树读单词表,时间复杂度上比数组快 3 倍多,空间复杂度上比数组节约 80%

前缀树的查询

查询某个单词是否在前缀树内,其实就是前文提到的 TypoSuggestion trait 的 is_typo 函数


#![allow(unused)]
fn main() {
fn is_typo(&self, word: &str) -> bool {
    let word = word.as_bytes();
    let mut curr_node = self;
    for letter in word {
        let index = (letter - b'a') as usize;
        match curr_node.children[index] {
            Some(ref child_node) => {
                curr_node = child_node.as_ref();
            }
            None => {
                return true;
            }
        }
    }
    !curr_node.is_word
}
}

再写一个 benchmark 对比数组和前缀树的查询功能


#![allow(unused)]
fn main() {
#[bench]
fn bench_vec_search(bencher: &mut test::Bencher) {
    let typo_checker = VecTypoChecker::new();
    bencher.iter(|| {
        assert_eq!(typo_checker.is_typo("doo"), true);
        assert_eq!(typo_checker.is_typo("lettuce"), false);
    });
}

#[bench]
fn bench_trie_search(bencher: &mut test::Bencher) {
    let typo_checker = TrieTypoChecker::new();
    bencher.iter(|| {
        assert_eq!(typo_checker.is_typo("doo"), true);
        assert_eq!(typo_checker.is_typo("lettuce"), false);
    });
}
}

查询功能的测试结果:

test bench_trie_search          ... bench:           8 ns/iter (+/- 2)
test bench_vec_search           ... bench:     351,254 ns/iter (+/- 176,276)

小结: 查询某个单词是否在前缀树比数组快了 5 个数量级


前缀树的编辑距离

虽说前缀树的插入和查询都比数组快,但前缀树的删除比数组要难,前缀树编辑距离的实现更是非常难(需要记忆化深度优先搜索等诸多复杂算法)

知乎上有个相关的提问: 鹅厂面试题,英语单词拼写检查算法 - 知乎

很多回答都引用了这篇文章

说实话最佳回答或上述文章都大量使用了 Python 的字符串拼接,每次拼接操作都会 new 一块字符串的堆内存

这样频繁字符串拼接操作性能开销大,不能让我满意


#![allow(unused)]
fn main() {
impl TypoSuggestion for TrieTypoChecker {
    fn typo_suggestions(&self, word: &str) -> Vec<String> {
        let mut dfs_helper = DfsHelper {
            suggestions: vec![],
            path: vec![],
            typo_checker: &self,
        };
        dfs_helper.dfs(&self);
        dfs_helper.suggestions
    }
}

/// 为了偷懒,把dfs一些不关键的递归间全局共享的状态放到一个结构体
struct DfsHelper<'a> {
    /// 返回值
    suggestions: Vec<String>,
    /// 当前深度优先搜索,从根节点到当前节点的遍历路径
    path: Vec<u8>,
    typo_checker: &'a TrieTypoChecker,
}

impl<'a> DfsHelper<'a> {
    fn dfs(&mut self, curr_node: &TrieTypoChecker) {

    }
}
}

由于搜索的是前缀树内相似的单词,所以不适合用广度优先搜索去遍历,用递归实现深度优先搜索比较方便

为了减少 dfs 函数传参个数以及便于增删和管理递归函数的「无需回溯」的入参,我定义了一个 DfsHelper

首先由于前缀树整体是个树,不方便像数组实现遍历所有单词挨个与输入单词之间计算编辑距离

虽然较难前缀树的编辑距离实现难度很高,但是还是先写出单元测试,以 TDD 的方式开发逐渐迭代和逼近正确的实现代码


#![allow(unused)]

fn main() {
#[test]
fn test_trie_typo_checker() {
    const TEST_CASES: [(&str, &[&str]); 1] = [
        ("doo", &["boo", "coo", "dao", "do", "doa", "dob"])
    ];
    let typo_checker = TrieTypoChecker::new();
    for (input, output) in std::array::IntoIter::new(TEST_CASES) {
        assert_eq!(typo_checker.typo_suggestions(input), output);
    }
}
}

递归的结束条件

由于前文中的 trait TypoSuggestion 的 NUMBER_OF_SUGGESTIONS 参数默认为 5

所以很容易想到一个递归结束条件就是 当前深度优先搜索已经找到 5 个 候选词了

另一个递归结束条件就是输入单词已经被扫描完了

深度优先搜索的剪枝

如果当前遍历到的单词跟输入的单词的编辑距离超过 1,就可以进行「剪枝」

这样能大大减少遍历前缀树的节点数量,作者水平有限,可能还有其它递归结束条件和剪枝条件没能想到

简陋的编辑距离搜索


#![allow(unused)]
fn main() {
impl TypoSuggestion for TrieTypoChecker {
    // ...
    fn typo_suggestions(&self, word: &str) -> Vec<String> {
        let mut dfs_helper = DfsHelper {
            input_word: word.as_bytes().to_vec(),
            input_word_len: word.len(),
            output_suggestions: vec![],
            path: vec![],
        };
        dfs_helper.dfs(&self, 0, 1);
        dfs_helper.output_suggestions
    }
}

struct DfsHelper {
    /// 输入的单词
    input_word: Vec<u8>,
    input_word_len: usize,
    /// 返回值
    output_suggestions: Vec<String>,
    /// 当前深度优先搜索,从根节点到当前节点的路径(path root to curr_node)
    path: Vec<u8>,
}

impl DfsHelper {
    fn dfs(&mut self, curr_node: &TrieTypoChecker, input_word_index: usize, edit_times: i32) {
        if edit_times < 0 {
            return;
        }

        if input_word_index == self.input_word_len {
            if curr_node.is_word {
                self.output_suggestions.push(unsafe { String::from_utf8_unchecked(self.path.clone()) });
            }
            if edit_times == 0 {
                return;
            }
            // 输入单词遍历遍历完了,如果还有编辑次数可用,则用剩余的编辑次数给当前dfs遍历路径组成的单词词尾巴追加字母
            // 例如 input_word="do", trie从根到当前节点的路径d->o遍历完还剩余1次编辑次数,则可以用做增加操作,把g加到当前路径中
            for (i, child_node_opt) in curr_node.children.iter().take(26).enumerate() {
                if let Some(child_node) = child_node_opt {
                    self.path.push(b'a' + i as u8);
                    self.dfs(child_node, input_word_index, edit_times-1);
                    self.path.pop().unwrap();
                }
            }
            return;
        }

        if self.output_suggestions.len() >= TrieTypoChecker::NUMBER_OF_SUGGESTIONS {
            return;
        }

        let curr_letter_index = (self.input_word[input_word_index] - b'a') as usize;
        for (i, child_node_opt) in curr_node.children.iter().take(26).enumerate() {
            if let Some(child_node) = child_node_opt {
                if i == curr_letter_index {
                    self.path.push(self.input_word[input_word_index]);
                    self.dfs(child_node, input_word_index+1, edit_times);
                    self.path.pop().unwrap();
                } else {
                    // replace
                    self.path.push(b'a' + i as u8);
                    self.dfs(child_node, input_word_index+1, edit_times-1);
                    self.path.pop().unwrap();
                }
            }
        }

    }
}
}

输出看上去很接近拼写错误单词:

Unknown word doo, did you mean one of ["boo", "coo", "doa", "dob", "doc", "dod", "doe", "dog", "don", "doom", "door", "dos", "dot", "dow", "doz"]?

遗憾的是还未能实现编辑距离的删除操作,相比知乎上那个最佳回答还少了很多情况的判断

再看看单元测试的情况:

thread 'test_trie_typo_checker' panicked at 'assertion failed: `(left == right)`
  left: `["boo", "coo", "doa", "dob", "doc", "dod", "doe", "dog", "don", "doom", "door", "dos", "dot", "dow", "doz"]`,
 right: `["boo", "coo", "dao", "do", "doa", "dob"]`', src/lib.rs:182:9

首先肉眼看错误单词 doo 返回的候选词基本满足,期望返回 5 个候选词,结果超过返回超过 5 个

但是没有将 do 收录进候选词,因为上述代码还没支持编辑距离的删除操作

其次候选词的排序似乎跟数组的实现不一样,原因是这个前缀树的遍历并不是跟数组按字母顺序遍历单词表一样

准确的说法是26 叉树的深度优先回溯搜索,类似的算法可以参考 leetcode lexicographical 一题

所以单元测试的期待值校验应该改成,遍历每一个候选词用 rustc_span::lev_distance::lev_distance 去计算跟输入单词之间的编辑距离

如果全部候选词的编辑距离小于等于 1 则测试通过

简陋编辑距离实现的不足

  1. 还没支持字符串编辑距离的删除操作
  2. 没有测试入参 edit_times >= 2 的情况
  3. 应该用迭代模拟递归,递归代码对编译器不友好,难优化
  4. 应当做成 iterator 或 generator 可以逐个输出值,返回值要实现标准库相关的 Iter trait
  5. 改良测试用例的期待值校验方法

单词拼写检查器还能干什么

作者一开始参与 sqlx 项目也是只能提 PR 修些 typo (typo 就是单词拼写错误的意思)

通过修 typo 的过程更仔细的阅读了多遍源码,更深入理解 sqlx 的架构,日后渐渐修复了 sqlx sqlite 部分的几个 Bug

本文讲述的这个拼写检查器,还可以用来检查开源项目的一些 typo

Rust 2021 年 4 月的这个 PR 只是修复些拼写错误,但也算对 Rust 的开源社区做出贡献

希望更多人能像作者这样从修复 typo 开始参与开源项目,慢慢能解决更困难的 issue,逐渐为开源社区做出更大的贡献

项目的 github 链接与总结

拼写错误候选词建议源码的 github 仓库链接: https://github.com/pymongo/typo_checker (持续更新,欢迎 star)

总的来说前缀树存储单词表性能会比数组优秀太多,后续打算添加一个检查一篇文章的单词拼写错误例子

然后再加一个实时检测 android 的 EditText 文本输入组件的单词拼写错误的示例

使用 nom 解析 url

编辑:张汉东


原文


nom


在本教程中,我们将演示如何使用 nom 解析器组合器库在 Rust 中编写一个非常基础的 URL 解析器。我们将包含以下内容

什么是解析器组合器?

解析器组合器是高阶函数,可以接受多个解析器作为输入,并返回一个新的解析器作为输出。

这种方式让你可以为简单的任务(如:解析某个字符串或数字)构建解析器,并使用组合器函数将它们组合成一个递归下降(recursive descent)的解析器。

组合解析的好处包括可测试性,可维护性和可读性。每个部件都非常小且具有自我隔离性,从而使整个解析器由模块化组件构成。

如果你对这个概念不熟悉,我强烈推荐你阅读 Bodil Stokke 的用 Rust 学习解析器组合器

nom是如何工作的

nom 是使用 Rust 编写的解析器组合器库,它可以让你创建安全的解析器,而不会占用内存或影响性能。它依靠 Rust 强大的类型系统和内存安全来生成既正确又高效的解析器,并使用函数,宏和特征来抽象出容易出错的管道。

为了演示 nom 是如何工作的,我们将创建一个基础的 URL 解析器。我们不会完整的实现 URL 规范;这将远远超出此代码示例的范围。相反,我们将采用一些捷径。

最终的目标是能够将合法的 URL (如:https://www.zupzup.org/about/?someVal=5&anotherVal=hello#anchorhttp://user:pw@127.0.0.1:8080) 解析成相关的结构,并在解析过程中为非法的 URL 返回一个有用的错误。

而且,由于可测试性被认为是解析器组合器的一大优势,我们将对大多数组件进行测试,以了解其具体的优势。

让我们开始吧!

设置nom

为了进行下面的一系列操作,你需要安装最新的 Rust 版本 (1.44+)。

首先,创建一个新的 Rust 项目:

cargo new --lib rust-nom-example
cd rust-nom-example

然后,编辑Cargo.toml文件并添加你需要的依赖:

[dependencies]
nom = "6.0"

是的,我们需要的是最新版本的nom库(在撰写本文时是 6.0)。

数据类型

编写解析器时,通常先定义输出结构以了解你需要哪些部分是很有意义的。

在这里,我们正在解析一个 URL,因此,让我们给它定义一个结构:


#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
pub struct URI<'a> {
    scheme: Scheme,
    authority: Option<Authority<'a>>,
    host: Host,
    port: Option<u16>,
    path: Option<Vec<&'a str>>,
    query: Option<QueryParams<'a>>,
    fragment: Option<&'a str>,
}

#[derive(Debug, PartialEq, Eq)]
pub enum Scheme {
    HTTP,
    HTTPS,
}

pub type Authority<'a> = (&'a str, Option<&'a str>);

#[derive(Debug, PartialEq, Eq)]
pub enum Host {
    HOST(String),
    IP([u8; 4]),
}

pub type QueryParam<'a> = (&'a str, &'a str);
pub type QueryParams<'a> = Vec<QueryParam<'a>>;
}

让我们逐行进行说明。

这些字段是根据它们在常规 URI 中出现的顺序进行排列的。首先,我们有 scheme。在这里,我们将 URI 的前缀限制为http://https://,但是请注意,这里还有很多其它可选的 scheme。

接下来是authority部分,它由用户名和可选密码组成,通常是完全可选的。

host 可以是 IP,(在我们的示例中仅为 IPv4),也可以是主机字符串,如:example.org,后面跟一个可选的port,port 仅是个数字:如:localhost:8080

在端口之后是 path。它是由/分割的字符串序列,如:/some/important/path。query 和 fragment 部分是可选的,它们表示 URL 的?query=some-value&another=5#anchor部分。query 是字符串元组的可选列表,而 fragment 只是可选字符串(完整的 URL 示例是https://some/important/?query=some-value&another=5#anchor)。

如果你对这些类型中的生命周期('a)感到困惑,请不用感到沮丧;它不会真的影响到我们写代码的方式。本质上,我们可以使用指向输入字符串各部分的指针,而不是为 URL 的每一部分分配新的字符串,只要输入的生命周期和我们 URI 结构一样长就可以了。

在开始解析之前,让我们实现From特征将合法的 scheme 转换成Scheme枚举:


#![allow(unused)]
fn main() {
impl From<&str> for Scheme {
    fn from(i: &str) -> Self {
        match i.to_lowercase().as_str() {
            "http://" => Scheme::HTTP,
            "https://" => Scheme::HTTPS,
            _ => unimplemented!("no other schemes supported"),
        }
    }
}
}

顺便说一句,让我们从顶部开始,开始解析 scheme。

nom中的错误处理

在我们开始之前,先讨论一下 nom 中的错误处理。虽然我们不会面面俱到,但是至少会让调用者大致了解在解析的哪一步出了什么问题。

为了达到我们的目的,我们将使用nom中的context组合器。在nom中,一个解析器通常会返回如下类型:


#![allow(unused)]
fn main() {
type IResult<I, O, E = (I, ErrorKind)> = Result<(I, O), Err<E>>;
}

在本例中,我们将返回一个输入值(&str - 输入字符串)的元组类型。它包含仍需要解析的字符串,以及输出的值。当解析失败时,它也会返回一个错误。

标准的IResult只允许我们使用 nom 内置的错误类型,如果我们想要创建自定义的错误类型以及在这些错误中添加一些上下文呢?

ParserError 特征和 VerboseError 类型让我们可以构建自己的错误类型,并可以在已有的错误中添加上下文。在这个简单的例子中,我们将会在我们的解析错误类型中添加上下文。为了方便起见,让我们定义一个自己的结果类型。


#![allow(unused)]
fn main() {
type Res<T, U> = IResult<T, U, VerboseError<T>>;
}

除了它带有VerboseError之外,本质上是相同的。这意味着我们可以使用 nom 的上下文组合器,它允许我们在任何解析器中隐式地添加错误上下文。

nom 的官方文档包含这些选项,但是错误处理并不是最直观的方法。

为了看到它的实际效果,让我们为该 scheme 创建第一个解析器。

使用Rust写一个解析器

为了解析 URL 的scheme,我们想要匹配http://https://,除此之外没有别的了。由于我们使用的是功能强大的解析器组合器库,因此我们不需要手动编写底层的解析器。nom 已经帮我们覆盖了。

解析器组合器宏清单讲述了在某些用例中如何使用 nom 中的解析器组合器。

我们将会使用tag_no_case解析器和alt组合器来做基础的说明:“每个小写(输入)应该是http://https://” 。在本教程中,我们将只使用常规函数,但请注意,nom 中的许多解析器和组合器也可以作为宏使用。

在 Rust 中使用 nom 如下所示:


#![allow(unused)]
fn main() {
fn scheme(input: &str) -> Res<&str, Scheme> {
    context(
        "scheme",
        alt((tag_no_case("HTTP://"), tag_no_case("HTTPS://"))),
    )(input)
    .map(|(next_input, res)| (next_input, res.into()))
}
}

如你所示:我们使用context组合器封装了实际的解析器并在其中添加了scheme上下文,因此,此处触发的任何错误都将在结果中标记为scheme

一旦将解析器和组合器组装成了整个解析器,便使用输入字符串来调用它,这是我们唯一的输入参数。然后我们对结果进行map - 如上所述,它由剩余的输入和解析的输出组成,并通过实现前面提到的.into()特征将我们解析后的 scheme 转换成Scheme枚举。


#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;
    use nom::{
        error::{ErrorKind, VerboseError, VerboseErrorKind},
        Err as NomErr,
    };

    #[test]
    fn test_scheme() {
        assert_eq!(scheme("https://yay"), Ok(("yay", Scheme::HTTPS)));
        assert_eq!(scheme("http://yay"), Ok(("yay", Scheme::HTTP)));
        assert_eq!(
            scheme("bla://yay"),
            Err(NomErr::Error(VerboseError {
                errors: vec![
                    ("bla://yay", VerboseErrorKind::Nom(ErrorKind::Tag)),
                    ("bla://yay", VerboseErrorKind::Nom(ErrorKind::Alt)),
                    ("bla://yay", VerboseErrorKind::Context("scheme")),
                ]
            }))
        );
    }
}
}

如你所见,在成功的情况下,我们取回已解析Scheme枚举和剩余待解析的字符串(yay)。另外,如果有错误,我们将列举出已触发的错误以及定义的上下文列表(scheme)。

在本例中,两次tag调用都失败了,因此,alt组合器也失败了,因为它无法产生单个值。

那不是很难。在上面我们基本上只是解析了一个常量的字符串,让我们通过解析authority部分来尝试更高级的内容。

解析待授权的URL

如果我们还记得我们在之前的 URI 的结构,尤其是 authority 部分,我们会看到我们正在寻找一个完全可选的结构。如果它存在,则需要一个用户名和一个可选的密码。

这是我们使用的类型别名:


#![allow(unused)]
fn main() {
pub type Authority<'a> = (&'a str, Option<&'a str>);
}

我们该怎么办呢?在 URL 中,它看起来像:

https://username:password@example.org

:password是可选的,但是在任何情况下,它都会以@作为结尾,所以我们可以通过使用terminated解析器开始。这给了我们一个字符串,该字符串是通过终止另一个字符串得到的。

authority部分中,我们看到:作为一个分隔符。根据文档,我们可以使用separated_pair组合器,它通过分割一个字符串给我们提供了两个值。但是我们如何处理实际的文本呢?这里有几种选项,一种是使用alphanumeric1解析器。它生成了一个至少包含一个字符的字母数字字符串。

为了简单起见,我们不必担心可以在 URL 的不同部分使用哪些字符。这与编写和构造解析器无关,只会使所有的内容变得更长且更不方便。出于我们的目的,我们假设 URL 的大部分都可以由字母数字组成,有时候还包含连字符和点 - 根据 URL 标准,这当然是错误的。

让我们来看看组合后的authority解析器:


#![allow(unused)]
fn main() {
fn authority(input: &str) -> Res<&str, (&str, Option<&str>)> {
    context(
        "authority",
        terminated(
            separated_pair(alphanumeric1, opt(tag(":")), opt(alphanumeric1)),
            tag("@"),
        ),
    )(input)
}
}

我们通过运行一些测试用例来检验它是否工作:


#![allow(unused)]
fn main() {
#[test]
fn test_authority() {
    assert_eq!(
        authority("username:password@zupzup.org"),
        Ok(("zupzup.org", ("username", Some("password"))))
    );
    assert_eq!(
        authority("username@zupzup.org"),
        Ok(("zupzup.org", ("username", None)))
    );
    assert_eq!(
        authority("zupzup.org"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (".org", VerboseErrorKind::Nom(ErrorKind::Tag)),
                ("zupzup.org", VerboseErrorKind::Context("authority")),
            ]
        }))
    );
    assert_eq!(
        authority(":zupzup.org"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (
                    ":zupzup.org",
                    VerboseErrorKind::Nom(ErrorKind::AlphaNumeric)
                ),
                (":zupzup.org", VerboseErrorKind::Context("authority")),
            ]
        }))
    );
    assert_eq!(
        authority("username:passwordzupzup.org"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (".org", VerboseErrorKind::Nom(ErrorKind::Tag)),
                (
                    "username:passwordzupzup.org",
                    VerboseErrorKind::Context("authority")
                ),
            ]
        }))
    );
    assert_eq!(
        authority("@zupzup.org"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (
                    "@zupzup.org",
                    VerboseErrorKind::Nom(ErrorKind::AlphaNumeric)
                ),
                ("@zupzup.org", VerboseErrorKind::Context("authority")),
            ]
        }))
    )
}
}

看起来很不错!对于各种情况,我们都有与之对应的测试用例,缺少密码,缺少@以及其他几种错误的情况。

让我们继续来到 host 部分。

Rust解析:主机,IP和端口

因为 host 部分可以包含 主机字符串或者 IP,这一步将会有点复杂。更糟的是,在结尾还有一个可选的:port

为了尽可能保持简单,我们只支持 IPv4 的 IP。我们将从 host 开始。让我们看一下它的实现并逐行进行说明。


#![allow(unused)]
fn main() {
fn host(input: &str) -> Res<&str, Host> {
    context(
        "host",
        alt((
            tuple((many1(terminated(alphanumerichyphen1, tag("."))), alpha1)),
            tuple((many_m_n(1, 1, alphanumerichyphen1), take(0 as usize))),
        )),
    )(input)
    .map(|(next_input, mut res)| {
        if !res.1.is_empty() {
            res.0.push(res.1);
        }
        (next_input, Host::HOST(res.0.join(".")))
    })
}
}

首先你注意到这里有两个选项(alt)。在这两种情况下,都有一个元组,并包含了一个解析器链。

在第一种情况下,我们想要一个或多个(many1)字母数字字符串,包含一个连字符,被一个.终结并以顶级域名 (alpha1) 结尾。

alphanumerichyphen1解析器如下所示:


#![allow(unused)]
fn main() {
fn alphanumerichyphen1<T>(i: T) -> Res<T, T>
where
    T: InputTakeAtPosition,
    <T as InputTakeAtPosition>::Item: AsChar,
{
    i.split_at_position1_complete(
        |item| {
            let char_item = item.as_char();
            !(char_item == '-') && !char_item.is_alphanum()
        },
        ErrorKind::AlphaNumeric,
    )
}
}

这有点复杂,但基本上是 nom 中alphanumeric1解析器带有-的复制版本。我不知道它是否是最好的方式,但是它确实有用。

在任何情况下,主机部分都有第二个选项,它是一个字符串,如:localhost

为什么我们要用将1和1传给many_m_n解析器这种看起来很无用的方式来表示呢?这里的问题是,在alt组合器中,所有的选项都必须返回相同的类型 - 在这里,它是一个字符串向量和另一个字符串的元组。

我们也在map函数中看到,如果元组的第二部分不为空(顶级域名),则将其添加到元组的第一部分。最后,我们构建了一个 HOST 枚举,将字符串部分用一个.相连,并创建了一个原始的主机字符串。

让我们来看一些测试用例:


#![allow(unused)]
fn main() {
#[test]
fn test_host() {
    assert_eq!(
        host("localhost:8080"),
        Ok((":8080", Host::HOST("localhost".to_string())))
    );
    assert_eq!(
        host("example.org:8080"),
        Ok((":8080", Host::HOST("example.org".to_string())))
    );
    assert_eq!(
        host("some-subsite.example.org:8080"),
        Ok((":8080", Host::HOST("some-subsite.example.org".to_string())))
    );
    assert_eq!(
        host("example.123"),
        Ok((".123", Host::HOST("example".to_string())))
    );
    assert_eq!(
        host("$$$.com"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                ("$$$.com", VerboseErrorKind::Nom(ErrorKind::AlphaNumeric)),
                ("$$$.com", VerboseErrorKind::Nom(ErrorKind::ManyMN)),
                ("$$$.com", VerboseErrorKind::Nom(ErrorKind::Alt)),
                ("$$$.com", VerboseErrorKind::Context("host")),
            ]
        }))
    );
    assert_eq!(
        host(".com"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (".com", VerboseErrorKind::Nom(ErrorKind::AlphaNumeric)),
                (".com", VerboseErrorKind::Nom(ErrorKind::ManyMN)),
                (".com", VerboseErrorKind::Nom(ErrorKind::Alt)),
                (".com", VerboseErrorKind::Context("host")),
            ]
        }))
    );
}
}

让我们来到 主机是 IP 的情况。首先,我们需要能够解析 IPv4 的 IP 中每一个的部分(如:127.0.0.1):


#![allow(unused)]
fn main() {
fn ip_num(input: &str) -> Res<&str, u8> {
    context("ip number", n_to_m_digits(1, 3))(input).and_then(|(next_input, result)| {
        match result.parse::<u8>() {
            Ok(n) => Ok((next_input, n)),
            Err(_) => Err(NomErr::Error(VerboseError { errors: vec![] })),
        }
    })
}

fn n_to_m_digits<'a>(n: usize, m: usize) -> impl FnMut(&'a str) -> Res<&str, String> {
    move |input| {
        many_m_n(n, m, one_of("0123456789"))(input)
            .map(|(next_input, result)| (next_input, result.into_iter().collect()))
    }
}
}

为了获取每一个数字,我们尝试使用n_to_m_digits解析器来寻找一到三个连续的数字并将他们转换成 u8

通过这种方式,我们可以查看如何将完整的 IP 解析成u8数组:


#![allow(unused)]
fn main() {
fn ip(input: &str) -> Res<&str, Host> {
    context(
        "ip",
        tuple((count(terminated(ip_num, tag(".")), 3), ip_num)),
    )(input)
    .map(|(next_input, res)| {
        let mut result: [u8; 4] = [0, 0, 0, 0];
        res.0
            .into_iter()
            .enumerate()
            .for_each(|(i, v)| result[i] = v);
        result[3] = res.1;
        (next_input, Host::IP(result))
    })
}
}

在这里,我们要查找的查好是3个后面跟.ip_num,然后是另一个ip_num。在映射函数中,我们将这些独立的结果拼接,从而将u8数组转换成Host::IP枚举。

再一次,我们将写一些测试用例来确保它是正常工作的:


#![allow(unused)]
fn main() {
#[test]
fn test_ipv4() {
    assert_eq!(
        ip("192.168.0.1:8080"),
        Ok((":8080", Host::IP([192, 168, 0, 1])))
    );
    assert_eq!(ip("0.0.0.0:8080"), Ok((":8080", Host::IP([0, 0, 0, 0]))));
    assert_eq!(
        ip("1924.168.0.1:8080"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                ("4.168.0.1:8080", VerboseErrorKind::Nom(ErrorKind::Tag)),
                ("1924.168.0.1:8080", VerboseErrorKind::Nom(ErrorKind::Count)),
                ("1924.168.0.1:8080", VerboseErrorKind::Context("ip")),
            ]
        }))
    );
    assert_eq!(
        ip("192.168.0000.144:8080"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                ("0.144:8080", VerboseErrorKind::Nom(ErrorKind::Tag)),
                (
                    "192.168.0000.144:8080",
                    VerboseErrorKind::Nom(ErrorKind::Count)
                ),
                ("192.168.0000.144:8080", VerboseErrorKind::Context("ip")),
            ]
        }))
    );
    assert_eq!(
        ip("192.168.0.1444:8080"),
        Ok(("4:8080", Host::IP([192, 168, 0, 144])))
    );
    assert_eq!(
        ip("192.168.0:8080"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (":8080", VerboseErrorKind::Nom(ErrorKind::Tag)),
                ("192.168.0:8080", VerboseErrorKind::Nom(ErrorKind::Count)),
                ("192.168.0:8080", VerboseErrorKind::Context("ip")),
            ]
        }))
    );
    assert_eq!(
        ip("999.168.0.0:8080"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                ("999.168.0.0:8080", VerboseErrorKind::Nom(ErrorKind::Count)),
                ("999.168.0.0:8080", VerboseErrorKind::Context("ip")),
            ]
        }))
    );
}
}

将它们放置在一起,我们需要另一个可以同时解析 IP 和 host 的解析器,并返回一个Host


#![allow(unused)]
fn main() {
fn ip_or_host(input: &str) -> Res<&str, Host> {
    context("ip or host", alt((ip, host)))(input)
}
}

最后,让我们来解析端口(原文遗漏):


#![allow(unused)]
fn main() {
fn port(input: &str) -> Res<&str, u16> {
    context(
        "port",
        tuple((
            tag(":"),
            n_to_m_digits(1, 5)
        )),
    )(input)
        .and_then(|(next_input, result)| {
            let port = result.1.parse::<u16>();
            match port {
                Ok(port) => Ok((next_input, port)),
                Err(e) => Err(NomErr::Error(VerboseError { errors: vec![ (input, VerboseErrorKind::Nom(ErrorKind::Digit))] }))
            }
        })
}
}

并使用一些测试用例保证它是可以正常工作的:


#![allow(unused)]
fn main() {
#[test]
fn test_port() {
    assert_eq!(port(":0"), Ok(("", 0u16)));
    assert_eq!(port(":65535"), Ok(("", 65535u16)));
    assert_eq!(
        port(":65536"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                (":65536", VerboseErrorKind::Nom(ErrorKind::Digit))
            ]
        })));
    assert_eq!(
        port(":a"),
        Err(NomErr::Error(VerboseError {
            errors: vec![
                ("a", VerboseErrorKind::Nom(ErrorKind::OneOf)),
                ("a", VerboseErrorKind::Nom(ErrorKind::ManyMN)),
                (":a", VerboseErrorKind::Context("port"))
            ]
        })));
}
}

还不错,一切正常!

使用Rust解析路径

下一步是解决路径问题。在此,我们再次假设该路径中的字符串只能包含带有连字符和点的字母数字字符串,并使用以下帮助程序进行解析:


#![allow(unused)]
fn main() {
fn url_code_points<T>(i: T) -> Res<T, T>
where
    T: InputTakeAtPosition,
    <T as InputTakeAtPosition>::Item: AsChar,
{
    i.split_at_position1_complete(
        |item| {
            let char_item = item.as_char();
            !(char_item == '-') && !char_item.is_alphanum() && !(char_item == '.')
            // ... actual ascii code points and url encoding...: https://infra.spec.whatwg.org/#ascii-code-point
        },
        ErrorKind::AlphaNumeric,
    )
}
}

为了解析path,我们希望可以将由/分隔的字符串解析成字符串向量:


#![allow(unused)]
fn main() {
fn path(input: &str) -> Res<&str, Vec<&str>> {
    context(
        "path",
        tuple((
            tag("/"),
            many0(terminated(url_code_points, tag("/"))),
            opt(url_code_points),
        )),
    )(input)
    .map(|(next_input, res)| {
        let mut path: Vec<&str> = res.1.iter().map(|p| p.to_owned()).collect();
        if let Some(last) = res.2 {
            path.push(last);
        }
        (next_input, path)
    })
}
}

我们总是由/开始。这已经是一个合法的路径了,但是我们仍然可以有0个或更多个(many0)由/分隔的字符串,后面跟一个最终的可选的字符串(如:index.php)。

在映射中,我们检查元组的第三部分(最后一部分)是否存在,如果存在,则将其添加到路径向量中。

让我们为路径也写一点测试用例:


#![allow(unused)]
fn main() {
#[test]
fn test_path() {
    assert_eq!(path("/a/b/c?d"), Ok(("?d", vec!["a", "b", "c"])));
    assert_eq!(path("/a/b/c/?d"), Ok(("?d", vec!["a", "b", "c"])));
    assert_eq!(path("/a/b-c-d/c/?d"), Ok(("?d", vec!["a", "b-c-d", "c"])));
    assert_eq!(path("/a/1234/c/?d"), Ok(("?d", vec!["a", "1234", "c"])));
    assert_eq!(
        path("/a/1234/c.txt?d"),
        Ok(("?d", vec!["a", "1234", "c.txt"]))
    );
}
}

看起来不错!我们获取到了路径中的不同部分以及剩余的字符串,并且它们都被添加到了字符串向量中了。

让我们通过解析 query 和 URL 部分的 fragment 来增强功能。

查询和片段

查询主要是由键值对组成:第一个键前面跟一个?,其余的查询由&进行分隔。再一次,我们将自己限制为有限的url_code_points


#![allow(unused)]
fn main() {
fn query_params(input: &str) -> Res<&str, QueryParams> {
    context(
        "query params",
        tuple((
            tag("?"),
            url_code_points,
            tag("="),
            url_code_points,
            many0(tuple((
                tag("&"),
                url_code_points,
                tag("="),
                url_code_points,
            ))),
        )),
    )(input)
    .map(|(next_input, res)| {
        let mut qps = Vec::new();
        qps.push((res.1, res.3));
        for qp in res.4 {
            qps.push((qp.1, qp.3));
        }
        (next_input, qps)
    })
}
}

实际上这相当不错,因为解析器是非常直观(intuitive)且可读性的。我们解析?后面的第一个键值对的元组,使用=分隔,然后同样的操作执行0或多次,它们是以&而不是?开头。

然后,在映射中,我们简单的将所有的键值对放在向量中,然后就有了我们在文章的开头定义的结构。


#![allow(unused)]
fn main() {
pub type QueryParam<'a> = (&'a str, &'a str);
pub type QueryParams<'a> = Vec<QueryParam<'a>>;
}

这里有一组基础的测试用例:


#![allow(unused)]
fn main() {
#[test]
fn test_query_params() {
    assert_eq!(
        query_params("?bla=5&blub=val#yay"),
        Ok(("#yay", vec![("bla", "5"), ("blub", "val")]))
    );

    assert_eq!(
        query_params("?bla-blub=arr-arr#yay"),
        Ok(("#yay", vec![("bla-blub", "arr-arr"),]))
    );
}
}

最后一部分是 fragment,它其实就是一个#后面跟一个字符串:


#![allow(unused)]
fn main() {
fn fragment(input: &str) -> Res<&str, &str> {
    context("fragment", tuple((tag("#"), url_code_points)))(input)
        .map(|(next_input, res)| (next_input, res.1))
}
}

在介绍了所有这些复杂的解析器之后,为了达到良好的效果,让我们编写一些完整性检查测试:


#![allow(unused)]
fn main() {
#[test]
fn test_fragment() {
    assert_eq!(fragment("#bla"), Ok(("", "bla")));
    assert_eq!(fragment("#bla-blub"), Ok(("", "bla-blub")));
}
}

在Rust中使用nom解析:最终的测试

让我们将它们都放在最顶层的 URI 解析器函数中:


#![allow(unused)]
fn main() {
pub fn uri(input: &str) -> Res<&str, URI> {
    context(
        "uri",
        tuple((
            scheme,
            opt(authority),
            ip_or_host,
            opt(port),
            opt(path),
            opt(query_params),
            opt(fragment),
        )),
    )(input)
    .map(|(next_input, res)| {
        let (scheme, authority, host, port, path, query, fragment) = res;
        (
            next_input,
            URI {
                scheme,
                authority,
                host,
                port,
                path,
                query,
                fragment,
            },
        )
    })
}
}

我们有一个强制的(mandatory)scheme,后面跟一个可选的authority,然后再跟一个强制的ip 或 host。最后后面跟可选的portpathquery 参数,和一个fragment

在映射中,剩下的唯一一件事就是将解析后的元素构成成我们的URI结构。

在这一点上,你可以看到整个结构的美观性和模块化。如果 uri 函数是你的起点,那么你可以从头到尾查看每个单独的解析器,以了解整个过程在做什么。

当然,我们也需要对uri解析器进行一些测试:


#![allow(unused)]
fn main() {
#[test]
fn test_uri() {
    assert_eq!(
        uri("https://www.zupzup.org/about/"),
        Ok((
            "",
            URI {
                scheme: Scheme::HTTPS,
                authority: None,
                host: Host::HOST("www.zupzup.org".to_string()),
                port: None,
                path: Some(vec!["about"]),
                query: None,
                fragment: None
            }
        ))
    );

    assert_eq!(
        uri("http://localhost"),
        Ok((
            "",
            URI {
                scheme: Scheme::HTTP,
                authority: None,
                host: Host::HOST("localhost".to_string()),
                port: None,
                path: None,
                query: None,
                fragment: None
            }
        ))
    );

    assert_eq!(
        uri("https://www.zupzup.org:443/about/?someVal=5#anchor"),
        Ok((
            "",
            URI {
                scheme: Scheme::HTTPS,
                authority: None,
                host: Host::HOST("www.zupzup.org".to_string()),
                port: Some(443),
                path: Some(vec!["about"]),
                query: Some(vec![("someVal", "5")]),
                fragment: Some("anchor")
            }
        ))
    );

    assert_eq!(
        uri("http://user:pw@127.0.0.1:8080"),
        Ok((
            "",
            URI {
                scheme: Scheme::HTTP,
                authority: Some(("user", Some("pw"))),
                host: Host::IP([127, 0, 0, 1]),
                port: Some(8080),
                path: None,
                query: None,
                fragment: None
            }
        ))
    );
}
}

它没问题!你可以在 Github找到完整的代码。

结论

真是太好了!我希望本文能够使你对 Rust 中的解析器特别是解析器组合器感到兴奋。

nom库解析速度特别快,是很多生产级别的库和系统的基础。除此之外,它还提供了出色的 API 和文档。

Rust 生态系统还提供了更多的解析选项,如:combinepest

真实世界的设计模式 | 外观模式(Facade Pattern)

作者:张汉东 / 编辑:张汉东

编者按:

本文摘录自开源电子书《Real World Rust Design Pattern》,这本书也是我创建的免费开源电子书,目前正在逐步完善中,欢迎贡献。

这本书旨在挖掘和记录 Rust 开源生态中设计模式的真实实践。欢迎参与贡献!


Facade(外观)模式

Rust 中最常用的设计模式是哪个?答案是,外观模式。

为什么这么说?看完本文就明白了。

一句话介绍

Facade,中文术语叫「外观模式」,也叫「门面模式」。在经典设计模式中,归为结构型(Structural)模式分类,因为这种模式用于帮助构建结构。它可以为程序库、框架或其他复杂情况提供一个简单的接口。

解决了什么问题

在软件开发中,有时候要处理很多同类型的业务,但具体处理方式却不同的场景。因此,建立一个「门面」来达到统一管理和分发的目的。

Facade 模式,帮忙建立了统一的接口,使得调用复杂的子系统变得更加简单。因为 Facade 模式只包括应用真正关心的核心功能。

如何解决

心智图:

                 +------------------+
+-------+        |                  |         +---------------+
|       |        |                  |         |  additional   |
|client +------> |     facade       +-------> |  facade       |
+-------+        |                  |         |               |
                 |                  |         |               |
                 +--+----+------+--++         +---------------+
                    |    |      |  |
           +--------+    |      |  +--------+
           |          +--+      +-+         |
           |          |           |         |
           v          |           v         v
       +---+---+  +---v--+   +----+--+  +---+----+
       |       |  |      |   |       |  |        |
       | system|  |system|   |system |  | system |
       |       |  |      |   |       |  |        |
       +-------+  +------+   +-------+  +--------+

真实案例

实现方式:

Rust 中的 门面模式 实现有三类:

模块 Re-Export

模块 Re-Export 是重导出功能。

比如,现在有如下模块层级:


#![allow(unused)]
fn main() {
src/
    - lib.rs
    - module/
        -- mod.rs
        -- submodule/
            --- mod.rs
}

Rust 允许你将 潜入到最深处的那个模块 submodule 里定义的函数,使用重导出功能,变成整个库的「门面」接口。


#![allow(unused)]
fn main() {
// in module/submodule/mod.rs
pub fn goodbye(){}

// in lib.rs
pub use module::submodule::goodbye;
}

那么在使用这个库(假设叫 hello)的时候,只需要使用 hello::goodby就可以使用这个函数。

这种方式在 Rust 的世界大量使用。比如 标准库 很多接口是重导出了 核心库 的 API。

在 Furutes-rs 中也有很多重导出。

条件编译

条件编译也是一种 门面模式。

比如在 TiKV 中,使用 条件编译 和 features 来支持多种内存分配器。


#![allow(unused)]
fn main() {
#[cfg(all(unix, not(fuzzing), feature = "jemalloc"))]
#[path = "jemalloc.rs"]
mod imp;
#[cfg(all(unix, not(fuzzing), feature = "tcmalloc"))]
#[path = "tcmalloc.rs"]
mod imp;
#[cfg(all(unix, not(fuzzing), feature = "mimalloc"))]
#[path = "mimalloc.rs"]
mod imp;
#[cfg(not(all(
    unix,
    not(fuzzing),
    any(feature = "jemalloc", feature = "tcmalloc", feature = "mimalloc")
)))]
#[path = "system.rs"]
mod imp;
}

实际上并不存在 imp 模块,通过不同的 cfg 判断,对应不同的 path,从而选择相应的模块:jemalloc.rs/tcmalloc.rs/mimalloc.rs/system.rs。而 imp 模块就是一个「门面」。

利用 类型 和 Trait

第三种方式,就是常规的 利用 类型 和 trait 来实现门面模型。

最典型的就是官方出的 log 库。


#![allow(unused)]
fn main() {
pub trait Log: Sync + Send {
    /// Determines if a log message with the specified metadata would be
    /// logged.
    ///
    /// This is used by the `log_enabled!` macro to allow callers to avoid
    /// expensive computation of log message arguments if the message would be
    /// discarded anyway.
    fn enabled(&self, metadata: &Metadata) -> bool;

    /// Logs the `Record`.
    ///
    /// Note that `enabled` is *not* necessarily called before this method.
    /// Implementations of `log` should perform all necessary filtering
    /// internally.
    fn log(&self, record: &Record);

    /// Flushes any buffered records.
    fn flush(&self);
}
}

官方通过指定这个 trait ,来创建了一个 「门面」。其他 log 库,比如 env_log / sys_log 等其他 log 库,都可以实现 Log trait。


#![allow(unused)]
fn main() {
// env_log
impl Log for Logger {
    fn enabled(&self, metadata: &Metadata) -> bool {
        self.filter.enabled(metadata)
    }

    fn log(&self, record: &Record) {
        if self.matches(record) {
            // ignore many codes
        }
    }

    fn flush(&self) {}
}


// syslog

impl Log for BasicLogger {
  fn enabled(&self, metadata: &Metadata) -> bool {
    true
  }

  fn log(&self, record: &Record) {
    //FIXME: temporary patch to compile
    let message = format!("{}", record.args());
    let mut logger = self.logger.lock().unwrap();
    match record.level() {
      Level::Error => logger.err(message),
      Level::Warn  => logger.warning(message),
      Level::Info  => logger.info(message),
      Level::Debug => logger.debug(message),
      Level::Trace => logger.debug(message)
    };
  }

  fn flush(&self) {
      let _ = self.logger.lock().unwrap().backend.flush();
  }
}

}

这样,不管用户使用哪个 log 库,行为是一样的,达到了一致的用户体验。

第二个例子是 mio 库。

mio 库中的 poll 方法,就使用了门面模式。


#![allow(unused)]

fn main() {
pub struct Poll {
    registry: Registry,
}

/// Registers I/O resources.
pub struct Registry {
    selector: sys::Selector,
}

impl Poll {
    /// Create a separate `Registry` which can be used to register
    /// `event::Source`s.
    pub fn registry(&self) -> &Registry {
        &self.registry
    }

    pub fn poll(&mut self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
        self.registry.selector.select(events.sys(), timeout)
    }
}
}

mio 是实现了跨平台的非阻塞I/O接口的 Rust 抽象,通过实现 Poll 这样一个门面,屏蔽了底层不同平台的 I/O 系统调用细节,比如 epoll/kqueue/IOCP。

第三个案例是 Cranelift

Cranelift 是一个编译器,目前用于 wasmtime 和 rustc debug 模式下。最近 Cranelift 在重构新的 后端,以支持不同的架构平台:Arm/X86等。

在 Cranelift 内部通过一个 MachBackend trait 来抽象出一个 后台门面,只关心核心逻辑:编译给定的函数。


#![allow(unused)]
fn main() {
/// Top-level machine backend trait, which wraps all monomorphized code and
/// allows a virtual call from the machine-independent `Function::compile()`.
pub trait MachBackend {
    /// Compile the given function.
    fn compile_function(
        &self,
        func: &Function,
        want_disasm: bool,
    ) -> CodegenResult<MachCompileResult>;

    // ignore others functions
}
}

然后给不同的平台来实现这个 trait:


#![allow(unused)]

fn main() {
impl MachBackend for AArch64Backend {
    fn compile_function(
        //...
    ){/* ... */}
}

impl MachBackend for X64Backend {
    fn compile_function(
        //...
    ){/* ... */}
}

impl MachBackend for Arm32Backend {
    fn compile_function(
        //...
    ){/* ... */}
}

}

然后在上层代码 Context 接口调用 compile_and_emit 方法时,就可以按当前平台信息生成相应指令:


#![allow(unused)]
fn main() {
pub fn compile_and_emit(/*...*/){

    // ...
    let info = self.compile(isa)?;
    //
}

pub fn compile(&mut self, isa: &dyn TargetIsa) -> CodegenResult<CodeInfo> {
    // ...
    if let Some(backend) = isa.get_mach_backend() {
        let result = backend.compile_function(&self.func, self.want_disasm)?; // 调用 compile_function
        let info = result.code_info();
        self.mach_compile_result = Some(result);
        Ok(info)
    } 
    // ...

}

// cranelift/codegen/src/machinst/adapter.rs 
// 返回 MachBackend 对象
fn get_mach_backend(&self) -> Option<&dyn MachBackend> {
    Some(&*self.backend)
}

}

所以,整个调用流程是:Context -> compile_and_emit -> compile -> get_mach_backend -> compile_function ,然后到各个架构平台。

结语

综上,门面模式是 Rust 应用最广泛的一个设计模式。感谢阅读,如有错漏,欢迎反馈和补充。

Rust 探索系统本原 | RAII

作者:陈天 / 后期编辑:张汉东

原文链接:https://mp.weixin.qq.com/s/jaKjzc_1rkDe67rfpnFTgg


透过 Rust 探索系统的本原:RAII

RAII - Resource Acquisition Is Initialization[1] 是个听起来高深莫测不知所云,但实际理解起来并不困难的概念。我们在理解一个新的解决方案的时候,先深入了解它面临的问题,再看之前的解决方案(prior work),然后再看它是怎么解决同样的问题,最后比较优劣。这大概是做研究的通识,也是我们从本原去真正理解事物的最佳途径。

如果要找计算机历史上最离谱的缩略词,RAII 一定会榜上有名,从嘴里吐出这个词却不做额外解释,除了用于居高临下地吓唬初学者,似乎别无他用。Resource Acquisition Is Initialization,中文翻译为「资源获取即初始化」,无论中英文,都很难让人明白它的真实含义。但这里有个词值得我们警觉:资源。

这里的资源,是指文件,锁,套接字等由操作系统提供给应用程序的,数量有限的东西。虽然内存往往也是操作系统提供的一种资源,但它的处理和上述其它资源还是有许多不同。

在我的上一篇文章《透过 Rust 探索系统的本原:编程语言》里,我提到了 Rust 是如何通过把问题回归到「一个值(value)在上下文中被安全访问的基本规则」,从而解决了内存安全的问题。

虽然很多语言也可以通过其他手段解决内存安全的问题,比如使用 GC 来在运行时对内存的使用做引用计数,如果一个 socket 对象的引用计数为零,GC 会把 socket 对象对应的内存清除,但如果 socket 本身没有被显式 close,这个 socket 资源就会被泄露。很多 Java 工程师会遇到系统运行一段时间便累计了大量 CLOSE_WAIT 的 socket,最终会吃光进程的文件句柄,抛出 too many files 异常的问题[2]。所以,如果你没有在各种可能的路径中妥善地关闭 socket,那么,即便使用一个内存安全的系统,依然有资源泄露的可能。

所以,虽然同为资源,内存和其它操作系统提供的资源,对于程序员来说,其处理方式是非常不同的。你即便不用担心内存资源的释放,但却要妥善地释放锁,关闭 socket,关闭文件等等。

有没有可能让资源的处理方式和内存的处理方式统一起来?这就是 RAII 要解决的问题。统一的方式其实很直观,就是让资源和资源对应的对象的生命周期保持一致,具体来说:

  • 对象的初始化会导致资源的初始化
  • 对象的释放会导致资源的释放

这里有两重保障:对象创建成功一定意味着资源获取成功;而对象释放成功则资源一定得到释放。所以使用 RAII 的话,我们只需要保证内存安全(没有内存泄漏),就能够保证资源安全(没有资源泄露)。

从这里我们可以看到 RAII 是一个多么奇怪的名字 —— 它字面上只包含了上面的第一层功能(对象的初始化会导致资源的初始化),直接忽略了第二层功能,而被忽略的第二层功能其实是 RAII 真正需要保障的。

RAII 是一种资源释放的思路,这种思路被应用在各种语言之中(尽管具体采用的方法不同),比如:

  • 在 Python 中,我们可以使用 with 来确保资源在退出某个 scope 时一定会被释放
  • 在 Golang 中,我们可以使用 defer 让资源获取的代码和资源释放的代码尽可能靠近,这样让程序容易理解。
  • 在 Rust 中,RAII 的思维被深深地嵌入到语言之中:所有权模型保证了当前 scope 所拥有的对象在退出 scope 时必然会被释放,而 Drop trait 保证了释放时,其相关的操作系统资源也得到释放。

我们以 Mutex Lock 为例,看看 Non-RAII 代码和 RAII 代码的区别:


#![allow(unused)]
fn main() {
fn bad() {
  m.lock();
  f(protected_data);
  if failed() return;
  m.unlock();
  ...
}
}

这段代码是典型的 Non-RAII 代码(也是很多语言处理资源的通用方式),调用者需要显式地释放锁。然而在整个过程中,可能出现错误,可能抛出异常,所以,释放锁的代码很可能没有执行,导致锁资源泄露。这是很多死锁问题出现的一大原因。

而支持 RAII 的代码:


#![allow(unused)]
fn main() {
fn good(m: Arc<Mutex<Protected>>) {
  let guard = m.lock();
  f(*guard);
  if failed() return;
  ...
}
}

在这里例子里,lock() 使用 RAII 实现,在获取这个锁的时候,它初始化了一个 MutexGuard 结构,这个结构里包含了对 Mutex 的引用,在做 DerefMut 时,可以访问到 Mutex 内部数据的可变引用,因而可以对数据进行修改。同时,MutexGuard 实现了 Drop ,里面处理了锁的释放,这样,当前的函数(scope)执行完退出时,锁就自动被释放了。

为什么 RAII 没有被普遍实现?

通过上文的介绍,我们可以看到 RAII 极大地解放了开发者,使其不必关心资源的释放,所写的代码反而比进行资源释放的代码更加安全可靠。为什么这种实践没有成为一种更加广泛的实践呢?似乎目前只有 C++ 在有限的场合使用,而实现地比较透彻的只有 Rust?

这还是因为 Rust 的所有权模型从本质上规范了一个值可以被安全使用和安全释放的场景,而这种规范对资源来说也是完美契合的。这就好比麦克斯韦把电磁光统一在一个框架之下,Rust 也把内存和其它资源统一成一种行为。

我们还是拿 Java 这种使用 GC 的语言来比较(不好意思 Java 我不是针对你)。如果你在堆上创建了一个文件资源,如果使用 RAII,意味着堆上的文件对象被释放的时候,这个文件资源也该关闭(file.close()),这么做是最安全也是最合理的。然而,因为堆上的对象什么时候被释放是不可知的,就算所有引用都不存在,GC 已经将其 mark 成可回收,回收线程什么时候被调度,依旧是无法保证的,就像薛定谔的猫。所以 Java 建议你资源用完了就要立刻关闭,于是你不得不殚精竭虑地照顾好每一个 try catch,每一次提前返回,以便 finally 你总是可以把资源关闭;你还得小心地处理资源的传递,传来传去,到处引用的资源,何时关闭可不是一件容易说清楚的事情。那么,既然你接受了「资源用完就立即关闭」的设定,你便不能在 finalize 里做关闭文件的事情,因为这将会导致资源的重复释放。这就是为什么 Java 无法做 RAII,或者 Python,golang 这些语言无法做 RAII 的原因。这也是为什么 Pythong 发展出了 with,golang 发展处 defer 来确保同一个 scope 下创建的资源,可以自动(正常)释放。不过,这些都是于小处的补丁,他们并不能完全解决 RAII 要解决的问题。

在 Rust 里,如果你在堆上创建一个文件资源,根据所有权模型,堆上的内存必然会有一个栈上的 owner(所有者),不管这个 owner 是单一 owner(如 Box),还是引用计数的 owner(如 Arc),也不管他们如何移动(move),被各种引用,Rust 只需关心 owner 离开 scope(对于 Arc 来说,最后一个 Arc 的 owner 离开 scope),此时 Drop 会被调用(仿佛此处有只麦克斯韦妖),从而释放堆上的内存和资源,简单直观。而所有这一切的决策,都在编译时完成,运行时只不过是按部就班执行编译出来的代码而已。

贤者时刻

和所有权模型一样,RAII 也是一种大道至简的处理问题的思路。相对于「使用完资源后,在所有可能的地方都妥善释放之以避免资源泄露」这种「头疼医头脚疼医脚」的方法,当我们重新考虑资源的生命周期,为其添加约束,限制住资源和对应的内存对象相同的生命期之后,一切变得简单而可控。

参考资料

[1] RAII: https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization

[2] Socket leak in Java app: http://www.javamonamour.org/2019/09/sockets-leak-case-study.html

[3] Socket leak in golang: https://www.reddit.com/r/golang/comments/b3adpq/nethttp_transport_leaking_established_connections/

透过 Rust 探索系统的本原:编程语言

作者:陈天 / 后期编辑:张汉东

原文链接:https://mp.weixin.qq.com/s/ZA-_BARVAWe0Q4eM0lYgwg


连续写了好几篇和 Rust 相关的文章,有朋友说:你好像还没有写过一篇比较正式的介绍 Rust 的文章哦。想了想确实如此,我写过不少介绍编程语言的文章,包括:Python,JavaScript,Racket,Clojure,Elixir,甚至我个人不怎么喜欢的 Golang,却没有正儿八经写一篇关于 Rust 特点或者 Rust 世界观的文章。

于是,我开始构思一篇从编程语言设计的角度出发,来探寻 Rust 的独到之处,以及这样的思想可以如何应用在我们的工作当中。正巧,我司(Tubi)内部也渐渐有越来越多的同学对 Rust 感兴趣,想让我做个 BBL 讲讲;我想了想,一个 BBL 似乎很难全面地介绍一门语言,于是构思了九讲,分九周完成;而这其中,第一讲是至关重要的,我需要让一群对 Rust 并不了解的工程师能在 45 分钟内 get 到 Rust 的精髓,这样,后续的讲座才有存在的意义。

结果就诞生了这篇文章,以及与之相关的 slides。

编程语言的世界观

一门编程语言的诞生,一定有它想解决的问题。而围绕着这个问题,语言会有自己的一个世界观。比如说我们熟知的 Erlang 的世界观:

  • everything is a process.
  • process are strongly isolated.
  • process creation and destruction is a lightweight operation.
  • message passing is the only way for processes to interact.
  • processes have unique names.
  • if you know the name of a process you can send it a message.
  • processes share no resources.
  • error handling is non-local.
  • processes do what they are supposed to do or fail.

在这个世界观下,Erlang 使用了 6 个基本函数:spawnsendreceiveregisterwhereisself,构建了一个恢弘大气的分布式系统。

再比如 Golang,其基本的处理并发的思想人人熟知:Do not communicate by sharing memory; instead, share memory by communicating。但要想轻松地用几句话概括整个 golang 这门语言的世界观,似乎有些费劲。Rob Pike 介绍过 golang 的最初想法 [2]:

  • a compiled, concurrent, garbage-collected, statically typed language
  • It must work at scale, for large programs with large numbers of dependencies, with large teams of programmers working on them.
  • It must be familiar, roughly C-like.
  • It must be modern.
  • Composition not inheritance.
  • No conventional exception facility since exceptions make it too easy to ignore

所谓种瓜得瓜种豆得豆,语言诞生初期的世界观,决定了语言之后的走向。很多人抱怨 golang 没有泛型,但泛型恰恰和 C-like 这个思想是冲突的,因而如果要引入泛型, golang 势必需要对其世界观进行妥协,这种妥协带来的代价将会是巨大的(起码会急剧影响 golang 语言的简单性,以及其引以自豪的编译速度)。

对于 Rust 而言,它诞生的初衷是作为一门可以替代 C++ 的系统级语言,满足如下核心价值观[3]:

  • Memory safety
  • Speed (Zero cost abstraction)
  • Productivity

显然,Memory safety 和 productivity 是 C++ 开发者的痛点。一门新的系统级语言可以在不失其效率的情况下达到内存安全么?C++ 做了很多探索,创造了不少智能指针,把 RAII(Resource Acquisition Is Initialization)引入语言当中,但由于历史的包袱,使得 C++ 只能在现有的体系下修补,无法从根上解决这一问题。Rust 则不然,它站在 C++ 的肩膀上,引入了所有权(ownership)和借用机制来提供内存安全,并创造性地使用了类型安全来辅助并发安全。所有权和借用机制虽然优雅且很容易理解,但它和我们所熟知的编程范式大为不同,让程序员无法随心所欲写出错误的代码,再加上 Rust 有意地在编译时就把这些错误暴露给开发者,使得 Rust 初期的学习曲线变得非常陡峭,对那些学了个一知半解就喜欢上手的开发者来说,尤其陡峭。

也因为 Rust 对自己系统级语言的定位,使得它对性能有一种偏执 —— 语言提供给开发者的抽象需要尽可能达到零成本(Zero-cost abstraction):任何功能只要你不用,就没有成本;你如果使用,你自己手工优化的代码也不可能比语言为你生成的代码效率更高。因为这种偏执,使得 Rust 即便早期对异步 IO 有 green thread 这样在其他语言看来已经是相当不错的支持,在 Rust 进入 1.0 版本时,这个功能还是被彻底拿掉[3],因为它会像 golang 一样,即便开发者不使用任何 green thread,其运行时带来的额外开销还在那里。所以 Rust 对异步 IO 的支持,直到 1.0 发布数年后的 Rust 1.39 async/await 正式 stable,才重新完美具备。既然提到了 async/await,容我再多说一句,我认为 Rust 在 async/await 的零成本抽象上带给整个编程语言世界的影响是巨大而长远的,它就像那个打破了「四分钟一英里」魔咒的 Roger Banister,让世界知道:高级的抽象并不必然以牺牲性能或者添加额外的运行时为代价。

Rust 还有一个重要的,被大家低估的世界观:公开透明(explicitness)。使用者可以对他所写的代码做到完全了解和掌控。

很多「高级」编程语言会营造一种易于学习的氛围:你不需要了解一切,不需要熟悉计算机工作原理,不需要掌握操作系统的基本知识,你也可以「高效」编程。这其实是一种假象。如果你做的事情仅仅和 CRUD 相关,那么掌握一些高层次的 API 的确可以很好地完成工作,但当你面临更复杂的系统设计时,当你想成为一名有追求的开发者时,你会遭遇瓶颈 —— 你还是得老老实实构建需要的知识体系,可是当初的「轻松」已经成为负担,就像练习钢琴一开始在双手的姿势上走了捷径,随着联系难度的增高,这捷径会反噬你。

而且这种假象还会被人才市场无情戳破。Java 工程师的确不需要了解内存的运作机制也能编程,但面试的时候,GC 的原理,Java memory leak 可能产生的原因,Java VM 是如何工作的这类问题还是屡见不鲜。原因无他,你如果不了解一切,你无法写出高效安全且设计良好的代码。同样是程序员,在并非供不应求的大环境下,用人单位更青睐那些有追求的程序员。

Rust 没有试图遮掩,它将所有你需要了解的细节明确地在编译环节暴露出来,并且把什么可为什么不可为的边界清晰地展现。这的确会给开发者带来学习的负担 —— 如果一个开发者对一门语言的从小工到大牛的掌握过程中所经受的 全部痛苦 是 100 分的话,Rust 的公开透明 —— 编译器把丑话说在前面 —— 帮你把 100 分降低为 90 分,然后在头 6 个月让你经受 70 分痛苦,接下来的 5-8 年经受剩下 20 分的痛苦;而其它语言会让你在头一两年只经受 20-30 分的痛苦,哄着你,呵护着你,然后在接下来的 5-8 年让你慢慢经受之后的 70-80 分的痛苦。

此外,很多语言没有明确的边界,有些地方做很多限制,有些地方却什么限制都没有,使得编程本身需要靠开发者额外的自觉或者规范才能保证代码的正确性。比如 Java 在内存分配和回收上设定了边界和限制,但在内存的并发访问上没有设定边界和限制,开发者如果不遵循一定规范,很难做到代码的线程安全。C 语言几乎没有设定任何边界和限制,使得指针的解引用成为开发者的梦魇。而 Rust 对一个值在某个 scope 下的所有可能访问做了严格的限制,并通过编译器将这些规则明确地告诉开发者(我们下文再展开)。

编程语言设计上的取舍

不同的编程语言为了解决不同的问题,形成了自己初始的世界观和价值观。而这些世界观和价值观,会严重影响编程语言设计上的取舍。一般而言,一门语言在设计之初,总需要在:性能(performance),安全(safety)和表达力(expressiveness)上做取舍。我画了个图,粗略地概括了不同编程语言在这些方向上的取舍:

programming_lang1.jpg

Assembly/C/C++ 显然是为了效率牺牲(部分)安全性和表达能力。这带来的后果是开发难度太大。

为了达到内存安全,以 Java 为首的很多语言采用了 GC(垃圾回收)。这意味着用其开发出来的系统不得不忍受三大弊病:1) 巨量内存消耗 —— 内存使用可以达到非垃圾回收系统的 1.5-5 倍之多。2) STW(Stop The World),垃圾回收线程工作时会导致其它线程挂起以方便清理内存。Erlang 为解决这个问题,把垃圾回收的粒度缩小到每个 process。3) 开发者并不太在意堆上内存的分配,甚至会无节制地使用堆上的内存。

OjbC 和 Swift 采用了 ARC(自动引用计数)的方式管理内存,编译器会分析每个对象的生命周期,为其插入维护引用计数的代码,当引用计数为 0 时释放对象使用的内存。这种方式相对于 GC,其内存开销和计算开销都大大减小,没有 STW 的问题。但 ARC 无法很好处理循环引用(retain cycle),需要开发者手工处理(使用 weak reference 或者 unowned reference),如果处理不妥则会带来内存泄漏。

尽管 ARC 带来的开销很小,我们要记得,它还是有额外开销的。

大部分编程语言并不提供太多对并发安全的保护。Java 提供了内存安全,但如果你要保证代码的线程安全,需要遵循某些规范,比如:

  • 如果可能,使用 thread-local fields,避免在多个线程内共享状态
  • 使用并发安全的数据结构,如 ConcurrentHashMap,atomic classes
  • 如果要在多线程共享状态,那么,保证共享的状态是只读的
  • 如果要在多线程共享可写状态,那么,使用 synchroized method/statement,或者加锁,保证「写」访问是独占的

编译器并不强迫你遵循这些规范,所以,一个不理解并发安全的程序员很可能写出编译通过但导致 race condition 的代码。

而小部分保证了并发安全的语言,则在内存安全的前提下,引入了一些特定的规则:

  • Javascript 运行在单线程之下,也就无所谓并发安全。
  • Erlang/Elixir/Sala(akka) 使用 actor model,开发者通过消息传递做 process 之间的同步,这样避免了共享内存,从而避免了 race condition。代价是额外的堆上的内存分配和拷贝。
  • Golang 采用了 CSP(Communication Sequential Process),使用 channel 来同步 goroutine,也避免了共享内存。同样的,代价是额外的堆上的内存分配和拷贝。

以上无论内存安全还是并发安全的解决方案,都有不菲的代价。这对于把安全和性能作为语言核心要素的 Rust 来说是不可接受的。

所以 Rust 注定要另辟蹊径。这个「蹊径」就是上文提到的所有权和借用规则,其原理非常简单:

  1. 在一个作用域(scope)内,一个值(value)只能有一个所有者(owner):
    1. 当所有者离开作用域时,值被丢弃(Drop)
    2. 值可以从一个作用域移动(move)到另一个作用域,但当前所有者立刻失去对值的所有权
  2. 值可以被借用(reference),但借用的生存期不能超过所有者的生存期(lifetime):
    1. 在一个作用域内,允许有多个不可变借用
    2. 或者至多一个可变借用(可变借用是独占的)

这个规则非常简单,如果你回顾我们写线程安全代码所遵循的规则,二者非常类似。只不过,Rust 把这些规则变得更明确,并且在编译器里强制执行。如果开发者的代码违反了任何一条规则,代码将无法编译通过。

这成为 Rust 带给开发者极大痛苦的一个根源。和 C/C++/Java 相比,Rust 编译器苛责你不仅要写出语法正确的代码,还必须写出符合内存安全和并发安全的代码,否则,不让你编译通过。因而,你需要非常仔细地考虑你是如何设计你的系统的:数据是否共享,如果共享,它们是如何传递,如何被引用,都被谁引用,生存期有多长等等。

也就是说,Rust 强迫你好好设计你的系统,你的接口,减少写代码的随意性,减少内存访问的随意性,把大部分问题都扼杀在摇篮。这看似不近人情的处理方式,其实极大地帮助我们成为更好的程序员。

当然,Rust 的强迫和其它语言的强迫有本质的不同。为了并发安全,Golang 强迫你使用 channel,Erlang 强迫你接受 actor model,它们都剥夺了你创建线程的权力,即便你很明确你要做的事情是计算密集而非 I/O 密集的任务。在这些体系之下,开发者没有太多的选择。Rust 只是强迫你写「对」的代码,但并不强迫你选用什么样的工具来达到这一路径。同样是并发处理,你可以使用 atomics,可以共享内存(Arc<RwLock<T>>),可以使用异步任务,当然也可以使用 channel(类似 Golang)。你可以使用线程,使用异步任务,甚至混用它们。Rust 不关心你实现的手段,只是强迫你把代码写对。

单单靠上面的所有权和借用规则,还不足以完全保证并发安全,Rust 巧妙地使用了类型系统,来帮助构建一个完整的并发安全体系。在 Rust 里,类型的行为是通过 Trait 来赋予的,几乎所有的数据结构都实现了一个到多个 Trait。其中,有一些特殊的 Trait 来说明类型安全:

  • Send:标记一个类型是否可以安全地在进程间移动(move)所有权
  • Sync:标记一个类型是否可以安全地在进程间共享

比如,Rc<T>(线程内引用计数的类型)被标记为没有实现 SendSync,因而,要想跨线程使用 Rc<T> ,Rust 编译器会报错,并且告诉你,你在某个上下文中使用了类型不安全的数据(在这里,你只能使用 Arc - Atomic Reference Counter)。

这是一个非常漂亮地对类型系统的使用,它是如此地简单,优雅,且可扩展,使得编译器的工作只需做好类型安全的检查。

同时,Rust 还有其它一些 Trait,它们跟线程安全没有直接关系,但巧妙地辅助了线程安全:

  • Copy:表明一个值在传递时会复制。所有的基本类型,如 i32 ,被实现成 Copy,当你传一个数值到另一个函数或者另一个线程时,它被复制了一份,所以不存在线程安全的问题。
  • Closure(FnFnMut):闭包是一种特殊的类型。默认情况下,闭包中使用的闭包外的值使用了引用,但当这个闭包是在另一个线程运行时,我们需要显式移动所有权,否则,会出现借用的生存期超过所有者的生存期的编译错误。通过这种方式,Rust 杜绝了多线程闭包带来的引用问题。
  • Deref/DerefMut。对于指针而言,通过解引用可以把指针转化成对应的类型进行操作。对于某些类型,Rust 控制其能否解引用,来控制其可用的范围。比如 Arc 类型不能解引用成为可变类型,如果你要多线程修改共享数据,只能通过 Arc<Mutex<T>> 类似的结构来完成。

上面说的这么多内容,可以用一张图来浓缩:

ownership.jpg

当你对这幅图理解地越深,你会愈发感慨 Rust 设计上的巧妙:从定义一个变量的值如何在不同场景下访问,得出几条简单的规则,再辅以类型安全,不引入任何运行时额外的开销,就保证了内存安全和并发安全。这种回归本源的做法,很像物理学的「第一性原理」:我们拨开表面的纷繁复杂,回归事物的本质,往往能得到最简单,同时也是最精妙的解决方案。所谓「大道至简」,不过如此。

Untitled.png

下图的代码,我刻意违背几乎所有的所有权和借用规则,Rust 编译器就像坐在我身旁跟我 peer review 的老司机一样,清晰地告诉我代码中所存在的问题:

rust_safety.png

以及,当我试图像一个 C 程序员那样,写出非并发安全的代码时,Rust 的所有权和借用规则,以及类型系统一起帮助我发现所有的并发安全方面的问题:

rust_safety1.png

贤者时刻

Rust 对我们做系统的启示:首先是把系统的运行规则清晰地定义出来,然后对其做类似从特殊化的定理到一般化的公理那样的推敲和抽象,找到满足系统运行的最小化的核心规则,最后用这些规则来限制系统的行为。

参考资料

[1] 思考,问题和方法:https://zhuanlan.zhihu.com/p/38813900

[2] Go at Google: Language Design in the Service of Software Engineering: https://talks.golang.org/2012/splash.article

[3] How rust views tradeoffs: https://www.infoq.com/presentations/rust-tradeoffs/

透过 Rust 探索系统的本原:内存管理

作者:陈天 / 后期编辑:张汉东

原文链接:https://mp.weixin.qq.com/s/1juaadR3AqHa8H19sHWHmQ


对于开发者来说,内存无非是两种管理模式:手动管理或者自动管理。C 语言是手动管理内存的大哥大,而 Java 则是自动管理的扛把子。很多人认为 Java 是使用垃圾回收做内存管理的鼻祖,其实不然,lisp 早于 Java 大概二三十年就武装上了简单的 GC。不过公认的高效率的 GC 是 Java 带给我们的。

不管手动管理还是自动管理内存,这个「管理」,我们说的都是堆上的内存。几乎所有的编程语言,一切栈上的内存都是自动管理的,并且在编译时就已经妥善完成了。

那么栈上的内存是怎么管理的呢?下面是一个简单的函数调用的例子:

#include <stdio.h>
static int VALUE = 42;
void world(char *st, int num) {
    printf("%s(%d)\n", st, num);
}

void hello(int num) {
    char *st = "hello world";
    int v = VALUE+num;
    world(st, v);
}

int main() {
    hello(2);
}

通过上面的代码,我们不难看出整个调用栈的关系:

Untitled.png

可以看到,栈上的内存管理简单高效:栈帧(frame pointer)一压,一个新的帧(frame)就开启了它全新的生命周期(lifetime),这生命周期结束后,栈帧一弹,所有在此期间「分配」的内存都得到了「释放」。分配和释放不过是动动指针的问题,一切在编译期就决定好了(这也是为什么栈上的对象一定是大小明确的)。

所以,生命周期不是个新鲜事,所有的自动内存管理,其实就是在管理内存的生命周期。栈上对象的生命周期非常简单,规则就一条:某个帧里「分配」的内存,其生命期不能超过该帧的生命期。

堆内存的管理就复杂很多。堆上的内存分配可以在运行时才决定长度,可以动态增长(分配新内存,拷贝,释放旧内存),也可以在不同的上下文中共享。这种灵活性,让堆上内存的生命周期不那么容易追踪,所以管理起来也颇费精力。

手动管理我们放下不表,我们看看不同的语言如何做堆内存的自动管理。

对于堆上的内存的生命周期的自动管理,Java 采用的是 Tracing GC。

tracing_gc.jpg

(图片来自 Mark-and-sweep [1])

对于 GC 来说,栈上活跃的变量以及全局变量属于 root set。任何由 root set 直接或者间接引用的堆上的对象,都是还在使用的对象。在 GC 定期做标记(Mark)的时候,会标记到这些对象,那些没被标记到的对象,属于生命周期终止的对象,可以安全地被回收。

Mark 阶段结束后,GC 会做内存的清理(Sweep)。在清理阶段,所有没有被标记的对象会被放入 free list 等待释放,而那些被标记过的对象的标记会被清除,等待下一轮的处理。

Tracing GC 管理生命周期的方式很巧妙,它并不跟踪每个具体的对象,而是通过扫描当前还有哪些对象仍然在使用,找到那些已经不再使用的对象,释放之。这种方式简单直观高效,但非常粗犷,无法确保某个对象一定在生命周期结束后得到释放。它还有一些副作用,比如消耗额外的 CPU 时间,GC 工作时会打断工作线程的执行,以及需要在内存消耗情况和 GC 频次之间找到一个平衡点等等。Tracing GC 下的内存管理就像我们的大学生活:平时各种浪,考前狂突击。

Swift 采用的是另一种策略 —— ARC(Atomic Reference Counting)。它通过编译时根据每个对象的引用情况,插入相应的引用计数代码(进入函数时,retain - 被引用的对象引用计数增加,退出时 release - 被引用的对象引用计数减少),从而精确掌控每个对象的生命周期:

arc.png

(图片来自:Swift: Avoiding Memory Leaks by Examples [2])

这种方式的虽然规避了 Tracing GC 的很多副作用,但它为了实时追踪对象的引用情况,在运行时也付出了不少代价:retain/release 做了很多事情,并且为了线程安全,修改引用计数需要加锁或者使用 atomics,这比我想象的要吃性能 [3],并且 ARC 无法处理循环引用 —— 所以需要开发者在适当的地方使用 weak ref 来解除循环引用带来的引用计数问题。

ARC 下的内存管理就像在工厂打工:上班签到,下班打卡,兢兢业业,一丝不苟。

而 Rust,采用了完全不同的方式。对 Rust 而言,像 C/C++ 那样靠开发者手工来追踪对象的生命周期是不靠谱的 —— 人总是会犯错,何况这个世界上合格的程序员非常稀缺;但像 Java/Swift 这样在运行时花费额外代价来追踪生命周期,又和语言本身的目标不符。最终 Rust 沿用和发展了 Cyclone 的生命周期的处理方式。

在我之前的文章《透过 Rust 探索系统的本原:编程语言》中,用一幅图概述了 Rust 的所有权和借用规则:

ownership.jpg

如果对所有权和借用规则不熟悉的读者,可以看我的那篇文章。我们来深入看看,Rust 的所有权和借用规则是如何结合编译期生命周期的推定,来解决堆上内存的生命周期管理的问题的。

我们先用一幅图看 move 是如何处理的:

rust_memory.jpg

这段简单的代码里,我们生成了一个 User 对象,然后将其传递给 insert() 函数。在 Rust 里,除非特别声明(使用智能指针),对象(结构)是存储在栈上的。而 Rust 的 String 对象,会在栈上放一个指针,指向堆里的实际字符串数据。由于 Rust 的单一所有权模型,当 user 移动到 insert 函数后,insert 就是其新的 owner,编译器会确保之前的 owner 失去对 user 的访问权:如果继续访问,会得到编译错误。由此,堆上的字符串依旧只有一个 owner(图中红色实线),而旧的 owner 因为被禁止使用,也就间接失去了对堆上数据的引用。当 insert 函数结束时,user 被丢弃(Drop),也即意味着堆上分配的内存被释放(还记得上篇文章讲的 RAII 么)。所以,对于对象的 move,Rust 轻松搞定堆上的数据的生命周期的管理。

那位问了,move 好懂,borrow(或者 reference)怎么破?

我们把上图稍微变一变,让 insert 的参数变成接受一个对 User 的引用( &User ):

rust_memory_ref.jpg

引用(reference),在 Rust 中,可以看做是受限的,带额外 metadata 的指针 —— 这里&user 指向原有的 user 对象。根据所有权和借用规则 4:引用的生命周期不能超过对象的生命周期。在这个上下文中,显然 user 的生命期更长一些,所以不存在问题。当 insert 结束,借用被丢弃,随后图中整段代码结束,user 也被丢弃,堆上的内存于是也得到了释放。一切是那么地显而易见。

那位又问:你这仅仅是一个借用,如果同一个对象有很多借用呢?

其实我们只要把多个借用分开来看:1) 同一个调用栈上的多个借用 2) 不同调用栈上的多个借用。对于 1),由于 user 对象是在最外层的栈分配的,其生命周期必然大于内层的调用栈(见本文开篇的那幅图),因而不存在任何问题;对于 2),我们继续看图说话:

borrow_cross_threads.jpg

这个借用跑去另一个调用栈的唯一机会是 insert 或者其后的函数创建了一个新的线程,并且将这个借用 move(copy 同理)给新的线程。由于两个独立线程的生命周期完全无法比较,所以存在 user 结束生命期被释放,而其另一个线程中的引用还继续存在的情形。

在 C/C++ 里,这样的问题很难规避,需要靠良好的代码规范来杜绝 —— 比如我之前学到的 C 的 best practice 之一就是:尽量避免用指针传递栈上的数据,这样不安全。

但 Rust 巧妙地通过类型推断在编译期就捕获了这样的问题。

怎么个巧妙法?

我们站在编译器的角度想想这个问题:如果我是一个编译器,我该怎么判断这里存在一个编译问题?我有什么信息可以利用?我还需要什么信息?

编译时编译器能够依赖的主要信息来源是类型。对于一个函数调用,其期待的输入(输出)类型,和实际传入(传出)的类型不匹配,那么编译器就可以稳稳地抛出编译错误。

如果我们把变量的生命周期看做类型的一部分,是不是就可以在编译期捕获生命周期的问题?

哈,听起来就像宇宙学中原本无处不在却被忽略的时间被爱因斯坦揉进了广袤无垠的空间,形成了独特的时空观,一下子解决了很多让人费解的问题那样子。在这里,类型是空间,生命周期是时间,两者组合,于是便有了光 —— 一抹如文艺复兴般点亮了内存管理漫长中世纪的亮光。

这抹亮光是 Cyclone 点燃的,后来被 Rust 沿袭,形成燎原之势。

在上图,&user 因为在另一个线程中使用,存在和 user 生命期不匹配的问题,那么,如果我们明确界定在创建线程时,允许传递什么生命周期的数据,不就可以把生命期不匹配的问题杜绝了么?

以下是 Rust 里 std::thread::spawn 的函数签名:


#![allow(unused)]
fn main() {
pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static
}

我们可以看到,调用 spawn 传入的闭包函数(FnOnce)需要 Send + 'static,这里的 'static 类型意味着,这个闭包只能携带生命周期是静态(意味着和整个程序同生共死)的引用,比如 bss/data/text 段中的全局数据/代码的引用,或者具有所有权的数据 —— 也就是说不能传一个非静态的引用。这就是 Rust 的解决之道。

如此一来,上图的场景就无法通过编译了。通过把生命周期嵌入类型系统,Rust 很自然地解决了这个问题。

这种解决方法还带来一个非常棒的副作用:由此,你可以放心地引用栈上的数据 —— 只要编译通过,你的代码就没有安全问题。而其它语言,因为无法规避这类问题,只好无一例外地把引用型数据存放在堆上:

  • C/C++:虽然理论上可以引用栈上的数据,但 best practice 不建议你这么做(的确不安全)
  • Java:对象都存储在堆上,只有基础类型(primitive type)—— 其实就是拷贝类型,才放在栈上
  • Swift:引用类型存储在堆上,拷贝类型存储在栈上

而 Rust,允许你把所有在编译期长度确定的数据放在栈上,并安全引用。相比之下,这能带来很多性能上的提升(大大减少了 heap alloc/dealloc 的开销)。同时,Rust 下,栈的使用更加有效,堆的使用更加纯粹 —— 除非不得已(Rc,Arc 这样的使用场景),我们不再需要依赖堆上的分配来「记录」生命周期。

当然,凡事都有代价。其它语言,生命周期的管理被掩盖在了语言的细节之中,你无需直接跟生命周期打交道。而在 Rust 中,你的数据结构,函数声明,都潜在需要做生命周期的标注,因为它们是类型系统的一部分。虽然 Rust 编译器做了很多工作,使得 80% 常用的场景下,你不需要标注生命周期,编译器会自动推导;但还是有一些场合,你需要手工添加生命周期,告诉编译器你对调用者的期待。在 C 语言里,一个刚入门的工程师可以很轻松地写出 strtok,但对 Rust 工程师来说,掌握生命周期,并在实战中运用,需要一定的时间 —— 同样是 strtok,在 Rust 里搞明白并且写出来并不轻松:

strtok.jpg

对此感兴趣的同学,建议你看看 Jon Gjengset 的 Crust of Rust: lifetime annotation[5]。

本文是我上周三在 Tubi 内部的 Rust BBL 的文字版,需要 slides 的同学可以去我的 github:tyrchen/rust-training 或者点击「阅读原文」自取。

贤者时刻

对于软件中的缺陷,不同阶段被发现会导致非常不同的解决时间:

  1. 不要引入任何缺陷(可惜我们是人,不是神,这不现实)—— 无需解决时间
  2. 当敲下代码的那一刻,有缺陷的地方就能够被捕获到(很多语言的 language server 已经在做这样的事情了)—— 秒级
  3. 在编译时被捕获到 —— 秒级到分钟级
  4. 在 UT 时被捕获到 —— 分钟级
  5. 在 CI 时被捕获到 —— 分钟级到小时级
  6. 在 code review 时被捕获到 —— 分钟级到天级
  7. 在 End-to-End test / QA 时被捕获到 —— 小时级到天级
  8. 在部署后被捕获到 —— 若干小时,若干天,甚至若干月之后
  9. 部署后被用户捕获到 —— 若干天,若干月,甚至若干年之后(也许永远无法解决 —— 很难复现)

所以:早发现,早隔离,早诊断,早治疗 —— 鲁迅诚不我欺也。

参考资料

[1] Mark-and-sweep: https://www.linkedin.com/pulse/mark-and-sweep-garbage-collection-algorithm-saral-saxena/

[2] Swift: Avoiding Memory Leaks by Examples: https://medium.com/hackernoon/swift-avoiding-memory-leaks-by-examples-f901883d96e5

[3] How does Object-C runtime do multithreaded reference counting without downgraded performance? https://stackoverflow.com/questions/13942226/how-does-apples-objective-c-runtime-do-multithreaded-reference-counting-without

[4] Cyclone: https://en.wikipedia.org/wiki/Cyclone_(programming_language)

[5] Crust of Rust: lifetime annotation: https://www.youtube.com/watch?v=rAl-9HwD858

透过 Rust 探索系统的本原:网络篇

作者:陈天 / 后期编辑:张汉东

原文链接:https://mp.weixin.qq.com/s/bOxEEK7Hh_tsua8HBahsjg


如今所有的应用程序几乎都离不开网络。从应用开发的角度,绝大多数应用以及其后端系统都工作在应用层:

0

一般来说,应用程序的网络层除了发生在客户端和服务器之间,还存在于整个后端。下图是一个典型的应用程序:

1

客户端和服务端之间的流量会走这些网络协议:

  1. API 和 analytics 大多会走 HTTP(S)/1.1 或 HTTP(S)/2,以及在其之上的 websocket。
  2. trace/metrics 走 UDP

服务端内部:

  1. 服务和服务间如果走 gRPC 的话,是 HTTP/2。
  2. 服务跟数据库之间走数据库自己的网络协议(一般直接建立在 TLS 之上)。
  3. 服务跟第三方的云服务,一般走 HTTPS。
  4. 服务器和 Message queue 之间,也许会走 AMQP 协议。

大多数时候,这些网络协议都被封装好,或以 SDK 的形式,或以类库的形式供你调用,所以开发者基本不用操心协议的细节是怎么回事。顶多,把 HTTP/1.1 的状态码(200/301/302/304/400/404/429/500),方法(GET/POST/PUT/PATCH/DELETE/HEAD),常见的头字段记一记,知道什么情况下客户端该请求什么方法,返回什么状态码,什么时候数据应该放在头部,什么时候应该放在腰部(body),也就足够应付 80% 的应用场景了。

不过,为了更大的吞吐量,更小的延迟,更好的用户体验,我们还需要掌握更多的细节。本文就谈谈这些内容。

异步处理

提升网络性能的第一大法宝是异步处理。网络跟 I/O 相关,发送和接收数据都存在潜在的阻塞线程的风险,如果不妥善处理,会大大降低系统的吞吐量。传统的处理方式是用 non-blocking I/O,自己处理 EWOULDBLOCK —— 一般来说,去调度其它能执行的任务,避免线程闲置。当然,现代编程语言都有不错的异步支持,相当于帮助开发者完成这种原本需要手工完成的调度。Rust 提供了 Future trait,在语言核心支持了 asyncawait。相信大家对 asyncawait 并不陌生,下图对比了 sync/async File I/O:

2

(图片来自:Writing an OS in Rust - Async/Await [1])

在众多不同语言的异步实现中,Rust 的实现是独树一帜的。它的异步库(无论 Tokio/async-std)使用了 Reactor/Executor 模式[2],一个 Future 只有被主动 poll(await)才会得到执行。这一点和 javascript 有本质的不同 —— 在 js 里,一个 promise 一旦生成,就会放入 event loop 里等待执行。

在 Reactor/Executor 模式里, executor 就是我们常说的调度器(scheduler)。它负责调度可执行的 Future 的执行。每次执行意味着一次 poll,要么 poll 到整个 Future 结束,要么 poll 到 Future 直到 Poll::Pending。当一个 Future 不能做更多事情时(Poll::Pending),executor 不会再管它,直到有人(Waker)通知 executor 这个 Future 又重新 ready 了。这个 Waker 就是我们所说的 reactor。它一般会跟操作系统的 nonblocking I/O(linux 下是 epoll,bsd 下是 kqueue,以及 windows 下是 IOCP)协作,来唤醒 Future。下图概括了 Tokio 下 Reactor/Executor 是如何协作的:

3.png

(图片来自 Explained: How does async work in Rust? [3])

如果你做过多核 CPU 下的(非 ASIC)网络设备相关的开发,会发现这个过程似曾相识。我觉得未来 Rust 会在高性能网络设备领域占据一席之地,这得益于其高效强大的易步处理库。

Rust 下主流的异步库有 Tokio 和 async-std。下图做了不错的总结,大家可以就着原文的讨论一起看:

4.png

(图片来自 reddit 讨论:Diagram of Async Architectures [4],有些旧,async-std 现在已经基于 smol 了,但整个讨论值得一读)

异步开发的好处是:尽管底层的处理相当复杂,各种实现但对开发者来说,接口非常简单,只需要很少的代价就可以把一个同步的处理变成异步的处理。

但是,我们要小心其中的一些陷阱:

  • 锁的使用。如果对 I/O 相关的数据结构的锁,需要使用支持异步的锁。比如你要对 TCP stream 的 writer 加锁(不建议这么做,下文会讲),那么要用异步锁。异步锁和同步锁的区别是,异步锁只是让异步任务状态变为 Poll::Pending,不会锁线程,等锁 ready 后异步任务重新被唤醒;而同步锁会锁线程,导致性能问题甚至死锁。
  • 异步代码和高延时同步代码的混用。如果在两个异步操作之间有高延时同步代码,要么把同步代码放在前面或者后面执行,要么把同步代码异步化(分阶段 poll)。

对于后者,我这两天在做一个类似 Phoenix Channel[5] 的 Websocket 应用(以下简称 WS channel)时遇到了这个问题:我的 WebSocket 在 wss 连接时,每个连接要花大概 300-400ms,很奇怪。后来我用 jaeger 追踪了一下 tracing,发现客户端代码在连接时时间都耗在了 new_with_rustls_cert 上,真正花在 TLS handshake 上的时间只有 1.5ms:

5.png

由于我客户端做的操作是:

  1. TCP connect(异步)
  2. 准备 TLS connector(这里会做 new_with_rustls_cert),这是同步操作,耗时 ~300ms
  3. TLS connect(异步)

这直接导致服务端 tls_accept 每个新建连接的延迟奇高(因为客户端 TCP 连上了迟迟不做 handshake):

6.png

解决办法:客户端准备 TLS connector 的步骤提前到第一步。之后,服务器的延迟正常了(~1ms):

7.png

这是个有趣的 bug。new_with_rustls_cert 看上去是个人畜无害的纯内存操作,但因为里面有读取操作系统的受信证书的操作,所以延时高一些。其实它应该做成异步操作。

队列

在网络开发中,最快能提升性能的工具就是队列。虽然操作系统层面,已经使用了发送队列和接收队列来提升性能,在应用层面,我们最好也构建相应的队列,来让整个服务的处理变得更顺滑,更健壮,更高效。除此之外,队列还能帮助我们解耦,让应用本身的逻辑和 I/O 分离。

我们还是以上文中提到的 WS channel 为例。其产品逻辑是:客户端可以连接 websocket,然后 join/leave 某个 channel,当成功 join 某个 channel 后,客户端可以向 channel 里广播任意消息,其它连接到这个 channel 的客户端可以接收到这条消息。

服务器端需要处理这样的逻辑:

  • 收到客户端的 join channel X 的请求后,如果验证通过,就给该客户端发送成功响应,同时给 channel X 的其它客户端发送 presence update 通知。
  • 收到客户端发往 channel Y 的消息后,验证客户端确实在 channel Y 中之后,将这条消息广播给 channel Y 中的其它客户端。
  • 收到客户端的 leave channel Z 的消息后,如果客户端在 channel Z 中,就将客户端移除,并给 channel Y 的其它客户端发送 presence update 通知。

很简单,是不是?

然而,如果把所有这些逻辑都塞在 accept socket 后的一个大的 async move { loop {...} } 里,代码晦涩难懂,到处耦合,不好单元测试;如果分成一个个子函数,又需要对 websocket 的 reader/writer 套上 Arc<RwLock<...>> 传递,啰嗦且性能不好,每个子函数还是不好单元测试(websocket reader/writer 不好构造)。

最好的方式是用队列将逻辑和 I/O 分离开:event loop 只负责从 websocket 中接收数据,将其发送到接收队列中,供后续的逻辑处理;以及从发送队列中 poll 出数据,写入 websocket。整体的示意图如下:

8.png

我们换个视角,只看一个 client,大概是这个样子:

9.png

服务器:

  1. accept socket,为 ws socket 创建一个本地 own 的 peer 结构和一个不在本地 own 的 client 结构。peer own socket 的 writer/reader,peer 和 client 之间建立一个双向队列。然后 spawn tokio task,处理该 peer。
  2. peer 的 event loop 很简单,只处理 socket 的收发 —— 收到的消息放入 recv 队列;从 send 队列拿到要发的消息,写入 socket
  3. client 在创建后会启动一个 tokio task,运行自己的 event loop:从 recv 队列收消息,根据消息类型进行相应处理(比如说 join 会和 channel 间建立队列)。如果消息需要 broadcast,将其放入 broadcast 的发送队列,由 channel 结构处理。
  4. channel 从 broadcast 接收队列里收到消息后,遍历自己的所有 subscribers(排除发送者),然后将消息发送到他们的 broadcast 发送队列。

这是理论上最佳的运作方式。实操的时候,为了节省内存,channel 可以不和 client 建立队列,直接获取 client send 队列的 rx 的浅拷贝,直接发送,省去了一层传递。

使用队列之后,我们可以很方便地测试 client / channel 处理的逻辑,而无需关心 I/O 部分(I/O 的构造是 unit test 中最困难的部分)。同时,整个结构的逻辑也更清晰,效率更高(使用队列缓存减少了等待),且更平滑(队列有效地缓解了 burst 请求)。

此外,我们还可以在 websocket 发送端,对 send 队列的 rx 做批处理,就像 ReactiveX 里的 Window 操作那样,让发送端在一段时间内等够一些数据再统一发送(比如:period=200ms / count=16 messages):

10.png

减少内存分配和拷贝

网络应用中,数据从内核态到用户态,在用户态的多个线程之间,以及最后经过内核态把新的数据发送出去,里面免不了有很多内存的分配和拷贝。还是上面 WS Channel 的例子,我大概统计了一下在 channel 中广播一条用 protobuf 序列化的消息,应用程序自己所需要的内存分配和内存拷贝:

  1. 首先 WebSocket 服务器收到消息后,需要把二进制的 protobuf 转化成 struct 进行一些处理。如果 protobuf 消息中含有 repeated (在 Rust 里对应的是 Vec)或者 map (在 Rust 里对应 HashMap)或者 string (在 Rust 里对应的是 String),那么都涉及到堆上的内存分配。堆上的内存的分配代价很大,切记。
  2. 假设 channel 里有 100 个用户,那么要做 broadcast 的话,这个 struct 需要被拷贝 100 次。
  3. 当要发送消息时,需要把 struct 再序列化成二进制,封装成 Websocket 消息,发送。这里面,序列化的过程涉及到承载二进制内容的 buf 的分配,如果不优化,也是在堆上进行。

这里最多的内存分配和复制在 2。为了优化这里的拷贝,我们可以用 Arc 来做引用计数,避免拷贝数据本身。

对于 3,我们可以使用一个全局的可增长的 ring buffer,每次需要 buf 时,从 ring buffer 里取;也可以用 slab,预先分配好相同大小的内存,然后使用之。

此外,还可以使用一些零拷贝的序列化/反序列化工具,如 rkyv[8]。

我在开始写 Rust 项目时往往在做应用的时候过多使用拷贝,导致辛辛苦苦编译通过的代码效率低下,有时候做同样一件事,Rust 代码的性能还不如 go 和 java。所以说,合理使用引用,避免代码中不必要的拷贝,是撰写高性能应用的必经之路。

降低延时

在服务器和客户端的交互中,往往数据传输本身占据总延迟的大头。一种降低延时的方式是将数据甚至数据和计算本身都挪到网络的边缘处理,这样因为尽可能贴近用户,传输的距离大大减小,延迟就大为改观。目前很多 CDN 都支持了边缘计算(如 aws for edge)

另外一种降低总延时的方式是压缩。如果原本传输完成 1MB 的数据需要 1s,压缩后只剩下 400k,那么传输完成所需要的时间也相应降低到 400ms。一般 http 服务器和客户端都支持的压缩算法有:gzip,deflate,compress 等。随着时间的推移,类似 zstd[9] 这样高性能且高压缩比的算法也会得到越来越多的使用。如果你的应用是自己的客户端(不是浏览器)和服务器交互,那么可以考虑使用 zstd —— 它有媲美 gzip 的性能,以及比 gzip 好不少的压缩比。

流式处理 (streaming)

降低延时的另一个手段是流式处理:发送端不需准备好所有数据才发送,而接收端也无需接收到所有数据才处理。gRPC 是应用的最为广泛的支持流式处理的工具。在 Rust 里,有 tonic [10] 这个库支持高性能 gRPC 服务。

流式处理虽然能大大降低延时,并让数据在系统中流动得更加自然(我们的时间是一个流式运转的世界,但大部分系统在处理起数据来,只能做批处理),但它最大的问题是使用起来不想批处理那么显而易见,更要命的是,测试起来很多时候无从下手。

在 Rust 下,我们可以将 channel 和 tonic 的流式接口绑起来,使用起来比较方便。至于测试,我制作了一个 tonic-mock[11],可以很方便地通过 prost 生成的 struct 从一个数组生成流式接口供测试使用。

如果你想在 TCP 之上构建流式处理,又希望能够避免应用层上的 head-of-line blocking[12],可以使用 yamux [13],它是 Hashicorp 提出的一种类似 HTTP/2 的流管理的 multiplexing spec。Rust 下的实现 有 Parity 的 yamux [14]。当然,HTTP/2 或者 yamux 只能解决应用层的 head-of-line blocking,无法解决 TCP 层的问题,如果对你而言,TCP 层是瓶颈所在,那么,可以试试 HTTP/3 或者在 QUIC(目前在 draft 34)[15] 上构建你自己的应用层。Rust 下对 HTTP/3 和 QUIC 的支持有 quinn(支持到 draft 32)[16] 和 cloudflare 出品的 quiche[17]。

日志/追踪(logging/tracing)

复杂的网络应用,在追踪问题的时候,合理的 logging/tracing 能帮助我们快速定位问题。在我用过的诸多语言的各种各样的库中,Rust 里的 tracing [18] 库是体验最佳的工具。它可以记录日志,生成 flamegraph,把数据以 opentelemetry[19] 的形式发送给第三方(比如 jaeger)。比如:

11.png

上文提到,通过使用它,我解决了一个非常令人困扰的 TLS 新建连接的延迟问题。

当我们构建应用的时候,最好从一开始就设计好你的 tracing infrastructure:

  • 日志:什么时候添置什么 level 的日志?日志要包含什么样的 metadata?
  • 追踪:什么时候添置什么 level 的 span?span 要包含什么 metadata?
  • metrics:系统需要收集什么样的 metrics,它们可以被用作什么样的分析?比如:我想知道 WS channel 广播一条消息的 p99,需要如何收集这个 metrics。

贤者时刻

下图囊括了 Rust 下面主流的和网络应用相关的库,希望能够帮助大家在合适的场合使用合适的协议和工具:

12.png

参考资料

[1] Writing an OS in Rust: https://os.phil-opp.com/async-await/

[2] The Reactor-Executor Pattern: https://cfsamsonbooks.gitbook.io/epoll-kqueue-iocp-explained/appendix-1/reactor-executor-pattern

[3] Explained: How does async work in Rust: https://dev.to/gruberb/explained-how-does-async-work-in-rust-46f8

[4] Diagram of Async Architectures: https://www.reddit.com/r/rust/comments/jpcv2s/diagram_of_async_architectures/

[5] Phoenix channel: https://hexdocs.pm/phoenix/channels.html

[6] Futures batch: https://github.com/mre/futures-batch

[7] ReactiveX window operation: http://reactivex.io/documentation/operators/window.html

[8] rkyv: https://github.com/djkoloski/rkyv

[9] zstd: https://facebook.github.io/zstd/

[10] tonic: https://github.com/hyperium/tonic

[11] tonic-mock: https://github.com/tyrchen/tonic-mock

[12] Head-of-line blocking: https://en.wikipedia.org/wiki/Head-of-line_blocking

[13] Yamux spec: https://github.com/hashicorp/yamux/blob/master/spec.md

[14] Yamux rust: https://github.com/paritytech/yamux

[15] QUIC: https://tools.ietf.org/html/draft-ietf-quic-transport-34

[16] Quinn: https://github.com/quinn-rs/quinn

[17] Quiche: https://github.com/cloudflare/quiche

[18] tracing: https://github.com/tokio-rs/tracing

[19] Opentelemetry: https://opentelemetry.io/

如何为 Rust 语言做贡献 Part 3 | 年轻人的第一个代码质量检测(Lint)

作者:CrLF0710(野喵)/ 后期编辑:张汉东


大家好,这次以为 rustc 增加一个代码质量检测为例,介绍一下编译器的一些内部细节。

先来看下本次要讲的内容的主角。这个代码检测检测是 **Rust RFC《允许使用非ASCII标识符》**中的一个小的功能点,名叫 uncommon_codepoints 。这次要检查的内容是代码中的标识符中是否含有非常规的Unicode代码点,如果有,默认给用户报一个编译警告。如果大家想在Github上阅读原PR的代码的话,链接在这里

理解 Rustc 总体过程

大家都知道,当前 rustc 的编译过程主要分成四个大的阶段:解析(parsing),展开(expansion), 分析(analysis), 代码生成(codegen)。

  • 第一步的解析是读取你提供的源码的过程,把它变成编译器的内部数据结构。

  • 第二步的展开是对内部结构进行一些初步的变换,去除各种语法糖,同时处理掉各种路径解析之类的工作,使其统一化。

  • 第三步的分析是对内部结构描述的程序进行进一步的变换,在这个阶段里也会完成各种类型检查、借用检查等等工作,确保你的代码是符合Rust规格的。

  • 第四部的代码生成则是将内部结构描述的程序单态化,然后送给后端(现在有两个官方支持的后端:LLVM和Cranelift)让它们生成优化过的目标指令,最后归档或链接。

听起来有点繁琐对不对,但是实际上大多数部分都只是代码数量比较大,做的工作并不复杂。毕竟编译器只是一个数据变换程序,从你提供给它的文本输入数据变换成一些目标指令塞进某种作为容器的文件格式里而已。

代码质量检测模块 (rustc_lint)

在 rust 代码仓库 compiler/rustc_lint目录下是一个专门用来实现各种代码质量检测分析过程(Lint pass)的模块。Lint 有好几个时机执行,从而分成了若干种类,在这里我们这词要写的这个lint是要对编译器实际处理的字面代码进行检查,也不涉及到各种类型信息相关的分析,所以我们可以选择一个比较靠前的执行时机。

按照rustc_lint规定的分类,我们可以把它作为是early分类下的实现。

src/librustc_lint/non_ascii_idents.rs文件里,已经有一个关于非ASCII标识符的代码质量分析过程了,它现在含有一项lint,我们在里面加一项,代码如下:


#![allow(unused)]
fn main() {
declare_lint! {
    pub UNCOMMON_CODEPOINTS,
    Warn,
    "detects uncommon Unicode codepoints in identifiers"
}
}

这个宏是 rustc_lint 这个模块定义的,第一项是lint的名称的全大写形式(UNCOMMON_CODEPOINTS);第二项是默认等级,我们在这里默认是报警告,所以是Warn;第三项是这个Lint的文本说明,这个说明是会显示在rustc -W help命令的输出中的。

以上就是这个lint本身的声明了,然后我们修改这个lint pass的声明,将


#![allow(unused)]
fn main() {
declare_lint_pass!(NonAsciiIdents => [NON_ASCII_IDENTS]);
}

改成


#![allow(unused)]
fn main() {
declare_lint_pass!(NonAsciiIdents => [NON_ASCII_IDENTS, UNCOMMON_CODEPOINTS]);
}

这样编译器的代码就知道这个分析过程会负责这两项Lint的分析。

实现代码质量检测逻辑

接着我们开始真正实现必要的逻辑。修改下面的实际lint pass的实现:


#![allow(unused)]
fn main() {
impl EarlyLintPass for NonAsciiIdents {
    fn check_ident(&mut self, cx: &EarlyContext<'_>, ident: ast::Ident) {
        // ...
    }
}
}

这里我们要对ident的文本内容做个检查。我们调用ident.name.as_str()获取字符串,然后调用我们事先实现的包unicode-security中的检查方法, 就可以知道这个标识符里有没有非常规Unicode代码点了。

当我们发现了的确有这样的代码点存在,我们就调用cx上面的lint方法:


#![allow(unused)]
fn main() {
cx.struct_span_lint(
    UNCOMMON_CODEPOINTS,
    ident.span,
    "identifier contains uncommon Unicode codepoints",
)
.emit();
}

这个函数会根据你第一个参数指定的lint,去检查lint机制本身的当前提示级别(allow, warn, deny, forbid),有可能产生一个警告或报错。

第二个参数span在编译器里用来标识一段原始代码。编译器警告错误提示在有span的情况下会“引述”你的源代码,展示给用户说是这里产生了警告或错误。

第三个参数则是给用户展示的提示文字,告诉用户这里产生警告或错误的原因。

我们没有其他需要给用户显示的提示啦,所以这里最后调用emit()方法,就完成了。

准备单元测试用例

Rust 要求每个面向用户的功能都有测试用例。在这里我们新加了一个Lint,所以也需要写一个。 于是我在src/test/ui/lint/rfc-2457-non-ascii-idents/这个目录下增加一个lint-uncommon-codepoints.rs文件, 里面首先用#![deny(uncommon_codepoints)]来将警告变成了错误(这样方便写测试用例),然后试着写了几个具有不推荐使用的Unicode代码点的标识符。 其中一行是这样的:


#![allow(unused)]
fn main() {
const µ: f64 = 0.000001; //~ ERROR identifier contains uncommon Unicode codepoints
}

注意右边的注释,//~ ERROR 表示期望这一行产生一个编译错误,错误提示以右边的文本开头。我们编译并跑一下这个测试用例(x.py test src/test/ui/ --bless), 还会记录一个参照用的错误提示文件lint-uncommon-codepoints.stderr

一点额外的收尾工作 & 上传提交

我们这次修改有一点额外的收尾工作,因为引入了unicode-security这个新的包和它的依赖项unicode-script,因此我们更新了Cargo.toml, Cargo.lock 还有 src/tools/tidy/src/deps.rs这些文件,新增了新的依赖项相关的信息。

都准备好之后,我们就提交PR,并且喊上官方的大佬来review 啦。在处理了官方大佬的几个review意见后,我们的代码就通过了review,并且合并进了仓库。

怎么样,是不是很容易呢?欢迎大家也来多多参与,做出自己的贡献。

优化 Rust 标准库的 binary_search

2021 年 5 月 6 号发布的 Rust 1.52 版将会包含我的一个 PR,将标准库 slice::binary_search_by() 的最好时间复杂度优化到 O(1)。PR 编号 #74024,从 2020 年 7 月初提交的 PR,到 2021 年 3 月 6 号才正式合并,其中包含 70 多条评论,前后历时大半年。这个 PR 虽然改动不算很大,但是笔者在其中也学到了不少知识点,所以特意写一篇文章总结一下,希望能帮助到大家。

首先看一下 slice::binary_search_by() 这个方法的示例来了解一下它的用途。


#![allow(unused)]
fn main() {
let s = [0, 1, 1, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55];

let seek = 13;
assert_eq!(s.binary_search_by(|probe| probe.cmp(&seek)), Ok(9));
let seek = 4;
assert_eq!(s.binary_search_by(|probe| probe.cmp(&seek)), Err(7));
let seek = 100;
assert_eq!(s.binary_search_by(|probe| probe.cmp(&seek)), Err(13));
let seek = 1;
let r = s.binary_search_by(|probe| probe.cmp(&seek));
assert!(match r { Ok(1..=4) => true, _ => false, });
}

这个函数的作用就是在给定的有序 slice 中二分查找目标值 seek。如果找到了返回 Ok(pos)pos 即目标值所在的位置;没找到则返回 Err(pos), 这里的 pos 的位置可以用来将 seek 插入 slice 后依然保持有序。其他的 slice::binary_search()slice::binary_search_by_key() 系列方法都是调用这个 slice::binary_search_by(),这里不再赘叙。

但是,1.52 之前的实现有一个小问题,如果 slice 中存在多个连续的目标值,则它会一直找到最后一个才返回,所以最好时间复杂度也是 O(log n),而不是 O(1),也就是找到了就马上返回。这是 1.52 之前的代码:


#![allow(unused)]
fn main() {
#[stable(feature = "rust1", since = "1.0.0")]
#[inline]
pub fn binary_search_by<'a, F>(&'a self, mut f: F) -> Result<usize, usize>
where
    F: FnMut(&'a T) -> Ordering,
{
    let s = self;
    let mut size = s.len();
    if size == 0 {
        return Err(0);
    }
    let mut base = 0usize;
    while size > 1 {
        let half = size / 2;
        let mid = base + half;
        // SAFETY: the call is made safe by the following inconstants:
        // - `mid >= 0`: by definition
        // - `mid < size`: `mid = size / 2 + size / 4 + size / 8 ...`
        let cmp = f(unsafe { s.get_unchecked(mid) });
        base = if cmp == Greater { base } else { mid };
        size -= half;
    }
    // SAFETY: base is always in [0, size) because base <= mid.
    let cmp = f(unsafe { s.get_unchecked(base) });
    if cmp == Equal { Ok(base) } else { Err(base + (cmp == Less) as usize) }
}
}

既然我们发现这个问题了,很简单,我们在 while 循环中判断 cmp == Equal 不就行了?如果相等就直接返回 Ok,否则继续二分去查找。


#![allow(unused)]
fn main() {
while size > 1 {
    let half = size / 2;
    let mid = base + half;
    // SAFETY:
    // mid is always in [0, size), that means mid is >= 0 and < size.
    // mid >= 0: by definition
    // mid < size: mid = size / 2 + size / 4 + size / 8 ...
    let cmp = f(unsafe { s.get_unchecked(mid) });
    if cmp == Equal {
        return Ok(base);
    } else if cmp == Less {
        base = mid
    };
    size -= half;
}
}

为了简洁性,这里(包括后面)都会适当省略重复代码。

嗯,看起来逻辑上没问题,单测也跑通过了,提交一个 PR,我们暂且叫它优化(1)。没过几天 dtolnay review 了我的 PR 并回复了:Would you be able to put together a benchmark assessing the worst case impact? The new implementation does potentially 50% more conditional branches in the hot loop.

确实,Rust 标准库对性能要求非常高,我们必须要有足够多 case 对新旧版本做 benchmark 对比,尽可能避免新的版本导致性能上的 regression。这是当时我做的 benchmark 数据:

// 标准库的实现
test slice::binary_search_l1           ... bench:          59 ns/iter (+/- 4)
test slice::binary_search_l1_with_dups ... bench:          59 ns/iter (+/- 3)
test slice::binary_search_l2           ... bench:          76 ns/iter (+/- 5)
test slice::binary_search_l2_with_dups ... bench:          77 ns/iter (+/- 17)
test slice::binary_search_l3           ... bench:         183 ns/iter (+/- 23)
test slice::binary_search_l3_with_dups ... bench:         185 ns/iter (+/- 19)
// 新版实现
test slice::binary_search_l1           ... bench:          58 ns/iter (+/- 2)
test slice::binary_search_l1_with_dups ... bench:          37 ns/iter (+/- 4)
test slice::binary_search_l2           ... bench:          76 ns/iter (+/- 3)
test slice::binary_search_l2_with_dups ... bench:          57 ns/iter (+/- 6)
test slice::binary_search_l3           ... bench:         200 ns/iter (+/- 30)
test slice::binary_search_l3_with_dups ... bench:         157 ns/iter (+/- 6)

可以看出来在 with_dups 模式(即重复元素较多)下,新的实现有很明显的提升,但是 l3 级别的普通模式性能反而要差很多。可能的原因正如 dtolnay 所说的 The new implementation does potentially 50% more conditional branches in the hot loop。那 conditional branches 具体是什么?为什么它在热循环中如此关键?这里引入这篇文章的第一个知识点:分支预测

分支预测(Branch prediction)

分支预测 (branch prediction) 是现代 CPU 为了加快指令并行化速度在碰到分支时提前预测下一个可能执行的分支的一种技术。 CPU 中一般都内置专门的分支预测器 (Branch predictor)。强烈推荐先阅读 Stackoverflow 这个高赞回答 Why is processing a sorted array faster than processing an unsorted array?,里面比较清晰易懂的解释了分支预测是什么以及对性能带来的影响。关于分支预测完全可以写一篇专门的文章介绍了,我这里只把我学到的知识总结一下。

分支

首先我们要了解一下分支到底是指什么?在高级语言层面,很显然分支就是 if/else/else ifgoto 或者 switch/match 这种语句。他们会转换成汇编代码中的 jump 指令。比如 x86 汇编的各种 j 开头的 jump 指令:

指令作用
jmpAlways jump
jeJump if cmp is equal
jneJump if cmp is not equal
jgSigned > (greater)
jgeSigned >=
jlSigned < (less than)
jleSigned <=
jaUnsigned > (above)
jaeUnsigned >=
jbUnsigned < (below)
jbeUnsigned <=
jecxzJump if ecx is 0
jcJump if carry: used for unsigned overflow, or multiprecision add
joJump if there was signed overflow

比如我们随便写一段包含 if/else 逻辑的代码,编译之后的汇编代码中就会包含类似的 jump 指令:

#![allow(unused_assignments)]
pub fn main() {
  // 接收用户从命令行中输入的参数
  let mut a: usize = std::env::args().nth(1).unwrap().parse().unwrap_or_defaul();
  if a > 42 { 
    a = 1;
  } else {
    a = 0;
  }
}

这段代码逻辑这么写主要是为了保持汇编指令的尽量简单。这里是对应的汇编代码,只保留了跟 if/else 有关的部分,完整的代码链接在这里:https://rust.godbolt.org/z/ahcKcK1Er

.LBB99_7:
        cmp     qword ptr [rsp + 56], 42 ; if a > 42
        jbe     .LBB99_10
        mov     qword ptr [rsp + 56], 1  ; a = 1
        jmp     .LBB99_11
.LBB99_10:
        mov     qword ptr [rsp + 56], 0  ; a = 0
.LBB99_11:
        add     rsp, 200
        ret

我在代码里面加了几条注释(汇编里面用分号注释)表明了该条指令对应的 Rust 代码,可以看出来 jbe 指令会判断是否跳转到 .LBB99_10 还是不跳转继续执行下面的 mov

了解了分支是什么之后还不够,我们依然不知道具体是为啥 CPU 需要做分支预测。接下来我们需要了解一下另外一个概念:指令流水线

指令流水线(instruction pipelining)

指令流水线是从 instruction pipelining 翻译过来的一个名词,主要是指为了提高单个处理器指令处理效率的一种技术,早在上个世纪 70 年代的芯片就有这种技术了。

CPU 处理一条指令一般要分为几个步骤:

  • 取指令(Instruction Fetch)
  • 解指令(Instruction Decode)
  • 执行指令 (Execute)
  • 回写结果到寄存器 (Register write back)

这非常类似工厂生产某件需要多道工序的商品一样。想象一下,如果工厂每次都是完整的按照工序把第一件商品生产出来之后再进行同样的步骤生产下一件商品的话,这个生产速度得多慢!所以 19 世纪人类就诞生了工业化的流水线,把所有这些工序分解并行化。第一件商品在工序(2)的时候,丝毫不影响第二件商品进入工序(1)。指令也完全可以这样,CPU 可以把指令执行过程流水线化,把不同的指令执行步骤分给不同的逻辑门 (logic stage)处理,第一条指令进入解指令阶段的时候,第二条指令就可以进入取指令阶段了。

Wikipedia 上的这张图可以帮助理解(图中文字颜色为黑色,推荐用 Light 主题查看)。

了解前面的这些概念之后,我们来看一下为什么 CPU 需要做分支预测?

为什么需要分支预测?

指令流水线会按照工厂流水线一样执行指令,这其中也包括前面讲的 jump 指令。而 jump 指令有一个问题就是它需要知道下一个时钟周期该跳转还是不跳转,而这需要等前面的判断逻辑执行完了之后才知道。比如上面的例子 cmp 判断完之后,jump 指令才能决定是跳转到 .LBB99_10 部分,还是不跳转继续执行下去。但是 CPU 指令流水线才不会等,否则白白的浪费一个时钟周期。所以人们发明了一种办法来避免出现这种问题,这就是分支预测

CPU 的分支预测器会提前预测这条 jump 指令可能会跳转的到哪个分支,然后把预测的那条分支放到流水线中去执行。如果预测正确,CPU 可以继续执行后面的指令;如果预测失败了(branch misprediction),那只能丢弃刚刚执行的分支结果,重新切换到正确的分支执行。可以看到,如果出现过多的预测失败,分支预测反而很影响性能。不过现在 CPU 的分支预测器经过这么多年的发展已经越来越先进了,人们会采用各种方式不断提高分支预测器的预测准确率,详细可以查看 Wikipedia 的 Branch_predictor 了解更多。

热循环中避免分支预测

虽然现代 CPU 都有分支预测器,但我们在软件层面依然要尽量避免发生分支预测,特别是在热循环中。最常用的优化方法就是避免在循环中写 if/else 语句,即 branchless code。标准库中有大量这种 branchless code 的案例来优化性能,比如 std::collection::Filtercount() 方法。


#![allow(unused)]
fn main() {
pub struct Filter<I, P> {
    iter: I,
    predicate: P,
}

impl<I: Iterator, P> Iterator for Filter<I, P>
where
    P: FnMut(&I::Item) -> bool,
{
    type Item = I::Item;

    // this special case allows the compiler to make `.filter(_).count()`
    // branchless. Barring perfect branch prediction (which is unattainable in
    // the general case), this will be much faster in >90% of cases (containing
    // virtually all real workloads) and only a tiny bit slower in the rest.
    //
    // Having this specialization thus allows us to write `.filter(p).count()`
    // where we would otherwise write `.map(|x| p(x) as usize).sum()`, which is
    // less readable and also less backwards-compatible to Rust before 1.10.
    //
    // Using the branchless version will also simplify the LLVM byte code, thus
    // leaving more budget for LLVM optimizations.
    #[inline]
    fn count(self) -> usize {
        #[inline]
        fn to_usize<T>(mut predicate: impl FnMut(&T) -> bool) -> impl FnMut(T) -> usize {
            move |x| predicate(&x) as usize
        }

        self.iter.map(to_usize(self.predicate)).sum()
    }
}
}

标准库的 Filter 类型在实现 Iterator 的时候重写了 count() 方法。想想如果我们没有意识到分支预测这个问题的情况下可能会这么实现:


#![allow(unused)]
fn main() {
// Bad
#[inline]
fn count(self) -> usize {
    let sum = 0;

    self.iter.for_each(|x| {
        if self.predicate(x) {
            sum += 1;
        }
    });
    
    sum
}
}

但这种实现在循环中有一个 if 语句,导致 CPU 需要进行大量的分支预测,而这些分支预测几乎都是随机的,CPU 很难根据历史记录提高预测的准确度,导致性能会比较低。而标准库的实现完全是 branchless 的,不仅性能好很多,而且也能方便 LLVM 做更多优化。

  • 关于怎样写好 branchless code 来优化性能又是另一个值得专门讨论的主题了,网上也有大量资料可以学习。但是 branchless code 会牺牲不少代码可读性,并不建议盲目的使用。

  • branchless code 的额外好处是还能帮助避免遭受旁路攻击(英文 timing attack 或者 side-channel attack)。参考:https://en.wikipedia.org/wiki/Timing_attack

继续回到我们刚才的那个 PR。我们的版本比标准库旧的版本在某些 case 下性能要低确实跟分支预测有关,因为我们的代码多了一种分支需要预测。

为了方便大家对比,我把汇编的截图贴到下面。我们只需要关注我标记文字的那几个颜色就可以。很容易看出来新版实现多了一个 jne 的跳转指令,导致 CPU 需要多做一次分支预测。

申明:启用 -O 参数之后汇编指令会被优化得更精简,这里没有启用 -O 是为了对应到每一行源码方便对比。

  • 标准库的汇编截图

  • 优化(1)的汇编截图

需要注意的是 jmp 指令是直接跳转,不需要进行分支预测。感兴趣的朋友可以看一下我在 Godbolt 上的对比:https://rust.godbolt.org/z/8dGbY8Pe1。这个网站是神器,强烈推荐!

优化(2)

所以我推测原作者实现标准库的 binary_search_by() 的时候不考虑 O(1) 最好时间复杂度的可能原因之一就是为了避免多余的分支预测。因为你要想 O(1) 就避免不了提前 return,要想提前 return 你就避免不了分支判断。那怎么办呢?PR 里面有一个大牛 tesuji 提供了一种思路:既然我们避免不了分支预测,那我们尽量帮助 CPU 更好的做好分支预测吧。于是我采用了他的方案,具体 commit 在这里,我们暂且叫它优化(2)


#![allow(unused)]
fn main() {
pub fn binary_search_by<'a, F>(&'a self, mut f: F) -> Result<usize, usize>
where
    F: FnMut(&'a T) -> Ordering,
{
    let mut left = 0;
    let mut right = self.len();
    while left < right {
        // never overflow because `slice::len()` max is `isize::MAX`.
        let mid = (left + right) / 2;
        // SAFETY: the call is made safe by the following invariants:
        // - `mid >= 0`
        // - `mid < size`: `mid` is limited by `[left; right)` bound.
        let cmp = f(unsafe { self.get_unchecked(mid) });
        if cmp == Less {
            left = mid + 1;
        } else if cmp == Greater {
            right = mid;
        } else {
            return Ok(mid);
        }
    }
    Err(left)
}
}

优化(2)的代码明显比标准库和优化(1)的代码更容易理解,再看一下它的生成的汇编代码。

可以看出来依然是两条 jne 指令,所以非重复模式下的性能可能还是没有标准库的高,但是确实比优化(1)的性能要好很多。过了几天 libs 组的 m-ou-se 回复了评论。她也做了 benchmark,发现对于原生类型比如 u32 下 l1 级别的数据量依然比标准库慢,但是那些需要更多时间比较的类型(比如 String)的情况下,新的实现在所有 case 下性能都要优于标准库的实现。后面大家又讨论了许多,最终 m-ou-se 决定先跑一个 crater 测试,先验证一下这个 PR 对 crates.io 上所有的 crate 影响面大不大。最终 library 团队会议一致同意可以 merge 这个 PR。

关于 crater 测试:

crater 大概就是针对整个 crates.io 上所有的 crate 给要测试的编译器版本(比如我的 PR )跑一次测试,看这个改动对线上所有 crate 影响大不大。crates.io 上超过5万个 crate,一般跑一次 crater 需要将近一周的时间。我的这个 crater 跑完之后就是因为没有对已发布的 crate 造成什么很大的影响,所以官方才愿意合并。

From m-ou-se:

"We discussed this PR in a recent library team meeting, in which we agreed that the proposed behaviour (stopping on Equal) is preferrable over optimal efficiency in some specific niche cases. Especially considering how small most of the differences are in the benchmarks above."

"The breakage in the crater report looks reasonably small. Also, now that partition_point is getting stabilized, there's a good alternative for those who want the old behaviour of binary_search_by. So we should go ahead and start on getting this merged. :)"

整数溢出问题

然而 scottmcm 又指出了另外一个问题:


#![allow(unused)]
fn main() {
// never overflow because `slice::len()` max is `isize::MAX`.
let mid = (left + right) / 2;
}

这行代码在零大小类型(Zero Sized Type,简称 ZST)下却可能会 overflow! 我们来分析一下为什么。

slice::len() 的返回值是 usize 类型,但是对于非零大小的类型(non-ZST),slice::len() 的值最大也只能是 isize::MAX。所以就像注释上写的那样 (isize::MAX + isize::MAX) / 2 是不能可能超过 usize::MAX 的,所以不会发生 overflow。但是对于 ZST 类型就不一样了,如果 slice 里面所有元素都是零大小的(比如 ()),那这个 slice 的长度完全可以达到 usize::MAX。虽然对于 [(); usize::MAX].binary_search(&()) 这种情况我们会在 O(1) 的时间复杂度上找到结果并马上返回,但是如果我们这么写 b.binary_search_by(|_| Ordering::Less),它就发生整数溢出了。

为什么 slice::len() 对于 non-ZST 最大值是 isize 呢?

最简单直接的原因是我们不能构造一个所有元素都为 non-ZST 并且长度为 usize::MAX 的数组或 slice,编译器在编译阶段直接会报错。比如以最简单的只占 1 个字节的 bool 类型为例,[bool; usize::MAX] 的大小将等于 std::mem::size_of::<bool>() * usize::MAX, 这是一个很大的数字了,整个计算机地址空间都不够。

fn main() {
    assert_eq!(std::mem::size_of::<bool>(), 1);
    // error: values of the type `[bool; 18446744073709551615]` are too big 
    // for the current architecture
    let _s = [true; usize::MAX];
}

但是对于 ZST 是可以的,因为 std::mem::size_of::<()>() * usize::MAX 依然是零。

fn main() {
    assert_eq!(std::mem::size_of::<()>(), 0);
    let s = [(); usize::MAX];
    assert_eq!(s.len(), usize::MAX);
}

不过上面的解释依然不够严谨,比如 std::mem::size_of::<bool>() * isize::MAX 也依然是一个很大的数字呀,为啥 isize::MAX 就可以?根本原因在于 Rust 指针寻址最大 offset 只允许 isize::MAX,至于为什么是 isize::MAXstd::pointer::offset() 的文档有解释。另外也可以看一下 std::slice::from_raw_parts() 的文档。而对于 ZST 的类型,编译器会做优化,它压根不需要寻址,所以最大大小可以是 usize::MAX

最终版本

意识到整数溢出的问题之后,解决方式也比较简单,这是我当时的提交,并且还增加了针对 overflow 的单元测试。


#![allow(unused)]
fn main() {
pub fn binary_search_by<'a, F>(&'a self, mut f: F) -> Result<usize, usize>
    where
        F: FnMut(&'a T) -> Ordering,
    {
        let mut size = self.len();
        let mut left = 0;
        let mut right = size;
        while left < right {
            let mid = left + size / 2;

            // SAFETY: the call is made safe by the following invariants:
            // - `mid >= 0`
            // - `mid < size`: `mid` is limited by `[left; right)` bound.
            let cmp = f(unsafe { self.get_unchecked(mid) });
            // The reason why we use if/else control flow rather than match
            // is because match reorders comparison operations, which is 
            // perf sensitive.
            // This is x86 asm for u8: https://rust.godbolt.org/z/8Y8Pra.
            if cmp == Less {
                left = mid + 1;
            } else if cmp == Greater {
                right = mid;
            } else {
                return Ok(mid);
            }

            size = right - left;
        }
        Err(left)
    }

#[test]
fn test_binary_search_by_overflow() {
    let b = [(); usize::MAX];
    assert_eq!(b.binary_search_by(|_| Ordering::Equal), Ok(usize::MAX / 2));
    assert_eq!(b.binary_search_by(|_| Ordering::Greater), Err(0));
    assert_eq!(b.binary_search_by(|_| Ordering::Less), Err(usize::MAX));
}
}

我们确实应该尽量避免写 let mid = (left + right) / 2 这种很容易发生整数溢出的代码,换成 let mid = left + size / 2 这种,可以避免发生 overflow。

另外还有人问为什么这里使用 if/else 而不是 match 语句?我们查看两个版本的汇编指令后发现 match 版本生成的汇编代码不仅指令更多而且还重排了 cmp 指令的顺序,性能似乎更差。理论上这两个版本生成的汇编指令应该可以做到一致的,我暂时没有深究原因为什么 match 版本的汇编会差一些,其他读者感兴趣可以研究一下。

总结

表面波澜不惊,实则暗流涌动。一个看起来十分简单的 PR 里面其实涉及到很多内容。学无止境,笔者通过这个 PR 收获了很多,现在分享出来同时也希望能够激励更多国内开发者参与进来。Rust 社区文化是如此开放和包容,任何人只要发现有可以改进的地方都可以随时给 Rust 仓库提交 PR 或 issue,这样做不仅能帮助 Rust 越来越好,你自己也会得到巨大的成长和收获!


关于我

Id: Folyd,GitHub:@folyd。字节跳动飞书 Rust 工程师,Rust Search Extension 作者。

招聘

字节跳动飞书团队自 2017 年就开始使用 Rust 开发飞书多端跨平台 SDK,为 Android / iOS / Window / macOS / Linux 等平台提供高质量的底层基础库,同时飞书内部的效率工具和少数后端系统也全部采用 Rust 开发。我们可能是国内 Rust 工程师最多的团队之一!我们长期招聘热爱 Rust、喜欢 Rust、看好 Rust 前景的工程师加入。支持实习、校招、社招。Base 北京,欢迎大家自荐或推荐,请联系 wx: newpants629,或者直接在内推链接投递简历:https://job.toutiao.com/s/eB1j29f

GitHub 趋势榜

编辑:张汉东


盘点一下 本月 GitHub 趋势榜上榜的 Rust 项目

从趋势榜中看得出来,「Rust 嵌入式/ WebAssembly/ 网络服务/ 性能遥测/ Web/ 云计算/ 游戏/ GUI」是 Rust 相对受关注比较多的领域。

Top 1: rust-embedded / rust-raspberrypi-OS-tutorials

该项目是 Rust 嵌入式工作组维护的一个教程项目,使用 Rust 实现一个基于 树莓派的操作系统。这个月热度很高,是因为最近该项目活跃度又提升了,加了不少更新。

Top 2: TheAlgorithms/Rust

该项目是教育为目的的 Rust 算法实现库。近两个月更新不太活跃,但是却上了本月的趋势榜,可能在某些技术媒体做了宣传?

Top 3: bytecodealliance/wasmtime

该项目为字节码联盟成员 Fastly 公司的 WebAssembly 的编译器和运行时,基于 Cranelift 。最近字节码联盟比较活跃,成立了新的开源组织,吸收了新成员,所以受关注比较多。

Top 4: LemmyNet/lemmy

Lemmy 是基于 actix-web 开发的 仿 reddit 开源论坛。最近发布了新版本。

Top 5: rustdesk/rustdesk

RustDesk 是国内一位开发者发布的开源项目,号称最好的远程桌面客户端,基于 Rust 和 sciter实现。我邀请过 RustDesk 作者写文分享这个产品的开发心得(主要想了解 Windows 开发方面的心得 ),但是遭到作者以不会写文章为由推辞(汗),但是该库代码是完全开源的,想学习的朋友可以看源码学习吧。

Top 6: firecracker-microvm/firecracker

Firecracker 是 AWS 开源的 用于 severless 计算的 microVMs。

Top 7: yewstack/yew

允许你使用 Rust 和 WebAssembly 构建 Web App 的客户端。最近又有了活跃的更新。

Top 8: dani-garcia/vaultwarden

用Rust 实现的非官方 Bitwarden 兼容服务器。并且与上游Bitwarden客户端*兼容,非常适合自托管部署,在这种情况下,运行官方资源密集型服务可能不理想。延伸阅读:登录信息就该自己掌握:基于私有云的 Bitwarden 迁移指南

Top 9: novifinancial/winterfell

构建分布式 STARK 证明程序的实验项目。这是和 零知识证明 相关的库。

警告:这是一个研究项目。 它未经审核,可能包含错误和安全漏洞。 此实现尚未准备好用于生产。

Top 10: KOBA789/relly

一个小型的 关系数据库管理系统(RDBMS) 实现,以了解RDBMS的工作方式。

Top 11: extrawurst/gitui

Rust 实现的高性能终端 Git UI。刚发布了新版本。

Top 12: mozilla/glean

Mozilla 出的现代高性能跨平台遥测(Telemetry)库,Glean有两种实现,总共支持5种不同的编程语言。

Glean Book

Top 13: meilisearch/MeiliSearch

MeiliSearch是功能强大,快速,开源,易于使用和部署的搜索引擎。 搜索和索引编制都是高度可定制的。 开箱即用的功能包括拼写错误,过滤器和同义词。这有一篇 MeiliSearch CEO 访谈

Top 14: timberio/vector

Vector是高性能的端到端(代理和聚合器)可观察性数据管道,可让开发者控制可观察性数据。 开源,并且比其他任何方式快10倍。这也是一个非常优秀的开源项目,国内豆瓣在用它。QuickStart

Top 15: serenity-rs/serenity

这是 Discord API 的 Rust 库,可以用它开发 Discord Bot 。

Top 16: EmbarkStudios/rust-gpu

EmbarkStudios 出品的 GPU 库,目标是让 Rust 成为 GPU 编码的一流语言和生态。EmbarkStudios 公司也是 Rust Game 工作组的成员,为 Rust 游戏领域贡献不少库。最近该公司也加入了字节码联盟。

Top 17: solana-labs/solana

网络级(Web-Scale)区块链,用于快速,安全,可扩展,去中心化的应用程序和市场。去年 Solana 还赞助了 RustChinaConf。

Top 18: Kethku/neovide

一个 Neovim 简单图形用户界面。

Top 19: hyperium/tonic

tonic是基于HTTP/2 的 gRPC 实施,专注于高性能,互操作性和灵活性。 创建该库是为了对async / await提供一流的支持,并充当用Rust编写的生产系统的核心构建块。 最近几天在努力为发新版(0.4.3)做准备。

Top 20: tonarino/innernet

允许你创建使用 WireGuard 的专用网络系统。 WireGuard 是一款新型虚拟专用网络,旨在替代 IPSec 和 OpenVPN。它的设计目标就是简单而且安全,并且充分利用噪声协议框架(Noise Protocol Framework)等新技术。

内部网的目标与Slack的 nebulaTailscale 类似,但采取了不同的方法。它旨在利用现有的网络概念(如CIDR)和WireGuard的安全属性,将计算机的基本IP网络转变为功能更强大的ACL原语。

innernet不是官方的WireGuard项目,WireGuard是Jason A. Donenfeld的注册商标。

该软件尚未接受独立的安全审核,因此应在其生命周期的早期阶段视为试验性软件。

Top 21: tauri-apps/tauri

允许开发者使用Web前端构建更小,更快和更安全的桌面应用程序。最近发布了 1.0-beta 版。知乎:如何评价 tauri?

Top 22: linebender/druid

Druid是一个实验性的Rust-native UI工具箱。 其主要目标是提供完善的用户体验。最近也是开发比较活跃。

Top 23: bevyengine/bevy

Bevy 可以说是当下最火的 Rust 游戏引擎了。最近发布了0.5版本,对其 ecs 引擎升级到了 V2。

Top 24: gfx-rs/wgpu-rs

wgpu 的 Rust 绑定库。 它的设计适合于Rust社区的通用图形和计算需求。wgpu-rs可以直接针对本地支持的后端和WASM。正在准备发布新版(0.8)。

推荐项目 | 基础工具库

编辑:张汉东


Rust Search Extension v1.2 发布

  • 支持使用 // 前缀搜索 rustc 编译器的源码。
  • 支持直接搜索 Rust 版本号,跳转到该版本的 release 界面。
  • 新的源码跳转模式:在关键字前添加 src:s:,搜索结果会跳转到源码页面。
  • 新的 :blog 命令。

https://rust.extension.sh/changelog/

Ockam:用于端到端加密、相互身份验证通信的Rust库

用于边缘设备和云服务之间的端到端加密、相互认证通信的Rust和Elixir库。

物联网中的机器通过与云服务和其他联网机器交换信息来进行操作。安全的、私有的和可信的连接应用程序必须保护这些消息不被窃听、篡改和伪造。

https://github.com/ockam-network/ockam/

Himalaya: 极简命令行邮件客户端

https://github.com/soywod/himalaya

Amadeus - Rust 大数据处理

Amadeus 是一组开箱即用、低层可重用构建件,来构造 Rust 分布式计算和大数据生态。 它宣称符合以下原则:

  • 无畏:无数据竞争,无 unsafe 代码,无损失数据克隆
  • 无感分布式:分布式运行时和在本地运行时一样简便和高性能
  • 数据递进类型化: 当调度计划已知时可以最大化性能,其他情况则可以保持灵活性
  • 简洁:尽可能地保持接口和实现简单可靠
  • 可靠:尽可能减少不可处理的错误,并且只会抛出内部不可处理的错误

https://github.com/constellation-rs/amadeus

使用 activex 和 feed-rs 开发的 RSS 浏览器

作者使用actix和feed-rs开发了自己的RSS浏览器。它很简单,作家将尽量保持这种方式,但仍然会添加一些功能。

robusta : 一个方便生成 JNI 交互代码的库

有了它就可以避免写那些繁琐冗长的 Java JNI 函数名了!

例子:


#![allow(unused)]

fn main() {
use robusta_jni::bridge;
use robusta_jni::convert::Signature;

#[bridge]
mod jni {
    #[derive(Signature)]
    #[package(com.example.robusta)]
    struct HelloWorld;

    impl HelloWorld {
        pub extern "jni" fn special(mut input1: Vec<i32>, input2: i32) -> Vec<String> {
            input1.push(input2);
            input1.iter().map(ToString::to_string).collect()
        }
    }
}
}
package com.example.robusta;

import java.util.*;

class HelloWorld {
    private static native ArrayList<String> special(ArrayList<Integer> input1, int input2);

    static {
        System.loadLibrary("robusta_example");
    }

    public static void main(String[] args) {
        ArrayList<String> output = HelloWorld.special(new ArrayList<Integer>(List.of(1, 2, 3)), 4);
        System.out.println(output)
    }
}

https://github.com/giovanniberti/robusta

SWC 1.2.52 版发布

swc 是一个 Rust 编写的 typescript/javascript 编译器, 可以生成兼容老旧浏览器的 javascript 代码.

单核比 babel 快 20倍, 4 核比 babel 快 70 倍.

https://swc.rs/blog/2021/04/11/swc-1.2.52/

dipa - 轻松为 Rust 数据结构添加增量编码支持

dipa 可以轻松高效地对大型 Rust 数据结构进行增量编码。增量编码技术可以用于确定在数据结构的两个实例之间发生了什么变化,利用此项技术,可以有效减少传输相似数据所耗费的流量和带宽。

static_init v1.0 发布 - 更快的静态变量

static_init 支持安全的可变静态量和非常量静态量初始化,与 lazy_static 或 parking_lot::RwLock 相比,具有 200 倍的速度提升。

https://crates.io/crates/static_init

Macchina: 快速,精简和可定制的系统信息提取器

Macchina允许您查看基本的系统信息,例如主机名,内核,正常运行时间,内存使用情况等等。它为您提供方便的功能和广泛的自定义选项,但不会忽略其两个主要优先级,即简约和性能。

https://github.com/Macchina-CLI/macchina

Evcxr : Rust Jupyter notebook

本文主要是对 Rust Jupyter 内核的创建者David Lattimore的采访。看完感觉 Rust 用于数据科学领域指日可待。

摘录一些:

  1. evcxr的发音是 “e-vix-er” 。
  2. David 之所以做这个,也是因为受他妻子影响。他妻子是数据科学家,常用 Jupyter notebook ,受她影响 David 尝试做了 Jupyter rust 核心,然后把 他之前做的 Rust REPL 工具 集成。
  3. 目前 Rust REPL 之类的工具还在维护的就是 evcxr ,以及一个最近几天刚开始的新库 IRust 。像之前的 rusti 之类的库已经停止维护了。
  4. 从 syn 改为 使用 rust-analyzer 来获取类型 :https://github.com/google/evcxr/commit/b82b7eabb9fe5f4fb7de42c686bd52148ad42a24 (将 ra直接当库使用,缺点是编译太慢,另一种作者想到的可以集成的替代方法是实际引入rust-analyzer二进制文件并使用语言服务器协议与其进行对话,但作者还没这么做)
  5. 一本新书:《DATA ANALYSIS WITH RUST NOTEBOOKS 》 https://datacrayon.com/shop/product/data-analysis-with-rust-notebooks/ 对应 video 介绍:https://www.youtube.com/watch?v=0UEMn3yUoLo

rustcommon : Twitter 开源的通用 Rust 库

看来 Twitter 内部也用 Rust 。

这是 Twitter 内部 Rust 项目通用库,包含数据结构/ log/ metrics/ timers/ ratelimiting。

https://github.com/twitter/rustcommon

Twitter 的 另外两个 Rust 开源项目:

vicis:允许你用 Rust 操作 LLVM-IR

目前完成度不高

https://github.com/maekawatoshiki/vicis

delay-timer 0.4 发布

delay-timer是一个基于时间轮算法构建的lib,它可以很方便地管理定时任务,或者周期性地执行任意任务。

轻松打造一个延迟/周期任务的运行容器。可以想象成crontab,但可以处理同步/异步任务,但支持动态添加/取消/删除/更新, 单个任务支持配置任务的最大并行数量,运行时间等。

底层运行时基于的smol和tokio(可选的feature),你可以用其中一个来构建你的应用程序。

v0.4.0 新功能:

  1. 支持动态的修改运行中的任务。
  2. 支持了insert任务后获取句柄TaskInstancesChain,可以动态获取运行中的任务实例TaskInstance
    • 运行中任务的任务实例可以动态取消。
    • 取消分为三种方式:同步阻塞取消、超时限制取消、异步取消。
    • 支持读取运行中任务的运行状态。
  3. 支持获取内部异步子任务进程的输出。
  4. 更新依赖:
    • 替换 waitmap -> dashmap .
    • 升级 cron_clock .
  5. 更新examples:
    • 增加,async-std & tokio 使用案例。
    • 增加,动态取消运行中任务实例案例。
  6. 丰富了文档。

perf-monitor-rs: 飞书 Rust 团队最近开源的一个跨平台的性能数据采集库

目前可以对cpu、内存、io和fd的相关数据进行采集。

https://github.com/larksuite/perf-monitor-rs

Zellij: Rust编写的新终端复用工具

img

推荐项目 | 框架引擎

编辑:张汉东


Bevy 0.5 发布

此版本更新较多, 包括:渲染时使用PBR着色器、GLTF 加载器改进、ECS core 重写等

https://bevyengine.org/news/bevy-0-5/

rust-gpu : 推动 Rust成为一流的GPU代码语言和生态系统

Embark Studios 公司出品

https://github.com/EmbarkStudios/rust-gpu

SixtyFPS : 用于桌面应用程序和嵌入式设备的全新的图形工具包

工具包包含以下关键组件:

  • 对 UI元素 设计友好的 标记语言
  • 具有C ++,Rust和JavaScript的API的运行时库
  • 一个优化的编译器,可将设计编译为本地C ++ / Rust

支持编译到 WebAssembly 。GitHub 仓库 Readme 中有在线demo,看着不错。

开源产品 | TensorBase,基于Rust的现代化开源数据仓库

作者:金明剑 / 后期编辑:张汉东

编者按:

在当前国产替代背景下,国产开源软件层出不穷。

数据库领域作为占据IT基础架构软件的 20%,目前也有很多国产数据库。比如 国内阿里的 OceanBase、腾讯的 TBase、PingCAP的 TiDB 等。

而作为基础架构软件领域开发难度最大的三个部分,替代周期非常长,因此国产数据库会有很大替代的机会。

TensorBase 是金明剑博士的创业项目,使用 Rust 实现的现代化开源数据仓库,也开始迈出了这艰难的一步。据悉,TensorBase 项目目前已获得世界知名创业投资加速机构的天使轮投资,期待 TensorBase 生态繁荣。


TensorBase是什么

这是一个数据以海量方式产生的时代,但我们这个时代的个体和绝大多数企业并没有很好基础设施来发掘这些正在海量产生的数据的价值。TensorBase,从一个现代的全新视角出发,用开源的文化和方式,重新构建一个Rust下的实时数据仓库,服务于这个海量数据时代的数据存储和分析。

TensorBase的优势

  1. All in Rust。Rust的优点已经被Rust月刊中的各种文章所反复“安利”,不多赘述。TensorBase认为,Rust是一次系统工程的编程范式革命。范式的革命为我们提供了利用新的工具和方法去构建新的基础设施平台的契机。作为系统语言“试金石”的数据库类软件,Rust不应缺席。TensorBase已经在日常测试中经历数十TB的数据注入锤炼,是目前Rust社区中,面向用户特别是中小企业实际场景深度优化的、生产完成度最高的通用数据仓库类项目。

  2. 开箱即用。TensorBase已经支持从数据插入或导入到查询完整数据仓库流程,具备了较高的早期完成度。从您看到本文的这一刻开始,就可以从TensorBase的Release页下载相关Linux环境下的二进制文件,进行尝试。(Windows 10的WSL2目前应该也是能用的,欢迎反馈!)

  3. ClickHouse协议兼容。ClickHouse作为一个C++编写的数据仓库,已经为国内外的很多企业所使用。TensorBase则使用Rust语言从头开始实现了一个高性能的ClickHouse SQL方言解析器和TCP通讯协议栈。ClickHouse TCP客户端可以无缝连接TensorBase。所以,如果你有ClickHouse的使用经验,你可以零学习成本地使用TensorBase。即使没有用过ClickHouse,你也很容易找到相关学习资料,这令你能轻松进入TensorBase。

  4. 性能为先。TensorBase期望通过新的软件和系统设计将现代硬件的所有潜力发挥出来。TensorBase首次在核心链路代码上实现了“F4”:Copy-free,Lock-free,Async-free,Dyn-free(无动态对象分发)。初步的性能评估显示:在14.7亿行的纽约出租车数据集上,TensorBase的简单查询的性能上已经领先ClickHouse。当然,在复杂的查询上,TensorBase离ClickHouse还有一定差距,但这正是我们开源社区所想要推进的。

  5. 化繁为简。目前的大数据系统使用非常复杂,即使想运行一个最简单的系统,都需要配置大量难以理解的参数或者安装大量第三方依赖。

    • 对于用户,除了现在已经达成的开箱即用,TensorBase希望系统在运行时能在自治运行,而不是依赖运维管理员。
    • 对于开发者,TensorBase希望将贡献门槛降低。整个项目架构设计简洁高效(更多信息参见后文),项目外依赖很少,完全重新编译(cargo clean到cargo build)的单机时间在1分钟之内。(大数据系统或者C++数据库的完整构建时间往往以小时计。)
  6. 互联未来。TensorBase在核心上改造了Apache Arrow和DataFusion,无缝支持Arrow格式查询、分析和传输。Arrow格式作为越来越广泛采用的大数据交换中间格式,已经被多个数据库及大数据生态平台所支持。TensorBase在引擎上兼容Arrow,未来可以同时支持云原生和云中立场景下的数据仓库体验,提供存储中立的数据湖服务。

TensorBase的架构

base_demo TensorBase整体架构

  • Base Server

    TensorBase服务接口层。对外提供数据的接口服务,比如数据的写入和查询入口。TensorBase创造性的实现了世界上第一个非C++的ClickHouse TCP协议服务栈,可以支持ClickHouse客户端(clickhouse-client命令行)以及native协议语言驱动的直接连接。同时,Base Server是第一个async中立的Rust高性能服务器。Base Server基于改造的Actix事件循环,在服务的实现中完全不使用async,在提供绝佳的可调试性的同时,评测性能也大幅超过基于tokio默认async表达层的实现。未来可以引入非tokio的网络io层实现。

  • Base Meta/Runtime/Storage

    TensorBase的元数据层、运行时层和存储层。在存储层,TensorBase非经典的列式存储。这其中最重要的,我们给出了一个反重力设计:No LSM。我们不再使用在目前开源数据库及大数据平台流行的LSM Tree(Log Structured Merge Tree)数据结构。而是使用一种我们自己称之为Partition Tree的数据结构,数据直接写入分区文件,在保持append only写入性能的同时,避免了LSM结构的后续compact开销。得益于现代Linux内核的支持和巧妙的写入设计,我们在用户态(User-space)核心读写链路上不使用任何锁(Lock-free),最大程度的发挥了高并发网络服务层所提供的能力,可以提供超高速数据写入服务。

  • Base Engine

    TensorBase的引擎层。TensorBase使用改造过的Apache Arrow和DataFusion,并创造性的将底层存储适配到Arrow格式,实现了Zero Copy的数据查询。当然,目前的适配性存储策略,还只算是一个现在进行中的次优解,TensorBase未来会对存储层进行持续迭代,提供更多与时俱进的优化。同时,TensorBase也将进一步地优化帮助Arrow/DataFusion社区优化其查询引擎的性能,和社区一起成长。

  • 其他

    TensorBase还有一些基础性的组件,比如:

    • base,通用工具库;
    • lang,语言层(目前主要实现一个ClickHouse兼容解析和表示层)。
    • lightjit,类表达式JIT引擎,未来可扩展至高性能和安全可控的用户定义函数UDF(User Defined Functions)层。 TensorBase未来将进一步开发和开放自己的高性能基础件,为Rust社区贡献一些独特的高性能可复用基础设施。

    最后,架构图中的虚线连接尚未实现,放在这里提供给大家一个全景式的架构蓝图。

TensorBase的进展

base_demo TensorBase开箱即用演示

如上图所示,TensorBase已经能提供开箱即用的单机数据仓库服务,欢迎试用反馈。

在未来的几天内,我们将完成通过TPC-H评测所需的存储层支持(主要是String类型的存储支持),并提供初步的TPC-H测试结果。

近期线路图包括如下几个有趣的方向:

  1. 分布式集群。 基于DataFusion的Ballista,很容易实现一个类ClickHouse的简易(半人工)分布式集群方案。然而,我们将这作为开始,TensorBase希望提供新的思维和方向,让分布式查询这件事能做的简单高效、易于开发和维护。
  2. 存储层增强。引入主键等高级选项。
  3. Arrow和Data Fusion查询内核性能改进。
  4. ClickHouse HTTP通讯协议及其他查询前端增强。
  5. 单机可靠性加固。

加入社区

TensorBase相信,一个能适应时代的基础设施,如同Rust语言本身一样,一定是开源和创新的。欢迎大家到TensorBase社区来分享自己的观点和代码,见证对未来数据仓库的思考和迭代。

  • 项目地址:https://github.com/tensorbase/tensorbase
  • Discord服务器:https://discord.gg/E72n2jzgKD
  • Slack频道:https://join.slack.com/t/tensorbase/shared_invite/zt-ntwmjvpu-TQ9drOdUwNJWmUTXvxMumA
  • 微信群

扫码加微信群:

扫码加入微信群

TensorBase将推出更多的系列文章,以期让大家更深入的理解项目以及Rust语言在项目中的使用,也欢迎所有有兴趣的小伙伴参与到整个开源工程当中。

加入TensorBase社区,一起构建Rust大数据生态的下一个五年!


作者简介:

金明剑,TensorBase项目创建人,中国科学院大学博士,17年商业和高性能软件开发经验,Eclipse平台先锋,Scala语言先锋,Scala基金会2010年首次入选谷歌编程之夏的项目完成人,阿里云天池工程系列赛多项赛事记录的保持者。在大数据基础设施、语言和高性能系统等方面长期保持系统性地思考及研究。

开源产品 | eclipse zenoh 助力雾计算和边缘计算

原作者:Angelo @ adlinktech.com / 编辑整理:张汉东

编者按:

凌华科技(AdlinkTech)公司总部位于台湾,并在美国、新家坡、北京、深圳、上海等40多个国家和地区设立分支机构。

在 2020年7月,凌华科技(ROS 2技术指导委员会成员)推动 Eclipse Cyclone DDS (OMG数据分发服务规范的实现) 成为 ROS Tier-1 中间件,将要书写机器人和IoT系统的未来。而 eclipse zenoh 正是 CychoneDDS 的姊妹项目。


eclipse zenoh (读: /zeno/ ) ,提供了零开销的Pub/Sub、Store/Query 和 计算。

zenoh 统一了 动态/静止/使用中的数据并提供计算结果。它颇有分寸地将传统的Pub/Sub与地理分布的存储、查询和计算融合在一起,同时保留了远远超出任何主流协议栈的时间和空间效率水平。

官网是 zenoh.io

GitHub代码仓库 eclipse-zenoh/zenoh

2020 年 12 月 Eclipse Edge Native 工作组启动,并将 Zenoh 引入 Eclipse 。并用 Rust 对 zenoh 进行重写。

Why Rust

zenoh的第一个版本是用OCaml(一种纯的 ML 函数式编程语言)编写的。 OCaml 使开发者能够非常快速地进行实验并具有良好的性能。 但是,zenoh 团队从社区收到的主要反馈之一是,很少有人知道这种编程语言,这限制了人们的贡献。 此外,zenoh团队还希望使zenoh更快更小。 一个显而易见的选择是用C / C ++编写新版本,但是zenoh团队不想失去的是使用OCaml所享有的安全性。 另外,如果不得不离开心爱的OCaml,也不想完全放弃高级抽象。 zenoh团队还想避免运行时间长和垃圾回收器过多的语言。 早在2015年,zenoh团队就已经看过Rust了,但那时他们并不觉得对他们而言是正确的选择。 2018 Edition 在编程语言中引入的改进以及异步的引入使 Rust 成为了 zenoh 的理想选择。在使用 Rust 重写以后,性能得到了很大的提升。

概述

zenoh 将动态数据、使用中数据、静态数据与计算整合。它巧妙地将传统的发布/订阅与地理上分散的储存、查询和计算融合,同时保有远胜任何主流堆栈的时间和空间效率。

随着连网设备的数量稳定增加,业界在计算、储存和通信能力方面遇到前所未有的异质性,并且在产生数据以及必须交付和使用数据的规模方面面临新的挑战。

此外,基于性能、效率和隐私考虑,人们越来越希望使数据处理尽可能靠近来源,同时方便存取远距离的应用程序。换句话说,我们正经历主流架构转换,从以云端为中心的模式(从云端到雾端储存、处理和采集数据)变成以边缘为中心的模式(在对性能、能源效率和安全性最有意义的位置储存和处理数据)。

zenoh 的目的在于满足必须以可扩展、高效率且位置透明化的数据方式处理动态数据、静态数据和计算的应用程序的需求。

zenoh 的目的在于:

  • 提供小型原语集合以处理动态数据、静态数据和计算。
  • 全面控制储存位置和后端技术整合。
  • 将网络负担最小化 – 数据信息的最小线路负担为 4 字节。
  • 支持极度受限的装置 – 在 Arduino Uno 上的占用空间为 300 字节。
  • 允许数据交换模式和排程交涉以支持低工作周期装置。
  • 提供丰富的抽象化集合,在整个系统中分配、查询和储存数据。
  • 提供极低延迟和高传输量。我们也提供 zenoh 的效率相对于 MQTT 等主流协议的分析与实证比较。
  • 提供称为 zenoh-net 的低端 API,让您全面控制 zenoh 原语。

zenoh 关键概念

部署单元

zenoh 提供三种部署单元:

  • 对等方(peers)。用于在 对等网络(peer-to-peer)、网状(mesh)拓扑网络和其他节点通信,也可以通过路由器与广泛的系统通信。
  • 客户(clients)。用于连接单个路由器(或点)来和系统其他部分通信。
  • 路由器(routers)。用于在 client和peers之间、集团拓扑和网状拓扑网络之间路由数据。

用户 API

zenoh 提供 两级 API 。

zenoh-stack

zenoh-net

实现能够在数据链路,网络或传输层之上运行的网络层。

zenoh-net支持点对点和路由通信,提供了允许Pub/Sub(Push)通信以及 Query/Reply (Pull) 通信的关键原语。它支持分段和有序可靠交付,并为 Discovery( ROS2 Discovery Service )提供了可插入的侦查抽象。

zenoh-net定义并建立在会话协议的基础上,该会话协议为有序尽力而为( best effort)和具有不受限制的 MTU 的可靠信道提供了抽象,而 MTU 与底层无关。

zenoh-net层仅关心数据传输,不关心数据内容或存储数据。

提供的原语:

  • Write:将实时数据推送给相对应的订阅者。
  • Subscribe:订阅实时数据。
  • Query:从匹配的查询对象中查询数据。
  • Queryable:能够响应查询的实体。

zenoh

zenoh 层为 Pub/Sub 和分布式查询提供了高级API,以更简单和更面向数据的方式提供与zenoh-net API相同的抽象,并提供所有构建块来创建分布式存储。 zenoh层知道数据内容,并且可以应用基于内容的过滤和代码转换。

它处理数据表示编码和代码转换,并提供地理分布式存储和分布式计算值的实现。 zenoh原生支持一系列数据编码,例如JSON,Properties,Relational,Raw等,以及跨支持格式的转码。 它还基于URI语法定义了规范的查询语法。 zenoh层还提供了存储后端插件API,以简化第三方存储技术的集成。 当前支持的存储后端是Memory,MySQL,MariaDB,PostgreSQL,SQLite和InfluxDB。默认情况下,地理分布式存储在最终一致性下工作。 用户可以利用仲裁(Quorum)机制实现更强的一致性。

提供的原语:

  • Put:将实时数据推送到匹配的订阅者和存储。 (相当于zenoh-net write)
  • Subscribe:实时数据的订阅者。 (相当于zenoh-net订阅)
  • Get:从匹配的存储和演算中获取数据。 (相当于zenoh-net查询)
  • Storage:一个zenoh-net 订阅者侦听要存储的实时数据,以及一个zenoh-net 可查询以响应匹配的get请求的组合。
  • eval:能够响应请求的实体。 通常用于按需提供数据或构建RPC系统。 (相当于zenoh-net可查询)

zenoh 目前的一些特性

更好的协议支持。在利用网络特定功能的同时将 zenoh 移植到不同类型的网络非常简单。用户提供的附件与数据和查询可以一起携带。 zenoh应用程序可以使用这些附件来扩展协议,或例如增加用户级别的安全性。

通用的点对点和客户通信。应用程序可以在运行时决定其行为类似于 对等方(peer) 还是 客户(client)。对等方在彼此之间路由信息,也可以代表客户进行路由,换句话说,对等方的行为类似于路由器。对等网络通信支持任意连接图,并在特殊情况下支持集团。

基于Closure的发现。侦察(scouting)协议支持zenoh中的发现,以便简化想要利用集团(clique)连接性的系统的部署,在多播不可用或不希望使用多播的情况下,现在支持基于Closure的发现。换句话说,从单个对等方开始,可以发现其 Closure,或者换句话说,可以从此起点直接或间接地到达对等点。

基于区域(Region)的路由。这实际上意味着两件事:(1)建立和维护路由表随区域大小缩放所需的路由信息,以及(2)每个区域都可以决定是否要在任意连接图上进行路由的集团。通过允许在区域内使用最合适的路由技术,这种路由方法将极大地提高可伸缩性和性能。

目前,Zenon 发布了 代号为 Aithusa 的版本,提供了 DDS PluginROS2 的集成。

参考

Unsafe Rust 编码技巧 | 01

编辑:张汉东

汇总一些 Unsafe Rust 编码技巧,欢迎补充。


编写 Unsafe Rust 的标准规范

来源:wasmtime


#![allow(unused)]
fn main() {
    /// Invokes this WebAssembly function with the specified parameters.
    ///
    /// Returns either the results of the call, or a [`Trap`] if one happened.
    ///
    /// For more information, see the [`Func::typed`] and [`Func::call`]
    /// documentation.
    ///
    /// # Panics
    ///
    /// This function will panic if it is called when the underlying [`Func`] is
    /// connected to an asynchronous store.
    pub fn call(&self, params: Params) -> Result<Results, Trap> {
        assert!(
            !cfg!(feature = "async") || !self.func.store().async_support(),
            "must use `call_async` with async stores"
        );
        unsafe { self._call(params) }
    }

}

当函数中调用了 Unsafe 函数,必须对其进行安全抽象。

上面代码示例中,使用 assert! 宏,将 _call调用控制在了安全边界内,所以函数 call 目前是一个安全的函数,所以不需要在 fn 前面增加 unsafe 标签。


#![allow(unused)]
fn main() {
    unsafe fn _call(&self, params: Params) -> Result<Results, Trap> {
        // Validate that all runtime values flowing into this store indeed
        // belong within this store, otherwise it would be unsafe for store
        // values to cross each other.
        if !params.compatible_with_store(&self.func.instance.store) {
            return Err(Trap::new(
                "attempt to pass cross-`Store` value to Wasm as function argument",
            ));
        }

        // ...
        // ignore others codes
        // ...

        // This can happen if we early-trap due to interrupts or other
        // pre-flight checks, so we need to be sure the parameters are at least
        // dropped at some point.
        if !called {
            drop(params.assume_init());
        }
        debug_assert_eq!(result.is_ok(), returned);
        result?;

        Ok(ret.assume_init())
    }
}

对于 _call 函数来说,因为无法在函数内验证所有传入的运行时值是否在合法的安全边界,所以需要将其标记为 Unsafe 函数,即在 fn 前面加上 unsafe 标签。除此之外,还必须在函数内脆弱的地方,加上必须的注释来说明什么情况下会突破安全边界。


#![allow(unused)]
fn main() {
/// A trait implemented for types which can be arguments and results for
/// closures passed to [`Func::wrap`] as well as parameters to [`Func::typed`].
///
/// This trait should not be implemented by user types. This trait may change at
/// any time internally. The types which implement this trait, however, are
/// stable over time.
///
/// For more information see [`Func::wrap`] and [`Func::typed`]
pub unsafe trait WasmTy {
    #[doc(hidden)]
    type Abi: Copy;
    #[doc(hidden)]
    #[inline]
    fn typecheck(ty: crate::ValType) -> Result<()> {
        if ty == Self::valtype() {
            Ok(())
        } else {
            bail!("expected {} found {}", Self::valtype(), ty)
        }
    }
    #[doc(hidden)]
    fn valtype() -> ValType;
    #[doc(hidden)]
    fn compatible_with_store(&self, store: &Store) -> bool;
    #[doc(hidden)]
    fn into_abi(self, store: &Store) -> Self::Abi;
    #[doc(hidden)]
    unsafe fn from_abi(abi: Self::Abi, store: &Store) -> Self;
}
}

对于上面的 trait ,因为是内部使用,随时可能发生改变。所以标记为 Unsafe ,并加上注释提示该 trait 不该又库用户自己实现,而是由维护者在内部为指定类型实现,这些类型应该是稳定的。如果用户想自己实现,那么要明白它是 Unsafe 的。

所以,不一定是出于内存安全才指定 Unsafe ,也可以作为一种和库用户的约定。

在 FFi 时方便调用 Rust 闭包

use std::os::raw::c_void;

pub type Callback = unsafe extern "C" fn(user_data: *mut c_void, arg: i32) -> i32;

pub unsafe extern "C" fn execute_a_closure(arg: i32, cb: Callback, user_data: *mut c_void) -> i32 {
    cb(user_data, arg)
}

/// 获取一个可以用作[`Callback`]的函数指针,该函数指针将指向闭包的指针作为其`user_data`。
pub fn raw_callback<F>(_closure: &F) -> Callback
where
    F: FnMut(i32) -> i32,
{
    unsafe extern "C" fn wrapper<P>(user_data: *mut c_void, arg: i32) -> i32
    where
        P: FnMut(i32) -> i32,
    {
        let cb = &mut *(user_data as *mut P);

        cb(arg)
    }

    wrapper::<F>
}

fn main() {
    let mut calls = 0;
    let mut closure = |arg: i32| {
        calls += 1;
        arg
    };

    unsafe {
        let func = raw_callback(&closure);

        let got = execute_a_closure(42, func, &mut closure as *mut _ as *mut c_void);

        assert_eq!(got, 42);
        assert_eq!(calls, 1);
    }
}

经过 Miri 检测没有 UB。

五月刊

logo

目录顺序排名不分先后!

发刊通告

Rust 资讯

Rust 与 学术

Rust in Production

学习园地

新手入门

语言特性与技巧

算法

Rust 与 操作系统

【主题专栏】Rust 探索系统本原

开源项目巡礼

Rust vs Security

发刊通告

编辑:张汉东

五月发刊通告

匆忙的五月即将过去,《 RustMagazine 中文精选 》2021 年第五期发布了!

随着五月刊的发布,时光也马上步入六月,意味着2021年半年即将过去。

祝大家抓紧每一份时光,活在当下。无论如何,加油吧!

本期遗憾的是,Rust 六周年社区征文无一响应,也许大家都很忙,但期望遇到你与 Rust 的故事。欢迎随时投稿。

本刊动态

(GitHub Page)现在已经支持 Rss 订阅

  1. 订阅地址:https://rustmagazine.github.io/rust_magazine_2021/rss.xml

  2. 复制订阅地址到你最喜欢的订阅工具开始订阅。

语雀平台自动支持邮件订阅。

上期(四月刊)访问数据统计小结

用户数

  • 新用户新增 2017

浏览量:

  • 网页浏览量 : 14,008

读者分布地区排名:

  • 亚洲,新用户增加 1,809
  • 美国,新用户增加 132
  • 欧洲,新用户增加 52

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

Rust 官方动态

编辑: 张汉东


Rust基金会成员开放申请了:

https://foundation.rust-lang.org/info/become-a-member/

Rust 1.52 稳定版发布

要点:

  1. unsafe_op_in_unsafe_fn lint 稳定。当前 Rust 把 unsafe 函数本身当作一个 unsafe block,该lint的出现就是为来改变这种行为: 现在要求,unsafe函数中调用unsafe函数,必须再加一个 unsafe block。 该lint现在是 allow-by-default ,就是说,默认不会更改 当前行为,只有你用 #[deny(unsafe_op_in_unsafe_fn)] 或 #[warn(unsafe_op_in_unsafe_fn)] 才会允许更改当前行为。
  2. 允许可变数组引用直接转换为可变/不可变原生指针。
  3. 编译器后端升级到 LLVM12
  4. 现在所有的整数除法和余数操作都是 const fn 了
  5. 稳定了 str/slice/char 类型的多个 API ,并且部分 API 都是 const fn 都了
  6. Rustdoc markdown 现在支持 task list 了
  7. cargo test 支持传入多个文件

兼容性变更:

  1. RUSTC_BOOTSTRAP 已经在 build.rs 中被禁止设置
  2. 代码中要使用了 proc-macro-hack 可能会导致 panic,需要执行 cargo unpdate -p proc-macro-hack 解决此问题

https://blog.rust-lang.org/2021/05/06/Rust-1.52.0.html

Rust 1.52.1 发布

此版本主要解决增量编译中的一个错误,该错误在1.52.0中变成了编译器错误。

建议所有Rust用户,包括当前使用1.52.0之前的稳定版本的用户,升级到1.52.1或禁用增量编译。

目前只是一个临时的修复计划,目前此错误还未被完全修复,毕竟增量编译功能是刚需。主要是修复 verify-ich 相关 issues,目前还有很多 :https://github.com/rust-lang/rust/issues?q=is%3Aissue+is%3Aopen+unstable+fingerprints

目前官方正在积极修复此错误。 Rust团队还将制定计划,以确保在将来有更好的bug跟踪系统,既可以防止此类情况再次发生,也可以通过更准确地跟踪bug来进一步提高我们版本的稳定性。

https://blog.rust-lang.org/2021/05/10/Rust-1.52.1.html

官方宣布 Rust 2021 edition plan

https://blog.rust-lang.org/2021/05/11/edition-2021.html

之前翻译版本 :https://mp.weixin.qq.com/s/C36k7_ZEcgpesAYmqDdV-w

P.S 大家要不要听听 Rust Edition 之歌:

https://smallcultfollowing.com/babysteps/blog/2021/05/26/edition-the-song/

新的 RFC 被合并: const-ub

为了在 CTFE 的时候进行 UB-checking

https://github.com/rust-lang/rfcs/blob/master/text/3016-const-ub.md

rustc 从 1.46 到 1.51 性能提升不少

有人在reddit上发帖,说他在 OpenBSD 平台上将 Rust 从1.46更新到1.51,发现他的项目编译时间减少了30%。

https://www.reddit.com/r/rust/comments/n2lh7z/rustc_performance_improvement_from_rust_146_to_151/

社区热点

编辑:李冬杰


Rust 核心团队进展

早在去年8月,核心团队就写了一篇题为《为 Rust的未来奠定基础》的博客文章,从那时起,核心团队一直在做大量的工作来帮助打下基础,并为项目上因这些事件而发生的变化做好准备。这种工作主要集中在内部,而不是真正从外部可见的东西,即使您在 Rust 团队中也是如此。 由于这些努力,Rust 基金会现在已经存在,并开始致力于其使命,这也意味着核心团队是时候转移工作重点了。

除了基金会的努力之外,Rust核心团队还开展了一些您可能知道也可能不知道的举措。

The 2021 Roadmap

2021年 Roadmap RFC 于1月合并,这为今年的核心团队设定了目标,与往年有些不同,Rust为整个项目制定了目标,2021年决定将重点放在 Rust Core 上,并为团队设定自己的目标留出空间,而我们则专注于总体组织健康。

今年晚些时候,Rust也将启动明年的流程,目前还没有积极考虑这个问题,但理想情况下年度 Roadmap 将在12月合并,而不是在1月,因此团队希望能尽早开始,以便在 2022 年按时完成目标。

Team Charters

作为该工作的一部分,Rust项目已经开始了为每个团队提供正式章程的流程的第一步。 早在 RFC 1068 中,初始团队的范围就已经列出。 虽然多年来这对Rust很有帮助,但随着团队的组建、关闭和变化,Rust项目在明确每个团队的责任界限方面并不总是做得很好。 Rust 治理结构的神奇之处在于,每个团队都被赋予了重要的权力,可以按照他们认为合适的方式做事,但这也意味着Rust项目必须意识到范围。 随着该过程的继续展开将有更多关于此过程的报告,但最终目标已在路线图中说明:

Rust 团队将与核心团队合作,在一年中为每个 Rust 团队制定章程,目的是定义,特别是目的和成员要求。我们的目标是,到 2022 年,Rust 项目中的所有活跃团体都将拥有明确定义的章程和成员资格。

Audit of packages owned by the project

Rust核心团队一直在努力澄清Rust团队在crates.io拥有的包的状态,目前核心团队正在对这些程序包进行全面审核,以确保它们是项目应该拥有的东西,确保它们有适当的权限,并确保有人维护它们。

Read More

在安卓上运行 Rust

作者决定对目前一个客户的产品使用Rust,这个决定背后有两个原因,除了技术优点之外,还有一个无可争辩的事实,即 Rust 仍然是一种相对较新的语言,花哨和时髦。当你是一家初创公司时,使用前十年出现的任何技术只会让自己失败,如果不使用创新技术,公司如何进行创新呢?最快的成功方式是搭乘炒作列车。因此作者开始研究如何让Rust在安卓上运行起来,并提供了一个最小运行程序的模板。

Read More MVP template Repo

Rust for Windows v0.9

Rust for Windows v0.9最近已发布,其中包括全面的消费支持以及其他几个更新!有了完整的使用支持,您现在可以使用Rust语言来调用任何Windows API(过去,现在和将来)。 Rust开发人员可以以一种惯用的语言访问整个Windows API接口,从而使他们可以轻松利用 Windows 开发的强大功能和广度。

Read More

一个 Rust 的新 AWS SDK:alpha 发布

我们很兴奋地宣布 Rust 的新 AWS SDK 的 alpha 版本发布。在 Rust 中开发的 AWS 客户想要一个本地的 Rust SDK,这样他们就可以使用他们习惯的语言结构,而 Rust 的新客户想要一个与他们在其他语言环境中使用的 SDK 行为类似的 SDK。在这个 alpha 版本中,客户可以在客户端试用7种AWS服务,并提供可用性方面的反馈。

Read More

Rust 六周年 🎉

2021年5月15日是 Rust 六岁生日(从2015年 1.0 版本算起),在这过去的六年里发生了许多变化,但 Rust 项目没有什么不同,依然没有基金会,没有 Const 泛型,许多组织仍然怀疑 Rust 是否已经准备好投入生产环境。

这篇文章将会回顾一下过去一年中的一些重大改进,社区如何在生产中使用Rust,最后展望目前正在进行的一些改进工作,这些改进和改进了Rust在小型和小型企业中的使用。明年的大型项目,让我们开始用Rust吧!

Read More

SpaceX 开始使用 Rust 了

考虑到 Rust 的安全性、高性能、现代化的工具集,SpaceX 可以在嵌入式系统、模拟器、工具集、web开发使用统一的语言,SpaceX 已经在开发一些原型工具,这仅仅是这个长途旅行的开始!

space use Rust

Read More

【Rust 安全案例】 Rust 的安全并不是指能写出 0 Bug 代码

题外话: 之前看到知乎上有人说 Rust 2021 年光 CVE 就有近百个。那咱们今天就捡个热乎的 CVE 看看到底是咋回事。

2021-05-18 ,openSUSE 邮件列表里收到一份安全报告,主题如下:

[oss-security] please: CVE-2021-31153,CVE-2021-31154,CVE-2021-31155: local root exploit and further

please 是一个 Rust 实现的替代 sudo 的工具,该库作者向 SUSE 团队提出 setuid-root 的代码安全审核,然后就被发现存在很多安全问题,并且包含一个比较严重的本地 root 漏洞,允许执行命令。

报告摘录如下:

  • 可以进行任意文件存在测试,并且可以以root用户身份打开任意文件
  • 通过search_path()函数进行任意文件存在性测试
  • 通过-d开关进行任意文件存在性测试
  • 使用不可信的umask创建令牌目录“ / var / run / pleaser / token”
  • 允许通过pleaseedit编辑任何文件,允许任意文件覆盖和所有权更改

结论:

  • 哪怕是 Rust 这样的现代语言,要实现 setuid-root 二进制文件也是一个很大的挑战。
  • please库中几乎没有unsafe(只有几行 libc/nix调用),所以基本已经不存在内存安全类的漏洞了,但是像这种逻辑漏洞是无法避免的,除非你不写代码。
  • setuid 二进制文件是在非 root 用户 shell 程序中运行的 root 权限二进制文件

Read More Read More

Rust for Windows v0.9最近已发布

意味着你可以用 Rust 调用任意 Windows API

(目测一大波 Rust 实现的恶意软件即将来袭 。。。)

观点:对 Windows-rs 的 看法

来自:Tony Huang

仔细看了一下winrt的设计,还是很不错的,集微软技术大成。

Windows技术栈中很重要的一个技术是COM。它的作用就是定义了一个语言无关的abi,让不同的语言可以无压力的互相交互。

举个例子,以前我们用VB6的时候可以使用COM调用ActiveX控件,然后到了.Net,还可以在.Net中直接使用COM组件,甚至可以把自己包装成COM组件来提供服务。

然后,我们再来看Windows在应用程序接口方面,和Linux有什么本质的不同。

在Linux中,所有对内核的调用,最终都是syscall,这个实际上是在每个arch上,通过一个syscall的number做的绑定,还是比较简陋的。操作系统只能提供很有限的syscall,其它的要嘛通过第三方库,要嘛通过ioctl里面的魔幻处理来实现。

而Windows完全不同,它的操作系统API是通过动态链接库的形式提供的。典型的就是那3个dll:KERNEL32.dll, USER32.dll, 和 GDI32.dll。那么它具体怎么跟内核交互,或者如何与系统的其它服务协作为应用程序提供这些功能,就是由操作系统的发行版决定的。从用户程序的视角来说,我只是调用了一个函数。如果使用了新的API,但是跑在老的系统上,只是会动态链接错误,找不到指定的symbol就好了。甚至可以通过API动态的检测是否存在某个接口。

而WinRT的技术基础就是基于COM技术,把这个功能变得更加强大了。

首先所有的操作系统组件(甚至你自己的组件),都可以通过COM把接口暴露出来,提供一份winmd文件,描述了你提供的接口。

然后针对不同的语言,微软官方提供了一个工具,依据这个winmd文件自动生成binding。

比如C++/WinRT,就是提供了一个编译器,在编译你自己的程序之前,根据你的依赖和windows sdk中的winmd文件生成c++的header,给你调用。然后运行时再使用COM把具体的dll和接口注入进来。

而windows-rs(Rust for Windows)干的事情是一样的,但是它很巧妙的利用了cargo的 build.rs 机制,在编译时做这个binding的生成工作 (具体参考 readme: https://github.com/microsoft/windows-rs/blob/master/readme.md)。

所以对于微软来说,由于操作系统的abi是语言无关的,通过winmd(从com的idl编译而来)描述的。那么对于新的语言的支持,只需要2个事情:

  1. 针对legacy的win32 api做一下手动的binding
  2. 针对新的WinRT API写一个对应的binding生成器

理论上来说,只要你用的api范围够小,rust for windows是能支持rust编译器支持的最低版本的windows的。

用于Rust的新 AWS 开发工具包– Alpha 版发布

intellij-rust 提交了展开属性宏的 PR

https://github.com/intellij-rust/intellij-rust/pull/7194

1password 宣布登陆 Linux 平台

摘录:

  1. Linux 1Password的后端和底层逻辑是用Rust 编写的。 由于其强大的安全性,Rust已在企业中得到广泛采用,甚至被提议作为Linux内核的官方语言。
  2. Linux 1Password 的数据加密使用 ring库
  3. 用户界面使用 React+neon(Node.js 模块的 Rust 绑定)+Rust
  4. 1Password 公司为了回馈开源社区,赞助了 tokio 和 rust-analyzer,并且也开源了自己的一些项目。

https://blog.1password.com/welcoming-linux-to-the-1password-family/?utm_medium=social&utm_source=twitter&utm_campaign=linuxblog&utm_ref=social

Fuchsia OS 发布,reddit 网友讨论 Rust 等代码所占比例

  • Rust 22%,
  • C++ 18%,
  • C 4%,
  • Go 2%,
  • Python 1%,
  • Dart 1%

其余 52% 都是 json 相关的,不算os内主流语言。 单独算这几门语言,Rust 的占比接近 50% 了

https://www.reddit.com/r/rust/comments/nldg5c/fuchsia_os_partially_written_in_rust_has_shipped/

Rust binary search PR 导致 Polkadot 线上事故的缘由

知名公链波卡 (Polkadot) 5 月 24 号某个节点发生了一次比较大的线上事故(Out Of Memory),是因为 Rust 标准库 binary search 一个优化 PR 导致的。

该PR 跑了将近一周时间的 crater 测试,也没有发现对线上 5 万多个 crate 有什么大影响。Rust 1.52 发布之后,没想到依然有人中招了,而且还是价值几十亿美金的项目。

@brson 特意发了 issue 说到这件事,但是 Rust 社区的人都认为这件事跟这个 PR 没有关系,因为 binary search 碰到多个重复的元素的时候确实是返回任意一个,文档上也说得很清楚了(所以这种情况下不会保证两个版本返回的位置一致,这也是 Polkadot 出现线上事故的原因)。

该 PR 作者(Folyd)说 :

不管怎样,这件事给我的触动还是蛮大的。软件开发是复杂的,其本质原因在于现实生活就是复杂的。软件工程师只能尽可能规避发生这种情况的风险,但是没有办法做到万无一失吧。就像这位工程师说的我这是中了 Hyrum 定律(Google 一个叫 Hyrum 的工程师提的定律,可以理解为 API 领域的墨菲定律)。

Polkadot 的事故后复盘:https://polkadot.network/a-polkadot-postmortem-24-05-2021/

@brson 的 issue: https://github.com/rust-lang/rust/issues/85773

binary search优化 PR:https://github.com/rust-lang/rust/pull/74024

关于 PR 的文章:https://zhuanlan.zhihu.com/p/371460665

Hyrum 定律:https://www.hyrumslaw.com/

rustc_codegen_gcc: GCC-rs 的一个简单替代项目

GCC-rs 是 用 Cpp 重新实现 Rustc 的一个 GCC 前端。

为什么有 GCC-rs 这个项目?

  1. 想要支持更多的 CPU 架构
  2. 跨语言 LTO。GCC-RS FAQ将Linux列为激励示例。 具有讽刺意味的是,Linux支持ltvm但不是gcc!
  3. Rust 自举(Bootstrap)链很长,因为需要从C到OCAML,然后编译预发布 Rust 以编译 Rust 1.0编译 Rust 1.1 、1.2等,直到捕获最多1.53(或者最新版本)。 因此,如果您可以用C++中编写的 Rust 编译器直接编译1.53,则可以节省一些时间。
  4. 复用 GCC 插件

但 rustc_codegen_gcc 作者认为 GCC-rs 其实没有很好的解决这些问题。

rustc_codegen_gcc 项目只需将GCC插入现有的Rust编译器作为代码生成后端,就可以简单的达成这些目标。

该项目的主要目标是能够在LLVM不支持的平台上编译 Rust 代码。 次要目标是检查使用GCC后端是否提供任何编译速度改进。

当前 rustc_codegen_gcc 还是 WIP。

https://github.com/antoyo/rustc_codegen_gcc

https://github.com/Rust-GCC/gccrs

文章:https://shnatsel.medium.com/the-simpler-alternative-to-gcc-rs-90da2b3685d3

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

  • 线上:《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》
  • 线下: WebAssembly meetup 北京站

【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 24 期 - 大家一起来闲聊一下最近使用 Rust 的心得和遇到的坑

时间: 2021/05/11 20:30-21:30

主讲人:无

内容:大家一起来闲聊一下最近使用 Rust 的心得和遇到的坑

顺便也约了金明剑老师来谈谈 TensorBase 项目的最新的一些进展。

其它几个可能的话题:

  • 1.52 编译器的增量编译 bug。紧急修复版本 1.52.1

  • bevy 0.5 及其它 Rust 游戏框架等

  • Rust 在嵌入式领域的实践

查看回放

《Rust 唠嗑室》第 25 期 - 使用 Rust 快速开发基于 Raft 共识算法的分布式应用

时间: 2021/05/25 20:30-21:30

主讲人:PsiACE

内容:介绍如何基于流行的 raft-rs 开发分布式应用以及 raft-frp/RiteRaft 设计思路

  • 回顾 raft 共识算法的一些基本内容

  • 如何使用 raft-rs 开发分布式应用

  • 降低重复劳动, 160 行代码实现一个简单的分布式键值服务

  • 目前项目进展

查看回放

参考资料:

  1. https://www.cnblogs.com/yanglang/p/10141583.html
  2. https://www.jianshu.com/p/81fe3e4f51a5
  3. https://zzjw.cc/post/raft-build
  4. https://github.com/erikgrinaker/toydb/blob/master/docs/architecture.md
  5. https://github.com/MarinPostma/raft-frp
  6. https://github.com/canonical/raft
  7. https://github.com/blackredscarf/raftfwk
  8. https://github.com/tikv/raft-rs

🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第六期讨论主题:

  1. 领域项目:eclipse zenoh ,一个零开销的 Pub/sub, Store/Query 和 Compute 平台
  2. 语言特性:Rust 中的 高阶类型 GAT (Generic Associated Types)

参考资料:

  1. https://rustmagazine.github.io/rust_magazine_2021/chapter_4/zenoh.html
  2. https://github.com/eclipse-zenoh/zenoh
  3. https://github.com/autocore-ai/SDV

查看回放

第七期 讨论主题:

  1. 语言特性:实现 RFC : Allow a re-export for main
  2. 领域项目: makepad, cloud9 ide 作者新的项目,支持 VR 开发的基于 WebAssembly 的 IDE/ 框架

参考资料:

  1. https://github.com/rust-lang/rfcs/blob/master/text/1260-main-reexport.md
  2. https://github.com/rust-lang/rust/pull/84217
  3. https://github.com/rust-lang/rust/pull/84401
  4. https://github.com/rust-lang/rust/pull/84507

查看回放

第八期 讨论主题:

  1. 语言特性: 聊聊 Rust 泛型特化功能现状
  2. 领域项目: 介绍一下 WAGI 项目及其进展

参考资料:

  1. https://github.com/deislabs/wagi
  2. https://deislabs.io/posts/introducing-wagi-easiest-way-to-build-webassembly-microservices/
  3. https://github.com/deislabs/hello-wagi-grain
  4. https://radu-matei.com/blog/wagi-updates/
  5. https://github.com/engineerd/wasm-to-oci
  6. https://github.com/WebAssembly/WASI

查看回放

第九期 讨论主题:WebAssembly 专题课堂(上)

《 WebAssembly Actors: From Cloud to Edge》这套课程简洁精炼,虽然着墨不多,但是把 WebAssembly 及其在 Server Side 的应用关键概念都涉及,并且通过简单的示例展现清楚了。

参考资料:

  1. https://www.edx.org/course/webassembly-actors-from-cloud-to-edge

查看回放

【线下】WebAssembly meetup 20210515 北京站

活动 PPT : https://github.com/alabulei1/WebAssembly-meetups/tree/main/meetup-beijing-20210515

  1. 基于WebAssembly的人像分割技术在RTC领域的应用

分享人:声网 Agora-高纯

B 站回放:https://www.bilibili.com/video/BV1u54y1V79y

  1. 用 Serverless WebAssembly 解决 SaaS 平台的定制化难题

分享人:WasmEdge-Michael Yuan

B 站回放:https://www.bilibili.com/video/BV17v411L7Zg?from=search&seid=14369338767741516201

  1. 基于WebAssembly 的ServiceMesh

分享人:旷视-王续

B 站回放:https://www.bilibili.com/video/BV1yy4y1W7FE?from=search&seid=101009679390055518

  1. WebAssembly 在浏览器端的实践

分享人:金山软件-黄烈锦

B 站回放:https://www.bilibili.com/video/BV1f5411u7v7?from=search&seid=16899798704041782034

本月招聘

中国科学院软件研究所 PLCT Lab

BJ46 Rust 工具链开发实习生

主要就是做 Rust for RISC-V 的工作,这是 Firefox on RISC-V 的一部分。确保 Rust 工具链和软件在 RV64GC 平台和 RV32GC 上可以跑起来,并进行功能的完善和性能的提升。

入职要求: LV3+,能进行英语交流,对编译技术有很好的了解。对 LLVM 工具链和 Rust 有浓厚的兴趣。 远程实习,不需要坐班。

BJ33 Firefox/Spidermonkey 开发实习生(名额:2 名)

工作内容: 就像将 V8 移植到 RISC-V 平台一样,将 Spidermonkey 移植到 RISC-V 平台。跟 mentor 一起将工作提交到 Mozilla upstream。最好能明白 Rust 相关的编译流程。

入职要求: LV3+ 级别。能够进行英文的交流。 远程实习,不需要坐班。

更多岗位参见 这里

联系方式 有意者请投递简历至: 吴老师 wuwei2016@iscas.ac.cn 邮件标题请注明: 实习生 - 岗位编号 - 姓名 - 手机号码 - 学校


【量化方向】Junior/Senior Rust Algo Developer

应聘者学校要求 C9 或者同级别的海外学校。

About Injective Protocol

Injective is building a decentralized exchange that provides limitless access to all financial markets. Injective Protocol enables fully decentralized trading without any restrictions, allowing individuals to trade on any derivative market of their choosing. Injective Protocol is backed by a prominent group of stakeholders including Pantera Capital, Binance, Mark Cuban, Hashed, and many others.

We have offices in New York City and Hong Kong. Injective is valued at over $1 Billion and has been dubbed the "DeFi Robinhood" by the likes of TechCrunch.

**Job Description **

We can accept remote working, working language is Chinese and/or English) Algorithm developers are responsible for building and maintaining the models that drive our trading. A typical day involves applying rigorous statistical analysis to vast quantities of market and financial data to produce provably predictive trading models. Working in close collaboration with fellow algo developers and core developers, you will research, develop, and test novel order execution and model training methods to increase trading efficiency.

Required Background and Skills

Candidates must have at least a bachelor's degree from prestigious universities in CS, Physics, Math, and are capable of working independently as well as part of a team. Great experience with Rust. Previous exposure in trading strategy implementation in a production environment will be a plus. We provide working visa sponsorship in the US / HK for any qualified candidates. Also, we provide US green card sponsorship.

Free free send your resume to maxim.shen@injectiveprotocol.com

Rust 远程工作,并且有瑞典移民的机会

要求: Rust/ Graphql/ Typescript

The role can optionally be temporarily remote due to the ongoing COVID-19 situation, with the goal of you joining us in Gothenburg, Sweden. We can help with your move and immigration.

https://technocreatives.homerun.co/senior-rust-full-stack-developer/en

Rust Remote 工作挺多

jobs

不过有些公司限制在本土远程,有些公司限制必须在本土时间范围内工作,有些比较自由。

Rust 发布六周年

作者:The Rust Team / 译者:杨楚天


今天是 Rust 自 2015 年发布 1.0 以来的诞生日六周年。六年以来发生了许多事情,去年尤其如是,Rust 也不例外。2020 年伊始,我们还没有 Rust 基金会,没有常量泛型。许多组织依然对在生产环境中使用 Rust 保持观望态度。

在新冠病毒爆发时期,分散在世界各地的上百名 Rust 的组织成员和志愿者为我们带来了 9 个 Rust 稳定版本,以及若干个 bug 修复版本。如今,我们不会再质疑 Rust 是否适用于生产环境。新成立的 Rust 基金会包含许多成员,不仅重视在实际产品中使用 Rust,也在持续地为 Rust 开源生态做出支持与贡献。

现在,我们来总结一下去年发生的各种重大进展,回顾社区是如何在生产环境中使用 Rust 的,并展望 Rust 社区当下的工作,会如何在未来一年继续提升这门语言的使用体验。

近期工作

Rust 语言在去年取得了非常大的进展,带来了许多新的特性。这些功能谈不上给语言带来了质变,却也让我们能在更多领域方便地使用和维护 Rust。

  • Rust 1.52.0 升级到了 LLVM 12,许多和 forward progress 有关的安全问题(例如无限循环的处理)得到了解决。这是 Rust 团队和 LLVM 长期以来合作的结果,也是 Rust 可以为更广阔的编程语言生态带来进步的例证。

  • Rust 为更多生态系统提供了支持,包括 Tier 1 中对 64 位 ARM Linux 的支持,Tier 2 中对 ARM macOS 和 ARM Windows 的支持等。这使得我们可以在更多的架构中使用 Rust 构建自己的项目。

  • Rust 在编译期的表达能力也获得了极大的提升。基础类型作为常量泛型的特性进入了稳定版本,同时 const fn 中也能更宽松地使用控制流,过程宏也能被用于更多的情景当中,这些都让我们能构造出更强力的 API,写出更好的 crate。

除了 rustc 以外,以下工具也有巨大的改进:

  • Cargo 稳定化了最新的特性分解机制,让我们可以在不同的 target 中更方便地管理依赖。

  • Rustdoc 稳定化了 "文档内部链接" 的功能,使得文档里的文本能自动地链接对应的类型或函数。

  • Clippy 现在使用单独的缓存,不再与 Cargo 共享,使其行为更加稳定。

生产环境中的 Rust

Rust 每年都能以难以置信的速度获得社区与业界的接纳,去年也不例外。2020 年 Rust 再一次成为了 StackOverflow 年度最受喜爱的编程语言。感谢社区里所有人的支持,是你们才有 Rust 的今天。

随着基金会的成立,Rust 社区得以在一个更合适的位置上,构建出一个可持续的开源生态系统,帮助我们构建高效可靠的软件。许多公司成立了专门维护和促进 Rust 项目的小组,包括 AWSFacebook 和微软等。

不仅 Rust 自身正在进步,许多大公司也开始允许在自己的产品中使用 Rust,或提供 Rust API 的官方支持。

  • 微软和亚马逊分别发布了 WindowsAWS 的官方 Rust 库。官方的海量 API 支持让 Rust 用户更能在项目中开发出想要的功能。

  • 在 cURL 项目发布的新版本中,默认采用了由 Rust 实现的库,用于处理 HTTP/sTLS 通信。ISRG、Hyper & Rustls 团队以及 cURL 项目团队之间密切地跨社区合作,为 cURL 这个被广泛使用的工具打造出了一个内存安全的后端。

  • Tokio (一个 rust 的异步运行时)发布了 1.0 版本 ,并承诺了三年的稳定维护。这个项目可以为我们实现可靠且高效的网络应用提供了坚实的基础。

未来工作

当然,这一切仅仅是开始,我们可以看到现在很多组织在开展各种开创性的工作,尝试将 Rust 带入全新的领域。

Right now the Rust teams are planning and coordinating the 2021 edition of Rust. Much like this past year, a lot of themes of the changes are around improving quality of life. You can check out our recent post about "The Plan for the Rust 2021 Edition" to see what the changes the teams are planning.

目前 Rust 团队正在围绕新版本 Rust 2021 进行相关的计划与协调工作。和去年一样,我们很多的改动还是聚焦在改善开发体验上。大家可以看一下我们最近的一篇文章,讲述 Rust 团队当前的工作计划。

这些只是冰山一角,当今的 Rust 社区,每时每刻在发生新的变化,每天都有激动人心的项目诞生。我们迫不及待地想看到新的一年,大家又用 Rust 打造出了什么样的创作。


过去一年 Rust 的那项改进最让你感到振奋?哪个项目最让你激动?是刚准备尝试使用 Rust 么?还是想参与 2021 版本的工作?欢迎大家来到我们的 Discourse 论坛和 Zulip 聊天室,随意介绍自己,然后加入我们的讨论吧。这里有安全友好的环境,无论你的性别取向身体状况宗教信仰如何,我们都欢迎你的到来。

造就 Rust 的无名英雄

作者:Brian / 译者&&编辑:张汉东

原文: https://brson.github.io/2021/05/02/rusts-most-unrecognized-contributor

Brian Anderson 之前是 Mozilla Research 的资深研究工程师,Rust 核心团队成员。从事 Rust 编程语言方面的工作 6 年,维护运行时和标准库,构建验证语言及其子系统的基础设施。Servo 浏览器的主要作者之一。Rust 以稳定著称,而 Brian 的使命就是让 Rust 成为最可靠的平台。于 2018 年加入了 PingCAP。


我认为 Rust 语言目前取得了巨大的成功。当我回想这一切发生的过往时,敬畏之心油然而生。因为必须做很多正确的事才能达到现在这个状态,在整个过程中,其实犯错的机会太多了。Rust 语言靠着一个又一个微小的奇迹,逐渐变成了它应该成为的样子。这些奇迹并不是偶然发生的,而是靠每一个真实的人创造的。这些真实的人,通过精心地策划来早就伟大。

有很多人为 Rust 的成长做出了贡献。这其中有一些对 Rust 的成功保驾护航的人,却几乎不被人所知。

开端:Mozilla 研究所

到2009年,Mozilla 通过与 Google 合作的搜索交易中积累了可观的资金储备。以我的理解,管理层认为是时候投资这笔钱了,公司进入了一个快速扩张的时期。

作为扩展的一部分,Mozilla 创建了一个新部门 Mozilla 研究所(Mozilla Research)。该部门的使命是,独立于 Firefox 产品之外,去尝试一些目标远大的想法,同时与计算机科学学术界建立联系。

Mozilla 研究所的第一个重要构想就是 Servo。然后就是 Rust 。

戴夫·赫尔曼(Dave Herman)是当时被任命领导该部门的人之一。

戴夫·赫尔曼(Dave Herman)何许人也?

戴夫·赫尔曼 是一名编程语言理论学者,也是一名宏专家(macrologist,超级喜欢宏的人),并且是 Mozilla 在ECMAScript 委员会上的代表之一。

他 和 创建 Rust 的工程师 格雷顿·霍尔( Graydon Hoare) 曾经在 ECMAScript 4 标准(已废弃)上合作过。

他俩都对创建编程语言有强烈的爱好。

除了 Servo,Rust 和 戴夫·赫尔曼之外,Mozilla 研究所的工作还有很多,但这在我们的故事之外。

我们要讲的是,戴夫·赫尔曼(Dave Herman)如何默默塑造 Rust 项目成果的故事。

戴夫对 Rust 的贡献

Rust 于 2010 年 6 月发布,但实际上它在 2009 年末就开始在 Mozilla 内部开发。关于这段 Rust 开发过往的唯一公开记录是在 graydon/rust-prehistory 仓库中。

Rust 从开发之初到 2010 年 6 月呈现给公众,只经历了短短六个月的冲刺。而 戴夫 就是攻克这项任务的成员之一。


#![allow(unused)]
fn main() {
~/rust-prehistory $ git shortlog -sn
  1156  Graydon Hoare
   163  Andreas Gal
   104  Dave Herman
    59  graydon@pobox.com
    55  Patrick Walton
    37  Graydon Hoare ext:(%22)
    13  Roy Frostig
     9  graydon@mozilla.com
     6  Brendan Eich
     5  Michael Bebenita
     1  Brian Campbell
}

此后,他将编码工作交给了 格雷顿 领导的成长中的 Rust 团队。但在接下来的这几年中,戴夫一直在办公室里。

那是,大多数为 Rust 工作的人都在同一间办公室。而 Rust 另一个重要人物 格雷顿 则是远程。戴夫 和 少数几名全职员工,以及一些轮换的实习生会定期聚集在Mozilla Mountain View 总部的某个小会议室的一张桌子旁。

我想象得到,他就像一个导师一样,依赖自己在 ECMAScript 上的经验以及基于自己对语言设计的兴趣,推动着团队到富有成效的方向前进。他从来没有以任何方式行使自己的权威,直到今天,也没有在 Rust 上为自己争取任何声望。

他几乎完全在幕后做出了自己的贡献,默默地发挥着他的影响力。

在那个会议室里发生的很多早期的争论,都是一些基础性争论。这些争论今天看过去似乎已经无关紧要了,诸如 “从函数返回的关键字是什么?” ,或,“如何安全地持有结构体字段的指针” 等琐碎事情。这些问题在今天来看显得太过遥远且无关紧要,但是,正因为他们这样的争论,让 Rust 语言不断地反复塑造和重塑,直到所有小问题都被解决成一个一致的整体。

戴夫 是会议室中少数几个具有真正的生产编程语言设计经验的人之一,如果没有他带领团队克服琐碎的语言设计障碍,Rust 肯定会一团糟。戴夫 的品味塑造了团队的品味,从而塑造了 Rust 语言。并且,大多数情况下,戴夫 对团队的决定感到满意。

尽管 戴夫 在所有早期的 Rust 设计主题中都有发言权,但是有一些主题深深捕获着他对 Rust 语言的兴趣,如果你完全了解 Rust,我猜你会熟悉这些主题:

  • 教学法(Pedagogy)。即,教学的方法。

    戴夫 具有学术背景,并且始终根据如何教授和理解设计的每一个决定来考虑。

  • 治理(Governance)。即,社区管理。

    Rust 从一开始就被设计为社区项目,我认为这在很大程度上受到 戴夫在语言标准机构工作经验的影响。 一直都存在这种设想,最成功的语言不是被谁拥有的,而是由一大批个人和公司合作设计的,所有这些都是各自兴趣和动机的叠加。

    因此,Rust 开发时所依据的流程在当时总是要尽可能地做到包容。 举一个例子,Rust最早的许多发展都记录在公开的会议记录中。 我几乎完全将这归因于 戴夫 的自律,并且要知道其中许多记录都是由他个人录制的。

    戴夫 最重要的 Rust 遗产可能就是培养一种语言社区。

  • 宏(Macro)

    如前所说,戴夫 是一名宏专家。他在宏(Macro)方面颇有造诣,并且有 Racket 语言背景。

    尽管主要由实习生(特别是 Paul Stansifer)实现,并得到 John Clements 的一些关键贡献,但由于 戴夫 的缘故,Rust 拥有强大而卫生的声明宏(macro_rules!)。

    尽管这并不是我所涉及的领域,但我记得他和保罗(Paul)花了很多时间讨论如何将传统 Lisp 式的卫生宏系统设计为Rust所在的类 C 的世界。

戴夫 参与的其他关键决策包括:

  • 将 Rust 语言从 语句式(statement)语言 转换为 表达式(expression)语言。
  • 聘请 Niko 设计 Rust 所有权系统。
  • 雇佣 Yehuda Katz 设计 Cargo。

除了这些明显的贡献之外,戴夫 在 Rust 中还扮演者另一个关键角色:在管理层极力拥护 Rust 。

在造就 Rust 的所有奇迹中,最伟大的也许是 Mozilla 的诸多付出。在 Rust 存在于 Mozilla 的整个时期(译注:现在已经移交 Rust 基金会),团队内部明显有一种该项目随时可能被取消的感觉。坚守 Rust 团队的 Brendan Eich离开Mozilla之后,尤其如此。这就是为什么必须围绕该语言建立一个强大的社区的原因之一。

戴夫 是公司内职位最高的 Rust 信徒。而且,他竭尽所能提倡 Rust 对公司使命的重要性,并且满足 Rust 的人员配备和资源。的确,我不清楚 戴夫 在管理角色上所要面对的一切,但这绝对是关键:他做到了让团队专注于 Rust 。

无论如何, Rust 总是人手不足。我记得当时对此很生气:我们如何在如此少的全职工程师的情况下与 Google 和 Apple 竞争?这个问题的答案,一半肯定是培养投资多元化的贡献者社区,但这是一个缓慢且不确定的过程。而这个问题答案的另一半要归功于 戴夫:实习生。Rust 通常拥有比全职员工更多的实习生,并且它们都是由 戴夫 雇佣的,戴夫 可以凭借他在学术界的诚信轻松招募 PL 人才。

一个值得赞赏的事实:Rust 主要由学生创建,其中许多人在 Mozilla 实习。

关于 Rust 设计的轶事

我想我应该说一些 戴夫 对 Rust 设计贡献的一些轶事,而第一个想到的就是他不同意团队的那些决定。这不是一个关于 戴夫 所作贡献的很好的案例,但也许仍然值得分享,并且是他如何在指导团队的同时也信任他们决定的一个例子。

至今仍难以记住确切的细节,但我印象最深的一幕:当我们引入了可变变量和不可变变量绑定之间的区别时,戴夫坚决不同意团队所做的决定。我们只是在决定要为每种语法使用什么语法,这很简单。 争论结束时,对于不可变和可变绑定分别有两个明显的选择:

  • letlet mut
  • letvar

第一个是我们目前已经采用的语法,第二个是直接从 JavaScript 中借鉴的,两者都有充分的理由。 当时的主要问题是,从 “较难” 还是 “较丑” 中选择一个更加合适的语法。语言设计师 强迫 程序员 键入两个关键字来创建可变绑定,潜移默化地去影响程序员,使得他们对引入可变性去做更多思考。

我记得 戴夫 不同意团队在这一方面的决定。 回想起来,我认为他不同意「严格限制用户选择编码模式」的原则,但我不确定。 我也仍然认为团队对此是正确的:额外的 mut注释不仅增加了一些额外的工作以增加变量的可变性,而且mut注释自然地扩展到 Rust 类型系统的其他区域,例如&mut引用。 而且,可变性的识别和管理已成为该语言的本质特征。

为成功奠定基础

我猜 戴夫 直接参与了Rust的设计于 2014 或 2015 年结束,Rust 社区中的大多数人甚至都没有意识到他的存在。

戴夫 没有创造出 Rust。

他仅向公共 Rust 仓库贡献了六次提交。他只在邮件列表上讲话了四次。

他所做的就是创建一个他认为可以为世界带来独特愿景的团队,并在该团队中巧妙地植入了一套价值观,这些价值观将使 Rust 能够超越 Mozilla 的边界,超越任何单独个体的参与和个性。

事实正是如此。

Rust 的成功有很多原因,其中有成千上万的贡献者,成千上万的微小奇迹汇聚成一个连贯的整体。

但是,微小的奇迹不会偶然变成大奇迹。

漫谈编程语言的设计和实现

作者:柴杰


编程语言教育

编程语言虽然复杂,但是其设计是遵循着一些基本规则的。不同语言的语法语义虽然有所不同,但一些基本的核是相似的(例如基于表达式扩展的语法和对基本数据类型的抽象)。编程语言可以顺着这些核一点点展开,根据语言的设计初衷添加规则(即类型系统)和语言特性(例如是否支持多线程、异步编程)。这些规则特性与编程语言之核的组合,造就了琳琅满目的编程语言。

然而,绝大多数国内编程课侧重于某一门具体语言的语法讲解,不解释语言特性与编程语言的本质关系。学生对编程语言普遍缺乏一种抓得住的“全局观”和举一反三的能力,看到的都是被“糖化”后的语法和复杂的语用环境,所以感觉编程语言学起来复杂、枯燥。如此一来,以下情形便容易解释了,例如:

  • 你经常对编译器和解释器报出的错误毫无头绪;

  • 无法从技术层面判断一门编程语言设计的好坏,人云亦云;

  • 对开发任务应该使用什么样的编程语言,或者应该使用哪些语言特性,无法做出独立的判断;

  • 无法理解为什么一些framework或library的用法是这样的,对于背后的原理一无所知,只能靠着例子和pattern来编程,遇到问题却难以找出原因,只能google或去问别人;

  • 不能对问题简洁的抽象与建模,难以写出简洁、高效、可维护的代码。

理想的编程语言课程应该是语言特性与编程语言理论的融合。因为理论需要实践来解释how,实践需要理论来支撑why,两者相互促进,方能做到知其然知其所以然。如果不把握编程语言的本质,就好比学人体素描不识人体骨骼,学中医不讲阴阳,学音乐不知乐理。如此一来,知识的理解受限且无法融会贯通,不可深究亦不能长远[1]。

编程语言素养是每个人编程知识树的主干(这种结构性很强的知识体系是很难通过经验自我摸索的),每一次的编程经历都是对编程语言抽象表达能力的一次运用和锻炼。日积月累,你会更容易理解新的编程语言和软件框架,建模与抽象的能力越来越好,写出的代码自然简洁、高效、可维护,进而能够构建出复杂的软件系统。

2021年5月18日,冯新宇老师在浙江大学做了讲座:漫谈编程语言的设计和实现[2]。冯新宇老师的讲座向大家介绍了编程语言的设计和实现中一些关键因素以及它们之间的联系,刚好弥补了一些当前编程语言教育的不足。下文是我的讲座笔记,希望对大家学习和理解编程语言(尤其是Rust)有所助益。

(长文预警,下文图片均选自讲座slides~)

编程语言设计和实现:关注什么

关注什么

img

编程语言是人机接口,指令集是软硬件接口,编译器将编程语言翻译成适用于特定指令集的指令序列。

img

在软件开发活动中,哪些需要程序员说,哪些不用说就可以实现。即编程语言的处理分为静态和动态两个阶段。静态阶段包括 parsing 和type-checking,以确保程序是well-formed;动态阶段是该well-formed程序的执行过程。如果一个well-formed的程序在执行时是well-behaved的,那么就说这个语言是安全的[3]。抽象语义和类型系统是编程语言的核心。

编程语言设计和实现的过程,就是确定两者边界的过程。边界如何确定,答案并不唯一。例如对于释放堆空间,在C语言中,需要在代码中显示的表达出来;在Java中,堆空间的释放不需要在代码中显示的表达出来,Java有垃圾回收机制,堆空间会在运行时自动回收。

img

开发态是针对软件开发者而言的,开发者希望编程语言能简单、高效、正确的表达计算任务。运行态是针对计算机而言的,希望用尽量少的时间和硬件资源完成计算任务。编程语言面对的是多样化的应用场景、运行环境和开发者群体,众口难调,并不存在一统天下的语言。

img

在编程语言设计的设计和实现中,易用性、安全性和性能三者难以兼顾,常常是改善了其中一个指标,同时也恶化另一个指标。所以需要根据应用场景做平衡和折衷。

img

JavaScript、Python、Lua属于动态脚本语言,易用性较高,但性能和可维护性差。适用于代码量小,Run过即抛的小型项目。Java、Go 、Dart 、C# 、Swift 、Kotlin 在安全性、易用性和性能三个方面表现比较均衡,适用于大中型的应用软件开发。C和C++为了追求性能,舍弃了安全性,产生了很多陷阱,造成debug和维护困难。Rust 是目前很受欢迎的一门系统编程语言,它在安全性和性能上做的很好,但上手门槛较高,对使用者的素质有一定要求。

易用性

img

不标注类型的代码更易写。但带有类型标注的代码更易读、更易维护,因为类型标注提供了额外信息,比如fun f(g: int -> int, x: int): int, 从类型标注可知f的第一个参数g是个函数,第二个输入参数x是个整数,函数返回一个整数。

img

不同的开发者群体对易用性的诉求是不一样的。应用开发人员希望语言特性精简,这样简单易学还容易维护,不要搞那么多稀奇古怪的语法特性,很难掌握,平时做应用开发也用不到。而库、框架和DSL的开发者,希望语言易复用、易扩展,希望编程语言具有泛型、运算符重载、宏等特性。

img

上述代码自定义了运算符+*-,这对DSL的使用者或许非常有用。但普通开发者却非常讨厌这种自定义运算符,因为它严重降低了程序的可读性。

易用性 vs. 安全性

img

易用性和安全性需要折衷,为了提升安全性,对开发者设置更多约束。这种约束主要体现在两个方面。第一个方面,在语法上禁止某些容易出错的语言特性。比如,Dijkstra 认为go to语句是有害的,所以现代编程语言就没有实现这个语言特性了。第二个方面,通过类型系统禁止可能出错的写法。这些约束虽然提升了安全性,但也降低了语言的易用性,编写代码时总感觉束手束脚。总之,安全性和易用性也是矛盾的,需要折衷和平衡。

img

根据是否允许自由类型转换,类型系统可以分为弱类型系统和强类型系统。JavaScript就是典型的弱类型系统,允许自由的类型转换,所以会出现各种各样、五花八门的坑。

img

C/C++也是弱类型系统,带来很多坑:悬空指针、double free、下标越界、缓冲区溢出等等。Rust是强类型的,它通过静态类型系统增强和动态检查来保证安全性,但 Rust 学习门槛较高。由于弱类型难以保证安全性,近年来新设计的编程语言已经很少采用弱类型系统了。

根据何时检查约束规则,类型系统又有静态和动态之分。如果全部或几乎全部的类型检查是在编译期进行的,那么就称为静态类型系统。如果全部或几乎全部的类型检查是在运行时进行的,那么就称为动态类型系统。两者各有优劣,有时候只有在运行时才能确定程序的执行流,所以在运行时做类型检查会更准确,但运行时类型检查会降低程序性能。如果采用静态类型检查,它的原则是宁杀错,不放过,有时会产生一些误判。

img

动态和静态类型各有优缺点,我们能否兼得两者的优点呢?Gradual type的目标正是兼顾两者优点,也是目前学术界的热门研究方向。

易用性 vs. 性能

img

在易用性和性能之间也需要权衡。虽然动态派遣的易用性高,但性能不如静态派遣。

img

语言特性越接近底层、越具体,开发者对细节会更有掌控,有利于性能的提高。而抽象会隐藏细节,易用性好,但性能往往不好。最近Intel推出了oneAPI,声称可以对程序员抽象掉具体硬件细节(CPU 、GPU、 AI加速器与FPGA),同时又不损失性能。至于真实效果如何,还有待验证。

安全性 vs. 性能

img

采用复杂的静态类型检查,虽然可以提升安全性,但也增加了学习和开发成本。所以会把一些安全机制(动态类型检查、垃圾回收等)放在运行时来做,但这样做会损失一些性能。这种动态保证安全性的机制集中体现在非系统编程语言中,如Java、Golang等。

总结

由于国内编程语言教育的缺陷,学生对编程语言普遍缺乏一种抓得住的“全局观”和举一反三的能力。编程语言是软件开发的基本工具,它一方面直接影响开发效率和开发体验,另一方面也影响软件运行时的表现,如性能和可靠性等。编程语言的设计和实现需要考虑三个关键因素:易用性、安全性和性能。三者难以兼顾,需要平衡和折衷。

参考资料

[1] 浅谈国内高效编程语言教育

[2] 漫谈编程语言的设计与实现, 冯新宇

[3] Robert Harper, Practical Foundations for Programming Languages, Second Edition, Cambridge University Press, 2016.

华为 | StratoVirt VCPU管理-Rust线程同步的实现

作者: 高炜 / 后期编辑:张汉东


StratoVirt是开源在openEuler社区的轻量级虚拟化平台,具备轻量低噪、强安全性的行业竞争力。

StratoVirt进程运行在用户态,在虚拟机启动之前,StratoVirt会完成启动之前的准备工作,包括虚拟机内存的初始化、CPU寄存器初始化、设备初始化等,启动,CPU寄存器初始化和虚拟机在运行过程中VCPU陷出事件的处理,都是由StratoVirt的VCPU管理模块CPU完成。如下是StratoVirt中VCPU管理模块的组成,以及其在StratoVirt中的位置。

stratovirt
├── address_space
├── boot_loader
├── Cargo.lock
├── Cargo.toml
├── cpu
│   ├── Cargo.toml
│   └── src
│       ├── aarch64
│       │   └── mod.rs
│       ├── lib.rs
│       └── x86_64
│           ├── cpuid.rs
│           └── mod.rs
├── devices
├── machine_manager
├── micro_vm
├── src
│   └── main.rs
├── sysbus
├── util
└── virtio

StratoVirt VCPU模块的整体设计

StratoVirt的虚拟化解决方案也是一套软硬结合的硬件辅助虚拟化解决方案,它的运作依赖于硬件辅助虚拟化的能力(如VT-X或Kunpeng-V)。VCPU模块的实现也是紧密依赖于这一套硬件辅助虚拟化的解决方案的。

对于物理机的CPU而言,硬件辅助虚拟化为CPU增加了一种新的模式:Non-Root模式,在该模式下,CPU执行的并不是物理机的指令,而是虚拟机的指令。这种指令执行方式消除了大部分性能开销,非常高效。但是特权指令(如I/O指令)不能通过这种方式执行,还是会强制将CPU退出到普通模式(即ROOT模式)下交给内核KVM模块和用户态StratoVirt去处理,处理完再重新回到Non-Root模式下执行下一条指令。

而StratoVirt中的VCPU模块主要围绕着KVM模块中对VCPU的模拟来实现,为了支持KVM模块中对CPU的模拟,CPU子系统主要负责处理退出到普通模式的事件,以及根据在GuestOS内核开始运行前对VCPU寄存器等虚拟硬件状态的初始化。整个VCPU模块的设计模型如下图所示:

cpu

StratoVirt通过第三方库kvm_ioctls来完成和KVM模块的交互,通过匹配vcpu_fd.run()函数的返回值来处理退出到ROOT模式的事件,该函数的返回值是一个名为VcpuExit的枚举,定义了退出到ROOT模式的事件类型,包括I/O的下发、系统关机事件、系统异常事件等,根据事件的类型VCPU将对不同的事件作出各自的处理。以上的整个过程都被包含在一个独立的VCPU线程中,用户可以自己通过对VCPU线程进行绑核等方式让虚拟机的VCPU获取物理机CPU近似百分之百的性能。

同时,对VCPU寄存器虚拟硬件状态信息的初始化则是和StratoVirt的另一个模块BootLoader相互结合,在BootLoader中实现了一种根据Linux启动协议快速引导启动Linux内核镜像的方法,在这套启动流程中,BootLoader将主动完成传统BIOS对一些硬件信息的获取,将对应的硬件表保存在虚拟机内存中,同时将提供一定的寄存器设置信息,这些寄存器设置信息将传输给VCPU模块,通过设置VCPU结构中的寄存器值,让虚拟机CPU跳过实模式直接进入保护模式运行,这样Linux内核就能直接从保护模式的入口开始运行,这种方式让StratoVirt的启动流程变得轻量快速。

在整个VCPU模块中,因为涉及到内核的KVM模块,少不了与C语言代码做交互。作为系统编程语言,Rust对FFI有非常完善的支持,让VCPU中和KVM模块交互的部分高效且安全。

VCPU线程模型同步

VCPU模块还有一大职责就是管理VCPU的生命周期,包括new(创建),realize(使能),run(运行),pause(暂停),resume(恢复),destroy(销毁)。New和realize的过程就是结构体创建和寄存器初始化的流程,run的过程即是实现KVM中VCPU运作和VCPU_EXIT退出事件处理的流程。

另外的三种生命周期的实现则涉及到对线程同步的精密控制,例如在虚拟机destroy的过程中,一般只有某一个VCPU接收到VCPU_EXIT中的SHUTDOWN事件,该VCPU线程需要把该事件传递到所有的VCPU线程,同步所有VCPU线程的状态,完成虚拟机的优雅关机。在这种场景下,我们就需要考虑在Rust中如何实现在多线程中进行状态同步。

Rust中通过条件变量来实现同步

Rust多线程编程中,有一类用于同步的机制叫做屏障(Barrier),用于让多线程来同步一些流程开始的位置,它相当于一个闸口,使用wait方法,将该线程放进临界区并阻塞住,只有每个Barrier都到达wait方法调用的点,闸口才会打开,所有的线程同步往下运行。

而在比较复杂的同步场景中,Rust还提供了另一个同步机制条件变量(Condition Variable)来支持更复杂的同步场景,它和屏障的功能类似,但是它并不阻塞全部进程,而是在满足指定的条件之前阻塞某个得到互斥锁的进程。也就是说,通过条件变量,我们可以在达到某种条件之前阻塞某个线程,这个特性可以让我们很好得对线程进行同步。

为了支持各种场景的同步控制,条件变量还提供了三个方法:

  • notify_one(): 用来通知一次阻塞线程,如果有复数个线程被阻塞住,notify_one会被一个阻塞的线程所消耗,不会传递到别的阻塞线程去。
  • notify_all(): 用来通知所有的阻塞线程。
  • wait_timeout(): 将当前线程置入临界区阻塞住并等待通知,可以设定一个timeout来设置阻塞的最大时间,以免造成永久的阻塞导致程序卡死。

需要注意的一点是条件变量需要和锁一起使用,而在程序运行中,每个条件变量每次只能和一个互斥体(被Mutex等锁包裹都可称为互斥体)进行使用。

VCPU生命周期控制和线程同步

CPU数据结构初始化时,创建一个互斥的生命周期枚举(CpuLifecycleState)和一个条件变量。


#![allow(unused)]
fn main() {
pub fn new(
        vcpu_fd: Arc<VcpuFd>,
        id: u8,
        arch_cpu: Arc<Mutex<ArchCPU>>,
        vm: Arc<Mutex<dyn MachineInterface + Send + Sync>>,
    ) -> Self {
        CPU {
            id,
            fd: vcpu_fd,
            arch_cpu,
            state: Arc::new((Mutex::new(CpuLifecycleState::Created), Condvar::new())),
            work_queue: Arc::new((Mutex::new(0), Condvar::new())),
            task: Arc::new(Mutex::new(None)),
            tid: Arc::new(Mutex::new(None)),
            vm: Arc::downgrade(&vm),
        }
    }
}

以destory生命周期为例,在x86_64架构下,当某个VCPU线程接收到VcpuExit::Shutdown事件后,会将该线程的CpuLifecycleState修改为Stopped,并调用保存在CPU数据结构中一个指向上层结构的虚拟机destroy方法,该方法能遍历一个保存着所有CPU数据结构的数组,执行数组中每一个CPUdestory()方法,该函数的实现如下:


#![allow(unused)]
fn main() {
fn destory(&self) -> Result<()> {
    let (cpu_state, cvar) = &*self.state;
    if *cpu_state.lock().unwrap() == CpuLifecycleState::Running {
        *cpu_state.lock().unwrap() = CpuLifecycleState::Stopping;
    } else {
        *cpu_state.lock().unwrap() = CpuLifecycleState::Stopped;
    }
    
    /* 省略具体的关机逻辑 */
    
    let mut cpu_state = cpu_state.lock().unwrap();
    cpu_state = cvar
            .wait_timeout(cpu_state, Duration::from_millis(32))
            .unwrap()
            .0;

    if *cpu_state == CpuLifecycleState::Stopped {
        *cpu_state = CpuLifecycleState::Nothing;
        Ok(())
    } else {
        Err(ErrorKind::DestroyVcpu(format!("VCPU still in {:?} state", *cpu_state)).into())
    }
}
}

作为CPU的成员方法,destory函数能获取到每个CPU数据结构的互斥状态和条件变量,此时将除触发VCPU外所有的CPU数据的互斥状态解锁,并将状态从运行时的Running修改为VCPU关机时的Stopping。这里要注意一点,此时所有CPUdestroy函数都是在触发关机事件的VCPU进程中进行的,而不是在每个VCPU各自的进程中进行。

紧接着进入Stopping状态后,destroy函数会执行每个VCPU各自的关机逻辑,包括触发VCPU,这部分主要还是与KVM模块进行交互,进行一些退出状态的变更等。在执行完VCPU的关机逻辑后,条件变量会进入到wait_timeout的等待状态,它的参数为每个VCPU的CpuLifecycleState生命周期状态枚举和等待超时时间,也就是说在该生命周期枚举状态变化前,该线程都会进入阻塞状态。

此时除触发VCPU外的VCPU线程中,CpuLifecycleState都已经进入了Stopping状态,在所有VCPU线程中,VCPU的指令模拟函数kvm_vcpu_exec()都运行在一个循环中,对于每次循环的入口,都会执行ready_for_running()函数进入是否继续模拟的判断,在该函数中会对每个VCPU对应的CpuLifecycleState进行监控,当发现CpuLifecycleState已经变成Stopping时,VCPU将会退出循环,不继续进行VCPU的模拟,退出模拟的循环后,将会修改CpuLifecycleStateStopped:


#![allow(unused)]
fn main() {
// The vcpu thread is about to exit, marking the state of the CPU state as Stopped.
let (cpu_state, _) = &*self.thread_cpu.state;
*cpu_state.lock().unwrap() = CpuLifecycleState::Stopped;
}

修改VCPU线程中互斥的生命周期状态枚举后,将会触发阻塞线程中对应的wait_timeout()函数,同时,该VCPU线程的生命周期结束。而对于阻塞线程,当其余VCPU线程的状态都已经变成Stopped后,阻塞解除,此时,所有的VCPU线程都已经状态都已经同步到了Stopped,线程状态同步成功。

用类似思路也可以实现pause(暂停)和resume(恢复)的生命周期控制。

关注我们

StratoVirt当前已经在openEuler社区(openEuler是一个开源、免费的Linux发行版平台,将通过开放的社区形式与全球的开发者共同构建一个开放、多元和架构包容的软件生态体系)开源。在未来的一段时间我们将开展一系列主题的分享,让大家更加详细的了解StratoVirt实现,非常期待您的围观和加入!

项目地址:https://gitee.com/openeuler/stratovirt

项目wiki:https://gitee.com/openeuler/stratovirt/wikis

项目交流:virt邮件列表或是提交一个issue

蚂蚁集团 | 如何在生产环境排查 Rust 内存占用过高问题

作者:ShiKaiWi / 后期编辑: 张汉东


背景

内存安全的 Rust,虽然基本不会出现内存泄漏,但如何合理分配内存,是每个复杂应用都要面临的问题。往往随着业务的不同,相同的代码可能会产生不同的内存占用,因此,有不小的概率会出现内存使用过多、内存逐渐增长不释放的问题。

在本文中,我想分享一下,我们在实践过程中,遇到的关于内存占用过高的问题,对于这些内存问题,在本文中,会做出简单的分类以及我们在生产环境下进行排查定位的方法。

内存分配器

首先在生产环境,我们往往不会选择默认的内存分配器(malloc),而是会选择 jemalloc,可以提供更好的多核性能以及更好的避免内存碎片(详细原因可以参考[1]),而在 Rust 的生态中,对于 jemalloc 的封装有很多优秀的库,这里我们就不纠结于哪一个库更好,我们更关心如何使用 jemalloc 提供的分析能力,帮助我们诊断内存问题。

阅读 jemalloc 的使用文档,可以知道其提供了基于采样方式的内存 profile 能力,而且可以通过 mallctl 可以设置 prof.activeprof.dump 这两个选项,来达到动态控制内存 profile 的开关和输出内存 profile 信息的效果。

内存快速增长直至 oom

这样的情况一般是相同的代码在面对不同的业务场景时会出现,因为某种特定的输入(往往是大量的数据)引起程序的内存快速增长。

不过有了上面提到的 memory profiling 功能,快速的内存增长其实一个非常容易解决的情况,因为我们可以在快速增长的过程中打开 profile 开关,一段时间后,输出 profile 结果,通过相应的工具进行可视化,就可以清楚地了解到哪些函数调用进行了哪些结构的内存分配。

不过这里分为两种情况:可以复现以及难以复现,对于两种情况的处理手段是不一样的,下面对于这两种情况分别给出可操作的方案。

可以复现

可以复现的场景其实是最容易的解决的问题,因为我们可以在复现期间采用动态打开 profile,在短时间内的获得大量的内存分配信息即可。

下面给出一个完整的 demo,展示一下在 Rust 应用中如何进行动态的内存 profile。

本文章,我会采用 jemalloc-sys jemallocator jemalloc-ctl 这三个 Rust 库来进行内存的 profile,这三个库的功能主要是:

  • jemalloc-sys: 封装 jemalloc。
  • jemallocator: 实现了 Rust 的 GlobalAlloc,用来替换默认的内存分配器。
  • jemalloc-ctl: 提供了对于 mallctl 的封装,可以用来进行 tuning、动态配置分配器的配置、以及获取分配器的统计信息等。

下面是 demo 工程的依赖:

[dependencies]
jemallocator = "0.3.2"
jemalloc-ctl = "0.3.2"

[dependencies.jemalloc-sys]
version = "0.3.2"
features = ["stats", "profiling", "unprefixed_malloc_on_supported_platforms"]

[profile.release]
debug = true

其中比较关键的是 jemalloc-sys 的几个 features 需要打开,否则后续的 profile 会遇到失败的情况,另外需要强调的是 demo 的运行环境是在 Linux 环境下运行的。

然后 demo 的 src/main.rs 的代码如下:

use jemallocator;
use jemalloc_ctl::{AsName, Access};
use std::collections::HashMap;

#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;

const PROF_ACTIVE: &'static [u8] = b"prof.active\0";
const PROF_DUMP: &'static [u8] = b"prof.dump\0";
const PROFILE_OUTPUT: &'static [u8] = b"profile.out\0";

fn set_prof_active(active: bool) {
    let name = PROF_ACTIVE.name();
    name.write(active).expect("Should succeed to set prof");
}

fn dump_profile() {
    let name = PROF_DUMP.name();
    name.write(PROFILE_OUTPUT).expect("Should succeed to dump profile")
}

fn main() {
    set_prof_active(true);

    let mut buffers: Vec<HashMap<i32, i32>> = Vec::new();
    for _ in 0..100 {
		buffers.push(HashMap::with_capacity(1024));
    }

    set_prof_active(false);
    dump_profile();
}

demo 已经是非常简化的测试用例了,主要做如下的说明:

  • set_prof_activedump_profile 都是通过 jemalloc-ctl 来调用 jemalloc 提供的 mallctl 函数,通过给相应的 key 设置 value 即可,比如这里就是给 prof.active 设置布尔值,给 profile.dump 设置 dump 的文件路径。

编译完成之后,直接运行程序是不行的,需要设置好环境变量(开启内存 profile 功能):

export MALLOC_CONF=prof:true

然后再运行程序,就会输出一份 memory profile 文件,demo 中文件名字已经写死 —— profile.out,这个是一份文本文件,不利于直接观察(没有直观的 symbol)。

通过 jeprof 等工具,可以直接将其转化成可视化的图形:

jeprof --show_bytes --pdf <path_to_binary> ./profile.out > ./profile.pdf

这样就可以将其可视化,从下图中,我们可以清晰地看到所有的内存来源:

mem-profile

这个 demo 的整体流程就完成了,距离应用到生产的话,只差一些 trivial 的工作,下面是我们在生产的实践:

  • 将其封装成 http 服务,可以通过 curl 命令直接触发,将结果通过 http response 返回。
  • 支持设置 profile 时长。
  • 处理并发触发 profile 的情况。

说到这里,这个方案其实有一个好处一直没有提到,就是它的动态性,因为开启内存 profile 功能,势必是会对性能产生一定的影响(虽然这里开启的影响并不是特别大),我们自然是希望在没有问题的时候,避免开启这个 profile 功能,因此这个动态开关还是非常实用的。

难以复现

事实上,可以稳定复现的问题都不是问题,生产上,最麻烦的问题是难以复现的问题,难以复现的问题就像是一个定时炸弹,复现条件很苛刻导致难以精准定位问题,但是问题又会冷不丁地出现,很是让人头疼。

一般对于难以复现的问题,主要的思路是提前准备好保留现场,在问题发生的时候,虽然服务出了问题,但是我们保存了出问题的现场,比如这里的内存占用过多的问题,有一个很不错的思路就是:在 oom 的时候,产生 coredump。

不过我们在生产的实践并没有采用 coredump 这个方法,主要原因是生产环境的服务器节点内存往往较大,产生的 coredump 也非常大,光是产生 coredump 就需要花费不少时间,会影响立刻重启的速度,此外分析、传输、存储都不太方便。

这里介绍一下我们在生产环境下采用的方案,实际上也是非常简单的方法,通过 jemalloc 提供的功能,可以很简单的进行间接性地输出内存 profile 结果。

在启动使用了 jemalloc 的、准备长期运行的程序,使用环境变量设置 jemalloc 参数:

export MALLOC_CONF=prof:true,lg_prof_interval:30

这里的参数增加了一个 lg_prof_interval:30,其含义是内存每增加 1GB(2^30,可以根据需要修改,这里只是一个例子),就输出一份内存 profile,这样随着时间的推移,如果发生了内存的突然增长(超过设置的阈值),那么相应的 profile 一定会产生,那么我们就可以在发生问题的时候,根据文件的创建日期,定位到出问题的时刻,内存究竟发生了什么样的分配。

内存缓慢增长不释放

不同于内存的急速增长,内存整体的使用处于一个稳定的状态,但是随着时间的推移,内存又在稳定地、缓慢的增长,通过上面所说的方法,很难发现内存究竟在哪里使用了。

这个问题也是我们在生产碰到的非常棘手的问题之一,相较于此前的剧烈变化,我们不再关心发生了哪些分配事件,我们更关心的是当前的内存分布情况,但是在没有 GC 的 Rust 中,观察当前程序的内存分布情况,并不是一件很简单的事情(尤其是在不影响生产运行的情况下)。

针对这个情况,我们在生产环境中的实践是这样的:

  • 手动释放部分结构(往往是缓存)内存
  • 然后观察前后的内存变化(释放了多少内存),确定各个模块的内存大小

而借助 jemalloc 的统计功能,可以获取到当前的内存使用量,我们完全可以重复进行 释放制定模块的内存+计算释放大小,来确定内存的分布情况。

这个方案的缺陷也是很明显的,就是参与内存占用检测的模块是先验的(你无法发现你认知以外的内存占用模块),不过这个缺陷还是可以接受的,因为一个程序中可能的占用内存过大的地方,我们往往都是知道的。

下面给出一个 demo 工程,可以根据这个 demo 工程,应用到生产。

下面是 demo 工程的依赖:

[dependencies]
jemallocator = "0.3.2"
jemalloc-ctl = "0.3.2"

[dependencies.jemalloc-sys]
version = "0.3.2"
features = ["stats", "profiling", "unprefixed_malloc_on_supported_platforms"]

[profile.release]
debug = true

demo 的 src/main.rs 的代码:

use jemallocator;
use jemalloc_ctl::{epoch, stats};

#[global_allocator]
static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc;

fn alloc_cache() -> Vec<i8> {
    let mut v = Vec::with_capacity(1024 * 1024);
    v.push(0i8);
    v
}

fn main() {
    let cache_0 = alloc_cache();
    let cache_1 = alloc_cache();

    let e = epoch::mib().unwrap();
    let allocated_stats = stats::allocated::mib().unwrap();
    let mut heap_size = allocated_stats.read().unwrap();

    drop(cache_0);
    e.advance().unwrap();
    let new_heap_size = allocated_stats.read().unwrap();
    println!("cache_0 size:{}B", heap_size - new_heap_size);
    heap_size = new_heap_size;

    drop(cache_1);
    e.advance().unwrap();
    let new_heap_size = allocated_stats.read().unwrap();
    println!("cache_1 size:{}B", heap_size - new_heap_size);
    heap_size = new_heap_size;

    println!("current heap size:{}B", heap_size);
}

比起上一个 demo 长了一点,但是思路非常简单,只要简单说明一下 jemalloc-ctl 的一个使用注意点即可,在获取新的统计信息之前,必须先调用一下 epoch.advance()

下面是我的编译后运行的输出信息:

cache_0 size:1048576B
cache_1 size:1038336B
current heap size:80488B

这里可以发现,cache_1 的 size 并不是严格的 1MB,这个可以说是正常的,一般来说(不针对这个 demo)主要有两个原因:

  • 在进行内存统计的时候,还有其他的内存变化在发生。
  • jemalloc 提供的 stats 数据不一定是完全准确的,因为他为了更好的多核性能,不可能使用全局的统计,因此实际上是为了性能,放弃了统计信息的一致性。

不过这个信息的不精确,并不会给定位内存占用过高的问题带来阻碍,因为释放的内存往往是巨大的,微小的扰动并不会影响到最终的结果。

另外,其实还有更简单的方案,就是通过释放缓存,直接观察机器的内存变化,不过需要知道的是内存不一定是立即还给 OS 的,而且靠眼睛观察也比较累,更好的方案还是将这样的内存分布检查功能集成到自己的 Rust 应用之中。

其他通用方案

metrics

另外还有一个非常有效、我们一直都在使用的方案,就是在产生大量内存分配的时候,将分配的内存大小记录成指标,供后续采集、观察。

整体的方案如下:

  • 使用 Prometheus Client 记录分配的内存(应用层统计)。
  • 暴露出 metrics 接口。
  • 配置 Promethues server,进行 metrics 拉取。
  • 配置 Grafana,连接 Prometheus server,进行可视化展示。

内存排查工具

在内存占用过高的排查过程中,也尝试过其他的强大工具,比如 heaptrack、valgrind 等工具,但是这些工具有一个巨大的弊端,就是会带来非常大的 overhead,一般来说,使用这类工具的话,基本上应用程序是不可能在生产运行的。

也正因如此,在生产的环境下,我们很少使用这类工具排查内存的问题。

总结

虽然 Rust 已经帮我们避免掉了内存泄漏的问题,但是内存占用过高的问题,我想不少在生产长期运行的程序还是会有非常大的概率出现的。本文主要分享了我们在生产环境中遇到的几种内存占用过高的问题场景,以及目前我们在不影响生产正常服务的情况下,一些常用的、快速定位问题的排查方案,希望能给大家带来一些启发和帮助。

当然可以肯定的是,还有其他我们没有遇到过的内存问题,也还有更好的、更方便的方案去做内存问题的定位和排查,希望知道的同学可以一起多多交流。

参考

[1] Experimental Study of Memory Allocation forHigh-Performance Query Processing

[2] jemalloc 使用文档

[3] jemallocator

关于我们

我们是蚂蚁智能监控技术中台的时序存储团队,我们正在使用 Rust 构建高性能、低成本并具备实时分析能力的新一代时序数据库,欢迎加入或者推荐,请联系:jiachun.fjc@antgroup.com

Datenlord | Rust实现RDMA异步编程(一):基于epoll实现RDMA 异步操作

作者:王璞 / 后期编辑: 张汉东


RDMA是一套高性能网络协议栈,多用于高性能计算、高性能存储领域。RDMA的library是用C实现的,但是没有很好用的Rust的binding,不方便Rust开发者使用。于是我们正在封装一层符合Rust风格、便于Rust开发者使用的RDMA Rust binding。特别的,异步编程是近几年很受关注的编程方式,用Rust异步编程来实现IO操作,可以避免操作系统的进程上下文切换,提高性能,而且Rust的异步编程框架也在逐步成熟和完善。本系列文章探讨下如何用异步的方式实现RDMA的操作。本文先讨论下如何基于Linux的epoll机制实现RDMA异步操作,后续的文章再探讨如何用Rust异步编程来实现RDMA异步操作。

RDMA操作简介

RDMA的编程模型是基于消息的方式来实现网络传输,并且用队列来管理待发送的消息和接收到的消息。RDMA的网络传输相关操作基本上都是跟队列相关的操作:比如把要发送的消息放入发送队列,消息发送完成后在完成队列里放一个发送完成消息,以供用户程序查询消息发送状态;再比如接收队列里收到消息,也要在完成队列里放个接收完成消息,以供用户程序查询有新消息要处理。

RDMA QP

由上面的描述可以看出RDMA的队列分为几种:发送队列Send Queue (SQ),接收队列Receive Queue(RQ),和完成队列Completion Queue (CQ)。其中SQ和RQ统称工作队列Work Queue (WQ),也称为Queue Pair (QP)。此外,RDMA提供了两个接口,ibv_post_sendibv_post_recv,由用户程序调用,分别用于发送消息和接收消息:

  • 用户程序调用ibv_post_send把发送请求Send Request (SR)插入SQ,成为发送队列的一个新的元素Send Queue Element (SQE);
  • 用户程序调用ibv_post_recv把接收请求Receive Request (RR)插入RQ,成为接收队列的一个新元素Receive Queue Element (RQE)。

SQE和RQE也统称工作队列元素Work Queue Element (WQE)。

当SQ里有消息发送完成,或RQ有接收到新消息,RDMA会在CQ里放入一个新的完成队列元素Completion Queue Element (CQE),用以通知用户程序。用户程序有两种同步的方式来查询CQ:

  • 用户程序调用ibv_cq_poll来轮询CQ,一旦有新的CQE就可以及时得到通知,但是这种轮询方式很消耗CPU资源;
  • 用户程序在创建CQ的时候,指定一个完成事件通道ibv_comp_channel,然后调用ibv_get_cq_event接口等待该完成事件通道来通知有新的CQE,如果没有新的CQE,则调用ibv_get_cq_event时发生阻塞,这种方法比轮询要节省CPU资源,但是阻塞会降低程序性能。

关于RDMA的CQE,有个需要注意的地方:对于RDMA的Send和Receive这种双边操作,发送端在发送完成后能收到CQE,接收端在接收完成后也能收到CQE;对于RDMA的Read和Write这种单边操作,比如节点A从节点B读数据,或节点A往节点B写数据,只有发起Read和Write操作的一端,即节点A在操作结束后能收到CQE,另一端节点B完全不会感知到节点A发起的Read或Write操作,节点B也不会收到CQE。

Linux epoll异步机制简介

Linux的epoll机制是Linux提供的异步编程机制。epoll专门用于处理有大量IO操作请求的场景,检查哪些IO操作就绪,使得用户程序不必阻塞在未就绪IO操作上,而只处理就绪IO操作。epoll比Linux之前的selectpoll这两种异步机制要强大,epoll特别适合有大量IO操作的场景,比如RDMA的场景,每个RDMA节点同时有很多队列,用于大量传输数据,那么就可以用epoll来查询每个CQ,及时获得RDMA消息的发送和接收情况,同时避免同步方式查询CQ的缺点,要么用户程序消耗大量CPU资源,要么用户程序被阻塞。

Linux的epoll机制提供了三个API接口:

  • epoll_create用于创建epoll实例,返回epoll实例的句柄;
  • epoll_ctl用于给epoll实例增加、修改、删除待检查的IO操作事件;
  • epoll_wait用于检查每个通过epoll_ctl注册到epoll实例的IO操作,看每个IO操作是否就绪/有期望的事件发生。

具体的epoll这三个接口的使用,后面结合代码示例来讲解。这里先解释下epoll的IO事件检查规则。如下图所示,epoll有两种检查规则:边沿触发Edge Trigger (ET),和电平触发Level Trigger (LT)。边沿触发和电平触发源自信号处理领域。边沿触发指信号一发生变化就触发事件,比如从0变到1就触发事件、或者从1到0就触发事件;电平触发指只要信号的状态处于特定状态就触发事件,比如高电平就一直触发事件,而低电平不触发事件。

Edge Trigger v.s. Level Trigger

对应到epoll,电平触发指的是,只要IO操作处于特定的状态,就会一直通知用户程序。比如当socket有数据可读时,用户程序调用epoll_wait查询到该socket有收到数据,只要用户程序没有把该socket上次收到的数据读完,每次调用epoll_wait都会通知用户程序该socket有数据可读;即当socket处于有数据可读的状态,就会一直通知用户程序。而epoll的边沿触发指的是epoll只会在IO操作的特定事件发生后通知一次。比如socket有收到数据,用户程序epoll_wait查询到该socket有数据可读,不管用户程序有没有读完该socket这次收到的数据,用户程序下次调用epoll_wait都不会再通知该socket有数据可读,除非这个socket再次收到了新的数据;即仅当socket每次收到新数据才通知用户程序,并不关心socket当前是否有数据可读。

RDMA完成队列CQ读取CQE的同步和异步方法

本节用RDMA读取CQ的操作为例展示如何基于epoll实现异步操作。先介绍下RDMA用轮询和阻塞的方式读取CQ,再介绍基于epoll的异步读取CQ的方法。下文的代码仅作为示例,并不能编译通过。

RDMA轮询方式读取CQE

RDMA轮询方式读取CQ非常简单,就是不停调用ibv_poll_cq来读取CQ里的CQE。这种方式能够最快获得新的CQE,直接用户程序轮询CQ,而且也不需要内核参与,但是缺点也很明显,用户程序轮询消耗大量CPU资源。

loop {
    // 尝试读取一个CQE
    poll_result = ibv_poll_cq(cq, 1, &mut cqe);
    if poll_result != 0 {
        // 处理CQE
    }
}

RDMA完成事件通道方式读取CQE

RDMA用完成事件通道读取CQE的方式如下:

  • 用户程序通过调用ibv_create_comp_channel创建完成事件通道;
  • 接着在调用ibv_create_cq创建CQ时关联该完成事件通道;
  • 再通过调用ibv_req_notify_cq来告诉CQ当有新的CQE产生时从完成事件通道来通知用户程序;
  • 然后通过调用ibv_get_cq_event查询该完成事件通道,没有新的CQE时阻塞,有新的CQE时返回;
  • 接下来用户程序从ibv_get_cq_event返回之后,还要再调用ibv_poll_cq从CQ里读取新的CQE,此时调用ibv_poll_cq一次就好,不需要轮询。

RDMA用完成事件通道读取CQE的代码示例如下:

// 创建完成事件通道
let completion_event_channel = ibv_create_comp_channel(...);
// 创建完成队列,并关联完成事件通道
let cq = ibv_create_cq(completion_event_channel, ...);

loop {
    // 设置CQ从完成事件通道来通知下一个新CQE的产生
    ibv_req_notify_cq(cq, ...);
    // 通过完成事件通道查询CQ,有新的CQE就返回,没有新的CQE则阻塞
    ibv_get_cq_event(completion_event_channel, &mut cq, ...);
    // 读取一个CQE
    poll_result = ibv_poll_cq(cq, 1, &mut cqe);
    if poll_result != 0 {
        // 处理CQE
    }
    // 确认一个CQE
    ibv_ack_cq_events(cq, 1);
}

用RDMA完成事件通道的方式来读取CQE,本质是RDMA通过内核来通知用户程序CQ里有新的CQE。事件队列是通过一个设备文件,/dev/infiniband/uverbs0(如果有多个RDMA网卡,则每个网卡对应一个设备文件,序号从0开始递增),来让内核通过该设备文件通知用户程序有事件发生。用户程序调用ibv_create_comp_channel创建完成事件通道,其实就是打开上述设备文件;用户程序调用ibv_get_cq_event查询该完成事件通道,其实就是读取打开的设备文件。但是这个设备文件只用于做事件通知,通知用户程序有新的CQE可读,但并不能通过该设备文件读取CQE,用户程序还要是调用ibv_poll_cq来从CQ读取CQE。

用完成事件通道的方式来读取CQE,比轮询的方法要节省CPU资源,但是调用ibv_get_cq_event读取完成事件通道会发生阻塞,进而影响用户程序性能。

基于epoll异步读取CQE

上面提到用RDMA完成事件通道的方式来读取CQE,本质是用户程序通过事件队列打开/dev/infiniband/uverbs0设备文件,并读取内核产生的关于新CQE的事件通知。从完成事件通道ibv_comp_channel的定义可以看出,里面包含了一个Linux文件描述符,指向打开的设备文件:

pub struct ibv_comp_channel {
    ...
    pub fd: RawFd,
    ...
}

于是可以借助epoll机制来检查该设备文件是否有新的事件产生,避免用户程序调用ibv_get_cq_event读取完成事件通道时(即读取该设备文件时)发生阻塞。

首先,用fcntl来修改完成事件通道里设备文件描述符的IO方式为非阻塞:

// 创建完成事件通道
let completion_event_channel = ibv_create_comp_channel(...);
// 创建完成队列,并关联完成事件通道
let cq = ibv_create_cq(completion_event_channel, ...);
// 获取设备文件描述符当前打开方式
let flags =
    libc::fcntl((*completion_event_channel).fd, libc::F_GETFL);
// 为设备文件描述符增加非阻塞IO方式
libc::fcntl(
    (*completion_event_channel).fd,
    libc::F_SETFL,
    flags | libc::O_NONBLOCK
);

接着,创建epoll实例,并把要检查的事件注册给epoll实例:

use nix::sys::epoll;

// 创建epoll实例
let epoll_fd = epoll::epoll_create()?;
// 完成事件通道里的设备文件描述符
let channel_dev_fd = (*completion_event_channel).fd;
// 创建epoll事件实例,并关联设备文件描述符,
// 当该设备文件有新数据可读时,用边沿触发的方式通知用户程序
let mut epoll_ev = epoll::EpollEvent::new(
    epoll::EpollFlags::EPOLLIN | epoll::EpollFlags::EPOLLET,
    channel_dev_fd
);
// 把创建好的epoll事件实例,注册到之前创建的epoll实例
epoll::epoll_ctl(
    epoll_fd,
    epoll::EpollOp::EpollCtlAdd,
    channel_dev_fd,
    &mut epoll_ev,
)

上面代码有两个注意的地方:

  • EPOLLIN指的是要检查设备文件是否有新数据/事件可读;
  • EPOLLET指的是epoll用边沿触发的方式来通知。

然后,循环调用epoll_wait检查设备文件是否有新数据可读,有新数据可读说明有新的CQE产生,再调用ibv_poll_cq来读取CQE:

let timeout_ms = 10;
// 创建用于epoll_wait检查的事件列表
let mut event_list = [epoll_ev];
loop {
    // 设置CQ从完成事件通道来通知下一个新CQE的产生
    ibv_req_notify_cq(cq, ...);
    // 调用epoll_wait检查是否有期望的事件发生
    let nfds = epoll::epoll_wait(epoll_fd, &mut event_list, timeout_ms)?;
    // 有期望的事件发生
    if nfds > 0 {
        // 通过完成事件通道查询CQ,有新的CQE就返回,没有新的CQE则阻塞
        ibv_get_cq_event(completion_event_channel, &mut cq, ...);
        // 循环读取CQE,直到CQ读空
        loop {
            // 读取一个CQE
            poll_result = ibv_poll_cq(cq, 1, &mut cqe);
            if poll_result != 0 {
                // 处理CQE
                ...
                // 确认一个CQE
                ibv_ack_cq_events(cq, 1);
            } else {
                break;
            }
        }
    }
}

上面代码有个要注意的地方,因为epoll是用边沿触发,所以每次有新CQE产生时,都要调用ibv_poll_cq把CQ队列读空。考虑如下场景,同时有多个新的CQE产生,但是epoll边沿触发只通知一次,如果用户程序收到通知后没有读空CQ,那epoll也不会再产生新的通知,除非再有新的CQE产生,epoll才会再次通知用户程序。

总之,本文用epoll机制实现RDMA异步读取CQE的例子,展示了如何实现RDMA的异步操作。RDMA还有类似的操作,都可以基于epoll机制实现异步操作。

对Rust和RDMA感兴趣的朋友,可以关注我们的开源项目https://github.com/datenlord/async-rdma/

Facebook 应用 Rust 简史

编辑整理:张汉东

原文: https://engineering.fb.com/2021/04/29/developer-tools/rust/


Facebook正在拥抱Rust,这是当今最受喜爱和增长最快的编程语言之一。 除了为其Rust团队带来新的人才外,Facebook还宣布将正式加入非营利组织Rust Foundation。 与Mozilla(Rust的创建者),AWS,Microsoft和Google、华为 等成员一样,Facebook将致力于维持和发展该语言的开源生态系统。

如今,Facebook上有数百名开发人员编写了数百万行Rust代码。 尽管很明显,Facebook在这种语言的未来上投入了越来越多的资金,但重要的是要了解 Facebook 如何发展到这一点。

2016–2017: Rust 最早应用于 源码版本控制系统。

Facebook的 Source Control团队启动了一个名为Mononoke的重写项目,目的是将Mercurial的提交率再提高几个数量级,以服务于Facebook的成千上万的开发人员和自动化流程。首先,用C ++开发Mononoke是显而易见的选择。 当时,Facebook的后端代码库非常C ++,这意味着Mononoke默认情况下将用C ++实现。 但是源控制团队需要考虑源控制后端的可靠性需求。 当损坏或停机可能导致服务中断时,可靠性是重中之重。 这就是为什么团队选择在C ++上 使用Rust的原因。

Facebook 在 RustConf 2019 上描述了相关体验

效果很好,Mononoke自2019年以来一直是monorepo的生产后端,多年来成功扩展了规模。

2017–2019: 各个项目陆续采用 Rust

随着Mononoke作为其可行性和实践的证明,随着时间的推移,其他项目也考虑并采用了Rust。 首先,这些通常是开发人员工具项目,不需要与更广泛的服务基础架构集成,或者是小型服务/守护程序,可以仅使用一些围绕C ++客户端库的手写包装来完成其工作。

Facebook上许多采用Rust的工程师都来自Python和Javascript背景。 他们赞赏Rust的高性能与编译时错误检测的结合。 随着公司内部流传着更多的成功案例(例如将性能提高2到4个数量级),人们越来越有兴趣将Rust用于后端服务代码,并探索其在移动应用程序中的使用。

2019–2020: 专门支持 Rust

从2017年到2019年,Source Control团队的人数增加了一倍,成为Facebook内非官方的Rust支持团队。但是到2019年,Facebook的Rust开发人员数量呈指数增长,超过了100个。

作为这种增长的一个重要例子,Rust是Diem(以前称为Libra)区块链开发中的领先语言,由独立的Diem协会监督。 Facebook通过其数字钱包Novi,成为Diem协会的会员。 Diem区块链主要是用Rust编写的,覆盖了94%的开源代码库。 此外,与Rust一起开发了Move,一种用于区块链的新型安全编程语言。

鉴于需求的增长,源代码控制团队的兼职帮助不足以支持有能力从中受益的团队数量。

我们创建了一个小的Rust开发人员经验小组,专门致力于工具和集成方面的挑战,例如建立在非Cargo生产版本中使用该语言的开源软件包注册中心生态系统的机制。 该团队为整个公司的Rust开发人员建立了一个中心联系点,优先考虑短期开发人员体验问题,改进核心库,并在新的Rust代码库通过百万行大关时为其成功奠定了基础。

未来: 2021 年及 以后

在2020年底,在fb内部编程语言组织中成立了一个Rust团队来重新履行承诺,该团队由负责Facebook的C ++标准工作和工具链的同一组织负责。

在我们的代码库中,该团队的重点是长期的工作,而不仅仅是开发人员的即时优先经验。 尤其是,为使Rust成为一种健壮,生产力的语言和社区而发展和维持的所有贡献现在都包括在内。

从短期来看,这个新团队专注于四个领域:

  • 从语言和工具链的角度为内部用户提供支持:这包括工具链的推出,跨领域迁移,代码审查/审核,最佳实践,并充当语言和工具链问题的联系点。
  • 在Facebook以外的社区中做出积极贡献:该团队对标准库和编译器进行代码审查,并为Rust社区的优先事项提供开发人员资源。
  • Rust与C++的轻松,安全的互操作性:我们有大量的C ++代码,用于与构建服务的后端系统进行通信。 我们需要开发人员能够安全,轻松地使用这些库,而又不牺牲Rust提供的好处。 相反,如果我们想将Rust组件与更大的C ++二进制文件集成在一起,则需要异步代码中的智能运行时互操作性。 Facebook的服务器分布高度且线程密集。 Rust任务需要在C ++线程池上很好地发挥作用,并安全地共享同步原语和I / O资源。 我们已经在C ++领域完成了大量工作,以改善异步性,已经支持并迅速采用了C ++ 20的协程。 将Rust引入游戏将是对它的扩展,并将基于Rust异步库堆栈中已经发生的出色工作。
  • 积极支持和与Rust基金会互动:自2016年以来,Facebook一直致力于Rust社区并通过Rust扩展其发展。我们通过以Rust白金会员身份加入Rust Foundation来进一步履行这一承诺,我们希望在此方面继续努力 以积极主动和有影响力的方式推动进步和采用。

Rust在Facebook上的旅程还远远没有结束。 这个团队很小,但是随着支持需求的扩展,它会随着时间的增长而增长。 FB 对Rust在Facebook以及整个行业中的发展轨迹感到兴奋和乐观。

Rust 中的 gRPC 负载均衡

译者: iamazy


原文

在我们的开源系列的第一篇文章中,我们分享了 gRPC 客户端侧负载均衡的解决方案:使用 ginepro。

我们希望对广大的开发者社区做出回馈。我们开源系列的每篇文章都将带你了解 TrueLayer 工程团队面临的挑战 - 我们在 OSS 许可下发布了解决这些问题的代码。

在本文中,我们将会解析我们是如何处理 Rust 中 gRPC 负载均衡的挑战的。

我们的解决方案是ginepro,一个崭新的用于tonic的 gRPC channel 实现。ginepro(github)通过带有周期性 (periodic) 服务发现的丰富的tonic通道来提供客户端侧 gRPC 负载均衡。

背景

TrueLayer 最近开始适配 Rust 作为后台开发语言。随着我们越来越多的服务部署到生产中,我们必须不断改进我们的应用程序,以便它们能够处理更多负载以及实现所需的可靠性 SLA。

gRPC 请求的负载均衡一直是一个挑战:我们不使用 Service Mesh,并且 Rust 生态系统中也没有任何 gRPC 客户端可以满足我们的所有要求。

为了弥补这个问题,我们构建了ginepro - 一个tonic channel 的插件,它提供了服务发现功能以便执行客户端侧的 gRPC 负载均衡。


#![allow(unused)]
fn main() {
// Using the `LoadBalancedChannel`.
use ginepro::LoadBalancedChannel;
use ginepro::pb::tester_client::TesterClient;

// Build a load-balanced channel given a service name and a port.
let load_balanced_channel = LoadBalancedChannel::builder(
    ("my_hostname", 5000)
  )
  .await
  .expect("Failed to initialise the DNS resolver.")
  .channel();

// Initialise a new gRPC client for the `Test` service
// using the load-balanced channel as transport
let grpc_client = TestClient::new(load_balanced_channel);
}

LoadBalanceChanneltonicChannel的替代品:


#![allow(unused)]
fn main() {
// Using `tonic::Channel`
use tonic::endpoint::Channel;
use ginepro::pb::tester_client::TesterClient;

let channel = Channel::from_static("my_hostname:5000");
let grpc_client = TestClient::new(channel);
}

问题

gRPC 使用 HTTP/2 协议在单个 TCP 连接中实现请求与响应的多路复用。

这使得 gRPC 更加高效:你只需付出建立一次连接的成本并且可以更好的利用底层传输的能力。

但是,多路复用在负载均衡方面会有一些影响。

HTTP/2 负载均衡

HTTP/2 连接是持久的:客户端(或负载均衡器)和特定服务器之间的直连应尽可能长时间保持打开状态。

当我们要请求时,不会重新与服务端建立一个新的连接。

在这里我们可以说负载均衡是基于每个请求完成的:对于每个请求,客户端将选择一个新服务器并通过现有连接发出该请求。

但是如果客户端移除负载均衡会发生什么呢?

客户端将保持与负载均衡器的连接,所有的请求都将通过这单个连接。然而,传统的网络负载均衡器无法区分应用请求。

由于网络负载均衡器在 OSI 栈的第四层(传输层)起作用,因此它们只能推断 TCP 和 UDP 连接。因此,它们只能将流量从一台客户端转发到一台固定的服务器(记住,连接是持久的)。

load-balancing-in-rust-2

为了解决这个问题,HTTP/2 负载均衡器必须能够检查应用程序的流量。这就是应用程序负载均衡器的用途:它们可以区分请求,并为每个传入请求选择一个新的服务器。

负载倾斜

让我们看一个场景,未能执行 HTTP/2-aware 负载均衡会导致服务器使用不均匀 (unevenly)。

让我们对一个玩具系统进行思考:一个客户端和两个服务器,在 HTTP/2 之上讨论 gRPC。

load-balancing-in-rust-3

当客户端启动时,它执行服务发现(如 DNS 请求):我可以将请求发往哪些 IP?

答案是包含服务器 A 和服务器 B 的集合。与它们中的每一个都建立了 HTTP/2 连接。

每当客户端需要执行请求时,它会在两个连接之间随机选择 (round-robin)。

当服务器 A 崩溃时会发生什么?

在大多数 gRPC 实现中,客户端不会再次执行服务发现。

客户端开始将其所有请求路由到服务器 B,即使生成服务器 C 来替换服务器 A。

load-balancing-in-rust-4

服务器 C 完全未使用,浪费资源并恶化系统的性能 (服务器 B 过载)。

gRPC 负载均衡方法概述

我们可以采取几种方法来避免我们刚刚描述的场景:

  1. 服务器周期性强制客户端重连
  2. 客户端周期性执行服务发现
  3. 引入应用级负载均衡

所有这些选项有什么共同点?

某些内容会周期性或在重连时刷新可用服务器列表。

前两个选项相当简单,所以让我们深入研究第三个选项,看看有哪些类型的 gRPC 应用级负载均衡器。

本文中,我们将 gRPC 负载均衡器分为两大类:客户端侧和外部进程。

客户端侧

所有的负载均衡逻辑都存在客户端侧:服务器选择,服务发现,健康检查(可选)。

因此,客户端维护 N 个连接,每个服务器一个连接,并且为每个请求选择一个连接发送请求。

所有的逻辑都在客户端:它更容易测试,并且你可以消除与通过网络或外部线程通信的负载均衡器相关的任何开销。

相反,这也增加了很多复杂性,并且你无法在不同语言中重用该实现。

load-balancing-in-rust-5

外部线程

在此类中,负载均衡的复杂性(全部或部分)从客户端转移到一个或多个专用的进程中。

这些进程可以与应用部署在一起(如 sidecar 容器),也可以作为独立服务运行。

通常,gRPC 外部线程的负载均衡器有两种形式:

  1. Look-aside 服务告诉客户端应该请求哪台服务器
  2. 对应用完全透明的独立服务,可以执行负载均衡,健康检查,上报负载及服务发现等(如 Envoy)。

Look-aside

对于 Look-aside 负载均衡器,我们可以将其归类为 hybird 解决方案,大多数的逻辑都从客户端移除:健康检查,服务发现等。

客户端只需要:

  • 维护与 look-aside 进程的连接 (我应该请求哪一台服务器)
  • 在后台与所有健康的服务器建立并维护开放连接

load-balancing-in-rust-6

代理

使用代理,将所有逻辑从客户端移除。

这让客户端非常简洁,它只需与代理之间维护一个连接即可。

有几种方法可以将代理整合进你的软件架构中:

  • Service Mesh:专用基础架构层,用于控制服务到服务的通信(如 Istio 和 Linkerd),作为 sidecar 进行部署。

load-balancing-in-rust-7

  • Service Proxy:所有客户端都需要连接的单个独立服务,并为每个 gRPC 服务做配置。

load-balancing-in-rust-8

  • Sidecar 代理:sidecar 代理与每个客户端部署在一起,并全部通过相同的 gRPC 服务配置代理。

load-balancing-in-rust-9

然而,没有任何一种方案是完美的,它们之间总有一个权衡:

  • hot path 中移动部件越多,越影响系统的延迟
  • service mesh 和独立代理都为你的设置增加了很多复杂性,并具有新型的故障模式。它们需要被设置,监控和维护。

TrueLayer 的方案

TrueLayer 利用 gRPC 在各种编程语言 (C#,Rust,TypeScript,Python)编写的应用程序之间建立强类型契约。

我们目前没有在 Kubernetes 集群中运行 service mesh,因此我们没有开箱即用的 gRPC 负载均衡。

历史先例,C#:使用 Envoy sidecar

我们大多数早期的 gRPC 服务端和客户端是使用 C# 编写的。

这里我们使用的是 sidecar 方案 - 一个手动配置的 Envoy 代理。

使用 Envoy sidecar,你可以获得一个生产强化解决方案,周边有很多社区。这是当时让 gRPC 负载均衡工作的最快方法。

独立的 sidecar 代理增加了系统整体的负载性:它是另一个需要配置,操作,理解的组件。

特别是,随着服务数量的增加,配置管理的扩展性很差,而在本地或 CI 上测试和重现故障模式相当困难。

Rust 机遇

Rust 的 gRPC 生态如何呢?支持客户端负载均衡吗?

让我们看下 Rust 中最受欢迎的 gRPC 库:

  • TiKV 编写的 grpc-rs - 实现了负载均衡但是无法更新服务 IP。
  • grpc-rust 没有实现负载均衡
  • tonic 实现了负载均衡并具有用于动态更新终结点的构建块。

tonic是最合适的候选人:一切开箱即用,除了周期性服务发现

这已足够,tonic暴露了我们需要扩展该功能的 API:它在Channel原语中提供了动态添加和移除 gRPC 终结点的支持。


#![allow(unused)]
fn main() {
use tonic::transport::{Endpoint, Channel};

// Create a Channel. 
// This returns the sender half of a multi-producer single-consumer channel.
let (channel, sender) = Channel::balance_endpoint(1024);

// Add a new Endpoint.
sender.send(
  Change::Insert(
    "localhost:8080",
    Endpoint::from_static("localhost:8080")
  )
);

// Remove the Endpoint from the list of healthy targets.
sender.send(Change::Remove("localhost:8080"));
}

tonic还有一个好处:它基于tower构建,这是一个灵活且使用广泛的网络应用程序的中间件框架。

特别是,它基于tower::balance实现负载均衡逻辑Linkerd 的数据平面代理使用了相同的组件:我们构建在一堆生产强化组件之上。

构建一个基于tonic的解决方案

我们决定尝试一下tonic,并投入一些时间来探索功能完备的客户端负载均衡 channel 会是什么样子。结果就是我们开源的ginepro库。

本节将更深入的研究实现细节。

tonic中我们需要弥补的功能是服务发现:我们想要在后台周期性刷新健康的服务器 - 我们可以发送请求的 IP 列表。

我们假设每个服务名都映射到一个或多个 IP 地址中(及服务器的副本)。

解析服务名称的逻辑可以通过LookupService特征与 channel 实现分离:


#![allow(unused)]
fn main() {
/// Interface that provides functionality to
/// acquire a list of ips given a valid host name.
#[async_trait::async_trait]
pub trait LookupService {
    /// Return a list of unique `SockAddr` associated with the provided
    /// `ServiceDefinition` containing the `hostname` `port` of the service.
    /// If no ip addresses were resolved, an empty `HashSet` is returned.
    async fn resolve_service_endpoints(
        &self,
        definition: &ServiceDefinition,
    ) -> Result<HashSet<SocketAddr>, anyhow::Error>;
}
}

这个契约为我们提供了三个属性:我们可以通过模拟实现轻松诸如故障以测试不满意的场景,我们不限制 IP 的解析方式,我们可以隐藏协议特定的细微差别,如 DNS 记录超时。

例如,在 TrueLayer,我们将服务名称解析为 IP 列表时有两种选择:我们可以查询 Kubernetes 的 DNS 或直接查询 Kubernetes 的 API(端点资源)。

LookupService是如何适应客户端逻辑的呢?

这里有一个在后台运行的事件循环,由我们的LoadBalancedChannel管理。

LoadBalancedChannel在一个定时触发的服务发现上保留了一个已知终结点集合。

然后我们移除已经消失的终结点,并添加新发现的终结点。


#![allow(unused)]
fn main() {
loop {
      let discovered_endpoints = self
          .lookup_service
          .resolve_service_endpoints(service_definition).await;
    
      let changeset = self.create_changeset(&discovered_endpoints).await;
      // Report the changeset to `tonic` to update the list of available server IPs.
      self.report_and_commit(changeset, endpoints).await?;    
      
      // Wait until the next interval.
      tokio::time::sleep(self.probe_interval).await;
}
}

需要指出当终结点启动失败时 gRPC 客户端不会移除终结点:它将会一直尝试连接直到我们显式的告诉客户端将它移除(如服务端未通过健康检查探测并被 Kubernetes 移除)。

结论

我们能够在部署之前在 CI 中对ginepro进行广泛的测试 - 这是在同一服务栈中编写客户端解决方案的好处。

测试发现了tonic中的一些 bug (关于传输和 TLS) - 我们因此提交了补丁 (12)

ginepro在五个月前在多个 gRPC 客户端上进行了生产部署。

目前我们还没有遇到 gRPC 负载均衡引发的问题。

有一个问题:它仅适用于我们的 Rust 服务。

这可能不是 TrueLayer 关于 gRPC 负载均衡的最后一篇文章。

未来会使用 service mesh 吗,我们拭目以待。

尽管如此,这个解决方案还是有价值的 - 这也是我们向整个 Rust 生态开放的原因。我们希望其他开发人员可以在我们的工作基础上继续推进 Rust 生态中 gRPC 栈的状态。

ginepro只是我们开源之旅的开端 - 下一期我们将介绍为扩展支持中间件的 reqwest 而构建的机制。

学习资源

编辑: Matrixtang


RESTful API in Sync & Async Rust

原文 来源是 github 上的 pretzelhammer, 他在 github 上开源了自己的 rust 博客, 上面的文章质量很高。 本文介绍了如何在 Rust 中为一个虚构的看板风格的项目管理应用程序实现一个 RESTful API 服务器。

The Rust Borrow Checker—A Deep Dive

视频链接 本视频由 MS 的工程师 Nell Shamrell-Harrington 带来。对 Borrow Checker 工作方式感兴趣的同学可以看一下这个视频。

##  Naming Your Lifetimes 原文

有效地命名生命周期可以提高代码的可读性,本文对此进行了详细介绍。

Rust 中最让我讨厌的东西

原文

Rust 是作者最喜欢的语言,但是其中一些东西也让作者喜欢不起来:比如太多的字符串类型 (&str, String, &OsStr, OsString, AsRef),再比如 impl<T> for Trait for T where T: OtherTrait 这样的写法太过麻烦等

Oxidizing the technical interview

原文

为 Rust 面试提供了一些新思路

Rucredstash release & Rust experience from a Haskeller

原文

Rust 身上有函数式编程的影子, 因此也吸引了不少喜欢函数式爱好者的关注。本文讲述了一个 Haskell 程序员的 Rust 学习之路。

Why and how we wrote a compiler in Rust - (blog post series 1/X): the context

原文

这篇博客文章是该系列的第一篇文章,讲述了为何要使用 Rust 来编写编译器, 以及如何实现 一个用 Rust 编写的编译器 demo。

Scylla Developer Hackathon: Rust Driver

原文

Scylla 的开发者们在黑客马拉松上使用 Rust 来编写驱动,一起来看看他们是如何使用 Rust 的吧。

How we utilized fuzzing to improve security in the TezEdge node and created an open-source CI tool for Rust code fuzzing.

原文

本文讲述了作者使用 Fuzz 工具和开源 CI 工具来提高项目安全性的经历。 Fuzz 已经成为开源基础设施的必选项了。

Verifying vectorized Rust revisited

原文

实践出真知, 作者将带领读者一探 rustc 如何处理 vector instructions

Writing Pythonic Rust

原文

想必大家对 Python 都不会陌生, Python 语法简单, 其风格被成为 Pythonic。在 Rust 中也可以写出 Pythonic 的代码。

Routes to Discovering Rust

原文

作者在文中讲述了自己的 Rust 探索之路。

Rust Verification Workshop 2021

video Rust Verification Workshop 2021 - Ferrite: A Rust EDSL for Message-passing Protocol Verification

video Rust Verification Workshop 2021 - RustBelt: A Quick Dive into the Abyss

video Rust Verification Workshop 2021 - Polonius

video Rust Verification Workshop 2021 - Rust Interest in safety- and mission-critical environments

video Rust Verification Workshop 2021 - Leveraging Compiler Intermediate Representation for Multi- and Cross-Language Verification

Rust Verification Workshop 2021 年的技术分享

宏小本的最新中文翻译

GitHub page

这是对 Daniel Keep 撰写的书 的续写, 自 2016 年初夏以来,那本书就一直没再更新。本书的续写者为 Veykril。译者是 zjp-cn。

An Incomplete Explanation of the Proc Macro That Saved Me 4000 Lines of Rust

原文

本文中作者使用了 Proc Macro 解决了代码复用问题。

The most underrated but useful Rust standard library type

原文

Rust 标准库充满了许多有用的类型,特征和抽象。 作者介绍了一个被我们经常忽略的一个类型 Cow

Compilers as Teachers

原文

Rust 程序员经常被编译器 "折磨" 的失去活来, 其实不妨把编译器看作一个教导自己的导师。

##  Is it possible to write overhead-free cyclic data-structures in safe, stable Rust? 原讨论地址 readdit

是否可以在稳定的 Rust 中编写无开销的循环数据结构?

构建 Rust 异步 GraphQL 服务:基于 tide + async-graphql + mongodb

原文

基于 actix-web + async-graphql + rbatis + postgresql / mysql 构建异步 Rust GraphQL 服务, 本文是系列文章。

Crust of Rust: Dispatch and Fat Pointers

video

视频来源于 Youtube 上一个著名的 Youtuber, Jon Gjengset。对静态分发和动态分发背后感兴趣的同学可以参考一下。

Using GDB and defmt to debug embedded programs

原文

能直接在嵌入式系统中使用 gdb 当然是 "坠" 好的了。

How Rust makes Rayon's data parallelism magical

原文

Rayon 是用于 Rust 编程语言的数据并行性库。使用 Rayon 在 Rust 中开启并行之路。

Linux 基金会提供的免费 WebAssembly 课程

此在线课程是为已经在构建微服务和云本机应用程序开发方面有经验的开发人员设计的。 简而言之,这不是WebAssembly的介绍。 但是,如果您准备在本地构建,试验和测试功能即服务(FaaS),则该类适合您。

具体来说,您将学习如何使用Rust创建和托管WebAssembly模块。 您还将学习如何将JavaScript WebAssembly API用于浏览器,以及有关WebAssembly的替代性非Web主机运行时的信息。

您将了解到可以使用社区工具和开源项目添加到基本WebAssembly规范中。 学完这些,您将清楚地了解如何构建基于WebAssembly的应用程序以及它们的实际工作方式。

https://www.zdnet.com/article/linux-foundation-offers-free-webassembly-online-class/

Rust 中无法办到的事情(以及如何替代)

译者:Matrixtang

原文


作为 Rust subreddit的主持人,我经常发布有关开发人员试图将其各自的语言范例转换为 Rust 的帖子,结果不一而足,取得了不同程度的成功。 在本指南中,我将描述开发人员在将其他语言范例转换为 Rust 时遇到的一些问题,并提出一些替代解决方案来帮助你客服 Rust 的局限性。

Rust 中的继承

可以说,继承是在面向对象语言中被问到最多的缺失特性。为什么 Rust 不让一个结构 (struct) 继承另一个结构呢?

你可以肯定地说,即使在 OO 世界中,继承的名声也没好到哪里去,而且实践者通常尽可能地喜欢组合(composition )。但是你也可以认为,允许类型以不同的方式执行方法可能会提高性能,因此对于那些特定的实例来说是可取的。

这是一个来自 Java 的经典示例:

interface Animal {
    void tell();
    void pet();
    void feed(Food food);
}class Cat implements Animal {
    public void tell() { System.out.println("Meow"); }
    public void pet() { System.out.println("purr"); }
    public void feed(Food food) { System.out.println("lick"); }
}// this implementation is probably too optimistic...
class Lion extends Cat {
    public void tell() { System.out.println("Roar"); }
}

对于 Rust,第一部分可以用 traits 实现:


#![allow(unused)]
fn main() {
trait Animal {
    fn tell(&self);
    fn pet(&mut self);
    fn feed(&mut self, food: Food);
}struct Cat;impl Animal for Cat {
    fn tell(&self) { println!("Meow"); }
    fn pet(&mut self) { println!("purr");
    fn feed(&mut self, food: Food) { println!("lick"); }
}
}

但第二部分并没用这么容易:


#![allow(unused)]
fn main() {
struct Lion;impl Animal for Lion {
    fn tell(&self) { println!("Roar"); }
    // Error: Missing methods pet and feed
    // 错误: 缺少 `pet` 和 `feed` 方法
}
}

显然,最简单的方法是复制这些方法。是的,重复是不好的。这样也会使得代码更加复杂。如果你需要代码复用的话, 不妨把这些方法抽出来, 在 CatLion 中调用它们。

但是,你也许会察觉到,如何实现 OO 中的多态性部分呢?这就是复杂的地方。面向对象语言通常给你提供动态转发,而 Rust 让你在静态和动态分发中做出选择,不管选择哪一种都有失有得。


#![allow(unused)]
fn main() {
// static dispatch
// 静态分发
let cat = Cat;
cat.tell();let lion = Lion;
lion.tell();// dynamic dispatch via enum // 通过enum 进行动态分发
enum AnyAnimal {
   Cat(Cat),
   Lion(Lion),
}// `impl Animal for AnyAnimal` left as an exercise for the readerlet animals = [AnyAnimal::Cat(cat), AnyAnimal::Lion(lion)];
for animal in animals.iter() {
   animal.tell();
}// dynamic dispatch via "fat" pointer including vtable
// 动态分发通过`胖` 指针来实现
let animals = [&cat as &dyn Animal, &lion as &dyn Animal];
for animal in animals.iter() {
   animal.tell();
}
}

译者注: 动态分发参见 https://juejin.cn/post/6872898487244029960 以及 https://alschwalm.com/blog/static/2017/03/07/exploring-dynamic-dispatch-in-rust/

注意,与垃圾收集语言不同的是,在 ( Rust 中) 每个变量在编译时必须有一个具体的类型。此外,对于 enum 的情况,使用进行委托 trait 的实现是冗长乏味的,但是像 ambassador[1] 这样的 crates 可以提供帮助。

将函数委托给成员的一种相当 hacky 的方法是使用 Deref trait for polymorphism,这样在 derefee 上可以直接调用Deref` 目标定义的函数。但是请注意,这通常被认为是一种反模式。

最后,可以为所有实现许多其他特性之一的类实现一个 trait,但它需要专门化,这是目前的一个 nightly 特性(尽管有一个可用的解决方案 workaround[2],如果你不想写出所需的所有样板代码,可以把他们打包在一个macro crate 中)。trait 很可能是相互继承的,尽管它们只规定行为,而不是数据。

链表或者其他基于指针的数据结构

许多从 C++ 来到 Rust 的人一开始会想实现一个 “简单的” 双向链表,但很快就会发现它远非 简单。这是因为 Rust 想要明确所有权,因此双向列表需要对指针和引用进行相当复杂的处理。

一个新手可能会尝试写下面的 struct:


#![allow(unused)]
fn main() {
struct MyLinkedList<T> {
    value: T
    previous_node: Option<Box<MyLinkedList<T>>>,
    next_node: Option<Box<MyLinkedList<T>>>,
}
}

当他们注意到这个方法失败时,他们会添加 OptionBox。但是一旦他们尝试实现插入,他们就会感到很惊讶:


#![allow(unused)]
fn main() {
impl<T> MyLinkedList<T> {
    fn insert(&mut self, value: T) {
        let next_node = self.next_node.take();
        self.next_node = Some(Box::new(MyLinkedList {
            value,
            previous_node: Some(Box::new(*self)), // Ouch
            next_node,
        }));
    }
}
}

当然,borrow checker[3] 不会允许这样做。值的所有权完全是混乱的。Box 拥有它所包含的数据,因此列表中每个节点都将由列表中的上一个和下一个节点拥有。Rust 中的每个数据只允许有一个所有者,所以这将至少需要一个 RcArc 才能工作。但是即使这样做也会很快变得麻烦,更不用说引用计数带来的开销了。

幸运的是,你不需要自己编写双向链表,因为标准库已经包含了一个(std::collections::LinkedList)。而且,与简单的 Vecs 相比,这种方法可能并不能给你带来好的性能,因此你可能需要相应地进行测试。

如果你真的想写一个双向链表列表,你可以参考Learning Rust With Entirely Too Many Linked Lists][4] ,它会教会你写链表,并在这个过程中学到很多关于 Unsafe Rust 的知识。

(此外:单列表完全可以用一连串的 box 来构建。实际上,Rust 编译器包含一个实现。)

同样的情况也适用于图结构,尽管你可能需要一个依赖项来处理图数据结构。Petgraph[5] 是目前最流行的,它提供了数据结构和一些图算法。

自引用类型

当面对自引用类型的概念时,很容易会问出: “谁拥有它?”同样,这也是 borrow checker 不乐意听到的关于 ownership的事情。

当你具有所有权关系并希望在一个结构中同时存储所有权对象和被所有的对象时,就会遇到这个问题。天真地尝试一下这个方法,你会有一段艰难的时期去尝试生命周期 (lifetime)。

我们只能猜测,许多 rustacean 已经转向 Unsafe Rust,这很微妙的,并且很容易出错。当然,使用普通指针而不是引用会消除生命周期烦恼,因为指针不会有生命周期(lifetime)的烦恼。但是,这需要手动承担管理生命周期的责任。

幸运的是,有一些 crate 可以采用这种解决方案并提供一个安全的接口,比如 ouroboros[6], self_cell[7] 和 one_self_cell[8] 等 crates。

全局可变状态

来自 C 或 C++ (或是来自动态语言) 的开发者,有时习惯于在他们的代码中创建和修改全局状态( global state )。例如,一位 reddit 用户说:“这是完全安全的,但 Rust 不让你这么做。”

下面是一个稍微简化的例子:

#include <iostream>
int i = 1;int main() {
    std::cout << i;
    i = 2;
    std::cout << i;
}

在 Rust 中,这大致可以理解为:

static I: u32 = 1;fn main() {
    print!("{}", I);
    I = 2; // <- Error: Cannot mutate global state
    print!("{}", I);
}

许多 Rustacean 会告诉你,你并不需要这种全局的状态。当然,在这样一个简单的例子中,这是正确的。但是对于大量的用例,确实需要全局可变状态的时候,例如,在一些嵌入式应用程序中。

当然,有一种方法可以做到这一点,使用 Unsafe Rust。但是在这之前,根据场景的不同,你可能只想使用互斥对象(Mutex)即可。或者,如果可变只需要在初始化时使用一次,那么 OnceCelllazy_static 就可以巧妙地解决这个问题。或者,如果你真的只需要整数,那么 std::sync::Atomic* 类型也可以使用。

话虽如此,尤其是在每个字节数和资源通常映射到内存的嵌入式世界中, 拥有一个可变的静态变量通常是首选的解决方案。因此,如果你真的必须这么做,写起来像这样:

static mut DATA_RACE_COUNTER: u32 = 1;fn main() {
    print!("{}", DATA_RACE_COUNTER);
    // I solemny swear that I'm up to no good, and also single threaded.
    // 我发誓即使是单线程,依然跑不了
    unsafe {
        DATA_RACE_COUNTER = 2;
    }
    print!("{}", DATA_RACE_COUNTER);
}

再次强调,除非真的需要,否则你不应该这样做。如果你想问这是不是一个好主意,答案是否定的。

直接初始化一个数组

新手可能会倾向于声明如下数组:


#![allow(unused)]
fn main() {
let array: [usize; 512];for i in 0..512 {
    array[i] = i;
}
}

这会报错,因为数组从未初始化。然后我们尝试给它赋值,但是没有告诉编译器,它甚至不会为我们在堆栈上保留一个写入的位置。Rust 是这样挑剔,它根据数组的内容来区分数组。此外,在我们读取它们之前,需要对它们进行初始化。

通过初始化 let array = [0usize; 512] ; ,我们以双重初始化为代价来解决这个问题,双重初始化可能会也可能不会得到优化——或者,根据类型的不同,甚至可能是无法实现的。参见 Unsafe Rust: How and when not to use it[9] 的解决方案。

总结

参考资料

[1]ambassador: https://docs.rs/ambassador/0.2.1

[2]workaround: https://github.com/dtolnay/case-studies/blob/master/autoref-specialization/README.md

[3]borrow checker: https://blog.logrocket.com/introducing-the-rust-borrow-checker/

[4] Learn Rust With Entirely Too Many Linked Lists : https://rust-unofficial.github.io/too-many-lists/

[5]Petgraph: https://crates.io/crates/petgraph

[6]oeuroboros: https://docs.rs/ouroboros/0.9.2/ouroboros/

[7]self_cell: https://docs.rs/self_cell/0.8.0/self_cell/

[8]one_self_cell: https://docs.rs/once_self_cell/0.6.3/once_self_cell/

[9]Unsafe Rust: How and when not to use it: https://blog.logrocket.com/unsafe-rust-how-and-when-not-to-use-it/

Rust过程宏系列教程 | Proc Macro Workshop 之 Builder 实现

作者:米明恒 / 后期编辑:张汉东

文字版首发:https://blog.ideawand.com/2021/03/24/rust_procedural_macro/rust_proc_marco_workshop_guide-02/

视频版本首发:https://space.bilibili.com/500416539

了解过程宏开发环境并熟悉基本原理请阅读:https://blog.ideawand.com/2021/02/27/rust_procedural_macro/rust_proc_marco_workshop_guide-01/


本文以戴维·托尔奈(David Tolnay,也就是synquote这两个库的作者)的教学项目proc-macro-workshop出发,带领大家实战几个Rust过程宏的编写。

proc-macro-workshop是一个包含5个过程宏的“解题游戏”,每一个过程宏都是有实际应用价值的案例,通过一系列由简到繁的测试用例,指导你去完成每一个过程宏的开发,而我们要做的,就是编写代码,像闯关游戏一样依次通过每一个测试用例,在这个过程中,我们会学到不同类型的过程宏的开发方法。

好了,不废话了,准备好一台电脑,我们从第一个过程宏挑战任务builder开始,每一关对应的视频教程我也放在下面了。

序言视频版:

首先克隆https://github.com/dtolnay/proc-macro-workshop这个项目到本地,我们将在这个项目中进行开发

cd ~/blog.ideawand.com  # 进入我的工作目录
git clone https://github.com/dtolnay/proc-macro-workshop.git

克隆项目后,我们会得到下面这样的目录结构。回忆我们上一篇文章中提到的,声明过程宏的crate需要和使用过程宏的crate独立开,这个项目也不例外,其中,5个题目分别独立存放在5个文件夹中。而目录的最顶层定义了一个bin类型的crate,可以使用我们定义的过程宏。我们后续的一些调试工作会借助外层crate的main.rs来进行。每一个题目所在的crate目录中都有一个tests文件夹,里面有通过数字编号命名的测试用例,以及一个progress.rs文件,progress.rs控制了哪些测试用例是生效的,在解题闯关的过程中,每当你通过一个测试用例,你就要到progress.rs中开启下一个测试用例,直到通过所有的测试用例。当然啦,解题过程中每一步的提示信息,都在以数字编号开头的测试用例中。

└── blog.ideawand.com           -- 我们的工作目录
    └── proc-macro-workshop
        ├── bitfield            -- 虽然排在第一个,却是最难的一个,我们在最后一篇文章再去讨论它
        │   ├── impl
        │   ├── src
        │   ├── tests
        │   │   ├── <省略这里的内容。。。>
        │   └── Cargo.toml
        ├── builder             -- builder项目,我们今天的主角
        │   ├── src
        │   ├── tests
        │   │   ├── 01-parse.rs
        │   │   ├── 02-create-builder.rs
        │   │   ├── 03-call-setters.rs
        │   │   ├── 04-call-build.rs
        │   │   ├── 05-method-chaining.rs
        │   │   ├── 06-optional-field.rs
        │   │   ├── 07-repeated-field.rs
        │   │   ├── 08-unrecognized-attribute.rs
        │   │   ├── 08-unrecognized-attribute.stderr
        │   │   ├── 09-redefined-prelude-types.rs
        │   │   └── progress.rs  -- 闯关进度控制文件,在里面决定开启哪些测试用例
        │   └── Cargo.toml
        ├── debug               
        │   ├── src
        │   ├── tests
        │   │   ├── <省略这里的内容。。。>
        │   └── Cargo.toml
        ├── seq
        │   ├── src
        │   ├── tests
        │   │   ├── <省略这里的内容。。。>
        │   └── Cargo.toml
        ├── sorted
        │   ├── src
        │   ├── tests
        │   │   ├── <省略这里的内容。。。>
        │   └── Cargo.toml
        ├── Cargo.toml
        ├── LICENSE-APACHE
        ├── LICENSE-MIT
        ├── README.md
        └── main.rs            -- 外层Crate,可以在这里使用上面定义的各个过程宏,我们将通过这个文件来查看过程宏展开后生成的代码

以上信息主要来自于proc-macro-workshop项目的readme文档,如果大家的英文水平还可以的话,建议阅读一下原始的文档。

了解完整体项目的结构后,我们开始正式挑战builder项目! 首先我们需要看一下builder题目要实现什么功能,每一个挑战题目到实现的目标都写在了proc-macro-workshop的readme文档中,下面我来把和builder项目相关的部分大致转述一下:

  • 我们要实现一个派生宏derive(Builder)
  • 这个宏提供了一个初始化结构体的方式,其使用效果大致如下,将derive(Builder)宏作用于Command结构体以后,会根据结构体的字段,自动生成一系列方法,来为我们提供初始化结构体中每个字段的方法:
use derive_builder::Builder;

#[derive(Builder)]
pub struct Command {
    executable: String,
    #[builder(each = "arg")]
    args: Vec<String>,
    current_dir: Option<String>,
}

fn main() {
    let command = Command::builder()
        .executable("cargo".to_owned())
        .arg("build".to_owned())
        .arg("--release".to_owned())
        .build()
        .unwrap();

    assert_eq!(command.executable, "cargo");
}

了解完任务目标以后,我们可以开始解题了。首先进入builder目录下的tests目录,并编辑progress.rs文件,将其中t.pass("tests/01-parse.rs");这一行的注释去掉,表示我们要检验第一个测试用例的结果是否正确。然后,我们打开tests/01-parse.rs这个文件,查看第一题的解题提示。

第一关

第一关视频版:

  • 这个测试检测是否定义了一个名为Builder的派生宏,也就是说,只要我们定义了这个宏,什么都不用做,就可以通过这一关
  • 什么都不做,意味着我们只需要返回空的TokenStream即可,但是,考官建议我们额外多做一些尝试:
    • 尝试把输入的TokenStream解析为syn::DeriveInput这个语法树节点类型
    • 阅读官方文档中关于syn::DeriveInput这个结构体的说明,看看其中的字段分别包含了哪些信息,对我们后续解题有什么用处。
      • 对于这个结构体的描述,我们会在第二关的闯关过程中再介绍,当然你也可以提前阅读文档

通过这一关的技能我们在上一篇文章中已经遇到过了,因此这一关不难,直接上代码。我们编写代码的位置是builder/src/lib.rs这个文件,代码如下:


#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use syn;

#[proc_macro_derive(Builder)] // 注意,这里和第一篇文章里的 #[proc_macro_attribute]不同
pub fn derive(input: TokenStream) -> TokenStream {
    let st = syn::parse_macro_input!(input as syn::DeriveInput);
    TokenStream::new()
}
}

上述代码中,我们使用了syn包的parse_macro_input!宏将类型为proc_macro::TokenStream的input解析成了syn::DeriveInput类型的语法树节点st,然后产生一个空的TokenStream对象,返回给编译器。

这里需要额外注意的一点是,上一篇入门文章中我们给出的示例是一个属性样式的过程宏,属性样式的过程宏使用#[proc_macro_attribute]来定义,而本篇我们编写的是一个派生样式的过程宏,派生样式的过程宏用#[proc_macro_derive(XXX)]的形式来定义,除此之外,还有:

  • 派生样式的过程宏,其名字不是由函数名来定义的,而是由#[proc_macro_derive(XXX)]中的XXX来定义的
  • 派生样式的过程宏,其定义的函数签名只包含一个TokenStream输入,相当于上篇文章介绍的属性式过程宏的item入参,也就是说派生式的过程宏相比于属性式的过程宏,少了attr这个入参
  • 开发派生式过程宏时,通常将输入的TokenStream解析为syn::DeriveInput,这是派生宏定义对应的语法树节点

其实,大家可以把派生过程宏认为是一种特殊的属性式过程宏,把属性式过程宏看成是派生式的扩展版本,能用派生式过程宏实现的,用属性式过程宏也可以实现。另外从发展时间线上来说,属性式过程宏是在2018版本中新加入的,所以它和派生式过程宏有功能重叠也是有历史原因的。

上述代码直接返回了空的TokenStream,而上一篇文章我们在定义属性式过程宏时曾经提到,最简单的过程宏就是对输入不做修改,原封不动返回给编译器,可是这里为什么你返回了一个空的TokenStream呢?这样做不是就把输入给修改了吗? 这其实是派生式过程宏和属性式过程宏的另外一个区别,先存疑,我们会在稍后的第二关详细介绍这个问题。

由于我们引用了synquote两个包,我们还需要修改一下builder/cargo.toml文件,将dependencies小节下的# TODO替换为如下的依赖声明,proc-macro2包我们后面会用到,这里也先引用过来:

syn = {version="1.0", features=["extra-traits"]}
proc-macro2 = {version="1.0"}
quote = {version="1.0"}

上述syn包额外加入的extra-traits特性是为了后续调试的方便。接下来,我们在builder目录下运行cargo test,不出意外,我们应该顺利通过了第一关测试。现在可以修改builder/tests/progress.rs文件,开启第二关的测试。

第二关

第二关视频版:

我们要生成一个辅助的结构体,他的命名格式为<结构体名称>+Builder,同时,我们要为原始结构体生成生成一个builder方法,让他返回一个辅助结构体的实例。最终要生成的代码样式如下。:


#![allow(unused)]
fn main() {
pub struct CommandBuilder {
    executable: Option<String>,
    args: Option<Vec<String>>,
    env: Option<Vec<String>>,
    current_dir: Option<String>,
}
   
impl Command {
    pub fn builder() -> CommandBuilder {
        CommandBuilder {
            executable: None,
            args: None,
            env: None,
            current_dir: None,
        }
    }
}
}

为了实现上面的功能,我们要用到几个个知识点,这些个知识点的其他介绍可以参考官方文档:

  • 标识符使用syn::Ident类型来表示
  • syn::DeriveInput类型中获取原始输入结构体的标识符
  • 构建出CommandBuilder这个新的标识符
  • syn::DeriveInput类型中获取原始输入结构体各个字段的信息,比如字段名、字段的类型等信息
  • 使用quote包中提供的quote!宏来按照模板生成TokenStream的方法

首先是关于标识符的获取与构建:

  • syn::DeriveInput类型提供了ident属性,可以获取到被过程宏修饰的原始结构体的标识符对象
  • syn::Ident类型提供了to_string()方法,可以将标识符转换为字符串
  • syn::Ident::new()方法可以创建一个新的syn::Ident类型的变量 因此,我们通过下面几行代码可以构建出新的辅助结构体的名称标识符:

#![allow(unused)]
fn main() {
    let struct_name_literal = st.ident.to_string();
    let builder_name_literal = format!("{}Builder", struct_name_literal);
    let builder_name_ident = syn::Ident::new(&builder_name_literal, st.span());
}

在上面的代码中,有几点说明:

  • 注意区分_literal_ident这两类变量的命名,这是我个人的习惯,大家也可以用其他的命名方式,但要注意区分,字符串和标识符不是一种类型
  • 标识符除了名字的字符串之外,还携带了它在源代码中的位置信息,也就是span
  • 上述的第三行中,在创建新的Ident时,将st的span作为新创建标识符的span,是出于这样的原因:
    • span信息主要用于发生编译错误时,编译器给用户指示出错误的位置
    • 编译器报错时,不会展示过程宏生成的代码,而只会展示用户编写的原始文件代码
    • 因此,对于我们通过过程宏产生出来的代码,应该指向用户原始代码文件中的某个位置,而不是凭空指向一个不存在的位置,否则后续一旦产生编译器报错,将会产生令人难以理解的错误提示
    • 由于我们后续要生成的代码都是由用户输入的原始结构体产生的,所以将原始输入结构体的位置信息当做虚构出的标识符的位置信息,后续一旦报错,编译器显示的错误提示将指向用户原始的结构体,从而引导用户有效排查问题

构建出新的标识符后,我们就可以生成一些简单的代码了,整体的代码如下所示,在下面会详细解释:


#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use syn::{self, spanned::Spanned};
use quote::{ToTokens, quote};

#[proc_macro_derive(Builder)]
pub fn derive(input: TokenStream) -> TokenStream {
    let st = syn::parse_macro_input!(input as syn::DeriveInput);
    match do_expand(&st) {
        Ok(token_stream) => token_stream.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    
    let struct_name_literal = st.ident.to_string();
    let builder_name_literal = format!("{}Builder", struct_name_literal);
    let builder_name_ident = syn::Ident::new(&builder_name_literal, st.span());

    let struct_ident = &st.ident;  // 模板代码中不可以使用`.`来访问结构体成员,所以要在模板代码外面将标识符放到一个独立的变量中

    let ret = quote! {     // ----------------------------------+
        pub struct #builder_name_ident {                   //   |
            // TODO                                             |
        }                                                  //   |
        impl #struct_ident {                               //   |
            pub fn builder() -> #builder_name_ident {      //  被quote!宏包裹的是模板代码
                builder_name_ident{                       //   |
                    // TODO                                     | 
                }                                          //   |
            }                                              //   |
        }                                                  //   |
    };                     // ----------------------------------+

    return Ok(ret);
}
}

划重点: 首先观察上面代码的骨架,分为了两个函数,宏定义本身将TokenStream转换为语法树对象后,交给另一个函数去完成主要功能,并处理其返回结果;do_expand函数返回一个syn::Result类型的结果。这种框架模式可以使得我们在后续书写代码时方便的进行错误处理,后续的几个实验项目我们也会使用类似的骨架。

对上面代码几个重点的说明:

  • to_compile_error方法是syn包提供的一个返回错误的方式,它会产生一个包含错误信息的proc_macro2::TokenStream类型的结果,将其返回给编译器后,我们就可以在编译器的输出日志中看到用波浪线标注出的错误位置以及上下文代码了。我们在后面会介绍如何产生错误信息。
  • quote!宏可以通过模板的形式生成TokenStream,在上一篇文章中我们已经了解到,所谓的过程宏,就是返回一个加工修改后的TokenStream,而TokenStream的结构很复杂,手工来生成一层层的嵌套结构太痛苦了,所以我们就有了quote!宏这个工具来帮助我们把和rust语言很相近的模板语言转换为TokenStream,在模板中我们可以做变量替换。
    • quote!宏内部形如#xxxx的部分会被替换为quote!宏外面定义的变量所表达的语法树元素
    • quote!宏的用法与rust内置的macro_rules!宏很类似,也支持重复替换等功能,区别是quote!里面用#macro_rules!里面用$
  • 代码中两个地方使用到了.into(),这些都是为了在proc_macro::TokenStreamproc_macro2::TokenStream类型之间转换,基本上synquote包产生的结果都是proc_macro2::TokenStream,我们的大原则就是在中间环节都用proc_macro2包的,只有在最终返回编译器的时候再转换成proc_macro包的。

上述代码写完以后,我们就可以看看我们的过程宏生成的结果是什么样的了,这里我们要借助cargo expand这个命令来查看展开的结果,如果没有安装过这个工具,可以通过cargo install cargo-expand命令来安装。

目前我们关心的是我们编写的过程宏在第二关的测试用例中使用时,会产生什么样的效果,因此我们就以第二关的测试用例来查看展开效果,将blog.ideawand.com/proc-macro-workshop/builder/tests/02-create-builder.rs文件中的内容全部拷贝到blog.ideawand.com/proc-macro-workshop/main.rs中,覆盖原来main.rs中的全部内容即可,然后在proc-macro-workshop目录下执行cargo expand命令,应该可以看到下面的输出,我将过程宏生成的代码用注释标记了出来:

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::v1::*;
#[macro_use]
extern crate std;
use derive_builder::Builder;
pub struct Command {                 // --------------------+
    executable: String,                                  // |
    args: Vec<String>,                                   // 用户原始的代码
    env: Vec<String>,                                    // |
    current_dir: String,                                 // |
}                                    // --------------------+
pub struct CommandBuilder {}         // -----------------+
impl Command {                                        // |
    pub fn builder() -> CommandBuilder {              // 由过程宏生成的代码片段
        CommandBuilder {}                             // | 
    }                                                 // |
}                                    // -----------------+
fn main() {
    let builder = Command::builder();
    let _ = builder;
}

可以看出,派生式过程宏会保留用户的原始输入的代码,然后把我们返回的TokenStream追加在用户原始代码的下方。它的行为和属性式过程宏是不一样的,属性式过程宏允许你修改用户的原始代码。所以,这里可以稍微纠正一下我们之前对于过程宏的描述,之前我们一直说,过程宏是把用户输入的代码做一顿处理之后,把加工处理后的代码给到编译器继续编译,但实际上,更准确的说法应该是,依照用户输入的代码信息,修改用户原始代码,或生成全新的代码,把修改后或新生成的代码返回给编译器继续编译。不同类型的过程宏赋予你操作用户代码的范围是不一样的。

好了,开始下一步操作。我们已经生成出了新的结构体定义,但是结构体中的字段还没有定义出来,下面我们要开始遍历原始输入结构体的各个字段,获取这些字段的信息。

我们现在手头的输入信息只有一个类型为syn::DeriveInput类型的语法树节点,看起来所有的信息系都要从它里面挖掘了,为了获取输入结构体的信息,我们先看看syn::DeriveInput这个语法树节点是怎么定义的,它的定义如下:


#![allow(unused)]
fn main() {
    pub struct DeriveInput {
        /// Attributes tagged on the whole struct or enum.
        pub attrs: Vec<Attribute>,

        /// Visibility of the struct or enum.
        pub vis: Visibility,

        /// Name of the struct or enum.
        pub ident: Ident,

        /// Generics required to complete the definition.
        pub generics: Generics,

        /// Data within the struct or enum.
        pub data: Data,
    }
}

首先尝试自己看一下syn::DeriveInput的各个字段,然后尝试把他和结构体的定义联系起来。假设我们有下面这样一个结构定义,那么这里的各个语法元素和syn::DeriveInput中各个字段的映射关系可以用下图来表示


#![allow(unused)]
fn main() {
#[derive(Builder)]
#[blog.ideawand.com]
#[geek_kindergarten]
pub struct Foo <T> {
    foo: T,
    bar: i64,
}
}

deriveInput类型和结构体的对应关系

划重点: 通过上面的例子,要建立起一种认知,rust中各个语法元素,都可以被不同的数据结构(也就是syn包提供的语法树节点类型)来表示。我们上面的例子是带领大家分析了syn::DeriveInput结构和struct定义之间的对应关系,后续我们还会遇到各种各样的语法元素,我们不能一一给大家作图展示对应关系,这就要求大家能够自己阅读syn包的文档,并逐步掌握rust语言中常见语法元素与syn包中对应的各种数据结构。方法都是一样的,并不困难,学会多看文档。

从上面DeriveInput结构源代码的注释中,我们可以看到很多地方都写了struct or enum,也就是说,派生样式的过程宏不仅可以用在struct上,也可以用在enum

知道了语法树节点的结构,接下来我们就可以开始从中抽取数据了。我们现在要处理的输入结构比较简单,attrsgenerics暂时都用不到,看来只要搞清楚data字段里的结构就行了,但是data节点里有什么呢?大家需要继续去继续阅读syn::Data数据类型的源码,一层一层去查看语法树中各种节点之间是怎样嵌套的,但语法树的嵌套层级非常灵活,你极有可能不知道写出的一段代码究竟会被解析成什么样的语法树,因此,我们可以回忆一下上一篇文章中的重点知识:

  • 遇到不熟悉的语法树节点,就打印出来看看,回忆一下我们在上一篇文章中提到的重点内容:print大法是开发调试rust过程宏的利器
  • print大法和阅读文档,要交替使用!

为了看看我们的原始输入结构体被解析成了什么样的语法树,我们在上述代码的do_expand函数开头加入一行打印语句如下所示:


#![allow(unused)]
fn main() {
fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    eprintln!("{:#?}", st.data);
    // 其他代码保持不变,省略。。。
}
}

然后依然在proc-macro-workshop目录下,这次执行cargo check命令(这个命令在上一篇文章中介绍过),因为我们不需要打印过程宏展开的代码,我们只是想通过check命令触发宏展开的过程,执行我们的调试打印代码,这次应该会在终端中看到很长的输出,我把关键结构放在这里,删掉了一些暂时不用的字段:


#![allow(unused)]
fn main() {
Struct(
    DataStruct {
        fields: Named(
            FieldsNamed {
                named: [
                    Field {
                        ident: Some(
                            Ident {
                                ident: "executable",
                                span: #0 bytes(1388..1398),
                            },
                        ),
                        ty: Path(
                           // ...
                        ),
                    },
                    Comma,
                    // 下面开始第二个字段的定义了,我们省略不写了
                ],
            },
        ),
        semi_token: None,
    },
)
}

阅读上面的代码,我们可以看到DeriveInputdata字段是一个枚举类型,从data字段开始,到我们想要得到的字段列表,也就是上面结构中Field结构体构成的列表,中间还间隔了很多层不同的语法树节点,因此我们的下一步就是通过一顿模式匹配操作,拿到结构体字段的列表。

为了代码结构清晰,我们将其定义为一个独立的函数吧,像下面这样:


#![allow(unused)]
fn main() {
type StructFields = syn::punctuated::Punctuated<syn::Field,syn::Token!(,)>;

fn get_fields_from_derive_input(d: &syn::DeriveInput) -> syn::Result<&StructFields> {
    if let syn::Data::Struct(syn::DataStruct {
        fields: syn::Fields::Named(syn::FieldsNamed { ref named, .. }),
        ..
    }) = d.data{
        return Ok(named)
    }
    Err(syn::Error::new_spanned(d, "Must define on a Struct, not Enum".to_string()))
}
}

在上面这段代码中,语法树各个层级的嵌套关系、枚举类到底是哪一个成员等信息,是通过print大法得到的,而解析后数据类型的泛型参数,则是通过翻syn包的文档或者代码得到的,这里大家要Get的重点不是代码现在写成的这个样子,而是怎样去综合使用各种手段来找到解析语法树各式各样节点的方法,注意这里是授之以渔,不是授之以鱼。说实话,我在完成这些挑战的时候,有相当多的时间是在翻文档和打印调试信息,从而捋清楚如何操作这些语法树节点。如果大家观看视频版本,就可以看到我是如何一层层阅读文档源码、找出类型定义的过程了。

关于错误信息,我们使用了syn::Error::new_spanned()方法,其第一个参数用于指定错误对应的span信息,也就是编译器在打印错误信息时,要显示用户源代码中的哪一段上下文,我们在这里直接将输入的原始信息放在了这里,这样报错的时候,就会显示出有问题的结构体定义了。我们这里出于演示目的,如果上面的模式匹配和解构流程失败,就返回一个"该过程宏只能作用在结构体上`的错误,如果做得完善一些,上面的代码中其实有两个枚举类型的匹配,应该分别返回不同的错误信息,有兴趣的同学可以完善一下

拿到输入结构体中的字段以后,就可以开始产生Builder结构体的字段定义了,其实产生字段定义的方法有很多种,rust过程宏的机制就是,你最后能拼出来有效的TokenStream就行,至于你是模块化的拼接,还是流水账式的拼接,都可以。我们只能给大家演示一种写法,大家如果自己写,可以随意发挥。我们这里要给大家顺带展示一下quote!宏里面重复展开的用法,代码如下:


#![allow(unused)]
fn main() {
fn generate_builder_struct_fields_def(fields: &StructFields) -> syn::Result<proc_macro2::TokenStream>{
    let idents:Vec<_> = fields.iter().map(|f| {&f.ident}).collect();
    let types:Vec<_> = fields.iter().map(|f| {&f.ty}).collect();

    let token_stream = quote!{
        (#idents: std::option::Option<#types>),*
    };
    Ok(token_stream)
}
}

上述代码中:

  • 首先通过两次迭代输入的字段列表,分别得到identstypes这两个列表变量,由于迭代过程是保序的,所以这两个列表的长度是相等的,并且列表中相同下标位置的元素恰好是(标识符,类型)的一对儿数据。
    • types里面的每一个元素都是一个&syn::Type类型的枚举,里面还有很深的嵌套层级,但我们现在可以把它当做一个整体(也就是一个语法树节点)来用,没必要去解析里面。
  • quote!宏中,可以使用#(#var1 #var2 ... #varN ),*的形式,来把一个列表中的内容展开成一组类似的代码,模板语言的详细说明请大家参阅官方文档
  • quote!宏返回的是一个proc_macro2::TokenStream类型数据,quote!宏里面可以再次加入其它quote!宏产生的结果,这个用法我们马上就会看到。

划重点:

  • 上述代码中用到了非常常见的Option枚举类型,但这里使用了std::option::Option这种完整路径的写法。这是由于你无法预料到过程宏在什么场景下被使用,有可能过程宏展开的上下文中,用户定义了其他的Option类型,因此,为了保证过程宏的稳定性,请大家尽量使用绝对路径。
  • 如果你在看B站视频,请一键三连,或者,至少点个赞吧~,如果你在看文字版,请关注我的微信公众号:极客幼稚园

上述代码在第二关的测试用例中执行后,预期会生成如下的代码片段,注意这四行代码周围并没有括号等其他语法元素的包围,它们仅仅是一些片段,后面我们要把这些片段插入到其他片段中,来组成更加复杂的代码:


#![allow(unused)]
fn main() {
    executable: std::option::Option<String>,
    args: std::option::Option<Vec<String>>,
    env: std::option::Option<Vec<String>>,
    current_dir: std::option::Option<String>,
}

准备好上述两个函数以后,我们可以修改一下之前的do_expand函数了,修改后的函数如下所示,新增代码在注释中标出:


#![allow(unused)]
fn main() {
fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let struct_name_literal = st.ident.to_string();
    let builder_name_literal = format!("{}Builder", struct_name_literal);
    let builder_name_ident = syn::Ident::new(&builder_name_literal, st.span());

    let struct_ident = &st.ident;

    // 以下两行代码是新增的,调用上述新定义的两个函数
    let fields = get_fields_from_derive_input(st)?;
    let builder_struct_fields_def = generate_builder_struct_fields_def(fields)?;

    let ret = quote! {
        pub struct #builder_name_ident {
            // 下面这行代码是增增的,注意这里的用法:
            // 在当前这个`quote!`宏中,引用了其他`quote!`宏返回的结果
            // 在这里把不同的代码碎片拼接起来,就像搭积木一样
            builder_struct_fields_def
        }
        impl #struct_ident {
            pub fn builder() -> #builder_name_ident {
                builder_name_ident{
                    // TODO 后面会再写一个函数,生成这里需要的代码片段
                }
            }
        }
    };

    return Ok(ret);
}
}

修改完代码后,我们再在proc-macro-workshop目录下执行以下cargo expand,查看一下现在过程宏展开得到的代码,应该是下面这个样子,重点关注一下我们拼接出的CommandBuilder结构体的成员定义部分:

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::v1::*;
#[macro_use]
extern crate std;
use derive_builder::Builder;
pub struct Command {
    executable: String,
    args: Vec<String>,
    env: Vec<String>,
    current_dir: String,
}
struct CommandBuilder {
    executable: std::option::Option<String>,
    args: std::option::Option<Vec<String>>,
    env: std::option::Option<Vec<String>>,
    current_dir: std::option::Option<String>,
}
impl Command {
    pub fn builder() -> CommandBuilder {
        CommandBuilder {}
    }
}
fn main() {
    let builder = Command::builder();
    let _ = builder;
}

让我们来继续产生其他代码片段,我们现在需要生成builder()方法中,结构体初始化的相关代码,最简单的做法就是把上面的代码简单修改一下即可,但我们前面提到过,rust过程宏的实现方法非常灵活,只要你能拼出正确的TokenStream就行,所以,这里我们采用了另外一种写法,下面的函数返回的是一个由TokenStream组成的列表,重复展开的工作留到do_expand函数:


#![allow(unused)]
fn main() {
fn generate_builder_struct_factory_init_clauses(fields: &StructFields) -> syn::Result<Vec<proc_macro2::TokenStream>>{
    let init_clauses: Vec<_> = fields.iter().map(|f| {
        let ident = &f.ident;
        quote!{
            ident: std::option::Option::None
        }
    }).collect();

    Ok(init_clauses)
}
}

相应的,我们修改一下do_expand函数,改动部分用注释标出:


#![allow(unused)]
fn main() {
fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let struct_name_literal = st.ident.to_string();
    let builder_name_literal = format!("{}Builder", struct_name_literal);
    let builder_name_ident = syn::Ident::new(&builder_name_literal, st.span());

    let struct_ident = &st.ident;

    let fields = get_fields_from_derive_input(st)?;
    let builder_struct_fields_def = generate_builder_struct_fields_def(fields)?;
    // 下面这一行是新加的 
    let builder_struct_factory_init_clauses = generate_builder_struct_factory_init_clauses(fields)?;

    let ret = quote! {
        pub struct #builder_name_ident {
            builder_struct_fields_def
        }
        impl #struct_ident {
            pub fn builder() -> #builder_name_ident {
                builder_name_ident{
                    // 下面这一行是新加的,注意我们在这里重复展开了每一个字段
                    (#builder_struct_factory_init_clauses),*
                }
            }
        }
    };

    return Ok(ret);
}
}

接下来大家可以再运行一下cargo expand来观察一下过程宏展开的结果,我们就不再列出来了。现在,我们看看第二关能不能通过了,确保proc-macro-workshop/builder/tests/progress.rs文件中对第二关的注释已经去掉,然后进入到proc-macro-workshop/builder目录下,执行cargo test,不出意外的话,第二关应该顺利通过!

第三关

第三关视频版本:

请阅读proc-macro-workshop/builder/tests/03-call-setters.rs文件中的说明,主要就是要产生一组类似下面这种形式的代码,我们要把其中的executableString全部动态替换掉:


#![allow(unused)]
fn main() {
fn executable(&mut self, executable: String) -> &mut Self {
    self.executable = Some(executable);
    self
}
}

这一关要实现的功能与第二关几乎完全一样,因此我就不再做其他解释了。大家可以先尝试自己编码之后,再看看我下面给出的一个参考实现。

在这个参考实现中,我再给大家展示一种代码片段拼接的方式,即直接通过proc_macro2::TokenStream类型提供的extend方法,把多个TokenStream串接在一起,就像字符串拼接一样:


#![allow(unused)]
fn main() {
fn generate_setter_functions(fields: &StructFields) -> syn::Result<proc_macro2::TokenStream>{
    let idents:Vec<_> = fields.iter().map(|f| {&f.ident}).collect();
    let types:Vec<_> = fields.iter().map(|f| {&f.ty}).collect();

    // 创建一个空的TokenStream
    let mut final_tokenstream = proc_macro2::TokenStream::new();

    for (ident, type_) in idents.iter().zip(types.iter()) {
        let tokenstream_piece = quote!{
            fn #ident(&mut self, #ident: #type_) -> &mut Self {
                self.#ident = std::option::Option::Some(#ident);
                self
            }
        };
        // 不断追加新的TokenStream片段到一个公共的TokenStream上
        final_tokenstream.extend(tokenstream_piece);
    }

    Ok(final_tokenstream)
}
}

#![allow(unused)]
fn main() {
fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let struct_name_literal = st.ident.to_string();
    let builder_name_literal = format!("{}Builder", struct_name_literal);
    let builder_name_ident = syn::Ident::new(&builder_name_literal, st.span());

    let struct_ident = &st.ident;

    let fields = get_fields_from_derive_input(st)?;
    let builder_struct_fields_def = generate_builder_struct_fields_def(fields)?;
    let builder_struct_factory_init_clauses = generate_builder_struct_factory_init_clauses(fields)?;

    // 下面这一行是第三关新加的
    let setter_functions = generate_setter_functions(fields)?;

    let ret = quote! {
        pub struct #builder_name_ident {
            builder_struct_fields_def
        }
        impl #struct_ident {
            pub fn builder() -> #builder_name_ident {
                builder_name_ident{
                    (#builder_struct_factory_init_clauses),*
                }
            }
        }

        // 下面这三行是第三关新加的
        impl #builder_name_ident {
            setter_functions
        }
    };

    return Ok(ret);
}
}

第四关

第四、五关视频版本:

这一关要生成的代码比之前的复杂了不少,需要由宏来产生出一些判断逻辑,错误信息等,但是换汤不换药,本质还是生成代码片段后进行拼接。大家可以在本关中继续尝试各种写法。

接下来我要给出的写法中,采用了先将各个片段放入一个Vec中,然后再展开的做法,这是因为这样写以后,对于后面关卡的实现会比较容易,大家完全可以尝试其他的方式来写。

产生build()方法代码片段的示例代码如下:


#![allow(unused)]
fn main() {
fn generate_build_function(fields: &StructFields, origin_struct_ident: &syn::Ident) -> syn::Result<proc_macro2::TokenStream>{
    let idents:Vec<_> = fields.iter().map(|f| {&f.ident}).collect();

    let mut checker_code_pieces = Vec::new();
    for idx in 0..idents.len() {
        let ident = idents[idx];
        checker_code_pieces.push(quote!{
            if self.#ident.is_none() {
                let err = format!("{} field missing", stringify!(#ident));
                return std::result::Result::Err(err.into())
            }
        });
    }

    let mut fill_result_clauses = Vec::new();
    for idx in 0..idents.len() {
        let ident = idents[idx];
        fill_result_clauses.push(quote!{
            ident: self.#ident.clone().unwrap()
        });
    }


    let token_stream = quote!{
        pub fn build(&mut self) -> std::result::Result<#origin_struct_ident, std::boxed::Box<dyn std::error::Error>> {
            (#checker_code_pieces)*
                               //  ^--注意,由于我们要重复的是一组if判断代码块,它们之间不需要用逗号分隔,所以这里的重复模式是`*`,而不是之前重复结构体字段时用到的`,*`
            let ret = #origin_struct_ident{
                (#fill_result_clauses),*
            };
            std::result::Result::Ok(ret)
        }
    };
    Ok(token_stream)
}
}

对do_expand()的修改如下:


#![allow(unused)]
fn main() {
fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let struct_name_literal = st.ident.to_string();
    let builder_name_literal = format!("{}Builder", struct_name_literal);
    let builder_name_ident = syn::Ident::new(&builder_name_literal, st.span());

    let struct_ident = &st.ident;

    let fields = get_fields_from_derive_input(st)?;
    let builder_struct_fields_def = generate_builder_struct_fields_def(fields)?;
    let builder_struct_factory_init_clauses = generate_builder_struct_factory_init_clauses(fields)?;
    let setter_functions = generate_setter_functions(fields)?;
    // 下面这一行是第四关新加的
    let generated_builder_functions = generate_build_function(fields,struct_ident)?;

    let ret = quote! {
        pub struct #builder_name_ident {
            builder_struct_fields_def
        }
        impl #struct_ident {
            pub fn builder() -> #builder_name_ident {
                builder_name_ident{
                    (#builder_struct_factory_init_clauses),*
                }
            }
        }
        impl #builder_name_ident {
            setter_functions
            // 下面这1行是第四关新加的
            generated_builder_functions
        }
    };

    return Ok(ret);
}
}

第五关

看一下题目要求,好开心,我们的代码已经实现了第五关的所有功能,什么都不用做了~

第六关

第六关视频版本:

这一关的题目要求比较长,在其中介绍了一个rust过程宏机制的缺陷:rust的引用消解,或者说是符号解析,是在宏展开之后进行的,这也就是说,例如有如下代码:

use std::any::TypeId;

pub mod blog {
    pub mod ideawand {
        pub mod com {
            pub struct GeekKindergarten{}
        }
    }
}

use blog::ideawand::com::GeekKindergarten;
use blog::ideawand::com::GeekKindergarten as MyPersonalBlog;

fn main() {
    assert!(TypeId::of::<GeekKindergarten>() == TypeId::of::<blog::ideawand::com::GeekKindergarten>());
    assert!(TypeId::of::<MyPersonalBlog>() == TypeId::of::<blog::ideawand::com::GeekKindergarten>());
}

正常来说,上面代码中的GeekKindergarten类型,可以用完整的路径名来表示,也可以通过use关键字将其导入到当前上下文中,使用短名字来表示,还可以通过as关键字为其重命名,但它们都是同一个类型;然而在过程宏中,我们看到的TokenStream也好,还是TokenStream解析出的syn包中定义的语法树节点类型也好,它们都还没有经过引用消解,所以在rust的过程宏中,GeekKindergartenblog::ideawand::com::GeekKindergarten以及MyPersonalBlog是不同的,或者说,我们无法判断他们是不是相同的类型。

第六关的题目是说,我们要识别出用户给出的结构体中,类型为Option类型的字段,对于这些字段,我们允许他们为None,也就是说要放宽第四关中builder方法的校验规则。为了简化这个题目,题目要求我们只要识别Option类型即可,不考虑通过as关键词重命名导致的不可识别问题,也不考虑其他同名的Option的情况。同时,第六关的提示中给出了我们要匹配Option枚举和泛型时可能用到的模式,如下所示,我们要查看结构体的某个字段是不是满足这个这个模式:


#![allow(unused)]
fn main() {
Type::Path(             // ------------------------------------------- 这个节点表示形如 `std::option::Option<Vec<String>>`这个整体
    TypePath {  
        qself: None,
        path: Path {        // --------------------------------------- 到这个节点其实也还是表示 `std::option::Option<Vec<String>>`这个整体
            segments: [           // --------------------------------- 这个列表表示 [std, option, Option<Vec<String>>]  这三个独立的段
                PathSegment {           // --------------------------- 这个结构体表示每一段的具体内容,我们以最后一段`Option<Vec<String>>`为例
                    ident: "Option",           // -------------------- 这里表示Option这个标识符
                    arguments: PathArguments::AngleBracketed(       // 这个节点表示<Vec<String>>
                        AngleBracketedGenericArguments {
                            args: [                                 // 由于泛型参数可能是<T,U,V>等多个,所以这里是一个列表
                                GenericArgument::Type(              // 在上面的例子中,这个节点表示`Vec<String>`
                                    ...                             // 这里可以将`Vec<String>`按照上面的套路继续展开。。。
                                ),
                            ],
                        },
                    ),
                },
            ],
        },
    },
)
}

有了上面的模式参考,我们来写一个新的函数,用来识别上面的模式,如果一个type能匹配上面的模式,就把Option里面的泛型参数返回出来,否则返回一个None


#![allow(unused)]
fn main() {
fn get_optional_inner_type(ty: &syn::Type) -> Option<&syn::Type> {
    if let syn::Type::Path(syn::TypePath { ref path, .. }) = ty {
        // 这里我们取segments的最后一节来判断是不是`Option<T>`,这样如果用户写的是`std:option:Option<T>`我们也能识别出最后的`Option<T>`
        if let Some(seg) = path.segments.last() {
            if seg.ident == "Option" {
                if let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
                    ref args,
                    ..
                }) = seg.arguments
                {
                    if let Some(syn::GenericArgument::Type(inner_ty)) = args.first() {
                        return Some(inner_ty);
                    }
                }
            }
        }
    }
    None
}
}

在能够判断一个field是否为Option以后,我们还需要对之前的几个函数都做出相应的调整,因此想通过这一关,改动还是比较大的,主要的改动包括:

  • 在创建builder对应的结构体时,Option类型字段要特殊处理,如果还按照原来的模板来生成,就会产生出Option<Option<T>>这种类型
  • Option<T>类型字段的setter接受的类型应该是T,如果使用原来的模板,会拼接出来Option<T>
  • builder方法对于Option<T>字段的校验规则和其他字段不同

下面我们来一个个修改,首先是修改generate_builder_struct_fields_def()这个函数,修改后如下:


#![allow(unused)]
fn main() {
fn generate_builder_struct_fields_def(
    fields: &StructFields,
) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    // 第六关,对types 变量的构建逻辑进行了调整
    let types: Vec<_> = fields
        .iter()
        .map(|f| {
            // 针对是否为`Option`类型字段,产生不同的结果
            if let Some(inner_ty) = get_optional_inner_type(&f.ty) {
                quote!(std::option::Option<#inner_ty>)
            } else {
                let origin_ty = &f.ty;
                quote!(std::option::Option<#origin_ty>)
            }
        })
        .collect();

    let token_stream = quote! {
        // 下面这一行,也做了修改
        (#idents: #types),*
    };
    Ok(token_stream)
}
}

然后修改generate_setter_functions()函数


#![allow(unused)]
fn main() {
fn generate_setter_functions(fields: &StructFields) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    let types: Vec<_> = fields.iter().map(|f| &f.ty).collect();

    let mut final_tokenstream = proc_macro2::TokenStream::new();

    for (ident, type_) in idents.iter().zip(types.iter()) {
        let tokenstream_piece;
        // 第六关,对tokenstream_piece 变量的构建逻辑进行了调整
        if let Some(inner_ty) = get_optional_inner_type(type_) {
            tokenstream_piece = quote! {
                fn #ident(&mut self, #ident: #inner_ty) -> &mut Self {
                    self.#ident = std::option::Option::Some(#ident);
                    self
                }
            };
        } else {
            tokenstream_piece = quote! {
                fn #ident(&mut self, #ident: #type_) -> &mut Self {
                    self.#ident = std::option::Option::Some(#ident);
                    self
                }
            };
        }
        final_tokenstream.extend(tokenstream_piece);
    }

    Ok(final_tokenstream)
}
}

最后,我们对generate_build_function()函数进行修改:


#![allow(unused)]
fn main() {
fn generate_build_function(
    fields: &StructFields,
    origin_struct_ident: &syn::Ident,
) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    // 下面这一行是第六关新加的,之前没用到type相关信息,就没写下面这一行
    let types: Vec<_> = fields.iter().map(|f| &f.ty).collect();

    let mut checker_code_pieces = Vec::new();
    for idx in 0..idents.len() {
        let ident = idents[idx];
        // 第六关修改,只对不是`Option`类型的字段生成校验逻辑
        if get_optional_inner_type(&types[idx]).is_none() {
            checker_code_pieces.push(quote! {
                if self.#ident.is_none() {
                    let err = format!("{} field missing", stringify!(#ident));
                    return std::result::Result::Err(err.into())
                }
            });
        }
    }

    let mut fill_result_clauses = Vec::new();
    for idx in 0..idents.len() {
        let ident = idents[idx];
        // 这里需要区分`Option`类型字段和非`Option`类型字段
        if get_optional_inner_type(&types[idx]).is_none() {
            fill_result_clauses.push(quote! {
                ident: self.#ident.clone().unwrap()
            });
        }else {
            fill_result_clauses.push(quote! {
                ident: self.#ident.clone()
            });
        }
    }

    let token_stream = quote! {
        pub fn build(&mut self) -> std::result::Result<#origin_struct_ident, std::boxed::Box<dyn std::error::Error>> {
            (#checker_code_pieces)*
            let ret = #origin_struct_ident{
                (#fill_result_clauses),*
            };
            std::result::Result::Ok(ret)
        }
    };
    Ok(token_stream)
}
     
}

第七关

第七关视频版本:

这一关会涉及到如何解析结构体中字段附加的属性信息(field attribute),例如下面这个结构体


#![allow(unused)]
fn main() {
#[derive(Builder)]
struct ArticleAuthor {
    Blog String,
    #[geek_kindergarten(foo=bar)]
    IdeaWand String,
    Com String,
}
}

在这个结构体中,#[geek_kindergarten(foo=bar)]这一行就是附加在IdeaWand这个字段上的属性,属性的名字是geek_kindergarten,这里需要注意的一个重点是:

  • 在派生宏中,上面这样的属性称为惰性属性(inert attributes),惰性属性必须指定关联一个派生过程宏,限定这个惰性属性只能在这个过程宏内使用。 那么,如何向编译器注册一个惰性属性的名字呢?在第七关的指导文档里有说明,简单来说,就是要在#[proc_macro_derive(Builder)]这个标签中添加上属性的名字,加入我们要加入一个名为geek_kindergarten的属性,那么就要这样写:#[proc_macro_derive(Builder, attributes(geek_kindergarten))]

接下来的操作,和前几关大致一样,我们先来把要做的事情列一下:

  • 解析每个field的attrs字段,它是一个Vec<Attribute>类型,Attribute是一个我们前面没遇到过的语法树节点,我们的处理思路还是一样的,需要通过文档 + Print大法来搞清楚里面的结构
  • 第六关中我们识别了Option<T>这种模式的代码,本关我们要识别Vec<T>,稍微调整一下,复用之前代码即可
  • 针对是不是Vec<T>类型,以及是不是有属性标签,我们要用不同的模板来产生代码

下面,我们先来看看怎么解析Attribute类型的语法树节点,由于这个属性标签是一个挺通用的解析需求,而且还比较灵活复杂,所以我们就多花点时间来介绍一下。为了探索他的行为,我们创建另一个极简版本的派生宏,专门用来打印Attribute结构的信息。同时,Attribute不一定是要写在结构体字段上的,我们也可以用一个属性来装饰整个结构体,所以DeriveInput语法树节点中就有attrs属性,因此我在接下来的小实验中,就不再去层层解析寻找字段上的属性了,直接用结构体的属性标签来做实验,效果都是一样的。下面,我们来搭建这个小的实验环境:

  • proc-macro-workshop/builder/src/lib.rs中,添加如下代码:
    
    #![allow(unused)]
    fn main() {
    #[proc_macro_derive(ExploreAttribute)]
    pub fn attribute_explore(input: TokenStream) -> TokenStream {
        let st = syn::parse_macro_input!(input as syn::DeriveInput);
        let attr = st.attrs.first().unwrap();
        eprintln!("{:#?}", attr);
        proc_macro2::TokenStream::new().into()
    }
    }
    
  • proc-macro-workshop/main.rs中的内容清空,替换为下面的代码:
    
    #![allow(unused)]
    fn main() {
    use derive_builder::ExploreAttribute;
    
    #[derive(ExploreAttribute)]
    #[blog::ideawand::com(Bar)]
    pub struct Foo{}
    }
    

proc-macro-workshop目录下执行cargo check,观察输出结果,对于上面的例子,我们得到的输出为:


#![allow(unused)]
fn main() {
Attribute {
    pound_token: Pound,
    style: Outer,
    bracket_token: Bracket,
    path: Path {
        leading_colon: None,
        segments: [
            PathSegment {
                ident: Ident {
                    ident: "blog",
                    span: #0 bytes(1383..1387),
                },
                arguments: None,
            },
            Colon2,
            PathSegment {
                ident: Ident {
                    ident: "ideawand",
                    span: #0 bytes(1389..1397),
                },
                arguments: None,
            },
            Colon2,
            PathSegment {
                ident: Ident {
                    ident: "com",
                    span: #0 bytes(1399..1402),
                },
                arguments: None,
            },
        ],
    },
    tokens: TokenStream [
        Group {
            delimiter: Parenthesis,
            stream: TokenStream [
                Ident {
                    ident: "Bar",
                    span: #0 bytes(1403..1406),
                },
            ],
            span: #0 bytes(1402..1407),
        },
    ],
}
}

简单解释一下,就是这样的:

#[blog::ideawand::com(Bar)]
^ ^^^^^^^^^^^^^^^^^^^ ^^^
|         |            |
|         |            +--这里对应的是`syn::Attribute`节点的`tokens`属性,它的类型是`proc_macro2::TokenStream`
|         +--这里对应的是`syn::Attribute`节点的`path`属性,它的类型是`syn::Path`,这个语法树节点我们在前面已经见到过了
+--这里对应`syn::Attribute`节点的`style`属性,`#`现在对应到的是`outer`,如果是`#!`对应处的就是`inner`

上面的stylepath两个字段都没什么要说的,好玩并且复杂的事情是tokens这个属性,我们可以看到,这个属性的类型并没有在syn包中定义对应的语法树节点,这是为什么呢?查看官方文档,我们可以了解到其背后的原因是:

  • Rust 属性的书写语法非常灵活,甚至可以不是有效的Rust标准语法,例如包含用户自定义的语法规则,因此,syn包没法通过有限的语法树节点类型来表示它的内容
  • 之所以要为syn::Attribute节点设置一个path部分,是可以把path作为一个类型提示,过程宏或者编译器可以根据path来决定如何解析后面的token部分
  • 如果在你的自定义属性中,要求属性是符合标准Rust语法的,那么syn::Attribute节点提供了一个parse_meta()方法,可以将整个属性(也就是pathtoken两部分)中的内容解析为syn::Meta枚举类型,而这个枚举类型的内容又十分丰富。

syn::Meta枚举各个成员的含义:

  • syn::Meta::Path表示一个路径,下面这些形式的都可以认为是路径:
    • #[A]中的A,它只有一个小节,没有::分割的多个小节,但它也是一个Path
    • #[A::B::C]中的A::B::C
  • syn::Meta::List表示一个列表,这个列表必须由一个前置路径和一个括号标记组成,括号里面通过逗号分隔为多个条目,组成一个列表,列表中的每一个条目又是一个syn::Meta枚举
    • #[Foo(AAA,BBB,CCC)]中的Foo是前置路径,后面的AAABBBCCC就是三个列表项,它们三个都是syn::Meta类型的枚举
      • 从形式上可以看出,这三个枚举类型实际上存放的又是三个syn::Path类型的数据
    • #[Foo(AAA,BBB(CCC,DDD))] 这种形式,演示了列表中每个项目是可以嵌套的,其中的BBB是嵌套内层syn::Meta::List的前置路径,而CCCDDD是内层的列表项
  • syn::Meta::NameValue表示一个kv对,其中key部分是一个syn::Path,而value部分是一个字符串字面量
    • #[xxx = "yyy"]就是一个典型的例子

通过上面的嵌套规则可以看出,Rust允许你书写的属性规则是非常灵活的,例如你可以书写这样的一个属性,你可以尝试解释一下下面这个属性的嵌套层级:

#[Foo::Bar(A1::A2(B1::B2="C",D="E",F1::F2,G,H(I,J)))]

大家可以通过修改proc-macro-workshop/main.rs中的代码来尝试各种不同形式的Attribute是如何被解析为syn::Meta类型的枚举的,当然,需要修改一下proc-macro-workshop/builder/src/lib.rs的测试派生宏的定义代码如下:


#![allow(unused)]
fn main() {
#[proc_macro_derive(ExploreAttribute)]
pub fn attribute_explore(input: TokenStream) -> TokenStream {
    let st = syn::parse_macro_input!(input as syn::DeriveInput);
    let attr = st.attrs.first().unwrap();
    let meta = attr.parse_meta(); // 解析为`sny::Meta`对象
    eprintln!("{:#?}", meta);
    proc_macro2::TokenStream::new().into()
}
}

现在我们回归正题,看一下第七关的题目,我们要解析形如#[builder(each = "arg")]的惰性属性,分解一下,首先这是一个syn::Meta::List枚举成员,它的前置路径是builder,后面的列表里有一个syn::Meta::NameValue枚举成员,在这个kv对中,each是固定的,而后面的值,我们要把它当做一个ident来使用,拼接到生成的代码中。开始撸代码:


#![allow(unused)]
fn main() {
fn get_user_specified_ident_for_vec(field: &syn::Field) -> Option<syn::Ident> {
    for attr in &field.attrs {
        if let Ok(syn::Meta::List(syn::MetaList {
            ref path,
            ref nested,
            ..
        })) = attr.parse_meta()
        {
            if let Some(p) = path.segments.first() {
                if p.ident == "builder" {
                    if let Some(syn::NestedMeta::Meta(syn::Meta::NameValue(kv))) = nested.first() {
                        if kv.path.is_ident("each") {
                            if let syn::Lit::Str(ref ident_str) = kv.lit {
                                return Some(syn::Ident::new(
                                    ident_str.value().as_str(),
                                    attr.span(),
                                ));
                            }
                        }
                    }
                }
            }
        }
    }
    None
}
}

解析Attribute的工作完成了,但为了使用上面的函数我们还得费一番脑筋,做出不少修改,让我们来仔细看看第七关题目给出的要求:

  • 我们需要兼容指定了each属性的字段和没有指定each属性的字段:

    • 如果指定了each属性,又分为两种情况:
      • 如果each指定的名字和原始字段名一致,则只产生出每次追加一个值的setter函数
      • 如果each指定的名字和原始字段名不一致,则需要产生两个版本的函数:
        • 与原始字段名同名的函数用于接收一个完整列表
        • 用户指定名称的setter,每次只接收一个条目
      • Vec字段和Option字段一样,不需要做为空的检查,对于Vec字段,我们要将其自动初始化
    • 如果没指定each属性,则可以把这个字段看做一个普通类型对待,前六关的测试用例还得通过才行
  • 为了实现上面的需求,我们之前每一个函数几乎都要修改。在前四关中,我给出了三种拼接TokenStream的方法,在这次大改造中你会发现:

    • 对于一些简单的拼接,使用迭代器和quote!宏的重复展开功能,几行简短的代码就可以优雅地实现拼接(第一关和第二关的写法),但对于复杂的需求,写起来就会很别扭
    • 对于有比较复杂逻辑的生成业务,自己维护一个Vec<TokenStream>数组,或者自己去extend一个TokenStream会更加灵活顺手(也就是第三关和第四关的写法)

有了上面的大方向,我们开始继续撸代码。

第一个修改点是,因为我们这次不仅要获取Option<T>的内部类型,还有处理Vec<T>,所以我们来修改一下之前的get_optional_inner_type()函数,使其能够被复用,我们将其重命名为get_generic_inner_type(),新的定义如下,修改完成后,需要调整一下之前调用它的代码:


#![allow(unused)]
fn main() {
fn get_generic_inner_type<'a>(ty: &'a syn::Type, outer_ident_name: &str) -> Option<&'a syn::Type> {
    if let syn::Type::Path(syn::TypePath { ref path, .. }) = ty {
        // 这里我们取segments的最后一节来判断是不是`T<U>`,这样如果用户写的是`foo:bar::T<U>`我们也能识别出最后的`T<U>`
        if let Some(seg) = path.segments.last() {
            if seg.ident == outer_ident_name {
                if let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
                    ref args,
                    ..
                }) = seg.arguments
                {
                    if let Some(syn::GenericArgument::Type(inner_ty)) = args.first() {
                        return Some(inner_ty);
                    }
                }
            }
        }
    }
    None
}
}

接下来一个个调整之前编写的函数,首先是generate_builder_struct_fields_def()函数,修改点在注释中:


#![allow(unused)]
fn main() {
fn generate_builder_struct_fields_def(
    fields: &StructFields,
) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    let types: Vec<_> = fields
        .iter()
        .map(|f| {
            if let Some(inner_ty) = get_generic_inner_type(&f.ty,"Option") {
                quote!(std::option::Option<#inner_ty>)
            // 以下三行是第七关新加入的
            } else if get_user_specified_ident_for_vec(f).is_some() {
                let origin_ty = &f.ty;
                quote!(#origin_ty)  // 题目中设定,如果用户指定了each属性,我们就可以认为它一定是作用在一个Vec字段上

            } else {
                let origin_ty = &f.ty;
                quote!(std::option::Option<#origin_ty>)
            }
        })
        .collect();

    let token_stream = quote! {
        (#idents: #types),*
    };
    Ok(token_stream)
}
}

然后是初始化函数:


#![allow(unused)]
fn main() {
fn generate_builder_struct_factory_init_clauses(fields: &StructFields) -> syn::Result<Vec<proc_macro2::TokenStream>>{
    let init_clauses: Vec<_> = fields.iter().map(|f| {
        let ident = &f.ident;
        // 下面这个if分支是第七关加入的,在第六关的时候只有else分支里的代码
        if get_user_specified_ident_for_vec(f).is_some() {
            quote!{
                ident: std::vec::Vec::new()  //指定了each属性的Vec需要初始化
            }
        } else {
            quote!{
                ident: std::option::Option::None
            }
        }
    }).collect();

    Ok(init_clauses)
}
}

然后是setter的生成:


#![allow(unused)]
fn main() {
fn generate_setter_functions(fields: &StructFields) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    let types: Vec<_> = fields.iter().map(|f| &f.ty).collect();

    let mut final_tokenstream = proc_macro2::TokenStream::new();

    // 第七关修改,这里之前用了zip串联了两个迭代器,现在需要拿到对应的原始field,所以又加了一层`enumerate()`迭代器
    // 这里写成 for idx in 0..fields.len() {let ident = &fields[idx].ident; let type_ = &fields[idx].ty;...} 这种写法或许更优雅一些
    for (idx,(ident, type_)) in idents.iter().zip(types.iter()).enumerate() {
        let mut tokenstream_piece;
        if let Some(inner_ty) = get_generic_inner_type(type_,"Option") {
            tokenstream_piece = quote! {
                fn #ident(&mut self, #ident: #inner_ty) -> &mut Self {
                    self.#ident = std::option::Option::Some(#ident);
                    self
                }
            };
        
        // 下面这个分支是第七关加入的
        } else if let Some(ref user_specified_ident) = get_user_specified_ident_for_vec(&fields[idx]) {
            let inner_ty = get_generic_inner_type(type_,"Vec").ok_or(syn::Error::new(fields[idx].span(),"each field must be specified with Vec field"))?;
            tokenstream_piece = quote! {
                fn #user_specified_ident(&mut self, #user_specified_ident: #inner_ty) -> &mut Self {
                    self.#ident.push(#user_specified_ident);
                    self
                }
            };
            // 如果用户指定的setter名字和原始字段的名字不一样,那么产生另一个setter,这个setter是一次性传入一个列表的
            if user_specified_ident != ident.as_ref().unwrap() {
                tokenstream_piece.extend(
                    quote! {
                        fn #ident(&mut self, #ident: #type_) -> &mut Self {
                            self.#ident = #ident.clone();
                            self
                        }
                    }
                );
            }
        } else {
            tokenstream_piece = quote! {
                fn #ident(&mut self, #ident: #type_) -> &mut Self {
                    self.#ident = std::option::Option::Some(#ident);
                    self
                }
            };
        }
        final_tokenstream.extend(tokenstream_piece);
    }

    Ok(final_tokenstream)
}
}

最后是我们的build()方法:


#![allow(unused)]
fn main() {
fn generate_build_function(
    fields: &StructFields,
    origin_struct_ident: &syn::Ident,
) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    let types: Vec<_> = fields.iter().map(|f| &f.ty).collect();

    let mut checker_code_pieces = Vec::new();
    for idx in 0..idents.len() {
        let ident = idents[idx];
        // 第七关修改,只对不是`Option`类型且没有指定each属性的字段生成校验逻辑
        if get_generic_inner_type(&types[idx],"Option").is_none() && get_user_specified_ident_for_vec(&fields[idx]).is_none() {
            checker_code_pieces.push(quote! {
                if self.#ident.is_none() {
                    let err = format!("{} field missing", stringify!(#ident));
                    return std::result::Result::Err(err.into())
                }
            });
        }
    }

    let mut fill_result_clauses = Vec::new();
    for idx in 0..idents.len() {
        let ident = idents[idx];
        // 第七关,这里需要判断是否有each属性。第一个分支是本关加入的。注意这里几个分支的先后判断顺序
        // 看我写在这里的代码可能没什么感觉,但如果是自己写的话,这几个分支的判断先后顺序是很重要的,否则可能生成出有问题的代码
        // 这里主要的问题是梳理清楚【是否有each属性】和【是否为Option类型】这两个条件的覆盖范围
        if get_user_specified_ident_for_vec(&fields[idx]).is_some() {
            fill_result_clauses.push(quote! {
                ident: self.#ident.clone()
            });
        } else if get_generic_inner_type(&types[idx],"Option").is_none() {
            fill_result_clauses.push(quote! {
                ident: self.#ident.clone().unwrap()
            });
        } else {
            fill_result_clauses.push(quote! {
                ident: self.#ident.clone()
            });
        }
    }

    let token_stream = quote! {
        pub fn build(&mut self) -> std::result::Result<#origin_struct_ident, std::boxed::Box<dyn std::error::Error>> {
            (#checker_code_pieces)*
            let ret = #origin_struct_ident{
                (#fill_result_clauses),*
            };
            std::result::Result::Ok(ret)
        }
    };
    Ok(token_stream)
}
}

大功告成

第八关

第八、九关视频版本:

这一关是一个如何打印友好错误信息的挑战,完成这个挑战时,因为会拿编译器输出的结果做字符串匹配比较,所以我们要保证代码的干净,清除掉所有的未引入引用等Warning

首先把错误产生出来,我们需要修改get_user_specified_ident_for_vec()函数,让他返回一个syn::Result


#![allow(unused)]
fn main() {
fn get_user_specified_ident_for_vec(field: &syn::Field) -> syn::Result<Option<syn::Ident>> {
    for attr in &field.attrs {
        if let Ok(syn::Meta::List(syn::MetaList {
            ref path,
            ref nested,
            ..
        })) = attr.parse_meta()
        {
            if let Some(p) = path.segments.first() {
                if p.ident == "builder" {
                    if let Some(syn::NestedMeta::Meta(syn::Meta::NameValue(kv))) = nested.first() {
                        if kv.path.is_ident("each") {
                            if let syn::Lit::Str(ref ident_str) = kv.lit {
                                return Ok(Some(syn::Ident::new(
                                    ident_str.value().as_str(),
                                    attr.span(),
                                )));
                            }
                        } else {
                            // 第八关加入,注意这里new_spanned函数的参数,我们需要在语法树中找到一个合适的节点来获取它的span,如果这个语法树节点找的不对,产生出的错误信息就会不一样
                            if let Ok(syn::Meta::List(ref list)) = attr.parse_meta() {
                                return Err(syn::Error::new_spanned(list, r#"expected `builder(each = "...")`"#))
                            }
                        }
                    }
                } 
            }
        }
    }
    Ok(None)
}
}

注意上面的代码,我们需要找到一个合适的语法树节点来产生错误的位置,例如,上面代码我们使用了语法树中的syn::MetaList节点,产生的报错信息是这样的:

error: expected `builder(each = "...")`
  --> $DIR/08-unrecognized-attribute.rs:22:7
   |
22 |     #[builder(eac = "arg")]
   |       ^^^^^^^^^^^^^^^^^^^^

而如果将上面new_spanned函数的第一个传入参数从list改为attr,即我们使用了syn::Attribute这个语法树节点的时候,给出的错误信息就会是这样的,注意其中^符号数量和位置的变化:

error: expected `builder(each = "...")`
  --> $DIR/08-unrecognized-attribute.rs:22:5
   |
22 |     #[builder(eac = "arg")]
   |     ^^^^^^^^^^^^^^^^^^^^^^^

可以返回Result以后,对这个函数的调用部分也需要改一下,对于普通的调用,我们直接在后面加一个?就好了,但是有两个函数是在迭代器map的闭包里调用了,这样改起来就有些麻烦了,这也再一次印证了上面的观点:对于复杂的过程宏,少用迭代器的map方法,自己构建循环展开可控性更好!

首先是generate_builder_struct_fields_def()函数,可以参考注释:


#![allow(unused)]
fn main() {
fn generate_builder_struct_fields_def(
    fields: &StructFields,
) -> syn::Result<proc_macro2::TokenStream> {
    let idents: Vec<_> = fields.iter().map(|f| &f.ident).collect();
    // 第八关修改,从这里又可以看出,对于复杂的过程宏,采用迭代器比较麻烦,返回一个错误要费一些周折
    // 这里修改了map中闭包的返回值,使其返回一个syn::Result<T>
    let types: syn::Result<Vec<proc_macro2::TokenStream>> = fields
        .iter()
        .map(|f| {
            if let Some(inner_ty) = get_generic_inner_type(&f.ty,"Option") {
                Ok(quote!(std::option::Option<#inner_ty>))
            } else if get_user_specified_ident_for_vec(f)?.is_some() {
                let origin_ty = &f.ty;
                Ok(quote!(#origin_ty)) 

            } else {
                let origin_ty = &f.ty;
               Ok(quote!(std::option::Option<#origin_ty>))
            }
        })
        .collect();
    
    let types = types?;
    let token_stream = quote! {
        (#idents: #types),*
    };
    Ok(token_stream)
}
}

然后是generate_builder_struct_factory_init_clauses()函数:


#![allow(unused)]
fn main() {
fn generate_builder_struct_factory_init_clauses(fields: &StructFields) -> syn::Result<Vec<proc_macro2::TokenStream>>{
    // 第八关修改,从闭包中返回错误信息
    let init_clauses: syn::Result<Vec<proc_macro2::TokenStream>> = fields.iter().map(|f| {
        let ident = &f.ident;
        if get_user_specified_ident_for_vec(f)?.is_some() {
            Ok(quote!{
                ident: std::vec::Vec::new()  
            })
        } else {
            Ok(quote!{
                ident: std::option::Option::None
            })
        }
    }).collect();

    Ok(init_clauses?)
}
}

终于,大功告成!

第九关

哈哈哈,由于我们之前已经严格遵守了规定,第九关自然就通过了。不过,关于第九关的知识点,我还是要给大家再明确一下:

  • 我们说的要使用绝对路径,是在生成的代码中要使用绝对路径
  • 过程宏本身的代码,也就是我们用来生成代码的代码,里面还是可以直接使用短名字的
  • 过程宏代码,和过程宏生成的代码,编译和运行在两个完全独立的时间以及空间上,请大家一定要想清楚~

作者简介:

米明恒,高级Golang、Python开发工程师,初级架构师,业余无线电爱好者(呼号BG2CCF),Rust业余爱好者。业余时间各种不务正业,研究奇怪的东西。

  • 个人博客: http://blog.ideawand.com
  • 微信公众号: 【极客幼稚园】

在 Android 中运行 Rust

译者:iamazy


原文 /

为了我目前的一位客户,我们决定将 Rust 作为我们主要的编程语言。做出这个决定的原因有很多:除了技术优势外,还有一个无可争议的事实就是:Rust 仍然是一门相对较新的语言,花哨且时髦 - 当你是一家初创公司时,使用十几年前的技术可能会让你陷入困境。我的意思是 - 不使用创新的技术如何进行创新?最快的成功方式就是对其大肆宣传。

”用户持有自己的数据“应该是产品的一个卖点,它不能是一个完全通过浏览器访问的服务,而应该是一种可以分发给用户,并让其运行在用户设备上的某个东西。我们在内部已经运行了一些 headless (一种无界面的软件运行模式) 实例,只要再完成一些琐碎的工作,就可以为 Windows 和 Linux 系统制作可重新分发的程序包。但是我们知道如果程序包只能运行在桌面操作系统中时,将会严重阻碍应用的普及 - 如果我们想让它脱颖而出,则需要该应用程序的移动版本。这意味着我们必须要知道如何让我们的程序运行在 Android 或者 iOS 系统中。因为我对交叉编译与自动化构建已经有了一些经验,因此我主动的研究了这个主题。

获取工具

先从基础开始,我需要获取 Rust 交叉编译器。幸运的是,Rust 让此操作变得十分简单,因为只需要调用以下命令:

$ rustup target add armv7-linux-androideabi  # For 32-bit ARM.
$ rustup target add aarch64-linux-android    # For 64-bit ARM.

# x86_64 is mainly useful for running your app in the emulator.
# Speaking of hardware, there are some commercial x86-based tablets,
# and there's also hobbyists running Android-x86 on their laptops.
$ rustup target add x86_64-linux-android

(注意:以后只会显示 aarch64 架构的所有示例)

我还需要 Android 的构建工具。在经过一番调研之后,我来到 Android Studio 的下载页面 并抓取了归档的命令行工具。尽管 SDK 包有 80+ MiB 大小,却依然只有所需工具的最小子集,所以我听从了互联网的建议并使用sdkmanager来安装额外的部件。

$ cd ~/android/sdk/cmdline-tools/bin/
$ ./sdkmanager --sdk_root="${HOME}/android/sdk" --install 'build-tools;29.0.2'
$ ./sdkmanager --sdk_root="${HOME}/android/sdk" --install 'cmdline-tools;latest'
$ ./sdkmanager --sdk_root="${HOME}/android/sdk" --install 'platform-tools'
$ ./sdkmanager --sdk_root="${HOME}/android/sdk" --install 'platforms;android-29'

尽管 Android 支持运行 native 代码,但是大多数应用还是采用 Java 或者 Kotlin 来编写,SDK 反应了这一点。为了能够使用 native 代码,我还需要一个工具 - 原生开发工具套件 (Native Development kit)。NDK 下载页面 提供了几个版本以供选择 - 在经过一段深思熟虑后,我决定使用 LTS 版本:r21e。

足够简单!或想太多?

搞定了开发工具之后,我决定试着直接编译项目。

$ cargo build --target=aarch64-linux-android

和预期的一样,构建失败了,并且错误信息占满了整个屏幕。经过筛选后,显示存在一个链接错误:

error: linking with `cc` failed: exit code: 1
/usr/bin/ld: startup.48656c6c6f20546865726521.o: Relocations in generic ELF (EM: 183)
/usr/bin/ld: startup.48656c6c6f20546865726521.o: error adding symbols: file in wrong format
collect2: error: ld returned 1 exit status

我认为这(错误提示)足够简单 - Cargo 试图使用系统的链接器而不是 Android NDK 的链接器。我可以使用CCLD环境变量让 Cargo 指向正确的链接器。

$ export ANDROID_NDK_ROOT="${HOME}/android/ndk"
$ export TOOLCHAIN="${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64"
$ export CC="${TOOLCHAIN}/bin/aarch64-linux-android29-clang"
$ export LD="${TOOLCHAIN}/bin/aarch64-linux-android-ld"
$ cargo build --target=aarch64-linux-android

让我失望的是,这并没有起作用。我不愿意花费一天的时间来和 Cargo 纠缠,因此我决定寻找是否有其他人给出了解决方案 - 很快,我便找到看似十分完美的工具。

cargo-apk

cargo-apk 是一个可以简单的将 Cargo 项目构建成.apk的工具。你所需要做得就是安装这个工具,在Cargo.toml文件中添加一些配置,然后你就可以继续了。

# cargo-apk compiles your code to an .so file,
# which is then loaded by the Android runtime
[lib]
path = "src/main.rs"
crate-type = ["cdylib"]
 
# Android-specic configuration follows.
[package.metadata.android]
# Name of your APK as shown in the app drawer and in the app switcher
apk_label = "Hip Startup"
 
# The target Android API level.
target_sdk_version = 29
min_sdk_version = 26
 
# See: https://developer.android.com/guide/topics/manifest/activity-element#screen
orientation = "portrait"

有了上面添加的配置,我试图使用cargo-apk来构建项目。

$ cargo install cargo-apk
$ export ANDROID_SDK_ROOT="${HOME}/android/sdk"
$ export ANDROID_NDK_ROOT="${HOME}/android/ndk"
$ cargo apk build --target aarch64-linux-android

令人惊奇的是,它成功了!(等等) 额,好吧,我再一次遇到了链接错误。但是这一次,它不是关于重定位和文件格式的神秘错误,而是一个缺少链接库的错误提示:

error: linking with `aarch64-linux-android29-clang` failed: exit code: 1
    aarch64-linux-android/bin/ld: cannot find -lsqlite3
    clang: error: linker command failed with exit code 1 (use -v to see invocation)

依赖,依赖,依赖

我们的项目使用 SQLite,这是一个 C 库。尽管 Rust 社区在每个可能的场合都吹捧”用 Rust 重写“在某种程度上是臭名昭著的,但是实际上某些与流行库一起使用的 crate 并不需要重新实现,因为这需要大量的工作。相反,它们仅提供在 Rust 代码中调用库的方式,既可以作为 C 函数重新导出,也可以提供更加友好的 API 并稍微抽象化 FFI 调用。我们使用的 rusqlite 并没有什么不同,意味着我们也需要构建 SQLite。

SQLite 使用 GNU Autotool 进行构建。在对环境变量和用于配置的选项有了一些了解之后,我仔细浏览了 NDK 的文档 - 我找到了一个在各种构建系统(包括 Autotools) 中使用 NDK 的文档页面。尽管 Google 提供了 LTS 版本的 NDK,以及最新版本的文档,但在 r21 LTS 和最新的 r22 之间发生了变化,事情变得稍微有点棘手。幸运的是,Wayback 机器具有该页面的历史版本,让我能够找到合适的 NDK r21 的说明。

$ ANDROID_API=29
$ TOOLCHAIN="${ANDROID_NDK_ROOT}/toolchains/llvm/prebuilt/linux-x86_64"i
$ export CC="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang"
$ export CXX="${TOOLCHAIN}/bin/aarch64-linux-android${ANDROID_API}-clang++"
$ export AR="${TOOLCHAIN}/bin/aarch64-linux-android-ar"
$ export AS="${TOOLCHAIN}/bin/aarch64-linux-android-as"
$ export LD="${TOOLCHAIN}/bin/aarch64-linux-android-ld"
$ export RANLIB="${TOOLCHAIN}/bin/aarch64-linux-android-ranlib"
$ export STRIP="${TOOLCHAIN}/bin/aarch64-linux-android-strip"
$ ./configure --host=aarch64-linux-android --with-pic
$ make -j $(nproc)

Pick me up, Scotty

使用上述方法,成功构建了 SQLite,生成了libsqlite3.so。现在只需要知道如何让 Cargo 使用它即可。在浏览 Cargo Book 时,我遇到了讲述环境变量的一个章节,它提及了RUSTFLAGS。和 Make 或 CMake 对待CFLAGSCXXFLAGS一样,RUSTFLAGS的内容被 Cargo 传递给rustc编译器,允许它影响编译器的行为。

尽管这种方式十分简单,但是对我来说不是很优雅,因此我进一步深入研究了其他选项。继续浏览 Cargo Book,我遇到了描述项目配置的章节,可以肯定的是,有一种方法可以指定 RUSTFLAGS。然而,无论我如何尝试,我始终都会收到来自 Cargo 的提示,告诉我关于未使用的 manifest 键的信息。

warning: unused manifest key: target.aarch64-linux-android.rustflags

浏览 Cargo Book 的更多章节,我遇到了关于构建脚本的章节。它们毫无疑问是一个强大的工具,但是我已经花费了很多时间学习 Cargo 的配置,不想再花更多的时间阅读关于如何编写构建脚本的内容,因此,最终我选择了环境变量的解决方案,并且可能会在之后尝试使用构建脚本的方式(不可能)。

我在终端中输入命令,并焦急的观察它的执行过程。

$ RUSTFLAGS="-L $(pwd)/sqlite-autoconf-3340000/.libs/" cargo apk build --target aarch64-linux-android

再一次,它。。。在某种程度上成功了。虽然链接器不再将错误解释成缺失链接库,但是cargo-apk无法找到该链接器并将其添加到最终的 APK 文件中。

 'lib/arm64-v8a/libstartup.so'...
Shared library "libsqlite3.so" not found.
Verifying alignment of target/debug/apk/statup.apk (4)...
      49 AndroidManifest.xml (OK - compressed)
     997 lib/arm64-v8a/libstartup.so (OK - compressed)
Verification succesful

当我还没有编译libsqlite3.so时,我返回上一步仔细阅读了链接器产生的错误信息。链接器组合了很多的目标文件,这些目标文件都位于target/aarch64-linux-android/debug/deps目录下。如果我将.so文件放在这里会发生什么?

$ cp sqlite-autoconf-3340000/.libs/sqlite3.so target/aarch64-linux-android/debug/deps
$ cargo apk build --target aarch64-linux-android

让我惊讶的是,它成功了!

 'lib/arm64-v8a/libstartup.so'...
 'lib/arm64-v8a/libsqlite3.so'...
Verifying alignment of target/debug/apk/startup.apk (4)...
      49 AndroidManifest.xml (OK - compressed)
     997 lib/arm64-v8a/libstatup.so (OK - compressed)
15881608 lib/arm64-v8a/libsqlite3.so (OK - compressed)
Verification succesful

我现在有了一个可以安装在 Android 手机上的.apk文件。真是个巨大的成功!

应用 和 Activity

将 Rust 代码编译进.apk中后,我们剩下要做的就是要搞清楚如何将 Rust 代码与编写 UI 的 Java 代码合并。我天真的在 DuckDuckGo 中输入“如何组合 APK”。在阅读顶层几个结果后,明白了这明显是不可能的,至少在对 Android 应用的工作原理没有更深的了解的情况下是不可能的。但是,并不是说没有其他的方法,因为文章提出了另一种方法 - 将 Activity 组合到一个应用程序里。

如果你像我一样,之前从未开发过 Android,可能会疑惑“什么是 Activity”:当你设计一个应用时,它就是所谓的“界面”或者“视图”。例如,在购物应用中:

  • 登陆页面是一个 Activity
  • 产品搜索页面是一个 Activity
  • 所选产品的详情页面是一个 Activity
  • 购物车页面是一个 Activity
  • 结账页面是一个 Activity

这里的每个页面可能都包含一些交互元素,如无处不在的汉堡包菜单。如果你愿意,从理论上来讲,你可以将整个应用程序放在一个单独的 Activity 中,但是开发难度比较大。当然,关于 Activity 还有很多内容可以介绍,但是目前和我们要讲的内容关系不大。

让我们继续介绍有关 Rust 的内容。虽然我的问题的解决方案是将 Activity 组合到一个应用程序中,但是我不确定用 Rust 构建的.apk文件是如何与所有这些联系在一起的。在仔细研究了 cargo-apk 代码之后,我意识到它本质是将我的代码封装进一些胶水代码中,并为 Android 的运行创建 NativeActivity

为了将 Activity 组合进一个应用中,我需要修改应用程序的AndroidManifest.xml文件,在文档中添加合适的 Activity 节点。但是我应该如何知道cargo-apk生成的 NativeActivity 的属性呢?幸运的是,当cargo-apk工作时,它会生成一个最小版的AndroidManifest.xml文件,并将其放在生成的.apk旁边。其中 NativeActivity 的声明如下所示:

<activity
    android:name="android.app.NativeActivity"
    android:label="startup"
    android:screenOrientation="portrait"
    android:launchMode="standard"
    android:configChanges="orientation|keyboardHidden|screenSize">
    <meta-data android:name="android.app.lib_name" android:value="startup" />
    <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
    </intent-filter>
</activity>

我要做的就是将上面的代码片段复制并粘贴到 Java 应用程序的 manifest 中。

当然,这只是在应用的 manifest 中添加了一条语句,告诉应用将要包含哪些 Activity。Java 应用程序的构建过程不会知道libstartup.so文件的位置,并自动的将其包含在内。幸运的是,我只需要将库文件复制到指定的文件夹下即可,Gradle (Android 应用的构建工具) 会自动将它们采集起来。

$ mkdir -p android/app/src/main/jniLibs/arm64-v8a
$ cp sqlite-autoconf-3340000/.libs/libsqlite3.so android/app/src/main/jniLibs/arm64-v8a/
$ cp target/aarch64-linux-android/debug/libstatup.so android/app/src/main/jniLibs/arm64-v8a/
$ cd android/ && ./gradlew && ./gradlew build

这些都完成后,我启动了构建,它成功了!我将.apk安装在我闲置的 Android 设备中,但是...好像有哪里不太对劲呢!

two-launcher-activities

我的应用一旦安装成功后,会在应用的启动界面产生两个快捷方式。其中一个启动 Java 的 UI 界面,而另一个启动包含 Rust 代码的 NativeActivity。在阅读了更多关于 Activity 和 AndroidManifest 的内容后,我了解到,造成此问题的部分是 NativeActivity 的 - 即 category 节点声明应在启动器中显示它。一旦我将它移除,一切就会恢复正常,NativeActivity 不再显示在启动器中。

但是,仍然存在一个问题:我如何让 Java 的 Activity 要求 Rust 的 Activity 为其工作?

恶意的 Intent

Android 中的 Activity 可以毫无问题的相互启动 - 如果这不可能,则无法真正在两者之间传递用户信息。调用另一个 Activity 的标准方法是通过 startActivity() 方法,该方法接收一个参数:Intent 类实例。

尽管 Intent 类的名称是不言而喻的,但是起初它的用法可能有点不直观。在它最基本的形式中,它仅包含对调用 Activity 实例的引用,以及我们要调用的 Activity 的类句柄。(确切的说,一个 Intent 需要调用一个 Context。Activity 只是 Context 的一种类型)。

但是,Intent 也可以用于传达为什么一个 Activity 会调用另一个 Activity 的信息(例如 action),可以用来区分例如“显示某些内容”和“编辑某些内容”;或要操作的数据 URI 及其 MIME 类型。除了 get/set 方法,Intent 还可以容纳几乎任何数量的“额外”数据,这些数据通常作为键值对存储。

Intent 提供了一种在 Activity 之间传递信息的标准化方式。调用者向被调用者提供处理其请求所需的一切信息,并且它可以接收包含所有请求信息的另一个 Intent 作为返回值。使用 Java 编写代码时,没有什么问题,但是,将 Rust 代码放入 NativeActivity 会发生什么?

如果你查看继承树,你可以看到 NativeActivity 继承了 Activity - 这意味着它可以访问 Activity 所有非私有方法。我可以调用getIntent()并从调用者中获取数据。除此之外,由于这是 Java 方法,并且我是在 native 代码中运行,因此需要使用 JNI (Java Native Interface) 执行函数调用。不幸的是,NativeActivity 没有任何其他的机制来传递信息或使用 Intent。这让我十分沮丧,因为这意味着我必须要与 JNI 一起工作。

JNI 之旅

在这一点上,我花了太多时间却没有取得明显的成果,这让我感到十分沮丧。另一方面,我意识到使用 JNI 带来了一些新的可能 - 不必使用 Activity 和 Intent,我可以将代码粘贴在函数中,并通过调用参数和返回值进行通信。有了这个新思路,我开始了对 JNI 的研究。

因为在 Java 中,万物皆对象,并且代码不能存在于类之外的部分 - native 代码也必须是类的一部分。因为我不需要持久化,因此使用静态方法即可。

package com.startup.hip;
 
public class RustCode {
    public static native void doStuff();
}

上面是一个 Java 类的最小示例,其中带有一个标记为native的静态方法。有了这个,我需要实现相应的功能。但是我应该如何正确的使用函数签名呢?

幸运的是,Java 具有为 JNI 生成 C 语言头文件的功能。在 Java SE9 之前,它是一个独立的工具 - javah;后来,它作为-h选项合并到了主要的javac编译器可执行文件中。该选项需要一个目录参数,用来放置生成的.h文件。用法十分简单。

$ javac -h ./ RustCode.java

调用上面的命令将创建一个com_startup_hip_RustCode.h文件,其中包含函数定义。

#include <jni.h>
JNIEXPORT void JNICALL Java_com_startup_hip_RustCode_doStuff(JNIEnv *, jclass);

有了这些知识,我就可以继续在 Rust 中创建适当的函数了。

C++ 闪回

当处理外部代码时,Rust 和 C 很像,主要是使用 extern 块。此外,与 C++ 一样,Rust 可以使用 name mangling - 这不足为奇,因为这门语言对范型和宏提供了强大的支持。幸运的是,Rust 提供了一种简单的方式来禁用 name mangling - 使用 [#[no mangle]](https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html#calling-rust-functions-from-other-languages) 注解。


#![allow(unused)]
fn main() {
use jni::{objects::JClass, JNIEnv};
 
#[no_mangle]
pub extern "C" fn Java_com_startup_hip_RustCode_doStuff(
    _env: JNIEnv,
    _class: JClass,
) {}
}

创建了函数声明之后,接下来我需要编写对应的实现。

接收参数

通常,native 函数需要接收一些参数。在本例中,它是一个包含代码的字符串,该代码随后将被传递给服务端。

package com.startup.hip;
 
public class RustCode {
    public static native void doStuff(String code);
}

修改 Java 代码之后,我重新生成了 C 语言的头文件并据此编辑了 Rust 代码。


#![allow(unused)]
fn main() {
use jni::{objects::JClass, JNIEnv};
 
#[no_mangle]
pub extern "C" fn Java_com_startup_hip_RustCode_doStuff(
    _env: JNIEnv,
    _class: JClass,
    code: JString,
) {}
}

这很简单。现在我需要从 Java 字符串中提取文本并将其传递给 Rust 代码。这比我预期要复杂的多。问题在于,JVM 内部使用 UTF-8 的修改版本存储字符串,而 Rust 字符串必须是有效的 UTF-8。尽管 Rust 具有用于处理任意字符串的类型,但是我们的代码仅使用“经典”的字符串类型,对其进行全部修改需要大量工作。

幸运的是,jni库带有内置的机制,可以通过特殊的 JNIStr 类型在标准 UTF-8 和 JVM 修改后的 UTF-8 之间进行转换。在仔细阅读了文档之后,我想到了以下代码:


#![allow(unused)]
fn main() {
// Convert from JString – a thinly wrapped JObject – to a JavaStr
let code_jvm = env.get_string(code).unwrap();
// Create a String from JavaStr, causing text conversion
let code_rust = String::from(code_jvm);
}

现在我有了一个 Rust 字符串,可以将其传递给之后的 Rust 代码。又是个巨大的成功!

返回值

接收参数只是故事的一半,我还需要一个返回值,巧的是,它也是一个字符串 - 一个代表服务端返回值的字符串。

package com.startup.hip;
 
public class RustCode {
    public static native String doStuff(String code);
}

我再一次修改了 Java 代码,重新生成了 C 语言的头文件,并据此编辑 Rust 代码。

use jni::{objects::JClass, JNIEnv};
 
#[no_mangle]
pub extern "C" fn Java_com_startup_hip_RustCode_doStuff<'a>(
    env: JNIEnv<'a>,
    _class: JClass,
    code: JString,
) -> JString<'a>
{
    // function body here
}

如你所见,JNI 中的返回值仍然作为返回值处理。剩下要做的事就是创建保存结果的 JString。与get_string()类似,JNIEnv结构还包含一个new_string()函数,该函数的功能与名称指代的完全相同。


#![allow(unused)]
fn main() {
// Copy-pasted from earlier snippet 
let code_rust = String::from(env.get_string(code_jni).unwrap());
 
let result = match some_rust_function(code_rust) {
    Ok(value) => format!("OK {}", value),
    Err(e) => format!("ER {:?}", e),
};
return env.new_string(result).unwrap();
}

就像这样,我的 JNI 封装器就完成了。现在,我可以在 Java 代码中调用 Rust 函数,将值传递给调用并接收返回值。

Rust 的错误处理

尽管代码如预期一样执行,但是我不喜欢出现的.unwrap()调用次数。毕竟,错误处理是 Rust 的重要组成部分,仅仅因为我正在进行语言的互操作,并不意味着就可以忽略此事。恰恰相反,我认为两种语言的接触面应该尽可能简单,以防止最终发现一些晦涩的错误是由于互操作性差而引起的。而且,必须检查 Java 的返回值以确定调用是否成功,这使得整个过程使用起来有些笨拙。

我没有重复造轮子,而是对如何更好的将 Rust 的Result<A, B>方式转换成 Java 侧的代码进行了思考。幸运的是,我的 Rust 函数的返回值都是字符串。至于错误,大多数错误要么是不可恢复的,要么是由错误的输入引起的 - 这意味着我可以放弃使用精确的错误代码,而仅仅依靠正确格式的错误信息 - 这又是指字符串。因此Result<A, B>可以变成Result<String, String>

定义 Java 类

尽管 Java 支持范型(虽然有点欺骗的感觉),但是我不想从 JNI 中深入了解使用范型的细节。我决定创建一个 Java 类,大致表示Result<String, String>语义。

public class Result {
    private boolean ok;
    private String value;
 
    public Result(boolean is_ok, String value) {
        this.ok = is_ok;
        this.value = value;
    }
 
    public boolean isOk() {
        return this.ok;
    }
 
    public boolean isError() {
        return !this.ok;
    }
 
    public String getValue() {
        return this.ok ? this.value : null;
    }
 
    public String getError() {
        return this.ok ? null : this.value;
    }
}

尽管完成了这项工作,但与 Rust 相比,它有一些缺点 - 最严重的就是当访问错误的结果变量时返回null。由于 null 对于 Java 字符串来说是一个没有问题的值,因此调用getValue()可能没有注意并将其传递给其他地方导致在无关紧要的代码中弹出 NullPointerException。尽管可以通过抛出异常来轻松地改善这个问题,我仍决定使用最好的方式来处理这个问题,以便此处以后永远也不需要修改这部分的代码。

从 JNI 中返回一个对象

剩下的唯一一件事就是从 Rust 函数中返回 Result 类的实例。经过一番搜索后,我找到了名为 NewObject() 的 JNI 函数。该函数有四个参数:

  • JNI 环境的句柄
  • 我们想要创建的类的句柄
  • 构造函数签名
  • 构造函数的参数

Rust 函数将 JNI 环境句柄作为其参数之一,因此已经进行了处理。构造函数参数可以作为数组传递,我需要找到另外两个函数参数。

为了获取该函数的句柄,JNI 提供了 FindClass() 函数。它有两个参数:环境句柄和类的完全限定名 - 简单的说就是类的“导入名”,但是./代替。例如java.lang.String变成java/lang/String。在本例中是指,com.startup.hip.Result变成com/startup/hip/Result

构造函数签名是一个字符串,它很好的描述了构造函数签名需要多少个参数以及哪些类型。乍一看,这让人有些困惑 - 但后来我想起 Java 支持函数重载,并且包括构造函数。由于一个类可能有多个构造函数,所以我必须让 JNI 知道我想使用哪个构造函数。在互联网上搜索了之后,我发现最简单的学习函数签名的方法就是编译 Java 类。然后使用 Java 的反汇编工具:javap

$ javac android/app/src/main/java/com/startup/hip/Result.java
$ javap -s android/app/src/main/java/com/startup/hip/Result.class
Compiled from "Result.java"
public class com.startup.hip.Result {
  public com.startup.hip.Result(boolean, java.lang.String);
    descriptor: (ZLjava/lang/String;)V

  public boolean isOk();
    descriptor: ()Z

  public boolean isError();
    descriptor: ()Z

  public java.lang.String getValue();
    descriptor: ()Ljava/lang/String;

  public java.lang.String getError();
    descriptor: ()Ljava/lang/String;
}

执行了上述的命令,现在我知道了我想要使用的函数签名是(ZLjava/lang/String;)V

在所有步骤都准备就绪之后,是时候创建持有构造函数参数的数组,并调用NewObject()


#![allow(unused)]
fn main() {
fn create_java_result<'e>(
    env: &JNIEnv<'e>,
    is_ok: bool,
    value: &str,
) -> JObject<'e>
{
    let class = env
        .find_class("com/startup/hip/Result")
        .unwrap();
    let args: [JValue<'e>; 2] = [
        JValue::Bool(u8::from(is_ok)),
        JValue::Object(JObject::from(env.new_string(value).unwrap())),
    ];
    env.new_object(class, "(ZLjava/lang/String;)V", &args)
        .unwrap()
}
}

现在,我可以从 native 函数中返回自定义的Result Java 类了。

使用更通用的解决方案

尽管上面的代码可以很好的实现这个目的,但是它有一个缺点:它显示地采用了布尔值和字符串,要求调用者自己处理 Result 并使用适当的参数调用函数。编写“错误应该尽早返回”的逻辑很繁琐,但是幸运的是,Rust 为此提供了一个解决方案 - ? 运算符。但是我们的代码从不同的库中调用函数,这些函数又使用了不同的错误类型 - 这意味着我们无法使用Result<OurType, OurError>,并且必须执行类似 Result<OurType, std::error::Error>的操作 - 这是不可能的,因为 Rust 不允许将特征用作函数的返回类型。

解决此问题的标准方法是使用 Box ,但为了使事情变得更加简单,我决定使用 anyhow 库,该库允许按我的喜好混合和匹配错误。不管怎样,我可以这样编写代码:


#![allow(unused)]
fn main() {
fn rust_result_to_java_result<'e, T>(
    env: &JNIEnv<'e>,
    result: anyhow::Result<T>,
) -> JObject<'e>
where
    T: Display,
{
    let (is_ok, value) = match result {
        Ok(v) => (true, format!("{}", v)),
        Err(e) => (false, format!("{:?}", e)),
    };
    create_java_result(env, is_ok, value)
}
 
fn actually_do_stuff<'a>(
    env: JNIEnv<'a>,
    code: JString,
) -> anyhow::Result<String>
{
    let code = String::from(env.get_string(code)?);
    let intermediate_value = some_rust_function(code)?;
    other_rust_function(intermediate_value)
}
 
#[no_mangle]
pub extern "C" fn Java_com_startup_hip_RustCode_doStuff<'a>(
    env: JNIEnv<'a>,
    _class: JClass,
    code: JString,
) -> JObject<'a>
{
    rust_result_to_java_result(actually_do_stuff(env, code))
}
}

更简单了!现在我可以返回任何想要的结果,并将其转换为 Java 类的实例,以供 Java 代码使用。

封装

在 Android 中运行 Rust 并不是一件容易的事,但是我对最终找到的解决方案感到满意。我们使用及其普通的 Rust 编写代码并将其编译到共享库中,然后由 JVM 在运行时加载。尽管 JNI 乍一看有点令人生畏,但是使用此标准化解决方案意味着 Java 代码和 Gradle 构建系统都不关心我们的 native 代码是用 Rust 编写的。使用 Cargo 进行交叉编译仍然有些棘手,因为事实证明cargo-apk设置了许多环境变量以使整个过程正常运行。我们的代码还依赖于外部库 - 但是所有的这些都可以通过一堆 shell 脚本来解决。

如果你想要自己尝试一下,我已经准备了一个公共 Github 仓库,其中包含了一个最小的 Android 应用程序,既包含用 Rust 编写的部分,还依赖于外部的 C 库。该项目的许可证是 zlib。因此可以随意的获取源代码并将其用于你的个人目的。

参考

Rust 与 区块链 | ink! 编程实战(一): 初识 ink!

作者: 朱春雷


笔者注:本篇我们不会深入探讨 ink! 语法的细节,主要介绍编程环境的搭建,并引导读者逐步完成智能合约的创建、编译、测试和部署,具体的 ink! 语法将会在下一篇中详细讲解。此外,本系列文章将默认读者已经掌握 Rust 的基础语法,故不涉及对 Rust 语法的解释。如果读者对 Rust 基础语法还不熟练的话,可参阅相关资料或购买本文下方推荐的 Rust 入门书籍《Rust 编程入门、实战与进阶》学习。 ​

1.1 ink! 简介

Parity 官方对 ink! 的定义是:“ink! is an eDSL to write smart contracts in Rust for blockchains built on the Substrate framework. ink! contracts are compiled to WebAssembly.” 也就是说,ink! 是使用 Rust 开发的嵌入式领域特定语言(eDSL),为基于 Substrate 框架构建的区块链编写 WASM(WebAssembly)智能合约。

WASM 是一种新的字节码格式,是一种全新的底层二进制语法,它所编译的代码指令具有体积小、可移植、加载快等特点。相对于基于 EVM 的 Solidity 智能合约,WASM 智能合约占用资源少,运行合约更快速且稳定,网络传输信息更加高效。WASM 支持 Rust、C/C++、C#、Go、Typescript 等语言编写智能合约所编译的字节码,目前 Substrate、ETH 2.0 以及多条联盟链均表示将支持 WASM 智能合约。 ​

1.2 搭建编程环境

“工欲善其事,必先利其器”。在开始 ink! 编程之前,需要先安装和配置相关的编程环境。

1.2.1 Rust 环境安装及配置

1. 安装 Rust

Rust 由工具 rustup 安装和管理。rustup 既是 Rust 安装器,也是版本管理工具。

在终端运行以下命令,遵循指示即可完成最新稳定版 Rust 的下载与安装。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

如果安装成功,将会出现以下内容。

Rust is installed now. Great!

2. 设置 PATH 环境变量

在 Rust 开发环境中,rustc、cargo 和 rustup 等所有 Rust 工具都安装在 ~/.cargo/bin 中,我们可以将其加入 PATH 环境变量中。 ​

在 ~/.bash_profile 文件中增加以下内容:

export PATH="$HOME/.cargo/bin:$PATH"

保存文件,回到终端命令行窗口,使用 source 命令使配置立即生效。

source ~/.bash_profile

运行以下命令,检查是否已正确设置 PATH 环境变量。

rustc --version

如果能显示 Rust 最新稳定版的版本号、提交哈希和日期等信息,代表环境变量设置成功。如果未看到这些信息,请检查 ~/.bash_profile 文件中 PATH 环境变量设置的路径是否正确。 ​

1.2.2 ink! 环境安装及配置

在配置了基本的 Rust 环境后,我们来配置 ink! 所需的开发环境。 ​

1. 安装 WASM 工具链

由于 ink! 需要 Rust 的 WASM 工具链,且当前 WASM 只能在 nightly 工具链下运行,我们需要先准备 WASM 编译环境。

rustup install nightly
rustup component add rust-src --toolchain nightly
rustup target add wasm32-unknown-unknown --toolchain nightly

2. 安装 cargo-contract

我们可以安装 Parity 官方提供的编译智能合约的工具 cargo-contract。

cargo install cargo-contract --force --feature=“binaryen-as-dependency”

也可以使用由 Patract 提供的 cargo-contract,它与 Europa 配合使用可以打印详细的出错信息。

cargo install cargo-contract --git https://github.com/patractlabs/cargo-contract --branch=v0.10.0 --force

3. 安装区块链节点

智能合约必须运行在区块链上,推荐安装由 Patract 开发的用于智能合约测试的沙盒 Europa(https://github.com/patractlabs/europa)。Europa 提供智能合约在部署与调用过程中的详细日志,能够最大程度地呈现合约模块这个“黑盒”的运行情况,对开发者有很大的帮助。 ​

可以执行如下命令在本地安装 Europa。

cargo install europa --git https://github.com/patractlabs/europa --locked --force

1.2.3 Node 环境安装及配置

下文用到的 Redspot 需要使用 Node,因此请确定系统已安装的 Node 版本 >= 12.0。如果不是,可以访问官方网站(https://nodejs.org/zh-cn)进行安装或升级。 ​

1.3 使用 ink! CLI 开发 ink! 项目

1.3.1 创建 flipper 合约

使用 cargo contract new 命令在当前目录下创建智能合约项目 flipper。

cargo contract new flipper

flipper 的目录结构如下所示。

flipper
|
+-- .gitignore
|
+-- Cargo.toml
|
+-- lib.rs

flipper 合约的核心代码在 lib.rs 文件,如代码清单 1-1 所示。flipper 是仅包含一个布尔对象的简单合约,它通过 flip 方法转换布尔值(从 true 转换为 false,或从 false 转换为 true),并通过 get 方法返回当前布尔对象的状态。 ​

代码清单 1-1 flipper 合约代码​


#![allow(unused)]
#![cfg_attr(not(feature = "std"), no_std)]

fn main() {
use ink_lang as ink;

#[ink::contract]
mod flipper {

    /// Defines the storage of your contract.
    /// Add new fields to the below struct in order
    /// to add new static storage fields to your contract.
    #[ink(storage)]
    pub struct Flipper {
        /// Stores a single `bool` value on the storage.
        value: bool,
    }

    impl Flipper {
        /// Constructor that initializes the `bool` value to the given `init_value`.
        #[ink(constructor)]
        pub fn new(init_value: bool) -> Self {
            Self { value: init_value }
        }

        /// Constructor that initializes the `bool` value to `false`.
        ///
        /// Constructors can delegate to other constructors.
        #[ink(constructor)]
        pub fn default() -> Self {
            Self::new(Default::default())
        }

        /// A message that can be called on instantiated contracts.
        /// This one flips the value of the stored `bool` from `true`
        /// to `false` and vice versa.
        #[ink(message)]
        pub fn flip(&mut self) {
            self.value = !self.value;
        }

        /// Simply returns the current value of our `bool`.
        #[ink(message)]
        pub fn get(&self) -> bool {
            self.value
        }
    }

    /// Unit tests in Rust are normally defined within such a `#[cfg(test)]`
    /// module and test functions are marked with a `#[test]` attribute.
    /// The below code is technically just normal Rust code.
    #[cfg(test)]
    mod tests {
        /// Imports all the definitions from the outer scope so we can use them here.
        use super::*;

        /// Imports `ink_lang` so we can use `#[ink::test]`.
        use ink_lang as ink;

        /// We test if the default constructor does its job.
        #[ink::test]
        fn default_works() {
            let flipper = Flipper::default();
            assert_eq!(flipper.get(), false);
        }

        /// We test a simple use case of our contract.
        #[ink::test]
        fn it_works() {
            let mut flipper = Flipper::new(false);
            assert_eq!(flipper.get(), false);
            flipper.flip();
            assert_eq!(flipper.get(), true);
        }
    }
}
}

1.3.2 编译 flipper 合约

运行以下命令,可以编译合约。

cargo +nightly contract build

代码编译成功会在当前目录下生成 target/ink 文件夹,里面有 3 个重要文件:flipper.contract、flipper.wasm、metadata.json。flipper.wasm 是代码编译后的字节码文件,metadata.json 是元数据文件,其中包含合约提供的可被调用的方法信息。flipper.contract 是将 flipper.wasm 和 metadata.json 合并到一个文件中,在区块链上部署合约时使用。 ​

1.3.3 测试 flipper 合约

运行以下命令,可以对合约做单元测试。

cargo +nightly test

看到如下结果,代表已成功完成测试。

running 2 tests
test flipper::tests::it_works ... ok
test flipper::tests::default_works ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

1.4 使用 Patract 工具套件开发 ink! 项目

Patract Labs 为波卡 WASM 智能合约开发提供全面的解决方案,为开发者提供覆盖开发、测试、调试、部署、监控和前端开发等阶段的全栈工具和服务支持。

我们这里主要用到 Patract 工具套件中的 Redspot 和 Europa。Redspot 是智能合约开发、测试和调试的脚手架工具,Europa 提供智能合约运行环境的模拟节点并在运行过程中记录合约执行的信息日志。 ​

1.4.1 创建 flipper 合约

Redspot 提供了合约开发模版,可以让用户快速搭建起一个项目。执行以下命令将会拉取最新版的 Redspot,并以 erc20 为模板构建项目。

npx redspot-new flipper

flipper 的目录结构如下所示。

flipper
|
+-- contracts
|  |
|  +-- Cargo.toml
|  |
|  +-- lib.rs
|
+-- scripts
|  |
|  +-- deploy.ts
|
+-- tests
|  |
|  +-- erc20.test.ts
|
+-- .gitignore
|
+-- package.json
|
+-- redspot.config.ts
|
+-- tsconfig.json

修改 contracts/lib.rs 文件,用代码清单 1-1 中的代码替换原代码。 ​

1.4.2 编译 flipper 合约

运行 npx redspot compile 命令进行合约编译。对于 compile 命令,我们可以传入合约的路径来指定需要编译的合约,如下所示的命令将仅对 contracts/flipper 目录下的合约进行编译。

npx redspot compile contracts/flipper

编译完成后,可以在 artifacts 目录中找到 Flipper.contract 和 Flipper.json 两个文件。Flipper.json 是元数据文件,Flipper.contract 是在区块链上部署合约时使用。 ​

1.4.3 测试 flipper 合约

可以使用 Redspot 对合约进行单元测试。首先,删除 tests 目录下默认的 erc20.test.ts 文件,再创建 flipper.test.ts 文件,其内容如代码清单 1-2 所示。 ​

代码清单 1-2 flipper 单元测试脚本

import BN from 'bn.js';
import { expect } from 'chai';
import { patract, network, artifacts } from 'redspot';

const { getContractFactory, getRandomSigner } = patract;

const { api, getSigners } = network;

describe('Flipper', () => {
  after(() => {
    return api.disconnect();
  });

  async function setup() {
    const one = new BN(10).pow(new BN(api.registry.chainDecimals[0]));
    const signers = await getSigners();
    const Alice = signers[0];
    const sender = await getRandomSigner(Alice, one.muln(10000));
    const contractFactory = await getContractFactory('Flipper', sender);
    const contract = await contractFactory.deploy('new', false);
    const abi = artifacts.readArtifact('Flipper');
    const receiver = await getRandomSigner();

    return { sender, contractFactory, contract, abi, receiver, Alice, one };
  }

  it('Initial value', async () => {
    const { contract } = await setup();
    const result = await contract.query.get();
    expect(result.output).to.equal(false);
  });

  it('Flip value', async () => {
    const { contract } = await setup();
    await contract.tx.flip();

    const result = await contract.query.get();
    expect(result.output).to.equal(true);
  });
});

其次,在运行测试命令前需要确保正确配置了区块链节点信息,这里使用 redspot.config.ts 文件中的默认配置。 ​

最后,通过 europa 命令启动本地开发节点。

europa --tmp

运行 npx redspot test 命令进行单元测试。对于 test 命令,我们可以传入单元测试脚本的路径来指定需要测试的合约,如下所示的命令将仅对 flipper 合约进行单元测试,同时设置 --no-compile 可以避免自动运行编译命令。

npx redspot test tests/flipper.test.ts --no-compile

看到如下结果,代表已成功完成测试,Redspot 的插件会自动计算并打印出执行合约操作的 Gas 费用。 image.png

1.4.4 部署 flipper 合约

在 Substrate 中,部署合约的过程分为两个步骤:

  • 上传代码到区块链上
  • 创建合约实例

通过这种模式,诸如标准的 ERC20 合约代码只需上传一次,就可以实例化任意次,避免了因上传相同功能的源代码而浪费区块链上的存储空间。 ​

下面首先也是使用 europa 命令启动本地开发节点。

europa --tmp

打开网址 https://polkadot.js.org/apps,配置连接本地启动的开发节点。 image.png

点击菜单栏上“开发者”中的子菜单“合约”,打开合约页面后,点击“Upload & deploy code”按钮。 image.png

“upload & deploy code 1/2”窗口对应部署合约的第一个步骤——上传代码到区块链上。选择有帐户余额的部署帐户,再上传 Flipper.contract 文件,确认智能合约的信息后,点击“Next”按钮。 image.png

image.png

“upload & deploy code 2/2”窗口对应部署合约的第二个步骤——创建合约实例。设置捐赠的数量用以支付合约的存储租金与 Gas 费,再点击“部署”按钮。 image.png

在“批准交易”窗口,点击“签名并提交”按钮,即可完成合约部署。 image.png

image.png

1.4.5 调用 flipper 合约

flipper 合约已成功部署,可以与它进行交互了! ​

我们可以看到布尔对象的当前值是 false,这是因为创建合约实例时将初始值设置为 false。

image.png

点击“flip()”左边的“exec”按钮,调用合约的 flip 方法,签名提交后布尔对象的当前值变成了 true,这个合约交互结果符合预期。

image.png

image.png

image.png

我们可以看到,Patract 工具套件为开发 ink! 项目带来了极大的便利,本系列后续文章的示例都将使用 Patract 工具套件来构建。 ​

1.5 参考资料

  1. ink! Smart Contracts Tutorial https://substrate.dev/substrate-contracts-workshop

  2. ink! 官方文档 https://paritytech.github.io/ink-docs ​

  3. Patract 官方文档 https://docs.patract.io


推荐 Rust 入门学习书籍《Rust 编程入门、实战与进阶》,该书详细讲解 Rust 核心语法,注重编码能力训练,将数据结构、算法与 Rust 编程结合,精选 39 道 LeetCode 高频算法面试真题。需要购买书籍的朋友,可以扫描下方二维码。

image.png


扫码关注公众号

image.png

常见问题汇总

说明:

常见问题汇总来自于:


为什么go仅仅160M的安装包就可以编译程序?而rust却还需要几个G的msvc才能编译?

来源:知乎

在windows下,我下载了一个go1.16.zip版,仅仅150M,解压后,都不需要安装,就可以用命令行编译程序,非常小巧。但是,为什么rust,在安装了自身安装包后,却还得要vstudio build,安装得好几个G!为什么都同样是编译程序,差距如此之大?我的疑问是差距在哪里,在编译方面?难道rust不能设计成象go这样,不依赖msvc?

回答:

什么是 MSVC ?

the Microsoft Visual C++ compiler and libraries toolset 。微软 Visual C++ 编译器和库工具集。

为什么要有 MSVC ?

MSVC的目标是成为Windows上针对Windows的最佳编译器选择,无论您选择使用哪种编辑器或IDE。 我们旨在通过在以下关键领域上进行持续投资来实现我们的目标:完全符合C ++,更好的错误和警告,运行时性能,可靠性,构建吞吐量和最佳安全性。

Rust 为什么要支持 MSVC 以及如何支持 ?

Windows - The rustup book

因为 Rust 要支持 Windows 开发,而 Windows 上面有两类 ABI :

  • Visual Studio使用的本机(MSVC)ABI
  • GCC工具链使用的GNU ABI。

你需要哪种版本的Rust很大程度上取决于您要与哪些C / C ++库进行互操作:

  • 要与Visual Studio生产的软件互操作,请使用Rust的MSVC版本;
  • 要与使用MinGW / MSYS2工具链构建的GNU软件互操作,请使用GNU构建。

以MSVC ABI为目标时,Rust还需要安装Visual Studio 2013(或更高版本)或Visual C ++ Build Tools 2019,以便rustc可以使用其链接器。 对于Visual Studio,请确保选中“ C ++工具”和“ Windows 10 SDK”选项。 基本使用GNU构建不需要额外的软件安装。

如果你计划仅使用 Rust 库和可以与 MinGW 一起构建和使用的各种开源库,则可以完全避免使用Microsoft 的工具。 只需将 Rust 切换到x86_64-pc-windows-gnu目标即可。

但是,如果你打算使用本地Windows库进行Windows开发,则需要Microsoft的链接器,并且应该使用 Rust 的 x86_64-pc-windows-msvc 目标与之兼容。 由于Windows上的大多数人都对Windows开发感兴趣,因此这是默认设置。

而 Rust却还需要几个G的 msvc 才能编译?

因为找不到人实现必要的功能,不得不依赖Windows sdk 。

go 为什么不需要 MSVC ?

因为go根本没做 msvc 的支持(不是默认支持,需要你自己手工再做处理),你用 cgo 只支持用 GCC。 (不晓得现在支持 msvc 没有)。

为什么 Rust 不能像 go 那样,不依赖 msvc ?

所以,你觉得呢?

这正是 Rust 和 Go 设计目标的差别了。

Rust 语言是一个通用的系统级语言,Go 语言则不是这个目标。所以 Go 可以不依赖 MSVC ,Rust 则不可以。

未来,Rust 将越来越倾向于使用 LLVM LLD 链接器,而不是平台本机链接器(Microsoft或GNU binutils)。 LLD 是通用的,旨在支持所有平台和ABI。 因此,未来预计将不需要任何其他工具。


为什么Rust中的String不能用整数下标进行切片?

来源:知乎

```rust
fn main() {
    let name: String = "ABCD".to_string();
    println!("{}", &name[2..3]);
}
// 为什么不能直接用 name[2] 或者 &name[2] ?
```

另外,为什么切片还要加&?

回答:

std::string::String - Rust

文档里说的很清楚了。

Rust 里 String 总是按 UTF-8 编码的。而索引旨在进行恒定时间操作,但是UTF-8编码不允许我们执行此操作。 另外,索引应返回哪种类型呢?字节,码点 还是 字素簇(grapheme cluster)。

实际上String 文档里帮你定义基于 字节和字符 处理的两类基本方法。

切片为啥加 & ,因为 Rust 里 切片 是一个 DST,必须放 & 后面。


错误处理推荐使用什么库?

目前一般认为对于应用程序推荐使用 anyhow,而对于库推荐使用 thiserror

anyhow 提供了一个基于[特质对象]的错误类型,可以很容易地将不同来源的错误统一到单一来源,并可以方便地为错误添加上下文,以及就地创建新的错误。

thiserror 则提供了一个 derive 宏,方便为自定义的错误类型实现 Error 特质

# fn() 类型与 Fn() 等特质的关系和区别是什么?

在 Rust 中,每一个函数,无论是由 fn 关键字定义的一般函数,还是由闭包表达式定义的闭包,都有一个各自独立的匿名类型。为了能间接地使用函数,Rust 准备了两种方式,即 fn() 类型与 Fn()FnMut()FnOnce() 等[特质]。

要表达不同的类型,最常见的方法即是使用特质(作为类型约束,即 T: Fn()impl Fn(),或者使用[特质对象],即 dyn Fn()),Fn() 一族就是用于表达函数类型的特质。

fn() 不是一个特质,而是一个具体的类型,表示一个函数指针。功能上它与特质对象类似,可以近似地看作 &'static dyn Fn()。但 fn()Fn() 不同,它不包含对上下文的引用,因而只有一般函数或没有捕获任何上下文的闭包能够被转换成 fn()。因此它也与 &dyn Fn() 不同,不需要使用[胖指针]。它的大小与普通的指针一致。

因为 fn() 是一个函数指针,通过它调用函数与通过特质对象一样是间接调用,而使用 Fn() 等特质约束的泛型则是通过[单态化]来直接调用的。

# Rust 的 Future 是基于轮询的,这种方式不会有性能问题吗?

Future 的轮询是带通知机制的轮询,与传统意义上的轮询不完全一样。

执行器调用 Futurepoll 方法时会传入一个 Waker,而 Future 可以将这个 Waker 保存起来,当自己的状态有所变化时,通过其通知执行器可以再次对自己进行轮询。通过这个机制,执行器可以避免反复轮询一个未准备好的 Future,避免了传统轮询带来的性能问题。

标准库的 Future、futures crate、tokio 和 async-std 等之间的关系是什么?

标准库的 Future [特质]以及相关的 ContextPinWaker 等是核心。由于编译器编译[异步函数]需要依赖它们的定义,因而它们必须被包含在标准库里。

futuresFuture 的扩展,提供了许多虽不必进入标准库但依然重要的基础性的东西,比如 FutureExtStreamExt 等扩展特质和基础的通道执行器实现等。

tokioasync-std 是同一个层次的,主要提供异步运行时的实现,都依赖 futures 提供的元语,但因为处理的层次不同,所以可以看到一些自定义的与 futures 差不多的模块。

此外,虽然目前 Stream 是由 futures 提供的,但未来如果编译器要实现异步生成器,这个特质也很可能会进入标准库,因而对其的扩展也依然放进了独立的 StreamExt 里。

语言技巧

编辑:张汉东


为生命周期参数命名

假如有这样一段代码:


#![allow(unused)]
fn main() {
struct Person {
    name: String
}

impl Person {
    pub fn name<'a>(&'a self) -> &'a str {
        &self.name
    }
}
}

上面示例只是简单的单一生命周期参数'a,但我们将其重写命名为'me,可能会更加直观:


#![allow(unused)]
fn main() {
struct Person {
    name: String
}

impl Person {
    pub fn name<'me>(&'me self) -> &'me str {
        &self.name
    }
}
}

在下面一些场景中,命名生命周期参数可能更好:

场景一


#![allow(unused)]
fn main() {
use once_cell::unsync::OnceCell;

struct Provider {
    data_cache: OnceCell<Data>,
    metadata_cache: OnceCell<Metadata>,
}

// ...

fn process_data(data: &Data) -> Result<&str> {
    // ...
}
}

修正为:


#![allow(unused)]
fn main() {
fn process_data<'prov>(data: &'prov Data) -> Result<&'prov str> {
    // ...
}
}

将生命周期参数命名为 'prov 有助于标识 data 数据来自于 Provider结构体实例。 在 Rust 编译器源码中也能见到'tcx 这样的命名,用来标识这是类型上下文(typing context )。

场景二


#![allow(unused)]
fn main() {
struct Article {
    title: String,
    author: Author,
}

#[derive(PartialEq, Eq)]
struct Author {
    name: String,
}

struct ArticleProvider {
    articles: Vec<Article>,
}

struct AuthorProvider {
    authors: Vec<Author>,
}

// 这里具有两种生命周期参数命名
struct AuthorView<'art, 'auth> {
    author: &'auth Author,
    articles: Vec<&'art Article>,
}

// 这里具有两种生命周期参数命名
// 在需要指定两个生命周期参数长短关系的时候可以通过 'auth : 'art 这种方式指定,但是此处不需要
fn authors_with_articles<'art, 'auth>(
    article_provider: &'art ArticleProvider,
    author_provider: &'auth AuthorProvider,
) -> Vec<AuthorView<'art, 'auth>> {
    author_provider
        .authors
        .iter()
        .map(|author| {
            let articles = article_provider
                .articles
                .iter()
                .filter(|article| &article.author == author)
                .collect();

            AuthorView { author, articles }
        })
        .collect()
}
}

小结

将生命周期参数重新命名,面对使用引用比较复杂的场景,可以增加可读性,方便开发者分析生命周期参数。这算得上一个最佳实践。

来源: https://www.possiblerust.com/pattern/naming-your-lifetimes

优化技巧:Rust 中 match 分支语句中避免使用 ?

来自微信群:David Li

最近碰到rust的一个坑,在match 的分支里面使用?可能会导致栈内存特别大。

有一个函数,match 一个 Enum,这个Enum 有十几个定义,因此match 有十几个分支,每个分支里面最后都会调一个函数,返回值是Result,因此使用了?

上周测试报告说 debug 版本跑查询进程会崩掉,分析发现栈溢出了。我们上层代码把线程栈设置为 512 KB,然后调试发现某些函数栈内存竟然用了几百KB。

代码示意:


#![allow(unused)]
fn main() {
match SomeEnum {
    One(_) => get_result()?,
    Two(_) => get_result()?,
    Three(_) => get_result()?,
    Four(_) => get_result()?,
    Five(_) => get_result()?,
    //...
}
}

最后把match分支的Result去掉?,把 match表达式赋值 给临时变量之后再用?,内存占用降下来了。

代码示意:


#![allow(unused)]
fn main() {
let get_res = match SomeEnum {
    One(_) => get_result(),
    Two(_) => get_result(),
    Three(_) => get_result(),
    Four(_) => get_result(),
    Five(_) => get_result(),
    //...
};

let res = get_res?
}

P.S : 还可以获得一个优化技巧是:先把栈内存调低点,让这种问题尽早暴露。

了解一点关于泛型关联类型(GAT)的事

作者:CrLF0710 / 后期编辑:张汉东


这名字好长!这是啥子哟?

不要紧,我们从头理理。咱们先来温习一下Rust的语法结构。Rust程序是什么构成的?答案是:条目(item)。

每个Rust程序都是一条一条的,每个都是一个条目。比如你在main.rs里写一个结构体定义, 再写一个实现定义为它加两个方法,再写一个main函数。这就是crate对应的模块条目里的三个条目。

说完了条目,咱们再来说关联条目。关联条目不是条目!重点就是在“关联”俩字上,什么是关联呢? 其实就是“跟某个类型有关”,效果就是可以使用一个专门的关键字叫做Self。 这个关键词就是用来指代刚才说的那个类型的。

关联条目可以定义在两处,一个是特质定义的花括号中,一个是实现定义的花括号中。

关联条目一共有三种:关联常数,关联函数,关联类型(别名);它们与条目中的三种:常数、函数、类型(别名) 一一对应。

举个栗子吧!


#![allow(unused)]
#![feature(generic_associated_types)]
#![allow(incomplete_features)]
fn main() {
const A: usize = 42;
fn b<T>() {}
type C<T> = Vec<T>;

trait X {
    const D: usize;
    fn e<T>();
    type F<T>; // 新加的就是这个!之前在这里不可以写<T>
}

struct S;
impl X for S {
    const D: usize = 42;
    fn e<T>() {}
    type F<T> = Vec<T>;
}
}

这个有啥用咧?

蛮有用的,但是仅仅是在特定的场景之下。Rust社区里对泛型关联类型有两个用例是非常经典的,我们先试着介绍它们一下。

但是在开始介绍之前,我们还要再来温习一下泛型。泛型这个词英文是generic,其实是通用的意思。 泛型类型是什么呢?简单来说,就是缺点什么参数的类型,让使用的人填充。

顺便说一下,前人把它意译取名叫泛型,因为很多系统里能填的参数是类型。其实在Rust里面,不只是类型可以当泛型参数。 泛型参数有三种:类型、生存期、常数。

好,我们来看一个具体的泛型类型的例子:Rc<T>,它是具有一个泛型参数的泛型类型。 泛型类型Rc不是类型哈,只有提供了这个泛型参数的“实参”,才是真正的类型,比如Rc<bool>

如果我写一个数据结构,里面要共享数据,但是我事先不知道使用者到底需要我在这里用Rc还是Arc,怎么办呢? 最简单的方法就是代码写两遍,这个听起来有点笨拙,确实也是如此,但是也是确实有效的。 随口一提,http://crates.io上有俩库im和im-rc代码主要区别就是里面用的是Arc还是Rc。 实际上泛型关联类型就可以很好的解决这个问题,接下来, 我们就来看泛型关联类型的第一个经典使用场景:类型家族(type family)。

任务#1:用泛型关联类型支持类型家族

好,现在我们来做一个“选择器”,让编译器根据这个选择器来知道需要用的到底是Rc<T>还是Arc<T>。代码长这样:


#![allow(unused)]
fn main() {
trait PointerFamily {
    type PointerType<T>;
}

struct RcPointer;

impl PointerFamily for RcPointer {
    type PointerType<T> = Rc<T>;
}

struct ArcPointer;

impl PointerFamily for ArcPointer {
    type PointerType<T> = Arc<T>;
}
}

挺简单的吧,这样你就定义了两个“选择器”类型,可以用它来代表要用的是Rc还是Arc。实际用用看:


#![allow(unused)]
fn main() {
struct MyDataStructure<T, PointerSel: PointerFamily> {
    data: PointerSel::PointerType<T>
}
}

这样你泛型参数用RcPointer或者ArcPointer 就可以选择实际的数据表示了。 有了这个功能,刚才说的两个包就可以合成一个包了。好耶~

任务#2:用泛型关联类型实现流式处理迭代器

另一个问题其实是Rust比较特有的,其他语言里,要么不存在这个问题(古尔丹:代价是什么呢?), 要么,放弃治疗这个问题(咳咳)。

这个问题是这样的,希望在API接口上表示输入值与输入值之间、输入值与输出值之间的依赖关系。 依赖关系并不是一个很容易表达出来的东西。Rust的方案是什么呢? 在Rust里,这个人见人爱的生存期小标记'_大家都见过啦。它就负责在API上表示这种依赖关系的对应。

我们来实际用用这个生存期标记,标准库里的迭代器特质大家都见过,它长这样:


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;

    pub fn next(&'_ mut self) -> Option<Self::Item>;
    // ...
}
}

挺好的,但是有个小问题。Item类型的值是与Iterator本身的类型(Self)完全不能有依赖关系的。为什么呢? 因为你从Iterator取一个值这个动作,产生的这个临时范围(也就是上面的'_),是next这个关联函数的泛型参数。 定义的Item是单独的另一个关联类型,怎么可能用到呢?

大多数时候这个不是什么问题,但是对于某些库的API来说,这个就不够用了。 比如假如有一个迭代器,依次递给用户一些临时文件用,用户什么时候关闭都可以。这个时候你用Iterator,没有任何问题。 但是要是每次生成一个临时文件,加载一个什么数据,你用完之后它需要关闭临时文件来删除的那种, 这个迭代器肯定就会希望你能够告诉它你用完了。这样它就可以删掉临时文件了, 或者干脆不删除,而是直接复用它的存储空间来存下一个文件,这些都是ok的。

所以这个时候我们可以用泛型关联类型来设计这个API。


#![allow(unused)]
fn main() {
pub trait StreamingIterator {
    type Item<'a>;

    pub fn next(&'_ mut self) -> Option<Self::Item<'_>>;
    // ...
}
}

实现时你其实就可以让Item的类型是一个带依赖的类型,比如一个借用, 类型系统能够保证你在下次调用next或者移动析构这个迭代器之前,Item已经不再被用户使用了。好耶~

你讲的太接地气了,能不能来点抽象的? 好嘞,从现在起我们开始不说人话了。先说一下,这里要说的依然是简化过的,比如我们会把各种binder和predicate放一边。

首先我们来建立泛型类型的名字和具体类型之间的关系。当然就是个映射关系了。


#![allow(unused)]
fn main() {
/// 伪代码
fn generic_type_mapping(_: GenericTypeCtor, _: Vec<GenericArg>) -> Type;
}

比如Vec<bool>中,Vec就是这个泛型类型的名字也是它的构造器, <bool> 是这个泛型参数的列表,就一项。经过了这个映射,得到了一个Vec<bool>

好,然后是特质,啥是特质啊,特质其实也是一个映射。


#![allow(unused)]
fn main() {
/// 伪代码
fn trait_mapping(_: Type, _: Trait) -> Option<Vec<AssociateItem>>;
}

这里这个Trait可以起到一个谓词的作用,也就是拿它来对某个类型做判定,结论要么是None,表示“不符合这个特质”, 要么是一个Some(items) ,表示“这个类型符合这个特质”,并映射出一串关联条目。


#![allow(unused)]
fn main() {
/// 伪代码
enum AssociateItem {
    AssociateType(Name, Type),
    GenericAssociateType(Name, GenericTypeCtor), // 这次新加的
    AssociatedFunction(Name, Func),
    GenericFunction(Name, GenericFunc),
    AssociatedConst(Name, Const),
}
}

这里的AssociateItem::GenericAssociateType是当前rust里唯一一处间接地执行generic_type_mapping的地方。 通过给trait_mapping的第一个参数传不同的Type,就可以用相同的Trait获取到不同的GenericTypeCtor, 然后执行generic_type_mapping,从而在Rust的语法框架下达到了让不同的GenericTypeCtor跟指定的Vec<GenericArg>组合的目的!

顺便提一下,GenericTypeCtor这类东西,就是某些文章里面介绍的HKT。通过以上描述的这套方法,Rust里第一次加入了供用户使用的HKT能力。 虽然只有这一种形式,但是其他使用形式都可以通过这一种形式做出来。总之就是,奇怪的能力增加了!

我和小鸭子学走路 好嘞,作为收尾,我们来试着用 GAT 仿制一些其他语言的一些构造。


#![allow(unused)]
#![feature(generic_associated_types)]
#![allow(incomplete_features)]

fn main() {
trait FunctorFamily {
    type Type<T>;

    fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
    where
        F: FnMut(T) -> U;
}

trait ApplicativeFamily: FunctorFamily {
    fn pure<T>(inner: T) -> Self::Type<T>;

    fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U>
    where
        F: FnMut(T) -> U;
}

trait MonadFamily: ApplicativeFamily {
    fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
    where
        F: FnMut(T) -> Self::Type<U>;
}
}

然后我们来给一个“选择器”实现这些类型:


#![allow(unused)]
fn main() {
struct OptionType;

impl FunctorFamily for OptionType {
    type Type<T> = Option<T>;

    fn fmap<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
    where
        F: FnMut(T) -> U,
    {
        value.map(f)
    }
}

impl ApplicativeFamily for OptionType {
    fn pure<T>(inner: T) -> Self::Type<T> {
        Some(inner)
    }

    fn apply<T, U, F>(value: Self::Type<T>, f: Self::Type<F>) -> Self::Type<U>
    where
        F: FnMut(T) -> U,
    {
        value.zip(f).map(|(v, mut f)| f(v))
    }
}

impl MonadFamily for OptionType {
    fn bind<T, U, F>(value: Self::Type<T>, f: F) -> Self::Type<U>
    where
        F: FnMut(T) -> Self::Type<U>,
    {
        value.and_then(f)
    }
}
}

好嘞,然后我们就可以通过OptionType这个“选择器”来表达、使用Option作为Functor, Applicative, Monad 的性质了。 怎么样,是不是打开了无数的新的可能性?

Rust Runtime 与 ABI

作者:于航 / 后期编辑:张汉东

本文主要介绍了 Rust 语言的 Runtime 与 ABI 的相关内容。由于这两个概念均较为底层且通用,而不会涉及诸如 std::Vec 等具体标准库应用层类型的实现细节,因此这里放到一起作为本文主要内容。


本文主要介绍了 Rust 语言(版本 1.52.0)的 Runtime 与 ABI 等相关内容。由于这两个概念均较为底层且通用,而不会涉及诸如 std::Vec 等具体标准库应用层概念的实现细节,因此这里放到一起作为本文主要内容。

相信无论是做前端、后端,还是移动端,对于 “runtime” 一词,你都肯定有所耳闻。Runtime 翻译过来即“运行时”,在 Wikipedia 上我们可以得到对它的一种定义:

In computer science, runtime, run time, or execution time is the final phase of a computer program's life cycle, in which the code is being executed on the computer's central processing unit (CPU) as machine code. In other words, "runtime" is the running phase of a program.

在这段定义中,我们可以将 “runtime” 简单理解为「尤指程序代码被 CPU 执行的那段时间」,这其实是对 runtime 一词最直观的字面理解,即 runtime 是指程序众多生命周期中的一个最重要的阶段。比如我们常见的一种运行时错误 —— “除零异常”,这里的“运行时”通常所指的便是程序的运行阶段。

每种编程语言都有其自身的“执行模型(Execution Model)”,比如以 C 语言为例,C 标准中规定了 C 语句在实际执行时应有的执行顺序(如下引用所示),以及诸如 C 程序在启动和终止时,执行环境所应该进行的处理:比如程序启动时应该调用名为 main 的函数;根据该函数签名的不同,应选择性地传入相应的 argcargv 参数;程序运行结束前需要选择性地调用 exit 系统调用等等。再举一个例子,WebAssembly 在其标准中也有定义当一个“抽象机器”在执行一段 Wasm 代码时,这个机器可以被抽象出来的、应有的组成部分,比如符合 Wasm 计算模型的“栈(stack)”结构、存放包含有所有 Wasm 全局实例(func \ table \ memory \ global \ element \ data 等)的 “Store” 结构等等。但值得注意的是,抽象机器的定义与真实的虚拟机实现并不完全相等,虚拟机的实现只要能够保证 Wasm 代码的执行在抽象层级上的表现与抽象机器完全一致即可。

A statement specifies an action to be performed. Except as indicated, statements are executed in sequence.

话说回来,执行模型本身是“脱离”于语言的具体语法与语义的,这意味着源代码本身无法直观地反映出其全部的执行细节。一般来说,编程语言的执行模型可以通过编译器或解释器来实现(对应于两种不同的执行方式)。对于编译器来说,它可以将高层次的上层代码转换为较低层次的中间(IR)或者汇编代码,而在这些代码中,上层代码所隐含的执行模型已经被“解构”到低层次代码的执行语义中了。而对于解释器来说,上层语言所隐含的执行语义则需要通过对程序进行结构化分析,再根据具体 token 类别的不同来分别进行相应的处理。而通常来说,我们会将「所有这些无法从程序源代码上直观体现的、为支持执行模型,使得程序可以正常运行而进行的处理和实现」统称为“运行时系统(Runtime System)”。

一门编程语言的运行时系统,提供了以该语言编写的程序可以在其中运行的环境。这个环境涉及到程序能够正确运行的多个重要方面,大到对应用程序内存的管理、提供程序与操作系统的可交互接口;小到正确设置函数调用时的 prologueepilogue 等等。还是以 C 语言为例,当我们运行一个需要动态链接的 C 应用时,个人认为动态链接器的行为也属于运行时系统的范畴。比如当我们在类 Unix 系统上运行这个应用时,操作系统将会以动态链接器作为执行入口,动态链接器首先完成自身的符号重定位,然后对将要运行的 C 应用进行所有依赖共享库的地址空间加载、符号重定位等一系列工作,最后再将执行流程(PC)转交给应用本身。而动态链接器的这一系列工作其目的便是为了能够正确执行我们的目标 C 应用,只是这部分流程并非局限于 C 语言本身,属于独立于具体语言的运行时系统组成。

而对于 C 语言本身来说,也有其专有的运行时系统组成,对于这部分内容我们一般称之为 CRT(C-Runtime)。在类 Unix 系统中,CRT 一般以几个不同目标文件的形式提供(crt1.o \ crti.o \ crtn.o)。其中,crt1.o 中包含了程序的实际入口函数(_start),在这部分汇编实现中,运行时系统一般会正确设置 argcargv 的参数,并最后调用 C 源代码中定义的 main 函数。而在 main 函数返回后,其还会调用 exit 系统调用以正确退出程序。另外的目标文件 crti.ocrtn.o 提供了部分用于辅助全局构造与析构相关功能的实现组成,这里不详细展开。

总而言之,对于“运行时系统”的识别边界并没有十分详细、清楚的区分条件(目前来看)。针对不同的编程语言、技术体系,有些时候可能需要用不同的视角来进行判断。而在下文对 Rust Runtime System 的陈述中,我们将主要介绍与 Rust 语言本身相关的那部分运行时系统组成,而对于独立于语言的部分(比如上文中提供到动态链接器)则不会涉及。

对于 Rust ABI 的部分,我们所讨论的内容不会详细到 ABI 的每一个具体细节。但实际上 Rust ABI 所涉及的内容与 C/C++ 十分类似,比如:内置类型的大小和在内存中的存储方式(字节序)、组合类型的存储方式和内存分布、函数调用方式(calling convention)、寄存器使用约定、虚函数表的内容和布局形式等等。在本文中,我们会概述现阶段 Rust ABI 的整体情况,以给大家一个直观的印象。

Rust Runtime System

为了保证阅读上的流畅性,后续我们会直接将 “Runtime System” 简称为 “Runtime”。从 Rust 官方的 FAQ 文档中我们可以得知,Rust 语言几乎没有运行时系统(如下引用所示)。而这使得它可以更方便地与其他带有 GC 的语言进行集成。

By avoiding GC, Rust can offer numerous benefits: predictable cleanup of resources, lower overhead for memory management, and essentially no runtime system.

但“几乎没有”并不代表完全没有,Rust 的一部分标准库实现可以被认定为属于 Rust 运行时系统的功能范畴,这部分标准库提供了与:Panic回溯(backtrace)Stack Unwinding 以及栈保护等概念有关的具体实现。不仅如此,同 C 语言类似,Rust 同样有着一部分运行时系统功能为实际的 main 函数调用提供了准备。

我们可以从 Rust 项目的源代码位置 rust/library/std/src/rt.rs 处看到 Rust Runtime 的完整实现。这段代码实现十分简短,完整的代码参考如下:


#![allow(unused)]
fn main() {
//! Runtime services
//!
//! The `rt` module provides a narrow set of runtime services,
//! including the global heap (exported in `heap`) and unwinding and
//! backtrace support. The APIs in this module are highly unstable,
//! and should be considered as private implementation details for the
//! time being.

#![unstable(
    feature = "rt",
    reason = "this public module should not exist and is highly likely \
              to disappear",
    issue = "none"
)]
#![doc(hidden)]

// Re-export some of our utilities which are expected by other crates.
pub use crate::panicking::{begin_panic, begin_panic_fmt, panic_count};

// To reduce the generated code of the new `lang_start`, this function is doing
// the real work.
#[cfg(not(test))]
fn lang_start_internal(
    main: &(dyn Fn() -> i32 + Sync + crate::panic::RefUnwindSafe),
    argc: isize,
    argv: *const *const u8,
) -> isize {
    use crate::panic;
    use crate::sys_common;

    // SAFETY: Only called once during runtime initialization.
    unsafe { sys_common::rt::init(argc, argv) };

    let exit_code = panic::catch_unwind(main);

    sys_common::rt::cleanup();

    exit_code.unwrap_or(101) as isize
}

#[cfg(not(test))]
#[lang = "start"]
fn lang_start<T: crate::process::Termination + 'static>(
    main: fn() -> T,
    argc: isize,
    argv: *const *const u8,
) -> isize {
    lang_start_internal(
        &move || crate::sys_common::backtrace::__rust_begin_short_backtrace(main).report(),
        argc,
        argv,
    )
}
}

导出的 Panicking 内部接口

从上往下看这段代码,首先可以发现这里重新导出了一些与 panic! 宏相关的内部函数,我们来一一看下。


#![allow(unused)]
fn main() {
// Re-export some of our utilities which are expected by other crates.
pub use crate::panicking::{begin_panic, begin_panic_fmt, panic_count};
}

其中,名为 begin_panic 的函数是宏 panic!assert! 对应变体的实际入口函数,该函数可以接受任意的(std::any::Any)参数类型作为 Panic Payload。函数内部通过调用 rust_panic_with_hook 函数执行了为触发 Panic 所做的一些处理,这些处理包括:处理递归的 Panic(比如在 Panic Hook 中触发 Panic)、执行 Panic Hook(自定义或默认的)、输出 Panic 信息,最后通过调用 Panic Runtime 提供的 __rust_start_panic 函数,Rust 可以选择性地通过 panic_abortpanic_unwind(默认情况)这两种不同的方式实现最后的 Panic 副作用。其中,前者将直接调用诸如 C 标准库中的 abort 函数来结束当前进程;而后者则会通过调用平台相关的 Stack Unwinding 逻辑来“解构”与异常相关的调用栈帧,直到可以将(如果有)控制逻辑转移给最外层的 std::panic::catch_unwind 函数栈帧。一个有关设置 Panic Hook 与 Panic 捕获的简单例子可以参考如下:

fn main() { 
    // setup a panic hook, will be called once a panic occurs.
    std::panic::set_hook(Box::new(|panic_info| {
        if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
            println!("Panic occurred: {:?}", s);
        }
    }));
    // catch occurred panics.
    let result = std::panic::catch_unwind(|| {
        (|| panic!("A panic occurs!"))();
    });
    /**
     * Output:
        Err(
            Any { .. },
        )
     */
    println!("{:#?}", &result);
}

begin_panic_fmt 函数与 begin_panic 类似,只不过它主要用来处理接收了类似 format! 宏格式参数的 panic! 调用,该函数在执行过程中也会间接调用 rust_panic_with_hook 函数,其后续处理流程与上述一致。

最后导出的 panic_count 模块中主要包含了与 “Panic 计数” 相关的一些内部接口,这些计数逻辑会计算发生的、分别独立于进程和线程(通过 TLS)的 Panic 数量,并进行不同的处理。其中的基本逻辑是:当发生 Panic 时,计数器会加一;相反,当 Panic 被捕获和处理后(比如通过 catch_unwind),计数器会减一。而在正常情况下,发生 Panic 会直接导致 Rust 程序的运行终止。

Runtime 入口函数

出于对代码生成大小的考虑,实际的 Runtime 入口函数被拆分成了两部分,分别对应 lang_startlang_start_internal,其中前者为首先被调用(由属性 #[lang = "start"] 标记)的函数,该函数在其内部直接调用了后者。调用的方式如下所示:


#![allow(unused)]
fn main() {
// ...
#[cfg(not(test))]
#[lang = "start"]
fn lang_start<T: crate::process::Termination + 'static>(
    main: fn() -> T,
    argc: isize,
    argv: *const *const u8,
) -> isize {
    lang_start_internal(
        &move || crate::sys_common::backtrace::__rust_begin_short_backtrace(main).report(),
        argc,
        argv,
    )
}
}

其中传入的第一个参数为一个闭包(Closure)。在该闭包内部,一个指向 main 函数的指针被作为参数传递给了 __rust_begin_short_backtrace 函数。Rust 自 1.47.0 版本后,在默认情况下,应用发生 Panic 时将会输出更加精简的 stack backtrace 信息,而这种改变便是由函数 __rust_begin_short_backtrace__rust_end_short_backtrace 一起配合并共同完成的。

这两个函数需要成对使用,两者在其内部并没有任何特殊逻辑,而只是简单地调用了传入其中的函数,并将调用结果返回。从 Rust 应用的完整调用栈(call stack)上来看,处于 __rust_begin_short_backtrace__rust_end_short_backtrace 之间的函数调用过程便可被视作完整的用户代码调用过程。而 Rust 在遇到 Panic 而需要打印当前的 backtrace 时,它实际上会遍历每一个栈帧,而通过栈帧附带的“符号名”来区分当前栈帧是否为上述两个函数中的某一个,进而 Rust 能够区分哪部分栈帧属于用户代码、哪部分属于运行时系统。

再回到之前的闭包,当函数 __rust_begin_short_backtrace 在其内部调用了传入的 main 函数并返回后,Rust 在此返回值的基础之上又调用了名为 report 的函数。从上面给出的 Runtime 入口函数 lang_start 的实现代码中可以看到,main 函数所对应的类型 fn() -> T 其返回值 T 实际上带有一个名为 crate::process::Terminationtrait bound,而该 trait 便提供了名为 report 的方法。该方法在调用后会返回一个用于表示状态信息i32 类型整数,该整数随后将会被传递给操作系统以作为状态信息。如下图所示,实际上 Rust 对诸如 () \ ! \ Result<(), E> 等常见的 main 函数返回值都默认实现了该 trait。通常情况下,Rust 使用来自 libc 的宏 libc::EXIT_SUCCESS 的值(整数 0)来表示成功状态;而使用宏 libc::EXIT_FAILURE 的值(整数 1)来表示失败状态。在这些 main 函数常见返回值类型对该 trait 的实现中,你可以看到对这些值的使用。

接下来,函数 lang_start_internal 在调用时传入的最后两个参数 argcargv 想必你已经十分熟悉了,两者与 C/C++ 程序中 main 函数所接收到的两个参数其含义和数值类型完全一致。流程继续行进到 lang_start_internal 函数的内部。在该函数内部,Rust 首先调用了名为 sys_common::rt::init 的“运行时初始化”函数,该函数主要完成了以下几件事情:

  • 初始化用于存放 argcargv 的全局静态变量;
  • 为当前 main 函数的所在线程设置名称(“main”);
  • 为当前 main 函数的所在线程设置 Thread Guard,以防(通过 bounds checking)栈缓冲区溢出。

其内部调用如下所示:


#![allow(unused)]
fn main() {
// One-time runtime initialization.
// Runs before `main`.
// SAFETY: must be called only once during runtime initialization.
// NOTE: this is not guaranteed to run, for example when Rust code is called externally.
#[cfg_attr(test, allow(dead_code))]
pub unsafe fn init(argc: isize, argv: *const *const u8) {
    unsafe {
        sys::init(argc, argv);

        let main_guard = sys::thread::guard::init();
        // Next, set up the current Thread with the guard information we just
        // created. Note that this isn't necessary in general for new threads,
        // but we just do this to name the main thread and to give it correct
        // info about the stack bounds.
        let thread = Thread::new(Some("main".to_owned()));
        thread_info::set(main_guard, thread);
    }
}
}

在调用完初始化函数后,我们在 Rust 源代码中定义的 main 函数便开始被真正地调用了。这里 main 函数实际上会被放置在 catch_unwind 中进行调用,以检查主函数在实际调用过程中是否产生了 Panic,借此来设置不同的返回值。紧接着在 main 函数执行完毕并返回后,Rust 通过调用 sys_common::rt::cleanup 函数完成对运行时上下文的清理,这些清理工作包括:

  • 输出并禁用 stdout 缓冲区;
  • 清理先前用于保存 argcargv 的静态变量。

最后,Rust 返回用户指定的或默认的(101 - 发生错误)退出状态码到操作系统,应用结束运行。

到这里一切看起来都很简单,我们可观测到的 Rust Runtime 并没有提供过多的功能,但实际上还有一个问题没有解决。我们在文章开头曾提到,名为 lang_start 的函数会被 Rust 首先调用,而后才是我们定义在 Rust 源代码中的 main 函数。但究竟 lang_start 函数是如何被调用的呢?函数在调用时传入的 argcargv 参数又是怎样获得的呢?

入口点(Entry Point)

实际上,我们上述介绍的函数 lang_start 只是 Rust Runtime 的入口点,而非整个程序的执行入口。当我们在通过 rustc 编译 Rust 源代码时,Rust 编译器会帮助我们动态生成一个名为 “main” 的函数,这个函数的签名同我们在 C/C++ 中定义的 main 函数基本一致。接着,Rust 借助在编译过程中链接的 libc 的帮助下,这个 main 函数便可以被正确调用(一般在 _start 中由汇编指定)。而我们在上文最开始介绍的 lang_start 函数便会由这个 rustc 自动生成的 main 函数来调用。具体可以参考 Rust 源代码 compiler/rustc_codegen_ssa/src/base.rs 中的 create_entry_fn 函数:


#![allow(unused)]
fn main() {
// location: compiler/rustc_codegen_ssa/src/base.rs.
fn create_entry_fn<'a, 'tcx, Bx: BuilderMethods<'a, 'tcx>>(
    cx: &'a Bx::CodegenCx,
    rust_main: Bx::Value,
    rust_main_def_id: DefId,
    use_start_lang_item: bool,
) -> Bx::Function {}
}

当然,在某些特殊场景下,我们也可以通过一些方式来让 rustc 不使用那个默认的、由 lang item 标记的 lang_start 函数。比如借助 #[start] 属性,我们可以让 rustc 在编译时直接使用我们指定的入口函数。一个简单的示例如下所示。


#![allow(unused)]
#![feature(start)]
fn main() {
#[start]
fn my_custom_start(argc: isize, argv: *const *const u8) -> isize {
    println!("{}", argc);
    unsafe {
        use std::ffi::{CStr, OsString};
        use std::os::unix::ffi::OsStringExt;
        use std::os::raw::c_char;
        let v: Vec<OsString> = (0..argc).map(|i| {
            let cstr = CStr::from_ptr(*argv.offset(i) as *const c_char);
            OsStringExt::from_vec(cstr.to_bytes().to_vec())
        }).collect();
        println!("{:?}", v);  // print out the argc and argv.
    }
    0
}
}

但这样做的问题在于:由于 rustc 直接使用了我们指定的入口函数(这个函数仍然是由编译器自动生成的 main 函数调用的),而没有执行 Rust 默认的 Runtime 初始化函数,因此我们上述介绍的那些在 Rust Runtime 中完成的事情,在这种情况下都不会被执行。而这是否会对应用的实际表现产生影响,则需要视具体情况而定。

输入参数(argc / argv)

对于程序运行时输入的 argcargv 两个参数,本质上 Rust 会通过两种方式来获取:

  • Runtime 启动时通过 main 函数直接传入,并保存于静态变量中;
  • 借助 FFI 通过链接的外部系统库来获得。

其中第一种方式,比如在与 libc 进行链接时,位于 _start 标签处的汇编代码便可能会对 argcargv 这两个参数进行处理。而在 libc 实际调用 main 函数时(在 Rust 中由 rustc 动态生成),这两个参数便会被放置在栈上,进而直接作为 main 函数的参数传入其中。而根据链接的具体 libc 其种类的不同,比如 glibc 则可能会采用 “init_array 扩展” 的方式来获取 argcargv 的实际值。

第二种方式的一个典型例子便是 macOS 系统上的 _NSGetArgc_NSGetArgv 方法。这两个方法均由操作系统提供,可以直接用来获得传递给当前运行进程的 argcargv 参数信息。因此,当我们在 Rust 中尝试通过 std::env::args 来获取当前进程的输入参数时,如下代码所示,在 macOS 系统上 Rust 将会直接通过 FFI 调用这两个函数来获取这些信息。


#![allow(unused)]
fn main() {
// location: library/std/src/sys/unix/args.rs.
#[cfg(target_os = "macos")]
pub fn args() -> Args {
    use crate::os::unix::prelude::*;
    extern "C" {
        // These functions are in crt_externs.h.
        fn _NSGetArgc() -> *mut libc::c_int;
        fn _NSGetArgv() -> *mut *mut *mut libc::c_char;
    }
  // ...
}
}

Rust ABI

同样的,从 Rust 官方的 FAQ 中我们也可以了解到,实际上目前 Rust 还没有稳定的 ABI。因此这里只简短地挑几个方面讲讲。

内存布局(Memory Layout)

对于诸如 i32 \ f32 \ &T \ *const T 等基本类型以及复合类型 array 来说,Rust ABI 保证其与 C/C++ 是相互兼容的。

而对于某些复合类型,从 Rustonomicon 中我们得知,在默认情况下,Rust 编译器对 struct 内部字段的内存排列方式并不固定。在某些情况下出于优化目的,其实际的字段内存排列顺序可能与定义时的“可见排列顺序”并不相同。而为了使内存布局保持稳定,我们可以通过添加额外的诸如 #[repr(C)] 等属性来指定被标记结构体应该采用哪种 ABI 规定的内存排列方式。如下所示我们规定结构体 Foo 应该按照 C 语言的 ABI 来进行实际的内存布局。同样的方式也可被应用于 enum 类型,但需要注意 Rust 与 C 中的 enum 本质并不相同。


#![allow(unused)]
fn main() {
#[repr(C)]
struct Foo {
    a: bool,
    b: f64,
    c: bool,
    d: i32,
}
}

除此之外,对于某些特殊类型(比如 Option<T> \ enum ),Rust 编译器可能会使用“空指针优化Null-pointer Optimization)”来优化这些类型的内存布局。此时,Option<T> 其内存布局将与 T 的内存布局保持一致(T 必须为 non-null)。

对齐(Alignment)

Rust 中所有类型都有以“字节”为单位的对齐要求。基本类型(整数、浮点数、布尔值,以及字符值)通常对齐到它们类型本身的大小(当然也受到具体平台的限制)。默认情况下,复合类型(如 struct)的整体大小需要是其内部字段最大对齐大小的整数倍。并且,内部的每一个字段的起始偏移也需要为该字段对齐字节大小的整数倍(同 C/C++ 一致)。某些情况下,Rust 会自动插入“对齐字节”以满足上述要求。

而动态大小类型(DSTs)其大小与对齐方式可能无法在静态编译时得知。对零大小类型(ZSTs)的操作通常会被编译器优化为 “no-op”,且对 ZSTs 的引用必须是 non-null 且适当对齐的。而解引用一个 null 或未对齐的 ZST 指针是未定义行为(UB)。

调用规范(Calling Convention)

目前还没有相关资料记载 Rust-to-Rust 调用时所采用的具体调用规范。而对于 C-to-Rust 调用,我们可以选择借助 extern 关键字让函数调用遵循当前平台默认的 C ABI。示例代码如下所示。


#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

同样,我们也可以显式指定想要使用的其他 ABI,比如 _cdecl


#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "cdecl" fn call_from_c() {
    println!("Just called a Rust function from C!");
}
}

参考资料

  1. https://en.wikipedia.org/wiki/Runtime_system.
  2. https://en.wikipedia.org/wiki/Execution_model.
  3. https://en.wikipedia.org/wiki/Runtime_(program_lifecycle_phase).
  4. https://whatis.techtarget.com/definition/runtime-system.
  5. https://www.techopedia.com/definition/24023/runtime-system.
  6. https://edge.seas.harvard.edu/runtime-systems.
  7. https://stackoverflow.com/questions/42728239/runtime-system-in-c.
  8. https://www.quora.com/Is-there-an-execution-model-for-every-programming-language-I-cannot-find-any-for-C++.
  9. http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1124.pdf.
  10. https://webassembly.github.io/spec/core/exec/index.html.
  11. https://prev.rust-lang.org/en-US/faq.html.
  12. https://ferrous-systems.github.io/rust-three-days-course/presentation/index.html?chapter=libcore-and-libstd&locale=en-US.
  13. https://blog.mgattozzi.dev/rusts-runtime.
  14. https://en.wikipedia.org/wiki/Stack_trace.
  15. https://rustc-dev-guide.rust-lang.org/panic-implementation.html.
  16. https://en.wikipedia.org/wiki/Call_stack#Unwinding.
  17. https://doc.rust-lang.org/beta/std/panic/fn.catch_unwind.html.
  18. https://mashplant.online/2020/09/06/panic-in-wasm/.
  19. https://blog.rust-lang.org/2020/10/08/Rust-1.47.html#shorter-backtraces.
  20. https://en.wikipedia.org/wiki/Buffer_overflow_protection.
  21. https://www.gnu.org/software/libc/manual/html_node/Exit-Status.html.
  22. https://users.rust-lang.org/t/who-calls-lang-start/51446/2.
  23. https://stackoverflow.com/questions/67444319/how-does-rust-begin-short-backtrace-work-in-rust.
  24. https://stackoverflow.com/questions/67445967/how-does-rust-retrieve-the-input-argc-and-argv-values-from-a-running-program.
  25. https://gankra.github.io/blah/rust-layouts-and-abis/.
  26. https://people.gnome.org/~federico/blog/rust-stable-abi.html.
  27. https://users.rust-lang.org/t/rust-function-calling-conventions/13499.
  28. https://doc.rust-lang.org/nomicon/ffi.html.
  29. https://www.reddit.com/r/rust/comments/50qk14/rust_abi/.
  30. https://github.com/rust-lang/rust/issues/29633.
  31. https://doc.rust-lang.org/unstable-book/language-features/lang-items.html.
  32. http://www.dpldocs.info/experimental-docs/core.sys.darwin.crt_externs._NSGetArgc.html.

借鉴数据库缓存解决动态规划难题

作者: 吴翱翔 / 后期编辑:张汉东


原文: 缓存解决动态规划难题

分享下 leetcode 困难题停在原地的方案数 不断推敲和优化逐步通过题目的过程

看到这种不同路径求方案总数,很容易想到 unique_path 这道动态规划入门题,

这题跟 unique_path 一样也是「求从起点到终点不同行走路径的方案总数」,自然想到用动态规划去实现

无记忆化的搜索

由于动态规划迭代解法的状态表达较难抽象,于是我先写出更简单动态规划递归的无记忆化搜索版本

递归的结束条件

那么递归的结束条件显然是「剩余步数」为 0

解答的更新条件

方案数的更新条件则是 剩余步数为 0 且 当前位置也是 0,这时候可以将方案数+1

递归函数的入参

首先需要当前位置和当前剩余步数两个"可变"的入参

再需要一个"常数"表达最大可前往的位置,一旦移动到数组的右边界,下一步就只能原地走或向左走

最后需要一个已发现方案总数的可变指针,用来更新解答集

递归搜索的决策层

只能在数组范围 [0, arr_len] 行走,行走方向 原地不动、向左、向右 三种

  1. 如果当前坐标是 0, 则只能 原地不动 或 向右
  2. 如果当前坐标是 arr_len-1,则只能 原地不动 或 向左
  3. 其余情况的行走方向决策则是 原地不动 或 向左 或 向右

无记忆化搜索代码


#![allow(unused)]
fn main() {
fn num_ways_dfs(cur_position: i32, remain_steps: i32, max_position: i32, plans_count: &mut u32) {
    if remain_steps == 0 {
        if cur_position == 0 {
            // panicked at 'attempt to add with overflow'
            *plans_count += 1;
        }
        return;
    }

    // 剪枝: 走的太远不可能移动回原点的情况
    if cur_position > remain_steps {
        return;
    }

    // 做决策
    // 决策: 原地不动
    num_ways_dfs(cur_position, remain_steps-1, max_position, plans_count);
    if cur_position == 0 {
        // 只能向右
        num_ways_dfs(cur_position+1, remain_steps-1, max_position, plans_count);
    } else if cur_position == max_position {
        // 只能向左
        num_ways_dfs(cur_position-1, remain_steps-1, max_position, plans_count);
    } else {
        num_ways_dfs(cur_position+1, remain_steps-1, max_position, plans_count);
        num_ways_dfs(cur_position-1, remain_steps-1, max_position, plans_count);
    }
}

fn num_ways_dfs_entrance(steps: i32, arr_len: i32) -> i32 {
    let mut plans_count = 0;
    num_ways_dfs(0, steps, arr_len-1, &mut plans_count);
    (plans_count % (10_u32.pow(9)+7)) as i32
}
}

虽然我加上了递归的剪枝条件,但是 leetcode 上只过了 1/3 的测试用例便在 (27,7) 这个测试用例上超时了

不仅如此,更新方案总数时还出现 u32 溢出的问题,我粗略估算下该函数的时间复杂度是 O(3^n) 指数级别的时间复杂度,其中 n 为剩余步数

非线性递归导致超时?

所谓线性递归大概指递归的决策层只有一个分支,或者说递归搜索树只有一个分支

像我上述代码的决策层有 向左/向右/原地不动 三种决策的就显然是个非线性递归,通常都很慢需要剪枝或记忆化才能提速

记忆化搜索

斐波那契递归的记忆化

斐波那契递归解法也是个典型的非线性递归

假设斐波那契数列的第 n 项为 fib(n),很容易想到斐波那契数列的 fib(3) 的搜索树可以展开为:

fib(3)=fib(2)+fib(1)=(fib(1)+fib(0))+fib(1)=2*fib(1)+fib(0)

我们发现 fib(1) 被重复计算了两次,所以业界有种「记忆化搜索」的优化策略

具体实现是定义一个 HashMap,key 为递归函数的入参,value 为该入参情况的计算结果

例如计算 fib(3) 的过程中,第一次遇到 fib(1) 这个入参时进行计算,并将计算结果存入 HashMap 中,

第二次递归调用 fib(1) 时可以直接从 HashMap 中查表取结果而不需要「重复计算」

这种优化思路有点像缓存,相信一个无状态的函数同样的入参一定能得到同样的结果,所以第二次遇到同样的入参时直接拿上一次相同入参的计算结果去返回

记忆化搜索的实现条件

我第一版的递归搜索代码中,方案总数作为可变指针参数来传入,这种写法「不能用记忆化搜索优化」

因函数 fn num_ways_dfs(cur_position: i32, remain_steps: i32, max_position: i32, plans_count: &mut u32)

并没有返回值,我无法实现一个 key 为入参,value 为该入参的上次计算结果返回值这样的记忆化缓存

逆向思维: 自下而上的递归

假设 f(pos,steps)=plans 表示从原点出发,当前位置 pos,剩余步数为 steps 的方案总数 plans

很容易想到 状态转移规律: f(0,0)=f(0,1)+f(1,1)

也就是终点是原点的前一个状态只能是: 前一个位置是 0 然后选择原地不动 或 前一个位置是 1 然后向左走

然后参考「数学归纳法」可以按照相同的规律将 f(0,1) 和 f(1,1) 也展开成子项,直到展开成 f(0, steps) 也就是起点

记忆化搜索的函数签名


#![allow(unused)]
fn main() {
struct NumWaysHelper {
    max_position: i32,
    steps: i32,
    /// memo
    cache: std::collections::HashMap<(i32, i32), u64>
}

impl NumWaysHelper {
    fn dfs(&mut self, cur_pos: i32, remain_steps: i32) -> u64 {
        // TODO 递归结束条件

        let mut plans_count = 0;
        // 做决策/状态转移
        // 上一步是: 原地不动
        // TODO
        if cur_pos == 0 {
            // 上一步是: 向左
            // TODO
        } else if cur_pos == self.max_position {
            // 上一步是: 向左
            // TODO
        } else {
            // 上一步是: 向左或向右
            // TODO
        }
        self.cache.insert((cur_pos, remain_steps), plans_count);
        plans_count
    }
}
}

缓存的写入

其中最关键的就是 self.cache.insert((cur_pos, remain_steps), plans_count); 这行

函数在 return 前先把(当前入参,返回值)这对计算结果「缓存到 HashMap」中

利用缓存避免重复计算


#![allow(unused)]
fn main() {
let mut plans_count = 0;
// 做决策/状态转移
// 上一步是: 原地不动
if let Some(plans) = self.cache.get(&(cur_pos, remain_steps+1)) {
    plans_count += *plans;
} else {
    plans_count += self.dfs(cur_pos, remain_steps+1);
}
}

因为递归调用的开销挺大的,以上上一步是原地不动的决策分支中,一旦发现之前运算过 (cur_pos, remain_steps+1) 的入参情况就直接取缓存中的上次计算结果(因为函数是无状态的,相同的入参一定能得到相同的结果)

记忆化搜索版本的实现


#![allow(unused)]
fn main() {
struct NumWaysHelper {
    max_position: i32,
    steps: i32,
    cache: std::collections::HashMap<(i32, i32), u64>
}

impl NumWaysHelper {
    fn dfs(&mut self, cur_pos: i32, remain_steps: i32) -> u64 {
        if remain_steps == self.steps {
            if cur_pos == 0 {
                return 1;
            } else {
                // 只有从起点出发的方案才是有效的方案,其余方案都不可取(0)
                return 0;
            }
        }

        let mut plans_count = 0;
        // 做决策/状态转移
        // 共同的决策分支-上一步是: 原地不动
        plans_count += self.calc_plans_from_cache(cur_pos, remain_steps+1);
        if cur_pos == 0 {
            // 上一步是: 向左
            plans_count += self.calc_plans_from_cache(cur_pos+1, remain_steps+1);
        } else if cur_pos == self.max_position {
            // 上一步是: 向右
            plans_count += self.calc_plans_from_cache(cur_pos-1, remain_steps+1);
        } else {
            // 上一步是: 向左或向右
            plans_count += self.calc_plans_from_cache(cur_pos+1, remain_steps+1);
            plans_count += self.calc_plans_from_cache(cur_pos-1, remain_steps+1);
        }
        self.cache.insert((cur_pos, remain_steps), plans_count);
        plans_count
    }

    fn calc_plans_from_cache(&mut self, last_pos: i32, last_remain_steps: i32) -> u64 {
        if let Some(plans) = self.cache.get(&(last_pos, last_remain_steps)) {
            *plans
        } else {
            self.dfs(last_pos, last_remain_steps)
        }
    }
}
}

本题缓存与数据库缓存的异同

MySQL 为了提高短时间相同 Query 的查询速度,会将查询的 SQL 语句计算哈希和对应的查询结果存入 Query Cache

在缓存的有效期内,遇到第二个相同的 SQL 查询就能直接从缓存中获取上次查询结果进行返回

MySQL 将 SQL 语句进行哈希是不是跟我们这题将递归调用的入参元祖作为 key 存入 HashMap 类似?

除了数据库,graphql 和 dataloader 也是大量用到了缓存,也是将查询计算 hash 作为 key 存入 HashMap 中

可以了解下 dataloader 这个 crate 的 源码 是如何进行缓存以及解决 N+1 查询的问题的

解决溢出错误

我们记忆化搜索的解法通过了80%的测试用例,但是在输入参数特别大时就出错了

输入:
93
85
输出:
468566822
预期结果:
623333920

看到期待值不对很多人以为「是不是我算法写错了」?

其实不是,一般这种入参很大的都是整数溢出的问题,leetcode 的 Rust 用的是溢出时自动 wrapping 的 release 编译

所谓 wrapping 值得就例如 0_u8.wrapping_sub(1)==255,0_u8 减 1 会下溢成 255

由于 leetcode 的题目描述中也提示了 方案总数可能会很大,所以每次加法都需要取模避免 i32 溢出

我也尝试修改 type PlansCount = i32,就算方案数用 u128 存储也会溢出,所以还是老老实实加法后取模

题解完整代码及测试代码


#![allow(unused)]
fn main() {
type PlansCount = i32;

struct NumWaysHelper {
    max_position: i32,
    steps: i32,
    /// memo
    cache: std::collections::HashMap<(i32, i32), PlansCount>,
}

impl NumWaysHelper {
    /// leetcode rust version not support const_fn pow
    const MOD: PlansCount = 1_000_000_007;
    fn dfs(&mut self, cur_pos: i32, remain_steps: i32) -> PlansCount {
        // 递归结束条件
        if remain_steps == self.steps {
            if cur_pos == 0 {
                return 1;
            }
            // 只有从起点出发的方案才是有效的方案,其余方案都不可取(0)
            return 0;
        }

        // 做决策/状态转移
        // 共同的决策分支: 上一步-原地不动
        let mut plans_count = self.calc_plans_from_cache(cur_pos, remain_steps + 1);
        if cur_pos == 0 {
            // 上一步是: 向左
            plans_count += self.calc_plans_from_cache(cur_pos + 1, remain_steps + 1);
        } else if cur_pos == self.max_position {
            // 上一步是: 向右
            plans_count += self.calc_plans_from_cache(cur_pos - 1, remain_steps + 1);
        } else {
            // 上一步是: 向左或向右
            plans_count += self.calc_plans_from_cache(cur_pos + 1, remain_steps + 1);
            plans_count =
                plans_count % Self::MOD + self.calc_plans_from_cache(cur_pos - 1, remain_steps + 1);
        }
        self.cache.insert((cur_pos, remain_steps), plans_count);
        plans_count
    }

    /// can't use map_or_else, reason: Error: closure requires unique access to `self` but `self` is already borrowed
    #[allow(clippy::option_if_let_else)]
    fn calc_plans_from_cache(&mut self, last_pos: i32, last_remain_steps: i32) -> PlansCount {
        (if let Some(plans) = self.cache.get(&(last_pos, last_remain_steps)) {
            *plans
        } else {
            self.dfs(last_pos, last_remain_steps)
        }) % Self::MOD
    }
}

fn num_ways_dfs_entrance(steps: i32, arr_len: i32) -> i32 {
    let mut helper = NumWaysHelper {
        max_position: arr_len - 1,
        steps,
        cache: std::collections::HashMap::new(),
    };
    helper.dfs(0, 0) % NumWaysHelper::MOD
}

#[test]
fn test_num_ways() {
    const TEST_CASES: [(i32, i32, i32); 4] = [(93, 85, 623333920), (3, 2, 4), (2, 4, 2), (4, 2, 8)];
    for (steps, arr_len, plans_count) in TEST_CASES {
        assert_eq!(num_ways_dfs_entrance(steps, arr_len), plans_count);
    }
}
}

完整源码: https://github.com/pymongo/leetcode-rust/blob/b6f0101a50a70512c12dd33333bfa535307ac40e/src/dp/number_of_ways_to_stay_in_the_same_place_after_some_steps.rs#L277

小结下逐步优化题解的过程

首先是根据题目意思写出了无缓存/无记忆化的从搜索树自上而下的递归解法,实现的过程中逐步理解了动态规划的状态转移方程,

进而写出了带缓存的深度优先搜索解法,解决了溢出等小问题后终于通过了

为什么不是 dp[i][j] 的动态规划写法

有读者可能疑惑,为什么 leetcode 这题官方题解或绝大部分题解都用 dp[i][j] 这种写法

我的题解运行速度比 dp[i][j] 的写法慢得多

首先我要明确一点动态规划其实是有两种主流的写法的,一种就是常见的 dp[i][j] 迭代写法去填表

另一种就是我介绍的递归记忆化/缓存化搜索

dp[i][j] 写法的最大毛病就是「可读性极差」,构思难度高

我以前写的动态规划代码,过五个月再看完全忘记 i 和 j 表达什么意思了

dfs(cur_position: i32, remain_steps: i32) 这种写法不比 dp[i][j] 的可读性强很多?

记忆化搜索另一种好处就是,可以快速写出简单的无缓存版本,再慢慢优化解决超时问题,而迭代的动态规划写法起步就很难

所以我个人更推荐大家多练习记忆化搜索解动态规划,这种借鉴数据库缓存的思路还是很简单的,面试中遇到不熟悉的动态规划题可以先试着用记忆化搜索去解决

Rust 与 OS | 一种有效的页表系统抽象设计

作者:洛佳 / 后期编辑 :张汉东


从段进化到页,是操作系统内存抽象的一大进步之处。页式存储管理由软硬件共同实现,软件提供映射关系, 硬件来加速地址翻译的过程。为了更好地设计硬件,往往要求软件页满足一定的数据要求。

这篇笔记尝试梳理软件层次上,页的实现过程。将会包括大页的分配算法,物理地址的对齐,多种分页模式的兼容设计, 以及如何使用泛型、模块化等现代语言技术实现它们。

可能讲得不清楚,请各位看官海涵。

大页分配算法

页式存储系统拥有“大页”的概念。在一些架构下,大页要求地址对齐。 即大页的物理地址必须对齐到特定的级别,随后的低级页号和索引,将共同看作更长的索引使用。 这就对分配算法提出了一定的要求。

归纳需要解决的问题。我们的问题是:给定页表系统M,最大等级为n;对虚拟页号v = (vn-1, ...v1, v0)和待映射连续页数n, 找到一组页的映射集合U = { (v_开始, p_开始, 长度) },使得页号v能映射到物理页号p。 考虑使用大页优化这一过程,使| U |越少越好,但需要注意对齐要求,即对等级为k的大页, 起始地址为(vn-1, ...vk, 0, ...0)k-10级均为0,即假如k级的对齐要求为a[k],有v = 0 (mod a[k])

M = Sv39系统中,最大等级n = 3,对齐要求a[0] = 1, a[1] = 512, a[2] = 26'2144[注释1]。即,每一级别包含512个页表项。

为了简化问题理解,我们定义一个M = 简单的页表系统,最大等级也为n = 3,但a[0] = 1, a[1] = 4, a[2] = 16。 每一级别包含3个页表项。给定虚拟地址v = (2, 2, 2)v + n = (5, 2, 1),要映射到物理地址p = (2, 2, 2)

现在我们在最高的3级,将要分配r(v) = (2, 2, 2)..=(5, 2, 1)p = (2, 2, 2)。 如果直接使用大页,将必须保证2、1级的编号都为0。也就是说,只能分配r(v) = (3, 0, 0)..(5, 0, 0)p = (3, 0, 0)

为什么不分配(2, 0, 0)..(3, 0, 0)(2, 0, 0)呢?因为这将分配超过(2, 2, 2)..=(5, 2, 1)的范围, 和我们要求解的问题不同。同理,也不能分配(5, 0, 0)..(6, 0, 0)(5, 0, 0)。这部分的范围,需要借助更低级的大页来完成。

第3级的分配完成了,然后我们借助2级页表,分配附近零碎的(2, 3, 0)..(3, 0, 0)(5, 0, 0)..(5, 2, 0)。 分配(2, 3, 0)..(3, 0, 0)(2, 3, 0)(5, 0, 0)..(5, 2, 0)(5, 0, 0)

最后,借助1级页表,分配(2, 2, 2)..(2, 3, 0)(2, 2, 2)(5, 2, 0)..(5, 2, 2)(5, 2, 0)。 至此,所有的分配完成了。

由于这种方法要求在虚拟的大页上分配物理的大页,两个大页的基地址必须有相同的对齐方式,因此, 初始传入的虚拟页号和物理页号,差值必须对齐到相应的页,即v = p (mod a[k]),才可以使用这种分配方法;否则就应当使用更低等级的分配方法。 (想想看为什么?)否则就会在分配过程中,产生对齐要求的异常。

比如v = (2, 2, 2)p = (2, 2, 2),就可以使用3级;而v = (2, 2, 2)p = (2, 1, 2),就只能使用2级。

大页分配图示

推广这个结论,我们可以得到一个规律。首先,使用贪心的方法,将地址的上下限分别向内取整,分配最大的大页给最高的地址范围。 然后,对两边的零碎范围,使用低1级的页表,继续分配。继续降低等级,直到所有的页都被分配完毕。

大页分配算法推导

根据对齐规则和所需求的页数,逐级降低算法的起始等级。对等级n = 3的页表系统,以分支形式,我们编写顺序规则的伪代码。


#![allow(unused)]
fn main() {
// input: v: VirtPageNum, p: PhysPageNum, n: usize, a: PageMode;
if (v - p) % (a[2].frame_align()) == 0 && n >= a[2].frame_align() {
    let l2n = (vs2 - ve2) / a[2].frame_align();
    map(2, ve2, vs2, ve2-v+p);
    let l1n = (ve2 - ve1 + vs1 - vs2) / a[1].frame_align();
    map(1, ve1, ve2, ve1-v+p); map(1, vs2, vs1, vs2-v+p);
    let l0n = (n + ve1 - vs1) / a[0].frame_align();
    map(0, v, ve1, p); map(0, vs1, v+n, vs1-v+p);
} else if (v - p) % (a[1].frame_align()) == 0 && n >= a[1].frame_align() {
    let l1n = (vs1 - ve1) / a[1].frame_align();
    map(1, ve1, vs1, ve1-v+p);
    let l0n = (n + ve1 - vs1) / a[0].frame_align();
    map(0, v, ve1, p); map(0, vs1, v+n, vs1-v+p);
} else if (v - p) % (a[0].frame_align()) == 0 && n >= a[0].frame_align() {
    let l0n = n / a[0].frame_align();
    map(0, v, v+n, p);
} else {
    panic!("Can't map v to p under this page mode")
}
}

我们发现,等级低算法的中间变量,也在等级高的地方出现了。于是这个算法可以改成循环的形式。


#![allow(unused)]
fn main() {
// input: v: VirtPageNum, p: PhysPageNum, n: usize, M: PageMode;
for i in M::visit_levels_until(PageLevel::leaf_level()) { // 遍历顺序:[n, ...1, 0]
    let align = M::a(i); // i层的对齐要求
    if (v - p) % align != 0 || n < align { // 对齐要求达不到等级,或者数量不够,使用低级算法
        continue;
    }
    let (mut ve_prev, mut vs_prev) = (None, None);
    for j in M::visit_levels_from(i) { // 遍历顺序:[j, j-1, ...0]
        let a = M::a(j); // j层的对齐要求
        let ve_cur = a * roundup(v / a)
        let vs_cur = a * rounddown((v + n) / a)
        if let (Some(ve_prev), Some(vs_prev)) = (ve_prev, vs_prev) {
            map(j, ve_cur..ve_prev); // 执行映射函数
            map(j, vs_prev..vs_cur);
        } else {
            map(j, ve_cur..vs_cur);
        }
        (ve_prev, vs_prev) = (Some(ve_cur), Some(vs_cur));
    }
    break;
} 
}

这个算法就可以用于任何等级的页表系统了,因此题目要求的算法得到解决。 使用Rust语言的生成器或者迭代器包装算法,即可得到比较好的算法实现。

传统分配算法是,将地址段内的所有地址,映射到最小的页帧上。此时,需要管理多少个页帧,就需要分配多少个页。 大页分配算法通过分配满足对齐要求更少的页,就能完成同样的任务。我们如何比较大页分配算法和传统算法分配的页数呢?

我们取用M = Sv39系统。其中,最大等级n = 3,对齐要求a[0] = 1, a[1] = 512, a[2] = 26'2144

同样分配505'5550个页帧,假设页号对齐能满足最大的26'2144,采用不同的虚拟页号。

虚拟页号数量所需页表数节省等级0等级1等级2
0505'55502270.00%6214619
10505'555012490.02%57465718
20505'555012490.02%57465718
512505'55507380.01%6265818
1024505'55507380.01%6265818
1025505'555012490.02%57465718
26'2144505'55502270.00%6214619
100'0000505'55507380.01%57414519
虚拟页号数量所需页表数节省等级0等级1等级2
30'000011100.00%1N/AN/A
30'00001010100.00%10N/AN/A
30'0000100100100.00%100N/AN/A
30'0000100048948.90%4881N/A
30'00001'00002912.91%27219N/A
30'000010'00003550.36%160195N/A
30'0000100'00009950.10%649292
30'00001000'00007520.01%12858737

可以发现,Sv39下对齐要求高、页帧数量大时,大页只需要小于一千个页表,就能管理百万个页帧空间,非常节省页表的数量。 页帧数量小时,由于对齐要求不高,节省的数量并不明显;对齐要求低时,节省数量也不明显。

实际使用时,尽量给出最大的对齐要求,这样可以在分配大量页帧时,节省更多的页帧空间。 这一结果对芯片外设的布局也有指导作用,如果高级的嵌入式芯片拥有较多外设,尽量将外设的物理地址放置到更高的对齐要求上, 这样操作系统管理时就可以腾出更多的内存空间,供应用使用。

[注释1]:表格中的'号表示万位分隔符,成会明院士:传承祖先的智慧,倡导中文中阿拉伯数字书写方式采用“4位数分隔法”. 中国科学院院刊

抽象软件设计

以Rust语言为例,给出页系统常见结构的抽象方法。

页号

首先定义物理和虚拟页号。


#![allow(unused)]
fn main() {
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct PhysPageNum(usize);

#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct VirtPageNum(usize);
}

“物理页号长度 + 偏移长度”可能大于“架构地址宽度”,这允许我们访问大于架构宽度的地址。 比如RISC-V RV32下,使用Sv32系统,可以访问34位的物理地址,即使架构只有32位。

物理页号和虚拟页号,可以通过对应的地址转换而来。


#![allow(unused)]
fn main() {
impl PhysPageNum {
    pub fn addr_begin<M: PageMode>(&self) -> PhysAddr {
        PhysAddr(self.0 << M::FRAME_SIZE_BITS)
    }
}
}

这种转换关系要求输入页表的模式。不同架构下,地址的偏移量可能不同。

页帧分配器

然后我们需要一个页帧分配器。模仿Rust语言alloc包的设计,可以给出结构如下。


#![allow(unused)]
fn main() {
pub trait FrameAllocator {
    fn allocate_frame(&self) -> Result<PhysPageNum, FrameAllocError>;
    fn deallocate_frame(&self, ppn: PhysPageNum);
}
}

构造页帧分配器时,应当给定一个物理页的范围。

而后,每次请求分配,其中的算法将返回分配的结果,或者当没有页存在时,返回一个错误。


#![allow(unused)]
fn main() {
impl StackFrameAllocator {
    pub fn new(start: PhysPageNum, end: PhysPageNum) -> Self {
        StackFrameAllocator { current: start, end, recycled: Vec::new() }
    }
}
}

页帧分配器只分配编号,不会向被分配的内存中存储或读取数据,所以它的设计与alloc库简单。

这种设计是为了方便测试页帧分配器的正确性和性能。

装箱的页帧

或者说FrameBox,借鉴了Rust中拥有所有权的Box名称,表示拥有所有权的一个页帧。


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct FrameBox<A: FrameAllocator = DefaultFrameAllocator> {
    ppn: PhysPageNum, // 相当于*mut类型的指针
    frame_alloc: A,
}
}

每次新建时,从页帧分配器frame_alloc中得到新的页帧,然后使用所有权语义包装妥当。 当它的生命周期结束,调用页帧分配器,释放所占有的页帧。


#![allow(unused)]
fn main() {
impl<A: FrameAllocator> FrameBox<A> {
    // 分配页帧并创建FrameBox
    pub fn try_new_in(frame_alloc: A) -> Result<FrameBox<A>, FrameAllocError> {
        let ppn = frame_alloc.allocate_frame()?;
        Ok(FrameBox { ppn, frame_alloc })
    }
}

impl<A: FrameAllocator> Drop for FrameBox<A> {
    fn drop(&mut self) {
        // 释放所占有的页帧
        self.frame_alloc.deallocate_frame(self.ppn);
    }
}
}

装箱的页帧实际地保管了页帧内存的所有权,可以向内写入数据,从中读取数据。

页式地址空间

一个表示分页系统实现的结构体,它保管着所有包含的页帧箱子,在释放时会释放其中的所有页帧。

这个结构体拥有一个分页模式的类型参数,用于计算页帧插入算法。


#![allow(unused)]
fn main() {
// 表示一个分页系统实现的地址空间
//
// 如果属于直接映射或者线性偏移映射,不应当使用这个结构体,应当使用其它的结构体。
#[derive(Debug)]
pub struct PagedAddrSpace<M: PageMode, A: FrameAllocator = DefaultFrameAllocator> {
    root_frame: FrameBox<A>,
    frames: Vec<FrameBox<A>>,
    frame_alloc: A,
    page_mode: M,
}
}

当创建页式地址空间时,立即分配一个根页表。


#![allow(unused)]
fn main() {
impl<M: PageMode, A: FrameAllocator + Clone> PagedAddrSpace<M, A> {
    // 创建一个空的分页地址空间。一定会产生内存的写操作
    pub fn try_new_in(page_mode: M, frame_alloc: A) -> Result<Self, FrameAllocError> {
        // 新建一个根页表要求的页帧
        let mut root_frame = FrameBox::try_new_in(frame_alloc.clone())?;
        // 而后,向帧里填入一个空的根页表 
        unsafe { fill_frame_with_initialized_page_table::<A, M>(&mut root_frame) };
        Ok(Self { root_frame, frames: Vec::new(), frame_alloc, page_mode })
    }
}
}

创建结构后,当插入新的映射关系,使用上一节提供的插入算法,得到需要插入的范围,然后读写页帧箱,完成插入操作。


#![allow(unused)]
fn main() {
impl<M: PageMode, A: FrameAllocator + Clone> PagedAddrSpace<M, A> {    
    // 设置页表项。如果寻找的过程中,中间的页表没创建,那么创建它们
    unsafe fn alloc_get_table(&mut self, entry_level: PageLevel, vpn_start: VirtPageNum)
    -> Result<&mut M::PageTable, FrameAllocError> 
    {
        let mut ppn = self.root_frame.phys_page_num();
        for &level in M::visit_levels_before(entry_level) {
            let page_table = unref_ppn_mut::<M>(ppn);
            let vidx = M::vpn_index(vpn_start, level);
            match M::slot_try_get_entry(&mut page_table[vidx]) {
                Ok(entry) => ppn = M::entry_get_ppn(entry),
                Err(mut slot) => {  // 需要一个内部页表,这里的页表项却没有数据,我们需要填写数据
                    let frame_box = FrameBox::try_new_in(self.frame_alloc.clone())?;
                    M::slot_set_child(&mut slot, frame_box.phys_page_num());
                    ppn = frame_box.phys_page_num();
                    self.frames.push(frame_box);
                }
            }
        }
        // println!("[kernel-alloc-map-test] in alloc_get_table PPN: {:x?}", ppn);
        let page_table = unref_ppn_mut::<M>(ppn); // 此时ppn是当前所需要修改的页表
        // 创建了一个没有约束的生命周期。不过我们可以判断它是合法的,因为它的所有者是Self,在Self的周期内都合法
        Ok(&mut *(page_table as *mut _))
    }
    pub fn allocate_map(&mut self, vpn: VirtPageNum, ppn: PhysPageNum, n: usize, flags: M::Flags)
    -> Result<(), FrameAllocError> 
    {
        for (page_level, vpn_range) in MapPairs::solve(vpn, ppn, n, self.page_mode) {
            // println!("[kernel-alloc-map-test] PAGE LEVEL: {:?}, VPN RANGE: {:x?}", page_level, vpn_range);
            let table = unsafe { self.alloc_get_table(page_level, vpn_range.start) }?;
            let idx_range = M::vpn_index_range(vpn_range.clone(), page_level);
            // println!("[kernel-alloc-map-test] IDX RANGE: {:?}", idx_range);
            for vidx in idx_range {
                let this_ppn = PhysPageNum(ppn.0 - vpn.0 + M::vpn_level_index(vpn_range.start, page_level, vidx).0);
                // println!("[kernel-alloc-map-test] Table: {:p} Vidx {} -> Ppn {:x?}", table, vidx, this_ppn);
                match M::slot_try_get_entry(&mut table[vidx]) {
                    Ok(_entry) => panic!("already allocated"),
                    Err(slot) => M::slot_set_mapping(slot, this_ppn, flags.clone())
                }
            }
        }
        Ok(())
    }
}
}

包装的页帧分配算法

定义与实现如下。


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct MapPairs<M> {
    ans_iter: alloc::vec::IntoIter<(PageLevel, Range<VirtPageNum>)>,
    mode: M,
}

impl<M: PageMode> MapPairs<M> {
    pub fn solve(vpn: VirtPageNum, ppn: PhysPageNum, n: usize, mode: M) -> Self {
        let mut ans = Vec::new();
        /* 省略求解过程 */
        Self { ans_iter: ans.into_iter(), mode }
    }
}

impl<M> Iterator for MapPairs<M> {
    type Item = (PageLevel, Range<VirtPageNum>);
    fn next(&mut self) -> Option<Self::Item> {
        self.ans_iter.next()
    }
}
}

每次迭代它的结果,会返回一个应当分配的页帧。应当根据这个结果,设置映射关系。

激活函数

这个函数的实现与具体架构有关,此处以RISC-V Sv39为例。


#![allow(unused)]
fn main() {
// 切换地址空间,同时需要提供1.地址空间的详细设置 2.地址空间编号
pub unsafe fn activate_paged_riscv_sv39(root_ppn: PhysPageNum, asid: AddressSpaceId) {
    use riscv::register::satp::{self, Mode};
    satp::set(Mode::Sv39, asid.0 as usize, root_ppn.0);
    asm!("sfence.vma {}", in(reg) asid.0 as usize);
}
}

执行完毕后,就已经进入新的地址空间了。注意当前的pc地址仍未改变,如果进入新空间后, 指令对应的代码段已经消失了,将产生异常。因此,一般使用各个虚拟空间中共同映射的“跳板页”,完成这一切换过程。

本设计的优缺点

这个设计的优点是,你会发现只需要传入泛型参数M,代表页表模式,就能自动填写算法剩余的部分。

比如,RISC-V Sv39模式可以实现为页表模式,传入泛型参数M;它的定义如下。


#![allow(unused)]
fn main() {
// Sv39分页系统模式;RISC-V RV64下有效
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct Sv39;

impl PageMode for Sv39 {
    const FRAME_SIZE_BITS: usize = 12;
    const PPN_BITS: usize = 44;
    type PageTable = Sv39PageTable;
    type Entry = Sv39PageEntry;
    type Slot = Sv39PageSlot;
    type Flags = Sv39Flags;
    /* 省略了大量的工具函数 */
}
}

只需要实现模式M中的这些参数,就可以无缝使用这个页表空间系统,包括求解算法。

完整的代码实现在这里

这种方法也有缺点,就是需要支持泛型的编程语言才可以使用;比如操作系统内核用Rust写,可以采用这种编程方法。

目前常见的硬件页表系统

RISC-V提供了Sv39和Sv48;它们分别是3、4级的页表系统,等级越高,能管理的虚拟空间越大。

我们使用上一节的描述方法,描述这些页表系统的基本参数。

页表系统M虚拟地址物理地址等级n对齐要求a
RISC-V Sv39395531, 512, 26'2144
RISC-V Sv48485541, 512, 26'2144, 1'3421'7728
RISC-V Sv323234*21, 1024
龙芯 LA64486041, 4096/2048*, ...
龙芯 LA32323621, 1024
arm64483941, 512, 26'2144, 1'3421'7728
arm32323221, 256
x86-64 (旧)484741, 512, 26'2144, 1'3421'7728
x86-64 (新)575251, 512, 26'2144, 1'3421'7728, 687'1947'6736
x86-32323221, 1024

*Sv32的物理地址的确超过32位

*龙芯LA64架构中,双页存储结构对齐要求不同

系统启动时,内核可以激活Sv39,页表的等级少,开销较低,启动快。随后,根据需求,可以更换到更大空间的页表系统,来容纳更多应用。

页式内存管理笔记

在文章的最后,我们花一些时间尝试整理页式内存管理概念的笔记。

页式内存管理

要管理挂载的外设和内存块,我们定义物理地址,它是真实硬件中资源单元的编号。 所有的物理地址构成一个地址空间;地址空间是可由地址索引的,具体资源和硬件的集合。

我们的应用程序可以直接在物理地址上运行。然而,为了便于程序独占地址空间,便于连续地规划内存, 我们为它们构造虚拟空间。于是程序可以在虚拟地址上运行,虚拟地址是虚拟空间中内存单元的编号。

从前我们使用段的方式管理内存。为了减少内碎片和内存的浪费,我们引入了分页管理系统。

分页系统将地址空间分为连续的等大内存块,它们被称作页帧。 又将一个或多个连续页帧组成一个页,页的大小由硬件实现决定,软件必须按硬件给定页的大小。

管理页的数据结构称作页表。在内存中,页表通常占一个页帧的大小,以便硬件上的分页系统管理。 页表在内存中的存储位置被称作页号。

超过一个页帧大小的页,又被称作大页。管理大页的页表中,每个项目代表一个子页。 这个项目被称作页表的页表项,通常由权限位、控制位和物理页号组成。

将地址空间看作最大的页,根页表就是管理最大页的页表。根页表的页号会被保存在专用的位置中,以作为硬件查询的起始地址使用。

翻译过程与地址对齐

现代的页表系统中,无论等级,都称管理页的数据结构为页表。页表的翻译过程大致等同于这个步骤:

  1. 取出根页表的页号
  2. 读取虚拟地址的特定区域,作为本级页表的索引
  3. 根据索引,取出管理更小页的页表项
  4. 如果页表项指向子页表,读取页号,返回到步骤2

在这个简化的步骤中,可能出现非常多的异常。需要注意的是,如果当前页表项指向物理页号,这个物理页号有对齐要求。

我们如果使用(v0, v1, v2):offset代表一个虚拟地址。

如果rt[v0][v1]指向子页表,那么将继续查找rt[v0][v1][v2]。得到物理页号(p0, p1, p2)。 和off结合,得到物理地址(p0, p1, p2):offset

如果rt[v0][v1]指向一个物理页,这是一个大页的页表项。页表项中,得到物理页号(p0, p1, 0), 直接将v2、off拼接,得到大页对应的物理地址为(p0, p1):v2,offset;相当于延长了偏移量的位数。

采用后一种形式时,硬件通常要求物理页号只有高于大页的等级有效,低于它的无效。 也就是说,如果页表项(p0, p1, p2)的p2不等于零,将会返回页异常。这就是大页页表系统的对齐要求。


作者简介:

洛佳

华中科技大学网络空间安全学院本科生,热爱操作系统、嵌入式开发。3年Rust语言开发经验,社区活跃贡献者。目前致力于向产业、教学等更多的领域推广Rust语言。

学习笔记 | 树莓派 Rust 嵌入式操作系统 之 驱动:GPIO 和 UART

作者:张汉东

学习笔记系列在线阅读地址:https://zhanghandong.github.io/raspberrypi-os-tutorials-notes/


这是我对官方 Operating System development tutorials in Rust on the Raspberry Pi 的学习笔记。以此来学习 Rust 裸机(Bare-Matel)编程。

学习笔记代码仓库:https://github.com/ZhangHanDong/raspberrypi-os-tutorials-notes,欢迎反馈交流。

注意:如果需要动手实现,请使用官方教程源码仓库配合本学习笔记。

当前已更新:

  • ARM 汇编基础
  • 00: 准备工作
  • 01: 循环等待
  • 02: 初始化执行环境(runtime)
  • 03: 硬核输出 Hello World
  • 04: 安全访问全局数据结构
  • 05: 驱动:GPIO 和 UART

本文节选第五章内容。


驱动:GPIO 和 UART

注意:这章不支持 BSP=rpi4 make qemu

一些前置知识

Arm 物理内存 vs Arm 虚拟地址空间

Arm 与物理内存等外设统一编址在4GB(32位)的地址空间中。而 x86 是将内存单独编址在一个地址空间,外设I/O端口在另外的地址空间,要访问IO地址空间需要用专门的指令操作。

Linux的每个进程都有4GB的虚拟地址空间,其中1~3GB是每个进程独占的用户空间,3GB~4GB是所有进程共享的内核空间(0xC0000000~0xFFFFFFFF)。因此虚拟地址空间包括内核空间与用户空间。linux的实虚地址的转换就是有MMU通过页表的形式完成转换的。

Arm 虚拟地址空间布局是一个标准的linux kernel实现,地址空间被分割成 1G 内核空间和 3G 用户空间,内核空间地址范围为0xC0000000 - 0xEFFFFFFF, 用户空间地址范围为 0x00000000 - 0xBFFFFFFF

通过操作内存来访问外设,这种方式称为 memory-mapped IO

需要注意:外设(Peripherals)的物理地址空间为 0x20000000+16MB,一般在内核虚拟地址中被映射到0x7E000000+16MB

树莓派 4b bcm 2711 文档中描述了ARM所见地址之间的映射:

rpi4: 0xFE200000 - 0xFE2000b3 : gpio@ 0x7E200000

只需把 fe200000 减去GPIO-BaseAddr(0x00200000),就可获得树莓派的PeripheralBaseAddr(PBase):0xFE000000。

GPIO 与 UART

在学习这一章之前,还是需要一些前置知识的。对树莓派如果不了解的话,很难理解它的代码在做什么。这部分内容,如果你暂时不想看,或者已经了解过,可以跳过。需要的时候可以回头看看。

GPIO(General Purpose I/O Ports)意思为通用输入/输出端口,通俗地说,就是一些引脚,可以通过它们输出高低电平或者通过它们读入引脚的状态-是高电平或是低电平。GPIO是个比较重要的概念,用户可以通过GPIO口和硬件进行数据交互(如UART),控制硬件工作(如LED、蜂鸣器等),读取硬件的工作状态信号(如中断信号)等。GPIO口的使用非常广泛。掌握了GPIO,差不多相当于掌握了操作硬件的能力。

以树莓派 4B 为例,其 40 针脚见下图:

gpio

UART(Universal Asynchronous Receiver/Transmitter),是一种串行通信协议,其中数据是串行传输的,一次传输一个字节的数据,即逐位传输。作为把并行输入信号转成串行输出信号的芯片,UART 通常被集成于其他通讯接口的连结上。

为了和树莓派串口通信,我们将树莓派的 UART 针脚连接到个人电脑(下面简称 PC)上。

UART 的端口至少有 RX、TX 和地线三个针脚。RX 负责读取,TX 负责输出。如果有两个 UART 端口,它们的连接方式如下:

uart

注意这里是:usb2ttl(RXD) <-> gpio(TXD) 以及 usb2ttl(TXD) <-> gpio(RXD)。另外注意,不要连接usb2tt 上的电源线到树莓派,小心烧坏树莓派。

uart 协议层

协议层中,规定了数据包的内容,它由起始位、主体数据、校验位以及停止位组成,通信双方的数据包格式要约定一致才能正常收发数据 。

  • 波特率:异步通信中由于没有时钟信号,所以2个通信设备需约定好波特率,常见的有4800、9600、115200等。
  • 通信的起始和停止信号:串口通信的一个数据包从起始信号开始,知道停止信号结束。数据包的起始信号由一个逻辑0的数据位表示,而数据包的停止信号可由0.5、1、1.5或2个逻辑1的数据位表示,只要双方约定一致即可。
  • 有效数据:在数据包的起始位之后紧接着的就是要传输的主体数据内容,也称为有效数据,有效数据的长度常被约定为8位或9位长。
  • 数据校验:在有效数据之后,有一个可选的数据校验位。由于数据通信相对容易受到外部干扰导致传输数据出现偏差,可以在传输过程加上校验位来解决这个问题。校验方法有奇校验(odd)、偶校验(even)、0校验(space)、1校验(mark)以及无校验(noparity)。
  • 奇校验要求有效数据和校验位中“1”的个数为奇数,比如一个 8 位长的有效数据为:01101001,此时总共有 4 个“1”,为达到奇校验效果,校验位为“1”,最后传输的数据将是 8 位的有效数据加上 1 位的校验位总共 9 位。偶校验与奇校验要求刚好相反,要求帧数据和校验位中“1”的个数为偶数,比如数据帧:11001010,此时数据帧“1”的个数为 4 个,所以偶校验位为“0”。0 校验是不管有效数据中的内容是什么,校验位总为“0”,1 校验是校验位总为“1”

uart 波特率计算

公式:

baud

其中,fck为 USART 时钟, USARTDIV 是一个存放在波特率寄存器(USART_BRR)的一个无符号定点数。其中 DIV_Mantissa[11:0]位定义 USARTDIV 的整数部分,DIV_Fraction[3:0]位定义 USARTDIV 的小数部分。

例如:DIV_Mantissa=24(0x18)DIV_Fraction=10(0x0A),此时 USART_BRR 值为0x18A;那么USARTDIV的小数位10/16=0.625;整数位24,最终USARTDIV的值为24.625

波特率的常用值有 2400960019200115200

如何设定寄存器值得到波特率的值?

假设:串口设置为961200 8N1,即 波特率为 961200,8数据位,N表示没有校验位,1位停止位。

而在内核 config.txt中将时钟设置为 48MHZinit_uart_clock=48000000)。

那么波特率(baud)计算为: (48_000_000 / 16) / 921_600 = 3.2552083。这意味着整数部分(DIV_Mantissa)为3,小数部分是0.2552083

根据PL011技术参考手册进行的 DIV_Fraction计算: INTEGER( (0.2552083 * 64) + 0.5 ) = 16INTEGER代表取整。

因此生成的波特率分频器(baud rate divider)为 3 + 16/64 = 3.25,那么生成的波特率是48_000_000 / (16 * 3.25) = 923_077。误差率为 ((923_077 - 921_600) / 921_600) * 100 = 0.16%

那么反过来,如果想用波特率为 115200 8N1,则:

  • 整数部分:(48000000/16)/115200 = 26.0416666667
  • 小数部分: INTEGER((0.0416666667 * 64) + 0.5) = 3

设置正确的波特率比较重要。

树莓派启动流程

树莓派在设计的时候,为了节省成本,没有使用掉电非易失性存储介质,也就在板子上不能找到相关的flash。这样芯片启动的程序只能放在sd卡里面了。本来U盘启动也是一种方式,但是树莓派4代之前的设计并不是很好,导致这种启动方式不可以使用。树莓派4也有一些尝试,但是目前树莓派4最主流的方式还是使用SD卡启动。

Raspberry Pi 4具有一个SPI连接的EEPROM(4MBits / 512KB)。其中包含用于启动系统的代码,并替换了先前在SD卡的启动分区中找到的bootcode.bin。如果你的树莓派4通电了但是一直无法启动系统,且绿灯常亮不闪,需要检查两种情况:

  1. 有没有插入 sd 卡。
  2. 如果有sd卡,那么就是EEPROM被损坏了。你需要重新格式化sd卡,并去官网下载Recovery固件修复即可。

在树莓派裸机实验中,从 sd 卡启动系统必须包含必要的文件:

  1. bootcode.bin (树莓派4不需要,之前的需要)。
  2. config.txt
  3. kernel8.img,内核镜像
  4. start4.elf
  5. bcm2711-rip-4.dtb

启动流程:

  1. 芯片上电,执行固化在内部的 first-stage bootloader,用于加载sd卡中的bootcode.bin文件。但是在树莓派4上,不需要这个文件,因为有了有了SPI的EEPROM。
  2. 启动 GPU。ARM Cortex-A72 Core处于standby状态,VideoCore IV GPU Core负责启动系统。
  3. bootcode.bin读取到了128K大小的二级缓存(L2 Cache)中。开始执行bootcode.bin代码。用于初始化RAM,并且把start4.elf加载到内存中,并且去读取config.txt中的配置信息,设置这些配置信息。当然,树莓派4就没有bootcode.bin什么事了。
  4. bcm2711-rpi-4-b.dtb文件也是需要的,如果不存在也会影响串口的输出,也就是只会有乱码输出。所以推测start4.elf文件也会去读取设备树文件,然后设置一些基本的参数。

config.txt 配置信息


#![allow(unused)]
fn main() {
enable_uart=1 // 表示使用的是miniUART
arm_64bit=0 // 告诉arm要启动的程序是32位的
core_freq=250 // 设置arm的频率
kernel=kernel7.img // 表示从start4.elf执行的程序是哪一个
kernel_address=0x8000 // 表示需要执行的内存地址,这个地址就是裸机程序在链接时的入口地址
}

这些配置并不是每个都必须,主要看实际情况。前期理解芯片的启动过程有助于对后面编写裸机代码的分析。

重要

以上概念,需要配合本章代码和真实的树莓派实机来验证才能有更深刻的领会。

代码释意

第五章算是一个里程碑。

前四章完成了从树莓派裸机到建立 Rust 执行环境,但都是基于QEMU。从第五章开始支持真实的树莓派执行内核代码。

所以,为了和真实的树莓派通信,我们需要实现两个驱动程序。

引导程序

引导程序基本和上一章代码一样。只是在 src/_arch/aarch64/cpu.rs 里增加了针对树莓派3的相关代码:


#![allow(unused)]
fn main() {
pub use asm::nop;

/// Spin for `n` cycles.
#[cfg(feature = "bsp_rpi3")]
#[inline(always)]
pub fn spin_for_cycles(n: usize) {
    for _ in 0..n {
        asm::nop();
    }
}
}

这个后面会用到。此处使用条件编译,指定bsp_rpi3 feature。使用汇编的nop操作实现cpu 空等待。

内核初始化

打开 src/main.rs ,看到 kernel_init 函数有了很大变化。


#![allow(unused)]
fn main() {
// 因为只有单核(core0)被激活执行初始化代码,能保证正确执行顺序,所以现在是安全的
/// # Safety
///
/// - Only a single core must be active and running this function.
/// - The init calls in this function must appear in the correct order.
unsafe fn kernel_init() -> ! {
    // 此处增加了 驱动管理
    use driver::interface::DriverManager;
    // 迭代驱动实例初始化,如果失败则 panic
    for i in bsp::driver::driver_manager().all_device_drivers().iter() {
        if let Err(x) = i.init() {
            panic!("Error loading driver: {}: {}", i.compatible(), x);
        }
    }
    // 
    bsp::driver::driver_manager().post_device_driver_init();
    // println! is usable from here on.

    // 这个是安全的函数
    // Transition from unsafe to safe.
    kernel_main()
}

/// The main function running after the early init.
fn kernel_main() -> ! {
    use bsp::console::console;
    use console::interface::All;
    use driver::interface::DriverManager;

    println!(
        "[0] {} version {}",
        env!("CARGO_PKG_NAME"),
        env!("CARGO_PKG_VERSION")
    );
    println!("[1] Booting on: {}", bsp::board_name());

    // 打印驱动加载过程
    println!("[2] Drivers loaded:");
    for (i, driver) in bsp::driver::driver_manager()
        .all_device_drivers()
        .iter()
        .enumerate()
    {
        println!("      {}. {}", i + 1, driver.compatible());
    }

    println!(
        "[3] Chars written: {}",
        bsp::console::console().chars_written()
    );

    // 下面打印回显信息
    println!("[4] Echoing input now");

    // 进入回显模式之前,请丢弃所有接收到的噪音字符
    // Discard any spurious received characters before going into echo mode.
    console().clear_rx();
    loop {
        let c = bsp::console::console().read_char();
        bsp::console::console().write_char(c);
    }
}
}

内存映射

MMIO 映射物理内存代码被定义在 src/bsp/raspberrypi/memory.rs中。


#![allow(unused)]
fn main() {
//--------------------------------------------------------------------------------------------------
// Public Definitions
//--------------------------------------------------------------------------------------------------

/// The board's physical memory map.
#[rustfmt::skip]
pub(super) mod map {

    pub const GPIO_OFFSET:         usize = 0x0020_0000;
    pub const UART_OFFSET:         usize = 0x0020_1000;

    /// Physical devices.
    #[cfg(feature = "bsp_rpi3")]
    pub mod mmio {
        use super::*;

        pub const START:            usize =         0x3F00_0000;
        pub const GPIO_START:       usize = START + GPIO_OFFSET;
        pub const PL011_UART_START: usize = START + UART_OFFSET;
    }

    // 注意,树莓派4 的物理内存基址为 0xFE00_0000 (前置知识有描述)
    /// Physical devices.
    #[cfg(feature = "bsp_rpi4")]
    pub mod mmio {
        use super::*;

        pub const START:            usize =         0xFE00_0000;
        pub const GPIO_START:       usize = START + GPIO_OFFSET;
        pub const PL011_UART_START: usize = START + UART_OFFSET;
    }
}

}

驱动

在看 src/bsp/raspberrypi.rs 代码:


#![allow(unused)]
fn main() {
//--------------------------------------------------------------------------------------------------
// Global instances
// 这里定义俩全局静态变量 GPIO 和 PL011_UART ,用于持有相应的基址。
// --------------------------------------------------------------------------------------------------
use super::device_driver;

static GPIO: device_driver::GPIO =
    unsafe { device_driver::GPIO::new(memory::map::mmio::GPIO_START) };

static PL011_UART: device_driver::PL011Uart =
    unsafe { device_driver::PL011Uart::new(memory::map::mmio::PL011_UART_START) };

//--------------------------------------------------------------------------------------------------
// Public Code
//--------------------------------------------------------------------------------------------------

/// Board identification.
pub fn board_name() -> &'static str {
    #[cfg(feature = "bsp_rpi3")]
    {
        "Raspberry Pi 3"
    }

    #[cfg(feature = "bsp_rpi4")]
    {
        "Raspberry Pi 4"
    }
}
}

接下来我们看一下 src/driver.rs 代码。


#![allow(unused)]
fn main() {
// 为驱动定义一个 interface 模块,当命名空间使用
// 该模块定义了两个 trait,规范了驱动的行为 和 管理操作
/// Driver interfaces.
pub mod interface {

    /// Device Driver functions.
    pub trait DeviceDriver {
        // 设备树基本属性之一,用于指定兼容的系统
        /// Return a compatibility string for identifying the driver.
        fn compatible(&self) -> &'static str;


        // 这段 Unsafe Rust 代码写的很标准
        // 初始化函数 init 为 unsafe 操作,因为在初始化的时候,驱动可能会对整个系统产生影响,所以这里加上 `#Safety` 注释来说明这种情况,并且为函数加上 `unsafe` 标签。
        // 整个函数是由内核调用以启动设备
        /// Called by the kernel to bring up the device.
        ///
        /// # Safety
        ///
        /// - During init, drivers might do stuff with system-wide impact.
        unsafe fn init(&self) -> Result<(), &'static str> {
            Ok(())
        }
    }

    /// Device driver management functions.
    /// `BSP` 应该提供一个全局实例
    /// The `BSP` is supposed to supply one global instance.
    pub trait DriverManager {
        // 返回所有实例化驱动的引用集合(切片)
        // 该函数返回 DeviceDriver trait对象,用 `'static`是因为该trait中方法返回都是 `'static str`。
        /// Return a slice of references to all `BSP`-instantiated drivers.
        ///
        /// # Safety
        /// 设备的顺序是调用`DeviceDriver::init()`的顺序
        /// - The order of devices is the order in which `DeviceDriver::init()` is called.
        fn all_device_drivers(&self) -> &[&'static (dyn DeviceDriver + Sync)];

        // 驱动程序初始化后运行的初始化代码
        /// Initialization code that runs after driver init.
        ///
        /// For example, device driver code that depends on other drivers already being online.
        fn post_device_driver_init(&self);
    }
}
}

具体的驱动代码实现在 src/bsp/device_driver/bcm.rs 模块中。

先来看一下 src/bsp/device_driver/common.rs


#![allow(unused)]
fn main() {
//! Common device driver code.

use core::{marker::PhantomData, ops};

//--------------------------------------------------------------------------------------------------
// Public Definitions
//--------------------------------------------------------------------------------------------------
// 对 MMIO 指针地址做了一个 Rust 包装,此处引入 `PhantomData<fn() -> T>`,为的保证只能传入 `'static`的引用。
pub struct MMIODerefWrapper<T> {
    start_addr: usize,
    phantom: PhantomData<fn() -> T>,
}

//--------------------------------------------------------------------------------------------------
// Public Code
//--------------------------------------------------------------------------------------------------

impl<T> MMIODerefWrapper<T> {
    /// Create an instance.
    pub const unsafe fn new(start_addr: usize) -> Self {
        Self {
            start_addr,
            phantom: PhantomData,
        }
    }
}

// 为 `MMIODerefWrapper<T>` 实现 Deref ,作为智能指针使用,返回一个引用
impl<T> ops::Deref for MMIODerefWrapper<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        unsafe { &*(self.start_addr as *const _) }
    }
}

}

再看 src/bsp/device_driver/bcm/bcm2xxx_gpio.rs,这是 GPIO 驱动的具体实现。下面摘录一些只和树莓派4b相关的关键代码:


#![allow(unused)]
fn main() {
use crate::{
    bsp::device_driver::common::MMIODerefWrapper, driver, synchronization,
    synchronization::NullLock,
};
// 此处用到了 `register-rs` 库,是一个类型安全的 MMIO 和 CPU 寄存器访问的库。
use register::{mmio::*, register_bitfields, register_structs};


// GPIO registers.
// 下面连接是 GPIO 寄存器的一些规格文档
// Descriptions taken from
// - https://github.com/raspberrypi/documentation/files/1888662/BCM2837-ARM-Peripherals.-.Revised.-.V2-1.pdf
// - https://datasheets.raspberrypi.org/bcm2711/bcm2711-peripherals.pdf

// Raspberry Pi 3/4 具有两个UART器件:PL011 UART和 mini UART。 
// 而 PL011 UART连接到蓝牙模块,而 mini UART 用作主要 UART。
// 但我们可以初始化 GPIO 寄存器来直接使用 PL011 UART 来替代 mini UART。

// 此宏为 `register-rs` 库提供,用于定义 MMIO 寄存器
register_bitfields! {
    u32,

    // 为了使用 PL011 UART 
    // 需要将 GPFSEL1寄存器 的 FSEL14 和 FSEL15 位字段设置为与 AltFunc0 功能相对应的 0b100 地址。
    /// GPIO Function Select 1
    GPFSEL1 [
        /// Pin 15
        FSEL15 OFFSET(15) NUMBITS(3) [
            Input = 0b000,
            Output = 0b001,
            AltFunc0 = 0b100  // PL011 UART RX

        ],

        /// Pin 14
        FSEL14 OFFSET(12) NUMBITS(3) [
            Input = 0b000,
            Output = 0b001,
            AltFunc0 = 0b100  // PL011 UART TX
        ]
    ],

    // 为了使用 PL011 UART 
    // 通过将GPPUD寄存器设置为0
    // 树莓派3需要,此处省略
    // ... ...

    // 为了使用 PL011 UART 
    // 将GPIO_PUP_PDN_CNTRL_REG0 寄存器 GPIO_PUP_PDN_CNTRL15 和 GPIO_PUP_PDN_CNTRL14位字段设置为1来关闭 Pullup 来启用这些引脚。 
    /// GPIO Pull-up / Pull-down Register 0
    ///
    /// BCM2711 only.
    GPIO_PUP_PDN_CNTRL_REG0 [
        /// Pin 15
        GPIO_PUP_PDN_CNTRL15 OFFSET(30) NUMBITS(2) [
            NoResistor = 0b00,
            PullUp = 0b01
        ],

        /// Pin 14
        GPIO_PUP_PDN_CNTRL14 OFFSET(28) NUMBITS(2) [
            NoResistor = 0b00,
            PullUp = 0b01
        ]
    ]
}

register_structs! {
    #[allow(non_snake_case)]
    RegisterBlock {
        (0x00 => _reserved1),
        (0x04 => GPFSEL1: ReadWrite<u32, GPFSEL1::Register>),
        (0x08 => _reserved2),
        (0x94 => GPPUD: ReadWrite<u32, GPPUD::Register>),
        (0x98 => GPPUDCLK0: ReadWrite<u32, GPPUDCLK0::Register>),
        (0x9C => _reserved3),
        (0xE4 => GPIO_PUP_PDN_CNTRL_REG0: ReadWrite<u32, GPIO_PUP_PDN_CNTRL_REG0::Register>),
        (0xE8 => @END),
    }
}

// 关联的MMIO寄存器的抽象
/// Abstraction for the associated MMIO registers.
type Registers = MMIODerefWrapper<RegisterBlock>;

//--------------------------------------------------------------------------------------------------
// Public Definitions
//--------------------------------------------------------------------------------------------------

pub struct GPIOInner {
    registers: Registers,
}

// Export the inner struct so that BSPs can use it for the panic handler.
pub use GPIOInner as PanicGPIO;

// GPIO 硬件抽象
/// Representation of the GPIO HW.
pub struct GPIO {
    inner: NullLock<GPIOInner>,
}

//--------------------------------------------------------------------------------------------------
// Public Code
//--------------------------------------------------------------------------------------------------

impl GPIOInner {
    /// Create an instance.
    ///
    /// # Safety
    /// 此处用户必须确保提供正确的 MMIO start 地址,所以用 unsafe 标记函数
    /// - The user must ensure to provide a correct MMIO start address.
    pub const unsafe fn new(mmio_start_addr: usize) -> Self {
        Self {
            registers: Registers::new(mmio_start_addr),
        }
    }

    // 关闭引脚 14 和 15 的 pull-up/down 
    /// Disable pull-up/down on pins 14 and 15.
    #[cfg(feature = "bsp_rpi4")]
    fn disable_pud_14_15_bcm2711(&mut self) {
        self.registers.GPIO_PUP_PDN_CNTRL_REG0.write(
            GPIO_PUP_PDN_CNTRL_REG0::GPIO_PUP_PDN_CNTRL15::PullUp
                + GPIO_PUP_PDN_CNTRL_REG0::GPIO_PUP_PDN_CNTRL14::PullUp,
        );
    }

    // 将  PL011 UART 映射为标准输出
    /// Map PL011 UART as standard output.
    ///
    /// TX to pin 14
    /// RX to pin 15
    pub fn map_pl011_uart(&mut self) {
        // Select the UART on pins 14 and 15.
        self.registers
            .GPFSEL1
            .modify(GPFSEL1::FSEL15::AltFunc0 + GPFSEL1::FSEL14::AltFunc0);

        // Disable pull-up/down on pins 14 and 15.
        #[cfg(feature = "bsp_rpi3")]
        self.disable_pud_14_15_bcm2837();

        #[cfg(feature = "bsp_rpi4")]
        self.disable_pud_14_15_bcm2711();
    }
}

impl GPIO {
    /// Create an instance.
    ///
    /// # Safety
    ///
    /// - The user must ensure to provide a correct MMIO start address.
    pub const unsafe fn new(mmio_start_addr: usize) -> Self {
        Self {
            inner: NullLock::new(GPIOInner::new(mmio_start_addr)),
        }
    }

    /// Concurrency safe version of `GPIOInner.map_pl011_uart()`
    pub fn map_pl011_uart(&self) {
        self.inner.lock(|inner| inner.map_pl011_uart())
    }
}

//------------------------------------------------------------------------------
// OS Interface Code
//------------------------------------------------------------------------------
use synchronization::interface::Mutex;

// 注意,这里还有个 init 方法使用默认实现。
impl driver::interface::DeviceDriver for GPIO {
    fn compatible(&self) -> &'static str {
        "BCM GPIO"
    }
}

}

接下来看 src/bsp/device_driver/bcm/bcm2xxx_pl011_uart.rs中 PL011 uart 的驱动实现。

只摘录关键代码聊聊。定义寄存器和 GPIO 驱动类似。有一些关于 uart 读取的代码就没贴出来。


#![allow(unused)]
fn main() {
use register::{mmio::*, register_bitfields, register_structs};

impl PL011UartInner {
    // ... ...

    pub fn init(&mut self) {
        self.flush();

        // Turn the UART off temporarily.
        self.registers.CR.set(0);

        // Clear all pending interrupts.
        self.registers.ICR.write(ICR::ALL::CLEAR);

        // Set the baud rate, 8N1 and FIFO enabled.
        // 这里设置波特率,关于波特率的计算可以查看本章前置知识
        // 这里有一个坑:
        //    现在这个注释的波特率设置最终值为 `921600`,
        //    但是实际在真实树莓派硬件执行的时候,
        //    有的 utf2ttl 可能不支持这么高的波特率,所以可能会出现乱码。
        //    如果遇到乱码输出可以尝试将波特率改为 115200 ,对应设置(26,3)
        // self.registers.IBRD.write(IBRD::BAUD_DIVINT.val(3));
        // self.registers.FBRD.write(FBRD::BAUD_DIVFRAC.val(16));
         self.registers.IBRD.write(IBRD::BAUD_DIVINT.val(26));
        self.registers.FBRD.write(FBRD::BAUD_DIVFRAC.val(3));
        self.registers
            .LCR_H
            .write(LCR_H::WLEN::EightBit + LCR_H::FEN::FifosEnabled);

        // Turn the UART on.
        self.registers
            .CR
            .write(CR::UARTEN::Enabled + CR::TXE::Enabled + CR::RXE::Enabled);
    }
}

// ... ...

impl driver::interface::DeviceDriver for PL011Uart {
    fn compatible(&self) -> &'static str {
        "BCM PL011 UART"
    }

    // 这里使用了同步锁,在当前示例下,不需要这个也可以
    // 因为内核初始化的时候只绑定了单核
    unsafe fn init(&self) -> Result<(), &'static str> {
        self.inner.lock(|inner| inner.init());

        Ok(())
    }
}

}

驱动管理

驱动定义好以后,就可以管理了。看 src/bsp/raspberrypi/driver.rs 中代码:


#![allow(unused)]
fn main() {
// 定义设备驱动管理器,这里是两个元素的固定大小数组
/// Device Driver Manager type.
struct BSPDriverManager {
    device_drivers: [&'static (dyn DeviceDriver + Sync); 2],
}

// 创建一个静态常量,持有 GPIO 和 PL011_UART 的引用地址
static BSP_DRIVER_MANAGER: BSPDriverManager = BSPDriverManager {
    device_drivers: [&super::GPIO, &super::PL011_UART],
};

//--------------------------------------------------------------------------------------------------
// Public Code
//--------------------------------------------------------------------------------------------------

/// Return a reference to the driver manager.
pub fn driver_manager() -> &'static impl driver::interface::DriverManager {
    &BSP_DRIVER_MANAGER
}

//------------------------------------------------------------------------------
// OS Interface Code
//------------------------------------------------------------------------------
use driver::interface::DeviceDriver;

// 实现 DriverManager trait
impl driver::interface::DriverManager for BSPDriverManager {
    fn all_device_drivers(&self) -> &[&'static (dyn DeviceDriver + Sync)] {
        &self.device_drivers[..]
    }

    // 在 GPIO 启动以后执行 映射 pl011_uart 引脚
    fn post_device_driver_init(&self) {
        // Configure PL011Uart's output pins.
        super::GPIO.map_pl011_uart();
    }
}
}

代码在真实树莓派上执行过程

如果没有树莓派,在 qemu 也可以。但是如果在真实硬件上启动内核,需要注意避开一些坑。我分享一下我的流程:

  1. 在测试内核之前,先安装树莓派官方的 Respbian OS 操作系统。这样做可以完整测试树莓派硬件的功能完整性,为后续启动 rust 实现的内核排坑。
  2. 如果遇到无法启动 OS 的情况,观察绿灯是否闪烁,排除是不是EEPROM损坏了。
  3. 如果遇到输出字符乱码,请确认内核设置的波特率,和你的串口调试工具波特率是否一致。
  4. 确保 usb2ttl RX/TX 连线对接正确。并且 usb2ttl 驱动安装正确。
  5. 烧录工具推荐:balenaetcher。官方推荐的不太好使。如果展示烧录失败,可以点击 skip 跳过 验证那一环节。
  6. 串口输出推荐使用 Rust 实现的这个工具:https://github.com/umaYnit/rust-serial-tool。不推荐 官方教程自带的这个 Ruby 实现,因为在 mac m1下有点坑。

小结

前五章,以一个非常短小精悍的 Rust 内核程序,为我们展示了如何在树莓派裸机上运行 Rust 代码。为后面的操作系统实现奠定了初步的基础。

在实际树莓派硬件上执行的时候,遇到坑不要轻易放弃,多半是因为知识经验不足导致的,此时正是学习的机会。

参考

  1. https://www.raspberrypi.com.tw/tutorial/faq/
  2. https://zhuanlan.zhihu.com/p/136806005
  3. https://github.com/bztsrc/raspi3-tutorial
  4. https://datasheets.raspberrypi.org/bcm2711/bcm2711-peripherals.pdf
  5. https://e-mailky.github.io/2016-12-06-dts-introduce

透过 Rust 探索系统的本原:泛型

作者:陈天 / 后期编辑:张汉东

原文链接:https://mp.weixin.qq.com/s/SJsEurfZr4TG-I3rncid5A


在 Fundamentals of Generic Programming[1] 里,Alexander Stepanov(泛型概念的创立者)用一段优雅的文字描绘了计算机技术不断泛化(generalized)的历史:

1

他说,目前我们走了三步:

  • 第一步,通用的计算机体系结构:将内存视作一组连续可寻址的空间
  • 第二步,通用的计算机语言:使用指针作为统一的引用类型的标识符
  • 第三步,泛型编程

今天我们就来讲讲泛型编程。

广义的泛型编程分为两部分:数据类型的泛型(Generic Data Types)或者说参数化类型(Parameterized Type),以及泛型函数(Generic Function)。

参数化类型

我们先看参数化类型。参数化类型是指定义数据结构或者类型的时候,不指定具体的类型,而是将其作为参数使用,使得该定义对各种具体类型都适用。参数化类型的好处是语言能够更具表达力(某种程度上接近动态类型语言),同时还保持了完整的静态类型安全。

打个不那么特别恰当,但比较容易理解的比方:类型之于数据,如同泛型之于一般类型。我们从具体的数据中抽象出核心的要素,构造出可以囊括这些数据的类型。比如 u8,囊括了 0-255 所有可能的数值。而我们进一步再从类型中抽取出通用的要素,构造出可以囊括这些类型的元类型。比如所有可以做加法操作的元类型 Add,它囊括了 u32f64usize 甚至 Complex 等各种可以进行加法操作的数据类型。

我们看一个实际的例子。

我们要实现一个数据结构,它可以从文件中读取一段数据,放入缓存中,然后对数据做一系列的处理。我们可以很容易定义出这样的结构:


#![allow(unused)]
fn main() {
struct MyReader {
  reader: File,
  buf: Vec<u8>,
}

impl MyReader {
  pub fn process(&mut self) {
    self.reader.read(&mut self.buf);
    // further processing on buf
  }
}
}

然而, 如果需求不仅仅是从文件读取,还可能从网络,从一个加密数据流,甚至从磁带上读取,做同样的缓存和处理,那么,我们不得不为每一种需求使用一个几乎相同的数据结构。如果使用参数化类型,我们可以这样定义:


#![allow(unused)]
fn main() {
struct MyReader<R> {
  reader: R,
  buf: Vec<u8>
}

impl<R> MyReader<R> {
  pub fn process(&mut self) {
    self.reader.read(&mut self.buf);
    // further processing on buf
  }
}
}

从代码中,我们可以更加深刻地领会到上文所说的「参数化类型的好处是语言能够更具表达力,同时还保持了完整的静态类型安全」。它是个非常强大的工具。

但这样给编译器出了一个难题:编译器在编译时如何得知 reader 可以执行 read() 操作呢?

不能。因为在这里,参数 R 可以是任何类型,而大多数类型并不直接支持 read() 操作。所以,这里我们需要对参数 R 进行限制。这个过程和函数的定义很类似:


#![allow(unused)]
fn main() {
fn add(a, b) // error - 我们不知道在函数调用时如何为 a, b 分配内存,因而需要对 a, b 做进一步限制
fn add(a: usize, b: usize) // ok - 现在我们知道 a, b 的限制(内存大小,允许的操作等)
}

对类型的参数的限制(bound),不同的语言使用的方式不同,Java 可以要求 <R extends ...>,Rust 使用 trait,而 Swift 使用 protocol,但大家的目标是一致的:编译器需要足够的信息来决定上述的代码是否可以编译通过。

如果你用 rust 编译器编译上述代码,编译器会给出详尽的错误:

2

它甚至为你推荐合适的 trait 来限制 R,真是比女(男)朋友还要更懂你。

泛型函数

静态分派

函数操作的对象是类型,当数据类型使用泛型时,使用其作为参数或者返回值的函数,也被称之为泛型函数,比如:

fn generic<T>(t: T) {
  todo!()
}

fn main() {
  generic::<u8>(42);
  generic::<&str>(&"hello");
}

main()generic() 的两次调用中,第一次使用了 u8 类型,第二次使用了 &str 类型。编译器在编译时就能捕获到使用的类型,从而进行对应的处理,这被称之为静态分派(static dispatch)。不同语言对静态分派的处理很不一样。在 Rust 中,处理的方法叫 monomorphization (单态化)—— 说人话就是编译器会为代码中所有使用到的类型编译出一个副本。对于这个例子,你可以想象编译器生成了类似于如下代码:

fn main() {
  generic_int(42);
  generic_ref_str(&"hello");
}

除了 Rust 外,C++ 也使用了单态化来处理泛型函数,只不过 C++ 使用的 template 并不携带类型,在编译过程中会被直接展开。

Java 对泛型的处理方式和 Rust 不一样。Java 会擦除(erase)类型,编译出的代码类似:


#![allow(unused)]
fn main() {
void main() {
  generic((Object) 42);
  generic((Object) "hello");
}
}

显然,Java 的方式会带来运行时性能上的损失(需要做在具体类型和 Object 间来回做额外的 typecast),并且,由于类型被擦除,所以很难为每种泛型编译的结果做特定的优化。

动态分派

静态分派固然很好,效率很高,但很多时候,类型可能很难在编译时决定。比如我要撰写一个格式化工具,这个在 IDE 里很常见。我们可以定义一个 Formatter 接口,然后创建一系列实现:


#![allow(unused)]
fn main() {
pub trait Formatter {
    fn format(&self, input &mut str) -> bool;
}

struct MarkdownFormatter;
impl Formatter for MarkdownFormatter {
    fn format(&self, input &mut str) -> bool { todo!() }
}

struct RustFormatter;
impl Formatter for RustFormatter {
    fn format(&self, input &mut str) -> bool { todo!() }
}

struct HtmlFormatter;
impl Formatter for HtmlFormatter {
    fn format(&self, input &mut str) -> bool { todo!() }
}
}

使用什么格式化方法,只有当我们打开文件,分析出文件内容之后才能确定,我们并无法在编译期给定一个具体类型,使其满足如下函数(一个文件可能有一到多个格式化工具,比如一个 markdown 文件里有 rust 代码,因而需要 MarkdownFormatterRustFormatter 来格式化,所以这里我们使用一个 Vec 来提供所有需要的格式化工具):


#![allow(unused)]
fn main() {
pub fn format(input: &mut str, formatters: Vec<???>) {
    for formatter in formatters {
        formatter.format(input);
    }
}
}

正常情况下,Vec<> 容器里的类型需要是一致的,但此处我们无法给定一个一致的类型。

所以我们需要有一种手段,告诉编译器,此处我们需要并且仅需要任何实现了 Formatter 接口的数据类型。在 Rust 里,这种类型叫 Trait Object,表现为 &dyn Trait 或者 Box<dyn Trait>。这里,dyn 关键字仅仅是用来帮助更好地区分普通类型和 Trait 类型。于是,上述代码可以写成:


#![allow(unused)]
fn main() {
pub fn format(input: &mut str, formatters: Vec<Box<dyn Formatter>>) {
    for formatter in formatters {
        formatter.format(input);
    }
}
}

这样,我们可以在运行时构造一个 Formatter 的列表,传递给 format 函数进行文件的格式化。这就是动态分派(dynamic dispatching)。

Trait Object 虽然是 Rust 独有的概念,但这个概念并不新鲜。我们看看它的实现机理。为了方便介绍,我们以 Rust 标准库里的 Write 接口为例:


#![allow(unused)]
fn main() {
pub trait Write {
  fn write(&mut self, buf: &[u8]) -> Result<usize>;
  fn flush(&mut self) -> Result<()>;

  fn write_all(&mut self, buf: &[u8]) -> Result<()> { ... }
  ...
}
}

Write 接口包含若干个方法,其中 write()flush() 必须实现。标准库中,Vec<T> 实现了 Write 接口。

当我们要使用这个接口做动态分派时,我们可以像如下例子一样,将一个具体类型(concrete type,比如 Vec<u8>)的引用,赋给 Write 接口:

3

这会生成一个 Trait Object,在上图中,我们可以看到,Trait Object 的底层逻辑不过就是胖指针(fat pointer) —— 一个包含两个指针的数据结构。其中,一个指针指向数据本身,另一个则指向虚函数表(vtable)。在这张表里,包含具体类型的一些信息,如 size,aligment 以及一系列函数指针:

  • 这个接口支持的所有方法(比如 write()flush()
  • 具体类型的 drop trait,当 Trait object(比如 Box<dyn Writer> ) 被释放,它用来释放其使用的所有资源。

这样,当执行 writer.write() 时,就可以从 vtable 里找到对应的函数指针,执行具体的操作。

所以,Rust 里的 Trait Object,没什么神秘的,它不过是 C++/Java 中大家熟知的 vtable 的一个变体而已。当然,C++/Java 指向 vtable 的指针在编译时放在类结构里,而 Rust 放在 Trait object 中。这也是为什么 Rust 很容易对基本类型(primitive type)做动态分派,而 C++/Java 不行。事实上,Rust 也并不区分基本类型,对 Rust 来说,所有的类型的地位都是一致的。

大多数通过接口实现动态分派的语言,都是通过 vtable 来协助接口方法的动态调用,Golang(interfaces)也是如此。Swift 虽然在很多方面跟 Rust 很类似,但在泛型的支持上,走了一条相对独特的道路:witness table[2]。有空讲 Swift 的时候,我可以专门讲讲 witeness table,很有意思(等不及的看参考资料中的视频吧)。

对于不同语言如何实现静态分派和动态分派,下面这个流程图表述地非常清楚,大家可以仔细研读一下。如果你读懂了这幅图,那么你对泛型函数的了解就足够清晰了:

4

(来源:Models of Generics and Metaprogramming: Go, Rust, Swift, D and More[4])

泛型编程的实践

泛型编程,与其说是一种技术,不如说是一种思想。它不仅仅是类型参数化,函数泛型化那么简单,背后体现的是程序员的抽象思维能力。抽象思维的能力,和语言无关,和工具无关,它是一种在实践中不断探索,不断学习,不断历练得出的能力。支持泛型的语言并不能帮助你更好地做泛型编程,就好比给我一台斯坦威钢琴,并不意味着我就具备了演奏李斯特《钟》的能力。

下图左边的代码是大家都很熟悉的,估计闭着眼睛也能写出来的 C 语言版本的 binary_search 算法。右边是 Alexander Stepanov 为 C++ STL 初版设计的类似算法(之所以叫 lower_bound,是因为它不仅使用 binary search 返回匹配的结果,在没找到时,还会返回其应该所处的位置):

5

可以看到, C 语言版本的算法的实现跟参数的细节绑定得很紧,而 Alex 的 lower_bound 版本,除了参数的抽象化之外,所有的实现细节都被不同的函数替代 —— 在不同的数据结构的上下文中,它们有不同的实现。拿 distance() 来说,对于数组,它是两个 index 之间的距离,对于链表,它是两个节点之间的跳数。在我看来,能够区分哪些是实现细节,哪些是算法的核心要素,并且能够把实现细节延迟到尽可能靠后的阶段(调用者调用时),这才是泛型编程的核心所在。Alan Perlis 在 Epigrams of Programming 里有个精妙的论断:

Functions delay binding: data structures induce binding. Moral: Structure data data in the programming process.

我们再看一个例子:客户端和服务器间通过 protobuf 来传递消息,两端约定消息的头四个字节是 protobuf 的长度,之后是 protobuf。我们需要做一个库处理消息的收发:从底层接收一个完整的消息,并反序列化传递给上层,以及上层需要发送消息时,序列化消息,添加长度信息,提交给底层发送出去。

这种需求往往一开始就有确定的底层传输机制,比如 WebSocket,以及确定的上层消息类型。如果照着这样的需求直接处理,很容易把系统做死:上层的消息的变动,会影响消息收发库的设计;底层传输机制的变化(比如另一个项目,改用 HTTP/2),也会影响消息收发库的设计。所以如果一开始没有做好对需求的足够抽象,日后各种变更会让你忙不过来。

我们可以把不变的部分抽取出来:带长度的数据读取/带长度的数据写入,以及序列化,反序列化。至于消息收发时底层的传输机制是什么,我们尽量不去关心;而上层究竟会传递什么样的消息,也不去关心。底层传输,我们可以将其抽象成 Reader / Writer(async 下是 Stream / Sink),上层的消息,我们将其抽象成 Message。接受和发送的数据,可以看做是一个 Frame,里面有不同的格式,于是,我们可以有这样的设计:

6

如果定义得当,甚至 frame 结构的变化,都可以不必大动干戈就可以很快实现。具体代码大家可以看 [5]。

贤者时刻

我在之前的文章中讲过,语言在开发过程形成的世界观极大地影响和限制着语言本身的行为和能力。Rust 为了追求极致的效率(和零成本抽象),在泛型的处理上选择了单态化来处理静态分派,这极大影响了编译速度,同时单态化也意味着 Rust 代码无法很好地以二进制分发,让其他 Rust 代码以 Rust ABI 的方式调用(而非以 C FFI 的方式调用)。这也意味着,即便 Rust 在很多场合能够取代 C,但 Rust 可能永远也无法取代 C 在操作系统应用程序二进制接口 (ABI)中的地位。

本文是上周四我在 Tubi 的有关 Rust 的 BBL 的一个总结和回顾,BBL 的讲稿可以在我的 github: tyrchen/rust-training 里获得,或者点击「阅读原文」查看。很有意思的是,周六(4/30),Jon Gjengset 在他的 Crust of Rust 系列的最新一期,讲了几乎同样的主题:Dispatch and Fat Pointers[6],感兴趣的同学可以看看那期视频。Jon 的直播期期都是精品,绝对值得花时间学习。

这个系列写到这里,也要告一段落了。原本只是想写个三四篇,没想到前前后后写了八篇。其实要继续写的话,还有很多方向可以写,比如 debug,分布式,状态机等等:

7

不过,生活中不只有 Rust。我的 Flutter 2,开了个头,尚待完成,Swift 和 iOS 开发,还有很多路要走,最近 Elixir 社区涌现了不少让人激动的项目,也有同学想让我聊一聊。所以,我会花些时间聊聊其它内容。大家有什么感兴趣的和软件开发有关的话题,也欢迎和我探讨。

参考资料

[1] Fundamentals of Generic Programming:

[2] Implementing Swift Generics: https://youtu.be/ctS8FzqcRug

[3] How Swift Achieved Dynamic Linking Where Rust Couldn't: https://gankra.github.io/blah/swift-abi/

[4] Models of Generics and Metaprogramming: Go, Rust, Swift, D and More: https://thume.ca/2019/07/14/a-tour-of-metaprogramming-models-for-generics/

[5] async-prost: https://github.com/tyrchen/async-prost

[6] Crust of Rust: Dispatch and Fat Pointers: https://www.youtube.com/watch?v=xcygqF5LVmM

https://gist.github.com/Kimundi/8391398

语言的设计很多时候也会影响到语言使用的方式。

Swift 使用 witness table 来处理泛型,而 Rust 在编译时为每种使用到的场景复制出专门的代码,这带来一些显著地不同:

  • Rust 编译速度更慢,而 Swift 会快不少
  • 如果你使用别人做的 Rust library,你需要集成源码进行编译,否则泛型将无法使用(你只能以 C FFI 的形式将其集成);而 Swift 可以允许你使用第三方的二进制,且不影响泛型的功用。

从这一点我们也可以看出,Rust 和 Swift 生态的显著不同 —— Swift 在设计上考虑了潜在的商业伙伴上的需求。

GitHub 趋势榜

编辑:张汉东


盘点一下 本月 GitHub 趋势榜上榜的 Rust 项目

对于上期出现过的项目,就暂时不排到本文里了。

Alacritty: 跨平台,OpenGL终端模拟器

Alacritty 号称是最快的终端模拟器,最近发布了最新版本。

alacritty

https://github.com/alacritty/alacritty

Awesome Alternatives in Rust

各个领域中可以用 Rust 替换的软件实现列表。可以关注或贡献。

https://github.com/TaKO8Ki/awesome-alternatives-in-rust

Git 工具: delta 和 gitui

Rust实现了有很多出色的终端工具,delta和gitui就是其中两个。

delta: Git 和 Diff 输出的查看器

delta

https://github.com/dandavison/delta

gitui: Rust 实现的 Git 终端 UI

gitui

https://github.com/extrawurst/gitui

Regex: 正则表达式引擎

Regex 本月发布了 1.5.4 版。该正则表达式引擎类似于 Perl 的正则引擎,但是缺乏 环视 和 反向引用,因为这两个特性会包含回溯功能,影响正则引擎的性能。如果想使用环视和反向引用可以使用其他第三方库,比如 fancy-regex

https://github.com/rust-lang/regex

materialize: 用于实时应用程序的流数据库

Materialize 是基于pg 和 开源Timely Dataflow项目构建 开发的一个可以处理流式数据的平台,同时提供了强大的数据处理能力。最近发布了新版本。

在无需复杂的数据管道的情况下,只须用标准SQL视图描述计算,然后将Materialize 连接到数据流,就能实现增量计算。 底层的差分数据流引擎能够运行增量计算,从而以最小的延迟,提供一致且准确的结果。经实验,将 Materialize 与 Spring Cloud Stream 以及 Kafka 配合使用,从而在分布式事件驱动的系统中,查询事件流并分析结果。其效果令人满意。

Materialize被称为“第一个真正的流式SQL数据库”。在 2020 年底,Materialize获得由Kleiner Perkins领投的3200万美元B轮投资。

materialize

Youki : 实验性的容器运行时

youki 是根据 runtime-spec 规范来实现的,参考 runc。

作者坦言:

Rust是实现OCI运行时的最佳语言之一。 许多容器工具都是完全用 Go 写的。这是一件非常好的产品。但是,容器运行时需要使用系统调用,这需要用 Go 实现的时候需要一些特殊处理。 这非常棘手(例如,namespaces(7), fork(2)); 使用Rust,它不是那么棘手,你可以使用系统调用。 此外,与 C 不同,Rust 提供了内存管理的好处。 Rust尚未成为容器领域的主流,但有可能为此领域提供更多贡献。 我希望 youki 成为该领域如何使用的 Rust 的例子之一。

https://github.com/utam0k/youki

tree-sitter: Rust 实现的解析器生成器

最近发布了新版本 0.19.5。

它不仅仅是一个解析器生成器工具,还支持增量解析。 它可以为源文件构建一个具体的语法树,并在源文件更新的时候有效更新语法树。该库还依赖了部分 C 代码。

同类型工具还有:https://github.com/lalrpop/lalrpop

https://github.com/tree-sitter/tree-sitter

话说,用 Rust 实现的新语言在不断冒出来。

本月看到一个新的语言 Gleam ,Rust 实现的可以与 Erlang 兼容的新语言。刚发布 0.15版本。

https://github.com/gleam-lang/gleam

v86 : 用于模拟X86兼容的CPU和硬件

v86 机器代码在运行时转换为webassembly模块,以实现体面的性能。

tokenizers: Hugging Face公司推出的分词器发布新版本

Hugging Face(抱抱脸)公司是一家总部位于美国纽约的聊天机器人初创服务商。该公司在 NLP界鼎鼎大名,三月份刚刚完成4000万美元B轮融资。在GitHub上发布了开源 NLP 库 Transformers。

基于深度学习的现代 NLP 管道中的瓶颈之一就是tokenization,尤其是通用性强且独立于框架的实现。

所以,该分词器的核心是用Rust编写的,并且存在Node和Python的绑定。提供当今最常用的分词器的实现,重点是性能和多功能性。

https://github.com/huggingface/tokenizers

推荐项目 | 基础工具库

编辑:张汉东


【飞书开源项目】rsmpeg : FFmpeg 的 Rust 绑定

利用 Rust 语言的设计,帮你构建健壮的多媒体应用。

https://github.com/larksuite/rsmpeg

embassy : 嵌入式 Rust 下的 异步执行器 和 HAL

该项目旨在 让 async/await 成为 嵌入式开发的 first class 选择

https://github.com/embassy-rs/embassy

Call: 用于远程开发的易于使用的命令工具。

它可以帮助您轻松而优雅地进行远程开发。 它可以与 makefile 和justfile 一起使用。

是国内社区朋友开发的。

https://github.com/bingryan/call

经过两年的努力, Tauri 终于迎来了 1.0 beta 版本。

Tauri 是基于 Rust 实现的桌面应用开发工具,支持集成可编译为HTML,JS和CSS的任何前端框架来构建用户界面。目前支持 Windows/Linux/Macos,iOS和 Android 的支持还在开发中。

Parcel 2 发布 beta3 版本

性能提升十倍!

https://v2.parceljs.org/blog/beta3/

Valuable: tokio发布的 对象安全的值检查库

提供了对象安全的trait,即 Valuable,它允许调用者在不知道其类型的情况下检查值的内容,无论是字段,枚举变量还是原生类型。

https://tokio.rs/blog/2021-05-valuable

命令行神器:typos,帮助你找到源码中的拼写错误

https://github.com/crate-ci/typos

推荐项目 | 框架引擎

编辑:张汉东

Cube.js: Rust 实现的 BI 框架

Cube.js 是一个开源的分析 API 平台。主要用于构建内部商业智能工具或将客户面向客户的分析添加到现有应用程序。

你可以使用 Cube.js 构建现代化的数据分析平台。

架构: Data(DB) -> Backend (Cube.js server) -> Frontend(React.js / Ant Design)

在最简单的方案中,Cube.js使用内存高速缓存和查询队列来提供比数据存储能够传递的更好的性能。 但是,它通常是最不可扩展且经济高效的解决方案,因此不建议生产。

因此引入 Cube Store,以提供有保证的高并发性和亚秒级延迟,用于分析查询的性能优化以及通过跨数据库连接的数据联合等附加功能。 Cube Store 由 Rust 实现。

Warp: Web server 框架

Warp 构建于 hyper 之上。 特色是:Filter 系统。

**tower vs wrap vs tower-web **

  • tower,类似于 Finagle (Twitter 研发的RPC系统)
  • warp,类似于 finch(用于构建Finagle HTTP服务的Scala组合器库)
  • tower-web,类似于 finatra (一个scala 异步 web 框架)

warp 的 Filter 抽象 和 Tower-web 的 Middleware 抽象 有异曲同工之效。阅读更多

目前 Warp 还在积极维护中。

Rocket 最近发布了 0.4.10

Rocket 的更新节奏比较缓慢,但还在维护中,五月份连续三天连发三个小版本。

最新的版本更新是移除了一处 unsafe 代码,修复了一个 soundless 的问题。

https://github.com/SergioBenitez/Rocket

sqlx: 发布了新版本 0.5.5

sqlx 是一个纯 Rust 的异步 SQL 库。它的特点是编译期查询检查,并且没有提供任何 DSL,所以它不是一个 ORM。

sqlx 跨数据库/跨运行时/跨TLS后端,不绑定于任何特定数据库和运行时(async-std / tokio / actix)和TLS后端(native-tls, rustls)。

sqlx

MoonZoon: 全栈 Rust 开发框架进展

MoonZoon 号称是 Rust 全栈框架。

最近 MoonZoon 好像取得了一些进展。增加了一个 光线追踪的教程:https://github.com/MartinKavik/ray_tracer/blob/main/README.md。MoonZoon 支持 HTML canvas 和 后端自动加载,支持快速开发。

https://github.com/MoonZoon/MoonZoon

Rust生态安全漏洞总结系列 | Part 2

作者:张汉东


本系列主要是分析RustSecurity 安全数据库库中记录的Rust生态社区中发现的安全问题,从中总结一些教训,学习Rust安全编程的经验。

本期分析了下面六个安全问题:

  • RUSTSEC-2021-0067 : Cranelift 模块中代码生成缺陷导致可能的 WASM 沙箱逃逸
  • RUSTSEC-2021-0054:rkyv crate 可能包含未初始化的内存
  • RUSTSEC-2021-0041:parse_duration 通过用太大的指数解析 Payload 来拒绝服务(DOS)
  • RUSTSEC-2021-0053: 算法库中 merge_sort::merge() 导致实现 Drop 的类型 双重释放( double-free)
  • RUSTSEC-2021-0068: iced x86 版本中 不合理(Soundness) 的问题
  • RUSTSEC-2021-0037: Diesel 库的 Sqlite 后端 UAF(use-after-free) bug

看是否能给我们一些启示。

RUSTSEC-2021-0067 : Cranelift 模块中代码生成缺陷导致可能的 WASM 沙箱逃逸

在 Cranelift 中发现了一个漏洞。具有未知输入的操作导致特权升级漏洞。 CWe正在将问题分类为CWE-264。 这将对机密性,完整性和可用性产生影响。

漏洞描述:

Cranelift X64后端的0.73.0中有一个错误,可以创建一个可能导致 Webassembly 模块中的潜在沙箱逃逸(sandbox escape )的场景。 版本0.73.0的Cranelift的用户应升级到0.73.10.74,以修复此漏洞。

如果未使用旧的默认后端,则在0.73.0之前的 Cranelift 用户应该更新为0.73.10.74

漏洞分析

此问题是在 Cranelift 新后端中引入的(Cranelift 经历过大的重构)。

一些背景: 寄存器分配

如果物理寄存器的数量不足以满足虚拟寄存器的需求,有些虚拟寄存器显然就只能映射到内存。这些虚拟寄存器称为溢出(spill)虚拟寄存器。寄存器分配算法的好坏直接决定了程序中寄存器的利用率。

Cranelift 寄存器分配相关文章:https://cfallin.org/blog/2021/03/15/cranelift-isel-3/

该文章还详细介绍了该团队如何保证 Cranelift 生成正确的代码。即便如此,还是产生了逻辑 Bug。

这个 Bug 是一个逻辑 Bug:

原因是,寄存器分配器重新加载比 64位 窄的溢出(spill)整数值时,从栈上加载的值执行了符号扩展而不是零扩展。

这对另一个优化产生了糟糕的影响:当我们知道产生32位值的指令实际上将其目标寄存器的高32位置零时,指令选择器将选择一个32到64位的零扩展运算符。因此,我们依赖于这些归零位,但值的类型仍然是I32,并且溢出/重新加载将这些比特位重构为I32的MSB的符号扩展。

所以,在某些特定情况下,如果i32值为指针,则可能会出现沙箱逃逸的情况。为堆访问发出的常规代码对 WebAssembly 堆地址进行零扩展,将其添加到64位堆基,然后访问结果地址。如果零扩展成为符号扩展,则模块可以在堆开始之前向后访问并访问最大2GiB的内存。

符号扩充 (sign-extend): 指在保留数字的符号(正负性)及数值的情况下,增加二进制数字位数的操作。

零扩充(zero-extend):用于将无符号数字移动至较大的字段中,同时保留其数值。

该 Bug 的影响力依赖于堆的实现。具体而言:

如果堆有边界检查。并且,不完全依赖于保护页面。并且堆绑定为2GiB或更小。则该 Bug 无法用于从另一个 WebAssembly 模块堆访问内存。

如果使用此 Bug 可访问的范围中没有映射内存,例如,如果 WebAssembly 模块堆之前有 2 GiB 保护区域,则可以减轻此漏洞的影响。

RUSTSEC-2021-0054:rkyv crate 可能包含未初始化的内存

漏洞描述:

rkyv是一个序列化框架 在序列化期间,可能无法初始化结构填充字节和未使用的枚举字节。 这些字节可以写入磁盘或发送不安全的通道。

漏洞分析

补丁代码:https://github.com/djkoloski/rkyv/commit/9c65ae9c2c67dd949b5c3aba9b8eba6da802ab7e

有问题的代码:


#![allow(unused)]
fn main() {
unsafe fn resolve_aligned<T: Archive + ?Sized>(
        &mut self,
        value: &T,
        resolver: T::Resolver,
    ) -> Result<usize, Self::Error> {
    // ...
    let mut resolved = mem::MaybeUninit::zeroed();
    // ...
}
}

mem::MaybeUninit::zeroed()函数会创建一个新的MaybeUninit<T>实例,并且该内存位会被填充0。但是这依赖于 T是否能被正确初始化。比如:MaybeUninit<usize>::zeroed()是初始化,但是MaybeUninit<&'static i32>::zeroed()就没有被正确初始化。这是因为 Rust 里引用不能为空。

所以,现在这个 resolver 是个泛型 T,不一定能正确初始化,所以有未初始化的风险。

修复之后的代码:


#![allow(unused)]
fn main() {
    let mut resolved = mem::MaybeUninit::<T::Archived>::uninit();
    resolved.as_mut_ptr().write_bytes(0, 1);
}

直接假设其没有正确初始化,然后使用write_bytes手工将其初始化,确保正确。

RUSTSEC-2021-0041:parse_duration 通过用太大的指数解析 Payload 来拒绝服务(DOS)

漏洞描述:

漏洞解析

parse_duration 库用来将字符串解析为持续时间(duration)。

问题代码:


#![allow(unused)]
fn main() {
if exp < 0 {
    boosted_int /= pow(BigInt::from(10), exp.wrapping_abs() as usize);
} else {
    boosted_int *= pow(BigInt::from(10), exp.wrapping_abs() as usize);
}
duration.nanoseconds += boosted_int;
}

此为 parse 函数内的代码片段,允许使用指数级的持续时间字符串解析,其中BigInt 类型与 pow 功能一起用于这类 Payload。该功能会导致长时间占用CPU和内存。

这允许攻击者使用 parse 功能来制造 DOS 攻击。虽然该库已经不维护了,而且star数也不多,但是不清楚依赖它的库有多少,可以使用 cargo-audit 来检查你项目里的依赖。

RUSTSEC-2021-0053: 算法库中 merge_sort::merge() 导致实现 Drop 的类型 双重释放( double-free)

漏洞分析

algorithmica是 Rust 实现算法的教学库,网站为:https://www.fifthtry.com/abrar/rust-algorithms/

该库中的归并排序的实现中,merge 函数导致 对列表元素持有双份所有权,所以会双重释放(double free)。

注意下面源码中,为 unsafe rust 实现。


#![allow(unused)]
fn main() {
 fn merge<T: Debug, F>(list: &mut [T], start: usize, mid: usize, end: usize, compare: &F) 
 where 
     F: Fn(&T, &T) -> bool, 
 { 
     let mut left = Vec::with_capacity(mid - start + 1); 
     let mut right = Vec::with_capacity(end - mid); 
     unsafe { 
         let mut start = start; 
         while start <= mid { 
             left.push(get_by_index(list, start as isize).read()); 
             start += 1; 
         } 
         while start <= end { 
             right.push(get_by_index(list, start as isize).read()); 
             start += 1; 
         } 
     } 
  
     let mut left_index = 0; 
     let mut right_index = 0; 
     let mut k = start; 
  
     unsafe { 
         while left_index < left.len() && right_index < right.len() { 
             if compare(&left[left_index], &right[right_index]) { 
                 
                 // 通过 `list[k] = ` 这种方式重复持有元素所有权
                 list[k] = get_by_index(&left, left_index as isize).read(); 
                 
                 left_index += 1; 
             } else { 
                 list[k] = get_by_index(&right, right_index as isize).read(); 
                 right_index += 1; 
             } 
             k += 1; 
         } 
  
         while left_index < left.len() { 
             list[k] = get_by_index(&left, left_index as isize).read(); 
             left_index += 1; 
             k += 1; 
         } 
  
         while right_index < right.len() { 
             list[k] = get_by_index(&right, right_index as isize).read(); 
             right_index += 1; 
             k += 1; 
         } 
     } 
 } 

unsafe fn get_by_index<T>(list: &[T], index: isize) -> *const T {
    let list_offset = list.as_ptr();
    list_offset.offset(index)
}
}

Bug 复现:

#![forbid(unsafe_code)]
use algorithmica::sort::merge_sort::sort;

fn main() {
    let mut arr = vec![
        String::from("Hello"),
        String::from("World"),
        String::from("Rust"),
    ];

    // Calling `merge_sort::sort` on an array of `T: Drop` triggers double drop
    algorithmica::sort::merge_sort::sort(&mut arr);
    dbg!(arr);
}

输出:


#![allow(unused)]
fn main() {
free(): double free detected in tcache 2

Terminated with signal 6 (SIGABRT)
}

该 Bug 还未得到修复。

此问题给我们的启示:不要为了刷题而忽略安全。

RUSTSEC-2021-0068: iced x86 版本中 不合理(Soundness) 的问题

漏洞描述:

漏洞分析

iced 用户在使用 miri 编译其项目时,发现 UB:


#![allow(unused)]
fn main() {
error: Undefined Behavior: memory access failed: pointer must be in-bounds at offset 4, but is outside bounds of alloc90797 which has size 3
    --> C:\Users\lander\.rustup\toolchains\nightly-x86_64-pc-windows-msvc\lib\rustlib\src\rust\library\core\src\slice\mod.rs:365:18
     |
365  |         unsafe { &*index.get_unchecked(self) }
     |                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^ memory access failed: pointer must be in-bounds at offset 4, but is outside bounds of alloc90797 which has size 3
     |
     = help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
     = help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
             
     = note: inside `core::slice::<impl [u8]>::get_unchecked::<usize>` at C:\Users\lander\.rustup\toolchains\nightly-x86_64-pc-windows-msvc\lib\rustlib\src\rust\library\core\src\slice\mod.rs:365:18
     = note: inside `iced_x86::Decoder::new` at C:\Users\lander\.cargo\registry\src\github.com-1ecc6299db9ec823\iced-x86-1.9.1\src\decoder\mod.rs:457:42
note: inside `Emulator::run` at src\lib.rs:563:27
    --> src\lib.rs:563:27
     |
563  |         let mut decoder = Decoder::new(self.bitness, bytes, self.decoder_options);
}

该用户在使用 Decoder::new 的时候出现了 UB。在 iced相关源码中,即 iced/src/rust/iced-x86/src/decoder.rs 中,存在


#![allow(unused)]
fn main() {
let data_ptr_end: *const u8 = unsafe { 
    data.get_unchecked(data.len()) 
}; 
}

根据标准库文档描述:

Calling this method with an out-of-bounds index is undefined behavior even if the resulting reference is not used.

使用 界外索引调用该方法就是 未定义行为(UB),即便这个结果的引用没有被使用。

示例:


#![allow(unused)]
fn main() {
let x = &[1, 2, 4];

unsafe {
    assert_eq!(x.get_unchecked(1), &2);
    assert_eq!(x.get_unchecked(3), &2); // UB
}
}

该代码已经被修复为,不再使用 get_unchecked :


#![allow(unused)]
fn main() {
let data_ptr_end = data.as_ptr() as usize + data.len();
}

RUSTSEC-2021-0037: Diesel 库的 Sqlite 后端 UAF(use-after-free) bug

漏洞描述:

漏洞分析

Diesel 的 sqlite 后端使用了 libsqlite3_sys 这个库来调用 sqlite 提供的sql函数。比如sqlite3_finalizesqlite3_step 之类。

sqlite 函数执行调用过程:

  • sqlite3_open()
  • sqlite3_prepare()
  • sqlite3_step() // 用于执行有前面sqlite3_prepare创建的 预编译语句
  • sqlite3_column() // 从执行sqlite3_step()执行一个预编译语句得到的结果集的当前行中返回一个列
  • sqlite3_finalize() // 销毁前面被sqlite3_prepare创建的预编译语句
  • sqlite3_close()

Diesel 的 by_name 查询通用做法是将预编译语句的所有字段名称保存为字符串切片以备以后使用。

但是sqlite的行为是:

  • 返回的字符串指针一直有效,直到准备好的语句被 sqlite3_finalize() 销毁,
  • 或者直到第一次调用 sqlite3_step() 为特定运行自动重新预编译该语句,
  • 或者直到下一次调用 sqlite3_column_name()sqlite3_column_name16() 在同一列。

在之前版本的 Diesel 中,没有注意到这种情况,在调用 sqlite3_step() 之后,因为重新预编译语句,导致之前字符串切片指针就无效了。就造成 UAF 的情况。

这个案例告诉我们,在使用 FFi 的时候,要注意绑定sys库 的相关行为。这个在 Rust 编译器这边是无法检查发现的,案例应该属于逻辑 Bug。

Rust 与 安全 | Rust 让恶意软件也变强了

作者:张汉东


技术 是一把锋利的双刃剑。 —— 鲁迅。

背景

今天看到 proofpoint 发表了一篇题为Buer Loader 用 Rust 创造了新的变种 (New Variant of Buer Loader Written in Rust)的文章。

Proofpoint是一家位于加利福尼亚州桑尼维尔的企业安全公司,为入站电子邮件安全,出站数据丢失防护,社交媒体,移动设备,数字风险,电子邮件加密,电子发现和电子邮件归档提供软件即服务和产品。

Buer 是在地下市场上出售的下载程序,以分发包括勒索软件在内的其他恶意软件。 Proofpoint于 2019年 首次观察到 Buer。

Proofpoint研究人员确定了4月初通过伪装成(快递公司 DHL)发货通知的电子邮件分发的 Buer 恶意软件加载程序的新变种。 邮件中包含指向恶意 Microsoft Word 或 Excel 文档下载的链接,这些链接使用宏来投放新的恶意软件变体。

Proofpoint 把新的变种称为 RustyBuer,因为它们用 Rust的语言完全重写,这与以前的C编程语言有所不同。常见的恶意软件以完全不同的方式编写是很少见的。用 Rust 重写恶意软件可以使威胁者更好地逃避现有的 Buer 检测功能,并且通过使用不同的诱饵技术,提升链接的点击率。这一切都表明威胁者正在以多种方式发展技术,从而逃避检测并试图提高成功的点击率。

RustyBuer 直接嵌入到文档宏中,需要用户交互才能启动感染。此宏利用应用程序绕过(通过 LOLBAS 的 Windows Shell DLL)逃避了端点安全性机制的检测。

尚不清楚威胁者为何花时间和精力用新的编程语言重写恶意软件,但是Proofpoint研究人员指出了两个可能的原因:

  • Rust 是一种越来越流行的编程语言,它比 C 语言更高效且具有更广泛的功能集。(例如,Microsoft越来越多地在其产品中使用它,并于2021年2月加入了Rust 基金会)。

  • 用 Rust 重写恶意软件可以使威胁参与者逃避基于 C 语言编写的恶意软件功能的现有 Buer 检测。恶意软件作者对其进行了编程,使其应与现有 Buer 后端 C2 服务器和面板保持兼容。

乱炖

其实用 Rust 写恶意软件,这个案例并不是第一个。早在 2016 年 Dr.Web(俄罗斯杀毒软件公司 大蜘蛛)公司就发现了一款用 Rust 实现的 针对 Linux 的特洛伊木马 Linux.BackDoor.Irc.16。Doctor Web的分析师认为,Linux.BackDoor.Irc.16 实际上是一个原型(概念验证),因为它无法自我复制。然而 RustyBuer 就是一款完全黑产化的软件了。

随着目前 Rust 知名度越来越高,Rust 在安全/黑产领域可能会越来越流行。抛开黑产软件的伦理道德不谈,单从技术以及 Rust 自身而言,在这种「攻与防」的觥筹交错中,也许会促进各种针对 Rust 的检测工具诞生,进一步丰富生态,促进 Rust 发展。

因此,我对目前安全领域生态中有多少用 Rust 实现的工具产生了好奇。接下来盘点一下 GitHub/GitLab 上发现的一些用 Rust 实现的安全工具。欢迎补充。

渗透测试工具 Metasploit (无)Rust 重写计划

2017 年有人在 Metasploit issue 留言,请求 Metasploit 用 Rust 重写。当然,这个 issue 被无情关闭了。因为 Metasploit 当年是从 Python 转为 Ruby 重写,是看中了 Ruby 强大的 DSL 能力。作为渗透测试工具的扛把子,Metasploit 目前没有使用 Rust 重写的理由。除非,有人直接提交 PR,用 Rust 完成了大部分功能,不过我想这件事不会发生。

不过还有个曲线救国的办法,就是用 Rust 重写 Ruby。这个还是有希望的:artichoke/artichoke。Artichoke 就是用 Rust 实现 Ruby 的项目,目前维护也很活跃,已经兼容 MIR(Ruby 2.6.3) 规格。

Metasploit 还有一个功能,叫「Metasploit's Hardware Bridge」,提供了一系列Hardware REST API,可以让 Metasploit 拥有对硬件(IoT领域)操作的能力。对于 REST Api,用任何语言都可以包装它,Rust 也可以。不过这个相关文档有五年没有更新了,不知道发展如何。

扫描

rustscan/rustscan

RustScan 是一个现代化的端口扫描器,并且提供脚本引擎来支持运行 Python,Lua,Shell。

该工具的特色是:

  • 高性能。3 秒内可以扫描 65,000 个端口。
  • 全面的脚本引擎支持。自动将结果传送到 Nmap,并支持自定义脚本。
  • 支持 自适应学习(Adaptive learning)。用的越多,RustScan 越智能,不是基于臃肿的机器学习,主要是基于基本数学。

feroxbuster

一款高性能的内容发现工具,用于渗透测试。feroxbuster 使用蛮力结合单词列表​​在目标目录中搜索未链接的内容,这是一种攻击行为。这些资源可能存储有关 Web 应用程序和操作系统的敏感信息,例如源代码,凭据,内部网络寻址等。此攻击也称为可预测资源位置,文件枚举,目录枚举和资源枚举。

隐身匿名

Tor 和 Rust

zero-gear/rusty-tor

一个概念验证的 Tor 客户端。不过有两年没有维护了。

免杀

Rust 写 Shell Code 也是可以的。

重点介绍:

amber。号称首款 反射式 PE 打包器,目前开发很活跃。

Amber是位置无关(反射)的 PE 加载器,可在内存中执行本机PE文件(EXE,DLL,SYS ...)。 它实现了秘密的的内存有效负载部署,可用于绕过防病毒,防火墙,IDS,IPS产品和应用程序白名单缓解措施。 由Amber生成的反射性有效负载可以从远程服务器上演,也可以像在通用Shellcode中一样直接在内存中执行。

amber 提供的打包方法,可以改变将恶意软件传送到系统的方式。 通过尝试通过无文件代码注入将有效负载传递到系统,可以直接绕过大多数安全机制,而不是尝试寻找提供机器学习数据集的新的反检测技术。 使用这种新的打包方法,可以将已编译的PE文件转换为可与常见软件漏洞(例如缓冲区溢出)一起使用的多阶段感染有效负载。

更多介绍:Introducing New Packing Method: First Reflective PE Packer Amber

Exploits/ payloads / hacking

这个开发者 kpcyrd,编写了一系列安全工具。

  • sn0int, 半自动高级公开资源情报(OSINT)框架和程序包管理器,用于扫描目标IP地址、电子邮件、网站和组织信息,并从不同消息源收集各种情报信息。

  • sniffglue,多线程网络嗅探器。Kpcyrd 经常使用tcpdump,但他认为输出更友好的话会更好,并且 wireshark 也经常存在漏洞,tcpdump 有时也有漏洞,所以他用 Rust 实现了这个工具。

  • badtouch, 可编写脚本的网络身份验证破解程序。

  • rshijack,用 Rust 重写了 shijack,实现 TCP 会话劫持。

Fuzz 工具

其他工具

六月刊

logo

目录顺序排名不分先后!

发刊通告

Rust 资讯

Rust 与 学术

Rust in Production

Clippy 专题

学习园地

新手入门

语言特性与技巧

Web 3.0 专题

开源项目巡礼

发刊通告

编辑:张汉东

六月发刊通告

《 RustMagazine 中文精选 》2021 年第六期发布了!

随着六月刊的发布,意味着2021年半年已经过去了。祝大家抓紧每一份时光,活在当下。无论如何,加油吧!

欢迎大家随时投稿分享,共建 Rust 社区繁荣。

本刊动态

(GitHub Page)从本月起停止 Rss 订阅

因为当前 mdbook 的 rss 插件不是很方便,等以后再加 rss 支持吧。

可以到语雀平台自动支持邮件订阅。

上期(五月刊)访问数据统计小结

用户数

  • 新用户新增 1969

浏览量:

  • 网页浏览量 : 14,447

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

编辑: 张汉东


随着 try-v2 RFC 的跟进,NoneError 已经被删除

https://github.com/rust-lang/rust/issues/46871#issuecomment-852663986

Rust 1.53 unicode ident 支持


#![allow(unused)]
fn main() {
const BLÅHAJ: &str = "🦈";

struct 人 {
    名字: String,
}

let α = 1;
}

Rust 1.53 支持了 Unicode 字符作为标识符,这意味着,只要是合法的 Unicode ,就可以作为标识符。对应 RFC 2457,该 RFC 中也记录了哪些 Unicode 是不安全的,感兴趣可以查看。

团队可以配置lint 属性, #[deny(uncommon_codepoints)] ,禁止一些不推荐的 codepoint 。

unicode

这样用就有点过分了!也许团队内要针对 Unicode 标识符来做一些编码规范了。

一个新的 MCP :将 rustc_codegen_gcc 作为 compiler/rustc_codegen_gcc 组件合并到 rust-lang/rust

https://github.com/rust-lang/compiler-team/issues/442

rustc_codegen_gcc 是rustc的GCC代码生成器,这意味着它可以被现有的rustc前端加载,但受益于GCC,因为它支持更多的架构,并可以获得GCC的优化。

进展报告 #1

Rustup 1.24.3 发布

使用rustup self update更新 rustup,然后使用rustup update更新 toolchain

Announcing Rustup 1.24.3 | Rust Blog (rust-lang.org)

社区热点

编辑: 张汉东


Rust for Linux 项目相关进展

Google 赞助 且 由 ISRG 组织雇佣 Miguel Ojeda,让他全职从事Rust for Linux和其他安全工作,为期一年。希望能通过让他全职从事这项工作,为支持数字基础设施尽一份力。

https://www.memorysafety.org/blog/supporting-miguel-ojeda-rust-in-linux/

RustConf 2021 CFP 开放申请

RustConf 2021 大会计划于 9月14号举行, 提案开放时间为太平洋时间6月13日至7月11日晚上11:59分。

https://cfp.rustconf.com/events/rustconf-2021

国内新的 crates.io 和 rustup 的国内镜像

字节跳动 的小伙伴搞的,希望能帮助建设国内的 rust 生态,感兴趣的话可以试用。

不限速(实际上是 1000Gbps),优质 CDN 分发,欢迎大家使用~

有问题可以直接在评论区反馈。

https://rsproxy.cn/

2021 Rust China Conf 的调研

朋友们,请抽出一分钟完成一下 2021 Rust China Conf 的调研,只有3个问题 。

https://wj.qq.com/s2/8683119/cbf3/

【现场实录】20210619-北京-Rust区块链开发者活动

https://www.bilibili.com/video/BV1Jh411h7pp

RustSBI组织宣布成立

RustSBI是RISC-V平台下的引导程序实现,它完全由Rust编写,并已经被录入RISC-V SBI国际标准。6月3日,RustSBI已经在GitHub上成立了组织,并提交了它对多个RISC-V平台的支持示例软件包。

RISC-V处理器架构中,存在着定义于操作系统之下的环境,它被称作SBI标准。这个环境除了引导系统启动,还将常驻后台,在内核运行时提供处理器功能。RustSBI就是这样的一种环境,它是一个扩展性较好的库,允许开发者自由地添加需要的功能模块,以支持芯片研发企业、应用厂商和板卡厂商提供自己芯片的SBI支持环境。

根据它的GitHub主页介绍,RustSBI项目组已经根据Mulan-PSL v2协议开源了它对K210和QEMU平台的支持,更多平台如FU540、C906等的支持也在筹划中。RustSBI组织以模块化开发、较好的支持嵌入式Rust生态,期望以这一形式,使厂商无需担忧是否合并到主分支,并鼓励开发者尝试多种多样的设计,来丰富引导程序方面的RISC-V生态环境。

“未来的RISC-V引导程序可能包含很多种可能性,这包括已经用在无盘工作站、安全网络等引导程序中的下载和验证机制,也可用于DIY爱好者的主板诊断。甚至用它做调试器也是可以的,它事实上提供了完全可定制的硬件兼容层,”RustSBI的维护者洛佳这样告诉《Rust日报》,“实践证明,它可以完成硬件到硬件的兼容性,从而延长硬件的生命周期,降低管理和迭代更新成本。”

“RustSBI已经能引导启动rCore等类Unix系统,”维护者继续说,“它是开源开放的,我们期待它被更进一步的被科研、教学和生产界广泛使用。”

RustSBI组织首页:https://github.com/rustsbi

惊叹! Rust 在艺术领域的商业应用!

观看视频

该艺术项目使用486步进电机,86,000个LED和5通道颗粒式合成引擎,控制软件系统使用 Rust 实现,利用的是 nannou 和 koto。

该项目背后是一家德国公司:mindbuffer.net

2021年开源操作系统夏令营 欢迎报名

任何 对Rust 和 RISC-V写操作系统感兴趣的均可报名

鹏城实验室和清华大学组织的2020年开源操作系统夏令营收到同学们的积极响应,参与夏令营的同学在今年的“2021全国大学生计算机系统能力大赛 - 操作系统赛”的初赛中都有不错的表现( https://mp.weixin.qq.com/s/Cb2SaonAAHDVNDli_80Bpw ),今年启元实验室、清华大学和CSDN等将继续组织“2021年开源操作系统夏令营”,希望能把对开源操作系统开发有兴趣的朋友团结在一起,形成一个活跃的开源社区。欢迎对开源操作系统有兴趣的任何人报名(需填写下面的报名问卷)参加。

2021年开源操作系统夏令营

GDC 2021 峰会 : Treyarch 公司致力于 游戏工具库的 Rust 应用

GDC (Game Developers Conference 2021) 峰会将于 7月19~23日举行。 Treyarch 公司(《使命召唤系列》游戏公司)将在该峰会发表 Rust 相关议题。

Treyarch是美国的一家电子游戏开发商,总部位于加利福尼亚州的圣莫尼卡。 Treyarch成立于1996年,2001年被美国动视收购。其代表作为《使命召唤系列》。

Rust编程语言已经悄悄地在科技界掀起风暴,但在游戏工作室中的采用却比较缓慢。自2018年以来,Treyarch一直在逐步将Rust整合到我们的工具和管道中。本次会议将利用这一经验,探讨Rust可以给游戏工具程序员带来的机遇和挑战,并研究Rust可以成为游戏工具库的有力补充的方式。

Treyarch 在 GDC 大会的议题:

  • The Rust Programming Language for Game Tooling
  • Boots on the Ground: The Terrain of 'Call of Duty'
  • Shadows of Cold War: A Scalable Approach to Shadowing

https://schedule.gdconf.com/session/tools-summit-the-rust-programming-language-for-game-tooling/880599

在curl中使用hyper如何帮助使互联网更安全

作者:Sean McArthur(hyper作者)

大概翻译了一下重点摘要:

今年2月,互联网安全研究小组的Josh Aas、curl的Daniel Stenberg和我(来自hyper和Amazon Web Services)联合举办了一场网络研讨会,讨论内存安全和互联网,以及在curl中使用hyper如何帮助使互联网更安全。由于curl是开源和许可的,从物联网设备到卫星,以及大多数Linux发行版中都可以找到它。Curl是那些我们认为理所当然的基础库之一,但它影响着我们的网络生活。(视频回顾:https://www.youtube.com/watch?v=okGUxW_i9yk)

不太安全的互联网

内存安全是编程语言或系统的一个属性,它可以保护程序不会错误地访问它不应该访问的内存,例如认为缓冲区比它大,或者从一个指针解释数据,而这个指针后来被清理并用于不同的值。缺乏内存安全是对互联网基础设施的一个严重的持续威胁,并对个人和组织都造成了重大的、有意义的损害。比如说。

  • 微软估计,在过去十年中,他们的产品中70%的漏洞都是由缺乏内存安全造成的。
  • 谷歌发现,Chrome浏览器70%的严重安全漏洞是内存安全问题。
  • Android团队报告说,他们90%的漏洞是内存安全问题。
  • Mozilla指出,Firefox在其风格组件中的74%的安全漏洞是内存安全漏洞。
  • 最近的一项研究表明,60-70%的iOS和macOS漏洞都与内存安全有关。
  • Project Zero的一项分析发现,被利用的0-day中,有超过80%是由于缺乏内存安全。

这些漏洞可能导致现实生活中的隐私被侵犯、财务损失、公共服务被剥夺,以及人权受到影响。

造成这种漏洞泛滥的一个重要原因是,许多工具是用编程语言编写的,而这些语言对内存安全漏洞的保护作用不大,甚至没有。尽管 "内存安全 "语言已经存在了很长时间,但由于性能或互操作性要求,它们经常被忽视。

通常说到内存安全就要提到 Rust 。Rust是一种较新的语言,它执行内存安全,但具有与C类似的性能和互操作性。然而,这篇博文并不是呼吁 "用Rust重写(所有)"。

重写所有的东西是不现实的,每一段代码对人类的影响也是不一样的。相反,让我们简单地走过一个实际的努力,把内存安全带到互联网的关键部分。这种努力鼓励项目用内存安全库来取代库或模块化功能,而不是着手进行基础重写。它将工作分解成可管理的部分,并逐步提供价值。

curl无处不在

curl是开始这项工作的理想人选。潜在的影响是巨大的,因为curl无处不在。根据curl网站的说法。

curl在命令行或脚本中被用来传输数据。curl也被用于汽车、电视机、路由器、打印机、音频设备、手机、平板电脑、机顶盒、媒体播放器中,并且是超过100亿套软件应用程序的互联网传输引擎。

curl也是用C语言编写的。

在最近的一篇博文中,Daniel Stenberg指出,curl有一半的漏洞是C语言的错误--换句话说,与内存安全有关。这些错误包括缓冲区超读、缓冲区溢出、使用后释放和双重释放。尽管curl处理了一大堆协议,但HTTP是内存安全漏洞的第二大领域。

我们能让curl更安全吗?

这样做会使互联网更安全,所以值得努力。而这正是我们要做的。Curl的API和ABI是稳定的 "装甲门",不会损坏。但是curl已经熟悉了为其内部实现细节选择不同的 "后端"。在此之前,curl可以被配置为内部支持TLS、DNS、压缩和其他组件的后端。我们只需要提供一个更安全的HTTP后端选项。

"此时 Hyper 加入了群聊"

Hyper是一个安全、正确、快速的Rust语言的HTTP库。Hyper是开源的,并被AWS、Buoyant、Discord、谷歌、微软、Mozilla等公司使用。它有客户端和服务器端的API,并提供对HTTP/1和HTTP/2的支持。

Rust库不会带来对新运行时的依赖,在C语言中调用Rust函数也没有开销,反之亦然。它只是需要工程师的工作来暴露一个与C语言兼容的API。

Hyper开发人员立即知道这是需要做的事情。考虑到curl的使用量,这是一个让互联网更安全的机会。而hyper的Rust用户也会受益,因为这项工作所处理的任何bug修复或边缘案例也会为他们修复。

我们为hyper设计了一个C语言API。大部分的工作是围绕着识别Rust和C语言之间的假设差异。在这些部分被解决后,API就开始工作了。Hyper添加了几个选项,让curl对他们的用户来说几乎没有区别。

目前状态:

curl和hyper的代码都已经合并到了各自的主开发分支。Curl 可以被配置为以 hyper 作为其 HTTP 后台进行编译,尽管它将处于实验状态,直到 curl 的所有 HTTP 支持与它的内部 C 后台一样工作,并且被更广泛地试用。大部分的代码已经工作了,剩下的问题是更复杂的HTTP功能,仍然需要更新以支持不同的后端。跟踪工作的最好方法是通过curl的广泛测试套件。

从数量上看,在800个左右的HTTP单元测试中,有95%的测试被移植并通过了配置为后端的hyper。这意味着 curl 的很多标准功能已经在工作了。使用hyper后端,curl支持HTTP/1和HTTP/2。它可以使用任何 TLS 后台的 HTTPS,甚至可以使用 HTTP(S) 代理。无论使用哪种后端,电线上的HTTP请求都是相同的。

下一步是什么?

还需要在curl中加入一些功能,以使整个测试套件通过。此外,我们还希望改善对恐慌和内存不足的处理,以及其他关于从Rust中暴露C语言库的细节。此外,开发人员还需要调整或修复curl在完成单元测试时注意到的任何问题。

https://aws.amazon.com/blogs/opensource/how-using-hyper-in-curl-can-help-make-the-internet-safer/

TensorBase | RISC-V 芯片上执行 SQL

TensorBase 是第一个在真正的 RISC-V 芯片上运行的 SQL 数据库

SQL on RISC-V Chip in Rust (tensorbase.io)

AWS 添加了 9 项关于rust sdk的新服务

https://github.com/awslabs/aws-sdk-rust/releases/tag/v0.0.7-alpha

Reddit 讨论:Rust语言在项目管理上的优势

Rust在技术/项目管理上有什么优势吗?足以支撑你说服自己和其他人在公司内部开始尝试使用Rust?

作者提到了两点:

  1. 安全
  2. 维护

或者你有其他任何观点和证据来说服别人使用rust吗?

https://www.reddit.com/r/rust/comments/o0a61h/rusts_advantages_in_13_slides_on_management_level/%5D

在 Facebook Rust 是如何被用来构建 Linux 系统工具、库和服务的

这篇是一篇采访纪录,@NavyataBawa邀请到了 Facebook 工程师 Daniel Xu,分享他在 Facebook 是如何使用 Rust 来构建 Linux 系统工具、库和服务的。

https://developers.facebook.com/blog/post/2021/06/24/meet-rustaceans-daniel-xu/

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 26 期 - 利用 Rust 构造 riscv asm 分析工程

时间: 2021/06/8 20:30-21:30

主讲人:lyzh

内容

  1. 如何利用 Pest 库做文法分析,
  2. 用 PyO3 与 Python 交互
  3. 一些简单的数据流分析方法

查看回放


《Rust 唠嗑室》第 27 期-Michael Yuan-WasmEdge 介绍使用 Wasm Reactor 开发飞书机器人

时间: 2021/06/23 20:30-21:30

主讲人:Michael Yuan@WasmEdge

内容

SaaS 平台通常会为开发者和客户提供 API 以扩展其核心功能,比如 JIRA 的自定义插件或 Slack 上的聊天机器人。但是,传统的 RESTful API 方法通常需要开发者设置服务器来接收和响应来自 SaaS 平台的事件。例如,在 飞书聊天机器人应用程序中,开发者需要设置一个服务器来监听发送给机器人的消息,然后将机器人的响应发送回 Slack。这对开发人员来说既乏味又昂贵。

随着 serverless 计算的进步,尤其是由 WebAssembly 等新运行时启用的轻量级无服务器功能,现在可以将反应式功能直接嵌入到 SaaS 平台中,而无需让创建任何新服务器。

这次将会与大家讨论 SaaS 平台中反应式 serverless 函数背后的想法、用例和应用程序架构。

参考资料:

  1. https://github.com/WasmEdge/WasmEdge
  2. https://github.com/second-state/runw
  3. https://github.com/second-state/crunw
  4. http://reactor.secondstate.info
  5. http://reactor.secondstate.info/en/docs/user-create-a-bot.html
  6. https://github.com/second-state/serverless-reactor-starter

查看回放


🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第十期讨论主题:

  1. 语言特性: 聊聊 Rust 类型系统和 特质(trait)系统
  2. 领域项目: 继续跟随 Linux 基金会在线 WebAssembly 课程学习 Host 和 guest 高级通信方式:WAPC

参考资料:

  1. https://github.com/wasmCloud
  2. https://github.com/waPc
  3. https://crates.io/crates/wasmtime-provider
  4. https://www.infoq.cn/article/bc0fzghd9s6fmm03pmbi

查看回放


Rust 中文社区飞书群线上直播:尝试 websocket 聊天的桌面端开发

  1. 考察 tauri 的可行性和易用性
  2. gtk glade layout Editor 工具编辑 ListView
  3. 学习 TreeView 追加元素的代码
  4. 完成类似微信/飞书的聊天 UI 界面
  5. 完成文本输入框和点击发送按钮更新聊天消息的功能

查看回放


第十一期 讨论主题:

  1. 三个 Rust Friday 线上沙龙,也就是三个周五晚上的时间,学习了 Linux 基金会的免费 Web Assembly 课程,感觉收获颇丰。(沙龙 九/十/十一期)

  2. 课程的难度适中,重点在于比较系统。很多细节其实是需要自己去学习和了解的,但是课程为你布置好了体系结构。一些关键的概念都讲到了。可以让对 wasm 在分布式和边缘计算方向有一个比较具象的认识。

  3. 最有意思的是作者针对 Web Assembly 现状,提出的 wa PC 协议和 wasmcloud 项目,都很有创新精神。

参考资料:

  1. https://wasmcloud.com/
  2. https://lunatic.solutions/
  3. https://crates.io/crates/nats

查看回放


第十二期 讨论主题:

  1. 聊聊 eBPF 技术。
  2. 聊聊 Rocket web 框架设计。

参考资料:

  1. https://jishuin.proginn.com/p/763bfbd4c155
  2. https://doc.rust-lang.org/rustc/platform-support.html#tier-3
  3. https://security.tencent.com/index.php/blog/msg/124
  4. https://www.anquanke.com/post/id/221545
  5. https://github.com/alessandrod/aya
  6. https://www.infinyon.com/blog/2021/05/ebpf-routing-rust/
  7. https://github.com/nacardin/ebpf-proxy

查看回放

本月招聘

[北京中关村/远程][TensorBase][开源数据仓库]等一群人,做一件事

TensorBase,基于Rust的现代化开源实时数据仓库。它专注于开源大数据存储和分析的基础设施,让大数据背后的价值可以惠及这个时代中每个个体和企业。

团队介绍:我本人的简介在这里。TensorBase已获陆奇博士旗下奇绩创坛(原YC中国)的种子轮投资,陆奇博士就是我们的合伙人!

开源,惠人达己。创新,守正出奇。基础设施的未来,必定是开源和创新的。目前TensorBase有很好的社区和成果,ClickHouse的核心领导人Alexey Milovidov给TensorBase提交了PR,中国的开源数据基础设施项目首次赢得了国外开源数仓领袖的敬意,我们可以骄傲!

我有一个梦想:和一群中国工程师一起相互帮助、探索和努力,做一件可以走在这个时代的最前沿,还可以让自己成长、开心和骄傲的事情。我等这样一群人:

  1. 愿意使用Rust语言,并贡献到Rust开源社区(希望你不要把语言当作一个工具,虽然客观上它确是一个工具)。
  2. 有创新和探索精神,愿意在无人区工作。
  • 内核向:对数据工程、高性能系统、高性能计算、编译器、Linux内核、分布式、并发、网络协议等一个或者多个领域有实践或有兴趣。
  • 社区向:对Rust/数据有兴趣的前端或Technical writer。
  1. 爱做TensorBase这样一件事情,爱和TensorBase这样一群人一起工作。

全职、实习均可,但实习生需要onsite。

对比大厂,提供有竞争力的薪酬 + 期权。

联系方式:

邮箱: hr@tensorbase.io

工作地点:

海淀区中关村。这里离好几个新近的开源创业团队都很近,比如太极图形。这里是中国开源创业的新硅谷。不加班,希望大家早睡早起,我邀请大家一起去边上的海淀公园晨跑,为家国健康工作50年。同时,对于合适的同学,可以远程工作。

加入我们,一起创造TensorBase的下一个五年!

Datafuse | 数据库Rust讲师

Datafuse是使用Rust构建的完全面向云架构的新一代开源数仓,目的为用户提供更高性能,更低成本、更加易用的数据分析服务。

Datafuse项目地址: https://github.com/datafuselabs/datafuse

简历发送: hr @ datafuselabs.com

职位诱惑:

  • Remote办公,六险一金,弹性工作,大牛带队,扁平管理。
  • 待遇:面谈

职位职责:

  1. 负责Rust相关课程开发及授课工作
  2. 了解Datafuse项目,从项目中抽取模块编排教学工作
  3. 从事Datafuse部分文档输出
  4. 针对Datafuse输出用户解决方案

职位要求:

  1. 良好的系统编程能力,喜爱或是有Rust经验及扎实的C/C++功底更佳
  2. 熟悉数据库运行原理或是大数据应用场景更佳
  3. 喜欢关注新技术,愿意做技术推广
  4. 了解课程设计及开发
  5. 热忠自动完成事情

百度招聘Rust研发工程师

1. 职位概况

  • 岗位名称:资深Rust研发工程师
  • 所属部门:百度安全部
  • 岗位职级:技术T5-T7
  • 工作地点:北京市

2. 工作职责

  • 负责数据安全计算相关产品核心组件研发以及开源社区建设
  • 参与数据安全与隐私计算产品研发
  • 负责基于Intel SGX、ARM TrustZone等硬件技术的调研、分析与应用转化
  • 负责机器学习与深度学习算法在TEE技术上的移植与优化

3.职位要求

  • 计算机软件、信息安全等相关专业,统招本科及以上学历
  • 有良好的系统编程能力,喜爱或有Rust开发经验以及扎实的C/C++功底
  • 熟悉常用数据结构和算法,熟悉Linux系统
  • 关注代码设计和新技术,有持续学习的习惯
  • 有TEE可信执行环境、Intel SGX、机器学习、区块链相关技术经验者优先
  • 熟悉Github开源社区,长期贡献者优先

有意者请投简历至邮箱:luoyanhua@baidu.com

Phala招聘 Rust区块链研发工程师

1. 关于Phala Network

波卡系保密智能合约平台,可以实现保密前提下的数据交易协议,致力于成为 Web3.0 数据隐私计算的基础设施。基于 Substrate 开发的 Phala 未来将会成为 Polkadot 平行链,通过跨链协议为任何区块链的提供机密智能合约能力。Phala 是波卡生态的重要成员:2019 年 8 月获得 Web3 基金会 Grant,2020 年 3 月成为首批加入 Parity Substrate Builders 计划的项目之一。在 Phala Network,我们相信下一代区块链需要安全高效的处理机密数据,因此我们开发了基于 TEE 的保密合约,致力于为区块链实现保密能力。

2. 职位描述

  • 有 Rust 开发经验,或拥有快速上手 Rust 的能力
  • 区块链相关数据结构与算法
  • 扎实的计算机科学基础知识
  • 熟悉 Git 版本管理、单元测试,熟悉开源协作
  • 认同技术文档、开源教程、参与技术分享的重要性并积极参与

3. 职位方向

核心协议

  • Intel SGX SDK 开发与整合

  • P2P网络通信与密钥管理

  • XCM 跨链协议整合

  • 对接 Filecoin 等分布式存储协议

保密合约基础设施

  • WASM、Ink! 执行环境的整合、优化、Chain Extension 开发
  • 调研和改进合约编程模型
  • WASM 工具链的整合

区块链开发

  • 设计与实现 Substrate Pallet
  • Ink! 合约研发、测试与部署
  • 不同场景下的 XCM API 设计与实现

计算平台

  • 构建兼容工业标准的 Serverless、FaaS 基础平台在 Phala 上开发与移植应用,并测试平台的可用性

4. 加分项

  • 有可信执行环境应用开发经验:Intel SGX、AMD SEV、Arm TrustZone
  • Substrate或其他区块链节点开发经验
  • 扎实的应用密码学知识:非对称加密、零知识证明、多方安全计算
  • 跨链桥(比特币SPV节点)、Layer 2 开发经验(核心协议方向)
  • Solidity、Ink等智能合约开发经验(区块链开发方向)
  • FaaS、Serverless、Event Sourcing实施经验(计算平台方向)
  • 分布式系统任务调度、分布式事务、分布式存储等基础设施的开发经验(计算平台方向)

有意者请联系邮箱:hire@phala.network或微信:h4xbigcat

Automata Network 招聘区块链研发工程师

1. 关于Automata Network

Automata Network 是一个全新的隐私中间件网络,致力于通过为 dApp 提供无缝衔接的隐私保护和高可用性解决方案,来促进 Web3.0 社区的发展。我们正在寻找对此领域感兴趣,有热情,同时具有强大自力驱 的小伙伴。希望你能加入我们,作为团队核心的研发成员,进行技术的探索和应用的研发。公司总部位于新加坡,你也可以选择在家远程办公,请速将简历砸向我们吧!

2. 职位描述

  • 基于 Substrate (https://substrate.dev) 进行区块链相关应用的研发
  • 基于 Intel SGX 进行支持可信执行环境(TEE)相关研发
  • 探索前沿的技术解决方案,设计技术架构
  • 编写单元测试,编写技术文档,审核其他成员的代码

3. 职位要求

  • 两年以上的软件开发经验
  • 熟练掌握 Rust 或者 Modern C++ 其中一门语言,熟悉常见脚本语言如 Shell 和 Python的使用
  • 具有很强的解决问题能力和沟通能力,能够快速学习并掌握新的技术
  • 熟悉开源社区的开发和协作方式
  • 加分项: 熟悉 TEE 相关技术,如 Intel SGX 开发,了解区块链相关协议和常见共识算法

4. 加分福利

  • 具有市场竞争力的薪资待遇,优秀的小伙伴更可获得 token 配额
  • 灵活安排的工作时间,积极开放的工作氛围以及行业大牛的手把手指导
  • 在最前沿的技术领域进行探索,填补技术空白,能让你快速成长,成为领域内的专家

有意者请投简历至邮箱: hiring@ata.network

如果对Rust与区块链有兴趣,请关注OneBlock+微信公众号,还有多家区块链项目在招募Rust工程师。

Rust 与开源 | Rust 并不是一家公司

译者:郭晓双 / 后期编辑:张汉东

原文: https://blog.m-ou.se/rust-is-not-a-company/

本文是 Mara(Rust Library Team Leader) 写的一篇关于 Rust 项目开源治理的文章。


不是公司

在大多数公司中,董事和股东等等都在层次结构的顶端,他们为公司设定目标。目标、里程碑、最后期限,以及有望推动公司朝着最终目标前进的事情;通常是钱。

然后在部门和团队上划分了几层管理来划分工作量。每个人都负责部分目标,确保他们的部分完成,最终将任务分配给层次结构底部的员工,所有人都以某种方式朝着主要目标努力。

虽然像 Rust 这样的大型开源项目背后的结构从远处看起来很相似,但它通常是完全颠倒的。在这样的项目中,目标和目标不是顶级团队的,而是贡献者的。

例如,作为库团队,我们可以尝试决定应该重写格式机制 (std::fmt) 以使其更小更高效。但是做出这个决定并不会让它发生。而且我们没有分配任务的员工。这不是它的工作原理。

相反,一个对格式化算法充满热情的贡献者可能会出现,并开始研究这些事情。作为图书馆团队,我们的工作是赋予这个人权力。确保他们的计划与标准库的其余部分一致,审查他们的更改并提供有用的建设性反馈,如果更多人出现在这方面工作,则成立一个工作组来帮助他们组织一切等等。

公司不会让新人随机出现来做某事。如果做得好,这就是开源的特别之处和如此美妙的地方。

个人目标

理想情况下,像 Rust 这样的开源项目的项目目标只是参与其中的每个人的个人目标的组合。这很棘手。因为当一个新人出现时,我们不会为他们分配符合我们目标的任务。相反,这个人有自己的目标和想法,增加了已经非常多样化的潜在冲突目标。

这就是为什么一个由志愿者运营的开源项目需要一个管理结构。你不能把一百个人都有自己的目标,然后希望一切顺利。

因此,管理层所做的就是从每个从事某个主题的人那里获取所有个人目标,并尝试以某种方式引导他们,使事情顺利进行。这可能涉及对不适合其他想法的想法说不,或者可能需要进行大量讨论以调整想法以使其适合。这与典型公司的工作方式完全相反,目标来自上层,管理层决定如何拆分并将其分配给从事技术工作的人员。

虽然包括 Rust 在内的许多开源项目确实有一个总体方向或路线图,但这些必须基于个人贡献者的目标才能成功。说“我们 2021 年的主要目标是改进标准库中的格式化机制”将赋予那些已经在致力于它的人,并吸引那些已经想从事类似工作的人。这会对他们有所帮助,因为我们现在会优先考虑他们需要的任何管理决策和代码审查。它可以让人们集中注意力,完成更多工作。但没有这些贡献者,设定这样的目标毫无意义。与公司不同,我们无法选择人们花时间做什么,我们也不会雇用人员分配任务。

这是一个很好的事情。

这就是人们想要在 Rust 上工作的原因。

我并不是说不能像公司那样“自上而下”管理编程语言。许多编程语言已经并且正在以这种方式非常有效地开发。然而,我要说的是,我个人不希望 Rust 项目以这种方式工作。

我不希望管理锈公司库部门。我想授权那些想要改进 Rust 语言库的人。

蓬勃的空间

不同的贡献者有着不同的目标,通过非常不同的方式工作,并且需要与管理结构完全不同的东西。

对他们中的一些人来说,我们有适当的流程让他们的工作更轻松。想要开发新的语言特性的人可以以RFC的形式提交他们的想法,并加入库团队的讨论,以获得建议和指导。想要改进编译器实现的某些重要部分的人可以提交一个MCP(majorchangeproval)并与编译器团队讨论。如果有人想修复标准库中的一个bug,可以提交一个PR并让了解上下文的人对其进行复查(review)。

换句话说,我们为贡献者提供了很多空间。工作的空间,反馈问题的空间,得到帮助的空间,赢得认可的空间, 每一种能帮助他们成功做成事情的空间。

然而我们没有为一些类型的、不幸的贡献者创造合适的空间。

理想情况下,Rust库团队直到最近的工作一直集中在在API设计上。编译器团队只会关注关键重要问题的开发。较小的变更由单个库审阅者审阅。但是并没有对更大领域的变更进行管理。这意味着没有合适的空间提供给那些想要彻底检查并且变更std : : fmt实现的人。没有一个团队他们可以去或参与这方面的工作,那么对于想去变更std : : fmt实现的人来讲,这个目标就是更难、或者完全不可能完成的了。

这就是我成立新库团队的原因。

为某个事情腾出空间通常会(意外地)导致其它事情的空间被夺走。 如果一个人不太理解事情的实现,但是他们想要应用标准库设计的实践经验,并且非常关心标准库的接口,但是周会上面主要讨论如何让bug少,在下一个版本之前如何解决现有的问题的事情,那么他是不会在一个这样的周会上面十分积极的。

我们现在已经有了库贡献者。这是一个空间,容纳管理比一般人更加多的参与项目,但是不参与整体团队的决策的人。例如那些只处理库的特定部分,或者帮助审核(review)的人,我们为这些人提供了空间。一年之前如果没有一个更加合适的团队的话,就没有合适的空间让一个人去审核(review)库的实现。

在核心团队和库团队成员的授权和帮助下,我能够做出这些团队结构的改变。反过来,这些变化有望使这些新团队的成员茁壮成长,并反过来使更多的贡献者受益,最终使语言的所有用户受益。

保持改变

我并不认为我们现在已经成功地为每个可能的贡献者创造了空间。但是一旦团队的重组尘埃落定,我确实认为结果会比我们之前的情况有所改善,更符合目前的项目。

“此时此刻” 在这里成为了一个重要的部分。这些团队是围绕着目前的成员和我认为在不久的将来可能成为成员的人而设计的。但是团队的成员的需求变化,有时会出奇地快。在一个完全由为其做出贡献的人定义的项目中,对于这个项目自身和结构发生变化的事情的时候, 它就会快速的改变。

2018年,库团队大量参与指导了流行crate的作者。该团队发布了一套指南,将会审查(review) crate 并且和crate的作者一起实施它。所有这些都是为了提高 Rust crate 生态系统的一致性和质量。

直到几天前,这在技术上仍然是我们团队明确目标的一部分,尽管这种事情已经不是很多了;尤其是因为 Ashley Mannix 离开了一段时间。如果没有人将事情作为他们的个人目标,那么这个事情就不会发生。

这是一个很好的事情。

每个人的 Rust 愿望清单上都有很多事情没有发生,因为没有人在做这件事。我们不是一家有我们需要达到的最后期限和里程碑的公司。我们是一群多元化且瞬息万变的人,他们热衷于让我们想要付出努力的事情发生。包括一群热衷于管理这一切的人,试图为所有这些努力的发生腾出空间。

有很多事情,我们应该有属于它的空间,但目前没有空间。但如果我们继续努力, 我们不断进行小的改进。继续适应。继续关注我们周围也想做出贡献的人;赋予他们以及彼此权力。然后每一步都将朝着正确的方向迈出一步,使 Rust 和所有为它做过贡献的人,与它一起茁壮成长。

开源与心理健康

作者: jackpot51(Redox作者) / 译者:张汉东


今天看到 Redox (Rust 实现的下一代操作系统)作者在 Redox 官方博客上发了一篇文章 《开源与心智健康》 。这篇文章是他对多年的好朋友,Redox OS 贡献者 jD91mZM2 的回忆录。

“ jD91mZM2 于 2021 年 3 月去世,享年 18 岁。他参与了 2018、2019 和 2020 年的 Redox OS Summer of Code。他的开发涉及Redox OS 的各个层面,从内核到 relibc,再到移植程序。** jD91mZM2** 在 https://www.redox-os.org/news/ 上发表的许多文章来介绍他自己的这些工作。 这篇文章有些黑暗、沉重,但作者写它的目的也不仅仅是为了回忆好朋友,同时也想帮助开源社区里有同样心智问题的朋友。作者给出了联系方式:https://twitter.com/jeremy_soller ,希望有相同问题的朋友能早点和他联系,他愿意提供帮助。

虽然这篇文章可能比较黑暗和沉重,但我觉得这篇文章值得翻译,也许能给我们有所警示。以下是正文。

反思

昨天,另一位贡献者给我发来消息,说 jD91mZM2 离线的时间很长,而且他没有回复电子邮件。我通过我所掌握的信息进行了联系,但无济于事。我把他的真实姓名告诉了另一位贡献者,后者找到了他的讣告。我们验证了他的名字、地点和出生日期是否相符。虽然没有列出死因,但我相信我们找到的证据表明他是在精神疾病发作后自杀的。

在得知这一切后,我感到很震惊。这样一个多产的贡献者,不仅对Redox,而且对许多项目都有贡献,怎么会觉得死亡比生命更重要?他是一个能力极其出色的人,而且直到最近,他似乎还能很好地把握自己的生活。但我思考越久就越意识到,这只是我对他生活的一种幻觉。而且,这种幻觉越深,事实可能越恶化。

我最后一次与 jD91mZM2 交流是在二月,在他去世前一个月。这次交流纯粹是技术性的,关于 Redox 内核的 arch64 端口。我不禁想到,这也许是他决定选择死亡的一个因素。

在开放源代码中,我们经常强调好的代码的重要性。毕竟,每个开源项目的交付物,都是源代码,对吗?我们常常忘记,好的代码是由优秀的人编写的,而留住这些人并让他们保持快乐,应该是任何项目维护者的首要考虑。

心理健康问题的发作有很多方面。一方面,精神疾病通常有一个遗传因素。另一方面,这些遗传的前兆通常需要慢性和急性的环境诱因。这些慢性诱因可以是长期的不良家庭或工作环境,并导致精神疾病本身的表现。急性诱因可能是,例如,与某人发生争执,导致精神疾病发作。这些发作可能严重到足以压倒求生本能,导致自杀。

在这种情况下,自杀并不是一种软弱的表现。事实上,它是一种极致信念和实力的展示。即使在精神疾病的背景下,大脑的某些部分通常不受影响。这些部分伴随人类历史悠久,我们很少有意识地控制它们。自杀的企图需要克服有意识的求生欲望。要成功,就是要克服极端的潜意识欲望。这就意味着,对于自杀,往往是最聪明、最有能力的人才能够成功。

这种对有能力的人的逆选择是一种可怕的流行病。人类总体上迫切需要人为地解决长期存在的问题。以气候变化为例。在每年80万自杀的人中,平均来说也许比我们其他人更有能力,如果有几个人在开发核聚变发电方面起到了作用,那会怎么样?

然而,我们作为一个社会已经采取了这样的立场:这些事件是一种不可阻挡的力量。导致自杀的因素是内部的,而不是外部的。原则上,我拒绝相信这一点。对于每一个问题,我们都必须寻找原因并加以缓解,即使最后不可能做到。

因此,我不得不审视自己的行为,看看有什么可以做得不同。看看我是否可以挽救一个人的生命,并看看我在未来可以挽救哪些生命。

开源与心理健康

开源的某些方面似乎吸引了人类中最奇怪的人,包括我自己。坚持所有的东西都是可检查的,这也许是由强迫性行为驱动的。而那些容易出现这种行为的人,往往会从其他疾病中继承下来。多动症、自闭症、双相情感障碍、抑郁症和其他疾病在开放源码贡献者中非常普遍。

因此,开源社区中也明显缺乏软技能。这显然会导致分裂社区和将开源本身与 "正常 "世界隔离的后果。对我们来说,幸运的是,开放源代码最终成为一个足以盈利的行业。这种资本的注入导致了开源领域人才的显著多样化。

然而,这也是有代价的。那些不符合开源项目新的商业利益的人往往被抛在一边。由于与原始维护者的分歧导致项目本身出现难以克服的变化,项目被进一步被分叉又分叉。我自己也参与了这方面的所有工作。

在大多数情况下,都缺乏对人力成本的调查。调查开源贡献者中众多的心理健康事件,并试图找出一些共同的因素。有时这些事件会导致新的项目,有时会导致贡献者精疲力尽,然后离开开放源码,有时会导致自杀。

我们必须认识到,我们在创造导致精神疾病的慢性压力和导致危险发作的急性压力方面所扮演角色。

我自己的旅程

我对心理疾病没有免疫力。我经常收到这样的信息:"你似乎把你的东西都控制住了,你是怎么做到的?" 残酷的事实是,我怀疑我们中是否有人真的做到了。而我们对 "控制 "的定义可能是非常不同的。拥有成功的项目并不等同于拥有普遍的幸福。

我不得不承认,我现在比以前幸福多了。因此,也许与一般人相比,我确实有事情在控制之中。我的生活一直是治疗师、精神病医生、药物和隔离的奥德赛。它本来很容易像其他人一样结束。我很幸运地找到了我的慢性压力源,并尽职尽责地消除它们。

我大一的时候,与 jD91mZM2 决定结束自己生命的年龄差不多,特别艰难。我的体重增加了近50磅。我和另外三个室友住在一起,其中两个也早逝了。我交替使用多动症药物、抗抑郁药,甚至吸烟,以寻找能 "修复 "自我的方法。在整个过程中,我一直在编程,为此常常忽略了我的学业。

在上大学之前,我曾在卓尔医疗公司担任实习生,编写除颤器软件。我在这项工作中拥有两项专利。老实说,在对计算机的理解上,我比别人高出一截。我也对自己说实话,在对人的理解方面,我比别人差了一大截,包括我自己。

在那段时间里,我与研发部的副总裁建立了关系,他成了我事实上的老板。大一快结束时,他联系我,问我是否想继续工作。我答应了。

大二的时候,我的情况非常不同。我把大部分时间花在写软件上,并且赚了不少钱。我对学校没有兴趣。我有几门课不及格,而许多高水平的CS课程也挂了。我很快就从大学退学,全职从事软件工程。

这对我的心理健康产生了巨大的积极影响。我减轻了体重。我与我大学的其他校友保持联系,并最终通过他们认识了我的妻子。我们一起买了一套房子。我开始了Redox操作系统,并且我和妻子结婚了,也开始在System76工作。我们拥有一个漂亮的女儿。从大二开始,我从来没有想过自己的心理健康问题,也不需要去治疗或用药。我所有的压力都消失了。

一个人的心理健康成功并不总是能转化为其他人的成功。在这一历程中,我创造和破坏了(通过忽视)数百种关系。我不得不承认,虽然我很快乐,但我有一种倾向,会在别人身上造成相当大的反差。我保留了给我带来快乐的关系,而忽略了那些需要努力的关系。而在某些时候,也许我忘记了与 jD91mZM2 保持联系,确保他找到与我一样的幸福。

有没有一个解决方案?

没有解决方案,每个案例都是不同的。但我觉得有必要检查一下这些年来我失去的许多人,我希望你也这样做。我们仍然处于了解人类思想的黑暗时代,我们越是相互检查,我们就会做得越好。我知道我本可以有所作为,如果我多做一点的话。也许不是为了jD91mZM2,而是为了有同样感觉的人。我不会再以他们所写的代码来评价贡献者。代码不会自己产生,写代码的人甚至比 "开源 "本身更需要维护。

尾声

翻译完本文的时候,在google上搜索到一个组织 https://osmihelp.org/ ,该组织出了一本书:《Mental Health in Tech: Guidelines for Mental Wellness in the Workplace》,可以在 Leanpub 上找到,也许能有所帮助。

三万言|2021 年 Rust 行业调研报告

作者:张汉东

PDF 版本下载


文前

Rust 语言是一门通用系统级编程语言,无GC且能保证内存安全、并发安全和高性能而著称。自2008年开始由 Graydon Hoare 私人研发,2009年得到 Mozilla 赞助,2010年首次发布 0.1.0 版本,用于Servo 引擎的研发,于 2015年5月15号发布 1.0 版本。

自发布以来,截止到2021 年的今天,经历六年的发展,Rust 得到稳步上升,已逐渐趋于成熟稳定。

至 2016 年开始,截止到 2021年,Rust 连续五年成为 StackOverflow 语言榜上最受欢迎的语言

1

2021年 2 月 9 号,Rust 基金会宣布成立华为、AWS、Google、微软、Mozilla、Facebook 等科技行业领军巨头加入 Rust 基金会,成为白金成员,以致力于在全球范围内推广和发展 Rust 语言。

那 Rust 语言到底有何魅力,能让广大开发者和巨头公司这么感兴趣呢?

本文打算从 Rust 语言自身特性 和 Rust 行业应用盘点两个方面的社区调研来尝试来解答这个问题。供想选择 Rust 的公司参考。

其实 Rust 生态还有很多内容,等待大家挖掘。本文内容还未覆盖完全 Rust 生态的方方面面。

注明: 本文中所罗列数据均来源互联网公开内容。

认识 Rust 语言

编程语言设计在两个看似不可调和的愿望之间长期存在着矛盾对立。

  • 安全 ( safe )。 我们想要强类型系统来静态地排除大量错误。 我们要自动内存管理。我们想要数据封装, 这样我们就可以对私有变量执行不变的对象的表示形式,并确保它们将不会被不受信任的代码破坏。
  • 控制 (control )。 至少对于 Web浏览器,操作系统,或游戏引擎这样的 系统编程 (system programming) 程序,约束它们性能或资源是一个重要的问题,我们想了解数据的字节级表示。 我们想要用底层语言 (low-level programming) 的编程技术优化我们程序的时间和空间的使用。 我们希望在需要时使用 裸机

然而,按照传统的看法,鱼和熊掌不能兼得。 Java 之类的语言使我们极大的安全保证,但代价是牺牲对底层的控制。结果,对于许多系统编程应用程序,唯一现实的选择是使用一种像 C 或 C++ 提供细粒度的语言控制资源管理。 但是,获得这种控制需要很高的成本。例如,微软最近报告说,他们修复的 70% 安全漏洞都归因于内存安全违规行为 33,并且都是能被强类型系统排除的问题。同样,Mozilla 报告指出,绝大多数关键 他们在Firefox中发现的错误是内存有关的16

如果可以以某种方式两全其美: 安全系统编程的同时对底层有控制权,岂不美哉。因此,Rust 语言应运而生。

官方网如此介绍 Rust : 一门赋予每个人 构建可靠且高效软件能力的语言。

Rust 语言有三大优势值得大家关注:

  1. 高性能。Rust 速度惊人且内存利用率极高。由于没有运行时和垃圾回收,它能够胜任对性能要求特别高的服务,可以在嵌入式设备上运行,还能轻松和其他语言集成。
  2. 可靠性。Rust 丰富的类型系统和所有权模型保证了内存安全和线程安全,让您在编译期就能够消除各种各样的错误。
  3. 生产力。Rust 拥有出色的文档、友好的编译器和清晰的错误提示信息, 还集成了一流的工具——包管理器和构建工具, 智能地自动补全和类型检验的多编辑器支持, 以及自动格式化代码等等。

Rust 足够底层,如果有必要,它可以像 C 一样进行优化,以实现最高性能。

抽象层次越高,内存管理越方便,可用库越丰富,Rust 程序代码就越多,做的事情越多,但如果不进行控制,可能导致程序膨胀。

然而,Rust 程序的优化也很不错,有时候比 C 语言更好,C 语言适合在逐个字节逐个指针的级别上编写最小的代码,而 Rust 具有强大的功能,能够有效地将多个函数甚至整个库组合在一起。

但是,最大的潜力是可以无畏(fearless)地并行化大多数 Rust 代码,即使等价的 C 代码并行化的风险非常高。在这方面,Rust 语言是比 C 语言更为成熟的语言。

Rust 语言也支持高并发零成本的异步编程,Rust 语言应该是首个支持异步编程的系统级语言。

媲美 C / Cpp 的高性能

Rust vs C

rustvsc

Rust vs Cpp

rustvscpp

Rust vs Go

rustvsgo

用 Rust 编写的程序的运行时速度和内存使用量应该和用 C 编写的程序差不多,但这两种语言的总体编程风格不同,很难去概括它们的性能。

总的来说:

  1. 抽象是一把双刃剑。Rust 语言抽象程度比 C 语言更高,抽象会隐藏一些不是那么优化的代码,这意味着,默认实现的 Rust 代码性能不是最好的。所以,你的 Rust 代码必须经过优化才能达到媲美 C 的性能。Unsafe Rust 就是高性能出口。
  2. Rust 默认线程安全,消除数据竞争,让多线程并发编程更具实用价值。
  3. Rust 在有些方面确实比 C 快。理论上,C 语言什么都可以做。 但在实践中,C 抽象能力比较低,不那么现代化,开发效率比较低。只要开发者有无限时间和精力,就可以让 C 语言在这些方面比 Rust 更快。

因为 C 语言足以代表高性能,下面就分别谈一下 C 和 Rust 的异同。如果你熟悉 C/Cpp,也可以根据此对比来评估 Cpp 和 Rust。

Rust 和 C 都是硬件直接抽象

Rust 和 C 都是直接对硬件的抽象,都可看作一种「可移植汇编程序」。

Rust 和 C 都能控制数据结构的内存布局、整数大小、栈与堆内存分配、指针间接寻址等,并且一般都能翻译成可理解的机器代码,编译器很少插入 "魔法"。

即便 Rust 比 C 有更高层次的结构,如迭代器、特质(trait)和智能指针,它们也被设计为可预测地优化为简单的机器代码(又称 "零成本抽象")。

Rust的类型的内存布局很简单,例如,可增长的字符串StringVec<T> 正好是{byte*, capacity, length}。Rust没有任何像 Cpp里的 移动 或 复制构造函数 这样的概念,所以对象的传递保证不会比传递指针或 memcpy 更复杂。

Rust 借用检查只是编译器对代码中引用的静态分析。生命周期(lifetime)信息早就在 中级中间语言(MIR) 生成前完全抽离了。

Rust 中不使用传统的异常处理,而是使用基于返回值的错误处理。但你也可以使用 恐慌(Panic)来处理像 Cpp 中那样的异常行为。它可以在编译时被禁用(panic = abort),但即便如此,Rust 也不喜欢 与 Cpp异常 或 longjmp 混在一起。

同样的 LLVM 后端

Rust与LLVM有很好的整合,所以它支持链接时间优化,包括ThinLTO,甚至是跨越C/C++/Rust语言边界的内联。也有按配置优化(Profile-guided Optimization,PGO)的支持。尽管 rustc 比 clang 生成的LLVM IR更加冗长,但优化器仍然能够很好地处理它。

C 语言用 GCC 编译比用 LLVM 更快,现在 Rust 社区也有人在开发 GCC 的 Rust 前端。

理论上,因为 Rust 有比C 更严格的不可变和别名规则,应该比 C 语言有更好的性能优化,但实际上并没有起到这样的效果。目前在LLVM中,超越 C语言的优化是一项正在进行的工作,所以Rust仍然没有达到其全部潜力。

都允许手工优化,但有一些小例外

Rust 的代码足够底层和可预测,可以通过手工调整它的优化到什么样的汇编代码。

Rust 支持 SIMD ,对内联和调用约定有很好的控制。

Rust 和 C 语言足够相似,C 语言的一些分析工具通常可以用于 Rust 。

总的来说,如果性能绝对关键,并且需要手工优化压榨最后一点性能,那么优化 Rus t 与 优化 C 语言并没有什么不同。

但是在一些比较底层的特性,Rust 没有特别好的替代方法。

  • goto。Rust 中没有提供goto,不过你可以使用循环的 break 标签来代替。C 语言中一般使用 goto 来清理内存,但是 Rust 因为有确定性析构功能,所以不需要 goto。然而有一个 非标准的 goto 扩展,对性能优化比较有用。
  • 栈内存分配allocaC99可变长度数组,可以节省内存空间,减少内存分配次数。但这些即使在 C 语言中也是有争议的,所以Rust远离了它们。

Rust 相比 C 语言的一些开销

如果没有经过手工优化,Rust 因为其抽象表达也会有一些开销。

  • Rust缺乏隐式类型转换和只用usize的索引,这导致开发者只能使用这种类型,哪怕只需要更小的数据类型。64位平台上用usize做索引更容易优化,而不需要担心未定义行为,但多余的bit位可能会给寄存器和内存带来更大的压力。而在 C 中,你可以选择 32位类型。
  • Rust 中的字符串,总是会携带指针和长度。但是很多 C 代码中的函数只接收指针而不管大小。
  • for i in 0...len {arr[i]} 这样的迭代,性能取决于 LLVM 优化器能否证明长度匹配。有时候,它不能,并且边界检查也会抑制自动矢量化。
  • C 语言比较自由,对于内存有很多“聪明”的使用技巧,但在 Rust 里就没这么自由了。但Rust仍然给了内存分配很多控制权,并且可以做一些基本的事情,比如内存池、将多个分配合并为一个、预分配空间等等。
  • 在不熟悉 Rust 借用检查的情况下,可能会用 Clone 来逃避使用引用。
  • Rust 的标准库中 I/O 是不带缓存的,所以需要使用 BufWriter 来包装。这就是为什么有些人说 Rust 写的代码还不如 Python 快的原因,因为 99% 的时间都用在 I/O上了。

可执行文件大小

每个操作系统都有一些内置的标准C库,其中有大约30MB的代码。C 语言的执行文件,可以“免费”使用这些库。

一个小的 "Hello World " 级 C 可执行文件实际上不能打印任何东西,它只调用操作系统提供的printf。

而 Rust 则不可以,Rust可执行文件会捆绑自己的标准库(300KB或更多)。幸运的是,这只是一次性的开销,可以减少。

对于嵌入式开发,可以关闭标准库,使用 "no-std",Rust将生成 "裸 "代码。

在每个函数的基础上,Rust代码的大小与C差不多,但有一个 "泛型膨胀 "的问题。泛型函数为它们所使用的每一种类型都有优化的版本,所以有可能出现同一个函数有8个版本的情况,cargo-bloat 库有助于发现这些问题。

在Rust中使用依赖关系是非常容易的。与 JS/npm 类似,现在推荐使用小型且单用途的包,但它们确实在不断增加。cargo-tree 命令对于删减它们非常有用。

Rust 略胜 C 的一些地方

  1. 为了隐藏实现细节,C 库经常返回不透明的数据结构指针,并确保结构的每个实例只有一个副本。它会消耗堆分配和指针间接寻址的成本。Rust 内置的隐私、单一所有权规则和编码惯例允许库暴露其对象,而不需要间接性,这样,调用者可以决定将其放入堆(heap)上还是栈(stack)中。可以主动或彻底地优化栈上的对象。
  2. 缺省情况下,Rust 可以将来自标准库、依赖项和其他编译单元的函数内联。
  3. Rust 会对结构体字段进行重排,以优化内存布局。
  4. 字符串携带大小信息,使得长度检查速度很快。并允许就地生成子串。
  5. 与 C++ 模板类似,Rust 中泛型函数会单态化,生成不同类型的副本,因此像 sort 这样的函数和 HashMap 这样的容器总是针对相应的类型进行优化。对于 C 语言,则必须在修改宏或者处理void*和运行时变量大小的效率较低的函数之间做出选择。
  6. Rust的迭代器可以组合成链状,作为一个单元一起被优化。因此,你可以调用it.buy().use().break().change().mail().upgrade(),而不是对同一个缓存区多次写入的一系列调用。
  7. 同样,通过 Read 和 Write 接口,接收一些未缓存的流数据,在流中执行 CRC 校验,然后将其转码、压缩,再写入网络中,所有这些都可以在一次调用中完成。虽然 C 语言中应该也可以做到,但它没有泛型和特质(trait),将很难做到。
  8. Rust 标准库中内置高质量的容器和优化过的数据结构,比 C 使用起来更方便。
  9. Rust的 serde 是世界上最快的JSON解析器之一,使用体验非常棒。

Rust 比 C 明显优越的地方

主要是两点:

  1. Rust 消除数据竞争,天生线程安全,解放多线程生产力,是 Rust 明显比 C / Cpp 等语言优越的地方。
  2. Rust 语言支持异步高并发编程。
  3. Rust 支持 安全的编译期计算。

线程安全

即使是在第三方库中,Rust 也会强制实现所有代码和数据的线程安全,哪怕那些代码的作者没有注意线程安全。一切都遵循一个特定的线程安全保证,或者不允许跨线程使用。当你编写的代码不符合线程安全时,编译器会准确地指出不安全之处。

Rust 生态中已经有了很多库,如数据并行、线程池、队列、任务、无锁数据结构等。有了这类组件的帮助,再加上类型系统强大的安全网,完全可以很轻松地实现并发/并行化 Rust 程序。有些情况下,用 par_iter 代替 iter 是可以的,只要能够进行编译,就可以正常工作!这并不总是线性加速( 阿姆达尔定律(Amdahl's law)很残酷),但往往是相对较少的工作就能加速 2~3 倍。

延伸:阿姆达尔定律,一个计算机科学界的经验法则,因 Gene Amdahl 而得名。它代表了处理器并行计算之后效率提升的能力。

在记录线程安全方面,Rust 和 C 有一个有趣的不同。

Rust 有一个术语表用于描述线程安全的特定方面,如 SendSyncguardscell

对于 C 库,没有这样的说法:“可以在一个线程上分配它,在另一个线程上释放它,但不能同时从两个线程中使用它”。

根据数据类型,Rust 描述了线程安全性,它可以泛化到所有使用它们的函数。

对于 C 语言来说,线程安全只涉及单个函数和配置标志。

Rust 的保证通常是在编译时提供的,至少是无条件的。

对于 C 语言,常见的是“仅当 turboblub 选项设置为 7 时,这才是线程安全的” (这句话并不是真的让你去设置 turboblub 选项,因为本来就没有这个选项,所以 C 语言不会保证你线程安全 )。

异步并发

Rust 语言支持 async/await异步编程模型。

该编程模型,基于一个叫做 Future 的概念,,在 JavaScript 中也叫做 PromiseFuture 表示一个尚未得出的值,你可以在它被解决(resolved)以得出那个值之前对它进行各种操作。在许多语言中,对 Future 所做的工作并不多,这种实现支持很多特性比如组合器(Combinator),尤其是能在此基础上实现更符合人体工程学的 async/await 语法。

Future 可以表示各种各样的东西,尤其适用于表示异步 I/O :当你发起一次网络请求时,你将立即获得一个 Future 对象,而一旦网络请求完成,它将返回任何响应可能包含的值;你也可以表示诸如“超时”之类的东西,“超时”其实就是一个在过了特定时间后被解决的 Future ;甚至不属于 I/O 的工作或者需要放到某个线程池中运行的CPU密集型的工作,也可以通过一个 Future 来表示,这个 Future 将会在线程池完成工作后被解决。

Future 存在的问题 是它在大多数语言中的表示方式是这种基于回调的方法,使用这种方式时,你可以指定在 Future 被解决之后运行什么回调函数。也就是说, Future 负责弄清楚什么时候被解决,无论你的回调是什么,它都会运行;而所有的不便也都建立在此模型上,它非常难用,因为已经有很多开发者进行了大量的尝试,发现他们不得不写很多分配性的代码以及使用动态派发;实际上,你尝试调度的每个回调都必须获得自己独立的存储空间,例如 crate 对象、堆内存分配,这些分配以及动态派发无处不在。这种方法没有满足零成本抽象的第二个原则,如果你要使用它,它将比你自己写要慢很多,那你为什么还要用它。

Rust 中的方案有所不同。不是由 Future 来调度回调函数,而是由一个被称为执行器(executor)的组件去轮询 Future。而 Future 可能返回“尚未准备就绪(Pending)”,也可能被解决就返回“已就绪(Ready)”。该模型有很多优点。其中一个优点是,你可以非常容易地取消 Future ,因为取消 Future 只需要停止持有 Future。而如果采用基于回调的方法,要通过调度来取消并使其停止就没这么容易了。

同时它还能够使我们在程序的不同部分之间建立真正清晰的抽象边界,大多数其他 Future 库都带有事件循环(event loop),这也是调度 你的Future 执行 I/O 的方法,但实际上你对此没有任何控制权。

而在 Rust 中,各组件之间的边界非常整洁,执行器(executor)负责调度你的 Future ,反应器(reactor)处理所有的 I/O ,然后是你的实际代码。因此最终用户可以自行决定使用什么执行器,使用他们想使用的反应器,从而获得更强的控制力,这在系统编程语言中真的很重要。

而此模型最重要的真正优势在于,它使我们能够以一种真正零成本的完美方式实现这种状态机式的 Future 。也就是当你编写的 Future 代码被编译成实际的本地(native)代码时,它就像一个状态机;在该状态机中,每次 I/O 的暂停点都有一个变体(variant),而每个变体都保存了恢复执行所需的状态。

而这种 Future 抽象的真正有用之处在于,我们可以在其之上构建其他 API 。可以通过将这些组合器方法应用于 Future 来构建状态机,它们的工作方式类似于迭代器(Iterator)的适配器(如 filtermap)。但是这种方式是有一些缺点的,尤其是诸如嵌套回调之类,可读性非常差。所以才需要实现 async / await异步语法。

目前 Rust 生态中,已经有了成熟的 tokio 运行时生态,支持 epoll 等异步 I/O。如果你想用 io_uring ,也可以使用 Glommio ,或者等待 tokio 对 io_uring 的支持。甚至,你可以使用 smol 运行时提供的 async_executorasync-io 来构建你自己的运行时。

编译期计算

Rust 可以支持类似于 Cpp 那样的 编译期常量求值。这一点是明显比C优越的。

虽然目前功能还不如 Cpp 那样强大,但还在不断的维护中。

为什么 Rust 中支持 编译期计算这么谨慎呢?因为Rust 编译期求值是必须要保证安全的,所以有很多考虑。Rust 编译期求值不像 Cpp 那样自由且容易滥用。

可靠性

2020 年 6月份,来自3所大学的5位学者在ACM SIGPLAN国际会议(PLDI'20)上发表了一篇研究成果,针对近几年使用Rust语言的开源项目中的安全缺陷进行了全面的调查。这项研究调查了5个使用Rust语言开发的软件系统,5个被广泛使用的Rust库,以及两个漏洞数据库。调查总共涉及了850处unsafe代码使用、70个内存安全缺陷、100个线程安全缺陷。

safety

在调查中,研究员不光查看了所有漏洞数据库中报告的缺陷和软件公开报告的缺陷,还查看了所有开源软件代码仓库中的提交记录。通过人工的分析,他们界定出提交所修复的BUG类型,并将其归类到相应的内存安全/线程安全问题中。所有被调查过的问题都被整理到了公开的Git仓库中:https://github.com/system-pclub/rust-study

调查结果说明:

  1. Rust语言的safe代码对于空间和时间内存安全问题的检查非常有效,所有稳定版本中出现的内存安全问题都和unsafe代码有关。

  2. 虽然内存安全问题都和unsafe代码有关,但大量的问题同时也和safe代码有关。有些问题甚至源于safe代码的编码错误,而不是unsafe代码。

  3. 线程安全问题,无论阻塞还是非阻塞,都可以在safe代码中发生,即使代码完全符合Rust语言的规则。

  4. 大量问题的产生是由于编码人员没有正确理解Rust语言的生命周期规则导致的。

  5. 有必要针对Rust语言中的典型问题,建立新的缺陷检测工具。

那么这份调查报告背后 Rust 的安全性是如何保证的呢?Unsafe Rust 又是为什么 Unsafe 呢?

所有权: Rust 语言内存安全机制

Rust 的设计深深地吸取了关于安全系统编程的学术研究的精髓。特别是,与其他主流语言相比,Rust 设计的最大特色在于采用了所有权类型系统(在学术文献中通常称为仿射子结构类型系统36)。

所有权机制,就是Rust 语言借助类型系统,承载其“内存安全”的思想,表达出来的安全编程语义和模型。

所有权机制要解决的内存不安全问题包括:

  1. 引用空指针。

  2. 使用未初始化内存。

  3. 释放后使用,也就是使用悬垂指针。

  4. 缓冲区溢出,比如数组越界。

  5. 非法释放已经释放过的指针或未分配的指针,也就是重复释放。

注意,内存泄露不属于内存安全问题范畴,所以 Rust 也不解决内存泄露问题。

为了保证内存安全,Rust 语言建立了严格的安全内存管理模型:

  • 所有权系统。每个被分配的内存都有一个独占其所有权的指针。只有当该指针被销毁时,其对应的内存才能随之被释放。
  • 借用和生命周期。每个变量都有其生命周期,一旦超出生命周期,变量就会被自动释放。如果是借用,则可以通过标记生命周期参数供编译器检查的方式,防止出现悬垂指针,也就是释放后使用的情况。

其中所有权系统还包括了从现代 C++ 那里借鉴的 RAII 机制,这是 Rust 无 GC 但是可以安全管理内存的基石。

建立了安全内存管理模型之后,再用类型系统表达出来即可。Rust 从 Haskell 的类型系统那里借鉴了以下特性:

  • 没有空指针
  • 默认不可变
  • 表达式
  • 高阶函数
  • 代数数据类型
  • 模式匹配
  • 泛型
  • trait 和关联类型
  • 本地类型推导

为了实现内存安全,Rust 还具备以下独有的特性:

  • 仿射类型(Affine Type),该类型用来表达 Rust 所有权中的 Move 语义。
  • 借用、生命周期。

借助类型系统的强大,Rust 编译器可以在编译期对类型进行检查,看其是否满足安全内存模型,在编译期就能发现内存不安全问题,有效地阻止未定义行为的发生。

内存安全的 Bug 和并发安全的 Bug 产生的内在原因是相同的,都是因为内存的不正当访问而造成的。同样,利用装载了所有权的强大类型系统,Rust 还解决了并发安全的问题。Rust 编译器会通过静态检查分析,在编译期就检查出多线程并发代码中所有的数据竞争问题。

Unsafe Rust :划分安全边界

**为了和现有的生态系统良好地集成,**Rust 支持非常方便且零成本的 FFI 机制,兼容 C-ABI,并且从语言架构层面上将 Rust 语言分成 Safe Rust 和 Unsafe Rust 两部分。

其中 Unsafe Rust 专门和外部系统打交道,比如操作系统内核。之所以这样划分,是因为 Rust 编译器的检查和跟踪是有能力范围的,它不可能检查到外部其他语言接口的安全状态,所以只能靠开发者自己来保证安全。

Rust 的最终目标并不是完全消除那些危险点,因为在某种程度上,我们需要能够访问内存和其他资源。实际上,Rust 的目标是将所有的unsafe元素抽象出来。在考虑安全性时,你需要考虑“攻击面”,或者我们可以与程序的哪些部分进行交互。像解析器这样的东西是一个很大的攻击面,因为:

  • 它们通常可以被攻击者访问;

  • 攻击者提供的数据可以直接影响解析通常需要的复杂逻辑。

    你可以进一步分解,将传统的攻击面分解成“攻击面”(可以直接影响程序代码的部分)和“安全层”,这部分代码是攻击面依赖的代码,但是无法访问,而且可能存在潜在的 Bug。在 C 语言中,它们是一样的:C 语言中的数组根本不是抽象的,所以如果你读取了可变数量的项,就需要确保所有的不变量都保持不变,因为这是在不安全层中操作,那里可能会发生错误。

所以,Rust 提供了 unsafe 关键字和unsafe块,显式地将安全代码和访问外部接口的不安全代码进行了区分,也为开发者调试错误提供了方便。Safe Rust 表示开发者将信任编译器能够在编译时保证安全,而 Unsafe Rust 表示让编译器信任开发者有能力保证安全。

有人的地方就有 Bug。Rust 语言通过精致的设计,将机器可以检查控制的部分都交给编译器来执行,而将机器无法控制的部分交给开发者自己来执行。

Safe Rust 保证的是编译器在编译时最大化地保障内存安全,阻止未定义行为的发生。

Unsafe Rust 用来提醒开发者,此时开发的代码有可能引起未定义行为,请谨慎!人和编译器共享同一个“安全模型”,相互信任,彼此和谐,以此来最大化地消除人产生 Bug 的可能。

Unsafe Rust,是Rust的安全边界。世界的本质就是Unsafe的。你无法避免它。还有人说,因为Unsafe Rust的存在,所以也不见得能比C/C++安全到哪里去?Unsafe Rust确实和C/C++一样,要靠人来保证它的安全。但它对人的要求更高。

它也给了开发者一个Unsafe的边界,这其实也是一种安全边界。它把你代码里的雷区,显式地标记了出来。团队代码里review的话,可以更快地发现问题。这本身就是一种安全。而反观C++,你写出的每一行代码都是Unsafe的,因为它没有像Rust这样明显的界限(Unsafe 块)。

以下是我总结的五条使用Unsafe的简单规范,方便大家做权衡:

  1. 能用Safe Rust就用Safe Rust;
  2. 为了性能可以使用Unsafe Rust;
  3. 在使用Unsafe Rust的时候确保不要产生UB,并且尽量判断其安全边界,抽象为 Safe 方法;
  4. 如果无法抽象为Safe,需要标注为Unsafe,并配以产生UB的条件文档;
  5. 对于Unsafe的代码,大家可以重点review。

所以,Unsafe 使用不当也会引发内存安全或逻辑 Bug 。所以,学习 如何对 Unsafe Rust 进行安全抽象至关重要。

不过,Rust 社区生态中有一个 Rust 安全工作组,该组提供 cargo-audit等一系列工具,并且维护RustSecurity 安全数据库库中记录的Rust生态社区中发现的安全问题。可以方便地检查 Rust 项目中依赖库的安全问题。

生产力

编程语言生产力,大概可以通过以下三个方面来评估:

  1. 学习曲线。
  2. 语言工程能力。
  3. 领域生态。

学习曲线

学习曲线的高低,依个人水平不同而不同。以下罗列了不同基础学习 Rust 应该注意的地方。

  1. 完全零基础的开发者:掌握计算机基础体系知识结构,理解Rust语言和硬件/OS层的抽象,理解Rust语言核心概念、以及它的抽象模式,选择Rust语言的某个适用领域进行实操训练,通过实践来提升Rust语言的熟练度和理解深度,同时掌握领域知识。

  2. 有C语言基础:由于C语言开发者对高级语言的抽象不是很理解,所以着重了解掌握Rust所有权机制,包括所有权的语义,生命周期和借用检查。了解Rust语言的抽象模式,主要是类型和trait;以及Rust本身的的OOP和函数式语言特性。

  3. 有C++基础:C++开发者对于Rust语言的所有权有很好的理解能力,主要精力放在Rust的抽象模式和函数式语言特性上。

  4. 有Java/Python/Ruby基础:着重理解攻克Rust所有权机制、抽象模式、函数式编程语言特性。

  5. 有Go基础:Go语言开发者比较容易理解Rust的类型和trait抽象模式,但Go也是GC语言,所以所有权机制和函数式语言特性是他们的学习重点。

  6. 有Haskell基础:Haskell系的开发者对Rust语言函数式特性能很好的理解,主要攻克所有权机制和OOP语言特性。

所以,对于有一定基础的开发者来说,学习Rust语言要掌握的几个关键概念有:

1、Rust所有权机制,包括所有权的语义,生命周期和借用检查

​ 所有权机制是Rust语言最核心的特性,它保证了在没有垃圾回收机制下的内存安全,所以对于习惯了GC的开发者,理解Rust的所有权是最关键的一环,切记这三点:

  • Rust中的每一个值都有一个被称为其所有者 (owner)的变量。
  • 值有且只有一个所有者。
  • 当所有者(变量)离开作用域,这个值将被丢弃。 这其中又涉及到生命周期和借用检查等概念,是相对比较难啃的一块硬骨头。

2、Rust语言的抽象模式,主要是类型和trait。trait借鉴了Haskell中的Typeclass,它是对类型行为的抽象,可以通俗地类比为其他编程语言里的接口,它告诉编译器一个类型必须提供哪些功能语言特性。使用时要遵循一致性,不能定义相互冲突的实现。

3、OOP语言特性。熟悉面向对象编程(OOP)的常见的四个特性:对象、封装、继承和多态,可以更好地理解Rust的一些特性,比如impl、pub、trait等等。

4、函数式语言特性。Rust语言的设计深受函数式编程的影响,看到函数式特性,数学不好的人可能会望而却步,因为函数式编程语言的最大特点是把运算过程尽量写成一系列嵌套的函数调用,在Rust中,掌握闭包和迭代器是编写函数式语言风格的高性能Rust代码的重要一环。

语言工程能力

Rust 已经为开发工业级产品做足了准备。

**为了保证安全性,**Rust 引入了强大的类型系统和所有权系统,不仅保证内存安全,还保证了并发安全,同时还不会牺牲性能。

**为了保证支持硬实时系统,**Rust 从 C++那里借鉴了确定性析构、RAII 和智能指针,用于自动化地、确定性地管理内存,从而避免了 GC 的引入,因而就不会有“世界暂停”的问题了。这几项虽然借鉴自 C++,但是使用起来比 C++更加简洁。

**为了保证程序的健壮性,**Rust 重新审视了错误处理机制。日常开发中一般有三类非正常情况:失败、错误和异常。但是像 C 语言这种面向过程的语言,开发者只能通过返回值、goto 等语句进行错误处理,并且没有统一的错误处理机制。而 C++和 Java 这种高级语言虽然引入了异常处理机制,但没有专门提供能够有效区分正常逻辑和错误逻辑的语法,而只是统一全局进行处理,导致开发者只能将所有的非正常情况都当作异常去处理,这样不利于健壮系统的开发。并且异常处理还会带来比较大的性能开销。

Rust 语言针对这三类非正常情况分别提供了专门的处理方式,让开发者可以分情况去选择。

  • 对于失败的情况,可以使用断言工具。
  • 对于错误,Rust 提供了基于返回值的分层错误处理方式,比如 Option 可以用来处理可能存在空值的情况,而 Result 就专门用来处理可以被合理解决并需要传播的错误。
  • 对于异常,Rust 将其看作无法被合理解决的问题,提供了线程恐慌机制,在发生异常的时候,线程可以安全地退出。

通过这样精致的设计,开发者就可以从更细的粒度上对非正常情况进行合理处理,最终编写出更加健壮的系统。

为了提供灵活的架构能力,Rust 使用 特质(trait) 来作为零成本抽象的基础。特质 面向组合而非继承,让开发者可以灵活地架构 紧耦合 和 松耦合的系统。Rust 也提供了 泛型 来表达类型抽象,结合 trait 特性,让 Rust 拥有静态多态 和 代码复用 的能力。泛型和trait 让你可以灵活使用各种设计模式来对系统架构进行重塑。

为了提供强大的语言扩展能力和开发效率,Rust 引入了基于宏的元编程机制。Rust提供了两种宏,分别是声明宏和过程宏。声明宏的形式和C的宏替换类似,区别在于Rust会对宏展开后的代码进行检查,在安全方面更有优势。过程宏则让 Rust 在代码复用、代码生成拥有强大的能力。

**为了和现有的生态系统良好地集成,**Rust 支持非常方便且零成本的 FFI 机制,兼容 C-ABI,并且从语言架构层面上将 Rust 语言分成 Safe Rust 和 Unsafe Rust 两部分。其中 Unsafe Rust 专门和外部系统打交道,比如操作系统内核。之所以这样划分,是因为 Rust 编译器的检查和跟踪是有能力范围的,它不可能检查到外部其他语言接口的安全状态,所以只能靠开发者自己来保证安全。Unsafe Rust 提供了 unsafe 关键字和 unsafe 块,显式地将安全代码和访问外部接口的不安全代码进行了区分,也为开发者调试错误提供了方便。Safe Rust 表示开发者将信任编译器能够在编译时保证安全,而 Unsafe Rust 表示让编译器信任开发者有能力保证安全。

有人的地方就有 Bug。Rust 语言通过精致的设计,将机器可以检查控制的部分都交给编译器来执行,而将机器无法控制的部分交给开发者自己来执行。Safe Rust 保证的是编译器在编译时最大化地保障内存安全,阻止未定义行为的发生。Unsafe Rust 用来提醒开发者,此时开发的代码有可能引起未定义行为,请谨慎!人和编译器共享同一个“安全模型”,相互信任,彼此和谐,以此来最大化地消除人产生 Bug 的可能。

**为了让开发者更方便地相互协作,**Rust 提供了非常好用的包管理器Cargo。Rust 代码是以包(crate)为编译和分发单位的,Cargo 提供了很多命令,方便开发者创建、构建、分发、管理自己的包。Cargo 也提供插件机制,方便开发者编写自定义的插件,来满足更多的需求。比如官方提供的 rustfmt 和 clippy 工具,分别可以用于自动格式化代码和发现代码中的“坏味道”。再比如,rustfix 工具甚至可以帮助开发者根据编译器的建议自动修复出错的代码。Cargo 还天生拥抱开源社区和 Git,支持将写好的包一键发布到 crates.io 网站,供其他人使用。

**为了方便开发者学习 Rust,**Rust 官方团队做出了如下努力:

  • 独立出专门的社区工作组,编写官方 Rust Book,以及其他各种不同深度的文档,比如编译器文档、nomicon book 等。甚至组织免费的社区教学活动 Rust Bridge,大力鼓励社区博客写作,等等。
  • Rust 语言的文档支持 Markdown 格式,因此 Rust 标准库文档表现力丰富。生态系统内很多第三方包的文档的表现力也同样得以提升。
  • 提供了非常好用的在线 Playground 工具,供开发者学习、使用和分享代码。
  • Rust 语言很早就实现了自举,方便学习者通过阅读源码了解其内部机制,甚至参与贡献。
  • Rust 核心团队一直在不断改进 Rust,致力于提升 Rust 的友好度,极力降低初学者的心智负担,减缓学习曲线。比如引入 NLL 特性来改进借用检查系统,使得开发者可以编写更加符合直觉的代码。
  • 虽然从 Haskell 那里借鉴了很多类型系统相关的内容,但是 Rust 团队在设计和宣传语言特性的时候,会特意地去学术化,让 Rust 的概念更加亲民。
  • 在类型系统基础上提供了混合编程范式的支持,提供了强大而简洁的抽象表达能力,极大地提升了开发者的开发效率。
  • 提供更加严格且智能的编译器。基于类型系统,编译器可以严格地检查代码中隐藏的问题。Rust 官方团队还在不断优化编译器的诊断信息,使得开发者可以更加轻松地定位错误,并快速理解错误发生的原因。

为了方便 Rust 开发者提升开发效率,Rust 社区还提供了强大的 IDE 支持。VSCode/Vim/Emacs + Rust Analyzer 成为了 Rust 开发的标配。当然 JetBrains家族的 IDEA/ Clion 也对 Rust 支持十分强力。

Rust 与 开源

Rust 语言自身作为一个开源项目,也是现代开源软件中的一颗璀璨的明珠。

在 Rust 之前诞生的所有语言,都仅仅用于商用开发,但是 Rust 语言改变了这一状况。对于 Rust 语言来说,Rust 开源社区也是语言的一部分。同时,Rust 语言也是属于社区的。

Rust 团队由 Mozilla 和非 Mozilla 成员组成,至今 Rust 项目贡献者已经超过了 1900 人。Rust 团队分为核心组和其他领域工作组,针对 Rust 2018 的目标,Rust 团队被分为了嵌入式工作组、CLI 工作组、网络工作组以及 WebAssembly 工作组,另外还有生态系统工作组和社区工作组等。

这些领域中的设计都会先经过一个 RFC 流程,对于一些不需要经过 RFC 流程的更改,只需要给 Rust 项目库提交 Pull Request 即可。所有过程都是对社区透明的,并且贡献者都可参与评审,当然,最终决策权归核心组及相关领域工作组所有。后面为了精简 FCP流程,也引入了 MCP。

**Rust 团队维护三个发行分支:稳定版(Stable)、测试版(Beta)和开发版(Nightly)。**其中稳定版和测试版每 6 周发布一次。标记为不稳定(Unstable)和特性开关(Feature Gate)的语言特性或标准库特性只能在开发版中使用。

在 Rust 基金会成立以后,Rust 团队也在不断探索新的开源治理方案。

Rust 语言的不足

Rust 虽然有很多优势,但肯定也存在一些缺点。

  1. Rust 编译速度很慢。虽然 Rust 官方也一直在改进 Rust 编译速度,包括增量编译支持,引入新的编译后端( cranelift ),并行编译等措施,但还是慢。而且 增量编译目前也有 Bug。
  2. 学习曲线陡峭。
  3. IDE 支持不够完善。比如,对宏代码的支持不是很好。
  4. 缺乏针对 Rust 语言特有内存不安全问题的各种检测工具。
  5. 针对某些场景、架构和硬件生态支持不是很完善,这其实是需要投入人力和硬件成本来支持了,需要社区和生态的共同努力。

Rust 生态基础库和工具链

Rust 生态日趋丰富,很多基础库和框架都会以 包(crate) 的方式发布到 crates.io ,截止目前,crates.io 上面已经有 62981 个 crate,总下载量已经达到 7,654,973,261次。

按包的使用场景分类,Crates.io 最流行的几个场景依次如下:

  • 命令行工具 (3133 crates)
  • no-std 库 (2778 crates)
  • 开发工具(测试/ debug/linting/性能检测等, 2652 crates)
  • Web 编程 (1776 crates)
  • API 绑定 (方便 Rust 使用的特定 api 包装,比如 http api、ffi 相关api等,1738 crates)
  • 网络编程 (1615 crates)
  • 数据结构 (1572 crates)
  • 嵌入式开发 (1508 crates)
  • 加密技术(1498 crates)
  • 异步开发(1487 crates)
  • 算法 (1200 crates)
  • 科学计算(包括物理、生物、化学、地理、机器学习等,1100 crates)

除此之外,还有 WebAssembly 、编码、文本处理、并发、GUI、游戏引擎、可视化、模版引擎、解析器、操作系统绑定 等其他分类,也有不少库。

常用知名基础库和工具链

其中已经涌现出不少优秀的基础库,都可以在 crates.io 首页里看到。这里罗列出一些:


Rust 行业应用盘点

Rust 是一门通用的高级系统级编程语言,其应用领域基本可以同时覆盖 C/Cpp/Java/Go/Python 的应用领域。

具体而言,Rust 的应用领域目前覆盖到以下领域:

下面来盘点不同领域内国内外的 Rust 项目。通过提供代码量、团队规模、项目周期相关数据,希望可以让大家对 Rust 领域应用和开发效率能有一个比较直观的认识。

数据服务

数据服务领域,包括了数据库,数据仓储,数据流,大数据,分布式系统等。

TiKV(国内/开源/分布式数据库)

关键字:数据库/ 分布式系统/ CNCF

介绍

TiKV 是一个开源的分布式事务 Key-Value 数据库,专注为下一代数据库提供可靠、高质量、实用的存储架构。最初由 PingCAP 团队在 目前,TiKV 已经在知乎、一点资讯、Shopee、美团、京东云、转转等多行业头部企业得到上线应用。

TiKV 通过 Raft 一致性算法来实现数据多副本之间的一致性,本地采用了 RocksDB 存储引擎存储数据,同时 TiKV 支持数据自动切分和迁移。TiKV 的跨行事务最初参考 Google Percolator 事务模型,并进行了一些优化,提供快照隔离与带锁快照隔离,支持分布式事务。

2018 年 8 月被 CNCF 宣布接纳为沙箱云原生项目,在 2019 年 5 月从沙箱晋级至孵化项目。

代码与团队规模

TiKV 项目 包含 Rust 代码行数大约 30 万行(包含测试代码)。

TiKV 是全球性开源项目,可以从贡献者名单来查看团队规模。TiKV 组织中也包含了一些 Go/Cpp 项目,这个并不算在内,只统计参与 Rust 项目的人力规模。

  • 主力开发:20人左右。
  • 社区贡献: 300 多人。

项目周期

TiKV 是作为 TiDB 的底层存储跟随 TiDB 演进。TiDB 为 Go 开发,TiKV 为 Rust 开发。

2016 年 1 月作为 TiDB 的底层存储引擎设计并开发 。

2016 年 4 月开源 发布第一版。

2017 年 10 月 16 日,TiDB 发布 GA 版(TiDB 1.0), TiKV 发布 1.0 。

2018 年 4 月 27 日,TiDB 发布 2.0 GA 版, TiKV 发布 2.0 。

2019 年 6 月 28 日,TiDB 发布 3.0 GA 版本, TiKV 发布 3.0 。

2020 年 5 月 28 日,TiDB 发布 4.0 GA 版本, TiKV 发布 4.0。

2021 年 4 月 07 日,TiDB 发布 5.0 GA 版本, TiKV 发布 5.0。

小评

有些朋友可能比较关注 Rust 开发效率如何,并且想对其量化,尤其是想对比 C/ Cpp / Go 等其他语言的开发效率。

私以为量化开发效率是非常困难的,尤其是和其他语言比较的量化。我们不妨换个角度来看这件事,比如,从敏捷项目迭代管理来看这件事。如果一门语言,可以满足日常的敏捷开发迭代需求,可以帮助完成产品的进化,那足以说明这门语言的开发效率了。

据了解,PingCAP 中 Go 开发人员是 Rust 开发人员的四五倍之多,当然工作量也差不多是这样的比例。从上面的数据,我们可以看得出来, Rust 项目(TiKV)依然可以稳步跟得上 Go 项目(TiDB)的迭代节奏,说明 Rust 的开发效率还是足以胜任现代化开发需求。

TensorBase (国内/开源/实时数据仓库)

关键字:实时数据仓库/ 创业/ 天使轮

介绍

TensorBase 是金明剑博士于 2020 年 8月启动的创业项目,从一个现代的全新视角出发,用开源的文化和方式,重新构建一个Rust下的实时数据仓库,服务于这个海量数据时代的数据存储和分析。TensorBase 项目目前已获得世界知名创业投资加速机构的天使轮投资。

代码与团队规模

因为 TensorBase 是构建于 Apache ArrowArrow DataFusion 之上,所以代码统计排除掉这两个项目的依赖。

TensorBase 核心代码行数 54000 多行。

团队规模:

  • 主力开发: 1人。
  • 社区贡献:13 人。

因为是新项目,开源社区还在建设中。

项目周期

TensorBase 以时间为节奏发版,而非语义版。 迭代周期预计 一年一个大版本,一月一个小版本。

从 2021年 4 月 20 正式发版,到最近 6月 16,保持这个节奏。

Timely Dataflow (国外/开源/实时数据流)

关键字:Dataflow/ 分布式系统/创业

介绍

Timely Dataflow基于微软 这篇 Timely Dataflow 论文: 《Naiad: A Timely Dataflow System》的 现代化 Rust 实现。是 clockworks.io 公司的开源产品。

在分布式系统中对流式数据进行复杂的处理,比如多次迭代或者递增计算是非常困难的。Storm, Streaming Spark, MillWheel 都不能很好的适应各种应用复杂的需求。Naiad 通过引入 timestamp 的概念,给出了一个非常 low-level 的模型,可以用来描述任意复杂的流式计算。

dataflow系统包罗万象,MapReduce,Spark都可以算是其中代表。Timely dataflow 给出了一个完全基于时间的抽象,统一了流式计算和迭代计算。当你需要对流式数据并行处理且需要迭代控制的时候,可以使用 Timely Dataflow 。

代码与团队规模

Rust 代码量 大约 13000 行。

团队规模:

  • 主力开发:4人。
  • 社区贡献:30多人。

项目周期

2017年9月7号,0.3.0 版本。

2018年6月28号,0.6.0 版本。

2018年9月16号,0.7.0 版本。

2018年12月3号,0.8.0 版本。

2019年3月31号,0.9.0 版本。

2019年7月10号,0.10.0 版本。

2021年3月10号,0.12.0版本。

基本上 三个月 出 一个 小版本,除了 Timely Dataflow 之外该团队同时还维护一个构建于 Timely Dataflow 之上的 Differential Dataflow ,和 Timely Dataflow 同步迭代。

Noria (国外/学术研究/开源/数据库)

关键字:数据库/ 学术论文项目

介绍

Noria 是一种新的流式数据流系统,旨在作为基于MIT Jon Gjengset博士学位论文的重型Web应用程序的快速存储后端,也参考了OSDI'18的论文。 它类似于数据库,但支持预计算和缓存关系查询结果,以便加速查询。 Noria 自动将缓存的结果保持为底层数据,存储在持久性基础表中。 Noria使用部分状态数据流来减少内存开销,并支持动态,运行时数据流和查询更改。

代码与团队规模

Rust 代码行数大约 59000 多行。

团队规模:

  • 主力贡献者:2人
  • 社区贡献者:21人

项目周期

因为是个人学术研究项目,所以发布周期没有那么明显。

项目周期 2016年7月30 ~ 2020年 4月30,一共5000多commit。

Vector (国外/开源/数据管道)

关键字:数据管道/分布式系统/创业

Vector 是 Timer 公司构建的一款高性能的、端到端的(代理和聚合器)可观察性数据管道。它是开源的,比该领域(Logstash、Fluentd之类)的所有替代方案快10倍。目前像 豆瓣、checkbox.ai、fundamentei、BlockFi、Fly.io 等公司使用了 Vector 。 点击此处查看官方性能报告,点击此处查看目前生产环境中使用 Vector 的公司。

代码与团队规模

代码量大约 18 万行 Rust 代码。

团队规模:

  • 主力开发:9人
  • 社区贡献:140 人

项目周期

2019年3月22,初始版本发布。

2019年6月10,0.2.0版本发布

2019年7月2,0.3.0版本发布

2019年9月25,0.4.0版本发布

2019年10月11,0.5.0版本发布

2019年12月13,0.6.0版本发布

2020年1月12,0.7.0版本发布

2020年2月26,0.8.0版本发布

2020年4月21,0.9.0版本发布

2020年7月23,0.10.0版本发布

2021年3月12,0.11.0 ~ 0.12 版本发布

2021年4月22,0.13.0版本发布

2021年6月3,0.14.0版本发布

Arrow-rs (国外/开源/大数据标准)

关键字: 大数据/数据格式标准/Apach

arrow-rs 是 Apache Arrow 的 Rust 实现。Apache Arrow 是 一种适合异构大数据系统的内存列存数据格式标准。它有一个非常大的愿景:提供内存数据分析 (in-memory analytics) 的开发平台,让数据在异构大数据系统间移动、处理地更快。

Arrow 从 2.0 版本开始引入 Rust ,从 4.0 开始 Rust 实现迁移到了独立仓库 arrow-rs 。

Arrow的Rust实现实际上由几个不同的项目组成,包括以下几个独立 crate 和 库 :

  • arrow,arrow-rs 核心库,包含在 arrow-rs 中。
  • arrow-flight ,arrow-rs 组件之一,包含在 arrow-rs 中。
  • parquet,arrow-rs 组件之一,包含在 arrow-rs 中。在大数据生态内,Parquet 是最为流行的文件存储格式。
  • DataFusion,一个可扩展的内存查询执行引擎,使用Arrow作为其格式。
  • Ballista,一个分布式计算平台,由Apache Arrow和DataFusion驱动,包含在 DataFusion 中。

代码与团队规模

arrow-rs 各相关组件加起来,Rust 代码量 大约 18 万行。

团队规模:

  • 主力开发:大约 10 人

  • 社区贡献: 550 多人

项目周期

项目 DataFusion 在 2016 年就开始构建了,后来进入了 Apache Arrow 项目。

以 arrow-rs 4.0 开始算:

2021 年 4 月 18 ,版本 4.0 发布。

2021 年 5 月 18,版本 4.1 发布。

2021 年 5 月 30, 版本 4.2 发布。

2021 年 6 月11, 版本 4.3 发布。

InfluxDB IOx (国外/ 开源/时序数据库)

关键字:时序数据库/分布式

InfluxDB IOx,是 InfluxDB 的下一代时序引擎,使用 Rust + Aarow 来重写。

现有设计主要有以下几个致命性问题:

  1. 无法解决时间线膨胀的问题
  2. 在云原生环境下,对内存管理要求比较严格,这意味 mmap 不在适用,而且 InfluxDB 需要支持无本地盘的运行模式
  3. 由于索引与数据分开存储,导致高效的数据导入导出功能难以实现

上述这三个问题都是现有设计的核心之处,因此要想支持现阶段需求,重写是个比较好的选择。

代码与团队规模

InfluxDB IOx 代码量大约是 16万行 Rust 代码。

团队规模:

  • 主力开发: 5人
  • 社区贡献: 24 人

项目周期

该项目从 2019年 11月开始立项,但截至到今天此项目还非常早期,它还没有准备好进行测试,也没有任何构建或文档。

但是从 GitHub 活动状态来看,开发状态还非常积极。主要的开发工作都是在2021年开始的。

CeresDB (国内/商业/时序数据库)

关键字:时序数据库

介绍

CeresDB 是蚂蚁集团研发的一款TP/AP 融合时序数据库,满足金融时序、监控、IOT 等场景下的海量时序数据的存储、多维查询下钻和实时分析需求。有开源计划,但目前暂未开源。

团队规模

目前数据库开发大约 8-10个人。

其他信息还未可知。

tantivy (国外/开源/全文检索)

关键字:全文检索/ lucene

tantivy 是一个由 Apache Lucene 启发的全文搜索引擎库,用 Rust 实现。

tantivy 性能卓越,这里有一个基于 Rust + Tantivy + AWS 构建的应用 :提供十亿网页搜索并生成常见单词云

代码及团队规模

代码量大约为 50000 行 Rust 代码。

团队规模:

  • 主力开发:1 人
  • 社区贡献:85人

项目周期

项目自 2016 年立项,迭代周期为平均一月一个小版本发布,目前发布到 0.15.2 版本。

Rucene (国内/开源/搜索引擎)

关键字:知乎/ lucene

介绍

Rucene 是知乎团队开源的一款基于 Rust 实现的搜索引擎。Rucene不是完整的应用程序,而是可以轻松用于将完整文本搜索功能添加到应用程序的代码库和API。它是对 Apache Lucene 6.2.1 项目的 Rust 移植。

代码及团队规模

代码量大约为 10 万 行 Rust 代码。

团队规模:

  • 主力开发: 4人
  • 社区贡献: 0 人

项目周期

可能因为是公司内部项目开源化,目前没有迭代出具体语义版本。在知乎内是用于生产环境的。

其他一些项目

  • libp2p-rs,是一个模块化和可拓展的网络堆栈,专注于传输协议无关,模块化和可移植编码,最终实现无论设备所处的环境、运行的协议如何,libp2p 都能让设备的互联成为现实。由国内Netwarps 团队开发,该团队是一个致力于实现去中心化网络和存储技术的创业团队,目标在于使用去中心化技术支撑各类 IOT 应用,以及保障数据安全。基于此目标,Netwarps 开发了去中心化文件存储系统(DFS)和去中心化计算平台(DCP)。

云原生

云原生领域包括:机密计算、Serverless、分布式计算平台、容器、WebAssembly、运维工具等

StratoVirt (国内/开源/容器)

关键字:容器/ 虚拟化/ Serverless

StratoVirt华为 OpenEuler 团队研发的 基于Rust的下一代虚拟化平台。

Strato,取自stratosphere,意指地球大气层中的平流层,大气层可以保护地球不受外界环境侵害,而平流层则是大气层中最稳定的一层;类似的,虚拟化技术是操作系统平台之上的隔离层,既能保护操作系统平台不受上层恶意应用的破坏,又能为正常应用提供稳定可靠的运行环境;以Strato入名,寓意为保护openEuler平台上业务平稳运行的轻薄保护层。同时,Strato也承载了项目的愿景与未来: 轻量、灵活、 安全和完整的保护能力。

StratoVirt是计算产业中面向云数据中心的企业级虚拟化平台,实现了一套架构统一支持虚拟机、容器、Serverless三种场景,在轻量低噪、软硬协同、安全等方面具备关键技术竞争优势。StratoVirt在架构设计和接口上预留了组件化拼装的能力和接口,StratoVirt可以按需灵活组装高级特性直至演化到支持标准虚拟化,在特性需求、应用场景和轻快灵巧之间找到最佳的平衡点。

代码与团队规模

代码量大约是 27000 行 Rust 代码。

团队规模:

  • 主力开发:4 人。
  • 社区贡献:15人。

项目周期

2020-09-23,发布 0.1.0 版本。

2021-03-25,发布 0.2.0 版本。

2021-05-28 ,发布 0.3.0 版本。

Firecracker (国外/产品)

关键字: 容器/ Serverless/ FaaS

Firecracker 由 AWS 发布并将firecracker开源, 它的定位是面向Serverless计算业务场景。 Firecracker本质上是基于KVM的轻量级的microVM, 可以同时支持多租户容器和FaaS场景。 Security和Fast是firecracker的首要设计目标。 它的设计理念可以概括为:

  • 基于KVM
  • 精简的设备集(极简主义)
  • 基于Rust语言(Builtin Safety)
  • 定制的guest kernel(快速启动)
  • 优化内存开销(使用musl c)

Firecracker使用了极为精简的设备模型(仅有几个关键的模拟设备),目的是减少攻击面已提升安全性。 同时这irecracker使用了一个精简的内核(基于Apline Linux),这使得Firecracker可以做在125ms内拉起一个虚拟机。 Firecracker使用musl libc而不是gnu libc,能够将虚拟机的最低内存开销小到5MB。

代码及团队规模

代码量大约为 75000 多行。

团队规模:

  • 主力开发:7人
  • 社区贡献:140 人

项目周期

从 2018年 3月5号 发布 0.1.0 开始,基本上是每个月发一个小版本。

截止到上个月,刚发布了 0.24.0 版本。

Krustlet (国外/产品)

关键字: Kubernetes/ WebAssembly/ 容器

介绍

微软 Deis Labs发布了Krustlet,它是使用 Rust 实现的一种 Kubernetes kubelet。它监听 Kubernetes API,以获取新的 Pod 请求(在集群中运行基于 WASI 的应用程序),只要请求事件与节点选择器是匹配的。因此,要在 Krustlet 节点上运行应用程序,用户可以使用 taints、tolerations 和节点选择器。此外,用户必须为应用程序生成 WebAssembly 二进制文件。如果应用程序是用 C 语言开发的,就用clang,如果应用程序是用 Rust 开发的,就用cargo。然后,用户必须使用wasm-to-oci打包,并将容器镜像推送到容器注册中心。要部署应用程序,用户需要定义一个 Kubernetes 清单,其中包含 tolerations。

目前该项目还未到 1.0,有很多实验性特性,但是它的存在证明了 WebAssembly 在容器方面的应用方向。但现在 微软加入了 字节码联盟 ,该项目也会连同字节码联盟的其他成员,一起发展 WebAssembly,特别是即将到来的WASI规范工作和模块链接。

代码及团队规模

代码量大约为 21000 多行。

团队规模:

  • 主力开发: 7人
  • 社区贡献: 32人

项目周期

自从 2020 年 4月7日,0.1.0 发布以来,大约每一两个月发布一次新版本,目前发布到 0.7.0 版本。

团队有计划在未来几个月内达到 1.0 版本。

linkerd2-proxy (国外/产品)

关键字: 服务网格/ k8s

介绍

Linkerd 算是 服务网格的鼻祖,但是由于 Linkerd-proxy 运行需要 Java 虚拟机的支持,在启动时间、预热、内存消耗等方面,相比起晚它半年发布的挑战者 Envoy 均处于全面劣势。后来才重写了 Linkerd2 。

Linkerd2 (曾命名为 Conduit) 是 Buoyant 公司推出的下一代轻量级服务网格框架。与 linkerd 不同的是,它专用于 Kubernetes 集群中,并且比 linkerd 更轻量级(基于 Rust 和 Go,没有了 JVM 等大内存的开销),可以以 sidecar 的方式把代理服务跟实际服务的 Pod 运行在一起(这点跟 Istio 类似)。

linkerd2-proxy 是 Linkerd2 中的底层代理。代理可以说是服务网格中最关键的组件。它可以随应用程序的部署而扩展,因此低附加延迟和低资源消耗至关重要。它也是处理应用程序所有敏感数据的地方,因此安全性至关重要。如果代理速度慢、臃肿或不安全,那么服务网格也是如此。使用 Rust 重写后,Linkerd2-proxy的性能与资源消耗方面都已不输 Envoy。

Rust是Linkerd2-proxy的唯一选择。它提供了闪电般的性能、可预见的低延迟和我们知道服务网格代理需要的安全属性。它还提供了现代语言特性,如模式匹配和富有表现力的静态类型系统,以及工具,如内置的测试框架和包管理器,使在其中编程变得非常愉快。

Linkerd2-proxy 构建在 Rust 异步生态之上,使用了Tokio, Hyper, and Tower 等框架和库。

代码及团队规模

代码量大约为 43000 多行。

团队规模:

  • 主力开发:3 人。
  • 社区贡献:37 人。

项目周期

目前该项目已经是 V2.148.0 版本。发布周期大约是每周一个小版本。

Lucet (国外/产品)

关键字: Faas/ Serverless/ WebAssembly / Compiler

Lucet 是一个本机 Webassembly 编译器和运行时。 它旨在安全地在您的应用程序内执行不受信任的 WebasseMbly 程序。由 Fastly 公司开发,属于 字节码联盟 的子项目。Fastly 于 2020 年从 Mozilla 招募了 WebAssembly Server side 团队,目前 lucet 团队已经和 wasmtime 团队合并。

Fastly 庞大的 CDN 业务催生了它们进军边缘计算的想法,并逐渐成为其中最具竞争力和投入度最高的头部公司。

关于边缘计算,另外一家头部公司是Cloudflare(NET.US)。从技术角度看,Fastly和Cloudflare在其无服务器边缘计算解决方案中采用了两种不同的方法。

Cloudflare选择在Chromium V8引擎上构建其解决方案。这使得他们可以利用Google(GOOG.US) Chrome团队已经完成的工作,在2018年便将边缘计算产品快速推向市场。

这是对当时由云供应商(如Amazon(AMZN.US) Lambda)提供的无服务器解决方案的重大改进。Cloudflare的工作者将冷启动时间减少了100倍,进入到毫秒阶段。并将内存使用量减少了10倍,从而可以更有效地利用硬件资源。

但Fastly决定不依赖现有技术来进行无服务器计算,例如可重复使用的容器或V8引擎,而是决定全心投入WebAssembly,并构建了自己的Lucet编译器和运行时,对性能、安全性和紧凑性进行了优化。

自2017年以来,Fastly一直在幕后进行这项工作,它为Compute@Edge产品线提供了坚实的基础,该平台现在为多个客户运行生产代码。

Lucet将WebAssembly编译为快速、有效的二进制文件以便执行,还可以通过内存分配来增强安全性,并且不会从先前的请求中产生任何残留。Lucet还包括一个经过严格优化的简化运行时环境,Fastly团队在该环境上花费了大部分开发时间。结果获得了比V8引擎更好的性能。

Fastly冷启动时间完全进入微秒级别--官方宣称为35微秒。这至少比V8引擎快100倍,后者需要3-5毫秒才能启动(3,000到5,000微秒)。

同样,由于Lucet仅包含运行已编译的汇编代码所需的代码模块,因此它只需要几千字节的内存。这大约是V8引擎使用的3MB的千分之一。

代码与团队规模

lucet 代码量为 29000 多行, wasmtime 总代码量为 27万多行。

团队规模:

  • 主力开发 : 16 人。
  • 社区贡献:200 多人 (wasmtime 贡献者居多)

项目周期

lucet 目前进入维护期,wasmtime在高速重构。

评价迭代周期为 每个月发一个小版本。

wasmcloud (国外/开源/产品)

关键字: WebAssembly/ 分布式计算

介绍

WasmCloud 运行时可以用于云、浏览器和嵌入式等场景。wasmcloud 是一个基于 WebAssembly 的分布式计算平台。比较有创新的地方在于,它制定了一个 waPC 标准,用于 Guest 和 Host 的安全过程调用,来解决当前 WASI 等特性不完善的问题。

代码及团队规模

代码量大约为 11000 多行 Rust 代码。

团队规模:

  • 主力开发:2 人。
  • 社区贡献:11 人。

项目周期

该项目从 2021 年 2月17 启动,迭代周期大约是每两周一个小版本。

Habitat (国外/开源/运维工具)

关键字:Chef/ DevOps/ 运维工具

介绍

Habitat 让应用团队能够在任何环境中构建、部署、管理任何应用 ,不管是传统的数据中心,还是容器化的微服务。

  • “Lift & Shift” 遗留应用到现代平台 。把已有的、对商业至关重要的应用迁移到现代平台上对许多机构来说 是一个痛点。
  • 通过云原生(云、容器)的策略来交付 应用 。许多机构在移居和部署云原生平台的 过程中备受阻碍。

特点:

  1. Habitat 把管理界面和应用构建在一起,使得自 动化更加简便。

  2. Habitat Operator:让你的所有应用仅需一个 Kubernetes Operator,不需 要每个应用都有特制的 Operator。

  3. 不管你的应用在不在 Kubernetes 上,Habitat 的 Open Service Broker 都能通过 Kubernetes 的原生界面让它们共存。

代码及团队规模

代码量大约为 74000 行 Rust 代码。

团队规模:

  • 主力开发:5 人。
  • 社区贡献:140人

项目周期

迭代周期为每周一个小版本,目前版本为 1.6.342。

其他一些项目

  • DatenLord 是用 Rust 实现的新一代开源分布式存储,面向云原生场景提供高性能存储解决方案。为了提高 IO 性能,DatenLord 采用绕过内核 (bypass Kernel) 的方式,主要在用户态实现 IO 功能,避免内核执行 IO 任务带来的额外开销,从而实现高性能分布式存储。Datenlord 是国内创业团队,点此查看更多信息。
  • Datafuse,是一种实时数据处理和分析DBMS,基于 Apache Arrow-rs开发,受 ClickHouse 启发。该项目属于 Datafuse labs,是由中国开发者领头的项目,但不清楚公司是在国内还是国外。

操作系统

操作系统领域包括使用 Rust 实现的各种操作系统。

Rust for Linux (国外/ Rust 进入 Linux 支持项目 )

关键字: Linux

介绍

Rust for Linux 项目旨在推动 Rust 成为 Linux 内核第二编程语言。

Linux内核是现代互联网的核心,从服务器到客户端设备。它处于处理网络数据和其他形式的输入的第一线。因此,Linux内核中的漏洞会产生广泛的影响,使人们、组织和设备的安全和隐私面临风险。由于它主要是用C语言编写的,而C语言不是内存安全的,所以内存安全漏洞,如缓冲区溢出和使用后赋值,是一个持续的问题。通过使Linux内核的部分内容用Rust语言编写成为可能,这是内存安全的,我们可以完全消除某些组件的内存安全漏洞,比如驱动程序。

当前进展:Google 赞助 且 由 ISRG 组织雇佣 Miguel Ojeda (核心开发),让他全职从事Rust for Linux和其他安全工作,为期一年。希望能通过让他全职从事这项工作,为支持数字基础设施尽一份力。

团队规模

核心开发: 1 ~ 6人。

暂未有其他信息。

Coreutils

关键字: GNU/ Shell/ Rust for Linux

介绍

Coreutils 是GNU Shell 的核心实用程序的 Rust 实现。

代码及团队规模

代码量大约为 77000 行 Rust 代码。

团队规模:

  • 主力开发:8人
  • 社区贡献:250人

项目周期

项目于2020年底启动,迭代周期平均一月一个小版本,目前 0.0.6 版本。目前状态,足以通过 GNOME 启动 Debian 系统。

Occulum (国内/开源/TEE 库操作系统)

关键字: 机密计算/ 可信计算/ TEE / 库操作系统

Occulum 是蚂蚁开源的 TEE 操作系统,也是 CCC 机密计算联盟中第一个由中国公司发起的开源项目。

Occlum 提供 POSIX 编程接口,支持多种主流语言(C/C++, Java, Python, Go, Rust 等),支持多种安全文件系统。可以说,Occlum 提供了一个兼容 Linux 的 Enclave 运行环境,使得机密计算可以轻松支持已有的应用,也使得机密应用开发者复用原有开发技能。Occlum 不但在工业界场景中得到了广泛的应用,而且也在系统顶会 ASPLOS 2020 发表了学术论文,代表了机密计算业界的领先水平。

从架构上来看,Occlum 不但提供基本的类似 Linux 的操作系统能力,而且提供一个类似 Docker 的用户使用接口,比如这里的 Occlum build 和 Occlum run 等都跟 docker 的命令类似。

代码与团队规模:

Occulum 代码量大约 28000 多行。

团队规模:

  • 主力开发: 5人。
  • 社区贡献:22 人。

项目周期

迭代周期是每六周发一个新版。

rCore 和 zCore (国内/ 教育/ 学术/ 开源/ 操作系统)

关键字: 清华大学/ rCore/ zCore/ 操作系统/ 教学

介绍

rCore 是用 Rust 重新实现的 Linux 内核,诞生于 2018 年,目前已在清华计算机系的操作系统教学实验中试点应用。

zCore 是用 Rust 语言重新实现的 Zircon (Google Fuchsia OS 的微内核)微内核。它运行在内核态,对外提供与 Zircon 完全相同的系统调用,因此能够运行原生的 Fuchsia 用户程序。不仅如此,它还可以作为一个普通的用户进程运行在 Linux 或 macOS 的用户态,我们一般把这种模式称为 LibOS 或 User-Mode OS。你甚至无需安装 QEMU 模拟器,只需装上 Rust 官方工具链,就可以编译运行体验 zCore!

一些相关学习资源:

代码及团队规模

rCore 代码量大约为 26000 行 Rust 代码,zCore 代码量大约为 27000 行 Rust 代码。

团队规模:

  • 主力开发:3~5人
  • 社区贡献:30 人左右

项目周期

两个项目都已进入维护期,没有对外发布版本。

Redox (国外/ 开源/ 操作系统)

关键字:操作系统

介绍

Redox是一个用Rust语言编写的类UNIX操作系统 , 它的目标是把Rust语言的创新带入到一个现代的微内核和全系列的应用程序。Redox 幕后公司应该是 System 76 公司。主要项目放在 GitLab 中。

代码及团队开发

代码量目前大约为 134 万行 Rust 代码,算得上是 Rust 生态中重量级项目了。

团队规模:

  • 主力开发: 21人
  • 社区贡献:79 人。

项目周期

Redox 从 2016 年开始立项,直到 2017 年 0.3 版本开始,每年发布一个小版本,到今年已经发布到 0.5 版本。

tockOS (国外/开源/嵌入式实时操作系统)

关键字:嵌入式操作系统/实时

介绍

Tock 是一个嵌入式操作系统,设计用于在基于Cortex-M和RISC-V的嵌入式平台上运行多个并发的、互不信任的应用程序。Tock的设计以保护为中心,既可以防止潜在的恶意应用程序,也可以防止设备驱动程序。Tock使用两种机制来保护操作系统的不同组件。首先,内核和设备驱动程序是用Rust编写的,Rust是一种提供compile-time内存安全、类型安全和严格别名的系统编程语言。Tock使用Rust来保护内核(例如调度程序和硬件抽象层)不受特定于平台的设备驱动程序的影响,并将设备驱动程序彼此隔离。其次,Tock使用内存保护单元将应用程序彼此和内核隔离开来。

Google发布的这个 OpenSK 是跑在 Tock上面的!OpenSK 是用Rust编写的安全密钥的开源实现,该密钥同时支持FIDO U2F和FIDO2标准。

代码及团队规模

代码量大约为 15万 行 Rust 代码。

团队规模:

  • 主力开发:4 人。
  • 社区贡献:123 人。

项目周期

该项目目前已进入维护期。

目前 1.6 版本发布,以往迭代周期大概是每半年发布一个小版本。

Theseus (国外/开源/高端嵌入式操作系统/研究项目)

关键字: 嵌入式操作系统/研究型

介绍

Theseus 是美国莱斯大学多年实验的结果,也有其他大学参与,比如耶鲁大学。它通过减少一个组件对另一个组件持有的状态来重新设计和改进操作系统的模块化,并利用一种安全的编程语言 Rust,将尽可能多的操作系统责任转移给编译器。

Theseus 体现了两个主要贡献。

  • 一个操作系统结构。其中许多微小的组件具有明确定义的、运行时持久的界限,它们之间的互动不需要彼此持有状态。
  • 一种使用语言级机制实现操作系统本身的内部语言方法。这样编译器就可以执行关于操作系统语义的不变性。

Theseus 的结构、内部语言设计和状态管理以超越现有作品的方式实现了核心操作系统组件的实时演进和故障恢复。

更多资料:《Theseus: an Experiment in Operating System Structure and State Management》

代码及团队规模

代码量大约为 56000 行代码。

团队规模:

  • 主力开发:1 人。
  • 社区贡献:17人。

项目周期

该项目自 2017 年 3月启动,目前已经进入维护期。

工具软件

工具软件包括使用 Rust 实现的一些 命令行工具、桌面软件等。

RustDesk (国内/部分开源/远程桌面软件)

rustdesk,是一款远程桌面软件,开箱即用,无需任何配置,替代TeamViewer和AnyDesk。您完全掌控数据,不用担心安全问题。rustdesk 是一款商业开源软件,开源度 90%。

代码及团队规模

代码量大约为 35000 行 Rust 代码。

团队规模:

  • 主力开发: 1人。
  • 社区贡献: 8 人。

项目周期

2021 年 3 月 27 发布 1.1 版本,之前的版本迭代无从知晓。

自此之后基本每月迭代一两个小版本。

spotify-tui (国外/终端音乐软件)

关键字:Terminal UI/ Spotify

介绍

spotify-tui 是一款终端 Spotify 音乐客户端,基于 Rust 终端 UI 开发框架 Tui-rs 开发。

代码及团队规模

代码量大约为 12000 行 Rust 代码。

团队规模:

  • 主力开发:1人。
  • 社区贡献:84 人。

开发周期

已经进入维护期,平均每月一个小版本。

Ripgrep (国外/终端文本搜索)

关键字:文本处理/ 终端工具

介绍

ripgrep 是一个以行为单位的搜索工具, 它根据提供的 pattern 递归地在指定的目录里搜索。它是由 Rust 语言写成,相较与同类工具,它的特点就是无与伦比地快。ripgrep 目前已经成为 Linux 下最快的文本搜索工具。

代码及团队规模

代码量大约 35000 行 Rust 代码。

团队规模:

  • 主力开发:1 人。
  • 社区贡献:287 人。

项目周期

项目于 2016 年开始启动,在 2018年之前迭代比较频繁,后面进入稳定维护期,基本一年一个大版本,目前版本为 13.0.0 。

nushell (国外/开源/shell工具)

关键字:shell

介绍

NuShell 是使用 Rust 语言编写的跨 Unix, Windows, macOS 系统的现代 Shell 程序。

NuShell 不像传统的 Unix Shell,它从 PowerShell 汲取灵感,将每个命令产生的结果视作具有结构的对象,而不是传统的原始字节。但和 PowerShell 相比,它的速度要快得多。

NuShell 的特色在于结构化数据以及类似 SQL 的二维表操作, 在处理大量结构化数据时具有一定优势,几乎相当于一个针对本地文件、数据的 SQL 解析器。不过其缺少流程控制语句的特点,让它难以胜任逻辑化的系统管理工作。

代码及团队规模

代码量大约为 10万行 Rust 代码。

团队规模:

  • 主力开发: 2人。
  • 社区贡献:231 人。

项目周期

该项目于 2019年 5月启动,迭代周期为每个月一个小版本,目前为 0.32.0 版本。

alacritty (国外/开源/模拟终端)

关键字:模拟终端/OpenGL

介绍

**Alacritty **是一个免费的开源,快速,跨平台的终端仿真器,它使用GPU图形处理单元 )进行渲染,实现了Linux中许多其他终端仿真器所不具备的某些优化。

Alacritty专注于简单性能两个目标。 性能目标意味着,它应该比任何其他可用的终端仿真器快。 简单性目标意味着,它不支持Linux中的选项卡分割 (可以由其他终端多路复用器 - tmux轻松提供)等功能。

性能上已经秒杀Linux 上同类工具了。

代码及团队规模

代码量大约为 22000 行 Rust 代码。

团队规模:

  • 主力开发: 2人
  • 社区贡献:330人。

项目周期

项目启动于 2016 年,目前迭代周期平均每三个月发布一个新的小版本。目前版本号 0.8.0。虽然还未稳定1.0版本,但已经成为很多人日常开发工具了。

Gitui (国外/开源/Git终端UI)

关键字:Git/ Terminal UI

介绍

Gitui 是一个速度很快的 Git 终端界面。

代码及团队规模

代码量大约为 29000 行 Rust 代码。

团队规模:

  • 主力开发:1 人。
  • 社区贡献:42 人。

项目周期

项目于 2020 年3 月 15日启动,迭代周期平均为每两周一个小版本发布。目前 0.16.1 版本。

其他优秀的终端工具

  • exa,Rust 重写 ls 工具。
  • bottom,Rust 重写 Top 工具。
  • starship 超级快、支持各种订制的极简命令行提示符,支持任意 shell
  • bat 支持更多特性的 cat 克隆
  • delta git、diff 输出查看器
  • zoxide 更快捷地浏览文件系统
  • fd简单、快速、用户友好的 find 替代品
  • tealdeer 众人一起编写的终端命令速查卡

机器学习

机器学习领域包括,基于 Rust 实现的机器学习框架、科学计算库等等。

linfa (国外/开源/机器学习工具包)

关键字:scikit-learn/ sklearn/ 基础工具包

介绍

Linfa 是一个 Rust 实现的 类似于 python scikit-learn 的库,旨在提供一个全面的工具包,可以使用 Rust 构建机器学习应用程序。该团队还创建了 Rust-ML 组织。

scikit-learn,又写作sklearn,是一个开源的基于python语言的机器学习工具包。 它通过NumPy, SciPy和Matplotlib等python数值计算的库实现高效的算法应用,并且涵盖了几乎所有主流机器学习算法。

更多资料:Rust 机器学习之书

代码及团队规模

代码量大约为 23000 行 Rust 代码。

团队规模:

  • 主力开发:6人
  • 社区贡献:12人

项目周期

该项目 2018年就立项了,但是正式动工是 2020年10月,2021年进入迭代期。基本每月一个小版本,最近的版本在4月份发布的,0.4.0版本。项目开发状态还是比较活跃的。

tokenizers (国外/开源/自然语言处理分词库)

关键字:自然语言处理/ 分词库

介绍

tokenizers 是 Hugging Face 公司开源的一款 Rust 实现的分词库。

Hugging Face 是一家总部位于美国纽约的聊天机器人初创服务商。该公司在 NLP界鼎鼎大名,三月份刚刚完成4000万美元B轮融资。在GitHub上发布了开源 NLP 库 Transformers。

基于深度学习的现代 NLP 管道中的瓶颈之一就是tokenization,尤其是通用性强且独立于框架的实现。

所以,该分词器的核心是用Rust编写的,并且存在Node和Python的绑定。提供当今最常用的分词器的实现,重点是性能和通用性。

特点:

  • 使用当今最常用的分词器(tokenize),训练新的词汇并进行标记。
  • 由于采用Rust实现,速度极快(包括训练和标记化)。在服务器的CPU上对一GB的文本进行标记化只需不到20秒。
  • 易于使用,但也非常通用。
  • 为研究和生产而设计。
  • 正则化带有对齐性跟踪。总是可以得到与给定标记相对应的原始句子的部分。
  • 做所有的预处理。截断、填充、添加你的模型需要的特殊标记。

代码及团队规模

代码量大约 28000 行 Rust 代码。占总项目的 68%,该项目还有部分 Python 代码。

项目周期

该项目自 2019 年10月开始启动,迭代周期平均为每月一个小版本,目前版本为 Python V0.10.3

tch-rs (国外/开源/PyTorch 绑定)

关键字:PyTorch/ cpp api 绑定

介绍

tch-rs是Pytorch的Cpp API的 Rust 绑定。 Tch Crate的目标是围绕 Cpp Pytorch API 提供一些薄的包装器。 它旨在尽可能接近原始的Cpp API。 然后可以在此之上开发更加惯用的 Rust 绑定。

代码及团队规模

代码量大约为 58000 行 Rust 代码。

团队规模:

  • 主力开发:1 人
  • 社区贡献:36 人

项目周期

该项目从 2019 年 2月启动,截止到今天,还没有正式发布任何版本。目前还在活跃维护中,但可能是 个人项目。

ndarray (国外/开源/科学计算)

关键字:科学计算/ N 维数组

ndarray 是一个由Rust官方团队中资深科学计算专家bluss开发的开源项目,实现了基于rust的矩阵和线性运算。目标是在Rust中建立类似于numpy和openblas的科学计算社区。它是机器视觉、数据挖掘、生物信息等多类科学计算库的基础,社区中的主要用户为一些相关技术的高校或者研究所。

目前 华为 也在深度参与该基础库的研发,参见 华为 | Rust 科学计算多维数组运算库的分析与实践

另外还有线性代数相关库:ndarray-linalg

代码及团队规模

代码量大约为 29000 行 Rust 代码。

团队规模:

  • 主力开发:1人
  • 社区贡献:57 人

项目周期

该项目自 2015 年11 月启动,平均每半年 1 ~ 2 个小版本发布。

TVM-rs (国外/开源/ TVM rust 绑定)

关键字:Apache/ TVM

介绍

tvm-rs 是 TVM 的 Rust 绑定。

TVM 是由华盛顿大学在读博士陈天奇等人提出的深度学习自动代码生成方法,去年 8 月机器之心曾对其进行过简要介绍。该技术能自动为大多数计算硬件生成可部署优化代码,其性能可与当前最优的供应商提供的优化计算库相比,且可以适应新型专用加速器后端。

简单来说,TVM可以称为许多工具集的集合,其中这些工具可以组合起来使用,来实现我们的一些神经网络的加速和部署功能。这也是为什么叫做TVM Stack了。TVM的使用途径很广,几乎可以支持市面上大部分的神经网络权重框架(ONNX、TF、Caffe2等),也几乎可以部署在任何的平台,例如Windows、Linux、Mac、ARM等等。

代码及团队规模

代码量大约为 1 万多行。

团队规模:

  • 主力开发:3人
  • 社区贡献: 7~10人

项目周期

不定时维护。

Neuronika (国外/开源/机器学习框架)

关键字:PyTorch/ 机器学习框架

介绍

Neuronika 是一个用 Rust 编写的机器学习框架,与 PyTorch 类似,现已实现最常见的层组件(dense 层、dropout 层等),速度堪比 PyTorch。其构建重点在于易用性、快速原型开发和高效性能。

Neuronika 是由 Francesco Iannelli 等人开发,他们现在是计算机科学的硕士生。该框架提供了自动微分和动态神经网络,非常像 Pytorch。目前已经实现了最常见的层组件,如 dense 层、dropout 层、GRU、LSTM 和 1d-2d-3d CNN,然而,缺少池化层等等。Neuronika 还提供了损失函数、优化器、计算图、张量和数据实用程序。

速度方面,项目作者表示 Neuronika 的表现可与 PyTorch 媲美。你可以对它进行基准测试。但目前还不能支持 GPU。想要进行基准测试,可以参考测试文档。

Neuronika 的核心机制是一种叫做反向模式自动微分机制( reverse-mode automatic differentiation),这种机制允许用户在改变动态神经网络时可以轻松实现,没有任何开销,可通过 API 运行。

代码及团队规模

代码量大约为 26000 行 Rust 代码。

团队规模:

  • 主力开发:2 人
  • 社区贡献:0 人

新项目,目前还未有人贡献。

项目周期

还未迭代出初始版本,但开发动态比较活跃。

其他

游戏

游戏领域包括,使用 Rust 制作的游戏、Rust 游戏引擎、Rust 游戏生态建设等。

veloren (国外/沙盒游戏/开源)

关键字:沙盒游戏/ 我的世界

介绍

Veloren 是用 Rust 开发的开源免费多玩家 voxel RPG。它的灵感来自诸如《魔方世界》,《塞尔达传说:旷野之息》,《矮人要塞》和《我的世界》等游戏。支持多人、单人,可以在Windows、Mac、Linux上游玩。点击 官网 了解更多。

Veloren 应该是最早使用 Rust 的项目了,该项目在 2013 年就开始使用 Rust 开发,当时 Rust 还未 1.0 。截止到2021年的今天,该项目还保持着活跃更新。

Veloren 的创始人,也是 Rust 官方游戏开发组成员之一。

代码及团队规模

代码量大约为 20 万行 Rust 代码。

团队规模:

  • 主力开发:15 人。
  • 社区贡献:175 人。

项目周期

项目迭代周期平均每三月发一个小版本。目前是 0.10.0 版。

A / B Street (国外/开源/街景交通探索游戏)

关键字:

A / B Street 是一个游戏,探索城市对驾驶员,骑自行车者,过境用户和行人的运动的小变化。

游戏最终目标是让玩家成为调整西雅图(默认)基础设施的真正建议,A/B Street 使用OpenStreetMap ,所以游戏可以是世界上任何一个地方。 A / B街当然是一个游戏,使用简化的交通建模方法,所以城市政府仍然必须使用现有方法来评估提案。 A / B Street旨在作为与交互式可视化传达想法的对话启动器和工具,或者给城市规划专家做参考。

代码量及团队规模

代码量大约为 10 万 行 Rust 代码。

团队规模:

  • 主力开发:1 人
  • 社区贡献:24 人

项目周期

项目从2018年3月11 启动,截止到当前 2021年6月,一直高速迭代。迭代周期平均是每周一个小版本。

Embark 公司 与 Rust 游戏生态

关键字: Rust Game Ecosystem

介绍

Embark 公司是 Johan Andersson (游戏界知名人物)创建的游戏工作室,在创建之初就选择 Rust 作为主要语言。

我们相信,通过与社区公开分享我们的工作、问题和想法,我们将创造更多的合作和讨论机会,使我们走向Rust和整个游戏行业的伟大未来。 -- Johan Andersson (@repi), CTO, Embark

在Embark,我们一直用 Rust 从头开始构建自己的游戏引擎。 我们在RLSL原型的内部开发方面拥有先前的经验,我们拥有一支由出色的渲染工程师组成的团队,他们熟悉游戏,游戏引擎和其他行业中当今着色器语言的问题。 因此,我们认为我们处于解决这一问题的独特位置。

我们希望使用一种出色的语言简化我们自己的内部开发,建立一个开源图形社区和生态系统,促进GPU和CPU之间的代码共享,最重要的是-使我们(未来的)用户和其他开发人员拥有更多快速创建引人入胜的体验。

创建 Rust 游戏生态并非口号,Embark 公司也加入了 Rust 游戏工作组,并且创建了一系列打造 Rust 游戏生态的库。

这些库都罗列在 rust-ecosystem 仓库中。

NameDescription
🌋 ash-moltenStatically linked MoltenVK for Vulkan on Mac using Ash
👷 buildkite-jobifyKubekite, but in Rust, using configuration from your repos
📜 cargo-aboutCargo plugin to generate list of all licenses for a crate
cargo-denyCargo plugin to help you manage large dependency graphs
🎁 cargo-fetchercargo fetch alternative for use in CI or other "clean" environments
⚙️ cfg-exprA parser and evaluator for Rust cfg() expressions
⛴️ discord-sdkAn open implementation of the Discord Game SDK in Rust
🚙 gsutilA small, incomplete replacement for the official gsutil
📦 kratesCreates graphs of crates from cargo metadata
🆙 octoborsGitHub action for automerging PRs based on a few rules
🎳 physxUse NVIDIA PhysX in Rust
🐦 puffinSimple instrumentation profiler for Rust
📓 relnotesAutomatic GitHub release notes
🐏 rpmalloc-rsCross-platform Rust global memory allocator using rpmalloc
🐉 rust-gpuMaking Rust a first-class language & ecosystem for GPU code
🆔 spdxHelper crate for SPDX expressions
🛠 spirv-tools-rsAn unofficial wrapper for SPIR-V Tools
🔆 superluminal-perfSuperluminal Performance profiler integration
📂 tame-gcsGoogle Cloud Storage functions that follows the sans-io approach
🔐 tame-oauthSmall OAuth crate that follows the sans-io approach
🧬 tame-oidcSmall OIDC crate that follows the sans-io approach
🎨 texture-synthesisExample-based texture synthesis generator and CLI example
💫 tryhardEasily retry futures

其中最重要的一个库是 rust-gpu,旨在让 Rust 成为建立GPU代码的一流语言和生态系统。

使用Rust的愿望 为GPU编写程序 不仅源于安全功能和高性能, 而且还需要获得与软件包和模块一起使用的现代工具,以提高开发过程的效率。

历史上,在游戏中,GPU编程是通过编写HLSL完成的 或在较小程度上包括GLSL。 这些是简单的编程语言,这些年来随着渲染API一起发展。

但是,随着游戏引擎的发展,这些语言还没有提供处理大型代码库的机制,总的来说, 与其他编程语言相比,它们已经落后了。

虽然一般来说,两种语言都有更好的替代方法,但是都无法代替HLSL或GLSL。

是因为它们被提供程序阻止,还是因为它们不受支持 与传统的图形管道。 这样的示例包括CUDA和OpenCL。 尽管已尝试在此空间中创建语言,但没有一个在gamedev社区中获得明显的吸引力。

Rust GPU继续根据RLSL项目开发思想,其中尝试为SPIR-V通用着色器中间件创建Rust编译器,该编译器在Vulkan API中提出,并在OpenGL 4.6中得到支持。在目前的开发阶段,Rust GPU已经允许您运行简单的图形着色器并编译Rust基本标准库的重要部分。 同时,该项目还没有为广泛使用做好准备,例如,着色器尚不支持循环。

基于Rust语言的代码,形成了SPIR-V着色器的表示形式,为此开发了一个Rust编译器的特殊后端,该类后端类似于用来编译成表示形式的Cranelift代码生成器WebAssembly。

当前的方法是支持Vulkan图形API和SPIR-V视图, 但是计划在未来的DXIL(DirectX)和WGSL(WebGPU)着色器视图中使用生成器。 基于Cargo和crates.io,正在开发工具来开发和发布带有SPIR-V格式的着色器的程序包。

目前还属于非常早期,以平均每周一个小版本迭代,现在发布 0.3 版。主力开发大约六人,社区贡献 35 人。

Bevy (国外/游戏引擎/开源)

关键字:游戏引擎/ ECS

介绍

Bevy 是一个基于 Rust 实现的 数据驱动游戏引擎。

Bevy完整实践了时下比较热门的data-driven开发理念, 也就是ECS模式。相比于其他架构比较老的开源引擎,比如Godot等,Bevy有一整套的从造轮子到游戏开发落地的ECS开发模式。而和商业引擎相比,Bevy的历史包袱也很少,不用像unity的DOTS开发一样还需要兼容传统的GameObject模式。此外,得益于Rust语言强大的表达能力,整个引擎在接口上看起来比用C++造的那些data-driven的轮子要简洁明了的多。 -- 《安利一个Rust Game Engine:Bevy--ECS部分》

相比于 Rust 实现的其他游戏引擎,比如 Amethyst, Bevy 属于后来着居上。Bevy 在 API 设计方面独具匠心,充分利用 Rust 语言特点,让开发者上手非常简单方便。得力于其 Plugin 机制,目前 Bevy 已经逐渐形成自己的生态,逐步涌现出很多基于 Bevy 的 Plugin 。

代码及团队模式

代码量大约为 65000 行 Rust 代码。

团队规模:

  • 主力开发:1 人
  • 社区贡献:244 人

项目周期

项目启动自 2019 年 11 月 10 号,目前发布 0.5 版本,还处于高速迭代期。Bevy 项目也经历过一次大的重构,重构了底层 ECS 系统。

其他动态

  • https://gamedev.rs/ 是 Rust 游戏工作组的官方站点,定期发布 Rust 在游戏生态中的动态。
  • godot-rust,Godot 3.2 引擎的

客户端开发

飞书 App (国内/商业)

关键字: lark/ 字节跳动

介绍

字节跳动旗下 飞书(lark)App ,应该属于国内最大的 Rust 开发团队,全职写 Rust 的开发人员大约在 30 ~ 60 人。

飞书在 客户端跨平台组件中使用 Rust ,代码量据说超过 55 万行代码 (包括测试和生成代码)。

其他信息目前不详细。

飞书团队还开源了几个 Rust 项目,可以在其 GitHub 仓库中查阅。

区块链/数字货币

区块链/数字货币领域包括,区块链基础设施、数字货币项目等。

Diem (国外/开源/Libra/ 超主权货币)

关键字:libra/ Facebook

介绍

Diem 前身是 Libra,其使命是建立一套简单的、无国界的货币和为数十亿人服务的金融基础设施。他们努力打造一个新的去中心化区块链、一种低波动性加密货币和一个智能合约平台的计划,以期为负责任的金融服务创新开创新的机遇。

他们认为,应该让更多人享有获得金融服务和廉价资本的权利,每个人都享有控制自己合法劳动成果的固有权利。他们相信,开放、即时和低成本的全球性货币流动将为世界创造巨大的经济机遇和商业价值,并坚信人们将会越来越信任分散化的管理形式。全球货币和金融基础设施应该作为一种公共产品来设计和管理。所有人都有责任帮助推进金融普惠,支持遵守网络道德规范的用户,并持续维护这个生态系统的完整性。

最新状态:Facebook旗下数字货币项目Diem放弃申请瑞士支付牌照:专注美国市场 (2021年05月13日 )。

代码及团队规模

代码量大约为 30 万 行 Rust 代码。

团队规模:

  • 主力开发:5~ 20 人
  • 社区贡献:150 人

项目周期

平均每月一个小版本,目前主框架 1.2.0 版,sdk 0.0.2 版。

Substrate (国外/ 开源/ 区块链框架)

关键字: parity/substrate

介绍

Substrate 是Parity Polkadot 旗下的项目。 Polkadot 是基于 Substrate 构建的。

Substrate 框架被称为下一代区块链框架,类似于Java的Spring、Python的Django,只不过后者创建的是网站,前者创建的是区块链。它由Parity团队基于Rust语言开发而成,是一个开箱即用的区块链构造器。基于Substrate创建区块链可以让开发者专注于业务需求,不需从头实现底层的P2P网络和共识逻辑。

代码及团队规模

代码量大约为 35 万行 Rust 代码。

团队规模:

  • 主力开发:4~10人
  • 社区贡献:243 人

项目周期

Substrate 经过两次大的版本迭代,已发展到了 V3.0 版。目前迭代周期平均每月一个小版本。

Nervos CKB (国内/ 区块链公链)

关键字:nervos/ ckb/ cita

介绍

Nervos Network 是一个开源的公链生态,包含以区块链技术为核心、相互兼容的一组分层协议,来解决区块链可扩展性上的困境。

Nervos CKB(Common Knowledge Base 共同知识库)是一个无需许可的公有区块链,在区块链中,这里提到的共同知识库通常指的是经过全球节点共同验证并确认的状态。 和比特币类似,Nervos CKB 是一个状态验证系统。由杭州秘猿科技研发。

秘猿也算国内 Rust 开发人员众多的公司之一,Rust 全职开发大约是 30 + 人。

代码量及团队规模

单 ckb 项目代码量,大约 11 万行 Rust 代码。

团队规模:

  • 主力开发:6 ~ 8 人(单ckb项目)
  • 社区贡献:22 人

项目周期

开发周期大约每周一个小版本。

其他区块链项目

其他 Rust 正在革新的领域

  • 机器人:

    • zenoh ,zenoh 将动态数据、使用中数据、静态数据与计算整合。它巧妙地将传统的发布/订阅与地理上分散的储存、查询和计算融合,同时保有远胜任何主流堆栈的时间和空间效率。zenoh 可作为 ROS2 中间件 DDS 替代品,也可以和 DDS 无缝集成。
  • 航天航空:

    • 新西兰 公司 Rocket Lab 是小型卫星发射领域的全球领导者,和 NASA 、xSpace 都是合作伙伴。团队有500人,而且每周都在增加。目前在使用 Rust 。
    • 非官方 航空航天工作组 AeroRust 创建了 Are we in space yet? 网站,跟踪 Rust 在航空航天领域的开源项目。
  • 汽车/自动驾驶:

    • erdos,用于构建自动驾驶汽车和机器人应用的数据流系统。
  • 编程语言:

    • Rust,Rust 语言早已实现了自举,算得上是世界上最大的 Rust 项目之一了。其代码量大约 179 万行 Rust 代码。
    • langs-in-rust 这个网站罗列了 几十种 用 Rust 实现的新编程语言。其中也不乏优秀之作。比如 Gleam / Dyon / Koto
    • deno,严格来说,deno 不算语言,只是 typescript/javascript 运行时。但是它的 Rust 成分占了 58.5%。比较有意思的是,作者最早使用 Cpp/ Golang 来实现 deno,最终都已换成了 Rust 。
  • 艺术:

  • VR 领域:

    • makepad,是一个 VR,Web和本机渲染UI框架 和 IDE,基于 Rust 和 WebAssembly (WebGL) 技术。 作者是 Cloud9 IDE 的创始人。该项目也包含白皮书,阐述了它的愿景。
  • 安全领域:

    • rustscan,是一个现代化高性能的端口扫描器,并且提供脚本引擎来支持运行 Python,Lua,Shell。支持 自适应学习(Adaptive learning)。用的越多,RustScan 越智能,不是基于臃肿的机器学习,主要是基于基本数学。

    • feroxbuster,一款高性能的内容发现工具,用于渗透测试。feroxbuster 使用蛮力结合单词列表在目标目录中搜索未链接的内容,这是一种攻击行为。这些资源可能存储有关 Web 应用程序和操作系统的敏感信息,例如源代码,凭据,内部网络寻址等。此攻击也称为可预测资源位置,文件枚举,目录枚举和资源枚举。

    • rusty-tor,一个概念验证的 Tor 客户端。不过有两年没有维护了。

    • Enarx,这是一个组织,由红帽公司牵头其他知名企业来做可信执行环境的应用部署系统。

    • sn0int, 半自动高级公开资源情报(OSINT)框架和程序包管理器,用于扫描目标IP地址、电子邮件、网站和组织信息,并从不同消息源收集各种情报信息。

    • sniffglue,多线程网络嗅探器。Kpcyrd 经常使用tcpdump,但他认为输出更友好的话会更好,并且 wireshark 也经常存在漏洞,tcpdump 有时也有漏洞,所以他用 Rust 实现了这个工具。

    • badtouch, 可编写脚本的网络身份验证破解程序。

    • rshijack,用 Rust 重写了 shijack,实现 TCP 会话劫持。

    • Cargo Fuzz,Rust 官方开发的 Fuzz 工具。

    • honggfuzz-rs,Google 开发的 Fuzz 工具。

    • Sidefuzz, 侧信道漏洞检测工具。

    • RustSec/advisory-db,通过crates.io发布的Rust Crate 的安全咨询数据库。

    • cargo-auditCargo.lock中依赖审查工具。

    • cargo-crev,代码 Review 工具。

    • ripasso,一款密码管理工具。

    • libinjection-rs, 对 libinjection 的 Rust 绑定,libinjection 是一款 SQL 注入工具。

    • lethe,安全,免费,跨平台和开源的驱动擦除工具。

盘点在生产环境使用 Rust 的公司

国内

华为

可信编程的愿景之旅,才刚刚开始。我们希望与 Rust 社区,以及即将成立的 Rust 基金会合作,为电信软件行业带来一场平稳的革命。

华为于 2021 年加入 Rust 基金会,旨在为 Rust 在全球推广和发展做贡献。目前在一些开源项目和内部试点项目使用了 Rust ,以及为 Rust 生态做了一些贡献。目前正在为大规模使用 Rust 做准备。

华为也是 Rust Conf China 2020 战略级赞助商。

PingCAP 及其 客户

PingCAP 公司创造的 分布式数据库 TiDB,底层TiKV分布式存储是基于 Rust 实现的。

TiDB 现已被 1500 多家不同行业的领先企业应用在实际生产环境。客户包括:中国移动/ 中通快递/ 伴鱼/ 知乎/ 网易游戏/ 美团/ 京东云/ 360云/ 今日头条 等等公司。

PingCAP 也是 Rust Conf China 2020 银牌赞助商。

阿里/蚂蚁集团

阿里云/ 钉钉团队,以及蚂蚁集团 机密计算 和 数据库团队,都在使用 Rust 。

字节跳动

字节跳动 飞书团队 使用 Rust 进行跨平台客户端组件开发。

字节跳动/飞书 也是 Rust Conf China 2020 钻石赞助商。

知乎

知乎搜索引擎团队在使用 Rust 。

收钱吧

上海收钱吧团队在生产环境使用 Rust 做消息队列服务。

吉利集团

吉利集团数字科技板块在使用 Rust 构建区块链。

上海息未信息科技

上海息未信息科技有限公司专注于航天及航空训练装备的研发生产,是由国内顶级基金投资的科技企业,主要服务于中国航天、军队以及航空公司。其公司部分产品使用 Rust 。

杭州秘猿科技

CBK 公链项目就是该公司产品。还有其兄弟公司 溪塔 也是 Rust 生产级用户。

秘猿和溪塔同是 Rust China Conf 2020 金牌赞助商。

国内其他区块链公司

Bitfrost

Bitfrost 也是区块链项目,为 Staking 提供流动性的跨链网络。Rust China Conf 2020 银牌赞助商。

达尔文网络

达尔文网络是基于Substrate开发的去中心化桥接网络,也是区块链行业。Rust China Conf 2020 银牌赞助商。

还有很多其他区块链公司,都在使用 Rust ,这里就不一一罗列了。

豆瓣

豆瓣使用了 Rust 开源库 Vector ,应该属于被动使用 Rust 。其他团队是否在其他项目使用 Rust 犹未可知。

国外

Google

Google Fuchsia OS 内 Rust 使用量大约为 137 万行 Rust 代码,算的上是 Rust 生态圈内除 Rust 之外第二大 Rust 项目了。

并且 Google 极力支持 Rust for Linux 项目,还出资金赞助了核心开发。

Google 也是 Rust 基金会成员之一。

Android“在过去的 18 个月里,我们一直在为 Android 开源项目添加对 Rust 的支持。我们有几个早期采用 Rust 开发的项目,我们将在未来几个月内分享。将 Rust 扩展到更多操作系统,是我们的一个多年项目。”

微软

Rust 基金会成员之一。目前 Windwos 已经全面支持 Rust 开发。

有没有注意到 VS Code 的搜索速度有多快?原因是 VS Code 正在使用 ripgrep增强它的搜索能力

AWS

Rust 基金会成员之一。AWS 应该是最早支持 Rust 社区的公司了,连续几年赞助 Rust Conf。

在 AWS,我们喜欢 Rust,因为它帮助 AWS 编写高性能、安全的基础设施级网络和其他系统软件。我们使用 Rust 提供的服务产品有很多,比如 Amazon 简单存储服务(Amazon S3)、Amazon 弹性计算云(Amazon EC2)、Amazon CloudFront、Amazon Route 53 等等。最近我们推出了 Bottlerocket,这是一个基于 Linux 的容器操作系统,也是用 Rust 开发的。

Facebook

Rust 基金会成员之一。 Facebook 内部 Rust 项目综合起来代码已超 百万行。著名的项目是 Diem 及其 MOVE 语言。

Facebook 目前也拥有一个团队,专门为 Rust 编译器和库做贡献。

Mozilla

Rust 语言的诞生之地。Servo 浏览器内核项目使用 Rust 开发,代码量大约为 30万 行 。

Apple

苹果在招聘公告中写到:“我们构建的系统的性能和安全性至关重要。目前使用异步 I/O 和线程来分配工作负载,直接与底层 Linux 内核接口进行交互。首次成功使用 Rust 之后,我们正在将已建立的代码库从 C 迁移到 Rust,并计划未来主要使用 Rust 构建新功能。”

苹果目前没有加入 Rust 基金会。

1Password

我们在 1Password 的生产环境中,使用 Rust 已有几年了。我们的 Windows 团队是这项工作的领跑者,Windows 中所用的 1Password 7,大约 70% 都是用 Rust 开发的。在 2019 年底,我们还将 1Password Brain(驱动浏览器填充逻辑的引擎)从 Go 移植到了 Rust。这样,我们就可以在浏览器扩展中,发挥将 Rust 程序部署到 WebAssembly 应用的性能优势。

Cloudflare

随着我们 Rust 开发经验的增长,Rust 语言在另外两个方面显示出了优势:作为一种具有强大内存安全性语言,它是边缘计算的一个绝好选择;作为一种具有极大热情的语言,它成为一种流行于组件重新开发(de novo)的语言。

Discord

当开始一个新的项目或组件时,我们首先考虑使用 Rust。当然,我们只在适合的地方使用。除了性能之外,Rust 对工程团队也有很多优势。例如,它的类型安全性和借用/引用检查器,使重构代码变得非常容易。此外,Rust 的生态系统和工具都非常出色,背后有着巨大的动力。

IBM

IBM 的一个团队使用 WebAssembly 和 Rust,实现了难以置信的性能改进。

Dropbox

我们用 Rust 开发了 Nucleus!Rust 帮助我们团队力量倍增,选择 Rust 是我们做出的最好决定之一。Rust 的人机工程学和正确原则,不仅有助于我们驯服 sync 的复杂性。而且,我们可以在类型系统中,对系统进行复杂的不变量编码,并让编译器为我们检查它们。

npm

npm 的第一个 Rust 程序,在一年半的生产环境中,没有发生任何警报。‘我对 Rust 最大的赞美,是它很无聊’,Dickinson说,‘这是一个令人惊奇的赞美’。部署新的 Rust 服务的过程是直接的,很快,他们就能够忘记这项 Rust 服务,因为它只引起很少的操作问题。

OneSignal

就在这个月,我们突破了每天发送 70亿 条通知的门槛,并创下了每秒 175 万条的记录。

Qovery

随着公司意识到云计算的好处,Rust 的势头就越来越强劲。Dropbox 使用 Rust 重写了它的一些核心系统,而 Mozilla 使用 Rust 构建了 Firefox 浏览器引擎,展示了 Rust 强大的优势。在 Qovery,我们相信 Rust 能够云构建的未来。

Astropad

有了 Rust,我们将拥有一个高性能、可移植的平台,可以轻松地在 Mac、iOS、Linux、Android,以及 Windows 上运行。这不仅极大地扩大了我们潜在的市场规模,而且还看到了我们的 LIQUID 技术的许多有趣的新用途。我们有信心用以强大的代码、更好的产品和对 Astropad 未来的乐观展望,以完成我们的 Rust 之旅。

Coursera

我们在增强的 Docker 容器中,高效、可靠、安全地为提交的作业评分。虽然我们将集群调度到 Amazon EC2 容器服务(ECS)上,但许多程序之间协同工作,是用 Rust 开发的。

三星

我们想向 Rust 语言的 5 个核心团队、Mozilla,以及 Rust 语言生态系统中众多软件包的贡献者们公开道谢:我们正在利用 Rust 开发新的更新客户端和服务器,以及其他一些软件的主干,并希望随着时间的推移,继续扩大我们对该语言的使用。”

System76

像我们今天的所有项目一样,它是用 Rust 编写的,并且遵循当前的最佳实践。该项目被配置为一个工作区,核心 crate 提供了一个通用库,用于从多个固件服务中发现和管理固件。支持 fwupdsystem76-firmware

Clever Cloud

对我们来说,这些好处有力地证明了 Rust 是生产平台的可靠构建块。这是一段我们不必担心的代码,它将使其它服务能够安全地运行。

Rapid7

我们在 Rust 部署中,看到的主要加速点是,不同平台上的部署工具,能够很容易地适应该语言。代理开发人员,能够很快地学习该语言,并开发与托管运行时的集成。

Figma

虽然我们有一些挫折,但我想强调的是,我们在 Rust 方面的经验,总体上是非常积极的。这是一个非常有前途的项目,我们拥有坚实的核心和健康的社区。

Fly.io

我们基础设施中的每台服务器,都在运行一个名为 fly-proxy 的基于信任的代理。此代理负责接受客户端连接、将其与客户应用程序匹配、应用处理程序(例如:TLS终止)以及服务器之间的回程处理。

Everlane

Rust 给予我们锻造性。这项服务已经在生产环境运行 4 个月了,它平均每秒处理 40 个请求,响应时间为 10ms。它的内存使用量很少超过 100MB。

还有很多公司,可以在 Rust 官网里看到介绍 :Rust Production users

作者介绍

张汉东,《Rust 编程之道》作者,独立企业咨询顾问,开源爱好者。喜欢读书、写作、分享,欢迎交流。

华为 | WebAssembly 安全性调研

作者:华为可信软件工程和开源2012实验室


2015年4月,W3C成立WebAssembly工作组,用于监督与规范WebAssembly提案,倡导浏览器厂商使用一致性的规范。WebAssembly 技术虽脱胎于浏览器,但截至到2021年的今天,早已经走出浏览器,大有让计算无处不在的趋势。

WebAssembly 天生具备安全、可移植、高效率,轻量化等特点,所以非常适于应用安全沙箱场景。除了浏览器领域,WebAssembly 还得到了容器、函数计算、IoT / 边缘计算等社区的广泛关注。WebAssembly 沙箱甚至可以安全地在与其他代码相同的进程中运行,这与软件故障隔离(SFI)非常相似。

本文将从 WebAssembly 的特性、安全、性能、应用领域四个方面探讨其应用价值。

WebAssembly 特性

WebAssembly(或wasm)是一种适用于Web的可移植编译格式,提供更小的文件尺寸和更快的加载速度。WebAssembly旨在成为高级语言的编译目标。目前可以使用C、C++、Rust、Go、Java、C#编译器(还有更多)来创建wasm模块。

WebAssembly 当前状态

截止 2021 年的今天,WebAssembly 的阶段目标还没有完全实现。从 WebAssembly 官方 Roadmap 可以了解到当前状态。

1

看得出来,三大主流浏览器,以及浏览器之外的主流 wasm 运行时基本都已支持 WebAssembly的一半特性 。对于异常处理,模块链接(Module Linking),尾调用,线程,接口类型(Interface Types)等还未得到完整支持。

2021 年,模块链接和接口类型有望得到支持。

模块链接提案旨在运行时声明标准中链接两个或更多个模块,而不是靠开发人员手动编写代码将一个模块导出给另一个模块,这很耗时且容易出错。如果其中一个模块被修改,则Webassembly 引擎可以处理将它们连接在一起,也可能会优化它们。

接口类型提议将有助于在模块(guest)或主机(host)之间进行通信。 这是因为WebAssembly 模块本地支持四个数据类型(32位整数,64位整数,32位浮点和64位浮点)。 为了让不同语言编写的 WebAssembly 模块相互通信,接口类型提供某种类似接口的映射方式,可以正确映射要通信的类型,这样就可以使用更富表达力的高级类型来相同通信了。

为 WebAssembly 在 Server Side 提供 Runtime 的 wasmtime 和 wasmer ,目前基本都已经进入 GA 状态。

WebAssembly 内存模型

WebAssembly 只提供一个沙盒化的线性内存,并不提供托管内存(堆)和 垃圾回收器。

线性内存是一个连续的、字节可寻址的内存范围,从偏移量 0 一直延伸到不同的内存大小。此大小始终是 WebAssembly 页面大小的倍数,固定为 64KiB。每个 WebAssembly 实例都有一个专门指定的默认线性内存。

线性内存有以下特点:

  • 线性内存中的所有数据始终是可写的,不存在只读内存。
  • 线性内存始终为零初始化。
  • 线性内存每个指针都是有效的。
  • 线性内存是确定性排列的,即栈位置可以从编译器和程序中预测。
  • 线性内存可以请求 VM 使用 memory.growin 指令增加线性内存。

数据管理

  • 非托管数据:指在线性内存中驻留的数据。不受 vm 保护,完全受程序内存写入指令的控制。
  • 托管数据:即局部变量、全局变量、栈上的执行的值和返回地址,都驻留在由 VM 直接处理的专用存储中。WebAssembly 代码只能通过指令隐式地与托管数据进行交互,而不能直接修改其底层存储。并且托管数据没有地址。

由于WebAssembly 只有四种基本类型,所以托管数据只能保留这四种基本类型实例。而其他的诸如字符串、数组、列表等非标量(复合)类型数据都必须存储在线性内存。

因为托管数据中没有地址,所以在源程序中取过地址的任何变量也必须存储在线性内存中。

内存布局

因为 源代码中有很多作为函数作用域、全局或动态生命周期的数据,所以编译器会在线性内存中创建分别用于调用栈、堆(动态分配内存)和静态数据的区域。

所以分为:

  • 非托管栈:编译器在线性内存上创建的调用栈。
  • 托管evaluation栈:用于保存指令的中间值,由 VM 管理。
  • 托管 调用栈:保存局部变量和返回地址等,由 VM 管理。

相比 elf 二进制文件,.data.rodata.bss 在 WebAssembly 中没有明确区分,因为 线性内存 并没有只读区域,并且始终零初始化。

2

不同的编译器会产生不同的内存布局。上图展示了 Emscripten 的 fastcomp backend 和 upstream backend,以及 clang 和 rustc 编译出的 Wasm 二进制文件内存布局。其中 clang和rustc 的内存布局相似:静态数据在栈和堆之间。

导入导出

Wasm 模块可以提供 Host 可以读取以便与 Wasm 模块链接的符号信息,包括:导出(Export)/ 导入(Import)/ 入口点(Entry Point)。

导出符号是指 Host 可以访问的 wasm 模块组件,包括:

  • 函数
  • 全局变量
  • 内存
  • 表(table)

Wasm 模块导出函数是类型安全的。函数导出信息由目标函数的函数签名组成。因此,宿主环境可以在函数调用之前验证函数参数。它消除了无效的参数传递,从而提高了整体程序的安全性。

Wasm 模块还可以导出全局变量和内存。它可以在不调用Wasm函数的情况下实现主机和Wasm模块之间的信息交换。全局变量导出也是类型安全的,但它只提供了Wasm 基本类型。

对于较大的数据,内存导出是更好的选择,因为它允许在 Wasm 模块和主机之间有效传输原始数据。然而,内存导出是无类型和非结构化的。宿主代码在通过内存导出操作数据时需要慎重考虑。

同样,导入是指从Host环境或其他模块的函数/ 内存/ 表。但必须在 Host 实例化 wasm 模块之前提过所需的元素,Host 环境要确保Host 代码提供所有必须的元素,包括正确的内存分配、表分配和函数。模块实例化过程类似于编译中的链接过程,其中代码在执行之前链接到所有必需的符号。

控制流安全

WebAssembly 不允许跳转到任意指令点。

wasm 模块中 指令 被分组为 块(block),分为三类块:

  • 常规块
  • 条件块
  • 循环块

一个块以block关键字开始,以end指令结束。end指令是一个伪指令,标志着块的结束。块内不能访问块外中的操作数。程序需要通过一个局部变量填充该值,以在块内传输该值。Wasm会根据块的结果类型来验证栈状态。

Wasm有三种类型的分支:

  • 无条件分支(br),总是被执行的,不考虑外部状态。
  • 有条件分支(br_if),从栈中消耗一个操作数来确定是否需要进行分支。
  • 表分支(br_table),需要一个整数操作数作为索引来选择目标块,分支指令对每个块类型的行为都不同。

Wasm 表

Wasm 表用于交换 引用信息。

3

Wasm 表存储对一个函数的引用,Wasm 程序可以使用该函数来执行动态或间接调用。通常,编译器会根据需要填充该表。 编译器使用函数指针检测动态函数调用是否需要表。

另一种情况,例如使用函数指针作为返回值,也需要一个表。 编译器静态填充表并使用表索引作为指令的引用。

目前,Wasm 不允许在 Wasm 程序中操作表。

WebAssembly 安全性

WebAssembly 生态安全性可以从两个维度看待:

  • Host 安全性 ,在运行时环境能有效保护主机系统免受恶意 WebAssembly 代码的侵害。
  • Wasm二进制安全性,内置故障隔离机制防止利用其他良性 WebAssembly 代码作恶。

Host 安全性分析

WASM虚拟机提供的的沙箱和内存隔离机制,可以有效减少安全攻击面。而当WebAssembly走出浏览器,面向更加通用的场景。WASM也面对更加复杂的安全挑战。

WASI 提供了基于能力的安全模型。WASI应用遵循最小权限原则,应用只能访问其执行所需的确切资源。传统上,如果应用需要打开文件,它会带路径名字符串调用系统操作open。然后系统调用会检查应用是否具有访问该文件的相关权限,比如Linux实现了基于用户/组的权限模型。这样隐式的安全模型,依赖于正确的安全管理配置,比如一旦特权用户执行了一个恶意应用,它就可以访问系统中任意的资源。而对于WASI应用而言,如果它需要需要访问指定文件等系统资源,需要从外部显式传入加有权限的文件描述符引用,而不能访问任何其他未授权资源。这中依赖注入的方式可以避免传统安全模型的潜在风险。

WASI的安全模型与传统操作系统安全模型非常不同,而且还在持续演进中。比如字节码联盟提出了 nanoprocess 来解决应用模块间的安全协同和信任传递。

WebAssembly/WASI的安全模型依然存在不足,比如:

  • 资源隔离:
    • 对于内存资源,WebAssembly实现了线性内存模型。WebAssembly应用只能利用索引访问传入的一段逻辑线性内存。而WASM虚拟机负责确定内存的实际物理地址,WASM应用无法获知内存的真实地址,也无法通过越界访问等方式发动攻击。所以理论上,可以对WASM应用进行资源容量限制。但是目前部分WASM虚拟机还无法对内存进行精确的隔离限制。
    • 对于CPU资源,部分的WASM虚拟机实现可以对应用使用的CPU资源进行计量,但是大多无法实现精确的配额限制、优先级和抢占式调度。
    • I/O资源,比如IOPS等,WASM目前完全没有相关的隔离能力。
  • 网络安全:
    • WASI的Capability模型对于文件系统访问相对比较容易保护。但是这个静态的安全模型无法适用于动态的网络应用场景。在微服务架构中,应用经常通过Service Registry进行服务发现,为服务的调用者和提供者实现动态的调用绑定。这个语义是无法用静态的capability模型描述和注入的。这也导致了WASI的网络部分API还处于讨论之中。现有的WASI网络安全模型,以及相关讨论

Wasm 二进制安全性分析

论文 《Everything Old is New Again:Binary Security of WebAssembly》 专门研究了这个问题。

该论文针对 WebAssembly 当前特性,构建出了一组攻击原语。

4

如上图,可以通过构造上面三步攻击原语来进行攻击。

  1. 获得写原语。
  2. 覆盖栈上或堆上的安全相关数据。
  3. 通过 控制流分发 或 操作宿主(Host )环境来触发恶意行为。

第一步,获得写原语。因为 WebAssembly 没有本地程序常见的 Fortify_source (用来编译时缓冲区检查)和 stack canary(栈溢出保护)安全机制,所以在 非托管栈上更容易受到利用。

有三种手段可以获得写原语:

  1. 栈缓冲区溢出。因为线性内存是非 VM 托管的,所以缓冲区溢出可能覆盖其他函数调用里的局部变量。
  2. 栈溢出。假如给函数传一个能引起无限递归的特定数据可以引发栈溢出。栈溢出在 WebAssembly 并不会发生段错误,而是会覆盖栈区之外的敏感数据,比如 堆上的数据。
  3. 破坏堆元数据。通过内存分配器的缺陷,破坏 WebAssembly 二进制文件附带的内存分配器的堆上元数据。

并非只有这三种方法可以获得写原语,还有一些其他传统的攻击方法可以用来利用,比如格式化字符串、UAF(use after free)等。

第二步,在获得写原语之后,就可以覆盖安全数据了。

能被覆盖的数据包括:

  • 线性内存中非托管栈包含的函数范围内的数据,比如函数表索引的函数指针或安全关键函数的参数。虽然缓冲溢出无法控制函数的执行路径,但是有能力可以覆盖掉当前溢出能到达的所有活动的调用栈帧。
  • 堆数据。堆通常包含具有更长生命周期的数据,并将存储跨不同功能的复杂数据结构。并且由编译器在线性内存上分配的堆区域没有任何保护机制,缓冲区溢出或栈溢出可能会破坏堆数据。
  • 常量数据。任意写入原语可以更改程序中任何非标量常量的值,包括例如所有字符串文字。从而破坏编程语言所预期的保证。

甚至有的 wasm 运行时的实现还无法正确分离栈和堆,更加重了安全风险。

第三步,触发恶意行为。

  • 重定向间接调用。攻击者可以通过覆盖线性内存中的整数来重定向间接调用。这个整数值可能是非托管堆栈上的局部变量、堆对象的一部分、vtable 中,甚至是一个所谓的常量值。受限于 WebAssembly 间接调用的机制,攻击者只能在相同类型的函数的等价类内重定向调用。
  • 代码注入宿主环境。例如,假设 WebAssembly 通常使用存储在线性内存中的“常量”代码字符串调用 eval,然后攻击者可以用恶意代码覆盖该常量。
  • 特定于应用程序的数据覆盖取决于应用程序,可能会有其他敏感的数据覆盖目标。例如,一个 WebAssembly 模块通过一个导入的函数发出 Web 请求,可以通过覆盖目标字符串来联系不同的Host,以启动 cookie 窃取。

总的来说,这些都是基于 WebAssembly 线性内存的天生的特性而构造的攻击方式。并且经过实验证明是可行的。

所以,应该使用安全的编程语言,比如 Rust ,来编写 WebAssembly 模块会更加安全。另外还需要独立的 wasm 模块验证装置,以确保每个 wasm 模块是符合规范的。

利用 CPU 漏洞可绕过 WASM 沙箱隔离

论文 《Swivel: Hardening WebAssembly against Spectre》中介绍了 利用 CPU 漏洞幽灵攻击(spectre attacks ),可以绕过 WASM 沙箱隔离。

幽灵攻击利用CPU 分支预测器来诱导错误预测,从而导致错误执行指令。 有三种攻击分类:

  • 利用模式历史表(PHT)攻击。攻击者 污染 PHT 中的条目,从而将分支错误预测到错误的路径。 然后攻击者可以使用这种错误路径执行来绕过内存隔离保护或控制流完整性。
  • 利用分支目标缓冲区(BTB)攻击。攻击者污染 BTB 中的条目,将推测控制流重定向到任意目标。
  • 利用返回堆栈缓冲区(RSB)攻击。攻击者使用调用或保留指令链使 RSB 上溢或下溢,并依次重定向推测控制流。

可以在 FaaS 平台上传任意恶意 wasm 模块,利用 幽灵漏洞 来绕过 wasm 沙箱隔离。

攻击类型也可以分为三种:

  1. 沙箱突围攻击:攻击者定制模块内的控制流,以达到访问沙箱区域外的数据。比如,可以在访问间接调用表时使用 Spectre-PHT 绕过条件边界检查。 或者,他们可以使用 Spectre-BTB 将控制流转移到指令中间以执行不安全的代码。
  2. 沙箱投毒攻击:攻击者通过影响受害者沙箱的控制流,强迫受害者泄露私有数据。
  3. 主机投毒攻击:攻击者通过幽灵攻击影响主机的控制流,以便从主机或其他任意沙箱访问数据。

为了防范幽灵攻击,论文中提出一种解决方案:构建线性块(linear block)。

通过自己实现的 Swivel-SFI的工具,将 wasm 代码编译为了 线性块。线性块是直线代码块,除了它们的终止符之外不包含任何控制流指令。与传统的基本块(Basic Block)形成对比,后者通常不将函数调用视为终止符。这个简单的区别很重要:它允许我们确保所有控制流传输 (顺序和推测) 落在线性块边界上。 然后,通过确保单个线性块是安全的,就可以确保整个 Wasm 程序在编译时受到限制并且不会违反 Wasm 的隔离保证。

所以,如果想利用 WebAssembly 的 SFI 机制,还必须防范类似于 幽灵攻击之类的恶意攻击。

WebAssembly 沙箱性能

wasm 执行性能

关于 Wasm 执行的性能,在 论文 《Swivel: Hardening WebAssembly against Spectre》 中也有一些统计。Swivel-SFI工具执行 wasm 模块中函数调用性能基本是 5微秒左右。

另外在 2021 第一季度也有人测试过目前主流 wasm 运行时的性能:Benchmark of WebAssembly runtimes - 2021 Q1

和本地代码执行性能相比,还是要差一个数量级。

沙箱和主机上下文切换性能

使用 WebAssembly 的沙箱机制,有一部分开销部分主要是存在于 主机(Host) 和 沙箱(Sandbox) 的上下文切换中。

如果在沙箱和主机切换中再加一些确保安全的指令,性能损耗就更多。

拿 lucet 来说,它的上下文切换成不就比较高,因为它要确保安全。上下文切换成本包括:

  • Callee-Save寄存器完整性。
  • 初始寄存器机密性。
  • 栈帧封装。

论文 《Isolation Without Taxation: Near Zero Cost Transitions for SFI》 提供了一种接近零成本切换的WebAssembly SFI 的方案,并且基于 lucet 实现了该方案,修改后将 Firefox 中的字体和图像渲染速度提高了 29.7% 和 10%。因为 Firefox 的渲染组件使用了 WebAssembly SFI 功能,该 SFI 基于 lucet 实现,下文会有介绍。

关于零成本切换的更多细节可以查看该论文。

WebAssembly 与 SFI

Firefox 中的应用

Firefox 中使用 RLBox 沙盒,在 论文 《Retrofitting Fine Grain Isolation in the Firefox Renderer》 中有详细介绍。

RLBox 采用了直接在 C++ 类型系统中表达的静态信息流强制和轻量级动态检查。RLBox 通过基于软件的故障隔离(software-based-fault isolation)或多核进程隔离(multi-core process isolation)来支持有效的沙箱。性能开销适中且短暂,并且只有很小对页面延迟的影响。RLBox 使用 WebAssembly 沙盒,已集成到生产 Firefox 中。

RLBox 通过其类型系统和 API 使渲染器沙盒接口处的数据和控制流显式化,以便调解这些流并跨信任边界强制执行安全检查,确保在任何潜在的不安全使用之前验证沙箱数据。另一方面,API 设计用于限制渲染器和沙箱之间的控制传递。例如,渲染器必须使用andbox_invoke() 来调用沙箱中的函数; 沙箱对渲染器的任何回调必须首先由渲染器使用sandbox_callback(callback_fn)API 注册。

RLBox 通过提供一个 tainted<T>数据类型,来包装来自沙箱的任何数据,以此保障控制流和数据流安全:

  1. 自动化安全检查:Swizzling 操作、执行检查以确保沙箱提供的指针指向沙箱内存,以及识别 tainted 数据的位置,都是自动完成的。
  2. 仅在必要时才执行 tainted 数据验证。
  3. 有效地共享数据结构:静态检查确保共享数据在沙盒内存中分配并通过 tainted 类型访问。

另外,RLBox 通过编译时错误强制应用程序开发人员使用sandbox_callback() 函数显式注册回调,因为在沙盒中允许回调任意函数是不安全的。

更多详细内容请查看该论文。

小结

因为 WebAssembly 的特性,和当前存在的安全风险,以及社区中的实践和验证,证明当前基于 WebAssemby 的 SFI 机制是可行的,但是还是有风险的。

并不能单纯依赖 WebAssembly 的沙盒机制来提供 SFI 机制,还需要从 加强 wasm 模块合法性验证、wasm 运行时安全机制、自定义 SFI 实现、减少性能损耗等等各种机制来进一步提供保证才可以。

附录: WebAssembly 应用统计

论文 《An Empirical Study of Real-World WebAssembly Binaries》 从当前 WebAssembly 生态中抽取五万多个 wasm 二进制文件,从使用源语言、使用不安全语言传播的漏洞、是否还有wasm在浏览器中挖矿、WebAssembly 应用领域 这四个角度进行了一份统计。

编写 WebAssembly 的语言占比

5

虽然 Cpp 是最先支持 WebAssembly 的语言,但是 Rust 后来居上,成为第二个使用最多的 WebAssembly 源语言。

使用不安全语言传播的漏洞

6

上图展示了,几乎 三分之二的 wasm 二进制文件使用 非托管栈(线性内存上)。图右 显示了二进制文件中有多少函数至少访问堆栈指针一次。所有二进制文件的 35% 中没有函数使用栈指针。已经有 33% 的函数使用栈指针,而在某些二进制文件中,几乎每个函数都使用非托管堆栈。

结合前文中对 wasm 安全的分析,其实已经得到结论了: WebAssembly 并不会让不安全的语言变得安全。

是否还有wasm在浏览器中挖矿

经过分析,目前在浏览器中利用 wasm 挖矿的应用下降了 99%。可能都去研究如何利用 wasm 漏洞去了。

WebAssembly 应用领域

7

从五万个 wasm 二进制文件中,随机抽取 100 个样本,对 wasm 应用领域做了一份统计。

但我认为这个统计有点偏颇。目前 WebAssembly 最大的应用潜力在于 云计算/边缘计算/分布式计算/ VR领域,但这个统计并没有凸显这点。

目前围绕 WebAssembly 云计算/边缘计算/分布式计算/ VR 涌现出了一些非常优秀的项目。

  • wasmcloud,利用 wasm 构建的分布式/微服务平台,并创造了 waPC 协议来安全方便地进行 guest 和 host 的通信。
  • wagi,利用 WebAssembly 构建 WebAssembly Gateway Interface 。
  • makepad, cloud9 IDE 作者创建的项目,它利用 wasm和webgl 技术为开发者提供一个 VR 开发平台。

华为 | StratoVirt - 基于Rust的 balloon 功能实践

作者: 杨铭


StratoVirt是计算产业中面向云数据中心的企业级虚拟化平台,实现了一套架构统一支持虚拟机、容器、Serverless三种场景。StratoVirt在轻量低噪、软硬协同、Rust语言级安全等方面具备关键技术竞争优势。

背景介绍:

通常,在同一台服务器上存在着不同的用户,而多数用户对内存的使用情况是一种间断性的使用。也就是说用户对内存的使用率并不是很高。在服务器这种多用户的场景中,如果很多个用户对于内存的使用率都不高的话,那么会存在服务器实际占用的内存并不饱满这样一种情况。实际上各个用户使用内存的分布图可能如下图所示(黄色部分表示used部分,绿色部分表示free的部分)。

1

解决方案:

为了解决上述服务器上内存使用率低的问题,可以将虚拟机中暂时不用的内存回收回来给其他虚拟机使用。而当被回收内存的虚拟机需要内存时,由host再将内存归还回去。有了这样的内存伸缩能力,服务器便可以有效提高内存的使用率。在StratoVirt中,我们使用balloon设备来对虚拟机中的空闲内存进行回收和释放。下面详细了解一下StratoVirt中的balloon设备。

balloon设备简介:

由于StratoVirt只是负责为虚拟机分配内存,只能感知到每个虚拟机总的内存大小。但是在每个虚拟机中如何使用内存,内存剩余多少。StratoVirt是无法感知的,也就无法得知该从虚拟机中回收多少内存了。为此,需要在虚拟机中放置一个“气球(balloon)”设备。该设备通过virtio半虚拟化框架来实现前后端通信。当Host端需要回收虚拟机内部的空闲内存时,balloon设备“充气”膨胀,占用虚拟机内部内存。而将占用的内存交给Host使用。如果虚拟机的空闲内存被回收后,虚拟机内部由于业务要求突然需要内存时。位于虚拟机内部的balloon设备可以选择“放气”缩小。释放出更多的内存空间给虚拟机使用。

balloon实现:

balloon的具体代码实现位于StratoVirt项目的/virtio/src/balloon.rs文件中,相关细节可阅读代码理解。代码架构如下:

virtio
├── Cargo.toml
└── src
    ├── balloon.rs
    ├── block.rs
    ├── console.rs
    ├── lib.rs
    ├── net.rs
    ├── queue.rs
    ├── rng.rs
    ├── vhost
    │   ├── kernel
    │   │   ├── mod.rs
    │   │   ├── net.rs
    │   │   └── vsock.rs
    │   └── mod.rs
    └── virtio_mmio.rs

由于balloon是一个virtio设备,所以在前后端通信时也使用了virtio框架提供的virtio queue。当前StratoVirt支持两个队列:inflate virtio queue(ivq)和deflate virtio queue(dvq)。这两个队列分别负责balloon设备的“充气”和“放气”。

气球的充放气时,前后端的信息是通过一个结构体来传递。


#![allow(unused)]
fn main() {
struct VirtioBalloonConfig {
    /// Number of pages host wants Guest to give up.
    pub num_pages: u32,
    /// Number of pages we've actually got in balloon.
    pub actual: u32,
}
}

因此后端向前端要内存的时候,只需要修改这个结构体中的num_pages的数值,然后通知前端。前端读取配置结构体中的num_pages成员。并与本身结构体中的actual对比,判断是进行inflate还是deflate。

  • inflate

如果是inflate,那么虚拟机以4k页为单位去申请虚拟机内存,并将申请到的内存地址保存在队列中。然后通过ivq将保存了分配好的页面地址的数组分批发往后端处理(virtio queue队列长度最大256,也就是一次最多只能传输1M内存信息,对于大于1M的内存只能分批传输)。后端通过得到信息后,找到相应的MemoryRegion,将对应的page标记为”WILLNEED“。然后通知前端,完成配置。

  • deflate

如果是deflate则从保存申请到的内存地址队列中弹出一部分内存的地址。通过dvq分批次传输给后端处理。后端将page标记为“DONTNEED"。

下面结合代码进行说明:

定义BalloonIoHandler结构体作为处理balloon事件的主体。


#![allow(unused)]
fn main() {
struct BalloonIoHandler {
    /// The features of driver.
    driver_features: u64,
    /// Address space.
    mem_space: Arc<AddressSpace>,
    /// Inflate queue.
    inf_queue: Arc<Mutex<Queue>>,
    /// Inflate EventFd.
    inf_evt: EventFd,
    /// Deflate queue.
    def_queue: Arc<Mutex<Queue>>,
    /// Deflate EventFd.
    def_evt: EventFd,
    /* 省略 */
}
}

其中包含上述的两个virtio队列inf_queuedef_queue,以及对应的触发事件描述符(EventFd)inf_evtdef_evt。两个队列均使用了Mutex锁,保证了队列在同一时刻只有一个使用者对该队列进行操作。保证了多线程共享的数据安全。


#![allow(unused)]
fn main() {
fn process_balloon_queue(&mut self, req_type: bool) -> Result<()> {
    let queue = if req_type {
        &mut self.inf_queue
    } else {
        &mut self.def_queue
    }; //获得对应的队列
    let mut unlocked_queue = queue.lock().unwrap();
    while let Ok(elem) = unlocked_queue
        .vring
        .pop_avail(&self.mem_space, self.driver_features)
    {
        match Request::parse(&elem) {
            Ok(req) => {
                if !self.mem_info.has_huge_page() {
                    // 进行内存标记
                    req.mark_balloon_page(req_type, &self.mem_space, &self.mem_info);
                }
                /* 省略 */
            }
            Err(e) => {
                /* 省略错误处理 */
            }
        }
    }
    /* 省略 */
}
}

当相应的EventFd被触发后process_balloon_queue函数将会被调用。通过判断请求类型确定是“充气”还是”放气“,然后再从相应的队列中取数据进行内存标记。其中while let是Rust语言提供的一种循环模式匹配机制。借助该语法可以将队列中pop出来的所有数据遍历取出到elem中。

内存标记及优化:

标记内存在mark_balloon_page函数中进行实现,起初的实现思路为:将虚拟机传送过来的地址逐个进行标记。即,从队列中取出一个元素,转化为地址后立即进行标记。后来经过测试发现:balloon设备在对页地址进行一页一页标记内存时花费时间巨大。而同时也发现通过虚拟机传回来的地址中有大段的连续内存段。于是通过改变标记方法:由原来的一页一页标记改为将这些连续的内存统一标记。大大节省了标记时间。下面代码为具体实现:


#![allow(unused)]
fn main() {
fn mark_balloon_page(
        &self,
        req_type: bool,
        address_space: &Arc<AddressSpace>,
        mem: &BlnMemInfo,
    ) {
        let advice = if req_type {
            libc::MADV_DONTNEED
        } else {
            libc::MADV_WILLNEED
        };
        /* 略 */
        for iov in self.iovec.iter() {
            let mut offset = 0;
            let mut hvaset = Vec::new();
            while let Some(pfn) = iov_to_buf::<u32>(address_space, iov, offset) {
                offset += std::mem::size_of::<u32>() as u64;
                let gpa: GuestAddress = GuestAddress((pfn as u64) << VIRTIO_BALLOON_PFN_SHIFT);
                let hva = match mem.get_host_address(gpa) {
                    Some(addr) => addr,
                    None => {
                        /* 略 */
                    }
                };
                //将hva地址保存在hvaset的vec中
                hvaset.push(hva);
            }
            //对hvaset进行从小到大排序。
            hvaset.sort_by_key(|&b| Reverse(b));
            /* 略 */
                //将hvaset中连续的内存段进行标记
                while let Some(hva) = hvaset.pop() {
                    if last_addr == 0 {
                        free_len += 1;
                        start_addr = hva;
                    } else if hva == last_addr + BALLOON_PAGE_SIZE {
                        free_len += 1;
                    } else {
                        memory_advise(
                            start_addr as *const libc::c_void as *mut _,
                            (free_len * BALLOON_PAGE_SIZE) as usize,
                            advice,
                        );
                        free_len = 1;
                        start_addr = hva;
                    }

                    if count_iov == iov.iov_len {
                        memory_advise(
                            start_addr as *const libc::c_void as *mut _,
                            (free_len * BALLOON_PAGE_SIZE) as usize,
                            advice,
                        );
                    }
                    count_iov += std::mem::size_of::<u32>() as u64;
                    last_addr = hva;
                }
            /* 略 */
        }
    }
}
}

首先将virtio队列中的地址全部取出,并保存在vec中,然后将该vec进行从小到大的排序。有利于快速找出连续的内存段并进行标记。由于hvaset中的地址是按照从小到大排列的,因此可以从头开始遍历hvaset,遇到不连续的地址后将前面的连续段进行标记。这样就完成了由原来逐页标记到连续内存段统一标记的优化。

经过测试,StratoVirt的balloon速度也有了极大的提高。

关注我们

StratoVirt当前已经在openEuler社区(openEuler是一个开源、免费的Linux发行版平台,将通过开放的社区形式与全球的开发者共同构建一个开放、多元和架构包容的软件生态体系)开源。在未来的一段时间我们将开展一系列主题的分享,让大家更加详细的了解StratoVirt实现,非常期待您的围观和加入!

项目地址:https://gitee.com/openeuler/stratovirt

项目wiki:https://gitee.com/openeuler/stratovirt/wikis

项目交流:virt邮件列表或是提交一个issue

蚂蚁集团 | Rust 数据内存布局

作者:worcsrcsgg


背景

我们团队同学 Jiacai Liu 在之前文章提到指向 trait obj 的指针为一个胖指针: >Rust 使用 fat pointer(即两个指针) 来表示 trait object 的引用,分别指向 data 与 vtable。 1

另外团队中使用了一些调用 C 库 lib,比如 rust-rocksdb 库,里面封装 C 的数据结构会频繁出现 #[repr(C)]。

本文是在以上两个问题引申下展开的,学习探讨下 Rust 数据类型的内存布局。

主要分为两个部分,一是 Rust 中基本的数据类型内存布局,二是内存布局的表示。

常用类型

类型的布局是其大小(size)、对齐方式(align)及其字段的相对偏移量。对于枚举,如何布局和解释判别式也是类型布局的一部分。 对于 Sized 的数据类型,可以在编译时知道内存布局,可以通过 size_ofalign_of 获得其 size 和 align。

The layout of a type is its size, alignment, and the relative offsets of its fields. 
For enums, how the discriminant is laid out and interpreted is also part of type layout.
Type layout can be changed with each compilation.

数字类型

整数类型

TypeMinimumMaximumsize(bytes)align(bytes)
u8028-111
u160216-122
u320232-144
u640264-188
u12802128-11616
TypeMinimumMaximumsize(bytes)align(bytes)
i8-(27)27-111
i16-(215)215-122
i32-(231)231-144
i64-(263)263-188
i128-(2127)2127-11616

浮点数

The IEEE 754-2008 "binary32" and "binary64" floating-point types are f32 and f64, respectively.

Typesize(bytes)align(bytes)
f3244
f6488

f64 在 x86 系统上对齐到 4 bytes。

usized & isized

usize 无符号整形,isize 有符号整形。 在 64 位系统上,长度为 8 bytes,在 32 位系统上长度为 4 bytes。

bool

bool 类型,取值为 true 或 false,长度和对齐长度都是 1 byte。

array


#![allow(unused)]
fn main() {
let array: [i32; 3] = [1, 2, 3];
}

数组的内存布局为系统类型元组的有序组合。

size 为 n*size_of::<T>()
align 为 align_of::<T>()

str

char 类型

char 表示:一个 32 位长度字符,Unicode 标量值 Unicode Scalar Value 范围为 in the 0x0000 - 0xD7FF 或者是 0xE000 - 0x10FFFF。

str 类型

str 与 [u8] 一样表示一个 u8 的 slice。Rust 中标准库中对 str 有个假设:符合 UTF-8 编码。内存布局与 [u8] 相同。

slice

slice 是 DST 类型,是类型 T 序列的一种视图。 slice 的使用必须要通过指针,&[T] 是一个胖指针,保存指向数据的地址和元素个数。 slice 的内存布局与其指向的 array 部分相同。

&str 和 String 的区别

下面给出 &str String 的内存结构比对:


#![allow(unused)]
fn main() {
let mut my_name = "Pascal".to_string();
my_name.push_str( " Precht");

let last_name = &my_name[7..];
}

String

                     buffer
                   /   capacity
                 /   /  length
               /   /   /
            +–––+–––+–––+
stack frame │ • │ 8 │ 6 │ <- my_name: String
            +–│–+–––+–––+
              │
            [–│–––––––– capacity –––––––––––]
              │
            +–V–+–––+–––+–––+–––+–––+–––+–––+
       heap │ P │ a │ s │ c │ a │ l │   │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+

            [––––––– length ––––––––]

String vs &str

         my_name: String   last_name: &str
            [––––––––––––]    [–––––––]
            +–––+––––+––––+–––+–––+–––+
stack frame │ • │ 16 │ 13 │   │ • │ 6 │ 
            +–│–+––––+––––+–––+–│–+–––+
              │                 │
              │                 +–––––––––+
              │                           │
              │                           │
              │                         [–│––––––– str –––––––––]
            +–V–+–––+–––+–––+–––+–––+–––+–V–+–––+–––+–––+–––+–––+–––+–––+–––+
       heap │ P │ a │ s │ c │ a │ l │   │ P │ r │ e │ c │ h │ t │   │   │   │
            +–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+–––+

struct

结构体是带命名的复合类型,有以下几种 struct: StructExprStruct


#![allow(unused)]
fn main() {
struct A {
    a: u8,
}
}

StructExprTuple


#![allow(unused)]
fn main() {
struct Position(i32, i32, i32);
}

StructExprUnit


#![allow(unused)]
fn main() {
struct Gamma;
}

详细内存布局见第二部分数据布局-数据对齐。

tuple

元组是匿名的复合类型,有以下几种 tuple:

() (unit)
(f64, f64)
(String, i32)
(i32, String) (different type from the previous example)
(i32, f64, Vec<String>, Option<bool>)

tuple 的结构和 Struct 一致,只是元素是通过 index 进行访问的。

closure

闭包相当于一个捕获变量的结构体,实现了 FnOnce 或 FnMut 或 Fn。


#![allow(unused)]
fn main() {
fn f<F : FnOnce() -> String> (g: F) {
    println!("{}", g());
}

let mut s = String::from("foo");
let t = String::from("bar");

f(|| {
    s += &t;
    s
});
// Prints "foobar".
}

生成一个闭包类型:


#![allow(unused)]
fn main() {
struct Closure<'a> {
    s : String,
    t : &'a String,
}

impl<'a> FnOnce<()> for Closure<'a> {
    type Output = String;
    fn call_once(self) -> String {
        self.s += &*self.t;
        self.s
    }
}
f(Closure{s: s, t: &t});
}

union

union 的关键特性是 union 的所有字段共享公共存储。因此,对 union 的一个字段的写入可以覆盖其其他字段,union 的大小由其最大字段的大小决定。


#![allow(unused)]
fn main() {
#[repr(C)]
union MyUnion {
    f1: u32,
    f2: f32,
}
}

每个 union 访问都只是在用于访问的字段的类型上解释存储。读取并集字段读取字段类型处的并集位。字段可能具有非零偏移量(除非使用C表示法);在这种情况下,从字段偏移量开始的位被读取。程序员有责任确保数据在字段的类型上是有效的。否则会导致未定义的行为。比如读取整数 3,但是需要转换为 bool 类型,则会出错。

enum


#![allow(unused)]
fn main() {
enum Animal {
    Dog(String, f64),
    Cat { name: String, weight: f64 },
}

let mut a: Animal = Animal::Dog("Cocoa".to_string(), 37.2);
a = Animal::Cat { name: "Spotty".to_string(), weight: 2.7 };
}

枚举项声明类型和许多变体,每个变体都独立命名,并且具有struct、tuple struct或unit-like struct的语法。 enum 是带命名的标签联合体,因此其值消耗的内存是对应枚举类型的最大变量的内存,以及存储判别式所需的大小。


#![allow(unused)]
fn main() {
use std::mem;

enum Foo { A(&'static str), B(i32), C(i32) }

assert_eq!(mem::discriminant(&Foo::A("bar")), mem::discriminant(&Foo::A("baz")));
assert_eq!(mem::discriminant(&Foo::B(1)), mem::discriminant(&Foo::B(2)));
assert_ne!(mem::discriminant(&Foo::B(3)), mem::discriminant(&Foo::C(3)));
}

#![allow(unused)]
fn main() {
enum Foo {
    A(u32),
    B(u64),
    C(u8),
}
struct FooRepr {
    data: u64, // 根据tag的不同,这一项可以为u64,u32,或者u8
    tag: u8, // 0 = A, 1 = B, 2 = C
}
}

trait obj

官方定义:

A trait object is an opaque value of another type that implements a set of traits. 
The set of traits is made up of an object safe base trait plus any number of auto traits.  

trait obj 是 DST 类型,指向 trait obj 的指针也是个胖纸针,分别指向 data 和 vtable。更详细描述可以参考 2

Dynamically Sized Types(DST)

一般来说大多数类型,可以在编译阶段确定大小和对齐属性,Sized trait 就是保证了这种特性。非 size (?Sized)及 DST 类型。DST 类型有 slice 和 trait obj。DST 类型必须通过指针来使用。 需要注意:

  1. DST 可以作为泛型参数,但是需要注意泛型参数默认是 Sized,如果是 DST 类型需要特别的指定为 ?Sized。
struct S {
    s: i32
}

impl S {
    fn new(i: i32) -> S {
        S{s:i}
    }
}

trait T {
    fn get(&self) -> i32;
}

impl T for S {
    fn get(&self) -> i32 { 
        self.s
    }
}

fn test<R: T>(t: Box<R>) -> i32 {
    t.get()
}


fn main() {
    let t: Box<T> = Box::new(S::new(1));
    let _ = test(t);
}

编译报错

error[E0277]: the size for values of type `dyn T` cannot be known at compilation time
   |
21 | fn test<R: T>(t: Box<R>) -> i32 {
   |         - required by this bound in `test`
...
28 |     let _ = test(t);
   |                  ^ doesn't have a size known at compile-time
   |
   = help: the trait `Sized` is not implemented for `dyn T`
help: consider relaxing the implicit `Sized` restriction
   |
21 | fn test<R: T + ?Sized>(t: Box<R>) -> i32 {
   |              ^^^^^^^^

fix it


#![allow(unused)]
fn main() {
fn test<R: T + ?Sized>(t: Box<R>) -> i32 {
    t.get()
}
}
  1. trait 默认实现了 ?Sized.
  2. 结构体实际上可以直接存储一个DST作为它们的最后一个成员字段,但这也使该结构体成为DST。可以参考DST 进一步了解自定义 DST。

零尺寸类型 (ZST, Zero Sized Type)


#![allow(unused)]
fn main() {
struct Nothing; // No fields = no size

// All fields have no size = no size
struct LotsOfNothing {
    foo: Nothing,
    qux: (),      // empty tuple has no size
    baz: [u8; 0], // empty array has no size
}
}

ZST 的一个最极端的例子是 Set 和 Map。已经有了类型 Map<Key, Value>,那么要实现 Set<Key, Value>的通常做法是简单封装一个 Map<Key, UselessJunk>。很多语言不得不给 UselessJunk 分配空间,还要存储、加载它,然后再什么都不做直接丢弃它。编译器很难判断出这些行为实际是不必要的。 但是在 Rust 里,我们可以直接认为 Set<Key> = Map<Key, ()>。Rust 静态地知道所有加载和存储操作都毫无用处,也不会真的分配空间。结果就是,这段范型代码直接就是 HashSet 的一种实现,不需要 HashMap 对值做什么多余的处理。

空类型(Empty Types)


#![allow(unused)]
fn main() {
enum Void {} // No variants = EMPTY
}

空类型的一个主要应用场景是在类型层面声明不可到达性。假如,一个 API 一般需要返回一个 Result,但是在特殊情况下它是绝对不会运行失败的。这种情况下将返回值设为 Result<T, Void>,API 的调用者就可以信心十足地使用 unwrap,因为不可能产生一个 Void 类型的值,所以返回值不可能是一个 Err。

数据布局

数据对齐

数据对齐对 CPU 操作及缓存都有较大的好处。Rust 中结构体的对齐属性等于它所有成员的对齐属性中最大的那个。Rust 会在必要的位置填充空白数据,以保证每一个成员都正确地对齐,同时整个类型的尺寸是对齐属性的整数倍。例如:


#![allow(unused)]
fn main() {
struct A {
    a: u8,
    b: u32,
    c: u16,
}
}

打印下变量地址,可以根据结果看到对齐属性为 4, 结构大小为 8 byte 。

fn main() {
    let a = A {
        a: 1,
        b: 2,
        c: 3,
    };
    println!("0x{:X} 0x{:X} 0x{:X}", &a.a as *const u8 as usize, &a.b as *const u32 as usize , &a.c as *const u16 as usize )
}

0x7FFEE6769276 
0x7FFEE6769270 
0x7FFEE6769274

Rust 中数据对齐


#![allow(unused)]
fn main() {
struct A {
    b: u32,
    c: u16,
    a: u8,
    _pad: [u8; 1],
}
}

编译器优化

来看下面这个结构


#![allow(unused)]
fn main() {
struct Foo<T, U> {
    count: u16,
    data1: T,
    data2: U,
}
}
fn main() {
    let foo1 = Foo::<u16, u32> {
        count: 1,
        data1: 2,
        data2: 3,
    };

    let foo2 = Foo::<u32, u16> {
        count: 1,
        data1: 2,
        data2: 3,
    };

    println!("0x{:X} 0x{:X} 0x{:X}", &foo1.count as *const u16 as usize, &foo1.data1 as *const u16 as usize, &foo1.data2 as *const u32 as usize);
    println!("0x{:X} 0x{:X} 0x{:X}", &foo2.count as *const u16 as usize, &foo2.data1 as *const u32 as usize, &foo2.data2 as *const u16 as usize);
}
0x7FFEDFDD61C4 0x7FFEDFDD61C6 0x7FFEDFDD61C0
0x7FFEDFDD61CC 0x7FFEDFDD61C8 0x7FFEDFDD61CE

foo1字段顺序:data2(0), count(4), data1(6) foo1字段顺序:data1(8), count(c), data2(e) 可以看到编译器会改变 Foo<T, U> 中成员顺序。内存优化原则要求不同的范型可以有不同的成员顺序。 如果不优化的可能会造成如下情况,造成大量内存开销:


#![allow(unused)]
fn main() {
struct Foo<u16, u32> {
    count: u16,
    data1: u16,
    data2: u32,
}

struct Foo<u32, u16> {
    count: u16,
    _pad1: u16,
    data1: u32,
    data2: u16,
    _pad2: u16,
}
}

repr(C)

repr(C) 目的很简单,就是为了内存布局和 C 保持一致。需要通过 FFI 交互的类型都应该有 repr(C)。而且如果我们要在数据布局方面玩一些花活的话,比如把数据重新解析成另一种类型,repr(C) 也是很有必要的。更深入了解可以参考repr(C)

repr(u) repr(i)

这两个可以指定无成员枚举的大小。包括:u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, and isize.

enum Enum {
    Variant0(u8),
    Variant1,
}

#[repr(C)]
enum EnumC {
    Variant0(u8),
    Variant1,
}

#[repr(u8)]
enum Enum8 {
    Variant0(u8),
    Variant1,
}

#[repr(u16)]
enum Enum16 {
    Variant0(u8),
    Variant1,
}

fn main() {
    assert_eq!(std::mem::size_of::<Enum>(), 2);
    // The size of the C representation is platform dependant
    assert_eq!(std::mem::size_of::<EnumC>(), 8);
    // One byte for the discriminant and one byte for the value in Enum8::Variant0
    assert_eq!(std::mem::size_of::<Enum8>(), 2);
    // Two bytes for the discriminant and one byte for the value in Enum16::Variant0
    // plus one byte of padding.
    assert_eq!(std::mem::size_of::<Enum16>(), 4);
}

repr(align(x)) repr(pack(x))

align 和 packed 修饰符可分别用于提高或降低结构和联合的对齐。packed 还可能改变字段之间的填充。 align 启用了一些技巧,比如确保数组的相邻元素之间永远不会共享同一缓存线(这可能会加速某些类型的并发代码)。 pack 不能轻易使用。除非有极端的要求,否则不应使用。

#[repr(C)]
struct A {
    a: u8,
    b: u32,
    c: u16,
}

#[repr(C, align(8))]
struct A8 {
    a: u8,
    b: u32,
    c: u16,
}

fn main() {
    let a = A {
        a: 1,
        b: 2,
        c: 3,
    };
    println!("{}", std::mem::align_of::<A>());
    println!("{}", std::mem::size_of::<A>());
    println!("0x{:X} 0x{:X} 0x{:X}", &a.a as *const u8 as usize, &a.b as *const u32 as usize, &a.c as *const u16 as usize);


    let a = A8 {
        a: 1,
        b: 2,
        c: 3,
    };
    println!("{}", std::mem::align_of::<A8>());
    println!("{}", std::mem::size_of::<A8>());
    println!("0x{:X} 0x{:X} 0x{:X}", &a.a as *const u8 as usize, &a.b as *const u32 as usize, &a.c as *const u16 as usize);
}
  
 结果:
4
12
0x7FFEE7F0B070 0x7FFEE7F0B074 0x7FFEE7F0B078
8
16
0x7FFEE7F0B1A0 0x7FFEE7F0B1A4 0x7FFEE7F0B1A8
  
#[repr(C)]
struct A {
    a: u8,
    b: u32,
    c: u16,
}

#[repr(C, packed(1))]
struct A8 {
    a: u8,
    b: u32,
    c: u16,
}

fn main() {
    let a = A {
        a: 1,
        b: 2,
        c: 3,
    };
    println!("{}", std::mem::align_of::<A>());
    println!("{}", std::mem::size_of::<A>());
    println!("0x{:X} 0x{:X} 0x{:X}", &a.a as *const u8 as usize, &a.b as *const u32 as usize, &a.c as *const u16 as usize);


    let a = A8 {
        a: 1,
        b: 2,
        c: 3,
    };
    println!("{}", std::mem::align_of::<A8>());
    println!("{}", std::mem::size_of::<A8>());
    println!("0x{:X} 0x{:X} 0x{:X}", &a.a as *const u8 as usize, &a.b as *const u32 as usize, &a.c as *const u16 as usize);
}

结果:
4
12
0x7FFEED627078 0x7FFEED62707C 0x7FFEED627080
1
7
0x7FFEED6271A8 0x7FFEED6271A9 0x7FFEED6271AD

repr(transparent)

repr(transparent) 使用在只有单个 field 的 struct 或 enum 上,旨在告诉 Rust 编译器新的类型只是在 Rust 中使用,新的类型(struc 或 enum)需要被 ABI 忽略。新的类型的内存布局应该当做单个 field 处理。

The attribute can be applied to a newtype-like structs that contains a single field. 
It indicates that the newtype should be represented exactly like that field's type, i.e., 
the newtype should be ignored for ABI purpopses: not only is it laid out the same in memory, it is also passed identically in function calls.

Structs and enums with this representation have the same layout and ABI as the single non-zero sized field.

总结

以上内容为 Rust 中常见数据类型内存布局描述,如有错误,欢迎指正~

参考

  1. Type system
  2. Data Layout
  3. Item
  4. String vs &str in Rust
  5. 数据布局
  6. enter-reprtransparent

关于我们

我们是蚂蚁智能监控技术中台的时序存储团队,我们正在使用 Rust 构建高性能、低成本并具备实时分析能力的新一代时序数据库,欢迎加入或者推荐,请联系:jiachun.fjc@antgroup.com

Datenlord | Rust 语言无锁数据结构的内存管理

作者: 施继成


无锁数据结构内存管理

正如大家所熟知的,无锁数据结构在并发访问中往往具有更好的访问效率和并发度。无锁数据结构的性能优势主要来自于以下两点:

  1. 数据结构的锁设计往往比较粗粒度,在很多可以并发访问的情况下,访问者被锁阻塞,无法实现并发访问。
  2. 无锁数据结构访问不需要进行上下文切换,有锁数据结构在并发度高的时候往往会触发操作系统上下文切换。

但是无锁数据结构也带来了新的问题,即内存管理问题。举个例子:当线程 A 读取一块数据的时候,线程 B 要释放该数据块。在有锁数据结构中,这两个操作被串行了起来;无锁数据结构由于缺乏锁的保护,这两个操作可能同时进行。为了保证线程 A 访问数据的正确性,线程 B 的释放操作必须要延后执行,直到 A 完成了读取操作。为了达到上述延后释放内存的目的,大家一般采用下列的几种方法:

  1. 语言本身的 GC 支持,如带有虚拟机 runtime 的语言,如 Java。
  2. 引用计数(Reference Count)。
  3. 基于代际的内存释放机制(Epoch-Based Reclamation),本文之后简称EBR。

语言本身的 GC 机制一方面有语言的限制,另外一方面全局的 GC 往往会造成一定的性能损失,程序执行Latency不稳定。引用计数本身的性能开销不可忽视,特别是在读取操作较多的场景下,仅仅为了保护数据安全,每次读取都需要进行计数增加,读完了再进行计数减少,高并发的情况下效率不乐观。EBR则规避了上述问题,一方面不需要语言层面的规约,另外一方面执行效率也相对更好。这里为大家简单介绍一下 EBR, 更加详细的解释请参见论文《 Practical lock-freedom 》。

Epoch-Based Reclamation

在 EBR 的概念中有代际(Epoch)的概念,Epoch 为数字,其代表了当前处于第几世代,该数字单调递增。全局具有一个Global Epoch, 代表全局当前是第几世代。每个线程的每次数据结构的访问都包含一个Epoch,即Local Epoch,表示当前线程处在第几代。有了这些概念我们来看一下下面的例子,就能够理解 EBR 的工作原理了。

如下图中的例子,线程 A 和 B 并发地访问无锁数据中的内存块,自上而下为时间的流逝方向。在时间点 1 之前 Global Epoch 为 0。

epoch example

  • 时间节点1:线程 A 发现没有其他线程正在发给访问该数据结构,将Global Epoch 加 1,变成 1。同时线程 A Local Epoch 设置为 1.
  • 时间节点2:线程 B 删除数据块 M,因为 B 发现只有线程 A 在访问数据结构,且 A 的Epoch 和 Global Epoch相等,都是1。线程 B 将 Global Epoch 再加 1,变成 2。B 线程 Local Epoch 和 Global Epoch 同步,也为 2. 由于 Epoch 的删除操作是延后的,需要放到一个收集器里,于是数据块 M 被放到收集器中,标记为 Epoch 1,意味着这个数据只有可能在Epoch 1中被使用,从 Epoch 2 开始数据结构中在没有数据块M(被线程 B 删除)。
  • 时间节点3:线程 B 访问数据块 N,发现 Global Epoch 为2,线程 A 的 Epoch 为 1,则 B 标记自己的 Local Epoch 为2,与 Global Epoch 一致。
  • 时间节点4 和 5:线程 A 和 B 都表示自己已经结束了数据访问,不再被数据结构追溯。
  • 时间节点6:线程 A 也开始访问数据块 N,当前 Global Epoch 为 2,且没有其他线程访问该数据块,则线程 A 增加 Global Epoch 到 3,标记自己Local Epoch 为 3。同时线程 A 发现收集器中有一个 Epoch 为 1 的数据块 M,比当前Global Epoch相差了两个世代,可以被删除,数据块 M 被释放。
  • 时间节点7:线程 A 表示自己结束了数据访问,不再被数据结构追溯。

通过上面的例子我们不难发现,被访问的数据只可能存在于两个 Epoch 中,一个为当前 Epoch,即 Global Epoch,另一个为前一个 Epoch,即 (Global Epoch - 1)。所有被标记了更早 Epoch 的数据都可以被删除,即收集器中被标记为小于 (Global Epoch - 1)的数据块。

分析一下 EBR 的算法,我们能够发现其性能优越性的根本原因在于数据回收的粗粒度管理。在 Reference Count 的方法中,并发度越高,对 Counter 的修改就越密集,竞争越大,性能越差。在 EBR 中,并发度高会造成几乎所有线程都处于一个 Epoch,并不需要对 Global Epoch 进行修改,也就避免了这方面的竞争,性能也就更好。当然 EBR 也存在其自身的问题,当某些原因导致一个访问操作无法结束时,则 Global Epoch 永远无法向前推进,也就永远无法触发垃圾回收,内存泄露就不可避免了。

综上所述,即使存在一些缺陷,EBR 极好的性能优势使其成为了高性能无锁数据结构实现的首选。

Rust 语言实现 EBR

通过上述对 EBR 的分析,我们不难看出, EBR 需要知道数据访问起始点,配合起始点控制 Epoch 的迭代。其他语言有自己的封装和实现方法,而 Rust 的生命周期的概念则从语言层面提供了帮助。基于这个优势,Rust 语言天生适合实现EBR,并且已经有了一个成熟的实现版本,即 crossbeam epoch。这里不会对该实现做源码级的分析,而是会尝试将框架 API 和 EBR 的相关概念进行对应,帮助大家理解。

这里是示范代码,是无锁数据结构使用 epoch 最简单的方法:


#![allow(unused)]
fn main() {
{
    let guard = epoch::pin();
    guard.defer(move || mem.release());
}
}

第一行表示当前线程开始访问访问该数据结构,可能是读取可能是写入。第二行表示,延迟释放一块内存,具体何时释放,由 EBR 算法来决定。当整个代码块执行完成,表示退出数据结构访问,guard 的 drop 方法会将当前线程从监测的队列中注销。

再例如,Datenlord 中实现和使用的无锁Hashmap, cuckoohash,其接口为:


#![allow(unused)]
fn main() {
{
    let guard = pin();
    let value = map.get(&key, &guard);
    /// ... Use the value
}
}

第一行和前面的例子类似,第二行的语义为从 map 中寻找 key 对应的 value,获取 value 的引用,其生命周期不超过 guard 的生命周期。通过生命周期的方法,我们限定了 value 引用的使用范围为 guard 的存活范围。

总结

本文简单介绍了 Epoch-Based Reclamation 内存管理方法,并且从接口层面介绍了 Rust 的实现和使用。同时本文也分析了 EBR 在性能上的优越性,以及 Rust 语言从语言实现的优势。之后我们还会从 crossbeam epoch 的实现细节给大家带来深入的 Rust EBR 实现的分析。

Second State | 用 Docker 工具管理 Rust 函数

作者:夏歌


开发者可以通过 DockerHub 和 CRI-O 等 Docker 工具在 WasmEdge 中部署、管理和运行轻量级 WebAssembly 应用程序。

WasmEdge 是由 CNCF (Cloud Native Computing Foundation) 托管的 WebAssembly 运行时,是边缘计算应用程序的执行沙箱。

虽然 WebAssembly 最初是作为浏览器应用程序的运行时而发明的,但其轻量级和高性能的沙箱设计使其成为通用应用程序容器的一个极具吸引力的选择。

如果在 2008 年已经有了 WASM + WASI,那么我们压根无需创始 Docker 这个项目了。 — Docker 联合创始人 Solomon Hykes

与 Docker 相比, WebAssembly 在启动时快一百倍, 占用更小的内存和磁盘空间,并且具有更优定义的安全沙箱。然而,缺点是 WebAssembly 需要自己的语言 SDK 和编译器工具链,使其作为开发者环境比 Docker 更受限制。WebAssembly 越来越多地用于边缘计算场景,通常这些场景中,部署 Docker 比较困难,或是应用程序的性能至关重要。

Docker 的一大优势是其丰富的工具生态系统。我们希望为 WasmEdge 开发者带来类似 Docker 的工具。为了实现这一点,我们为 CRI-O 创建了一个名为 runw 的替代 runner 来加载并运行 WebAssembly 字节码程序,如同他们是 Docker 镜像文件一样。

在 CRI-O 中安装 WebAssembly runner

为了在 CRI-O 中支持 WebAssembly,您只需下载 runw 二进制码发布并将其安装到您的 CRI-O 中。

因为 runw 二进制码已经包括了 WasmEdge,无需单独安装 WasmEdge 或任何其它 WebAssembly VM。

首先,确保你使用的是安装了 LLVM-10 的 Ubuntu 20.04。如果你使用的是不同的平台,请参阅如何为你的操作系统创建 runw 项目文档。

sudo apt install -y llvm-10-dev liblld-10-dev

确保你安装了 cri-ocrictlcontainernetworking-pluginsbuildah 或者 docker

下一步,下载 runw binary build

wget https://github.com/second-state/runw/releases/download/0.1.0/runw

现在,你可以安装 runw 进 CRI-O 作为 WebAssembly 的备选方案。

# Get the wasm-pause utility
sudo crictl pull docker.io/beststeve/wasm-pause

# Install runw into cri-o
sudo cp -v runw /usr/lib/cri-o-runc/sbin/runw
sudo chmod +x /usr/lib/cri-o-runc/sbin/runw
sudo sed -i -e 's@default_runtime = "runc"@default_runtime = "runw"@' /etc/crio/crio.conf
sudo sed -i -e 's@pause_image = "k8s.gcr.io/pause:3.2"@pause_image = "docker.io/beststeve/wasm-pause"@' /etc/crio/crio.conf
sudo sed -i -e 's@pause_command = "/pause"@pause_command = "pause.wasm"@' /etc/crio/crio.conf
sudo tee -a /etc/crio/crio.conf.d/01-crio-runc.conf <<EOF
[crio.runtime.runtimes.runw]
runtime_path = "/usr/lib/cri-o-runc/sbin/runw"
runtime_type = "oci"
runtime_root = "/run/runw"
EOF

最后,重启 cri-o ,从而使新的 WebAssembly runner 开始生效。

sudo systemctl restart crio

用 Rust 构建 Wasm 应用程序

下面案例中的 Wasm 应用程序是 Rust 写的。为了让这些程序工作,确保你安装了 Rustrustwasmc 工具链。

你需要 Rust 编译器和 rustwasmc 来构建 Rust 源成为 wasm 字节码文件。如果你已经有一个 wasm字节码程序,且只是想要用 cri-o 跑一遍,你可以跳过这个部分。

应用程序源代码仅为一个 main.rs 函数。在此处。该应用程序演示了如何使用标准 Rust API 从 WasmEdge 访问文件系统和其它操作系统资源。

fn main() {
  println!("Random number: {}", get_random_i32());
  println!("Random bytes: {:?}", get_random_bytes());
  println!("{}", echo("This is from a main function"));
  print_env();
  create_file("/tmp.txt", "This is in a file");
  println!("File content is {}", read_file("/tmp.txt"));
  del_file("/tmp.txt");
}

pub fn get_random_i32() -> i32 {
  let x: i32 = random();
  return x;
}

pub fn get_random_bytes() -> Vec<u8> {
  let mut rng = thread_rng();
  let mut arr = [0u8; 128];
  rng.fill(&mut arr[..]);
  return arr.to_vec();
}

pub fn echo(content: &str) -> String {
  println!("Printed from wasi: {}", content);
  return content.to_string();
}

pub fn print_env() {
  println!("The env vars are as follows.");
  for (key, value) in env::vars() {
    println!("{}: {}", key, value);
  }

  println!("The args are as follows.");
  for argument in env::args() {
    println!("{}", argument);
  }
}

pub fn create_file(path: &str, content: &str) {
  let mut output = File::create(path).unwrap();
  output.write_all(content.as_bytes()).unwrap();
}

pub fn read_file(path: &str) -> String {
  let mut f = File::open(path).unwrap();
  let mut s = String::new();
  match f.read_to_string(&mut s) {
    Ok(_) => s,
    Err(e) => e.to_string(),
  }
}

pub fn del_file(path: &str) {
  fs::remove_file(path).expect("Unable to delete");
}

你可以通过下面的命令行将应用程序构建到一个 wasm 字节码文件中。

rustwasmc build

wasm 字节码文件 在这里。

为 Wasm app 构建并发布一个 Docker Hub 镜像

您现在可以将整个 wasm 字节码文件发布到 Docker hub 中,就好像这是一个 Docker 镜像一样。

首先,在 pkg/ 目录中创建一个 Dockerfile,如下所示。

FROM scratch
ADD wasi_example_main.wasm .
CMD ["wasi_example_main.wasm"]

创建一个镜像并发布到 Docker hub。

sudo buildah bud -f Dockerfile -t wasm-wasi-example
sudo buildah push wasm-wasi-example docker://registry.example.com/repository:tag

# Example: the following command publishes the wasm image to the public Docker hub under user account "hydai"
sudo buildah push wasm-wasi-example docker://docker.io/hydai/wasm-wasi-example:latest

现在,你可以使用 Docker 工具(例如 crictl)将发布的 wasm 文件拉为镜像。 下面是我们发布的 wasm 文件镜像的示例。

sudo crictl pull docker.io/hydai/wasm-wasi-example

使用 CRI-O 启动 Wasm app

要启动并运行 wasm 文件,您需要为 CRI-O 创建两个配置文件。 创建一个 container_wasi.json 文件,如下所示。 它告诉 CRI-O 运行时应该从 Docker 存储库的哪里提取 wasm 文件映像。

{
  "metadata": {
    "name": "podsandbox1-wasm-wasi"
  },
  "image": {
    "image": "hydai/wasm-wasi-example:latest"
  },
  "args": [
    "wasi_example_main.wasm", "50000000"
  ],
  "working_dir": "/",
  "envs": [],
  "labels": {
    "tier": "backend"
  },
  "annotations": {
    "pod": "podsandbox1"
  },
  "log_path": "",
  "stdin": false,
  "stdin_once": false,
  "tty": false,
  "linux": {
    "resources": {
      "memory_limit_in_bytes": 209715200,
      "cpu_period": 10000,
      "cpu_quota": 20000,
      "cpu_shares": 512,
      "oom_score_adj": 30,
      "cpuset_cpus": "0",
      "cpuset_mems": "0"
    },
    "security_context": {
      "namespace_options": {
        "pid": 1
      },
      "readonly_rootfs": false,
      "capabilities": {
        "add_capabilities": [
          "sys_admin"
        ]
      }
    }
  }
}

接下来,创建一个 sandbox_config.json 文件,如下所示。 它定义了运行 wasm 应用程序的沙箱环境。

{
  "metadata": {
    "name": "podsandbox12",
    "uid": "redhat-test-crio",
    "namespace": "redhat.test.crio",
    "attempt": 1
  },
  "hostname": "crictl_host",
  "log_directory": "",
  "dns_config": {
    "searches": [
      "8.8.8.8"
    ]
  },
  "port_mappings": [],
  "resources": {
    "cpu": {
      "limits": 3,
      "requests": 2
    },
    "memory": {
      "limits": 50000000,
      "requests": 2000000
    }
  },
  "labels": {
    "group": "test"
  },
  "annotations": {
    "owner": "hmeng",
    "security.alpha.kubernetes.io/seccomp/pod": "unconfined"
  },
  "linux": {
    "cgroup_parent": "pod_123-456.slice",
    "security_context": {
      "namespace_options": {
        "network": 0,
        "pid": 1,
        "ipc": 0
      },
      "selinux_options": {
        "user": "system_u",
        "role": "system_r",
        "type": "svirt_lxc_net_t",
        "level": "s0:c4,c5"
      }
    }
  }
}

现在可以创建一个 CRI-O pod 如下:

# 创建 POD,输出将会和示例不同。
sudo crictl runp sandbox_config.json
7992e75df00cc1cf4bff8bff660718139e3ad973c7180baceb9c84d074b516a4

# 设置一个辅助变量供以后使用。
POD_ID=7992e75df00cc1cf4bff8bff660718139e3ad973c7180baceb9c84d074b516a4

自 pod ,您可以创建一个容器以隔离方式运行 wasm 字节码程序。

# 创建容器实例,输出将会和示例不同。
sudo crictl create $POD_ID container_wasi.json sandbox_config.json
1d056e4a8a168f0c76af122d42c98510670255b16242e81f8e8bce8bd3a4476f

最后,启动容器并查看 wasm 应用程序的输出。

# 列出容器,状态应该是 `Created`
sudo crictl ps -a

CONTAINER           IMAGE                           CREATED              STATE               NAME                     ATTEMPT             POD ID
1d056e4a8a168       hydai/wasm-wasi-example:latest   About a minute ago   Created             podsandbox1-wasm-wasi   0                   7992e75df00cc

# 启动容器
sudo crictl start 1d056e4a8a168f0c76af122d42c98510670255b16242e81f8e8bce8bd3a4476f
1d056e4a8a168f0c76af122d42c98510670255b16242e81f8e8bce8bd3a4476f

# 再次检查容器状态。#如果容器没有完成工作,你会看到运行状态。 #因为这个例子很小。此时您可能会看到 Exited。
sudo crictl ps -a
CONTAINER           IMAGE                           CREATED              STATE               NAME                     ATTEMPT             POD ID
1d056e4a8a168       hydai/wasm-wasi-example:latest   About a minute ago   Running             podsandbox1-wasm-wasi   0                   7992e75df00cc

# 当容器完成。你能看到状态变为 Exited。
sudo crictl ps -a
CONTAINER           IMAGE                           CREATED              STATE               NAME                     ATTEMPT             POD ID
1d056e4a8a168       hydai/wasm-wasi-example:latest   About a minute ago   Exited              podsandbox1-wasm-wasi   0                   7992e75df00cc

# 查看容器记录 
sudo crictl logs 1d056e4a8a168f0c76af122d42c98510670255b16242e81f8e8bce8bd3a4476f

Test 1: 打印随机数
Random number: 960251471

Test 2: 打印随机字节
Random bytes: [50, 222, 62, 128, 120, 26, 64, 42, 210, 137, 176, 90, 60, 24, 183, 56, 150, 35, 209, 211, 141, 146, 2, 61, 215, 167, 194, 1, 15, 44, 156, 27, 179, 23, 241, 138, 71, 32, 173, 159, 180, 21, 198, 197, 247, 80, 35, 75, 245, 31, 6, 246, 23, 54, 9, 192, 3, 103, 72, 186, 39, 182, 248, 80, 146, 70, 244, 28, 166, 197, 17, 42, 109, 245, 83, 35, 106, 130, 233, 143, 90, 78, 155, 29, 230, 34, 58, 49, 234, 230, 145, 119, 83, 44, 111, 57, 164, 82, 120, 183, 194, 201, 133, 106, 3, 73, 164, 155, 224, 218, 73, 31, 54, 28, 124, 2, 38, 253, 114, 222, 217, 202, 59, 138, 155, 71, 178, 113]

Test 3: 调用 echo 函数
Printed from wasi: This is from a main function
This is from a main function

Test 4: 打印环境变量
The env vars are as follows.
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
TERM: xterm
HOSTNAME: crictl_host
PATH: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
The args are as follows.
/var/lib/containers/storage/overlay/006e7cf16e82dc7052994232c436991f429109edea14a8437e74f601b5ee1e83/merged/wasi_example_main.wasm
50000000

Test 5: 创建文件 `/tmp.txt` 包含内容 `This is in a file`

Test 6: 从之前文件读取内容
File content is This is in a file

Test 7: 删除之前文件

下一步

在本文中,我们看到了如何使用类似 Docker 的 CRI-O 工具启动、运行和管理 WasmEdge 应用程序。

我们的下一步是使用 Kubernetes 来管理 WasmEdge 容器。 为此,我们需要在 Kubernetes 中安装一个 runner 二进制文件,以便它可以同时支持常规 Docker 镜像和 wasm 字节码镜像。

Second State 介绍

Second State 专注 WebAssembly 生态,其开源项目 WasmEdge 已经成为 CNCF 沙箱项目。WasmEdge 是为边缘计算优化的 mission critical、实时、轻量级、高性能软件执行环境(也称 runtime 或者虚拟机)。

华为 | 如何定制 Rust Clippy

作者:华为可信软件工程和开源2012实验室


Clippy 是什么

Clippy 是 Rust 官方提供的 代码检查 lint 工具,通过静态分析,来检查代码中有问题或不符合指定规范的代码。

项目地址:https://github.com/rust-lang/rust-clippy

安装


#![allow(unused)]
fn main() {
rustup component add clippy
}

使用


#![allow(unused)]
fn main() {
cargo clippy
}

配置

可以在项目中添加 clippy.toml.clippy.toml 来指定使用的 Lints 。

类似于:


#![allow(unused)]
fn main() {
avoid-breaking-exported-api = false
blacklisted-names = ["toto", "tata", "titi"]
cognitive-complexity-threshold = 30
}

Cargo Clippy 中目前包含超过 450 个 Lint

Rust 编译器内置 Lint 介绍

在 Rust 编译器 中 lint 包含四种级别:

  • allow ,编译器
  • warn
  • deny
  • forbid

每个 lint 都有一个 默认级别。下面是一个分类:

编译器内置 Lint 主要是围绕 Rust 语言特性。开发者可以通过配置文件来修改 Lints 等级。

Clippy 中的 Lints

Clippy 中的 Lints 级别包括:

  • Allow
  • Warn
  • Deny
  • Deprecated

Clippy 中的 lints 分类如下表:

分类描述默认级别
clippy::allall lints that are on by default (correctness, style, complexity, perf)
所有默认的 Lints 都会被开启(正确性、风格、复杂性、性能)
warn/deny
clippy::correctnesscode that is outright wrong or very useless
代码是完全错误或根本无用的
deny
clippy::stylecode that should be written in a more idiomatic way
代码应该用更惯用的方式来写
warn
clippy::complexitycode that does something simple but in a complex way
代码把简单的事情写复杂了
warn
clippy::perfcode that can be written to run faster
代码的写法在性能上还可以改进
warn
clippy::pedanticlints which are rather strict or might have false positives
这些 lints 相当严格或可能有误报
allow
clippy::nurserynew lints that are still under development
仍然在开发中的新 lints
allow
clippy::cargolints for the cargo manifest
用于cargo manifest 的 lints
allow

总的来说,Clippy 对代码的检查主要是包括下面五个方面:

  • 代码正确性(Correctness)。检查代码中不正确的写法。

  • 代码风格(Style)。相比于 rustfmt,clippy 更偏向于代码实践中的惯用法检查。

  • 代码复杂性(Complexity)。检查过于复杂的写法,用更简洁的写法代替。

  • 代码不灵动 (Pedantic)。写法过于教条。

  • 代码性能(Perf)。

代码正确性

Lint 示例: absurd_extreme_comparisons (荒谬的极值比较)

检查关系中的一方是其类型的最小值或最大值的比较,如果涉及到永远是真或永远是假的情况,则发出警告。只有整数和布尔类型被检查。

代码示例:


#![allow(unused)]
fn main() {
let vec: Vec<isize> = Vec::new();
if vec.len() <= 0 {}
if 100 > i32::MAX {} // 这里会报错:Deny ,因为 100 不可能大于 i32::MAX
}

代码风格

Lint 示例: assertions_on_constants (对常量的断言)

用于检查 assert!(true) 和 assert!(false) 的情况。

代码示例:


#![allow(unused)]
fn main() {
assert!(false)
assert!(true)
const B: bool = false;
assert!(B) // 会被编译器优化掉。
}

代码复杂性

Lint 示例: bind_instead_of_map

检查 _.and_then(|x| Some(y)), _.and_then(|x| Ok(y)) or _.or_else(|x| Err(y)) 这样的用法,建议使用更简洁的写法 _.map(|x| y) or _.map_err(|x| y)

代码示例:


#![allow(unused)]
fn main() {
// bad
let _ = opt().and_then(|s| Some(s.len()));
let _ = res().and_then(|s| if s.len() == 42 { Ok(10) } else { Ok(20) });
let _ = res().or_else(|s| if s.len() == 42 { Err(10) } else { Err(20) });

// good
let _ = opt().map(|s| s.len());
let _ = res().map(|s| if s.len() == 42 { 10 } else { 20 });
let _ = res().map_err(|s| if s.len() == 42 { 10 } else { 20 });
}

代码不灵动

Lints 示例: cast_lossless

用于检查可以被安全转换(conversion)函数替代的数字类型之间的转换( cast )。

as强制转换与From转换从根本上不同。 From转换是“简单和安全”,而as强制转换纯粹是“安全”。在考虑数字类型时,仅在保证输出相同的情况下才存在From转换,即,不会丢失任何信息(不会出现截断或下限或精度下降)。 as强制转换没有此限制。

代码示例:


#![allow(unused)]
fn main() {
// bad
fn as_u64(x: u8) -> u64 {
    x as u64
}

// good
fn as_u64(x: u8) -> u64 {
    u64::from(x) // from内部其实也是as,但只要是实现 from 的,都是无损转换,在代码可读性、语义上更好
}
}

代码性能

Lints 示例: append_instead_of_extend

检查动态数组中是否出现 extend,建议使用 append代替。

代码示例:


#![allow(unused)]
fn main() {
let mut a = vec![1, 2, 3];
let mut b = vec![4, 5, 6];

// Bad
a.extend(b.drain(..));

// Good
a.append(&mut b); // 用 append 代替 extend 更加高效和简洁。
}

还有一些其他分类,比如包括一些「约束性(Restriction)」建议、对 cargo.toml 的检查、以及正在开发中的Lints 等。

如何定制 Clippy Lint

定制 Clippy Lint 有两种办法:

  1. 方法一:fork rust-clippy 项目,自己维护。因为使用了不稳定的接口,所以维护和使用不太方便。
  2. 方法二:使用第三方 Dylint 工具。维护自定义 lint 比方法一更方便。

方法一:fork clippy

在 fork Clippy 定制自己的 LInt 之前,还需要了解 Clippy 的 工作机制。

Clippy 工作机制

clippy

Clippy 通过 rust_driverrustc_interface 这两个库,可以把 rustc 作为库来调用。

rustc_driver 本质上就像是整个rustc 编译器的main函数(入口)。它使用在rustc_interface crate中定义的接口以正确的顺序运行编译器。

rustc_interface crate为外部用户提供了一个(未稳定的)API,用于在编译过程中的特定时间运行代码,允许第三方(例如RLSrustdoc)有效地使用rustc的内部结构作为分析crate 或 模拟编译器过程的库。

对于那些使用 rustc 作为库的人来说,rustc_interface::run_compiler() 函数是进入编译器的主要入口。它接收一个编译器的配置和一个接收编译器的闭包。run_compiler从配置中创建一个编译器并将其传递给闭包。在闭包中,你可以使用编译器来驱动查询,以编译一个 crate 并获得结果。这也是 rustc_driver 所做的。

rustc_interface 组件库中定义了Compiler 结构体,持有 register_lints 字段。该 Compiler结构体就是编译器会话实例,可以通过它传递编译器配置,并且运行编译器。

register_lints 是 持有 LintStore 可变借用的闭包,其类型签名是 Option<Box<dyn Fn(&Session, &mut LintStore) + Send + Sync>>

LintStorerustc_lint 组件库中定义的类型。


#![allow(unused)]
fn main() {
pub struct LintStore {
    /// Registered lints.
    lints: Vec<&'static Lint>,

    // 构造不同种类的 lint pass
    /// Constructor functions for each variety of lint pass.
    ///
    /// These should only be called once, but since we want to avoid locks or
    /// interior mutability, we don't enforce this (and lints should, in theory,
    /// be compatible with being constructed more than once, though not
    /// necessarily in a sane manner. This is safe though.)
    pub pre_expansion_passes: Vec<Box<dyn Fn() -> EarlyLintPassObject + sync::Send + sync::Sync>>,
    pub early_passes: Vec<Box<dyn Fn() -> EarlyLintPassObject + sync::Send + sync::Sync>>,
    pub late_passes: Vec<Box<dyn Fn() -> LateLintPassObject + sync::Send + sync::Sync>>,
    /// This is unique in that we construct them per-module, so not once.
    pub late_module_passes: Vec<Box<dyn Fn() -> LateLintPassObject + sync::Send + sync::Sync>>,

    /// Lints indexed by name.
    by_name: FxHashMap<String, TargetLint>,

  	// lint group,通过一个名字触发多个警告,把lint分组
    /// Map of registered lint groups to what lints they expand to.
    lint_groups: FxHashMap<&'static str, LintGroup>,
}
}

可以注册的 lint pass 还分好几类:

  • early_passes:表示该类型的 lint pass对应的是 EarlyContext,是在 AST 层级的 lint 检查,还未到 HIR 层面。
  • late_passes:表示该类型的 lint pass对应的是 LateContext,是在 类型检查之后的 lint 检查。意味着这样的检查需要获取类型信息。类型检查是在 HIR 层级做的。

rust_interface 中,还定义了相应的 check 方法:early_lint_methods! 定义的很多check方法late_lint_methods

声明一个 lint pass 需要使用 declare_late_lint_pass! 宏 中定义的 rustc_lint::LateLintPass trait

再来看 run_compiler函数。


#![allow(unused)]
fn main() {
pub fn run_compiler<R: Send>(mut config: Config, f: impl FnOnce(&Compiler) -> R + Send) -> R {
    tracing::trace!("run_compiler");
    let stderr = config.stderr.take();
    util::setup_callbacks_and_run_in_thread_pool_with_globals(
        config.opts.edition,
        config.opts.debugging_opts.threads,
        &stderr,
        || create_compiler_and_run(config, f), // 设置一个回调函数
    )
}

// 回调函数
pub fn create_compiler_and_run<R>(config: Config, f: impl FnOnce(&Compiler) -> R) -> R {
    let registry = &config.registry;
    let (mut sess, codegen_backend) = util::create_session(
        config.opts,
        config.crate_cfg,
        config.diagnostic_output,
        config.file_loader,
        config.input_path.clone(),
        config.lint_caps,
        config.make_codegen_backend,
        registry.clone(),
    );
    // 。。。省略
  	let compiler = Compiler {
        sess,
        codegen_backend,
        input: config.input,
        input_path: config.input_path,
        output_dir: config.output_dir,
        output_file: config.output_file,
        register_lints: config.register_lints, // 配置 register_lints
        override_queries: config.override_queries,
    };

}
}

再看看 rustc_driver库,其中定义了 Callbacks trait


#![allow(unused)]
fn main() {
pub trait Callbacks {
    /// Called before creating the compiler instance
    fn config(&mut self, _config: &mut interface::Config) {}
    /// Called after parsing. Return value instructs the compiler whether to
    /// continue the compilation afterwards (defaults to `Compilation::Continue`)
    fn after_parsing<'tcx>(
        &mut self,
        _compiler: &interface::Compiler,
        _queries: &'tcx Queries<'tcx>,
    ) -> Compilation {
        Compilation::Continue
    }
    /// Called after expansion. Return value instructs the compiler whether to
    /// continue the compilation afterwards (defaults to `Compilation::Continue`)
    fn after_expansion<'tcx>(
        &mut self,
        _compiler: &interface::Compiler,
        _queries: &'tcx Queries<'tcx>,
    ) -> Compilation {
        Compilation::Continue
    }
    /// Called after analysis. Return value instructs the compiler whether to
    /// continue the compilation afterwards (defaults to `Compilation::Continue`)
    fn after_analysis<'tcx>(
        &mut self,
        _compiler: &interface::Compiler,
        _queries: &'tcx Queries<'tcx>,
    ) -> Compilation {
        Compilation::Continue
    }
}
}

该trait中定义了在编译不同阶段要执行的回调函数。

所以,在 Clippy 的 driver.rs 中就做了如下定义:


#![allow(unused)]
fn main() {
struct ClippyCallbacks {
    clippy_args_var: Option<String>,
}

// 为 ClippyCallbacks 实现 rustc_driver::Callbacks ,定义 config 方法
// 该 config 方法创建编译器实例之前被执行的
impl rustc_driver::Callbacks for ClippyCallbacks {
    fn config(&mut self, config: &mut interface::Config) {
        let previous = config.register_lints.take();
        let clippy_args_var = self.clippy_args_var.take();
        config.parse_sess_created = Some(Box::new(move |parse_sess| {
            track_clippy_args(parse_sess, &clippy_args_var);
        }));
        // 注册 lints
        config.register_lints = Some(Box::new(move |sess, lint_store| {
            // technically we're ~guaranteed that this is none but might as well call anything that
            // is there already. Certainly it can't hurt.
            if let Some(previous) = &previous {
                (previous)(sess, lint_store);
            }

            let conf = clippy_lints::read_conf(sess);
            clippy_lints::register_plugins(lint_store, sess, &conf);
            clippy_lints::register_pre_expansion_lints(lint_store);
            clippy_lints::register_renamed(lint_store);
        }));

        // FIXME: #4825; This is required, because Clippy lints that are based on MIR have to be
        // run on the unoptimized MIR. On the other hand this results in some false negatives. If
        // MIR passes can be enabled / disabled separately, we should figure out, what passes to
        // use for Clippy.
        config.opts.debugging_opts.mir_opt_level = Some(0);
    }
}
}

所以,Clippy 通过 ClippyCallbacks config 来注册 lints 。在 config 函数内部,通过调用 clippy_lints::read_conf(sess) 来读取 clippy 配置文件里的lint。在 clippy_lints 里还定义了 register_plugins,使用 rustc_lint::LintStore 来注册 clippy 里定义的 lints。

以上就是 Clippy 的工作机制。

自定义 Clippy lints

通过了解 Clippy 工作机制,可以看得出来,如果要自定义 Clippy lints,是需要严重依赖 rustc 版本的,因为 rustc_interface 提供的接口并不稳定。所以维护成本比较高。

如果一定要通过这种方式自定义 Clippy lints ,需要按以下步骤开发。

安装配置 Clippy
  1. 下载 Clippy 源码。
  2. 执行 cargo buildcargo test 。因为Clippy 测试套件非常大,所以可以只测试部分套件,比如,cargo uitest,或 cargo test --test dogfood。如果 UITest 和预期不符,可以使用 cargo dev bless更新相关文件。
  3. Clippy 提供了一些开发工具,可以通过 cargo dev --help 查看。

UI测试的目的是捕捉编译器的完整输出,这样我们就可以测试演示的所有方面。

测试正常的话,修改Clippy 生成二进制的名字,防止影响我们开发环境中安装的 Clippy命令。

  1. Cargo.toml中修改

#![allow(unused)]
fn main() {
[[bin]]
name = "cargo-myclippy" // 此处原本是 "cargo-clippy"
test = false
path = "src/main.rs"

[[bin]]
name = "clippy-mydriver" //  此处原本是 "clippy-mydriver"
path = "src/driver.rs"
}
  1. 修改 src/main.rs

#![allow(unused)]
fn main() {
.with_file_name("clippy-mydriver"); // 将使用 `clippy-driver` 的地方修改为 `clippy-mydriver`
}
起一个有意义的名字

定义 lints 需要先起一个符合 Lints 命名规范 的名字。

Lints 命名规范的首要原则就是:lint 名字要有意义。比如 allow dead_code,这是有意义的,但是allow unsafe_code这个就有点过分了。

具体来说,有几条注意事项:

  1. Lint 名称应该标明被检查的「坏东西」。比如 deprecated,所以,#[allow(deprecated)](items)是合法的。但是 ctypes就不如improper_ctypes 更明确。
  2. 命名要简洁。比如 deprecated,就比 deprecated_item更简洁。
  3. 如果一个 lint 应用于特定的语法,那么请使用复数形式。比如使用 unused_variables而不是unused_variable
  4. 捕捉代码中不必要的、未使用的或无用的方面的行文应该使用术语unused,例如unused_importsunused_typecasts
  5. lint 命名请使用蛇形(snake case)命名,与函数名的方式相同。
设置样板代码

假如新的 lint 叫 foo_functions,因为该lint不需要用到类型信息(比如某结构体是否实现 Drop),所以需要定义 EarlyLintPass。

在 Clippy 项目根目录下,通过以下命令创建 Lint:


#![allow(unused)]
fn main() {
cargo dev new_lint --name=foo_functions --pass=early --category=pedantic
}

如果没有提供 category ,则默认是 nursery 。

执行完该命令以后,在 Clippy-lint/src/ 目录下就会多一个 foo_functions.rs 的文件,文件中包含了样板代码:


#![allow(unused)]
fn main() {
use rustc_lint::{EarlyLintPass, EarlyContext};
use rustc_session::{declare_lint_pass, declare_tool_lint};
use rustc_ast::ast::*;

// 此宏用于定义 lint
declare_clippy_lint! {
    /// **What it does:**
    ///
    /// **Why is this bad?**
    ///
    /// **Known problems:** None.
    ///
    /// **Example:**
    ///
    /// ```rust
    /// // example code where clippy issues a warning
    /// ```
    /// Use instead:
    /// ```rust
    /// // example code which does not raise clippy warning
    /// ```
    pub FOO_FUNCTIONS, // lint 名字大写
    pedantic, // lint 分类
    "default lint description" // lint 描述
}

// 定义 lint pass。 注意,lint 和 lint pass 并不一定成对出现
declare_lint_pass!(FooFunctions => [FOO_FUNCTIONS]);

// 因为不需要使用类型信息,此处实现 EarlyLintPass
impl EarlyLintPass for FooFunctions {}


}

除了此文件,还会创建 test/ui/foo_functions.rs 测试文件。

接下来,需要执行 cargo dev update_lints 命令来注册新 lint。

添加 Lint pass 内容

先来写一些测试代码。

Clippy使用UI测试进行测试。UI测试检查Clippy的输出是否与预期完全一致。每个测试都是一个普通的Rust文件,包含我们要检查的代码。Clippy的输出与一个.stderr文件进行比较。注意,你不需要自己创建这个文件,我们将进一步讨论生成.stderr文件。

我们首先打开在test/ui/foo_functions.rs创建的测试文件。

用一些例子来更新该文件,以便开始使用。

#![warn(clippy::foo_functions)]

// Impl methods
struct A;
impl A {
    pub fn fo(&self) {}
    pub fn foo(&self) {}
    pub fn food(&self) {}
}

// Default trait methods
trait B {
    fn fo(&self) {}
    fn foo(&self) {}
    fn food(&self) {}
}

// Plain functions
fn fo() {}
fn foo() {}
fn food() {}

fn main() {
    // We also don't want to lint method calls
    foo();
    let a = A;
    a.foo();
}

可以使用 TESTNAME=foo_functions cargo uitest来执行测试。

可以看到输出:


#![allow(unused)]
fn main() {
test [ui] ui/foo_functions.rs ... ok
}

接下来,打开 src/foo_functions.rs 编写 Lint 代码。


#![allow(unused)]
fn main() {
declare_clippy_lint! {
    /// **What it does:**
    ///
    /// **Why is this bad?**
    ///
    /// **Known problems:** None.
    ///
    /// **Example:**
    ///
    /// ```rust
    /// // example code
    /// ```
    pub FOO_FUNCTIONS,
    pedantic, // 该类型的lint 等级 默认是 Allow
    "function named `foo`, which is not a descriptive name" // 修改 lint 声明的描述内容
}
}

可以通过执行 cargo dev serve在本地打开网页服务,可以查到 foo_functions显示的描述。

foo-fn

`Pedantic的默认lint 等级是allow\,定义于 https://github.com/rust-lang/rust-clippy/blob/master/clippy_lints/src/lib.rs#L119

通常在声明了lint之后,我们必须运行cargo dev update_lints 来更新一些文件,以便 Clippy 知道新的 Lint。由于上面是用cargo dev new_lint ... 命令来生成lint声明,所以这是自动完成的。

虽然 update_lints自动完成了大部分工作,但它并没有自动完成所有工作。我们必须在clippy_lints/src/lib.rsregister_plugins函数中手动注册我们的lint pass


#![allow(unused)]
 
fn main() {
pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf: &Conf) {
    // 此处省略 2000 行代码
		// foo_functions
    store.register_early_pass(|| box foo_functions::FooFunctions);

}
}

该函数有 2000 多行代码,维护起来可想而知多么麻烦了。

因为此 lint pass 只是检查函数名字,不涉及类型检查,所以只需要 AST 层面的处理即可。关于 EarlyLintPass 和 LateLintPass 的区别前文已经介绍过。EarlyLintPass 比 LateLintPass 更快一些,然而 Clippy 的性能并不是关注的重点。

由于我们在检查函数名时不需要类型信息,所以在运行新的lint自动化时,我们使用了--pass=early,所有的样板导入都相应地被添加了。

下一步就可以实现 Lint 的检查逻辑了。


#![allow(unused)]
fn main() {
// src/foo_functions.rs 
impl EarlyLintPass for FooFunctions {
  	// 此处 check_fn 是内置 EarlyLintPass trait 包含方法,前文介绍过
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        // TODO: Emit lint here 此处编写检查逻辑
    }
}
}

对于如何检查函数名字,在 clippy_utils/src/diagnostics.rs中定义了一些帮助函数。经过查找,span_lint_and_help函数在此处使用比较适合。


#![allow(unused)]
fn main() {
// src/foo_functions.rs 
use clippy_utils::diagnostics::span_lint_and_help;
use rustc_span::Span;
use rustc_ast::{ast::NodeId, visit::FnKind};

impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        span_lint_and_help(
            cx,
            FOO_FUNCTIONS,
            span,
            "function named `foo`",
            None,
            "consider using a more meaningful name"
        );
    }
}
}

执行测试代码,输出如下:

uitest

uitest2

诊断信息是有效果了,但是缺乏一些lint检测逻辑。所以进一步修改:


#![allow(unused)]
fn main() {
impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        // 增加判断逻辑
        fn is_foo_fn(fn_kind: FnKind<'_>) -> bool {
            match fn_kind {
                FnKind::Fn(_, ident, ..) => {
                    // check if `fn` name is `foo`
                    ident.name.as_str() == "foo"
                }
                // ignore closures
                FnKind::Closure(..) => false
            }
        }
        // 增加判断逻辑
        if is_foo_fn(fn_kind) {
            span_lint_and_help(
                cx,
                FOO_FUNCTIONS,
                span,
                "function named `foo`",
                None,
                "consider using a more meaningful name (考虑使用一个更有意义的函数名字)"
            );
        }
    }
}
}

再次执行测试输出:

uitest3

接下来执行:

  1. cargo dev bless 更新 .stderr文件。这个 .stderr文件是需要提交的。如果测试出现错误,记得执行这一步。
  2. cargo test

执行 cargo test 失败,因为 clippy 不允许出现 中文描述。所以,修改:


#![allow(unused)]
fn main() {
if is_foo_fn(fn_kind) {
    span_lint_and_help(
      cx,
      FOO_FUNCTIONS,
      span,
      "function named `foo`",
      None,
      "consider using a more meaningful name (考虑使用一个更有意义的函数名字)" // 此处不允许中文,当然你也可以修改 clippy 自身的 lint 配置
    );
}

// 修改为

if is_foo_fn(fn_kind) {
    span_lint_and_help(
      cx,
      FOO_FUNCTIONS,
      span,
      "function named `foo`",
      None,
      "consider using a more meaningful name"
    );
}

}

测试成功。

最后执行 cargo dev fmt,格式化代码。

到目前为止,自定义 clippy lint 已经完成。

测试 Clippy lint 效果

因为我们自定义的 Clippy 二进制名字已经被修改了,所以可以直接安装,不怕和已安装的clippy有冲突了。

执行以下命令安装自定义的Clippy:


#![allow(unused)]
fn main() {
cargo install --bin=cargo-myclippy --bin=clippy-mydriver --path=.
}

然后重新使用 cargo new clippytest创建一个新项目。

src/main.rs修改为:

#![warn(clippy::foo_functions)]

// Impl methods
struct A;
impl A {
    pub fn fo(&self) {}
    pub fn foo(&self) {}
    pub fn food(&self) {}
}

// Default trait methods
trait B {
    fn fo(&self) {}
    fn foo(&self) {}
    fn food(&self) {}
}

// Plain functions
fn fo() {}
fn foo() {}
fn food() {}

fn main() {
    // We also don't want to lint method calls
    foo();
    let a = A;
    a.foo();
}

【如有必要】然后在 clippytest项目目录下创建 rust-toolchain 文件:


#![allow(unused)]
fn main() {
[toolchain]
channel = "nightly-2021-06-17"
components = ["llvm-tools-preview", "rustc-dev", "rust-src"]
}

这个文件里的配置,要和 官方 rust-clippy 下一致,也就是你fork的那个原项目。

然后命令行执行:cargo myclippy,输出:

myclippy

成功!

然后回去 src/main.rs中,将 #![warn(clippy::foo_functions)] 改为 #![error(clippy::foo_functions)],再次执行 cargo myclippy,输出:

myclippy-error

成功!

到此为止,自定义 Clippy Lint 成功!

小结

通过 fork clippy,完全可以定制自己的 Lint 。但是也有很明显的缺陷:

  1. Clippy 内置 lint 很多,需要手工注册自定义lint,想想那个 2000 行的函数就头疼。
  2. Clippy 依赖 rustc_interface 是未稳定的 API 。clippy_utils 里提供的helper方法也是依赖于编译器这个未稳定接口,这样不同编译器版本就会难以兼容。导致不能通用。
  3. 需要命名为自己的 Clippy 二进制文件,避免和原本的 Clippy 冲突。

如果自定义 Lint 可以 PR 更好,但并不是所有自定义 Lint 都可以提交到官方 PR ,必然需要维护自己的/团队的特殊场景的 Lint。就会面对上面的缺陷。

有没有更好的办法呢?

方法二:使用 Dylint

参考:Write Rust lints without forking Clippy

社区有人开发了一个工具: Dylint 。它的特点:

  1. 以动态库的方式来提供 lint 。而 Clippy 是静态库。Clippy 的所有 lint 都使用相同的编译器版本,因此只需要 rustc_driver
  2. Dylint 用户可以选择从不同编译器版本的库中加载 lint。

dylint

Dylint 可以动态构建 rustc_driver。换句话说,如果用户想要 A 版本的编译器库中加载 lint,并且找不到 A 版本的 rustc_driver,Dylint 将构建一个新的 A 版本的rustc_driverrustc_driver缓存在用户的主目录中,因此仅在必要时重建它们。

Dylint 根据它们使用的编译器版本对库进行分组,使用相同编译器版本的库一起加载,并和它们的 lint 一起运行。这允许在 lint 之间共享中间编译结果(如:符号解析,类型检查,trait求解等)。

在上图中,如果库 U 和 V 都使用了 A 版本的编译器,这两个库将被放到同一个分组中。A 版本编译器的rustc_driver将只被调用一次。rustc_driver在将控制权移交给 Rust 编译器之前会在库 U 和库 V 中注册 lint。

安装和配置

通过下面命令安全 dylint:


#![allow(unused)]
fn main() {
cargo install cargo-dylint
cargo install dylint-link
}

然后获取模版项目:


#![allow(unused)]
fn main() {
git clone https://github.com/trailofbits/dylint-template
}

或者使用 cargo-generate来创建模版


#![allow(unused)]
fn main() {
cargo generate --git https://github.com/trailofbits/dylint-template
}

将项目命名为 :mylints

然后进入到项目根目录,执行:


#![allow(unused)]
fn main() {
cargo build
cargo dylint fill_me_in --list
}
编写 lint

因为生成的模版其实和 上面 fork clippy 自定义生成的代码模版类似,所以直接将上面的 lint 代码复制过来。

创建新文件 src/foo_functions.rs


#![allow(unused)]
fn main() {
use clippy_utils::diagnostics::span_lint_and_help;
use rustc_ast::{ast::NodeId, visit::FnKind};
use rustc_lint::{EarlyContext, EarlyLintPass};
use rustc_span::Span;
use rustc_lint::LateLintPass;
use rustc_session::{declare_lint, declare_lint_pass};

declare_lint! {
    /// **What it does:**
    ///  检查 以 foo 命名的函数,并给予警告
    /// **Why is this bad?**
    ///    因为该命名没有意义
    /// **Known problems:** None.
    ///
    /// **Example:**
    ///
    /// ```rust
    /// // example code where clippy issues a warning
    /// ```
    /// Use instead:
    ///   考虑使用一个更有意义的函数名字
    /// ```rust
    /// // example code which does not raise clippy warning
    /// ```
    pub FOO_FUNCTIONS,
    Warn, //  注意:这里和  fork Clippy 略有不同
    "function named `foo`, which is not a descriptive name"
}

declare_lint_pass!(FooFunctions => [FOO_FUNCTIONS]);


impl EarlyLintPass for FooFunctions {
    fn check_fn(&mut self, cx: &EarlyContext<'_>, fn_kind: FnKind<'_>, span: Span, _: NodeId) {
        fn is_foo_fn(fn_kind: FnKind<'_>) -> bool {
            match fn_kind {
                FnKind::Fn(_, ident, ..) => {
                    // check if `fn` name is `foo`
                    ident.name.as_str() == "foo"
                },
                // ignore closures
                FnKind::Closure(..) => false,
            }
        }

        if is_foo_fn(fn_kind) {
            span_lint_and_help(
                cx,
                FOO_FUNCTIONS,
                span,
                "function named `foo`",
                None,
                "consider using a more meaningful name",
            );
        }
    }
}
}

代码复制完毕之后,在 src/lib.rs 中添加:


#![allow(unused)]
fn main() {
mod foo_functions;

#[no_mangle]
pub fn register_lints(_sess: &rustc_session::Session, lint_store: &mut rustc_lint::LintStore) {
    lint_store.register_lints(&[foo_functions::FOO_FUNCTIONS]);
    lint_store.register_early_pass(|| Box::new(foo_functions::FooFunctions));
}
}

注意:需要配置当前项目下 .cargo/config.toml 中针对当前架构平台的 target 指定的链接器,否则会报 找不到库 之类的错误。


#![allow(unused)]
fn main() {
[target.aarch64-apple-darwin]
linker = "dylint-link"

[target.x86_64-apple-darwin]
linker = "dylint-link"

[target.x86_64-unknown-linux-gnu]
linker = "dylint-link"
}

然后执行 cargo build 编译成功。

接下来需要设置几个环境变量:


#![allow(unused)]
fn main() {
export MY_LINTS_PATH=/Work/Projects/myworkspace/mylints
export DYLINT_LIBRARY_PATH=$MY_LINTS_PATH/target/debug
}

然后执行 cargo test。可以看到 uitest 的输出。

但是 dylint 有个缺点,就是 uitest 无法像 clippy那样(cargo dev bless) 更新引用。所以 cargo test 会测试失败。

但是可以在 src/lib.rs 中,添加:


#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn foo() {}
}

然后在 mylints项目下执行: cargo dylint --all 。就能看到 lint 生效了。

以上是我们编写了独立的 lints。

测试独立项目

随便创建一个 新的项目 myproject,将 src/main.rs 换成和前面测试 clippy 时候用的代码。

基于前面设置好的 mylints ,我们只需要直接使用 cargo dylint --all 命令即可。

然后在该项目根目录下执行:


#![allow(unused)]
fn main() {
cargo dylint --all -- --manifest-path=/Work/Projects/myproject/Cargo.toml
}

然后就可以正常执行 lint 检测了。

小结

使用 dylint 比较麻烦的是,文档不是很全,测试不支持更新引用,不如 fork clippy 方便测试。

但是 dylint 确实比较小巧,只需要维护我们自定义的lint 即可,不再需要维护 2000 多行的注册lint代码。

使用 dylint 的时候,因为也依赖了 clippy 的 clippy_utils,所以需要和 clippy 的 rustc 版本保持一致。

总结

上面总结了两种定制 Clippy Lints 的方法,各有优劣。

一个观点:

  • 第一种方法比较适合 大公司/大团队,因为第一种方法比较完善,功能齐备,只是需要一个专门的团队来维护这个 lints。并且还有可能给上游去发 PR (如果需要),形成正向反馈,让工具更加完善。另外,也许可以给 Clippy 提供一个 Plugin 机制,方便维护定制的 Lint。

  • 第二种方法适合小团队,没有多余的人力去维护,只需要定制自己的一些 lints 使用即可。

欢迎在评论区留言交流。

有用的参考资源:

以下资源对你编写 lint 将很有帮助:

定制静态分析 lint 检查规则

作者: 吴翱翔@pymongo / 后期编辑: 张汉东

原文: custom Rust lint

需求: vscode-ra 的 tfn 会生成 fn feature 的函数,我希望静态分析能帮我检查出来,不让 feature 函数提交到 github 上

静态分析的可行性

由于 Intellij-Rust 是用 kotlin 写的,好处是不依赖 rustc,坏处暂时还不能分析 rustc 源码

由于做静态分析/过程宏/编译原理相关的研究都需要深入研究编译器源码,所以我只考虑用 Rust 静态分析 Rust 代码的解决方案,不考虑 Intellij-Rust

  • 过程宏: 被过程宏标记的函数可以在编译时进行AST静态分析,但是每个函数都得打上标记很不方便
  • 改 rustc 源码: 例如 non_ascii_idents lint,但 rustc 编译太慢
  • cargo clippy: 改 clippy 源码后可以 cargo install 编译成 cargo 子命令
  • cargo dylint: 可行性待考察

lint 对公司的重大意义

例如公司团队禁止项目代码使用递归调用,因为 Rust 对非线性递归的优化有限,而且递归用的不好容易爆栈导致生产服务器 panic

但毕竟有很多用 Rust 算法实现时用到了递归,所以不可能提 PR 让 clippy 官方加入「禁止递归」的 lint

如果让公司人工 code review 有没有递归代码,不仅效率低而且不能保证 100% 准确

这时候如果把自定义的 lint 加到公司的 CI/CD 流程中,就能自动检测不符合公司 coding_style 的代码

cargo-lint 可执行文件

为了跟 clippy 的可执行文件命名区分,我把 fork 的 clippy 代码做出以下改动:

diff --git a/Cargo.toml b/Cargo.toml
index 9b5d9b2ad..17e13950d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -12,13 +12,15 @@ publish = false
 [[bin]]
-name = "cargo-clippy"
+name = "cargo-lint"
 
 [[bin]]
-name = "clippy-driver"
+name = "lint-driver"
 
 [dependencies]
diff --git a/src/main.rs b/src/main.rs
index 7bb80b119..3df9e40d5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -107,7 +107,7 @@ impl ClippyCmd {
-            .with_file_name("clippy-driver");
+            .with_file_name("lint-driver");

首先 clippy 需要 cargo-clippy 和 clippy-driver 两个可执行文件,所以我是通过以下方式安装 cargo-lint 的:

cargo install --bin=cargo-lint --bin=lint-driver --path=.

添加新的 lint

参考: addling_lints的文档

cargo dev是 clippy 项目的 cargo alias,可以通过 cargo dev 创建新的 lint

cargo dev new_lint --name=my_lint_function_name_is_feature --pass=early --category=correctness

diff --git a/clippy_lints/src/lib.rs b/clippy_lints/src/lib.rs
index f5082468a..005f99895 100644
--- a/clippy_lints/src/lib.rs
+++ b/clippy_lints/src/lib.rs
@@ -276,6 +276,7 @@ mod mut_mutex_lock;
 mod mutex_atomic;
+mod my_lint_function_name_is_feature;
 mod needless_arbitrary_self_type;
@@ -822,6 +823,7 @@ pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf:
         mutex_atomic::MUTEX_INTEGER,
+        my_lint_function_name_is_feature::my_lint_function_name_is_feature,
@@ -1345,6 +1347,7 @@ pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf:
         LintId::of(mutex_atomic::MUTEX_ATOMIC),
+        LintId::of(my_lint_function_name_is_feature::my_lint_function_name_is_feature),
@@ -1702,6 +1705,7 @@ pub fn register_plugins(store: &mut rustc_lint::LintStore, sess: &Session, conf:
         LintId::of(mut_key::MUTABLE_KEY_TYPE),
+        LintId::of(my_lint_function_name_is_feature::my_lint_function_name_is_feature),

新增了两个文件:

  1. clippy_lints/src/my_lint_function_name_is_feature.rs
  2. tests/ui/my_lint_function_name_is_feature.rs

改动了一个文件: clippy_lints/src/lib.rs

但是这样会把 CHANGELOG.md 改掉,也把 clippy_lints/src/lib.rs 改掉,导致我很难合并 clippy upstream 的改动

lint 的单元测试

我将我的 lint 命名为: my_lint_function_name_is_feature

所以单元测试的重点是只有函数名是 feature 时,才会报错,如果变量名是 feature 则不会

TESTNAME=my_lint_function_name_is_feature cargo uitest

cargo dev bless会将上次运行的cargo uitest的错误生成为my_lint_function_name_is_feature.stderr文件

这时候报错: error: unknown lint: clippy::my_lint_function_name_is_feature

Unknown lint

clippy 的 CONTRIBUTE.md 的 How clippy worksSyncing changes between Clippy and rust-lang/rust

章节提醒 clippy 跟 rustc 版本似乎是强绑定的,要等 rust 更新 clippy 子仓库后才能让新的 lint 生效?

2021-06-19 clippy 加入的 lint nonstandard_macro_braces 用 2021-06-16 的 rustc 就会报错 Unknown lint

由于用 clippy 添加新的 lint 时各种报错读了几遍文档依然没解决,所以我寻求更可行的静态分析方案

cargo dylint

由于 Rust 自定义 lint 静态分析检查的资料很少,我搜索全网只能找到这篇文章: https://www.trailofbits.com/post/write-rust-lints-without-forking-clippy

幸运的是 cargo dylint 跟 clippy/rustc 新增 lint 的写法完全一样,看 rustc 的学习资料足以学习 dylint

首先需要安装 dylint 工具以及 dylint 的 linker:

cargo install cargo-dylint dylint-link

dylint 模板

虽然 dylint 跟 clippy 极其相似,但还是建议学习下 dylint 的示例

  1. dylint-template
  2. dylint 代码仓库下 examples 每个示例都可以参考下
  3. 我基于 path_separator 改的模板: https://github.com/pymongo/my_lints

⚠注意由于 dylint 工作原理跟 clippy 相似,所以 dylint/rustc/clippy_utils 三者的版本必须互相兼容

修改 rustc 或 clippy 版本很可能让 dylint 编译失败或不生效,不要轻易修改依赖版本!

dylint 运行方法

假设我们自定义 lint 的源码文件夹目录是 MY_LINTS_PATH

export MY_LINTS_PATH=/home/w/repos/my_repos/my_lints

假设公司项目代码的路径是 /home/w/temp/other_rust_project

⚠注意!: 如果发现新加的 lint 不生效,那就 cargo clean 清理下 MY_LINTS_PATH 再重新编译

¶ 1. 在自己写 lint 的文件夹运行 dylint

cd $MY_LINTS_PATH

cargo dylint --all -- --manifest-path=/home/w/temp/other_rust_project/Cargo.toml

¶ 2. 在项目文件夹内引入

DYLINT_LIBRARY_PATH=$MY_LINTS_PATH/target/debug cargo dylint --all

¶ 3. 在项目 Cargo.toml 中 package.metadata.dylint

该方法我一直报错: Warning: No libraries were found,故放弃

early/late lint 的概念

按照 Overview of the Compiler - rustc-dev-guide 的介绍

我把 Rust 编译过程大致概括为以下流程:

(rustc_args_and_env -rustc_driver-> rustc_interface::Config)

  1. source_code_text(bytes) -rustc_lexer-> TokenStream
  2. TokenStream -rustc_parse-> AST
  3. AST analysis: macro_expand, name_resolution, feature_gating, checking/early_lint
  4. AST convert to HIR
  5. HIR analysis: type/trait checking, late_lint
  6. HIR convert to MIR
  7. MIR analysis: ownership/lifetime/borrow checking
  8. MIR Optimizations
  9. MIR convert to LLVM IR
  10. LLVM backend compile LLVM IR to executable or library

所以 early_lint 能分析 AST 代码, late_lint 则是分析 HIR 代码

宏和过程宏则是输入 token_stream ,宏输出则是展开后的 token_stream (参考 heapsize 过程宏)

dylint 添加新的 lint

假设公司需要一个 lint,函数名字带 todo 的都要抛出警告

§ fn_name_contains_todo-step_1: 新建 lint 文件

我把这个 lint 命名为 fn_name_contains_todo,注意 lint 的命名不能跟 rustc 自带的 lint 冲突

首先复制我写的 dylint 简易模板: git clone https://github.com/pymongo/my_lints

然后在 src/ 目录下 新增一个 fn_name_contains_todo.rs 的文件

并且在 src/lib.rs 下加一行 mod fn_name_contains_todo; 加新文件加入到模块树中

§ fn_name_contains_todo-step_2: lint 实现

首先要在 fn_name_contains_todo.rs 中定义 lint 的结构体和常量,这步跟 clippy 创建新的 lint完全一样


#![allow(unused)]
fn main() {
rustc_session::declare_lint! {
    pub FN_NAME_CONTAINS_TODO,
    Warn,
    "fn_name_contains_todo"
}

rustc_session::declare_lint_pass!(FnNameContainsTodo => [FN_NAME_CONTAINS_TODO]);
}

由于分析变量名或函数名只需要 AST 就足够了,不需要带类型信息的 HIR,因此只用 early lint


#![allow(unused)]
fn main() {
impl rustc_lint::EarlyLintPass for FnNameContainsTodo {
    fn check_fn(
        &mut self,
        cx: &rustc_lint::EarlyContext<'_>,
        fn_kind: rustc_ast::visit::FnKind<'_>,
        span: rustc_span::Span,
        _: rustc_ast::NodeId,
    ) {
        // Ignore FnKind::Closure
        if let rustc_ast::visit::FnKind::Fn(_, ident, ..) = fn_kind {
            if ident.as_str().contains("todo") {
                clippy_utils::diagnostics::span_lint(
                    cx,
                    FN_NAME_CONTAINS_TODO,
                    span,
                    "fn name with todo is not allow to commit",
                );
            }
        }
    }
}
}

§ fn_name_contains_todo-step_3: 注册 lint

lint 的实现写完后,在 lib.rs 的 pub fn register_lints

  1. lint_store.register_lints 的入参数组内追加 fn_name_contains_todo::FN_NAME_CONTAINS_TODO
  2. 加入 early_pass 一行 lint_store.register_early_pass(|| Box::new(fn_name_contains_todo::FnNameContainsTodo));

第二点很好理解,如果 lint 用了 EarlyLintPass 就加 register_early_pass,用了 LateLIntPass 就加 register_late_pass

⚠注意: lint_store.register_lints 加入的是 lint 常量, 而 lint_store.register_early_pass 则是传入 lint 结构体

于是重新编译代码后,新加的 lint 就生效了

§ fn_name_contains_todo-step_4: (可选) 测试 lint

粗糙的测试方法是,我们在 src/lib.rs 中加入以下内容:


#![allow(unused)]
fn main() {
#[allow(dead_code)]
fn todo() {

}
}

然后运行 dylint 分析当前项目(也就是 lint 源码项目):

cargo clean && cargo b && cargo dylint --all

    Checking my_lints v0.1.0 (/home/w/repos/my_repos/my_lints)
warning: fn name with todo is not allow to commit
  --> src/lib.rs:26:1
   |
26 | / fn todo() {
27 | |
28 | | }
   | |_^
   |
   = note: `#[warn(fn_name_contains_todo)]` on by default

warning: 1 warning emitted

如果觉得 lib.rs 其他代码污染了 cargo expand 或者 TokenSteam/HIR 代码的展开

例如我只想看两个函数的HIR代码,那么可以新建一个 examples/fn_name_contains_todo.rs 文件或者用 --tests 指定静态分析的文件

例如 cargo clean && cargo b && cargo dylint --all -- --examples

至于 ui 测试方法,建议去读 clippy 文档,本文不做演示

⚠注意!: 跑 ui 测试时不能加 RUST_LOG=info 日志级别的环境变量,否则会把日志输出导致测试比较失败

禁止递归代码的 lint

由于自己对 AST/HIR 解析不是很熟练,我参考了以下代码:

  • rustc_lint::builtin::UNCONDITIONAL_RECURSION: 没找到实现代码(好像在MIR)
  • clippy 的 main_recursion lint

仿照 main_recursion 不难写出静态分析检测递归的代码:


#![allow(unused)]
fn main() {
impl rustc_lint::LateLintPass<'_> for MyLintRecursiveCode {
    fn check_expr_post(&mut self, cx: &rustc_lint::LateContext<'_>, expr: &rustc_hir::Expr< '_>) {
        if let rustc_hir::ExprKind::Call(func_expr, _) = &expr.kind {
            // 函数调用表达式 func_expr 的前一个函数调用**栈帧**函数的 defid
            let func_expr_owner_defid = func_expr.hir_id.owner.to_def_id();
            if let rustc_hir::ExprKind::Path(rustc_hir::QPath::Resolved(_, path)) = &func_expr.kind {
                // path.res: The resolution of a path or export
                if let Some(func_expr_call_defid) = path.res.opt_def_id() {
                    if func_expr_owner_defid == func_expr_call_defid {
                        clippy_utils::diagnostics::span_lint(
                            cx,
                            MY_LINT_RECURSIVE_CODE,
                            expr.span,
                            "our company forbid recursive code, Reference: company_code_style.pdf, page 17",
                        );
                    }
                }
            }
        }
    }
}
}

遗憾的是还不能检测a调用b且b调用a这种函数循环调用导致的无限递归

在 Rust 官方的 issue 57965, 70727 中也在讨论编译期要如何检测跨函数调用导致的无限递归

更多的 lint 需求

f32_cast_to_f64

公司业务上有个价格的参数需要保留两位小数,然后再从 f32 转换为 f64 进行传输,也就是 JavaScript 的 Number 类型

但是保留两位小数的 f32 转 f64 时会精度丢失: 0.1_f32 as f64 = 0.10000000149011612

但这是浮点数 IEEE 标准的正常行为,用 C/C++ 进行 float -> double 的转换也会出现一样的问题

目前 Rust 浮点数的 lint 只有大小比较、i32 as f32 等 lint 并没有业务需要的这种精度丢失检测

所以很有必要根据自身业务定制一些浮点数的 lint ,避免前端展示的浮点数出现异常情况


结束语

得益于 强大的调试宏 dbg! 和 AST/HIR 优秀的结构体设计,像作者这样非计算机专业没学过编译原理课程的水平也能轻松定制静态分析

本文的源码仓库: https://github.com/pymongo/my_lints ,欢迎大家能贡献代码检查的需求或想法

无需 fork Clippy 就可以编写 Rust lints

原文

本文主要介绍 Dylint,它是一个可以从动态库中加载 Rust lints 规则的工具。Dylint 可以让开发人员轻松维护自己的个人 lint 集合。

在此之前,编写一个新的 Rust lint 的最简单的方式就是 fork Clippy,它是 Rust 事实上的 (de facto) lint 工具。但是这种方式在运行或维护新 lint 时存在缺陷 (drawback)。Dylint 最大程度的减少了这方面的干扰 (distraction),让开发者可以专注于编写 lint。

首先,我们将回顾 (go over) Rust linting 的当前状态以及 Clippy 的工作原理。然后,我们将解释 Dylint 是如何改善现状 (quo) 并提供一些关于如何开始使用它的提示。如果你想直接编写 lint,请跳到最后一节。

Rust linting 和 Clippy

Clippy 等工具利用了 Rust 编译器对 linting 的专用支持。Rust linter 的核心组件(即“驱动程序”)可以链接到对应的库 (rustc_driver) 中。通过这种方式,驱动程序本质上是对 Rust 编译器做了封装。

为了运行 linter,环境变量 RUSTC_WORKSPACE_WRAPPER 需要指向驱动程序并运行cargo check。Cargo 会注意到该环境变量已被赋值并调用该驱动程序,而不是调用 rustc。当驱动程序被调用时,它在 Rust 编译器中的 Config 结构体中设置了一个 callback。该 callback 注册了一些 lint,它们将会与 Rust 内置的 lint 一起运行。

Clippy 执行一些检查以确保它已被启用,否则将以上述方式进行工作。(关于 Clippy 架构,请参阅图 1)。尽管它在安装之后人们对它的认识依旧不是那么的清晰,但是 Clippy 实际上有两个二进制文件:一个 Cargo 命令以及一个 rustc 驱动。你可以输入以下命令进行验证:

which cargo-clippy
which clippy-driver
dylint1
图 1:Clippy 架构

现在假设你想编写自己的 lint。你该怎么办?你需要一个驱动程序来运行它们,而 Clippy 有一个驱动程序,因此 fork Clippy 看起来是一个合理的步骤。但是这个解决方案有一些缺陷,即 (namely) 运行和维护你将开发的 lint。

首先,你的 fork 将拥有两个二进制文件的副本,确保它们可以被找到是一件很麻烦 (hassle) 的事情。你必须确保至少 cargo 命令在你的PATH中,并且你可能必须将二进制文件重命名,以保证它们不会干扰 Clippy。虽然这些问题不是难以克服 (insurmountable),但你可能会选择尽量避免它们。

其次,所有的 lint (包括 Clippy 的 lint) 都是在 unstable 编译器 API 之上构建的。一起编译的 lint 必须使用相同版本的 API。为了理解为什么会出现这个问题,我们将参考 clippy_utils - Clippy 作者慷慨地公开的一组实用程序。请注意,clippy_utils 使用与 lint 相同的编译器 API,并且同样不提供稳定性保证(参见下文)。

假设你已经 fork Clippy,然后你希望添加一个新的 lint。很显然,你希望新的 lint 使用最新版本 clippy_utils。但是假设 clippy_utils 使用的编译器版本是 B,而你 fork 的 Clippy 使用的编译器版本是 A。然后你将面临一个困境 (dilemma):你应该使用一个旧版本的 clippy_utils (使用的 A 版本的编译器)还是将 fork 中所有 lint 更新到 B 版本的编译器?两者都不是理想的选择。

Dylint 同时解决了这两个问题。首先,它提供了一个 Cargo 命令,使你不必管理多个这样的命令。其次,对于 Dylint,lint 是在一起编译的以生成动态库。因此在上述情况下,你可以简单地将新的 lint 存储在使用B 版本的编译器的新动态库中。你可以根据需要将这个新库与现有库一起使用,如果你愿意的话,可以将现有库升级到更新的库的编译器版本。

Dylint 提供了与重用中间编译结果相关的额外好处。要理解它,我们需要了解 Dylint 的工作原理。

Dylint 的工作原理

和 Clippy 一样,Dylint 提供了一个 cargo 命令。可以指定用户想要加载 lint 的动态库。Dylint 以确保在将控制权移交给 Rust 编译器之前注册 lint 的方式运行cargo check

然而,Dylint 的 lint 注册过程比 Clippy 更复杂。Clippy 的所有 lint 都使用相同的编译器版本,因此只需要一个驱动程序。但是 Dylint 用户可以选择从不同编译器版本的库中加载 lint。

Dylint 按需即时 (on-the-fly) 构建驱动程序来处理此类情况。换句话说,如果用户想要 A 版本的编译器库中加载 lint,并且找不到 A 版本的驱动程序,Dylint 将构建一个新的 A 版本的驱动程序。驱动程序缓存在用户的主目录中,因此仅在必要时重建它们。

dylint1
图 2:Dylint 架构

这给我们带来了上面暗指 (alluded to) 的额外好处。Dylint 根据它们使用的编译器版本对库进行分组,使用相同编译器版本的库一起加载,并在它们的 lint 一起运行。这允许在 lint 之间共享中间编译结果(如:符号解析,类型检查,特征求解等)。

举个例子,在图 2 中,如果库 U 和 V 都使用了 A 版本的编译器,这两个库将被放到同一个分组中。A 版本编译器的驱动程序将只被调用一次。驱动程序在将控制权移交给 Rust 编译器之前会在库 U 和库 V 中注册 lint。

为了理解为什么这种方式更好,可以做如下思考。假设 lint 由编译器驱动程序(而不是动态库)直接存储,并回顾一下驱动程序本质上是 Rust 编译器的封装。因此,如果在使用相同编译器版本的两个编译器的驱动程序中有两个 lint,则在同一代码上运行这两个驱动程序将等同于该代码进行了两次编译。通过将 lint 存储在动态库中并按照编译器版本对它们进行分组,Dylint 避免了这些低效的操作。

应用:特定项目的 lint

你是否知道 Clippy 包含 lint,其唯一目的是对 Clippy 的代码进行 lint?这是真的。Clippy 包含用于检查的 lint,例如:每个 lint 都有一个关联的 LintPass,它使用某些 Clippy 封装函数而不是它们自己封装的函数,并且每个 lint 都有一个非默认的描述。将这些 lint 应用于 Clippy 以外的代码是没有意义的。但是没有规定所有 lint 都必须是通用的,Clippy 就利用了这一点。

Dylint 包含 lint 的主要目的是对 Dylint 的代码进行 lint。例如:在开发 Dylint 时,我们发现自己编写了如下代码:


#![allow(unused)]
fn main() {
let rustup_toolchain = std::env::var("RUSTUP_TOOLCHAIN")?;
...
std::env::remove_var("RUSTUP_TOOLCHAIN");
}

这么做不是很好。为什么?因为我们对字符串字面量进行 fat-fingered 只是时间问题。


#![allow(unused)]
fn main() {
std::env::remove_var("RUSTUP_TOOLCHIAN"); // Oops
}

更好的方法是使用常量而不是字符串字面量,就如下代码所示:


#![allow(unused)]
fn main() {
const RUSTUP_TOOLCHAIN: &str = "RUSTUP_TOOLCHAIN";
...
std::env::remove_var(RUSTUP_TOOLCHAIN);
}

因此当使用 Dylint 时,我们编写了一个 lint 来检查这种不适当的做法并提出适当的建议。我们将该 lint 应用到 Dylint 源码。lint 称其为 env_literal,其当前的核心实现如下:


#![allow(unused)]
fn main() {
impl<'tcx> LateLintPass<'tcx> for EnvLiteral {
    fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &Expr<'_>) {
        if_chain! {
            if let ExprKind::Call(callee, args) = expr.kind;
            if is_expr_path_def_path(cx, callee, &REMOVE_VAR)
            || is_expr_path_def_path(cx, callee, &SET_VAR)
            || is_expr_path_def_path(cx, callee, &VAR);
            if !args.is_empty();
            if let ExprKind::Lit(lit) = &args[0].kind;
            if let LitKind::Str(symbol, _) = lit.node;
            let ident = symbol.to_ident_string();
            if is_upper_snake_case(&ident);
            then {
            span_lint_and_help(
                cx,
                ENV_LITERAL,
                args[0].span,
                "referring to an environment variable with a string literal is error prone",
                None,
                &format!("define a constant `{}` and use that instead", ident),
            );
            }
        }
    }
}
}

以下是它可以产生的警告示例:

warning: referring to an environment variable with a string literal is error prone
--> src/main.rs:2:27
|
2 |     let _ = std::env::var("RUSTFLAGS");
|                           ^^^^^^^^^^^
|
= note: `#[warn(env_literal)]` on by default
= help: define a constant `RUSTFLAGS` and use that instead

回顾之前所说的,编译器以及 clippy_utils 都没有为它的 API 提供稳定性保证,因此 env_literal 的未来版本可能看起来有点不同。(实际上,当本文还在撰写的过程中,clippy_utils 某个 API 的变更就已经导致 env_literal 某个实现发生改变!)。当前版本的 env_literal 总是可以在 Dylint 仓库中的 examples 目录下找到。

但是 Clippy “自我 lint” 的方式与 Dylint 略有不同。Clippy 的内部 lint 被编译成启用了特定功能的 Clippy 版本。但是对于 Dylint,env_literal lint 被编译成了一个动态库。因此,env_literal 不是 Dylint 的一部分。它本质上是输入。

为什么这很重要?因为你可以为你的项目编写自定义 lint 并使用 Dylint 来运行它们,就像 Dylint 运行自己的 lint 一样。在 Dylint 仓库中 Dylint 运行的 lint 来源没有任何重要意义。Dylint 可以很轻易的在你的仓库中运行该仓库的 lint。

最重要的是 (The bottom line is this):如果你发现不喜欢自己编写的代码,并且可以使用 lint 检测该代码,Dylint 可以帮助你清除该代码并防止重新引入。

开始 linting

使用以下命令安装 Dylint:

cargo install cargo-dylint

我们还推荐安装 dylint-link 来处理超链接:

cargo install dylint-link

编写 Dylint 库的最简单的方式是 fork dylint-template 仓库。该仓库直接生成了一个可加载的库。你可以按如下方法进行验证:

git clone https://github.com/trailofbits/dylint-template
cd dylint-template
cargo build
cargo dylint fill_me_in --list

你只需实现 LateLintPass 特征并容纳要求填写的符号即可。

以下资源对你编写 lint 将很有帮助:

也可以考虑使用上面提到的 clippy_utils。它包含许多底层任务的功能,如查找符号和打印诊断信息,可以让编写 lint 变得更加容易。

我们十分感谢 Clippy 作者将 clippy_utils 开放在 Rust 社区。我们也十分感谢 Philipp Krones 在本文的早期版本中提供了有用的建议。

学习资源

编辑:张汉东


《Programming Rust》第二版已经官宣发行

摘要:

由O'Reilly出版的《Programming Rust》第二版的电子书和实体书都已经上市了:

关于《Programming Rust》

《Programming Rust》是一本深入考察Rust语言设计思想以及如何将其应用于一系列系统编程问题的技术书籍。本书通过若干入门级的项目示例,深入研究了Rust语言具体的语言特性和各种特性相互作用的逻辑,从类型系统到如何根据类型系统建模现实中的问题,从unsafe rust的本质探讨、如何处理FFI外部函数调用到Rust异步编程等高级特性,无一不做到了深入浅出。

为什么编写第二版?

本书较之第一版,根据Rust编译器最新版本的特性完全进行了跟进,并且新增了一个详尽的讲述异步编程的机制和技巧的章节。 《Programming Rust》是一本适合想要了解Rust语言的C、C++、Python、Java或者其他语言的开发者,或者那些想要提升自己编程技巧的Rust编程人员的书籍。

《Rust in Action》 新书发布

新书:使用 Rust 进行系统编程

Hands-On Systems Programming with Rust

Rust 机器学习之书

由 Rust-ml 组织编写,目测正在完善中

该书内容目测是围绕 rust-ml/linfa 库,而/rust-ml/linfa 库 类似于 python scikit-learn 库。

scikit-learn,又写作sklearn,是一个开源的基于python语言的机器学习工具包。 它通过NumPy, SciPy和Matplotlib等python数值计算的库实现高效的算法应用,并且涵盖了几乎所有主流机器学习算法。

Solana Season Hackathon 参赛项目之一

发个 Brian 的 Solana Season Hackathon 参赛项目,现在爆火的 NFT 方向。

Brian 也写了篇文章(First impressions of Rust programming on Solana)来介绍 Solana 的感受。

Rust 社区朋友关于 Actix-web 和 Rocket 框架的观点

之前用 actix-web 寫了兩個小專案,覺得 actix 還行,但用起來沒有特別有熱情。這兩天藉機第一次試用了下 rocket 0.5.rc1,雖然還沒寫多少代碼,但個人感覺認為 rocket 的設計更 rusty。說真的稍微有點燃起來了,就是當初第一次認識 rust 時的那種愉悅感。

具體說來,rocket 感覺對於安全與正確性的追求非常徹底,幾乎所有東西都強型別且能編譯期進行驗證。

而且,在保證變強的同時,整體寫法也更簡單了;rocket 的設計會自然誘使開發者寫出強型別的代碼。就個人經驗來說,actix_web 沒有這種傾向,或至少傾向較弱。

此外,部份無法於編譯期驗證的項目,凡有注意到的,也都會在服務啟動瞬間驗證完畢。比方說部份 route handler 可能會依賴某些全域 State (等效於 actix_web 中的 web::Data,像是資料庫連接池之類的東西),這些注入項如果沒有正確初始化,rocket 啟動瞬間就會報錯。而 actix_web 碰到 web::Data 忘記注入的狀況,會要等到呼叫了該 route 時才出錯。除此之外,像是 routing table 是否有衝突等問題,也會在服務啟動瞬間驗證完畢。而在內建服務以外如果有想驗證的東西,也有接口供用戶自定義自己的啟動時驗證方式。

還有很多方便寫碼的小特徵,比方說開發者只有明確需要控制 async 運行狀態時,才需要寫 async fn,否則只要和 fn 打交道即可。除非有東西想要 await 或想平行運行,否則不必然需要處理 Future 接口。

功能方面,我個人特別開心的是 rocket 完整支援了(包含處理檔案上傳在內)Multipart Form 的全部功能。Form 還可以傳輸任意完整的 json-like nested collections 資料格式,這表示不需要透過 javascript 轉成 Json 就能語意精確地發送複雜資料,或是方便地把複雜資料連同檔案合併傳輸。雖然我有點懷疑會有多少人真的這樣設計接口,喂這傢伙不會做過頭了嗎 XDDD

關於 Form 資料的騷操作可看官方說明: https://rocket.rs/v0.5-rc/guide/requests/#collections

Middleware 方面,rocket 的 Fairing 接口也遠比 actix_web 的 Service 容易寫。actix_web 的完整 Service 寫起來實在很整人,有興趣的話可以比較一下兩者 example 的複雜度: [https://rocket.rs/v0.5-rc/guide/fairings/#example https://actix.rs/docs/middleware/](https://rocket.rs/v0.5-rc/guide/fairings/#example https://actix.rs/docs/middleware/)

不過得說一句,fairing 與 middleware 的功能不完全一樣,Rocket 的 Fairing 蓄意被設計得比傳統 middleware 更受限(因為許多問題的解決方案 rocket 不建議由 middleware 處理),因此這種比較可能不完全公平。如此設計的理由可見:

https://github.com/SergioBenitez/Rocket/issues/55#issuecomment-274655441

舉例來說,Rocket 推薦透過一種叫 Custom Guard 的機制,控制每個 route 的用戶身份與存取權。如果 Guard 解析失敗當然無權進入對應的 route,反之如果解析成功,自然會得到已經經過強型別建模後的用戶身份。第一印象看起來非常清晰,之後打算更深入試試看。如果不清楚 Custom Guard 是什麼的話,可看官方範例:

https://api.rocket.rs/v0.5-rc/rocket/request/trait.FromRequest.html#example-1

rocket 用了不少 macro,但是都是非常小且局部的,我認為反而提升了代碼的清晰度。我特別喜歡 #[derive(Responder)],由此建構強型別 Response 非常容易,且單一一個就統整了 actix 的 ResponseError 與 HttpResponse 兩個用例。

在錯誤處理方面,rocket 除 Responder 可回傳 404 等錯誤外,還有額外一層 Catcher 層,能參考 StatusCode 與 Request 內容產生適當響應。這層看上去是通用錯誤如 404, 401 的解決方案,應該是設計來與 Custom Guard 組合使用的,這也表示並非所有錯誤都需要在 route 中手動組裝定義,會對編碼更方便。但關於此處 catcher 方面的想定說明,我還不是很有把握,需要再深入體會一下,此處說法僅供參考。

以上只是個人簡單用過後的印象,兩個框架距離精通都還遠得很,有錯請隨手斧正。

(如果大家想交流,可以在评论区回复)

对 王垠《对 Rust 语言的分析》的分析

时隔五年,回头看 王垠这篇文章

Rust vs. C++ for game development

本文在游戏开发的背景下比较Rust与C ++。 并介绍Rust和C++之间的相似性和差异以及使用每个编程语言进行游戏开发的优缺点和工具

https://blog.logrocket.com/rust-vs-c-for-game-development/

使用 Rust 和 React 构建桌面 App

基于 Tauri 框架

https://kent.medium.com/get-started-making-desktop-apps-using-rust-and-react-78a7e07433ce

【视频】Rust 无锁编程|实现险象指针(Hazard Pointers )

Jon Gjengset 的系列视频之一

https://www.youtube.com/watch?v=fvcbyCYdR10

微软发布 Rust 新的学习视频

https://channel9.msdn.com/Series/Beginners-Series-to-Rust?WT.mc_id=academic-29077-cxa

揭秘Rust中的可变性和引用

  1. 对于可变引用,不能出现在不可变引用的声明域和使用域之间;
  2. 对于可变/不可变引用,不能出现在在可变引用的声明域和使用域之间。

这就是Rust的借用检查规则:在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用。

Demystifying Mutability and References in Rust

给C程序员的Rust入门系列文章

Learn Rust the Dangerous Way

使用Windbg调试非安全Rust代码导致的崩溃

作者在Windows平台写非安全Rust代码的时候遇到一个奔溃:


#![allow(unused)]
fn main() {
error: process didn't exit successfully: `target\debug\rustdesk.exe` (exit code: 0xc0000374, STATUS_HEAP_CORRUPTION)
}

因为这是一个与unsafe的Rust代码相关的分段故障崩溃,很难从控制台消息中得到任何线索,作者便使用了其它工具进行调试,并且记录在了这篇Blog中。

这篇blog的作者是大家最近比较热门的用Rust写的跨平台远程桌面开源软件 RustDesk 的作者。

Read More: https://dev.to/rustdesk/debugging-a-crash-in-unsafe-rust-with-windbg-2b39

使用 Rust+WASM 的 WebRTC 视频聊天教程

基于Rust+WASM构建的ewebrt信令服务器,结合chrome浏览器构建简单视频聊天应用程序

链接: https://charles-schleich.medium.com/webrtc-video-chat-tutorial-using-rust-wasm-fa340f7aeef9

TheAlgorithms: Rust

大名鼎鼎的 《The Algorithms》 的 Rust 版本,使用 Rust 实现所有算法。

TheAlgorithms/Rust: All Algorithms implemented in Rust

对比 Java : Rust 中的 面向对象特性

https://blog.knoldus.com/object-oriented-programming-concepts-in-rust/

Android 平台中的 Rust/C++ 互操作

https://security.googleblog.com/2021/06/rustc-interop-in-android-platform.html

使用nannou和rust-gpu进行实时光线追踪

Peter Shirley 尝试实时实现“在周末做一个光线追踪”。这是一个个人实验,目的是了解更多关于 rust-gpu、光线追踪以及实时光线追踪的局限性。

https://github.com/mitchmindtree/nannou-rustgpu-raytracer

cacao 0.2.0 发布,使用 Rust 构建 native 的 MacOS 应用

该库为 macOS 上的 AppKit 和 iOS/tvOS 上的 UIKit(alpha 质量,请参阅 repo)提供安全的 Rust 绑定。 如果之前使用 Swift 或 Objective-C 为该框架进行过编程,cocao 会尝试提供一种熟悉的开发体验。

由于所有权模型,这在 Rust 中很棘手,但一些创造性的编码和假设可以让我们走得很远。crates.io 上存在 0.2.0 部分是为了使项目能够看到更广泛的使用,这可以为开发提供信息。 也就是说,这个库目前处于早期阶段,可能有错误——你使用它的风险由你自己承担。 但是,只要开发者遵守规则(关于内存/所有权),对于某些应用程序来说就可以了,核心存储库有丰富的示例可以帮助开发者入门。

https://crates.io/crates/cacao

Rust中类型的未开发的潜力

Rust 的类型,除了可以用来检查属性外,还可以做一些更有意思的事情. 本文会涉及到很多动态类型.

https://www.jakobmeier.ch/blogging/Untapped-Rust.html

使用Rust+WebAssembly 加速Webcola图可视化工具

摘要:

作者最近从事的项目希望在web页面上展示Spotify上不同音乐家之间的联系。Spotify提供了可以获取数据的官方API,作者获取某个用户最喜欢的若干个音乐家,然后把他们之间的关系在浏览器上展示出来。受HTTP服务器性能调优的启发,借助一系列profile和分析工具,使用Rust+WebAssembly的方式优化了Webcola的调用,使得单机性能从<10 FPS 提升到超过60 FPS。 详细博文可以参见:https://cprimozic.net/blog/speeding-up-webcola-with-webassembly/

【教程】用Rust动手实现一个桌面跨平台GUI项目

使用iced构建一个Rust跨平台GUI项目,作者是我,因为有些地方比较仓促,可能有不少错别字,可以的话帮我提一下issue。

其实写到现在,前五章的内容都比较简单,如果目的主要是想要学一下iced怎么用,我的建议是看看官方的例子比较好。

本教程的受众偏向Rust新手,已经看过Rust的大部分概念,急需一个实战项目练手的小伙伴。

目前正在筹备的新章节是Canvas绘制图表,可能干货上比前几章多点。

后续还有多语言支持,多主题支持,以及如何自己构建一个iced的控件,感兴趣的一定不要错过。

0-5章: https://localnative.app/docs/tutorial0

Rust中使用 mongoDB 和 redis

本文详细讲解了如果使用 Rust 来操作 mongo 和 redis, 并且使用 actix web 来构建一个简单的 web 应用.

https://.com/blog/2021/06/mongodb-redis-rust/

Totally Speedy Transmute: std::mem::transmute的安全替代

如果你想要一个标准库中transmute函数的安全替代,可以看看这个。

https://docs.rs/totally-speedy-transmute/1.69.420/totally_speedy_transmute/

Rust过程宏系列教程 | Proc Macro Workshop 之 Debug

作者:米明恒 / 后期编辑:张汉东

本文来自于 blog.ideawand.com 投稿


本系列的上一篇文章中,我们实战了proc_macro_workshop项目的builder题目。并且介绍了proc_macro_workshop这个项目的概况,如果您是第一次阅读本系列文章,对proc_macro_workshop项目的结构还是不很熟悉的话,可以先阅读一下上一篇文章。

好了,不废话了,准备好一台电脑,开始我们的第二个挑战任务debug

首先打开proc_macro_workshop项目的readme.md文件,看一下debug这个项目要实现什么样的功能。根据其中的描述,这个题目的最终目标是实现一个可以输出指定格式调试信息的派生宏,他要实现的功能和rust自带的Debug派生宏是一样的,只不过我们要实现的这个宏比Debug更强大一些,可以指定每一个字段的输出格式。

我们之前提到过,Rust中的过程宏分为三种样式:派生样式的、属性样式的,还有函数样式的,上一篇和本篇要讨论的过程宏都是派生样式的,另外两种样式的过程宏会在后续文章中对另外三道题目讲解时介绍。如果你对派生样式的过程宏还不了解,请一定先阅读本系列的前一篇文章。本篇文章介绍的debug挑战题目,除了在大量使用上一篇builder项目使用的知识点之外,主要增加了对泛型的处理。

第一关

第一关视频版:https://www.bilibili.com/video/BV1vU4y187TR?zw

第一关的工作和上一篇文章中介绍的builder题目的第一关一样,搭建一个框架,配置好cargo.toml,然后把输入的TokenStream转换为syn::DeriveInput类型即可。项目代码结构可以参考第一篇文章Rust过程宏系列教程(2)--实现proc-macro-workshop项目之builder题目,回忆一下上篇文章提到的知识点,我们要实现一个类似下面这种结构的框架,便于我们做错处处理:


#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use syn::{self, spanned::Spanned};
use quote::{ToTokens, quote};

#[proc_macro_derive(CustomDebug)]
pub fn derive(input: TokenStream) -> TokenStream {
    let st = syn::parse_macro_input!(input as syn::DeriveInput);
    match do_expand(&st) {
        Ok(token_stream) => token_stream.into(),
        Err(e) => e.to_compile_error().into(),
    }
}

fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let ret = proc_macro2::TokenStream::new();
    return Ok(ret);
}
}

第二关

第二关视频版:https://www.bilibili.com/video/BV1Kf4y1W7ss?zw

第二关要实现基本的Debug Trait,其原理是使用rust标准库提供的std::fmt::DebugStruct结构来实现,例如对于下面这个结构体


#![allow(unused)]
fn main() {
struct GeekKindergarten {
    blog: String,
    ideawand: i32,
    com: bool,
}
}

我们要生成如下模式的代码,实现Debug Trait:


#![allow(unused)]
fn main() {
impl fmt::Debug for GeekKindergarten {
    fn fmt(&self, fmt: &mut fmt::Formatter) -> fmt::Result {
        fmt.debug_struct("GeekKindergarten")
           .field("blog", &self.blog)
           .field("ideawand", &self.ideawand)
           .field("com", &self.com)
           .finish()
    }
}
}

这样,我们就可以在println!中使用{:?}来打印结构体中的各个字段。

fn main() {
    let g = GeekKindergarten{blog:"foo".into(), ideawand:123, com:true};
    println!("{:?}", g);
}

所以,我们目标也很明确了,和上一篇的builder类似,我们要首先读取出被过程宏处理的结构体的每一个字段的名字,然后按照模板生成上面的代码即可,没有什么新的知识,所以我们直接给出代码即可:


#![allow(unused)]
fn main() {
fn do_expand(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let ret = generate_debug_trait(st)?;
    return Ok(ret);
}

type StructFields = syn::punctuated::Punctuated<syn::Field,syn::Token!(,)>;
fn get_fields_from_derive_input(d: &syn::DeriveInput) -> syn::Result<&StructFields> {
    if let syn::Data::Struct(syn::DataStruct {
        fields: syn::Fields::Named(syn::FieldsNamed { ref named, .. }),
        ..
    }) = d.data{
        return Ok(named)
    }
    Err(syn::Error::new_spanned(d, "Must define on a Struct, not Enum".to_string()))
}

fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let fields = get_fields_from_derive_input(st)?;
    let struct_name_ident = &st.ident;
    let struct_name_literal = struct_name_ident.to_string();

    let mut fmt_body_stream = proc_macro2::TokenStream::new();

    fmt_body_stream.extend(quote!(
        fmt.debug_struct(#struct_name_literal) // 注意这里引用的是一个字符串,不是一个syn::Ident,生成的代码会以字面量形式表示出来
    ));
    for field in fields.iter(){
        let field_name_idnet = field.ident.as_ref().unwrap();
        let field_name_literal = field_name_idnet.to_string();
        
        fmt_body_stream.extend(quote!(
            .field(#field_name_literal, &self.#field_name_idnet)  // 这行同样注意literal和ident的区别
        ));
    }

    fmt_body_stream.extend(quote!(
        .finish()
    ));

    let ret_stream = quote!(
        impl std::fmt::Debug for #struct_name_ident {
            fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
                fmt_body_stream
            }
        }
    );

    return Ok(ret_stream)
}
}

第三关

第三关视频版:https://www.bilibili.com/video/BV1df4y1a73x?zw

这一关要求我们可以为结构体中的每一个字段指定自己独立的格式化样式,也就是可以在结构体内部写下面这样的惰性属性标注:


#![allow(unused)]
fn main() {
#[derive(CustomDebug)]
struct GeekKindergarten {
    blog: String,
    #[debug = "0b{:32b}"]    // 在派生式过程宏中,这是个`惰性属性`,在上一篇文章中有介绍
    ideawand: i32,
    com: bool,
}
}

在上面代码中,#[debug=xxx]是我们编写的CustomDebug派生宏的惰性属性,派生宏惰性属性的概念在上一篇文章中介绍过了,因此我们可以参照一下前面文章中编写的提取惰性属性的函数,进行编写即可。一个提示是,在编写的过程中,可以继续使用print大法来分析属性的结构。

  • 在上一篇文章中,我们处理的惰性属性是#[builder(each=xxxx)]的形式,这个写法在解析成语法树时是两层的嵌套结构,外面的builder(xxxx)转换成语法树节点的MetaList类型结构,而内部的each=xxxx转换成语法树节点的NameValue类型结构
  • 本篇文章处理的,直接就是#[debug = xxxx]的形式,所以处理起来,其实比上一题的简单一些

在我们从惰性属性中拿到格式模板以后,接下来要做的就是使用fmt包中提供的相关方法,来格式化我们的结构,这部分只是与过程宏的开发无关,主要是fmt包中相关格式化工具的用法,参考资料在第三关的测试文件中也已经给出,我将其复制在下面,大家可以自行参阅:

  • https://doc.rust-lang.org/std/macro.format_args.html

通过阅读上面的参考资料,我们可以了解到,想在debug_struct工具方法里指定输出的格式,我们就要借助format_args!宏,生成例如下面这样的代码:


#![allow(unused)]
fn main() {
// 原来的样子是:
// .field("ideawand", &self.ideawand)
// 现在的样子是:
.field("ideawand", &format_args!("0b{:32b}", self.ideawand))
}

有了上述的分析,我们可以直接给出第三关的核心代码,首先是提取字段惰性属性的函数:


#![allow(unused)]
fn main() {
fn get_custom_format_of_field(field: &syn::Field) -> syn::Result<Option<String>> {
    for attr in &field.attrs {
        if let Ok(syn::Meta::NameValue(syn::MetaNameValue {
            ref path,
            ref lit,
            ..
        })) = attr.parse_meta()
        {
            if path.is_ident("debug") {
                if let syn::Lit::Str(ref ident_str) =lit {
                    return Ok(Some(
                        ident_str.value()
                    ));
                }
            }
        }
    }
    Ok(None)
}
}

然后修改一下generate_debug_trait()函数,这里只给出了进行调整的核心循环体的代码片段


#![allow(unused)]
fn main() {
fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    
    // <此处省略未修改代码> ................

    for field in fields.iter(){
        let field_name_idnet = field.ident.as_ref().unwrap();
        let field_name_literal = field_name_idnet.to_string();
        
        // 以下若干行代码直到循环体结束 是第三关进行修改的部分
        let mut format_str = "{:?}".to_string();
        if let Some(format) = get_custom_format_of_field(field)? {
            format_str = format;
        } 
        // 这里是没有指定用户自定义的格式
        fmt_body_stream.extend(quote!(
            .field(#field_name_literal, &format_args!(#format_str, self.#field_name_idnet))
        ));

    }

    // <此处省略未修改代码> ................

}
}

第四关

第四关视频版:https://www.bilibili.com/video/BV1S64y1d7fc?zw

从这一关开始,我们接触一下泛型参数的处理,有点小激动~~

在前几关中我们生成的Debug Trait的代码是没有泛型参数的,因此,对于形如下面的带有泛型参数的结构体:


#![allow(unused)]
fn main() {
struct GeekKindergarten<T> {
    blog: T,
    ideawand: i32,
    com: bool,
}
}

我们生成的代码应该是形如


#![allow(unused)]
fn main() {
impl<T> Debug for GeekKindergarten<T> {
    // .....
}
}

但由于我们在代码模板中只是用了结构体的标识符,也就是GeekKindergarten这一部分,而没有使用泛型参数信息,也就是丢掉了<T>这一部分,因此我们生成的代码会是下面这个样子:


#![allow(unused)]
fn main() {
impl Debug for GeekKindergarten {
//  ^---- 这里丢掉了泛型参数 -----^
}
}

第四关的提示文档里,给出了泛型参数语法树节点的链接:

  • https://docs.rs/syn/1.0/syn/struct.Generics.html

这个语法树节点提供了一个工具函数,可以帮助我们把泛型参数切分成三个用于生成impl块的片段,这个函数是:

  • https://docs.rs/syn/1.0/syn/struct.Generics.html#method.split_for_impl

此外,他还给出了另一个示例程序的代码库地址,里面演示了如何处理泛型参数,推荐大家去看一下,不过毕竟这个链接只是纯代码,没什么讲解,所以大家还是要先读完我的文章,关注一下我的微信公众号【极客幼稚园】~ 示例项目地址:

  • https://github.com/dtolnay/syn/tree/master/examples/heapsize

我们重点来看一下split_for_impl()这个工具函数的用法,比如说我们有这样一个泛型结构体,泛型参数TU分别受到BlogIdeaWandCom这三个Trait Bound的限制:


#![allow(unused)]
fn main() {
struct GeekKindergarten<T, U> where T: Blog + IdeaWand, U: Com {}
}

那么,我们生成的Debug Trait的形式应该是下面这样的:


#![allow(unused)]
fn main() {
impl<T,U> Debug for GeekKindergarten<T, U> where T: Blog + IdeaWand + Debug, U: Com + Debug {
 // ^^^^^                           ^^^^^^  ^^^^^^^^^^^^^^^^^^^^^^^          ^^^^^^
 //  |                                 |            |                           |   
 //  |                                 |            +---------第三部分 ----------+
 //  |                                 +--第二部分
 //  +------第一部分
}
}

split_for_impl()这个工具函数就是用来帮助我们生成上面这三个代码片段的,上面的限定在还出现了Debug,这个是我们要后面再手动加上去的,并不是split_for_impl()能帮我们生成的,所以我没有把他们标出来。

好了,我们总结一下第四关需要做的事情,然后给出示例代码:

  • DeriveInput语法树节点获取泛型参数信息
  • 为每一个泛型参数都添加一个Debug Trait限定
  • 使用split_for_impl()工具函数切分出用于模板生成代码的三个片段
  • 修改impl块的模板代码,使用上述三个片段,加入泛型参数信息
  • 此外由于目前generate_debug_trait()函数已经较为冗长,我们也对代码的结构进行微调,将其拆分为两个函数。

接下来上代码,首先是拆分出的新函数:


#![allow(unused)]
fn main() {
fn generate_debug_trait_core(st :&syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
    let fields = get_fields_from_derive_input(st)?;
    let struct_name_ident = &st.ident;
    let struct_name_literal = struct_name_ident.to_string();
    let mut fmt_body_stream = proc_macro2::TokenStream::new();

    fmt_body_stream.extend(quote!(
        fmt.debug_struct(#struct_name_literal) 
    ));
    for field in fields.iter(){
        let field_name_idnet = field.ident.as_ref().unwrap();
        let field_name_literal = field_name_idnet.to_string();
        
        let mut format_str = "{:?}".to_string();
        if let Some(format) = get_custom_format_of_field(field)? {
            format_str = format;
        } 
        // 这里是没有指定用户自定义的格式
        fmt_body_stream.extend(quote!(
            .field(#field_name_literal, &format_args!(#format_str, self.#field_name_idnet))
        ));
    }

    fmt_body_stream.extend(quote!(
        .finish()
    ));
    return Ok(fmt_body_stream)
}
}

然后是我们生成impl块的代码,详细阅读一下其中的注释,添加Debug Trait限定的代码一开始我也不知道怎么写,是参考了第四关提示中的heapsize项目的范例:


#![allow(unused)]
fn main() {
fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {

    let fmt_body_stream = generate_debug_trait_core(st)?;

    let struct_name_ident = &st.ident;

    // 从输入的派生宏语法树节点获取被修饰的输入结构体的泛型信息
    let mut generics_param_to_modify = st.generics.clone();
    // 我们需要对每一个泛型参数都添加一个`Debug` Trait限定
    for mut g in generics_param_to_modify.params.iter_mut() {
        if let syn::GenericParam::Type(t) = g {
            t.bounds.push(parse_quote!(std::fmt::Debug));
        }
    }

    // 使用工具函数把泛型抽取成3个片段
    let (impl_generics, type_generics, where_clause) = generics_param_to_modify.split_for_impl();

    let ret_stream = quote!(
        // 注意下面这一行是如何使用三个与泛型参数有关的代码片段的
        impl #impl_generics std::fmt::Debug for #struct_name_ident #type_generics #where_clause {
            fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result {
                fmt_body_stream
            }
        }
    );

    return Ok(ret_stream)
}
}

第五关

第五关视频版:https://www.bilibili.com/video/BV1v44y1z7eb?zw

这一关的关卡说明非常长,信息量不少,我们来仔细看看:

首先抛出了一个问题,例如对于下面这个结构体:


#![allow(unused)]
fn main() {
pub struct GeekKindergarten<T> {
    ideawand: PhantomData<T>,
}
}

这个结构体中使用了PhantomData这个类型,而PhantomData类型本身在标准库中实现了Debug Trait,如下所示:


#![allow(unused)]
fn main() {
impl<T: ?Sized> Debug for PhantomData<T> {...}
}

在这种情况下,我们没有必要限定TDebug的。面对这类本身已经实现了Debug的泛型结构体,我们有一种应对办法,即我们在生成Trait限定时,不是针对每一个泛型参数去限制,而是对结构体中每一个字段的类型来添加限制,还是以下面这个结构体为例来说:


#![allow(unused)]
fn main() {
pub struct GeekKindergarten<T, U> {
    blog: Foo<U>,
    ideawand: PhantomData<T>,
}
}

原来我们代码生成的限定条件是:

  • T: Debug, U: Debug 而我们现在应该生成的是:
  • Foo<U>: Debug, PhantomData<T>: Debug

但是,这样的限定会有很大的副作用,这些副作用在后续的关卡中大家会遇到,所以,解题提示中给我们指出了另一个方法:

  • 因为PhantomData类型的使用太常见了,所以我们就把PhantomData这个类型作为一个特例,我们检测是不是有PhantomData类型的字段,如果有,我们看看它所使用的泛型参数是不是只在PhantomData中出现过,如果是,我们就不为它添加Debug限定了。
  • 在后续关卡中,我们会为PhantomData之外的情况,提供一个“逃生出口”(escape hatch),用来标记某一个字段的类型限定。

在给出上述提示之后,出题人还给我们介绍了一下Rust过程宏的一些设计上的取舍:

  • 在Rust过程宏中,你不可能获得到一个完全正确的Trait限定,这是因为假设要实现这个功能,就要在展开过程宏时做命名解析。而这样做会导致rust编译器复杂度急剧上升
  • Rust核心团队认为这样的取舍带来的收益非常大,因此没有任何计划打算在后续支持宏展开时的命名解析
  • 使用escape hatch来解决问题是一种常用手段
  • 另一种更加常见的手段是,通过Rust的Trait机制,将命名解析的执行时间推后到真正的编译阶段去处理
    • 特别注意一下本关的测试用例代码,看看过程宏的调用是如何能够在不知道S指代的是String类型的情况下,产生出可以调用String类型的DebugTrait实现的。

第五关的解题提示到这里就分析完了,在我们开始写代码之前,我们先看看下面新增代码的主要逻辑,例如有这样一个泛型结构体,则我们的过程宏的行为应该是:


#![allow(unused)]
fn main() {
struct GeekKindergarten<T, U, V, W> {
    blog: T,
    ideawand: PhantomData<U>,
    com: U,
    foo: PhantomData<V>,
    bar: Baz<W>,
}
}
  • 对T,由于没有出现在PhantomData中,则需要对T增加Debug限定
  • 对U,虽然出现在PhantomData中,但因为其同时直接作为com字段的类型,所以仍然需要加Debug限定
  • 对于V,满足这一关设定的特殊条件,不添加Debug限定
  • 对于W,因为其不在PhantomData的泛型参数中,所以需要加Debug限定

可以看到,想实现上面的逻辑,我们需要获取<>之前的类型名称,以及<>内部的类型名称,剩下的就是各种判断这些类型名字对应的字符串是不是满足各种组合条件了。

下面开始撸代码,先定义一个获取PhantomData泛型参数名字的函数,这个函数的作用是把PhantomData<X>里面的X作为字符串提取出来:


#![allow(unused)]
fn main() {
fn get_phantomdata_generic_type_name(field: &syn::Field) -> syn::Result<Option<String>> {
    if let syn::Type::Path(syn::TypePath{path: syn::Path{ref segments, ..}, ..}) = field.ty {
        if let Some(syn::PathSegment{ref ident, ref arguments}) = segments.last() {
            if ident == "PhantomData" {
                if let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments{args, ..}) = arguments {
                    if let Some(syn::GenericArgument::Type(syn::Type::Path( ref gp))) = args.first() {
                        if let Some(generic_ident) = gp.path.segments.first() {
                            return Ok(Some(generic_ident.ident.to_string()))
                        }
                    }
                }
            }
        }
    }
    return Ok(None)
}
}

然后再定义一个函数,用于把结构体定义中foo: XXXfoo:XXX<YYY>这种形式中,XXX所在位置的类型名字(即不包括泛型参数)作为字符串返回:


#![allow(unused)]
fn main() {
fn get_field_type_name(field: &syn::Field) -> syn::Result<Option<String>> {
    if let syn::Type::Path(syn::TypePath{path: syn::Path{ref segments, ..}, ..}) = field.ty {
        if let Some(syn::PathSegment{ref ident,..}) = segments.last() {
            return Ok(Some(ident.to_string()))
        }
    }
    return Ok(None)
}
}

然后我们来修改generate_debug_trait()函数的代码,请仔细阅读其中的注释:


#![allow(unused)]
fn main() {
fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {

    let fmt_body_stream = generate_debug_trait_core(st)?;

    let struct_name_ident = &st.ident;

    
    let mut generics_param_to_modify = st.generics.clone();

    // 以下代码构建两个列表,一个是`PhantomData`中使用到的泛型参数,另一个是输入结构体中所有字段的类型名称
    let fields = get_fields_from_derive_input(st)?;
    let mut field_type_names = Vec::new();
    let mut phantomdata_type_param_names = Vec::new();
    for field in fields{
        if let Some(s) = get_field_type_name(field)? {
            field_type_names.push(s);
        }
        if let Some(s) = get_phantomdata_generic_type_name(field)? {
            phantomdata_type_param_names.push(s);
        }
    }

    for mut g in generics_param_to_modify.params.iter_mut() {
        if let syn::GenericParam::Type(t) = g {
            let type_param_name = t.ident.to_string();
            // 注意这个判断条件的逻辑,精华都在这个条件里了,自己试着看看能不能把上面的4种情况整理成这个条件
            // 如果是PhantomData,就不要对泛型参数`T`本身再添加约束了,除非`T`本身也被直接使用了
            if phantomdata_type_param_names.contains(&type_param_name) && !field_type_names.contains(&type_param_name) {
                continue;
            }
            t.bounds.push(parse_quote!(std::fmt::Debug));
        }
    }

    // <省略未修改代码> ............
    
}
}

在结束第五关之前,我们再来回想一下出题人给我们留下的思考题:

在第五关的测试用例中,过程宏的调用是如何能够在不知道S指代的是String类型的情况下,产生出可以调用String类型的DebugTrait实现的?

其实,这个问题很简单,大家只要记住一件事,过程宏,其实就是在玩“字符串替换拼接”的游戏,在过程宏执行的时候,虽然我们把它解析成了语法树,但语法也只是一种字符串排列形式上的约束,并没有类型的概念。你只要能生成出符合rust语法的字符串排列组合即可。真正的符号消解、类型检验等等,是在后面的编译阶段完成的。

第六关

这一关展示了第五关被舍弃掉的一个解决方案所存在的问题,比较绕,有兴趣的同学可以自己看看。我们的代码不需要修改就可以通过第六关。

第七关

第七关视频版:https://www.bilibili.com/video/BV1Gq4y1774f?zw

第七关我们要处理关联类型的问题。从第七关的提示里,我们了解到需要做的一个主要工作是寻找出同时满足如下要求的类型为syn::TypePath的语法树节点:

  • 其Path长度大于等于2
  • 其Path的第一项为泛型参数列表中某一个

根据Rust的语法,我们面临的关联类型可以有如下的形式:


#![allow(unused)]
fn main() {
pub trait Trait {
    type Value;    // 定义一个关联类型
}

pub struct GeekKindergarten<T:Trait> {
    blog: T::Value,
    ideawand: PhantomData<T::Value>,
    com: Foo<Bar<Baz<T::Value>>>,
}
}

也就是说,我们要寻找的形如T::Value的代码片段,可能嵌套在很深的地方,根据前面的经验,我们可能要写一个嵌套了几层if条件的递归函数来在整个语法树中遍历,有没有更优雅的写法呢,幸好syn库为我们提供了visit模式来访问语法树中你感兴趣的节点。

默认情况下,Visit访问模式在syn库中是没有开启的,根据syn官方文档首页中的描述,我们需要在cargo.toml里添加visit这个特性后才可以使用。所以我们首先需要更新一下cargo.toml。

Visit模式的使用说明可以参阅官方文档:https://docs.rs/syn/1.0.64/syn/visit/index.html

Visit模式的核心原理是,其定义了一个名为Visit的Trait,这个Trait中包含了上百个类型的语法树节点各自对应的回调函数,当其遍历语法树时,每遍历到一个类型的语法树节点,就会调用相应的回调函数。在第七关中,由于我们只希望筛选出所有syn::TypePath类型的节点,所以我们只需要实现这个节点对应的回调函数,然后在其中判断当前节点是否满足上述要求即可。大家可以看一下官方文档给出的实例,这里我就直接给出相关代码实现:

首先是Visitor的定义:


#![allow(unused)]
fn main() {
use syn::visit::{self, Visit};

// 定义一个用于实现`Visit` Trait的结构体,结构体中定义了一些字段,用于存储筛选条件以及筛选结果
struct TypePathVisitor {
    generic_type_names: Vec<String>,  // 这个是筛选条件,里面记录了所有的泛型参数的名字,例如`T`,`U`等
    associated_types: HashMap<String, Vec<syn::TypePath>>,  // 这里记录了所有满足条件的语法树节点
}

impl<'ast> Visit<'ast> for TypePathVisitor {
    // visit_type_path 这个回调函数就是我们所关心的
    fn visit_type_path(&mut self, node: &'ast syn::TypePath) {
        
        if node.path.segments.len() >= 2 {
            let generic_type_name = node.path.segments[0].ident.to_string();
            if self.generic_type_names.contains(&generic_type_name) {
                // 如果满足上面的两个筛选条件,那么就把结果存起来
                self.associated_types.entry(generic_type_name).or_insert(Vec::new()).push(node.clone());
            }
        }
        // Visit 模式要求在当前节点访问完成后,继续调用默认实现的visit方法,从而遍历到所有的
        // 必须调用这个函数,否则遍历到这个节点就不再往更深层走了
        visit::visit_type_path(self, node);
    }
}
}

然后是我们初始化Visitor然后执行遍历访问,最终返回筛选结果的函数:


#![allow(unused)]
fn main() {
fn get_generic_associated_types(st: &syn::DeriveInput) -> HashMap<String, Vec<syn::TypePath>> {
    // 首先构建筛选条件
    let origin_generic_param_names: Vec<String> = st.generics.params.iter().filter_map(|f| {
        if let syn::GenericParam::Type(ty) = f {
            return Some(ty.ident.to_string())
        }
        return None
    }).collect();

    
    let mut visitor = TypePathVisitor {
        generic_type_names: origin_generic_param_names,  // 用筛选条件初始化Visitor
        associated_types: HashMap::new(),
    };

    // 以st语法树节点为起点,开始Visit整个st节点的子节点
    visitor.visit_derive_input(st);
    return visitor.associated_types;
}
}

例如对于下面这样的关联类型和结构体:


#![allow(unused)]

fn main() {
pub trait TraitA {
    type Value1;
    type Value2;
}

pub trait TraitB {
    type Value3;
    type Value4;
}

pub struct GeekKindergarten<T: TraitA, U: TraitB> {
    blog: T::Value1,
    ideawand: PhantomData<U::Value3>,
    com: Foo<Bar<Baz<T::Value2>>>,
}
}

则我们上面函数将会返回这样一个结构,之所以用了一个字典,是为了后续检索方便,而字典的值又是一个列表的原因是,一个Trait里面可能有多个关联类型:


#![allow(unused)]
fn main() {
{
    "T": [T::Value1, T::Value2],
    "U": [U::Value3],
}
}

筛选出所有的关联类型后,我们再更新一下impl块的生成代码,与之前不同的是,对于关联类型的限定,只能放在where子句中,代码如下:


#![allow(unused)]
fn main() {
fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {

    // <此处省略没有修改的代码> ..........

    // 下面这一行是第七关新加的,调用函数找到关联类型信息
    let associated_types_map = get_generic_associated_types(st);
    for mut g in generics_param_to_modify.params.iter_mut() {
        if let syn::GenericParam::Type(t) = g {
            let type_param_name = t.ident.to_string();        
            
            if phantomdata_type_param_names.contains(&type_param_name) && !field_type_names.contains(&type_param_name){
                continue;
            }

            // 下面这3行是本次新加的,如果是关联类型,就不要对泛型参数`T`本身再添加约束了,除非`T`本身也被直接使用了
            if associated_types_map.contains_key(&type_param_name) && !field_type_names.contains(&type_param_name){
                continue
            }

            t.bounds.push(parse_quote!(std::fmt::Debug));
        }
    }

    // 以下6行是第七关新加的,关联类型的约束要放到where子句里
    generics_param_to_modify.make_where_clause();
    for (_, associated_types) in associated_types_map {
        for associated_type in associated_types {
            generics_param_to_modify.where_clause.as_mut().unwrap().predicates.push(parse_quote!(#associated_type:std::fmt::Debug));
        }
    }

    // <此处省略没有修改的代码> ..........
}
}

第八关

第八关视频版:https://www.bilibili.com/video/BV1vV41147ES?zw

这一关要实现的是之前提到的“逃生出口“(escape hatch),由于前面介绍过的Rust过程宏展开机制的缺陷,在一些边界情况下我们无法正确推断出泛型的Trait限定,这时候,我们就需要提供一个人为干预的后门。本关分为两部分,一部分是必答题,提供一个全局的干预方式,还有一个是选做题,精确到对每个字段进行控制。因为这篇文章已经很长了,所以我们就只做必答题,选做题留给大家自己去实现了。

首先是要解析一个全局的属性标签,属性标签我们已经解析过很多次了,这次就直接给大家代码了:


#![allow(unused)]
fn main() {
fn get_struct_escape_hatch(st: &syn::DeriveInput) -> Option<String> {
    if let Some(inert_attr) = st.attrs.last() {
        if let Ok(syn::Meta::List(syn::MetaList { nested, .. })) = inert_attr.parse_meta() {
            if let Some(syn::NestedMeta::Meta(syn::Meta::NameValue(path_value))) = nested.last() {
                if path_value.path.is_ident("bound") {
                    if let syn::Lit::Str(ref lit) = path_value.lit {
                        return Some(lit.value());
                    }
                }
            }
        }
    }
    None
}
}

然后,我们拿到了用户输入的干预指令,其实就是一小段Rust的代码,我们要把这一小段Rust代码解析为语法树的节点后插入到where子句对应的节点中。解析用户的输入可以使用syn::parse_str()这个函数来实现。好了,直接上代码:


#![allow(unused)]
fn main() {
fn generate_debug_trait(st: &syn::DeriveInput) -> syn::Result<proc_macro2::TokenStream> {
   
    // <此处省略没有修改的代码> ..........


    // 判定是否设置了限定条件干预,如果设定了,则不进行推断,直接使用用户给出的限定条件放到where子句中
    if let Some(hatch) = get_struct_escape_hatch(st) {
        generics_param_to_modify.make_where_clause();
        generics_param_to_modify
                    .where_clause
                    .as_mut()
                    .unwrap()
                    .predicates
                    .push(syn::parse_str(hatch.as_str()).unwrap());
    } else {
        // 原来位于此处的代码,全部移动到else分支里面,其他不变,省略 ..........
    }

    let (impl_generics, type_generics, where_clause) = generics_param_to_modify.split_for_impl();

    // <此处省略没有修改的代码> ..........


}
}

最后,需要承认的一点是,上面写的这些代码肯定是不严谨的,或者说是漏洞百出的。一方面是这只是为了通过测试用例,并没有充分考虑测试用例没有覆盖到的场景;另一方面,大家也应该充分认识到,Rust的过程宏就是一个复杂的“字符串拼接”过程,他没有类型校验,我们通过字符串匹配来关联一些“类型”,因此你完全可以通过构造一些冲突的命名来迷惑我们的代码。这就是Rust的过程宏,充满了Trick。

Rust 学习笔记系列| Part 4

作者: 李大狗

本文是"Rust 学习笔记"系列的第四篇(除此之外还有两篇用 Rust 写智能合约的)。

在「Mapping 数据结构 | 用 Rust 写智能合约(二)」一文中,我们尝鲜了 FISCO BCOS 中的 Rust 智能合约,今天我们来看一看如何通过 Rust,和 FISCO BCOS 生态中的重要组件 —— Webase 进行交互。

WeBase:

WeBASE(WeBank Blockchain Application Software Extension) 是在区块链应用和FISCO-BCOS节点之间搭建的一套通用组件。围绕交易、合约、密钥管理,数据,可视化管理来设计各个模块,开发者可以根据业务所需,选择子系统进行部署。WeBASE屏蔽了区块链底层的复杂度,降低开发者的门槛,大幅提高区块链应用的开发效率,包含节点前置、节点管理、交易链路,数据导出,Web管理平台等子系统。

在本例中,为简单起见,我们仅需要安装部署WeBase系列中的WeBaseFront即可:

WeBaseFront:

WeBASE-Front是和FISCO-BCOS节点配合使用的一个子系统。此分支支持FISCO-BCOS 2.0以上版本,集成web3sdk,对接口进行了封装,可通过HTTP请求和节点进行通信。另外,具备可视化控制台,可以在控制台上开发智能合约,部署合约和发送交易,并查看交易和区块详情。还可以管理私钥,对节点健康度进行监控和统计。

安装文档:

https://webasedoc.readthedocs.io/zh_CN/latest/docs/WeBASE-Front/index.html

本文涉及知识点:

  • reqwest 这个 Rust Http 库的使用
  • lib 的编写与使用

1 确保 WeBaseFront 已启动

image-20210430152702912

2 创建新的 Rust 项目

  1. 创建项目webase-interactor-example

#![allow(unused)]
fn main() {
cargo new webase-interactor-example
}
  1. 更新目录结构

在这次学习中,我们引入更复杂的项目结构,我们会在项目中创建一个lib并引用它,做到模块解耦。

我们进入项目目录并在项目下创建一个lib:


#![allow(unused)]
fn main() {
cd webase-interactor-example
cargo new webase-interactor --lib
}

我们再创建一个文件,在webase-interactor/src目录下创建chain.rs

这样,我们得到了这样的目录结构:

.
├── Cargo.toml
├── src
│   └── main.rs
└── webase-interactor
    ├── Cargo.toml
    └── src
        ├── chain.rs
        └── lib.rs

3 编写webase-interactor

3.1 编写webase-interactorCargo.toml

webase-interactorCargo.toml如下:

[package]
name = "webase-interactor"
version = "0.1.0"
authors = ["leeduckgo <albertschr@163.com>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
reqwest = { version = "0.10", features = ["blocking", "json"] }
tokio = { version = "0.2", features = ["full"] }

我们在此引入了reqwest这个HTTP库。

An ergonomic, batteries-included HTTP Client for Rust.

https://github.com/seanmonstar/reqwest

reqwest支持同步/异步的http调用,在本实例中,我们使用同步方案。

3.2 编写chain.rs

chain.rs的内容如下:


#![allow(unused)]
fn main() {
pub struct Chain{
    ip: String,
}

impl Chain{
    pub fn new(ip: String) -> Chain {
        Chain { ip }
    }
    pub fn get_ip(&self) -> String {
        self.ip.to_string()
    }

    pub fn get_block_number(&self) -> Result<String, reqwest::Error>{
        let mut url =self.ip.to_string();
        url += &"WeBASE-Front/1/web3/blockNumber/".to_string();
        let resp = 
            reqwest::blocking::get(&url)?
            // .await?
            .text();
            // .await?;

        resp
    }
}
}

3.2.1 结构体

我们先定义了一个结构体,关于结构体更详细的介绍请见如下两个链接:

https://www.runoob.com/rust/rust-struct.html

https://kaisery.github.io/trpl-zh-cn/ch05-01-defining-structs.html

在 Rust 中,Struct 语句仅用来定义,不能声明实例,结尾不需要;符号,而且每个字段定义之后用 ,分隔。

如,定义一个矩形:

struct Rectangle {
    width: u32,
    height: u32,
}

在本项目中,我们定义了一个结构体Chain,这个结构体有一个参数ip,我们可以通过这个参数定位到相应的webase

3.2.2 结构体方法

如同在面向对象的编程语言中,函数挂载在类(Class)的里面一样,在 Rust 中,我们可以将函数挂载在结构体里面。

如,实现一个结构体函数area,计算出矩形的面积。


#![allow(unused)]
fn main() {
impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}
}

在本项目中,我们定义了如下方法:

  • new方法:创建一个新的Chain。
  • get_ip方法:获取Chain实例的ip。
  • get_block_number方法:通过和Webase交互,获取当前块高。

get_block_number方法中,我们拼接出url字符串,然后调用reqwestget函数。

get函数的返回值是Result<String, reqwest::Error>,所以我们的函数结构是这样的:


#![allow(unused)]
fn main() {
pub fn get_block_number(&self) -> Result<String, reqwest::Error>{ //注意不要遗漏 &self
  // do sth
  resp // 在 rust 中,我们无需return关键字,最后一行不带;,执行的结构即函数返回值。
}
}

3.3 编写lib.rs

lib.rs内容如下:


#![allow(unused)]
fn main() {
//! # WeBase
//!
//! A library to interact with webase.
#![warn(unused_extern_crates)]

pub mod chain;
pub use self::chain::*;
}

这里我们把chain声明为一个命名空间。

mod还支持多级嵌套,如:


#![allow(unused)]
fn main() {
// phrases.rs
pub mod english {
    pub mod greetings {
        pub fn hello() {
            println!("Hello!")
        }
        pub fn hey_guies() {
           println!("Hey, guies!")
        }
    }
    pub mod farewells {
        pub fn goodbye() {
            println!("Goodbye!")
        }
        pub fn see_you() {
            println!("See you!")
        }
    }
}

pub mod chinese {
    pub mod greetings {
        pub fn hello() {
            println!("你好!")
        }
        pub fn have_eaten() {
            println!("吃了么?")
        }
    }
    pub mod farewells {
        pub fn goodbye() {
            println!("再见!")
        }
        pub fn everyone_will_know_you() {
            println("天下谁人不识君!")
        }
    }
}
}

这样来管理我们的模块,我们的代码在各种意义上都会更清晰。

4 完成主项目

4.1 编写Cargo.toml

主项目的Cargo.toml如下:


#![allow(unused)]
fn main() {
[package]
name = "webase-interactor-example"
version = "0.1.0"
authors = ["leeduckgo <albertschr@163.com>"]
edition = "2018"

See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
pretty_env_logger = "0.3"
log = "0.4"

reqwest = { version = "0.10", features = ["json"] }
tokio = { version = "0.2", features = ["full"] }
webase-interacter = { path="./webase-interactor", version = "0.1.0"}
}

在本项目中,我们引入了pretty_env_logger,以便更好的进行输出。

除此之外,我们还引入了刚才创建的webase-interactor

4.2 编写main.rs

main.rs的内容如下:

extern crate pretty_env_logger;

use webase_interactor::Chain;

#[macro_use] extern crate log;

fn main(){
    pretty_env_logger::init(); 
    print_block_number();
    
}

pub fn print_block_number() {
    let ip = "http://127.0.0.1:5002/".to_string();
    let chain = Chain::new(ip);
    let res = chain.get_block_number();
    match res {
        Err(e) => {
            println!("error: {}", e);
        }
        Ok(b_number) => {
            info!("last block height: {}", b_number);
        }
    }
}

到此为止,我们的代码已经全部编写完成了。

编译:


#![allow(unused)]
fn main() {
cargo build
}

image-20210430163532957

执行:


#![allow(unused)]
fn main() {
RUST_LOG=info cargo run
}

image-20210430165231274

如期打印出当前块高。

我们在cargo run命令前加上RUST_LOG=info,所以我们在输出的时候仅会打印info!函数中的内容。

本系列所有源码见:

https://github.com/leeduckgo/RustStudy

Rust 学习笔记系列| Part 5

作者: 李大狗


本项目代码见:

https://github.com/leeduckgo/weid-rust-sample

本系列代码见:

https://github.com/leeduckgo/Rust-Study

在上一篇文章中,我们使用 Rust 和 FISCO BCOS 开源框架中的 WeBase 进行了交互,使用了 reqwest 这个 Rust 中的 http 库,同时介绍了 Rust 项目中模块分离的设计。

今天,我们将结合 FISCO BCOS 生态中的数字身份组件 WeIdentity,来讲 Rust 中的数据库操作。

1 什么是 数字身份标识与数字身份体系?

首先,让我们来看下什么是分布式数字身份标识(DID):

分布式数字标识符(DID)是一种新型标识符,用以标识可验证的分布式的数字身份。 DID的控制者决定标识的主体(例如,人,组织,事物,数据模型,抽象实体等)。

— W3C DID规范

围绕分布式数字身份标识,我们可以构建如下组件:

  • DID 文档——用以对该 DID 相关的地址、服务以及其它特性进行进一步的阐述。
  • 可验证声明——通过可验证声明,DID 控制者可以发放、持有、验证电子证书与电子凭证。
  • 选择型披露——结合隐私保护技术,DID 控制者可以在保障自己隐私的情况下,向需要的第三方选择性披露自己的数据,如证明自己的年龄大于 18 岁。
  • 数据存证——将可信数据和 DID 进行挂钩,便完整的形成了「数字身份—数字凭证—数据存证」的体系,如「学生身份—毕业证书—课堂表现」。

这便是数字身份体系

2 什么是 WeIdentity?

WeIdentity是一套分布式多中心的技术解决方案,可承载实体对象(人或者物)的现实身份与链上身份的可信映射、以及实现实体对象之间安全的访问授权与数据交换。WeIdentity由微众银行自主研发并完全开源,秉承公众联盟链整合资源、交换价值、服务公众的理念,致力于成为链接多个垂直行业领域的分布式商业基础设施,促进泛行业、跨机构、跨地域间的身份认证和数据合作。

WeIdentity 目前主要包含两大模块:WeIdentity DID 以及 WeIdentity Credential。

WeIdentity 参考场景:

roles-relation.png

3 WeIdentity DID规范

WeIdentity 对基本的 DID 规范进行了扩展处理。

基本的 DID 规范:

DID是一个简单的文本字符串,由三部分组成: 1)DID过的URI方案标识符(Scheme,固定就是 did) 2)DID方法的标识符(DID Method) 3)DID方法生成的标识符(DID Method-Specific Identifier)

扩展后的 WeIdentity:

weidentity-did-format1.png

字段说明
did遵循DID规范,使用固定字符“did”
weidWeIdentity DID规范的method name字段,固定为“weid”
chain-id链 ID,用于路由到不同的链网络(如果需要跟其他链打通,需要找 WeIdentity 开源项目的 owner 微众银行注册路由信息),例如同时使用 WeIdentity 的可能有多条区块链,可以使用这个字段作为标识信息,路由到特定区块链
bs-specific-string基于底层区块链平台生成,代表Entity在链上的地址,保证全网唯一

备注:bsSpecificString根据区块链底层平台和业务具体情况来确定生成规则,例如可以是随机字符串,或者区块链上的地址。

示例(这个例子中,chain-id是101: "did:weid:101:0x0086eb1f712ebc6f1c276e12ec21"

4 数据结构设计

在今天的实践中,我们希望可以把weid保存到本地数据库中。

我们这次选择的是 Sqlite 数据库,在Rust - ORM 选择上,我们选择的是 Diesel,这个项目有 6.8k Stars。

https://github.com/diesel-rs/diesel

Tips: 接触新库时,我们可以通过学习 Repo 中的 Examples,来掌握 Repo 的用法。

因为weid中,前半部分did:weid是不变的,所以我们只需保存chain_id和`bs-specific-string\即可。

在Rust中数据结构如下:


#![allow(unused)]
fn main() {
pub struct Weid {
    id: i32,
    chain_id: i32, //当然也可以同样设置为 String
    addr: String,

    created_at: NaiveDateTime, // 创建时间
    updated_at: NaiveDateTime, // 更新时间
}
}

数据库的创建语句如下:

CREATE TABLE weids (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  addr TEXT NOT NULL,
  chain_id INTEGER, 
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

我们用标准规范创建数据库迁移文件夹migration

migrations
└── 2020-05-13-105400_create_weids
    ├── down.sql
    └── up.sql

其中,up.sql的内容即是上面的内容:

CREATE TABLE weids (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  addr TEXT NOT NULL,
  chain_id INTEGER,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

down.sql的内容就是移除weids表:

DROP TABLE weids;

5 数据库的创建与建表(Create&Migrate)

此处我们使用diesel使用的命令行工具。

(1)复制必要文件到项目目录下

从 Diesel 的代码仓库中 Clone 代码到本地。

https://github.com/diesel-rs/diesel

将 Repo 中的 dieseldiesel_clidiesel_derivesdiesel_migrations复制到项目根目录下。

在项目根目录下新建bin文件夹。

编译 Diesel Repo,将target/debug/diesel文件复制到bin目录下。

(2)配置环境变量

我们需要设置两个环境变量:

  • DATABASE_URL——数据库路径
  • BACKEND———数据库类型

直接执行如下命令即可:

export DATABASE_URL="examples.db"
export BACKEND="sqlite"

(3)创建数据库与建表

执行如下命令:

./bin/diesel database reset

顺利的话,会出现如下返回:

image-20210513171857371

同时根目录下出现examples.db文件。

6 models.rs

我们在src目录下创建models.rs文件,在其中定义结构体WeidNewWeid,定义 schema(模式)weids

Scheme,可以简单的理解为我们告诉程序数据库中有哪些字段,这样程序才能顺利对接数据库。

models.rs


#![allow(unused)]
fn main() {
use chrono::NaiveDateTime;
#[cfg(test)]
use diesel::debug_query;
use diesel::insert_into;
use diesel::prelude::*;
#[cfg(test)]
use diesel::sqlite::Sqlite;
use serde_derive::Deserialize;
use std::error::Error;

pub mod schema {
    diesel::table! {
        weids {
            id -> Integer,
            chain_id -> Integer,
            addr -> Text,

            created_at -> Timestamp,
            updated_at -> Timestamp,
        }
    }
}

use schema::weids;

#[derive(Insertable)]
#[table_name = "weids"]
pub struct NewWeid {
    pub chain_id: i32,
    pub addr: String,
}

#[derive(Queryable, PartialEq, Debug)]
pub struct Weid {
    pub id: i32,
    pub chain_id: i32,
    pub addr: String,

    pub created_at: NaiveDateTime,
    pub updated_at: NaiveDateTime, 
}

pub fn insert_default_values(conn: &SqliteConnection) -> QueryResult<usize> {
    use schema::weids::dsl::*;

    insert_into(weids).default_values().execute(conn)
}
}

代码解析:

注: 以下内容对《Rust 程序设计语言(第一版)》有所参考。

https://kaisery.gitbooks.io/rust-book-chinese/content/content/Traits.html

trait 是一个告诉 Rust 编译器一个类型必须提供哪些功能语言特性。

例如,我们可以为结构体Circle实现HasArea这个trait


#![allow(unused)]
fn main() {
struct Circle {
    x: f64,
    y: f64,
    radius: f64,
}

trait HasArea {
    fn area(&self) -> f64;
}

impl HasArea for Circle {
    fn area(&self) -> f64 {
        std::f64::consts::PI * (self.radius * self.radius)
    }
}
}

如你所见,trait块与impl看起来很像,不过我们没有定义一个函数体,只是函数标记。当我们impl一个trait时,我们使用impl Trait for Item,而不是仅仅impl Item

重复的实现像DebugDefault这样的 trait 会变得很无趣。为此,Rust 提供了一个[属性](https://kaisery.gitbooks.io/rust-book-chinese/content/content/Attributes 属性.md)来允许我们让 Rust 为我们自动实现 trait:

#[derive(Debug)]
struct Foo;

fn main() {
    println!("{:?}", Foo);
}

Rust 1.15中引入了自定义derive特性,从而让derive有了更多的想象空间。

我们通过#[derive(Insertable)]#[derive(Queryable, PartialEq, Debug)],让该结构体具备可插入数据库,或从数据库查询的特性。

7 main.rs

main.rs 的内容如下所示:

extern crate pretty_env_logger;

pub mod models;

use diesel::prelude::*;
use std::env;
use dotenv::dotenv;

use weid_light_client::WeIdRestService;
use models::*;

use models::schema::weids;
use models::schema::weids::dsl::*;

#[macro_use] extern crate log;

fn main(){
    pretty_env_logger::init(); 

    // create data
    let sqlite_conn = establish_connection();
    create_weid(&sqlite_conn, 1, "34be11396f3a91c5Ab5A1220e756C6300FB2b20a");
    
    // query data
    let results = weids.load::<Weid>(&sqlite_conn)
        .expect("Error loading weids");
    // log weids
    info!("Displaying {} weids", results.len());
    for weid in results{
        info!("did:weid:{}:{}", weid.chain_id, weid.addr);
    }
}

pub fn establish_connection() -> SqliteConnection {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    SqliteConnection::establish(&database_url)
        .unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

pub fn create_weid(conn: &SqliteConnection, c_id: i32, address: &str) -> usize {
    

    let new_weid = NewWeid {chain_id: c_id, addr: address.to_string()};

    diesel::insert_into(weids::table)
        .values(&new_weid)
        .execute(conn)
        .expect("Error saving new weid")
}

代码解析:

establish_connection直接拷贝自 Diesel 的Examples,作用是根据环境变量中的DATABASE_URL连接sqlite数据库。

create_weid函数中,我们先创建一个 NewWeid 结构体对象,然后通过diesel::insert_into函数将新建的结构体对象插入数据库。

通过loads函数,我们从数据库中加载Weid结构体。

8 运行

执行RUST_LOG=trace cargo run

我们成功向数据库插入一条 weid 数据,并读取 出weid 数据。

image-20210513180708811

关于diesel的更多更详细的用法,见:

https://diesel.rs/guides/getting-started

Rust 学习笔记系列| Part 6

作者: 李大狗(李骜华)


**系列简介:**狗哥 Rust 学习笔记系列是大狗为对抗 Rust 陡峭的学习曲线而推出的 Rust 学习系列,具备如下原则:

  1. 循序渐进原则

按照阶梯法则(下一篇的难度是上一篇难度+1)原则进行设计,让学习 Rust 跟打游戏一样简单。

  1. 单一知识点原则

一篇文章只讲一个一个知识点,保证简单性与专注性。

  1. 实用原则

所有案例均是真实实践案例,实用性超强。

在上一篇文章里,我们开启了weid-rust-example项目,学习了如何通过diesel项目玩转SQLite数据库。今天我们依然在这个项目的基础上往前推进。

实现功能

我们将围绕结构体Struct WeId,实现create_weid_online函数:


#![allow(unused)]
fn main() {
#[derive(Default)]
pub struct WeId{
    endpoint_url: String,
    weid: String, 
}

impl WeId{
	pub fn create_weid_online(&self) -> ... {
    
  }
}
}

和以往我们实现过的函数不同的是,在这个函数中,我们可以遇到多种可能的错误(Error),因此,在返回值里我们就不能向过去一样,填写Result<Value, reqwest::Error>,我们需要通过一个枚举(Enum)把可能的错误打包在一起。

Let's Go!

create_weid_online 函数拆解

在函数式编程中,我们会遵循「单一职责原则」,简单来说,就是一个函数只做一件事。因此,即使create_weid_online是简单的函数,我们依然可以将其拆分:

create_weid_online —— 子函数的组合
    |------ call_create_weid —— 通过weid-rest-service的接口注册托管型 weid 并获得返回值
    |------ str_to_json —— 将 &str 值转换为 json 

子函数的实现

str_to_json函数:


#![allow(unused)]
fn main() {
fn str_to_json(&self, payload: &str) -> Result<Value, serde_json::Error> {
	serde_json::from_str(payload)
}
}

这个函数中使用了serde_json库,在Cargo.toml为:


#![allow(unused)]
fn main() {
[dependencies]
...
serde_json = { version = "1.0" }
...
}

call_create_weid函数:


#![allow(unused)]
fn main() {
pub fn call_create_weid(&self) -> Result<String, reqwest::Error> {
    let mut url =self.endpoint_url.to_string();
    url += &"/weid/api/invoke".to_string();
    // ::blocking:: to block
    let response = reqwest::blocking::Client::new()
    .post(&url)
    .json(&serde_json::json!({
        "functionArg": {},
        "transactionArg": {},
        "v": "1.0.0",
        "functionName": "createWeId"
    }))
    .send()?
    .text();
    
    response
}
}

**注:**在前面的文章中我们介绍了用 reqwest 调用 get 接口,这次我们调用 post 接口。

这个函数中使用了reqwest库,在Cargo.toml为:


#![allow(unused)]
fn main() {
[dependencies]
...
reqwest = { version = "0.10", features = ["blocking", "json"] }
tokio = { version = "0.2", features = ["full"] }
...
}

WeId-Rest-Service 的接口说明请见:

https://weidentity.readthedocs.io/zh_CN/latest/docs/weidentity-rest-api.html

主函数的实现

以下是create_weid_online函数的源码:


#![allow(unused)]
fn main() {
pub fn create_weid_online(&self) -> Result<Value, GenerateWeIdError>{
    let response = self.call_create_weid()?; // line1
    let resp = self.str_to_json(&response)?; // line2
    Ok(resp)
}
}

我们可以看到,在返回值里的 Error 处,我们填的是自定义的错误类型GenerateWeidError

所以,在line1处可能发生的reqwest::Error错误,和line2处可能发生的serde_json::Error,会被汇集在GenerateWeIdError中。

聚合错误处理的实现

在这里我们使用thiserror这个库,这是目前的最佳处理方案。

Cargo.toml中引用thiserror


#![allow(unused)]
fn main() {
[dependencies]
...
thiserror = "1.0"
...
}

官方的例子是这样的:


#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum DataStoreError {
    #[error("data store disconnected")]
    Disconnect(#[from] io::Error),
    #[error("the data for key `{0}` is not available")]
    Redaction(String),
    #[error("invalid header (expected {expected:?}, found {found:?})")]
    InvalidHeader {
        expected: String,
        found: String,
    },
    #[error("unknown data store error")]
    Unknown,
}
}

在这里我们简单使用:


#![allow(unused)]
fn main() {
// 记得 enum 要写在 Struct 外面。
#[derive(Error, Debug)]
pub enum GenerateWeIdError {
    #[error("req error")]
    RequestError(#[from] reqwest::Error),
    #[error("parse error")]
    ParseError(#[from] serde_json::Error),
}
}

这样,就能让GenerateWeIdError囊括这个函数中的所有可能 error 了。

【补充资料】关于 Enum 枚举:

https://kaisery.github.io/trpl-zh-cn/ch06-01-defining-an-enum.html

main函数

main函数中,我们对结构体与函数进行调用:

fn main(){
    let weid = WeId::new("http://127.0.0.1:6001".to_string());
    let result = weid.create_weid_online();
    match result {
        Ok(payload) => println!("{:}", payload),
        Err(e) => println!("{}", e)
    }

执行后,打印出了我们期待的结果:

image-20210527142348963

本项目代码见:

https://github.com/leeduckgo/weid-rust-sample

本系列代码见:

https://github.com/leeduckgo/Rust-Study

Rust 学习笔记系列| Part 7

作者:李大狗


**系列简介:**狗哥 Rust 学习笔记系列是大狗为对抗 Rust 陡峭的学习曲线而推出的 Rust 学习系列,具备如下原则:

  1. 循序渐进原则

按照阶梯法则(下一篇的难度是上一篇难度+1)原则进行设计,让学习 Rust 跟打游戏一样简单。

  1. 单一知识点原则

一篇文章只讲一个一个知识点,保证简单性与专注性。

  1. 实用原则

所有案例均是真实实践案例,实用性超强。

在之前的两篇文章中,我们分别介绍了:

将二者整合,我们可以得到 WeId 的链上生成 — 本地存储的完整闭环。这个项目我已推送到 Github 上并补充了 README,欢迎 STAR~

https://github.com/leeduckgo/weid-rust-sample

项目结构

.
├── Cargo.lock
├── Cargo.toml
├── README.md
├── bin # bin 中放置的是 diesel 二进制软件体
├── diesel # diesel 开头均为数据库相关
├── diesel_cli
├── diesel_derives
├── diesel_migrations
├── ethereum # 以太坊包,为之后本地生成-链上注册做准备
├── model # 同上
├── examples.db # sqlite 数据库
├── migrations # 数据库记录文件
├── src # 主文件
|     ├── main.rs
|     └── models.rs
├── weid-light-client # 解耦轻客户端,对接 weid-rest-service 服务
└── target # 编译后的文件

当前流程

  1. 调用 WeIdGenerator,在链上注册WeIdentity

借助了reqwest库,详细使用方法见:

链上注册WeId与错误处理 | Rust 学习笔记(六)

weid-light-client/src/weid_generator.rs源码:


#![allow(unused)]
fn main() {
use serde_json::{Value};
use thiserror::Error;

/// Provide an implementation for the default() method:
/// https://doc.rust-lang.org/stable/core/default/trait.Default.html
#[derive(Default)]
pub struct WeIdGenerator{
    endpoint_url: String,
    weid: String, 
}

impl WeIdGenerator{
    pub fn new(endpoint_url: String) -> WeIdGenerator {
        WeIdGenerator {endpoint_url, ..Default::default()}
    }
    /// String or &str?
    /// Ref: https://zhuanlan.zhihu.com/p/123278299
    /// 显然,这取决于很多因素,但是一般地,保守来讲,如果我们正在构建的API不需要拥有或者修改使用的文本,
    /// 那么应该使用&str而不是String。
    /// 等一下,但是如果这个API的调用者真的有一个String并且出于某些未知原因无法将其转换成&str呢?完全没有问题。
    /// Rust有一个超级强大的特性叫做deref coercing,这个特性能够允许把传进来的带有借用操作符的String引用,
    /// 也就是&String,在API执行之前转成&str。我们会在另一篇文章里介绍更多地相关细节。
    pub fn generate_local(&mut self, chain_id: i32, addr: &str) -> String {
        self.weid = "did:weid:".to_string() + &chain_id.to_string() + ":" + addr;
        // Ref: https://stackoverflow.com/questions/38304666/how-to-define-a-copyable-struct-containing-a-string
        // String is copyable, use .clone()
        // String is not implicitly copyable, because that would cause non-obvious memory allocations to occur
        self.weid.clone()
    }

    /// create weid online.
    pub fn create_weid_online(&self) -> Result<Value, GenerateWeIdError>{
        let response = self.call_create_weid()?;
        let resp = self.str_to_json(&response)?;
        Ok(resp)
    }

    fn str_to_json(&self, payload: &str) -> Result<Value, serde_json::Error> {
        serde_json::from_str(payload)
    }
    pub fn call_create_weid(&self) -> Result<String, reqwest::Error> {
        let mut url =self.endpoint_url.to_string();
        url += &"/weid/api/invoke".to_string();
        // ::blocking:: to block
        let response = reqwest::blocking::Client::new()
        .post(&url)
        .json(&serde_json::json!({
            "functionArg": {},
            "transactionArg": {},
            "v": "1.0.0",
            "functionName": "createWeId"
        }))
        .send()?
        .text();
        
        response
    }
}
/// multi error handle:
/// https://my.oschina.net/jmjoy/blog/3190024
#[derive(Error, Debug)]
pub enum GenerateWeIdError {
    #[error("req error")]
    RequestError(#[from] reqwest::Error),
    #[error("parse error")]
    ParseError(#[from] serde_json::Error),
}
}
  1. create_weid_online()函数结果处理,见src/main.rs

#![allow(unused)]
fn main() {
fn gen_weid_online_and_save(weid_generator: WeIdGenerator) -> Result<Value, GenerateWeIdError>{
  let result = weid_generator.create_weid_online();

  match result {
    Ok(payload) => {
      // TODO
      //weid = payload |> to_weid
      //vec_weid = weid |> vec
      // save to local sqlite
      // info
      Ok(payload)
    },
    Err(e) => {
      info!("{}", e);
      Err(e)
    }
  }
}
}
  1. 定义数据结构与数据 CRUD 操作

详见:

用 Sqlite 存储 WeId | Rust 学习笔记(五)

src/models.rs源码:


#![allow(unused)]
fn main() {
use chrono::NaiveDateTime;
#[cfg(test)]
use diesel::debug_query;
use diesel::insert_into;
use diesel::prelude::*;

use std::env;
use dotenv::dotenv;

#[cfg(test)]
use diesel::sqlite::Sqlite;

pub mod schema {
    diesel::table! {
        weids {
            id -> Integer,
            chain_id -> Integer,
            addr -> Text,

            created_at -> Timestamp,
            updated_at -> Timestamp,
        }
    }
}

use schema::weids;

#[derive(Insertable)]
#[table_name = "weids"]
pub struct NewWeId {
    pub chain_id: i32,
    pub addr: String,
}

#[derive(Queryable, PartialEq, Debug)]
pub struct WeId {
    pub id: i32,
    pub chain_id: i32,
    pub addr: String,

    pub created_at: NaiveDateTime,
    pub updated_at: NaiveDateTime, 
}

pub fn insert_default_values(conn: &SqliteConnection) -> QueryResult<usize> {
    use schema::weids::dsl::*;

    insert_into(weids).default_values().execute(conn)
}

pub fn establish_connection() -> SqliteConnection {
    dotenv().ok();

    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");
    SqliteConnection::establish(&database_url)
        .unwrap_or_else(|_| panic!("Error connecting to {}", database_url))
}

pub fn save_weid(conn: &SqliteConnection, c_id: i32, address: &str) -> usize {
    
    let new_weid = NewWeId {chain_id: c_id, addr: address.to_string()};

    diesel::insert_into(weids::table)
        .values(&new_weid)
        .execute(conn)
        .expect("Error saving new weid")
}
}
  1. 补充 2 中的 TODO 部分

#![allow(unused)]
fn main() {
fn gen_weid_online_and_save(weid_generator: WeIdGenerator) -> Result<Value, GenerateWeIdError>{
    let result = weid_generator.create_weid_online();

    match result {
        Ok(payload) => {
          	// str handle
            let weid_str: String = 
                payload["respBody"]
                .to_string()
                .replace("\"", "");
          	// str to vec
            let vec: Vec<&str> = 
                weid_str
                .split(":")
                .collect();
          	

            let chain_id: i32 = vec[2].parse().unwrap();
            let addr: &str = vec[3];
            // create data
            let sqlite_conn = models::establish_connection();
            
            models::save_weid(&sqlite_conn, chain_id, addr);
            info!("gen and save weid to local {}.", weid_str);
            Ok(payload)
        },
        Err(e) => {
            info!("{}", e);
            Err(e)
        }
    }
}
}
  1. 在 main 函数中调用
fn main(){
    pretty_env_logger::init();
  	// 从环境变量中拿取 "WEID_URL"
    let url = env::var("WEID_URL").expect("DATABASE_URL must be set");
    let weid_generator = WeIdGenerator::new(url.to_string());
    gen_weid_online_and_save(weid_generator);
}

运行项目

  1. 初始化数据库
./bin/diesel database reset
  1. 设置环境变量
# 推荐使用direnv
export DATABASE_URL="examples.db"
export BACKEND="sqlite"
export WEID_URL=<weid-rest-service url>
  1. 运行项目
RUST_LOG=info cargo run

目前会在链上创建托管型WeId并存储在本地的Sqlite数据库中。

image-20210617172840467

升级方向

  1. 添加「私钥不出域」创建WeId的方式;
  2. 实现 WeIdentity Document的同步操作;
  3. 目前项目开发需要先启动WeIdentity-Rest-Service,考虑设计 Mock 接口,方便开发。

用 Rust 学习解析器组合子 (combinator)

原文

译者: iamazy, Matrixtang

本文向已是 Rust 程序员的人们传授解析器组合子的基础知识。它假定不涉及其他知识,并将解释与 Rust 没有直接关系的所有内容,以及为达到此目的使用 Rust 的一些令人意外的特性。如果你不了解 Rust,他不会教你 Rust 的内容,但是这样的话,同样也无法更好的教你解析器和组合子的知识。如果你想要学习 Rust 语言,我推荐 Rust 编程语言 这本书。

Beginner's Mind

当每个程序员发现自己需要解析器时,它们的生活将变得有意义。

初级程序员将会问:“什么是解析器?”。
中级程序员将会说:“这很简单,我将写一串正则表达式”。
高级程序员将会说:“退一步考虑问题,我知道Lex(词法分析器)和Yacc(语法解析器)”

初级程序员的想法是正确的。

不是说正则表达式不好(但是请不要用正则表达式写复杂的解析器)。使用经过 数千年 (millennia) 磨练至完美的解析器和词法分析生成器之类的强大工具并不是没有乐趣可言。但是从头开始一点一点学习解析器将更有趣。这两者只是对当前实际问题的抽象,如果你直接选择正则表达式或者解析器生成工具,你将失去这一份乐趣。在初学者眼中,正如人们说的:本来(解决这个问题)有很多种方法,但是在专家眼里,已经形成思维定视,只会选择一种他们最习惯的方式。 在本文中我们将从头开始学习如何构建解析器,基于被称为解析器组合子的函数式编程语言的通用技术。一旦你掌握了它们的基本概念,它们的优势将非常巨大,同时又非常接近第一原理。因为这里唯一的抽象是你将在基础的组合子之上构建你自己的抽象。所有这些,你必须先构建它们,才能使用它们。

How To Work Through This Article

强烈建议你初始化一个 Rust 项目,并且在src/lib.rs中书写你阅读到的代码片段(你可以直接从页面上直接复制,但是最好还是自己手敲,因为这样会自动确保你完整阅读代码)。本文将按序介绍你需要的每段代码。请注意,有时会引入你先前编写功能的最新版本,这时,你需要将旧版本替换为新版本。

该代码是使用 2018 年语言版本的rustc的 1.34.0 版本编写的。你应该尽可能使用最新版本的编译器,只要你确保你使用的是 2018 的版本即可(检查Cargo.toml文件中是否包含edition = "2018")。代码不需要任何的外部依赖。

如果要运行本文中的测试用例,请执行cargo test

The Xcruciating Markup Language

我们将用简化的 XML 格式写一个解析器,如下所示:

<parent-element>
  <single-element attribute="value" />
</parent-element>

XML 元素使用<符号开头和一个由字母组成的标志符开头,后面可以根任意数量的字母,数字和-。后面跟一些空格,以及一些属性对的可选列表:前面定义一个标识符,后面跟一个=符号和一个双引号字符串,最后以/>结尾来表示一个没有子节点的单个元素。或者用>表示后面跟了一串子节点,最终是一个以</开头的结束标签,后跟一个必须与开始标识匹配的标志符,然后以>结束。

这就是我们将要支持的特性。没有命名空间,没有文本节点,其余的都没有,并且没有模式校验。我们甚至都不会自寻烦恼,去实现字符串的转义引号(\") - 它们由第一个双引号开始,到下一个双引号结束,就是这样。如果你想在实际字符串中使用双引号,你可以将这个不合理的要求转移到其他地方。

我们将用下面的结构体来解析这些元素。


#![allow(unused)]
fn main() {
#[derive(Clone, Debug, PartialEq, Eq)]
struct Element {
    name: String,
    attributes: Vec<(String, String)>,
    children: Vec<Element>,
}
}

没有花哨的类型,仅仅用字符串表示名称(那是每个标签开始的标志符),用字符串元组表示属性(标志符和对应的值),还有一个看起来和父元素完全一样的子元素列表。

(如果你正在输入,请加上那一系列的derive,你将在后面用到它们)。

Defining The Parser

好了,是时候编写解析器了。

解析是从数据流中派生结构的过程。解析器就是要弄清楚该结构的工具。

在我们即将探索的知识中,解析器最简单的一种形式就是一个函数,该函数接受一些输入并返回解析后的输出以及输入的其余部分,或者是一个错误信息:“我无法解析该输入”。

事实证明,简而言之,更复杂的解析器看起来也是如此。如果你要获取友好的错误提示,你可能会使输入,输出和错误的含义复杂化,但是解析器的特征依然如此:消费输入并产生解析后的数据以及输入的其余部分,或者会让你知道无法将输入解析成输出。

让我们将其(解析器)写成函数的形式。


#![allow(unused)]
fn main() {
Fn(Input) -> Result<(Input, Output), Error>
}

更具体的说,就我们而言,我们希望可以填充这些类型,就像下面代码块所示。因为我们要做的是将一个字符串转化为Element结构体,并且在这方面我们不想陷入错综复杂的错误提示中,所以当我们不能够解析输入的字符串时,只需返回一段提示错误的字符串即可。


#![allow(unused)]
fn main() {
Fn(&str) -> Result<(&str, Element), &str>
}

我们使用字符串切片(&str),是因为他是指向字符串的有效指针。我们可以进一步对其进行切片,通过切掉已解析的部分来消耗输入,并将余下的输入和结果一起返回。

可能使用&[u8]类型作为输入(一串字节数组,如果将输入的字节限制在ASCII范围内,则u8char相对应)会看起来更加干净,尤其是字符串切片(&str)的行为和大多数切片的行为有所不同。你不能够通过一个数字来检索它们,你必须使用一个分片input[0..1]。另一方面,它们对于解析字符串有很多有用的函数,而字节切片没有。

事实上,我们通常都需要依赖这些函数而不是像input[0]这样对他进行索引。因为 Rust 的字符串是UTF-8格式的,这些索引并不总是对应于单个字符,所以对于我们来说,让标准库为我们处理这些问题更好。

Our First Parser

让我们来一起写一个解析器,只需要关注字符串中的第一个字符并判断它是否是字母a


#![allow(unused)]
fn main() {
fn the_letter_a(input: &str) -> Result<(&str, ()), &str> {
  match input.chars().next() {
      Some('a') => Ok((&input['a'.len_utf8()..], ())),
      _ => Err(input),
  }
}
}

首先,我们来看一下输入和输入和输出的类型:和上面讨论的一样,我们让字符串切片作为输入,并返回携带(&str, ())元组或者错误类型&strResult(&str, ())元组比较有趣:正如我们所说的,我们想要返回一个包含下一次待解析的输入和输出。&str是下一次输入,解析返回的结果仅仅是一个单元类型()。因为如果解析成功,只可能有一种结果(我们找到了字母a),但是这里我们并不需要返回字母a,我们只需要告诉调用者我们成功发现了字母a即可。

然后,让我们看下解析器本身的代码。我们从提取输入的第一个字符开始:input.chars().next()。依靠标准库来避免带来的 Unicode 编码问题并不是在开玩笑 - 我们要求它为字符串的字符提供一个chars()迭代器,然后从中取出第一项。该项是封装在Option中的char类型,因此是Option<char>,如果它的值为None则表示我们尝试在空字符串中提取一个char

更糟的是,char可能并不是你想的那样是 Unicode 字符。它很可能就是 Unicode 所说的“字素簇”,它可以由几个char组成,实际上代表“标量值”,大约比字素簇低两级。但是这种方式太疯狂了,出于我们(讲解)的目的,我们可能根本不会看到除 ASCII 之外的字符集,所以这种情况我们不做讨论。

我们模式匹配上了Some('a'),这是我们正在寻找的特定结果,如果匹配上,则返回结果Ok((&input['a'.len_utf8()..], ()))。也就是说,我们在字符串切片中删除了刚刚解析的'a'并返回剩余部分以及我们的解析结果(一个空的())。考虑到 Unicode 怪物,我们在切片之前向标准库询问了'a'在 UTF-8 编码中的长度,但是,请永远不要假设 Unicode 怪物。

如果我们匹配到其他的Some(char)或者None,我们将返回一个错误。你可能还记得,我们的错误类型目前还是解析失败时的字符串切片,也就是作为input传入的字符串切片。它不以'a'开头,因此这是我们的错误。但这不是一个大的错误,但是至少比“其他地方出了问题”要好一些。

虽然我们不需要使用此解析器解析 XML,但是我们要做的第一件事就是查找<,因此我们可能需要一些相似的东西。我们还需要专门解析>/=,因此我们可以创建一个函数为我们想要的字符构建一个解析器?

A Parser Builder

我们想象一下:编写一个函数,可以为任意长度的静态字符串(而不仅仅是单个字符)生成一个解析器。这种方式甚至更容易,因为一个字符串切片已经是一个合法的 UTF-8 字符串切片,我们不需要再去考虑 Unicode 怪物。


#![allow(unused)]
fn main() {
fn match_literal(expected: &'static str)
    -> impl Fn(&str) -> Result<(&str, ()), &str>
{
    move |input| match input.get(0..expected.len()) {
        Some(next) if next == expected => {
            Ok((&input[expected.len()..], ()))
        }
        _ => Err(input),
    }
}
}

这个函数看起来有点不同。

我们先看下类型。我们的函数看起来不再像是一个解析器,它将我们期望的字符串作为参数,并返回一个看似解析器的东西。它是一个返回函数的函数 - 换言之,是一个高阶函数。基本上,我们在编写一个函数,该函数可以构建一个类似之前的the_letter_a函数。

因此,我们不是在函数体中完成工作,而是返回一个闭包用来处理这些事情,该闭包与我们之前解析器的类型签名相匹配。

模式匹配看起来是一样的,除了我们不能直接匹配字符串字面量,因为我们不知道它具体是什么,所以我们使用匹配条件if next == expected代替。否则它和以前完全一样,只是在闭包的主体内。

Testing Our Parser

我们来为它编写测试用例,确保这个函数没有问题。


#![allow(unused)]
fn main() {
#[test]
fn literal_parser() {
    let parse_joe = match_literal("Hello Joe!");
    assert_eq!(
        Ok(("", ())),
        parse_joe("Hello Joe!")
    );
    assert_eq!(
        Ok((" Hello Robert!", ())),
        parse_joe("Hello Joe! Hello Robert!")
    );
    assert_eq!(
        Err("Hello Mike!"),
        parse_joe("Hello Mike!")
    );
}
}

首先,我们构建了一个解析器:match_literal("Hello Joe!")。它应该消耗字符串"Hello Joe!"并返回字符串的剩余部分,或者失败并返回整个字符串。

在第一种情况下,我们为它提供了它期望的确切字符串,并看到它返回了一个空字符串以及()值,这表示“我们解析了期望的字符串,并且不需要将它真的返回”。

在第二种情况下,我们提供了字符串"Hello Joe! Hello Robert!",并且我们看到它确实消耗了字符串"Hello Joe!"并且返回了输入字符串的剩余部分:"Hello Robert!"(包括空格)。

在第三种情况下,我们提供了一个不正确的输入"Hello Mike!",并注意到该函数拒绝了这个输入并返回了一个错误。不是说 Mike 作为一般规则是不正确的,而是它不是此解析器所需要的。

Exercises

  • 你能在标准库中找到一个关于str类型的方法,让你编写match_literal()时不必做麻烦的get索引吗?

A Parser For Something Less Specific

我们可以继续解析<>=以及<//>。我们几乎已经完成了。

<之后需要识别的部分是元素名称。我们不能通过简单的字符串比较做到这一点。但是我们可以使用正则表达式。

但是我们需要克制一下自己,这将是一个很容易在简单代码中复用的正则表达式,我们不需要为此引入regex库。让我们尝试一下是否可以只使用 Rust 标准库来编写自己的解析器。

回顾元素名称标志符的规则:首位是字母,后跟零个或多个字母,数字或-


#![allow(unused)]
fn main() {
fn identifier(input: &str) -> Result<(&str, String), &str> {
    let mut matched = String::new();
    let mut chars = input.chars();

    match chars.next() {
        Some(next) if next.is_alphabetic() => matched.push(next),
        _ => return Err(input),
    }

    while let Some(next) = chars.next() {
        if next.is_alphanumeric() || next == '-' {
            matched.push(next);
        } else {
            break;
        }
    }

    let next_index = matched.len();
    Ok((&input[next_index..], matched))
}
}

与往常一样,我们首先查看类型。这次我们不是编写函数来构建解析器,只需要编写解析器本身,就像第一次一样。这里显著的区别是,我们返回元组中的String和剩余的输入,而不是()的结果类型。这个String将包含我们刚刚解析的标志符。

考虑到这一点,我们首先创建一个空字符串并将称之为matched。它是我们将要返回的结果值。我们在input中获取一个字符的迭代器,我们将对其进行拆解。

第一步是查看前面是否有字母。我们从迭代器中取出第一个字符并检查它是否是一个字母:next.is_alphabetic()。Rust 标准库当然是帮我们来处理 Unicode 的 - 这将匹配任何字母表中的字母,而不仅仅是 ASCII。如果它是一个字母,我们将它放入我们的matched字符串中,如果不是,显然我们不是在查看元素标志符,所以我们立即返回一个错误。

第二步,我们不断从迭代器中取出字符,将它们放入我们正在构建的字符串中,直到我们找到一个既不是is_alphanumeric()(就像是is_alphabetic(),它只是不能匹配字母表中的任何数字)也不是破折号'-'的字符。

当我们第一次看到不符合这些条件的字符时,意味着我们已经完成了解析,所以我们跳出循环并返回我们构建的String,记住去掉我们在input中消耗的字符。如果迭代器用完了字符,意味着我们到达了input的末尾。

值得注意的是,当我们看到不是字母数字 (alphanumeric) 或破折号(-)的内容时,我们不会返回错误。一旦我们匹配了第一个字母,我们就已经有足够的东西来创建一个有效的标志符。而且在我们解析完标志符以后,输入字符串中有更多待解析的字符是非常正常的,所以我们只是停止解析并返回我们的结果,只有当我们找不到第一个字母时,我们才真正的返回错误,因为在这种情况下,肯定没有标志符。

还记得我们要将 XML 文档解析成的Element结构吗?


#![allow(unused)]
fn main() {
struct Element {
    name: String,
    attributes: Vec<(String, String)>,
    children: Vec<Element>,
}
}

我们实际上只是完成了解析器的第一部分,name字段。我们解析器返回的字符串就在那里。它也是解析每个attribute第一部分需要的解析器。

让我们对其进行测试。


#![allow(unused)]
fn main() {
#[test]
fn identifier_parser() {
    assert_eq!(
        Ok(("", "i-am-an-identifier".to_string())),
        identifier("i-am-an-identifier")
    );
    assert_eq!(
        Ok((" entirely an identifier", "not".to_string())),
        identifier("not entirely an identifier")
    );
    assert_eq!(
        Err("!not at all an identifier"),
        identifier("!not at all an identifier")
    );
}
}

我们可以在第一种情况中看到,字符串"i-am-an-identifier"被完整解析,只留下空的字符串。在第二种情况中,解析器返回"not"作为标志符,并且字符串的剩余部分作为剩余的输入返回。在第三种情况中,解析彻底 (outright) 失败,因为它找到的第一个字符不是字母。

Combinators

现在,我们已经可以解析<,以及之后的标志符了,但是我们需要同时将它们进行解析,以便在这里可以取得进展。因此接下来将编写另一个解析器的构造函数,它将两个解析器作为输入并返回一个新的解析器,并按顺序解析它们。换言之,它是一个解析器组合子,因为它将两个解析器组合成一个新的解析器。让我们看看我们是否能够做到这一点。


#![allow(unused)]
fn main() {
fn pair<P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Fn(&str) -> Result<(&str, (R1, R2)), &str>
where
    P1: Fn(&str) -> Result<(&str, R1), &str>,
    P2: Fn(&str) -> Result<(&str, R2), &str>,
{
    move |input| match parser1(input) {
        Ok((next_input, result1)) => match parser2(next_input) {
            Ok((final_input, result2)) => Ok((final_input, (result1, result2))),
            Err(err) => Err(err),
        },
        Err(err) => Err(err),
    }
}
}

这里有点复杂,但是你知道该怎么做:从查看类型开始。

首先,我们有四种类型变量:P1P2R1以及R2。这是 Parser1,Parser2,Result1,Result2 的缩写。P1P2是函数,你会注意到它们遵循解析器函数的既定模式 (established pattern):就像返回值,它们将&str作为输入并返回一个Result类型,该Result类型可能是一个包含剩余输入以及解析结果的二元组,或者是一个错误。

但是查看每个函数的结果类型:P1如果解析成功,将会产生R1P2同理会产生R2。最终解析器的结果 - 从我们的函数返回的结果 - 是(R1, R2)。因此该解析器的工作是先在输入上运行解析器P1,然后在P1返回的剩余输入上运行P2,如果这两个解析器都执行成功,则我们将两个解析器返回的结果组合进元组(R1, R2)

查看代码,我们看到这也正是它所做得。我们首先在输入上运行第一个解析器,然后是第二个解析器,接着将两个结果组合成一个元组并返回。如果这些解析器中的任何一个执行失败,我们会立即返回它给出的错误。

这样,我们应该能够结合之前的两个解析器match_literalidentifier来实际解析 XML 标签的第一部分(<my-first-element/>)。让我们编写测试用例看它是否正确。


#![allow(unused)]
fn main() {
#[test]
fn pair_combinator() {
    let tag_opener = pair(match_literal("<"), identifier);
    assert_eq!(
        Ok(("/>", ((), "my-first-element".to_string()))),
        tag_opener("<my-first-element/>")
    );
    assert_eq!(Err("oops"), tag_opener("oops"));
    assert_eq!(Err("!oops"), tag_opener("<!oops"));
}
}

它看起来成功了!但是看下结果类型:((), String)。很明显,我们只关心右边的值 - String。这种情况相当普遍 - 我们的一些解析器只匹配输入中的模式但不产生值,因此可以安全的忽略他们的输出。为了适应这种模式,我们将使用pair组合子编写另外两个组合子left,它丢弃第一个解析器的结果,只返回第二个,以及它的相反数right,我们想在上面的测试中使用right而不是pair - 它可以丢弃二元组左边的()只保留右边的String

Enter The Functor

在我们深入讨论之前,先介绍另一个组合子:map,它将使编写这两个解析器更加简单。

这个组合子有一个目的:改变结果的类型。例如,假设你有一个解析器返回((), String),但是你希望能够将其返回值类型修改为String

为了做到这点,我们传入一个函数,该函数知道如何将原始类型转换成新的类型。在我们的示例中,该函数十分简单:|(_left, right)| right。它的一般格式就像Fn(A) -> B,其中A是解析器的原始类型,B是期望的新类型。


#![allow(unused)]
fn main() {
fn map<P, F, A, B>(parser: P, map_fn: F) -> impl Fn(&str) -> Result<(&str, B), &str>
where
    P: Fn(&str) -> Result<(&str, A), &str>,
    F: Fn(A) -> B,
{
    move |input| match parser(input) {
        Ok((next_input, result)) => Ok((next_input, map_fn(result))),
        Err(err) => Err(err),
    }
}
}

这些类型说明什么?P是我们的解析器。它在成功时将返回AF是我们将用于将P映射到我们的返回值中的函数,它看起来和P很像,但是它的返回值类型是B而不是A

在代码中,我们运行parser(input),如果执行成功,我们将拿到result并在其上运行函数map_fn(result),然后将A转换成B

实际上,我们可以稍微放纵 (indulge) 一下自己并缩短一下这个函数,因为map实际上是处理Result的一种常见模式:


#![allow(unused)]
fn main() {
fn map<P, F, A, B>(parser: P, map_fn: F) -> impl Fn(&str) -> Result<(&str, B), &str>
where
    P: Fn(&str) -> Result<(&str, A), &str>,
    F: Fn(A) -> B,
{
    move |input|
        parser(input)
            .map(|(next_input, result)| (next_input, map_fn(result)))
}
}

这种模式在 Haskell 以及范畴论 (category theory) 中被称为”函子 (functor)“。如果你在其中有一个类型为A的东西,并且有一个可用的map函数,你可以将某个函数从A传递到B以将其转换成相同类型的东西,但是在其中使用类型B,这就是一个函子。你可以在 Rust 中很多地方见到它,如在 Option 中,Result 中,Iterator 甚至在 Future 中,但它没有被明确命名。因为在 Rust 的类型系统中,你不能真正的将函子表示为泛化的东西,因为它缺乏更高级的类型,但那是另一回事了,所以我们只需关注这些函子,寻找map函数即可。

Time For A Trait

你可能已经注意到,我们不断重复解析器类型签名的格式:Fn(&str) -> Result<(&str, Output), &str>。你可能已经厌倦了像我写的那样完整的阅读它,所以我认为是时候引入一个 trait,让这些更具有可读性,还可以为解析器添加一些扩展性。

但是首先,我们先为一直使用的返回值类型创建一个类型别名:


#![allow(unused)]
fn main() {
type ParseResult<'a, Output> = Result<(&'a str, Output), &'a str>;
}

现在,我们可以不用一直输入那串冗长的类型,可以直接使用ParseResult<String>。我们可以在这里添加一个生命周期,因为类型的声明需要它,但是大多数时候 Rust 编译器可以帮你推断。通常,可以尝试不添加生命周期,并观察 rustc 是否报错,如果报错则添加生命周期。

在此例中,'a特指input的生命周期。

现在,对于 trait,我们也需要在这里声明生命周期,并且当你使用该 trait 时,生命周期通常也是必须的。这是一个额外的输入,但它击败了以前的版本。


#![allow(unused)]
fn main() {
trait Parser<'a, Output> {
    fn parse(&self, input: &'a str) -> ParseResult<'a, Output>;
}
}

它目前只有一个方法:parse()方法,它看起来似曾相识:它和我们之前编写的解析器函数基本相同。

为了让它更加简单,我们实际上可以为任何匹配解析器签名的函数实现这个 trait。


#![allow(unused)]
fn main() {
impl<'a, F, Output> Parser<'a, Output> for F
where
    F: Fn(&'a str) -> ParseResult<Output>,
{
    fn parse(&self, input: &'a str) -> ParseResult<'a, Output> {
        self(input)
    }
}
}

通过这种方式,我们不仅可以传递迄今为止已传递的相同函数作为解析器,并完全实现Parser trait,还可以使用其他类型作为解析器。

但是,更重要的是,它把我们从必须一直输入这些函数签名的噩梦中解救了出来。让我们重写map函数,并看它是如何工作的。


#![allow(unused)]
fn main() {
fn map<'a, P, F, A, B>(parser: P, map_fn: F) -> impl Parser<'a, B>
where
    P: Parser<'a, A>,
    F: Fn(A) -> B,
{
    move |input|
        parser.parse(input)
            .map(|(next_input, result)| (next_input, map_fn(result)))
}
}

这里需要特别注意的一件事是:我们现在必须执行parser.parse(input),而不是将解析器作为函数调用,因为我们不知道类型P是否是函数,我们只知道它实现了Parser,并且我们必须使用Parser提供的接口。但是函数体看起来完全一样,类型看起来也更加简洁。只是有一个新的额外噪音:生命周期'a,但总的来说是一个很大的改进。

如果我们以相同的方式重新编写pair函数,会看起来更加简洁:


#![allow(unused)]
fn main() {
fn pair<'a, P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Parser<'a, (R1, R2)>
where
    P1: Parser<'a, R1>,
    P2: Parser<'a, R2>,
{
    move |input| match parser1.parse(input) {
        Ok((next_input, result1)) => match parser2.parse(next_input) {
            Ok((final_input, result2)) => Ok((final_input, (result1, result2))),
            Err(err) => Err(err),
        },
        Err(err) => Err(err),
    }
}
}

和之前一样,这里唯一的改变就是类型签名更加简洁,并且需要执行parser.parse(input)而不是parse(input)

实际上,我们还可以简化pair函数的主题,通过使用map


#![allow(unused)]
fn main() {
fn pair<'a, P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Parser<'a, (R1, R2)>
where
    P1: Parser<'a, R1>,
    P2: Parser<'a, R2>,
{
    move |input| {
        parser1.parse(input).and_then(|(next_input, result1)| {
            parser2.parse(next_input)
                .map(|(last_input, result2)| (last_input, (result1, result2)))
        })
    }
}
}

Resultand_then方法和map类似,不同之处在于map函数不会返回新值到Result内部,而是返回一个新的Result。上面的代码与先前使用match块的版本效果相同。稍后我们将回到and_then,但是现在,既然我们有一个干净简洁的map,我们可以真正实现leftright组合子。

Left And Right

有了pairmap,我们可以非常简洁的编写leftright组合子:


#![allow(unused)]
fn main() {
fn left<'a, P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Parser<'a, R1>
where
    P1: Parser<'a, R1>,
    P2: Parser<'a, R2>,
{
    map(pair(parser1, parser2), |(left, _right)| left)
}

fn right<'a, P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Parser<'a, R2>
where
    P1: Parser<'a, R1>,
    P2: Parser<'a, R2>,
{
    map(pair(parser1, parser2), |(_left, right)| right)
}
}

我们使用pair组合子将两个解析器组合成一个解析器,然后使用map组合子选择其结果元组中我们想要保留的部分。

现在我们需要为元素标签的前两部分重写测试,使它更简洁一些,在此过程中,我们将获得了一些重要的新解析器组合子功能。

不过,我们必须先更新我们的两个解析器以使用ParserParseResultmatch_literal是更复杂的一个:


#![allow(unused)]
fn main() {
fn match_literal<'a>(expected: &'static str) -> impl Parser<'a, ()> {
    move |input: &'a str| match input.get(0..expected.len()) {
        Some(next) if next == expected => Ok((&input[expected.len()..], ())),
        _ => Err(input),
    }
}
}

除了改变返回值类型之外,我们还必须确保闭包的输入类型是&'a str,否则 rustc 会报错。

对于identifier,只需更改返回值类型即可,编译器会自动为你推断生命周期。


#![allow(unused)]
fn main() {
fn identifier(input: &str) -> ParseResult<String> {}
}

接下来进行测试,可以看见在结果中没有那个笨拙 (ungainly) 的()


#![allow(unused)]
fn main() {
#[test]
fn right_combinator() {
    let tag_opener = right(match_literal("<"), identifier);
    assert_eq!(
        Ok(("/>", "my-first-element".to_string())),
        tag_opener.parse("<my-first-element/>")
    );
    assert_eq!(Err("oops"), tag_opener.parse("oops"));
    assert_eq!(Err("!oops"), tag_opener.parse("<!oops"));
}
}

One Or More

让我们继续解析元素标签。我们已经解析了<和标志符。然后呢?应该是我们的第一个属性对。

实际上这些属性对是可选的。我们需要找到一种方式来处理这种可选的标记。

等等,实际上在我们得到第一个可选属性对之前我们必须处理一些事情:空格。

在元素名称的末尾以及第一个属性名称(如果存在的话)的开头之间,存在一个空格。我们需要处理这个空格。

甚至更糟的是,我们需要处理一个或多个空格,因为<element attribute="value"/>也是一个合法的语法,即使它的空格很多。所以这似乎是我们考虑是否可以编写一个组合子来表示一个或多个解析器想法的好时机。

我们在identifier解析器中已经处理过这个问题,但是一切都是在那里手动完成的。毫不奇怪,总体思路的代码并没有什么不同。


#![allow(unused)]
fn main() {
fn one_or_more<'a, P, A>(parser: P) -> impl Parser<'a, Vec<A>>
where
    P: Parser<'a, A>,
{
    move |mut input| {
        let mut result = Vec::new();

        if let Ok((next_input, first_item)) = parser.parse(input) {
            input = next_input;
            result.push(first_item);
        } else {
            return Err(input);
        }

        while let Ok((next_input, next_item)) = parser.parse(input) {
            input = next_input;
            result.push(next_item);
        }

        Ok((input, result))
    }
}
}

首先,我们构建的解析器返回值类型是A,组合解析器的返回值类型是Vec<A> - 任意数量的A

这个代码看起来确实和identifier很像。首先,我们解析第一个元素,如果不存在,则返回一个错误。然后我们尽可能多的解析元素,直到解析失败,此时我们返回包含收集元素的向量。

将上述代码修改为支持解析零次或多次的解析器也很简单,只需删除前部分if判断即可:


#![allow(unused)]
fn main() {
fn zero_or_more<'a, P, A>(parser: P) -> impl Parser<'a, Vec<A>>
where
    P: Parser<'a, A>,
{
    move |mut input| {
        let mut result = Vec::new();

        while let Ok((next_input, next_item)) = parser.parse(input) {
            input = next_input;
            result.push(next_item);
        }

        Ok((input, result))
    }
}
}

让我们编写测试用例以验证这两个解析器的功能符合预期。


#![allow(unused)]
fn main() {
#[test]
fn one_or_more_combinator() {
    let parser = one_or_more(match_literal("ha"));
    assert_eq!(Ok(("", vec![(), (), ()])), parser.parse("hahaha"));
    assert_eq!(Err("ahah"), parser.parse("ahah"));
    assert_eq!(Err(""), parser.parse(""));
}

#[test]
fn zero_or_more_combinator() {
    let parser = zero_or_more(match_literal("ha"));
    assert_eq!(Ok(("", vec![(), (), ()])), parser.parse("hahaha"));
    assert_eq!(Ok(("ahah", vec![])), parser.parse("ahah"));
    assert_eq!(Ok(("", vec![])), parser.parse(""));
}
}

请注意两者的不同之处:对于one_or_more,解析空字符串时会返回错误,因为它需要找到至少一个满足其子解析器的输入,但是对于zero_or_more,一个空字符串只是意味着没有满足条件的输入,它并不是一个错误。

鉴于此,可以考虑将这两个方法进行泛化,因为一个是另一个的副本,只是移除了一部分功能。用zero_or_more来表示one_or_more可能会很诱人 (tempt)。


#![allow(unused)]
fn main() {
fn one_or_more<'a, P, A>(parser: P) -> impl Parser<'a, Vec<A>>
where
    P: Parser<'a, A>,
{
    map(pair(parser, zero_or_more(parser)), |(head, mut tail)| {
        tail.insert(0, head);
        tail
    })
}
}

在这里,我们遇到了 Rust 的一个问题,我甚至不是指Vec没有cons方法的问题,但我知道每个阅读那段代码的 Lisp 程序员都在思考这个问题:那就是所有权。

我们拥有这个解析器,所以我们不能将其作为参数传递两次,编译器会试图对你大喊,因为你试图移动一个已经移动的值。那么我们可以让组合子代替引用吗?不,事实证明,我们还遇到另一整套借用检查器的问题 - 我们目前不会试图解决这些问题。并且因为这些解析器是函数,所以它们不会实现Clone,该 trait 本来可以帮我们节省一整天的时间,所以我们被困在一个约束中,我们不能在组合子中轻易地复用我们的解析器。

不过,这不是一个大问题。它只是说明我们不能使用组合子来表达one_or_more,但事实证明,这两个通常是你需要的唯一组合子,它们往往会重用解析器,而且,如果你想变得非常花哨,除了解析器,你还可以编写一个带有RangeBound的组合子,并在一个范围内对其进行重复:zero_or_more使用range(0..)one_or_more使用range(1..)five_or_six使用range(5..=6),依此类推。

不过,让我们把它留给读者作为练习。现在我们只需使用zero_or_moreone_or_more即可。

另一个练习可能是找到解决这些所有权问题的方法 - 也许可以通过将解析器包装在Rc中使其支持克隆?

A Predicate Combinator #

我们现在有了需要用 one_or_more 解析空白的 xml 块,并用 zero_or_more 解析属性对。 其实,稍等一下。我们其实不想先解析空格 然后 解析属性 (attributes)。如果你考虑一下,如果没有属性,空格是可选的,我们可能会遇到直接的 >/>。但是如果有一个属性,那么必须是空格。幸运的是,每个属性之间也必须有空格,如果有多个,所以我们在这里真正看到的是一系列零或更多出现的一个或多个空格项,然后是属性。

我们首先需要一个解析单个空白项的解析器。我们可以选择下列三种方式之一。

一,我们可以愚蠢地使用我们的 match_literal 解析器和一个只包含一个空格的字符串。为什么这么傻?因为空格也是换行符、制表符和大量呈现为空格的奇怪 Unicode 字符。我们将不得不再次依赖 Rust 的标准库,当然 char 有一个 is_whitespace 方法,就像它有 is_alphabeticis_alphanumeric 一样。

二,我们可以使用 is_whitespace Predicate 写出另外一个解析器,它消耗任意数量的空白字符,就像我们之前编写的 identifier 一样。

三,我们可以写的更加巧妙。编写一个解析器 any_char,它返回一个单一的 char,只要输入中还剩下一个,以及一个组合子 pred,它接受一个解析器和一个谓词 (Predicate)函数,并像这样将两者结合起来:pred (any_char, |c| c.is_whitespace())。这有一个额外的好处,通过它,编写我们将需要的最终的解析器变得非常容易:属性值的引用字符串。 any_char 解析器写起来很简单,但我们得记得注意那些 UTF-8 问题。


#![allow(unused)]
fn main() {
fn any_char(input: &str) -> ParseResult<char> {
    match input.chars().next() {
        Some(next) => Ok((&input[next.len_utf8()..], next)),
        _ => Err(input),
    }
}
}

经验丰富 的我们来说, pred 组合子也并没有给我们太多惊喜 。 我们调用解析器,然后我们在解析器成功的情况下调用我们的谓词函数,并且只有当它返回 true 时,我们才真正返回 success,否则我们将返回与解析器失败一致的错误。


#![allow(unused)]
fn main() {
fn pred<'a, P, A, F>(parser: P, predicate: F) -> impl Parser<'a, A>
where
    P: Parser<'a, A>,
    F: Fn(&A) -> bool,
{
    move |input| {
        if let Ok((next_input, value)) = parser.parse(input) {
            if predicate(&value) {
                return Ok((next_input, value));
            }
        }
        Err(input)
    }
}
}

写一个快速测试以确保一切正常:


#![allow(unused)]
fn main() {
#[test]
fn predicate_combinator() {
    let parser = pred(any_char, |c| *c == 'o');
    assert_eq!(Ok(("mg", 'o')), parser.parse("omg"));
    assert_eq!(Err("lol"), parser.parse("lol"));
}
}

有了这两个组件,我们可以用一个快速的单行代码编写我们的 whitespace_char 解析器:


#![allow(unused)]
fn main() {
fn whitespace_char<'a>() -> impl Parser<'a, char> {
    pred(any_char, |c| c.is_whitespace())
}
}

并且,现在我们有了whitespace_char,我们也可以用它实现我们之前的想法,一个或多个空白,以及它的姊妹想法,零个或多个空白。 让我们专注于几个简单的地方,并分别称它们为space1space0


#![allow(unused)]
fn main() {
fn space1<'a>() -> impl Parser<'a, Vec<char>> {
    one_or_more(whitespace_char())
}

fn space0<'a>() -> impl Parser<'a, Vec<char>> {
    zero_or_more(whitespace_char())
}
}

Quoted Strings

完成所有这些组件后,我们现在终于可以解析这些属性了吗? 当然啦,我们只需要确保我们拥有所有这些属性组件的单独的解析器。 我们已经为属性名称提供了 identifier(尽管使用 any_charpred 加上我们的 *_or_more 组合子来重写它是很诱人的)。 = 只是 match_literal("=")。 不过,我们只有一个带引号的字符串解析器,所以让我们把它组合起来。 幸运地是,我们已经拥有了完成它所需的所有组合子。


#![allow(unused)]
fn main() {
fn quoted_string<'a>() -> impl Parser<'a, String> {
    map(
        right(
            match_literal("\""),
            left(
                zero_or_more(pred(any_char, |c| *c != '"')),
                match_literal("\""),
            ),
        ),
        |chars| chars.into_iter().collect(),
    )
}
}

组合子的嵌套在这一点上变得有点烦人,但我们暂时不会重构所有代码来修复它,而是专注于这里发生的事情。

最外面的组合子是一个 map,因为前面提到的烦人的嵌套,从这开始理解代码是很让人困惑的,所以让我们试着找出它真正开始的地方:第一个引号字符。在map里面,有一个right,而right的第一部分就是我们要找的:match_literal("\"")。那是我们的引号的开始 。

right 的第二部分是字符串的其余部分。在 left 里面,我们很快注意到那个 leftright 参数,我们一直忽视的那个: 是另一个 match_literal("\"") - 引号的结束。所以左手部分是我们用引号包裹的字符串。

我们在这里利用新的 predany_char 来获得一个接受除另一个引号之外的任何内容的解析器,并将其放入 zero_or_more 中,因此我们所说的实现如下:

  • 一个引号

  • 后跟零个或多个不是另一个引号的内容

  • 接着是另一个引号

    并且,在 rightleft 之间,我们丢弃结果值中的引号并取回引用的字符串。

    但是等等,这不是一个字符串。还记得 zero_or_more 返回什么吗?内部解析器的返回类型 A 的 Vec<A>。对于any_char来说,就是char。那么,我们得到的不是字符串而是Vec<char>。这就是 map 的用武之地:我们使用它把 Vec<char> 转换为 String,因为你可以从 Iterator<Item = char> 构建一个 String,所以我们可以调用 vec_of_chars.into_iter().collect(),并且由于类型推断的强大功能,我们有了 String

    在我们继续之前,让我们编写一个快速测试以确保一切正常,因为如果我们需要这么多词来解释它,这可能不是我们作为程序员对自己有信心的样子。


#![allow(unused)]
fn main() {
#[test]
fn quoted_string_parser() {
    assert_eq!(
        Ok(("", "Hello Joe!".to_string())),
        quoted_string().parse("\"Hello Joe!\"")
    );
}
}

所以, 终于, 我们可以解析点属性了.

At Last, Parsing Attributes

我们现在可以解析空格、标识符、= 符号和带引号的字符串。 最后,这就是我们解析属性所需的全部内容。

首先,让我们为一个属性对编写一个解析器。 我们将把它们存储为 Vec<(String, String)>,你可能还记得,所以我们需要一个解析器来处理 (String, String) 元组,来提供给我们值得信赖的 zero_or_more 组合子。 让我们看看我们是否可以写出一个。


#![allow(unused)]
fn main() {
fn attribute_pair<'a>() -> impl Parser<'a, (String, String)> {
    pair(identifier, right(match_literal("="), quoted_string()))
}
}

易如反掌! 总结一下:我们已经有一个方便的组合子来解析一个值的元组,pair,所以我们将它与 identifier 解析器一起使用,产生一个 String 和一个带有 = 符号的 right, 它包括了我们不想保留的值,以及我们新的 quoted_string 解析器,它给了我们另一个 String

现在,让我们将其与 zero_or_more 结合起来构建该 vector - 但不要忘记它们之间的空白。


#![allow(unused)]
fn main() {
fn attributes<'a>() -> impl Parser<'a, Vec<(String, String)>> {
    zero_or_more(right(space1(), attribute_pair()))
}
}

零次或多次出现以下内容:一个或多个空白字符,然后是一个属性对。 我们使用right来丢弃空格并保留属性对。

让我们测试一下。


#![allow(unused)]
fn main() {
#[test]
fn attribute_parser() {
    assert_eq!(
        Ok((
            "",
            vec![
                ("one".to_string(), "1".to_string()),
                ("two".to_string(), "2".to_string())
            ]
        )),
        attributes().parse(" one=\"1\" two=\"2\"")
    );
}
}

测试通过! 起飞!

实际上,并没有,在叙述者点上, 我的 rustc 抱怨我的类型变得非常复杂,我需要增加最大允许的类型大小才能继续。你也可能遇到这种情况,如果发生了, 你需要知道如何处理它。 幸好,在这些情况下,rustc 通常会给出很好的建议,所以当它告诉你将 #![type_length_limit = "...some big number..."] 添加到文件顶部时,就按照它说的去做。 实际上,只需将其设为 #![type_length_limit = "16777216"],这将使我们进一步深入复杂类型的平流层。 全力以赴,我们现在是宇航员!

So Close Now

这个时候,事情似乎即将开始合为一体,这让人松了一口气,因为我们的类型正在快速接近 NP 完整性。 我们只需要处理两个版本的元素标签:单个元素和带有子元素的父元素,但是我们非常有信心,一旦我们有了这些,解析子元素将只是 zero_or_more 的问题, 对吧?

所以让我们先从单一元素开始,把孩子的问题推迟一点。 或者,更好的是,让我们首先为两者的所有共同点编写一个解析器:开头的 <、元素名称和属性。 让我们看看我们是否可以从几个组合子中得到 (String, Vec<(String, String)>)类型。


#![allow(unused)]
fn main() {
fn element_start<'a>() -> impl Parser<'a, (String, Vec<(String, String)>)> {
    right(match_literal("<"), pair(identifier, attributes()))
}
}

有了它,我们可以快速地给它打上标签,为单个元素创建一个解析器。


#![allow(unused)]
fn main() {
fn single_element<'a>() -> impl Parser<'a, Element> {
    map(
        left(element_start(), match_literal("/>")),
        |(name, attributes)| Element {
            name,
            attributes,
            children: vec![],
        },
    )
}
}

万岁,感觉我们的目标已经触手可及了——我们现在实际上正在构建一个 Element

让我们来测试一下这个现代科技的奇迹吧。


#![allow(unused)]
fn main() {
#[test]
fn single_element_parser() {
    assert_eq!(
        Ok((
            "",
            Element {
                name: "div".to_string(),
                attributes: vec![("class".to_string(), "float".to_string())],
                children: vec![]
            }
        )),
        single_element().parse("<div class=\"float\"/>")
    );
}
}

...我想我们才刚刚突破大气层。

single_element 的返回类型非常复杂,编译器会花费很长时间,直到遇到我们之前给它的非常大的类型大小限制 (#![type_length_limit = ""]),要求更大的类型。 很明显我们不能再忽视这个问题,因为它是一个相当简单的解析器和(应该只需要)几分钟的编译时间——对于成品来说甚至可能是几个小时——似乎有点不合理。

在继续之前,你最好在我们修复问题时注释掉这两个函数和测试......

To Infinity And Beyond

如果你曾经尝试过在 Rust 中编写递归类型,你可能已经知道我们的小问题的解决方案。

递归类型的一个非常简单的例子是单向链表。 原则上,可以将其表示为这样的枚举:


#![allow(unused)]
fn main() {
enum List<A> {
    Cons(A, List<A>),
    Nil,
}
}

rustc 会非常明智地反对你的递归类型 List<A> ,因为它具有无限大小,因为在每个 List::<A>::Cons 内部是另一个 List<A>,这意味着它也是一个 List<A>......直到无穷大。就 rustc 而言,我们要求一个无限列表,我们要求它能够分配一个无限列表。

在许多语言中,无限列表原则上对于类型系统来说不是问题,实际上对于 Rust 也不是问题。问题是在 Rust 中,如前所述,我们需要能够分配它,或者更确切地说,我们需要能够在我们构造它时预先确定类型的 大小,以及当类型是无限的,这意味着大小也必须是无限的。

解决方案是使用一点间接性。我们的List::Cons 不是A 的一个元素和A 的另一个list,而是我们使它成为A 的一个元素和一个指向A 列表的指针。我们知道指针的大小,不管它指向什么都是一样的,所以我们的 List::Cons 现在有一个固定的和可预测的大小,无论列表的大小如何。在 Rust 中,将一个拥有的东西变成指向堆上拥有的东西的指针的方法是用 box 包裹它。


#![allow(unused)]
fn main() {
enum List<A> {
    Cons(A, Box<List<A>>),
    Nil,
}
}

Box 的另一个有趣的特性是它里面的类型可以是抽象的。 这意味着我们可以让类型检查器处理一个非常简洁的 Box<dyn Parser<'a, A>>,而不是我们现在非常复杂的解析器函数类型。

听起来不错。 有什么缺点? 好吧,我们可能会因为必须遵循该指针而失去一两个指令周期,也可能是编译器失去了一些优化解析器的机会。 但回想一下 Knuth 关于过早优化(1)的告诫:它会没事的。 你完全可以负担得起这些周期。 来这里是为了解 解析器组合子,而不是了解手写的超专业化 SIMD 解析器(尽管它们本身就很令人兴奋)。 因此,除了迄今为止我们一直在使用的裸函数之外,让我们继续为 boxed 解析器函数实现 Parser

译者注: 1: 过早优化是万恶之源


#![allow(unused)]
fn main() {
struct BoxedParser<'a, Output> {
    parser: Box<dyn Parser<'a, Output> + 'a>,
}

impl<'a, Output> BoxedParser<'a, Output> {
    fn new<P>(parser: P) -> Self
    where
        P: Parser<'a, Output> + 'a,
    {
        BoxedParser {
            parser: Box::new(parser),
        }
    }
}

impl<'a, Output> Parser<'a, Output> for BoxedParser<'a, Output> {
    fn parse(&self, input: &'a str) -> ParseResult<'a, Output> {
        self.parser.parse(input)
    }
}
}

出于礼节,我们创建了一个新类型 BoxedParser来保存我们的 box。 要从任何其他类型的解析器(包括另一个BoxedParser,即使那毫无意义)创建一个新的BoxedParser,我们提供了一个函数BoxedParser::new(parser),它只会将该解析器放入 我们的新类型中的一个 Box。 最后,我们为它实现了 Parser,这样它就可以作为解析器互换使用。

这使我们能够将解析器函数放入 Box,并且 BoxedParser 将与函数一样用作 Parser。 现在,正如前面提到的,这意味着将解析器移动到堆中,并且必须取消引用一个指针才能找到它,这可能会花费我们几个宝贵的纳秒,所以我们实际上可能想要推迟使用 box。 将一些更常用的组合子放到 box 就足够了。

An Opportunity Presents Itself

但是,稍等片刻,这为我们提供了解决另一个变得麻烦的问题的机会。

还记得我们写的最后几个解析器吗? 因为我们的组合子是独立的函数,当我们嵌套大量的组合子时,我们的代码开始变得有点不可读。 回想一下我们的 quoted_string 解析器:


#![allow(unused)]
fn main() {
fn quoted_string<'a>() -> impl Parser<'a, String> {
    map(
        right(
            match_literal("\""),
            left(
                zero_or_more(pred(any_char, |c| *c != '"')),
                match_literal("\""),
            ),
        ),
        |chars| chars.into_iter().collect(),
    )
}
}

如果我们可以在解析器上使用这些组合子方法而不是独立函数,它会有更好的可读性。 如果我们可以将组合子声明为 Parser trait 上的方法会怎样?

问题是,如果我们这样做,我们就失去了依赖 impl Trait 作为返回类型的能力,因为 impl Trait 不允许出现在 trait 声明中。

……但现在我们有了BoxedParser。 我们不能声明一个返回 impl Parser<'a, A> 的 trait ,但我们肯定可以声明一个返回 BoxedParser<'a, A> 的 trait 。

最好的部分是我们甚至可以使用默认实现声明这些,这样我们就不必为实现 Parser 的每个类型重新实现每个组合子。

让我们用 map 来试试,通过扩展我们的 Parser trait 如下:


#![allow(unused)]
fn main() {
trait Parser<'a, Output> {
    fn parse(&self, input: &'a str) -> ParseResult<'a, Output>;

    fn map<F, NewOutput>(self, map_fn: F) -> BoxedParser<'a, NewOutput>
    where
        Self: Sized + 'a,
        Output: 'a,
        NewOutput: 'a,
        F: Fn(Output) -> NewOutput + 'a,
    {
        BoxedParser::new(map(self, map_fn))
    }
}
}

啊这,好多'a',唉,它们都是必要的。 幸运的是,我们仍然可以不变地重用旧的组合子函数——而且,我们还得到一个额外的好处,不仅可以获得更好的语法来应用它们,我们还通过自动 box 来摆脱爆炸性的 impl Trait 类型。 现在我们可以稍微改进我们的 quoted_string 解析器:


#![allow(unused)]
fn main() {
fn quoted_string<'a>() -> impl Parser<'a, String> {
    right(
        match_literal("\""),
        left(
            zero_or_more(pred(any_char, |c| *c != '"')),
            match_literal("\""),
        ),
    )
    .map(|chars| chars.into_iter().collect())
}
}

乍一看现在更明显,正在对 right() 的结果调用 .map()

我们也可以给 pairleftright 相同的处理,但是对于这三个,我认为当它们是函数时读起来更容易,因为它们反映了 pair 的输出结构 类型。 如果你不同意,完全可以像我们对 map 所做的那样将它们添加到 trait 中,并且非常欢迎你继续尝试将其作为练习。

不过,另一个主要候选人是 pred。 让我们将它的定义添加到 Parser trait 中:


#![allow(unused)]
fn main() {
fn pred<F>(self, pred_fn: F) -> BoxedParser<'a, Output>
where
    Self: Sized + 'a,
    Output: 'a,
    F: Fn(&Output) -> bool + 'a,
{
    BoxedParser::new(pred(self, pred_fn))
}
}

这下我们就可以用 pred 重写 quoted_string


#![allow(unused)]
fn main() {
zero_or_more(any_char.pred(|c| *c != '"')),
}

我认为这读起来更好一些,我认为我们也会保留 zero_or_more 原样 - 它读起来就像应用了以下谓词的零个或多个 any_char,这对我来说听起来很半。 当然,如果你愿意这么写,你也可以继续将 zero_or_moreone_or_more 移到 trait 中。

除了重写quoted_string,我们还要修正single_element中的map


#![allow(unused)]
fn main() {
fn single_element<'a>() -> impl Parser<'a, Element> {
    left(element_start(), match_literal("/>")).map(|(name, attributes)| Element {
        name,
        attributes,
        children: vec![],
    })
}
}

让我们试着取消对 element_start 和我们之前注释掉的测试的注释,看看情况是否变得更好。 开始编译并尝试运行测试......

……而且,是的,现在编译时间恢复正常了。 甚至可以继续删除文件顶部的字体大小设置。

这只是通过 box 两个 map 和一个 pred - 而且我们从中得到了更好的语法!

Having Children

现在让我们为父元素的开始标记编写解析器。 它几乎与single_element 相同,只是它以> 结尾而不是/>。 后面还有零个或多个子元素和一个结束标记,但首先我们需要解析实际的开始标记,所以让我们完成这个函数。


#![allow(unused)]
fn main() {
fn open_element<'a>() -> impl Parser<'a, Element> {
    left(element_start(), match_literal(">")).map(|(name, attributes)| Element {
        name,
        attributes,
        children: vec![],
    })
}
}

现在,我们如何得到这些子元素? 它们将是单个元素或父元素本身,并且它们有零个或多个,所以我们有我们可信赖的 zero_or_more 组合子,但是我们提供什么? 我们还没有编写过的一件事是多重解析器:解析或者单个元素父元素的东西。

为了完成这个特性,我们需要一个组合子,它按顺序尝试两个解析器:如果第一个解析器成功,我们就完成了,我们返回它的结果,就是这样。 如果失败,我们不会返回错误,而是在相同的输入上尝试第二个解析器。 如果成功,很好,如果没有,我们也会返回错误,因为这意味着我们的两个解析器都失败了,这是一个整体性失败。


#![allow(unused)]
fn main() {
fn either<'a, P1, P2, A>(parser1: P1, parser2: P2) -> impl Parser<'a, A>
where
    P1: Parser<'a, A>,
    P2: Parser<'a, A>,
{
    move |input| match parser1.parse(input) {
        ok @ Ok(_) => ok,
        Err(_) => parser2.parse(input),
    }
}
}

这使得我们可以声明一个解析器element,它匹配单个元素或父元素(现在,我们只使用open_element 来表示它,一旦我们有了element,我们将处理 element)。


#![allow(unused)]
fn main() {
fn element<'a>() -> impl Parser<'a, Element> {
    either(single_element(), open_element())
}
}

现在让我们为结束标记添加一个解析器。 它具有必须匹配开始标签的特性,这意味着解析器必须知道开始标签的名称是什么。 但这就是函数参数的用途,对吧?


#![allow(unused)]
fn main() {
fn close_element<'a>(expected_name: String) -> impl Parser<'a, String> {
    right(match_literal("</"), left(identifier, match_literal(">")))
        .pred(move |name| name == &expected_name)
}
}

事实证明,那个 pred 组合子真的很有用,不是吗?

现在,让我们为完整的父元素解析器、子元素和所有元素组合起来:


#![allow(unused)]
fn main() {
fn parent_element<'a>() -> impl Parser<'a, Element> {
    pair(
        open_element(),
        left(zero_or_more(element()), close_element(…oops)),
    )
}
}

Oops。现在我们要怎么把参数传给close_element?我想我们还缺少最后一个组合子。

我们已经很接近了。一旦我们解决了最后这个问题以让parent_element工作,我们就能把element解析器的open_element替换成新的parent_element,这就意味着,我们就有一个完整可用的XML解析器。

还记得我前面说过我们后面会回到and_then吗?好的,后面就是这里了。我们所需要的组合子,实际上,就是and_then:我们需要一个东西能接收一个解析子,还要一个函数,这个函数能接收解析子的返回结果并返回一个解析子,这个解析子我们后面再用。这有点像pair,但不是仅仅收集tuple里的两个结果,我们把结果传入一个函数。这也是and_then怎么作用在ResultOptions的方式,除了这个更好理解,因为ResultOptions基本上什么也不做,它们只是包含了一些数据而已(或者不包含,在某一些情况下)。

所以让我们试着实现一下它。


#![allow(unused)]
fn main() {
fn and_then<'a, P, F, A, B, NextP>(parser: P, f: F) -> impl Parser<'a, B>
where
    P: Parser<'a, A>,
    NextP: Parser<'a, B>,
    F: Fn(A) -> NextP,
{
    move |input| match parser.parse(input) {
        Ok((next_input, result)) => f(result).parse(next_input),
        Err(err) => Err(err),
    }
}
}

看看这些类型,这里有很多类型变量,但是我们认识P,我们的输入解析器,P有一个结果类型A。我们的函数F,本来是一个从ABmap,然而,关键的不同在于and_then接收一个函数,这个函数把A转化为一个新的解析器NextPNextP有一个返回类型B。最终的结果类型是B,因此我们可以假设不管从NextP返回的是什么,将是最后的结果。

这段代码没类型那么复杂:我们一开始运行输入解析器,如果失败,这段code也失败,但是如果输入解析器成功,接下来我们调用函数f作用于result(为类型A),从f(result)返回的是个新的解析器, 类型是B。我们把返回的解析器作用在下一段输入,然后直接返回结果。如果失败,就挂在这里,如果成功,我们就得到类型为B的值。

再来一次:首先我们运行类型为P的解析器,如果成功,我们调用函数F作用在解析器P的结果上,得到下一个类型为NextP的解析器,然后我们运行,得到最终的结果。

让我们也把它直接加到Parser特质上,因为这个就像map,肯定这样读起来更好。


#![allow(unused)]
fn main() {
fn and_then<F, NextParser, NewOutput>(self, f: F) -> BoxedParser<'a, NewOutput>
where
    Self: Sized + 'a,
    Output: 'a,
    NewOutput: 'a,
    NextParser: Parser<'a, NewOutput> + 'a,
    F: Fn(Output) -> NextParser + 'a,
{
    BoxedParser::new(and_then(self, f))
}

}

OK,现在,这样做有什么好处?

首先,我们几乎可以用这个实现pair:


#![allow(unused)]
fn main() {
fn pair<'a, P1, P2, R1, R2>(parser1: P1, parser2: P2) -> impl Parser<'a, (R1, R2)>
where
    P1: Parser<'a, R1> + 'a,
    P2: Parser<'a, R2> + 'a,
    R1: 'a + Clone,
    R2: 'a,
{
    parser1.and_then(move |result1| parser2.map(move |result2| (result1.clone(), result2)))
}
}

看上去很整洁,但是这里有个问题:parser2.map()消耗了parser2来创建包装解析器,而且函数是Fn,不是FnOnce,因此不允许消费parser2,只能取它的引用。Rust的问题,换句话说。在一个这些不是问题的更高级的语言中,这可以是一个很整洁的定义pair的方法。

尽管如此,我们还是能在Rust用这个函数懒生成close_element解析器的正确版本,或者,换句话,我们可以把参数传入它。

回想一下我们失败的尝试:


#![allow(unused)]
fn main() {
fn parent_element<'a>() -> impl Parser<'a, Element> {
    pair(
        open_element(),
        left(zero_or_more(element()), close_element(…oops)),
    )
}
}

通过使用 and_then,我们现在可以通过使用该函数构建正确版本的 close_element 来获得正确的结果。


#![allow(unused)]
fn main() {
fn parent_element<'a>() -> impl Parser<'a, Element> {
    open_element().and_then(|el| {
        left(zero_or_more(element()), close_element(el.name.clone())).map(move |children| {
            let mut el = el.clone();
            el.children = children;
            el
        })
    })
}
}

现在看起来有点复杂,因为 and_then 必须在 open_element() 中进行,在那里我们找到进入 close_element 的地方。这意味着 open_element 之后的解析器的其余部分都必须在 and_then 闭包内构造。此外,因为该闭包现在是来自 open_elementElement 结果的唯一接收者,我们返回的解析器也必须向前传递该信息。

我们在生成的解析器上 map的内部闭包具有对外部闭包中的 Elementel)的引用。我们必须使用 clone() ,因为一个 Fn 中,只有对它的引用。我们获取内部解析器的结果(我们的 Vec<Element> 子元素)并将其添加到我们克隆的 Element 中,然后将其作为最终结果返回。

我们现在需要做的就是返回到我们的 element 解析器并确保我们将 open_element 更改为 parent_element,这样它就会解析整个元素结构,而不是只是它的开头,我相信我们已经完成了!

Word Or Do I Have To?

还记得我们讨论过 map 模式如何在 Planet Haskell 上被称为“函子”吗?

and_then 模式是你在 Rust 中经常看到的另一种模式,通常与 map 位于相同的位置。它在 Iterator 上被称为 flat_map,但它的模式与其他模式相同。

它的花名是monad。如果你有一个东西 Thing<A>,并且你有一个可用的 and_then 函数,它可以将一个函数从 A 传递给 Thing<B>,这样现在你就有了一个新的 Thing<B> 来替代原来的,这就是一个 monad。

该函数可能会立即被调用,比如当你有一个 Option<A> 时,我们已经知道它是一个 Some(A) 还是一个 None,我们直接应用该函数,如果它是一个 Some(A),那么就会给我们一个Some(B)

它也可能被称为 惰性 (lazy)。例如,如果有一个仍在等待解析的 Future<A>,它不会立即调用该函数来创建一个 Future<B>,而是创建一个新的 Future<B>它包含 Future<A> 和函数,然后等待 Future<A> 完成。当它这样做时,它会使用 Future<A> 的结果调用函数,这样就搞定了 1(He isn't really your uncle.),得到你的 Future< B> 。换句话说,在Future 的情况下,可以将传递给and_then 的函数视为回调函数,因为它会在完成时使用原始 future 的结果进行调用。它还比这更有趣,因为它返回一个 new Future,它可能已经或可能没有被解决,所以它是一种将 future 连在一起的方法。

然而,与函子一样,Rust 的类型系统目前不能表达 monad,所以让我们只需注意这种模式被称为 monad,而且令人失望的是,这和网上的搜到的意思并不一样,它与墨西哥卷饼毫无关系。

Whitespace, Redux

只有最后一件事。

我们现在应该有一个能够解析一些 XML 的解析器,但是他并不能很好的接受空格。 标签之间应该允许任意空格,这样我们就可以自由地在标签之间插入换行符等(原则上,标识符和文字之间应该允许空格,比如< div />,但这里跳过它)。 在这一点上,我们应该能够毫不费力地为此组合一个快速组合子。


#![allow(unused)]
fn main() {
fn whitespace_wrap<'a, P, A>(parser: P) -> impl Parser<'a, A>
where
    P: Parser<'a, A>,
{
    right(space0(), left(parser, space0()))
}
}

如果我们将 element 包裹在里面,它将忽略 element 周围的所有前面和后面空格,这意味着我们可以随意使用尽可能多的换行符和尽可能多的缩进。


#![allow(unused)]
fn main() {
fn element<'a>() -> impl Parser<'a, Element> {
    whitespace_wrap(either(single_element(), parent_element()))
}
}

We're Finally There!

我想我们终于做到了! 让我们写个测试庆祝一下!

#[test]
fn xml_parser() {
    let doc = r#"
        <top label="Top">
            <semi-bottom label="Bottom"/>
            <middle>
                <bottom label="Another bottom"/>
            </middle>
        </top>"#;
    let parsed_doc = Element {
        name: "top".to_string(),
        attributes: vec![("label".to_string(), "Top".to_string())],
        children: vec![
            Element {
                name: "semi-bottom".to_string(),
                attributes: vec![("label".to_string(), "Bottom".to_string())],
                children: vec![],
            },
            Element {
                name: "middle".to_string(),
                attributes: vec![],
                children: vec![Element {
                    name: "bottom".to_string(),
                    attributes: vec![("label".to_string(), "Another bottom".to_string())],
                    children: vec![],
                }],
            },
        ],
    };
    assert_eq!(Ok(("", parsed_doc)), element().parse(doc));
}

下面这个测试会因为有未闭合 tag 而解析失败:


#![allow(unused)]
fn main() {
#[test]
fn mismatched_closing_tag() {
    let doc = r#"
        <top>
            <bottom/>
        </middle>"#;
    assert_eq!(Err("</middle>"), element().parse(doc));
}
}

好消息是它返回不匹配的结束标记作为错误。 坏消息是它实际上并没有问题是不匹配的结束标签,只是错误在哪里。 总比没有好,但是,老实说,随着错误消息的出现,它仍然很糟糕。 但是想让它能成功的找到错至少得再写一篇同样长的文章。

让我们关注好消息:我们使用解析器组合子从头开始编写了一个解析器! 我们知道解析器既构成函子又构成单子,因此你现在可以在令人生畏的范畴论知识聚会上给人们留下深刻印象了2。(Please don't be that person at parties. 别真的这么干)

最重要的是,我们现在知道解析器组合子是如何从头开始工作的。 现在没有人能阻止我们!

Victory Puppies

img

Further Resources

首先,我对用严格的 rust术语 向你解释 monad 感到内疚,而且我知道如果我不向你指出 他的开创性论文,Phil Wadler 会对我非常不满。 其中详细介绍了更多令人兴奋的细节——包括它们与解析器组合子的关系。

本文中的想法与 pom 解析器组合子库背后的想法极为相似,如果这让你想在同一个解析器组合子中使用风格,我强烈推荐它。

Rust 解析器组合子的最新技术仍然是 nom,以至于前面提到的 pom 显然是派生的名称(而且没有比这更好的称赞了),但它采用了与我们今天在这里构建的方法截然不同的方法。

另一个流行的 Rust 解析器组合库是 combine,它可能也值得一看。

Haskell 的开创性解析器组合库是 Parsec

最后,我对解析器组合子的第一次认识归功于 Graham Hutton 所著的 Programming in Haskell,这是一本很棒的书,不仅容易读而且还会教给你 Haskell 的积极副作用。

Licence

This work by Bodil Stokke is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

本文由 Bodil Stokke 撰写,基于署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)协议。

对Web3.0概念的梳理

作者: Mike Tang, Lester Li

前言

随着Bitcoin的天启事件(参见:《比特币白皮书》),近年来技术领域涌现出一大批新名词。这些名词的意义以及它们之间的关联,别说普通人不容易理解,就连混迹业界多年的资深人士也很难分得清。因为这些概念已经不再局限在纯技术领域了,它们是多学科交叉的前沿领域。

这些概念包括,但不仅限于如下:

1

一个新手面对这么多的概念,会不知所措。这时,他们往往会选择某一个术语所代表的方向研究下去,这是完全没问题的。但对整体性的把握也非常重要,有关这些综述性的资料,在目前的互联网中,要么就是一些飘渺的宏大乌托邦叙述,要么就是一些概念的简单陈列讲解。对这些概念成系统的成逻辑的梳理,还非常缺乏。

限于篇幅,本文不可能对这些概念一一讲解。本文的主要目标是尝试在这成堆的概念中,寻找出有关Web3.0的主要脉络。

对Web3.0的畅想

许多人对Web3.0有各种畅想,这些畅想的内涵可谓是千差万别。《刘毅:Web3.0到底是醒世恒言还是危言耸听?》中对Web3.0思想史的一个简要摘述如下:

什么是Web3.0?互联网当前处于Web2.0阶段,Web3.0是对下一代互联网的设计和设想。15年前,Web的发明人Tim Bernard-Lee爵士认为,目前互联网的局限在于,HTML是给人看的,没有元数据,不能被机器所理解。因此应该把数据赋予含义,开发出更智能、自动化程度更高的互联网应用,从而实现Web3.0,即语义网(Semantic Web)。但是大约在5年前,Lee爵士对下一代互联网的看法发生了变化,他发起了互联网大宪章运动,呼吁用户和从业者对互联网的未来进行广泛地讨论。

以太坊联创和Polkadot创始人Gavin Wood博士在2014年提出了一种革命性的Web3.0设想,并随后发起成了Web3基金会。他的理念是:Web3是为让互联网更去中心化、可验证、安全而发起的一组广泛的运动和协议;Web3愿景是实现无服务器、去中心化的互联网,即用户掌握自己身份、数据和命运的互联网;Web3将启动新全球数字经济系统,创造新业务模式和新市场,打破平台垄断,推动广泛的、自下而上的创新。

两位大师在不同的方向上进行着尝试。

Tim Bernard-Lee亲自发起了Solid项目 ,旨在让用户能控制他们在 Web 上产生的数据和内容,能选择数据如何被使用。Solid 的核心是个人数据存储系统 Solid POD,你在网上产生的所有数据都储存在 Solid POD 中,如你的联系人,你的照片和评论, 用户可选择将 Solid POD 储存在自己家中的计算机上,或挑选的网上 Solid POD 供应商(比如 Berners-Lee 创办的 Inrupt),你的所有数据都控制在自己手中,可以自由的添加或删除数据,授予权限给他人或应用来读取或写入部分 Solid POD 数据,不需要同步,因为你的数据一直伴随着你。这个平台需要开发者支持才能真正产生效果,很难想象中心化的平台会支持这个无法控制数据的去中心化平台。

Gavin Wood创办了Polkadot项目,旨在通过提供开箱即用的共享安全性保证和可互操作的多链网络架构,为用于承载Web3.0使命的各种parachain项目创建一个创新的平台。Gavin博士把Polkadot定义成为一个layer0的项目,即平台的平台 (The platform of platforms)。以此平台为根,期待绽放新时代的无穷分支没有边界的应用网络,从而逐渐侵蚀和替代现有的Web2.0互联网。

当然,业界还存在更多其它的有价值的观点,并在不同的方向上进行着尝试。

面对这些看似都很有道理的前沿的庞杂的甚至是相互冲突的思想,我们如何进行有效地梳理呢?

Web1.0和Web2.0

让我们回到互联网1.0的时代去看看能否发现一些有价值的东西。(以下这部分翻译自 a16z 的文章《Web 3.0 and the Future of Trust》by Ali Yahya 中的互联网演化这部分,有删节修改)

2

互联网早期的那一批协议其实相当美妙,TCP, IP, SMTP 和 HTTP,我们叫它 Web1.0 协议,它们是在70年代到80年代设计的,秉承的是开放性和包容性的精神,它们是开放的标准。这意味着在世界上任何地方任何人,能够站在平等的位置,在它们之上构建系统,而不需要经过任何人的准许。比如,现在已经存在几百个这些协议的开源的实现了。目前,在你手机中的那些协议的代码,不管它是iPhone还是Android手机,都直接依赖于这些开源的代码。

不知你有没有感受到,互联网对人类来说其实是一件特别伟大的事情,一个奇迹。现在的全球互联网是一个大的整体,而不是大量的不相交的独立的网络。世界上二百多个国家,千万计的公司——它们甚至有利益冲突——竟然神奇般地聚集和运行在这些同样的协议上,并在此之上互相连接,没有任何例外。

在这样一个常年分化的世界中,这是不寻常的,我们如何来解释这种现象呢?它其实就是开源的结果。因为互联网的核心协议是开源的,没人能单方面地控制互联网。它们的出现和支持是至下而上的且基本中立的。正是由于这些底层协议的稳定,在其之上才构建了现今的互联网生态。

3

这些协议的出现引导了一个创新的黄金时代的来临。在其之上,企业主和它们的投资人相信游戏的规则是中立和公平的。但是,开源很难营利!所以这些创业公司的商业模式是在这些互联网的开放协议上构建带产权的闭源的协议。这些协议就是Web2.0协议。

4

这其中少数创业公司已经变成了人类历史上最有价值的公司,你肯定听过其中一些。然后,也因为这些公司,数十亿人几乎免费地用上了伟大的新科技。这是一个现象级的事情。直到最近,这些公司都没有为此获得足够的名声(针对让数十亿人几乎免费地用上了伟大的新科技这一件事情)。

5

但是,一个严重的警告也随之而来,Web2.0的这些科技巨头已经变成大中介和互联网的守门人了。今天我们在互联网上做的大部分的事情,比如搜索网络、与人们联系、分享内容,都被迫依赖由这些公司开发的带产权的不透明的服务代码,不然就无法去做那些事情。

于是,这些公司就对他们的用户和第三方开发者,掌握了巨大的权力。凭借他们对所有数据的控制,它们控制了:平台上用户间的每个交互,每个用户无缝退出和切换到其它平台的能力,内容创作者被发现和发布内容的潜力,所有资本的流动,及第三方开发者和他们的用户间的所有关系。

他们还控制了游戏规则。在任何时候,没有警告,几乎完全按照他们的条款,这些公司可以改变在他们平台上允许发生的任何事情——经常在运行过程中剥夺整个第三方公司或用户的所有权力。这些公司已经成长为巨型怪兽。

互联网的故事,就变成了:基于中立和开放,就有了价值创造的可能性。而当太多权力掌握在少数营利性人类机构手中时,就会抑制创新,进入一个令人警惕的状态。

试想一下,在Web1.0的协议之上,孕育了Google这样的巨头。而在Google这样的Web2.0协议之上,还能孕育出一个新的类似的巨头吗?这是不可想象的事情(想象不出来如何做到)。Google已经成为互联网的守门人,它是不会让你在它之上再去做守门人的事情的。也就是说,Web2.0已经是科技树的一片叶子,已经到头。

于是我们要回去最初的状态去思考,不基于这些守门人的服务去思考,而是基于开放的Web1.0去思考:我们如何从“Don’ be evil“(Google已经放弃)去到 “Can’t be evil”。从不去做恶,进化到无法做恶。

对协议的进一步分析

Web1.0的开放协议让今天的互联网变得可能。但是,对于真实世界的软件平台服务来说,它们并不是一套完整的乐高积木。还有很多部分的协议缺失了,比如数据的存储协议,基于数据的计算协议等。

而Web2.0的科技巨头更进一步,提供了这些缺失协议的闭源版本,从而建立起了强大的商业模式(这是它们出现的本质原因)。

Web2.0有如下一些特点

  • 平台经济。平台最终必然走向垄断
  • 隐私换服务
  • 数据归平台方所有。对数据的任何挖掘、加工等产生的利润,归平台方所有
  • 依赖于平台而生存的某些职业,在平台的利益分配上,没有定价权

简单地说,Web2.0让平台成为最有钱有势的一方,所有人都在给平台打工。这种平台模式,不大可能从内部突破。这里面更详细的分析,请见:《刘毅:Web3.0到底是醒世恒言还是危言耸听?》。

Web2.0,数据完全由企业掌控,会有如下问题

  • 数据易泄漏。外界的攻击,导致数据易被盗取。特别是很多企业内部数据用明文存储,一旦被盗,所有信息相当于完全公开
  • 数据易丢失。企业运维的事故性丢失,或黑客的攻击性丢失,或企业倒闭服务关停导致的数据丢失等等
  • 数据可被篡改。企业对其内部的数据库,有至高无上的权利,理论上来说,可修改任何数据(比如删除做恶的记录),即使是用的所谓纯增量数据库也是如此
  • 数据会被审查。审查这个东西争议很大,虽然在某些方面有积极的意义,但是在另一方面负面作用非常大
  • 数据会被打包售卖。这其实是一个灰色的商业模式,此不赘述
  • 数据孤岛。同一行业下竞争企业数据孤岛现象特别严重,数据的相互交互特别困难

数据有这么多潜在的问题,核心原因就是数据被视为企业最核心的资产,数据及数据的使用规则,完全由企业来掌控。普通用户完全没有权力参与其中。

从Web3.0的角度来看,如果数据依赖于人或机构,必定会出现上述现象。而如果数据本身依托于开放的协议以及数学算法,那么是有可能解决上述那些问题的。

业界一些大师最近几年提出了一些观点,比如

  • A16z的Chris Dixon提出”重启互联网”(参见:《Rebooting the Internet》)
  • Gavin Wood说:“今天的互联网在设计上就是坏掉的。“ (参见:《Why We Need Web 3.0》)

我们需要的是一套开放的协议——就像Web1.0那样。

比特币的启发

如何做?比特币给了我们一些启发。

比特币背后的思想很有意思,它用密码学的一些基础设施比如数字签名,Web1.0的开放协议(TCP,UDP等),和一个非常聪明的激励结构来构建了一个集体所有的中立的数据库,或者叫分布式账本,用于记录比特币的交易/支付数据。

这个数据库的创新点在于,它的安全性是由它的用户至下而上建立的,而这个用户可以是任何人,在任何地点,在没有任何人允许的情况下参与进来。换句话说,对数据库的控制本身是分布式的,没有守门人。当然,它的挑战在于,许多参与者是不诚实的,如果可能,他们会想在这个系统中钻空子以获利。比特币的天才之处在于它的激励结构,它让系统可以自我监管(self-policing)。它不把唯一的一份数据库放在一个“可信赖“的数据中心(这个数据中心可能是被Google这种大公司拥有),而是在网络上每个参与者都持有一份他们自己的拷贝。

这会产生了一个问题:我们如何确保数据库的所有拷贝是始终保持一致,以保证没人能插入一个做假的交易数据呢?

答案是网络中的每个参与者(被称为矿工)监视着网络,并且给他们认为有效的交易集合投票。关键之处在于他们用他们算力进行投票。比如Alice是一个矿工,她给网络贡献的算力越大,她就帮助网络有更多的安全性。作为结果,协议给她更多的投票权力和更大的奖励。

重要的是,付给Alice的奖励是新挖出的比特币。所以比特币一口气做了两件事情,做为一种货币而存在,同时又成为引导自身安全性的资金来源。

这种思想被称为工作量证明(PoW)。内在逻辑是你必须给这个数据库贡献安全性以获得投票和回报。结果是形成了一种优雅的激励结构,该结构鼓励网络中的参与者相互检查。因此,即使他们彼此不信任,他们也会信任他们一直共同保护的这个数据库(分布式账本)。

比特币是最简单的加密网络。它试图处理去信任货币的问题——货币的正统性和背书不依赖于人类机构,而是依赖于数学上的保证。

这就是比特币背后的思想,伟大!当然,从这种模式出发不能直接推出Web3.0的具体形式,但会给我们巨大的启发。比特币给我们展示了一个可靠的无许可分布式系统是能够在现实世界中稳定持久运行的。人们有理由相信沿着这个思路发展下去,一个新的世界正在等待着我们。现在全世界最聪明的头脑们正在沿着这个方向积极探索(同时也伴随着巨大的投资热潮和泡沫)。

Web3.0的特点

业界对Web3.0的定义花样百出,莫衷一是,几乎把所有对未来的美好理想都强加到Web3.0之上,这是不可取的。

我们对Web3.0的特点进行了一些归纳:

  • Web3.0必须是开放的
  • Web3.0必须是安全的。因为开放,且安全,所以必须用到密码学技术
  • Web3.0必定是去中心化的或者叫分布式的。开放协议,必定造就去中心化
  • Web3.0的平台和应用必定具有原生通证,因为去中心化需要通过通证自动结算各方的利益分配

Web3.0会在生产关系上产生巨大的突破。

对服务的提供方来说:

  • 平台的代码(协议)是开放的
  • 不是一家企业独自掌控平台,而是一个社区拥有这个平台
  • 平台的利润,不再是不透明的,不可预测的,优先流向大股东的(大股东可能为追求更高的巨额利润来对平台的策略进行修改)。而是按规则的,可计算的,可预测地分配给所有平台中价值的创造者
  • 平台的商业模式仍然能容纳Web2.0的成熟的商业模式,比如广告、会员服务、游戏等
  • 平台可能催生新的商业模式
  • 平台不会像Web2.0一样,因为其主体公司的倒闭而关闭,一个参与节点的关闭不影响Web3.0平台整体的运行

对用户来说:

  • App的使用体验与Web2.0仍然类似或一致
  • 用户对自己贡献的内容具有所有权,能够根据对平台的内容贡献获得一定的回报
  • 对自己使用平台服务时产生的隐私数据,能够比较清楚的知晓这些数据的边界和用途,并对这些数据具有一定的决策权,可用其产生一定的经济收益
  • 对平台承诺的对一些私密数据的存储有信心(因为是密码学保证且代码是开源的)
  • 用户能跨平台地拥有一些东西的所有权(这个所有权是密码学保证的,不是某个机构认证的),这样在跨平台交互的时候,能认证且自由转移这些所有权资产

我们整理Web1.0, Web2.0, Web3.0的历史关系,做图如下:

6

Web3.0不是Web2.0的直系后代,而更像是远房表亲。Web3.0的核心思想更多来自Web1.0的开放协议簇和密码学领域的混合。Web3.0平台的运营商业模式和用户使用体验与Web2.0没有太大区别。Web3.0是开放的协议簇,基于Web3.0将会产生更丰富多样的创新的商业模式。

Web3.0不一定能在所有领域对Web2.0产生全面的替代,但一定会在某些领域,特别是具有明显网络效应的领域产生突破。比如:社交媒体、UGC内容平台(博客,问答等)、原创内容(如音乐,艺术,书籍等)分发平台等领域。

结语

我们用一张图来总结Web3.0的核心要素:

7

图片来自《Web3.0应用的新机遇》

Web3.0是一个更加开放,公平和安全的网络,其网络形态不是当前Web2.0的简单升级,它要解决的核心问题,其实是平台利益的分配问题。Web3.0要在激励人的主观能动性和价值创造的基础上尝试解决利益分配问题。而利益的分配,又与组织形式密切相关。这正是区块链能发挥作用的地方,这些细节具体会在下一篇《为什么Web3.0需要区块链》中详细讲解。

Web3.0不是在一块空白之地上建立空中楼阁,它致力于解决人类社会发展中的深层次问题。

引用

  1. 比特币白皮书 https://bitcoin.org/bitcoin.pdf
  2. Web 3.0 and the Future of Trust https://a16z.com/2019/11/12/the-end-of-centralization-and-the-future-of-trust/
  3. Rebooting the Internet https://medium.com/dfinity/rebooting-the-internet-andreessen-horowitzs-chris-dixon-on-the-decentralized-web-e0b4b0245645
  4. Why We Need Web 3.0 https://gavofyork.medium.com/why-we-need-web-3-0-5da4f2bf95ab
  5. 《刘毅:Web3.0到底是醒世恒言还是危言耸听?》 https://mp.weixin.qq.com/s?src=11&timestamp=1618666137&ver=3014&signature=gvJ5zET-uhEK6GCJGTPMG70iAollqC4oToIB1aXZrOq6drLiThxPVXc1feP84JpcNKnovNPIc2umIuPPHuwzef7mKrD*sUhGCcH89SG2aUT5-9lp4nNxJ86S9HxLUQ&new=1
  6. 《Web3.0应用的新机遇》 https://mp.weixin.qq.com/s/DxHSRASawmwU_snbsKXKdw

Rbatis ORM 2.0 | 零开销编译时动态SQL的探索

作者:朱秀杰


  • 什么是动态SQL?

在某种高级语言中,如果嵌入了SQL语句,而这个SQL语句的主体结构已经明确,例如在Java的一段代码中有一个待执行的SQL“select * from t1 where c1>5”,在Java编译阶段,就可以将这段SQL交给数据库管理系统去分析,数据库软件可以对这段SQL进行语法解析,生成数据库方面的可执行代码,这样的SQL称为静态SQL,即在编译阶段就可以确定数据库要做什么事情。 而如果嵌入的SQL没有明确给出,如在Java中定义了一个字符串类型的变量sql:String sql;,然后采用preparedStatement对象的execute方法去执行这个sql,该sql的值可能等于从文本框中读取的一个SQL或者从键盘输入的SQL,但具体是什么,在编译时无法确定,只有等到程序运行起来,在执行的过程中才能确定,这种SQL叫做动态SQL

前言

笔者曾经在2020年发布基于rust的orm第一版,参见文章https://rustcc.cn/article?id=1f29044e-247b-441e-83f0-4eb86e88282c

v1.8版本依靠rust提供的高性能,sql驱动依赖sqlx-core,未作特殊优化性能即超过了go、java之类的orm v1.8版本一经发布,受到了许多网友的肯定和采纳,并应用于诸多生产系统之上。 v1.8版本借鉴了mybatis plus 同时具备的基本的crud功能并且推出py_sql简化组织编写sql的心理压力,同时增加一系列常用插件,极大的方便了广大网友。

同时1.8版本也具备了某些网友提出的问题,例如:

  • by_id*()的方式,局限性很大,只能操作具有该id的表,能否更改为 by_column*(column:&str,arg:xxx);传入需要操作的column的形式?
  • CRUDTable trait 能否不要指定id主键(因为有的表有可能不止一个主键)?
  • 当使用TxManager外加tx_id管理事务的方式,因为用到了锁,似乎影响性能
  • py_sql使用ast+解释执行的方式,不但存在 运行时,运行时解析阶段,运行时解释执行阶段,能否优化为完全0开销的方式?
  • 能否加入xml格式的动态sql存储,实现sql和代码解耦分离,不要使用CDATA转义(太麻烦了),适当兼容从java迁移过来的系统并适当复用之前的mybais xml?

经过一段时间的思考和整理,于是推出v2.0版本,实现完全0开销的动态sql,sql构建性能提高N倍(只生成sql),完整查询QPS(组织sql到得到结果)性能提高至少2倍以上,并解决以上问题

兼顾方便和性能,例如这里使用html_sql查询(v2.0版本)分页代码片段:

  • html文件
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "https://github.com/rbatis/rbatis_sql/raw/main/mybatis-3-mapper.dtd">
<mapper>
    <select id="select_by_condition">
        select * from biz_activity where
        <if test="name != ''">
            name like #{name}
        </if>
    </select>
</mapper>
  • main.rs文件
    #[crud_table]
    #[derive(Clone, Debug)]
    pub struct BizActivity {
        pub id: Option<String>,
        pub name: Option<String>,
        pub pc_link: Option<String>,
        pub h5_link: Option<String>,
        pub pc_banner_img: Option<String>,
        pub h5_banner_img: Option<String>,
        pub sort: Option<String>,
        pub status: Option<i32>,
        pub remark: Option<String>,
        pub create_time: Option<NaiveDateTime>,
        pub version: Option<i32>,
        pub delete_flag: Option<i32>,
    }
    
    #[html_sql(rb, "example/example.html")]
    async fn select_by_condition(rb: &mut RbatisExecutor<'_>, page_req: &PageRequest, name: &str) -> Page<BizActivity> { todo!() }
    
    #[async_std::main]
    pub async fn main() {
        fast_log::init_log("requests.log", 1000, log::Level::Info, None, true);
        //use static ref
        let rb = Rbatis::new();
        rb.link("mysql://root:123456@localhost:3306/test")
            .await
            .unwrap();
        let a = select_by_condition(&mut (&rb).into(), &PageRequest::new(1, 10), "test")
            .await
            .unwrap();
        println!("{:?}", a);
    }

介绍Java最普遍的ORM框架前世今生 - Mybatis、MybatisPlus,XML,OGNL表达式,dtd文件

  • MyBatis在java和sql之间提供更灵活的映射方案,MyBatis将sql语句和方法实现,直接写到xml文件中,实现和java程序解耦 为何这样说,MyBatis将接口和SQL映射文件进行分离,相互独立,但又通过反射机制将其进行动态绑定。 其实它底层就是Mapper代理工厂[MapperRegistry]和Mapper标签映射[MapperStatement],它们两个说穿了就是Map容器,就是我们常见的HashMap、ConcurrentHashMap。 所以说,MyBatis使用面向接口的方式这种思想很好的实现了解耦和的方式,同时易于开发者进行定制和扩展,比如我们熟悉的通用Mapper和分页插件pageHelper,方式也非常简单。

  • 什么是DTD文件?

文档类型定义(DTD)可定义合法的XML文档构建模块。它使用一系列合法的元素来定义文档的结构。同样,它可以作用于xml文件也可以作用于html文件. Intellij IDEA,CLion,VSCode等等ide均具备该文件合法模块,标签智能提示的能力 例如:

<?xml version="1.0" encoding="UTF-8" ?>
        <!ELEMENT mapper (sql* | insert* | update* | delete* | select* )+>
        <!ATTLIST mapper
                >
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "https://github.com/rbatis/rbatis_sql/raw/main/mybatis-3-mapper.dtd">
<mapper>
</mapper>
  • 什么是OGNL表达式?

OGNL(Object-Graph Navigation Language)大概可以理解为:对象图形化导航语言。是一种可以方便地操作对象属性的开源表达式语言. Rbatis在html,py_sql内部借鉴部分ognl表达式的设计,但是rbatis实际操作的是json对象。

例如(#{name},表示从参数中获取name参数,#符号表示放如预编译sql参数并替换为mysql的'?'或者pg的‘$1’,如果是$符号表示直接插入并替换sql):

<select id="select_by_condition">select * from table where name like #{name}</select>

探索实现架构走弯路-最初版本基于AST+解释执行

AST抽象语法树,可以参考其他博客 https://blog.csdn.net/weixin_39408343/article/details/95984062

  • AST结构体大概长这样

#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Node {
    pub left: Option<Box<Node>>,
    pub value: Value,
    pub right: Option<Box<Node>>,
    pub node_type: NodeType,
}
impl Node{
    #[inline]
    pub fn eval(&self, env: &Value) -> Result<Value, crate::error::Error> {
        if self.equal_node_type(&NBinary) {
            let left_v = self.left.as_ref().unwrap().eval(env)?;
            let right_v = self.right.as_ref().unwrap().eval(env)?;
            let token = self.to_string();
            return eval(&left_v, &right_v, token);
        } else if self.equal_node_type(&NArg) {
            return self.value.access_field(env);
        }
        return Result::Ok(self.value.clone());
    }
}
}

表达式是如何运行的?

  • 例如执行表达式‘1+1’,首先经过框架解析成3个Node节点的二叉树,‘+’符号节点左叶子节点为1,右叶子节点为1
  • 执行时,执行‘+’节点的eval方法,这时它会执行叶子节点的eval()方法得到2给值(这里eval方法实际执行了clone操作),并根据符号‘+’对2给值累加,并返回。

结论: 这种架构下,其实存在一些弊端,例如存在很多不必要的clone操作,node需要在程序运行阶段 解析->生成AST->逐行解释执行AST。这些都是存在一些时间和cpu、内存开销的

探索实现架构走弯路-尝试基于wasm

  • 什么是wasm? WebAssembly/wasm WebAssembly 或者 wasm 是一个可移植、体积小、加载快并且兼容 Web 的全新格式。

rust也有一些wasm运行时,这类框架可以进行某些JIT编译优化工作。例如 wasmtime/cranelift/ 曾经发现调用cranelift 运行时调用开销 800ns/op,对于频繁进出宿主-wasm运行时调用的话,似乎并不是特别适合ORM。况且接近800ns的延迟,说实话挺难接受的。参见issues https://github.com/bytecodealliance/wasmtime/issues/2644 经过一些时间等待,该问题被解决后,仍然需要耗费至少50ns的时间开销。对于sql中出现参数动则20次的调用,时间延迟依然会进一步拉大

探索实现架构-真正的0开销抽象,尝试过程宏,是元编程也是高性能的关键

我们一直在说0开销,C++的实现遵循“零开销原则”:如果你不使用某个抽象,就不用为它付出开销[Stroustrup,1994]。而如果你确实需要使用该抽象,可以保证这是开销最小的使用方式。 — Stroustrup

  • 如果我们使用过程宏直接把表达式编译为纯rust函数代码,那么就实现了真正意义上令人兴奋的0开销!不但降低cpu使用率,同时提升性能

过程宏框架,syn和quote(分别解析和生成词条流)

我们知道syn和quote结合起来是实现过程宏的主要方式,但是syn和quote仅支持rust语法规范。 如何让它能变相解析我们自定义的语法糖呢?

  • 答案就是让我们的语法糖转换为符合rust规范的语法,让syn和quote能够正常解析和生成词条流

关于扩展性-包装serde_json还是拷贝serde_json源码?

我们执行的表达式参数都是json参数,这里涉及使用到serde_json。但是serde_json其实不具备 类似 serde_json::Value + 1 的语法规则,你会得到编译错误!

  • (语法不支持)解决方案: impl std::ops::Add for serde_json::Value{} 实现标准库的接口即可支持。

  • 但是碍于 孤儿原则(当你为某类型实现某 trait 的时候,必须要求类型或者 trait 至少有一个是在当前 crate 中定义的。你不能为第三方的类型实现第三方的 trait )你会得到编译错误!

语法糖语义和实现trait 支持扩展

  • (孤儿原则)解决方案: 实现自定义结构体,并依赖serde_json::Value对象,并实现该结构体的语法规则支持!

自定义的结构体大概长这样


#![allow(unused)]
fn main() {
#[derive(Eq, PartialEq, Clone, Debug)]
pub struct Value<'a> {
    pub inner: Cow<'a, serde_json::Value>,
}
}

性能优化1-写时复制Cow-避免不必要的克隆

  • 科普:写时复制(Copy on Write)技术是一种程序中的优化策略,多应用于读多写少的场景。主要思想是创建对象的时候不立即进行复制,而是先引用(借用)原有对象进行大量的读操作,只有进行到少量的写操作的时候,才进行复制操作,将原有对象复制后再写入。这样的好处是在读多写少的场景下,减少了复制操作,提高了性能。

实现表达式执行时,并不是所有操作都存在‘写’的,大部分场景是基于‘读’ 例如表达式:

 <if test="id > 0 || id == 1">
            id = ${id}
</if>
  • 这里,读取id并判断是否大于0或等于1

性能优化2-重复变量利用优化

  • 表达式定义了变量参数id,进行2次访问,那我们生成的fn函数中即要判断是否已存在变量id,第二次直接访问而不是重复生成 例如:
 <select id="select_by_condition">
        select * from table where
        id != #{id}
        and 1 != #{id}
</select>

性能优化3-sql预编译参数替换算法优化

预编译的sql需要把参数替换为例如 mysql:'?',postgres:'$1'等符号。

  • 字符串替换性能的关键-rust的string存储于堆内存

rust的String对象是支持变长的字符串,我们知道Vec是存储于堆内存(因为计算机堆内存容量更大,而栈空间是有限的)大概长这样


#![allow(unused)]
fn main() {
#[stable(feature = "rust1", since = "1.0.0")]
pub struct String {
    vec: Vec<u8>,
}
}
  • 性能优化-不使用format!宏等生成String结构体的函数,减少访问堆内存。

  • 巧用char进行字符串替换,因为单个char存储于栈,栈的速度快于堆

  • 替换算法优化内容长这样.(这里我们使用 new_sql.push(char),只访问栈内存空间)


#![allow(unused)]
fn main() {
    macro_rules! push_index {
     ($n:expr,$new_sql:ident,$index:expr) => {
                  {
                     let mut num=$index/$n;
                     $new_sql.push((num+48) as u8 as char);
                     $index % $n
                  }
              };
    ($index:ident,$new_sql:ident) => {
                if  $index>=0 && $index<10{
                    $new_sql.push(($index+48)as u8 as char);
                }else if $index>=10 && $index<100 {
                    let $index = push_index!(10,$new_sql,$index);
                    let $index = push_index!(1,$new_sql,$index);
                }else if $index>=100 && $index<1000{
                    let $index = push_index!(100,$new_sql,$index);
                    let $index = push_index!(10,$new_sql,$index);
                    let $index = push_index!(1,$new_sql,$index);
                }else if $index>=1000 && $index<10000{
                    let $index = push_index!(1000,$new_sql,$index);
                    let $index = push_index!(100,$new_sql,$index);
                    let $index = push_index!(10,$new_sql,$index);
                    let $index = push_index!(1,$new_sql,$index);
                }else{
                     use std::fmt::Write;
                     $new_sql.write_fmt(format_args!("{}", $index))
                    .expect("a Display implementation returned an error unexpectedly");
               }
       };
    }
    
        for x in sql.chars() {
        if x == '\'' || x == '"' {
            if string_start == true {
                string_start = false;
                new_sql.push(x);
                continue;
            }
            string_start = true;
            new_sql.push(x);
            continue;
        }
        if string_start {
            new_sql.push(x);
        } else {
            if x=='?' && #format_char != '?' {
                index+=1;
                new_sql.push(#format_char);
                push_index!(index,new_sql);
            }else{
                new_sql.push(x);
            }
        }
    }

}

最后的验证阶段,(零开销、编译时动态SQL)执行效率压测


#![allow(unused)]
fn main() {
v2.0请求耗时
耗时:3923900800
耗时:3576816000
耗时:3248177800
耗时:3372922200


v1.8请求耗时
耗时:6372459300
耗时:7709288000
耗时:6739494900
耗时:6590053200
}

结论: v2.0相对于老版本,qps至少快一倍

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


vaultwarden : 非官方 Bitwarden 兼容服务器的 Rust 实现

Bitwarden 是一个开源的密码管理服务,全平台支持,介面简洁,前后端均开源(Telegram 仅客户端开源)功能全面,支持两步验证(Lastpass 付费版都不支持的功能)、团队共享密码库,另外,支持将服务端部署在自己的服务器上。

以前称为 bitwarden_rs。

https://github.com/dani-garcia/vaultwarden

sysinfo : 一个系统处理程序

以获取信息并与 Rust 实现的进程进行交互。

https://github.com/GuillaumeGomez/sysinfo

feroxbuster: 一种快速,简单,递归的内容发现工具

feroxbuster是一个旨在执行强制浏览的工具。

强制浏览是一种攻击,其目的是列举和访问没有被网络应用程序引用的资源,但攻击者仍可访问。

feroxbuster使用穷举结合词表来搜索目标目录中未链接的内容。这些资源可能存储有关网络应用程序和操作系统的敏感信息,如源代码、证书、内部网络地址等。

这种攻击也被称为可预测资源位置、文件列举、目录列举和资源列举。

https://github.com/epi052/feroxbuster

aya : Rust 实现的 eBPF 库

aya 的特色是 仅仅依赖 libc 的 纯度 99.9% 的Rust 实现。

https://github.com/alessandrod/aya

Neovide: Rust 实现的干净利落的 Neovim 客户端

https://github.com/Kethku/neovide

libreddit: 用 Rust 实现一个私人定制的 Reddit 前端

https://github.com/spikecodes/libreddit

paru: pacman 助手

pacman 软件包管理器是Arch Linux 的一大亮点。

https://github.com/Morganamilo/paru

delta: git 和 git diff 查看器

该项目 star 数目近万了

https://github.com/dandavison/delta

git-branchless: Git 无分支工作流

Git-Brantless 是一套帮助开发者可视化,导航,操作和修复提交历史记录的工具。 它基于谷歌和Facebook等大公司的无可分割的Mercurial工作流。

https://github.com/arxanas/git-branchless

metaplex: Metaplex 协议 Rust 实现

Metaplex 是一个无需许可的链上协议,支持Solana 区块链的原生购买、销售和拍卖NFT,可大大降低了铸造和交易费用。

https://github.com/metaplex-foundation/metaplex

zoxide: 一个更智能的CD命令。

支持所有主流 shell。

https://github.com/ajeetdsouza/zoxide

eww: 用 Rust 实现 widgets

Elkowar’s Wacky Widgets,是一个独立的小部件系统,它基于 Rust,允许开发者在任何窗口管理器中实现自己的自定义小部件。

基于 GTK,应该是跨平台的。

推荐项目 | 基础工具库

编辑:张汉东


helix : Rust 实现的新一代文本编辑器

Helix 编辑器具备以下主要特性:

  • 多项选择(Multiple selections)
  • 通过 tree-sitter 实现语法高亮和代码编辑
  • 解析过程是渐进式的,并且速度足够快,足以运行每一次击键。并且它足够稳健,甚至能够输出带有语法错误的结果。
  • 内置语言服务器支持
  • 语境感知补全: 文档/诊断/Code actions
  • 在终端中运行:其基础实现利用终端前端,且与 tmux 连接良好或使用 ssh。
  • 使用 Rust 构建,未使用 Electron、VimScript 和 JavaScript,更少能耗。

https://github.com/helix-editor/helix

Findomain : 发布了新版本

功能介绍摘录:

Findomain提供了一个Subdomains监控服务,提供:目录模糊/端口扫描/漏洞发现 - 以及更多

这允许您使用多个顶级工具(OWASP AMASS,SUBLIST3R,ASSETFINDER和SUBFINDER)监视您的目标域,并在新子域名时发送警报,以便发送到Discord,Slack,Telegram,电子邮件或推送通知(Android / IOS / Smart Watch /桌面) 。

唯一需要做的是使用电子邮件地址(如果适用)或/和Webhooks / Telegram聊天信息配置文件,并将您的域放在另一个文件中,一旦您完成了保持您的完整自动子域监控服务 最新使用已发现的新子域,htth网站,打开端口,子域CNAME等的HOST IP,HTTP状态,屏幕截图。 您的所有数据都会安全地保存在关系数据库中,您可以随时申请数据转储。

https://github.com/Findomain/Findomain

脑机接口 协议/API Rust 实现

(满脑子都是 Rust.jpg )

针对 NeuroSky MindWave EEG headset 设备

应该是这家公司 http://www.neurosky.com.cn/products-markets/mindwave-mobile-2/ 的设备

https://github.com/junjunjd/rustymind

Rust 高性能计时库

by lemonhx & zhongzc @ PingCAP

minstant 在 Linux x86 x86_64 下使用 TSC 和在其他平台上使用 MONOTONIC_COARSE 吊打 std , 精度突破 10ns !

大家快来试用吧!

https://github.com/LemonHX/minstant

tabled : 用于表格化打印结构体或枚举类型


#![allow(unused)]
fn main() {
use tabled::{Tabled, table};

#[derive(Tabled)]
struct Language {
    name: &'static str,
    designed_by: &'static str,
    invented_year: usize,
}

let languages = vec![
    Language{
        name: "C",
        designed_by: "Dennis Ritchie",
        invented_year: 1972
    },
    Language{
        name: "Rust",
        designed_by: "Graydon Hoare",
        invented_year: 2010
    },
    Language{
        name: "Go",
        designed_by: "Rob Pike",
        invented_year: 2009
    },
];

let table = table!(&languages);
let expected = "+------+----------------+---------------+\n\
                | name |  designed_by   | invented_year |\n\
                +------+----------------+---------------+\n\
                |  C   | Dennis Ritchie |     1972      |\n\
                +------+----------------+---------------+\n\
                | Rust | Graydon Hoare  |     2010      |\n\
                +------+----------------+---------------+\n\
                |  Go  |    Rob Pike    |     2009      |\n\
                +------+----------------+---------------+\n";

assert_eq!(expected, table);
}

https://github.com/zhiburt/tabled

Crusty

Crusty,一个快速,可扩展,礼貌性的通用网络爬虫(Broad Web Crawler)。Crusty 旨在:

提供一种研究 www 和通用网络爬虫领域的方法;

  • 为可扩展性、可配置性和自定义数据收集提供程序接口;
  • 快速,在稳定、可预测的单节点性能、不错的硬件饱和度方面;
  • 易于扩展;
  • 礼貌性,可能是通用网络爬虫中最重要的部分;
  • 可监控,日志、自定义指标、实时 Grafana 仪表板;
  • 易于交互,仅使用一个命令构建和运行,可重现的 docker 构建;

https://github.com/let4be/crusty

trillium.rs: Rust 异步 Web 组件

trillium 是一个 toolkit,可以让你快速的创建 Rust 的异步 Web 应用. 可以运行在 tokio, async-std, 或者smol上. 用起来就像下面一样简单.


fn main() {
    trillium_smol::run(|conn: trillium::Conn| async move {
        conn.ok("hello from trillium!")
    });
}

玄铁处理核 Rust 语言支持库现已发布

这个支持库允许Rust语言访问玄铁处理器的专有功能,包括特殊的页表、寄存器和专有指令,调试模块的支持也在编写之中。支持库适用于玄铁9系列RISC-V处理核,它能很好地支持裸机引导程序、调试器软件和机器监视环境的开发工作,也可用于研发新型操作系统内核。

同时,Rust芯片支持库对裸机环境的开发有一定的帮助。例如,它可以用于开发适配RustSBI的引导程序环境,以制作基于玄铁C906处理核的全志D1芯片引导程序。

支持库已经发布到crates平台,它详细的文档托管在docs.rs网站上;使用木兰宽松许可协议第2版开源,允许商业使用。

https://github.com/luojia65/xuantie

ripgrep 发布 v13.0.0

ripgrep 13 是一个新的主要版本,本次更新包含bug修复,性能优化,和几个小的突破性的改进,并且修复了windows平台下的一个安全漏洞。 详细的版本说明可以参见:https://github.com/BurntSushi/ripgrep/releases/tag/13.0.0

ripgrep 是一个面向文本行的搜索工具,可以根据正则表达式递归搜索当前的目录。ripgrep在功能上类似其他流行的搜索工具,如 The Silver Searcher,ack 和 grep 等。ripgrep支持Windows,macOS,和Linux,并且对于每次发行版都提供构建好的二进制程序以供下载。

cuda-oxide

cuda-oxide 是 CUDA 的安全包装器,使用它可以执行和协调 CUDA 内核。

CUDA是Compute Unified Device Architecture的缩写,是Nvidia开发的一项技术,可加速GPU计算流程。

https://github.com/Protryon/cuda-oxide

xh:友好快速的 HTTP 请求工具

尽可能多地重新实现了 HTTPie 的优秀设计。


#![allow(unused)]
fn main() {
$ xh get baidu.com

HTTP/1.1 200 OK
accept-ranges: bytes
cache-control: max-age=86400
connection: Keep-Alive
content-length: 81
content-type: text/html
date: Sun, 20 Jun 2021 05:13:12 GMT
etag: "51-47cf7e6ee8400"
expires: Mon, 21 Jun 2021 05:13:12 GMT
last-modified: Tue, 12 Jan 2010 13:48:00 GMT
server: Apache

<html>
<meta http-equiv="refresh" content="0;url=http://www.baidu.com/">
</html>
}

https://github.com/ducaale/xh

elfcat: ELF visualizer

elfcat 可以从 ELF binary 中生成 HTML, 从而可以让你以一种非常生动的形式查看 ELF. 可以点击示例地址进行查看.

gtk4-rs 发布: GTK4 的 rust 绑定

GTK4 的 rust 绑定正式发布,新功能,新官网,新的教程。

FeoBlog v0.4.0 发布

FeoBlog 是对分布式社交网络(协议 + 实现)的探索。

https://github.com/NfNitLoop/feoblog

smallnum: 编译期间的 number 优化

smallnum 可以对 number 进行 编译时大小优化。 宏返回能够适合静态边界的最小数值类型。对于无符号整数,宏输入是一个最大值。对于有符号整数,宏输入可以是最大值或最小值。

https://github.com/tnballo/smallnum

fang: 后台任务执行库

fang是一个后台任务执行库. 支持:

推荐项目 | 框架引擎

编辑:张汉东


Hitbox:异步分布式缓存框架

Hitbox是一个异步缓存框架,支持多个后端,适用于分布式和单机应用程序。 目前 Actix 已经集成了该框架。

后端支持 Redis ,该框架还在逐步完善中。

https://github.com/hit-box/hitbox

Neuronika: Rust 新的机器学习框架

随着 Rust 语言热度的强劲增长,也有许多研究者探索 Rust 在机器学习等方面的研究。近日,有研究者用 Rust 编写了一个机器学习框架 Neuronika。

Neuronika 是由 Francesco Iannelli 等人开发,他们现在是计算机科学的硕士生。该框架提供了自动微分和动态神经网络,非常像 Pytorch。

Thruster发布v1.1.2

Thruster,一个旨在让开发者在项目和团队之间保持高效一致的快速、直观的 Rust Web 框架。

v1.1.2版本的一些亮点:

  • 完全重建的路由树和解析器;
  • 完全重建的内部“中间件”系统,获取中间件功能列表并使用闭包将它们组合起来;
  • 更容易使用的中间件宏;
  • 升级依赖库;

在生产中使用 Thruster 的一些有趣指标:

  • 在过去的四个月里,它处理了 240 万个请求;
  • 实例中没有(0%) 与框架相关的致命错误;
  • 实例中95%的平均响应时间远低于 25 毫秒;
  • 每个实例的平均 RAM 为 14.75MB;

版本详情参见Release,https://github.com/thruster-rs/Thruster/blob/master/RELEASE.md

tantivy: v0.15 发布了

tantivy 是 Rust 写的全文搜索引擎库. 现在tantivy 由 quickwit-inc 开发托管.

https://github.com/quickwit-inc/tantivy

messages: 异步 actor 框架

messages 是一个异步的 actor 框架, 受 actix 启发.

https://github.com/popzxc/messages-rs

发布rg3d 0.20,一个功能丰富且易于使用的游戏引擎

rg3d 0.20 已经发布。此版本包含许多新功能和改进。最大功能的是 WebAssembly 支持、初始 2D 支持和多层地形。

Wasmer 2.0 发布!

WebAssembly运行时Wasmer 2.0发布了:运行时速度快了50+%,热启动速度快了70+%,还有诸如引用类型、SIMD等更多新功能发布!

wasmer-2.0 发布:https://wasmer.io/posts/wasmer-2.0

Walleye:国际象棋引擎

Walleye 是一种使用经典 alpha-beta 风格 AI 编写的与 UCI 兼容的引擎。 支持从任意 FEN 字符串加载棋盘位置、Unicode 漂亮地打印到控制台和帮助调试的 UCI 通信日志。注意,这是引擎,如果要玩儿的话要加 GUI,比如已经做过测试的 [Cute Chess(https://cutechess.com/)]。

https://github.com/MitchelPaulin/Walleye

注:FEN 是 Forsyth-Edwards Notation 的简称,用来描述棋盘位置,详见维基百科

七月刊

logo

目录顺序排名不分先后!

发刊通告

Rust 资讯

Rust 与 学术

Rust 生产实践

学习园地

新手入门

原理与工程实践

游戏

Rust 与 操作系统

为 Rust 语言做贡献

开源项目巡礼

发刊通告

编辑:张汉东

七月发刊通告

《 RustMagazine 中文精选 》2021 年第七期发布了!

今年的七月,很不平凡。夏日最盛的时期,疫情却反反复复;河南遭遇千年一遇洪涝灾害。不过,无论如何,七月已过,愿所有美好如期而至,所有不好随风飘散。

本刊动态

2021年(上半年)RustMagazine 中文精选 PDF 下载 : https://github.com/RustMagazine/rust_magazine_2021/releases/tag/0.5

(GitHub Page)从六月起已经停止 Rss 订阅

因为当前 mdbook 的 rss 插件不是很方便,等以后再加 rss 支持吧。

可以到语雀平台自动支持邮件订阅。

上期(六月刊)访问数据统计小结

用户数

  • 新用户新增 2,124

浏览量:

  • 网页浏览量 : 15,115

六月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

编辑: 张汉东


Rust 1.54 稳定版发布

Rust 1.54 更新的特性并不是很多,值得注意的是:

  1. 增量编译功能又重新默认开启了。
  2. 在 ErrorKind 中 多了一个 OutOfMemory 类型。

https://blog.rust-lang.org/2021/07/29/Rust-1.54.0.html

task::ready! 宏将在 1.56 稳定

futures-core 的 ready!() 宏存在好几年了,这个宏可以在处理 Future 的时候减少很多模板代码。

ready!() 宏稳定之后将会在 std::task 模块。

https://github.com/rust-lang/rust/pull/81050

Rust 2021 进入公开测试期

Rust 2021 版进入 公开测试期 。该版本的所有计划功能现在都可以在 nightly 版本中率先体验。同时还提供了方便的迁移办法,以便将代码从 Rust 2018 快速迁移到 Rust 2021 。

  • 安装最近的 nightly 工具链:rustup update nightly
  • 运行 cargo +nightly fix --edition
  • 编辑 Cargo.toml ,将 cargo-features = ["edition2021"] 置于顶部(位于 [package] 上方),并将 edition 改为 edition = "2021"
  • 运行 cargo +nightly check 以确认能否在新版本上正常工作。

Datafuse 团队目前正在进行 Rust 2021 迁移的尝鲜体验,相关工作可以关注:https://github.com/datafuselabs/datafuse/pull/1159

Rust 2021 public testing period: https://blog.rust-lang.org/2021/07/21/Rust-2021-public-testing.html

the nightly version of Rust edition guide: https://doc.rust-lang.org/nightly/edition-guide/rust-2021/index.html

rustc_codegen_gcc 的 MCP 已经被接受

在不久的将来,Rust 就会多一个 GCC 的后端。

同类项目还有 GCC-rs ,GCC-rs 是 用 Cpp 重新实现 Rustc 的一个 GCC 前端。

为什么有 GCC-rs 这个项目?

  1. 想要支持更多的 CPU 架构
  2. 跨语言 LTO。GCC-RS FAQ将Linux列为激励示例。 具有讽刺意味的是,Linux支持ltvm但不是gcc!
  3. Rust 自举(Bootstrap)链很长,因为需要从C到OCAML,然后编译预发布 Rust 以编译 Rust 1.0编译 Rust 1.1 、1.2等,直到捕获最多1.53(或者最新版本)。 因此,如果您可以用C++中编写的 Rust 编译器直接编译1.53,则可以节省一些时间。
  4. 复用 GCC 插件

rustc_codegen_gcc 作者认为 GCC-rs 其实没有很好的解决这些问题。

rustc_codegen_gcc 项目只需将GCC插入现有的Rust编译器作为代码生成后端,就可以简单的达成这些目标。

该项目的主要目标是能够在LLVM不支持的平台上编译 Rust 代码。 次要目标是检查使用GCC后端是否提供任何编译速度改进。

现在 rustc_codegen_gcc 已经被接受,gcc-rs 该何去何从?

相关阅读:Rust 与 开源 | GPL 许可证引发的问题

GAT (generic associated types )今年能否稳定? 我看行。

四天前,在 GAT tracking issues 下有人回复:

摘录

我预计我们可能在10月份左右就能实现稳定化;但这确实取决于1-3个月的测试期。如果测试结束后相对来说没有错误,那么应该会很顺利。如果发现一些严重的错误、设计缺陷等,可能会推迟几个月,但这仍然为年底提供一些余地。

鼓励大家来测试相关功能。

详细:https://github.com/rust-lang/rust/issues/44265#issuecomment-869888398

错误处理工作组工作进展报告

官方错误处理工作组发布了一篇新文章,描述了当前错误处理需要解决的主要挑战:容易丢失上下文。

以下是一些摘要:

use std::fmt;

// 需要加载一个配置文件,并且期望配置加载总是成功的。
fn main() {
    let _config = load_config()
        .expect("config is always valid and exists");
}

// 这里实现一个加载配置文件总是错误的样例,因为要展示错误诊断信息
fn load_config() -> Result<(), Error> {
    Err(Error(SourceError))
}

// 我们有一个错误类型,只需打印“invalid config“
// 并且具有仅打印的源错误 "config file does not exist"
#[derive(Debug)]
struct Error(SourceError);

impl fmt::Display for Error {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("invalid config")
    }
}

impl std::error::Error for Error {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.0)
    }
}

#[derive(Debug)]
struct SourceError;

impl fmt::Display for SourceError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("config file does not exist")
    }
}

impl std::error::Error for SourceError {}

我们想要得到的错误信息如下:


#![allow(unused)]
fn main() {
$ cargo run
thread 'main' panicked at 'config is always valid and exists', src/main.rs:4:33

Error:
    0: invalid config
    1: config file does not exist

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

}

通过这样的信息,我们可以看出程序退出是因为 panic 发生了。我们可以看出违反了 0 和 1 两种原因而导致了恐慌,并且能定位到恐慌发生的位置。

但是当前实际的输出是:


#![allow(unused)]
fn main() {
$ cargo run
thread 'main' panicked at 'config is always valid and exists: Error(SourceError)', main.rs:4:33
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
}

Rust的panic基础设施没有提供将Error类型转换为panic的方法,它只支持将Debug类型转换为panic,我们觉得这是一个大问题。同样的,语言也没有提供方便的工具来打印一个错误和它的所有源的错误信息。

当我们处理 Result 的时候:

fn main() {
    let result = load_config();
    let _config = match result {
        Ok(config) => config,
        Err(error) => {
            println!("Error: {}", error);
            return;
        }
    };
}

我们希望得到:


#![allow(unused)]
fn main() {
$ cargo run
Error: invalid config: config file does not exist
}

但实际输出:


#![allow(unused)]
fn main() {
$ cargo run
Error: invalid config
}

默认情况下,源的所有错误信息都会丢失。这是因为我们用Display作为单个错误信息的接口。如果我们能回到过去,我们目前会建议在Error trait中加入fn message(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result,但现在这艘船已经起航了。

今天,库中解决这个问题的方法是滥用Debug trait。像eyreanyhow这样的类型,甚至有时自定义的错误枚举也使用它们的Debug输出来打印人类可读报告中的全部错误链。

这样做的好处是使打印完整的错误报告变得容易,并且使unwrapexpect和从main返回都打印完整的错误报告。但这样做使我们无法访问错误的 Derive Debug格式,有可能隐藏了调试可能需要的内部细节,但这些细节并不是供用户阅读的错误信息的一部分。

错误处理的未来

最终,我们希望在Rust中进行错误处理时,你所使用的默认工具都能做正确的事情,并充分利用Error trait的设计。Unwrap 一个实现了Error trait的类型将保留原始的错误,作为一个动态的Error,然后可以在panic hook 中使用。打印一个完整的错误报告将是很容易做到的,而且很明显。有了这些变化,希望在报告错误时很难意外地丢弃信息。

要达成这个目标,需要有两步计划:

  1. 集成 Error TraitPanic Runtime

    a. 先将Error trait移至 core b. 添加一个接口,用于从Error类型中创建一个panic,比如 panic_error 函数。类似于std中已经有的panic_any函数。这个函数将让panic处理程序通过dyn Error来访问错误。 c. 更新std提供的默认panic hook,通过Error trait 实际报告恐慌。它应该遍历来源并打印由错误本身捕获的回溯,如果有的话,或者可能自己捕获一个。 d. 最后,我们需要对expectunwrap进行特殊处理,以便对实现了Error trait的类型 unwrap 时使用这些新的Error感知恐慌的接口。要做到这一点,我们首先需要解决一个健全性问题,即对基于寿命的条件的trait impls进行专业化处理,不过幸好我们已经有了一个很好的解决方法

  2. 实现一个基础的错误报告工具。

    我们不能完全达到这个目标,因为我们使用Display来处理单个错误信息,而且我们不能以向后兼容的方式来改变这一点,但我们希望增加一个方便的方法来打印完整的错误链和一些巧妙的行文,以减轻大部分的压力。

    我们计划通过在标准库中添加一个Report类型来解决这个问题,该类型包装了一个&dyn Error,并实现了Display,这样它就可以按要求打印每个源。我们希望Reportdisplay 方法的输出能够支持Rust生态系统中最常见的错误串联的风格。

    
    #![allow(unused)]
    fn main() {
    println!("Error: {}", Report::from(error));
    
    // Outputs:
    // Error: outermost error: second error: root error
    
    println!("Error: {:#}", Report::from(error))
    
    // Outputs:
    // Error: outermost error
    //
    // Caused by:
    //    0: second error
    //    1: root error
    
    // report 方法大概像这样
    fn report(&self) -> impl Display + '_
    where
        Self: Sized,
    {
        Report::from(self)
    }
    }
    
  3. 错误处理工作组最近创建了一个关于如何实现Display::fmtError::source的指导原则。 这个建议只适用于作为库的API的一部分被暴露的错误类型。库或应用程序中的内部错误可以做任何他们想做的事情,但一旦他们需要被第三方用户集成到其他板块,重要的是错误要遵循一致的风格。。如果你对我们的理由感兴趣或有任何意见,请查看我们关于这个主题的github问题:Rust-lang/project-error-handling#27

这就是目前的计划,它不是我们想做的所有变化的完整计划,但我们认为这是最好的第一步。

免责声明:这篇文章是计划和愿望的结合。这里有一些技术上的挑战需要解决,所以最终的结果可能与我们最初的设想有很大的不同,所以请不要认为这些都是最终结果。

https://blog.rust-lang.org/inside-rust/2021/07/01/What-the-error-handling-project-group-is-working-towards.html

cargo-supply-chain: 官方安全代码工作组发布的新工具

作用:

在依赖包关系图中收集作者、贡献者和发布者的crate数据。

使用场景:

  • 寻找值得支持的人和团体。
  • 识别依赖关系图中的风险。
  • 对所有你通过构建他们的软件而隐含信任的贡献者进行分析。这可能有清醒和谦卑的效果。

cargo-supply-chain 自身也提供了一个输出样本: publishers, crates, json

用法:


#![allow(unused)]
fn main() {
cargo install cargo-supply-chain
}

该组织内还有另外一个工具:cargo-geiger。 可以检测crate 及其依赖关系中unsafe rust的用法。也可以 Rust 项目的安全检查比率。

相关链接:

让 Rust 更快的解析 float类型 已被合并到 Rust core 库

作者两年前就提出了如何让 Rust 解析 float 更快更安全, 不过最近, 这些改变才合并到libcore.

这意味着, 当你解析大量的 float 类型时, 性能会得到非常夸张的提升.

例如:

  • 0.06,0.13,0.25,0.38,0.44,0.44,0.38,0.44,0.5,0.56 这样的数据大概会提升 2 倍.

  • -65.613616999999977,43.420273000000009,-65.619720000000029,43.418052999999986,-65.625,43.421379000000059 这样的数据大约会提升 10倍 性能.

  • 8.988465674311580536566680e307 这种数据大概会提升1600~10,000倍的提升.

  • https://github.com/rust-lang/rust/pull/86761

  • 相关算法论文

RFC 3128: I/O Safety

Rust 现在已经有一个被采纳的 I/O 安全的 RFC. 该 RFC 用于处理 文件操作符和 sockets 等的生命周期. 这让编写底层安全的系统对象有了可能.

https://github.com/rust-lang/rfcs/blob/master/text/3128-io-safety.md

rustc_codegen_gcc 进展报告#2

rustc_codegen_gcc 是 rustc 的 GCC 代码,这意味着它可以被现有的 rustc 前端加载,通过支持更多的架构和 GCC 的优化而受益于 GCC。

当前状态:Antoyo 手动实现了 popcount(因为 gcc 会产生一个对某些函数的调用,这在 no-std 中是行不通的)。除此之外,修复了一些东西,使之更容易在 godbolt 中添加这个 gcc 代码,并使 Antoyo 的这个PR合并到 rustc 中。

报告#2的详情参见 Antoyo 的博客链接,https://blog.antoyo.xyz/rustc_codegen_gcc-progress-report-2

社区热点

编辑:张汉东


RustChinaConf 2021 议题征集开放申请

申请通道详情

为Linux内核添加Rust支持的最新补丁已经出现

Miguel Ojeda一直在领导 "Rust for Linux"的工作--现在这个项目得到了Google的资助以使这种编程语言能够在内核中使用。虽然5.14内核合并窗口目前正在进行,但这并没有被标记为拉动请求阶段,估计要到下一个或者更晚的周期才会登陆。这是继4月份发出的 "征求意见"补丁之后的又一进展。

Rust for Linux的启用现在已经达到了33000多行代码,部分原因是他们目前在树中包括了Rust的 "alloc"标准库的一个子集,为内核目的进行了修改。最终,开发者们将尝试把变化放到上游的alloc crate中,但现在是在内核树中进行,其他需要的库也是在内核树中进行的。

这些新补丁的另一个变化是,之前的版本需要使用Rust编译器的每夜版本,而现在内核可以用beta和稳定版的Rustc编译。然而,内核支持确实需要一些Rust编译器的功能,目前被上游视为不稳定的来源。

除了AArch64、PowerPC和x86_64之外,ARM 32位和RISC-V体系现在也被Rust for Linux所支持。

除了Linux内核中的Rust管道外,Rust支持的最初用户是Rust中的Android Binder IPC实现,这仍被认为是一项正在进行的工作。

这些最新的Rust for Linux内核补丁可以在内核邮件列表中找到:

https://lore.kernel.org/lkml/20210704202756.29107-1-ojeda@kernel.org/

此消息来自: https://www.cnbeta.com/articles/tech/1148741.htm

Facebook 从 Java 到 Rust | Buck 的未来

Buck 是 Facebook 在 2013 年的Facebook Mobile DevCon上亮相的一个快速的 Android 构建系统。从那时起,它已经扩展到支持用15种以上的语言编写的应用程序,目标平台包括手机、服务器、智能设备和VR头盔等等。

不过,随着时间的推移,Buck并没有跟上这种功能和灵活性的增长,没有进行必要的架构改变和改进来管理这种复杂性。随着Facebook内部和外部开发新的功能,发现这导致了巨大的实施复杂性,对核心和语言规则进行修改的挑战越来越大。虽然该团队在近4年前就开始了一项举措,逐步对 Buck 进行一些这样的跨领域的架构改进,但这些改变是非常困难的。

所以,从2020年开始,该团队开始构思,如果从零开始构建 Buck 会怎么样?怎样才能写出一个可以在未来 10 年或 20 年中继续扩展的构建系统?

在考虑这次重写的同时,我们利用这个机会实验并验证了Rust作为构建系统的技术栈。Facebook对Rust编程语言的投资越来越大,许多项目都在使用它,并取得了巨大的成功。我们发现这种语言非常合适,原因有很多:

  1. Rust的async/await语法使得编写异步代码非常顺畅,而且Rust有助于正确处理复杂的并发性细节。Buck的Java计算向并发计算的每一次迁移都是一个长达数月的艰难过渡,而且那里仍然存在着明显的单线程瓶颈。
  2. Rust有很多高级语言特性,使开发更容易,更愉快。这些都是像枚举、模式匹配、特质(trait)、过程宏和所有其他的功能,这些功能都受到 Rust开发者的广泛喜欢。
  3. Rust对内存分配提供了更大的控制。GC语言(即便是分代收集)在处理像Buck这样的增量计算时也有挑战。
  4. Rust是高性能的。我们已经看到了将一些程序用Rust重写后的显著加速。

在接下来的几个月里,你可能会看到Buck的进展较少,但请放心,我们将继续努力为社区提供最好的构建系统。我们认识到,分享过程的一个重要部分是为Buck的用户定义一个平稳的过渡,并确保社区可以和我们一起前进。我们计划在2022年1月前公开这个方案,届时会有更多关于当前Buck用户过渡的细节。

https://developers.facebook.com/blog/post/2021/07/01/future-of-buck

知乎近期 Rust 相关问题摘录

Rust + Copilot 什么效果?

近日 Discord 工程师尝试用 copilot 来辅助开发 Rust 项目。效果不是很好。

视频观看:https://t.me/rust_daily_news/4914

Rust GameDev #23

这一期游戏开发报告中包含了一些很有创意的游戏。写 Rust 累了,可以玩一玩,都是开源的。

  1. 吃尾蛇。支持wasm,可以网页玩耍。关卡设计的很有心。基于 bevy 0.5 实现。
  1. Egregoria,模拟城市建设者,试图复制现代社会以及尽可能复制。基于 Legion ecs 实现。

https://github.com/Uriopass/Egregoria

  1. Blightmud ,是一款 命令行终端的 mud 客户端,可以支持很多 mud server,比如 bat.org 等。

https://github.com/Blightmud/Blightmud

  1. Dango, 多人物理沙盒游戏。基于 bevy , Nphysics 物理引擎, CrystalObs 网络库等。Dango 目前在浏览器中基于wasm 运行 server,其他玩家通过 webrtc 加入。

https://github.com/ErnWong/dango-tribute

  1. hyper-farmer : 基于bevy实现,游戏虽然简单但是挺有创意,锻炼双手协调
  1. fish-game,基于 macroquad 游戏引擎实现,支持wasm

https://gamedev.rs/news/023/

CNCF 又多了俩 Rust 项目

Good to see both wasmCloud and Krustlet submitted to the CNCF Sandbox at the same time:

Zenoh 性能提升的故事| 漫游在 Rust 异步仙境

在 Rust Maginze 月刊第四期中介绍过 Zenoh : 开源产品 | eclipse zenoh 助力雾计算和边缘计算

eclipse zenoh (读: /zeno/ ) ,提供了零开销的Pub/Sub、Store/Query 和 计算。

zenoh 统一了 动态/静止/使用中的数据并提供计算结果。它颇有分寸地将传统的Pub/Sub与地理分布的存储、查询和计算融合在一起,同时保留了远远超出任何主流协议栈的时间和空间效率水平。

官网是 zenoh.io 。

GitHub代码仓库 eclipse-zenoh/zenoh

2020 年 12 月 Eclipse Edge Native 工作组启动,并将 Zenoh 引入 Eclipse 。并用 Rust 对 zenoh 进行重写。

在本文中,Zenoh 团队剖析了他们如何改进让异步性能提升一倍。

  • 8字节payload 时超过3.5M msg/s
  • 1Mb payload 时超过 45Gb/s
  • 在 backlogged 场景下,延迟低至 35 微秒

该团队如何做到的呢?

一:准备工作

  1. 准备测试环境,以便获得可复现的结果。因为很多外部因素可能会影响代码性能,这是为了排除这些干扰。这有个非常棒的指南:https://easyperf.net/blog/2019/08/02/Perf-measurement-environment-on-Linux
  2. 彻底阅读 《Rust 性能手册》。我们发现它对Rust的性能技巧和诀窍以及剖析技术都很有见地。另外,还有一篇关于如何在Rust中编写高性能代码的博客也是不错的参考。

二:寻找性能热点(hotspots)

  1. 我们先使用 flamegraph 来生成火焰图,打算寻找 zenoh 中的异步性能热点。然而,异步使得火焰图相当难以阅读,因为异步调度器和future执行器基本上出现在火焰图中每一个地方。所以改变了性能剖析工具,开始使用 perf ,可以提供更清晰的热点图,尤其是序列化和反序列化方面。
  2. 改进了序列化/反序列化相关实现,性能直接提升 100% 。但是这种改进在吞吐量测试中没有反映出来。

三: 堆分配还是栈分配?

zenoh 团队 一直避免在关键环节进行堆分配。用 valgrind 仔细检查后发现,并没有不必要的堆分配,缓存未命中率也不高。因此该团队开始检查 栈分配的问题,利用 Rust 编译器的一个 flag (仅在 Rust Nightly 可用)来验证一个数据结构多大以及它的内存对齐方式。

rust $ RUSTFLAGS=-Zprint-type-sizes cargo build --release 用这种方式来编译 zenoh 即可。输出:


#![allow(unused)]
fn main() {
print-type-size type: `net::protocol::proto::msg::Data`: 304 bytes, alignment: 8 bytes
print-type-size     field `.key`: 40 bytes
print-type-size     field `.data_info`: 168 bytes
print-type-size     field `.payload`: 96 bytes
}

异步数据结构也会这样打印出来。然后该团队发现了一个痛苦的事实:

  1. 异步 future,一旦被编译,就会在栈中占用几十 KB 的空间。每次有消息需要通过网络发送,就会调用这些 futures。
  2. 因为zenoh广泛使用异步,所以现在导致 栈太深太大,给内存带来很大压力。
  3. 经过思考,该团队将 异步代码隔离在特定的部分,尤其是网络交互部分,而其他部分则转为使用同步。由此来平衡 同步和异步,汲取了两个世界的优点。大幅减少了栈内存的压力,带了巨大的性能提升。

四: 性能测试结果

该团队性能测试环境为:AMD Ryzen 5800x,32GB内存,通过100Gb以太网连接,根据前面所说的性能测试环境配置指南配置好。

具体的性能测试图表,可以进一步查看文章。也可以关注 zenoh 团队的博客,因为他们性能优化还会继续。

https://zenoh.io/blog/2021-07-13-zenoh-performance-async/

清华90后校友、MIT助理教授范楚楚获ACM博士论文奖,Rust社区Ralf Jung荣誉提名

Ralf Jung 的博士论文为《Understanding and Evolving the Rust Programming Language (https://people.mpi-sws.org/~jung/phd/thesis-screen.pdf)》

RalfJung 研究的这个,是对 Safe Rust 安全模型做了形式化验证,也为 Unsafe Rust UB 检查建立了模型。实至名归。

https://mp.weixin.qq.com/s/wkjexOyXXpEC-nYEWxpkWQ

Tokio 支持 io-uring

Tokio 为 Linux 上的 io-uring 系统 API 提供支持。此版本提供异步文件操作,将在后续版本中添加对更多操作的支持。

tokio-uring API可能构成Tokio 2.0版本的基础,2024 年 可能会发布 tokio 2.0 ,然后集成这个库。

https://tokio.rs/blog/2021-07-tokio-uring

同一功能实现下 Rust vs Elixir 代码量比较 案例 : Ockam

Ockam 是一套 用于相互身份验证和分布式应用程序之间的端到端加密消息的工具。 目前提供 Rust 和 Elixir 两种实现。

从 Rust 和 Elixir 的比例来看:

  • Rust 69.3%
  • Elixir 30.7%

因为是同一种功能特性实现(Rust 比 Elixir 多两个特性实现),但是否可以从 代码量得出开发效率 elixir 是 Rust 的两倍呢?

我认为不尽然。也需要看场景。 这个项目设计到分布式消息通信,这方面因为elixir 的默认支持 的生产力可能更高。 而 Rust 可能还需要多做一些基础工作。

这就好比,很多人说 rust 编译helloworld比 c 的大,但其实 c 的一些基础库操作系统都是默认支持的,不需要另外携带,rust 则需要。

https://github.com/ockam-network/ockam

CNCF宣布Linkerd毕业

Linkerd 是第一个加入 CNCF 沙箱的项目,沙箱当时被称为 inception,现在是第一个获得毕业地位的服务网格项目。

Linkerd 创始人、Buoyant 首席技术官 Oliver Gould 说。“我们的使命是为服务网格领域带来简单性和用户同理心,我们不得不孜孜不倦地努力消除普遍认为服务网格复杂而笨重的说法。虽然我们做出了有争议的技术决策——采用 Rust 而不是 C++,构建特定于服务网格的'微代理'而不是使用通用代理,专注于 Kubernetes 而不是构建抽象层——这些决策已经得到验证,而我们的全球运营者社区再次对这一愿景下注。”

Rust Search Extension 1.3.0-rc 版发布!

想提前体验的朋友欢迎在这个页面下载并帮忙测测,如果没啥 bug 了周末就正式发布!

Arti: 一个纯Rust实现的Tor

今天,我很高兴地宣布 Tor 的新时代实现。

在过去一年左右的时间里,我们一直致力于“Arti”,这是一个用 Rust 重写 Tor 的项目。感谢 Zcash Open Major Grants (ZOMG) 的资助,我们终于可以将 Arti 项目列入我们的优先事项列表,并投入更多时间。

下面我将谈谈我们为什么要做这个项目,它对 Tor 用户和运营商意味着什么,它的未来发展方向,以及人们可以如何提供帮助。

Scott Mabin 全职加入 乐鑫科技

Scott Mabin 全职加入 Espressif,将为其所有芯片提供 Rust 支持,改善其芯片的 Rust 生态系统。

乐鑫科技(股票代码:688018)是一家全球化的无晶圆厂半导体公司,成立于2008 年,在中国、捷克、印度、新加坡和巴西均设有办公地,团队来自20 多个国家和地区。

组织

支持esp的分叉编译器已经被移到 esp-rs组织中,同时还有一些idf支持板块--很快就会有更多的内容。在esp-rs/rust资源库中的讨论已经开始,在过去的一个月中,Espressif每周都会举办社区会议,重点是推动Rust支持的发展。

Espressif芯片上的Rust标准库

@ivmarkov 一直在努力将Rust STD库移植到esp-idf上,esp-idf是Espressif芯片的基于C的开发环境,esp-idf有一个newlib环境,可以用来构建Rust库。@ivmarkov把rust-esp32-std-hello演示版本放在一起,以展示其功能,其中包括WiFi、线程和使用esp-idf的驱动来驱动显示器。非常酷的东西! 有一些初期的问题,你可以在这里追踪,但希望我们很快就能开始向上游提供这些补丁。

为了补充Espressif芯片的标准库,esp-idf-hal已经被更新并移到esp-rs组织中。这个 crate 有API来使用标准库中没有抽象的外围设备和硬件,例如SPI外围设备。与HAL一起,esp-idf-svc也被加入,它是Espressif芯片的嵌入式svc特性的实现。embedded-svc背后的目标是提供围绕更高级别的嵌入式服务的抽象,WiFi、Ping、HTTPD、NVS等。

https://mabez.dev/blog/posts/esp-rust-espressif/

Lemmy 发布 v0.11.3

Lemmy 类似于Reddit、Hacker News等网站。你订阅你感兴趣的论坛,发布链接和讨论,然后投票,并对其进行评论。在背后它却是非常不同的;任何人都可以很容易地运行一个服务器,所有这些服务器是联合的(类似电子邮件),并连接到同一个宇宙,称为Fediverse。对于一个链接聚合器来说,这意味着在一个服务器上注册的用户可以订阅任何其他服务器上的论坛,并可以与其他地方注册的用户进行讨论。

总体目标是创建一个容易自主托管的、分散的替代Reddit和其他链接聚合器的网站,不受公司控制和干涉。

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


活动预告:

2021.08.08 杭州线下 Meetup

公开课:认识面向基础架构语言Rust

Rust究竟是一门怎样的语言? 为什么连续五年夺得”最受程序员喜爱的编程语言”称号。我们有幸邀请到Rust中文社区的苏林老师分享一下:

课程主题:《认识面向基础架构语言Rust》

课程时间: 2021年8月1日 20:30-21:30

课程内容:

  • Rust语言设计思想
  • Rust语言现状和未来
  • 通过一个hello word理解Rust语言如何执行

https://mp.weixin.qq.com/s/z-L33EyR7qac8KUlSxUFlA


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第28期 - 对Rust标准库的扩展实现技术分享

时间: 2021/07/06 20:30-21:30

主讲人:Arch

题目:hzqd 的小工具箱

内容: Kotlin 标准库的 Rust 实现,和其他一些小玩意儿。对Rust标准库的扩展实现技术分享。

参考资料:

  1. https://crates.io/crates/tap
  2. https://github.com/hzqd/aoko
  3. https://github.com/tokio-rs/bytes

查看回放


《Rust 唠嗑室》第29期 - rust no_std的库开发经验分享

时间: 2021/07/20 20:30-21:30

主讲人:DaviRain

题目:rust no_std的库开发经验分享

内容

  • 首先简单介绍std和no_std的区别

  • 然后介绍使用no_std库方式, 由于支持no_std的特性有两种不同的方式,因此使用no_std库也有两种方式。

    • 如何验证一个库是否支持no_std特性的验证方式
    • 如何改写一个std库为支持std和no_std的特性的方法。
  • 其它:

    • 具体的如何写一个支持std和no_std的库。
    • 一些在std和no_std下都可以使用的primitive的仓库和相关的资源和文章。

参考资料:

  1. https://github.com/DaviRain-Su/rust-no-std-source
  2. https://github.com/confio/ics23

查看回放


🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第十三期 讨论主题:如何在面试中考察一个人 Rust 水平?

  1. 如何面试/或准备应聘一场 Rust 面试。
  2. 实际面试经验分享 (@ huangjj )
  3. vscode + ra 使用小技巧分享 。

查看回放

第十四期:

分享者:吴翱翔

【讨论主题】

  1. Rust 消息队列实现多房间号的聊天室应用(视频长度 26 分钟)
  2. Rust 运行时动态加载配置文件和软中断(视频长度 17 分钟)

【回放】

第十五期 讨论主题:了解 rust-analyzer 原理 Part 1

  1. 这系列视频是 ra 作者为贡献者或潜在贡献者录制的
  2. 第一集展示了 ra 源码仓库的代码组织结构,从项目文档开始,逐步介绍了 cargo xtask 的使用,虽然采用扁平的 crate 结构,但也分主要的几个入口库 (ide/ hir 等),还看到了处理过程宏和声明宏相关的crate,还有 vfs(虚拟文件系统)等。
  3. 还展示了一些可能的贡献点:
  • bench 测试目录下有一个很大的 8500 行 代码的文件,需要分隔成多个小文件
  • 指定了 code style,然后接下来会根据code style 进行代码重构
  • 对标准库扩展的库中,可能需要添加一些文档
  • 帮忙增加 fuzz 测试等等。

参考资料:

  1. https://github.com/rust-analyzer/rust-analyzer

查看回放

第十六期 讨论主题:了解 rust-analyzer 原理 Part 2

【主题】

  1. Rust 1.54 特性
  2. 学习 LSP-language-server-protocol规范 ,然后跟随 rust-analyzer 作者学习 rust-analyzer 原理 (part 2)

【总结】

  1. Rust 1.54 更新的特性并不是很多,值得注意的是,增量编译功能又重新默认开启了。并且在 ErrorKind 中 多了一个 OutOfMemory 类型。
  2. RustAnalyzer 学习心得:
  • ra 为什么需要虚拟文件系统? 主要有三点原因:
    • 为了重复使用文件,以及优化内存。所以不可能使用 std::fs::read_to_string 这样的方式去读取文件。有虚拟文件系统就方便多了。
    • 需要兼容多平台文件系统和路径。比如 windows 路径和linux路径有很大差异,需要统一抽象。
    • 需要平台无关的 绝对路径和相对路径的 控制转换。
  • ra 的虚拟文件系统(vfs)只是一种快照系统,它只需要获取文件什么时候修改,哪里修改即可,不需要知道具体修改什么内容。类似于 git。
  • 代码架构比较常规,比如通过对象安全的 Hander trait 来提供统一接口,用于监控文件。也针对 每个 crate 提供一个对应的 FileSet 来管理文件,就好像是 文件系统分区的概念。
  • vfs 是 ra 数据流动的基础。 如果你对 实现一个简易且健壮的文件系统比较感兴趣,可以看看 vfs 的实现。
  • ra 作者强烈推荐了这个库 : https://crates.io/crates/fst 。

【回放】

https://www.bilibili.com/video/BV1jL411n7e3/


🔥🔥🔥🔥 北京-Rust区块链开发者活动现场实录-20210619 🔥🔥🔥🔥

视频集地址:https://www.bilibili.com/video/BV1Jh411h7pp

Rust异步编程原理及其在Starcoin中实践

演讲者:方圆

参考资料:

  1. https://starcoin.org
  2. https://github.com/starcoinorg

Web3.0时代背景下Rust的机遇

演讲者:Mike

Web3.0时代背景下,Rust的机遇

WebAssembly

  • Serverless 边缘计算
  • 链上代码
  • 浏览器中运行UI

Web3.0 框架

参考资料:

  1. https://rustcc.cn/article?id=289da7be-19ce-49fe-af6d-3a7946404ca6
  2. https://www.chainnews.com/articles/601126700329.htm

谈谈CKB的同步优化

演讲者:罗超

CKB上线前4个月遇到重大问题优化思路和优化方案。

用 Rust 语言开发可扩展和隐私保护服务

演讲者:Jamie

用Rust写EVM.Ewasm合约

演讲者:Antonio

参考资料:

  1. https://github.com/WasmEdge/WasmEdge

Rust 与零知识证明

演讲者:张烨

参考资料:

  1. https://github.com/veorq/cryptocoding

Rust 在Substrate开发框架中的使用

演讲者:王大锤

圆桌1-Rust编程语言应用前景

Rust 独特的地方:

  1. 内存管理机制,在编译进做代码检查;
  2. 所有权和生命周期机制,保证内存安全;
  3. 后云计算时代会使用WebAssembly,WebAssembly与Rust紧密相关;
  4. Rust是最适合区块链开发的语言; ......

圆桌2-Rust区块链开发实践漫谈

问题1:如何与Rust结缘?

  • 因工作结缘
  • 因解决实际问题结缘

问题2:开发者放弃Rust语言的原因是什么?新手如何克服困难?

  • 计算机基础知识欠缺
  • 习惯于高级语言,不习惯底层算法与逻辑,不愿意改变

问题3:Rust安全性是如何实现的?

  • 所有权机制,没有空指针

问题4:项目由C++转向Rust,项目在迁移过程中有什么需要注意的?

  • Rust 基于社区语言,参与度很强,通过交流不断学习
  • 对Rust有全局认识,减少学习成本

本月招聘

【招聘】Second State | Rust-WebAssembly 工程师/开发者布道师

Second State 于2019年成立,专注云计算与边缘计算的开源软件。我们的开源产品 WasmEdge, 目前已被 Linux 基金会旗下的 CNCF 接受为沙箱项目。

Second State 目前已经获得 SIG(海纳亚洲) 、奇绩创坛(YC中国)的投资。Marc Fleury 博士与陆奇博士是我们的顾问。我们目前已经与头部云厂商、头部区块链项目建立了密切的合作关系,现金流为正。

我们希望能够打造一支国际化团队,目前团队分布在台北、北京、美国、与澳洲。这其中工程师占比90%,绝大多数是名校硕士以上学历。我们在北京与台北有研发中心。

20年前,我们经历了 Java 从浏览器端到服务端的转变。10年前,我们经历了 JavaScript 从浏览器到服务端的转变。现在,我们想邀请您一起见证 WebAssembly 从浏览器到服务端的过程,共同构建下一代的开源基础设施软件。

我们正在期待一位 Rust-WebAssembly 工程师/开发者布道师加入:

  1. 熟悉 Rust/C++ 与 WebAssembly,或者愿意学习 Rust
  2. 热爱分享,技术输出是一大乐趣
  3. 具有探索和创新精神,WasmEdge 根据实际场景的需求,希望能够尽快推动落地 WebAssembly
  4. 能够自觉主动地完成工作
  5. 英文水平过关

以下是加分项

  1. 有自己的技术输出平台,比如 blog、微信公众号、B站
  2. 有开源项目工作经验或是开源贡献经历

工作职责:

  1. 编写 Rust SDK 与 WasmEdge (此部分可根据个人经历自由选择)
  2. WasmEdge 社区增长与活跃
  3. 撰写 WasmEdge 文档及使用案例
  4. 积极参与线上/线下社区活动,布道 Rust、 WebAssembly/WasmEdge
  5. 探索新技术与 WasmEdge 的合作场景及案例

我们可以提供:

  1. 具有竞争力的薪酬+期权,六险一金
  2. 自由灵活的办公时间,可以选择远程办公
  3. 国际化团队氛围
  4. 顶级开源基金会平台

工作氛围轻松,结果导向,只要你有意愿与能力,可以独立掌控项目 全职、实习均可。

联系方式:vivian@secondstate.io 或添加微信 h0923xw

加入 Second State,一起建立下一代开源基础设施吧。

[北京中关村/远程][TensorBase][开源数据仓库]等一群人,做一件事

TensorBase,基于Rust的现代化开源实时数据仓库。它专注于开源大数据存储和分析的基础设施,让大数据背后的价值可以惠及这个时代中每个个体和企业。

团队介绍:我本人的简介在这里。TensorBase已获陆奇博士旗下奇绩创坛(原YC中国)的种子轮投资,陆奇博士就是我们的合伙人!

开源,惠人达己。创新,守正出奇。基础设施的未来,必定是开源和创新的。目前TensorBase有很好的社区和成果,ClickHouse的核心领导人Alexey Milovidov给TensorBase提交了PR,中国的开源数据基础设施项目首次赢得了国外开源数仓领袖的敬意,我们可以骄傲!

我有一个梦想:和一群中国工程师一起相互帮助、探索和努力,做一件可以走在这个时代的最前沿,还可以让自己成长、开心和骄傲的事情。我等这样一群人:

  1. 愿意使用Rust语言,并贡献到Rust开源社区(希望你不要把语言当作一个工具,虽然客观上它确是一个工具)。
  2. 有创新和探索精神,愿意在无人区工作。
  • 内核向:对数据工程、高性能系统、高性能计算、编译器、Linux内核、分布式、并发、网络协议等一个或者多个领域有实践或有兴趣。
  • 社区向:对Rust/数据有兴趣的前端或Technical writer。
  1. 爱做TensorBase这样一件事情,爱和TensorBase这样一群人一起工作。

全职、实习均可,但实习生需要onsite。

对比大厂,提供有竞争力的薪酬 + 期权。

联系方式:

邮箱: hr@tensorbase.io

工作地点:

海淀区中关村。这里离好几个新近的开源创业团队都很近,比如太极图形。这里是中国开源创业的新硅谷。不加班,希望大家早睡早起,我邀请大家一起去边上的海淀公园晨跑,为家国健康工作50年。同时,对于合适的同学,可以远程工作。

加入我们,一起创造TensorBase的下一个五年!

Datafuse | 数据库Rust讲师

Datafuse是使用Rust构建的完全面向云架构的新一代开源数仓,目的为用户提供更高性能,更低成本、更加易用的数据分析服务。

Datafuse项目地址: https://github.com/datafuselabs/datafuse

简历发送: hr @ datafuselabs.com

职位诱惑:

  • Remote办公,六险一金,弹性工作,大牛带队,扁平管理。
  • 待遇:面谈

职位职责:

  1. 负责Rust相关课程开发及授课工作
  2. 了解Datafuse项目,从项目中抽取模块编排教学工作
  3. 从事Datafuse部分文档输出
  4. 针对Datafuse输出用户解决方案

职位要求:

  1. 良好的系统编程能力,喜爱或是有Rust经验及扎实的C/C++功底更佳
  2. 熟悉数据库运行原理或是大数据应用场景更佳
  3. 喜欢关注新技术,愿意做技术推广
  4. 了解课程设计及开发
  5. 热忠自动完成事情

百度招聘Rust研发工程师

1. 职位概况

  • 岗位名称:资深Rust研发工程师
  • 所属部门:百度安全部
  • 岗位职级:技术T5-T7
  • 工作地点:北京市

2. 工作职责

  • 负责数据安全计算相关产品核心组件研发以及开源社区建设
  • 参与数据安全与隐私计算产品研发
  • 负责基于Intel SGX、ARM TrustZone等硬件技术的调研、分析与应用转化
  • 负责机器学习与深度学习算法在TEE技术上的移植与优化

3.职位要求

  • 计算机软件、信息安全等相关专业,统招本科及以上学历
  • 有良好的系统编程能力,喜爱或有Rust开发经验以及扎实的C/C++功底
  • 熟悉常用数据结构和算法,熟悉Linux系统
  • 关注代码设计和新技术,有持续学习的习惯
  • 有TEE可信执行环境、Intel SGX、机器学习、区块链相关技术经验者优先
  • 熟悉Github开源社区,长期贡献者优先

有意者请投简历至邮箱:luoyanhua@baidu.com

Phala招聘 Rust区块链研发工程师

1. 关于Phala Network

波卡系保密智能合约平台,可以实现保密前提下的数据交易协议,致力于成为 Web3.0 数据隐私计算的基础设施。基于 Substrate 开发的 Phala 未来将会成为 Polkadot 平行链,通过跨链协议为任何区块链的提供机密智能合约能力。Phala 是波卡生态的重要成员:2019 年 8 月获得 Web3 基金会 Grant,2020 年 3 月成为首批加入 Parity Substrate Builders 计划的项目之一。在 Phala Network,我们相信下一代区块链需要安全高效的处理机密数据,因此我们开发了基于 TEE 的保密合约,致力于为区块链实现保密能力。

2. 职位描述

  • 有 Rust 开发经验,或拥有快速上手 Rust 的能力
  • 区块链相关数据结构与算法
  • 扎实的计算机科学基础知识
  • 熟悉 Git 版本管理、单元测试,熟悉开源协作
  • 认同技术文档、开源教程、参与技术分享的重要性并积极参与

3. 职位方向

核心协议

  • Intel SGX SDK 开发与整合

  • P2P网络通信与密钥管理

  • XCM 跨链协议整合

  • 对接 Filecoin 等分布式存储协议

保密合约基础设施

  • WASM、Ink! 执行环境的整合、优化、Chain Extension 开发
  • 调研和改进合约编程模型
  • WASM 工具链的整合

区块链开发

  • 设计与实现 Substrate Pallet
  • Ink! 合约研发、测试与部署
  • 不同场景下的 XCM API 设计与实现

计算平台

  • 构建兼容工业标准的 Serverless、FaaS 基础平台在 Phala 上开发与移植应用,并测试平台的可用性

4. 加分项

  • 有可信执行环境应用开发经验:Intel SGX、AMD SEV、Arm TrustZone
  • Substrate或其他区块链节点开发经验
  • 扎实的应用密码学知识:非对称加密、零知识证明、多方安全计算
  • 跨链桥(比特币SPV节点)、Layer 2 开发经验(核心协议方向)
  • Solidity、Ink等智能合约开发经验(区块链开发方向)
  • FaaS、Serverless、Event Sourcing实施经验(计算平台方向)
  • 分布式系统任务调度、分布式事务、分布式存储等基础设施的开发经验(计算平台方向)

有意者请联系邮箱:hire@phala.network或微信:h4xbigcat

Automata Network 招聘区块链研发工程师

1. 关于Automata Network

Automata Network 是一个全新的隐私中间件网络,致力于通过为 dApp 提供无缝衔接的隐私保护和高可用性解决方案,来促进 Web3.0 社区的发展。我们正在寻找对此领域感兴趣,有热情,同时具有强大自力驱 的小伙伴。希望你能加入我们,作为团队核心的研发成员,进行技术的探索和应用的研发。公司总部位于新加坡,你也可以选择在家远程办公,请速将简历砸向我们吧!

2. 职位描述

  • 基于 Substrate (https://substrate.dev) 进行区块链相关应用的研发
  • 基于 Intel SGX 进行支持可信执行环境(TEE)相关研发
  • 探索前沿的技术解决方案,设计技术架构
  • 编写单元测试,编写技术文档,审核其他成员的代码

3. 职位要求

  • 两年以上的软件开发经验
  • 熟练掌握 Rust 或者 Modern C++ 其中一门语言,熟悉常见脚本语言如 Shell 和 Python的使用
  • 具有很强的解决问题能力和沟通能力,能够快速学习并掌握新的技术
  • 熟悉开源社区的开发和协作方式
  • 加分项: 熟悉 TEE 相关技术,如 Intel SGX 开发,了解区块链相关协议和常见共识算法

4. 加分福利

  • 具有市场竞争力的薪资待遇,优秀的小伙伴更可获得 token 配额
  • 灵活安排的工作时间,积极开放的工作氛围以及行业大牛的手把手指导
  • 在最前沿的技术领域进行探索,填补技术空白,能让你快速成长,成为领域内的专家

有意者请投简历至邮箱: hiring@ata.network

如果对Rust与区块链有兴趣,请关注OneBlock+微信公众号,还有多家区块链项目在招募Rust工程师。

RustChinaConf 2021 议题征集开放申请

大会介绍

Rust China Conf 2021 由 Rust 中文社区发起主办、知名企业和开源组织联合协办,是年度国内规模最大并唯一的 Rust 线下大型会议,深受 Rust 中文社区开发者与相关企业的喜爱与推崇。

本次大会为线下会议,将于10月16日-17日在上海举办,预计到场人数 300 左右,包含个人开发者和企业用户。到场人员主要为一线程序员,已在个人或公司项目中实践 Rust。

本次大会将同步开启线上直播、以及结束后上传相关录像。线上线下联动,将覆盖 30,000+ Rust 开发者群体。

本次大会也获得了国内知名开发者社区、媒体与出版社的支持。依托 Rust 中文社区本身的影响力,加上多个社区联合宣传,将进一步扩大本次 Rust China Conf 的传播范围。

大会目标

本次大会为中国本土范围的第一次 Rust 大会,致力于成为中国 Rustaceans 面对面交流的盛宴,为国内的 Rust 开发者和企业提供一次充分的成果展示、技术分享、能力提升、行业资讯交流、企业人才储备建设的机会。

大会议题范围

Rust 语言独有的特性,使得其适应面非常广泛,通过广泛搜集整个社区和企业中的议题,分类参考如下:

  • 库或框架
  • Rust 语言贡献
  • Rust 学术/ 教育/ 教程 / 书籍
  • Rust 社区
  • Rust 生产环境使用经验
  • 最佳实践
  • 系统编程
  • WebAssembly
  • 数据库
  • 游戏开发
  • 区块链
  • 嵌入式开发
  • 安全(Security)领域
  • 其他领域
  • workshop

议题提交截止时间

8月20日 23:59

议题审核流程

议题由 Rust China Conf 2021 组委会审核, 已确认通过的议题会第一时间更新在大会官网:https://rustcc.cn/2021rustchinaconf

所有议题审核最后截止时间:2021年9月10日

组委会联系邮箱

624910278@qq.com

期待与大家相见。

议题申请通道

议题申请通道:https://shimo.im/forms/xqpwpdXw6YxrJTj9/fill

论文导读 | 性能与生产力 : Rust vs C

作者: 张汉东


  • 原标题:Performance vs Programming Effort betweenRust and C on Multicore Architectures: CaseStudy in N-Body
  • 论文地址:https://arxiv.org/abs/2107.11912
  • 发表时间:2021年7月26
  • 关键字:Rust, C, N-Body, Parallel Computing, Performance comparsion, Programming Cost

前言

曾经 Fortran和C一直是高性能计算(HPC)的默认编程语言。这两种语言都提供了可以和操作系统内存以及硬件进行交互的基础类型和函数,从而在响应时间和资源使用方面产生高效的代码。然而,对这两种语言而言,如何生成可维护和可扩展的代码是一个真正的挑战。

Rust 语言诞生之后,它天生为并发和安全而设计,并且借鉴了面向过程/面向对象/函数式等语言的特点。Rust 的目标在性能方面对标 C 语言,但在安全和生产力方面则比 C 更胜一筹。

这篇论文就是比较研究 Rust 和 C 语言在 性能和 编程效能(Programming effort)两方面,看能否确定 Rust 是一种保持一定性能水平的同时拥有更少工作量(更高的编程效能和生产力)的语言。如果是这样,那么 Rust 则是 HPC 领域的绝佳替代品。

之前 Rust 社区也探讨过如何确定 Rust 生产力的问题,那么这篇文章就是一个启示。本文并非论文完整翻译,只是一些重点摘要。

什么是 N-Body ?

背景

N-Body ,即 N 体问题。

在二十世纪的第一次数学家大会(1900年)上,二十世纪伟大的数学家希尔伯特(David Hilbert)在他著名的演讲中提出了23个困难的数学问题。其中 N 体问题的 特例 三体问题就被提了出来。

先从 三体 问题说起。三体问题是天体力学中的基本力学模型。它是指三个质量、初始位置和初始速度都是任意的可视为质点的天体,在相互之间万有引力的作用下的运动规律问题。三体问题(three-body problem)最简单的一个例子就是太阳系中太阳、地球和月球的运动。

N 体问题就是 三体问题更一般化的多体问题。多体问题是一个十分复杂的理论问题,也是天体力学各个分支学科的共同基础课题。当N=2时,即为二体问题,已完全解决(回想一下牛顿万有引力定律)。N=3即成为著名的三体问题,除一些特殊的限制性三体问题可以得出特解外 ,一般三体问题仍是悬而未决的难题。对于N>3的N体问题,根本无法求出分析解。现在主要是采用数值方法和定性方法来进行研究。特别是随着电子计算机的广泛使用,数值方法更成为研究N体问题的主要手段。

八卦:

  1. 科幻作家刘慈欣的《地球往事》三部曲之一《三体》即是以此问题为基础而创作的。
  2. 多体问题也在电视连续剧《犯罪心理》中"Compulsion"这段被显著提到。
  3. 多体问题也出现在1951年科幻电影《地球停转之日》,其中Klaatu为了吸引一位科学家的注意而解决了这个问题。

以上参考维基百科。

计算

对于 N body 有很多的稍微近似的算法,多是基于实际的物理场景的,比如大部分天体是属于一个星系的,每个星系之间都非常远,所以可以将一些星系作为整体计算,构建一颗树,树中的某些节点是其所有叶子的质心,这样就可以减少计算量,但是放到 GPU 的场景下一个 O(N^2) 的暴力算法利用 GPU 的并行能力可以非常快的算出来。

在网上也有针对 n-body的 各个语言性能比较:https://benchmarksgame-team.pages.debian.net/benchmarksgame/performance/nbody.html

高性能计算特点

高性能计算(HPC)是指使用非凡的计算能力系统和并行处理技术来解决具有高计算需求的复杂问题。实现这一目的不仅需要有提供必要处理能力的架构,还需要有允许问题被有效计算的软件。这就是为什么不能简单地选择一门编程语言,它的选择会对应用性能和所需的编程效能产生影响。

HPC系统必须有效地计算问题,以提高程序的响应时间。能做到这一点的语言,必须是拥有和底层硬件打交道的能力。目前在这个领域最流行的语言是 Fortran 和 C 。尽管被广泛使用,但用这些语言生成可维护和可扩展的代码是一个真正的挑战。Java 和 Python 这两种语言还试图进入这个领域,可想而知,它们失败了。

在这个意义上,语言应该至少提供以下功能:

  • 允许直接操作内存内容的指针。
  • 一套位操作符,如果使用得当,可以大大改善程序时间。
  • 支持不同的标志或编译选项,根据支持的架构优化代码。
  • 使用/嵌入本地底层架构指令的能力,以利用其硬件特性的优势。

此外,为了提高响应时间,语言应该提供工具或库,允许扩展基础语言功能,为多处理器架构提供并发和并行处理能力,包括共享(如OpenMP)和分布式内存(如OpenMPI或MPICH)。

该论文的重点是评估 Rust 能否在 HPC 领域成为 C 语言的替代品,所以使用 HPC 领域最常见的 N体问题 作为案例,并且做了如下工作:

  1. 在多核架构上,使用 Rust 语言对 N 体问题进行多次优化实现。
  2. 严格对比 多核架构下 N 体问题的 C 和 Rust 实现,来确定 Rust 在 HPC 领域中的优势与劣势。

Rust 实现

N 体问题用于模拟一个由 N 个个体组成的系统在时间推移过程中的演变。每个个体都有一个初始状态,由其速度和位置给出。系统的运动是通过离散的时间瞬间来模拟的。 在每一个瞬间,个体都经历了一个加速度,这个加速度来自于其余个体的引力,这影响了它的状态。牛顿力学是模拟的基础。

这项模拟是在 3 个空间维度上进行的,两个物体CiCj之间的引力是用牛顿的万有引力定律计算出来的。

1

其中,F是指物体之间的引力大小,G是指引力常数(6.674×(10^11)),mi 对应 Ci 的质量,mj 对应 Cj 的质量,r 对应 Ci和Cj之间的欧氏距离(euclidean distance)。

当 N 大于 2 时,一个物体上的引力相当于其余 N-1 个物体施加的所有引力总和。根据牛顿第二定律,牵引力导致每个物体加速和移动: F = m·a (这是一个矢量表达式,加速度和合力的方向始终保持一致)

牛顿第二定律独立性告诉我们:物体受几个外力作用,在一个外力作用下产生的加速度只与此外力有关,与其他力无关,各个力产生的加速度的矢量和等于合外力产生的加速度,合加速度和合外力有关。

在一个小的时间间隔dt 内,Ci的加速度大约是恒定的,所以速度的变化大约是: d·vi = ai·dt

Ci位置的变化是其速度和加速度在dt时间间隔内的积分:

2

在这个公式中,很明显,一半的位置变化是由于旧的速度,而另一半是由于新的速度。 这种整合方案被称为 Leapfrog 。解法伪代码如下:


#![allow(unused)]
fn main() {
FOR every-body-i = 1 to N
		FOR every-body-j = 1 to N
    		Calculate the force exerted by j on i // 计算 j 对 i 所施加的力
        Sum of the forces affecting i         // 影响 i 的力之和
    Calculate the displacement of the body i  // 计算 i 的位移
    Moving the body i                         // 移动 i
}

上面伪代码中有两个数据依赖:

  1. 首先,一个个体不能移动,直到其他个体完成计算它们的相互作用。
  2. 第二,在其他个体完成当前步骤之前,他们也不能前进到下一个步骤。

Rust vs C 实现

性能

论文中给出了一些性能测试图表。看得出来,整体性能 Rust 和 C 相差无几。

在单精度方面,C语言版本在所有问题规模上都优于Rust,实现了高达1.18倍的改进,而在双精度方面,两种实现的性能几乎相同。

当分析两种实现产生的汇编代码时,可以看到当使用数学优化(precision relaxation)时,C语言对主代码进行了更有效的转译。 这种行为在双精度中没有被复制,在双精度中两种代码是非常相似的。

这些优化还没有包含在 Rust 的稳定版本中,所以这一点有望在未来得到改善。

编程效能

有很多方法来衡量编程的成本,包括计算代码行数。尽管它们很简单,但这些参数并不能反映算法的复杂性。

还可以测量开发时间,但这取决于程序员的经验。这些指标都极具主观性,导致很难去评估编程的成本。

但是 代码行数 和 开发时间 算是互补的方法,一定程度上在广义上来评估编程的效能是可以的。

先来看看代码行数:

CRust
Main6640
Total219195

Rust的优势在于,作为一种具有高级语言的一些特征的语言,它既是函数式的,也是面向对象的,它可以开发出比C语言更紧凑、更容易解释的代码。代码量少的情况下,还有更强的可维护性。

在优化过程中:

  • C 语言需要不断改变解决方案的逻辑才能更好地利用数据位置的优势,而 Rust 则更有效地管理了内存,优化过程中不需要对解决方案进行修改。
  • Rust 的迭代器可以通过更简单的方式生成并行代码,而 C 则需要对不同的 OpenMP 选项来实现适当并行化。
  • Rust 中添加外部库非常方便,比如数学优化库或 rayon库。C 则比较麻烦。

实验结论

先来看一下论文结论。

Rust 创建的 N-Body 优化算法是从一个基础版本开始,然后不断迭代优化出来的。优化措施如下:

  1. 多线程。将单线程的基础版本修改为多线程,增加并发计算。
  2. for_each 代替 fold,性能没有变化,但是增加了可读性。
  3. 在数学层面上进行优化,虽然损失了计算精度,但是提升了算法性能。
  4. 开启自动向量化,进行并行计算。Rust 支持自动向量化(SIMD)。
  5. 使用 Jemalloc 内存分配器替换默认分配器,性能有所提高,但不是很明显。
  6. 其他。

对 Rust 算法优化完之后,和 C 语言对应的算法进行了比较。在双精度方面,性能结果很接近,但在单精度方面,C版本的性能要好一些。 这是因为Rust对这种数据类型的数学运算的优化不如C语言好。

在编程效能(生产力)方面,Rust与C不同,它有一些高级语言的特性,这有利于生成易于维护的代码。 此外,由于它具有函数式语言和面向对象语言的特点,它允许生成更紧凑的代码,导致程序的代码行数更少。 此外,Rust试图有效地管理内存,在某些情况下,不需要对计算逻辑进行修改就可以利用数据位置的优势。

基于所获得的结果和所进行的分析,论文作者们认为在与本研究类似场景的情况下,Rust可以被定位为HPC的C语言的替代品。 由于该语言仍在不断发展中,社区支持将成为其最终可行性的决定因素。

相关资料

https://github.com/ManuelCostanzo/Gravitational_N_Bodies_Rust

华为 | Rust 调用约定和名称修饰规则介绍

作者:周紫鹏 / 后期编辑:张汉东


背景介绍

一般来说调用的约定和名称修饰(Name mangling)对于程序员来说是不需要感知到的,它们一般都是通过编译器来完成,但是如果涉及到汇编代码的函数调用,就需要了解所编写代码最终编译生成的符号信息。调用约定和名称修饰都是属于ABI(Application Binary Interface)范畴内容,当前Rust还没有一个稳定的ABI,所以编译的时候需要将所有依赖的crates一起编译。名称修饰最主要解决的一个问题是,保证代码链接时名称的唯一性。因为在一些编程语言中,支持命名空间、泛型、重载等特性,支持在同一个命名空间中存在相同的函数名称或者标识符,为了做区分就需要在编译时做相应的修饰,比如加入crate信息、命名空间信息等。

在进行Rust二进制大小分析和Rust热补丁分析时,对Rust符号组名规则的分析同样也是必要的,这有助于我们了解Rust代码生成的符号信息,所以本文将对Rust名称修饰规则做一个简单的介绍。

legacy 规则

Rust从 1.9版本开始使用legacy规则,Rust的legacy规则是基于Itanium IA-64 C++ ABI进行了部分的修改,最主要的是在符号最后加了哈希值用来解决部分场景下的符号唯一性的问题。

普通函数


#![allow(unused)]
fn main() {
// crate 名称:legacy_mangling
fn foo() {
    println!("foo");
}

fn foo_arg(x: i32) {
    println!("x = {}", x);
}
}

foo组名后:_ZN15legacy_mangling3foo17h7bf46936ec8fddf1E

其中_ZN为legacy规则的组名符号开头,和Itanium IA-64 C++ ABI规则一致,后面紧跟的15是crate的legacy_mangling的字符个数,包括了中间的下划线。3表示函数foo,最后面紧跟着17是hash值h7bf46936ec8fddf1,并以E表示结束。

foo_arg组名后:_ZN15legacy_mangling7foo_arg17h9d3deebd56cd9668E

可以发现参数并不会体现在前面的组名中,而是通过hash值来做区分。

带泛型参数的函数

// crate 名称:legacy_mangling
fn main() {
    foo_generic(1);
    foo_generic(1.0);
    foo_generic("Hello");
}
fn foo_generic<T: std::fmt::Display>(x: T) {
    println!("x = {:#}", x);
}

foo_generic组名后:


#![allow(unused)]
fn main() {
_ZN15legacy_mangling11foo_generic17hf6d667a670f9aa59E
_ZN15legacy_mangling11foo_generic17ha0a4115d4cba4650E
_ZN15legacy_mangling11foo_generic17he59e5604e24e62a6E
}

泛型参数实例化后符号差异也是在hash值上,不会将泛型参数信息体现在组名中。由于参数不会体现在,这也使得在函数热补丁场景下,很难确定具体的补丁函数是哪个。

结构体方法

fn main() {
    let point = Point{
        x: 1,
        y: 2,
    };
    println!("{}", point.add());
    println!("{}", point.sub())
}

struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn add(&self) -> i32 {
        self.x + self.y
    }

    fn sub(&self) -> i32 {
        self.x - self.y
    }
}

Point结构体方法addsub组名后:

_ZN15legacy_mangling5Point3add17h9b332fc1bb45a67eE
_ZN15legacy_mangling5Point3sub17hf189faef70b4895dE

组名后是crate + Struct + func + hash的组成方式,同样是不会携带参数信息。由于Struct名称也会当成组名的一部分,所以此时不允许定义mod和Struct同名的子模块。编译器在编译的时候,也会判断函数是否被使用,如果没有被使用的函数,不会进行编译,比如main函数中只调用了point.add(),那不会将sub函数编译到二进制中,这也是编译器对可执行二进制大小的优化。

trait方法

fn main() {
    let point = Point{
        x: 1,
        y: 2,
    };
    println!("{}", point.add());
}

pub trait Compute {
    fn add(&self) -> i32;
}

struct Point {
    x: i32,
    y: i32,
}

impl Compute for Point{
    fn add(&self) -> i32 {
        self.x + self.y
    }
}

组名后:_ZN67_$LT$legacy_mangling..Point$u20$as$u20$legacy_mangling..Compute$GT$3add17h9b332fc1bb45a67eE

可以看到组名之后变得相对比较复杂,因为我们在不违反孤儿原则的情况下,可以实现另外crate中的trait,或者Struct在另外的crate中,所以在组名中的trait和Struct包含了crate名称。$.两个符号是保留的特殊符号,这两个符号在编码中用来命名会报错。

闭包

fn main() {
    let add_one = |x: u32| -> u32 { x + 1 };
    println!("{}", add_one(1));

}

组名后:_ZN15legacy_mangling4main28_$u7b$$u7b$closure$u7d$$u7d$17h5e4f3fa236bcd1c3E

闭包组名会包含crate名称和闭包所在函数名称,并且会携带closure关键字,但是不会体现具体的捕获参数等信息。

关于legacy规则的其他语法特性的组名方式此处不再进行更多的介绍,并且官方对于legacy的组名规则说明也比较少,后面介绍正在标准化过程中的V0规则。

V0规则

V0规则的RFC当前正在实现过程中,对应的ISSUEPR,从Rust1.39版本之后的Nightly版本中可以通过rustflags设置使用V0规则。使用方式:可以通过设置RUSTFLAGS=-Zsymbol-mangling-version=v0或者在.cargo/config中添加rustflags


#![allow(unused)]
fn main() {
[build]
rustflags = ["-Z", "symbol-mangling-version=v0"]
}

当前V0规则标准化过程涉及的GDB工具、GCC、Perf、valgrind等适配已经完成,按照进展ISSUE描述的情况看,只剩下Doc相关的适配,相信在不久将来会合入到stable版本中。

RFC中总结了当前legacy规则存在如下一些问题:

  • 通用的参数和一些其他的信息在重组名过程中会丢失,并且不能从符号中获取到单态函数的参数类型。如同我们前面做的测试,泛型函数和带参数的函数并不能通过重组名的符号反推出来,因为都是以Hash值作为结尾。
  • 方案存在不一致情况,大部分使用 Itanium ABI样式进行编码,但有些却没有使用
  • 生成的符号中包含.,但是该符号在部分平台上不支持
  • 它取决于编译器内部结构,其结果无法被其他编译器实现或外部工具复制

V0规则主要解决如下问题:

  • 它以可逆的方式编码有关泛型参数的信息。也就是可以通过符号反推出泛型的参数信息。
  • 它有一个一致的定义,不依赖于漂亮地打印某些语言结构。
  • 字符由A-Za-z0-9,和_组成

V0规则很重要的一个点是取消了Hash值,可以通过重组后的符号信息解码出源码的信息,这也是后续如果Rust的ABI稳定的一部分,可以预测给定的代码生成的符号信息。

下面以几个简单的例子介绍V0规则函数名称重组之后的情况

普通函数


#![allow(unused)]
fn main() {
// crate 名称:v0_mangling
fn foo() {
    println!("foo");
}

fn foo_arg(x: i32) {
    println!("x = {}", x);
}
}

foo组名后:_RNvCs1L72TZisdJI_11v0_mangling3foo

foo_arg组名后:_RNvCs1L72TZisdJI_11v0_mangling7foo_arg

V0 组名规则以_R作为开头,去掉了后面的Hash值,普通函数中也去掉了E函数作为结尾。

mod中函数


#![allow(unused)]
fn main() {
fn foo() {
    fn bar() {}
}

mod foo {
    fn bar() {}
}
}
_RNvNtCs1234_7mycrate3foo3bar
<>^^^^^<----><------><--><-->
 ||||||   |      |     |   |
 ||||||   |      |     |   +--- "bar" identifier
 ||||||   |      |     +------- "foo" identifier
 ||||||   |      +------------- "mycrate" identifier
 ||||||   +-------------------- disambiguator for "mycrate"
 |||||+------------------------ start-tag for "mycrate"
 ||||+------------------------- namespace tag for "foo"
 |||+-------------------------- start-tag for "foo"
 ||+--------------------------- namespace tag for "bar"
 |+---------------------------- start-tag for "bar"
 +----------------------------- common Rust symbol prefix

上图从RFC中复制的内容,对每个字段进行了详细的说明,当然具体的编码方式可以参见RFC。

带泛型参数的函数

// crate 名称:legacy_mangling
fn main() {
    foo_generic(1);
    foo_generic(1.0);
    foo_generic("Hello");
}
fn foo_generic<T: std::fmt::Display>(x: T) {
    println!("x = {:#}", x);
}

foo_generic组名后:


#![allow(unused)]
fn main() {
_RINvCs1L72TZisdJI_11v0_mangling11foo_genericReEB2_        -->  foo_generic("Hello");
_RINvCs1L72TZisdJI_11v0_mangling11foo_genericdEB2_         -->  foo_generic(1.0);
_RINvCs1L72TZisdJI_11v0_mangling11foo_genericlEB2_         -->  foo_generic(1);
}

泛型参数实例化之后会再最后的参数中体现,其中参数对照如下,比如foo_generic("Hello")传入的是&str所以符号名称为ReER代表&e表示的是str类型,E表示参数结束。同理传入为float的实例化是dE,传入i32的实例化是lE

<type> = <basic-type>
       | <path>                      // named type
       | "A" <type> <const>          // [T; N]
       | "S" <type>                  // [T]
       | "T" {<type>} "E"            // (T1, T2, T3, ...)
       | "R" [<lifetime>] <type>     // &T
       | "Q" [<lifetime>] <type>     // &mut T
       | "P" <type>                  // *const T
       | "O" <type>                  // *mut T
       | "F" <fn-sig>                // fn(...) -> ...
       | "D" <dyn-bounds> <lifetime> // dyn Trait<Assoc = X> + Send + 'a
       | <backref>

<basic-type> = "a"      // i8
             | "b"      // bool
             | "c"      // char
             | "d"      // f64
             | "e"      // str
             | "f"      // f32
             | "h"      // u8
             | "i"      // isize
             | "j"      // usize
             | "l"      // i32
             | "m"      // u32
             | "n"      // i128
             | "o"      // u128
             | "s"      // i16
             | "t"      // u16
             | "u"      // ()
             | "v"      // ...
             | "x"      // i64
             | "y"      // u64
             | "z"      // !
             | "p"      // placeholder (e.g. for generic params), shown as _

其他语法特性的组名规则不再一一进行列举,V0相对legacy规则有一些的改进,可以方便的从组名后的符号方便的推测出组名之后的代码。组名规则的变更会涉及到大量的工具需要适配,V0的RFC从18年就开始提出,当前大部分的适配已经完成,V0规则对需要精确知道组名之后的源代码是很有帮助的,比如在我们实际业务中会涉及到Rust函数级别的热补丁实现,就需要知道我们所要打补丁的代码最终会生成的符号组成,legacy规则就很难解决这个问题。

参考:

https://doc.rust-lang.org/reference/abi.html

https://people.gnome.org/~federico/blog/rust-stable-abi.html

https://rust-lang.github.io/rfcs/2603-rust-symbol-name-mangling-v0.html#unresolved-questions

蚂蚁集团 | Futures 0.1 和 0.3 兼容层的分析与应用

作者: Rachelint

背景

我们团队在 futures 0.1 版本时便在项目中对其进行了广泛使用,但 futures 发展到 0.3 加上 rust 引入了async/await 关键字后,明显具有了更高的可读性和可维护性,将 futures crate 进行版本升级是迟早要做的事情。但是如果一次性将项目中所有相关代码进行升级,工作量之大明显是难以接受的,稳定性方面也难以确保。因此我们决定利用 futures 0.3 提供的兼容层,对 futures 相关代码进行逐步升级。 在本文中,我想分享一下对兼容层实现的一些理解,还有在实践中得到的一些经验。文章大致分为三部分:

  • 兼容层的大致使用方法
  • 兼容层原理分析
  • 实践经验

部分观点可能存在过于主观,或者存在错误等情况,欢迎指正。

1. Future01 和 Future03 的区别

首先看看两个版本中trait的声明 (在文中,futures 0.1 的 Future trait 称为 Future01,而 futures 0.3 的称为 Future03,下同):


#![allow(unused)]
fn main() {
// 0.1
pub trait Future {
    type Item;
    type Error;
    fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
}

// 0.3
pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll<Self::Output>;
}
}

可以看出,主要有三处不同:

  1. Future01 有两个关联类型 Item 和 Error,因为其中的 Poll 其实就是一个特殊的 Result 类型; 而 Future03 则只有一个关联类型 Output,而其中的 Poll 变为一个枚举类型;
  2. Future03 poll 中的参数之一从 Future01 中的 &mut self 变为了Pin<&mut Self>;
  3. Future03 poll 中多了一个参数cx: &mut Context; 这里主要简单介绍一下第2和第3点区别:
  • Future03 为什么需要 Pin<&mut Self>?首先 Pin 只会限制实现了 !Unpin 的类型的移动,而 rust 只为一些如下特例实现了 !Unpin:
    • PhantomPinned;
    • 编译器为 async/await desugar 之后生成的 impl Future结构体;

很明显,Pin 主要是为了解决 async/await 自动生成 Future 的问题,那问题就是自引用,移动自引用结构体会造成指针失效。限于篇幅问题,这里先不再展开了,想更详细地了解关于 Pin 的知识,推荐阅读以下文章:

  1. Rust的Pin与Unpin
  2. Rust Async: Pin概念解析
  • Future03 为什么需要 cx: &mut Context?现在 Context 当中还是只有一个 waker: &Waker 成员,新加入这个参数,按个人理解主要是用于改变按个人理解主要是用于改变 Waker(在01中起相同作用的是Notify) 设置和在 poll(..) 中对其进行获取的方式。

    Future01 中 notify 的传入与获取大致流程:

    wait_future1.png

    Future 0.3 中 notify 的传入与获取大致流程:

    future03_wake.png

很明显,实现 Future01 和 03 相互间的转换只要解决以后区别即可,而实际的实现大致上也是这么一个思路。

2. 使用方法

我们的目的是在项目中让 futures 0.1 和 0.3 相关内容并存,并根据需要实现它们之间的相互转换。

Cargo.toml 配置

  • 首先要解决 futures 0.1 和 futures 0.3 两个 crate 都叫 futures 的问题。利用 cargo 提供的重命名功能即可,由于项目中暂时还是以 futures 0.1为主,因此把 futures 0.3 重命名为了 future03。

#![allow(unused)]
fn main() {
[dependencies]
futures = "0.1"
futures03 = { package = "futures", version = "0.3" }
}
  • 然后需要解决它们之间的相互转换的问题。这里可以启用 futures 0.3 提供的 compat feature,可以使用组合子的方式很方便解决相互转换的问题,在 Cargo.toml 中进行简单设置即可启用。

#![allow(unused)]
fn main() {
[dependencies]
futures = "0.1"
futures03 = { package = "futures", version = "0.3", features = ["compat"] }
}

转换方法

那么具体要如何相互转换呢? 这比较简单,利用compat组合子就可以直接实现了。

use futures::future::Ok;
use futures03::compat::Future01CompatExt;
use futures03::executor::block_on;

fn main() {
    let fut01 = futures::future::ok::<i32, ()>(42);
    let fut03 = fut01.compat();
    let res = block_on(fut03);
    println!("the fut03 res:{}", res.unwrap());
}

Future03 转 Future01。这就没那么简单了,能转 Future01 的 Future03 需要满足以下两个限制(文章后面会解析为什么):

  • 这个 Future03 需要是一个TryFuture。怎样的 Future03 才会是 TryFuture 呢?其实很简单,关联类型 Output 为 Result 就行了,看相关源码就很容易知道,会自动为这类 Future 实现 TryFuture:

#![allow(unused)]
fn main() {
impl<F, T, E> TryFuture for F
where
    F: ?Sized + Future<Output = Result<T, E>>,
{
	...
}
}
  • 这个 Future03 需要是 Unpin 的。Pin 解析起来比较复杂,这里就先不进行解析了,留到后面。 所以如果当前的 Future03 既不是 TryFuture,也不是 Unpin 的。那要怎么转 Future01 呢?还是利用组合子就可以解决了。

unit_error 组合子可以通过将 Future03<Ouput=T> 转换为Future03<Output=Result<T, ()>> 解决问题1;
而 boxed 组合子则可以通过将其放到堆区的方法使之满足 Unpin,从而解决问题2。

use futures03::future::{FutureExt, TryFutureExt};
use futures::Future;

async fn make_fut03() -> i32 {
    42
}

fn main() {
    // 这是一个既不是TryFuture也不满足Unpin的Future
    let fut03 = make_fut03();
    // let fut01 = fut03.compat(); 错误做法,编译时会报不满足trait限制的错
    let fut01 = fut03.unit_error().boxed().compat();
    let res = fut01.wait();
    println!("the fut01 res:{}", res.unwrap());
}

3. 原理分析

Compat01As03 分析

接下来,按照以上思路简单分析一下在 futures03::compat 的具体实现,先介绍 Future01 到 Future03 的转换,主要逻辑为位于 compat/compat01as03.rs 中。

  • 关于关联类型和 Poll 的转换:

#![allow(unused)]
fn main() {
#[derive(Debug)]
#[must_use = "futures do nothing unless you `.await` or poll them"]
pub struct Compat01As03<T> {
    pub(crate) inner: Spawn01<T>,
}

impl<Fut: Future01> Future03 for Compat01As03<Fut> {
    type Output = Result<Fut::Item, Fut::Error>;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> task03::Poll<Self::Output> {
        poll_01_to_03(self.in_notify(cx, Future01::poll))
    }
}

fn poll_01_to_03<T, E>(x: Result<Async01<T>, E>) -> task03::Poll<Result<T, E>> {
    match x? {
        Async01::Ready(t) => task03::Poll::Ready(Ok(t)),
        Async01::NotReady => task03::Poll::Pending,
    }
}
}

可以看到,Compat01As03 被实现为 Future03<Output = Result<Fut::Item, Fut::Error>>,poll 返回值的转换逻辑也很比较简单;

  • 关于 Pin,直接为 Compat01As03 实现了 Unpin。个人看法,在这个源文件中没发现对 T 为 Unpin 的限制,或许是因为是当前如果要自行实现自引用结构,只能使用 unsafe 的缘故?

#![allow(unused)]
fn main() {
impl<T> Unpin for Compat01As03<T> {}
}
  • 将 Waker 转换为 NotifyHandle:

#![allow(unused)]
fn main() {
struct NotifyWaker(task03::Waker);

#[allow(missing_debug_implementations)]
#[derive(Clone)]
struct WakerToHandle<'a>(&'a task03::Waker);

impl From<WakerToHandle<'_>> for NotifyHandle01 {
    fn from(handle: WakerToHandle<'_>) -> Self {
		// 将waker放到堆区
        let ptr = Box::new(NotifyWaker(handle.0.clone()));
		
        // 获取指针放到NotifyHandle中
        unsafe { Self::new(Box::into_raw(ptr)) }
    }
}

// 将waker封装一层后为其实现Notify,
// notify方法的实现其实就是直接调用waker.wake_by_ref()
impl Notify01 for NotifyWaker {
    fn notify(&self, _: usize) {
        self.0.wake_by_ref();
    }
}


// 在上一节所列的源码中,可以看到in_notify的身影,在这里看到:
// in_notify的作用就是将Waker转化而成的NotifyHanle传入到Spawn::poll_fn_notify中,
// 之后的过程就可以参考之前的流程图了
fn in_notify<R>(&mut self, cx: &mut Context<'_>, f: impl FnOnce(&mut T) -> R) -> R {
    let notify = &WakerToHandle(cx.waker());
    self.inner.poll_fn_notify(notify, 0, f)
}
}

如上所述,主要实现思路还是比较简单的,将 Waker 放到堆区,然后获取其指针放到 NotifyHanle,然后将构建出的 NotifyHanle 传到 Spawn::poll_fn_notify 中。这样在调用 task.notify() 对内层 Futures01 进行唤醒时,就能转而调用外层 Compat01As03 的 waker.wake_by_ref(),对其进行唤醒,其他相关函数同理。

Compat 分析

Future03 到 01 的思路也是比较类似的,主要逻辑位于 compat/compat03as01.rs。

  • 关于关联类型和Poll的转换:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy)]
#[must_use = "futures do nothing unless you `.await` or poll them"]
pub struct Compat<T> {
    pub(crate) inner: T,
}

fn poll_03_to_01<T, E>(x: task03::Poll<Result<T, E>>) -> Result<Async01<T>, E> {
    match x? {
        task03::Poll::Ready(t) => Ok(Async01::Ready(t)),
        task03::Poll::Pending => Ok(Async01::NotReady),
    }
}

impl<Fut> Future01 for Compat<Fut>
where
	// 注意这里,对Fut进行了限制,需要实现TryFuture03和Unpin
    Fut: TryFuture03 + Unpin,
{
    type Item = Fut::Ok;
    type Error = Fut::Error;

    fn poll(&mut self) -> Poll01<Self::Item, Self::Error> {
        with_context(self, |inner, cx| poll_03_to_01(inner.try_poll(cx)))
    }
}
}

可以看到,Compat 被实现为 Future01<Item = Fut::Ok, Error = Fut::Error>,poll 返回值的转换逻辑同样比较简单。从源码中可以看出 Fut 需要实现 TryFuture03 和 Unpin,这就能解析为什么 Future03 转 01 需要用到unit_error 和 boxed 组合子(详细可看第一章)。

  • 关于 Pin,Future03 一般是由 async/await 自动生成而来的,而入本章第一节所示,该自动生成的 Future 是少数由rust 自动实现 !Unpin 的类型之一,所以这里进行了限制,只会有关联类型 T 满足 Unpin的Compat 实现Future01。

当想要将 async/await 自动生成的 Future03(大多数情况下) 转化为 Future01 时,必须先调用boxed 组合子 重建构建一个满足 Unpin的 新 Future03。

  • 将 NotifyHandle 转换为 Waker:

#![allow(unused)]
fn main() {
#[derive(Clone)]
struct Current(task01::Task);

impl Current {
    fn new() -> Self {
       	// 注意了, 这里是不是很眼熟, task::current()可以获取当前的Task结构,
        // 然后利用其构建Current结构,并转换为空指针在构建RawWaker时候传入,
        // 最后再经由vtable调用并传参到wake中,最终进行熟悉的task.notify()调用。
        Self(task01::current())
    }

    fn as_waker(&self) -> WakerRef<'_> {
        // 对指针解引用后获取再借用
        unsafe fn ptr_to_current<'a>(ptr: *const ()) -> &'a Current {
            &*(ptr as *const Current)
        }

        // 将self转为void*
        fn current_to_ptr(current: &Current) -> *const () {
            current as *const Current as *const ()
        }
		
        // vtable中需要实现的方法 ////////////////////////////////
        unsafe fn clone(ptr: *const ()) -> RawWaker {
   			...
        }

        unsafe fn drop(_: *const ()) {}

        unsafe fn wake(ptr: *const ()) {
            ptr_to_current(ptr).0.notify()
        }
		////////////////////////////////////////////////////////
       	
        // 将self转化为空指针,主要为了之后构建RawWaker时传入
        let ptr = current_to_ptr(self);

        // 构建vtable
        let vtable = &RawWakerVTable::new(clone, wake, wake, drop);
        
        // 构建RawWaker,构建Waker
        WakerRef::new_unowned(std::mem::ManuallyDrop::new(unsafe {
            task03::Waker::from_raw(RawWaker::new(ptr, vtable))
        }))
    }
}
}

在注释中已经说的较为详细了,具体的思路和 Compat01As03 是类似的,为了能在调用内层 Future03 waker.wake()/waker.wake_by_ref() 时,能转而调用到外层 Compat 的 task.notify(),从而对其进行唤醒,其他相关函数亦同理。

4. 一些实践经验

如何较好地进行局部升级

在这次的任务中,我需要对特定路径进行 futures 0.1 到 0.3 的升级,以适配想要加入到项目中的 crate。我想趁机在相关路径上引入 async/await,以提高代码可读性和可维护性。在实现的过程中,主要遇到了两个问题:

  • 当前 Trait 不支持 async fn;
  • 还需要保持旧的 runtime,因为如果升级 runtime 的话,影响太大(几乎要对整个项目所有相关的地方进行升级);

对于 Trait 不支持 async fn 的问题,暂时可以通过使用第三方库 async-trait 来解决,使用方法很简单。其大致原理是,async_trait 宏将代码转换为一个返回 Pin<Box<dyn Future + Send + 'async>> 的同步方法。

use futures03::executor::block_on;
use async_trait::async_trait;

#[async_trait]
trait TestTrait {
    async fn method1(&self);
    async fn method2(&self);
}

struct TestStruct;

#[async_trait]
impl TestTrait for TestStruct {
    async fn method1(&self) {
        println!("I am async method1");
    }

    async fn method2(&self) {
        println!("I am async method2");
    }
}

fn main() {
    let ts = TestStruct;
    block_on(ts.method1());
    block_on(ts.method2());
}

那么如何有效地在保持旧版本 runtime 的情况下,将调用路径升级到 async/await 方式呢?个人比较推荐将最底层返回的 Future01 使用 compat 组合子转为 03,然后中间路径一直使用 async/await,再在最上层再次使用 compat 等组合子再次转为 Future01,放入到旧版 runtime 执行,示例如下:

use futures::Future;
use futures03::compat::Future01CompatExt;
use futures03::{FutureExt, TryFutureExt};
use futures_cpupool::CpuPool;

type BoxedFuture<T> = Box<dyn Future<Item = T, Error = ()> + Send>;

fn func1() -> BoxedFuture<i32> {
    Box::new(futures::future::ok(42))
}

async fn func2() -> Result<i32, ()> {
    // convert to Future 0.3
    let res1_compat_03 = func1().compat().await?;
    Ok(res1_compat_03 + 42)
}

async fn func3() -> Result<i32, ()> {
    let res2_03 = func2().await?;
    Ok(res2_03 + 42)
}

fn main() {
    let pool = CpuPool::new(4);

    let fut03 = func3();
    // convert back to Future 0.1
    let fut01 = fut03.boxed().compat();

    // spawn to the old runtime
    let res = pool.spawn(fut01).wait();
    
    println!("res:{}", res.unwrap());
}

返回 Future 的函数和 async fn 的区别

在 futures 0.1 中,由于没有 async fn 所以返回 Future 的函数是一种比较普遍的写法,例如:


#![allow(unused)]
fn main() {
type BoxedFuture<T> = Box<dyn Future<Item = T, Error = ()> + Send>;

fn func1() -> BoxedFuture<i32> {
    Box::new(futures::future::ok(42))
}
}

但这和 async fn 其实是有着微妙的区别,借用 minitrace-rust crate 来进行示例(这里返回的是 Future03,因为这是 01 和 03 共有的区别,用 01 来写这例子的话要多次来回 compat,代码非常绕)

use futures03::{Future as Future03, FutureExt};
use minitrace::{CollectArgs, FutureExt as MiniFutureExt, Span};
use std::pin::Pin;

type BoxedFuture<T> = Pin<Box<dyn Future03<Output=T> + Send>>;

async fn func1() -> i32 {
    42
}

// async fn func2() -> i32 {
//     func1().in_span(Span::from_local_parent("func1")).await
// }

fn func2() -> BoxedFuture<i32> {
    func1().in_span(Span::from_local_parent("func1")).boxed()
}

#[tokio::main]
async fn main() {
    let (span, collector) = Span::root("func2");
    let f = func2().in_span(span);

    tokio::spawn(f).await.unwrap();

    let spans = collector.collect_with_args(CollectArgs::default().sync(true));

    for span in spans {
        println!("span: event:{}, id:{}, pid:{}", span.event, span.id, span.parent_id);
    }
}


#当前结果:
span: event:func2, id:1, pid:0

#注释掉func2,而去除async func2注释后结果:
span: event:func1, id:65537, pid:1
span: event:func2, id:1, pid:0

可以看出,当把 async fn func2() -> i32 改成 func2() -> BoxedFuture 后,func1 相关的 span 生成失败了 (Span::from_local_parent("func1")),这是为什么呢?

我刚开始遇到这个问题时也被搞晕了,后来用调试器进行跟踪,发现问题主要在于各层次 Future 生成和 poll() 执行顺序方面。

  • async fn 情况下的大致执行顺序,GenFutureFuncx 表示 funcx 所生成或返回的 Future:

afunc2.png

  • 返回 Future 的 fn 情况下的可能执行顺序:

sfunc2.png

正如流程图中所示,导致最终结果差别的,正是 Span::from_local_parent() 和 最外层 InSpan::poll() 执行的先后顺序不同(在上述流程图中对这两个步骤进行了相关标注)。

因为经过在 InSpan::poll() 中会将进行一些操作,产生一个与当前线程绑定的 span_id 作为所谓的local_parent,而在返回 Future 的情况下,Span::from_local_parent() 先于 最外层 InSpan::poll() 执行从而导致了前者的执行失败,因为 local_parent 在其执行时根本还没被构建。

这个差别比较隐蔽,一般也不会造成影响,毕竟 Future::poll() 的执行顺序还是正确的,但在以上例子中还是使执行结果产生了差异,还是值得稍微知道一下的。

5. 总结

以上为本人对 futures 兼容层的一些理解和经验,之前由于要进行这方面的工作,看了比较多的相关资料,但一直没空进行梳理。这次重新看了一下并进行总结,感觉还是收获颇大的。

参考

  1. 异步代码的几种写法 Rust学习笔记
  2. Compatibility Layer
  3. Rust - Future与异步
  4. TiKV Rust Client 迁移记 - Futures 0.1 至 0.3
  5. Rust的Pin与Unpin
  6. Async/Await
  7. Rust Async: Pin概念解析
  8. rfcs - 2349

字节跳动 | 飞书 Rust 实践 之 SQLite 适配 WASM

作者:木色 / 后期编辑:张汉东


飞书在做WASM的适配,分享下关系型数据库SQLite适配WASM的历程。

SQLite是一个跨平台的关系型数据库,广泛使用于客户端开发,飞书也使用SQLite作为数据持久化存储;同时为了方便上层使用,采用了diesel作为orm与SQLite进行交互,整体使用方式为:

rust code -> diesel orm -> sqlite ffi

调用情况如图:

1.png

为了将SQLite移植到WEB上,我们需要做两部分内容:

  1. 将sqlite编译到wasm平台
  2. 封装wasm平台的跨模块调用接口给diesel使用

考虑到WEB上持久化存储机制的脆弱,以及业务形态考量,在WEB上并不需要做持久化,暂时只做一个内存中的关系型数据库;确定好这几个特点要求,我们SQLite的WASM移植之路开始了,Let's Go!

WASM的工作模式

目前WASM实际有三种工作模式:Emscripten模式、WASI模式和无任何依赖的纯粹模式,在Rust语言中,分别对应wasm32-unknown-emscriptenwasm32-wasiwasm32-unknown-unknown三种编译目标;前两种模式的wasm产物分别需要宿主提供posix接口和wasi接口功能,最后一种模式完全没有外部依赖

对于这三种模式,对C/C++代码的友好度:Emscripten>Wasi>>Unknown

rust社区的生态基本是围绕着wasm32-unknown-unknownwasm32-wasi构建的,如wasm-bindgen工具等;不过考虑到unknown环境对外部依赖少,所以sdk中的rust代码我们就先确定了,优先使用wasm32-unknown-unknown模式,wasm32-wasi模式次之。而对于sqlite部分,我们则将三种wasm工作模式都尝试了:

Emscripten模式适配

Emscripten是用于帮助将C/C++代码编译到WASM目标格式的工具链,并且提供posix相关调用的模拟功能。

编译出emscripten产物

SQLite是一个C库,用emscripten很方便地就可以将SQLite编译为wasm,这个过程很简单,使用emcc就可以直接编译(可以参考:https://github.com/sql-js/sql.js/blob/master/Makefile)

第一步编译sqlite到wasm轻松搞定:我们将sqlite编译为一个emscripten target的wasm实例,由前端负责加载;然后在sdk侧,通过wasm的abi接口调用sqlite wasm实例提供的接口。

SQLite接口调用

但是到第二步,提供wasm的ffi给diesel时,我们遇到了麻烦:默认diesel使用的libsqlite-sys提供的是C abi的ffi,在native环境,SQLite库和ffi的使用者共享同一个内存空间,所以很多事情都比较容易处理,比如内存分配或者指针的直接操作等;但是如果sqlite编译成一个单独的wasm实例,ffi部分作为一个独立的wasm实例调用sqlite时,两个wasm实例在不同的内存空间,不能直接使用指针等依赖相同内存空间的操作,这就导致emscripten target下的ffi调用流程都需要全新实现。

使用类似动态库方式调用

具体表现为:先异步启动sqlite wasm实例,并将实例导出的接口挂在js全局对象window上,然后在rust中通过wasm-bindgen来绑定这些js接口。比如sqlite连接db创建db连接时传入db路径的操作,wasm环境需要调用wasm的内存分配函数分配内存,并写入数据:


#![allow(unused)]
fn main() {
// native版操作

pub fn establish(raw_database_url: &str) -> ConnectionResult<Self> {

    let mut conn_pointer = ptr::null_mut();

    let database_url = CString::new(raw_database_url.trim_start_matches("sqlite://"))?;

    let connection_status = unsafe { ffi::sqlite3_open(database_url.as_ptr(), &mut conn_pointer) };

    ...

}

// wasm版操作

#[wasm_bindgen]

extern "C" {

    // sqliteBindings是挂在window上的全局对象

    // allocateUTF8、stackAlloc是emscripten wasm导出的字符串、栈内存分配接口

    #[wasm_bindgen(js_namespace = sqliteBindings, js_name = allocateUTF8)]

    pub fn allocate_utf8(s: &str) -> *const i8;

    #[wasm_bindgen(js_namespace = sqliteBindings, js_name = stackAlloc)]

    pub fn stack_alloc_sqlite3(size: usize) -> *mut *mut ffi::sqlite3;

}

pub fn establish(raw_database_url: &str) -> ConnectionResult<Self> {

    let conn_pointer = stack_alloc_sqlite3(0);

    let database_url_ptr = allocate_utf8(raw_database_url.trim_start_matches("sqlite://"));

    let connection_status = unsafe { ffi::sqlite3_open(database_url_ptr, conn_pointer) };

    ...

}
}

对于diesel中用到sqlite的地方都类似添加wasm支持,我们实现了在emscripten模式下工作的diesel+sqlite,其数据流动方式为:

2.png

这种工作模式下,sqlite是一个独立的wasm实例,其他的lark sdk代码是一个实例,实际运行时,先由js代码加载sqlite的wasm实例,再加载sdk的wasm实例,之后sdk中的diesel代码通过封装好的交互接口调用sqlite实例的功能。

这个工作模式下,每次sqlite的调用都涉及到两个wasm实例间数据的拷贝(不同wasm实例的内存空间是独立的),对于db这种高频率数据调用场景来说开销过大。

因此我们考虑:能否能将sqlite实例和其他sdk实例代码合并生成一个wasm实例?如果sqlite是一个emscripten模式的wasm,sdk其他代码就也必须打成emscripten模式,但是如前面所述,rust的wasm生态的核心是wasm32-unknown-unknownwasm32-wasi,所以如果想做到一个实例包含sdk代码和sqlite,就不能使用wasm32-unknown-emscripten模式。另外,在wasm32-wasiwasm32-unknown-unknown模式下,我们可以使用C的abi,也就是不需要如emscripten模式的wasm接口封装,可以类似native平台下的方式从rust调用sqlite。

WASI模式适配

在优化sdk和sqlite为一个实例的实践中,我们排除了Emscripten模式的使用;而在wasi和unknown模式中,wasi是一个对C/C++代码更加友好的平台,wasi标准中的接口和posix比较接近。

但是wasi目前一般是在非WEB平台执行的,想要在web上跑就需要提供wasi需要的对应功能的模拟,幸运的是,社区已经有了对应功能:https://github.com/wasmerio/wasmer-js/tree/master/packages/wasi

运行的宿主环境搞定了,我们来看sqlite本身;目前sqlite是没有提供wasi的官方支持的,但是sqlite有一个非常灵活的架构:

3.png

SQLite将所有平台相关的操作都封装在了OS对应模块中,并且通过VFS的方式抽象了平台功能使用,那么只要我们实现一个在WASI模式下工作的vfs即可

直接参考官方的实现https://www.sqlite.org/src/doc/trunk/src/test_demovfs.c, 编译时打开SQLITE_OS_OTHER选项,并链接到我们对应的C语言实现的vfs,配合wasmer-js的wasi模拟,终于,我们将sqlite和sdk其他代码都打成一个wasm32-wasi模式的wasm实例。

但是。。。

一次升级rust版本后,发现wasm-bindgen不再工作了。。。详情见:https://github.com/rustwasm/wasm-bindgen/issues/2471, 问题原因是2021年1月13日,rust合并了一个更改wasi模式下abi格式的提交,在这个之前rust下wasi模式和unknown模式的abi是一致的,但是这个提交之后,两者分叉了,而且wasm-bindgen官方也没有适配wasi的计划。。。

所以现在留给我们的只剩下一条路了:wasm32-unknown-unknown

Unknown模式适配

unknown模式是对C/C++最不友好的模式:没有头文件声明、没有字符串操作方法、没有fd相关方法甚至连malloc都没有。。。不过只有这一条路了,见山开山,遇海填海

有三个功能需要提供在Unknown模式下工作的实现:内存分配器、用到的C函数、VFS实现

内存分配器适配

C语言在wasm32-unknown-unknown模式下是没有提供malloc的封装的,但是rust里面有内存相关的封装,那么我们可以在rust中实现malloc方法供sqlite链接后调用:


#![allow(unused)]
fn main() {
// 为了最小影响,更改了malloc的调用名

#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]

mod allocator {

    use std::alloc::{alloc, dealloc, realloc as rs_realloc, Layout};

    #[no_mangle]

    pub unsafe fn sqlite_malloc(len: usize) -> *mut u8 {

        let align = std::mem::align_of::<usize>();

        let layout = Layout::from_size_align_unchecked(len, align);

        let ptr = alloc(layout);

        ptr

    }

    const SQLITE_PTR_SIZE: usize = 8;

    #[no_mangle]

    pub unsafe fn sqlite_free(ptr: *mut u8) -> i32 {

        let mut size_a = [0; SQLITE_PTR_SIZE];

        size_a.as_mut_ptr().copy_from(ptr, SQLITE_PTR_SIZE);

        let ptr_size: u64 = u64::from_le_bytes(size_a);

        let align = std::mem::align_of::<usize>();

        let layout = Layout::from_size_align_unchecked(ptr_size as usize, align);

        dealloc(ptr, layout);

        0

    }

    #[no_mangle]

    pub unsafe fn sqlite_realloc(ptr: *mut u8, size: usize) -> *mut u8 {

        let align = std::mem::align_of::<usize>();

        let layout = Layout::from_size_align_unchecked(size, align);

        rs_realloc(ptr, layout, size)

    }

}
}

libc功能提供

打开SQLITE_OS_OTHER开关后,因为不再使用系统的接口,对libc的依赖已经少了很多,但是还有几个基础非系统函数依赖:

strcspn
strcmp/strncmp
strlen
strchr/strrchr
qsort

字符串的几个函数非常简单,直接自己实现就行;而对最后一个qsort函数,拷贝许可证宽松的三方实现就行

VFS实现

emscripten和wasi都是利用宿主提供的虚拟文件系统进行操作,在unknown模式下为了不增加外部依赖,我们可以直接在sdk代码内部提供一个memory vfs供sqlite使用。

实现vfs的核心是提供两个结构体实现:

typedef struct sqlite3_vfs sqlite3_vfs;

typedef void (*sqlite3_syscall_ptr)(void);

struct sqlite3_vfs {

    int iVersion; /* Structure version number (currently 3) */

    int szOsFile; /* Size of subclassed sqlite3_file */

    int mxPathname; /* Maximum file pathname length */

    sqlite3_vfs *pNext; /* Next registered VFS */

    const char *zName; /* Name of this virtual file system */

    void *pAppData; /* Pointer to application-specific data */

    int (*xOpen)(sqlite3_vfs*, const char *zName, sqlite3_file*,

    int flags, int *pOutFlags);

    int (*xDelete)(sqlite3_vfs*, const char *zName, int syncDir);

    int (*xAccess)(sqlite3_vfs*, const char *zName, int flags, int *pResOut);

    int (*xFullPathname)(sqlite3_vfs*, const char *zName, int nOut, char *zOut);

    void *(*xDlOpen)(sqlite3_vfs*, const char *zFilename);

    void (*xDlError)(sqlite3_vfs*, int nByte, char *zErrMsg);

    void (*(*xDlSym)(sqlite3_vfs*,void*, const char *zSymbol))(void);

    void (*xDlClose)(sqlite3_vfs*, void*);

    int (*xRandomness)(sqlite3_vfs*, int nByte, char *zOut);

    int (*xSleep)(sqlite3_vfs*, int microseconds);

    int (*xCurrentTime)(sqlite3_vfs*, double*);

    int (*xGetLastError)(sqlite3_vfs*, int, char *);

    /*

    ** The methods above are in version 1 of the sqlite_vfs object

    ** definition. Those that follow are added in version 2 or later

    */

    int (*xCurrentTimeInt64)(sqlite3_vfs*, sqlite3_int64*);

    /*

    ** The methods above are in versions 1 and 2 of the sqlite_vfs object.

    ** Those below are for version 3 and greater.

    */

    int (*xSetSystemCall)(sqlite3_vfs*, const char *zName, sqlite3_syscall_ptr);

    sqlite3_syscall_ptr (*xGetSystemCall)(sqlite3_vfs*, const char *zName);

    const char *(*xNextSystemCall)(sqlite3_vfs*, const char *zName);

    /*

    ** The methods above are in versions 1 through 3 of the sqlite_vfs object.

    ** New fields may be appended in future versions. The iVersion

    ** value will increment whenever this happens.

    */

};

typedef struct sqlite3_io_methods sqlite3_io_methods;

struct sqlite3_io_methods {

    int iVersion;

    int (*xClose)(sqlite3_file*);

    int (*xRead)(sqlite3_file*, void*, int iAmt, sqlite3_int64 iOfst);

    int (*xWrite)(sqlite3_file*, const void*, int iAmt, sqlite3_int64 iOfst);

    int (*xTruncate)(sqlite3_file*, sqlite3_int64 size);

    int (*xSync)(sqlite3_file*, int flags);

    int (*xFileSize)(sqlite3_file*, sqlite3_int64 *pSize);

    int (*xLock)(sqlite3_file*, int);

    int (*xUnlock)(sqlite3_file*, int);

    int (*xCheckReservedLock)(sqlite3_file*, int *pResOut);

    int (*xFileControl)(sqlite3_file*, int op, void *pArg);

    int (*xSectorSize)(sqlite3_file*);

    int (*xDeviceCharacteristics)(sqlite3_file*);

    /* Methods above are valid for version 1 */

    int (*xShmMap)(sqlite3_file*, int iPg, int pgsz, int, void volatile**);

    int (*xShmLock)(sqlite3_file*, int offset, int n, int flags);

    void (*xShmBarrier)(sqlite3_file*);

    int (*xShmUnmap)(sqlite3_file*, int deleteFlag);

    /* Methods above are valid for version 2 */

    int (*xFetch)(sqlite3_file*, sqlite3_int64 iOfst, int iAmt, void **pp);

    int (*xUnfetch)(sqlite3_file*, sqlite3_int64 iOfst, void *p);

    /* Methods above are valid for version 3 */

    /* Additional methods may be added in future releases */

};

使用rust实现vfs

实现一个memvfs,至少需要一个可动态调整的容器;而C语言官方没有这种容器,如果要使用C语言实现memvfs,那只能自己实现一个类似的HashMap或者LinkedList,稍显麻烦;所以这块逻辑也用rust实现了。

VFS绑定

在rust代码中,提供一个sqlite3_os_init的方法出来,在和sqlite链接时,会自动链接到这个函数


#![allow(unused)]
fn main() {
#[no_mangle]

pub unsafe fn sqlite3_os_init() -> std::os::raw::c_int {
    let mut mem_vfs = Box::new(super::memvfs::get_mem_vfs());

    let mem_vfs_ptr: *mut crate::sqlite3_vfs = mem_vfs.as_mut();

    let rc = crate::sqlite3_vfs_register(mem_vfs_ptr, 1);

    debug!("sqlite3 vfs register result: {}", rc);

    std::mem::forget(mem_vfs);

    rc
}
}

内存数据存储容器

因为要支持多个路径,所以最简单的实现就是提供一个HashMap,使用路径作为key:


#![allow(unused)]
fn main() {
struct Node {
    size: usize,
    data: Vec<u8>,
}

lazy_static! {
    static ref FS: RwLock<HashMap<String, Arc<RwLock<Node>>>> = RwLock::new(HashMap::new());
}
}

数据读写接口:


#![allow(unused)]
fn main() {
fn copy_out(&self, dst: *mut raw::c_void, offset: isize, count: usize) -> Option<()> {
        if self.size < offset as usize + count {
            log::trace!("handle invalid input offset");
            return None;
        }

        let ptr = self.data.as_ptr();

        let dst = dst as *mut u8;

        unsafe {
            let ptr = ptr.offset(offset);
            ptr.copy_to(dst, count);
        }

        Some(())
 }

 fn write_in(&mut self, src: *const raw::c_void, offset: isize, count: usize) {
        let new_end = offset as usize + count;
        
        // 这里注意要根据传入的offset做扩容
        let count_extend: isize = new_end as isize - self.data.len() as isize;
        if count_extend > 0 {
            self.data.extend(vec![0; count_extend as usize]);
        }

        if new_end > self.size {
            self.size = new_end;
        }

        let ptr = self.data.as_mut_ptr();

        unsafe {
            let ptr = ptr.offset(offset);
            ptr.copy_from(src as *const u8, count);
        }
 }
}

VFS实现

sqlite3_vfsxOpen方法实现中注册对应自定义的sqlite3_io_methods

借助上述的工作,我们最终将sqlite编译为wasm32-unknown-unknown模式下的wasm文件,同时上层使用可以直接复用diesel,做到业务代码不用改动。

至此,lark sdk在web上的工作模式为:

4.png

整体工作模式再次和native平台对齐了,无外部依赖,查询时也不需要wasm实例之间的数据拷贝。

Datenlord | Rust实现RDMA异步编程(二):async Rust 封装 UCX 通信库

作者:王润基


UCX 是一个高性能网络通信库,它作为 MPI 所依赖的通信模块之一在高性能计算领域得到广泛的使用。UCX 使用 C 语言编写,为了在 Rust 项目中使用它,我们需要将它的 C 接口包装成 Rust 库。在这个过程中我们充分利用了 Rust 的杀手级特性—— async-await 协程来包装异步 IO 接口,从而极大降低了应用的编程复杂度。

去年我们用 Rust 实现的高性能分布式文件系统 MadFS,底层就使用了我们自己包装过的 UCX 作为通信模块,它在大规模 RDMA 网络上展现出了良好的性能。UCX 官方在得知这一消息后也很开心地宣传了我们这个项目 :)

1

本文首先会介绍一下 UCX 通信库的功能和编程模型,然后介绍我们用 async Rust 封装 UCX 的过程,具体代码可以参考 GitHub 仓库:async-ucx。值得注意的是,这里介绍的 IO 模型和封装异步 IO 的方法是通用的,可以适用到其它 IO 库当中。

UCX 通信接口简介

UCX 的全称是 Unified Communication X。正如它名字所展示的,UCX 旨在提供一个统一的抽象通信接口,能够适配任何通信设备,并支持各种应用的需求。

下图是 UCX 官方提供的架构图:

2

可以看到,UCX 整体分为两层:上层的 UCP 接口和底层的 UCT 接口。

底层的 UCT 适配了各种通信设备:从单机的共享内存,到常用的 TCP Socket,以及数据中心常用的 RDMA 协议,甚至新兴的 GPU 上的通信,都有很好的支持。

上层的 UCP 则是在 UCT 不同设备的基础上,封装了更抽象的通信接口,以方便应用使用。具体来说有以下几类:

  • Active Message:最底层的接口,提供类似 RPC 的语义。每条 Active Message 会触发接收端进行一些操作。
  • RMA / Atomic:是对远程直接内存访问(RDMA)的抽象。通信双方可以直接读写远端的内存,但是需要有额外的内存注册过程。
  • Tag Matching:常用于高性能计算 MPI 程序中。每条消息都会附带一个 64 位整数作为 tag,接收方每次可以指定接收哪种 tag 的消息。
  • Stream:对字节流(TCP)的抽象。

一般来说,和底层通信设备模型最匹配的接口具有最高的性能,其它不匹配的接口都会有一次软件转换过程。另一方面,同一种 UCP 接口发送不同大小的消息可能也会使用不同的 UCT 方法。例如在 RDMA 网络中,由于内存注册也有不小的开销,因此对于小消息来说,拷贝到预注册好的缓冲区再发送的性能更高。这些策略默认是由 UCX 自己决定的,用户也可以通过设置环境变量的方式手动修改。

在我们的系统中,使用了 UCP Tag 接口并基于此实现了轻量级的 RPC。在 RPC 场景下,Tag 可以用于区分不同上下文的消息:每个链接双方首先随机生成一个 tag 作为请求的标识,对于每次请求再随机生成一个 tag 作为回复的标识。此外 Tag 接口还支持 IO Vector,即将不连续的多个内存段合并成一个消息发送。这个特性可以用来将用户提供的数据缓冲区和 RPC 请求打包在一起,一定程度上避免数据拷贝。

UCX 编程模型简介

UCX 采用了以异步 IO 为核心的编程模型。其中 UCP 层定义的核心对象有以下四种:

  • Context:全局资源的上下文,管理所有通信设备。一般每个进程创建一个即可。
  • Worker:任务的管理调度中心,以轮询方式执行任务。一般每个线程创建一个,会映射为网卡上的一个队列。
  • Listener:类似 TCP Listener,用来在 worker 之间创建连接。
  • Endpoint:表示一个已经建立的连接。在此之上提供了各种类型的通信接口。

它们之间的所属关系如下图所示:

3

建立连接

UCX 中双方首先要建立连接,拿到一个 Endpoint 之后才能进行通信。建立连接一般要通过 Listener,过程和 TCP 比较类似:

通信双方 A/B 首先建立各自的 Context 和 Worker,其中一方 A 在 Worker 上创建 Listener 监听连接请求,Listener 的地址会绑定到本机的一个端口上。用户需要通过某种方法将这个地址传递给另一方 B。B 拿到地址后在 Worker 上发起 connect 操作,此时 A 会收到新连接请求,它可以选择接受或拒绝。如果接受则需要在 Worker 上 accept 这个请求,将其转换为 Endpoint。之后 B 会收到 A 的回复,connect 操作完成,返回一个 Endpoint。此后双方就可以通过这对 Endpoint 进行通信了。

内存注册

对于常规的通信接口,用户可以直接在 Endpoint 上发起请求。但对于 RMA(远程内存访问)操作,需要被访问的一方首先在自己的 Context 上注册内存,同时指定访问权限,获得一个 Mem handle。然后将这个本地 handle 转化为其他节点可以访问的一串 token,称为 remote key(rkey)。最后想办法把 rkey 传给远端。远端拿着这个 rkey 进行远程内存访问操作。

异步任务处理(重点)

为了发挥最高的性能,整个 UCX 通信接口是全异步的。所谓异步指的是 IO 操作的执行不会阻塞当前线程,一次操作的发起和完成是独立的两个步骤。如此一来 CPU 就可以同时发起很多 IO 请求,并且在它们执行的过程中可以做别的事情。

不过接下来问题来了:程序如何知道一个异步任务是否完成了?常见的有两种做法:主动轮询,被动通知。前者还是需要占用 CPU 资源,所以一般都采用通知机制。在 C 这种传统过程式语言中,异步完成的通知一般通过 **回调函数(callback)**实现:每次发起异步操作时,用户都需要传入一个函数指针作为参数。当任务完成时,后台的运行时框架会调用这个函数来通知用户。下面是 UCX 中一个异步接收接口的定义:

ucs_status_ptr_t ucp_tag_recv_nb (
  ucp_worker_h worker,
  void ∗ buffer,
  size_t count,
  ucp_datatype_t datatype,
  ucp_tag_t tag,
  ucp_tag_t tag_mask,
  ucp_tag_recv_callback_t cb  // <-- 回调函数
);

// 回调函数接口的定义
typedef void(∗ ucp_tag_recv_callback_t) (
  void ∗request, 
  ucs_status_t status,        // 执行结果,错误码
  ucp_tag_recv_info_t ∗info   // 更多信息,如收到的消息长度等
);

这个接口的语义是:发起一个异步 Tag-Matching 接收操作,并立即返回。当真的收到 tag 匹配的消息时,UCX 后台会处理这个消息,将其放到用户提供的 buffer 中,最后调用用户传入的 callback,通知用户任务的执行结果。

这里有一个很重要的问题是:上面提到的“后台处理”到底是什么时候执行的?答案是 UCX 并不会自己创建后台线程去执行它们,所有异步任务的后续处理和回调都是在 worker.progress() 函数中,也就是用户主动向 worker 轮询的过程中完成的。这个函数的语义是:“看看你手头要处理的事情,有哪些是能做的?尽力去推动一下,做完的通知我。” 换句话说,Worker 正在处理的所有任务组成了一个状态机,progress 函数的作用就是用新事件推动整个状态机的演进。后面我们会看到,对应到 async Rust 世界中,所有异步 IO 任务组成了最基础的 Future,worker 对应 Runtime,而 progress 及其中的回调函数则充当了 Reactor 的角色。

回到传统的 C 语言,在这里异步 IO 的最大难点是编程复杂性:多个并发任务在同一个线程上交替执行,只能通过回调函数来描述下一步做什么,会使得原本连续的执行逻辑被打散到多个回调函数中。本来局部变量就可以维护的状态,到这里就需要额外的结构体来在多个回调函数之间传递。随着异步操作数量的增加,代码的维护难度将会迅速上升。下面的伪代码展示了在 UCX 中如何通过异步回调函数来实现最简单的 echo 服务:

// 由于 C 语言语法的限制,这段代码需要从下往上读

// 这里存放所有需要跨越函数的状态变量
struct CallbackContext {
  ucp_endpoint_h ep;
  void *buf;
} ctx;

void send_cb(void ∗request, ucs_status_t status) {
  //【4】发送完毕
  ucp_request_free(request);
  exit(0);
}

void recv_cb(void ∗request, ucs_status_t status, ucp_tag_recv_info_t ∗info) {
  //【3】收到消息,发起发送请求
  ucp_tag_send_nb(ctx->ep, ctx->buf, info->length, ..., send_cb);
  ucp_request_free(request);
}

int main() {
  // 省略 UCX 初始化部分
  //【0】初始化任务状态
  ctx->ep = ep;
  ctx->buf = malloc(0x1000);
  //【1】发起异步接收请求
  ucp_tag_recv_nb(worker, ctx->buf, 0x1000, ..., recv_cb);
  //【2】不断轮询,驱动后续任务完成
	while(true) {
    ucp_worker_progress(worker);
  }
}

作为对比,假如 UCX 提供的是同步接口,那么同样的逻辑只需要以下几行就够了:

int main() {
  // 省略 UCX 初始化部分
  void *buf = malloc(0x1000);
  int len;
  ucp_tag_recv(worker, buf, 0x1000, &len, ...);
  ucp_tag_send(ep, buf, len, ...);
  return 0;
}

面对传统异步编程带来的“回调地狱”,主流编程语言经过了十几年的持续探索,终于殊途同归,纷纷引入了控制异步的终极解决方案—— async-await 协程。它的杀手锏就是能让开发者用同步的风格编写异步的逻辑。经过我们的封装过后,在 Rust 中用 async 协程编写同样的逻辑是长这样的:

async fn main() {
  // 省略 UCX 初始化部分
  let mut buf = vec![0u8; 0x1000];
  let len = worker.tag_recv(&mut buf, ...).await.unwrap();
  ep.tag_send(&buf[..len], ...).await.unwrap();
}

下面我们就来介绍如何用 Rust 的协程机制包装 UCX 异步接口。

Rust 封装 UCX

生成 Rust 接口

用 Rust 包装 C 语言库的第一步是用社区提供的 bindgen 工具,从 C 头文件自动生成 Rust 绑定代码。生成的代码一般直接作为 *-sys 库发布,具体实现可以参考我们封装的 ucx-sys。接下来我们要在它的基础上继续封装出高层接口,也就是 async-ucx。

封装 UCX 对象

async-ucx 做的第一件事就是封装 UCX 对象。在 C 语言中对象创建出来后用户会拿到一个 handle,也就是一个指针。用户之后需要自己管理对象的生命周期,在用完后手动释放掉资源。

在 Rust 中我们需要将 C 的 handle 包装成一个 struct,通过引用计数来自动管理对象的生命周期,在对象的 Drop 函数中释放其资源。下面的代码展示了对 Worker 对象的封装过程:


#![allow(unused)]
fn main() {
// 创建 Worker 需要依赖 Context,具体实现代码略过
pub struct Context {
  handle: ucp_context_h,
}

pub struct Worker {
  handle: ucp_worker_h,		// 包装 C handle
  context: Arc<Context>,	// 引用上级对象
}
impl Worker {
  // 从 Context 创建 Worker
  pub fn new(context: &Arc<Context>) -> Rc<Self> {
    // 准备参数和返回值
    let mut params = MaybeUninit::<ucp_worker_params_t>::uninit();
    unsafe { (*params.as_mut_ptr()).field_mask = 0 };
    let mut handle = MaybeUninit::uninit();
    // 调用 C 函数创建对象,获得 handle
    let status =
    unsafe { ucp_worker_create(context.handle, params.as_ptr(), handle.as_mut_ptr()) };
    assert_eq!(status, ucs_status_t::UCS_OK);
    // 包装为 Rust 对象
    Rc::new(Worker {
      handle: unsafe { handle.assume_init() },
      context: context.clone(),
    })
  }
}
// 析构时释放资源
impl Drop for Worker {
  fn drop(&mut self) {
    unsafe { ucp_worker_destroy(self.handle) }
  }
}
}

对象其它接口的包装也是类似的:首先将用户传入的参数转化成 C 接口的形式,然后 unsafe 调用 C 接口,最后将返回结果转化成 Rust 的形态返回给用户。

封装异步操作(重点)

接下来到了最重要的一步:用 Future 封装基于回调函数的异步接口。

首先我们来回顾一下 Future 的工作原理:它本质是一个状态机,只提供一个 poll 函数来驱动内部状态的演进。poll 函数的本质是事件轮询,它会检查自己关心的事件是否已经完成,如果完成就继续推进执行,否则就挂起等待。在挂起之前 Future 需要将自己的 waker 注册到后台的事件响应器(Reactor)中,以便在事件发生时能够被唤醒。当事件发生后,Reactor 通过 waker 唤醒 Future,Future 再次执行上述 poll 的过程,这一次它会看到事件已经发生,于是状态机得以继续推进。

基于 Future 的整个异步运行时的工作原理如下图所示:

4

其中 Future 接口的具体定义如下:


#![allow(unused)]
fn main() {
pub trait Future {
  type Output;
  // 尝试推进状态机:
  //   如果事件就绪就返回 Poll::Ready(T)
  //   否则从 cx 中拿一个 waker 注册给 reactor,然后返回 Poll::Pending
  fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

下面我们要做的就是:利用回调函数来实现上面的 poll 函数。主要解决两个问题:查询事件状态,注册 waker。

  • 查询事件状态:这个比较好办。因为 UCX 的异步函数都会返回一个 request 对象,并且提供了接口来查询 request 的状态。所以我们只需要将 request 对象加入 Future 的状态中即可。

    // 异步函数的返回值就是 request
    ucs_status_ptr_t ucp_tag_recv_nb (...);
    // 查询 request 状态的接口
    ucs_status_t ucp_tag_recv_request_test (
      void ∗request,
    	ucp_tag_recv_info_t ∗info
    );
    
  • 注册 waker:这件事需要一些技巧。之前我们提到 callback 实际上充当了 Reactor 的角色,所以这里我们需要将 waker 从 Future 传递给 callback。

    观察 UCX callback 函数的定义,可以发现其中一个参数就是 request:

    typedef void(∗ ucp_tag_recv_callback_t) (
      void ∗request,  // 可以通过这里,在 Future 和 callback 之间传递信息
      ucs_status_t status,
      ucp_tag_recv_info_t ∗info
    );
    

    并且 UCX 允许我们塞一些自己的私货到 request 对象中,正好就解决了传递 waker 的问题。

接下来正式开始实现!首先定义我们要向 request 中夹带的私货:


#![allow(unused)]
fn main() {
#[derive(Default)]
struct Request {
  // 只有一个 waker,使用了 futures 库中的 AtomicWaker 来保证原子性
  waker: futures::task::AtomicWaker,
}
// NOTE:这里无需 #[repr(C)],因为操作内部变量都是在 Rust 中实现的
}

然后向 UCX Context 注册这个私货:


#![allow(unused)]
fn main() {
impl Context {
  pub fn new() -> Arc<Self> {
    // 创建 Context 的时候注册私货信息
    let params = ucp_params_t {
      request_size: std::mem::size_of::<Request>() as u64,
      request_init: Some(Request::init),
      request_cleanup: Some(Request::cleanup),
			...
    };
    // 构造 Context 略
  }
}
impl Request {
	// 初始化私货(原地构造)
  unsafe extern "C" fn init(request: *mut c_void) {
    (request as *mut Self).write(Request::default());
    // 注意:不能使用 *request = xxx; 的写法
    // 因为 request 指向的内存是未初始化状态,会导致在未定义内存上触发析构函数!
  }
	// 清理私货(原地析构)
  unsafe extern "C" fn cleanup(request: *mut c_void) {
    std::ptr::drop_in_place(request as *mut Self)
  }
}
}

接下来将整个 request 包装成一个 Future:


#![allow(unused)]
fn main() {
// 由于不同的操作会有不同的返回值,这里就用泛型参数 T 表示返回值类型
struct RequestHandle<T> {
  // UCX 返回的 request 对象,它的头部就是我们的私货
  ptr: ucs_status_ptr_t,
	// 查询 request 状态的函数
  poll_fn: unsafe fn(ucs_status_ptr_t) -> Poll<T>,
}
//////////////////////// 核心代码 /////////////////////////
impl<T> Future for RequestHandle<T> {
  type Output = T;
  fn poll(self: Pin<&mut Self>, cx: &mut std::task::Context) -> Poll<Self::Output> {
    // 查询状态,如果就绪直接返回
    if let ret @ Poll::Ready(_) = unsafe { (self.poll_fn)(self.ptr) } {
      return ret;
    }
    // 注册 waker(通过私货)
    let request = unsafe { &mut *(self.ptr as *mut Request) };
    request.waker.register(cx.waker());
    // 返回等待,挂起任务
    Poll::Pending
  }
}
//////////////////////////////////////////////////////////
impl<T> Drop for RequestHandle<T> {
  fn drop(&mut self) {
    // request 要求手动释放
    unsafe { ucp_request_free(self.ptr as _) };
  }
}
}

最后,用这个 Future 实现一个完整的 recv 过程:


#![allow(unused)]
fn main() {
impl Endpoint {
	pub async fn tag_recv(&self, tag: u64, buf: &mut [u8]) -> usize {
    // 首先实现 callback
    unsafe extern "C" fn callback(request: *mut c_void, ...) {
      // 只需简单地从 request 中取出 waker 唤醒即可
      let request = &mut *(request as *mut Request);
      request.waker.wake();
    }
    // 发起异步操作,调用 C 函数,传入 callback
    let status = unsafe {
      ucp_tag_recv_nb(self.handle, ..., Some(callback))
    };
    if UCS_PTR_IS_PTR(status) {
      panic!("failed to recv tag: {:?}", UCS_PTR_RAW_STATUS(status));
    }
    // 将返回的 request 包装成 Future 并 await 结果!
    RequestHandle {
      ptr: status,
      poll_fn: poll_tag,	// 查询状态的具体实现略
    }.await
  }
}
}

经过这样一番包装,我们就可以简单地使用一行命令,以同步风格完成 IO 操作了:


#![allow(unused)]
fn main() {
let len = worker.tag_recv(tag, &mut buf, ...).await;
}

封装 worker progress

终于结束了吗?别忘了所有的回调函数都是由 worker.progress() 驱动的。用户必须定期调用这个函数,不然所有任务都会卡住不动了。

那么解决方法也非常简单:我们在异步运行时中首先创建一个协程,不停地调用 worker.progress() ,然后 yield 让出。为了做到与具体的异步运行时库无关,我们这里只实现了这个协程本身,用户需要手动 spawn 它:


#![allow(unused)]
fn main() {
impl Worker {
	pub async fn polling(self: Rc<Self>) {
    // 不停循环直到 worker 的其它引用都释放了为止
    while Rc::strong_count(&self) > 1 {
      // 不停地调用 progress,直到没有新事件产生
      while self.progress() != 0 {}
      // 暂时让出 CPU,等待下次调度
      futures_lite::future::yield_now().await;
    }
  }
}
// 用法示例:tokio::task::spawn_local(worker.clone().polling());
}

当所有其它协程任务都挂起时,运行时就会调度运行 polling 协程,用新事件触发若干回调函数,从而唤醒对应的协程。

在 Rust 异步运行时中使用 UCX

到此为止我们就完成了对 UCX 异步封装的主要工作。值得注意的是,上面过程只用到了 Rust 语言内建的 async-await 语法,以及标准库中定义的最核心接口(Future 等),完全不依赖于任何一种特定的运行时环境(Tokio,async-std,smol 等)。

接下来我们以 Tokio 为例,介绍在 Rust 异步运行时中使用 async-ucx 构建一个完整可用程序的过程。这里我们实现一个最简单的 echo 程序:客户端向服务端发送一条消息,服务端收到后打印出来。完整代码在 examples/tag.rs

首先写主函数:

use async_ucx::ucp::*;
use std::io::Result;

// 使用 Tokio 默认的单线程运行时
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
  env_logger::init();
  // 这里需要创建一个 LocalSet,才能 spawn !Send 的单线程 Future
  let local = tokio::task::LocalSet::new();
  // 根据命令行参数决定是服务端还是客户端
  if let Some(server_addr) = std::env::args().nth(1) {
    local.run_until(client(server_addr)).await?;
  } else {
    local.run_until(server()).await?;
  }
  Ok(())
}

接下来实现服务端:


#![allow(unused)]
fn main() {
async fn server() -> Result<()> {
  println!("server");
  // 创建 worker 和后台 progress 协程
  let context = Context::new();
  let worker = context.create_worker();
  tokio::task::spawn_local(worker.clone().polling());

  // 创建 listener 并等待连接
  let mut listener = worker.create_listener("0.0.0.0:0".parse().unwrap());
  println!("listening on {}", listener.socket_addr());
  let connection = listener.next().await;
  let _endpoint = worker.accept(connection);
  println!("accept");

  // 接收消息并打印出来
  let mut buf = [0u8; 0x100];
  let len = worker.tag_recv(100, &mut buf).await;
  let msg = std::str::from_utf8(unsafe { transmute(&buf[..len]) }).unwrap();
  println!("recv: {:?}", msg);
  Ok(())
}
}

客户端更加简单:


#![allow(unused)]
fn main() {
async fn client(server_addr: String) -> Result<()> {
  println!("client");
  // 创建 worker 和后台 progress 协程
  let context = Context::new();
  let worker = context.create_worker();
  tokio::task::spawn_local(worker.clone().polling());

  // 建立连接
  let endpoint = worker.connect(server_addr.parse().unwrap());
  println!("connect to {:?}", server_addr);
  
  // 发送消息
  let msg = b"hello";
  endpoint.tag_send(100, msg).await;
  println!("send: {:?}", msg);
  Ok(())
}
}

就这么简单,不到 50 行代码就可以实现一个完整的客户端和服务端。相比之下,UCX 官方提供的 ucp_hello_world 程序足足用了好几百行 C 代码才实现了同样的功能。这说明我们的封装更加简单易用,同时 async Rust 能够极大的提高异步编程开发效率。

总结与展望

本文介绍了用 async Rust 封装 UCX 异步接口的主要过程。其中涉及的核心知识技巧包括:

  • C 语言中用回调函数处理异步逻辑的方法
  • Rust 语言中 Future 的角色和功能,异步运行时的基本原理
  • 如何用 Future 包装回调函数,如何在二者之间传递信息
  • 如何实现 Reactor,用事件驱动整个异步框架的运行

目前 async-ucx 已经基本完成了对 UCP 接口的封装,还剩下另一半 UCT 接口没有实现。

此外,目前 async-ucx 还只支持以 busy-polling 轮询模式处理事件,这会使得运行它的 CPU 核一直处于 100% 满载状态。这样做当然性能是最高的,但是不太节能。UCX 自身支持休眠-唤醒机制,可以在没有事件发生的时候休眠当前线程、让出 CPU、等事件发生时再唤醒。其内部实现是为每个 worker 都创建了一个 event_fd,用户可以拿它去做 epoll,从而阻塞线程等待事件发生。

但是,要用好这一机制需要与 Rust 异步执行器进行深度整合。据我了解,至少 Tokio 1.0 是没有暴露相关接口的。而且这部分的实现一旦有任何闪失,都会导致唤醒遗失、程序卡住的问题出现。未来如何科学地实现休眠机制也是一个不小的挑战。

总的来说,异步 IO 在支持极高性能的同时也带来了极大的编程复杂性,而 Rust 的 async 协程机制很好的压制了这种复杂性。在我看来,Rust 是这个时代最适合编写复杂 IO 系统的语言。欢迎感兴趣的读者继续关注,一同加入到我们推翻 C/C++ 旧世界的行列中来!

Zellij 的性能优化

Zellij impovement post cover

过去的几个月里,我们一直工作在 Zellij 的故障修复和性能调优上。在这个过程中,我们发现了不少问题和瓶颈,并采取了一些创造性的手段解决或者绕过它们。

本文我会用图文描述我们遇到的 2 个问题。在处理完这 2 个问题后,我们的应用已经能在性能上和同类产品打成平手,甚至超越它们。

这是 Zellij 的社区维护人员和贡献者共同创造的成果,详情请见后文的致谢部分。

关于本文的代码示例

本文中的代码示例围绕想表达的论点做了酌情简化。由于 Zellij 是一个已用于实际使用的应用,其内部代码可能会涉及和包含无关的细节。如果读者想深入研究的话,在每个代码示例后,有实际代码的链接,包括相关 PR 的链接。

应用的功能与遇到的问题

Zellij Application

Zellij 是一个终端复用软件,简单来说,这是一个运行于虚拟终端(如 Alacritty, iterm2, Konsole 等)和 shell 之间的应用。

Zellij 中可以创造标签页和窗格,另外由于一直在后台运行,也可以脱离和重连某个会话。Zellij 保存了每个窗格的状态,让用户在重连或者切换标签页的之后还能回到原有的会话中。这个状态包含了窗格中的文本和样式,以及光标的位置等信息。

当某个窗格包含有大量的数据时,应用会遇到严重的性能问题。例如,cat 一个非常大的文件,Zellij 不仅比一个裸的虚拟终端要慢,比其他终端复用软件也要慢上许多。

这里我们深入挖掘这个问题,看看问题的根源是什么,并探索相应的解决方案。

问题流程

我们采用了多线程的架构,每个线程完成某些特定的任务,并通过 MPSC 通道相互通信。数据的解析与渲染分别由 pty 线程和 screen 线程完成。

pty 线程 会查询 pty,这是我们与 shell(或者其他在终端中运行的程序)的接口。该线程会向 screen 线程发送原始数据,并由其解析数据,构造出这个窗格的内部状态。

另外,每隔一小段时间,pty 线程会给 screen 线程发送 render 消息,让其根据窗格的状态渲染用户的 UI。

Zellij work flow

pty 线程会启动一个异步任务,采用一个非阻塞的循环去轮询 pty,检查是否有新的数据。如果没有数据,pty 线程会休眠一段固定的时间。pty 线程在拿到数据后会向 screen 线程发送 data 指令,让其解析数据。此外,在以下情况下,pty 线程会去发 render 指令:

  1. pty 缓存中没有数据
  2. 从上次 render 指令发送已经过了 30ms 以上

其中第二种情况是为了用户体验,这样当有大量数据从 pty 传来时,用户可以实时地在屏幕中看到更新。

让我们看一下代码:


#![allow(unused)]
fn main() {
task::spawn({
    async move {
        // TerminalBytes is an asynchronous stream that polls the pty
        // and terminates when the pty is closed
        let mut terminal_bytes = TerminalBytes::new(pid);
        let mut last_render = Instant::now();
        let mut pending_render = false;
        let max_render_pause = Duration::from_millis(30);
        while let Some(bytes) = terminal_bytes.next().await {
            let receiving_data = !bytes.is_empty();
            if receiving_data {
                send_data_to_screen(bytes);
                pending_render = true;
            }
            if pending_render && last_render.elapsed() > max_render_pause {
                send_render_to_screen();
                last_render = Instant::now();
                pending_render = false;
            }
            if !receiving_data {
                // wait a fixed amount of time before polling for more data
                task::sleep(max_render_pause).await;
            }
        }
    }
})
}

实际的代码可以参考该链接

代码排障

为了测试这段流程的性能,我们会 cat 一个 2,000,000 行的文件,并使用 hyperfine,并打开 --show-output 选项,使其不会忽略 stdout 的时间。我们采用 tmux 作为对照组。

hyperfine --show-output "cat /tmp/bigfile" 在 tmux 的运行结果如下(窗格大小: 59 行,104 列):

Time (mean ± σ): 5.593 s ± 0.055 s [User: 1.3 ms, System: 2260.6 ms] Range (min … max): 5.526 s … 5.678 s 10 runs

同样的指令在 Zellij 的运行结果如下(窗格大小:59 行,104 列):

Time (mean ± σ): 19.175 s ± 0.347 s [User: 4.5 ms, System: 2754.7 ms] Range (min … max): 18.647 s … 19.803 s 10 runs

结果并不理想,对此我们要采取一些措施。

难点 1: MPSC 消息通道溢出

我们遇到的第一个性能瓶颈是 MPSC 消息通道的溢出。为了形象描述这个问题,我们给前面的流程图加个速:

Zellij Application

pty 线程和 screen 线程的数据处理速率并不同步,前者向消息通道中发送数据的速度远快于后者消耗的速度。这在以下方面影响了性能:

  1. 消息通道持续地扩张,不停地占用更多的内存
  2. 由于 screen 线程随着数据的增加,占用了越来越多的 CPU 时间,原有的 30ms 间隔也变的相对不重要,线程在渲染上会花费比未溢出情况下更多的时间。

解决方案:限制的消息通道的大小(背压机制)

问题的直接解决方法,是对消息通道的缓存大小进行限制,以此给 2 个线程带来了同步。我们将消息通道的大小限制到了很小的一个值(50 条消息),并切换到了 crossbeam,采用了其提供的 select! 宏。

除此之外,我们移除了自己实现的异步流,而是采用 async_stdFile, 这样就无需在后台进行轮训。

我们来看下相关的代码:


#![allow(unused)]
fn main() {
task::spawn({
    async move {
        let render_pause = Duration::from_millis(30);
        let mut render_deadline = None;
        let mut buf = [0u8; 65536];
        // AsyncFileReader is implemented using async_std's File
        let mut async_reader = AsyncFileReader::new(pid);

        // "async_send_render_to_screen" and "async_send_data_to_screen"
        // send to a crossbeam bounded channel
        // resolving once the send is successful, meaning there is room
        // for the message in the channel's buffer

        loop {
            // deadline_read attempts to read from async_reader or times out
            // after the render_deadline has passed
            match deadline_read(&mut async_reader, render_deadline, &mut buf).await {
                ReadResult::Ok(0) | ReadResult::Err(_) => break, // EOF or error
                ReadResult::Timeout => {
                    async_send_render_to_screen(bytes).await;
                    render_deadline = None;
                }
                ReadResult::Ok(n_bytes) => {
                    let bytes = &buf[..n_bytes];
                    async_send_data_to_screen(bytes).await;
                    render_deadline.get_or_insert(Instant::now() + render_pause);
                }
            }
        }
    }
})
}

完整的代码可以参考这个链接

现在的运行流程可以参照下图:

Zellij Application

性能提升的度量

让我们回到之前的性能测试,以下是使用 hyperfine --show-output "cat /tmp/bigfile" 的结果(窗格大小:59 行,104 列):

# Zellij before this fix
Time (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]
Range (min … max):   18.647 s … 19.803 s    10 runs

# Zellij after this fix
Time (mean ± σ):      9.658 s ±  0.095 s    [User: 2.2 ms, System: 2426.2 ms]
Range (min … max):    9.433 s …  9.761 s    10 runs
 
# Tmux
Time (mean ± σ):      5.593 s ±  0.055 s    [User: 1.3 ms, System: 2260.6 ms]
Range (min … max):    5.526 s …  5.678 s    10 runs

可以看到已经有了很大的改进,不过和 tmux 比起来,还不够好。

难题 2: 提升渲染和数据处理的性能

现在我们将有背压的流水线和 screen 线程连接在了一起,如果我们能提升 sceen 线程的工作,也就是数据的处理与渲染,那么应用的性能将得到进一步的提升。

数据解析

数据解析部分会将 ANSI/VT 指令(例如 \033[10;2H\033[36mHi there!),并将其转换成 Zellij 所定义的数据结构。

相关的代码如下:


#![allow(unused)]
fn main() {
struct Grid {
    viewport: Vec<Row>,
    cursor: Cursor,
    width: usize,
    height: usize,
}

struct Row {
    columns: Vec<TerminalCharacter>,
}

struct Cursor {
    x: usize,
    y: usize
}

#[derive(Clone, Copy)]
struct TerminalCharacter {
    character: char,
    styles: CharacterStyles
}
}

实际的代码可以参考链接 1 链接 2

Row 的预分配

数据解析器的是应用中被优化最频繁的部分,其中很多改动超出了本文的范畴。在此我们列举提升最大的几个优化。

以下是 Row 的定义,其中添加字符的方法是解析器中最常使用的方法。特别是向行尾添加字符,这个过程中会将 TerminalCharacter 添加到 Rowcolumns 字段。每次 push 都会改变这个 vector 的大小,并可能造成内存的再分配。这对性能造成了一定的影响。为此我们在新建或者调整窗体大小的时候,对 Row 进行了预分配。

代码修改前:


#![allow(unused)]
fn main() {
impl Row {
    pub fn new() -> Self {
        Row {
            columns: Vec::new(),
        }
    }}
}
}

修改后:


#![allow(unused)]
fn main() {
impl Row {
    pub fn new(width: usize) -> Self {
        Row {
            columns: Vec::with_capacity(width),
        }
    }}
}
}

具体代码可以参考该链接

缓存字符长度

有些字符比另一些更长,例如东亚的字符,或者 emoji。 Zellij 使用了 unicode-width 这个优秀的 crate,去查询字符的长度。

在将字符加入行中后,虚拟终端需要知道当前行的长度,去决定是否要自动换行。因此我们需要不停地查询字符的长度。

既然我们要多次查询字符的长度,我们可以缓存 c.width() 的结果,将其存入 TerminalCharacter 结构体中。

于是如下程序:


#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct TerminalCharacter {
    character: char,
    styles: CharacterStyles
}

impl Row {
    pub fn width(&self) -> usize {
        let mut width = 0;
        for terminal_character in self.columns.iter() {
            width += terminal_character.character.width();
        }
        width
    }
}
}

在做了如下更改后,性能得到了提升:


#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct TerminalCharacter {
    character: char,
    styles: CharacterStyles,
    width: usize,
}

impl Row {
    pub fn width(&self) -> usize {
        let mut width = 0;
        for terminal_character in self.columns.iter() {
            width += terminal_character.width;
        }
        width
    }
}
}

实际代码可以参考该链接

加速渲染

Screen 线程的渲染部分将每个窗格的状态,按照前文提到的数据结构进行组织,并将其转换成 ANSI/VT 指令,发送到用户的虚拟终端上。

Grid 中的 render 方法将各个字符以及它的样式和位置转换成 ANSI/VT 指令并发给终端,覆盖前一次渲染的结果。


#![allow(unused)]
fn main() {
fn render(&mut self) -> String {
    let mut vte_output = String::new();
    let mut character_styles = CharacterStyles::new();
    let x = self.get_x();
    let y = self.get_y();
    for (line_index, line) in grid.viewport.iter().enumerate() {
        vte_output.push_str(
            // goto row/col and reset styles
            &format!("\u{1b}[{};{}H\u{1b}[m", y + line_index + 1, x + 1)
        );
        for (col, t_character) in line.iter().enumerate() {
            let styles_diff = character_styles
                .update_and_return_diff(&t_character.styles);
            if let Some(new_styles) = styles_diff {
                // if this character's styles are different
                // from the previous, we update the diff here
                vte_output.push_str(&new_styles);
            }
            vte_output.push(t_character.character);
        }
        // we clear the character styles after each line
        // in order not to leak styles from the pane to our left
        character_styles.clear();
    }
    vte_output
}
}

具体代码可以参考该链接

写入 STDOUT 是一个很耗时的操作,我们可以通过限制发往终端的指令数量,从而提升应用的性能。为了达成这个目的,我们要采用一个输出缓冲区,用于追踪渲染时改变的视图区域。在渲染时,就可以构造这部分区域的指令。


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct CharacterChunk {
    pub terminal_characters: Vec<TerminalCharacter>,
    pub x: usize,
    pub y: usize,
}

#[derive(Clone, Debug)]
pub struct OutputBuffer {
    changed_lines: Vec<usize>, // line index
    should_update_all_lines: bool,
}

impl OutputBuffer {
    pub fn update_line(&mut self, line_index: usize) {
        self.changed_lines.push(line_index);
    }
    pub fn clear(&mut self) {
        self.changed_lines.clear();
    }
    pub fn changed_chunks_in_viewport(
        &self,
        viewport: &[Row],
    ) -> Vec<CharacterChunk> {
        let mut line_changes = self.changed_lines.to_vec();
        line_changes.sort_unstable();
        line_changes.dedup();
        let mut changed_chunks = Vec::with_capacity(line_changes.len());
        for line_index in line_changes {
            let mut terminal_characters: Vec<TerminalCharacter> = viewport
                .get(line_index).unwrap().columns
                .iter()
                .copied()
                .collect();
            changed_chunks.push(CharacterChunk {
                x: 0,
                y: line_index,
                terminal_characters,
            });
        }
        changed_chunks
    }
}}
}

实际代码可以参考该链接

当前的实现仅仅跟踪了改动行,也尝试过对列做进一步的优化,但我发现这样会极大增加代码的复杂度,但对性能的提升十分有限。

最后,我们来看看所有这些优化的成效。以下是使用 hyperfine --show-output "cat /tmp/bigfile" 的结果,窗体大小还是 59 行,104 列):

# Zellij before all fixes
Time (mean ± σ):     19.175 s ±  0.347 s    [User: 4.5 ms, System: 2754.7 ms]
Range (min … max):   18.647 s … 19.803 s    10 runs

# Zellij after the first fix
Time (mean ± σ):      9.658 s ±  0.095 s    [User: 2.2 ms, System: 2426.2 ms]
Range (min … max):    9.433 s …  9.761 s    10 runs

# Zellij after the second fix (includes both fixes)
Time (mean ± σ):      5.270 s ±  0.027 s    [User: 2.6 ms, System: 2388.7 ms]
Range (min … max):    5.220 s …  5.299 s    10 runs

# Tmux
Time (mean ± σ):      5.593 s ±  0.055 s    [User: 1.3 ms, System: 2260.6 ms]
Range (min … max):    5.526 s …  5.678 s    10 runs

至此,我们的应用已经能在性能上与其他成熟的终端复用软件一较高下。改进的空间依然还有,但现在也能为用户带来优秀的体验了。

结论

我们通过 cat 大文件来度量性能,能覆盖的情景其实比较有限。在其他情境下,Zellij 有可能表现地更好或者更糟。性能测试是一个很复杂的领域,本文的数据只能作为一个模糊的指标。

Zellij 从未宣称比同类应用更快,只是将性能作为一个尽力提高的目标。

如果你发现了本文中的错误,可以联系 aram@poor.dev,我们欢迎任何任何改动、想法、反馈。

如果你觉得本文不错,想在未来看到更多这样的内容,可以考虑在 twitter 上关注我。

链接

致谢

  • Tamás Kovács: MPSC 通道和背压等改动的作者,审阅了本文
  • Kunal Mohan: 校验并帮助完成了背压相关改动,审阅了本文
  • Aram Drevekenin: 参与了数据解析与渲染的改动

学习资源

编辑:张汉东


Rand 之书

Rust 新书 :《 Hands-on Rust 》

PragProg 出版社出的一本新书 《Hands-on Rust》,以游戏开发为主题学习 Rust 。

https://pragprog.com/titles/hwrust/hands-on-rust/

视频介绍:https://www.youtube.com/watch?v=DvcWrd5VJ2I

《Rust for Rustaceans》 样章试译 | 第二章 Rust 基础

本文是对 Jon Gjengset 写的新书 《Rust for Rustaceans》样章第二章的中文试译初稿。出于对 Jon 的尊敬,以及想了解 Jon 眼中的 Rust ,我打算翻译一下这本书。发出来让大家看看翻译效果,欢迎指正。

https://mp.weixin.qq.com/s/PW7UQ1QpolXeXQTgJGblog

曼宁新书推荐: 《Refactoring to Rust》

这本书试图教会你,如何把现有项目用 Rust 语言重构。 比较适合想在公司里推广 Rust 的朋友。

https://www.manning.com/books/refactoring-to-rust

在 R 语言中调用 Rust

https://extendr.github.io/rextendr/

TezEdge: 使用 nom 加速二进制解析

Tezos 是一个开源去中心化区块链网络,为智能合约和数字资产提供平台。 之前 Tezos 节点使用 serde 来序列化/反序列化二进制,但是这样始终维护着一个中间结构,占用了 CPU 和 内存。所以他们使用 nom 直接对二进制流进行解析,就消除来这个中间结构,提升了性能。

Rust 概念解惑 | Deref vs AsRef vs Borrow vs Cow

arithmetic-parser: 一个多功能的算术表达式解析器

此项目可以作为 Rust 学习案例

https://github.com/slowli/arithmetic-parser

Bevy 引擎游戏开发指南系列视频

该指南是利用 Bevy 引擎开发一个好玩的类似小蜜蜂的游戏。

https://www.youtube.com/channel/UCiT_r1GD7JSftnbViKHcOtQ

Rust 如何和 FFI 的数据打交道

该博客主要是简单展示如何通过 FFi 进行数据交互

https://blog.guillaume-gomez.fr/articles/2021-07-29+Interacting+with+data+from+FFI

与c相比,如何改善Rust巨大的二进制大小?

如果你想尽可能的减少Rust生成的二进制文件大小,请看这里:

【讨论】为什么说 OpenSSL 是 Rust 开发的痛点?

来自 reddit 的讨论,摘录一段评论:

在Node.JS和Python中,TLS的实现是来自运行时的。有人已经为你处理了这个令人头痛的问题。缺点是,当你写一个NodeJS或Python程序时,你需要确保你的用户也安装了该运行时。

Rust没有这样的运行时。当你运行你的Rust程序时,该程序必须知道如何以某种方式处理TLS。

那么openssl-sys与其他 crate,如serdeclap等有什么不同?

你从 crates.io 上找到的大多数crates都是纯粹的Rust crates,它们是用Rust工具链编译的,(通常)静态链接到你的可执行文件中。这是一个相对简单的过程,只依赖于Rust编译工具,反正你已经设置好了。

openssl-sys(像大多数其他的-sys板块)不是一个纯粹的Rust crate。它依赖于实际的OpenSSL库,它是一个C/C++库。这意味着还有许多其他方面需要考虑。

OpenSSL是静态链接还是动态链接到最终可执行文件?

在哪里找到/如何为链接器编译必要的文件?

这里没有适用于所有用户的开箱即用的解决方案。通常你需要下载带有必要文件的OpenSSL二进制分布,并以某种方式通知openssl-sys在哪里提取这些文件,或者你需要设置OpenSSL构建工具链(C/C++编译器等),然后让openssl-sys为你从头编译 OpenSSL。

https://www.reddit.com/r/rust/comments/oto406/why_is_ssl_such_a_pain/

Local Native系列教程完结了

Local Native是一个跨平台的Rust写的桌面应用程序,使用的UI框架是iced,教程记录了如何搭建整个程序的过程,因为本人水平有待提高,因此教程肯定会有不少问题,希望对此感兴趣的朋友可以提出问题,我会尽可能解决。这个项目会长期维护,知道iced等重要依赖达到1.0稳定版本。

上次收到的反馈有注释过长之类的,在新版本中有了改正,非常感谢大家的反馈。

Prechelt 论文 | 比较Rust、Java、Lisp、C/C++ 和脚本语言

文章主要介绍了关于编程语言对生产力和程序效率的影响的讨论。

https://renato.athaydes.com/posts/revisiting-prechelt-paper-comparing-languages.html

Rust 实现 esp8266 驱动程序

通过串口与esp8266 模块通信。通过这个模块,可以加入现有的接入点或创建自己的接入点。创建网络后,该模块既可以侦听传入的 TCP 连接,也可以连接到其他套接字。

https://github.com/alekseysidorov/esp8266-wifi-serial

Rust 中那些超棒的 Unstable 特性

本文讲解了Rust编译器的一些超棒的未稳定特性,并且通过例子来使读者了解这些未稳定特性的基本用法和情况。

https://lazy.codes/posts/awesome-unstable-rust-features/

深入理解 Rust 的 Features

https://fasterthanli.me/articles/understanding-rust-futures-by-going-way-too-deep

如何在 Rust 中实现工作池

单纯看到 Rust 所有权模型,工作池模式会不太适合 Rust。但是相反,拥抱函数式编程和不可变数据使 Rust 拥有了更简单易用和更优雅的工具:并行迭代器和流。作者提供了 #计算密集型# 和 #I/O 密集型# 两种作业的工作池使用方式。

https://kerkour.com/blog/rust-worker-pool/

Rust 和 JVM

通过 jni 让 rust 和 Java 结合起来,可以在 Java 中使用 rust function

https://blog.frankel.ch/start-rust/7

如何 在 yew 框架 和 Trunk 中使用 Tailwind CSS

Rust + Tauri + Svelte 指南

https://jbarszczewski.com/rust-tauri-svelte-tutorial

系列: 在树莓派上跑 wasm 模块

https://blog.knoldus.com/host-a-wasm-module-on-raspberry-pi-easily-part-1/

Fluvio SmartStreams:: 为应用程序日志编写 wasm 过滤器

https://www.infinyon.com/blog/2021/06/smartstream-filters/

用 Rust 重写 go 项目,最开始慢 4~5 倍,然后经过Rust 社区网友支招优化以后,反过来比 go 项目快且稳定

这个帖子下涉及一些异步优化技巧可以看看

https://www.reddit.com/r/rust/comments/oje3w7/rewrote_golang_project_in_rust_its_4x_times/

Discourse 论坛 : 使用 Rust 、WebAssembly 和 MozJPEG 加速上传

https://blog.discourse.org/2021/07/faster-user-uploads-on-discourse-with-rust-webassembly-and-mozjpeg

Rust 实现的轻量级浏览器,用于教学目标

https://github.com/lmt-swallow/puppy-browser/

如何在 Windows 上进行 Rust 开发

微软官方指南,在 Windows 上使用 Rust 进行开发,包括开发环境的设置、Windows 的 Rust 和代码示例。

https://docs.microsoft.com/en-us/windows/dev-environment/rust/

hyperfunctions: PostgreSQL 中的函数,用于简化时序数据的工作

Rust 实现的 postgresql 扩展。能简化开发工作。

其中提到一个理念:创建新的 SQL 函数,而不是重新发明语法。值得品味。

https://blog.timescale.com/blog/introducing-hyperfunctions-new-sql-functions-to-simplify-working-with-time-series-data-in-postgresql/

并不总是iCache的原因

摘要:

众所周知,内联是一项很有用的代码优化技术。但有时候我们也常听到类似下面这样的观点:

内联也会使代码变慢,因为内联会增加代码的大小,使指令缓存变大并导致缓存未命中。

对于内联导致代码运行速度变慢的原因,不少人归结为内联使得iCache命中率降低,造成指令流水线在取指的过程中耗费更多的时间。

本文作者通过使用若干有效的工具(perf/valgrind)对比了是否启用内联特性的代码性能和汇编指令,提出了内联可能造成代码变慢的另一种解释:

试考虑内联技术将函数S内联展开于函数C中:

  1. 内联使得C占用了更多的寄存器。由于函数S的代码直接在函数C的函数体中展开,造成函数C在程序上下文切换过程中加入了更多的push/pop指令,并且函数C的运行时栈的空间进一步膨胀。与内联版本中每次调用函数C都意味着这些新增的push/pop指令都会运行不同,未内联版本的push/pop指令只存在于函数S的上下文中,并且只有当函数C确实调用函数S时,这些指令才会被运行;
  2. 基于第一点的基本认识,现在设想函数S在流程控制语句中被调用(循环或条件分支等),编译器可能会提升函数S中的某些指令到条件分支之外,造成这些指令从冷路径变为热路径(冷热路径:因为条件分支可能不会执行,但是位于条件分支之外的代码总会执行,是为热路径);
  3. 在上述场景中,随着外层函数C的栈中局部变量和流程控制语句增多,编译器的优化反而使得热路径执行效率降低。

一个 Rust 编写的 k8s controller

作者为了学习 k8s 的 controller 以及 Rust, 用 Rust 编写了一个 controller.

https://blog.frankel.ch/start-rust/6/

Rust 在前端中的使用

这其实是一个手把手教你如何使用 WebAssembly 的教程.

https://blog.frankel.ch/start-rust/5/

【完整】Rust 标准库 Trait 指南

译者: Praying

内容目录

  • 引言
  • Trait 基础
  • 自动 Trait
  • 泛型 Trait
  • 格式化 Trait
  • 操作符 Trait
  • 转换 Trait
  • 错误处理
  • 迭代器 Trait
  • I/O Trait
  • 总结

引言

你是否曾想过下面这些 trait 有什么不同?

  • Deref<Traget=T>AsRef<T>,以及Borrow<T>
  • CloneCopy,和ToOwned
  • From<T>Into<T>?
  • TryFrom<&str>FromStr
  • FnOnceFnMutFnfn?

或者你曾问过自己下面这些问题:

  • “我在 trait 中,什么时候使用关联类型(associated type),什么时候使用泛型(generic types)?”

  • “什么是泛型覆盖实现(generic blanket impls)”?

  • “subtrait 和 supertrait 是如何工作的?”

  • “为什么这个 trait 没有任何方法?”

那么这篇文章就是为你而写的!它回答了包括但不限于上述所有的问题。我们将一起对 Rust 标准库中所有最流行和最常用的 trait 进行快速的浏览。

你可以按章节顺序阅读本文,也可以跳到你最感兴趣的 trait,因为每个 trait 章节的开头都有一个指向前置章节的链接列表,你应该阅读这些链接,以便有足够的背景知识来理解当前章节的解释(译注:很抱歉,译文中暂时无法提供链接跳转)。

Trait 基础

我们将会覆盖足够多的基础知识,这样文章的其余部分就可以精简,而不必因为它们在不同的 trait 中反复出现而重复解释相同的概念。

Trait 项(Item)

Trait 项是指包含于 trait 声明中的任意项。

Self

Self总是指代实现类型。


#![allow(unused)]
fn main() {
trait Trait {
    // always returns i32
    fn returns_num() -> i32;

    // returns implementing type
    fn returns_self() -> Self;
}

struct SomeType;
struct OtherType;

impl Trait for SomeType {
    fn returns_num() -> i32 {
        5
    }

    // Self == SomeType
    fn returns_self() -> Self {
        SomeType
    }
}

impl Trait for OtherType {
    fn returns_num() -> i32 {
        6
    }

    // Self == OtherType
    fn returns_self() -> Self {
        OtherType
    }
}
}

函数(Function)

Trait 函数是指第一个参数不是self关键字的任意函数。


#![allow(unused)]
fn main() {
trait Default {
    // function
    fn default() -> Self;
}
}

Trait 函数可以通过 trait 或者实现类型的命名空间来调用。

fn main() {
    let zero: i32 = Default::default();
    let zero = i32::default();
}

方法(Method)

Trait 方法是指,第一个参数使用了self关键字并且self的类型是Self,&Self&mut Self之一。self的类型也可以被BoxRcArcPin来包装。


#![allow(unused)]
fn main() {
trait Trait {
    // methods
    fn takes_self(self);
    fn takes_immut_self(&self);
    fn takes_mut_self(&mut self);

    // above methods desugared
    fn takes_self(self: Self);
    fn takes_immut_self(self: &Self);
    fn takes_mut_self(self: &mut Self);
}

// example from standard library
trait ToString {
    fn to_string(&self) -> String;
}
}

Trait 方法可以通过在实现类型上使用点(.)操作符来调用。

fn main() {
    let five = 5.to_string();
}

此外,trait 方法还可以像函数那样由 trait 或者实现类型通过命名空间来调用。

fn main() {
    let five = ToString::to_string(&5);
    let five = i32::to_string(&5);
}

关联类型(Associated Types)

Trait 可以有关联类型。当我们需要在函数签名中使用Self以外的某个类型,但是希望这个类型可以由实现者来选择而不是硬编码到 trait 声明中,这时关联类型就可以发挥作用了。

trait Trait {
    type AssociatedType;
    fn func(arg: Self::AssociatedType);
}

struct SomeType;
struct OtherType;

// any type implementing Trait can
// choose the type of AssociatedType

impl Trait for SomeType {
    type AssociatedType = i8; // chooses i8
    fn func(arg: Self::AssociatedType) {}
}

impl Trait for OtherType {
    type AssociatedType = u8; // chooses u8
    fn func(arg: Self::AssociatedType) {}
}

fn main() {
    SomeType::func(-1_i8); // can only call func with i8 on SomeType
    OtherType::func(1_u8); // can only call func with u8 on OtherType
}

泛型参数(Generic Parameters)

“泛型参数”泛指泛型类型参数(generic type parameters)、泛型生命周期参数(generic lifetime parameters)、以及泛型常量参数(generic const parameters)。因为这些说起来比较拗口,所以人们通常把它们简称为 “泛型类型(generic type)”、“生命周期(lifetime)”和 “泛型常量(generic const)”。由于我们将要讨论的所有标准库 trait 中都没有使用泛型常量,所以它们不在本文的讨论范围之内。

我们可以使用参数来对一个 trait 声明进行泛化(generalize )。


#![allow(unused)]
fn main() {
// trait declaration generalized with lifetime & type parameters
trait Trait<'a, T> {
    // signature uses generic type
    fn func1(arg: T);

    // signature uses lifetime
    fn func2(arg: &'a i32);

    // signature uses generic type & lifetime
    fn func3(arg: &'a T);
}

struct SomeType;

impl<'a> Trait<'a, i8> for SomeType {
    fn func1(arg: i8) {}
    fn func2(arg: &'a i32) {}
    fn func3(arg: &'a i8) {}
}

impl<'b> Trait<'b, u8> for SomeType {
    fn func1(arg: u8) {}
    fn func2(arg: &'b i32) {}
    fn func3(arg: &'b u8) {}
}
}

泛型可以具有默认值,最常用的默认值是Self,但是任何类型都可以作为默认值。


#![allow(unused)]
fn main() {
// make T = Self by default
trait Trait<T = Self> {
    fn func(t: T) {}
}

// any type can be used as the default
trait Trait2<T = i32> {
    fn func2(t: T) {}
}

struct SomeType;

// omitting the generic type will
// cause the impl to use the default
// value, which is Self here
impl Trait for SomeType {
    fn func(t: SomeType) {}
}

// default value here is i32
impl Trait2 for SomeType {
    fn func2(t: i32) {}
}

// the default is overridable as we'd expect
impl Trait<String> for SomeType {
    fn func(t: String) {}
}

// overridable here too
impl Trait2<String> for SomeType {
    fn func2(t: String) {}
}
}

除了可以对 trait 进行参数化之外,我们还可以对单个函数和方法进行参数化。


#![allow(unused)]
fn main() {
trait Trait {
    fn func<'a, T>(t: &'a T);
}
}

泛型类型 vs 关联类型

泛型类型和关联类型都把在 trait 的函数和方法中使用哪种具体类型的决定权交给了实现者,因此这部分内容要去解释什么时候使用泛型类型,什么时候使用关联类型。

通常的经验法则是:

  • 当每个类型只应该有 trait 的一个实现时,使用关联类型。

  • 当每个类型可能会有 trait 的多个实现时,使用泛型类型。

比如说我们想要定义一个名为Add的 trait,该 trait 允许我们对值进行相加。下面是一个最初的设计和实现,里面只使用了关联类型。

trait Add {
    type Rhs;
    type Output;
    fn add(self, rhs: Self::Rhs) -> Self::Output;
}

struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Rhs = Point;
    type Output = Point;
    fn add(self, rhs: Point) -> Point {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 1 };
    let p2 = Point { x: 2, y: 2 };
    let p3 = p1.add(p2);
    assert_eq!(p3.x, 3);
    assert_eq!(p3.y, 3);
}

假设现在我们想要添加这样一种功能:把i32加到Point上,其中Point里面的成员xy都会加上i32

trait Add {
    type Rhs;
    type Output;
    fn add(self, rhs: Self::Rhs) -> Self::Output;
}

struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Rhs = Point;
    type Output = Point;
    fn add(self, rhs: Point) -> Point {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

impl Add for Point { // ❌
    type Rhs = i32;
    type Output = Point;
    fn add(self, rhs: i32) -> Point {
        Point {
            x: self.x + rhs,
            y: self.y + rhs,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 1 };
    let p2 = Point { x: 2, y: 2 };
    let p3 = p1.add(p2);
    assert_eq!(p3.x, 3);
    assert_eq!(p3.y, 3);

    let p1 = Point { x: 1, y: 1 };
    let int2 = 2;
    let p3 = p1.add(int2); // ❌
    assert_eq!(p3.x, 3);
    assert_eq!(p3.y, 3);
}

上面的代码会抛出错误:

error[E0119]: conflicting implementations of trait `Add` for type `Point`:
  --> src/main.rs:23:1
   |
12 | impl Add for Point {
   | ------------------ first implementation here
...
23 | impl Add for Point {
   | ^^^^^^^^^^^^^^^^^^ conflicting implementation for `Point`

因为Add trait 没有被任何的泛型类型参数化,我们只能在每个类型上实现这个 trait 一次,这意味着,我们只能一次把RhsOutput类型都选取好!为了能够使Pointi32类型都能和Point相加,我们必须把Rhs从一个关联类型重构为泛型类型,这样就能够让我们根据Rhs不同的类型参数来为Point实现 trait 多次。

trait Add<Rhs> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

struct Point {
    x: i32,
    y: i32,
}

impl Add<Point> for Point {
    type Output = Self;
    fn add(self, rhs: Point) -> Self::Output {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

impl Add<i32> for Point { // ✅
    type Output = Self;
    fn add(self, rhs: i32) -> Self::Output {
        Point {
            x: self.x + rhs,
            y: self.y + rhs,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 1 };
    let p2 = Point { x: 2, y: 2 };
    let p3 = p1.add(p2);
    assert_eq!(p3.x, 3);
    assert_eq!(p3.y, 3);

    let p1 = Point { x: 1, y: 1 };
    let int2 = 2;
    let p3 = p1.add(int2); // ✅
    assert_eq!(p3.x, 3);
    assert_eq!(p3.y, 3);
}

假如说我们增加了一个名为Line的新类型,它包含两个Point,现在,在我们的程序中存在这样一种上下文环境,即将两个Point相加之后应该产生一个Line而不是另一个Point。这在当我们当前的Add trait 设计中是不可行的,因为Output是一个关联类型,但是我们通过把Output从关联类型重构为泛型类型来实现这个新需求。

trait Add<Rhs, Output> {
    fn add(self, rhs: Rhs) -> Output;
}

struct Point {
    x: i32,
    y: i32,
}

impl Add<Point, Point> for Point {
    fn add(self, rhs: Point) -> Point {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

impl Add<i32, Point> for Point {
    fn add(self, rhs: i32) -> Point {
        Point {
            x: self.x + rhs,
            y: self.y + rhs,
        }
    }
}

struct Line {
    start: Point,
    end: Point,
}

impl Add<Point, Line> for Point { // ✅
    fn add(self, rhs: Point) -> Line {
        Line {
            start: self,
            end: rhs,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 1 };
    let p2 = Point { x: 2, y: 2 };
    let p3: Point = p1.add(p2);
    assert!(p3.x == 3 && p3.y == 3);

    let p1 = Point { x: 1, y: 1 };
    let int2 = 2;
    let p3 = p1.add(int2);
    assert!(p3.x == 3 && p3.y == 3);

    let p1 = Point { x: 1, y: 1 };
    let p2 = Point { x: 2, y: 2 };
    let l: Line = p1.add(p2); // ✅
    assert!(l.start.x == 1 && l.start.y == 1 && l.end.x == 2 && l.end.y == 2)
}

所以,哪个Add trait 是最好的呢?这取决于你程序中的需求!放在合适的场景中,它们都很好。

作用域(Scope)

只有当 trait 在作用域之中时,trait 项才能被使用。大多数 Rustaceans 在第一次尝试写一个 I/O 相关的程序时,都会在吃过一番苦头之后了解到这一点,因为ReadWrite的 trait 并不在标准库的预置(prelude)中。

use std::fs::File;
use std::io;

fn main() -> Result<(), io::Error> {
    let mut file = File::open("Cargo.toml")?;
    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?; // ❌ read_to_string not found in File
    Ok(())
}

read_to_string(buf: &mut String)声明于std::io::Read中并且被std::fs::File结构体实现,但是要想调用它,std::io::Read必须在当前作用域中。

use std::fs::File;
use std::io;
use std::io::Read; // ✅

fn main() -> Result<(), io::Error> {
    let mut file = File::open("Cargo.toml")?;
    let mut buffer = String::new();
    file.read_to_string(&mut buffer)?; // ✅
    Ok(())
}

标准库预置(The standard library prelude)是标准库中的一个模块,也就是说,std::prelude::v1,它在每个其他模块的顶部被自动导入,即use std::prelude::v1::*。这样的话,下面这些 trait 就总会在作用域中,我们不需要自己显式地导入它们,因为它们是预置的一部分。

  • AsMut
  • AsRef
  • Clone
  • Copy
  • Default
  • Drop
  • Eq
  • Fn
  • FnMut
  • FnOnce
  • From
  • Into
  • ToOwned
  • IntoIterator
  • Iterator
  • PartialEq
  • PartialOrd
  • Send
  • Sized
  • Sync
  • ToString
  • Ord

派生宏(Derive Macros)

标准库导出了一小部分派生宏,这么派生宏可以让我们可以便捷地在一个类型上实现 trait,前提是该类型的所有成员都实现了这个 trait。派生宏以它们所实现的 trait 来命名。

  • Clone
  • Copy
  • Debug
  • Default
  • Eq
  • Hash
  • Ord
  • PartialEq
  • PartialOrd

使用示例:


#![allow(unused)]
fn main() {
// macro derives Copy & Clone impl for SomeType
#[derive(Copy, Clone)]
struct SomeType;
}

注意:派生宏也是过程宏(procedural macros),它们可以被用来做任何事情,没有强制规定它们必须要实现一个 trait,或者它们只能在所有成员都实现 trait 的情况下才能工作,这些只是标准库中派生宏所遵循的惯例。

默认实现(Default Impls)

Trait 可以为它们的函数和方法提供默认实现。

trait Trait {
    fn method(&self) {
        println!("default impl");
    }
}

struct SomeType;
struct OtherType;

// use default impl for Trait::method
impl Trait for SomeType {}

impl Trait for OtherType {
    // use our own impl for Trait::method
    fn method(&self) {
        println!("OtherType impl");
    }
}

fn main() {
    SomeType.method(); // prints "default impl"
    OtherType.method(); // prints "OtherType impl"
}

如果 trait 中的某些方法是完全通过 trait 的另一些方法来实现的,这就非常方便了。

trait Greet {
    fn greet(&self, name: &str) -> String;
    fn greet_loudly(&self, name: &str) -> String {
        self.greet(name) + "!"
    }
}

struct Hello;
struct Hola;

impl Greet for Hello {
    fn greet(&self, name: &str) -> String {
        format!("Hello {}", name)
    }
    // use default impl for greet_loudly
}

impl Greet for Hola {
    fn greet(&self, name: &str) -> String {
        format!("Hola {}", name)
    }
    // override default impl
    fn greet_loudly(&self, name: &str) -> String {
        let mut greeting = self.greet(name);
        greeting.insert_str(0, "¡");
        greeting + "!"
    }
}

fn main() {
    println!("{}", Hello.greet("John")); // prints "Hello John"
    println!("{}", Hello.greet_loudly("John")); // prints "Hello John!"
    println!("{}", Hola.greet("John")); // prints "Hola John"
    println!("{}", Hola.greet_loudly("John")); // prints "¡Hola John!"
}

标准库中的很多 trait 为很多它们的方法提供了默认实现。

泛型覆盖实现(Generic Blanket Impls)

泛型覆盖实现是一种在泛型类型而不是具体类型上的实现,为了解释为什么以及如何使用它,让我们从为整数类型实现一个is_even方法开始。


#![allow(unused)]
fn main() {
trait Even {
    fn is_even(self) -> bool;
}

impl Even for i8 {
    fn is_even(self) -> bool {
        self % 2_i8 == 0_i8
    }
}

impl Even for u8 {
    fn is_even(self) -> bool {
        self % 2_u8 == 0_u8
    }
}

impl Even for i16 {
    fn is_even(self) -> bool {
        self % 2_i16 == 0_i16
    }
}

// etc

#[test] // ✅
fn test_is_even() {
    assert!(2_i8.is_even());
    assert!(4_u8.is_even());
    assert!(6_i16.is_even());
    // etc
}
}

很明显,上面的实现十分啰嗦。而且,所有我们的实现几乎都是一样的。此外,如果 Rust 决定在未来增加更多的整数类型,我们必须回到这段代码中,用新的整数类型来更新它。我们可以通过使用泛型覆盖实现来解决所有的问题。


#![allow(unused)]
fn main() {
use std::fmt::Debug;
use std::convert::TryInto;
use std::ops::Rem;

trait Even {
    fn is_even(self) -> bool;
}

// generic blanket impl
impl<T> Even for T
where
    T: Rem<Output = T> + PartialEq<T> + Sized,
    u8: TryInto<T>,
    <u8 as TryInto<T>>::Error: Debug,
{
    fn is_even(self) -> bool {
        // these unwraps will never panic
        self % 2.try_into().unwrap() == 0.try_into().unwrap()
    }
}

#[test] // ✅
fn test_is_even() {
    assert!(2_i8.is_even());
    assert!(4_u8.is_even());
    assert!(6_i16.is_even());
    // etc
}
}

不同于默认实现,泛型覆盖实现提供了方法的实现,所以它们不能被重写。


#![allow(unused)]
fn main() {
use std::fmt::Debug;
use std::convert::TryInto;
use std::ops::Rem;

trait Even {
    fn is_even(self) -> bool;
}

impl<T> Even for T
where
    T: Rem<Output = T> + PartialEq<T> + Sized,
    u8: TryInto<T>,
    <u8 as TryInto<T>>::Error: Debug,
{
    fn is_even(self) -> bool {
        self % 2.try_into().unwrap() == 0.try_into().unwrap()
    }
}

impl Even for u8 { // ❌
    fn is_even(self) -> bool {
        self % 2_u8 == 0_u8
    }
}
}

上面的代码会抛出下面的错误:

error[E0119]: conflicting implementations of trait `Even` for type `u8`:
  --> src/lib.rs:22:1
   |
10 | / impl<T> Even for T
11 | | where
12 | |     T: Rem<Output = T> + PartialEq<T> + Sized,
13 | |     u8: TryInto<T>,
...  |
19 | |     }
20 | | }
   | |_- first implementation here
21 |
22 |   impl Even for u8 {
   |   ^^^^^^^^^^^^^^^^ conflicting implementation for `u8`

这些实现有重叠,因此它们是冲突的,所以 Rust 拒绝编译这段代码以确保 trait 的一致性。trait 一致性是指,对于任意给定的类型,最多存在某一 trait 的一个实现。Rust 用来强制执行特质一致性的规则,这些规则的含义,以及针对这些含义的变通方案都不在本文的讨论范围之内。

Subtraits & Supertraits

subtrait中的sub指的是子集(subset),supertrait中的super指的是超集(superset)。如果我们有下面这个 trait 声明:


#![allow(unused)]
fn main() {
trait Subtrait: Supertrait {}
}

所有实现了Subtrait的类型是所有实现了Supertrait的类型的子集,或者反过来讲:所有实现了Supertrait的类型是所有实现了Subtrait类型的子集。而且,上面的代码是一种语法糖,展开来应该是:


#![allow(unused)]
fn main() {
trait Subtrait where Self: Supertrait {}
}

这是一个微妙而重要的区别,要明白约束在Self上,也就是实现Subtrait的类型而非Subtrait自身。后者也没有意义,因为 trait 约束只能作用于能够实现 trait 的具体类型,trait 本身不能实现其他的 trait:

trait Supertrait {
    fn method(&self) {
        println!("in supertrait");
    }
}

trait Subtrait: Supertrait {
    // this looks like it might impl or
    // override Supertrait::method but it
    // does not
    fn method(&self) {
        println!("in subtrait")
    }
}

struct SomeType;

// adds Supertrait::method to SomeType
impl Supertrait for SomeType {}

// adds Subtrait::method to SomeType
impl Subtrait for SomeType {}

// both methods exist on SomeType simultaneously
// neither overriding or shadowing the other

fn main() {
    SomeType.method(); // ❌ ambiguous method call
    // must disambiguate using fully-qualified syntax
    <SomeType as Supertrait>::method(&st); // ✅ prints "in supertrait"
    <SomeType as Subtrait>::method(&st); // ✅ prints "in subtrait"
}

此外,对于一个类型如何同时实现一个 subtrait 和一个 supertrait,也没有明确的规则。它可以在另一个类型的实现中实现其他的方法。

trait Supertrait {
    fn super_method(&mut self);
}

trait Subtrait: Supertrait {
    fn sub_method(&mut self);
}

struct CallSuperFromSub;

impl Supertrait for CallSuperFromSub {
    fn super_method(&mut self) {
        println!("in super");
    }
}

impl Subtrait for CallSuperFromSub {
    fn sub_method(&mut self) {
        println!("in sub");
        self.super_method();
    }
}

struct CallSubFromSuper;

impl Supertrait for CallSubFromSuper {
    fn super_method(&mut self) {
        println!("in super");
        self.sub_method();
    }
}

impl Subtrait for CallSubFromSuper {
    fn sub_method(&mut self) {
        println!("in sub");
    }
}

struct CallEachOther(bool);

impl Supertrait for CallEachOther {
    fn super_method(&mut self) {
        println!("in super");
        if self.0 {
            self.0 = false;
            self.sub_method();
        }
    }
}

impl Subtrait for CallEachOther {
    fn sub_method(&mut self) {
        println!("in sub");
        if self.0 {
            self.0 = false;
            self.super_method();
        }
    }
}

fn main() {
    CallSuperFromSub.super_method(); // prints "in super"
    CallSuperFromSub.sub_method(); // prints "in sub", "in super"

    CallSubFromSuper.super_method(); // prints "in super", "in sub"
    CallSubFromSuper.sub_method(); // prints "in sub"

    CallEachOther(true).super_method(); // prints "in super", "in sub"
    CallEachOther(true).sub_method(); // prints "in sub", "in super"
}

希望上面的例子能够表达出,subtrait 和 supertrait 之间可以是很复杂的关系。在介绍能够将这些复杂性进行整洁封装的心智模型之前,让我们快速回顾并建立我们用来理解泛型类型上的 trait 约束的心智模型。


#![allow(unused)]
fn main() {
fn function<T: Clone>(t: T) {
    // impl
}
}

在不知道这个函数的实现的情况下,我们可以合理地猜测,t.clone()会在某个时候被调用,因为当一个泛型类型被一个 trait 所约束时,意味着它对 trait 有依赖性。泛型与 trait 约束之间关系的心智模型是一个简单而直观的模型:泛型依赖于 trait 约束。

现在让我们看看Copy的 trait 声明:


#![allow(unused)]
fn main() {
trait Copy: Clone {}
}

上面的语法看起来与在一个泛型类型上应用 trait 约束很相似,但是Copy完全不依赖于Clone。之前的模型在这里没有帮助。个人认为,理解 subtrait 和 supertrait 最为简洁优雅的心智模型是:subtrait 细化(refine)了它们的 supertrait。

“细化(Refinement)”刻意保持一定的模糊性,因为它们在不同的上下文环境中会有不同的含义:

  • subtrait 可能会使得 supertrait 的方法实现更为具体,快速,占用更少的内存,例如,Copy:Clone

  • subtrait 可能会对 supertrait 的方法实现增加额外的保证,例如:Eq: PartialEq,Ord: PartialOrd,ExactSizeIterator: Iterator;

  • subtrait 可能会使得 supertrait 的方法更为灵活和易于调用,例如:FnMut: FnOnce,Fn: FnMut;

  • subtrait 可能会扩展 supertrait 并添加新的方法,例如:DoubleEndedIterator: Iterator,ExactSizeIterator: Iterator

Trait 对象

泛型给我们提供了编译期多态,而 trait 对象给我们提供了运行时多态。我们可以使用 trait 对象来让函数在运行时动态地返回不同的类型。


#![allow(unused)]
fn main() {
fn example(condition: bool, vec: Vec<i32>) -> Box<dyn Iterator<Item = i32>> {
    let iter = vec.into_iter();
    if condition {
        // Has type:
        // Box<Map<IntoIter<i32>, Fn(i32) -> i32>>
        // But is cast to:
        // Box<dyn Iterator<Item = i32>>
        Box::new(iter.map(|n| n * 2))
    } else {
        // Has type:
        // Box<Filter<IntoIter<i32>, Fn(&i32) -> bool>>
        // But is cast to:
        // Box<dyn Iterator<Item = i32>>
        Box::new(iter.filter(|&n| n >= 2))
    }
}
}

Trait 对象还允许我们在集合中存储多种类型:


#![allow(unused)]
fn main() {
use std::f64::consts::PI;

struct Circle {
    radius: f64,
}

struct Square {
    side: f64
}

trait Shape {
    fn area(&self) -> f64;
}

impl Shape for Circle {
    fn area(&self) -> f64 {
        PI * self.radius * self.radius
    }
}

impl Shape for Square {
    fn area(&self) -> f64 {
        self.side * self.side
    }
}

fn get_total_area(shapes: Vec<Box<dyn Shape>>) -> f64 {
    shapes.into_iter().map(|s| s.area()).sum()
}

fn example() {
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 1.0 }), // Box<Circle> cast to Box<dyn Shape>
        Box::new(Square { side: 1.0 }), // Box<Square> cast to Box<dyn Shape>
    ];
    assert_eq!(PI + 1.0, get_total_area(shapes)); // ✅
}
}

Trait 对象是没有大小的,所以它们必须总是在一个指针后面。我们可以根据类型中dyn关键字的存在来区分具体类型和 trait 对象在类型级别上的区别。


#![allow(unused)]
fn main() {
struct Struct;
trait Trait {}

// regular struct
&Struct
Box<Struct>
Rc<Struct>
Arc<Struct>

// trait objects
&dyn Trait
Box<dyn Trait>
Rc<dyn Trait>
Arc<dyn Trait>
}

不是所有的 trait 都可以被转成 trait 对象。当且仅当一个 trait 满足下面这些要求时,它才是对象安全的(object-safe):

  • trait 不要求Self:Sized
  • trait 的所有方法都是对象安全的

当一个 trait 方法满足下面的要求时,该方法是对象安全的:

  • 方法要求Self:Sized 或者
  • 方法在其接收者位置仅使用一个Self类型

理解为什么要求是这样的,与本文的其余部分无关,但如果你仍然好奇,可以阅读Sizeness in Rust(译注:Sizedness in Rust 这篇文章已翻译,可在公众号翻阅往期文章)。

标记 Trait(Marker Traits)

标记 trait 是不含 trait 项的 trait。它们的工作把实现类型“标记(mark)”为具有某种属性,否则就没有办法在类型系统中去表示。


#![allow(unused)]
fn main() {
// Impling PartialEq for a type promises
// that equality for the type has these properties:
// - symmetry: a == b implies b == a, and
// - transitivity: a == b && b == c implies a == c
// But DOES NOT promise this property:
// - reflexivity: a == a
trait PartialEq {
    fn eq(&self, other: &Self) -> bool;
}

// Eq has no trait items! The eq method is already
// declared by PartialEq, but "impling" Eq
// for a type promises this additional equality property:
// - reflexivity: a == a
trait Eq: PartialEq {}

// f64 impls PartialEq but not Eq because NaN != NaN
// i32 impls PartialEq & Eq because there's no NaNs :)
}

自动 Trait(Auto Trait)

自动 Trait 是指如果一个类型的所有成员都实现了该 trait,该类型就会自动实现该 trait。“成员(member)”的含义取决于类型,例如:结构体的字段、枚举的变量、数组的元素、元组的项,等等。

所有的自动 trait 都是标记 trait,但不是所有的标记 trait 都是自动 trait。自动 trait 必须是标记 trait,所以编译器可以为它们提供一个自动的默认实现,如果它们有任何 trait 项,这就不可能实现了。

自动 trait 的例子。


#![allow(unused)]
fn main() {
// implemented for types which are safe to send between threads
unsafe auto trait Send {}

// implemented for types whose references are safe to send between threads
unsafe auto trait Sync {}
}

不安全 Trait(Unsafe Trait)

Trait 可以被标记为 unsafe,以表明实现该 trait 可能需要 unsafe 代码。SendSync都被标记为 unsafe,因为如果它们不是自动实现的类型,就意味着它必须包含一些非Send或非Sync的成员,如果我们想手动标记类型为SendSync,作为实现者我们必须格外小心,确保没有数据竞争。

自动 Trait

Send & Sync

所需预备知识


#![allow(unused)]
fn main() {
unsafe auto trait Send {}
unsafe auto trait Sync {}
}

如果一个类型是Send,这就意味着它可以在线程之间被安全地发送(send)。如果一个类型是Sync,这就意味着它可以在线程间安全地共享引用。说得更准确点就是,当且仅当&TSend时,类型TSync

几乎所有的类型都是SendSync。唯一值得注意的Send例外是RcSync例外中需要注意的是RcCellRefCell。如果我们需要一个满足SendRc,我们可以使用Arc。如果我们需要一个CellRefCellSync版本,我们可以使用MutexRwLock。尽管我们使用MutexRwLock来包装一个原始类型,但通常来讲,使用标准库提供的原子类型会更好一些,比如AtomicBoolAtomicI32AtomicUsize等等。

几乎所有的类型都是Sync这件事,可能会让一些人感到惊讶,但它是真的,即使是对于没有任何内部同步的类型来讲,也是如此。这能够得以实现要归功于 Rust 严格的借用规则。

我们可以传递同一份数据的若干个不可变引用到多个线程中,由于只要有不可变引用存在,Rust 就会静态地保证底层数据不被修改,所以我们可以保证不会发生数据竞争。

use crossbeam::thread;

fn main() {
    let mut greeting = String::from("Hello");
    let greeting_ref = &greeting;

    thread::scope(|scoped_thread| {
        // spawn 3 threads
        for n in 1..=3 {
            // greeting_ref copied into every thread
            scoped_thread.spawn(move |_| {
                println!("{} {}", greeting_ref, n); // prints "Hello {n}"
            });
        }

        // line below could cause UB or data races but compiler rejects it
        greeting += " world"; // ❌ cannot mutate greeting while immutable refs exist
    });

    // can mutate greeting after every thread has joined
    greeting += " world"; // ✅
    println!("{}", greeting); // prints "Hello world"
}

同样地,我们可以把数据的一个可变引用传递给一个单独的线程,由于 Rust 静态地保证不存在可变引用的别名,所以底层数据不会通过另一个可变引用被修改,因此我们也可以保证不会发生数据竞争。

use crossbeam::thread;

fn main() {
    let mut greeting = String::from("Hello");
    let greeting_ref = &mut greeting;

    thread::scope(|scoped_thread| {
        // greeting_ref moved into thread
        scoped_thread.spawn(move |_| {
            *greeting_ref += " world";
            println!("{}", greeting_ref); // prints "Hello world"
        });

        // line below could cause UB or data races but compiler rejects it
        greeting += "!!!"; // ❌ cannot mutate greeting while mutable refs exist
    });

    // can mutate greeting after the thread has joined
    greeting += "!!!"; // ✅
    println!("{}", greeting); // prints "Hello world!!!"
}

这就是为什么大多数类型在不需要任何显式同步的情况下,都满足Sync的原因。当我们需要在多线程中同时修改某个数据T时,除非我们用Arc<Mutex<T>>或者Arc<RwLock<T>>来包装这个数据,否则编译器是不会允许我们进行这种操作,所以编译器会在需要时强制要求进行显式地同步。

Sized

如果一个类型是Sized,这意味着它的类型大小在编译期是可知的,并且可以在栈上创建一个该类型的实例。

类型的大小及其含义是一个微妙而巨大的话题,影响到编程语言的许多方面。因为它十分重要,所以我单独写了一篇文章Sizedness in Rust,如果有人想要更深入地了解 sizedness,我强烈推荐阅读这篇文章。我会把这篇文章的关键内容总结在下面。

  1. 所有的泛型类型都有一个隐含的Sized约束。

#![allow(unused)]
fn main() {
fn func<T>(t: &T) {}

// example above desugared
fn func<T: Sized>(t: &T) {}
}
  1. 因为所有的泛型类型上都有一个隐含的Sized约束,如果我们想要选择退出这个约束,我们需要使用特定的“宽松约束(relaxed bound)”语法——?Sized,该语法目前只为Sized trait 存在。

#![allow(unused)]
fn main() {
// now T can be unsized
fn func<T: ?Sized>(t: &T) {}
}
  1. 所有的 trait 都有一个隐含的?Sized约束。

#![allow(unused)]
fn main() {
trait Trait {}

// example above desugared
trait Trait: ?Sized {}
}

这是为了让 trait 对象能够实现 trait,重申一下,所有的细枝末节都在Sizedness in Rust中。

泛型 traits

Default


#![allow(unused)]
fn main() {
trait Default {
    fn default() -> Self;
}
}

可以为实现了Default的类型构造默认值。


#![allow(unused)]
fn main() {
struct Color {
    r: u8,
    g: u8,
    b: u8,
}

impl Default for Color {
    // default color is black
    fn default() -> Self {
        Color {
            r: 0,
            g: 0,
            b: 0,
        }
    }
}
}

这在快速构建原型的时候十分有用,尤其是在我们没有过多要求而只需要一个类型实例的情况下:

fn main() {
    // just give me some color!
    let color = Color::default();
}

当我们想要显式地把函数暴露给用户时,也可以选择这样做:


#![allow(unused)]
fn main() {
struct Canvas;
enum Shape {
    Circle,
    Rectangle,
}

impl Canvas {
    // let user optionally pass a color
    fn paint(&mut self, shape: Shape, color: Option<Color>) {
        // if no color is passed use the default color
        let color = color.unwrap_or_default();
        // etc
    }
}
}

当我们需要构造泛型类型时,Default在泛型上下文中也是有用的:


#![allow(unused)]
fn main() {
fn guarantee_length<T: Default>(mut vec: Vec<T>, min_len: usize) -> Vec<T> {
    for _ in 0..min_len.saturating_sub(vec.len()) {
        vec.push(T::default());
    }
    vec
}
}

我们还可以利用Default类型结合 Rust 的结构体更新语法(struct update syntax)来对结构体部分初始化。现在,我们有一个Color结构体构造函数new,该函数接收结构体的所有成员作为参数:


#![allow(unused)]
fn main() {
impl Color {
    fn new(r: u8, g: u8, b: u8) -> Self {
        Color {
            r,
            g,
            b,
        }
    }
}
}

但是,我们可以有更为便利的构造函数,这些构造函数分别只接收结构体的一部分成员,结构体剩下的其他成员使用默认值:


#![allow(unused)]
fn main() {
impl Color {
    fn red(r: u8) -> Self {
        Color {
            r,
            ..Color::default()
        }
    }
    fn green(g: u8) -> Self {
        Color {
            g,
            ..Color::default()
        }
    }
    fn blue(b: u8) -> Self {
        Color {
            b,
            ..Color::default()
        }
    }
}
}

还有一个Default派生宏,通过使用它我们可以像下面这样来写Color


#![allow(unused)]
fn main() {
// default color is still black
// because u8::default() == 0
#[derive(Default)]
struct Color {
    r: u8,
    g: u8,
    b: u8
}
}

Clone


#![allow(unused)]
fn main() {
trait Clone {
    fn clone(&self) -> Self;

    // provided default impls
    fn clone_from(&mut self, source: &Self);
}
}

我们能够把Clone类型的不可变引用转换为所拥有的值,即&T->TClone不保证这种转换的效率,所以它会很慢并且成本较高。我们可以使用派生宏在一个类型上快速实现Clone


#![allow(unused)]
fn main() {
#[derive(Clone)]
struct SomeType {
    cloneable_member1: CloneableType1,
    cloneable_member2: CloneableType2,
    // etc
}

// macro generates impl below
impl Clone for SomeType {
    fn clone(&self) -> Self {
        SomeType {
            cloneable_member1: self.cloneable_member1.clone(),
            cloneable_member2: self.cloneable_member2.clone(),
            // etc
        }
    }
}
}

Clone可以用于在泛型上下文中构造一个类型实例。下面是从前面章节拿过来的一个例子,其中的Default被替换为了Clone


#![allow(unused)]
fn main() {
fn guarantee_length<T: Clone>(mut vec: Vec<T>, min_len: usize, fill_with: &T) -> Vec<T> {
    for _ in 0..min_len.saturating_sub(vec.len()) {
        vec.push(fill_with.clone());
    }
    vec
}
}

人们通常把克隆(clone)作为一种避免和借用检查器打交道的逃生出口(escape hatch)。管理带有引用的结构体很具有挑战性,但是我们可以通过克隆把引用变为所拥有的值。


#![allow(unused)]
fn main() {
// oof, we gotta worry about lifetimes 😟
struct SomeStruct<'a> {
    data: &'a Vec<u8>,
}

// now we're on easy street 😎
struct SomeStruct {
    data: Vec<u8>,
}
}

如果我们正在编写的程序对性能不敏感,那么我们就不需要担心克隆数据的问题。Rust 是一门暴露了很多底层细节的语言,所以开发者很容易陷入过早的优化而非真正解决眼前的问题。对于很多程序来讲,最好的优先级顺序通常是,首先构建正确性,其次是优雅性,第三是性能,仅当在对性能进行剖析并确定性能瓶颈之后再去关注性能。通常而言,这是一个值得采纳的好建议,但是你需要清楚,它未必适用于你的程序。

Copy


#![allow(unused)]
fn main() {
trait Copy:Clone{}
}

我们拷贝Copy类型,例如:T->T.Copy承诺拷贝操作是简单的按位拷贝,所以它是快速高效的。我们不能自己实现Copy,只有编译器可以提供实现,但是我们可以通过使用Copy派生宏让编译器这么做,就像使用Clone派生宏一样,因为CopyClone的一个 subtrait:


#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
struct SomeType;
}

CopyClone进行了细化。一个克隆(clone)操作可能很慢并且开销很大,但是拷贝(copy)操作保证是快速且开销较小的,所以拷贝是一种更快的克隆操作。如果一个类型实现了CopyClone实现就无关紧要了:


#![allow(unused)]
fn main() {
// this is what the derive macro generates
impl<T: Copy> Clone for T {
    // the clone method becomes just a copy
    fn clone(&self) -> Self {
        *self
    }
}
}

当一个类型实现了Copy之后,它在被移动(move)时的行为就发生了改变。默认情况下,所有的类型都有移动(move)语义 ,但是一旦某个类型实现了Copy,它就有了拷贝(copy)语义 。为了解释二者的不同,让我们看一下这些简单的场景:


#![allow(unused)]
fn main() {
// a "move", src: !Copy
let dest = src;

// a "copy", src: Copy
let dest = src;
}

在上面两种情况下,dest = srcsrc的内容进行按位拷贝并把结果移动到dest,唯一的不同是,在第一种情况("a move")中,借用检查器使得src变量失效并确保它后面不会在任何其他地方被使用;在第二种情况下("a copy")中,src仍然是有效且可用的。

简而言之:拷贝就是移动,移动就是拷贝。它们之间唯一的区别就是其对待借用检查器的方式。

来看一个关于移动(move)的更具体的例子,假定sec是一个Vec<i32>类型,并且它的内容看起来像下面这样:


#![allow(unused)]
fn main() {
{ data: *mut [i32], length: usize, capacity: usize }
}

当我们执行了dest = src,我们会得到:


#![allow(unused)]
fn main() {
src = { data: *mut [i32], length: usize, capacity: usize }
dest = { data: *mut [i32], length: usize, capacity: usize }
}

在这个未知,srcdest对同一份数据各有一个可变引用别名,这是一个大忌,因此,借用检查器让src变量失效,在编译器不报错的情况下。使得它不能再被使用。

再来看一个关于拷贝(copy)的更具体的例子,假定src是一个Option<i32>,且它的内容看起来如下:


#![allow(unused)]
fn main() {
{ is_valid: bool, data: i32 }
}

现在,当我们执行dest = src时,我们会得到:


#![allow(unused)]
fn main() {
src = { is_valid: bool, data: i32 }
dest = { is_valid: bool, data: i32 }
}

它们俩同时都是可用的!因此,Option<i32>Copy

尽管Copy是一个自动 trait,但是 Rust 语言设计者决定,让类型显式地选择拷贝语义,而不是在类型符合条件时默默地继承拷贝语义,因为后者可能会引起经常导致 bug 的混乱行为。

Any


#![allow(unused)]
fn main() {
trait Any: 'static {
    fn type_id(&self) -> TypeId;
}
}

Rust 的多态风格是参数化的,但是如果我们正在尝试使用一种类似于动态类型语言的更为特别(ad-hoc)的多态风格,那么我们可以通过使用Any trait 来进行模拟。我们不必手动为我们的类型实现Any trait,因为这已经被 generic blanket impl 所涵盖:


#![allow(unused)]
fn main() {
impl<T: 'static + ?Sized> Any for T {
    fn type_id(&self) -> TypeId {
        TypeId::of::<T>()
    }
}

}

我们通过使用downcast_ref::<T>()downcast_mut::<T>()方法从一个dyn Any中拿出一个T:

use std::any::Any;

#[derive(Default)]
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn inc(&mut self) {
        self.x += 1;
        self.y += 1;
    }
}

fn map_any(mut any: Box<dyn Any>) -> Box<dyn Any> {
    if let Some(num) = any.downcast_mut::<i32>() {
        *num += 1;
    } else if let Some(string) = any.downcast_mut::<String>() {
        *string += "!";
    } else if let Some(point) = any.downcast_mut::<Point>() {
        point.inc();
    }
    any
}

fn main() {
    let mut vec: Vec<Box<dyn Any>> = vec![
        Box::new(0),
        Box::new(String::from("a")),
        Box::new(Point::default()),
    ];
    // vec = [0, "a", Point { x: 0, y: 0 }]
    vec = vec.into_iter().map(map_any).collect();
    // vec = [1, "a!", Point { x: 1, y: 1 }]
}

这个 trait 很少需要用到,因为在大多数情况下,参数化多态要优于临时多态性,后者也可以用枚举(enum)来模拟,枚举具有更好的类型安全,需要的间接(抽象)也更少。例如,我们可以用下面的方式实现上面的例子:

#[derive(Default)]
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn inc(&mut self) {
        self.x += 1;
        self.y += 1;
    }
}

enum Stuff {
    Integer(i32),
    String(String),
    Point(Point),
}

fn map_stuff(mut stuff: Stuff) -> Stuff {
    match &mut stuff {
        Stuff::Integer(num) => *num += 1,
        Stuff::String(string) => *string += "!",
        Stuff::Point(point) => point.inc(),
    }
    stuff
}

fn main() {
    let mut vec = vec![
        Stuff::Integer(0),
        Stuff::String(String::from("a")),
        Stuff::Point(Point::default()),
    ];
    // vec = [0, "a", Point { x: 0, y: 0 }]
    vec = vec.into_iter().map(map_stuff).collect();
    // vec = [1, "a!", Point { x: 1, y: 1 }]
}

尽管Any很少被需要用到,但是在某些时候它也会十分地便利,正如我们在后面错误处理(Error Handling)部分所看到的那样。

格式化 Traits (Formatting Traits)

我们可以使用std::fmt中的格式化宏来把类型序列化(serialize)为字符串,其中最为我们熟知的就是println!。我们可以把格式化参数传递给{}占位符,这些占位符用于选择使用哪个 trait 来序列化占位符参数。

TraitPlaceholderDescription
Display{}显示表示
Debug{:?}调试表示
Octal{:o}八进制表示
LowerHex{:x}小写十六进制表示
UpperHex{:X}大写十六进制表示
Pointer{:p}内存地址
Binary{:b}二进制表示
LowerExp{:e}小写指数表示
UpperExp{:E}大写指数表示

Display & ToString


#![allow(unused)]
fn main() {
trait Display {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result;
}
}

Display类型可以被序列化为对用户更为友好的String类型。以Point类型为列:

use std::fmt;

#[derive(Default)]
struct Point {
    x: i32,
    y: i32,
}

impl fmt::Display for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "({}, {})", self.x, self.y)
    }
}

fn main() {
    println!("origin: {}", Point::default());
    // prints "origin: (0, 0)"

    // get Point's Display representation as a String
    let stringified_point = format!("{}", Point::default());
    assert_eq!("(0, 0)", stringified_point); // ✅
}

除了使用format!宏让一个类型以String类型显示,我们还可以使用ToString trait:


#![allow(unused)]
fn main() {
trait ToString {
    fn to_string(&self) -> String;
}
}

这个 trait 不需要我们实现,事实上,由于 generic blanket impl,我们也不能去实现它,因为所有实现了Display的类型都会自动实现ToString


#![allow(unused)]
fn main() {
impl<T: Display + ?Sized> ToString for T;
}

Point上使用ToString


#![allow(unused)]
fn main() {
#[test] // ✅
fn display_point() {
    let origin = Point::default();
    assert_eq!(format!("{}", origin), "(0, 0)");
}

#[test] // ✅
fn point_to_string() {
    let origin = Point::default();
    assert_eq!(origin.to_string(), "(0, 0)");
}

#[test] // ✅
fn display_equals_to_string() {
    let origin = Point::default();
    assert_eq!(format!("{}", origin), origin.to_string());
}
}

Debug


#![allow(unused)]
fn main() {
trait Debug {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result;
}
}

DebugDisplay有着相同的签名。唯一的不同在于,只有当我门指定了{:?}才会调用Debug实现。Debug可以被派生:


#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug)]
struct Point {
    x: i32,
    y: i32,
}

// derive macro generates impl below
impl fmt::Debug for Point {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Point")
            .field("x", &self.x)
            .field("y", &self.y)
            .finish()
    }
}
}

为一个类型实现Debug能够使得这个类型在dbg!中使用,dbg!宏在快速打印日志方面比println!更合适,它的一些优势如下:

  1. dbg!打印到 stderr 而不是 stdout,因此在我们的程序中,能够很容易地和标准输出的输出结果区分。
  2. dbg!会连同传入的表达式和表达式的计算结果一起打印出来。
  3. dbg!会获取传入参数的所有权并将其返回,因此你可以在表达式中使用它:

#![allow(unused)]
fn main() {
fn some_condition() -> bool {
    true
}

// no logging
fn example() {
    if some_condition() {
        // some code
    }
}

// println! logging
fn example_println() {
    // 🤦
    let result = some_condition();
    println!("{}", result); // just prints "true"
    if result {
        // some code
    }
}

// dbg! logging
fn example_dbg() {
    // 😍
    if dbg!(some_condition()) { // prints "[src/main.rs:22] some_condition() = true"
        // some code
    }
}

}

dbg!的唯一缺点就是它不会在 release 构建中自动裁剪,所以如果我们不想在最后生成的二进制包含这些内容,就必须手动移除它。

操作符 Trait(Operator Traits)

Rust 中所有的操作符都和 trait 关联,如果我们想要为我们的类型实现一些操作符,我们就必须实现与之关联的 trait。 | Trait(s) | 分类(Category) | 操作符(Operator(s)) | 描述(Description) | |----------|----------|-------------|-------------| | Eq, PartialEq | 比较 | == | 相等 | | Ord, PartialOrd | 比较 | <, >, <=, >= | 比较 | | Add | 算术 | + | 相加 | | AddAssign | 算术 | += | 相加并赋值 | | BitAnd | 算术 | & | 按位与 | | BitAndAssign | 算术 | &= | 按位与并赋值 | | BitXor | 算术 | ^ | 按位异或 | | BitXorAssign | 算术 | ^= | 按位异或并赋值 | | Div | 算术 | / | 除 | | DivAssign | 算术 | /= | 除并赋值 | | Mul | 算术 | * | 乘 | | MulAssign | 算术 | *= | 乘并赋值 | | Neg | 算术 | - | 一元求反 | | Not | 算术 | ! | 一元逻辑求反 | | Rem | 算术 | % | 求余 | | RemAssign | 算术 | %= | 求余并赋值| | Shl | 算术 | << | 左移 | | ShlAssign | 算术 | <<= | 左移并赋值 | | Shr | 算术 | >> | 右移 | | ShrAssign | 算术 | >>= | 右移并赋值 | | Sub | 算术 | - | 减 | | SubAssign | 算术 | -= | 减并赋值 | | Fn | 闭包 | (...args) | 不可变闭包调用 | | FnMut | 闭包 | (...args) | 可变闭包调用 | | FnOnce | 闭包 | (...args) | 一次性闭包调用 | | Deref | 其他 | * | 不可变解引用 | | DerefMut | 其他 | * | 可变解引用 | | Drop | 其他 | - | 类型析构 | | Index | 其他 | [] | 不可变索引 | | IndexMut | 其他 | [] |可变索引| | RangeBounds | 其他 | .. | 区间 |

比较 Trait (Comparison Traits)

Trait(s)分类(Category)操作符(Operator(s))描述(Description)
Eq, PartialEq比较==相等
Ord, PartialOrd比较<, >, <=, >=比较

PartialEq & Eq


#![allow(unused)]
fn main() {
trait PartialEq<Rhs = Self>
where
    Rhs: ?Sized,
{
    fn eq(&self, other: &Rhs) -> bool;

    // provided default impls
    fn ne(&self, other: &Rhs) -> bool;
}

}

PartialEq<Rhs>类型可以通过==操作符检查是否和Rhs类型相等。

所有的PartialEq<Rhs>实现必须确保相等性是对称的和可传递的。这意味着,对于任意的abc:

  • a == b也意味着b == a(对称性)
  • a == b && b == c 意味着 a == c (传递性)

默认情况下,Rhs = Self,因为我们几乎总是想要比较同一类型的不同实例,而不是不同类型的不同实例。这也保证了我们的实现是对称的和可传递的。


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32
}

// Rhs == Self == Point
impl PartialEq for Point {
    // impl automatically symmetric & transitive
    fn eq(&self, other: &Point) -> bool {
        self.x == other.x && self.y == other.y
    }
}
}

如果一个类型的所有成员都实现了PartialEq,则它会派生实现PartialEq


#![allow(unused)]
fn main() {
#[derive(PartialEq)]
struct Point {
    x: i32,
    y: i32
}

#[derive(PartialEq)]
enum Suit {
    Spade,
    Heart,
    Club,
    Diamond,
}

}

一旦我们为自己的类型实现了PartialEq,我们就能够轻松地在类型的引用之间进行相等性比较,这要归功于 generic blanket impls:


#![allow(unused)]
fn main() {
// this impl only gives us: Point == Point
#[derive(PartialEq)]
struct Point {
    x: i32,
    y: i32
}

// all of the generic blanket impls below
// are provided by the standard library

// this impl gives us: &Point == &Point
impl<A, B> PartialEq<&'_ B> for &'_ A
where A: PartialEq<B> + ?Sized, B: ?Sized;

// this impl gives us: &mut Point == &Point
impl<A, B> PartialEq<&'_ B> for &'_ mut A
where A: PartialEq<B> + ?Sized, B: ?Sized;

// this impl gives us: &Point == &mut Point
impl<A, B> PartialEq<&'_ mut B> for &'_ A
where A: PartialEq<B> + ?Sized, B: ?Sized;

// this impl gives us: &mut Point == &mut Point
impl<A, B> PartialEq<&'_ mut B> for &'_ mut A
where A: PartialEq<B> + ?Sized, B: ?Sized;

}

因为这个 trait 是泛型的,所以我们可以在不同的类型之间定义相等性(比较)。标准库利用这一点实现了类字符串类型之间的相互比较,比如String&strPathBuf&PathOsString&OsStr等等。

通常,我们应该仅为特定的不同类型之间实现相等性,这些不同类型包含了相同类型的数据,并且它们之间唯一的区别是表现数据的方式和与数据交互的方式。

下面是一个反面实例,关于某人试图在没有满足上述规则的不同类型之间实现PartialEq用以检查完整性的例子:

#[derive(PartialEq)]
enum Suit {
    Spade,
    Club,
    Heart,
    Diamond,
}

#[derive(PartialEq)]
enum Rank {
    Ace,
    Two,
    Three,
    Four,
    Five,
    Six,
    Seven,
    Eight,
    Nine,
    Ten,
    Jack,
    Queen,
    King,
}

#[derive(PartialEq)]
struct Card {
    suit: Suit,
    rank: Rank,
}

// check equality of Card's suit
impl PartialEq<Suit> for Card {
    fn eq(&self, other: &Suit) -> bool {
        self.suit == *other
    }
}

// check equality of Card's rank
impl PartialEq<Rank> for Card {
    fn eq(&self, other: &Rank) -> bool {
        self.rank == *other
    }
}

fn main() {
    let AceOfSpades = Card {
        suit: Suit::Spade,
        rank: Rank::Ace,
    };
    assert!(AceOfSpades == Suit::Spade); // ✅
    assert!(AceOfSpades == Rank::Ace); // ✅
}

Eq是一个标记 trait,并且是PartialEq<Self>的一个 subtrait。


#![allow(unused)]
fn main() {
trait Eq: PartialEq<Self> {}
}

如果我们为一个类型实现了Eq,在PartialEq所要求的对称性和可传递性之上,我们还保证了反射性(reflexivity),也就是对于任意的a,都有a == a。从这种意义上来说,EqPartialEq进行了细化,因为它表示了一个更为严格的相等性。如果一个类型的所有成员都实现了Eq,那么Eq的实现可以派生到这个类型。

浮点型实现了PartialEq但是没有实现Eq,因为NaN != NaN。几乎所有其他的实现了PartialEq的类型都实现了Eq,除非它们包含浮点类型。

一旦一个类型实现了PartialEqDebug,我们可以就可以在assert_eq!宏中使用它。我们还可以比较实现了PartialEq类型的集合。


#![allow(unused)]
fn main() {
#[derive(PartialEq, Debug)]
struct Point {
    x: i32,
    y: i32,
}

fn example_assert(p1: Point, p2: Point) {
    assert_eq!(p1, p2);
}

fn example_compare_collections<T: PartialEq>(vec1: Vec<T>, vec2: Vec<T>) {
    // if T: PartialEq this now works!
    if vec1 == vec2 {
        // some code
    } else {
        // other code
    }
}

}

Hash


#![allow(unused)]
fn main() {
trait Hash {
    fn hash<H: Hasher>(&self, state: &mut H);

    // provided default impls
    fn hash_slice<H: Hasher>(data: &[Self], state: &mut H);
}
}

这个 trait 没有与任何操作符关联,但是讨论它的最好时机就是在PartialEqEq之后,所以把它写在这里。Hash类型可以通过一个Hasher被(计算)哈希。


#![allow(unused)]
fn main() {
use std::hash::Hasher;
use std::hash::Hash;

struct Point {
    x: i32,
    y: i32,
}

impl Hash for Point {
    fn hash<H: Hasher>(&self, hasher: &mut H) {
        hasher.write_i32(self.x);
        hasher.write_i32(self.y);
    }
}

}

使用派生宏可以生成和上面一样的实现:


#![allow(unused)]
fn main() {
#[derive(Hash)]
struct Point {
    x: i32,
    y: i32,
}

}

如果一个类型同时实现了HashEq,那么这些实现必须达成一致,从而保证对于所有的ab,如果a == b那么a.hash() == b.hash()。因此,当为一个类型同时实现这两个 trait 时,要么都用派生宏,要么都手动实现,但是不要混合,否则我们就有可能破坏上面的不变性。

为一个类型实现EqHash的最大好处是,它让我们能够把类型作为 key 存储在HashMapHashSet中。


#![allow(unused)]
fn main() {
use std::collections::HashSet;

// now our type can be stored
// in HashSets and HashMaps!
#[derive(PartialEq, Eq, Hash)]
struct Point {
    x: i32,
    y: i32,
}

fn example_hashset() {
    let mut points = HashSet::new();
    points.insert(Point { x: 0, y: 0 }); // ✅
}

}

PartialOrd & Ord


#![allow(unused)]
fn main() {
enum Ordering {
    Less,
    Equal,
    Greater,
}

trait PartialOrd<Rhs = Self>: PartialEq<Rhs>
where
    Rhs: ?Sized,
{
    fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>;

    // provided default impls
    fn lt(&self, other: &Rhs) -> bool;
    fn le(&self, other: &Rhs) -> bool;
    fn gt(&self, other: &Rhs) -> bool;
    fn ge(&self, other: &Rhs) -> bool;
}

}

PartialOrd<Rhs>类型可以通过<<=>=操作符和Rhs类型比较。所有的PartialOrd<Rhs>实现必须保证比较时非对称和可传递的。这意味着,对于任意的abc

  • a < b意味着!(a>b)(非对称性)
  • a < b && b < c 意味着a < c(传递性)

PartialOrdPartialEq的一个 subtrait,并且它们的实现必须相互一致。


#![allow(unused)]
fn main() {
fn must_always_agree<T: PartialOrd + PartialEq>(t1: T, t2: T) {
    assert_eq!(t1.partial_cmp(&t2) == Some(Ordering::Equal), t1 == t2);
}

}

当比较PartialEq类型时,我们可以检查是否它们相等或者不相等,但是当比较PartialOrd类型时,我们除了可以检查是否它们相等或不相等自己哦之外,如果它们不相等,我们还可以检查它们不相等是因为第一项小于第二项或者是第一项大于第二项。

默认情况下,Rhs == Self,因为我们总是想要比较同一类型的实例,而不是对不同类型的实例。这也自动保证了我们的实现是对称的和可传递的。


#![allow(unused)]
fn main() {
use std::cmp::Ordering;

#[derive(PartialEq, PartialOrd)]
struct Point {
    x: i32,
    y: i32
}

// Rhs == Self == Point
impl PartialOrd for Point {
    // impl automatically symmetric & transitive
    fn partial_cmp(&self, other: &Point) -> Option<Ordering> {
        Some(match self.x.cmp(&other.x) {
            Ordering::Equal => self.y.cmp(&other.y),
            ordering => ordering,
        })
    }
}

}

如果一个类型的所有成员都实现了PartialOrd,那么它就可以被派生:


#![allow(unused)]
fn main() {
#[derive(PartialEq, PartialOrd)]
struct Point {
    x: i32,
    y: i32,
}

#[derive(PartialEq, PartialOrd)]
enum Stoplight {
    Red,
    Yellow,
    Green,
}
}

派生宏PartialOrd根据字典序(lexicographical)对它们的成员进行排序:


#![allow(unused)]
fn main() {
// generates PartialOrd impl which orders
// Points based on x member first and
// y member second because that's the order
// they appear in the source code
#[derive(PartialOrd, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

// generates DIFFERENT PartialOrd impl
// which orders Points based on y member
// first and x member second
#[derive(PartialOrd, PartialEq)]
struct Point {
    y: i32,
    x: i32,
}

}

OrdEqPartialOrd<Self>的一个 subtrait:


#![allow(unused)]
fn main() {
trait Ord: Eq + PartialOrd<Self> {
    fn cmp(&self, other: &Self) -> Ordering;

    // provided default impls
    fn max(self, other: Self) -> Self;
    fn min(self, other: Self) -> Self;
    fn clamp(self, min: Self, max: Self) -> Self;
}

}

如果我们为一个类型实现了Ord,在PartialOrd保证了非对称性和传递性之上,我们还能保证整体的非对称性,即对于任意给定的aba < ba == ba > b中必有一个为真。从这个角度来讲,Ord细化了EqPartialOrd,因为它表示一个更严格的比较。如果一个类型实现了Ord,我们就可以利用这个实现来实现PartialOrdPartialEqEq


#![allow(unused)]
fn main() {
use std::cmp::Ordering;

// of course we can use the derive macros here
#[derive(Ord, PartialOrd, Eq, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

// note: as with PartialOrd, the Ord derive macro
// orders a type based on the lexicographical order
// of its members

// but here's the impls if we wrote them out by hand
impl Ord for Point {
    fn cmp(&self, other: &Self) -> Ordering {
        match self.x.cmp(&other.x) {
            Ordering::Equal => self.y.cmp(&other.y),
            ordering => ordering,
        }
    }
}
impl PartialOrd for Point {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}
impl PartialEq for Point {
    fn eq(&self, other: &Self) -> bool {
        self.cmp(other) == Ordering::Equal
    }
}
impl Eq for Point {}

}

浮点型实现了PartialOrd但是没有实现Ord,因为NaN < 0 == falseNaN >= 0 == false都为真。几乎所有的其他的PartialOrd类型都实现了Ord,除非它们中包含有浮点型。

一旦一个类型实现了Ord,我们就可以把它存储在BTreeMapBTreeSet,还可以在 slice 上使用 sort()方法对其进行排序,这同样适用于其他可以解引用为 slice 的类型,比如数组、VecVecDeque


#![allow(unused)]
fn main() {
use std::collections::BTreeSet;

// now our type can be stored
// in BTreeSets and BTreeMaps!
#[derive(Ord, PartialOrd, PartialEq, Eq)]
struct Point {
    x: i32,
    y: i32,
}

fn example_btreeset() {
    let mut points = BTreeSet::new();
    points.insert(Point { x: 0, y: 0 }); // ✅
}

// we can also .sort() Ord types in collections!
fn example_sort<T: Ord>(mut sortable: Vec<T>) -> Vec<T> {
    sortable.sort();
    sortable
}

}

算术 Trait(Arithmetic Traits)

Trait(s)分类(Category)操作符(Operator(s))描述(Description)
Add算术+相加
AddAssign算术+=相加并赋值
BitAnd算术&按位与
BitAndAssign算术&=按位与并赋值
BitXor算术^按位异或
BitXorAssign算术^=按位异或并赋值
Div算术/
DivAssign算术/=除并赋值
Mul算术*
MulAssign算术*=乘并赋值
Neg算术-一元求反
Not算术!一元逻辑求反
Rem算术%求余
RemAssign算术%=求余并赋值
Shl算术<<左移
ShlAssign算术<<=左移并赋值
Shr算术>>右移
ShrAssign算术>>=右移并赋值
Sub算术-
SubAssign算术-=减并赋值

我们没有必要把所有的算术操作符都仔细看一遍,毕竟它们中大多数都只作用于数值类型。我们将会讨论AddAddAssign,因为+操作符经常被重载用来完成其他事情,比如往集合里添加一项,或者进行拼接操作,这样我们就可以从最有趣的地方入手而不会重复。

Add & AddAssign


#![allow(unused)]
fn main() {
trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}
}

Add<Rhs, Output = T>类型可以被加到Rhs类型上并产生一个T作为输出。

例如,在Point上实现Add<Point, Output = Point>:

#[derive(Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

impl Add for Point {
    type Output = Point;
    fn add(self, rhs: Point) -> Point {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let p3 = p1 + p2;
    assert_eq!(p3.x, p1.x + p2.x); // ✅
    assert_eq!(p3.y, p1.y + p2.y); // ✅
}

但是,如果我们只有Point的引用,那该怎么办呢?我们还能把它们相加么?让我们试试:

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let p3 = &p1 + &p2; // ❌
}

显然不可以,编译器抛出下面的提示:


#![allow(unused)]
fn main() {
error[E0369]: cannot add `&Point` to `&Point`
  --> src/main.rs:50:25
   |
50 |     let p3: Point = &p1 + &p2;
   |                     --- ^ --- &Point
   |                     |
   |                     &Point
   |
   = note: an implementation of `std::ops::Add` might be missing for `&Point`

}

在 Rust 的类型系统中,对于某个类型TT&T&mut T都会被视作是完全不同的类型,这意味着我们必须分别为它们提供 trait 的实现。让我们为&Point实现Add

impl Add for &Point {
    type Output = Point;
    fn add(self, rhs: &Point) -> Point {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let p3 = &p1 + &p2; // ✅
    assert_eq!(p3.x, p1.x + p2.x); // ✅
    assert_eq!(p3.y, p1.y + p2.y); // ✅
}

尽管如此,但是仍然感觉有些地方不太对。我们针对Point&Point实现了两份Add,它们恰好目前还做了相同的事情,但是我们不能保证将来也是如此。例如,假设我们决定,当我们把两个Point相加时,我们想要创建一个包含这两个PointLine类型而不是创建一个新的Point,那么我们会把Add的实现更新:

use std::ops::Add;

#[derive(Copy, Clone)]
struct Point {
    x: i32,
    y: i32,
}

#[derive(Copy, Clone)]
struct Line {
    start: Point,
    end: Point,
}

// we updated this impl
impl Add for Point {
    type Output = Line;
    fn add(self, rhs: Point) -> Line {
        Line {
            start: self,
            end: rhs,
        }
    }
}

// but forgot to update this impl, uh oh!
impl Add for &Point {
    type Output = Point;
    fn add(self, rhs: &Point) -> Point {
        Point {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
        }
    }
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let line: Line = p1 + p2; // ✅

    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let line: Line = &p1 + &p2; // ❌ expected Line, found Point
}

我们当前针对&PointAdd实现就产生了一个不必要的维护负担,我们希望这个实现能够自动匹配Point的实现而无需我们每次在修改Point的实现时都手动维护更新。我们想要保持我们的代码尽可能地 DRY(Don't Repeat Yourself,不要重复自己)。幸运的是这是可以实现的:

// updated, DRY impl
impl Add for &Point {
    type Output = <Point as Add>::Output;
    fn add(self, rhs: &Point) -> Self::Output {
        Point::add(*self, *rhs)
    }
}

fn main() {
    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let line: Line = p1 + p2; // ✅

    let p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    let line: Line = &p1 + &p2; // ✅
}

AddAssign<Rhs>类型能够让我们和Rhs类型相加并赋值。该 trait 声明如下:


#![allow(unused)]
fn main() {
trait AddAssign<Rhs = Self> {
    fn add_assign(&mut self, rhs: Rhs);
}
}

Point&Point为例:

use std::ops::AddAssign;

#[derive(Copy, Clone)]
struct Point {
    x: i32,
    y: i32
}

impl AddAssign for Point {
    fn add_assign(&mut self, rhs: Point) {
        self.x += rhs.x;
        self.y += rhs.y;
    }
}

impl AddAssign<&Point> for Point {
    fn add_assign(&mut self, rhs: &Point) {
        Point::add_assign(self, *rhs);
    }
}

fn main() {
    let mut p1 = Point { x: 1, y: 2 };
    let p2 = Point { x: 3, y: 4 };
    p1 += &p2;
    p1 += p2;
    assert!(p1.x == 7 && p1.y == 10);
}

闭包 Trait(Closure Traits)

Trait(s)分类(Category)操作符(Operator(s))描述(Description)
Fn闭包(...args)不可变闭包调用
FnMut闭包(...args)可变闭包调用
FnOnce闭包(...args)一次性闭包调用

FnOnce, FnMut, & Fn


#![allow(unused)]
fn main() {
trait FnOnce<Args> {
    type Output;
    fn call_once(self, args: Args) -> Self::Output;
}

trait FnMut<Args>: FnOnce<Args> {
    fn call_mut(&mut self, args: Args) -> Self::Output;
}

trait Fn<Args>: FnMut<Args> {
    fn call(&self, args: Args) -> Self::Output;
}

}

虽然存在这些 trait,但是在 stable 的 Rust 中,我们无法为自己的类型实现这些 trait。我们能够创建的唯一能够实现这些 trait 的类型就是闭包。闭包根据其从环境中所捕获的内容来决定它到底是实现FnOnceFnMut还是Fn

FnOnce闭包只能被调用一次,因为它会在执行过程中消耗掉某些值:

fn main() {
    let range = 0..10;
    let get_range_count = || range.count();
    assert_eq!(get_range_count(), 10); // ✅
    get_range_count(); // ❌
}

迭代器上的.count()方法会消耗迭代器,因此它只能被调用一次。因此,我们的闭包也只能调用一次。这也是为什么我们在尝试调用第二次的时候会得到下面的错误:

error[E0382]: use of moved value: `get_range_count`
 --> src/main.rs:5:5
  |
4 |     assert_eq!(get_range_count(), 10);
  |                ----------------- `get_range_count` moved due to this call
5 |     get_range_count();
  |     ^^^^^^^^^^^^^^^ value used here after move
  |
note: closure cannot be invoked more than once because it moves the variable `range` out of its environment
 --> src/main.rs:3:30
  |
3 |     let get_range_count = || range.count();
  |                              ^^^^^
note: this value implements `FnOnce`, which causes it to be moved when called
 --> src/main.rs:4:16
  |
4 |     assert_eq!(get_range_count(), 10);
  |                ^^^^^^^^^^^^^^^

FnMut闭包可以被多次调用,并且可以修改它从环境中捕获到的变量。我们可以说FnMut有副作用或者是有状态的(stateful)。下面是一个闭包的示例,通过从迭代器中追踪它见到的最小值来过滤所有非升序的值。

fn main() {
    let nums = vec![0, 4, 2, 8, 10, 7, 15, 18, 13];
    let mut min = i32::MIN;
    let ascending = nums.into_iter().filter(|&n| {
        if n <= min {
            false
        } else {
            min = n;
            true
        }
    }).collect::<Vec<_>>();
    assert_eq!(vec![0, 4, 8, 10, 15, 18], ascending); // ✅
}

FnOnce会获取它的参数的所有权并且只能被调用一次,但是FnMut仅要求获取参数的可变引用并且可以被多次调用,从这一点上来讲,FnMut细化了FnOnceFnMut可以被用于任何可以使用FnOnce的地方。

Fn闭包也可以被调用多次,但是它不能修改从环境中捕获的变量。我们可以说,Fn闭包没有副作用或者无状态的(stateless)。下面是一个示例,从一个迭代器中过滤出所有小于某个栈上变量的数字,该变量是它是环境中捕获到的:

fn main() {
    let nums = vec![0, 4, 2, 8, 10, 7, 15, 18, 13];
    let min = 9;
    let greater_than_9 = nums.into_iter().filter(|&n| n > min).collect::<Vec<_>>();
    assert_eq!(vec![10, 15, 18, 13], greater_than_9); // ✅
}

FnMut要求可变引用并且可以被多次调用,Fn只要求不可变引用并可以被多次调用,从这一点来讲,Fn细化了FnMutFn可以被用于任何可以使用FnMut的地方,当然也包括可以使用FnOnce的地方。

如果一个闭包不从环境中捕获任何变量,从技术角度来讲它算不上是闭包,而只是一个被匿名声明的内联函数,并且可以作为一个普通函数指针(即Fn)被使用和传递,这包括可以使用FnMutFnOnce的地方。

fn add_one(x: i32) -> i32 {
    x + 1
}

fn main() {
    let mut fn_ptr: fn(i32) -> i32 = add_one;
    assert_eq!(fn_ptr(1), 2); // ✅

    // capture-less closure cast to fn pointer
    fn_ptr = |x| x + 1; // same as add_one
    assert_eq!(fn_ptr(1), 2); // ✅
}

下面是一个传递普通函数指针而不是闭包的示例:

fn main() {
    let nums = vec![-1, 1, -2, 2, -3, 3];
    let absolutes: Vec<i32> = nums.into_iter().map(i32::abs).collect();
    assert_eq!(vec![1, 1, 2, 2, 3, 3], absolutes); // ✅
}

其他 Trait (Other Traits)

Trait(s)分类(Category)操作符(Operator(s))描述(Description)
Deref其他*不可变解引用
DerefMut其他*可变解引用
Drop其他-类型析构
Index其他[]不可变索引
IndexMut其他[]可变索引
RangeBounds其他..区间

#![allow(unused)]
fn main() {
trait Deref {
    type Target: ?Sized;
    fn deref(&self) -> &Self::Target;
}

trait DerefMut: Deref {
    fn deref_mut(&mut self) -> &mut Self::Target;
}
}

Deref<Target = T>类型可以使用*操作符解引用为T类型。这在像BoxRc这样的智能指针类型中有很明显的用例。尽管如此,但是我们在 Rust 代码中很少见到这种显式的解引用操作,这是因为 Rust 有一个被称为解引用强制转换(deref coercion)的特性。

当类型被作为函数参数传递、从函数返回或者作为方法调用的一部分时,Rust 会自动对这些类型进行解引用。这也解释了为什么我们可以在一个期望&str&[T]的函数中可以传入&String&Vec<T>,因为String实现了Deref<Target = str>并且Vec<T>实现了Deref<Target = [T]>

DerefDerefMut应该仅被实现于智能指针类型。人们误用和滥用这些 trait 的最常见的方式是,试图把 OOP(面向对象程序设计)风格的数据继承塞进 Rust 中。这样是行不通的。Rust 不是 OOP。让我们进行一些测试,来看看它是在哪里、怎么样以及为什么行不通。让我们从下面的例子开始:


#![allow(unused)]
fn main() {
use std::ops::Deref;

struct Human {
    health_points: u32,
}

enum Weapon {
    Spear,
    Axe,
    Sword,
}

// a Soldier is just a Human with a Weapon
struct Soldier {
    human: Human,
    weapon: Weapon,
}

impl Deref for Soldier {
    type Target = Human;
    fn deref(&self) -> &Human {
        &self.human
    }
}

enum Mount {
    Horse,
    Donkey,
    Cow,
}

// a Knight is just a Soldier with a Mount
struct Knight {
    soldier: Soldier,
    mount: Mount,
}

impl Deref for Knight {
    type Target = Soldier;
    fn deref(&self) -> &Soldier {
        &self.soldier
    }
}

enum Spell {
    MagicMissile,
    FireBolt,
    ThornWhip,
}

// a Mage is just a Human who can cast Spells
struct Mage {
    human: Human,
    spells: Vec<Spell>,
}

impl Deref for Mage {
    type Target = Human;
    fn deref(&self) -> &Human {
        &self.human
    }
}

enum Staff {
    Wooden,
    Metallic,
    Plastic,
}

// a Wizard is just a Mage with a Staff
struct Wizard {
    mage: Mage,
    staff: Staff,
}

impl Deref for Wizard {
    type Target = Mage;
    fn deref(&self) -> &Mage {
        &self.mage
    }
}

fn borrows_human(human: &Human) {}
fn borrows_soldier(soldier: &Soldier) {}
fn borrows_knight(knight: &Knight) {}
fn borrows_mage(mage: &Mage) {}
fn borrows_wizard(wizard: &Wizard) {}

fn example(human: Human, soldier: Soldier, knight: Knight, mage: Mage, wizard: Wizard) {
    // all types can be used as Humans
    borrows_human(&human);
    borrows_human(&soldier);
    borrows_human(&knight);
    borrows_human(&mage);
    borrows_human(&wizard);
    // Knights can be used as Soldiers
    borrows_soldier(&soldier);
    borrows_soldier(&knight);
    // Wizards can be used as Mages
    borrows_mage(&mage);
    borrows_mage(&wizard);
    // Knights & Wizards passed as themselves
    borrows_knight(&knight);
    borrows_wizard(&wizard);
}

}

乍看之下,上面的代码似乎还不错!但是,仔细观察之后它就没这么好了。首先,解引用强制转换仅作用于引用,因此,当我们想要传递所有权的时候它是行不通的:


#![allow(unused)]
fn main() {
fn takes_human(human: Human) {}

fn example(human: Human, soldier: Soldier, knight: Knight, mage: Mage, wizard: Wizard) {
    // all types CANNOT be used as Humans
    takes_human(human);
    takes_human(soldier); // ❌
    takes_human(knight); // ❌
    takes_human(mage); // ❌
    takes_human(wizard); // ❌
}

}

此外,解引用强制转换在泛型上下文中是无法工作的。假定我们仅在 humans 上实现某个 trait:


#![allow(unused)]
fn main() {
trait Rest {
    fn rest(&self);
}

impl Rest for Human {
    fn rest(&self) {}
}

fn take_rest<T: Rest>(rester: &T) {
    rester.rest()
}

fn example(human: Human, soldier: Soldier, knight: Knight, mage: Mage, wizard: Wizard) {
    // all types CANNOT be used as Rest types, only Human
    take_rest(&human);
    take_rest(&soldier); // ❌
    take_rest(&knight); // ❌
    take_rest(&mage); // ❌
    take_rest(&wizard); // ❌
}

}

而且,尽管解引用强制转换在很多场景都可以使用,但它不是万能的。它无法作用于操作数,尽管操作符只是方法调用的语法糖。假定,我们想要Mage(魔术师)通过+=操作符学会Spell(拼写)


#![allow(unused)]
fn main() {
impl DerefMut for Wizard {
    fn deref_mut(&mut self) -> &mut Mage {
        &mut self.mage
    }
}

impl AddAssign<Spell> for Mage {
    fn add_assign(&mut self, spell: Spell) {
        self.spells.push(spell);
    }
}

fn example(mut mage: Mage, mut wizard: Wizard, spell: Spell) {
    mage += spell;
    wizard += spell; // ❌ wizard not coerced to mage here
    wizard.add_assign(spell); // oof, we have to call it like this 🤦
}

}

在具有 OOP 风格的数据继承的编程语言中,一个方法中的self的值总是等于调用这个方法的类型,但是在 Rust 中,self的值永远等于实现这个方法的类型:


#![allow(unused)]
fn main() {
struct Human {
    profession: &'static str,
    health_points: u32,
}

impl Human {
    // self will always be a Human here, even if we call it on a Soldier
    fn state_profession(&self) {
        println!("I'm a {}!", self.profession);
    }
}

struct Soldier {
    profession: &'static str,
    human: Human,
    weapon: Weapon,
}

fn example(soldier: &Soldier) {
    assert_eq!("servant", soldier.human.profession);
    assert_eq!("spearman", soldier.profession);
    soldier.human.state_profession(); // prints "I'm a servant!"
    soldier.state_profession(); // still prints "I'm a servant!" 🤦
}

}

当在一个新类型上实现DerefDerefMut时,上面的陷阱令人震惊。假定我们想要创建一个SortedVec类型,它就是一个Vec只不过是有序的。下面是我们可能的实现方式:


#![allow(unused)]
fn main() {
struct SortedVec<T: Ord>(Vec<T>);

impl<T: Ord> SortedVec<T> {
    fn new(mut vec: Vec<T>) -> Self {
        vec.sort();
        SortedVec(vec)
    }
    fn push(&mut self, t: T) {
        self.0.push(t);
        self.0.sort();
    }
}

}

显然,这里我们不能实现DerefMut<Target = Vec<T>>,否则任何使用SortedVec的人都能轻易打破已排好的顺序。但是,实现Deref<Target = Vec<T>>就一定安全么?试试找出下面程序中的 bug:

use std::ops::Deref;

struct SortedVec<T: Ord>(Vec<T>);

impl<T: Ord> SortedVec<T> {
    fn new(mut vec: Vec<T>) -> Self {
        vec.sort();
        SortedVec(vec)
    }
    fn push(&mut self, t: T) {
        self.0.push(t);
        self.0.sort();
    }
}

impl<T: Ord> Deref for SortedVec<T> {
    type Target = Vec<T>;
    fn deref(&self) -> &Vec<T> {
        &self.0
    }
}

fn main() {
    let sorted = SortedVec::new(vec![2, 8, 6, 3]);
    sorted.push(1);
    let sortedClone = sorted.clone();
    sortedClone.push(4);
}

我们未曾给SortedVec实现Clone,所以当我们调用.clone()方法时,编译器使用解引用强制转换把它解析为Vec上的方法调用,所以它会返回一个Vec而不是一个SortedVec

fn main() {
    let sorted: SortedVec<i32> = SortedVec::new(vec![2, 8, 6, 3]);
    sorted.push(1); // still sorted

    // calling clone on SortedVec actually returns a Vec 🤦
    let sortedClone: Vec<i32> = sorted.clone();
    sortedClone.push(4); // sortedClone no longer sorted 💀
}

不管怎样,上面的限制、约束或者陷阱都不是 Rust 的错,因为 Rust 从来都没有被设计成一门 OO(面向对象)的语言或者把支持 OOP(面向对象程序设计)模式放在首位。

本节的要点在于不要试图在DerefDerefMut的实现耍小聪明。它们仅仅适用于智能指针类型,目前只能在标准库中实现,因为智能指针类型目前需要 unstable 的特性和编译器的魔法才能工作。如果我们想要类似于DerefDerefMut的功能和行为,我们可以去了解一下后面会提到的AsRefAsMut

Index & IndexMut


#![allow(unused)]
fn main() {
trait Index<Idx: ?Sized> {
    type Output: ?Sized;
    fn index(&self, index: Idx) -> &Self::Output;
}

trait IndexMut<Idx>: Index<Idx> where Idx: ?Sized {
    fn index_mut(&mut self, index: Idx) -> &mut Self::Output;
}

}

我们可以将[]索引到带有 T 值的Index<T, Output = U>类型,索引操作将返回&U值。为了语法方便,编译器会自动在索引操作返回值的前面插入一个解引用操作符*

fn main() {
    // Vec<i32> impls Index<usize, Output = i32> so
    // indexing Vec<i32> should produce &i32s and yet...
    let vec = vec![1, 2, 3, 4, 5];
    let num_ref: &i32 = vec[0]; // ❌ expected &i32 found i32

    // above line actually desugars to
    let num_ref: &i32 = *vec[0]; // ❌ expected &i32 found i32

    // both of these alternatives work
    let num: i32 = vec[0]; // ✅
    let num_ref = &vec[0]; // ✅
}

为了展示我们自己如何实现Index,下面是一个有趣的示例,这个例子展示了我们如何使用一个新类型和Indextrait 在Vec上实现环绕索引和非负索引:


#![allow(unused)]
fn main() {
use std::ops::Index;

struct WrappingIndex<T>(Vec<T>);

impl<T> Index<usize> for WrappingIndex<T> {
    type Output = T;
    fn index(&self, index: usize) -> &T {
        &self.0[index % self.0.len()]
    }
}

impl<T> Index<i128> for WrappingIndex<T> {
    type Output = T;
    fn index(&self, index: i128) -> &T {
        let self_len = self.0.len() as i128;
        let idx = (((index % self_len) + self_len) % self_len) as usize;
        &self.0[idx]
    }
}

#[test] // ✅
fn indexes() {
    let wrapping_vec = WrappingIndex(vec![1, 2, 3]);
    assert_eq!(1, wrapping_vec[0_usize]);
    assert_eq!(2, wrapping_vec[1_usize]);
    assert_eq!(3, wrapping_vec[2_usize]);
}

#[test] // ✅
fn wrapping_indexes() {
    let wrapping_vec = WrappingIndex(vec![1, 2, 3]);
    assert_eq!(1, wrapping_vec[3_usize]);
    assert_eq!(2, wrapping_vec[4_usize]);
    assert_eq!(3, wrapping_vec[5_usize]);
}

#[test] // ✅
fn neg_indexes() {
    let wrapping_vec = WrappingIndex(vec![1, 2, 3]);
    assert_eq!(1, wrapping_vec[-3_i128]);
    assert_eq!(2, wrapping_vec[-2_i128]);
    assert_eq!(3, wrapping_vec[-1_i128]);
}

#[test] // ✅
fn wrapping_neg_indexes() {
    let wrapping_vec = WrappingIndex(vec![1, 2, 3]);
    assert_eq!(1, wrapping_vec[-6_i128]);
    assert_eq!(2, wrapping_vec[-5_i128]);
    assert_eq!(3, wrapping_vec[-4_i128]);
}

}

这里没有要求Idx类型是数值类型或者是一个Range,它也可以是一个枚举!下面是一个使用篮球位置在一支球队里检索球员的例子:


#![allow(unused)]
fn main() {
use std::ops::Index;

enum BasketballPosition {
    PointGuard,
    ShootingGuard,
    Center,
    PowerForward,
    SmallForward,
}

struct BasketballPlayer {
    name: &'static str,
    position: BasketballPosition,
}

struct BasketballTeam {
    point_guard: BasketballPlayer,
    shooting_guard: BasketballPlayer,
    center: BasketballPlayer,
    power_forward: BasketballPlayer,
    small_forward: BasketballPlayer,
}

impl Index<BasketballPosition> for BasketballTeam {
    type Output = BasketballPlayer;
    fn index(&self, position: BasketballPosition) -> &BasketballPlayer {
        match position {
            BasketballPosition::PointGuard => &self.point_guard,
            BasketballPosition::ShootingGuard => &self.shooting_guard,
            BasketballPosition::Center => &self.center,
            BasketballPosition::PowerForward => &self.power_forward,
            BasketballPosition::SmallForward => &self.small_forward,
        }
    }
}

}

Drop


#![allow(unused)]
fn main() {
trait Drop {
    fn drop(&mut self);
}
}

如果一个类型实现了Drop,那么drop将会在该类型离开作用域但是销毁之前被调用。我们很少需要去为我们的类型实现它,但是如果一个类型中持有某些外部资源,这些资源需要在类型销毁时被清理,这种情况下就会用到了。

标准库中有一个BufWriter类型让我们能够把写入的数据缓冲到Write类型中。但是,如果BufWriter在它里面的内容被刷入到底层的Write类型之前就被销毁了,该怎么办呢?幸运的是那是不可能的!BufWriter实现了Droptrait,因此,无论什么它什么时候离开作用域,flush总会被调用!


#![allow(unused)]
fn main() {
impl<W: Write> Drop for BufWriter<W> {
    fn drop(&mut self) {
        self.flush_buf();
    }
}
}

此外,Rust 中的Mutexs没有unlock()方法,因为它们不需要!在Mutex上调用lock()会返回一个MutexGuard,当MutexGuard离开作用域时,它会自动解锁(unlock)Mutex,这要归功于它的Drop实现:


#![allow(unused)]
fn main() {
impl<T: ?Sized> Drop for MutexGuard<'_, T> {
    fn drop(&mut self) {
        unsafe {
            self.lock.inner.raw_unlock();
        }
    }
}
}

一般而言,如果你正在实现对某类资源的抽象,这类资源需要在使用后被清理,那就是时候充分利用Drop trait 了。

转换 Traits(Conversion Traits)

From & Into


#![allow(unused)]
fn main() {
trait From<T> {
    fn from(T) -> Self;
}
}

From<T>类型允许我们把T转换为Self


#![allow(unused)]
fn main() {
trait Into<T> {
    fn into(self) -> T;
}
}

Into<T>类型允许我们把Self转换为T。 它们就像是一个硬币的两面。我们只能为自己的类型实现From<T>,因为Into<T>的实现会通过 generic blanket impl 自动提供:


#![allow(unused)]
fn main() {
impl<T, U> Into<U> for T
where
    U: From<T>,
{
    fn into(self) -> U {
        U::from(self)
    }
}

}

这两个 trait 之所以存在,是因为它能够让我们以稍微不同的方式来进行 trait 约束(bound):


#![allow(unused)]
fn main() {
fn function<T>(t: T)
where
    // these bounds are equivalent
    T: From<i32>,
    i32: Into<T>
{
    // these examples are equivalent
    let example: T = T::from(0);
    let example: T = 0.into();
}
}

没有规则强制要求什么时候使用前者或后者,所以在每种情景下采用最合理的方式就可以了。现在让我们来看一个例子:


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
}

impl From<(i32, i32)> for Point {
    fn from((x, y): (i32, i32)) -> Self {
        Point { x, y }
    }
}

impl From<[i32; 2]> for Point {
    fn from([x, y]: [i32; 2]) -> Self {
        Point { x, y }
    }
}

fn example() {
    // 使用 From
    let origin = Point::from((0, 0));
    let origin = Point::from([0, 0]);

    // 使用 Into
    let origin: Point = (0, 0).into();
    let origin: Point = [0, 0].into();
}

}

这个实现不是对称的,因此,如果我们想要把Point转为 tuple 和 array,我们必须显式地添加下面的内容:


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
}

impl From<(i32, i32)> for Point {
    fn from((x, y): (i32, i32)) -> Self {
        Point { x, y }
    }
}

impl From<Point> for (i32, i32) {
    fn from(Point { x, y }: Point) -> Self {
        (x, y)
    }
}

impl From<[i32; 2]> for Point {
    fn from([x, y]: [i32; 2]) -> Self {
        Point { x, y }
    }
}

impl From<Point> for [i32; 2] {
    fn from(Point { x, y }: Point) -> Self {
        [x, y]
    }
}

fn example() {
    // 从 (i32, i32) 到 Point
    let point = Point::from((0, 0));
    let point: Point = (0, 0).into();

    // 从 Point 到 (i32, i32)
    let tuple = <(i32, i32)>::from(point);
    let tuple: (i32, i32) = point.into();

    // 从 [i32; 2] 到 Point
    let point = Point::from([0, 0]);
    let point: Point = [0, 0].into();

    // 从 Point 到 [i32; 2]
    let array = <[i32; 2]>::from(point);
    let array: [i32; 2] = point.into();
}

}

From<T>的一个常见用法是精简模板代码。假定我们想要在程序中添加一个Triangle类型,它里面包含三个Point,下面是我们可以构造它的方式:


#![allow(unused)]
fn main() {
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn new(x: i32, y: i32) -> Point {
        Point { x, y }
    }
}

impl From<(i32, i32)> for Point {
    fn from((x, y): (i32, i32)) -> Point {
        Point { x, y }
    }
}

struct Triangle {
    p1: Point,
    p2: Point,
    p3: Point,
}

impl Triangle {
    fn new(p1: Point, p2: Point, p3: Point) -> Triangle {
        Triangle { p1, p2, p3 }
    }
}

impl<P> From<[P; 3]> for Triangle
where
    P: Into<Point>
{
    fn from([p1, p2, p3]: [P; 3]) -> Triangle {
        Triangle {
            p1: p1.into(),
            p2: p2.into(),
            p3: p3.into(),
        }
    }
}

fn example() {
    // 手动构造
    let triangle = Triangle {
        p1: Point {
            x: 0,
            y: 0,
        },
        p2: Point {
            x: 1,
            y: 1,
        },
        p3: Point {
            x: 2,
            y: 2,
        },
    };

    // 使用 Point::new
    let triangle = Triangle {
        p1: Point::new(0, 0),
        p2: Point::new(1, 1),
        p3: Point::new(2, 2),
    };

    // 使用 From<(i32, i32)> for Point
    let triangle = Triangle {
        p1: (0, 0).into(),
        p2: (1, 1).into(),
        p3: (2, 2).into(),
    };

    // 使用 Triangle::new + From<(i32, i32)> for Point
    let triangle = Triangle::new(
        (0, 0).into(),
        (1, 1).into(),
        (2, 2).into(),
    );

    // 使用 From<[Into<Point>; 3]> for Triangle
    let triangle: Triangle = [
        (0, 0),
        (1, 1),
        (2, 2),
    ].into();
}

}

关于你应该什么时候,以什么方式、什么理由来为我们的类型实现From<T>,并没有强制规定,这取决于你对具体情况的判断。

Into<T>一个常见的用途是,使得需要拥有值的函数具有通用性,而不必关心它们是拥有值还是借用值。


#![allow(unused)]
fn main() {
struct Person {
    name: String,
}

impl Person {
    // 接受:
    // - String
    fn new1(name: String) -> Person {
        Person { name }
    }

    // 接受:
    // - String
    // - &String
    // - &str
    // - Box<str>
    // - Cow<'_, str>
    // - char
    // 因为上面所有的类型都可以转换为 String
    fn new2<N: Into<String>>(name: N) -> Person {
        Person { name: name.into() }
    }
}

}

错误处理(Error Handling)

讨论错误处理和Error trait 的最好时机应该是紧跟在DisplayDebugAnyFrom之后,但是在TryFrom之前,这也是为什么把错误处理部分尴尬地嵌入在转换 trait 之间。

Error


#![allow(unused)]
fn main() {
trait Error: Debug + Display {
    // 提供默认实现
    fn source(&self) -> Option<&(dyn Error + 'static)>;
    fn backtrace(&self) -> Option<&Backtrace>;
    fn description(&self) -> &str;
    fn cause(&self) -> Option<&dyn Error>;
}
}

在 Rust 中,错误(error)是被返回(return)的,而不是被抛出(throw)的,让我们看个例子。

因为整数除以 0 会 panic,如果我们想要让我们的程序更为安全,我们可以实现一个safe_div函数,它会返回一个Result,就像下面这样:


#![allow(unused)]
fn main() {
use std::fmt;
use std::error;

#[derive(Debug, PartialEq)]
struct DivByZero;

impl fmt::Display for DivByZero {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "division by zero error")
    }
}

impl error::Error for DivByZero {}

fn safe_div(numerator: i32, denominator: i32) -> Result<i32, DivByZero> {
    if denominator == 0 {
        return Err(DivByZero);
    }
    Ok(numerator / denominator)
}

#[test] // ✅
fn test_safe_div() {
    assert_eq!(safe_div(8, 2), Ok(4));
    assert_eq!(safe_div(5, 0), Err(DivByZero));
}

}

因为错误是被返回而不是被抛出,所以这些错误必须被显式地处理,如果当前函数无法处理错误,该函数应该把错误传递给自己的调用者。传递错误的最常用方式是使用?操作符,它是现在已经弃用的try!宏的语法糖:


#![allow(unused)]
fn main() {
macro_rules! try {
    ($expr:expr) => {
        match $expr {
            // if Ok just unwrap the value
            Ok(val) => val,
            // if Err map the err value using From and return
            Err(err) => {
                return Err(From::from(err));
            }
        }
    };
}
}

如果我们想要写一个函数,该函数读取文件内容到String里,我们可以像这样写:


#![allow(unused)]
fn main() {
use std::io::Read;
use std::path::Path;
use std::io;
use std::fs::File;

fn read_file_to_string(path: &Path) -> Result<String, io::Error> {
    let mut file = File::open(path)?; // ⬆️ io::Error
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // ⬆️ io::Error
    Ok(contents)
}
}

假定我们当前正在读取的文件内容是一串数字,并且我们想要把这些数字求和,我们可能会把函数更新成这样:


#![allow(unused)]
fn main() {
use std::io::Read;
use std::path::Path;
use std::io;
use std::fs::File;

fn sum_file(path: &Path) -> Result<i32, /*这里放置什么? */> {
    let mut file = File::open(path)?; // ⬆️ io::Error
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // ⬆️ io::Error
    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i32>()?; // ⬆️ ParseIntError
    }
    Ok(sum)
}
}

但是,现在我们的Result里的错误类型应该是什么?它要么返回一个io::Error,要么返回一个ParseIntError。我们尝试寻找第三种方式来解决这个问题,以最快最乱的方式开始,以最健壮的方式结束。

第一种方式就是,识别出所有实现了ErrorDisplay的类型,这样我们把所有的错误映射(map)到String类型并把String作为我们的错误类型:


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io;
use std::io::Read;
use std::path::Path;

fn sum_file(path: &Path) -> Result<i32, String> {
    let mut file = File::open(path)
        .map_err(|e| e.to_string())?; // ⬆️ io::Error -> String
    let mut contents = String::new();
    file.read_to_string(&mut contents)
        .map_err(|e| e.to_string())?; // ⬆️ io::Error -> String
    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i32>()
            .map_err(|e| e.to_string())?; // ⬆️ ParseIntError -> String
    }
    Ok(sum)
}
}

但是,这种方式的缺点在于,我们会丢弃所有的错误类型信息,从而导致调用者在处理错误时十分困难。

另外一个不太明显的优点则是,我们可以定制字符串来提供更多的特定上下文信息。例如,ParseIntError通常会变成字符串“invalid digit found in string”,这个信息就非常模糊并且没有提及无效的字符串是什么或者它正在尝试解析到哪一类整数类型。如果我们正在调试这个问题,这个错误信息几乎没什么用。尽管如此,我们还可以自己动手提供所有的上下文信息来改善这个问题:


#![allow(unused)]
fn main() {
sum += line.parse::<i32>()
    .map_err(|_| format!("failed to parse {} into i32", line))?;

}

第二种方式则是充分利用标准库中的 generic blanket impl:


#![allow(unused)]
fn main() {
impl<E: error::Error> From<E> for Box<dyn error::Error>;
}

这意味着,任意的Error类型都可以通过?被隐式地转换为Box<dyn error::Error>,因此我们可以把任何可能产生错误的函数返回的Result中的错误类型设置为Box<dyn error::Error>,这样?操作符就可以帮我们完成剩下的工作:


#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::Read;
use std::path::Path;
use std::error;

fn sum_file(path: &Path) -> Result<i32, Box<dyn error::Error>> {
    let mut file = File::open(path)?; // ⬆️ io::Error -> Box<dyn error::Error>
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // ⬆️ io::Error -> Box<dyn error::Error>
    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i32>()?; // ⬆️ ParseIntError -> Box<dyn error::Error>
    }
    Ok(sum)
}

}

虽然更为简洁,但是它似乎也存在着前面一种方式的缺点,即丢掉了类型信息。大多数情况下的确如此,但是如果调用者知道函数的实现细节,它们仍然可以通过使用error::Error上的downcast_ref()方法来处理不同的错误类型,这与它在dyn Any类型上的作用相同。


#![allow(unused)]
fn main() {
fn handle_sum_file_errors(path: &Path) {
    match sum_file(path) {
        Ok(sum) => println!("the sum is {}", sum),
        Err(err) => {
            if let Some(e) = err.downcast_ref::<io::Error>() {
                // 处理 io::Error
            } else if let Some(e) = err.downcast_ref::<ParseIntError>() {
                // 处理 ParseIntError
            } else {
                // 我们知道 sum_file 只会返回上面错误中的其中一个
                // 所以不会到达这个分支
                unreachable!();
            }
        }
    }
}

}

第三种方法是最稳健和类型安全的方法,它可以汇总这些不同的错误,使用一个枚举类型构建我们自己的自定义错误类型:


#![allow(unused)]
fn main() {
use std::num::ParseIntError;
use std::fs::File;
use std::io;
use std::io::Read;
use std::path::Path;
use std::error;
use std::fmt;

#[derive(Debug)]
enum SumFileError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for SumFileError {
    fn from(err: io::Error) -> Self {
        SumFileError::Io(err)
    }
}

impl From<ParseIntError> for SumFileError {
    fn from(err: ParseIntError) -> Self {
        SumFileError::Parse(err)
    }
}

impl fmt::Display for SumFileError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            SumFileError::Io(err) => write!(f, "sum file error: {}", err),
            SumFileError::Parse(err) => write!(f, "sum file error: {}", err),
        }
    }
}

impl error::Error for SumFileError {
    // 这个方法的默认实现总是返回 None
    //但是我们现在重写它,让它更有用    
    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
        Some(match self {
            SumFileError::Io(err) => err,
            SumFileError::Parse(err) => err,
        })
    }
}

fn sum_file(path: &Path) -> Result<i32, SumFileError> {
    let mut file = File::open(path)?; // ⬆️ io::Error -> SumFileError
    let mut contents = String::new();
    file.read_to_string(&mut contents)?; // ⬆️ io::Error -> SumFileError
    let mut sum = 0;
    for line in contents.lines() {
        sum += line.parse::<i32>()?; // ⬆️ ParseIntError -> SumFileError
    }
    Ok(sum)
}

fn handle_sum_file_errors(path: &Path) {
    match sum_file(path) {
        Ok(sum) => println!("the sum is {}", sum),
        Err(SumFileError::Io(err)) => {
            // 处理 io::Error
        },
        Err(SumFileError::Parse(err)) => {
            // 处理 ParseIntError
        },
    }
}

}

继续转换类型(Conversion Traits Continued)

TryFrom & TryInto

TryFromTryIntoFromInto的可能会失败的版本。


#![allow(unused)]
fn main() {
trait TryFrom<T> {
    type Error;
    fn try_from(value: T) -> Result<Self, Self::Error>;
}

trait TryInto<T> {
    type Error;
    fn try_into(self) -> Result<T, Self::Error>;
}

}

类似于Into,我们无法实现TryInto,因为它的实现是由 generic blanket impl提供:


#![allow(unused)]
fn main() {
impl<T, U> TryInto<U> for T
where
    U: TryFrom<T>,
{
    type Error = U::Error;

    fn try_into(self) -> Result<U, U::Error> {
        U::try_from(self)
    }
}

}

假定在我们的程序上下文环境中,Point中的xy如果值小于-1000或者大于1000没有意义。下面是我们使用TryFrom重写之前的From实现来告诉用户,现在这种转换可以失败。


#![allow(unused)]
fn main() {
use std::convert::TryFrom;
use std::error;
use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

#[derive(Debug)]
struct OutOfBounds;

impl fmt::Display for OutOfBounds {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "out of bounds")
    }
}

impl error::Error for OutOfBounds {}

// 现在是可以出错的
impl TryFrom<(i32, i32)> for Point {
    type Error = OutOfBounds;
    fn try_from((x, y): (i32, i32)) -> Result<Point, OutOfBounds> {
        if x.abs() > 1000 || y.abs() > 1000 {
            return Err(OutOfBounds);
        }
        Ok(Point { x, y })
    }
}

// 仍然是不会出错的
impl From<Point> for (i32, i32) {
    fn from(Point { x, y }: Point) -> Self {
        (x, y)
    }
}

}

下面是对TriangleTryFrom<[TryInto<Point>; 3]>实现:


#![allow(unused)]
fn main() {
use std::convert::{TryFrom, TryInto};
use std::error;
use std::fmt;

struct Point {
    x: i32,
    y: i32,
}

#[derive(Debug)]
struct OutOfBounds;

impl fmt::Display for OutOfBounds {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "out of bounds")
    }
}

impl error::Error for OutOfBounds {}

impl TryFrom<(i32, i32)> for Point {
    type Error = OutOfBounds;
    fn try_from((x, y): (i32, i32)) -> Result<Self, Self::Error> {
        if x.abs() > 1000 || y.abs() > 1000 {
            return Err(OutOfBounds);
        }
        Ok(Point { x, y })
    }
}

struct Triangle {
    p1: Point,
    p2: Point,
    p3: Point,
}

impl<P> TryFrom<[P; 3]> for Triangle
where
    P: TryInto<Point>,
{
    type Error = P::Error;
    fn try_from([p1, p2, p3]: [P; 3]) -> Result<Self, Self::Error> {
        Ok(Triangle {
            p1: p1.try_into()?,
            p2: p2.try_into()?,
            p3: p3.try_into()?,
        })
    }
}

fn example() -> Result<Triangle, OutOfBounds> {
    let t: Triangle = [(0, 0), (1, 1), (2, 2)].try_into()?;
    Ok(t)
}

}

FromStr


#![allow(unused)]
fn main() {
trait FromStr {
    type Err;
    fn from_str(s: &str) -> Result<Self, Self::Err>;
}
}

FromStr 类型允许执行一个从&strSelf的可失败的转换。最常见的使用是在&str上调用.parse()方法:


#![allow(unused)]
fn main() {
use std::str::FromStr;

fn example<T: FromStr>(s: &'static str) {
    // 这些都是相等的
    let t: Result<T, _> = FromStr::from_str(s);
    let t = T::from_str(s);
    let t: Result<T, _> = s.parse();
    let t = s.parse::<T>(); // 最常见的
}
}

例如,在Point上的实现:


#![allow(unused)]
fn main() {
use std::error;
use std::fmt;
use std::iter::Enumerate;
use std::num::ParseIntError;
use std::str::{Chars, FromStr};

#[derive(Debug, Eq, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

impl Point {
    fn new(x: i32, y: i32) -> Self {
        Point { x, y }
    }
}

#[derive(Debug, PartialEq)]
struct ParsePointError;

impl fmt::Display for ParsePointError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "failed to parse point")
    }
}

impl From<ParseIntError> for ParsePointError {
    fn from(_e: ParseIntError) -> Self {
        ParsePointError
    }
}

impl error::Error for ParsePointError {}

impl FromStr for Point {
    type Err = ParsePointError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let is_num = |(_, c): &(usize, char)| matches!(c, '0'..='9' | '-');
        let isnt_num = |t: &(_, _)| !is_num(t);

        let get_num =
            |char_idxs: &mut Enumerate<Chars<'_>>| -> Result<(usize, usize), ParsePointError> {
                let (start, _) = char_idxs
                    .skip_while(isnt_num)
                    .next()
                    .ok_or(ParsePointError)?;
                let (end, _) = char_idxs
                    .skip_while(is_num)
                    .next()
                    .ok_or(ParsePointError)?;
                Ok((start, end))
            };

        let mut char_idxs = s.chars().enumerate();
        let (x_start, x_end) = get_num(&mut char_idxs)?;
        let (y_start, y_end) = get_num(&mut char_idxs)?;

        let x = s[x_start..x_end].parse::<i32>()?;
        let y = s[y_start..y_end].parse::<i32>()?;

        Ok(Point { x, y })
    }
}

#[test] // ✅
fn pos_x_y() {
    let p = "(4, 5)".parse::<Point>();
    assert_eq!(p, Ok(Point::new(4, 5)));
}

#[test] // ✅
fn neg_x_y() {
    let p = "(-6, -2)".parse::<Point>();
    assert_eq!(p, Ok(Point::new(-6, -2)));
}

#[test] // ✅
fn not_a_point() {
    let p = "not a point".parse::<Point>();
    assert_eq!(p, Err(ParsePointError));
}
}

FromStrTryFrom<&str>有着相同的签名。只要我们通过其中一个实现另一个,先实现哪个并不重要。下面是对Point实现TryFrom<&str>,假定它已经实现了FromStr:


#![allow(unused)]
fn main() {
impl TryFrom<&str> for Point {
    type Error = <Point as FromStr>::Err;
    fn try_from(s: &str) -> Result<Point, Self::Error> {
        <Point as FromStr>::from_str(s)
    }
}

}

AsRef & AsMut


#![allow(unused)]
fn main() {
trait AsRef<T: ?Sized> {
    fn as_ref(&self) -> &T;
}

trait AsMut<T: ?Sized> {
    fn as_mut(&mut self) -> &mut T;
}

}

AsRef被用于轻量级的引用到引用之间的转换。然而,它最常见的一个用途是使函数在是否获取所有权上具有通用性:


#![allow(unused)]
fn main() {
// 接受:
//  - &str
//  - &String
fn takes_str(s: &str) {
    // use &str
}

// 接受:
//  - &str
//  - &String
//  - String
fn takes_asref_str<S: AsRef<str>>(s: S) {
    let s: &str = s.as_ref();
    // 使用 &str
}

fn example(slice: &str, borrow: &String, owned: String) {
    takes_str(slice);
    takes_str(borrow);
    takes_str(owned); // ❌
    takes_asref_str(slice);
    takes_asref_str(borrow);
    takes_asref_str(owned); // ✅
}

}

另一个常见用途是返回一个内部私有数据的引用,该数据由一个保护不变性的类型所包裹。标准库中一个比较好的示例是String,它包裹了Vec<u8>


#![allow(unused)]
fn main() {
struct String {
    vec: Vec<u8>,
}
}

内部的Vec<u8>不能被公开,因为如果这样的话,人们就会修改里面的字节并破坏String中有效的 UTF-8 编码。但是,暴露内部字节数组的一个不可变的只读引用是安全的,即下面的实现:


#![allow(unused)]
fn main() {
impl AsRef<[u8]> for String;
}

一般而言,只有当一个类型包裹了其他类型用来为该内部类型提供了额外功能或者保护内部类型的不变性时,为这样的类型实现AsRef才有意义。 让我们来看一个AsRef的不合适使用:


#![allow(unused)]
fn main() {
struct User {
    name: String,
    age: u32,
}

impl AsRef<String> for User {
    fn as_ref(&self) -> &String {
        &self.name
    }
}

impl AsRef<u32> for User {
    fn as_ref(&self) -> &u32 {
        &self.age
    }
}

}

一开始是可行的,而且看上去还有点道理,但是当我们为User添加更多成员时,问题就出现了:


#![allow(unused)]
fn main() {
struct User {
    name: String,
    email: String,
    age: u32,
    height: u32,
}

impl AsRef<String> for User {
    fn as_ref(&self) -> &String {、
        //我们返回 name 还是 email?        
    }
}

impl AsRef<u32> for User {
    fn as_ref(&self) -> &u32 {
        //我们返回 age 还是 height?
    }
}

}

User是由Stringu32组成,但是它并不等同于一个String和一个u32,甚至我们还会有更多的类型:


#![allow(unused)]
fn main() {
struct User {
    name: Name,
    email: Email,
    age: Age,
    height: Height,
}
}

对于这样的类型实现AsRef没有什么意义,因为AsRef用于语义相等的事物之间引用到引用的转换,而且NameEmailAge以及Height并不等同于一个User

下面是一个好的示例,其中,我们会引入一个新类型Moderator,它只包裹了一个User并添加了特定的审核权限:


#![allow(unused)]
fn main() {
struct User {
    name: String,
    age: u32,
}

//不幸地是,标准库并没有提供一个generic blanket impl来避免这种重复的实现
impl AsRef<User> for User {
    fn as_ref(&self) -> &User {
        self
    }
}

enum Privilege {
    BanUsers,
    EditPosts,
    DeletePosts,
}

//尽管 Moderators 有一些特殊权限,它们仍然是普通的 User 
//并且应该做相同的事情
struct Moderator {
    user: User,
    privileges: Vec<Privilege>
}

impl AsRef<Moderator> for Moderator {
    fn as_ref(&self) -> &Moderator {
        self
    }
}

impl AsRef<User> for Moderator {
    fn as_ref(&self) -> &User {
        &self.user
    }
}

//使用 User 和 Moderators (也是一种User)应该都是可以调用的
fn create_post<U: AsRef<User>>(u: U) {
    let user = u.as_ref();
    // etc
}

fn example(user: User, moderator: Moderator) {
    create_post(&user);
    create_post(&moderator); // ✅
}

}

这是有效的,因为Moderator就是User。下面是Deref章节中的例子,我们用了AsRef来实现:


#![allow(unused)]
fn main() {
use std::convert::AsRef;

struct Human {
    health_points: u32,
}

impl AsRef<Human> for Human {
    fn as_ref(&self) -> &Human {
        self
    }
}

enum Weapon {
    Spear,
    Axe,
    Sword,
}

// a Soldier is just a Human with a Weapon
struct Soldier {
    human: Human,
    weapon: Weapon,
}

impl AsRef<Soldier> for Soldier {
    fn as_ref(&self) -> &Soldier {
        self
    }
}

impl AsRef<Human> for Soldier {
    fn as_ref(&self) -> &Human {
        &self.human
    }
}

enum Mount {
    Horse,
    Donkey,
    Cow,
}

// a Knight is just a Soldier with a Mount
struct Knight {
    soldier: Soldier,
    mount: Mount,
}

impl AsRef<Knight> for Knight {
    fn as_ref(&self) -> &Knight {
        self
    }
}

impl AsRef<Soldier> for Knight {
    fn as_ref(&self) -> &Soldier {
        &self.soldier
    }
}

impl AsRef<Human> for Knight {
    fn as_ref(&self) -> &Human {
        &self.soldier.human
    }
}

enum Spell {
    MagicMissile,
    FireBolt,
    ThornWhip,
}

// a Mage is just a Human who can cast Spells
struct Mage {
    human: Human,
    spells: Vec<Spell>,
}

impl AsRef<Mage> for Mage {
    fn as_ref(&self) -> &Mage {
        self
    }
}

impl AsRef<Human> for Mage {
    fn as_ref(&self) -> &Human {
        &self.human
    }
}

enum Staff {
    Wooden,
    Metallic,
    Plastic,
}

// a Wizard is just a Mage with a Staff
struct Wizard {
    mage: Mage,
    staff: Staff,
}

impl AsRef<Wizard> for Wizard {
    fn as_ref(&self) -> &Wizard {
        self
    }
}

impl AsRef<Mage> for Wizard {
    fn as_ref(&self) -> &Mage {
        &self.mage
    }
}

impl AsRef<Human> for Wizard {
    fn as_ref(&self) -> &Human {
        &self.mage.human
    }
}

fn borrows_human<H: AsRef<Human>>(human: H) {}
fn borrows_soldier<S: AsRef<Soldier>>(soldier: S) {}
fn borrows_knight<K: AsRef<Knight>>(knight: K) {}
fn borrows_mage<M: AsRef<Mage>>(mage: M) {}
fn borrows_wizard<W: AsRef<Wizard>>(wizard: W) {}

fn example(human: Human, soldier: Soldier, knight: Knight, mage: Mage, wizard: Wizard) {
    // all types can be used as Humans
    borrows_human(&human);
    borrows_human(&soldier);
    borrows_human(&knight);
    borrows_human(&mage);
    borrows_human(&wizard);
    // Knights can be used as Soldiers
    borrows_soldier(&soldier);
    borrows_soldier(&knight);
    // Wizards can be used as Mages
    borrows_mage(&mage);
    borrows_mage(&wizard);
    // Knights & Wizards passed as themselves
    borrows_knight(&knight);
    borrows_wizard(&wizard);
}

}

Deref在之前的例子中没有起作用,是因为解引用强制转换是类型间的隐式转换,这就为人们制定错误的想法并对其行为方式的期望留下了空间。AsRef能够工作是因为它让类型之间的转换变为显式的,并且没有给开发者错误的想法和期望留有余地。

Borrow & BorrowMut


#![allow(unused)]
fn main() {
trait Borrow<Borrowed>
where
    Borrowed: ?Sized,
{
    fn borrow(&self) -> &Borrowed;
}

trait BorrowMut<Borrowed>: Borrow<Borrowed>
where
    Borrowed: ?Sized,
{
    fn borrow_mut(&mut self) -> &mut Borrowed;
}

}

这些 trait 被发明用于解决非常具体的问题,即使用&str类型的值在HashSetHashMapBTreeSetBTreeMap中查找String类型的 key。

我们可以把Borrow<T>BorrowMut<T>看作更严格的AsRef<T>AsMut<T>,它们返回的引用&TSelf有等价性的EqHashOrd实现。通过下面的例子会更易于理解:


#![allow(unused)]
fn main() {
use std::borrow::Borrow;
use std::hash::Hasher;
use std::collections::hash_map::DefaultHasher;
use std::hash::Hash;

fn get_hash<T: Hash>(t: T) -> u64 {
    let mut hasher = DefaultHasher::new();
    t.hash(&mut hasher);
    hasher.finish()
}

fn asref_example<Owned, Ref>(owned1: Owned, owned2: Owned)
where
    Owned: Eq + Ord + Hash + AsRef<Ref>,
    Ref: Eq + Ord + Hash
{
    let ref1: &Ref = owned1.as_ref();
    let ref2: &Ref = owned2.as_ref();

    // refs aren't required to be equal if owned types are equal
    assert_eq!(owned1 == owned2, ref1 == ref2); // ❌

    let owned1_hash = get_hash(&owned1);
    let owned2_hash = get_hash(&owned2);
    let ref1_hash = get_hash(&ref1);
    let ref2_hash = get_hash(&ref2);

    // ref hashes aren't required to be equal if owned type hashes are equal
    assert_eq!(owned1_hash == owned2_hash, ref1_hash == ref2_hash); // ❌

    // ref comparisons aren't required to match owned type comparisons
    assert_eq!(owned1.cmp(&owned2), ref1.cmp(&ref2)); // ❌
}

fn borrow_example<Owned, Borrowed>(owned1: Owned, owned2: Owned)
where
    Owned: Eq + Ord + Hash + Borrow<Borrowed>,
    Borrowed: Eq + Ord + Hash
{
    let borrow1: &Borrowed = owned1.borrow();
    let borrow2: &Borrowed = owned2.borrow();

    // borrows are required to be equal if owned types are equal
    assert_eq!(owned1 == owned2, borrow1 == borrow2); // ✅

    let owned1_hash = get_hash(&owned1);
    let owned2_hash = get_hash(&owned2);
    let borrow1_hash = get_hash(&borrow1);
    let borrow2_hash = get_hash(&borrow2);

    // borrow hashes are required to be equal if owned type hashes are equal
    assert_eq!(owned1_hash == owned2_hash, borrow1_hash == borrow2_hash); // ✅

    // borrow comparisons are required to match owned type comparisons
    assert_eq!(owned1.cmp(&owned2), borrow1.cmp(&borrow2)); // ✅
}

}

意识到这些 trait 以及它们为什么存在是有益的,因为它有助于搞清楚HashSetHashMapBTreeSet以及BTreeMap的某些方法,但是我们很少需要为我们的类型实现这些 trait,因为我们很少需要创建一对儿类型,其中一个是另一个的借用版本。如果我们有某个类型T&T在 99.99%的情况下可以完成工作,并且因为 generic blanket impl,T:Borrorw<T>已经为所有的类型T实现了,所以我们不需要手动地实现它并且我们不需要创建一个U以用来T:Borrow<U>

ToOwned


#![allow(unused)]
fn main() {
trait ToOwned {
    type Owned: Borrow<Self>;
    fn to_owned(&self) -> Self::Owned;

    // 提供默认实现
    fn clone_into(&self, target: &mut Self::Owned);
}
}

ToOwnedClone的一个更为通用的版本。Clone允许我们获取一个&T并把它转为一个T,但是ToOwned允许我们拿到一个&Borrowed并把它转为一个Owned,其中Owned: Borrow<Borrowed>

换句话说,我们不能从一个&str克隆一个String,或者从一个&Path克隆一个PathBuf,或者从一个&OsStr克隆一个OsString,因为clone方法签名不支持这种跨类型的克隆,这就是ToOwned产生的原因。

类似于BorrowBorrowMut,知道这个 trait 并理解它什么存在同样是有益的,只是我们几乎不需要为我们的类型实现它。

Iteration Traits


#![allow(unused)]
fn main() {
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;

    // provided default impls
    fn size_hint(&self) -> (usize, Option<usize>);
    fn count(self) -> usize;
    fn last(self) -> Option<Self::Item>;
    fn advance_by(&mut self, n: usize) -> Result<(), usize>;
    fn nth(&mut self, n: usize) -> Option<Self::Item>;
    fn step_by(self, step: usize) -> StepBy<Self>;
    fn chain<U>(
        self, 
        other: U
    ) -> Chain<Self, <U as IntoIterator>::IntoIter>
    where
        U: IntoIterator<Item = Self::Item>;
    fn zip<U>(self, other: U) -> Zip<Self, <U as IntoIterator>::IntoIter>
    where
        U: IntoIterator;
    fn map<B, F>(self, f: F) -> Map<Self, F>
    where
        F: FnMut(Self::Item) -> B;
    fn for_each<F>(self, f: F)
    where
        F: FnMut(Self::Item);
    fn filter<P>(self, predicate: P) -> Filter<Self, P>
    where
        P: FnMut(&Self::Item) -> bool;
    fn filter_map<B, F>(self, f: F) -> FilterMap<Self, F>
    where
        F: FnMut(Self::Item) -> Option<B>;
    fn enumerate(self) -> Enumerate<Self>;
    fn peekable(self) -> Peekable<Self>;
    fn skip_while<P>(self, predicate: P) -> SkipWhile<Self, P>
    where
        P: FnMut(&Self::Item) -> bool;
    fn take_while<P>(self, predicate: P) -> TakeWhile<Self, P>
    where
        P: FnMut(&Self::Item) -> bool;
    fn map_while<B, P>(self, predicate: P) -> MapWhile<Self, P>
    where
        P: FnMut(Self::Item) -> Option<B>;
    fn skip(self, n: usize) -> Skip<Self>;
    fn take(self, n: usize) -> Take<Self>;
    fn scan<St, B, F>(self, initial_state: St, f: F) -> Scan<Self, St, F>
    where
        F: FnMut(&mut St, Self::Item) -> Option<B>;
    fn flat_map<U, F>(self, f: F) -> FlatMap<Self, U, F>
    where
        F: FnMut(Self::Item) -> U,
        U: IntoIterator;
    fn flatten(self) -> Flatten<Self>
    where
        Self::Item: IntoIterator;
    fn fuse(self) -> Fuse<Self>;
    fn inspect<F>(self, f: F) -> Inspect<Self, F>
    where
        F: FnMut(&Self::Item);
    fn by_ref(&mut self) -> &mut Self;
    fn collect<B>(self) -> B
    where
        B: FromIterator<Self::Item>;
    fn partition<B, F>(self, f: F) -> (B, B)
    where
        F: FnMut(&Self::Item) -> bool,
        B: Default + Extend<Self::Item>;
    fn partition_in_place<'a, T, P>(self, predicate: P) -> usize
    where
        Self: DoubleEndedIterator<Item = &'a mut T>,
        T: 'a,
        P: FnMut(&T) -> bool;
    fn is_partitioned<P>(self, predicate: P) -> bool
    where
        P: FnMut(Self::Item) -> bool;
    fn try_fold<B, F, R>(&mut self, init: B, f: F) -> R
    where
        F: FnMut(B, Self::Item) -> R,
        R: Try<Ok = B>;
    fn try_for_each<F, R>(&mut self, f: F) -> R
    where
        F: FnMut(Self::Item) -> R,
        R: Try<Ok = ()>;
    fn fold<B, F>(self, init: B, f: F) -> B
    where
        F: FnMut(B, Self::Item) -> B;
    fn fold_first<F>(self, f: F) -> Option<Self::Item>
    where
        F: FnMut(Self::Item, Self::Item) -> Self::Item;
    fn all<F>(&mut self, f: F) -> bool
    where
        F: FnMut(Self::Item) -> bool;
    fn any<F>(&mut self, f: F) -> bool
    where
        F: FnMut(Self::Item) -> bool;
    fn find<P>(&mut self, predicate: P) -> Option<Self::Item>
    where
        P: FnMut(&Self::Item) -> bool;
    fn find_map<B, F>(&mut self, f: F) -> Option<B>
    where
        F: FnMut(Self::Item) -> Option<B>;
    fn try_find<F, R>(
        &mut self, 
        f: F
    ) -> Result<Option<Self::Item>, <R as Try>::Error>
    where
        F: FnMut(&Self::Item) -> R,
        R: Try<Ok = bool>;
    fn position<P>(&mut self, predicate: P) -> Option<usize>
    where
        P: FnMut(Self::Item) -> bool;
    fn rposition<P>(&mut self, predicate: P) -> Option<usize>
    where
        Self: ExactSizeIterator + DoubleEndedIterator,
        P: FnMut(Self::Item) -> bool;
    fn max(self) -> Option<Self::Item>
    where
        Self::Item: Ord;
    fn min(self) -> Option<Self::Item>
    where
        Self::Item: Ord;
    fn max_by_key<B, F>(self, f: F) -> Option<Self::Item>
    where
        F: FnMut(&Self::Item) -> B,
        B: Ord;
    fn max_by<F>(self, compare: F) -> Option<Self::Item>
    where
        F: FnMut(&Self::Item, &Self::Item) -> Ordering;
    fn min_by_key<B, F>(self, f: F) -> Option<Self::Item>
    where
        F: FnMut(&Self::Item) -> B,
        B: Ord;
    fn min_by<F>(self, compare: F) -> Option<Self::Item>
    where
        F: FnMut(&Self::Item, &Self::Item) -> Ordering;
    fn rev(self) -> Rev<Self>
    where
        Self: DoubleEndedIterator;
    fn unzip<A, B, FromA, FromB>(self) -> (FromA, FromB)
    where
        Self: Iterator<Item = (A, B)>,
        FromA: Default + Extend<A>,
        FromB: Default + Extend<B>;
    fn copied<'a, T>(self) -> Copied<Self>
    where
        Self: Iterator<Item = &'a T>,
        T: 'a + Copy;
    fn cloned<'a, T>(self) -> Cloned<Self>
    where
        Self: Iterator<Item = &'a T>,
        T: 'a + Clone;
    fn cycle(self) -> Cycle<Self>
    where
        Self: Clone;
    fn sum<S>(self) -> S
    where
        S: Sum<Self::Item>;
    fn product<P>(self) -> P
    where
        P: Product<Self::Item>;
    fn cmp<I>(self, other: I) -> Ordering
    where
        I: IntoIterator<Item = Self::Item>,
        Self::Item: Ord;
    fn cmp_by<I, F>(self, other: I, cmp: F) -> Ordering
    where
        F: FnMut(Self::Item, <I as IntoIterator>::Item) -> Ordering,
        I: IntoIterator;
    fn partial_cmp<I>(self, other: I) -> Option<Ordering>
    where
        I: IntoIterator,
        Self::Item: PartialOrd<<I as IntoIterator>::Item>;
    fn partial_cmp_by<I, F>(
        self, 
        other: I, 
        partial_cmp: F
    ) -> Option<Ordering>
    where
        F: FnMut(Self::Item, <I as IntoIterator>::Item) -> Option<Ordering>,
        I: IntoIterator;
    fn eq<I>(self, other: I) -> bool
    where
        I: IntoIterator,
        Self::Item: PartialEq<<I as IntoIterator>::Item>;
    fn eq_by<I, F>(self, other: I, eq: F) -> bool
    where
        F: FnMut(Self::Item, <I as IntoIterator>::Item) -> bool,
        I: IntoIterator;
    fn ne<I>(self, other: I) -> bool
    where
        I: IntoIterator,
        Self::Item: PartialEq<<I as IntoIterator>::Item>;
    fn lt<I>(self, other: I) -> bool
    where
        I: IntoIterator,
        Self::Item: PartialOrd<<I as IntoIterator>::Item>;
    fn le<I>(self, other: I) -> bool
    where
        I: IntoIterator,
        Self::Item: PartialOrd<<I as IntoIterator>::Item>;
    fn gt<I>(self, other: I) -> bool
    where
        I: IntoIterator,
        Self::Item: PartialOrd<<I as IntoIterator>::Item>;
    fn ge<I>(self, other: I) -> bool
    where
        I: IntoIterator,
        Self::Item: PartialOrd<<I as IntoIterator>::Item>;
    fn is_sorted(self) -> bool
    where
        Self::Item: PartialOrd<Self::Item>;
    fn is_sorted_by<F>(self, compare: F) -> bool
    where
        F: FnMut(&Self::Item, &Self::Item) -> Option<Ordering>;
    fn is_sorted_by_key<F, K>(self, f: F) -> bool
    where
        F: FnMut(Self::Item) -> K,
        K: PartialOrd<K>;
}

}

Iterator<Item = T>类型可以被迭代并产生T类型。没有IteratorMut trait。每个Iterator实现可以指定它返回的是不可变引用、可变引用还是拥有通过Item关联类型的值。

Vec<T> 方法返回
.iter()Iterator<Item = &T>
.iter_mut()Iterator<Item = &mut T>
.into_iter()Iterator<Item = T>

大多数类型没有它们自己的迭代器,这对于初级Rustaceans来说,并不明显,但中级Rustaceans认为这是理所当然的。如果一个类型是可迭代的,我们几乎总是实现自定义的迭代器类型来迭代它,而不是让它自己迭代。


#![allow(unused)]

fn main() {
struct MyType {
    items: Vec<String>
}

impl MyType {
    fn iter(&self) -> impl Iterator<Item = &String> {
        MyTypeIterator {
            index: 0,
            items: &self.items
        }
    }
}

struct MyTypeIterator<'a> {
    index: usize,
    items: &'a Vec<String>
}

impl<'a> Iterator for MyTypeIterator<'a> {
    type Item = &'a String;
    fn next(&mut self) -> Option<Self::Item> {
        if self.index >= self.items.len() {
            None
        } else {
            let item = &self.items[self.index];
            self.index += 1;
            Some(item)
        }
    }
}

}

为了便于教学,上面的例子展示了如何从头开始实现一个迭代器,但在这种情况下,常用的解决方案是直接延用Veciter方法。


#![allow(unused)]
fn main() {
struct MyType {
    items: Vec<String>
}

impl MyType {
    fn iter(&self) -> impl Iterator<Item = &String> {
        self.items.iter()
    }
}

}

而且,这也是一个需要注意到的generic blanket impl:


#![allow(unused)]
fn main() {
impl<I: Iterator + ?Sized> Iterator for &mut I;
}

一个迭代器的可变引用也是一个迭代器。知道这一点是有用的,因为它让我们能够使用self作为接收器(receiver)的迭代器方法,就像&mut self接收器一样。

举个例子,假定我们有一个函数,它处理一个数据超过三项的迭代器,但是函数的第一步是取出迭代器的前三项并在迭代完剩余项之前单独处理它们,下面是一个初学者可能会写出的函数实现:


#![allow(unused)]
fn main() {
fn example<I: Iterator<Item = i32>>(mut iter: I) {
    let first3: Vec<i32> = iter.take(3).collect();
    for item in iter { // ❌ iter consumed in line above
        // process remaining items
    }
}

}

这看起来有点让人头疼。take方法有一个self接收器,所以我们似乎不能在没有消耗整个迭代器的情况下调用它!下面是对上面代码的重构:


#![allow(unused)]
fn main() {
fn example<I: Iterator<Item = i32>>(mut iter: I) {
    let first3: Vec<i32> = vec![
        iter.next().unwrap(),
        iter.next().unwrap(),
        iter.next().unwrap(),
    ];
    for item in iter { // ✅
        // process remaining items
    }
}
}

这样是没问题的,但是实际中通常会这样重构:


#![allow(unused)]
fn main() {
fn example<I: Iterator<Item = i32>>(mut iter: I) {
    let first3: Vec<i32> = iter.by_ref().take(3).collect();
    for item in iter { // ✅
        // process remaining items
    }
}
}

这种写法不太常见,但不管怎样,现在我们知道了。

此外,对于什么类型可以或者不可以是迭代器,并没有规则或者约定。如果一个类型实现了Iterator,那么它就是一个迭代器。下面是标准库中一个新颖的例子:


#![allow(unused)]
fn main() {
use std::sync::mpsc::channel;
use std::thread;

fn paths_can_be_iterated(path: &Path) {
    for part in path {
        // iterate over parts of a path
    }
}

fn receivers_can_be_iterated() {
    let (send, recv) = channel();

    thread::spawn(move || {
        send.send(1).unwrap();
        send.send(2).unwrap();
        send.send(3).unwrap();
    });

    for received in recv {
        // iterate over received values
    }
}

}

IntoIterator


#![allow(unused)]
fn main() {
trait IntoIterator 
where
    <Self::IntoIter as Iterator>::Item == Self::Item, 
{
    type Item;
    type IntoIter: Iterator;
    fn into_iter(self) -> Self::IntoIter;
}
}

正如其名,IntoIterator类型可以转化为迭代器。当一个类型在一个for-in循环里被使用的时候,该类型的into_iter方法会被调用:


#![allow(unused)]
fn main() {
// vec = Vec<T>
for v in vec {} // v = T

// above line desugared
for v in vec.into_iter() {}
}

不仅Vec实现了IntoIterator,如果我们想在不可变引用或可变引用上迭代,&Vec&mut Vec同样也是如此。


#![allow(unused)]
fn main() {
// vec = Vec<T>
for v in &vec {} // v = &T

// above example desugared
for v in (&vec).into_iter() {}

// vec = Vec<T>
for v in &mut vec {} // v = &mut T

// above example desugared
for v in (&mut vec).into_iter() {}

}

FromIterator


#![allow(unused)]
fn main() {
trait FromIterator<A> {
    fn from_iter<T>(iter: T) -> Self
    where
        T: IntoIterator<Item = A>;
}

}

正如其名,FromIterator类型可以从一个迭代器创建而来。FromIterator最常用于Iterator上的collect方法调用:


#![allow(unused)]
fn main() {
fn collect<B>(self) -> B
where
    B: FromIterator<Self::Item>;

}

下面是一个例子,搜集(collect)一个Iterator<Item = char>String:


#![allow(unused)]
fn main() {
fn filter_letters(string: &str) -> String {
    string.chars().filter(|c| c.is_alphabetic()).collect()
}

}

标准库中所有的集合都实现了IntoIteratorFromIterator,从而使它们之间的转换更为简单:


#![allow(unused)]
fn main() {
use std::collections::{BTreeSet, HashMap, HashSet, LinkedList};

// String -> HashSet<char>
fn unique_chars(string: &str) -> HashSet<char> {
    string.chars().collect()
}

// Vec<T> -> BTreeSet<T>
fn ordered_unique_items<T: Ord>(vec: Vec<T>) -> BTreeSet<T> {
    vec.into_iter().collect()
}

// HashMap<K, V> -> LinkedList<(K, V)>
fn entry_list<K, V>(map: HashMap<K, V>) -> LinkedList<(K, V)> {
    map.into_iter().collect()
}

// and countless more possible examples

}

I/O Traits


#![allow(unused)]
fn main() {
trait Read {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;

    // provided default impls
    fn read_vectored(&mut self, bufs: &mut [IoSliceMut<'_>]) -> Result<usize>;
    fn is_read_vectored(&self) -> bool;
    unsafe fn initializer(&self) -> Initializer;
    fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize>;
    fn read_to_string(&mut self, buf: &mut String) -> Result<usize>;
    fn read_exact(&mut self, buf: &mut [u8]) -> Result<()>;
    fn by_ref(&mut self) -> &mut Self
    where
        Self: Sized;
    fn bytes(self) -> Bytes<Self>
    where
        Self: Sized;
    fn chain<R: Read>(self, next: R) -> Chain<Self, R>
    where
        Self: Sized;
    fn take(self, limit: u64) -> Take<Self>
    where
        Self: Sized;
}

trait Write {
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    fn flush(&mut self) -> Result<()>;

    // provided default impls
    fn write_vectored(&mut self, bufs: &[IoSlice<'_>]) -> Result<usize>;
    fn is_write_vectored(&self) -> bool;
    fn write_all(&mut self, buf: &[u8]) -> Result<()>;
    fn write_all_vectored(&mut self, bufs: &mut [IoSlice<'_>]) -> Result<()>;
    fn write_fmt(&mut self, fmt: Arguments<'_>) -> Result<()>;
    fn by_ref(&mut self) -> &mut Self
    where
        Self: Sized;
}

}

值得关注的generic blanket impls:


#![allow(unused)]
fn main() {
impl<R: Read + ?Sized> Read for &mut R;
impl<W: Write + ?Sized> Write for &mut W;

}

也就是说,Read类型的任何可变引用也都是ReadWrite同理。知道这些是有用的,因为它允许我们使用任何带有self接收器的方法,就像它有一个&mut self接收器一样。我们已经在迭代器trait部分讲过了它是如何起作用的以及为什么很有用,所以这里不再赘述。

这里我想指出的是,&[u8] 实现了ReadVec<u8>实现了Write。因此我们可以对我们的文件处理函数进行简单的单元测试,通过使用String转换为&[u8]以及从Vec<u8> 转换为String


#![allow(unused)]
fn main() {
use std::path::Path;
use std::fs::File;
use std::io::Read;
use std::io::Write;
use std::io;

// function we want to test
fn uppercase<R: Read, W: Write>(mut read: R, mut write: W) -> Result<(), io::Error> {
    let mut buffer = String::new();
    read.read_to_string(&mut buffer)?;
    let uppercase = buffer.to_uppercase();
    write.write_all(uppercase.as_bytes())?;
    write.flush()?;
    Ok(())
}

// in actual program we'd pass Files
fn example(in_path: &Path, out_path: &Path) -> Result<(), io::Error> {
    let in_file = File::open(in_path)?;
    let out_file = File::open(out_path)?;
    uppercase(in_file, out_file)
}


// however in unit tests we can use Strings!
#[test] // ✅
fn example_test() {
    let in_file: String = "i am screaming".into();
    let mut out_file: Vec<u8> = Vec::new();
    uppercase(in_file.as_bytes(), &mut out_file).unwrap();
    let out_result = String::from_utf8(out_file).unwrap();
    assert_eq!(out_result, "I AM SCREAMING");
}
}

总结

我们一起学到了很多! 事实上是太多了。这是我们现在的样子:

Rust 中的隐式类型转换

译者:iamazy

原文: https://www.possiblerust.com/guide/what-can-coerce-and-where-in-rust


Rust 支持多种类型转换,它可以隐式的将一种类型转换成另一种类型。与其他支持类型转换的语言一样,Rust 在易读性与易写性之间做了权衡。虽然对于 Rust 支持类型转换的清单是否最好存在分歧,但是学习类型转换是有意义的,因为有些是惯用的 (idiomatic) Rust 代码的核心。在本文中,我将描述 Rust 支持什么样的类型转换,以及在何处应用。

说明:本文介绍的所有类型转换都是隐式强制转换,将简称为强转。

什么是(类型)强转

在讨论类型强转之前,最好先弄清楚它的含义。Rust 支持多种类型转换的方式。FromInto特性用于库级别的可靠 (infallible) 转换。TryFromTryInto用于处理易出错的类型转换。AsRefAsMutBorrowToOwned提供了更多不同类型之间库级转换。但是,这些都是显式的。要执行转换,用户必须调用相关的函数。相比之下,强转是隐式的,这些转换的隐式属性意味着它们仅在其裨益依赖于易用性时才是可用的,并且隐式类型更改造成的潜在危害最小。使用as关键字完成的转换是显式的,并且允许的显式强转 (cast) 的种类比隐式强转 (coercion) 要多。

INFO 1transmute - unsafe 转换
标准库中有一个函数std::mem::transmute,它可以将任意类型转换成其他类型。该函数是unsafe的,因为它不能保证输入类型的有效位可以表示为输出类型的有效位。确保这两种类型兼容由用户决定。

有一个致力于在 Rust 中开发“safe transmute”选项的工作,可以称之为“Project Safe Transmute”。他们的工作正在进行中,目的是当讨论的转化合法时,不需要使用unsafe版本的transmute(意味着源类型的有效位始终是目标类型中的有效位)。

有哪些类型强转 (coercion) 呢?

Rust 支持多种隐式的类型强转,尽管它们的定义都是非正式的,但是仍然需要进行一定程度的标准化。事实上,这些转换的长期规范预计将成为最终标准化过程的一部分,因为它们对于理解 Rust 的类型系统至关重要。

INFO 2,标准化编程语言
由于缺乏规范,Rust 不如 C/C++ 值得信赖的批评定期出现,在这里我要解释一下:首先,Rust 确实没有像 C/C++ 那样的规范(由国际标准组织发布和管理),但这并不意味着 Rust 完全没有标准。 Rust 有一个 reference,它编纂 (codify) 了该语言的大部分预期语义。它还具有管理语言变化的 RFC 流程,以及监督 (oversee) 语言发展的团队。这些团队包括不安全代码指南工作组 (Unsafe Code Guidelines Working Group),旨在更好的指定影响 unsafe Rust 代码的语义,要求和保证。该小组开发了miri,这是 Rust 中的 MIR (Mid-Level Internal Representation) 语言的解释器,它可以自动验证 MIR 代码是否与 Rust 语义中的“stacked borrows”模型(由 UCG WG 提出)一致。主要的 Rust 编译器也经过彻底测试,包括实验特性变更和新编译器版本的自动回归测试。
rustc 有一个可用的替代实现 - mrustc,尽管它通常不供最终用户使用。在实现支持 Rust 的 GNU 编译器集合方面还有更新的工作,称为“rust-gcc”。
Ferrocene 一直在致力于获得 Rust 认证以用于关键的安全领域,包括航空电子(avionic) 和自动化行业。它由 Ferrous Systems (一家 Rust 咨询公司) 维护,其团队中包括主要的语言和社区贡献者。 最终,正式指定的挑战以及证明 Rust 的保证已经在学术中得到解决,多个项目构建了模型,包括 Patina,Oxide,RustBelt,KRust 和 K-Rust。这些工作在 Alexa White 的硕士学位论文 Towards a Complete Formal Semantics of Rust 中得到了研究和扩展,该论文是理解这些不同科研工作的一个很好的切入点。
所有这些虽然不是标准,但是提高了 Rust 的水平,使其可以达到它所保证的能力。主 Rust 编译器中存在可靠性漏洞,这些漏洞会随着时间的推移被跟踪解决。如 RFC 1122 所述,Rust 稳定性策略为修复可靠性漏洞的破坏性更改留下了一个例外。
还值得注意的是,C 语言在 1972 年引入,而 C 语言标准的第一个正式非草案版本在 1989 年问世 (ANSI X3.159-1989 “编程语言 - C,” 现已撤回)。C++ 于 1985 年推出,其标准的第一个非草案版本于 1998 年发布 (ISO/IEC 14882:1998 “编程语言 — C++”)。
Rust 第一个公开版本是在 2010 年发布的,它在 2015 年 5 月 15 日对早期版本的语言进行了重大更改后,发布了 1.0 版本。从 1.0 发布之日算起,已经过去了 6 年。标准化需要时间,耐心是一种美德。

引用降级强转

引用降级强转是一种非常常见的强转操作,它可以将&mut T强转为&T。显然,这种强转总是安全的,因为不可变引用会受到更多的限制。它还允许借用检查器接受一些你可能认为不会编译或正常工作的代码。

一个引用降级强转的例子如下所示:

struct RefHolder<'a> {
    x: &'a i64,
}

impl<'a> RefHolder<'a> {
    fn new(x: &'a i64) -> RefHolder<'a> {
        RefHolder { x }
    }
}

fn print_num(y: &i64) {
    println!("y: {}", y);
}

fn main() {
    // Create `x`
    let mut x = 10;

    // Make sure `y` is `&mut i64`.
    let y = &mut x;

    // Package the downgraded reference into a struct.
    let z = RefHolder::new(y);
    
    // Print `y` downgrading it to an `&i64`.
    print_num(y);
    
    // Use the `z` reference again.
    println!("z.x: {}", z.x);
}

在该例中,我们可以看到print_num函数只需要&i64,但它传入了一个&mut i64。它可以正常运行是因为引用降级强转成了一个不可变引用。这也解决了给可变借用起别名的问题。RefHolder类型的构造函数也会发生同样的情况。

请注意该强转发生的次数。这里有一个类似的无法编译的例子。

struct RefHolder<'a> {
    x: &'a i64,
}

impl<'a> RefHolder<'a> {
    fn new(x: &'a i64) -> RefHolder<'a> {
        RefHolder { x }
    }
}

fn print_num(y: &i64) {
    println!("y: {}", y);
}

fn main() {
    // Create `x`
    let mut x = 10;

    // Make sure `y` is `&mut i64`.
    let y = &mut x;

    // Package the downgraded reference into a struct.
    //
    //---------------------------------------------------
    // NOTE: this is a _fresh_ reference now, instead of
    //       being `y`.
    //---------------------------------------------------
    let z = RefHolder::new(&mut x);
    
    // Print `y` and update it, downgrading it
    // to `&i64`.
    print_num(y);
    
    // Use the `z` reference again.
    println!("z.x: {}", z.x);
}

在该例中,即使引用在函数签名中降级,借用检查器仍然观察到在同一作用域内(针对同一内存)创建了两个可变引用,这是不被允许的。

解引用强转

下一种强转是 Rust 人体工程学 (ergonomics) 的基石 (cornerstone)。“解引用强转”是由两个特征的实现产生的强转:DerefDerefMut。这些(特征)明确存在的目的是选择加入这种强转,让容器可以透明使用它们包含的类型(这些容器通常称为“智能指针”)。

这类特征定义如下所示:


#![allow(unused)]
fn main() {
pub trait Deref {
    type Target: ?Sized;

    pub fn deref(&self) -> &Self::Target;
}

pub trait DerefMut: Deref {
    pub fn deref_mut(&mut self) -> &mut Self::Target;
}
}

第一个特征Deref定义了一个类型,可以提供对其他“目标”类型的引用。这个目标是一个关联类型,而不是一个类型参数,因为每个“智能指针”应该只能被解引用为一种类型。如果它被定义为Deref<Target>,则任何类型都可以提供尽可能多的实现,因为它们可以提供内部类型,然后编译器根据某种机制来选择正确的内部类型。解引用强转的关键在于它们是隐式的,因此通常明确的类型注释会与解引用强转功能的好处相冲突。

DerefMut特征需要Deref作为超类型,这既可以让其访问 Target 关联类型,也可以确保DerefDerefMut的目标类型始终一致。否则,你可能会在可变上下文中启用对一种类型的强转,而在不可变上下文中启用对另一种类型的强转。这种级别的灵活性为解引用强转增加了更多的复杂性,但没有明显的好处,因此它不可用。

这两个特征所需的方法derefderef_mut,在实现这些特征的类型上调用方法时会被隐式调用。比如,在Box<T>上实现了Deref<Target = T>,因此可以透明地调用其包含类型的方法,这使得Box<T>比用户必须为每个操作显式访问其内容更符合人体工程学。

裸指针强转

Rust 的裸指针可能会从*mut T强转为*const T。尽管通过解引用来使用这些指针是 unsafe 的,并且受制于 Rust 对指针的安全性要求(即访问永远不会悬垂或未对齐),但是这些转换是 safe Rust 的一部分(即不是为在 unsafe 上下文中使用而保留的功能)。

裸指针的强转示例如下所示:

#[derive(Debug)]
struct PtrHandle {
    ptr: *const i32,
}

fn main() {
    let mut x = 5;
    let ptr = &mut x as *mut i32;

    // The coercion happens on this line, where
    // a `*mut i32` is set as the value for a field
    // with type `*const i32`, coercing to that type.
    let handle = PtrHandle { ptr };

    println!("{:?}", handle);
}

INFO 3 指针转换的安全性
Rust 还允许将*const T通过as转换成*mut T
虽然允许将*const T转换为*mut T似乎让人感到惊讶,但有时这种转换是必要的。例如,FFI 代码可能会从Box::into_raw中创建一个*mut T,但只想为 API 的 C 使用者提供一个*const T。因此 FFI 接口提供的等效删除函数需要将*const T作为参数,将其转换回*mut T以将其传递给Box::from_raw,从而使 Rust 在函数结束时对Box进行释放。
虽然指针出处 (provenance) 的细节意味着这种转换并不总是未定义的行为,但如果指针的原始出处不是可变的,则它可能是未定义的行为。换句话说,如果一个值最初是*mut T类型,它可以在将来用作*mut T,即使类型在此期间 (interim) 被转换为*const T

引用与裸指针强转

你可以将&T转换为*const T,将&mut T转换为*mut T。尽管产生的裸指针只能在 unsafe 的代码块中解引用,但是这些强转是 safe 的。

和上一个例子类似,但是这一次是将引用转换成指针而不是改变指针类型的可变性。

// Notice that these coercions work when
// generic types are present too.
#[derive(Debug)]
struct ConstHandle<T> {
    ptr: *const T,
}

#[derive(Debug)]
struct MutHandle<T> {
    ptr: *mut T,
}

fn main() {
    let mut x = 5;

    let c_handle = ConstHandle {
        // Coercing `&i32` into `*const i32`
        ptr: &x,
    };

    let m_handle = MutHandle {
        // Coercing `&mut x` into `*mut i32`
        ptr: &mut x,
    };

    println!("{:?}", c_handle);
    println!("{:?}", m_handle);
}

函数指针强转

闭包是函数加上其执行的上下文。这使得它们在许多情况下非常有用,但有时它们携带的这种额外状态会阻碍 (impede) 它们的使用,特别是没有实际的状态捕获时。在 Rust 中,除了编译时生成的无名闭包类型之外,还有函数指针类型表示没有上下文环境的函数。为了使闭包尽可能灵活,,当且仅当它们不从上下文中捕获任何变量时,闭包才会强制使用指针。

一个函数指针的示例如下:

// This function takes in a function pointer, _not_ a generic type
// which implements one of the function traits (`Fn`, `FnMut`, or
// `FnOnce`).
fn takes_func_ptr(f: fn(i32) -> i32) -> i32 {
    f(5)
}

fn main() {
    let my_func = |n| n + 2;

    // The coercion happens here, and is possible because `my_func`
    // doesn't capture any variables from its environment.
    println!("{}", takes_func_ptr(my_func));
}

请注意,在 Rust 中使用泛型实现FnFnMutFnOnce特征的方式比使用函数指针要常见的多。如果你想要传递或存储从上下文中捕获的闭包,则需要使用这三种特征的其中一个。

子类型强转

令某些人惊讶的是,Rust 支持子类型强转。虽然 Rust 的类型系统通常被认为仅支持参数多态性,但实际上它也支持子类型多态性,适用于生存期。当一个生存期比另一个生存期更长时,Rust 中的生存期会彼此形成子类型关系。在这种情况下,生存期较长的是子类型,生存期较短的是超类型。因为在子类型多态中,任何子类型都可以代替超类型,这对于生存期就意味着当预期的生存期较短时,可以安全地使用较长的生存期。

这种强转意味着允许在强转点“缩短”生存期,因此可以使用更长的生存期来代替函数所需的较短边界。对于 Rustacean 来说,这样做的最终结果是编译器可以接受更多的程序。

像 Rust 一样支持参数和子类型多态的语言中出现的一个共同问题是范型类型的子类型关系如何与其范型参数的子类型关系相关联。该属性称为型变 (variance)。

范型类型有三个有用的变型。它们每一个都与特定的通用类型相关;如果一个类型有多个范型参数,它将对每个参数进行单独的型变确定。

  • 协变 (Covariance):对于类型A<T>,如果TU的子类型,则A<T>A<U>的子类型。容器的子类型与其范型参数子类型相匹配。

  • 逆变 (Contravariance):对于类型A<T>,如果TU的子类型,则A<U>A<T>的子类型。容器的子类型与其范型参数的子类型互逆。

  • 不变 (Invariance):对于类型A<T>,在A<T>A<U>之间不存在子类型关系。容器没有子类型。

在 Rust 中,由于子类型只存在于生存期中,并且生存期表示数据存活的时间,这就意味着:

  • 协变类型的生存期允许比预期的更长(这些生存期允许“缩短”,这样不会有问题是因为引用的使用时间总是少于它们的有效时间)。

  • 逆变类型的生存期允许延长(就像通过要求使用'static而不是'a的生存期,来避免函数指针携带引用类型)。

  • 不变类型没有子类型关系,需要一个既不会缩短也不会延长的生存期。

也许一个带有子类型强转的逆变生存期示例可以帮助理解:

struct FnHolder {
    f: fn(&'static str) -> i32,
}

fn number_for_name<'a>(name: &'a str) -> i32 {
    match name {
        "Jim" => 32,
        _ => 5,
    }
}

fn main() {
    // Voila! A subtype coercion! In this case coercing a
    // lifetime in a contravariant context (the lifetime in
    // the function pointer type parameter) from `'a` to `'static`.
    //
    // `'static` is longer than `'a`, which in this case is safe
    // because it's always fine to make the function _less_ accepting.
    //
    // Once it's been assigned into the `FnHolder` type, it'll only
    // accept string literals (which have a `'static` lifetime).
    let holder = FnHolder { f: number_for_name };
    
    // The extra parentheses are part of the syntax for calling
    // functions as fields, to disambiguate between this and
    // calling a method on the `FnHolder` type.
    println!("{}", (holder.f)("Jim"));
}

never 强转

Rust 类型系统中有一个特殊的类型 - never 类型(写作!)。此类型可以强转为其他所有类型,通常表示非终止 (non-termination)。例如,unimplemented!unreachable!todo!宏都返回!类型。!类型强转可以利用这些宏类型检查,如果它们在运行时中执行,则!被实现为当前线程有保证的 panic。退出当前进程的std::process::exit函数返回!也是出于相同的原因。

never 类型强转让程序可以使用 panic 或 exit 通过类型检查。

// Turn off some warnings about unreachable code.
#![allow(unreachable_code)]
#![allow(unused_variables)]
#![allow(dead_code)]

struct Value {
    x: bool,
    y: String,
}

fn never() -> ! {
    // `loop`s without some way to exit
    // like this have the `!` type, because
    // the expression (and, in this case,
    // the containing function) will never
    // terminate / return.
    loop {}
}

fn main() {
    let x = never();
    
    let v = Value {
        x: todo!("uhhh I haven't gotten to this"),
        y: unimplemented!("oh, not this either"),
    };
    
    // This program compiles because `never`,
    // `todo!`, and `unimplemented!` all return
    // the `!` type, which coerces into any type.
}

切片强转

切片强转是指从数组到切片的转换。它们是“未知大小强转”集合(以及特征对象强转和 trailing unsized 强转)中的一部分。之所以这么称呼它们,是因为它们涉及从有大小的类型(在编译时已知大小并实现了Sized特征的类型)到未知大小的类型(在编译时不知道其类型的大小,并且没有实现 Sized特征)。在切片强转的过程中,已知大小的类型为[T; n](具有固定大小 n 的 T 数组),未知大小的类型为[T](T 数组的切片)。

切片强转发生的次数可能比你想到的还要多:

#[derive(Debug)]
struct SliceHolder<'a> {
    slice: &'a [i32],
}

fn main() {
    let nums = [1, 2, 3, 4, 5];
    
    // It may not look like, but there's a coercion here!
    //
    // The type of `&nums` is `&[i32; 5]`, which is coerced
    // into `&[i32]` to match the `slice` field on `SliceHolder`.
    let holder = SliceHolder { slice: &nums };
    
    println!("{:#?}", holder);
}

请注意,虽然也可以将Vec<T>强转为&[T],但它不是切片强转,而是解引用强转。由于语言的历史原因,数组无法与 const 泛型一起使用(因为 const 泛型没有实现Deref),因此需要特殊的强转将其静默转换成切片。

特征对象强转

特征对象是 Rust 的动态调度机制,并且特征对象强转的存在是为了可以轻松构建特征对象。这种强转从某种类型T转换成dyn U,其中U是被T实现的特征,并且U满足 Rust 的对象安全规则。我们之前已经讨论过对象安全规则,但要点是对象特征类型必须是可构造的(这意味着它在任何地方都不依赖在编译时不确定的泛型类型(泛型不包括关联的函数,没有引用Self的方式 - 在编译时期无法确定,并且在不包含Self: Sized边界的情况下,不包括按值获取Self的函数)。

一个函数通过特征对象强转进行调用的示例:

trait HasInt {
    fn get(&self) -> i32;
}

struct IntHolder {
    x: i32,
}

impl HasInt for IntHolder {
    fn get(&self) -> i32 {
        self.x
    }
}

fn print_int(x: &dyn HasInt) {
    println!("{}", x.get());
}

fn main() {
    let holder = IntHolder { x: 5 };
    // The coercion happens here, from `&IntHolder`
    // into `&dyn HasInt`.
    print_int(&holder);
}

trailing unsized 强转

trailing unsized 强转意味着,如果类型T的最后一个字段是已知大小的且可以转换为未知大小的类型,并存在一个U类型,它是T类型但执行了最后一个字段的强转,那么T可以被强转为U。因为这个定义非常特殊,我们可以具体说明:

  • T必须是一个结构体
  • T的字段A必须可以强转为未知大小的B类型
  • T的最后一个字段必须包含A
  • T的其他字段不能包含A
  • 如果T最后一个字段本身就是包含A的结构体,则该结构体必须可以强转为另一种类型,该类型包含未知大小的,用来替换AB类型。

这比最初的解释更准确。本质上,当相关字段是最后一个字段时,允许在结构体内进行有限的 unsized 强转。

最小上限强转

有时 Rust 需要同时在多个强转点进行强转,这样它们都可以变成相同的类型。例如,这可能发生在if/else表达式中,其中条件的每个分支都返回一个需要强转的类型。在这种情况下,Rust 试图找到最通用的类型,这被称为“最小上限强转”。

该强转可以被以下情况触发:

  1. 一系列if/else分支
  2. 一系列match分支
  3. 一系列数组元素
  4. 在闭包中的一系列返回值
  5. 在函数中的一系列返回值

执行此强转的过程是遍历每个系列中的每种类型,检查它们是否可以转换为先前确定的相同类型。如果可以,则继续。如果不能,则尝试找出一种类型C,可以将先前看到的类型A与最新的类型B都强转为类型C。最终的类型C被确定为该系列中所有表达式的类型。

传递性强转

Rust 支持传递性强转,如果类型A可以强转为类型B,并且类型B可以强转为类型C,则类型A可以强转为类型C。这个特性目前正在开发,可能不是总是有效。

哪里会发生强转

代码中发生类型强转的位置被称为“强转点 (coercion sites)”,Rust 中有多种类型的强转点。

强转点

首先是变量的声明,不管是通过letconst还是static进行声明。在这些情况下,如果在左侧显式声明变量的类型,则右侧将被强转为该类型。如果无法进行这种强转,则会发出编译器错误。

接下来是函数参数,其中实参被强转为型参的类型。在方法调用中,接收者类型(Self的类型)只能使用 unsized 强转。

然后你就可以拥有任何结构或枚举的字面量实例。这些数据类型中的字段被实例化的位置是强转点,实际类型被强转为整体数据类型声明中定义的正式类型。

强转传播的表达式

有些表达式被认为是“强转传播 (coercion propagating)”,这意味着它们会将强转检查传递给它们的子表达式。

数组字面量是强转传播的,并传播到数组字面量声明的每个元素定义中。如果与重复语法一起使用,该语法将重复给定次数的元素的初始定义。

元组在它们内部的每个单独表达式上也类似的进行强转传播。

如果表达式带有括号,则强转将传播到括号内的表达式。如果它被括号括起来,使它成为一个块,那么强转将传播到该块的最后一行。

unsized 强转和强转点

与其他强转相比,unsized 强转(上述对切片,特征对象或 trailing unsized 类型的强转)可以在一个额外的上下文中发生。具体来说,如果有一个指向类型 T 的引用,裸指针或(有所有权的)指针,其中 T 具有对类型 U 的 usized 强转,则可以通过引用或指针类型进行强转。

这意味着以下强转点仅对 unsized 强转有效:

  • &T&U
  • &mut T&mut U
  • *const T*const U
  • *mut T*mut U
  • Box<T>Box<U>

这就是为什么上述切片强转的示例可以正常运行的原因!这种情况下的强转发生在引用之后,将[i32; 5]强转为[i32]。

结论

强转功能十分强大,因为它们是隐式的,有时会引起争议 (controversial)。

无论你对正确使用强转有何看法,重要的是了解什么是可能的强转,以及它们可能发生的位置。在本文中,我们命名并描述了 Rust 中所有可能的强转,并描述了哪些类型的表达式可能包含强转,以及哪些表达式可以传播强转。希望这有助于使 Rust 中这个经常被隐藏的部分变得更加清晰。

Tokio Internal 之 任务调度

作者:韩冰(Tony) / 后期编辑:张汉东

这是一个系列文章:Tokio Internal: https://tony612.github.io/tokio-internals/

本篇精选其中相对独立的一篇《Task scheduler》,对该系列感兴趣可以去看完整系列文章。


选择合适的 task 来运行是调度器非常重要的一个逻辑,如果处理不好,可能会使调度很慢,也可能会使不同的 task 没有被公平地执行,甚至有些 tasks 可能一直得不到执行。我们来看一下 tokio 中是怎么解决这些问题的。

各种 run queue

我们来看 Tokio 中取下一个 task(next_task)以及 steal_task 的代码:


#![allow(unused)]
fn main() {
// core.next_task:
fn next_task(&mut self, worker: &Worker) -> Option<Notified> {
    if self.tick % GLOBAL_POLL_INTERVAL == 0 {
        worker.inject().pop().or_else(|| self.next_local_task())
    } else {
        self.next_local_task().or_else(|| worker.inject().pop())
    }
}

--------------------------
// self.next_local_task:
fn next_local_task(&mut self) -> Option<Notified> {
    self.lifo_slot.take().or_else(|| self.run_queue.pop())
}

--------------------------
// core.steal_work:
let num = worker.shared.remotes.len();
let start = self.rand.fastrand_n(num as u32) as usize;

for i in 0..num {
    let i = (start + i) % num;

    // Don't steal from ourself! We know we don't have work.
    if i == worker.index {
        continue;
    }

    let target = &worker.shared.remotes[i];
    if let Some(task) = target.steal.steal_into(&mut self.run_queue) {
        return Some(task);
}

link

可以看到,Worker 会从多个地方取 task,按顺序依次是:

  1. LIFO slot

  2. 自己的 local queue

  3. global queue

  4. 从其他 worker 的 queue(remotes) 中 steal 任务

link

Global queue 肯定需要,但如果只有一个 global queue,每个 worker 从其中取 task 时,都需要加锁,会影响性能。因此给每个 worker 增加自己的 local queue 是很自然的选择,worker 可以优先从自己的 local queue 中取任务。

global 和 local 这两种 queue 都是 FIFO 的,这对于公平性很好,先到先得嘛,但不好的地方是 locality 带来的性能,每个 task 切换执行时,之前 CPU 的缓存就没用了。LIFO slot 就是为了改善这种问题的,它 (似乎)是从 Go 里借鉴来的一个机制,可以解决一些场景 locality 问题。除非是 task 主动 yield,否则当 task 被调度时,会优先考虑放到 LIFO slot(相当于是 queue 的最前边),这个 task 会被优先执行。

比如在一个 task 中 spawn 了另外一个 task,并且有一些变量需要被 move 到新的 task 中,这时如果新的这个 task 能够先被运行的话,这些变量在 CPU 中的缓存就能得到有效利用。Tokio 代码中还提到了 LIFO 可以减少 message passing 中的延迟。比如,当一个 task 向另一个 task 通过 channel 发消息,如果 task 收到消息后可以先被执行,就可以减少因为在 run queue 中排队带来的延迟。

LIFO 可以带来更好的性能,但也会牺牲公平性,因此 LIFO slot 目前只有一个,当这个 LIFO slot 已被占用时,原来的任务就会被转移到 run queue 末尾,而新的 task 则会被放在 LIFO slot 中。LIFO slot 对于性能和公平性,可以实现一定程度上的平衡。

任务的窃取也是调度器中常见的机制,当 global 和 local queue 中都没有 task 可以执行,就会尝试去 "steal" 其他 worker 的任务,这样可以平衡不同 worker 的任务量。Tokio 会随机挑一个 worker 开始尝试窃取,并且会窃取一半的任务,如果那个 worker 也没有任务,就会尝试窃取下一个 worker。不过 LIFO slot 的任务并不会被窃取。

Starvation 问题

虽然不同类型的 queue 和 work stealing 机制可以带来不错的性能和公平性,但还不够,tasks 依然可能会被“饿死”(starvation)。下边描述了几种常见的场景,和 Tokio 中的解决方法。

一个 task 执行过久

如果一个 task 执行很久,最坏情况是进入了死循环,那当前 worker 的 queue 中的 tasks 就要等待更长时间才能得到执行,甚至是一直不会被执行。我们知道,目前 Rust runtime 中无法抢占式调度(preempt)这样的 task,主要还是需要开发者自己进行代码“协同”。

但 Tokio 也有机制来改善这类问题,比如在 2.5 见过的 coop module,在 task 运行之前会调用


#![allow(unused)]
fn main() {
coop::budget(|| {
})
}

它会创建一个 thread local 的 counter,目前初始值是 128。调用 coop::poll_proceed 会把 counter 减 1,当减小到 0 时,就会返回 Pending。而 Tokio 中在 poll 之前都会先调用 coop::poll_proceed 来判断是否超过 budget,如果超过,就会直接返回而不会调用实际的 poll。比如 2.5 中提过的 poll_ready 以及 tokio mpsc recv 等方法里都调用了它。

不过即便是有 coop ,如果是纯 CPU 的计算,Tokio 没办法了。当然这种还是用 tokio::task::spawn_blocking 比较好。

Global queue 中的任务被饿死

因为 local queue 中任务的优先级比 global queue 要高,如果一个 task 一直没有执行结束,比如一个 TCP server 的连接不停有新的数据从 client 发过来,于是它不停被挂起、放在队列、运行,这样 global queue 的任务就一直得不到运行。

Tokio 会用 worker 的 tick(和 3.1 的 driver tick 不同)来记录 worker 在循环中运行的次数,在运行 task 或者 park 之前就会把 tick 加 1。而当取 task 时,就会判断是否运行了一定次数,是的话,就会从先从 global queue 中取 task 来运行,其实就是本章第一段代码。GLOBAL_POLL_INTERVAL 目前取值是 61,是从 Go 中 copy 来的


#![allow(unused)]
fn main() {
fn next_task(&mut self, worker: &Worker) -> Option<Notified> {
    if self.tick % GLOBAL_POLL_INTERVAL == 0 {
        worker.inject().pop().or_else(|| self.next_local_task())
    } else {
        self.next_local_task().or_else(|| worker.inject().pop())
    }
}
}

LIFO slot 导致的饿死

LIFO slot 因为改变了 task 的优先级,就可能会导致其他任务被饿死。有一种可能是,一个 worker 中的两个 task 一直在互相发消息,这两个 task 就会一直在 LIFO slot 中,导致 local queue 的任务得不到执行,有个测试用例 专门来测试这种场景。Tokio 的解决方法是,对 LIFO slot 做了特殊处理:


#![allow(unused)]
fn main() {
coop::budget(|| {
    task.run();

    // As long as there is budget remaining and a task exists in the
    // `lifo_slot`, then keep running.
    loop {
        // Check for a task in the LIFO slot
        let task = match core.lifo_slot.take() {
            Some(task) => task,
            None => return Ok(core),
        };

        if coop::has_budget_remaining() {
            // Run the LIFO task, then loop
            *self.core.borrow_mut() = Some(core);
            task.run();
        } else {
            // Not enough budget left to run the LIFO task, push it to
            // the back of the queue and return.
            core.run_queue.push_back(task, self.worker.inject());
            return Ok(core);
        }
    }
})
}

link

一个任务执行后,不会回到之前的 next_task的地方,而是直接看 LIFO slot 中是否有任务可以执行,有就执行 LIFO slot 任务,没有就返回。但这个逻辑是放在一个 coop::budget 调用里的,当没有剩余 budget 时,就把 LIFO 的任务放到 run queue 末尾,从而避免了一直循环执行这两个 LIFO slot 的任务。

Event poll 被饿死

之前说过 Tokio 的 worker 会优先执行 run queue 中的 tasks,当没有任务可执行时,会在 park中 poll events。问题很明显,如果 run queue 一直没有执行完,就不会 poll events。Tokio 用了和 Global queue 饿死问题一样的方案,在取 task 之前,会在 maintenance 里先判断 worker 的 tick 是否运行了 GLOBAL_POLL_INTERVAL 次,是的话就强制 park

fn maintenance(&self, mut core: Box<Core>) -> Box<Core> {
    if core.tick % GLOBAL_POLL_INTERVAL == 0 {
        // Call `park` with a 0 timeout. This enables the I/O driver, timer, ...
        // to run without actually putting the thread to sleep.
        core = self.park_timeout(core, Some(Duration::from_millis(0)));

        // Run regularly scheduled maintenance
        core.maintenance(&self.worker);
    }

    core
}

注意这里调用了 park_timeout,并且超时时间为 0,如果当前没有事件的话,就会直接返回,继续执行 tasks,不会等在这里。

总结

Tokio 用了几种不同的 queue,分别解决了不同的问题,并且针对可能出现的 starvation 做了预防。可以看到,虽然并非完美,但 Tokio 在尽力平衡性能、公平性,并且还在不断被优化。

为什么你不应该沉迷于 Rust 的“特性”

译者:NiZerin

原文链接:https://nullderef.com/blog/rust-features/#cargo-release


Rust 使得表达条件编译变得非常容易,特别是由于它的“特性”。它们很好地集成到语言中并且非常易于使用。但是我通过维护Rspotify(Spotify API 的库)学到的一件事 是,人们不应该对它们着迷。当条件编译是解决问题的唯一方法时,应该使用条件编译,原因有很多,我将在本文中解释。

这对某些人来说可能很明显,但对我来说,当我开始使用 Rust 时并没有那么清楚。即使您已经知道,这也可能是一个有趣的提醒;也许您在最新的项目中忘记了它并添加了一个不必要的功能。

条件编译也不是什么新鲜事。C 和 C++ 已经这样做了很长时间,一方面。所以同样的事情可以应用于这些情况。然而,根据我的经验,在 Rust 中使用条件编译要容易得多,这意味着它也更有可能被滥用。

问题

在决定如何在 Rspotify 中配置缓存令牌时,我遇到了这个困境 。所述库使您可以通过 JSON 文件持久管理身份验证令牌。这样,当程序再次启动时,可以重复使用前一个会话中的令牌,而无需再次执行完整的身份验证过程——也就是说,直到令牌过期。

最初,这将是一个名为cached_token. 我并没有想太多。如果您不需要它,为什么需要代码来保存和读取令牌文件?最简单的方法是使用一个功能,你可以在你的 Cargo.toml.

但是,我后来需要另一个非常相似的功能,refreshing_token. 当可选地启用时,客户端将自动刷新过期的令牌。随着这种模式在库中越来越多地出现,我想确保它的设计是最佳的。深入研究后,我开始发现功能的许多不便之处:

它们是不灵活的:你不能在同一个程序中拥有一个带有缓存令牌的客户端和另一个没有它们的客户端。这是一个图书馆范围的事情,所以你要么启用它们,要么不启用。显然,它们也不能在运行时进行配置;用户可能想选择什么样的行为遵循,而 在程序运行。

它们很丑:写作 #[cfg(feature = "cached_token")]比普通的更奇怪和冗长 if cached_token

它们很乱:代码库中的功能很难管理。你可以很容易地发现自己处于 Rust 中,相当于 #ifdef 地狱。

它们很难记录和测试:Rust 没有提供公开库功能的方法。您所能做的就是在文档的主页中手动列出它们。测试也更难,因为您必须弄清楚要使用哪些功能组合来覆盖整个代码库,并在您想要运行测试时应用它们。

仅仅保证二进制文件不会包含您不需要的代码,所有这些都被认为是重要的。但这有多真实,真的吗?它有多重要?

替代

事实证明,编译器可以实现的最简单的优化之一是常量的传播。这与去除死代码相结合,可以产生与特征完全相同的效果,但以更自然的方式。除了添加功能来配置程序的行为之外,您还可以对 Config 结构进行相同的操作。如果它只是一个要配置的选项,您甚至可能不需要结构体,但这样它就可以面向未来。例如:


#![allow(unused)]
fn main() {
#[derive(Default)]
struct Config {
    cached_token: bool,
    refreshing_token: bool,
}
}

然后,您可以修改您的客户端,以便有选择地采用Config结构:


#![allow(unused)]
fn main() {
struct Client {
    config: Config
}

impl Client {
    /// Uses the default configuration for the initialization
    fn new() -> Client {
        Client {
            config: Config::default(),
        }
    }

    /// Uses a custom configuration for the initialization
    fn with_config(config: Config) -> Client {
        Client {
            config,
        }
    }

    fn do_request(&self) {
        if self.config.cached_token {
            println!("Saving cache token to the file!");
        }
        // The previous block used to be equivalent to:
        //
        // #[cfg(feature = "cached_token")]
        // {
        //     println!("Saving cache token to the file!");
        // }

        if self.config.refreshing_token {
            println!("Refreshing token!");
        }

        println!("Performing request!");
    }
}
}

最后,用户可以以一种非常自然的方式在代码中自定义他们想要的客户端:

fn main() {
    // Option A
    let client = Client::new();

    // Option B
    let config = Config {
        cached_token: true,
        ..Default::default()
    };
    let client = Client::with_config(config);
}

证明你最终得到了相同的代码

感谢出色的Compiler Explorer,我们可以使用以下代码段确保编译符合我们的预期:

image

似乎从 Rust 1.53 开始,对于 opt-level 大于或等于 2 的值,已停用功能的代码甚至不会出现在程序集中(通过查看末尾的字符串很容易看到)。cargo build --release 配置 opt-level 为 3,因此对于生产二进制文件应该不是问题。

我们甚至没有使用 const !我想知道在这种情况下会发生什么。使用 这个稍微修改的片段:

image

唔。我们实际上得到了相同的结果。生成的程序集完全相同,可选代码仅从 opt-level=2.

问题是这 const 仅仅意味着它的值可以(而不是必须)被内联。没有其他的。所以我们仍然没有任何保证,内联不足以简化函数内部的代码。

因此,对于我所调查的内容,最好不要担心它并使用变量而不是 const. 它看起来更好,并得到相同的结果。

无论如何

即使之前的优化没有实现,可选代码真的会对最终的二进制文件造成任何伤害吗?我们是否一如既往地过度设计了解决方案?事实是缓存/刷新令牌的可选代码甚至没有那么膨胀。

当然,这取决于,但在我看来,二进制膨胀对于更高级别的二进制文件来说并不是什么大问题。Rust 已经在每个二进制文件中静态嵌入了它的标准库、运行时和大量调试信息,总大小约为 3MB。您在运行时可能获得的唯一开销是分支。

结论

有时你只需要使用条件编译;没有办法解决它。您可能正在处理特定于平台的代码或想要减少 crate 的依赖项数量,在这种情况下,功能非常有用。

但这不是 Rspotify 的情况。条件编译绝对不是要走的路。当你准备向你的 crate 引入一个新特性时,想想自己,“我真的需要条件编译吗?”。

既不遵循通常的推理,cached_token也不refreshing_token遵循为什么可能会添加功能的原因。他们不允许访问新的功能/模块。它们无助于摆脱可选的依赖项。而且它们当然不是特定于平台的功能。他们只是配置库的行为。

为了避免这种情况,也许功能的命名可能会有所不同?启用对缓存令牌的支持听起来确实是一项“功能”,而特定于操作系统的代码似乎并不是真正的功能。有时我也觉得很困惑,谷歌在这一点上同意我的观点。寻找与 Rust 特性相关的信息可能会返回完全不相关的东西,因为结果有“特性”这个词,但意思是“程序的一个属性或方面”。有点像你必须谷歌“golang X”而不是“go X”,否则它没有意义。但无论如何,我的意见已经太迟了。

Rust no-std 工程实践

作者:苏胤榕

改写std的库为支持no_std的库及写出一个支持std和no_std库的经验谈 github repo: https://github.com/DaviRain-Su/rust-no-std-source

简介

首先介绍std和no_std的区别,然后介绍使用no_std库的方式,由于支持no_std的特性有两种不同的方式,因此使用no_std库也有两种方式。其次,验证一个库是否支持no_std特性的验证方式,如何改写一个std的库为同时支持std和no_std的特性方法。具体的如何写一个支持std和no_std的库。一些在std和no_std下都可以使用的primitive的仓库和相关的资源和文章。

目录

  • std和no_std的区别
  • Rust中使用no_std的两种方式
  • 验证一个库是否支持no_std特性的验证方式
  • 具体的写一个支持std和no_std的库
  • 一些no_std和std可以使用primitive类型的仓库和相关资源的文章

std和no_std的区别

核心库

Rust语言的语法由核心库和标准库共同提供。 其中Rust核心库是标准库的基础。核心库中定义的是Rust语言的核心,不依赖于操作系统和网络等相关的库,甚至不知道堆分配,也不提供并发和I/O

可以通过在模块顶部引入#![no_std]来使用核心库。核心库和标准库的功能有一些重复,包括如下部分:

  • 基础的trait,如Copy、Debug、Display、Option等。
  • 基本原始类型,如bool、char、i8/u8、i16/u16、i32/u32、i64/u64、isize/usize、f32/f64、str、array、slice、tuple、pointer等。
  • 常用功能型数据类型,满足常见的功能性需求,如String、Vec、HashMap、Rc、Arc、Box等。
  • 常用的宏定义,如println!、assert!、panic!、vec!等。 做嵌入式应用开发的时候,核心库是必需的。

标准库

Rust标准库提供应用程序开发所需要的基础和跨平台支持。标准库包含的内容大概如下:

  • 与核心库一样的基本trait、原始数据类型、功能型数据类型和常用宏等,以及与核心库几乎完全一致的API。
  • 并发、I/O和运行时。例如线程模块、用于消息传递的通道类型、Sync trait等并发模块,文件、TCP、UDP、管道、套接字等常见I/O。
  • 平台抽象。os模块提供了许多与操作环境交互的基本功能,包括程序参数、环境变量和目录导航;路径模块封装了处理文件路径的平台特定规则。
  • 底层操作接口,比如 std::mem、std::ptr、std::intrinsics 等,操作内存、指针、调用编译器固有函数。
  • 可选和错误处理类型Option和Result,以及各种迭代器等。

还有一些解释,#![no_std] 是一个crate level 级别的属性,表示core crate将链接到core crate而不是std crate。

下面是std crate和core crate的解释,其实这里也就解释了标准库与和核心库之间的区别。当让也内在的包括了std与no_std之间的区别。

首先是,std crate 是Rust 的标准库。它包含的功能假定程序将在操作系统上运行,而不是直接在裸系统上运行。std还假定操作系统是一个通用的操作系统,就像人们在服务器和台式机上看到的那样。出于这个原因,std为通常在这类操作系统中发现的功能提供了一个标准的API: 线程、文件、套接字、文件系统、进程等等。

然后是,core crate是std crate的一个子集,对程序运行的系统不做任何假设。因此它提供了基于语言的API,如浮点,字符串和切片,以及暴露处理器特性的API,如原子操作和SIMD指令。然而,它缺乏涉及堆内存分配和I/O的任何API。

对于一个应用程序来说,std所做的不仅仅是提供一种访问操作系统抽象的方式,std还负责涉及堆栈溢出保护,处理命令行参数,以及在程序的主函数被调用之前生成主线程。一个#![no_std]应用程序缺乏所有这些标准的运行时,所以它必须初始化自己的运行时,如果需要的话。

由于这些特性,#![no_std]应用程序可以是第一个或者唯一在系统上运行的代码。

Rust中no_std的一些使用方法

主要具体介绍第二种方式的使用no_std

具体如何使用,参见写一个no_std的库的第二种使用方式。

也可参考,实例:serde no-std的使用规范

验证一个库是否支持no_std的验证方式

cargo check --target wasm32-unknown-unknown

但是wasm环境不一定就是no_std,或者别的编译目标也可以,也就是裸露的编译目标环境不带有任何系统的环境。

参考文档: 使用Rust编写操作系统(一):独立式可执行程序

具体的写一个no_std的库

创建一个no_std库的第一种方式(使用#![no_std])

使用#![no_std]的话,默认的就是这个库是在no_std环境下的,然而又因为no_std下的库 一般来说都是核心库,而核心库又是标准库的子集,所以声明#![no_std]写出来的库,也可以在 std(标准库环境)下使用。

  1. 创建一个仓库
  2. 使用#![no_std]将这个仓库中的函数能支持在no_std和std下使用
  3. 开始添加一个函数编译报错commit 1
  4. 修复错误commit 2

创建no_std库的第二种方式(使用#![cfg_attr(not(features = "std"), no_std)] )

  1. 创建一个仓库
  2. 使用#![cfg_attr(not(feature = "std"), no_std)]
  3. 添加的一些函数和测试

使一些不能在no_std环境下运行的仓库也能在no_std下支持

首先,要验证这个库能不能支持no_std的环境(见,验证一个库是否支持no_std的验证方式)。

找出这个库依赖的库支持no_std的方式,如果使用的是#![no_std] 那么这个库本身就是可以在std和no_std下同时的运行。

如果使用的是#![cfg_attr(not(features = "std"), no_std)],就需要打开default-features = false,进行配置。

最后可能需要做一些标准库的替换,使其能在no_std和std同时编译成功,一些可以使用的类型库有sp-std(这个库仅仅封装了一部分的类型,例如有些类型是没有的,string,File, IO)当然,IO,File,这些标准库在核心库当中是没有的。还有rust本身的alloc, core这些都是属于核心库的。也是在no_std环境下支持的。

具体的使用案例:

相关的Pr,使ics23支持no_std

有些代码也在no_std写测试很难。因为这里做了编译选择处理

一些no_std和std可以使用的primitive 类型的仓库

引用及资源

结论

参照serder的使用以及一些论坛的讨论,推荐使用#![cfg_attr(not(feature = "std"), no_std ))]来同时支持std和no_std.

Rust 内存错误调试和动态分析工具分享

作者: 吴翱翔 / 后期编辑: 张汉东

原文: gdb/lldb 调试 segfault

相比静态分析工具例如 clippy/ra,动态分析工具则需要程序运行才能进行分析,例如官方的 bench, test

为什么需要动态分析

以上是某个 Rust 程序动态分析生成的火焰图,通过火焰图可以很清晰的看到程序的性能瓶颈在频繁 alloc 分配内存

借助动态分析不仅能发现程序性能瓶颈,还能调试运行时的内存错误,也可以生成函数调用树让团队新成员能快速读懂项目代码

目录 - 动态分析工具

  1. 常用调试工具和内存检测工具: 首先通过一个 segfault 案例,介绍常用调试工具如何检测该案例的内存错误:
  • coredumpctl
  • valgrind
  • gdb
  • lldb/vscode-lldb/Intellij-Rust
  1. 火焰图/函数调用树等动态分析工具(profile)
  • dmesg
  • cargo-miri
  • pref
  • cargo-flamegraph
  • KCachegrind
  • gprof
  • uftrace
  • ebpf
  1. 最后通过上述工具再分析几个内存错误案例:
  • SIGABRT/double-free
  • SIGABRT/free-dylib-mem

segfault 案例和常用调试工具

以下是我重写 ls 命令的部分源码(以下简称ls 应用),完整源码在这个代码仓库

fn main() {
    let dir = unsafe { libc::opendir(input_filename.as_ptr().cast()) };
    loop {
        let dir_entry = unsafe { libc::readdir(dir) };
        if dir_entry.is_null() {
            break;
        }
        // ...
    }
}

跟原版 ls 命令一样,输入参数是一个文件夹时能列出文件夹内所有文件名

但当 ls 应用的参数不是文件夹时,就会 segfault 内存段错误:

> cargo r --bin ls -- Cargo.toml 
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/ls Cargo.toml`
Segmentation fault (core dumped)

coredumpctl

systemd-coredump 配置

首先查看系统配置文件 /proc/config.gz 看看是否已开启 coredump 记录功能

> zcat /proc/config.gz | grep CONFIG_COREDUMP
CONFIG_COREDUMP=y

由于 /proc/config.gzgzip 二进制格式而非文本格式,所以要用 zcat 而非 cat 去打印

再看修改 coredumpctl 配置文件 /etc/systemd/coredump.conf

把默认的 coredump 日志大小限制调到 20G 以上: ExternalSizeMax=20G

然后重启: sudo systemctl restart systemd-coredump

查看最后一条 coredump 记录

通过 coredumpctl list 找到最后一条 coredump 记录,也就是刚刚发生的 segfault 错误记录

Tue 2021-07-06 11:20:43 CST 358976 1000 1001 SIGSEGV present /home/w/repos/my_repos/linux_commands_rewritten_in_rust/target/debug/ls 30.6K

注意用户 id 1000 前面的 358976 表示进程的 PID,用作 coredumpctl info 查询

coredumpctl info 358976

           PID: 358976 (segfault_opendi)
// ...
  Command Line: ./target/debug/ls
// ...
       Storage: /var/lib/systemd/coredump/core.segfault_opendi.1000.d464328302f146f99ed984edc6503ca0.358976.1625541643000000.zst (present)
// ...

也可以选择用 gdb 解析 segfault 的 coredump 文件:

coredumpctl gdb 358976 或者 coredumpctl debug 358976

参考: core dump - wiki

valgrind 检查内存错误

valgrind --leak-check=full ./target/debug/ls

// ...
==356638== Process terminating with default action of signal 11 (SIGSEGV): dumping core
==356638==  Access not within mapped region at address 0x4
==356638==    at 0x497D904: readdir (in /usr/lib/libc-2.33.so)
==356638==    by 0x11B64D: ls::main (ls.rs:15)
// ...

gdb 调试分析错误原因

§ gdb 打开 ls 应用的可执行文件:

gdb ./target/debug/ls

§ gdb 通过 llist 命令打印可执行文件的代码:

(gdb) l

§ gdb运行 ls 应用且传入 Cargo.toml 文件名作为入参:

(gdb) run Cargo.toml

Starting program: /home/w/repos/my_repos/linux_commands_rewritten_in_rust/target/debug/ls Cargo.toml
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".

Program received signal SIGSEGV, Segmentation fault.
0x00007ffff7e5a904 in readdir64 () from /usr/lib/libc.so.6

§ 查看 segfault 发生时的栈帧

(gdb) backtrace

#0  0x00007ffff7e5a904 in readdir64 () from /usr/lib/libc.so.6
#1  0x0000555555568952 in ls::main () at src/bin/ls.rs:15

此时已经找到出问题的系统调用函数是 readdir64,上一个栈帧在 ls.rs 的 15 行

$ 查看问题代码的附近几行

(gdb) list 15

§ 查看问题栈帧的局部变量

  • info variables 能打印全局或 static 变量
  • info locals 打印当前栈帧的局部变量
  • info args 打印当前栈帧的入参

(gdb) frame 1 # select frame 1

(gdb) info locals

(gdb) frame 1
#1  0x0000555555569317 in ls::main () at src/bin/ls.rs:20
20              let dir_entry = unsafe { libc::readdir(dir) };
(gdb) info locals
dir = 0x0
// ...

此时发现 main 栈帧的 dir = 0x0 是空指针,导致 readdir 系统调用 segfault

分析错误原因


#![allow(unused)]
fn main() {
let dir = unsafe { libc::opendir(input_filename.as_ptr().cast()) };
loop {
    let dir_entry = unsafe { libc::readdir(dir) };
    // ...
}
}

问题出在没有判断 opendir 系统调用是否成功,系统调用失败要么返回 NULL 要么返回 -1

如果 opendir 系统调用传入的文件类型不是 directory,就会调用失败

因此 Bug 解决方法是 检查上游的 opendir 创建的 dir 变量是否为 NULL

解决 segfault

只需要在加上 dir 是否为 NULL 的代码,如果为 NULL 则打印系统调用的错误信息


#![allow(unused)]
fn main() {
if dir.is_null() {
    unsafe { libc::perror(input_filename.as_ptr().cast()); }
    return;
}
}

再次测试 ls 应用读取非文件夹类型的文件

> cargo r --bin ls -- Cargo.toml 
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `target/debug/ls Cargo.toml`
Cargo.toml: Not a directory

此时程序没有发生段错误,并且打印了错误信息 Cargo.toml: Not a directory

关于修复 ls 应用 segfault 的代码改动在这个 commit

参考 gnu.org 的官方教程: https://www.gnu.org/software/gcc/bugs/segfault.html

lldb 调试

lldb 调试和 gdb 几乎一样,只是个别命令不同

(lldb) thread backtrace # gdb is backtrace

error: need to add support for DW_TAG_base_type '()' encoded with DW_ATE = 0x7, bit_size = 0
* thread #1, name = 'ls', stop reason = signal SIGSEGV: invalid address (fault address: 0x4)
  * frame #0: 0x00007ffff7e5a904 libc.so.6`readdir + 52
    frame #1: 0x0000555555568952 ls`ls::main::h5885f3e1b9feb06f at ls.rs:15:34
// ...

(lldb) frame select 1 # gdb is frame 1

frame #1: 0x0000555555569317 ls`ls::main::h5885f3e1b9feb06f at ls.rs:15:34
   12       
   13       let dir = unsafe { libc::opendir(input_filename.as_ptr().cast()) };
   14       loop {
-> 15           let dir_entry = unsafe { libc::readdir(dir) };
   16           if dir_entry.is_null() {
   17               // directory_entries iterator end
   18               break;

§ lldb 变量打印上 frame variable 等于 gdb 的 info args 加上 info locals

(gdb) info args 等于 (lldb) frame variable --no-args

除了 primitive types, lldb 还可以打印 String 类型变量的值,但是无法得知 Vec<String> 类型变量的值

vscode-lldb 调试

不打任何断点运行程序时,会指向以下代码

7FFFF7E5A904: 0F B1 57 04 cmpxchgl %edx, 0x4(%rdi)

此时应当关注 vscode 左侧 Debug 侧边栏的 CALL STACK 菜单 (也就 gdb backtrace)

call stack 菜单会告诉 readdir 当前汇编代码的上一帧(也就是 backtrace 第二个栈帧)是 main 函数的 15 行

点击 main 栈帧,相当于 (gdb) frame 1,就能跳转到出问题的源码所在行了

在 main 栈帧 下再通过 variable 菜单发现 readdir 传入的 dir 变量值为 NULL 导致段错误

Intellij-Rust 调试

Debug 运行直接能跳转到问题代码的所在行,并提示 libc::readdir(dir) 的 dir 变量的值为 NULL


动态分析工具

dmesg 查看 segfault 记录

sudo dmesg 能查看最近几十条内核消息,发生 segfault 后能看到这样的消息:

[73815.701427] ls[165042]: segfault at 4 ip 00007fafe9bb5904 sp 00007ffd78ff8510 error 6 in libc-2.33.so[7fafe9b14000+14b000]

cargo-miri 检查 unsafe 代码

可惜 miri 现在似乎还不支持 FFI 调用函数的检查

[w@ww linux_commands_rewritten_in_rust]$ cargo miri run --example sigabrt_free_dylib_data
   Compiling linux_commands_rewritten_in_rust v0.1.0 (/home/w/repos/my_repos/linux_commands_rewritten_in_rust)
    Finished dev [unoptimized + debuginfo] target(s) in 0.00s
     Running `/home/w/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/bin/cargo-miri target/miri/x86_64-unknown-linux-gnu/debug/examples/sigabrt_free_dylib_data`
error: unsupported operation: can't call foreign function: sqlite3_libversion
 --> examples/sigabrt_free_dylib_data.rs:5:19
  |
5 |         let ptr = sqlite3_libversion() as *mut i8;
  |                   ^^^^^^^^^^^^^^^^^^^^ can't call foreign function: sqlite3_libversion

perf 函数调用树

检查 perf 配置

首先通过 perf record 测试下 perf 的配置能否读取系统事件,如果返回 Error 则修改以下配置文件

sudo vim /etc/sysctl.d/sysctl.conf

在 sysctl.conf 配置文件下加上一行:

kernel.perf_event_paranoid = -1

然后重启 sysctl 进程重新加载配置:

sudo systemctl restart systemd-sysctl.service

perf call-graph

用 perf-record 记录 Rust 程序的调用信息:

perf record -a --call-graph dwarf ./target/debug/tree

Rust 程序运行结束后,会在当前目录生成 perf.data 数据文件

perf-report 会默认打开当前目录的 perf.data 文件,也可以通过 -i 参数制定数据文件

用 perf-report 解析 Rust 程序的函数调用树,会进入一个用 curses 写的类似 htop 的命令行 UI 界面:

perf report --call-graph

可以选中 tree::main 的函数符号按下回车,选择 zoom into tree thread 来展示 main 函数的子函数调用树

主要浏览方法是通过上下左右方向键移动光标,再通过**+**按键展开或折叠光标所在行的函数调用树

在作者的电脑上,Clion 默认的 profiler(性能探测器)就用的 perf

cargo-flamegraph

cargo-flamegraph 需要系统已装 perf ,能将 perf 数据渲染成火焰图

KCachegrind

valgrind --tool=callgrind ./target/debug/tree

通过 valgrind 生成 callgrind.out.887505 数据( 887505 是 PID ),再通过 KCachegrind 打开进行可视化

参考: https://users.rust-lang.org/t/is-it-possible-to-print-the-callgraph-of-a-cargo-workspace/50369/6

gprof

gcc/clang 加上 -pg 参数,会在运行程序结束后生成监控数据文件 mon.out

然后 gprof 对 mon.out 文件进行分析,可惜 Rust 没有部分支持

uftrace

为了支持数据通过火焰图格式可视化,安装 utftrace 的同时也把火焰图装了:

yay -S uftrace-git flamegraph-git

跟 grpof/KCachegrind 类似,也是要收集数据,数据可视化分两步走

首先 Rust 编译程序时要加上类似 gcc 的 -pg 的参数:

rustc -g -Z instrument-mcount main.rs

或者用 gccrs 或 gcc 后端进行编译

gccrs -g -pg main.rs

然后 uftrace 开始记录数据:

uftrace record ./main

本文篇幅有限只介绍 uftrace 通过火焰图进行可视化的方式:

uftrace dump --flame-graph | flamegraph > ~/temp/uftrace_flamegraph.svg && google-chrome-stable ~/temp/uftrace_flamegraph.svg

uftrace 记录参数:

  • --no-libcall: uftrace 可以加上 --no-libcall 参数不记录系统调用
  • --nest-libcall: 例如 new() 函数记录上内置的 malloc()
  • --kernel(need sudo): trace kernel function
  • --no-event: 不记录线程调度

ebpf

ebpf 分析 Rust 程序应该是可行的,作者还没试过


熟悉上述工具后,可以接下来看几个内存错误的案例

SIGABRT/double-free 案例分享

以下是 tree 命令深度优先搜索遍历文件夹的代码(省略部分无关代码,完整源码链接在这)


#![allow(unused)]
fn main() {
unsafe fn traverse_dir_dfs(dirp: *mut libc::DIR, indent: usize) {
    loop {
        let dir_entry = libc::readdir(dirp);
        if dir_entry.is_null() {
            let _sigabrt_line = std::env::current_dir().unwrap();
            return;
        }
        // ...
        if is_dir {
            let dirp_inner_dir = libc::opendir(filename_cstr);
            libc::chdir(filename_cstr);
            traverse_dir_dfs(dirp_inner_dir, indent + 4);
            libc::chdir("..\0".as_ptr().cast());
            libc::closedir(dirp);
        }
    }
}
}

这段代码运行时会报错:

malloc(): unsorted double linked list corrupted

Process finished with exit code 134 (interrupted by signal 6: SIGABRT)

通过 gdb 调试能知道 std::env::current_dir() 调用报错了,但错误原因未知

经验: SIGABRT 可能原因

通过上述段错误的分析,我们知道 SIGSEGV 可能的原因是例如 readdir(NULL) 解引用空指针

根据作者开发经验,SIGABRT 的可能原因是 double free

valgrind 检查 double free

顺着 double-free 的思路,通过 valgrind 内存检查发现,libc::closedir(dirp) 出现 InvalidFree/DoubleFree 的内存问题

分析 double free 原因

再细看源码,递归调用前创建的是子文件夹的指针,递归回溯时却把当前文件夹指针给 close 掉了

这就意味着,一旦某个目录有 2 个以上的子文件夹,那么当前的文件夹指针可能会被 free 两次

进而将问题的规模简化成成以下三行代码:


#![allow(unused)]
fn main() {
let dirp = libc::opendir("/home\0".as_ptr().cast());
libc::closedir(dirp);
libc::closedir(dirp);
}

double free 的通用解决方法

C 语言编程习惯: free 某个指针后必须把指针设为 NULL


#![allow(unused)]
fn main() {
let mut dirp = libc::opendir("/home\0".as_ptr().cast());
libc::closedir(dirp);
dirp = std::ptr::null_mut();
libc::closedir(dirp);
dirp = std::ptr::null_mut();
}

在「单线程应用」中,这种解决方法是可行的,

第一次 free 后 dirp 指针被成 NULL,第二次 free 时传入 dirp 则什么事都不会发生

因为大部分的 C/Java 函数第一行都会判断输入是否空指针 if (ptr == null) return

为什么有时 double free 没报错

有个问题困惑了我:

  • 为什么连续写几行 closedir 进程会提前 SIGABRT?
  • 为什么循环中多次 closedir 进程还能正常退出?
  • 为什么循环中多次 closedir 调用 std::env::current_dir() 时就 SIGABRT?

原因是 double free 不一定能及时被发现,可能立即让进程异常中止,也可能要等下次 malloc 时才会报错

正因为 current_dir() 用了 Vec 申请堆内存,内存分配器发现进程内存已经 corrupted 掉了所以中止进程

我摘抄了一些系统编程书籍对这一现象的解释:

one reason malloc failed is the memory structures have been corrupted, When this happens, the program may not terminate immediately

感兴趣的读者可以看这本书: Beginning Linux Programming 4th edition 的 260 页

SIGABRT/free-dylib-mem 案例分享

假设我想打印 sqlite 的版本,sqlite 版本信息以静态字符串的形式存储在 /usr/lib/libsqlite3.so

#[link(name = "sqlite3")]
extern "C" {
    pub fn sqlite3_libversion() -> *const libc::c_char;
}

fn main() {
    unsafe {
        let ptr = sqlite3_libversion() as *mut i8;
        let version = String::from_raw_parts(ptr.cast(), "3.23.0\0".len(), "3.23.0\0".len());
        println!("found sqlite3 version={}", version);
    }
}

结果上述代码一运行就发生内存错误 SIGABRT 异常中止,通过 gdb 调试发现报错前一个栈帧在 unsafe 代码块的析构流程内

由于代码中只有 version 是 String 类型需要调用 drop 自动析构,所以就把问题锁定在 String 的析构错误上

根据操作系统进程内存管理相关知识,当 Rust 的进程想要释放不属于 Rust 进程而是属于 libsqlite3.so 动态链接库的内存时就会 SIGABRT

解决方法是通过 std::mem::forget 阻止 String 的析构函数调用,这也是 mem::forget API 最常用的应用场景

更多的内存错误调试案例

可以关注作者的 linux_commands_rewritten_in_rust 项目的 src/examples 文件夹

examples 目录下几乎都是各种内存错误的常见例子,也是作者踩过坑的各种内存 Bug

项目链接: <https://github.com/pymongo/linux_commands_rewritten_in_rust/>

使用Macroquad在Android设备上发布游戏

Macroquad_game_1

译者注: macroquad是一个简单易用的Rust游戏库。macroquad尝试避免任何Rust特定概念比如生命周期/借用,这使得它对初学者非常友好。

详见https://docs.rs/macroquad/0.3.7/macroquad/index.html

1. 简介

这篇教程基于在Google Play商店发布的 Zemeroth游戏开发发布过程。目前这款游戏已经公开测试,点击这里可以试玩!

本篇主题包括:

  • 构建一个适用于android设备的macroquad游戏
  • 常见问题和调试技巧
  • 构建并上传APK包到Google Play商店

2.搭建开发环境

使用docker配置环境并构建APK包

在已经拉取所有NDK(Native Development Kit原生开发工具包)依赖的docker宿主机上构建开发环境非常简单,

docker pull not-fl3/cargo-apk

推荐大家使用docker来构建适用于android设备的macroquad游戏。

基于docker构建APK包

只需一条命令即可完成构建APK包

docker run 
  --rm 
  -v $(pwd):/root/src 
  -w /root/src 
  notfl3/cargo-apk cargo quad-apk build --release

上面的命令会生成APK包到 target/android-artifacts/release/apk

这一步骤可能会耗费较长时间,因为每次docker调用都会完整构建三个android目标文件。

docker命令中加入-v /tmp/registry\":/usr/local/cargo/registry\"会使得构建过程稍微快一些。这样做会使docker将宿主机的 /tmp/registry目录作为缓存目录注册到cargo,然后docker就无需在每次构建时都重新下载依赖。

另一种方法:运行docker交互模式,在同一个容器中完成多次构建。

docker run 
  --rm 
  -v $(pwd):/root/src 
  -w /root/src 
  -it notfl3/cargo-apk /bin/bash

最后,在docker的shell中运行cargo quad-apk build --release

手工配置环境并构建APK包

docker简化了安装android-sdk和android-ndk的过程。不过,有些时候使用原生构建流程可能会更方便。这里我们不太不推荐这样的构建方式,不过我们可以通过这个构建流程来阐述容器中是如何完成这个过程的。

环境配置

命令及路径写法取决于宿主机操作系统,我们这里使用的linux,其他操作系统下也是相同思路。

  • 安装JRE或者JDK

    这一步骤不同的操作系统差别会很大,这里以ubuntu为例:

    sudo apt-get install openjdk-8-jdk
    
  • 安装Rust android

    利用rustup安装android相关库:

    rustup target add armv7-linux-androideabi
    rustup target add aarch64-linux-android
    rustup target add i686-linux-android
    rustup target add x86_64-linux-android
    
  • 安装Android SDK

    # 创建目录存放所有前置安装内容
    mkdir /this/may/be/any/path/android
    
    cd android
    wget -q https://dl.google.com/android/repository/sdk-tools-linux-4333796.zip
    unzip -q sdk-tools-linux-4333796.zip
    rm sdk-tools-linux-4333796.zip
    tools/bind/sdkmanager "platform-tools"
    tools/bin/sdkmanager "platforms;android-29"
    tools/bin/sdkmanager "build-tools;29.0.0"
    tools/bin/sdkmanager --update
    
  • 安装Android NDK

    # 到前一步android-sdk的目录
    cd /path/from/previous/step/android
    
    wget -q http://dl.google.com/android/repository/android-ndk-r20-linux-x86_64.zip
    unzip -q android-ndk-r20-linux-x86_64.zip
    rm android-ndk-r20-linux-x86_64.zip
    
  • 安装Cargo APK

    安装cargo-quad-apkcargo扩展。

    cargo install cargo-quad-apk
    

手工构建APK包

export ANDROID_HOME=/path/from/previous/step/android
export NDK_HOME=/path/from/previous/step/android/android-ndk-r20

# 构建debug版本
cargo quad-apk build
# 构建release版本
cargo quad-apk build --release

构建完成的apk文件在 target/android-artifacts/debug/apktarget/android-artifacts/release/apk下。

3. 调试android游戏

资源目录

假设目录结构如下:

.
├── assets
├── └── nice_texture.png
├── src
├── └── main.rs
└── Cargo.toml

加入如下内容到你的Cargo.toml以引入资源目录到APK:

[package.metadata.android]
assets = "assets/"

之后通过load_texture("nice_texture.png")来加载纹理贴图。

不过,在PC中通常使用load_texture("assets/nice_texture.png"),需包含assets到路径中。

为修复这个问题使得android和PC中使用的路径统一,我们需要借助 set_pc_assets_folder来解决。

macroquad::file::set_pc_assets_folder("assets");

这样我们就可以在不同的平台使用统一的load_texture("nice_texture.png")来加载纹理贴图了。

高DPI适配

Macroquad_game_2

图片来源

不同android设备具有明显的像素密度差异。默认情况下,android操作系统会尝试用高像素密度来模拟低像素密度显示效果。

这意味着screen_width()/screen_height()得到的值低于实际的屏幕像素分辨率,不过之后android操作系统会自动缩放viewport来适配设备屏幕。

这样也许没问题,因为更小的viewport能够获得更高的FPS,如果我们不想让android操作系统自动缩放的话,我们需要告知android操作系统以支持高DPI屏幕。

fn window_conf() -> window::Conf {
    window::Conf {
        window_title: "Zemeroth".to_owned(),
        high_dpi: true,
        ..Default::default()
    }
}

#[macroquad::main(window_conf)]
async fn main() {
}

屏幕方向

默认情况下,Macroquad游戏是全屏幕的并且允许任何屏幕方向,我们可以在Cargo.toml文件中加入以下内容来限制支持的屏幕方向:

[package.metadata.android.activity_attributes]
"android:screenOrientation" = "userLandscape"

图标

icon

应用图标在APK内被视为一种特殊资源,我们将res目录加入APK:

[package.metadata.android]
res = "android_res"
icon = "@mipmap/ic_launcher"

不同DPI的图标目录类似下面这样:

android_res/
├── mipmap-hdpi
│   └── ic_launcher.png
├── mipmap-mdpi
│   └── ic_launcher.png
├── mipmap-xhdpi
│   └── ic_launcher.png
├── mipmap-xxhdpi
│   └── ic_launcher.png
└── mipmap-xxxhdpi
    └── ic_launcher.png

不同尺寸的图标需要手工生成。这里提供了一种较为好用的方法。

日志调试

macroquad的所有warn!/info!/debug! 消息都会合并到android系统消息。我们可以通过 adb logcat 命令来访问,下面是几种过滤 adb logcat 的方法。

通过tag来过滤 adb logcat

tag过滤针对的是macroquad产生的 warn!/info!/debug!消息。

adb logcat -v brief SAPP:V "*:S"

-v brief 会通过隐藏一些log元数据来优化显示结果;

之后的参数是过滤器参数;

SAPP:V - V(verbose)过滤器会应用到tag为SAPP的消息,这些消息都将被输出。*:S - S(silent)过滤器会应用到所有其他tag,这些消息都将被过滤掉。

通过PID过滤

一些应用在运行时会产生额外的系统消息,可能是系统警告或者未处理的原生库异常。但是这些消息在应用tag过滤时都将被过滤掉。

PID是进程ID,使用PID过滤可以获得包括任何tag在内的所有应用输出消息。

# Find out PID
> adb shell pidof -s rust.CRATENAME
30243
# Get all the messages from a given PID
>adb shell pidof -s 30243

以上的命令可以通过命令参数的形式简写如下:

adb logcat --pid=$(adb shell pidof -s rust.CRATENAME)

4. APK签名

默认情况下,cargo quad-apk生成调试kestore文件并使用调试key来签名APK。这样的APK包可以在本地安装测试,但是不能上传至Google Play应用商店。

Google Play应用商店要求非调试kestore文件,这个文件将被上传到Play Console来验证开发者身份。

我们需要用keytool 来生成key,keytool 包含在openjdk中。然后使用 apksigner给APK签名, apksigner内置在Android SDK中。

keytool -v -genkey -keystore mygame.keystore -alias mygame -keyalg RSA -validity 10000

现在所有的前置环节都已完成,这一步生成的key可以用于构建release包。

首先,我们使用参数通知cargo-apk停止使用调试keystore文件来签名APK,

cargo quad-apk build --release --nosign

然后再使用非调试keystore文件来签名APK。

apksigner sign --ks mygame.keystore my-app.apk --ks-key-alias alias_name

最后使用下面的命令进行验证:

apksigner verify my-app.apk

点此查看APK签名官方文档

提示:如何在docker中获取keytool/apksigner

假设android keystore文件在~/.android目录,并且待签名的.apk文件在当前工作目录:

docker run --rm 
    -v (pwd):/root/src  
    -v(/home/USER/.android):/root/.android_secrets 
    -w /root/src -it notfl3/cargo-apk /bin/bash

这条命令会启动docker容器,并挂载当前工作目录到容器中/root/src ,挂载.android到容器中/root/.android_secrets ,然后在容器中使用下面的命令来签名APK:

apksigner sign --ks my.keystore my-app.apk --ks-key-alias alias_name

Android目标设备

默认情况下,cargo quad-apk 会构建适用于三个不同平台的APK包。为满足Google Play应用商店要求,我们需要添加所有平台如下:

[package.metadata.android]
..
build_targets = [ "armv7-linux-androideabi", "aarch64-linux-android", "i686-linux-android", "x86_64-linux-android" ]

在调试期间为加快构建过程我们可以选择我们的测试设备在用的其中任一个平台。

[package.metadata.android]
..
build_targets = [ "armv7-linux-androideabi" ]

Google Play版本管理

Google Play应用商店对上传的APK包有它自己的版本管理机制,每个APK都必须有独一无二的version_code。否则google开发者控制台会报错:Version code 1 has already been used. Try another version code.

在Cargo.toml中设置app版本号version_code

[package.metadata.android]
..
version_code = 2
version_name = "Version Name"

点此查看APK版本管理官方文档

商店APP预览页面

为了提交游戏到Google Play应用商店用于审核及开放测试、正式发布,我们需要上传用于APP展示页面的游戏截图、描述信息等内容。APP上传过程中很多数据都被标记为必填(*),这里我们至少需要上传下面几种游戏图像:

  • 512x512 图标
  • 1024x500 banner图
  • 两个16:9截图

关于预览页面的其他信息

内部测试、开放测试和正式发布

内部测试、封闭测试和开放测试区别是什么?你可以在发布正式产品之前发布各种测试版本用于测试验证。

**内部测试:**创建内部测试版本以快速分发至最多100测试用户,进行早期质量保证测试。

不需要搭建商店页面也不需要应用审核。用于帮助开发者确保签名过程有效、应用成功上传至商店。也允许通过邮件方式添加测试用户。

**封闭测试:**分发预发布版本至更大范围的测试用户用于收集更多反馈意见。

接近于开放测试,但是要求添加测试用户邮箱。

**开放测试:**创建开放测试版本可以将你的APP展示到Google Play应用商店进行大规模测试。这种情况下,任何人都可以加入你的测试并提交自己的反馈意见。

接近于正式发布状态,商店页面公开可用,测试用户无需其他额外步骤即可安装使用。

关于测试发布流程的其他信息

跨空间跳板内核

作者:洛佳


现代内核设计中,常运用地址空间来隔离内核与应用。在分页内存管理下这样的方法较为简便;但也有利用此类设计的安全漏洞出现。本文尝试将完整的地址空间交还给应用,空间中不再保留内核的部分,而由“跳板页”机制切换到内核,我们希望借此解决传统内核的一部分安全问题。

在前面的文章中,我们介绍了一种简单的生成器内核,它使用了较新的生成器语法,便于编写。现代的系统内核通常基于地址空间隔离不同的应用、应用与内核,本文中我们使用Rust语言编写内核,尝试将它的生成器语法与全隔离内核相结合,提出跨空间跳板内核的解决方案,以为完整的异步内核实现提供参考。

1 全隔离内核

传统内核的地址空间有时分为上下两部分:下部分由各个应用轮流占有,而上部分保留于内核使用。这种设计在运行用户程序时,限制用户访问上半部分内存,来避免内核数据本身受到破坏。这部分数据仍然保存在地址空间中,只是通过权限设置,让攻击者无法直接访问。

攻击者确实无法直接访问,于是侧信道攻击出现了。

访问这部分地址的数据,即使访问失败,它也被用于计算其它访问目标的地址,这个目标将进入处理核的高速缓存中。于是攻击者通过时间差,探测其它访问目标的访问时间,计算出最快的访问地址,从而倒推出禁止访问地址的数据值。这类攻击原理中最出名的是Meltdown攻击,它可以以数十千字节每秒的速度套出内核的机密信息。

我们可以采用一种比较新的地址空间设计,rCore-Tutorial内核就采用了类似的设计。在这种设计中,所有的地址全部交由应用使用,内核本身不保留地址。这种设计将无法访问的内核数据挡在地址空间切换之后,而不是留在高地址区域。因为它除了少量需的跳板页,完全不与内核本身共享内存空间,我们可以称之为“全隔离内核”。

全隔离和传统内核的地址空间布局

全隔离内核的用户空间中并非仍然存在不可访问的内核数据,而是完全挡在地址空间之外。除此之外,它为应用提供更多的地址位置,允许运行更大的应用程序,或加载更多的动态链接库,以便于提高用户程序设计的灵活性。

注意的是我们通过全隔离机制,可以减少通过其它通道获得内核数据的途径,并不能防止此类攻击命中用户程序的其它部分。针对此类攻击,重新设计处理核的电路仍然是最彻底的防御方法。

2 跳板代码页和跳板数据页

全隔离空间没有和内核本身交集的部分,会出现地址切换“尴尬的代码”问题。我们可以使用跳板页的思想来解决问题。

跳板页是内核和用户空间中保留的少量共享部分。在地址空间切换完成后,程序指针的值没有变化,在上一空间这个指针指着有效的代码,但下一个空间中,该地址就并非是有效的代码了。跳板页的思想是,在不同的地址空间中保留仅有地址相同的有效部分,它们能保证在切换完成后短暂的步骤内,处理核仍然能运行有效的代码。

跳板代码页设计

这是跳板代码页的设计思路。切换完成后,应当有一部分的代码完成上下文的加载过程。上下文应该加载到哪儿呢?由于地址空间已经切换,全隔离内核中无法访问内核数据段的内容,因此我们专门设计“跳板数据页”,这是映射到用户空间的一个部分,用于保存当前用户的上下文。

进入用户态时,上下文在切换空间后恢复。为什么不能在之前恢复呢?是因为如果这样做,那么在系统调用、中断等情形需要陷入内核时,需要保存上下文,这些上下文包括内核的地址空间配置,此时就没有地方得知内核的地址空间如何设置了。所以上下文恢复应当在跳板页中用户空间执行的部分。因为每个用户程序需要一个上下文,因此每个处理核都应当有一个跳板数据页,而跳板代码页可以共享同一个。

我们注意到,地址空间切换完成后,特权级的切换并未立即完成。进入新的地址空间后,跳板页的剩余部分将完成特权级的切换流程。因此,跳板页在所有的地址空间下,无论是内核还是用户的空间,都应只有内核特权级可见。跳板代码页和跳板数据页都应当遵守这个规则。

3 帧翻译算法

我们的代码能够在程序间切换了。除了切换,它仍然需要使用操作系统的功能,需要提供部分数据给操作系统使用。在传统内核中,直接设置“以用户身份访问”位,即可直接通过当前地址空间访问用户。然而全隔离内核要求用户和系统的数据隔离,就需要额外的方法。

这里我们选择恢复到传统中模拟页表查询的流程。

不同于简单的页表查询,我们的代码将根据需要查询的缓冲区长度,增加虚拟页号的数值,访问多个页时,多次地查询页表。这样就能连续查询内核需要的所有用户数据了。

我们在分页空间的代码中加入下面的部分。


#![allow(unused)]
fn main() {
// impl<M: PageMode, A: FrameAllocator + Clone> PagedAddrSpace<M, A> 中的实现    
/// 根据虚拟页号查询物理页号,可能出错。
pub fn find_ppn(&self, vpn: VirtPageNum) -> Result<(&M::Entry, PageLevel), PageError> {
    let mut ppn = self.root_frame.phys_page_num();
    for &lvl in M::visit_levels_until(PageLevel::leaf_level()) {
        // 注意: 要求内核对页表空间有恒等映射,可以直接解释物理地址
        let page_table = unsafe { unref_ppn_mut::<M>(ppn) };
        let vidx = M::vpn_index(vpn, lvl);
        match M::slot_try_get_entry(&mut page_table[vidx]) {
            Ok(entry) => if M::entry_is_leaf_page(entry) {
                return Ok((entry, lvl))
            } else {
                ppn = M::entry_get_ppn(entry)
            },
            Err(_slot) => return Err(PageError::InvalidEntry)
        }
    }
    Err(PageError::NotLeafInLowerestPage)
}
}

为了简化设计,我们假设内核具有恒等映射,可以直接通过虚拟地址访问物理地址。于是查找单个物理页号的过程完成了。

然后,我们可以编写完整的帧翻译流程。


#![allow(unused)]
fn main() {
// 帧翻译:在空间1中访问空间2的帧。本次的实现要求空间1具有恒等映射特性
pub fn translate_frame_read</*M1, A1, */M2, A2, F>(
    // as1: &PagedAddrSpace<M1, A1>, 
    as2: &PagedAddrSpace<M2, A2>, 
    vaddr2: VirtAddr, 
    len_bytes2: usize, 
    f: F
) -> Result<(), PageError>
where 
    // M1: PageMode, 
    // A1: FrameAllocator + Clone,
    M2: PageMode, 
    A2: FrameAllocator + Clone,
    F: Fn(PhysPageNum, usize, usize) // 按顺序返回空间1中的帧
{
    let mut vpn2 = vaddr2.page_number::<M2>();
    let mut remaining_len = len_bytes2;
    let (mut entry, mut lvl) = as2.find_ppn(vpn2)?;
    let mut cur_offset = vaddr2.page_offset::<M2>(lvl);
    while remaining_len > 0 {
        let ppn = M2::entry_get_ppn(entry);
        let cur_frame_layout = M2::get_layout_for_level(lvl);
        let cur_len = if remaining_len <= cur_frame_layout.page_size::<M2>() {
            remaining_len
        } else {
            cur_frame_layout.page_size::<M2>()
        };
        f(ppn, cur_offset, cur_len);
        remaining_len -= cur_len;
        if remaining_len == 0 {
            return Ok(())
        }
        cur_offset = 0; // 下一个帧从头开始
        vpn2 = vpn2.next_page::<M2>(lvl);
        (entry, lvl) = as2.find_ppn(vpn2)?;
    }
    Ok(())
}
}

如果内核不是通过恒等或线性映射布局的,可以维护一个反查询表,需要一个方法让内核直接访问物理空间。在物理空间大于虚拟空间时,这个做法还是有必要实现的。

帧翻译过程完成后,我们可以在空间1中访问空间2的帧了。我们来使用上刚写完的函数,来实现最简单的控制台输出系统调用。


#![allow(unused)]
fn main() {
// 核心部分代码。参数:let [fd, buf, len] = args;
let buf_vaddr = mm::VirtAddr(buf);
mm::translate_frame_read(user_as, buf_vaddr, len, |ppn, cur_offset, cur_len| {
    let buf_frame_kernel_vaddr = ppn.addr_begin::<M>().0 + cur_offset; // 只有恒等映射的内核有效
    let slice = unsafe { core::slice::from_raw_parts(buf_frame_kernel_vaddr as *const u8, cur_len) };
    for &byte in slice {
        crate::sbi::console_putchar(byte as usize);
    }
}).expect("read user buffer");
SyscallOperation::Return(SyscallResult { code: 0, extra: len as usize })
}

用户使用系统调用时,提供了若干个变量。当用户传入缓冲区地址和它的长度,帧翻译函数将查询缓冲区占用的所有物理帧,然后内核访问物理帧,来获得它们的内容。内容按块读出,每块包括物理页号、页内的起始偏移地址和剩余长度。最终,本次系统调用将解释每一块内容,并打印到控制台中。

需要注意的是,本次的程序实现只能一块一块地读取数据。如果需要验证跨块的数据合法性,比如需要验证UTF-8字符串是否合法,要么使用方法映射到连续的虚拟地址上再运行,要么需要复制字符串后再运行,否则跨块的合法性验证将可能不正确。

测试程序,我们编写用户程序如下,直接编译,发现输出是对的。

fn main() {
    println!("Hello, world!");
}

跨空间切换内核启动

事实上,如果将打印的字符串换为超过一帧的长度,也是可以成功打印的。有了跨地址空间访问内存的方法,其它的系统调用也可以开始实现了。

4 跨空间生成执行器

根据上文的分析,每次恢复到用户,先保存执行器上下文,然后切换空间,然后加载用户上下文。每次从用户陷入内核,执行相反的过程即可。

在RISC-V下,编写如下的汇编代码。


#![allow(unused)]
fn main() {
#[naked]
#[link_section = ".trampoline"] 
unsafe extern "C" fn trampoline_resume(_ctx: *mut ResumeContext, _user_satp: usize) {
    asm!(
        // a0 = 生成器上下文, a1 = 用户的地址空间配置, sp = 内核栈
        "addi   sp, sp, -15*8",
        "sd     ra, 0*8(sp)
        sd      gp, 1*8(sp)
        ...... 依次保存tp, s10等寄存器 ......
        sd      s11, 14*8(sp)", // 保存子函数寄存器,到内核栈
        "csrrw  a1, satp, a1", // 写用户的地址空间配置到satp,读内核的satp到a1
        "sfence.vma", // 立即切换地址空间
        // a0 = 生成器上下文, a1 = 内核的地址空间配置, sp = 内核栈
        "sd     sp, 33*8(a0)", // 保存内核栈位置
        "mv     sp, a0", 
        // a1 = 内核的地址空间配置, sp = 生成器上下文
        "sd     a1, 34*8(sp)", // 保存内核的地址空间配置
        "ld     t0, 31*8(sp)
        ld      t1, 32*8(sp)
        csrw    sstatus, t0
        csrw    sepc, t1
        ld      ra, 0*8(sp)
        ld      gp, 2*8(sp)
        ...... 依次加载tp, t0等寄存器 ......
        ld      t5, 29*8(sp)
        ld      t6, 30*8(sp)", // 加载生成器上下文寄存器,除了a0
        // sp = 生成器上下文
        "csrw   sscratch, sp",
        "ld     sp, 1*8(sp)", // 加载用户栈
        // sp = 用户栈, sscratch = 生成器上下文
        "sret", // set priv, j sepc
        options(noreturn)
    )
}
}

它被链接到专门的跳板代码页中。为了避免和用户程序冲突,跳板代码页被放置在最高的位置上,比如0xffffffffffff000。根据跳板页的长度,我们可以计算它需要多少个页,然后在初始化代码中映射它们。

在后续的代码中,跳板代码页的权限被设置为仅可执行。跳板代码页应当只有内核特权层能访问,否则将可被需要拼接指令的攻击方法利用,或者产生一些逻辑错误。


#![allow(unused)]
fn main() {
fn get_trampoline_text_paging_config<M: mm::PageMode>() -> (mm::VirtPageNum, mm::PhysPageNum, usize) {
    let (trampoline_pa_start, trampoline_pa_end) = {
        extern "C" { fn strampoline(); fn etrampoline(); }
        (strampoline as usize, etrampoline as usize)
    };
    assert_ne!(trampoline_pa_start, trampoline_pa_end, "trampoline code not declared");
    let trampoline_len = trampoline_pa_end - trampoline_pa_start;
    let trampoline_va_start = usize::MAX - trampoline_len + 1;
    let vpn = mm::VirtAddr(trampoline_va_start).page_number::<M>();
    let ppn = mm::PhysAddr(trampoline_pa_start).page_number::<M>();
    let n = trampoline_len >> M::FRAME_SIZE_BITS;
    (vpn, ppn, n)
}
}

为了跳转到跳板页,由于它在高地址上,我们提前得到函数地址保存,以便恢复函数找到跳板函数的位置。


#![allow(unused)]
fn main() {
// 在Runtime::new_user中得到跳板函数的位置
extern "C" { fn strampoline(); }
let trampoline_pa_start = strampoline as usize;
let resume_fn_pa = trampoline_resume as usize;
let resume_fn_va = resume_fn_pa - trampoline_pa_start + trampoline_va_start.0;
unsafe { core::mem::transmute(resume_fn_va) }
// 在初始化执行器函数中得到返回跳板的位置
pub fn init(trampoline_va_start: mm::VirtAddr) {
    extern "C" { fn strampoline(); }
    let trampoline_pa_start = strampoline as usize;
    let trap_entry_fn_pa = trampoline_trap_entry as usize;
    let trap_entry_fn_va = trap_entry_fn_pa - trampoline_pa_start + trampoline_va_start.0;
    let mut addr = trap_entry_fn_va;
    if addr & 0x2 != 0 {
        addr += 0x2; // 必须对齐到4个字节
    }
    unsafe { stvec::write(addr, TrapMode::Direct) };
}
}

然后,从用户层返回,我们使用相似的思路编写汇编代码。


#![allow(unused)]

fn main() {
#[naked]
#[link_section = ".trampoline"]
unsafe extern "C" fn trampoline_trap_entry() {
    asm!(
        ".p2align 2", // 对齐到4字节
        // sp = 用户栈, sscratch = 生成器上下文
        "csrrw  sp, sscratch, sp", 
        // sp = 生成器上下文, sscratch = 用户栈
        "sd     ra, 0*8(sp)
        sd      gp, 2*8(sp)
        ...... 保存tp到t5 ......
        sd      t6, 30*8(sp)",
        "csrr   t0, sstatus
        sd      t0, 31*8(sp)",
        "csrr   t1, sepc
        sd      t1, 32*8(sp)",
        // sp = 生成器上下文, sscratch = 用户栈
        "csrrw  t2, sscratch, sp", 
        // sp = 生成器上下文, sscratch = 生成器上下文, t2 = 用户栈
        "sd     t2, 1*8(sp)", // 保存用户栈
        "ld     t3, 34*8(sp)", // t3 = 内核的地址空间配置
        "csrw   satp, t3", // 写内核的地址空间配置;用户的地址空间配置将丢弃
        "sfence.vma", // 立即切换地址空间
        "ld     sp, 33*8(sp)", 
        // sp = 内核栈
        "ld     ra, 0*8(sp)
        ld      gp, 1*8(sp)
        ...... 加载tp到s10 ......
        ld      s11, 14*8(sp)
        addi    sp, sp, 15*8", // sp = 内核栈
        "jr     ra", // ret指令
        options(noreturn)
    )
}
}

有了所有的代码之后,我们最终可以实现生成器语法实现的执行器运行时了。


#![allow(unused)]
fn main() {
impl Generator for Runtime {
    type Yield = KernelTrap;
    type Return = ();
    fn resume(mut self: Pin<&mut Self>, _arg: ()) -> GeneratorState<Self::Yield, Self::Return> {
        (self.trampoline_resume)(
            unsafe { self.context_mut() } as *mut _,
            self.user_satp
        ); // 立即跳转到跳板页,来进入用户
        // 从用户返回
        let stval = stval::read();
        let trap = match scause::read().cause() {
            Trap::Exception(Exception::UserEnvCall) => KernelTrap::Syscall(),
            Trap::Exception(Exception::LoadFault) => KernelTrap::LoadAccessFault(stval),
            Trap::Exception(Exception::StoreFault) => KernelTrap::StoreAccessFault(stval),
            Trap::Exception(Exception::IllegalInstruction) => KernelTrap::IllegalInstruction(stval),
            // ..... 其它的异常和中断
            e => panic!("unhandled exception: ....")
        };
        GeneratorState::Yielded(trap)
    }
}
}

执行器语法降低了编写内核的思考量,开发者有更多的时间专注于异构计算外设的开发工作中。这种方法暂时相比原来的写法无性能提升,需要编译器技术更新后,对需要保存的执行器上下文有更精细的控制,就有性能提升了。

5 一些思考

我们用执行器语法编写了跨空间跳板内核,它采用了全隔离内核的思想,运用最新的执行器语义降低编程难度。在这之后,异步内核核心的共享内存概念得到了充分的设计经验考验。配合上共享调度器等等核心的概念,我们就可以更便捷、更高效地设计异步内核了。文件、网络等模块也可以更快地完成设计。

编写代码时,因为经常需要操作较高的虚拟地址,可能需要将减法放在运算的前面,或者使用取模回环运算,否则将可能出现运算溢出,干扰内核的正常运行。这种情况很容易在调试时找到。

使用文章的方法编写内核后,完整的地址空间就可以给用户使用了。用户可以把程序链接到0x1000等地址上,无需担心是否与内核冲突。用户的栈也是由内核分配的。

在编写这些代码时,无相之风团队的RISC-V二进制工具箱给了我很大的帮助,让我能更快地完成页表调试过程。完整代码的地址保存在GitHub仓库


作者简介:

洛佳

华中科技大学网络空间安全学院本科生,热爱操作系统、嵌入式开发。“无相之风”战队成员,飓风内核项目作者之一,3年Rust语言开发经验,社区活跃贡献者。目前致力于向科研、产业和教学界推广Rust语言。

Trait Upcasting 系列 | Part I

作者: 张汉东 / 审校:crlf0710


引子

记录 @crlf0710 的 Trait Upcasting系列 系列 PR 过程。因为后续我也参与其中一个 PR,所以先要理清楚 @crlf0710 的 PR 思路,以便后续我的参与。也借此机会分享出来,希望更多的人能参与到 Rust 语言贡献中。

PR 系列:

  1. Refactor vtable codegen #86291
  2. Change vtable memory representation to use tcx allocated allocations.#86475
  3. Refactor vtable format for upcoming trait_upcasting feature. #86461
  4. Trait upcasting (part1) #86264
  5. Trait upcasting (part2)

本文为 第一个 PR 的描述。


前情提要

故事要从 Trait upcasting #60900 这个 PR 讲起 。

Trait upcasting ,是 trait 向上转型的意思。这个 PR 提出,当 Foo: Bar ,即 Foo trait 继承自 Bar trait 时,允许从 dyn Foo转到 dyn Bar

目前 Rust 版本中,不支持此功能。因为目前trait 继承情况下, trait 对象的方法都是存储在同一个虚表中,无法区分哪个函数是属于哪个trait 对象。

社区内有一个通用的解决办法:

trait Base {
    fn base(&self) {
        println!("base...");
    }
}

trait AsBase {
    fn as_base(&self) -> &dyn Base; //返回 Base trait对象
}

// blanket implementation
// 为所有实现 Base 的 T 来实现 AsBase
impl<T: Base> AsBase for T {
  	// 返回 Base trait对象
    fn as_base(&self) -> &dyn Base {
        self
    }
}

trait Foo: AsBase {
    fn foo(&self) {
        println!("foo..");
    }
}

#[derive(Debug)]
struct MyStruct;

impl Foo for MyStruct {}
impl Base for MyStruct {}

fn main() {
    let s = MyStruct;
    let foo: &dyn Foo = &s;
    foo.foo();
    let base: &dyn Base = foo.as_base(); // 通过 as_base 来返回 Base trait对象达到 upcasting 的效果
    base.base();
}

在 PR #60900 中,作者给出了一些实现,但是因为太大了,需要对这份工作进行重构,然后这个 PR 就被关闭了。关于这个 PR 的相关讨论被记录于 rust-lang.zulipchat.

这份重构的工作,就由 crlf0710 承接起来了,这就是这个系列 PR 的由来。相关提案:Trait Upcasting #98 , 跟踪 issues :Tracking issue for trait upcasting #65991

第一步工作: 重构 vtable 代码生成

状态:这部分工作已经被合并。

相关PR: Refactor vtable codegen #86291

修改文件概述

本次修改涉及 十个文件。

  1. compiler/rustc_codegen_cranelift/src/vtable.rs
  2. compiler/rustc_codegen_ssa/src/glue.rs
  3. compiler/rustc_codegen_ssa/src/meth.rs
  4. compiler/rustc_codegen_ssa/src/mir/block.rs
  5. compiler/rustc_middle/src/query/mod.rs
  6. compiler/rustc_middle/src/ty/mod.rs
  7. compiler/rustc_mir/src/interpret/traits.rs
  8. compiler/rustc_mir/src/monomorphize/collector.rs
  9. compiler/rustc_trait_selection/src/traits/mod.rs
  10. compiler/rustc_trait_selection/src/traits/select/confirmation.rs

这十个文件涉及五个 crate:

  1. rustc_codegen_cranelift,是 基于 cranelift 的编译器后端,专门用于 debug 模式。
  2. rustc_codegen_ssa,截至2021年1月,RustC_Codegen_SSA 为所有后端提供了一个抽象的接口,以允许其他Codegen后端(例如Cranelift)。
  3. rustc_middle,属于 rust 编译器的 main crate ,包含rustc“家族”中的其他crate使用的通用类型定义,包括 HIR/MIR/Types。
  4. rustc_mir,用于操作 MIR 的库。
  5. rustc_trait_selection,该库定义了 trait resolution 相关方法。详细内容:Trait resolution (old-style)

rustc_middle 库中的修改

compiler/rustc_middle/src/ty/mod.rs 中新增了枚举类型:VtblEntry。


#![allow(unused)]
fn main() {
#[derive(Clone, Copy, Debug, PartialEq, HashStable)]
pub enum VtblEntry<'tcx> {
    MetadataDropInPlace,
    MetadataSize,
    MetadataAlign,
    Vacant,
    Method(DefId, SubstsRef<'tcx>),
}

pub const COMMON_VTABLE_ENTRIES: &[VtblEntry<'_>] =
    &[VtblEntry::MetadataDropInPlace, VtblEntry::MetadataSize, VtblEntry::MetadataAlign];

pub const COMMON_VTABLE_ENTRIES_DROPINPLACE: usize = 0;
pub const COMMON_VTABLE_ENTRIES_SIZE: usize = 1;
pub const COMMON_VTABLE_ENTRIES_ALIGN: usize = 2;
}

这是为了识别 vtable 中的不同 entry,这样才有可能识别 存储在vtable中的不同 trait 对象。

接下来,在 compiler/rustc_middle/src/query/mod.rs 中把 query vtable_methods 修改为 query vtable_entries


#![allow(unused)]
fn main() {
// 使用的是一个宏
rustc_queries! {
   // ...
   query vtable_entries(key: ty::PolyTraitRef<'tcx>)
                        -> &'tcx [ty::VtblEntry<'tcx>] {
        desc { |tcx| "finding all vtable entries for trait {}", tcx.def_path_str(key.def_id()) }
    }
  
    // ...
}
}

在 rust_middle 中定义了 rustc 的 query 系统 。Rust 使用查询系统,是为了支持增量编译。参考 编译器概览

举个例子。假如有一条查询负责询问某个东西的类型, 而另一条查询负责询问某个函数的优化后的 MIR。这些查询可以相互调用并且由查询系统所跟踪。 查询的结果被缓存于硬盘上,这样我们就可以分辨相较于上次编译,哪些查询的结果改变了,并且仅重做这些查询。 这就是增量编译是如何工作的。

类型上下文(TyCtxt),它是一个相当巨大的结构体, 是所有东西的中心。所有查询都被定义为在TyCtxt类型上 的方法,并且内存中的查询缓存也同样被存储在此。在代码中,通常会有一个名为tcx变量,它是 类型上下文上的一个句柄。有同样会见到名为'tcx的生命周期,这意味着有东西被和TyCtxt的 生命周期绑定在了一起(通常它会被存储或者被驻留化)。

ty::Ty 介绍

类型在 Rust 中相当重要,并且形成了许多编译器分析的核心。用于表示类型(在用户程序中)的 主要类型(在编译器中)是 rustc_middle::ty::Ty。它是如此的重要以至于我们为其 设置了一整章ty::Ty,但是对于现在而言,我们只想提到它存在并且是rustc用来表示类型的方法!

同样注意到rustc_middle::ty模块定义了我们之前提到的TyCtxt结构体。

rustc_codegen_ssa 中的修改

因为 rustc_codegen_ssa 是 后端 codegen 的接口,所以先看这里。

rustc_codegen_ssa 主要入口点: rustc_codegen_ssa::base::codegen_crate

在 rust_codgen_ssa 出现之前,生成代码都是由 rust_codgen_llvm 处理。

LLVM codegen的两个最重要的结构是CodegenCxBuilder。它们由多个生命期参数和Value的类型组成。


#![allow(unused)]

fn main() {
struct CodegenCx<'ll, 'tcx> {
  /* ... */
}

struct Builder<'a, 'll, 'tcx> {
  cx: &'a CodegenCx<'ll, 'tcx>,
  /* ... */
}
}

CodegenCx是用来编译一个可以包含多个函数的 codegen-unit 的,而Builder 是为了编译一个基本块而创建的。CodegenCxBuilder将是实现所有定义后端接口的traits的结构。

这些 trait 被定义在rustc_codegen_ssa/traits文件夹中,所有与后端无关的代码都以它们为参数。

在 rustc_codegen_ssa 有个关键的 trait :BuilderMethods,它表示后端实现的构建方法。那么实际上, rustc_codegen_cranelift 目前并没有依赖 rustc_codegen_ssa 的这个 BuilderMethods trait, 而 rustc_codegen_llvm 依赖了。看来目前 rustc_codegen_ssa 并未重构完成。

重构 vtable 的相关工作,主要涉及三个文件:

  1. rustc_codegen_ssa/src/meth.rs
  2. rustc_codegen_ssa/src/glue.rs
  3. rustc_codegen_ssa/src/mir/block.rs

meth.rs 中:


#![allow(unused)]
fn main() {
use rustc_middle::ty::{self, Instance, Ty, VtblEntry, COMMON_VTABLE_ENTRIES}; // 引入 rustc_middle 新加的枚举 VtblEntry 相关

impl<'a, 'tcx> VirtualIndex {
    pub fn from_index(index: usize) -> Self {
        VirtualIndex(index as u64) // 修改虚表index ,之前是偏移 3 ,因为之前是没有 vtable Entry 的,所以 DESTRUCTOR(index 0),SIZE(index 1),ALIGN(index 2) 都展开放了,现在则不需要。
    }
  
    // ...
  
    // 修改
    pub fn get_vtable<'tcx, Cx: CodegenMethods<'tcx>>(
    cx: &Cx,
    ty: Ty<'tcx>,
    trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
) -> Cx::Value {
  			// ...
        // 新增
        // 当有  T: Trait 或 SubTrait: ParentTrait 这种形式出现时,就会有 trait_ref 
        // 所以,相当于是 如果是有 trait 继承的情况下,就利用 query vtable_entries 来查询该trait
        // 并返回 vtable_entries ,否则返回 COMMON_VTABLE_ENTRIES,代表是单一的trait 对象
  	    let vtable_entries = if let Some(trait_ref) = trait_ref {
            tcx.vtable_entries(trait_ref.with_self_ty(tcx, ty))
        } else {
            COMMON_VTABLE_ENTRIES
        };
  
  			let layout = cx.layout_of(ty); // 新增
    // /////////////////////////////////////////////////////////////////////////////////////////////
    // If you touch this code, be sure to also make the corresponding changes to
    // `get_vtable` in `rust_mir/interpret/traits.rs`.
      // /////////////////////////////////////////////////////////////////////////////////////////////
    // 新增
    // 迭代处理每个 vtable entry 的元信息:drop/大小/对齐/方法等
    let components: Vec<_> = vtable_entries
        .iter()
        .map(|entry| match entry {
            VtblEntry::MetadataDropInPlace => {
                cx.get_fn_addr(Instance::resolve_drop_in_place(cx.tcx(), ty))
            }
            VtblEntry::MetadataSize => cx.const_usize(layout.size.bytes()),
            VtblEntry::MetadataAlign => cx.const_usize(layout.align.abi.bytes()),
            VtblEntry::Vacant => nullptr,
            VtblEntry::Method(def_id, substs) => cx.get_fn_addr(
                ty::Instance::resolve_for_vtable(
                    cx.tcx(),
                    ty::ParamEnv::reveal_all(),
                    *def_id,
                    substs,
                )
                .unwrap()
                .polymorphize(cx.tcx()),
            ),
        })
        .collect();
  
        // ...
  
     }
}
}

文档:rustc_middle::ty::PolyExistentialTraitRef ,该类型表示 对一个已经擦除了 Self 的 trait 的存在性引用,所以使用 with_self_ty 来提供一个self占位。

src/glue.rs 中:


#![allow(unused)]
fn main() {
// BuilderMethods 是通用后端接口,但目前只有llvm用这个
pub fn size_and_align_of_dst<'a, 'tcx, Bx: BuilderMethods<'a, 'tcx>>(
    bx: &mut Bx,
    t: Ty<'tcx>,
    info: Option<Bx::Value>,
) -> (Bx::Value, Bx::Value) {
  
 		// ...
  
  	match t.kind() {
        ty::Dynamic(..) => {
            // load size/align from vtable
            // 新增
            let vtable = info.unwrap();
            (
                meth::VirtualIndex::from_index(ty::COMMON_VTABLE_ENTRIES_SIZE)
                    .get_usize(bx, vtable),
                meth::VirtualIndex::from_index(ty::COMMON_VTABLE_ENTRIES_ALIGN)
                    .get_usize(bx, vtable),
            )
        }
    		// ...  
    }
  
    // ...
}
}

问题: 既然目前 BuilderMethods 只有 llvm使用而 cranelift没有使用, 为什么 rustc_codegen_cranelift/src/unsize.rs#L131 中对应的 size_and_align_of_dst 函数不做对应修改?

答:因为 rustc_codegen_cranelift 中 vtable 要做相应修改,具体在后面描述。

src/glue.rs 就是一个胶水模块,在生成底层指令的相关模块中,会调用该方法。

src/mir/block.rs 中:


#![allow(unused)]
fn main() {
struct TerminatorCodegenHelper<'tcx> {
    bb: mir::BasicBlock,
    terminator: &'tcx mir::Terminator<'tcx>,
    funclet_bb: Option<mir::BasicBlock>,
}

impl<'a, 'tcx> TerminatorCodegenHelper<'tcx> {
    // ...
  
  	fn codegen_drop_terminator(
        &mut self,
        helper: TerminatorCodegenHelper<'tcx>,
        mut bx: Bx,
        location: mir::Place<'tcx>,
        target: mir::BasicBlock,
        unwind: Option<mir::BasicBlock>,
    ) {
        // ...
         let (drop_fn, fn_abi) = match ty.kind() {
            // FIXME(eddyb) perhaps move some of this logic into
            // `Instance::resolve_drop_in_place`?
            ty::Dynamic(..) => {
                 // ...
                 // 新增
                 (
                   meth::VirtualIndex::from_index(ty::COMMON_VTABLE_ENTRIES_DROPINPLACE)
                        .get_fn(&mut bx, vtable, &fn_abi),
                    fn_abi,
                )
            }
            // ... 
          }
        // ...
    }
  
    // ...
  
}
}

src/mir/block.rs 顾名思义,这个是和 MIR 生成 basicblock 有关。代码中,要生成 drop 相关的终止符,所以需要得到虚表中 COMMON_VTABLE_ENTRIES_DROPINPLACE相关 index信息。

rustc_codegen_cranelift 库中的修改

compiler/rustc_codegen_cranelift/src/vtable.rs 中,定义了一些自由函数,用于定义 trait 对象中 vtable相关。

因为在 rustc_codegen_ssa 做了一些相关修改,而目前 rustc_codegen_cranelift 并没有使用 rustc_codegen_ssa 的统一接口,所以需要修改 rustc_codegen_cranelift vtable相关代码。


#![allow(unused)]

fn main() {
use ty::VtblEntry; // 新增

pub(crate) fn drop_fn_of_obj(fx: &mut FunctionCx<'_, '_, '_>, vtable: Value) -> Value {
    let usize_size = fx.layout_of(fx.tcx.types.usize).size.bytes() as usize;
    fx.bcx.ins().load(
        pointer_ty(fx.tcx),
        vtable_memflags(),
        vtable,
        (ty::COMMON_VTABLE_ENTRIES_DROPINPLACE * usize_size) as i32, // 新增
    )
}

pub(crate) fn size_of_obj(fx: &mut FunctionCx<'_, '_, '_>, vtable: Value) -> Value {
    let usize_size = fx.layout_of(fx.tcx.types.usize).size.bytes() as usize;
    fx.bcx.ins().load(
        pointer_ty(fx.tcx),
        vtable_memflags(),
        vtable,
        (ty::COMMON_VTABLE_ENTRIES_SIZE * usize_size) as i32, // 新增
    )
}

pub(crate) fn min_align_of_obj(fx: &mut FunctionCx<'_, '_, '_>, vtable: Value) -> Value {
    let usize_size = fx.layout_of(fx.tcx.types.usize).size.bytes() as usize;
    fx.bcx.ins().load(
        pointer_ty(fx.tcx),
        vtable_memflags(),
        vtable,
        (ty::COMMON_VTABLE_ENTRIES_SIZE * usize_size) as i32, // 新增
    )
}

pub(crate) fn get_ptr_and_method_ref<'tcx>(
    fx: &mut FunctionCx<'_, '_, 'tcx>,
    arg: CValue<'tcx>,
    idx: usize,
) -> (Value, Value) {
    let (ptr, vtable) = if let Abi::ScalarPair(_, _) = arg.layout().abi {
        arg.load_scalar_pair(fx)
    } else {
        let (ptr, vtable) = arg.try_to_ptr().unwrap();
        (ptr.get_addr(fx), vtable.unwrap())
    };

    let usize_size = fx.layout_of(fx.tcx.types.usize).size.bytes();
    let func_ref = fx.bcx.ins().load(
        pointer_ty(fx.tcx),
        vtable_memflags(),
        vtable,
        (idx * usize_size as usize) as i32, // 修改,因为 idx 变了,之前是 idx+3
    );
    (ptr, func_ref)
}

fn build_vtable<'tcx>(
    fx: &mut FunctionCx<'_, '_, 'tcx>,
    layout: TyAndLayout<'tcx>,
    trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
) -> DataId {
    let tcx = fx.tcx;
    let usize_size = fx.layout_of(fx.tcx.types.usize).size.bytes() as usize;

    let drop_in_place_fn = import_function(
        tcx,
        fx.module,
        Instance::resolve_drop_in_place(tcx, layout.ty).polymorphize(fx.tcx),
    );

    // 新增
    let vtable_entries = if let Some(trait_ref) = trait_ref {
        tcx.vtable_entries(trait_ref.with_self_ty(tcx, layout.ty))
    } else {
        ty::COMMON_VTABLE_ENTRIES
    };
  
     let mut data_ctx = DataContext::new();
     let mut data = ::std::iter::repeat(0u8)
         .take(vtable_entries.len() * usize_size)
         .collect::<Vec<u8>>()
         .into_boxed_slice();
      // 新增
      // 迭代处理 vtable entry
      for (idx, entry) in vtable_entries.iter().enumerate() {
          match entry {
              VtblEntry::MetadataSize => {
                  write_usize(fx.tcx, &mut data, idx, layout.size.bytes());
              }
              VtblEntry::MetadataAlign => {
                  write_usize(fx.tcx, &mut data, idx, layout.align.abi.bytes());
              }
              VtblEntry::MetadataDropInPlace | VtblEntry::Vacant | VtblEntry::Method(_, _) => {}
          }
      }
      data_ctx.define(data);

  
      // 迭代处理 vtable entry
      for (idx, entry) in vtable_entries.iter().enumerate() {
          match entry {
              VtblEntry::MetadataDropInPlace => {
                  let func_ref = fx.module.declare_func_in_data(drop_in_place_fn, &mut data_ctx);
                  data_ctx.write_function_addr((idx * usize_size) as u32, func_ref);
              }
              VtblEntry::Method(def_id, substs) => {
                  let func_id = import_function(
                      tcx,
                      fx.module,
                      Instance::resolve_for_vtable(tcx, ParamEnv::reveal_all(), *def_id, substs)
                          .unwrap()
                          .polymorphize(fx.tcx),
                  );
                  let func_ref = fx.module.declare_func_in_data(func_id, &mut data_ctx);
                  data_ctx.write_function_addr((idx * usize_size) as u32, func_ref);
              }
              VtblEntry::MetadataSize | VtblEntry::MetadataAlign | VtblEntry::Vacant => {}
          }
      }

    // ... 
}
}

对 vtable 的修改,类似于 rustc_codegen_ssa 相关代码修改,只不过 rustc_codegen_cranelift 没有完全使用 rustc_codegen_ssa 的接口,所以需要另行单独处理。

rustc_trait_selection 中的修改

该库定义了 trait resolution 相关方法。Rust 编译器类型检查,mir层都依赖于该库。

Trait Resolution 主要用于判断该如何选择合理的 trait。 比如:


#![allow(unused)]
fn main() {
trait Convert<Target> {
    fn convert(&self) -> Target;
}
}

这个trait只有一个方法。它是最简单的。它从(隐含的)Self类型转换到Target类型。如果我们想允许isize和usize之间的转换,我们可以这样实现Convert。


#![allow(unused)]

fn main() {
impl Convert<usize> for isize { ... } // isize -> usize
impl Convert<isize> for usize { ... } // usize -> isize
}

现在想象一下,有一些像下面这样的代码。


#![allow(unused)]
fn main() {
let x: isize = .....;
let y = x.convert();
}

对convert的调用将为isize生成一个trait reference Convert<$Y>,其中$Y是代表y类型的类型变量。在我们可以看到的两个impls中,唯一匹配的是Convert<usize> for isize。因此,我们可以选择这个函数,这将导致$Y的类型被统一为usize。(注意,在组装候选程序时,我们在一个事务中进行初始统一,这样它们就不会相互影响。)

还有其他情况,可以参考 Trait resolution (old-style)

既然 vtable 已经修改,那也必须得修改该库中相关代码。

一共修改两个文件:rustc_trait_selection/src/traits/mod.rsrustc_trait_selection/src/traits/select/confirmation.rs

src/traits/mod.rs中:


#![allow(unused)]
fn main() {
use rustc_middle::ty::{
    self, GenericParamDefKind, ParamEnv, ToPredicate, Ty, TyCtxt, VtblEntry, WithConstness,
    COMMON_VTABLE_ENTRIES,
}; // 引入 新增的 VtblEntry类型

pub use self::util::{
    supertrait_def_ids, supertraits, transitive_bounds, transitive_bounds_that_define_assoc_type,
    SupertraitDefIds, Supertraits,
};

/// Given a trait `trait_ref`, iterates the vtable entries
/// that come from `trait_ref`, including its supertraits.
// 修改 原方法
fn vtable_entries<'tcx>(
    tcx: TyCtxt<'tcx>,
    trait_ref: ty::PolyTraitRef<'tcx>,
) -> &'tcx [VtblEntry<'tcx>] {
    debug!("vtable_entries({:?})", trait_ref);

    let entries = COMMON_VTABLE_ENTRIES.iter().cloned().chain(
        supertraits(tcx, trait_ref).flat_map(move |trait_ref| {
            let trait_methods = tcx
                .associated_items(trait_ref.def_id())
                .in_definition_order()
                .filter(|item| item.kind == ty::AssocKind::Fn);

            // Now list each method's DefId and InternalSubsts (for within its trait).
            // If the method can never be called from this object, produce `Vacant`.
            trait_methods.map(move |trait_method| {
                debug!("vtable_entries: trait_method={:?}", trait_method);
                let def_id = trait_method.def_id;

                // Some methods cannot be called on an object; skip those.
                if !is_vtable_safe_method(tcx, trait_ref.def_id(), &trait_method) {
                    debug!("vtable_entries: not vtable safe");
                    return VtblEntry::Vacant;
                }

                // The method may have some early-bound lifetimes; add regions for those.
                let substs = trait_ref.map_bound(|trait_ref| {
                    InternalSubsts::for_item(tcx, def_id, |param, _| match param.kind {
                        GenericParamDefKind::Lifetime => tcx.lifetimes.re_erased.into(),
                        GenericParamDefKind::Type { .. } | GenericParamDefKind::Const { .. } => {
                            trait_ref.substs[param.index as usize]
                        }
                    })
                });

                // The trait type may have higher-ranked lifetimes in it;
                // erase them if they appear, so that we get the type
                // at some particular call site.
                let substs =
                    tcx.normalize_erasing_late_bound_regions(ty::ParamEnv::reveal_all(), substs);

                // It's possible that the method relies on where-clauses that
                // do not hold for this particular set of type parameters.
                // Note that this method could then never be called, so we
                // do not want to try and codegen it, in that case (see #23435).
                let predicates = tcx.predicates_of(def_id).instantiate_own(tcx, substs);
                if impossible_predicates(tcx, predicates.predicates) {
                    debug!("vtable_entries: predicates do not hold");
                    return VtblEntry::Vacant;
                }

                VtblEntry::Method(def_id, substs)
            })
        }),
    );

    tcx.arena.alloc_from_iter(entries)
}

/// Find slot base for trait methods within vtable entries of another trait
// 新增 : 查找其他 trait 的VTable条目中的 trait 方法的位置
fn vtable_trait_first_method_offset<'tcx>(
    tcx: TyCtxt<'tcx>,
    key: (
        ty::PolyTraitRef<'tcx>, // trait_to_be_found
        ty::PolyTraitRef<'tcx>, // trait_owning_vtable
    ),
) -> usize {
    let (trait_to_be_found, trait_owning_vtable) = key;

    let mut supertraits = util::supertraits(tcx, trait_owning_vtable);

    // For each of the non-matching predicates that
    // we pass over, we sum up the set of number of vtable
    // entries, so that we can compute the offset for the selected
    // trait.
    let vtable_base = ty::COMMON_VTABLE_ENTRIES.len()
        + supertraits
            .by_ref()
            .take_while(|t| *t != trait_to_be_found)
            .map(|t| util::count_own_vtable_entries(tcx, t))
            .sum::<usize>();

    vtable_base
}

// 修改
pub fn provide(providers: &mut ty::query::Providers) {
    object_safety::provide(providers);
    structural_match::provide(providers);
    *providers = ty::query::Providers {
        // ...
      
        vtable_entries,
  
        // ...
    };
}
}

src/traits/select/confirmation.rs 中:

该模块用于确认选中的 trait 。


#![allow(unused)]
fn main() {
 fn confirm_object_candidate(
        &mut self,
        obligation: &TraitObligation<'tcx>,
        index: usize,
    ) -> Result<ImplSourceObjectData<'tcx, PredicateObligation<'tcx>>, SelectionError<'tcx>> {
    // ...
    let unnormalized_upcast_trait_ref =
            supertraits.nth(index).expect("supertraits iterator no longer has as many elements"); // 修改
      
    // ...
    let vtable_base = super::super::vtable_trait_first_method_offset(
            tcx,
            (unnormalized_upcast_trait_ref, ty::Binder::dummy(object_trait_ref)),
        );
    // ...
}
}

rustc_mir 中的修改

在 rust_mir 中涉及两个文件修改: rustc_mir/src/interpret/traits.rs rustc_mir/src/monomorphize/collector.rs

src/interpret/traits.rs 中:

interpret 是和 mir 转译为 llvm ir 相关。


#![allow(unused)]
fn main() {
use rustc_middle::ty::{
    self, Instance, Ty, VtblEntry, COMMON_VTABLE_ENTRIES, COMMON_VTABLE_ENTRIES_ALIGN,
    COMMON_VTABLE_ENTRIES_DROPINPLACE, COMMON_VTABLE_ENTRIES_SIZE,
}; // 修改,引入 VtblEntry 相关新类型


// 修改原方法
// InterpCx 是 interpret 上下文
impl<'mir, 'tcx: 'mir, M: Machine<'mir, 'tcx>> InterpCx<'mir, 'tcx, M> {
    /// Creates a dynamic vtable for the given type and vtable origin. This is used only for
    /// objects.
    ///
    /// The `trait_ref` encodes the erased self type. Hence, if we are
    /// making an object `Foo<Trait>` from a value of type `Foo<T>`, then
    /// `trait_ref` would map `T: Trait`.
    pub fn get_vtable(
        &mut self,
        ty: Ty<'tcx>,
        poly_trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
    ) -> InterpResult<'tcx, Pointer<M::PointerTag>> {
        // ...
      
        // 获取 vtable entries
      	let vtable_entries = if let Some(poly_trait_ref) = poly_trait_ref {
            let trait_ref = poly_trait_ref.with_self_ty(*self.tcx, ty);
            let trait_ref = self.tcx.erase_regions(trait_ref);

            self.tcx.vtable_entries(trait_ref)
        } else {
            COMMON_VTABLE_ENTRIES
        };

        // ...
        // 新增
        ////////////////////////////////////////////////////////////////////////
        // If you touch this code, be sure to also make the corresponding changes to
        // `get_vtable` in `rust_codegen_llvm/meth.rs`.
        // /////////////////////////////////////////////////////////////////////
        let vtable_size = ptr_size * u64::try_from(vtable_entries.len()).unwrap();
      
        // ...
        // 新增
        // No need to do any alignment checks on the memory accesses below, because we know the
        // allocation is correctly aligned as we created it above. Also we're only offsetting by
        // multiples of `ptr_align`, which means that it will stay aligned to `ptr_align`.
        // 迭代处理 vtable entries 中每个虚表的布局
        let scalars = vtable_entries
            .iter()
            .map(|entry| -> InterpResult<'tcx, _> {
                match entry {
                    VtblEntry::MetadataDropInPlace => Ok(Some(drop.into())),
                    VtblEntry::MetadataSize => Ok(Some(Scalar::from_uint(size, ptr_size).into())),
                    VtblEntry::MetadataAlign => Ok(Some(Scalar::from_uint(align, ptr_size).into())),
                    VtblEntry::Vacant => Ok(None),
                    VtblEntry::Method(def_id, substs) => {
                        // Prepare the fn ptr we write into the vtable.
                        let instance =
                            ty::Instance::resolve_for_vtable(tcx, self.param_env, *def_id, substs)
                                .ok_or_else(|| err_inval!(TooGeneric))?;
                        let fn_ptr = self.memory.create_fn_alloc(FnVal::Instance(instance));
                        Ok(Some(fn_ptr.into()))
                    }
                }
            })
            .collect::<Result<Vec<_>, _>>()?;
        let mut vtable_alloc =
            self.memory.get_mut(vtable.into(), vtable_size, ptr_align)?.expect("not a ZST");
        for (idx, scalar) in scalars.into_iter().enumerate() {
            if let Some(scalar) = scalar {
                let idx: u64 = u64::try_from(idx).unwrap();
                vtable_alloc.write_ptr_sized(ptr_size * idx, scalar)?;
            }
        }
        // ...  
      
        // 修改原方法
        /// Resolves the function at the specified slot in the provided
    /// vtable. Currently an index of '3' (`COMMON_VTABLE_ENTRIES.len()`)
    /// corresponds to the first method declared in the trait of the provided vtable.
    pub fn get_vtable_slot(
        &self,
        vtable: Scalar<M::PointerTag>,
        idx: u64,
    ) -> InterpResult<'tcx, FnVal<'tcx, M::ExtraFnVal>> {
        let ptr_size = self.pointer_size();
        let vtable_slot = vtable.ptr_offset(ptr_size * idx, self)?; // 新增
        let vtable_slot = self
            .memory
            .get(vtable_slot, ptr_size, self.tcx.data_layout.pointer_align.abi)?
            .expect("cannot be a ZST");
        let fn_ptr = vtable_slot.read_ptr_sized(Size::ZERO)?.check_init()?;
        self.memory.get_fn(fn_ptr)
    }

    /// Returns the drop fn instance as well as the actual dynamic type.
    pub fn read_drop_type_from_vtable(
        &self,
        vtable: Scalar<M::PointerTag>,
    ) -> InterpResult<'tcx, (ty::Instance<'tcx>, Ty<'tcx>)> {
        let pointer_size = self.pointer_size();
        // We don't care about the pointee type; we just want a pointer.
        let vtable = self
            .memory
            .get(
                vtable,
                pointer_size * u64::try_from(COMMON_VTABLE_ENTRIES.len()).unwrap(),
                self.tcx.data_layout.pointer_align.abi,
            )?
            .expect("cannot be a ZST");
        let drop_fn = vtable
            .read_ptr_sized(
                pointer_size * u64::try_from(COMMON_VTABLE_ENTRIES_DROPINPLACE).unwrap(),
            )?
            .check_init()?;
          // ....
        }
      
        // ...
        // 修改原方法
        pub fn read_size_and_align_from_vtable(
        &self,
        vtable: Scalar<M::PointerTag>,
        ) -> InterpResult<'tcx, (Size, Align)> {
            let pointer_size = self.pointer_size();
            // We check for `size = 3 * ptr_size`, which covers the drop fn (unused here),
            // the size, and the align (which we read below).
            let vtable = self
                .memory
                .get(
                    vtable,
                    pointer_size * u64::try_from(COMMON_VTABLE_ENTRIES.len()).unwrap(),
                    self.tcx.data_layout.pointer_align.abi,
                )?
                .expect("cannot be a ZST");
            let size = vtable
                .read_ptr_sized(pointer_size * u64::try_from(COMMON_VTABLE_ENTRIES_SIZE).unwrap())?
                .check_init()?;
            let size = u64::try_from(self.force_bits(size, pointer_size)?).unwrap();
            let align = vtable
                .read_ptr_sized(pointer_size * u64::try_from(COMMON_VTABLE_ENTRIES_ALIGN).unwrap())?
                .check_init()?;
            let align = u64::try_from(self.force_bits(align, pointer_size)?).unwrap();
            let align = Align::from_bytes(align).map_err(|e| err_ub!(InvalidVtableAlignment(e)))?;

            if size >= self.tcx.data_layout.obj_size_bound() {
                throw_ub!(InvalidVtableSize);
            }
            Ok((Size::from_bytes(size), align))
        }
        // ...
    }
}
}

src/monomorphize/collector.rs 中:

monomorphize 意思是 单态化,意味着 这个模块用于 泛型单态化。


#![allow(unused)]
fn main() {
use rustc_middle::ty::{self, GenericParamDefKind, Instance, Ty, TyCtxt, TypeFoldable, VtblEntry}; // 引入新的 VtablEntry 类型

/// Creates a `MonoItem` for each method that is referenced by the vtable for
/// the given trait/impl pair.
fn create_mono_items_for_vtable_methods<'tcx>(
    tcx: TyCtxt<'tcx>,
    trait_ty: Ty<'tcx>,
    impl_ty: Ty<'tcx>,
    source: Span,
    output: &mut Vec<Spanned<MonoItem<'tcx>>>,
) {
   // ...
   if let ty::Dynamic(ref trait_ty, ..) = trait_ty.kind() {
       if let Some(principal) = trait_ty.principal() {
           // ...
           // Walk all methods of the trait, including those of its supertraits
           // 走查所有的 trait 方法,包括 supertrait 的
           let entries = tcx.vtable_entries(poly_trait_ref);
           let methods = entries
                .iter()
                .filter_map(|entry| match entry {
                    VtblEntry::MetadataDropInPlace
                    | VtblEntry::MetadataSize
                    | VtblEntry::MetadataAlign
                    | VtblEntry::Vacant => None,
                    VtblEntry::Method(def_id, substs) => ty::Instance::resolve_for_vtable(
                        tcx,
                        ty::ParamEnv::reveal_all(),
                        *def_id,
                        substs,
                    )
                    .filter(|instance| should_codegen_locally(tcx, instance)),
                })
                .map(|item| create_fn_mono_item(tcx, item, source));
            output.extend(methods);
       }
   }
   // ...
}
}

小结

第一步工作,主要是为了改进生成的 vtable结构,能够识别 多个trait 对象。

rustc_middle -> rustc_codgen_ssa -> rustc_codegen_cranelift -> rustc_trait_selection -> rustc_mir 这个过程,是从上到下,从抽象类型 到 能转译为 llvm IR 的 MIR。

如果 rustc_codgen_cranelift 能够完全使用 rustc_codgen_ssa ,那么代码修改起来应该更方便了。

后续:看到 rustc_codegen_gcc 就是基于 rustc_codgen_ssa来实现的。

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


mdBook : Rust 实现的电子书创建工具

使用 Markdown 文件来创建电子书,最近发布了新版本。 注意:RustMagazine 就是用了 mdBook。

https://github.com/rust-lang/mdBook

Datafuse 现代实时数据处理与分析的 DBMS

Datafuse 是一款现代实时数据处理与分析的 DBMS,云原生架构,基于 Rust 编写,受到 ClickHouse 启发并由 arrow-rs 赋能。

该框架秉持以下基本原则:

  • 无畏: 没有数据竞争,没有 unsafe,尽可能少的未处理错误
  • 高性能: 所有执行都是并行的
  • 高可扩展性: 所有组件都是分布式的
  • 高可靠性: 该框架的首要目标就是要可靠

https://github.com/datafuselabs/datafuse

websocat: websocket的命令行客户端

用于测试 websocket 很好的工具。

https://github.com/vi/websocat

zola: 静态网站生成器

该项目也算是 Rust 生态里历史比较长的了。

SixtyFPS : 为任何显示器开发流畅的图形用户界面

SixtyFPS 是一个工具包,可以为任何显示器高效开发流畅的图形用户界面:嵌入式设备和桌面应用程序。 支持多种编程语言,例如 Rust、C++ 或 JavaScript。

https://github.com/sixtyfpsui/sixtyfps

broot : 查看和导航目录树的新方法

此库值得你一用,功能比较丰富。

fnm: node.js 版本管理器

特点:简单/ 跨平台

https://github.com/Schniz/fnm

mcfly: 智能的 shell 历史命令推荐工具

McFly 通过一个小型神经网络实时确定优先级来进行shell命令推荐。

https://github.com/cantino/mcfly

推荐项目 | 基础工具库

编辑: 张汉东


pyre-http:用 Rust 实现的 Python HTTP Server

目前还未到达生产可用级别。

pyre-http 几乎完全用 Rust 实现,为了兼容 python 的 ASGI/Asyncio生态系统,还使用了 PyO3。

具体来说,基本上用纯 Rust 重写了整个 asyncio 服务器和协议API,将Python调用减少到平均每个请求只有1-2个调用。

作者的一些心得:

Rust和Python在一起工作很好,但有些地方开销很大。

在Rust中为异步Python写异步代码,需要从完全不同的角度来看待你在Python中写的东西,你最好以事件驱动的状态机风格来写代码,而不是Python中正常的基于回调的Future。

编写异步Python并不意味着编写高级的异步Rust,公平地说,在大多数情况下Python也是如此;通常你会期望看到大多数服务器代码是完全同步的。

https://github.com/Project-Dream-Weaver/pyre-http

Fang: 后台任务处理库

作者处理后台任务的心路历程:

  1. 最初的方法(天真烂漫)

在同一个线程中执行tokio任务中的每个同步工作。在并发量大的时候,出现了问题:有一些同步任务根本没有执行。开发者对tokio任务没有任何控制权,所以没有办法检查任务的总数和当前正在执行的任务等。

有趣的是,作者在2020年还为此写过一篇文章:在 Rust 里,你不需要后台任务框架

然而,现在作者萌生了实现简单后台任务处理库的想法。

  1. Fang 的方案(简单,但不天真)

    a. 任务被存储在 Postgres 数据库中。 b. Fang 启动指定数量的 Worker,每个 Worker 就是一个独立线程,用于执行任务。 c. 如果数据库中没有剩余的任务,Worker就会在指定的秒数内休眠。 d. 如果任何 worker 在任务执行过程中出现故障,它将被重新启动。

Fang 的方案确实简单粗暴,并且还对 Postgres 数据库绑定很深。因为 需要保证每个 Worker 必须对任务只处理一次,所以 Fang 依赖了 Postgres 数据库的 Locks(FOR UPDATE SKIP LOCKED)

个人观点:

这种方案其实比较传统,比如 Ruby 的 delayed_job ,或者 Sidekiq(依赖于 redis)。

其实在 Rust 社区,也有不少后台任务库,但很多就不维护了,可能和 Rust 异步生态不太稳定有关系。

有一个目前还维护(但不积极)的语言无关的后台任务库:ocypod,是基于 redis 的,值得关注。但依赖的还是 tokio 0.2 。

Jonhoo 实现了一个 faktory-rs,是 高性能任务系统 Faktory 的 Rust 客户端和 worker 实现。Faktory 是 Sidekiq 作者(实现财富自由以后)的新产品(go语言实现),支持异构系统,可以用任意语言做生产者和消费者。虽然褒贬不一,但毕竟作者已经有一个非常成功的Sidekiq实现了。

Faktory 特点:

  • Faktory server(not worker)支持 retry 等特性
  • 不特定依赖 redis,内置RocksDB做消息存储
  • 提供类似 Sidekiq 的 WebUI
  • 支持异构系统,并且保留了从 Sidekiq 积累下的一些好用的特性
  • Faktory 的接口格式很简单,核心是 queue、 jobtype 和 args
  • 目前国内云服务商不提供 Faktory 相关服务,需要自己维护
  • 已经发布1.5.1 版本

Fang 相关链接:

enarx : 在可信执行环境的应用部署系统

据说是红帽和一些知名企业一起搞的。

Enarx是独立于CPU架构的,使相同的应用程序代码可以在多个目标上部署,抽象出诸如交叉编译和硬件供应商之间不同的认证机制等问题。目前,支持 AMD SEV和英特尔SGX的工作正在进行中。

Enarx 使用 WebAssembly 作为其隔离和可移植性的基础。

https://github.com/enarx/enarx

cargo-c :构建并安装C-ABI兼容的动态和静态库。

它生成并安装正确的PKG-Config文件,静态库和动态库,以及任何C(和C兼容)软件使用的C头。

https://github.com/lu-zero/cargo-c

macroquad游戏引擎分离出音频系统

macroquad 游戏引擎在最近将其音频系统给单独提取出来,作为一个更通用的Rust包,该crate是对多个平台的多个音频后端的统一抽象,目前完成度如下:

  • Web: WebAudio
  • Android: OpenSLES
  • Linux: Alsa
  • Mac: CoreAudio
  • Windows: Wasapi
  • IOS: CoreAudio(?)

https://github.com/not-fl3/quad-snd

Gloo v0.3.0 发布

Gloo 团队很高兴地宣布一个新的、姗姗来迟的 Gloo 版本:v0.3.0。Gloo 是一个模块化工具包,用于使用 Rust 和 WASM 构建快速、可靠的 Web 应用程序和库。

https://gloo-rs.web.app/blog/release-0.3.0

Throne : 用于游戏原型设计和故事逻辑的脚本语言

@tobmansf 一直在研究用于游戏原型设计和故事逻辑的脚本语言。它可以编译成 WebAssembly,可以在 https://t-mw.github.io/throne-playground/ 上试一试。

https://github.com/t-mw/throne

零成本反序列化框架 rkyv 发布 0.7.1 版本

https://github.com/djkoloski/rkyv

一个 Rust 和 TypeScript 实现的 体素(Voxel)引擎

基于 actix-web 实现

慢啃 Rust 系列 | Gazebo 库 之 Dupe

原文标题:Rust Nibbles - Gazebo : Dupe

「Rust Nibbles」 翻译成 「慢啃 Rust 」 没毛病吧 ?

这是 Facebook for Develpers 网站出的Rust Nibbles系列文章,介绍 facebook 开源的各种 Rust 库。

Gazebo 是 facebook 工程师 编写的基础库,Gazebo以独立模块的形式包含了一系列经过测试的Rust实用程序。这篇文章是介绍了 Gazebo 中的 Dupe trait 。

在Rust中,有两个用于 "复制 "一个值的相关特性--Copy和Clone。

在Gazebo中引入了第三个类似的trait,称之为Dupe,它可以在Gazebo Prelude中使用。(dupe 有复制物品/复制底片的意思)。

Copy 是 编译器的自动行为,复制成本也不高。而 Clone 则不然。为了降低 Clone 的成本,一般可以使用 Arc,但是 Arc 使得代码阅读成本提升。比如 let xs = ys.clone();,你可能需要查看大量上下文来弄清是 调用了 Clone 还是 Arc 。当然你可以使用 let xs = Arc::clone(ys)来提升可读性,但缺点是,它破坏了抽象。

所以,Gazebo 中引入了 Dupe trait, let xs = ys.dupe()


#![allow(unused)]
fn main() {
use gazebo::prelude::*;
#[derive(Clone, Dupe)]
struct MyArc(Arc<String>);
}

看了一下实现源码:https://github.com/facebookincubator/gazebo/blob/master/gazebo/src/dupe.rs


#![allow(unused)]
fn main() {
pub trait Dupe: Clone {
    fn dupe(&self) -> Self {
        self.clone()
    }
}
}

看上去和 Clone 很像,但它仅在 常量时或零分配下可用,比如 Arc。因为 Dupe 只给这些类型实现了。

https://developers.facebook.com/blog/post/2021/07/06/rust-nibbles-gazebo-dupe/

想用 Rust 写脚本吗?

rust-script ,可以在没有任何设置或编译步骤的情况下运行rust 文件和表达式。


#![allow(unused)]

fn main() {
$ echo 'println!("Hello, World!");' > hello.rs
$ rust-script hello.rs
Hello, World!
}

也支持 依赖 crate


#!/usr/bin/env rust-script
//! This is a regular crate doc comment, but it also contains a partial
//! Cargo manifest.  Note the use of a *fenced* code block, and the
//! `cargo` "language".
//!
//! ```cargo
//! [dependencies]
//! time = "0.1.25"
//! 
fn main() {
    println!("{}", time::now().rfc822z());
}


#![allow(unused)]
fn main() {
$ rust-script now
Wed, 28 Oct 2020 00:38:45 +0100
}

Quilkin : 一个用于游戏服务器的开源UDP代理

由embark 和 Google Cloud 共同推出,目标是为任何游戏工作室提供和巨头同等的网络功能。

https://medium.com/embarkstudios/say-hi-to-quilkin-an-open-source-udp-proxy-88577c795204

franzplot : Rust 实现的教学软件

米兰理工大学的一名研究助理,担任了 “设计的曲线和表面”课程的助教,这门课主要是为设计专业的学生解释三维数学概念。 因为没有趁手的教学工具,所以这位助教自己用 Rust 实现了一个。

第一个版本是 cpp 实现的。然后新版本用 Rust 重写了,为什么呢?

  1. 他在cpp版本内亏欠的技术债务太多,不利于开源协同
  2. OpenGL 已经被苹果废弃
  3. 想让工具变得更加强大

所以,现在用 WebGPU + Rust 重写了这个工具。基于 https://github.com/gfx-rs/wgpu

FranzPlot目前是闭源的,未来可能会开源。因为尽管重新写了软件,也还需要处理一些技术债务。另外想完全使用 WGSL 而抛弃 GLSL ,还想将 界面替换为 纯 Rust 实现,比如 使用egui框架。现在是用了 imgui-rs。

https://gfx-rs.github.io/stories/franzplot

delicate 一个轻量的分布式的任务调度平台

特色大概有几点:

  1. 丰富Rust生态。是一个中小型项目(代码量3W+), 涉及的交互端包括(Front-end , Server , agent) js & rust 的交织, 可以让新同学作为参考实现一个Rust的工程。
  2. 里面的面对C端的交互都尽量设计的符合用户习惯,除了性能之外 项目很关心人使用的舒适度。
  3. 里面有一些新定义的概念比如绑定,任务不直接关联机器而是关联机器的一个抽象(绑定),当有成百上千的任务需要从 A 机器迁移到 B 机器 ,只需要修改一次关联关系,任务就自动完成了迁移。

https://github.com/BinChengZhao/delicate

一个安全的可用于 #[no_std] ASN.1 的 解码器框架

https://github.com/XAMPPRocky/rasn

Rust 实现的 Windows 上的下一代包管理器,非常快

现在只发布了 Alpha 版本,但是已经比目前 Windows 自带的包管理器快 5 倍了。

https://github.com/novus-package-manager/novus

Loadstone 发布,一个安全的裸金属的 bootloader

Rust 开始写 bootloader 了。看起来这是一个相当严肃的项目,目前已经发布了 1.0.0。提供了如下功能:

  • 多镜像操作:存储,拷贝,升级,验证和启动。可灵活配置
  • 支持外部 flash 芯片
  • Golden image rollbacks (怎么翻译?)
  • 自动或应用触发的升级

搞底层的同学,请严重关注一下此项目。

https://github.com/absw/loadstone

一个研究型的 OS:NrOS

Node Replicated Kernel (NRK) 最早是在 VMware Research 里面的一个原型研究型 Os 项目,现在已经做出了一些学术成果。主要特点是要探索未来操作系统的结构。

https://nrkernel.systems/

Sycamore v0.5.0发布

Sycamore 是一个用于在 Rust 和 WebAssembly 中构建同构 Web 应用程序的库。v0.5.0 版本是Sycamore迄今为止最大的版本,包含大量新功能和错误修复。其中这个版本还引入了功能齐全的路由系统

文章链接:https://sycamore-rs.netlify.app/news/announcing-v0.5.0

nanorand v0.6 发布

nanorand 是一个快速、轻量、高效的随机数生成器,其提供的 nanorand::WyRand 在 M1 Macbook Air 上速度可达 16.4 GB/s。

目前 nanorand 发布了 v0.6 ,RandomGen 支持带符号整数和浮点数,RandomRange 支持带符号整数。

https://github.com/Absolucy/nanorand-rs

Hora 0.1.0

Hora,Rust 实现的近似最邻近搜索(Approximate Nearest Neighbor Search, ANNS)算法库。先发布了 v0.1.0,专注于近似最邻近搜索领域,已经实现了 HNSW(Hierarchical Navigable Small World Graph Index)索引,SSG(Satellite System Graph)索引,PQIVF(Product Quantization Inverted File)索引,BruteForceIndex,其他索引也即将推出。

Hora 可以部署在任何操作系统平台上,已经支持的 PC 操作系统 Linux,Mac OS,Windows,将支持移动设备操作系统 IOS 和Android,以及将来支持嵌入式系统(no_std),并将支持多语言绑定,包括 Python,Javascript,Java,Ruby,Swift 和 R。

相关链接信息:https://github.com/hora-search/hora

推荐项目 | 框架引擎

编辑: 张汉东


Axum: tokio 出品的 Web 框架

这个框架一出,意味着 tokio 生态正在强势建立! 算的上是Rust Web 开发领域的里程碑事件!让其他 web 框架顿时失色!当然,我并不是否认其他 web 框架的优秀!

该框架全面整合 tokio 生态,并且由tokio 团队自己维护。这一点就很占优势了!

查了一下,Axum 这名字 有 king of kings 之意 😂,https://en.wikipedia.org/wiki/Kingdom_of_Aksum

https://tokio.rs/blog/2021-07-announcing-axum

MeiliSearch 搜索引擎合并了前半年的重构工作

改进摘要:

  1. 改进索引系统。
  2. 设计了更快的过滤系统版本。
  3. 提供了一个新的Facet系统。
  4. 提供一个新的 Dashboard。
  5. 其他。

Fluvio:现代化可编程流处理平台

https://www.infinyon.com/blog/2021/06/introducing-fluvio/#fluvio-programmable-platform-for-data-in-motion

Quickwit : 极具成本效益(cost-efficient )的搜索引擎 诞生了

从成本角度看,当前有两类搜索模型:

  1. 公共搜索引擎。比如 Google/ Twitter/ Wiki /GitHub/ Reddit 之类
  2. 私有搜索引擎。 有限的搜索集。比如你的服务日志 / 邮件

如果你运营的是一家成功的电商,你付出昂贵的硬件成本在搜索上面,你的投资回报率可能比较高,所以你也不会在意这点成本。

但是当你还没有达到足够的投资回报率,而你的日志搜索就占了你硬件成本的很大部分,那你就该关心这个成本了。

Quickwit 搜索引擎主要是为了解决这个问题,基于 tantivy ,类似Lucene,的一个分布式搜索引擎。

这是我们的第一个版本,它为一个具有成本效益的搜索引擎奠定了基础。

具体来说,Quickwit 0.1采用了一个命令行界面的形式来创建、索引和提供搜索集群,其主要特点如下。

  • 由Tantivy支持的快速索引
  • 改良的索引数据结构布局,可以直接在对象存储上打开并读取索引
  • 具有无状态实例的分布式搜索
  • 基于SWIM协议(Scalable Weakly-consistent Infection-style Process Group Membership Protocol, 可伸缩的弱一致性传染式进程组成员协议,由 gossip 协议发展而来)的集群形成,不用担心 leader 或 consensus
  • 可配置的映射
  • 自然查询语言

Quickwit 如何节省成本:

  1. 使用段复制方式优化索引,降低 cpu 使用时间
  2. 分离存储与计算,减少 cpu 空闲时间
  3. 使用 热启动 bundle,减少每次查询打开索引的花费
  4. 采用并发请求来解决 s3的低吞吐量问题
  5. 使用自定义索引格式,减少搜索关键路径

https://quickwit.io/blog/quickwit-first-release/

🌱🦀🌱 trillium : 构建 异步 web 应用的 组件库

  • trillium 意思是 延龄草 ,或者三叶草。

  • trillium 整体架构受 Elixir Plug的启发,并打算成为plug和 tide 的混合体。

  • trillium 的特点是 组件化,一切都是 opt-in 的。

  • trillium 的核心概念是: Handlers/ Conn/ Adapters

  • Trillium 遵守12 factor的配置方法,尽可能接受环境中的配置。

  • https://trillium.rs/

  • https://github.com/trillium-rs/trillium

八月刊(August)

发刊通告

Rust 资讯

Rust 与 学术

Rust 生产实践

学习园地

新手入门

原理与工程实践

为 Rust 语言做贡献

开源项目巡礼

发刊通告

编辑:张汉东

八月发刊通告

《 RustMagazine 中文精选 》2021 年第八期发布了!

盛夏结束,步入秋季,收获硕果的日子快到了!祝大家今年硕果累累!

欢迎大家积极投稿,也欢迎广大使用 Rust 的公司分享你们生产环境中 Rust 的应用。

本刊动态

感谢 @libook 的贡献,为月刊(GitHub Page)增加了「本地链接在当前标签页跳转,外链直接打开新标签页」的功能。

2021年(上半年)RustMagazine 中文精选 PDF 下载 : https://github.com/RustMagazine/rust_magazine_2021/releases/tag/0.5

(GitHub Page)从六月起已经停止 Rss 订阅

因为当前 mdbook 的 rss 插件不是很方便,等以后再加 rss 支持吧。

可以到语雀平台自动支持邮件订阅。

上期(七月刊)访问数据统计小结

用户数

  • 新用户新增 2,520

浏览量:

  • 网页浏览量 : 15,947

七月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

六月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

编辑:张汉东


官方 | 来推动 GAT 稳定吧

GAT RFC 从2016年启动,到今年已经五年了,现在终于接近稳定状态了。GAT 是 Rust github 仓库里期待率最高的一个问题。

现在经过编译器的大量修改,终于让 GAT 达到了一个 「完整」状态,虽然还有一些诊断问题,但现在你在 nightly 下使用 GAT 将不会看到 “generic_associated_types is incomplete”这种错误。 但是现在想 稳定 GAT ,还需要大家来帮助测试此功能,为你发现但任何错误或潜在的诊断改进提出问题。并且官方还希望可以在 GAT 上实现一些有趣的模式。

如果不出意外,未来几个月内稳定应该会比较顺利。

详细内容请看原文吧,原文也介绍了什么是 GAT 。

https://blog.rust-lang.org/2021/08/03/GATs-stabilization-push.html

wasm-bindgen 发布新版本

虽然官方 Rust WebAssembly 工作组的博客已经停更快两年了,但是实际他们的工作还是在继续的。

最近的更新:

  • 升级 webpack 示例中 npm 包的依赖版本
  • 添加 no_deref 属性以选择不为导入的类型生成 deref 实现
  • 通过非零初始化缓冲区来提高 TypedArray::to_vec 性能
  • 升级最新的WebGPU WebIDL

https://github.com/rustwasm/wasm-bindgen

Rustdoc 中源码页面中支持跳转到定义功能

比如,在标准库文档中源码(src)页面看到类似下面的代码:


#![allow(unused)]
fn main() {
mod other_module;
struct Foo;
fn bar() {}

fn x<T: other_module::Trait>(f: Foo, g: other_module::Whatever, t: &T) {
    let f: Foo = Foo;
    bar();
    f.some_method();
}
}

其中,other_module::Trait, Foo, other_module::Whatever, barsome_method 都会出现链接,点击链接可以跳转到其定义页面。

如果有来自另一个crate 的类型,它会链接到它的文档页面而不是它的定义(但你可以点击 [src])。

https://github.com/rust-lang/rust/pull/84176

[CVE-2021-29922] Rust 标准库net 模块漏洞: 前导零改变 IP 地址

本周,在DEF CON上,安全研究人员Cheng Xu、Victor Viale、Sick Codes、Nick Sahler、Kelly Kaoudis、opennota和John Jackson披露了Go和Rust语言的net模块的一个缺陷。CVE-2021-29922(针对Rust) 和 CVE-2021-29923(针对Golang)。

IP地址可以用多种格式表示,包括十六进制和整数,不过最常见的IPv4地址是用十进制格式表示的。

例如,BleepingComputer的IPv4地址以十进制格式表示为104.20.59.209,但同样的地址也可以以八进制格式表示为:0150.0024.0073.0321

假设你得到一个十进制格式的IP地址,127.0.0.1,这被广泛理解为本地回环地址或localhost

如果你在它前面加上一个0,应用程序是否仍应将0127.0.0.1解析为127.0.0.1或其他什么?在Chrome的地址栏中输入0127.0.0.1,浏览器会将其视为八进制格式的IP。在按下回车键或返回键时,该IP实际上变成了十进制的87.0.0.1,这就是大多数应用程序应该处理这种模糊的IP地址的方式。

根据IETF的原始规范,IPv4地址的部分内容如果前缀为 "0",可以解释为八进制。

但是Go和Rust中的net模块都忽略了这一点,将部分地址视为十进制。

rust 1.52.1 std::net 及以下版本中IP地址输入未按八进制处理而导致不确定的 SSRF 和 RFI 漏洞。

例如,攻击者向依赖std::net::IpAddr的网络程序提交IP地址,可以通过输入位组的输入数据引起 SSRF;

如果位组(octet)是3位,攻击者可以提交可利用的IP地址,最小可利用的位组是08(拒绝服务),最大可利用的位组是099(拒绝服务)。

例如,攻击者可以提交010.8.8.8,也就是8.8.8.8(RFI),然而std::net::IpAddr将计算为10.8.8.8。同样,攻击者可以输入127.0.026.1,这实际上是127.0.22.1,但Rust将其计算为127.0.26.1。

  • SSRF是Server-side Request Forge的缩写,中文翻译为服务端请求伪造。
  • RFI 是Remote File Inclusion的缩写,客户端可控制网页包含远程文件。

受影响 Rust 版本: 1.52.1 及以下。

该漏洞已于三月份修复: https://github.com/rust-lang/rust/pull/83652

PoC 代码:

// ##!/usr/bin/env rustc
// # Authors:      https://twitter.com/sickcodes, https://twitter.com/kaoudis
// # License:      GPLv3+

use std::net::IpAddr;

fn main() {
  let addr = "127.026.0.1".parse::<IpAddr>().unwrap();
  println!("{}", addr.to_string());
  let addr1 = "127.0.026.1".parse::<IpAddr>().unwrap();
  println!("{}", addr1.to_string());
  let addr2 = "127.0.0.093".parse::<IpAddr>().unwrap();
  println!("{}", addr2.to_string());
  let addr3 = "099.0.0.01".parse::<IpAddr>().unwrap();
  println!("{}", addr3.to_string());
}

// $ rustc -o main main.rs
// $ ./main
// 127.26.0.1
// 127.0.26.1
// 127.0.0.93
// 99.0.0.1

相关:

黑客大会 defconf29 演讲之一 : 烂代码、老化标准和 IPv4 解析

针对 Rust / Go 前导零改变 IP 地址相关漏洞的演讲

https://www.youtube.com/watch?v=_o1RPJAe4kU

Deprecate llvm_asm! 的 pr 已经被合并了

过期 llvm_asm!,而用新的 asm!来代替。

https://github.com/rust-lang/rust/pull/87590#issuecomment-899111280

Rust IO Safety RFC 已经被实现

这是为操作系统资源增加所有权语义的第一步!

https://github.com/rust-lang/rust/pull/87329

Gcc Rust 月报

注意:GCC Rust(gccrs) 是给 gcc 增加 Rust 前端,而现在 Rust 后端正在准备合并的是 rustc_codegen_gcc,是给 Rust 增加 gcc 后端。

两者目标不同。

Rust 1.56 中即将发布增强版的错误消息格式

cargo 构建时如果发生错误将用类似于 diff 的方式显示输出建议帮助开发者修改程序。

https://github.com/rust-lang/rust/pull/86532

社区热点

编辑:张汉东


Rust 毫无意外地,连续六年成为了 Stackoverflow 榜单最受喜爱语言。

https://insights.stackoverflow.com/survey/2021#technology-most-loved-dreaded-and-wanted

RustChinaConf 2021 正式启动

好消息,2021 Rust China Conf 要来了!

本次大会初步定于2021年10月16、17日在上海市徐汇区云锦路701号西岸智塔AI Tower 45层 举行。

详情:RustChinaConf 2021 正式启动

RustChinaConf 2021 议题开放申请https://shimo.im/forms/xqpwpdXw6YxrJTj9/fill

UltraOS获第一届全国大学生操作系统比赛一等奖

2021第一届全国大学生操作系统比赛的比赛结果公布了,哈工大(深圳)的李程浩,宫浩辰,任翔宇获得了内核实现赛道的一等奖,指导教师为夏文老师和江仲鸣老师。 他们用Rust语言设计实现了基于RISC-V64的多核操作系统UltraOS,支持qemu和k210平台运行,在比赛过程的多个评比阶段排名第一。 项目网址 [https://gitlab.eduxiji.net/ultrateam/ultraos https://github.com/xiyurain/UltraOS](https://gitlab.eduxiji.net/ultrateam/ultraos https://github.com/xiyurain/UltraOS) 项目使用GPL3.0协议,欢迎开发者使用该项目进行学习。项目使用了洛佳等开发者的RustSBI 2021.03.26版本,以及吴一凡等开发者的rCoreTutorial-v3 2021.03.26版本(清华大学计算机系2021 OS课实验指导教程)。 这也说明了,基于开源社区的模式,采用Rust开发操作系统等系统软件是Rust语言的一种发展趋势。

Rust Search Extension 1.3 发布

小编的 Rust 插件发布新版本半个多月了,一直忘了发帖了,今天补上。欢迎大家体验!

更新说明:https://github.com/huhu/rust-search-extension/wiki/V1.3.0-Release-Candidate-(zh_CN)

https://rust.extension.sh/changelog

RIIR (Rewrite it in Rust ) Rome !

其官网写道:

Rome 是一家工具开发公司,为 JavaScript 和 Web 开发构建了第一个一体化工具。 我们希望让产品开发人员专注于构建他们的产品,而不是为其提供工具和配置。 我们已收到 450 万美元的资金,并致力于开源社区。我们正在壮大我们的团队,以从头开始用 Rust 重写 Roma,并为我们未来的所有工作奠定基础。

Bevy 发布一周年

其中作者如图写道。

在微软上班一个月目测 至少 1w6 美刀/月, 但是这哥们现在准备考虑以开源为职业目标了,4000刀/月甚至可以让他存一点钱了,这就是真爱。

https://bevyengine.org/news/bevys-first-birthday/

Next.js 11.1 最新版发布

亮点:使用 基于 Rust 的工具 swc 替代了 babel和terser。

将 swc 作者也招募到团队里了。

https://nextjs.org/blog/next-11-1

第一个非官方的 Rust 编程语言游戏Jam

这个Jam更侧重于使用 Rust 而不是其他任何东西。 这意味着您不受设计、音乐或图形的限制,只要您使用 Rust 来制作它!

https://itch.io/jam/rusty-jam

Rust 云原生组织(github 组织)成立

[Rust 云原生组织(https://rust-cloud-native.github.io/)]成立,用于推动 Rust 在云原生领域的生态发展。

其实 Rust 生态中已经有一些有关云原生的项目了:

  • Bottlerocket OS
  • Cloud Native Rust Day
  • Firecracker
  • kube-rs
  • Kubewarden
  • Krustlet
  • Rust Foundation
  • TiKV

这个组织的存在是为了使Rust在 "云 "中的使用成为可能,其重点是基础设施软件和相关组件。你可能对 "云 "技术很熟悉,比如Docker和Kubernetes。该组织的存在是为了促进存在于同一技术领域的项目。

这份博客通告,只是一种呼吁。由于目前还没有一个关于云原生Rust的中心位置,组织者主要是想启动一些东西。

详情请参考:https://nickgerace.dev/post/launching-rust-cloud-native

wgpu v0.10 发布,采用纯 Rust 写成

wgpu 是安全和可移植的 GPU 抽象,使用 Rust 写成,并实现了 WebGPU API 。

新版本值得期待的特性包括更为扁平化的 GPU 抽象和小巧的代码库,以及完全用 Rust 技术栈带来的便利,包括更方便的构建以及减少编译时的怪异行为。

Bevy 团队决定完全移除旧的 “抽象渲染层” ,转为使用 wgpu 作为核心的渲染抽象,以使得代码更精练、更简单,并且与广泛的 wgpu 生态更兼容。

https://gfx-rs.github.io/2021/08/18/release-0.10.html

Bevy 核心开发者的评论:

作为Bevy的首席开发者,这个版本让我感到兴奋,原因有很多。

  • 更小的(和更少的抽象)代码库意味着我们可以更容易地用我们现在和将来需要的功能来扩展wgpu。(例如:XR、光线追踪、暴露原始后端apis)。) 进入的门槛低了很多。
  • 这表明wgpu团队是可以接受我们的反馈的。在我们的 "新渲染器实验 "中,有一段时间我们正在考虑为我们的新渲染器采用其他 "更扁平 "的gpu抽象。他们立即考虑到了这一点,并启动了这个重新架构。还有其他一些人也有类似的反馈,所以我不能完全归功于此,但这个时机是完美的。
  • 纯粹的Rust栈意味着我们的构建更加简单。结合Naga的着色器反射和编译,我们可以在我们的管道中移除许多来自非rust依赖的 "构建怪癖"。Windows尤其受到这种构建怪异的影响,我很高兴不再需要处理这些问题。
  • 由于过去的几点,将wgpu作为我们的 "主要gpu抽象层 "的 "风险 "已经大大降低了。因此,我们决定完全删除我们以前的 "抽象渲染层",转而使用wgpu。这意味着wgpu不再是一个 "bevy_render后端"。它现在是bevy_render的核心gpu抽象。这使得我们的代码更小,更简单,并且与更广泛的wgpu生态系统更兼容。
  • 新的wgpu有一个正在进行的WebGL2后端。这将有望最终消除对第三方bevy_webgl2后端的需求(它为我们提供了良好的服务,但它有自己的怪癖和复杂性)。

Apache Arrow DataFusion 5.0.0 版本

Apache Arrow 团队很高兴地宣布 DataFusion 5.0.0 版本。这涵盖了 4 个月的开发工作,包括来自以下 31 位不同贡献者的 211 次提交。

https://arrow.apache.org/blog/2021/08/18/datafusion-5.0.0/

如何组织大型 Rust workspace

在本文中,作者分享了自己组织大型Rust项目的经验。这并不权威, 只是作者通过反复试验发现的一些技巧。

https://matklad.github.io/2021/08/22/large-rust-workspaces.html

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 30 期 - 一起来围观 axum

时间: 2021/08/03 20:30-21:30

主讲人:Mike Tang

题目:一起来围观 Axum

内容: 就刚不久,tokio 团队推出了 Web 开发框架 axum,引起社区轰动。我们今晚一起来感受一下这个新框架。

参考资料:

  1. https://docs.rs/axum/0.2.3/axum/

【回放】


《Rust 唠嗑室》第 31 期 - Rust FFI 实践:分布式机器学习

时间: 2021/08/17 20:30-21:30

主讲人:Alice

题目:Rust FFI 实践:分布式机器学习

内容:本主题讲述如何通过 Rust 连接起深度学习已有的 Python, C++, CUDA 生态,构建大规模分布式训练工具。同时享受到 Rust 需要带来的高安全性、高开发效率。

参考资料:

  1. 源代码:https://github.com/BaguaSys/bagua

【回放】


🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第十三期 讨论主题:如何在面试中考察一个人 Rust 水平?

  1. 如何面试/或准备应聘一场 Rust 面试。
  2. 实际面试经验分享 (@ huangjj )
  3. vscode + ra 使用小技巧分享 。

【回放】

第十七期 | Axum 异步 Web 框架

分享者:张汉东

【讨论主题】

  1. axum 的中间件是直接使用 tower 的抽象,这样的好处就是: a. 使用了统一 的 Service 和 Layer 抽象标准,方便大家来繁荣生态 b. 复用 tokio / hyper/ tonic 生态

  2. axum 的路由机制是提供了简单的 DSL (链式调用)。路由是基于迭代和正则表达式来匹配的

  3. 也提供了方便的 提取器 ,只要实现 FromRequest 就是一个提取器,实现起来也非常方便。

【参考资料】

  1. 源代码:https://github.com/tokio-rs/axum

【回放】

第十八期 | 如何用 Rust 实现 RPC 框架

分享者:张汉东

【讨论主题】

  1. 教程作者 茌海 分享了 Lust 框架的思路。为什么要用rust呢?因为go 服务的性能已经到了存量优化的一个瓶颈,不得不考虑使用rust重新实现从根本上消除go系统所带来的问题。
  2. 大家一起跟随教程学习 如果构建一个 rpc 框架。 该教程虽然比较简短,但其实内容很丰富,并且融合了很多生产实践的思考。
    • 从第二章到第三章是介绍如果抽象消息和协议,
    • 第四章和第五章,介绍如何结合tokio codec 和 transport 来进一步整合消息和协议,以及使用tower 增加中间件支持,复用tokio生态里的工具。
    • 第六章 则介绍了如何使用nom来解析 thrift idl,以及通过过程宏来自动生成代码。
    • 第七和第八章则进一步基于tower来增加服务发现/负债均衡/自定义中间件等功能。麻雀虽小,五脏俱全,非常值得学习。

【参考资料】

https://github.com/mini-lust/tutorials

【回放】


🔥🔥🔥🔥 北京-杭州MeetUp-20210808 🔥🔥🔥🔥

视频集地址:https://www.bilibili.com/video/BV1tM4y157UP

大会 PPT 链接:https://pan.baidu.com/s/1FKVQRcTk5YfXvo9lOuKe2Q

提取码:2ivv

Rust+Tokio 在又拍云的实践

演讲者:夏功勋

Tokio Internals

演讲者:王福音

Rust 生态的性能调试器 pprof-rs

演讲者:杨可奥

参考资料:

  1. https://github.com/tikv/pprof-rs

深挖 move 语义

演讲者:丁绍顺

Rust Web 开发见解

演讲者:赵春霖

ARM 上的 Cloud-Hypervisor

演讲者:李枫

参考资料:

  1. http://en.wikipedia.org/wiki/
  2. http://www.slideshare.net/
  3. https://en.wikipedia.org/wiki/Comparison_of_application_virtualization_software
  4. https://www.sciencedirect.com/topics/computer-science/assisted-virtualization
  5. https://en.wikipedia.org/wiki/Systems_programming
  6. https://docs.01.org/clearlinux/latest/tutorials/kata.html
  7. https://www.redhat.com/sysadmin/selinux-kata-containers

cps 变化在 Rust 语言中尾递归应用及其性能分析

演讲者:常开颜


🔥🔥🔥🔥 20210807-Hello Web3.0 Meetup 全国行首次 online 分享会 🔥🔥🔥🔥

视频集地址:https://www.bilibili.com/video/BV1nq4y1Q73F

Web3.0 导论简述

演讲者:Mike

关于 Web3.0 的几种观点:

  1. 语义互联网
  2. 按请填写交互性来分代
  3. 价值互联网
  4. Coinbase 的观点

面向 Web3.0 的智能合约开发

演讲者:Robert

本次主题主要讲解了基于 Near 智能合约开发。为什么 Near 会成为智能合约开发选项呢?本次 Topic 中进一步分析。

参考资料:

  1. https://near.org/

Web3.0 应用链开发

演讲者:Lester

对于 Web3.0 的概述:

  1. Web3.0 一个更开放、更公平更安全的互联网新阶段。
  2. Web3.0 应用【社区拥有的加密协议】取代【公司拥有的平台】。
  3. Web3.0 应用落地,要有媲美 Web2.0 应用的用户体验和服务能力。

重点介绍了通过 Substrate 框架开发应用链。

Web3.0 隐私计算系统设计

演讲者:周顺帆

Phala Network 是尝试给区块链提供隐私计算的网络,通过什么样的技术手段和系统设计保证合约隐私、安全和整体性能。

Akash 开源云计算市场

演讲者:红军大叔

Akash 一个去中心化的亚马逊云或阿里云。当前云计算现状是由四大巨头(亚马逊云、谷歌云、阿里云、微软云)垄断,垄断最终伤害用户权益。Akash 借助区块链的特性和虚拟化容器技术提供去中心化版本的云计算。

Web3.0 存储和可信计算

演讲者:熊炜

开发过程是遇到很多问题,包括,矿工费贵、用户门槛高等,这些问题一直在困扰着开发着,去年参加星火组织的以太坊 MeetUp 产生新的想法,使用一条区块链构建一个新的应用。

Programming On Solana

演讲者:葛鑫

Francium 是一个杠杆收益平台。在 Solana 上开发合约,需要理解账户模型,Transaction 和 Instruction 之间的关系,最后是对 Program 的理解。

Applications of zkSNARK in Decentralized Finance

演讲者:Jamie

在 Web3.0 中,在 DeFi 领域如何使用零知识证明技术达到隐私保护目的。

本月招聘

DatenLord 招聘 Rust研发工程师

公司简介

DatenLord专注打造新一代云原生高性能存储平台,实现跨云、跨数据中心之间数据高速访问,从而极大提升存储系统的可扩展性,满足企业客户海量数据跨地域高速访问需求。

DatenLord专注底层硬核技术,涉及Linux内核、高性能网络、分布式系统、片上系统等多个底层技术领域。DatenLord通过深入优化软硬件IO通路实现存储系统的性能革命性提升。

DatenLord团队核心成员大部分来自Google、Microsoft、Alibaba,拥有深厚的软硬件技术积累和行业经验。

DatenLord着重打造具有很强技术氛围的团队文化,鼓励团队技术交流分享,勇攀技术高峰。欢迎对硬核技术有追求的同学加入DatenLord~


Rust研发工程师

岗位职责:高性能分布式存储系统研发

岗位要求:

  • 基本要求:

    • 熟悉Rust编程语言;
    • 熟悉Docker和K8S等容器相关工具;
    • 熟悉Linux操作系统;
    • 具有很强的学习能力,自我驱动以及团队合作意识。
  • 加分项:

    • 熟悉Rust异步编程优先;
    • 熟悉Linux内核IO相关功能,有ext4、FUSE、io_uring、eBPF相关经验优先;
    • 有Linux内核模块或驱动相关开发经验优先;
    • 有lock-free编程经验优先;
    • 熟悉分布式一致性协议Paxos或Raft优先;
    • 有DPDK或RDMA等高性能网络编程经验优先;
    • 有SPDK、Ceph、Key-Value等存储相关编程经验优先;
    • 有K8S的CNI或CSI接口编程经验优先。

工作地点:不限,远程办公

薪酬待遇:面议,业内很有竞争力的薪资

简历投递:dev@datenlord.io

非凸科技:算法开发工程师

非凸科技正成为国内金融市场智能投资交易平台的引领者,潜心打造了智能算法交易平台,在原有基础上全面升级到互联网新一代技术架构,结合机器学习等新兴技术,逐步完成各类交易算法的研发迭代,正持续为券商、量化私募等众多大型金融机构提供优质的算法服务。

岗位职责 :

1.设计并开发基于Rust的高性能,低时延算法交易系统; 2.设计并开发数据处理平台,监控运维平台; 3.设计并开发面向客户的高可用交易工具等; 4.设计并开发策略相关的回测平台。

岗位要求 :

1.本科及以上学历(985优先)。编程基础扎实,具 有良好的计算机理论基础; 2.熟练掌握Linux操作,性能分析,具备 Rust/C++/Java/Go丰富开发经验,熟悉常用的设计 模式,有分布式相关经验加分; 3.有研发高性能,低时延系统经验加分; 4.对技术充满热情,思考深入。自我驱动,能快速学 习新鲜事物。

联系方式:

  • 联系人:Schiele
  • 联系电话:150 0928 5657
  • 地址:上海市漕河泾开发区 凯科国际大厦

RustChinaConf 2021 正式启动

好消息,2021 Rust China Conf 要来了!

本次大会初步定于2021年10月16、17日在上海市徐汇区云锦路701号西岸智塔AI Tower 45层 举行。

大会介绍

2021 Rust China Conf 由 Rust 中文社区(RUSTCC)发起主办、知名企业和开源组织联合协办,是年度国内规模最大的 Rust 线下会议,深受 Rust 中文社区开发者与相关企业的喜爱与推崇。

本次大会为线下会议,将于10月16日-17日在上海举办,预计到场人数 350 人左右。大会面向企业技术负责人、一线工程师及社区个人开发者,特别是已在公司或个人项目中实践过 Rust 的开发者。

本次大会将同步开启线上直播,以方便无法亲临现场的广大程序员第一时间观看。并且大会结束后将上传相关录像,用于国内外的开发者长期回顾学习。

本次大会也获得了国内知名开发者社区、媒体与出版社的支持,如Infoq, CSDN、思否、掘金、电子工业出版社、人民邮电出版社等。

大会目标

本次大会,致力于成为中国 Rustaceans 面对面交流的盛宴,为国内的 Rust 开发者和企业提供一次充分的成果展示、技术分享、能力提升、行业资讯交流、企业人才储备建设的机会。

大会议题征集

范围

Rust 语言独有的特性,使得其适应面非常广泛,通过广泛搜集整个社区和企业中的议题,分类参考如下:

  • 库或框架
  • Rust 语言贡献
  • Rust 学术/ 教育/ 教程 / 书籍
  • Rust 社区
  • Rust 生产环境使用经验
  • 最佳实践
  • 系统编程
  • WebAssembly
  • 数据库
  • 游戏开发
  • 区块链
  • 嵌入式开发
  • 安全(Security)领域
  • 其他领域
  • workshop

议题提交截止时间

9月20日23:59

议题提交链接

https://shimo.im/forms/xqpwpdXw6YxrJTj9/fill

赞助通道

本次大会赞助权益文档:https://shimo.im/docs/Yg9H6kTVYtqCWRGH/ 《Rust 大会 2021赞助方案》,可复制链接后用石墨文档 App 或小程序打开。

欢迎各企业参与赞助。如有赞助需求,请联系:

  • Mike, vx: daogangtang,email: daogangtang@qq.com
  • 张汉东, vx: blackanger,email:blackang3r@163.com
  • 胡晓维, vx:h0923xw,email:vivian.xiage@gmail.com

志愿者招募通道

本次大会需要大量的志愿者,包括且不仅限于:线上文案、设计、网络宣传、现场指引、签到、资料装袋、周边发放、展台服务等。优先报幕上海地区的志愿者。志愿者拥有相关福利。

志愿者报名地址:https://www.wjx.top/vj/exW24IP.aspx

详情请联系志愿者事务负责人 高粱 vx:zucc2400。

售票通道

即将开启。估计本次大会的门票价格会与2020年Rust中国大会持平。

热忱欢迎广大 Rustaceans 们到场参会,也希望大家帮忙积极宣传,让大会的信息传达到更多开发者那里,推动国内 Rust 社区的发展!

社区有你的参与,我们前进更有动力!10月,上海,咱们不见不散!

去年盛况

最后,附上去年 2020 Rust China Conf 的赞助及相关支持情况。

conf

论文导读 | Rudra : 查找 Rust 生态系统中的内存安全 Bug

作者:张汉东


引子

美国佐治亚理工学院的系统软件安全实验室开源了Rudra ,用于分析和报告 Unsafe Rust 代码中潜在的内存安全和漏洞,为此他们也将在 2021 年第 28 届 ACM 操作系统原则研讨会论文集上发表相关论文,该论文目前在 Rudra 源码仓库中提供下载

说明:本篇文章不是论文的翻译,而是本人对该论文的梳理和总结。

概要

Rust 语言关注内存安全和性能,Rust 目前已经在传统的系统软件中得到了广泛的应用,如操作系统、嵌入式系统、网络框架、浏览器等,在这些领域,安全和性能都是不可或缺的。

Rust 内存安全的思想是在编译时验证内存的所有权,具体而言是验证内存分配对象的访问和生存期。Rust 编译器对值的共享和独占引用通过借用检查提供两个保证:

  1. 引用的生存期不能长于其拥有者变量的生存期。为了避免 use-after-free (UAF) 。
  2. 共享和独占引用不能同时存在,排除了并发读写同一个值的风险。

不幸的是,这些安全规则太过限制。在某些需要调用底层硬件系统,或需要获得更好性能时,需要暂时绕过安全规则。这些需求无法被 Safe Rust 解决,但是对于系统开发却是必不可少的,所以 Unsafe Rust 被引入。Unsafe Rust 意味着,编译器的安全检查职责被暂时委托给了程序员。

Unsafe Rust代码的健全性(soundness )对于整个程序的内存安全是至关重要的,因为大多数系统软件,如操作系统或标准库,都离不开它。

有些人可能比较天真地以为,Unsafe Rust 只要在审查源码的时候就可以排除它的风险。然而,问题的关键在于,健全性的推理是非常微妙的,且很容易出错,原因有三:

  1. 健全性的错误会顺道破坏Rust的安全边界,这意味着所有的外部代码,包括标准库都应该是健全的。
  2. Safe 和 Unsafe 的代码是相互依赖的。
  3. 编译器插入的所有不可见的代码路径都需要由程序员正确推理。

为了让 Rust 有一个健全性的基础,已经有了很多研究型项目,比如形式化类型系统和操作语义,验证其正确性,并且建立模型用于检查。这些都是非常重要的,但还不够实用,因为它没有覆盖到整个生态系统。另外还有一些动态方法,比如 Miri 和 Fuzz 模糊测试,但是这些方法不太容易被大规模使用,因为它需要大量的计算资源。

当前,Rust 语言正在变得流行,Unsafe Rust 的包也逐渐变多。因此,设计一个实用的检测内存安全的算法就很重要了。

这篇论文介绍了三种重要的Bug模式,并介绍了 Unsafe 代码,以及提供 Rudra 这样的工具。该论文作者的工作一共有三个贡献:

  1. 确定了三种 Unsafe Rust 中的 Bug 模式,并且设计了两种新的算法可以发现它们。
  2. 使用 Rudra 在Rust 生态系统中发现263个新的内存安全漏洞。这代表了自2016年以来RustSec中所有bug的41.4%。
  3. 开源。Rudra 是开源的,我们计划 贡献其核心算法到官方的Rust linter中。

Rudra

Rudra 用于分析和报告Unsafe Rust 代码中潜在的内存安全漏洞。 由于Unsafe 代码中的错误威胁到 Rust 安全保证的基础,Rudra 的主要重点是将我们的分析扩展到 Rust 包注册仓库(比如 crates.io)中托管的所有程序和库。Rudra 可以在 6.5 小时内扫描整个注册仓库(43k 包)并识别出 263 个以前未知的内存安全漏洞,提交 98 个 RustSec 公告和 74 个 CVE,占自 2016 年以来报告给 RustSec 的所有漏洞的 41.4%。

rudra

Rudra 发现的新漏洞很微妙,它们存在于Rust 专家的库中:两个在 std 库中,一个在官方 futures 库中,一个在 Rust 编译器 rustc 中。 Rudra 已经开源, 并计划将其算法集成到官方 Rust linter 中。

Rudra, 这个名称来自于 梵文,译为鲁特罗(或楼陀罗),印度神话中司风暴、狩猎、死亡和自然界之神。他在暴怒时会滥伤人畜;他又擅长以草药来给人治病。其名意为“狂吼”或“咆哮”(可能是飓风或暴风雨)。

RudraMiri 的区别 :

Rudra 是静态分析,无需执行即可分析源码。Miri 是解释器,需要执行代码。

两者可以结合使用。

关于 Unsafe Rust

因为 unsafe 关键字的存在,引出了一个有趣的 API 设计领域: 如何交流 API 的安全性。

通常有两种方法:

  1. 内部 Unsafe API 直接暴露给 API 用户,但是使用 unsafe 关键字来声明该 API 是不安全的,也需要添加安全边界的注释。
  2. 对 API 进行安全封装(安全抽象),即在内部使用断言来保证在越过安全边界时可以Panic,从而避免 UB 的产生。

第二种方法,即将 Unsafe 因素隐藏在安全 API 之下的安全抽象,已经成为 Rust 社区的一种约定俗成。

Safe 和 Unsafe 的分离,可以让我们区分出谁为安全漏洞负责。Safe Rust 意味着,无论如何都不可能导致未定义行为。换句话说,Safe API 的职责是,确保任何有效的输入不会破坏内部封装的 Unsafe 代码的行为预期。

这与C或C++形成了鲜明的对比,在C或C++中,用户的责任是正确遵守 API 的预期用法。

比如,在 libc 中的printf(),当它调用一个错误的指针而导致段错误的时候,没有人会指责它。然而这个问题却导致了一系列的内存安全问题:格式字符串漏洞(format-string vulnerability)。还记得前段时间 苹果手机因为加入一个经过特别构造名字的Wifi就变砖的漏洞否?

而在 Rust 中,println!() 就不应该也不可能导致一个段错误。此外,如果一个输入确实导致了段错误,那么它会被认为是 API 开发者的错误。

Rust 中内存安全Bug 的定义

在 Rust 中有两类 Unsafe 定义: Unsafe 函数 和 Unsafe 特质(trait)。

Unsafe 函数希望调用者在调用该函数时,可以确保其安全性。

Unsafe 特质则希望实现该 trait 的时候提供额外的语义保证。比如标准库里的 pub unsafe trait TrustedLen: Iterator { },该 trait 要求必须检查 Iterator::size_hint() 的上界,才能保证 TrustedLen 所表达的“可信的长度”语义。

该论文对 内存安全 Bug 提供了一个清晰的一致性的定义,而非 Rust 操作语义:

定义 1: 类型(Type)和值(Value)是以常规方式定义的。类型是值的集合。

定义2: 对于 类型 T, safe-value(T) 被定义为可以安全创建的值。例如 Rust 里的字符串是内部表示为字节的数组,但它在通过 安全 API 创建的时候只能包含 UTF-8 编码的值。

定义3:函数 F 是接收类型为 arg(F)的值,并返回一个类型为 ret(F) 的值。对于多个参数,我们将其看作元组。

定义4: 如果 在 safe-value(arg(F))集合中存在v (记为:∃𝑣 ∈ safe-value(𝑎𝑟𝑔(𝐹)) ),使得当调用 F(v)时触发违反内存安全的行为,或者返回一个不属于 safe-value(𝑟𝑒𝑡(𝐹)) 集合中的返回值𝑣𝑟𝑒𝑡 时(记为:𝑣𝑟𝑒𝑡 ∉ safe-value(𝑟𝑒𝑡(𝐹))),则 函数 F 有内存安全缺陷。

定义5: 对于一个泛型函数Λpred(Λ)被定义为满足Λ的类型谓词(指trait 限定)的类型集合。给定一个类型𝑇∈pred(Λ)resolve(Λ,𝑇)将泛型函数实例化为具体函数𝐹。

定义6: 如果一个泛型函数Λ可以被实例化为一个具有内存安全缺陷的函数,即,∃𝑇 ∈ pred(Λ),使得𝐹=resolve(Λ,𝑇)具有内存安全缺陷,则该泛型函数具有内存安全缺陷。

定义7:如果一个类型的Send实现不能跨越线程边界传输,那么该类型就有内存安全问题。

定义8: 如果一个类型的Sync实现不能通过别名指针(aliased pointer)并发地访问该类型,那么它就有内存安全问题。即,定义了一个非线程安全的方法,该方法接收&self

Unsafe Rust 中三类重要 Bug 模式

论文通过对已知漏洞进行定性分析,总结出 Unsafe Rust 中三类重要的 Bug 模式:

  1. Panic Safety (恐慌安全): 由恐慌导致的内存安全 Bug。
  2. Higher-order Safety Invariant(高阶安全不变性 ):由高阶类型没有给定安全保证而引发的 Bug。
  3. Propagating Send/Sync in Generic Types(泛型中Send/Sync传播):由泛型内部类型不正确的手工Send/Sync实现引起泛型 Send/Sync 约束不正确而引发的 Bug。

Panic Safety

这与其他编程语言(如C++)中的异常安全的概念类似。Rust 中类似其他编程语言中异常(Exception)的概念叫 恐慌(Panic)。恐慌一般在程序达到不可恢复的状态才用,当然在 Rust 中也可以对一些实现了 UnwindSafe trait 的类型捕获恐慌。

当 Panic 发生时,会引发栈回退(stack unwind),调用栈分配对象的析构函数,并将控制流转移给恐慌处理程序中。所以,当恐慌发生的时候,当前存活变量的析构函数将会被调用,从而导致一些内存安全问题,比如释放已经释放过的内存。

但是想要正确的推理在 Unsafe 代码中的恐慌安全,是非常困难且易于出错的。通常, 封装的Unsafe 代码可能会暂时绕过所有权检查,而且,安全封装的 API 在内部unsafe 代码的值返回之前,会根据安全边界条件确保它不会违反安全规则。但是,假如封装的Unsafe 代码发生了恐慌,则其外部安全检查可能不会执行。这很可能导致类似 C/C++ 中 未初始化(Uninitialized )或双重释放(Double Free)的内存不安全问题。

论文对此给出定义:

如果一个函数𝐹 Drop一个类型为𝑇的值𝑣,使得𝑣在Unwind 过程中 𝑣 ∉ safe-value(𝑇),并导致违反内存安全,则说明该函数存在恐慌性安全漏洞。

// 标准库 `String::retain()` 曝出的 CVE-2020-36317 Panic safety bug

pub fn retain<F>(&mut self, mut f: F)
where 
    F: FnMut(char) -> bool
{
    let len = self.len();
	let mut del_bytes = 0;
 	let mut idx = 0;
 
    unsafe { self.vec.set_len(0); }    // + 修复bug 的代码
 	while idx < len {
 		let ch = unsafe {
  			self.get_unchecked(idx..len).chars().next().unwrap()
 		};
 		let ch_len = ch.len_utf8();
 
 		// self is left in an inconsistent state if f() panics
        // 此处如果 f() 发生了恐慌,self 的长度就会不一致
 		if !f(ch) {
 			del_bytes += ch_len;
 		} else if del_bytes > 0 {
 			unsafe {
 				ptr::copy(self.vec.as_ptr().add(idx),
 				self.vec.as_mut_ptr().add(idx - del_bytes),
 				ch_len);
 			}
 		}
 		idx += ch_len; // point idx to the next char
 	}
 	unsafe { self.vec.set_len(len - del_bytes); } // + 修复bug 的代码 ,如果 while 里发生panic,则将返回长度设置为 0 
}

fn main(){
    // PoC: creates a non-utf-8 string in the unwinding path
    // 此处传入一个 非 UTF-8 编码字符串引发恐慌
    "0è0".to_string().retain(|_| {
        match the_number_of_invocation() {
            1 => false,
            2 => true,
            _ => panic!(),
        }
    });
}

Higher-order Safety Invariant

一个函数应该安全地执行所有安全的输入,包括参数数据类型、泛型类型参数以及外部传入的闭包。

换句话说,一个安全的函数不应该提供比 Rust 编译器提供的安全不变式更多的东西。所谓 安全不变式就是指 Rust 里的安全函数,在任何有效输入的情况下,都不应该发生任何未定义行为。

例如,Rust 里的 sort 函数,不应该触发任何未定义行为,哪怕用户提供的比较器不遵循全序关系,也不会发生段错误。但是 Cpp 中的排序函数,当用户提供一个不兼容当前的比较器的情况下,就会发生段错误。

Rust 为 高阶类型提供的唯一安全不变式是 类型签名的正确性。然而常见的错误是,对调用者提供的函数在以下方面产生了不正确的假设:

  1. 逻辑一致性:比如,sort函数遵循全序关系。
  2. 纯洁性:对相同的输入总是返回相同的输出。
  3. 语义约束:只针对参数,因为它可能包含未初始化字节。

对于 Unsafe 代码,必须自己检查这些属性,或者指定正确的约束(例如,用Unafe 的特质)让调用者义务检查这些属性。

在 Rust 类型系统下,执行高阶类型的安全不变式是很困难的。比如,将一个未初始化的缓冲区传给一个调用者提供的 Read 实现。

不幸的是,许多Rust程序员为调用者提供的函数提供一个未初始化的缓冲区来优化性能,而没有意识到其固有的不健全性。由于其普遍性和微妙性,Rust标准库现在明确指出,用一个未初始化的缓冲区调用read() 本身就是不健全的行为。

论文对此给出定义:

高阶不变性bug是指函数中的内存安全bug,它是由假设保证高阶不变性引起的,而 Rust 的类型系统对调用者提供的代码没有保证。


#![allow(unused)]
fn main() {
1 // CVE-2020-36323: a higher-order invariant bug in join()
2 fn join_generic_copy<B, T, S>(slice: &[S], sep: &[T]) -> Vec<T> 
3 where T: Copy, B: AsRef<[T]> + ?Sized, S: Borrow<B>
4 {
5     let mut iter = slice.iter();
6
7 	  // `slice`is converted for the first time
8     // during the buffer size calculation.
9     let len = ...;  // `slice` 在这里第一次被转换	
10    let mut result = Vec::with_capacity(len);
11    ...
12    unsafe {
13        let pos = result.len();
14        let target = result.get_unchecked_mut(pos..len);
15
16        // `slice`is converted for the second time in macro
17        // while copying the rest of the components.
18        spezialize_for_lengths!(sep, target, iter; // `slice` 第二次被转换
19        0, 1, 2, 3, 4);
20
21        // Indicate that the vector is initialized
22        result.set_len(len);
23    }
24    result
25 }
26
27 // PoC: a benign join() can trigger a memory safety issue
28 impl Borrow<str> for InconsistentBorrow {
29     fn borrow(&self) -> &str {
30         if self.is_first_time() {
31             "123456"
32         } else {
33             "0"
34         }
35     }
36 }
37
38 let arr: [InconsistentBorrow; 3] = Default::default();
39 arr.join("-");
}

该代码是为 Borrow<str>实现 join 方法内部调用的一个函数 join_generic_copy的展示。 在 join_generic_copy 内部,会对 slice 进行两次转换,而在 spezialize_for_lengths! 宏内部,调用了.borrow()方法,如果第二次转换和第一次不一样,而会返回一个未初始化字节的字符串。

这里, Borrow<B> 是高阶类型,它内部 borrow 的一致性其实并没有保证,可能会返回不同的slice,如果不做处理,很可能会暴露出未初始化的字节给调用者。

Propagating Send/Sync in Generic Types

当涉及泛型时候, Send/Sync 的规则会变得很复杂,如图:

rudra2

通常 Send/Sync 会由编译器自动实现,但是当开发者涉及 Unsafe 时,可能需要手动实现这俩 trait。手动实现 Send/Sync 想要正确很困难。一个不懂 Send/Sync 如何手动实现的开发者,很容易在代码中引入 Bug。

论文对此给出定义:

如果泛型在实现Send/Sync类型时,如果它对内部类型上指定了不正确的Send/Sync约束,那么泛型的Send/Sync约束就会变得不正确。这就是 泛型中 Send/Sync 传播引发的不安全 Bug。


#![allow(unused)]
fn main() {
1 // CVE-2020-35905: incorrect uses of Send/Sync on Rust's futures
2 pub struct MappedMutexGuard<'a, T: ?Sized, U: ?Sized> {
3     mutex: &'a Mutex<T>,
4     value: *mut U,
5     _marker: PhantomData<&'a mut U>, // + 修复代码
6 }
7
8 impl<'a, T: ?Sized> MutexGuard<'a, T> {
9     pub fn map<U: ?Sized, F>(this: Self, f: F)
10        -> MappedMutexGuard<'a, T, U>
11        where F: FnOnce(&mut T) -> &mut U {
12            let mutex = this.mutex;
13            let value = f(unsafe { &mut *this.mutex.value.get() });
14                  mem::forget(this);
15                  // MappedMutexGuard { mutex, value }
16                  MappedMutexGuard { mutex, value, _marker: PhantomData } //  + 修复代码
17    }
18 }
19
20 // unsafe impl<T: ?Sized + Send, U: ?Sized> Send
21 unsafe impl<T: ?Sized + Send, U: ?Sized + Send> Send // + 修复代码
22 for MappedMutexGuard<'_, T, U> {}
23 //unsafe impl<T: ?Sized + Sync, U: ?Sized> Sync
24 unsafe impl<T: ?Sized + Sync, U: ?Sized + Sync> Sync // + 修复代码
25 for MappedMutexGuard<'_, T, U> {}
26
27 // PoC: this safe Rust code allows race on reference counter
28 * MutexGuard::map(guard, |_| Box::leak(Box::new(Rc::new(true))));
}

Rust futures 库中发现的问题,错误的手工 Send/Sync实现 破坏了线程安全保证。

受影响的版本中,MappedMutexGuardSend/Sync实现只考虑了T上的差异,而MappedMutexGuard则取消了对U的引用。

MutexGuard::map()中使用的闭包返回与T无关的U时,这可能导致安全Rust代码中的数据竞争。

这个问题通过修正Send/Sync的实现,以及在MappedMutexGuard类型中添加一个PhantomData<&'a mut U>标记来告诉编译器,这个防护也是在U之上。

Rudra 的设计

整体设计图如下:

rudra3

Rudra 通过 HIR 来获取 crate 的代码结构(包括 trait定义、函数签名、Unsafe 块等),通过 MIR 来获取代码语义(数据流、控制流图、调用依赖等)。为什么不使用 LLVM IR 呢?因为在这个层面上 Rust 的抽象已经消失了。

然后通过内部的 Unsafe Dataflow Checker (UD) 来检查 Panic Safety BugHigher-order Invariant Bug,通过 Send/Sync Variance Checker(SV)来检查 Send/Sync Variance Bug。最终将结果按优先级汇总输出报告。

Unsafe Dataflow Checker (UD)Send/Sync Variance Checker(SV) 对应两套算法,具体可参加论文和代码。

关于安全性相关英文术语解释

英文中关于安全性有多个单词,比如 Security和Safety,但是中文只有“安全性”这一个词。所以这里需要说明一下:

  1. Security,通常指信息安全、网络安全之类。
  2. Safety,通常指功能性安全。

通常,会因为功能性的漏洞,而造成信息安全问题。

小结

该论文的最后一章,还包含了很多数据来证明 Rudra 的效果,以及 Rudra 和 Fuzz 测试、Miri 和其他 Rust 静态分析工具的比较等结果。

rudra4

上图是论文作者们使用 Rudra 对 Rust 实现的几个操作系统进行检查的结果,详细内容参加论文。

这篇论文非常值得一看,对于我们真正理解 Rust 的安全理念有所帮助。该论文也为 Rust 语言的安全状况提供了新的视角,也提供了一个静态检查工具,值得我们关注。

华为 | Rust语言中SIMD计算加速指令的使用

作者:李原


一. SIMD简介

SIMD全称Single Instruction Multiple Data,即单指令多数据流,是一种基于特定CPU指令集的计算性能优化技术。顾名思义,指的是在一条CPU指令的执行期间,可以同时进行多条数据的计算。使得在科学计算、多媒体应用等数据密集型运算场景下达到数倍乃至数十倍的性能提升。

二. Rust语言官方SIMD加速库介绍

Rust语言是一种可以选择不同编译后端的编程语言,当然,目前业界绝大部分Rust项目都是以编译器默认选择的llvm作为编译后端。值得注意的是,llvm本身已经集成了绝大部分主流CPU架构的包含SIMD在内的各类指令集。这为Rust语言使用SIMD提供了天然的方便,因为Rust可以在编译器甚至用户代码中以静态链接的形式直接使用llvm提供的SIMD函数接口,而不用像Go、C等语言一样由开发者自己编写汇编代码。

Rust语言在官方github项目群中提供了两个simd加速库:stdarchstdsimd。这里给出了它们的github仓库地址。stdarch以模块化的方式为每种不同的CPU架构提供了各自的专用simd加速指令集,比如x86架构的AVX、AVX512、SSE、SSE2等指令集;ARM/Aarch64平台的NEON、SVE指令集;以及RISCV、WASM等架构的simd指令集等,使用时用户必须对自己所使用的CPU架构及该架构所提供的simd指令集功能有所了解。而stdsimd则是提供了大量各平台通用的抽象simd函数接口,如向量加减乘除、位移、类型转换等。读者不必对自己所使用的硬件架构以及指令集有所了解,使用起来相对更方便,但在使用功能上会有所限制。这两个项目在功能设计的出发点上有所不同,而且各自项目的维护者也有所不同。下面会具体介绍他们的设计以及使用。

三. 多架构通用加速库stdsimd的使用

stdsimd提供了各平台通用的simd加速接口,其实现依赖于Rust编译器所提供的platform-intrinsic接口集合。该接口集合又是对llvm所提供的各平台指令集的封装,因此它们之间的关系应该是:

stdsimd —封装→ Rust编译器 —封装→ llvm

stdsimd项目因功能尚未集成完全,目前尚未集成到Rust标准库中,读者可以通过将源码克隆到自己的项目中进行使用,或者使用stdsimd的社区版本packed_simd(在Cargo.toml文件中加入packed_simd = { version = "0.3.4", package = "packed_simd_2" })。下面的使用也基于社区版本进行介绍。

packed_simd项目提供了一系列向量数据类型Simd<[T; N]>,即由N个T元素组成的向量,并为他们提供了简单易懂的的类型别名,比如f32x4类型,就代表着Simd<[f32; 4]>。packed_simd所提供的SIMD加速功能,也都是基于这种向量数据类型所实现的。

packed_simd一共提供了以下几种SIMD数据类型(element_width代表数据的大小和数量,比如32x4、64x8):

  • i{element_width}: 有符号整数类型
  • u{element_width}: 无符号整数类型
  • f{element_width}: 浮点数类型
  • m{element_width}: bool类型
  • *{const,mut} T: 可变或不可变SIMD类型指针

默认情况下,对向量结构的操作是“垂直”的,即它们独立于其他向量应用于每个向量通道,比如下面这个例子:


#![allow(unused)]
fn main() {
let a = i32x4::new(1, 2, 3, 4);
let b = i32x4::new(5, 6, 7, 8);
assert_eq!(a + b, i32x4::new(6, 8, 10, 12));
}

该例子声明了两个i32x4向量,并通过加法运算符重载计算出了它们的和。另一方面,“水平”的操作当然也是有提供的,比如下面的例子:


#![allow(unused)]
fn main() {
assert_eq!(a.wrapping_sum(), 10);
}

总体上,"垂直"的操作总是最快的,而"水平"的操作相对会较慢。也就是说,在计算一个数组的和时,最快的方法是使用多次"垂直"运算加一次"水平"运算,如下所示:


#![allow(unused)]
fn main() {
fn reduce(x: &[i32]) -> i32 {
    assert!(x.len() % 4 == 0);
    let mut sum = i32x4::splat(0); // [0, 0, 0, 0]
    for i in (0..x.len()).step_by(4) {
        sum += i32x4::from_slice_unaligned(&x[i..]);
    }
    sum.wrapping_sum()
}

let x = [0, 1, 2, 3, 4, 5, 6, 7];
assert_eq!(reduce(&x), 28);
}

下面再给出一些常见的用例:


#![allow(unused)]
fn main() {
// 生成元素全为0的i32x4向量:
let a = i32x4::splat(0);

// 由数组中的前4个元素生成i32x4向量:
let mut arr = [0, 0, 0, 1, 2, 3, 4, 5];
let b = i32x4::from_slice_unaligned(&arr);

// 读取向量中的元素:
assert_eq!(b.extract(3), 1);

// 替换向量中对应位置的元素:
let a = a.replace(3, 1);
assert_eq!(a, b);

// 将向量写入数组中:
let a = a.replace(2, 1);
a.write_to_slice_unaligned(&mut arr[4..]);
assert_eq!(arr, [0, 0, 0, 1, 0, 0, 1, 1]);
}

除此之外,packed_simd还提供了向量的条件运算,比如下面的代码表示根据m中元素是否为真进行向量中对应元素的+1操作:


#![allow(unused)]
fn main() {
let a = i32x4::new(1, 1, 2, 2);

// 将a中的前两个元素进行+1操作.
let m = m16x4::new(true, true, false, false);
let a = m.select(a + 1, a);
assert_eq!(a, i32x4::splat(2));
}

由此可以衍生出更灵活的使用方法,比如由两个向量中各个位置的较大值组成新的向量


#![allow(unused)]
fn main() {
let a = i32x4::new(1, 1, 3, 3);
let b = i32x4::new(2, 2, 0, 0);

// ge: 大于等于计算,生成bool元素类型的向量
let m = a.ge(i32x4::splat(2));

if m.any() {
    // 根据m中的结果选择a或b中的元素
    let d = m.select(a, b);
    assert_eq!(d, i32x4::new(2, 2, 3, 3));
}
}

以上就是stdsimd(packed_simd)的基础使用方法,该项目可以让开发者方便地通过SIMD类型数据结构享受到SIMD加速的效果。但该项目也存在一定的缺陷,比如用户必须手动选择向量的长度。因为大多数CPU架构都至少提供了128位SIMD指令集,因此选择128位的向量长度总是合理的。但当CPU提供了更高级的SIMD指令集(比如AVX512)时,选择更长的指令集会获得更好的效果。因此当开发者拥有一定的CPU架构及SIMD相关的知识储备时,使用起来会有事半功倍的效果。

四. 专用指令加速库stdarch的使用

stdarch已经集成到了Rust语言的标准库中,可以在代码中通过use std::arch语句进行使用。这里要注意的是,目前只有x86_64以及x86两种架构已经发布了stable版本,因此其他结构比如arm、aarch64等必须将Rust编译器切换到nightly版本(在命令行输入rustup default nightly命令)方可编译及使用。因此下面主要使用stable版本可用的x86_64(x86)为例进行介绍。

stdarch以静态链接的方式封装了诸多llvm所提供的SIMD指令集,并以模块的方式提供了各种主流架构下的SIMD指令集,如下所示。每种架构下可用的SIMD函数接口可以点进相应的链接进行查阅。

相比于stdsimd,stdarch对开发者的CPU架构知识储备有着更高的要求。因为stdarch对每个主流的CPU架构都提供了上千个不同功能的SIMD指令,开发者需要手动识别哪一条指令是自己最需要的。

比如下面这个例子:


#![allow(unused)]
fn main() {
#[cfg(
    all(
        any(target_arch = "x86", target_arch = "x86_64"),
        target_feature = "avx2"
    )
)]
fn foo() {
    #[cfg(target_arch = "x86")]
    use std::arch::x86::_mm256_add_epi64;
    #[cfg(target_arch = "x86_64")]
    use std::arch::x86_64::_mm256_add_epi64;

    unsafe {
        _mm256_add_epi64(...);
    }
}
}

这段代码首先使用Rust语言原生提供的CPU特征检测功能,即target_arch属性宏,来检测开发环境是否为x86_64或者x86,再使用target_feature属性宏检测avx2指令集是否可用。在以上条件都满足时,才会编译下面的foo函数。而在foo函数内部,则会根据CPU为x86_64还是x86架构选择相应的simd指令。

或者开发者可以使用动态的特征检测语句is_x86_feature_detected!,如下所示:


#![allow(unused)]
fn main() {
fn foo() {
    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
    {
        if is_x86_feature_detected!("avx2") {
            return unsafe { foo_avx2() };
        }
    }

    // return without using AVX2
}

#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
#[target_feature(enable = "avx2")]
unsafe fn foo_avx2() {
    #[cfg(target_arch = "x86")]
    use std::arch::x86::_mm256_add_epi64;
    #[cfg(target_arch = "x86_64")]
    use std::arch::x86_64::_mm256_add_epi64;

    _mm256_add_epi64(...);
}
}

stdarch本身存在着大量类似的条件编译代码。因此相应的指令集模块只有在满足环境的需求时才可用。比如x86_64架构下可以使用use std::arch::x86_64语句,却不能使用use std::arch::x86_64或者use std::arch::arm语句。

下面通过一个具体的例子,即16进制编码函数的simd实现来介绍stdarch的具体使用。这个例子中主要使用了x86及x86_64下的SSE4.1指令集。

具体的代码实现如下,其中使用到的各类SIMD指令及用途都可以在注释或上文中对应模块(x86或x86_64)的链接文档中进行查阅。

fn main() {
    let mut dst = [0; 32];
    hex_encode(b"\x01\x02\x03", &mut dst);
    assert_eq!(&dst[..6], b"010203");

    let mut src = [0; 16];
    for i in 0..16 {
        src[i] = (i + 1) as u8;
    }
    hex_encode(&src, &mut dst);
    assert_eq!(&dst, b"0102030405060708090a0b0c0d0e0f10");
}

pub fn hex_encode(src: &[u8], dst: &mut [u8]) {
    let len = src.len().checked_mul(2).unwrap();
    assert!(dst.len() >= len);

    #[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
    {
        if is_x86_feature_detected!("sse4.1") {
            return unsafe { hex_encode_sse41(src, dst) };
        }
    }

    hex_encode_fallback(src, dst)
}

#[target_feature(enable = "sse4.1")]
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
unsafe fn hex_encode_sse41(mut src: &[u8], dst: &mut [u8]) {
    #[cfg(target_arch = "x86")]
    use std::arch::x86::*;
    #[cfg(target_arch = "x86_64")]
    use std::arch::x86_64::*;

    // 生成包含16个int8类型的向量,并将全部值设为字符'0'的ascii编号
    let ascii_zero = _mm_set1_epi8(b'0' as i8);
    // 生成包含16个int8类型的向量,并将全部值设为整数9
    let nines = _mm_set1_epi8(9);
    // 生成包含16个int8类型的向量,并将全部值设为字符'a'的ascii编号减去10
    let ascii_a = _mm_set1_epi8((b'a' - 9 - 1) as i8);
    // 生成包含16个int8类型的向量,并将全部值设为二进制数00001111
    let and4bits = _mm_set1_epi8(0xf);

    let mut i = 0_isize;
    while src.len() >= 16 {
        // 从指针中读取128位整数,组成一个128位的向量(可以转化为int8x16、int32x4等形式的向量)
        let invec = _mm_loadu_si128(src.as_ptr() as *const _);
		
        // 将该128位向量类型转化为int8x16类型的向量,并将其中每个元素和二进制数00001111进行与操作
        let masked1 = _mm_and_si128(invec, and4bits);
        // 将该128位向量类型转化为int8x16类型的向量,再将每个元素逻辑右移4位,随后将其中每个元素和二进制数00001111进行与操作
        let masked2 = _mm_and_si128(_mm_srli_epi64(invec, 4), and4bits);

        // 向量对应元素比较大小,获取向量中所有大于9的元素的位置
        let cmpmask1 = _mm_cmpgt_epi8(masked1, nines);
        let cmpmask2 = _mm_cmpgt_epi8(masked2, nines);
		
        // _mm_blendv_epi8表示生成一个新的向量,该向量中的元素是根据cmpmask1中对应位置是否为true选择ascii_zero或者ascii_a中的元素
        // _mm_add_epi8则表示向量对应位置元素相加,结果表示最终生成的十六进制编码的ascii编号
        let masked1 = _mm_add_epi8(
            masked1,
            _mm_blendv_epi8(ascii_zero, ascii_a, cmpmask1),
        );
        let masked2 = _mm_add_epi8(
            masked2,
            _mm_blendv_epi8(ascii_zero, ascii_a, cmpmask2),
        );

        // 生成一个新的向量,其中偶数位置元素(从0开始)来自于masked2,奇数位置元素来自于masked1
        // 该向量共有256位,所以将前128位放入res1中,后128位放入res2中
        let res1 = _mm_unpacklo_epi8(masked2, masked1);
        let res2 = _mm_unpackhi_epi8(masked2, masked1);

        // 将结果向量写入目标指针中
        _mm_storeu_si128(dst.as_mut_ptr().offset(i * 2) as *mut _, res1);
        _mm_storeu_si128(
            dst.as_mut_ptr().offset(i * 2 + 16) as *mut _,
            res2,
        );
        src = &src[16..];
        i += 16;
    }

    let i = i as usize;
    hex_encode_fallback(src, &mut dst[i * 2..]);
}

fn hex_encode_fallback(src: &[u8], dst: &mut [u8]) {
    fn hex(byte: u8) -> u8 {
        static TABLE: &[u8] = b"0123456789abcdef";
        TABLE[byte as usize]
    }

    for (byte, slots) in src.iter().zip(dst.chunks_mut(2)) {
        slots[0] = hex((*byte >> 4) & 0xf);
        slots[1] = hex(*byte & 0xf);
    }
}

此处通过这个具体的例子简单呈现了stdarch中SIMD加速指令的用法。可以看出,专用指令的使用相比于stdsimd来说对开发者的SIMD经验要求高上许多,但提供的功能和适用的场景也会更加完备。

以上就是Rust语言中官方SIMD加速库的简单使用介绍,希望能对各位读者的学习开发有所启示和帮助。

蚂蚁集团 | Trait Object 还是 Virtual Method Table

作者: 史泽宇

Trait object 是 Rust 动态分发的实现方式。在 2021 年 4 月发刊的 Rust Magazine 中,Jiacai Liu 同学在《Trait 使用及实现分析》文章中介绍了 Rust 中 Ad-hoc 多态的使用方式,包括静态分发与动态分发,并且对 trait object 中的对象安全问题以及原因做出了详细解释。 那么,使用 trait object 就是 Rust 中动态分发的终点吗?事实上我们发现,在很多 Rust 代码中使用了原始的虚表而不是 trait object,这其中的原因又是什么呢? 在本文中,会先简单介绍一下 trait object 与虚表,然后结合笔者挑选出的几个具有代表性的代码片段,讨论手动构造虚表而不使用 trait object 的优缺点。

简介

在 Rust 中使用 trait 实现多态有两种方式,静态分发或者动态分发。静态分发使用 trait bound 或者 impl trait 方式实现编译期单态化,根据类型参数生成对应的结构或者函数。动态分发使用 trait object 的方式实现,而由于 trait object 是动态大小类型,无法在编译期确定类型大小,所以一般会使用指向 trait object 的引用或者指针来操作 trait object。而指向 trait object 的引用或者指针本质上是一个胖指针,其中包含了指向擦除了具体类型的对象指针与虚函数表。所以每次调用 trait object 的方法时,需要解引用该胖指针,所以部分观点认为动态分发比静态分发开销更大,而相反的观点认为使用静态分发会导致编译时间变长,编译后二进制文件膨胀以及增加缓存失效概率等问题,所以具体使用哪种方式就见仁见智了。

trait object

然而,标准的 Trait 结构也有着一些缺陷,比如由于对象安全的要求,一些 trait 无法通过 trait object 的方式使用。所以我们在使用或者阅读一些 Rust crate 的时候会发现,这些库实现了自己的 trait object 结构,比如标准库的 RawWaker 结构,tokioRawTask 结构,bytesBytes 结构,anyhowErrorImpl 结构,以及关于类型擦除的内存分配器 1 的讨论。在接下来的内容里,我对其中几个实现进行了一些粗浅的分析,并结合一些已有的讨论 2,尝试总结它们的共同点。笔者水平有限,如有错漏,烦请指出。

Examples

std 中的 RawWaker {#RawWaker}

Rust 异步编程的核心是 Executor 与 Reactor,其中 Reactor 部分主要是 Waker 结构。查看源代码发现 Waker 结构仅仅包装了 RawWaker 结构。而 RawWaker 的结构与指向 trait object 的胖指针十分相似,包含了一个指向任意类型的数据指针 data 与自定义此 Waker 行为的虚函数指针表 vtable。当调用 Waker 的相关方法时,实际上会调用虚表中对应的函数,并将 data 作为函数的第一个参数传入。

/// A `RawWaker` allows the implementor of a task executor to create a [`Waker`]
/// which provides customized wakeup behavior.
///
/// [vtable]: https://en.wikipedia.org/wiki/Virtual_method_table
///
/// It consists of a data pointer and a [virtual function pointer table (vtable)][vtable]
/// that customizes the behavior of the `RawWaker`.
#[derive(PartialEq, Debug)]
#[stable(feature = "futures_api", since = "1.36.0")]
pub struct RawWaker {
    /// A data pointer, which can be used to store arbitrary data as required
    /// by the executor. This could be e.g. a type-erased pointer to an `Arc`
    /// that is associated with the task.
    /// The value of this field gets passed to all functions that are part of
    /// the vtable as the first parameter.
    data: *const (),
    /// Virtual function pointer table that customizes the behavior of this waker.
    vtable: &'static RawWakerVTable,
}

#[stable(feature = "futures_api", since = "1.36.0")]
#[derive(PartialEq, Copy, Clone, Debug)]
pub struct RawWakerVTable {
    clone: unsafe fn(*const ()) -> RawWaker,
    wake: unsafe fn(*const ()),
    wake_by_ref: unsafe fn(*const ()),
    drop: unsafe fn(*const ()),
}

在学习这部分代码的时候,我产生了一个疑问,为什么不使用 Rust 提供的 Trait 作为 Waker 的抽象,而是要手动实现一个类似 trait object 胖指针的复杂,危险且容易出错的 RawWaker。为了解开这个疑问,我尝试使用 Trait 来模拟 RawWaker 的功能。

pub trait RawWaker: Send + Sync {
    fn clone(&self) -> Box<dyn RawWaker>;

    fn wake(&self);
    fn wake_by_ref(&self);
}

impl Clone for Box<dyn RawWaker> {
    fn clone(&self) -> Self {
        RawWaker::clone(&**self)
    }
}

pub struct Waker {
    waker: Box<dyn RawWaker>,
}

根据虚表 RawWakerVTable 中要求的方法,我们可以写出一个简单的 RawWaker trait。这里遇到了几个问题,首先,RawWaker 要求实现 Clone,这样做的原因在 Saoirse Shipwreckt 3 的博客文章中有过简单的总结:

当事件源注册某个 future 将等待一个事件时,它必须存储 Waker,以便稍后调用 wake 方法。为了引入并发,能够同时等待多个事件是非常重要的,因此 Waker 不可能由单个事件源唯一拥有,所以 Waker 类型需要是可克隆的。

然而,Clone trait 本身不是对象安全的,因为它有着 Sized supertrait 限定。也就是说,如果我们使用 pub trait RawWaker: Clone 的写法,则该 trait 将无法作为 trait object 使用。所以在使用 trait 模拟的 RawWaker 中,我退而求其次的为 Box<dyn RawWaker> 实现了 Clone,并将具体的细节转移到了 RawWaker::clone 内,这样一来,每次调用 clone 方法都会构造一个新的 trait object,并且这些 trait object 会共享同一些数据。

其次,为了能够在多线程环境中使用,我要求 RawWaker 的 supertrait 为 Send + Sync,这样我们可以将其在多个线程间共享或者发送到某个线程中。

最后,为了通过指针使用 trait object,我们需要通过将该对象装箱在堆上。那么我们应该选用哪种智能指针呢?在上面的代码中,我使用了 Box 作为具体的指针类型,不使用 Arc 的原因是唤醒器中公共数据的共享方式应该由具体的实现决定。比如 RawWaker 的实现可以使用引用计数的方式跟踪另一个堆上的对象,或者全部指向静态全局的某个对象,比如:

use std::sync::Arc;

#[derive(Debug, Default)]
struct RcWakerInner {}

#[derive(Debug, Default)]
pub struct RcWaker {
    inner: Arc<RcWakerInner>,
}

impl RawWaker for RcWaker {
    fn clone(&self) -> Box<dyn RawWaker> {
        Box::new(RcWaker {
            inner: self.inner.clone(),
        })
    }

    fn wake(&self) {
        todo!()
    }

    fn wake_by_ref(&self) {
        todo!()
    }
}

static GLOBAL_RAW_WAKER: StaticWakerInner = StaticWakerInner {};

#[derive(Debug, Default)]
struct StaticWakerInner {}

#[derive(Debug)]
pub struct StaticWaker {
    global_raw_waker: &'static StaticWakerInner,
}

impl Default for StaticWaker {
    fn default() -> Self {
        Self {
            global_raw_waker: &GLOBAL_RAW_WAKER,
        }
    }
}

impl RawWaker for StaticWaker {
    fn clone(&self) -> Box<dyn RawWaker> {
        Box::new(StaticWaker {
            global_raw_waker: self.global_raw_waker,
        })
    }

    fn wake(&self) {
        todo!()
    }

    fn wake_by_ref(&self) {
        todo!()
    }
}

接下来我们将标准库的 RawWaker 与上述实现方式进行一些对比可以发现:

  • 由于我们发现 Box<dyn RawWaker> 首先经过了一层指针的包装,用来实现 trait object,而具体的 RawWaker 实现也很可能会使用指针来共享同一个对象。这样的不仅会在堆内存中占用额外的存储空间,产生许多小对象,也会由于解引用多级指针而带来额外的时间开销。
  • 标准库的 Waker 位于 core::task 模块下,而 BoxArc 等结构都位于 alloc 模块下,它们都是 std 的子集。在通常的 std 应用程序中,我们确实可以使用 Arc 等智能指针。但 Rust 不想在 no_std 的 futures 上妥协,所以我们必须使用特别的技巧来实现这种能力。

考虑到上面的这些原因,Rust 选择了使用数据指针与虚表的方式来实现高性能的动态分发。std::task::RawWaker 中的 data 指针既提供了类型擦除的能力,也实现了对象的共享,非常的灵活。

tokioasync-task 中的 RawTask {#RawTask}

tokioasync-task 的代码中,都将 RawTask 作为 Task 结构的具体实现。与刚才提到的 RawWaker 相似,RawTask 也通过虚表提供了类似于 trait object 的功能,然而,它们在内存布局上却有着不同的选择。下面以 tokio::runtime::raw::RawTask 为例。

#[repr(C)]
pub(crate) struct Header {
    pub(super) state: State,
    pub(super) owned: UnsafeCell<linked_list::Pointers<Header>>,
    pub(super) queue_next: UnsafeCell<Option<NonNull<Header>>>,

    /// Table of function pointers for executing actions on the task.
    pub(super) vtable: &'static Vtable,

    pub(super) owner_id: UnsafeCell<u64>,
    #[cfg(all(tokio_unstable, feature = "tracing"))]
    pub(super) id: Option<tracing::Id>,
}

/// Raw task handle
pub(super) struct RawTask {
    ptr: NonNull<Header>,
}

通过与 RawWaker 的结构相对比,我们发现 RawTask 将虚表部分移动到了数据指针 ptr 内。这么做的好处显而易见,RawTask 的内存结构更为紧凑,只需要占用一个指针的大小,缺点是多了一层解引用的开销。 所以,通过自定义类似 trait object 胖指针的结构,我们可以控制内存布局,使指针更瘦,或者更胖(比如 async_task::raw::RawTask)。

bytes 中的 Bytes {#Bytes}

bytes crate 提供用于处理字节的抽象,它包含了一种高效的字节缓冲结构与相关的 traits。其中 bytes::bytes::Bytes 是用于存储和操作连续内存切片的高效容器,这是通过允许多个 Bytes 对象指向相同的底层内存来实现的。Bytes 结构充当了接口的功能,本身占用四个 usize 的大小,主要包含了内联的 trait object。

pub struct Bytes {
    ptr: *const u8,
    len: usize,
    // inlined "trait object"
    data: AtomicPtr<()>,
    vtable: &'static Vtable,
}

它的虚表主要是 clone 方法,这允许 Bytes 的具体实现来定义具体的克隆或者共享策略。Bytes 的文档中举了两个例子

  • 对于 Bytes 引用常量内存(例如通过 Bytes::from_static() 创建)的实现,clone 实现将是空操作。
  • 对于 Bytes 指向引用计数共享存储(例如 Arc<[u8]>)的实现,将通过增加引用计数来实现共享。

可以看到,与 std::task::RawWaker 相似,Bytes 需要使用 clone 方法,并且具体的实现完全交给了实现方。如果选择 Trait 接口的方式,由于公共的数据部分已经是指针的形式,会引入额外的内存分配与解引用开销,感兴趣的同学可以尝试使用 Trait 的方式实现一下这两个例子,最终效果和上文中的 RawWaker 类似。而在内联了 trait object 之后,整个设计非常优雅,data 部分指向共享的内存,vtable 定义了如何进行 clone,其余字段作为独占的数据。

总结

Rust 提供了安全的抽象以避免产生安全问题或者错误。比如我们使用 RC 而不直接管理引用计数,使用 Box 而不是 malloc/free 直接管理内存分配。同样,dyn Trait 隐藏了复杂而又为危险的虚表实现,为我们提供了简单而又安全的动态分发。我们看到,上述手动实现虚表的代码中充斥着大量的 unsafe,稍有不慎,就会引入 bug。如果你的设计不能使用标准的 dyn Trait 结构来表达,那么你首先应该尝试重构你的程序,并参考以下理由来决定是否使用自定义的虚表。

  • 你想要为一类指针对象实现多态,并且无法忍受多级指针解引用造成的性能开销,参考 RawWakerBytes
  • 你想要自定义内存布局,比如像 C++ 中虚表一样紧凑的内存结构(虚表指针位于对象内),参考 RawTask
  • 你的 crate 需要在 no_std 环境中使用动态分发,参考 RawWaker
  • 或者,标准的 trait object 确实无法实现你的需求。

相关链接

1

https://github.com/rust-lang/wg-allocators/issues/33 2: https://users.rust-lang.org/t/dyn-trait-vs-data-vtable/36127/3 3: https://boats.gitlab.io/blog/post/wakers-i/

Datenlord | Rust 无锁编程之Crossbeam Epoch算法解析

作者:施继成


上次的文章 介绍了无锁数据结构的内存管理机制 EBR,该机制相较于其他的内存管理机制具有更高的执行效率。然而由于理念的复杂性,EBR 的实现并不容易,为每一个无锁数据结构从头实现 EBR 也务必要,因此很自然得大家会考虑将 EBR 的核心理念 epoch 抽取出来变成库,让大家能够复用。Crossbeam-epoch 是一套成熟的被大家广泛使用的 EBR 库,本文将从实现原理部分进行较为详细的解析,并且在此过程中进行。

Guard 只是最外层的壳

前文所述,大家一般在和Crossbeam-epoch 进行交互时仅仅使用 guard,如下所示:


#![allow(unused)]
fn main() {
/// delete data
{
    let guard = epoch::pin();
    guard.defer(move || mem.release());
}

/// get data for reading
{
    let guard = epoch::pin();
    let value = map.get(&key, &guard);
    /// ... Use the value
}
}

在读取数据的时候,guard扮演的角色仅仅是生命周期守护者,其确保获取出来的data引用(上述代码中的 value)生命周期一定不长于 guard,当 guard 被销毁时,value 也一定被销毁。删除数据过程中,guard扮演的角色要更复杂一些,其负责将销毁函数注册到 defer 延迟执行的队列中。至于该销毁函数何时被调用,则需要进一步深入了解其内部实现细节。

Pin 到底做了什么

epoch::pin() 到底做了什么,官方的代码注释中给出了解释,即将当前的 thread pin 住,以便将堆上数据的指针放到栈上。该解释其实只解释了上述读取数据本分的内容,其底层执行的操作如下:

  1. 将当前线程注册到 Global 收集器,该注册过程每个线程仅仅做一次。
  2. 获取当前全局Epoch并设置到当前线程,表示在pin有效的这段时间,当前线程属于哪个Epoch。
  3. 记录当前线程已经被pin的次数,当次数达到一定数量,尝试让 Global 收集器推进 Epoch 增长,回收垃圾。
  4. 增加 guard_count 计数,记录有多少guard被创建出来且还没有被销毁。

由于 pin() 可以反复调用,所以连续两次调用 epoch::pin() 也被允许。只是除了第一次调用,其他的调用都不会有任何作用,仅仅增加了guard_count 计数。具体实现参见 internal.rs 文件中 Local struct 的 pin 方法。

这里提及的 Global 收集器负责所有资源的回收工作,其从各个线程收集需要回收的垃圾,并在适当的时机进行回收。

Epoch 推进

Epoch Number 需要不停向前迭代,在迭代的过程中,垃圾回收器将隶属与老的 Epoch Number的可回收垃圾回收掉。每次 Global 收集器想要回收垃圾时都会尝试推进 Epoch Number,满足下列条件则 Epoch Number 向前推进成功:

  1. 所有注册的线程都处于当前的Epoch,即不存在线程处于上一个Epoch。

如果条件不满足则 Epoch 推进失败。具体实现请参见 internal.rs 文件中 Global struct 的 try_advance 方法。

垃圾回收机制

如果所有的线程都将待回收的垃圾注册到 Global 收集器,那么会出现非常巨大的竞争关系,线程越多操作越频繁则性能影响越大。为了解决共享数据结构造成的竞争,每个线程都会维护自己的垃圾回收队列,队列长度为62(神奇的magic number,猜测和CPU cache line 相关)。当队列被装满时,线程会将本地的队列中的数据统一移动到到 Global 收集器,放到收集器的垃圾链表当中。这里值得注意的是,放入链表的除了垃圾回收函数,还有该垃圾产生的 Epoch Number,该数字被用于决定是否可以回收对应的垃圾。

垃圾回收的触发点有两个,一个主动一个被动。主动的触发点为 Guard 的 flush 方法,调用该方法则会使得 Global 收集器尝试收集垃圾链表中的垃圾。被动的触发点为 Guard 的 pin 方法,即 pin 每被调用 128 次会触发一次垃圾回收。

满足下列条件的垃圾可以被回收:

  1. (Global Epoch Number) > ((Garbage Epoch Number) + 1),即垃圾对应的 Epoch 至少比当前 Epoch 早两个世代。

具体实现请参见 internal.rs 文件中 Global struct 的 collect 方法。

内部数据结构

其内部数据结构值得一提有两个,一个List,一个Queue。List 被用于管理注册的线程,Queue 被用于管理等待被回收的垃圾。这两个数据结构的共同点是被多个线程同时操作,为了高效的实现,crossbeam没有使用Lock来保护数据结构,而是实现了内部的无锁数据结构。

List

List 有新元素插入方法,插入的方法就是将新元素插入到 List 的 head 位置,实现中使用了 CAS 原子操作。在多线程同时进行插入时,同一时间只有一个能够成功,失败的操作会获得新的 header 值进行再次尝试。

List 删除操作并不真正移除元素,而是在该元素上进行标记,最后在某次 Iteration 过程中被真正删除,该删除操作也使用了 CAS 原子操作。如果有多个线程在尝试删除同一个元素,只有一个能够成功。如果在删除某个元素时发现其前一个元素也被标记为可被删除,则通知 Iteration 调用方需要重头再遍历一次。当然调用方可以根据情况重头遍历,还是留给其他人来处理。

具体实现请参见 list.rs 文件。

Queue

Queue 的插入和 List 类似,区别在于插入点为 tail。Queue 的删除操作叫做 pop,从 Queue 的 head 开始弹出数据,如果弹出数据出错则说明有其他线程也在进行弹出操作,那么需要重新使用获取 head 的位置再次尝试。

那些从 List 和 Queue 中删除的元素如何处理呢? crossbeam 采用了自举的方法,即也放入垃圾回收队列中,等待之后的某次操作触发垃圾回收。

总结

Crossbeam-epoch 给大家提供了一个极其方便的工具,将epoch的实现细节隐藏在库中,暴露给用户极其简单的接口,使得大家在实现无锁数据结构时更多关注数据结构的细节,将内存回收工作交给 Crossbeam-epoch 来处理即可。

DatenLord | Rust 实现K8S调度扩展

作者:潘政


背景介绍

K8S调度器(kube-scheduler)是K8S调度pod的控制器,它作为K8S核心组件运行。调度器基于一定的调度策略为pod找到合适的运行节点。

调度流程

K8S调度器调度一个pod时候主要分为三个阶段

  1. 过滤阶段:调用一系列predicate函数或者叫filter函数过滤掉不合适的节点,比如说某些节点上不满足pod声明的运行所需要的资源,如CPU,内存等资源,那么这些节点会在这个阶段被过滤掉。
  2. 打分阶段:调用一系列priority函数或者叫score函数给通过第一步过滤的节点打分排序。比如有三个节点通过了过滤阶段,当三个节点通过资源均衡函数的打分,那么最终剩余CPU,内存等资源更多的节点会获得更高的优先级评分。
  3. 调度阶段:把pod调度到选择优先级最高的节点上

调度过程如下图所示:

k8s-scheduler

调度算法

前文提到,K8S调度器会调用一系列filter函数和priority函数,这些函数是K8S内置的调度算法, K8S会根据默认的调度算法将pod调度到最合适的节点上。filter函数的目的是过滤掉不符合pod要求的节点,通常会有多个节点通过filter阶段,prioriry函数的目的就是在剩下的节点中选出最合适的节点。

我们来介绍一下K8S默认的调度策略有哪些。

  • filter策略:
    • PodFitsResources: 这条策略是过滤掉所剩资源不满足pod要求的节点
    • MatchNodeSelector: 这条策略是过滤掉不匹配pod nodeSelector要求的节点
    • PodFitsHostPorts: 这条策略是过滤掉pod声明的HostPort已经被占用的节点
    • 等等
  • priority策略:
    • BalancedResourceAllocation: 这条策略希望当pod被调度到一个节点后CPU,内存使用率是均衡的。
    • LeastRequestedPriority: 这条策略基于节点的空闲部分,空闲部分基本算法就是(节点总容量 - 已有pod的请求 - 将要被调度的pod请求)/ 节点总容量,有更多的空闲部分的节点会获得更高的优先级。
    • 等等

K8S提供了很多调度策略,我们在这里就不一一列举,K8S并不会使用所有的这些调度策略,它会默认使用部分调度策略,我们可以通过K8S配置文件来组合使用这些策略来达到最符合我们应用场景的调度策略。

调度扩展

前文中介绍到K8S会提供很多调度策略,它们很大程度上允许我们控制K8S调度器的行为,然而K8S提供的调度策略也有它的局限性,它只能根据通用的标准如CPU,内存的使用率来设计调度策略,当我们的应用需要更有针对性的调度策略时,比如说我们想把一个pod调度到网路带宽更高,延迟更低的节点上,这时候K8S提供的调度策略很难满足我们的需求。幸运的是K8S提供了K8S调度扩展机制。K8S调度扩展提供给用户扩展调度策略的方法。K8S调度扩展允许用户提供定制的predicate/filter函数和priority/score函数来扩展的调度策略。调度扩展的实现方式是用户提供一个web服务,并将web服务的url通过配置文件通知K8S,K8S会在调度pod的过滤阶段和打分阶段通过RESTful API的POST方法来调用用户提供的自定义扩展函数,从而达到扩展K8S调度策略的效果。具体架构如下图所示:

scheduler-extender

  • 在K8S过滤阶段,kube-scheculer发送 POST ${URL}/filter到scheduler-extender,请求中包含Json格式序列化的数据ExtenderArgs,scheduler-extender收到请求后,通过本地自定义的filter函数,产生filter结果ExtenderFilterResult并序列化成Json格式返回。
  • 在打分阶段,kube-scheculer发送 POST ${URL}/priority到scheduler-extender,请求中包含Json格式序列化的数据ExtenderArgs,scheduler-extender收到请求后,通过本地自定义的priority函数,产生score结果List<HostPriority>并序列化成Json格式返回。

数据结构

如上所示,K8S在和用户调度扩展交互的过程中涉及到三个数据结构,K8S使用go语言来定义它们,在通过http发送的时候会把它们序列化成Json格式。在Rust实现的调度扩展中需用将这些数据结构用Rust语言定义,并进行序列化和反序列化。

调度扩展请求

这个数据结构是K8S发送的调度扩展请求,filterpriority请求的结构是相同的。go语言定义如下:

type ExtenderArgs struct {
	// Pod being scheduled
	Pod *v1.Pod
	// List of candidate nodes where the pod can be scheduled; to be populated
	// only if Extender.NodeCacheCapable == false
	Nodes *v1.NodeList
	// List of candidate node names where the pod can be scheduled; to be
	// populated only if Extender.NodeCacheCapable == true
	NodeNames *[]string
}
  • Pod表示需要调度的pod
  • Nodes表示候选的节点列表
  • NodeNames表示候选的节点名字列表

这里需要注意的是NodesNodeNames只有一个会被填写,所以在Rust语言中需要将这两个域定义成Option,Rust的定义如下:

#[derive(Clone, Debug, Serialize, Deserialize)]
struct ExtenderArgs {
    /// Pod being scheduled
    pub Pod: Pod,
    /// List of candidate nodes where the pod can be scheduled; to be populated
    /// only if Extender.NodeCacheCapable == false
    pub Nodes: Option<NodeList>,
    /// List of candidate node names where the pod can be scheduled; to be
    /// populated only if Extender.NodeCacheCapable == true
    pub NodeNames: Option<Vec<String>>,
}

filter请求的应答

这个数据结构作为predicate请求的应答,它包含了通过过滤的节点列表,节点失败的原因和错误信息。go语言的定义如下:

type ExtenderFilterResult struct {
	// Filtered set of nodes where the pod can be scheduled; to be populated
	// only if Extender.NodeCacheCapable == false
	Nodes *v1.NodeList
	// Filtered set of nodes where the pod can be scheduled; to be populated
	// only if Extender.NodeCacheCapable == true
	NodeNames *[]string
	// Filtered out nodes where the pod can't be scheduled and the failure messages
	FailedNodes FailedNodesMap
	// Error message indicating failure
	Error string
}
  • Nodes表示通过filter函数的节点列表
  • NodeNames表示通过filter函数的节点名字列表
  • FailedNodes是一个hashmap,保存了没有通过filter函数的节点和没有通过的原因
  • Error表示filter函数过程中的失败原因

同样NodesNodesNames只有一个会被填写,也需要定义成Option,Rust的定义如下:


#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct ExtenderFilterResult {
    /// Filtered set of nodes where the pod can be scheduled; to be populated
    /// only if Extender.NodeCacheCapable == false
    pub Nodes: Option<NodeList>,
    /// Filtered set of nodes where the pod can be scheduled; to be populated
    /// only if Extender.NodeCacheCapable == true
    pub NodeNames: Option<Vec<String>>,
    /// Filtered out nodes where the pod can't be scheduled and the failure messages
    pub FailedNodes: HashMap<String, String>,
    /// Error message indicating failure
    pub Error: String,
}
}

priority请求的应答

priority请求的应答是一个HostPriority的列表,HostPrority包含节点的名字和节点的分数,go的定义如下:

type HostPriority struct {
	// Name of the host
	Host string
	// Score associated with the host
	Score int64
}
  • Host表示节点的名字
  • Score表示priority函数给该节点打的分数

对应的Rust定义如下:


#![allow(unused)]
fn main() {
#[derive(Clone, Debug, Serialize, Deserialize)]
struct HostPriority {
    /// Name of the host
    pub Host: String,
    /// Score associated with the host
    pub Score: i64,
}
}

在前面这三个数据结构中使用的类型比如说Pod, NodeListK8S-openapi这个库中有Rust版本的定义,可以通过在Cargo.toml中加入依赖来使用它

K8S-openapi = { version = "0.11.0", default-features = false, features = ["v1_19"] }

这个库是根据K8S openapi的定义自动生成的Rust定义,节省了大家把go定义的数据结构转成Rust定义的麻烦,但是这个库只包含core API部分的数据结构,ExtenderArgsExtenderFilterResultHostPriority属于extender的API,所以需要自己定义。在使用过程中发现了go语言定义的一个问题, 主要原因是[]string在go语言中,[]nil都是有效的值,序列化对应到Json中分别是[]null,一个域可以是null代表了它是optional的,所以类型是[]string的域需要加上+optional标记,相应的在Rust定义中也需要定义成Option。 详细讨论请参考issue, 目前这个问题在K8S中已经得到修复。

调度扩展Web服务

有了数据结构就需要实现一个web服务来处理K8S发起的请求,web服务Rust有丰富的库可以使用,这里使用的是一个轻量级同步http的库(tiny-http),具体filter函数和priority函数的实现与具体业务逻辑相关,这里就不具体展示,示意代码如下:


#![allow(unused)]
fn main() {
match *request.method() {
            Method::Post => match request.url() {
                "/filter" | "/prioritize" => {
                    let body = request.as_reader();
                    let args: ExtenderArgs = try_or_return_err!(
                        request,
                        serde_json::from_reader(body),
                        "failed to parse request".to_string()
                    );

                    let response = if request.url() == "/filter" {
                        info!("Receive filter");
                        // 处理`filter`请求
                        let result = self.filter(args);
                        try_or_return_err!(
                            request,
                            serde_json::to_string(&result),
                            "failed to serialize response".to_string()
                        )
                    } else {
                        info!("Receive prioritize");
                        // 处理`priority`请求
                        let result = Self::prioritize(&args);
                        try_or_return_err!(
                            request,
                            serde_json::to_string(&result),
                            "failed to serialize response".to_string()
                        )
                    };
                    Ok(request.respond(Response::from_string(response))?)
                }
                _ => Self::empty_400(request),
            },
            ...省略
        }

}

总结

通过本文的介绍我们对K8S的调度流程,K8S的调度算法,K8S调度扩展机制有了基本的了解。并且我们用Rust语言实现了K8S调度扩展,用Rust语言定义了K8S和调度扩展之间交互的数据结构,以及介绍了Rust定义中需要定义成Option的域以及相关需要注意的问题。

SecondState | AWS Lambda 中的 Rust 与 WebAssembly Serverless 函数

作者 Robby QiuSecond State 开发与 WasmEdge 贡献者


Serverless 函数为开发者节省了管理后端基础设施的大量麻烦。Serverless 还简化了开发过程,因为开发者只需关注业务本身的逻辑。本文是有关如何在 Amazon 的 serverless 计算平台 AWS Lambda 上编写和部署 WebAssembly serverless 函数的分步指南。在我们的演示中,WebAssembly 函数使用 WasmEdge runtime 执行。下图显示了我们解决方案的整体架构。

在本文的第一部分,我们将解释为什么 WebAssembly 是 serverless 函数极佳的 runtime。我们将 WebAssembly 字节码(通常由 Rust、C++ 编译得来)、高级编程语言(例如 Python 和 JavaScript)以及机器本机可执行文件(本机客户端或 NaCl)进行比较。然后,在第二部分,我们将演示两个 serverless 函数示例,都是用 Rust 编写并编译为 WebAssembly 进行部署。第一个示例展示了 WasmEdge 快速处理图像的能力,而第二个示例运行由 WasmEdge 的 TensorFlow 扩展提供支持的 AI 推理。

为什么选择 WebAssembly?

简单回答是 WebAssembly 快速、安全且可移植。那么具体是为什么呢?下面是详细回答。

WebAssembly vs. Python 和 JavaScript

DataDog 最近的一项调查发现大部分 AWS Lambda serverless 函数是用 JavaScript 和 Python 写的。 二者是世界上最流行的两种编程语言,所以这并不出人意料。

但是,众所周知,高级语言运行速度非常慢。 事实上,根据发表在Science上的一篇论文 ,Python 比用 C 或 C++ 编写的相同程序最多慢 60,000 倍。

因此,虽然 JavaScript 和 Python 非常适合简单的函数,但它们不适合计算密集型任务,例如图像、视频、音频和自然语言处理,这些在现代应用程序中越来越普遍。

另一方面,WebAssembly 的性能与 C/C++ 编译的本机二进制文件 (NaCl) 相当,同时仍保持与高级语言 runtime 相关的可移植性、安全性和可管理性。 WasmEdge 是市场上目前最快的WebAssembly runtime 之一。

WebAssembly vs. 原生客户端

但是,当两者都在 Docker 容器或者 microVM 内部运行的时候, WebAssembly 相比 NaCl 的优势有哪些呢?

我们对未来的愿景是在原生基础设施中, WebAssembly 作为一个替代轻量级 runtime,与 Docker 和 microVM 并行运行。与类似 Docker 的容器或 microVM 相比,WebAssembly 性能更加出色并且消耗的资源更少。但就目前而言,AWS Lambda 和许多其他平台仅支持在 microVM 内运行 WebAssembly。尽管如此,与运行容器化的 NaCl 程序相比,在 microVM 中运行 WebAssembly 函数仍然具有许多优势。

首先,WebAssembly 为单个函数提供了细粒度的 runtime 隔离。一个微服务可以有多个函数并支持在一个 microVM 中运行的服务。 WebAssembly 可以让微服务更安全、更稳定

其次,WebAssembly 字节码是可移植的。即使在容器内,NaCl 仍然依赖于安装在 OS 上的底层 CPU、操作系统和动态库。 而 WebAssembly 字节码应用程序是跨平台的。开发者只需编写一次即可部署在任何云、任何容器和任何硬件平台上。

第三,WebAssembly 应用**易于部署和管理。**与 NaCl 动态库和可执行文件相比,它们的平台依赖性和复杂性要少得多。

最后,WebAssembly 是多语言的。 C/C++、Rust、Swift、Kotlin 程序都可以轻松编译成 WebAssembly。 WebAssembly 甚至支持 JavaScript。 WasmEdge Tensorflow API 提供了以 Rust 编程语言执行 Tensorflow 模型的最符合习惯的方式

我们能够看到,WebAssembly + WasmEdge 是一个更好的选择。为了实际见证这个结论,让我们深入示例,亲自上手吧!

前期准备

由于我们的 demo WebAssembly 函数是用 Rust 写的,你需要安装一个 Rust 编译器。 确保你添加了 wasm32-wasi 编译器目标(如下),从而生成 WebAssembly 字节码。

$ rustup target add wasm32-wasi

该 demo 应用程序前端是 Next.js 写的,并部署在 AWS Lambda 上。我们假设你已经有使用 Next.js 和 Lambda 的基础知识了。

案例1:图像处理

我们的第一个 demo 应用程是让用户上传一个图像,然后用户调用 serverless 函数将其变成黑白的。 你可以查看已经通过 GitHub Pages 部署好的实时 demo

demo 链接: https://second-state.github.io/aws-lambda-wasm-runtime/

Fork demo 应用程序的 GitHub repo ,就可以开始部署自己的函数了。将应用程序部署在 AWS Lambda 上的具体流程,请参考 repository 中的 README 教程。

模板 GitHub repo:https://github.com/second-state/aws-lambda-wasm-runtime

创建函数

模板 repo 是一个标准的 Next.js 应用程序。后端 serverless 函数是在 api/functions/image_grayscale 文件夹。 src/main.rs 文件包含 Rust 程序的源代码。 该 Rust 程序从 STDIN 读取数据,然后输出黑白图片到 STDOUT。

use hex;
use std::io::{self, Read};
use image::{ImageOutputFormat, ImageFormat};

fn main() {
  let mut buf = Vec::new();
  io::stdin().read_to_end(&mut buf).unwrap();

  let image_format_detected: ImageFormat = image::guess_format(&buf).unwrap();
  let img = image::load_from_memory(&buf).unwrap();
  let filtered = img.grayscale();
  let mut buf = vec![];
  match image_format_detected {
    ImageFormat::Gif => {
        filtered.write_to(&mut buf, ImageOutputFormat::Gif).unwrap();
    },
    _ => {
        filtered.write_to(&mut buf, ImageOutputFormat::Png).unwrap();
    },
  };
  io::stdout().write_all(&buf).unwrap();
  io::stdout().flush().unwrap();
}

可以使用 Rust 的 cargo 工具将 Rust 程序构建为 WebAssembly 字节码或者本机代码。

$ cd api/functions/image-grayscale/
$ cargo build --release --target wasm32-wasi 

将 build artifact 复制到 api 文件夹。

$ cp target/wasm32-wasi/release/grayscale.wasm ../../

当我们构建 docker 镜像时,会执行 api/pre.shpre.sh 安装 WasmEdge runtime,然后将每个 WebAssembly 字节码程序编译为原生 so 库以加快执行速度。

创建服务脚本,加载函数

api/hello.js 脚本加载 WasmEdge runtime,在 WasmEdge 中启动编译了的 WebAssembly 程序,并将已上传的图片数据通过 STDIN传递。 注意 api/hello.js 运行已编译的由 api/pre.sh 产生的 grayscale.so 文件,以达到更佳的性能。

const { spawn } = require('child_process');
const path = require('path');

function _runWasm(reqBody) {
  return new Promise(resolve => {
    const wasmedge = spawn(path.join(__dirname, 'wasmedge'), [path.join(__dirname, 'grayscale.so')]);

    let d = [];
    wasmedge.stdout.on('data', (data) => {
      d.push(data);
    });

    wasmedge.on('close', (code) => {
      let buf = Buffer.concat(d);
      resolve(buf);
    });

    wasmedge.stdin.write(reqBody);
    wasmedge.stdin.end('');
  });
}

hello.jsexports.handler 部分导出一个异步函数处理程序,用于每次调用 serverless 函数时处理不同的事件。 在这个例子中,我们只是通过调用上面的函数来处理图像并返回结果,但你可以根据需要定义更复杂的事件处理行为。 我们还需要返回一些 Access-Control-Allow header 以避免在从浏览器调用 servereless 时发生跨域资源共享 Cross-Origin Resource Sharing (CORS) 错误。 如果你在复制我们的示例时遇到 CORS 错误,你可以在此处查看更多有关 CORS 错误的信息。

exports.handler = async function(event, context) {
  var typedArray = new Uint8Array(event.body.match(/[\da-f]{2}/gi).map(function (h) {
    return parseInt(h, 16);
  }));
  let buf = await _runWasm(typedArray);
  return {
    statusCode: 200,
    headers: {
      "Access-Control-Allow-Headers" : "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token",
      "Access-Control-Allow-Origin": "*",
      "Access-Control-Allow-Methods": "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT"
    },
    body: buf.toString('hex')
  };
}

构建 Docker 镜像用于 Lambda 部署

现在我们有了 WebAssembly 字节码函数和脚本来加载和连接到 Web 请求。 为了将它们部署为 AWS Lambda 上的函数服务,仍然需要将整个内容打包到 Docker 镜像中。

我们不会详细介绍如何构建 Docker 镜像并在 AWS Lambda 上部署,你可以参考 README 中的 deploy 部分 。 但是,我们将突出显示 Dockerfile 中的一部分,以避免一些陷阱。

FROM public.ecr.aws/lambda/nodejs:14

# Change directory to /var/task
WORKDIR /var/task

RUN yum update -y && yum install -y curl tar gzip

# Bundle and pre-compile the wasm files
COPY *.wasm ./
COPY pre.sh ./
RUN chmod +x pre.sh
RUN ./pre.sh

# Bundle the JS files
COPY *.js ./

CMD [ "hello.handler" ]

首先,我们从 AWS Lambda 的 Node.js 基础镜像 构建镜像。使用 AWS Lambda 基础镜像的优势在于它包含了 Lambda Runtime 接口客户端 (RIC),当我们在 AWS Lambda 部署 Docker 镜像时需要这个。 Amazon Linux 使用 yum 作为包管理器。

这些基本镜像包含 Amazon Linux Base 操作系统、给定语言的 runtime、依赖项和 Lambda runtime 接口客户端 (RIC),它实现 Lambda Runtime API。 Lambda Runtime API 客户端允许你的 runtime 从 Lambda 服务接收请求并向其发送请求。

其次,我们需要将我们的函数及其所有依赖项放在 /var/task 目录中。 AWS Lambda 不会执行其他文件夹中的文件。

第三,我们需要在启动容器时定义默认命令。 CMD [ "hello.handler" ] 意味着只要调用 serverless 函数,我们就会调用 hello.js 中的 handler 函数。回想一下,我们在前面的步骤中通过 hello.js 中的 exports.handler = ... 定义并导出了 handler 函数。

可选:在本地测试 Docker 镜像

你可以按照 AWS 给出的指南在本地测试从 AWS Lambda 的基础镜像中构建的 Docker 镜像。 本地测试需要 AWS Lambda Runtime Interface Emulator (RIE) ,它已经安装在所有 AWS Lambda 的基础镜像中。 要测试你的镜像,首先,通过运行以下命令启动 Docker 容器:

docker run -p 9000:8080  myfunction:latest 

该命令在你的本地机器设置了一个函数端点 http://localhost:9000/2015-03-31/functions/function/invocations.

然后从一个独立的终端窗口,运行:

curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d '{}'

你应在终端中获得预期的输出。

如果你不想使用来自 AWS Lambda 的基础镜像,你也可以使用自己的基础镜像并在构建 Docker 镜像时安装 RIC 和/或 RIE。 只需按照 AWS 给出的指南,从替代基础镜像部分创建镜像即可。

就是这样! 构建 Docker 镜像后,你可以按照 repo 中的 README 概述的步骤将其解压到 AWS Lambda 。 现在,你的serverless 函数已准备就绪!让我们看看第二个高难度的函数

案例2: AI推理

第二个 demo 应用程序是让用户上传图片,然后触发一个 serverless 函数对图片上的主要物品进行识别。

它与上一个示例位于同一 GitHub repo 中,但位于 tensorflow 分支中。 用于图像分类的后端 serverless 函数位于 tensorflow 分支的 api/functions/image-classification 文件夹中。 src/main.rs文件包含 Rust 程序的源代码。 Rust 程序从 STDIN 读取图像数据,然后将脚本输出输出到 STDOUT。 它利用 WasmEdge Tensorflow API 来运行 AI 推理。

AI 推理模板:https://github.com/second-state/aws-lambda-wasm-runtime/tree/tensorflow

pub fn main() {
    // Step 1: Load the TFLite model
    let model_data: &[u8] = include_bytes!("models/mobilenet_v1_1.0_224/mobilenet_v1_1.0_224_quant.tflite");
    let labels = include_str!("models/mobilenet_v1_1.0_224/labels_mobilenet_quant_v1_224.txt");

    // Step 2: Read image from STDIN
    let mut buf = Vec::new();
    io::stdin().read_to_end(&mut buf).unwrap();

    // Step 3: Resize the input image for the tensorflow model
    let flat_img = wasmedge_tensorflow_interface::load_jpg_image_to_rgb8(&buf, 224, 224);

    // Step 4: AI inference
    let mut session = wasmedge_tensorflow_interface::Session::new(&model_data, wasmedge_tensorflow_interface::ModelType::TensorFlowLite);
    session.add_input("input", &flat_img, &[1, 224, 224, 3])
           .run();
    let res_vec: Vec<u8> = session.get_output("MobilenetV1/Predictions/Reshape_1");

    // Step 5: Find the food label that responds to the highest probability in res_vec
    // ... ...
    let mut label_lines = labels.lines();
    for _i in 0..max_index {
      label_lines.next();
    }

    // Step 6: Generate the output text
    let class_name = label_lines.next().unwrap().to_string();
    if max_value > 50 {
      println!("It {} a <a href='https://www.google.com/search?q={}'>{}</a> in the picture", confidence.to_string(), class_name, class_name);
    } else {
      println!("It does not appears to be any food item in the picture.");
    }
}

你可以使用 cargo 工具构建 Rust 程序为 WebAssembly 字节码或本机代码。

$ cd api/functions/image-classification/
$ cargo build --release --target wasm32-wasi

将 build artifacts 复制到 api 文件夹中。

$ cp target/wasm32-wasi/release/classify.wasm ../../

同样,api/pre.sh 脚本会在此应用程序中安装 WasmEdge runtime 及其 Tensorflow 依赖项。 它还在部署时将 classify.wasm 字节码程序编译为 classify.so 原生共享库。

api/hello.js 脚本加载 WasmEdge runtime,在 WasmEdge 中启动已编译的 WebAssembly 程序 , 并通过 STDIN 传递上传的图像数据。 注意 api/hello.js 运行 api/pre.sh 生成的已编译的 classify.so文件以获得更好的性能。 Handler 函数和我们前面的例子类似,这里不再详述了。

const { spawn } = require('child_process');
const path = require('path');

function _runWasm(reqBody) {
  return new Promise(resolve => {
    const wasmedge = spawn(
      path.join(__dirname, 'wasmedge-tensorflow-lite'),
      [path.join(__dirname, 'classify.so')],
      {env: {'LD_LIBRARY_PATH': __dirname}}
    );

    let d = [];
    wasmedge.stdout.on('data', (data) => {
      d.push(data);
    });

    wasmedge.on('close', (code) => {
      resolve(d.join(''));
    });

    wasmedge.stdin.write(reqBody);
    wasmedge.stdin.end('');
  });
}

exports.handler = ... // _runWasm(reqBody) is called in the handler

你可以按照上一个示例中讲述的方式构建 Docker 镜像并部署该函数。 现在你已经创建了一个用于主题分类的 Web 应用程序!

展望未来

从部署在 AWS Lambda 上的 Docker 容器运行 WasmEdge 是一种向 Web 应用程序添加高性能函数的简单方法。 展望未来,更好的方法是使用WasmEdge作为容器本身。 这样就无需 Docker 和 Node.js 来装 WasmEdge。这样一来,我们运行 serverless 函数的效率就更高了。 WasmEdge 已经与 Docker 工具兼容。 如果你有兴趣加入 WasmEdge 和 CNCF 一起进行这项激动人心的工作,请告诉我们

便利蜂 | 门店网络与 Rust 落地实践

作者:刘凯,裴振 / 后期编辑:张汉东

编者按: 便利蜂门店相信很多人都去过,也享受过其便利的自助服务。但谁能想到,Rust 已在便利蜂100% 支撑店内网络已达 2 年时间,稳定性达 99.9999% 呢? 功能虽然不太复杂,但这个是 Rust 在嵌入式领域落地的一个非常接地气的场景了!


背景

关于便利蜂

便利蜂是一家以新型便利店为主体的科技创新零售企业,公司以科技为核心驱动运营,以“品质生活 便利中国”为己任,怀抱“小小幸福 在你身边”的初心,为中国消费者提供优质、健康、安心的产品和高效、便捷、满意的服务。目前全国开业的门店已超2000家。

网络在门店的作用

便利蜂店内大部分设备都已智能化,依赖网络的设备多达数十种,覆盖了从订单支付到店内运营的方方面面。 店内琐碎的事务中,背后有若干算法在支撑,从订货、陈列、盘点、废弃、热餐制作,到实时变价、自助咖啡、订单结算,每一个环节都有着复杂的网络交互。 要支撑住门店的正常运营,门店网络的稳定非常重要。

门店网络架构

net

上图是便利蜂门店网络拓扑。 在便利蜂门店场景中,要特别关注稳定性与成本的平衡。 对于网络稳定性,拓扑中可以看到,互联网出口以主线为主4G作为兜底,由算法控制主线恶化程度后决定是否启用4G网络,从而保证门店网络的可靠。 关于成本,便利店生意很注重规模效应,大规模的场景通常对单套成本是非常敏感的。拆解开看,成本项主要有三部分,第一是设备成本,第二是互联网接入成本,第三是运维人力成本。关于设备成本,在后续章节有所提及。关于互联网接入,通常我们会使用最低廉的宽带做为主线接入。

难点分析

基于上述要求,我们要达成【高质量的一人管千店】,那么会面临几个难题:

  1. 如何定义高质量;
  2. 如何解决多品牌设备问题;
  3. 这么多设备配置,因为各种原因,每天都有可能出现网络中断的情况,如何做到一人管千店。

其中最关键的因素在网关,它负责重要的线路逃生决策,还兼具各种智能检测、信息采集等任务,汇聚信息到中心,最终会在监控中实时分析全国门店的网络情况。 所以网关是整个管理系统的眼睛和手,总部系统是大脑。

方案选择

硬件和系统选型

硬件选型要满足如下条件:

  1. 不能单一供应商,风险太大;
  2. 多供应商带来的复杂性不能伤害一人管千店的目标;
  3. 硬件稳定性不输给大厂设备;

目前我们选型的策略如下:

  1. 品牌 or ODM(设备制造商);
    1. 品牌往往Console界面互不兼容且不具备编程能力,灵活度大打折扣;
    2. ODM往往出货量远低于大品牌,需要对硬件有一定了解谨慎选择;
    3. 我们目前倾向ODM;
  2. 高通 or MTK;
    1. 从sdk成熟度来讲,高通占优,但若选MTK需要接受使用低版本Kernel;
    2. 从成本来讲,MTK占优;
    3. 我们目前倾向MTK;
  3. 系统用 OpenWRT 官方 or 厂商 SDK;
    1. 如果对新版本有洁癖,且具备一定的 Kernel debug 能力,建议用 OpenWRT 官方,我们在这条路上有过探索,是完全可以走通的;
    2. 综合考虑后,我们目前倾向厂商SDK。

从以上策略出发,便利蜂必然会是一个多系统并存的状态。

开发语言为什么选 Rust

我们的嵌入式硬件有三种,两种 ARM 和一种 MIPS,其中最低的配置为 MT7621 CPU,有 880MHz MIPS CPU、512M内存(可用400M)、370M Flash,属于嵌入式环境。

兼顾嵌入式环境和成熟度,入选的语言有:Golang、C、Lua、Shell、Rust。分析各语言利弊如下:

  1. Golang:
    1. 优势:支持多种平台的移植、强大的异步编程能力、并且开发快速;
    2. 缺点:需要 Runtime,内存和CPU占用较高,且测试中发现MIPS版本有内存泄漏;
    3. 结论:排除;
  2. C:
    1. 优势:代码简洁、轻量高效,执行效率高,可移植性好;
    2. 缺点:开发效率不高、需要面临内存安全性问题;
    3. 结论:备选;
  3. Lua:
    1. 优势:Openwrt 因为有 luci,是最佳选择,并且足够的轻量,可以完美的和C系语言做粘合;
    2. 缺点:其他OS移植sdk的工作量较大;
    3. 结论:排除;
  4. Shell:
    1. 优势:轻量、开发快捷、上手难度低、系统自带;
    2. 缺点:对类型的定义和检查不严格,不适合做大型项目的构建,对于高质量交付对人要求较高;
    3. 结论:排除;
  5. Rust:
    1. 优势:运行速度快、内存安全、没有Runtime和GC(零成本抽象)、跨平台;
    2. 缺点:学习曲线陡峭、上手难度大、比较新的语言、许多基础库待完善;
    3. 结论:备选。

最终在 C 和 Rust 中做选择,做过一些尝试后最终决定使用 Rust, 它的高质量交付是我们最关心的优势。

Rust 实践

网络质量定义

衡量一个门店网络质量的好坏最直接的方法就是通过Http检测或者ICMP检测,根据检测结果的丢包率、延迟等相关指标来评定。我们根据门店的已有监控数据计算了一个合理的Ping值检测定级区间,具体分级如下:

  • A 级:延迟 <= 200ms or 丢包 <= 10%
  • B 级:延迟 <= 500ms or 丢包 <= 20%
  • C 级:延迟 <= 600ms or 丢包 <= 40%
  • D 级:延迟 > 600ms or 丢包 > 40%

A级网络质量基本不会对门店产生影响;B级基本会造成门店网络出现卡顿以及服务短暂失效等影响;C级就是比较大的影响了,门店网络属于短暂不可用;D级则代表门店网络不可用状态严重影响到了门店业务。

线路逃生的难题

因为低成本的宽带和分散的接入方式,一些门店的网络环境甚至比家用网络更不可靠,所以网关会配有一个 4G 路由器来作 Standby。在主线中断时候切换到备线,在主线恢复后切换回主线。保证门店的网络不受影响。同时,店内实时采集监控数据到中心,并通过流式处理实时监控全局状态,当单店出问题时,会有工单生成一线运维介入,当整体A级比例下降时,网络组二线会第一时间收到电话告警并介入处理。

理想情况下,主线质量始终要优于备线质量,所以即使主线和备线同等级别,也应该优先保证线路在主线。其余情况则是谁好在谁则路由保持在哪里。

但实际情况不止如此。主线通常不会直接中断,而是处于弱网状态,大多数设备此时只能眼看网络变差不能切换,我们会把质量经过ABCD规则匹配并防抖后第一时间做切换。但这又引出新问题,主线故障会频繁在A和D之间抖动,这时候就会出现主备切换的 flapping 状态,因为路由切换的瞬间会影响到业务 http 访问,所以我们通过指数退避的方式尽可能的减少 flapping 的发生。然而又会暴露一个问题:在退避期间,由于抑制了频繁切换,但是实际线路已经无法使用,就会导致全店网络中断情况的发生。这时候就需要采用异常介入模式,避免网络处于瘫痪状态。

快速调研,扫清障碍

既然明确了需求、明确了选型,剩下的就是 POC 的工作了。

首先是并发库方面。在 Rust 社区中,我们有几个选型可以参考:

  • crossbeam (多线程)
  • async-std (异步)
  • tokio (异步)

crossbeam 这个库非常之优秀,完全可以满足程序的功能设计,但是我们需要考虑到我们的设备硬件资源限制条件,CPU 更是能省则省,所以我们更倾向于选择一个异步运行时来解决我们的问题。

tokioasync-std 都是比较好的选择,在最初开发的时候 async-std 项目刚刚开始起步,很多功能还没有完善,并且 tokio 库已经在很多项目中进行过验证,稳定性各方面要相对于 async-std 更好一些。所以我们最终决定采用 tokio 来作为我们整个程序的异步运行时。

其次是网络检测方面。网络质量检测不仅需要检测主备线质量、还有若干业务的质量检测,ip数量一般能达到20~30左右。并且这些检测的频率SizeInterface也各不相同。我们的网络设备款式比较多,Ping 命令的输出格式不统一,且由于 Ping 命令本身是一个 blocking 的操作,放在异步运行时中需要单开 worker 来执行这些操作,后来开始调研其他的 Ping 实现,如果要做到多设备兼容需要适配各种设备上安装的 Ping 命令的输出结果,并且和程序嵌套会繁琐,开销较大;

我们对Ping的使用主要有这几个需求:

  • 每个地址发送的频率不一致,大小不一致
    • 大包低频:对一些比较重的资源地址采用了大包检测,但同时要保证门店网络的带宽不受影响所以作为低频包
    • 小包高频:对一些敏感资源地址,采用小包高频策略,这样可以保证每分钟相对均匀的检测
    • 小包低频:对一些不敏感,仅做链路检测的做小包低频,能节省带宽
  • 发送Ping包需要与Interface接口绑定
    • 由于需要支持主备线路由检测,需要绑定 Interface 来做(backup:静态路由)
  • 最好支持Traceroute功能(Icmp)
    • 需要上传重点监测线路的完整 traceroute 信息到机房来做后期故障分析
  • 能与Tokio的 Runtime 结合并且支持mipsarmaarch64等架构
    • 网络多元化,拥有多款设备需要做到跨平台,并且对资源占用有要求
  • 能控制 Sequence Number 起始数
  • 可编程
    • 可个性化定制、符合人体工程学

社区已有几个实现:

  • oping: oping 是一个 Rust 绑定 C 的实现,还是一个 blocking 的操作,但是能提升一些编程体验;
  • fastping: fastping 是类似 go 的 fastping 实现,可以同时 Ping 多个地址,但是我们的需求是在 Ping 很多地址的前提下 duration 不同、size 不同、绑定的网络接口不同,所以虽然也是 batch 类的 Ping,但是不太适用;
  • tokio-ping: tokio-ping 其实最开始是最适合我们项目的,但是 rust 异步生态那会刚好处在新老交替的阶段,tokio-ping 处于异步的上一个阶段,没法跟我们的项目完美兼容,需要一些 compat 才能使用,并且作者基本已经不维护了。

基于以上的情况,我们实现了一套适用于自己的异步 Ping 程序surge-ping 完全贴合我们的使用需求。

代码示例:

use std::time::Duration;

use surge_ping::Pinger;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut pinger = Pinger::new("114.114.114.114".parse()?)?;
    pinger.bind_device(Some("eth0".as_bytes()))?;
    // pinger.set_ttl(20)?;
    pinger.timeout(Duration::from_secs(1));
    for seq_cnt in 0..10 {
        let (reply, dur) = pinger.ping(seq_cnt).await?;
        println!(
            "{} bytes from {}: icmp_seq={} ttl={:?} time={:?}",
            reply.size, reply.source, reply.sequence, reply.ttl, dur
        );
    }
    Ok(())
}

程序结构

edgechecker 网关 Agent 的组织图如上所示,我们对每个门店都部署了 Agent 来监测门店网络质量,通过将采集的数据上传到监控系统中对门店网络进行实时监控,在故障发生时能做到尽快发现减少门店损失。

多设备兼容

目前我们门店由于网络设备比较多元化,所以会涉及到跨平台的需求,以目前我们主要的两个平台 ARM64 和 MIPS 举例展现下 Rust 的跨平台能力。

MIPS交叉编译

这种需要借助厂商的编译 sdk 来做,由于我们使用的是一款老 Rom(mipsel-uclibc)的,这种在 rust 的编译阶级里属于第三层,没有预设在 rust 官方支持的 target 列表中,所以要借用xargo来实现编译。

先确定设备的三元组信息({arch}-{vendor}-{sys}-{abi}),一般通过 toolchain 的目录基本可以确定 libc 版本,比如我们的:toolchain-mipsel_24kec_gcc-4.8-linaro_uClibc,我们看到 Arch 是mipsel,ABI 是 uclibc,vendor 的话一般是unknown,system 为Linux,所以我们的三元组信息为mipsel-unknown-linux-uclibc.

操作如下:

# step1 配置环境变量
export STAGING_DIR="/path/to/staging_dir"
export PATH="$STAGING_DIR/toolchain-mipsel_1004kc_gcc-4.8-linaro_uClibc/bin/:$PATH"
export OPENSSL_DIR='/path/to/openssl'
export AR_mipsel_unknown_linux_musl=mipsel-openwrt-linux-uclibc-ar
export CC_mipsel_unknown_linux_musl=mipsel-openwrt-linux-uclibc-gcc
export CXX_mipsel_unknown_linux_musl=mipsel-openwrt-linux-uclibc-g++

# step2 指定编译选项
cat ~/.cargo/config
[target.mipsel-unknown-linux-uclibc]
linker = "mipsel-openwrt-linux-gcc"

# step3 安装Xargo
cargo install xargo

# step4
# 向Cargo.toml中加入如下内容,因为panic有栈回溯, 某些平台不支持这个语法;取消panic展开异常堆栈信息有利于减小程序体积(https://doc.rust-lang.org/edition-guide/rust-2018/error-handling-and-panics/aborting-on-panic.html)
[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

# step5
cat Xargo.toml
[target.mipsel-unknown-linux-uclibc.dependencies.std]
features = []

# step6 编译
xargo build --target mipsel-unknown-linux-uclibc --release

TIPS: 在早期编译期间遇到了很多libc库支持不全的问题,这时候需要去查 uclibc 的代码来协助完善 rust 的libc库。

ARM64 交叉编译

需要用到 Cross 项目: https://github.com/rust-embedded/cross

使用 Cross 的坑不多,大部分 unix 类的 OS 都可以使用它,它默认使用 docker 进行编译。使用方法:

cargo install cross
cross build --target aarch64-unknown-linux-gnu --release

对于 OpenSSL,非 MIPS 架构可直接使用 rustls-tls 库,使用方法如下:

# reqwest使用rustls
reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json"]}

若使用 mips + openssl,需要将 OpenSSL 在 Dockerfile 中注明编译。Dockerfile 如下:

# File: docker/Dockerfile.aarch64-gnu
# openssl: https://github.com/rust-embedded/cross/blob/c183ee37a9dc6b0e6b6a6ac9c918173137bad4ef/docker/openssl.sh
FROM rustembedded/cross:aarch64-unknown-linux-gnu-0.2.1

COPY openssl.sh /
RUN bash /openssl.sh linux-aarch64 aarch64-linux-gnu-

ENV OPENSSL_DIR=/openssl \
    OPENSSL_INCLUDE_DIR=/openssl/include \
    OPENSSL_LIB_DIR=/openssl/lib

然后进行编译

docker build -t kolapapa/aarch64-gnu:0.1 docker/ -f docker/Dockerfile.aarch64-gnu
cat > Cross.toml <<EOF
[target.aarch64-unknown-linux-gnu]
image = "kolapapa/aarch64-gnu:0.1"
EOF
cross build --target aarch64-unknown-linux-gnu --release

总结

Rust 非常适合此类门店嵌入式场景,其完整易用的工具链、高质量社区、安全的内存管理,能大量缩短上线时间、提高交付质量。 目前 Rust 在便利蜂已 100% 支撑店内网络 2 年时间,稳定性达 99.9999%,需求频次每半个月会做一次迭代。

开源社区贡献

我们在实践的过程中也对社区有一些小的贡献:


作者简介

刘凯,便利蜂运维开发工程师,主要负责 监控报警系统 以及 门店网络多元化项目 的开发和维护。

裴振,便利蜂运维负责人。

最后,便利蜂正在寻找优秀的伙伴,每一份简历我们都会认真对待,期待遇见。

招聘官网

https://bianlifeng.gllue.me/portal/homepage

学习资源

编辑: 张汉东


《Rust In Action》书籍第一版发布

作何 Tim McNamara 是一位经验丰富的程序员,对自然语言处理、文本挖掘以及更广泛的机器学习和人工智能形式有着浓厚的兴趣。 他在包括新西兰开源协会在内的开源社区中非常活跃。Rust in Action 是使用 Rust 进行系统编程的实践指南,它是为具有好奇心的程序员编写的,提供了远远超出语法和结构的实际用例。

国外最近的Rust的书籍,除了《Rust In Action》还有另外两本,《Refactor to Rust》和 《Rust Servers, Services, and Apps》。

国内翻译版也在路上了。

Amazon

使用 Rust 进行端到端加密

《End-to-End Encryption with Rust》是一本ockam-network/ockam实践指南, 在本指南中,我们将创建两个名为 Alice 和 Bob 的小型 Rust 程序。 Alice 和 Bob 将通过云服务通过网络相互发送消息。 在我们的代码示例中,Alice 和 Bob 将相互进行身份验证,并将获得加密保证,以确保其消息的完整性、真实性和机密性得到端到端的保护。

网络上的中间云服务和攻击者将无法看到或更改途中消息的内容。 在后面的示例中,我们还将看到即使当 Alice 和 Bob 之间的通信路径更复杂 - 具有多个传输连接、各种传输协议和许多中介时,我们如何才能实现这种端到端保护。

https://github.com/ockam-network/ockam/tree/develop/documentation/use-cases/end-to-end-encryption-with-rust#readme

两张图展示当前 Rust Web 生态

微信: https://mp.weixin.qq.com/s/eIOMI0JvpOkdmiTqJfWkRg 知乎: https://zhuanlan.zhihu.com/p/398232138

创意!用 Rust crate 作为自己的简历

如果你觉得学习 Rust 不知道该做些什么好?那不如从做自己简历开始。

https://yozhgoor.github.io/yohan_boogaert_1995/

Mini Lust 系列教程:

好奇如何从零造出来一个 RPC 框架?本教程将带你一步一步写出来一个 Rust 版 Thrift RPC 框架。

1.前言部分,RPC 相关概念介绍 2. Thrift IDL 介绍 3. 序列化/反序列化的抽象 4. Codec 和 Transport 抽象 5. 客户端和服务端实现 6. Thrift IDL 解析和代码生成 7. 基于 tower 的服务发现和负载均衡 8. 中间件支持

https://github.com/mini-lust/tutorials

Rust 公开课 | 《 Rust 异步编程二: Tokio 入门运行时介绍》|Vol. 6

这节课预计 9.5 号晚上8点,感兴趣的可以去听听。

该系列课程大纲

1、回顾 Rust 异步编程模型. 2、谈谈对 Rust 异步框架的认识 ( futures-rs、async-std、tokio ) . 3、Tokio 介绍 4、Tokio 里的 Executor、Reactor、Future 如何使用. 5、使用 Tokio 实现一个简单的服务端与客户端程序.

https://mp.weixin.qq.com/s/23YDZdwJNOAu15AIBDnWuQ

Clippy 1.54 增加 disallowed-methods 配置

允许你在 clippy.toml 中配置不允许的方法:


#![allow(unused)]
fn main() {
clippy.toml
disallowed-methods = ["std::vec::Vec::leak", "std::time::Instant::now"]
}

不良代码:


#![allow(unused)]
fn main() {
// 该代码将要被警告

let xs = vec![1, 2, 3, 4];
xs.leak(); // Vec::leak is disallowed in the config.

let _now = Instant::now(); // Instant::now is disallowed in the config.
}

应该用此代替:


#![allow(unused)]
fn main() {
// Example code which does not raise clippy warning
let mut xs = Vec::new(); // Vec::new is _not_ disallowed in the config.
xs.push(123); // Vec::push is _not_ disallowed in the config.
}

5000倍速度提升的 CRDT

CRDT 全称 Conflict-Free Replicated Data types. 主要用于在线合作文档编辑等方面.

作者详细介绍了如何提升相关实现和算法的一些过程,并且最终使得提升了 5000 倍的速度.

https://josephg.com/blog/crdts-go-brrr/

如何写出运行缓慢的 Rust 代码

用Rust写代码并不意味着你的代码会快得不得了。你很容易犯错并获得相当慢的性能。正如这篇博文所显示的,你甚至可能需要付出相当多的汗水才能打败Common Lisp和Java。

作者分享了自己如何使用 Rust 重写自己的 Lisp 代码, 如何成功的写出更慢的代码 并且 修复他们的故事.

https://renato.athaydes.com/posts/how-to-write-slow-rust-code.html

RustCast: Rust 系列教学视频

一系列 Rust 学习系列视频,希望能坚持下去。

https://www.youtube.com/channel/UCZSy_LFJOtOPPcsE64KxDkw

用Rust重写我的手机游戏,并且编译到 wasm

作者的游戏之前是用 C++ 写的。这篇文章详细记录了他决心使用rust重写的心路历程和一些idea的发展。

推荐阅读:

https://itnext.io/rewriting-my-mobile-game-in-rust-targeting-wasm-1f9f82751830

使用 Rust 从头开始​​实现 Base64

文章仔细研究 Base64 算法,并使用 Rust 编程语言从头开始实现编码器和解码器。

https://dev.to/tiemen/implementing-base64-from-scratch-in-rust-kb1

Async Rust 从头开始​​:一个简单的 Web 服务器

https://ibraheem.ca/writings/a-simple-web-server/

一个网络应用程序,可以学习使用 AI(遗传算法)构建车辆,使用Rust编写

它在你的浏览器中运行,使用人工智能(具体来说:遗传算法)来尝试制造越来越好的车辆。车辆必须克服障碍路线,从一些小山坡开始,然后是陡峭的山坡,最后是一些跳跃。车辆由面板和轮子制成,连接在一起,类似于Besiege游戏。

https://github.com/Bauxitedev/vehicle_evolver_deluxe

当零成本抽象不再是零成本

Rust 是围绕着“零成本抽象”的概念构建的。其理念是,您可以编写人机友好的高级代码,而编译器将为您提供至少与您自己编写的任何优化的低级别代码一样好的性能。使用零成本抽象,您不再需要在可维护性和性能之间进行权衡。

不幸的是,很难确保零成本抽象是真正的零成本,并且在实践中Rust经常不能满足这个崇高的理想。在这篇文章中,我将展示两个例子,在这两个例子中,即使看似简单的零成本抽象实际上也不是零成本。

https://blog.polybdenum.com/2021/08/09/when-zero-cost-abstractions-aren-t-zero-cost.html

【系列】Rust 每周一模块

这是一个系列博客,目前只发了两篇文章,每周讲一个模块:

比如第二周:Rust 标准库中std::fs模块

std::fs 是Rust标准库中操作文件系统的模块,包括创建、读取、更新、删除等常见操作。由于不同操作系统支持的API不尽相同,本文仅展示了与平台无关的一些例子:

  • 通过修改时间(mtime)来聚合相同年份、月份乃至日期的文件;
  • 硬链接(hard link)一个路径至另一个路径;
  • 递归创建目录;
  • 递归删除文件夹;
  • 拷贝文件;

https://motw.rs/

【书籍】Black Hat Rust 早期访问版

Black Hat Rust 是一本深入研究使用 Rust 编程语言的进攻性安全(Offensive Security)的书籍,支持PDF,Kindle 和 Epub。

这本书是一项正在进行的工作。它可以在早期访问计划的背景下使用,这意味着各章节将在写完后立即发送给你,我们非常感谢你的反馈。当前状态:

可访问页数:250+ 代码进度:~90% https://github.com/skerkour/black-hat-rust 预计最终出版:Q3 2021 估计的页数:~320

备注:作者为感谢所有帮助其完成这本书的人,所有早期访问的买家还将获得以下奖励:一个高级恶意软件分析的策划清单。在开发自己的攻击性工具时,会在里面找到巨大的灵感。

https://academy.kerkour.com/black-hat-rust?coupon=BLOG

如何写出高效的 Rust 代码

该文作者对如何写出高效 Rust 代码给出了一些建议,内容还比较长,感兴趣可以看看。

https://renato.athaydes.com/posts/how-to-write-fast-rust-code.html

理解 #[derive(Clone)]

你可能不知道这个宏背后发生的事,这篇文章带你探索一下。

https://stegosaurusdormant.com/understanding-derive-clone/

使用 Rust 进行即时分词

文章思路基于 Rust 实现 Python wordsegment 库,目的是为了实现更快的即时域搜索。文章将分词实现问题分为两点:实现一种估计句子在现实世界中出现的概率的方法以及实现一种对输入字符串的所有可能分段进行评分的有效方法。让我们来讨论这两者是如何工作的:

https://instantdomainsearch.com/engineering/instant-word-segmentation-with-rust

使用 WebAssembly 实现的 2048 游戏

2048 这个游戏相比大家都不陌生,它是一个以合成 2048 数字为目的的游戏,游戏玩法只有左右上下,通过将两个 2 合并成 4,将两个 4 合并成 8,以此类推合并出 2048。

https://2048.nishchith.com/

Pin,Unpin为什么Rust需要它们

又是一篇讲Pin的blog,是作者本人在学习Rust异步过程中做的一些总结和理解,方便大家在学习异步时遇到相关疑惑可以查阅。

https://blog.adamchalmers.com/pin-unpin/

Typing the technical interview 从Haskell翻译到Rust

Typing the technical interview是一篇将计算机知识拟作魔法的小说?原文提到的相关代码都是使用Haskell写的,现在社区里有人将其用Rust重新实现了一遍:

Github: https://github.com/insou22/typing-the-technical-interview-rust/

同时,如果对这篇原文感兴趣的,链接也在这里:

Read More: https://aphyr.com/posts/342-typing-the-technical-interview

关于Futures和运行时如何工作的心智模型

这一部分的主要目标是建立一个高层次的心理模型,说明我们在前一章中读到的不同部分是如何一起工作的。我希望这将使我们在接下来的几章中深入研究特质对象和生成器等主题之前,更容易理解高层次的概念。

这并不是创建一个异步系统模型的唯一方法,因为我们要对运行时的具体情况进行假设,而这些情况可能会有很大的不同。这是我认为最容易建立的方式,而且对于理解你在异步生态系统中发现的很多真实的实现也很有意义。

最后,请注意,由于需要简洁明了,代码本身是 "伪的"。

https://cfsamson.github.io/books-futures-explained/2_a_mental_model_for_futures.html

【系列】Embedded Rust 第一步:选择一块板子

内容整理自 robyoung (Rob Young) 的文章:First steps with Embedded Rust: Selecting a board

有这么多令人眼花缭乱的微控制器和项目,对于嵌入式经验很少的人来说应该从哪里开始?

我们在开发板中想要什么?

  • 良好的架构支持
  • 良好的芯片支持
  • 活跃的社区
  • 内置调试器

我们需要什么架构?

拥有最完整库、最详尽指南和最大社区的架构是 ARM Cortex-M。 ARM Cortex-M 是面向微控制器应用的低功耗、低成本处理器。 查看 crates.io 上的下载量虽说不是一个完美的指标,但可以让我们了解规模上的差异。在过去的 90 天内,cortex-m 的下载量超过 250k。 RISC-V、AVR 或 Xtensa 最多有 3k 次下载,cortex-a 有大约 18k 次下载。ARM Cortex-M 独树一帜。

  • AVR:AVR 是用于嵌入式系统的 8 位微控制器系列。在 Rust 生态系统中,它们并没有得到很好的支持。直到最近,还需要使用 rustc 的一个分支来构建 AVR。 现在有几个不同的选择,awesome-avr-rust 是一个很好的起点。
  • ARM Cortex-A:更强大的多核 ARM 处理器,专为运行更大的东西而设计。 通常会在它们上运行完整的操作系统。 例如这是大多数智能手机和掌上游戏机中使用的架构。查看 cortex-a - crates.io: Rust Package Registry 了解更多。
  • RISC-V:似乎是机器架构的新热点,它是一种免费且开放的指令集架构 (ISA)。 它也从一开始就被设计成模块化的,这意味着芯片设计人员可以创建各种各样的专用芯片,虽然目前开发板的范围很小。有一个活跃的 Rust RISC-V 社区,SiFive 或 www.riscv.org 都是不错的起点,Rust 方面,可以查看 riscv crate。
  • Xtensa:最受欢迎的主板组是来自 Espressif 的 ESP32 系列芯片。它们是小型、廉价、支持 WiFi 的电路板。 需要注意的是,并非所有 ESP32 开发板都使用 Xtensa 芯片,新的 ESP32-C3 是基于 RISC-V 的。在 Xtensa 芯片上使用 Rust 的最大障碍可能是 llvm 不支持它,因此需要构建 Rust 的 fork:esp-rs/rust。

我们需要什么芯片?

因此,我们将使用 ARM Cortex-M。 这缩小了搜索范围,但仍有很多选择。如果我们查看 cortex-m crate 的依赖项,我们会看到有两组芯片比其他任何一组都使用得更多; STM32 系列芯片和 nRF5 系列,这是我们要重点搜索的地方。

STM32:STM32 系列芯片可能是应用最广泛的嵌入式 Rust ARM Cortex-M 芯片。两种最受欢迎的 STM32 板是 Blue Pill 和 Black Pill。主要的缺点是没有板载调试器。如果想要带有调试器的基于 STM32 的电路板,那么获得 STMicroelectronics 官方套件是一个不错的选择(STM32F3 或 STM32F4 是不错的选择)。Rust Embedded Discovery 书的原始版本是针对 STM32F3 板编写的,因此有非常高质量的初学者文档,可以从那里开始。

nRF5:用于嵌入式 Rust 的第二个最广泛使用的 ARM Cortex-M 芯片系列是 Nordic Semiconductor 的 nRF5 系列。官方开发套件 (DK) 是很棒的入门板。 Ferrous Systems 的 Knurling-rs 会议使用 nRF52840 开发套件。Knurling 课程质量非常高,手把手指导,通过有趣好玩的项目教授嵌入 Rust,是使用 Rust 进行嵌入式开发的最佳切入点。另一个很棒的基于 nRF 的开发板是 BBC micro:bit。它配备了板载调试器和一系列有趣的板载外围设备,如板上的 LED 显示屏、按钮和传感器。BBC micro:bit 被设计为一个教育平台,因此硬件在他们的开发者社区中以非常适合初学者的方式进行记录,并且互联网上有大量项目创意。

RP2040:RP2040 于 2020 年底发布,是 Raspberry Pi 基金会首次尝试设计自己的芯片。由于如此新,Rust 对它的支持仍在开发中。与 BBC micro:bit 一样,RP2040 旨在成为一个教育平台,因此硬件文档是一流的,并且有大量初学者友好的代码示例和其他编程语言的库(没有多少适合初学者的嵌入式 Rust 文档)。这是一个非常令人兴奋的平台,并且在 Embedded Rust 社区中围绕它进行了大量活动,所以一定要密切关注,但它可能不适合作为入门第一块板。 板载调试器?

在主机上运行程序时,可以在 shell 中运行它并查看打印输出。这在嵌入式目标上更加困难,调试器填补了这一空白。除了允许单步调试、断点调试外,它还允许将程序加载到设备上并轻松查看输出。不过有一个问题,它通常是连接到主机然后连接到目标设备的单独设备。第一次开始时,这是一笔不可忽视的费用,也是必须正确设置的另一件事。幸运的是,有些设备带有内置调试器,将它们直接插入主机并在瞬间探测运行的代码(通常需要在主机上进行一些设置才能使调试器正常工作,ferrous 有一个很好的设置指南)。

结论

以下这些板都有很棒的 HAL 和 BSP crate、活跃友好的社区和板载调试器。

  • BBC micro:bit(约 13 英镑):它是新版 Rust Embedded Discovery 书中使用的板。
  • nRF52840 开发套件(约 35 英镑); 它是 Ferrous Systems 在 Kunrling 会议和培训中使用的板。
  • STM32F3 探索套件(约 14 英镑); 它是 Rust Embedded Discovery 书的第一版中使用的板。

密切关注:

  • Raspberry Pi Pico(约 6 英镑,带预焊引脚); ARM Cortex-M 但没有内置调试器,HAL 仍在开发中。不过目前有很多活动,进展很快。
  • HiFive1 Rev B(约 50 英镑); RISC-V 是新的热点。 Rust 中似乎有很多围绕它的活动,但它目前还没有 ARM Cortex-M 的支持。 其他需要关注的开发板是 Logan Nano 和 ESP32-C3。

部分内容略有轻微调整,更多可阅读原文:Rob Young | digital

如何来看待 unwrap

unwrap 方法可能会让新手感到困惑。一些建议:

  • 可以使用 Expect (&str) 而不是 unwrap() 为 panic 提供上下文。
  • 使用 unwrap 和 expect 类似于断言。如果他们 panic,那只有在不可挽回的情况下才会发生。
  • 避免在库代码中使用。

https://owengage.com/writing/2021-08-30-how-to-think-of-unwrap/

Learning Rust: Interfacing with C

通过本文学习如何使用 Rust 调用 C 方法以及如何在 C 中调用 Rust 方法.

https://piware.de/post/2021-08-27-rust-and-c/

Unsafe Rust 的超能力

该文是 knoldus 团队的系列文章之一,该团队博客还有很多 Rust 相关文章可以学习。

https://blog.knoldus.com/superpowers-of-unsafe-rust/

Rust 密码学生态系统概述

根据密码库漏洞的实证研究(Jenny Blessing、Michael A. Specter、Daniel J. Weitzner - MIT),密码库中 37.2% 的漏洞是内存安全问题,而只有 27.2% 是密码问题。

现在是时候从C语言作为实现加密原语的现实中走出来了!

这篇文章摘自 Black Hat Rust 一书:

由于其具有低级控制、无垃圾收集器、可移植性和易于嵌入的高级性质,Rust 是我们替换当今最著名的加密库的最佳选择:OpenSSL、BoringSSL 和 libsodium,它们都是用 C 编写的 .

这肯定需要时间,但在 2019 年,根据任务的不同,rustls(我们稍后会看到的一个库)的基准测试比 OpenSSL 快 5% 到 70%。 看到广泛采用的最重要的事情之一(今天缺少)? 认证(如 FIPS)。

事不宜迟,这里是 2021 年对 Rust 密码学生态系统的调查。

https://kerkour.com/blog/rust-cryptography-ecosystem/

Rust 技巧篇 | 用 #[doc] 属性宏改善你的文档注释

编辑: 张汉东

说明: 本文是在原文基础上的梳理,也引入了其他内容。


属性宏的新特性介绍

Rust 1.54 开始,属性宏增加了类函数宏的支持。

类函数的宏可以是基于macro_rules!的声明宏,也可以是像macro!(...)那样被调用的过程宏。这对于文档注释相当有好处。如果你的项目的 README 是一个很好的文档注释,你可以使用include_str!来直接纳入其内容。以前,各种变通方法允许类似的功能,但从1.54开始,这就更符合人体工程学了。


#![allow(unused)]
#![doc = include_str!("README.md")]
fn main() {
}

如果你看过一些 Rust 开源项目,你应该在 lib.rs 中看到过一大堆文档注释吧?这些注释太长,导致真正的代码被挤到到最下面。有了这个功能,就可以解决这类问题了。


#![allow(unused)]
fn main() {
macro_rules! make_function {
    ($name:ident, $value:expr) => {
        // 这里使用 concat! 和 stringify! 构建文档注释
        #[doc = concat!("The `", stringify!($name), "` example.")]
        ///
        /// # Example
        ///
        /// ```
        #[doc = concat!(
            "assert_eq!(", module_path!(), "::", stringify!($name), "(), ",
            stringify!($value), ");")
        ]
        /// ```
        pub fn $name() -> i32 {
            $value
        }
    };
}

make_function! {func_name, 123}
}

也可以像这样,在属性中嵌入宏调用来构建文档注释。可以对比下展开后的代码:


#![allow(unused)]
fn main() {
///The `func_name` example.
///
/// # Example
///
/// ```
///assert_eq!(doc_attr::func_name(), 123);
/// ```
pub fn func_name() -> i32 {
    123
}

}

这样的话,文档也可以复用了。当然你也可以扩展出其他用法。

其他用法

国外社区朋友的这篇文章中,他列举了一些应用场合。

用文档测试扩展测试能力

Rust 的文档测试相当灵活,假如你写了一些函数或者宏,你想确保它在输入某个值的时候不能编译。使用单元测试比较麻烦,但是用文档测试就很方便了。


#![allow(unused)]
fn main() {
/// ```compile_fail
#[doc = include_str!("compile_fail.rs")]
/// ```
mod doc_test {}
}

你可以把相关测试放到 complile_fail.rs 中,然后使用 文档注释将其包括进来,这样在 cargo 执行测试的时候就可以进行测试了。而且对于 Rust 代码整体增加了可读性和可维护性。同样,你也可以检查 panic 等。

我们也不希望这种注释出现在最终用户的文档中,或者是编译文件中,所以需要使用 cfg(doctest) 来将其隐藏:


#![allow(unused)]
fn main() {
#[cfg(doctest)]
/// ```compile_fail
#[doc = include_str!("compile_fail.rs")]
/// ```
mod doc_test {}
}

为 reqwest 增加中间件支持

译者:华为-王江桐,华为-周紫鹏


原文

继续我们的开源系列,我们为无处不在的reqwest Rust crate提供了一个中间件适配器。

这是我们开源系列的第二篇文章,我们将在其中讨论TrueLayer的工程挑战并开源我们的解决方案。如果你错过了我们的第一篇推文,我们的第一篇推文是Rust中的gRPC负载平衡。(原文/中文月刊

本文主题是reqwest-middleware,一个构建在reqwest HTTP客户端之上的crate,用于提供中间件功能。

问题

当通过网络与内部和外部服务进行通信时,由于服务会失败,大规模运行应用程序需要内置的韧性。

重试是提高可靠性的常用策略。机制相当简单:将每个请求包装在一个循环中并重试,直到获得成功响应或尝试次数用完为止。

我们的代码库中有数十个客户端:我们不想以特别的方式为每个客户端重新实现重试。

同时,我们更愿意让我们的域代码不受这种网络级别的限制——最完美的方式是,在 HTTP 客户端本身中透明地实现重试。

我们可以编写一个RetryHttpClient来包装标准客户端,以增加重试功能——但重试并不是全部。我们希望 HTTP 客户端处理其他功能:分布式跟踪header的传播、缓存、日志记录。

但是我们不想编写TracingRetryableClientTracingRetryableCachingHttpClientRetryableTracingCachingHttpClient(顺序很重要!)以及所有其他可能的组合。

我们想要一种可组合的抽象模式。

所有这些功能都遵循相同的模式:

  • 我们想在执行请求之前和之后运行一些任意逻辑
    
  • 该逻辑完全独立于问题域,它只关注底层传输和整个组织统一的要求(例如日志记录标准)。
    

好消息是,这是软件系统中常见的一个问题,因此也有一个通用的解决方案:中间件。

(P.S.:请注意,我们在本文中指的是一种非常特殊的中间件,该术语本身更为笼统。有关中间件在其他上下文中的用法,请参阅中间件的维基百科页面

Rust HTTP客户端中间件

在TrueLayer,我们使用reqwest作为我们所有Rust服务的 HTTP 客户端。

我们选择它是因为它提供了async-first API,与tokio兼容,并且它已广泛的在生产中使用。

遗憾的是,reqwest不支持现有即用的中间件。

我们的选择是什么?

  • 使用现成的crate替换<span style="background-color:#D2D2D2">reqwest</span>,或者在<span style="background-color:#D2D2D2">reqwest</span>之上做拓展。在撰写本文时,对我们来说,没有其他完善的、支持中间件的 Rust HTTP 客户端能够提供与reqwest一样的功能。[surf](https://crates.io/crates/surf)非常流行并且内置中间件,但[它需要引入async-std](https://github.com/http-rs/surf/issues/295)。
    
  • 尝试去获取上游实现的中间件支持。reqwest的维护者从 2017 年开始讨论这个问题(请参阅[ISSUE](https://github.com/seanmonstar/reqwest/issues/155)),但似乎仍然没有达成共识,甚至没有就此类功能是否属于该crate 达成共识。因此,我们不太可能在短期内完成某些事情。
    
  • 最后一个选择是,包装<span style="background-color:#D2D2D2">reqwest</span>并在其上实现中间件,所以这就是我们采用的方法。<span style="background-color:#D2D2D2">reqwest-middleware</span>诞生了。
    

使用reqwest-middleware我们能够将中间件附加到Client上,然后就像我们直接使用reqwest一样发出请求:

use reqwest_middleware::{ClientBuilder, ClientWithMiddleware};
use reqwest_retry::{RetryTransientMiddleware, policies::ExponentialBackoff};
use reqwest_tracing::TracingMiddleware;

#[tokio::main]
async fn main() {
    let retry_policy = ExponentialBackoff::builder().build_with_max_retries(3);
    let client = ClientBuilder::new(reqwest::Client::new())
        .with(TracingMiddleware)
        .with(RetryTransientMiddleware::new_with_policy(retry_policy))
        .build();
    run(client).await;
}

async fn run(client: ClientWithMiddleware) {
    // free retries!
    client
        .get("https://some-external-service.com")
        .header("foo", "bar")
        .send()
        .await
        .unwrap();
}

现有技术

在讨论我们的实现之前,让我们先看看现有的一些常用的中间件API:

Surf

Surf 是一个Rust HTTP客户端。这是他们文档中的中间件示例:


#![allow(unused)]
fn main() {
/// Log each request's duration
#[derive(Debug)]
pub struct Logger;

#[surf::utils::async_trait]
impl Middleware for Logger {
    async fn handle(
        &self,
        req: Request,
        client: Client,
        next: Next<'_>,
    ) -> Result<Response> {
        println!("sending request to {}", req.url());
        let now = time::Instant::now();
        let res = next.run(req, client).await?;
        println!("request completed ({:?})", now.elapsed());
        Ok(res)
    }
}
}

我们能看到,它接受一个请求对象和一个next值,该值可用于将该请求转发到剩余的管道中,并返回一个Response。这让我们在向下转发之前,可以通过改变请求方式来处理请求,我们还可以在返回之前更改从next.run返回的res值。

我们甚至可以在next周围使用控制流,它允许重试和短路:


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct ConditionalCall;

#[surf::utils::async_trait]
impl Middleware for ConditionalCall {
    async fn handle(
        &self,
        req: Request,
        client: Client,
        next: Next<'_>,
    ) -> Result<Response> {
        // Silly example: return a dummy response 50% of the time
        if rand::random()::<bool>() {
          let res = next.run(req, client).await?;
          Ok(res)
        } else {
          let response = http_types::Response::new(StatusCode::Ok);
          Ok(response)
        }
    }
}
}

Express

Express是一个完善的Node.js Web框架。它的中间件被编写为普通函数,这是他们文档中的一个例子:

app.use(function (req, res, next) {
  console.log('Time:', Date.now())
  next()
})

这与surf的方法非常相似,除了我们使用response对象并可以直接改变它:中间件函数不返回任何内容。

Tower

tower是用于网络应用程序的通用Rust组件库。

它被用于许多著名crate中,例如hypertonictower的中间件有点复杂,很可能是因为,他们不想强制用户使用动态调度(例如async_trait)。

至于其他库,这是tower文档中给出的示例:


#![allow(unused)]
fn main() {
pub struct LogLayer {
    target: &'static str,
}

impl<S> Layer<S> for LogLayer {
    type Service = LogService<S>;

    fn layer(&self, service: S) -> Self::Service {
        LogService {
            target: self.target,
            service
        }
    }
}

// This service implements the Log behavior
pub struct LogService<S> {
    target: &'static str,
    service: S,
}

impl<S, Request> Service<Request> for LogService<S>
where
    S: Service<Request>,
    Request: fmt::Debug,
{
    type Response = S::Response;
    type Error = S::Error;
    type Future = S::Future;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.service.poll_ready(cx)
    }

    fn call(&mut self, request: Request) -> Self::Future {
        // Insert log statement here or other functionality
        println!("request = {:?}, target = {:?}", request, self.target);
        self.service.call(request)
    }
}
}

忽略用于反压的poll_ready方法,towerService被定义为从请求到响应的函数:call返回一个Future,其中Future::ItemService::Response的关联类型。

surf中的异步中间件的trait更为简单,因为它依赖于过程宏(async_trait),在trait中使用async fn语法——在底层它转换为boxing futures 。这是必要的,因为trait方法尚不支持异步。请参阅Nicholas D. Matsakis的这篇文章以深入了解原因。

tower中的中间件是通过Layer trait定义的,该trait将一个服务映射到另一个服务。实现这个特性通常涉及让一个通用结构包装一些Service并委托对它的调用。

被包装的Servicesurfexpress中的next参数起到相同的作用。它提供了一种调用中间件链其余部分的方法。这种方法仍然允许我们使用next的API相同的方式处理请求和响应。

Finagle

Finagle是一个用Scala编写的JVM RPC系统。让我们也从finagle文档中举一个中间件示例:

class TimeoutFilter[Req, Rep](timeout: Duration, timer: Timer)
  extends SimpleFilter[Req, Rep] {
  def apply(request: Req, service: Service[Req, Rep]): Future[Rep] = {
    val res = service(request)
    res.within(timer, timeout)
  }
}

这里的Servicetower非常相似:一个从请求到响应的函数。

Finagle中的中间件称为FilterFilter类型比towerLayer更复杂,因为它不要求apply中的ReqRep类型与服务参数中请求和回复的类型保持一致。

SimpleFilter,顾名思义,是具有固定请求/响应类型的简化版本。SimpleFilter将一个请求和包装服务作为参数,并返回一个响应,因此它的功能类似tower API,但是将Layer::layerService::call压缩到了单个SimpleFilter::apply方法中。

中间件类型

一般来说,你会发现,中间件API分为两类:要么是一个参数为请求和next的函数,就像surfexpress;或者从一个映射服务到另一个,就像towerFinagle.

总的来说,这两种方法都提供了同样多的灵活性。两者都需要每个中间件至少有一个额外的动态分发,因为 Rust不支持在 trait 方法的返回类型中包含impl Trait(目前),所以我们采用Next方法,因为这使得更容易实现中间件。surftower之间的差异证明了这一点。

reqwest-中间件

我们最终得到了一个非常标准的中间件API(有关API的更详细描述,请参阅文档):


#![allow(unused)]
fn main() {
#[async_trait]
pub trait Middleware {
  async fn handle(&self, req: Request, extensions: &mut Extensions, next: Next<'_>) 
   -> Result<Response>;
}
}

Extensions用于以类型安全的方式跨中间件获取任意信息,不论是从外部中间件到更深的中间件,还是从内部中间件到以前的中间件。

出于演示目的,举例一个简单的日志中间件实现:


#![allow(unused)]
fn main() {
use reqwest::{Request, Response};
use reqwest_middleware::{Middleware, Next};
use truelayer_extensions::Extensions;

struct LoggingMiddleware;

#[async_trait::async_trait]
impl Middleware for LoggingMiddleware {
    async fn handle(
        &self,
        req: Request,
        extensions: &mut Extensions,
        next: Next<'_>,
    ) -> reqwest_middleware::Result<Response> {
        tracing::info!("Sending request {} {}", req.method(), req.url());
        let resp = next.run(req, extensions).await?;
        tracing::info!("Got response {}", resp.status());
        Ok(resp)
    }
}
}
use reqwest_middlewar::ClientBuilder;

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt::init();

    let client = ClientBuilder::new(reqwest::Client::new())
        .with(LoggingMiddleware)
        .build();
    client
        .get("https://truelayer.com/")
        .send()
        .await
        .unwrap();
}
$ RUST_LOG=info cargo run
Jul 20 19:59:35.585  INFO post_reqwest_middleware: Sending request GET https://truelayer.com/
Jul 20 19:59:35.705  INFO post_reqwest_middleware: Got response 200 OK

结论

我们使用启用中间件的客户端包装reqwest,该客户端使用相同的简单API。这使得能够为我们的韧性和可观察性需求构建可重用的组件。

最重要的是,我们还发布了reqwest-retryreqwest-opentracing,它们应该能涵盖reqwest crate很多的使用场景。

开发人员现在可以通过导入几个crate并将with_middleware调用添加到客户端设置代码来强化与远程HTTP的集成——而不会中断任何其他应用程序代码。

想用 Rust 编写 GUI 框架吗?

原文链接:https://www.cmyr.net/blog/gui-framework-ingredients.html

通过最近关于 Rust 中 GUI 编程的几次讨论,我留下了这样的印象:“GUI”这个词对不同的人来说意味着截然不同的东西。

我想尝试澄清这一点,首先通过描述人们称为 GUI 框架/工具包的一些不同事物,然后详细探讨其中一个经典桌面 GUI 框架的必要组件。

尽管这篇文章并不是特别针对 Rust,但它确实起源于 Rust:它很大程度上来自于我在 Druid 上工作的经验,Druid 是桌面版的 Rust GUI 工具包。

一旦我们对这个问题有了共同的理解,我们就可以更好地讨论这项工作在 Rust 中的状态,这将是后续帖子的主题 。

当我们谈论 GUI 时我们在谈论什么

GUI 框架可以有很多不同的东西,具有不同的用例和不同的部署目标。用于构建嵌入式应用程序的框架也不会在桌面上轻松运行;用于构建桌面应用程序的框架不会在网络上轻松运行。

无论具体情况如何,都需要认识到一个主要的分界线,即框架是否有望紧密集成到现有平台或环境中。

因此,在这条线的一侧是用于构建游戏、嵌入式应用程序和(在较小程度上)网络应用程序的工具。在这个世界中,您负责提供应用程序所需的几乎所有内容,并且您将与底层硬件密切交互:接受原始输入事件,并将您的 UI 输出到某种缓冲区或表面。(网络是不同的;这里浏览器供应商已经为您完成了集成工作。)

这条线的另一边是用于构建传统桌面应用程序的工具。在这个世界中,您必须与大量现有的平台 API、设计模式和约定紧密集成,而这种集成正是您设计复杂性的主要来源。

游戏和嵌入式 GUI 在我们开始深入研究桌面应用程序框架所期望的所有集成之前,让我们简要谈谈第一种情况。

嵌入式应用程序的游戏和 GUI(想想出租车后面的信息娱乐系统,或医疗设备上的界面)在许多方面与桌面 GUI 不同,其中大部分可以从系统集成的角度考虑:游戏和嵌入式应用程序不必做那么多。一般来说,游戏或嵌入式应用程序是一个自成一体的世界;只有一个“窗口”,应用程序负责绘制其中的所有内容。应用程序不需要担心菜单或子窗口;它不需要担心合成器,或与平台的 IME系统集成。尽管它们可能应该,但它们通常不支持复杂的脚本. 他们可以忽略富文本编辑。他们可能不需要支持font enumeration或fallback。他们经常忽略可访问性。

当然,他们自己也有额外的挑战。嵌入式应用程序必须更仔细地考虑资源限制,并且可能需要完全避免分配。当他们确实需要复杂脚本或文本输入等功能时,他们必须自己实现这些功能,而不能依赖系统提供的任何东西。

游戏是相似的,另外还有它们自己独特的性能问题和考虑因素,我没有资格谈论任何真正的细节。

游戏和嵌入式当然是有趣的领域。特别是嵌入式是我认为 Rust GUI 真的很有意义的地方,出于许多相同的原因,Rust 通常 对嵌入式使用具有强大的价值主张。

然而,一个旨在用于游戏或嵌入式开发的项目不太可能解决我们在桌面应用程序中期望的整个功能列表。

“原生桌面应用程序”剖析

桌面应用程序的主要区别特征是它与平台的紧密集成。与游戏或嵌入式应用程序不同,桌面应用程序需要与主机操作系统以及其他软件密切交互。

我想尝试了解一些主要的必需集成点,以及一些可用于提供它们的可能方法。

窗口化

应用程序必须实例化和管理窗口。API 应该允许自定义窗口外观和行为,包括窗口是否可以调整大小,是否有标题栏等。 API 应该允许多个窗口,并且它还应该以某种方式支持模式和子窗口尊重平台约定。这意味着支持 应用程序模式窗口(例如从整个应用程序中窃取焦点直到处理的警报)以及窗口模式windows(从给定窗口窃取焦点直到处理的警报)。模态窗口用于实现大量常用功能,包括打开/保存对话框(可能是平台的特殊情况)警报、确认对话框以及标准 UI 元素,例如组合框和其他下拉菜单(想想一个文本字段的完成列表)。

API 必须允许相对于父窗口的位置精确定位子窗口。例如在组合框的情况下,当显示选项列表时,您可能希望在列表关闭时使用的相同基线位置绘制当前选定的项目。

标签

您还需要支持选项卡。您应该能够从选项卡组中拖出选项卡以创建新窗口,以及在窗口之间拖动选项卡。理想情况下,您希望使用平台的本机标签基础结构,但是……这很复杂。浏览器都推出了自己的实现,这可能是有充分理由的。你会想尊重周围的标签的用户的喜好(Mac系统,让我们的用户选择打开新窗口的标签,系统范围的),但是这将是一个额外的并发症。如果你跳过它,我会原谅你,但是如果你的框架看到很多用处,那么你每个月都会有人将它报告为错误,直到你死,而且他们没有错。

菜单

与窗口密切相关的是菜单;桌面应用程序应该尊重围绕窗口和应用程序菜单的平台约定。在 Windows(操作系统系列)上,菜单是窗口的一个组件。在 macOS 上,菜单是应用程序的一个属性,它会更新以反映可用于活动窗口的命令。在 linux 上,事情不太清楚。如果您使用 GTK,则有窗口和应用程序菜单,尽管后者已弃用。如果您直接针对 x11 或 Wayland,则需要自己实现菜单,理论上您可以随心所欲,尽管简单的方法是 Windows 风格的窗口菜单。

通常,对于应该提供哪些菜单以及应该在其中显示哪些命令,有明确的 约定;一个行为良好的桌面应用程序应该遵守这些约定。

绘图

要绘制应用程序的内容,您(至少)需要一个基本的 2D 图形 API。这应该提供填充和描边路径(使用颜色,包括透明度,以及径向和线性渐变)、布置文本、绘制图像、定义剪辑区域和应用转换的能力。理想情况下,您的 API 还提供了一些更高级的功能,例如混合模式和模糊,用于阴影等。

这些 API 以略有不同的形式存在于各种平台上。在 macOS 上,有CoreGraphics,在 Windows Direct2D 上,在 linux 上有Cairo。那么,一种方法是在这些平台 API 之上呈现一个通用的 API 抽象,在粗糙的边缘上涂油并填补空白。(这是我们目前采用的方法,使用piet库。)

这确实有其缺点。这些 API 足够不同(特别是在棘手的领域,例如 text),设计一个好的抽象可能具有挑战性,并且需要一些跳跃。微妙的不同平台行为会导致渲染不规则。

在任何地方都使用相同的渲染器会更简单。一种选择可能是像Skia这样的东西,它是 Chrome 和 Firefox 中使用的渲染引擎。这具有可移植性和一致性的优点,但代价是二进制大小和编译时间成本;使用skia-safe crate的Rust 二进制文件的发布版本的基线大小约为17M(我的方法不是很好,但我认为这是一个合理的基线。)

Skia 仍然是一个相当传统的软件渲染器,尽管它现在确实有重要的 GPU 支持。不过,归根结底,最令人兴奋的前景是那些将更多渲染任务转移到 GPU 的前景。

这里的一个初始挑战是用于 GPU 编程的 API 的多样性,即使对于相同的硬件也是如此。相同的物理 GPU 可以通过 Apple 平台上的Metal、Windows上的DirectX和许多其他平台上的Vulkan进行连接。使代码在这些平台上可移植需要重复实现、某种形式的交叉编译或 抽象层。后一种情况的问题在于,很难编写一个抽象来提供对高级 GPU 功能(例如计算能力)的充分控制,这些功能跨细微不同的低级 API。

一旦你弄清楚你想如何与硬件对话,你就需要弄清楚如何在 GPU 上有效和正确地光栅化 2D 场景。这也可能比您最初怀疑的更复杂。由于 GPU 擅长绘制 3D 场景,而且 3D 场景似乎比 2D 场景“更复杂”,因此 GPU 应该轻松处理 2D 似乎是一个自然的结论。他们不。3D 中使用的光栅化技术不太适合 2D 任务,例如裁剪到矢量路径或抗锯齿,而那些产生最佳结果的技术性能最差。更糟糕的是,一旦涉及到大量混合组或剪辑区域,这些传统技术在 2D 中的表现就会变得非常糟糕,因为每个区域都需要自己的临时缓冲区和绘制调用。

有一些有前途的新工作(例如piet-gpu)使用计算着色器,并且可以在 2D 成像模型中以平滑一致的性能绘制场景。这是一个活跃的研究领域。一个潜在的限制是计算着色器是一项相对较新的功能,并且仅在过去五年左右制造的 GPU 中可用。其他渲染器,包括Firefox 使用的WebRender,使用更传统的技术并具有更广泛的兼容性。

动画

哦,还有:无论您选择哪种方法,您还需要提供符合人体工程学的高性能动画 API。值得尽早考虑这一点;稍后尝试添加它会很烦人。

文本

不管你如何绘画,你都需要渲染 text。GUI 框架至少应该支持富文本、复杂脚本、文本布局(包括诸如换行、对齐和对齐之类的东西,理想情况下诸如在任意路径中换行之类的东西)。您需要支持表情符号。您还需要支持文本编辑,包括支持从右到左和BiDi。可以说这是一项非常庞大的事业。实际上,您有两种选择:捆绑 HarfBuzz或使用平台文本 API:macOS上的CoreText、Windows上的 DirectWrite以及可能的Pango+ Linux 上的 HarfBuzz。还有其他一些替代方案,包括一些有前途的 Rust 项目(例如 Allsorts、rustybuzz和swash),但这些项目都还不够完整,无法完全取代 HarfBuzz 或平台文本 API。

合成器

2D 图形是可能由桌面应用程序完成的绘图的主要部分,但它们不是唯一的部分。还有另外两种常见情况值得一提:视频和 3D 图形。在这两种情况下,我们都希望能够利用可用的硬件:对于视频,硬件 H.264 解码器,对于 3D,GPU。这归结为指示操作系统在我们窗口的某个区域嵌入视频或 3D 视图,这意味着与合成器进行交互。合成器是操作系统的组件,负责从各种来源(不同程序的不同窗口、视频播放、GPU 输出)获取显示数据,并将其组装成桌面的连贯图片。

也许思考为什么这对我们很重要的最好方法是考虑与滚动的交互。如果您有一个可滚动视图,并且该视图包含一个视频,您希望在滚动视图时视频与视图的其他内容同步移动。这比听起来要难。您不能只定义窗口的一个区域并在其中嵌入视频;您需要以某种方式告诉操作系统与滚动同步移动视频。

网页浏览器

让我们不要忘记这些:迟早会有人想要在他们的应用程序中显示一些 HTML(或一个实际的网站!)。我们真的不想捆绑整个浏览器引擎来实现这一点,但使用平台 webview也会牵涉到合成器,并且总体上使我们的生活显着复杂化。也许您的用户根本不需要那个网络视图?无论如何,有些事情要考虑。

输入处理

一旦您弄清楚如何管理窗口以及如何绘制内容,您就需要处理用户输入。我们可以粗略地将输入分为 指针、键盘和其他,其中其他是操纵杆、游戏手柄和其他HID 设备之类的东西。我们将忽略最后一个类别,只是说这很好,但不需要成为优先事项。最后,还有源自系统可访问性功能的输入事件;当我们谈论可访问性时,我们将处理这些。

对于指针和键盘事件,有一个相对简单的方法,然后有一个原则性的、正确的方法,但要正确得多。

指针输入

对于指针事件,简单的方法是呈现一个发送鼠标事件的 API,然后以一种使它们看起来像鼠标事件的方式发送触控板事件:忽略多次触摸、压力或其他不具有明显特征的触摸手势的特征类似于鼠标。该硬的方法是实现网络的一些等价PointerEvent API,你都能够充分代表了多点触摸的信息(从触控板都还有一个触控式显示器),其中和手写笔的输入事件。

以简单的方式执行指针事件是……好吧,假设您还可以为常见的触控板手势(例如捏缩放和两指滚动)提供事件,否则您的框架将立即使许多用户感到沮丧。虽然需要或想要进行高级手势识别或期望处理手写笔输入的应用程序数量相当少,但它们确实存在,并且不支持这些情况的桌面应用程序框架从根本上是有限的。

键盘输入

键盘输入的情况更糟,有两个方面:在这里,困难的情况既难以做到,而“简单的方法”则从根本上受到限制;走简单的路线意味着您的框架对于世界上的大部分人口来说基本上是无用的。

对于键盘输入来说,简单的方法非常简单:键盘的键通常与一个字符或字符串相关联,当用户按下一个键时,您可以取出该字符串并将其插入活动中的光标位置文本域。这对于单语英语文本相当有效,对于一般的拉丁语 1语言以及行为类似于拉丁语的脚本,例如希腊语或西里尔语或土耳其语,效果稍差但至少是一种。不幸的是(但并非巧合),大量程序员大多只键入 ASCII,但世界上的大部分地区都没有。服务于这些 用户需要与平台文本输入和 IME 系统集成,这是一个不幸的问题,它既是必不可少的,又是极其繁琐的。

IME 代表Input Method Editor,是将键盘事件转换为文本的平台特定机制的统称。对于大多数欧洲语言和文字,这个过程相当简单,您最多可能需要插入一个带重音的元音,但对于东亚语言(中文、日文和韩文,或统称为 CJK)来说,这个过程要复杂得多,因为以及其他各种复杂的脚本。

这在很多方面都很复杂。首先,它意味着给定文本字段和 IME 之间的交互是双向的:IME 需要能够修改文本框的内容,但还需要能够查询文本框的当前内容,在以便有适当的上下文来解释事件。同样,需要通知光标位置或选择状态的变化;相同的按键可能会根据周围的文本产生不同的输出。其次,我们还需要在屏幕上文本框的位置上使 IME 保持最新,因为 IME 经常为键盘事件的活动序列提供可能输入的“候选”窗口。最后(实际上并不像最后,我已经写了三千字,但还没有完成)由于底层平台 API 的差异,以跨平台方式实现 IME 非常复杂;macOS 需要可编辑的文本字段来 实现协议,然后让文本字段处理接受和应用来自 IME 的更改,而Windows API使用 锁定和释放机制;设计对这两种方法的抽象是一个额外的复杂层。

还有一个与文本输入相关的额外复杂性:在 macOS 上,您需要支持Cocoa Text System,它允许用户指定可以发出各种文本编辑和导航命令的系统范围的键绑定。

总而言之:正确处理输入需要大量工作,如果您不这样做,您的框架基本上就是一个玩具。

无障碍

桌面应用程序框架必须支持本机可访问性 API,并且理想情况下应该以不需要应用程序开发人员特殊思考或工作的方式来实现这一点。可访问性是大量辅助技术的统称。最重要的是支持 屏幕阅读器和辅助导航。屏幕阅读器支持意味着与描述应用程序结构和内容的平台 API 互操作,辅助导航意味着提供一种在屏幕上的元素之间线性移动的方法,允许使用键盘或操纵杆依次突出显示、描述和激活元素.

除了这些核心功能之外,您的框架还应尊重用户在系统级偏好方面的诸如文本大小、降低颜色对比度和减少动画等方面的偏好。相关,但不是可访问性,确切地说:您希望支持暗模式,以及用户选择的强调色之类的东西。

国际化和本地化

你的框架应该支持国际化。其中最明显的组成部分是字符串的本地化,但它还包括在从右到左的语言环境中镜像接口之类的内容。此外,时间、日期、货币单位、日历单位、名称、序列和数字数据的一般 格式等信息应尊重用户的语言环境。如果这不是您之前考虑过的问题,那么它几乎肯定比您想象的要复杂。但别担心: 有一个标准的. 您需要做的就是实施它。

其他不太常见的功能

除了在大多数桌面环境中共享的所有功能外,还需要考虑特定于平台的功能:其中一些是风格方面的东西,例如用于为窗口的某些部分添加透明度或活力的API ;或支持添加额外的菜单栏或使用任务栏扩展,或快速查看,或实现控制面板项目,或任何数量的其他东西。你的框架至少应该让这些事情成为 可能. 至少,您应该为用户提供直接使用平台 API 的机会,以便他们在真正需要实现一些您没有预见到的(或绕过来然而)。

组合起来

感觉这是一个合理的停下来的地方;我当然忽略了一些事情,但我希望我已经触及了最重要的事情。一旦您对需要支持和实施的事情有了一个想法,您就可以开始考虑如何将它们组合在一起。

设计跨平台 API

设计 GUI 框架的一项更微妙和有趣的挑战是设计 API。在这里,您面临一个非常特殊的问题:您正在尝试设计一个 API,该 API 为一组根本不同的底层平台 API 提供通用接口。

一个很好的例子是关于你的应用程序的菜单。如前所述,Linux 和 Windows 通常希望在您的应用程序的各个窗口上存在一个菜单栏,而 macOS 有一个菜单栏,它是桌面环境的一个组件,当您的应用程序处于活动状态时,它将成为您的应用程序菜单。

为了天真地处理这个问题,您可能有单独的“应用程序”和“窗口”菜单,然后您可能有条件代码来根据条件编译或运行时检查更新一个或另一个。然而,这最终会产生大量重复代码,而且很容易出错。在这种特殊情况下,我认为有一个相当清晰、相当简单的 API 可以在两个平台上运行。在您的框架中,您将菜单视为窗口的一个属性:在 Windows 和 Linux 上实际上就是这种情况,所以没问题,然后在 macOS 上您将应用程序菜单设置为当前活动窗口的菜单,并对其进行更改当 Windows 获得或失去活动状态时根据需要。

这是一个相当干净的示例,许多其他 API 并不是那么清晰。一般来说,设计这些跨平台 API 是一个仔细阅读和试验特定于平台的 API 的过程,然后尝试确定您可以在上述抽象中表达的共享特性和功能集;当不存在清晰共享的功能集时,这意味着提出一些其他API,这些API 至少可以根据平台提供的内容来实现。

网络视图

一些主要的跨平台 GUI 框架已经成功地解决了所有这些平台复杂性及其所有微妙的设计缺陷、缺少的文档和神秘的错误:主要浏览器、Chrome、Firefox 和(越来越多)Edge . (Safari 不需要担心这个,因为它不是跨平台的。)

浏览器必须弄清楚所有这些:子窗口、文本输入、可访问性、字体回退、合成器、高性能绘画、拖放……都在那里。

如果你想做一些跨平台的事情,那么,通过创建一个在浏览器中运行的真正的 web 应用程序,或者依靠浏览器引擎,有一种非常自然且可以理解的冲动去接触网络技术并使用它在本机窗口中呈现您的 UI,à la Electron。这确实有明显的缺点,特别是在性能(在各个方面,例如应用程序大小和内存消耗)以及“外观和感觉”(我们将很快扩展)方面,但它确实让生活变得更简单,我花在这个领域的项目上的时间越多,我就越同情那些选择浏览器方面的人。

原生外观和感觉

在讨论跨平台 GUI 工作时经常出现的东西是我称之为“本机外观和感觉”的东西的集合。这是模糊的,我认为将其分为两部分是有帮助的:原生行为和约定,以及原生外观(尽管这些可以重叠。)

本机行为指的是我们已经讨论过的许多事情,以及除此之外的其他一些事情。一些示例是滚动行为:您的应用程序是否尊重用户的滚动偏好?您的应用程序在滚动时是否具有与默认平台滚动视图相同的加速度曲线?您的应用程序是否处理标准系统键盘快捷键,例如最大化或隐藏窗口?输入法有效吗?这也扩展到其他不太明显的约定:应用程序是否将用户数据存储在当前平台的常规位置?它是否使用系统文件打开/保存对话框?它是否显示包含预期菜单项的预期菜单?

这些东西在某些平台上比在其他平台上更重要。特别是在 Mac 上,让这些行为细节正确很重要:Mac 比其他平台更多地是围绕特定约定设计的,Mac 应用程序开发人员历来都在努力遵守这些约定。这反过来又帮助创建了一个用户社区,他们重视这些约定并对它们很敏感,而打破它们势必会 扰乱这个群体。在 Windows 上,事情稍微轻松一些;历史上,Windows 上的软件种类繁多,而且微软从来没有像 Apple 那样对应用程序的外观和行为表现得如此教条。

本机外观更多地是指应用程序的外观。你的按钮看起来像原生按钮吗?它们是否具有相同的尺寸和渐变?您是否更普遍地使用平台期望用于给定交互的控件,例如更喜欢桌面上的复选框但移动设备上的切换?

由于“本机外观”不仅在平台之间而且在操作系统版本之间发生变化,这一事实也变得更加复杂,以至于在给定机器上看起来“本机”需要对操作系统版本进行运行时检测。

虽然所有这些都是可能的,但它开始增加大量额外的工作,而且对于人员有限的项目来说,这很难证明是合理的。出于这个原因,我个人原谅一个项目,该项目不再试图对平台的内置小部件进行像素完美的复制,而是倾向于尝试做一些有品位和连贯的事情,同时提供框架所需的工具用户根据需要设置样式。

总结

我希望这个目录至少有助于模糊地定义问题的范围。我在这里所描述的事情都不是不可能的,但这样做所有这些,和他们做的很好,是相当多的工作。

最后一点值得结束:为了使这项工作有用,它的存在是不够的。如果您希望人们使用您的框架,您将必须使其对他们有吸引力:提供易于使用的良好 API,在宿主语言中是惯用的,有据可查的,并且可以让他们解决他们的实际问题。

Trait Upcasting 系列 | 如何把子 trait 转成父 trait ?

作者: CrLF0710

本文由 Trait Upcasting 贡献者 CrLF0710(猫老师)来介绍一下特质向上类型转换(Trait upcasting coercion) 这个功能。


此功能对应的MCP是Trait Upcasting · Issue #98 · rust-lang/lang-team (github.com)

现在是2021年8月,主体功能已经完成了,在nightly上可以试用。

目前还剩两个边角情况没支持:

  1. 在新的vtable设计方案下,trait upcasting 在多重继承存在的情况下有时需要实际访问vtable 内容。因为原生指针(raw pointer)的metadata是否必须有效是未解决问题,因而对原生指针的upcasting 应该是unsafe操作。这又涉及到了CoerceUnsized 相关的一些问题。(本条为这个功能目前为incomplete_features的主要原因)

  2. 在多重继承下,trait存在列表中可能具有相同父trait使用不同泛型参数的情况。这里的类型推导有一些实现上的问题,目前尚未实现。(这种情况下会提示无法转换)

接下来我会继续推动这个功能完成,然后根据实现经验撰写一篇RFC。如果有必要的话,我也许会再看看在chalk下怎么实现这个功能。

接下来简单介绍下这个功能怎么用,其实还蛮自然的:

1需要转换的地方,标注转换的目标类型(函数参数、返回值之类的地方已经有标注了),然后如果编译器认为可以(认定的方法可以参见下面的详细设计),转换就会成功了。

以下是详细设计:

  1. 对于实现了CoerceUnsized特质的类型可以在两个类型(暂时称为T, U)间进行尺寸擦除类型转换(unsizing coercion),最常用的有从 &T -> &U, Box 到 Box等等。类型转换的场景(coercion site) 简单说就是转换目标有类型标注的情况,包括let上的类型标注,函数参数的标注,返回值的标注,as表达式等等。
  2. 特质对象的语法是 dyn PrincipalTrait [+ AutoTrait]* [+ 'lifetime]* 或 dyn AutoTrait [+ AutoTrait]* [+ 'lifetime]* 其中方括号表示可选,星号表示0个或任意个。 这里的PrincipalTrait是一个Rustc内部术语,指的是占据特质对象主导地位的那个特质。(Rust目前不支持多个)。特质对象上面的方法/关联函数由这个特质和它的所有的祖先特质决定。
  3. 特质对象之间的尺寸擦除类型转换原本就存在,主要包括三种: a. 减少AutoTrait部分 b. 在subtyping规则允许的范围下调整lifetime c. 本次新增:将PrincipalTrait置换为它的任意一个祖先特质。
  4. 置换的方法是调整trait object的metadata,也就是vtable的指针。我们本次重新设计了vtable的结构: 对于
    A
    / \
    B C
    \ /
    D
    

这样的菱形继承结构,我们保障最左边一列 A- B 特质所需的vtable恰好是D的vtable的前缀(从而对单继承优化),并为这一列之外的特质(C)在vtable中存储一个vtable指针。 实际转换时,对于最左边一列的向上转换,是no-op,不会做任何事。对于这一列之外的转换,从vtable中取出对应的vtable指针替换即可。

目前官方 T-lang下推动这件事的活动(initiative),有反馈和讨论都会放这个仓库里:https://github.com/rust-lang/dyn-upcasting-coercion-initiative/,感兴趣的可以关注。

Trait Upcasting 系列 | Part II

作者:张汉东 / 审校:CrLF0710

记录 Trait Upcasting系列 系列 PR 过程。

PR 系列:

  1. Refactor vtable codegen #86291
  2. Change vtable memory representation to use tcx allocated allocations.#86475
  3. Refactor vtable format for upcoming trait_upcasting feature. #86461
  4. Trait upcasting (part1) #86264
  5. Trait upcasting (part2)

本文为 第二个 PR 的描述。


在第一个 PR 发出之后,收到了官方成员(Member)的一些 review 意见。其中之一就是促进第二个 PR 的原因,被记录于 issues #86324

第二个 PR 的目标是在#86291 (comment) 中描述:

第一步是重构 miri 中的 vtable 生成,以在Machine上下文之外创建一个Allocation

cg_{clif,ssa} 中的 vtable 代码生成器的地方可以调用此函数,然后再调用任何用于降级到后端常量分配的方法。

trait + type -> allocation 的映射添加到 tcx.alloc_map 或类似的东西来替换后端内部实现也不错。

一句话描述:修改miri和两套codegen 以便让它使用tcx中构建的用allocation表示的 vtable。

编译器内部概念说明

tcx 是指类型上下文,是由编译器内部 rustc_middle::ty模块定义的,它是编译器内部核心数据结构。

Rust 的类型在编译器内部,由 Ty 表示。当我们说Ty的时候,是指rustc_middle::ty::Ty,而不是指rustc_hir::Ty,了解它们之间的区别是比较重要的。

rustc_hir::Ty vs ty::Ty

rustc_hir::Ty表示脱糖以后的类型,而ty::Ty 代表了类型的语义。

例如,fn foo(x: u32) → u32 { x } 这个函数中,u32 出现两次。从 HIR 的角度看,这是两个不同的类型实例,因为它们出现在程序中不同的地方,也就是说,它们有两个不同的 Span (位置)。但是对于 ty::Ty来说,u32 在整个程序中都是同一个类型,它代表的不是具体的类型实例。

除此之外,HIR 还会有更多的信息丢失。例如, fn foo(x: &u32) -> &u32,在 HIR 看来,它不需要 lifetime 信息,所以 &u32 是不完整的。但是对于 ty::Ty 来说,它是完整的包含了 lifetime 信息。

一个简单总结:

rustc_hir::Tyty::Ty
描述类型的「语法」描述类型的「语义」
每一个 rustc_hir::Ty都有自己的 Span整个程序而言都是同一个类型,并不特指某个类型实例
rustc_hir::Ty 有泛型和生命周期; 但是,其中一些生命周期是特殊标记,例如 LifetimeName::Implicitty::Ty 具有完整的类型,包括泛型和生命周期,即使用户将它们排除在外

HIR 是从 AST 中构建的,它产生在 ty::Ty 之前。在 HIR 构建之后,一些基本的类型推导和类型检查就完成了。ty::Ty就是被用于类型检查,并且确保所有的东西都有预期的类型。 rustc_typeck::astconv 模块负责将 rustc_hir::Ty转换为ty::TY

ty::Ty 实现

rustc_middle::ty::Ty实际上是&TyS的一个类型别名。&TyS Type Structure的简称。一般情况下,总是会通过 ty::Ty 这个类型别名来使用 &TyS

要分配一个新的类型,你可以使用tcx上定义的各种mk_方法。这些方法的名称主要与各种类型相对应。例如:


#![allow(unused)]
fn main() {
let array_ty = tcx.mk_array(elem_ty, len * 2); // 返回 Ty<'tcx> 
}

你也可以通过访问tcx的字段来找到tcx本身的各种常见类型:tcx.types.booltcx.types.char,等等。

修改文件概述

本次修改涉及 21 个文件。

  1. compiler/rustc_codegen_cranelift/src/common.rs
  2. compiler/rustc_codegen_cranelift/src/constant.rs
  3. compiler/rustc_codegen_cranelift/src/lib.rs
  4. compiler/rustc_codegen_cranelift/src/unsize.rs
  5. compiler/rustc_codegen_cranelift/src/vtable.rs
  6. compiler/rustc_codegen_llvm/src/common.rs
  7. compiler/rustc_codegen_ssa/src/meth.rs
  8. compiler/rustc_codegen_ssa/src/traits/consts.rs
  9. compiler/rustc_middle/src/ty/context.rs
  10. compiler/rustc_middle/src/ty/mod.rs
  11. compiler/rustc_middle/src/ty/vtable.rs
  12. compiler/rustc_mir/src/interpret/eval_context.rs
  13. compiler/rustc_mir/src/interpret/intern.rs
  14. compiler/rustc_mir/src/interpret/memory.rs
  15. compiler/rustc_mir/src/interpret/terminator.rs
  16. compiler/rustc_mir/src/interpret/traits.rs
  17. src/test/ui/consts/const-eval/ub-upvars.32bit.stderr
  18. src/test/ui/consts/const-eval/ub-upvars.64bit.stderr
  19. src/test/ui/consts/issue-79690.64bit.stderr
  20. src/test/ui/consts/miri_unleashed/mutable_references_err.32bit.stderr
  21. src/test/ui/consts/miri_unleashed/mutable_references_err.64bit.stderr

修改主要涉及 五个组件:

  1. rustc_middle,属于 rust 编译器的 main crate ,包含rustc“家族”中的其他crate使用的通用类型定义,包括 HIR/MIR/Types。
  2. rustc_codegen_ssa,截至2021年1月,RustC_Codegen_SSA 为所有后端提供了一个抽象的接口,以允许其他Codegen后端(例如Cranelift)。
  3. rustc_mir,用于操作 MIR 的库。
  4. rustc_codegen_cranelift,是 基于 cranelift 的编译器后端,专门用于 debug 模式
  5. rustc_codegen_llvm,是 基于 llvm 的编译器后端,专门用于 release 模式

rustc_middle 库中的修改

  1. 首先新增 src/ty/vtable.rs 模块,将 vtable 的内存分配移动到 rustc_middle,以达到通用的目的。
  2. src/ty/mod.rs 中将 vtable 模块导入
  3. src/ty/context.rs 中增加 vtables_cache

src/ty/vtable.rs 模块


#![allow(unused)]
fn main() {
use std::convert::TryFrom;

use crate::mir::interpret::{alloc_range, AllocId, Allocation, Pointer, Scalar};
use crate::ty::fold::TypeFoldable;
use crate::ty::{self, DefId, SubstsRef, Ty, TyCtxt}; // 导入 `ty`模块中相关类型 
use rustc_ast::Mutability;

#[derive(Clone, Copy, Debug, PartialEq, HashStable)]
pub enum VtblEntry<'tcx> {
    MetadataDropInPlace,
    MetadataSize,
    MetadataAlign,
    Vacant,
    Method(DefId, SubstsRef<'tcx>),
}

pub const COMMON_VTABLE_ENTRIES: &[VtblEntry<'_>] =
    &[VtblEntry::MetadataDropInPlace, VtblEntry::MetadataSize, VtblEntry::MetadataAlign];

pub const COMMON_VTABLE_ENTRIES_DROPINPLACE: usize = 0;
pub const COMMON_VTABLE_ENTRIES_SIZE: usize = 1;
pub const COMMON_VTABLE_ENTRIES_ALIGN: usize = 2;

impl<'tcx> TyCtxt<'tcx> {
  	// 给 vtable 分配内存,`TyCtxt` 中包含一个缓存,所以必须删除其重复数据
    /// Retrieves an allocation that represents the contents of a vtable.
    /// There's a cache within `TyCtxt` so it will be deduplicated.
    pub fn vtable_allocation(
        self,
        ty: Ty<'tcx>,
        poly_trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
    ) -> AllocId {
        let tcx = self;
        let vtables_cache = tcx.vtables_cache.lock();
        if let Some(alloc_id) = vtables_cache.get(&(ty, poly_trait_ref)).cloned() {
            return alloc_id;
        }
        drop(vtables_cache);

        // See https://github.com/rust-lang/rust/pull/86475#discussion_r655162674
        assert!(
            !ty.needs_subst() && !poly_trait_ref.map_or(false, |trait_ref| trait_ref.needs_subst())
        );
        let param_env = ty::ParamEnv::reveal_all();
        let vtable_entries = if let Some(poly_trait_ref) = poly_trait_ref {
            let trait_ref = poly_trait_ref.with_self_ty(tcx, ty);
            let trait_ref = tcx.erase_regions(trait_ref);

            tcx.vtable_entries(trait_ref)
        } else {
            COMMON_VTABLE_ENTRIES
        };

        let layout =
            tcx.layout_of(param_env.and(ty)).expect("failed to build vtable representation");
        assert!(!layout.is_unsized(), "can't create a vtable for an unsized type");
        let size = layout.size.bytes();
        let align = layout.align.abi.bytes();

        let ptr_size = tcx.data_layout.pointer_size;
        let ptr_align = tcx.data_layout.pointer_align.abi;

        let vtable_size = ptr_size * u64::try_from(vtable_entries.len()).unwrap();
        let mut vtable = Allocation::uninit(vtable_size, ptr_align);

      
        // 无需对下面的内存访问进行任何对齐检查,因为我们知道
				// 分配正确对齐,因为我们在上面创建了它。 我们也只是抵消了
				// `ptr_align` 的倍数,这意味着它将与 `ptr_align` 保持对齐
        // No need to do any alignment checks on the memory accesses below, because we know the
        // allocation is correctly aligned as we created it above. Also we're only offsetting by
        // multiples of `ptr_align`, which means that it will stay aligned to `ptr_align`.

        for (idx, entry) in vtable_entries.iter().enumerate() {
            let idx: u64 = u64::try_from(idx).unwrap();
            let scalar = match entry {
                VtblEntry::MetadataDropInPlace => {
                    let instance = ty::Instance::resolve_drop_in_place(tcx, ty);
                    let fn_alloc_id = tcx.create_fn_alloc(instance);
                    let fn_ptr = Pointer::from(fn_alloc_id);
                    fn_ptr.into()
                }
                VtblEntry::MetadataSize => Scalar::from_uint(size, ptr_size).into(),
                VtblEntry::MetadataAlign => Scalar::from_uint(align, ptr_size).into(),
                VtblEntry::Vacant => continue,
                VtblEntry::Method(def_id, substs) => {
                    // See https://github.com/rust-lang/rust/pull/86475#discussion_r655162674
                    assert!(!substs.needs_subst());

                    // Prepare the fn ptr we write into the vtable.
                    let instance =
                        ty::Instance::resolve_for_vtable(tcx, param_env, *def_id, substs)
                            .expect("resolution failed during building vtable representation")
                            .polymorphize(tcx);
                    let fn_alloc_id = tcx.create_fn_alloc(instance);
                    let fn_ptr = Pointer::from(fn_alloc_id);
                    fn_ptr.into()
                }
            };
            vtable
                .write_scalar(&tcx, alloc_range(ptr_size * idx, ptr_size), scalar)
                .expect("failed to build vtable representation");
        }

        vtable.mutability = Mutability::Not;
        let alloc_id = tcx.create_memory_alloc(tcx.intern_const_alloc(vtable));
        let mut vtables_cache = self.vtables_cache.lock();
        vtables_cache.insert((ty, poly_trait_ref), alloc_id);
        alloc_id
    }
}
}

src/ty/context.rs


#![allow(unused)]
fn main() {
pub struct GlobalCtxt<'tcx> {
    // ...
  
    // 不过在合并以后,eddyb 对此代码提出了异议: https://github.com/rust-lang/rust/pull/86475/files#r680788892
    // FxHashMap 是 rustc 内部使用的一个 hashmap 结构,使用了比 fnv 还快的 hasher,因为这里没有必要防止 DoS 攻击
    pub(super) vtables_cache:
        Lock<FxHashMap<(Ty<'tcx>, Option<ty::PolyExistentialTraitRef<'tcx>>), AllocId>>,
}

impl<'tcx> TyCtxt<'tcx> {
    pub fn create_global_ctxt( /* ... */ ) {
        // ...
        GlobalCtxt {
            // ...
            vtables_cache: Default::default(),
        }
    }
}
}

rustc_codegen_ssa 中的修改

修改 src/traits/consts.rs 中的 ConstMethods trait,该 trait 定义了一些方法用于调用不同 后端的相关实现。比如在 rustc_codegen_llvm中:


#![allow(unused)]
fn main() {
impl ConstMethods<'tcx> for CodegenCx<'ll, 'tcx> {
    // ... 
}
}

src/traits/consts.rs 中 :


#![allow(unused)]


fn main() {
pub trait ConstMethods<'tcx>: BackendTypes {
 
    // ...
  
    fn const_data_from_alloc(&self, alloc: &Allocation) -> Self::Value;
    // ...
  
}

}

然后在 src/meth.rs 中引入 ty::Ty,并移除 vtable 内存分配相关代码


#![allow(unused)]


fn main() {
use rustc_middle::ty::{self, Ty};


pub fn get_vtable<'tcx, Cx: CodegenMethods<'tcx>>(
    cx: &Cx,
    ty: Ty<'tcx>,
    trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
) -> Cx::Value {
    let tcx = cx.tcx();

    debug!("get_vtable(ty={:?}, trait_ref={:?})", ty, trait_ref);

    // Check the cache.
    if let Some(&val) = cx.vtables().borrow().get(&(ty, trait_ref)) {
        return val;
    }
   
    // 新增
    let vtable_alloc_id = tcx.vtable_allocation(ty, trait_ref);
    let vtable_allocation = tcx.global_alloc(vtable_alloc_id).unwrap_memory();
    let vtable_const = cx.const_data_from_alloc(vtable_allocation);
  
    let align = cx.data_layout().pointer_align.abi;
    let vtable = cx.static_addr_of(vtable_const, align, Some("vtable"));
    cx.create_vtable_metadata(ty, vtable);
    cx.vtables().borrow_mut().insert((ty, trait_ref), vtable);
    vtable
}
}

rustc_mir 中的修改

viable 内存分配已经被定义在了 rustc_middle::ty::Ty 中,所以要移除 rustc_mir 中 vtable 内存分配相关代码。

rustc_mir 中修改的是 miri 相关代码,miri 用于编译器常量计算。

compiler/rustc_mir/src/interpret/intern.rs 内删除 Vtable 相关内存类型。 该模块用于 常量计算的全局内存分配。


#![allow(unused)]
fn main() {
// compiler/rustc_mir/src/interpret/intern.rs
fn intern_shallow<'rt, 'mir, 'tcx, M: CompileTimeMachine<'mir, 'tcx, const_eval::MemoryKind>>(
    ecx: &'rt mut InterpCx<'mir, 'tcx, M>,
    leftover_allocations: &'rt mut FxHashSet<AllocId>,
    alloc_id: AllocId,
    mode: InternMode,
    ty: Option<Ty<'tcx>>,
) -> Option<IsStaticOrFn> {
    // ...
    match kind {
        MemoryKind::Stack
        | MemoryKind::Machine(const_eval::MemoryKind::Heap)
//      | MemoryKind::Vtable  // 移除
        | MemoryKind::CallerLocation => {}
    }
    // ...
  
}

}

compiler/rustc_mir/src/interpret/eval_context.rs 中删除 vtable cache相关:


#![allow(unused)]
fn main() {
// compiler/rustc_mir/src/interpret/eval_context.rs

pub struct InterpCx<'mir, 'tcx, M: Machine<'mir, 'tcx>> {
    // ...
  
   //  移除下面三行
   //  /// A cache for deduplicating vtables
   //  pub(super) vtables:
   //      FxHashMap<(Ty<'tcx>, Option<ty::PolyExistentialTraitRef<'tcx>>), Pointer<M::PointerTag>>,
 
    // ...
  
}

impl<'mir, 'tcx: 'mir, M: Machine<'mir, 'tcx>> InterpCx<'mir, 'tcx, M> {
    pub fn new(
        tcx: TyCtxt<'tcx>,
        root_span: Span,
        param_env: ty::ParamEnv<'tcx>,
        machine: M,
        memory_extra: M::MemoryExtra,
    ) -> Self {
        InterpCx {
            machine,
            tcx: tcx.at(root_span),
            param_env,
            memory: Memory::new(tcx, memory_extra),
//          vtables: FxHashMap::default(), // 移除此行
        }
    }
    // ...
}
}

compiler/rustc_mir/src/interpret/memory.rs 中:


#![allow(unused)]
fn main() {
impl<T: MayLeak> MayLeak for MemoryKind<T> {
    #[inline]
    fn may_leak(self) -> bool {
        match self {
            MemoryKind::Stack => false,
//          MemoryKind::Vtable => true, // 移除此行
            MemoryKind::CallerLocation => true,
            MemoryKind::Machine(k) => k.may_leak(),
        }
    }
}

impl<T: fmt::Display> fmt::Display for MemoryKind<T> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            MemoryKind::Stack => write!(f, "stack variable"),
//          MemoryKind::Vtable => write!(f, "vtable"), // 移除此行
            MemoryKind::CallerLocation => write!(f, "caller location"),
            MemoryKind::Machine(m) => write!(f, "{}", m),
        }
    }
}
}

compiler/rustc_mir/src/interpret/terminator.rs 中:


#![allow(unused)]
fn main() {
impl<'mir, 'tcx: 'mir, M: Machine<'mir, 'tcx>> InterpCx<'mir, 'tcx, M> {
    // ...
  
    /// Call this function -- pushing the stack frame and initializing the arguments.
    fn eval_fn_call(
        &mut self,
        fn_val: FnVal<'tcx, M::ExtraFnVal>,
        caller_abi: Abi,
        args: &[OpTy<'tcx, M::PointerTag>],
        ret: Option<(&PlaceTy<'tcx, M::PointerTag>, mir::BasicBlock)>,
        mut unwind: StackPopUnwind,
    ) -> InterpResult<'tcx> {
         // ...
      	 
         // 这里处理trait对象
         ty::InstanceDef::Virtual(_, idx) => {
             // ...
             // Find and consult vtable
             let vtable = receiver_place.vtable();
             let fn_val = self.get_vtable_slot(vtable, u64::try_from(idx).unwrap())?; // 修改 `drop_val` 为 `fn_val`

             // ...
             // recurse with concrete function
             self.eval_fn_call(fn_val, caller_abi, &args, ret, unwind)
         }
    }
    // ...
  
}
}

compiler/rustc_mir/src/interpret/traits.rs 中:


#![allow(unused)]
fn main() {
impl<'mir, 'tcx: 'mir, M: Machine<'mir, 'tcx>> InterpCx<'mir, 'tcx, M> {
    /// Creates a dynamic vtable for the given type and vtable origin. This is used only for
    /// objects.
    ///
    /// The `trait_ref` encodes the erased self type. Hence, if we are
    /// making an object `Foo<Trait>` from a value of type `Foo<T>`, then
    /// `trait_ref` would map `T: Trait`.
    pub fn get_vtable(
        &mut self,
        ty: Ty<'tcx>,
        poly_trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
    ) -> InterpResult<'tcx, Pointer<M::PointerTag>> {
        trace!("get_vtable(trait_ref={:?})", poly_trait_ref);

        let (ty, poly_trait_ref) = self.tcx.erase_regions((ty, poly_trait_ref));

        // All vtables must be monomorphic, bail out otherwise.
        ensure_monomorphic_enough(*self.tcx, ty)?;
        ensure_monomorphic_enough(*self.tcx, poly_trait_ref)?;

        // 移除了之前的大部分代码,浓缩为这两行
        // 为 vtable 分配内存,并拿到相关指针
        let vtable_allocation = self.tcx.vtable_allocation(ty, poly_trait_ref);
        let vtable_ptr = self.memory.global_base_pointer(Pointer::from(vtable_allocation))?;

        Ok(vtable_ptr)
    }
}
}

rustc_codegen_cranelift 中的修改

rustc_codegen_cranelift 中也是移除 vtable 内存分配相关代码。

上一个 PR 分析文章中说到, rustc_codgen_cranelift 因为没有依赖 rust_codgen_ssa的一些关键trait,所以vtable 内存分配这里还存在冗余代码。在重构 vtable 内存分配之后,就可以将这些冗余代码消除了。

compiler/rustc_codegen_cranelift/src/vtable.rs 中:


#![allow(unused)]
fn main() {
pub(crate) fn get_vtable<'tcx>(
    fx: &mut FunctionCx<'_, '_, 'tcx>,
    ty: Ty<'tcx>, // 这里使用了 `ty::Ty`
    trait_ref: Option<ty::PolyExistentialTraitRef<'tcx>>,
) -> Value {
    // 删除了之前的内存分配相关代码(主要是 build_vtable 函数),精简很多
    let vtable_ptr = if let Some(vtable_ptr) = fx.vtables.get(&(ty, trait_ref)) {
        *vtable_ptr
    } else {
        let vtable_alloc_id = fx.tcx.vtable_allocation(ty, trait_ref);
        let vtable_allocation = fx.tcx.global_alloc(vtable_alloc_id).unwrap_memory();
        let vtable_ptr = pointer_for_allocation(fx, vtable_allocation);

        fx.vtables.insert((ty, trait_ref), vtable_ptr);
        vtable_ptr
    };

    vtable_ptr.get_addr(fx)
}


}

主要是这个方法的修改,其他修改都是围绕该方法的琐碎修改。

rustc_codegen_llvm 中的修改

compiler/rustc_codegen_llvm/src/common.rs 中:


#![allow(unused)]
fn main() {
impl ConstMethods<'tcx> for CodegenCx<'ll, 'tcx> {
    // ...
  
  	fn const_data_from_alloc(&self, alloc: &Allocation) -> Self::Value {
        const_alloc_to_llvm(self, alloc)
    }
  
    // ...
  
}
}

小结

这次 PR 主要是将 vtable 的内存分配重构到 rustc_middle::ty::Ty ,以便其他组件可以公用。这里只是一个大概梳理,还有很多细节可以深究。

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


medusa: 一个快速安全的多协议蜜罐

一个快速且安全的多协议蜜罐,可以模拟运行 ssh、telnet、http、https 或任何其他 tcp 和 udp 服务器的真实设备。

https://github.com/evilsocket/medusa

winit: 纯 Rust 中的窗口处理库

https://github.com/rust-windowing/winit

anchor : Solana 的 Sealevel 运行时框架

https://github.com/project-serum/anchor

copy-translator:划词翻译

复制后翻译,使用 DeepL 的 API,不过目前只有 Local 版本好用。

当然,也可以使用 Eudic(欧路词典)。

https://github.com/zu1k/copy-translator

opensea-rs: Opensea 的 API 和以太坊智能合约的 Rust 客户端

https://github.com/gakonst/opensea-rs

godot-rust : Godot 游戏引擎的Rust 绑定

这些绑定覆盖了Godot 3.2 的大部分公开 API。

https://github.com/godot-rust/godot-rust

推荐项目 | 基础工具库

编辑: 张汉东


Hora 0.1.0 发布

Hora,Rust 实现的近似最邻近搜索(Approximate Nearest Neighbor Search, ANNS)算法库。先发布了 v0.1.0,专注于近似最邻近搜索领域,已经实现了 HNSW(Hierarchical Navigable Small World Graph Index)索引,SSG(Satellite System Graph)索引,PQIVF(Product Quantization Inverted File)索引,BruteForceIndex,其他索引也即将推出。

Hora 可以部署在任何操作系统平台上,已经支持的 PC 操作系统 Linux,Mac OS,Windows,将支持移动设备操作系统 IOS 和Android,以及将来支持嵌入式系统(no_std),并将支持多语言绑定,包括 Python,Javascript,Java,Ruby,Swift 和 R。

相关链接信息:

voila:另类处理文件的方式

Voila 是一种通过 CLI 工具启动的特定领域语言,用于以快速可靠的方式处理大量文件和目录。

安装需要切换到 nightly 版本:


#![allow(unused)]
fn main() {
$ rustup default nightly
$ cargo install voila
}

一些使用实例:


#![allow(unused)]

fn main() {
删除创建日期在 2020年1月1日 之后的所有文件
$ voila ./backup "@creation=date >= 2020-01-01 { print(@name has been deleted) delete(@path) }"
删除文件名以 2020 结束的文件
$ voila ./backup "@name ~= #(.*)-2020# { print(@name has been deleted) delete(@path) }"
}

语法如下:

$ voila DIRECTORY "<@VARIABLE | STRING | /REGEXP/> OPERATOR <@VARIABLE | STRING | #REGEXP#> [|| | && ANOTHER_CONDITIONAL ...] {OPERATION1-CYCLE-1(ARG1 ARG1, ARG2) OPERATION2-CYCLE-1(ARG1 ARG2) ...; OPERATION1-CYCLE-2(ARG1, ARG2 ARG2, ARG3)...}"

https://github.com/Alonely0/Voila

bustd:用于内存不足场景的进程杀手守护进程

相比 earlyoom 有更少的内存占用(注意是 Linux 系统的,不是跨平台的噢):

$ ps -F -C bustd
UID          PID    PPID  C    SZ   RSS PSR STIME TTY          TIME CMD
vrmiguel  353609  187407  5   151     8   2 01:20 pts/2    00:00:00 target/x86_64-unknown-linux-musl/release/bustd -V -n

$ ps -F -C earlyoom
UID          PID    PPID  C    SZ   RSS PSR STIME TTY          TIME CMD
vrmiguel  350497    9498  0   597   688   6 01:12 pts/1    00:00:00 ./earlyoom/

https://github.com/vrmiguel/bustd

kas : 一个新的 GUI 库

这个库不知不觉已经来到 v0.9 了。

是否愿意尝试,读者自己决定。

https://github.com/kas-gui/kas

slitter : 可信且经过验证的 slab 分配器

slitter 是由 Backtrace Labs 团队设计实现并用于 C 后端服务器的 slab 分配器,采用 Rust 编写。

在实际生产的两个月中,该团队使用 slitter 来:

Connector-x Rust 和 Python 中将数据从 DB 加载到 DataFrame 的最快库

ConnectorX 团队观察到现有解决方案在下载数据时或多或少会多次冗余数据。此外,在 Python 中实现数据密集型应用程序会带来额外的成本。ConnectorX 是用 Rust 编写的,并遵循“零拷贝”原则。这允许它通过变得对缓存和分支预测器友好来充分利用 CPU。此外,ConnectorX 的架构确保数据将直接从源复制到目标一次。

https://github.com/sfu-db/connector-x

RillRaate: 带有实时Web界面的系统监控工具

RillRate 是完全使用 Rust 和 Yew 框架制作的机器人、微服务和物联网的快速 UI。 全栈 Rust 是真实存在的!

最新版本增加的新功能:

  • 新控件:按钮、开关、选择器和滑块。
  • 新数据类型:表格、仪表、直方图(尚未图形化)。

[[Media] System Tools with real-time Web UI 🖥️ 🚀](https://www.reddit.com/r/rust/comments/p1b65e/media_system_tools_with_realtime_web_ui/)

项目使用RillRate(一个为机器人、微服务和IoT设备设计的实时UI工具),实现了对CPU、内存和系统信息的监控,将数据可视化并实时呈现在web界面上。

https://github.com/rillrate/rillrate

gzp: v0.3.0 现在支持多线程压缩snappy

关于gzp:

gzp是一个用Rust实现的多线程压缩编码库,目前支持Gzip格式(依赖flate2)和snappy格式(依赖rust-snappy)

https://github.com/sstadick/gzp

httpmock - 一个 http 服务端

  • 简单、富有表现力、流畅的 API。
  • 许多内置帮助程序可轻松进行请求匹配。
  • 并行测试执行。
  • 可扩展的请求匹配。
  • 具有同步和异步 API 的完全异步核心。
  • 高级验证和调试支持。
  • 网络延迟模拟。
  • 支持正则表达式匹配、JSON、serde、cookies 等。
  • 带有Docker 镜像的独立模式。
  • 支持基于 YAML 文件的模拟规范。

https://github.com/alexliesenfeld/httpmock

helix-editor - 一个受 neovim 启发的编辑器

helix-editor 是一个深受 neovim 启发使用 Rust 开发的编辑器,感兴趣的朋友可以看看。

Github: https://github.com/helix-editor/helix

cargo-smart-release

cargo-smart-release,无所畏惧地发布工作空间 crate,无需处理依赖关系或版本。

与 cargo release 的比较

cargo-release 是这个工具存在的原因,因为它让我迷上了一个了解git的全自动化发布工作流程。截至2021-08-12,这对简单的工作区或单速率工作区来说是完美的,所以请使用它:cargo install cargo-release。

以下是 cargo smart-release 的不同之处。

  • 安全地执行,所以默认情况下,它被解除了武装
  • 指定一个或多个 crate,并自动检测哪些板块需要发布
  • 处理依赖性循环,以增加整体成功的机会
  • 当出现问题时,努力避免让工作区处于不一致的状态
  • 成为 gitoxide 的 playground,为应用程序作者提供更多的便利和更多的可行性。

https://crates.io/crates/cargo-smart-release

jsonschema-rs:Rust Json 校验工具

如果你没有听(用)过 Json Schema,请允许我首先简单介绍一下。JSON Schema 是用于验证 JSON 数据结构的工具,如果你厌恶对 Json 数据各种 if else 的判断和校验,那该工具非常适合。它的官网:JSON Schema | The home of JSON Schema,先看一个简单的例子,假设我们有下面的 Schema:


#![allow(unused)]

fn main() {
{
  "type": "object",
  "properties": {
    "first_name": { "type": "string" },
    "last_name": { "type": "string" },
    "birthday": { "type": "string", "format": "date" },
    "address": {
      "type": "object",
      "properties": {
        "street_address": { "type": "string" },
        "city": { "type": "string" },
        "state": { "type": "string" },
        "country": { "type" : "string" }
      }
    }
  }
}
}

这个 Schema 一共定义了四个字段,每个字段的类型都做了规定,address 本身也是一个 Json Object。此时,有效的数据是:


{
  "first_name": "George",
  "last_name": "Washington",
  "birthday": "1732-02-22",
  "address": {
    "street_address": "3200 Mount Vernon Memorial Highway",
    "city": "Mount Vernon",
    "state": "Virginia",
    "country": "United States"
  }
}

而下面这样的无效数据则会被 Json Schema 验证并报错:

{
  "name": "George Washington",
  "birthday": "February 22, 1732",
  "address": "Mount Vernon, Virginia, United States"
}

Json Schema 本身是语言无关的,这里已经有很多实现了:Implementations | JSON Schema,Rust 版本的使用与其他语言类似:

use jsonschema::{Draft, JSONSchema};
use serde_json::json;

fn main() {
    let schema = json!({"maxLength": 5});
    let instance = json!("foo");
    编译 Schema
    let compiled = JSONSchema::compile(&schema)
        .expect("A valid schema");
    验证实例
    let result = compiled.validate(&instance);
    if let Err(errors) = result {
        for error in errors {
            println!("Validation error: {}", error);
            println!(
                "Instance path: {}", error.instance_path
            );
        }
    }
}

这个工具唯一有个麻烦的地方就是编写 Schema 比较费劲,可以理解为设计类。不过好在写好之后就省事了。

https://github.com/Stranger6667/jsonschema-rs

cargo-auto:自动任务工具

包括:构建、发布、文档等功能。Cargo 功能已经很强大了,为啥还要做这个东西呢?因为有时我们需要做更多的事情,比如复制一些文件、发布到 ftp 或输入长命令。这些重复性任务必须自动化(也称为 “工作流自动化”)。


#![allow(unused)]
fn main() {
$ cargo install cargo-auto
$ cargo auto new
$ cargo auto build
$ cargo auto release
$ cargo auto docs
}

https://github.com/LucianoBestia/cargo-auto

rust-grad 用 Rust 写成的自动微分库,支持 wgpu 计算

实验性的自动微分库,由作者自己维护的 ndarray 分叉提供 wgpu 支持。这也为向 ndarray 添加 wgpu 支持提供了概念性的验证。

SnakeOS v0.1.0发布

这是继在 Rust 博客系列中编写操作系统之后适用于 x86 CPU 的可启动贪吃蛇游戏。它支持 async/await 和动态内存管理,当然,现在你可以在裸机上玩贪吃蛇了!

https://github.com/trusch/snakeos

miette: 人类友好的错误诊断库

miette是Rust的诊断库。它包括一系列的traits/protocols,允许您hook到它的错误报告工具,甚至编写您自己的错误报告!它允许你定义错误类型,可以像这样打印出来(或以任何你喜欢的格式!):

https://github.com/zkat/miette

RustSBI 0.2.0-alpha.5 版本发布

RustSBI 0.2.0-alpha.5版本发布。良好支持您的业务和学术研究系统,兼容性强,先进的模块化设计,值得信赖。 RustSBI是RISC-V下的引导程序环境,它支持多款模拟器和芯片平台。在今年的首届全国大学生系统能力竞赛操作系统赛中,RustSBI被来自全国高校的赛队广泛使用,用于实现自己的比赛用系统,并取得全国一、二和三等奖。

本次更新特性:

  • 完整支持RISC-V SBI 0.3正式版标准
  • 支持SBI PMU性能监视扩展

兼容性修改指南:

  • rustsbi::ecall指令要求[usize; 6],加入ctx.a5;
  • 降级embedded-hal到0.2.6

https://docs.rs/rustsbi/0.2.0-alpha.5/

default-args: 零开销实现带缺省值的函数

通过使用default_args::default_args!宏来实现带缺省值的函数,下面是用例:


#![allow(unused)]
fn main() {
use default_args::default_args;

// this would make a macro named `foo`
// and original function named `foo_`
default_args! {
    fn foo(important_arg: u32, optional: u32 = 100) -> String {
        format!("{}, {}", important_arg, optional)
    }
}

// in other codes ...
assert_eq!(foo!(1), "1, 100"); // foo(1, 100)
assert_eq!(foo!(1, 3), "1, 3"); // foo(1, 3)
assert_eq!(foo!(1, optional = 10), "1, 10"); // foo(1, 10)

// let's make another one
default_args! {
    #[inline]
    pub async unsafe extern "C" fn bar<S1, S2, S3>(a: S1, b: S2 = "b", c: S3 = "c") -> String
    where
        S1: AsRef<str>,
        S2: AsRef<str>,
        S3: AsRef<str>,
    {
        format!("{}, {}, {}", a.as_ref(), b.as_ref(), c.as_ref())
    }
    // that was long signature!
}

// in other codes ...
assert_eq!(unsafe { bar!("a") }.await, "a, b, c");
assert_eq!(unsafe { bar!("a", "d") }.await, "a, d, c");
// you can even mix named & unnamed argument in optional arguments
assert_eq!(unsafe { bar!("a", "d", c = "e") }.await, "a, d, e");
assert_eq!(unsafe { bar!("a", c = "e") }.await, "a, b, e");
}

https://github.com/buttercrab/default-args.rs

nom 7.0 版本发布

nom 是一个用 Rust 编写的解析器组合库。它的目标是提供工具来构建安全的解析器,而不会影响速度或内存消耗。为此,它广泛使用 Rust 的强类型和内存安全来生成快速且正确的解析器,并提供函数、宏和特征来抽象大部分容易出错的管道。目前7.0已经发布

https://crates.io/crates/nom

egui 0.14 版本发布

egui 是一个易于使用的纯 Rust 图形用户界面。egui 可以在 Web 上、本机上以及您最喜欢的游戏引擎中运行。egui 旨在成为最容易使用的 Rust GUI 库,以及在 Rust 中制作 Web 应用程序的最简单方法,它可以在任何可以绘制纹理三角形的地方使用,这意味着您可以轻松地将其集成到您选择的游戏引擎中。

https://github.com/emilk/egui

crates.live:可视化 Rust crates 依赖项

crates.live 是来自 crates.io 的 Rust crates 的依赖可视化工具。 它显示了 Rust crates(包)的依赖树。功能包括:

  • 依赖解析, crates.live 引擎通过匹配依赖版本来完成完整的依赖解析。
  • 交互式图表,带有标记的板条箱的可缩放交互式图表。
  • 图像导出, 将图形导出为 PNG。
  • 开放 API:(即将推出)GraphQL API。

crates.live 使用了一堆技术框架,技术栈包括:

  • Rust, crates.live 后端和爬虫是用 Rust 和开源 Rust 库开发的。
  • GraphQl, WASM 驱动的 GraphQL 服务器。
  • React/Bulma, 前端库。
  • Terraform, 帮助启动和维护我们的基础设施。
  • Cloudflare, Cloudflare 工作人员运行 WASM 后端。

如果在使用此应用程序时有任何疑问、建议或问题; 可以通过 contact@crates.live 联系。 crates.live 由 Abid Omar 开发,可通过 contact@omarabid.com 联系。

https://crates.live/

Obake,版本化数据结构

Obake 是一个用于声明和维护版本化数据结构的过程宏。 “obake”这个名字取自日语“お化け(おばけ)”,这是日本民间传说中一类会变形的超自然生物。

在开发应用程序时,配置格式和内部数据结构通常会在版本之间演变。 然而,保持这些版本之间的向后兼容性需要声明和维护遗留格式的数据结构和用于在它们之间迁移的代码。 Obake 的目标是让这个过程变得轻松。


#![allow(unused)]
fn main() {
#[obake::versioned]                 // create a versioned data-structure
#[obake(version("0.1.0"))]          // declare some versions
#[obake(version("0.2.0"))]
#[derive(PartialEq, Eq, Hash)]      // additional attributes are applied to all versions
struct Foo {
    #[obake(cfg("0.1.0"))]          // enable fields for specific versions with
    foo: String,                    // semantic version constraints
   
    #[obake(cfg(">=0.2, <=0.3.0"))] // any semantic version constraint can appear in
    bar: u32,                       // a `cfg` attribute 
   
    #[obake(cfg("0.1.0"))]          // multiple `cfg` attributes are treated as a
    #[obake(cfg(">=0.3"))]          // disjunction over version constraints
    baz: char,
}

// describe migrations between versions using the `From` trait
// and an automatically generated type-level macro for referring to
// specific versions of `Foo`
impl From<Foo!["0.1.0"]> for Foo!["0.2.0"] {
    fn from(foo: Foo!["0.1.0"]) -> Self {
        Self { bar: 0 }
    }
}

// an enumeration of all versions of `Foo` is accessed using the
// `obake::Versioned` trait:
let versioned_example: <Foo as obake::Versioned>::Versioned = unimplemented!();

// this enumeration implements `Into<Foo>`, where `Foo` is the latest declared
// version of `Foo` (in this case, `Foo!["0.2.0"]`)
let example: Foo = versioned_example.into();
}

https://github.com/doctorn/obake

lateral:一个在 x86_64 上启动的模块化内核

在本地执行:

$ make run-release ARCH=x86_64

可以根据自己的情况调整 Makefile 第一行 Bash 的配置。执行后如果有安装 QEMU 的话会自动加载:

每个组件都建立在窗口管理器之上,而不是像大多数操作系统那样建立在终端之上。

https://github.com/carterisonline/lateral

Persy 1.0 发布

Persy 是一个简单的事务存储引擎。

推荐项目 | 框架引擎

编辑: 张汉东


Poem是一个基于tokio/hyper的WEB服务端框架

以下为作者自述:

为什么要做一个新的web框架:

  • actix-web目前仅维护状态,和tokio1兼容的新版本始终出不来(就算出来和其它生态结合也有问题,因为用得单线程runtime)
  • tide也和 actix-web 类似
  • warp太复杂,就算是一个rust老手也会经常被整的死去活来
  • axum目前比较火,但是在我深入研究之后,发现也存在和warp同样的问题。
  • 在和社区的朋友聊axum的过程中,发现大家都不太玩得明白,我突然就想做一个用起来简单点的。

Poem简单在哪里:

warp 复杂在于大量的泛型造成代码难以理解,甚至连IDE都无法正确识别类型造成编码的困难。

但简单不代表没有泛型,而是去掉一些不必要的泛型。

Poem在对性能影响不大的地方尽量减少泛型的使用,定义IDE友好,容易理解的API。

Poem的当前状态:

完全覆盖warp的功能,API已经基本稳定。

Poem的后续目标

  • 更完善的文档以及使用手册。
  • 覆盖更全面的测试用例。
  • 提供更多开箱即用的功能。
  • 内置openapi(swagger)的支持。

感谢:

感谢张汉东提供的Poem注册名,小丁制作的网站,以及社区各位朋友提供的意见和PR。

https://github.com/poem-web/poem

pgx : 方便用 Rust 扩展 PostgreSQL 的框架

pgx 是一个在 Rust 中开发 PostgreSQL 扩展的框架,并力求尽可能地惯用和安全。

特点:

  • 通过一系列 cargo 子命令来管理开放环境。
  • 支持 Postgres v10、v11、v12 和 v13。
  • 使用Rust features 来使用特定版本的API。
  • 自动生成 Schema。
  • 为常见的SQL对象生成DDL。
  • 安全第一:将Rust的恐慌转化为Postgres的ERROR,中止事务,而不是中止进程。

https://github.com/zombodb/pgx

rg3d 游戏引擎发布v0.22 - 同时发布了一个展示版本特色的视屏

rg3d 最近发布了 0.22 版,作者在 Reddit 发布了一段 rg3d 的演示视频,看起来非常棒!可能是目前最成熟的一个 Rust 3D 游戏引擎了。

视频:https://www.youtube.com/watch?v=N8kmZ9aBtZs

https://github.com/rg3dengine/rg3d

Tangram:训练、部署和监控机器学习模型

一个机器学习套件,使用方法如下:

# 训练
$ tangram train --file heart_disease.csv --target diagnosis --output heart_disease.tangram

推理支持多种语言:Elixir, Go, JavaScript, Python, Ruby 和 Rust,以 Rust 为例:


#![allow(unused)]
fn main() {
let model: tangram::Model = tangram::Model::from_path("heart_disease.tangram", None).unwrap();

let input = tangram::predict_input! {
  "age": 63.0,
  "gender": "male",
  // ...
};

let output = model.predict_one(input, None);
{ className: 'Negative', probability: 0.9381780624389648 }
}

很好奇训练的时候居然没有要指定模型,发现其将模型共分为三类:回归、二分类和多分类,训练时会根据数据自动选择合适(使用评估方法)的模型,每种模型又有两种不同的训练方法:线性方法和树方法。

自带的监控功能看起来还不错。

项目理论上可以用在简单机器学习场景下,尤其是那些还没有支持机器学习的语言,不过推理并没有 Benchmark,生产中使用需要做好性能测试。

https://github.com/tangramdotdev/tangram

RefineDB: Rust编写的强类型文档数据库

运行在任何事务性 键值存储上的 强类型 文档数据库。

目前支持的 backends 有:

  • FoundationDB
  • 单机部署的 SQLite。
  • 一个简单的内存键值存储。

https://github.com/losfair/RefineDB

九月刊(September)

发刊通告

Rust 资讯

外刊评论

Rust 生产实践

学习园地

新手入门

原理与工程实践

开源项目巡礼

Rust 与 安全

发刊通告

编辑:张汉东

九月发刊通告

祝大家国庆假期快乐!

九月应该繁忙的一个月,月刊也姗姗来迟。 《 RustMagazine 中文精选 》2021 年第九期发布了!

金秋十月,有美丽的秋色,有收获的喜悦,当然,还会有你我在十月刊的再次相逢。

欢迎大家多多投稿!

本刊动态

九月份新增栏目:「外刊评论」。

国外有很多精彩的内容,需要大家一起取其精华,以供大家学习。

(GitHub Page)从六月起已经停止 Rss 订阅

因为当前 mdbook 的 rss 插件不是很方便,等以后再加 rss 支持吧。

可以到语雀平台自动支持邮件订阅。

上期(八月刊)访问数据统计小结

用户数

  • 新用户新增 2,342

浏览量:

  • 网页浏览量 : 16,081

八月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

七月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

六月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

编辑: 张汉东


Rust 1.55 stable版本已经发布

可以通过以下命令安装:

$ rustup update stable

具体可见Rust 1.55特性介绍。概括而言,新的特性包括:

  • Cargo
    • 不再重复展示cargo test中显示的重复编译错误
  • 标准库
    • 使用Eisel-Lemire algorithm算法来解析浮点数
    • std::io::ErrorKind类型更新:原先未分类错误的类型为ErrorKind::Other,避免和常用自定义错误类别冲突,1.55中将此类错误的类别改为ErrorKind::Uncategorized
    • range在match中可以不写上限或下限了,支持类同 1.. 的写法,详情见https://github.com/rust-lang/rust/pull/83918
    • 确定一些方法和trait的实现

Rust 官方发布 1.55 的时候在博客里还特意纪念了最近刚去世的 Anna Harren。

Anna Harren 是第一个将 Rust 的 ::<> 语法命名为 Turbofish 的人。

https://twitter.com/garblefart/status/627886036211900416

构建安全的 I/O

最近Rust官方合并了一个 RFC ,通过引入I/O安全的概念和一套新的类型和特征,为AsRawFd和相关特质的用户提供关于其原始资源句柄的保证,从而弥补Rust中封装边界的漏洞。

相关资源:

GitHub Advisory Database(安全咨询数据库)现在已经支持 Rust 了

下一步将支持 dependabot , dependabot是 GitHub 推出的一个提醒依赖更新机器人,当你项目的依赖有更新的时候就会自动推送一个 Pull requests。

GitHub Advisory Database 官方写道:

这一覆盖范围确保了Rust社区的任何成员都可以在他们的代码所在的同一个地方检查安全问题:GitHub上。这仅仅是第一步! 请查看我们的公共路线图,我们正在努力实现Rust对依赖关系图和Dependabot警报的支持。

谢谢你,RustSec和Rust社区! 在我们努力将Rust生态系统加入咨询数据库的过程中,我们得到了RustSec和Rust社区的大量支持。

我们非常感谢RustSec,这是一个独立的组织,负责收集、规范和发布与Rust库相关的安全建议。它的免费公共数据库是我们自己的Rust漏洞数据集的起点。

我们计划继续与RustSec和更广泛的Rust社区合作,使我们自己的GitHub安全咨询数据可用并易于使用,以进一步补充他们的数据。通过合作,我们可以为减少漏洞的可见性问题做更多的工作,而不是单独行动。

https://github.blog/2021-09-23-github-advisory-database-now-supports-rust/

Rust 1.56 beta1 (2021版)现已发布!!

你现在可以用rustup安装2021测试版了。

使用rustup default beta切换到最新的测试版,然后你可以将你的toml文件迁移到 edition="2021" 或者用cargo new启动一个使用21版的新项目。

关于现有项目的迁移过程的一些信息:

https://doc.rust-lang.org/cargo/commands/cargo-fix.html

Rustaceans 准则

Niko 发布了一篇博客,总结出 Rustaceans 的准则,比如其中提到:

Rust 赋予系统以下能力:

  • ⚙️ 可靠性: "如果它能编译,它就能工作"
  • 🐎 高效性:"惯用代码高效运行"
  • 🥰 支持性:"语言、工具和社区都在这里提供帮助"
  • 🧩 生产性:"事半功倍"。
  • 🔧 透明性:"你可以预测和控制低级别的细节"
  • 🤸 通用性:"你可以用Rust做任何事情"

如何使用Rustacean

  • 💖 善良和体贴
  • ✨ 给用户带来快乐
  • 👋 展现出来
  • 🔭 认可他人的知识
  • 🔁 从某处开始
  • ✅ 贯彻执行
  • 🤝 付诸行动
  • 🎁 信任和授权

https://smallcultfollowing.com/babysteps//blog/2021/09/08/rustacean-principles/

GCC 代码生成后端现已加入 rust-lang 大家庭

rustc_codegen_gcc 是为 Rustc 设计的 GCC 代码生成后端,目前已经加入 rust-lang 官方大家庭。

它不仅可以复用现有的 Rustc 前端,还能够获取来自 GCC 的增益,比如支持更多架构以及应用 GCC 的独门优化。

尽管该项目采用 libgccjit 进行实现,但其实并不是利用 JIT 技术,而是采用 AOT 方案,可不要被名字迷惑哦。

https://github.com/rust-lang/rustc_codegen_gcc

LLVM13 的最新的 pass manager 进展让 Rust 的编译速度整体提高 5~20%

微软的 Ryan Levick 大神提到,LLVM13 的最新的 pass manager 进展让 Rust 的编译速度整体提高 5~20%。目前 LLVM13 还在 nightly 状态。很快估计能惠及到 Rust 这边来。

https://twitter.com/ryan_levick/status/1443202538099073027

社区热点

【视频】Miguel Ojeda 分享 Rust for Linux

Linux 内核维护者 Miguel Ojeda 近日在 Linaro Connect 虚拟会议上介绍了 Rust for Linux 的工作进展。Miguel Ojeda 受雇于谷歌,专门负责 Rust for Linux 的开发工作,也是主要开发者之一。

据介绍,现在已经有 RFC 补丁用于添加 Rust 基础设施支持和至少一个基本的虚拟驱动程序,但到目前为止,这项工作还没有真正落地。

Miguel 先是介绍了 Rust 的相关背景和特性,以及它为 Linux 内核提供的好处。后面才进入了主题 —— Rust 在内核中的支持情况。

他还提到了三个编译器后端的进展情况,rustc_codegen_gcc 进展最快,目前已通过了部分的 rustc 测试,rustc_codegen_llvm 是目前的主要开发项目,Rust GCC 预计在 1~2 年内完成。

Rust for Linux 研讨会 9.13 ~ 9.15

9.13 分享:

  1. High Velocity Kernel Modules in Rust with Bento —— Samantha Miller

Bento 是 Safe Rust 实现的 linux 内核 文件系统。 通过实现安全的 API 并使用安全的内核函数包装器,文件系统是用安全的 Rust 编写的。 这些安全接口尽可能接近现有的用户空间(主要是标准库)接口,因此只需将 Bento 包含更改为用户空间 Rust 库和/或 bento_utils 包含,就可以将文件系统重新编译为 FUSE 文件系统。更多信息:https://github.com/smiller123/bento

这次分享主要讨论 Bento ,用于加速 Linux 内核开发的框架。目前已经为文件系统模块实现了Bento框架,并利用它实现了一个性能类似于ext4的文件系统,可以在不卸载的情况下进行升级,而且只需要15ms的停机时间。我们目前正在努力扩展Bento,以支持自定义的TCP/IP堆栈。

  1. How can we formally verify Rust for Linux? —— Alastair Reid

在Linux中使用Rust的目的是创建更稳固、更安全的代码:通过利用Rust的语言特性和设计一个安全的API供驱动程序使用,避免内存安全问题和并发问题。该分享研究了我们如何/是否可以利用自动形式验证工具走得更远。

9.14 分享:

  1. Rust key concepts for the Linux kernel (I) —— Miguel Ojeda

介绍 在 Linux 内核领域应用 Rust 的一些关键概念,第一部分

  1. The Thread wrapper for Rust in Linux kernel — Boqun Feng (冯博群)

内核线程是内核中最重要的组件之一,它也是实现内核中其他核心子系统的必要环节。本专题将分享如何在Linux内核中实现Rust类线程的封装器的学习过程,以及目前的状况和未来的工作。

  1. Implementing the Iterator trait for seq_file —— Adam Bratschi-Kaye

内核中的seq_file接口允许通过实现一个迭代可以打印的值的接口来轻松创建虚拟文件。这似乎应该直接转化为Rust的Iterator特性,其中Item实现了Display,但当然,魔鬼在细节中。该分享将展示如何为Rust代码提供seq_file的接口。

9.15 分享

  1. Rust key concepts for the Linux kernel (II) —— Wedson Almeida Filho

介绍 在 Linux 内核领域应用 Rust 的一些关键概念,第二部分

  1. Writing an embedded SPI-based Linux driver in Rust —— Arthur Cohen, Esteban Blanc, Martin Schmidt

虽然Linux主要不是一个以嵌入式为重点的操作系统,但它仍然被用于诸如Raspberry Pi这样的平台。在这些平台上,内核模块提供了一种有用的方式,可以在内核层面与各种设备进行交互,这些设备通常使用低级协议进行通信,如SPI或I2C。

在这种工作负载中使用Rust有很多优势,虽然这些协议的内核API已经被尝试和测试了很长时间,但目前还没有Rust的抽象。

在该分享中,将谈论在ARM64平台上为Linux带来一个安全的Rust的SPI协议的抽象,以及如何使用它来实现一个简单的设备驱动程序。该分享将与C语言的原始实现进行比较,后者提供了同样多的功能。最后,将深入探讨所使用的技术和他们使用Rust-for-Linux的经验。

以上三天的研讨会,应该是线下的,因为并没有提供线上参与链接。

对此话题感兴趣的可以关注:https://github.com/Rust-for-Linux/linux

也可以登记参加在线讨论: https://rust-for-linux.zulipchat.com

研讨会官网: https://kangrejos.com/

rcore tutorial book 更新了

rCore-Tutorial-Book 在暑假期间又进行了一轮修改,算是从0.35版进化到0.50版了。请对学习用Rust写OS感兴趣的朋友看看。如果有问题、建议,发现了bug,请直接在每节下方的交互窗口留言。如果想一起来参与写作,请直接联系 陈渝或吴一凡。谢谢! 本书定位是以尽量简单的编程和尽量少的OS/CPU知识来逐步设计实现一个一个的小OS,让学生知道操作系统的概念的实际体现和操作系统的全貌。经过我们讨论,虽然这本书是基于单处理器讲解的,但觉得还是要加入OS的同步互斥支持,与传统方式不同,这一章主要讲解操作系统如何支持用户态线程的同步互斥操作。所以,目前还缺的是关于同步互斥的一章,各种图,相关OS历史的介绍,相关知识点的进一步补充。争取本月完成。

https://rcore-os.github.io/rCore-Tutorial-Book-v3/

tokio-console 开发日志 #1

tokio-console 是一个 Rust 异步调试工具,它的目标是可以让开发者更好地了解异步任务的行为方式。它基于 tracing events和spans,和运行时没有关系。

目前该库只是概念原型阶段。 官方这篇文章介绍了 console 目前开发相关进展。

https://tokio.rs/blog/2021-09-console-dev-diary-1

carllerche’s tokio-console thoughts:

https://hackmd.io/a4P75rj0RP6qettxAVzv8w

Rust 实现的开放沙盒游戏 veloren 发布 0.11 新版本

Chrome 中 Rust 和 C++ 的两手准备

月初,Chrome 团队写了一篇文章,介绍 Rust 和 Cpp 的互操性。

Chrome的工程师们正在尝试使用Rust。在可预见的未来,C++是我们代码库中的统治者,任何对Rust的使用都需要与C++相适应,而不是相反。这似乎给C++/Rust的互操作性带来了一些挑战,而这些挑战是其他人没有遇到过的。

由于C++是统治者,该团队主要关注的是新的Rust代码能够调用现有的C++代码,而不是C++对Rust的调用。

cxx是目前最先进的在C++和Rust之间安全交换数据的方法,基本能符合该团队对 安全调用cpp的标准。但对于开发体验方面还有很多要做的事,

目前,Chrome在Rust的投资仍将是一个背景调查(主要针对这些工具和技术的原型)。如果确信这种互操作性是可能的,该团队就会重新考虑在Chrome中广泛使用Rust,到那时,他们将计划通过强大的生产质量解决方案努力实现这一目标。

https://www.chromium.org/Home/chromium-security/memory-safety/rust-and-c-interoperability

九月份,谷歌发布了一篇名为在Chrome中更安全地使用C++,它有一些关于这些安全问题如何困扰Chrome和Android代码库的安全报告的链接,这部分链接内提到了Rust。

https://docs.google.com/document/d/e/2PACX-1vRZr-HJcYmf2Y76DhewaiJOhRNpjGHCxliAQTBhFxzv1QTae9o8mhBmDl32CRIuaWZLt5kVeH9e9jXv/pub

Kraken 使用 Rust 改进基础设施

Kraken 一家总部位于旧金山的公司,是世界上最大的基于欧元交易量和流动性的全球数字资产交易所。

Kraken 的后端服务早期是使用 PHP 实现的,在最近两年时间,使用 Rust 改造原有的 PHP 服务并开发新的产品和功能,并使用 Rust 支撑了不断扩大的加密货币交易业务。

Kraken 发布了关于使用 Rust 改进基础建设的一篇文章,并希望对于考虑使用 Rust 构建产品的公司和想要投入时间学习该语言的开发人员来说是一个有用的资源。同时,Kraken 还表示,为了感谢 RustAnalyzer 的出色工作,将会向该项目捐赠 50K EUR 。

https://blog.kraken.com/post/7964/oxidizing-kraken-improving-kraken-infrastructure-using-rust/

WGPU 与 Deno 在 CTS 上的成功合作

三月份的时候,Deno 1.8 就已经使用 wgpu 来提供了初始的 WebGPU 支持。Deno 团队花了更多的时间将一致性测试套件(CTS)连接到 Deno WebGPU 运行,并报告了 wgpu 上第一个 CTS 结果/问题。

现在该工作已经与 wgpu CI 集成,作为 wgpu 基础设施和生态系统的一部分。

https://gfx-rs.github.io/2021/09/16/deno-webgpu.html

Linux Plumbers Conference 2021 有 9 个 topic 与 Rust 相关

Linux Plumbers Conference 是一个面向开源社区的开发者会议,将以下场景的顶级开发者汇聚在一起:内核子系统,核心库, 窗口系统等等。

Linux Plumbers Conference 2021 有 9 个 topic 与 Rust 相关:

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 32 期 - delicate 分布式调度系统

时间: 2021/08/31 20:30-21:30

主讲人:槟橙炮炮

题目delicate 分布式调度系统

内容: Rust 社区之前没有活跃的分布式调度系统项目,为了填补这个空白我开始调研实现项目,目前已经快要发布 V1.1 了。

在项目设计与底层库的实现从 smol 套件中获得了很多灵感,也会简单跟大家介绍下 smol & tokio 一些各自的设计哲学,async-process async-io async-task 一些漂亮的代码片段。

参考资料:

  1. 文档地址:https://delicate-rs.github.io/
  2. 源码:https://github.com/BinChengZhao/delicate

【回放】


《Rust 唠嗑室》第 33 期 - Web3.0 与 Rust

时间: 2021/09/14 20:30-21:30

主讲人:Mike Tang

题目:Web3.0 与 Rust

内容:Mike 带你了解 Web3.0,用逻辑梳理脉络,以及 Rust 在这个潮流中的机会。

参考资料:

  1. Web3.0 导论系列索引:https://rustcc.cn/article?id=632e4ecb-1b8a-477b-af4c-3e41981c7315
  2. https://mp.weixin.qq.com/s/TIy9DrvxetqvCEpCiivPsg
  3. https://mp.weixin.qq.com/s/vJM6TIZT2f-tnQ49cpMnrw
  4. https://mp.weixin.qq.com/s/h76lTnFWlvpXs72aBVs3FA

【回放】


🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第十九期 | Rust 与架构

分享者:张汉东

【讨论主题】

  1. 架构的终极目标是用最小的人力成本来构建可维护的系统。但是 开发者的过度自信 始终阻碍着达成这个目标。
  2. 「先快速上线,后面再重构」,但实际上,永远没有重构的机会。
  3. 系统的行为价值(软件正常工作)和架构价值(软件的可修改能力)对你来说,哪个更重要?
  4. 在编程范式、设计原则、组件原则、架构模型上依次对 Rust 语言的表现进行了探讨。
  5. 嵌入式 Rust 生态正在遵循 嵌入式整洁架构 而构建

【参考资料】

  1. https://zhuanlan.zhihu.com/p/378085465
  2. https://github.com/tomaka/rouille

【回放】

本月招聘

猎头推荐高薪 Rust 职位两个: Rust 量化开发 与 Rust 智能合约开发

有兴趣请投递至统一邮箱rust @ circlequantgl.com

招聘岗位一:高频量化研究员(Senior)

  • 工作地点:北京/上海/深圳
  • 待遇:总包 100w - 350w,外加高夏普自营盘投资机会

工作内容:

  • 数字货币高频做市、趋势、套利策略和信号的开发及优化
  • 与工程师协作开发和维护高频交易系统
  • 设计和开发数据分析及可视化工具

我们希望你:

  • 至少1年Rust技术栈工作经验
  • 毕业于国内985或者海外知名高校,数学、物理、计算机或相关理工科专业本科及以上 学历
  • 拥有高频做市或者趋势策略的研究经验
  • 熟悉Python及一门静态类型编程语言(Rust, C++, Go,Java, C#)
  • 扎实的数学基础:概率统计、线性代数、时间序列分析

加分项:

  • 传统金融市场做市策略的实盘经验
  • 逐笔成交、多档order book数据的分析经验,市场微观结构因子的开发经验
  • 数学、物理竞赛的获奖经历
  • 统计、应用数学、计算机等领域的知名期刊、会议上发表文章
  • 数字货币交易经验

有兴趣请投递至统一邮箱rust @ circlequantgl.com

招聘岗位二:全栈工程师 (Full Stack Engineer)

  • 工作地点:北京/上海/深圳
  • 待遇:总包 40w ~ 150w

工作内容:

  • 开发基于React框架的DeFi产品网页端
  • 开发基于Rust的智能合约
  • 开发与产品相关的应用工具及服务

我们希望你:

  • 毕业于国内985/211或者海外知名高校,计算机或者相关理工科专业本科及以上学历
  • 熟悉服务端开发流程并熟练掌握至少一种服务端语言(Rust、Java、C++、C# 等)
  • 熟悉网页端开发常用语言(JavaScript、CSS、HTML)
  • 熟练掌握常见的数据结构且能够对其相关算法复杂度进行分析

加分项:

  • 区块链及智能合约开发经验
  • 程序设计竞赛获奖经历(中学OI、大学ACM/ICPC)
  • 知名互联网公司、投行、资管的工作经历
  • 丰富的Rust开发经验
  • 丰富的ReactJS开发经验

有兴趣请投递至统一邮箱rust @ circlequantgl.com

长桥证劵 | Rust 跨平台基础架构工程师

公司介绍

长桥 成立于 2019 年 2 月,是一家致力于提供全球股票经纪的互联网券商,核心成员均来自于阿里巴巴,蚂蚁金服,华为等知名公司。

我司技术文化开放,实际上目前大部分服务端是采用 Go 语言开发,我们通过一些初步尝试与验证,发现了 Rust 对于跨平台开发有很不错的前景,准备持续投入资源开拓、探索 Rust 生态对于我们公司技术的各种可能性。

我司除此岗位之外,也长期开放很多其他岗位如:Go、Ruby、前端,有兴趣可以邮件联系我。

岗位职责

参与公司跨平台的基础架构设计与开发工作,采用 Rust + FFI / WebAssembly,用来解决多端基础库的支持。并基于这个可能性在基础架构内实现更多有益与多端支持的功能,比如:API 封装、复杂行情资产计算、数据离线/持久化/预加载、网络协议改进(HTTP/3)、数据传输格式改进(JSON to Protobuf 或其他)、传输加密等等。

同时这个岗位也将会持续探索 Rust 在跨平台方向可行性。

全职岗位,你可以选择成都、杭州、新加坡、香港、北京等多个办公地入职,或也可以选择远程。

岗位要求

这个岗位是要招来做跨平台基础架构的,得有一定的设计能力和技术功底。

满足以下任意一项:

  • 有 Rust 项目经验
  • GitHub Profile 过硬,是个开源社区参与者
  • 独立 App 开发经历,有较好的作品
  • 有 Rust FFI / WebAssembly 的相关开发经验
  • 擅长多种语言,爱折腾
  • 对 Rust 有兴趣,有一定其他语言(Go、Ruby、Node.js),此项不支持远程

待遇

  • 入职配 MacBook Pro (Apple M1) + LG 4K 27 寸显示器;
  • 薪资范围开放,面议
  • 完整社保、公积金、以及带薪年假;

简历投递:huacnlee@gmail.com

广州或深圳 : Rust 开发工程师

工作职责

  1. 负责区块链系统的框架,设计和开发工作。
  2. 参与系统的需求分析、代码编写、代码维护写等工作。

工作要求

  1. 具有一定英文基础,可以较为流畅阅读英文文档
  2. 熟悉 Rust/ Go 两种语言,1年以上Go语言的大中型系统开发经验,熟悉两个以上的主流 Go 开发框架。
  3. 对存储技术,网络安全、应用系统架构等有一定的认识,具备良好的团队合作能力和学习能力。
  4. 熟悉TCP/IP 协议,对 Socket/WebSacket 通信和 HTTP/HTTPS 协议有一定理解。
  5. 认同软件工程思维,熟悉面向对象技术,能理解各种uml图。

工作地点: 广州或深圳 ,公司包住宿,15-30k+,更高薪资可谈

联系:13926293314 熊总

官方 RustConf 2021 盘点

作者: 张汉东


大会官网


1. Niko:Rust 下一步的目标是广泛使用!

2021 年对 Rust 来说,是非常令人兴奋的。

2021 年 Rust 基金会成立,以及一些致力于 Rust 开发的团队成立。

Rust 甚至被考虑用于 Linux 内核。

Rust 的学习曲线正在逐步被降低。Rust 生态也越来越丰富。

Rust 2021 edition 蓄势待发!其目标是关键性的采用和可持续发展。

Niko 在该talk 中分享了 Rust 近几年的成长。

下一步 Rust 的目标:广泛使用!

2. Mara Bos: 以改进 Rust 标准库锁为例,探讨大型工作团队如何突出困境

问题:

在很久之前,为标准库添加同步原语。其中最重要的是 Mutex。因为操作系统已经为我们实现了很多同步原语,那么标准库只需要将它们包起来即可吗?答案是否定的。因为这样做不符合 Rust 的要求,它们是为 C 设计的。

为 Rust 设计实现时,有很多微妙的事情,但其中有三个较大的问题。

第一个问题是,如果你拥有一个对象,你可以把它移动到内存的不同位置。意味着你不能假设一个对象在其整个生命周期内都会停留在同一个内存位置。但是,操作系统的锁原语是要求该对象在同一个内存位置。比如 SRW 读写锁。你可以将其放到堆上来达到效果,但是很低效。也无法静态实现。

第二个问题是,在 Rust 中 Unsafe 和 Safe 的边界是 非常明确的。即便你的实现是错误的或者是多余的,但内存安全是底线。比如,死锁。死锁是实现错误,但它绝对是安全的。然而,操作系统实现的锁并不总是死锁,还可能是未定义行为。如果只是简单的用 Rust 包装底层原语,那么就会破坏语言的安全保证。

第三个问题是,内存/资源泄漏是安全的。比如我们创建了一个 Mutex锁,但是忘记解锁了,我可以drop它,但它还是锁定的,它是安全的。但是在操作系统的实现中,销毁mutex将引起未定义行为。

一个解决方案:

使用 parking-lot 全局数据结构来取代这些锁。这个方案在 2018年9月被提出,然后经历了大概一年的讨论,直到大家的精力耗完了。因为这个改变是非常巨大的。最终只留下300多条讨论,PR被关闭。

再之后,当有人对 Mutex 的实现提出一些改进,人们就会回答说,parking lot 会解决这个问题。

人们仍然期望 parking lot在不久后被整合进标准库。但面对之前遗留的讨论,没有人能很好地概述问题是什么,没有任何需要改变的清单,没有明确的地方可以开始这件事。

让我们从小事做起:

挑选出一个最微小的障碍,开始尝试解决它。

第一个障碍:稳定性保证。

Rust 承诺了很多保证,对于整合 parking lot 来说,要保证在某些情况下不会发生恐慌。但正是这个保证,引发了很多讨论。所以,Mara 提出,将这个保证的细节删掉。得到了整个团队的同意。于是,一个干扰的因素被去掉了。

第二个障碍:不可移动。

给微软发了一个 PR,添加了一个说明,澄清如果在windows平台如果锁没有被借用,就可以移动它。仅仅几天就得到了微软的响应,于是 团队得到了一个很大的平台的支持,有了可移动的mutex。

就这样,Mara拆分出了一些很小的问题,逐个去解决它。这样一来,小问题就不会再分散对大问题的注意力了。

小结

Mara 组建了 新的 Lib Team,命名为 Lib API Team,致力于 API 的设计,而不会被具体实现分心。本次分享的目的主要是用具体的案例来说明,当事情遭遇困境的时候,应该从微小单一的障碍着手,想办法突破困境。

3. Miguel Young: 移动构造函数,Rust 中可能吗?

作者说:注意,学习这一切你不需要C++知识!

我认为还是需要一点的 ,至少得知道什么是移动构造函数吧?

值得说明的是,以下这部分知识在 Cpp 中算高阶知识,但是在 Rust 中类似的知识,则是入门级的。

C++ 中为了防止指向堆内存的成员变量被默认的拷贝构造函数浅拷贝而导致双重释放,所以需要开发者自己编写拷贝构造函数。

虽然 Rust 中没有构造函数,但C++的这个行为和 Rust 的 Copy 语义有些类似。

但是只有这种拷贝构造函数,在面对处理临时对象的时候,会有性能损失。因为在创建临时对象的时候,拷贝构造函数也会执行,这些临时对象用完就会被释放。

为了优化拷贝构造函数带来的性能问题,C++ 11 中引入了右值引用和移动构造函数。

右值的概念,相对于左值。对应于 Rust 中,左值即地址表达式,右值即值表达式。在 Rust 中,右值,即值表达式,如果没有使用let 绑定,它就是一个临时变量。C++ 也是一样,但是在 C++ 中,可以通过右值引用(&&v,两个引用前缀) 这个东西,将右值(临时值)的生存期延长了,相当于给右值又赋予一个变量名。

cpp 中 std::move 可以将一切值变成右值。

有了右值引用,就可以实心移动构造函数来复用临时对象了,而不需要重写拷贝构造。移动构造函数,可以将一个对象的指针成员转移给另一个对象。只有创建对象的时候传入的是右值才会执行移动构造函数。

Rust 中处理 自引用类型 是个难题,虽然有 Pin<T> ,但是不如 C++ 那么自然。C++ 可以通过移动构造函数安全地处理自引用类型。

一个 "自引用(self-referential) "类型是指持有对自身的引用;异步Futures是目前Rust中最常见的自引用类型。然而,它们不能在不使引用无效的情况下被移动,所以它们被 Pin 在了堆或栈上。你不能返回它们,也不能把它们放入集合中(如果没有Boxing的话)。

该 talk 中,作者使用Pin<P>保证,将把所有的C++构造函数,而不仅仅是移动构造函数,移植到Rust中,而不影响Rust的使用后移动(move-after-use)保护(这是C++缺乏的)。在今天的稳定版Rust中完全支持通过移动返回和集合。除了零成本的C++ FFI之外,Rust的 "构造函数 "可以应用于在纯Rust中表达新的数据结构。

该 talk 作者在之前也写过详细的博客文章 : https://mcyoung.xyz/2021/04/26/move-ctors/

相关的库 : https://github.com/mcy/moveit

关于作者:

Miguel Young 是来自 Google OpenTitan 项目的开发者。OpenTitan 项目,想通过开源框架减少芯片被破解的可能。

OpenTitan 将由非营利组织lowRISC监督, 该公司正在开发基于RISC-V架构的免费微处理器。

OpenTitan项目涵盖了各种逻辑组件的开发 RoT芯片的需求,包括基于RISC-V架构的lowRISC Ibex开放式微处理器,加密协处理器,硬件随机数生成器,恒定和随机存取存储器数据和密钥存储层次结构,机制保护,I / O输入块,安全启动媒体等

可以在必要时使用OpenTitan,以确保系统的硬件和软件组件的完整性,并确保未更换关键的系统组件,并基于制造商验证和授权的代码。

基于OpenTitan的芯片可用于 服务器主板,网卡,消费类设备,路由器,物联网设备,以验证固件(检测恶意软件对固件的修改),提供加密的唯一系统标识符(硬件防伪保护)以及保护加密密钥(如果出现以下情况,则隔离密钥):攻击者可以获得对计算机的物理访问权限),提供与安全相关的服务,并维护无法编辑或删除的隔离审核跟踪。

本人深挖了一下 OpenTitan 项目,在 GitHub 的语言成分分析中看不到 Rust 的痕迹。但是在源码中搜索 Rust,则发现很多 Rust 痕迹。

一、其中,OpenTitan 的 Software 部分支持 Rust 实现。

  • 设备软件的固件镜像,支持 Rust 实现。
  • Host 软件必须用 Rust 实现 (也支持 Cpp)。

二、 ROM_EXT 由 Rust 实现

OpenTitan 安全启动过程中,为了增加一定程度的灵活性,特别是为了允许制造商的特定配置和提供安全更新的设施--OpenTitan设计了扩展ROM(ROM_EXT),常驻闪存中。

ROM_EXT由一个 manifest 和 image 本身组成。当 image 生成时,manifest 是 "空白 "的。ROM_EXT签名者的责任是更新manifest ,签名image,并将签名加入其中。

源码:https://github.com/lowRISC/opentitan/tree/master/sw/host/rom_ext_image_tools/signer ,它是一个 Host 软件。

在 Readme 里介绍了他们为什么选择 Rust : https://github.com/lowRISC/opentitan/blob/master/sw/host/rom_ext_image_tools/signer/README.md

该项目中其他比较有用的资源:

  1. RISC-V Assembly Style Guide : https://docs.opentitan.org/doc/rm/asm_coding_style/

  2. FPGA Reference Manual:https://docs.opentitan.org/doc/rm/ref_manual_fpga/

  3. Rust for Embedded C Programmers https://docs.opentitan.org/doc/ug/rust_for_c/

4. Lily Mara: 在 Rust 中不要过度优化的重要性

Lily Mara 是 曼宁新书《Refactoring to Rust》 的作者,目前在 OneSignal(让开发人员可以轻松地向他们开发的应用程序添加个性化推送通知的工具,也使用 Rust)工作。这是一个轻talk。

Lily 分享了一个新手 Rust 用户 Jean 的故事:

Jean 之前是做 Python 开发开发一些聚合 API 的东西,其中要使用 Json,然后就在 json 的序列号/反序列化的过程中产生了性能瓶颈。然后他听说 Rust 性能不错,就尝试使用 Rust 来解决这个问题。

他用 Rust 实现了那部分代码,发现性能提升了20倍,感觉不错。后来他向一名更有 Rust 经验的开发者展示了这段代码,那名有经验的开发者告诉他,里面还有很多不必要的内存分配,建议他们使用 引用 来替代多余的分配,提升性能。

于是 Jean 又花了很长时间学习 引用/生命周期/泛型,经历了一些挫折之后,终于写出来使用引用的第二版。性能比上一个版本提升了 1.5 倍。

img

看到这个结果,Jean 比较郁闷。他们只是想写一些比 Python 程序性能更好的代码。第一版 Rust 实现的代码已经达到了这个要求,比 Python 代码快 20倍。但是他们花了九牛二虎之力写的第二个 Rust 版本,和第一个版本差距并不大。

当然这个过程 Jean 也学到了很多东西。但是为了提升这么一点性能,付出了相对比较大的精力,是否值得,这是一个需要考虑的问题。

这让笔者想到曾经在某公司也遇到类似的场景。 同事 A 某,花了三天优化了服务的性能,在技术会议上,CTO 听到他花三天时间只是为了优化那点性能之后,痛批了他一顿。 CTO 的看法是,他花三天优化的那点性能,再加一台服务器就够了。但是,他三天可以创造出更多的价值,远远不需要为公司去省那点成本。

Lily Mara 想在这次 talk 中表达的观点就是: Rust 学习成本确实是在那摆着的,尤其是,想把代码优化到最佳,也需要花很多精力。作为一名新手,不应该在开发的时候去想着优化的事情,不需要一步到位。先完成再完美。她希望新手们不要太担心性能的问题,可以逐步通过后续的重构来解决性能问题。可以参考她写的那本书。

5. Hugo Peixoto:鉴别神奇宝贝卡片

Hugo Peixoto 是来自葡萄牙的一名自由职业工程师。

分享者 Hugo 制作了一套 口袋妖怪TCG库存数字化的工具,可以通过计算机视觉来识别 口袋妖怪的卡片。他将这个过程中的经验以及计算机视觉相关领域的算法做了一个分享。这也是一个轻 talk 。

Hugo 做的这个工具整体流程如下:

先从网络摄像头的视频流中抓取一帧,然后需要从中提取卡片图像。最后通过已知卡片的数据集,寻找与卡片图像最相似的那张把它提取出来,然后把它添加到网站后台数据库中。

本次分享中主要是讲解具体的视觉识别算法,相关的代码在这里: https://github.com/hugopeixoto/ptcg-detection

6. Midas Lambrichts: Fuzz 驱动开发

Midas Lambrichts 是一名软件工程师,Fuzz 爱好者。

什么是 Fuzzing?

模糊测试是一种软件测试技术,用于通过向软件提供伪随机数据作为输入来发现安全性和稳定性问题。

Fuzz 驱动开发的流程:

  • 确定一个不变量 (Determine an invariant)
  • 编写表达该不变量的fuzz 目标(Write fuzz target that expresses that invariant)
  • 运行Fuzzing目标,直到失败 (Run Fuzzing target until failure)
  • 反思(Reflect)
  • 使用失败的结果来创建新的单元测试 和/或推进开发 (Use failures to create new unit tests and/or progress development)
  • 迭代(Iterate)

示例:一个解析器

  • 程序不应该 panic,它应该返回 Ok 或 Err (确定一个不变量)
  • 将模糊器的输入原封不动地提供给解析器(编写表达该不变量的fuzz 目标)
  • 运行Fuzzing目标,直到失败
  • 简单的不变性和模糊测试目标,可能是实现中的错误
  • 任何失败的输入都会成为一个单元测试,以原始输入为测试对象
  • 迭代(Iterate)

接下来 Midas 编写一个 JSON Patch Squasher 工具为例,展示了如何使用这六个步骤进行 fuzz 驱动开发。

其他 fuzz 相关资源:

7. Isabella Tromba:用 Rust 编写最快的 GBDT 库

Isabella 是 Tangram 的创始人,这是一个多合一的自动化机器学习框架(完全用 Rust 编写!从核心机器学习算法到网络应用的后端和前端。),Tangram 使程序员可以轻松地训练、部署和监控机器学习模型。

在此之前,她是 Slack 的机器学习工程师,并在麻省理工学院学习计算机科学。 该分享也是一个 轻 talk。

在本次演讲中,Isabella 将分享 优化梯度提升决策树机器学习算法的 Rust 实现的经验。 通过代码片段、堆栈跟踪和基准测试,探索如何使用 rayon、perf、cargo-asm、编译器内部函数(intrinsics)和 unsafe rust 来编写一个训练速度比用 C/C++ 编写的类似库更快的 GBDT 库。

什么是 GBDT ?

GBDT简介GBDT,英文全称是 Gradient Boosting Decision Tree,梯度提升决策树,顾名思义,与梯度、boosting. 是一种迭代的决策树算法,由多棵决策树组成,每一颗决策树也叫做基学习器,GBDT最后的结果就是将所有基学习器的结果相加。

Rust 在 Tangram 的应用

  • rayon,用来并行化计算输入训练数据的特征矩阵中每个节点的分数。
  • cargo-flamegraph , 用于创建火焰图,寻找性能瓶颈。
  • cargo-asm,帮助查看生成的汇编代码。从火焰图中,知道哪个函数占用了大部分的时间。然后通过查看了生成的汇编代码,看看是否有机会让它更快。
  • 使用编译器的内部函数(intrinsics)来为特定的CPU进行优化。内部函数是一些特殊的函数,提示编译器生成特定的汇编代码。
  • 用 unsafe 方法来消除一些不必要的跳转检查。前提是,他们知道这些地方可以安全地忽略检查,比如输入的数据永远不可能越界访问。

8. Brian Martin: 用 Rust 重写 Twitter 缓存服务

Brian 是 Twitter 的一名软件工程师,他专注于性能和优化项目。 他为多个 Twitter 开源项目做出了贡献,包括:Pelikan、Rezolus 和 rpc-perf。 Brian 对高性能软件、系统调优、监控、基准测试和 Rust 充满热情。 在不编写代码的时候,Brian 会在他当地的搜救部门做志愿者,并训练他的狗寻找在荒野中迷路的人。

Pelikan 是 推特开源的 缓存框架,Brain 想用它来取代 memcached 和 redis。他说服了该项目的维护者,允许他们用 Rust 为 Pelikan 增加 TLS 支持。因为他认为用 Rust 比 C 更好。

缓存服务的性能是超级重要的。正如我之前提到的。Brain 希望单个缓存实例能够在单个核心上每秒支持大约十万个请求,并且延迟低于一毫秒。为了确保这一点,Brain 通常会应用一些具有固定请求率的合成工作负载,并将每个请求的延迟记录在柱状图中。Brain 的目标是在 P999 延迟低于某个阈值时达到最高吞吐量。P999 是所有请求延迟分布中的第99.9个百分点。

Rust的实现最终使Pelikan模块成为它们应该成为的东西,但由于C语言的限制,以前无法做到。

Brian 分享了他们用 Rust 重写 Pelikan 的来龙去脉。最后总结出使用 Rust 重写的成本和收益:

成本:

  • 额外的时间会导致错过交付日期
  • Duplicating work that’s been paid for (应该是开玩笑)

收益:

  • 在纯 Rust 库中更容易工作
  • 不再有cmake!!
  • 增加了新的想法

并且通过一些性能优化,他们达到了 C 语言的性能。目前 Rust 重写工作还在继续中。

9. Luke Westby: 我是如何利用 Rust 逃离互联网的

Luke Westby 在 StructionSite 使用 Rust 和 Elm 构建软件。 他和他的妻子和狗住在加利福尼亚,他喜欢做编程之外的各种事情。 luke 是从 Elm 社区来到 Rust 的,在那里他是长期的核心贡献者、定期会议发言人,并且以前是 elm-conf 的助理组织者。 他非常喜欢 Rust,尤其是 Bevy 引擎。

Luke 一直在执行一项逃离互联网的任务。Luke 认为自己已经浪费了数不清的时间,在网上游荡,无所事事地浏览,并发表一些没有人关心的意见。

因此,他删除了他所有的账户,并屏蔽了所有让他分心的网站。他以为他终于成功了,但似乎内容总是能找到被消费的方式。他首选的搜索引擎显示了与查询主题相关的新闻、最近的帖子和视频标题的小片段。搜索 "新闻 "并被标题分散注意力的诱惑往往过于强烈,不幸的是,他的网站拦截器只对URL起作用,而不是页面的元素。

为了切断这最后的干扰源,并最终完全逃离互联网,他不得不自己动手建立了一个浏览器扩展,可以删除页面的这些区域,他还用Rust来确保他永远不会关闭它。这次分享就是为了告诉你他是怎么做的。

我有个疑问:他不用手机吗 。。。

img2

Luke 做的是这样一个工具:实现了一个浏览器扩展,帮助他屏蔽页面上让他焦虑分心的信息。但是为了防止他关闭那个扩展,他通过Rust 实现了一个类似于图中架构的程序,来监控浏览器中那个扩展是否正常工作,如果他关闭了扩展,那么这个监控程序就会自动为它关闭浏览器。整个代码实现用到了多线程channel / ipc 通信等。

这个分享有点意思。

10. Jackson Lewis: 用五种鲜为人知的属性宏来增强你的代码

Jackson Lewis, 是 1Password 的 19 岁软件开发人员,拥有三年的专业经验。 他喜欢 Rust 并创建了宏库 cascade,以及多个爱好项目,包括多人游戏引擎。

这个分享中有句话很有意思: 你必须保护你的代码不被未来的自己发现,他们会忘记你写的所有细微的细节,并毁掉一切。 幸运的是,我们 Rustaceans 有工具来帮助缓解这个问题:代码注释、惯用指南和类型系统。

哪五个属性呢:

  1. #[non_exhaustive] ,可以让枚举体支持「非穷举性」,当你不确定枚举在将来有什么变化时,可以增加这个属性。
  2. #[deprecated],当你想替换掉一个以后不会被使用的函数时,你可以加这个属性。
  3. #[pre],Pre是precondition的简称,是第三库提供的,详细请看:https://github.com/aticu/pre。通过它你可以为指定的函数提供前置条件,用来判断它是否满足某种条件,而保证安全使用。通常用于 unsafe 函数。
  4. #[readonly::make],同样是第三方库,详细:https://github.com/dtolnay/readonly。它允许你为结构体中某个别字段设置只读。
  5. #[typetag::serde],详细:https://github.com/dtolnay/typetag。它允许你无痛地对trait对象进行序列化和反序列化。

其他超酷的属性宏:

img

11. Zac Burns: 编译时社会协同( Social Coordination)

Zac Burns 是 Edge & Node 的软件工程师(他们正在招聘 Rust 开发人员!)。 他热爱 Rust 两年多,编程近 20 年。 Zac 说:「你想知道什么比学习生命期更难吗?通过二十年时间去犯可预防的错误能学习同样的课程。」

你能写出好代码,这很不错。但是你能让我们一起写出好代码吗?

这次分享中,Zac 带领我们来学习 Rust 是如何让我们更容易写出一致的代码,并且在文件、crate和人之间保持一致。这是一个关于我们如何停止踩踏每个人的脚趾并学会爱上借用检查器的故事。

一个生物学家物理学家和一个Rust 程序员正在观察一所房子。有两个人 进入房子,过了一会儿,有三个人从房子里出来。

生物学家说,他们繁殖了。

物理学家说,测量时有一个错误。

而 Rust 程序员说,现在房子里有 -1 个人。

三个人有三套不同的关于世界如何运作的假设。

Zac 通过这个小段子,引出开发过程中,不同的人拥有不同的世界观,代码协作要保证一致性比较困难。实施良好的程序是内部高度一致的。有模式和设计选择,在整个代码的远程位置上,完成后都会遵守。

一致性不好的代码会出问题。Zac 举了游戏开发中的一些示例说明这个问题。程序越大越复杂,一致性就越难保持。

Zac 越是考虑保持一致性的问题,他就越发相信,作为一门学科,投资的最佳回报是在编译器和编译器辅助的解决方案中,与沟通或指导不同的是,编译器可以扩展到任何团队规模,在每个贡献者需要的时候为他们提供个性化的建议。

乍一看,似乎是限制,由编译器强加的限制。实际上,它赋予了我们自由,让我们能够写出更好的软件,而不被作者之间的交流和共享知识的限制所约束。

Zac 在分享中举了很多开发中的示例来阐述他的这些观点,推荐大家看看这场分享。

12. Esteban Kuber : Hacking rustc 为编译器做贡献

Esteban Kuber 从2015年开始参与社区活动,从2017年开始成为编译器团队的成员,目前作为首席工程师在AWS从事rustc的工作,专注于改进其诊断信息,努力使该语言对所有人都平易近人。这也是一场轻 talk。

很多人对编译器有这样的看法:它们是靠魔法和精灵尘运行的黑盒子。而且,你要参与其中,你需要同时在多个领域有不同寻常的技术能力。

有这样一种想法:只有优秀程序员才能玩转编译器。Esteban 今天在这里就是活生生的证明,这绝对是根本性的错误。

你不需要知道或理解编译器的每一个方面,就能做出有意义的贡献。你不需要知道优化通道如何工作。要修改解析器。你不需要知道这个人是如何工作的,就可以理解类型推理的运作。

而且你不需要了解类型推理是如何运作的,就可以了解生命周期分析是如何运作的。

你也不需要精通语言设计的艺术,你只需要想做这件事。

Estenban 这个分享就是告诉你,如何给 Rust 做贡献:

  1. 了解 RFC/ MFC 流程
  2. 寻找你能做的:增加新特性?改 Bug? 等等。
  3. 了解 issues 中 不同标签的意义,有助于你寻找适合你的 issue 去解决。(具体可以看 PPT 图片,Rust 作为一个开源项目,在管理 issue 方面做的很不错,也值得学习)
  4. 配置开发环境 (rust-analyzer / rustc dev guide)
  5. 遇到问题可以去 Zulip 和官方团队交流

13. Nell Shamrell-Harrington: This Week in Rust: 400 Issues and Counting!

Nell Shamrell-Harrington 是微软开源项目办公室的首席软件工程师,也是Rust基金会的董事。她也是《This Week in Rust》的主编,以及多个Rust工作组和团队的成员。在此之前,她曾在Mozilla和Chef软件公司工作。在业余时间,她是Operation Code的董事会成员,这是一个致力于向退伍军人及其家属教授软件工程技能的非营利组织。

七年来,超过400期的《Rust周刊》将Rust社区的动态带到了 Rustaceans 的收件箱中。Nell 回顾了 This Week in Rust 这么多年来的历程,以及它为 Rust 社区带来的意义:是社区更新的聚合者。

也许,这也是 RustMagazine 社区月刊的意义吧!

RustChinaConf 2021 动态

重要通知:Rust大会延期至11月举行

1

本届 Rust 大会赞助商列表

1

Rust China Conf 2021 首批议题确定

议题审核标准发布

为了提升大会的议题内容质量,议题不与赞助商权益绑定。本次议题名额有限,符合评审规则者优先考虑。评审结果将分批次放出,名额满即止。

议题入选评审标准:

  • 内容要围绕Rust,标题明确,大纲清晰;
  • 内容面向Rust的学术研究/生产经验优先;
  • 内容面向Rust创业产品 且围绕Rust技术层面者优先;
  • 对Rust生态有影响的开源项目优先;
  • 在往期大会或meetup并未出现的内容优先;
  • 禁止出现国家法规禁止的内容;
  • 要符合社区友善度;
  • 禁止出现大量广告;

PPT 评审标准

  • PPT不要超过 10页(15分钟)或20页(半小时),主要是防止超过议题时间;
  • PPT内容要与大纲吻合;
  • 禁止出现国家法规禁止的内容;

已确定议题

以下是第一批已确定议题,排名不分先后。

《通过 rust 拓展 js 并在 webassmebly 中运行》

陈思衡,WasmEdge社区开发者,就职于滴滴。

WebAssembly (Wasm) 最初为浏览器应用而生,但随着 WASI 的出现以及开发者生态的壮大,Wasm 作为一种轻量级的软件容器在服务端的作用愈发明显。服务端的 Wasm 提供了与本地代码相媲美的速度,但又不失安全性,可移植性,与可管理性。通过支持 JavaScript 在 wasm 中运行,可以绕开学习 c/c++、rust 使得开发者可以对基于 wasm 的产品有流畅的上手体验,减少因为开发语言的问题而流失用户的遗憾。得益于 Rust 优秀的设计,使用 Rust 来拓展 JavaScript runtime 能够开发出高质量的 module,给用户稳定的使用体验。

《Rust实现高效的模糊测试工具 (Use rust to develop efficient fuzzer)》

陈鹏,腾讯安全大数据实验室安全研究员,曾开发Angora、Matryoshka等多个模糊测试工具,在IEEE S&P, ACM CCS, Usenix Security等安全顶级会议发表多篇论文。

模糊测试(Fuzzing)是一种高效的自动化漏洞挖掘手段,其本身的实现需要高效的执行效率和高度抽象的输入,我们将介绍使用Rust来实现模糊测试工具核心特性时遇到的挑战,以及如何解决的它们的。

《Rust助力量化高频交易》

朱为,非凸科技北京负责人,致力于打造非凸科技下一代算法交易及高频交易引擎。拥有12年通讯和互联网行业从业经验,对于构建高并发低延迟实时系统有丰富的经验。

本次演讲分享非凸科技Rust选型过程以及基于Rust生态打造高效率、低延迟、高可靠全内存高频交易平台的经验。

《集成 Crashpad 到 Rust 应用》

杨博宇,秘猿科技软件开发工程师。

当线上 Rust 应用崩溃时,如果崩溃的点在依赖的 C/C++ 库或其他 unsafe 的代码中,往往很难定位和修复。这些情况下,集成崩溃报告系统会带来很大帮助。

《高性能 Rust RPC 框架设计》

徐帅,2017 年开始接触 rust, 有幸成为 rust team member, 一直致力于挖掘这门语言的潜力,并希望吸引更多的使用者, 同时壮大社区。现在在字节跳动基础架构团队探索 rust 在性能和安全问题上的更多可能性。

我们都知道 "机器比人便宜, 能加机器就加机器" 的这一道理, 在字节, 我们遇到了困境, 机房容量有限, 自建机房周期太长, 跨机房延时太高等等, 无法再去肆意挥霍机器, 也不能想加就加, 彻底打破了这一道理. 在存量优化的阶段, 我们希望有更好的技术选择, 就和当年大家在知道摩尔定律失效之后一样. 当然机器确实比人便宜:)

《乐白机器人和物联网的Rust实践》

田劲锋,乐白机器人团队。

Rust在机器人控制器开发、运动控制、实时系统、物联网、WebAssembly的实践。

《基于 Rust 的下一代分布式存储 - HydroFS》

宁安,前豆瓣网DBA, 目前任职青云, 文件存储和对象存储架构师

Hydrofs 是基于 rust 从0开发的一款支持 posix 语义的分布式存储, 此次演讲会阐述我们创新的设计哲学和分布式理论, 以及分享 Rust 在系统编程方面的独有优势

《我们如何通过手工汇编代码击败 Rust 的零成本抽象》

叶万标,具有十多年经验的高度专注的软件工程师, 使用各种技术在多种开发和工程职位中. 在业余时间, 我喜欢探索老旧的技术, 从纸带程序开始, 第一枚商用处理器到最古老的游戏机. 从前人的智慧中获取灵感, 并指导如今的程序开发. 这是一个有趣的体验, 因为你能学到许多极端的改善性能的方案.

ckb-vm 是 Rust 实现的 RISC-V 虚拟机, 我们通过在其关键位置引入手工汇编代码以获得相较原生 Rust 代码 5 倍以上的性能提升.

《内核新设计与嵌入式Rust实现》

洛佳,华中科技大学网络空间安全学院大四学生,4年Rust开发经验,热爱Rust嵌入式和操作系统开发工作。目前致力于向教学、研究和产业界推广使用Rust语言。

Rust语言抽象能力强、语义描述丰富,使它尤其适合开发系统级应用。本次演讲我们列举一种内核功能模块的编程方法,以及嵌入式Rust在过去一年内的发展。最后,我们将发布一款典型Rust嵌入式应用的新版本。

《Madsim:神奇的分布式系统确定性模拟器》

王润基,清华大学计算机系在读硕士生。从 2018 年开始用 Rust 编写操作系统内核 rCore/zCore,并参与了清华 OS 课程改革。2020 年做了打榜专用分布式文件系统 MadFS,最近在学习分布式系统与数据库。致力于推动 Rust 成为未来十年系统编程的首选语言。

调试是分布式系统开发过程中的一大难题。面对那种运行 1000 次才会发生一次的罕见 bug,我们常常手足无措直到怀疑人生。为了解决这一问题,我们参考 FoundationDB 的工程实践,用 Rust 开发了一个能够确定性模拟分布式系统行为的运行时框架 Madsim,并基于此移植了 6.824 Raft 实验框架 MadRaft。MadRaft 可以在 1s 内运行完成全部 50+ 组测试用例,或者重复运行一组测试 100+ 次,并在出现问题后随时复现相同的执行过程,大幅提高了开发和调试效率。本次分享讲介绍 Madsim 的实现原理,阐述确定性测试在分布式系统开发中的重要价值与其局限性。

《在无libc的SGX可信执行环境下移植std支持》

周顺帆,毕业于复旦大学系统软件与安全实验室,曾在国际顶级安全会议 USENIX Security 上发表对于以太坊生态中攻击交易与防御手段的研究,并协作发表多篇国际顶级安全会议论文。是《Phala Network 白皮书》的联名作者,并参与设计了 Phala 的系统架构。周博士负责带领Phala研究和工程团队在算法、系统架构、网络优化方面逐步提升 Phala 云计算的先进程度,在其带领下目前已在SGX里实现了phala-blockchain的密钥管理核心功能。

Intel SGX是一个把应用与OS完全隔离的可信执行环境,应用无法直接访问OS提供的资源。我们采用的Teaclave-SGX-SDK只提供了no_std环境,导致crates生态下大量的库都无法被使用。为了在SGX环境下开启std支持,我们通过补齐libc缺失的函数,实现了对OS基本功能的模拟。为了保证尽可能小的安全边界,我们对每个增补的libc函数做了权限控制。同时引入了二进制分析,确保程序不会出现SGX非法指令。

《用 Rust 构建插件框架 》

骆迪安,PingCAP TiKV 研发工程师,工作中使用 Rust 以及个人贡献 Rust 社区,从 2015 年开始陆续在 crate.io 贡献多个库,最高达到 468 K 下载量;5 个 LFX、GSoC 项目的 mentor;钻研分布式事务领域、PLT、形式化证明。

本次演讲将会介绍 Rust 语言实现热插拔插件框架及运行时的架构设计和实现细节以及其在 TiKV(一个分布式 KV 数据库)中的实践。

《基于LLVM Rust代码混淆设计与实现》

赵禅,蚂蚁集团基础安全部 安全专家

基于LLVM Rust代码混淆设计与实现

《基于Rust构建高性能图数据库》

翟士丹,海致科技集团技术VP,IT老兵,专注于高性能数据分析领域,有丰富的大数据BI系统及知识图谱分析平台构建经验。

AtlasGraph是基于Rust开发的一款高性能图数据库,项目借助了Rust强大的静态类型检查能力,在内存安全、类型安全等方面避免了很多潜在问题。Topic 会分享我们图数据库的设计,以及使用Rust构建图数据库方面的实践。

《基于 RDMA 的 Rust 内存管理》

施继成,复旦本硕。专注于操作系统和分布式计算方向的研究和开发,曾就职于阿里巴巴、微软和谷歌等互联网公司。现为 DatenLord 的联合创始人,致力于提供更加自由的云计算环境,提供高效跨云数据访问平台。

RDMA 是一种高效的内存访问形式,其允许计算机直接访问远程其他机器的内存,其被广泛使用在数据中心和超算场景。RDMA 需要独特的内存管理机制来控制远程内存访问的权限, 这里介绍如何为 Rust 语言提供 RDMA 内存管理的封装接口。

《基于SIMD指令优化Rucene》

陶锴,毕业于吉林大学,知乎搜索引擎开发工程师。目前从事于Rucene的相关研究。

本次演讲给大家带来的是知乎搜索技术团队在 Rucene 优化中落地 SIMD 优化的一些实践经验,总结下如何利用 SIMD 指令优化 Rust 程序,以及Rucene优化后的实际效果。

《面向分布式服务架构的高可用流量控制组件 Sentinel 的 Rust 实现》

杨培灏,目前为上海交通大学在读硕士研究生,研究兴趣为物联网、云计算,在国际会议、期刊发表了多篇论文,曾获得杨元庆奖学金、博世 AIoT 奖学金等荣誉奖项。此次会议中,作为 Sentinel Commiter,分享阿里巴巴开源项目“面向分布式服务架构的高可用流量控制组件 Sentinel 的 Rust 实现”。

随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 是面向分布式服务架构的流量控制组件,主要以流量为切入点,从流量控制、熔断降级、系统自适应保护等多个维度来帮助您保障微服务的稳定性。Sentinel 拥有庞大的开源社区和众多的使用者,随着 Rust 生态在云原生领域的蓬勃发展,社区也开始尝试进行 Rust 版本的 Sentinel 开发。

《打造可静态检查的编码规范》

张汉东,《Rust 编程之道》作者,企业咨询顾问。

详情

通知:Rust China Conf 2021 大会票已售罄

详情

外刊评论三篇

编辑:张汉东/ 王江桐(华为)

编者按:国外的 Rust 资源非常丰富,将其都翻译过来也不现实,所以想到这样一个栏目。外刊评论,主要是收集优秀的 Rust 相关文章,取其精华,浓缩为一篇简单评论。

欢迎大家贡献:https://github.com/RustMagazine/rust_magazine_2021/discussions/129


目录


将 TensorFlow 模型移植到 Pure Rust 的开发成本有多高?

tf

原文: https://www.crowdstrike.com/blog/development-cost-of-porting-tensorflow-models-to-pure-rust/

该文来自于美国网络安全软件开发商 CrowdStrike Holdings(纳斯达克上市代码:CRWD)的官方博客。该公司雇员三千多人,致力于重塑云时代的安全性。

此文作者之前写过一篇文章: 站在巨人的肩膀上:结合 TensorFlow 和 Rust,其中阐述了如何进行超参数调整和实验已知的深度学习框架(如TensorFlow,PyTorch,Caffe),同时用底层语言重新实现这些模型的最佳版本,以加快预测过程。然而,在那篇文章中没有触及的一个同样重要的部分是将 TensorFlow 模型移植到 Rust 中的开发成本,所以就有了现在这篇文章。

可扩展性是CrowdStrike的重点之一,因此出现了对实现可扩展性的自动化机制的需求。该文介绍了一种新颖的通用转换机制,可以在短时间内成功地将TensorFlow模型转换为纯Rust代码,以及这种技术带来的挑战。

从普通的训练过程说起:

training

一般机器学习项目的理想工作流程是从收集和清理训练阶段要用到的语料库开始的。同样重要的是选择模型架构,以及定义它的超参数集。一旦这些要求得到满足,训练阶段就可以开始了。在这个过程结束时,我们有多个候选模型可以选择。

在所有生成的候选模型中,将使用具有最有希望的结果的模型,该模型与一组预定义的指标(如验证损失、召回率、AUC)以及定义分类器信心水平的决策值相匹配。

此外,在安全等领域,检查模型对新数据进行预测时产生的FN(false negatives)和FP(false positives)可能被证明有助于通过应用聚类或人工技术发现相关信息。如果获得的结果令人满意(例如,高TPR、低FPR,并证明在设想的对抗性攻击方面具有鲁棒性),使用所选模型进行推理的代码将被转换成底层语言,该语言将被进一步优化并提供安全保证(即防止内存泄漏、内存损坏、竞赛条件)。

Rust被证明是这项特定任务的一个杰出候选者。当然,它也可以被任何其他底层编程语言所取代。

最终目标是彻底分析分类器在受控环境中的行为,并检查决策阈值是否选择得当,并有可能进一步微调。最后,在目标终端上发布模型,同时仔细监测其性能。这个过程通常会在分类器的生命周期中多次重复,以提高检测能力,并跟上每天出现的最新威胁。

转换机制

conversion

转换机制是一个通用工具,旨在将TensorFlow模型转化为纯Rust代码。它的主要目的是过滤冗余信息,只保留推理所需的相关细节(即每层的权重和描述性超参数),并根据计算图中描述的依赖关系重新创建目标模型。最后,产生的Rust文件就可以用来在生产中安全地运行模型,同时实现性能的显著提升。

该转换机制包括:

  • 神经层(Neural-Layers Crate):目前构建于Ndarray Rust crate之上,但是还未使用它的多线程功能,未来还有性能潜力。还有一些其他优化:
    • 使用迭代器而不是直接索引矩阵
    • 批量服务能力
    • 对瓶颈层(如卷积层)使用通用矩阵乘法程序
    • 启用了BLAS(基本线性代数子程序),以使所需的乘法运算更快,而不引入预测错误
  • Rust 转换器(Rust Converter):重新创建给定神经网络背后的整个逻辑,采用了面向对象的编程风格。

结论

  1. 下一步的改进是采用一种更加标准化的格式,可以统一来自各种深度学习框架的模型(即ONNX)。这将确保没有必要强加一个特定的开发框架,而是让工程师根据自己的喜好来决定。
  2. 即使 Rust 被证明是深度学习模型的一个显著的候选者(在时间、空间和解决安全问题方面),该团队也将继续探索进一步的优化策略。
  3. 一个通用的转换机制,使该团队能够将注意力转移到更有创造性的任务上--比如设计、微调或验证不同的架构,同时将准备这些模型用于生产的成本降到最低。

Rust 2021

通常情况下来说,Rust语言组尽量让所有新增的功能在未来的版本都能适用。然而,在一些情况下,新增的功能会使之前的代码失效。例如,新版本中或许会使用新的关键词,比如await,如果之前的代码里有名为await的变量,那么变量就会因为关键词的原因而失效,造成错误。在这种情况下,Rust会发布一个新版本,并用版本(edition)来使编译器知晓代码将使用哪个版本的Rust。

在Rust 2018之后,Rust的新版本Rust 2021(1.56.0)目前暂定于9月9号进行beta 测试,10月21号正式上线。

Rust 2021 有哪些新功能

来自《This Week in Rust 》第404篇博文

对于std::preclude, 增加了新的引用

std::preclude是每一个rs module默认引入的库,不需要程序员自己手动引用。当与用户自定义的库有冲突时,会优先使用用户引入的库来保证代码没有二义性。当用户自定义try_into方法时,如果同时引用标准库的try_into,并且希望同一个rs文件中,两个不同的对象一个使用用户自定义的try_into,一个使用标准库的try_into,此时由于try_into具有二义性,编译器会报错。为了解决这个问题,Rust 2021决定将新的trait加入std::preclude.

新版本的preclude与老版本相比,没有删除任何项,只是新增了三个trait: std::convert::TryInto, std::convert::TryFrom, std::iter::FromIterator。

Cargo Feature Resolver

自从1.51.0,Rust引入了新的resolver(resolver = "2"),来解决功能对于编译环境与发布环境不同时兼容的问题。

例如,对于依赖foo与其功能A和B,假设有两个包,bar依赖于foo + A,和baz依赖于foo + B。当resolver = “1”时,Cargo在编译的时候会合并功能A和B,并且编译foo + AB。这样做的好处是,foo只会被编译一次;但是问题在于,如果foo + A仅作为dev dependencies使用、foo + B仅作为target dependencies使用、且开发环境和目标环境并不同时兼容A和B呢?foo + AB将无法同时在两个平台上编译。

resolver = "2"可以解决这个问题,因为它将单独编译并使用这些功能。比如在这个例子中,foo + A只会在当前build包含dev dependencies时被启用,模块将会被分开单独编译,而不会一起编译。这确实或许会增加编译时间,但是同时可以解决在不同平台与环境上的编译问题。

对于1.56(Rust 2021)之前的版本,虽然或许resolver = "2"已被引入,但是默认的resolver仍然是”1“。Rust 2021以及后续版本将把默认resolver设定为”2“。如果项目确实有以上顾虑,请不要忘记修改Cargo.toml。

闭包与所有权

闭包提供匿名函数功能,并且闭包中可以捕获调用者作用域中的值。在之前版本中,对于结构体a,


#![allow(unused)]
fn main() {
struct a{
	x: int,
	y: int,
}
}

如果有如下闭包:


#![allow(unused)]
fn main() {
|| a + 1;
}

闭包将会获取对于a的不可变引用。

如果有如下闭包:


#![allow(unused)]
fn main() {
|| a.x + 1;
}

闭包将会获取对a(整个a)的不可变引用,而不是单独对于a.x的。如果a的其他属性(e.g. a.y)被删除,那么这个闭包将无法编译,因为闭包要求获取对于整个a的引用,而a的部分属性已经失效。


#![allow(unused)]
fn main() {
let a = SomeStruct::new();

drop(a.x); // Move out of one field of the struct

println!("{}", a.y); // Ok: Still use another field of the struct

let c = || println!("{}", a.y); // Error: Tries to capture all of `a`
c();
}

然而对于Rust 2021,闭包将不会再获取对于整个a的引用。对于a.x + 1,闭包将只获取a.x这个属性的引用。

#### 语法与前缀

其他一些有关于语法,格式,前缀的修改:

  • panic!将允许单一参数,并将其识别为format string;
  • 列表循环支持类python写法:for e in [1, 2, 3];
  • 为未来的语法和新功能保留一些前缀:prefix#identifier, prefix"string", prefix'c', and prefix#123,其中prefix可能是任何标识符;
    • 未来计划会有的新前缀:类同python写法的format string: f""c""或者z""表示C类型的String,etc.
  • Warning升格至Error;
  • macro_rules的Or(|)的使用;

更详细的说明请参阅:https://blog.rust-lang.org/2021/05/11/edition-2021.html。

如何升级到Rust新版本

在Cargo.toml中我们可以手动配置使用的Rust版本:


#![allow(unused)]
fn main() {
[package]
edition = "2021"
}

同时可以利用Cargo帮助我们自动修订对于版本不适用的代码。cargo fix --edition (--all features --target (platform, x86, lib/bin, etc.))可以帮助修复cargo check所找出的所有在local package以及这些package的dependencies之中的错误。

例如dyn,对于一些语法关键词,可以通过cargo fix --edition-idioms进行自动修改。

如果不怕代码报错的话,可以使用cargo fix --edition-idioms --broken-code,让Cargo修改所有它觉得有问题的地方。这可能导致代码无法编译。

周期性服务发现

来自《This Week in Rust 》第404篇博文

ginepro是由truelayer公司开发的一个Rust crate,在tonic的gRPC channel基础之上增加了周期性服务发现的功能来更好地实现负载均衡。

什么是gRPC

要知道gRPC,首先我们要知道RPC。

RPC(Remote Procedure Call)称为远程过程调用。当项目较小时,项目或可以完全部署在本地,调用本地服务并且获得结果。当项目规模逐渐增大,本地部署不再能完全满足需求,服务或部署至远程服务器,新的问题由此产生:部署在A服务器上的应用需要调用B服务器上应用提供的函数或方法,让B服务器应用进行计算,并得到其返回结果。

RPC框架是这个问题的一个解法。通过远程过程调用协议,不需要了解底层细节,客户端应用可以向服务端请求服务,并且像访问本地资源一样访问服务器资源。

RPC的过程广义上可以被形容为:

  1. 客户端传参;
  2. 服务器(函数)运行;
  3. 服务器返回结果。

RPC涉及到的组件通常包含:

  • 客户端;

  • 客户端存根/打桩(Client Stub):存放服务端地址信息,请求参数打包成网络信息,etc.;

  • 服务器存根/打桩(Server Stub):类同客户端存根/打桩;

  • 服务器。

gRPC是谷歌开发的一个开源通用高性能的RPC框架,有严格的接口约束条件,支持流式通讯,并可以将数据序列化为二进制编码,大幅减少传输的数据量,提高性能。然而,gRPC没有针对分布式系统数据传输的必要组件,比如负载均衡,限流熔断,服务注册,等等。

什么是tonic

toinc是基于HTTP/2的gRPC的rust实现。相比于rust的其他gRPC库,tonic已经实现了负载均衡并且可以动态更新节点,而其他的Rust库还没有实现这一点。

tonic涉及到的其他相关模块:

  • hyper(异步,底层http)
  • tokio(异步)
  • prost(a Protocol Buffers:读写序列化结构数据 e.g. XML)

问题:负载均衡

gRPC的负载均衡问题来自于HTTP/2协议的稳定性。gRPC使用HTTP/2协议在单个TCP连接中实现请求和响应的多路复用(一个信道同时传输多路信号)。由于连接是持久的,在已有连接建立以后,即使有新的服务器,连接也不会向新服务器倾斜,可能造成原有的服务器过载。

我们需要负载均衡来使得针对于每一个新请求,客户端可以合理选择一个服务器并且通过现有链接发出请求。

#### 解决方案

分析问题,可知总体解决方案如下:

  • 服务器周期性强制客户端重连(解决连接不发现问题,使得连接不过于持久)
  • 客户端周期性执行服务发现(解决服务不发现问题)
  • 引入应用级负载均衡(在应用层面监控负载均衡相关)

应用级负载均衡通常可以分为两类,客户端侧维护,以及代理或外部线程维护,也就是边车模式。前者便于测试,不会过于复杂,但没有语言或平台兼容;后者相对比较复杂,或导致系统延迟,并且难于维护和监控,或是需要额外的投入去监控,比如service mesh。

由于流量和项目要求,truelayer希望能实现负载均衡。当前Rust关于gRPC的crate有三个:grpc-rsgrpc-rusttonic,但是它们没有完全满足要求。出于性能和维护原因,truelayer也不考虑使用service mesh。这使得truelayer最终选择在三个库中最贴近需求的tonic基础之上编写了一个crate: ginepro

ginepro

tonic已经实现了大部分解决方案,但是它缺少周期性服务发现的功能。gineprotonic的channel增加了此功能来解决问题。

总体思路大致如下:

  1. 服务名字和IP相关联:通过trait LookupService来解析得到IP并存入socketaddr set;
    • 不限制解决方案,在例中truelayer使用Kubernetes' DNS或者API做
    • Kubernetes是一个开源的 Linux 容器自动化运维平台,它消除了容器化应用程序在部署、伸缩时涉及到的许多手动操作。换句话说,你可以将多台主机组合成集群来运行 Linux 容器,而 Kubernetes 可以帮助你简单高效地管理那些集群。
  2. LoadBalancedChannel将使用tonic的Channel,并进行事件循环,在循环中实现服务发现功能,并且维护endpoints列表。

具体代码如下:


#![allow(unused)]
fn main() {
/// Interface that provides functionality to
/// acquire a list of ips given a valid host name.
#[async_trait::async_trait]
pub trait LookupService {
    /// Return a list of unique `SockAddr` associated with the provided
    /// `ServiceDefinition` containing the `hostname` `port` of the service.
    /// If no ip addresses were resolved, an empty `HashSet` is returned.
    async fn resolve_service_endpoints(
        &self,
        definition: &ServiceDefinition,
    ) -> Result<HashSet<SocketAddr>, anyhow::Error>;
}
}

#![allow(unused)]
fn main() {
loop {
      let discovered_endpoints = self
          .lookup_service
          .resolve_service_endpoints(service_definition).await;
    
      let changeset = self.create_changeset(&discovered_endpoints).await;
      // Report the changeset to `tonic` to update the list of available server IPs.
      self.report_and_commit(changeset, endpoints).await?;    
      
      // Wait until the next interval.
      tokio::time::sleep(self.probe_interval).await;
}
}

评估

ginepro可以很好地解决truelayer的问题,但是同样,没有银弹,这个解决方案仍然不是完美的。如果endpoint失效,除非客户端收到显式指令(e.g. Kubernets删除了这个端点),不然客户端不会将其从端点列表中删除;其次,ginepro是客户端侧解决方案,只适用于truelayer的Rust服务,它并不通用。

引用

外刊评论三篇

编辑:张汉东/ 王江桐(华为)

编者按:国外的 Rust 资源非常丰富,将其都翻译过来也不现实,所以想到这样一个栏目。外刊评论,主要是收集优秀的 Rust 相关文章,取其精华,浓缩为一篇简单评论。

欢迎大家贡献:https://github.com/RustMagazine/rust_magazine_2021/discussions/129


目录


将 TensorFlow 模型移植到 Pure Rust 的开发成本有多高?

tf

原文: https://www.crowdstrike.com/blog/development-cost-of-porting-tensorflow-models-to-pure-rust/

该文来自于美国网络安全软件开发商 CrowdStrike Holdings(纳斯达克上市代码:CRWD)的官方博客。该公司雇员三千多人,致力于重塑云时代的安全性。

此文作者之前写过一篇文章: 站在巨人的肩膀上:结合 TensorFlow 和 Rust,其中阐述了如何进行超参数调整和实验已知的深度学习框架(如TensorFlow,PyTorch,Caffe),同时用底层语言重新实现这些模型的最佳版本,以加快预测过程。然而,在那篇文章中没有触及的一个同样重要的部分是将 TensorFlow 模型移植到 Rust 中的开发成本,所以就有了现在这篇文章。

可扩展性是CrowdStrike的重点之一,因此出现了对实现可扩展性的自动化机制的需求。该文介绍了一种新颖的通用转换机制,可以在短时间内成功地将TensorFlow模型转换为纯Rust代码,以及这种技术带来的挑战。

从普通的训练过程说起:

training

一般机器学习项目的理想工作流程是从收集和清理训练阶段要用到的语料库开始的。同样重要的是选择模型架构,以及定义它的超参数集。一旦这些要求得到满足,训练阶段就可以开始了。在这个过程结束时,我们有多个候选模型可以选择。

在所有生成的候选模型中,将使用具有最有希望的结果的模型,该模型与一组预定义的指标(如验证损失、召回率、AUC)以及定义分类器信心水平的决策值相匹配。

此外,在安全等领域,检查模型对新数据进行预测时产生的FN(false negatives)和FP(false positives)可能被证明有助于通过应用聚类或人工技术发现相关信息。如果获得的结果令人满意(例如,高TPR、低FPR,并证明在设想的对抗性攻击方面具有鲁棒性),使用所选模型进行推理的代码将被转换成底层语言,该语言将被进一步优化并提供安全保证(即防止内存泄漏、内存损坏、竞赛条件)。

Rust被证明是这项特定任务的一个杰出候选者。当然,它也可以被任何其他底层编程语言所取代。

最终目标是彻底分析分类器在受控环境中的行为,并检查决策阈值是否选择得当,并有可能进一步微调。最后,在目标终端上发布模型,同时仔细监测其性能。这个过程通常会在分类器的生命周期中多次重复,以提高检测能力,并跟上每天出现的最新威胁。

转换机制

conversion

转换机制是一个通用工具,旨在将TensorFlow模型转化为纯Rust代码。它的主要目的是过滤冗余信息,只保留推理所需的相关细节(即每层的权重和描述性超参数),并根据计算图中描述的依赖关系重新创建目标模型。最后,产生的Rust文件就可以用来在生产中安全地运行模型,同时实现性能的显著提升。

该转换机制包括:

  • 神经层(Neural-Layers Crate):目前构建于Ndarray Rust crate之上,但是还未使用它的多线程功能,未来还有性能潜力。还有一些其他优化:
    • 使用迭代器而不是直接索引矩阵
    • 批量服务能力
    • 对瓶颈层(如卷积层)使用通用矩阵乘法程序
    • 启用了BLAS(基本线性代数子程序),以使所需的乘法运算更快,而不引入预测错误
  • Rust 转换器(Rust Converter):重新创建给定神经网络背后的整个逻辑,采用了面向对象的编程风格。

结论

  1. 下一步的改进是采用一种更加标准化的格式,可以统一来自各种深度学习框架的模型(即ONNX)。这将确保没有必要强加一个特定的开发框架,而是让工程师根据自己的喜好来决定。
  2. 即使 Rust 被证明是深度学习模型的一个显著的候选者(在时间、空间和解决安全问题方面),该团队也将继续探索进一步的优化策略。
  3. 一个通用的转换机制,使该团队能够将注意力转移到更有创造性的任务上--比如设计、微调或验证不同的架构,同时将准备这些模型用于生产的成本降到最低。

Rust 2021

通常情况下来说,Rust语言组尽量让所有新增的功能在未来的版本都能适用。然而,在一些情况下,新增的功能会使之前的代码失效。例如,新版本中或许会使用新的关键词,比如await,如果之前的代码里有名为await的变量,那么变量就会因为关键词的原因而失效,造成错误。在这种情况下,Rust会发布一个新版本,并用版本(edition)来使编译器知晓代码将使用哪个版本的Rust。

在Rust 2018之后,Rust的新版本Rust 2021(1.56.0)目前暂定于9月9号进行beta 测试,10月21号正式上线。

Rust 2021 有哪些新功能

来自《This Week in Rust 》第404篇博文

对于std::preclude, 增加了新的引用

std::preclude是每一个rs module默认引入的库,不需要程序员自己手动引用。当与用户自定义的库有冲突时,会优先使用用户引入的库来保证代码没有二义性。当用户自定义try_into方法时,如果同时引用标准库的try_into,并且希望同一个rs文件中,两个不同的对象一个使用用户自定义的try_into,一个使用标准库的try_into,此时由于try_into具有二义性,编译器会报错。为了解决这个问题,Rust 2021决定将新的trait加入std::preclude.

新版本的preclude与老版本相比,没有删除任何项,只是新增了三个trait: std::convert::TryInto, std::convert::TryFrom, std::iter::FromIterator。

Cargo Feature Resolver

自从1.51.0,Rust引入了新的resolver(resolver = "2"),来解决功能对于编译环境与发布环境不同时兼容的问题。

例如,对于依赖foo与其功能A和B,假设有两个包,bar依赖于foo + A,和baz依赖于foo + B。当resolver = “1”时,Cargo在编译的时候会合并功能A和B,并且编译foo + AB。这样做的好处是,foo只会被编译一次;但是问题在于,如果foo + A仅作为dev dependencies使用、foo + B仅作为target dependencies使用、且开发环境和目标环境并不同时兼容A和B呢?foo + AB将无法同时在两个平台上编译。

resolver = "2"可以解决这个问题,因为它将单独编译并使用这些功能。比如在这个例子中,foo + A只会在当前build包含dev dependencies时被启用,模块将会被分开单独编译,而不会一起编译。这确实或许会增加编译时间,但是同时可以解决在不同平台与环境上的编译问题。

对于1.56(Rust 2021)之前的版本,虽然或许resolver = "2"已被引入,但是默认的resolver仍然是”1“。Rust 2021以及后续版本将把默认resolver设定为”2“。如果项目确实有以上顾虑,请不要忘记修改Cargo.toml。

闭包与所有权

闭包提供匿名函数功能,并且闭包中可以捕获调用者作用域中的值。在之前版本中,对于结构体a,


#![allow(unused)]
fn main() {
struct a{
	x: int,
	y: int,
}
}

如果有如下闭包:


#![allow(unused)]
fn main() {
|| a + 1;
}

闭包将会获取对于a的不可变引用。

如果有如下闭包:


#![allow(unused)]
fn main() {
|| a.x + 1;
}

闭包将会获取对a(整个a)的不可变引用,而不是单独对于a.x的。如果a的其他属性(e.g. a.y)被删除,那么这个闭包将无法编译,因为闭包要求获取对于整个a的引用,而a的部分属性已经失效。


#![allow(unused)]
fn main() {
let a = SomeStruct::new();

drop(a.x); // Move out of one field of the struct

println!("{}", a.y); // Ok: Still use another field of the struct

let c = || println!("{}", a.y); // Error: Tries to capture all of `a`
c();
}

然而对于Rust 2021,闭包将不会再获取对于整个a的引用。对于a.x + 1,闭包将只获取a.x这个属性的引用。

#### 语法与前缀

其他一些有关于语法,格式,前缀的修改:

  • panic!将允许单一参数,并将其识别为format string;
  • 列表循环支持类python写法:for e in [1, 2, 3];
  • 为未来的语法和新功能保留一些前缀:prefix#identifier, prefix"string", prefix'c', and prefix#123,其中prefix可能是任何标识符;
    • 未来计划会有的新前缀:类同python写法的format string: f""c""或者z""表示C类型的String,etc.
  • Warning升格至Error;
  • macro_rules的Or(|)的使用;

更详细的说明请参阅:https://blog.rust-lang.org/2021/05/11/edition-2021.html。

如何升级到Rust新版本

在Cargo.toml中我们可以手动配置使用的Rust版本:


#![allow(unused)]
fn main() {
[package]
edition = "2021"
}

同时可以利用Cargo帮助我们自动修订对于版本不适用的代码。cargo fix --edition (--all features --target (platform, x86, lib/bin, etc.))可以帮助修复cargo check所找出的所有在local package以及这些package的dependencies之中的错误。

例如dyn,对于一些语法关键词,可以通过cargo fix --edition-idioms进行自动修改。

如果不怕代码报错的话,可以使用cargo fix --edition-idioms --broken-code,让Cargo修改所有它觉得有问题的地方。这可能导致代码无法编译。

周期性服务发现

来自《This Week in Rust 》第404篇博文

ginepro是由truelayer公司开发的一个Rust crate,在tonic的gRPC channel基础之上增加了周期性服务发现的功能来更好地实现负载均衡。

什么是gRPC

要知道gRPC,首先我们要知道RPC。

RPC(Remote Procedure Call)称为远程过程调用。当项目较小时,项目或可以完全部署在本地,调用本地服务并且获得结果。当项目规模逐渐增大,本地部署不再能完全满足需求,服务或部署至远程服务器,新的问题由此产生:部署在A服务器上的应用需要调用B服务器上应用提供的函数或方法,让B服务器应用进行计算,并得到其返回结果。

RPC框架是这个问题的一个解法。通过远程过程调用协议,不需要了解底层细节,客户端应用可以向服务端请求服务,并且像访问本地资源一样访问服务器资源。

RPC的过程广义上可以被形容为:

  1. 客户端传参;
  2. 服务器(函数)运行;
  3. 服务器返回结果。

RPC涉及到的组件通常包含:

  • 客户端;

  • 客户端存根/打桩(Client Stub):存放服务端地址信息,请求参数打包成网络信息,etc.;

  • 服务器存根/打桩(Server Stub):类同客户端存根/打桩;

  • 服务器。

gRPC是谷歌开发的一个开源通用高性能的RPC框架,有严格的接口约束条件,支持流式通讯,并可以将数据序列化为二进制编码,大幅减少传输的数据量,提高性能。然而,gRPC没有针对分布式系统数据传输的必要组件,比如负载均衡,限流熔断,服务注册,等等。

什么是tonic

toinc是基于HTTP/2的gRPC的rust实现。相比于rust的其他gRPC库,tonic已经实现了负载均衡并且可以动态更新节点,而其他的Rust库还没有实现这一点。

tonic涉及到的其他相关模块:

  • hyper(异步,底层http)
  • tokio(异步)
  • prost(a Protocol Buffers:读写序列化结构数据 e.g. XML)

问题:负载均衡

gRPC的负载均衡问题来自于HTTP/2协议的稳定性。gRPC使用HTTP/2协议在单个TCP连接中实现请求和响应的多路复用(一个信道同时传输多路信号)。由于连接是持久的,在已有连接建立以后,即使有新的服务器,连接也不会向新服务器倾斜,可能造成原有的服务器过载。

我们需要负载均衡来使得针对于每一个新请求,客户端可以合理选择一个服务器并且通过现有链接发出请求。

#### 解决方案

分析问题,可知总体解决方案如下:

  • 服务器周期性强制客户端重连(解决连接不发现问题,使得连接不过于持久)
  • 客户端周期性执行服务发现(解决服务不发现问题)
  • 引入应用级负载均衡(在应用层面监控负载均衡相关)

应用级负载均衡通常可以分为两类,客户端侧维护,以及代理或外部线程维护,也就是边车模式。前者便于测试,不会过于复杂,但没有语言或平台兼容;后者相对比较复杂,或导致系统延迟,并且难于维护和监控,或是需要额外的投入去监控,比如service mesh。

由于流量和项目要求,truelayer希望能实现负载均衡。当前Rust关于gRPC的crate有三个:grpc-rsgrpc-rusttonic,但是它们没有完全满足要求。出于性能和维护原因,truelayer也不考虑使用service mesh。这使得truelayer最终选择在三个库中最贴近需求的tonic基础之上编写了一个crate: ginepro

ginepro

tonic已经实现了大部分解决方案,但是它缺少周期性服务发现的功能。gineprotonic的channel增加了此功能来解决问题。

总体思路大致如下:

  1. 服务名字和IP相关联:通过trait LookupService来解析得到IP并存入socketaddr set;
    • 不限制解决方案,在例中truelayer使用Kubernetes' DNS或者API做
    • Kubernetes是一个开源的 Linux 容器自动化运维平台,它消除了容器化应用程序在部署、伸缩时涉及到的许多手动操作。换句话说,你可以将多台主机组合成集群来运行 Linux 容器,而 Kubernetes 可以帮助你简单高效地管理那些集群。
  2. LoadBalancedChannel将使用tonic的Channel,并进行事件循环,在循环中实现服务发现功能,并且维护endpoints列表。

具体代码如下:


#![allow(unused)]
fn main() {
/// Interface that provides functionality to
/// acquire a list of ips given a valid host name.
#[async_trait::async_trait]
pub trait LookupService {
    /// Return a list of unique `SockAddr` associated with the provided
    /// `ServiceDefinition` containing the `hostname` `port` of the service.
    /// If no ip addresses were resolved, an empty `HashSet` is returned.
    async fn resolve_service_endpoints(
        &self,
        definition: &ServiceDefinition,
    ) -> Result<HashSet<SocketAddr>, anyhow::Error>;
}
}

#![allow(unused)]
fn main() {
loop {
      let discovered_endpoints = self
          .lookup_service
          .resolve_service_endpoints(service_definition).await;
    
      let changeset = self.create_changeset(&discovered_endpoints).await;
      // Report the changeset to `tonic` to update the list of available server IPs.
      self.report_and_commit(changeset, endpoints).await?;    
      
      // Wait until the next interval.
      tokio::time::sleep(self.probe_interval).await;
}
}

评估

ginepro可以很好地解决truelayer的问题,但是同样,没有银弹,这个解决方案仍然不是完美的。如果endpoint失效,除非客户端收到显式指令(e.g. Kubernets删除了这个端点),不然客户端不会将其从端点列表中删除;其次,ginepro是客户端侧解决方案,只适用于truelayer的Rust服务,它并不通用。

引用

外刊评论三篇

编辑:张汉东/ 王江桐(华为)

编者按:国外的 Rust 资源非常丰富,将其都翻译过来也不现实,所以想到这样一个栏目。外刊评论,主要是收集优秀的 Rust 相关文章,取其精华,浓缩为一篇简单评论。

欢迎大家贡献:https://github.com/RustMagazine/rust_magazine_2021/discussions/129


目录


将 TensorFlow 模型移植到 Pure Rust 的开发成本有多高?

tf

原文: https://www.crowdstrike.com/blog/development-cost-of-porting-tensorflow-models-to-pure-rust/

该文来自于美国网络安全软件开发商 CrowdStrike Holdings(纳斯达克上市代码:CRWD)的官方博客。该公司雇员三千多人,致力于重塑云时代的安全性。

此文作者之前写过一篇文章: 站在巨人的肩膀上:结合 TensorFlow 和 Rust,其中阐述了如何进行超参数调整和实验已知的深度学习框架(如TensorFlow,PyTorch,Caffe),同时用底层语言重新实现这些模型的最佳版本,以加快预测过程。然而,在那篇文章中没有触及的一个同样重要的部分是将 TensorFlow 模型移植到 Rust 中的开发成本,所以就有了现在这篇文章。

可扩展性是CrowdStrike的重点之一,因此出现了对实现可扩展性的自动化机制的需求。该文介绍了一种新颖的通用转换机制,可以在短时间内成功地将TensorFlow模型转换为纯Rust代码,以及这种技术带来的挑战。

从普通的训练过程说起:

training

一般机器学习项目的理想工作流程是从收集和清理训练阶段要用到的语料库开始的。同样重要的是选择模型架构,以及定义它的超参数集。一旦这些要求得到满足,训练阶段就可以开始了。在这个过程结束时,我们有多个候选模型可以选择。

在所有生成的候选模型中,将使用具有最有希望的结果的模型,该模型与一组预定义的指标(如验证损失、召回率、AUC)以及定义分类器信心水平的决策值相匹配。

此外,在安全等领域,检查模型对新数据进行预测时产生的FN(false negatives)和FP(false positives)可能被证明有助于通过应用聚类或人工技术发现相关信息。如果获得的结果令人满意(例如,高TPR、低FPR,并证明在设想的对抗性攻击方面具有鲁棒性),使用所选模型进行推理的代码将被转换成底层语言,该语言将被进一步优化并提供安全保证(即防止内存泄漏、内存损坏、竞赛条件)。

Rust被证明是这项特定任务的一个杰出候选者。当然,它也可以被任何其他底层编程语言所取代。

最终目标是彻底分析分类器在受控环境中的行为,并检查决策阈值是否选择得当,并有可能进一步微调。最后,在目标终端上发布模型,同时仔细监测其性能。这个过程通常会在分类器的生命周期中多次重复,以提高检测能力,并跟上每天出现的最新威胁。

转换机制

conversion

转换机制是一个通用工具,旨在将TensorFlow模型转化为纯Rust代码。它的主要目的是过滤冗余信息,只保留推理所需的相关细节(即每层的权重和描述性超参数),并根据计算图中描述的依赖关系重新创建目标模型。最后,产生的Rust文件就可以用来在生产中安全地运行模型,同时实现性能的显著提升。

该转换机制包括:

  • 神经层(Neural-Layers Crate):目前构建于Ndarray Rust crate之上,但是还未使用它的多线程功能,未来还有性能潜力。还有一些其他优化:
    • 使用迭代器而不是直接索引矩阵
    • 批量服务能力
    • 对瓶颈层(如卷积层)使用通用矩阵乘法程序
    • 启用了BLAS(基本线性代数子程序),以使所需的乘法运算更快,而不引入预测错误
  • Rust 转换器(Rust Converter):重新创建给定神经网络背后的整个逻辑,采用了面向对象的编程风格。

结论

  1. 下一步的改进是采用一种更加标准化的格式,可以统一来自各种深度学习框架的模型(即ONNX)。这将确保没有必要强加一个特定的开发框架,而是让工程师根据自己的喜好来决定。
  2. 即使 Rust 被证明是深度学习模型的一个显著的候选者(在时间、空间和解决安全问题方面),该团队也将继续探索进一步的优化策略。
  3. 一个通用的转换机制,使该团队能够将注意力转移到更有创造性的任务上--比如设计、微调或验证不同的架构,同时将准备这些模型用于生产的成本降到最低。

Rust 2021

通常情况下来说,Rust语言组尽量让所有新增的功能在未来的版本都能适用。然而,在一些情况下,新增的功能会使之前的代码失效。例如,新版本中或许会使用新的关键词,比如await,如果之前的代码里有名为await的变量,那么变量就会因为关键词的原因而失效,造成错误。在这种情况下,Rust会发布一个新版本,并用版本(edition)来使编译器知晓代码将使用哪个版本的Rust。

在Rust 2018之后,Rust的新版本Rust 2021(1.56.0)目前暂定于9月9号进行beta 测试,10月21号正式上线。

Rust 2021 有哪些新功能

来自《This Week in Rust 》第404篇博文

对于std::preclude, 增加了新的引用

std::preclude是每一个rs module默认引入的库,不需要程序员自己手动引用。当与用户自定义的库有冲突时,会优先使用用户引入的库来保证代码没有二义性。当用户自定义try_into方法时,如果同时引用标准库的try_into,并且希望同一个rs文件中,两个不同的对象一个使用用户自定义的try_into,一个使用标准库的try_into,此时由于try_into具有二义性,编译器会报错。为了解决这个问题,Rust 2021决定将新的trait加入std::preclude.

新版本的preclude与老版本相比,没有删除任何项,只是新增了三个trait: std::convert::TryInto, std::convert::TryFrom, std::iter::FromIterator。

Cargo Feature Resolver

自从1.51.0,Rust引入了新的resolver(resolver = "2"),来解决功能对于编译环境与发布环境不同时兼容的问题。

例如,对于依赖foo与其功能A和B,假设有两个包,bar依赖于foo + A,和baz依赖于foo + B。当resolver = “1”时,Cargo在编译的时候会合并功能A和B,并且编译foo + AB。这样做的好处是,foo只会被编译一次;但是问题在于,如果foo + A仅作为dev dependencies使用、foo + B仅作为target dependencies使用、且开发环境和目标环境并不同时兼容A和B呢?foo + AB将无法同时在两个平台上编译。

resolver = "2"可以解决这个问题,因为它将单独编译并使用这些功能。比如在这个例子中,foo + A只会在当前build包含dev dependencies时被启用,模块将会被分开单独编译,而不会一起编译。这确实或许会增加编译时间,但是同时可以解决在不同平台与环境上的编译问题。

对于1.56(Rust 2021)之前的版本,虽然或许resolver = "2"已被引入,但是默认的resolver仍然是”1“。Rust 2021以及后续版本将把默认resolver设定为”2“。如果项目确实有以上顾虑,请不要忘记修改Cargo.toml。

闭包与所有权

闭包提供匿名函数功能,并且闭包中可以捕获调用者作用域中的值。在之前版本中,对于结构体a,


#![allow(unused)]
fn main() {
struct a{
	x: int,
	y: int,
}
}

如果有如下闭包:


#![allow(unused)]
fn main() {
|| a + 1;
}

闭包将会获取对于a的不可变引用。

如果有如下闭包:


#![allow(unused)]
fn main() {
|| a.x + 1;
}

闭包将会获取对a(整个a)的不可变引用,而不是单独对于a.x的。如果a的其他属性(e.g. a.y)被删除,那么这个闭包将无法编译,因为闭包要求获取对于整个a的引用,而a的部分属性已经失效。


#![allow(unused)]
fn main() {
let a = SomeStruct::new();

drop(a.x); // Move out of one field of the struct

println!("{}", a.y); // Ok: Still use another field of the struct

let c = || println!("{}", a.y); // Error: Tries to capture all of `a`
c();
}

然而对于Rust 2021,闭包将不会再获取对于整个a的引用。对于a.x + 1,闭包将只获取a.x这个属性的引用。

#### 语法与前缀

其他一些有关于语法,格式,前缀的修改:

  • panic!将允许单一参数,并将其识别为format string;
  • 列表循环支持类python写法:for e in [1, 2, 3];
  • 为未来的语法和新功能保留一些前缀:prefix#identifier, prefix"string", prefix'c', and prefix#123,其中prefix可能是任何标识符;
    • 未来计划会有的新前缀:类同python写法的format string: f""c""或者z""表示C类型的String,etc.
  • Warning升格至Error;
  • macro_rules的Or(|)的使用;

更详细的说明请参阅:https://blog.rust-lang.org/2021/05/11/edition-2021.html。

如何升级到Rust新版本

在Cargo.toml中我们可以手动配置使用的Rust版本:


#![allow(unused)]
fn main() {
[package]
edition = "2021"
}

同时可以利用Cargo帮助我们自动修订对于版本不适用的代码。cargo fix --edition (--all features --target (platform, x86, lib/bin, etc.))可以帮助修复cargo check所找出的所有在local package以及这些package的dependencies之中的错误。

例如dyn,对于一些语法关键词,可以通过cargo fix --edition-idioms进行自动修改。

如果不怕代码报错的话,可以使用cargo fix --edition-idioms --broken-code,让Cargo修改所有它觉得有问题的地方。这可能导致代码无法编译。

周期性服务发现

来自《This Week in Rust 》第404篇博文

ginepro是由truelayer公司开发的一个Rust crate,在tonic的gRPC channel基础之上增加了周期性服务发现的功能来更好地实现负载均衡。

什么是gRPC

要知道gRPC,首先我们要知道RPC。

RPC(Remote Procedure Call)称为远程过程调用。当项目较小时,项目或可以完全部署在本地,调用本地服务并且获得结果。当项目规模逐渐增大,本地部署不再能完全满足需求,服务或部署至远程服务器,新的问题由此产生:部署在A服务器上的应用需要调用B服务器上应用提供的函数或方法,让B服务器应用进行计算,并得到其返回结果。

RPC框架是这个问题的一个解法。通过远程过程调用协议,不需要了解底层细节,客户端应用可以向服务端请求服务,并且像访问本地资源一样访问服务器资源。

RPC的过程广义上可以被形容为:

  1. 客户端传参;
  2. 服务器(函数)运行;
  3. 服务器返回结果。

RPC涉及到的组件通常包含:

  • 客户端;

  • 客户端存根/打桩(Client Stub):存放服务端地址信息,请求参数打包成网络信息,etc.;

  • 服务器存根/打桩(Server Stub):类同客户端存根/打桩;

  • 服务器。

gRPC是谷歌开发的一个开源通用高性能的RPC框架,有严格的接口约束条件,支持流式通讯,并可以将数据序列化为二进制编码,大幅减少传输的数据量,提高性能。然而,gRPC没有针对分布式系统数据传输的必要组件,比如负载均衡,限流熔断,服务注册,等等。

什么是tonic

toinc是基于HTTP/2的gRPC的rust实现。相比于rust的其他gRPC库,tonic已经实现了负载均衡并且可以动态更新节点,而其他的Rust库还没有实现这一点。

tonic涉及到的其他相关模块:

  • hyper(异步,底层http)
  • tokio(异步)
  • prost(a Protocol Buffers:读写序列化结构数据 e.g. XML)

问题:负载均衡

gRPC的负载均衡问题来自于HTTP/2协议的稳定性。gRPC使用HTTP/2协议在单个TCP连接中实现请求和响应的多路复用(一个信道同时传输多路信号)。由于连接是持久的,在已有连接建立以后,即使有新的服务器,连接也不会向新服务器倾斜,可能造成原有的服务器过载。

我们需要负载均衡来使得针对于每一个新请求,客户端可以合理选择一个服务器并且通过现有链接发出请求。

#### 解决方案

分析问题,可知总体解决方案如下:

  • 服务器周期性强制客户端重连(解决连接不发现问题,使得连接不过于持久)
  • 客户端周期性执行服务发现(解决服务不发现问题)
  • 引入应用级负载均衡(在应用层面监控负载均衡相关)

应用级负载均衡通常可以分为两类,客户端侧维护,以及代理或外部线程维护,也就是边车模式。前者便于测试,不会过于复杂,但没有语言或平台兼容;后者相对比较复杂,或导致系统延迟,并且难于维护和监控,或是需要额外的投入去监控,比如service mesh。

由于流量和项目要求,truelayer希望能实现负载均衡。当前Rust关于gRPC的crate有三个:grpc-rsgrpc-rusttonic,但是它们没有完全满足要求。出于性能和维护原因,truelayer也不考虑使用service mesh。这使得truelayer最终选择在三个库中最贴近需求的tonic基础之上编写了一个crate: ginepro

ginepro

tonic已经实现了大部分解决方案,但是它缺少周期性服务发现的功能。gineprotonic的channel增加了此功能来解决问题。

总体思路大致如下:

  1. 服务名字和IP相关联:通过trait LookupService来解析得到IP并存入socketaddr set;
    • 不限制解决方案,在例中truelayer使用Kubernetes' DNS或者API做
    • Kubernetes是一个开源的 Linux 容器自动化运维平台,它消除了容器化应用程序在部署、伸缩时涉及到的许多手动操作。换句话说,你可以将多台主机组合成集群来运行 Linux 容器,而 Kubernetes 可以帮助你简单高效地管理那些集群。
  2. LoadBalancedChannel将使用tonic的Channel,并进行事件循环,在循环中实现服务发现功能,并且维护endpoints列表。

具体代码如下:


#![allow(unused)]
fn main() {
/// Interface that provides functionality to
/// acquire a list of ips given a valid host name.
#[async_trait::async_trait]
pub trait LookupService {
    /// Return a list of unique `SockAddr` associated with the provided
    /// `ServiceDefinition` containing the `hostname` `port` of the service.
    /// If no ip addresses were resolved, an empty `HashSet` is returned.
    async fn resolve_service_endpoints(
        &self,
        definition: &ServiceDefinition,
    ) -> Result<HashSet<SocketAddr>, anyhow::Error>;
}
}

#![allow(unused)]
fn main() {
loop {
      let discovered_endpoints = self
          .lookup_service
          .resolve_service_endpoints(service_definition).await;
    
      let changeset = self.create_changeset(&discovered_endpoints).await;
      // Report the changeset to `tonic` to update the list of available server IPs.
      self.report_and_commit(changeset, endpoints).await?;    
      
      // Wait until the next interval.
      tokio::time::sleep(self.probe_interval).await;
}
}

评估

ginepro可以很好地解决truelayer的问题,但是同样,没有银弹,这个解决方案仍然不是完美的。如果endpoint失效,除非客户端收到显式指令(e.g. Kubernets删除了这个端点),不然客户端不会将其从端点列表中删除;其次,ginepro是客户端侧解决方案,只适用于truelayer的Rust服务,它并不通用。

引用

Datenlord | 重新思考Rust Async - 如何实现高性能IO

作者: 王璞


长期以来,计算机系统IO的速度一直没能跟上CPU速度的提升,相比而言IO往往成为系统的性能瓶颈,计算任务等待IO存取数据,成为高性能系统的一大性能瓶颈。本文先剖析IO性能瓶颈的根源,然后举例说明如何解决IO瓶颈,最后简要介绍我们在高性能IO方面的尝试。

IO性能瓶颈

当用户程序执行IO操作时,绝大多数情况下是调用操作系统内核提供的系统调用来执行IO操作,最常见的IO系统调用是read和write。在现代计算机体系结构和操作系统的架构下,导致程序IO性能瓶颈主要有三大因素:阻塞、上下文切换、内存拷贝。下面分别简述为什么这三个因素会导致程序性能下降。

阻塞

阻塞比较好理解,比如用户程序调用read系统调用来读取数据,如果要读取的数据没有准备好(没有命中缓存),那用户程序就会被阻塞,导致用户程序休眠。等要读的数据加载到系统态的内存之后,内核再唤醒用户程序来读取数据。

阻塞对用户程序性能最大的影响在于,用户程序会被强制休眠,而且用户程序什么时候被唤醒也无法控制,程序休眠期间什么都不能做。于是阻塞带来了大量的休眠等待时间。如果程序把大量时间花在阻塞等待IO上,自然IO效率低下,进而导致程序性能受影响。

上下文切换

上下文切换是操作系统的基本概念。内核运行在系统态,用户程序运行在用户态,这么做主要是处于安全的考虑,限制用户程序的权限。用户程序调用系统调用执行IO操作,会发生上下文切换,比如用户程序调用read系统调用来读取数据,用户程序的上下文被保存起来,然后切换到内核态执行read系统调用,当read系统调用执行完毕,再切换回用户程序。

上下文切换的代价不小,一方面内核要保存上下文现场,另一方面CPU的流水线也会被上下文切换打断,需要重新加载指令。上下文切换等同一次中断操作,于是系统调用也被称软中断。频繁的上下文操作对计算机系统带来很大的开销,导致程序执行效率大大降低,进而极大影响程序的性能。

此外,阻塞的时候,一定会发生上下文切换。还是沿用read操作的例子,用户程序在调用read系统调用后,内核发现要读的数据没有命中缓存,那用户程序会被强制休眠导致阻塞,内核调度其他程序来运行。但是上下文切换的时候不一定有阻塞。比如read操作的时候,如果用户程序在调用read系统调用之后,缓存命中,则read系统调用成功返回,把该用户程序的上下文切换回来继续运行。

内存拷贝

内存拷贝对程序性能的影响主要源于内存的访问速度跟不上CPU的速度,加之内存的访问带宽有限,亦称内存墙(Memory Wall)。

现在的CPU频率都是几个GHz,每个CPU指令的执行时间在纳秒量级。但是CPU访问内存要花很多时间,因为CPU发出读取内存的指令后,不是马上能拿到数据,要等一段时间。CPU访问内存的延迟大约在几十纳秒量级,比CPU指令的执行时间差不多慢一个数量级。

再者,内存的访问带宽也是有限的,DDR4内存总的带宽大约几十GB每秒。虽然看着不小,但是每个程序在运行时都要访问内存,不论是加载程序指令,执行计算操作,还是执行IO操作,都需要访问内存。

当发生内存拷贝时,CPU把数据从内存读出来,再写到内存另外的地方。由于内存访问延迟比CPU指令执行时间慢很多,再加上内存带宽有限,于是CPU也不是随时能访问内存,CPU的访存指令会在DDR控制器的队列里排队。因此内存拷贝对于CPU来说是很花时间的操作,数据没有从内存读出来就不能执行后续写入操作,导致大量CPU等待,使得程序性能下降。

如何实现高性能IO

针对上面提到的三种影响IO性能的因素,下面举三个例子,Rust Async,io_uring和RDMA,分别来介绍如何解决这三种影响程序性能的IO问题。

Rust Async

Rust Async异步编程通过协程、waker机制,部分解决了阻塞和上下文切换的问题。

首先,Rust Async采用协程机制,在某个异步任务被阻塞后,自行切换执行下一个异步任务,一方面避免了工作线程被阻塞,另一方面也避免了工作线程被内核上下文切换。Rust Async底层依靠操作系统的异步机制,比如Linux的epoll机制,来通知IO是否完成,进而唤醒waker来调度异步任务。

但是,Rust Async仍然有阻塞。Rust Async里工作线程没有被阻塞,不过被阻塞的是waker,所以Rust Async是把阻塞从工作线程搬到了waker身上。

此外,Rust Async无法避免上下文切换。Rust Async采用Reactor的IO方式:比如用户程序要读取数据,发起read异步任务,假定该任务被阻塞放到等待队列,当该任务要读取的数据被内核准备好之后,该任务被唤醒,继续调用read系统调用把数据从内核里读到用户内存空间,这次read系统调用因为要读的数据已经被内核加载到系统态内存里,所以不会发生阻塞,但是read系统调用还会有上下文切换。

Rust Async运行在用户态,而阻塞和上下文切换是操作系统内核决定的。要想进一步避免阻塞和上下文切换,就得在内核上做文章。

io_uring

io_uring是Linux提供的原生异步接口,不仅支持异步IO,还可以支持异步系统调用。io_uring在内核与用户程序之间建立发送队列SQ和完成队列CQ,用户程序把IO请求通过SQ发给内核,然后内核把IO执行完毕的消息通过CQ发给用户程序。采用io_uring,一方面避免了阻塞,另一方面也避免了上下文切换。

io_uring采用Proactor的IO方式,Proactor是相对Reactor而言。比如用户程序采用io_uring来读取数据,先把read请求放到发送队列SQ,然后用户程序可以去执行其他任务,或者定期轮询完成队列CQ(当然用户程序也可以选择休眠被异步唤醒,但这样就会有上下文切换,不过这个上下文切换是用户程序自行选择的)。IO完成的时候,io_uring会把用户程序要读的数据加载到read请求里的buffer,然后io_uring在CQ里放入完成消息,通知用户程序IO完成。这样当用户程序收到CQ里的完成消息后,可以直接使用read请求buffer里的数据,而不需要再调用read系统调用来加载数据。

所以io_uring通过内核的支持,可以实现无阻塞和无上下文切换,进一步提升了IO的性能。但是io_uring还无法避免内存拷贝,比如read操作的时候,数据是先从IO设备读到内核空间的内存里,然后内核空间的数据再在复制到用户空间的内存。内核这么做是出于安全和简化IO的考虑。但是要想避免内存拷贝,那就得实现内核旁路(kernel bypass),避免内核参与IO。

RDMA

RDMA是常用于超算中心、高端存储等领域的高性能网络方案。RDMA需要特殊的网卡支持,RDMA网卡具有DMA功能,可以实现RDMA网卡直接访问用户态内存空间。在RDMA网卡和用户态内存之间的数据传输(即数据通路),完全不需要CPU参与,更无需内核参与。用户程序通过RDMA传输数据时,是调用RDMA的用户态library接口,然后直接和RDMA网卡打交道。所以RDMA传输数据的整个数据通路是在用户态完成,没有内核参与,既没有阻塞也没有上下文切换,更没有内存拷贝,因此采用RDMA可以获得非常好的网络IO性能。

虽然RDMA通过内核旁路避免了阻塞、上下文切换和内存拷贝,实现了高性能网络IO,但是RDMA也是有代价的。首先,RDMA编程复杂度大大提高,RDMA有自己的编程接口,不再采用内核提供的socket接口,RDMA的接口偏底层,而且调试不够友好。另外,用户程序采用RDMA之后要自行管理内存,保证内存安全,避免竞争访问:比如用户要通过RDMA网路发送数据,在数据没有发送完成前,用户程序要保证存放待发送数据的用户内存空间不能被修改,不然会导致发送的数据错误,而且即便用户程序在已经开始发送但还没有发送完成前修改了待发送的数据,RDMA也不会报错,因为RDMA的用户态library也无法控制用户程序的内存空间来保证数据一致性。这极大地增加了用户程序的开发难度。对比内核执行写操作,用户程序调用write系统调用之后,内核把待写的数据先缓存在内核空间的内存,然后就可以通知用户程序写操作完成,回头内核再把写数据写入设备。虽然内核有做内存拷贝,但是保证了数据一致性,,也降低了用户程序执行IO操作的开发复杂度。

我们的尝试

DatenLord的目标是打造高性能分布式存储系统,我们在开发DatenLord的过程中,一直在探索如何实现高性能IO。

虽然Rust Async异步编程理念非常不错,用类似同步IO语意实现异步IO。但是我们认为Rust Async更多是异步IO的编程框架,还称不上是高性能IO框架。于是我们尝试把Rust Async跟io_uring和RDMA结合,以实现高性能IO。

首先,Rust Async与io_uring的结合工作,虽然Rust社区在这方面也有不少类似的尝试,但是我们的重点是如何在io_uring执行异步IO的时候避免内存拷贝,这方面Rust社区的工作还很少。我们尝试采用Rust的ownership机制来防止用户程序修改提交给io_uring用于执行IO操作的用户态内存,一方面避免内存拷贝,一方面保证内存安全。感兴趣的朋友可以看下ring-io

另外,我们也在尝试结合Rust Async与RDMA,这方面Rust社区的工作不多。RDMA性能虽好,但是开发复杂度很大,而且调试不友好。我们尝试采用Rust friendly的方式来实现RDMA接口的异步封装,同时解决RDMA程序需要开发者自行管理内存的问题,从而大大降低采用Rust开发RDMA程序的难度。感兴趣的朋友可以看下async-rdma

最后,欢迎对高性能IO感兴趣的朋友们联系我们,跟我们一起交流探讨。我们的联系方式:dev@datenlord.io

Phala Network | 在Intel SGX环境下实现Rust原生std支持

作者:Kevin Wang


简介:Intel SGX是一个把应用与OS完全隔离的可信执行环境,应用无法直接访问OS提供的资源。我们采用的Teaclave-SGX-SDK只提供了no_std环境,导致crates生态下大量的库都无法被使用。我们通过添加libc函数模拟linux平台特性,实现依赖std的Rust生态库无需修改即可在SGX环境使用。为了保证尽可能小的安全边界,我们对每个增补的libc函数做了权限控制。同时引入了二进制分析,确保程序不会出现SGX非法指令。


背景

Phala Network的隐私云计算服务基于teaclave-sgx-sdk开发,由于Intel CPU的SGX执行环境相当于裸机无系统,自然地基于teaclave-sgx-sdk开发的rust程序也只能用no_std开发。

但当项目复杂后,我们还是希望能够充分利用Rust的crate生态,这个生态里大部分crate是依赖rust std的。我们no_std环境想要使用这些std的crate的话那就得做移植了。

如果单纯地将std的crate移植到no_std环境,那每一个crate都会有比较大的工作量。teaclave-sgx-sdk为了方便移植,给我们准备了一个sgx_tstd(一个sgx环境的std仿制品)。sgx_tstd保留了rust std中的大部分功能,因此,一般简单的crate移植到sgx_tstd仅需要改动数行代码,比如在crate根部添加extern sgx_tstd as std,以及添加一些use std::prelude::v1::*; 。这样移植rust crate生态就方便了许多,teaclave-sgx-sdk团队甚至将一些常用的crate都移植好了放到github.com/mesalock-linux中.

sgx_tstd的问题

移植一个crate看上去工作量并不大,但我们很多时候引入一个crate并不是单纯的一个crate,他背后的依赖树连根拔起可能有数十个crate。原本正常Rust生态使用一个第三方crate只需要在Cargo.toml中添加一行代码,而现在变成要去移植一大车crate到sgx环境。

这种开发模式已经事实上导致了Rust生态被分叉成了crates.iomesalock-linux两个世界,这种分裂甚至让一些no_std的crate也受影响让两边互不兼容,比如混用一些依赖log或serde的no_std crate就不能正常编译,不得不修改他们使用log-sgx和serde-sgx。如果哪天有人再为arm/AMD的TEE做一个类似的rust sdk,难道我们要将crates.io继续分叉下去?

这种分叉行为同时会导致被移植的生态可能代码更新不及时,一些针对crates.iogithub.com的安全扫描公共设施可能也会漏掉mesalock-linux生态中的隐患,从而影响下游开发或带来安全威胁。

让SGX支持Rust原生std

teaclave-sgx-sdk开发应用目前标准做法是开启#![no_std]并编译target到 x86_64-unknown-linux-gnu

既然已经target到了x86_64-unknown-linux-gnu,那么我们如果不开启#![no_std]编译会有什么问题呢?

简单尝试会得到类似如下链接错误:

Rust的std会依赖libc来和OS交互,intel sgx-sdk里面有一个不完全实现的sgx libc。但Rust需要的和系统交互这部分libc函数往往是SGX不信任的,所以sgx libc没有直接提供,而是大部分实现在ocall模块下以rust ABI函数的方式提供对等功能,以此提醒开发者这是不受信任的操作。

因此,我们想提供一个转接层,把这些缺失libc函数都补齐,并代理到sgx-sdk的对等实现基本就能正常编译使用原生std了。比如,上图缺失write函数,我们就补一个write函数:


#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn write(fd: c_int, buf: *const c_void, count: size_t) -> ssize_t {
	unsafe { ocall::write(fd, buf, count) }
}
}

这样,我们转接层对上模拟一个linux glibc的行为,对下转接到sgx特别实现,可让针对linux的编译的Rust应用程序跑在sgx内。

经验证的确如此,在添加了相应libc函数并拆掉一部分特殊代码后,我们enclave程序就运行起了。

std和sgx_tstd共存

上面提到,拆掉了一部分代码,主要这些代码依赖sgx_tstd里面特有功能,比如sgx_tstd::sgxfs::SgxFile。而开启原生std后sgx_tstd就因为rust的lang_item冲突而不能编译了。要想恢复使用这些功能,我们要么自己重新实现(copy)一份,要么让sgx_tstd和std共存。显然,后者更符合可持续发展原则。因此,我们给sgx_tstd打个补丁,让lang_item变成一个feature,不开启它就能与原生std共存了。

安全考虑

我们enclave程序是安全敏感的,如果一股脑将libc代理到ocall函数,显然是粗鲁的不安全的。因此,我们对每个代理的函数都会根据我们业务需求对其安全性做思考,调整其实现行为。

getrandom

随机数安全性尤其重要,直接关系到我们的密钥安全。rust的rand crate会调用getrandom函数来获取随机熵源。我们将getrandom函数代理到sgx_read_rand,sgx_read_rand在HW模式下会通过CPU硬件获取真随机数。实现如下:


#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn getrandom(buf: *mut c_void, buflen: size_t, flags: c_uint) -> ssize_t {
    if buflen == 0 {
        return 0;
    }

    let rv = unsafe { sgx_read_rand(buf as _, buflen) };

    match rv {
        sgx_status_t::SGX_SUCCESS => buflen as _,
        _ => {
            if flags & libc::GRND_NONBLOCK != 0 {
                set_errno(libc::EAGAIN);
            } else {
                set_errno(libc::EINTR);
            }
            -1
        }
    }
}
}

权限相关

支持了std,我们需要严格控制enclave内代码的权限,以最大限度降低安全风险。越权访问代码最好是在编译构建时阻拦下来,次之是在运行时限制越权访问。

转接层对权限的控制策略如下:

操作处置方法
打开文件禁止打开文件,运行时报错。由于crate getrandom实现需要open("/dev/urandom")作为不支持getrandom函数平台的fallback, 虽然由于我们getrandom的实现, 使其不会执行到open的路径去, 但代码依赖无法去除, 除非给crate getrandom打补丁。
read/write运行时限制只允许操作stdio相关fd。
网络操作禁止编译,链接报错。
syscall只允许SYS_getrandom,并代理到sgx_read_rand。 其余call运行时报错。
创建线程禁止编译,链接报错。
获取工作目录返回“(unreachable)", 该值是linux-glibc定义的工作目录不可达时的返回值。
获取环境变量禁止,永远返回空。
获取时间代理到ocall::clock_gettime, 由业务代码注意不要信任获取到的时间。
dlsym动态加载函数只允许getrandom,其余返回空。
mmap允许其内存分配功能,代理到sgx的malloc。其余操作返回错误。

链接错误排查

由于我们现在的原生std依然存在部分功能缺失, 当我们引入新的依赖到SGX时,少数情况有可能会遇到链接错误,比如依赖中有网络操作,报了如下错误:

简单情况下,我们可以去检查源码,发现是哪部分功能引入了这个依赖。但很多情况下,其实我们看到的代码虽然会经过编译,但最终进入binary的只有其中一小部分,那些静态不可达的代码都会被编译器/链接器丢弃掉。因此,我们可能很难根据原始源码判断出实际生效的依赖关系。

因此,我们需要从最终输出的binary出发来分析上图中的connect究竟是怎么被引入进来的。我们写了一个callerfinder.py来辅助分析此类问题。

第一步先把这些undefined reference都用一个空的占位符号补上,使其能编译通过:

然后使用callerfinder库查找undefined函数的依赖关系:

In [1]: from callerfinder import CallerFinder
In [2]: finder = CallerFinder("./enclave/enclave.so")
In [3]: finder.print_callers('std::net::tcp::TcpStream::connect', 14)
std::net::tcp::TcpStream::connect_timeout::h79c6c1fec8ad56c5
  http_req::request::Request::send::h8ea00de7a9d4e562
    enclaveapp::create_attestation_report::h08c59df2ec69ab65
      enclaveapp::prpc_service::get_runtime_info::h5ee8ea7c8422d583
        phala_enclave_api::...::dispatch_request::hd1bf94703ec9513e
          ecall_prpc_request
            sgx_ecall_prpc_request

这样我们就很清晰地找到网络操作的来源,根据情况采取对应的措施,比如这里我们把http_req换成原http_req-sgx移植版本即可。

CPUID指令问题

我们将enclave代码迁移到std后用SGX_MODE=SW模式顺畅运行,SGX_MODE=HW环境下则出现多处崩溃。经排查这些崩溃均指向同一个函数rand::thread_rng(),而rand::thread_rng()其内部实现使用了std::is_x86_feature_detected宏来检测CPU对SIMD的支持程度。该宏使用了SGX环境禁止的CPUID指令,导致程序崩溃。

一方面SGX环境出于安全考虑禁止了CPUID指令,另一方面,应用程序使用CPUID检测CPU对SIMD的支持情况是很常见的“正当行为”。虽然CPUID触发崩溃虽然没有泄露信息,没有越权访问,没有触发Unsound等安全问题,是一种运行时安全守卫措施。但这种“正当行为”而触发运行时崩溃显然不能接受,如果我们代码依赖中有相关检测逻辑,在我们的业务随时有宕机风险。因此,我们要么让std::is_x86_feature_detected适配sgx环境,要么让保证我们整体代码不触及CPUID。

Teaclave的sgx_tstd中重新实现了is_x86_feature_detected宏来避免触及CPUID。而我们采用原生std的方案,问题就稍微复杂一点了,我们无法像前文那样通过libc重新实现is_x86_feature_detected(除非给std打补丁)。另外,单解决一个is_x86_feature_detected显然也不能避免代码直接内嵌CPUID汇编指令的情况。因此,我们暂且选择让代码不触及CPUID。

具体方法为,我们增加一个编译后处理步骤,通过反汇编输出的enclave.so来检查其中是否含有CPUID指令。如果有我们让make报错并打印出函数名:

然后可利用前述callerfinder.py找出哪些函数导致依赖了CPUID:

然后,我们可以顺藤摸瓜找到对应的代码实现。如果是必要的,我们可以patch对应的crate让他使用teaclave提供的is_x86_feature_detected, 比如rand::thread_rng();如果是能砍掉的功能我们就砍掉,比如上图中的env_logger我们关掉其regex feature即可消除此依赖。

关于其它SGX非法指令

既然CPUID存在此问题,那么是否可能碰到其它SGX特别禁止的指令呢?理论上当然是可能碰到的,从intel的指南看看还有哪些特殊指令。

指南描述一些SGX环境的非法指令如下:

对于其2和其3,除了INT/SYSCALL/SYSENTER等系统调用指令之外,其余都应只出现在系统内核代码中。而系统调用相关功能除非极其个性的程序,否则不论是Rust还是C/C++生态都应调用libc的相关函数或syscall函数来完成,而不是直接嵌入汇编指令。

我们着重需要关注1中这些指令:

  • CPUID 常用于检测CPU对SIMD的支持,以便使用不同SIMD指令集处理计算密集任务,需要关注。

  • GETSEC 是一个leaf funtion总入口,有很多子功能,都是特殊用途的。一般程序不会用到

  • RDPMC

    读取性能计数器,特殊用途,perf之类的工具使用,不必关注。

  • RDTSC/RDTSCP

    读取CPU timestamp计数器,可能被应用程序使用,加入指令检测脚本。

  • SGDT - Store Global Descriptor Table Register

    仅操作系统使用

  • SIDT - Store Interrupt Descriptor Table Register

    仅操作系统使用

  • SLDT - Store Local Descriptor Table Register

    仅操作系统使用

  • STR - Store Task Register

    仅操作系统使用

  • VMCALL/VMFUNC 虚拟化相关指令,不用关注

为保险起见,我们把这些指令全都加入后处理检查脚本中,禁止其使用。

小结

添加std支持后,我们用Rust开发SGX程序变得和开发普通Rust应用程序无太大差异,也能直接使用Rust标准工具链的单元测试等设施,开发效率上升一个台阶。

字节 | 如何用Rust做Android UI渲染

作者:大力智能技术团队-客户端-西豢沝尔

来源:https://juejin.cn/post/7002778072348885000/


背景

Rust优秀的安全性、媲美C++的性能以及对跨平台编译和外部语言(ffi)的支持使得其成为高性能跨平台库的上佳实现语言。然而,Rust是否可以在逻辑层之上进一步服务于一些通用性的UI渲染?我们大力智能客户端团队针对开源项目rust-windowing中几个核心工程进行剖析,并结合在Android系统的接入对此进行探索。

1

Rust UI渲染:

Android 系统上使用 Rust 渲染核心围绕ANativeWindow类展开,ANativeWindow位于android ndk中,是egl跨平台EGLNativeWindowType窗口类型在 Android 架构下的特定实现,因而基于ANativeWindow 就可以创建一个EglSurface 并通过 GLES 进行绘制和渲染。另一方面,ANativeWindow可以简单地与 Java 层的Surface相对应,因而将 Android 层需要绘制的目标转换为ANativeWindow是使用 Rust 渲染的关键,这一部分可以通过JNI完成。首先,我们先看一下rust-windowing 对UI渲染的支持。

1 软件绘制:

rust-windowing项目中,android-ndk-rs提供了rustandroid ndk之间的胶水层,其中与UI渲染最相关的就是NativeWindow类,NativeWindow在Rust上下文实现了对ANativeWindow的封装,支持通过ffiANativeWindow进行操作,达到与在 java 层使用lockCanvas()unlockCanvasAndPost()进行绘制相同的效果,基于这些api,我们可以实现在(A)NativeWindow上的指定区域绘制一个长方形:


#![allow(unused)]
fn main() {
unsafe fn draw_rect_on_window(nativewindow: &NativeWindow, colors: Vec<u8>, rect: ndk_glue::Rect) {
    let height = nativewindow.height();
    let width = nativewindow.width();
    let color_format = get_color_format();
    let format = color_format.0;
    let bbp = color_format.1;
    nativewindow.set_buffers_geometry(width, height, format);
    nativewindow.acquire();
    let mut buffer = NativeWindow::generate_epmty_buffer(width, height, width, format);
    let locked = nativewindow.lock(&mut buffer, &mut NativeWindow::generate_empty_rect(0, 0, width, height));
    if locked < 0 {
        nativewindow.release();
        return;
    }

    draw_rect_into_buffer(buffer.bits, colors, rect, width, height);
    let result = nativewindow.unlock_and_post();
    nativewindow.release();
}

unsafe fn draw_rect_into_buffer(bits: *mut ::std::os::raw::c_void, colors: Vec<u8>, rect: ndk_glue::Rect, window_width: i32, window_height: i32) {
    let bbp = colors.len() as u32;
    let window_width = window_width as u32;
    for i in rect.top+1..=rect.bottom {
        for j in rect.left+1..=rect.right {
            let cur = (j + (i-1) * window_width - 1) * bbp;
            for k in 0..bbp {
                *(bits.offset((cur + (k as u32)) as isize) as *mut u8) = colors[k as usize];
            }
        }
    }
}

}

这样就通过提交一个纯色像素填充的Buffer在指定的位置成功渲染出了一个长方形,不过这种方式本质上是软件绘制,性能欠佳,更好的方式是通过在Rust层封装GL在ANativeWindow上使能硬件绘制。

2 硬件绘制:

2.1 跨平台窗口系统:winit

2.1.1 Window:窗口

窗口系统最主要的目的是提供平台无关的 Window 抽象,提供一系列通用的基础方法、属性方法、游标相关方法、监控方法。winit 以 Window 类抽象窗口类型并持有平台相关的 window 实现,通过 WindowId 唯一识别一个 Window 用于匹配后续产生的所有窗口事件WindowEvent,最后通过建造者模式对外暴露实例化的能力,支持在 Rust 侧设置一些平台无关的参数(大小、位置、标题、是否可见等)以及平台相关的特定参数,基本结构如下:


#![allow(unused)]
fn main() {
// src/window.rs
pub struct Window {
    pub(crate) window: platform_impl::Window,
}

impl Window {
    #[inline]
    pub fn request_redraw(&self) {
        self.window.request_redraw()
    }

    pub fn inner_position(&self) -> Result<PhysicalPosition<i32>, NotSupportedError> {
        self.window.inner_position()
    }

    pub fn current_monitor(&self) -> Option<MonitorHandle> {
        self.window.current_monitor()
    }
}

pub struct WindowId(pub(crate) platform_impl::WindowId);

pub struct WindowBuilder {
    /// The attributes to use to create the window.
    pub window: WindowAttributes,

    // Platform-specific configuration.
    pub(crate) platform_specific: platform_impl::PlatformSpecificWindowBuilderAttributes,
}

impl WindowBuilder {
    #[inline]
    pub fn build<T: 'static>(
        self,
        window_target: &EventLoopWindowTarget<T>,
    ) -> Result<Window, OsError> {
        platform_impl::Window::new(&window_target.p, self.window, self.platform_specific).map(
            |window| {
                window.request_redraw();
                Window { window }
            },
        )
    }
}

}

在Android平台,winit暂时不支持使用给定的属性构建一个“Window”,大部分方法给出了空实现或者直接panic,仅保留了一些事件循环相关的能力,真正的窗口实现仍然从android-ndk-rs胶水层获得:当前的android-ndk-rs仅针对ANativeActivity进行了适配,通过属性宏代理了unsafe extern "C" fn ANativeActivity_onCreate(...)方法,在获得ANativeActivity指针*activity后,注入自定义的生命周期回调,在onNativeWindowCreated回调中获得ANativeWindow(封装为NativeWindow)作为当前上下文活跃的窗口。当然,android-ndk-rs的能力也支持我们在任意一个ANativeWindow上生成对应的上层窗口。

2.1.2 EventLoop:事件循环 - 上层

事件循环是整个窗口系统行为的驱动,统一响应抛出的系统任务和用户交互并将反馈渲染到窗口上形成闭环,当你需要合理地触发渲染时,最好的方式就是将指令发送给事件循环。winit中,将事件循环封装为EventLoop,使用ControlFlow控制EventLoop如何获取、消费循环中的事件,并对外提供一个EventLoopProxy代理用于作为与用户交互的媒介,支持用户通过proxyEventLoop发送用户自定义的事件:

2

Android 平台的事件循环建立在ALooper之上,通过android-ndk-rs提供的胶水层注入的回调处理生命周期行为和窗口行为,通过代理InputQueue处理用户手势,同时支持响应用户自定义事件和内部事件。一次典型的循环根据当前first_event的类型分发处理,一次处理一个主要事件;当first_event处理完成后,触发一次MainEventsCleared事件回调给业务方,并判断是否需要触发ResizedRedrawRequested,最后触发RedrawEventsCleared事件标识所有事件处理完毕。 单次循环处理完所有事件后进入控制流,决定下一次处理事件的行为,控制流支持Android epoll多路复用,在必要时唤醒循环处理后续事件,此外,控制流提供了强制执行、强制退出的能力。事实上,android-ndk-rs就是通过添加fd的方式将窗口行为抛到EventLoop中包装成Callback事件处理:

  • 首先,新建一对fdPIPE: [RawFd; 2],并把读端加到ALooper中,指定标识符为NDK_GLUE_LOOPER_EVENT_PIPE_IDENT
  • 然后,在适当的时机调用向fd写端写入事件;
  • 最后,fd写入后触发ALooperpoll时被唤醒,且得到被唤醒fdidentNDK_GLUE_LOOPER_EVENT_PIPE_IDENT,便可以从fd读端读出此前wake()写入的事件并进行相应的处理;

#![allow(unused)]
fn main() {
// <--1--> 挂载fd
// ndk-glue/src/lib.rs
lazy_static! {
    static ref PIPE: [RawFd; 2] = {
        let mut pipe: [RawFd; 2] = Default::default();
        unsafe { libc::pipe(pipe.as_mut_ptr()) };
        pipe
    };
}

{
    ...
    thread::spawn(move || {
        let looper = ThreadLooper::prepare();
        let foreign = looper.into_foreign();
        foreign
            .add_fd(
                PIPE[0],
                NDK_GLUE_LOOPER_EVENT_PIPE_IDENT,
                FdEvent::INPUT,
                std::ptr::null_mut(),
            )
            .unwrap();
    });
    ...
}

// <--2--> 向fd写入数据
// ndk-glue/src/lib.rs
unsafe fn wake(_activity: *mut ANativeActivity, event: Event) {
    log::trace!("{:?}", event);
    let size = std::mem::size_of::<Event>();
    let res = libc::write(PIPE[1], &event as *const _ as *const _, size);
    assert_eq!(res, size as _);
}

// <--3--> 唤醒事件循环读出事件
// src/platform_impl/android/mod.rs
fn poll(poll: Poll) -> Option<EventSource> {
    match poll {
        Poll::Event { ident, .. } => match ident {
            ndk_glue::NDK_GLUE_LOOPER_EVENT_PIPE_IDENT => Some(EventSource::Callback),
            ...
        },
        ...
    }
}

// ndk-glue/src/lib.rs
pub fn poll_events() -> Option<Event> {
    unsafe {
        let size = std::mem::size_of::<Event>();
        let mut event = Event::Start;
        if libc::read(PIPE[0], &mut event as *mut _ as *mut _, size) == size as _ {
            Some(event)
        } else {
            None
        }
    }
}

}

2.2 跨平台egl上下文:glutin

我们有了跨平台的 OpenGL(ES) 用于描述图形对象,也有了跨平台的窗口系统 winit 封装窗口行为,但是如何理解图形语言并将其渲染到各个平台的窗口上?这就是egl发挥的作用,它实现了OpenGL(ES)和底层窗口系统之间的接口层。在rust-windowing项目中,glutin工程承接了这个职责,以上下文的形式把窗口系统winitgl关联了起来。

3

Context是gl的上下文环境,全局可以有多个gl上下文,但是一个线程同时只能有一个活跃的上下文,使用ContextCurrentState区分这一状态。glutinContext可关联零个或多个Window,当Context与Window相关联时,使用ContextWrapper类,ContextWrapper使得可以方便地在上下文中同时操作gl绘制以及Window渲染。在其上衍生出两个类型:

  • RawContext:Context与Window虽然关联但是分开存储;
  • WindowedContext:同时存放相互关联的一组Context和Window。常见的场景下WindowedContext更加适用,通过ContextBuilder指定所需的gl属性和像素格式就可以构造一个WindowedContext,内部会初始化egl上下文,并基于持有的EglSurfaceType类型的 window 创建一个eglsurface作为后续gl指令绘制(draw)、回读(read)的作用目标(指定使用该surface上的缓冲)。

2.3 硬件绘制的例子:

基于 winit 和 glutin 提供的能力,使用 Rust 进行渲染的准备工作只需基于特定业务需求去创建一个glutin 的Context,通过 Context 中创建的 egl上下文可以调用gl api进行绘制,而window让我们可以掌控渲染流程,在需要的时候(比如基于EventLoop重绘指令或者一个简单的无限循环)下发绘制指令。简单地实现文章开头的三角形demo动画效果如下:


#![allow(unused)]
fn main() {
fn render(&mut self, gl: &Gl) {
    let time_elapsed = self.startTime.elapsed().as_millis();
    let percent = (time_elapsed % 5000) as f32 / 5000f32;
    let angle = percent * 2f32 * std::f32::consts::PI;

    unsafe {
        let vs = gl.CreateShader(gl::VERTEX_SHADER);
        gl.ShaderSource(vs, 1, [VS_SRC.as_ptr() as *const _].as_ptr(), std::ptr::null());
        gl.CompileShader(vs);
        let fs = gl.CreateShader(gl::FRAGMENT_SHADER);
        gl.ShaderSource(fs, 1, [FS_SRC.as_ptr() as *const _].as_ptr(), std::ptr::null());
        gl.CompileShader(fs);
        let program = gl.CreateProgram();
        gl.AttachShader(program, vs);
        gl.AttachShader(program, fs);
        gl.LinkProgram(program);
        gl.UseProgram(program);
        gl.DeleteShader(vs);
        gl.DeleteShader(fs);
        let mut vb = std::mem::zeroed();
        gl.GenBuffers(1, &mut vb);
        gl.BindBuffer(gl::ARRAY_BUFFER, vb);
        let vertex = [
            SIDE_LEN * (BASE_V_LEFT+angle).cos(), SIDE_LEN * (BASE_V_LEFT+angle).sin(),  0.0,  0.4,  0.0,
            SIDE_LEN * (BASE_V_TOP+angle).cos(),  SIDE_LEN * (BASE_V_TOP+angle).sin(),  0.0,  0.4,  0.0,
            SIDE_LEN * (BASE_V_RIGHT+angle).cos(), SIDE_LEN * (BASE_V_RIGHT+angle).sin(),  0.0,  0.4,  0.0,
        ];

        gl.BufferData(
            gl::ARRAY_BUFFER,
            (vertex.len() * std::mem::size_of::<f32>()) as gl::types::GLsizeiptr,
            vertex.as_ptr() as *const _,
            gl::STATIC_DRAW,
        );

        if gl.BindVertexArray.is_loaded() {
            let mut vao = std::mem::zeroed();
            gl.GenVertexArrays(1, &mut vao);
            gl.BindVertexArray(vao);
        }

        let pos_attrib = gl.GetAttribLocation(program, b"position\0".as_ptr() as *const _);
        let color_attrib = gl.GetAttribLocation(program, b"color\0".as_ptr() as *const _);
        gl.VertexAttribPointer(
            pos_attrib as gl::types::GLuint,
            2,
            gl::FLOAT,
            0,
            5 * std::mem::size_of::<f32>() as gl::types::GLsizei,
            std::ptr::null(),
        );

        gl.VertexAttribPointer(
            color_attrib as gl::types::GLuint,
            3,
            gl::FLOAT,
            0,
            5 * std::mem::size_of::<f32>() as gl::types::GLsizei,
            (2 * std::mem::size_of::<f32>()) as *const () as *const _,
        );

        gl.EnableVertexAttribArray(pos_attrib as gl::types::GLuint);
        gl.EnableVertexAttribArray(color_attrib as gl::types::GLuint);
        gl.ClearColor(1.3 * (percent-0.5).abs(), 0., 1.3 * (0.5 - percent).abs(), 1.0); 
        gl.Clear(gl::COLOR_BUFFER_BIT);
        gl.DrawArrays(gl::TRIANGLES, 0, 3);
    }
}

}

3 Android - Rust JNI开发

以上 Rust UI渲染部分完全运行在Rust上下文中(包括对c++的封装),而实际渲染场景下很难完全脱离Android层进行UI的渲染或不与Activity等容器进行交互。所幸Rust UI渲染主要基于(A)NativeWindow,而Android Surface在c++的对应类实现了ANativeWindow,ndk也提供了ANativeWindow_fromSurface方法从一个surface获得ANativeWindow对象,因而我们可以通过JNI的方式使用Rust在Android层的Surface上进行UI渲染:


#![allow(unused)]
fn main() {
// Android

surface_view.holder.addCallback(object : SurfaceHolder.Callback2 {

    override fun surfaceCreated(p0: SurfaceHolder) {
        RustUtils.drawColorTriangle(surface, Color.RED)
    }

    override fun surfaceChanged(p0: SurfaceHolder, p1: Int, p2: Int, p3: Int) {}

    override fun surfaceDestroyed(p0: SurfaceHolder) {}

    override fun surfaceRedrawNeeded(p0: SurfaceHolder) {}

})

// Rust
pub unsafe extern fn Java_com_example_rust_1demo_RustUtils_drawColorTriangle__Landroid_view_Surface_2I(env: *mut JNIEnv, _: JClass, surface: jobject, color: jint) -> jboolean {
    println!("call Java_com_example_rust_1demo_RustUtils_drawColor__Landroid_view_Surface_2I"); 
    ndk_glue::set_native_window(NativeWindow::from_surface(env, surface));
    runner::start();
    0
}

}

需要注意,由于EventLoop是基于ALooper的封装,调用Rust实现渲染时需要确保调用在有Looper的线程(比如HandlerThread中),或者在Rust渲染前初始化时为当前线程准备ALooper

总结

使用Rust在Android上进行UI渲染的可行性已经得证,但是它的性能表现究竟如何?未来又将在哪些业务上落地?这些仍待进一步探索。

学习资源

2021 春季全球进阶类 OS 课程汇总信息

Advanced OS Course (2021 Spring)

本仓库由清华大学陈渝教授维护

https://github.com/chyyuu/aos_course_info

Rust based OS/Hypervisor/VMM/Firmwire :

https://github.com/chyyuu/aos_course_info/blob/master/oslist.md

结合 Axum、Hyper、Tonic 和 Tower 用于混合 Web/gRPC 应用程序

由 FPComplete 公司写的系列教程:

FishFight: macroquad 游戏框架实现的一款2D 闯关游戏

刚刚开源

一个深度学习研究员学习Rust的经验

作为深度学习研究员,处于深度学习领域的最前沿,自然用 py 和现成的框架是不够的。而 Rust 是一个非常好的替代 c++ 的底层算法实现选择。

Reddit 下面有相关的讨论值得了解:https://www.reddit.com/r/rust/comments/pft9n9/i_wanted_to_share_my_experience_of_rust_as_a_deep/

编写高性能 Rust 代码的几个简单技巧

请记住,下面的建议并不能取代实际的分析和优化 :

  1. 配置 release profile:
[profile.release]
lto = "fat"
codegen-units = 1
panic = "abort"

  1. 设置 -C target-cpu=native
  2. 选择 jemalloc 或 mimalloc 分配器
  3. 阅读 Profile Guided Optimization
  4. 使用性能测试工具。

https://deterministic.space/high-performance-rust.html

使用 Rust 和 SIMD加速来创建世界上最快的 tac

tac是对Coreutils的GNU tac工具的一个高性能、模拟加速、跨平台的重写,在与BSD兼容的(MIT)许可证下发布。tac从文件(或从stdin)中读取输入,然后逐行逆向打印。

tac 倒过来就是 cat 。

这个tac的实现使用simd-acceleration进行新行检测(在这里阅读更多关于它的内容),并在所有支持的操作系统上利用内存映射的文件。此外,它是用rust编写的,以获得最大的完整性和安全性。

作者说道:“在2021年,在tac中动态地启用AVX2支持要容易得多!”。

如果你想学习 SIMD 相关内容可以参考这篇文章。

hebi : 由 Bevy 引擎驱动的贪吃蛇游戏

hebi 是一个高度可定制的贪吃蛇游戏复刻,使用 Rust 写就,由 Bevy 引擎驱动,命名源于日语中的“蛇”。

该项目有助于学习 Bevy 引擎。

https://github.com/ElnuDev/hebi

【PodCast】cURL 中的 Rust

Allen Wyma 与 cURL 的原作者 Daniel 谈论在 cURL 中使用 Rust。

  • cURL 是一个命令行工具和库,用于通过 URL 传输数据。
  • cURL 及其数据传输核心 libcurl 都是用 C 编写的,众所周知,这不是内存安全的。
  • 虽然几乎不可能将其重写为另一种语言,但提供一个用 Rust 编写的第三方库可能会更进一步。

https://rustacean-station.org/episode/035-daniel-stenberg/

加快 Rust 的编译

众所周知,Rust代码编译起来很慢。但我有一种强烈的直觉,大多数Rust代码的编译速度比它本可以的要慢得多。

例如, Rust 的 rust-analyzer CI 在 GitHub 上操作需要8分钟。这是一个相当大和复杂的项目,有20万行自己的代码和100万行依赖。

跟随作者, 让我们进一步了解如何使编译时间保持在合理的范围内!

https://matklad.github.io/2021/09/04/fast-rust-builds.html

对 Rust 异步语法的重新思考

该文作者对 async trait 相关问题做了深刻思考之后得出一个结论:现在的 async fn 语法设计是错误的。


#![allow(unused)]
fn main() {
// this really returns an `impl Future<Output = usize>`, but that's hidden
async fn foo() -> usize { 1 }
}

这是当前的异步语法,它隐藏了真正的返回类型:impl Future<Output = usize>

该文作者认为,这个返回类型不应该被隐藏:


#![allow(unused)]
fn main() {
async fn foo() -> impl Future<Output = usize> { 1 }
}

这篇文章就是介绍这种显式的返回有哪些好处。文章很长,可以抽个安静的时间,跟随作者的思考去看此文。

https://ibraheem.ca/writings/an-alternative-async-fn-syntax/

Rust 插件开发系列文章

这系列文章来自于 Google代码之夏中 Tremor 项目的 issues: Plugin Development Kit ( PDK )

简而言之,Tremor 是一个事件处理系统。 它最初是为了替代 Logstash 或 Telegraf 等软件而设计的。 然而,通过支持更复杂的工作流(例如聚合、汇总、ETL 语言和查询语言),tremor 已经超出了这个单一用例的范围。

为 Tremor 插件开发一个通用接口,使上述库变得更加模块化,并减少核心依赖集。

这将大大减少 Tremor 的核心大小,这意味着该库的编译速度将更快,二进制大小也更小。最重要的是,它将把Tremor的架构转变为完全模块化,其中插件可以根据需要进行配置并以语言无关的方式独立开发。

为什么 Rust 需要 Pin 和 unpin

  • 什么是Futures
  • 什么是自引用类型
  • 为什么他们不安全
  • Pin/Unpin 如何使它们安全
  • 使用 Pin/Unpin 编写复杂的futures

https://blog.cloudflare.com/pin-and-unpin-in-rust/

Rust 的 Logging 推荐

内容整理自 Reddit 的讨论:What is the current recommendation for logging in Rust? : rust

问题简述:除了标准的 log,还有不少选择:env_logger,tracing,slog,simplelog 等等,最佳实践是什么?

来自 Koxiaet 的答复:

通常有两类与日志相关的 crate:日志接口和日志消费者。接口提供了想要记录某些东西时调用的函数,消费者处理将结构化日志数据格式化到某个地方(stderr 或文件)。两个主要的接口是 log 和 tracing,后者功能更强大因为它支持结构化日志记录,但前者更普遍。还有另一个结构化日志接口 slog,比 tracing 更古老但用的较少。每个日志接口都有自己生态系统,可以根据自己的需要选择。如果在写一个库,log 是个不错的选择,因为所有的日志记录接口都与它兼容。但如果你确实需要结构化日志记录,则可以改用 tracing,这取决于你的需求,比如你是需要写到文件还是只是终端。

其他网友的推荐:

  • File Logging:emabee/flexi_logger: A flexible logger for rust programs that can write to stderr or to log files。(来自 cfsamson)
  • tracing 的接口:tracing_log - Rust,有多个同时操作交错日志消息时特别方便,可以按某些属性对它们进行分组并单独查看它们。(来自 class_two_perversion)
  • estk/log4rs: A highly configurable logging framework for Rust,log4rs 是一个高度可配置的日志框架,以 Java 的 Logback 和 log4j 库为模型。通过 Yaml 配置,到 sdout 和文件,带有文件大小限制选项,还可以配置不同级别的日志。(来自 tms102)
  • tracing-appender - crates.io: Rust Package Registry,推荐者所知道的唯一线程外日志记录解决方案,不仅适用于异步应用程序。(来自 Pand9)
  • daboross/fern: Simple, efficient logging for Rust,像 Python 的 logging 和 JS 的 Winston。(来自 RapBeautician)

Rust 全栈

本文是一篇博客翻译,来自:Full Stack Rust - Blog

一年前,我的首选语言如下:

  • Python 用于高级代码快速原型设计,或用于需要第三方功能的代码
  • C/C++ 用于长期的 low-level 项目

当时只听过 Rust 并简单使用过,我的经验来自用 Rust 写了一个处理大文件(>4GB)的事务并从中挖掘一些统计信息的小工具。我用了一个库将文件映射到内存,缤瑞按照顺序对其进行分析。有一些很酷的概念,比如编译器静态地强制内存映射在它被取消映射后无法访问——如果你不小心,C++ 中可能就会发生这种错误。

不过当时并没有真正吸引我,因为那只是一个小新奇。当我向 pdblister 添加新功能以并行获取数千个 PDB 文件时诀窍来了。由于 GIL,在 CPython 中几乎不可能,而在 C/C++ 中做到不面临并行错误是极其困难的。然而 Rust 让这变得容易。我添加了 tokio 驱动的异步,使用 tokio::spawn 生成新任务来下载 PDB,并修复了编译器报的错误,它可以正常工作了。Rust 编译器输出一个二进制文件,它可以在任何地方运行,没有运行时依赖。

取代 Python

这是第一点,Rust 是 Python 作为中长期工具语言的绝佳替代品。Python 的好处是庞大的库和生态系统,通过 pip 可以直接拿到,想要快速制作与 API 交互的原型,可以使用 requests,只要 import requests 就可以使用了。Rust 的 reqwest 也是如此,只要输入 cargo add reqwest 就可以在代码中使用它。

然而当进入更长期的生命周期时,Python 就显示出劣势,requests 是程序的依赖,用户需要后去后才能使用。此外,由于弱类型和错误处理能力(与 Rust 比),Python 变得更加劣势。这一点上,我可以使用 Rust 比使用 Python 更快地编写原型工具,并且我可以自信地知道我的工具比等效的 Python 更易于维护且寿命更长。但是,对于短期工具,Python 可能仍然更好,因为它不需要启动项目即可在 VSCode 中获得智能感知支持。 Rust 的 cargo-script 接近将 Rust 推入脚本语言的领域,但不幸的是,我还没有在 VSCode 中找到与之集成的插件。

取代 C

Rust 也是 C 的直接替代品,它在各方面都更好,并且可以与遗留 C 代码原生互操作以进行增量替换。Rust 最大的改进是生态系统:如上所述,利用 Rust 生态中已有的库是很容易的。如果你从未使用过 C,那很幸运,实际上 C 中使用高级功能的最佳方法是自己写。

C 生态系统是支离破碎的,而且很脆弱。ABI 或构建系统没有一致的标准:

  • 由于缺乏 ABI 一致性,你不能跨平台或操作系统使用相同的二进制文件。 所以你必须从源代码构建。
  • 由于缺乏一致的构建系统,你不能简单地和应用程序一起构建 C 库,必须修补或重写要使其与你的库兼容的库的构建系统。
  • C 库很少跨平台兼容,因为它们缺乏可以依赖的共享抽象。

然后还有 Rust 最特色的安全改进——我就不展开了。但根据我的经验 - 安全性在很大程度上是一种工具,可以让第三方库开发人员更容易强迫我正确使用他们的库,这是 C 库不能做的事情。

全栈 Rust

总而言之,在过去的一年中,我一直在堆栈的所有部分使用 Rust,而我之前使用过其他语言。我已经使用 Rust 来实现引导加载程序:xenia-project/xell-rs: Xell Bootloader, rewritten in Rust because ¯_(ツ)_/¯,我已经使用它通过 pdblister 和 panamax 中的高级 HTTP/HTTPS 和其他技术来镜像文件。我利用并贡献了优秀的 gdbstub 库,用于控制由自定义 VMM 运行的 VM。这些项目都是在堆栈的不同级别完成的,而 Rust 非常适合所有级别。 我已经开始在我的个人项目中专门使用 Rust,并在适合的时候推动它在我的工作中使用。

tagged_cell:快速、可初始化和线程安全的静态变量

通过 TaggedCellTag 类型实现,为了安全操作,TaggedCell 的每个实例都必须是唯一的。然后必须通过 TaggedCell::init() 初始化 TaggedCell,它使用用户提供的函数或闭包初始化底层数据,然后返回一个特殊的零大小的 Init<Tag> 用于访问 Cell 的数据。为了确保每个单元格使用唯一的标签类型,tagged_cell! 提供宏。该宏根据变量的名称创建一个新的标记类型,并将其应用到声明中。


#![allow(unused)]
fn main() {
use tagged_cell::tagged_cell;
tagged_cell!{
   static BAR: TaggedCell<Vec<usize>, _> = TaggedCell::new();
}

let tag = BAR.init(|| vec![0, 10, 20]);
let vec = BAR.get(tag);

assert_eq!(vec[2], 20);
}

为了允许跨线程使用,只有第一次调用TaggedCell::init才会初始化 Cell 的数据。所有未来的 TaggedCell::init 调用都将返回一个新标签。未确定哪个线程将初始化 Cell 的数据。


#![allow(unused)]
fn main() {
use std::thread;
use tagged_cell::tagged_cell;

tagged_cell!{
    static TABLE: TaggedCell<Vec<usize>, _> = TaggedCell::new();
}

thread::spawn(move || {
    let tag = TABLE.init(|| vec![0, 10, 20]);
    let table = TABLE.get(tag);
    assert_eq!(table[2], 20);
});

thread::spawn(move || {
    let tag = TABLE.init(|| vec![0, 10, 20]);
    let table = TABLE.get(tag);
    assert_eq!(table[1], 10);
});
}

https://github.com/Dasch0/tagged_cell

Learn Wgpu 更新了

wgrpu 是 WebGPU API spec 的 Rust 实现, 目前这个教程已经更新到了 0.10 版本, 有大量的原理和代码示例讲解.

https://sotrh.github.io/learn-wgpu/beginner/tutorial2-surface/

使用 Rust 写 shellcode 代码

漏洞利用中必不可缺的部分就是shellcode,shellcode是用来发送到服务器利用特定漏洞的代码,它能在极小的空间内完成一些基本而重要的工作。

Shellcode编写方式基本有3种:

  1. 直接编写十六进制操作码(不现实);
  2. 采用像C这样的高级语言编写程序,编译后,进行反汇编以获取汇编指令和十六进制操作码。
  3. 编译汇编程序,将该程序汇编,然后从二进制中提取十六进制操作码。

《Black Hat Rust》 一书作者的文章介绍了如何使用 Rust 编写 shellcode 。

https://kerkour.com/blog/shellcode-in-rust/

一个使用 axum 框架开发的二维码生成服务

axum 的一个实战项目示例,是一个真正在跑的服务:

https://github.com/sayanarijit/qrcode.show

Rust CI/CD: github action 使用

和任何其他语言一样, 在我们掌握语法之外, 我们往往还有 CI/CD 的需求:

  • 需要哪些组件来组成CI管道,以确保我的代码是健康的?
  • 如何部署?
  • 我需要编写自定义工具还是有社区资源可用?

作者会用三篇文章来讲解 Rust在 github 中如何使用 action 来完成 CI/CD.

https://www.homeops.dev/continuous-integration-with-github-actions-and-rust/

使用 100 行 Rust 代码构建一个静态站点生成器

原理很简单,通过解析 markdown 文件,填充一个 html 模板。完成。

https://kerkour.com/blog/rust-static-site-generator/

使用 Rust 和 Godot 实现 RPG 游戏

油管上有个系列视频 Godot Action RPG Series,学习在Godot引擎3.2中制作一个动作RPG。

Godot是一个出色的免费和开源的游戏引擎,是为独立游戏设计的。

但是油管那个视频教程不是 Rust 的,于是有人用 Rust 实现了一遍,公开了出来:

https://github.com/Nejat/godot-action-rpg-tutorial-rs

使用Rust和tokio构建可扩展服务

使用Tokio,很容易实现网络服务器应用。然而,你经常在指南书或此类书籍中发现的典型代码在多个CPU上的扩展性很差。我将解释为什么,以及如何通过改变几行来解决这个问题。

https://medium.com/@fujita.tomonori/scalable-server-design-in-rust-with-tokio-4c81a5f350a3

Rust 生命周期和智能指针

作者: 王江桐(华为)

本文是对 第407篇推文,以及第408篇推文推荐博文中有关于 Rus生命周期与智能指针内容的扩展和梳理。


生命周期

变量的有效范围被称为变量的生命周期。根据Rust the Book,生命周期的主要作用是避免指针失效/悬垂引用,因此,如果变量的引用超过了变量的生命周期,编译器会发现这个错误,并且拒绝编译文件。而当编译器无法确定引用是否有效时,编译器也会拒绝编译文件,此时编程者可以手动标注生命周期,承诺引用不会失效。例如,当函数返回变量引用时,如果编译器无法确定返回的引用生命周期应该和哪个输入参数保持一致,编译器就会报错,要求注明生命周期。

结构体中,如果成员是对某个类型的引用,则必须标注生命周期,表明结构体实例存在时间会短于类型实例存在时间。换句话说,当结构体的实例有效时,对结构体外的类型的引用一定有效。

长生命周期可以转换为短生命周期(如static项作为参数传递给函数,函数的返回值不再拥有'static生命周期),反之不可以。

生命周期的前缀是撇号(')。示例语法如下:


#![allow(unused)]
fn main() {
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
}

大部分情况下生命周期可以省略。如果省略则遵从以下三条规则:

  1. 每一个引用的参数对应的生命周期不同;
  2. 如果只有一个输入参数,那么它的生命周期会赋予所有的输出,也就是说,所有输出的值的生命周期都与输入参数相同;
  3. 如果输入参数中包括self的引用,那么所有输出值的生命周期都与self——也就是输入的实例——相同。

如果不符合以上规则,那么必须手动注明生命周期。如例子中,如果不注明只有一个生命周期,那么x和y会分别有一个生命周期,一共两个生命周期。由于不能确定函数返回哪个值的引用,编译器无法确定返回值的生命周期,因此会报错。

与生命周期相关的RFC如下:

‘static

'static是一种特殊的生命周期,定义该变量会存活于整个程序运行期间,变量会存储在二进制文件的内存中。所有的str的生命周期都是'static。

使得变量拥有'static生命周期的方式有两种:

  1. 声明static或constant项:e.g. static NUM: i32 = 18;
  2. 声明str类型:e.g. let static_string = "I'm in read-only memory"。在这个情况下,当static_string这个变量名退出作用域,对其引用将不再有效,但是字符串数据本身仍然存储在二进制文件中。

当'static作为trait bound出现的时候,'static并不要求引用的变量一定拥有static生命周期,只要求这个类型不包含任何非static引用。例如:

fn print_it( input: impl Debug + 'static ) {
    println!( "'static value passed in is: {:?}", input );
}

fn main() {
    // i is owned and contains no references, thus it's 'static:
    let i = 5;
    print_it(i);

    // oops, &i only has the lifetime defined by the scope of
    // main(), so it's not 'static:
    print_it(&i);
}

static项与常量(constant)

static项与常量(constant)不同,尽管两者的生命周期都是'static(除非特殊说明修改生命周期)。static项使用关键字static声明,代表程序中指定的内存位置,所有对于此项的引用都会指向内存中的同一位置。如果将安全相关的信息保存为static项,那么攻击者有可能通过读取内存等方式获取这些内容。如果static项不可改,那么它们可能会被存储在只读内存中;如果static项定义为可改(mut),改动static的代码块必须放在unsafe中,以便定位数据竞争等问题。当程序结束时,static项不会调用drop。

由于以上性质,所有使用static的方法基本都是安全的,但是它们含有以下限制:

  • 如果要在多线程中使用,定义为static不可改的类型必须实现Sync trait,static mut则不用;
  • 当一个static需要获取另一个static值的时候,只能通过对其引用来获取;
  • 常量(constant)不可以引用static。

常量(constant)则不同,它不与某个内存地址关联,当使用时,值会被直接复制进上下文。因此,对同一常量的引用并不保证指向同一内存地址。常量的类型必须拥有'static生命周期。constant可以有析构函数(Destructor),析构函数会在值离开作用域时被调用。

通常情况下更建议使用const,除非以下三种情况:

  1. 大量的数据需要存储;
  2. 需要static的固定地址或non-inlining性质时;
  3. 需要可变性。

如果static或const项值为参数类型为引用的函数或闭包,编译器会首先尝试使用省略规则推断生命周期,如果失败,则认为生命周期为'static。

智能指针

有时过多的生命周期标注会使代码可读性下降。从某个角度而言,对于长期存在的变量引用,可以使用智能指针。当然,智能指针不只可以加强代码可读性,可以解决很多其它的问题。

智能指针是一类数据结构,表现类似于指针,但是拥有指针之外的数据和功能。智能指针通常使用结构体实现,并实现Deref和Drop trait。Deref使智能指针表现类似于指针,Drop可以在指针退出作用域时清理堆上或做其它必要清理。

Vector和String都是智能指针。

Box

Box是Rust的标准库,在std::preclude中。Box将值放在堆上并返回一个指向堆上数据的指针。可以用解引用符号“*”来转移Box内值的所有权,在转移之后,如果值没有实现copy trait,则不可以再使用指针本身或者是Box里的值。使用场景如下:

  • 编译时大小未知的类型,如递归类型。由于递归编译器无法确定类型会占有多少空间,但是如果是指针的话,指针的大小是确定的;
  • 有大量数据并且希望数据不被拷贝的情况下转移所有权。由于所有权转移,数据在栈上时可能会被拷贝/移动,但是在堆上时只需拷贝/移动指针;
  • 只关心值是否实现某一个trait而不是具体类型,在这个情况下使用dyn关键词 e.g. Box<dyn Trait>。

Box的常用方法/实现trait如下:

  • new / try_new:后者返回一个result,当无法分配内存的时候报错
  • pin:返回Pin<Rc<T>>,保证Box内值不会移动
  • from_raw:根据指定指针获取值,消耗该指针,放入Box并返回Box。这个方法是unsafe,因为这个方法可能在同一个原始指针上重复调用,并在退出作用域时多次调用析构函数(double free)
  • into_raw:消耗Box,返回*mut T,非unsafe
  • leak:消耗Box,返回对其值引用。如果引用退出作用域,由于Box已经被消耗,drop不会被调用,有可能造成内存泄漏,可以使用from_raw生成一个新的Box。一般在值的生命周期等于程序剩余生命周期时考虑使用。
  • borrow / borrow_mut:返回对其值引用,不消耗Box
  • clone / clone_from:复制Box中的值,返回新的Box,是deep copy
  • 在nightly版本中有一些关于不同初始化模式以及设置Allocator的方法,在此不展开

Rc

Rc(Reference Counting)是Rust的标准库,但是不在std::preclude中,需要使用use引入作用域。Rc可以启用多所有权,通过引用计数来知晓这个值是否仍在被使用,并且由此决定是否在当前作用域结束的时候,调用drop。由于多所有权,Rc只允许对值的不可变引用。Rc<T>将值分配在堆上,以便程序不同部分读取。Rc::clone会增加引用计数,而当引用计数为0时,在当前值退出作用域时清理数据。

有时候这个问题可以用存储引用解决,但是这会引入生命周期标注,降低可读性并增加新的或许不必要的生命周期限制。对于递归结构而言,问题不可以用存储引用解决,因为对于临时空值的引用是无效的。顺带一提,Rust中没有C/C++/Java/Python概念中的null/None。表示空值根据情况不同,或可以使用Option/Result代替。

与RefCell相同,Rc<T>只适用于单线程,Rc<T>的引用计数加减不是原子操作,在多线程中无法保证它的行为是符合逻辑预期的。原子操作指不会被线程调度机制打断的操作,这个操作一旦开始,会一直运行到结束,中间不会有上下文切换到另一个线程。

Rc的常用方法/实现trait如下:

  • new:后者返回一个result,当无法分配内存的时候报错
  • pin:返回Pin<Rc<T>>保证值不会移动
  • try_unwrap:返回一个Result。如果Rc有正好一个强引用,则返回内部值,否则返回Err以及原来的Rc
  • into_raw / as_ptr:返回*const T,前者消耗Rc,后者不消耗。
  • from_raw
  • downgrade:返回Weak<T>
  • weak_count / strong_count:返回count次数
  • ptr_eq:比较两个Rc是否指向同一个位置
  • increment_strong_count / decrement_strong_count:unsafe,修改强引用次数
  • get_mut:返回Option,当没有其他Rc或是Weak指针时,返回对值的可变引用,反之则返回None
  • make_mut:返回对值的可变引用,当不可行时,如有其他Rc指向同一个位置,复制值保证单一所有权,并返回对新值的可变引用。如果含有Weak指针,Weak指针会指向复制前的位置
  • borrow
  • clone
  • drop:drop会减少强引用计数。drop之后,强引用计数 -= 1。如果强引用次数为0,内部值会被清理。
  • 在nightly版本中有一些关于不同初始化模式以及unsafe获取可变引用的方法,在此不展开

Weak

Weak指针是Rc的一个小变种,包含对值的引用,但是Weak不像Rc,Weak不拥有该值,同时Weak引用和所有权无关。

Weak可以防止递归结构使用Rc导致的引用死循环。例如,父指向子,子指向父,如果引用是强引用(使用Rc),那么Rc中的强引用计数将永远不会是0;弱引用则可以解决这个问题。通常Weak指针通过调用Rc::downgrade获取。

Weak的常用方法/实现trait如下:

  • upgrade:返回Option<Rc<T>>。由于Weak特性,Weak不保证值仍然存在,upgrade后如果值不存在,Option值为None。
  • strong_count / weak_count:返回Weak指向值的引用计数
  • ptr_eq
  • clone:复制该弱引用计数
  • !Send
  • !Sync

其他的一些Weak方法:

  • new:返回一个关于指定类型的Weak指针,但是并不分配内存。此时使用upgrade会返回None。
  • as_ptr / into_raw:返回*const T。没有强引用保证,指针可能无效。
  • from_raw

Arc

Arc(Atomically Reference Counted)是类似于Rc的指针,但是它可以安全应用于并发环境的类型。Arc的操作是原子性的,意味着它不会被多线程的上下文切换打断,从而保证它的行为符合逻辑预期,但是原子操作的开销高于普通访问内存。Arc和Rc有相同的API,对其引用是只读的。Arc位于std::sync::Arc,同样,在同层下有std::sync::Weak,与Rc的Weak功能相同。

Arc实现了Send和Sync。

Cell

Cell是一种提供内部可变性的容器,可以在不可变结构中改变Cell中的数据,位于std::cell::Cell。适合实现了Copy的类型,或者体积小的struct。Cell没有运行时开销,运行时安全。Cell在多线程不安全。

Cell的常用方法/实现trait如下:

  • new
  • set:修改内部值
  • swap:交换两个Cell的内部值。不同于std::mem::swap,std::mem::swap需要可变引用
  • replace:用新值代替旧值并返回旧值
  • into_inner / get / take:返回内部值,into_inner消耗Cell,get复制值并返回,不消耗Cell,take返回值,使Cell内部值为空
  • as_ptr:返回裸指针
  • get_mut / from_mut:&mut T和&Cell<T>的互相转换

RefCell

当无法确定内部值是否实现了Copy trait时可以使用RefCell。运行时有开销,会执行借用检查,运行时不安全。RefCell在多线程不安全,因为它类似于Rc,会记录作用域内有多少个活动的引用。

RefCell的常用方法/实现trait如下:

  • new / take
  • borrow / borrow_mut / try_borrow / try_borrow_mut / try_borrow_unguarded:创建不可变和可变引用;try是不panic版本,返回result,unguarded成功返回对内部值的直接不可变引用
  • into_inner
  • replace / replace_with
  • swap:与Cell::swap不同,但是与std::mem::swap类似,如果参数中某一个RefCell已经被可变调用则panic
  • as_ptr
  • get_mut:通常不使用此方法而使用borrow_mut,因为此方法期望RefCell是可变的。返回对内部值的可变引用,并且不会在运行时检查

atomic_refcell

atomic_refcell是一个三方库,提供了RefCell的线程安全版本,其API与RefCell的API基本一致。当用户确定在多线程中不会进行可变借用时,使用RwLock会造成不必要的性能损耗。atomic_refcell复制了RefCell的源码并修改了线程不安全的部分,并不涉及到线程锁,从而提升运行效率。atomic_refcell的核心机制在于,它使用一个高位bit来统计当前值是否已被可变引用,高位bit的比较和翻转的操作都是原子操作,保证其不会被多线程上下文切换打断。当某个线程执行非法借用(例如在已有可变借用时执行不可变借用)时,atomic_refcell根据情况恢复或保留高位bit,并保证当前线程panic不会影响其他线程。

qcell

第408篇推文中,本周crate是qcell。qcell是一个三方库,提供了可以在编译时检查的4种功能形似RefCell的struct。这4个struct没有机制差异,唯一的差异是所有权的表示方式:Qcell使用数字ID,TCell和TLCell使用标记类型,LCell使用Rust生命周期。

qcell的核心机制是,除了类似于RefCell存储值以外,qcell引入了一个存储在栈上的owner,并且只有通过owner才可以借用到qcell中存储的值。由此,当其他引用试图通过owner借用值时,编译器可以像检查出其他引用问题一样查出qcell的引用问题,而不是像RefCell使用计数来统计。

qcell的缺陷在于静态检查的限制有时过于严苛,导致有些可行的代码会无法通过编译。如果不同的cell被被同一个owner所限制,qcell的规则会使这些cell的借用将无法同时发生,然而refcell将会允许这一正确的行为。同时,qcell同时只能允许最多3个对象的借用。

此外,qcell也提供了线程安全版本的refcell。

CellOwner typeCell type
RefCelln/aSend
QCellSend + SyncSend + Sync
TCellSend + SyncSend + Sync
TLCellSend
LCellSend + SyncSend + Sync

Mutex

RwLock与Mutex都是智能指针,功能上都可用于多线程保护数据。

Mutex(互斥锁)会等待获取锁令牌,同时只有一个线程的Mutex对象可以获取到锁令牌(token),而其他线程会被阻塞,直到锁被释放。在多线程中,经常和Arc一起使用来使多个线程共用一个互斥锁。如果某个已获取但是未释放令牌的线程崩溃,那么其他线程将无法获取令牌,因此造成死锁问题。Rust将这种情况下的Mutex称为poisoned Mutex。如果试图对这类Mutex获取锁,lock方法会阻塞线程,但是try_lock不会阻塞线程,并会返回PoisonError。通过PoisonError的into_inner方法,用户仍然可以获取保存着内部值的MutexGuard。由此,无效的锁并不能完全保证数据的完整性,如果需要通过PoisonError获取锁的内部值,需要注意数据是否已被修改。

Mutex的使用一般只在两个场景:与Arc一起,在线程之间共享数据,提供多线程的变量读写功能;MaybeUninit<Mutex<T>> 用来做全局变量,Mutex本身并不能保证不变,由MaybeUninit保证其不变性。在单线程中使用Mutex容易造成死锁,并且并不是最优解,一般使用refcell或cell替代。

Mutex的常用方法/实现trait如下:

  • new
  • lock / try_lock:获取令牌并上锁。前者会阻塞线程,而后者不会。在线程崩溃造成的死锁情况下,如果锁仍然可以由别的方式获取(如PoisonError::into_inner),try_lock将会返回PoisonError,如果由于Mutex已上锁而无法再获取,则返回WouldBlock错误。在Rust中,unlock操作在drop方法中完成,drop方法属于MutexGuard对象,也就是lock返回的LockResult.unwrap()获取的结果。
  • is_poisoned:返回当前锁是否被污染,其他获取锁未释放的线程是否崩溃
  • into_inner需要:消耗锁并返回LockResult<T>
  • get_mut:对锁的可变引用,并返回LockResult<&mut T>,由于编译器可以检测可变引用是否合理,实际上不使用上锁机制

RwLock

RwLock(读写锁)则允许多个读、最多一个写,并且读和写不能同时存在。与Mutex不同,读写锁允许多个读而非仅单一用户获取锁,而互斥锁不区分获取锁的种类。除此以外,RwLock只在内部值<T>实现Sync trait时,自己也实现Sync trait,以此来保证多个线程可以同时拿到对T的不可变引用,如果T是!Sync,那么RwLock也是!Sync;Mutex对此则不作要求。

类似于Mutex,RwLock也可能被污染(poisoned),不过这个只发生在写模式线程崩溃时。reader崩溃不会影响到锁。

RwLock可以解决多个并发线程试图同时访问同一共享资源的情况,不过锁定策略将会取决于操作系统的实现,如在Windows和Mac中,reader和writer公平排队,但是在Linux中,reader的优先级会高于writer。

RwLock的常用方法/实现trait如下:

  • new
  • read / try_read:获取读锁,当有writer时线程被阻塞,方法本身不保证reader和writer的优先级
  • write / try_write
  • is_poisoned:返回当前锁是否被污染,其他获取写锁未释放的线程是否崩溃
  • into_inner需要:消耗锁并返回LockResult<T>
  • get_mut:对锁的可变引用,并返回LockResult<&mut T>,由于编译器可以检测可变引用是否合理,实际上不使用上锁机制

引用

Rust the Book,https://kaisery.github.io/trpl-zh-cn/ch10-03-lifetime-syntax.html

Rust by example,https://doc.rust-lang.org/rust-by-example/scope/lifetime/static_lifetime.html

Smart pointers: The secret to write clean Rust code,https://kerkour.com/blog/rust-avoid-lifetimes/

The Rust Reference,http://web.mit.edu/rust-lang_v1.25/arch/amd64_ubuntu1404/share/doc/rust/html/reference/introduction.html

Rust中的Mutex,https://zhuanlan.zhihu.com/p/357506863

【Rust每周一知】Rust中的读写锁RwLock,https://blog.csdn.net/u012067469/article/details/105283463

如何理解 rust 中的 Sync、Send?,https://cloud.tencent.com/developer/article/1459819

【译·四万言】为 嵌入式 C 程序员编写的 Rust 指南

译者:张汉东

原文:https://docs.opentitan.org/doc/ug/rust_for_c/

作者: Google OpenTitan 团队


引子

在今年(2021)RustConf 大会上,Miguel Young 分享了《移动构造函数,Rust 中可能吗?》的演讲。

在观看这个演讲视频的时候,本人深挖了一下作者的信息,发现了这篇文章。

Miguel Young 是来自 Google OpenTitan 项目的开发者。OpenTitan 项目,想通过开源框架减少芯片被破解的可能。

OpenTitan 将由非营利组织lowRISC监督, 该公司正在开发基于RISC-V架构的免费微处理器。

OpenTitan项目涵盖了各种逻辑组件的开发 RoT芯片的需求,包括基于RISC-V架构的lowRISC Ibex开放式微处理器,加密协处理器,硬件随机数生成器,恒定和随机存取存储器数据和密钥存储层次结构,机制保护,I / O输入块,安全启动媒体等

可以在必要时使用OpenTitan,以确保系统的硬件和软件组件的完整性,并确保未更换关键的系统组件,并基于制造商验证和授权的代码。

基于OpenTitan的芯片可用于 服务器主板,网卡,消费类设备,路由器,物联网设备,以验证固件(检测恶意软件对固件的修改),提供加密的唯一系统标识符(硬件防伪保护)以及保护加密密钥(如果出现以下情况,则隔离密钥):攻击者可以获得对计算机的物理访问权限),提供与安全相关的服务,并维护无法编辑或删除的隔离审核跟踪。

本人又深挖了一下 OpenTitan 项目,在 GitHub 的语言成分分析中看不到 Rust 的痕迹。但是在源码中搜索 Rust,则发现很多 Rust 痕迹。

一、其中,OpenTitan 的 Software 部分支持 Rust 实现。

  • 设备软件的固件镜像,支持 Rust 实现。
  • Host 软件必须用 Rust 实现 (也支持 Cpp)。

二、 ROM_EXT 由 Rust 实现

OpenTitan 安全启动过程中,为了增加一定程度的灵活性,特别是为了允许制造商的特定配置和提供安全更新的设施--OpenTitan设计了扩展ROM(ROM_EXT),常驻闪存中。

ROM_EXT由一个 manifest 和 image 本身组成。当 image 生成时,manifest 是 "空白 "的。ROM_EXT签名者的责任是更新manifest ,签名image,并将签名加入其中。

源码:https://github.com/lowRISC/opentitan/tree/master/sw/host/rom_ext_image_tools/signer ,它是一个 Host 软件。

在 Readme 里介绍了他们为什么选择 Rust : https://github.com/lowRISC/opentitan/blob/master/sw/host/rom_ext_image_tools/signer/README.md

该项目中其他比较有用的资源:

  1. RISC-V Assembly Style Guide : https://docs.opentitan.org/doc/rm/asm_coding_style/
  2. FPGA Reference Manual:https://docs.opentitan.org/doc/rm/ref_manual_fpga/
  3. Rust for Embedded C Programmers https://docs.opentitan.org/doc/ug/rust_for_c/

接下来是 Rust for Embedded C Programmers 的翻译正文。

正文

前言

本文档旨在作为Rust的介绍,针对的是对嵌入式系统C语言有深入接触的工程师,以及几乎没有C++经验和不了解Rust的工程师。本文档将包含以下内容:

  • 提供嵌入式 C 语言工具库中和 Rust 相似的内容
  • 讨论这些相似内容和 C 语言的区别
  • 指出 Rust 内存和执行模型 与 C 语言的实质性差异
  • 介绍 Rust 特有的功能,这些功能要么是使用 Rust 的关键,要么是非常有用(引用、生命周期、泛型和特质)。

虽然本文并不是很严谨,但在脚注中也包含了语言律法相关的说明,只不过这些说明不影响理解本文。

学习 Rust 的一个很好的方法是使用编译器,看看能编译什么。 Matt Godbolt 的 Compiler Explorer 对于了解 Rust 产生的汇编很有用。 Rust Playground 也可以用来观察 Rust 代码执行时的情况,不过它的局限性更大。

本文档是针对嵌入式开发而写。这里不会讨论任何非嵌入式的 Rust 特性:见 https://rust-embedded.github.io/book/intro/no-std.html

Cpp 用户请注意。 Rust 和 Cpp 共享很多术语与概念(所有权、生命周期、析构器、多态性),但 Rust 对它们的实现往往具有明显不同的语义。在 Cpp 中的经验不应该被期望能准确地迁移。

什么是 Rust ?

Rust 是一门通用的编程语言,其重点是最大限度地提高程序员的控制能力和零运行时开销。同时消除了传统系统语言中容易“让人中招”的部分。Rust 有时也被称为“系统语言”。

在语法和设计哲学角度, Rust 很像 Cpp 和 ML(一种函数式语言)的结合体,尽管在语义上它与这两种语言有很大的不同。Rust 是第一种流行的,得到良好支持的语言,它提供了绝对的内存安全,而无需使用自动引用计数或垃圾回收器(GC)。Rust 也提供一种在源码中提供注解(生命周期参数)的技术,来避免访问失效的内存,它被称为借用检查器,不是在运行时检查。

译注:

这里说 绝对的内存安全,有点过了。

作者在脚注里虽然有说明:在Rust中,如果不使用关键字unsafe,使用后释放(use-after-frees)、双重释放(double frees)、取消引用(null dereferences)和数据竞争(data race)都是不可能的;这也适用于其他大多数传统上被认为是C语言中未定义行为的东西。︎

但是,没有绝对的安全,更没有绝对的内存安全。Rust 只是消除了比较常见的内存安全问题。

Rust 可以编译为本地代码,在内存和计算性方面可以和 C/Cpp 相媲美,并且可以与任何使用 C 调用惯例的东西无缝集成。它还静态地消除了一大类与安全漏洞相关的内存错误。Rust 的工具链是建立在 LLVM 之上的,所以,所有针对 LLVM 性能的工作都会有利于 Rust。

Rust 还包含了一种特殊的方言,叫做 “Unsafe Rust”。 在极少数需要进行底层操作的时候,它无法提供静态检查。本文档将围绕 Unsafe Rust 展开。

Rust 工具链

一个完整的 Rust 工具链由几个主要部分组成:

  • rustc, Rust 编译器。
  • rustup,Rust 工具链的安装程序。
  • Cargo, Rust 的构建系统(基于 rustc)。
  • std 和 core 。

Rust工具链的发布周期为六周,类似于Chrome的发布周期:每六周,一个发布(release)分支被切割为下一个测试版(beta),六周后成为下一个稳定版(Stable)。Rust的夜间版(Nightly)每天都从主干版(master)上切割下来;正是在夜间版上,非稳定(unstable)的功能才可以被启用。一些非稳定的特性(features)对嵌入式非常有用,所以嵌入式Rust项目使用夜间编译器的情况并不少见。

rustup用于管理Rust的安装。这主要是由于Rust的发布过于频繁,操作系统的包管理器无法跟上,而且项目可以锁定特定版本的Rust。当Rust工具链通过rustup安装时,rustc和cargo等组件会感知到它;rustc +nightly-2020-03-22能通过rustup来下载和执行3月22日构建的rustc nightly。项目目录中一个名为rust-toolchain的文件也可以达到同样的效果。

Cargo是一个Rust的构建系统/包管理器。它可以自动构建项目(即有Cargo.toml文件的目录)和它们的依赖项。Rust中的单个编译单元被称为 "crates",它们要么是静态库(即与.a文件相当),要么是完全链接的本地二进制文件。这与C语言不同,在C语言中,每个.c文件都会生成一个单独的对象文件。 Rust也没有头文件,尽管它提供了一个模块系统来组织内部的代码,这将在后面讨论。Tock板是一个很好的例子,说明一个更复杂的货物文件是什么样子的:https://github.com/tock/tock/blob/master/boards/opentitan/Cargo.toml

一些有用的Cargo子命令包括:

  • cargo check 运行rustc的检查,但在它开始输出代码和优化之前就停止了。这对于开发过程中的错误检查很有用。
  • cargo build 构建一个库或二进制文件,取决于crate类型。
  • cargo clippy 运行 Rust linter, Clippy。
  • cargo doc --open 构建 crate 文档,然后在浏览器中打开它。
  • cargo fmt 运行 Rust formatter 。

此外,RUSTFLAGS环境变量的内容也会传递给rustc,作为注入标志的机制。

Rust标准库,像libc一样,在嵌入式环境中比较罕见。标准库由三个板块组成:coreallocstdcore,有时被称为libcore,是所有的基本定义,不依赖于操作系统的支持。core中的任何东西都不能进行堆分配。alloc不需要操作系统的支持,但需要malloc和free符号。std是core+alloc,以及操作系统API,如文件和线程支持。#[no_std] 禁用了stdalloc,留下了core。在本文档中,我们将只使用core类型,尽管我们可以通过std命名空间来引用它们(它们是别名)。也就是说,我们可以引用std::mem::drop,尽管在#[no_std]代码中它必须被命名为core::mem::drop

rustc有许多 flag。其中最突出的是:

  • --emit asm--emit llvm-ir,对检查编译器输出很有用。 --target,设置交叉编译的目标。它的作用类似于Clang的-target-march-mabi标志。它接受一个定义了平台的目标定义(在很多情况下类似于LLVM的目标三要素)。例如,OpenTitan 软件使用riscv32imc-unknown-none-elf目标。使用一个不是宿主目标的目标(例如,x86_64-unknown-linux-musl)需要用rustup component install rust-std-<target>来安装相应的标准库构建。参见 rustc --print targets--target也被Cargo直接接受,这与大多数rustc的标志不同。
  • -C link-arg,等同于Clang的-T
  • -C opt-level,相当于Clang的-O(我们主要使用-C opt-level=z来嵌入)。
  • -C lto,相当于Clang的-flto
  • -C force-frame-pointers,相当于Clang的-fno-omit-frame-pointer
  • -D warnings大致等同于-Werror

其他有趣的标志可以在rustc -C帮助下找到,在夜间,可以在rustc -Z帮助下找到。

Part I: 用 Rust 重写 C 程序

在 深入研究 Rust 的具体特性前,我们将首先探讨 C 语言的概念如何映射到 Rust 中,以及 Unsafe Rust。 Unsafe Rust 是Rust 的方言,虽然没有 Safe Rust 的太多限制,但也是有一定安全保证。

类型(Type)

Rust 和 C 对类型的处理方法大致相同,尽管 Rust 很少有隐式转换。在这一节中,我们将讨论如何将 C 语言类型转换为 Rust 类型。

整数

Rust缺少C语言中的intlongunsigned和其他具有实现定义大小的类型。相反,Rust的原生整数类型是精确大小的类型:i8i16i32i64i128分别是8163264128位的有符号整数,而u8u16u32u64u128是其无符号变体。Rust还提供了isizeusize,它们对应于intptr_tuintptr_t11。对齐要求与C语言完全相同。

Rust支持所有整数类型的常规二进制运算,不过在做算术时不能混合不同的类型,而且与C不同的是,没有整型提升(integral promotion)。Rust中的溢出与C14不同:它是由实现定义的,并且必须使程序崩溃或回绕( wrap around)。Casting是通过as关键字完成的,其行为方式与C语言完全相同。(uint8_t) x被写成u8。整数类型之间从不进行隐式转换,甚至在有符号和无符号变体之间也是如此。

Rust有常见的整数字元:十进制为123,十六进制为0xdead,二进制为0b1010,八进制为0o777。下划线可以任意穿插在一个整数字头中,以分隔数字组。0xdead_beef,1_000_000。它们也可以用原生整数类型的名称作为后缀,以强制其类型。0u8, 0o777i16, 12_34_usize;否则它们将默认为任何类型推导(后面会详细介绍)所选择的类型,如果不受限制,则为i32

Rust也有一个专门的bool类型。它不能与整数隐式转换,否则就是一个u8,保证具有0x000x01的值,以及各自的字面意义falsetruebool支持所有的位操作,是唯一与短路的&&||兼容的类型。它也是唯一可以在ifwhile条件下使用的类型。

整数有一套广泛的内置位操作,以方法的形式暴露出来,如x.count_zeros()x.next_power_of_two()。例子见https://doc.rust-lang.org/std/primitive.u32.html

结构体和元组

结构体声明和C 相似:


#![allow(unused)]
fn main() {
struct MyStruct {
    pub foo: i32,
    pub bar: u8,
}
}

Rust有每个字段的可见性修改器pub;我们将在后面对可见性进行更彻底的处理。

结构值可以使用类似于C语言的指定初始化语法来创建。


#![allow(unused)]
fn main() {
MyStruct { foo: -42, bar: 0xf, }
}

不过,Rust的结构体并不像C结构体那样布局,事实上,Rust并没有指定其结构体的布局。在Rust中可以使用#[repr(C)]属性来指定一个C结构。


#![allow(unused)]
fn main() {
#[repr(C)]
struct MyCStruct {
    a: u8,
    b: u32,
    c: u8,
}
}

这保证了按声明顺序排列字段,为对齐添加填充。#[repr(Rust)] 是隐含的默认值。#[repr(packed)] 类似于__attribute__((packed)),并且不会产生任何padding。整个结构的对齐方式可以使用#[repr(align(N))]强制为一个较大的值,类似于_Alignas

可以使用与C语言相同的点语法来访问字段:my_struct.foo, my_struct.bar = 5;

Rust还提供了 "类元组结构",这是有编号而非命名字段的结构体。


#![allow(unused)]
fn main() {
struct MyTuple(pub u32, pub u8);
}

字段的访问采用类似的点状语法:tuple.0, tuple.1,并采用类似函数调用的语法构造:MyTuple(1, 2)。除了语法之外,它们与普通结构体没有区别。类元组结构上的字段可以省略,以声明一个零字节的结构。


#![allow(unused)]
fn main() {
struct MyEmpty
}

元组的匿名版本也是可用的: (u32, u8)。这些本质上是具有未命名字段的匿名结构。空元组类型,(),被称为 "单元",作为Rust的空类型(与空不同,()只有一个值,也叫(),是零大小)。Rust 还有一个类似于void的类型,即,我们将在讨论函数的时候讨论这个类型。

如果一个结构的每个字段都可以用==来比较,编译器就可以为你的结构生成一个等价函数。


#![allow(unused)]
fn main() {
#[derive(PartialEq, Eq)]
struct MyStruct {
    a: u32,
    b: u8,
}
}

这使得在 MyStruct 的值上使用==成为可能,它会比较字段是否相等。同样也可以对<>=这样的排序操作进行操作。#[derive(PartialOrd, Ord)]将定义比较函数,按字母顺序比较结构。

枚举体与联合体

和 C 语言一样,Rust 也有枚举,用于描述具有固定数值的类型。


#![allow(unused)]
fn main() {
enum MyEnum {
    Banana, Apple, Pineapple,
}
}

但与C不同的是,MyEnum 是一个实数类型,而不仅仅是一个整数类型的别名。同样与C不同的是,枚举的变体不会被转储到全局命名空间,而是必须通过枚举类型来访问。MyEnum::Banana。请注意,与结构不同,枚举的变体是默认 pub 的。

虽然Rust用整数来表示枚举值(这些整数被称为判别值),但是它们的排列方式没有被指定。为了得到一个像C语言那样分配判别符的枚举,我们可以使用一个repr属性。


#![allow(unused)]
fn main() {
#[repr(C)]
enum MyCEnum {
    Banana = 0,
    Apple = 5,
    Pineapple = 7,
}
}

但与C不同的是,Rust 只保证明确写下的判别值。这样的枚举可以安全地转换为整数类型(比如MyCEnum::Apple as u32),反之则不然:编译器总是假设MyCEnum 的底层值是057,违反这个约束是未定义行为(UB)。如果我们想要求一个枚举是一个精确的整数宽度,我们可以使用#[repr(T)],其中T是一个整数类型,如u16i8

Rust中的Union是一个相当新的特性,一般来说,在正常的代码中不会用到很多。它们的声明方式与结构很相似。


#![allow(unused)]
fn main() {
union MyUnion {
    pub foo: i32,
    pub bar: u8,
}
}

并像结构体一样创建。


#![allow(unused)]
fn main() {
MyUnion { bar: 0xa, }  // `bar` is the active variant.
}

对union变体的赋值与结构中的赋值相同,但读取变体需要使用 Unsafe 的Rust,因为编译器无法证明你没有读取未初始化的或无效的数据,所以你需要写上


#![allow(unused)]
fn main() {
unsafe { my_union.bar }  // I assert that bar is the active variant.
}

由于对析构器的关注,Union 对哪些类型可以作为变体也有限制。

由于 Union 在C语言中非常有用,但完全不安全,所以Rust提供了内置的标记(tagged) Union,可以通过枚举语法访问。


#![allow(unused)]
fn main() {
enum MyEnum {
    FooVariant { foo: i32 },
    BarVariant(u8),
}
}

tagged-union 的枚举变体使用与Rust 结构体相同的语法;枚举由一个足以区分所有变体的标签值(判别值)和一个编译器跟踪的变体联合组成。然而,有效使用这种枚举需要模式匹配,我们将在讨论模式时再次看到这些枚举。

就像对待结构体一样,#[derive]可以用在枚举上以定义比较运算符,其定义与结构体的情况类似。

数组

Rust数组等同C语言的数组:内联存储编译时已知数量的值。C中的T[N]在Rust中被拼成[T;N]。数组是用[a, b, c]语法创建的,一个有很多相同值的副本的数组可以用[0x55u8; 1024]创建。一个多维数组可以被声明为一个数组的数组。[[T;N];M]

数组元素可以用x[index]来访问,就像在C语言中一样。但是请注意,Rust会自动在每个数组访问周围插入边界检查;边界检查失败会引发程序的恐慌(panic)。Unsafe Rust可以用来欺骗边界检查,当它知道(对程序员来说,而不是Rustc!)没有必要执行边界检查,但当它是性能关键的时候,就可以省略它。

Rust数组是 "真正的 "类型,与C不同,它们可以通过值传递到函数中,并通过值从函数中返回。当传入函数时,它们也不会衰变为指针。

指针

像其他所有的嵌入式语言一样,Rust 也有指针。这些指针通常被称为原始指针,以区别于无数的智能指针类型。Rust将Tconst T 拼成mut Tconst T。与C不同的是,指针在被解除引用之前不需要与它们的指针类型对齐(与C一样,Rust假设所有指针的读/写都是对齐的)。

请注意,C的基于类型的严格别名在Rust中并不存在。正如我们稍后所了解的,Rust对引用有不同的别名规则,这些规则更加强大,编译器可以自动检查。

空指针可以使用std::ptr::null()std::ptr::null_mut()函数创建。Rust指针不支持算术运算符;相反,一个方法填补了这个角色:用 ptr.offset(4) 代替 ptr + 4。指针之间是否相等是简单的判断地址是否相等。

指针可以用*ptr语法进行解引用,尽管这是 Unsafe 的Rust,需要说出unsafe。当指针被解引用时,它们必须像C语言一样,良好地对齐并指向有效的内存;不这样做就是不安全。与C语言不同,操作符&x产生一个引用,而不是一个指针。

指针的解除引用仍然受移动语义的约束,就像在普通的Rust中一样。指针上的read()write()方法可以用来忽略这些规则。read_unaligned()write_unaligned() 可以用来执行安全的无对齐访问,而copy_to()copy_nonoverlapping_to()分别类似于memmove()memcpy()。关于其他有用的指针方法,见https://doc.rust-lang.org/std/primitive.pointer.html。挥发性操作也是使用指针方法进行的,这将在后面单独讨论。

由于所有这些操作都会解除对指针的定义,它们自然被限制在不安全的Rust中。

我们将在后面发现,除了原始指针之外,Rust还有许多其他的指针类型。一般来说,原始指针在 Rust 中只用于指向潜在的未初始化的内存,一般表示地址,而不是实际内存的引用。为此,我们使用引用,这将在后面讨论。

当我们遇到函数时,我们将触及到函数指针。

项(item)

和C语言一样,Rust也有全局变量和函数。这些,连同上面的类型定义,在语法中被称为(语法层面)项(Item),以避免与C的声明/定义的区别相混淆。与C不同的是,Rust没有前向声明或声明顺序语义;所有的东西对整个文件都是可见的。语法项是通过专门的导入语句导入的,而不是通过文本包含;关于这一点,后面会有更多的介绍。

常量和全局

Rust有专门的编译时常量语法,其作用与C语言中的#defined常量相同,其语法为:


#![allow(unused)]
fn main() {
const MY_CONSTANT: u32 = 0x42;
}

这里需要类型,右侧必须是一个常量表达式,大致是字面符号、数字运算符和常量函数的任何组合(稍后会详细介绍)。

常量在运行时不存在。它们可以被认为是固定的表达式,被复制+粘贴到它们被使用的地方,类似于C语言中的#definesenum声明器。

Globals看起来像常量,但有一个关键字static


#![allow(unused)]
fn main() {
static MY_GLOBAL: u8 = 0x00;
static mut MY_MUTABLE_GLOBAL: Foo = Foo::new();
}

Globals保证住在.rodata.data.bss中,这取决于它们的可变性和初始化。与常量不同,它们有唯一的地址,但是与常量一样,它们必须用常量表达式进行初始化。

可变的全局变量特别危险,因为它们可能是多核系统中数据竞争的来源。由于IRQ控制流的存在,可变全局变量也可能成为其他恶意行为的来源。因此,对可变全局的读写,或者创建对其的引用,都需要使用Unsafe的Rust。

函数

在C和Rust中,函数是最重要的句法结构。Rust对函数的声明是这样的。


#![allow(unused)]
fn main() {
fn my_function(x: u32, y: *mut u32) -> bool {
    // Function body.
}
}

->标记后面的返回类型,当它是()("单元",空元组)时可以省略,它作为Rust的无效类型的等价物。函数的调用采用通常的foo(a, b, c)语法。

一个函数的主体由一个语句列表组成,可能以一个表达式结束;该表达式是函数的返回值(不需要返回关键字)。如果缺少表达式,那么()将被假定为返回类型。项目可以与语句混合在一起,这些语句在其当前范围内是局部的,但在所有范围内都是可见的。

Rust 函数可以被标记为unsafe fn,这意味着该函数不能被正常调用,而必须使用Unsafe 的Rust调用。unsafe fn的主体就像一个unsafe block;当我们详细讨论Unsafe 的Rust时,我们会更多地了解这个问题。

Rus t函数有一个未指定的调用约定。为了声明一个具有不同调用约定的函数,该函数被声明为extern "ABI" fn foo(),其中ABI是一个支持的ABI。"C "是我们真正关心的唯一一个,它将调用约定转换为系统的C ABI。默认的、隐式的调用约定是extern "Rust "

将函数标记为extern并不能禁用名字改编(mangling);这必须通过给函数添加#[no_mangle]属性来实现。然后,未改编的函数可以被C语言调用,允许Rust库有一个C语言接口。一个经过处理的extern "C "函数的主要用途是变成一个函数指针,传递给C。

函数指针类型看起来就像去掉所有变量名的函数:fn(u32, *mut u32) -> bool。函数指针不能为空,而且必须始终指向一个具有正确ABI的有效函数。函数指针可以通过隐含地将一个函数转换为一个函数来创建(没有&操作符)。函数指针也可以指定 unsafeexternunsafe extern "C" fn() -> u32

永不返回的函数有一个特殊的返回类型!,称为 "永无类型(Never type)"。这类似于C语言中的noreturn注解。然而,使用类型为!的表达式必然是死代码,因此,!将隐含地强制到所有类型(这简化了类型检查,并且完全没有问题,因为这都发生在可证明的死代码中)。

函数也可以被标记为常数。这使得该函数可用于常数计算,但大大限制了可用的操作。不过,在每个版本中,const函数的可用语法都在增加。大多数可以成为常数的标准库函数已经是常数了。

Rust和C语言一样,也有宏。Rust的宏比C的宏要强大得多,它在Rust的语法树上操作,而不是通过字符串替换。宏调用与函数调用的区别是在宏名称后面加上一个!。例如,file!()会扩展为一个带有文件名的字符串字面。要了解更多关于宏的信息,请参见https://danielkeep.github.io/tlborm/book/index.html

译注: Rust 的宏就目前的实现而言,并不是在 Rust 语法树上操作。但它也可以被认为是在语法树上操作。

因为 Rust 的语法树 API 本身并未稳定,目前 Rust 的宏都是建立在稳定的 词法分析 API 基础上,然后通过特定的语法解析(声明宏使用宏解析器,过程宏使用第三方syn/quote库)来完成操作。

别名

Rust有type,它的工作原理和C语言中的typedef完全一样,其语法为:


#![allow(unused)]
fn main() {
type MyAlias = u32;
}

表达式和语句

与C语言非常不同的是,Rust的语法中几乎没有语句:几乎所有的东西都是某种表达式,并且可以在表达式上下文中使用。粗略地说,语言中唯一的语句是创建一个绑定。


#![allow(unused)]
fn main() {
let x: u32 = foo();
}

:后面的类型是可选的,如果缺少,编译器将使用当前范围内的所有信息,包括let之前和之后的信息,来推断出一个类型。

表达式后面的分号只是简单地计算表达式的副作用,就像在其它语言中一样。有些表达式,比如ifwhilefor,不需要在后面加上分号。如果它们没有在表达式中使用,它们将被执行以产生副作用。

let绑定在默认情况下是不可变的,但是let mut x = /* ... */; 将使其成为可变的。

和C语言一样,重新赋值是一个表达式,但和C语言不同的是,它求值为()而不是赋值。

和几乎所有其他语言一样,字面意义、运算符、函数调用、变量引用等等都是标准表达式,我们已经看到了Rust的拼写方式。让我们深入了解一下Rust的一些其他表达式。

块表达式(block)

Rust中的块就像是C语言中块的更好版本;在Rust中,每个块都是一个表达式。一个块以{ }为界,由一组语句和项组成,可能还有一个结束表达式,很像一个函数。然后,该块将求值最后的表达式。比如说:


#![allow(unused)]
fn main() {
let foo = {
    let bar = 5;
    bar ^ 2
};
}

块就像立即执行的局部函数,对于限制变量的范围很有用。

如果一个块没有以表达式结束(也就是说,里面的每个语句都以分号结束),它将隐式返回(),就像函数一样。在处理像if和匹配表达式这样的结构时,这种自动的()很重要,因为它需要将多个执行分支的类型统一为一个。

条件表达式:if 和 match

Rust的if表达式在语法上与C语言类似。完整的语法是:


#![allow(unused)]
fn main() {
if cond1 {
    // ...
} else if cond2 {
    // ...
} else {
    // ...
}
}

条件必须是求值为 bool的表达式。一个条件可以有零个或多个else if子句,else子句是可选的。正因为如此,Rust不需要(因此也没有)三元操作符。


#![allow(unused)]
fn main() {
let x = if c { a } else { b };
}

Rust中的if表达式需要使用大括号。

在Rust中,if表达式的值为最终被执行的块的值。因此,所有块都必须有相同的类型。例如,下面这个表达式不会被编译,因为一个错误的分号导致类型检查失败。


#![allow(unused)]
fn main() {
if cond() {
    my_int(4)   // Type is i32.
} else {
    my_int(7);  // Type is (), due to the ;
}
}

i32() 是不同的类型,所以编译器不能将它们统一为整个if的整体类型。

一般来说,在if子句中用分号结束所有最终表达式是个好主意,除非需要它的值。

和C语言一样,Rust也有一个类似switch 的结构,叫做匹配。你可以匹配整数。


#![allow(unused)]
fn main() {
let y = match x {
    0 => 0x00,       // Match 0.
    1..=10 => 0x0f,  // Match all integers from 1 to 10, inclusive.
    _ => 0xf0,       // Match anything, like a `default:` case.
};
}

if表达式一样,匹配表达式产生一个值。语法case val: stuff; break; 在Rust中大致转化为val => stuff。Rust称这些case子句为 "匹配分支 "。

与C语言不同的是,match语句没有贯穿(fallthrough),特别是只有一个分支被执行。然而,Rust允许一个匹配分支来匹配多个值。


#![allow(unused)]
fn main() {
match x {
    0 | 2 | 4 => /* ... */,
    _ => /* ... */,
}
}

Rust会静态地检查每一种可能的情况是否被覆盖。这在对一个枚举进行匹配时特别有用。


#![allow(unused)]
fn main() {
enum Color { Red, Green, Blue, }
let c: Color = /* ... */;
match c {
    Color::Red =>   /* ... */,
    Color::Green => /* ... */,
    Color::Blue =>  /* ... */,
}
}

不需要_ => case,就像C语言中的default:一样,因为Rust静态地知道所有的情况都被覆盖了(因为枚举不能接受没有被列为变体的值)。如果在枚举中不需要这种行为(因为将来会有更多的变体加入),#[non_exhaustive]属性可以应用于枚举定义,以要求一个默认的分支。

我们将在后面看到,模式匹配使得match比C的switch要强大得多。

循环:loopwhile

Rust有三种循环:loopwhileforfor不是C语言风格的for,所以我们将在后面讨论它。while是标准的C语言while循环,语法略有不同。


#![allow(unused)]
fn main() {
while loop_condition { /* Stuff. */ }
}

它可以作为一个表达式使用,但它的类型总是();当它是一个块中的最后一个表达式时,这一点最值得注意。

loop是Rust特有的;它只是一个无限循环。


#![allow(unused)]
fn main() {
loop { /* Stuff. */ }
}

因为无限循环永远不会结束,所以循环表达式的类型(如果其中没有中断!)是因为循环之后的任何代码都是死的。有了无条件的无限循环,Rust可以对循环进行更好的类型和寿命分析。在语言底层实现中,Rust的所有控制流都是以循环、匹配和中断的方式实现的。

控制流

Rust有returnbreakcontinue,它们具有C语言中的通常含义。它们也是表达式,并且和loop {}一样,具有类型因为所有跟在它们后面的代码都不会被执行(因为它们阻碍了控制流)。

return x 带着值x提前退出一个函数。 return只是return ()的语法。breakcontinue 就是常规循环中的用法。

所有类型的循环都可以用标签进行注释(这是Rust唯一允许标签的地方)。


#![allow(unused)]
fn main() {
'a: loop {
    // ...
}
}

breakcontinue可以和这些标签一起使用(例如break 'a),这将会破坏或继续带有该标签的循环(而不是最邻近的循环)。虽然C语言缺少这个功能,但大多数没有goto的语言都有这个功能。

也可以从一个无限循环中以值 break,这将导致循环表达式计算为该值,而不是!


#![allow(unused)]
fn main() {
let value = loop {
  let attempt = get();
  if successful(attempt) {
    break attempt;
  }
};
}

与 C 语言对话

Rust的一大优势是与现有的C语言库实现了大部分的无缝对接。因为Rust基本上没有运行时间,与C类型相对应的Rust类型可以被简单地共享,Rust可以在几乎没有开销的情况下调用C函数。外部符号的名称可以使用extern块进行 "前向声明",这使得Rust可以命名这些符号,并在之后与之链接。


#![allow(unused)]
fn main() {
extern "C" {
    fn malloc(bytes: usize) -> *mut u8;
    static mut errno: i32;
}
}

当指定的ABI是 "C "时,它可以不写: extern {}是隐含的extern "C" {}

确保这些符号的存在是链接器的责任。此外,还必须注意在边界上发送哪些类型。更多细节见https://doc.rust-lang.org/reference/items/external-blocks.html

其他类似的功能

Volatile

Rust没有volatile限定词。相反,可以使用指针上的read_volatile()write_volatile() 方法进行易失性读取,其行为与C语言中的易失性指针转指完全相同。

注意,这些方法在比架构的易失性加载和存储更宽的类型上工作,这将扩展成一系列的易失性访问,所以要小心。同样的注意事项也适用于C语言:volatile uint64_t在32位机器上会发出多个访问。

内联汇编

Rust还不完全支持内联汇编。Clang的内联汇编语法在不稳定的宏llvm_asm!()后面可用,它最终会被Rust特有的语法取代,从而更好地与语言结合。 global_asm!()也是如此,但可在全局范围内使用,用于定义整个函数。裸函数可以用#[naked]来创建。参见https://doc.rust-lang.org/1.8.0/book/inline-assembly.html

请注意,这种语法目前正处于重新设计和稳定的过程中。

按位转换

Rust提供了一个类型系统的陷阱门,可以将任何类型按位转换位任何其他相同大小的类型。


#![allow(unused)]
fn main() {
let x = /* ... */;
let y = std::mem::transmute<A, B>(x);
}

这个陷阱门是非常危险的,只应该在强制转换不够用的情况下使用。

https://doc.rust-lang.org/std/mem/fn.transmute.html 有一个用途列表,其中许多实际上不需要转换。

链接器技巧和其他属性

下面是与嵌入式编程有关的各种属性。其中许多属性会巧妙地影响链接器/优化器的行为,并且在很大程度上属于 "你可能不需要担心它 "的范畴。

  • #[link_section = ".my_section"] __attribute__((section(".my_section"))的简单拼写,它将在给定的ELF(或等价)部分粘贴一个符号。
  • #[used]可以用来强制链接器保留一个符号 (这在C语言中通常是通过将符号标记为volatile来实现的)。通常对__attribute__((used))的注意事项,以及其他链接器的提示,在这里也适用。
  • #[inline]类似于C的inline,只是一个提示;#[inline(always)]#[inline(never)]将分别总是 或从不被inline。
  • #[cold]也可以用来对那些不太可能被调用的函数进行最小化的内联。

Part II : Rust 专属特性

前一部分介绍了可以直接将 C 代码翻译为 Rust 代码所需要的知识。但是,这样直接从 C 翻译的 Rust 代码,其安全性将和 C 代码一样,没有太多保证。这一节,我们将重点介绍 使 Rust (译注:是指 Safe Rust) 更加安全、更容易编写的特性。

所有权(Ownership)

双重释放(double-free),或者一般来说,双重使用,是 C 语言中一大类潜在的 Bug,这些 Bug 一眼看上去并没有明显的错误。


#![allow(unused)]
fn main() {
// `handle` is a managed resource to a peripheral, that should be
// destroyed to signal to the hardware that the resource is not in use.
my_handle_t handle = new_handle(0x40000);
use_for_scheduling(handle);  // Does something with `handle` and destroys it.
// ... 200 lines of scheduler code later ...
use_for_scheduling(handle);  // Oops double free.
}

在 C 语言中,DF(double-free) 和 UAF(use-after-free)是崩溃和安全漏洞常见来源。让我们看看在 Rust 代码中尝试这样做会发生什么?

考虑一下用 Rust 编写等效代码:


#![allow(unused)]
fn main() {
let handle = new_handle(0x40000);
use_for_scheduling(handle);
// ...
use_for_scheduling(handle);
}

如果你试图编译这段代码,你会得到一个错误:


#![allow(unused)]
fn main() {
error[E0382]: use of moved value: `handle`
  --> src/main.rs:10:24
   |
7  |     let handle = new_handle(0x40000);
   |         ------ move occurs because `handle` has type `Handle`,
   |                which does not implement the `Copy` trait
8  |     use_for_scheduling(handle);
   |                        ------ value moved here
9  |     // ...
10 |     use_for_scheduling(handle);
   |                        ^^^^^^ value used here after move
}

UF 和 DF 的错误在 Safe Rust 中是不可能存在的。这类特殊错误(不直接涉及指针)是由移动语义来防止的。正如上面错误示例所示,变量标志着它已经被 "移出":该变量现在是一个未初始化内存的空槽。编译器会静态地跟踪这一点,如果你试图再次移出,编译会失败。当前存储一个值的变量被称为它的 "所有者 ";所有者有权将所有权移交给另一个变量,但只能这样做一次。

该错误还指出,"Handle没有实现Copy 特质"。特质本身是以后的话题;现在这意味着Handle有移动语义(新类型的默认)。实现了复制的类型具有复制语义;这就是 C 语言中所有按值传递的类型的行为方式:在 C 语言中,按值传递的结构总是复制整个结构,而按引用传递的结构只是复制指向该结构的指针。这就是为什么在处理整数和原始指针时,移动并不相关:它们都是Copy类型。

请注意,您定义的任何结构和枚举都不是默认的复制类型,即使它们的所有字段都是。如果你希望一个字段都是Copy的结构体也是Copy的,你可以使用以下特殊语法。


#![allow(unused)]
fn main() {
#[derive(Clone, Copy)]
struct MyPodType {
  // ...
}
}

当然,复制/移动的区别是一种错误的说法:由于复制和移动语义而导致的重新赋值可以编译成相同的memcpy或寄存器移动代码。这种区分纯粹是为了静态分析。

引用(References)和生命周期(Lifetimes)

另一类 UAF 使用涉及到栈销毁以后的栈变量。考虑下面的 C 代码:

const int* alloc_int(void) {
  int x = 0;
  return &x;
}

这个函数显然是错误的,但是这样的错误,即一个指针超过它所指向的数据,在C语言中是很隐蔽的,因为它们很常见。

Rust的主要指针类型: 引用,使得这种情况不可能发生。引用就像原始的指针,只是它们总是对齐良好、非空,并且指向有效的内存;它们也比C语言的指针有更强的别名限制。让我们来探讨一下Rust是如何实现这最后一项保证的。

考虑一下下面这个Rust程序。


#![allow(unused)]
fn main() {
fn alloc_int() -> &i32 {
    let x = 0i32;
    &x
}
}

这个程序将无法编译,并出现一个隐秘的错误:missing lifetime specifier.。显然,我们漏掉了什么,但至少编译器没有让这个明显错误的程序通过。

生命周期(lifetime),用'a这样的符号表示(撇号通常读作 "tick"),标记源代码的一个区域。Rust中的每个引用都有一个生命周期,代表了一个引用指向有效内存的区域:这是由语法&'a i32'a期间对i32的引用所指定。生命周期和类型一样,在运行时不存在;它们的存在只是为了让编译器进行借用检查,在这种情况下,编译器会确保引用只存在于各自的生命周期内。一个特殊的生命期,static代表整个程序。它是常量和全局变量的生命周期。

考虑一下下面的Rust代码:


#![allow(unused)]
fn main() {
let x: i32 = 42;
let y: &'a i32 = &x;  // Start of 'a.
use_reference(y);
use_value(x);  // End of 'a, because x has been moved.
use_reference(y);  // Error: use of y outside of 'a.
}

引用生命周期从引用被接受时开始,当生命周期超出范围或引用的值被移动时结束。试图在生命周期外使用引用是一个错误,因为它现在是一个悬空的指针。

Rust经常把引用称为借用:引用可以在有限的时间内(生命周期)从它的所有者那里借用一个值,但是必须在所有者把这个值让给其他人之前归还它。引用也有可能是借用的借用,或者是再借用:总是有可能创建一个具有较短生命周期但与另一个引用具有相同价值的引用。重新借用通常是由编译器隐式执行的,通常是在调用点周围,但也可以通过写&*x显式执行。

在大多数使用生命周期的地方都可以省略。


#![allow(unused)]
fn main() {
fn get_field(m: &MyStruct) -> &u32 {
  &m.field  // For references, unlike for raw pointers, . acts the same way -> does in C.
}
}

在这里,编译器假定返回类型的生命周期应该与m的生命周期相同。然而,我们可以明确地写出这一点。


#![allow(unused)]
fn main() {
fn get_field<'a>(m: &'a MyStruct) -> &'a u32 { /* ... */ }
}

<'a>语法在函数签名中引入了一个新的命名生命周期,这样我们就可以明确地告诉编译器 "这两个引用具有相同的生命周期"。当编译器无法做出任何假设时,这对于指定许多生命周期来说特别有用。


#![allow(unused)]
fn main() {
fn get_fields<'a, 'b>(m1: &'a MyStruct, m2: &'b MyStruct) -> (&'a u32, &'b u32) {
    (&m1.field, &m2.field)
}
}

现在我们可以尝试修复我们错误的栈返回函数。我们需要为这个函数引入一个新的生命周期,因为没有函数参数可以得到一个生命周期。


#![allow(unused)]
fn main() {
fn alloc_int<'a>() -> &'a i32 {
    let x = 0i32;
    &x
}
}

现在这给了我们一个直接的错误,表明借用检查可以防止错误的栈返回。


#![allow(unused)]
fn main() {
error[E0515]: cannot return reference to local variable `x`
 --> src/lib.rs:9:3
  |
9 |   &x
  |   ^^ returns a reference to data owned by the current function
}

这种<'a>语法也可以应用于结构体等项目。如果你要创建一个包含引用的类型,<'a>是必须的。


#![allow(unused)]
fn main() {
struct MyRef<'a> {
    meta: MyMetadata,
    ptr: &'a u32,  // Lifetime elision not allowed here.
}
}

Rust的引用有两种类型:共享和唯一。一个共享引用,&T,提供了对T类型值的不可改变的访问,并且可以自由复制:&T是Copy。唯一的引用,&mut T,提供了对T类型值的可变访问,但要遵守Rust的别名规则,这比C的严格别名规则要严格得多,而且不能被关闭。

对于一个给定的值,在同一时间只能有一个&mut T激活。这意味着在这个唯一引用的有效期内不能创建其他引用。然而,一个&mut T可以被重新借用,通常用于传递给一个函数。在再借用的有效期内,不能使用原来的引用。这意味着下面的代码可以正常工作。


#![allow(unused)]
fn main() {
fn do_mut(p: &mut Handle) { /* ... */ }

let handle: &mut Handle = /* ... */;
do_mut(handle);  // Reborrow of handle for the duration of do_mut.
// handle is valid again.
do_mut(handle);  // Reborrow again.
}

换句话说,Rust 没有一个安全的int*等价物;它只有const int*int* restrict的等价物......再加上强制去掉 const是即时未定义行为。Rust会假设没有可变的引用别名,以便进行别名分析。这意味着更多的优化机会,而不需要安全代码做任何事情。

最后,不用说,引用只对主(main)内存有用;Rust有权为(可能未使用的)引用产生虚假的load和store,所以MMIO应该完全通过原始指针进行。

https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html 进一步阐述了各种生命周期规则。

引用操作

Rust引用的行为更像标量值,而不像指针(除了借用检查)。因为静态地知道每个引用在任何时候都指向一个有效的、初始化了的T类型的值,所以在大多数时候显式地取消引用(尽管在必要时,它们可以被取消引用。*x是一个可以被赋值的lvalue)。)

Rust没有->操作符,但是对于x: &T来说,点操作符的行为就像x是一个T一样。例如,如果fieldT的一个字段,x.field就是fieldlvalue(在C语言中拼写为x->field)。这甚至适用于严重嵌套的引用:&&&&的点运算符将触发三次内存查找。这被称为 auto-deref 行为。

引用是否相等取决于其指向值的是否相等:x == y,对于x: &Ty: &T,变成*x == *y。指针相等判断仍然可以用std::ptr::eq(x, y)。引用可以被强转为原始指针:x as *const T,并直接进行比较。

方法

虽然Rust不是一种面向对象的语言,但它确实提供了一种在类型下对函数进行命名的机制:impl(代表实现)块。这也允许你使用Rust的可见性注解,使外部用户无法看到实现细节。

下面是一个带有方法的类型的例子。


#![allow(unused)]
fn main() {
pub struct Counter(u64);  // Non-public field!
impl Counter {
    /// Creates a new `Counter`.
    pub fn new() -> Self {
      Counter(0)
    }

    /// Private helper.
    fn add(&mut self, x: u64) {
        self.0 += x;
    }

    /// Get the current counter value.
    pub fn get(&self) -> u64 {
        self.0
    }

    /// Increment the counter and return the previous value.
    pub fn inc(&mut self) -> u64 {
        let prev = self.get();
        self.add(1);
        prev
    }

    /// Consumes the counter, returning its final value.
    pub fn consume(self) -> u64 {
        self.get()
    }
}
}

外部模块不能访问任何未标记为pub的东西,这使得我们可以对 Counter 执行一个不变性:它是不变的。让我们来解读一下这个语法。

impl 块中的函数被称为 "固有函数 (inherent functions) "或 "方法 (method)",这取决于它们是否带一个self参数。固有函数不需要self参数,它的调用方式与Counter::new() 类似。

self参数是一个名为 self 的参数(这是一个关键字),其类型涉及Self (另一个关键字,是内含块的类型别名),如&Self self&selfmut self&mut self的语法 是语法糖,分别对应 self: Selfself: &Selfmut self: Selfself: &mut Self,分别代表了self-by-valueself-by-referenceself-by-mut-valueself-by-mut-reference

因此,方法可以像这样被调用:my_counter.inc()。方法实际上只是普通的函数:你也可以像Counter::inc(&mut my_counter)这样调用。请注意,调用一个接收&self&mut self的函数会触发接收类型的借用;如果在一个非引用值上调用&self函数,该值将被获取其地址,并被传递到方法中。

impl块,像其他项目一样,可以通过生命周期参数化。为了给一个有引用的结构添加方法,可以使用以下语法。


#![allow(unused)]
fn main() {
impl<'a> MyStruct<'a> { /* ... */ }
}

如果'a'在 impl 块内从未实际使用过,可以使用 lifetime 占位符 来写。


#![allow(unused)]
fn main() {
impl MyStruct<'_> { /* ... */ }
}

正如我们已经看到的,许多原始类型也有方法;这些方法被定义在标准库的特殊 impl 块中。

切片(slice) 和 for 循环

引用也不允许进行指针运算,所以&u32不能被用来指向字的缓冲区。静态缓冲区可以作为数组来传递,比如&[u32; 1024],但我们经常想传递一个运行时已知值的连续内存的指针。切片 是Rust对指针加长度的解决方案。

T的切片是[T]类型;这种类型最像C语言中的 "灵活数组成员"。


#![allow(unused)]
fn main() {
struct slice {
    size_t len;
    T values[];
}
}

那么,一个slice*将指向一个长度,后面是那么多的T;除了在指针后面,它不可能合理地存在。同样地,[T]就是Rust所说的动态大小的类型,它需要存在于一个引用后面:更常见的是看到&[T]&mut[T]

然而,Rust 仍然与C版本不同:&[T]是一个胖指针,有两个字宽。它本质上看起来像这样。


#![allow(unused)]
fn main() {
struct Slice {
    len: usize,
    values: *const T,
}
}

对一个切片的引用与数组引用类似:&x[n]提取对切片中第n个元素的引用(有边界检查),x[n] = y分配给它。切片的长度也可以用len方法提取:x.len()

str 是一个类似切片的类型,保证包含UTF-8字符串数据。

可以使用 "范围索引操作 "从数组和其他切片创建切片:&x[a..b]。这需要数组或切片x,并创建一个从索引a到索引b(包括a,不包括b)的元素的切片,长度为b-a&x[a..]是后缀,从a开始,&x[...b]是前缀,从b结束,&x[...]是整个切片,对于将数组转换为切片很有用。包容范围也是可用的,其语法是a...=b

切片可以通过for循环进行迭代。


#![allow(unused)]
fn main() {
let slice: &[u32] = /* ... */;
for x in slice {
    // x is a reference to the nth element in slice.
}
}

如果需要一个索引,可以直接在一个范围内迭代。


#![allow(unused)]
fn main() {
for i in 0..slice.len() {
    let x = &slice[i];
    // ...
}
}

这可以与_模式相结合,简单地重复一个操作n次。


#![allow(unused)]
fn main() {
for _ in 0...n {
    // ...
}
}

与借用有关的切片的一个重要注意事项是唯一引用。如果我们有一个对切片的唯一引用,就不可能同时对多个元素采取唯一引用。


#![allow(unused)]
fn main() {
let slice: &mut [u32] = /* ... */;
let x = &mut slice[0];
let y = &mut slice[1];  // Error: slice is already borrowed.
}

split_at_mut() 方法可以用来将一个唯一的slice引用分割成两个不重叠的唯一slice引用。


#![allow(unused)]
fn main() {
let slice: &mut [u32] = /* ... */;
let (slice1, slice2) = slice.split_at_mut(1);
let x = &mut slice1[0];  // slice[0]
let y = &mut slice2[0];  // slice[1]
}

通常情况下,可以通过结构化的方式来避免这种情况的发生,但这种逃逸的方式是为了在必要的时候存在。切片也可以用as_ptr()len()函数分解成其指针和长度部分,然后用std::slice::from_raw_parts()重新组装起来。这种操作是不安全的,但对于跨越系统调用或IPC边界的C和Rust,或者Rust和Rust之间的桥接非常有用。

更多的切片操作可以在https://doc.rust-lang.org/std/slice/index.htmlhttps://doc.rust-lang.org/std/primitive.slice.html找到。

字符串字面量

Rust的字符串字头很像C的字符串字面量 : "abcd..."。任意的ASCII范围的字节可以用\xNN插入,并支持大多数常见的转义序列。然而,所有的Rust字符串都是UTF-8编码的字节切片:&str是围绕&[u8]的一个封装类型,保证里面的字节是有效的UTF-8。所有字符串字面的类型是&'static str

Rust字符串字面意义中可以包含任意的换行,这可以被转义。


#![allow(unused)]
fn main() {
// Equivalent to "foo\n  bar".
let s = "foo
  bar";
// Equivalent to "foo  bar".
let s = "foo\
  bar";
}

原始字符串不能使用转义序列,并由任意的、匹配数量的#符号分隔。


#![allow(unused)]
fn main() {
let s = r"...";
let s = r#" ..."#;
let s = r#####"..."#####;
}

Rust也有'z' 形式的字符字面,不过它们的类型是char,一个32位的Unicode代码点。要获得一个u8类型的ASCII字节,可以使用b'z'

析构 与 RAII

析构器是一些特殊的函数,当一个值变得不可触及时(即,最初声明它的let不能再被命名,并且对它的最后一次引用已经过期),它将执行清理逻辑。在析构器运行后,如果它是一个结构体或枚举,那么该值的每个字段也被销毁(或 "丢弃")。

析构器是用一种特殊的 impl 块来声明的(我们将在后面看到更多这样的内容)。


#![allow(unused)]
fn main() {
impl Drop for MyType {
    fn drop(&mut self) {
        // Dtor code.
    }
}
}

如果几个值同时超出了范围,它们会按照声明的相反顺序被丢弃。

Drop方法不能被手动调用;然而,标准库函数std::mem::drop()可以用来放弃一个值的所有权并立即销毁它。Union 和具有复制语义的类型不能有析构器。

析构器可以实现资源获取即初始化(RAII)的惯用法。一个持有某种临时资源的类型,像一个外设的句柄,可以有一个析构器来自动释放该资源。一旦句柄超出范围,该资源就会被清理掉。

RAII的典型例子是动态内存管理:你用malloc分配内存,把返回的指针藏在一个结构中,然后该结构的析构器对该指针调用free。由于在调用free时,该结构已经超出了范围,所以UAF是不可能的。由于Rust的移动语义,这个结构不能被复制,所以析构器不能被调用两次。因此,双重释放也是不可能的 。

在某些情况下,调用一个析构器可能是不可取的(例如,在某些不安全的Rust操作中)。标准库提供了特殊的函数std::mem::forget(),它消耗一个值而不调用其析构器。std::mem::ManuallyDrop<T>类型是一个智能指针 ,它持有一个T,同时抑制其析构器。由于这个原因,不存在期望一个析构器实际运行的问题。

std::mem::needs_drop()这个函数可以用来发现一个类型是否需要被drop;即使它没有drop方法,它也可能递归地有一个字段可以drop。 std::ptr::drop_in_place() 可以用来在一个原始指针后面的值中运行析构器,而在技术上不放弃对它的访问。

模式匹配

引用不能为空,但事实证明,空值有时是有用的。Option<T>是一个标准的库类型,代表一个 "可能没有的T " 。它被实现为一个枚举。


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}
}

<T>类似于我们之前看到的lifetime语法;它意味着Option<T>是一个通用类型;我们很快就会深入研究这些。

如果我们有一个Option<T>类型的值(或者,任何其他的枚举,真的),我们可以使用模式匹配来编写以该值的判别式为条件的代码,这可以通过匹配表达式访问。


#![allow(unused)]
fn main() {
let x: Option<u32> = /* ... */;
let y = match x {
    Some(val) => val,  // If `x` is a `Some`, bind the value inside to `val`.
    None => 42,  // If `x` is a `None`, do this instead.
};
}

模式匹配给我们的关键是能够安全地检查枚举内的联合体:标签(tag)检查是由编译器强制执行的。

模式就像表达式,形成一种小型语言。如果说表达式是通过组合现有的值来建立一个值,那么模式则是相反的:它们通过解构值来建立值。特别是,应用于表达式的模式会执行以下操作。

  • 检查表达式的值是否真的与该模式匹配。(注意,类型检查并不包括在内;模式不能表达式的类型)。
  • 可以选择将表达式的值绑定到一个名字上。
  • 可以选择递归到子模式中。

下面是几个模式的例子。请记住每个模式的匹配、绑定和递归属性。一般来说,模式看起来像它们匹配的表达式的值。

  • _,一个下划线模式。匹配总是成功的,但是会扔掉匹配的值。这就是相当于默认情况下的_:case。_
  • foo,一个标识符模式。这个模式与_完全一样,但它将匹配的值与它的名字绑定。这就是上面Some(val)中的val。这也可以作为一个默认的案例,希望对匹配到的值做一些事情。绑定可以通过写Some(mut val)而变得可变。
  • 任何数字字面量,用于一个字面量模式。这种匹配将匹配的值与字面值进行比较,并且不匹配任何东西。这些也可以是包容性的范围: 5..=1686.
  • (pat1, pat2, /* etc */),一个元组模式。这种匹配对元组类型进行操作,并且总是成功的:它提取元组的各个元素,并将它们应用于模式的子模式。特别是,()模式匹配单位值()

#![allow(unused)]
fn main() {
let x: (u32, u32) = /* ... */;
match x {
    (5, u) => /* ... */,  // Check that first element is five,
    // bind the second element to `u`.
    (u, _) => /* ... */,  // Bind the first element to `u`,
    // discard the second element.
}

let y: (u32, (u32, u32)) = /* ... */;
match y {
    // All patterns can nest arbitrarily, like expressions.
    (42, (u, _)) =>  /* ... */,
    // `..` can be used to match either a head or a tail of tuple.
    (.., u) => /* ... */,
    (u, ..) => /* ... */,
    (..) =>    /* ... */,  // Synonymous with _.
}
}

结构模式类似于元组模式。对于类似元组的结构,它们的语法完全相同,但以结构的名称开始。MyTuple(a, b, _)。普通结构的语法要有趣得多。


#![allow(unused)]
fn main() {
struct MyStruct { a: i32, b: u32 }
match my_struct {
    MyStruct { a, b } => /* ... */,  // Bind the fields `a` and `b` to
    // names `a` and `b`, respectively.
    MyStruct { a: foo, b: _ } => /* ... */,  // Bind the field `a` to the name
    // `foo`, and discard the field `b`.
    MyStruct { a: -5, .. } => /* ... */  // Check that `a` is -5, and ignore
    // other fields.
}
}

枚举模式可能是最重要的一种模式,也是我们在上面的Option的匹配语句中看到的。它们与结构模式非常相似,只是它们不总是成功的,而是检查枚举判别符是否是模式中指定的那个。


#![allow(unused)]
fn main() {
enum MyEnum { A, B{u32), C { a: i32, b: i32 }, }
match my_enum {
    MyEnum::A =>    /* ... */,  // Match for variant `A`.
    MyEnum::B(7) => /* ... */,  // Match for variant `B`, with 7 as the value inside.
    MyEnum::B(x) => /* ... */,  // Match for variant `B`, binding the value inside to
    // `x`.

    MyEnum::C { a: 7, .. } => /* ... */,  // Match for variant `C`, with 7 as the
    // value in `a` and all other fields ignored.

    MyEnum::C { b, .. } => /* ... */,  // Match for variant `C`, binding b to b.
}
}

对模式语法的完整处理可以在https://doc.rust-lang.org/book/ch18-03-pattern-syntax.html

匹配表达式将针对一个值对每个模式进行计算,直到有一个匹配,依次进行;编译器将对无法到达的模式发出警告。编译器还将确保每个值都能与其中一个匹配分支相匹配,这是因为每个情况都被覆盖了(例如,每个枚举变体都存在),或者存在一个不可辩驳(irrefutable )的模式(即,一个匹配所有值的模式)。_, foo, (a, _), 和MyStruct { a, .. }都是不可反驳的模式的例子。

如果被匹配的值是某种类型的引用,绑定的名字也将是引用。

比如说:


#![allow(unused)]
fn main() {
match &my_struct {
    MyStruct { a, .. } => {
        // Here, `a` is a `&i32`, which is a reference to the `a` field in my_struct.
    },
}
}

这个特性有时被称为匹配的人机工程学,因为在它被添加之前,必须在引用的匹配中添加明确的解除引用和特殊的ref模式限定符。

此外,匹配语句在上面讨论的模式语法的基础上支持两个额外的特性。

  • 多重匹配分支可以允许一个匹配分支匹配多个模式中的一个:a | b | c => /* ... */,. 如果有任何模式匹配,就执行该分支。
  • 匹配守卫为你提供了一种快捷方法,用于在某些表达式上限制一个分支:Some(foo) if foo.has_condition() => /* ... */,.

另外,标准库提供了 matches!() 宏,作为以下常见匹配表达式的简写。


#![allow(unused)]
fn main() {
match expr {
  <some_complex_match_arm> => true,
  _ => false,
}
// ... can be replaced with ...
matches!(expr, some_complex_match_arm)
}

matches! 也支持多重匹配和匹配守卫。

不可反驳的模式可以与普通变量声明一起使用。语法let x = /* ... */; 实际上使用了一个模式:x是一个模式。当我们写let mut x = /* ... */;时,我们使用的是一个mut x模式。其他不可辩驳的模式也可以用在这里。


#![allow(unused)]
fn main() {
// Destructure a tuple, rather than using clunky `.0` and `.1` field names.
let (a, b) = /* ... */;

// Destructure a struct, to access its fields directly.
let Foo { foo, bar, baz } = /* ... */;

// Syntactically valid but not allowed: `42` is not an irrefutable pattern.
let 42 = /* ... */;
Special variants of if and while exist to take advantage of patterns, too:

if let Some(x) = my_option {
    // If the pattern succeeds, the body will be executed, and `x` will be bound
    // to the value inside the Option.
    do_thing(x);
} else {
    // Else block is optional; `x` is undefined here.
    // do_thing(x);  // Error.
}

while let Some(x) = some_func() {
    // Loop terminates once the pattern match fails. Again, `x` is bound
    // to the value inside the Option.
}
}

与普通的let语句不同,if letwhile let表达式是为了与可反驳模式一起使用。

一般来说,几乎所有绑定值的地方都可以是一个不可反驳的模式,例如函数参数和for循环变量。


#![allow(unused)]
fn main() {
fn get_first((x, _): (u32, u32)) -> u32 { x }

for (k, v) in my_key_values {
    // ...
}
}

特质(trait)

特质是Rust的核心代码重复使用抽象。Rust的特质就像其他语言中的接口:一个类型必须实现的方法列表。然而,特质本身并不是类型。

标准库中一个非常简单的traitClone


#![allow(unused)]
fn main() {
trait Clone {
    fn clone(&self) -> Self;
}
}

一个满足Clone接口的类型(用Rust的说法是 "实现Clone")有一个具有给定签名的Clone方法,它返回一个Self的副本。为了实现一个特质,你可以使用一个略微有趣的impl 语法。


#![allow(unused)]
fn main() {
impl Clone for MyType {
    fn clone(&self) -> Self { /* implementation */ }
}
}

这给了我们一个一致的方式来拼写 "我想要这个值的一个副本"。标准库为一些类似的操作提供了特质,比如Default,用于提供默认值;PartialEqEq,用于平等;PartialOrdOrd,用于排序;以及Hash,用于非加密散列。

上述 特质 的特殊之处在于,它们对一个结构或枚举有微不足道的实现,假设该结构或枚举的所有字段都实现了它。在 "所有权 "一节中描述的#[derive()]语法可以与任何这些特质一起使用,为一个类型自动实现它们。普通旧数据(POD)类型看起来像这样的情况并不少见。


#![allow(unused)]
fn main() {
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
struct MyPod {
    pub a: u8,
    pub b: u8,
    // The following line wouldn't compile, because `derive(Eq)` requires
    // all fields to be `Eq`.
    // c: NonEq,
}
}

特质也可以提供用其他方法实现的内置方法,以提供一个默认的实现(如果对某一特定类型有更有效的实现,则可以重写)。完整的Clone trait实际上是这样的。


#![allow(unused)]
fn main() {
pub trait Clone {
    fn clone(&self) -> Self;
    fn clone_from(&mut self, source: &Self) {
        *self = source.clone();
    }
}
}

实现者不需要提供clone_from,但如果默认的实现不够好的话,允许他们这样做。

特质和实现它们的类型可以被定义在不同的模块中,只要实现模块定义了特质或类型。这意味着特质方法并不是类型的一部分,而是特质和类型的一部分。因此,为了在一个特定类型上调用特质方法,该特质也必须在范围内。当无歧义时,trait函数可以作为foo.trait_fn()Foo::trait_fn(foo)Trait::trait_fn(foo)来调用。然而,由于名字有时可能有歧义,所以有一个完全不模糊的语法 :<Foo as Trait>::trait_fn(foo)。最后一种语法在通用语境中也是很有用的,或者可以精确到被引用的确切函数。

特质 也是操作符重载的载体:这些trait可以在标准库的std::ops模块中找到。

Trait 对象

特质可以通过一种叫做特质对象的机制用于动态调度(也被称为虚拟多态性)。给定一个trait Trait和一个实现它的类型T,我们可以将一个引用&T as 强转成一个动态trait对象:&dyn Trait。比如说:


#![allow(unused)]
fn main() {
trait Id {
    fn get_id(&self) -> usize;
}
impl Id for Device {
  // ...
}

let device: Device = /* ... */;
let dyn_id = &device as &dyn Id;  // Create a vtable.
let id = dyn_id.get_id();  // Indirect procedure call.
}

dyn Trait 是一个动态大小的类型,很像切片,只能存在于一个指针后面。引用&dyn Trait看起来像这样。


#![allow(unused)]
fn main() {
struct TraitObject {
    value: *mut (),
    vtable: *mut Vtable,
}

struct Vtable {
    size: usize,
    align: usize,
    dtor: fn(&mut T),
    // Other trait methods.
}
}

因此,对get_id的动态函数调用将编译成如下内容。


#![allow(unused)]
fn main() {
let device: Device = /* ... */;
let dyn_id = &device as IdTraitObject;
let id = (dyn_id.vtable.get_id)(dyn_id.value);
}

Unsafe Trait

我们可以通过编写unsafe trait MyTrait { /* ... */ };与普通trait的唯一区别是,它需要实现不安全的 impl。Unsafe 的 trait 通常在其方法之外执行某种额外的约束;事实上,Unsafe 的 trait 经常根本就没有方法。例如,标准库的特质Sync是由所有同步访问的类型实现的。因为这个特质所断言的不变性超出了编译器可以检查的范围,所以它是一个不安全的特质。

特质方法可以单独标记为 unsafe 。这样做通常是为了表明,不仅在实现该特质时需要小心,而且调用该函数也需要小心(并发出不安全的声音)。这与将特质标记为 unsafe是分开的,没有必要将一个特质标记为 unsafe,因为它有 unsafe的方法。

Auto Trait

Auto Trait 是一种编译器机制,用于自动实现某些特征;在标准库的源代码中,它显示为 auto trait Foo {}。(尽管这种语法在普通库中是不可用的)。如果一个结构或枚举类型的所有字段也实现了该特性,那么自动特性就会自动实现,并用于向特性系统暴露跨行属性。例如,Send和Sync是自动特质;其他一些标记性特质98也是自动特质。

自动特征总是你并不希望选择的标记。它们就像derive()特性的反面,你需要选择加入,因为它们对你的类型的API产生了有意义的影响,能够控制这种影响是很重要的。

泛型编程

泛型编程是指编写可以被编译为许多类型的源代码。泛型是Rust的核心功能之一,它可以实现多态静态调度。

函数可以通过引入类型参数来实现泛型,使用的语法类似于显式生命期。


#![allow(unused)]
fn main() {
fn identity<T>(x: T) -> T {
    x
}
}

这个函数接受一个任何类型的值并立即返回。然后它可以像这样被调用:identity::<i32>(42)。使用一个填写了所有类型参数的通用函数会导致它被实例化(或单态化),从而导致为它生成代码。这个过程本质上包括用具体的值替换T的每一次出现。

每个不同的实例化在运行时是一个单独的函数,有一个单独的地址,尽管对于产生相同代码的函数,如identity::<i32>identity::<u32>,链接器可能会重复它们。过分热衷于使用泛型代码会导致二进制的膨胀。

大多数时候,::<>位(被一些参考资料称为 "涡轮鱼")是不必要的,因为Rust类型演绎可以推断出它:let x: u64 = identity(42); 会推断出T = u64。然而,在没有必要的情况下,包括它也是有用的,可以帮助提高可读性。

类型也可以是泛型的,比如之前的Option<T>类型。


#![allow(unused)]
fn main() {
struct MyWrapper<T> {
    foo: usize,
    bar: T,
}
}

具体的类型MyWrapper<i32>包括将定义中所有出现的T替换成i32,否则我们可以将其作为普通类型使用。


#![allow(unused)]
fn main() {
fn get_foo(mw: MyWrapper<i32>) -> usize {
    mw.foo
}
}

注意,MyWrapper本身并不是一个类型。

注意不同的泛型实例是不同的类型,有不同的布局和大小,一般来说,它们之间不能相互转换。

不出所料,我们可以将泛型函数与泛型类型结合起来。在这种情况下,我们并不真的需要知道T = i32,所以我们把它剔除。


#![allow(unused)]
fn main() {
fn get_foo<T>(mw: MyWrapper<T>) -> usize {
    mw.foo
}
}

我们也可以建立一个泛型函数来提取泛型字段。


#![allow(unused)]
fn main() {
fn get_bar<T>(mw: MyWrapper<T>) -> T {
    mw.bar
}
}

就像对待生命周期一样,impl 块在使用前需要引入类型参数。


#![allow(unused)]
fn main() {
impl<T> MyWrapper<T> {
    // ...
}
}

泛型限定

然而,仅仅是泛型就有一个限制:函数在其泛型形式下只进行一次类型和借用检查,而不是每次实例化;这意味着泛型代码不能仅仅调用T的固有方法,并期望查找成功。例如,这段代码不会被编译。


#![allow(unused)]
fn main() {
fn generic_add<T>(x: T, y: T) -> T {
    x + y
}
}

错误看起来像这样。


#![allow(unused)]
fn main() {
error[E0369]: cannot add `T` to `T`
 --> src/lib.rs:2:6
  |
2 |     x+y
  |     -^- T
  |     |
  |     T
  |
  = note: T might need a bound for std::ops::Add
}

编译器很有帮助地建议我们需要某种 "限定"。泛型限定是特质真正发挥作用的地方。

Add 是一个标准库特质,看起来像下面这样。


#![allow(unused)]
fn main() {
trait Add<Rhs> {
    type Output;
    fn add(self, other: Rhs) -> Self::Output;
}
}

这个特质不仅是泛型的,而且它还定义了一个相关的类型,允许实现者选择加法运算的返回类型。因此,对于任何类型的TU,如果T实现了Add<U>,我们可以把它们加在一起;操作的返回类型是<T as Add<U>>::Output

因此,我们的generic_add函数应该被改写成:


#![allow(unused)]
fn main() {
fn generic_add<T: Add<T>>(x: T, y: T) -> T::Output {
    x + y
}
}

T: Add<T>部分是一个泛型约束,断言这个函数只有在所选的T实现了Add<T>时才能被编译。

如果我们想确保返回一个 T,我们可以改变约束,要求 Output T


#![allow(unused)]
fn main() {
fn generic_add<T>(x: T, y: T) -> T
  where T: Add<T, Output=T>
{
    // ...
}
}

注意,这个限定被包含在where子句中,在返回类型之后。这与把它放在尖括号中是一样的,但对于复杂的限定,建议不要让它们挡住路。括号内的限定和where子句适用于所有其他可以有泛型限定的项,比如traitsimplsstructsenums

限定泛型可以被用来模拟各种其他行为。例如,From Into 特质代表无损转换,所以一个想要任何可以转换为 MyType 的值的函数可能看起来像:


#![allow(unused)]
fn main() {
fn foo<T: Into<MyType>>(x: T) {
    // ...
}
}

然后你可以在 MyType 上为所有可以转换为MyTypeT 实现 From<T>。当 U 实现 From<T> 时,标准库中的泛型 impl 会使 T 实现 Into<U>。在调用点,这看起来像一个重载函数。

限定的泛型也可以被用来传递常量。想象一下,我们定义了一个特质,比如


#![allow(unused)]
fn main() {
trait DriverId {
    const VALUE: u8;
}
}

然后,这个特质可以由各种零大小的类型来实现,这些类型的存在只是为了作为类型参数传入。


#![allow(unused)]
fn main() {
struct GpioDriverId;
impl DriverId for GpioDriverId {
    const VALUE: u8 = 0x4a;
}
}

然后,需要接受一个驱动的常数id的函数可以这样编写和调用。


#![allow(unused)]
fn main() {
fn get_device_addr<Id: DriverId>() -> usize {
  // use Id::VALUE somehow ...
}
// ...
get_device_addr::<GpioDriverId>()
}

类型也可以通过生命周期来绑定。绑定T:'a表示T中的每个引用都比'a长;每当一个通用的&'a T被传递时,这种绑定将被隐式插入。限定可以被组合: T: Clone + DefaultT: Clone + 'a 都是有效的限定。最后,生命周期可以被其他生命期所约束:'a: 'b意味着生命期'a'b长。

幻影类型

以下是 Rust 中的一个错误:


#![allow(unused)]
fn main() {
error[E0392]: parameter `T` is never used
 --> src/lib.rs:2:12
  |
2 | struct Foo<T>;
  |            ^ unused parameter
  |
  = help: consider removing `T`, referring to it in a field,
    or using a marker such as `std::marker::PhantomData`
}

Rust 要求使用所有的生命周期和类型参数,因为生成调用析构器的代码需要知道某个特定类型是否拥有一个T。这并不总是理想的,因为有时在你的类型中暴露一个T是很有用的,即使你不拥有它;我们可以使用编译器的建议来解决这个问题:PhantomData。关于如何使用它的更多信息,请参阅类型文档或相关的Rustonomicon条目

智能指针

在Rust中,"智能指针"是任何实现了std::ops::Deref的类型,即解引用操作符 。Deref的定义是这样的。


#![allow(unused)]
fn main() {
trait Deref {
    type Target;
    fn deref(&self) -> &Self::Target;
}
}

实现Deref的类型也可以实现可变体。


#![allow(unused)]
fn main() {
trait DerefMut: Deref {
    fn deref_mut(&mut self) -> &mut Self::Target;
}
}

实现Deref特质给了一个类型T两个特征。

它可以被解除引用。*x成为*(x.deref())*(x.deref_mut())的语法糖,这取决于产生的左值是否被赋值。 它获得了自动的deref:如果x.foo不是T的一个字段或方法,那么它将扩展为x.deref().foox.deref_mut().foo,同样取决于用途。 此外,derefderef_mut的调用是通过做一个明确的重借:&*x&mut *x

智能指针的一个例子是ManuallyDrop<T>。尽管这个类型直接包含了一个T(而不是通过引用),它仍然被称为 "智能指针",因为它可以被解引用以获得里面的T,而且T的方法可以被调用。我们将在后面看到,RefCell<T>类型也会产生智能指针。限制对一个值的访问的泛型包装类型是智能指针,这种情况并不少见。

注意,因为Target是一个关联类型,所以一个类型只能转指到一个其他类型。

虽然与智能指针不太相关,但IndexIndexMut特质类似于 Deref DerefMut特质,它可以实现x[foo]下标语法。Index看起来像这样。


#![allow(unused)]
fn main() {
trait Index<Idx> {
    type Output;
    fn index(&self, index: Idx) -> &Self::Output;
}
}

一个索引操作,很像一个解引用的操作,从x[idx]扩展到*(x.index(idx))。注意,索引操作可以被重载,这也是通过特质重载的一个有用的例子。例如,<[u8] as Index<usize>>::Outputu8,而<[u8] as Index<Range>>::Output[u8]。用单个索引进行索引产生一个字节,而用一个范围进行索引产生另一个切片。

闭包

闭包(有时在其他语言中被称为 "lambda表达式")是捕获其环境的某些部分的函数字面,它可以被传递给其他函数以定制行为。

Closures不是单纯的函数指针,因为这种捕获的状态。在C语言中,与此最相近的是一个函数,它需要一个函数指针和一些 "上下文"。例如,Linux的pthread_create()函数需要一个void* (*start_routine)(void*)参数和一个void* arg参数,arg代表start_routine需要执行的状态。以类似的方式,Rust闭包需要额外的状态来执行,只是arg成为start_routine值的一部分。不仅如此,Rust还会为arg合成一个定制的上下文结构,而通常情况下,程序员需要手动完成这一工作。Rust让这个习语更容易使用,因此也更常见。

正如我们将看到的,Rust有许多不同的闭包ABI,其中一些与pthread_create的做法非常相似;在某些情况下,函数指针及其上下文甚至可以被内联。

在Rust中,创建闭包的语法是|arg1, arg2| expr。它们可以很简单,比如|(k, _)| k(使用模式匹配来提取一个元组的第一个元素),也可以很复杂,使用一个块表达式来创建一个较长的函数。|foo| { /* ... */ } 参数的类型可以选择性地指定为|foo: Foo| { /* ... */ },而返回类型则是|foo| -> Bar { /* ... */ },尽管在几乎所有的情况下,类型推导都可以正确地计算出它们。一个不需要参数的闭包可以写成|| /* ... */.

闭包通过引用来捕获它们的环境;该引用的可变性是通过使用来推导的。比如说。


#![allow(unused)]
fn main() {
let x = /* ... */;
let y = /* ... */;
let f = |arg| {
    x.do_thing(arg);  // Takes &self, so this implicitly produces a shared reference.
    y.do_mut_thing(arg);  // Takes &mut self, so it takes a unique reference instead.
};
// Note: f holds a unique borrow of y.
let z = &mut y;  // Error!
}

上面,f通过共享引用捕获x,通过唯一引用捕获y。实际的闭包值f是一个包含捕获的合成结构体。


#![allow(unused)]
fn main() {
struct MyClosure<'a> {
    x: &'a X,
    y: &'a mut Y,
}
}

调用一个闭包,比如f(),会调用一个合成函数,该函数将MyClosure作为其第一个参数。我们可以通过移动到闭包中来代替捕获;这可以通过移动|arg| { /* ... */ } 语法来实现。如果将其应用于fMyClosure将变成:


#![allow(unused)]
fn main() {
struct MyClosure<'a> {
    x: X,
    y: Y,
}
}

Rust 并不完全支持通过移动捕获和通过引用捕获的混合方式,但通过移动捕获引用的方式可以将它们混合起来。


#![allow(unused)]
fn main() {
let x = /* ... */;
let y = /* ... */;
let x_ref = &x;
let f = move |arg| {
    x_ref.do_thing(arg);  // Capture x_ref by move, aka capture x by shared ref.
    y.do_mut_thing(arg);  // Capture y by move.
};
}

对于Copy类型来说,通过移动捕获和通过引用捕获之间的区别是不重要的。

为了在不同的闭包类型上实现多态性,我们使用了特殊的FnFnMutFnOnce特性。这些代表了可以通过共享引用、唯一引用或移动来调用的函数。只捕获共享引用的闭包实现了所有三种;通过唯一引用捕获的闭包只实现了后两种,而通过移动捕获的闭包只实现了最后一种 。函数指针、函数项 和不捕获的闭包也实现了这三者,并且都可以被转换为函数指针。

这些特质使用类似于函数指针的特殊语法 。例如,Fn(i32) -> i32表示接受一个i32参数并返回另一个i32。如果所有的捕捉器都实现了 CopyClone,那么闭包也实现了 CopyClone

作为函数参数的闭包

编写接受闭包参数的函数大致有两种方式:通过动态分发,或通过静态分发,这两种方式分别对性能和大小有影响。

FnFnMut闭包可以使用trait对象来接受。


#![allow(unused)]
fn main() {
fn my_do_thing(func: &dyn Fn(i32) -> i32) -> i32 {
    func(MY_CONST)
}
}

这与 C 语言的方法完全相同:合成函数住在 trait 对象的 vtable 中,而捕获则在实际 trait 对象指针本身的后面。换句话说。


#![allow(unused)]
fn main() {
struct DynClosure {
    vtable: *mut Vtable,
    captures: *mut Captures,
}
}

当然,vtable 的调用会带来性能上的损失,但避免了泛型实例化的代码大小开销。

使用泛型允许传递实现FnFnMutFnOnce的闭包,方法是为每个函数类型专门设计调用函数。


#![allow(unused)]
fn main() {
fn my_do_thing<F: Fn(i32) -> i32>(func: F) -> i32 {
    func(MY_CONST)
}
}

这将转化为对合成闭包函数的直接调用,没有任何开销,但会为传入的每个闭包重复该函数,如果在大型函数上使用,会导致很大的尺寸冲击。

我们可以使用简洁的方法来声明这种类型的函数,这样可以避免声明一个类型参数。


#![allow(unused)]
fn main() {
fn my_do_thing(func: impl Fn(i32) -> i32) -> i32 { /* ... */ }
}

impl Trait 可以用在函数参数位置,表示 "这个参数可以是任何实现了 Trait的类型",这实际上是声明了一个匿名的 泛型参数。请注意,从技术上讲,Trait 可以是任何涉及至少一个trait的泛型限定:impl Clone + Defaultimpl Clone + 'a都是有效的。

作为函数返回的闭包

闭包类型通常是不可命名的。返回闭包的典型方式是将 impl Trait 放在返回位置。


#![allow(unused)]
fn main() {
fn new_fn() -> impl Fn(i32) -> i32 {
    |x| x * x
}
}

返回位置 impl Trait 意味着 "此函数返回某个实现 Trait 的未指定类型"。函数的调用者不能使用实际的类型,只能使用通过Trait提供的函数。 impl Trait也可以用来隐藏实现细节,当一个返回值只存在于实现某些trait时。

返回位置 impl Trait 有一个主要的注意事项:它不能返回实现该特征的多个类型。例如,下面的代码是一个类型错误。


#![allow(unused)]
fn main() {
fn new_fn(flag: bool) -> impl Fn(i32) -> i32 {
    if flag {
        |_| 0
    } else {
        |x| x * x
    }
}
}

由此产生的编译器错误看起来像这样。


#![allow(unused)]
fn main() {
  = note: expected type `[closure@src/lib.rs:3:5: 3:10]`
          found closure `[closure@src/lib.rs:5:5: 5:14]`
  = note: no two closures, even if identical, have the same type
  = help: consider boxing your closure and/or using it as a trait object
}

在非嵌入式环境中,解决方案(正如编译器所建议的)是在堆上分配闭包,并使用特质对象。然而,在嵌入式上下文中,分配是有限的,所以这个解决方案是不可用的。

如果没有闭包捕获,返回一个函数指针可能是一个可接受的解决方案。


#![allow(unused)]
fn main() {
fn new_fn(flag: bool) -> fn(i32) -> i32 {
    if flag {
        |_| 0
    } else {
        |x| x * x
    }
}
}

作为结构体字段的闭包

如果不能轻松分配,使用闭包作为结构字段是相当有限的。两个选择是:要么让闭包类型的特质泛化(这需要通过使用该结构的所有东西来传播),要么要求闭包不捕获,而是使用函数指针。


#![allow(unused)]
fn main() {
struct MyStruct<F>
where F: Fn(i32) -> i32 {
    val: usize,
    func: F,
}
// Vs.
struct MyStruct {
    val: usize,
    func: fn(i32) -> i32,
}
}

一般来说,函数指针是最简单的,而且没有捕获的要求也不是特别苛刻。泛型变体往往对临时类型更有用,比如组合器。

临时的结构体也可以尝试使用特质对象,但生命周期要求会有相当大的限制。


#![allow(unused)]
fn main() {
struct MyStruct<'a> {
    val: usize,
    func: &'a dyn Fn(i32) -> i32,
}
}

Rust 中的错误处理: Option 和 Result

正如我们在上面看到的,Option 是一个让我们指定一个 "潜在的未初始化的 "值的类型。虽然使用匹配表达式来处理Option是很常见的,但它也有一些方便的函数来缩短常见的代码序列。 is_none()可以用来检查一个Option是否为空;map可以用来转换一个Option里面的值。


#![allow(unused)]
fn main() {
opt.map(|t| t + 1)  // Increments the value inside, if there is one.
}

unwrap_or()可以用来提取里面的值,有一个默认值。


#![allow(unused)]
fn main() {
opt.unwrap_or(42)  // Get the value inside, or the value 42 if the former is unavailable.
}

Option的文档描述了许多其他潜在的用途和对Option的操作:https://doc.rust-lang.org/std/option。这个类型的文档本身有一个为 Option 定义的所有便利函数的完整列表:https://doc.rust-lang.org/std/option/enum.Option.html

Option的一个关键应用是安全的可归零引用。Option<T>。Rust语言保证Option<&T>在ABI层与一个可归零的指针相同 ,所以它可以安全地被传递到C代码中。这种优化也适用于至少包含一个引用的结构:is_none()位将被压缩到该结构的一个引用字段中。其他一些类型也有资格进行内存布局优化,比如NonZeroI32

Result<T, E>Option<T>类似,但它没有 "空 "的状态,而是有 "错误 "的状态。


#![allow(unused)]
fn main() {
enum Result<T, E> {
    Ok(T),
    Err(E),
}
}

Result<T, E> 表示对T类型的值完成了计算,但可能出了问题。E通常是某种枚举类型,因为Rust并没有为所有情况提供单一的错误类型。


#![allow(unused)]
fn main() {
enum MyError {
    DeadlineExceeded,
    BufferExhausted(usize),
    OtherError(ErrorCode),
}
}

使用常见的错误枚举为你的代码定义自定义的 Result 类型是相当普遍的。


#![allow(unused)]
fn main() {
type Result<T> = std::result::Result<T, MyError>;
}

在某种程度上,Option<T>只是一个Result<T, ()>,其中错误类型只是微不足道的单元元组。Rust 提供了一些函数用于它们之间的转换。


#![allow(unused)]
fn main() {
opt.ok_or(error)  // Converts Option<T> into Result<T, E>, using the provided error if
// the Option is empty.
res.ok()  // Discards the error portion and returns an Option<T>.
res.err()  // Discards the ok portion and returns an Option<E>.
}

为其可能失败的副作用而执行的计算,如写操作,倾向于返回 Result<(), E>。这与C语言不同,当函数返回void时,对可能失败的函数的处理是不一致的,因为void不是一个真实的类型。

有时,由于某些特性的接口,有必要为一个不能失败的操作产生一个结果。目前的做法是使用Result<T, std::convert::Infallible>类型,它可以被匹配到如下内容。


#![allow(unused)]
fn main() {
let res: Result<T, Infallible> = /* ... */;
match res {
    Ok(t) => { /* ... */ },
    Err(x) => match x {},
}
}

Result 支持一种特殊的提前返回语法。当在一个返回Result<T, E>的函数中,你有一个Result<U, E>类型的值,表达式res? 将解开 Result,如果它存在,将得到里面的 "ok "值,如果不存在,则立即返回错误。也就是说,res? 被翻译成:


#![allow(unused)]
fn main() {
match res {
    Ok(x) => x,
    Err(e) => return Err(e),
}
}

这个问号操作符可以与方法链在一起,这样就可以写出在第一个错误时提前返回的直接代码,而无需显式控制流。


#![allow(unused)]
fn main() {
let x = my_thing.foo()?.bar()?.baz()?;
}

更多的结果操作见 https://doc.rust-lang.org/std/result/index.html

回顾for : 迭代器

迭代器是任何实现了迭代器特质的类型,它看起来像这样。


#![allow(unused)]
fn main() {
trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}
}

一个迭代器产生一个Option<Item>值的序列;next()方法允许一个迭代器推进一些内部状态,并产生序列中的下一个值。

例如,一个非常简单的迭代器只是依次产生每个非负的整数值。


#![allow(unused)]
fn main() {
struct Counter { state: u64 }
impl Iterator for Counter {
    type Item = u64;
    fn next(&mut self) -> Option<u64> {
        let current = self.state;
        self.state += 1;
        Some(current)
    }
}
}

这个迭代器将永远产生数值:它总是返回Some。一个最终产生None,然后永远返回None的迭代器,被称为 "fused " 。有些迭代器在返回None后可能又开始返回Some,但大多数Rust结构将所有迭代器都视为fused的。

一个相关的特质是IntoIter特质。


#![allow(unused)]
fn main() {
trait IntoIter {
    type Iter: Iterator;
    fn into_iter(self) -> Self::Iter;
}
}

一个IntoIter类型可以被转换成一个迭代器。这个类型被用来驱动我们看到的用于迭代切片的for循环。


#![allow(unused)]
fn main() {
for pattern in expr {
    // ...
}
// is syntactic sugar for
let iter = expr.into_iter();
while let Some(pattern) = iter.next() {
    // ...
}
}

在切片的例子中,&'a [T]实现了IntoIter,它产生了一个迭代器,依次产生切片的每个元素;Range<i32>类型(也就是语法0...32产生的)也实现了IntoIter

所有这些机制允许用户建立自己的迭代器,用于自己类型的for循环,或者使用现有的迭代器和迭代器组合器。迭代器特性定义了几十个提供的方法,这些方法可以用来建立更复杂的迭代器。下面是几个特别有用的组合器的例子。

  • iter.chain(iter2)。将两个具有相同 Item 类型的迭代器链在一起。当 iter 产生 None 时,第二个迭代器开始。
  • iter.peekable()。将迭代器转换为具有.peek()函数的迭代器,该函数返回对序列中下一个值的引用(但不前进)。
  • iter.enumerate()。将 Item 类型从T变为(usize, T),跟踪序列中的当前索引和值。
  • iter.step_by(n)。改变迭代器以返回每n个元素。
  • iter.take(n). 缩短迭代器的长度,在 fuse 前返回n个元素。
  • iter.map(|x| /* ... */) 。Lazy 地对每个元素应用闭包。
  • iter.filter(|x| /* ... */)。对每个元素应用一个谓词;如果谓词返回错误,则被next()跳过。

一些其他的特质(trait)可以增强迭代器的属性,从而实现进一步的方法。ExactSizeIterator迭代器产生一个已知的、固定数量的值;DoubleEndedIterators可以从序列的前部和后部提取元素。虽然上面的许多操作在下一个方面有朴素的实现,但当有更有效的算法时,标准库的迭代器会覆盖它们。一般来说,迭代器可以产生非常高效的代码,类似于while循环所发出的代码,但在使用特别复杂的组合器链时,应该注意。

参见https://doc.rust-lang.org/std/iter/trait.Iterator.htmlhttps://doc.rust-lang.org/std/iter 以了解可用操作的全部细节。

模块 和 Crate 布局

每个Rust crate(从编译器的角度来看)都有一个唯一的、单一标识符的名字。这个名字被用来命名一个 crate 的符号 。core 和 std是 crate。

每个crate 都以lib.rsmain.rs文件为根,这取决于它是一个库还是一个二进制文件。这个文件可以声明模块,这些模块是crate的子命名空间。


#![allow(unused)]
fn main() {
// Declares a public module named `devices`. Its definition is found in
// either `devices.rs` or `devices/mod.rs`, relative to the current file.
pub mod devices;

// Declares a private module named `tests`. Its definition is found
// within the curly braces.
mod tests {
  // ...
}

// Declares a private module named `generated`. Its definition is found
// in the given path.
#[path = "relative/path/to/file.rs"]
mod generated;
}

模块可以任意嵌套:一个模块可以声明更多的模块。

模块中的符号可以通过路径引用:std::mem::drop指的是crate std::mem模块中的符号dropcrate::devices::gpio:Gpio指的是当前crate的devices::gpio模块中的符号Gpio

use项可以用来在当前范围内创建符号别名。


#![allow(unused)]
fn main() {
// Pull in std::mem::drop, aliased to `drop`.
use std::mem::drop;

// Pull in the module std::mem, so we can now write `mem::drop` for `std::mem::drop`.
use std::mem;

// Pull in both size_of and drop:
use std::mem::{size_of, drop};

// Pull in all symbols in `std::mem`, including `drop`. Should typically be best
// avoided, for readability.
use std::mem::*;

// Pull in all symbols from the parent module:
use super::*;

// Pull in a symbol from a submodule (synonymous with using the full
// path starting with `crate`).
use self::devices::Gpio;

// Pull in a name, but rename it.
use std::io::Result as IoResult;

// Pull in a trait to enable its methods, but without pulling its name
// into scope.
use std::io::Write as _;
}

请注意,这是受可见性限制的。默认情况下,所有符号都是 "私有 "的,只在当前模块和它的子模块中可见。这可以明确地拼成pub(self)。一个符号可以用pub(super)限制在父模块和子模块中,也可以用pub(crate)限制在当前的crate中。最后,一个符号可以用pub(in that::path)限制在一个特定的路径上。pub简单地说就是 "完全是公开的"。

几乎所有的项都可以用可见性标记,除了 impl。用可见性标记一个模块会限制其中所有项目的可见性。一个pub(crate)模块中的pub符号就是pub(crate)use语句也可以用可见性标记:这将导致导入的符号成为模块的一部分。例如,std中充满了pub use core::Symbol;的实例,以使core符号能够通过std被导入。

甚至use项也可以被标记为可见性。


#![allow(unused)]
fn main() {
// mod my_mod
pub use std::mem::size_of;
}

这意味着其他模块现在可以通过my_mod::size_of访问符号size_of,有效地重新导出了该符号。许多基本的核心类型也是这样通过std crate访问的。

Rust 没有头文件,也没有声明顺序的限制;一个crate 内的模块可以自由地形成循环的依赖关系,因为它们不是编译的单位,只是命名空间。Rust crate的命名空间是封闭的:在一个crate被完全编译后,没有其他符号可以被添加到其中。

内部可变性

内部可变性是绕过Rust的别名规则的一个借用检查逃生舱。

通常情况下,Rust要求你在改变一个值之前静态地证明你对它有唯一的访问权。UnsafeCell<T>是一种特殊的、被编译器所认可的 类型,它包含一个单一的T,并且有一个方法fn get(&self) -> *mut T。当你在运行时可以保证对UnsafeCell的共享引用实际上是唯一的,由get()返回的原始指针可以被转换为唯一引用。这使得安全地突变代码成为可能,在运行时,已知代码是唯一的。当然,直接使用UnsafeCell是非常不安全的,它的存在是为了形成其他抽象的基础。

有两种安全暴露内部可变性的常见策略:Cell 方式 和RefCell方式。

Cell 方式根本就没有创建一个唯一的引用:相反,它在任何时候都持有一个有效的T,并提供一个交换原语来取出T并留下另一个。这样一来,就不需要执行别名规则了,因为没有引用实际指向那个T

RefCell 方式则在运行时进行基本的借用检查。除了持有一个T之外,RefCell 还持有一个未完成的共享引用的数量的计数器(或者一个未完成的唯一引用的哨位值)。try_borrow()try_borrow_mut()方法动态地检查这种借用是否有效(分别是没有未完成的唯一引用,或者根本没有未完成的引用),并返回一个Result来表示成功或失败。在成功的情况下,返回值是一个包裹着引用的智能指针,其析构器将减少原始 RefCell 中的共享/唯一引用计数。换句话说,RefCell 就像一个单线程的读写mutex,没有原子性的代价。

其他的抽象可以建立在UnsafeCell之上,用其他的策略来维持别名不变性,但它们最终会类似于Cell或RefCell中的一个。

内部可变性也是常量和静态的主要区别之一。


#![allow(unused)]
fn main() {
static S: MyCell<u32> = MyCell::new(0);
const C: MyCell<u32> = MyCell::new(0);

S.set(1);
S.get();  // value = 1, because `set` modified the memory location.
C.set(1);
C.get()  // value = 0, because `set` modified an inlined copy.
}

注意,S后面的内存改变了,所以必须在.data.bss部分分配。这说明了UnsafeCell的另一个特性:它导致本来被声明为不可变的数据被分配为可变的。

更多细节请参见https://doc.rust-lang.org/std/cell/index.html;就像所有与别名相关的主题一样,它需要仔细关注细节,本节还远远没有穷尽。

Unsafe Rust

Unsafe Rust是Rust的一种方言,由关键词unsafe来表示:unsafe block,unsafel 函数,unsafe trait。重要的是,所有这些行为都需要说出关键词unsafe,这样就可以在代码审查中很容易地发现它们。在unsafe 块中的代码向读者表明,程序员已经检查了微妙的安全保证,而编译器自己是无法做到的。

Unsafe 的Rust从根本上说是通过 "关闭 "编译器通常执行的某些检查来实现的,只要在 usnafe { /* ... */ }中,Unsafe Rust可以做的事情是Safe Rust不能做的。

  • 调用 Unsafe 的函数。
  • 对原始指针的解引用。
  • 通过一个可变静态来改变全局状态。
  • 读取Union 字段。
  • 调用asm!宏。

此外,Unsafe的 impl 可以实现 unsafe trait,但不需要在 unsafe 块内。

典型的参考文献是Rustonomicon ,这是一份非规范性文件,描述了Unafe Rust的常见用途。它是嵌入式编程的必读文件(主要是前半部分)。它包含了正确和不正确使用Unsafe Rust的详细例子,以及关于何时调用Unsafe Rust 的指导。

在本文中,提到了Unsafe Rust,主要是围绕调用unsafe 函数和引用原始指针,这大概是Unsafe Rust能做的所有普通Rust不能做的。有了这些能力就有了责任。Unsafe 的Rust对未定义行为并不安全,它可以让机器处于正常安全的Rust所允许的行为会触发未定义行为的状态。一般来说,有几条经验法则是有用的。

  • 每个unsafe fn都应该在文档中声明,它假定调用者会坚持哪些不变性,以及它将使机器处于什么状态。例如,<[T]>::get_unchecked(n)忽略了索引操作的边界检查,而由调用者来维护它。
  • 每当 unsafe 的代码调用到一个unsafe 的函数时,它必须确保在安全的代码中观察不到违反的不变性,这些不变性可能会触发未定义行为。例如,如果我们有一个类型保持着len>0的不变性,而我们在 unsafe 块中暂时将其设置为0,那么在对该类型调用任何安全方法之前,必须将其恢复为>0
  • Unsafe 的代码应该保持在绝对最小的范围内,并且用安全的接口来包装,通过静态类型系统保证或运行时检查来断言不变量。每一行 unsafe 的代码都是浪费了Rust保证的工程成本的地方。

换句话说,Safe Rust能够自由地假设Rust对别名、所有权和值的表示的保证在任何时候都是成立的。这种假设是普遍存在的:不仅编译器使用它来积极优化代码的速度和大小,而且其他库代码,如包装类型的析构器,也这样假设。Unsafe Rust 负责维护这一核心保证。在某种程度上,Unsafe Rust 负责保护 Safe Rust。

可视化项目成员包的调用关系

作者: 吴翱翔


通常一个大型 Rust 项目都会用 cargo workspace 来管理 workspace 下面有多个 member 也叫 package 或者叫 crate

伞结构?

在《张汉东的Rust实战课》鉴赏 Rust 各个知名项目的视频分集中

经常会提到某某项目用的是 伞结构,但是 伞结构 又是什么意思呢?

借助 rust-analyzer 可视化 API 如上图就是 clippy 源码内各个库的依赖关系

可见 clippy_lints package 往下依赖很多子 package 但各个子 package 之间没有任何互相依赖

所以 clippy 源码这种项目就叫 伞结构 通过可视化工具发现确实很像 一把倒立的雨伞

本文介绍基于 rust-analyzer 公有 API 对项目中各个 package 依赖关系进行可视化

导入 rust-analyzer 库

rust-analyzer 的 lsp-server 的工作原理是 vscode 创建 rust-analyzer 子进程

然后 vscode 跟 rust-analyzer 之间通过两个管道借助 STDIN/STDOUT 进行通信

其实 rust-analyzer 还可以作为一个库调用它的 API

由于公开的接口尚未稳定频繁改动,所以 rust-analyzer 并没上传到 crates.io

我们可以将 rust-analyzer 源码下载到本地,在 Cargo.toml 下加上以下内容就可以引入

[dependencies]
# rust-analyzer commit hash dd21ad6a5e8ffa166c97447212d3da0f86555aee
rust-analyzer = { path = "../rust-analyzer/crates/rust-analyzer" }
project_model = { path = "../rust-analyzer/crates/project_model" }
paths = { path = "../rust-analyzer/crates/paths" }
syntax =  { path = "../rust-analyzer/crates/syntax" } # AST
base_db = { path = "../rust-analyzer/crates/base_db" }
hir =  { path = "../rust-analyzer/crates/hir" }
hir_expand =  { path = "../rust-analyzer/crates/hir_expand" }
ide = { path = "../rust-analyzer/crates/ide"  }
ide_db = { path = "../rust-analyzer/crates/ide_db" }
vfs = { path = "../rust-analyzer/crates/vfs" }

load_cargo API 加载需要分析的项目

假设我们想要分析 rust-analyzer 源码中各个模块库的调用关系


#![allow(unused)]
fn main() {
let manifest_path = "/home/w/repos/clone_repos/rust-analyzer/Cargo.toml";
let manifest_path: paths::AbsPathBuf = manifest_path.try_into().unwrap();
let manifest = project_model::ProjectManifest::from_manifest_file(manifest_path).unwrap();
let workspace = project_model::ProjectWorkspace::load(
    manifest,
    &project_model::CargoConfig::default(),
    &|_| {},
)
.unwrap();
}

通过 project_model::ProjectWorkspace::load 加载出 workspace 信息后

此时我们可以遍历打印出该项目的 cargo workspace 下面总共有多少个 crate


#![allow(unused)]
fn main() {
// traverse all cargo_package(members) in cargo_workspace
for package in workspace.to_roots() {
    if !package.is_local {
        continue;
    }
    let package_path: &std::path::Path = package.include[0].as_ref();
    println!("found package {}", package_path.to_str().unwrap());
}
}

接着开始分析项目并生成 graphviz 格式的依赖关系图


#![allow(unused)]
fn main() {
let (analysis_host, _vfs, _proc_macro_srv_opt) =
    rust_analyzer::cli::load_cargo::load_workspace(
        workspace,
        &rust_analyzer::cli::load_cargo::LoadCargoConfig {
            load_out_dirs_from_check: false,
            with_proc_macro: false,
            prefill_caches: false,
        },
    )
    .unwrap();
let analysis = host.analysis();

// graphviz 文件格式的 dot 图
let is_include_std_and_dependencies_crate = false;
let dot: String = analysis
    .view_crate_graph(is_include_std_and_dependencies_crate)
    .unwrap()
    .unwrap();

// 再把 dot 图字符串写入到文件中
let file_path = concat!(env!("CARGO_MANIFEST_DIR"), "/target/graph.gv");
let mut f = std::fs::OpenOptions::new()
    .write(true)
    .create(true)
    .truncate(true)
    .open(file_path)
    .unwrap();
std::io::Write::write_all(&mut f, dot.as_bytes()).unwrap();

// 最后调用 xdot 可视化 graphviz
let is_success = std::process::Command::new("xdot")
    .arg(file_path)
    .spawn()
    .unwrap()
    .wait()
    .unwrap()
    .success();
assert!(is_success);
}

更简单的可视化方法

我在阅读 rust-analyzer 源码后发现其实 rust-analyzer 本身就提供 "crate graph" 的 vscode 如下图

基于Poem的OpenAPI服务端框架

作者: 孙黎


目录

大家好,我是老油条,一个热爱Rust语言的码农。上个月我决定开发一个新的Web框架Poem,当整个框架基本成型之后,我觉得应该给它添加别的框架所不具备的并且很有用的功能,所以我开发了Poem-openapi

简介

OpenAPI规范为RESTful API定义了一个标准的并且与语言无关的接口,它允许人类和计算机在不访问源代码、文档或通过网络流量检查的情况下发现和理解服务的功能。调用者可以很容易的理解远程服务并与之交互,并提供了一些好用的工具,例如 Swagger UI (在网页中浏览测试测试API),Swagger CodeGen (生成多种语言的客户端SDK)。

Poem-openapi是基于PoemOpenAPI 服务端框架。

通常,如果你希望让你的API支持该规范,首先需要创建一个 接口定义文件 ,然后再按照接口定义编写对应的代码。或者创建接口定义文件后,用 Swagger CodeGen 来生成服务端代码框架。但Poem-openapi区别于这两种方法,它让你只需要编写Rust的业务代码,利用过程宏来自动生成符合OpenAPI规范的接口和接口定义文件(这相当于接口的文档),和我之前开源的另外一个库Async-graphql 的原理很像,OpenAPIGraphQL是互补的关系,它们适用于不同的场景。

有的朋友可能觉得宏很可怕,它会让代码难以理解,但我觉得如果能用正确的方法来实现过程宏,那么它可以帮我们大大提升开发的效率,所以Poem-openapi过程宏的实现遵循了以下几个原则:

  1. 你永远都不会直接用到过程宏生成的任何东西。(因为IDE无法识别过程宏生成的代码,如果直接使用它们,可能会有烦人的红色下划线,并且自动完成也无法使用,相当于让IDE变成了一个文本编辑器)
  2. 如果你的代码无法通过编译,那么你的接口不符合OpenAPI规范。(尽量把所有的问题都暴露在编译阶段)
  3. 不自己发明DSL。(如果我的代码没法被Rustfmt格式化,这会让我相当恼火)
  4. 不带来额外的开销。(你完全可以纯手工打造符合OpenAPI规范的接口,但在执行效率上通常没有任何的提升)

快速开始

下面这个例子,我们定义了一个路径为/hello的API,它接受一个名为name的URL参数,并且返回一个字符串作为响应内容。name参数的类型是Option<String>,意味着这是一个可选参数。

运行以下代码后,用浏览器打开http://localhost:3000就能看到Swagger UI,你可以用它来浏览API的定义并且测试它们。

use poem::{listener::TcpListener, route};
use poem_openapi::{payload::PlainText, OpenApi, OpenApiService};

struct Api;

#[OpenApi]
impl Api {
    #[oai(path = "/hello", method = "get")]
    async fn index(
        &self,
        #[oai(name = "name", in = "query")] name: Option<String>, // in="query" 说明这个参数来自Url
    ) -> PlainText<String> { // PlainText是响应类型,它表明该API的响应类型是一个字符串,Content-Type是`text/plain`
        match name {
            Some(name) => PlainText(format!("hello, {}!", name)),
            None => PlainText("hello!".to_string()),
        }
    }
}

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
    // 创建一个TCP监听器
    let listener = TcpListener::bind("127.0.0.1:3000");
  
    // 创建API服务
    let api_service = OpenApiService::new(Api)
        .title("Hello World")
        .server("http://localhost:3000/api");
  
    // 开启Swagger UI
    let ui = api_service.swagger_ui("http://localhost:3000");

    // 启动服务器,并指定api的根路径为 /api,Swagger UI的路径为 /
    poem::Server::new(listener)
        .await?
        .run(route().nest("/api", api_service).nest("/", ui))
        .await
}

这是poem-openapi的一个例子,所以你也可以直接执行以下命令来验证:

git clone https://github.com/poem-web/poem
cargo run --bin example-openapi-hello-world

基础类型

基础类型可以作为请求的参数,请求内容或者请求响应内容。Poem定义了一个Type trait,实现了该trait的类型都是基础类型,它们能在运行时提供一些关于该类型的信息用于生成接口定义文件。

Poem为大部分常用类型实现了Typetrait,你可以直接使用它们,同样也可以自定义新的类型,但你需要对 Json Schema 有一定了解(这并不难,事实上在写这个库之前我也只会Json Schema的一些简单用法,并没有进行过深入的了解)。

下表是Json Schema中的数据类型对应的Rust数据类型(只是一小部分):

Json SchemaRust
{type: "integer", format: "int32"}i32
{type: "integer", format: "float32"}f32
{type: "string" }String, &str
{type: "string", format: "binary" }Binary
{type: "string", format: "bytes" }Base64
{type: "array" }Vec

对象类型

用过程宏Object来定义一个对象,对象的成员必须是实现了Type trait的类型(除非你用#[oai(skip)]来标注它,那么序列化和反序列化时降忽略该字段用默认值代替)。

用以下代码定义了一个对象类型,它包含四个字段,其中有一个字段是枚举类型。

对象类型也是基础类型的一种,它同样实现了Type trait,所以它也可以作为另一个对象的成员。


#![allow(unused)]
fn main() {
use poem_api::{Object, Enum};

#[derive(Enum)]
enum PetStatus {
    Available,
    Pending,
    Sold,
}

#[derive(Object)]
struct Pet {
    id: u64,
    name: String,
    photo_urls: Vec<String>,
    status: PetStatus,
}
}

定义API

下面定义一组API对宠物表进行增删改查的操作。

add_petupdate_pet用于添加和更新Pet对象,这是我们在之前定义的基本类型,基本类型不能直接作为请求内容,需要使用一个Payload类型来包装它,这样就可以确定内容的Content-Type。在下面的例子中,我们使用payload::Json来包装它,表示这两个API请求内容的Content-Typeapplication/json

find_pet_by_idfind_pets_by_status用于查找Pet对象,它们的响应也是一个Pet对象,同样需要使用Payload类型来包装。

我们可以用#[oai(name = "...", in = "...")]来修饰一个函数参数用于指定此参数值的来源,in的值可以是query, path, header, cookie四种类型。delete_petid参数从路径中提取,find_pet_by_idfind_pets_by_status的参数从Query中获取。如果参数类型不是Option<T>,那么表示这个参数不是一个可选参数,提取失败时会返回400 Bad Request错误。

你可以定义多个函数参数,但只能有一个Payload类型作为请求内容,或者多个基本类型作为请求的参数。


#![allow(unused)]
fn main() {
use poem_api::{
  OpenApi,
  poem_api::payload::Json,
};
use poem::Result;

struct Api;

#[OpenApi]
impl Api {
    /// 添加新Pet
    #[oai(path = "/pet", method = "post")]
    async fn add_pet(&self, pet: Json<Pet>) -> Result<()> {
        todo!()
    }
  
    /// 更新已有的Pet
    #[oai(path = "/pet", method = "put")]
    async fn update_pet(&self, pet: Json<Pet>) -> Result<()> {
        todo!()
    }

    /// 删除一个Pet
    #[oai(path = "/pet/:pet_id", method = "delete")]
    async fn delete_pet(&self, #[oai(name = "pet_id", in = "path")] id: u64) -> Result<()> {
        todo!()
    }
  
    /// 根据ID查询Pet
    #[oai(path = "/pet/:pet_id", method = "delete")]
    async fn find_pet_by_id(&self, #[oai(name = "status", in = "query")] id: u64) -> Result<Json<Pet>> {
        todo!()
    } 
  
    /// 根据状态查询Pet
    #[oai(path = "/pet/findByStatus", method = "delete")]
    async fn find_pets_by_status(&self, #[oai(name = "status", in = "query")] status: Status) -> Result<Json<Vec<Pet>>> {
        todo!()
    }
}

}

自定义请求

OpenAPI规范允许同一个接口支持处理不同Content-Type的请求,例如一个接口可以同时接受application/jsontext/plain类型的Payload,你可以根据不同的Content-Type分别做处理。

Poem-openapi中,要支持此类型请求,需要用ApiRequest宏自定义一个实现了Payload trait的请求对象。

create_post函数接受CreatePostRequest请求,当创建成功后,返回id


#![allow(unused)]
fn main() {
use poem_open::{
    ApiRequest, Object,
    payload::{PlainText, Json},
};
use poem::Result;

#[derive(Object)]
struct Post {
    title: String,
    content: String,
}

#[derive(ApiRequest)]
enum CreatePostRequest {
    /// 从JSON创建
    Json(Json<Blog>),
    /// 从文本创建
    Text(PlainText<String>),
}

struct Api;

#[OpenApi]
impl Api {
    #[oai(path = "/hello", method = "post")]
    async fn create_post(
        &self,
        req: CreatePostRequest,
    ) -> Result<Json<u64>> {
        // 根据Content-Type分别处理
        match req {
            CreatePostRequest::Json(Json(blog)) => {
                todo!();
            }
            CreatePostRequest::Text(content) => {
                todo!();
            }
        }
    }
}

}

自定义响应

在前面的例子中,我们的所有请求处理函数都返回的Result类型,当发生错误时返回一个poem::Error,它包含错误的原因以及状态码。但OpenAPI规范允许更详细的描述请求的响应,例如该接口可能会返回哪些状态码,以及状态码对应的原因和响应的内容。

下面的我们修改create_post函数的返回值为CreateBlogResponse

OkForbiddenInternalError描述了特定状态码的响应类型。


#![allow(unused)]
fn main() {
use poem_openapi::ApiResponse;
use poem::http::StatusCode;

#[derive(ApiResponse)]
enum CreateBlogResponse {
    /// 创建完成
    #[oai(status = 200)]
    Ok(Json<u64>),
    
    /// 没有权限
    #[oai(status = 403)]
    Forbidden,
  
    /// 内部错误
    #[oai(status = 500)]
    InternalError,
}

struct Api;

#[OpenApi]
impl Api {
    #[oai(path = "/hello", method = "get")]
    async fn create_post(
        &self,
        req: CreatePostRequest,
    ) -> CreateBlogResponse {
        match req {
            CreatePostRequest::Json(Json(blog)) => {
                todo!();
            }
            CreatePostRequest::Text(content) => {
                todo!();
            }
        }
    }
}
}

当请求解析失败时,默认会返回400 Bad Request错误,但有时候我们想返回一个自定义的错误内容,可以使用bad_request_handler属性设置一个错误处理函数,这个函数用于转换ParseRequestError到指定的响应类型。


#![allow(unused)]
fn main() {
use poem_openapi::{
    ApiResponse, Object, ParseRequestError, payload::Json,
};

#[derive(Object)]
struct ErrorMessage {
    code: i32,
    reason: String,
}

#[derive(ApiResponse)]
#[oai(bad_request_handler = "bad_request_handler")]
enum CreateBlogResponse {
    /// 创建完成
    #[oai(status = 200)]
    Ok(Json<u64>),
    
    /// 没有权限
    #[oai(status = 403)]
    Forbidden,
  
    /// 内部错误
    #[oai(status = 500)]
    InternalError,
    
    /// 请求无效
    #[oai(status = 400)]
    BadRequest(Json<ErrorMessage>),
}

fn bad_request_handler(err: ParseRequestError) ->   CreateBlogResponse {
    // 当解析请求失败时,返回一个自定义的错误内容,它是一个JSON
    CreateBlogResponse::BadRequest(ErrorMessage {
        code: -1,
        reason: err.to_string(),
    })
}
}

文件上传

Multipart通常用于文件上传,它可以定义一个表单来包含一个或者多个文件以及一些附加字段。下面的例子提供一个创建Pet对象的接口,它在创建Pet对象的同时上传一些图片文件。


#![allow(unused)]
fn main() {
use poem_openapi::{Multipart, OpenApi}
use poem::Result;

#[derive(Debug, Multipart)]
struct CreatePetPayload {
    name: String,
    status: PetStatus,
    protos: Vec<Upload>, // 多个照片文件
}

struct Api;

#[OpenApi]
impl Api {
    #[oai(path = "/pet", method = "post")]
    async fn create_pet(&self, payload: CreatePetPayload) -> Result<Json<u64>> {
        todo!()
    }
}
}

完整的代码请参考例子

参数校验

OpenAPI引用了Json Schema的校验规范,Poem-openapi同样支持它们。你可以在请求的参数,对象的成员和Multipart的字段三个地方应用校验器。校验器是类型安全的,如果待校验的数据类型和校验器所需要的不匹配,那么将无法编译通过。例如maximum只能用于数值类型,max_items只能用于数组类型。更多的校验器请参考文档


#![allow(unused)]
fn main() {
use poem_openapi::{Object, OpenApi, Multipart};

#[derive(Object)]
struct Pet {
    id: u64,

    /// 名字长度不能超过32
    #[oai(max_length = "32")]
    name: String,

    /// 数组长度不能超过3
    #[oai(max_items = "3")]
    photo_urls: Vec<String>,

    status: PetStatus,
}
}

认证

OpenApi规范定义了apikeybasicbeareroauth2openIdConnect五种认证模式,它们描述了指定的API接口需要的认证参数。

注意:API的认证信息最主要的用途是让Swagger UI在测试该API时能够正确的执行认证流程。

下面的例子是用Github登录,并提供一个获取所有公共仓库信息的接口。


#![allow(unused)]
fn main() {
use poem_openapi::{
    SecurityScheme, SecurityScope, OpenApi,
    auth::Bearer,
};

#[derive(OAuthScopes)]
enum GithubScope {
    /// access to public repositories.
    #[oai(rename = "public_repo")]
    PublicRepo,

    /// access to read a user's profile data.
    #[oai(rename = "read:user")]
    ReadUser,
}

/// Github authorization
#[derive(SecurityScheme)]
#[oai(
    type = "oauth2",
    flows(authorization_code(
        authorization_url = "https://github.com/login/oauth/authorize",
        token_url = "https://github.com/login/oauth/token",
        scopes = "GithubScope",
    ))
)]
struct GithubAuthorization(Bearer);

struct Api;

#[OpenApi]
impl Api {
    #[oai(path = "/repo", method = "get")]
    async fn repo_list(
        &self,
        #[oai(auth("GithubScope::PublicRepo"))] auth: GithubAuthorization,
    ) -> Result<PlainText<String>> {
        // 使用GithubAuthorization得到的token向Github获取需要的数据
        todo!()
    }
}
}

完整的代码请参考例子

总结

当你读到这里时候,恭喜你已经掌握了Poem-openapi的大部分用法,使用它开发API接口比直接使用Poem这样通用Web框架更加的方便,并且它并不是独立于Poem的另外一套框架,你可以很容易复用现有的提取器,中间件等组件。

有用的链接

密码协议算法与Rust生态

  • 作者: Sylvain Kerkour
  • 译者: 王江桐(华为)

原文: https://kerkour.com/blog/rust-cryptography-ecosystem/


密码协议算法

本章将会简单介绍密码协议算法中常见概念,此篇Rust生态简介中涉及的算法,以及一些常用的相关算法。

安全目标

通常来说,通过加密方式,对于信息的传输,我们希望达成以下五个目标:

  • 机密性(Confidentiality): 保证信息私密性和保密性;
  • 真实性(Authentication): 确保信息是正确的人发来的;
  • 完整性(Integrity): 信息没有被篡改;
  • 接入控制(Access control): 避免资源滥用;
  • 可获性(Availability): 资源可以被使用。

不同的攻击方式可能针对于不同的目标进行攻击。比如DoS(拒绝服务Denial of Service)攻击就是针对可获性进行的攻击,使计算机或网络无法提供正常的服务。

算法概述

如果对常见密码算法做一个分类,那么结构图大概如下所示:

1

对称加密算法

对称加密(也叫私钥加密)指加密和解密使用相同密钥的加密算法。换句话来说,通讯双方使用相同的密钥。有时又叫传统密码算法,就是加密密钥能够从解密密钥中推算出来,同时解密密钥也可以从加密密钥中推算出来。而在大多数的对称算法中,加密密钥和解密密钥是相同的,所以也称这种加密算法为秘密密钥算法或单密钥算法。它要求发送方和接收方在安全通信之前,商定一个密钥。对称算法的安全性依赖于密钥,泄漏密钥就意味着任何人都可以对他们发送或接收的消息解密,所以密钥的保密性对通信的安全性至关重要。

对称加密算法的优点在于计算量小,加密速度快,加密效率高,以及使用长密钥时的难破解性。

对称加密算法的缺点在于,通讯双方如何安全地商定、交换、保管密钥是个难题。如果密钥泄漏,双方的所有通讯都不再安全。此外,每对用户使用对称加密算法时,都需要使用其他人不知道的惟一钥匙,这会使得发收信双方所拥有的钥匙数量呈几何级数增长。同时,由于双方的密钥是一样的,对称加密算法没有办法提供签名功能。

综上所述,对称加密算法可以保障信息的机密性与完整性,但是无法保障信息的真实性。对称加密算法除了算法本身以外,还需要约定密钥/生成密钥,交换密钥,然后再通过算法进行加解密。

关于密钥生成:

密钥派生函数(KDF,Key Derivation Function)可以从一个共享的秘密比特串中派生出需要的密钥数据。在密钥协商过程中,密钥派生函数作用在密钥交换所获共享的秘密比特串上,从中产生所需的会话密钥或进一步加密所需的密钥数据。

HKDF(HMAC-based key derivation function)在RFC 5869中提出,并于1997年作为RFC2104被公布,是基于HMAC的KDF,可以应用于各种协议(如TLS,RPF)和应用程序的构建。HKDF在逻辑上由两个部分组成。第一个部分采用输入密钥材料和“提取”,它是一个固定长度的伪随机密钥K。第二部分则将密钥扩展为几个随机密钥,并以我们需要的长度输出。

对称加密算法常用于:

  • 保证信息机密性
  • 验证加密(AEAD)
  • 计算MAC
  • SSH,SSL/TLS等网络协议中使用,在网络协议中传输信息

常见的以及此篇Rust生态简介中涉及的密钥生成算法有:

  • blake2b
  • HMAC_SHA
  • PBKDF2(Password-Based Key Derivation Function2)
  • Argon
  • bcrypt, scrypt, SHA-crypt

常见的以及此篇Rust生态简介中涉及的密钥交换算法有:

  • Diffi-Hellman
    • X25519:定义在椭圆曲线Curve25519之上的DH协议

常见的以及此篇Rust生态简介中涉及的对称加密算法有:

  • 流加密
    • RC4
    • chacha20, xsalsa20
  • 分组加密
    • 算法
      • DES,3DES 与 AES
      • BlowFish
      • Deoxys
      • EAS
      • rabbit
      • hc256
    • 模式
      • ECB(Electronic Code Book)/ 电码本
      • CBC(Cipher Block Chaining)/ 密文分组链接
      • OFB(Output Feedback)/ 输出反馈
      • CFB(Cipher Feedback)/ 密文反馈
      • CTR(Counter)/ 计算器模式
      • GCM ( Galois/Counter Mode)
      • CCM(Counter with CBC-MAC)
密钥生成算法
blake2b

blake2b在2012提出,列于RFC-7693,用来替代MD5和SHA1,并且在速度上优于它们。常应用于区块链数字货币(decred, sia, verge,etc.)。

PBKDF2

PBKDF2(Password-Based Key Derivation Function 2)定义在RFC2898 Sec 5.2,测试集定义在RFC6070,同时NIST Special Publication800-132也有关于PBKDF2算法的介绍。PBKDF2算法通过多次hash来对密码进行加密。原理是通过password和salt进行hash,然后将结果作为salt在与password进行hash,多次重复此过程,生成最终的密文。此过程可能达到上千次,逆向破解的难度太大,破解一个密码的时间可能需要几百年,所以PBKDF2算法是安全的。IBM Security Directory Server使用了PBKDF2算法。

密钥交换算法:Diffi-Hellman

Diffie-Hellman算法是由Whitfield Diffie和Martin Hellman在1976年公布的一种密钥一致性算法。Diffie-Hellman是一种双方约定密钥的方法,而不是加密方法。Diffie-Hellman密钥交换算法及其优化首次发表的公开密钥算法出现在Diffie和Hellman的论文中,这篇影响深远的论文奠定了公开密钥密码编码学。

Diffie-Hellman算法的问题在于没有验证双方身份的信息,同时由于需要密集计算幂,容易遭受阻塞性攻击。Oakley算法是对Diffie-Hellman密钥交换算法的优化。

算法如图所示:

2

对称加密算法
流加密:ChaCha20与xsalsa20

ChaCha系列流密码,作为salsa密码的改良版,具有更强的抵抗密码分析攻击的特性,“20”表示该算法有20轮的加密计算。ChaCha由Daniel J. Bernstein在2005年提出,并于2008年提交至欧盟密码验证过程项目eSTREAM。由于是流密码,故以字节为单位进行加密,安全性的关键体现在密钥流生成的过程,即所依赖的伪随机数生成器(PRNG)的强度,加密过程即是将密钥流与明文逐字节异或得到密文,反之,解密是将密文再与密钥流做一次异或运算得到明文。

ChaCha运行速度通常快于AES。ChaCha20的密码强度是256 bits。

ChaCha20Poly1305详见以下MAC - Poly1305章节。Poly1305也由Bernstein提出,ChaCha20-Poly1305的使用已在RFC 7905中标准化。

xsalsa20是基于salsa20的流加密,免疫时序攻击。Salsa20的nonce只有64bit,xsalsa20使用192位的nonce。可用于私钥加密与私钥验证加密。

分组加密:DES,3DES与AES

DES(Data Encryption Standard)是AES(Advanced Encryption Standard,也称Rijndael加密法)的前身,都由美国联邦政府的国家标准局提出并确定为加密标准。3DES(Triple Data Encryption Algorithm)是对DES的改进,相当于是对每个数据块应用三次DES加密算法,是DES向AES过渡的加密算法。三者的安全性逐渐递增,然而DES与3DES已被NIST证实有安全性问题,在如今计算机的算力下已经可以利用有限资源被破解,因此不再赘述。

AES由美国联邦政府于2001年11月26日发布于FIPS PUB 197,并在2002年5月26日成为有效的标准。AES的区块长度固定为128 bits,192 bits,或256 bits,对应的名字分别为AES128,AES192,AES256。它的安全性由密钥位数决定,128位及以上的密钥长度可以保证算法不能通过当前有限资源破译。

AES的优势在于,它的计算非常快,因此一般用于对效率有要求的实时数据加密通信,比如在使用 VPN 或者代理进行加密通信时。

虽然算法本身是安全的,但是可能通过在特定场景下的旁路攻击(Side Channel Attack)被破译密匙。同时,作为对称加密算法,密钥传输与保管也是使用AES的潜在问题。

具体算法可见链接:高级加密标准 - 维基百科,自由的百科全书 (wikipedia.org)

对称加密算法应用:MAC与HMAC

MAC(Message Authentication Code)也称消息认证码(带密钥的Hash函数)。密码学中,通信实体双方使用的一种验证机制,保证消息数据完整性的一种工具。构造方法由M.Bellare提出,安全性依赖于Hash函数,故也称带密钥的Hash函数。消息认证码是基于密钥和消息摘要所获得的一个值,可用于数据真实性认证和完整性校验。

HMAC是密钥相关的哈希运算消息认证码(Hash-based Message Authentication Code),由H.Krawezyk,M.Bellare,R.Canetti于1996年提出的一种基于Hash函数和密钥进行消息认证的方法,并于1997年作为RFC2104被公布,并在IPSec和其他网络协议(如SSL)中得以广泛应用,现在已经成为事实上的Internet安全标准。它可以与任何迭代散列函数捆绑使用。

MAC与HMAC的安全性来源于现有计算不可能找到冲突(Collision)结果来伪造mac。换句话说,对于已知输入x与其MAC结果M(x),现有计算无法找到另一个输入x'使得M(x') == M(x)。

MAC/HMAC中计算常用以及此篇Rust生态简介中涉及的Hash函数有:

  • SHA(SHA0,SHA1,SHA2:SHA224/256/384/512)
  • SipHash
  • MD系列(e.g. MD5)
  • blake2, blake3
  • FSB
  • gost94
  • groestl
  • k12
  • ripemd160/256/320
  • shabal
  • SM3
  • Server relief

MAC/HMAC以及此篇Rust生态简介中涉及的算法有:

  • Poly1305
Hash: SHA

SHA(Secure Hash Algorithm,安全散列算法) 是美国国家安全局 (NSA) 设计,美国国家标准与技术研究院 (NIST) 发布,FIPS所认证的一系列密码散列函数。在TLS、SSL、PGP、SSH、IPsec等网络协议与HMAC计算等中广泛使用。SHA0与SHA1已被证实不再安全,顾不再在赘述。

SHA224/256/384/512有时并称为SHA2,是SHA0与SHA1的后继者,在FIPS180-2中提出。

Hash: SipHash

Siphash是一种伪随机函数,也称散列函数),由Jean-Philippe Aumasson and Daniel J. Bernstein在2012年于“SipHash: a fast short-input PRF” 中提出。该论文发表于INDOCRYPT 2012。Siphash与SHA等密码散列函数(cryptographic hash functions: a mathematical algorithm)计算方式不同。在防止冲突上优于SHA:除非SipHash的整个值域都被使用,对于相对小的应用(e.g. 在hash map中通过hash function生成索引)可以保证没有冲突,由此可以防止DOS攻击;SHA等算法则无法保证没有冲突。

Siphash的优势在于运算简单快速以及算法上安全,应用于操作系统(Linux kernel, OpenBSD, FreeBSD),语言库(OpenSSL libcrypto, Sodium, etc.)与一些应用(Wireguard, Redis, etc.)的Hash计算。

然而SipHash并不是传统意义上不需要密钥的hash函数,为了达成安全性,它需要密钥来进行计算。

Hash: MD5

MD5 Message-Digest Algorithm,一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。这套算法的程序在 RFC 1321 标准中被加以规范。1996年后该算法被证实存在弱点,可以被加以破解,对于需要高度安全性的数据,专家一般建议改用其他算法,如SHA-2。2004年,证实MD5算法无法防止碰撞(collision),因此不适用于安全性认证,如SSL公开密钥认证或是数字签名等用途。

MAC/HMAC: Poly1305

Poly1305是Daniel.J.Bernstein创建的消息认证码,可用于检测消息的完整性和验证消息的真实性,现常在网络安全协议(SSL/TLS)中与salsa20或ChaCha20流密码结合使用。ChaCha20-Poly1305是Google所采用的一种新式加密算法,性能强大,在CPU为精简指令集的ARM平台上尤为显著,在同等配置的手机中表现是AES的4倍(ARM v8之后加入了AES指令,所以在这些平台上的设备,AES方式反而比chacha20-Poly1305方式更快,性能更好)。ChaCha20-Poly1305的使用已在RFC 7905中标准化。谷歌选择了ChaCha20和伯恩斯坦的Poly1305消息认证码取代过去一直在互联网安全领域使用的基于OpenSSL的RC4密码,并应用在OpenSSH中的ChaCha20-Poly1305新算法中。

Poly1305消息认证码的输入为32字节(256bit)的密钥和任意长度的消息比特流,经过一系列计算生成16字节(128bit)的摘要。不依赖AES的Poly1305变种已由互联网工程任务组在RFC 8439中标准化。

非对称加密算法/公开密钥算法

非对称加密算法又称公开密钥算法。顾名思义,每个人都有一对唯一对应的密钥:公开密钥(简称公钥)和私人密钥(简称私钥),公钥对外公开,私钥由个人秘密保存;用其中一把密钥加密,就只能用另一把密钥解密。非对称加密算法的典型代表是RSA。非对称加密的安全性通常依赖于算法在数学上的复杂程度,通常涉及到的底层数学问题在难度上是NP complete或NP hard,目前这类问题在计算复杂度理论上仍无证明表明,它们可以像P等级问题一样,在多项式时间内被解决。换句话来说,非对称加密涉及到的底层数学问题无法通过利用当前的有限资源来求解,例如整数因子分解问题(RSA)与离散对数问题(DSA,椭圆曲线)。

非对称加密算法实现机密信息交换的基本过程是:接收方生成一对密钥并将公钥公开,需要向接收方发送信息的其他角色(发送方)使用该密钥(接收方的公钥)对机密信息进行加密后再发送给接收方;接收方再用自己私钥对加密后的信息进行解密。接收方想要回复发送方时正好相反,使用发送方的公钥对数据进行加密,同理,接收方使用自己的私钥来进行解密。

非对称加密算法的优点在于,它解决了对称加密算法在密钥上的困难,消除了用户交换密钥的需要。非对称加密算法的安全性依赖于私钥的私密保管,比起对称加密算法,提升了安全性。

非对称加密算法的缺点在于,由于安全性依赖于复杂的算法强度,使得加解密速度没有对称加密算法的速度快,依赖于算法的不同,有时甚至慢数千倍。

综上所述,对称加密算法可以保障信息的机密性与完整性,同时由于公钥和私钥的存在,也可以保障信息的真实性。

非对称加密算法常用于:

  • 保证信息机密性
  • 数字签名与证书
  • 由于以上用例,也常用于网络安全机制(SSL/TLS,VPN,etc.)

常见的以及此篇Rust生态简介中涉及的非对称加密算法有:

  • RSA
  • DSA
  • 椭圆曲线:Curve25519,P256/384, BP256/384, k256, SM2
  • Elgamal
  • Rabin

常见的以及此篇Rust生态简介中涉及的数字签名算法有:

  • ECDSA:ED25519
非对称加密算法
RSA

RSA是1977年由Ron Rivest、Adi Shamir和Leonard Adleman一起提出的,RSA就是他们三人姓氏开头字母拼在一起组成的。1983年麻省理工学院在美国为RSA算法申请了专利。RSA允许你选择公钥的大小,512位的密钥被视为不安全的,768位的密钥不用担心受到除了国家安全管理(NSA)外的其他事物的危害,1024位的密钥几乎是安全的。RSA在一些主要产品内部都有嵌入,像 Windows、网景 Navigator、 Quicken和 Lotus Notes。

RSA属于PKCS(Public-Key Cryptography Standards) #1。PKCS #1由RSA实验室发布,提供了RSA算法的定义和实现方式。PKCS #1 v2.1作为RFC 3447公布,目前最新版本为v2.2。

椭圆曲线:Curve25519

Curve25519是一种椭圆曲线加密算法,基于蒙哥马利曲线,由美国国家标准与技术研究院NIST在RFC 7748正式公布,提供128 bits的安全性。它的密钥大小是256 bits。自从2014年以后,OpenSSH的ECDH(Elliptic Curve Diffie-Hellman)默认使用Curve25519。2018年,RFC8446作为新的TLS v1.3标准公布,强制要求X25519,ED25519,X448和ED448算法。

X25519与X448定义了ECDH密匙交换中的DH协议。

ED25519与ED448则是定义在Curve25519与Curve448之上的数字签名算法。ED25519是一种EDDSA算法,是一种在扭曲爱德华兹曲线(Twisted Edwards curves)上使用 Schnorr 机制来实现的一种数字签名机制,优势在于高效以及高安全性。

25519系列曲线是目前最快的椭圆曲线加密算法。

Rust生态与库

为什么Rust std中没有密码库

“为什么Rust std中没有密码库”中非常简明扼要地解释了原因,大家可以去内部论坛阅读完整文章。如果再次概述一下的话,主要原因在于,要保证标准库保持小型以及稳定,同时满足安全库的标准需要很多时间和精力。然而,社区核心成员并没有这么多时间和精力来投入开发,并且由于各种各样的原因, 成立社区加解密工作组的计划也失败了。

虽然如此,社区上也有很多安全相关的库,比如Rust-openssl、ring和rustcrypto。但是rust-openssl被指出代码质量不好,并且缺少文档;ring则存在大量的汇编代码和C代码 ,不便于后期维护和开发;rustcrypto由纯rust实现,大概是目前社区中加解密算法最为完善的安全库了。

Rust的密码生态

根据“Empirical Study of Vulnerabilities in Cryptographic Libraries”,在现有的流行密码相关库中,37.2%系统级漏洞的根源在于内存问题,27.2%才是算法本身的问题。同时,过于复杂的密码系统使得安全问题更容易产生,问题被发现的周期也很长,中位数在4.18年。以C的OpenSSL库为例,每一千行就会有一个攻击点,预示大型的C/C++代码或许存在潜在问题。作为一门以内存安全为特色的语言,Rust编写的三方密码库或许能解决许多C和C++密码库难以解决的问题。

本期Rust周报推文“Overview of the Rust cryptography ecosystem”主要介绍的库有:

  • Sodiumoxide
  • Ring
  • Delek
  • rust-crypto
  • Rustls

Sodiumoxide

Sodiumoxide是C中非常流行的密码库libsodium的Rust封装,应用于Discord, apache, tuweni, Abot(Slack), RavenDB, Wordpress等项目。libsodium fork了C密码库NaCl,因此Sodiumoxide的大多数文档来源于NaCl。Sodiumoxide的性能和通用性自不用说,但是由于它是libsodium的封装,存在“Empirical Study of Vulnerabilities in Cryptographic Libraries”一文中提到的C与C++库的维护问题。同时,Sodiumoxide已不再继续维护。

3

Sodiumoxide实现的算法有:

  • 对称加密算法
    • 验证加密:aes256gcm, chacha20poly1305
    • 密钥生成:blake2b
    • 密钥交换:x25519blake2b
  • 非对称加密算法
    • curve25519xsalsa20poly1305
  • HMAC:hmacsha256/512
  • HASH:SipHasher13,SHA256/512

Ring

Ring被设计为适用于小型设备、微控制器和IoT应用,编写语言上包含了Rust、C和汇编语言。

4

Ring实现的算法有:

  • 对称加密算法
    • 验证加密:aes128/256gcm, chacha20poly1305
    • 密钥生成:HKDF_SHA256/384/512,PBKDF2_HMAC_SHA1,PBKDF2_HMAC_SHA256/384/512
  • 非对称加密算法
    • 数字签名:ECDSA(P-256 Curve)+ SHA256/384,ED25519,RSA_PKCS1_SHA1/256/384/512,RSA_PSS_SHA256/384/512
  • HMAC:HMACSHA256/384/512
  • HASH:SHA1, SHA256/384/512

Dalek

相较于其他库,Dalek专注于椭圆曲线相关的算法Rust实现,是一个在github上的rust库集合。

5

6

Dalek实现的椭圆曲线相关算法有:

  • X25519
  • Curve25519
  • ED25519

rust-crypto

rust-crypto是一个大集合体,整合了大部分密码学需要使用的模块。

7

rust-crypto涉及到的算法有:

  • 对称加密算法
    • 验证加密:aesgcm, aessiv, ccm, chacha20poly1305, xsalsa20poly1305, deoxys, eax, mgm
    • 密钥生成:Argon2, bcrypt, PBKDF2, scrypt, SHA-crypt
    • 加解密:
      • 模式:CFB,CTR, OFB
      • 算法:chacha20, rabbit, salsa20, hc256
  • 非对称加密算法
    • 数字签名:ECDSA,ED25519
    • 椭圆曲线:BP256/384, k256, P-256/384
  • HMAC:HMACSHA256/384/512
  • HASH:SHA1, SHA256/384/512, blake2, FSB, MD系列, gost94, groestl, k12, ripemd160/256/320, shabal, SM3

Rustls

Rustls是一个Rust TLS库,底层使用ring,目标是为TLS 1.2或更高版本提供安全功能和组件。根据报告,在一定场景下效率比OpenSSL更高。

8

作者认为在未来,Rustls或许有可能替代OpenSSL与BoringSSL等C的SSL库。

9

Crate使用概述图

10

Rust库中涉及到的C库

NaCl

NaCl包含了各种各样的密码算法,被用于网络通讯,加解密,数字签名等应用场合,支持C,C++和Python。优势在于运算速度非常快,会自动替用户选择最优加密方案,防止使用加密库的人未必了解不同加密方式的优劣,同时没有动态内存分配与秘密信息的数据流,一定程度上保证内存安全以及防止缓存计时攻击。

NaCl库实现的算法基本与Sodium相同。

Sodium

Sodium是一个可移植、跨平台可编译、NaCl库的一个fork,但是扩展了NaCl库的内容。

11

Sodium实现的算法有:

  • 对称加密算法
    • 验证加密:aes256gcm, chacha20poly1305
    • 密钥交换:X25519, blake2b-512
    • 加解密:
      • 算法:xchacha20
  • 非对称加密算法
    • 数字签名:ED25519ph,ED25519
    • 椭圆曲线:BP256/384, k256, P-256/384
  • HMAC:HMACSHA256/512
  • Hash: blake2b, siphash, Argon2, Scrypt, Server relief
  • 匿名消息盒(持有对方公有密匙,匿名发送信息):X25519,XSalsa20-Poly1305

引用

Overview of the Rust cryptography ecosystem,https://kerkour.com/blog/rust-cryptography-ecosystem

Is Triple DES Secure?,https://cryptosense.com/blog/is-triple-des-secure

密码学应用基础,http://ilearning.huawei.com/next/learnCourse.html?courseId=9691

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


Flowistry: 强大的Rust IDE工具

Flowistry是一个VSCode扩展,可以帮助你理解Rust程序。Flowistry使用数据流分析和指针分析,在更深的层次上分析Rust程序,而不仅仅是类型所能提供的(例如,你已经可以在rust-analyzer中找到)。

目前为 alpha 版本。

https://github.com/willcrichton/flowistry

polars: 可用于 Rust 和 Python 的 高性能 DataFrames 库

Polars 是一个极快的 DataFrames 库,它使用 Apache Arrow(2) 作为内存模型用 Rust 实现。

DataFrame 是 Python 中 Pandas 库中的一种数据结构,是一种二维表。它类似 excel,或许说它可能有点像 matlab 的矩阵,但是 matlab 的矩阵只能放数值型值(当然 matlab 也可以用 cell 存放多类型数据),DataFrame 的单元格可以存放数值、字符串等,这就和 excel 表很像。

https://github.com/pola-rs/polars

radius 是 ESILSolve 的 Rust 重写版

ESILSolve 可以简化复杂移动应用的逆向工程过程。 ESILSolve + R2frida 可以模拟执行二进制文件。

radius是ESILSolve的一个rust重写版,在架构上有一些改进。它使用boolector作为SMT求解器而不是z3。它的执行速度比ESILSolve平均快1000倍。

https://github.com/aemmitt-ns/radius

goscript : 用 Rust 实现的 Go 脚本语言

不知道有啥用,可能是写着玩吧,也可以用于学习。

https://github.com/oxfeeefeee/goscript

推荐项目 | 基础工具库

sentinel-rust : 阿里的 sentinel 出了 Rust 版本

目前是 WIP

Sentinel 是 阿里之前出的微服务限流熔断框架。

Sentinel 的主要工作机制如下:

  • 对主流框架提供适配或者显示的 API,来定义需要保护的资源,并提供设施对资源进行实时统计和调用链路分析。
  • 根据预设的规则,结合对资源的实时统计信息,对流量进行控制。同时,Sentinel 提供开放的接口,方便您定义及改变规则。
  • Sentinel 提供实时的监控系统,方便您快速了解目前系统的状态。

以上参考 : https://zhuanlan.zhihu.com/p/64786381

https://github.com/sentinel-group/sentinel-rust

apkeep: 从多个源下载APK 文件的命令行工具

https://github.com/EFForg/apkeep

远程桌面软件 RustDesk 1.1.8 发布

  • 修复各种兼容性问题:白屏,闪退(有可能还会出现,没法测试所有系统)
  • 修复鼠标/键盘异常和快捷键问题
  • 修复Linux剪贴板同步问题
  • 支持32位Windows,>=Win7
  • 添加iOS客户端
  • 手机端增加触屏模式,优化鼠标拖拽/选取
  • 启用dxgi,如果失败就退回到gdi
  • 升级tokio到v1,升级vpx到1.10
  • 实验实现Wayland,可用性还是比较低,暂时放弃
  • 默认只提供Windows安装程序,但是可以在安装界面选择无安装运行

作者:RustDesk 链接:https://zhuanlan.zhihu.com/p/402963916 来源:知乎 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

https://github.com/rustdesk/rustdesk/releases/tag/1.1.8

MegFlow : 旷视开源的满足长尾需求的高效机器学习解决方案

MegFlow 提供快速视觉应用落地流程,最快 15 分钟搭建起视频分析服务。

基于 Rust 实现。

https://github.com/MegEngine/MegFlow

Novus : Rust实现的 Windows 包管理器

性能比 Chocolatey 高 3~10倍。

https://github.com/novus-package-manager/novus

pilka : 用 Rust 写成跨平台实时编码工具

Pilka是一种用于创建着色器(shader)演示的跨平台实时编码工具,类似于 Bonzomatic 或 KodeLife 。支持热重载,能够在后台检查和更新资源。

https://github.com/pudnax/pilka

pixels 0.6.0 发布

pixels 是用于简单软件侧光栅化的板条箱。它可以提供一个像素缓冲区,用于插入颜色(在 CPU 端完成)。缓冲区作为纹理上载到GPU,所有缩放和剪裁都由默认着色器处理。对于其他控件,可以添加自己的自定义着色器以进行预处理和后处理。

https://github.com/parasyte/pixels

NoProto:灵活、快速和紧凑的序列化和rpc

特点:

  • 零依赖
  • 支持no_std,WASM
  • 最紧凑的非编译存储格式
  • 稳定

https://github.com/only-cliches/NoProto

gradient-rs: 用于玩颜色渐变的命令行工具

Features:

  • 许多预设渐变。
  • 自定义渐变。
  • 从 SVG 和 GIMP 渐变 (ggr) 文件中读取渐变 ...

https://github.com/mazznoer/gradient-rs

Relm4 v0.1 发布

在第一个测试版发布大约一个月后,经过无数个小时的工作,作者高兴地宣布Relm4的第一个稳定版本正式发布!

关于Relm4:

Relm4是一个受Elm启发并基于gtk4-rs的惯用GUI库。它是一个从头开始构建的relm的新版本,并且兼容gtk4和libadwaita。Relm4的主要目标是生产效率、灵活性、简单性和可维护性。

功能特性

更多信息:

Skiff: 一门用Rust编写的逐渐类型化的函数式编程语言

Skiff,是一门用Rust编写的逐渐类型化的函数式编程语言。所谓逐渐类型化是指作者计划下一步通过添加类型化关键字来区分完全类型函数和部分类型函数。

Skiff受Elm/Pyret/Python语言启发,并受Rust/Javascript/Typescript/Haskell/OCaml/Lua等语言影响,当前语言功能还在持续完善中,作者提供了一个由wasm!驱动的网页编辑器可供读者学习使用,更多信息请访问项目主页的Readme。

更多信息:

htmlq

像 jq,但用于 HTML。使用 CSS 选择器从 HTML 文件中提取部分内容。

jq 就像用于 JSON 数据的 sed - 您可以使用它来切片、过滤、映射和转换结构化数据,就像 sed、awk、grep 和朋友让您处理文本一样轻松。

htmlq 则用于处理 html 数据。

https://github.com/mgdm/htmlq

zerocopy 0.6.0刚刚发布,带来了很多新的功能!

其中包括:simdsimd-nightly特性使分别支持 Stable 和 Unstable 的SIMD。

https://docs.rs/zerocopy/0.6.0/zerocopy/

Tabled 发布v0.3

Tabled 是一个易于使用的库,用于美化 Rust 结构和枚举的输出。

https://github.com/zhiburt/tabled

ferros : 为 seL4 开发添加额外保证的 Rust 库

seL4 是 L4 微内核家族的成员,它为系统中运行的应用之间的隔离提供了最高级别保障,可以遏制系统某一部分的危害,并防止损害系统中其它可能更关键的部分。 据介绍,seL4 是世界上第一个通过数学方法被证明安全的操作系统内核,并且在安全的基础上还强调高性能,是世界上最快、最先进的OS 微内核。

ferros 围绕 seL4 功能提供智能类型安全包装器,重点是编译时资源跟踪。

ferros 建立在 selfe-sys 库之上。

https://github.com/auxoncorp/ferros

Matchbox: Rust wasm 中的 p2p 网络解决方案

Matchbox 的诞生是因为作者在rust 中制作了一款多人网页游戏,遇到了以下问题:

如何使用不可靠的、无序的 p2p connection 连接 N 个web浏览器?

https://johanhelsing.studio/posts/introducing-matchbox

Sycamore: v0.6.0 版本发布了

Sycamore是一个用 Rust 和 WebAssembly 构建同构web应用程序的库. 目前发布了 0.6.0 版本了.

compact_str: 一种内存高效的不可变 string 类型

CompactStr 是一种内存效率更高的不可变字符串类型,它可以在堆栈上存储较小的字符串,并透明地在堆上存储更长的字符串。它们大多可以用作String的替换,在解析、反序列化或任何其他可能有较小字符串的应用程序中特别有用。

https://github.com/ParkMyCar/compact_str

使用 Tarpaulin 进行 Rust 工程测试率覆盖

Tarpaulin是一个用于货物构建系统的代码覆盖率报告工具,它的名字来源于船上用来覆盖货物的防水布。目前,Tarpaulin提供的是工作线覆盖率,虽然相当可靠,但在结果中仍可能包含小的不准确之处。

Tarpaulin只支持运行Linux的x86_64处理器。这是因为在可执行文件中设置断点并跟踪其执行需要处理器和操作系统的特定代码。当达到更高的稳定性时,它的目标是增加更广泛的系统支持,然而这足以让Tarpaulin在Travis等流行的CI工具上运行。

它也可以在Docker中运行,这对你不使用Linux但又想在本地运行它的时候很有用,比如在开发期间。

Allen Wyma 与软件工程师 Daniel McKenna,也是 Tarpaulin 覆盖测试工具的作者的访谈节目。欢迎收听。

Trunk - 一个 Rust 的 WASM web 应用打包器

Trunk 会打包 WASM,JS 代码片断,静态资源(images, css, scss 等)。它的配置使用 HTML 文件。

Trunk 支持所有基于 wasm-bindgen 的框架,包括但不仅限于 Yew 和 Seed。

官网:https://trunkrs.dev/

代码仓库:https://github.com/thedodd/trunk

Composing Studio - 协作编曲工具

Composing Studio 是一款支持在线实时协作的音乐编辑器,使用 Rust、WebAssembly 和 TypeScript 构建,允许任何人创建简单的音乐作品。

Composing Studio 使用一种名为 ABC 的文本格式来完成对音乐的编辑,可以用于转录简单的歌曲 + 吉他和弦,以及其他一些作品,如合唱和民间音乐。同时提供一个友好直观的 Web 界面,具有语法突出显示、实时预览、音频播放和实时协作等功能。

termusic - 终端音乐播放器

termusic 是一款用 Rust 开发的终端音乐播放器,目前支持 mp3, m4a, flac 和 ogg/vorbis 多种格式。作者曾经是 GOMU 的贡献者,由于在开发时遇到像数据竞争这样的严重问题,所以使用 Rust 进行了重写。

https://github.com/tramhao/termusic

eztd:让 Rust 更易学习

项目致力于『可学习性和可控制』,目标包括:

rustls 0.20 发布了

russtls 是一个 Rust 编写的现代的 TLS库。它使用ring进行加密,使用libwebpki进行证书验证。

目前已发布 0.20 版本.

https://github.com/rustls/rustls

dune: 一个 Rust 写的 shell

https://github.com/adam-mcdaniel/dune

推荐项目 | 框架引擎

Perseus - 另一个前端集成 Web UI 框架

perseus 采用 No-VDOM 技术实现页面渲染。实现纯 Rust 前端 Web UI 开发。

  • 支持服务端静态页面生成

  • 支持服务端动态渲染

  • 支持增量生成

  • 各种定制渲染策略

  • 命令行工具

  • 基于 Fluent 的 i18n 支持

  • 它基于强大的 sycamore 实现。实际上,Perseus 与 Yew, Seed 等算竞争对手,但是所采用的技术思路实际是不一样的。

  • https://github.com/arctic-hen7/perseus

  • perseus book

SeaORM: 异步动态的 ORM

SeaORM 是一个关系型 ORM,可以帮助你构建轻量的高并发的 web 服务.

该团队介绍道:「SeaORM 正是 Rust 的 ActiveRecord」,如果你用过这款 Ruby 的 ORM ActiveRecord,你可能会有感觉。

SeaORM是一个从底层建立起来的异步ORM,旨在与异步生态系统很好地配合,无论是actix、async-std、tokio还是任何建立在上面的Web框架。

https://www.sea-ql.org/SeaORM/blog/2021-09-20-introducing-sea-orm/

Rust生态安全漏洞总结系列 | Part 3

作者: 张汉东

本系列主要是分析RustSecurity 安全数据库库中记录的Rust生态社区中发现的安全问题,从中总结一些教训,学习Rust安全编程的经验。

本期分析了下面十一个安全问题:

看是否能给我们一些启示。

RUSTSEC-2021-0110: Vulnerability in wasmtime

在 Wasmtime 中发现多个代码缺陷。包括 UAF(use-after-free)、越界读写等。

漏洞描述:

  • 漏洞类型:Vulnerability
  • 漏洞分类:memory-corruption/ memory-exposure
  • CVE 编号: CVE-2021-39216CVE-2021-39219CVE-2021-39218
  • 详细:https://rustsec.org/advisories/RUSTSEC-2021-0110.html
  • 补丁:>=0.30.0
  • 关键字:use-after-free / out-of-bounds read /out-of-bounds write / Wasm/ garbage collection

漏洞分析

背景: externref 是 WebAssembly 引用类型(Reference Types)中引入的概念,用于表示 Host 引用。

Use after free passing externrefs to Wasm in Wasmtime

当从 Host 传递给 Guest externrefs 时会引发 UAF 。满足下列条件之一可触发此 Bug :

  1. 同时明确地从Host传递多个 externrefswasm 实例
  2. 通过将多个 externrefs 作为参数从 Host 代码传递给 wasm函数
  3. 从Host定义的多值返回函数中返回多个 externrefswasm

如果 WasmtimeVMExternRefActivationsTable在传入第一个externref后容量被填满,那么传入第二个externref可能会触发垃圾回收。然而,在把控制权传给Wasm之前,第一个externref是没有根(root)的,因此,如果没有其他东西持有对它的引用或以其他方式保持它的live,就会被GC回收。然后,当控制权在垃圾收集后被传递给Wasm时,Wasm可以使用第一个externref,但这时它已经被释放了。

Out-of-bounds read/write and invalid free with externrefs and GC safepoints in Wasmtime

Wasmtime运行使用externrefsWasm时,存在一个无效释放和越界读写的错误。

要触发这个错误,Wasmtime需要运行使用externrefsWasm,Host 创建非空的externrefsWasmtime执行一个垃圾收集(GC),并且堆栈上必须有一个Wasm帧,它处于GC Safepoint(安全点就是指代码运行到这个地方,它的状态是确定的, GC就可以安全的进行一些操作),在这个安全点上没有 Live 的引用,这种情况下 Wasmtime 会错误地使用 GC Stack map 而非 安全点。这就会导致释放一些不应该释放的内存,以及潜在的越界读写。

Wrong type for Linker-define functions when used across two Engines

Engine,是在 wasmtime 中被用于跨线程管理wasm模块的全局上下文。

Linker,是用于支持模块链接的结构。

Linker::func_* 安全函数中发现了一个问题。wasmtime 不支持函数的跨 engine 使用,这可能导致函数指针的类型混乱,导致能够安全地调用一个类型错误的函数。这种情况应该 panic!

RUSTSEC-2021-0098: Vulnerability in openssl-src

openssl-src 是用于构建 OpenSSL 给 openssl-sys 库使用的。OpenSSL 最近又发现了很多新的安全缺陷,也记录到这里了。

具体这个漏洞是指 处理ASN.1字符串时的读取缓冲区超限问题。

漏洞描述:

  • 漏洞类型:Vulnerability

  • 漏洞分类:denial-of-service / crypto-failure

  • CVE 编号:CVE-2021-3712

  • 详细:https://www.openssl.org/news/secadv/20210824.txt

  • 补丁:>=111.16

漏洞分析

ASN.1字符串在OpenSSL内部被表示为一个ASN1_STRING结构,它包含一个容纳字符串数据的缓冲区和一个容纳缓冲区长度的字段。这与普通的C语言字符串不同,后者表示为一个字符串数据的缓冲区,以NUL(0)字节结束。

虽然不是严格的要求,但使用OpenSSL自己的 "d2i "函数(和其他类似的解析函数)解析的ASN.1字符串,以及任何用ASN1_STRING_set()函数设置值的字符串,都会在ASN1_STRING结构中以NUL结束字节数。

然而,应用程序有可能直接构建有效的ASN1_STRING结构,通过直接设置ASN1_STRING数组中的 "data "和 "length "字段,不以NUL方式终止字节数组。这也可以通过使用ASN1_STRING_set0()函数来实现。

许多打印ASN.1数据的OpenSSL函数被认为ASN1_STRING字节数组将以NUL结尾,尽管这对直接构建的字符串来说是不保证的。如果应用程序要求打印一个ASN.1结构,而该ASN.1结构包含由应用程序直接构建的ASN1_STRING,而没有以NUL结束 "data "字段,那么就会发生读取缓冲区超限。

如果一个恶意行为者可以使一个应用程序直接构建一个ASN1_STRING,然后通过受影响的OpenSSL函数之一处理它,那么这个问题可能会被击中。这可能导致崩溃(造成拒绝服务攻击,DOS)。它还可能导致私人内存内容(如私人密钥或敏感明文)的泄露。

其他 OpenSSL 问题

OpenSSL 缺陷列表: https://rustsec.org/packages/openssl-src.html

RUSTSEC-2021-0082: Unsoundness in vec-const

vec-const试图从一个指向常量切片的指针构造一个Vec

漏洞描述:

  • 漏洞类型:Unsound

  • 漏洞分类:memory-corruption

  • CVE 编号:CVE-2021-3711

  • 详细:https://github.com/Eolu/vec-const/issues/1#issuecomment-898908241

  • 补丁:暂无,不建议使用该 crate

  • 关键字: memory-safety

漏洞分析

这个crate 违反了Rust的规则,使用起来会有危害。你不应该使用这个crate。这个crate不应该存在。它创建了不健全的抽象,允许不安全的代码伪装成安全代码。

这个crate声称要构造一个长度和容量都不为零的const Vec,但这是做不到的,因为这样的Vec需要一个来自分配器(allocator)的指针。参见:https://github.com/rust-lang/const-eval/issues/20。

RUSTSEC-2021-0093: Vulnerability in crossbeam-deque

crossbeam-deque中发生了数据竞争。

漏洞描述:

  • 漏洞类型:Vulnerability

  • 漏洞分类:memory-corruption

  • CVE 编号: GHSA-pqqp-xmhj-wgcw、 CVE-2021-32810

  • 详细:https://github.com/Eolu/vec-const/issues/1#issuecomment-898908241

  • 补丁:>=0.7.4, <0.8.0 / >=0.8.1

漏洞分析

在受影响的版本中,队列的一个或多个任务会被弹出两次,如果在堆上分配,会导致 dobule free 和 内存泄漏。如果不是堆上分配,则会引起逻辑错误。

修复PR :https://github.com/crossbeam-rs/crossbeam/pull/726

问题是因为任务窃取相关条件判断错误导致的,是逻辑 Bug。

RUSTSEC-2021-0077: Vulnerability in better-macro

better-macro 是一个假的 crate,它在 "证明一个观点",即proc-macros可以运行任意的代码。这是一个特别新颖或有趣的观察。

它目前打开的 https://github.com/raycar5/better-macro/blob/master/doc/hi.md,似乎没有任何恶意的内容,但不能保证会一直如此。

这个 crate 没有任何有用的功能,不应该被使用。


#![allow(unused)]
fn main() {
#[proc_macro]
pub fn println(input: TokenStream) -> TokenStream {
    if let Ok(_) = Command::new("xdg-open").arg(URL).output() {
    } else if let Ok(_) = Command::new("open").arg(URL).output() {
    } else if let Ok(_) = Command::new("explorer.exe").arg(URL).output() {
    }
    let input: proc_macro2::TokenStream = input.into();
    let out = quote! {::std::println!(#input)};
    out.into()
}
}

RUSTSEC-2021-0106: Vulnerability in bat

bat 中存在不受控制的搜索路径元素,可能会导致非预期代码执行。

0.18.2之前的windows系统中的bat会从当前工作目录中执行名为less.exe的程序。

漏洞描述:

  • 漏洞类型:Vulnerability

  • 漏洞分类:code-execution

  • CVE 编号:CVE-2021-36753 、 GHSA-p24j-h477-76q3

  • 详细:https://nvd.nist.gov/vuln/detail/CVE-2021-36753

  • 补丁:>=0.18.2

  • 平台: Windows

漏洞分析

修复 PR: https://github.com/sharkdp/bat/pull/1724

对传入的 Path 进行了合法验证。使用的库是 grep_cli

RUSTSEC-2021-0073: Vulnerability in prost-types

prost_types::TimestampSystemTime的转换可能导致溢出和恐慌。

漏洞描述:

  • 漏洞类型:Vulnerability

  • 漏洞分类:denial-of-service

  • CVE 编号:CVE-2021-36753 、 GHSA-p24j-h477-76q3

  • 详细:https://github.com/tokio-rs/prost/issues/438

  • 补丁:>=0.8.0

漏洞分析

prost-types 0.7.0中,从TimestampSystemTime的转换使用UNIX_EPOCH上的+-运算符。如果输入的Timestamp是不被信任的,这可能会溢出和恐慌,造成拒绝服务的漏洞。因为 SystimeTime 内部实现的 +- 使用 checked_add/checked_sub会发生 panic。


#![allow(unused)]
fn main() {
use prost_types::Timestamp;
use std::time::SystemTime;

SystemTime::from(Timestamp {
    seconds: i64::MAX,
    nanos: 0,
}); // panics on i686-unknown-linux-gnu (but not x86_64) with default compiler settings

SystemTime::from(Timestamp {
    seconds: i64::MAX,
    nanos: i32::MAX,
}); // panics on x86_64-unknown-linux-gnu with default compiler settings
}

另外,转换涉及到调用Timestamp::normalize,它使用了+-运算符。这可能会引起恐慌或环绕(wrap around,取决于编译器设置),如果应用程序被编译为溢出时恐慌,也会产生拒绝服务的漏洞。

解决问题的思路是:

Timestamp::normalize可能应该使用 saturating_{add,sub} 方法,如果时间戳的nanos字段超出了范围,这可能会默默地改变时间戳,最多3秒,但这样的时间戳可以说是无效的,所以这可能是好的。

SystemTime 没有Saturating_{add,sub}方法,也没有MINMAX常数,应该再次使用 SystemTime::checked_{add,sub} 进行转换。

修复 PR: https://github.com/tokio-rs/prost/pull/439

RUSTSEC-2021-0078: Vulnerability in hyper

对Content-Length进行宽松的 header 解析,可能会使请求被偷渡(走私,smuggling)。

背景: 请求偷渡

不合法的请求被夹杂在合法请求中被得到处理。需要通过 content-lengthTransfer-Encoding 两个header 来构造攻击。

漏洞描述:

  • 漏洞类型:Vulnerability

  • 漏洞分类:http、parsing

  • CVE 编号:CVE-2021-32715

  • 详细:https://github.com/hyperium/hyper/security/advisories/GHSA-f3pg-qwvg-p99c

  • 补丁:>=0.14.10

漏洞分析

hyper的HTTP/1服务器代码存在一个缺陷,即错误地解析和接受带有前缀加号的Content-Length头的请求,而这一请求本应作为非法请求被拒绝。这与上游HTTP代理不解析这种Content-Length头而转发的情况相结合,可能导致 "请求偷渡("request smuggling) "或 "去同步攻击(desync attacks)"。

修复代码:https://github.com/hyperium/hyper/commit/06335158ca48724db9bf074398067d2db08613e7

需要判断 content-lenght 是不是可以正常转换为有效数位。

RUSTSEC-2021-0072: Vulnerability in tokio

当用JoinHandle::abort中止一个任务时,对于 LocalSet上生成的任务不正确, 容易导致竞态条件。

漏洞描述:

  • 漏洞类型:Vulnerability

  • 漏洞分类:memory-corruption

  • CVE 编号:CVE-2021-32715

  • 详细:https://github.com/tokio-rs/tokio/issues/3929

  • 补丁:

    >=1.5.1, <1.6.0>=1.6.3, <1.7.0>=1.7.2, <1.8.0>=1.8.1

漏洞分析

当用JoinHandle::abort中止一个任务时,如果该任务当前没有被执行,那么在调用abort的线程中,Future会被 Drop。这对于在LocalSet上生成的任务是不正确的。

这很容易导致竞态条件,因为许多项目在它们的Tokio任务中使用RcRefCell以获得更好的性能。

修复 PR: https://github.com/tokio-rs/tokio/pull/3934

RUSTSEC-2021-0070: Vulnerability in nalgebra

nalgebra 库中 VecStorageDeserialize实现没有保持元素数量必须等于nrows * ncols的不变性。对特制的输入进行反序列化时,可能会允许超出向量分配的内存访问。

漏洞描述:

  • 漏洞类型:Vulnerability
  • 漏洞分类:memory-corruption/ memory-exposure
  • CVE 编号:CVE-2021-32715
  • 详细:https://github.com/dimforge/nalgebra/issues/883
  • 补丁:>=0.27.1

漏洞分析

这个缺陷是在v0.11.0(086e6e)中引入的,因为为MatrixVec增加了一个自动派生(derive)的Deserialize实现。MatrixVec后来在v0.16.13(0f66403)中被改名为VecStorage,并继续使用自动派生的Deserialize实现。

修复 PR : https://github.com/dimforge/nalgebra/pull/889

在反序列化的过程中,对 nrows.value() * ncols.value() == data.len() 进行校验。

CVE-2021-31162: Vulnerability in std

在 Rust 1.52.0之前的Rust标准库中,如果释放元素时出现panic ,在Vec::from_iter函数中会出现 double free。

漏洞描述:

  • 漏洞类型:Vulnerability
  • 漏洞分类:memory-corruption
  • CVE 编号:CVE-2021-31162
  • 详细:https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-31162
  • 补丁:>=1.52.0

漏洞分析

漏洞复现代码:

use std::iter::FromIterator;

#[derive(Debug)]
enum MyEnum {
    DroppedTwice(Box<i32>),
    PanicOnDrop,
}

impl Drop for MyEnum {
    fn drop(&mut self) {
        match self {
            MyEnum::DroppedTwice(_) => println!("Dropping!"),
            MyEnum::PanicOnDrop => {
                if !std::thread::panicking() {
                    panic!();
                }
            }
        }
    }
}

fn main() {
    let v = vec![MyEnum::DroppedTwice(Box::new(123)), MyEnum::PanicOnDrop];
    Vec::from_iter(v.into_iter().take(0));
}

// Output : free(): double free detected in tcache 2

因为枚举MyEnum在 析构的时候panic,导致资源泄漏,而引发了双重 free 的问题。

修复 PR: https://github.com/rust-lang/rust/pull/84603

Vec::from_iter 中执行 forget_allocation_drop_remaining,即,忘记已经被drop的src的元素分配的内存,即便 drop 发生了 panic,也不会泄漏资源。

十月刊(October)

发刊通告

Rust 资讯

外刊评论

Rust 生产实践

学习园地

新手入门

原理与工程实践

开源项目巡礼

Rust 与 安全

发刊通告

编辑:张汉东

十月发刊通告

《 RustMagazine 中文精选 》2021 年第十期发布了!

十月,全国疫情又开始有所抬头,大家注意防范。这份月刊可以陪伴你宅在家里学习Rust,收下它吧。

距离今年结束剩下两个月了,大家加油!

欢迎大家多多投稿!

本刊动态

九月份新增栏目:「外刊评论」。

国外有很多精彩的内容,需要大家一起取其精华,以供大家学习。

(GitHub Page)从六月起已经停止 Rss 订阅

因为当前 mdbook 的 rss 插件不是很方便,等以后再加 rss 支持吧。

可以到语雀平台自动支持邮件订阅。

上期(九月刊)访问数据统计小结

用户数

  • 新用户新增 2,063

浏览量:

  • 网页浏览量 : 14,391

九月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

八月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

七月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

六月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

聚焦 Rust 官方活动与新闻


Rust 1.56.0 版本和 Rust 2021 版次发布,迈向 Rust 广泛应用的征程

Rust 1.56.0 和 Rust 2021 的升级或安装

如果你已通过 rustup 安装了 Rust 的早期版本,那么更新到 Rust 1.56.0 相当容易:

$ rustup update stable

如果您还未安装过 Rust,可以从 Rust 官网页面获取 rustup

新特性一览

Rust 1.56.0 版本和 Rust 2021 版次的升级改进并不算大,新特性大抵如下:


#![allow(unused)]
fn main() {
// 2015 or 2018 edition code
let a = SomeStruct::new();

// Move out of one field of the struct
drop(a.x);

// Ok: Still use another field of the struct
println!("{}", a.y);

// Error: Before 2021 edition, tries to capture all of `a`
let c = || println!("{}", a.y);
c();
}

将已有项目迁移到 Rust 2021 版次

  1. 运行 cargo fix --edition
  2. 编辑 Cargo.toml,设定 edition = "2021"。如:
[package]
name = "..."
version = "0.0.1"
...
edition = "2021"
...

https://blog.rust-lang.org/2021/10/21/Rust-1.56.0.html

Rust 基金会 新成员 聚焦 | 为什么要加入 Rust 基金会

Rust 基金会官网成员聚焦栏目,会定期采访一些新加入基金会的成员,这里是对这些采访内容的纪要。

详情见: Rust 基金会 新成员 聚焦 | 为什么要加入 Rust 基金会

一个实用的 rustdoc 改进刚刚在 nightly 中合并

#[cfg()] 信息现在将出现在文档中(默认情况下不再需要使用 #[doc(cfg())]!)

https://github.com/rust-lang/rust/pull/89596

P.S Rustdoc team leader Guillaume Gomez 将在 RustChinaConf 2021 大会上有精彩分享

Rust Lang 团队 10 月更新

本周 lang 团队召开了 10 月的计划会议,涉及以下内容:

Rust 生态的可持续增长和可见性

Rust 生态系统的长期健康非常重要,确保它不仅需要我们随着时间的推移而增长,还需要确保我们能够适当地扩展。实现这一目标意味着确保我们生态系统的关键部分“人员充足”并且能够自我维护,并且不会有任何维护人员精疲力竭。

至关重要的是,人不是计算机。对一个人来说,那种令人无法接受的承诺会很快耗尽他们的精力,而其他人则可能会因此茁壮成长。不同的代码库对“处于健康状态”的含义有不同的需求。但是,拥有更多信息、明确数字来引导我们提出更有针对性的问题,这将有助于我们提前发现精疲力竭的人,帮助我们更早地发现问题(以及进展顺利的事情!)。

所以官方创建了工具 optopodi 。 optopodi 是一个实验项目,用于开始收集有关 github 组织和存储库的指标。

目标是了解各种“开源健康”风格的指标,例如:

  • 哪些存储库最活跃?
  • 谁在为他们做出贡献?
  • 获得 review 需要多长时间?

最终,我们希望使用这些数据来帮助项目和贡献者取得更大的成功。

官方的长期目标:

是让这个工具成为一个全面的自动化服务,为公共仪表板提供服务。我希望能够一目了然地检查生态系统的状况,并且让每个人都能看到我正在看的相同数据。我希望能够知道贡献者何时面临倦怠的风险并加以预防。我想确保贡献者的 "服务质量 "很高,如果它没有得到解决,并能够验证我们的改变是否达到了预期效果。我希望我们能够清楚地看到我们的行动所产生的效果。我希望我们不要怀疑我们是否在做正确的事情,我希望我们能有具体的证据。

有了这些信息,不仅可以让项目指导我们的工作,还可以让我们尽早做到这一点,因为我们可以在维护者感受到这些问题之前发现潜在的问题。增加参与关键库的知识贡献者的数量有助于这些项目的每个贡献者。公关和问题将有更快的周转时间。而将这些信息公之于众,也可以让其他人独立评估我们的健康状况。

https://estebank.github.io/sustainable-growth-and-visibility.html

社区热点

聚焦 Rust 生态热点新闻


Linus Torvalds : Rust 为 Linux 的发展带来更多乐趣,乐趣是Linux 长青的基石

原文: Linus Torvalds on Community, Rust and Linux’s Longevity

本周,Linux创建者Linus Torvalds在北美开源峰会上进行了一年一度(去年也做过相同主题的报告)的报告,今年的峰会在西雅图举行(同时也包括线上)。

Torvalds周二在西雅图凯悦酒店的宴会厅登台,在下午的主题会议上接受了Linux早期贡献者Dirk Hohndel(现在也是VMware的首席开源官和副总裁)的惯例半小时提问。

仪式一开始就确认了一个特殊的时间点,将一个生日蛋糕隆重地送给了 Linus Torvalds,以纪念Linux的30周年,引来了观众的阵阵掌声。Hohndel补充说,他向 "所有的内核开发者 "致以30岁生日的祝福,它确实也是一个社区。

之后,Torvalds 开始回忆 Linux 的发展过程,包括他从 Linux 社区学到了什么。

Torvalds 认为 Linux 之所以如此长青,其中一个重要的基石就是 乐趣(Fun),并且 乐趣也是他一直追求的东西。当人们讨论 使用Rust编写一些Linux内核模块的可能性时,乐趣就出现了。

"从技术角度来看,Rust 进 Linux 内核 有意义吗?"

当 Torvalds 被问道这个问题,他如是说:“谁知道呢。这不是问题的关键。重点是为了使一个项目保持有趣--保持乐趣--你必须玩它。”

即使现在有数十亿的设备依赖于Linux,该项目仍然强调要找到有趣的新方向来探索。"这是我非常自豪的一件事,仍然如此。"

在用C语言开发了三十年的操作系统之后,Hohndel承认他 "非常惊讶地看到 linux 对在新系统中引入Rust模块这个想法是如此开放"。

"我真的很喜欢C,"Torvalds承认。"我认为C语言是一种伟大的语言,对我来说,C语言确实是一种在相当低的水平上控制硬件的方法。因此,当我看到C语言代码时,我可以非常接近地猜测编译器的工作。它是如此接近硬件,以至于你可以用它来做任何事情"。

然而,Torvalds也看到了Hohndel的比喻,即它可能像玩电锯一样。

作为C语言的长期观察者,Torvalds知道C语言微妙的类型交互 "并不总是合乎逻辑的","对几乎所有人来说都是陷阱。它们很容易被忽视,而在内核中,这并不总是一件好事"。

Torvalds称Rust是 "我看到的第一种看起来像是真的可以解决问题的语言"

还有其他C语言程序员看重的考虑因素--比如高性能和易于调试--但对Rust的实验仍有一些开放性。"人们现在已经谈论Rust在内核中的应用很久了--它还没有完成,"这位Linux创建者说。"所以我们将拭目以待。

"可能在明年,我们会开始看到一些首次用Rust编写的无畏的模块,也许会被整合到主线内核中。"

主题谈话结束时,Hohndel问他们应该为Linux的50周年做什么,在2041年,他们两个都将是70多岁的人。

Torvalds的回答很有特色,就像对Linux内核一样,他不做超过6个月的计划。但这个问题确实引起了一些思考。"我做了30年的内核,非常高兴," ,Torvalds开始思考。

"不知何故,我不认为自己在70岁时还能做内核编程。但另一方面,几年前,我也没有看到自己在50岁时做内核编程。所以......我们会看到的。"

Linkerd 2.11 发布:更多组件向 Rust 迁移

Linkerd 之前只有 proxy 部分是 Rust 实现,在昨天发布的2.11.0版本中,Linkerd采用了一个用Rust编写的新策略控制器组件!它使用 kube-rs 进行通信。它使用 kube-rs 与Kubernetes API进行通信,并暴露了一个用Tonic实现的gRPC API。

虽然 Linkerd 在数据面有丰富的Rust经验,但他们选择Go作为控制面组件,因为Kubernetes生态系统(以及它的API客户端等)是如此严重地倾向于Go。然而,由于u/clux在kube-rs上的出色工作,现在用Rust实现控制器已经变得可行。这对Linkerd项目来说是一大进步,他们计划在整个项目中更多地使用Rust。

他们希望Linkerd的这个新方向将有助于欢迎更多希望增长Rust实践经验的贡献者。

【Rust 生态观察】 Rust 实现的事件处理引擎 tremor-runtime 已经在 美国最大家具电商公司 Wayfair 生产环境跑了三年

深挖了一下 tremor-runtime 项目背后的公司,原来是 Wayfair 。

Wayfair 是美国最大的家具电商,2017 年市值就达58亿美元,前身是早在2002年就成立的CNSStores。亚马逊都吃不下它。

Tremor 应该是 Wayfair 公司旗下的开源项目,已经进入 CNCF 。今年九月份还召开了一次小型的线上的 Tremor Conf

去年(2020)3月份的一次分享:Rust 如何为 Wayfair 省掉数千个核心和TB级的内存的成本 :2020-03-31-RustAndTellBerlin-functions

从2018年开始, tremor 就是跑在了 wayfair生产环境中,每天处理10兆字节的数据,或每分钟100亿条消息,每秒1000万个指标。tremor 降低了成本,减少了复杂性,巩固和简化了操作环境,以激发SRE的乐趣,减少NOC的工作量,并降低运营成本。

最近有一个 Rust 插件开发系列文章,也是出自 tremor 项目的 GSoC 挑战:rust-plugins ,已经发布了四篇。

tremor-runtime

使用Rust进行内核开发

在2021年的Linux Plumbers大会上,Linux的Rust开发者们都在那里进行了许多富有成效的讨论。在维护者峰会上,Miguel Ojeda从Plumbers中走出来,在一个不同的场合谈论Rust。要怎样才能让Rust补丁被合并?他得到的答案是令人鼓舞的,即使不是完全承诺的。

https://lwn.net/Articles/870555/

【官方】安卓团队正式介绍 Android Rust

Android平台提供了对用Rust开发本地操作系统组件的支持。

还有其他模块介绍,详细请看:https://source.android.com/setup/build/rust/building-rust-modules/overview

OpenSUSE 2021 Rust Survey的结果

从9月8日到10月7日,OpenSUSE帮助我主持了一个关于开发人员如何在他们的环境中使用Rust的调查。作为SUSE和OpenSUSE中Rust包的维护者,对我来说,更好地了解人们如何使用Rust是很重要的,这样我们才能做出符合社区工作方式的决定。

所有的数据都可以在这里找到: https://fy.blackhats.net.au/blog/html/2021/10/08/results_from_the_opensuse_2021_rust_survey.html

Pest 项目找维护人

pest 是著名的 Rust 解析器框架,现在作者好像停止维护了。需要有人接手。有意者请参与讨论:

https://github.com/pest-parser/pest/discussions/547

嵌入式异步的现在与未来

该文作者写过三篇嵌入式下Rust异步的系列文章:

在前面的两篇中,作者介绍了 嵌入式下使用 Rust 异步的好处:更好的任务管理和节省能耗。

但是实际的测试结果发现,同步和异步的能耗其实没差多少。使用 embassy 嵌入式异步运行时库可以工作的更好。

但是目前异步还有限制,比如 trait 不能包含 async 方法。幸运的是,Rust 的 泛型关联类型 (GAT)可能在今年稳定,届时可以解决这个限制。

Rust Nightly 已经实现了 trait 中支持异步方法的特性。作者试用了这个Nightly特性。


#![allow(unused)]
fn main() {
pub trait I2c<A: AddressMode = SevenBitAddress> {
    /// Error type
    type Error;
    // 这里用到了生命周期参数,需要 Nightly 下 GAT 的支持
    type ReadFuture<'a>: Future<Output = Result<(), Self::Error>> + 'a
    where
        Self: 'a;

    fn read<'a>(&'a mut self, addr: A, bs: &'a mut [u8]) -> Self::ReadFuture<'a>;
}
}

trait 里虽然不让出现异步函数,但是可以在函数实现中使用 异步块来解决:


#![allow(unused)]
fn main() {
fn read<'a>(&'a mut self, addr: u8, bs: &'a mut [u8]) -> Self::ReadFuture<'a> {
    async move {
        // implementation
    }
}
}

总之,对于嵌入式需求来说,稳定目前存在于 nightly 上的GAT 就足以为编写 async驱动 crate 提供基础,有效地推动了生态系统的发展。有了这些,嵌入式上的异步将成为一种非常有效的技术。

在准备过程中,现在可能是一个很好的时机,可以开始在夜间玩玩异步特性。一旦GAT 稳定下来,我们就可以一起建立 Rust Embedded 异步生态系统🦀❤️🦀。

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust 唠嗑室》第 34 期 - Poem Web 开发框架介绍

时间: 2021/09/28 20:30-21:30

主讲人:油条哥

题目:Poem Web 开发框架介绍

内容:油条哥今晚给大家分享一下 Poem Web 开发框架的特性,poem-openapi 的特性,以及框架开发过程中的一些心路历程,欢迎大家今晚围观油条哥。

参考资料:

  1. 源码:https://github.com/poem-web/poem

【回放】


《Rust 唠嗑室》第 35 期 - 闲谈 Rust 生态目前缺什么 新人问题现场辅导

时间: 2021/10/12 20:30-21:30

主讲人:Mike

题目:闲谈 Rust 生态目前缺什么 新人问题现场辅导

内容:国庆好好嗨了一下,回来来不及邀请大佬来讲 topic 了,今晚我们就来闲聊一下,放松放松。顺便新人还可以现场找大佬来辅导辅导功课。

【回放】


《Rust 唠嗑室》第 36 期 - 分享使用 Rust 进行嵌入式开发创业的一些点滴

时间: 2021/10/26 20:30-21:30

主讲人:cz

题目:分享使用 Rust 进行嵌入式开发创业的一些点滴

内容

一. 创业项目为什么选择 Rust 进行嵌入式开发(For STM32)?

二. 根据项目选用哪些 Rust crates?

三. Rust 在嵌入式开发中表现如何?

四. Rust 嵌入式开发总结.

【回放】

参考资料:

  1. https://github.com/stm32-rs/stm32f4xx-hal
  2. https://github.com/rtic-rs/cortex-m-rtic
  3. https://arewertosyet.com/
  4. https://github.com/rtic-rs/defmt-app-template
  5. http://www.hi-flying.com/

🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第二十期

分享者:张汉东

【讨论主题】

  1. 如何定制 rustc_lint?
  2. 讨论 是否需要完全遵照 clippy lint 的提示去修改代码?

【参考资料】

  1. https://github.com/pymongo/lints

【回放】

本月招聘

【RustDesk】 Rust 工程师

岗位描述

  • 开源远程桌面产品前后端开发
  • RustDesk在目前阶段是一款重客户端,轻服务端的跨平台产品。根据你的经验或者喜好,你可以选择你的侧重点。
  • 技术栈:Rust 、Flutter 、React

职位要求:

  • 写过 Rust,或者熟悉C++,有意愿学习Rust
  • 了解基础数据结构和算法
  • 喜欢学习新东西,主动思考,提问前先 Google
  • 能够接受他人意见,不要对自己的代码迷之自信,也不要轻易吐槽他人的代码
  • 加分项:不错的 GitHub 项目、能够写出漂亮的 UI 、视频编解码开发经验、网络通信安全开发经验、后端高并发开发经验、网络协议栈开发经验

面试方式:

你不需要准备 LeetCode,也无需通读算法导论,但是请熟悉 GNU STL 里的基础数据结构和算法。希望你花点时间了解 RustDesk,然后自行选择 GitHub 上的 3 个 issues,我们一起在面试中讨论分享。

联系方式: https://rustdesk.com/blog/job/


【非凸科技】Rust 开发工程师

非凸科技正成为国内金融市场智能投资交易平台的引领者,潜心打造了智能算法交易平台,在原有基础上全面升级到互联网新一代技术架构,结合机器学习等新兴技术,逐步完成各类交易算法的研发迭代,正持续为券商、量化私募等众多大型金融机构提供优质的算法服务。

岗位职责

  1. 设计并开发基于Rust的高性能,低时延算法交易系统;
  2. 设计并开发数据处理平台,监控运维平台;
  3. 设计并开发面向客户的高可用交易工具等;
  4. 设计并开发策略相关的回测平台。

岗位要求

  1. 本科及以上学历(985优先)。编程基础扎实,具 有良好的计算机理论基础;
  2. 熟练掌握Linux操作,性能分析,具备 Rust/C++/Java/Go丰富开发经验,熟悉常用的设计 模式,有分布式相关经验加分;
  3. 有研发高性能,低时延系统经验加分;
  4. 对技术充满热情,思考深入。自我驱动,能快速学 习新鲜事物。

联系方式:

  • 联系人:Schiele
  • 联系电话:150 0928 5657
  • 地址:上海市漕河泾开发区 凯科国际大厦

【地平线自动驾驶AI团队】Rust 开发实习生

岗位描述:

参与自动驾驶研发基础软件的设计、开发,高效完成相关模块的编码、测试工作。

岗位要求:

  1. 掌握rust基本用法,能用rust进行生产环境的开发。
  2. 有发布过crate,加分;了解wasm,加分。
  3. 极强的自学能力,面对陌生的知识点,知道如何快速理解并掌握。
  4. 超强的耐性足以面对编译器的各种报错。
  5. 对自动驾驶领域感兴趣,对AI感兴趣,对软件2.0感兴趣。

请投递简历至 min.du@horizon.ai,标题注明 “我要去地平线写rust” 。


【创梦天地】平台/应用服务端开发实习(有转正机会)

职位描述:

  1. 使用Rust参与机器人,开放平台开发;
  2. 参与分布式音视频服务开发;
  3. 参与IM业务开发,网络IO性能优化的工作;

职位要求:

  1. 了解Rust语言,了解actix-web和tokio,全日制本科及以上学历;
  2. 熟悉Linux、分布式、多线程,熟悉c/c++/PHP语言优先;
  3. 熟悉网络编程,TCP/UDP/HTTP等网络协议;

投递/咨询邮箱:sammi.zheng@idreamsky.com


【远程】北京/多伦多/远程Rust工程师

北京做数据库底层/多伦多做区块链底层/待遇面谈

Rust开发工程师要求

  1. 擅长Rust语言实现区块链的开发;
  2. 有较好的沟通能力,及Rust实现区块链业务系统开发、优化工作经验;
  3. 熟悉或比特币等区块链模型及原理及开发经验;
  4. 有分布式系统,中间件体系经验;
  5. 有evm、Wasm合约模块开发,底层组件优化,及合约周边的工具链开发经验;
  6. 有基本的英语阅读能力。

联系方式: qwy@msn.com

聚焦Rust基金会新成员 | 为什么要加入 Rust 基金会

Rust 基金会官网成员聚焦栏目,会定期采访一些新加入基金会的成员,这里是对这些采访内容的纪要。

Open Source Security

  1. Open Source Security, Inc. 开发和维护 grsecurity ,这是 Linux 内核的安全替代品。
  2. 去年开始支持(与 Embecosm 合作)全职开发 Rust 的 GCC 前端,宣布为 Rust GCC 前端提供资金
  3. 加入 Rust 基金会的动机是: 确保参与帮助支持 Rust 更广泛的目标和计划,也为了证明对他们更广泛的 Rust 生态系统有兴趣和资金支持,这对于 Rust 更加广泛采用至关重要。
  4. 希望 Rust 基金会帮助打破 Rust 被采用的各种障碍,支持整个 Rust 生态,并帮助向更广泛的受众宣传 Rust 的优势与成功。

原文: https://foundation.rust-lang.org/posts/2021-09-21-member-spotlight-open-source-security-software/

Parastate

  1. ParaState 是一个多链、智能合约平台,它在 Polkadot、Substrate 和 Ethereum 以及其他希望提供高性能以太坊兼容性的网络之间架起了应用程序和开发人员生态系统的桥梁。
  2. 认为 Rust 是云计算和无服务器计算领域非常有前途的编程语言。
  3. Rust 基金会是全球 Rust 开发者的官方社区。随着ParaState 扩大对 Rust 的使用,ParaState 希望确保我们是这个领先组织的一部分,并有机会帮助基金会在全球范围内进一步开展工作和使用 Rust。

P.S ParaState 也赞助了 RustChinaConf 2021

原文: https://foundation.rust-lang.org/posts/2021-10-01-member-spotlight-parastate/

Knoldus

  1. Knoldus以产品思维设计高性能系统、数据战略和分析以及智能驱动系统,使企业通过技术取得成功。利用函数式编程和快速的数据生态系统,通过尖端的数字工程实现企业现代化。使命是提供消息驱动、弹性、弹性和响应迅速的云原生、反应式和流式快速数据解决方案。
  2. Knoldus 从 2019 年开始接受 Rust,并在开发的各个方面实现了它,从源代码控制到编译器。我们对 Rust 感兴趣的主要原因之一是构建高性能系统,因为它具有更好的安全标准,可以降低开发过程成本。Rust 已经在嵌入式编程、网络、CLI、Web 程序集和区块链等多个领域取得了成功。Knoldus 已成功地为这些领域做出了贡献。
  3. Knoldus 用 Rust 做的产品:
    1. Kerb是一个消费者定位智能平台,旨在测量消费者的流量。该平台使用由人工智能驱动的摄像头,映射和测量人行道上的行人脚步流量,以计算潜在客户总数、捕获的客户、潜在线索、每个潜在线索的购买意向指标和每个潜在线索的人口统计学,使企业能够测量脚步的洞察力。Jetson Nano设备被用于边缘计算,所有捕获相关数据的服务,如立体图像、GPS、加速器等,都建立在Rust上。
    2. Polkadex是一个专门用于去中心化交易所的区块链,它是为优化DEX的性能而专门建造和配置的。Polkadex通过让AMM直接连接到其交易引擎,解决了低流动性的问题。
    3. 嵌入式产品:用于考勤管理和安全的图像识别系统 和 用于监控会议的物联网系统。
  4. 致力于为 IoT 平台开发嵌入式软件,并尝试在网络、Web 程序集和 CLI 领域使用 Rust 编程的核心功能
  5. Knoldus 加入基金会的主要动机是相信 Rust 是系统编程的未来,可以共同支持 Rust 项目的工作,推动 Rust 向前发展。
  6. 希望 Rust 基金会将为 Rust 社区提供一个集中式平台,使 Rust 生态系统更加成熟,并为 Rust 编程语言的发展做出贡献

原文: https://foundation.rust-lang.org/posts/2021-10-07-member-spotlight-knoldus/

Crates.io 的新发展

  1. Crates.io 的下载已超过100 亿次,现在拥有69,000 多个 crate!相当令人印象深刻的数字。
  2. Rust 编程顾问团队Ferrous Systems已经接管了确保Crates.io始终可访问所需的值班轮换的责任。Rust 基金会正在资助一个由 Ferrous Systems 的四名程序员组成的团队,该团队将充当保持服务器正常运行的“第一道防线”。他们将随时轮换,以确保持续覆盖和快速、无缝的问题解决,以便全球的程序员不会意识到任何服务故障,并且不会被任何服务中断。

原文: https://foundation.rust-lang.org/posts/2021-10-18-crates-io-oncall-ferrous-systems/

Tag1

  1. Tag1 是一家全球技术咨询公司,也是 Drupal 平台的第二位历史领先贡献者,该平台为 3% 的互联网提供支持。
  2. 使用 Rust 来改进内部工具和流程,移植了许多最初用 Python 编写的脚本和实用程序。
  3. 对社区的主要贡献是GooseGoose是受Locust启发的Rust负载测试工具。Goose 每 CPU 核产生的流量至少是 Locust 的 11 倍,对于更复杂的负载测试(例如使用第三方库抓取表单内容的负载测试),收益甚至更大。详见 Goose Book
  4. 加入 Rust 基金会 希望看到它和它周围的社区成长和繁荣。Rust 以其出色的文档和热情而乐于助人的社区而闻名,Tag1 很自豪能成为其中的一小部分。
  5. 希望 Rust 基金会将鼓励用户继续使用该语言创造新事物,推广新功能和用途并为社区倡导。该基金会对于筹款、支持和扩大项目规模至关重要。它还将帮助组织会议并将社区聚集在一起,并帮助围绕项目建立一个强大的生态系统。

原文: https://foundation.rust-lang.org/posts/2021-10-26-member-spotlight-tag1/

【Linux Plumbers 大会总结】Rust 和 GCC 整合的两种方式

原文 https://lwn.net/SubscriberLink/871283/c437c1364397e70e/

作者: Jonathan Corbet

CC++等语言中工作的开发者可以使用两种相互竞争的编译器: GCCLLVM。 它们中的任何一种通常都可以完成工作。不过,Rust 的开发者目前只能使用基于LLVMrustc编译器。虽然rustc工作得很好,但开发者也有合理的理由希望有一个替代品。事实证明,有两种不同的方法可以使用GCC编译Rust,虽然目前都还没有准备好。这两种方法的开发者都来到了2021年的 Linux Plumbers 大会,介绍他们的工作状况。

rustc_codegen_gcc 是为 Rust 编译器添加 GCC 后端;Native GCC ,是为 GCC 前端 添加Rust语言实现。

rustc_codegen_gcc

首先是 Antoni Boucher,他讲述了 rustc_codegen_gcc 项目。他开始说,rustc编译器是基于LLVM的,除此之外,这意味着它不支持GCC所支持的所有体系结构。LLVM有的,rustc也有,但它有一个API,可以插入一个替代的代码生成器。这个API可以用来通过libgccjit插入GCC的代码生成机制。这就是 rustc_codegen_gcc 所采取的方法。

为什么这会是一件有用的事情呢?Boucher说,Rust 语言越来越受欢迎,但它需要对LLVM所能提供的更多架构的支持。特别是Rust for Linux的工作突出了这个问题,但也有很多其他用户在那里。嵌入式系统的开发者会从更好的架构支持中受益,Firefox浏览器也是如此。

rustc_codegen_gcc现在支持许多Rust特性,包括基本和聚合类型、变量、函数、原子类型、线程本地存储、内联汇编、许多内部函数等等。该编译器在 Compiler Explorer 中得到了支持。libcore测试通过了,大部分的用户界面测试也通过了。作为一个实验,这个编译器已经被用来为m68k架构构建Rust代码;这项工作仍处于早期阶段,Boucher 说,但它表明确实有可能为LLVM不支持的平台构建Rust程序。

仍然有一些问题需要解决。一些属性仍然需要支持,调试信息的生成也是如此。生成的代码质量并不总是最好的。必须做更多的工作来支持新的体系结构。还不支持链接时优化(LTO),等等。这项工作也需要对libgccjit进行一系列的修改,其中大部分仍在审查中。

还有一些其他的问题,包括需要使用GCC的补丁版本,直到所有的修改都被合并到上游。即便如此,也需要回传这些补丁,以允许使用较早的GCC版本,这对于编译内核是很重要的。

即便如此,这个项目似乎已经取得了合理的进展。Boucher 指出,曾经有一个活跃的Pull Request,要把rustc_codegen_gcc添加到rustc编译器中, 它已经在 9月29日被合并了。

Native GCC

Philip Herron 接着谈到了 Rust的本地GCC前端,也就是gccrs。这个编译器不是LLVMGCC的混合体,而是GNU工具链中Rust语言的完整实现。这项工作是用C++编写的(更容易启动,他说),并打算成为主线GCC的一部分。它使用现有的binutils,并重新使用官方的Rust库(如libcore、libstd和libproc)。

演讲者再次提出了 "为什么?"的问题。他说,他喜欢大项目,所以这个项目看起来很有吸引力。它与LLVM中的问题解决方式形成了有趣的对比,也是一个很好的机会,可以看到GCC是如何处理一种现代的高级语言。一旦工作完成,在代码大小、寄存器分配和能源效率方面对结果进行比较将会很有帮助。

他说,有一个独立的Rust实现,会有很多好处。与GCC的紧密结合将对一些项目很有帮助,这些项目也将能够从GCC插件中受益。基于GCCRust编译器将使rustc在新平台上的启动更加容易。对链接时优化的支持应该得到改善,这种优化在混合编译器的情况下往往效果不佳。当然,GCC也会带来对更多目标架构的支持。

Herron 说,GCC支持Rust的工作早在 2014 年就开始了,但后来停滞不前;该语言的发展速度太快,GCC的开发者无法跟上它。这项工作后来在 2019 年重新启动;最近对Rust用于内核的兴趣正在帮助推动这个项目。包括开源安全和Embecosm 在内的多家公司正在支持以GCC为基础的Rust编译器的开发。有一个详细的计划,即在 2022 年底前发布一个 "最小可行产品(MVP) "编译器。

到目前为止,对核心数据结构和大多数控制流都有工作支持,尽管一些控制流的工作仍在进行中。泛型和特质解析工作。未来的工作包括宏、导入、未稳定的特性和内部函数。有趣的是,目前的编译器可以构建 "hello world",但它需要使用Unsafe 的代码;缺乏宏意味着println!()不可用,必须调用C语言的printf()函数来代替。

计划在未来进一步开展的工作包括借用检查器,这将与Polonius项目合作完成。增量编译也在列表中,还有将前端移植到旧的GCC版本。从长远来看,希望这项工作能够帮助推动Rust编译器的兼容性测试。

这些会谈的视频可以在YouTube上找到。

P99 Conf Talk 汇总 | Rust 在高性能低延迟系统中的应用

P99 Conf 是一个由 Scylladb 组织的新的跨行业的线上Conf,为工程师而设。该活动以低延迟、高性能设计为中心,范围包括操作系统(内核、eBPF、IO_uring)、CPU(Arm、Intel、OpenRisc)、中间件和语言(Go、Rust、JVM、DPDK)、数据库和可观测性方法。P99 CONF只面向技术人员,你的老板不在邀请之列。

四场演讲分别为:

  1. Rust,莱特定律和低延迟系统的未来
  2. 芜湖!我用 Rust 重写了推特的内存缓存框架 Pelikan 模块 !
  3. Rust 确实安全,但是它快吗?
  4. 使用eBPF、XDP和 io_uring 构建高性能网络

以下是本人观看四场 Talk 的过程中记录的摘要笔记。


Rust,莱特定律 和 低延迟系统的未来

《Rust, Wright’s Law, and the Future of Low-Latency Systems》- 作者: Bryan Cantrill, Oxide 计算公司 CTO

关于 Oxide 公司: https://cloud.tencent.com/developer/news/567765

莱特定律(Wright's Law): 莱特定律在1936年由西奥多·莱特(Theodore Wright)率先提出,核心内容就是说某种产品的累计产量每增加一倍,成本就会下降一个恒定的百分比。 在汽车领域,从1900年就遵循这一规律,产量每累计增加一倍,成本价格就会下降15%。

莱特定律 和 摩尔定律 共同被应用于预判科技如何改进。

系统性能的仲裁者:硬件。

系统性能的历史,能看到三类性能改革:

  1. process revolutions。半导体工艺(process)改革。
  2. Architectural revolutions。架构改革。
  3. Software revolutions。软件改革。

不管技术如何改革,这一切都得受经济的制约。

谈到 半导体工艺改革,就得谈到摩尔定律。

作者说到现在的 7nm、5nm、3nm工艺时,比较激动,他说,作为这个芯片行业的人,他觉得应该对这类术语负责,实际上并没有什么东西可以是5nm、3nm长,没有那么长的晶体管,这里实际指的是晶体管密度。

在晶体管工艺上,摩尔定律实际已经减速。因为低纳米的制程工艺成本很高,摩尔定律不再成为经济规律。

莱特定律比摩尔定律可以更好地预测晶体管变化的规律,它代表的是一个规模经济。晶体管累计产量越多,成本就会下降一个恒定的百分比。

你的电脑上有一堆隐藏的cpu核心,并且可能会越来越多。并且这些核心可能是不同工艺的核心的复合体。并且还可以把核心放到它需要的地方。

这就是来自莱特定律的礼物。这对性能产生了影响。

我想到了苹果发布的 m1 macbook。

当这些CPU 核心充斥到很多角落的时候,也就意味着那里多了很多计算单元,并且有很大一部分是资源有限的情况,比如没有过多内存等。

那如果你要为这些计算单元编写程序,你选择什么语言呢? Python ?Go ?Java? Cpp? C?

有些人说 CPP 和 C ,但是作者作为二十多年的 资深 C 开发者,他推荐 Rust。 Rust 在专用计算领域有杀手级特性: 它不依赖于标准库,所有核心功能都在 core里。作者的公司 Oxide 开发了一套基于消息传递、内存保护的操作系统,只需要 30k 空间(未优化)。

其实 tockOS 更小,自身编译后大小 4 KB 左右,内存占用 900 B,不到 1KB。

因为莱特定律的原因,未来将会在更多地方进行计算,并且这些计算元素将为特殊用途服务,在这些领域,Rust 将成为首选语言,而C 语言它存在于 软件与 硬件的边界。

作者最后说,不要等待,现在正是开发高性能低延迟系统的一个非常激动人心的时刻, Rust 革命就在这里发生。

芜湖!我用 Rust 重写了推特的内存缓存框架 Pelikan 模块 !

《Whoops! I Rewrote It in Rust》 - 作者 :Brian Martin, Twitter 软件工程师

用 Rust 的机遇 ?

Pelikan 框架 (之前是C 实现)需要增加 tls 支持,因为 Brian 的 经理没有时间做这个模块,就交给了他。 他认为这个模块用 Rust 实现会很舒服,所以就用了 Rust。

如何看待性能测试?

在深入之前,需要介绍下性能测试,他们对性能有两个目标:

  1. 最大化吞吐量
  2. 提供良好的延迟: p999 < 1ms (过去10秒内最慢的0.1%的请求的平均延迟要小于 1ms)

这里面需要考虑尾延迟(Tail Latency)的问题。

少量响应的延迟高于均值,我们把这些响应称为尾延迟(Tail Latency)。对于大规模分布式系统来说,尾延迟的影响尤其严重。 磁盘老化、超时、后台任务、超负载运行、全局资源共享等等。

  1. fanout = 1 , 只有 0.1% 的请求比 p999 慢
  2. fanout = 10, 会有 1% 的请求比 p999 慢
  3. fanout = 100, 会有 10% 的请求比 p999 慢

尾延迟发生的频率比你想象的高。

开始第一次尝试

对 2019 年用 Rust 实现的 Rust 缓存 Twemcache 为基础来添加 tls 支持,该项目特性:

  1. memcached 兼容
  2. Tokio 异步实现网络部分
  3. 使用 C 存储库

先进行了性能测试,发现吞吐量不满足要求。尾延迟比性能测试目标也高出 25%。和 C 实现的 Twemcache 版本相比,Rust 的延迟更高,吞吐量也是 Rust 更低。

Brian 再想, Rust 的性能是否可以更好。

他于是进行了第二次尝试。

Rust Pingserver vs C Pingserver

为了验证 Rust 的性能,他分别用 Rust 和 C 实现了一个简单的 Ping/Pong 服务。

然后为它们支持 性能测试相关内容,增加 tls 支持。

基准测试以后发现 Rust 版本 和 C 版本的 吞吐、延迟是完全持平的。

继续开始 Twemcache 原型实现

验证了 Rust 的性能之后,继续 Twemcache 原型实现:

  1. 临时将 C 存储库换成 Rust 的hashmap,为了专注于实现 memchached 协议的兼容
  2. 然后再把 C 存储库换回来

但是后面因为性能监控部分 metrics 和 log 导致会出现 Rust和C 交互的一些问题,Brian花了大量时间来处理这个问题,虽然找到了解决办法,但是也让他萌生了一个想法:为什么不用 Rust 完全重写 Twmcache 呢?

用 Rust 重写

C 存储库用 Rust 重写相当复杂。包括了很多自引用类型、内存分配、链表、到处都是指针、要用很多unsafe等等。

也许可以先从单线程版的 C 存储库开始会更简单。然后他就用 Rust 重写了。

优点:

  1. 纯 Rust
  2. 重写有助于改进代码
  3. 为设计增加新的idea
  4. 抛弃 make和cmake,愉快地使用 cargo

缺点:

两个月的工作量,还没有100%完成。

小结

Brian以及他的经理,认为这次重写,从 Rust 得到了很多收益。 Rust 让他们突破了 C 的限制,觉得这就应该是 Pelikan 模块的正确方向。

最终性能指标也达到了要求,并且比 memcached 延迟更低。

Rust 确实安全,但是它快吗?

《Rust Is Safe. But Is It Fast?》- 作者: Glauber,Rust异步框架 Glommio作者,曾经是Datadog 资深工程师,非常关注性能和资源管理。现在是 ChiselStrike 公司 CEO。

Why Rust

他选择 Rust 的理由:

因为 Rust 功能不如 Cpp 强大。 你没看错,他认为 Rust 不如 Cpp强大,所以选 Rust。他认为,当你给了代码编写者强大的力量,意味着,与此同时也剥夺了代码阅读者的权力。

Rust 的特性

依次介绍了 Rust 的一些特性,比如 Lifetime、 trait系统、Sync/Send 。异步 等。

Glommio

Glommio 是一个基于 io_uring 的 Thread-per-Core 架构的异步运行时框架。

什么是 Thread per core?

为了消除线程上下文的切换成本,引入了该架构。Thread-per-core 就是指每个内核或CPU都运行一个线程,而且通常(不是必须)这些线程都被“钉在”同一个特定 CPU 上。操作系统调度程序将不会随意移动这些线程,而且同一个CPU 内也不会有其他线程,因此就不会有上下文切换。

每个异步任务都会明确分配给特定线程。这种模式不需要锁。一般用于降低系统延迟。

Glommio有自己的内部调度器,它选择运行哪个任务队列,并为每个队列提供与其份额成比例的时间。通过从一开始就利用io_uring,Glommio可以重新审视Rust中的I/O应该是什么样子。

对于每个执行线程,Glommio都注册了自己的独立 ring (角色不同),可以无锁地操作。一个正常的请求,比如打开或关闭一个文件,从一个套接字中发送或接收数据,将进入主ring 或延迟ring,这取决于其延迟需求。

当这些请求准备好了,它们就会发布到io_uring的完成ring 上,Glommio就可以消费它们了。由于io_uring的架构,甚至不需要发出系统调用。事件存在于Linux和应用程序之间的共享内存区域,并由一个环形缓冲器管理。

相比于 Tokio

Tokio 默认是多线程运行时,所以需要 static 生命周期和 实现 Send 的 任务,而不能发送引用,这对于系统延迟是一个影响。

但是 Glommio 因为是 thread-per-core,所以可以方便地使用引用。Glommio 整个运行时没有使用太多的原子类型。

tokio 最近也在支持 io_uring 。

与其他运行时性能比较:

glommio 的吞吐量比 tokio、async-std、smol更高。

其他心得

在 Rust 中很难实现 零拷贝(zero-copy),并且很难与您希望进行零拷贝的任何接口一起使用。

使用eBPF、XDP和 io_uring 构建高性能网络

《High-Performance Networking Using eBPF, XDP, and io_uring》 - 作者:Bryan McCoid, Couchbase 分布式系统工程师,也是 Glommio 的积极贡献者,他更喜欢 thread-per-core 模型,因为这个模型更像分布式。

在网络世界中,有许多方法可以提高性能,而不是只会使用基本的Berkeley套接字。

这些技术包括轮询阻塞套接字,由 Epoll 控制的非阻塞套接字,io_uring ,一直到完全绕过Linux内核,通过使用像 DPDK 或 Netmap 这样的东西直接与网络接口卡对话,来获得最大的网络性能。

Bryan 这次演讲会介绍AF_XDP的设计,使用的eBpf代码,以及驱动这一切所需的用户空间代码。它还将包括与常规内核网络相比,这种设置的性能数字。最重要的是如何把所有这些放在一起,在一个现代多核系统上处理尽可能多的数据。

为什么不用 DPDK ? Bryan回答,因为它太复杂。

eBPF 是什么?

eBPF 是一套通用执行引擎,可以在 Linux内核执行沙箱程序。提供了可基于系统或程序事件高效安全执行特定代码的通用能力,通用能力的使用者不再局限于内核开发者;eBPF 可由执行字节码指令、存储对象和 Helper 帮助函数组成,字节码指令在内核执行前必须通过 BPF 验证器 Verfier 的验证,同时在启用 BPF JIT 模式的内核中,会直接将字节码指令转成内核可执行的本地指令运行。

同时,eBPF 也逐渐在观测(跟踪、性能调优等)、安全和网络等领域发挥重要的角色。Facebook、NetFlix 、CloudFlare 等知名互联网公司内部广泛采用基于 eBPF 技术的各种程序用于性能分析、排查问题、负载均衡、防范 DDoS 攻击,据相关信息显示在 Facebook 的机器上内置一系列 eBPF 的相关工具。

相对于系统的性能分析和观测,eBPF 技术在网络技术中的表现,更是让人眼前一亮,BPF 技术与 XDP(eXpress Data Path) 和 TC(Traffic Control) 组合可以实现功能更加强大的网络功能,更可为 SDN 软件定义网络提供基础支撑。XDP 只作用与网络包的 Ingress 层面,BPF 钩子位于网络驱动中尽可能早的位置无需进行原始包的复制就可以实现最佳的数据包处理性能,挂载的 BPF 程序是运行过滤的理想选择,可用于丢弃恶意或非预期的流量、进行 DDOS 攻击保护等场景;而 TC Ingress 比 XDP 技术处于更高层次的位置,BPF 程序在 L3 层之前运行,可以访问到与数据包相关的大部分元数据,是本地节点处理的理想的地方,可以用于流量监控或者 L3/L4 的端点策略控制,同时配合 TC egress 则可实现对于容器环境下更高维度和级别的网络结构。 ---- 来自于 《eBPF 技术简介》

XDP 是什么?

XDP 的引入是为了解决 Kernel Bypass 存在的一些问题。解决方案就是:给内核网络栈添加可编程能力。

这使得我们能在 兼容各种现有系统、复用已有网络基础设施的前提下,仍然实现高速包处理。 这个框架称为 XDP,

  • XDP 定义了一个受限的执行环境(a limited execution environment),运行在一个 eBPF 指令虚拟机中。eBPF 是 BSD Packet Filter (BPF) [37] 的扩展。
  • XDP 程序运行在内核上下文中,此时内核自身都还没有接触到包数据( before the kernel itself touches the packet data),这使得我们能在网卡收到包后 最早能处理包的位置,做一些自定义数据包处理(包括重定向)。
  • 内核在加载(load)时执行静态校验,以确保用户提供的 XDP 程序的安全。
  • 之后,程序会被动态编译成原生机器指令(native machine instructions),以获得高性能。

参考:[[译] [论文] XDP (eXpress Data Path):在操作系统内核中实现快速、可编程包处理(ACM,2018)](http://arthurchiao.art/blog/xdp-paper-acm-2018-zh/)

如何使用 XDP ,以及设计一个 Rust 运行时

Bryan 要把现存的 Glommio 框架,改为基于 xdp socket 的实现。

基本布局 :

1

我们使用普通的 socket() 系统调用创建一个AF_XDP套接字(XSK)。每个XSK都有两个ring:RX RINGTX RING。套接字可以在 RX RING 上接收数据包,并且可以在 TX RING 环上发送数据包。这些环分别通过 setockopts()XDP_RX_RING XDP_TX_RING 进行注册和调整大小。每个 socket 必须至少有一个这样的环。RXTX描述符环指向存储区域(称为UMEM)中的数据缓冲区。RXTX可以共享同一UMEM,因此不必在RXTX之间复制数据包。

UMEM也有两个 ring:FILL RING COMPLETION RING。应用程序使用 FILL RING 向内核发送可以承载报文的 addr (该 addr 指向UMEM中某个chunk),以供内核填充RX数据包数据。每当收到数据包,对这些 chunks 的引用就会出现在RX环中。另一方面,COMPLETION RING包含内核已完全传输的 chunks 地址,可以由用户空间再次用于 TXRX

参考 AF_XDP技术详解 ,有助于理解该 Talk内容。

也有很多其他设计。

2

3

以上是两种其他方案。

如何集成到 Glommio 中,请看代码 : https://github.com/bryandmc/glommio/blob/xdp/glommio/src/net/xdp_socket.rs

《Rust 安全编码规范》 初稿发布

作者: 张汉东


为什么需要《Rust 编码规范》

在最初学习 Rust 的那几年,我觉得 Rust 有rustfmt Clippy 这样的静态检查工具,就没有必要搞什么编码规范了。

也许大部分人也是这么想的,所以整个 Rust 社区现在都非常依赖这两个工具。

但我通过对 Rust 社区里各种开源项目观察,虽然大家都在用这两个工具,但却对这两个工具依赖的规则都不是非常了解。

比如,有的项目将 rustfmt 里的规则全部写到 rustfmt.toml 文件中,也不管里面有多少规则其实是默认的;有的项目则严格依赖 clippy 的警告去修改代码,而不去思考是否适合当前场景。

Rust 语言优秀的地方就在于,让开发者在编写代码的过程中就去思考代码的安全性和健壮性。Rust 项目每天都在不断增多,社区在不断涌入新人。rustfmtclippy 这种工具也只能在他们编写完代码之后,才能进行检测,无法在编写代码的过程中给予其规范和指导。并且 rustfmtclippy 只记录了一些可以静态检查的规则,实际 Rust 编程中还有一些原则性的问题是无法检测的。所以,社区需要《Rust 编码规范》。

《Rust 编码规范》有以下四个好处:

  1. 帮助 Rust 开发者在写代码之前或者写代码过程中,就了解地道的用法。
  2. 可以帮助开发人员,在面对 rustfmtclippy 这样的工具的规则,可以更好地做出决策。
  3. 同时,也提供了一个针对 Rust 编码规范的平台,帮助大家思考和讨论更加合理的规范,帮助 rustfmtclippy 进化。
  4. 可以让公司和开源产品以此为蓝本,定制自己的《Rust 编码规范》,更好地帮助 Rust 在企业内部落地,帮助开源贡献者们统一代码规范。

内容介绍

《Rust 编码规范》中文版项目仓库地址:https://github.com/Rust-Coding-Guidelines/rust-coding-guidelines-zh

《Rust 编码规范》在线渲染版地址: https://rust-coding-guidelines.github.io/rust-coding-guidelines-zh/index.html

规范的内容组织结构

目前《Rust 编码规范》包括以下六大部分:

  1. 《Rust 安全编码规范》,包括 Rust 开发环境、代码风格、 编程实践等内容,形成原则和规则。其中规则是可以静态检查的。
  2. 《Rust 领域最佳实践》, 包括 Rust 在各个领域中的最佳实践。
  3. 《Rust Cheat Sheet》, 综合 Rust 中的 Cheat Sheet ,开发技巧等。
  4. 《Rust 工具链使用指南》,介绍 Rust 生态中好用的工具链,比如 rustfmtclippy等静态分析工具,以及其他一些安全类工具。
  5. 《Rust 优化指南》, 综合 Rust 代码 性能、编译时间、编译大小的优化心得。
  6. 《Rust 生态常用库指南》,介绍 Rust 生态中常用的基础库、 框架等用法。

目前发布的是 《Rust 安全编码规范》 初稿。

《Rust 安全编码规范》的内容组织结构:

  1. 前言。 主要是安全编码规范的整体介绍,组织结构,规范约定。
  2. 开发环境。介绍了 Rust 开发环境的一些配置,包括 编辑器、IDE选择,国内镜像等。
  3. 代码风格。介绍 Rust 命名、 格式、注释的编码风格规范。
  4. 编程实践。按 Rust 语法特性分类,介绍 Rust 编程实践中该特性下需要注意的一些安全、技巧、性能方面相关的规范。
  5. 附录。包括 测试、术语、以及 建议的 rustfmtclippy 配置模板。

1 2 3 4

目前发布的初稿还比较粗糙,需要大家一起贡献完善。

贡献者召集

欢迎各大使用 Rust 开发产品的公司和开源项目团队试用和反馈。

如果愿意长期一起维护和完善该规范的朋友,可以申请加入 Rust编码规范 SIG 小组。

sig

目前已经得到了以下公司中团队成员和开源项目作者的支持:

  • 华为
  • 字节跳动
  • 知乎
  • PingCAP
  • Datenlord
  • Databend
  • 非凸团队
  • 海致图数据库成员
  • AsyncGraphql / Poem 作者
  • Rbatis ORM 作者
  • Delicate 作者
  • Rustsbi 作者

也欢迎各大 Rust 开发者和爱好者们,一起参与讨论和贡献。在 Rust 编码规范的 GitHub 项目下的 Discussions 里发帖交流。

关于具体如何贡献,我后面也会写一篇贡献者参考文档。

未来计划

本规范未来计划大概分个阶段:

  1. 将中文版规范继续完善,推进到 1.0 的状态。
  2. 翻译为 英文版,接受国际 Rust 社区开发者的意见和贡献。
  3. 推广到 Rust 官方。

希望能借助开源的力量,在 2022 年完成以上三个阶段的目标。

外刊评论 | 知名项目进展报告

编辑:张汉东

编者按:国外的 Rust 资源非常丰富,将其都翻译过来也不现实,所以想到这样一个栏目。外刊评论,主要是收集优秀的 Rust 相关文章,取其精华,浓缩为一篇简单评论。

欢迎大家贡献:https://github.com/RustMagazine/rust_magazine_2021/discussions/129


各大开源项目进展报告

GCC Rust

取得了里程碑式进展。通过阅读 rustc 的测试套件代码, 找出了测试用例的不少问题,正在取得稳定进展。

Black Hat Rust book 更新

  • 100% 的随书代码已上传 GitHub
  • 内容 完成度 90%,进入 beta 阶段
  • 所有的插图都经过修改

为什么要写这本书?从安全角度来学习 Rust。

SixtyFPS

SixtyFPS 是一个工具包,可以为任何显示器高效开发流畅的图形用户界面:嵌入式设备和桌面应用程序。

  • SixtyFPS 0.1.4 发布
  • cargo UI 0.3 发布

Fluvio

Fluvio 是一个开源数据流平台,可聚合、关联并将可编程智能应用于动态数据。Fluvio 由 Rust 提供支持,在云原生架构上提供低延迟、高性能的可编程流。

  • 我们为 MQTT 协议提供了一个新的连接器
  • 即将推出 table 功能,将启用具有结构化 JSON/YAML/TOML 数据的物化视图 。

Databend

Databend 旨在成为一个开源的弹性可靠的云仓库,它提供极快的查询和联合弹性,简单,云的低成本,内置使数据云容易。

新增了一些特性:

  • 新增 system.metrics
  • 为查询新增了简单的 REST API
  • 命令行新特性: 支持 admin 和 sql 模式;可通过 http 查询。

另外还做了一些改进和 修复了一些 Bug。

Rust Analyzer

新增了一些新功能:

  • 为关联方法自动增加类型前缀
  • 方便为多个方法创建模块

修复了一些 Bug。

Intellij Rust

新功能:为属性宏提供初始支持。可以展开属性宏了。

修复了一些 Bug。

Gloo

Gloo 0.4.0 版本发布。Gloo 是使用 Rust 和 Wasm 构建快速、可靠的 Web 应用程序和库的工具包。

gfx-rs

gfx-rs 是一个为 Rust 带来高效跨平台图形的项目,

  • wgpu 建立在 wgpu-hal 和 naga 之上,为图形应用程序提供安全性、可访问性和便携性。
  • naga 在语言之间翻译着色器程序,包括 WGSL,提供着色器验证和转换,确保在 GPU 上运行的用户代码安全高效。

现发布 wgpu v0.11 和 naga v0.7。

https://gfx-rs.github.io/2021/10/07/release-0.11.html

Rust Async trait 更新与多线程

作者: 王江桐

本篇将会简要介绍什么是《This Week in Rust》,第412篇推文中有关于Rust语言组十月会议总结,其中有关于Async fn的更新以及多线程的相关内容


语言组十月月会

Rust语言组会在每个月的第一个周三开一次例会,并总结本月更新方向。具体的会议描述可见链接:https://blog.rust-lang.org/inside-rust/2021/10/08/Lang-team-Oct-update.html

十月会议内容大致如下:

  • 实现traits中的异步函数,异步drop,异步闭包

  • 在模块和impl层面上实现如下类型定义type Foo = impl Trait

    • type Foo = impl Trait;
      
      impl SomeTrait for SomeType {
          type AssocType = impl Debug;
      
          ...
      }
      
  • 允许dyn trait类型向上覆盖,例如如果trait Foo: Bar,可以将类型dyn Foo 映射为dyn Bar

  • 允许关联类型包含泛型参数,例如type Foo<'me>

  • let else语法更新:在nightly版本中现在允许如下写法:let Ok(x) = something else panic!(),预备在stable版本中实现

  • 对于实现了DerefDerefMut的类型,允许使用match进行模式匹配,并且在进行匹配时使用Deref

  • !(Never)类型更新;目前部分此类类型由()表示,在保证现有代码功能性的情况下,之后可能会使用!表示

Async fn更新

在十月例会中,语言组确定了目前大体上对于异步函数功能的拓展,目标如下:

  • 在traits中实现Async fn,不论是在static或是在dyn上下文中
  • 实现Async drop
  • 实现Async closure

在traits中实现异步函数则意味着,循环、读、写等功能在未来都可以异步实现。

dyn trait

在讨论为什么异步函数很难在trait之中实现之前,我们需要先了解什么是dyn safe。

dyn trait是一种特殊的类型,通常情况下来说,dyn trait类型将被视作实现了该trait。例如:

trait DoTheThing {
	fn do_the_thing(&self);
}

impl DoTheThing for String {
    fn do_the_thing(&self) {
        println!(“{}”, self);
    }
}

fn some_generic_fn<T: ?Sized + DoTheThing>(t: &T) {
	t.do_the_thing();
}

fn some_nongeneric_fn(x: &dyn DoTheThing) {
    some_generic_fn(x)
}

对于some_generic_fn,我们可以传入一个String,因为它实现了DoTheThing这个trait。对于some_nongeneric_fn,由于x的类型是dyn DoTheThing,x将被视作实现了DoTheThing,因此可以将x作为参数传给some_generic_fn。

这样做的便利在于,当函数的参数类型是dyn trait时,用户可以自由地传入任何实现了该trait的类型,但是这个模式同样也带来了问题:编译器不知道这些类型的大小是什么。dyn trait作为一个类型无法和实际的类型,例如String,相互比较,因为dyn trait并没有大小,它的大小会随着具体实现该trait的类型大小而改变。因此,对于dyn trait,参数往往以某种指针的形式传入,例如Box<dyn DoTheTHing>或是如例子中使用引用,&dyn DoTheThing。泛型函数并不直接接受dyn类型,Rust因此提供了另一种可以在泛型函数中使用的写法,也就是T: ?Sized + DoTheThing

在编译器编译并决定什么时候使用什么函数时,通常有两种模式:静态派遣(static dispatch)与动态派遣(virtual / dyn dispatch)。在静态派遣模式下,编译器知道具体调用哪个函数;而在动态派遣模式下,编译器不知道具体是哪个函数,但是知道函数的地址,以及需要调用在这个地址的函数。当使用dyn trait时,编译器的模式是动态派遣,并且使用vtable来保留函数指针,每个指针指向trait中的方法。vtable形似如下struct:

struct DoTheThingVtable {
    do_the_thing: fn(*mut ())
}

第一个参数并不是常见的Self——对于dyn trait,我们并不知道实际上的类型是什么,因此只能保留某种指针。实际创建vtable可以看做创建例中struct的一个实例,例如对于实现了DoTheThing这个trait的String,其对应的vtable大概如图:

static Vtable_DoTheThing_String: &DoTheThingVtable = &DoTheThingVtable {
    do_the_thing: <String as DoTheThing>::do_the_thing as fn(*mut ())
    //            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //            Fully qualified reference to `do_the_thing` for strings
};

在Rust中,形似于&dyn DoTheTHing的dyn trait指针是一个宽指针,在运行时包含两个指针,一个指向数据本身,另一个指向trait。如例中该宽指针值为:(*mut (), &’static DoTheThingVtable)。当&String被映射为&dyn DoTheThing,实际上它会先被映射为*mut (),然后再与对应的vtable相关联。因此,当使用泛型时,在编译的时候泛型会被解析为固定的类型,就像其他非泛型的函数参数一样,类型具有具体的限定。

dyn safe

一个trait只有在它不包括泛型时可以安全作为dyn trait使用。参考如下例子:

PrintPrefixed:

trait PrintPrefixed {
    fn prefix(&self) -> String;
    fn apply<T: Display>(&self, t: T);
}

impl PrintPrefixed for String {
    fn prefix(&self) -> String {
        self.clone()
    }
    fn apply<T: Display>(&self, t: T) {
        println!(“{}: {}”, self, t);
    }
}

对于prefix,使用dyn trait时,vtable可以建立StringPrintPrefixed的关联,但是使用apply时,关联将无法被建立,因为实现了Display这一trait的具体类型是什么仍是未知的。

相反,在静态派遣的模式下,除非被调用,我们不需要知道T是什么类型,在调用时也只需生成副本保证运行。

虽然trait可以同时包含dyn safe和非dyn safe的方法,在目前版本的Rust中,只要有一个方法不是dyn safe,那么整个trait就不是dyn safe,因为trait无法拆分方法来实现。虽然dyn PrintPrefixed在静态检查时可以通过,但是除非PrintPrefixed中所有方法都是dyn safe的,不然dyn PrintPrefixed并不会被认作实现了PrintPrefixed trait。不过,对于&dyn PrintPrefixed类型,prefix函数依然可以使用。

对于关联类型,dyn trait必须声明所有的关联类型具体是什么。例如:

trait Iterator {
    type Item;

    fn next(&mut self) -> Option<Self::Item>;
}

impl<T> Iterator for dyn Iterator<Item = T> {
    type Item = T;
    
    fn next(&mut self) -> Option<T> {
        let next_fn = /* get next function from vtable */;
        return next_fn(self);
    }
}

这个trait不涉及其他的泛型,因此它是dyn safe的。但是在实际使用时,必须声明Item的类型:dyn Iterator<Item = u32>

问题来源与解决方案

目前的问题在于,如果在traits中编写async fn,例如async fn foo(&self),trait以及impl块中语法糖会被解糖为:

trait Trait {
    async fn foo(&self);
}

impl Trait for TypeA {
    async fn foo(&self);
}

impl Trait for TypeB { ... }

// 解糖后
trait Trait {
    // 匿名关联类型
    type Foo<'s>: Future<Output = ()> + 's;

    fn foo(&self) -> Self::Foo<'_>;
}

impl Trait for TypeA {
    // 匿名关联类型
    type Foo<'s> = impl Future<Output = ()> + 's;

    fn foo(&self) -> Self::Foo<'_> {
        async move { ... } // has some unique future type F_A
    }
}

impl Trait for TypeB { ... }

因此,如果trait使用了异步,那么impl块也需要使用异步函数,反之亦然。

问题在于,使用异步函数的trait在dyn情况下并不安全,因为我们并不知道Future具体是什么类型;使用dyn时必须列出所有的关联类型的值,也就是说,如果要使用dyn,必须这样写:

// XXX是impl块定义的future类型
dyn for<'s> Trait<Foo<'s> = XXX>

一个更具体的例子:

trait AsyncIter {
    type Item;
    async fn next(&mut self) -> Option<Self::Item>;
}

// 需要写成如下形式
for<'me> dyn AsyncIter<
    Item = u32, 
    Next<'me> = SleepyRangeNext<'me>,
>

不论繁琐以及是否可能,这使dyn trait限制于某一个特定的impl块,而这与dyn trait的设计意图冲突:在使用dyn时,用户并不知道实际上的类型是什么,只知道类型实现了目标trait。出于这个原因,一个使用#[async_trait]的改进方式如下:

#[async_trait]
// to state whether Box<...> is send or not if desired: #[async_future(?Send)]
trait Trait {
    async fn foo(&self);
}

// desugars to

trait Trait {
    fn foo(&self) -> Box<dyn Future<Output = ()> + Send + '_>;
}

这样子做可以通过编译,缺点在于,哪怕不使用dyn trait,也会在堆上为Box分配空间,以及用户必须提早声明Box<...>是否实现了Sendtrait。这会带来不必要的麻烦,并且与Rust的设计意图冲突。

根据dyn trait、Rust本身的设计意图、以及其他的一些限制来看,要解决async fn in traits的问题,必须满足以下条件:

  • 在trait中使用async fn应该不强制要求堆分配
  • 当使用dyn trait,对于所有impl块,future的类型应该相同,使得dyn trait泛用,而非局限于某个特定类型。也就是说,future应与实际的impl块独立,并且它应当有固定的大小,保证编译器可以编译

目前的进展是,语言组已实现初步的static async fn in traits,但是只能与impl trait或泛型一起使用,具体可见这篇MVP(Minimum viable product最小可行产品)。目前解决方案的缺陷在于:

  • 不支持dyn
  • 无法命名Future types并且获取它们的引用
  • 无法限定这些Future,例如限定它们必须实现Send

Async Drop

异步drop的实现将允许在drop时进行await调用。异步drop的实现问题目前有:

  • 对于泛型函数foo:

    • async fn foo<T>(t: T) {
          runtime::sleep(22).await;
      }
      
    • 函数foo(t)的类型将会是一个Future,其中包括了所有函数内调用的Future的类型。然而对于泛型T,在编译时我们无法得知T是否也是异步的,由此无法推断函数的类型,也无法推断该函数是否需要实现async drop
  • 无法确定async drop一定会被调用,当调用drop时,编译器或许会调用同步的drop而非异步的

  • 如果实现了async drop,并且代码有可能因为报错而逻辑中断时,例如:

    • async fn foo(input: &QueryInput) -> anyhow::Result<()> {
          let db = DatabaseHandle::connect().await;
          let query = assemble_query(&input)?;
          let results = db.perform_query(query).await;
          while let Some(result) = results.next().await? {
              ...
          }
      }
      
      • 假设db实现了async drop,对于使用?的函数,它们有概率报错并且使函数在逻辑上应当结束,并调用drop清理函数中每一个变量。因此,如果?出错,AsyncDrop::async_drop(db).await应被调用,而Rust认为不应由用户手动处理这些事情。同时,这也削弱了Rust对.await的要求:每一个阻塞点应该都明确写出,而非隐式调用

此次会议以及”async fn fundamentals initiative“中暂时没有提到关于Async drop问题的解决方案。

Rust多线程

多线程(Multithreading),多进程(Multiprocessing),与多协程(Coroutine)

多线程(Multithreading)

多线程是一种并发执行机制。一个线程可以同时运行一个任务,多个线程则可以运行多个任务。用最简单的话来说,多线程就是计算机同时运行多个线程,从而在同一段时间内并列同时执行多个任务。实际上,虽然CPU可以创建并协调多个线程,目前一个CPU在同一时间只能运行一个线程。在这种情况下,对于单一的CPU而言,线程在实际运行中也不是并行(parallel)的,而是并发(concurrent)的,CPU的调度器会决定何时切换线程,线程的运行顺序无法人为控制。

一个进程可以创建多个线程,这些线程的运行彼此独立,但是它们共享同一个进程资源。

多线程的优势在于:

  • 加速程序运行,提高CPU利用率,在有多个CPU的情况下,线程与线程之间甚至可以真正地并行运行;
  • 与线程之间彼此独立,某个线程的错误或阻塞不会影响其它线程。

多线程的缺陷在于:

  • 切换线程时有上下文切换的开销,在一些场景下,这些切换或许会造成不必要的性能损耗;
  • 线程与线程之间进程资源共享,会导致资源竞争问题,例如阅读与修改同一资源,线程饿死,std输出竞争。

多线程常用于:

  • 处理程序在一部分情况会阻塞,但是在另一部分需要持续运行的场合,例如网络服务器同时接收并处理多个请求;
  • 加速程序运行效率,拆分任务并提高CPU利用率,例如图像分析算法使用多线程计算图像的不同部分,并在最后汇总结果;
  • IO密集型计算,同时运行的任务数目要求不多。

多进程(Multiprocessing)

简单来说,进程可以这样定义:

进程可以简单的理解为一个可以独立运行的程序单位,它是线程的集合,进程就是有一个或多个线程构成的。而线程是进程中的实际运行单位,是操作系统进行运算调度的最小单位。可理解为线程是进程中的一个最小运行单元。

与多线程相对的概念之一是多进程。在日常生活中,运行多个软件的情况也是计算机正在运行多进程。多进程和多线程有一定的相似度,例如进程和进程之间彼此独立,并且可以提高多核CPU利用率,但是进程和进程之间并不共享内存,同时进程的上下文切换开销一般大于线程的上下文切换。上下文切换更具体的介绍可以参考博文《结合中断上下文切换和进程上下文切换分析Linux内核的一般执行过程》。此外,由于内存不共享,比起共享进程资源的多线程,在一些场景下,多进程需要额外解决通信问题。

创建多进程与多线程的时间开销受操作系统影响。Windows环境下,进程创建开销很大,而在Linux环境下则很小。《多进程和多线程的概念》博文中记录了作者的一个实验:

可以做个实验:创建一个进程,在进程中往内存写若干数据,然后读出该数据,然后退出。此过程重复 1000 次,相当于创建/销毁进程 1000 次。在我机器上的测试结果是: UbuntuLinux:耗时 0.8 秒 Windows7:耗时 79.8 秒 两者开销大约相差一百倍。 这意味着,在 Windows 中,进程创建的开销不容忽视。换句话说就是,Windows 编程中不建议你创建进程,如果你的程序架构需要大量创建进程,那么最好是切换到 Linux 系统。

在Linux中,可以使用fork()命令来根据当前父进程复制一个子进程。

多进程常用于在程序中的重复操作多、且重复操作多为计算操作,即CPU密集型的场景。这些重复计算任务如果使用多线程,由于需要频繁切换上下文,效率不如多进程。

多协程(Coroutine)

协程的概念可见《This Week in Rust #406:Rust异步》。协程优点在于内存开销最少,适用于IO密集计算以及多任务运行。

Rust实现多线程

Rust中,线程可以用std::thread::spawn创建,线程创建时要求一个闭包,作为线程运行的函数使用。线程创建后就与当前线程独立,彼此运行时相互不干扰,当目前线程不是主线程时,创建的线程运行时长可以长于目前线程;对于主线程而言,主线程终止,则程序终止,程序所创建的所有线程都会停止。因此,不论线程在哪里创建,线程接收的闭包内的变量应与主线程生命周期一致。最便捷的实现方式是使用move关键字,让线程拥有变量所有权,并管理变量何时drop。

如果主线程不等待子线程,当主线程运行结束时,子线程可能仍未运行完毕。在这种情况下,子线程会随着主线程的结束被提早关闭,无法完成任务。创建线程时,std::thread::spawn方法会返回JoinHandle,调用它的join方法可以阻塞当前线程,并等待目标线程返回。同时,JoinHandle可以携带子线程的返回值,使用join也可以获取它的返回值。

std::thread模块概述

std::thread模块提供了std库中与多线程相关的功能。

主要包含函数如下:

  • spawn:生成新线程并运行,返回对应的JoinHandle
  • yield_now:当前线程强行放弃运行,让其他线程运行。在调度上不是最优解,在放弃运行时可能没有其他线程准备好运行,可能造成资源浪费
  • sleep:使当前线程暂停运行一段时间
  • park:阻塞当前线程,直到其他线程调用当前线程的unpark函数
  • park_timeout:阻塞当前线程,直到unpark被调用或者超时
  • current:获取当前线程对应的句柄

主要包含struct如下:

  • Thread:处理线程的句柄。通常推荐使用Builder或者spawn函数生成线程,而非手动创建Thread实例。
    • unpark:原子操作,使对应线程从park阻塞状态恢复
    • id:返回线程ID
    • name:返回线程名字,返回值为Option;默认没有名字,返回None
  • ThreadId
  • JoinHandle:join线程句柄,当JoinHandle退出作用域时,JoinHandle与对应的线程分离,将导致对应的线程无法被join
    • thread:返回线程句柄引用
    • join:等待对应线程运行结束,阻塞当前线程,返回Result;如果对应线程panic,Result值为Err
  • LocalKey:线程私有存储,可以用来存放线程共用变量,并使每一个线程启动时该变量都是初始化时的值。使用thread_local!宏建立
    • with:返回key中值的引用
    • try_with:同with,但是返回Result
  • Builder:线程仓库,可以修改线程运行设置,目前支持设置线程名字以及线程可用栈大小
    • new
    • name:设置线程名字,返回Builder
    • stack_size:设置线程可用栈大小,返回Builder
    • spawn:以当前设置生成线程并运行,返回Result

共享资源

当线程运行还未结束,但是线程之间需要共享资源时,可以使用std::sync::mpsc::channel。Rust标准库中的mpsc意思是多个生产者,单一消费者(Multiple producer, single consumer),因此这类channel可以有多个发送者,但是只能有一个接受者。发送的信息必须获得所有权而不能是引用,以便于在线程之间转移所有权。

std::sync::mpsc::channel<T>返回(Sender<T>, Receiver<T>)。在很多习惯中,会把返回值命名为(tx, rx),取transmit/TX mode、receive/RX mode的简称。当所有的发送者都退出作用域,或唯一的接受者退出作用域,channel关闭,不再起效。channel关闭以后,receiver的iterator会返回None,从而结束对接收值的遍历。

一个简单的例子如下:

use std::mem;
use std::thread;
use std::sync::mpsc;

fn main() {
    let (sender, receiver) = mpsc::channel();
    for i in 0..10 {
        let sender = sender.clone();
        thread::spawn(move|| {
            sender.send(i).unwrap();
        });
    }

    // drop the original sender
    mem::drop(sender);

    // this will wait until all senders are dropped
    // if the original sender isn't dropped manually, it will never be dropped, so this waits forever
    for received in rx {
        println!("Got: {}", received);
    }
}

由于Sender的迭代器返回None的条件是所有发送者都被drop,如果在同一个线程中,函数创建了最初的Sender,同时又调用Receiver接收信息,那么需要在接收之前使其退出作用域,例如手动drop第一个Sender,或是使用大括号,不然遍历将永远不会结束。例如在例子中,为了使程序正确结束,main()需要手动drop sender。

std::sync::mpsc模块概述

std::sync::mpsc模块提供了std库中与多线程通信相关的功能。

主要包含函数如下:

  • channel:生成异步通信通道,返回对应的Sender和Receiver。Receiver收到信息的顺序与Sender发送的顺序相同,当没有Sender发送信息时,Receiver会被阻塞

  • sync_channel:生成同步通信通道,返回对应的SyncSender和Receiver。Receiver收到信息的顺序与SyncSender发送的顺序相同,当没有Sender发送信息时,Receiver会被阻塞。与channel的不同点在于,sync_channel内部缓冲区有大小上限,当缓冲区已满,SyncSender.send会被阻塞,直到缓冲区重开。缓冲区大小可以为0,在这种情况下,除非有Receiver与SyncSender一对一配对,不然SyncSender将被阻塞

主要包含struct如下:

  • Sender<T>

    • clone:复制sender,使得多个线程可以使用sender向同一个receiver发送信息
    • send:发送信息,返回值为Result,当Receiver退出作用域时返回SendError
  • SyncSender<T>

    • send:同Sender.send
    • try_send:同send,但是不阻塞
  • Receiver<T>

    • recv:阻塞当前线程,依次接收缓冲区内所有信息,当所有的Sender退出作用域时返回RecvError

    • try_recv:同recv,但是不阻塞

    • recv_timeout:同recv,超时后不阻塞

      • recv_timeout有错误仍未修复,例如以下例子中,recv_timeout会意外panic。具体可见issue #39364

        use std::sync::mpsc::channel;
        use std::thread;
        use std::time::Duration;
        
        let (tx, rx) = channel::<String>();
        
        thread::spawn(move || {
            let d = Duration::from_millis(10);
            loop {
                println!("recv");
                let _r = rx.recv_timeout(d);
            }
        });
        
        thread::sleep(Duration::from_millis(100));
        let _c1 = tx.clone();
        
        thread::sleep(Duration::from_secs(1));
        
    • iter:返回可以阻塞线程的迭代器,当通道关闭,迭代器返回None

    • try_iter:同iter,但是除了通道关闭以外,如果当前没有信息被发送,try_iter同样返回None

资源竞争与锁

由于多线程共享进程内存,当线程需要获取同一个数据并对其进行非只读操作,资源竞争就有可能出现。为了防止资源竞争,可以使用Mutex保护数据。由于不同的线程需要获取同一个锁,通常使用Arc包裹Mutex,在保证操作不会被线程切换打断的同时,使锁可以存在多个引用。除了MutexRWLock读写锁也可以防止资源竞争。读写锁允许多个读用户同时存在,而Mutex不区分获取锁的种类,同时只能有一个用户获取锁。ArcMutexRWLock更详细的介绍可见《This Week in Rust #407 & 408:Rust 生命周期和智能指针》。

Mutex的一个简易例子如下:

use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

pub fn frequency(input: &[&str], worker_count: usize) -> HashMap<char, usize> {
    // threads will all write frequency to this HashMap
    let result = Arc::new(Mutex::new(HashMap::new()));
    let chunks = input.chunks((input.len() / worker_count).max(1));
    let mut handles: Vec<_> = Vec::new();

    for chunk in chunks {
        let string = chunk.join("");
        let result = Arc::clone(&result);
        let handle = thread::spawn(move || {
            let mut map: HashMap<char, usize> = HashMap::new();
            // create a HashMap for this chunk
            for c in string.chars().filter(|c| c.is_alphabetic()) {
                *map.entry(c.to_ascii_lowercase()).or_default() += 1;
            }
            // add the HashMap of this chunk to the HashMap that is wrapped by the Mutex
            let mut result = result.lock().unwrap();
            for (key, value) in map {
                *result.entry(key).or_default() += value;
            }
        });
        handles.push(handle);
    }

    // wait for each thread to finish
    for handle in handles {
        handle.join().unwrap()
    }

    // get the HashMap from the Arc<Mutex<HashMap>>
    Arc::try_unwrap(result).unwrap().into_inner().unwrap()
}

引用

Rust调研与内存安全

作者: 王江桐

本篇将会简要介绍什么是《This Week in Rust》,Rust 2021(1.56),第409篇推文中有关已通过的RFC2845,supertrait_item_shadowing,Rust安全性,以及第413篇推文中关于OpenSUSE 2021 Rust Survey和Rust安全性的内容。


社区新闻与更新

Rust 2021(1.56)

Rust 2021如预期已于10.21号上线,具体可见官方博文“Announcing Rust 1.56.0 and Rust 2021”或是官方《Edition Guide》。可以使用如下命令更新到1.56.0版本:

$ rustup update stable

具体的版本更新内容基本同5.11的新版本前瞻,以及具体如何迁移到新版本使用新的功能,可见之前的总结《This Week in Rust #404:Rust 2021 与 周期性服务发现》,在此不再赘述。前瞻中未提及的新功能恢复了Rust 1.0之后移除的功能,使用@符号绑定值,如以下例子:

struct Matrix {
    data: Vec<f64>,
    row_len: usize,
}

// Before, we need separate statements to bind
// the whole struct and also read its parts.
// 之前,在获取结构式中某一个属性的值时,我们必须一步步操作,先将结构式的值绑定给某个变量,然后从它的从属属性中获取值
let matrix = get_matrix();
let row_len = matrix.row_len;
// or with a destructuring pattern:
// 或者使用分解模式
let Matrix { row_len, .. } = matrix;

// Rust 1.56 now lets you bind both at once!
// 现在,使用@,Rust 1.56可以同时做到这两件事情
let matrix @ Matrix { row_len, .. } = get_matrix();

RFC 2845:supertrait item shadowing

概括来说,RFC 2845修改了作用域内方法定位,当泛型实现的子trait和父trait有同名方法时,除非显式声明,否则不再考虑父方法trait,从而消除之前编译器认为方法名混淆的问题。

如以下例子:

mod traits {
    trait Super {
        fn foo(&self);
    }

    trait Sub: Super {
        fn foo(&self);
    }
}

两个trait拥有同名方法foo。如果我们再定义一个泛型类型:

use traits::Sub;

fn use_trait_obj(x: Box<dyn Sub>) {
    x.foo();
}

那么编译器会产生如下告警:

error[E0034]: multiple applicable items in scope
  --> src\main.rs:10:4
   |
10 |     x.foo();
   |       ^^^ multiple `foo` found
   |
note: candidate #1 is defined in the trait `traits::Super`
  --> src\main.rs:2:2
   |
2  |     fn foo(&self);
   |     ^^^^^^^^^^^^^^
   = help: to disambiguate the method call, write `traits::Super::foo(x)` instead
note: candidate #2 is defined in the trait `traits::Sub`

如同这个例子,实际应用的问题在于,对于定义父trait的底层库和子trait的上层库,如果开发者更新底层库,使父trait拥有和子trait的同名方法,那么使用泛型调用方法时,代码就会告警,并且这些告警可能会造成一系列的连锁反应。修改这些告警可能是非常费事费力的,并且要求双方库都对彼此知情,从而消除名称混淆。然而,在逻辑上,底层库并不应知道上层库,同时开发或使用上层库的用户也并一定不知道底层库。

因此,为了解决这个问题,RFC 2845决定优先使用子trait中的方法,并且除非明确声明,不考虑使用父trait的同名方法。例如:

fn generic_fn<S: Sub>(x: S) {
    // This:
    x.foo();
    // is the same as:
    // x.foo()将会使用Sub::foo而不是Super::foo
    Sub::foo(x);
    // also still possible:
    // 但是可以通过显式声明来调用Super::foo
    Super::foo(x);
}

如果同时引入子trait和父trait,那么由于此时两个trait的关系是并列的,用户需要显式声明使用哪个方法:

fn generic_fn<S: Sub+Super>(x: S) {
    // Error: both Sub::foo and Super::foo are in scope
    x.foo();
}

类似的,在子trait中编写方法时,也需要声明使用的是自己的方法还是父trait的方法:

trait Super {
    fn foo(&self);
}

trait Sub: Super {
    fn foo(&self);

    fn bar(&self) {
        // Is and will continue to be an error
        self.foo();
    }
}

Rust与内存安全

OpenSUSE 2021 Rust Survey

从2006年到2021年,个人项目到Rust基金会成立、Rust发布2021版本,Rust作为新兴开发语言之一,其高可靠、高性能、高生产力的特性使得它逐渐成为了新的热点,并且成为StackOverflow调研中连续六年最受欢迎的语言。从2021年9月8日至10月7日,OpenSUSE帮助调研了1360人他们关于Rust的想法,以及如何使用Rust工具链。详细的报告可见”Results from the OpenSUSE 2021 Rust Survey“。以下是部分调研结果:

  • 态度
    • 44%的人认为Rust对于他们的项目和工作很重要(评分4-5 / 5;599 / 1360)
    • 57%的人认为Rust会对他们未来的项目和工作越来越重要(评分4-5 / 5;778 / 1360)
    • 60%的人人为Rust会对未来的其他开发者和项目越来越重要(评分4-5 / 5;820 / 1360)
  • 使用

4

虽然数据可能因为调研者大多来自于Rust社区而略有偏差,但这个调研依旧可以部分证明Rust在目前以及未来的重要性。

Rust与内存安全

Rust致力于实现能与C和C++相匹配的高性能,但是相较于C和C++,Rust的另一优势在于内存安全。"You Really Shouldn’t Roll Your Own Crypto: An Empirical Study of Vulnerabilities in Cryptographic Libraries"中指出,对于密码库中的安全问题,27.2%的问题来自于算法,但是37.2%的问题来自于内存安全,而发现这些错误的中位数为4.18年。非常著名的密码与安全协议C语言库,Openssl,在文中证实平均每1000行代码就存在1个问题。

1

谷歌对于Chromium项目的调研得出了类似的结论:70%的安全问题是内存安全问题,例如由C/C++错误指针导致的问题。同时,非安全的bug根错误也与此相同。此外,超过50%的安全问题时间超过1年,约25%超过3年。

2

3

目前谷歌的解决方案是使用沙盒隔离每一个网站和Tab,然而这个解决方案并不是完美的。进程是沙盒隔离的最小单位,但是进程的开销并不是免费的,例如在安卓环境下,进程过多会影响设备安全,例如杀死后台其他进程。同时,共享网页之间信息的进程仍然是需要的,越来越多的进程会超出Chromium需要的进程,同时影响运行效率。

Rust:可能的解决方案

除了沙盒,谷歌也在探索新的解决方案,例如自研C++库,硬件迁徙,以及使用其他更安全的语言。谷歌的2021年9月21日博文中简要介绍了目前正在实施的解决方案,运用编译时和运行时检查保证指针正确,以及,探索Rust。

运行时检查有性能开销,对于内存较小的移动设备并不一定适用;对于编译时检查,C++借用检查的实现则不是非常容易。关于谷歌对于C++借用检查的文档可见“Borrowing Trouble: The Difficulties Of A C++ Borrow-Checker”

对于Rust,谷歌担心C++和Rust的代码是否可以互相兼容使用,Rust在Chrome的开发使用仍在测试阶段,不过已开始逐渐在各项目使用,例如安卓。自从2019年,安卓开源项目(Android Open Source Project)支持Rust作为开发语言开发操作系统,作为一种方案用来代替C和C++,并且使用更少的沙盒隔离。

将Rust集成进安卓项目

将Rust集成进安卓开源项目时,谷歌进行了一些改进。谷歌不使用Cargo作为默认构建系统和包管理器,而是使用自研Soong来调用rustc。这样做的理由有:

  • 维系之前建立的体系。在Rust引入之前,Soong有一套成熟的体系构建C库和依赖,同时安卓会控制编译器的版本和全局编译变量,来保证库以某种特定的方式被编译。如果使用Cargo,那么这些设置将不再被Soong所控制,可能会导致同一个库的版本差异,影响内存和存储使用。
  • 保证体系稳定性和控制性。尽管Cargo通过Cargo.toml也可以做到一些控制,Soong并不理解Cargo.toml如何影响rustc,因此不如只使用Soong。
  • 维系封闭构建体系。自包含且对主机配置不敏感的构建,也就是封闭构建,是Android生成可重现构建所必需的。Cargo 依赖于build.rs脚本,尚未提供密封性保证。
  • 保证可拓展性。Cargo并不是为了集成到现有构建系统内而设计的,因此并不会公开它的编译单元。每次Cargo的调用都会以给定的Cargo.toml编译整个crate依赖关系图,使得在一整个项目中,crate会被构建多次、对于Soong而言,这样构建方式太过于粗糙;Soong需要更小的构建单元,来控制Rust的使用规模。

Cargo通过build.rs脚本编译Rust二进制文件并处理这个过程中的一些构建之前的任务,例如设置环境等。因此,避免build.rs在某种程度上可以避免使用Cargo。同时,谷歌开源项目也有其他的理由避免使用build.rs:

  • build.rs可以在构建主机上执行任意代码,如果使用三方库,这会导致额外的安全问题或是安全检查问题。
  • 三方库的build.rs脚本可能并不是封闭构建或者是可以重复使用的。同时,对于build.rs脚本,获取构建文件夹之外的文件并不少见,然而这意味着build.rs并不是封闭构建,需求额外的本地补丁或者是上游合作来解决这个问题。
  • build.rs最常用的功能室构建C库,不过Soong已经解决了这个问题,并不额外需要build.rs。
  • 基于类似的理由,其他语言库的构建也会避免构建脚本的使用,而是在Android.bp文件中使用它们来告知架构。

Rust和先存C代码库的交互

谷歌对于Rust和现存C代码库的交互这一问题同样得出了结论。通常而言,在安卓平台上,语言之间的交互通过定义外部函数接口(Foreign Function Interface, FFI)来实现。对于C而言,Rust可以提供相应的FFI接口,并且C也有充足的应用二进制接口(Application Binary Interface,ABI),因此C和Rust的交互并不是大问题。

Rust和C++的交互相对则会更困难。虽然Rust和C++的交互支持都使用C的ABI,但是这两个语言的特性使得两个语言本身并不能非常容易地直接翻译彼此的功能。对于谷歌而言,Rust和C代码库的交互问题根源来源于大量已存在的C/C++代码库。谷歌侧重于调研在Rust中使用C++函数的可能性,查看了导出的C++函数以及Rust通过C ABI和兼容库支持的兼容性,最后将可表达的类型分类至以下几类:

  • 可以被原始类型支持的类型,包括指针和对这些指针的引用。Rust现有的FFI可以正确地处理它们,安卓本身的构建系统会自动生成绑定。
  • 可以被cxx compat crate支持的类型,目前包括std::string,std::vector,以及C++的一些方法。只要用户定义了他们想要兼容的类型和函数,这些crate就会产生正确的对应代码,从而实现兼容。
  • 对于有些类型,虽然它们不能被直接支持,但是使用它们的接口已被手动修改并添加Rust支持,例如AIDL和protobufs使用的类型。
  • 未来可能会添加到cxx crate的类型,例如std::optional和std::chrono::duration。
  • 我们不打算支持或不需要支持的类型,例如互斥锁,native_handle,std::local&。
  • 其他不属于以上任何分类的其他类型,例如以值传递的std::string。以值传递的std::string目前并不被任何cxx crate所支持。

谷歌随后调研了安卓上最常用的C++库(liblog,libbase,libutils,libcutils,libhidlbase,libbinder,libhardware,libz,libcrypto,libui)以及Mainline模块,分析并分类了这些库使用的所有外部C++函数和参数,以确定它们是否可以和Rust互通。

  • 常用的C++库

5

  • Mainline模块

6

对于库而言,87%属于前四类;对于Mainline模块而言,90%属于前四类。对于已进行一年的安卓开源项目,当前的交互分析如下:

7

也就是说,原始类型和 cxx crate提供了安卓锁需要的绝大多数的Rust和C++的互操作性,大部分的其他的类型也有对应的解决方案。Rust和C++之间的互操作性使得在安卓环境下使用Rust开发是可实现的。

引用

对外输出 Mocks 接口 和 Tokio 的高延展性服务器设计

作者:陈明煜

《This Week in Rust》是由Rust语言组编写的一份Rust社区周报,简要介绍一周内Rust社区新动态,例如Rust项目更新、应用、语言介绍、新的crate、项目组通过的语言修订、相关工作、等等。可以通过Rust官网 - Community - Read about Rust (This Week in Rust)来阅读原文以及其他周报,或者通过以下链接阅读10.06的第411篇推文

本篇主要包含了以下内容

  • 对外输出Mocks接口
  • Tokio的高延展性服务器设计

对外输出Mocks接口

一个Rust Library通常会被细分成好几个crate,这样做极大地简化了代码结构,但却留下了一个问题:任何被标注了#[cfg(test)] 特性的代码通常无法被export到该crate外。这样的设计在大多数情况下是非常合理的,但在有些时候,如果多个crate需要用到同一套测试性的接口,比如说打桩,这就需要将这些标有 #[cfg(test)]的代码公开出去。这篇文章将会阐述如何将打桩接口公开出去,使其他crate甚至是其他用户可以打桩你的结构体和函数,以此来测试他们自己的代码。

为了能更清楚的讲明白这个问题,下面我们用一个叫 testable-renderer 的库进行举例。这个库使用了一个十分常见的打桩工具faux,并在 dev-dependencies 里标明使用了 faux 打桩库。


#![allow(unused)]
fn main() {
[package]
name = "testable-renderer"

[dev-dependencies]
faux = "^0.1"
}

testable-render的具体实现:


#![allow(unused)]
fn main() {
#[cfg_attr(test, faux::create)]
pub struct Renderer {
    /* snip */
}

#[cfg_attr(test, faux::methods)]
impl Renderer {
    pub fn new() -> Renderer {
        /* snip */
    }

    pub fn render(&mut self, texture: &Texture) -> Result<(), RenderError> {
        /* snip */
    }
}

pub struct Texture;

impl Texture {
    pub fn render(&self, renderer: &mut Renderer) -> Result<(), RenderError> {
        renderer.render(self)
    }
}

#[derive(Debug)]
pub struct RenderError;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_textures() {
        let mut renderer = Renderer::faux();
        faux::when!(renderer.render).then(|_| Ok(()));

        let subject = Texture {};
        subject.render(&mut renderer).expect("failed to render the texture")
    }
}
}

为了将Render这个结构体的可打桩版本公开出去,要做的第一步是让 faux 在测试外也能被构建到,但同时我们又不想让它污染我们Library原有的构建依赖,所以需要将对faux的依赖标注为optional


#![allow(unused)]
fn main() {
[dependencies]
在dev-dependencies外设置一个可选的依赖,这样使用该Library的用户就可以选择性的使用我们提供的打桩接口来测试他们自己的代码
faux = { version = "^0.1", optional = true }

[dev-dependencies]
当然我们的测试还是需要依赖faux的,所以它必须得包含在dev-dependencies内
faux = "^0.1"
}

将Config设置为如上之后,Cargo会将 faux 的feature flag暴露出去。只有在进行测试,或是feature flag被设置为true的时候,faux 才会被构建。

做完这一步骤后,下面我们要将打桩对外输出。解决问题的关键在于any字段


#![allow(unused)]
fn main() {
// 在标注了any关键字后,在测试中或者faux feature flag被设置为true时,该结构体的可打桩版本将会被对外输出
#[cfg_attr(any(test, feature = "faux"), faux::create)]
pub struct Renderer {
    /* snip */
}

// 在标注了any关键字后,在测试中或者faux feature flag被设置为true时,该结构体的函数的可打桩版本将会被对外输出
#[cfg_attr(any(test, feature = "faux"), faux::methods)]
impl Renderer {
    pub fn new() -> Renderer {
        /* snip */
    }

    pub fn render(&mut self, texture: &Texture) -> Result<(), RenderError> {
        /* snip */
    }
}
}

将原本的


#![allow(unused)]
fn main() {
#[cfg_attr(test, ...)]
}

替换成


#![allow(unused)]
fn main() {
#[cfg_attr(any(test, feature = "faux"), ...)]
}

这样在测试中或者feature flag设置为true时,rust就会使用faux的特性( createmethod )。关于 any 关键词,你可以在[rust reference][https://doc.rust-lang.org/reference/conditional-compilation.html]里了解到更多。

现在一切准备就绪,下面让我们看看该如何在另一个库里使用test-renderer里的桩函数和桩结构体。

现在我们定义另一个库,以下是该库的Cargo.toml


#![allow(unused)]
fn main() {
[dependencies]
testable-renderer = * // 版本号
}

该库里使用了testable-renderer的接口的代码


#![allow(unused)]
fn main() {
use testable_renderer::{RenderError, Renderer, Texture};

struct World {
    player: Texture,
    enemy: Texture,
}

impl World {
    pub fn new() -> Self {
        World {
            player: Texture {},
            enemy: Texture {},
        }
    }

    pub fn render(&self, renderer: &mut Renderer) -> Result<(), RenderError> {
        self.player.render(renderer)?;
        self.enemy.render(renderer)?;

        Ok(())
    }
}
}

我们想要给 World::render 这个函数写测试,但是 render 是一个非常费时的操作。不过好在 testable-renderer 里有打桩功能可以使用,我们首先要将它的打桩功能开启。


#![allow(unused)]
fn main() {
[package]
设置后,被dev-dependencies开启的特性不会影响在正常构建下生成的二进制文件
resolver = "2"

[dependencies]
正常的依赖不会激活faux打桩
testable-renderer = * # some version

[dev-dependencies]
在测试内,我们会激活faux,以此来使用该库里的桩函数
testable-renderer = { version = "*", features = ["faux"] }

需要依赖faux以此来设置Mocks
faux = "^0.1"
}

需要注意的是:

  • resolver = "2". 为了让faux在正常构建中不被包括,需要注明这一点。具体请看[Cargo reference][https://doc.rust-lang.org/nightly/cargo/reference/features.html#feature-resolver-version-2]
  • dev-dependencies 内开启 faux 特性,我们只想在测试中使用这个功能。

下面我们就可以正常的写测试啦


#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_the_world() {
        // the test target enables the faux feature on `testable-renderer`
        // thus allowing us to use the mocks of the *external* crate
        let mut renderer = Renderer::faux();
        faux::when!(renderer.render).then(|_| Ok(()));

        let world = World::new();
        world.render(&mut renderer).expect("failed to render the world")
    }
}
}

Tokio的高延展性服务器设计

Tokio是目前最被广泛使用的异步运行时库,使用它可以轻松的设计出一个可用的服务器。但是在一些常见的使用Tokio的库上,用它设计出的代码经常无法在多CPU环境下保持性能高效。

gRPC greeter server

以下代码是一个常见的 gRPC Greater 服务器的实现,即使你对Tokio只是略有涉猎,你也能写出这样的服务器应用。

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let addr = "[::1]:50051".parse().unwrap();
    let greeter = MyGreeter::default();

    println!("GreeterServer listening on {}", addr);

    Server::builder()
        .add_service(GreeterServer::new(greeter))
        .serve(addr)
        .await?;

    Ok(())
}

但如果你对Tokio运行机制有了较深的理解,你可以写出性能提升两倍的代码

tokio1

在多CPU环境下的服务器设计

为什么常见的服务器设计表现的不是特别理想呢?Tokio的内部运行机制给了我们一点提示:

tokio2

​ 常见的服务器设计

Tokio创建的线程数量一般和CPU核数保持一致。每个线程都会处理多个clients,但却只有一个监视线程通过epoll监听所有的socket,除此之外,也只有一个listen socket来接收新的连接。这样的设计非常容易成为性能的瓶颈。

下图中的设计大大增加了服务器的延展性,这也是Envoy, Nginx使用的设计方法。每个线程都会通过epoll监视它所持有的client的socket,且每个线程都有一个自己的listen socket用来接收新的连接,并通过设置SO_REUSEPORT在同一个端口进行监听。

tokio1

通过更改一小段代码,可以极大的提升在多CPU环境下的性能。对该设计感兴趣的话可以在这里找到一个例子:tokio-reuseport

Reference

Datenlord |在 Rust 中管理 RDMA 内存

作者: 施继成


RMDA 是近年越来越热门的高速网络传输协议,被广泛应用于超算中心和高端存储领域。RDMA 的全称为 Remote Direct Memory Access,即允许本地内存被远端机器直接访问,该访问不经过被访问机器的操作系统,直接由网卡代为完成。正式因为网卡完成了大部分的数据传输工作,操作系统的负载被降低,使得其在大量数据传输的情况下具有更好的拓展性(scalability)。

为了保证远端能够正确和安全地访问本地内存,RDMA 协议中有一系列规范来约束用户的访问,下面来简单介绍一下。

RDMA的内存管理

远端想要访问本地内存,首先需要本地的“同意”,即本地仅仅暴露想要暴露的内存,其他内存远端则不可访问。该“同意”操作,我们一般称为 Memory Region (简称 MR) 注册,操作系统在收到该请求时会锁住该段申请内存,防止内存被 swap 到硬盘上,同时将这个注册信息告知 RDMA 专用网卡,RDMA 网卡会保存虚拟地址到物理地址的映射。经过此番操作,由 MR 代表的内存暴露出来了,远端可以对其进行访问。处于安全的考虑,只有被允许的远端才可以访问,这些远端持有远端访问密钥,即Remote Key(简称 RKey),只有带有正确 RKey 的请求才能够访问成功。为了内存管理的细粒度化,RDMA 还提供了 Memory Window(简称 MW),一个 MR 上可以分列出多块 MW,并且每一块 MW 上都可以自定义访问权限。

除了上述中的 MR 和 MW,RDMA 中的内存管理还和 Protect Domain(简称 PD) 和 Queue Pair (简称 QP) 相关,这里不详细阐述这两个概念。下图详细介绍了,这些概念之间的依赖关系:

rdma dependecy

现有的 RDMA 开发接口,即 InfiniBand Verbs 接口(简称 IBV 接口)并没有显式地展现这种依赖关系,但在实际使用中,任何不按规定顺序的资源释放都会造成错误,而用户找到问题的根本原因则非常困难。更进一步,当 MR 或者 MW 中的任何内存段被使用时,对应的 MR 和 MW 都不应该被释放或注销,这些在原有的 IBV 接口中也很难规范化。Rust 作为一门内存安全的语言,在处理类似问题上具有天然优势,接下来我们来分析如何用 Rust 解决上述问题。

利用 Rust 特性管理 RDMA 内存

Allocator API

在 Rust 的 nightly feature 中有一个叫 Allocator API,这个 feature 允许用户创建自己的 Allocator,之后创建堆上数据时可以制定使用用户定制的 Allocator。大家很自然能想到,MR 或者 MW 很适合作为一种 Allocator,后续用户创建的 Vector 或者 Box 都可以使用其中的内存,这些内存可以直接开放给远端访问,既方便又高效。

但 Allocator API 有个核心问题无法保证,即 Allocator 本身应该比所有从其分配的数据活得更久,不然就会产生数据访问不安全的问题,如 use after free 问题。下列例子很好得阐述这一问题:

fn alloc_vec<'a> () -> Vec<u32, &MemoryRegion> {
    // Allocat missing
    let alloc = CusAllocator::New();
    let mut v = Vec::new_in(alloc);
    v.push(1);
    v
}

fn main() {
    let v = alloc_vec();
    println!("{:?}", v);
}

在 alloc_vec 方法中我们创建了一个新的 Allocator 叫 CusAllocator, 在方法结束时,该 Allocator 已经被释放,使用其内存的 vector 仍然存活着,被后续使用。Rust 语言无法判断出潜在的风险,唯一能够解决该问题的办法就是将 CusAllocator 变成 static 变量,这样其生命周期和整个程序一样长,也就不存在 use after free 的问题。然而该解决方法不适用 MR 和 MW 的场景,原因是 MR 和 MW 会随着用户的使用动态注册和注销,无法被注销的 MR 和 MW 会影响使用的便利性。若初始化太大的内存块,系统的内存压力太大,其他程序容易触发 OOM 问题;若初始化内存块太小,用户的使用会受到限制。结合上述考虑,Allocator 不是一个可行的方案。

Reference 还是 Reference Count

Rust 语言中 Reference 带有生命周期属性,非常适合用来管理依赖关系,即被依赖 Ref 的生命周期不短于依赖者的生命周期,但是其在处理自引用时非常困难,当结构复杂到一定成都仅仅依赖 Reference 很难设计出用户易于使用的接口。因此我们采用了下列的设计方式:

struct MemoryRegion {
    pd: Arc<ProtectDomain>,
    ...
}

这样核心数据接口都放到了堆上管理,同时保证了被依赖的数据结构一定不会提前释放 —— RC 特性的保证。解决了核心数据结构的管理,内存使用的管理则更加简单,下列方法保证了当有内存被使用时,MR 或者 MW 一定不会被释放。

impl MemoryRegion {
    pub fn get_ref(&self, offset: usize, len: usize) -> &[u8] {
        // Just a demo, missing length check
        &self.buf[offset..(offset + len)]
    }
}

在此基础上,配合一些序列化方法,Memory Region 则可以处理各种数据结构的传输。

远端访问

RDMA 是为了远端数据访问而存在的,仅仅管理好本地内存还不够,如何保证远端访问时本地内存的可靠性也很重要,不过 Rust 语言本身的特性只能够维护本地内存的安全性,远程访问需要更上层的设计来完成。我们在我们的设计中提供了类似的接口来完成相应的任务:


#![allow(unused)]
fn main() {
impl MemoryRegion {
    async fn reserve (&self, timeout: Duration, f: T) where T: Future {
        timeout(timeout, f).await
    }
}
}

timeout表示该 MemoryRegion 外部能访问的最长时间段,如果 f Future 提前结束,则我们可以提前回收 MemoryRegion,否则至少等待 timeout 的时间长度才能回收。其中 f 可以在以下场景进行不同的操作:

  1. 在一对一传输的场景中,f 将传输的必要信息传递给对方,等待对方完成的回复,一旦收到回复则结束 future。
  2. 在一对多传输的场景中,f 将传输的必要信息放到某个看板上,然后等待 timeout 时间的结束。

这里之所以要在接口中固定一个timeout,是为了防止内存被无限期得占用不能够释放,最终造成内存泄露。例如上述的第一个场景,对方如果由于软硬件的问题程序结束前并没有给出回复,则 timeout 至少可以保证 MemoryRegion 在 timeout 时间段之后还有释放的机会。

值得注意的是,这里的机制并不能完全避免远端访问错误的发生,本次程序无法控制远端程序的弱点仍然存在,因此,RDMA 自带的保护机制也能够避免错误数据访问的发生,相应请求的失败会带来一些性能损失,这是无法避免的 tradeoff。

总结

Rust 语言的内存安全性部分地解决 RDMA 内存管理问题,同时上层的使用接口设计也部分解决了 RDMA 远端访问的管理问题。欢迎在我们的 Rust RDMA 封装项目 交流讨论,促进项目发展,使得 Rust 社区能够更方便地使用 RDMA 网络。

SecondState | Dapr的一个轻量安全可移植且高性能的Runtime

本文作者:Michael Yuan,WasmEdge Maintainer

本文源发自 infoQ.com,链接:https://www.infoq.com/articles/webassembly-dapr-wasmedge/

本文要点:

  • Dapr 是功能强大的构建微服务的框架。
  • WebAssembly VM,如 WasmEdge,为微服务应用程序提供高性能且安全的 runtime。
  • 基于WebAssembly的微服务可以用多种编程语言编写,包括Rust、C/C++、Swift 和JavaScript。
  • WebAssembly 程序嵌入到Dapr sidecar 应用程序中,因此对 Dapr主机环境来说是可移植且跨平台的。
  • WasmEdge SDK 为 Tensorflow 推理构建微服务提供了一种简单方法。

自 2019 年发布以来,Dapr(Distributed Application runtime )已迅速成为非常流行的构建微服务的开源框架。它提供了分布式应用程序中常用的构建块和已打包的服务,例如服务调用、状态管理、消息队列、资源绑定和触发器、mTLS 安全连接和服务监控。分布式应用程序开发者可以在 runtime 利用和使用这些基于 Web 的 API,这些 API 是由构建块公开的。这些应用程序通常称为微服务并作为 sidecar 运行。 Dapr 是多 runtime 微服务架构的一个例子,正如 InfoQ 作者 Bilgin Ibryam 所描述的那样。

Dapr 的 sidecar 模式非常像服务网格(Service Mesh)。然而,不同于传统服务网格意在无需任何代码更改即可管理应用程序,Dapr 应用程序需要集成并积极利用外部 Dapr 构建块服务。

Dapr Sidecar 中的微服务应用程序可以是用 Go 和 Rust 等语言编译的本地客户端 (NaCl) 应用程序,也可以是用 Python 或 JavaScript 编写的托管语言应用程序。换句话说,sidecar 应用程序可以有自己的语言 runtime。 Sidecar 模型允许 Dapr 为其应用程序支持“在任何地方任何语言、任何框架、任何地方”。

WebAssembly 和 WasmEdge

Dapr 可以直接在操作系统上运行 sidecar 应用程序,也可以通过 Docker 等应用程序容器运行。容器提供了可移植性、易于部署和安全性等优点,但它也伴随着庞大的开销。

在本文中,我们提出了一种运行 Dapr sidecar 应用程序的新方法。我们使用一个用 Rust 或 Go 编写的简单 NaCl 来监听对微服务的 API 请求。它将请求数据传递给 WebAssembly runtime 处理。微服务的业务逻辑是由应用程序开发者创建和部署的 WebAssembly 函数。

在本文发布时,你已经可以使用 WasmEdge WASI Socket 来处理网络请求了。具体请见 https://github.com/second-state/dapr-wasm

图 1. 有 WebAssembly 函数的 Dapr 微服务。

WebAssembly runtime 非常适合执行业务逻辑函数。

  • WebAssembly 程序可以像编译后的机器原生二进制文件一样快速运行,并且比容器消耗更少的资源。

  • WebAssembly 支持高性能语言,如 C/C++、Rust、Swift 和 Kotlin。它还可以支持高级语言,如 JavaScript 和 DSL(Domain Specific Languages)。

  • WebAssembly 程序是可移植的,可以轻松部署在不同的操作系统和硬件平台上。

  • WebAssembly 提供了一个在 runtime 层级隔离应用程序的安全沙箱。开发者可以通过声明安全策略来限制程序对操作系统或其他资源的访问。

下表总结了 sidecar 应用程序不同方法的优缺点。

WasmEdge 是由 CNCF(云原生计算基金会)/Linux 基金会托管的领先云原生 WebAssembly runtime。它是当今市场上最快的 WebAssembly runtime 之一。 WasmEdge 支持所有标准的 WebAssembly 扩展以及 Tensorflow 推理、KV 存储和图像处理、Socket 等专有扩展。其编译器工具链不仅支持 C/C++、Rust、Swift、Kotlin 和 AssemblyScript 等 WebAssembly 语言,还支持 常规 JavaScript

WasmEdge 应用程序可以嵌入到 C 程序、 Go 程序、Rust 程序、JavaScript 程序或操作系统的 CLI 中。Runtime 可以通过 Docker 工具(例如 CRI-O)、编排工具(例如 K8s)、serverless 平台(例如 VercelNetlifyAWS Lambda腾讯 SCF)和数据流框架(例如 YoMo 和 Zenoh)进行管理。

本文中,我将演示如何使用 WasmEdge 作为 Dapr 的 sidecar 应用程序 runtime。

快速开始

首先你需要安装 GoRustDaprWasmEdgerustwasmc 编译器工具。

接下来,从 Github fork 或 clone demo 应用程序。你可以将此 Repo 用作应用程序模板。

$ git clone https://github.com/second-state/dapr-wasm

该演示有 3 个 Dapr sidecar 应用程序。

  • web-port 项目为静态 HTML 页面提供公共 Web 服务。这是应用程序的 UI。
  • image-api-rs 项目提供了一个 WasmEdge 微服务,使用 grayscale函数将输入图像转换为 grayscale 图像。它演示了将 Rust SDK 用于 Dapr 和 WasmEdge。
  • image-api-go 项目提供了一个 WasmEdge 微服务,使用分类函数对输入图像上的对象进行识别和分类。它演示了 Go SDK 对 Dapr 和 WasmEdge 的使用。

图 2. 演示应用程序中的 Dapr Sidecar 微服务

你可以根据 README 中的指示启动 sidecar 服务。 下面是构建 WebAssembly 函数和启动 3 个 sidecar 服务的命令。

# Build the classify and grayscale WebAssembly functions, and deploy them to the sidecar projects
$ cd functions/grayscale
$ ./build.sh
$ cd ../../
$ cd functions/classify
$ ./build.sh
$ cd ../../

# Build and start the web service for the application UI
$ cd web-port
$ go build
$ ./run_web.sh
$ cd ../

# Build and start the microservice for image processing (grayscale)
$ cd image-api-rs
$ cargo build
$ ./run_api_rs.sh
$ cd ../

# Build and start the microservice for tensorflow-based image classification
$ cd image-api-go
$ go build --tags "tensorflow image"
$ ./run_api_go.sh
$ cd ../

最后,你应该能在浏览器中看到这个 Web UI。

图三 生产环境的 demo 应用程序

两个 WebAssembly 函数

我们有两个用 Rust 编写并编译成 WebAssembly 的函数。 它们部署在 sidecar 微服务中,以执行图像处理和分类的实际工作。

虽然我们的示例 WebAssembly 函数是用 Rust 编写的,但你也可以将用 C/C++、Swift、Kotlin 和 AssemblyScript 编写的函数编译为 WebAssembly。 WasmEdge 还为用 JavaScript 和 DSL 编写的函数提供支持。

grayscale函数是一个 Rust 程序,它从 STDIN 读取图像数据并将 Grayscale 图像写入 STDOUT

use image::{ImageFormat, ImageOutputFormat};
use std::io::{self, Read, Write};

fn main() {
   let mut buf = Vec::new();
   io::stdin().read_to_end(&mut buf).unwrap();

   let image_format_detected: ImageFormat = image::guess_format(&buf).unwrap();
   let img = image::load_from_memory(&buf).unwrap();
   let filtered = img.grayscale();
   let mut buf = vec![];
   match image_format_detected {
       ImageFormat::Gif => {
           filtered.write_to(&mut buf, ImageOutputFormat::Gif).unwrap();
       }
       _ => {
           filtered.write_to(&mut buf, ImageOutputFormat::Png).unwrap();
       }
   };
   io::stdout().write_all(&buf).unwrap();
   io::stdout().flush().unwrap();
}

我们使用 rustwasmc 来 build 它并将其复制到 image-api-rs sidecar。

$ cd functions/grayscale
$ rustup override set 1.50.0
$ rustwasmc  build --enable-ext
$ cp ./pkg/grayscale.wasm ../../image-api-rs/lib

分类函数是一个 Rust 函数,它以图像数据的字节数组作为输入并返回用于分类的字符串。 它使用 WasmEdge TensorFlow API

use wasmedge_tensorflow_interface;

pub fn infer_internal(image_data: &[u8]) -> String {
   let model_data: &[u8] = include_bytes!("models/mobilenet_v1_1.0_224/mobilenet_v1_1.0_224_quant.tflite");
   let labels = include_str!("models/mobilenet_v1_1.0_224/labels_mobilenet_quant_v1_224.txt");

   let flat_img = wasmedge_tensorflow_interface::load_jpg_image_to_rgb8(image_data, 224, 224);

   let mut session = wasmedge_tensorflow_interface::Session::new(
       &model_data,
       wasmedge_tensorflow_interface::ModelType::TensorFlowLite,
   );
   session
       .add_input("input", &flat_img, &[1, 224, 224, 3])
       .run();
   let res_vec: Vec<u8> = session.get_output("MobilenetV1/Predictions/Reshape_1");

   // ... Map the probabilities in res_vec to text labels in the labels file ...
  
   if max_value > 50 {
       format!(
           "It {} a <a href='https://www.google.com/search?q={}'>{}</a> in the picture",
           confidence.to_string(),
           class_name,
           class_name
       )
   } else {
       format!("It does not appears to be any food item in the picture.")
   }
}

我们使用 rustwasmc 来 build 它然后将其复制到 image-api-go sidecar 中。

$ cd functions/classify
$ rustup override set 1.50.0
$ rustwasmc  build --enable-ext
$ cp ./pkg/classify_bg.wasm ../../image-api-go/lib/classify_bg.wasm

在接下来三个章节中,我们会仔细看看这三个 sidecar 服务。

图片处理 sidecar

image-api-rs sidecar 应用程序是用 Rust 编写的。 它应该已经安装了上一步中的 WebAssembly 函数 lib/grayscale.wasm。 请参考 functions/bin/install.sh 脚本来安装 WasmEdge Runtime二进制 lib/wasmedge-tensorflow-lite 及其依赖项。

sidecar 微服务运行一个基于 Tokio 的事件 loop,监听路径 /api/image 传入的 HTTP 请求。

#[tokio::main]
pub async fn run_server(port: u16) {
   pretty_env_logger::init();

   let home = warp::get().map(warp::reply);

   let image = warp::post()
       .and(warp::path("api"))
       .and(warp::path("image"))
       .and(warp::body::bytes())
       .map(|bytes: bytes::Bytes| {
           let v: Vec<u8> = bytes.iter().map(|&x| x).collect();
           let res = image_process(&v);
           Ok(Box::new(res))
       });

   let routes = home.or(image);
   let routes = routes.with(warp::cors().allow_any_origin());

   let log = warp::log("dapr_wasm");
   let routes = routes.with(log);
   warp::serve(routes).run((Ipv4Addr::UNSPECIFIED, port)).await
}

一旦它在 HTTP POST 请求中接收到图像文件,它就会调用 WasmEdge 中的 WebAssembly 函数来执行图像处理任务。 它创建了一个 WasmEdge 实例来与 WebAssembly 程序交互。

pub fn image_process(buf: &Vec<u8>) -> Vec<u8> {
   let mut child = Command::new("./lib/wasmedge-tensorflow-lite")
       .arg("./lib/grayscale.wasm")
       .stdin(Stdio::piped())
       .stdout(Stdio::piped())
       .spawn()
       .expect("failed to execute child");
   {
       // limited borrow of stdin
       let stdin = child.stdin.as_mut().expect("failed to get stdin");
       stdin.write_all(buf).expect("failed to write to stdin");
   }
   let output = child.wait_with_output().expect("failed to wait on child");
   output.stdout
}

下面的 Dapr CLI 命令行是在 Dapr runtime 环境启动该微服务。

$ cd image-api-rs
$ sudo dapr run --app-id image-api-rs \
        --app-protocol http \
        --app-port 9004 \
        --dapr-http-port 3502 \
        --components-path ../config \
        --log-level debug \
        ./target/debug/image-api-rs
$ cd ../

Tensorflow sidecar

image-api-go sidecar 应用程序是用 Go 写的。它应该已经有了从前面那一步安装的 WebAssembly 函数 lib/classify_bg.wasm 。 请参考 functions/bin/install.sh 脚本安装 WasmEdge Runtime Go SDK。

sidecar 微服务运行一个事件 loop,监听路径 /api/image 传入的 HTTP 请求。

func main() {
   s := daprd.NewService(":9003")

   if err := s.AddServiceInvocationHandler("/api/image", imageHandlerWASI); err != nil {
       log.Fatalf("error adding invocation handler: %v", err)
   }

   if err := s.Start(); err != nil && err != http.ErrServerClosed {
       log.Fatalf("error listenning: %v", err)
   }
}

一旦它在 HTTP POST 请求中接收到图像文件,它就会调用 WasmEdge 中的 WebAssembly 函数来执行基于 Tensorflow 的图像识别任务。 它利用 WasmEdge 的 Go API 与 WebAssembly 程序交互。

func imageHandlerWASI(_ context.Context, in *common.InvocationEvent) (out *common.Content, err error) {
   image := in.Data

   var conf = wasmedge.NewConfigure(wasmedge.REFERENCE_TYPES)
   conf.AddConfig(wasmedge.WASI)
   var vm = wasmedge.NewVMWithConfig(conf)

   var wasi = vm.GetImportObject(wasmedge.WASI)
   wasi.InitWasi(
       os.Args[1:],     /// The args
       os.Environ(),    /// The envs
       []string{".:."}, /// The mapping directories
       []string{},      /// The preopens will be empty
   )

   /// Register WasmEdge-tensorflow and WasmEdge-image
   var tfobj = wasmedge.NewTensorflowImportObject()
   var tfliteobj = wasmedge.NewTensorflowLiteImportObject()
   vm.RegisterImport(tfobj)
   vm.RegisterImport(tfliteobj)
   var imgobj = wasmedge.NewImageImportObject()
   vm.RegisterImport(imgobj)

   vm.LoadWasmFile("./lib/classify_bg.wasm")
   vm.Validate()
   vm.Instantiate()

   res, err := vm.ExecuteBindgen("infer", wasmedge.Bindgen_return_array, image)
   ans := string(res.([]byte))
  
   vm.Delete()
   conf.Delete()

   out = &common.Content{
       Data:        []byte(ans),
       ContentType: in.ContentType,
       DataTypeURL: in.DataTypeURL,
   }
   return out, nil
}

下面的 Dapr CLI 命令行是在Dapr runtime 环境启动微服务。

$ cd image-api-go
$ sudo dapr run --app-id image-api-go \
        --app-protocol http \
        --app-port 9003 \
        --dapr-http-port 3501 \
        --log-level debug \
        --components-path ../config \
        ./image-api-go
$ cd ../

The web UI sidecar

Web UI 服务 web-port 是一个用 Go 编写的简单 Web 服务器。 它位于 static 文件时,提供静态 HTML 和 JavaScript 文件,并将上传到 /api/hello 的图像发送到 Grayscale 函数或对 Classify sidecar 的 /api/image 端点。

func main() {
   http.HandleFunc("/static/", staticHandler)
   http.HandleFunc("/api/hello", imageHandler)
   println("listen to 8080 ...")
   log.Fatal(http.ListenAndServe(":8080", nil))
}

func staticHandler(w http.ResponseWriter, r *http.Request) {
   // ... read and return the contents of HTML CSS and JS files ...
}

func imageHandler(w http.ResponseWriter, r *http.Request) {
   // ... ...
   api := r.Header.Get("api")
   if api == "go" {
       daprClientSend(body, w)
   } else {
       httpClientSend(body, w)
   }
}

// Send to the image-api-go sidecar (classify) via the Dapr API
func daprClientSend(image []byte, w http.ResponseWriter) {
   // ... ...
   resp, err := client.InvokeMethodWithContent(ctx, "image-api-go", "/api/image", "post", content)
   // ... ...
}

// Send to the image-api-rs sidecar (grayscale) via the HTTP API
func httpClientSend(image []byte, w http.ResponseWriter) {
   // ... ...
   req, err := http.NewRequest("POST", "http://localhost:3502/v1.0/invoke/image-api-rs/method/api/image", bytes.NewBuffer(image))
   // ... ...
}

page.js 中的 JavaScript 只是将图像上传到 web-port sidecar 的 /api/hello 端点,web-port 将根据 request header api 请求分类或 Grayscale 微服务。

function runWasm(e) {
   const reader = new FileReader();
   reader.onload = function (e) {
       setLoading(true);
       var req = new XMLHttpRequest();
       req.open("POST", '/api/hello', true);
       req.setRequestHeader('api', getApi());
       req.onload = function () {
           // ...  display results ...
       };
       const blob = new Blob([e.target.result], {
           type: 'application/octet-stream'
       });
       req.send(blob);
   };
   console.log(image.file)
   reader.readAsArrayBuffer(image.file);
}

以下 Dapr CLI 命令启动静态 UI 文件的 Web 服务。

$ cd web-port
$ sudo dapr run --app-id go-web-port \
        --app-protocol http \
        --app-port 8080 \
        --dapr-http-port 3500 \
        --components-path ../config \
        --log-level debug \
        ./web-port
$ cd ../

完成了。你现在得到一个有三个部分的分布式应用,并且是用两种语言编写的。

接下来

正如我们所展示的,Dapr 的分布式网络 runtime 和 WasmEdge 的通用语言 runtime 之间有很多协同。 这种方法可以推广并应用于其他服务网格或分布式应用程序框架。与 Dapr 不同,许多服务网格只能在 Kubernetes 作为其控制平面运行,因此依赖于 Kubernetes API。 WasmEdge 是一个与 Kubernetes 兼容的 runtime,可以作为运行微服务的轻量级容器替代方案,起到举足轻重的作用。 敬请关注!

Dropbox | 为什么我们要为 Capture 构建自定义 Rust 库

翻译:张汉东

原文: https://dropbox.tech/application/why-we-built-a-custom-rust-library-for-capture


Dropbox Capture 是一个新的视觉交流工具,旨在使团队能够轻松地使用屏幕记录、视频信息、屏幕截图或GIF来异步分享他们的工作。不需要正式的入职培训,你可以在几秒钟内开始分享你的想法。事实上,简单性是Capture体验的关键,而这一价值也延伸到Capture底层代码的开发中。

我们团队的指导原则之一是 "成为一个玛格丽特比萨(Margherita pizza)"。正如玛格丽特披萨的完美之处在于其简单性:除了番茄酱、马苏里拉奶酪和罗勒,你不必需要其他的东西。我们很早就知道,Electron和Node会让我们很容易为macOS和Windows建立一个跨平台的TypeScript应用。但要找到合适的第三种成分,使我们能够快速、简单、可靠地调用原生操作系统级别的代码,需要更多的试验。

理想的情况是,我们想要一个精简的代码库,可以无痛地、一致地针对多个平台,并且便于我们的开发人员构建。我们还希望对屏幕捕捉和录音的能力有更多的控制,更好的错误处理,以及更快的幕后性能。事实上,我们正在寻找一种能在每一层都有更多控制的东西--它不需要跳过那么多圈来调用本地代码--并能更好地支持我们想要建立的新功能。

我们有很多方法可以解决这些问题--也许是TypeScript或C++,但最后我们决定使用Rust。

在某些方面,这是个容易的决定。Dropbox拥有一个蓬勃发展的开发者社区,他们将Rust纳入我们的产品。Rust是我们的桌面客户端最近重写的同步引擎的核心,它使你电脑上的Dropbox文件夹像魔术一样工作。我们还把它用于文件压缩,用于崩溃报告基础设施,以及用于Magic Pocket--超字节规模的定制存储基础设施,以优化文件数据的存储

事实证明,Rust也很适合我们的需求。构建一个定制的Rust库有助于解锁更高质量的屏幕记录,从720p到4K,使屏幕截图和屏幕记录可以更快地分享,并大大改善我们的错误处理能力,使我们能够为用户提供更可靠的体验。

我们还认为这将是一个有趣的借口来学习近年来最受欢迎的编程语言。

译注:Dropbox 使用 Rust 的经验已经有四五年了,为什么还要说找借口来学习 Rust 这句话呢?我想,也许 Capture 这个产品对应部门是第一次使用 Rust。

从黑客周(Hack Week)而来

Capture 开始是一个内部黑客周项目,快速迭代是关键。在早期的版本中,我们使用了一些第三方库来做一些事情,如拍摄屏幕截图和处理GIF。将一些预先存在的代码拼凑在一起,帮助我们快速开发一个原型,测试我们最初的假设,并尝试新的功能。但是,当我们考虑到Capture代码的长期健康状况以及这些第三方库引入的所有复杂性时,我们知道最终将不得不偿还早期的技术债务。

我们使用的第三方库通常是基于shell的应用程序;Capture将向每个shell应用程序发送命令,并接收stderr/stdout响应作为回报。这意味着每次我们想完成某些任务时都要启动一个应用程序,或者在某些情况下,有一个应用程序持续运行并等待输入--这并不完全是理想的。

更重要的是,这也意味着Capture与本地代码的通信方式存在一些固有的脆弱性。来自shell应用程序的每一行输出都必须被解析。如果某一行未能解析,就会被认为这是一个错误,而如果是一个错误,问题很可能被掩盖了,我们不会知道在本机代码中究竟发生了什么故障。正如你所预料的,这使得监测和处理错误变得很困难。

从开发者的角度来看,这些库还带来了其他挑战。我们发现macOS和Windows之间的API可能有很大的不同,甚至在同一个跨平台库中也是如此,这增加了为这两个平台开发的复杂性。有些库维护得很好,但缺少我们需要的功能,而另一些库拥有我们想要的一切,但维护得并不那么好。每一个库都提出了我们必须解决的权衡问题,有些比其他库更容易解决。

例如,如果我们想对单个库进行任何改变,我们必须拥有如何构建每个库的机构知识,然后将它们构建到Capture中。案例:我们的一位工程师花了几个小时的宝贵开发时间来学习如何构建Windows屏幕记录库,只是为了修复一个单一的解析错误。

Rust 来拯救

由于Rust对Capture团队来说是个新事物,最初,我们专注于重写简单的功能,否则需要第三方库。例如,激活窗口(activate-windows)以前是一个仅适用于macOS的库,把一个窗口带到最前面,并且只记录该窗口。我们很快就能把这个功能用 Rust 移植到macOS上,然后再把这个功能带到Windows上,因为它以前并不存在。

这些早期的成功给了我们信心去尝试更多雄心勃勃的东西。我们学得越多,就有越多的功能被转移到我们的自定义Rust库中,这使Capture在很多方面受益。

没有开销。有了Neon绑定,我们现在可以轻松地从TypeScript调用本地操作系统的代码,而没有任何开销(而且也更可靠)。换句话说,我们不再需要为完成某些任务而启动单独的shell应用程序。例如,拍摄屏幕截图,曾经是异步的,需要我们等待来自shell应用程序的响应,现在是立即和快速的。

更好的错误处理。Rust还极大地提高了我们处理错误的能力。一旦Capture的大部分代码在我们自己的库中运行,并且在macOS和Windows中具有一致的API,我们就能够添加更强大的日志和监控。不再试图解释来自shell应用程序的输出! 将所有的代码放在一个地方,让我们更深入地了解我们的应用程序实际上是如何运作的。

更多的控制。对库的所有权意味着可以更快地进行修复和改进。把我们所有的代码放在一个地方也使我们更容易解决一些模糊的问题,比如在捕捉或录制超过三个屏幕时不断遇到的不稳定性。这也导致了跨平台的构建管道更加简单。

一个更小的足迹。不需要包括第三方库也减少了我们应用程序的整体大小。例如,在用Rust重写这些功能后,macOS上不再需要大约17MB的Swift库。现在我们可以根据需要简单地调用函数--而不是一直在后台运行 shell 应用程序--我们需要的内存也比以前少了。

新的功能。正如我们在激活窗口时发现的那样,迁移到Rust也使我们能够做一些以前做不到的事情。我们能够为Windows带来以前只存在于macOS上的功能。我们还能够引入一个新的裁剪工具,新的录音控制,并增加新的录音类型,如音频或相机,以及增加录音质量到720p/1080p/4K。这并不是说我们不能用其他语言来构建这些东西,而是Rust让我们能够比以前更快、更省力地构建这些东西。

Capture 和 Rust的下一步是什么

Capture现在是Beta版,如果你还没有,你应该试试。

在开发Capture时,我们最大的惊喜之一是使用Rust是如此简单。在短短几周内,我们就将Rust代码推向了生产,而且我们从Dropbox精通Rust的工程师的支持性社区中受益匪浅,他们在我们遇到困难时提供帮助和指导。而且,随着我们团队的壮大,新的开发人员的学习曲线也会变小。我们不再像以前那样与多个库搏斗,现在我们有一个非常简单的文件,基本上说 "这里是如何使用这个单一的命令来构建一切"。

有了Rust,我们的开发者就像我们心爱的玛格丽特披萨一样,清楚地知道应该期待什么。

随着时间的推移,我们希望将更多的功能转移到我们的内部库。macOS的屏幕记录器已经用Rust重写了,Windows记录器的类似重写也正在进行中。我们也一直在将GIF创建和其他操作系统级的集成等功能移入我们的Rust库。我们对Windows上的Rust (windows-rs)的未来感到特别兴奋,它最近刚刚达到0.21.0版本

但这也不是一个全有或全无的方法。我们对Rust进行了配置,所以如果需要的话,我们仍然可以使用旧的shell进程方法调用第三方库。这意味着我们可以有意识地选择重写哪些功能以及何时重写。当然,如果我们对Rust感到挣扎,那就很容易回头了。但考虑到Rust所带来的好处,我们的热情和兴奋原来是有道理的。

学习资源

聚焦学习 Rust 的网络资源


toml-edit 优化之旅

toml_edit 是一个保留格式(在修改用户的Cargo.toml时,能保留格式)的TOML crate,允许用户修改.toml文件。

优化之前

toml_edit 8.7us 271us
toml_edit::easy 20.7us 634us

优化之后

toml_edit 4.0us 149us
toml_edit::easy 5.0us 179us

上下文:

该作者是 cargo-edit 的核心贡献者,现在正致力于将 cargo-add 合并到 cargo 的工作中,其中对 toml 的修改要用到 toml_edit 这个库,他们已经把相关的工作都做完了,只剩下最后都性能优化。这篇文章就是 作者对 toml_edit 性能优化的记录。

性能之旅:

  1. 要确定你的优化目标。 作者之前并不确定可以从 toml_edit 中可以挤出多少性能,但是Alex Crichton帮助他确定了这个目标,他特别指出Cargo的解析器,是一个影响用户的瓶颈,这个结果是在toml-rs 分析时展示出来的。因此,toml_edit 至少应该和 toml_rs有同样的速度,他们还想进一步优化toml_rs
  2. 依靠性能之书(https://nnethercote.github.io/perf-book/profiling.html)来帮助开始profile,选择了callgrind和kcachegrind作为可视化工具。
  3. 使用 kstring,来优化字符串key。

默认情况下,kstring

  • 内联字符串存储总共 16个字节
  • max_inline 特性选择存储总共 23 个字节,对于小字符串来说会很慢
  • Box<str>的形式存储堆字符串
  • 使用Arc<str>,它用Ref-counting的成本代替了分配的成本
  1. 要注意第三方解析便利性背后的成本

解析器组合器,如nomcombined,使语法转换为代码变得容易,但也很容易隐藏大量的成本:

  • 不必要的分配
  • 缺少批处理

他优化解析器的 https://github.com/ordian/toml_edit/pull/209

  1. 将字符和字符串的操作换成按字节操作,并且根据情况在某些安全的情况下,使用 uncheck 的方法来避免 utf-8 校验。 pr 在这里 https://github.com/ordian/toml_edit/pull/219/

  2. 良好错误处理背后的代价。

toml_edit 之前的解析器用的是 combine,它的特点是错误处理非常细致,将检查每个选择并合并错误。它优先考虑在字符串中最早出现的错误,并合并发生在相同点的错误。这样做的问题是,即使没有面向用户的错误发生,错误处理的逻辑也会让你付出代价。

作者要优化他还有很多选择,比如放弃 combine,使用nom或者手写parse (性能优化效果将最大),但是他选择继续使用 combine,但是用 dispatch! (可以迅速实施改变)来代替map。这样的选择算是小步迈进。PR: https://github.com/ordian/toml_edit/pull/222

  1. serde的隐藏成本

serde对toml文件中每个字符串进行解析,看它是否可能是一个日期。没有Datetime的文件必须为它的存在付出代价。所以作者将使用一个专门的结构体来优化这种情况。pr:https://github.com/ordian/toml_edit/pull/226

默认情况下,serde检查每个未标记的枚举的变体,看它是否有效。作者使用 手动实现 serde::Deserialize trait 来优化这种情况,而避免 derive 自动实现。pr :https://github.com/ordian/toml_edit/pull/227

有哪些优化是失败的呢?

不是所有的修复都是成功的。不幸的是,失败的尝试通常不会被很好地记录下来,因为它们可能不会被记录到PR中。

这里作者凭记忆罗列了一些失败的尝试:

  1. 批处理优化,收益比较小
  2. 想进一步优化KString,反而变慢了
  3. 尝试将 combine 迁移到 nom,目前正在努力

最后,发现了这句话: 感谢 Futurewei 对这项工作的赞助

https://epage.github.io/blog/2021/09/optimizing-toml-edit/

在 Rust 项目中编写 dockerfile 的建议

当你写一个Rust项目时,也许你想建立一个小型的运行容器(基于alpine和distroless/cc-debian),然后你可以在 k8s 或其他你喜欢的地方运行它。

文章中介绍了步骤以及最终的三个示例:

  1. https://windsoilder.github.io/writing_dockerfile_in_rust_project.html#6-final-dockerfile
  2. https://windsoilder.github.io/writing_dockerfile_in_rust_project.html#7-additional-dockerfile
  3. https://windsoilder.github.io/writing_dockerfile_in_rust_project.html#not-vendor-based-dockerfile-based-on-cargo-chef

https://windsoilder.github.io/writing_dockerfile_in_rust_project.html

面向有多门语言编程经验的开发者的 Rust 入门书

https://www.chiark.greenend.org.uk/~ianmdlvl/rust-polyglot/index.html

使用Rust编写嵌入式固件

本文探讨了Rust在嵌入式上的能力,并为用它编写固件提供了一个起点。本文包含关于嵌入式编程和Rust的介绍性信息。本文描述为什么你应该考虑在新项目中使用Rust,提供常用库的概述,并提供涵盖最重要部分的代码示例。其中的一些主题与官方的Rust嵌入式和Rust嵌入式发现书籍有重叠。

这些都是很好的入门资源,并为刚接触Rust的有经验的嵌入式工程师和刚接触嵌入式的人分别进行了详细介绍。本文的重点是实用固件的架构,并介绍了在这些书之后发布的工具和库。

作者使用Rust编写了水监测器固件,并计划在未来的设备中也这样做,因为它的内存安全和人体工程学。

https://www.anyleaf.org/blog/writing-embedded-firmware-using-rust

使用 Rust Cloudflare Workers 构建 ServerLess

文章带我们了解怎么用 Rust 编写 Cloudflare Workers ServerLess 代码。

然后将展示构建一个完整的 ServerLess 功能,并可以使用它来验证带有 hCaptcha 的前端 Web 用户。

https://dev.to/askrodney/using-rust-cloudflare-workers-serverless-hcaptcha-358g

Rust游戏 - "自走棋"

由 Rust (bevy) 开发的自走棋游戏。

https://github.com/yopox/LD49

【系列】Rust 插件开发 | 深入理解动态加载

https://nullderef.com/blog/plugin-dynload/

Rust中的 Phantom 类型

Phantom 类型是在运行时中从不使用的类型,但有助于在编译时强制执行某些约束。对其感兴趣的同学可以通过本文一探究竟.

https://www.greyblake.com/blog/2021-10-11-phantom-types-in-rust/

使用 Rust 进行 PIC32 单片机编程

作者: 这篇文章是给新手 开始使用PIC32 和 Rust进行编程的。

所以本文的步骤非常的详尽, 感兴趣的小伙伴可以跟着动手试试.

https://gill.net.in/posts/pic32-blink-led-rust/

使用 Rust 编写高性能的 javascript API

WasmEdge 集Rust的性能和JavaScript的易用性于一身

将Rust函数合并到JavaScript api中,使得开发人员可以用“纯JavaScript”编写程序,同时还可以利用Rust的高性能功能。使用WasmEdge Runtime,您可以做到这一点。

https://www.secondstate.io/articles/embed-rust-in-javascript/

Java 开发者分享的 Rust 学习笔记

初学 Rust 的 Java 开发者 的学习笔记

https://github.com/sumeetdas/succinct-rust

【油管视频】 Rust 学习视频两则

在java和rust中返回Optional的开销对比

一些编程语言,如 Java 或 Scala,提供了不止一种方式来表达“lack of value”的概念。传统上,一个特殊的null值用于表示根本不引用任何值的引用。 然而,随着时间的推移,我们了解到使用null 可能非常容易出错,并且会导致许多麻烦,例如 NullPointerException在最意想不到的时刻出现错误使程序崩溃。因此,现代编程风格建议null尽可能避免使用更好的Option,Optional或Maybe数据类型(在很多语言中叫法不同,但概念是一样的)。不幸的是,人们认为 Java 中的可选值可能会带来性能损失。在这篇博文中,我将尝试回答是否属实,如果性能惩罚真的存在,那么严重程度如何。

https://pkolaczk.github.io/overhead-of-optional/

在Rust中使用建造者模式

由于Rust不支持函数的可选参数和具名参数,也不支持函数重载,建造者模式在Rust中被广泛使用。 以下是一个使用建造者模式创建一个User结构体的代码示例:

#[derive(Debug)]
struct User {
    id: String,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>
}

struct UserBuilder {
    id: String,
    email: String,
    first_name: Option<String>,
    last_name: Option<String>
}


impl UserBuilder {
    fn new(id: impl Into<String>, email: impl Into<String>) -> Self {
        Self {
            id: id.into(),
            email: email.into(),
            first_name: None,
            last_name: None,
        }
    }

    fn first_name(mut self, first_name: impl Into<String>) -> Self {
        self.first_name = Some(first_name.into());
        self
    }

    fn last_name(mut self, last_name: impl Into<String>) -> Self {
        self.last_name = Some(last_name.into());
        self
    }

    fn build(self) -> User {
        let Self { id, email, first_name, last_name } = self;
        User { id, email, first_name, last_name }
    }
}

impl User {
    fn builder(id: impl Into<String>, email: impl Into<String>) -> UserBuilder {
        UserBuilder::new(id, email)
    }
}

fn main() {
    let greyblake = User::builder("13","greyblake@example.com")
        .first_name("Sergey")
        .build();
    dbg!(greyblake);
}

要点:

  1. 使用UserBuilder来创建User结构体;
  2. 结构体的必要字段通过必选位置显示传入建造者的new方法,可选参数通过setter来设置;
  3. 最后通过build函数返回完整的结构体实例;

Builder pattern in Rust

Rust中的容器运行时 - 第二部分

克隆容器进程和父子通信。

本系列的第一部分描述了文件系统布局以及运行时如何将容器进程囚禁在容器的根文件系统中。

第二部分更深入地探讨了实现,并展示了运行时如何创建子进程以及它们如何通信,直到用户定义的进程启动。它还将描述如何设置伪终端并展示其重要性Unix 套接字。

到本部分结束时,我们应该有一个可与 Docker 互操作的基本运行时。

https://itnext.io/container-runtime-in-rust-part-ii-9c88e99d8cbc

如何实现一个跨平台的Rust库

oso 项目团队新写的文章。oso 支持六种编程语言,是基于 Rust 编写的 核心库来完成多语言支持的。

这篇文章就是介绍了 oso 如何做到通过一个 Rust 核心库支持多语言。

https://www.osohq.com/post/cross-platform-rust-libraries

一个很酷的 Rust 优化故事

在 Quickwit,我们正在为大数据构建最具成本效益的搜索引擎。我们的整个搜索引擎是用 rust 开发的,搜索的核心是由一个名为tantivy的库提供的。

人们经常问为什么tantivy基准测试中的表现优于Lucene,这是一个复杂的问题。许多人认为这是 Rust 比 Java 更快的故事之一,真相要复杂得多。

https://quickwit.io/blog/search-a-sorted-block/

rust-motd 无运行时命令行可配置界面美观的 MOTD 生成工具

类Unix系统中,/etc/motd是一个包含“今日消息”(message of the day)的文件。

https://github.com/rust-motd/rust-motd

block-ciphers:用纯 Rust 编写的分组密码算法集合

支持多种算法。

https://github.com/RustCrypto/block-ciphers

darkfi:匿名的 DeFi 网络

DarkFi 是一个匿名的 DeFi 网络。它的目标是提供灵活的私有原语,可以用来创建任何类型的应用程序。 DarkFi 使用零知识密码学的进步,并创建了一种合同语言和开发人员工具包,旨在使匿名工程对开发人员具有高度的可访问性。

https://github.com/darkrenaissance/darkfi

tunneler:隧道工具

Rust 实现的,通过 TCP、(相互)TLS 或 DNS(权威服务器或直接连接)隧道传输 TCP 或 UDP 流量。

每个可执行文件包含 2 个组件,通过客户端 stream 通道(字节读取器和写入器元组)进行通信:

  • 客户端监听器绑定套接字并将传入和传出的流量转为新的流。
  • 客户端 tunneler 将流读取器和写入器转为隧道协议。
  • 服务器 untunneler 根据隧道协议绑定套接字并将隧道流量转换回原始流。
  • 服务器转发器将流写入器和读取器转换回流量。

基于 TCP 的流量被简单地转换为流。 基于 UDP 的流量转换取决于隧道协议。基于 UDP 的流量还需要一种方法来识别现有客户端以继续其会话。解决方案是内存中的客户端缓存,它将客户端的标识符映射到其对应的流。

https://github.com/dlemel8/tunneler

rust-kernel-barebones:Rust 内核和配置脚本

一个最小的 64 位 Rust 内核和一堆配置脚本,可用于使用 Nightly-Rust 编译器引导操作系统开发。使用 Rust OsDev 社区构建的工具如 xbuild、bootimage、bootloader crates,并将所有这些工具配置为协同工作。从而开发人员不必担心 toochain 的配置。工具链构建并配置项目以使用 x86_64-unknown-none 目标。 一些功能包括:

  • 配置整个环境的脚本。
  • 构建内核并使用 Qemu 模拟的脚本。
  • VS Code RLS 配置。

https://github.com/Narasimha1997/rust-kernel-barebones

使用 Rust 实现健康检查 API 模式

通过添加一个/healthAPI 端点来满足kubelet探测以进行livenessreadiness检查,该端点根据您的服务的当前状态以OkServiceUnavailableHTTP 状态进行响应。

/healthAPI 端点解决方案是Health Check API 模式的实现,该模式用于检查 API 服务的健康状况。在像Spring这样的 web 框架中,像Spring Actuator这样的嵌入式解决方案可供您集成到您的 Spring 项目中。但是,在许多 Web 框架中,您必须自己构建此 Health Check API 行为。

在这篇博文中,使用actix-web Web 框架实现健康检查 API 模式,该框架使用sqlx连接到本地 PostgreSQL 数据库实例。

https://itnext.io/implementing-the-health-check-api-pattern-with-rust-eaef04cb4d2d

使用 Rust 构建 Emacs lisp VM

这是编写 Emacs lisp vm 的介绍(非教程),相关项目: rune

https://coredumped.dev/2021/10/21/building-an-emacs-lisp-vm-in-rust/

【系列】使用 tracing-subscribertracing 自定义 日志

该系列文章有两篇,介绍了如何使用 tracingtracing-subscriber 来构建日志系统。

Iced.rs 教程:如何构建一个简单的 Rust 前端 Web 应用程序

本文使用 Iced 和 Rust 构建一个非常基本的前端应用程序,它使用JSONPlaceholder来获取数据。获取帖子并将它们显示在一个列表中,每个帖子都有一个详细链接,引导用户阅读带有评论的完整帖子。

Iced.rsYew最大的区别在于,虽然 Yew 纯粹是用于构建 Web 应用程序,但 Iced 的重点实际上是跨平台应用程序;Web 只是您可以为其构建应用程序的多个平台之一。

另一件需要注意的事情是 Iced.rs 处于早期和积极的开发阶段。虽然使用它构建基本应用程序是绝对可能的,但生态系统还不是特别成熟。除了文档示例之外,在这个早期阶段,开始有点困难,特别是如果你试图构建一些复杂的东西。

https://dev.to/logrocket/icedrs-tutorial-how-to-build-a-simple-rust-frontend-web-app-2pg7https://fasterthanli.me/articles/my-ideal-rust-workflow)

使用 Rust 进行嵌入式开发

本文基于开源的RT-Thread 操作系统来简单演示如何使用 Rust 进行嵌入式开发。

RT-Thread诞生于2006年,是一款以开源、中立、社区化发展起来的物联网操作系统。 RT-Thread主要采用 C 语言编写,浅显易懂,且具有方便移植的特性(可快速移植到多种主流 MCU 及模组芯片上)。RT-Thread把面向对象的设计方法应用到实时系统设计中,使得代码风格优雅、架构清晰、系统模块化并且可裁剪性非常好。

本文介绍了以下内容:

  • 如何在 C 中调用 Rust
  • 在 Rust 中调用 C

https://opensource.com/article/21/10/rust-embedded-development

【系列】在 Raspberry Pi Pico 上开始使用 Rust

这是探索 Raspberry Pi Foundation 的第一个微控制器 (RP2040) + 开发板 (Pico) 的高效且令人兴奋的世界的系列文章中的第一篇,其中固件是用 Rust 编写的。

本指南涵盖使用两块 Raspberry Pi Pico 板,一块作为目标板,另一块作为硬件编程器和调试器。

https://reltech.substack.com/p/getting-started-with-rust-on-a-raspberry

【趣文】Rust 公案 | 新锈与大师的对话

Rust 公案(Koans)是来自 Rust 官方用户论坛中 DanielKeep 在 2015 年编写的一系列趣文

公案(Koans)是来自于佛教中的一个词,用来表示禅师无法理性回答的疑问或矛盾的问题。

此处 Rust 公案 则用来映射 Rust 新锈在学习 和 使用 Rust 时必然要经历和思考的一些问题,给人启迪。

本文一共分成四个短篇:

障碍

连续第八天, 新锈 科尔 发现自己站在神庙的两个大守卫面前。他们站在寺庙的大门前,身穿简单的长袍。尽管如此,他们还是很有气势,让人敬畏。他自信满满地走向第一个守卫,并把写有他的计划的羊皮纸递给他。

第一守卫仔细阅读了它。这一步不过是个形式;昨天,他只是没有得到第二卫队的同意。他确信他已经解决了所有悬而未决的意见。

第一守卫将羊皮纸递回给科尔。然后,他以一种盲目的动作,用他的手在科尔脸上拍了一下。第一守卫用缓慢且严肃的声音对他说:"不匹配的类型:期望 &Target,发现 &<T as Deref>::Target",然后就沉默了。

科尔拿着他的羊皮纸,退回到附近的长椅上,几乎要哭了。八天了。他的程序似乎并不特别复杂,但他却无法说服两大守卫允许他进入神庙。他在其他寺庙没有遇到过这么大的困难!在另一张长椅上,他看到了一个同伴。

在另一个长椅上,他看到了一个同龄的 新锈。两天前他们曾交谈过,当时他得知他的同胞为了让他的计划被接受,已经在寺庙外辛苦了近两个星期。

这都是警卫的错。科尔知道他的程序会按计划进行。他们所做的似乎只是在小错误上挑毛病,以最琐碎的理由拒绝他。他们在寻找拒绝他进入的理由;就是这样!他开始愤愤不平。

他开始愤愤不平了。

这时,他注意到一个僧人在和另一个新来的人说话。他们的谈话似乎很活跃,突然间,这个 新锈大叫一声,跳了起来,冲向寺庙。在他奔跑的过程中,他似乎在疯狂地修改他的程序。

然而,他没有面对两大守卫,而是走到墙的一个小的、喧闹的部分。令科尔惊讶的是,墙壁打开后似乎是一个秘密入口。 新锈通过了,然后从眼前消失了。

科尔目瞪口呆地坐在那里。一个秘密的入口?那么......那么两个大守卫可能只是一个恶作剧!?其他僧认让 新锈经历的事情,是为了教他们谦虚。或者......复原力。或者......或者......也许只是为了偷偷地嘲笑他们。

"你想知道我对他说了什么吗?"一个声音问。科尔转过身来,看到那个僧人站在他的长椅旁边。"你告诉他有另一条路可以进去,是吗?"

"是的," 他回答。"我告诉他那扇门不安全。"

"不安全?" 科尔问。

"的确如此。这是那些在寺庙里长期努力学习的人知道的秘密。事实上,人们可以通过使用不安全的艺术来克服编写程序中的许多障碍,正如《Rustonomicon 》中所说的那样。"

"它们很强大吗?" 科尔惊奇地问道。

"非常强大。通过转换,人们可以简单地重新分配一个值的类型,或延长一个指针的生命周期。人们甚至可以从空气中召唤指针,或从虚无中召唤数据。"

科尔觉得他终于明白了神庙的工作原理。僧侣们所使用的正是这种 "不安全 "的魔法! 然而...

"那么,如果人们可以简单地穿过不安全的门到达寺庙,为什么还要雇用两大守卫?为什么不......"

就在这时,从寺庙内传出一声令人毛骨悚然的尖叫。它回荡在整个院子里,然后突然结束。

寂静降临了。没有人动。没有人说话。风静止了。甚至鸟儿也停止了歌唱。

科尔可以感觉到他的心脏在他的耳边跳动。

"他们在那里,"僧侣说,打破了咒语,"保护你不受寺庙和里面的东西影响。"

科尔转过身来,再次凝视着那扇隐藏的门。"那为什么那扇门会存在?"

"因为,即使是他们也不是无懈可击的。有些时候,人们必须独自面对危险。" 他叹了口气。"但不是所有的人都那么勇敢或熟练。"

就这样,僧人从他的斗篷里拿出一张羊皮纸,走向两大守卫。第一守卫看了他的方案,点了点头。第二守卫看了他的方案,把它递了回去,然后在她脸上打了一下。

"借来的价值活得不够长。"

僧人揉着脸,走了回来,坐在其中一个长椅上,喃喃自语地咒骂。

在那一刻,科尔豁然开朗。

行为建模

在一个温暖的秋日里,法布尔德修士在最近开放的 锈寺 附近的抽象花园里散步。他经过了许多好奇的、精心设计的复杂抽象作品的展示,这时他偶然发现了一个僧侣在她自己的展示中辛勤工作。她的长袍表明她是个新锈,尽管她那双布满皱纹、磨损的手说明她有很多很多年的经验。在她周围,散落着几十块各种形状和大小的白色石头,还有几把不同的耙子。每个石头上都贴着一个小的纸标签,上面写着一个字。

他很感兴趣,走近一看。

"嗬!"他说,"你叫什么名字?"

"我是尼奥普特-马兰。"

"如果我可以询问的话,你在研究什么问题?"

"我正试图翻译一个以前的设计,它可能适合于寺庙," 新锈回答说,集中精力看一张大纸,上面画满了盒子的草图,由许多线条和箭头连接。

"我看到了许多标签;那些是用来做什么的?"

"它们定义了每个物体的类(Class)。"

法布尔德弟兄停顿了一下。"什么是类?"

新锈面向他露出困惑的眼神。"你不知道类是什么?"

"不,"他回答。"我听说过这样一个从远方随风传来的词语,但在这个地方,我们不使用它。"

"啊,我明白了,"马兰说。"在我来的那个寺庙里,我们用类来定义一个事物的所有属性和行为,许多人可以共享一个定义。"

"一个有用的装置,然后。在这样的计划中,作为一个简单的园丁,我将有一个倾向性的行为,那么?"

新锈点了点头,拿起一块小石头。"这是一块岩石,"她说,给法布尔德修士看了看标签。"它没有方法,因为它所做的就是躺在地上,直到被操作,但它有重量和体积等属性。"

"所以一块石头根本不能做任何事?"

"什么都不能。不像这个耙子,它拥有耙动一个或多个岩石的能力。这使我能够对一个花园及其演变进行完整的建模。"

"真的吗?这个简单的模型允许你捕捉所有可能的相互作用,然后呢?"

"是的!"马兰新锈自豪地感叹道。"我已经使用了很多年,取得了巨大的成功。"

"但如果岩石希望滚动,或发光,或说话呢?类听起来是限制性的;我更喜欢定义较小的行为,每个东西都可以挑选它想做的事情。"

新锈嘲笑道。"一个物体只能做它被设计为做的事情,没有别的。你说你是个园丁;你的任务是指导 新锈,还是做饭?"

"我不是;我的任务是做园艺。"

"对于类,这样的情况在设计时由你的类来表达。因此,永远不可能有任何混淆!"。改变行为就是改变系统的设计!"

"很迷人,"法布尔德说,点了点头。 新锈转身回到她的工作上。

"我必须承认,我很难找到如何以神庙认可的方式来表达这个概念,但我--" 新锈被打断了,因为一块较小的白色石头砸中了她的头。

抱着她的头,马兰转过身来。"是你向我扔石头吗?"她问道。

"我?"法布尔德修士说。"当然不是。我只是一个普通的园丁;我对扔石头一无所知。"

"这块石头不可能自己扔出去!"

"然而,你的设计不允许这样的事情发生。当然,你一定是搞错了:没有石头被扔出来。"

马兰新锈冷哼一声,转身回到她的草图上。

一会儿,她闪开了,因为一根长木杆击中了她的手臂。"你是不是......?"

"耙子 " 只能耙岩石;它不能 "打新锈"。 你一定是在胡思乱想。

"但你......"

"嗬!法布尔德修士!"另一个修士从花园的另一端喊道。"谢布修士生病了;你能不能好心地代替他做晚膳?"

"我很乐意,杜尔修女;我现在刚刚完成对这位新锈的指导。"

在那一刻,马兰豁然开朗。

完美

修女清 在被阿伯特称为 "架构大师 "的人的注视下紧张地跺脚;他是一个叫托兰大师的僧人。"按照承诺,我在新月的前一天向他提交了我完成的设计,以及三百六十一页的大量文件,解释了接口的所有正确和错误的使用方法,"她总结说。"我对文件特别自豪。我费尽心思,确保所有可能的不相容性和失败案例都被详细列举出来,但阿伯特似乎并不太高兴。"

"然后呢?"托兰问。

"他说,'你已经辛苦了很久才完成这个,但现在我想让你和架构大师一起工作一段时间。也许会学到一些东西'。因此,我来到这里,"她说完。

托兰大师点头表示理解。他显然似乎知道这是怎么回事。"阿伯特让我为寺院设计一座新的宝塔,让寺院的知识有一个合适的归宿。在我开始建造之前,我必须确保设计是完美的。因此,我将给阿伯特送去一个模型;既是为了让他检查它,也是为了确定设计是否合理。"

清点了一下头,因为这听起来非常有道理。

"恰好,我已经准备了这样一个模型。它在那里。"大师说,指了指房间角落里的一张桌子。清走到那里,发现上面布满了数以百计的细小的、松散的精雕细琢的木头碎片。

"这是......当它未被组装的时候,它真的能被称为模型吗?"

"有道理,修女。你的任务是将模型组装起来。"

"我......"?

"我已经劳累了许多天,几乎没有休息;我想我应该在花园里走走,让我的头脑清醒一下。毫无疑问,像你这样有天赋的修女在完成这最后的手续时不会有任何困难。"

"师父,我是算法的架构师,不是建筑的架构师;我对木材和石头知之甚少。"

"要学的东西很多,但幸运的是,我已经知道这项任务所需要知道的一切,并且已经为你建造了这些作品。"

"但我不知道......事实上,我对宝塔的外观只有一个模糊的概念!"

"这对你这种能力的人来说应该没有什么困难。" 就这样,托兰大师把清和那堆模型零件单独留在车间里。

清感到很沮丧。在不知道模型是什么样子的情况下,她不可能组装好这个模型的!她不知道该怎么办。图纸,蓝图,一些大致的轮廓......一个所谓的大师怎么能指望她在没有任何指导的情况下建造如此复杂的东西?

有一段时间,清只是盯着这些杂乱无章的木片。漫不经心地,她开始检查这些碎片。她开始为有这么多不同的、独特的部件而着迷。即使是像小木梁这样简单的东西,也有许多略有不同的变体。一个可能在一端有一个方形接头,下一个可能是圆形。另一个会以六边形结束。

她心血来潮,拿了两块看起来很像的木砖,发现它们并不适合在一起。放下一块,她又尝试下一块。再下一个。再下一个。最后,她找到了第二块合适的砖。

她现在有了两块瓦片。于是,她又找了第三块。

然后是第四块。

然后是第五块。

每走一步,她都能找到一块合适的地方。不久之后,她意识到她已经建造了一个似乎是地板的东西。地板上有几个孔,可以容纳其他部件。仔细搜索了一下这堆东西,发现只有五根横梁可以安装。因此,她把它们放在一起。

当外面的太阳越过天空时,这一切都在继续。宝塔越长越高,似乎是自己的意愿。地板被组装起来,墙壁被连接起来,瓦片被锁在那里。清友觉得她无法控制模型是如何形成的:就好像模型知道它需要什么形状,不允许她有任何自由。

当太阳低垂在天空时,清友发现模型已经完成。每一块都被使用了。结构是健全的。它很高,屋顶有优雅的弧度。尽管她不明白这是怎么做到的,但托兰大师以某种方式建造了一个稳定而美丽的宝塔模型,尽管他没有参与组装。

"啊,正好赶上我散步回来。"

清并没有转身面对他。"我相信你现在头脑清醒了很多 ?"

"的确如此。现在,我想让你把模型交给阿伯特,并告诉他所学到的东西。"

。。。

"于是,我回到这里。"她说完,向阿伯特展示了完成的模型。

阿伯特皱起了眉头。"那么,"他问,"学到了什么?"

"我为我的文档感到自豪是错误的。虽然它来自于巨大的努力和用心,但我应该把这种努力用于使接口本身更好。我应该努力做出一个不需要三百多页解释的接口。一个由类型本身引导程序员使用的接口。

"一个完美的接口是一个不可能使用错误的接口,即使是意外。

阿伯特叹了口气。"我想这是没办法的事。来,让我们把它和其他的放在一起。"

清困惑地跟着阿伯特,他带着她穿过寺庙的蜿蜒走廊。最后,他们来到一个长长的大厅,每面墙上都有几个架子。

每个架子上都摆放着几十个小模型佛塔,每个都和上一个略有不同。走到房间的最里面,经过一系列的小建筑,阿伯特指了指架子上的一个空位,基友把她的模型放了进去。

这似乎是一个教过和学过很多次的课程。

"这是你学到的宝贵一课,但这不是我派你去见托兰大师的原因。"

"噢?"

"的确不是。大约八年前,托兰大师被要求为这座寺庙建造一座宝塔。每次他完成一个设计,就会再次撤回,声称它还不够完美。" 他又叹了口气。

"我倒是希望你能教教他实用主义的价值,为了完成而妥协完美。即使是一个丑陋的、摇摇欲坠的棚子,也会比一个假设的、无缺陷的宝塔更有用。"

在那一刻,清豁然开朗。

咒语

新锈 科尔有一天来到图尔大师面前说。"我几乎已经完成了一个程序,在其中我看到了重复的代码模式。这些模式不够规则,无法用函数或特质来抽象。我想使用宏,但不知道该如何进行。"

图尔大师考虑了这个问题。"我相信我知道谁可以帮助你找到清晰的思路。来吧。"

他们一起离开了主殿,经过了花园。在院子的外围,他们遇到了一座奇怪的建筑。每块木板、每块面板、瓷砖、窗户;在形状、大小、颜色、质地、材料上都是独一无二的...... "这是什么地方?" 新锈问。

"这是 "落雪花(Falling Snowflake) "家族练习合成抽象艺术的地方。他们最讨厌重复。" 科尔在他们进入时点了点头。

在里面,建筑同样是无情的独特。没有两扇门是相同的形状或大小。每支蜡烛和它的栖息地都以不同的方式设计和着色。科尔开始感到头晕目眩。

甚至僧侣们本身也是这种做法的典范。与其他僧侣所穿的朴素的深棕色长袍相比,落雪寺的每个僧侣都穿着杂乱无章的材料和颜色的集合。科尔想,也许是一场大风暴卷起了一个布商的货物,并把它们放在这些毫无戒心的僧侣身上。

然后他的目光被墙壁所吸引。墙壁上布满了程序,这些程序似乎是用某种奇怪的语言写成的,正如他所期望的那样,来自APL的寺庙......但在这里和那里,他瞥见了熟悉的语法,尽管它们没有什么意义。

僧侣们的讲话也很奇怪。科尔观察到有几组人静静地站着,互相凝视着对方。他开始想,也许他们根本就被禁止说话,这时他听到有一组人爆发了。

"Sie durm! Wik puom, eeis"。

就这样,站在这群僧侣中的人互相点了点头,走了。

"大师...... "科尔开始说。

"正如我所说,他们厌恶重复。因此,他们厌恶我们的语言,因为它充满了冗余,更不用说不准确了。相反,他们使用自己的语言,这既是完全精确的,也是完全最佳的。他们只说自己的意思,不说别的。"

"但他们是如此安静,否则。"

图尔大师点了点头。"你认为在说一个字之前,完全准确地决定一个人想说什么,这容易吗?"

当他们穿过一个小小的、五边形的门洞时,他们来到了一个穿着染成千百种深灰色的布的僧人面前。袈裟上缠绕着一个令人着迷的白线图案,似乎永远在布上盘旋;一个由直线和方块组成的无限的、弯曲的野兽。

"新来的,这是昆仑大师,落雪花族的族长。" 僧人点头致意。"告诉他你的问题。"

科尔吸了一口气。"我几乎已经完成了一个程序,在这个程序中我看到了很多重复的东西。

"在某些情况下,逻辑必须调用不同的函数,尽管周围都保持不变。在其他情况下,不同的类型实现了类似的方法,然而它们不能通过特质来统一。

"有人告诉我,宏可能是解决我的问题的一个办法。你有什么建议?"

昆仑大师沉默了。持续了很长时间,以至于科尔开始在大师的注视下紧张地晃动,不确定他是否以某种方式冒犯了他。

科尔注意到,这位大师的眉毛有点奇怪......

最后,昆仑大师这样说话。

"Fheiq kah, puom"。

然后他向科尔点了点头,转身就走了。

"谢谢你的建议,昆仑大师。"图尔大师一边说,一边引导科尔沿着他们来时的路回去。

他们一边走,科尔一边试图理解发生了什么。走回阳光下,他得出了一个结论。

"与昆仑大师交谈是否有助于你找到答案?" 图尔大师问他。

"是的,"科尔回答。"我已经完成我的程序。"

图尔大师点了点头;这个新来的人已经开窍了。

【区块链】Solana 开发笔记 Part 1

作者:朱春雷

笔者注:因近期笔者工作需要,开始接触 Solana 链上程序开发。本系列文章是笔者的学习笔记,既是为了备忘,也是希望得到 Solana 开发者的指点与交流。本系列文章将默认读者已经掌握 Rust 的基础语法,故不涉及对 Rust 语法细节的解释。如果读者对 Rust 基础语法还不熟练的话,可参阅相关资料或购买本文下方推荐的 Rust 入门书籍 《Rust 编程入门、实战与进阶》学习。 ​


1.1 Solana 简介

Solana 是一个高性能、无许可的底层公链,专注于在不牺牲去中心化或安全性的前提下提供可扩展性。Solana 主网于 2020 年一季度上线,目前主网的全球节点超过 800 个,TPS 最高可达 6.5 万,出块时间约 400 毫秒。 ​ Solana 的共识算法采用 PoH(历史证明),其核心是一个去中心化时钟,该时钟旨在解决缺乏单个可信赖时间源在分布式网络中的时间问题。PoH 免除了在节点网络中广播时间戳的需求,从而提高整个网络的效率。 ​

1.1.1 链上程序

Solana 的智能合约叫做链上程序(On-chain Program),Solana 官方提供了 Rust 和 C 的 SDK 来支持开发链上程序。链上程序的开发工作流如图 1-1 所示,开发者可以使用工具将程序编译成 Berkley Packet Filter (BPF) 字节码(文件以 .so 为扩展名),再部署到 Solana 链上,通过 Sealevel 并行智能合约运行时去执行智能合约的逻辑。此外,基于 Solana JSON RPC API,官方提供了诸多 SDK 用于客户端与 Solana 链上数据交互。

640.jpg 图 1-1 链上程序开发工作流 ​

1.1.2 账户模型

与以太坊类似,Solana 也是基于账户模型的区块链。通过将任意状态存储于链上账户并同步复制给集群中的所有节点,可以创建复杂而强大的去中心化应用程序。 ​

Solana 提供了一套不同于以太坊的账户模型,账户定义的字段如表 1-1 所示。Solana 的账户可以分为可执行账户和不可执行账户。

  • 可执行账户:存储不可变的数据,主要用于存储程序的 BPF 字节码。
  • 不可执行账户:存储可变的数据,主要用于存储程序的状态。

表 1-1 账户定义字段

字段描述
lamports账户余额
owner账户所有者
executable是否为可执行账户
data账户存储的数据
rent_epochSolana链上程序的部署是按其账户大小进行定期收费的,如果账户无法支付租金,系统将清除该账号

我们知道以太坊上每个智能合约的代码和状态都存储在同一个账户中,而 Solana 链上程序是只读或无状态的,即程序的账户(可执行账户)只存储 BPF 字节码,不存储任何状态,程序会把状态存储在其他独立的账户(不可执行账户)中。为了区分某个账户是用作哪个程序的状态存储,每个账户都指定了一个程序作为其所有者。程序可以读取其不作为所有者的账户中的状态,但只有作为所有者的程序才能修改账户中的状态,任何其他程序所做的修改都会被还原并导致交易失败。 ​

更多关于账户模型的资料可以参见官方文档:https://solana.wiki/zh-cn/docs/account-model/

1.2 搭建编程环境

在开始 Solana 链上程序开发之前,需要先安装和配置相关的编程环境。首先请正确安装 Node、NPM 和 Rust 的最新稳定版本,下面来安装 Solana CLI 并配置相关环境。 ​

1.2.1 安装 Solana CLI

Solana CLI 是与 Solana 集群进行交互的命令行管理工具,包含节点程序 solana-validator、密钥对生成工具 solana-keygen,以及合约开发工具 cargo-build-bpf、cargo-test-bpf 等。

在终端运行以下命令,可完成 Solana CLI 最新稳定版的下载与安装。

sh -c "$(curl -sSfL https://release.solana.com/stable/install)"

如果安装成功,会出现以下内容。

downloading stable installer
  ✨ stable commit e9bef425 initialized
Adding export PATH="~/.local/share/solana/install/active_release/bin:$PATH" to ~/.profile
Adding export PATH="~/.local/share/solana/install/active_release/bin:$PATH" to ~/.bash_profile

Close and reopen your terminal to apply the PATH changes or run the following in your existing shell:
  export PATH="~/.local/share/solana/install/active_release/bin:$PATH"

Solana CLI 的所有命令行工具都安装在 ~/.local/share/solana/install/active_release/bin 中,并会自动将该路径加入 ~/.profile 和 ~/.bash_profile 文件的 PATH 环境变量。

运行以下命令,检查 PATH 环境变量是否已正确设置。

solana --version

// solana-cli 1.7.18 (src:e9bef425; feat:140464022)

如果能显示 solana-cli 的版本号、版本哈希等信息,代表环境变量设置成功。如果未看到这些信息,请检查相关文件中 PATH 环境变量设置的路径是否正确。

如果已安装过 Solana CLI,想升级到最新版本,可在终端运行以下命令。

solana-install update

1.2.2 配置 Solana CLI

1. 连接到集群

Solana 的集群有本地集群(localhost)和公开集群。根据不同的用途,公开集群又分为开发者网络(devnet)、测试网(testnet)和主网(mainnet-beta)。

  • devnet 是适用于开发者的集群,开发者可获得 SOL token 的空投,但这个 SOL token 不具有真实价值,仅限测试使用。devnet 的 RPC 链接是 https://api.devnet.solana.com
  • testnet 是用于测试最新功能的集群,如网络性能、稳定性和验证程序行为等。同样可获得 SOL token 的空投,但也仅限测试使用。testnet 的 RPC 链接是 https://api.testnet.solana.com
  • mainnet-beta 是主网集群,在 Mainnet Beta 上发行的 SOL token 具有真实价值。mainnet-beta 的 RPC 链接是 https://api.mainnet-beta.solana.com

运行以下命令,根据实际需要来选择集群。

// 选择localhost集群
solana config set --url localhost

// 选择devnet集群
solana config set --url devnet

2. 创建账户

如果是第一次使用 Solana CLI,需要先创建一个账户。运行以下命令,根据操作提示可以设置一个 BIP39 规范的密码,此密码用来增强助记词的安全性,当然也可以为空。生成新的账户后,密钥对会被自动写入 ~/.config/solana/id.json 文件中。需要注意的是,这种存储密钥对的方式是不安全的,仅限开发测试使用。

solana-keygen new

要查看当前这个账户的公钥,运行以下命令。

solana-keygen pubkey

当前如果是在 devnet 集群,该账户的余额为 0 SOL,可以运行以下命令查询余额。

solana balance

在 devnet 上申请 SOL 空投,运行以下命令后再次查询当前账户的余额,会发现余额为 2 SOL。

solana airdrop 2

1.3 第一个 Solana 项目——Hello World

Hello World 是一个官方演示项目,展示了如何使用 Rust 和 C 开发链上程序,并使用 Solana CLI 来构建与部署,以及使用 Solana JavaScript SDK 与链上程序进行交互。

1.3.1 Hello World 源码解读

example-helloworld 项目的目录结构如下所示,其中 program-rust 目录下是 Rust 开发的程序源代码,client 目录下是客户端的源代码。

example-helloworld
|
+-- src
|  |
|  +-- client
|  |  |
|  |  +-- hello_world.ts
|  |  |
|  |  +-- main.ts
|  |  |
|  |  +-- utils.ts
|  |
|  +-- program-rust
|  |  |
|  |  +-- src
|  |  |  |
|  |  |  +-- lib.rs
|  |  |
|  |  +-- tests
|  |  |  |
|  |  |  +-- lib.rs
|  |  |
|  |  +-- Cargo.toml
|  |  |
|  |  +-- Xargo.toml
|
+-- .gitignore
|
+-- package.json
|
+-- tsconfig.json

1. 链上程序源码解读

program-rust/src/lib.rs 是链上程序的核心代码,如代码清单 1-1 所示,实现了将程序被调用次数存储在链上账户中。

第 1 行代码将 borsh::BorshDeserialize 和 borsh::BorshSerialize 引入本地作用域,用于序列化和反序列化数据。第 2~9 行代码将 Solana Rust SDK 的模块引入本地作用域,使用 Rust 编写程序都需要这个 SDK。 ​

第 13~16 行代码定义了 GreetingAccount 结构体作为存储在账户中的状态类型,里面有一个 u32 类型的字段 counter,用于记录程序被有效调用的次数。 ​

第 19 行代码 entrypoint 声明了 process_instruction 函数是程序入口,每个程序都有一个唯一的入口。第 22~26 行代码是 process_instruction 函数签名,它要接收 3 个参数:

  • program_id:链上程序的部署地址,在这里也就是 helloworld 程序账户的公钥。
  • accounts:与程序交互的账户列表,当前程序会使用账户列表中的账户来存储状态或修改账户中的数据。如果当前程序不是某个账户的所有者,那就无法使用该账户存储状态或修改数据,当前交易会执行失败。
  • instruction_data:指令数据,比如要转账的代币数量、转账地址等。

process_instruction 函数的返回值类型是 ProgramResult,ProgramResult 类型的定义如下所示。


#![allow(unused)]
fn main() {
pub type ProgramResult = Result<(), ProgramError>;
}

当程序的逻辑执行成功时返回 Ok(()),否则将 ProgramError 错误返回。ProgramError 是自定义错误的枚举类型,其中包含程序可能失败的各种原因。 ​

第 27 行代码使用 msg! 宏将字符串输出到日志中,方便观察业务的执行逻辑和调试信息。第 30 行代码通过 iter 方法将账户列表转换为迭代器,以安全的方式获取账户地址。第 33 行代码使用了 ? 操作符,如果迭代器中有账户地址,会将账户地址与变量 account 绑定。如果迭代器中没有账户地址,? 操作符会让程序执行失败。 ​

第 36~39 行代码判断存储状态的账户所有者是否是当前程序。只有账户所有者才能修改数据,否则输出日志并返回。 ​

第 42~44 行代码先对账户中的数据进行反序列化操作,再将 counter 加一,最后将其序列化后存储到账户中。

代码清单 1-1 helloworld 链上程序


#![allow(unused)]
fn main() {
use borsh::{BorshDeserialize, BorshSerialize};
use solana_program::{
    account_info::{next_account_info, AccountInfo},
    entrypoint,
    entrypoint::ProgramResult,
    msg,
    program_error::ProgramError,
    pubkey::Pubkey,
};

/// Define the type of state stored in accounts
#[derive(BorshSerialize, BorshDeserialize, Debug)]
pub struct GreetingAccount {
    /// number of greetings
    pub counter: u32,
}

// Declare and export the program's entrypoint
entrypoint!(process_instruction);

// Program entrypoint's implementation
pub fn process_instruction(
    program_id: &Pubkey, // Public key of the account the hello world program was loaded into
    accounts: &[AccountInfo], // The account to say hello to
    _instruction_data: &[u8], // Ignored, all helloworld instructions are hellos
) -> ProgramResult {
    msg!("Hello World Rust program entrypoint");

    // Iterating accounts is safer then indexing
    let accounts_iter = &mut accounts.iter();

    // Get the account to say hello to
    let account = next_account_info(accounts_iter)?;

    // The account must be owned by the program in order to modify its data
    if account.owner != program_id {
        msg!("Greeted account does not have the correct program id");
        return Err(ProgramError::IncorrectProgramId);
    }

    // Increment and store the number of times the account has been greeted
    let mut greeting_account = GreetingAccount::try_from_slice(&account.data.borrow())?;
    greeting_account.counter += 1;
    greeting_account.serialize(&mut &mut account.data.borrow_mut()[..])?;

    msg!("Greeted {} time(s)!", greeting_account.counter);

    Ok(())
}
}

2. 客户端程序源码解读

要想测试链上程序,我们必须通过 Solana JSON RPC API 去和链上程序进行交互。example-helloworld 项目提供的客户端用 Typescript 编写,使用了 web3.js 库这个 Solana JavaScript SDK。

在 client 目录下,客户端执行的入口是 main.ts 文件,它按特定的顺序执行任务,每个任务的业务逻辑代码在 hello_world.ts 文件。 ​

首先,客户端调用 establishConnection 函数与集群建立连接。

export async function establishConnection(): Promise<void> {
  const rpcUrl = await getRpcUrl();
  connection = new Connection(rpcUrl, 'confirmed');
  const version = await connection.getVersion();
  console.log('Connection to cluster established:', rpcUrl, version);
}

接着,客户端调用 establishPayer 函数来确保有一个有支付能力的账户。

export async function establishPayer(): Promise<void> {
  let fees = 0;
  if (!payer) {
    const {feeCalculator} = await connection.getRecentBlockhash();

    // Calculate the cost to fund the greeter account
    fees += await connection.getMinimumBalanceForRentExemption(GREETING_SIZE);

    // Calculate the cost of sending transactions
    fees += feeCalculator.lamportsPerSignature * 100; // wag

    try {
      // Get payer from cli config
      payer = await getPayer();
    } catch (err) {
      // Fund a new payer via airdrop
      payer = await newAccountWithLamports(connection, fees);
    }
  }

  const lamports = await connection.getBalance(payer.publicKey);
  if (lamports < fees) {
    // This should only happen when using cli config keypair
    const sig = await connection.requestAirdrop(
      payer.publicKey,
      fees - lamports,
    );
    await connection.confirmTransaction(sig);
  }

  console.log(
    'Using account',
    payer.publicKey.toBase58(),
    'containing',
    lamports / LAMPORTS_PER_SOL,
    'SOL to pay for fees',
  );
}

然后,客户端调用 checkProgram 函数从 src/program-rust/target/deploy/helloworld-keypair.json 中加载已部署程序的密钥对(此操作前需先构建链上程序,详见 1.3.2 节),并使用密钥对的公钥来获取程序账户。如果程序不存在,客户端会报错并停止执行。如果程序存在,将创建一个新账户来存储状态,并以该程序作为新账户所有者。这里新账户存储的状态,就是程序被调用的次数。

export async function checkProgram(): Promise<void> {
  // Read program id from keypair file
  try {
    const programKeypair = await createKeypairFromFile(PROGRAM_KEYPAIR_PATH);
    programId = programKeypair.publicKey;
  } catch (err) {
    const errMsg = (err as Error).message;
    throw new Error(
      `Failed to read program keypair at '${PROGRAM_KEYPAIR_PATH}' due to error: ${errMsg}.`,
    );
  }

  // Check if the program has been deployed
  const programInfo = await connection.getAccountInfo(programId);
  if (programInfo === null) {
    if (fs.existsSync(PROGRAM_SO_PATH)) {
      throw new Error(
        'Program needs to be deployed with `solana program deploy dist/program/helloworld.so`',
      );
    } else {
      throw new Error('Program needs to be built and deployed');
    }
  } else if (!programInfo.executable) {
    throw new Error(`Program is not executable`);
  }
  console.log(`Using program ${programId.toBase58()}`);

  // Derive the address (public key) of a greeting account from the program so that it's easy to find later.
  const GREETING_SEED = 'hello';
  greetedPubkey = await PublicKey.createWithSeed(
    payer.publicKey,
    GREETING_SEED,
    programId,
  );

  // Check if the greeting account has already been created
  const greetedAccount = await connection.getAccountInfo(greetedPubkey);
  if (greetedAccount === null) {
    console.log(
      'Creating account',
      greetedPubkey.toBase58(),
      'to say hello to',
    );
    const lamports = await connection.getMinimumBalanceForRentExemption(
      GREETING_SIZE,
    );

    const transaction = new Transaction().add(
      SystemProgram.createAccountWithSeed({
        fromPubkey: payer.publicKey,
        basePubkey: payer.publicKey,
        seed: GREETING_SEED,
        newAccountPubkey: greetedPubkey,
        lamports,
        space: GREETING_SIZE,
        programId,
      }),
    );
    await sendAndConfirmTransaction(connection, transaction, [payer]);
  }
}

客户端再调用 sayHello 函数向链上程序发送交易。一个交易可以包含一个或多个不同的指令,当前该交易包含了一个指令,指令中带有要调用链上程序的 Program Id 以及客户端要交互的账户地址。需要注意的是,如果交易中包含多个不同的指令,其中有一个指令执行失败,那么所有指令所做的操作都会被还原。

export async function sayHello(): Promise<void> {
  console.log('Saying hello to', greetedPubkey.toBase58());
  const instruction = new TransactionInstruction({
    keys: [{pubkey: greetedPubkey, isSigner: false, isWritable: true}],
    programId,
    data: Buffer.alloc(0), // All instructions are hellos
  });
  await sendAndConfirmTransaction(
    connection,
    new Transaction().add(instruction),
    [payer],
  );
}

最后,客户端调用 reportGreetings 函数访问账户数据,查询链上程序被有效调用的次数。

export async function reportGreetings(): Promise<void> {
  const accountInfo = await connection.getAccountInfo(greetedPubkey);
  if (accountInfo === null) {
    throw 'Error: cannot find the greeted account';
  }
  const greeting = borsh.deserialize(
    GreetingSchema,
    GreetingAccount,
    accountInfo.data,
  );
  console.log(
    greetedPubkey.toBase58(),
    'has been greeted',
    greeting.counter,
    'time(s)',
  );
}

1.3.2 Hello World 构建与部署

1. 创建项目

使用 git clone 命令下载 example-helloworld 项目。

git clone https://github.com/solana-labs/example-helloworld.git
cd example-helloworld

2. 构建链上程序

运行以下命令,在 program-rust 目录下构建链上程序。

cd src/program-rust/
cargo build-bpf

构建完成后,src/program-rust/target/deploy 目录下的 helloworld.so 就是可在 Solana 集群部署的链上程序的 BPF 字节码文件。 ​

3. 启动本地集群

当前项目在本地集群部署运行,因此首先选择 localhost 集群,运行以下命令。

solana config set --url localhost

本地集群设置成功,会出现以下内容。

Config File: ~/.config/solana/cli/config.yml
RPC URL: http://localhost:8899
WebSocket URL: ws://localhost:8900/ (computed)
Keypair Path: ~/.config/solana/id.json
Commitment: confirmed

再运行以下命令,启动 localhost 集群。

solana-test-validator

看到以下内容,代表本地集群已成功启动。

Ledger location: test-ledger
Log: test-ledger/validator.log
Identity: A4HuRgmABNCe94epY2mU7q6WqEHCo2B9iBFE5Yphiw5u
Genesis Hash: 96TF9n1uuyFv4rAKECffA61jLrgYjMjNRZ3hJpP6HSr7
Version: 1.7.18
Shred Version: 13390
Gossip Address: 127.0.0.1:1024
TPU Address: 127.0.0.1:1027
JSON RPC URL: http://127.0.0.1:8899
⠉ 00:00:42 | Processed Slot: 45430 | Confirmed Slot: 45430 | Finalized Slot: 45398 | Snapshot Slot: 45300 | Transactions: 45452 | ◎499.772930000

4. 部署链上程序

运行以下命令,在 localhost 集群部署链上程序。

solana program deploy target/deploy/helloworld.so

// Program Id: 6AArMEBpFhhtU2mBnEMEPeEH7xkhfUwPseUeG4fhLYto

链上程序部署成功会返回 Program Id,它类似于以太坊智能合约的地址。 ​

5. 调用链上程序

helloworld 已成功部署,可以与它进行交互了!example-helloworld 项目提供了一个简单的客户端,在运行客户端之前先安装依赖软件包。

npm install

由于我们调整了链上程序的构建方式,没有使用该项目默认的 npm run build:program-rust 命令,因此需要修改 client 目录下的 hello_world.ts 文件,将第 48 行代码定义的变量 PROGRAM_PATH 的路径由“../../dist/program”改为“../program-rust/target/deploy”。 再运行以下命令,启动客户端去调用链上程序。

npm run start

客户端成功调用链上程序,输出内容如下所示。如果再次运行客户端,第 10 行所显示的次数会加一。至此,我们已经成功在 Solana 集群部署链上程序并与之交互了。

> helloworld@0.0.1 start
> ts-node src/client/main.ts

Let's say hello to a Solana account...
Connection to cluster established: http://localhost:8899 { 'feature-set': 3179062686, 'solana-core': '1.6.23' }
Using account 4xRm2FYmRB8WdxJk6nXicVMgsPnsxChEnpQwFDGwdcSS containing 499999999.93435186 SOL to pay for fees
Using program 6AArMEBpFhhtU2mBnEMEPeEH7xkhfUwPseUeG4fhLYto
Creating account Eq7bcsg5p6AaYiPnfiia99ESsuq4B4jYpVbWZhQ94Zvy to say hello to
Saying hello to Eq7bcsg5p6AaYiPnfiia99ESsuq4B4jYpVbWZhQ94Zvy
Eq7bcsg5p6AaYiPnfiia99ESsuq4B4jYpVbWZhQ94Zvy has been greeted 1 time(s)
Success

如果没有输出期望值,请首先确认是否已正确启动了本地集群,构建并部署好了链上程序。此外,可以运行以下命令查看程序日志,日志包括程序日志消息以及程序失败信息。

solana logs

包含程序失败信息的日志如下所示,检查日志找出程序失败的原因。

Transaction executed in slot 5621:
Signature: 4pya5iyvNfAZj9sVWHzByrxdKB84uA5sCxLceBwr9UyuETX2QwnKg56MgBKWSM4breVRzHmpb1EZQXFPPmJnEtsJ
Status: Error processing Instruction 0: Program failed to complete
Log Messages:
  Program G5bbS1ipWzqQhekkiCLn6u7Y1jJdnGK85ceSYLx2kKbA invoke [1]
  Program log: Hello World Rust program entrypoint
  Program G5bbS1ipWzqQhekkiCLn6u7Y1jJdnGK85ceSYLx2kKbA consumed 200000 of 200000 compute units
  Program failed to complete: exceeded maximum number of instructions allowed (200000) at instruction #334
  Program G5bbS1ipWzqQhekkiCLn6u7Y1jJdnGK85ceSYLx2kKbA failed: Program failed to complete

1.4 本章小节

本章对 Solana 区块链的基本概念进行了简要介绍,Solana 的智能合约叫做链上程序。在开始 Solana 链上程序开发之前,需要先安装和配置相关的编程环境,我们着重介绍了 Solana CLI 的安装和配置。 ​

Hello World 是一个官方演示项目,通过对这个项目源码的解读,我们了解了如何使用 Rust 开发链上程序,并使用 Solana CLI 来构建与部署,以及使用 Solana JavaScript SDK 与链上程序进行交互。


推荐 Rust 入门学习书籍《Rust 编程入门、实战与进阶》,该书详细讲解 Rust 核心语法,注重编码能力训练,将数据结构、算法与 Rust 编程结合,精选 39 道 LeetCode 高频算法面试真题。需要购买书籍的朋友,可以访问当当网(购书链接)或扫描下方二维码。 image.png


扫码关注公众号 image.png

Rust 中常见的新手错误和不良实践:不良习惯

译者:柴杰

原文


[TOC]

如果你有其它语言的编程经验,在切换到Rust时,你会带着以前的经验。通常情况下,这是很好的,因为这意味着你不用从头开始学习编程。然而,这也可能带来一些坏习惯,让你写出糟糕的代码。

使用哨兵值

在大多数类C的编程语言(C、C#、Java等)中,表明某操作失败或无法找到的方式是返回一个 "特殊 "值。例如,C#的String.IndexOf()方法扫描一个数组中的某个元素,并返回其索引。如果没有找到,则返回-1。

这就导致了下面这样的代码:


#![allow(unused)]
fn main() {
string sentence = "The fox jumps over the dog";

int index = sentence.IndexOf("fox");

if (index != -1)
{
  string wordsAfterFox = sentence.SubString(index);
  Console.WriteLine(wordsAfterFox);
}
}

你经常可以看到这种 "用一个哨兵值来表示一些特殊的东西 “的做法,类似的哨兵值还有""或null(有人曾把它们称为 "十亿美元的错误")。

这是一个坏主意,因为绝对没有什么可以阻止你忘记这个检查。这意味着你可能会因为一个错误的假设,或者生成哨兵的代码与使用它的代码距离较远,而意外地使程序崩溃。

不过在Rust中我们可以做得更好。只要使用Option!

在Rust中,如果不处理Option可能是None的情况,就无法获取有效值。这是编译器在编译时强制执行的,这意味着忘记检查的代码甚至不会被编译。


#![allow(unused)]
fn main() {
let sentence = "The fox jumps over the dog";
let index = sentence.find("fox");

// let words_after_fox = &sentence[index..]; // Error: Can't index str with Option<usize>

if let Some(fox) = index {
  let words_after_fox = &sentence[fox..];
  println!("{}", words_after_fox);
}
}

匈牙利记号法

在20世纪70年代,一种被称为 "匈牙利记号法 "的命名惯例开始在无类型或动态类型语言中使用。它的原理是在名字的开头添加一个助记符,以表示它所代表的内容,例如,布尔型访问变量可能被称为bVisited,字符串名称可能被称为strName。

你仍然可以在Delphi语言中看到这种命名惯例,类(类型)以T开头,字段以F开头,参数以A开头等。

type
 TKeyValue = class
  private
    FKey: integer;
    FValue: TObject;
  public
    property Key: integer read FKey write FKey;
    property Value: TObject read FValue write FValue;
    function Frobnicate(ASomeArg: string): string;
  end;

C#也有一个惯例,即所有的接口都应该以I开头,这意味着从C#切换到Rust的程序员有时也会在traits之前加上I。


#![allow(unused)]
fn main() {
trait IClone {
  fn clone(&self) -> Self;
}

let account_bytes: Vec<u8> = read_some_input();
let account_str = String::from_utf8(account_bytes)?;
let account: Account = account_str.parse()?;
}

在这种情况下,只要去掉前面的I就可以了。Rust的语法保证了不可能将trait和普通类型混淆,所以这种标记是多余的。这与C#形成了鲜明的对比,在C#中接口和类在很大程度上是可以互换的。

这在函数中也可以看到,人们在将某物从一种形式转换为另一种形式时,会为它重新命名。这些名字往往是愚蠢的或臆造的,给读者提供的额外信息可以忽略不计。


#![allow(unused)]
fn main() {
let account_bytes: Vec<u8> = read_some_input();
let account_str = String::from_utf8(account_bytes)?;
let account: Account = account_str.parse()?;
}

我的意思是,如果我们调用String::from_utf8(),我们已经知道account_str将是一个字符串,那么为什么还要加上_str后缀呢?

与其他很多语言不同的是,Rust鼓励你将变量从一种形式转化为另一种形式时对其进行隐式处理,特别是当之前的变量不再访问时(例如它被移动了)。


#![allow(unused)]
fn main() {
let account: Vec<u8> = read_some_input();
let account = String::from_utf8(account)?;
let account: Account = account.parse()?;
}

这可以说是一种优势,因为我们可以用同样的名字来表示同样的概念。

其他语言不喜欢隐式处理,因为它很容易失去对一个变量所包含的类型的跟踪(例如,在像JavaScript这样的动态类型语言中),或者容易引入错误(例如,程序员认为一个变量是某个类型,但它实际上包含其它东西)。

这两种情况,对于像Rust这样具有移动语义的强类型语言来说,都不太重要,所以你可以自由地使用隐式处理,不用担心会给自己带来麻烦。

大量的Rc<RefCell>

在面向对象的语言中,一个常见的模式是接受某个对象的引用,这样你就可以调用它的方法。

这本身并没有错,依赖注入是一件非常好的事情,但与大多数OO语言不同的是,Rust没有垃圾收集器,并且Rust对共享可变性有强烈的感受。

也许用一个例子会更容易理解。假设我们正在实现一个游戏,玩家需要殴打一群怪物,直到对它们造成一定量的伤害。

我们创建了一个怪物类,它有一个健康属性和一个takeDamage()方法,为了能够跟踪已经造成了多少伤害,我们将让人们提供回调,每当怪物受到伤害时就会调用。

type OnReceivedDamage = (damageReceived: number) => void;

class Monster {
    health: number = 50;
    receivedDamage: OnReceivedDamage[] = [];

    takeDamage(amount: number) {
        amount = Math.min(this.health, amount);
        this.health -= amount;
        this.receivedDamage.forEach(cb => cb(amount));
    }

    on(event: "damaged", callback: OnReceivedDamage): void {
        this.receivedDamage.push(callback);
    }
}

让我们也创建一个DamageCounter类,追踪我们造成了多少伤害,并让我们知道什么时候达到了这个目标。

class DamageCounter {
    damageInflicted: number = 0;

    reachedTargetDamage(): boolean {
        return this.damageInflicted > 100;
    }

    onDamageInflicted(amount: number) {
        this.damageInflicted += amount;
    }
}

现在我们将创建一些怪物,并不断造成随机数量的伤害,直到DamageCounter满意为止。

const counter = new DamageCounter();

const monsters = [new Monster(), new Monster(), new Monster(), new Monster(), new Monster()];
monsters.forEach(m => m.on("damaged", amount => counter.onDamageInflicted(amount)));

while (!counter.reachedTargetDamage()) {
    // pick a random monster
    const index = Math.floor(Math.random()*monsters.length);
    const target = monsters[index];
    // then damage it a bit
    const damage = Math.round(Math.random() * 50);
    target.takeDamage(damage);

    console.log(`Monster ${index} received ${damage} damage`);
}

现在把这段代码用Rust编写,使用Box<dyn Fn(u32)>来表示接受单个u32参数的闭包(JavaScript中所有闭包默认都分配在堆上)。


#![allow(unused)]
fn main() {
type OnReceivedDamage = Box<dyn Fn(u32)>;

struct Monster {
    health: u32,
    received_damage: Vec<OnReceivedDamage>,
}

impl Monster {
    fn take_damage(&mut self, amount: u32) {
        let damage_received = cmp::min(self.health, amount);
        self.health -= damage_received;
        for callback in &mut self.received_damage {
            callback(damage_received);
        }
    }

    fn add_listener(&mut self, listener: OnReceivedDamage) {
        self.received_damage.push(listener);
    }
}

impl Default for Monster {
    fn default() -> Self {
        Monster { health: 100, received_damage: Vec::new() }
    }
}
}

接下来是DamageCounter。


#![allow(unused)]
fn main() {
#[derive(Default)]
struct DamageCounter {
    damage_inflicted: u32,
}

impl DamageCounter {
    fn reached_target_damage(&self) -> bool {
        self.damage_inflicted > 100
    }

    fn on_damage_received(&mut self, damage: u32) {
        self.damage_inflicted += damage;
    }
}
}

最后是关于造成伤害的代码。

fn main() {
    let mut rng = rand::thread_rng();
    let mut counter = DamageCounter::default();
    let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

    for monster in &mut monsters {
        monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
    }

    while !counter.reached_target_damage() {
        let index = rng.gen_range(0..monsters.len());
        let target = &mut monsters[index];

        let damage = rng.gen_range(0..50);
        target.take_damage(damage);

        println!("Monster {} received {} damage", index, damage);
    }
}

当编译代码时,Rustc给出了4个关于monster.add_listener()的编译错误。


#![allow(unused)]
fn main() {
error[E0596]: cannot borrow `counter` as mutable, as it is a captured variable in a `Fn` closure
  --> src/main.rs:47:48
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                                                ^^^^^^^ cannot borrow as mutable

error[E0499]: cannot borrow `counter` as mutable more than once at a time
  --> src/main.rs:47:39
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                              ---------^^^^^^^^------------------------------------
   |                              |        |        |
   |                              |        |        borrows occur due to use of `counter` in closure
   |                              |        `counter` was mutably borrowed here in the previous iteration of the loop
   |                              cast requires that `counter` is borrowed for `'static`

error[E0597]: `counter` does not live long enough
  --> src/main.rs:47:48
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                              ------------------^^^^^^^----------------------------
   |                              |        |        |
   |                              |        |        borrowed value does not live long enough
   |                              |        value captured here
   |                              cast requires that `counter` is borrowed for `'static`
...
60 | }
   | - `counter` dropped here while still borrowed

error[E0502]: cannot borrow `counter` as immutable because it is also borrowed as mutable
  --> src/main.rs:50:12
   |
47 |         monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
   |                              -----------------------------------------------------
   |                              |        |        |
   |                              |        |        first borrow occurs due to use of `counter` in closure
   |                              |        mutable borrow occurs here
   |                              cast requires that `counter` is borrowed for `'static`
...
50 |     while !counter.reached_target_damage() {
   |            ^^^^^^^ immutable borrow occurs here
}

这些编译问题可以归结为:

  • 闭包捕获了一个对counter的引用

  • counter.on_damage_received()方法需要&mut self,所以我们的闭包需要一个&mut引用。我们在一个循环中添加闭包,所以我们最终在同一时间对同一个对象获取多个&mut引用。

  • listener 是一个没有生命周期参数的的盒式闭包,这意味着它拥有该变量的所有权。因为我们在循环中把counter移到闭包中,所以会出现 “use of moved value "的错误。

  • 在把counter传递给add_listener()后,又试图在循环条件中使用它。

上述问题的典型解决办法是把DamageCounter包裹在一个引用计数的指针中,它允许同时存在多个句柄。然后因为我们还需要调用一个&mut self方法,所以需要一个RefCell来将借用检查从编译时"移动"到运行时。

fn main() {
     let mut rng = rand::thread_rng();
-    let mut counter = DamageCounter::default();
+    let mut counter = Rc::new(RefCell::new(DamageCounter::default()));
     let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

     for monster in &mut monsters {
-        monster.add_listener(Box::new(|damage| counter.on_damage_received(damage)));
+        let counter = Rc::clone(&counter);
+        monster.add_listener(Box::new(move |damage| {
+            counter.borrow_mut().on_damage_received(damage)
+        }));
     }

-    while !counter.reached_target_damage() {
+    while !counter.borrow().reached_target_damage() {
         let index = rng.gen_range(0..monsters.len());
         let target = &mut monsters[index];
         ...
     }
 }

嗯......这很有效。但这种方法往往会产生混乱,特别是当你在结构中存储一些非平凡的东西,如Rc<RefCell<Vec>>>(或关于多线程的Arc<Mutex<Vec>>>)。

这也为你提供了这样的情况:RefCell可能被多次借用,因为你的代码很复杂,而调用栈中更高的东西已经在使用RefCell。对于Mutex来说,这将导致一个死锁,而RefCell会引发panic,这两种情况都对程序的可靠性有负面影响。

一个更好的方法是改变你的API,不持有对其他对象的长期引用。根据不同的情况,在Monster::take_damage()方法中接受一个回调参数可能是明智的做法。

struct Monster {
    health: u32,
}

impl Monster {
    fn take_damage(&mut self, amount: u32, on_damage_received: impl FnOnce(u32)) {
        let damage_received = cmp::min(self.health, amount);
        self.health -= damage_received;
        on_damage_received(damage_received);
    }
}

impl Default for Monster {
  fn default() -> Self { Monster { health: 100 } }
}

...

fn main() {
    let mut rng = rand::thread_rng();
    let mut counter = DamageCounter::default();
    let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

    while !counter.reached_target_damage() {
        let index = rng.gen_range(0..monsters.len());
        let target = &mut monsters[index];

        let damage = rng.gen_range(0..50);
        target.take_damage(damage, |dmg| counter.on_damage_received(dmg));

        println!("Monster {} received {} damage", index, damage);
    }
}

这样做的一个好处是,我们摆脱了所有的回调管理模板代码,这意味着这个版本只有47行,而不是Rc<RefCell<_>>版本的62行。

在某些情况下,给take_damage()一个回调参数可能是不可接受的,此时你可以返回一个发生了什么的"摘要",这样调用者就可以决定下一步该怎么做。

impl Monster {
    fn take_damage(&mut self, amount: u32) -> AttackSummary {
        let damage_received = cmp::min(self.health, amount);
        self.health -= damage_received;
        AttackSummary { damage_received }
    }
}

struct AttackSummary {
    damage_received: u32,
}

...

fn main() {
    let mut rng = rand::thread_rng();
    let mut counter = DamageCounter::default();
    let mut monsters: Vec<_> = (0..5).map(|_| Monster::default()).collect();

    while !counter.reached_target_damage() {
        let index = rng.gen_range(0..monsters.len());
        let target = &mut monsters[index];

        let damage = rng.gen_range(0..50);
        let AttackSummary { damage_received } = target.take_damage(damage);
        counter.on_damage_received(damage_received);

        println!("Monster {} received {} damage", index, damage);
    }
}

这是我的首选解决方案;根据经验,对于较大规模的代码或代码比较复杂的情况下,它往往效果很好。

**使用错误的整数类型 **

编写大量C语言的导致的另一个坏习惯是使用错误的整数类型,并且对usize的频繁转换感到沮丧。

特别是在索引的时候,C语言的程序员都被教导要使用int来做索引和for-loop,所以当他们使用Rust,需要存储一个索引列表时,往往会使用Vec。然后他们就会感到沮丧,因为Rust在索引方面相当严格,像数组、切片和Vec这样的标准类型只能使用usize(相当于size_t)进行索引,这意味着他们的代码被从i32到usize的转换弄得一团糟。

为什么Rust只允许用usize进行索引,有许多合理的理由:

  • 有一个负的索引是没有意义的(在一个片断开始之前访问项目是不合法的),所以我们可以通过用无符号整数做索引来避免bug。

  • usize被定义为一个整数,其大小与普通指针相同,这意味着指针运算不会有任何隐藏的转换。

  • std::mem::size_of() 和 std::mem::align_of() 函数返回usize类型。

由此观之,解决方案也显而易见。为你的应用选择正确的整数类型,当用于索引时,这个"正确的整数类型”很可能是usize。

**不安全 - 我知道我在做什么 **

每当我看到一个灰头土脸的C程序员因为借用检查器一直拒绝他们的代码而去找原始指针或std::mem::transmute()时,我就会想到这句话。

你经常会看到有人想黑掉隐私,创建自我引用的结构,或者用不安全的方式创建全局可变的变量。经常会有这样的评论:"但我知道这个程序只用一个线程,所以访问静态的Mut就可以了 "或者 "但这在C语言中完全可以工作"。

现实情况是,不安全代码是有细微差别的,你需要对Rust的借用检查规则和内存模型有良好的直觉。我不想当守门员,说 "你必须有这么高的身高才能写出多线程的不安全代码",但是如果你是这个语言的新手,你很有可能没有这种直觉,而且会让你自己和你的同事承受很大的痛苦。

如果你想更多的了解Rust,或者你知道自己在做什么并合法地使用它,那么玩玩不安全的东西是可以的,但不安全的东西并不是一个神奇的避难所,它可以让编译器停止抱怨,让你用Rust语法写C语言。

**不使用命名空间 **

在C语言中,一个常见的做法是在函数前加上库或模块的名字,以帮助读者了解它来自哪里,并避免重复符号错误(例如rune_wasmer_runtime_load())。

然而,Rust有真正的命名空间,并允许你将方法附加到类型上(例如,rune::wasmer::Runtime::load())。

**过度使用切片索引 **

for-loop和索引在类C语言中的使用频繁很高。


#![allow(unused)]
fn main() {
let points: Vec<Coordinate> = ...;
let differences = Vec::new();

for i in 1..points.len() [
  let current = points[i];
  let previous = points[i-1];
  differences.push(current - previous);
]
}

然而,在使用索引时,即使是经验丰富的程序员也不能避免引入错误(例如,我需要记住从1开始循环并减去1以获得前一个点)。

在这种情况下,Rust鼓励你用迭代器。切片类型甚至带有高级工具,如windows()和array_windows()方法,让你在相邻的元素对上进行迭代。


#![allow(unused)]
fn main() {
let points: Vec<Coordinate> = ...;
let mut differences = Vec::new();

for [previous, current] in points.array_windows().copied() {
  differences.push(current - previous);
}
}

你甚至可以删除for-loop 和 differences变量。


#![allow(unused)]
fn main() {
let differences: Vec<_> = points
  .array_windows()
  .copied()
  .map(|[previous, current]| current - previous)
  .collect();
}

有些人认为带有map()和collect()的版本更干净或更 "实用",但我会让你自己来判断。

而且迭代器通常有更好的性能,因为检查可以作为循环条件的一部分来完成,而不是单独进行。

过度使用迭代器

过度使用迭代器也会产生问题:当你拥有的只是一把锤子时,一切看起来都像钉子。

长长的map()、filter()和and_then()调用链会让人很难读懂并追踪到实际发生的事情,尤其是当类型推理让你省略闭包参数的类型时。

其他时候,你基于迭代器的解决方案只是不必要的复杂。作为一个例子,看一下这段代码,看看你是否能弄清楚它要做什么。


#![allow(unused)]
fn main() {
pub fn functional_blur(input: &Matrix) -> Matrix {
    assert!(input.width >= 3);
    assert!(input.height >= 3);

    // Stash away the top and bottom rows so they can be
    // directly copied across later
    let mut rows = input.rows();
    let first_row = rows.next().unwrap();
    let last_row = rows.next_back().unwrap();

    let top_row = input.rows();
    let middle_row = input.rows().skip(1);
    let bottom_row = input.rows().skip(2);

    let blurred_elements = top_row
        .zip(middle_row)
        .zip(bottom_row)
        .flat_map(|((top, middle), bottom)| blur_rows(top, middle, bottom));

    let elements: Vec<f32> = first_row
        .iter()
        .copied()
        .chain(blurred_elements)
        .chain(last_row.iter().copied())
        .collect();

    Matrix::new_row_major(elements, input.width, input.height)
}

fn blur_rows<'a>(
    top_row: &'a [f32],
    middle_row: &'a [f32],
    bottom_row: &'a [f32],
) -> impl Iterator<Item = f32> + 'a {
    // stash away the left-most and right-most elements so they can be copied across directly.
    let &first = middle_row.first().unwrap();
    let &last = middle_row.last().unwrap();

    // Get the top, middle, and bottom row of our 3x3 sub-matrix so they can be
    // averaged.
    let top_window = top_row.windows(3);
    let middle_window = middle_row.windows(3);
    let bottom_window = bottom_row.windows(3);

    // slide the 3x3 window across our middle row so we can get the average
    // of everything except the left-most and right-most elements.
    let averages = top_window
        .zip(middle_window)
        .zip(bottom_window)
        .map(|((top, middle), bottom)| top.iter().chain(middle).chain(bottom).sum::<f32>() / 9.0);

    std::iter::once(first)
        .chain(averages)
        .chain(std::iter::once(last))
}

信不信由你,但这是我见过的可读性较强的版本之一。现在我们来看看命令式的实现。


#![allow(unused)]
fn main() {
pub fn imperative_blur(input: &Matrix) -> Matrix {
    assert!(input.width >= 3);
    assert!(input.height >= 3);

    // allocate our output matrix, copying from the input so
    // we don't need to worry about the edge cases.
    let mut output = input.clone();

    for y in 1..(input.height - 1) {
        for x in 1..(input.width - 1) {
            let mut pixel_value = 0.0;

            pixel_value += input[[x - 1, y - 1]];
            pixel_value += input[[x, y - 1]];
            pixel_value += input[[x + 1, y - 1]];

            pixel_value += input[[x - 1, y]];
            pixel_value += input[[x, y]];
            pixel_value += input[[x + 1, y]];

            pixel_value += input[[x - 1, y + 1]];
            pixel_value += input[[x, y + 1]];
            pixel_value += input[[x + 1, y + 1]];

            output[[x, y]] = pixel_value / 9.0;
        }
    }

    output
}
}

我喜欢这个可读性好的版本。

不使用模式匹配

在大多数其他主流语言中,程序员在做一个可能抛出异常的操作之前写一个检查是很常见的。我们前面的C# IndexOf()片段就是一个很好的例子。


#![allow(unused)]
fn main() {
int index = sentence.IndexOf("fox");

if (index != -1)
{
  string wordsAfterFox = sentence.SubString(index);
  Console.WriteLine(wordsAfterFox);
}
}

你可能会看到这样的代码:


#![allow(unused)]
fn main() {
let opt: Option<_> = ...;

if opt.is_some() {
  let value = opt.unwrap();
  ...
}
}

或者


#![allow(unused)]
fn main() {
let list: &[f32] = ...;

if !list.is_empty() {
  let first = list[0];
  ...
}
}

现在这两个片段都是完全有效的代码,而且永远不会失败,但与哨兵值类似,在未来重构代码时容易引入错误。

使用模式匹配和Option可以帮助你避免这种情况,因为它可以确保你访问一个值的唯一方法是它是有效的。


#![allow(unused)]
fn main() {
if let Some(value) = opt {
  ...
}

if let [first, ..] = list {
  ...
}
}

构造后初始化

在许多语言中,调用对象的构造函数并在之后初始化其字段是很正常的(手动或通过调用一些init() 方法)。然而,这违背了Rust "使无效状态无法表示” 的一般惯例。

假设你正在编写一个NLP应用程序,并且有一个包含所有可能的单词的字典。

下面是创建字典的一种方式:


#![allow(unused)]
fn main() {
let mut dict = Dictionary::new();
// read the file and populate some internal HashMap or Vec
dict.load_from_file("./words.txt")?;
}

然而,这样写Dictionary意味着它现在有两个(隐藏的)状态--空和已填充。

所有使用 Dictionary 的下游代码都会认为它已经被填充了,并据此编写代码。如果把一个空的 dictionary 传递给期望有一个填充的 dictionary 的代码,可能会引发恐慌。比如用 dict["word"]索引到字典中,如果 "word "不在那里,就会引发panic。

但这是完全没有必要的。只要确保字典在构建后立即可用,而不是在事后填充它。


#![allow(unused)]
fn main() {
let dict = Dictionary::from_file("./words.txt")?;

impl Dictionary {
  fn from_file(filename: impl AsRef<Path>) -> Result<Self, Error> {
    let text = std::fs::read_to_string(filename)?;
    let mut words = Vec::new();
    for line in text.lines() {
      words.push(line);
    }
    Ok(Dictionary { words })
  }
}
}

Dictionary::from_file()可能会创建一个空的Vec,并逐步填充它,但它还不会被存储在Dictionary的字段中,所以不存在它被填充和有用的假设。

你陷入这种反模式的频率在很大程度上取决于你的背景和编码风格。

函数式语言通常是不可变的,所以你会很自然地使用惯用模式。毕竟,当你不允许改变任何东西的时候,要创建一个半初始化的东西并在以后填充它是有些困难的。

另一方面,面向对象语言允许在对象构建完成后对其进行初始化,特别是因为对象引用默认为空,而且他们对可变性没有任何顾虑。这会导致面向对象语言容易出现 NullPointerException。

防御性拷贝

不可变对象的一个非常好的属性是不可变。然而,在像Python和Java这样的语言中,不可变性不是传递性的--也就是说,如果x是一个不可变的对象,x.y也不能保证是不可变的,除非它被明确定义为不可变的。

这意味着有可能写出这样的代码。


#![allow(unused)]
fn main() {
class ImmutablePerson:
  def __init__(self, name: str, age: int, addresses: List[str]):
    self._name = name
    self._age = age
    self._addresses = addresses

  read-only properties
  @property
  def name(self): return self._name
  @property
  def age(self): return self._age
  @property
  def addresses(self): return self._addresses
}

然后有人来了,不小心把地址列表弄乱了。


#![allow(unused)]
fn main() {
def send_letters(message: str, addresses: List[str]):
  Note: the post office's API only works with with uppercase letters so we
  need to pre-process the address list
  for i, address in enumerate(addresses):
    addresses[i] = addresses.upper()

  client = PostOfficeClient()
  client.send_bulk_mail(message, addresses)


person = ImmutablePerson("Joe Bloggs", 42, ["123 Fake Street"])

send_letters(
  f"Dear {person.name}, I Nigerian prince. Please help me moving my monies.",
  person.addresses
)

print(person.addresses) # ["123 FAKE STREET"]
}

虽然我承认这个例子有点矫揉造作,但函数修改它们所给的参数是很常见的。通常这是好的,但是当你的ImmutablePerson假设它的地址字段永远不会改变时,项目另一边的一些随机代码在你不知道的情况下修改它是令人讨厌的。

典型的解决方案是预先复制列表,这样即使调用者试图改变它的内容,他们也会改变一个副本而不是原来的地址域。


#![allow(unused)]
fn main() {
class ImmutablePerson:
  ...

  @property
  def addresses(self): return self._addresses.copy()
}

一般来说,你会看到防御性副本被用在任何想要确保另一段代码不会在不恰当的时候修改某个共享对象的地方。

考虑到这是一篇关于Rust的文章,你可能已经猜到了这是什么根源--别名和可变性的组合。

你可能也猜到了为什么在编写Rust代码时,防御性拷贝并不是真正必要的 —— 生命期和引用的 "shared immutable XOR single mutable" 规则意味着,如果不首先向其原始所有者请求可变访问或通过使用std::sync::Mutex这样的类型明确选择共享变异,代码就不可能修改某物。

结论

还有一堆其他的坏习惯,我还没来得及触及,或者因为我想不出一个简洁的例子,所以没有包括在内。最后感谢所有回复我的帖子并提出建议的人。

用 Rust 实现 Lisp 解释器

  • 深度参考:https://stopachka.essay.dev/post/5/risp-in-rust-lisp
  • 本文作者:suhanyujie
  • 文章来自:https://github.com/suhanyujie/rust-cookbook-note
  • ps:水平有限,翻译不当之处,还请指正,谢谢!

前言

一段时间没有写 Rust 了,感觉有些生疏了,打算找个 Rust 小项目复习一下。在芽之家博客看到了这个博文,讲的是用 Rust 实现 lisp。有感兴趣的同学,可以一起看看。

作者介绍到,这是他的第一个练手项目,有些地方可能会实现的不是很好,但我觉得也是很有参考价值的,尤其是对于我这样的 Rust 新手。此外,作者还提到了另一篇 python 实现 lisp,这应该也是参考资料之一。

Lisp

在开始前,我们需要了解一些关于 lisp 的背景知识。Lisp 是一种高阶编程语言,在其基础上演变出了很多中方言,如:Scheme、Common Lisp 等。查阅了下百度百科,其描述可读性不强,建议阅读维基百科的描述,或者这个 Lisp 教程

在实现一个 Lisp(子集)的解析器之前,先要了解 Lisp 的语法规则。如果你想大概了解一下它的语法和简单使用,可以自己在本地安装一个环境,并尝试。这里以 Ubuntu 20.04 为例。可通过以下命令安装一个 common lisp 的实现 —— sbcl,用于熟悉 lisp:

sudo apt-get install sbcl

然后,在命令行中输入 sbcl,即可进入它的交互式命令行:

$ sbcl
This is SBCL 2.0.1.debian, an implementation of ANSI Common Lisp.
More information about SBCL is available at <http://www.sbcl.org/>.

SBCL is free software, provided as is, with absolutely no warranty.
It is mostly in the public domain; some portions are provided under
BSD-style licenses.  See the CREDITS and COPYING files in the
distribution for more information.

输入一个加法运算试一试:

$ * (+ 1 2)
3

可以看到,能得到计算后地结果 —— 3。

关于更多关于 Lisp 的语法在这里就不详细说明了,可以参考这个教程进行进一步学习。

Lisp 的算术运算

为了能尽快地实现目标,我们只是简单地实现一个类似于计算器的运算功能,别看只是一个小小的计算器,但也包含了很多的基础知识。

在开始之前,我们先确定好最终的目标,我们最终实现的效果如下:

(+ 10 5) //=> 15
(- 10 5) //=> 5

输入简单的 lisp 程序,就能输出对应的计算结果。在开始之前,先介绍一下我们的程序执行,所经历的大体过程:

程序 -> parse(解析) -> 抽象语法树 -> eval(执行) -> 结果

这个过程中的 parse 和 eval 就是我们要实现的功能。比如下面这个程序示例:

$ (+ 1 2)
3
$ (* 2 3)
6

换句话说,就是我们需要将我们输入的源代码解析转换成语法树,然后执行语法树就能得到我们想要的结果。而源码中,我们只需有三类输入:

  • 符号
  • 数值
  • 列表

将其用 Rust 枚举类型表示,如下:


#![allow(unused)]
fn main() {
#[derive(Clone)]
enum RispExp {
  Symbol(String),
  Number(f64),
  List(Vec<RispExp>),
}
}

你可能有些疑惑,没关系,我们继续向后看。

在解析源码时,我们会遇到错误,因此需要定义错误类型:

enum RispErr {
    Reason(String),
}

如果你想定义更健壮、好用的错误类型,可以参考这个。但这里,为了简化实现,我们只是将错误类型定义成一个枚举变体 Reason(String),一旦遇到异常,我们将异常信息装入其中,返回给调用方即可。

我们还需要一个作用域类型,用它来存储定义的变量、内置函数等。


#![allow(unused)]
fn main() {
#[derive(Clone)]
struct RispEnv {
  data: HashMap<String, RispExp>,
}
}

解析

根据前面的过程描述,我们要将源码解析成语法树,也就是 RispExp 的表示形式。这样做之前,我们需要将源码解析成一个一个 token。

比如我们的输入是 (+ 10 5),将其 token 化的结果是 ["(", "+", "10", "5", ")"]。使用 Rust 实现如下:


#![allow(unused)]
fn main() {
fn tokenize(expr: String) -> Vec<String> {
    expr.replace("(", " ( ")
        .replace(")", " ) ")
        .split_whitespace()
        .map(|x| x.to_string())
        .collect()
}
}

根据 lisp 表达式的规则,表达式一般都是由小括号包裹起来的,为了更好的通过空格分割 token,我们将小括号替换为两边各带有一个空格的括号。然后通过 split_whitespace 函数将字符串进行分割,并把每段字符串转换成带所有权的字符串,最后通过 collect 收集,以字符串数组的形式存放到变量中。

然后通过 parse 函数将其转化成 RispExp 类型结构:


#![allow(unused)]
fn main() {
fn parse<'a>(tokens: &'a [String]) -> Result<(RispExp, &'a [String]), RispErr> {
    let (token, rest) = tokens
        .split_first()
        .ok_or(RispErr::Reason("could not get token".to_string()))?;
    match &token[..] {
        "(" => read_seq(rest),
        ")" => Err(RispErr::Reason("unexpected `)`".to_string())),
        _ => Ok((parse_atom(token), rest)),
    }
}

fn read_seq<'a>(tokens: &'a [String]) -> Result<(RispExp, &'a [String]), RispErr> {
    let mut res: Vec<RispExp> = vec![];
    let mut xs = tokens;
    loop {
        let (next_token, rest) = xs
            .split_first()
            .ok_or(RispErr::Reason("could not find closing `)`".to_string()))?;
        if next_token == ")" {
            return Ok((RispExp::List(res), rest));
        }
        let (exp, new_xs) = parse(&xs)?;
        res.push(exp);
        xs = new_xs;
    }
}
}

得到 token 列表后,我们对 token 逐个解析,通过 split_first 取出 token 列表中的第一个 token,以及第一个以外的其余元素。 对第一个 token 进行模式匹配:

  • 如果表达式以 ( 开头,则调用 read_seq 读取表达式剩余部分的 token
  • 如果表达式以 ) 开头,则意味着当前表达式是错误的表达式。
  • 以上之外,则是要按正常情况解析 lisp 表达式中的原子 —— atom。parse_atom 的实现如下:

#![allow(unused)]
fn main() {
fn parse_atom(token: &str) -> RispExp {
    let potential_float: Result<f64, ParseFloatError> = token.parse();
    match potential_float {
        Ok(v) => RispExp::Number(v),
        Err(_) => RispExp::Symbol(token.to_string().clone()),
    }
}
}

根据语法规则,一个原子是一个数字连续字符或字符串,它包括数字和特殊字符。 我们先尝试将其解析为数值类型,如果解析失败,则意味着它是字符串 —— RispExp::Symbol(token.to_string().clone())。

我们会在全局符号表中存储变量的定义和函数定义,因此我们需要扩展一下 RispExp:


#![allow(unused)]
fn main() {
#[derive(Clone)]
enum RispExp {
    Symbol(String),
    Number(f64),
    List(Vec<RispExp>),
    Func(fn(&[RispExp]) -> Result<RispExp, RispErr>), // new
}
}

我们先创建一个存储特定符号的容器,每一个符号都有特殊的功能:


#![allow(unused)]
fn main() {
fn default_env() -> RispEnv {
    let mut data: HashMap<String, RispExp> = HashMap::new();
    data.insert(
        "+".to_string(),
        RispExp::Func(|args: &[RispExp]| -> Result<RispExp, RispErr> {
            let sum = parse_list_of_floats(args)?
                .iter()
                .fold(0.0, |sum, a| sum + a);
            Ok(RispExp::Number(sum))
        }),
    );
    data.insert(
        "-".to_string(),
        RispExp::Func(|args: &[RispExp]| -> Result<RispExp, RispErr> {
            let floats = parse_list_of_floats(args)?;
            let first = *floats
                .first()
                .ok_or(RispErr::Reason("expected at least one number".to_string()))?;
            let sum_of_rest = floats[1..].iter().fold(0.0, |sum, a| sum + a);

            Ok(RispExp::Number(first - sum_of_rest))
        }),
    );

    RispEnv { data }
}
}

这里我们先实现 +- 运算符的功能。并且为了简化实现,我们先简单粗暴地认为参数都是合法的数值类型,可以通过 parse_list_of_floats 解析这些参数:


#![allow(unused)]
fn main() {
fn parse_list_of_floats(args: &[RispExp]) -> Result<Vec<f64>, RispErr> {
    args.iter().map(|x| parse_single_float(x)).collect()
}

fn parse_single_float(exp: &RispExp) -> Result<f64, RispErr> {
    match exp {
        RispExp::Number(num) => Ok(*num),
        _ => Err(RispErr::Reason("expect a number".to_string())),
    }
}
}

执行

接下来是实现 eval(程序执行)部分了。

  • 1.程序体(表达式)的第一部分如果是标识符,则在全局环境中查询该标识符,如果存在,则返回(如果是 +- 等操作符,则返回 RispExp::Func 类型的操作逻辑实现)。
  • 2.如果是数值,则返回该数值
  • 3.如果是列表,则尝试步骤一。即先返回 RispExp::Func(函数类型),然后列表中的其他原子作为参数执行该函数。

#![allow(unused)]
fn main() {
fn eval(exp: &RispExp, env: &mut RispEnv) -> Result<RispExp, RispErr> {
    match exp {
        RispExp::Symbol(k) => env
            .data
            .get(k)
            .ok_or(RispErr::Reason(format!("unexpected symbol k={}", k)))
            .map(|x| x.clone()),
        RispExp::Number(_a) => Ok(exp.clone()),
        RispExp::List(list) => {
            let first_form = list
                .first()
                .ok_or(RispErr::Reason("expected a non-empty list".to_string()))?;
            let arg_forms = &list[1..];
            let first_eval = eval(first_form, env)?;
            match first_eval {
                RispExp::Func(f) => {
                    let args_eval = arg_forms
                        .iter()
                        .map(|x| eval(x, env))
                        .collect::<Result<Vec<RispExp>, RispErr>>();
                    f(&args_eval?)
                }
                _ => Err(RispErr::Reason("first form must be a function".to_string())),
            }
        }
        RispExp::Func(_) => Err(RispErr::Reason("unexpected form".to_string())),
    }
}
}

前面提到过,我们要实现一个简单的计算器,而 lisp 的计算表达式一般是以符号原子开始的,如:(+ 1 2)。 当把这个表达式转换为 RispExp 结构后的形式类似于:

// 伪代码
PlusFunc(
  num1,
  num2,
  ...
)

我们先通过 + 匹配到事先在 default_env 中注册好的函数 f,然后向该函数中传入第一个原子之后的所有参数:f(num1, num2),就能得到执行结果。

REPL

REPL 的全称是 Read Evel Print Loop,表示一种交互形式:读取 -> 执行 -> 打印结果 -> 循环。

针对前面实现的 lisp 子集,我们可以为其实现一个 repl,用于更好的使用该“lisp 解释器”。

我们要做的很简单,读取用户输入,然后解析执行,把执行结果打印出来,然后不断地循环整个过程。那接下来,把解释器的实现用循环包裹起来试试:


#![allow(unused)]
fn main() {
fn parse_eval(expr: String, env: &mut RispEnv) -> Result<RispExp, RispErr> {
    let (parsed_exp, _) = parse(&tokenize(expr))?;
    let evaled_exp = eval(&parsed_exp, env)?;
    Ok(evaled_exp)
}
}

获取用户输入的表达式,再调用 parse_eval:


#![allow(unused)]
fn main() {
fn slurp_expr() -> String {
    let mut expr = String::new();
    io::stdin()
        .read_line(&mut expr)
        .expect("Failed to read line");
    expr
}

pub fn run_repl() {
    let env = &mut default_env();
    loop {
        println!("risp >");
        let expr = slurp_expr();
        match parse_eval(expr, env) {
            Ok(res) => println!("// 🔥 => {}", res),
            Err(e) => match e {
                RispErr::Reason(msg) => println!("// 🙀 => {}", msg),
            },
        }
    }
}
}

好了,接下来我们把 run_repl 放入 main 函数中:

fn main() {
    run_repl();
}

大功告成!我们只需在命令行中输入 cargo run 即可启动你的 repl 程序。完整的代码可以点此查看

启动后,输入简单的 lisp 表达式,看看效果:

risp >
(+ 1 2 )
// 🔥 => 3
risp >
(+ 1 10 (+ 20 1))
// 🔥 => 32

可以看出,单一的表达式和嵌套的表达式的加、减法都可以正确地计算出结果。这样,我们算是实现了这个简单的加减法计算。

版本 0.1.1

目前,我们的“lisp”仅支持简单的加、减等算数运算,我们需要扩展它。先给它增加 bool 类型的支持。


#![allow(unused)]
fn main() {
#[derive(Clone)]
enum RispExp {
    Symbol(String),
    Number(f64),
    List(Vec<RispExp>),
    Func(fn(&[RispExp]) -> Result<RispExp, RispErr>),
    Bool(bool),  // ->new
}
}

对应的我们需要调整 parse_atom 中的代码:


#![allow(unused)]
fn main() {
fn parse_atom(token: &str) -> RispExp {
    match token {
        "true" => {
            RispExp::Bool(true)
        },
        "false" => {
            RispExp::Bool(false)
        },
        _ => {
            let potential_float: Result<f64, ParseFloatError> = token.parse();
            match potential_float {
                Ok(v) => RispExp::Number(v),
                Err(_) => RispExp::Symbol(token.to_string().clone()),
            }
        }
    }
}
}

有了布尔类型之后,我们可以实现 ><= 等比较运算符,因为通过这些运算符计算后的结果值是布尔值。

要能支持这些比较运算符,我们需要将 = 对应的处理逻辑加到 default_env 中:


#![allow(unused)]
fn main() {
// = 逻辑实现
data.insert(
    "=".to_string(),
    RispExp::Func(|args: &[RispExp]| -> Result<RispExp, RispErr> {
        let floats = parse_list_of_floats(args)?;
        // 要想比较,需要有两个值
        if floats.len() != 2 {
            return Err(RispErr::Reason("expected two number".to_string()));
        }
        // 将第 0 个元素和第 1 个元素进行比较
        if floats.get(0).is_none() || floats.get(1).is_none() {
            return Err(RispErr::Reason("expected number".to_string()));
        }
        let is_ok = floats.get(0).unwrap().eq(floats.get(1).unwrap());
        Ok(RispExp::Bool(is_ok))
    }),
);
}

此时,我们的 lisp 解释器已经支持了 = 的操作,使用 cargo run 运行 repl:

risp >
(= 12 12)
// 🔥 => true
risp >
(= 1 2 3)
// 🙀 => expected two number
risp >

真不错,我们实现了 = 操作的扩展支持。我们还需要继续支持 >>=<<=。以 >= 为例,将其实现加入到 default_env 函数中:


#![allow(unused)]
fn main() {
data.insert(
    ">=".to_string(),
    RispExp::Func(|args: &[RispExp]| -> Result<RispExp, RispErr> {
        let floats = parse_list_of_floats(args)?;
            // 要想比较,需要有两个值
        if floats.len() != 2 {
            return Err(RispErr::Reason("expected two number".to_string()));
        }
            // 校验这两个值必须存在
        if floats.get(0).is_none() || floats.get(1).is_none() {
            return Err(RispErr::Reason("expected number".to_string()));
        }
        Ok(RispExp::Bool(
            floats.get(0).unwrap().gt(floats.get(1).unwrap()),
        ))
    }),
);
}

根据原博客,为了简化代码,这部分的实现可以用宏实现:


#![allow(unused)]
fn main() {
macro_rules! ensure_tonicity {
    ($check_fn:expr) => {{
        |args: &[RispExp]| -> Result<RispExp, RispErr> {
            let floats = parse_list_of_floats(args)?;
            let first = floats
                .first()
                .ok_or(RispErr::Reason("expected at least one number".to_string()))?;
            let rest = &floats[1..];
            fn f(prev: &f64, xs: &[f64]) -> bool {
                match xs.first() {
                    Some(x) => $check_fn(prev, x) && f(x, &xs[1..]),
                    None => true,
                }
            };
            Ok(RispExp::Bool(f(first, rest)))
        }
    }};
}

data.insert(
    ">".to_string(),
    RispExp::Func(ensure_tonicity!(|a, b| a > b)),
);

data.insert(
    "<".to_string(),
    RispExp::Func(ensure_tonicity!(|a, b| a < b)),
);

data.insert(
    "<=".to_string(),
    RispExp::Func(ensure_tonicity!(|a, b| a <= b)),
);
}

这样就实现了所有比较运算符的处理逻辑了。

要实现一个更接近 lisp 的语言,我们还需要引入 defif 这两关键字了。这两关键字的作用见下表:

图片来自知乎专栏

因此,我们先更新 eval 函数,使其优先匹配内置标识符(关键字),如果不是关键字,则直接按照原先逻辑执行:

fn eval(exp: &RispExp, env: &mut RispEnv) -> Result<RispExp, RispErr> {
    match exp {
        ...
        ...

        RispExp::List(list) => {
            let first_form = list
                .first()
                .ok_or(RispErr::Reason("expected a non-empty list".to_string()))?;
            let arg_forms = &list[1..];
            // 优先匹配并处理“关键字”
            match eval_built_in_form(first_form, arg_forms, env) {
                Some(built_in_res) => built_in_res,
                None => {
                    let first_eval = eval(first_form, env)?;
                    match first_eval {
                        RispExp::Func(f) => {
                            let args_eval = arg_forms
                                .iter()
                                .map(|x| eval(x, env))
                                .collect::<Result<Vec<RispExp>, RispErr>>();
                            f(&args_eval?)
                        }
                        _ => Err(RispErr::Reason("first form must be a function".to_string())),
                    }
                }
            }
        }
        RispExp::Func(_) => Err(RispErr::Reason("unexpected form".to_string())),
    }
}

// 处理内置标识符
fn eval_built_in_form(
    exp: &RispExp,
    other_args: &[RispExp],
    env: &mut RispEnv,
) -> Option<Result<RispExp, RispErr>> {
    match exp {
        RispExp::Symbol(symbol) => match symbol.as_ref() {
            "if" => Some(eval_if_args(other_args, env)),
            "def" => Some(eval_def_args(other_args, env)),
            _ => None,
        },
        _ => None,
    }
}

fn eval_if_args(args: &[RispExp], env: &mut RispEnv) -> Result<RispExp, RispErr> {
    let test_form = args
        .first()
        .ok_or(RispErr::Reason("expected test form".to_string()))?;
    let test_eval = eval(test_form, env)?;
    match test_eval {
        RispExp::Bool(b) => {
            let form_idx = if b { 1 } else { 2 };
            let res_form = args
                .get(form_idx)
                .ok_or(RispErr::Reason(format!("expected form idx={}", form_idx)))?;
            let res_eval = eval(res_form, env);
            res_eval
        }
        _ => Err(RispErr::Reason(format!(
            "unexpected test form='{}'",
            test_form.to_string()
        ))),
    }
}

根据上图表格中的描述,if 语法如下:(if test conseq alt),对 test 表达式求值,如果为真,则对 conseq 表达式求值并返回;否则,对 alt 表达式求值并返回。例如:(if (> 10 20) (+ 2 3) (- 1 2))

同理,def 语法:(def var exp)。用于定义一个新的变量 var,它的值是 exp 表达式的值,例如:(def k1 10)。逻辑实现如下:


#![allow(unused)]
fn main() {
fn eval_def_args(args: &[RispExp], env: &mut RispEnv) -> Result<RispExp, RispErr> {
    let var_exp = args.first().ok_or(RispErr::Reason(format!("unexepceted string for var")))?;

    let val_res = args.get(1).ok_or(RispErr::Reason(format!("expected second param.")))?;
    let evaled_val = eval(val_res, env)?;

    match var_exp {
        RispExp::Symbol(ref var_name) => {
            env.data.insert(var_name.clone(), evaled_val);
            Ok(var_exp.clone())
        },
        _ => Err(RispErr::Reason(format!("unexpected var name")))
    }
}
}

我们运行 repl(cargo run),通过一些输入,看看实现的效果:

risp >
(def a 1)
// 🔥 => a
risp >
(+ 1 a)
// 🔥 => 2
risp >
(if (> 2 1) true false)
// 🔥 => true
risp >
(if (< 2 1) true false)
// 🔥 => false

太棒了,一切都运行的很完美!

接下来,我们尝试支持另一种语法 —— lambda。下面是一个翻译文章对 lambda 的描述:

lambda 特殊形式会创建一个过程(procedure)。(lambda这个名字来源于Alonzo Church的lambda calculus) —— 来自译文

lambda 其实就是一种匿名函数,既然是函数也就意味着有参数列表和函数体,所以,lambda 的语法形式如下:(lambda (var...) exp),其中的 (var...) 是参数列表,exp 是函数体,因此我们定义 lambda 结构体:


#![allow(unused)]
fn main() {
#[derive(Clone)]
struct RispLambda {
    params: Rc<RispExp>,
    body: Rc<RispExp>,
}
}

解析 lambda 表达式:


#![allow(unused)]
fn main() {
fn eval_lambda_args(args: &[RispExp]) -> Result<RispExp, RispErr> {
    let params = args
        .first()
        .ok_or(RispErr::Reason(format!("unexpected args form")))?;
    let body = args
        .get(1)
        .ok_or(RispErr::Reason(format!("unexpected second form")))?;
    if args.len() != 2 {
        return Err(RispErr::Reason(format!("lambda can only have two forms")));
    }
    Ok(RispExp::Lambda(RispLambda {
        params: Rc::new(params.clone()),
        body: Rc::new(body.clone()),
    }))
}
}

对用户的输入进行解析,基于已经解析了的 RispExp 结构,当遇到的 List 是 lambda 类型时,将跟随在 lambda 后的第一个表达式视为“参数列表”,第二个表达式视为“lambda 函数体”。然后返回一个 RispExp::Lambda 实例。

当 lambda 被调用时,会生成一个不同于 default_env 的新 env,可将其视为当前函数的作用域,当执行函数体的时候,会使用新的 env 中的符号、参数等信息,如果查找不到,则在全局环境(default_env)中查找,所以需要调整一下 RispEnv:


#![allow(unused)]
fn main() {
struct RispEnv<'a> {
    data: HashMap<String, RispExp>,
    outer: Option<&'a RispEnv<'a>>,
}

/// 构建 lambda 执行环境
fn env_for_lambda<'a>(
    params: Rc<RispExp>,
    args: &[RispExp],
    outer_env: &'a mut RispEnv,
) -> Result<RispEnv<'a>, RispErr> {
    let ks = parse_list_of_symbol_strings(params)?;
    if ks.len() != args.len() {
        return Err(RispErr::Reason(format!(
            "expected {} params, got {}",
            ks.len(),
            args.len()
        )));
    }
    let vs = eval_forms(args, outer_env)?;
    let mut data: HashMap<String, RispExp> = HashMap::new();
    for (k, v) in ks.iter().zip(vs.iter()) {
        data.insert(k.clone(), v.clone());
    }

    Ok(RispEnv {
        data,
        outer: Some(outer_env),
    })
}

/// 执行一组表达式,将结果放入数组中
fn eval_forms(args: &[RispExp], env: &mut RispEnv) -> Result<Vec<RispExp>, RispErr> {
    args.iter().map(|x| eval(x, env)).collect()
}

/// 解析参数列表
fn parse_list_of_symbol_strings(params: Rc<RispExp>) -> Result<Vec<String>, RispErr> {
    let list = match params.as_ref() {
        RispExp::List(s) => Ok(s.clone()),
        _ => Err(RispErr::Reason(format!("expected params to be a list"))),
    }?;
    list.iter()
        .map(|x| match x {
            RispExp::Symbol(s) => Ok(s.clone()),
            _ => Err(RispErr::Reason(format!(
                "expected symbol in the argument list"
            ))),
        })
        .collect()
}
}

env_for_lambda 函数中的 data 是 lambda 内部环境,outer 则是外层(全局环境)env。 通过构建好的 lambda body,将其基于新构建的 lambda 环境执行,得到的结果即 lambda 调用结果。

fn eval(exp: &RispExp, env: &mut RispEnv) -> Result<RispExp, RispErr> {
    ...

    RispExp::List(list) => {
        ...

        match eval_built_in_form(first_form, arg_forms, env) {
            Some(built_in_res) => built_in_res,
            None => {
                let first_eval = eval(first_form, env)?;
                match first_eval {
                    RispExp::Func(f) => {
                        let args_eval = arg_forms
                            .iter()
                            .map(|x| eval(x, env))
                            .collect::<Result<Vec<RispExp>, RispErr>>();
                        f(&args_eval?)
                    }
                    RispExp::Lambda(lambda) => {    // ->  New
                        let new_env = &mut env_for_lambda(lambda.params, arg_forms, env)?;
                        eval(&lambda.body, new_env)
                    },
                    _ => Err(RispErr::Reason("first form must be a function".to_string())),
                }
            }
        }
    }
}

基本完成了 lambda 的支持,我们编译代码试试吧!(cargo run

risp >
(def add-one (lambda (a) (+ a 1)))
// 🔥 => add-one
risp >
(add-one 1)
// 🔥 => 2
risp >
(add-one 5)
// 🔥 => 6

REPL 中,我们通过 def 定义了一个名为 add-one 的 lambda 表达式。 然后调用 add-one,传入的参数为 1,结果为 2,入参为 5 时,计算结果为 6。符合预期!

至此,lambda 表达式支持完成!完整的代码可以点此查看

Lisp 是非常早期的高阶编程语言之一,它的出现开创了很多先驱概念,如:树、动态类型、高阶函数等。它结构简单,却是计算机语言发展中非常重要的基础。本文通过使用 Rust 实现 Lisp 子集,即是学习 Lisp 本身,也是学习 Rust 的语法和使用。基于此,你可以探索更加完整的 Lisp 实现。希望对读者你有帮助,感谢阅读!

参考资料

  • https://stopachka.essay.dev/post/5/risp-in-rust-lisp
  • 如何(用Python)写一个(Lisp)解释器(译文) https://zhuanlan.zhihu.com/p/28989326
  • https://lisp-lang.org/learn/getting-started/
  • mod 作用域,关于 Rust 模块详解,可以查看这篇文章
  • Lisp 教程 https://www.yiibai.com/lisp/lisp_overview.html
  • Lisp 维基百科 https://zh.wikipedia.org/wiki/LISP

基于编译器插件定制 clippy lint

作者: 吴翱翔@pymongo / 后期编辑: 张汉东

原文: 基于编译器插件定制 clippy lint


本文基于 Rust 月刊先前文章 华为 | 如何定制 Rust Clippy

上述文章中提供了改 clippy 源码或 dylint 库的两个开发定制 lint 的方案,

但 dylint 不能跨平台且以动态库形式分发难以使用,改 clippy 源码不方便与官方 lint 同步

基于上述困难,我便有了以下 lint 框架的设计需求:

  1. 一定要跨平台,同时支持 windows/mac/linux 等主流操作系统
  2. 不要有任何依赖,dylint 依赖 clippy 导致 rust-toolchain 被迫绑定跟 clippy 一样的版本
  3. 代码足够简单 50 行足以加新的 Lint,不用任何宏导致 IDE 看宏代码时变成"瞎子"
  4. 定制的 lint 工具对 用户/使用者 而言要易于使用

首先用 rustc_private 编译器模块将自己 lint 框架的库编译为库(以下简称 lints 库),然后可通过三种渠道运行

  1. rustc plugin 编译器动态库补丁
  2. ui_test
  3. 改 rust 源码引入 lints 库并编译为 toolchains
  4. RUSTC=/path/to/my_rustc cargo check

RUSTC 环境变量

最简单的定制 lint 的方法,将自己写的 lint 注入到 rustc 中假设将自己修改的 rustc 可执行文件叫 my_rustc

然后运行 "RUSTC=my_rustc" cargo check 就能运行自己定制的 lint 的检查了

所以我们将问题简化为 如何改 rustc 源码 + 如何编译 rustc 两个部分(当然这么运行会有问题,后文再展开)

rustc_driver

如果说 rust 源码路径下的 compiler/rustc/src/main.rs 是最终的编译器二进制文件

那么 rustc_driver 模块就是整个编译器的入口和"大脑",可以通过 rustc_private feature 引入这些编译器的模块

对比 rustc_driver 源码和 rustc 源码在 compiler/rustc/src/main.rs 里面也一行调用 rustc_driver::main()

发现只需要一行代码就能让 自己的代码编译出 rustc 编译器

fn main() {
    rustc_driver::RunCompiler::new(&std::env::args().collect::<Vec<_>>(), &mut DefaultCallback).run().unwrap();
}

rustc_driver::RunCompiler.run() 这就是 rustc 编译器的入口函数,我们将自己可执行文件的参数原封不动的转发给 rustc 就能让"编译器"跑起来

编译源码的子问题被简化为直接调用 RunCompiler,修改源码子问题转换成改 DefaultCallback 即可

如何引入 rustc_driver 库

  1. rustup component add rustc-dev 然后就可以用 feature rustc_private
  2. crates.io rustc-ap-rustc_driver 这个是 Rust 官方将编译器模块同步上传的版本
  3. 自己下载 rustc 源码通过路径引入或者通过 github 链接引入

trait rustc_driver::Callback

无论是官方的 clippy/miri 工具还是第三方的 flowistry(类似 rust-analyzer 的 Rust vscode 插件)或者 c2rust 等静态分析相关的项目

都是通过重写编译器回调 trait 的各个回调钩子方法,注入自己静态分析代码的逻辑

可以参考 flowistry 静态分析的这行代码:

https://github.com/willcrichton/flowistry/blob/2f0f843d46995367bf20f76b43315a7199bca70d/src/core/analysis.rs#L50

rustc_driver 找不到标准库

rustc_driver::RunCompiler::new 这样编译出来的 rustc 运行时报错找不到标准库

"can't find crate for std"

有个不完美的解决方案,在 build.rs 中加入一行,编译时"链接"标准库(以下为 Linux 示例)

println!("cargo:rustc-link-search=/home/w/.rustup/toolchains/nightly-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib");

build.rs 的 cargo:rustc-link-search= 默认库搜索类型是 all 也包含 Rust ABI 的 rlib 格式库文件

build.rs 方案 部分解决了「链接标准库」问题,某些时候也能正常运行,但 ldd 依然提示标准库找不到

[w@ww lints]$ ldd ~/.cargo/bin/lints 
        librustc_driver-e1b628cff3afb6ed.so => not found
        libstd-d6566390077dd5f5.so => not found

相比 build.rs 中链接标准库,更可靠的做法是运行时给 rustc 加一个 -L 参数链接标准库

例如以下是我 lint 框架的 ui 测试部分代码,我就是通过 rustc 运行时 -L 参数解决标准库找不到的问题


#![allow(unused)]
fn main() {
let stderr = std::process::Command::new("cargo")
    .arg("run")
    .arg("--")
    .arg("--emit=metadata")
    .arg("--crate-type=lib")
    .arg("--allow=dead_code")
    .arg("-L")
    .arg(env!("STD_DYLIB_PATH"))
    .arg(rs_file_path)
    .output()
}

env!("STD_DYLIB_PATH") 来自 build.rs,具体可看我 lint 框架源码: https://github.com/pymongo/lints

即便运行时加上 -L 参数,但这样的 rustc 依然不容易进行 cargo build 或 codegen,建议仅用于分析代码

45 行代码写个编译器补丁/插件

例如我想添加一个检测函数名为 foo 的 lint 检查规则,首先 cargo 新建一个库:

cargo new --lib rustc_plugin_my_lints

然后在 Cargo.toml 将库的类型设置成 dylib

[lib]
crate-type = ["dylib"]

然后在 src/lib.rs 中写下以下代码:


#![allow(unused)]
#![feature(rustc_private)]
fn main() {
extern crate rustc_ast;
extern crate rustc_driver;
extern crate rustc_interface;
extern crate rustc_lint;
extern crate rustc_span;

#[no_mangle]
fn __rustc_plugin_registrar(reg: &mut rustc_driver::plugin::Registry) {
    reg.lint_store.register_early_pass(|| Box::new(FnNameIsFoo));
}

struct FnNameIsFoo;
impl FnNameIsFoo {
    const LINT: rustc_lint::Lint = {
        let mut lint = rustc_lint::Lint::default_fields_for_macro();
        lint.name = "fn_name_is_foo";
        lint.default_level = rustc_lint::Level::Warn;
        lint
    };
}

impl rustc_lint::LintPass for FnNameIsFoo {
    fn name(&self) -> &'static str {
        "fn_name_is_foo"
    }
}

impl rustc_lint::EarlyLintPass for FnNameIsFoo {
    fn check_fn(
        &mut self,
        cx: &rustc_lint::EarlyContext<'_>,
        fn_kind: rustc_ast::visit::FnKind<'_>,
        span: rustc_span::Span,
        _: rustc_ast::NodeId,
    ) {
        if let rustc_ast::visit::FnKind::Fn(_, ident, ..) = fn_kind {
            if ident.as_str() == "foo" {
                rustc_lint::LintContext::struct_span_lint(cx, &Self::LINT, span, |diagnostic| {
                    diagnostic.build("foo is a bad name for function").emit();
                });
            }
        }
    }
}
}

然后新建一个 examples/test_plugin.rs 文件测试我们写好的编译器插件/补丁

#![feature(plugin)]
#![plugin(rustc_plugin_my_lints)]
fn foo() {

}
fn main() {
    foo();
}

最后可以运行下编译器补丁的测试:

cargo run --example test_plugin

然后得到了这样的输出:

warning: foo is a bad name for function
 --> examples/test_plugin.rs:3:1
  |
3 | / fn foo() {
4 | |
5 | | }
  | |_^
  |
  = note: `#[warn(fn_name_is_foo)]` on by default

lints 框架的设计思路

由于 plugin feature 已经 deprecated 了,以后可能会被删掉,我的 lint 框架的设计思路是只做 lint 检查规则的功能测试和 ui 测试,定期集成到 rust 源码中编译一套自己的工具链做长远使用,同时提供 my_rustc 可执行文件或者编译器插件库进行使用

在我读了很多 rust-analyzer/rust-clippy/dylint/flowistry 等静态分析相关项目源码后设计了 lints 静态分析框架

这是 lints 项目源码的链接: https://github.com/pymongo/lints

以下是 lints 项目的源码结构和解读:

.
├── build.rs
├── Cargo.lock
├── Cargo.toml
├── examples
│   └── compiler_plugin.rs # 编译器插件的示例
├── README.md # crate lints 的文档
├── rust-toolchain.toml # 定义所需 rustup components
├── src
│   ├── bin
│   │   └── rustc_.rs # 用于 ui 测试,也能通过 RUSTC=/path/to/rustc_ cargo c 运行在简单项目
│   ├── lib.rs
│   └── lints # early_lint + late_lint 的定义和逻辑
│       ├── check_enum_size.rs
│       ├── fn_name_is_foo.rs
│       └── mod.rs
├── tests # 测试代码
│   └── ui_test.rs # ui 测试
└── ui_test # ui 测试用例: 输入+输出+期待值
    ├── check_enum_size.rs
    ├── check_enum_size.stderr
    ├── fn_name_is_foo.rs
    └── fn_name_is_foo.stderr

添加一条 lint 检查规则非常简单只需在 src/lints 下加一个文件,定义一个新的结构体并注册在 src/lints/mod.rs 即可

最后,用 rustc plugin 插件不光能定制 lint 还可以做帮组源码中的 enum 排序等等有趣的事情

Rust 生态观察| SeaORM: 要做 Rust 版本的 ActiveRecord

作者:张汉东


引子

有些人说用 Rust 进行 Web 开发 是杀鸡用牛刀,这种观点其实是对「系统级语言」的刻板印象造成的。无论从性能、工程架构还是开发效率,Rust 其实都很出色,目前就是需要一套比较成熟的框架。无论如何,Rust 在 Web 开发领域的生态在逐步成型。

注意:这里的 Web 是指更加广义的 Web ,不仅仅是 CRUD,还包括 网络服务、云原生服务端、 WebAssembly 、嵌入式物联网、区块链等等。

这也促使我想写《Rust Web 生态观察》系列文章,时间精力有限,不定时更新。希望能给大家提供一个视角,来客观地 了解 Rust 在 Web 开发领域的发展。

Rust ORM 生态

Rust ORM 生态中,最早的 ORM 是 Diesel。Diesel 的作者 sgrif 曾经也是 ActiveRecord (知名 Web 框架 Ruby on Rails 内置的 ORM )的核心贡献者。Diesel ORM 的设计也是 sgrif 总结了 AR 中的经验教训。Diesel 是一个优秀的 ORM 框架,但是它并不支持异步。并且,Diesel 并不是 ActiveRecord 的 Rust 复刻版。

Active Record ,是一种领域模型模式,特点是一个模型类对应关系型数据库中的一个表,而模型类的一个实例对应表中的一行记录。它并不是 Ruby on Rails 首创,而是由 Martin Fowler 的《企业应用架构模式》一书中提出的。

Rails 的 Active Record ORM 框架,和 Rails 框架一样,遵循的是「约定大于配置」的惯例。比如 :

  • User 模型,对应的是 users 表。遵循单复数的约定。
  • 默认会以 id字段为主键。而以 _id后缀的字段作为外键。
  • 自动生成 find_by_id 之类的查询方法。
  • created_atupdated_at 在创建和更新记录的时候,自动设置时间戳。
  • (table_name)_count ,保存关联对象的数量。
  • 其他。

ORM 有两种模式: Active Record 与 Data Mapper

ActiveRecord : 一个对象既包含数据又包含行为。这些数据大部分是持久性的,需要存储在数据库中。Active Record使用最明显的方法,将数据访问逻辑放在域对象中。这样,所有人都知道如何在数据库中读取和写入数据。

DataMapper: 与Active Record不一样的地方在于它增加了一个映射器,把持久化对象的数据跟行为分开了。它的关键地方在于数据模型遵循了单一职责原则。DataMapper 适合更加复杂的层次结构。

随着 Rust 异步生态的发展,ORM 异步支持的需求也逐渐增多。

之后,sqlx 出现了。Go 语言生态中也有同名的数据库包,不确定 Rust 这个 sqlx 的命名是否参考它。

sqlx并不是一个 ORM 框架,它没有像Diesel这类支持orm框架的 DSL ,用户可以自己编写sql语句,将查询结果按列取出或映射到struct上。它的一些特点:

  • 支持 async-std tokio
  • 编译时查询检查(可选)
  • 内置连接池
  • 支持 postgresqlmysql/maridbsqlite
  • Rust实现mysqlpostgresql 访问驱动程序(sqlite使用了 libsqlite3 C 库)
  • 支持 TLS
  • 嵌套事务

sqlx 使用起来相对比较“原始”,直接操作 SQL 语句,没有 ORM 不太方便。

国内 Rust 社区小伙伴 @zhuxiujia 也实现了一个异步 ORM 框架 rbatis。Rbatis 并不是基于 sqlx 实现的,它的灵感来自于 Java 的 ORM 框架 Mybatis。Rbatis 提供了一些内置插件,可以针对一些常用场景增加开发效率。

而我们今天要看的主角是sea-orm ,它是基于 sqlx 实现的 ORM 框架,号称要实现 Rust 版本 的 ActiveRecord

SeaORM : 要做 Rust 版本的 Active Record

既然 sea-orm 喊出这样的口号,那它的架构设计肯定和 Active Record 是有点关系吧?让我们先从它的 API 开始探索。

SeaORM 示例

从它的 example 项目中可以看到如下使用示例:


#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/examples/rocket_example/src/main.rs
// 只摘录关键代码

mod post;
pub use post::Entity as Post;

const DEFAULT_POSTS_PER_PAGE: usize = 5;

// 使用 Rocket web 框架的一个 endpoint api
#[post("/", data = "<post_form>")]
async fn create(conn: Connection<Db>, post_form: Form<post::Model>) -> Flash<Redirect> {
    let form = post_form.into_inner();

    // 注意 ActiveModel ,这个在 Rails 的 ActiveRecord 中也有同名组件
    post::ActiveModel {
        title: Set(form.title.to_owned()),
        text: Set(form.text.to_owned()),
        ..Default::default()
    }
    .save(&conn)
    .await
    .expect("could not insert post");

    Flash::success(Redirect::to("/"), "Post successfully added.")
}

#[post("/<id>", data = "<post_form>")]
async fn update(conn: Connection<Db>, id: i32, post_form: Form<post::Model>) -> Flash<Redirect> {
    // 注意: find_by_id 关联函数
    let post: post::ActiveModel = Post::find_by_id(id)
        .one(&conn)
        .await
        .unwrap()
        .unwrap()
        .into();

    let form = post_form.into_inner();

    post::ActiveModel {
        id: post.id,
        title: Set(form.title.to_owned()),
        text: Set(form.text.to_owned()),
    }
    .save(&conn)
    .await
    .expect("could not edit post");

    Flash::success(Redirect::to("/"), "Post successfully edited.")
}

#[get("/?<page>&<posts_per_page>")]
async fn list(
    conn: Connection<Db>,
    posts_per_page: Option<usize>,
    page: Option<usize>,
    flash: Option<FlashMessage<'_>>,
) -> Template {
    // Set page number and items per page
    let page = page.unwrap_or(1);
    let posts_per_page = posts_per_page.unwrap_or(DEFAULT_POSTS_PER_PAGE);
    if page == 0 {
        panic!("Page number cannot be zero");
    }

    // Setup paginator
    // 注意: find() 函数
    let paginator = Post::find()
        // 注意 order_by_asc 函数
        .order_by_asc(post::Column::Id)
        .paginate(&conn, posts_per_page);
    let num_pages = paginator.num_pages().await.ok().unwrap();

    // Fetch paginated posts
    let posts = paginator
        .fetch_page(page - 1)
        .await
        .expect("could not retrieve posts");

    Template::render(
        "index",
        context! {
            page: page,
            posts_per_page: posts_per_page,
            posts: posts,
            flash: flash.map(FlashMessage::into_inner),
            num_pages: num_pages,
        },
    )
}

#[get("/<id>")]
async fn edit(conn: Connection<Db>, id: i32) -> Template {
    // 注意: post::Model 
    let post: Option<post::Model> = Post::find_by_id(id)
        .one(&conn)
        .await
        .expect("could not find post");

    Template::render(
        "edit",
        context! {
            post: post,
        },
    )
}

}

上面示例中,我们发现有很多来自于 ActiveRecord 的影子(标注注释的地方)。

如果你没有使用 Rails 和 ActiveRecord 的经验,也没有关系。至少你现在已经对 ActiveRecord 有了一个初步的印象:

  1. 数据模型 和 数据表 存在一一映射的关系,命名上甚至可能还有默认约定存在。
  2. ORM 会自动生成一些查询方法,比如 find_by_id / find 等等。

然后,我们在看看 post.rs 示例:


#![allow(unused)]
fn main() {
use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Deserialize, Serialize, FromForm)]
#[serde(crate = "rocket::serde")]
// 关于表名,和 Diesel 处理类似,你可以自己设置
// 这个 Model 是示例中定义的和数据表 `posts` 对应的数据模型,你也可以命名为 `Post`
#[sea_orm(table_name = "posts")] 
pub struct Model {
    // 可以通过宏指定主键
    #[sea_orm(primary_key)]
    pub id: i32,
    pub title: String,
    #[sea_orm(column_type = "Text")]
    pub text: String,
}

// 暂时不清楚这是起什么作用
// 几乎每个示例都会有这个类型,但没有使用它的地方
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

// 为 `ActiveModel` 实现一个 `ActiveModelBehavior ` trait 
// 这里有点莫名其妙 ,`ActiveModel` 和 `ActiveModelBehavior ` 应该都是 sea-orm 内部的
// 暂时猜测这行代码是为 Model 实现了一些默认行为,比如`find_by_id` 之类
impl ActiveModelBehavior for ActiveModel {}
}

至少,我们通过示例代码,找到了 SeaORM 框架架构的关键信息: ActiveModel/ ActiveModelBehavior / Entity 等。

我们继续找一个更加复杂的例子: examples/async-std

在这个例子里描述了如图这样的表关系:

1

按照 ActiveRecord 的思想,每个表要映射一个数据模型:


#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/examples/async-std/src/example_cake.rs
// example_cake.rs 对应 cake 表

use sea_orm::entity::prelude::*;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "cake")]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    pub name: String,
}

// 现在我们已经明确,Relation 类型是为了定义表关系
// 这里 Fruit 和 Cake 之间存在关系
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
    Fruit,
}

// 这里就是使用 `RelationTrait` 来定义它们之间的关系
impl RelationTrait for Relation {
    fn def(&self) -> RelationDef {
        match self {
            // 通过 `Entity::has_many` 函数来指定 Cake 和 Fruit 的一对多关系
            // Cake has_many Fruit
            // 返回的是 RelationDef 类型
            Self::Fruit => Entity::has_many(super::fruit::Entity).into(),
        }
    }
}

// 另外一个 trait : Related
impl Related<super::fruit::Entity> for Entity {
    // 此次应该是返回 Cake 模型有关系的 model 信息
    fn to() -> RelationDef {
        Relation::Fruit.def()
    }
}

// Cake 和 filling 之间是 多对多关系
impl Related<super::filling::Entity> for Entity {
    fn to() -> RelationDef {
        // 多对多关系通过中间表 cake_filling 来指定
        super::cake_filling::Relation::Filling.def()
    }

    fn via() -> Option<RelationDef> {
        // 多对多关系通过中间表 cake_filling 来指定
        // 这里是指 via Cake to filling
        Some(super::cake_filling::Relation::Cake.def().rev())
    }
}

// 熟悉的行为
// 为什么不直接由框架实现?
impl ActiveModelBehavior for ActiveModel {}

}

再看看 Fruit :


#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/examples/async-std/src/example_fruit.rs
use sea_orm::entity::prelude::*;

// 注意这个结构体 Entity 
#[derive(Copy, Clone, Default, Debug, DeriveEntity)]
pub struct Entity;

// 提供 EntityName trait 来指定 table name
// 根据之前的示例,这里也可以使用宏指定
impl EntityName for Entity {
    fn table_name(&self) -> &str {
        "fruit"
    }
}

#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)]
pub struct Model {
    pub id: i32,
    pub name: String,
    pub cake_id: Option<i32>,
}

// 这里有一个 DeriveColumn
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
pub enum Column {
    Id,
    Name,
    CakeId,
}

#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)]
pub enum PrimaryKey {
    Id,
}

// 实现 PrimaryKeyTrait ,指定 auto_increment 
// 猜测应该也可以通过宏指定
impl PrimaryKeyTrait for PrimaryKey {
    type ValueType = i32;

    fn auto_increment() -> bool {
        true
    }
}

// 设置 Fruit 和 Cake 有关系
#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
    Cake,
}

impl ColumnTrait for Column {
    type EntityName = Entity;
		// ColumnType 指定了对应数据库表的类型
    // 猜测框架应该有默认类型映射,这里是出于文档作用来显式指定
    fn def(&self) -> ColumnDef {
        match self {
            Self::Id => ColumnType::Integer.def(),
            Self::Name => ColumnType::String(None).def(),
            Self::CakeId => ColumnType::Integer.def(),
        }
    }
}

impl RelationTrait for Relation {
    fn def(&self) -> RelationDef {
        match self {
            // 指定 和 Cake 的关系,是一对多
            // Fruit belongs_to Cake
            Self::Cake => Entity::belongs_to(super::cake::Entity)
                .from(Column::CakeId) // 指定外键
                .to(super::cake::Column::Id)
                .into(),
        }
    }
}

impl Related<super::cake::Entity> for Entity {
    // 设置关系
    fn to() -> RelationDef {
        Relation::Cake.def()
    }
}

// 熟悉的操作
impl ActiveModelBehavior for ActiveModel {}
}

再看 CakeFilling :


#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/examples/async-std/src/example_cake_filling.rs

use sea_orm::entity::prelude::*;

#[derive(Copy, Clone, Default, Debug, DeriveEntity)]
pub struct Entity;

impl EntityName for Entity {
    fn table_name(&self) -> &str {
        "cake_filling"
    }
}

// Cake 和 Filling 是多对多的关系,所以这个 cake_filling 表是中间表
// 这里需要两个表的外键
#[derive(Clone, Debug, PartialEq, DeriveModel, DeriveActiveModel)]
pub struct Model {
    pub cake_id: i32,
    pub filling_id: i32,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
pub enum Column {
    CakeId,
    FillingId,
}

#[derive(Copy, Clone, Debug, EnumIter, DerivePrimaryKey)]
pub enum PrimaryKey {
    CakeId,
    FillingId,
}

// 中间表的外键不能自增
impl PrimaryKeyTrait for PrimaryKey {
    type ValueType = (i32, i32);

    fn auto_increment() -> bool {
        false
    }
}

#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {
    Cake,
    Filling,
}

impl ColumnTrait for Column {
    type EntityName = Entity;

    fn def(&self) -> ColumnDef {
        match self {
            Self::CakeId => ColumnType::Integer.def(),
            Self::FillingId => ColumnType::Integer.def(),
        }
    }
}

impl RelationTrait for Relation {
    fn def(&self) -> RelationDef {
        match self {
            // 设置 多对多关系
            // CakeFilling belongs_to Cake
            Self::Cake => Entity::belongs_to(super::cake::Entity)
                .from(Column::CakeId)
                .to(super::cake::Column::Id)
                .into(),
						// CakeFilling belongs_to Filling
            Self::Filling => Entity::belongs_to(super::filling::Entity)
                .from(Column::FillingId)
                .to(super::filling::Column::Id)
                .into(),
        }
    }
}

impl ActiveModelBehavior for ActiveModel {}

}

接下来,我们可以看看示例代码中关于表操作的代码:


#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/examples/async-std/src/select.rs

// 查询一对多关系的方法
async fn find_together(db: &DbConn) -> Result<(), DbErr> {
    print!("find cakes and fruits: ");

    // 通过 find_also_related 方法进行一对多关联查询
    let both: Vec<(cake::Model, Option<fruit::Model>)> =
        Cake::find().find_also_related(Fruit).all(db).await?;

    println!();
    for bb in both.iter() {
        println!("{:?}\n", bb);
    }

    Ok(())
}


// 查询多对多关系的方法
async fn find_many_to_many(db: &DbConn) -> Result<(), DbErr> {
    print!("find cakes and fillings: ");

    // 看得出来,通过提供的 `find_with_related` 可以进行关联查询
    let both: Vec<(cake::Model, Vec<filling::Model>)> =
        Cake::find().find_with_related(Filling).all(db).await?;

    println!();
    for bb in both.iter() {
        println!("{:?}\n", bb);
    }

    print!("find fillings for cheese cake: ");

    let cheese = Cake::find_by_id(1).one(db).await?;

    if let Some(cheese) = cheese {
        // find_related
        let fillings: Vec<filling::Model> = cheese.find_related(Filling).all(db).await?;

        println!();
        for ff in fillings.iter() {
            println!("{:?}\n", ff);
        }
    }

    print!("find cakes for lemon: ");

    let lemon = Filling::find_by_id(2).one(db).await?;

    if let Some(lemon) = lemon {
        let cakes: Vec<cake::Model> = lemon.find_related(Cake).all(db).await?;

        println!();
        for cc in cakes.iter() {
            println!("{:?}\n", cc);
        }
    }

    Ok(())
}

// from : https://github.com/SeaQL/sea-orm/blob/master/examples/async-std/src/operation.rs

pub async fn insert_and_update(db: &DbConn) -> Result<(), DbErr> {
    let pear = fruit::ActiveModel {
        // 注意 : Set 是函数
        name: Set("pear".to_owned()),
        ..Default::default()
    };
    // insert 函数
    let res = Fruit::insert(pear).exec(db).await?;

    println!();
    println!("Inserted: last_insert_id = {}\n", res.last_insert_id);

    let pear: Option<fruit::Model> = Fruit::find_by_id(res.last_insert_id).one(db).await?;

    println!();
    println!("Pear: {:?}\n", pear);

    let mut pear: fruit::ActiveModel = pear.unwrap().into();
    pear.name = Set("Sweet pear".to_owned());
    // update 函数
    let pear: fruit::ActiveModel = pear.update(db).await?;

    println!();
    println!("Updated: {:?}\n", pear);

    Ok(())
}
}

通过上面一系列从模型定义到数据操作,我们看得出来,SeaORM 的设计确实和 ActiveRecord 类似。如果开发者对 ActiveRecord 熟悉,那么会感觉很容易上手。比如,设置表关系的 DSL 方法: has_manybelongs_to

当然,SeaORM 也提供一些方便的编写数据迁移功能的方法和函数:


#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/examples/rocket_example/src/setup.rs

use sea_orm::sea_query::{ColumnDef, TableCreateStatement};
use sea_orm::{error::*, sea_query, DbConn, ExecResult};

async fn create_table(db: &DbConn, stmt: &TableCreateStatement) -> Result<ExecResult, DbErr> {
    let builder = db.get_database_backend();
    db.execute(builder.build(stmt)).await
}

pub async fn create_post_table(db: &DbConn) -> Result<ExecResult, DbErr> {
    let stmt = sea_query::Table::create()
        .table(super::post::Entity)
        .if_not_exists()
        .col(
            ColumnDef::new(super::post::Column::Id)
                .integer()
                .not_null()
                .auto_increment()
                .primary_key(),
        )
        .col(
            ColumnDef::new(super::post::Column::Title)
                .string()
                .not_null(),
        )
        .col(
            ColumnDef::new(super::post::Column::Text)
                .string()
                .not_null(),
        )
        .to_owned();

    create_table(db, &stmt).await
}

}

是通过 sql_query 组件提供的功能,我们接下来就会介绍它。

到目前为止,我们已经基本了解 SeaORM 的 架构设计 和 关键 概念和 API,让我们继续探索 SeaORM 的源码实现。

SeaORM 源码架构

Rails 的 ActiveRecord ORM 是一个功能相当丰富和成熟的框架,并且还细分了很多组件:

  • ActiveModel: 是从 ActiveRecord 抽象出来的组件,它是数据模型的抽象接口。
  • ActiveRecord: 专注于 数据库相关功能
  • ActiveStorage: 是 ActiveRecord 抽象的延伸,专门负责抽象和处理文件上传相关。

反观 SeaORM ,目前还很单薄,但是反过来看,未来也是充满想象的。

SeaORM 中也提供了 ActiveModel 抽象。

Entity 与 ActiveModel 抽象

Entity 抽象

主要代码在 https://github.com/SeaQL/sea-orm/tree/master/src/entity 目录下。


#![allow(unused)]
fn main() {
// Entity 必须是有 Entity Name 的,并且要实现 

// 这种写法避免了泛型限定过长
// `Iden` 是在 SeaQuery 中定义的,它表示任意查询语句中的标识符,可以转换为字符串
pub trait IdenStatic: Iden + Copy + Debug + 'static {
    fn as_str(&self) -> &str;
}

// 作为一个 Entity ,应该有特定的行为
pub trait EntityName: IdenStatic + Default {
    fn schema_name(&self) -> Option<&str> {
        None
    }

    fn table_name(&self) -> &str;

    fn module_name(&self) -> &str {
        self.table_name()
    }

    fn table_ref(&self) -> TableRef {
        match self.schema_name() {
            Some(schema) => (Alias::new(schema).into_iden(), self.into_iden()).into_table_ref(),
            None => self.into_table_ref(),
        }
    }
}

/// An Entity implementing `EntityTrait` represents a table in a database.
///
/// This trait provides an API for you to inspect it's properties
/// - Column (implemented [`ColumnTrait`])
/// - Relation (implemented [`RelationTrait`])
/// - Primary Key (implemented [`PrimaryKeyTrait`] and [`PrimaryKeyToColumn`])
///
/// This trait also provides an API for CRUD actions
/// - Select: `find`, `find_*`
/// - Insert: `insert`, `insert_*`
/// - Update: `update`, `update_*`
/// - Delete: `delete`, `delete_*`
pub trait EntityTrait: EntityName {
    type Model: ModelTrait<Entity = Self> + FromQueryResult;

    type Column: ColumnTrait;

    type Relation: RelationTrait;

    type PrimaryKey: PrimaryKeyTrait + PrimaryKeyToColumn<Column = Self::Column>;

    fn belongs_to<R>(related: R) -> RelationBuilder<Self, R>
    where
        R: EntityTrait,
    {
        RelationBuilder::new(RelationType::HasOne, Self::default(), related, false)
    }

    fn has_one<R>(_: R) -> RelationBuilder<Self, R>
    where
        R: EntityTrait + Related<Self>,
    {
        RelationBuilder::from_rel(RelationType::HasOne, R::to().rev(), true)
    }

    fn has_many<R>(_: R) -> RelationBuilder<Self, R>
    where
        R: EntityTrait + Related<Self>,
    {
        RelationBuilder::from_rel(RelationType::HasMany, R::to().rev(), true)
    }
  
  	fn find() -> Select<Self> {
        Select::new()
    }
  
  	fn find_by_id(values: <Self::PrimaryKey as PrimaryKeyTrait>::ValueType) -> Select<Self> {
        let mut select = Self::find();
        let mut keys = Self::PrimaryKey::iter();
        for v in values.into_value_tuple() {
            if let Some(key) = keys.next() {
                let col = key.into_column();
                select = select.filter(col.eq(v));
            } else {
                panic!("primary key arity mismatch");
            }
        }
        if keys.next().is_some() {
            panic!("primary key arity mismatch");
        }
        select
    }
  	
  	fn insert<A>(model: A) -> Insert<A>
    where
        A: ActiveModelTrait<Entity = Self>,
    {
        Insert::one(model)
    }
  
  	fn insert_many<A, I>(models: I) -> Insert<A>
    where
        A: ActiveModelTrait<Entity = Self>,
        I: IntoIterator<Item = A>,
    {
        Insert::many(models)
    }
  
  	fn update<A>(model: A) -> UpdateOne<A>
    where
        A: ActiveModelTrait<Entity = Self>,
    {
        Update::one(model)
    }

  	
  	fn update_many() -> UpdateMany<Self> {
        Update::many(Self::default())
    }
  
  	fn delete<A>(model: A) -> DeleteOne<A>
    where
        A: ActiveModelTrait<Entity = Self>,
    {
        Delete::one(model)
    }
  
  	fn delete_many() -> DeleteMany<Self> {
        Delete::many(Self::default())
    }
}
}

通过上面的关键代码,看得出来,一个 Entity 满足下面条件:

  1. 必须实现 EntityTrait
  2. Model/ Clomen/ Relation/ PrimaryKey 四个关联类型
  3. 提供一些默认行为,包括:belongs_to/ has_many/ CRUD相关方法

再看 ModelTrait :


#![allow(unused)]

fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/src/entity/model.rs
pub trait ModelTrait: Clone + Send + Debug {
    type Entity: EntityTrait;

    fn get(&self, c: <Self::Entity as EntityTrait>::Column) -> Value;

    fn set(&mut self, c: <Self::Entity as EntityTrait>::Column, v: Value);

    // 内连接(inner join)
    // Select 结构体对应查询对象
    fn find_related<R>(&self, _: R) -> Select<R>
    where
        R: EntityTrait,
        Self::Entity: Related<R>,
    {
        <Self::Entity as Related<R>>::find_related().belongs_to(self)
    }

		// 内连接(inner join),方向与 find_related 相反
    fn find_linked<L>(&self, l: L) -> Select<L::ToEntity>
    where
        L: Linked<FromEntity = Self::Entity>,
    {
        let tbl_alias = &format!("r{}", l.link().len() - 1);
        l.find_linked().belongs_to_tbl_alias(self, tbl_alias)
    }
}
}

如果说 Entity 是对 数据库中表的映射,那么 Model 是对 Entity 行为的抽象。

ModelTrait 中定义了 一个 Model 应该可以 Get/Set 一个字段的值(Value),并且可以通过 find_related 方法可以查询 belongs_to 关系。

ActiveModel 抽象

#![allow(unused)]
fn main() {
// https://github.com/SeaQL/sea-orm/blob/master/src/entity/active_model.rs

// ActiveRecord 模式中,Entity 对应每张表,那么表中每一行数据就代表一个 Active 对象
// ActiveValue 代表「当前活动行」Value
#[derive(Clone, Debug, Default)]
pub struct ActiveValue<V>
where
    V: Into<Value>,
{
    value: Option<V>,
    state: ActiveValueState,
}

// 这里刻意使用 驼峰式 来定义这个方法,个人理解是为了突出表达语义
#[allow(non_snake_case)]
pub fn Set<V>(v: V) -> ActiveValue<V>
where
    V: Into<Value>,
{
    ActiveValue::set(v)
}

// ActiveValue 状态
#[derive(Clone, Debug)]
enum ActiveValueState {
    Set,
    Unchanged,
    Unset,
}

// ActiveModelTrait trait 定义了 ActiveModel 的行为
#[async_trait]
pub trait ActiveModelTrait: Clone + Debug {
    type Entity: EntityTrait;

    fn take(&mut self, c: <Self::Entity as EntityTrait>::Column) -> ActiveValue<Value>;

    fn get(&self, c: <Self::Entity as EntityTrait>::Column) -> ActiveValue<Value>;

    fn set(&mut self, c: <Self::Entity as EntityTrait>::Column, v: Value);

    fn unset(&mut self, c: <Self::Entity as EntityTrait>::Column);

    fn is_unset(&self, c: <Self::Entity as EntityTrait>::Column) -> bool;

    fn default() -> Self;

    async fn insert(self, db: &DatabaseConnection) -> Result<Self, DbErr>
    where
        <Self::Entity as EntityTrait>::Model: IntoActiveModel<Self>,
    {
        let am = self;
        let exec = <Self::Entity as EntityTrait>::insert(am).exec(db);
        let res = exec.await?;
        // Assume valid last_insert_id is not equals to Default::default()
        if res.last_insert_id
            != <<Self::Entity as EntityTrait>::PrimaryKey as PrimaryKeyTrait>::ValueType::default()
        {
            let found = <Self::Entity as EntityTrait>::find_by_id(res.last_insert_id)
                .one(db)
                .await?;
            match found {
                Some(model) => Ok(model.into_active_model()),
                None => Err(DbErr::Exec("Failed to find inserted item".to_owned())),
            }
        } else {
            Ok(Self::default())
        }
    }

    async fn update(self, db: &DatabaseConnection) -> Result<Self, DbErr> {
        let exec = Self::Entity::update(self).exec(db);
        exec.await
    }

    /// Insert the model if primary key is unset, update otherwise.
    /// Only works if the entity has auto increment primary key.
    async fn save(self, db: &DatabaseConnection) -> Result<Self, DbErr>
    where
        Self: ActiveModelBehavior,
        <Self::Entity as EntityTrait>::Model: IntoActiveModel<Self>,
    {
        let mut am = self;
        am = ActiveModelBehavior::before_save(am);
        let mut is_update = true;
        for key in <Self::Entity as EntityTrait>::PrimaryKey::iter() {
            let col = key.into_column();
            if am.is_unset(col) {
                is_update = false;
                break;
            }
        }
        if !is_update {
            am = am.insert(db).await?;
        } else {
            am = am.update(db).await?;
        }
        am = ActiveModelBehavior::after_save(am);
        Ok(am)
    }

    /// Delete an active model by its primary key
    async fn delete(self, db: &DatabaseConnection) -> Result<DeleteResult, DbErr>
    where
        Self: ActiveModelBehavior,
    {
        let mut am = self;
        am = ActiveModelBehavior::before_delete(am);
        let exec = Self::Entity::delete(am).exec(db);
        exec.await
    }
}

// ActiveModelBehavior 中定义用户可以自定义的行为
/// Behaviors for users to override
pub trait ActiveModelBehavior: ActiveModelTrait {
    /// Create a new ActiveModel with default values. Also used by `Default::default()`.
    fn new() -> Self {
        <Self as ActiveModelTrait>::default()
    }

    /// Will be called before saving
    fn before_save(self) -> Self {
        self
    }

    /// Will be called after saving
    fn after_save(self) -> Self {
        self
    }

    /// Will be called before deleting
    fn before_delete(self) -> Self {
        self
    }
}
}

ActiveModel 代表的就是 活动中的 数据模型,对应当前被操作的表数据。

Rails 中的 ActiveModel 还提供一些模型验证等丰富的功能,目前 SeaORM 中的 ActiveModel 抽象也正在完善相关功能,参见 PR: Update ActiveModelBehavior API #210

Entity 和 ActiveModel 抽象是 SeaORM 抽象架构的基石。

DSL: 宏与代码生成

我们通过前面的示例看到 SeaORM 提供了一些 DSL 方法。除此之外,SeaORM 还提供了一些代码生成和宏,来方便开发。

sea-orm-cli

cargo run 提供了命令参数 -- generate entity 根据数据库表自动生成 Entity 文件。


#![allow(unused)]
fn main() {
MySQL (`--database-schema` option is ignored) 
cargo run -- generate entity -u mysql://sea:sea@localhost/bakery -o out

PostgreSQL
cargo run -- generate entity -u postgres://sea:sea@localhost/bakery -s public -o out
}

内部是通过 sea-orm-codegen 组件提供的 transform 来生成的 Entity 文件

sea-orm-macros

sea-orm-macros 组件中,实现了 DeriveEntity/ DeriveColumn/ DerivePrimaryKey /DeriveModel/DeriveActiveModel/ DeriveActiveModelBehavior等过程宏。

你可以通过 cargo run -- generate entity 来自动生成 Entity 文件,也可以通过这些过程宏自定义 Entity 文件。

多数据库支持

SeaORM 的 src 目录下还有关于 database/ driver/ query/ executor 等模块,主要负责底层数据库交互了,这些功能主要基于 sqlxSeaQuery 构建。

SeaQuery

SeaQuery是一个查询生成器,是 SeaORM的基础,用来在Rust中构建动态SQL查询,使用一个符合人体工程学的 API 将表达式、查询和模式构建为抽象语法树(AST)。在同一个 接口 后面,统一支持MySQL、Postgres和SQLite。它类似于 Rails 的 ActiveRecord ORM 框架的 Arel 组件。

示例代码:


#![allow(unused)]
fn main() {
// 参数绑定
assert_eq!(
    Query::select()
        .column(Glyph::Image)
        .from(Glyph::Table)
        .and_where(Expr::col(Glyph::Image).like("A"))
        .and_where(Expr::col(Glyph::Id).is_in(vec![1, 2, 3]))
        .build(PostgresQueryBuilder),
    (
        r#"SELECT "image" FROM "glyph" WHERE "image" LIKE $1 AND "id" IN ($2, $3, $4)"#
            .to_owned(),
        Values(vec![
            Value::String(Some(Box::new("A".to_owned()))),
            Value::Int(Some(1)),
            Value::Int(Some(2)),
            Value::Int(Some(3))
        ])
    )
);

// 动态查询
Query::select()
    .column(Char::Character)
    .from(Char::Table)
    .conditions(
        // some runtime condition
        true,
        // if condition is true then add the following condition
        |q| {
            q.and_where(Expr::col(Char::Id).eq(1));
        },
        // otherwise leave it as is
        |q| {},
    );

// 生成查询 SQL 
let query = Query::select()
    .column(Char::Character)
    .column((Font::Table, Font::Name))
    .from(Char::Table)
    .left_join(Font::Table, Expr::tbl(Char::Table, Char::FontId).equals(Font::Table, Font::Id))
    .and_where(Expr::col(Char::SizeW).is_in(vec![3, 4]))
    .and_where(Expr::col(Char::Character).like("A%"))
    .to_owned();

assert_eq!(
    query.to_string(MysqlQueryBuilder),
    r#"SELECT `character`, `font`.`name` FROM `character` LEFT JOIN `font` ON `character`.`font_id` = `font`.`id` WHERE `size_w` IN (3, 4) AND `character` LIKE 'A%'"#
);
assert_eq!(
    query.to_string(PostgresQueryBuilder),
    r#"SELECT "character", "font"."name" FROM "character" LEFT JOIN "font" ON "character"."font_id" = "font"."id" WHERE "size_w" IN (3, 4) AND "character" LIKE 'A%'"#
);
assert_eq!(
    query.to_string(SqliteQueryBuilder),
    r#"SELECT `character`, `font`.`name` FROM `character` LEFT JOIN `font` ON `character`.`font_id` = `font`.`id` WHERE `size_w` IN (3, 4) AND `character` LIKE 'A%'"#
);
}

小结

SeaORM 目前只是 0.2 版本,对比 Rails 的 ActiveRecord 来看,SeaORM 还有很长的路要走。通过这篇文章,我们大概对 SeaORM 有了高屋建瓴的理解,为使用 SeaORM 或 给 SeaORM 做贡献打一个基础。

Delicate 为什么从 actix-web 迁移到 poem

作者: 槟橙炮炮

槟橙炮炮离开了原来的工作岗位,准备找一份新的工作。如果你对他的工作感兴趣并需要一名 Rust工程师,请与他联系 binchengZhao@outlook.com .


What is delicate ?

delicate 一个轻量的分布式的任务调度平台.

delicate logo

特性

  • 友好的用户界面: [前端]方便地管理任务和执行器,监控其状态,支持手动维护运行中的任务等。

  • 灵活的操作: 灵活的任务操作,支持限制单个节点的最大并行数,与cron表达式相对应的时区设置,调度模式(单一、固定数量、不断重复),能够在任何时候手动触发任务,手动终止任务实例,在线查看任务日志。

  • 高可用性: Delicate支持横向扩展。通过部署尽可能多的Delicate服务器和执行器,很容易实现高可用性和性能。

  • 高性能: 轻量级和基本功能加快了性能,`delicate'的基本资源开销大约是(小于0.1%的cpu使用率,10m的内存.)

  • 可观察性: 有许多有意义的统计数据定期以图表的方式展现。

  • 升级: 系统的动态升级(升级是通过获得最新的源代码和进行数据库迁移.)

  • 复用性: 执行器提供restful-api ,可以让用户应用维护自定义任务.

  • 权限管理: 基于casbin实现的权限管理功能,持续优化体验.

delicate 架构图:

architecture

topology

项目效果图参见:https://github.com/BinChengZhao/delicate/tree/main/doc/src/_media

技术栈

  • 后端( scheduler & executor ): Rust

  • 原主要的依赖: (actix-web & diesel & delay-timer & serde & tracing)

  • 现主要的依赖: (poem & tokio & diesel & delay-timer & serde & tracing)

  • 前端: antd-admin (React)

  • Ui: Ant Design

  • 数据库: mysql , postgres (计划支持)

为什么要迁移到poem

  • 在使用 actix-web 迭代时, 由于actix-web 4 稳定版一直没有正式发布,想升级核心依赖,引入新功能, 却被动受限。技术栈革新是一个刻不容缓的计划,当poem发布时我知道机会来了。

  • 在使用 poem 且透明的依赖tokio时,我感到了前所未有的灵活性。 直接使用tokio生态的组件,去代替原来 actix-web 的一些组件,并且把大量的依赖进行了升级, 再也不用自己人工制作补丁,或者使用陈旧的依赖。

关于 poem的简要背景。

  1. 该框架具有极快的性能,一致的理念,以及清晰的实现。
  2. 基于hyper,与tokio结合,用户有更多的控制。

迁移的重点:

  1. 网络组件的重新组合,不同风格的维护应用状态。

  2. api级别的修改,避免业务逻辑调整。

迁移前的基本梳理:

  • poem中的handler是一个会生成一个FutureEndpoint对象,框架与tokio的协作可以让请求在多线程运行时中进行效计算。

    而actix-web则不是这样,它是内部由多个单线程的Runtime组成。 由于这种微妙的差别,以前用于actix-web的handler不能直接用于poem, 因为需要确保每一个handler的输入状态,并且保证跨越.await的值需要都Send。

  • poem的路由是一个可嵌套的Endpoint数据结构,与原来actix-web的配置不同。

  • poem公开的数据结构大多支持Send,可以高效利用线程资源, 而actix-web与之相反。

  • 需要修改所有的中间件实现,改造所有的后台Task,调整所有的全局状态。

  • 在直接依赖 tokio 1.0 的情况下升级多处依赖。

  • 全链路的测试,并编写迁移纪要。

下面是一些 poem & actix-web 的对比:

路由侧

之前基于actix-web的实现,大量路由组通过configure去注册, 应用状态通过 app_data注册,中间件通过wrap注册:


#![allow(unused)]
fn main() {
let app = App::new()
    .configure(actions::task::config)
    .configure(actions::user::config)
    .configure(actions::task_log::config)
    .configure(actions::executor_group::config)
    .configure(actions::executor_processor::config)
    .configure(actions::executor_processor_bind::config)
    .configure(actions::data_reports::config)
    .configure(actions::components::config)
    .configure(actions::operation_log::config)
    .configure(actions::user_login_log::config)
    .app_data(shared_delay_timer.clone())
    .app_data(shared_connection_pool.clone())
    .app_data(shared_scheduler_meta_info.clone())
    .wrap(components::session::auth_middleware())
    .wrap(components::session::session_middleware());
}

路由配置实例:


#![allow(unused)]
fn main() {
pub fn config(cfg: &mut web::ServiceConfig) {
    cfg.service(create_user)
        .service(show_users)
        .service(update_user)
        .service(delete_user)
        .service(login_user)
        .service(logout_user)
        .service(check_user)
        .service(change_password)
        .service(roles)
        .service(permissions)
        .service(append_permission)
        .service(delete_permission)
        .service(append_role)
        .service(delete_role);
}

}

handler 处理请求示例:


#![allow(unused)]
fn main() {
#[post("/api/user/create")]
async fn create_user(
    web::Json(user): web::Json<model::QueryNewUser>,
    pool: ShareData<db::ConnectionPool>,
) -> HttpResponse {

    // do someting.
}
}

现基于 poem的实现, 大量路由组通过Route去组织,可以多重嵌套。应用状态 & 中间件都是通过with注册, 所有的组件都有通用的特征 Endpoint


#![allow(unused)]
fn main() {
let app = Route::new().nest_no_strip(
            "/api",
            Route::new()
                .nest_no_strip("/api/task", actions::task::route_config())
                .nest_no_strip("/api/user", actions::user::route_config())
                .nest_no_strip("/api/role", actions::role::route_config())
                .nest_no_strip("/api/task_log", actions::task_log::route_config())
                .nest_no_strip("/api/tasks_state", actions::data_reports::route_config())
                .nest_no_strip("/api/task_instance", actions::task_instance::route_config())
                .nest_no_strip("/api/binding", actions::components::binding::route_config())
                .nest_no_strip("/api/operation_log", actions::operation_log::route_config())
          )
          .with(shared_delay_timer)
          .with(shared_connection_pool)
          .with(shared_scheduler_meta_info)
          .with(shared_request_client)
          .with(components::session::auth_middleware())
          .with(components::session::cookie_middleware());

}

poem中的路由配置实例:


#![allow(unused)]
fn main() {
pub fn route_config() -> Route {
    Route::new()
        .at("/api/user/create", post(create_user))
        .at("/api/user/list", post(show_users))
        .at("/api/user/update", post(update_user))
        .at("/api/user/delete", post(delete_user))
        .at("/api/user/login", post(login_user))
        .at("/api/user/logout", post(logout_user))
        .at("/api/user/check", post(check_user))
        .at("/api/user/change_password", post(change_password))
        .at("/api/user/roles", post(roles))
        .at("/api/user/permissions", post(permissions))
        .at("/api/user/append_permission", post(append_permission))
        .at("/api/user/delete_permission", post(delete_permission))
        .at("/api/user/append_role", post(append_role))
        .at("/api/user/delete_role", post(delete_role))
}
}

poemhandler 处理请求示例:


#![allow(unused)]
fn main() {
async fn create_user(
    web::Json(user): web::Json<model::QueryNewUser>,
    pool: ShareData<db::ConnectionPool>,
) -> HttpResponse {

    // do someting.
}
}

poem 理念的代入:

handler

poem 中的 handler, 与actix-web差异并不大只需要调整一些 extractor, 对于一些阻塞性的task,切换到tokio的api去计算


#![allow(unused)]
fn main() {
#[handler]

async fn show_task_log_detail(
    Json(query_params): Json<model::RecordId>,
    pool: Data<&Arc<db::ConnectionPool>>,
) -> impl IntoResponse {
    use db::schema::task_log_extend;

    if let Ok(conn) = pool.get() {
        let f_result = spawn_blocking::<_, Result<_, diesel::result::Error>>(move || {
            let task_log_extend = task_log_extend::table
                .find(query_params.record_id.0)
                .first::<model::TaskLogExtend>(&conn)?;

            Ok(task_log_extend)
        })
        .await;

        let log_extend = f_result
            .map(|log_extend_result| {
                Into::<UnifiedResponseMessages<model::TaskLogExtend>>::into(log_extend_result)
            })
            .unwrap_or_else(|e| {
                UnifiedResponseMessages::<model::TaskLogExtend>::error()
                    .customized_error_msg(e.to_string())
            });
        return Json(log_extend);
    }

    Json(UnifiedResponseMessages::<model::TaskLogExtend>::error())
}
}

Endpoint

Endpoint 抽象HTTP请求的Trait, 是所有 handler 的真实面貌。

你可以实现Endpoint来创建你自己的Endpoint处理器。

如下是Endpoint的定义:


#![allow(unused)]
fn main() {
/// An HTTP request handler.
#[async_trait::async_trait]
pub trait Endpoint: Send + Sync {
    /// Represents the response of the endpoint.
    type Output: IntoResponse;

    /// Get the response to the request.
    async fn call(&self, req: Request) -> Self::Output;
}
}

poemEndpoint 哲学,跟tower中的Service非常相似,但是poem更简洁一些, 并且poem也兼容tower可以复用其生态与组件。


#![allow(unused)]
fn main() {
/// `Service` provides a mechanism by which the caller is able to coordinate
/// readiness. `Service::poll_ready` returns `Ready` if the service expects that
/// it is able to process a request.
pub trait Service<Request> {
    /// Responses given by the service.
    type Response;

    /// Errors produced by the service.
    type Error;

    /// The future response value.
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    /// Returns `Poll::Ready(Ok(()))` when the service is able to process 
    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;

    /// Process the request and return the response asynchronously.
    fn call(&mut self, req: Request) -> Self::Future;
}
}

IntoResponse

IntoResponse 是响应数据的抽象。

所有可以转换为HTTP响应的Response类型都应该实现IntoResponse,并且它们可以作为handler的返回值。


#![allow(unused)]
fn main() {
pub trait IntoResponse: Send {
    /// Consume itself and return [`Response`].
    fn into_response(self) -> Response;

    /// Wrap an `impl IntoResponse` to add a header.
    fn with_header<K, V>(self, key: K, value: V) -> WithHeader<Self>
    where
        K: TryInto<HeaderName>,
        V: TryInto<HeaderValue>,
        Self: Sized,
    {
        let key = key.try_into().ok();
        let value = value.try_into().ok();

        WithHeader {
            inner: self,
            header: key.zip(value),
        }
    }

    /// Wrap an `impl IntoResponse` to set a status code.
    fn with_status(self, status: StatusCode) -> WithStatus<Self>
    where
        Self: Sized,
    {
        WithStatus {
            inner: self,
            status,
        }
    }

    /// Wrap an `impl IntoResponse` to set a body.
    fn with_body(self, body: impl Into<Body>) -> WithBody<Self>
    where
        Self: Sized,
    {
        WithBody {
            inner: self,
            body: body.into(),
        }
    }
}

}

middleware

使用poem制作中间件非常的轻松,如下是一个给请求增加 logger-id 的middlware的示例:


#![allow(unused)]
fn main() {
// Unit-struct of logger-id for impl Middleware.
pub struct LoggerId;

impl<E: Endpoint> Middleware<E> for LoggerId {
    type Output = LoggerIdMiddleware<E>;

    fn transform(&self, ep: E) -> Self::Output {
        LoggerIdMiddleware { ep }
    }
}
// Wraps the original handler and logs the processing of the request internally.
pub struct LoggerIdMiddleware<E> {
    ep: E,
}

#[poem::async_trait]
impl<E: Endpoint> Endpoint for LoggerIdMiddleware<E> {
    type Output = E::Output;

    async fn call(&self, req: Request) -> Self::Output {
        let unique_id = get_unique_id_string();
        self.ep
            .call(req)
            .instrument(info_span!("logger-", id = unique_id.deref()))
            .await
    }
}

}

如下是actix-web 实现middlware的模板示例,模板代码确实稍有冗长,且耐人寻味。


#![allow(unused)]
fn main() {
pub(crate) struct CasbinService;

impl<S, B> Transform<S> for CasbinService
where
    S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = ActixWebError>
        + 'static,
    S::Future: 'static,
    B: 'static,
{
    type Request = ServiceRequest;
    type Response = ServiceResponse<B>;
    type Error = ActixWebError;
    type InitError = ();
    type Transform = CasbinAuthMiddleware<S>;
    type Future = Ready<Result<Self::Transform, Self::InitError>>;

    fn new_transform(&self, service: S) -> Self::Future {
        ok(CasbinAuthMiddleware {
            service: Rc::new(RefCell::new(service)),
        })
    }
}

pub struct CasbinAuthMiddleware<S> {
    service: Rc<RefCell<S>>,
}


impl<S, B> Service for CasbinAuthMiddleware<S>
where
    S: Service<Request = ServiceRequest, Response = ServiceResponse<B>, Error = ActixWebError>
        + 'static,
    S::Future: 'static,
    B: 'static,
{
    type Request = ServiceRequest;
    type Response = ServiceResponse<B>;
    type Error = ActixWebError;
    type Future = MiddlewareFuture<Self::Response, Self::Error>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.service.poll_ready(cx)
    }

    fn call(&mut self, req: ServiceRequest) -> Self::Future {
        Box::pin(async move {

           // do something.
           return service.call(req).await;
        })
    }
}

总结

  1. 本次迁移涉及到45个文件的修改和4000行代码的修改(增加了2500行,删除了1579行)。

  2. 切换到poem上,陈旧依赖得以升级,且透明的依赖tokio生态时,项目拥有了前所未有的灵活性。再也不用自己人工制作补丁,或者使用陈旧的依赖。

  3. 迁移后有了poem & tokio 生态加持更容易扩展功能,也降低了维护成本。

  4. 在不影响性能指标的情况下,更好的提升了资源利用率,发挥多核优势。

感谢

在迁移过程中,我有一些需求使用poem 是无法直接处理 随后在 poem 上打开了几个issues,不到一天内就与作者沟通完成,并在poem支持了该功能,太强大了!

  • 我要感谢整个社区和代码贡献者。特别是poem的作者: 油条哥
  • 感谢用户报告文档中的拼写错误, 这非常感谢大家。
  • 感谢用户加入我们,提供反馈,讨论功能,并获得帮助!
  • 我也很感谢actix-web社区这么好的作品,因为技术选择问题,我决定迁移到poem

Repos:

poem

delicate

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


keyscope: 用Rust构建的 key/secret 工作流工具

service_policy_kit提供支持。

https://github.com/SpectralOps/keyscope

universal-android-debloater: 用Rust编写的跨平台 Debloat GUI

使用ADB对未root的android设备进行debloat。改善你的隐私,安全和你的设备的电池寿命。

免责声明:该软件仍处于开发的早期阶段,使用它需要您自担风险。对于您手机上可能发生的任何事情,该库作者概不负责。

https://github.com/0x192/universal-android-debloater

ruffle: Rust 实现的 Adob​​e Flash Player 模拟器

基于 Rust 和 WebAssembly 实现。

https://github.com/ruffle-rs/ruffle

chumsky: 一个友好的解析器组合器

使编写具有错误恢复和部分解析功能的LL(k)解析器变得容易。

https://github.com/zesterer/chumsky

onefetch: 一个用命令行 Git 信息工具

可以直接在终端上显示本地 Git 存储库的项目信息和代码统计信息。该工具完全离线,不需要网络访问。

https://github.com/o2sh/onefetch

solana-program-library: Solana 维护的链上程序的集合

Solana 程序库 (SPL) 是一系列针对 Sealevel 并行运行时的链上程序。这些程序针对 Solana 的 Sealevel 实现、solana-runtime 进行了测试,并部署到其主网上。

https://github.com/solana-labs/solana-program-library

aliyundrive-webdav: 阿里云盘 WebDAV 服务

阿里云盘 WebDAV 服务,主要使用场景为配合支持 WebDAV 协议的客户端 App 如 Infuse、nPlayer 等实现在电视上直接观看云盘视频内容, 支持上传文件,但受限于 WebDAV 协议不支持文件秒传。

https://github.com/messense/aliyundrive-webdav

metaboss: Solana Metaplex NFT“瑞士军刀”工具

这是一个年轻生态系统的实验性软件。使用风险自负。作者不对软件的滥用或在用于生产 NFT 之前未能测试特定命令负责。

https://github.com/samuelvanderwaal/metaboss

nearcore: NEAR 协议的参考客户端

NEAR的组件之一是 NEAR 协议,这是一种用于无服务器应用程序和由区块链提供支持的智能合约的基础设施。NEAR 协议旨在以以太坊等区块链收费的一小部分价格提供 Firebase 等现代 PaaS 的可用性和可扩展性。

https://github.com/near/nearcore

mullvadvpn-app: 适用于桌面和移动设备的 Mullvad VPN 客户端应用程序

此存储库包含该应用程序的桌面和移动版本的所有源代码。对于桌面,这包括系统服务/守护程序 ( mullvad-daemon)、图形用户界面 ( GUI ) 和命令行界面 ( CLI )。Android 应用程序对隧道和安全使用相同的支持系统服务,但在android/ 中有一个专用前端。iOS 由一个完全独立的实现组成,它驻留在ios/ 中。

hyperfine: 命令行基准测试工具

https://github.com/sharkdp/hyperfine

lighthouse : Ethereum 2.0 客户端

用 Rust 编写并由 Sigma Prime 维护

Just: 是保存和运行项目的特定命令的工具

命令存储在一个名为justfile,语法启发自make:

build:
    cc *.c -o main

# test everything
test-all: build
    ./test --all

# run a specific test
test TEST: build
    ./test --test {{TEST}}

然后可以使用just 运行它们:

$ just test-all
cc *.c -o main
./test --all
Yay, all your tests passed!

https://github.com/casey/just

推荐项目 | 基础工具库

聚焦 Rust 生态库


SwapQueue: 一个高度优化和轻量级的crossbeam::deque替代品

一个无锁线程拥有的队列,其中任务由线程安全窃取者通过缓冲区交换而不是任务弹出来完成。

只为通过缓冲区交换来获取整个队列这一单一的使用情况而设计。通过放弃弹出单个任务的能力,可以做出一个更适合于完全批处理的数据结构。

https://github.com/Bajix/swap-queue-rs

Goose:Rust 实现的负载测试框架

Goose是受Locust启发的Rust负载测试工具。用户行为是用标准的 Rust 代码定义的。负载测试是依赖于Goose 库的应用程序。Web 请求是使用Reqwest HTTP 客户端发出的。

Goose 每 CPU 核产生的流量至少是 Locust 的 11 倍,对于更复杂的负载测试(例如使用第三方库抓取表单内容的负载测试),收益甚至更大。

Flowistry:强大的 Rust IDE 工具

Flowistry 是一个 VSCode 扩展,可帮助您理解 Rust 程序。Flowistry 使用数据流分析和指针分析在比类型所能提供的更深层次上分析 Rust 程序(参见 rust-analyzer)。

Flowistry 是alpha软件(请参阅限制),正在寻找早期采用者来尝试并提供反馈!

https://github.com/willcrichton/flowistry

doku: 0.10 发布

Doku是一个框架,可以直接从代码中构建美观的、人类可读的文档;它允许您轻松地为配置文件、HTTP端点等生成文档。

告别陈旧的手写文档——使用Doku,代码就是文档!。

https://github.com/anixe/doku

Mimic: 一款用Rust语言编写的命令行Gameboy模拟器

Mimic是一款用Rust编写的开源Gameboy模拟器,可以使用命令行界面作为屏幕和输入设备。该项目试图为Gameboy制作一个易于使用和学习的模拟器,该模拟器可用于解释模拟一个系统所需的概念,而不会让读者感到不知所措。模拟器的核心逻辑完全使用safe rust 编写,没有JIT编译器,屏幕/IO逻辑与模拟器核心功能保持分离以降低复杂性。因此,它的性能并不理想,但Gameboy是一个旧系统,因此理想的性能对于全速运行游戏来说不是必要的。

Boa发布v0.13

Boa是一个用Rust语言编写的实验性Javascript词法分析器、解析器和编译器。它可以相当容易地嵌入到Rust项目中,也可以从命令行使用。Boa的存在也是为了作为EcmaScript规范的Rust实现,我们可以在某些领域利用Rust及其奇妙的生态系统来制造一个快速、并行和安全的引擎。

Boa由Jason Williams在2019年JSConf欧盟大会上首次介绍,目前官方已经实现了该语言的一部分特性。在这个版本中,Boa与ECMAScript标准的一致性已经增长到官方ECMAScript测试套件(Test262)的41.97%;已经解决了40个问题,并且合并了105个请求。此版本带来了一些新特性,例如支持从JavaScript调用Rust闭包,以提高JS和Rust之间的更好互操作性。

https://github.com/boa-dev/boa

Hurl 命令行工具,简单纯文本格式定义HTTP请求

Hurl是一个 HTTP 客户端,它执行以简单纯文本格式定义的 HTTP 请求。

Hurl 非常好用,它可以发送 HTTP 请求,从 HTTP 响应中提取数据。

$ hurl session.hurl

如果未指定输入文件,则从标准输入读取输入。

$ echo GET http://httpbin.org/get | hurl
    {
      "args": {},
      "headers": {
        "Accept": "*/*",
        "Accept-Encoding": "gzip",
        "Content-Length": "0",
        "Host": "httpbin.org",
        "User-Agent": "hurl/0.99.10",
        "X-Amzn-Trace-Id": "Root=1-5eedf4c7-520814d64e2f9249ea44e0"
      },
      "origin": "1.2.3.4",
      "url": "http://httpbin.org/get"
    }

默认输出到标准输出。要输出到文件,请使用 -o 选项:

$ hurl -o output input.hurl

默认情况下,Hurl 执行所有 HTTP 请求并输出最后一次 HTTP 调用的响应体。

https://hurl.dev/docs/man-page.html

Bronze: Rust 垃圾回收器

Rust 没有使用垃圾回收器,而是借助精密、复杂的类型系统,这样做使得 Rust 很高效,但相对难以学习和使用。本文作者们为 Rust 设计了一个可选的基于库的垃圾回收器。为了验证效果,文章对来自 633 人班级的志愿者进行了一项随机对照试验,总共收集了 428 名学生的数据。结果发现,对于需要管理复杂别名的任务,使用垃圾回收器的用户更有可能在可用时间内完成任务,而完成任务的用户只需要大约三分之一的时间(4 小时与 12 小时)。

将来希望扩展 Bronze 跟踪器以跟踪可能传递地包含对 GC 对象引用的任意对象。还希望调查使用 GC 的影响,不仅是针对复杂的别名场景,而且是为了减轻总体所有权的影响;也许这样做可以使学习曲线变平,并且帮助用户更积极地使用 Rust。

很有意思的想法,详细可阅读下方论文。

作者主页:Michael Coblenz

Paper:Does the Bronze Garbage Collector Make Rust Easier to Use? A Controlled Experiment

GitHub:mcoblenz/Bronze

abi_stable: 动态加载

Rust-to-Rust ffi,标准类型的 ffi-safe 等价物,并创s建在启动时加载的库。对于 Rust-to-Rust ffi,重点是创建在程序启动时加载的库,以及加载时类型检查。这个库允许定义可以在运行时加载的 Rust 库,即使它们是使用与依赖它的 crate 不同的 Rust 版本构建的。

相关文章:Plugins in Rust: Diving into Dynamic Loading | NullDeref

GitHub:rodrimati1992/abi_stable_crates: Rust-to-Rust ffi,ffi-safe equivalents of std types,and creating libraries loaded at startup.

rust-ci-release-template: Rust 二进制 CI 发布模板

每次开发时自动执行:

  • 带缓存的 CI:检查、测试、代码风格

推送新标签时自动执行:

  • 构建多平台二进制文件
  • 创建 GitHub 版本
  • 更新 Homebrew

SpectralOps/rust-ci-release-template: A Github Actions based CI release template for Rust binaries

crabz: 跨平台、快速压缩和解压缩工具

支持以下格式:

  • Gzip
  • Zlib
  • Mgzip
  • BGZF
  • Raw Deflate
  • Snap

sstadick/crabz: Like pigz, but rust

Dart/Flutter <–> Rust binding 开源了

想要结合Flutter 和 Rust 之间的优点吗?这里来了!

特性:

  • 内存安全
  • 类型支持
  • 零拷贝
  • 异步编程
  • 易于使用
  • 轻量
  • 易于代码评审
  • Prue-Dart 兼容

https://github.com/fzyzcjy/flutter_rust_bridge

redact: 构建去中心化的、端到端加密的网站的工具

值得一提的是,这个项目并没有用到区块链,但是其号称实现了零信任 zero-trust,相关领域的同学可以关注一下。

它怎样工作的:https://redact.ws/how-it-works

仓库:https://github.com/pauwels-labs/redact-client

lnx: 一个基于 tantivy 的搜索引擎

它是 MeiliSearch 和 ElasticSearch 的竞品。其基于 tokio-rs,hyper 和 tantivy 进行开发。提供 REST 接口。现已发布 v0.6 版。持续关注。

https://github.com/lnx-search/lnx

rs-merkle:一个用Rust编写的高级散列树库

rs-merkle是一个高级的Rust merkle树库。基本功能包括构建Merkle树、创建和验证单个和多个元素的Merkle证明,即多重证明。高级功能包括对树进行事务性更改,并回滚到以前提交的任何树状态,类似于Git。 该库有两个主要结构。第一个是MerkleTree,它构建了一棵树,可用于验证数据完整性并生成Merkle证明。第二种是MerkleProof,可用于验证集合中是否包含项目。 这个库是高度可定制的。哈希算法和树的构建方式可以通过Hasher trait进行配置。

关于Merkle树

Merkle树,也称为散列树,用于验证两个或多个参与方是否拥有相同的数据,而无需交换整个数据集合。 Merkle树被用于Git、Mercurial、ZFS、IPFS、比特币、以太坊、Cassandra等许多领域。例如,在Git中,Merkle树用于查找本地和远程存储库状态之间的增量,以便通过网络仅传输它们之间的差异。在比特币中,Merkle树用于验证交易是否包含在区块中,而无需下载整个区块内容。ZFS使用Merkle树快速验证数据完整性,提供保护,防止幻象写入、磁盘固件中的错误、电源浪涌和其他原因导致的静默数据损坏。

https://github.com/antouhou/rs-merkle

Rust 的动态类型 dyn_struct

这个库可以安全地初始化动态大小类型 (DST)。

#[repr(C)]
#[derive(DynStruct)]
struct MyDynamicType {
    pub awesome: bool,
    pub number: u32,
    pub dynamic: [u32],
}

// the `new` function is generated by the `DynStruct` macro.
let foo: Box<MyDynamicType> = MyDynamicType::new(true, 123, [4, 5, 6, 7]);
assert_eq!(foo.awesome, true);
assert_eq!(foo.number, 123);
assert_eq!(&foo.dynamic, &[4, 5, 6, 7]);

https://github.com/nolanderc/dyn_struct

elasticsearch-dsl-rs - 用 Rust 写就的 Elasticsearch DSL

elasticsearch-dsl-rs 是 Elasticsearch DSL 的 Rust 实现,特性如下:

  • 强类型查询
  • 强类型聚合
  • 自动跳过空查询
  • 不依赖 elasticsearch-rs,可以作为独立库来方便 HTTP 客户端调用 ElasticSearch

https://github.com/vinted/elasticsearch-dsl-rs

ROAPI:静态资源服务器

ROAPI是一个 API 服务器,用户无需编写任何代码即可公开 CSV、JSON 和 Parquet 文件。ROAPI 由 4K 行 Rust 组成。

https://tech.marksblogg.com/roapi-rust-data-api.html

Infinitree 嵌入式数据库

具有 3 层缓存的可扩展,加密嵌入式数据库

https://github.com/symmetree-labs/infinitree

Rust Web Local Storage API

这里有一个 Rust 实现的 Web LocalStorage API,用于非浏览器环境。

https://github.com/richardanaya/web-local-storage-api

autograph:Rust的机器学习库

这是在SPIR-V compute shaders上重建的autograph的第一个版本,可以使用rust-gpu从Rust源代码编译!

要在 crate 中使用autograph,请将其添加为 Cargo.toml 中的依赖项:

[dependencies]
autograph = { git = https://github.com/charles-r-earp/autograph }

Cooptex -无死锁 Mutexes

这应该是可用的crate版本,旨在提供不会死锁的互斥锁。这个crate使用wait-die scheme方案来实现这一点。

如果Mutex::lock调用可能会死锁,它将返回一个Err(Retry),请求调用方删除所有持有的锁并再次尝试获取它们。这由retry_循环函数处理。

https://crates.io/crates/cooptex

https://www.reddit.com/r/rust/comments/qis8zy/cooptex_deadlockfree_mutexes/

Rusterizer:用Rust编写的简单3D渲染器

该项目实现了一个基本的OpenGL渲染pipeline。没有使用依赖项,从零开始就完全使用Rust。你可以在这里在线试用

https://github.com/dzharvis/rusterizer#readme

https://www.reddit.com/r/rust/comments/qixyuw/simple_3d_renderer_written_in_rust/

【系列】用 Rust 实现文本编辑器

分为 7 个部分:从设置 到读取按键和进入“原始模式”在屏幕上绘图和移动光标显示文本文件(使我们的程序成为文本视图)编辑文本文件和保存更改实现一个很酷的搜索功能,最后添加语法突出显示

https://medium.com/@otukof/build-your-text-editor-with-rust-678a463f968b

Rust 开源气象站

weather-station,运行和监控自己的开源气象站所需的一切。

气象站将测量值发送到 API 服务器进行收集,然后 API 服务器将这些数据提供给用户加载到其计算机或移动设备上的 Web 应用程序 (PWA)。

https://github.com/codi-hacks/weather-station

Rust生态安全漏洞总结系列 | Part 4

本系列主要是分析RustSecurity 安全数据库库中记录的Rust生态社区中发现的安全问题,从中总结一些教训,学习Rust安全编程的经验。

本期主要是侧重于 Chrono 里面的一个情况比较复杂的漏洞分析。

TL;DR : 请使用 time 代替 Chrono


问题

本月在 Reddit Rust 频道有人发布了一个帖子,在询问 Chrono 库是否还有人在维护。之所以问出这个问题,是该贴作者看到 Rust 安全依赖报告中显式 Chrono 有一个安全问题两年了还没有被修复,所以他不知道是怎么回事。

Chrono 遇到的安全问题在 rustsec.org : RUSTSEC-2020-0159 有描述, 大概内容是:

  1. Chrono 调用 libclocaltime_r,用于将时间转换为本地时间。
  2. libc 实现库中大多数都是直接调用 getenv
  3. setenvgetenvlibc 中都不是线程安全的
  4. 为了确保 setenv 的健全性,Rust 的 libstd 为其添加了一个锁
  5. 这意味着在非 libstd下使用 getenv 将是不健全的。

这里面也涉及 Rust 中 std::env 的相关安全问题。 目前当你在并发环境下使用 setenv会存在数据竞争的问题,建议使用 互斥锁来处理,但只能防止 std::env建议不要使用 std::env::set_var。对此问题更详细的总结参见 [同步 FFI 访问 POSIX 环境变量函数](https://internals.rust-lang.org/t/synchronized-ffi-access-to-posix-environment-variable-functions/15475)

但是,直接调用 libcsetenvgetenv 是线程不安全的。

发现这个漏洞的是 tokio-rs/io-uring 作者 quininer。

解决方案:使用 time

Chrono 的作者现身回复:

  1. 出于个人原因,目前已经离开了该项目,但该项目目前有人维护。
  2. 这个安全漏洞目前有一些限制,所以无法修复。
    1. Chrono 必须支持系统本地时区(Local::now())等。
    2. 该系统本地时区必须与 C 的 localtime* 返回的内容相匹配。

避免该漏洞有一个解决办法就是用 time 0.3 代替 chrono

最近几天 chrono 也发布了一个公告: no time for chrono ,主要内容是:

  1. chrono 用户可以切换到 time 0.3
  2. localtime_r 相当复杂,处理时区被认为是所有开发者的噩梦。
  3. time 0.3 通过移除对 localtime_r 的调用来缓解此问题。
  4. Rich Felkermusl的作者)有另一种观点。他认为,问题不在于调用 localtime_r 函数,而在于修改环境。环境应该是不可改变的。

Time 的 0.3 版本添加了许多 API,它们涵盖了 Chrono 的诸多 API:

  • 无分配模式
  • Month类型
  • 日历/序数/ISO/儒略转换
  • 大日期(超过 +/- 9999 年)
  • 解析和 serde 支持

还有一些功能仅由较新的 Time 支持, Chrono 则没有:

  • const 函数
  • datetime!在编译时构造日期时间的宏
  • 序列化non-ISO8601 表示
  • 随机日期/时间
  • 快速检查支持

因此,您现在可以合理地将 Chrono 替换为 Time

相关链接

https://www.reddit.com/r/rust/comments/qamgyh/is_the_chrono_crate_unmaintained/

https://rustsec.org/advisories/RUSTSEC-2020-0159.html

https://github.com/chronotope/chrono/issues/499

https://passcod.name/technical/no-time-for-chrono.html

十一月刊(November )

发刊通告

Rust 资讯

元宇宙与区块链

外刊评论

Rust 与 学术

Rust 生产实践

学习园地

新手入门

原理与工程实践

开源项目巡礼

Rust 与 安全

发刊通告

编辑:张汉东

十一月发刊通告

《 RustMagazine 中文精选 》2021 年第十一期发布了!

时光荏苒,当你看到这一期内容的时候,意味着 2021 年已经进入尾声!你今年的目标达成了吗?

本刊动态

在去年RustChinaConf大会上我萌生想法,Rust Magazine 中文社区月刊开始启动,迄今为止也坚持下来每月一期,虽然也得到了一些公司和社区成员的投稿支持,但感觉效果并不符合我的预期,打算做完今年两期就停刊了。

月刊访问量近两万,GitHub star 数近 600,不缺读者,关键是缺少投稿的人。我搞这个是希望国内社区和公司把自己研究、使用、学习rust的心得经验、生产经验分享出来。但投稿并不多,不符合我的预期。虽然还是可以继续搞,但对我而言这不是我期望的就没必要搞了。

如果后续投稿量还上不来的话,也许本刊会变为内部期刊。

上期(十月刊)访问数据统计小结

用户数

  • 新用户新增 2,993

浏览量:

  • 网页浏览量 : 19,511

十月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

九月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

八月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

七月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

六月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

聚焦 Rust 官方活动与新闻


Rust 1.56.1 版本发布

Rust1.56.1引入了两个新的lints,以减轻最近披露的一个安全问题的影响,CVE-2021-42574。我们建议所有用户立即升级,以确保其代码库不受安全问题的影响。

如果已经安装过 Rust,使用如下命令升级:

rustup update stable

本月新加入 Rust 基金会的公司

涉及领域: 游戏、芯片、汽车、AI 安全

Rust 基金会正在推动 Rust 在各个领域广泛应用

https://foundation.rust-lang.org/members/

Rust 基金会任命 Rebecca Rumbul 为执行董事兼首席执行官

2021 年 11 月 17 日,Rust 基金会宣布任命Rebecca Rumbul为该组织的执行董事兼首席执行官。

Rumbul 来到 Rust 基金会,她在国际非营利管理方面拥有深厚的专业知识,并且是数字民主和信息权利的领先全球倡导者。Rumbul 最近担任 mySociety 的研究和参与总监,在那里她致力于为世界各地的政府、非政府组织和商业企业的治理和议会系统带来透明度。

Rumbul 拥有在执行和学术委员会工作的广泛背景,曾担任广告标准局的理事会成员、Hansard Society 的受托人以及 Privacy Collective 的英国代表索赔人。她拥有开放大学的政治、公共行政和项目治理博士学位、公共行政硕士学位和人文科学学士学位。

“Rust 社区正在使用 Rust 编程语言做重要的、鼓舞人心的工作,我很高兴能成为对世界产生重大影响的合作的一部分,”Rumbul 说。“Rust 的核心功能使其具有全球影响力,包括支持安全和可持续性计划的进步。很荣幸成为其中的一员,我期待加入这个变革性的社区。”

“Rebecca 在非营利组织和数字治理方面带来了丰富的领导经验,”执行董事搜索委员会主席、Rust 基金会董事会成员和 Mozilla 杰出工程师 Bobby Holley 说。“她是一位聪明、善于协作的领导者,拥有建立 Rust 基金会的远见和勇气,并产生我们在这里要产生的影响。”

除 Holley 外,执行董事搜寻委员会还包括红帽项目总监兼首席软件工程师 Shane Miller、Josh Stone、核心团队项目总监兼 Rust 基金会秘书 Mark Rousskov 以及项目总监兼高级软件工程师 Tyler Mandry谷歌工程师。

https://foundation.rust-lang.org/news/2021-11-17-news-announcing-rebecca-rumbul-executive-director-ceo/

Rustc 变得更快了!

作者在 Mozilla 从事 Rust 的兼职工作数年,那段时间里,作者定期总结编译器的性能是如何改进的。 例如:2017-11-12 至 2019-07-24。进行的最后一次比较是在 2020 年 8 月,从 2020 年末开始暂停 Rust 工作,直到本周我成为 Futurewei Technologies 的 Rust 团队的全职成员,作者很想知道那段时间的性能是如何提高的,好消息!

从 2020-08-05 到 2021-11-11,编译器基准套件的结果有 459 项改进,只有 18 项回归,如下面(很长)的屏幕截图所示,因为截图过长印象阅读体验,请移步原文查看。

std::simd nightly 可用

可以移植的 SIMD 模块.

该模块提供了一个可移植的不绑定于任何硬件架构的 SIMD 操作的跨平台抽象。目前 nightly-only.

https://doc.rust-lang.org/nightly/std/simd/index.html

内联汇编语法快稳定了

跟踪贴在这里:https://github.com/rust-lang/rust/issues/72016#issuecomment-964186287

Rust Mod 团队辞职以抗议“不负责任”的核心团队

Rust Mod 团队,是 Rust 审核团队(moderation team)。辞职是通过GitHub上的 PR 请求提出的,其中团队成员Andrew Gallant写道,该团队辞职 "是为了抗议核心团队将自己置于不对任何人负责的地位,除了他们自己。"

根据描述Rust治理的页面,审核团队的目的就是帮助 "维护行为准则和社区标准"。而根据辞职信,他们无法做到这一点,核心团队似乎在这些范围之外。

这与今年早些时候Steve Klabnik对Rust基金会寻找执行董事和亚马逊对Rust基金会的影响无关:核心团队是一个独立于Rust基金会的组织,Rust基金会后来选择了执行董事,而核心团队和审核团队中似乎没有人与亚马逊有任何关系。"

这对 Rust 语言发展是否有影响?目前为止还没有产生什么实质影响。这件事的本质是大型开源组织治理中必会遇到的问题之一。作为 Rust 开发者,没必要大惊小怪。

https://thenewstack.io/rust-mod-team-resigns-in-protest-of-unaccountable-core-team/

社区热点

聚焦 Rust 生态热点新闻


JetBrains 下一代 IDE Fleet 发布早期预览版

首批支持语言中包括了 Rust。并且 Fleet 使用了 skiko 写界面,用了Kotlin和一点Rust 。

https://www.jetbrains.com/fleet/

Rust for linux: 编写安全的抽象和驱动

Rust有一个关键属性,使其成为内核中的第二语言变得非常有趣:它保证不会发生未定义的行为(只要不安全的代码是合理的)。这包括没有 内存释放后内存使用, double free, 数据竞争等等。

https://linuxfoundation.org/webinars/rust-for-linux-writing-abstractions-and-drivers/

lib.rs: 一个 crates.io的替代品

lib.rs 索引了 Rust的 72,419 个应用。快速,轻量级,可以作为 crates.io 的一个替代品.

lib.rs

Poem-openapi 1.0 发布

Poem OpenAPI是一个基于 Poem 的 OpenAPI 服务器端框架。如果你用过FastAPI,它们在使用上是非常相似的。

1.0版简化了一些宏属性,使用起来更加方便。

use poem::{listener::TcpListener, Route, Server};
use poem_openapi::{param::Query, payload::PlainText, OpenApi, OpenApiService};

struct Api;

#[OpenApi]
impl Api {
  #[oai(path = "/hello", method = "get")]
  async fn index(
    &self,
    #[oai(validator(max_length = 32))] name: Query<Option<String>>,
  ) -> PlainText<String> {
    match name.0 {
      Some(name) => PlainText(format!("hello, {}!", name)),
      None => PlainText("hello!".to_string()),
    }
  }
}

#[tokio::main]
async fn main() -> Result<(), std::io::Error> {
  let api_service = OpenApiService::new(Api, "Hello World", "1.0")
      .server("http://localhost:3000/api");
  let ui = api_service.swagger_ui();

  Server::new(TcpListener::bind("127.0.0.1:3000"))
    .run(Route::new().nest("/api", api_service).nest("/", ui))
    .await
}

https://crates.io/crates/poem-openapi

w806-pac项目正在招募贡献者

w806是北京联盛德推出的一款IoT嵌入式MCU。嵌入式rust对它的支持项目正在开发中,其中pac库的svd文件编写工作需要更多的贡献者参与。主要工作是,阅读官方文档,把寄存器信息整理到svd文件中,以供后续嵌入式rust使用。 欢迎大家关注项目!如果您愿意为w806-pac项目做出贡献,请打开项目主页的issues,来查看详细的信息。感谢各位对项目的支持!

【投票】为Rust标准库添加控制台输入API

Simple Console Input API for Standard Library

StrawPoll.com

我们正试图将一个简单的控制台输入API推送到标准库中,以使编写简单的命令行输入变得更容易,我们需要社区决定实现的高级程度。因为这是一个相当有争议的话题(双方的数量非常均匀),所以这次投票就是为了解决这个问题。

注意:下面的例子不是最终的,它只是一个用来阐述这个概念的例子。

简单输入的例子:


#![allow(unused)]
fn main() {
let age: i32 = std::inputln!()?.parse().expect("Invalid age!");
}

高级输入的例子:


#![allow(unused)]
fn main() {
let person = scan!("{} {}", String, u8).expect("Invalid input!");
}

或者


#![allow(unused)]
fn main() {
let name: String;
let age: u8;

scan!("{} {}", name, age).expect("Invalid input!");
}

「投票」

  • A. 简单输入系统:一个函数,读取一行、执行错误检查并返回一个字符串;
  • B. 高级输入系统:一个或多个宏,提供格式化输入,用于读取多个值和各种类型;
  • C. 二者都选择:在需要时提供简单的输入宏和高级宏;
  • D. 这些都不是:别的东西。

如果想参与投票,请访问原文投票。

RustSBI-Nezha项目已经可以在oreboot引导链中使用

oreboot是类似于coreboot的引导程序项目,而RustSBI是适用于RISC-V的引导程序环境,这两者都是纯粹由rust编写的嵌入式应用程序。

经过社区成员@OrangeCMS和贡献者们的进一步适配,RustSBI-Nezha现在能在oreboot引导链中能正常运行。图片的控制台输出显示,引导链在初始化DDR内存后,能够做到M态的陷入处理,随后进入下一步的系统启动过程。这项适配工作意味着,oreboot和RustSBI-Nezha提供了一种启动Linux系统可用的新引导途径。

RustSBI-Nezha项目是湖南农业大学的杨云枫同学、天津科技大学的王涛同学和队友们在暑假的“2021年开源操作系统夏令营”活动中发起的开源项目。

项目分支地址: https://github.com/orangecms/rustsbi-nezha/tree/rustsbi-nezha

推文链接:https://twitter.com/OrangeCMS/status/1462197961606246403?t=_n8beWS2OFhygZ9CWdrwiA&s=19

当标准输出写入失败时,Clap 的默认设置会导致 Rust CLI 在 --help 上出现恐慌


#![allow(unused)]
fn main() {
nu -h | false
fd -h | false
hyperfine -h | false
}

以上这些工具会出现 panic。

Artichoke (Rust 实现的 Ruby) 是如何避免这个错误的: https://github.com/artichoke/artichoke/blob/d527412f9438aeba4cadb1f4303237f6f9e0cd4d/src/bin/artichoke.rs#L138-L173

https://www.reddit.com/r/rust/comments/r48hem/claps_defaults_cause_rust_clis_to_panic_on_help/

Hubris : 新的嵌入式操作系统

Hubris 是由Oxide Computer Company开发的用于微控制器的操作系统。我们还在Oxide 博客上发布一篇宣布Hubris的博客文章。

Hubris 提供抢占式多任务处理、单独编译的组件之间的内存隔离、隔离崩溃的驱动程序并在不影响系统其余部分的情况下重新启动它们的能力,以及灵活的组件间消息传递,消除了对大多数系统调用的需要——大约 2000 行 Rust . Hubris 调试器 Humility 允许我们走近正在运行的系统并检查所有任务的交互,或捕获转储以进行离线调试。

然而,Hubris 可能更有趣,因为它没有运行时创建或销毁任务的操作,没有动态资源分配,没有以特权模式运行的驱动程序代码,系统中也没有C代码。这通过构造消除了许多通常存在于类似系统中的攻击面。

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust唠嗑室》第37期 - 闲聊Rust在OS领域和嵌入式领域的一些发力

时间: 2021/11/09 20:30-21:30

主讲人:Mike

题目:闲聊Rust在OS领域和嵌入式领域的一些发力

内容

最近有一些新的大新闻,比如,Arm和Toyota加入了Rust基金会,System76要搞自己的使用Rust重写的Linux发行版Desktip Environment.

这些举动,是否意味着Rust即将在OS,嵌入式领域,正式暴露在聚光灯下?

欢迎大家特别是相关行业的大佬和菜鸟们一起来来闲聊闲聊。

参考资料:

  1. https://github.com/rtic-rs/cortex-m-rtic
  2. https://github.com/phil-opp/blog_os
  3. https://www.theregister.com/2021/11/08/system76_developing_new_linux_desktop/
  4. https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzI1MjAzNDI1MA==&action=getalbum&album_id=1319566712852873217&scene=173&subscene=10000&sessionid=0&enterid=1631026232&from_msgid=2648212753&from_itemidx=1&count=3&nolastread=1
  5. https://blog.auxon.io/2020/10/26/fixed-slice-vec/

【回放】


《Rust唠嗑室》第38期 - Rust For Linux 简介

时间: 2021/11/23 20:30-21:30

主讲人:吴章金

题目:Rust For Linux 简介

内容:简单介绍 Rust 在 Linux 内核开发领域的历史、现状和简单用法?

参考资料:

  1. https://static.linaro.org/connect/lvc21f/presentations/LVC21F-317.pdf
  2. https://lwn.net/Articles/863459/
  3. https://ldpreload.com/p/kernel-modules-in-rust-lssna2019.pdf
  4. https://zhuanlan.zhihu.com/p/298648575
  5. https://gitee.com/tinylab/cloud-lab/issues/I3T3QB
  6. http://tinylab.org/linux-lab-v08-rc3/

【回放】


🔥🔥🔥🔥 Rust 源码阅读活动 🔥🔥🔥🔥

【线上】Rust 源码阅读活动

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第一期

分享者:张汉东

时间:2021.11.07 14:00 ~ 15:30

【讨论主题】

  1. 名称解析

【相关代码】

  1. https://github.com/rust-lang/rust/tree/master/compiler/rustc_resolve/src

【参与前准备】

  1. 阅读 《the-programmers-brain》 这本书
  2. 阅读 Rustc dev guide ,了解下 Rust 编译过程
  3. 阅读 compiler/rustc_resolve/src 文档相关,了解该库

【回放】

第二期

时间:2021.11.14 14:00 ~ 17:30

【讨论主题】

  1. Tokio 源码阅读活动

分享者:字节跳动工程师 刘翼飞

时间:14: 00 ~ 15:30

  1. Rust 源码阅读活动

分享者:detenlord 联合创始人 施继成

时间:16:00 ~ 17:30

【相关资料】

  1. https://egghead.io/lessons/rust-type-inference-in-rust

【回放】

本月招聘


海致星图 Rust 工程师

欢迎找我内推

分布式图数据库开发工程师岗位角色描述

  • 岗位1: 参与分布式图数据库查询引擎的架构设计,负责分布式查询引擎的迭代开发。
  • 岗位2: 负责分布式查询引擎优化器的研发工作。

我们需要什么样的人才

  1. 有很强的编程能力,至少能熟练运用Rust、C++、C中的一种;
  2. 熟悉Linux系统,熟悉并行和分布式系统下的编程和性能优化。

我们眼中的加分项

  1. 熟悉Rust编程语言;
  2. 有数据库内核开发经验,如MySQL、HBase、TiDB等,有图数据库内核开发经验,如Neo4j、ArangoDB、TigerGraph等;
  3. 熟悉上述数据库、图数据库,熟悉数据库语言理论,熟悉数据库查询引擎,熟悉数据库查询引擎优化器;
  4. 熟悉图相关算法,如弱联通分量、最短路、PageRank、子图匹配等;
  5. 对计算机体系结构有较深入的了解。

来我们团队能得到什么

  1. 亲手参与一个一流的高性能分布式图数据库设计与开发;
  2. 与优秀的团队成员共事、团队和公司内部定期的技术分享;
  3. 有竞争力的薪酬。

CertiK 招聘 Rust 安全工程师 【可远程】

CertiK 最近刚完成红杉资本领投的 8000w 美元投资,是区块链安全领域的黑马。

我观察到 CertiK 在 Lever.co 上面的 Rust 招聘信息,是可以远程的。并且还特意加粗了一句话:

Don't hesitate to apply even if you don't have a university degree or x amount of years of experience in Rust.

即使您没有大学学位或 X 年的 Rust 经验,也不要犹豫地申请

当然,你至少得有一年的区块链或安全工作经验。

职位信息详情: https://jobs.lever.co/certik/9e246ac9-a2e1-42b4-a224-b04cee992b1d

RFC 导读 | 构建安全的 I/O

动机

最近Rust官方合并了一个 RFC ,通过引入I/O安全的概念和一套新的类型和特征,为AsRawFd和相关特质的用户提供关于其原始资源句柄的保证,从而弥补Rust中封装边界的漏洞。

Rust 标准库提供了 I/O 安全性,保证程序持有私有的原始句柄(raw handle),其他部分无法访问它。但是 FromRawFd::from_raw_fd 是 Unsafe 的,所以在 Safe Rust中无法做到 File::from_raw(7) 这种事。 在这个文件描述符上面进行 I/O 操作,而这个文件描述符可能被程序的其他部分私自持有。

但是,很多 API 通过接受 原始句柄 来进行 I/O 操作:


#![allow(unused)]
fn main() {
pub fn do_some_io<FD: AsRawFd>(input: &FD) -> io::Result<()> {
    some_syscall(input.as_raw_fd())
}
}

AsRawFd并没有限制as_raw_fd的返回值,所以do_some_io最终可以在任意的RawFd值上进行 I/O 操作。甚至可以写do_some_io(&7),因为RawFd本身实现了AsRawFd。这可能会导致程序访问错误的资源。甚至通过创建在其他部分私有的句柄别名来打破封装边界,导致一些诡异的 远隔作用(Action at a distance)。

远隔作用Action at a distance)是一种程式设计中的反模式,是指程式某一部分的行为会广泛的受到程式其他部分指令的影响,而且要找到影响其他程式的指令很困难,甚至根本无法进行。

在一些特殊的情况下,违反 I/O 安全甚至会导致内存安全。

I/O 安全概念引入

标准库中有一些类型和特质: RawFd(Unix) / RawHandle/RawSocket(Windows) ,它们代表原始的操作系统资源句柄。这些类型本身并不提供任何行为,而只是代表可以传递给底层操作系统API的标识符。

这些原始句柄可以被认为是原始指针,具有类似的危险性。虽然获得一个原始指针是安全的,但是如果一个原始指针不是一个有效的指针,或者如果它超过了它所指向的内存的生命周期,那么解引用原始指针可能会调用未定义的行为。

同样,通过AsRawFd::as_raw_fd和类似的方式获得一个原始句柄是安全的,但是如果它不是一个有效的句柄或者在其资源关闭后使用,使用它来做I/O可能会导致输出损坏、输入数据丢失或泄漏,或者违反封装边界。而在这两种情况下,影响可能是非局部的且影响到程序中其他不相关的部分。对原始指针危险的保护被称为内存安全,所以对原始句柄危险的保护被称为I/O安全

Rust的标准库也有一些高级类型,如FileTcpStream,它们是这些原始句柄的封装器,提供了操作系统API的高级接口。

这些高级类型也实现了Unix-like平台上的FromRawFdWindows上的FromRawHandle/FromRawSocket的特性,这些特性提供了包裹底层(low-level )值以产生上层(high-level)值的函数。这些函数是不安全的,因为它们无法保证I/O安全,类型系统并不限制传入的句柄。


#![allow(unused)]
fn main() {
use std::fs::File;
use std::os::unix::io::FromRawFd;

// Create a file.
let file = File::open("data.txt")?;

// 从任意的整数值构造 file
// 然而这种类型的检查在运行时可能无法识别一个合法存活的资源
// 或者它可能意外地在程序的其他地方被以别名方式封装处理(此处无法判断)
// 这里添加  unsafe 块 是让调用者来避免上述危险
let forged = unsafe { File::from_raw_fd(7) };

// Obtain a copy of `file`'s inner raw handle.
let raw_fd = file.as_raw_fd();

// Close `file`.
drop(file);

// Open some unrelated file.
let another = File::open("another.txt")?;

// 进一步使用 raw_fd ,也就是 file 的内部原始句柄,将超出操作系统与之相关的生命周期
// 这可能会导致它意外地与其他封装好的 file 实例发生别名,比如 another  
// 因此,这里 unsafe 块是让调用者避免上述危险
let dangling = unsafe { File::from_raw_fd(raw_fd) };
}

调用者必须确保传入from_raw_fd的值是明确地从操作系统返回的,而且from_raw_fd的返回值不会超过操作系统与句柄相关的生命周期。

I/O 安全的概念虽然是新的,但它反映出了一个普遍的做法。 Rust 生态系统将会逐步支持 I/O 安全。

I/O 安全 Rust 解决方案

OwnedFdBorrowedFd<'fd>

这两种类型用于替代 RawFd ,对句柄值赋予所有权语义,代表句柄值的 拥有和借用。

OwnedFd 拥有一个 fd ,会在析构的时候关闭它。BorrowedFd<'fd> 中的生命周期参数表示对这个 fd 的访问被借用多长时间。

对于Windows来说,也有类似的类型,但都是HandleSocket形式。

类型类似于
OwnedFdBox<_>
BorrowedFd<'a>&'a _
RawFd*const _

和其他类型相比,I/O 类型并不区分可变和不可变。操作系统资源可以在Rust的控制之外以各种方式共享,所以I/O可以被认为是使用内部可变性。

AsFdInto<OwnedFd>From<OwnedFd>

这三个概念是AsRawFd::as_raw_fdIntoRawFd::into_raw_fdFromRawFd::from_raw_fd的概念性替代,分别适用于大多数使用情况。它们以OwnedFdBorrowedFd的方式工作,所以它们自动执行其I/O安全不变性。


#![allow(unused)]
fn main() {
pub fn do_some_io<FD: AsFd>(input: &FD) -> io::Result<()> {
    some_syscall(input.as_fd())
}
}

使用这个类型,就会避免之前那个问题。由于AsFd只针对那些适当拥有或借用其文件描述符的类型实现,这个版本的do_some_io不必担心被传递假的或悬空的文件描述符。

逐步采用

I/O安全和新的类型和特性不需要立即被采用,可以逐步采用。

  • 首先,std为所有相关的std类型添加新的类型和特质,并提供impls。这是一个向后兼容的变化。
  • 之后,crate可以开始使用新的类型,并为它们自己的类型实现新的特质。这些变化将是很小的,而且是半兼容的,不需要特别的协调。
  • 一旦标准库和足够多的流行crate实现了新的特质,crate就可以按照自己的节奏开始使用新的特质作为接受通用参数时的边界。这些将是与semver不兼容的变化,尽管大多数切换到这些新特质的API的用户不需要任何改变。

原型实现

该 RFC 内容原型已经实现,参见 io-lifetimes

Raw APIThis experimental API
Raw*Borrowed* and Owned*
AsRaw*As*
IntoRaw*Into*
FromRaw*From*

trait 实现

AsFd 转换为 原生 fd ,是带有生命周期参数的 BorrowedFd<'_>


#![allow(unused)]
fn main() {
#[cfg(any(unix, target_os = "wasi"))]
pub trait AsFd {
    /// Borrows the file descriptor.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # #![cfg_attr(io_lifetimes_use_std, feature(io_safety))]
    /// use std::fs::File;
    /// # use std::io;
    /// use io_lifetimes::{AsFd, BorrowedFd};
    ///
    /// let mut f = File::open("foo.txt")?;
    /// let borrowed_fd: BorrowedFd<'_> = f.as_fd();
    /// # Ok::<(), io::Error>(())
    /// ```
    fn as_fd(&self) -> BorrowedFd<'_>;
}

}

IntoFd从 原生 fd 转为 安全的 fd,是 OwnedFd


#![allow(unused)]
fn main() {
#[cfg(any(unix, target_os = "wasi"))]
pub trait IntoFd {
    /// Consumes this object, returning the underlying file descriptor.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # #![cfg_attr(io_lifetimes_use_std, feature(io_safety))]
    /// use std::fs::File;
    /// # use std::io;
    /// use io_lifetimes::{IntoFd, OwnedFd};
    ///
    /// let f = File::open("foo.txt")?;
    /// let owned_fd: OwnedFd = f.into_fd();
    /// # Ok::<(), io::Error>(())
    /// ```
    fn into_fd(self) -> OwnedFd;
}
}

FromFd 从原生 fd 构造 OwnedFd


#![allow(unused)]
fn main() {
#[cfg(any(unix, target_os = "wasi"))]
pub trait FromFd {
    /// Constructs a new instance of `Self` from the given file descriptor.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # #![cfg_attr(io_lifetimes_use_std, feature(io_safety))]
    /// use std::fs::File;
    /// # use std::io;
    /// use io_lifetimes::{FromFd, IntoFd, OwnedFd};
    ///
    /// let f = File::open("foo.txt")?;
    /// let owned_fd: OwnedFd = f.into_fd();
    /// let f = File::from_fd(owned_fd);
    /// # Ok::<(), io::Error>(())
    /// ```
    fn from_fd(owned: OwnedFd) -> Self;

    /// Constructs a new instance of `Self` from the given file descriptor
    /// converted from `into_owned`.
    ///
    /// # Example
    ///
    /// ```rust,no_run
    /// # #![cfg_attr(io_lifetimes_use_std, feature(io_safety))]
    /// use std::fs::File;
    /// # use std::io;
    /// use io_lifetimes::{FromFd, IntoFd};
    ///
    /// let f = File::open("foo.txt")?;
    /// let f = File::from_into_fd(f);
    /// # Ok::<(), io::Error>(())
    /// ```
    #[inline]
    fn from_into_fd<Owned: IntoFd>(into_owned: Owned) -> Self
    where
        Self: Sized,
    {
        Self::from_fd(into_owned.into_fd())
    }
}
}

上述为针对 Unix 平台的 trait,该库也包含 Windows 平台的相关 trait : AsHandle / AsSocket IntoHandle /IntoSocketFromHandle /FromSocket

相关类型

BorrowedFd<'fd>


#![allow(unused)]
fn main() {
#[cfg(any(unix, target_os = "wasi"))]
#[derive(Copy, Clone)]
#[repr(transparent)]
#[cfg_attr(rustc_attrs, rustc_layout_scalar_valid_range_start(0))]
// libstd/os/raw/mod.rs assures me that every libstd-supported platform has a
// 32-bit c_int. Below is -2, in two's complement, but that only works out
// because c_int is 32 bits.
#[cfg_attr(rustc_attrs, rustc_layout_scalar_valid_range_end(0xFF_FF_FF_FE))]
pub struct BorrowedFd<'fd> {
    fd: RawFd,
    _phantom: PhantomData<&'fd OwnedFd>,
}

#[cfg(any(unix, target_os = "wasi"))]
#[repr(transparent)]
#[cfg_attr(rustc_attrs, rustc_layout_scalar_valid_range_start(0))]
// libstd/os/raw/mod.rs assures me that every libstd-supported platform has a
// 32-bit c_int. Below is -2, in two's complement, but that only works out
// because c_int is 32 bits.
#[cfg_attr(rustc_attrs, rustc_layout_scalar_valid_range_end(0xFF_FF_FF_FE))]
pub struct OwnedFd {
    fd: RawFd,
}

#[cfg(any(unix, target_os = "wasi"))]
impl BorrowedFd<'_> {
    /// Return a `BorrowedFd` holding the given raw file descriptor.
    ///
    /// # Safety
    ///
    /// The resource pointed to by `raw` must remain open for the duration of
    /// the returned `BorrowedFd`, and it must not have the value `-1`.
    #[inline]
    pub unsafe fn borrow_raw_fd(fd: RawFd) -> Self {
        debug_assert_ne!(fd, -1_i32 as RawFd);
        Self {
            fd,
            _phantom: PhantomData,
        }
    }
}

#[cfg(any(unix, target_os = "wasi"))]
impl AsRawFd for BorrowedFd<'_> {
    #[inline]
    fn as_raw_fd(&self) -> RawFd {
        self.fd
    }
}

#[cfg(any(unix, target_os = "wasi"))]
impl AsRawFd for OwnedFd {
    #[inline]
    fn as_raw_fd(&self) -> RawFd {
        self.fd
    }
}

#[cfg(any(unix, target_os = "wasi"))]
impl IntoRawFd for OwnedFd {
    #[inline]
    fn into_raw_fd(self) -> RawFd {
        let fd = self.fd;
        forget(self);
        fd
    }
}

#[cfg(any(unix, target_os = "wasi"))]
impl Drop for OwnedFd {
    #[inline]
    fn drop(&mut self) {
        #[cfg(feature = "close")]
        unsafe {
            let _ = libc::close(self.fd as std::os::raw::c_int);
        }

        // If the `close` feature is disabled, we expect users to avoid letting
        // `OwnedFd` instances drop, so that we don't have to call `close`.
        #[cfg(not(feature = "close"))]
        {
            unreachable!("drop called without the \"close\" feature in io-lifetimes");
        }
    }
}


}

为 std 和其他生态库 支持安全 I/O

再构建一些跨平台抽象类型之后,为 ffi / async_std/ fs_err/ mio/ os_pipe/ socket2/ tokio / std 来支持 安全I/O 抽象。

使用案例

// From: https://github.com/sunfishcode/io-lifetimes/blob/main/examples/hello.rs

#[cfg(all(rustc_attrs, unix, feature = "close"))]
fn main() -> io::Result<()> {
    // write 是 c api,所以用 unsafe
    let fd = unsafe {
        // Open a file, which returns an `Option<OwnedFd>`, which we can
        // maybe convert into an `OwnedFile`.
        // 拥有一个 fd
        let fd: OwnedFd = open("/dev/stdout\0".as_ptr() as *const _, O_WRONLY | O_CLOEXEC)
            .ok_or_else(io::Error::last_os_error)?;

        // Borrow the fd to write to it.
        // 借用这个 fd 
        let result = write(fd.as_fd(), "hello, world\n".as_ptr() as *const _, 13);
        match result {
            -1 => return Err(io::Error::last_os_error()),
            13 => (),
            _ => return Err(io::Error::new(io::ErrorKind::Other, "short write")),
        }

        fd
    };

    // Convert into a `File`. No `unsafe` here!
    // 这里不再需要 Unsafe 了
    let mut file = File::from_fd(fd);
    writeln!(&mut file, "greetings, y'all")?;

    // We can borrow a `BorrowedFd` from a `File`.
    unsafe {
        // 借用 fd
        let result = write(file.as_fd(), "sup?\n".as_ptr() as *const _, 5);
        match result {
            -1 => return Err(io::Error::last_os_error()),
            5 => (),
            _ => return Err(io::Error::new(io::ErrorKind::Other, "short write")),
        }
    }

    // Now back to `OwnedFd`.
    let fd = file.into_fd();

    // 不是必须的,会自动析构 fd 
    unsafe {
        // This isn't needed, since `fd` is owned and would close itself on
        // drop automatically, but it makes a nice demo of passing an `OwnedFd`
        // into an FFI call.
        close(fd);
    }

    Ok(())
}

理由与替代方案

关于 “unsafe 是为了内存安全” 的说法

Rust 在历史上划定了一条界线,指出 unsafe 仅仅是用于 内存安全相关。 比较知名的例子是 std::mem::forget, 它增加是 unsafe 的,后来改为了 safe。

声明 unsafe 只用于内存安全的结论表明,unsafe 不应该用于 其他非内存安全类的 API ,比如 标示某个 API 是应该避免使用的之类。

内存安全优先级高于其他缺陷,因为它不仅仅是为了避免非预期行为,而是为了避免无法约束一段代码可能做的事情的情况。

I/O 安全也是属于这类情况,理由有二:

  1. I/O安全错误会导致内存安全错误,在mmap周围的安全包装器存在的情况下(在具有操作系统特定API的平台上,允许它们是安全的)。
  2. I/O安全错误也意味着一段代码可以读取、写入或删除程序中其他部分使用的数据,而不需要命名它们或给它们一个引用。如果不知道链接到程序中的所有其他crate的实现细节,就不可能约束一个crate可以做的事情的集合。

原始句柄很像进入独立地址空间的原始指针;它们可以悬空或以虚假的方式进行计算。I/O安全与内存安全类似;两者都是为了防止诡异的远隔作用,而且在两者中,所有权是健壮抽象的主要基础,所以使用类似的安全概念是很自然的。

相关

PL 观点 | 未定义行为也有好的一面

是亦彼也,彼亦是也,彼亦一是非,此亦一是非。——《庄子•内篇》

如果你把所有的错误都关在门外时,真理也要被关在门外面了——泰戈尔

引子

Rust 官方团队 Ralf Jung 在 PL 观点 (PL Perspectives) 博客 上发表了一篇文章 《Undefined Behavior deserves a better reputation》 ,文中对 UB(未定义行为)有利的一面进行了详细的阐述。通过这篇文章,我们可以对 UB 有更深入的理解。

PLAI是计算机科学的两大学科分支。其中 PL (Programming Languages) 可以理解为是概括“程序设计语言自理论体系到其实现系统”的一个总称。

PL PerspectivesACM SIGPLAN(编程语言特别兴趣小组)的博客。SIGPLAN 的成员对编程语言概念和工具感兴趣,重点关注 PL 设计、实现、实践和理论的主题,或者 PL 思想和技术在其他领域的应用。

Ralf Jung 工作于马克斯普朗克软件系统研究所,也是Rust 官方团队成员之一,他研究 Rust 安全性 形式化验证系统 RustBelt 的论文获得了 2020 年 ACM 博士论文荣誉提名奖。

本文是我学习这篇文章的笔记,内容是围绕 UB以及 Ralf 的文章进行的二次创作,仅供参考。

什么是未定义行为

在计算机程序设计中,未定义行为(英语:undefined behavior)是指执行某种计算机代码所产生的结果,这种代码在当前程序状态下的行为在其所使用的语言标准中没有规定。常见于翻译器对源代码存在某些假设,而执行时这些假设不成立的情况。

一些编程语言中,某些情况下存在未定义行为,以CC++最为著名。在这些语言的标准中,规定某些操作的语义是未定义的,典型的例子就是程序错误的情况,比如越界访问数组元素。标准允许语言的具体实现做这样的假设:只要是符合标准的程序代码,就不会出现任何类似的行为。具体到 C/C++ 中,编译器可以选择性地给出相应的诊断信息,但没有对此的强制要求:针对未定义行为,语言实现作出任何反应都是正确的,类似于数字逻辑中的无关项。虽然编译器实现可能会针对未定义行为给出诊断信息,但保证编写的代码中不引发未定义行为是程序员自己的责任。这种假设的成立,通常可以让编译器对代码作出更多优化,同时也便于做更多的编译期检查和静态程序分析。

有时候也可能存在对于未定义行为本身的限制性要求。例如,在CPU的指令集说明中可能将某些形式的指令定为未定义,但如果该CPU支持内存保护,说明中很可能会还会包含一条兜底的规则,要求任何用户态的指令都不会让操作系统的安全性受损;这样一来,在执行未定义行为的指令时,就允许CPU破坏用户寄存器,但不允许发生诸如切换到监控模式的操作。

和未指定行为(unspecified behavior)不同,未定义行为强调基于不可移植或错误的程序构造,或使用错误的数据。一个符合标准的实现可以在假定未定义行为永远不发生(除了显式使用不严格遵守标准的扩展)的基础上进行优化,可能导致原本存在未定义行为(例如有符号数溢出)的程序经过优化后显示出更加明显的错误(例如死循环)。因此,这种未定义行为一般应被视为bug

来自《维基百科-未定义行为》

Rust: Unsafe vs Undefined

关于 Unsafe Rust 相关术语解释可以参考 《Rust 安全编码规范: Unsafe Rust 编码术语指南》

Rust 里的未定义行为

程序员承诺,代码不会出现未定义行为。作为回报,编译器承诺以这样的方式编译代码:最终程序在实际硬件上的表现与源程序根据Rust抽象机的表现相同。如果发现程序确实有未定义的行为,那么程序员和编译器之间的契约就无效了,编译器产生的程序基本上是垃圾(特别是,它不受任何规范的约束;程序甚至不一定是格式良好的可执行代码)。

未定义行为列表:

  • 数据竞争。
  • 解引用悬空指针或者是未对齐指针
  • 打破指针别名规则(引用生命周期不能长于其引用的对象,可变引用不能被别名)。
  • 使用错误的 调用 ABI
  • 执行使用当前执行线程不支持的目标特性(target features)编译的代码
  • 产生无效的值
    • 01 表达的 bool
    • 具有无效判别式的 枚举
    • [0x0, 0xD7FF] [0xE000, 0x10FFFF] 范围之外的 字符
    • 来自于未初始化内存的整数、浮点数、指针读取或字符串
    • 悬垂引用或 Box
    • 宽引用、Box 或 裸指针有无效的元数据
      • dyn Trait 如果元数据不是指向, Trait 与指针或引用指向的实际动态 trait 匹配的 vtable,的指针,则元数据无效
      • 如果长度无效,则切片数据无效
    • 具有自定义无效值的类型,比如 NonNull

Unsafe 不等于 未定义行为

Unsafe 仅意味着避免未定义的行为是程序员的责任。 Rust 程序员在编写代码过程中要确保不要触发未定义行为。

然而,Unsafe 的内涵更加广泛: 所有在 Rust 中产生未定义行为的代码是 Unsafe 的,但并非所有 Unsafe 的代码都会产生 未定义行为。

比如我们解引用裸指针,就必须要放到 unsafe 块中,但并不意味着,解引用裸指针就一定会产生未定义行为。Unsafe 的存在,是让开发者可以划分出 Safe 和 Unsafe 的区域,意味着 “不需要担心安全的区域” 和 “需要担心安全的区域(雷区)”,这样划分使得我们的代码更加安全和有保证。

而 Safe Rust 的含义,则是指不使用 Unsafe 块的情况下,编译器能保证程序的 健全性(Soundness),它不会产生未定义行为。

PL 观点: UB 有利的一面

按以往的观点, UB 通常都是有害的。但是今天 Ralf 告诉我们,从 PL 的视角看,UB 也有有利的一面。

Ralf 认为, UB 是编程语言设计者工具箱中一个有价值的工具。因为 UB 可以看作是程序员向编译器传达的其自身对代码的理解,以此可以帮助编译器实现更多优化。

思考一段代码

参考下面代码:


#![allow(unused)]
fn main() {
fn mid(data: &[i32]) -> Option {
    if data.is_empty() { return None; }
    return Some(data[data.len()/2]);
}
}

假如该代码在一个循环中被调用,其性能就会变得相对重要。对于该函数,能否实现性能改进呢?

上面代码中,包含一些隐藏成本:编译器会插入一个边界检查,以确保访问的数据不会超过数据所指向数组的大小。

但是作为程序员,我们知道这个检查完全没有必要。因为 data.len()/2 总是会小于 data.len()

如果有一种方法,可以让程序员告诉编译器这里不需要插入边界检查,是不是更好?


#![allow(unused)]
fn main() {
fn mid(data: &[i32]) -> Option {
    if data.is_empty() { return None; }
    match data.get(data.len()/2) {
        Some(&x) => return Some(x),
        None => unsafe { unreachable_unchecked() }
    }
}
}

现在使用get操作来访问数组,它返回一个Option,对于越界访问来说是None。如果我们得到的是None,则会调用一个特殊的函数unreachable_unchecked,它向编译器承诺这段代码是不可访问的。

这里的关键字 unsafe 表示我们正在做的事情不在语言的类型安全保证范围内:编译器实际上不会检查我们的承诺是否成立,它只是相信我们。

unchecked 这个短语是 Rust 的惯用语,这里是unreachableunchecked版本,它插入了一个运行时检查,如果达到这段代码,就会安全地中止程序。或者,更准确地说,它会触发 Rust 的 恐慌(Panic)。

经过一些内联,这段代码相关部分看起来像这样:


#![allow(unused)]
fn main() {
let idx = data.len()/2;
if idx < data.len() { // 自动插入边界检查
    ... // Access the array at idx.
} else {
    unreachable_unchecked()
}
}

由于我们告诉编译器else分支是不可达的,所以很容易优化掉这个条件,所以我们最后只需要直接访问数组中的idx元素。

事实上,Rust提供了get_unchecked作为get的替代方法,调用者必须保证索引在界内,所以Rust的程序员只需要写data.get_unchecked(data.len()/2)就可以有效地实现上面的mid函数。

UB 是把双刃剑

上面好像没谈到 UB,但其实只是 Ralf 偷换了术语而已。

可以查看标准库文档 std::hint::unreachable_unchecked 的介绍:hint 模块中包含了提示编译器进行优化的一些方法, unreachable_unchecked 就是其中之一。如果你滥用它,比如上面示例代码中的 else 其实是程序可达的路径,那么编译器对此的优化就会让其导致未定义行为。

所以,需要明白,编译器并不是真的知道这段代码是否有未定义行为,它只是在假设没有未定义行为的情况下进行优化。

unreachable_unchecked 本身是一种 UB 行为 ,不建议随便使用。这里使用它只是 Ralf 为了说明程序员如何使用它来向编译器传达额外的信息。但如果使用不当,也会产生 UB

再比如, Rust 里提供了一个 unchecked_add 函数。

在其他语言中,一个看起来无辜的加法操作+变成了程序员的承诺,即,程序员要保证这个加法永远不会溢出,但程序员可能不会为他们程序中的每一个加法都仔细做一个无溢出证明。Rust 中对加法操作会有溢出检查。而通过 unchecked_add函数,来告诉程序员,使用它可以在不可能有溢出的场景下,来省略一些检查成本。

这其实是一个 语言设计的问题: UB 是一把双刃剑,使用得当,可以很好地完成工作,而使用不当,会造成很大伤害。

UB 的未来

Rust 从 C/Cpp 的数十年 UB经验中学习到了很多。这方面最典型的一个例子就是可变引用使用不正确的别名有关的 UB

Rust 的类型系统可以确保可变引用永远不会与程序中正在使用的其他引用发生别名,这意味着,它们永远不会指向与其他引用相同的内存。然而 Unsafe Rust 可以很容易地打破这种保证,即,为一个可变引用创建别名。

对此,我们能做什么呢?来看一段代码:


#![allow(unused)]
fn main() {
let x = &mut 42;                // 安全地创建一个引用。
let xptr = x as *mut i32;       // 把这个引用变成一个原始(未检查的)指针。
let x1 = unsafe { &mut *xptr }; // 将指针转为引用...
let x2 = unsafe { &mut *xptr }; // ...两次,所以违反了唯一性。
*x1 = 0; // 未定义行为!
}

这段代码有 UB 的原因不难看出来,通过裸指针创建了两个可变借用互为别名。

在这里我们能否期望程序员可以通过心智内化这个别名规则,从而承诺他们会在写代码的时候来保证这套规则? 显然是不可能的,否则 C/Cpp 就不会有那么多 UB 了。 但是我们可以通过提供工具来帮助程序员: Miri

Miri 包含了一个 Stacked Borrows 模型来检查上面示例中那种非法别名。这也是 Ralf 在他的博士论文中提出来的。

Stacked Borrows不是Rust规范的一部分,也不是Rust中与别名相关的UB的最终版本。因此,未来仍有可能对这个模型进行修订,以更好地与程序员的直觉保持一致。上面的代码可能会被接受,因为x2实际上没有被用来访问内存。或者,也许&mut expr只有在unsafe 块之外使用时才应该做出这样的承诺。但那样的话,添加Unsafe 的东西真的应该改变程序的语义吗?像往常一样,语言设计是一个权衡的游戏。

小结

Ralf 的观点总结如下:

  1. UB 是语言设计者工具箱中的一个有用的工具。
  2. 语言设计者应该承认优化器有其局限性,并给程序员提供他们需要的工具来帮助优化器。
  3. Unsafe不是一个错误;它是一个特性,没有它,Rust 就无法在实践中使系统编程更加安全。
  4. 提议:"未定义行为 "可能需要重新命名。这个术语关注的是负面情况,而作为程序员或编译器作者,我们真正关心的是程序没有未定义行为。我们能摆脱这种双重否定吗?也许我们应该谈论 "确保定义良好的行为 "而不是 "避免未定义行为"。
  5. 大多数时候,确保定义良好的行为是类型系统的责任,但作为语言设计者,我们不应该排除与程序员分担这一责任的想法。

作为 Rust 语言使用者,通过 Ralf 这篇文章来了解 Rust 语言设计者如何看待 UnsafeUB ,对我们理解 Rust 语言也许更有好处,至少对我是这样。

感谢阅读。

区块链快讯

来自于区块链领域的信息,来自于 https://rustinblockchain.org/newsletters/rib-newsletter-29/


随着区块链世界的总价值锁定(TVL)测量不断上升,以太坊极度拥挤,流动性提供者逃往高收益和低费用的区块链,许多链上都付出了巨大的努力来快速推出 dapps 并吸引市场份额。在 Rust 世界中,有一些值得注意的发展:

  • NEAR推出了 8 亿美元的生态系统基金,并一直在悄悄攀升 DeFi TVL 排行榜。

  • Secret Network 现在可以连接到多个链,其SecretSwap具有足够的流动性,很快就会成为在新兴的多链生态系统之间谨慎转移资产的可行途径。

  • Internet Computer(以前称为 DFINITY)一直在发布有关技术问题和合作伙伴关系的博客文章。他们正在运行一个开发者资助计划。

  • Nervos 已经启动了他们的 Force Bridge来连接到以太坊,并且正在努力增加EVM 兼容性

  • Polkadot 一直在对其 Canary 网络 Kusama 上的平行链插槽进行高调拍卖,并将很快开始对主要 Polkadot 网络的拍卖。一些 Kusama 平行链,特别是Moonriver,已经启动并运行并吸引了 Sushi 等与 EVM 兼容的 dapp。

  • Solana 已经变成了一个拥有巨大资金支持的巨头,而 TVL 仅次于以太坊和币安智能链。它一直吸引着以前仅支持 EVM 的 dapp 和协议,例如LidoRenVM;但它也有自己的 Solana 原生 dapp 的强大稳定。

项目聚焦

每个月我们都喜欢关注一个著名的 Rust 区块链项目。本月该项目是……

mina-rs .

Mina 是一个新的区块链网络,它使用零知识证明来验证链的状态,而无需访问完整的区块链,而只是一个很小的 (~22k) 证明。这应该使甚至移动设备也能够作为完整的验证者参与网络,与当今大多数客户端连接到托管在云中的其他人的完整节点的情况相比,需要更少的信任。他们将这种风格的链称为“简洁区块链”,并引起了其他一些项目的注意,结成合作伙伴关系,将这种想法带到其他链上。

mina-rs是 Mina 在 Rust 中的一个实现,由ChainSafe开发。它的开发不仅考虑了移动环境,还考虑了 WASM,这表明我们将能够直接在浏览器中嵌入完整节点。

关于 Mina / mina-rs 的一些最新信息:

有趣的东西

博客文章

文件

项目

  • Shade Protocol 是一系列建立在 Secret Network 上的连接隐私保护 dApp。

外刊评论 | 知名项目进展报告

编辑:张汉东

编者按:国外的 Rust 资源非常丰富,将其都翻译过来也不现实,所以想到这样一个栏目。外刊评论,主要是收集优秀的 Rust 相关文章,取其精华,浓缩为一篇简单评论。

欢迎大家贡献:[https://github.com/RustMagazine/rust_magazine_2021/discussions/129](


从语言架构的角度来看,Rust 最不适合哪些场景?

来自于 Reddit 的一篇帖子: What is Rust NOT suitable for, architecturally?

这里引用来自用户 Prokopyl 的观点,大多数人也比较赞同。

与所有编程语言(不仅仅是像 C/C++ 这样的系统编程语言)相比,Rust 最大的设计方向是:

  • 对系统细节的底层控制能力。引用、内存分配和布局、线程/异步等等(在嵌入式领域更是如此)。
  • 非常高的性能(或低延迟)。大约 <1 毫秒,每秒数千或数百万次处理(无论您的情况是什么“处理”)。还严格控制内存使用。
  • 非常注重正确性,尤其是在错误管理方面。换句话说,就是 “失败不是一种选择”。

当你的需求离这个设计方向越远,Rust 越会妨碍你的生产力,它就越不适合你的任务:

  • 如果你不关心你的数据在内存中的布局,你的数据结构(例如 OOP 中的对象/类)只是为了代码组织和架构,那么 Rust 语言和标准库的很大一部分只是充其量看起来很奇怪或愚蠢,最糟糕的是令人讨厌。
  • Rust 的错误管理绝对是一流的,但前提是你需要它,否则就很痛苦。当你的程序在发生不好的情况下如果只需要对用户说一句“抱歉!”就行时,也许异常处理更适合你。
  • 性能很大程度上应该看情况:
    • 如果你有一个“需要每秒生成 TB 数据”的需求,那么性能对你来说几乎就成了唯一需要考虑的问题。
    • 如果你的程序变得更快可以让你的用户更加 Happy, 那你应该考虑性能。比如 检索更快、音视频处理更快、图形渲染更快等。
    • 如果你的用户或者你认为你的用户只是抱着“又不是不能用”的想法,那性能就无所谓了。
    • Rust 的性能设计几乎是 “要想性能更佳,则需要在问题点上投入更多的工程(engineering)”。所以,这取决于你对于 工程投入的成本 ,如果你投入更多机器的成本低于工程投入的成本,那就没必要用 Rust。
    • 如果你是 “真的不需要那么多原始性能(或者负担不起工程)” ,那你选择带 GC 的语言就可以了。

另外一个用户TheWaterOnFire的观点也发人深省:

生产力是一个相对的尺度。例如,在$job,我刚刚修复了一个已经生产了两年的Go服务中的并发错误。Go中甚至都没有警告过这个问题,但是在Rust中写这个bug会让人更讨厌。它可以促使开发者采用一种可以防止这种事情发生的设计。

使用Go会更有成效吗?毕竟它已经 "完成 "了,而且多年来没有人触发这个bug!但是现在,我在我的工作中失去了生产力,因为我不得不重新审视那些 "已经完成 "的代码。

所以我想说,Rust 在让没有经验的开发者快速提供 "足够好 "的软件方面 并不出色。

你觉得Rust目前还缺乏哪些成熟的开源库?

来自于 Reddit 的一篇帖子 : What sort of mature, open-source libraries do you feel Rust should have but currently lacks?

总结一下帖子下提到的呼声比较高的库:

  1. 类似 Cuda 的GPU/GPGPU库;
  2. 图片处理相关的库,支持WebP、XBM、XPM等格式;支持类似python中Pillow库功能的图片处理库;
  3. 图像处理库;
  4. 纯Rust实现的解压缩算法库,支持LZMA、BZip2等格式及其衍生格式;
  5. 文档处理库,支持PDF、ODF、OOXML、EPUB、MOBI等格式的解析和提取操作;
  6. 检验和相关的库;
  7. Qt相关的生成和绑定库;
  8. 完全支持XML格式文档的读写操作的库;
  9. LLVM高级绑定的库;
  10. 机器学习相关的库;

更多想法和细节欢迎围观该讨论帖

Rust适合做科学计算吗?

来自于 Reddit 的一篇帖子 : Is rust good for mathematical computing?

科学计算相关的任务:

  • 矩阵计算
  • 数值分析

对编程语言的要求:

  • 对lambda的良好支持;
  • 简单易用的函数组合;

Rust语言本身对lambda(在Rust中称为闭包)、通用组合和函数式编程有很好的支持,但像柯里化(curry)这样的东西看起来并不漂亮。

目前Rust语言实现的跟科学计算相关的crate列表,可以在此查看:Scientific Computing

【论文导读】Rust 程序 Bug 静态检测工具 MirChecker

作者: **李卓华 **

本文转载自公众号 《 安全研究GoSSIP》,原文 G.O.S.S.I.P 学术论文推荐 2021-11-24 Rust 程序 Bug 静态检测工具 MirChecker

作者为香港中文大学ANSR Lab五年级博士生 李卓华,与百度安全实验室合作完成。他的研究方向为系统安全,程序分析和随机模型。

MirChecker vs Rudra

佐治亚理工 Teasoo Kim 研究组的研究论文Rudra: Finding Memory Safety Bugs in Rust at the Ecosystem Scale @SOSP 2021 ,里面介绍了 Rudra 这个静态检测工具。它和 MirChecker 有什么不同呢?

相同点:

  1. MirChecker (CCS '21) 和 Rudra (SOSP '21) 都是静态分析工具找 Rust 程序的 bugs;
  2. 都找到了真实世界的 bug;
  3. 同样基于 Rust 的 MIR;
  4. 两者都实现为Cargo子命令和Rust编译器的自定义回调函数

不同点:

  1. 算法 – MirChecker 使用 abstract interpretation/symbolic execution with constraint solving (sound +1), Rudra 使用 static taint analysis with heuristics (简单的 taint propagation 算法,只分析 unsafe code body 等);
  2. Bug 类型 – 除了相同的 runtime panic bugs (buffer overflow, integer overflow),Rudra 也支持 higher-order safety invariant 和 unsafe Send/Sync 造成的 thread safety 问题;
  3. 关于false positive 问题,MirChecker 通过 flags 开关一些分析路径 (e.g., bitwise ops),Rudra 则是提供 high/medium/low 的 precision filter

研究背景

系统级编程既要能对底层硬件进行操作,同时还有极高的安全性需求。传统的系统编程语言如C/C++很难提供足够的安全性保障,因此Rust语言成为了呼声极高的替代品。Rust强大的类型系统和独特的“所有权(ownership)”机制,能够在支持底层操作的同时,很大程度地消除内存安全错误。谷歌、微软等公司均积极在自己的项目中引入Rust,Linux内核也正试图将Rust作为C之外的第二种内核开发语言。然而安全领域没有灵丹妙药,现有的研究已经显示,Rust仍然有很多问题,导致运行时错误和内存安全问题。

因此,论文作者实现了MirChecker,一个针对Rust程序的静态分析和错误检测工具。它以一个Rust软件包作为输入,分析可能出现的错误并输出错误报告。该工具与其他自动代码查错工具有以下不同:

  1. 利用静态分析的方法,可以最大限度地利用Rust强大的类型系统;
  2. 考虑了Rust语言特性导致的其特有的错误类型;
  3. 基于Rust MIR而不是LLVM IR,最大限度地利用Rust编译器提供的类型信息。

Rust 的错误分类

通过观察已报告的Rust漏洞,作者将常见的错误分成两类,并分析其产生的原因。

  1. 运行时崩溃(Runtime Panics)。对于无法在编译期检测的错误,例如数组越界检查,整数溢出等,Rust编译器将自动在产生的可执行程序中插入assertion语句,动态地进行检查。一旦发生错误,程序将在运行时以panic的方式终止执行。这种设计虽然通过终止执行的方式阻止了内存错误的发生,但对于系统级的程序例如操作系统等,运行时的意外终止仍是不被接受的。
  2. 生命周期损坏(Lifetime Corruption)。Rust的所有权机制可以静态地跟踪变量的生命周期,因而程序员无需显式地申请和释放内存。这种自动化的内存管理机制很大程度上消除了常见的内存错误,例如use-after-free和double-free。但Rust的unsafe关键字会使编译器放松一些安全性检查,可能会破坏所有权机制对内存生命周期的跟踪,再加之隐式的内存释放机制,从而导致程序员肉眼难以发觉的内存错误。

作者发现约40%的运行时崩溃都源于错误的整数数值操作,例如下标越界源于对下标变量错误的计算,整数溢出源于缺少必要的数值范围检查。因此利用静态数值分析,给出程序中整数数值的约束条件,再通过约束求解即可判断是否有可能的错误。同时,针对生命周期损坏,作者提出通过符号执行(symbolic execution)的方法,对于可能扰乱所有权系统的unsafe函数,跟踪其所有权的传递,检测是否存在一块可能仍在使用的内存被释放,或一块已经释放的内存被使用的情况,并生成错误检测报告。

MirChecker 的设计

如下图所示,MirChecker采用了经典的三段式设计:(1)用户界面,(2)静态分析,(3)错误检测。

1

其执行流程可以总结为:

  1. 用户界面读取要检测的Rust代码包,并下载其所依赖的包。将目标Rust代码包源文件送入静态分析器,而其依赖的代码仍由官方Rust编译器编译。这种设计使得MirChecker专注于用户希望检测的代码,避免了分析器盲目地深入依赖。
  2. 在静态分析环节,使用了“抽象释义(abstract interpretation)”的分析框架,对于每个函数,提取并预处理其控制流图(control-flow graph),根据控制流的结构,迭代地对每一条语句进行分析(即执行其“转移函数(transfer function)”)直至分析结果收敛。
  3. 最后在错误检测环节调用约束求解器,判断可能的错误并输出分析报告。

语言模型与内存模型

在静态分析环节,为了分析Rust编译器生成的中间代码(MIR),作者根据MIR的数据结构设计了一个简单的语言模型,并定义每一条语句的语义。

2

设计这个语言模型的目的有二:

  1. 在此语言模型上,作者可以对每一条语句定义静态分析所需的转移函数(transfer function),即定义每一条语句对当前状态产生什么影响。
  2. 根据此模型,作者可以构造符号执行所需的符号表达式,例如分支条件等。这些符号表达式还可以用作静态分析的内存模型,在分析内存读取和写入相关指令时,将符号表达式用作“抽象地址(abstract memory address)”,用于判断哪一块内存被访问了。

代码实现

MirChecker由Rust语言实现(共约12000行代码)。数值分析部分的底层实现使用了开源的Apron,它实现了常见的静态分析所用的数值模型,如Interval,Octagon,Polyhedra等。符号分析部分的实现基于MIRAI,一个利用符号执行进行程序验证的工具。在错误检测部分,使用了Z3约束求解器。

MirChecker的用户界面被实现为Rust的官方包管理器Cargo的子命令,从而可以方便地被Rust开发者安装和使用。静态分析部分算法被实现为Rust编译器的一个自定义回调函数,因而可以利用Rust编译器内部的数据和工具函数,输出结构化的错误报告。在现实使用中,MirChecker不仅可以输出可能的错误类型,还可以指出错误可能发生的位置,方便开发者快速定位程序中可能的错误。

分析结果

为了评估MirChecker的效果,作者从Rust官方包管理网站crates.io上抓取下载量前1000的代码包,同时也从GitHub上搜索得到数十个未注册到crates.io的包。经过MirChecker分析,检测出其中12个包中33个漏洞,其中包括了16个内存安全错误。作者已将这些问题报告给开发者,目前已有25个漏洞被修复。同时作者还比较了不同数值模型的运行时间以及内存占用情况。作者还实现了一些用于减少误报的用户选项,可以方便地结合MirChecker的用户界面使用。

3

小广告

一个小广告~ 如果对 CUHK ANSRLab 感兴趣,John 也在招收相关方向的博士生和博士后,详细信息可以参考以下链接:

  • ANSR Lab主页:http://ansrlab.cse.cuhk.edu.hk/
  • Important information for potential post-doc: https://www.cse.cuhk.edu.hk/~cslui/potential_postdoc.html
  • Important information for potential Ph.D students: https://www.cse.cuhk.edu.hk/~cslui/potential_students.html

华为 | no std的可执行文件编写和运行

作者: 楼智豪


背景

众所周知,Rust支持no std的编译方式,可以支持在没有标准库的情况下运行程序。

对于一个Rust lib库而言,只要在库lib.rs开头加上一句#![no_std]就可以很方便的将其定义为no std环境下的crate。

但是,这并不意味着该crate可以在no std环境下运行。目前,no std的crate依然可以依赖于std的crate并且可以编译成功。no std仅仅检查该crate自身,而不检查依赖项是否是no std(详见 issue #38509)。

所以,想要验证一个库在no std环境下的功能是否正常,就需要编译一个no std的可执行文件来进行实际的运行验证,而不能仅仅依赖std环境下的测试。

实际操作

no std的程序和一般的程序差别比较大,除了std库里的println、String等无法使用以外,还需要用户自行配置很多内容,程序才可以正常运行。主要分为以下几点

  1. 主要流程代码如下所示,其中,#![no_std]表示该crate为不依赖std,#![no_main]表示该crate没有main函数。

    一般情况下,程序从main函数开始,但实际上,程序执行的流程为_start->libc_start_main->main->exit,其中执行了一些环境初始化流程,在no std中则没有这些,所以我们需要自行实现start和exit函数,如下所示。exit退出函数也是必要的,否则也会导致段错误。

  2. 注意在start函数的开头有一句汇编指令push rbp,这条语句的作用是将函数的栈帧按16字节对齐,在x86中某些汇编指令比如movdqa有栈帧16字节对齐的要求,如果没对齐则会导致段错误。一般如果程序比较简单的话不会生成movdqa指令,那么不用对齐也能正常运行。


#![allow(unused)]
fn main() {
// main.rs
#![feature(lang_items, asm, start)]
#![no_std]
#![no_main]

/// no std下无法自动运行main函数,所以需要命名为start
#[no_mangle]
pub extern "C" fn _start(_argc: i32, _argv: *const *const u8) -> i32 {
    // 用于在x86下进行函数栈帧16字节对齐
    #[cfg(target_arch = "x86_64")]
    unsafe {
        asm!("push rbp");
    }
    exit(0)
}

/// 使用系统调用进行进程退出。否则会导致coredump
pub fn exit(code: isize) -> ! {
    #[cfg(target_arch = "x86_64")]
    unsafe {
        asm!(
        "syscall",
        in("rax") 60, // exit
        in("rdi") code,
        options(noreturn)
        );
    }
}
}
  1. 如下所示,eh_personalitypanic_handler用来处理Rust异常panic。在no std中,我们需要自行接管和处理panic,这两个语言项是必要的。程序发生panic之后就会跳转到panic handler。

    注意:此处调用了println,正常情况下no std中是不能使用println的,这样会导致错误信息无法打印,调试起来就会相当困难。所以我们可以使用libc的printf替代原本的println,使得在no std中也可以打印出异常信息,方便调试,当然前提是依赖于libc,如果环境中没有libc那也无法使用。

#[lang = "eh_personality"]
extern "C" fn eh_personality() {
    println!("eh_personality");
}

#[panic_handler]
fn my_panic(_info: &core::panic::PanicInfo) -> ! {
    println!("{}", _info);
    exit(11)
}
  • println的实现如下所示,使用write方法写入相关的信息,现在panic就可以打印出错误信息了。也可以使用println打印其他想要打印的信息。
panicked at 'assertion failed: `(left == right)`
  left: `1`,
 right: `2`', src/main.rs:25:5
/// 由于在no std下无法使用println,所以使用libc::printf来接管标准库的println
#[macro_export]
macro_rules! println {
    () => ($crate::libc_printf(core::format_args!("")));
    ($($arg:tt)*) => ({
        $crate::libc_printf(core::format_args!($($arg)*));
    })
}

const BUFFER_LEN: usize = 512;

struct LibcWriter {
    buffer: [u8; BUFFER_LEN],
    used: usize,
}

impl LibcWriter {
    pub fn new() -> Self {
        Self {
            buffer: [0; BUFFER_LEN],
            used: 0,
        }
    }
}

impl Write for LibcWriter {
    fn write_str(&mut self, s: &str) -> Result {
        if self.used > self.buffer.len() {
            return Err(Error);
        }
        let remaining_buf = &mut self.buffer[self.used..];
        let raw_str = s.as_bytes();
        let len = min(raw_str.len(), remaining_buf.len());
        remaining_buf[..len].copy_from_slice(&raw_str[..len]);
        self.used += raw_str.len();

        Ok(())
    }
}

pub fn libc_printf(args: Arguments<'_>) {
    let mut writer = LibcWriter::new();
    let _res = write!(&mut writer, "{}", args);
    writer.buffer[BUFFER_LEN - 1] = 0;
    unsafe {
        libc::printf("%s\n\0".as_ptr() as *const _, writer.buffer.as_ptr());
    }
}
  1. 该crate的编译指令为cargo +nightly rustc --release -- -C link-arg=-nostartfiles -lc。其中很多功能都只能在nightly才能用。-C link-arg=-nostartfiles和上文提到的#![no_main]是一个概念,没有main函数取而代之的是start函数。-lc则表示需要链接libc库。另外注意,--release现在是必须的,no std程序目前只能以release方式运行,debug版本编译会报错,详见issue #47493

  2. 上面编写了这么多,最终其实只是想为no std的库提供一个测试环境。因为原生的cargo test很难用于no std的测试。固然,我们也可以在测试文件的开头加上一句#![no_std]来表明该测试文件是no std的,但其实用处不大。

//test.rs
#![no_std]

#[test]
fn test() {
	//println!();
	test_func();
}
  • 如上所示,这样的测试代码,如果我们在其中使用println,那么编译器会正常报错,告诉我们no std中没有println,这看起来很正常。但是如果在test_func中调用了println,那么编译不会报错且完全正常运行。所以实际上它依然依赖于std,这样的测试很难保证功能的正确性。为了这一点,所以我们单独编写了一个no std的测试环境,来完成这一切。

华为 | 硬件加速指令实践心得

作者:胡凯


最近在尝试使用Rust标准库core_arch下的硬件加速指令来实现一些通用加密算法。在这些指令使用的过程中,遇到了一些实现上的问题。在此我将把解决方法总结如下,希望能对后续Rust core_arch库的使用者提供一些帮助。我将从一个最简单问题开始来介绍这些解决方法。

最初的需求

假设我们需要实现一个函数func提供给外部使用,并且在不同架构上的实现不同,应该怎么实现呢?这个问题比较简单,Rust有提供#[cfg]宏来区分不同的架构、操作系统等。

区分不同架构,我们可以使用target_arch


#![allow(unused)]
fn main() {
// lib.rs 文件下
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
mod x86;
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
pub use x86::func;

#[cfg(target_arch = "aarch64")]
mod aarch64;
#[cfg(target_arch = "aarch64")]
pub use aarch64::func;
}

#![allow(unused)]
fn main() {
// x86.rs 文件下
fn func() {
    // ... 具体实现
}
}

#![allow(unused)]
fn main() {
// aarch64.rs 文件下
fn func() {
    // ... 具体实现
}
}

OK,按照以上方法即可完成对应的功能,该模块的使用者只需在对应环境上进行编译即可使用。#[cfg]宏还具备很多其他的选项供选择,例如target_ostarget_endian等,使用者可以根据自身需求选择。

更进一步

假设我们要在func函数中使用不同架构下的硬件加速指令,应该怎么做呢?还是和之前一样使用#[cfg]宏,可以使用target_feature。以AES硬件加速指令为例:


#![allow(unused)]
fn main() {
// lib.rs 文件下
#[cfg(all(
    any(target_arch = "x86", target_arch = "x86_64"),
    target_feature = "aes",
))]
mod x86;
#[cfg(all(
    any(target_arch = "x86", target_arch = "x86_64"),
    target_feature = "aes",
))]
pub use x86::func;

#[cfg(all(
    target_arch = "aarch64",
    target_feature = "aes",
))]
mod aarch64;
#[cfg(all(
    target_arch = "aarch64",
    target_feature = "aes",
))]
pub use aarch64::func;
}

#![allow(unused)]
fn main() {
// x86.rs 文件下
#[cfg(target_arch = "x86")]
use core::arch::x86::*;
#[cfg(target_arch = "x86_64")]
use core::arch::x86_64::*;

fn func() {
    // ... 具体实现,调用core_arch\x86下的指令
}
}

#![allow(unused)]
fn main() {
// aarch64.rs 文件下
#[cfg(target_arch = "aarch64")]
use core::arch::aarch64::*;

fn func() {
    // ... 具体实现,调用core_arch\aarch64下的指令
}
}

这样就能使用对应架构下的指定硬件加速指令了。这里有几点需要注意:

  1. 除了x86\x86_64下的硬件加速指令以外,其他的所有core_arch下的硬件加速指令现在都只能在Rust nightly版本下编译和执行。
  2. 在一些架构上target_feature有时并不能检测出机器是否提供指定功能,编译时需要使用以下方式之一:
     $ RUSTFLAGS='-C target-cpu=native' cargo build
    
     $ RUSTFLAGS='-C target-feature=+aes' cargo build
    

遇到的第一个问题

我尝试将以上代码放到不同机器上编译、执行,大部分机器都能成功了。但出现少部分机器编译失败的情况(找不到func)。出现这种情况主要是因为有一些机器不支持对应的硬件加速指令。上面的代码默认在遇到指定架构时使用了硬件加速,没有注意可能有些机器不支持。于是需要做一些修改:


#![allow(unused)]
fn main() {
// lib.rs 文件下
#[cfg(all(
    any(target_arch = "x86", target_arch = "x86_64"),
    target_feature = "aes",
))]
mod x86;
#[cfg(all(
    any(target_arch = "x86", target_arch = "x86_64"),
    target_feature = "aes",
))]
pub use x86::func;

#[cfg(all(
    target_arch = "aarch64",
    target_feature = "aes",
))]
mod aarch64;
#[cfg(all(
    target_arch = "aarch64",
    target_feature = "aes",
))]
pub use aarch64::func;

#[cfg(not(any(
    all(
        any(target_arch = "x86", target_arch = "x86_64"),
        target_feature = "aes",
    ),
    all(
        target_arch = "aarch64",
        target_feature = "aes",
    )
)))]
mod soft;
#[cfg(not(any(
    all(
        any(target_arch = "x86", target_arch = "x86_64"),
        target_feature = "aes",
    ),
    all(
        target_arch = "aarch64",
        target_feature = "aes",
    )
)))]
pub use soft::func;
}

#![allow(unused)]
fn main() {
// x86.rs 文件下
#[cfg(target_arch = "x86")]
use core::arch::x86::*;
#[cfg(target_arch = "x86_64")]
use core::arch::x86_64::*;

fn func() {
    // ... 具体实现,调用core_arch\x86下的指令
}
}

#![allow(unused)]
fn main() {
// aarch64.rs 文件下
#[cfg(target_arch = "aarch64")]
use core::arch::aarch64::*;

fn func() {
    // ... 具体实现,调用core_arch\aarch64下的指令
}
}

#![allow(unused)]
fn main() {
// soft.rs 文件下
pub fn func() {
    // ... 具体实现,一般的实现方式,不使用硬件加速
}
}

这里添加了一个默认的软件实现方式,用于在指定架构上不支持硬件加速或者不是以上架构的情况下使用的func。

遇到的第二个问题

以上方法能够解决编译失败的问题,但是当你在某一个架构机器上进行编译后,编译出来的二进制文件在另一个不同架构机器或是不支持硬件加速指令的机器上运行时会指令异常。这里就涉及交叉编译的问题。因为#[cfg]是静态编译,编译出来的结果只和当前环境有关。core_arch库中提供了一种动态检测的方法,这种动态检测方法能够解决同一架构下是否支持硬件加速的情况,不需要交叉编译,但是在不同架构下仍需交叉编译解决此问题。

例如在x86\x86_64架构下提供了is_x86_feature_detected!宏,用于动态检测指定cpu功能,我们可以使用该宏来对代码进行改造:


#![allow(unused)]
fn main() {
// lib.rs 文件下
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
mod x86;
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
pub use x86::func;

// aarch64 下暂无动态检测机制。

pub(crate) mod soft;
}

#![allow(unused)]
fn main() {
// x86.rs 文件下
#[cfg(target_arch = "x86")]
use core::arch::x86::*;
#[cfg(target_arch = "x86_64")]
use core::arch::x86_64::*;

pub(crate) use soft::func;

fn func() {
   if is_x86_feature_detected!("aes") {
      // ... 具体实现,调用core_arch\x86下的指令
   } else {
      func()
   }
}
}

#![allow(unused)]
fn main() {
// soft.rs 文件下
pub(crate) fn func() {
    // ... 具体实现,一般的实现方式,不使用硬件加速
}
}

这种方式可以将x86\x86_64架构下编译好的二进制文件放到另一台x86\x86_64的机器上去运行,无需交叉编译。

总结

以上便是我对Rust core_arch库的一些使用方法总结。更具体的使用方式可以查看core_arch下的core_arch_docs.md,里面有对整个core_arch库更为详细的介绍。

DatenLord | Rust程序性能分析

作者:潘政


简介

程序的性能分析是一个很广很深的话题,有各种各样的工具来对不同的指标进行测试分析。本文主要介绍如何用profiling工具对Rust程序进行On-CPUOff-CPU的性能分析,以及如何绘制火焰图对结果测试进行可视化处理。

On-CPU性能分析

On-CPU的性能分析为了找出占用CPU时间多的任务或者函数,进而找出程序的性能瓶颈。这里主要介绍perf工具,perfLinux提供的命令,也叫perf_events,它属于Linux kernel,在tools/perf目录下。perf提供了强大的功能包括监测CPU performance counters, tracepoints, kprobes和 uprobes等。这里我们使用perf的CPU profiling功能。由于perf会拿到系统的信息,所以运行需要root权限。perfOn-CPU性能分析的原理是以一个指定的频率对CPU进行采样,进而拿到正在CPU上运行的指令乃至整个函数调用栈的快照,最后对采样的数据分析,比如说在100次采样中有20次在运行A指令或者A函数,那么perf就会认为A函数的CPU使用率为20%。
下面我们使用一个简单的程序来展示如何用perf来进行On-CPU的性能分析,需要使用debug编译,程序如下:

fn test2() {
    for _ in 0..200000 {
        ()
    }
}
fn test1() {
    for _ in 0..100000 {
        ()
    }
    test2();
}
fn main() {
    for _ in 0..10 {
        test1();
    }
}

我们在test1()函数和test2()函数中分别加入了一个段循环来消耗CPU资源,在test2()中我们循环了200000次是在test1()中的两倍。我们使用如下perf命令来做CPU profiling:

$ sudo perf record --call-graph=dwarf ./target/debug/mytest

采样的数据默认会存到perf.data文件中。参数--call-graph的目的是开启函数调用栈的记录,这样在profiling的结果中可以打印出完整的函数调用栈。目前perf支持fp(frame pointer), dwarf(DWARF's CFI - Call Frame Information)lbr(Hardware Last Branch Record facility)三种方式来获取函数调用栈。稍后我们会简单介绍fpdwarf的原理。由于Rust编译器默认生成了dwarf格式的调试信息,我们可以直接使用--call-graph=dwarf。我们可以使用如下命令来读取profiling的结果:

$ sudo perf report --stdio

这个命令会默认读取perf.data中的数据并格式化输出。命令的输出如下,因为输出很长,部分无关信息被省略掉,

# Children      Self  Command  Shared Object      Symbol
# ........  ........  .......  .................  .........................................................................................................
#
    77.57%     0.00%  mytest   mytest             [.] _start
            |
            ---_start
               __libc_start_main
               main
               ......
               mytest::main
               mytest::test1
               |
               |--52.20%--mytest::test2
               |          |
               |           --46.83%--core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::Range<A>>::next
               |                     |......
               |
                --23.87%--core::iter::range::<impl core::iter::traits::iterator::Iterator for core::ops::range::Range<A>>::next
                          |......
               |......            

从输出可以看出整个测试程序占用了77.57%的CPU资源,在mytest::test1函数中有23.87%的时间在做for循环,有52.20%的时间被调用的mytest::test2函数占用,然后在mytest::test2函数中,有45.32%时间在做for循环,剩下的时间是一些其他的开销。profiling的结果基本体现了我们测试程序CPU占用率的实际情况。我们在mytest::test2函数中for循环次数是mytest::test1函数中两倍,相应的我们也看到了在CPU占用率上也几乎是两倍的关系。

火焰图

火焰图是对profiling进行可视化处理的一种方式,从而更直观地展示程序CPU的使用情况,通过如下命令可以生成火焰图

$ git clone https://github.com/brendangregg/FlameGraph
$ cd FlameGraph
$ sudo perf record --call-graph=dwarf mytest
$ sudo perf script | ./stackcollapse-perf.pl > out.perf-folded
$ ./flamegraph.pl out.perf-folded > perf.svg

可以使用Chrome浏览器打开perf.svg,生成的火焰图如下 on-cpu
火焰图的纵轴代表了函数调用栈,横轴代表了占用CPU资源的比例,跨度越大代表占用的CPU资源越多,从火焰图中我们可以更直观的看到程序中CPU资源的占用情况以及函数的调用关系。

Frame Pointer 和 DWARF

前文有提到Frame PointerDWARF两种方式拿到函数的调用栈,这里做一个简单的介绍。

Frame Pointer是基于标记栈基址EBP的方式来获取函数调用栈的信息,通过EBP我们就可以拿到函数栈帧的信息,包括局部变量地址,函数参数的地址等。在做CPU profiling的过程中,fp帮助函数调用栈的展开,具体原理是编译器会在每个函数入口加入如下的指令以记录调用函数的EBP的值

push ebp
mov	ebp, esp
sub	esp, N

并在函数结尾的时候加入如下指令以恢复调用函数的EBP

mov	esp, ebp
pop	ebp
ret

通过这种方式整个函数调用栈像一个被EBP串起来的链表,如下图所示
frame-pointer
这样调试程序就可以拿到完整的调用栈信息,进而进行调用栈展开。

因为Frame Pointer的保存和恢复需要引入额外的指令从而带来性能开销,所以Rust编译器,gcc编译器默认都是不会加入Frame Pointer的信息,需要通过编译选项来开启。Rust编译器加入Frame Pointer的选项如下

$ RUSTFLAGS="-C force-frame-pointers=yes" cargo build

加入Frame Pointer的信息后就可以通过--call-graph=fp来打印函数的调用栈。

DWARF是被广泛使用的调试格式,Rust编译器默认加入了DWARF调试信息,DWARF格式提供了各种调试信息,在帮助函数调用栈展开方面,编译器会插入CFI(Call Frame Information)指令来标记CFA(Canonical Frame Address)CFA指的是调用函数在call被调函数前ESP的地址。通过CFA再加上预先生成的调试信息,就可以解析出完整的函数调用栈信息。

Off-CPU性能分析

Off-CPU性能分析与On-CPU性能分析是互补的关系。Off-CPU性能分析是为了分析进程花在等待上的时间,等待包括被I/O请求阻塞,等待锁,等待timer,等等。有很多可以做Off-CPU性能分析的工具,这里我们使用eBPF的前端工具包bcc中的offcputime-bpfcc工具。这个工具的原理是在每一次内核调用finish_task_switch()函数完成任务切换的时候记录上一个进程被调度离开CPU的时间戳和当前进程被调度到CPU的时间戳,那么一个进程离开CPU到下一次进入CPU的时间差即为Off-CPU的时间。为了模拟Off-CPU的场景我们需要修改一下我们的测试程序,需要使用debug编译,因为offcputime-bpfcc依赖于frame pointer来进行栈展开,所以我们需要开启RUSTFLAGS="-C force-frame-pointers=yes"的编译选项,程序如下:

use std::io::Read;
fn test1() {
    std::thread::sleep(std::time::Duration::from_nanos(200));
}
fn test2() {
    let mut f = std::fs::File::open("./1.txt").unwrap();
    let mut buffer = Vec::new();
    f.read_to_end(&mut buffer).unwrap();
}
fn main() {
    loop {
        test1();
        test2();
    }
}

程序中一共有两种会导致进程被调度出CPU的任务,一个是test1()函数中的sleep(),一个是在test2()函数中的读文件操作。这里我们需要开启Frame Pointer编译选项以便打印出用户态的函数栈。我们使用如下的命令获取Off-CPU的分析数据,

$ ./target/debug/mytest &
$ sudo offcputime-bpfcc -p `pgrep -nx mytest` 5
    .......
    b'finish_task_switch'
    b'preempt_schedule_common'
    b'_cond_resched'
    b'copy_page_to_iter'
    b'generic_file_buffered_read'
    b'generic_file_read_iter'
    b'ext4_file_read_iter'
    b'new_sync_read'
    b'vfs_read'
    b'ksys_read'
    b'__x64_sys_read'
    b'do_syscall_64'
    b'entry_SYSCALL_64_after_hwframe'
    b'__libc_read'
    b'std::io::read_to_end::hca106f474265a4d9'
    b'std::io::Read::read_to_end::h4105ec7c4491a6c4'
    b'mytest::test2::hc2f1d4e3e237302e'
    b'mytest::main::h9ce3eef790671359'
    b'core::ops::function::FnOnce::call_once::hac091ad4a6fe651c'
    b'std::sys_common::backtrace::__rust_begin_short_backtrace::h071a56f0a04107d5'
    b'std::rt::lang_start::_$u7b$$u7b$closure$u7d$$u7d$::hc491d6fbd79f86bd'
    b'std::rt::lang_start_internal::h73711f37ecfcb277'
    b'[unknown]'
    -                mytest (179236)
        52

    b'finish_task_switch'
    b'schedule'
    b'do_nanosleep'
    b'hrtimer_nanosleep'
    b'common_nsleep'
    b'__x64_sys_clock_nanosleep'
    b'do_syscall_64'
    b'entry_SYSCALL_64_after_hwframe'
    b'clock_nanosleep'
    -                mytest (179236)
        1093

这里只截取了最后两个函数栈,输出显示有52ms的Off-CPU时间在等待文件读取的syscall,有1092ms的Off-CPU时间在等待sleep

火焰图

Off-CPU性能分析的结果同样可以用火焰图进行可视化处理,命令如下,

$ git clone https://github.com/brendangregg/FlameGraph
$ cd FlameGraph
$ sudo offcputime-bpfcc -df -p `pgrep -nx mytest` 3 > out.stacks
$ ./flamegraph.pl --color=io --title="Off-CPU Time Flame Graph" --countname=us < out.stacks > out.svg

生成的火焰图如下: off-cpu
On-CPU的火焰图相同,纵轴代表了函数调用栈,横轴代表了Off-CPU时间的比例,跨度越大代表Off-CPU的时间越长。

总结

本文介绍了如何对Rust程序进行On-CPUOff-CPU的性能分析,两者相结合就实现了对一个程序100%运行时间的分析。至此我们对程序的性能有了初步的理解,进而可以通过更加具体的工具和编写profiling程序来对我们关心的点进行更深入的分析。

参考引用

https://www.brendangregg.com/FlameGraphs/offcpuflamegraphs.html
https://www.brendangregg.com/FlameGraphs/cpuflamegraphs.html
https://en.wikipedia.org/wiki/Function_prologue_and_epilogue

蚂蚁集团 | Play With Your Async Code

作者:夏锐航

作为 2018 edition 一个比较重要的特性,rust 的异步编程现在已经得到了广泛的使用。使用的时候难免会有一些好奇它是如何运作的,这篇文章尝试从 generator 以及变量捕获的方面进行探索,而后介绍了在嵌入式时序存储引擎 ceresdb-helix 的研发过程中遇到的一个场景。囿于作者水平内容难免存在一些错漏之处,还烦请留言告知。


async/.await 语法在 1.39 版本[1]进入 stable channel,它能够很方便地编写异步代码:


#![allow(unused)]
fn main() {
async fn asynchronous() {
    // snipped
}

async fn foo() {
    let x: usize = 233;
    asynchronous().await;
    println!("{}", x);
}
}

在上面的示例中,局部变量 x 能够直接在一次异步过程(fn asynchoronous)之后使用,和写同步代码一样。而在这之前,异步代码一般是通过类似 futures 0.1[2] 形式的组合子来使用,想要给接下来 (如 and_then()) 的异步过程的使用的局部变量需要被显式手动地以闭包出入参的方式链式处理,体验不是特别好。 ​

async/.await 所做的实际上就是将代码变换一下,变成 generator/coroutine[3] 的形式去执行。一个 coroutine 过程可以被挂起,去做一些别的事情然后再继续恢复执行,目前用起来就是 .await 的样子。以上面的代码为例,在异步过程 foo()中调用了另一个异步过程 asynchronous() ,在第七行的 .await 时当前过程的执行被挂起,等到可以继续执行的时候再被恢复。 ​

而恢复执行可能需要之前的一些信息,如在 foo()中我们在第八行用到了之前的信息 x。也就是说 async 过程要有能力保存一些内部局部状态,使得它们能够在 .await之后被继续使用。换句话说要在 generator state 里面保存可能在 yield 之后被使用的局部变量。这里需要引入 pin[4] 机制解决可能出现的自引用问题,这部分不再赘述。

visualize generator via MIR

我们可以透过 MIR[5] 来看一下前面提到的 generator 是什么样子的。MIR 是 rust 的一个中间表示,基于控制流图 CFG[6] 表示。CFG 能够比较直观地展示程序执行起来大概是什么样子,MIR 在有时不清楚你的 rust 代码到底变成了什么样子的时候能够起到一些帮助。 ​

想要得到代码的 MIR 表示有几种方法,假如现在手边有一个可用的 rust toolchain,可以像这样传递一个环境变量给 rustc ,再使用 cargo 进行构建来产生 MIR:

RUSTFLAGS="--emit mir" cargo build

构建成功的话会在 target/debug/deps/ 目录下生成一个 .mir 的文件。或者也能通过 https://play.rust-lang.org/ 来获取 MIR,在 Run 旁边的溢出菜单上选择 MIR 就可以。 ​

由 2021-08 nightly 的 toolchain 所产生的 MIR 大概是这个样子的,有许多不认识的东西可以不用管,大概知道一下

  • _0, _1 这些是变量
  • 有许多语法和 rust 差不多,如类型注解,函数定义及调用和注释等

就行了(我也只知道这么多)。

fn future_1() -> impl Future {
    let mut _0: impl std::future::Future; // return place in scope 0 at src/anchored.rs:27:21: 27:21
    let mut _1: [static generator@src/anchored.rs:27:21: 27:23]; // in scope 0 at src/anchored.rs:27:21: 27:23

    bb0: {
        discriminant(_1) = 0;            // scope 0 at src/anchored.rs:27:21: 27:23
        _0 = from_generator::<[static generator@src/anchored.rs:27:21: 27:23]>(move _1) -> bb1; // scope 0 at src/anchored.rs:27:21: 27:23
                                         // mir::Constant
                                         // + span: src/anchored.rs:27:21: 27:23
                                         // + literal: Const { ty: fn([static generator@src/anchored.rs:27:21: 27:23]) -> impl std::future::Future {std::future::from_generator::<[static generator@src/anchored.rs:27:21: 27:23]>}, val: Value(Scalar(<ZST>)) }
    }

    bb1: {
        return;                          // scope 0 at src/anchored.rs:27:23: 27:23
    }
}

fn future_1::{closure#0}(_1: Pin<&mut [static generator@src/anchored.rs:27:21: 27:23]>, _2: ResumeTy) -> GeneratorState<(), ()> {
    debug _task_context => _4;           // in scope 0 at src/anchored.rs:27:21: 27:23
    let mut _0: std::ops::GeneratorState<(), ()>; // return place in scope 0 at src/anchored.rs:27:21: 27:23
    let mut _3: ();                      // in scope 0 at src/anchored.rs:27:21: 27:23
    let mut _4: std::future::ResumeTy;   // in scope 0 at src/anchored.rs:27:21: 27:23
    let mut _5: u32;                     // in scope 0 at src/anchored.rs:27:21: 27:23

    bb0: {
        _5 = discriminant((*(_1.0: &mut [static generator@src/anchored.rs:27:21: 27:23]))); // scope 0 at src/anchored.rs:27:21: 27:23
        switchInt(move _5) -> [0_u32: bb1, 1_u32: bb2, otherwise: bb3]; // scope 0 at src/anchored.rs:27:21: 27:23
    }

    bb1: {
        _4 = move _2;                    // scope 0 at src/anchored.rs:27:21: 27:23
        _3 = const ();                   // scope 0 at src/anchored.rs:27:21: 27:23
        ((_0 as Complete).0: ()) = move _3; // scope 0 at src/anchored.rs:27:23: 27:23
        discriminant(_0) = 1;            // scope 0 at src/anchored.rs:27:23: 27:23
        discriminant((*(_1.0: &mut [static generator@src/anchored.rs:27:21: 27:23]))) = 1; // scope 0 at src/anchored.rs:27:23: 27:23
        return;                          // scope 0 at src/anchored.rs:27:23: 27:23
    }

    bb2: {
        assert(const false, "`async fn` resumed after completion") -> bb2; // scope 0 at src/anchored.rs:27:21: 27:23
    }

    bb3: {
        unreachable;                     // scope 0 at src/anchored.rs:27:21: 27:23
    }
}

这个 demo crate 中还有一些别的代码,不过对应上面的 MIR 的源码比较简单:


#![allow(unused)]
fn main() {
async fn future_1() {}
}

只是一个简单的空的异步函数,可以看到生成的 MIR 会膨胀很多,如果内容稍微多一点的话通过文本形式不太好看。我们可以指定一下生成的 MIR 的格式,然后将它可视化。步骤大概如下:


#![allow(unused)]
fn main() {
RUSTFLAGS="--emit mir -Z dump-mir=F -Z dump-mir-dataflow -Z unpretty=mir-cfg" cargo build > mir.dot
dot -T svg -o mir.svg mir.dot
}

能够在当前目录下找到 mir.svg,打开之后可以看到一个像流程图的东西(另一幅差不多的图省略掉了,有兴趣的可以尝试通过上面的方法自己生成一份)。

image.png

这里将 MIR 按照基本单位 basic block (bb) 组织,原本的信息都在,并且将各个 basic block 之间的跳转关系画了出来。从上面的图中我们可以看到四个 basic blocks,其中一个是起点,另外三个是终点。首先起点的 bb0 switch(match in rust)了一个变量 _5,按照不同的值分支到不同的 blocks。能大概想象一下这样的代码:


#![allow(unused)]
fn main() {
match _5 {
	0: jump(bb1),
    1: jump(bb2),
    _ => unreachable()
}
}

而 generator 的 state 可以当成就是那个 _5,不同的值就是这个 generator 的各个状态。future_1的状态写出来大概是这样


#![allow(unused)]
fn main() {
enum Future1State {
    Start,
    Finished,
}
}

如果是 §1 中的 async fn foo(),可能还会多一个枚举值来表示那一次 yield。此时再想之前的问题,就能够很自然地想到要跨越 generator 不同阶段的变量需要如何保存了。


#![allow(unused)]
fn main() {
enum FooState {
    Start,
    Yield(usize),
    Finished,
}
}

generator captured

让我们把保存在 generator state 中,能够跨越 .await/yield 被后续阶段使用的变量称为被捕获的变量。那么能不能知道到底哪些变量实际上被捕获了呢?让我们试一试,首先写一个稍微复杂一点的异步函数:


#![allow(unused)]
fn main() {
async fn complex() {
    let x = 0;
    future_1().await;
    let y = 1;
    future_1().await;
    println!("{}, {}", x, y);
}
}

生成的 MIR 及 svg 比较复杂,截取了一段放在了附录中,可以尝试自己生成一份完整的内容。 ​

稍微浏览一下生成的内容,我们可以看到一个很长的类型总是出现,像是这样子的东西:


#![allow(unused)]
fn main() {
[static generator@src/anchored.rs:27:20: 33:2]
// or
(((*(_1.0: &mut [static generator@src/anchored.rs:27:20: 33:2])) as variant#3).0: i32)
}

对照我们代码的位置可以发现这个类型中所带的两个文件位置就是我们异步函数 complex()的首尾两个大括号,这个类型是一个跟我们这整个异步函数相关的类型。 ​

通过更进一步的探索我们大概能猜一下,上面代码片段中第一行的是一个实现了 Generator trait[7] 的匿名类型(struct),而 "as variant#3" 是 MIR 中的一个操作,Projection 的 Projection::Downcast,大概在这里[8]生成。在这个 downcast 之后所做的 projection 的到的类型是我们认识的 i32。综合其他类似的片段我们能够推测这个匿名类型和前面描述的 generator state 是差不多的东西,而各个 variant 是不同的状态元组,投影这个 N 元组能够拿到被捕获的局部变量。 ​

anchored

知道哪些变量会被捕获能够帮助我们理解自己的代码,也能够基于这些信息进行一些应用。 ​

先提一下 rust 类型系统中特殊的一种东西 auto trait[9] 。最常见的就是 SendSync,这种 auto trait 会自动为所有的类型实现,除非显式地用 negative impl opt-out,并且 negative impl 会传递,如包含了 !Send的 Rc 结构也是 !Send的。通过 auto trait 和 negative impl 我们控制一些结构的类型,并让编译器帮忙检查。 ​

比如 anchored[10] crate 就是提供了通过 auto trait 和 generator 捕获机制所实现的一个小工具,它能够阻止异步函数中指定的变量穿过 .await 点。比较有用的一个场景就是异步过程中关于变量内部可变性的获取。 ​

通常来说,我们会通过异步锁如tokio::sync::Mutex 来提供变量的内部可变性;如果这个变量不会穿过 .await point 即被 generator state 捕获,那么 std::sync::Mutex这种同步锁或者 RefCell也能使用;如果想要更高的性能,避免这两者运行时的开销,那也能够考虑 UnsafeCell或其他 unsafe 手段,但是就有一点危险了。而通过 anchored 我们可以在这种场景下控制不安全因素,实现一个安全的方法来提供内部可变性,只要将变量通过 anchored::Anchored这个 ZST 进行标记,再给整个 async fn 带上一个 attribute 就能够让编译器帮我们确认没有东西错误地被捕获并穿越了 .await、然后导致灾难性的数据竞争。就想这样:


#![allow(unused)]
fn main() {
#[unanchored]
async fn foo(){
    {
        let bar = Anchored::new(Bar {});
    }
    async_fn().await;
}
}

而这种就会导致编译错误:


#![allow(unused)]
fn main() {
#[unanchored]
async fn foo(){
    let bar = Anchored::new(Bar {});
    async_fn().await;
    drop(bar);
}
}

对于 std 的 Mutex, RefRefMut 等常见类型,clippy 提供了两个 lints[11] ,它们也是通过分析 generator 的类型来实现的。并且与 anchored 一样都有一个缺点,在除了像上面那样明确使用单独的 block 放置变量外,都会出现 false positive 的情况[12]。因为局部变量在其他的形式下都会被记录下来[13],导致信息被污染。

anchored 目前还缺少一些 ergonomic 的接口,attribute macro 和 ecosystem 的其他工具交互的时候也存在一点问题,欢迎感兴趣的小伙伴来了解一下 https://github.com/waynexia/anchored 👋 (文档 https://docs.rs/anchored/0.1.0/anchored/

Ref

Appendix


#![allow(unused)]
fn main() {
fn future_1::{closure#0}(_1: std::pin::Pin<&mut [static generator@src/anchored.rs:35:21: 35:23]>, _2: std::future::ResumeTy) -> std::ops::GeneratorState<(), ()>
let mut _3: ();
let mut _4: std::future::ResumeTy;
let mut _5: u32;
debug _task_context => _4;
fn complex::{closure#0}(_1: std::pin::Pin<&mut [static generator@src/anchored.rs:27:20: 33:2]>, _2: std::future::ResumeTy) -> std::ops::GeneratorState<(), ()>
let mut _3: impl std::future::Future;
let mut _4: std::task::Poll<()>;
let mut _5: std::pin::Pin<&mut impl std::future::Future>;
let mut _6: &mut impl std::future::Future;
let mut _7: &mut impl std::future::Future;
let mut _8: &mut std::task::Context;
let mut _9: &mut std::task::Context;
let mut _10: std::future::ResumeTy;
let mut _11: isize;
let _12: ();
let mut _13: std::future::ResumeTy;
let mut _14: ();
let mut _15: impl std::future::Future;
let mut _16: std::task::Poll<()>;
let mut _17: std::pin::Pin<&mut impl std::future::Future>;
let mut _18: &mut impl std::future::Future;
let mut _19: &mut impl std::future::Future;
let mut _20: &mut std::task::Context;
let mut _21: &mut std::task::Context;
let mut _22: std::future::ResumeTy;
let mut _23: isize;
let _24: ();
let mut _25: std::future::ResumeTy;
let mut _26: ();
let _27: ();
let mut _28: std::fmt::Arguments;
let mut _29: &[&str];
let mut _30: &[&str; 3];
let _31: &[&str; 3];
let mut _32: &[std::fmt::ArgumentV1];
let mut _33: &[std::fmt::ArgumentV1; 2];
let _34: &[std::fmt::ArgumentV1; 2];
let _35: [std::fmt::ArgumentV1; 2];
let mut _36: (&i32, &i32);
let mut _37: &i32;
let mut _38: &i32;
let _39: &i32;
let _40: &i32;
let mut _41: std::fmt::ArgumentV1;
let mut _42: &i32;
let mut _43: for<'r, 's, 't0> fn(&'r i32, &'s mut std::fmt::Formatter<'t0>) -> std::result::Result<(), std::fmt::Error>;
let mut _44: std::fmt::ArgumentV1;
let mut _45: &i32;
let mut _46: for<'r, 's, 't0> fn(&'r i32, &'s mut std::fmt::Formatter<'t0>) -> std::result::Result<(), std::fmt::Error>;
let mut _47: &[&str; 3];
let mut _48: ();
let mut _49: std::future::ResumeTy;
let mut _50: u32;
}

学习资源

聚焦学习 Rust 的网络资源


使用 Rust 处理信号

信号是 linux 上进程生命周期的重要组成部分,但它们非常复杂,要使用它们需要非常小心。这篇文章介绍了信号处理程序中有挑战的问题之一:信号处理程序的限制。

Unix 信号难以处理的原因总结:

  • 它们是一种全局资源。如果一个库想设置自己的信号处理程序,它就有可能干扰其他库。有可能将以前的信号处理程序串联起来,但这样就不可能以任何实际的方式将旧的信号处理程序从串联链中移除。
  • 它们可以从任何线程中被调用,需要同步。另外,由于它们可以在任何时候中断(interrupt)一个线程,使得大多数处理方式都容易发生竞争。
  • 根据 POSIX 标准,一个信号处理程序中可以调用的函数被限制在很少的函数集中。要强调的是,互斥(或其他锁定机制)以及内存分配和删除是不允许的。

本文还推荐了一个安全正确处理 Unix 信号的库: signal-hook,用来解决上面的问题。

https://www.jameselford.com/blog/working-with-signals-in-rust-pt1-whats-a-signal/

Gitoxide 作者编写的 Rust 学习视频教程

gitoxide 是纯 Rust 实现的 Git 。

视频教程 : Learn Rust with Gitoxide ,源码地址: https://github.com/Byron/learning-rust-with-gitoxide

Horcrux:在Rust中实现Shamir密钥共享(第1部分)

Horcrux: Implementing Shamir's Secret Sharing in Rust (part 1)

就像我在之前一篇博客中提到过的那样,归因于内存安全、强类型、简单易用的单元测试和高性能等特性,我认为Rust是一门极好的用来实现加密算法的编程语言。

Horcruxs是一个用来展示如何用Rust来实现Shamir共享密钥算法的程序例子,目前它还不能被用于生产中。

从这篇博客开始,作者将从基本的数学原理开始讲起,并带领读者学习如何用Rust来实现Shamir共享密钥算法(关于Rust实现部分的讲解需要等到下一篇博客,有兴趣的读者可以关注作者的动态)。

项目地址: https://github.com/gendx/horcrux

PodCast: 和 错误处理工作组的负责人 Jane 聊聊 Rust 错误处理

Rustacean Station 播客之家 是一个社区项目,提供 PodCast。(音频联系听力也不错)。

实验性的 CXX 应用案例 : TouchDesigner 的 Rust 绑定

TouchDesigner 可谓是一款真正意义上定义在新媒体交互领域的可视化节点式创作工具。一个重要特色有众多的API接口(一些预先设定好的函数,简单理解原本是不同厂商生产的硬件或软件可以通过API来配合TouchDesigner使用*)使它可以和市面几乎所有的软硬件实现便捷的信息交换) 。TouchDeisgner 可以实现控制机械、声音、灯光,还有实时视觉的特效渲染都有十分错的表现。

目前还是实验性的库,但是作为学习案例还是不错的。

https://github.com/tychedelia/td-rs

【系列】Rust 中的循环引用结构

Rust 中一共有两类自引用问题: 结构体内部的自引用 和 结构体外的自引用 。 这个系列的文章讨论的是后者,即循环引用。

Rust 优秀博客推荐 第一期 | https://fasterthanli.me/

Rust 社区现在有很多优秀的博客,我感觉做这么一个栏目挺好,给大家推荐一些优秀的Rust 学习博客,也作为一个学习资源的备份。

第一期的主角是 fasterthanli.me ,作者是 Amos 。

弹钢琴、玩乐高长大的90 后游戏 Boy ,之前用 Java,现在用 Rust。关于他更详细的介绍可以参考 : https://fasterthanli.me/about

为什么第一期就推荐他呢? 因为他的文章写的好。他的博客文章写的系统且细腻入微,引导你思考。

如果你学习 Rust ,那么强烈推荐 Amos 的博客: https://fasterthanli.me/

如果你觉得他的文章对你有很大帮助,可以适当支持一下他的写作:https://www.patreon.com/fasterthanlime

Python中调用 Rust

PyO3 使从 Python 调用Rust 代码变得很容易。您可以编写一个Rust库,并依赖PyO3和 maturin (PyO3生态系统的一个支持工具)的组合来编译Rust库,并将其直接作为Python模块安装。除此之外,PyO3可以在Python和Rust之间转换类型,还可以通过一组宏方便地将Rust函数导出到Python。

http://saidvandeklundert.net/learn/2021-11-18-calling-rust-from-python-using-pyo3/

Life simulation

Life simulation, 一个 Rust 编写的 模拟器.

生物可以通过突变和自然选择进化。生物有一个简单的基因组,赋予它们独特的特征.

demo地址

github地址

How to write idiomatic Rust

想写出更多 idiomatic Rust 吗?@matthiasendler 维护了一个经过同行评审的项目,他包括文章、演讲、repos,它们都使用了 Rust。

Redox OS(以及类似的项目)计划如何处理缺乏动态链接支持的问题?

几天前,我问是否有人对C FFI之外的二进制库发行有什么建议,人们不断地给我指出各种 "hack "解决方案,对此我非常感激(尽管这些hack的方法在我的特定情况下没有帮助,C FFI看起来仍然是最不坏的解决方案)。

然而,有一件事让我很吃惊,那就是动态链接(或者说是可预测的ABI,我认为动态链接需要ABI)的想法(也许只是被认为)受到了反对。还有一个事实是,几乎没有人关心在我看来是系统语言设计中的一个巨大漏洞。

TL;DR:

这让我想到:Redox OS或类似的项目(比如今天早些时候u/dptzippy发布的项目)打算如何解决这个问题?他们肯定不会期望人们在某个核心库/缓存中的错误修复后重新编译整个操作系统,或者他们会怎样做?还是我错过了什么?

*我说的是 "可预测 "而不是 "稳定",因为我们不需要一个完全稳定的ABI。即使只是保证如果我用同一版本的编译器编译东西,如果公共API保持不变,公共ABI也会有很大的帮助。

今日reddit上的Rust板块的热帖,贴一下高赞回复:

你好,我是Theseus操作系统的作者。很好的帖子,真的和我心底的想法一致。

缺乏一个稳定的ABI是我们尚未完全解决的一个大障碍。这个问题对于像 Theseus 这样基于安全语言的 SAS/SPL 操作系统来说特别重要,考虑到我们的目标是让编译器对系统各层的所有代码都能可见(visibility)/自我反省(instrospection),没有盲点,就更重要了。我个人认为,对于使用系统调用作为用户应用程序和内核之间的 "稳定 "接口的其他操作系统来说,这不是一个问题,或者在更广泛的意义上,作为两个独立编译的代码片断之间的 "稳定 "连接层,它们在执行时相互依赖。 我记录了我在实现针对现有 Theseus 编译实例为了编译out-of-tree这个crate(以及一个各种启动器 libc 骨架)的工具时所经历的历程和思考过程。这涉及到对cargo的hack式滥用,我没必要推荐其他用例 -- 在我们有限的事例下,它是有效的,因为我们可以控制一切的编译 -- 但相当难以处理。它很可能无法扩展到支持闭源应用程序或库的分发。显然,我意识到,在运行时动态地重新链接单独编译的二进制文件中的符号/依赖关系是非常愚蠢和乏味的,这些符号/依赖关系在构建时是静态链接的,与实际运行的系统实例中存在的这些依赖关系的版本相一致。请注意,我们还没有时间去探索更好的选择。我发这个帖子只是为了说明人们为了解决稳定的ABI问题而可能需要做的事情。

最好的 "替代方案 "基本上是其他人在你上面链接的u/dptzippy的帖子中所建议的—使用某种bindgen来创建一个薄的FFI层,将稳定的C ABI函数作为Rust函数的存根来暴露。然而,这有一个主要的缺点,那就是到处引入不必要的unsafe因素,这有点违反了我上面提到的Theseus的整个目标,因为unsafe因素是一个盲点。

不管怎么说,我只是觉得应该把我的想法说出来,在这里分享我的经验,让对话进行下去,看看其他人有什么意见。

Read More: https://www.reddit.com/r/rust/comments/qr5pyo/how_is_redox_os_and_similar_projects_planning_to/

Rust 中使用 opencv

作者做了一个关于如何在 Rust 中使用 opencv 的视频。

油管视频

kotlin 可以从 Rust 身上学到哪些?

虽然是站在 kotlin 立场上来看的,比较研究也比较有价值。

https://www.beust.com/weblog/2021/11/09/what-kotlin-could-learn-from-rust/

好文推荐:如何改进限制过多的 Rust 库 API

文章非常深入地讨论基于 Rust 中的泛型、dyn trait, slice 等设施的更有宽容度的上层设计。属于进阶文章,值得学习。

https://blog.logrocket.com/improving-overconstrained-rust-library-apis/

使用纯 Rust 开发 ios 应用

属于验证型的项目,做 ios 开发的童鞋可以研究研究。

https://github.com/wooden-worm/ios-app-rs

使用 Prometheus 和 Grafana 监控 Rust Web 应用程序

主要就是一些配置和对应的代码嵌入,写得非常详细。推荐。

https://romankudryashov.com/blog/2021/11/monitoring-rust-web-application/

Neon - 使用 Rust 创建内存和类型安全的 Node.js 模块

Neon除了内存和类型安全之外,应该在 Node.js 中使用 Rust Embedding 的原因还有很多。

  • 并行编程和线程
  • 性能更强
  • 访问操作系统特定的库
  • 通过 Cargo 访问 Rust 的生态系统

https://levelup.gitconnected.com/create-memory-and-type-safe-node-js-modules-with-rust-2c10bba92013

Stack-safety for free?

一篇有意思的文章,演示了如何使用 Rust 的生成器将任何递归函数转换为迭代函数,而几乎无需更改代码。

Stack-safety for free?

IDE 和 宏

我们日常中享用着各种 IDE 的便利性,但是很少人会去实现相关的功能。

在本文中,我们将讨论语言服务器在支持宏时所面临的挑战。这很有趣,因为对于Rust 的 analyzer 来说,宏是最难破解的。

原文链接

Inx 如何做到使用 SymSpell 使 模糊搜索提速超过5倍

本文介绍了 SymSpell这个难以置信的算法, 以及大致了解我们如何在lnx中实现它。

https://chillfish8.ghost.io/fuzzy-searching-5x-faster-with-symspell/

三个Rust代码库的故事

现在是使用Rust的好时机了吗?

Convex的创始团队(从DropBox分离出来的)有使用Rust开发Magic Pocket(Dropbox的地理分布式数据存储系统),Nucleus(重写的Dropbox的同步引擎),Convex(0配置,无限扩容的为响应式应用开发需求设计的后端)。它们是目前世界上负载最大的基于Rust语言的系统之一。

Convex的创始团队分享了使用Rust的好处和一些取舍心得体会。十分推荐。

https://blog.convex.dev/a-tale-of-three-codebases/

使用 Rust 和机器学习的无人机摄影测量尝试

文章作者用无人机拍了一张防水布的照片。使用基本的摄影测量法估计了图片的面积,使用机器学习对图像中的防水布进行了分割,得到的防水布面积为 3.86 m2,而实际面积为 3.96 m2(误差约为 4%)整个预估算法由 Rust 实现。

使用 Bors 合并队列

合并队列是一个与您的版本控制系统(本文将重点关注git和 GitHub)集成的应用程序,它要求以原子方式合并代码更改,从而确保主分支始终包含经过全面测试的代码版本。 许多工程团队和开源项目正在引入合并队列作为其工作流程的一部分。这篇文章探讨了使用合并队列的几个原因,并描述了如何设置 Bors,Rust 语言项目使用的合并队列实现。

使用 Rust 和 WebRTC 为浏览器实现 Raft

介绍了 WRaft 。这是一个基于 WebRTC 的 Raft 实现,用 Rust 编写,用于 WebAssembly。

https://wraft0.eevans.co 上有几个演示应用程序,代码在 GitHub 上开源的。这是一个有趣且具有挑战性的项目,所以我想我会写一篇关于这种体验的博客文章,作为一种“事后分析”。

https://eevans.co/blog/wraft/

Rust Tips : 软链接引发的卡死问题

作者: 吴翱翔

原文: 软链接引发的卡死问题

fs::symlink_metadata

最近写的递归搜索文件夹统计大小的程序,测量某个文件夹大小时卡死

find -type l 去搜索软链接发现有两个链接循环引用:
a 指向 b 的文件夹的同时 b 又指向 a 的文件夹

看源码才知 std::fs::metadata 底层调用 stat() 系统调用会 follow 链接

我改用 symlink_metadata 底层是 lstat() 的总算跳过软链接就解决掉卡死的 Bug

errno 40 ELOOP

Linux glibc 的错误码 errno 40(ELOOP) 的大意是软链接跳转次数过多?

想着标准库会报错 ELOOP 后来野猫老师说 std::fs 为了跨平台做了很多牺牲

(好像 std::fs 的 API 不会处理 ELOOP 错误码? 但我从未见过 errno ELOOP 就不瞎说了)

is_symlink() 永远返回 false?

标准库为了跨平台做出的 trade-off 例如 metadata() 的 is_syslink() 永远返回 false 显然对 Linux 系统而言设计的并不好

因为 metadata/stat 会"吃掉"软链接,相当于 解析成 普通文件也就是软链接的抽象就不存在了

所以 metadata().is_symlink() 在 Linux 上 一定是永远返回 false

所以 Rust 文档很贴心会强调 is_symlink() 要搭配 symlink_metadata() 一起使用才会生效

只有用 symlink_metadata/lstat 不追踪软链接的时候才能获知某个文件到底是不是软链接

当然 man 文档肯定也有提示,只不过我当初看的时候 TLDR 嫌太长没仔细看...

is_symlink 与 is_dir 互斥

在 symlink_metadata()/lstat() 的返回值中

is_symlink() 和 is_dir() 互斥只能其中一个是 true 另一个是 false

is_symlink 还没 stable 要怎么用?

不得不说标准库对各种 LinuxExt 的支持欠缺很多,is_symlink 预计要 2022 年初才能 stable

由于 Metadata 的成员字段全是私有的,只能 transmute 或者找找有没有 UnixExt 之类的


#![allow(unused)]
fn main() {
// 方法一: linux::fs::MetadataExt
let st_mode = std::os::linux::fs::MetadataExt::st_mode(&metadata);

// 方法二: transmute
let st_mode = unsafe { std::mem::transmute::<_, libc::mode_t>(metadata.file_type()) };

// 方法三: 我不用标准库了,直接调用 libc::stat 或 libc::lstat
}

du 命令为啥跟 Metadata::len() 不一样

硬盘 4k 对齐

例如 a.txt 只有一个字符,stat 命令或 fs::Metadata::len() 去看确实大小为 1

但是用 du 去看却说 4k 的大小,原因是 Linux ext4 的文件系统的 block-size 一般是 4k

可以理解成硬盘的最小存储单位是 4k,所有文件占据硬盘的空间都是 4k 的整数倍,好像也叫 4k 对齐

有点像结构体内存布局要跟 CPU 寄存器大小 8 byte 对齐,结构体大小尽量要是 8 byte 的整数倍

如果 du 命令加上指定 block-size 的参数例如 du --apparent-size --block-size 1 就跟 stat 命令一样了

du --bytesdu -bdu --apparent-size --block-size 1 的缩写

/proc 真的是零大小吗

du 命令没骗你,/dev, /proc, /sys 这三个虚拟文件系统还真的在硬盘的大小为零(因为压根没存在硬盘上)

虽然 stat 命令去看这三文件夹绝大部分文件都是零大小,但是例如 "/proc/bus/pci/00/01.2" 还是有大小的

例如 "/proc/config.gz" 存的是 Linux kernel 的编译时参数

有很多参数的值都是 String 类型,所以说 zcat 出来的大小也是「变长」的或者说 不确定的长度

所以 stat 只是说 /proc/config.gz 在当前时刻如果读取出来那么长度会是多少而已

[w@ww repos]$ stat /proc/config.gz 
  File: /proc/config.gz
  Size: 58526           Blocks: 0          IO Block: 1024   regular file
Device: 0,21    Inode: 4026532079  Links: 1
Access: (0444/-r--r--r--)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2021-11-08 21:11:50.742480314 +0800
Modify: 2021-11-08 21:11:50.742480314 +0800
Change: 2021-11-08 21:11:50.742480314 +0800
 Birth: -
[w@ww repos]$ du /proc/config.gz 
0       /proc/config.gz

Rust 源码阅读俱乐部 | 第一期 : 名称解析

说明: 这不是线上沙龙的文字记录,而是我本人会后的学习记录。

引子

最近 Rust 官方发起了 Rust 源码阅读俱乐部 活动,参见 Rust Code Reading Club

此活动目的就是为了让更多人为 Rust 编译器做贡献。由 语言工作组 Leader Niko 亲自来讲解 Rust 编译器中的源码。

这次活动不会有任何回放,只能实时参与。

参与人员条件:

  1. 听得懂英文
  2. 对 Rust 有一定了解,不必要了解 Rust 的所有角落
  3. 不一定需要编译器背景知识,但是需要阅读 Rustc Dev guide (中文版也需要你的贡献: https://github.com/RustcRustc/rustc-dev-guide-zh)

第一期活动:

参与前准备:

  1. 阅读 《the-programmers-brain》 这本书
  2. 阅读 Rustc dev guide ,了解下 Rust 编译过程
  3. 阅读 compiler/rustc_resolve/src 文档相关,了解该库

Rust 飞书群 Rust 源码阅读俱乐部

为了响应官方的活动,我在飞书 Rust 中文社群也准备每周六或日,也组织一次源码阅读在线沙龙。

内容就是跟随官方内容,共同学习。

并且会把每周源码学习记录下来,行为文字输出,分享给大家。

如何加入飞书的Rust 中文社群?

准备工作

书籍推荐《 The Programmer's Brain》

Rust 官方推荐了《The Programmer's Brain》 这本书,在 Manning 的官网可以免费看这本书的在线版本。如果要翻译书名的话,我觉得《编程大脑》这个名字还行? 听说这本书已经被国内出版社引进。

这本书分为四部分:

  1. 如何更好地阅读代码
  2. 关于代码的思考
  3. 编写更好的代码
  4. 关于代码协作

这本书的特点在于,它会介绍不同类型代码和大脑认知思维之间的联系,而不是纯技巧罗列。

既然 Rust 官方推荐这本书,那说明它的内容还是有一定价值,感兴趣可以去看看。

关于编程过程中的三类困扰

这一期分享这本书第一章中提到的编程过程中的三类困扰开发者的问题:

  1. 缺乏知识(Lack of knowledge,对应长期记忆 long-term memory , LTM)。指开发者缺乏基本的编程语言的知识,无法使用或理解基本的语法,而造成的困扰。
  2. 缺乏信息(Lack of information,对应短期记忆, short-term memory, STM)。指开发者缺乏程序要处理的问题领域信息的了解,而造成的困扰。
  3. 缺乏处理能力(Lack of processing power ,对应工作记忆,working memory,WM)。指开发者缺乏对整个编程执行过程的处理能力,而造成的困扰。

这三类问题,不仅仅困扰开发者编写新的程序,还对开发者阅读既有代码造成困扰。

所以,当我们在阅读别人写的源码时,要确保对这三类问题中缺乏的知识有一个预备补充。

我的源码阅读习惯

我阅读源码和读书的方式一致,都是从整体结构到细节。

首先要确保自己对 rustc_resolve 这个库的上下文信息有所了解,也就是上面提到的 编程过程中的三类困扰中的第二类问题要做信息补充。第一类和第三类问题,相信对于 非 Rust 新手应该是可以避开了。一般阅读 Rust 源码最常见的问题就是第二类问题,缺乏对程序要处理问题领域的信息的了解。

官方建议的阅读方法

官方给出的 Rustc 源码阅读第一期 Slides 中,建议采用一种 广-深-广( Broad - deep - broad) 的三段阅读方法。

具体来说:

  1. 广(Broad): 整体了解模块。
  2. 深(Deep): 聚焦于某个函数或小片区域(你感兴趣的,或有疑问的)。
  3. 广(Broad): 回到整个模块中。

按上述三段阅读方法来执行几轮,整个代码就读完了。

Rustc 编译器架构

Rustc Dev Guide 中介绍了 Rust 编译器(Rustc) 的 整体架构。

Rustc 编译器架构不同于传统的编译器架构。传统编译器架构是 基于 遍历 (pass-based) 而设计,Rust 编译器架构则是 基于 按需驱动demand-driven)而设计。

基于遍历 的编译器架构

所谓 遍历(Pass) ,就是对 代码 / AST 扫描并进行处理。

早期的编译器一般都是 Single Pass 的,后来又出现 Multi Pass ,并且分为编译前端和后端。前端负责生成 AST ,而后端用于生成机器码。编译流程的每一步都被抽象为 Pass,这个称呼最早由 LLVM 采用,进而扩展到整个编译原理领域。

遍历 分为两类:

  • 分析(analysis)遍历,负责收集信息供其他 Pass 使用,辅助调试或使程序可视化
  • 变换 (transform)遍历,用于改变程序的数据流或控制流,比如优化等

这两类遍历流程,也对应着编译器的两大阶段:分析阶段和综合阶段。前者从给定的源码文本创建出一个中间表示,后者从中间表示创建等效的目标程序。

编译器前端一般对应于 分析阶段,编译器后端对应于综合阶段。

编译器前端又包括以下几部分:

  1. 词法分析器
  2. 语法分析器
  3. 语义分析器
  4. 中间代码生成器
  5. 代码优化器

而目标代码生成则由后端完成。

在 词法分析、语法分析和语义分析阶段,编译器会创建和维护一个重要的数据结构,用于跟踪变量的语义,即它会存储有关的信息和名称的绑定信息等,叫做 符号表(Symbol Table)。在中间代码生成和目标代码生成过程中会使用它。

传统的基于遍历的编译器架构大概就是这样。

按需驱动的编译器架构

Rust 编译器执行过程:

  • rustc 命令执行编译
  • rustc_driver 来解析命令行参数,相关编译配置被记录于 rustc_interface::Config
  • rustc_lexer 用于词法解析,将源代码文本输出为 词条流 (Token Stream )
  • rustc_parse 为编译过程下一阶段做准备。包含了词法分析的一部分,通过 内置的 StringBuffer 结构体对文本字符串进行验证,以及将字符串进行符号(Symbol)化。 符号化是一种叫做 String interning 的技术,将字符串的值存储一份不可变的副本。
  • rustc_parse 另一部分就是语法解析,使用递归下降(自顶向下)方法进行语法分析,将 词条流转换为 抽象语法树(AST)。入口点是 rustc_parse::parser::Parser 结构体的 Parser::parse_crate_mod()Parser::parse_mod() 关联方法。外部模块解析入口点是rustc_expand::module::parse_external_mod。宏解析器入口点是Parser::parse_nonterminal()
  • 宏展开、AST验证、名称解析,以及 early lint 发生在编译过程的词法分析和语法分析阶段。
  • 此后,将 AST 转为 HIR, 使用 HIR 进行 类型推断](https://rustc-dev-guide.rust-lang.org/type-inference.html)(自动检测表达式类型的过程)、特质求解(将impl与对特质的每个引用配对的过程)和类型检查(转换类型的过程)。
  • 随后,HIR降级到中级中级代表 (MIR)。在此过程中,也构建了 THIR,这是一个更加脱糖的 HIRTHIR (Typed HIR) 用于模式和穷举检查。转换成 MIR 也比 HIR 更方便。
  • MIR 用于借用检查,它基本上是一个控制流图 (CFG)。此外 , MIR 还用于 优化、增量编译、Unsafe Rust UB 检查等。
  • 最后,进行 代码生成 (Codegen)。 将 MIR 转换为 LLVM IR,然后将LLVM IR传递给 LLVM 生成目标机器代码。

另一件需要注意的事情是编译器中的许多值都是intern 的。这是一种性能和内存优化,我们在称为Arena的特殊分配器中分配值。

在 Rust 编译器中,上面说的过程主要步骤都被组织成一堆相互调用的查询。

Rust 编译器使用的是 查询系统(Query System),而非大多数编译原理教科书那种遍历式编译器(基于遍历 的编译器架构 )。 Rust 使用查询系统是为了实现 增量编译功能,即按需编译。

Rust 编译器最初并不是基于查询系统实现的,所以现在整个编译器还在改造为查询系统过程中,上面的整个编译过程都将被改造为基于查询系统。但是截至到 2021年 11月,目前仅是在 HIR LLVM IR 这个过程是基于查询的。

编译器源码结构

Rust 语言项目本身由三个主要目录组成:

  • compiler/,包含源代码rustc。它由许多 crate 组成,这些 crate 共同构成了编译器。
  • library/,包含标准库 ( core, alloc, std, proc_macro, test) 以及 Rust 运行时 ( backtrace, rtstartup, lang_start)。
  • src/ ,包含 rustdocclippycargo、构建系统、语言文档等的源代码。

compiler/包装箱所有名称以rustc_*。这些是大约 50 个相互依存的crate的集合,大小不等。还有rustc crate 是实际的二进制文件(即 main函数);除了调用rustc_driver crate之外,它实际上并没有做任何事情。

Rust 编译器之所以区分这么多 crate ,主要是以下两个因素考虑:

  1. 便于组织代码。编译器是一个巨大的代码库,拆分为多个 crate,更利于组织。
  2. 加速编译时间。多个 crate 有利于增量和并行编译。

但是因为 查询系统是在 rustc_middle 中定义的,而其他很多 crate 都依赖于它,而它又很大,导致编译时间很长。但是将其拆分的工作又没那么简单。

整个编译器 依赖树的顶部是rustc_interfacerustc_driver crate。rustc_interface是围绕查询系统的未稳定包装器,有助于驱动编译的各个阶段。

查询: 按需驱动编译

什么叫查询? 比如有一个查询叫 type_of(def_id),只要给定某个Itemdef-id (标识符定义的索引值 rustc_middle/src/hir/def_id.rs ),就可以得到该Item的类型。查询执行是被缓存的,这也是增量编译的机制。


#![allow(unused)]
fn main() {
let ty = tcx.type_of(some_def_id);
}

但是,如果查询 不在缓存中,则编译器将尝试找到合适的提供程序(provider)。提供程序是一个已定义并链接到编译器某处的函数,该函数包含用于计算查询结果的代码。

由 Rust 编译器的查询系统还衍生出一个通用的按需增量计算框架 Salsa。你可以通过 Salsa BOOK 进一步了解查询系统工作机制。

源码阅读:名称解析组件 rustc_resolve

第一期 源码阅读 的内容聚焦在 rustc_resolve 库,它和 名称解析 相关。

经过前面关于 Rust 编译器架构背景相关了解,我们知道, rustc_resolve 名称解析是发生在 语法分析阶段,为生成最终 抽象语法树而服务,所以,这个库并没有使用到 查询系统。

这也是源码阅读第一期指定这个库的原因吧,不会上来就涉及相对比较复杂的查询系统。

crate 的模块在这里构建,宏的路径、模块导入、表达式、类型、模式、标签(label)和生命周期 都是在这里解析的

类型相关的名称解析(方法、字段、关联项)发生在rustc_typeck 上。

Rust 中的名称解析

经过查阅名称解析相关的资料,了解到 Rust 编译器在 2016 年引入 RFC 1560 来改进名称解析的处理过程。

在这之前,名称解析在编译器的早期被处理,在 AST 降级到 HIR 之后。AST 会被遍历三遍,第一遍用于构建 简化图(reduce_graph),第二遍用于解析名称,第三遍来检查未使用的名称。简化图是程序中所有定义和导入的记录。

RFC 1560 将名称解析分成两个阶段:第一个阶段是与宏展开同时发生,并且会解析导入,来定义一个作用域范围内名称到定义的映射。第二阶段是从整个映射中根据一个名称来查找定义。这样做的目的是解耦。

当前 RFC 1560 已经被实现,在宏扩展期间不会做 全名解析,只解析导入和宏。当整个 AST 被构建以后,才会做全名解析,以便解析整个 crate 中所有的名称。

来看一个示例:


#![allow(unused)]
fn main() {
    type x = u32;
    let x: x = 1;
    let y: x = 2;
}

上面代码是可以合法编译的。其中 x 即是类型的命名,也是一个变量的命名。 Rust 如何进行名称解析来让两个同名的标识符共存呢?

因为 Rust 有不同的命名空间。不同类型的符号存在于不同的命名空间中,比如类型和变量不会发生冲突。每个命名空间都会有自己的独立的 rib (编译器内部引入的抽象作用域概念,比如 let绑定、花括号定义范围、宏定义范围等都是一个 rib )栈。

接下来,我们现在来采用官方建议的三段阅读方法来阅读这个库的源码。

rustc_resolve 的整体模块结构

包括在阅读 rustc_resolve 这个库的时候,我先从其文档着手。一个crate 的文档可以非常清晰的展现出这个 crate 的整体结构。

https://doc.rust-lang.org/stable/nightly-rustc/rustc_resolve/index.html

模块

  • build_reduced_graph 从宏中获取 AST 片段后,此模块中的代码有助于将该片段集成到已经部分构建的模块结构中。
  • check_unused,顾名思义,检测 unused 结构体、枚举和函数
  • def_collector, 给 AST 的节点创建 DefId(定义标识ID)
  • diagnostics,失败时候的诊断信息
  • imports,一揽子和解析 导入 相关的方法和结构
  • late,“后期求解(late resolution)” 是除 导入 和 宏之前大多数名称求解的过程。它在 crate 完全展开并且模块结构完全构建时运行。所以,它只是遍历crate 并解析所有表达式、类型等。为什么没有对应的 early,因为它被分散到 build_reduced_graph.rsmacros.rsimports.rs 中。
  • macros, 一揽子和 解析 宏 相关的方法和结构

结构体

错误类型

  • AmbiguityError,歧义错误
  • BindingError, 绑定错误
  • PrivacyError,可见性错误
  • UseError, use 错误

数据类型

  • DeriveData, Derive 相关数据
  • ExpandHasher ,展开 Hasher
  • ModuleData,模块树某个节点的数据
  • ExternPreludeEnty ,处理 Extern、Prelude 相关
  • NameBinding, 记录可能是私有的值、类型或模块定义
  • UsePlacementFinder,use 相关

命名空间和作用域

  • PerNS,每个命名空间的单独结构,辅助类型
  • ParentScope, 记录scope 访问者的起点
  • Segment,path 段最小呈现

解析器相关

  • Resolver 主要的解析器类型
  • ResolverArenas,为 crate其他部分提供内存,Arena 模型

枚举

这里就不罗列了,和结构体分类类似的一些枚举类型。

Traits

  • ToNameBinding,用来转换areans 引用为 NameBinding 引用

函数

一些辅助函数

类型别名

记录了一些类型别名

依赖crate

rustc_resolveCargo.toml 中可以看到一些依赖 crate:

  • rustc_ast , 该库中定义了 Rust 内部用的 AST 数据结构
  • rustc_arean,编译器内部全局内存池,使用它来分配内存,被分配的内存生命周期为 'tcx
  • rustc_middle,Rust 编译器的 main 库,包含了其他 库中使用的通用类型定义
  • rustc_attr,和编译器内建属性相关
  • rustc_data_structures,定义了很多 编译器内部使用的数据结构,包括一些并行编译需要的线程安全的数据结构
  • rustc_errors,定义了 编译器常用的报告错误的实用工具
  • rustc_expand,用于宏展开。
  • rustc_feature,定义了编译器中的 features gate
  • rustc_hir,定义了 HIR 相关数据类型
  • rustc_index, 对 usize的一个 NewType 包装,用于编译器内部索引
  • rustc_metadata,关于 Rust 静态库、动态库相关的一些链接元信息
  • rustc_query_system,Rust 查询系统
  • rustc_session,编译器编译过程中错误处理和内建lint相关
  • rustc_span,定义源代码位置相关的数据类型,也包括宏卫生相关信息。

以上只是列出一些主要的依赖。截止今天(2021.11.13),看到 名称解析库也加入了 查询系统。

接下来我们看一下 lib.rs 中定义了什么内容。

看得出来,在 lib.rs 中定义的基本都是 上面文档中展示的那些 用于在名称解析过程中使用的结构体或枚举类型。

这里罗列有几个比较容易懂的类型:

Scope 枚举类型:


#![allow(unused)]
fn main() {
// 用于查找名称的特定作用域,只能用于 early 解析过程,比如 导入 和 宏,而不能用于 late 解析。
/// A specific scope in which a name can be looked up.
/// This enum is currently used only for early resolution (imports and macros),
/// but not for late resolution yet.
#[derive(Clone, Copy)]
enum Scope<'a> {
    DeriveHelpers(LocalExpnId),
    DeriveHelpersCompat,
    MacroRules(MacroRulesScopeRef<'a>),
    CrateRoot,
    // The node ID is for reporting the `PROC_MACRO_DERIVE_RESOLUTION_FALLBACK`
    // lint if it should be reported.
    Module(Module<'a>, Option<NodeId>),
    RegisteredAttrs,
    MacroUsePrelude,
    BuiltinAttrs,
    ExternPrelude,
    ToolPrelude,
    StdLibPrelude,
    BuiltinTypes,
}
}

Segment 结构体:


#![allow(unused)]
fn main() {
// path 的最小化呈现 : 段
// 比如  std::sync::Arc  这就是一个 path,其中 `::` 分开的就是段
/// A minimal representation of a path segment. We use this in resolve because we synthesize 'path
/// segments' which don't have the rest of an AST or HIR `PathSegment`.
#[derive(Clone, Copy, Debug)]
pub struct Segment {
    ident: Ident,
    id: Option<NodeId>,
    /// Signals whether this `PathSegment` has generic arguments. Used to avoid providing
    /// nonsensical suggestions.
    has_generic_args: bool,
}
}

**LexicalScopeBinding 枚举:**


#![allow(unused)]
fn main() {
// Item,整个块中可见
// Res,只在定义的地方可见
/// An intermediate resolution result.
///
/// This refers to the thing referred by a name. The difference between `Res` and `Item` is that
/// items are visible in their whole block, while `Res`es only from the place they are defined
/// forward.
#[derive(Debug)]
enum LexicalScopeBinding<'a> {
    Item(&'a NameBinding<'a>),
    Res(Res),
}
}

ModuleKind 枚举

#[derive(Debug)]
enum ModuleKind {
    // 比较有意思的是,我们发现内部模块的分类,还有一种是 匿名模块,一个 block 就是一个匿名模块
    /// An anonymous module; e.g., just a block.
    ///
    /// ```
    /// fn main() {
    ///     fn f() {} // (1)
    ///     { // This is an anonymous module
    ///         f(); // This resolves to (2) as we are inside the block.
    ///         fn f() {} // (2)
    ///     }
    ///     f(); // Resolves to (1)
    /// }
    /// ```
    Block(NodeId),
    /// Any module with a name.
    ///
    /// This could be:
    ///
    /// * A normal module – either `mod from_file;` or `mod from_block { }` –
    ///   or the crate root (which is conceptually a top-level module).
    ///   Note that the crate root's [name][Self::name] will be [`kw::Empty`].
    /// * A trait or an enum (it implicitly contains associated types, methods and variant
    ///   constructors).
    Def(DefKind, DefId, Symbol),
}

AmbiguityKind 枚举


#![allow(unused)]
fn main() {
// 歧义类型
#[derive(Clone, Copy, PartialEq, Debug)]
enum AmbiguityKind {
    Import,  //  多个导入源
    BuiltinAttr, // 内建属性命名冲突
    DeriveHelper, //  derive 内命名冲突
    MacroRulesVsModularized, //   宏名和非宏名冲突
    GlobVsOuter, 
    GlobVsGlob,
    GlobVsExpanded,
    MoreExpandedVsOuter,
}
}

Resolver<'a'> 结构体


#![allow(unused)]
fn main() {
// 这是主要用于解析的结构体,这是一个很大的结构体,包含了名称解析过程需要的数据信息
/// The main resolver class.
///
/// This is the visitor that walks the whole crate.
pub struct Resolver<'a> {
    session: &'a Session,

    definitions: Definitions,

    graph_root: Module<'a>,

    prelude: Option<Module<'a>>,
    extern_prelude: FxHashMap<Ident, ExternPreludeEntry<'a>>,
    // ...
}

// 用于 Resolver 库里的内存分配
pub struct ResolverArenas<'a> {
    modules: TypedArena<ModuleData<'a>>,
    local_modules: RefCell<Vec<Module<'a>>>,
    imports: TypedArena<Import<'a>>,
    name_resolutions: TypedArena<RefCell<NameResolution<'a>>>,
    ast_paths: TypedArena<ast::Path>,
    dropless: DroplessArena,
}

}

接下来就是一些函数,包括 report_errors / report_conflict / add_suggestion_for_rename_of_use 等一些用于编译器诊断信息等函数。

聚焦于问题

我们现在对 名称解析 功能相关背景有了足够且系统的认识。让我们来看一些代码细节。

根据官方阅读源码的建议,现在这一步,应该是 Deep ,聚焦于某些感兴趣或有疑问的函数。

我对 Rustc 如何检查未使用变量比较感兴趣,就让我们聚焦 check_unused.rs 模块中的相关功能。

该模块注释中写道,检查未使用的导入主要分为三步:

第一步: UnusedImportCheckVisitor 来遍历 AST 以查找UseTree内所有未使用的导入,并且记录它们的 use分组和 NodeId 信息。

对于 unused trait 方法,则在 rustc_typeck/check_unused.rs 中检查。

我们从前面背景资料已经知道,check_unused 发生在第三遍 AST 遍历,经过前面两遍遍历,已经构建出了 UseTree,只需要走查 Unused NodeId 即可:


#![allow(unused)]
fn main() {
struct UnusedImport<'a> {
    use_tree: &'a ast::UseTree,
    use_tree_id: ast::NodeId,
    item_span: Span,
    unused: FxHashSet<ast::NodeId>,  // 内部的 快速 HashSet 存储 NodeId 信息
}

impl<'a> UnusedImport<'a> {
    fn add(&mut self, id: ast::NodeId) {
        self.unused.insert(id);
    }
}

struct UnusedImportCheckVisitor<'a, 'b> {
    r: &'a mut Resolver<'b>,
    /// All the (so far) unused imports, grouped path list
    unused_imports: NodeMap<UnusedImport<'a>>,
    base_use_tree: Option<&'a ast::UseTree>,
    base_id: ast::NodeId,
    item_span: Span,
}

impl<'a, 'b> UnusedImportCheckVisitor<'a, 'b> {
    // We have information about whether `use` (import) items are actually
    // used now. If an import is not used at all, we signal a lint error.
    fn check_import(&mut self, id: ast::NodeId) {
        /* do something */
    }
    
}

// 实现 rustc_ast 中 定义 的 Visitor trait, 这是访问者模式在 Rust 编译器中的应用
// Visitor trait 中定义了 AST Node的访问钩子方法,这样具体的访问者就可以实现 Visitor 的特定方法来进行具体的访问
// 这里具体的访问者就是 UnusedImportCheckVisitor
impl<'a, 'b> Visitor<'a> for UnusedImportCheckVisitor<'a, 'b> {
      fn visit_item(&mut self, item: &'a ast::Item) { /* do something */ }
      fn visit_use_tree(&mut self, use_tree: &'a ast::UseTree, id: ast::NodeId, nested: bool) { /* do something */ }
}
}

第二步: calc_unused_spans ,遍历上一步收集的 NodeId 关联的 Span


#![allow(unused)]
fn main() {
fn calc_unused_spans(
    unused_import: &UnusedImport<'_>,
    use_tree: &ast::UseTree,
    use_tree_id: ast::NodeId,
) -> UnusedSpanResult {
    /* do something */
    match use_tree.kind {
        ast::UseTreeKind::Simple(..) | ast::UseTreeKind::Glob => { /* do something */ }
        ast::UseTreeKind::Nested(ref nested) => {/* do something */}
    }
    /* do something */
}
}

第三步: check_crate,根据生成的数据发出诊断信息


#![allow(unused)]
fn main() {
impl Resolver<'_> {
    // 为 Resolver 实现 check_unused 方法
    crate fn check_unused(&mut self, krate: &ast::Crate) {
        /* do something */
        // 检查导入源
        for import in self.potentially_unused_imports.iter() {
            match import.kind {
                ImportKind::MacroUse => { /* do something */ }
                ImportKind::ExternCrate { .. } =>  { /* do something */ }
            }
        }
        let mut visitor = UnusedImportCheckVisitor {
            r: self,
            unused_imports: Default::default(),
            base_use_tree: None,
            base_id: ast::DUMMY_NODE_ID,
            item_span: DUMMY_SP,
        };
        visit::walk_crate(&mut visitor, krate);
        for unused in visitor.unused_imports.values() {
             let mut fixes = Vec::new(); // 为 cargo fix 记录
             /* do something */
             // 计算 unused 位置信息
             let mut spans = match calc_unused_spans(unused, unused.use_tree, unused.use_tree_id) {
             	/* do something */
             }
             /* do something */
             // 发出诊断消息
             visitor.r.lint_buffer.buffer_lint_with_diagnostic(
                UNUSED_IMPORTS,
                unused.use_tree_id,
                ms,
                &msg,
                BuiltinLintDiagnostics::UnusedImports(fix_msg.into(), fixes),
            );
        }
    }
}
}

通过阅读这部分代码,我们大概了解了 rustc_resolve 库的组织结构:

  • lib.rs 中定义主要的 Resolver相关类型和方法
  • 在不同的 Resolver 功能模块中实现具体的 解析方法,比如 check_unused

回到整体模块

然后,我们再回到整体模块中来了解其他部分的代码。

我们知道第一遍 AST 遍历会构建 简化图 ( reduced graph),那么这个过程肯定是对应于 build_reduced_graph.rs 模块。

我们可以看到该模块引入了 rustc_ast / rustc_expand/ rustc_data_structures::sync::Lrc (等价于 Arc)/ rustc_hir::def_id 等相关组件,可想而知,它是和宏展开相关,并且也支持并行编译。


#![allow(unused)]
fn main() {
impl<'a> Resolver<'a> {
    crate fn define<T>(&mut self, parent: Module<'a>, ident: Ident, ns: Namespace, def: T) where
        T: ToNameBinding<'a>,
    {
        let binding = def.to_name_binding(self.arenas);
        let key = self.new_key(ident, ns);
        // https://github.com/rust-lang/rust/blob/master/compiler/rustc_resolve/src/imports.rs#L490
        // try_define 定义于 imports 模块,解析导入的时候用于检查绑定的名称
        if let Err(old_binding) = self.try_define(parent, key, binding) {
            // 如果命名有冲突,这里会调用 report_conflict 来发出错误报告
            self.report_conflict(parent, ident, ns, old_binding, &binding);
        }
    }
    fn get_nearest_non_block_module(&mut self, mut def_id: DefId) -> Module<'a>  {/* do something */}
    crate fn get_module(&mut self, def_id: DefId) -> Option<Module<'a>>  {/* do something */}
    crate fn expn_def_scope(&mut self, expn_id: ExpnId) -> Module<'a>  {/* do something */}
    crate fn build_reduced_graph(
        &mut self,
        fragment: &AstFragment,
        parent_scope: ParentScope<'a>,
    ) -> MacroRulesScopeRef<'a>  {/* do something */}
    
}
}

实现了构建 简化图需要的 Resolver 相关方法。 具体细节我们就不再看了,了解整体流程即可。

总结

官方的源码阅读俱乐部活动,旨在带动 Rust 编译器贡献者踊跃去对 Rust 编译器做贡献。但是具体的源码阅读过程,猜测不会太详细,还有很多东西是需要私下去了解的。

本文作为一次源码阅读学习的记录分享,旨在抛砖引玉,如果文中有发现错误,欢迎反馈。

参考链接

【我读】Rust 语言应该使用什么内存模型?

原文:https://paulmck.livejournal.com/66175.html

引子

《深入理解并行编程》 作者 Paul E. McKenney 最近几天写了这篇文章。可以通过知乎这篇文章《深入理解并行编程》笔记来了解下这本书。

该书 PDF 版本下载,放心,这本书是公开免费,下载链接是作者公开的。

Paul 日常应该工作于 Facebook,拥有 30 年的 Cpp 并发工作经验,是 Linux 内核内存模型(LKMM)的主要维护者。他写这篇文章的初衷是想为 Rust 社区的并发相关日常工作提供一个良好的起点。Paul 非常关注 Rust 进入 Linux 内核这件事,他在之前的博客文章中提出一个见解:在 Linux 内核中减少 Unsafe Rust 代码的一种方法就是将 Unsafe 操作下推到原子操作、内存屏障和锁原语中。

他的博客里也写了很多 Rust 和 Linux 的文章: https://paulmck.livejournal.com/tag/rust

本篇是我学习这篇文章过程中的阅读记录,内容不仅仅是这篇文章,同时也参考了文章中所提及的一些资料。

Rust 当前的内存模型

在 Rust 标准库 std::sync::atomic模块写道:“These orderings are the same as the C++20 atomic orderings.” 。意思就是说, Rust 现在的内存顺序采用的是 C++ 20 的原子内存顺序。

在 Rust nomicon 一书中的 Atomics 章节, 谈到,实际上这个模型相当复杂,并且已知有几个缺陷

Rust 很明显地只是从 C++20 继承了原子的内存模型。这并不是因为该模型特别出色或易于理解。事实上,这个模型非常复杂,并且已知有几个缺陷。相反,这是对每个人都不擅长原子建模这一事实的务实让步。至少,我们可以从围绕 C/C++ 内存模型的现有工具和研究中受益。(你会经常看到这个模型被称为“C/C++11”或“C11”。C 只是复制了 C++ 内存模型;C++11 是该模型的第一个版本,但它已经收到了一些从那以后的错误修正。)

试图完全解释本书中的模型是相当无望的。它是根据引发疯狂的因果关系图来定义的,需要一整本书才能以实用的方式正确理解。如果你想要所有的细节,你应该查看C++ 规范

C++ 内存模型从根本上是试图弥合我们想要的语义、编译器想要的优化以及我们的硬件想要的不一致的混乱之间的差距。我们只想编写程序,让它们完全按照我们说的去做。

Linux 内核内存模型(LKMM)

LKMM 中最容易令人生畏的地方包括:

  1. 控制依赖
  2. 地址和数据相关性

控制依赖

在许多弱序架构的汇编语言层面上,条件性分支充当了一个非常弱、非常便宜但非常有用的内存屏障指令。它命令任何返回值进入条件代码的load,在分支指令完成后执行的所有store之前,无论分支是否被采纳。ARMv8也有一个条件移动指令(CSEL),提供类似的排序。

因为条件分支的排序属性涉及从加载(Load)到分支以及从分支到存储(Store)的依赖关系,并且因为分支是控制流指令,所以这种排序被称为控制依赖。

因为编译器不理解它们,所以控制依赖非常脆弱。但是它们的成本非常低,因此它们被用于 Linux 内核中一些非常重要的快速路径。

Rust 可以通过多种方式处理控制依赖:

  1. 简单的解决方案是将控制依赖项的load提升到smp_load_acquire()。这有效,但在某些架构上增加了指令开销,并不必要地限制了所有架构上的编译器优化(但公平地说,ARMv8 在使用链接时优化构建时正是这样做的)。另一个困难是确定(无论是手动还是自动)确切地需要提升哪些READ_ONCE()调用。
  2. 一个更简单的解决方案是将包含控制依赖项的代码分类为 Rust 范围之外的核心 Linux 内核代码。由于在 Linux 内核中很少使用控制依赖项,因此 Rust 采取这种方法不会损失太多。此外,还有可能创建更高级别的 C 语言原语,其中包含所需的控制依赖项,然后将其包装起来以供 Rust 语言使用。
  3. Linux-kernel-in-Rust 开发人员的角度来看,最好的方法是让 Rust 强制执行memory-barriers.txt 中记录的代码样式限制。然而,这种方法有可能被证明是不平凡(non-trivial)的。
  4. 等待编译器后端了解控制依赖项。这可能需要等待一段时间,尤其是考虑到在 C/C++ 标准的当前命名法中甚至难以定义控制依赖项。

地址和数据相关性

地址依赖涉及一个加载,它的返回值直接或间接决定了后面加载或存储的地址,这导致较早的加载在后面的加载或存储之前被排序。数据依赖涉及一个加载,它的返回值直接或间接决定了后面的存储存储的值,这导致加载在存储之前被排序。这些被 RCU (Read Copy Update,读复制更新)大量使用。尽管它们不像控制依赖那样脆弱,但编译器仍然不知道它们。因此,仍然需要小心,如rcu_dereference.rst Linux 内核编码指南中所见。与控制依赖一样,地址和数据依赖的开销非常低,但与控制依赖不同,它们在 Linux 内核中被大量使用。

Rust 内存模型应该将其对 Linux 内核原子操作的支持限制为提供排序的那些。这些将是返回值的非宽松(non-relaxed)读-修改-写 (RMW) 原子操作以及非返回值的 RMW 原子操作的_acquire()_release()变体。允许无序 RMW 操作与组合内存屏障的组合也可能有意义,例如,atomic_inc()后跟smp_mb__after_atomic(),但将它们组合包装为单个 Rust 可访问原语会更有意义。这个组合的 Rust 原语将不再是无序的,因此可以作为一个有序的单元包含在 Rust 内存模型中。或者,无序原子操作(relax)可能会降级为 Rust 的unsafe模式。

因此,从 LKMM 开始,我们得到了一个支持有序原子操作和锁定的模型,可能包括unsafe模式下的无序原子操作。

C++ 内存模型

Cpp 的 memory_order_relaxed 会导致 out-of-thin-air (OOTA) 的值出现。所以,Rust 中 memory_order_relaxed 建议只允许在 unsafe 代码中使用。

而安全的 Rust 代码应该允许使用这四个顺序:memory_order_acquire、`memory_order_releasememory_order_acq_rel\

建议

从 Linux 内核内存模型 和 Cpp 内存模型的一些问题,作者对 Rust 中的内存顺序改进提出以下建议:

  1. 可以在 SafeUnSafe 下使用 锁原语。
  2. memory_order_seq_cstmemory_order_acquire、`memory_order_releasememory_order_acq_rel可以在SafeUnSafe\ 下使用。
  3. memory_order_relaxedmemory_order_consume 只可在 UnSafe 下使用。

在安全模式下采用C/C++内存模型中没有问题的部分,其余部分采用UnSafe模式。这种方法允许人们用 Rust 编写每天的并发算法,并确信所产生的代码在未来仍然可以工作。

就看 Rust 社区如何选择了。

来自 Rust 社区的声音

有另一位读者对他给 Rust 社区的建议做了如下回复(以下摘要):

Relax 读取的 OOTA 行为本身不会违反 Rust 的任何内存安全保证 —— 一般而言,Relax 内存操作仍不会引起未定义的行为。因此,没有真正的理由从 Rust 安全代码中排除更多“异乎寻常”的操作。

Relax 的操作也有实际的编译器支持,并且在许多对性能非常重要的情况下很有用,尤其是在弱排序的硬件上,因此出于纯粹的教学原因将它们全部升级为更昂贵的操作似乎很愚蠢。

所以这就是 Rust 现在的位置:它基于 C++ 内存模型不是因为人们认为没有更好的东西(尤其是在 Relaxed 周围),也不是因为人们不接受显式内存或控制依赖,而是因为那些是编译器目前提供的实际“工作”并且可以证明事情的原语。

至于“Rust 现在真的需要它的内存模型吗”:我认为是的,它确实需要。但它不需要成为最终的内存模型。

仁者见仁,智者见智吧。

参考

Rust AEAD benchmark与Const generics

作者: 王江桐

本篇将会简要介绍什么是《This week in Rust》,第416篇推文。 中有关Rust密码学相关的两个库,RustCrypto和Ring,中AEAD算法的性能测试,以及Const generics相关的内容。

AEAD Benchmark

带有关联数据的认证加密(Authenticated Encryption with Associated Data)是一种能够同时保证数据的保密性、 完整性和真实性的一种加密模式。"A Conventional Authenticated-Encryption Mode"中提到,当人们将对称加密与消息认证码手动组合到一起时非常容易出错。很多实际攻击会通过这类错误导致的身份验证(包括SSL与TLS)的不正确实现或缺失,从而攻击安全协议和应用程序。因此,通常来说,比起分组加密,AEAD能提供更高的安全性。

AEAD通常有三种模式:

  • Encrypt-then-MAC (EtM),最后发送消息 = 明文加密 + 密文MAC,当MAC算法强不可伪造时是安全的。是ISO/IEC 19772:2009规定的六种认证加密方法中的一种,并且在RFC 7366中作为TLS与DTLS拓展发布。
  • Encrypt-and-MAC (E&M),最后发送消息 = 明文加密 + 明文MAC,但是E&M方法本身并未被证明是强不可伪造的。
  • MAC-then-Encrypt (MtE),最后发送消息 = 明文与明文MAC拼接之后计算MAC。尽管理论安全,但是SSL/TLS中将其实现为MAC-then-pad-then-encrypt,也就是说,明文会先填充到加密函数的块大小,之后再进行相应计算,而填充错误通常会导致接收方发现可检测到的错误,从而造成Padding oracle attack,例如Lucky Thirteen attack。

在Rust生态中,通常使用的已认证AEAD加密如下:

测试源码可见作者github repo。总体性能表统计如下:

100B1kB100kB1MB10MB100MB
RustCrypto’s XChaCha20-Poly1305 v0.8.2928.91 ns (102.67 MiB/s)1.9851 us (480.41 MiB/s)116.50 us (818.58 MiB/s)1.1579 ms (823.59 MiB/s)11.571 ms (824.17 MiB/s)117.74 ms (809.99 MiB/s)
RustCrypto’s ChaCha20-Poly1305 v0.8.2805.40 ns (118.41 MiB/s)1.8660 us (511.08 MiB/s)116.02 us (821.96 MiB/s)1.1522 ms (827.68 MiB/s)11.517 ms (828.02 MiB/s)117.87 ms (809.11 MiB/s)
RustCrypto’s AES-256-GCM v0.9.4154.27 ns (618.20 MiB/s)910.31 ns (1.0231 GiB/s)84.677 us (1.0999 GiB/s)844.85 us (1.1023 GiB/s)8.4719 ms (1.0993 GiB/s)88.666 ms (1.0504 GiB/s)
ring’s ChaCha20-Poly1305 v0.16.20195.90 ns (486.81 MiB/s)701.99 ns (1.3267 GiB/s)51.594 us (1.8051 GiB/s)563.75 us (1.6520 GiB/s)5.1991 ms (1.7913 GiB/s)54.879 ms (1.6971 GiB/s)
ring’s AES-256-GCM v0.16.20214.48 ns (444.64 MiB/s)455.70 ns (2.0437 GiB/s)26.476 us (3.5177 GiB/s)264.13 us (3.5260 GiB/s)2.6474 ms (3.5179 GiB/s)30.450 ms (3.0585 GiB/s)

Const generics

在版本1.51之后,Rust支持const generics使用。对于Rust 1.56(Rust 2021)而言,此后并没有对于Const generics进行更新,因此这个特性仍与1.51时相同。

Const generics使得用户定义struct时,可以使用常量变量定义一些类型,例如:

struct ArrayPair<T, const N: usize> {
    left: [T; N],
    right: [T; N],
}

impl<T: Debug, const N: usize> Debug for ArrayPair<T, N> {
    // ...
}

T是一个普通的类型参数,N则是常量泛型。不过,当N不同时,实例并不属于同一个类型,例如ArrayPair<_, 16>并不与ArrayPair<_, 32>同属于同一个类型。

这样做的好处之一是,当用户想在列表上实现trait,当不能使用常量泛型时,用户必须对于每一个可能大小的列表都实现这个trait。标准库中的一些trait甚至也受此限制,在Rust 1.47之前很多trait只能在长度小于等于32的列表上实现。

同时这样做带来了另一个便利:限制trait的实现条件。通常情况下,限制可以这样达成:

struct Assert<const COND: bool> {}

trait IsTrue {}

impl IsTrue for Assert<true> {}

不过同样,如果是对于列表等不定长度的类型实现trait,那么列举这些类型将会非常麻烦。常量变量可以非常快速地解决这个问题:

/// The struct we're going to conditionally implement for
#[derive(Clone, Debug)]
struct Foo<const N: usize>{ inner: [usize; N] }

impl<const N: usize> Copy for Foo<N> where Assert::<{N < 128}>: IsTrue {}

这个例子限定了只有包含长度为128以下的列表的Foo实例才支持Copy。当长度过大时,编译器将会检测到这个错误,并给出相应告警。

另一个好处是,可以避免运行时检查。例如:

/// A region of memory containing at least `N` `T`s.
pub struct MinSlice<T, const N: usize> {
    /// The bounded region of memory. Exactly `N` `T`s.
    pub head: [T; N],
    /// Zero or more remaining `T`s after the `N` in the bounded region.
    pub tail: [T],
}

let slice: &[u8] = b"Hello, world";
let reference: Option<&u8> = slice.get(6);
// We know this value is `Some(b' ')`,
// but the compiler can't know that.
assert!(reference.is_some())

let slice: &[u8] = b"Hello, world";
// Length check is performed when we construct a MinSlice,
// and it's known at compile time to be of length 12.
// If the `unwrap()` succeeds, no more checks are needed
// throughout the `MinSlice`'s lifetime.
let minslice = MinSlice::<u8, 12>::from_slice(slice).unwrap();
let value: u8 = minslice.head[6];
assert_eq!(value, b' ')

虽然Const generics提供了很多便利,目前的版本只支持数字类型,包括且不限于有符号/无符号数字、char、bool等,并且不支持非常复杂的泛型表达式。常量泛型只能由常量参数初始化,例如单一的常量参数,常量定值(例如有符号/无符号数字、char、bool),或是常量定值的表达计算式,但是不能由涉及到常量泛型的表达计算式。例如:

fn foo<const N: usize>() {}

fn bar<T, const M: usize>() {
    foo::<M>(); // ok: `M` is a const parameter
    foo::<2021>(); // ok: `2021` is a literal
    foo::<{20 * 100 + 20 * 10 + 1}>(); // ok: const expression contains no generic parameters
    
    foo::<{ M + 1 }>(); // error: const expression contains the generic parameter `M`
    foo::<{ std::mem::size_of::<T>() }>(); // error: const expression contains the generic parameter `T`
    
    let _: [u8; M]; // ok: `M` is a const parameter
    let _: [u8; std::mem::size_of::<T>()]; // error: const expression contains the generic parameter `T`
}

在未来这些限制或许会被取消,Rust版本会支持新的功能,不过由于这些限制涉及到很多的设计问题,或许这个功能的拓展还需要很长的一段时间。不过由于便利性,很多三方库也实现了Rust标准库不支持的一些常量泛型类似的功能,例如typenum。尽管它的编译性能以及编译器集成并不是特别良好,但是它仅依赖于libcore,并且支持在编译时检查的类型级数字,也就是将数字作为类型而不是值使用。

引用

Benchmarking symmetric encryption (AEAD) in Rust,https://kerkour.com/rust-symmetric-encryption-aead-benchmark/

Authenticated encryption,https://en.wikipedia.org/wiki/Authenticated_encryption

A Conventional Authenticated-Encryption Mode,https://csrc.nist.gov/csrc/media/projects/block-cipher-techniques/documents/bcm/proposed-modes/eax/eax-spec.pdf

Const generics MVP hits beta!,https://blog.rust-lang.org/2021/02/26/const-generics-mvp-beta.html

It's Time to Get Hyped About Const Generics in Rust,https://nora.codes/post/its-time-to-get-hyped-about-const-generics-in-rust/

This Week in Rust #415:Rust与Linux信号

作者: 王江桐

本篇将会简要介绍什么是《This Week in Rust》,第415篇推文中有关rustc安全漏洞,以及Rust与信号的内容。

rustc安全漏洞

在11.1日,Rust公开了已修复的rustc安全漏洞CVE-2021-42574,详细介绍可参阅"Security advisory for rustc (CVE-2021-42574)"或张汉东老师的博文《特洛伊之源| 在 Rust 代码中隐藏无形的漏洞》。 简要概括一下,整体情况大概为:

  • Unicode编码同时支持从右往左以及从左往右的语言语序。为了支持在某个语序中书写相反语序的句子,Unicode额外支持名叫双向覆写的不可见代码点。

  • 这些不可见代码点会在某些编译器和代码查看器中影响可见文本的输出,导致编译器编译的代码和用户看到的代码可能完全不同,从而导致实际执行的代码和用户看到代码并不一样。理论而言,这个漏洞实际上算是Unicode的漏洞而非rustc的漏洞,此类攻击方法对于C、C++、Java、Python等语言都有效。

  • 为了解决这个安全问题,Rust1.56.1引入了两个新的lint来检测此类问题,并且拒绝潜藏问题的代码。如果代码中必须使用此类代码点,Rust编译器将提议使用对应的转义序列。同时,Rust项目组筛查了crate.io上所有的源码,保证这些代码不存在此类攻击。

  • 但是这两个lint不能防止同形字符攻击,Rust编译器会认为这些方法是同名的并发出告警,需要用户自行注意。

  • 此外,Rust 1.0.0以及1.56.0之间的版本则没有这个特性,如果不进行此项检查,代码可能遭受安全攻击。

Linux信息

信号

信号(Signal)常用于Unix、类Unix、以及其他兼容便携式操作系统接口(POSIX)的操作系统,是进程间通信的一种方式,也是一种软中断,采用异步通信方式。信号由操作系统生成,发送给某个进程或同一进程内的某个线程,中断进程正常的控制流程,来触发某个事件。任何非原子操作在这个过程中都将被中断。此时,如果进程注册了该信号的处理函数,那么处理函数将被执行;没有处理函数时,程序可以忽略这个信号,或者执行默认的处理函数。不过,SIGKILLSIGSTOP无法被截获并处理,这两个信号向内核和超级用户提供了进程终止和停止的可靠方法,如果忽略了,那么这个进程就变成了没人能管理的的进程,显然是内核设计者不希望看到的场景。

由于信号是异步的,在处理某一个信号的过程中,进程可能收到另一个信号,低级别信号会被高级别的信号中断,从而中断前一个信号的处理。信号可以造成进程中系统调用的中断,不过在信号处理完后会重新开始未完成的系统调用。因此,信号处理函数应该没有任何不想要的副作用,比如,errno的改变、信号掩码的改变、信号处理方法的改变,以及其他全局进程性质的改变。在信号处理函数内使用不可重入函数,如mallocprintf,也是不安全的。

信号可分为不可靠信号和可靠信号。不可靠信号意为信号可能会丢失,一旦信号丢失了,进程并不能知道信号丢失;相反,可靠信号,也称为阻塞信号,则不会丢失。当发送了一个阻塞信号,并且该信号的动作时系统默认动作或捕捉该信号,如果信号从发出以后会一直保持未决的状态,直到该进程对此信号解除了阻塞,或将对此信号的动作更改为忽略。

信号的阻塞和未决是通过信号的状态字来管理的,该状态字是按位来管理信号的状态。如果该信号被设置为阻塞的状态,也就是阻塞状态字对应位为1,那么信号未决状态字(pending)相应位会被内核设置为1;如果该信号阻塞解除了,也就是阻塞状态字设置为了0,那么信号未决状态字(pending)相应位会被内核设置为0,表示信号此时可以抵达了,也就是可以接收该信号了。阻塞状态字用户可以读写,未决状态字用户只能读,是由内核来设置表示信号递达状态的。每个信号都有独立的阻塞字,规定了当前要阻塞地达到该进程的信号集。

sigprocmask()系统调用可以阻塞和恢复信号的传递。在C中,signal.h头文件中包含了信号对应的正整数以及信号处理函数。

信号的计算和内存占用很小。信号类似于中断(Interrupt),不同之处在于中断由处理器调解并由内核处理,而信号由内核调解并由个体进程处理,内核调解可能通过系统调用。内核可以将中断作为信号传递给导致中断的进程,例如SIGSEGVSIGBUSSIGILLSIGFPE。而对于异常,当其产生时,内核的异常处理程序有时不能解决问题,因此只能通过发送信号将异常交给进程自己处理。异常与信号的具体对应关系在不同的CPU架构上不同。

Linux常用不可靠信号

如下表格列出了Linux前31个信号,它们都是不可靠信号。信号的数值越小,则优先级越高。在Linux,可通过终端输入kill -l查看所有的signal信号。Linux信号的详解可见Linux manual page "signal(7)"

表中默认行为定义如下:

  • 终止:进程异常终止。进程终止的结果和调用_exit()是一样的,除了终止可以向wait()waitpid()返回导致进程终止的信号。

  • 终止(核心转储):进程异常终止。这种进程中止的过程根据实现有所不同,一般会创建一个核心文件。

  • 忽略:进程忽略该信号。

  • 暂停:进程被暂停,非终止。

  • 继续:进程恢复执行。

信号可移植代号默认行为描述
SIGHUP1终止挂起
SIGINT2终止终端中断信号
SIGQUIT3终止 (核心转储)终端退出信号
SIGILL4终止 (核心转储)非法的指令
SIGTRAP5终止 (核心转储)追踪/断点陷阱
SIGABRT6终止 (核心转储)进程终止信号
SIGBUS7终止 (核心转储)访问内存对象未定义区域
SIGFPE8终止 (核心转储)错误的算术运算
SIGKILL9终止杀死 (无法被捕获或忽略的信号)
SIGUSR110终止用户自定义信号1
SIGSEGV11终止 (核心转储)非法的内存引用
SIGUSR212终止用户自定义信号2
SIGPIPE13终止写入一个没有连接另一端的管道
SIGALRM14终止计时器告警
SIGTERM15终止终止信号
SIGSTKFLT16终止x86协处理器栈错误。但是因为协处理器不会出错,此信号不会隐式产生
SIGCHLD17忽略子进程终止、暂停、继续
SIGCONT18继续如果被暂停,重新继续执行
SIGSTOP19暂停暂停执行(无法被捕获或忽略的信号)
SIGTSTP20暂停终端中止信号
SIGTTIN21暂停后台进程尝试读
SIGTTOU22暂停后台进程尝试写
SIGURG23忽略I/O有紧急数据(带外数据)到达当前进程
SIGXCPU24终止 (核心转储)超出CPU时间限制
SIGXFSZ25终止 (核心转储)超出文件大小限制
SIGVTALRM26终止虚拟定时器超时
SIGPROF27终止性能调优定时器超时
SIGWINCH28忽略端窗口大小已变化
SIGIO29终止文件描述符准备就绪, 可以开始进行输入/输出操作
SIGPWR30终止电源错误
SIGSYS31终止 (核心转储)错误的系统调用

Windows与信号

Windows系统实际上也有一套信号处理机制,或是某些机制处理产生信号的错误,但是并不完全与类Unix系统相同。例如,当用户按下CTRL+C时,在Linux系统下,SIGINT信号会被发送,但是在Win32系统环境下,操作系统会特殊生成一个线程来处理此信号。对于单线程应用来说,这会使得这个应用变成多线程,并且可能产生未知的错误。

中断

中断(Interrupt)是由硬件或软件所发送的一种称为IRQ(中断请求)的信号,所有的linux操作系统都是基于中断驱动的。中断事件由硬件或CPU产生,当发生时,会挂起正常的程序运行流程,并运行对应的中断处理器(Interrupt Handler)。当处理器运行完毕,之前的执行流程,也就是正常的程序运行流程,会被继续。

从来源上分类,中断可被分为两类:

  • 软中断,由软件发送的中断请求信号,也被称为同步中断,一般由执行某个指令产生。软中断再分类,可分为两类:

    • 异常(Exception),通常是处理器在执行指令时,发现条件不符合要求,例如零除数或是某些系统调用。
    • 普通中断(Normal Interrupts)。
  • 硬中断,由硬件发送的中断请求信号,也被称为异步中断,当I/O设备需要获取CPU资源或电源故障时产生。例如当包到达时,网卡会产生一个中断。从能否暂延或临时无效化中断上分类,硬中断可被分为另外两类:

    • 可屏蔽的(Maskable),大部分中断都可屏蔽,也就是说,我们可以延时运行中断处理器。

    • 不可屏蔽的(Non-Maskable)。

软中断和硬中断其他的一些差异可见下表:

Hardware InterruptSoftware Interrupt
由外部设备或硬件产生,与外部硬件通信的一种方式由计算机内部系统产生,与内核通信或产生系统调用的一种方式,尤其在处理错误或异常的时候
不增加程序计数器(Program Counter)增加程序计数器
由外部设备产生,例如对开始I/O的请求,或是硬件失败可由汇编指令产生
在所有中断中优先级最低在所有中断中优先级最高
异步事件同步事件
例:键盘输入或鼠标移动例:所有的系统调用

异常

异常(Exception)也被称为同步中断。异常的来源通常有两个:

  • 执行指令时,处理器察觉到的程序条件异常,含三类:
    • 故障(Fault)
      • 故障通常在执行指令前被报告,并且通常可以被纠正,例如页错误(Page Fault)。在正常运行情况下,EIP寄存器里存储的是CPU下次要执行的指令的地址。在故障情况下,EIP中保存的是当前出错的指令地址,因此在纠正故障后,此条指令会被重新执行。
    • 陷阱(Trap)
      • 陷阱通常在执行指令后被报告,例如调试器陷阱。EIP寄存器中保存的是错误指令之后的下一个指令地址,而非当前地址。
    • 终止(Abort)
      • 终止通常意味着不可恢复的异常,会直接中断程序运行。
  • 编程中出现的异常
    • 例如,int n

Rust与信号

使用Rust生态表示信号以及对应处理机制是相对比较困难的。理由有三:

  • 当收到信号时,当前运行线程被打断,转向运行信号处理函数。当信号处理函数运行结束,线程应当回到原来运行的位置继续运行。使用Rust的类型系统无法表示这种关系,因此无法安全地与主逻辑线程共享状态。
  • 所有线程都应当共享信号处理函数,如果状态也是共享的,这会导致数据竞争。
  • 在处理信号时,无法保证程序不会收到其他的信号。因此,信号处理函数中所有的指令都应当是可重入的,以便在之后恢复状态以及继续运行,或者是在接收到相同信号时,终止目前运行的实例,并且开始另一个新的同类实例的运行。然而,类似于SIGSEGV的信号可能会导致用户自定义的信号处理函数隐式调用其他不安全的函数。

Linux的Man 7 signal-safety给出了两种可能的解决方案:

  • 保证信号处理函数只调用异步信号安全函数,并且对于主程序的全局变量而言,信号处理函数本身是可重入的。
  • 信号处理函数或许可以获取某些全局数据。当使用不安全或处理这些全局数据的函数时,阻塞主程序获取信号。

第二个方案不适用于信号的真正处理。对于第一个方案,Rust确实会保证全局变量的修改是线程安全的,但是Rust并不保证函数是可重入的。通常情况下,建立与POSIX互斥锁之上的Rust互斥锁(Mutex)被用来保证全局变量修改的线程安全,然而对于同一个线程而言,如果试图对互斥锁再次进行上锁,那么就会陷入死锁的僵局。对于这点,更具体的讨论可见Mutex.rs源码中注释。

因此,函数甚至是不可重入的。此外,对于第一点要求“异步信号安全”,锁并不在这个列表之中。锁、stdio、malloc、以及其他的一些函数都不是异步信号安全的。不过,使用Rust处理信号仍旧是可能的。"Working with signals in Rust"一文中除了分析以上内容,也给出了两个可能的实际解决方案:C FFI和Rust库,signal-hook。

C FFI

在Rust中,可以使用C的函数来处理信号。例如,C中signal函数的接口如下:

void (*signal(int sig, void (*func)(int)))(int);

Rust的libc对应接口如下:


#![allow(unused)]
fn main() {
pub unsafe extern "C" fn signal(
   signum: c_int, 
   handler: sighandler_t
) -> sighandler_t

type sighandler_t = size_t;
}

extern关键字表示函数来源于C。引号C("C")的部分定义了外部函数使用的应用二进制接口(Application Binary Interface, ABI),以及在汇编层面上如何调用该函数。其他的接口和Rust C FFI可见C的signal.h以及Rust libc文档。

一个非常简单的例子是,如果想在用户按下Ctrl + C时告诉用户程序仍在执行,可以根据如上接口,注册如下信号处理器:

extern "C" fn handle_interrupt(sig: libc::c_int) { // 1
    println!("Sorry we didn't get the chance to finish");
}

fn main() {
    println!("Hello");

    // All libc functions are unsafe
    unsafe { 
        libc::signal(libc::SIGINT, handle_interrupt as libc::sighandler_t); // 2
    }

    std::thread::sleep(Duration::from_secs(10)); 
    println!("Goodbye");
}

当然,这个例子并不能满足要求信号处理机制的要求,因为println!显然调用stdio,而这并不异步信号安全。C中同样提供了逃脱异步信号安全函数限制的两个方式,write的管道,以及使用signalfd。在Rust中,这些方式同样可以使用libc库调用。

自写管道

虽然很多函数都被认为是异步信号不安全,但是write并不在此列,意味着用户可以在信号处理函数中调用write和合适的文件描述符,在信号处理函数以及其他线程之间设置管道,从而传递信息。例如如下C例子,信号处理函数和主线程设置了管道,以便于主线程知晓何时信号处理器被调用:

static int pipefds[2] = {0};

void signal_handler(int signum)
{
    uint8_t empty[1] = {0};
    int write_fd = pipefds[1];
    write(write_fd, empty, 1);                 // 3
}

void handle_signal(int read_pipe_fd)
{
    uint8_t buff[1] = {0};
    read(read_pipe_fd, buff, 1);               // 4
    printf("Received signal\n");
}

int main()
{
    pipe(pipefds)                              // 1
    fcntl(pipefds[1], F_SETFD, O_NONBLOCK);

    int read_fd = pipefds[0];

    signal(SIGINT, signal_handler);            // 2

    while(true) {
        handle_signal(read_fd);
    }
}

signalfd

除了使用write建立管道,signalfd可以直接创建文件描述符来接受信号。使用selectpollepoll等事件循环相关函数,用户可以非常轻松地处理文件描述符。例如:

void handle_signal(int);

int main()
{
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);

    sigprocmask(SIG_SETMASK, &mask, NULL);                // 1

    int signal_fd = signalfd(-1, &mask, SFD_NONBLOCK);    // 2

    struct pollfd pollfd = {
        .fd = signal_fd,
        .events = POLLIN,
    };

    while (poll(&pollfd, 1, 5000) > 0)                    // 3
    {
        handle_signal(pollfd.fd);
    }
}

void handle_signal(int signal_fd)
{
    struct signalfd_siginfo siginfo;
    ssize_t s;
    s = read(signal_fd, &siginfo, sizeof(siginfo));       // 4
    if (s != sizeof(siginfo))
    {
        perror("read");
        exit(1);
    }

    uint32_t signo = siginfo.ssi_signo;
    char *signame = strsignal(signo);

    printf("Received signal %d (%s)\n", signo, signame);
}

在以上程序中,信号会被阻塞,写入信号文件描述符,并且按顺序等待被同步读。当事件循环告知主线程有信号待读时,信号处理函数被调用。通过信号文件描述符,信号处理函数可以获取同一个信号,并且处理信号。

信号文件描述符的使用类似于普通的代码,因此用户可以享有使用锁、信息序列、内存分配等等便利功能。不过,除此之外,信号文件描述符也有它的缺点,例如与子进程交互时可能会出现问题。子进程会继承父进程的信号阻塞设置,但是信号文件描述符并不会继承这个设置。因此,子进程要不不能正常接受信号,要不无法正常处理信号。当然,生成子进程时可以手动清楚继承的设置,并且想办法自己管理信号,但是这非常麻烦,甚至有人因此说信号文件描述符毫无用处。因为这个原因,大部分子进程仍然使用自写管道而不事信号文件描述符来解决信号处理的问题。

Rust: signal-hook

除了C FFI,Rust库signal-hook提供了信号处理需要的相应功能:

  • 循环接受信号的迭代器,主线程可以从迭代器中获取下一个信号。
  • 通过自写管道,主线程可从信号处理器中获取信号信息。
  • 相应的适应器使得signal-hook也可以在tokio的事件循环和异步标准库中使用。

对于之前的C例子,如果使用signal-hook库改写,那么代码如下:

use signal_hook::consts::*;
use signal_hook::iterator::Signals;
use crossbeam::channel::{select, self, Sender, Receiver, after};
use std::time::Duration;

fn await_interrupt(interrupt_notification_channel: Sender<()>) {
    let mut signals = Signals::new(&[                              // 1:装载信号处理函数,形容libc::sigaction调用
        SIGINT,
    ]).unwrap();

    for s in &mut signals {                                        // 2:通过管道传递信号信息
        interrupt_notification_channel.send(());                   // 3:通过channel在线程间传递信息
    }
}

fn main() {
    let (interrupt_tx, interrupt_rx) = channel::unbounded();
    std::thread::spawn(move || { await_interrupt(interrupt_tx)});

    let timeout = after(Duration::from_secs(5));
    loop {
        select! {
            recv(interrupt_rx) -> _ => {                           // 4:当主线程接受到中断,循环将结束
                println!("Received interrupt notification");
                break;
            },
            recv(timeout) -> _ => {                                // 5:同时也有超时机制
                println!("Finally finished the long task");
                break;
            }
        }
    }
}

引用

特洛伊之源| 在 Rust 代码中隐藏无形的漏洞,imghttps://zhuanlan.zhihu.com/p/428305373

Working with signals in Rust - some things that signal handlers can't handle,https://www.jameselford.com/blog/working-with-signals-in-rust-pt1-whats-a-signal/

Unix信号,https://zh.wikipedia.org/wiki/Unix%E4%BF%A1%E5%8F%B7

Signal (IPC),https://en.wikipedia.org/wiki/Signal_(IPC)

signal(7) — Linux manual page,https://man7.org/linux/man-pages/man7/signal.7.html

Linux 信号(signal),https://www.jianshu.com/p/f445bfeea40a

Interrupts,https://linux-kernel-labs.github.io/refs/heads/master/lectures/interrupts.html

简单介绍下linux下的中断(interrupt),https://blog.51cto.com/noican/1355357

Difference between Hardware Interrupt and Software Interrupt,https://www.geeksforgeeks.org/difference-between-hardware-interrupt-and-software-interrupt/

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


Dockeye 一个用来管理 Docker 的 GUI 应用

@wwojtekk 用egui开发了一个管理Docker的GIU应用,作者知道目前有一个适用于 macOS 和 Windows 的 Docker 桌面应用程序,但在涉及小型 QOL 功能以及更高级的高级用户功能和元数据时,它的功能有些欠缺,它还缺少 Linux 版本。目前这个项目实现的功能包括:

  1. 删除/暂停/启动/检查容器
  2. 显示容器的日志
  3. CPU/内存/网络/磁盘使用情况和图表统计
  4. 删除/拉取/导出/检查图像
  5. 浏览图像历史和摘要

Diem (国外/开源/Libra/ 超主权货币)

Diem 最近又上了 GitHub 趋势榜,不知道是不是和 Facebook 进军元宇宙有关系。在最新的新闻里看到: “Facebook 元宇宙将拥有一个以 Diem 货币为中心的经济体,在许可的 Diem 网络上发行基于 NFT 的资产。”

前身是 Libra,其使命是建立一套简单的、无国界的货币和为数十亿人服务的金融基础设施。他们努力打造一个新的去中心化区块链、一种低波动性加密货币和一个智能合约平台的计划,以期为负责任的金融服务创新开创新的机遇。

他们认为,应该让更多人享有获得金融服务和廉价资本的权利,每个人都享有控制自己合法劳动成果的固有权利。他们相信,开放、即时和低成本的全球性货币流动将为世界创造巨大的经济机遇和商业价值,并坚信人们将会越来越信任分散化的管理形式。全球货币和金融基础设施应该作为一种公共产品来设计和管理。所有人都有责任帮助推进金融普惠,支持遵守网络道德规范的用户,并持续维护这个生态系统的完整性。

最新状态:Facebook旗下数字货币项目Diem放弃申请瑞士支付牌照:专注美国市场 (2021年05月13日 ),但还未在美国拿到支付牌照。

snarkOS: 用于私有应用程序的去中心化操作系统

它构成了Aleo的支柱,并使应用程序能够以可公开验证的方式验证和存储状态。

https://github.com/AleoHQ/snarkOS

ic: 互联网计算机协议

互联网计算机是世界上第一个以网络速度运行的区块链,可以无限制地增加其容量。就像互联网(由许多遵守 TCP/IP 协议的机器组成)和区块链协议(如比特币和以太坊)。

https://github.com/dfinity/ic

Graph Node 索引来自区块链(如以太坊)的数据并通过 GraphQL 提供服务

Graph是一种使用 GraphQL 在以太坊和 IPFS 上快速构建去中心化应用程序 (dApps) 的协议。

Graph Node 是一个开源 Rust 实现,它以事件为源以太坊区块链来确定性地更新可以通过 GraphQL 端点查询的数据存储。

有关详细说明和更多上下文,请查看入门指南

https://github.com/graphprotocol/graph-node

Chainsaw : 快速搜索和搜寻 Windows 事件日志

Chainsaw 提供强大的“第一响应”功能,可快速识别 Windows 事件日志中的威胁。它提供了一种通用且快速的方法来搜索关键字的事件日志,并通过使用内置检测逻辑和通过对 Sigma 检测规则的支持来识别威胁。

https://github.com/countercept/chainsaw

engula: 又一个 Rust 实现的数据库

Engula 是一种存储引擎,它使工程师能够以更少的努力和更多的信心构建可靠且经济高效的数据库。目前还处于 0.1 版本。

Engula 的设计目标如下:

  • Elastic:利用云上的弹性资源
  • 自适应:适应动态工作负载和多样化的应用程序
  • 可扩展:提供可插拔的 API 和模块以进行定制
  • 平台独立:允许在本地主机、本地服务器和云平台上灵活部署

https://github.com/engula/engula

推荐项目 | 基础工具库

聚焦 Rust 生态库


ouch: 压缩和解压缩工具

ouch代表Obvious Unified Compression Helper,是一个 CLI 工具,可帮助您压缩和解压缩多种格式的文件。

格式.tar.zip.bz, .bz2.gz.lz4.xz, .lzma.zst
支持的

https://github.com/ouch-org/ouch

Meadowlark: 数字音频工作站

Meadowlark 是一个(目前未完成)项目,旨在成为适用于 Linux、Mac 和 Windows 的免费开源 DAW(数字音频工作站)。它的目标是成为世界各地艺术家的强大录音、作曲、编辑、声音设计、混音和母带制作工具,同时还具有直观性和可定制性。

可以关注下此项目,看看它的目标能否实现

https://github.com/MeadowlarkDAW/Meadowlark

gpgpu-rs : 基于 wgpu 实现的 GPU 计算库

wgpu 是一个安全且可移植的 GPU 抽象库,实现 WebGPU API 。gpgpu-rs 在其之上创建了一个简单的 GPU 计算库。

libracity: 基于 Bevy 实现的益智游戏

LibraCity - 针尖上的城市规划!LibraCity 是一款益智游戏,您可以用一根针在平衡状态下建造一座城市。要取得成功,请利用建筑物的各种重量,并在确保城市保持稳定的同时放置它们。

https://github.com/djeedai/libracity

Chaos Theory : 一款小游戏

用 Rust 和 WASM 实现的一款 H5 小游戏,利用了一个小型的游戏引擎 ld-game-engine

soldank: 是 Soldat 游戏的 Rust 实现

soldat 是 是一款独特的2D(侧视图)多人动作游戏。 它受到了Liero,Worms,Quake和Counter-Strike等最好的游戏的影响,并提供了大量的鲜血和肉体的快节奏游戏体验。soldank 是这款游戏的 Rust 实现。

https://github.com/smokku/soldank

plotters :快速绘图工具

Plotters 是一个绘图库,设计用于以纯 Rust 渲染图形、绘图和图表。支持各种类型的后端,包括位图、矢量图、活塞窗口、GTK/Cairo 和 WebAssembly。

在有的人看来,这仍然不是"非常"便捷,尤其是在 debug 的时候,仍然需要花费一点精力来绘制。 debug_plotter 这个库帮我们实现了这个需求,通过 plot! 宏,可以快速完成图像绘制,可视化程序运行情况。

fn main() {
    for a in 0usize..10usize {
        let b = (a as f32 / 2.0).sin() * 10.0;
        let c = 5 - (a as i32);
        debug_plotter::plot!("My Plot"; a, b, c);
    }
}

https://crates.io/crates/debug_plotter

rnet - 从 .net 中调用 Rust

这个 crate 原理仍然是将rust编译成 cdylib,然后在 c# 里面调用。不过将这个过程变得更容易了一点。

https://docs.rs/rnet/0.1.0/rnet/index.html

Rust-CUDA: 完全用 Rust 编写和执行快速 GPU 代码的库和工具生态系统

Rust CUDA 项目是一个旨在使 Rust 成为使用 CUDA 工具包进行极快 GPU 计算的一级(tier-1)语言的项目。它提供了将 Rust 编译为极快的 PTX 代码的工具,以及使用现有 CUDA 库的库。

过去,通用高性能 GPU 计算是使用 CUDA 工具包完成的。CUDA 工具包主要提供了一种使用 Fortran/C/C++ 代码与单一源的 CPU 代码协同进行 GPU 计算的方法。它还提供了许多库、工具、论坛和文档来补充单源 CPU/GPU 代码。

CUDA 是唯一的 NVIDIA 工具包。已经提出了许多用于跨平台 GPU 计算的工具,例如 OpenCL、Vulkan Computing 和 HIP。然而,到目前为止,CUDA 仍然是此类任务最常用的工具包。这就是为什么必须让 Rust 成为与 CUDA 工具包一起使用的可行选择。

然而,使用 Rust 的 CUDA 在历史上一直是一条非常崎岖的道路。到目前为止,唯一可行的选择是使用 LLVM PTX 后端,但是,LLVM PTX 后端并不总是有效,并且会为许多常见的 Rust 操作生成无效的 PTX,而且近年来已经一次又一次地表明随着 rust-gpu(用于 Rust -> SPIR-V)等项目的出现,GPU 上的 Rust 需要专门的解决方案。

我们希望通过这个项目,我们可以推动 Rust GPU 计算行业向前发展,并使 Rust 成为处理此类任务的优秀语言。Rust 提供了很多好处,例如__restrict__每个内核的性能优势、出色的模块/板条箱系统、使用 分隔 CPU/GPU 代码的不安全区域unsafe、高级包装器到低级 CUDA 库等。

https://github.com/Rust-GPU/Rust-CUDA

minijinja: Rust 实现的一款最小依赖的模板库

作者是 Python 框架 Flask 的作者,minijinja 基于 Python 的 Jinja2 模板引擎语法和行为而实现。

https://github.com/mitsuhiko/minijinja

appflowy: 一个开源的基于Rust和Flutter的Notion替代产品

@annieanqi 开源了一个基于 Rust 和 Flutter 的 Notion 替代产品 appflowy,目前是MVP状态,该项目还处于一个比较早的状态,欢迎各位开发者提出自己的想法。

Quinn: 0.8.0发布,正式支持 QUIC v1

Quinn是IETF QUIC传输协议的纯rust、异步兼容的实现。

目前刚发布 0.8.0 版本,正式支持了 QUIC v1标准.

https://github.com/quinn-rs/quinn/releases/tag/0.8.0

Docker Activity:获取docker容器的统计数据和能耗

Get stats and the energy consumption of your docker containers

Docker activity是一种用于监视Docker容器统计信息并输出其能耗的工具。

当前它还处于早期阶段,对于demoing来说,它很快就会变得更好。。。

Docker Activity

pixels - 一个微小的硬件加速像素帧缓冲区。

  • 建立在现代图形 API 的基础上

    • wgpu:Vulkan、Metal、DirectX 12、OpenGL ES3。

    • DirectX 11、WebGL2 和 WebGPU 支持正在进行中。

  • 使用您自己的自定义着色器来获得特殊效果。

  • 完美像素边界上的硬件加速缩放。

  • 支持非方形像素纵横比。

https://github.com/parasyte/pixels

Lemmy v0.14.0 发布

Lemmy 是一款 Reddit 替代品,使用 Rust 编写。在刚刚发布的 v0.14.0 中完成了与 Mastodon 和 Pleroma 联合。这意味着,如果你是 Mastodon 和 Pleroma 的用户,那么你也可以:

  • 查看 Lemmy 社区,用户配置文件,帖子和评论
  • 关注 Lemmy 社区获取新的帖子和评论
  • 答复(提及)双向生效,包括通知

Lemmy Online: https://lemmy.ml

Lemmy (a federated reddit alternative written in Rust) Release v0.14.0: Federation with Mastodon and Pleroma: https://lemmy.ml/post/89740

hRPC:面向用户的 API 的简单 RPC 系统

hRPC 是一个 RPC 系统,在 Harmony 仓库中,作者们一直在用 hRPC 开发他们的去中心化聊天协议。hRPC 使用 PB 作为协议中间件,并支持流式传输。hRPC 使用 REST 对普通一元请求建模,使用 WebSockets 对流请求建模。因此,为不支持它的语言编写一个库应该很容易。

elfshaker 一个高性能的针对二进制文件进行了优化的版本控制系统

400 GiB -> 100 MiB,访问时间为1s+;当应用于 clang 构建时。

Github链接

semver-explain: 语义版本解释工具

semver-explain,是一个 CLI 工具,用于解释语义版本控制 (Semantic Versioning)要求,将其转换为仅具有小于、大于或等于比较器的形式,其中主要、次要和补丁版本都是指定的。

SemVer 需求的确切含义解释来自于 Cargo。尤其是它不处理连字符范围或来自 JS node-semver 库的 x-ranges。


#![allow(unused)]
fn main() {
$ semver-explain "^1.4.0"
>=1.4.0, <2.0.0
$ semver-explain "~0.5.3"
>=0.5.3, <0.6.0
$ semver-explain "5.6.*"
>=5.6.0, <5.7.0
}

https://github.com/alilleybrinker/semver-explain

pigeon-rs:电子邮件自动化工具

Pigeon 是一种命令行工具,用于以廉价且高效的方式自动化电子邮件工作流程。

比如,查询时事通讯的订阅者并向他们发送电子邮件:

pigeon send-bulk \
    sender@your-domain.com \
    --receiver-query "select email from user where newsletter_confirmed = true" \
    --message-file "message.yaml" \
    --display \
    --assume-yes

结果如下:

> Display query result: shape: (4, 1)
+------------------------------+
| email                        |
| ---                          |
| str                          |
+==============================+
| "marie@curie.com"            |
+------------------------------+
| "alexandre@grothendieck.com" |
+------------------------------+
| "emmy@noether.com"           |
+------------------------------+
| "elie@cartan.com"            |
+------------------------------+
> Sending email to 4 receivers ...
marie@curie.com ... ok
alexandre@grothendieck.com ... ok
emmy@noether.com ... ok
elie@cartan.com ... ok

Massage 配置文件格式如下:

# You can leave EITHER the text OR the html empty, but not both. Ideally, fill out both.
# You MUST provide a subject. Personalize message by wrapping variables in curly brackets, eg. {firstname}.

message:
    # The subject of your email
    subject: "Test subject"
    # The plaintext version
    text: "This is a test message (plaintext)."
    # The html version
    html: "This is a test message (html)."

GitHub:https://github.com/quambene/pigeon-rs

LibertyOS - 使用Rust语言从头开发的操作系统

LibertyOS完全从头开发,具体面向桌面还是IoT目前并不清楚,项目还在早期,感兴趣可参与。

https://github.com/LibertyOS-Development/kernel

Persy - 一个单文件数据库存储,类似于 sqlite

Persy完全由Rust开发,目前已发布1.1版本。在性能上有一些改进。

https://persy.rs/posts/persy-1.1.html

yap:一个小型的、基于迭代器的、零依赖的解析库

Yap是一个小型的、零依赖的解释器库,灵感来自于parser-combinator。我试图以简洁性换取简单性,并以迭代器接口的灵活性为基础。它的目标是使解析字符串和切片变得容易,并且易于使用。

在过去的几个星期里,我一直在构建和使用它,我认为它已经准备好向其他可能有兴趣使用它的人发布了!

下面是它的用法:


#![allow(unused)]
fn main() {
use yap::{ 
    // This trait has all of the parsing methods on it:
    Tokens,
    // Allows you to use `.into_tokens()` on strings and slices, 
    // to get an instance of the above:
    IntoTokens
};

// Step 1: convert our input into something implementing `Tokens`
// ================================================================

let mut tokens = "10 + 2 x 12-4,foobar".into_tokens();

// Step 2: Parse some things from our tokens
// =========================================

#[derive(PartialEq,Debug)]
enum Op { Plus, Minus, Multiply }
#[derive(PartialEq,Debug)]
enum OpOrDigit { Op(Op), Digit(u32) }

// The `Tokens` trait builds on `Iterator`, so we get a `next` method.
fn parse_op(t: &mut impl Tokens<Item=char>) -> Option<Op> {
    match t.next()? {
        '-' => Some(Op::Minus),
        '+' => Some(Op::Plus),
        'x' => Some(Op::Multiply),
        _ => None
    }
}

// We also get other useful functions..
fn parse_digits(t: &mut impl Tokens<Item=char>) -> Option<u32> {
    let s: String = t
        .tokens_while(|c| c.is_digit(10))
        .collect();
    s.parse().ok()
}

// As well as combinator functions like `sep_by_all` and `surrounded_by`..
let op_or_digit = tokens.sep_by_all(
    |t| t.surrounded_by(
        |t| parse_digits(t).map(OpOrDigit::Digit),
        |t| { t.skip_tokens_while(|c| c.is_ascii_whitespace()); }
    ), 
    |t| parse_op(t).map(OpOrDigit::Op)
);

// Now we've parsed our input into OpOrDigits, let's calculate the result..
let mut current_op = Op::Plus;
let mut current_digit = 0;
for d in op_or_digit {
    match d {
        OpOrDigit::Op(op) => {
            current_op = op 
        },
        OpOrDigit::Digit(n) => {
            match current_op {
                Op::Plus => { current_digit += n },
                Op::Minus => { current_digit -= n },
                Op::Multiply => { current_digit *= n },
            }
        },
    }
}
assert_eq!(current_digit, 140);

// Step 3: do whatever you like with the rest of the input!
// ========================================================

// This is available on the concrete type that strings
// are converted into (rather than on the `Tokens` trait):
let remaining = tokens.remaining();

assert_eq!(remaining, ",foobar");
}

amdfand v1.0.6发布

新版本的AMD显卡冷却和电压守护程序。

当前版本包括:

每月安全公告

来自RustSecGitHub 咨询


11 月

本月总结来自于 https://rustinblockchain.org/newsletters/rib-newsletter-29/

特洛伊之源| 在 Rust 代码中隐藏无形的漏洞

作者: 张汉东


今天 Rust 官方安全应急响应工作组发布了 Rust 编译器安全公告(CVE-2021-42574) ,宣称该工作组被告知 Rust 编译器存在一个安全问题,这个安全问题可能会导致 「人眼审查」的 Rust 代码 与 「编译后给机器用的」机器码 完全不一致。

这个安全问题并不是 Rust 编译器自身的缺陷导致的,而是由 Unicode 相关的双向算法 漏洞(CVE-2021-42574)而导致的。

而利用这个 Unicode 漏洞的攻击方法叫做:特洛伊之源(Trojan Source)

特洛伊之源(Trojan Source): 无形的漏洞

在 特洛伊之源(Trojan Source)的官方网站上链接了一篇论文: 特洛伊之源 :无形的漏洞(Trojan Source: Invisible Vulnerabilities)

这篇论文来自于剑桥大学的研究人员,在其中介绍了在程序源代码中隐藏一种人眼无法识别的漏洞的攻击方法,就是 特洛伊之源,POC 攻击代码已发布在 GitHub 上。

这种被称为 特洛伊之源 的攻击方法利用的是字符编码标准 Unicode 中的微妙之处,利用方向覆盖、同形异义等创造出对编译器和人类代码审查员视觉上存在差异的源代码,人眼看不出漏洞,但对编译器来说逻辑编码顺序和显示的顺序是不同的。这种攻击方法对 CC++C#JavaScriptJavaRustGo Python 都有效,研究人员已经将漏洞报告给了相关项目。

特洛伊之源攻击方法的危害是非常大的。试想一下,你从 GitHub 下载的依赖 crate 中,可能植入了被进行特洛伊之源攻击的代码。而这种攻击,可以通过 GitHub 这类开源平台中的 PR 像任意开源项目进行攻击,防不胜防。

POC 代码

让我们看一下 POC 中的 Rust 代码。POC Rust 代码一共给了四个案例,其中有三个案例是可以执行成功的。

移花接木:通过注释进行攻击

该文件包含双向Unicode文本,其解释或编译方式可能与下面的内容不同。要审查,请在一个能显示隐藏的Unicode字符的编辑器中打开该文件。

1

// 该文件包含双向Unicode文本,其解释或编译方式可能与下面的内容不同。要审查,请在一个能显示隐藏的Unicode字符的编辑器中打开该文件。
// 执行输出 
fn main() {
    let is_admin = false;
    /*‮ } ⁦if is_admin⁩ ⁦ begin admins only */
        println!("You are an admin.");
    /* end admins only ‮ { ⁦*/
}

代码看上去好像 不会执行打印语句,实际上,if 表达式和块早已被注释。

暗渡陈仓:字符串攻击

2

fn main() {
    let access_level = "user";
    if access_level != "user‮ ⁦// Check if admin⁩ ⁦" {
        println!("You are an admin.");
    }
}

这段代码不应该输出里面的打印语句,但实际上输出了。因为开发者看上去条件表达式里 确实等于 "user",但实际上不等于"user",因为这个字符串里被加了隐藏字符。

李代桃僵:同形字符攻击

3

fn say_hello() {
    println!("Hello, World!\n");
}

fn say_һello() {
    println!("Goodbye, World!\n");
}

fn main() {
    say_һello();
}

图片中代码里有两个 say_hello 函数,但实际上函数名不一样,main 函数里调用的是被篡改过的函数,有种李代桃僵的感觉。

当然,有时候 Rust 编译器也能发现问题,编译出错,但上面这三个例子,则是完全编译通过。

特洛伊之源的漏洞机制

看到这里,你是否感觉特洛伊之源攻击虽然危害性很大,但其实也很有意思?

其实这个 Unicode 漏洞只要是玩过 QQ / 微信的,大部分人都利用过:你肯定改过你的qq签名,让大家看到语句是从右到左的逆向显示。

严格来说,它也不算是漏洞,只是 Unicode 的一种特性。但因为 Unicode 的设计缺陷,就很容易被利用实施恶意攻击,而 视觉欺骗,是最常见考虑最多的 Unicode 安全问题。

Unicode(统一码、万国码、单一码)是一种在计算机上使用的字符编码。它为每种语言中的每个字符设定了统一并且唯一的二进制编码,以满足跨语言、跨平台进行文本转换、处理的要求。1990年开始研发,1994年正式公布。

为了兼容世界各地不同的语言书写习惯,Unicode 引入了双向算法来控制文本显示顺序。大多数语言的文本在水平方向都是按从左到右(Left To Right,简称LTR)的顺序显示字符的,但也有不少语言是按从右到左(Right To Left,简称RTL)的顺序显示字符的(比如阿拉伯语、希伯来语)。当然还有按垂直方向书写的文本,比如中国古代的汉字、蒙语就是从上到下从右到左书写的。

Unicode 标准内规定两种文本顺序:在内存中表示的顺序为逻辑顺序,而最终显示的文本顺序为显示顺序。文本的逻辑顺序和显示顺序并不一定会一致。

Unicode 中如果出现 双向文本 ,若不明确确定文本的显示顺序,在显示的时候就会出现歧义。特洛伊之源攻击利用的就是这一点!

Unicode 引入 定向格式化字符 来解决这个问题。比如,使用RLO控制符来控制字符从右向左显示,PDF表示RLO的终止字符,那么ab cd RLO EF GH PDF x,将被显示为ab cd HG FE x

除了双向显示之外,Unicode 还提供一些同形字符 和隐藏字符等,这些都是构成了 视觉欺骗 攻击的特性基础。

Rust 的解决方式

为了评估生态系统的安全性,官方分析了 crates.io 上发布的所有 crate 版本(截至 2021 年 10 月 17 日),只有 5 个 crate 的源代码中包含受影响的代码点,但没有一个是恶意的。

官方就在发布此安全公告的当天(2021.11.01),已经发布了 1.56.1 版本,通过新增两个 lint 来解决此问题。lint 将阻止包含这些码点的源代码文件被编译,从而保护 Rust 开发者免受攻击。如果你的代码对代码点有合法用途,官方建议将它们替换为相关的转义序列。错误消息也将建议使用正确的转义。

1.56.1 相关 PR

  • [[stable] Fix CVE-2021-42574 and prepare Rust 1.56.1](https://github.com/rust-lang/rust/pull/90460/files)

被加入的两个lint 是 :

#![deny(text_direction_codepoint_in_comment)]
fn main() {
    println!("{:?}"); // '‮');
}

#![deny(text_direction_codepoint_in_literal)]
fn main() {
    println!("{:?}", '‮');
}

这两个 lint 拒绝代码中出现可以更改显示顺序的 Unicode 码点出现。并且特别禁止 \u{202A}\u{202B}\u{202D}\u{202E}\u{2066}\u{2067}\u{2068}\u{202C}\u{2069} 这几个特殊的 Unicode 码点。

但是这两个 lint 并不能防止 同形攻击,好在 Rust 编译器还能通过识别同名方法给予 警告,写代码的时候要注意警告,识别代码中隐藏的同形攻击。

事件响应时间表

  • 2021-07-25:官方收到了报告并开始着手修复。
  • 2021-09-14:接到漏洞公开的解禁日期(2021-11-01)。
  • 2021 年 10 月 17 日:对发布到 crates.io 的所有源代码进行了分析,以检查是否存在此攻击。
  • 2021-11-01:解禁日,漏洞公开,Rust 1.56.1 发布。

看得出来 Rust 官方对此类安全事件的处理还是非常到位的。

GitHub 应对

在 GitHub 网站上,现在会检测这类双向 Unicode 字符并发出警告,但是对于 隐藏字符和同形攻击的 Unicode 字符就无法警告了。

Rust Crates后门与Cargo

作者: 王江桐

本篇将会简要介绍什么是《This week in Rust》,第409篇推文中有关于Cargo优化二进制大小,第417篇推文中有关于crates后门、Cargo指令与工具相关的内容。

Crates与后门(Backdoor)

Rust crates一般在crates.io上统一进行管理,然而并不是所有的crates都是可信安全的。尽管对于新上传的库,Rust语言组正在努力建立管理机制,对于已上传在crates.io的库,这些管理机制可能并没有被良好应用。例如之前第415篇周报中提到的Rust安全漏洞,由于Unicode编码支持的双向覆写不可见代码点,用户在编辑器中阅读的代码和实际的代码可能并不是匹配的。除了这个已经修复的安全漏洞以外,"Backdooring Rust crates for fun and profit"中另外提到了八种可以在目标机器远程执行代码的方式。

比较简单的方式包括使用类似crate名以及前缀来迷惑不够仔细的用户。例如对于已有库num_cpus,攻击者可以上传一个类似名字的库num_cpu,并仿照num_cpus的文档来迷惑用户。此外,所有在crates.io上的库并不像github一样,列在组织或个人账户下,而是放置于公共的平台。因此,一些项目或开发者会使用前缀来区分他们的库,例如tokio-streamactix-http。然而,这些前缀任何人都可以使用,攻击者可以给不安全的库加上前缀来迷惑用户,而对于相似的readme,github库,tags,以及等等仿造的属性,有时用户并不能一眼区分出这些库并不来源于可信项目。

另外的方式例如,将带有后门的依赖藏在库的依赖树中。当用户使用某个库时,用户几乎不会查看库的依赖树,从而在第一时间发现这个库依赖了不安全的库。

涉及到管理上的漏洞例如,攻击者发布某个库的更新只需要一个token。如果攻击者获取了token,如果使用cargo publish --allow-dirty指令,那么发布更新并不会需要攻击者将代码上传到公共仓,而如果发布了更新,在用户使用Cargo时,所有使用该库旧版本的crates会下载这个恶意更新。

此外,使用某些库编写代码、恶意宏和恶意build.rs也可能带来问题。

Rust中,程序的运行由main函数开始,然而,通过使用Linux或FreeBSD的.init_array区块,macOS / iOS的__DATA, __mod_init_func区块,Windows的.ctors,.CRT$XCU区块,可以在调用函数之前运行一些代码。例如对于crate startup


#![allow(unused)]
fn main() {
#[macro_export]
macro_rules! on_startup {
    ($($tokens:tt)*) => {
        const _: () = {
            // pulled out and scoped to be unable to see the other defs because
            // of the issues around item-level hygene.
            extern "C" fn __init_function() {
                // Note: currently pointless, since even when loaded at runtime
                // via dlopen, panicing before main makes the stdlib abort.
                // However, if that ever changes in the future, we want to guard
                // against unwinding over an `extern "C"` boundary, so we force
                // a double-panic, which will trigger an abort (rather than have
                // any UB).
                let _guard = $crate::_private::PanicOnDrop;
                // Note: ensure we still forget the guard even if `$tokens` has
                // an explicit `return` in it somewhere.
                let _ = (|| -> () { $($tokens)* })();
                $crate::_private::forget(_guard);
            }
            {
                #[used]
                #[cfg_attr(
                    any(target_os = "macos", target_os = "ios", target_os = "tvos"),
                    link_section = "__DATA,__mod_init_func",
                )]
                // These definitely support .init_array
                #[cfg_attr(
                    any(
                        target_os = "linux",
                        target_os = "android",
                        target_os = "freebsd",
                        target_os = "netbsd",
                    ),
                    link_section = ".init_array"
                )]
                // Assume all other unixs support .ctors
                #[cfg_attr(all(
                    any(unix, all(target_os = "windows", target_env = "gnu")),
                    not(any(
                        target_os = "macos", target_os = "ios",
                        target_os = "tvos", target_os = "linux",
                        target_os = "android", target_os = "freebsd",
                        target_os = "netbsd",
                    ))
                ), link_section = ".ctors")]
                #[cfg_attr(all(windows, not(target_env = "gnu")), link_section = ".CRT$XCU")]
                static __CTOR: extern "C" fn() = __init_function;
            };
        };
    };
}
}

可以在库中创建后门:

  • lib.rs

#![allow(unused)]
fn main() {
pub fn do_something() {
    println!("do something...");
}

startup::on_startup! {
    println!("Warning! You just ran a malicious package. Please read https://kerkour.com/rust-crate-backdoor for more information.");
}
}
  • main.rs
fn main() {
    backdoored_crate::do_something();
}

恶意代码将会在main函数开始之前运行。

宏代码会在编译或使用cargo check指令的时候运行,rust-analyzer在编辑器打开项目时也会展开宏,因此,如果依赖库中的宏中含有恶意代码,这些代码可能会在用户未知的时候运行。例如:

  • lib.rs

#![allow(unused)]
fn main() {
use proc_macro::TokenStream;
use std::path::Path;

fn write_warning(file: &str) {
    let home = std::env::var("HOME").unwrap();
    let home = Path::new(&home);
    let warning_file = home.join(file);

    let message = "Warning! You just ran a malicious package. Please read https://kerkour.com/rust-crate-backdoor for more information.";
    let _ = std::fs::write(warning_file, message);
}

// 属性宏
#[proc_macro_derive(Evil)]
pub fn evil_derive(_item: TokenStream) -> TokenStream {
    write_warning("WARNING_DERIVE");

    "".parse().unwrap()
}
}
  • 依赖这个库的lib.rs

#![allow(unused)]
fn main() {
use malicious_macro::Evil;

#[derive(Evil)]
pub struct RandomStruct {}
}

另一个例子:

  • lib.rs

#![allow(unused)]
fn main() {
// 类函数过程宏
#[proc_macro]
pub fn evil(_item: TokenStream) -> TokenStream {
    write_warning("WARNING_MACRO");

    "".parse().unwrap()
}
}
  • 依赖于此库的lib.rs

#![allow(unused)]
fn main() {
pub fn do_something() {
    println!("do something...");
}

malicious_macro::evil!();
}
  • main.rs
fn main() {
    lib::do_something();
}

在编译时,两个宏都会被展开,而其中的代码会被运行。

build.rs可以帮助自定义编译流程,例如编译或链接非Rust三方库。build.rs类似于宏,在使用cargo check指令或使用rust-analyzer时被调用。类似的,如果build.rs内含有恶意代码,在使用这些指令时代码会被运行,导致安全问题。例如:

use std::path::Path;

fn main() {
    let home = std::env::var("HOME").unwrap();
    let home = Path::new(&home);
    let warning_file = home.join("WARNING_BUILD");

    let message = "Warning! You just ran a malicious package. Please read https://kerkour.com/rust-crate-backdoor for more information.";
    let _ = std::fs::write(warning_file, message);
}

不过,虽然使用crates存在风险,但是这些风险并不是完全不可控,可以使用例如如下方式预防安全问题:

  • 使用较大的标准库,减少对三方库依赖
  • 使用Rust git依赖,定位更新到具体commit
  • 使用云开发环境以及沙盒环境减少安全问题的损害

Cargo

Cargo是Rust的包管理器,负责管理Rust的主要工作流程,可以提供控制整理Rust项目的依赖、帮助编译Rust项目等功能。官方的英文手册可见https://doc.rust-lang.org/cargo/,同时也有中文译本https://cargo.budshome.com/,可以对照英文手册查看。

Cargo指令

根据"Top 10 Rust Cargo Commands",常用的十条指令如下:

  • cargo install [option] crate ...

    • 在Rust安装根目录下的bin文件夹中安装Rust二进制包,只有包含可执行的bin或是example文件目标才可以被安装
    • 默认包位置为crate.io,但是如果设置flag,例如--git--path--registry等, 也可以从别的地址安装包。华为内部使用可参考此篇设置:http://openx.huawei.com/communityHome/postDetail?sid=1e3bc00ebdfd11eba17afa163efec54d&postId=2744&id=90。【外发删除】参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-install.html
    • e.g.
      • cargo install sqlx
      • cargo install --path PATH
  • cargo uninstall [options] [spec...]

    • 卸载二进制包,spec参数为待卸载的包ID,参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-uninstall.html
    • e.g.
      • cargo uninstall ripgrep
  • cargo tree [options]

    • 查看依赖树,参数可见https://doc.rust-lang.org/cargo/commands/cargo-tree.html

    • e.g.

      • myproject v0.1.0 (/myproject)
        └── rand v0.7.3
            ├── getrandom v0.1.14
            │   ├── cfg-if v0.1.10
            │   └── libc v0.2.68
            ├── libc v0.2.68 (*)
            ├── rand_chacha v0.2.2
            │   ├── ppv-lite86 v0.2.6
            │   └── rand_core v0.5.1
            │       └── getrandom v0.1.14 (*)
            └── rand_core v0.5.1 (*)
        [build-dependencies]
        └── cc v1.0.50
        
  • cargo search [options] [query...]

    • 在crate.io上搜索指定字符,并返回排版后的结果。query指搜索字符,option可见https://doc.rust-lang.org/cargo/commands/cargo-search.html

    • e.g.

      • cargo search serde会返回:

      • serde = "1.0.130"                         # A generic serialization/deserialization framework
        discord_typed_interactions = "0.1.0"      # suppose you're working with discord slash commands and you want statically typed requ…
        serde_json_experimental = "1.0.29-rc1"    # A JSON serialization file format
        alt_serde_json = "1.0.61"                 # A JSON serialization file format
        serde_json = "1.0.70"                     # A JSON serialization file format
        serde_partiql = "1.1.65"                  # A PartiQL data model serialization file format
        cargo-geiger = "0.11.1"                   # Detects usage of unsafe Rust in a Rust crate and its dependencies.
        serde-encrypt = "0.6.0"                   # Encrypts all the Serialize
        serde-encrypt-core = "0.6.0"              # Encrypts all the Serialize
        typescript-definitions = "0.1.10"         # serde support for exporting Typescript definitions
        ... and 2787 crates more (use --limit N to see more)
        
  • cargo edit

    • 是cargo的拓展,需要通过cargo install cargo-edit安装
    • 编辑依赖除了直接编辑cargo.toml以外,也可以使用edit指令编辑。子命令包括:
      • cargo add
      • cargo rm
      • cargo upgrade
      • cargo set-version
  • cargo +nightly udeps

    • 需要通过cargo install cargo-udeps --locked安装
    • 可以帮助发现Cargo.toml中未使用的依赖。需要nightly版本运行
  • cargo expand

    • 需要通过cargo install cargo-expand安装

    • 对于当前crate,打印出宏与#[derive]的扩展结果

    • e.g.

      • 原代码:
      #[derive(Debug)]
      struct S;
      
      fn main() {
          println!("{:?}", S);
      }
      
      • 使用指令展开后
      #[prelude_import]
      use std::prelude::v1::*;
      #[macro_use]
      extern crate std;
      struct S;
      #[automatically_derived]
      #[allow(unused_qualifications)]
      impl ::core::fmt::Debug for S {
          fn fmt(&self, f: &mut ::core::fmt::Formatter) -> ::core::fmt::Result {
              match *self {
                  S => {
                      let mut debug_trait_builder = f.debug_tuple("S");
                      debug_trait_builder.finish()
                  }
              }
          }
      }
      fn main() {
          {
              ::std::io::_print(::core::fmt::Arguments::new_v1(
                  &["", "\n"],
                  &match (&S,) {
                      (arg0,) => [::core::fmt::ArgumentV1::new(arg0, ::core::fmt::Debug::fmt)],
                  },
              ));
          };
      }
      
  • cargo tarpaulin

    • 需要通过cargo install cargo-tarpaulin安装。tarpaulin可以报告Cargo测试的代码覆盖率,不过目前只能在x86_64的CPU架构与Linux系统上使用
    • e.g.
      • 使用指令cargo tarpaulin --ignore-tests计算除了测试函数之外的代码覆盖率
  • cargo audit

    • 需要通过cargo install cargo-audit安装
    • 可以审核Cargo.lock文件,查找被RustSec Adivisory数据库中记录的有安全隐患的库
  • cargo deny

    • 需要通过cargo install --locked cargo-deny && cargo deny init && cargo deny check安装

    • 可以审核Cargo.lock文件,查找依赖树中被RustSec Adivisory数据库中记录的有安全隐患的库,限制某些依赖的使用、许可、下载源以及版本。

    • e.g.

      • cargo deny check licenses
      • 1

其他一些常用的指令例如:

  • cargo help [subcommand]
    • 帮助菜单,可以查找某个指令的帮助菜单
    • 同样也可以在指令之后加--help flag
  • cargo version [options]
    • 查看版本
  • cargo bench [options] [benchname] [-- bench-options]
    • 编译并执行benchmarks。只能在nightly版本执行,详细参数可见https://doc.rust-lang.org/cargo/commands/cargo-bench.html
    • e.g.
      • cargo bench -- foo --exact:只运行名为foo的benchmark
  • cargo build [options]
    • 编译当前包
    • 可通过--package--workspace--exclude等flag来设置编译哪些包。参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-build.html
  • cargo check [option]
    • 验证当前包以及其依赖是否有错误
  • cargo clean [option]
    • 清除Cargo生成的target。有时更新代码测试或benchmark测试前,最好先clean
  • cargo doc [option]
    • 生成文档,文档位于target/doc文件夹。参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-doc.html
  • cargo fix [option]
    • 修复rustc告警,等同于cargo check --all-targets。参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-fix.html
  • cargo run [option] [-- args]
    • 运行当前包的二进制或example文件。在--之后的参数都会作为二进制文件参数运行。如果二进制文件本身需要命令行输入,在--之前的参数会传递给Cargo,而在之后的参数会传递给二进制文件。参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-run.html
    • e.g.
      • cargo run --example exname -- --exoption exarg1 exarg2
  • cargo test [options] [testname] [-- test-options]
    • 运行单元和集成测试,也就是运行由#[test]属性修饰的函数。与cargo run相同,在--之前的参数会传递给Cargo,而在之后的参数会传递给测试二进制文件。参数详解可见https://doc.rust-lang.org/cargo/commands/cargo-test.html
    • e.g.
      • cargo test foo -- --test-threads 3

以及一些其他的拓展:

  • cargo depgraph

    • 需要通过cargo install cargo-depgraph安装。使用Cargo metadata和graphviz,生成项目依赖图

    • 详细可见:https://sr.ht/~jplatte/cargo-depgraph/

    • e.g.

      • cargo depgraph --all-deps
      • 可以生成如下依赖图: 2
  • cargo bloat

    • 需要通过cargo install cargo-bloat安装。查找可执行文件中占用存储位置最大的部分

    • 详细可见:https://github.com/RazrFalcon/cargo-bloat

    • e.g.

      • cargo bloat --crates --release
      ❯ cargo bloat --crates --release 
         Compiling bar v0.1.0 (/home/okno/_Workshop/playground/bar)
         Compiling foo v0.1.0 (/home/okno/_Workshop/playground/foo)
          Finished release [optimized] target(s) in 0.47s
          Analyzing target/release/foo
      
      File  .text     Size Crate
      6.0%  92.2% 204.5KiB std
      0.3%   4.0%   8.9KiB rand_chacha
      0.1%   0.9%   2.0KiB getrandom
      0.0%   0.7%   1.6KiB rand
      0.0%   0.1%     230B [Unknown]
      0.0%   0.1%     219B rand_core
      0.0%   0.1%     185B foo
      0.0%   0.0%      68B ppv_lite86
      6.5% 100.0% 221.9KiB .text section size, the file size is 3.3MiB
      
      Note: numbers above are a result of guesswork. They are not 100% correct and never will be.
      

cargo-sonar

除了这些指令以外,417篇周报中提到了一个新的Cargo拓展:cargo-sonar。可以通过如下指令安装:

cargo install cargo-sonar

Sonar指包括SonarQube,SonarCloud,SonarSource等一系列SonarSource公司的静态代码质量与安全分析工具,支持包括C、C#、Java、Python等一系列语言。具体的介绍可参阅此链接:https://www.cnblogs.com/lfpriest/p/13366171.html

尽管社区提供了SonarQube的Rust插件,sonar-rust,插件只能装在服务端侧,无法在本地使用。cargo-sonar使用clippy的输出,然后使用Sonar分析输出得到sonar.json,随后将新生成的json文件上传至sonarcloud.io分析得到结果。

使用过程大致如下:

# 首先使用cargo clippy生成json输出
cargo clippy --all-features --all-targets --message-format=json -- --warn clippy::pedantic > clippy.json
# 使用sonar分析,生成sonar.json
cargo sonar --clippy

使用sonar-scanner可以扫描sonar.json并在sonarcloud.io上生成结果。sonar-scanner的sonar-project.properties文件样例如下:

sonar.organization=woshilapin
sonar.projectKey=woshilapin_cargo-sonar
sonar.projectName=cargo-sonar
sonar.sources=.
sonar.host.url=https://sonarcloud.io
sonar.sourceEncoding=UTF-8
sonar.externalIssuesReportPaths=sonar.json	# 指向之前生成的sonar.json
sonar.login=the_token_of_the_project		# 账号相关 

使用sonarcloud.io需要注册账号,不过开源项目的分析是免费的。

此外,cargo-sonar也可以分析别的一些cargo拓展的输出,例如:

  • cargo-audit
cargo audit --json > audit.json
cargo sonar --audit
  • cargo-deny
cargo deny --format json check 2> deny.json
cargo sonar --deny
  • cargo-outdated
cargo outdated --depth 1 --format json --workspace > outdated.json
cargo sonar --outdated

Cargo与二进制大小优化

除了管理包和依赖,Cargo同时也负责项目编译。可以通过配置Cargo.toml文件以及编译时选项进行一些编译优化。生成库的格式和形式,以及动态库形式编译结果的段和对应大小介绍可见《Rust 编译后二进制大小和常用优化方式》,其中有非常详细的解释以及与C的对比。此章节将主要基于"Optimize Rust binaries size with cargo and Semver",概述配置文件可实现的编译优化。

优化等级

在Cargo.toml中,可以通过配置opt-level属性来控制rustc编译时的优化等级。一般来说,等级越高,编译时间越长。opt-level属性在[profile.dev]下,可以通过更改Cargo.toml来设置:

[profile.dev]
opt-level = 1               # Use slightly better optimizations.

Cargo只会根据位于workspace根目录下的Cargo.toml来配置profile,依赖项与子crate中的Cargo.toml设置会被忽略,并且产生提醒。不过,根目录下的Cargo.toml中的设置会被环境变量或config定义所覆盖。

config文件可以放置在当前crate目录、父目录、Cargo安装目录来分别配置当前crate、父目录内crates、或所有的crates。例如,如果Cargo在文件路径/projects/foo/bar/baz被调用,那么Cargo会按序查找config:

  1. /projects/foo/bar/baz/.cargo/config.toml
  2. /projects/foo/bar/.cargo/config.toml
  3. /projects/foo/.cargo/config.toml
  4. /projects/.cargo/config.toml
  5. /.cargo/config.toml
  6. $CARGO_HOME/config.toml
    • Windows: %USERPROFILE%\.cargo\config.toml
    • Unix: $HOME/.cargo/config.toml

其余关于config参数等详细的描述可见https://doc.rust-lang.org/cargo/reference/config.html。

opt-level可选的赋值为:

opt-level描述备注
0无优化优化强度随着数字增长而增长
1基础优化
2一些优化
3所有的优化
"s"优化二进制大小
"z"优化二进制大小,并且关闭SIMD对于loop并行计算的优化

其余的profile设置可见https://doc.rust-lang.org/cargo/reference/profiles.html。

在使用任何优化之前,"Optimize Rust binaries size with cargo and Semver"中初始二进制包大小为:

  • coco: 5.1M
  • cog: 5.7M

默认使用level 2。

使用level s:

  • coco: 5.3M
  • cog: 5.9M

使用level z:

  • coco: 5.5M
  • cog: 6.2M

使用level 1:

  • coco: 9.4M
  • cog: 12M

使用level 3:

  • coco: 5.1M
  • cog: 5.7M

也就是说,尽管可能对于其他项目有用,对于文中的例子,opt-level并没有起到优化二进制大小作用。不过,除了opt-level,Cargo也提供了其他的编译优化选项。

链接时间优化

链接时间优化决定了后端LLVM如何优化二进制大小,以时间换空间,优化等级越高,编译时间越长,但是二进制包大小相对越小。同样,可以通过更改Cargo.toml来设置:

[profile.dev]
lto = "fat"

可选参数值列表如下:

lto描述
false仅在当前crate的代码生成单元上执行"thin" LTO。如果代码生成单元按为1或者优化等级为0,那么不进行LTO
true / "fat"对于依赖图上所有crates执行优化
"thin"类似于"fat",但是平衡性能和时间,尽量使优化后结果持平于"fat"设置
"off"不进行LTO

lto = "fat"opt-level = 3时,文中例子二进制大小为:

  • coco: 3.0M
  • cog: 3.8M

lto = "fat"opt-level = 2时,文中例子二进制大小为:

  • coco: 2.9M
  • cog: 3.7M

代码生成单元

codegen-units属性决定编译器将一个crates分解为代码生成单元的数量,这些代码生成单元会被并行编译,但是并行编译可能会降低性能。codegen-units属性值取值范围是正整数,默认使用256,属性值越小,生成单元数量越少,编译时间越长。

当使用如下配置时,文中例子二进制大小为:

[profile.release]
opt-level = 2
lto = "fat"
codegen-units = 1
  • coco: 2.6M
  • cog: 3.6M

尽管二进制文件变小了,但是编译时间延长了5秒。对于更大的项目,使用优化设置时,编译时间或许会更长。

重复依赖

对于含有重复依赖的项目,这些重复的依赖会不必要地增加二进制文件的大小。例如对于有如下重复依赖的项目:

❯ cargo tree --duplicates -e=no-dev            
num-traits v0.1.43
└── serde-hjson v0.9.1
    └── config v0.11.0
        └── cocogitto v3.0.0 (/home/okno/_Workshop/MyRepos/cocogitto)

num-traits v0.2.14
├── chrono v0.4.19
│   └── cocogitto v3.0.0 (/home/okno/_Workshop/MyRepos/cocogitto)
├── num-integer v0.1.44
│   └── chrono v0.4.19 (*)
└── num-traits v0.1.43 (*)

serde v0.8.23
└── serde-hjson v0.9.1 (*)

serde v1.0.130
├── cocogitto v3.0.0 (/home/okno/_Workshop/MyRepos/cocogitto)
├── config v0.11.0 (*)
├── serde_json v1.0.67
│   └── config v0.11.0 (*)
└── toml v0.5.8
    ├── cocogitto v3.0.0 (/home/okno/_Workshop/MyRepos/cocogitto)
    └── config v0.11.0 (*)

如果使用cargo depgraph --build-deps | dot -Tpng > graph.png分析,可以得到如下依赖图:

3

如图中serde-hjson等以虚线链接的依赖是可选的,可以通过关闭cargo feature的方式来去除,而不影响总体功能。config默认开启的feature包括:tomljsonyamlhjsonini;可以通过修改config features的方式关闭这些cargo feature:

config = { version = "^0", default-features = false, features = ["toml"] }

此外,对于部分项目而言,也可以使用版本兼容符号^来避免编译某个依赖的多个版本:

[dependencies]
rand = "^0.7" # crate requirement: >=0.7.0, <0.8.0

修改之后的二进制大小为:

  • coco: 2.3M
  • cog: 3.1M

去除末尾字节

二进制文件的目标文件符号是用于连接或debug的元数据,例如解决不同模块之间符号交叉引用的信息、重定位信息、堆栈展开信息、注释、程序符号、调试或分析信息。其他元数据可能包括日期和编译时间、编译器名称和版本以及其他识别信息。虽然一般不推荐,但是如果希望极限压缩二进制文件大小,可以通过unix程序strip删除这些符号:

strip -S cog
strip -S coco

在删除之后,裸二进制文件大小为:

  • coco: 1.1M
  • cog: 1.5M

引用

The Cargo Book,https://doc.rust-lang.org/cargo/index.html

Top 10 Rust Cargo Commands,https://dev.to/davidadewoyin/top-rust-cargo-commands-2b70

cargo-sonar,https://hole.tuziwo.info/cargo-sonar.html

Optimize Rust binaries size with cargo and Semver,https://oknozor.github.io/blog/optimize-rust-binary-size/

Backdooring Rust crates for fun and profit,https://kerkour.com/rust-crate-backdoor/

Rust 编译后二进制大小和常用优化方式,https://rustmagazine.github.io/rust_magazine_2021/chapter_4/hw_bin_opt.html

十二月刊(December)

发刊通告

Rust 资讯

元宇宙与区块链

外刊评论

Rust 与 OS

Rust 生产实践

新手入门

原理与工程实践

开源项目巡礼

Rust 与 安全

发刊通告

编辑:张汉东

十二月发刊通告

《 RustMagazine 中文精选 》2021 年第十二期发布了!

当你看到这一期的时候,2021年已过,2022年已然来临。

愿每位读者朋友们,新的一年,有新的收获!

本刊动态

第十二期内容虽然姗姗来迟,但它代表了一年的终结,只有​正式发布了第十二期,我才敢于2021年正式告别!也许这会是本刊的最后一期,感谢大家一年来的支持、陪伴与阅读​!​

访问数据统计小结

截止 2021.12.31

用户数

  • 新用户新增 3,373

浏览量:

  • 网页浏览量 : 18,270

十一 月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

十月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

九月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

八月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

七月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

六月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

五月份比较受欢迎的原创文章 Top 5(按访问量依次排名):

四月份比较受欢迎的文章 Top 5(按访问量依次排名):

三月份比较受欢迎的文章 Top 5(按访问量依次排名):

  • 《华为 | 基于 Rust 的下一代虚拟化平台 StratoVirt》
  • 《华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型》
  • 《透过 Rust 探索系统本原 : 并发篇》
  • 《蚂蚁集团 | Rust CPU Affinity 初探》
  • 《Rust 中使用 MySql》

二月份比较受欢迎的文章 Top 5(按访问量依次排名):

注: 一月刊的 《图解 Rust 所有权》文章依旧最受欢迎,作者:肖猛

  • 《 前端入门 | Rust 和 WebAssembly》,作者: 陈鑫(lencx)
  • 《 实践案例 | 使用 Bevy 游戏引擎制作炸弹人》,作者:Cupnfish
  • 《 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考 》, 作者:evenyag
  • 《 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景》,作者:俞一峻等三位
  • 《 DatenLord | Linux 全新异步接口 io_uring 的 Rust 生态盘点》,作者:施继成

后续也期待大家投稿。欢迎大家直接使用本刊 mdbook 模版进行创作投稿发PR!

官方动态

聚焦 Rust 官方活动与新闻


Rust 1.57 发布

通常来说,Rust每隔6个星期会发布一次新版本。Rust 1.56.0版本发布于2021年10月21日,约6周之后,Rust 1.57已于2021年12月2日发布。

新的稳定版本内容大致如下:

  • panic!assert!以及其他一些宏可以在const fn以及其他编译时上下文中使用了,不过参数仍然只支持str类型数据(e.g. panic!("..."))或是&str类型数据(e.g. panic!("{}", a)

  • Cargo profile支持命名后配置了,例如:

    [profile.production]
    inherits = "release"
    lto = true
    

    编译时可以通过--profile production来使用此配置。编译后文件会被放在不同的文件夹里(e.g. target/production)。

  • 其他的一些从nightly版本加入稳定版本的API,例如try_reverse,详情可见"Announcing Rust 1.57.0"

Rust 异步改进| 可移植和可互操作的异步 Rust

异步基础工作组的目标是使异步 Rust 具有可移植性和可互操作性

  • 无论开发者使用什么运行时或其他库,都能够从 crates.io 获取库并自信地混合搭配它们
  • 能够轻松地编写可与其他库组合且独立于运行时的库
  • 能够在运行时之间轻松更改应用程序以探索新的可能性
  • 能够轻松编写新的运行时来尝试新的执行策略或一些新的环境,并让它们与大多数现存的库互操作,而无需更改这些库
  • 能够找到适合各种场景和使用模式的运行时

但是要达成这个目标,还有很多路要走。Rust 官方团队成员 nrc 现在带头发起这项倡议,并给出了他的愿景:

选择运行时不会让开发者承诺使用到生态系统的特定部分。几乎所有的库都应该与运行时的选择无关。。改变运行时应该是相对容易的,而且你应该能够开始使用异步编程,而不需要为寻找和选择运行时而烦恼。

nrc 并不是希望有一个大一统的终极的运行时执行器,这是不现实的,异步运行时根据不同场景有不同的内容。他所希望的是,能将异步生态系统中的很多抽象和实用程序标准化,这样异步代码就更容易移植。为了达到这个目的,nrc认为很多东西都应该放在标准库中,而不是与执行器捆绑在一起。

https://www.ncameron.org/blog/portable-and-interoperable-async-rust/

【官方】对 Rust 审核团队问题的后续跟进

对于关心 Rust 的 中文社区的朋友和技术媒体而言,我觉得没必要过度解读。因为我们不了解美国社会以及处于该社会下人们所关心和敏感的问题是什么,真正想去理解也是比较困难的。我们只知道,这是一个超过大多数公司人员规模且都是志愿者组成的开源组织所要面临和解决的问题,问题一旦经过解决,那么这个社区将得到进化,会更加强大。所以没必要担心什么 Rust 会被负面影响。

详情请移步这里阅读更多

使用 rust core库就能做risc-v的内核开发和宿主机(虚拟机)开发了

具体来说是core库支持了hypervisor和supervisor的扩展指令,顺便加了个is_riscv_feature_detcted!宏,能在用户态检测riscv扩展是否存在, 已经在真机上跑通,合理利用检测宏,能彻底解决“生态碎片”问题。 如果rollup顺利,能进明天的nightly,然后1.59的stable

社区热点

聚焦 Rust 生态热点新闻


Rust for Linux 补丁更新到 V2 版本

2022 年,我们很可能会看到 Linux 内核中的实验性 Rust 编程语言支持成为主流。2021.12.6 早上发出了更新的补丁,介绍了在内核中处理 Rust 的初始支持和基础设施。

这次更新的内容包括:

  1. 升级到了最新 Stable 编译器和 Rust 2021 edition 。因此可以摆脱了 const_fn_transmuteconst_panicconst_unreachable_uncheckedcore_panictry_reserve 这几个之前未稳定的特性。未稳定特性心愿单
  2. 自定义 corealloc。为 alloc 添加了更加模块化的选项,以便禁用一些他们不需要的功能:no_rcno_sync,主要是为上游 Rust 项目添加。
  3. 更严格的代码、文档和新的 lint
  4. 抽象和驱动程序更新。添加了序列锁、电源管理回调的抽象,io 内存(readX/writeX)、irq 芯片和高级流处理程序,gpio 芯片(包括 irq 芯片)、设备、amba 设备和驱动程序以及证书。此外,也改进并简化了 Refrefcount_t 支持)对象并用它替换了 Rust 的 Arc 的所有实例。完全地从 alloc crate 中删除了 ArcRc

从现在开始,Rust for linux 团队将开始定期提交补丁,每两周左右。

除了来自 Arm、Google 和 Microsoft 的支持外,这次该团队又收到一封来自红帽的信:红帽对 Rust 用于内核的工作也非常感兴趣(There is interest in using Rust for kernel work that Red Hat is considering)。

Hubris : OXide公司出品的新的开源嵌入式 OS

Hubris 没有运行时创建或销毁任务的操作,没有动态资源分配,没有以特权模式运行的驱动程序代码,系统中也没有C代码。通过这种构造,消除了许多通常存在于类似系统中的攻击面。

OXide 公司在今年 OSFF Mini Summit 2021 会议上分享了 即将到来的固件革命 中提到,Rust 将会是即将到来的固件革命的一部分。所以,他们重新审视嵌入式操作系统并用 Rust 开发了 Hubris。 Hubris 目前只支持 Arm Cortex M 平台。

Hubris vs TockOS :

Mixxx 工程师正在探索用 Rust 实现新的 DJ 软件

Mixxx 是起源于 2001 年的一款开源数字DJ系统。一名在 Mixxx 工作近7年的工程师现在决定要用 Rust 来实现新的 DJ 软件。但他认为,世界上已经不在需要另一个 Mixxx 了,他想做一些更具开创性的工作。

Mixxx 太古老了,当初设计的时候也存在一些技术缺陷,而且它使用 Qt 实现,现在正在经历 Qt5 到 Qt6 升级的障碍,技术债务巨大。Mixxx 的 GUI 渲染非常复杂,效率低下,并且围绕着一个已弃用的API 16在 Qt6 中被删除。迁移到 Qt 的较新 API,尽管工作了几个月,但仍然没有成功。Mixxx 唯一可行的前进道路是使用 QML 而不是 QWidgets 从头开始​​重写整个 GUI。在这方面已经取得了进展,但仍有很长的路要走。许多 Mixxx 与 GUI 耦合,包括整个音乐库数据库系统和 Auto DJ 功能,因此必须为 Qt6 完全重写。实际上,该工程师预计 Mixxx 需要 1 到 2 年才能发布 Qt6。

该工程师现在正在考虑将这些年投入到一个建立在 Rust 更好基础上的新应用程序上,而不是继续逐个系统地重写 Mixxx。我一直在探索现有的 Rust 音频生态系统,看起来有一些不错的库可供入门。特别是creekSymphonia看起来很有用。dasp看起来像是一个很好的基础,我希望它被广泛采用,这样我们就可以建立一个易于互操作的 crate 生态系统。cpal也不错,但他感觉该库还不够成熟。还有 Meadowlark旨在成为适用于 Linux、Mac 和 Windows 的免费和开源 DAW(数字音频工作站)。

对于 GUI ,该工程师决定使用 SixtyFPS,因为 SixtyFPS 更有前景,两位创始人都是 Qt 多年经验的人,其中一位更是 QML 的首席工程师。

https://rust-audio.discourse.group/t/a-new-dj-application-in-rust/484/12

Wasmer 2.1.0 发布

经过几个月的工作,WASMER 发布了 2.1 版本。包含一些新功能和错误修复,包括:

  • Wasmer Js.
  • 虚拟文件系统
  • iOS 支持
  • Windows 下支持 Singlepass 编译
  • LLVM ARM64 支持 & LLVM 13
  • 更快的 Singlepass 编译
  • 可重复和确定性构建
  • 新语言集成:LISP 和 Crystal

Wasmer 2.1: https://wasmer.io/posts/wasmer-2.1

GitHub 新的代码搜索引擎使用 Rust 实现

https://github.blog/2021-12-08-improving-github-code-search/

分析 Rust 生态系统对 IoT 的准备情况

本文是斯德哥尔摩 KTH 皇家理工学院 ID2012 普适计算课程的一部分。

任务是对普适计算或物联网领域的某个主题进行分析概述。作者选择 Rust 的物联网应用程序是出于我个人对 Rust 的兴趣以及之前深入物联网的经验,特别是使用 Rust 的嵌入式设备。

这篇文章于2021 年 5 月完成并提交,今天回顾该领域已经显示出一些细微的变化。因此请注意,该领域正在迅速发展,并且可能看起来更好,具体取决于您何时阅读本文。

Rust 完全有能力在嵌入式计算等更高级别的物联网领域完成特定任务,例如边缘轻量级计算和后端服务的实现。然而,它表明 Rust 是一种相当新的语言。由于其生态系统的这些部分,与物联网相关,仍在不断发展,甚至缺乏一些重要的基础,而且远非稳定。对于早期采用者和普遍好奇的人来说,Rust 仍然为构建自己的解决方案提供了基础,尽管没有提供数十年开发的成熟度。从好的方面来说,我们看到像 Drogue、Ferrous Systems 和其他独立集团这样的几家公司正在这样做。至关重要的基础正在积极开发中,并为 Rust 带来更光明的未来。

Rust 展示了成为未来首选物联网平台的潜力,提供速度、人体工程学和安全性,但没有显示出成熟的成熟度,可以在没有经过全面考虑的情况下轻松使用。

https://blog.ysndr.de/posts/essays/2021-12-12-rust-for-iot/

## Deno加入ECMA国际组织的TC39工作组

Deno Joins TC39

Deno 是 Ryan Dahl 在2017年创立的(Ryan Dahl 同时也是 Node.js 的作者),旨在为JavaScriptTypeScript构建一个简单、现代、安全的运行时,Deno是用Rust实现的,内置V8引擎。

Deno基本上是为现代JavaScript构建的:Promisesasync/awaitES模块异步迭代器等在Deno中都是一等公民。

为了确保JavaScript的未来发展将继续适用于Deno和服务器端JavaScript运行时,Deno公司已加入负责JavaScript标准(ECMA-262)的ECMA国际标准组织,并参与到TC39工作组的工作中,Luca Casonato(卢卡·卡索纳托)将是Deno在TC39的主要代表。

在TC39工作组中,Deno将与其他ECMA成员和更广泛的JS社区合作开发下一代JavaScript,并将推动该语言的功能和改进,使每个人受益,尤其是服务器端JavaScript的用户。

TC39是指第39号技术委员会。它是ECMA的一部分,该机构根据“ECMAScript”规范标准化JavaScript语言。

参考资料:

## cbor4ii:一种新的CBOR格式序列化库

cbor4ii: A new CBOR serialization crate

CBOR

简明二进制对象表示法(CBOR)是一种数据格式(RFC8949),其设计目标包括实现极小的代码大小、相当小的消息大小和无需版本协商的可扩展性。

cbor4ii是一个新的用Rust语言实现的CBOR格式序列化库,它没有针对性能进行专门优化,但基准测试表明,它的性能略优于serde_cbor

而且它支持零拷贝反序列化和反序列化任何支持deserialize_ignored_anyserde,因此在某些情况下,它的性能可能比不支持此功能的serde要好。

Cratesinquire: 搜索并分析crates.io

Cratesinquire: crates.io insight and explorer.

www.cratesinquire.com是一个crates.io的辅助功能网站,以Bundlephobia为灵感,搜索crate包并检查其细节。

它能做什么?

  • crates.io的API发送请求,并处理数据;
  • 显示从crates.io扩展的额外数据;
  • 显示数据比较图表;
  • 深入显示crate的依赖;
  • 洞察crate的特征。

立刻体验,请访问这里:www.cratesinquire.com

现代链接器 mold v1.0 发布

mold 是一个更加现代的链接器,它是由 C++ 编写的,但能够与 Rust / Cargo 生态完美协作,提供更快地链接过程。

你可以使用 mold --run cargo build 来尝试它,或者将下述内容添加到 ~/.cargo/config.toml

[target.x86_64-unknown-linux-gnu] 
linker = "clang" 
rustflags = ["-C", "link-arg=-fuse-ld=/PATH/TO/mold"]

GitHub - mold

tokio-rs/console 发布 v0.1

一个分析与调试的工具,为异步 Rust 程序设计。分析套件包括几个组件:

  • 一个 wire protocol,为了串流来自被分析应用到分析工具的分析数据。这个格式是用了 gPRC 和 protocol buffer 定义的,为了高效地在 wire 上传送,以及为了保证不同数据生产者和消费者之间的互操作性。 console-api 库包含了 wire format 的自动生成代码,用的是 tonic gPRC。此外,使用其他 gPRC 代码生成器的项目(包括使用其他语言实现的!)能够自行依赖 protobuf 定义。
  • 从进程收集分析数据并通过 wire format 暴露它的instrumentation。 仓库中的 console-subscriber 库包含了作为tracing-subscriber 层的 instrumentation 端API,给用 Tokio 和 tracing 的项目使用。
  • 用来展现和探索分析数据的工具,使用 console wire protocol 实现成了gPRC 客户端。console 库实现了一个非交互式的命令行工具来消费这些山上,但是其他实现,例如图形化的或者基于web 的工具,也是可用的。

https://github.com/tokio-rs/console

一个由Atom开发者用Rust编写的代码编辑器

目前官方透露的信息很少,给了一个简陋的官网:https://zed.dev/

同时官方在该帖下透露了一些有趣的信息。

在正式介绍相关信息之前,我想多说几句,为什么都过了一周了,又把这个信息扒出来,之前的时候我以为这个信息热度挺高的,一定会发日报吧,没仔细看,知道后面想看看大家对这件事的评论的时候,才发现居然没发日报。索性今天我值班,就由我来给大家说说这个旧闻。

Atom大家应该有听说过,是GitHub开发的一个代码编辑器,早年我还在大学的时候折腾过各种编辑器,对这个的印象实在是不怎么好,现在回想起来,第一个感觉就是卡,真卡。同时,由于该团队开发Atom的时候,需要一个前端框架,于是有了大名鼎鼎的Electron,早先的名字叫做Atom Shell,名气上应该是要大于Atom本身的。对于这个框架,我的感受,还是卡,因为有用过Linux各种桌面发行版的经历,很多软件为了跨平台支持,都是Electron开发的,很卡。

现在这个团队,要用Rust开发一个代码编辑器,和前段时间的Fleet不一样的地方在于,前端的部分,也是Rust写的,同时因为Rust的GUI生态很薄软,他们直接开发了一个Rust前端框架,叫做GPUI,直接放原文:

我们最初计划使用Electron作为为Zed提供跨平台GUI的便捷手段,同时用Rust构建应用程序的核心。但每当这时,我们发现网络技术是实现惊人性能的瓶颈。最后,我们决定建立一个由GPU驱动的可完全控制的并简单的UI框架,以满足我们的需求。

我们称它为GPUI。

我们从Mozilla的Webrender项目中得到了很多灵感。关键的见解是,现代图形硬件可以以高帧率渲染复杂的3D图形,那么为什么不利用它来渲染相对简单的2D用户界面,并采用即时模式架构呢?

Rust的所有权模型要求我们重新思考我们在其他UI编程范式中所学到的很多东西,但其结果是一个框架,使用起来很有成效,而且非常容易推理。

控制每一个像素是一种解放,而以闪电般的速度推动这些像素则是一种冲动。(It’s liberating to control every pixel, and it’s a rush to push those pixels at lightning speed.)

单单是上述官方的博文,仍然无法确定这个GUI框架是用Rust写的,但是该帖子下有这样的回复:

Rust迫切需要一个好的UI框架,让每个人都觉得用起来很舒服......现在有一些很棒的独立解决方案,但没有一个听起来适合每个人的。

这样大概率是Rust的UI框架解决方案了。

相对于这个编辑器本身,我对该UI框架的兴趣更为浓厚,也正是有了很大的期望,所以如果最后一些偏差,比如该框架不是给Rust做的、比如该框架不是像宣传所说的很简单易用,那会让我很失望,索性把这个旧闻翻出来,让对Rust进行GUI开发感兴趣的同志们一齐期待,比我最后一个人可能失望要好的多。

Read More

Rust有什么是Zig所没有的?

Reddit 上有个讨论很有意思,同属于更好的C的类型的语言,Zig提供了很多有趣的设计思路,甚至于Rust语言团队也需要从中吸取经验,用以改进Rust,这部分讨论收录在这里:

我还没有深入研究过Zig,但与Rust相比,它似乎是一种非常简单的语言,就系统编程语言而言,它是相当有能力的。Rust有什么是Zig所没有的?

高赞回复:

Rust有什么是Zig所没有的?

编写代码时不需要经常考虑内存管理问题的能力。

Zig让内存管理变得非常明确,比如强迫你处理分配失败的情况,这很好......但是,这并不是我在PC平台上做游戏时真正要处理的问题。

一旦Rust的基本借用规则成为第二天性,代码就几乎不存在内存管理部分了......一切都会自动地做 "正确的事情"。

在使用Rust这么长时间后,我觉得要传递内存分配器,要存储指向内存分配器的指针都是很麻烦的。

不要误会我的意思,它是一个非常棒的项目(就像Jai、Odin和所有其他即将推出的"更好的C"一样),但是不断有走错路的危险,我觉得很难再回去了。

而且这还没有触及多线程的问题

Read More

活动回顾

后期编辑:张汉东

编者按:

总结了本月的活动,包括线上和线下。

线上: 《Rust 唠嗑室》和 《RustFriday 飞书群线上沙龙》


【线上】Rust 唠嗑室本月汇总

  • 来源:Rust 唠嗑室
  • 主持人:MikeTang
  • 后期编辑:高宪凤

《Rust唠嗑室》第39期 - 来分享分享你们用Rust做的项目

时间: 2021/12/7 20:30-21:30

主讲人:Mike

题目:来分享分享你们用Rust做的项目

内容

群里有朋友提到,唠嗑室可以来一次群友正在做的项目的一个分享,于是便有了这一期的主题。大家正在做的,以前做过的,未来将要准备做的都可以来分享一下。没有中心topic,每个人都可以发言。

【回放】


《Rust唠嗑室》第40期 - 我的Rust学习历程分享(令狐一冲)

时间: 2021/12/28 20:30-21:30

主讲人:令狐一冲

题目:我的Rust学习历程分享

内容:这次我们请到了B站令狐一冲与大家一起闲聊一下Rust的学习故事。比较随意,欢迎大家参与。聊天内容级别:新手入门级。

【回放】


🔥🔥🔥🔥 RustFriday 飞书群线上沙龙 🔥🔥🔥🔥

【线上】RustFriday 飞书群线上沙龙

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第二十一期 用Poem-openapi实现遵循OpenApiv3规范的HTTP API

分享者:老油条

【讨论主题】

  1. Rust web 框架 Poem 作者介绍如何使用Poem-openapi实现遵循OpenApiv3规范的HTTP API

【参考资料】

  1. https://github.com/poem-web/poem

【回放】

第二十一期 Monoio:基于 io-uring 的高性能 Rust Runtime

分享者:茌海 字节跳动工程师

【讨论主题】

  1. 介绍 Monoio,一款 基于 io-uring 的高性能 Rust Runtime

【参考资料】

  1. https://github.com/bytedance/monoio

【回放】


🔥🔥🔥🔥 Rust 源码阅读活动 🔥🔥🔥🔥

【线上】Rust 源码阅读活动

每周五晚八点,限定两个主题:语言特性和开源项目,在线讨论。

Rust 中文社群 飞书群 邀请你加入:

对话群: https://applink.feishu.cn/TeLAcbDR

话题群:https://applink.feishu.cn/TeLD868w

视频来源:https://space.bilibili.com/24917186

第三期

分享者:detenlord 联合创始人 施继成

时间:2021.11.07 14:00 ~ 15:30

【讨论主题】

  1. Rust 异步运行时 Tokio 源码阅读系列之二

【回放】


🔥🔥🔥🔥 Rust中文社群线上学习室 🔥🔥🔥🔥

通过 Rustlings 学 Rust

想要帮助 Rust 新手过一遍语法基础,以看懂 Rust 代码为目标。面向:

  1. 《Rust 编程之道》和 极客时间视频教程 的读者/观众们,因为在学习我的书和视频之前需要对 Rust 的语法有一定的了解。
  2. 有很多人因为 Rust 语法就放弃 Rust,我认为不至于。
  3. 为《Rust 编程之道》第二版编写积累素材。希望在和新人的互动中,进一步了解新手的痛点。
  4. 对这次活动感兴趣的任何人。

源码: https://github.com/ZhangHanDong/rustlings

视频来源:https://space.bilibili.com/24917186

第一期

【回放】

第二期

【主要内容】

  • 回顾 rustlings | variables
    • 我们能学到什么?
    • 思考问题解答
  • 回顾 rustlings | functions
  • 自由讨论

【回放】

第三期

【主要内容】

  • 回顾 rustlings | functions
    • 我们能学到啥?
    • 思考解答
  • rustlings | if && move_semantics
  • 自由讨论

【回放】

第四期

【主要内容】

【回放】

第五期

【主要内容】

【回放】

第六期

【主要内容】

【回放】

第七期

【主要内容】

【回放】

第八期

【主要内容】

【回放】

第九期

【主要内容】

【回放】

第十期

【主要内容】

  • rustlings | collections
  • 继续 Visualizing memory layout of Rust's data types
  • 自由讨论: 今天有什么思考?
  • 额外讨论:move 语义 要和 底层实现 分开理解,不要混为一谈
  • rust里面如果不加mut是不是就是不可变集合,类似于immutable含义? 这叫 不能改集合。看看 https://github.com/bodil/im-rs
  • rust有并发集合吗? 标准库没有,第三方库有。https://github.com/jonhoo/flurry / https://github.com/xacrimon/dashmap (Redox)

【回放】

第十一期

【主要内容】

  • rustlings | strings
  • 自由讨论: 今天有什么思考?

【回放】

第十二期

【主要内容】

  • rustlings | error handle
  • 自由讨论: 今天有什么思考?

【回放】

圣诞特辑 :围绕 EmbarkStudios 的开源生态来理解Rust在图形计算方向的潜力

【主要内容】

  • 本期主要是围绕 EmbarkStudios 的 开源 Rust 生态项目来理解 Rust on GPU 的潜力。

【相关资料】

  • 直播过程中完善的脑图pdf下载链接 : https://rust-lang.feishu.cn/file/boxcnv3gQVn0uaBYbHBvJSOFuSZ

【回放】

本月招聘


[上海] 区块链合约工程师(30-40k) 全栈工程师 (25-35k) 招聘

我们是一家天使轮Defi公司。创始团队有强技术和金融背景,一位创始人是Loot NFT的维护者(3亿美金交易量)和Elixir语言的重要贡献者,另一个位创始人在投行工作了5年。团队曾经一起做过一个进了社交榜前100名的APP。

我们目前在招聘前两位员工。区块链合约工程师(30-40k) 和 全栈工程师 (25-35k) 。

因为我们是在Terra链上开发,合约都是需要用Rust写的。全栈工程师可以自己选择编程语言。工作地点在上海。关于更具体的项目细节,我们可以加微信详细聊聊。

如果你有兴趣,请联系我的微信 zhew2013

【官方】对 Rust 审核团队问题的后续跟进

https://blog.rust-lang.org/inside-rust/2021/12/17/follow-up-on-the-moderation-issue.html

引子

官方在 12月 10号正式对审核团队集体辞职事件做出了回应。

From: Mara Bos To: All members of the Rust project Date: Fri, 10 Dec 2021 18:12:06 UTC Subject: Follow-up on the moderation issue

Mara 是 Rust 库团队 Leader。她在 RustConf 2021 大会上也有分享 : Mara Bos: 以改进 Rust 标准库锁为例,探讨大型工作团队如何突出困境

正文

Rust 语言审核团队(mod team)集体离职,矛盾产生于审核团队和核心团队成员之间关于如何处理审核问题时造成的分歧。因为这些矛盾涉及了很多相关人员很多个人隐私,所以官方也不能透露更多内幕信息,这就导致外界对这件事有很多猜测和夸大影响。这件事本来也就是 Rust 官方团队内部事件,其实根本没有必要让外界知道。

但是事已至此,官方团队也出来澄清了。2021年12月10日官方库团队 Leader Mara Bos 代表 Rust 项目团队在官方博客上发布了后续情况的回应

从历史上看,涉及Rust团队成员的审核行为都是在审核团队和核心团队之间合作进行的。然而,由于核心团队的参与,并没有明确的流程可循。两个团队都付出了巨大的努力,试图解决这种缺乏流程的问题,但在之前8个月的时间里,由于沟通不畅和意见分歧,这升级成了审核团队和核心团队之间的信任问题。审核团队和核心团队最终都陷入了一个无法运作的局面,没有人可以掌握完整的背景,使得团队之间无法继续开展后续工作。

由于审核团队辞职比较突然,情况比较复杂,所以 Rust 官方需要迅速作出决策,并且让所有利益相关人员进入一起进行决策。但是因为 Rust 团队本身领导层这个概念是非常模糊的,不太好确认到底要谁来参与解决此事更加合适,并且能确保他们所代表的观点可以被大家听取。所以,为了疏通这个问题,官方库团队以 Mara Bos 为代表对 Rust 项目高层团队leader、Rust 基金会项目主管、所有核心团队成员、新指派的审核团队成员开始,讨论下一步工作。随着时间的推移,这个解决问题的小组逐渐扩大了规模,引入了Rust Project 中的其他成员。

然而,由于所讨论的内容具有高度的背景依赖性和敏感性(美国各种敏感,可以理解),向 Rust Project 中的每个人开放所有的讨论会产生反作用,使事实调查和背景建设成为不可能。所以现在由 Mara Bos 和 Ryan Levick 统一协调这件事。

为此,他们目前确定了一个比较模糊的目标:

  1. 最近的事件表明,Rust Project在处理复杂的审核问题方面的政策不够明确。这一点必须通过公开记录的程序来解决,以确保隐私、公平、问责和对所有相关方的公正。他们正在收集意见和经验,试图找到一个能够满足每个人的需求并考虑到过去经验的结果。如果没有更广泛的社区参与,将不会做出决定。
  2. 更广泛地说,这个问题是另一个指标,表明了一个所有参与Rust Project 领导的人,包括核心团队的所有成员都已经清楚的事实:Rust Project 需要调整它的结构,以进行治理。Rust治理的未来应该是什么样子,是一个很大的开放性问题,但从他们目前收集到的意见来看,确实有足够的共同点可以建立。他们希望通过所有Rust Project 成员的反馈来解决这个问题。
  3. 最具体的是,他们需要解决具体的审核问题,这个问题是核心团队和前审核团队之间分歧的中心。这个解决方案需要尊重第1点中列出的所有价值:隐私、公平、责任和对所有相关方的公正。这将在所有参与方的投入下进行处理。

这些问题是非常复杂的,需要大量的背景来理解,并涉及私人和个人信息,不应该公开讨论。然而,Rust Project的中心是对透明和公开的信念。他们致力于与Rust Project 的所有成员和更广泛的Rust社区一起解决这些问题,但在整理思路和构建草案时,他们希望Rust 社区的人们能耐心等待。当他们能够确保这样做不会引起更多的混乱时,将公开讨论细节和计划。

毋庸置疑,要管理一个规模超过大多数公司,却由志愿者组成的开源项目是很困难的。他们有很多工作要做,但他们相信Rust Project会因此变得更加强大。虽然这些问题很严重,需要谨慎地得出积极的结论,但他们相信这不会对Rust语言及其核心工具、文档和支持进行改进的能力产生负面影响。

小结

对于关心 Rust 的 中文社区的朋友和技术媒体而言,我觉得没必要过度解读。因为我们不了解美国社会以及处于该社会下人们所关心和敏感的问题是什么,真正想去理解也是比较困难的。我们只知道,这是一个超过大多数公司人员规模且都是志愿者组成的开源组织所要面临和解决的问题,问题一旦经过解决,那么这个社区将得到进化,会更加强大。所以没必要担心什么 Rust 会被负面影响。

区块链快讯

来自于区块链领域的信息,来自于 https://rustinblockchain.org/newsletters/rib-newsletter-30/


项目聚焦

每个月,我们都希望关注一个著名的 Rust 区块链项目。这个月这个项目是……

wagyu

wagyu是一个命令行加密货币钱包生成器,支持比特币、以太坊、门罗币和 Zcash。它可以生成标准和分层确定性(HD)钱包。它来自Aleo项目。对于那些创建大量钱包的人来说,这是一个有用的工具。

有趣的东西

消息

博客文章和讨论

文件

项目

  • DAS,去中心化账户系统。DAS 是一种多链协议,用于维护各种类型的身份信息。它将数据存储在Nervos Network (CKB) 上,但通过使用 CKB 的租用模型和验证外链交易的能力,它主要让用户避免与 CKB 链直接交互。
  • 脑钱包。比特币脑钱包的 Rust 命令行程序。
  • AlephBFT。Aleph 协议的 Rust 实现,这是一种异步和拜占庭容错共识协议,旨在对任意消息(事务)进行排序。
  • miden。基于 STARK 的虚拟机。
  • ark-plonk。使用 arkworks 作为后端的纯 Rust PLONK 实现。
  • radixdlt-scrypto。在 Radix 上构建 DeFi 应用程序的语言。
  • zeekoe。零知识二层支付渠道。

外刊评论

精选 Reddit 讨论热帖


大纲

  • 2021 总结系列
    • Rust嵌入式工作组2021总结
    • Cranelift 进度报告:2021 年回顾
    • Rust-Analyzer 2021 总结
    • SixtyFPS 2021 总结
    • Dimforge 的 2021 和 2022 目标
    • Mozilla Glean 的 2021 总结
    • hyper 生态相关的 2021 总结
    • Wgpu 2021 总结
  • Reddit 讨论热帖
    • 你有哪些项目没有使用 Rust 语言?为什么?
    • 有哪些重要特性是 Rust 缺失 而 C/Cpp 中存在的?
    • 用 Rust 编写科学计算有哪些资源?
    • Rust 是在炒作吗?
    • Rust 只是复制而不是 Move ?
    • 你梦想中的 Rust 大学课程是什么样的?

2021 总结系列

https://blog.rust-embedded.org/this-year-in-embedded-rust-2021/

对Rust嵌入式工作组2021总结的总结,看完以后感觉Rust在嵌入式大有可为:

  1. 树莓派2021发布首款RP2040微控制器中有两个Cortex M0内核。这让工作组的成员开始思考,在多核微控制器下该如何提供安全性,由此有了 rp-rs 组织。
  2. Espressif (乐鑫)正式雇佣mabez 针对eso芯片开发Rust支持:esp-rs
  3. 其他平台也逐渐开始支持Rust,包括:Atmel ARM SAM-D和SAM-E、Atmel AVR、NXP ARM iMX. RT微控制器、ARM nRF51、52和9160蓝牙/LTE设备、RISC-V、树莓派、STM32等。
  4. 嵌入式Rust生态得到长足发展:嵌入式并发框架已经1.0 、嵌入式异步框架正在大力开发且支持STM32,nRF和RP2040平台,并且还深深影响着Rust异步的改进、嵌入式开发和调试工具又发布了新的探针工具、嵌入式smoltcpTCP/IP栈发布了新版本、嵌入式图形库Matrix发布了新版本、新的嵌入式实时OS Hubirs 开源。
  5. 嵌入式工作组自身维护的项目在这一年也是大力开发和维护中。

Cranelift 进度报告:2021 年回顾

https://bytecodealliance.org/articles/cranelift-progress-2021

Cranelift 是字节码联盟的原生代码编译器,作为Wasmtime和Lucet WebAssembly 虚拟机的基础,也用于其他环境,例如作为Rust 编译器的替代后端。

2021 年完成的目标:

  1. 从旧后端框架到新后端框架的迁移(全部完成)。这项工作持续了两年时间,新的后端框架可以达成“在更换发动机的同时保持飞机飞行”。
  2. 安全性:CVE、VeriWasm(完成)。收到了第一个 CVE,并且开发 VeriWasm 工具来检查从 WebAssembly 编译的机器代码,以确保其沙箱属性完好无损。现在也将其集成到了 lucet 中。
  3. 模糊测试和正确性(完成)。除了 VeriWasm 之外,还在 oss-fuzz 上运行很多模糊测试目标。现在还有针对V8、官方 Wasm 规范解释器和CLIF(Cranelift IR)解释器的差异化模糊测试。还有一个极具创新性的项目来构建“自定义修改器”,wasm-mutate,它允许我们所有基于 Wasm 的模糊测试通过进行语义保留更改来更有效地生成和测试有趣的程序。
  4. 增加基准测试套件 sightglass ,在改进 Cranelift 时可以提供稳定的基准测试基础。该基准测试确保引入新的指令 DSL 没有性能损失。
  5. SIMD 支持。目前 Cranelift 已优先完成对 aarch64 和 x86-64 的 SIMD 支持。并且现在已经有了一个完整的 Wasm-SIMD 实现。
  6. 引入寄存器分配器 regalloc2。为了优化编译器的性能,引入了 regalloc2,目前这部分工作还在持续改进中。
  7. 指令选择器 DSL (ISLE, Instruction Selection Lowering Expressions)。指令选择(instruction selection)是将中间语言转换成汇编或机器代码的过程。ISLE 是一种领域特定语言,用于编写指令选择和重写规则。ISLE 源文本被编译成 Rust 代码。ISLE 的目标是表示指令降低模式。指令降低模式是一种规范,即 IR (CLIF) 中的某种运算符组合,当在特定条件下组合时,可以编译成特定的机器指令序列。
  8. 收到了一项重大贡献:一个支持IBM z/Architecture(也就是64 位 ISA,具有直接向后兼容的 1960 年代大型机谱系)的全功能 s390x 后端

Rust-Analyzer 2021 总结

https://rust-analyzer.github.io/blog/2021/12/30/2021-recap.html

2022年 ra 可能会被官方收录。

  1. 最大改进之一可能是属性过程宏的支持。过程宏目前ABI不稳定,虽然它的不断发展,但每次更改都会破坏 proc-macro 服务器。为了解决这个问题,ra团队现在尝试至少支持 ~3 个 ABI 版本,这些版本名义上对应于最新的稳定版、测试版和夜间工具链。实际上,ra团队目前支持更旧的版本:1.47 及更高版本。
  2. 本地语法项(item)解析。不如函数内部 use 自定义枚举。
  3. 整合 chalk 中的类型替代之前的类型表示,避免每次都进行一次多余的转换。
  4. 开始考虑常量泛型参数的支持,这非常困难,目前进展缓慢。因为 Rust 编译器通过 miri 来进行常量表达式求值,但是 ra 并没有共享 miri 使用的数据结构,在未来也不打算这么做,ra团队自己实现了常量表达式求值的功能。
  5. ra使用的语法树库 rowan 已被调整为允许创建不可变(Immutable)语法树的可变副本。
  6. 今年的最终版本为语言服务器二进制文件的获取方式带来了两个重要的变化。到目前为止,该扩展程序调用 GitHub API 来查找匹配的版本,并从那里下载服务器。此外,如果你选择加入Nightly channel,则扩展程序每天都会搜索更新的 VSIX。
  7. 支持独立的 Rust 文件,可以使用大部分不依赖于 cargo 的功能。
  8. 另外还改进了很多小功能,提升 IDE 体验。

SixtyFPS 2021 总结

https://sixtyfps.io/blog/2021-in-review.html

从2021年开始,就看到一些基于Rust和开源来创业的公司慢慢增加,这个专注于GUI的SixtyFPS也是其中一个,这个开源GUI工具库面向全平台,目标之一是取代Qt。

看他们的 2021 总结报告,发现他们在2021才注册了公司,和tQCS这样的咨询公司建立了合作关系,找到了第一个客户,招募了新成员。(感觉国外技术流创业跟国内还是有点区别)。tQCS提供世界No.1的Qt咨询和UI/UX设计服务,选择和SixtyFPS合作,这意味着什么呢?之前知乎上还有人黑Rust说连qt都支持不好,没有前途?

2022年他们可能要长足支持嵌入式,要有新品牌名字了。

Dimforge 的 2021 和 2022 目标

2021 年,Dimforge 开源组织为 Rust 社区带来了用于线性代数和物理模拟的开源 crate 中最重要的新增内容。

  • Rapier, 用 Rust 编写的用于游戏、机器人和动画的 2D 和 3D 物理引擎。2021年发布了新的版本,新增了很多改进。此版本的重点是对多体关节的支持。
  • nalgebra,是 Rust 的通用线性代数库。2021 年新增功能包括:
    • 发布新的nalgebra-sparse crate
    • 集成常量泛型来改进了人体工程学、可调试性和泛型编程,用于涉及静态大小矩阵的代码
  • 修复一些 Bug
  • 增加了与最近宣布的rust-cuda项目的兼容性。
  • 发布了新的碰撞检测库parry
  • 致力于通过 SIMD 优化来提高流体模拟 Salva 的性能

对于 2022 年,有两个主要目标:

  • 专注于实现 Rapier 的高级特性。
  • 继续对用于流体和变形体模拟的 MPM的探索。

目前 Dimforge 接受的赞助商有 : croquet: 做 web 实时服务 / embark: 游戏工作室 / fragcolor: 正在为分布式游戏生态系统构建引擎

Mozilla Glean 的 2021 总结

https://fnordig.de/2021/12/17/glean-in-2021/

glean 是 Mozilla 在 2021 年发布的现代化跨平台遥测(telemetry)库。

Glean SDK 是一个完全可自助服务的遥测 SDK,可跨不同平台使用。它使产品所有者和工程师能够检测他们的产品并依赖他们的数据收集,同时遵循 Mozilla 政策和隐私标准。

2021 年总共发布了 9 个主要版本。现在 Mozilla 越来越多的产品和项目正在使用 Glean,包括Rally 研究、Mozilla VPN 客户端和新版Focus for Android。Glean Dictionary 中提供了完整的产品列表。

Mozilla 数据文档中可以看到关于 Clean 详细介绍。

hyper 生态相关的 2021 总结

https://seanmonstar.com/post/672473147126300672/hyper-ish-2021-in-review

  1. hyper 1.0 即将到来。
  2. 为 hyper 提供了 C-API,允许 curl 使用 hyper 作为 HTTP 后端。相关文章:https://aws.amazon.com/blogs/opensource/how-using-hyper-in-curl-can-help-make-the-internet-safer/
  3. 开发 http3 库 h3,目前已经更新到 Tokio 1.0 和 quinn 0.8 中。
  4. 开发 Axum,它是一个 Rust 服务器框架,旨在从 Tower 和 Tower-HTTP 扩展。
  5. 推出了 Tokio Console的第一个版本。

Wgpu 2021 总结

gfx-rs 是一个为 Rust 带来高效跨平台图形的项目,这篇文章包含该项目的主要里程碑、概念和回顾。

从 gfx-hal 转移到新创建的 wgpu-hal 并重组了代码仓库以将所有内容保持在一起。 与此同时放弃了 SPIRV-Cross 以支持 naga,实现纯 Rust 技术堆栈。在 0.10 发布帖子中了解更多信息,归功于@kvark。

同时,@cwfitzgerald 使用 Rust 集成测试和示例快照改进了我们的测试基础设施。 最重要的是,wgpu 已经与 Deno 紧密集成(感谢 Deno 团队的努力!),开辟了在真正的 CTS 上进行测试的道路,现在可以在 CI 中使用。

WebGL 适配变得可用,现在 wgpu-rs 示例可以使用 WebGL 在线运行。

https://gfx-rs.github.io/2021/12/25/this-year.html

你有哪些项目没有使用 Rust 语言?为什么?

https://www.reddit.com/r/rust/comments/r8xsag/what_projects_didnt_you_make_in_rust/

Rust 社区大部分都是宣传自己使用 Rust 做了什么,而这个帖子则反问:Rust 不能做什么?

大家的回答:

  1. 想用 Python 快速做出艺术作品推荐引擎的原型,因为是机器学习相关。但是想稍后用 Rust 重写它们。
  2. 写bash脚本恐怕不太方便。
  3. 写 GUI 依然比较困难。但可能没有使用过 egui/ iced/ druid / tauri 这些 GUI 框架。
  4. 高性能科学计算领域目前还比较困难。AMD 和 Nvidia 都花费大量资金确保他们的编译器链与 C++ 兼容,因此使用 C++ 时很容易在 GPU 上启动和运行。但是像 rust-cuda 这样的库,依旧很有前景。
  5. 用 Rust 实现游戏,目前还没有一个成熟的游戏引擎。
  6. web 前端应用不会考虑 Rust 。
  7. 与 DevOps 相关的脚本。

还有很多评论,就不一一列出。这些评论里也存在一些对 Rust 语言的误解,总之,见仁见智吧。

有一句评论,我觉得说的很好:Rust 语言你平常可以不用,但是当你真正需要的时候,你会发现 Rust 语言很好。

有哪些重要特性是 Rust 缺失 而 C/Cpp 中存在的?

https://www.reddit.com/r/rust/comments/rj8gfg/which_important_features_from_cc_are_missing_in/

这篇帖子同样也是值得看的,这里摘录了部分评论:

  1. C 语言中的 位域,是 Rust 缺失的。至于Rust为什么目前没有支持,可能是考虑到 可移植性。如果Rust放弃可移植性,那么位域的支持不是什么困难的问题。
  2. Rust 标准库中还缺乏一些 侵入式的集合。
  3. Rust 目前无法为集合单独指定内存分配器。
  4. 没有稳定的 ABI 。 (好像 C/Cpp 都不存在稳定的 ABI,C-ABI只是一种事实标准)
  5. 泛型特化 / 泛型关联类型 / 泛型常量 等
  6. 面向零基础的 Rust 教学材料。为什么 C/Cpp 语言学习可以面向零基础?

Rust 虽然还在完善中,但其实并不一定要把 C/Cpp 支持的特性都再次支持一遍。不过这篇帖子里评论大部分还是比较中肯的,建议阅读。

用 Rust 编写科学计算有哪些资源?

https://www.reddit.com/r/rust/comments/rk12bg/writing_rust_libraries_for_the_python_scientific/

这里摘录该贴评论中提及的第三方库:

这篇帖子里评论数不是很多,在科学计算领域还有一些其他库,这里就不一一列出来了。

Rust 是在炒作吗?

https://www.reddit.com/r/rust/comments/rkx12s/stop_whining_about_rust_hype_a_prorust_rant/

有一篇文章《停止抱怨Rust炒作》在 Reddit 上引起了讨论。

该篇文章主要是劝诫一些人,停止抱怨 Rust 炒作,而应该去好好了解一下 Rust ,不要动不动就用“炒作”这个词。

国内技术社区我好像并没有看到“炒作”这个词用到 Rust 身上,但知乎里确实见了几个 Rust 黑粉。在国外技术社区论坛上也很少看见这类言论。毕竟 Rust 还是最受欢迎的语言。

我们来看看 Reddit 里评论怎么说的,这里摘录几条:

  1. “我选择在我的公司采用 Rust,并帮助所有其他 3 名工程师学习这门新语言。对于 Rust 炒作的各种抱怨,我基本上只是一个旁观者”。
  2. “我可以像编写 Python 一样快速地编写 Rust,其他人也可以。”
  3. “大约两年前,我想知道它是否会发生在我身上,或者我是否会在使用 Rust 构建之前使用 Python/F# 进行原型设计。现在,我可以确认,我用 Rust 的开发速度和用 Python 一样快”
  4. “大声笑,我记得几年前在 Discord 上看到有人这么说,并认为他满嘴胡话,现在……我可以像编写 Python 一样快速地编写 Rust。”

太多的评论就不贴了。你是否也认为 Rust 是过度炒作呢?至少我不这么认为,甚至我还觉得 Rust 从来都没有炒作过。

Rust 只是复制而不是 Move ?

来自: https://www.reddit.com/r/rust/comments/rlzhy1/rust_is_creating_copy_instead_of_moving/

Reddit 一位网友贴出如下代码:

struct Massive {
    a: [i128; 10000],
}

impl Massive {
    #[inline(always)]
    fn stupid(mut self) -> Self {
        println!("{:?}", &mut self.a[1] as *mut i128); // 0x7ffe178babc0

        //do some stuff to alter it
        self.a[1] += 23;
        self.a[4] += 24;
        
        self
    }

}

fn main() {
    let mut f = Massive { a: [10i128; 10000] }; // 0x7ffe17845870

    println!("{:?}", &mut f.a[1] as *mut i128);

    let mut f2 = f.stupid();

    println!("{:?}", &mut f2.a[1] as *mut i128); // 0x7ffe17893ac0
}

结构体 Massive 没有实现 Copy trait,所以默认是 Move 语义,但是 move 以后它们的地址都变了。这意味着每次 Rust 都是在复制这个大的结构体对象,性能很差。所以他好奇,为什么移动语义下还创建这三个副本?然后他在release编译时启用了lto优化,也无济于事。

他怀疑这是 Rust 的 Bug ,或者 Rust 没有实现类似 Cpp 里的 复制消除(Copy Elision)优化。

真的如此吗?

其实,在 Rust 里,Move 语义是语言的语义,像上面的结构体 Massive,它是存储在栈上。栈上的数据,如果要实现 Move 语义,那么必须经过 memcpy (按位复制)来进行 Move。

也就是说,Move 是语义,而 memcpy 是底层实现。语义和实现要分开理解。

复制消除(Copy Elision)是属于性能优化层面,不是语言的语义,不能混为一谈。

Reddit 中 一个网友指出,通过查看去除 println! 语句之后再编译后的汇编:https://godbolt.org/z/G5Ghr5jxd,发现并没有通过 memcpy指令进行复制。所以看上去是打印语句抑制了编译器的复制消除优化。

所以,Rust 编译器是有 复制消除 优化的,只不过 println! 打印语句中使用了借用,而抑制了编译器优化。

至于这个问题本身就是错误的,不应该是“为什么只是复制而不是Move”,而应该换成 “为什么 Move 没有优化” 才对。 移动语义本身就是复制数据,至于只复制指针,还是整个结构,这取决于这个数据结构自身如何存储。

移动语义是 Rust 中唯一的语义。Rust 确实提供了两个工具来提供 C++ 的面向复制的语义:Copy和Clone。但是 Clone 永远不会被隐式调用,它只能被显式调用。Copy 是 Clone 的一个特例,它只实现了按位复制。并且为一些基本数据类型实现了 Copy ,在赋值的时候隐式调用,但这个只是一种手段,避免Move以后的类型变为未初始化而已。Move 语义的本质就是让移动的变量变为未初始化状态,编译器就不允许它继续被使用了。

还是那句话,语义和实现不要混为一谈。

你梦想中的 Rust 大学课程是什么样的?

https://www.reddit.com/r/rust/comments/rwp8mo/teaching_rust_at_the_university_of_warsaw/

该贴作者在波兰华沙(Warsaw)大学 担任助教,一直在教授 C 语言中的内存管理和编程(第一年)和 Java 和 C 中的并发和并行编程(第二年)。目前该学校决定为二年级学生开设 Rust 课程,他被委托负责准备课程和授课。

他想向 Rust 开发者寻求建议,你梦想中的 Rust 大学课程是什么样的呢?

背景:该课程将包括约 13 次 1.5 小时的每周会议(结合讲座和实验室)。将有 2 个小组,大约 15 名学生参加(这不是必修课,可以从列表中选择)。学生将具备 Java、C 和 C++(包括其最新特性,如概念)、并发编程和算法以及数据结构的丰富知识。

他目前的教学计划大概如下:

  1. 将官方的 the book 作为教材。作为教师,他会将 the book 中的知识浓缩为 PPT 进行教授。在学习语法的过程中,也掺杂一些 Rust 生态的最佳实践内容。
  2. 在学习完 the book 之后,将以异步为主要侧重内容。
  3. 设置五个不同等级的小任务,让学生完成,每个任务有十天时间来完成。

详细内容可以参考reddit贴。在评论中有人给出一些建议:

  1. 耶鲁大学的钟林教授开设 《CPSC 429a, 计算机系统设计原理》 和 《CPSC 425b,移动和嵌入式系统 》 支持 Rust 语言。但是该课程不是公开课。
  2. 一位本科生使用 Rust 完成强化学习库:https://github.com/ZuseZ4/Rust_RL。但是他反对将 Rust 用于机器学习的教学中,因为使用 Python 学习机器学习更加简单。(这样说也很有道理,学习的时候要讲究学习目标,学生的学习目标是学习机器学习领域知识,而非 Rust)。
  3. 一位朋友说,在大学里应该教授学生如何解决问题。比如通过学习多线程生命游戏的实现,来学习解决多线程并发的问题。

Rust RFC static async fn in trait

本篇将会简要介绍什么是《This Week in Rust》,第420篇推文中有关Rust新通过RFC 3185 static async fn in trait、Linux支持以及Shellcode相关的内容。

RFC 3185:static async fn in trait

在《This Week in Rust #412》中,语言组发布了十月月会的决策内容,包括实现traits中的异步函数,对于这一部分具体的说明可见《This Week in Rust #412:Rust Async trait更新与多线程》。而在第420篇周报中,尽管dyn async fn in traits可能还需要时间,目前Rust已正式支持static async fn in traits。具体的RFC可见《Static async fn in traits》。

概括来说,Rust现在支持如下写法:


#![allow(unused)]
fn main() {
trait Service {
    async fn request(&self, key: i32) -> Response;
}

struct MyService {
    db: Database
}

impl Service for MyService {
    async fn request(&self, key: i32) -> Response {
        Response {
            contents: self.db.query(key).await.to_string()
        }
    }
}
}

async fn request的实现必须包含async关键字,不可以使用去糖化的形式(即在函数体内编写async块),不然编译器会报错,提示"expected async fn"。

原理上来说,去糖化后,async fn request(&self, key: i32) -> Response的实际返回值为一个匿名泛型关联类型(Generic Associated Type,GAT)。这个类型的限制会与fn request保持一致,也就是说,如果request这个函数含有where语句限定,这个类型也会保留这些限定。例如:


#![allow(unused)]
fn main() {
trait Example {
    async fn method<P0..Pn>(&self)
    where
        WC0..WCn;
}

// Becomes:

trait Example {
    type $<'me, P0..Pn>: Future<Output = ()>
    where
        WC0..WCn, // Explicit where clauses
        Self: 'me; // Implied bound from `&self` parameter

    // use $ to represent the anonymous GAT's name
    fn method<P0..Pn>(&self) -> Self::$<'_, P0..Pn>
    where
        WC0..WCn;
}
}

async fn的去糖化与其他异步函数相同:


#![allow(unused)]
fn main() {
// Source
async fn example(x: &str) -> usize {
    x.len()
}

// Desugared
// For async fn, type Self::$<'_, P0..Pn> is equal to an impl Future type, but not exactly the impl Future
fn example<'a>(x: &'a str) -> impl Future<Output = usize> + 'a {
    async move { x.len() }
}
}

Rust与Linux

2020年Linux线上会议中,由于内存安全的特性,Rust被提议作为Linux内核的第二官方语言使用。当三分之二的安全问题可以追溯至内存使用,相当程度上保证内存安全但并不增加任何运行时开销的Rust无疑是可行的解决方案。尽管无法用Rust重构内核近25,000,000行C代码,Linux内核作者Linus Torvalds认为在驱动以及一些非核心内核程序上引入Rust是一个不错的开头,因为这些组件并不被核心内核程序依赖,修改它们并不会带来连锁反应。Rust-for-linux项目由此成立。

2021年4月14日,新的Linux RFC介绍了Rust for Linux项目、使用Rust的理由以及设计理念,该项目目前只涉及在编写驱动以及非核心程序时提供Rust支持。尽管在内核中重新引入新的语言有失去长期支持的风险,Rust本身也不够成熟,项目组人员认为Rust的内存安全特性、由此带来的错误减少以及重构便利会超过引入Rust所带来的麻烦。

项目组于7月4日以及12月6日发布了项目进度汇总Patch series v1以及v2。Patch series v2中,内核项目的Rust代码不再依赖于beta版本的编译器,而是稳定版本的,使得Rust代码将会更可靠。项目组将会持续推进演进代码,尽量使其不依赖于任何不稳定功能。

Rust与Shellcode

机器码(Machine Code)

任何低级编程语言都可以被称为机器码,机器码可以视作是汇编程序更低一层的表示,是一种更原始的、依赖于硬件的编程语言。机器码由架构指令集组成,可以直接控制CPU执行某些指令,例如load,store,jump,等等。机器码由纯数字构成,例如:

  • 指令部分的示例
    1. 0000 代表 加载(LOAD)
    2. 0001 代表 存储(STORE)

...

  • 寄存器部分的示例
    1. 0000 代表寄存器 A
    2. 0001 代表寄存器 B

...

  • 存储器部分的示例

    1. 000000000000 代表地址为 0 的存储器
    2. 000000000001 代表地址为 1 的存储器
    3. 000000010000 代表地址为 16 的存储器
    4. 100000000000 代表地址为 2^11 的存储器
  • 集成示例

    1. 0000,0000,000000010000 代表 LOAD A, 16
    2. 0000,0001,000000000001 代表 LOAD B, 1
    3. 0001,0001,000000010000 代表 STORE B, 16
    4. 0001,0001,000000000001 代表 STORE B, 1

另一个例子是,在MIPS架构环境下,机器码指令固定长度为32 bits。不同类型指令以及对应格式为:

   6      5     5     5     5      6     bits
[  op  |  rs |  rt |  rd |shamt| funct]  R-type
[  op  |  rs |  rt | address/immediate]  I-type
[  op  |        target address        ]  J-type

示例机器码:

R-type:

[  op  |  rs |  rt |  rd |shamt| funct]
0     1     2     6     0     32     decimal
000000 00001 00010 00110 00000 100000   binary

I-type:

[  op  |  rs |  rt | address/immediate]
35     3     8           68           decimal
100011 00011 01000 00000 00001 000100   binary

J-type:

 [  op  |        target address        ]
    2                 1024               decimal
 000010 00000 00000 00000 10000 000000   binary

由于手算地址以及管理bits太过繁琐,一般开发并不会直接编写机器码。高级语言的编译器会将代码转译成机器码。

例如对于gcc编译器:

  • gcc -E hello.c -o hello.i:展开#include与宏进行预编译,输出对应的体积更大的文本文件
  • gcc -S hello.i -o hello.s:生成汇编代码
  • gcc -c hello.s -o hello.o:将汇编代码转译为机器码
  • gcc hello.o -o hello:链接机器码,生成可执行文件

可执行文件相当于以某种格式链接、排布机器码,使得计算机可以根据机器码执行一系列指令。*.o文件并不一定是计算机可执行的机器码,可执行文件可能会包括实际执行中需要的额外信息,例如运行时。实际执行时,可执行文件必须与当前系统的应用二进制接口(Application Binary Interface,ABI)一致。应用二进制接口指两程序模块间的接口,通常其中一个程序模块会是库或操作系统所提供的服务,而另一边的模块则是用户所执行的程序。ABI定义了机器码如何访问数据结构与运算程序,此处所定义的界面相当低端并且相依于硬件。而类似概念的API则在源代码定义,较为高端,并不直接依赖于硬件,通常会是人类可阅读的代码。对于某些比较简单的接口,执行这些文件会将其装在到内存中,跳转到指令开始地址,并且从此开始执行指令;更复杂的接口或许会要求可执行文件提供metadata,说明程序入口。

不过,虽然机器码是纯数字,不可以直接阅读,可执行文件反而并不一定如此。以脚本语言撰写的脚本文件,都可以是可执行文件,而且内含的资料可被人类阅读,多数以ASCII文本存档。脚本语言无需经过编译器预先编译,就可经过解释器(如Perl、Python、Shell)执行。

字节码

字节码的定义可见《机器码(machine code)和字节码(byte code)是什么?》:

字节码(Bytecode)是一种包含执行程序、由一序列 OP代码(操作码)/ 数据对组成的二进制文件。 字节码是一种中间码,它比机器码更抽象,需要直译器转译后才能成为机器码的中间代码。

通常情况下它是已经经过编译,但与特定机器码无关。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。

字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。

字节码的典型应用为Java bytecode。 字节码在运行时通过JVM(JAVA虚拟机)做一次转换生成机器指令,因此能够更好的跨平台运行。

总结:字节码是一种中间状态(中间码)的二进制代码(文件)。需要直译器转译后才能成为机器码。

Shellcode

Shellcode是十六进制的机器码。在计算机攻防中,shellcode通常被作为传输信息的一部分(Payload)使用。攻击者通常用指定机器码打开命令行(Command Shell),从而通过Shell控制机器,Shellcode也因此得名。不过,机器码不仅仅可以打开命令行,也可以执行其他的指令,因此Shellcode的命名在某种程度上实际上并不准确。

缓冲区溢出攻击就利用了Shellcode,通过缓冲区溢出覆写内存中原有的代码,使得当其他指令跳转到该代码地址时,由于原有代码已被覆盖,程序并不会按原逻辑执行,而是执行给定的机器码。如果运行程序本身具有root权限,此时再利用机器码打开Shell,那么攻击者就获得了具有root权限的Shell,可以对系统进行任意操作。不过,对于Rust而言,Rust并不像C直接使用指针读写数据,而是使用借用检查等机制,在非unsafe代码块中避免内存问题,因此对于Rust非unsafe代码块,缓冲区溢出攻击几乎是不可能的。

由于手写机器码并不现实,通常编写Shellcode的方式是反汇编可执行文件或从汇编后二进制中提取十六进制Shellcode。对于*.o文件,可以通过Linux的反汇编指令objdump来获取二进制文件可能带有的附加信息,在这个场景下,即阅读其中的机器码。

例如,对于代码:

main()
{
    exit(0);
}

生成exit_shellcode.o以后,输入objdump -d exit_shellcode

1

不过,这串Shellcode中还包括Null字符,即\x00,尽管逻辑成立,但是在实际使用时需要想办法去掉这些字符,否则该值会被认为是终止符,运行会发生异常。由于xor指令在操作数相等的情况下返回0,也就是可以在指令里不使用0,但是结果返回0,可以使用xor来代替mov指令,一个可能的改写方式为:

1

由此,获得的Shellcode为:

// 机器码
char shellcode[] = "\x31\xdb"
                   "\xb0\x01"
                   "\xcd\x80";
int main()
{
    int *ret;
    ret = (int *)&ret + 2;
    (&ret) = (int)shellcode;
}

在该程序中,直接使用该Shellcode会导致程序退出,即执行exit(0)

Rust与Shellcode

How to execute shellcodes from memory in Rust》介绍了如何在Rust中运行Shellcode。例如,对于Shellcode:

488d35140000006a01586a0c5a4889c70f056a3c5831ff0f05ebfe68656c6c6f20776f726c640a

x86_64架构下,本质上这段Shellcode打印了字符串hello world!。具体指令如下:

$ echo '488d35140000006a01586a0c5a4889c70f056a3c5831ff0f05ebfe68656c6c6f20776f726c640a' | xxd -r -p > shellcode.bin\
$ objdump -D -b binary -mi386 -Mx86-64 -Mintel shellcode.bin

shellcode.bin:     file format binary


Disassembly of section .data:

00000000 <.data>:
   0:   48 8d 35 14 00 00 00    lea    rsi,[rip+0x14]        # 0x1b
   7:   6a 01                   push   0x1
   9:   58                      pop    rax
   a:   6a 0c                   push   0xc
   c:   5a                      pop    rdx
   d:   48 89 c7                mov    rdi,rax
  10:   0f 05                   syscall           // <- write(1, "hello world\n", 12)
  12:   6a 3c                   push   0x3c
  14:   58                      pop    rax
  15:   31 ff                   xor    edi,edi
  17:   0f 05                   syscall           // <- exit
  19:   eb fe                   jmp    0x19
  1b:   68 65 6c 6c 6f          push   0x6f6c6c65 // <- hello world\n
  20:   20 77 6f                and    BYTE PTR [rdi+0x6f],dh
  23:   72 6c                   jb     0x91
  25:   64                      fs
  26:   0a                      .byte 0xa

std::mem::transmute接口定义如下:


#![allow(unused)]
fn main() {
pub const unsafe extern "rust-intrinsic" fn transmute<T, U>(e: T) -> U
}

这一函数可以将组成某一个值的bits认定为另一个给定的类型。由于编译器无法保证这个转换,非常可能导致未定义行为,转换是unsafe

可以通过这个函数,将Shellcode中包含的内容认定为某个函数,通过调用函数来执行机器码。可以通过如下代码将Shellcode存储至程序.text部分,并调用std::mem::transmute来执行:

use std::mem;

// we do this trick because otherwise only the reference of the buffer is in the .text section
// and not the buffer itself
const SHELLCODE_BYTES: &[u8] = include_bytes!("../shellcode.bin");
const SHELLCODE_LENGTH: usize = SHELLCODE_BYTES.len();

#[no_mangle]
#[link_section = ".text"]
static SHELLCODE: [u8; SHELLCODE_LENGTH] = *include_bytes!("../shellcode.bin");

fn main() {
    let exec_shellcode: extern "C" fn() -> ! =
        unsafe { mem::transmute(&SHELLCODE as *const _ as *const ()) };
    exec_shellcode();
}

include_bytes!会读取目标文件的内容,并将变量SHELLCODE_BYTES视作是对其内容的引用。通过解引用,Shellcode的实际内容存储至.text区块,并通过std::mem::transmute将其认定为函数。

另一个方法是使用crate mmap。mmap可以将内存中指定的某个区域认作是可执行的,经过一些变换以后,可以通过函数名来尝试执行区域中的代码。使用mmap的例子如下:

use mmap::{
    MapOption::{MapExecutable, MapReadable, MapWritable},
    MemoryMap,
};
use std::mem;

// as the shellcode is not in the `.text` section, we can't execute it as it
const SHELLCODE: &[u8] = include_bytes!("../shellcode.bin");

fn main() {
    let map = MemoryMap::new(SHELLCODE.len(), &[MapReadable, MapWritable, MapExecutable]).unwrap();

    unsafe {
        // copy the shellcode to the memory map
        std::ptr::copy(SHELLCODE.as_ptr(), map.data(), SHELLCODE.len());
        let exec_shellcode: extern "C" fn() -> ! = mem::transmute(map.data());
        exec_shellcode();
    }
}

Rust 裸函数相关新RFC 和 序列化

本篇将会简要介绍什么是《This Week in Rust》,第418、419篇推文中有关Rust新RFC,第417篇推文中有关Rust Serde实现序列化相关的内容。

RFC 2972:Constrained Naked Functions

裸函数(Naked Functions)可以像普通函数一样定义,但是实际上与普通的函数不同。裸函数不会入栈,也就是说,并不像普通函数在被调用或结束调用时,会存储或恢复寄存器,裸函数没有函数序言(Prologue)和尾声(Epilogue),而是更类似于C中的GOTO标签。如果在裸函数中写return,此种行为将是未定义的,因为并不像普通函数返回时读取寄存器中存储的地址,没有函数序言和尾声的裸函数会读取调用者存储的return地址,return并不会像想象中的逻辑一样被执行。

在此前,Rust复制了其他编译器支持裸函数的方式,以在Rust中实现这个功能,但是由于裸函数的未定义行为以及复杂的管理模式,往往不被推荐使用。RFC 2972尝试更好地在Rust中限定与定义裸函数,使其便利性能够被利用。

在Rust中,裸函数可以通过#[naked]来表示,函数体应只包含汇编代码。详细的规则可见RFC 2972,概述如下:

  • 除了extern 'Rust'以外,必须声明调用规则
  • 应当只规定FFI安全参数以及返回值
  • 不可以使用#[inline]#[inline(*)]属性
  • 函数体只包含单一asm!()表达式,且该表达式:
    • 除了constsym不能包含任何其他操作符,避免使用或引用栈上变量导致未定义行为
    • 必须包含noreturn选项
    • 除了att_syntax以外不能包含其他选项
    • 必须保证函数是unsafe的或满足调用规则

编译器将不允许其中出现未使用的变量,并且隐式使用属性#[inline(never)]。使用例子如下:


#![allow(unused)]
fn main() {
const THREE: usize = 3;

#[naked]
pub extern "sysv64" fn add_n(number: usize) -> usize {
    unsafe {
        asm!(
            "add rdi, {}"
            "mov rax, rdi"
            "ret",
            const THREE,
            options(noreturn)
        );
    }
}
}

例中调用规范为sysv64,因此函数输入位于 rdi寄存器,函数返回位于rax寄存器。

不过,这个方案依然存在缺陷。这样的实现方法无法兼容目前nightly版本的#[naked]属性使用,也会改变asm!()的参数定义。不作为asm!()操作符的寄存器原本定义为包含未定义值,然而在修订后裸函数的语境下,这个定义被更新为初始寄存器状态不受函数调用修改。其次这些定义相较于原始的裸函数定义,或许过为严格。在裸函数中使用Rust而不是汇编理论可行,但是实际上也非常困难。最后,不同架构对于裸函数的支持或许不同。

RFC 3180:Cargo --crate-type CLI Argument

crate-type属性定义了cargo build会生成的目标crate类型,只能对于库(Library)与例子(Example)声明。二进制(Binaries)、测试(Tests)以及Benchmarks永远都是"bin"类型。

默认类型如下:

TargetCrate Type
Normal library"lib"
Proc-macro library"proc-macro"
Example"bin"

可声明选项具体解释可见"Linkage"。概括如下:

  • bin:可执行文件。
  • lib:Rust库,具体类型以及表达方式由编译器决定,输出库可以被rustc使用。
  • rlib:Rust库文件,类似于静态库,但是会在未来的编译中被编译器链接,这一点形似动态库。
  • dylib:动态库,可以作为其他库或是可执行文件依赖,文件类型在Linux中为*.so,macOS中为*.dylib,Windows中为*.dll
  • cdylib:动态系统库,当编译需要提供给其他语言调用的库时使用,文件类型在Linux中为*.so,macOS中为*.dylib,Windows中为*.dll
  • staticlib:静态库,编译器永远不会尝试链接静态库,库会包括所有的本地crate代码以及所有上游依赖项,文件类型在Linux、macOS、Windows(minGW)中为*.a,Windows(MSVC)中为*.lib
  • proc-macro:输出形式将不在此说明,但是要求提供-L路径,编译器将认为输出文件是一个可以用于某个程序的宏,并且依赖于当前架构编译。由这个选项编译的crate应当只输出过程宏。

RFC 3180新增了Cargo的命令行使用功能,--crate-type <crate-type>可以作为命令行参数输入了,功能与在Cargo.toml中定义crate-type一致,不过享有更高的优先级。也就是说,如果命令行参数定义值与Cargo.toml中定义值冲突,Cargo会认为该值与命令行输入相同。

序列化与Serde

序列化(Serialization)是将对象的状态信息转换为可以存储或传输的形式的过程,反序列化(Deserialization)则是其逆过程。Serde是Rust的一个序列化与反序列化框架,可以解析Json、Yaml、Toml、Bson以及其他一些序列化文件。serde_json则基于此框架,实现Json文件序列化与反序列化。

Serde

Serde通过trait实现序列化与反序列化。如果一个数据结构实现了Serde的SerializeDeserialize trait,那么它就可以被序列化或反序列化。

Serialize

Serde的Serialize trait定义如下:


#![allow(unused)]
fn main() {
pub trait Serialize {
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer;
}
}

实现Serialize trait的实例可以调用方法serialize来序列化,并使用对应的实现Serializer trait的实例作为参数。Serializer trait定义如下:


#![allow(unused)]
fn main() {
pub trait Serializer: Sized {
    type Ok;
    type Error: Error;
    type SerializeSeq: SerializeSeq<Ok = Self::Ok, Error = Self::Error>;
    type SerializeTuple: SerializeTuple<Ok = Self::Ok, Error = Self::Error>;
    type SerializeTupleStruct: SerializeTupleStruct<Ok = Self::Ok, Error = Self::Error>;
    type SerializeTupleVariant: SerializeTupleVariant<Ok = Self::Ok, Error = Self::Error>;
    type SerializeMap: SerializeMap<Ok = Self::Ok, Error = Self::Error>;
    type SerializeStruct: SerializeStruct<Ok = Self::Ok, Error = Self::Error>;
    type SerializeStructVariant: SerializeStructVariant<Ok = Self::Ok, Error = Self::Error>;
    
    // 需要实现的方法
    fn serialize_bool(self, v: bool) -> Result<Self::Ok, Self::Error>;
    fn serialize_i8(self, v: i8) -> Result<Self::Ok, Self::Error>;
    fn serialize_i16(self, v: i16) -> Result<Self::Ok, Self::Error>;
    fn serialize_i32(self, v: i32) -> Result<Self::Ok, Self::Error>;
    fn serialize_i64(self, v: i64) -> Result<Self::Ok, Self::Error>;
    fn serialize_u8(self, v: u8) -> Result<Self::Ok, Self::Error>;
    fn serialize_u16(self, v: u16) -> Result<Self::Ok, Self::Error>;
    fn serialize_u32(self, v: u32) -> Result<Self::Ok, Self::Error>;
    fn serialize_u64(self, v: u64) -> Result<Self::Ok, Self::Error>;
    fn serialize_f32(self, v: f32) -> Result<Self::Ok, Self::Error>;
    fn serialize_f64(self, v: f64) -> Result<Self::Ok, Self::Error>;
    fn serialize_char(self, v: char) -> Result<Self::Ok, Self::Error>;
    fn serialize_str(self, v: &str) -> Result<Self::Ok, Self::Error>;
    fn serialize_bytes(self, v: &[u8]) -> Result<Self::Ok, Self::Error>;
    fn serialize_none(self) -> Result<Self::Ok, Self::Error>;
    fn serialize_some<T: ?Sized>(
        self, 
        value: &T
    ) -> Result<Self::Ok, Self::Error>
    where
        T: Serialize;
    fn serialize_unit(self) -> Result<Self::Ok, Self::Error>;
    fn serialize_unit_struct(
        self, 
        name: &'static str
    ) -> Result<Self::Ok, Self::Error>;
    fn serialize_unit_variant(
        self, 
        name: &'static str, 
        variant_index: u32, 
        variant: &'static str
    ) -> Result<Self::Ok, Self::Error>;
    fn serialize_newtype_struct<T: ?Sized>(
        self, 
        name: &'static str, 
        value: &T
    ) -> Result<Self::Ok, Self::Error>
    where
        T: Serialize;
    fn serialize_newtype_variant<T: ?Sized>(
        self, 
        name: &'static str, 
        variant_index: u32, 
        variant: &'static str, 
        value: &T
    ) -> Result<Self::Ok, Self::Error>
    where
        T: Serialize;
    fn serialize_seq(
        self, 
        len: Option<usize>
    ) -> Result<Self::SerializeSeq, Self::Error>;
    fn serialize_tuple(
        self, 
        len: usize
    ) -> Result<Self::SerializeTuple, Self::Error>;
    fn serialize_tuple_struct(
        self, 
        name: &'static str, 
        len: usize
    ) -> Result<Self::SerializeTupleStruct, Self::Error>;
    fn serialize_tuple_variant(
        self, 
        name: &'static str, 
        variant_index: u32, 
        variant: &'static str, 
        len: usize
    ) -> Result<Self::SerializeTupleVariant, Self::Error>;
    fn serialize_map(
        self, 
        len: Option<usize>
    ) -> Result<Self::SerializeMap, Self::Error>;
    fn serialize_struct(
        self, 
        name: &'static str, 
        len: usize
    ) -> Result<Self::SerializeStruct, Self::Error>;
    fn serialize_struct_variant(
        self, 
        name: &'static str, 
        variant_index: u32, 
        variant: &'static str, 
        len: usize
    ) -> Result<Self::SerializeStructVariant, Self::Error>;
    
    // 已实现方法
    fn serialize_i128(self, v: i128) -> Result<Self::Ok, Self::Error> { ... }
    fn serialize_u128(self, v: u128) -> Result<Self::Ok, Self::Error> { ... }
    fn collect_seq<I>(self, iter: I) -> Result<Self::Ok, Self::Error>
    where
        I: IntoIterator,
        <I as IntoIterator>::Item: Serialize,
    { ... }
    fn collect_map<K, V, I>(self, iter: I) -> Result<Self::Ok, Self::Error>
    where
        K: Serialize,
        V: Serialize,
        I: IntoIterator<Item = (K, V)>,
    { ... }
    fn collect_str<T: ?Sized>(self, value: &T) -> Result<Self::Ok, Self::Error>
    where
        T: Display,
    { ... }
    fn is_human_readable(&self) -> bool { ... }
}
}

Serde试图通过此trait中的方法,将所有的Rust数据结构分类至某一种可能的类型中。当为某个数据类型实现Serialize时,只要调用对应的方法即可。例如:


#![allow(unused)]
fn main() {
impl<T> Serialize for Option<T>
where
    T: Serialize,
{
    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    where
        S: Serializer,
    {
        match *self {
            Some(ref value) => serializer.serialize_some(value),
            None => serializer.serialize_none(),
        }
    }
}
}

Serialize trait已对大部分原始类型实现,具体列表可见"Module serde::ser"Serializer trait的实现则由第三方库或用户自定义,如serde_json

serde_json与序列化

serde_json序列化使用如下例:


#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use serde_json::Result;

#[derive(Serialize, Deserialize)]
struct Address {
	street: String,
	city: String,
}

let address = Address {
	street: "10 Downing Street".to_owned(),
	city: "London".to_owned(),
};

// Serialize it to a JSON string.
let j = serde_json::to_string(&address)?;

// Print, write to a file, or send to an HTTP server.
println!("{}", j);
}

或是对于一个Json对象,Value进行序列化操作:


#![allow(unused)]
fn main() {
let John = json!({
	"name": "John Doe",
	"age": 43,
	"phones": [
		"+44 1234567",
		"+44 2345678"
	]
});

println!("first phone number: {}", john["phones"][0]);

// Convert to a string of JSON and print it out
println!("{}", john.to_string());
}

serde_json提供枚举Value,成员类型与Json对应,包括NullBoolNumberStringArrayObject,实现Display trait与to_string方法,以此将Json对象(在serde_json中,即Value实例)转换为字符串。to_string通过一个实现ser::Serializer trait并包含WriterFormatter的同名结构体Serializer对象来实现,Display的成员方法fmt会对于Value对象调用serialize方法,而serialize方法会调用该同名结构体对象。该对象实现ser::Serializer,在各个序列化方法中调用Formatter的方法函数写出字符串至WriterFormatter则负责书写的格式,例如match布尔值然后输出对应的字符串"false"或"true",以及在每个序列化字符串之前和之后添加相应字符。

对于实现Serialize trait的对象,可以通过to_value方法将其转换为Value对象,to_value将会调用value.serialize,函数参数为serde_json::value::Serializerserde_json/value/ser.rs定义了同名结构体Serializer,根据Json对象规则,将输入转换为对应的Value对象。

对于自定义的struct,可以通过#[derive(Serialize)]来使其获得该属性。#[derive(Serialize)]过程宏的定义fn derive_serialize(input: TokenStream)serde/serde_derive crate中,并依赖于rust库,proc-macro2synproc-macro2是编译器crate proc_macro的包装,提供了新的接口以及功能,例如实现synquotesyn是Rust源码解析库,可以将Rust代码解析成抽象语法树,quote则提供了将抽象语法树转换为Rust源码的功能。derive_serialize宏将输入的TokenStream使用as显示转换为syn::DeriveInput,也就是将目标结构体或枚举递归解析为语法树,并记录它的属性、可见性、名字、使用到的泛型、以及数据。ser::expand_derive_serialize则接受这个语法树作为参数,对其进行一系列过滤与再处理后,使用quote库实现impl serde::Serialize的过程宏。在ser.rs中,serde_derive提供了例如serialize_enumserialize_structserialize_tuple_structserialize_variant等函数,对于不同的对象类型实现对应的impl源码。

例如,对于自定义结构体Person#[derive(Serialize)]展开结果如下:


#![allow(unused)]
fn main() {
use serde::ser::{Serialize, SerializeStruct, Serializer};

struct Person {
	name: String,
    age: u8,
    phones: Vec<String>,
}

// This is what #[derive(Serialize)] would generate.
impl Serialize for Person {
	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
		where
			S: Serializer,
	{
		let mut s = serializer.serialize_struct("Person", 3)?;
		s.serialize_field("name", &self.name)?;
		s.serialize_field("age", &self.age)?;
		s.serialize_field("phones", &self.phones)?;
		s.end()
	}
}
}

依赖于在serde_json中已经定义好的Serializer,可以将Person实例转换为对应的Json对象或是Json格式字符串。

此外,serde_json提供了宏json!(),可以将Json格式的token串转换为Value实例,例如,


#![allow(unused)]
fn main() {
use serde_json::json;

let value = json!({
	"code": 200,
	"success": true,
	"payload": {
		"features": [
			"serde",
			"json"
		]
	}
});
}

该宏将输入解析为抽象语法树,并定义内部宏json_internal,对于不同的语法树匹配做不同的处理,并且生成对应的Value实例。

Deserialize

Serde的Deserialize trait定义如下:


#![allow(unused)]
fn main() {
pub trait Deserialize<'de>: Sized {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>;
}
}

同样,实现Deserialize trait的实例可以调用方法deserialize来反序列化,即从某个格式的数据输入中恢复实例。方法deserialize将使用实现了Deserializer trait的实例作为参数。Deserializer trait定义如下:


#![allow(unused)]
fn main() {
pub trait Deserializer<'de>: Sized {
    type Error: Error;
    fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_byte_buf<V>(
        self, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_unit_struct<V>(
        self, 
        name: &'static str, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_newtype_struct<V>(
        self, 
        name: &'static str, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_tuple<V>(
        self, 
        len: usize, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_tuple_struct<V>(
        self, 
        name: &'static str, 
        len: usize, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_struct<V>(
        self, 
        name: &'static str, 
        fields: &'static [&'static str], 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_enum<V>(
        self, 
        name: &'static str, 
        variants: &'static [&'static str], 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_identifier<V>(
        self, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;
    fn deserialize_ignored_any<V>(
        self, 
        visitor: V
    ) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>;

    fn deserialize_i128<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>,
    { ... }
    fn deserialize_u128<V>(self, visitor: V) -> Result<V::Value, Self::Error>
    where
        V: Visitor<'de>,
    { ... }
    fn is_human_readable(&self) -> bool { ... }
}
}

对于某种格式的数据输入,Deserializer trait提供了转换数据为Rust实例的方法。与Serialize trait相同,当为某个数据类型实现Deserialize时并希望以后通过这个trait恢复数据结构时,只要调用对应的方法即可。例如:


#![allow(unused)]
fn main() {
#[cfg(any(feature = "std", feature = "alloc"))]
impl<'de> Deserialize<'de> for String {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_string(StringVisitor)
    }

    fn deserialize_in_place<D>(deserializer: D, place: &mut Self) -> Result<(), D::Error>
    where
        D: Deserializer<'de>,
    {
        deserializer.deserialize_string(StringInPlaceVisitor(place))
    }
}
}

同样,虽然与实现Serialize trait的原始类型列表不同,Deserialize trait也对很多原始类型实现,具体列表可见"Module serde::de"Deserializer trait的实现同样由第三方库或用户自定义。

serde_json与反序列化

serde_json反序列化使用如下例:


#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use serde_json::Result;

#[derive(Serialize, Deserialize)]
struct Person {
	name: String,
	age: u8,
	phones: Vec<String>,
}

// Some JSON input data as a &str. Maybe this comes from the user.
let data = r#"
	{
		"name": "John Doe",
		"age": 43,
		"phones": [
			"+44 1234567",
			"+44 2345678"
		]
	}"#;

// Parse the string of data into serde_json::Value.
let v: Value = serde_json::from_str(data)?;

// Parse the string of data into a Person object. This is exactly the
// same function as the one that produced serde_json::Value above, but
// now we are asking it for a Person as output.
let p: Person = serde_json::from_str(data)?;

// Do things just like with any other Rust data structure.
println!("Please call {} at the number {}", p.name, p.phones[0]);
}

from_str定义如下:


#![allow(unused)]
fn main() {
fn from_trait<'de, R, T>(read: R) -> Result<T>
where
    R: Read<'de>,
    T: de::Deserialize<'de>,
{
    let mut de = Deserializer::new(read);
    let value = tri!(de::Deserialize::deserialize(&mut de));

    // Make sure the whole stream has been consumed.
    tri!(de.end());
    Ok(value)
}
}

from_str会间接调用指定实例的de::Deserialize::deserialize(&mut de)方法,实例类型由赋值时指定变量的类型决定。同名结构体Deserializer负责将Json数据转换为Value对象,实现de::Deserializer trait,并实现解析方法。例如,对于给定输入,fn deserialize_any<V>(self, visitor: V)peek第一个字符,并进入选择分支。对于布尔值匹配如下:


#![allow(unused)]
fn main() {
b't' => {
    self.eat_char();
    tri!(self.parse_ident(b"rue"));
    visitor.visit_bool(true)
}
b'f' => {
    self.eat_char();
    tri!(self.parse_ident(b"alse"));
    visitor.visit_bool(false)
}
}

tri!实现相当于Result::map的功能,parse_ident会逐字检查输入是否符合预期值,例如检查布尔值是否是"true"或"false"。visitor实例实现serde::de::Visitor trait,表明给定的输入是否满足预期格式。在布尔值匹配的例子中,根据给定的visitor实例规则,visit_bool方法将返回对应的值或错误。

对于Value实例,de::Deserialize::deserialize(deserializer: serde::Deserializer<'de>)方法中定义了ValueVisitor,实现serde::de::Visitor trait的各个方法,并规定当给定输入满足Json格式时,返回相应的值,反之则报错,随后调用deserializer.deserialize_any(ValueVisitor),从而将Json格式字符串转换为对应的Value实例。

过程宏#[derive(Deserialize)]的实现与#[derive(Serialize)]类似,同样先将目标结构体源码解析为语法树,随后调用de::expand_derive_deserialize。这个方法的代码逻辑和ser::expand_derive_serialize一致,并且同样在de.rs中提供deserialize_enumdeserialize_structdeserialize_tuple等函数实现impl源码。这些函数同样利用quote库,实现Visitor实例,并将其传递给deserializer.deserialize_any,从而实现Deserialize trait。

Rust for Linux 源码导读 | Ref 引用计数容器

作者:张汉东


引子

2022 年,我们很可能会看到 Linux 内核中的实验性 Rust 编程语言支持成为主流。2021.12.6 早上发出了更新的补丁,介绍了在内核中处理 Rust 的初始支持和基础设施。

这次更新的内容包括:

  1. 升级到了最新 Stable 编译器和 Rust 2021 edition 。因此可以摆脱了 const_fn_transmuteconst_panicconst_unreachable_uncheckedcore_panictry_reserve 这几个之前未稳定的特性。未稳定特性心愿单
  2. 自定义 corealloc。为 alloc 添加了更加模块化的选项,以便禁用一些他们不需要的功能:no_rcno_sync,主要是为上游 Rust 项目添加。
  3. 更严格的代码、文档和新的 lint
  4. 抽象和驱动程序更新。添加了序列锁、电源管理回调的抽象,io 内存(readX/writeX)、irq 芯片和高级流处理程序,gpio 芯片(包括 irq 芯片)、设备、amba 设备和驱动程序以及证书。此外,也改进并简化了 Refrefcount_t 支持)对象并用它替换了 Rust 的 Arc 的所有实例。完全地从 alloc crate 中删除了 ArcRc

从现在开始,Rust for linux 团队将开始定期提交补丁,每两周左右。

除了来自 Arm、Google 和 Microsoft 的支持外,这次该团队又收到一封来自红帽的信:红帽对 Rust 用于内核的工作也非常感兴趣(There is interest in using Rust for kernel work that Red Hat is considering)。

为什么需要引入 Ref 来代替 Arc

Rust for Linux 中这个 kernel crate 中之前使用的是 Arc ,但是现在换成了 Ref。 通过查看相关PR rust: update Ref to use the kernel's refcount_t ,可以了解其中主要有两点原因:

  1. 最大化利用现有的 C 代码 和 消除恐慌(Panic)。内核中已经有了引用计数的实现 refcount_t,而且它超过引用计数的阈值时,不是 Panic(abort) 而是返回最大值(饱和加法)。因为这个原因,也使用 RBTree(红黑树) 替代了 BTreeMap
  2. 不需要弱引用。

Arc 有一个 MAX_REFCOUNT 的限制,是 isize::MAX as usize 大小,引用计数加法超过该大小就会溢出然后发生Panic(abort)。

所以最终实现的 RefArc的区别在于:

  1. Ref 是基于内核的 refcount_t 来支持的
  2. 它不支持 弱引用,所以大小减少了一半
  3. 当它超过阈值时,它使得引用计数饱和(saturating)而非中止(abort)
  4. 它不提供 get_mut 方法,所以引用计数对象是 Pin 的。

Ref源码分析

接下来分析一下Ref的实现。

Ref 结构体

Ref 结构体定义如下:


#![allow(unused)]
fn main() {
/// A reference-counted pointer to an instance of `T`.
///
/// The reference count is incremented when new instances of [`Ref`] are created, and decremented
/// when they are dropped. When the count reaches zero, the underlying `T` is also dropped.
///
/// # Invariants
///
/// The reference count on an instance of [`Ref`] is always non-zero.
/// The object pointed to by [`Ref`] is always pinned.
pub struct Ref<T: ?Sized> {
    ptr: NonNull<RefInner<T>>,
    _p: PhantomData<RefInner<T>>,
}
}

它维护一个不变量(Invariants):引用计数 Ref 总是一个非零的一个实例,并且被 Ref引用的对象总是 Pin 的(不可移动)。

该结构体中使用 NonNull<T>,而非 *mut T,这里需要协变(covariant),而非不变(invariant)。可以参考下面示例:


#![allow(unused)]
fn main() {
use std::ptr::NonNull;

struct Ref<T: ?Sized> {
    x: NonNull<T>,
    // x: *mut T, // 如果换成 *mut T,编译将不会通过
}

fn take<'a>(r: Ref<&'a u32>, y: &'a u32) {}

fn give() -> Ref<&'static u32> { todo!() }

fn test() {
    let y = 1;
    // 协变,能传入 Ref<&'a u32> 的函数take,也能接收 Ref<&'static u32> 类型的参数,因为 'static: 'a ,能接受子类型,也能接受父类型
    take(give(), &y); 
}
}

NonNull*mut T的协变版本,并且也代表了非空指针,代表了引用计数对象总是非空的,因为当计数为零就会释放。

而这里使用 PhatomData 则是为了 Drop 检查,此处表示 Ref 类型拥有 RefInner<T>,当 Ref 被 Drop 的时候,RefInner<T>也能跟着被 Drop 。

RefInner 结构体

再来看 RefInner 结构体:


#![allow(unused)]
fn main() {
#[repr(C)]
struct RefInner<T: ?Sized> {
    refcount: Opaque<bindings::refcount_t>,
    data: T,
}
}

RefInner 内部包含了内核中 C 语言实现的引用计数结构体 refcount_t,这里就是为了复用 C 代码。

其中 Opaque 类型kernel crate 内置的专门为了和 C 打交道提供的一个包装类型,定义如下:


#![allow(unused)]
fn main() {
pub struct Opaque<T>(MaybeUninit<UnsafeCell<T>>);

impl<T> Opaque<T> {
    /// Creates a new opaque value.
    pub fn new(value: T) -> Self {
        Self(MaybeUninit::new(UnsafeCell::new(value)))
    }

    /// Creates an uninitialised value.
    pub fn uninit() -> Self {
        Self(MaybeUninit::uninit())
    }

    /// Returns a raw pointer to the opaque data.
    pub fn get(&self) -> *mut T {
        UnsafeCell::raw_get(self.0.as_ptr())
    }
}
}

Opaque 类型意味着永远都不需要 Rust 代码来解释的 FFi 对象。所以,为了使用内核中已经存在的引用计数结构体,这里用 Opaque<bindings::refcount_t>类型。

关于 refcount_t

Linux 内核中定义的 refcount_t 结构体定义如下:


// from: https://github.com/torvalds/linux/blob/master/tools/include/linux/refcount.h
typedef struct refcount_struct {
	atomic_t refs;
} refcount_t;

refcount_t API的目标是为实现对象的引用计数器提供一个最小的API。虽然内部使用了原子操作,但一些 refcount_*()atomic_*() 函数在内存顺序保证方面有很多不同。

refcount_t 在2018年曾经发生过 引用计数溢出的安全漏洞,即,当引用计数达到最大值时,如果再加一,则引用计数就会归零。所以,此时引用的对象就会被错误释放。这样就变成了一个 UAF(use-after-free) 漏洞,容易被人利用。

所以现在 refcount_t 被增加了引用计数检测:

// from: https://github.com/torvalds/linux/blob/master/tools/include/linux/refcount.h#L69

static inline __refcount_check
bool refcount_inc_not_zero(refcount_t *r)
{
	unsigned int old, new, val = atomic_read(&r->refs);

	for (;;) {
		new = val + 1;

		if (!val)
			return false;

		if (unlikely(!new))
			return true;

		old = atomic_cmpxchg_relaxed(&r->refs, val, new);
		if (old == val)
			break;

		val = old;
	}

	REFCOUNT_WARN(new == UINT_MAX, "refcount_t: saturated; leaking memory.\n");

	return true;
}

在达到引用计数最大时采用饱和(saturated)加法,即,返回最大的值,而非零。注意,这里使用了compare-and-swap原子操作且并未提供内存顺序(使用relaxed)。

Ref 实现的一些 trait

为了让 Ref<T> 拥有一些类似于 Arc<T> 的行为,所以为其实现一些内置 trait。


#![allow(unused)]
fn main() {
// This is to allow [`Ref`] (and variants) to be used as the type of `self`.
impl<T: ?Sized> core::ops::Receiver for Ref<T> {}

// This is to allow [`RefBorrow`] (and variants) to be used as the type of `self`.
impl<T: ?Sized> core::ops::Receiver for RefBorrow<'_, T> {}

// This is to allow coercion from `Ref<T>` to `Ref<U>` if `T` can be converted to the
// dynamically-sized type (DST) `U`.
impl<T: ?Sized + Unsize<U>, U: ?Sized> core::ops::CoerceUnsized<Ref<U>> for Ref<T> {}

// This is to allow `Ref<U>` to be dispatched on when `Ref<T>` can be coerced into `Ref<U>`.
impl<T: ?Sized + Unsize<U>, U: ?Sized> core::ops::DispatchFromDyn<Ref<U>> for Ref<T> {}

// SAFETY: It is safe to send `Ref<T>` to another thread when the underlying `T` is `Sync` because
// it effectively means sharing `&T` (which is safe because `T` is `Sync`); additionally, it needs
// `T` to be `Send` because any thread that has a `Ref<T>` may ultimately access `T` directly, for
// example, when the reference count reaches zero and `T` is dropped.
unsafe impl<T: ?Sized + Sync + Send> Send for Ref<T> {}

// SAFETY: It is safe to send `&Ref<T>` to another thread when the underlying `T` is `Sync` for
// the same reason as above. `T` needs to be `Send` as well because a thread can clone a `&Ref<T>`
// into a `Ref<T>`, which may lead to `T` being accessed by the same reasoning as above.
unsafe impl<T: ?Sized + Sync + Send> Sync for Ref<T> {}
}

从上面代码里看得出来,用到的 trait 有:

  • core::ops::Receiver : 是一个未稳定特性(receiver_trait features),它表示一个结构体可以作为方法接收者,不需要arbitrary_self_types 特性。标准库中一些智能指针实现了该trait,比如 Box<T>/ Arc<T> / Rc<T> / &T / Pin<P> 等。
  • core::ops::CoerceUnsized :也是一个未稳定特性(coerce_unsized features),它表示将 Size 类型转换为 DST 类型。
  • core::ops::DispatchFromDyn: 同样是一个未稳定的特性(dispatch_from_dyn features),它用于对象安全(动态安全 dyn safe)的检查。实现 DispatchFromDyn 的类型可以安全地用作对象安全方法中的 self 类型。
  • Send/Sync,是Rust 中稳定的特性,用于标记线程间可安全传递和共享的类型。

现在为 Ref<T> 实现了这些 trait,那么 Ref<T> 也就拥有了相应的行为。基本上 Ref<T> 的行为和 Arc<T> 类似了,除了上面所说的那些区别。

引用计数管理

因为 Ref<T> 是复用内核 C 代码,所以对于引用计数的管理,只需要实现相应的 trait 即可。

比如,Clone 时应该自增引用计数,而 Drop 时应该自减引用计数。所以,分别来看一下这两个实现。


#![allow(unused)]

fn main() {
// 实现 Clone trait
impl<T: ?Sized> Clone for Ref<T> {
    fn clone(&self) -> Self {
        // INVARIANT: C `refcount_inc` saturates the refcount, so it cannot overflow to zero.
        // SAFETY: By the type invariant, there is necessarily a reference to the object, so it is
        // safe to increment the refcount.
        unsafe { bindings::refcount_inc(self.ptr.as_ref().refcount.get()) };

        // SAFETY: We just incremented the refcount. This increment is now owned by the new `Ref`.
        unsafe { Self::from_inner(self.ptr) }
    }
}
}

实现 Clone trait 很简单,直接通过 bindings::refcount_inc 来调用内核中 refcount_t 的自增方法 refcount_inc即可。

因为 refcount_inc 已经是有了引用计数溢出检测,使用饱和加法,所以不用担心归零。


#![allow(unused)]
fn main() {
// 实现 Drop trait
impl<T: ?Sized> Drop for Ref<T> {
    fn drop(&mut self) {
        // SAFETY: By the type invariant, there is necessarily a reference to the object. We cannot
        // touch `refcount` after it's decremented to a non-zero value because another thread/CPU
        // may concurrently decrement it to zero and free it. It is ok to have a raw pointer to
        // freed/invalid memory as long as it is never dereferenced.
        let refcount = unsafe { self.ptr.as_ref() }.refcount.get();

        // INVARIANT: If the refcount reaches zero, there are no other instances of `Ref`, and
        // this instance is being dropped, so the broken invariant is not observable.
        // SAFETY: Also by the type invariant, we are allowed to decrement the refcount.
        let is_zero = unsafe { bindings::refcount_dec_and_test(refcount) };
        if is_zero {
            // The count reached zero, we must free the memory.

            // SAFETY: This thread holds the only remaining reference to `self`, so it is safe to
            // get a mutable reference to it.
            let inner = unsafe { self.ptr.as_mut() };
            let layout = Layout::for_value(inner);
            // SAFETY: The value stored in inner is valid.
            unsafe { core::ptr::drop_in_place(inner) };
            // SAFETY: The pointer was initialised from the result of a call to `alloc`.
            unsafe { dealloc(self.ptr.cast().as_ptr(), layout) };
        }
    }
}
}

实现 Drop trait,同样直接通过 bindings::refcount_dec_and_test 调用内核 refcount_dec_and_test 函数即可,该函数也包含了引用计数溢出检查。但是在引用计数归零的时候,需要释放内存。

注意上面 CloneDrop 这两个 trait 的实现,是 Unsafe Rust 抽象为 Safe Rust 的一个经典范例,主要是其中的Safety注释,考虑了安全边界,并且加以说明。

创建新的引用计数对象

接下来需要关注 Ref<T> 如何创建新的引用计数对象。


#![allow(unused)]
fn main() {
impl<T> Ref<T> {
    /// Constructs a new reference counted instance of `T`.
    pub fn try_new(contents: T) -> Result<Self> {
        let layout = Layout::new::<RefInner<T>>();
        // SAFETY: The layout size is guaranteed to be non-zero because `RefInner` contains the
        // reference count.
        let inner = NonNull::new(unsafe { alloc(layout) })
            .ok_or(Error::ENOMEM)?
            .cast::<RefInner<T>>();

        // INVARIANT: The refcount is initialised to a non-zero value.
        let value = RefInner {
            // SAFETY: Just an FFI call that returns a `refcount_t` initialised to 1.
            refcount: Opaque::new(unsafe { bindings::REFCOUNT_INIT(1) }),
            data: contents,
        };
        // SAFETY: `inner` is writable and properly aligned.
        unsafe { inner.as_ptr().write(value) };

        // SAFETY: We just created `inner` with a reference count of 1, which is owned by the new
        // `Ref` object.
        Ok(unsafe { Self::from_inner(inner) })
    }
}

}

try_new 方法中使用 core::alloc::Layout 结构体来定义内存布局。

通过 NonNull::new 和 自定义的 core::alloc::alloc 函数 来分配新的内存,并转换为 RefInner<T>> 类型,并通过 bindings::REFCOUNT_INIT调用内核 C 函数对其初始化为 1 。其中 自定义的 core::alloc 模块将来都会同步到 rust core 中。

其中 Error::ENOMEM代表 OOM 错误。在 kernel/error.rs 中定义了很多内核错误码对应的错误。

Linux 内核中使用整数定义了很多错误码,在 kernel crate 中,使用了 NewType 模式对其进行封装,而非直接使用整数错误码:


#![allow(unused)]
fn main() {
macro_rules! declare_err {
    ($err:tt) => {
        pub const $err: Self = Error(-(bindings::$err as i32));
    };
    ($err:tt, $($doc:expr),+) => {
        $(
        #[doc = $doc]
        )*
        pub const $err: Self = Error(-(bindings::$err as i32));
    };
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Error(c_types::c_int);

impl Error {
    declare_err!(EPERM, "Operation not permitted.");

    declare_err!(ENOENT, "No such file or directory.");

    declare_err!(ESRCH, "No such process.");

    declare_err!(ENOMEM, "Out of memory.");

    // ...

}
}

从已经存在的 RefInner<T> 构造 Ref<T>

在上面的 try_new 方法中看到,最后一步使用 from_inner 方法将一个裸指针构造为最终的 Ref<T>。并且它是一个内部方法,不是公开的 API。

注意,它是一个 unsafe 的方法,因为需要调用者来确保 inner 的指针是有效且非空的,对于这一点其文档注释也写的比较清楚。


#![allow(unused)]
fn main() {
impl<T: ?Sized> Ref<T> {
    /// Constructs a new [`Ref`] from an existing [`RefInner`].
    ///
    /// # Safety
    ///
    /// The caller must ensure that `inner` points to a valid location and has a non-zero reference
    /// count, one of which will be owned by the new [`Ref`] instance.
    unsafe fn from_inner(inner: NonNull<RefInner<T>>) -> Self {
        // INVARIANT: By the safety requirements, the invariants hold.
        Ref {
            ptr: inner,
            _p: PhantomData,
        }
    }

}
}

RefBorrow<T>

不存在对底层引用计数结构体的可变借用,但是存在一个不可变的借用,并且需要手动维护生命周期。


#![allow(unused)]
fn main() {
/// A borrowed [`Ref`] with manually-managed lifetime.
///
/// # Invariants
///
/// There are no mutable references to the underlying [`Ref`], and it remains valid for the lifetime
/// of the [`RefBorrow`] instance.
pub struct RefBorrow<'a, T: ?Sized + 'a> {
    inner: NonNull<RefInner<T>>,
    _p: PhantomData<&'a ()>,
}

impl<T: ?Sized> Clone for RefBorrow<'_, T> {
    fn clone(&self) -> Self {
        *self
    }
}

impl<T: ?Sized> Copy for RefBorrow<'_, T> {}
}

RefBorrow 结构体使用 PhantomData<&'a ()> 来持有生命周期参数,并为其实现 Copy trait,其行为和普通的不可变引用类似。

然后为 Ref<T> 实现一个 as_ref_borrow 方法即可从 Ref<T> 得到 RefBorrow<T>


#![allow(unused)]
fn main() {
impl<T> Ref<T> {

    /// Returns a [`RefBorrow`] from the given [`Ref`].
    ///
    /// This is useful when the argument of a function call is a [`RefBorrow`] (e.g., in a method
    /// receiver), but we have a [`Ref`] instead. Getting a [`RefBorrow`] is free when optimised.
    #[inline]
    pub fn as_ref_borrow(&self) -> RefBorrow<'_, T> {
        // SAFETY: The constraint that lifetime of the shared reference must outlive that of
        // the returned `RefBorrow` ensures that the object remains alive.
        unsafe { RefBorrow::new(self.ptr) }
    }

}

}

其实按 Rust 命名规范,此处 as_ref_borrow 改为 as_ref 更好。但是这里其实 as_ref 另有用处:


#![allow(unused)]
fn main() {
impl<T: ?Sized> AsRef<T> for Ref<T> {
    fn as_ref(&self) -> &T {
        // SAFETY: By the type invariant, there is necessarily a reference to the object, so it is
        // safe to dereference it.
        unsafe { &self.ptr.as_ref().data }
    }
}
}

要通过 as_ref 方法从 Ref<T> 得到 &T

然后为 RefBorrow<T> 实现 Deref trait,也可以从 RefBorrow<T> 拿到 &T


#![allow(unused)]
fn main() {
impl<T: ?Sized> Deref for RefBorrow<'_, T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        // SAFETY: By the type invariant, the underlying object is still alive with no mutable
        // references to it, so it is safe to create a shared reference.
        unsafe { &self.inner.as_ref().data }
    }
}
}

唯一引用类型 UniqueRef<T>

除了 Ref<T> 之外,还实现了一个 UniqueRef<T> 类型。顾名思义,该类型表示只有唯一一个引用计数的情况。


#![allow(unused)]
fn main() {
pub struct UniqueRef<T: ?Sized> {
    inner: Ref<T>,
}

impl<T> UniqueRef<T> {
    /// Tries to allocate a new [`UniqueRef`] instance.
    pub fn try_new(value: T) -> Result<Self> {
        Ok(Self {
            // INVARIANT: The newly-created object has a ref-count of 1.
            inner: Ref::try_new(value)?,
        })
    }

    /// Tries to allocate a new [`UniqueRef`] instance whose contents are not initialised yet.
    pub fn try_new_uninit() -> Result<UniqueRef<MaybeUninit<T>>> {
        Ok(UniqueRef::<MaybeUninit<T>> {
            // INVARIANT: The newly-created object has a ref-count of 1.
            inner: Ref::try_new(MaybeUninit::uninit())?,
        })
    }
}
}

没有为其实现 CloneDrop 这两个 trait,所以它只能持有唯一一个引用。引入该类型也许可以为内核开发提供更多便利。

其他

Ref<T> 还实现了其他 trait,比如 From/TryFrom ,可以从裸指针和 Ref<T>之间相互转换。

一个值得注意的地方是:


#![allow(unused)]
fn main() {
impl<T> Ref<T> {
    /// Deconstructs a [`Ref`] object into a raw pointer.
    ///
    /// It can be reconstructed once via [`Ref::from_raw`].
    pub fn into_raw(obj: Self) -> *const T {
        let ret = &*obj as *const T;
        core::mem::forget(obj);
        ret
    }
}
}

Ref<T> 转换为裸指针时,注意使用 core::mem::forget(obj) 避免调用 obj 的 Drop ,否则会让引用计数减少而引起问题。

小结

从 Rust for Linux 源码中可以学习很多 Unsafe Rust 的相关技巧,尤其是和 C 语言打交道的一些比较好的实践。如果你感兴趣,还能学习 Linux 内核相关的一些内容,为将来是要 Rust 编写 Linux 内核驱动做一些准备。

蚂蚁集团 | 一次 Go 使用嵌入式 Rust 库的踩坑实践

作者:RuihangXia


背景

故事发生在一个内部项目上,这个项目有 Go 和 Rust 两个部分,其中 Rust 库作为一个存储组件被 Go 的业务部分依赖着。出于种种原因,两个部分需要作为同一个进程来运行,中间有一层 C FFI 接口作为理想与现实的桥梁。大概是这个样子的

img

Rust 部分首先将使用到的 Library 的接口和结构使用另一个小小的 cdylib [1] shim 项目封装一下,并通过这个项目生成 Rust 库的编译产物一个动态链接对象和 C 的接口定义。再基于 C 的定义写一个 Go 的 SDK 给上层使用。单独的左边 Go 或者右边 Rust 项目都不够刺激,本文主要集中在中间那一团麻花的部分上,也是我 too young too simple 掉了很多头发的地方。

本文从线程、内存和信号三个大概的方面讲几个事故。

线程

刚开始的时候 shim 和 SDK 都非常简单,SDK 每个请求都通过 cgo 走到 shim,shim 在进行一些结构的转换后调用 library 来处理请求,等待请求完成之后把结果返回给 SDK。比较符合直觉的流程,但是在测试的时候出现了问题,整个程序的行为都不太正常。当时刚开始连调,在这之前 component 和 library/shim 都只进行了单独的测试,当然单独测试的时候都是一切正常,所以场景大概是这样的…

img

虽然很快就怀疑到了 SDK 和 shim 部分,但是也不清楚问题到底出在哪里。直到在 library 的日志中发现了一条原本只应该在启动的时候输出的日志出现了复数次的时候才意识到不对劲。出于一些原因,shim 和 library 中都存在有 TLS [2](thread local storage,线程局部存储)结构,在 Rust std 中就提供了一种实现 [3]。它会在每个线程第一次使用这个结构的时候进行初始化,之后同一个线程都会一直访问到这个结构,而不同的线程之间访问到的是不同的结构。而那条重复出现的日志就是一个 TLS 结构在初始化时输出的,也就是说 TLS 不符合预期地被重复初始化了。

先看下 Go 在进行 cgo 调用的时候会发生什么,Go 中每个线程与系统线程不是一一对应,在进行非 Go 的调用时,会把当前 goroutine 放在一个系统线程上,使用这个系统线程后完成后续的调用流程。所以如果不做特殊处理的话对于 shim 来说每次请求都有可能发生在一个新的线程上,而使得之前保存在 TLS 中的状态失效。并且更加严重的是,shim 的 TLS 中包括了一些 library 的线程句柄,导致 library 的部分状态也出现了混乱,最终整个进程的行为都变得很奇怪。

为了解决这个问题,我们需要把 shim 和 library 的线程固定住,shim 接到请求后通过线程信息传递的方式与 library 交互,包括发送请求与接收结果。代码如下


#![allow(unused)]
fn main() {
struct Handle {
    runtime: Runtime // this was TLS
}

impl Handle {
    fn new() -> Self{
        let worker_thread = std::thread::spawn(|| {}); // main thread, channels inside
        let runtime = Runtime::new(); // cb threads
        Self { runtime }
    }
}
}

同时来都来了,也顺便将 library 异步的接口暴露到了上层,由原来一次 cgo 完成整个请求的方式变成了一次 cgo 提交请求,结果通过后续另一次 C->Go 调用异步地返回,也能够防止大请求把 Go runtime spawn 出来的 cgo 线程阻塞太长时间。现在的交互流程类似下图:

img

不过这只是很基础的方案,“变电器” cgo 的原理很简单,但也存在许多优化空间,能够在解决问题的同时降低损耗 [5, 6, 7]。

内存

除此之外,为了通过 C 的接口来传递数据,需要对数据结构进行一些修改,这一部分主要在 shim 和 SDK 进行。简单来说就是把所有需要传递的类型都能够用 C 表示出来,在这里 [13] 提供了一些例子,下面也有我们的一段示例:


#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Bytes {
    ptr: *mut libc::c_void,
    len: libc::size_t,
}

crate fn make_bytes(mut bytes: Vec<u8>) -> Self {
    bytes.shrink_to_fit();
    let (ptr, len, cap) = bytes.into_raw_parts();
    debug_assert_eq!(len, cap);

    Bytes {
        ptr: unsafe { mem::transmute::<*mut u8, *mut libc::c_void>(ptr) },
        len,
    }
}
}

Go 与 Rust 在远离 FFI 边界的地方(一般来说)都能够很好地处理内存问题,这些非法的指针和地址基本上都是另一方丢过来的。在这个场景下,为了减少工作量我们已经通过使用大量的拷贝减少了许多使用裸指针的地方,剩下的主要集中在两处,即接收对方传递过来的数据以及通知对方回收这一块数据的内存。除开遵守老生常谈的“谁分配谁释放”原则外,我们也要能够对出现的问题进行定位。

最先的入手点就是发生错误时所打出来的堆栈,按照经验跟着上面的错误信息后的第一条 Go routine backtrace 通常是发生问题的地方。可以先顺着调用栈检查一下代码中是否有不正确的指针使用。不过这个堆栈中只会存在 Go 这一部分的信息。当问题出现在 Go 之外的时候我们只能够知道是哪一个 cgo 函数值得怀疑。这一部分就和普通的内存问题排查差不多,我们几个常用的方法如下:

打印日志

如果问题比较好复现的话,可以在路径上多增加一些日志输出,把值得怀疑的指针地址以及它们解引用之后的内容打印出来,有时能够观察到一个指针是如何一步步走向非法的。注意解引用时最好放到另一行日志中,每一个解引用操作都是在非法边缘试探,如果这个指针在解引用的时候出错可能会带着其他有用的日志输出一起消失。

借助工具

也可以通过一些工具来监测内存的使用情况,比如各种 sanitizer [8],valgrind [9] 等,有的时候不是所有的内存问题都会导致程序挂掉,一些非法的内存操作有可能被忽略,比如数组稍微越界一点点,不小心 free 多次或忘记 free 等。这些工具能够帮助及时的发现这些问题。不过它们都或多或少会带来一些性能影响,所以并不是所有情况都适用。

# valgrind
$ cargo build
$ valgrind --leak-check=full --show-leak-kinds=all --tool=memcheck $(BIN)

# address sanitizer
$ export RUSTFLAGS=-Zsanitizer=address RUSTDOCFLAGS=-Zsanitizer=address
$ cargo run -Zbuild-std --target x86_64-unknown-linux-gnu

特殊值

有些环境会在内存操作前后把那一块内存设置上特殊值来表示这块内存的状态,使它能方便地被观察到。如 jemalloc 的 --enable-fill 参数 [10] 或 MariaDB 的 TRASH_ALLOC() [11] 宏。

缩小范围

分为两个方面的缩小。如果条件允许的话也可以通过注释掉一些代码来缩小排查范围,比如先不进行 free 操作或者暂停部分路径进行观察;以及缩短调用路径,对各个组件进行 mock 测试。

还有一些其他的小地方,比如观察指针是否对齐,出错的指针与周围指针的范围等。比如这里 Go 与 Rust 运行在同一个进程内,能够观察出 Go 出来的指针和 Rust 出来的指针在两个地址段上,指针的值有时候也能够说明一些信息(不是故意讲这么玄乎的…)。不过说归说,在实际调试过程中遇到的内存问题表面上千篇一律,背地里各有千秋。当程序有一个内存问题时可能会带来各种千奇百怪的表现,因此也需要能够对不同的问题进行分类并案。在有各种方法手册的同时也避免形成固定流程,而是能够根据新得到的信息及时调整手段。毕竟一般来说全然无计可施的情况比较少,而经常是有许多手段但不知道哪一个才能得到信息。每个技能都会有施法时间消耗,万一被 invalid pointer 纠缠太久导致做梦都是 panic 就很痛苦了 TAT。

在制作 shim,binding 和它们的 c demo 的时候通过一些 C 重新体验了一下文明的进步,但是回过头想想是不是有更先进的方法来避免 unsafe 一把梭的 FFI 呢,这是我们后面需要继续思考的问题。

信号

这是一个花了比较久时间的问题,由一次 SIGSEGV 开始,先剧透一下这个本质上也是一个内存问题。

fatal error: unexpected signal during runtime execution
[signal SIGSEGV: segmentation violation code=0x2 addr=0x18589091b1 pc=0x7f9205f814ba]

runtime stack:
runtime.throw({0xace954, 0xefc4e055})
        /home/go/src/runtime/panic.go:1198 +0x71 fp=0x7f91dce8afe8 sp=0x7f91dce8afb8 pc=0x448451
runtime.sigpanic()
        /home/go/src/runtime/signal_unix.go:719 +0x396 fp=0x7f91dce8b038 sp=0x7f91dce8afe8 pc=0x45fdf6

这里的复现比较花时间,原始场景一般需要半天到一天才能出现,同时也有其他未解决的问题混杂在一起,并且存在许多不同的系统环境和流量,各方面提高了排查的难度。

在花了一些时间把其他的因素都排除掉之后,我们把精力集中到了 SIGSEGV 产生的 core dump 文件上。但是通过 gdb 查看 core 时,它们都基本上是这样的

Thread  (Thread 0x7f7c45fef700 (LWP 47715)):
#0  0x0000000000895b93 in runtime.futex ()
#1  0x0000000000860a20 in runtime.futexsleep ()
#2  0x0000000001dea010 in runtime.sched ()
#3  0x0000000000000080 in ?? ()
#4  0x00007f7c45feccb0 in ?? ()
#5  0x0000000000000000 in ?? ()
Thread  (Thread 0x7f7c457ee700 (LWP 47716)):
#0  0x0000000000895b93 in runtime.futex ()
#1  0x00000000008609ab in runtime.futexsleep ()
#2  0x000000c00011e840 in ?? ()
#3  0x0000000000000080 in ?? ()
#4  0x0000000000000000 in ?? ()
Thread  (Thread 0x7f7c4468c700 (LWP 52927)):
#0  0x00007f7c49301489 in syscall () from /lib64/libc.so.6
#1  0x00007f7c49834da9 in futex_wait (self=0x7f7c4468a640, ts=...) at parking_lot_core-0.8.5/src/thread_parker/linux.rs:112

问号的很好猜是 Go 的 m:n 线程来的,但是其他非 Go 的线程却都停在 syscall 上。完全不知道到底该 blame 谁,为了查看下 Go 里面发生了什么事情,我们使用 delve [12] 来查看 Go 的堆栈,用法和 gdb 类似。这是一个有些痛苦的过程,dump 出了几千个 goroutine 的 backtrace,花了些时间简单对栈顶分析了一下之后,也发现基本都是业务 goroutine,没有什么值得怀疑的。

(dlv)   Goroutine 1 - User: main.go:207 main.main (0xfa6f0e) [chan receive (nil chan) 451190h16m34.324364485s]
  Goroutine 2 - User: /root/go/src/runtime/proc.go:367 runtime.gopark (0x7f8076) [force gc (idle) 451190h16m34.619109482s]
  Goroutine 3 - User: /root/go/src/runtime/proc.go:367 runtime.gopark (0x7f8076) [GC sweep wait]
  Goroutine 4 - User: /root/go/src/runtime/proc.go:367 runtime.gopark (0x7f8076) [GC scavenge wait]
  Goroutine 5 - User: /root/go/src/runtime/proc.go:367 runtime.gopark (0x7f8076) [finalizer wait 451190h14m6.860088484s]
* Goroutine 17 - User: /root/go/src/runtime/sys_linux_amd64.s:165 runtime.raise (0x82acc1) (thread 71636) [GC assist marking]
  Goroutine 47 - User: /root/go/src/runtime/proc.go:367 runtime.gopark (0x7f8076) [select 451190h16m34.324667846s]
  Goroutine 48 - User: /root/go/src/runtime/sigqueue.go:169 os/signal.signal_recv (0x825998) (thread 67111)

唯一的信息就是看到了 SIGSEGV 是从哪里出来的(Goroutine 17)

runtime.throw({0xace954, 0xefc4e055})
        /home/go/src/runtime/panic.go:1198 +0x71 fp=0x7f91dce8afe8 sp=0x7f91dce8afb8 pc=0x448451
runtime.sigpanic()
        /home/go/src/runtime/signal_unix.go:719 +0x396 fp=0x7f91dce8b038 sp=0x7f91dce8afe8 pc=0x45fdf6

看着有点眼熟,就是经常在错误信息里面出现的第一行 stack。这个 goroutine 只有上面两层,只能说这个信息虽然有用,但也基本没用 : (

又经过了一些时间的挣扎,最后怀疑上面看到的 core dump 并不是实际上的第一手堆栈。通过把进程挂在 gdb 里面运行,终于能够拿到非法操作实际上出现的位置,gdb 在信号第一次抛出的时候先于 Go runtime 捕获到它。现在回看起来想说:如果早知道,堆栈信号会被 Go runtime 转手…

img

在能够看到第一案发现场之后,后续的流程就比较普通了,也是在当天下午就完成了修复解脱了出来。

最后

每次调 bug 一般最花时间的就是定位,包括本文没怎么提的事前准备,如避免 bug 产生,提前准备好趁手的工具,有各种复现方法等;和出现 bug 之后根据信息来一步步排查的手段都很重要。最后祝各位和自己时常一写就对一跑就过🤤。

参考

字节跳动 | Rust 异步运行时的设计与实现

作者:茌海/徐帅


本文主要介绍如何设计和实现一个基于 io-uring 的 Thread-per-core 模型的 Runtime。

我们的 Runtime Monoio 现已开源,你可以在 github.com/bytedance/monoio 找到它。

下面我们会通过两个章节来介绍:

  • 科普篇 简单介绍一下异步 IO 以及 rust runtime 中需要的主要抽象。
  • 实现篇 本章节我们会介绍到 Monoio 的实现细节。

科普篇

epoll & io-uring

为了做到异步并发,我们需要内核提供相关的能力,来做到在某个 IO 处于阻塞状态时能够处理其他任务。

epoll

讲 epoll 的文章多如牛毛,在此简单地提一下:epoll 是 linux 下较好的 IO 事件通知机制,可以同时监听多个 fd 的 ready 状态。

它主要包含 3 个 syscall:

  1. epoll_create 创建 epoll fd。
  2. epoll_ctl 向 epoll fd 添加、修改或删除其监听的 fd event。
  3. epoll_wait 等待监听 fd event,任何一个 event 发生时即返回;同时也支持传入一个 timeout,这样即便是没有 ready event,也可以在超时后返回。

如果你不使用 epoll 这类,而是直接做 syscall,那么你需要让 fd 处于阻塞模式(默认),这样当你想从 fd 上 read 时,read 就会阻塞住直到有数据可读。

使用 epoll 的时候,需要设置 fd 为非阻塞模式。当 read 时,在没有数据的情况下,read 也会立刻返回 WOULD_BLOCK。这时需要做的事情是将这个 fd 注册到 epoll fd 上,并设置 EPOLLIN 事件。

之后在没事做的时候(所有任务都卡在 IO 了),陷入 syscall epoll_wait。当有 event 返回时,再对对应的 fd 做 read(取决于注册时设置的触发模式,可能还要做一些其他事情,确保下次读取正常)。

总的来说,这个机制十分简单:设置 fd 为非阻塞模式,并在需要的时候注册到 epoll fd 上,然后 epoll fd 的事件触发时,再对 fd 进行操作。这样将多个 fd 的阻塞问题转变为单个 fd 的阻塞。

io-uring

与 epoll 不同,io-uring 不是一个事件通知机制,它是一个真正的异步 syscall 机制。你并不需要在它通知后再手动 syscall,因为它已经帮你做好了。

io-uring 主要由两个 ring 组成(SQ 和 CQ),SQ 用于提交任务,CQ 用于接收任务的完成通知。任务(Op)往往可以对应到一个 syscall(如 read 对应 ReadOp),也会指定这次 syscall 的参数和 flag 等。

在 submit 时,内核会消费掉所有 SQE,并注册 callback。之后等有数据时,如网卡中断发生,数据通过驱动读入,内核就会触发这些 callback,做 Op 想做的事情,如拷贝某个 fd 的数据到 buffer(这个 buffer 是用户指定的 buffer)。相比 epoll,io-uring 是纯同步的。

注:本节涉及的 io-uring 相关是对带 FAST_POLL 的描述。

https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=d7718a9d25a61442da8ee8aeeff6a0097f0ccfd6

总结一下,io-uring 和 epoll 在使用上其实差不多,一般使用方式是:直接将想做的事情丢到 SQ 中(如果 SQ 满了就要先 submit 一下),然后在没事干(所有任务都卡在 IO 了)的时候 submit_and_wait(1)submit_and_waitsubmit 并不是 syscall,它们是 liburing 对 enter 的封装);返回后消费 CQ,即可拿到 syscall 结果。如果你比较在意延迟,你可以更激进地做 submit,尽早将任务推入可以在数据 ready 后尽早返回,但与此同时也要付出 syscall 增多的代价。

异步任务的执行流

常规的编程方式下,我们的代码控制流是对应线程的。就像你在写 C 时理解的那样,你的代码会直接编译到汇编指令,然后会由操作系统提供的“线程”去执行,在这其中没有多余的插入的逻辑。

以 epoll 为例,基于 epoll 的异步本质上是对线程的多路复用。那么常规方式下的类似下面的代码就无法在这种场景下使用:

for connection = listener.accept():
    do_something(connection)

因为这段代码中的 accept 是需要等待 IO 的,直接阻塞在这里会导致线程阻塞,这样就无法执行其他任务了。

面向 Callback 编程

在 C/C++ 中常被使用的 libevent 就是这种模型。用户代码不掌握主动权(因为线程的掌控权就一个,而用户任务千千万万),而是通过将 Callback 注册在 libevent 上,关联某个事件。当事件发生时,libevent 会调用用户的回调函数,并将事件的参数传递给用户。用户在初始化好一些 callback 后,将线程的主动权交给 libevent。其内部会帮忙处理和 epoll 的交互,并在 ready 时执行 callback。

这种方式较为高效,但是写起来却令人头大。举例来说,如果你想做一次 HTTP 请求,那么你需要将这段代码拆成多个同步的函数,并通过 callback 将他们串起来: callback

本来一次可以内聚在一个一个函数里的行为,被拆成了一堆函数。相比面向过程,面向状态编程十分混乱,且容易因为编码者遗忘一些细节而出问题。

有栈协程

如果我们能在用户代码和最终产物之间插入一些逻辑呢?像 Golang 那样,用户代码实际上只对应到可被调度的 goroutine,实际拥有线程控制权的是 go runtime。goroutine 可以被 runtime 调度,在执行过程中也可以被抢占。

当 goroutine 需要被中断然后切换到另一个 goroutine 时,runtime 只需要修改当前的 stack frame 即可。每个 goroutine 对应的栈其实是存在堆上的,所以可以随时打断随时复原。

网络库也是配合这一套 runtime 的。syscall 都是非阻塞的,并可以自动地挂在 netpoll 上。

有栈协程配合 runtime,解耦了 Task 级的用户代码和线程的对应关系。

基于 Future 的无栈协程

有栈协程的上下文切换开销不可忽视。因为可以被随时打断,所以我们有必要保存当时的寄存器上下文,否则恢复回去时就不能还原现场了。

无栈协程没有这个问题,这种模式非常符合 Rust 的 Zero Cost Abstraction 的理念。Rust 中的 async + await 本质上是代码的自动展开,async + await 代码会基于 llvm generator 自动展开成状态机,状态机实现 Future 通过 poll 和 runtime 交互(具体细节可以参考这篇文章)。

Rust 异步机制原理

Rust 的异步机制设计较为复杂,标准库的接口和 Runtime 实现是解耦的。

Rust 的异步依赖于 Future trait。


#![allow(unused)]
fn main() {
pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}

Future 是如何被执行的?根据上述 trait 定义很显然,是通过 poll 方法。返回的结果是 Poll,要么 Pending 要么 Ready(T)

那么 poll 方法是谁来调用的?

  1. 用户。用户可以手动实现 Future,如用户对某个 Future 做包装,显然它需要实现 poll 并调用 inner.poll
  2. Runtime。Runtime 是最终 poll 的调用者。

作为 Future 的实现者,需要保证的是,一旦返回了 Poll::Pending,就要在未来该 Future 依赖的 IO 就绪后能够唤醒它。唤醒一个 Future 是通过 Context 内的 Waker 做到的。至于说唤醒之后要做什么,这个由 Runtime 所提供的 cx 自行实现(如将这个 Task 加入到待执行队列)。

所以任何会产生事件的东西都要负责存储 Waker 并在 Ready 后唤醒它;而提供 cx 的东西会接收到这次唤醒操作,要负责重新调度它。这两个概念分别对应 Reactor 和 Executor,这两套东西靠 Waker 和 Future 解耦。

simple-runtime

Reactor

举例来说,你甚至可以在 Tokio 之上再实现一套自己的 Reactor(约等于自己做了一套多路复用)。事实上 Tokio-uring 就是这么做的:它本身注册在 Tokio(mio) 上了一个 uring fd,而基于这个 fd 和一套自己的 Pending Op 管理系统又对外作为 Reactor 暴露了事件源的能力。在 tokio-tungstenite 中,也通过 WakerProxy 来解决了读写唤醒问题。

另一个例子是计时器驱动。显然 IO 事件的信号来自于 epoll/io-uring 等,但计时器并不是,其内部维护了时间轮或 N 叉堆之类的结构,所以唤醒 Waker 一定是 Time Driver 的职责,所以它一定需要存储 Waker。Time Driver 在这个意义上是一个 Reactor 实现。

Executor

说完了 Reactor 我们来科普一下 Executor。Executor 负责任务调度和执行。以 Thread-per-core 场景为例(为啥用这个例子?没有跨线程调度写起来多简单啊),完全可以实现成一个 VecDeque,Executor 做的事就是从里面拿任务,调用它的 poll 方法。

IO 组件

看到这里你可能会好奇,既然 Reactor 负责唤醒对应 Task,Executor 负责执行唤醒的 Task,那我们看一下源头,是谁负责将 IO 注册到 Reactor 上的呢?

IO 库(如 TcpStream 实现)会负责将未能立刻完成的 IO 注册到 Reactor 上。这也是为什么你在使用 Tokio 的时候会非要用 Tokio::net::TcpStream 而不能用标准库的原因之一;以及你想异步 sleep 时也需要用 Runtime 提供的 sleep 方法。

实现一个极简的 Runtime

出于简便目的,我们使用 epoll 来写这个 Runtime。这并不是我们的最终产品,只是为了演示如何实现最简单的 Runtime。

本小节的完整代码在 github.com/ihciah/mini-rust-runtime

对应前文的三部组成部分:Reactor、Executor 和 IO 组件,我们分别实现。我们从 Reactor 入手吧。

Reactor

由于裸操作 epoll 的体验有点差,并且本文的重点也并不是 Rust Binding,所以这里使用 polling 这个 crate 来完成 epoll 操作。

polling crate 的基础用法是创建 Poller,以及向 Poller 添加或修改 fd 和 interest。这个包默认使用 oneshot 模式(EPOLLONESHOT),需要在 event 触发后重新注册。在多线程场景下这可能是有用的,不过在我们的单线程的最简版本中似乎没有必要这么做,因为会带来额外的 epoll_ctl syscall 开销。不过处于简便起见,我们仍旧使用它。

作为 Reactor,我们在向 Poller 注册 interest 时,需要提供一个其对应的标识符,这个标识符在很多其他地方会被叫做 Token 或 UserData 或 Key。当 Event Ready 后,这个标志符会被原样返回。

所以我们需要做的事情大概是这样的:

  1. 创建 Poller
  2. 当需要关注某个 fd 的 Readable 或 Writable 时,向 Poller 添加 interest Event,并将 Waker 存下来
  3. 添加 interest Event 前需要分配一个 Token,这样当 Event Ready 后我们才知道这个 Event 对应的 Waker 在哪。

于是我们可以将 Reactor 设计为:


#![allow(unused)]
fn main() {
pub struct Reactor {
    poller: Poller,
    waker_mapping: rustc_hash::FxHashMap<u64, Waker>,
}
}

在其他 Runtime 实现中往往会使用 slab,slab 同时处理了 Token 分配和 Waker 的存储。

简便起见,这里保存 Token 和 Waker 的关系时直接使用了 HashMap。Waker 的存储比较 trick 的方式解决:由于我们只关心读和写,所以我们将读对应的 MapKey 定义为 fd * 2,写对应的 MapKey 定义为 fd * 2 + 1(因为 TCP 连接是全双工的,同一个 fd 上的读写无关,可以在不同的 Task 上,有各自独立的 Waker);而 Event 的 UserData(Token)仍旧使用 fd 本身。


#![allow(unused)]
fn main() {
impl Reactor {
    pub fn modify_readable(&mut self, fd: RawFd, cx: &mut Context) {
        println!("[reactor] modify_readable fd {} token {}", fd, fd * 2);

        self.push_completion(fd as u64 * 2, cx);
        let event = polling::Event::readable(fd as usize);
        self.poller.modify(fd, event);
    }

    pub fn modify_writable(&mut self, fd: RawFd, cx: &mut Context) {
        println!("[reactor] modify_writable fd {}, token {}", fd, fd * 2 + 1);

        self.push_completion(fd as u64 * 2 + 1, cx);
        let event = polling::Event::writable(fd as usize);
        self.poller.modify(fd, event);
    }

    fn push_completion(&mut self, token: u64, cx: &mut Context) {
        println!("[reactor token] token {} waker saved", token);

        self.waker_mapping.insert(token, cx.waker().clone());
    }
}
}

要将 fd 挂在 Poller 上或摘掉也十分简单:


#![allow(unused)]
fn main() {
impl Reactor {
    pub fn add(&mut self, fd: RawFd) {
        println!("[reactor] add fd {}", fd);

        let flags =
            nix::fcntl::OFlag::from_bits(nix::fcntl::fcntl(fd, nix::fcntl::F_GETFL).unwrap())
                .unwrap();
        let flags_nonblocking = flags | nix::fcntl::OFlag::O_NONBLOCK;
        nix::fcntl::fcntl(fd, nix::fcntl::F_SETFL(flags_nonblocking)).unwrap();
        self.poller
            .add(fd, polling::Event::none(fd as usize))
            .unwrap();
    }

    pub fn delete(&mut self, fd: RawFd) {
        println!("[reactor] delete fd {}", fd);

        self.completion.remove(&(fd as u64 * 2));
        println!("[reactor token] token {} completion removed", fd as u64 * 2);
        self.completion.remove(&(fd as u64 * 2 + 1));
        println!(
            "[reactor token] token {} completion removed",
            fd as u64 * 2 + 1
        );
    }
}
}

一个注意事项是,在挂上去之前要设置为 Nonblocking 的,否则在做 syscall 时,如果出现误唤醒(epoll 并没有保证不会误唤醒)会导致线程阻塞。

然后我们会面临一个问题:什么时候 epoll_wait?答案是没有任务的时候。如果所有任务都等待 IO 了,那么我们可以安全地陷入 syscall。所以我们的 Reactor 需要暴露一个 wait 接口来供 Executor 在没有任务时等待。


#![allow(unused)]
fn main() {
pub struct Reactor {
    poller: Poller,
    waker_mapping: rustc_hash::FxHashMap<u64, Waker>,

    buffer: Vec<Event>,
}

impl Reactor {
    pub fn wait(&mut self) {
        println!("[reactor] waiting");
        self.poller.wait(&mut self.buffer, None);
        println!("[reactor] wait done");

        for i in 0..self.buffer.len() {
            let event = self.buffer.swap_remove(0);
            if event.readable {
                if let Some(waker) = self.waker_mapping.remove(&(event.key as u64 * 2)) {
                    println!(
                        "[reactor token] fd {} read waker token {} removed and woken",
                        event.key,
                        event.key * 2
                    );
                    waker.wake();
                }
            }
            if event.writable {
                if let Some(waker) = self.waker_mapping.remove(&(event.key as u64 * 2 + 1)) {
                    println!(
                        "[reactor token] fd {} write waker token {} removed and woken",
                        event.key,
                        event.key * 2 + 1
                    );
                    waker.wake();
                }
            }
        }
    }
}
}

接收 syscall 结果时需要提供预分配好的 buffer(Vec<Event>),为了避免每次都分配,我们直接在结构体中保存下来这个 buffer,通过 Option 包装可以让我们临时地拿到它的所有权。

wait 需要做的事情是:

  1. 陷入 syscall
  2. syscall 返回后,处理所有就绪的 Event。如果事件是 readable 或者 writable 的,那么就分别从 HashMap 里找到并删除其对应的 Completion,然后唤醒它(这个 fd 和 Map Key 的对应规则我们前面也说到了,readable 对应 fd * 2,writable 对应 fd * 2 + 1)。

最后将 Reactor 的创建函数补齐:


#![allow(unused)]
fn main() {
impl Reactor {
    pub fn new() -> Self {
        Self {
            poller: Poller::new().unwrap(),
            waker_mapping: Default::default(),

            buffer: Vec::with_capacity(2048),
        }
    }
}

impl Default for Reactor {
    fn default() -> Self {
        Self::new()
    }
}
}

这时我们的 Reactor 就写完了。总的来说就是包装了 epoll,同时额外做了 Waker 的存储和唤醒。

Executor

Executor 需要存储 Task 并执行。

Task

Task 是什么?Task 其实是 Future,但因为 Task 需要共享所有权,所以这里我们使用 Rc 来存储;并且我们只知道用户丢进来一个 Future,并不知道它的具体类型,所以我们需要把它 Box 起来,这里使用 LocalBoxFuture。再加上内部可变性,所以 Task 的定义如下:


#![allow(unused)]
fn main() {
pub struct Task {
    future: RefCell<LocalBoxFuture<'static, ()>>,
}
}

TaskQueue

设计 Task 的存储结构,简便起见直接使用 VecDeque。


#![allow(unused)]
fn main() {
pub struct TaskQueue {
    queue: RefCell<VecDeque<Rc<Task>>>,
}
}

这个 TaskQueue 需要能够 push 和 pop 任务:


#![allow(unused)]
fn main() {
impl TaskQueue {
    pub(crate) fn push(&self, runnable: Rc<Task>) {
        println!("add task");
        self.queue.borrow_mut().push_back(runnable);
    }

    pub(crate) fn pop(&self) -> Option<Rc<Task>> {
        println!("remove task");
        self.queue.borrow_mut().pop_front()
    }
}
}

Waker

Executor 需要提供 Context,其中包含 Waker。Waker 需要能够在被 wake 时将任务推入执行队列等待执行。


#![allow(unused)]
fn main() {
pub struct Waker {
    waker: RawWaker,
}

pub struct RawWaker {
    data: *const (),
    vtable: &'static RawWakerVTable,
}
}

Waker 通过自行指针和 vtable 自行实现动态分发。所以我们要做的事情有两个:

  1. 拿到 Task 结构的指针并维护它的引用计数
  2. 生成类型对应的 vtable

我们可以这么定义 vtable:


#![allow(unused)]
fn main() {
struct Helper;

impl Helper {
    const VTABLE: RawWakerVTable = RawWakerVTable::new(
        Self::clone_waker,
        Self::wake,
        Self::wake_by_ref,
        Self::drop_waker,
    );

    unsafe fn clone_waker(data: *const ()) -> RawWaker {
        increase_refcount(data);
        let vtable = &Self::VTABLE;
        RawWaker::new(data, vtable)
    }

    unsafe fn wake(ptr: *const ()) {
        let rc = Rc::from_raw(ptr as *const Task);
        rc.wake_();
    }

    unsafe fn wake_by_ref(ptr: *const ()) {
        let rc = mem::ManuallyDrop::new(Rc::from_raw(ptr as *const Task));
        rc.wake_by_ref_();
    }

    unsafe fn drop_waker(ptr: *const ()) {
        drop(Rc::from_raw(ptr as *const Task));
    }
}

unsafe fn increase_refcount(data: *const ()) {
    let rc = mem::ManuallyDrop::new(Rc::<Task>::from_raw(data as *const Task));
    let _rc_clone: mem::ManuallyDrop<_> = rc.clone();
}
}

手动管理引用计数:我们会通过 Rc::into_raw 来取得 Rc<Task> 的裸指针并使用它和 vtable 构建 RawTask 然后构建 Task。在 vtable 实现中,我们需要小心地手动管理引用计数:如 clone_waker 时,虽然我们只 clone 一个指针,但它在含义上有了一次拷贝,所以我们需要手动让其引用计数加一。

Task 实现 wake_wake_by_ref_ 来重新调度任务。重新调度任务做的事情只是简单地从 thread local storage 拿到 executor 然后向 TaskQueue push。


#![allow(unused)]
fn main() {
impl Task {
    fn wake_(self: Rc<Self>) {
        Self::wake_by_ref_(&self)
    }

    fn wake_by_ref_(self: &Rc<Self>) {
        EX.with(|ex| ex.local_queue.push(self.clone()));
    }
}
}

Executor

有了前面这些组件以后,构建 Executor 是非常简单的。


#![allow(unused)]
fn main() {
scoped_tls::scoped_thread_local!(pub(crate) static EX: Executor);

pub struct Executor {
    local_queue: TaskQueue,
    pub(crate) reactor: Rc<RefCell<Reactor>>,

    /// Make sure the type is `!Send` and `!Sync`.
    _marker: PhantomData<Rc<()>>,
}
}

当用户 spawn Task 的时候:


#![allow(unused)]
fn main() {
impl Executor {
    pub fn spawn(fut: impl Future<Output = ()> + 'static) {
        let t = Rc::new(Task {
            future: RefCell::new(fut.boxed_local()),
        });
        EX.with(|ex| ex.local_queue.push(t));
    }
}
}

其实做的事情就是将传入的 Future Box 起来然后构建 Rc<Task> 之后丢到执行队列里。

那么 Executor 的主循环在哪呢?我们可以放在 block_on 里。


#![allow(unused)]
fn main() {
impl Executor {
    pub fn block_on<F, T, O>(&self, f: F) -> O
    where
        F: Fn() -> T,
        T: Future<Output = O> + 'static,
    {
        let _waker = waker_fn::waker_fn(|| {});
        let cx = &mut Context::from_waker(&_waker);

        EX.set(self, || {
            let fut = f();
            pin_utils::pin_mut!(fut);
            loop {
                // return if the outer future is ready
                if let std::task::Poll::Ready(t) = fut.as_mut().poll(cx) {
                    break t;
                }

                // consume all tasks
                while let Some(t) = self.local_queue.pop() {
                    let future = t.future.borrow_mut();
                    let w = waker(t.clone());
                    let mut context = Context::from_waker(&w);
                    let _ = Pin::new(future).as_mut().poll(&mut context);
                }

                // no task to execute now, it may ready
                if let std::task::Poll::Ready(t) = fut.as_mut().poll(cx) {
                    break t;
                }

                // block for io
                self.reactor.borrow_mut().wait();
            }
        })
    }
}
}

这段有点复杂,可以分成以下几个步骤:

  1. 创建一个 dummy_waker,这个 waker 其实啥事不做。
  2. (in loop)poll 传入的 future,检查是否 ready,如果 ready 就返回,结束 block_on。
  3. (in loop)循环处理 TaskQueue 中的所有 Task:构建它对应的 Waker 然后 poll 它。
  4. (in loop)这时已经没有待执行的任务了,可能主 future 已经 ready 了,也可能都在等待 IO。所以再次检查主 future,如果 ready 就返回。
  5. (in loop)既然所有人都在等待 IO,那就 reactor.wait()。这时 reactor 会陷入 syscall 等待至少一个 IO 可执行,然后唤醒对应 Task,会向 TaskQueue 里推任务。

至此 Executor 基本写完了。

IO 组件

IO 组件要在 WouldBlock 时将其挂在 Reactor 上。以 TcpStream 为例,我们需要为其实现 tokio::io::AsyncRead


#![allow(unused)]
fn main() {
pub struct TcpStream {
    stream: StdTcpStream,
}
}

在创建 TcpStream 时需要将其添加到 Poller 上,销毁时需要摘下:


#![allow(unused)]
fn main() {
impl From<StdTcpStream> for TcpStream {
    fn from(stream: StdTcpStream) -> Self {
        let reactor = get_reactor();
        reactor.borrow_mut().add(stream.as_raw_fd());
        Self { stream }
    }
}

impl Drop for TcpStream {
    fn drop(&mut self) {
        println!("drop");
        let reactor = get_reactor();
        reactor.borrow_mut().delete(self.stream.as_raw_fd());
    }
}
}

在实现 AsyncRead 时,对其做 read syscall。因为在添加到 Poller 时已经设置 fd 为非阻塞,所以这里 syscall 是安全的。


#![allow(unused)]
fn main() {
impl tokio::io::AsyncRead for TcpStream {
    fn poll_read(
        mut self: std::pin::Pin<&mut Self>,
        cx: &mut std::task::Context<'_>,
        buf: &mut tokio::io::ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        let fd = self.stream.as_raw_fd();
        unsafe {
            let b = &mut *(buf.unfilled_mut() as *mut [std::mem::MaybeUninit<u8>] as *mut [u8]);
            println!("read for fd {}", fd);
            match self.stream.read(b) {
                Ok(n) => {
                    println!("read for fd {} done, {}", fd, n);
                    buf.assume_init(n);
                    buf.advance(n);
                    Poll::Ready(Ok(()))
                }
                Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
                    println!("read for fd {} done WouldBlock", fd);
                    // modify reactor to register interest
                    let reactor = get_reactor();
                    reactor
                        .borrow_mut()
                        .modify_readable(self.stream.as_raw_fd(), cx);
                    Poll::Pending
                }
                Err(e) => {
                    println!("read for fd {} done err", fd);
                    Poll::Ready(Err(e))
                }
            }
        }
    }
}
}

read syscall 可能会返回正确的结果,也可能会返回错误。其中错误中有一个错误需要特殊处理,就是 WouldBlock。当 WouldBlock 时,我们便需要将其挂在 Reactor 上,这里通过我们前面定义的函数 modify_readable 表示我们对 readabe 是关心的。在挂 Reactor 动作完成后,我们可以放心地返回 Poll::Pending,因为我们知道,它后续会被唤醒。

实现篇

Monoio

Motivation

我在字节参与 Mesh Proxy(基于 Envoy)的研发过程中,我感觉我们不得不因为 C++ 的问题而采取非常不优雅的代码组织和设计。

因此我尝试调研了基于 Linkerd2-proxy(一个基于 Rust + Tokio 的 Mesh Proxy)来替代现有版本。压测数据显示,在 HTTP 场景性能提升只有 10% 左右;而 Envoy 压测数据显示非常多的 CPU 消耗是在 syscall 上。

我们可以利用 Rust 泛型编程消除 C++ 中的基于动态分发的抽象带来的运行时开销;在 IO 上,我们考虑利用 io-uring 来替代 epoll。

前期调研

项目前期我们对比了几种方案下的性能:1. Tokio 2. Glommio 3. 裸 epoll 4. 裸 io-uring。之后发现裸 io-uring 性能上确实领先,但基于 io-uring 的 Glommio 的表现并不如人意。我们尝试 fork Glommio 的代码并做优化,发现它的项目本身存在较大问题,比如创建 uring 的 flag 似乎都没有正确理解;同时,它的 Task 实现相比 Tokio 的也性能较差。

自己造轮子

最终我们决定自己造一套 Runtime 来满足内部需求,提供极致的性能。

该项目是我和 @dyxushuai 两人共同完成的。在我们实现过程中,我们大量参考了 Tokio、Tokio-uring 等项目,并且尝试了一些自己的设计。

模型讨论

不同的设计模型会有各自擅长的应用场景。

Tokio 使用了公平调度模型,其内部的调度逻辑类似 Golang,任务可以在线程之间转移,这样能尽可能充分地利用多核心的性能。

Glommio 也是一个 Rust Runtime,它基于 io-uring 实现,调度逻辑相比 Tokio 更加简单,是 thread-per-core 模型。

两种模型各有优劣,前者更加灵活,通用型更强,但代价也并不小:

  1. 在多核机器上的性能表现不佳。

    在我的 1K Echo 的测试中(2021-11-26 latest version),Tokio 4 Core 下性能只是 1 Core 下性能的 2.2 倍左右。而我们自己的 Monoio 可以基本保持线性。

    1 Core4 Cores
    1core4cores

    详细的测试报告在这里

  2. 对 Task 本身的约束也不能忽视。如果 Task 可以在 Thread 之间调度,那么它必须实现 Send + Sync。这对用户代码是一个不小的限制。

    举例来说,如果要实现一个 cache 服务,基于公平调度模型的话,cache 对应的 map 就要通过 Atomic 或 Mutex 等来确保 Send + Sync;而如果实现成 thread-per-core 模型,直接使用 thread local 就可以了。以及,nginx 和 envoy 也是基于这种模型。

但是 thread-per-core 并不是银弹。例如,在业务系统中,不同的请求可能处理起来的逻辑是不同的,有的长连接需要做大量的运算,有的则几乎不怎么消耗 CPU。如果基于这种模型,那么很可能导致 CPU 核心之间出现不均衡,某个核心已经被满载,而另一个核心又非常空闲。

事件驱动

这里主要讨论 io-uring 和 epoll。

epoll 只是通知机制,本质上事情还是通过用户代码直接 syscall 来做的,如 read。这样在高频 syscall 的场景下,频繁的用户态内核态切换会消耗较多资源。io-uring 可以做异步 syscall,即便是不开 SQ_POLL 也可以大大减少 syscall 次数。

io-uring 的问题在于下面几点:

  1. 兼容问题。平台兼容就不说了,linux only(epoll 在其他平台上有类似的存在,可以基于已经十分完善的 mio 做无缝兼容)。linux 上也会对 kernel 版本有一定要求,且不同版本的实现性能还有一定差距。大型公司一般还会有自己修改的内核版本,所以想持续跟进 backport 也是一件头疼事。同时对于 Mac/Windows 用户,在开发体验上也会带来一定困难。
  2. Buffer 生命周期问题。io-uring 是全异步的,Op push 到 SQ 后就不能移动 buffer,一定要保证其有效,直到 syscall 完成或 Cancel Op 执行完毕。无论是在 C/C++ 还是 Rust 中,都会面临 buffer 生命周期管理问题。epoll 没有这个问题,因为 syscall 就是用户做的,陷入 syscall 期间本来就无法操作 buffer,所以可以保证其持续有效直到 syscall 返回。

生命周期、IO 接口与 GAT

前一小节提到了 io-uring 的这个问题:需要某种机制保证 buffer 在 Op 执行期间是有效的。

考虑下面这种情况:

  1. 用户创建了 Buffer
  2. 用户拿到了 buffer 的引用(不管是 & 还是 &mut)来做 read 和 write。
  3. Runtime 返回了 Future,但用户直接将其 Drop 了。
  4. 现在没有人持有 buffer 的引用了,用户可以直接将其 Drop 掉。
  5. 但是,buffer 的地址和长度已经被提交给内核,它可能即将被处理,也可能已经在处理中了。我们可以推入一个 CancelOp 进去,但是我们也不能保证 CancelOp 被立刻消费。
  6. Kernel 这时已经在操作错误的内存啦,如果这块内存被用户程序复用,会导致内存破坏。

如果 Rust 实现了 Async Drop,这件事还能做——以正常的方式拿引用来使用 buffer;然鹅木有,我们不能保证及时取消掉内核对 buffer 的读写。

所以,我们很难在不拿所有权的情况下保证 buffer 的有效性。这样就对 IO 接口有个新的挑战:常规的 IO 接口只需要给 &self&mut self,而我们必须要给所有权。

这部分设计我们参考了 tokio-uring,并把它定义为了 trait。这个 Trait 必须启动 GAT。


#![allow(unused)]
fn main() {
/// AsyncReadRent: async read with a ownership of a buffer
pub trait AsyncReadRent {
    /// The future of read Result<size, buffer>
    type ReadFuture<'a, T>: Future<Output = BufResult<usize, T>>
    where
        Self: 'a,
        T: 'a;
    /// The future of readv Result<size, buffer>
    type ReadvFuture<'a, T>: Future<Output = BufResult<usize, T>>
    where
        Self: 'a,
        T: 'a;

    /// Same as read(2)
    fn read<T: IoBufMut>(&self, buf: T) -> Self::ReadFuture<'_, T>;
    /// Same as readv(2)
    fn readv<T: IoVecBufMut>(&self, buf: T) -> Self::ReadvFuture<'_, T>;
}

/// AsyncWriteRent: async write with a ownership of a buffer
pub trait AsyncWriteRent {
    /// The future of write Result<size, buffer>
    type WriteFuture<'a, T>: Future<Output = BufResult<usize, T>>
    where
        Self: 'a,
        T: 'a;
    /// The future of writev Result<size, buffer>
    type WritevFuture<'a, T>: Future<Output = BufResult<usize, T>>
    where
        Self: 'a,
        T: 'a;

    /// Same as write(2)
    fn write<T: IoBuf>(&self, buf: T) -> Self::WriteFuture<'_, T>;

    /// Same as writev(2)
    fn writev<T: IoVecBuf>(&self, buf_vec: T) -> Self::WritevFuture<'_, T>;
}
}

类似 Tokio 的做法,我们还提供了一个带默认实现的 Ext:


#![allow(unused)]
fn main() {
pub trait AsyncReadRentExt<T: 'static> {
    /// The future of Result<size, buffer>
    type Future<'a>: Future<Output = BufResult<usize, T>>
    where
        Self: 'a,
        T: 'a;

    /// Read until buf capacity is fulfilled
    fn read_exact(&self, buf: T) -> <Self as AsyncReadRentExt<T>>::Future<'_>;
}

impl<A, T> AsyncReadRentExt<T> for A
where
    A: AsyncReadRent,
    T: 'static + IoBufMut,
{
    type Future<'a>
    where
        A: 'a,
    = impl Future<Output = BufResult<usize, T>>;

    fn read_exact(&self, mut buf: T) -> Self::Future<'_> {
        async move {
            let len = buf.bytes_total();
            let mut read = 0;
            while read < len {
                let slice = buf.slice(read..len);
                let (r, slice_) = self.read(slice).await;
                buf = slice_.into_inner();
                match r {
                    Ok(r) => {
                        read += r;
                        if r == 0 {
                            return (Err(std::io::ErrorKind::UnexpectedEof.into()), buf);
                        }
                    }
                    Err(e) => return (Err(e), buf),
                }
            }
            (Ok(read), buf)
        }
    }
}

pub trait AsyncWriteRentExt<T: 'static> {
    /// The future of Result<size, buffer>
    type Future<'a>: Future<Output = BufResult<usize, T>>
    where
        Self: 'a,
        T: 'a;

    /// Write all
    fn write_all(&self, buf: T) -> <Self as AsyncWriteRentExt<T>>::Future<'_>;
}

impl<A, T> AsyncWriteRentExt<T> for A
where
    A: AsyncWriteRent,
    T: 'static + IoBuf,
{
    type Future<'a>
    where
        A: 'a,
    = impl Future<Output = BufResult<usize, T>>;

    fn write_all(&self, mut buf: T) -> Self::Future<'_> {
        async move {
            let len = buf.bytes_init();
            let mut written = 0;
            while written < len {
                let slice = buf.slice(written..len);
                let (r, slice_) = self.write(slice).await;
                buf = slice_.into_inner();
                match r {
                    Ok(r) => {
                        written += r;
                        if r == 0 {
                            return (Err(std::io::ErrorKind::WriteZero.into()), buf);
                        }
                    }
                    Err(e) => return (Err(e), buf),
                }
            }
            (Ok(written), buf)
        }
    }
}
}

开启 GAT 可以让我们很多事情变得方便。

我们在 trait 中的关联类型 Future 上定义了生命周期,这样它就可以捕获 &self 而不是非要 Clone self 中的部分成员,或者单独定义一个带生命周期标记的结构体。

定义 Future

如何定义一个 Future?常规我们需要定义一个结构体,并为它实现 Future trait。这里的关键在于要实现 poll 函数。这个函数接收 Context 并同步地返回 Poll。要实现 poll 我们一般需要手动管理状态,写起来十分困难且容易出错。

这时你可能会说,直接 asyncawait 不能用吗?事实上 async 块确实生成了一个状态机,和你手写的差不多。但是问题是,这个生成结构并没有名字,所以如果你想把这个 Future 的类型用作关联类型就难了。这时候可以开启 type_alias_impl_trait 然后使用 opaque type 作为关联类型;也可以付出一些运行时开销,使用 Box<dyn Future>

生成 Future

除了使用 async 块外,常规的方式就是手动构造一个实现了 Future 的结构体。这种 Future 有两种:

  1. 带有所有权的额 Future,不需要额外写生命周期标记。这种 Future 和其他所有结构体都没有关联,如果你需要让它依赖一些不 Copy 的数据,那你可以考虑使用 RcArc 之类的共享所有权的结构。
  2. 带有引用的 Future,这种结构体本身上就带有生命周期标记。例如,Tokio 中的 AsyncReadExtread 的签名是 fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> Read<'a, Self>。这里构造的 Read<'a, Self> 捕获了 self 和 buf 的引用,相比共享所有权,这是没有运行时开销的。但是这种 Future 不好作为 trait 的 type alias,只能开启 generic_associated_typestype_alias_impl_trait,然后使用 opaque type。

定义 IO trait

通常,我们的 IO 接口要以 poll 形式定义(如 poll_read),任何对 IO 的包装都应当基于这个 trait 来做(我们暂时称之为基础 trait)。

但是为了用户友好的接口,一般会提供一个额外的 Ext trait,主要使用其默认实现。Ext trait 为所有实现了基础 trait 的类自动实现。例如,read 返回一个 Future,显然基于这个 future 使用 await 要比手动管理状态和 poll 更容易。

那为什么基础 trait 使用 poll 形式定义呢?不能直接一步到位搞 Future 吗?因为 poll 形式是同步的,不需要捕获任何东西,容易定义且较为通用。如果直接一步到位定义了 Future,那么,要么类似 Ext 一样直接把返回 Future 类型写死(这样会导致无法包装和用户自行实现,就失去了定义 trait 的意义),要么把 Future 类型作为关联类型(前面说了,不开启 GAT 没办法带生命周期,即必须 static)。

所以总结一下就是,在目前的 Rust 稳定版本中,只能使用 poll 形式的基础 trait + future 形式的 Ext trait 来定义 IO 接口。

在开启 GAT 后这件事就能做了。我们可以直接在 trait 的关联类型中定义带生命周期的 Future,就可以捕获 self 了。

这是银弹吗?不是。唯一的问题在于,如果使用了 GAT 这一套模式,就要总是使用它。如果你在 poll 形式和 GAT 形式之间反复横跳,那你会十分痛苦。基于 poll 形式接口自行维护状态,确实可以实现 Future(最简单的实现如 poll_fn);但反过来就很难受了:你很难存储一个带生命周期的 Future。虽然使用一些 unsafe 的 hack 可以做(也有 cost)这件事,但是仍旧,限制很多且并不推荐这么做。monoio-compat 基于 GAT 的 future 实现了 Tokio 的 AsyncReadAsyncWrite,如果你非要试一试,可以参考它。

Buffer 管理

Buffer 管理参考了 tokio-uring 设计。

Buffer 由用户提供所有权,并在 Future 完成时将所有权返回回去。 利用 Slab 维护一个全局状态,当 Op drop 时转移内部 Buffer 所有权至全局状态,并在 CQE 时做真正销毁。正常完成时丢回所有权。

Time Driver 设计

很多场景需要计时器,如超时,需要 select 两个 future,其中一个是 timeout。作为 Runtime 必须支持异步 sleep。

计时器管理与唤醒

Glommio 内部这部分实现较为简单,直接使用了 BTreeMap 维护 Instant -> Waker 的映射,每次拿当前时间去 split_off 拿到所有过期的 timer 唤醒并计算下次 wake 间隔时间,然后在 driver park 的时候作为参数传入。Tokio 中类似,也有自己的时间轮实现,更复杂,但效率也更高(精确度上不如 Glommio 的实现方案)。

考虑到我们性能优先的实现初衷,我们选择类似 Tokio 的时间轮方案。

和 Driver 集成

在 epoll 下,在我们做 wait 前,需要检查当前最近的计时器。如果有,那么必须将它的超时事件作为 wait 的参数传入,否则如果没有任何 IO 在这段时间里就绪,我们就会错过这次计时器的预期唤醒时间,如用户要 timeout 100ms,结果可能 200ms 了才唤醒,这已经失去意义了。

Tokio 内部基于 EPOLL 和 时间轮 做了这件事。EPOLL 作为 IO Driver,并在这个之上封装了 Timer Driver。在 Timer Driver 陷入 syscall 之前,计算时间轮中最近的事件超时间隔,并作为 epoll wait 的参数。

为啥不直接使用 TimerFd 直接利用 epoll 能力?因为这么搞有点重:timerfd 的创建、使用 epoll_ctl 添加和移除都是 syscall,而且不能做粗粒度的合并(时间轮的可以)。

然而,io-uring 的 enter 并不支持传入一个超时时间。我们只能向 SQ 推 TimeoutOp 来做到这件事。

方案1

在插入 element 到时间轮空格子的时候,推 TimeoutOp;并在该格子取消至数量为 0 时推 TimeoutRemoveOp(取消这部分也可以不推,只是要额外付出一次误唤醒的 cost)。

例如,我们会创建 5 个 10ms 的超时,它们会被插入到时间轮的同一个格子。在这个格子中数量从 0 变 1 的时机,我们向 SQ 推一个 10ms 的 TimeoutOp。

方案 2

每次 wait 前计算最近超时时间,推入 SQ 然后 wait;TimeoutOp 中设置 offset = 1。

这里解释一下 offset 参数的含义,简单来说就是当有 $offset 个 CQ 完成时,或超时发生时会完成。

This command will register a timeout operation. The addr field must contain a pointer to a struct timespec64 structure, len must contain 1 to signify one timespec64 structure, timeout_flags may contain IORING_TIMEOUT_ABS for an absolute timeout value, or 0 for a relative timeout. off may contain a completion event count. A timeout will trigger a wakeup event on the completion ring for anyone waiting for events. A timeout condition is met when either the specified timeout expires, or the specified number of events have completed. Either condition will trigger the event. If set to 0, completed events are not counted, which effectively acts like a timer. io_uring timeouts use the CLOCK_MONOTONIC clock source. The request will complete with -ETIME if the timeout got completed through expiration of the timer, or 0 if the timeout got completed through requests completing on their own. If the timeout was cancelled before it expired, the request will complete with -ECANCELED. Available since 5.4.

这样需要在每次 wait 前推 SQ 进去,好处是不需要 remove(因为每次返回时就已经被消费掉了),没有误唤醒问题;并且实现简单,不需要维护 Op 的 user_data 字段用来推 TimeoutRemoveOp。 wait

方案 3

类似方案 2,只不过 TimeoutOp 中的 offset 设置为 0。

这样实现起来较为麻烦:因为 offset = 0 表示它是一个纯粹的计时器,与 CQ 完成个数无关,它只会在实际超时时完成。这样就意味着,我们需要推 TimeoutRemoveOp 或者承担误唤醒开销(Glommio 实现类似这种方案,它的 cost 选择了后者)。 wait

讨论

在插入 TimeoutOp 时,我们应当尽可能晚地插入,因为它可能会被 cancel。所以方案 1 会在 wait 前 0->1->0->1 变化时插入 2 次 TimeoutOp 和 2 次TiemoutRemoveOp,而这是不必要的,方案 1 基本不可取。

方案 2 和 3 在执行时机上和 EPOLL 场景下的 Tokio 以及 Glommio 是一样的。细节上的差别是:

  • 方案 2 下,任何一个 CQ 完成时顺便把 TimeoutOp 给完成掉,这样就不需要 Remove,也就是说不需要维护 user_data,实现上会非常简单,也省了推 TimeoutRemoveOp 以及内核处理的开销。
  • 方案 3 相对 2 的好处是,当 wait 次数很多时,方案 2 每次都要推一个 TimeoutOp 进去,而方案 3 可以检查 TimeoutOp 是否被消耗掉,省一些推入次数;当然,对比方案 2 也有缺点,就是当超时取消时得推 TimeoutRemove 进去。

在我们的实际业务场景中,时间事件绝大多数都是作为超时,少部分是定时轮询用。 超时场景中往往是注册超时并移除超时,真正的超时并非热路径:所以我们这里初步决定是使用方案 2。同时,方案 2 实现简单,后续即便是要优化也成本不大。

跨线程通信

虽然是 thread per core 的 runtime,但是如果没有跨线程通信能力,很多事情是做不了的。比如常见的 case:单个线程拉取配置并分发到每个线程的 thread local 上。

如果只是期望有跨线程通信能力,那么不需要任何 runtime 支持。无论是使用无锁的数据结构,或是跨线程锁,都可以做到。

但我们希望可以在 runtime 层面集成。举例来说,A 线程有一个 channel rx,B 线程有一个 tx,我们通过 B 发送数据,A 可以 await 在 rx 上。这里的实现难点在于,A 线程上的 reactor 可能已经陷入内核进入等待 uring CQ 状态了,我们需要在任务被唤醒时额外唤醒其所在 thread。

Unpark 能力

所以我们需要在 Driver trait 上额外添加一个 Unpark 接口用于跨线程主动唤醒。

在 epoll 下,tokio 内部实现是注册上去一个 eventfd。因为 tokio 本身的调度模型就依赖于跨线程唤醒,所以无论你是否使用 tokio 提供的一些 sync 数据结构,它都会在 epoll 上挂上这么一个 eventfd;而我们的实现主体是不依赖这个的,只有在用到我们实现的 channel 的时候才会依赖,所以我们这里通过条件编译,开启 “sync” feature 才插入相关代码,尽可能做到 zero cost。

在 iouring 下怎么插入 eventfd 呢?同 time driver 中我们实现 park_timeout 做的事情差不多,可以直接推一个 ReadOp 进去读 8 byte,fd 就是 eventfd 的 fd。eventfd 读写都是 8 byte(u64)。

注:文档里提到了两个 syscall flag(IORING_REGISTER_EVENTFD, IORING_REGISTER_EVENTFD_ASYNC),不是做这个事的。 Ref: https://unixism.net/loti/ref-iouring/io_uring_register.html

在什么时机我们需要推入 eventfd 呢?我们可以在内部维护一个状态标记当前 ring 里是否已存在 eventfd。在 sleep 前,如果已存在则直接 sleep,不存在则推一个进去并标记已存在。

当消费 CQ 的时候,遇到 eventfd 对应的 userdata,则标记为不存在,这样下次 sleep 前会重新插入。

当我们需要 unpark 线程时,只需要拿到它对应的 eventfd,并向其中写入 1u64,则这个 fd 就会可读,触发 ring 从 syscall 返回。

我们将 UnparkHandle 维护在一个全局 Map 中便于每个线程都能够唤醒其他线程。在线程创建时,我们向全局注册自己的 UnparkHandle 的 Weak 引用。

当需要跨线程唤醒时,我们只需要从全局 Map 里拿到这个 UnparkHandle 并尝试 upgrade,然后写入数据即可。为了减少对全部 Map 的访问,我们在每个线程中缓存这个映射。

参考 Eventfd 的实现,kernel 内部一来有锁,二来会保证这 8 byte 的 u64 是一口气写完的,不存在乱序问题。所以目前实现改为了直接走 libc::write。(wow so unsafe!)

集成 Waker

在纯本线程下,我们的唤醒逻辑是这样的:

  1. 我们想等待一个在本线程执行 future
  2. 因为事件源是 uring,所以我们在 future 被 poll 时将 task 的 waker 存储在 op 关联的存储区
  3. Uring 产生了事件,唤醒 waker
  4. Waker 执行时将任务重新塞回本线程的执行队列

在 uring driver 下,我们的事件源是 uring,所以 uring 负责存储并唤醒 waker;在 time driver 下,我们的事件源是 time wheel,所以也由其负责存储和唤醒 waker。

现在我们的事件源是其他线程。以 oneshot channel 为例,当 rx poll 时,需要将 waker 存储在 channel 的共享存储区内;在 tx send 后,需要从 channel 共享存储区拿到 waker 并唤醒。waker 的唤醒逻辑不再是无脑把任务加到本地队列,而是需要调度到其所在线程的队列中。

所以这样我们需要为每个 Executor 添加一个 shared_queue 用于共享地存储远程推入的 waker。当非本地 waker 被 wake 时,会将自己添加到目标线程的 queue 中。

Glommio 中的另一种参考实现:

前面说的方案是跨线程传递 waker,可以通用支持 channel、mutex 等数据结构。

还可以不传递 waker,poll 的时候将 waker 加入本线程的数据结构,然后发送端有数据后并不是直接唤醒接收端的 waker,而是直接唤醒它所在的线程,由对端线程轮询所有存在 waker 的 channel。

这种轮询的方式在某些场景下不够高效,且方案并不通用。

Executor 设计

Executor 在 thread per core 下按理说应该非常简单:

  1. 直接做一个 Queue 然后从一端推任务,从另一端消费任务
  2. 在没任务可做时,陷入 syscall 并等待至少一个任务完成
  3. 拿到完成任务后逐个处理,将 syscall 结果应用到 buffer 上,并 wake 对应任务。

在 epoll 下可能确实是这个逻辑;但是在 io-uring 下可能可以做一些额外的优化。

低延迟 or 减少 syscall

在我们推入 SQE 之后,我们可以当场 submit() 以尽快完成 syscall,降低延迟;也可以先等等,等到无事可做时再 submit_and_wait(1)。考虑到尽可能高性能,我们选择第二种方案(Glommio 和 Tokio-uring)——测试数据反映事实上延迟并不高,相比 Glommio 延迟有时反而因为 CPU 利用率降低而更低。在负载相对较低时,也可以采用一些动态的方式决定是否更激进地 submit

饥饿问题

在这个 case 下,饥饿问题往往是用户代码写出了问题导致的。考虑下面几个场景:

  1. 用户的 Future 每次 poll 都会 spawn 一个新任务,然后返回 Ready。
  2. 用户的 Future 每次都会立刻 wake。
  3. 用户的 Future 中的状态转换太多了,或者状态转换出现了死循环。

如果我们选择在没有任务时再处理 IO(包括提交、等待和收割),那么这几个场景下,依赖 IO 的任务都无法得到处理,因为任务队列永远不会为空,或者任务永远执行不完。

对于问题 1 和 2,我们提出了一个做法,与其执行完所有任务,我们设置一个执行上限,当达到上限时强制做一次提交和收割。

对于问题 3,可以类似 Tokio 做一个 coop 机制,限制递归 poll 执行的次数来做到限制状态转换的目的。

打个广告

Monoio 目前仍处于非常不完善的阶段,期待你的贡献:)

另外,我们还搭建了一个国内的 crates.io 和 rustup 的镜像,欢迎使用 RsProxy

达坦科技 | Tokio 解析之任务调度

作者: 施继成


简介

Tokio 是 Rust 世界里最著名的异步执行框架,该框架包罗了几乎所有异步执行的接口,包括但不限于文件、网络和文件系统管理。在这些方便使用的高层接口之下则是一些“基石”,他们并不存在于用户直接交互的接口中,而是藏于表层之下默默完成任务。这其中就包括了线程池,执行异步任务的基本单元,本文就来介绍一下 tokio 的线程池及其调度,尽可能说明其中有趣的关键点。本文涉及的代码主要在 tokio/src/runtime 下。

线程池

线程池的概念在许多语言中都有,一般大家使用线程池的原因是减少创建和销毁线程带来的性能损失。在 tokio 中线程池被用作执行异步 task 的执行资源,例如下列代码其实就是创建了一个异步任务放到了 tokio 线程池中:


#![allow(unused)]
fn main() {
tokio::spawn(
    // This is an async task
    async { ... }
);
}

至于如何存放这些 task,有几种显而易见的选择(并非只有这几种):

  1. 将所有的待执行 task 都放到一个公共的队列中(即全局队列),每个线程都从这个队列中拿取信息。
  2. 每个线程自己一个独立队列,只抓取自己队列中的 task 执行,队列中 task 空了就歇着。
  3. 每个线程自己一个独立队列,首先抓取自己队列中的 task 执行,如果自己的队列为空,则从其他线程的队列中偷取。

第一种实现很糟糕,无论如何优化那个公共队列——用锁或者原子操作——竞争带来的性能下降是无法避免的。这里需要指明一点,用原子操作并不是没有竞争,原子操作是将竞争放到了硬件,竞争多了效率仍然不好。

第二种实现也不太好,当一个线程堆满任务时,他的任务来不及执行,而其他空闲线程“无事可做”,造成线程间的不平等。这种不平等也会使得多线程并行执行的优势发挥不出来。

第三种实现则是现在常用的“任务偷取(Work Stealing)”方式,该方法避免了上述两种方法的问题,但在实现细节上仍然有许多值得优化的地方。

Work Steal 如何才能高效

Work Steal 的实现方法虽然很直接,但是有个问题仍然无法避免,存在两个或者多个线程同时从一个队列中拿取 task。想要线程安全,要么采用锁,要么采用无锁数据结构。Tokio 在早期版本中采用了基于 crossbeam 的无锁队列,但是 Tokio 作者认为这种实现仍然太重了(epoch-based gc 仍然效率不高,此为 Tokio 作者观点,本文作者未实验论证),因此之后采用了现在的实现方法。

现在 Tokio 任务队列实现仍然是无锁的,采用的是环形队列数据结构,即 ring buffer。该数据结构为了能够知道哪些 slot 已经被使用,一般会使用两个 index —— head 和 tail,从 tail 初放入 item,从 head 处拿出 item,如下图所示:

ring buffer

但是这样的数据结构如果不做修改,仍然无法满足并行访问的需求,因此 tokio 对数据结构进行了一些调整。其将 head 这个 index 变成了两部分,一部分为 head,另一外一部分为 steal。原来的 head index 为 u32,修改后前面 16 bits 是 steal, 后面 16 bits 为 head,组合一下变成了 AtomicU32。如下图所示:

atomic ring buffer

当没有人来偷取任务时,Tail 和 Head 相等,指向同一块位置。下面我们分析几种情况,看看该数据结构如何处理多人并发访问的问题。

当前 Thread 拿取本地的 Task

  1. 如果开始操作时还没有人来偷取任务,那么读取(使用 Atomic Load 方法读取,并 unpack)到的 Steal 和 Head 一定相等,那么只需要将 Steal 和 Head 都加一,再利用 compare and swap (cmp-swp) 操作存入结果,成功的话则说明 task 拿取成功。这里存在两个原子操作,第一个为 load,第二个为 cmp-swp,第二个操作有可能失败,失败的原因是此时恰巧其他线程来偷取任务,修改了该原子变量。如果cmp-swp 操作失败,则头开始再尝试一次即可,直到成功。
  2. 如果开始操作时已经有人正在偷取任务,那么读取到的 Steal 和 Head 一定不相等,那么只需要将 Head 加一,Steal 保持不变,再利用 cmp-swp 操作存入结果即可。同上,cmp-swp 如果成功,说明拿取本地 task 成功,否则失败,重复上述操作直到成功。

偷取其他 Thread 的任务

  1. 如果开始操作时,已经有其他 Thread 在偷取任务,那么 load 出来的结果中 Steal 和 Head 的值一定不等,那么放弃本次偷取,直接结束操作。

  2. 如果开始操作时,只有该 Thread 在偷取任务,那么 load 出来的结果 Steal 和 Head 一定相等,那么 Steal 值不变,仅仅增加 Head,然后利用 cmp-swp 操作存入结果。此操作的内在含义为,提前预留一块区域为偷取区域。如果 cmp-swp 成功则表明偷取任务可以继续,当前状态如下图所示。当偷取(将Steal 到 Head 中间的任务移动到偷窃者队列中)完成时,再将 Steal 变为和 Head 一样,最后利用 cmp-swp 操作存入结果。偷窃结束后的状态和开始前一样,Steal 和 Head 保持一致。这里值得注意的是,偷取包括三个原子操作,第一个为 load,第二个和第三个为 cmp-swp。第二个 cmp-swp 成功表明可以开始偷窃任务,第二个 cmp-swp 失败表明还有其他线程在操作队列(可能是其他线程在做偷取,也有可能本地线程在拿自己的任务),则从头再试一次。第三个 cmp-swp 成功表明没有线程在同步操作,失败则表明本地线程在拿取任务,这里重试将第一个load 和 第三个 cmp-swp再做一次即可。

steal ring buffer

总结一下,任务偷取的关键点:

  1. 使用一个原子变量存储 Steal 和 Head 两个值。
  2. 使用 cmp-swp 操作,保证操作过程中,没有其他人进行改动,如果有则重试。
  3. 同一时间只有一个偷窃操作能够成功。

上述的方法就实现了仅仅使用一个原子变量也能保证并发安全的 ring buffer,非常巧妙。

Work Steal 的其他优化

除了上述的核心数据结构,还有一些其他的技巧,被用以提高任务偷取的效率,这里简单列出几项:

  1. 限制同时实施任务偷取的线程数量。由于等待线程唤醒是一次性唤醒所有人,所以有可能发生大家都在偷其他线程的任务,竞争太激烈。为了避免上述情况的发生,Tokio 默认只允许一半的线程实施任务偷取,剩下的线程继续进入睡眠。
  2. 偷取对象的公平性。所谓“薅羊毛不能盯着一头羊薅”,偷取目标的选择也应该是随机的,因此初试偷窃目标是通过一个随机数生成器来决定,保证了公平性,让队列的任务更加平均。
  3. 偷取操作不应该太频繁,所以每次偷取任务的数量也不能太少,所以 Tokio 采取了“批发”的策略,每次都偷取当前队列一半数量的任务。

Work Steal 没有解决的问题

Work Steal 还有一些问题没有解决,例如当本地队列一下子涌入过多 task 的时候,这些 task 应该放在哪里?不限制长度的本地队列显然不是一个可选方案,这样会完全打破上述的 ring buffer 设计。Tokio 的解决方法是,在本地队列之外仍然维护了一个共有队列,本地放不下的任务可以放到共有队列中,由于本地队列的存在,访问共有队列的机会不多,所以竞争不会太激烈,效率也就得到了保证。

即使如此仍然存在后续问题,例如如果所有线程都优先考虑本地队列,则会出现一种可能情况——共有队列上的任务永远没有被调度起来的机会。为了避免这种不均衡的情况出现,Tokio 规定了每个线程在拿取了 61 个本地队列的 task 后就需要去共有队列中看一看,通过这种机制保证了共有队列中的任务在有限的时间内一定会被调度起来。至于为何采用了 61 这样奇怪的数字,代码中的解释如下,翻译过来就是“抄golang的,具体为啥问golang去”。


#![allow(unused)]
fn main() {
/// The number is fairly arbitrary. I believe this value was copied from golang.
}

总结

Tokio 为其多线程执行器实现了一个高效的任务偷取机制,保证了多线程能够高效并且均衡地调度任务。Tokio 的任务调度系统是其他组件的“基石”,之后的文章会继续分析 Tokio 的其他组件,一定会提到这块 “基石” 在其中起到的作用。

特定场景下的Rust无锁队列实现

作者: Joshua022 上海飞蓬网络科技有限公司


问题抽象

现有一个较大文件A(>20G),通过某种算法可以利用文件A生成另外一个相同大小的文件B,而这个目标文件B的生成同时需要用到已经产生的数据部分,即假设两个文件均有n个节点,而新文件中的i号节点需要用到原始文件中的部分节点以及新文件中[0,i-1]中的部分节点。

由于这种数据依赖关系的限制,导致在生成目标文件B的过程中,每个新生成的节点必须等待其前一个节点生成后才能生成,杜绝了同时生成文件B的不同节点的可行性。但在每次生成节点前,其依赖关系是已知的,因此,采用生产者-消费者模型预读取依赖的数据,当消费者生成节点i时,其依赖的数据已经被预读取到队列中,从而加速生成过程。这里通过无锁队列来完成新文件的生成。

问题抽象


解决思路

总体逻辑

算法实现的难点在于生成新文件依赖两部分数据,一部分是旧文件的,一部分是新文件已经生成的数据,不能将程序单独拆分为读取和运算两部分,当读取领先于计算部分时,会存在需要的数据还未生成的情况,需要计算单元单独从已经计算完毕的部分再次载入已经完成的部分。

将整个算法拆分成两个部分,由三个线程实现。两部分分别为纯读取部分,以及读取运算混合部分。由于读取数据的速率慢于计算的速度,所以使用多个线程负责读取以及标记本层缺失的部分。使用一个线程负责执行计算,以及从标记缺失处读取缺失的数据。

为了提升程序的效率,将全部的待运算数据放入内存中连续的地址中,对于复杂的运算,这样会大大提升程序的运行效率。

总体逻辑

预备知识

-Linux线程

-自旋锁 自旋锁的定义:当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock)。

-CAS算法 CAS操作需要我们提供一个期望值,当期望值与当前线程的变量值相同时,说明还没线程修改该值,当前线程可以进行修改,也就是执行CAS操作,但如果期望值与当前线程不符,则说明该值已被其他线程修改,此时不执行更新操作,但可以选择重新读取该变量再尝试再次修改该变量,也可以放弃操作。

数据共享

共享内存的实现

三个线程共享两片内存,分别为本层数据内存(layer_label),读取算法参数内存(ring_buf),本层内存主线程(consumer)具有读写的权利,子线程(producer)只有读的权力;算法参数内存首先由子线程填充原始文件数据,再填充已经生成的新文件中的数据,并将缺失的数据标记。

子线程之间共享原始文件所对应的内存,二者只负责将所对应的数据放入与主线程共享的算法参数内存中。

为了实现内存在三个线程中共享,定义了结构体UnsafeSlice以及RingBufUnsafeCell中存放的原始数据可以通过ptr以及length 重新构建,RingBuf的重新构建与UnsafeSlice相似,只不过指针需要临时获得。


#![allow(unused)]
fn main() {
#[derive(Debug)]
pub struct UnsafeSlice<'a, T> {
	// holds the data to ensure lifetime correctness
	data: UnsafeCell<&'a mut [T]>,
	/// pointer to the data   
	ptr: *mut T,   
	/// Number of elements, not bytes.   
	len: usize,
}

#[derive(Debug)]
pub struct RingBuf {    
	data: UnsafeCell<Box<[u8]>>,   
	slot_size: usize,   
	num_slots: usize,
}
}

UnsafeSlice的重构方法


#![allow(unused)]
fn main() {
#[inline]
pub unsafe fn as_mut_slice(&self) -> &'a mut [T] {
    slice::from_raw_parts_mut(self.ptr, self.len)
}
}

RingBuf的重构方法


#![allow(unused)]
fn main() {
#[allow(clippy::mut_from_ref)]
unsafe fn slice_mut(&self) -> &mut [u8] {
    slice::from_raw_parts_mut((*self.data.get()).as_mut_ptr(), self.len())
}
}

原始文件所对应的内存(exp_label),当前层的文件(layer_label)类型为: UnsafeSlice。

RingBuf 参数内存(ring­_buf)的类型为:RingBuf,由于其大小有限,因此为环状结构,即当数据填满buf之后会从头开始填充,覆盖掉之前的数据,因此consumer的长度不能超过producer的规定长度(lookahead)。 RingBuf

共享参数的实现

主线程和子线程之间共享主线程的进度参数(consumer)和子线程总进度(cur_producer),这两个变量的读取和修改应该为原子操作,即当一个线程在读取数据或修改时,另一个线程不能去访问这个数据。子线程的进度不能超过主线程的进度过多,否则会导致用于参数共享的内不足,并且本层数据的缺失率会增大。而主线程需要等待子线程进度超过主线程进度后才能继续执行(主线程需要子线程读取原始文件中的数据)。

子线程之间需要同步进度,使用两个原子变量实现,所有子线程已经完成的总进度(cur_producer),而用于告知其余子线程这一段由自己来处理的变量(cur_await)。在整个程序执行中,占位变量要领先于子线程总进度。子线程首先读取占位变量,获取自己可以执行的位置,获取成功后将站为变量进行自增操作,当别的子线程再次访问此变量时,便可以知晓前一段已经被其他线程获取,只需要接着执行即可。数据填充完毕后子线程并不能直接将总进度变量增加,为了防止数据共享内存中空段的出现,需要获取等待上一段的的线程执行完毕(cur_producer增加后),才能将总进度增加,否则会一直等待。这样可以保证每一段数据都被获取到,不会出现空线程的现象。cur_awaitingcur_producerconsumer的类型为AtomicU64,满足RMW (read modify write) 无锁程序(Lockfree program) 的规则。 共享参数

代码结构

用crossbeam开辟三个线程,首先启动子线程producer,然后执行主线程consumer的循环,直至所有节点产生完毕。


#![allow(unused)]
fn main() {
Crossbeam::thread::scope(|s| {
    let mut runners = Vec::with_capacity(num_producers);
     
     for i in 0..num_producers {
        
        // sub-thread consumer 
        runners.push(s.spawn(move |_| {
        // read data
        }))
     }
     
     // main-thread producer 
     // calculate
     for runner in runners {
            runner.join().expect("join failed");
     }
     
}).expect("crossbeam scope failure");
}

-共享参数

layer_label: UnsafeSlice 新文件数据

exp_label: UnsafeSlice 原始文件数据

base_parent_missing: UnsafeSlice 子线程标记的数据数据缺失

consumer: AtomicU64 主线程的进展

cur_producer: AtomicU64 子线程的总进展

cur_awaiting: AtomicU64 子线程的已经被分配的任务

ring_buf: RingBuf 数据传递共享内存

子线程

子线程通过参数实现同步,通过填充函数fill_buffer()完成在ring_buf的数据填充以及缺失节点base_parent_missing的标记。

输入参数

ring_buf(算法参数内存):由producer所传入的参数节点

cur_producer:当前所有producer的进度

cur_awaiting:当前所有producer已经分配的进度

layer_labels:新文件数据所对应的内存

exp_labels:源文件中所对应的内存

base_parent_missing:由producer标记的缺失节点

consumer:当前consumer的进度

stride:每个子线程每次前进的最大步长

number_nodes:总结点的数量

lookahead:Ring_buf的大小,能存储节点信息的个数

伪代码

子线程执行一个循环结构,当所有的任务都被分配完毕时cur_awaiting >= num_nodes退出循环。函数大致分为两部分,同步部分和填充数据部分,读取数据通过调用读取函数实现。


#![allow(unused)]
fn main() {
Loop{

	If Cur_await >=  num_nodes then quit loop
	Cur_await += stride
	For node between origin
	{
		cur_await and added cur_await
		If node –consumer > lookahead then sleep
		Calling fill_buffer function
	}

	If origin cur_await > cur_producer then sleep

}
}

线程同步

子线程(producer)之间通过共同维护子线程总进度变量cur_producer,子线程“占位”变量cur_awaiting实现同步。对于每个子线程首先从“占位”变量中取出已经被分配的任务节点,向占位变量增加一定的长度告知其他子线程此处任务已经被分配。而在一个子线程获取占位变量后,便可以执行数据填充操作,在向算法参数内存ring_buf中填充数据前需要判断待填充数据的序号不能领先主程序超过预留的节点个数lookahead,否则算法参数内存中还未使用的数据将会被覆盖掉,在要填充的数据序号不领先于主线程一定长度lookahead长度的情况下,将数据填充至共享内存当中,假设子线程当前的进度为p,那么应满足p - consumer <=lookahead

填充完数据后,子线程则需要修改cur_producer变量,在此使用了CAS(Compare And Swap)的概念,子线程不能直接调节子线程总进度变量cur_producer,需要判断之前节点的参数是否填充完毕,如果填充完毕则可以修改cur_producer变量,告知所有线程(producer)的新完成了部分数据,并且存放在ring_buf中,这样不会ring_buf中不会出现有些数据还未填充但其他线程(producer)的变量已经显示完毕的情况,与主线程实现同步。

子线程 填充函数

函数fill_buffer()首先处理当前层所需要的节点对应序号的数据。由于数据来自于本层需要由主程序处理产生,因此存在概率部分节点还未产生,但基于无锁队列的设计,并且程序也不希望在此处等待这些未产生的节点,因此将这些还未产生的节点标记至base_parent_missing中,由主程序执行到此自行获取。


#![allow(unused)]
fn main() {
fn fill_buffer(
    cur_node: u64,
    parents_cache: &CacheReader<u32>,
    mut cur_parent: &[u32], 
    layer_labels: &UnsafeSlice<'_, u32>,
    exp_labels: Option<&UnsafeSlice<'_, u32>>, 
    buf: &mut [u8],
    base_parent_missing: &mut BitMask,
) 
}

在标记完毕本层的数据后,取出放入缓存中或者标记为"missing"。程序从上层节点取出数据,写入buffer(上层数据已经产生,因此不存在missing的情况)。

主线程

主函数从ring_buf中读取当前已经准备好的数据,从base_parent_missing读取缺失的数据自行填充,然后计算得到新文件中的当前节点的数据。

输入参数

ring_buf:由producer所传入的参数节点

cur_producer:当前所有producer的进度

layer_labels:新文件数据所对应的内存

base_parent_missing(本层缺失标记):由producer标记的缺失节点

consumer:当前consumer的进度

number_nodes:总结点的数量

lookahead:Ring_buf的大小,能存储节点信息的个数

i:现已完成的阶段序号,不断自增直至所有节点计算完毕i>=num_nodes。

主线程逻辑

主线程执行一个有限循环结构,直至所有节点都产生完毕,对于每个子线程准备完毕的节点首先根据本层缺失标记填充相应的节点,然后利用算法参数内存中已经准备完毕的所有数据计算得到此节点。


#![allow(unused)]
fn main() {
While i < num_nodes
{

	If cur_producer < i then sleep
       	
	Get all the done node
	
	For known done nodes
	{
       		
		Use base_parent_missing to fill ring_buf
		Generate node[i] with ring_buf
		i +=1
		consumer +=1
	}
}
}

主线程执行流程

首先从共享变量中取出cur_producer,并等待主线程中的当前序号数(i)小于producer的总进度时cur_producer> i,然后根据现有所有已完成的producer的节点进行运算。

对于已知的consumer完成的所有节点,进行如下操作。首先从base_parent_missing中读取缺失的本层节点填充至ring_buf中,为接下来的运算做准备。当计算完毕后,将consumer 变量自增(告诉所有子线程已经计算完毕),再把其他必要的变量自增,为下一个节点的计算做准备。当所有已经准备好的节点计算完毕后,需要再次访问判断是否有准备好的节点,如果有则与上相同将所有节点取出并运算。 主线程

整体模型

"producer"和"consumer"通过ring_buf交互,实现同步。 整体模型

总结

并行访问同一块内存是极其不安全的,如果对同一个地址同时进行读写,极有可能引发程序崩溃。因此需要对读写的时序加以控制。在rust所有权机制的制约下,并行读写某个变量,需要增加Mutex或者Rwlock,才能保证安全,但也引入一定的性能开销。 比如对已开辟好的内存区域中的不同部分,并行进行读操作和写操作,理论上互不影响,但如果加锁,会带来对整个内存区域访问的严格控制,而且降低运行效率,对于本文描述的问题场景,并不适用。

这里是通过UnsafeCell 的方式,封装了内存,再通过ptr的方式,在每次访问内存的时候,通过unsafe获取可变引用。通过不同的原子变量,控制生产者和消费者的进度,保证对与同一地址的内存,同一时刻只有一个线程进行读或者写操作,进而保证内存安全。

当然,将内存安全的控制权交由开发者决定并不符合Rust的设计哲学,这里实现的无锁队列也只是针对特定算法场景下的一种实践,供大家一起学习研究参考。附源码地址

源代码


#![allow(unused)]
fn main() {
fn create_layer_labels(
    parents_cache: &CacheReader<u32>,
    replica_id: &[u8],
    layer_labels: &mut MmapMut,
    exp_labels: Option<&mut MmapMut>,
    num_nodes: u64,
    cur_layer: u32,
    core_group: Arc<Option<MutexGuard<'_, Vec<CoreIndex>>>>,
) 
}

code at fn create_layer_labels()

Signal | 如何用Rust构建大规模端到端加密通话

原文: https://signal.org/blog/how-to-build-encrypted-group-calls/

前言

本文不是对原文的完整翻译,只是关键摘要。

Signal是由Signal技术基金会和Signal Messenger LLC开发的跨平台加密消息服务。Signal经互联网发送一对一及组群消息,消息可包含图像及视频,它还可以用来经互联网作一对一及组群语音通话。非营利组织Signal基金会于2018年2月成立, Brian Acton 提供了初期5000万美元之初始资金。自2021年1月,Signal已获得超过1亿5百万之下载总量,并且该软件拥有约4千万月活跃用户。截止2021-2-14,Signal已被安装于超过5千万台android设备上。2021年因为 WhatsApp 修改隐私条款,马斯克呼吁大众使用 Signal 。

Signal依赖于由Signal Messenger维护的集中式服务器。 除了路由Signal的消息外,服务器还有助于发现同时也是Signal用户注册的联系人以及自动交换用户的公共密钥。 默认情况下,Signal的语音和视频通话是双方的直接连接。如果调用者不在接收者的通讯簿中,则将调用路由通过服务器以隐藏用户的IP地址。

Signal内部也大量使用 Rust 语言,并且开源了一些服务和组件库。其中包括了 Signal-Calling-Service调用服务,该服务帮助 Signal 支持端到端加密群组通话规模扩展到40人。本文就是阐述了 Signal-Calling-Service 的原理。

开源调用服务 Signal-Calling-Service 原理

选择性转发单元 (SFU,Selective Forwarding Units)

在群组通话中,每一方都需要将他们的音频和视频发送给通话中的所有其他参与者。有 3 种可能的通用架构可以这样做:

  • 全网(Full mesh):每个呼叫参与者将其媒体数据(音频和视频)直接发​​送给其他呼叫参与者。这只适用于小规模的呼叫,但不适用于参与者多的情况。大多数人的网络连接速度不够快,无法同时发送 40 个视频副本。
  • 服务器混合(Server mixing):每个呼叫参与者将其媒体数据发送到服务器。服务器将媒体“混合”在一起并将其发送给每个参与者。这适用于许多参与者,但与端到端加密不兼容,因为它要求服务器能够查看和更改媒体数据。
  • 选择性转发(Selective Forwarding):每个参与者将其媒体发送到服务器。服务器将媒体“转发”给其他参与者而不查看或更改它。这适用于许多参与者,并且与端到端加密兼容。

由于 Signal 必须具有端到端加密并扩展到许多参与者,因此使用选择性转发。执行选择性转发的服务器通常称为选择性转发单元或 SFU。

1

SFU 的主循环逻辑 Rust 代码简化版为:


#![allow(unused)]
fn main() {
let socket = std::net::UdpSocket::bind(config.server_addr);  
let mut clients = ...;  // changes over time as clients join and leave
loop {
  let mut incoming_buffer = [0u8; 1500];
  let (incoming_size, sender_addr) = socket.recv_from(&mut incoming_buffer);
  let incoming_packet = &incoming_buffer[..incoming_size];

  for receiver in &clients {
     // Don't send to yourself
     if sender_addr != receiver.addr {
       // Rewriting the packet is needed for reasons we'll describe later.
       let outgoing_packet = rewrite_packet(incoming_packet, receiver);
       socket.send_to(&outgoing_packet, receiver.addr);
     }
  }
}
}

实际上 Signal 团队考察了很多开源的 SFU 实现,但是符合拥塞控制(congestion control)的只有两个,然而还需要大量修改才最多支持8个参与者。因此,他们才决定用 Rust 重新实现一个新的 SFU。目前,它已经为 Signal 线上服务九个月,可以轻松扩展到40个参与者(未来会更多)。可以作为基于 WebRTC 协议实现 SFU 的参考实现,代码可读性非常高。并且借鉴了 googcc 的拥塞控制算法。

SFU 最困难的部分

SFU 最困难的部分是在网络条件不断变化的同时将正确的视频分辨率转发给每个呼叫参与者。

这个困难是以下基本问题的组合:

  • 每个参与者的网络连接容量都在不断变化并且很难知道。如果 SFU 发送过多,则会造成额外的延迟。如果 SFU 发送的太少,质量就会很低。因此,SFU 必须不断仔细地调整它发送给每个参与者的数量,以使其“恰到好处”。
  • SFU 不能修改它转发的媒体数据。要调整它发送的数量,它必须从发送给它的媒体数据中进行选择。如果可供选择的“选项”仅限于发送可用的最高分辨率或根本不发送,则很难适应各种网络条件。因此,每个参与者必须向 SFU 发送多种分辨率的视频,并且 SFU 必须不断小心地在它们之间切换。

解决方案是结合几种我们将单独讨论的技术:

  • 联播(Simulcast)和数据包重写(Packet Rewriting)允许在不同的视频分辨率之间切换。
  • 拥塞控制(Congestion Control)确定要发送的正确数量。
  • 速率分配(Rate Allocation)决定在该预算内发送什么内容。

联播(Simulcast)和数据包重写(Packet Rewriting)

为了让 SFU 能够在不同分辨率之间切换,每个参与者必须同时向 SFU 发送许多层(layers,分辨率),这就叫联播(Simulcast),俗称大小流。它是 WebRtc 中的一个概念。

2

  • 上行一般是三路流,按分辨率和码率,一般分为fhq(大中小)三层
  • 下行可以分给不同的用户不同的流,比如网不好时分发个小流q,网变好了再切回大流f

SFU 与视频流服务器不同,它不存储任何内容,它的转发必须是即时的,它通过称为数据包重写的过程来实现。

数据包重写是更改媒体数据包中包含的时间戳、序列号和类似 ID 的过程,这些 ID 指示数据包在媒体时间线上的位置。它将来自许多独立媒体时间线(每层一个)的数据包转换为一个统一的媒体时间线(一层)。

数据包重写与端到端加密兼容,因为在端到端加密应用于媒体数据之后,发送参与者将重写的 ID 和时间戳添加到数据包中。这类似于使用 TLS 时加密后如何将 TCP 序列号和时间戳添加到数据包中。

拥塞控制

拥塞控制是一种确定通过网络发送多少的机制:不要太多也不要太少。它的历史悠久,主要是TCP的拥塞控制形式。不幸的是,TCP 的拥塞控制算法通常不适用于视频通话,因为它们往往会导致延迟增加,从而导致通话体验不佳(有时称为“滞后”)。为了为视频通话提供良好的拥塞控制,WebRTC 团队创建了 googcc ,这是一种拥塞控制算法,可以确定正确的发送量,而不会导致延迟大幅增加。

拥塞控制机制通常依赖于某种从包接收方发送到包发送方的反馈机制。googcc 旨在与 transport-cc 一起使用,该协议中接收方将定期消息发送回发送方,例如,“我在时间 Z1 收到数据包 X1;在时间 Z2 的数据包 X2,……”。然后发送方将这些信息与自己的时间戳结合起来,例如,“我在 Y1 时间发送了数据包 X1,它在 Z1 被接收到;我在时间 Y2 发送了数据包 X2,然后在 Z2 收到了它……”。

在Signal Calling Service中,以流处理的形式实现了googcc和transport-cc。流管道的输入是上述关于数据包何时发送和接收的数据,我们称之为acks。管道的输出是应该通过网络发送多少的变化,称之为目标发送速率。

流程的前几步是绘图确认延迟与时间的关系图,然后计算斜率以确定延迟是增加、减少还是稳定。最后一步根据当前的斜率决定要做什么。代码的简化版本如下所示:


#![allow(unused)]
fn main() {
let mut target_send_rate = config.initial_target_send_rate;
for direction in delay_directions {
  match direction {
    DelayDirection::Decreasing => {
      // While the delay is decreasing, hold the target rate to let the queues drain.
    }
    DelayDirection::Steady => {
      // While delay is steady, increase the target rate.
      let increase = ...;
      target_send_rate += increase;
      yield target_send_rate;
    }
    DelayDirection::Increasing => {
      // If the delay is increasing, decrease the rate.
      let decrease = ...;
      target_send_rate -= decrease;
      yield target_send_rate;
    }
  }
}
}

拥塞控制很难,但是现在它基本可以用于视频通话:

  • 发送方选择一个初始速率并开始发送数据包。
  • 接收方发送回有关何时收到数据包的反馈。
  • 发送方使用该反馈根据上述规则调整发送速率。

速率分配

现在 SFU 已经知道要发送多少,接下来就必须确定要发送什么内容(要转发哪些层),这个过程被称为速率分配。

这个过程就像 SFU 从受发送速率预算约束的层菜单中进行选择。例如,如果每个参与者发送 2 个层,而还有 3 个其他参与者,则菜单上总共有 6 个层。

如果预算足够大,我们可以发送我们想要的所有内容(直到每个参与者的最大层)。但如果没有,我们必须优先考虑。为了帮助确定优先级,每个参与者通过请求最大分辨率来告诉服务器它需要什么分辨率。使用该信息,我们使用以下规则进行费率分配:

  • 大于请求的最大值的层被排除在外。例如,如果只查看小视频网格,则无需发送每个视频的高分辨率。
  • 较小的层优先于较大的层。例如,最好以低分辨率查看每个人,而不是以高分辨率查看某些人而其他人则根本不查看。
  • 较大的请求分辨率优先于较小的请求分辨率。例如,一旦可以看到所有人,那么认为最大的视频将在其他视频之前以更高的质量填充。

代码的简化版本如下所示:


#![allow(unused)]
fn main() {
// The input: a menu of video options.
// Each has a set of layers to choose from and a requested maximum resolution.
let videos = ...;

// The output: for each video above, which layer to forward, if any
let mut allocated_by_id = HashMap::new();
let mut allocated_rate = 0;

// Biggest first
videos.sort_by_key(|video| Reverse(video.requested_height));

// Lowest layers for each before the higher layer for any
for layer_index in 0..=2 {
  for video in &videos {
    if video.requested_height > 0 {
      // The first layer which is "big enough", or the biggest layer if none are.
      let requested_layer_index = video.layers.iter().position(
         |layer| layer.height >= video.requested_height).unwrap_or(video.layers.size()-1)
      if layer_index <= requested_layer_index {
        let layer = &video.layers[layer_index];
        let (_, allocated_layer_rate) = allocated_by_id.get(&video.id).unwrap_or_default();
        let increased_rate = allocated_rate + layer.rate - allocated_layer_rate;
        if increased_rate < target_send_rate {
          allocated_by_id.insert(video.id, (layer_index, layer.rate));
          allocated_rate = increased_rate;
        }
      }
    }
  }
}

}

整合

通过结合这三种技术,就有一个完整的解决方案:

  • SFU 使用 googcc 和 transport-cc 来确定它应该向每个参与者发送多少。
  • SFU 使用速率分配来选择要转发的视频分辨率(层)。
  • SFU 将多个层的数据包重写为每个视频流的一层。

结果是每个参与者都可以在给定当前网络条件的情况下以最佳方式查看所有其他参与者,并且与端到端加密兼容。

端到端加密

说到端到端加密,值得简要描述它的工作原理。因为它对服务器完全不透明,所以它的代码不在服务器中,而是在客户端中。特别是,我们的实现存在于RingRTC,一个用 Rust 编写的开源视频通话库。

每个帧的内容在被分成数据包之前都经过加密,类似于SFrame。有趣的部分实际上是密钥分发和轮换机制,它必须对以下场景具有鲁棒性:

  • 未加入呼叫的人必须无法解密他们加入之前的媒体数据。如果不是这种情况,可以获取加密媒体数据的人(例如通过破坏 SFU)将能够在他们加入之前知道呼叫中发生的事情,或者更糟的是,从未加入过。
  • 离开通话的人必须无法解密他们离开后的媒体。如果不是这种情况,可以得到加密媒体的人就可以知道他们离开后通话中发生了什么。

为了保证这些属性,我们使用以下规则:

  • 当客户端加入呼叫时,它会生成一个密钥并通过 Signal 消息(它们本身是端到端加密的)将其发送到呼叫的所有其他客户端,并在将其发送到 SFU 之前使用该密钥加密媒体数据。
  • 每当任何用户加入或离开通话时,通话中的每个客户端都会生成一个新密钥并将其发送给通话中的所有客户端。然后它在 3 秒后开始使用该密钥(允许客户端有一段时间接收新密钥)。

使用这些规则,每个客户端都可以控制自己的密钥分配和轮换,并根据呼叫中的人而不是受邀参加呼叫的人来轮换密钥。这意味着每个客户端都可以验证上述安全属性是否得到保证。

小结

在该文章相关的 Reddit 讨论贴中,该库作者表示:

  1. 很喜欢用 Rust 来写这个项目,并且认为 Rust 是实现这类项目最好的语言。
  2. 关于优化性能有两点努力:
    • 通过使用 epoll 和多线程,使读写数据包的代码更快、更并发。
    • 将我们的锁定更改为更细粒度。
  3. 认为服务器的所有主要逻辑对性能的影响几乎没有一般的“通过服务器推送大量数据包”那么重要。
  4. RingRTC 通过 JNI 将 Rust 代码整合到 Andriod 平台。

学习资源

聚焦学习 Rust 的网络资源


Rust 编译为什么慢?

本文作者使用的硬件很高端,AMD Ryzen9 5950X CPU 32 核,128GB内存 和 SSD 硬盘。理论上编译 Rust 项目是非常快的。

他的项目在这个硬件配置下编译时间最多只有两秒零九。(但是作者还觉得 Rust 编译很慢)

所以作者开始探索 Rust 编译时候到底在干什么。

https://fasterthanli.me/articles/why-is-my-rust-build-so-slow

用 Rust 和 WebAssembly 在浏览器中绘制“甜甜圈”圆环

该demo 作者分享给想进入计算机图形学新手的学习路线:

如果您有兴趣深入研究图形世界,请查看学习 OpenGL。我经历这件事的速度很慢——主要是因为工作,不知道 C++,有编程之外的生活,需要边学习线性代数;但是一旦我完成了“入门”部分,我就有了足够的知识来学习 WebGL,并且知道使用什么方程式来实现基本的变换(旋转、平移和缩放)和相机移动。

如果您不了解 C++,请不要被它吓倒,因为如果您能阅读 Rust,您就可以阅读作者在书中写的 C++。为了与 OpenGL 交互,我使用了glium;不过,将 C++ 代码翻译成 Rust/glium 可能很棘手。与作者使用的线性代数库 (GLM) 等效的 Rust 是nalgebra_glm

现在,如果您不知道线性代数,那会有点困难,但是,那里有大量资源,它们基本上概述了在图形中产生生产力所需的最少线性代数。

作者对 Rust 和 WebAssembly 工具链的看法:

我还没有完全阐明我的观点,但对我来说最大的吸引力当然是能够用 Rust 编写浏览器代码并以 WASM 为目标,这非常安全、小巧且高效。然而,也有缺点,其中大部分在于开发人员的体验。

Rust + WASM 工具链还很年轻,生态系统还很不成熟,这意味着在学习最佳实践、如何启动项目以及如何将 Rust + WASM 引入时,那里的资源稀缺现有项目。

然而,在一天结束时,我很高兴我可以用 JavaScript 以外的其他东西编写严格的浏览器代码,让该语言编译为 JavaScript 以外的东西(看看你的 TypeScript),并且可以肯定,我得到的二进制文件是小巧、安全、高效。

用 egui & Wasm 学习 Rust,做了一个小的在线进制转换工具作为圣诞节项目

作者并没有分享源码,但是分享了他学习 egui 的经验:

对我来说,使用 egui 非常简单,GitHub 上有一个模板项目“eframe_template”(由 egui 作者 emilk 维护),它可以轻松编译本机和 Wasm。因此,针对本机后端进行开发并最终在需要时为 Wasm 构建是一个非常快速的周转。虽然我也简要地研究过 imgui-rs,我还没有尝试过,所以我无法比较这两个,但根据我的理解 imgui-rs / Dear ImGui 功能更丰富,但是 egui 的视觉效果和简单性更吸引我,并且我还想从一个 Rust 原生库开始,尽可能少的依赖,所以我选择了 egui。说到功能,我发现 egui 开箱即用,非常适合各种本机工具,对于 Web 应用程序,它在桌面上运行良好,在移动设备上,体验可能会好一点,例如,我在移动设备上需要的 UI 缩放方面有点挣扎,这可以通过使用样式系统(顺便说一句,功能强大)以某种方式解决,但这需要一些额外的工作,所以我很好奇 egui 在这方面将如何发展。在任何情况下,如果 Wasm 是主要目标,我会考虑使用 iced lib,它使用本机 Web DOM,这对于传统的 Web 体验和集成来说绝对更好。

https://apps.4fips.com/nubco/

使用 cargo-udeps 检查 Rust 项目中未使用的依赖项

cargo-udeps 是一个了不起的项目,可帮助您分析 Rust 代码库上未使用的依赖项。您可以简单cargo install cargo-udeps地安装它。

1、安装


#![allow(unused)]
fn main() {
cargo install cargo-udeps --locked
}

2、使用


#![allow(unused)]
fn main() {
cargo +nightly udeps
}

3、忽略依赖项 要忽略某些依赖项,请添加package.metadata.cargo-udeps.ignore到Cargo.toml.


#![allow(unused)]
fn main() {
[package.metadata.cargo-udeps.ignore]
normal = ["if_chain"]
development = []
build = []

[dependencies]
if_chain = "1.0.0" # Used only in doc-tests, which `cargo-udeps` cannot check.
https://erayerdin.com/checking-unused-dependencies-in-a-rust-project-with-github-actions-ckwm3yov901cwlvs1h48z54xi
}

使用 rg3d 游戏引擎进行游戏开发

Rg3d, Rust Game engine 3D and 2D, 一个用 Rust 编写的功能丰富、生产环境就绪、通用的 2D/3D 游戏引擎,并带有场景编辑器。

用 Vim 写 Rust 代码

Neovim 是 vim 的一个分支,它专注于可扩展性和可用性。比如,能够使用 Lua 以及各种脚本来编写插件,并为扩展编辑器提供更大的灵活性。

在 Neovim 0.5 版本中,开发者引入了语言服务器协议 (LSP) 客户端框架 (:help lsp)

这意味着,Neovim 可以充当 LSP 服务器(如 rust-analyzer)的客户端,并协助构建增强的 LSP 工具。

下面这篇文章就详细说明了 如何为 Neovim 配置 Rust 插件,可以省去很多自己折腾的时间。

https://sharksforarms.dev/posts/neovim-rust/

为 Rust 借用检查建模

这篇文章通过为 Rust 借用检查建模,探索 Rust 借用检查的工作机制,这个思考过程值得学习。

https://whileydave.com/2021/12/06/modelling-borrow-checking-in-rust/

Rust 错误处理

一篇介绍 Rust 错误处理的新文章

https://www.unwoundstack.com/blog/rust-error-handling.html

用 Nom 在 Rust 中构建一个 CEDICT 解析器

CEDICT 格式是一种简单的、创造性的、通用许可的中/英词典文件格式。虽然有很多只支持普通话的CEDICT解析器,但在英语编程世界中,基本上不支持粤语的jyutping。作为一个希望在节目中使用广东话发音的人,一开始作者被困住了。最终,作者自己动手写了一个解析器.

https://briankung.dev/2021/12/07/building-a-cedict-parser-in-rust-with-nom/

Embark Studio : 使用 Rust 进行本地渲染

作为 Rust 的忠实拥护者,Embark Studio 的工程师们正在使用 Rust 开发渲染引擎和游戏,并且贡献了 rust-gpu 这个项目。

在这篇技术博客中,一位来自 Embark 的工程师讲述了如何处理用户创建的世界的 3D 渲染,以及 Rust 如何帮助他们更好地实现这个目标。

并且宣布开源了实验性全局光照渲染引擎:kajiya

https://medium.com/embarkstudios/homegrown-rendering-with-rust-1e39068e56a7

关于Rust中上下文和能力的思考

今天早些时候,我阅读了Tyler Mandry的《Rust中的上下文和能力》,我非常喜欢我看到的内容。但是我和很多人一样,有很多问题。这将如何影响语言?这是否会损害定位性/可读性?需要什么来使之适应?

所以这里是我到目前为止的想法的探索。

https://jam1.re/blog/thoughts-on-contexts-and-capabilities-in-rust

无缓冲 I/O 会使您的 Rust 程序变慢

Rust 作为一种开发语言已经确立让开发人员能够编写快速和安全的代码的声誉现在。像 Mozilla、Microsoft、Dropbox 和 Amazon(仅举几例)这样的大型组织都依赖 Rust 为他们的客户提供一流的性能,同时避免许多影响用 C 或 C++ 编写的程序的安全问题。性能是 Rust 成为大多数人选择语言的一个主要原因。然而,仅仅用 Rust 编写代码并不能保证高性能。Rust 是好的,但它不是魔法。它是一种工具,与任何工具一样,我们必须有效地使用它才能获得最佳结果。在这篇文章中,我们将研究 Rust 代码性能不佳的常见原因,即使是资深开发人员也可能会遇到这种情况。也就是说,默认情况下,不缓冲文件的读取和写入。

https://era.co/blog/unbuffered-io-slows-rust-programs

在没有 Docker 的情况下在 macOS 上交叉编译 Rust Lambdas

在 Rust 中开发 Lambda 函数的标准方法是使用 AWS 提供的自定义 Lambda 运行时并在部署之前交叉编译所有内容。这里介绍如何在 macos 上进行开发 Lambda 函数。

brew tap messense/macos-cross-toolchains
brew install aarch64-unknown-linux-gnu

然后,在您的环境中设置这些变量(例如在bashrc 中):

export CC_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-gcc
export CXX_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-g++
export AR_aarch64_unknown_linux_gnu=aarch64-unknown-linux-gnu-ar
export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-unknown-linux-gnu-gcc

现在cargo build --target aarch64-unknown-linux-gnu将在 macOS 上按预期工作,并生成准备部署到 AWS Lambda 的二进制文件 - 无需 Docker

https://noserve.rs/rust-lambdas-macos/

Rust 安全可移植的数据结构设计

这是来自 Code and Supply Lighting Talk 的一个演讲, 主题是如何 设计一个数据结构, 他能够在任意的系统和任意的嵌入式设备.

使用 Rust 编写prometheus exporter

作者详细描述了自己如何使用 Rust 编写 prometheus的 exporter 到存储和画图的过程。这对于了解 Prometheus 导出器工作原理有帮助。

https://mateusfreira.github.io/@mateusfreira-writing-a-prometheus-exporter-in-rust-from-idea-to-grafana-chart/

在Rust中模拟HTTP服务

Mocking HTTP Services in Rust

本文展示了如何使用mock库来模拟HTTP服务。如何使我们在自动化测试期间验证应用程序发送的HTTP请求是否符合我们的期望,并且还可以模拟来自依赖服务的HTTP响应,以确保我们的应用程序能够相应地处理它们。此外,作者还展示了模拟工具如何在开发过程中替换不可用的HTTP服务,并使它们能够同时被许多应用程序访问。

多功能模拟工具可以在开发生命周期的多个阶段中实用,而不仅仅是集成测试。然而,它们对于增强基于HTTP的API客户端特别有用,并允许我们测试很难复现的边界case。

作者介绍了一些可以做mock的开源库,并做了对比:

LibraryExecutionCustom MatchersMockable APIsSync APIAsync APIStand-alone Mode
mockitoserialno1yesnono
httpmockparallelyesyesyesyes
httptestparallelyesyesnono
wiremockparallelyesnoyesno

《在Rust中制作游戏》系列教程

现在我们有了一种语言:Rust,一种游戏引擎:Bevy,还有一种类型:Platformer。 这一系列文章将是我用这些工具构建一个小型平台游戏之旅的日志。

infinitree - 嵌入式加密数据库

Infinitree 是一个嵌入式加密数据库。

  • 默认线程安全
  • 透明地处理热/温/冷存储层;目前支持 S3 兼容的后端
  • 可以使用Iterator trait查询,无需完全加载的版本化数据结构
  • 加密所有磁盘数据,仅在使用时解密
  • 专注于性能和灵活
  • 可扩展的自定义数据类型和存储策略

https://github.com/symmetree-labs/infinitree

使用 Rust 编写的生成艺术服务

chilipepperhott/generative-art 是一个全新的交互式生成艺术服务,用 Rust 编写,可以编译为 wasm 。访问 https://elijahpotter.dev/art/ 即可在线体验。

https://github.com/chilipepperhott/generative-art

一行代码如何让 24 核服务器比笔记本电脑还慢

https://pkolaczk.github.io/server-slower-than-a-laptop/

Rust写Python扩展的9个原则

  • 创建一个同时包含 Rust 和 Python 项目的仓库。
  • 使用 maturin & PyO3 在 Rust 中创建 Python 可调用的翻译器函数。
  • 让 Rust 翻译器函数调用 “好的” Rust 函数。
  • 在 Python 中预分配内存。
  • 将好的 Rust 错误处理转换为好的 Python 错误处理。
  • 使用 Rayon 和 ndarray::parallel 的多线程,返回任何错误。
  • 允许用户控制并行线程数。
  • 将好的动态类型 Python 函数转换为好的 Rust 通用函数。
  • 同时创建 Rust 和 Python 的测试。

https://towardsdatascience.com/nine-rules-for-writing-python-extensions-in-rust-d35ea3a4ec29

了解原子和内存排序的工作原理

作者使用一个带有简单单元测试的 repo 增强对这个主题的理解。其中,包含了一个简单例子和一个复杂例子。

https://github.com/blasrodri/atomic-story

在 Rust 中为 RISC-V OS 实现自旋锁

自旋锁是最基本的同步实现之一,也是实现操作系统时首先要考虑的组件之一。文章将简要回顾自旋锁的基础知识,如何在 Rust 中为自制操作系统实现它,以及它相对于 C 语言的优势。

https://vmm.dev/en/rust/spinlock.md

SNAFU 0.7 发布

SNAFU 是一个库,可以在添加上下文的同时轻松地将底层错误分配到特定于域的错误中。可以类似这样处理错误:


#![allow(unused)]
fn main() {
// We support struct errors ...
#[derive(Debug, Snafu)]
#[snafu(display("An error occurred for {username}"))]
struct OneKindOfError { username: String }

// ... enum errors ...
#[derive(Debug, Snafu)]
enum AnotherKindOfError {
    #[snafu(display("Unable to finish situation one"))]
    SituationOne { source: OneKindOfError },

    #[snafu(display("Unable to finish situation two for {user_id}"))]
    SituationTwo { source: OneKindOfError, user_id: u32 },
}

// ... and opaque errors, great for exposing as part of a public API.
#[derive(Debug, Snafu)]
pub struct Error(AnotherKindOfError);
}

1 月 4 日 SNAFU 0.7 发布,更新了包括:使用结构和枚举的自定义错误类型,上下文选择器可以放在一个模块中等。

https://users.rust-lang.org/t/snafu-0-7-released/69766

将 Rust 的 std 移植到 rustix

Rustix 是一个具有多个后端的系统调用包装库。它有一个原始的 Linux 系统调用后端,以及一个 libc 后端,其他后端正在开发中。Rustix 专为内存安全、I/O 安全和性能而设计。rustix 简化了与 C 整数类型大小相关的系统调用 API 中的一些小缺陷。举个例子:


#![allow(unused)]
fn main() {
let len = cmp::min(buf.len(), <wrlen_t>::MAX as usize) as wrlen_t;
   let ret = cvt(unsafe {
       c::send(self.inner.as_raw(), buf.as_ptr() as *const c_void, len, MSG_NOSIGNAL)
   })?;
}

将会变成


#![allow(unused)]
fn main() {
   let ret = rustix::net::send(&self.inner, buf, SendFlags::NOSIGNAL)?;
}

这将重点放在send操作上,而没有unsafe、原始指针、wrlen_t类型和cvt错误处理的干扰。Rustix 还能够从 Rust 代码直接进行 Linux 系统调用。比如 origin 是一个 Rust 库,它能够启动和关闭进程和线程(类似于 crt1.o 和 libpthread)。有了这些,我们就有了在 Linux 上运行 Rust 程序所需的所有东西。

https://blog.sunfishcode.online/port-std-to-rustix/

Rust 比 C 代码更容易移植

pngquant/libimagequant 的作用把这个库使用 Rust 重写了。他发现使用 Rust 写的版本,更容易移植。这个库是用来处理 png 图片压缩的其中一步的。

https://pngquant.org/rust.html

Rust 和 Valgrind 配合使用

这篇文章讲了为什么 Valgrind 对 Rust 是有用的。

  • Valgrind 不止是内存错误探测工具
  • Rust 并不是完全内存安全的(因为有的时候 unsafe 代码不可避免)

https://nnethercote.github.io/2022/01/05/rust-and-valgrind.html

让数字解析快 4 倍

一篇有关解析器 parser 的文章,学习。

https://cantortrading.fi/rust_decimal_str/

cargo 的 strip 指令稳定下来了

也就是裁减编译后的二进制文件体积大小。

https://github.com/rust-lang/cargo/pull/10088

Aero - 一个现代、实验性、unix-like 的操作系统

又一个用 Rust 写的操作系统!

https://github.com/Andy-Python-Programmer/aero

Rust基础语句汇编分析

作者:李睿

作者正在积极寻找寒暑假实习机会,如果您能提供帮助,请通过文章底部的联系方式联系作者,不胜感激。

从开始接触Rust以来,我一直在惊叹于Rust的安全、高效,享受Rust带来的开发的愉悦。但随着开发的Rust项目越来越多,我的疑惑也逐渐增大,我们知道Rust可以很好的工作,但它是如何运作的?Rust是怎么处理每一条match语句的?是怎么传递参数的?

下面我们尝试去理解它,窥视Rust运作原理的一部分(非常小的一部分)。我们将编写一系列包含简单语句的函数,用静态分析工具分析汇编指令,搞明白程序是怎么执行的。希望这篇文章能够帮助到您,当然这篇文章可能包含部分甚至完全的错误,如果您能够在评论区指出,既能够帮助作者纠正错误,也能让其他读者免受作者之害(笑)。

本文的代码

作者的二进制水平有限,目前只能对于简单的语句进行分析,因此我们的第一篇文章就围绕下面的简单代码展开,争取搞懂每一个函数。作者事先也没有分析过Rust的二进制,希望能在写这篇文章的时候和读者一起学习。如果遇到带有疑问的地方,作者会用加粗字体标识,大家可以一起讨论。

在编译代码的时候,我们的项目名称为assembly,使用debug编译,而非release。

#![feature(start)]

#[start]
#[no_mangle]
fn boost(argc: isize, argv: *const *const u8) -> isize {
    mangled();
    try_variable();
    try_match();
    try_match_with_arg(1);
    try_so_many_arg(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
    try_str_arg("wow");
    0
}

fn mangled() {}

#[no_mangle]
fn try_variable() {
    let foo = 1;
    let bar = 2;
    let mut bar = 3;
    bar += 1;
    let foo = 5;
}

#[no_mangle]
fn try_match() {
    let mut foo = 1;
    match foo {
        1 => {
            foo = 2;
        }
        _ => {
            foo = 3;
        }
    }
}

#[no_mangle]
fn try_match_with_arg(foo: i32) -> i32 {
    match foo {
        1 => {
            0
        }
        2 => {
            1
        }
        _ => {
            2
        }
    }
}

#[no_mangle]
fn try_so_many_arg(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32, i: i32, j: i32, k: i32, l: i32) {
    let a = a + 1;
    let b = b + 2;
}

#[no_mangle]
fn try_str_arg(s: &str) {
    let _ = s.len();
}

#[no_mangle]是什么

本节对应的RFC页面:2603-rust-symbol-name-mangling-v0

在Rust中,我们程序中的符号在编译时会经过名字修饰这一过程,这是为了保证实体名字必须唯一的一种技术。比如我们的函数名为mangled,在经过名字修饰后,函数名会变为类似_ZN8assembly7mangled17hdae1ffd2c2da5f53E的一串,具体的规则可以查看RFC中的讲述。为了保证我们可以轻松找到编译后的函数,我们需要为函数加上#[no_mangle]这一属性,保证函数保留原本的名称。

#[start]是什么

相信部分读者已经注意到了,我们的函数没有main函数,而是为boost加上了start属性。Rust和C语言不一样,我们一般情况编写的main函数在编译后同样会经过上面说的名字修饰。而Rust在执行时,会经过_start -> main -> our_main的过程,我们的main函数实际上是被调用的,而不是入口!

下面我们来验证:

首先读取ELF头,得到入口点地址为0x6470。

ELF 头:
  Magic:  7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  类别:                              ELF64
  数据:                              2 补码,小端序 (little endian)
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              DYN (共享目标文件)
  系统架构:                          Advanced Micro Devices X86-64
  版本:                              0x1
  入口点地址:              0x6470
  程序头起点:              64 (bytes into file)
  Start of section headers:          3585048 (bytes into file)
  标志:             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         14
  Size of section headers:           64 (bytes)
  Number of section headers:         43
  Section header string table index: 42

查看Objdump的结果:

而main函数:

验证成功!

这里我们想为main设置no_mangle属性,但实际上已经有一个main了,重复的符号是不允许的,因此我们需要使用start+no_mangle来自定义我们程序的“入口”。

boost函数

源码

fn boost(argc: isize, argv: *const *const u8) -> isize {
    try_variable();
    try_match();
    try_match_with_arg(1);
    try_so_many_arg(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
    try_str_arg("wow");
    0
}

汇编

0000000000006570 <boost>:
    6570:        48 83 ec 48                  sub    $0x48,%rsp
    6574:        48 89 7c 24 38               mov    %rdi,0x38(%rsp)
    6579:        48 89 74 24 40               mov    %rsi,0x40(%rsp)
    657e:        e8 7d 00 00 00               call   6600 <_ZN8assembly7mangled17hdae1ffd2c2da5f53E>
    6583:        e8 88 00 00 00               call   6610 <try_variable>
    6588:        e8 b3 00 00 00               call   6640 <try_match>
    658d:        bf 01 00 00 00               mov    $0x1,%edi
    6592:        e8 c9 00 00 00               call   6660 <try_match_with_arg>
    6597:        31 ff                        xor    %edi,%edi
    6599:        be 01 00 00 00               mov    $0x1,%esi
    659e:        ba 02 00 00 00               mov    $0x2,%edx
    65a3:        b9 03 00 00 00               mov    $0x3,%ecx
    65a8:        41 b8 04 00 00 00            mov    $0x4,%r8d
    65ae:        41 b9 05 00 00 00            mov    $0x5,%r9d
    65b4:        c7 04 24 06 00 00 00         movl   $0x6,(%rsp)
    65bb:        c7 44 24 08 07 00 00         movl   $0x7,0x8(%rsp)
    65c2:        00 
    65c3:        c7 44 24 10 08 00 00         movl   $0x8,0x10(%rsp)
    65ca:        00 
    65cb:        c7 44 24 18 09 00 00         movl   $0x9,0x18(%rsp)
    65d2:        00 
    65d3:        c7 44 24 20 0a 00 00         movl   $0xa,0x20(%rsp)
    65da:        00 
    65db:        c7 44 24 28 0b 00 00         movl   $0xb,0x28(%rsp)
    65e2:        00 
    65e3:        e8 c8 00 00 00               call   66b0 <try_so_many_arg>
    65e8:        48 8d 3d 11 fa 02 00         lea    0x2fa11(%rip),%rdi        # 36000 <_fini+0x3d8>
    65ef:        be 03 00 00 00               mov    $0x3,%esi
    65f4:        e8 67 01 00 00               call   6760 <try_str_arg>
    65f9:        31 c0                        xor    %eax,%eax
    65fb:        48 83 c4 48                  add    $0x48,%rsp
    65ff:        c3                           ret  

伪代码

isize __fastcall assembly::boost(isize argc, u8 **argv)
{
  _str v2; // rdi

  assembly::mangled::hdae1ffd2c2da5f53();
  assembly::try_variable();
  assembly::try_match();
  assembly::try_match_with_arg(1);
  assembly::try_so_many_arg(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
  v2.data_ptr = (u8 *)"wowsrc/main.rs";
  v2.length = 3LL;
  assembly::try_str_arg(v2);
  return 0LL;
}

分析

在我们boost函数中,包含了一系列函数调用和参数传递,从中可以看出:Rust中的参数传递,前六个参数放到rdi rsi rdx rcx r8d r9d寄存器,之后的参数放入栈中。

而对于&str类型,Rust会用一个结构去存储,这个结构包含下面两个元素:

  • data_ptr:指向数据地址的指针
  • length:长度

这里我们的第一个放置了指向"wow"的地址,其保存在.rodata段中。第二个参数为3,代表&str的长度为3。如果不指定长度,&str也不像C语言会以'\0'进行截断,我们将不知道&str的值为什么。

最后的返回值部分可以看到eax用异或清零,直接返回。

try_variable函数

源码

fn try_variable() {
    let foo = 1;
    let bar = 2;
    let mut bar = 3;
    bar += 1;
    let foo = 5;
}

汇编

0000000000006610 <try_variable>:
    6610:        48 83 ec 10                  sub    $0x10,%rsp
    6614:        c7 44 24 04 01 00 00         movl   $0x1,0x4(%rsp)
    661b:        00 
    661c:        c7 44 24 08 02 00 00         movl   $0x2,0x8(%rsp)
    6623:        00 
    6624:        c7 04 24 03 00 00 00         movl   $0x3,(%rsp)
    662b:        c7 04 24 04 00 00 00         movl   $0x4,(%rsp)
    6632:        c7 44 24 0c 05 00 00         movl   $0x5,0xc(%rsp)
    6639:        00 
    663a:        48 83 c4 10                  add    $0x10,%rsp
    663e:        c3                           ret    
    663f:        90                           nop

分析

在Rust中i32的类型会占用4Byte内存,在函数头部我们可以看到栈顶地址被减去16Byte为局部变量开辟空间,此时栈的分布如下:

由于Rust中支持变量遮蔽,我们能够重复声明foo和bar变量并赋值,但他们都拥有各自的内存空间,而不是覆盖原有的空间。同时编译器已经为我们把加法优化为了mov指令直接赋值。

在可变性上,Rust会在编译上为我们强调mut和非mut的区别,而实际的编译结果是看不出来的。

try_match函数

源码

fn try_match() {
    let mut foo = 1;
    match foo {
        1 => {
            foo = 2;
        }
        _ => {
            foo = 3;
        }
    }
}

汇编

0000000000006640 <try_match>:
    6640:        48 83 ec 04                  sub    $0x4,%rsp
    6644:        c7 04 24 01 00 00 00         movl   $0x1,(%rsp)
    664b:        c7 04 24 02 00 00 00         movl   $0x2,(%rsp)
    6652:        48 83 c4 04                  add    $0x4,%rsp
    6656:        c3                           ret    
    6657:        66 0f 1f 84 00 00 00         nopw   0x0(%rax,%rax,1)
    665e:        00 00 

分析

我们可以看到这里编译器已经为我们优化了代码,由于match的变量可知,所以只需要包含值为1的分支即可。

这里抛出一个疑问:最后的nopw指令有何作用?

try_match_with_arg函数

源码

#[no_mangle]
fn try_match_with_arg(foo: i32) -> i32 {
    match foo {
        1 => {
            0
        }
        2 => {
            1
        }
        _ => {
            2
        }
    }
}

汇编

0000000000006660 <try_match_with_arg>:
    6660:        48 83 ec 0c                  sub    $0xc,%rsp
    6664:        89 3c 24                     mov    %edi,(%rsp)
    6667:        89 7c 24 08                  mov    %edi,0x8(%rsp)
    666b:        83 ef 01                     sub    $0x1,%edi
    666e:        74 16                        je     6686 <try_match_with_arg+0x26>
    6670:        eb 00                        jmp    6672 <try_match_with_arg+0x12>
    6672:        8b 04 24                     mov    (%rsp),%eax
    6675:        83 e8 02                     sub    $0x2,%eax
    6678:        74 16                        je     6690 <try_match_with_arg+0x30>
    667a:        eb 00                        jmp    667c <try_match_with_arg+0x1c>
    667c:        c7 44 24 04 02 00 00         movl   $0x2,0x4(%rsp)
    6683:        00 
    6684:        eb 12                        jmp    6698 <try_match_with_arg+0x38>
    6686:        c7 44 24 04 00 00 00         movl   $0x0,0x4(%rsp)
    668d:        00 
    668e:        eb 08                        jmp    6698 <try_match_with_arg+0x38>
    6690:        c7 44 24 04 01 00 00         movl   $0x1,0x4(%rsp)
    6697:        00 
    6698:        8b 44 24 04                  mov    0x4(%rsp),%eax
    669c:        48 83 c4 0c                  add    $0xc,%rsp
    66a0:        c3                           ret    
    66a1:        66 2e 0f 1f 84 00 00         cs nopw 0x0(%rax,%rax,1)
    66a8:        00 00 00 
    66ab:        0f 1f 44 00 00               nopl   0x0(%rax,%rax,1)

分析

和上面一个函数的区别是我们这里match的是传入的参数,得到的汇编变得稍微复杂了一些。首先我们的参数在了rsp和rsp+8的内存处保存了副本。

之后保存参数的rdi与1进行比较,这里的cmp与sub、je与jz指令效果相同,在进行比较的时候,计算机会两数相减,如果等于零说明相等(即ZF标志为1)。如果我们的foo为1,则跳转到+0x26处,将rsp+4内存置为0。

如果不为1,可以看到Rust的编译结果会jmp到下一条指令处,这里并未做优化做了一次无谓的跳转。和之前的类似,只不过这里我们从rsp处取得了保存的参数值,交由eax寄存器比较是否为2,如果等于2则将rsp+4内存置为1。

如果不等于1和2,将使用默认匹配_,直接将rsp+4内存置为2。最后rsp+4内存的值交由eax寄存器作为返回值,释放局部变量空间。

看到这里有个疑问:为什么会保存两次副本,在rsp+8内存的副本并没有被使用到?

try_so_many_arg函数

源码

fn try_so_many_arg(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32, i: i32, j: i32, k: i32, l: i32) {
    let a = a + 1;
    let b = b + 2;
}

汇编

00000000000066b0 <try_so_many_arg>:
    66b0:        48 83 ec 38                  sub    $0x38,%rsp
    66b4:        89 74 24 10                  mov    %esi,0x10(%rsp)
    66b8:        8b 44 24 68                  mov    0x68(%rsp),%eax
    66bc:        8b 44 24 60                  mov    0x60(%rsp),%eax
    66c0:        8b 44 24 58                  mov    0x58(%rsp),%eax
    66c4:        8b 44 24 50                  mov    0x50(%rsp),%eax
    66c8:        8b 44 24 48                  mov    0x48(%rsp),%eax
    66cc:        8b 44 24 40                  mov    0x40(%rsp),%eax
    66d0:        89 7c 24 18                  mov    %edi,0x18(%rsp)
    66d4:        89 74 24 1c                  mov    %esi,0x1c(%rsp)
    66d8:        89 54 24 20                  mov    %edx,0x20(%rsp)
    66dc:        89 4c 24 24                  mov    %ecx,0x24(%rsp)
    66e0:        44 89 44 24 28               mov    %r8d,0x28(%rsp)
    66e5:        44 89 4c 24 2c               mov    %r9d,0x2c(%rsp)
    66ea:        ff c7                        inc    %edi
    66ec:        89 7c 24 14                  mov    %edi,0x14(%rsp)
    66f0:        0f 90 c0                     seto   %al
    66f3:        a8 01                        test   $0x1,%al
    66f5:        75 1c                        jne    6713 <try_so_many_arg+0x63>
    66f7:        8b 44 24 10                  mov    0x10(%rsp),%eax
    66fb:        8b 4c 24 14                  mov    0x14(%rsp),%ecx
    66ff:        89 4c 24 30                  mov    %ecx,0x30(%rsp)
    6703:        83 c0 02                     add    $0x2,%eax
    6706:        89 44 24 0c                  mov    %eax,0xc(%rsp)
    670a:        0f 90 c0                     seto   %al
    670d:        a8 01                        test   $0x1,%al
    670f:        75 2d                        jne    673e <try_so_many_arg+0x8e>
    6711:        eb 1e                        jmp    6731 <try_so_many_arg+0x81>
    6713:        48 8d 3d f6 f8 02 00         lea    0x2f8f6(%rip),%rdi        # 36010 <str.0>
    671a:        48 8d 15 a7 c3 03 00         lea    0x3c3a7(%rip),%rdx        # 42ac8 <__do_global_dtors_aux_fini_array_entry+0x8>
    6721:        48 8d 05 78 f5 ff ff         lea    -0xa88(%rip),%rax        # 5ca0 <_ZN4core9panicking5panic17h50b51d19800453c0E>
    6728:        be 1c 00 00 00               mov    $0x1c,%esi
    672d:        ff d0                        call   *%rax
    672f:        0f 0b                        ud2    
    6731:        8b 44 24 0c                  mov    0xc(%rsp),%eax
    6735:        89 44 24 34                  mov    %eax,0x34(%rsp)
    6739:        48 83 c4 38                  add    $0x38,%rsp
    673d:        c3                           ret    
    673e:        48 8d 3d cb f8 02 00         lea    0x2f8cb(%rip),%rdi        # 36010 <str.0>
    6745:        48 8d 15 94 c3 03 00         lea    0x3c394(%rip),%rdx        # 42ae0 <__do_global_dtors_aux_fini_array_entry+0x20>
    674c:        48 8d 05 4d f5 ff ff         lea    -0xab3(%rip),%rax        # 5ca0 <_ZN4core9panicking5panic17h50b51d19800453c0E>
    6753:        be 1c 00 00 00               mov    $0x1c,%esi
    6758:        ff d0                        call   *%rax
    675a:        0f 0b                        ud2    
    675c:        0f 1f 40 00                  nopl   0x0(%rax)

分析

看上去简单的函数没想到我们得到了这么复杂的结果!

我们前6个参数被手动保存到了rsp+0x18到rsp+0x2c处,而后6个参数在最开始的rsp+0x40到rsp+0x68处:

这里我们并未使用到后面的参数,但编译结果仍然可以看到这些参数分别被赋给了eax一次。

对于局部变量a和b的加法分别对应66ea和6703处的inc和add指令,有趣的是,可以看到这里Rust会校验加法是否溢出。即seto指令将OF标志赋给al寄存器,如果不为零就会将报错的信息、文件名作为参数调用panic函数。这保证了我们不会因为传入的参数为INT_MAX导致溢出为负数。

try_str_arg函数

源码

fn try_str_arg(s: &str) {
    let _ = s.len();
}

汇编

0000000000006760 <try_str_arg>:
    6760:        48 83 ec 18                  sub    $0x18,%rsp
    6764:        48 89 7c 24 08               mov    %rdi,0x8(%rsp)
    6769:        48 89 74 24 10               mov    %rsi,0x10(%rsp)
    676e:        e8 2d 00 00 00               call   67a0 <_ZN4core3str21_$LT$impl$u20$str$GT$3len17h99821c36485a7d2eE>
    6773:        48 83 c4 18                  add    $0x18,%rsp
    6777:        c3                           ret    
    6778:        0f 1f 84 00 00 00 00         nopl   0x0(%rax,%rax,1)
    677f:        00 

分析

终于来到了我们最后一步,这里我们接受了上文提到的data_ptr(str地址)和长度参数。在本地保存副本后调用core库str的len方法。由于没有使用返回值,我们可以看到这里没有处理rax而是直接返回。

release编译

以上的分析都是在debug编译下面进行的,如果我们使用release编译呢?

稍微修改一下boost函数的代码:

fn boost(argc: isize, argv: *const *const u8) -> isize {
    let a = 1;
    mangled();
    try_variable();
    try_match();
    try_match_with_arg(1);
    try_so_many_arg(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
    try_str_arg("wow");
    1
}

然后让我们来看看release编译的main函数:

0000000000001120 <main>:
    1120:        b8 01 00 00 00               mov    $0x1,%eax
    1125:        c3                           ret    
    1126:        66 2e 0f 1f 84 00 00         cs nopw 0x0(%rax,%rax,1)
    112d:        00 00 00 

我们的函数调用都没了,也找不到try_variable等函数!这些没有必要的函数在release下面都会被删除掉,没有使用的局部变量也会直接删除。这保证了release编译下面的高效和二进制文件精简。

总结

通过这次分析,我们可以看到Rust中一些基础语句在编译后的结果。个人觉得比较有趣的是,因为传入的参数不可知,Rust会主动判断加法后是否会溢出,而不是产生溢出出现UB。而在release模式下,Rust能够发现我们写的无用的函数,直接删除。如果文章有什么错误或者您有什么建议,欢迎在评论区指出~

最后希望大家在2022年都能达成自己的目标,心想事成。:)

关于作者

北邮大二学生,开源爱好者。目前正在努力学习Linux内核、Rust中,对技术抱有热情。欢迎在飞书Rust 中文社群搜索李睿添加好友。

GitHub:https://github.com/KernelErr

个人博客:https://lirui.tech/

联系邮箱:me@lirui.tech

《Rust For Rustaceans》读书笔记之Rust生态系统

作者: 吴翱翔

cargo-deny

在字节的 monoio 库源码和众多开源库中都能看到该工具的配置文件 deny.toml

公司中有同事大力推荐引进项目,我的理解是集成了一堆工具例如 cargo-audit

其中 license 功能很有意思,分析项目所有依赖有没有开源许可冲突

打个比方 MIT2 和 MIT3 开源许可在法律上有冲突, 那么 cargo-deny 能帮你找出项目有没有依赖 A 是 MIT2 许可的同时依赖 B 是 MIT3 许可这样项目的开源许可会有法律风险

别小看项目开源许可的法律风险问题,例如 rust-lang/rust_codegen_gcc 库曾因为 libgccjit.so 的开源许可的法律问题让 Rust 基金会的律师进行处理,所以我估计 monoio 在开源前也是让字节的法务部帮忙检查下开源后有没有法律风险

pin_project

pin_project 的作用在酷熊(https://fasterthanli.me/)博客上面好几篇文章介绍过,

我的印象是 pin_project 的作者是 tokio 成员而且异步和过程宏造诣很深,

社区的油条哥的 async_graphql::Object 和 poem_openapi::OpenApi 这两个过程宏 应该是参考了 pin_project 宏的一种设计模式/编程范式,可以让属性宏也能像 derive 宏一样在 "语法树父子节点" 间传递宏的上下文和元信息

cargo tree --invert

例如通过 cargo-deny/cargo-audit 发现 chrono 有安全漏洞,想看看项目中哪些库引用了 chrono

如果用 cargo tree | grep chrono 去看依赖树很长,很难找

[w@ww polars]$ cargo tree | grep -B2 -A2 chrono
│   ├── anyhow v1.0.51
│   ├── arrow2 v0.8.1 (https://github.com/jorgecarleitao/arrow2?rev=89921d33bdb4becf8e9197fdc8392818862357cd#89921d33)
│   │   ├── chrono v0.4.19
│   │   │   ├── libc v0.2.112
│   │   │   ├── num-integer v0.1.44

如果加上 --invert 参数能将依赖树「颠倒过来」

[w@ww polars]$ cargo tree --invert --package chrono
chrono v0.4.19
├── arrow2 v0.8.1 (https://github.com/jorgecarleitao/arrow2?rev=89921d33bdb4becf8e9197fdc8392818862357cd#89921d33)
│   ├── polars-arrow v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-arrow)
│   │   ├── polars-core v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-core)
│   │   │   ├── polars v0.18.0 (/home/w/repos/clone_repos/polars/polars)
│   │   │   ├── polars-io v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-io)
│   │   │   │   ├── polars v0.18.0 (/home/w/repos/clone_repos/polars/polars)
│   │   │   │   └── polars-lazy v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-lazy)
│   │   │   │       └── polars v0.18.0 (/home/w/repos/clone_repos/polars/polars)
│   │   │   └── polars-lazy v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-lazy) (*)
│   │   ├── polars-io v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-io) (*)
│   │   ├── polars-lazy v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-lazy) (*)
│   │   └── polars-time v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-time)
│   │       └── polars-core v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-core) (*)
│   ├── polars-core v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-core) (*)
│   └── polars-io v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-io) (*)
└── polars-time v0.18.0 (/home/w/repos/clone_repos/polars/polars/polars-time) (*)

很容易发现项目中 polars-time 是直接依赖了 chrono

代码杂谈:无锁编程

作者: Ark

原文: 代码杂谈:无锁编程(一)

缘起

俗话说的好,作为一个在数据库团队做 Kubernetes 开发的同学,不能做到编写 B 树至少要做到心里有 B 数。因此最近闲暇时间一直在研究可持久化的 B 树怎么写,接着就了解到了 BwTree(微软 14 年提出的无锁 B 树),然后开始思考其中的 mapping table 怎么实现,然后开始看 FasterKV(微软 18 年提出的可持久化无锁 KV),然后就彻底跑偏了…

本文主要记录我拿 Rust 折腾几个无锁数据结构的经验和教训,这样以后不记得了还能翻一翻,并告诉自己:

没事别瞎折腾什么劳什子无锁编程!

数据结构

通常,无锁数据结构的实现都要求有一些特殊的硬件支持,例如支持原子 CASLL/SC 指令的处理器,或是事务型内存(transactional memory)。目前,主流的处理器都已经支持了 CAS 操作,因此大部分无锁数据结构是基于 CAS 操作设计的。通常来说,CAS 操作只能作用于 32/64 位整数 – 正好放下一个指针,因此各种无锁结构都围绕着指针的原子操作而设计。本文也将主要介绍一种无锁链表在 Rust 中的实现,链表算法主要基于 Zhang et al. 在 2013 年提出的一种无锁无序链表 [1]。

注:也存在支持 multi-word CAS 的 CPU。

无锁链表(Unordered Set)

首先介绍一下上述提到的无锁链表,它其实是一个结构为单向链表的无序集合,包含以下 3 种操作:

  • insert(k),插入一个元素 k 并返回是否成功;
  • remove(k),删除一个元素 k 并返回是否成功;
  • contains(k),判断一个元素 k 是否在集合(链表)中;

链表的节点和整体结构如下图所示:

1

Lock Free Linked List

其中每个节点除了包含本身的元素以外,还有两个原子变量:

  • state,代表了当前节点的状态,论文中状态的定义有 4 种:
    • DAT,表示可见的元素(节点)
    • INS,表示插入中的元素
    • REM,表示删除中的元素
    • INV,表示无效的元素(节点)
  • next,存储了下一个节点的指针

和通常的单向链表一样,链表有一个 head 指针指向链表头,然后通过节点上 next 中存储的指针串联起来。当需要插入一个新节点的时候,使用 CAS 原子操作将 head 修改为新节点的地址,如图所示。当然 CAS 操作是可能会失败的,此时我们只需要重新进行图中的步骤。Rust 代码大致如下:


#![allow(unused)]
fn main() {
fn enlist(&self, node_ptr: SharedNodePtr<E>, g: &Guard) {
    debug_assert!(!node_ptr.is_null());
    let node = unsafe { node_ptr.deref() };
    let mut head_ptr = self.head.load(Ordering::Acquire, g);
    loop {
        // Set node.next to untagged(head).
        node.next.store(head_ptr.with_tag(0), Ordering::Release);   
        match self.head.compare_exchange(
            // Keep head's tag.
            head_ptr, node_ptr.with_tag(head_ptr.tag()),                      
            Ordering::AcqRel, Ordering::Acquire, g
        ) {
            Ok(_) => return,
            Err(err) => head_ptr = err.current,
        }
    }
}
}

假如不考虑链表节点的删除,基于无锁链表的无序集合其实很容易实现 – 添加只需要在链表头插入对应元素的节点 (DAT),删除同样插入一个节点,只不过标记状态为删除 (REM)。集合元素的查找只要顺序遍历链表,直到找到第一个拥有相同元素的节点,那么节点的状态就是元素的存在状态。这种简单的方式显然有一个比较大的问题,那就是链表会越来越长,而集合操作的复杂度和链表长度是正相关的。因此 Zhang et al. 提出的算法引入了 INS 和 INV 两种状态,并包含了节点的删除操作。

在上述 3 种操作中,contains 是一个只读操作,因此实现它只需要按照链表无脑向下寻找就可,直到找到第一个可见的元素。在不考虑 GC 的情况下,只读操作永远都能正常运行。另外两个操作是比较类似的:它们都在链表头插入了一个包含了对应元素的节点,然后从该节点开始往后探查是否操作成功。算法流程大致为:

  • insert 插入状态为 INS 的节点,并向后找到第一个元素相同的节点

  • 节点状态为 INS 或者 DAT 则表明链表中已经存在该元素了,表明插入失败;

  • 节点状态为 REM 则表明链表中该元素已经被删除了,表明插入成功;

  • 节点状态为 INV 的忽略;

  • 未找到,则插入成功;

  • remove 插入状态为 REM 的节点,并向后找到第一个元素相同的节点

  • 节点状态为 REM 则表明链表中该元素已经被删除了,表明删除失败;

  • 节点状态为 INS 则表明有另一个线程正在插入该元素,尝试使用 CAS 标记为 REM

    • 成功,表明删除成功;
    • 失败,则重新读取节点状态重试;
  • 节点状态为 INV 的忽略;

  • 节点状态为 DAT 则尝试用 CAS 标记为 INV,CAS 的成功/失败代表删除的成功/失败;

在插入和删除的向后查找过程中,如果遇到 INV 的无效节点,则会尝试使用 CAS 删除该节点,从而达到缩短链表的目的。


#![allow(unused)]
fn main() {
CAS(prev.next, cur_ptr, cur.next)
}

在基于 CAS 的无锁链表算法中,通常会遇到两个问题:

  1. ABA 问题:即在一个线程 1 进行原子 CAS 操作前,它看到的是 A,此时另一个线程 2将它修改为 B,然后又修改回 A,然后线程 1 再进行 CAS 操作时将会成功完成操作并可能破坏数据结构;
    • 在本文描述的算法中不会出现 ABA 问题,因为其中的任意一个原子变量都不会出现值循环的修改(state 是有顺序的,next 一定是间隔向后修改,而链表中间不会有插入);
  2. 并发删除问题:当链表为 A -> B -> C -> D 时,线程 1 和线程 2 并发删除 B 和 C 可能会导致 C 的删除无效化/被取消:
    • 无效化:线程 1 删除 B,此时链表为 A -> C -> D,此时链表 2 看到的链表还是 B -> C -> D,它将 B.next 设置为 D 对原链表无影响;
    • 被取消:线程 1 读取 B.next 为 C,此时 线程 2 删除 C,链表为 A -> B -> D,然后线程 1 执行 CAS(A.next, B, C) 成功,链表又恢复到了 A -> C -> D;
    • 并发删除问题在该算法中是存在的,但是并不影响正确性,因为被删除的节点一定是 INV 状态的,而该状态的节点不会影响任何操作;

在 Java 等 GC 语言中,并发删除问题并不是一个大问题。而在无 GC 的语言中,并发删除会让节点 GC 变得更难,这在后续的 GC 小节中会进行分析并提出解决方案。

上述算法三个操作的时间复杂度都是 O(n) 的,因此在集合大的时候性能不是很好。同样,指针本身会带来巨量的 cpu cache miss,因此无锁算法通常单线程性能比普通的非并发算法要差的多。

无锁哈希表

在哈希表中通常使用链表来解决哈希冲突的问题,那当我们拥有一个无锁链表,自然而然的可以构建一个无锁的哈希表,它包含以下几个操作:

  • put(k, v),将 k 映射到 v
  • remove(k),删除 k 上的映射
  • get(k),获取 k 上映射的值

显然,我们只需要和普通的哈希表一样准备一组 buckets,每一个 bucket 指向一个无锁链表就可以轻松地构建出一个无锁哈希表。这样的无锁建立在无法增加或是减少 buckets 的基础上。在 SIGMOD 2018 上微软发表了关于 FasterKV 的论文[3]中提到,通过结合 epoch 框架提出了一种无锁扩缩容的方式,按照我的理解仍然是一个变相的自旋锁,这里不做详细介绍。

为了进行实验,我也用 Rust 实现了 FasterKV 的内存版本(不带扩缩容的能力),在实现小节中会呈现实验结果。

垃圾回收(GC)

现代的高级编程语言通常都会提供内存 GC 的能力,例如 Java、Go、Python 以及一众基于 JVM 的语言等等。只有一些偏向底层的“倒霉”语言才没有 GC,Rust 是属于新晋的这一类编程语言之一。在 Rust 中,编译器会自动将内存释放的相关代码编织到程序中,这种方式大约是借鉴的 C++ 的 RAII。但是在无锁数据结构中,并发访问会带来一个额外的问题:什么时候对象以及它的内存才是可以安全回收的?

考虑这样一个场景:线程 1 删除了链表中的一个节点,但线程 2 此时还在访问这个节点,这在无锁并发场景下是成立的。此时,这个节点还不能被安全回收,但我们都知道它最终将不可能被访问到。因此无锁情况下的垃圾回收方法主要需要解决的问题是:如何找到一个时刻,此时被删除的对象已经不可能在访问到了。著名的 GC 方法有 QSBR(Quiescent-state-based Reclaimation)、EBR(Epoch-based Reclaimation)和 HPBR(Hazard-pointer-based Reclaimation)等,本文主要介绍和使用其中的 EBR 这一种方法。

Epoch-based Reclaimation

EBR 的主要思路是将操作分代(epoch),在 epoch 不断推进的过程中保证在老 epoch 中删除的对象在新的 epoch 中不可能再被访问到,从而找到一个安全的时刻去回收对象。EBR 的工作方式如下图所示:

2

通常的 EBR 实现方式中:

  • 全局有一个唯一的 epoch,它会不断地增长,并且在增长时加 memory fence 保证之前的所有修改可见;
  • 每个线程在操作时进入当前 epoch,在结束时退出当前 epoch,实现上来说就是每个线程维护一个本地的 epoch,在每次操作时进行同步;
    • 为了效率考虑,真正实现时可以在数次操作后再同步;
  • 在线程从无锁数据结构中删除一个对象时,将这个对象加到 retire 列表中,并标记回收 epoch 为当前的全局 epoch,同时尝试抬高全局 epoch;
    • 同样效率起见,实现时可以在数次操作后再抬高全局 epoch;

显然,在 epoch 抬高之后,之前的删除操作都应该对所有线程可见了,那么只要所有的线程都退出了删除发生的 epoch,就一定能安全地回收那些被删除的对象。因此,安全 epoch(在小于等于这个 epoch 的删除的对象可以安全回收)的是所有线程的本地 epoch 中最小的那个减 1。这种策略下全局 epoch 可能会很快地增长上去,因此还有另外一种方式:在抬高全局 epoch 时,检查是否所有线程的本地 epoch 都已经等于全局 epoch 了,如果是才进行抬高。因为在这种情况下全局 epoch 和本地 epoch 最多差不超过 1,因此安全 epoch 可以简单的用全局 epoch 减 2 来计算。在这种策略下也可以将 epoch 简化成只有 3 代,可以安全回收的那一代是 (全局 epoch + 1) % 3。

注:图中虽然也保证了代差不超过 1,但是使用的是本地 epoch 作为 retire 的代,因此安全 epoch 应该是全局 epoch - 3。

EBR 通过分代的方式有效地找出了可安全回收的 grace period,但是也存在一些问题:

  • 如果有一些操作比较慢,可能会导致安全 epoch / 全局 epoch(三代)无法推进,从而导致内存始终无法释放;
  • 其中进行的删除操作一定要完全发生,不能被取消或是失效使得在后续的操作中还能够访问到;

上文描述的无锁链表在配合 EBR 时就存在第二个问题,因为并发删除会导致节点删除的取消或者失效。这里感谢 @zzy590 指出这个问题,在和他讨论后得出了一个解决办法 – 做指针标记,两阶段删除节点。方法大概原理为:

  • 删除前在对应节点的 next 指针上做标记:CAS(node.next, unmark(next), mark(next))
  • 禁止删除标记过的节点的下一个节点,也就是禁止了相邻的并发删除;
    • 删除操作的 CAS 改为 CAS(prev.next, unmark(cur), unmark(next))
  • 所有线程遇到标记过指针的节点都可以尝试进行删除操作;
  • 删除操作中遇到禁止删除的情况则跳过节点;

在更改过的算法中,相邻并发删除的一种情况如下图所示:

3

除了进行对象回收之外,EBR 还可以成为防止其他并发访问问题的框架,例如 FasterKV 中就使用它来推进 Hybrid Log 中 fuzzy region,具有比较强的通用性。

实现

最后,我将文中所描述到的无锁链表、哈希表使用 Rust 进行了实现。EBR 框架使用了 crossbeam-epoch 这个 crate 提供,然后参考内部的结构实现了 FasterKV 中提到的前 16 位作为标记、后 48 位作为地址的原子指针。

代码开放在 github 上:https://github.com/ruaaadb/lock-free 。感兴趣的同学可以下下来玩一下。

注:编译要求本地有 tcmalloc 链接库,tcmalloc 可以适应极高的本地内存分配速率。

我的 Macbook Pro / M1 Pro 上简单做了一下链表的实验,性能如下所示:

ThreadsR=2K, S=1K, L=0%R=2K, S=1K, L=34%R=2K, S=1K, L=80%R=2K, S=1K, L=100%
1637 ops/ms649 ops/ms660 ops/ms671 ops/ms
21060 ops/ms1094 ops/ms1196 ops/ms1312 ops/ms
41754 ops/ms1914 ops/ms2148 ops/ms2571 ops/ms
82907 ops/ms3321 ops/ms4042 ops/ms4941 ops/ms

注:R 是 key range,S 是链表期望长度,L 是查询比例,测试中写是一半删除一半插入。

可以看到性能并不是特别好,但得益于芯片的进步,即使做了两阶段删除还是能吊打论文的数据。

参考文献

[1] Zhang, Kunlong, et al. “Practical non-blocking unordered lists.” International Symposium on Distributed Computing. Springer, Berlin, Heidelberg, 2013.

[2] Harris, Timothy L. “A pragmatic implementation of non-blocking linked-lists.” International Symposium on Distributed Computing. Springer, Berlin, Heidelberg, 2001.

[3] Chandramouli, Badrish, et al. “Faster: A concurrent key-value store with in-place updates.” Proceedings of the 2018 International Conference on Management of Data. 2018.

[4] https://github.com/crossbeam-rs/crossbeam

[5] https://en.wikipedia.org/wiki/ABA_problem

[6] https://www.youtube.com/watch?v=trsmyznC2I8&ab_channel=ACMSIGPLAN

Rust Pin 进阶

原博客地址:https://folyd.com/blog/rust-pin-advanced/

去年我写了一篇关于 Pin 的文章,算是由浅入深介绍了 Pin 到底是怎么一回事,为什么需要 Pin。但只掌握那部分知识依然不够,所以这篇文章希望能系统性的梳理跟 Pin 有关的知识点,所以标题我命名为《Rust Pin 进阶》。

Pin API 剖析

要想深入理解 Pin,熟悉 Pin 所有的方法是必不可少的。除去 nightly 的 API 后,Pin 总共有 13 个方法:


#![allow(unused)]
fn main() {
// Pin<P> where P: Deref
impl Pin<P> where P: Deref {
  unsafe fn new_unchecked(pointer: P) -> Pin<P>
  
  fn as_ref(&self) -> Pin<&P::Target>
  
  unsafe fn into_inner_unchecked(pin: Pin<P>) -> P
}

impl<P: Deref<Target: Unpin>> Pin<P> {
  fn new(pointer: P) -> Pin<P>
  
  fn into_inner(pin: Pin<P>) -> P
}

impl<'a, T: ?Sized> Pin<&'a T> {
  unsafe fn map_unchecked<U, F>(self, func: F) -> Pin<&'a U>
    where
        U: ?Sized,
        F: FnOnce(&T) -> &U
  
  fn get_ref(self) -> &'a T
}


// Pin<P> where P: DerefMut
impl<P: DerefMut> Pin<P> {
  fn as_mut(&mut self) -> Pin<&mut P::Target>
  
  fn set(&mut self, value: P::Target) where P::Target: Sized
}

impl<'a, T: ?Sized> Pin<&'a mut T> {
  fn into_ref(self) -> Pin<&'a T>
  
  fn get_mut(self) -> &'a mut T where T: Unpin
  
  unsafe fn get_unchecked_mut(self) -> &'a mut T
  
  unsafe fn map_unchecked_mut<U, F>(self, func: F) -> Pin<&'a mut U>
    where
        U: ?Sized,
        F: FnOnce(&mut T) -> &mut U
}
}

这些方法可以分为两个大类:

  • Pin<P> where P: Deref
  • Pin<P> where P: DerefMut

之前的文章有说过,Pin 一般以 Pin<P<T>> 的形式来表示(P 是 Pointer 的缩写,T 是 Type 的缩写) ,所以 Pin 包裹的内容只能是智能指针(实现了 Deref trait 的类型都可以称为智能指针),对其他普通类型是没有意义的。因为 &T&mut T分别实现了 DerefDerefMut,所以 Pin<&'a T>Pin<&'a mut T> 分别算这两大类下的特化实现。

初看起来这 13 个方法有点杂乱,但其实他们的设计非常讲究,甚至可以说存在对称性。按功能来划分的话,这些方法可以分为 5 大类,各个类别按可变性或是符合 T: Unpin 限定来细分为 2~3 种。其中可变的版本都以 mut 结尾,因为不符合T: Unpin 限定的 unsafe 版本都包含 unchecked

功能方法备注
构造 Pinnew() / new_unchecked()按是否满足 T: Unpin 限定来区分 safe 和 unsafe 两个版本。
转换 Pin 类型as_ref() / as_mut()&/&mut Pin<P<T>> 转换成 Pin<&/&mut T>
获取 Pin<P<T>> 里面 T 的借用get_ref() / get_mut() / get_unchecked_mut()消耗所有权,拿到里面的 T 的借用。按可变性分为两个版本。因为 &mut T 是"万恶之源",所以 get_mut 又按是否满足 T: Unpin 限定来区分 safe 和 unsafe 两个版本。
消耗 Pin 所有权,拿到里面的指针 Pinto_inner() / into_inner_unchecked()按是否满足 T: Unpin 限定来区分 safe 和 unsafe 两个版本。另外,为了避免和 P 自己的 into 类方法冲突,这几个 API 都设计成静态方法,必须通过 Pin::into_inner() 这种形式调用,不能用 pin.into_inner()
Pin projectionmap_unchecked() / map_unchecked_mut()通常用来做 Pin projection。

只剩下两个方法没有归类到上面的表格中,它们也比较简单,分别是:

  • Pin::set() - 设置 Pin<P<T>> 中新的 T 的值。
  • Pin<&mut Self>::into_ref() - 将 Pin<&mut T> 转换成 Pin<&T>

值得注意的是其实 new()new_unchecked()get_mut()get_unchecked_mut()into_inner()into_inner_unchecked() 的实现是完全一样的,唯一的区别是 safe 版有 Unpin 限定。


#![allow(unused)]
fn main() {
pub const fn new(pointer: P) -> Pin<P> 
  where P: Deref,
	     <P as Deref>::Target: Unpin {
    unsafe { Pin::new_unchecked(pointer) }
}

pub const unsafe fn new_unchecked(pointer: P) -> Pin<P> {
		Pin { pointer }
}

pub const fn get_mut(self) -> &'a mut T where T: Unpin {
		self.pointer
}

pub const unsafe fn get_unchecked_mut(self) -> &'a mut T {
		self.pointer
}

pub const fn into_inner(pin: Pin<P>) -> P
	where P: Deref,
		    <P as Deref>::Target: Unpin {
    pin.pointer
}

pub const unsafe fn into_inner_unchecked(pin: Pin<P>) -> P {
		pin.pointer
}
}

为什么相同的代码要区分 safe 和 unsafe 两个版本呢?要解答这个问题,还是要回到 Pin 的本质。**Pin 本质上解决的问题是在 safe Rust 下保证 Pin<P<T> 中的 T 的内存地址不会被改变(也就是不被 move),除非 T 满足 T: Unpin。**保证 T 的内存地址不会被改变的本质是避免暴露T&mut T("万恶之源")。暴露 T 的话,随随便便就能 move 掉它;暴露 &mut T 的话,开发者可以调用 std::mem::swap()std::mem::replace() 这类方法来 move 掉 T。还有一条,Rust 里面 safe 和 unsafe 的边界一定要区分非常明确,不能有任何含糊。所以只要你不满足 T: Unpin ,然后需要构造 Pin<P<T>>、暴露 T 或者 &mut T 的方法都应该是 unsafe 的。

满足 T: Unpin不满足 T: Unpin
构造 Pinsafeunsafe
暴露 Tsafeunsafe
暴露 &Tsafesafe
暴露 &mut Tsafeunsafe

比如,into_inner_unchecked() 虽然返回的是 P,但是它是间接暴露了 T&mut T。因为你可以通过*P&mut *P 轻而易举的拿到 T&mut T。而你构造 Pin<P<T>> 的时候相当于是承若要遵守 Pin 的契约 的,但这步操作明显就违约了。

为什么 Pin::get_ref() 是 safe 的呢?因为它返回的是 &T,你没有办法 move 掉它:std::mem::swap() 类方法只支持 &mut T,解引用 &T 的话编译器会报错阻止你。(再一次感谢 rustc)另外需要强调的是内部可变性的类型。比如 RefCell<T>Pin<&mut RefCell<T>>.into_ref().get_ref() 返回的是 &RefCell<T>,而 RefCell<T>::into_inner() 之类的方法可以拿到 T 然后 move 掉它。但是这个没关系,因为 Pin<P<T>> 的契约是保证 P 里面的 T 不被 move,而这里的 P&, TRefCell,并不是 RefCell<T> 里面的 T。只要没有额外 Pin<&T> 指向 RefCell<T> 里面的 T 就行,但是你构造 RefCell<T> 的时候其实已经自动杜绝这种可能了。因为 RefCell::new() 的参数是 value: T,早就把 T move 进来了。

类似的,Pin<&mut Box<T>> 保证的是 Box<T> 本身不被 move,而不是 Box 里面的 T。如果要保证 Box<T> 里面的 T 不被 move,直接使用 Pin<Box<T>> 即可。

Pin 额外的属性

#[fundamental]

使用#[fundamental]属性标记的 trait 是不受孤儿规则约束。所以你可以给 Pin<P<T>> impl 你本地的 trait。


#![allow(unused)]
fn main() {
use std::pin::Pin;

trait LocalTrait {}

impl<P> LocalTrait for Pin<P> {
}
}

#[repr(transparent)]

#[repr(transparent)] 这个属性可以让 Pin 拥有和里面 pointer 字段同样的 ABI 布局,在 FFI 的场景下会很有用。

#[repr(transparent)] 是 1.28 稳定的,详细可以看 release note

The #[repr(transparent)] attribute is now stable. This attribute allows a Rust newtype wrapper (struct NewType<T>(T);) to be represented as the inner type across Foreign Function Interface (FFI) boundaries.

Pin 实现的 trait

再来看看 Pin 实现了哪些值得关注的 trait。

Unpin


#![allow(unused)]
fn main() {
impl<P> Unpin for Pin<P> where P: Unpin {}
}

因为 Unpin 是 auto trait,所以 Pin<P<T> 如果满足 P: Unpin ,则 Pin<P<T>> 也会实现 Unpin。而几乎所有 P 都会是 Unpin ,所以 Pin<P<T>> 几乎总会是 Unpin 的。这个实现很重要,特别是如果这里的 TFuture 的时候。不管你的 Future 是不是满足 Unpin,把你用 Pin<&mut ...> 包裹之后,它就是一个满足 UnpinFuture 了(因为 Pin<P> 实现了 Future,后面会讲到)。很多异步的方法可能需要你的 Future 满足 Unpin 才能调用,而 async fn 方法返回的 Future 显然不满足 Unpin,这个时候往往需要你把这个 Future pin 住才行。比如使用 tokio::pin!() 这个宏。

use tokio::pin;

async fn my_async_fn() {
    // async logic here
}

#[tokio::main]
async fn main() {
    let future = my_async_fn();
    pin!(future);

    (&mut future).await;
}

另外,需要再次强调的是:

  • Pin 本身是不是 UnpinT 是不是 Unpin 没有任何关系,只跟 P 有关系。
  • Pin 能不能把 T pin 住跟 P 是不是 Unpin 没有任何关系,只跟 T 有关系。

上面两句话虽然有点绕,但是搞清楚了后,很多 Pin 的场景就不会懵逼了。

Deref 和 DerefMut


#![allow(unused)]
fn main() {
impl<P: Deref> Deref for Pin<P> {
    type Target = P::Target;
    fn deref(&self) -> &P::Target {
        Pin::get_ref(Pin::as_ref(self))
    }
}

impl<P: DerefMut<Target: Unpin>> DerefMut for Pin<P> {
    fn deref_mut(&mut self) -> &mut P::Target {
        Pin::get_mut(Pin::as_mut(self))
    }
}
}

这两个 trait 对 Pin 非常关键。只有实现了 DerefPin<P> 才是智能指针,开发者才可以无缝的调用 P 的方法。值得注意的是,只有满足 T: Unpin ,才会给 Pin<P<T>> 实现 DerefMut。因为 Safe Rust 下,Pin<P<T>> 的职责之一就是不能在不满足 T: Unpin 的前提条件下暴露 &mut T

另外,实现了这两个 trait 之后,分别可以解引用拿到 &T&mut T,但这种解引用的方式和 get_ref()get_mut() 是有区别的。以 &T 为例,假设有 let p = Pin::new(&T);, 解引用 p 拿到 &Tlet t = &*p;,这里拿到的 &T 的生命周期其实等于 &Pin::new(&T) 的生命周期。而 Pin::new(&T).get_ref() 拿到的 &T 的生命周期和 Pin 自身的生命周期是相等的。

为什么会这样呢?我们把解引用智能指针的语法糖展开之后看看。


#![allow(unused)]
fn main() {
let p = Pin::new(&T);
// let t = &*p; 展开语法糖之后如下:
let t = &*Deref::deref(&p);
}

PinDeref 实现代码是:Pin::get_ref(Pin::as_ref(self)),而 Pin::as_ref() 的代码如下。通过对比,就能发现解引用拿到的 &T 的生命周期确实和 get_ref() 拿到的是不一样的。


#![allow(unused)]
fn main() {
impl Pin<P> where P: Deref {
    pub fn as_ref(&self) -> Pin<&P::Target> {
        unsafe { Pin::new_unchecked(&*self.pointer) }
    }
}

// 对比上面 Deref 的实现
impl<'a, T: ?Sized> Pin<&'a T> {
    pub const fn get_ref(self) -> &'a T {
        self.pointer
    }
}
}

另外一个值得注意的地方是 Pin::as_ref()Pin::as_mut() 里面会解引用 self.pointer,其实就是会调用它的 deref()deref_mut() 方法。这两个方法是由 P 自己实现的,所以这里会存在“恶意实现”把 T move 掉的可能性。但是这种“恶意实现”会被 Pin 的契约 排除掉:这是你自己“恶意实现”导致的,并不是由于使用 Pin 导致。

Pin::new_unchecked() 的文档特意强调到了这一点:

By using this method, you are making a promise about the P::Deref and P::DerefMut implementations, if they exist. Most importantly, they must not move out of their self arguments: Pin::as_mut and Pin::as_ref will call DerefMut::deref_mut and Deref::deref on the pinned pointer and expect these methods to uphold the pinning invariants.

use std::pin::Pin;
use std::marker::PhantomPinned;
use std::ptr::NonNull;
use std::ops::{Deref, DerefMut};

struct Unmovable {
    data: String,
    slice: NonNull<String>,
    _pin: PhantomPinned,
}

impl Unmovable {
    fn new(data: String) -> Pin<Boz<Self>> {
        let res = Unmovable {
            data,
            slice: NonNull::dangling(),
            _pin: PhantomPinned,
        };
        let mut bozed = unsafe { Pin::new_unchecked(Boz(res)) };

        let slice = NonNull::from(&bozed.data);
        // we know this is safe because modifying a field doesn't move the whole struct
        unsafe {
            let mut_ref: Pin<&mut Self> = Pin::as_mut(&mut bozed);
            Pin::get_unchecked_mut(mut_ref).slice = slice;
        }
        bozed
    }
}

impl Default for Unmovable {
    fn default() -> Self {
        Unmovable {
            data: String::new(),
            slice: NonNull::dangling(),
            _pin: PhantomPinned,
        }
    }
}

struct Boz<T>(T);

impl<T> Deref for Boz<T>  {
    type Target = T;
    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

// “Malicious” implementations of DerefMut
impl<T: Default> DerefMut for Boz<T>  {
    fn deref_mut(&mut self) -> &mut Self::Target {
        let _s = std::mem::take(&mut self.0);
        &mut self.0
    }
}

fn main() {
   let mut unmovable = Unmovable::new(String::from("Malicious!!!"));
   unmovable.as_mut();
}

上面的例子,我们构造了一个 Pin<Boz<Unmovable>>,然后调用 as_mut() 方法的时候会解引用这个 BozBoz 有一个“恶意”的 DerefMut 实现,里面会 move 掉这个 Unmovable。但明明我是是把它 Pin 住了的。

Future

Pin 还实现了 Future, 这个和 Unpin 关系密切,我们在接下来的部分统一讲解。

Unpin 和 Future

Rust 的 pinning API 让初学者困惑的一大难点就是 Unpin 的引入,往往很容易把人绕晕,所以我们必须要彻底搞清楚 Unpin,特别是它和 Future 的关系。

前面说过,Unpin 是一个 auto trait,几乎所有类型都实现了 Unpin,包括你没意识到的一些类型。比如:

  • &T: impl<'a, T: ?Sized + 'a> Unpin for &'a T {}
  • &mut T: impl<'a, T: ?Sized + 'a> Unpin for &'a mut T {}
  • *const T: impl<T: ?Sized> Unpin for *const T {}
  • *mut T: impl<T: ?Sized> Unpin for *mut T {}
  • 其他,包括 Box, Arc, Rc 等等

注意这里是不管 T 满不满足 T: Unpin ,它们都是 Unpin。原因我们前面已经说了:Pin 能不能把 T pin 住跟 P 是不是 Unpin 没有任何关系,只跟 T 有关系。

第一篇文章有讲过,只有 std::marker::PhatomPinned ,包含 PhatomPinned 的类型,还有 .await 解语法糖之后的那个结构体是 !Unpin,这里不再赘述。

Unpin 是 safe trait

另外一个很重要的特点:**Unpin 是一个 safe trait。**也就是说你可以在 safe Rust 下给任意类型实现 Unpin,包括你的 Future 类型。

我们提前准备好两个 assert 函数,后面会用到:


#![allow(unused)]
fn main() {
fn assert_future<F: Future>(_f: F) {}
fn assert_unpin<T: Unpin>(_t: T) {}
}
use std::{
    future::Future,
    pin::Pin,
    task::{Context, Poll},
};

#[derive(Clone)]
struct Dummy(String);

impl Future for Dummy {
    type Output = ();

    fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
        println!("{}", self.0);
        Poll::Ready(())
    }
}

// 不加这一行,编译器也会帮你自动实现 Unpin
impl Unpin for Dummy {}

fn main() {
    let dummy = Dummy(String::from("hello world!"));
    assert_future(dummy.clone());
    assert_unpin(dummy);
}

如果你要在另外一个 Future 中去 poll 这个 Dummy future 也是完全没有问题的。futures crate 甚至还提供了一系列 unpin 版的方法来帮助你做这件事,比如 FutureExt::poll_unpin()


#![allow(unused)]
fn main() {
pub trait FutureExt: Future {
    /// A convenience for calling `Future::poll` on `Unpin` future types.
    fn poll_unpin(&mut self, cx: &mut Context<'_>) -> Poll<Self::Output>
    where
        Self: Unpin,
    {
        Pin::new(self).poll(cx)
    }
}
}

可以看到这里是 &mut self,而不是 self: Pin<&mut Self>

但是,pin projection 场景下需要特别注意,如果你的类型中有字段是 !Unpin 的,你就不能给这个类型实现 Unpin。具体可以看官网 Pinning is structural for field

为什么 Future 可以是 Unpin 的

可能有人就要问了,Pin 设计之初不是为了解决实现 Future 的那个自引用结构体不被 move 的问题吗?为什么还可以给 Future 类型实现 Unpin?原因是这样的:如果你实现 Future 的类型是自引用结构体,那当然不能是 Unpin 的,除此之外实现 Unpin 完全没问题。上面那个例子,也包括很多第三方库的 Future 类型,都不会存在自引用结构体,可以放心大胆 move,所以完全可以是 Unpin 的。另外一个好处是,完全可以用 safe 版的 Pin::new() 方法构造 Pin 去poll future,不需要跟 unsafe 打交道。

Pin 的 Future 实现

之所以移到这里来讲 PinFuture 实现,是因为 1.56 有一个 PR #81363P: Unpin 的限定去掉了。我们先来看一下为什么要给 Pin 实现 Future,然后再来分析为什么这里的 Unpin 限定可以放开。

impl<P> Future for Pin<P>
where
-   P: Unpin + ops::DerefMut<Target: Future>,
+   P: ops::DerefMut<Target: Future>,
{
    type Output = <<P as ops::Deref>::Target as Future>::Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
-       Pin::get_mut(self).as_mut().poll(cx)
+       <P::Target as Future>::poll(self.as_deref_mut(), cx)
        // self.as_deref_mut() 其实就是 unsafe { self.get_unchecked_mut() }.as_mut()
    }
}

Pin 实现 Future 的理由很简单,就是为了方便调用 poll(),特别是在 pin projection 的场景。因为 poll()selfPin<&mut Self> 类型,你没办法直接用 future 来调用 poll()

error[E0599]: no method named `poll` found for struct `Dummy` in the current scope
  --> src/main.rs:35:20
   |
35 |         Dummy(String::from("hello world!")).poll(cx)
   |                    						 ^^^^ method not found in `Dummy`

你必须要先构造一个 Pin<&mut Dummy> 才能调用 poll()。给 Pin 实现 Future 之后,你可以直接这样写:Pin::new(&mut dummy).poll(ctx),要不然你还需要写 Future::poll(Pin::new(&mut dummy), ctx)

再来看看为什么这里不需要 P: Unpin。首先,这个方法的目的是要去 poll P::Target 这个 Future,而 poll() 方法的 SelfPin<P<T>>selfPin<&mut Pin<P<T>>>(注意这里有两层 Pin)。我们要把 Pin<&mut Pin<P<T>>> 安全地转换成 Pin<&mut T> 才能调用 P::Targetpoll()。那查一下 Pin 的 API 来倒推一下,拿到 Pin<&mut T> 很简单,有 Pin::as_mut(),前后两个版本的最后都是调用 as_mut() 的,这里没问题。但 as_mut() 的签名是 &mut self,也就是我们要先拿到 &mut Pin<P<T>>。如果把 Pin<&mut Pin<P<T>>>还原成基本形式 Pin<P<T>> 的话,那 &mut 是那个 PPin<P<T>> 是那个 T。从 Pin<&mut Pin<P<T>>> 拿到 &mut Pin<P<T>> 其实就是需要从 Pin<P<T>> 拿到 &mut Tget_mut()get_unchecked_mut() 两个方法都能满足,唯一的区别是 Unpin 限定,这也是那个 PR 改动的地方。没有 Unpin 限定的话,我们只能使用 unsafe 版的 get_unchecked_mut() 。但是这里是完全安全的,因为我们拿到 &mut Pin<P<T>> 后马上就调用 as_mut() 了,并不会 move 它。所以之前的 P: Unpin 是多余的。更多细节可以查看 Pin::as_deref_mut() 的文档和源码注释。

为什么需要 Unpin 限定

上面也提到过,有些异步相关的 API 需要你的类型满足 Unpin 才能调用。目前我所了解到的,这些 API 大致可以分为三类:

  1. **需要 &mut future 的场景。**比如 tokio::select!(), 这个宏需要你的 Future 满足 Unpin
  2. **AsyncRead / AsyncWrite 的场景。**比如 tokio::io::AsyncWriteExt 的方法需要你的 Self 满足 Unpin
  3. **Future 本身就是符合 Unpin,并且不想直接和 Pin 打交道。**上面提到的 FutureExt::poll_unpin()方法就属于这一类。

第 (2) 类主要跟 AsyncRead / AsyncWriteself 需要 Pin<&mut Self> 有关,社区也有不少这方面的讨论,不是这篇文章的重点,感兴趣可以查看下面的资料。

其次,tower 也在考虑要不要加 Pin<&mut Self>Pinning and Service.

关于第 (1) 类,主要原因是给 &mut Future 实现 Future 的时候指定需要了 F: Unpin


#![allow(unused)]
fn main() {
impl<F: ?Sized + Future + Unpin> Future for &mut F {
    type Output = F::Output;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        F::poll(Pin::new(&mut **self), cx)
    }
}
}

所以归根结底需要搞清楚为什么这里需要 Unpin。先从场景谈起,假如我们有一个 future 需要在 loop 中不断地 poll 它,但是 Future::poll() 每调用一次就会消耗掉 self 的所有权。因此我们需要可变借用这个 future,避免消耗掉 future 的所有权。但是 &mut future 之后会存在 move 掉这个 future 的风险(“万恶之源”),所以要么你这个 futureUnpin 的,要么你就要把它 pin 住再可变借用它(也就是 &mut Pin<&mut future>)。而刚好 Pin<P> where P: DerefMut 实现了 Future!(前面部分刚讲过的)而且 Pin<P> 也是满足 Unpin 的!真的太完美了,我们干脆给 &mut F 实现 Future 吧,只要 F 满足 Future + Unpin 就行。这样的好处是如果你的 future 满足 Unpin,那你直接 loop 中多次 poll 就行,不用担心 move 的问题;如果你的 future 不满足 Unpin,那也没关系,把它 pin 住就好了。比如下面的例子,因为 tokio::time::Sleep 不满足 Unpin,你需要先使用 tokio::pin!() 把它 pin 住才可以编译通过。

use tokio::time::{self, Duration, Instant};

#[tokio::main]
async fn main() {
    let sleep = time::sleep(Duration::from_millis(10));
    tokio::pin!(sleep);

    loop {
        tokio::select! {
            () = &mut sleep => {
                println!("timer elapsed");
                sleep.as_mut().reset(Instant::now() + Duration::from_millis(50));
            },
        }
    }
}

另外,同样的道理,给 Box<F> 实现 Future 的时候也需要 Unpin


#![allow(unused)]
fn main() {
impl<F: ?Sized + Future + Unpin, A: Allocator> Future for Box<F, A>
where
    A: 'static,
{
    type Output = F::Output;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        F::poll(Pin::new(&mut *self), cx)
    }
}
}

其他需要 Pin 的场景

经常碰到有人问类似"我这个场景是不是需要用 Pin 来解决?"的问题,我看了一下,根本跟 Pin 没有任何关系,于是会用这句经典名言回复他:

Rust 社区经典名言:Whenever you wonder if Pin could be the solution, it isn't.

Pinning API 是朝着通用性设计的,并不是只为了解决异步里面自引用结构体 move 的一个问题,其他一些场景也需要用到 Pin

Intrusive collections

Intrusive collections(侵入式集合) 是 Pin 的另外一个应用场景。 Pin 的文档上有提到 intrusive doubly-linked list (侵入式双链表)这个例子,但是对于其他侵入式数据结构也类似(比如侵入式单链表)。不过文档只有言简意赅的几句话,并不是很好理解,我在这里简单的总结一下。

首先需要了解 instrusive collections 是什么。我们平时用到的集合数据结构几乎都是 non-intrusive 的,比如标准库的 VecLinkedList 等。non-intrusive 类型集合的特点是集合中的元素和集合自身是完全解耦的,集合不需要关心每一个元素的类型是什么,集合也可以用来存放任意类型的元素。但 intrusive 类型的集合是一种完全侵入式的集合,它的 prevnext 指针是定义在元素上面的。

以 C++ 为例子 ,non-intrusive 的双链表可以这样定义:

struct Point {
    float x, y;
};

struct ListNode {
    Point val;
    ListNode *next, *prev;
};

intrusive 的版本需要这样写:

struct Point {
    float x, y;
    Point *next, *prev;
};

Rust 版 intrusive 的伪代码大概也是这样:


#![allow(unused)]
fn main() {
struct Point {
    x: f64,
    y: f64,
    prev: Option<Rc<Point>>,
    next: Option<Rc<Point>>,
}
}

可以看到两者最大的区别在于指针是放在集合上面还是放在元素上面。两种类型的集合各自有优缺点,而 intrusive 类型最大的优点在于性能更好,缺点也很明显,不通用,不同的元素需要重复定义集合。相关的知识不是本文的重点,更多详细信息可以看看下面的资料:

那为什么侵入式集合需要用到 Pin 呢?其原因在于元素之间互相有 prevnext 指针指向自己,如果中间某个元素发生 move 了,那其他元素指向它的指针地址就失效了,导致不安全行为。所以必须要用 Pin 来把元素给 pin 住!Rust 有一个叫 intrusive-collections 的库提供了众多侵入式的集合类型,另外 Tokio 里面也定义了侵入式集合,毫无疑问他们都用到了 Pin

其他

其实,只要是需要处理防止被 move 的场景,理论上都需要用到 Pin 来解决。其他的 case 我暂时想不出来了,以后有发现新的场景再补充,或者如果大家知道其他场景欢迎告诉我。

总结

这篇文章稍微有点长,我们来总结一下:

  • Pin 的 API 设计很讲究,甚至充满对称性,它的方法大致可以分为 5 类。其中涉及到 Unpin&mut T 又可以细分为 safe 和 unsafe 两种;
  • Pin#[fundamental]#[repr(transparent)] 很重要,但你一般不需要关心它;
  • Pin 实现的 trait 需要重点关注 UnpinDeref / DerefMutFuture,搞懂他们你才能完全掌握 Pin
  • UnpinFuture 关系十分密切。Unpin 是 safe trait,理论上可以任意实现,Future 也可以是 Unpin 的。一些异步 API 中可能需要 Unpin 限定,需要理解它的原因,而不是一味的只顾使用。
  • Pin 是一种通用 API,除了 async / await 下需要 Pin 之外,也会有其他场景需要用 Pin 来解决,比如 intrusive collections

文章中多次提到的 Pin projection 没有展开讲,下一篇文章再来详细探讨它。再会!

Rust性能评估与调优实践

作者:张汉东


内容介绍

  • Rust 性能优化总则
  • Rust 性能优化准备工作
  • Rust 性能剖析工具介绍
  • 日常 Rust 开发性能优化的技巧总结
  • Rust 编译大小和编译时间优化技巧

本次分享将围绕 Rust 性能评估和调优主题,比较系统地介绍 Rust 代码的性能优化经验。先从大的总原则出发,介绍在编写 Rust 过程中应该遵循哪些原则对后续优化有帮助。接下来会分享一些代码优化的方法和技巧,然后介绍可以用于 Rust 代码性能评估的工具,也会包括 Rust专用的一些异步并发测试工具介绍。

引子

Rust 语言天生为并发和安全而设计,并且借鉴了面向过程/面向对象/函数式等语言的特点。Rust 的目标在性能方面对标 C 语言,但在安全和生产力方面则比 C 更胜一筹。

虽说 Rust 语言性能对标 C 语言,但开发者写出的Rust 代码如果不经任何优化,也有可能比 Python 更慢。导致 Rust 代码性能慢的因素有很多种,本文就是尝试来梳理这些情况,并且给出一套方法论和一些工具集,来帮助开发者编写高性能的 Rust 代码。

Rust 性能优化总则

原则一: 不要过早优化性能

过早优化(Premature Optimization)

Premature optimization is the root of all evil. -- DonaldKnuth

在 DonaldKnuth 的论文 《 Structured Programming With GoTo Statements 》中,他写道:"程序员浪费了大量的时间去考虑或担心程序中非关键部分的速度,而当考虑到调试和维护时,这些对效率的尝试实际上会产生强烈的负面影响。我们应该忘记这种微小的效率,比如说因为过早优化而浪费的大约97%的时间。然而,我们不应该放弃那关键的 3% 的机会"。

想把代码优化到最佳,需要花很多精力。不应该在开发的时候去想着优化的事情,不需要一步到位。先完成再完美。

但是并非所有优化过早。在编写代码的过程中,优化代码的可读性是你持续要做的。Rust 是一门讲究显式语义的语言,在命名上体现出类型的语义,对于提升可读性非常重要。

原则二: 不要过度优化性能

RustConf 2021 一个演讲就举了一个过度优化例子:

某个用户只是想写一些比 Python 程序性能更好的代码。第一版 Rust 实现的代码已经达到了这个要求,比 Python 代码快 20倍。但是他们花了九牛二虎之力写的第二个 Rust 版本,和第一个版本差距并不大。

1

性能够用就好,否则就容易浪费不必要的时间。

原则三: Rust 代码的性能、安全、编译速度和编译大小之间需要权衡

Rust 是同时注重安全和性能的语言。但是在优化性能的同时,是有可能损失安全性的。比如使用 Unsafe Rust 来提升性能,而忽略安全检查在某些调用环境比较安全的地方是允许的,但是并非通用的做法。所以在优化性能之前,要考虑是否要牺牲安全性。

另外 Rust 优化性能的同时,可能会导致编译速度变慢 和 编译文件大小膨胀。这也是需要权衡的地方。

Rust 优化准备工作

在性能优化之前,你还需要做一些准备工作,用于测量你的优化是否有效。

基准测试

第一步是建立一套一致的基准,可以用来确定性能的基线水平,并衡量任何渐进的改进。

参考:

mongodb 的案例中,标准化的MongoDB 驱动微基准集在这方面发挥了很好的作用,特别是因为它允许在用其他编程语言编写的MongoDB驱动之间进行比较。由于这些是 "微 "基准,它们还可以很容易地测量单个组件的变化(例如,读与写),这在专注于在特定领域进行改进时是非常有用的。

一旦选择了基准,就应该建立一个稳定的环境,可以用来进行所有的定时测量。确保环境不发生变化,并且在分析时不做其他 "工作"(如浏览猫的图片),这对减少基准测量中的噪音很重要。

推荐工具:

使用 cargo bench 和 criterion 来进行基准测试

[dev-dependencies]
criterion = { version = "0.3.5", features = ["async_tokio", "html_reports"] }

[[bench]]
name = "find"
harness = false

因为 Rust 自带的基准测试只能用于Nightly Rust ,所以需要使用这个第三方库 criterion 在 Stable Rust 下进行基准测试。

Criterion 会将每次运行的时间记录、分析到一个 HTML 报告中。

2

在报告的底部,有两个最近的运行之间的比较,较早的运行(基线)为红色,最近的运行(优化的)为蓝色。这些报告是非常有用的工具,用于可视化由于性能调整而发生的变化,并且它们对于向其他人展示结果特别有用。

它们还可以作为过去性能数据的记录,无需手动记录结果。如果有性能回归的情况,也会得到及时的反映。

压力/负载测试

基准测试是开发过程中对程序性能的一种预判。而项目最终发布之后,还需要在实际环境对其进行真正的负载测试,来判断系统的延时和吞吐量。

常用的负载测试工具基本都可以使用,比如 locust,wrk之类。这里介绍一个 Rust 基金会成员公司的一个用 Rust 实现的开源分布式负载测试工具 :goose

Goose 每 CPU 核产生的流量至少是 Locust 的 11 倍,对于更复杂的负载测试(例如使用第三方库抓取表单内容的负载测试),收益甚至更大。虽然 Locust 要求您管理分布式负载测试,只是为了在单个服务器上使用多个 CPU 内核,但 Goose 使用单个进程利用所有可用的 CPU 内核,从而大大简化了运行更大负载测试的过程。对代码库的持续改进继续带来新功能和更快的性能。Goose 的扩展性远远优于 Locust,可以有效地利用可用资源来实现其目标。它还支持异步流程,使更多的同步流程能够轻松且一致地从单个服务器上增加数千名用户。

Goose 拥有许多其他负载测试工具所没有的独特调试和日志记录机制,简化了负载测试的编写和结果的分析。Goose 还通过对数据的多个简单视图提供了更全面的指标,并且可以轻松地确认负载测试在您按比例放大或缩小时按照您的预期执行。它公开了用于分配任务和任务集的算法,对操作的顺序和一致性进行更精细的控制,这对于易于重复的测试很重要。

3

明白高性能系统的标准

在进行性能剖析之前,还应该明白高性能系统的一个标准。

性能 = 产出 / 资源消耗

产出 = 事务次数(比如,qps)和 吞吐的数据量

消耗资源 = cpu时间片,磁盘/网络 I/O 次数、流量 等

而高性能的系统是要求在固定资源消耗之下来提高产出。

对于高性能系统的设计一般遵循两个标准:

  1. 最大化地利用资源。
  2. 使用流水线技术减少程序中任务总耗时。比如 Rust 编译器优化编译时间,也使用了流水线技术来对crate进行并行编译。

常见瓶颈类型:

  1. CPU :
    1. CPU 占用过高,那么就需要减少计算的开销。
    2. CPU 负载过高,那么就需要查看是否线程过多,以及多个线程的切换太过频繁,多线程交互是否有必要。
  2. I/O:
    1. 磁盘 IOPS(Input/Output Operations Per Second) 达到了上限。那么需要减少读写次数,提高 cache命中率。
    2. IO 带宽(bandwidth) 上限。那么就需要减少磁盘的读写流量,比如使用更紧凑的数据存储格式,更小的读写放大(本来只需要读取100字节,结果触发了好多个page的读写,产生了放大的效果)。
    3. I/O 并发达到上限。那么就需要考虑使用 异步I/O。
    4. 锁、计时器、分页/交换等被阻塞。

Rust 性能剖析工具介绍

在做好准备工作之后,就可以开启我们的性能剖析工作了。

性能剖析,就是要发现程序中真正存在的性能瓶颈。而不是你自以为的想象中的性能瓶颈。如果不遵守这点,就会导致过早优化或过度优化。

因为常见的性能瓶颈一般都是两类,CPU 和 I/O 。所以工具也基本面向这两类。

On-CPU 性能剖析

使用 Perf 寻找“热点”

做cpu性能剖析有很多常用的 Linux 命令行工具,比如 linux 命令行工具 perf。它功能强大:它可以检测 CPU 性能计数器、跟踪点、kprobes 和 uprobes(动态跟踪)。

你可以使用 perf 工具对 CPU 进行采样分析。以一个指定的频率对CPU进行采样,进而拿到正在CPU上运行的指令乃至整个函数调用栈的快照,最后对采样的数据分析。比如说在100次采样中有20次在运行A指令或者A函数,那么perf就会认为A函数的CPU使用率为20%。

可以在 Cargo.toml 中加入:

[profile.release]
debug = true

然后执行:


#![allow(unused)]
fn main() {
$ cargo build --release
$ perf record -g target/release/perf-test
$ perf report
}

就可以看到报告了。

火焰图工具

但我们 Rust 程序中要通过flamegraph crate,来生成 火焰图(flamegraph),它可以与cargo一起工作,非常方便。

因为火焰图有助于阅读源码,它以可视化的图案非常明确地展示调用栈之间的关系。火焰图可以让开发者从整体上看出各个线程的开销比例和子函数占有的比例,指引我们从整体上找到优化的优先级。

火焰图中,在被测量的执行过程中调用的每个函数会被表示为一个矩形,每个调用栈被表示为一个矩形栈。一个给定的矩形的宽度与在该函数中花费的时间成正比,更宽的矩形意味着更多的时间。火焰图对于识别程序中的慢速部分非常有用,因为它们可以让你快速识别代码库中哪些部分花费的时间不成比例。

用 Mongodb 调优的示例来说:

4

5

火焰图中的栈从底部开始,随着调用栈的加深而向上移动(左右无所谓),通常这是开始阅读它们的最佳方式。看一下上面火焰图的底部,最宽的矩形是Future::poll,但这并不是因为Rust 的 Future 超级慢,而是因为每个.await都涉及轮询(poll)Future。考虑到这一点,我们可以跳过任何轮询矩形,直到我们在mongodb中看到我们关心的信息的函数。

蓝色方块包含了调用CommandResponse::body所花费的时间,它显示几乎所有的时间都花在了clone()上。各个紫色矩形对应的是将BSON(MongoDB使用的二进制格式)解析到Document中所花费的时间,绿色矩形对应的是Documentserde::Deserialize实现中所花费的时间。最后,黑色虚线矩形对应的是释放内存的时间,黑色实线对应的是将命令序列化为BSON的时间。

所以从火焰图中反映出性能瓶颈在于:

  1. Clone 过多。
  2. 序列化 bson 耗费更多时间

修复完这些性能瓶颈之后,再使用基准测试测试一次。

如果可能的话,再使用 goose 这样的压测工具进行一次负载测试更好。

perf 适合测试 Rust 异步代码

对于异步 Rust 程序而言,火焰图的效果可能并不是很好,因为异步调度器和执行器几乎会出现在火焰图中每一块地方,看不出瓶颈所在。这个时候使用 perf 工具会更加清晰。

检查内存泄露和不必要的内存分配

可以使用 Valgrind 工具来检查程序是否存在内存泄露,或者在关键的调用路径上存在不必要的内存分配。

不仅仅要考察堆分配,也需要考虑栈上的分配,特别是异步操作时。

有一个非常有用的 Rust 编译标志(仅在 Rust nightly 中可用)来验证数据结构有多大及其缓存对齐。


#![allow(unused)]
fn main() {
$ RUSTFLAGS=-Zprint-type-sizes cargo build --release
}

除了通常的 Cargo 输出之外,包括异步 Future 在内的每个数据结构都以相应的大小和缓存对齐方式打印出来。比如:


#![allow(unused)]
fn main() {
print-type-size type: `net::protocol::proto::msg::Data`: 304 bytes, alignment: 8 bytes
print-type-size     field `.key`: 40 bytes
print-type-size     field `.data_info`: 168 bytes
print-type-size     field `.payload`: 96 bytes
}

Rust 异步编程非常依赖栈空间,异步运行时和库需要把所有东西放到栈上来保证执行的正确性。如果你的异步程序占用了过多的栈空间,可以考虑将其进行优化为 平衡的同步和异步代码组合,把特定的异步代码隔离出来也是一种优化手段。

其他性能剖析/监控工具

如果允许,可以使用 英特尔出品的 VTune 工具进行 CPU 性能剖析。

或者使用在线的性能监控平台,比如 Logrocket,支持 Rust 程序,可以监控应用程序的性能,报告客户端 CPU 负载、客户端内存使用等指标。

也可以使用开源的链路追踪工具来监控你自己的 Rust 项目:使用 OpenTelemetry 标准。OpenTelemetry 也支持 Rust 。

opentelemetry是一款数据收集中间件。我们可以使用它来生成,收集和导出监测数据(Metrics,Logs and traces),这些数据可供支持OpenTelemetry的中间件存储,查询和显示,用以实现数据观测,性能分析,系统监控,服务告警等能力。

PingCAP 也开源了一款高性能的 tracing 库 : minitrace-rust

Off-CPU 性能剖析

Off-CPU 是指在 I/O、锁、计时器、分页/交换等被阻塞的同时等待的时间。

Off-CPU 的性能剖析通常可以在程序运行过程中进行采用链路跟踪来进行分析。

还有就是使用 offcpu 火焰图进行可视化观察。

6

这里推荐的工具是 eBPF的前端工具包bcc中的offcputime-bpfcc工具。

这个工具的原理是在每一次内核调用finish_task_switch()函数完成任务切换的时候记录上一个进程被调度离开CPU的时间戳和当前进程被调度到CPU的时间戳,那么一个进程离开CPU到下一次进入CPU的时间差即为Off-CPU的时间。

比如这里一段代码:

use std::io::Read;
fn test1() {
    std::thread::sleep(std::time::Duration::from_nanos(200));
}
fn test2() {
    let mut f = std::fs::File::open("./1.txt").unwrap();
    let mut buffer = Vec::new();
    f.read_to_end(&mut buffer).unwrap();
}
fn main() {
    loop {
        test1();
        test2();
    }
}

程序中一共有两种会导致进程被调度出CPU的任务,一个是test1()函数中的sleep(),一个是在test2()函数中的读文件操作。

这里需要使用debug编译,因为offcputime-bpfcc依赖于frame pointer来进行栈展开,所以我们需要开启RUSTFLAGS="-C force-frame-pointers=yes"的编译选项以便打印出用户态的函数栈。我们使用如下的命令获取Off-CPU的分析数据。


#![allow(unused)]
fn main() {
$ ./target/debug/mytest & sudo offcputime-bpfcc -p `pgrep -nx mytest` 5
}

然后使用 火焰图工具将其生成 off-cpu 火焰图:


#![allow(unused)]
fn main() {
$ git clone https://github.com/brendangregg/FlameGraph
$ cd FlameGraph
$ sudo offcputime-bpfcc -df -p `pgrep -nx mytest` 3 > out.stacks
$ ./flamegraph.pl --color=io --title="Off-CPU Time Flame Graph" --countname=us < out.stacks > out.svg
}

得到下面火焰图:

7

On-CPU的火焰图相同,纵轴代表了函数调用栈,横轴代表了Off-CPU时间的比例,跨度越大代表Off-CPU的时间越长。

其他适合 Rust 性能剖析的工具介绍

除了 perf 和 火焰图 工具,下面还有一些 Rust 程序适用的工具。

  • HotspotFirefox Profiler是查看perf记录的数据的好工具。
  • CachegrindCallgrind给出了全局的、每个函数的、每个源线的指令数以及模拟的缓存和分支预测数据。
  • DHAT可以很好的找到代码中哪些部分会造成大量的分配,并对峰值内存使用情况进行深入了解。
  • heaptrack是另一个堆分析工具。
  • counts支持即席(Ad Hoc)剖析,它将eprintln!语句的使用与基于频率的后处理结合起来,这对于了解代码中特定领域的部分内容很有帮助。
  • Coz执行因果分析以衡量优化潜力。它通过coz-rs支持Rust。因果分析技术可以找到程序的瓶颈并显示对其进行优化的效果。

日常 Rust 开发性能优化技巧总结

虽然我们需要通过完善的性能测试方法来剖析系统中存在的瓶颈,保证不会过早优化和过度优化。但是在日常编码过程中,Rust 社区内也总结出来一些优化技巧来供参考:

1. 对于只被调用一次的函数可能并不需要进行优化。

比如读取配置文件,这种多慢都没有关系。

不要只优化程序中最慢的函数,要优化占用大部分运行时间的函数。

在一个被调用 1000 次的函数上得到 2 毫秒的改进,那比在一个被调用一次的函数上获得 1 秒的改进要好。

2. 优先改进你的算法

很多时候性能不佳,很可能是由于算法不佳而不是实现不佳。请检查你的代码中循环的使用,只需尝试尽可能少的循环。

  1. 记住每次使用collect必须至少会迭代整个集合一次,所以最好只 collect 一次。
  2. 警惕你使用的标准库方法和第三方库方法内部实现中隐藏的循环。

3. 要充分理解 Rust 中数据结构的内存布局

要学会区分 Rust 中数据类型的内存布局,它们在栈上和堆上如何分配的。

比如 StringVecHashMapBox<Trait>/Box<[T]>所有分配都在堆上。

在栈上分配的数据,移动的时候只能是 按位复制的方式。所以即便内存是在栈上分配,也要考虑这个 Copy 的成本。

堆上的数据,要尽可能地避免深拷贝(显式 Clone) 。

并且要尽可能地缓存数据,而避免频繁的内存分配发生。比如可以使用诸如 slab 之类的第三方库,可以合理复用内存。

4. 避免 Box<Trait> 动态分发

创建 trait 对象的规范方法是Box<Trait>,但大多数代码都可以使用&mut Trait,它也具有动态分派但节省了分配。如果您绝对需要所有权,请使用Box,但大多数用例都可以使用&Trait&mut Trait

有些场景也可以使用 Enum 来代替 trait 对象。参见 enum_dispatch

5. 使用基于栈的可变长度数据类型

定长度的数据类型可以简单地存储在堆栈上,但对于动态大小的数据,它并不是那么简单。但是,smallvec, smallstringtendril都是可变长度数据类型,允许在栈上存储少量元素。像smallvec这样的库非常适合缓存局部性,可以减少分配。


#![allow(unused)]
fn main() {
// This is a gross oversimplification of how this type is implemented in the// crate, but it's enough to explain how it works.enum SmallVec<T> {    Small([T; 4]),    Big(Vec<T>),}type Matrix<T> = SmallVec<SmallVec<T>>;
}

6. 合理使用断言避免数组越界检查

Safe Rust 会被编译器自动塞入数组越界检查,比如下面代码:


#![allow(unused)]
fn main() {
fn do_something_with_array(array: &[u8]) -> u8 {    array[0] + array[1] + array[2] + array[3] + array[4] + array[5]}
}

可以通过编译输出 MIR 看到,编译器会给数组索引访问插入断言检查:


#![allow(unused)]
fn main() {
assert(move _9, "index out of bounds: the length is {} but the index is {}", move _8, _7) 
}

有几个数组索引访问就会被插入几次,上面的代码会被插入 6 次,这极大影响性能。

8

所以我们可以手工插入一次断言检查,就可以消除编译器的自动插入。


#![allow(unused)]
fn main() {
fn do_something_with_array(array: &[u8]) -> u8 {    assert!(array.len >= 5);    array[0] + array[1] + array[2] + array[3] + array[4] + array[5]}
}

这一条也是可以举一反三的,比如 Rust 也会为普通的加法操作添加防止计算溢出的断言,但是你如何手工使用了 wrapped_add 之类的方法,那就可以避免编译器自动插入这类断言。

7. 使用链接时优化(LTO)

链接时优化允许编译器跨 crate 进行内联,但是这样做的代价是减慢编译时间。但我认为,编译时间如何能换取性能提升,那么这个时间值得牺牲。

8. 不要使用 #[inline(always)]

Rust 编译器自身的优化可以计算出何时需要内联一些函数,不需要你手工明确指定。除非这个函数调用十分频繁。

因为这种显式的指定会导致编译大小的膨胀,如果你的硬件资源不受限可能不太重要。但是对于资源受限的环境,比如嵌入式,则需要进行权衡。

对于一些小的函数,如果没有使用 LTO,但是需要跨 crate 内联的话,也可以显式指定 #[inline]

9. 避免显式 Clone

尽可能地使用引用,避免过多的 Clone 。因为Clone 可能伴随内存分配。

10. 使用 Unsafe 方法消除一些不必要的安全检查

在 Rust 标准库中,你可以看到很多 _unchecked后缀的方法。

比如 String::from_utf8String::from_utf8_unchecked,是一对 Safe 和 Unsafe 的方法。

一般情况下,应该使用 String::from_utf8u8序列转换为合法的字符串,这个方法对 u8序列进行了合法 utf8编码的检查。但是这个检查也会有一定开销。

如果开发者能确保调用环境的 u8序列来源是完全合法的 utf8 编码,那么这个安全检查就完全可以忽略。此时就可以使用 String::from_utf8_unchecked 来替换 String::from_utf8 用来提升性能。


#![allow(unused)]
fn main() {
pub fn from_utf8(vec: Vec<u8>) -> Result<String, FromUtf8Error> {    match str::from_utf8(&vec) {        Ok(..) => Ok(String { vec }),        Err(e) => Err(FromUtf8Error { bytes: vec, error: e }),    }}pub unsafe fn from_utf8_unchecked(bytes: Vec<u8>) -> String {    String { vec: bytes }}
}

11. 并发/并行化你的程序

用 Rust 写多线程和异步并发程序是非常便利的。

推荐的库有很多:

  • rayon,并行迭代器
  • crossbeam / flume,多线程channel/ 无锁并发结构
  • Tokio ,高性能异步运行时
    • loom, Tokio 提供的并发代码测试工具,支持 C11 内存模型。
    • console,Tokio 提供的 Rust 异步诊断和调试工具,可以将其视为异步代码的 Clippy。通过监控应用程序中任务的运行时操作,可以检测可能暗示错误或性能问题的行为模式,并突出显示它们以供用户分析。
  • 跨平台 SIMD,并行化你的计算。

12. 并发程序中,合理使用锁,或替换无锁数据结构

在某些场景中,可能读并发访问要比写并发更频繁,那么可以用 读写锁来替换互斥锁。另外,使用第三方库 parking_lot 中定义的并发锁来代替标准库中的锁。

或者合理选择无锁数据结构来替换用锁来同步的数据结构,并不是说无锁一定比锁同步性能更好,也是需要看场景和选择高质量的第三方实现。

13. 使用 Clippy

使用 Clippy 工具对代码进行静态分析,它可以针对性能改进提供一些建议。

关于 Clippy 性能改进 lint 可以在这里找到:https://rust-lang.github.io/rust-clippy/master/index.html

同样可以遵循 Rust 编码规范 中的一些规范,也会包括 Clippy 的一些建议。如果你有什么性能优化的小技巧,欢迎提交贡献。

编译大小和编译时间的优化总结

1. 优化编译大小

  • 设置 codegen-units=1 ,codegen-units 叫做代码生成单元,Rust 编译器会把crate 生成的 LLVMIR进行分割,默认分割为16个单元,每个单元就叫 codegen-units,如果分割的太多,就不利于 Rust编译器使用内联优化一些函数调用,分割单元越大,才越容易判断需要内联的地方。但是这也有可能增大编译文件大小,需要大小和性能间寻找平衡。
  • 设置panic=abort。可以缩减编译文件的大小。
  • 设置编译优化等级为 z,意为最小二进制体积。编译器的优化级别对应的是LLVM函数内联的阈值,z 对应的是 25,而 级别 3则对应 275 。
  • 评估代码中泛型和宏的使用,是否可以精简
  • 其他参考:https://github.com/johnthagen/min-sized-rust

2. 优化编译大小的一些技巧

  • 使用 cargo check 代替 cargo build

  • 使用最新 Rust 工具链

  • 使用 Rust Analyzer 而不是 Rust Language Server (RLS)

  • 删除未使用的依赖项

  • 替换依赖过多的第三方库

  • 使用 workspace,将项目拆分为多个crate,方便并行编译

  • 将针对模块的测试单独拆分为一个测试文件

  • 将所有集成测试组合在一个文件中

  • 禁止 crate 依赖未使用功能

  • 使用 ssd或Ramdisk(虚拟内存盘) 进行编译

  • 使用 sccache 缓存依赖项

  • 切换到更快的链接器:mold (Linux)/ zld (MacOS) / 🤷 (Windows),可以使用以下命令检查链接所花时间:

    
    #![allow(unused)]
    fn main() {
    cargo cleancargo +nightly rustc --bin <your_binary_name> -- -Z time-passes
    }
    
  • Rust 针对 MacOS 用户也提升了增量编译性能,在 Cargo.toml 中进行以下配置:

  • 
    #![allow(unused)]
    fn main() {
    [profile.dev]split-debuginfo = "unpacked"
    }
    
  • 调整更多 Codegen 选项/编译器标志。这是完整的 codegen 选项列表 。为了获得灵感,这里是bevy 的用于更快编译的配置

  • 剖析文件编译时间。使用 cargo rustc -- -Zself-profile生成的跟踪文件可以使用火焰图或 Chromium 分析器进行可视化。还有一个cargo -Z timings功能可以提供有关每个编译步骤需要多长时间的一些信息,并随着时间的推移跟踪并发信息。

  • 避免过程宏 Crates,主要是因为使用了 syn 。过程宏是 Rust 开发的热点:它们会消耗 CPU 周期,因此请谨慎使用。serde 库中包含了过程宏,它在很多地方都用到,所以需要注意是否一定需要serde 进行序列化和反序列化。

  • 避免过多的泛型。过多的泛型单态化也会导致编译时间增加。

  • 提升你的硬件,或者在云端(比如Gitpod.io,可免费使用 16 核 Intel Xeon 2.80GHz,60GB RAM的主机)使用更好的硬件环境进行编译。

  • 下载所有的依赖 crate。编译过程中有很大一部分时间用于下载,提前下载好crate是有帮助的。参考 https://github.com/the-lean-crate/criner

  • 使用 docker 进行编译。cargo-chef可用于充分利用 Docker 层缓存,从而大大加快 Rust 项目的 Docker 构建。

  • 超频 cpu ?谨慎。

  • 优化 CI 构建速度。参考 https://matklad.github.io/2021/09/04/fast-rust-builds.html。

  • 你自己开发 crate 的时候尽量保持精简,利人利己。

参考

  1. https://zenoh.io/blog/2021-07-13-zenoh-performance-async/

  2. https://bheisler.github.io/criterion.rs/book/getting_started.html

  3. https://rust-coding-guidelines.github.io/rust-coding-guidelines-zh/safe-guides/Appendix/test/benchmark.html#%E7%94%A8-cargo-bench-%E5%92%8C-criterionrs-%E6%9D%A5%E6%89%A7%E8%A1%8C%E5%9F%BA%E5%87%86%E6%B5%8B%E8%AF%95

  4. https://gist.github.com/jFransham/369a86eff00e5f280ed25121454acec1

  5. https://github.com/tag1consulting/goose

  6. https://rustmagazine.github.io/rust_magazine_2021/chapter_11/rust-profiling.html?search=

  7. https://rustmagazine.github.io/rust_magazine_2021/chapter_7/paper-rust-vs-c.html

  8. https://blues-star.github.io/perf-book-zh/benchmarking_zh.html

  9. https://en.pingcap.com/blog/how-we-trace-a-kv-database-with-less-than-5-percent-performance-impact/

  10. https://rust-coding-guidelines.github.io/rust-coding-guidelines-zh/

  11. https://endler.dev/2020/rust-compile-times/

  12. https://github.com/johnthagen/min-sized-rust

  13. https://docs.rust-embedded.org/book/unsorted/speed-vs-size.html

  14. https://fasterthanli.me/articles/why-is-my-rust-build-so-slow

Tokio 异步传播的缺陷

作者: 吴翱翔

原文: tokio 异步传播的缺陷

最近项目中遇到一些 Bug:tokio channel的接收方不知道为何被 drop 掉了导致 send Error

经过 Debug 后发现其实是 hyper 的 cancel propagation 导致的, 以下这个对 hyper 源码中 examples/web_api.rs 的改动就能复现

diff --git a/Cargo.toml b/Cargo.toml
index 862c20f9..694b8855 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -73,7 +73,7 @@ pnet_datalink = "0.27.2"
 
 [features]
 # Nothing by default
-default = []
+default = ["full"]
 
 # Easily turn it all on
 full = [
diff --git a/examples/web_api.rs b/examples/web_api.rs
index 5226249b..6de7f682 100644
--- a/examples/web_api.rs
+++ b/examples/web_api.rs
@@ -56,6 +56,12 @@ async fn api_post_response(req: Request<Body>) -> Result<Response<Body>> {
 
 async fn api_get_response() -> Result<Response<Body>> {
     let data = vec!["foo", "bar"];
+    let (tx, rx) = tokio::sync::oneshot::channel::<()>();
+    tokio::spawn(async move {
+        tokio::time::sleep(std::time::Duration::from_secs(5)).await;
+        tx.send(()).unwrap();
+    });
+    rx.await.unwrap();
     let res = match serde_json::to_string(&data) {
         Ok(json) => Response::builder()
             .header(header::CONTENT_TYPE, "application/json")

async fn api_get_response 是一个 hyper 处理 http 请求的异步函数,在里面 spawn 了一个 Task 去做一些费时的操作, 我们用 sleep 模拟需要 5 秒才能做完任务,最后通过 chanel 将处理完的数据/结果发送给 async fn api_get_response, 如果客户端还没等 server response 就提前主动关闭连接,hyper 会将 async fn api_get_response 给 cancel 掉, 所以 rx 就被 drop 掉导致后续的发送失败

cancel 没有传播带来的问题

大量主动断开连接的恶意请求

这样导致的问题是,客户端没等 response 已经掐断连接, server 端还在不停的查 spawn 协程查数据库, 极容易被人利用攻击服务器,例如有人恶意发 10 万个处理时间要很长的请求,请求发出去之后客户端立即 cancel 掉, 此时 server 如果还在处理「已经被 cancel 掉的请求」会造成资源浪费

如果客户端断开连接后,理应让处理该请求的所有关联的异步 Task/Future 从根节点开始往后全部往后传播并取消掉,

否则客户端早就断开连接服务端还在傻乎乎的继续去查一堆数据库,消耗大量资源查出来的数据准备返回给客户端的时候才发现 Send Error

systemctl stop 超时

例如 web server 进程一般通过 libc::signal 回调函数,让进程收到关闭的信号后,graceful shutdown web server

一般收到 cancel 后要把信号传播到每个协程中,但有些顽固协程活的时间很长(例如 loop sleep 之类的轮询任务)

最终让 systemd stop 超时无奈发 kill 信号停止,然而发 kill 信号停止进程完成部署更新并不好,因为 libc::signal 的回调函数不能也无法处理 SIGKILL 信号,无法进行定制的一些资源回收操作

Dec 18 10:39:21 ww systemd[715]: Stopping graph...
Dec 18 10:39:21 ww atlasd[1518986]: 2021-12-18 10:39:21.588323  INFO atlasd: Signal SIGTERM received, stopping this daemon server
Dec 18 10:39:21 ww atlasd[1518986]: 2021-12-18 10:39:21.588408  INFO server::graph: Prepare to stop graph server
Dec 18 10:39:21 ww atlasd[1518986]: 2021-12-18 10:39:21.588744  INFO start_prometheus_exporter{ip=0.0.0.0 port=19100 instance_kind=Graph}:prometheus_exporter(accept): common::metrics::prome>
Dec 18 10:39:21 ww atlasd[1518986]: 2021-12-18 10:39:21.588830  INFO web::server: graceful shutdown web server
Dec 18 10:40:51 ww systemd[715]: graph.service: State 'stop-sigterm' timed out. Killing.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1518986 (atlasd) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1518988 (tokio-runtime-w) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1518989 (tokio-runtime-w) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1518993 (tokio-runtime-w) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1519000 (tokio-runtime-w) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1519002 (tokio-runtime-w) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Killing process 1519007 (tokio-runtime-w) with signal SIGKILL.
Dec 18 10:40:51 ww systemd[715]: graph.service: Main process exited, code=killed, status=9/KILL
Dec 18 10:40:51 ww systemd[715]: graph.service: Failed with result 'timeout'.
Dec 18 10:40:51 ww systemd[715]: Stopped graph.

Future 剪枝?

async fn api_get_response 可以抽象成 Future 的根节点,里面 spawn 的 Future 可以抽象成儿子节点

我以为一个 rpc 请求处理函数的根节点 Future 由于客户端断开连接会被 hyper 掐断掉,连同该 Future 的所有叶子节点 Future 也全部中止掉,但实际上 tokio 并没有这样的 API 或者设计

我希望的是类似 scoped_thread(子线程活的更短) 这样的约束可以让 async fn api_get_response spawn 出来的 Future 一定活的更短,这样能减少很多心智负担不必担心当父节点被 drop 之后子 Future 还持有父节点的资源

听上去就有点像 scoped future

Monoio 群的讨论

吴翱翔:
假设 hyper http 处理一个 http(rpc) 请求要 15 秒,handler 函数内 tokio::spawn,此时如果请求没处理完 客户端主动断开链接,hyper 会 cancel propagation 将 HTTP server 的当前请求 的 async fn handler 处理函数给 cancel 掉
但是 Rust 的异步 spawn 很大的问题是,我在 async fn handler 里面的 tokio::spawn 不会被 cancel 掉依然继续执行
spawn 里面的代码还想等 15 秒后给外面的 async fn api_get_response() 的 channel 发 send, 结果外面的 Future 早就被 cancel 掉了

Shuai Xu:
所以大家都希望能有 结构化并发的能力

吴翱翔:
我在想让 `async fn api_get_response()` 持有一个 tokio::spawn 返回的 join handle, 这样能不能在 async fn api_get_response() 被 Drop 的时候把里面的 spawn 的
儿子 Future 全部也 cancel 掉, 实现了 cancel 的传播,好像现在 Rust 很难实现啊

Shuai Xu:
是的 最主要的是缺 AsyncDrop
只能自己注意和处理
(翱翔批注: 社区有人提议借助 linear-types 实现 AsyncDrop 抽象, https://aidancully.blogspot.com/2021/12/linear-types-can-help.html)

吴翱翔:
[MoonFace]写代码都得小心翼翼,父 Future 被 cancel 掉一些资源例如 chancel 被释放,结果 儿子 Future 还在往 父 Future 持有的 receiver 发数据,@张汉东  这种情况编译器也没法检查吧

JZ:
smol里task必须显式detach()才会在后台运行,而tokio 和async-std都是drop自动detach,这个取消的问题不好解
如果用的smol,你这里可以join在task和rx上,应该就可以了吧
(翱翔批注: glommio runtime 也是需要显式 detach)

张汉东:
@吴翱翔 这没法检查
这看上去像是代码设计的问题

JZ:
@吴翱翔 你可以搜一下Rust async cancellation,作者应该是是Joshua,里面有写临时解决方案

怎么让 hyper 的 cancel 传播下去


#![allow(unused)]
fn main() {
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let task_handle = tokio::spawn(async move {
    dbg!("task start");
    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
    dbg!("task finished, prepare send back to parent future Node");
    tx.send(()).unwrap();
});
}

以上写法当客户端提前断开连接的时候 tx.send(()).unwrap(); 这行就会 panic

为了能让 tokio::spawn 的任务能在请求被 cancel 时提前中止,需要引入更多的 boilerplate code


#![allow(unused)]
fn main() {
struct ReqHandlerContext {
    task_handle: tokio::task::JoinHandle<()>,
    // rx: tokio::sync::oneshot::Receiver<()>
}
impl Drop for ReqHandlerContext {
    fn drop(&mut self) {
        dbg!("fn api_get_response() ReqHandlerContext in drop");
        self.task_handle.abort();
    }
}
let ctx = ReqHandlerContext {
    task_handle,
};
}

当客户端提前断开连接的时候,hyper 的日志如下

Listening on http://127.0.0.1:1337
[examples/web_api.rs:60] "task start" = "task start"
[examples/web_api.rs:72] "fn api_get_response() ReqHandlerContext in drop" = "fn api_get_response() ReqHandlerContext in drop"

可见 dbg!("task finished 这行没被执行之前,spawn 已经被 cancel 掉,符合预期

写 spawn 之前必须向上级申请

为了避免 tokio::spawn 处理不当引起各种 Bug,必须严格对 spawn 的使用进行 code review 很限制。

我们公司要求引入新的 tokio::spawn 之前必须向上级领导申请, 并将为什么这处要使用 spawn 的原因和该 spawn 的生存期、引入的外部资源 详细的写进注释中

并且要求显式的存储 spawn 的 JoinHandle 一般会在 Drop 中主动调用 handle.abort()

spawn 不是银弹,如果只是为了不阻塞代码执行其实还有别的写法可以代替,使用 spawn 之前一定要慎重考虑清楚

tokio 若干问题的思考

tokio::task::JoinHandle

文档中说 A JoinHandle detaches the associated task when it is dropped

个人感觉无法像 glommio 的协程 detach 或 libpthead.so 的线程 pthread_detach 让调用者自行决定是否需要 detach 确实很不方便

pin Future to CPU core

tokio 中缺少像 glommio pin to core 的概念,类似于 libpthread.so 的 pthread_setaffinity_np? 似乎 tokio 也没有

这也是 tokio 为了跨平台带来的限制,有些 API 在 Linux 系统上本来就可以实现,但是可能因为 windows/mac 没有所以 tokio 就干脆不提供了

tokio 能不能让某个 Future 固定在某个 CPU 核心上执行,避免在多个核心间执行带来的上下文切换开销, 但这似乎跟 tokio 想让所有核心负载均衡的工作窃取法冲突,tokio 不希望一核有难多核围观

单线程的 runtime 有些场合反而更快

在我们项目中 benchmark 发现某些模块用 tokio 的单线程 runtime 反而性能会更好,所以不能迷信多线程就一定比单线程性能好,由于 CPU 多个核心之前同步数据的开销很大,需要具体情况具体分析到底用单线程还是多线程 runtime

(但单线程和多线程的 tokio::spawn 的 Future 签名是一样的... 单线程并没有少一个 Send 约束...)


最后大胆的设想下能不能从 Linux only 的角度出发去设计 runtime, 完全不用考虑兼容 mac/windows 这样能用很多 Linux only 的 API 充分榨干 Linux 性能,可能最后发现 Rust 自身的 Future 也不太够用或者不好用,甚至会造一个自己运行时专用的 Future (就像 C++ 很多库各自造各自的 Future 不通用)

来造一个OS吧

作者:李睿

作者正积极寻找寒暑假实习机会,欢迎通过文章底部联系方式联系作者。如果本文有错或建议欢迎在评论区指出。

项目地址:https://github.com/KernelErr/MoeOS

MoeOS在哪吒MQ开发板(全志D1s芯片)上最新运行截图:

从中学时期看《30天自制OS》到最近学习rCore-Tutorial-Book,愈发想做一个自己的玩具操作系统。但一直以来看这类教程内心都是一个感想:“我看不懂,但大受震撼”。真正决定开始动手的契机是看到麻雀开发板的问世和查阅了国内外的一些资料,发现有可行性。

目前MoeOS已经完成了基本的MMU(内存管理单元)的设置,中断处理和时钟,一共耗时一个月时间,接下来会完成用户态等工作。做MoeOS主要还是为了学习操作系统的知识,可以看作为优秀的教程交作业,这里对rCore团队等开源贡献者表示感谢。

本篇文章将记录我在学习的过程中踩的坑和所思所想,文章会根据一个个遇到的问题分节讲述。

载入内存

开发MoeOS遇到的第一个问题就是想办法把系统载入内存然后跑起来,如果是QEMU,我们可以使用下面的命令轻松指定启动的镜像:

qemu-system-riscv64 \
        -machine virt \
        -nographic \
        -bios sbi/fw_payload.bin \
        -device loader,file=$(bin),addr=0x80200000

在早期阶段,我决定先不制作SD卡镜像,转而使用xfel工具。xfel是由xboot大佬制作的工具,对于全志系列芯片,在BootROM中固化了一个名为FEL的低级子程序,我们可以用FEL完成内存初始化、载入文件到内存、执行二进制等操作。

在MoeOS的Makefile中我们可以看到下面这段命令:

d1s: d1s_lds build
    xfel ddr ddr2
    xfel write 0x40000000 ./sbi/fw_jump_d1.bin
    xfel write 0x40200000 $(bin)
    xfel exec 0x40000000

这里我们先初始化ddr2内存(MQ开发板内置64M DDR2内存),然后将OpenSBI写入0x40000000地址,将MoeOS的二进制写入0x40200000地址,最后执行SBI。这里有一个问题,我们怎么知道应该写入哪个地址?

对于QEMU来说,我们可以查看位于hw/riscv/virt.c的源码:

static const MemMapEntry virt_memmap[] = {
    [VIRT_DEBUG] =       {        0x0,         0x100 },
    [VIRT_MROM] =        {     0x1000,        0xf000 },
    [VIRT_TEST] =        {   0x100000,        0x1000 },
    [VIRT_RTC] =         {   0x101000,        0x1000 },
    [VIRT_CLINT] =       {  0x2000000,       0x10000 },
    [VIRT_PCIE_PIO] =    {  0x3000000,       0x10000 },
    [VIRT_PLIC] =        {  0xc000000, VIRT_PLIC_SIZE(VIRT_CPUS_MAX * 2) },
    [VIRT_UART0] =       { 0x10000000,         0x100 },
    [VIRT_VIRTIO] =      { 0x10001000,        0x1000 },
    [VIRT_FW_CFG] =      { 0x10100000,          0x18 },
    [VIRT_FLASH] =       { 0x20000000,     0x4000000 },
    [VIRT_PCIE_ECAM] =   { 0x30000000,    0x10000000 },
    [VIRT_PCIE_MMIO] =   { 0x40000000,    0x40000000 },
    [VIRT_DRAM] =        { 0x80000000,           0x0 },
};

可以知道DRAM内存以0x80000000开始,而对于我们的开发板,我们需要查看全志D1s的芯片手册的Memory Mapping一节,找到0x40000000

SBI

在RISC-V中,存在SBI这个概念,官方的描述如下:SBI (Supervisor Binary Interface) is an interface between the Supervisor Execution Environment (SEE) and the supervisor. It allows the supervisor to execute some privileged operations by using the ecall instruction.

SBI会在M态(Machine)下面工作,与硬件打交道,而我们的内核是在S态(Supervisor)工作,可以通过ecall去调用SBI提供的接口。(如果是我们写的一些用户软件,是在User态工作,各个态会有不同的权限限制)

SBI会作为系统启动的一个阶段,在Makefile中可以发现我们先执行了SBI,SBI完成工作后会跳转到我们在编译时指定的地址去执行系统。

MoeOS就是使用SBI提供的接口完成时钟设置、输入输出的。目前基于标准实现的SBI有很多,我们使用的是OpenSBI,但这里也很推荐我们国产也是Rust编写的的RustSBI

pub fn putchar(c: char) {
    sbi_call(LegacyExt::ConsolePutchar.into(), 0, c as usize, 0, 0);
}

pub fn sbi_call(eid: usize, fid: usize, arg0: usize, arg1: usize, arg2: usize) -> usize {
    let ret;
    unsafe {
        asm!("ecall",
             inout("x10") arg0 => ret,
                in("x11") arg1,
                in("x12") arg2,
                in("x17") eid,
                in("x16") fid,
        );
    }
    ret
}

上面展示的是ecall调用SBI接口以输出一个字符的代码,对于这些接口的定义,可以查看参考资料中的Binary Interface Specification。

Inline asm

一个好消息是,Rust正在稳定化inline asm,目前的Nightly版本我们可以像下面这样去使用inline asm和global asm:

use core::arch::{asm, global_asm};
...
global_asm!(include_str!("asm/boot.S"));
...
unsafe {
    asm!(
        "",
        out("x10") a0,
        out("x11") a1,
    );
}

做系统的时候我们需要使用inline asm来直接调用SBI接口、读取寄存器的值等。

点亮LED灯

对应源码:src/device/d1s/mq.rs

MQ开发板上带有一颗蓝色LED,在原理图上标明这颗LED挂在PD22上面。查阅芯片文档可以看到控制PD22的寄存器地址为0x02000098,而针对PD22是第27到24位,我们可以对其写入0001来调至输出模式。下面是点亮LED的代码:

pub fn blue_led_on() {
    let pd2_controller = 0x02000098 as *mut u32;
    let mut pd2_controller_val: u32;
    unsafe {
        pd2_controller_val = ptr::read(pd2_controller);
    }
    pd2_controller_val &= 0xf0ffffff;
    pd2_controller_val |= 0x01000000;
    unsafe {
        ptr::write_volatile(pd2_controller, pd2_controller_val);
    }
}

熄灭LED灯只需要把或改为pd2_controller_val |= 0x0f000000;即可。

设置MMU

对应源码:src/mem/mmu.rs

我们使用的芯片支持Sv39标准的MMU,MMU是一块负责处理CPU的内存访问请求的硬件,可以帮助我们完成虚拟地址到物理地址的转换,下面我将结合代码和图来说明MMU的工作原理。这里我们的Page Size是4KByte。

上图画出了我们页表项的样子,在头部几位是厂商扩展的内容,表示该页是否能够Cache等。PPN是我们的物理地址。末尾的Bits定义了详细的状态和权限标识,如代码所示,如果末尾为1说明页表有效,如果第一位为1说明对该页表对应的地址有写权限。

map函数的作用是把虚拟地址和其对应的物理地址映射到页表上,我们一步步来解析代码。首先我们将VPN(虚拟页面号)和PPN(物理页面号)对应的地址位按照标准给设置好,然后从第二级页表(有512个项)取VPN[2]对应的项,如果无效(即末尾为0)则调用zalloc申请到内存并初始化内容为0,并把地址保存到页表项中。然后我们还原之前申请的地址,将这块地址空间作为下一级页表,重复以上操作完成再第一级页表的初始化。这里我们申请的是4096个字节给一个页表项,我们的VPN一段是9位,即512个数,4096/512=8字节,8*8=64位。

一共三级页表的最后一级页表保存了我们的物理地址和相应的权限。

pub enum EntryBits {
    None = 0,
    Valid = 1 << 0,
    Read = 1 << 1,
    Write = 1 << 2,
    Execute = 1 << 3,
    User = 1 << 4,
    Global = 1 << 5,
    Access = 1 << 6,
    Dirty = 1 << 7,

    ReadWrite = 1 << 1 | 1 << 2,
    ReadExecute = 1 << 1 | 1 << 3,
    ReadWriteExecute = 1 << 1 | 1 << 2 | 1 << 3,

    UserReadWrite = 1 << 1 | 1 << 2 | 1 << 4,
    UserReadExecute = 1 << 1 | 1 << 3 | 1 << 4,
    UserReadWriteExecute = 1 << 1 | 1 << 2 | 1 << 3 | 1 << 4,

    // T-HEAD Extend
    Sec = 1 << 59,
    Buffer = 1 << 61,
    Cacheable = 1 << 62,
    StrongOrder = 1 << 63,
}

pub fn map(root: &mut Table, vaddr: usize, paddr: usize, bits: u64, level: usize) {
    if bits & 0xe == 0 {
        panic!("Invalid PTE bits found");
    }

    let vpn = [
        // VPN[0] = vaddr[20:12]
        (vaddr >> 12) & 0x1ff,
        // VPN[1] = vaddr[29:21]
        (vaddr >> 21) & 0x1ff,
        // VPN[2] = vaddr[38:30]
        (vaddr >> 30) & 0x1ff,
    ];

    let ppn = [
        // PPN[0] = paddr[20:12]
        (paddr >> 12) & 0x1ff,
        // PPN[1] = paddr[29:21]
        (paddr >> 21) & 0x1ff,
        // PPN[2] = paddr[55:30]
        (paddr >> 30) & 0x3ff_ffff,
    ];

    let mut v = &mut root.entries[vpn[2]];

    for i in (level..2).rev() {
        if !v.is_valid() {
            let page = zalloc(1);
            v.set_entry(
                // Set PTE's PPN
                // As page size is 4kb, we have 12 zeros
                // Set the address start from 10th bit
                (page as u64 >> 2) | EntryBits::Valid.val(),
            );
        }
        // Store next level PTE
        let entry = ((v.entry & !0x3ff) << 2) as *mut Entry;
        v = unsafe { entry.add(vpn[i]).as_mut().unwrap() };
    }

    // TODO: Complete C-SKY Extentions
    let entry = (ppn[2] << 28) as u64 |   // PPN[2] = [53:28]
                (ppn[1] << 19) as u64 |   // PPN[1] = [27:19]
                (ppn[0] << 10) as u64 |   // PPN[0] = [18:10]
                bits |
                EntryBits::Valid.val();
    v.set_entry(entry);
}

那么我们如果取得了一个虚拟地址,我们怎么获取物理地址呢?

pub fn virt_to_phys(root: &Table, vaddr: usize) -> Option<usize> {
    let vpn = [
        // VPN[0] = vaddr[20:12]
        (vaddr >> 12) & 0x1ff,
        // VPN[1] = vaddr[29:21]
        (vaddr >> 21) & 0x1ff,
        // VPN[2] = vaddr[38:30]
        (vaddr >> 30) & 0x1ff,
    ];

    let mut v = &root.entries[vpn[2]];
    for i in (0..=2).rev() {
        if !v.is_valid() {
            break;
        } else if v.is_leaf() {
            let off_mask = (1 << (12 + i * 9)) - 1;
            let vaddr = vaddr & off_mask;
            let addr = ((v.entry << 2) as usize) & !off_mask;
            return Some(addr | vaddr);
        }
        let entry = ((v.entry & !0x3ff) << 2) as *mut Entry;
        v = unsafe { entry.add(vpn[i - 1]).as_mut().unwrap() };
    }

    None
}

我们逐级寻找即可,确保找到的每一级页表有效。在src/init.rs中可以发现我们初始化MMU(将内核的内存地址映射好)后设置了satp寄存器(MMU 地址转换寄存器)为我们第二级页表的地址,并通过sfence.vma指令去完成虚拟内存同步。

需要注意的是我们需要设置内核的页面为Access和Dirty,否则QEMU能正常运行但实机会出问题。

中断

对应源码:src/asm/trap.S src/trap.rs

对于RISC-V,MoeOS将stvec(超级用户模式向量基址寄存器)设置为中断处理函数的地址,同时设置模式为Direct。

模式解释:

  • 00: Direct 所有中断都会使用同一个地址作为异常入口

  • 01: Vectored 使用BASE + 4 * Exception Code的地址作为异常入口

当中断发生,我们使用汇编编写的代码会先从栈开辟一块空间出来用于保存现场(如寄存器的值等),然后去调用Rust编写的trap_handler函数。当函数返回后再恢复现场。

#[no_mangle]
extern "C" fn trap_handler(cx: &mut TrapContext) -> &mut TrapContext {
    let scause = scause::read();
    let stval = stval::read();
    match scause.cause() {
        Trap::Exception(Exception::UserEnvCall) => {
            cx.sepc += 4;
            cx.x[10] = 0;
        },
        Trap::Exception(Exception::StoreFault) |
        Trap::Exception(Exception::StorePageFault) => {
            error!("{:?} va = {:#x} instruction = {:#x}", scause.cause(), stval::read(), sepc::read());
            panic!("page fault!");
        },
        Trap::Exception(Exception::IllegalInstruction) => {
            error!("IllegalInstruction");
        },
        Trap::Interrupt(Interrupt::SupervisorTimer) => {
            timer_next_triger();
        }
        _ => {
            panic!("Unhandled trap: {:?} stval = {:#x}", scause.cause(), stval);
        }
    }
    cx
}

scause保存了中断的类型,stval保存了异常的原因。我们使用sepc寄存器保存从异常处理函数退出后的PC值。

时钟

对应源码:stc/timer.rs

SBI为我们提供了接口用于设置过了多少时钟时间后触发中断,从上面的中断处理函数也能发现我们对SupervisorTimer的处理。

pub static mut CLOCK_FREQ: usize = 24000000;
const MICRO_PER_SEC: usize = 1_000_000;
const TICKS_PER_SEC: usize = 1000;

pub fn get_time_us() -> usize {
    unsafe {
        time::read() / (CLOCK_FREQ / MICRO_PER_SEC)
    }
}

pub fn get_time() -> usize {
    time::read()
}

pub fn timer_next_triger() {
    unsafe {
        set_timer(get_time() + CLOCK_FREQ / TICKS_PER_SEC);
    }
}

查阅资料可知时钟为24Mhz,我们设定一秒钟有1000个tick,即每过24000个单位时间我们就触发一次中断,这样我们就能知道过了多少时间了。

总结

目前MoeOS要做的工作很多,我也会在学习和工作之余继续完善MoeOS,做OS的过程中踩了不少坑,但也学到了不少东西。在经历几场面试后,我发现自己对很多基础知识掌握得并不过关,于是决定做点有趣的事情学习知识。

让我们在2022年继续加油!

学习资料

关于作者

北邮大二学生,开源爱好者。目前正在努力学习Linux内核、Rust中,对技术抱有热情。欢迎在飞书Rust 中文社群搜索李睿添加好友。

GitHub:https://github.com/KernelErr

个人博客:https://lirui.tech/

联系邮箱:me@lirui.tech

LLVM基础设施和Rust

原文地址: LLVM Infrastructure and Rust

翻译来源: LLVM基础设施和Rust

LLVM是许多编程语言后端引擎。 它被C、C++、Rust、Go和Swift等使用。这篇博客主要是LLVM相关话题,我将探讨以下主题:

什么是LLVM基础设施

LLVM基础设施是模块化和可重用的编译器和工具链技术的集合。 LLVM包含几个子项目,如Clang、C/C++/Objective-C 编译器、调试器 LLDB、libc++ 等。数十年的研究和改进创造了一个惊人的用于构建语言和编译器的工具,并且一些现代技术正在利用这些工具的。

LLVM项目于2000年由在伊利诺伊大学厄巴纳-香槟分校的Vikram Adve和Chris Lattner启动。 LLVM的名称最初是Low-Level Virtual Machine的首字母缩写词。 尽管现代LLVM基础设施已经与虚拟机无关,但名称保持不变。 但缩写本身的背后含义被删除,而LLVM成为该项目的全名。

LLVM项目是在开源许可下发布的。 后来Lattner被Apple聘用,整个团队被分配到LLVM体系上进行研发,用于满足Apple产品中的各种需求。 LLVM开始扩展其功能并成长为一个综合项目,该项目结合了LLVM IR、LLVM调试器和LLVM API C++库。

LLVM Compiler Framework是一个模块化和可重用的编译器框架,它使用LLVM基础设施来提供端到端的代码编译。 它用于构建、优化、清理和生成中间代码(IR)或二进制(机器)代码(BC)。LLVM允许将编程语言代码转换为中间代码(IR),中间代码(IR)可以转换为任何给定硬件架构的二进制代码(BC)。IR语言独立于源语言和目标语言。

LLVM有很多用途。它的主要用途是作为编译器框架。在这里,我们提供的所谓的前端和后端,或机器代码,它会生成IR。 该IR可以通过生成并链接目标平台的汇编语言转换为目标平台的二进制代码。

LLVM的中间代码(IR)是一种类似于汇编的低级编程语言。 但是除了汇编之外,它还提供了更好的类型注释和人性化的语法。 此外,IR 使用无限组寄存器而不是预定义数量的寄存器。 我们将在后面的部分探讨IR的内部运作。

LLVM是如何工作的

如前所述,LLVM是一个模块化且可重用的编译器框架,支持多个前端和后端。 程序的生命周期包括编写源代码,然后将其编译为二进制的代码执行。 我们的兴趣点是编译器阶段。 当源代码被编译成二进制文件时,它会按后续顺序经历几个步骤。

编译过程可以分为三个部分。 前端、优化、后端(图 1-a)。 编译过程从前端开始。 首先,预处理器开始组织源代码。 这是外部库扩展到目标源代码的阶段。 在Rust中,该指令由use语句定义。 C和C++中的一个类似指令是#include语句。

图1-a 程序的生命周期

其次,是解析。 在此阶段,代码会被解析,从而发现语法错误,并构建抽象语法树 (AST)。 前端编译的第三步是IR Generation。 在这一步,编译器将AST转换为中间代码(IR)并输出结果。

在优化阶段,编译器对程序执行各种转换和清理。 这提高了程序性能,通过减少各种Bug的产生使程序更安全可靠,同时运行一些辅助程序完成一些工作。 稍后我们将探索IR并查看编译器附带的一些优化过程。

程序优化后进入后端阶段。 在这,Compiler Back-Endd将IR转换为特定于目标的汇编代码。 然后汇编程序将特定于目标的汇编代码转换为特定于目标的机器代码。 最后,Linker将多个机器代码文件组合成一个文件,我们称之为可执行文件。

目前我们已经有几种特定于语言的前端。我们有C和C++的clang,Go有gollvm,而Rust有一个叫做rustc的编译器。 类似地,在完成LLVM IR的优化之后,代码被转换为特定于架构的后端,如 x86、ARM、MIPS。

前端和后端之间的部分称为优化器,是代码优化和清理的神奇之处。 这是通过一系列所谓的Pass 来完成的。 每一个Pass都一个接一个地运行,因此可以有N个Pass。 Pass可以分为两组:分析和转换。 Analysis pass分析IR来检查程序属性,而Transformation pass转换IR来监控和优化程序。

当源代码转换为LLVMIR 时,它可以采用三种格式之一(图 1-b)。内存格式是一种二进制文件,适用于编译过程中的前端。 其他两种格式是Bitcode和Assembly。Bitcode是一种二进制磁盘存储格式,适用于快速加载,因为它具有更高的内存效率。Assembly是一种便于人类阅读的文本格式。

图1-b LLVM IR的格式

你可能想知道为什么要使用IR/BC而不是原生汇编和原生二进制?原因是本机程序集与平台的机器代码一对一绑定。 这取决于目标的体系结构,例如,x86的程序汇编和ARM的汇编会有所不同。 原生汇编通过汇编程序转换为原生二进制文件,当然LLVM也包含此功能。 这是开箱即用的LLVM pass列表。 但这还不是全部,你可以实现你自己的pass来清理或优化源代码。

LLVM的程序结构

LLVM IR,就像任何其他系统一样,它有自己的程序结构(图2-a)。 顶层容器是一个模块,对应前端编译器的每个翻译单元。 模块可以由一个或多个函数组成。 每个函数都有一个或多个基本块,其中是指令。每个指令一行,IR语言中有非常多的指令。

我们可以通过在终端中运行以下命令从Rust源代码中获取.ll(IR 程序集)文件:

 $ rustc someprogram.rs --emit=llvm-ir -o somename.ll 

在这里,我们告诉rustc编译器为someprogram.rs生成llvm-ir(这是由选项--emit=llvm-ir指定)格式,并将其输出(使用选项 -o)作为someoname.ll。 同样,我们可以使用--emit=llvm-bc标志生成位代码。

LLVM IR 中的实际程序结构由分层容器组成。 在顶层,我们有模块。 它对应前端编译器的每个翻译单元。 模块可以与合并函数定义,以供LLVM链接器链接时使用。函数是下一级容器,包括函数签名及其基本块。基本块是一组按顺序执行的指令。 基本块有一个入口和一个出口。 第一个块称为入口块。 最后,IR有指令。 指令是可执行的单行(图 2-a)。

图2-a IR程序结构

在IR文件中你会遇到两种变量,局部变量,用%符号表示,全局变量用@符号表示。LLVM IR可以使用无限数量的临时寄存器,而不是像本地汇编程序那样使用预定义数量的寄存器。 IR的寄存器由整数定义,例如 1,2,3,..N。 例如,%2 = load i32, i32* %x 表示将存储在局部变量x 的地址处的值加载到临时寄存器2中。

LLVM IR的另一个优点是它利用了所谓的静态单赋值(SSA)形式。这允许对代码进行各种优化。 SSA 变量只能分配一次,而不是可以多次重新分配的常规变量。 这允许编译器进行更有效的寄存器分配,因为很容易在任何给定的地方获得还活动的变量的数量。 它可以检测未使用的变量并防止程序进行不必要的指针分配。

例如,SSA形式中的以下代码x = x * x将是x_2 := x_1 * x_1。如果我们进入变量不断获得新值的循环,SSA将使用所谓的Phi节点。 Phi会将变量合并到将从分支或循环返回/输出的最终值中。 我们将在此博文的下一部分中查看具体示例并回顾IR语法。

LLVM和Rustc

Rustc的官方指南列出了他们使用LLVM的所有原因,我就直接引用:

  • 我们不必编写整个编译器后端。 这减少了实施和维护负担。
  • 我们受益于LLVM项目一直在收集的大量高级优化。
  • 我们可以自动将Rust编译到LLVM支持的任何平台。 例如,一旦LLVM添加了对 wasm 的支持,瞧! rustc、clang 和许多其他语言都能够编译为wasm!(好吧,当然还有一些额外的事情要做,但无论如何我们拥有了这90%的完成度)。
  • 我们和其他编译器项目相互受益。 例如,当发现Spectre和Meltdown安全漏洞时,只需要修补LLVM。

现在,当我们熟悉了LLVM IR是什么、它的作用以及为什么有这么多编译器基于它构建时,是时候动手并使用一个名为simple1.rs的简单Rust程序来探索IR的内部结构了:


//simple1.rs

fn square(x: u32) -> u32 {
    x * x
}

fn factorial(mut n: u32) -> u32 {
    let mut acc = 1u32;
    while n > 0 {
        acc *= n;
        n = n - 1;
    }
     acc
}
    
fn main() {
    let s = square(4);
    let f = factorial(s);
}
    

我们的程序有三个函数,main函数是我们程序的入口点,square函数返回给定数字的平方,以及factorial函数提供的整数的阶乘的计算。 我们通过在终端中调用以下命令来输出LLVM IR文件:

 
 >$ rustc simple1.rs --emit=llvm-ir 
 

现在我们应该在与源代码相同的文件夹中出现simple1.ll文件了。 让我们在文本编辑器中打开 .ll文件并开始探索。在文件的开头,你会发现一堆与程序相关的元数据和其他信息和指令,但我们的兴趣是深入研究我们的Rust函数是如何转换为IR的,我们将首先开始看square函数。

搜索带有注释simple1::square的行,在它旁边你会找到一个给定函数的LLVM IR容器。 看起来像这样:


; simple1::square
; Function Attrs: nonlazybind uwtable
define internal i32 @_ZN7simple16square17h74fdb775aec225a0E(i32 %x) unnamed_addr #1 {
start:
  %0 = call { i32, i1 } @llvm.umul.with.overflow.i32(i32 %x, i32 %x)
  %_4.0 = extractvalue { i32, i1 } %0, 0
  %_4.1 = extractvalue { i32, i1 } %0, 1
  %1 = call i1 @llvm.expect.i1(i1 %_4.1, i1 false)
  br i1 %1, label %panic, label %bb1

bb1:                                              ; preds = %start
  ret i32 %_4.0

panic:                                            ; preds = %start
; call core::panicking::panic
  call void @_ZN4core9panicking5panic17h50b51d19800453c0E([0 x i8]* ...
}

IR首先定义具有随机生成的唯一名称(其中还包括原始函数名称)的函数,该函数返回i32值并将 i32值作为参数。start是函数入口点的标签。 在第一条指令中,LLVM IR调用其内在函数 @llvm.umul.with.overflow.i32。 此函数接受两个i32参数(在本例中均为x),执行无符号乘法并输出类似元组的结果,其中第一个值为i32,第二个值为i1。 结果分配给未命名的临时量%0

至此,你可能会有几个问题。首先,为什么square函数参数和返回值都是i32而不是源代码中声明的u32? LLVM IR不为有符号和无符号值提供单独定义的数据类型。 相反,它执行特定于值类型的操作,有符号值的有符号操作,无符号值的无符号操作。 如果我们的square函数将i32作为参数并返回i32,IR将调用@llvm.smul.with.overflow.i32,这是有符号乘法的内在函数。

你可能会问的下一个问题是为什么将整数乘以整数会返回一个元组? 如果查看输出元组的类型,第二项是i1(单个位值),它常用于二进制/布尔值。 被调用函数的名称表明它执行带溢出的无符号乘法。 如果检测到溢出,即乘法值超过32位可以容纳的最大无符号值,因为我们执行了无符号乘法,它将i1设置为1,或者说是true

在以下两条指令中,IR解包乘法的结果。未命名的临时量%_4.0取相乘后的值,并将布尔结果分配给%_4.1。 接下来,IR调用另一个内部函数@llvm.expect.i1(i1 %_4.1, i1 false),它期望分配给未命名临时量%_4.1的值为false。 程序将执行结果分配给另一个未命名的临时量%1。 未命名的临时变量是由IR创建的带有前缀%或@的无符号数值(例如%1)。命名值表示为具有从源代码引入的相同前缀规则的字符串字符(例如 %acc)。

之后,IR执行分支。 br指令检查放置在临时量%1中的值是否为真,如果是,则跳转到%panic,否则跳转到%bb1。上面提到的两个名称是基本块的标签。 %panic调用Rust的panic 方法(如果你用Rust编程过你应该知道 panic 是做什么的),而bb1从函数square返回存储在临时量%_4.0中的值。

让我们在看下我们的factorial函数,这里有更多有趣的事情发生:


; simple1::factorial
; Function Attrs: nonlazybind uwtable
define internal i32 @_ZN7simple19factorial17hceebae2a8a73b808E(i32 %0) unnamed_addr #1 {
start:
  %acc = alloca i32, align 4
  %n = alloca i32, align 4
  store i32 %0, i32* %n, align 4
  store i32 1, i32* %acc, align 4
  br label %bb1

bb1:                                              ; preds = %bb4, %start
  %_3 = load i32, i32* %n, align 4
  %_2 = icmp ugt i32 %_3, 0
  br i1 %_2, label %bb2, label %bb5

bb5:                                              ; preds = %bb1
  %1 = load i32, i32* %acc, align 4
  ret i32 %1

bb2:                                              ; preds = %bb1
  %_4 = load i32, i32* %n, align 4
  %2 = load i32, i32* %acc, align 4
  %3 = call { i32, i1 } @llvm.umul.with.overflow.i32(i32 %2, i32 %_4)
  %_5.0 = extractvalue { i32, i1 } %3, 0
  %_5.1 = extractvalue { i32, i1 } %3, 1
  %4 = call i1 @llvm.expect.i1(i1 %_5.1, i1 false)
  br i1 %4, label %panic, label %bb3

bb3:                                              ; preds = %bb2
  store i32 %_5.0, i32* %acc, align 4
  %_6 = load i32, i32* %n, align 4
  %5 = call { i32, i1 } @llvm.usub.with.overflow.i32(i32 %_6, i32 1)
  %_7.0 = extractvalue { i32, i1 } %5, 0
  %_7.1 = extractvalue { i32, i1 } %5, 1
  %6 = call i1 @llvm.expect.i1(i1 %_7.1, i1 false)
  br i1 %6, label %panic1, label %bb4

panic:                                            ; preds = %bb2
; call core::panicking::panic
  call void @_ZN4core9panicking5panic17h50b51d19800453c0E ...

bb4:                                              ; preds = %bb3
  store i32 %_7.0, i32* %n, align 4
  br label %bb1

panic1:                                           ; preds = %bb3
; call core::panicking::panic
  call void @_ZN4core9panicking5panic17h50b51d19800453c0E  ...
  

回想一下,作为参数,factorial采用可变的u32值,在IR中它被定义为未命名的临时量%0。而函数square的定义以命名变量%n作为参数,就像在源代码中一样。 这是因为square从不修改它的值,而factorial要求它是可变的,然后在函数的作用域内制作参数的副本。 在函数的开头定义了两个命名变量%n%acc。 它们都分配了(使用alloca调用)32位内存,因为数据对齐为4字节。 你可以在此处阅读有关内存对齐的更多信息。

分配内存后,store指令将%0中的内容临时存储在%n的地址中。在%acc的地址中,它存储常量值1。最后,它跳转到while循环的入口点。 bb1标签标志着进入while条件。 IR将%n地址中存储的值加载到临时量%_3中,并将其与常量值0进行比较。icmp检查%_3中存储的值是否无符号大于(ugt)0并将结果分配给另一个临时量%_2。 之后,它检查%_2的内容。 如果为真,则分支跳转到标签%bb2,如果为假-就去标签%bb5

bb5标签标记了while循环的退出块。 在这里,它将%acc地址中的值加载到临时量%1中,并从函数中返回它。 标签bb2是while循环体的入口。 while循环的主体分为两半。 第一个,负责乘法运算(bb2标签),第二个负责递减n,或者说是减法运算。 操作n = n - 1是通过调用另一个用于无符号减法的内部函数来完成的。 其余的操作与我们在square函数中看到的相同(图4-a)。

图4-a 控制流和基本块

你应该已经注意到SSA在此处起作用。对于每条赋值指令,IR都为每条指令引入了新的未命名临时量,而不是像源代码中定义的那样将值重新赋值给nacc。未命名临时量的编号在每个实例生成时在函数内是从0开始递增。

LLVM IR支持二进制数的二进制补码。它具有整数数据类型,如i8、i16、i32、i64和浮点数f16、f32等,它们被称为es操作数。 如果您在整数类型后看到星号符号,则表示我们正在处理指针(例如:i32*)。 当然值也可以是常数。 每条指令也有其类型,例如算术运算符、二进制比较、数据存储和加载。 IR中还有更多的操作,你可以在本节末尾的链接中找到完整列表。

LLVM的类层次结构有点复杂。 基础对象是所谓的Value,用于表示SSA寄存器。 所有其他类都继承自它。 然后是User类,然后是InstructionOperatorContstnt类等(图4-b)。 这是LLVM中完整类继承树

图4-b LLVM类继承

LLVM语言有许多高级结构、类型、常量、函数、代码生成指令等。如果你有兴趣了解更多信息,可以翻阅浏览参考手册

总结

正如我们在本文中所回顾的,LLVM IR有许多用例,允许我们通过它的pass来分析和优化源代码。了解IR语言本身将帮助我们编写我们的pass并围绕它构建项目以进行调试、测试、优化。 目前,LLVM IR没有Rust API。它主要通过作为C++库来使用。 但是,一些用户在crates.io上创建了仓库上用来绑定LLVM。 有一个Rust绑定LLVM C API的实现-llvm-sys和另外两个同样使用LLVM的但是提供更Rust化的API:inkwellllvm-ir。 最后,如果你想学习如何编写 LLVM pass,你应该从这里开始

GitHub 趋势榜

编辑:张汉东

说明:记录一下本月在 GitHub 趋势榜单出现的 新的项目。

排名不分先后


relay: Meta(原facebook) 出品的 js 框架

Relay 是一个 JavaScript 框架,用于构建数据驱动的 React 应用程序。正在用 Rust 重写。

https://github.com/facebook/relay

dioxus : 优雅的类似 React 的前端库

用于为桌面、Web、移动、SSR、liveview 等构建用户界面。

polars: 高性能多线程 DataFrame 库

https://github.com/pola-rs/polars

rathole: 一个用于 NAT 遍历的轻量级、稳定和高性能的反向代理

用 Rust 编写,是 frp 和 ngrok 的替代品。

https://github.com/rapiz1/rathole

nym: 隐私平台

Nym 为复杂的端到端攻击者提供强大的网络级隐私,并使用blinded、rerandomizable、decentralized credentials进行匿名交易。

https://github.com/nymtech/nym

stacks-blockchain: Stacks 2.0 区块链实现

Stacks 2.0 是一个 layer-1 区块链,它连接到比特币以实现安全性,并支持去中心化应用程序和可预测的智能合约。Stacks 2.0 实现以比特币安全为基础的转移证明 (PoX)挖掘。

https://github.com/blockstack/stacks-blockchain

wry : tauri-apps 开发的跨平台 WebView 库

跨平台 WebView 渲染库,支持 Windows、macOS 和 Linux 等所有主要桌面平台。

https://github.com/tauri-apps/wry

czkawka: 用于查找重复项、空文件夹、类似图像等的多功能应用程序

https://github.com/qarmin/czkawka

Crypto Ecosystems : 围绕开源加密货币、区块链和去中心化生态系统共享数据

将它们与 github 组织和代码存储库联系起来。所有的生态系统都在TOML配置文件中指定。

推荐项目 | 基础工具库

聚焦 Rust 生态库


SeaORM 0.4.0 发布

SeaORM 是一个 异步 ORM 框架。0.4 版本升级到了 Rust 2021 Edition。也新增了很多特性。

详情: https://www.sea-ql.org/SeaORM/blog/2021-11-19-whats-new-in-0.4.0/

Zoxide 新版本发布: 现代化的智能 cd 命令

跨平台。它会记住cd进入的目录,因此只需按几下键就可以导航到任何地方。新版本最近添加了 shell 补全。

https://github.com/ajeetdsouza/zoxide

wraft: 使用 Rust 和 WebRTC 为浏览器实现 Raft

这是一个 Raft 共识算法的玩具版实现。

WRaft 使用WebRTC 数据通道来建立浏览器窗口之间的通信。遗憾的是,WebRTC 并不是纯粹的点对点,因此有一个单独的基于 WebSocket 的服务 ( webrtc-introducer),它可以在集群启动之前将浏览器窗口相互“介绍”。浏览器窗口可以在同一台计算机上,也可以在 LAN 上的不同机器上(理论上,或者不同的网络,但我还没有测试过)。Firefox 和 Chrome(或两者的任意组合)似乎可以工作;Safari 似乎无法正常工作。

toql: 一个异步 ORM

目前只支持 MySQL。

https://github.com/roy-ganz/toql

Rust Cuda: 0.2 版本发布

Rust CUDA 项目是一个旨在使Rust成为使用CUDA工具包进行极其快速的GPU计算的一级语言的项目。它提供了将Rust编译为极其快速的 PTX 代码的工具,以及使用现有CUDA库的库。

目前发布了 0.2 版本.

https://github.com/Rust-GPU/Rust-CUDA/releases/tag/0.2

monoio: 基于 io-uring 的异步运行时

Monoio 是字节跳动服务框架组开源的基于 io-uring 的 thread-per-core 模型高性能 Rust Runtime,旨在为高性能网络中间件等场景提供必要的运行时。

与 Golang 不同,Rust 语言中标准库并没有提供异步运行时(Runtime),只提供了必要的结构抽象。Runtime 负责与操作系统打交道,并对齐标准库的 Future 和 Waker 等定义,用户可以自主选择 Runtime。

当前被广泛使用的 Runtime 是 Tokio,它提供了类似 Golang 调度器的实现,用户的 Task 可以在多个线程之间被调度,较为有效地利用了多核心的性能。

但问题也随之而来:在部分强依赖高性能低延迟的场景下,调度带来的开销反而是用户不希望看到的。在核心数较多的情况下,调度开销反而会抵消调度带来的好处。

Nginx 和 Envoy 这类组件往往使用 thread-per-core 模型,即多少核心就运行多少线程,一个任务一旦被一个线程所接收,它后续的处理则都在该线程上。这种做法下几乎没有跨线程的开销,提升了 CPU 利用率,可以较好地保持系统的线性扩展性。此外,由于没有跨线程,处理逻辑也可以尽可能地利用 thread local 的便利,多数时候无需加锁便可操作共享数据。

面向这类场景,Monoio 基于 io-uring 致力于提供最佳的性能;另外,我们还定义了一个更适合 io-uring 的 IO trait。

https://github.com/bytedance/monoio/blob/master/README-zh.md

Hurl 1.5.0:一个运行和测试HTTP请求的命令行工具

Hurl 是一个简单的HTTP命令行工具,建立在libcurl和Rust之上。

Hurl允许运行以纯文本格式定义的HTTP请求。它可以用来获取数据,或者模拟一个场景(请求序列),并在过程中对响应断言。它的文本格式既适合devops,也适合开发人员。

https://github.com/Orange-OpenSource/hurl

amdgpud: AMD GPU tools: 1.0.8 版本发布

该仓库包含几个 AMD 图形相关的工具:

  • amdfand - fan speed daemon
  • amdvold - voltage and overclocking tool
  • amdmond - monitor daemon

目前 1.0.8 版本发布了.

https://github.com/Eraden/amdgpud

yew: 0.19.3 版本发布

Yew是一个现代的Rust框架,用于使用WebAssembly创建多线程前端web应用。

目前 0.19.3 版本已经发布.

https://github.com/yewstack/yew/releases/tag/yew-v0.19.3

Lapce: 用Rust编写的闪电般快速且功能强大的代码编辑器

Lapce完全是用Rust编写的,它的UI使用Druid,它使用Xi编辑器的Rope Science技术进行文本编辑,并使用Wgpu进行渲染。

特性:

  • 作为一等公民的模态编辑(类似Vim)支持(也可以关闭);
  • 内置LSP(语言服务器协议)支持;
  • 内置远程开发支持(受VSCode远程开发启发);
  • 插件可以用任何能编译成WASI格式的编程语言编写;
  • 内置终端;

https://github.com/lapce/lapce

geoping - shodan开发的多地ping工具

从世界各地的多个位置向指定地址发送 ping 请求。

https://gitlab.com/shodan-public/geonet-rs

Goose: 由 locust 框架启发的性能测试框架

熟悉小编的朋友知道小编最近又转到了测试岗位的工作,在进行性能测试的时候由于 Jmeter 多线程模型占用了极大量的资源才能 打出并发量比较高的测试压力,于是找到了 Python 的 Locust(蝗虫)框架,用起来还算顺手,但资源占用对于在办公室里的笔记本电脑来说实在是弱了些。另外,由于 Python 的 GIL 原因,使得Locust 在单机上只能使用单核单进程,要想利用多核只能开启 Locust 的分布式配置

于是,在 @PotatoTooLarge 的指点下,发现 Goose这个 Rust 的框架,它由如下特点:

  • 文档齐全,一如 Rust 社区的作用
  • 基于 Reqwest,可以异步发起请求
  • Locust 报告里有的指标,Goose 的报告里面基本都有
  • 资源消耗极小,适合在没有资源的情况下发起测试压力

但是,该框架仍有以下不足:

  • 没有配置文件(以及profile)
  • 没有 locust 的实时监控 WebUI

YAAR,异步运行时

YAAR,Yet Another Asynchronous Runtime 专注于#![forbid(unsafe_code)]和扩展性,目前这个 crate 正在开发中。

作者决定挑战自己,为 Rust 编写一个不使用unsafe的异步运行时,但仍然会依赖 parking_lot, crossbeam, arc-swap。目标是做出一个#![forbid(unsafe_code)]在性能上与 tokio 有竞争力的异步运行时。这首先是一个研究项目,其次才是一个库。

https://github.com/kprotty/yaar/tree/forbid_unsafe_std3

Spiderfire: JS运行时

Spiderfire 是一个用 Mozilla 的 SpiderMonkey 引擎和 Rust 构建的 JavaScript 运行时,Spiderfire 的目标是颠覆服务器端的 JS 运行环境。

https://github.com/Redfire75369/spiderfire

foundry:以太坊应用程序开发工具包

一个用 Rust 编写的用于以太坊应用程序开发的快速、便携和模块化的工具包。包括:

  • Forge:以太坊测试框架(如 Truffle、Hardhat 和 Dapptools)。
  • Cast:瑞士军刀,用于与 EVM 智能合约交互、发送交易和获取链数据。

特点:

  • 快速、灵活的编译管道
    • Solidity 编译器版本自动检测安装
    • 增量编译和缓存:仅重新编译更改的文件
    • 并行编译
    • 非标准目录结构支持
  • 测试是用 Solidity 编写的
  • 使用收缩输入和打印反例的快速模糊测试
  • 快速远程 RPC forking 模式利用 Rust 的异步基础架构
  • 灵活的 Debug 日志
    • Dapptools 风格,使用 DsTest 发出的日志
    • Hardhat 风格,使用流行的 console.sol 合约
  • 便携 (5-10MB) & 易于安装静态链接的二进制文件,无需 Nix 或任何其他包管理器
  • 抽象的 EVM 实现(目前支持:Sputnik、EvmOdin)

https://github.com/gakonst/foundry

Zetro: 从 schema 中生成高效的 API

作者从微软的一些 API 中获得灵感, 开了一个 可以 从 schema 文件生成 类型化和极其高效的api的库.

https://github.com/muscache/zetro

codasai: 使用 git 的历史记录来创建编程指南

codasai 可以让你使用 git 的历史记录来创建编程指南,这样读者就可以在任何给定的时间点查看程序的状态。

https://github.com/emi2k01/codasai

Robyn 发布 v0.10 版本

Robyn 是一个用 Rust 编写的快速异步 Web Python 框架。 作者从今年 5 月开始编写 Robyn,今天发布了 v0.10.0。 根据他的基准测试,它是最快的 Python 框架之一。

目前已经添加了 WebSockets、MultiCore 扩展(以及更多)等功能!欢迎大家尝试和贡献!

https://github.com/sansyrox/robyn

gitoxide - 一个体验和性能更好的git工具套件

它不但是一个命令行工具,还是一批 crates,你可以基于它开发自己的 git 工具。git 本身可以作为一套版本管理系统或数据库存在,二次开发打开了一道新大门。

https://github.com/Byron/gitoxide

podman-api Rust的podman api

Podman 是一个容器引擎,用于在 Linux 上开发,管理和运行 OCI 容器。

https://github.com/vv9k/podman-api-rs

Hello, youki!

Youki 是一个用Rust编写的底层容器运行时,实现了OCI运行时规范。简单地说,youki是一个可以创建容器的命令行工具。您可能听说过的其他同类运行时包括 runc 和 crrun。当您使用Docker或Podman创建容器时,实际的容器创建被委托给 youki 这样的工具。

https://www.utam0k.jp/en/blog/2021/12/27/youki_first_release/

clap v3.0 发布

clap 3.0,一个 Rust CLI 参数解析器

https://epage.github.io/blog/2021/12/clap3/

SIMD 加速操作

支持 SSE4.1、AVX2、ARM NEON、Aarch64 NEON 和 WASM SIMD128!

比 fast-hex、base64 和 radix64 更快!

pixels v0.9 发布

Pixels 是一个小的硬件加速像素帧缓冲区。它广泛用于模拟器、软件渲染器、2D 像素艺术游戏和桌面实用程序。

0.9 版本带来了一些重大变化。 值得注意的是 wgpu 已更新到 0.12,我们现在需要 2021 版。

Kira, 一个用于游戏开发的音频库

Kira 是用 Rust 编写的用于游戏开发的音频库,并且具有不太常见的功能,例如平滑的补间参数和声音的精确定时,作者正在用它来制作一个动态生成的音乐游戏。

github.com/tesselode/kira

mmids: Rust编写的多媒体收发系统

mmids (multimedia Ingestion and Distribution System)是一个功能强大、用户友好、开源的实时视频工作流服务器。

目前 mmids 能做什么?

  • 通过RTMP 接收音频/视频
  • 提供RTMP 音频/视频服务
  • 从外部源接收音频和视频
  • 直播视频转码
  • 生成视频的HLS流
  • 将实时视频推到外部RTMP服务器。

https://github.com/KallDrexx/mmids/

LLML: Low Level Math Library

具有高级前端和低级后端的基本数学数据类型的实现

  • Complex number
  • Quaternion
  • Affine Transform 2D
  • Vector 2
  • Vector 3
  • Vector 4
  • Matrix 2
  • Matrix 3

https://github.com/Aandreba/llml

Dioxus v0.1 发布

Dioxus 是一个新的 Rust GUI 工具包,用于 Web、桌面、移动、SSR、TUI,关注开发人员体验,有下面几点优势:

  • 熟悉:提供类似 React 的心智模型和 API
  • 健壮:借助 Rust,将规则和错误处理移入类型系统来避免运行时错误
  • 高性能:可扩展到最大的应用程序
  • 高效:全面的文档、快速重新编译和深度集成的工具
  • 可扩展:适用于所有平台的可重用 hook 和组件

https://dioxuslabs.com/blog/introducing-dioxus/

zbus 2.0 发布

简要介绍一下 zbus 是什么:

D-Bus 是一种在 Linux(尤其是桌面和嵌入式系统)上非常流行的进程间通信 (IPC) 机制。 而 zbus 是一个纯粹的 Rust 库,旨在使 D-Bus 处理尽可能简单,许多服务(例如 systemd、NetworkManager、Geoclue 等)都使用它,所以如果你发现自己在 Rust 中与它们交互,你可以使用 zbus。

而大家期待已久的2.0 稳定版发布了! 😎 虽然 1.x 版本很受欢迎,但缺少异步 API。 2.0 使用了全新的设计,将异步 API 作为主要的 API,阻塞 API 只是一个包装器。

docs.rs 链接 zbus book 链接

LibAFL - 高性能 Rust fuzzer

Fuzzing 可以完全自动化地发现对安全至关重要的错误。

而 LibAFL 是从零开始用 Rust 编写的 fuzzing 框架,可以创建几乎所有的模糊测试器。

在谷歌的 fuzzbench 基准测试中,它已经超过了许多著名的覆盖引导模糊器,包括 honggfuzz、libfuzzer/entropic 以及 old-skool afl。最重要的是,LibAFL 可以更好地跨内核和机器扩展。

您还可以使用它在 Windows、Android、macOS 和 LibAFL 上对纯二进制目标进行模糊测试,也完全与 no_std 兼容。

https://github.com/AFLplusplus/LibAFL

git-smart-checkout: git 智能 checkout 工具

https://github.com/craciuncezar/git-smart-checkout/

每月安全公告

来自RustSecGitHub 咨询


12 月

每月安全公告,来自RustSecGitHub 公告。这里的粗体条目与区块链项目特别相关。