Rust语言开源杂志(2021)
The roots aren't deep but the seeds are planted!
为了丰富 Rust 社区成员的学习文化生活而特别推出此刊!
如何贡献?
为月刊做贡献有下面几种方式:
- 修正错误:查看每月文章,如果发现错误,可以直接提交 PR 修正。
- 补充内容:查看当月对应目录下的markdown文件,如果有标题,但内容空缺,欢迎补充,同样是发 PR。
- 新增内容:如果你在当月有想要投稿的文章,可以直接发PR,文章为 markdown 格式,放到当月对应目录下。
PDF 下载
2021 上半年集合 PDF: 下载
如何订阅本刊 RSS ?
-
订阅地址:https://rustmagazine.github.io/rust_magazine_2021/rss.xml
-
复制订阅地址到你最喜欢的订阅工具开始订阅。
发刊渠道
graph TD A[RustMagazine] -->|每月最后一天| B(发刊) B --> C{阅读渠道} C --> |GitHub Page| D[GitHub] C -->|Rustcc| E[Rust中文论坛/公众号] C -->|Rust视界| F[Telegram] C -->|掘金| G[技术社区] C -->|语雀| H[在线文档]
编辑小组
-
张汉东(Chaos)
-
柴杰
-
聂雷海(大海)
-
严炳(ryan)
-
高宪凤
-
杨楚天(yct21)
-
Matrixtang
-
m1zzx2
-
<其他成员招募位> 招募条件见下方
编辑招募条件
- 热爱 Rust 语言
- 有时间参与编辑文章
- 有学习的心态
有意者请联系。
发刊渠道
支持公司和高校
感谢以下公司和高校大力支持 RustMagazine并贡献内容,排名不分先后。
- 华为
- PingCAP
- 蚂蚁集团
- 溪塔科技
- 国汽智控
- 清华大学
- 期待更多
特别感谢
- Rustcc 中文社区
- 《Rust 日报》小组全体成员
- 《Rust 唠嗑室》组织者和全体参与者
许可
本电子杂志采用「署名-非商业性使用-禁止演绎 4.0 国际 (CC BY-NC-ND 4.0)许可协议」进行许可,非商业性转载请注明出处,其他需求请与我们联系。
创刊寄语
作者:张汉东
The roots aren't deep but the seeds are planted!
自从2015
年5
月15
号 Rust 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
中文精选》将是永久开源和非商业化的。
《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
在云原生领域的实践。 - 前端开发。 分享
Rust
和WebAssembly
在前端的应用实践。 - 图形化开发。分享
Rust
在 图形化开发方面的实践。 - 大数据/人工智能。分享
Rust
在 大数据和人工智能开发方面的实践。 - Rust 算法。用
Rust
刷算法是什么体验?
希望大家能踊跃投稿和参与编辑。可以直接给本刊GitHub
仓库发PR
,哪怕不一定能被入选,也可以发出你的声音!每个月一期,意味着你每个月精下心来创作一份精品文章,用来总结你在公司的实践和学习Rust
的心得。
也希望越来越多的公司参与到期刊内容建设中来,对公司而言,不仅仅是经验分享,更是一种技术文化输出。
在这浮躁的年代,希望这份期刊能帮助你找回技术的初心和野望。
一月刊
本月社区动态简报
精选自《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-darwin
和aarch64-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::description
和Error::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
类型只实现了Display
和Debug
特征。这意味着使用回溯类型的唯一方法是打印出来,这不是很理想。一个能够提供迭代堆栈框架的迭代器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
操作以“nonRust
”、“nonC-unwind
”ABI到达Rust
函数边界时,我们为之前未定义的有限几种情况定义了行为。作为该规范的一部分,我们引入了术语“Plain Old Frame”(POF)。POF帧不会挂起析构函数,可以轻松地释放析构函数。这个RFC没有定义被外部异常展开的Rust框架中的catch unwind行为。
引入动机:
有些Rust项目需要跨语言展开以提供所需的功能。 一个主要的例子是
Wasm
解释器,包括Lucet
和Wasmer
项目。还有一些现有的
Rust
crate(尤其是围绕libpng和libjpeg C库的包装器)会在C
帧之间出现混乱。 这种展开的安全性取决于Rust的展开机制与GCC
,LLVM
和MSVC
中的本机异常机制之间的兼容性。 尽管使用了兼容的展开机制,但是当前的rustc
实现假定“externC
”函数无法展开,这允许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
回车运行编译,非常棒的使用体验。
本月简报 | 社区热点
- 来源:Rust日报
- 作者:Rust 日报小组
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表现还不错。
- 原文链接: https://www.boringcactus.com/2020/08/21/survey-of-rust-gui-libraries.html
- AreWeGuiYet 网站也可以看到 GUI 相关信息:https://www.areweguiyet.com/
- 另一篇
GUI
调研文章
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日报
- 作者:
Rust
日报小组
「微软」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 程序。
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:让你的平板电脑用作电脑上的图形平板/触摸屏
特点:
- 用平板电脑控制鼠标
- 将屏幕镜像到平板电脑上
上述功能在所有操作系统上都可以使用,但Weylus
在Linux
上效果最好。Linux
上的其他功能有:
-
支持手写笔/笔(支持压力和倾斜)。
-
多点触控。用支持多点触控的软件试试,- 比如Krita,你就会知道了。
-
捕捉特定的窗口,并只对其进行绘制。
-
更快的屏幕镜像
-
硬件加速视频编码
-
平板电脑作为第二屏幕
本月简报:学习资源
- 来源:Rust日报
- 作者:Rust 日报小组
🎈Rust Design Patterns Book
非官方好书系列, 再次安利! Rust Design Patterns Book. 作者最近更新了很多东西。
看下翻译的中文引言吧。
引言
设计模式
在开发程序中,我们必须解决许多问题。一个程序可以看作是一个问题的解决方案。它也可以被看作是许多不同问题的解决方案的集合。所有这些解决方案共同解决一个更大的问题。
在Rust中的设计模式
有许多问题的形式是相同的,由于事实上,rust不是面向对象设计,模式不同于其他面向对象程序设计语言,虽然细节是不同的,因为他们有相同的形式,他们可以解决使用相同的基本方法。
设计模式是解决编写软件时常见问题的方法。
反模式是解决这些相同问题的方法。
然而,尽管设计模式给我们带来了好处,反模式却带来了更多的问题。
惯用法,是编码是要遵守的指南,他们是社区的社区规范,你可以破他们,但如果你这样做,你应该有一个很好的理由。
TODO: 说明为什么Rust是一个有点特殊功能要素,类型系统,借用检查。
🎈异步书翻译更新啦
这次翻译新增了第八章-关于生态的叙述(@EthanYuan) 以及第九章http服务器项目(@huangjj27), 欢迎来指正错误或贡献~
🎈Manning的Rust新书《Refactoring to Rust》
这本书正在MEAP阶段,目前才更新了3章,感兴趣的同学可以看看。
🎈Rust 书籍宝库
glynnormington整理了网络上大部分有关rust的mdbook,有官方的,也有非官方的。值得注意的一点是大家关注的rust宏小册很多人以为一直没有更新,但是其实有另一个团队重新在原来的基础上,更新了新的版本,目前已收录到该书库中。
🎈使用Rust 编写一门语言
有关使用Rust编程语言制作称为Eldiro的编程语言的系列文章。
Rust 错误处理: python 同学专用
本文是python同学专用,介绍了python日常中的错误处理以及如何在rust中达到类似效果和最佳实践。
🎈其他语言调用Rust - C++
作者选择Rust作为运行时库的实现语言,并且希望使同一库可用于不同的编程语言。
最初,选择从对三种语言的支持开始:
- Rust:因为这是我们的实现语言。
- C ++:这是我们熟悉的低级语言,仍然是嵌入式设备领域中最成熟的语言之一。
- JavaScript / TypeScript:因为它是一种非常流行的动态语言。
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代码中实现一些特性。
接下来,你需要编写消耗FFI
的Kotlin
代码,通常是在./android/
目录下。这段代码应该使用JNA
通过共享库加载编译后的Rust
代码,并将其作为一个漂亮的安全且易于使用的Kotlin API
暴露出来。
似乎我们很可能在这里提供一个有用的模板来让你入门。但我们还没有这样做。
最后,将你的包添加到android-components repo
中。
文章还回答了一些导出过程中的问题。
入门教程:用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
- 翻译链接: https://mp.weixin.qq.com/s/OL7_usSmY_gAZzYYydyr8A
- 原文链接:https://os.phil-opp.com/async-await/#multitasking
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。
ref vs & in variables
帖子讨论了ref和&的使用,哪个使用更好。
在Rust中包装错误
在开发时错误处理是必须,有时错误处理非常糟糕,文章中提高了warp Error提高体验。
本月简报 | Rust 唠嗑室本月汇总
- 来源:Rust 唠嗑室
- 主持人:MikeTang
《Rust 唠嗑室》第 16 期 - tensorbase 高性能数据仓库
时间: 2021/01/05 20:30-21:30
主讲人:金明剑
内容:金明剑老师在 RustChinaConf2020 上分享了《基于 Rust 构建高性能新型开源数据仓库》,很多人感兴趣 Tensorbase 的技术内幕,这次唠嗑室一起来聊 Tensorbase。
扩展资料:
《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 扩展。
扩展资料:
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所示,以JavaScriptCore,V8,Wasmer三个引擎为例,他们均实现了用户可以自由选择后端的操作或者在运行时自动在不同后端间切换的方式,使得可以支持编译优化从低优化级别切换到高优化级别,并且经过未经优化代码的时候,再切换回去的操作。
这里动态优化的流程是我们通过不断Profile,追踪运行状态,去编译优化等级更高的代码,同时编译开销变大,也会做deoptimize操作,当优化的代码做一些错误的假设时,我们就需要回滚。
而用来实现动态切换优化级别的主要技术是OSR技术 ,即栈上替换(on-stack replacement).
让我们来看看OSR技术的简易流程。如图2所示,调用栈出现了左边的假想情况时,函数Baz代码优化从解释执行提升到 jit级别1 时, 运行时就会触发函数baz的编译,一旦编译完成,则会发生调用栈的重构,使得 原调用栈中所有函数Baz的记录映射到Jit级别1 的堆栈结构上,使得在原来状态基础之上,以Jit级别1的机器码上继续运行。代价 是 提升了计算的复杂度。
我曾经的一个工作, 在wasmer中实现的OSR技术。 OSR入口动态加载Image,在OSR退出的时候把image提取出来(从调用栈到wasm抽象表示,回到另一种优化等级的wasmer调用栈内的结构。(8:39)
图3则是我当时项目benchmark的表现。在图3中,singlepass是我编写的编译最快,运行最慢的后端。llvm是优化等级最高的后端。红线为使用LLVM后端的性能曲线,蓝线为前面2s左右使用singlepass后端,后面使用llvm后端的性能曲线。
如果我们直接用llvm编译的话,我们就需要在程序执行之前,在测试程序中等待2s左右。如果我们引入动态切换机制,在程序启动时可以先使用编译快,但执行满的引擎去做执行,当优化等级高的编译器准备好之后,就动态地切换执行流,得到二者的平衡。红线和蓝线后面没有重合,只是由于我们在蓝线上针对一些做了一些额外操作,性能理论上还是一样的。
**我要介绍的第二钟动态优化技术 是 内联缓存inline caching。**我了解到有两种典型的用例。
-
一些动态语言中的method lookup (方法查找)
for (let x of list){ document.write(x); // method lookup }
其中write函数是可以被动态重写的,但是这种情况发生的概率非常小,所以说我们在运行时可以假设它不变,去编译生成机器码。当假设不成立的时候,回滚。本来需要从哈希表中查找该方法,对缓存不友好,运行速度慢。
所以我们可以直接对该指令映射为一个缓存槽(slot),把write函数对应的某些标记和write函数地址写入,检查运行条件是否符合,符合就可以直接执行,避免哈希表查找的开销,否则进行回滚。
-
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展示了访存指令的快速路径,可以看见我们分配了关于上界和下界的两个缓存槽。检查目标虚拟地址是否位于预期界限当中,如果在范围内,就直接加载,不用回滚到解释执行了。否则就走慢速路径,执行查表处理。
图6展示了访存指令的慢速路径。当发生load/store miss
时,我们就会针对地址addr进行查表, 检查读写权限和相关信息,如果可以的话就将其写入缓存槽内,下次就可以快速执行。
接下来我来介绍有关内存安全方面的内容。
我们知道rust作为一个以安全性著称的语言,保证safe代码内存安全。所以我们就需要在运行时通过动态的机制确保内存安全。
我以空指针检查和访问越界检查为例来介绍Jit如何确保内存安全。
①空指针检查:
比如在java,c#这类有空指针的语言中,我们会遇到一个很常见的情况。当引用为空的时候,我们不应该对它解引用并且成功。我们应该检查它是否为空,如果为空,应该产生异常而非解引用。一个显而易见的方法是if (a == null){ throw Exception(...)}
,但这样开销很大。如同下面代码所展示的,在mov
指令前需要插入cmp
和je
指令,就会增加额外的分支预测的开销。
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实现.
②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中的地址,而lfence
,jmp L1
指令会导致一个死循环。
④CPU发现内存栈上的返回地址和RSB投机地址不同,所以投机执行终止,跳转到*%rax
这样Retpoline机制就避免了CPU的投机执行。
但是在 linux内核中我们发现,有很多pattern的间接调用目标是一定的,比如虚表所以我们会把它装化成两次直接调用,第二次直接调用代码使用jit重写,如图7 _trampoline
所示, 这样我们消除了spectre v2的可能性, 而且也减少了间接调用的开销(因为使用了直接调用)
在我的项目中是否应该使用jit?
如图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目标语言提高执行效率。
参考文章:
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。
- 区块链 wasm JIT 虚拟机:基于 cranelift/wasmtime;
- 智能合约开发库和配套的工具链:目前合约开发都首选 Rust,开发效率高,迭代速度快;
- 密码学库;
同步任务多线程池
为了讲解异步编程模型,我们先来看一看大家都比较熟悉的同步任务多线程池的实现,一个比较典型的实现如 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
在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 分钟讲不完,所以在这里先跳过。下面就是整个异步任务多线程的模型图:
首先用户通过 spawn
函数把异步任务 push 到全局队列里去,然后工作线程会拿到 task 执行,并且创建一个 Waker
,传给执行的 Future
,如果任务执行完成了,那就
ok 了;如果没执行完成,Future
负责把 Waker
注册到 Reactor
上面,Reactor
负责监听事件,收到事件后会把 Waker
唤醒,把 task
放到全局队列中,这样下次其他线程可以拿到这个 task 继续执行,这样循环重复直到任务执行完毕。
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
,因为 Future
在 poll
的时候只是传了一个 Waker
,现在要把 Waker
注册到多个 Reactor
上,就需要 clone
。然后
Executor
和 Waker
可能不在一个线程里面,Waker
需要跨线程发送到 Reactor
上面,所以也就需要一个 Send
的约束。最后多个事件源可能同时调用这个 Waker
,这里就存在并发调用的问题,要满足并发调用的话就需要实现Sync
约束。这是对 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
来构造 Waker
,RawWaker
有个数据字段,和一个静态的虚表,不同的 Executor
就是要把这些虚表中的方法全部实现,
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
如果用 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
如果poll
的时候没有好的话,它负责把 Waker
注册到 Reactor
里去,这里面会有一个 Waker
过期的问题。第一次调用 poll
和第二次调用
poll
时,Executor
传的 Waker
可能不是同一个,只有最新的 Waker
能把 task 唤醒,老的 Waker
就唤不醒,这样导致的问题是每次 poll
的时候都要把 waker
更新到 Reactor
里,以确保能够唤醒 task。
比如上图中的例子,Future
同时对两个事件感兴趣,对应着两个 Reactor
。Future
在 poll
的时候需要向 Reactor1 注册 waker
,也要向
Reactor2 注册 waker
,当它下次 poll
的时候每次都要把两个 waker
更新,那么现在问题来了,Future
的 poll
执行在 Executor
线程,Reactor
执行在 Reactor
线程,一个线程往里面写,另一个线程试图从里面读,并发问题就出现了。为了处理这个问题,最简单的方式就是加一把锁,每个 Reactor
都要加锁解锁,这个操作本身就比较复杂,比较耗时。
AtomicWaker
完美处理了这个问题,它通过单生产者多消费者的模式,将 waker
放到 AtomicWaker
里面,AtomicWaker
被多个 Reactor
共享,Waker
只需要更新一次,所有 Reactor
就能拿到最新的 waker
。
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 组合的效率
上面的 Future
组合模型涉及到一个 JoinN
组合的效率问题,问题是怎么产生的呢?waker
只用于唤醒整个task,但是没有携带任何唤醒信息,比如 task
是怎么被唤醒的。JoinN
负责把多个 Future
组合在一起同时并发的执行,Join4
把 4 个 Future
组合,每次 poll
的时候挨个去执行子 Future
,如果没有好的话就会注册到 Reactor
里面,假设第二个突然就好了,下一次 poll
时,Join4
并不知道自己为什么被唤醒了,只能挨个再遍历一遍 Future
,但其实第一、三、四都是浪费掉的。
怎么解决这个问题呢?futures-rs
里面有一个 FuturesUnordered
专门处理这个事情,可以管理成千上万个子 Future
,它内置了一个并发队列,维护已经
ready 的子 Future
。当 Executor
在 poll
整个任务的时候,它只遍历并发队列,挨个拿出来执行,执行的时候并不是把 waker
原封不动的传下去,而是进行了一次包装拦截:wake
调用的时候,它会先把 Future
添加到自己的ready队列里面去,再去通知Executor
的全局队列,Executor
下次再
poll
的时候直接从内置的并发队列去执行 Future
,这样能达到效率最大化。
异步任务之间的同步
传统多个线程之间也有同步的需求,比如说锁。异步任务之间也不可能是完全隔离的,它们之间可能做一些消息的交互,我们比较一下线程和 Task 之间的区别:
线程 | Task | |
---|---|---|
睡眠 | thread::park | return Pending |
唤醒 | thread::unpark | Waker::wake |
获取方式 | thread::current() | poll的参数 |
线程如果想暂停工作可以调用 thread::park
,task想暂停工作可以直接 return Pending
;线程可以通过 thread::unpark
唤醒,task
需要调用 Waker::wake
;获取方式上,线程直接调用 thread::current
,task 是通过 poll
的参数拿到 waker
。
异步任务之间的同步 Mutex
Mutex
数据结构里面有一个数据字段,表示要锁的数据,一个 locked
原子变量表示有没有被锁住,还有一个等待队列,异步任务想拿锁却没有拿到,它就只能进入等待队列里面,等着别人去通知它。先看一下拿锁的过程,如果 waker
拿到锁之前 locked
是
false,表示拿锁成功了,如果没拿到失败了的话,就只能等,把 waker
丢到等待队列里。拿到锁的任务想释放这把锁的时候,把 locked
改成 false,并从等待队列中拿一个
waker
出来,去唤醒相应的task。
这里跟大家讲一个很多人误区的地方,很多人认为异步任务里面是必须要用异步锁的,同步锁有阻塞就不行,这是不对的。大部分的等待队列的实现都是用了同步锁,也就是说 Mutex
也不是完全异步的,它本身有个同步锁在里面。如果你在应用里面只是想保护一段数据,对共享的数据做点加减操作,那么应该用 std
里面的同步锁,因为用异步锁的话,更新内部的等待队列需要加同步锁,这个开销可能比你直接用同步锁更新共享数据还要复杂很多。
那么什么时候用异步锁呢?在保护 IO 资源的时候,当你的锁需要跨越多个 .await
,时间差的比较大的时候,那应该优先使用异步锁。
异步任务之间的同步 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
存在 Oneshot
的 RxWaker
里面,同时也更新相应的 state
,表示接收端的 RxWaker
已经存在。接收端在 drop
的时候,也要通知
sender
,表示“我现在对你的数据没有兴趣了,你可以不用继续计算下去",所以接受端在 drop 的时候也要修改 state
,从 Oneshot
里面拿到发送端的
TxWaker
,把发送端唤醒。
异步任务之间的同步 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,它并不需要去唤醒 Waker
,WaitGroup
只关心所有任务都结束了,只需要让最后一个 worker
去唤醒
waker
。什么时候是最后一个 worker
呢?我们可以借用标准库里的 Arc
,Arc
是一个共享引用,当所有的 Arc
强引用都销毁的时候,就会析构内部的数据,只要在 Arc
包装的数据的 drop
方法里面把 waker
唤醒就可以了。
WaitGroup
持有一个弱引用,所有的 Worker
都持有强引用,WaitGroup
在 poll
的时候试图把弱引用升级成强引用,如果升级失败了,说明所有的强引用都没了,也就是任务都执行完了,就可以返回 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这样以安全为中心的应用程序的支持(库和其他)有多好?
对于实现安全软件的大部分基础组件来说,那是绰绰有余的。有两个大型的、突出的密码学平台( ring 和Rust 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
采用了微服务架构,因此也采用了 Protobuf
和 gRPC
的组合。
但是因为 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
年分布式系统架构设计,编程语言和虚拟机方面工作经验。
-
Blog : https://rink1969.github.io
-
GitHub : https://github.com/rink1969
-
为国产自主云原生区块链
CITA-Cloud
点赞https://github.com/cita-cloud/cita_cloud_proto
后期编辑:
丁 烁(Jarvib Ding),Rust 爱好者。
建造者模式(Builder)
概述
构建者模式是一种设计模式,提供一种灵活的解决方案,已解决面向对象程序设计中的各种对象创建问题。Builder设计模式的目的是将复杂对象的构造与其表示分离开来。是"是四人帮"设计模式之一[wiki]。建造者模式是一种创建型设计模式,使你能够分步骤创建复杂对象。该模式允许你使用相同的创建代码生成不同类型和形式的对象。
定义:Builder设计模式的目的是将复杂对象的构造与其表示分离开来。通过这样做,同样的构造过程可以创建不同的表示。
历史
假如有一个复杂的对象,需要对其进行构造时需要对诸多成员变量和嵌套对象进行繁杂的初始化工作。有时这些初始化代码通常深藏于一个包含众多参数且让人看不懂的构造函数中;或者这些代码散落在客户端代码的多个位置。
- 例如,创建一个房子,不同种类的房子有不同的风格,为每一种类型的房子创建一个子类,这可能会导致程序变得过于复杂。
- 或者无需生成子类,但是需要创建一个包括所有可能参数的超级构造函数,并用它来控制房屋对象的创建。这样虽然可以避免生成子类,但是会造成当拥有大量输入参数的构造函数不是每次都要全部用上。通常情况下,绝大部分的参数都没有使用,这使得对于构造函数的调用十分不简洁。
建造者模式 的使用
建造者模式建议将对象构造的代码从产品类中抽取出来,并将其放在一个名为生成器的独立对象中。每次创建对象时,都需要通过生成器对象执行一系列步骤。重点在于无需调用所有步骤,而只需调用创建特定对象配置所需的那些步骤。
适用场景
- 使用建造者设计模式可以避免“重叠构造函数”的出现。
- 假设复杂函数中有十几个可选参数,那么调用这些函数会非常不方便,因此需要重载这个构造函数,新建几个只有较少参数的简化版本。
- 建造者设计模式让你可以分步骤生成对象,而且允许你仅适用必须的步骤。
- 当使用代码创建不同形式的产品时,可使用生成器模式
- 如果你需要创建各种形式的产品,他们的制造过程相似且仅有细节上的差异,此时可使用生成器模式。
- 基本生成器接口中定义了所有可能的制造步骤,具体生成器将实现这些步骤来制造特定形式的产品。
- 使用构造者模式构造其他复杂对象
- 构造者模式让你能分步骤构造产品,你可以延迟执行某些步骤而不会影响最终产品。
优点
- 可以分步骤创建对象,暂缓创建步骤或者递归运行创建步骤。
- 生成不同形式的产品,你可以复用相同的制造代码
- 单一职责原则,可以将复杂构造代码从产品的业务逻辑中分离出来。
缺点
由于该模式需要新增多个类,因此代码整体复杂程度会有所增加。
描述
通过使用构建者助手创建一个对象。
例子
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()样式。
参见
- Description in the style guide
- derive_builder, a crate for automatically implementing this pattern while avoiding the boilerplate.
- Constructor pattern for when construction is simpler.
- Builder pattern (wikipedia)
- Construction of complex values
- Rust编程之道 ch7,p234
项目中的使用
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 拿到相关结构的内存访问权限,这样就能让内核态与用户态共享内存,绕过系统调用双向传递数据。
概念工作流程具有三个阶段
- 准备:应用程序获取一些提交队列项 SQE (Submission Queue Entry),将每个异步任务分别设置到每个 SQE 中,用操作码、参数初始化。
- 提交:应用程序向 SQ 中推入一些需要提交的 SQE,通过一次系统调用告诉内核有新的任务,或者让内核不停轮询来获取任务。
- 收割:应用程序从 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 的设计思路是其中一种可能的方向。社区还需要探索什么才是最完美的设计。
扩展阅读
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等脚本语言才提供的高级语法,让我产生给它做一些力所能及的贡献的想法,nvg和Xactor是刚学Rust不久之后的小试牛刀,而Async-graphql是Rust 1.39异步稳定之后的产物。
学习Rust的过程很艰辛,需要保持一颗修行的心,当你能够越过那一座座阻碍在面前的高山,也许才能够发现它真正的美好。
目录
在今天的文章中,我将描述如何使用Rust及其生态系统创建GraphQL后端服务。 本文提供了创建GraphQL API时最常见任务的实现示例。最后,将使用Apollo Server和Apollo Federation将三个微服务组合为一个端点。 这使客户端可以同时从任意数量的源中获取数据,而无需知道哪些数据来自哪个源。
介绍
概览
在功能方面,所描述的项目与我上一篇文章中所描述的非常相似,但是现在它是使用Rust编写的。 该项目的架构如下所示:
架构的每个组件都回答了在实现GraphQL API时可能出现的几个问题。整个模型包括有关太阳系中的行星及其卫星的数据。该项目具有多模块结构,并包含以下模块:
-
planets-service (Rust)
-
satellites-service (Rust)
-
auth-service (Rust)
-
apollo-server (JS)
在Rust中有两个库来创建GraphQL后端:Juniper和Async-graphql,但是只有后者支持Apollo Federation,因此我在项目中选择了它(Juniper中的Federation支持存在未解决的问题)。 这两个库都遵循代码优先方法。
同样,PostgreSQL用于持久层实现,JWT用于认证,而Kafka用于消息传递。
技术栈
下表总结了该项目中使用的主要技术栈:
类型 | 名字 | 网站 | 代码仓库 |
---|---|---|---|
语言 | Rust | link | link |
GraphQL服务端库 | Async-graphql | link | link |
GraphQL网关 | Apollo Server | link | link |
Web框架 | Actix-web | link | link |
数据库 | PostgreSQL | link | link |
消息队列 | Apache Kafka | link | link |
容器编排 | Docker Compose | link | link |
另外还有一些需要依赖的Rust库:
类型 | 名字 | 网站 | 代码仓库 |
---|---|---|---|
ORM | Diesel | link | link |
Kafka客户端 | rust-rdkafka | link | link |
密码哈希库 | argonautica | link | link |
JWT | jsonwebtoken | link | link |
测试 | Testcontainers-rs | link | link |
开发工具
要在本地启动项目,你只需要Docker Compose
。 如果没有Docker
,可能需要安装以下内容:
- Rust
- Diesel CLI (运行
cargo install diesel_cli --no-default-features --features postgres
) - LLVM(
argonautica
依赖) - CMake (
rust-rdkafka
依赖) - PostgreSQL
- Apache Kafka
- npm
实现
清单1. 根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 IDEcreate_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_planets
和get_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问题
如果Planet
的details
函数的实现是直接从数据库中查询对应id
的planet
对象则将导致N+1问题,如果你发出这样的请求:
清单8: 可能消耗过多资源的GraphQL请求的示例
{
getPlanets {
name
details {
meanRadius
}
}
}
这将对每个plant
对象的details
字段执行单独的SQL查询,因为details
是与planet
关联的类型,并存储在其自己的表中。
但借助Async-graphql
的DataLoader实现,可以将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_planet
受RoleGuard
保护,可确保只有具有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尝试执行具有相当深度和复杂度的自省查询。
认证
使用argonautica
和jsonwebtoken
库在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-service
向Planet
对象扩展了satellites
字段:
清单 35. satellites-service
中Plant
对象的扩展
#![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 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
创建的通知。
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上。如果你发现文章或源代码中有任何错误,欢迎联系我。谢谢阅读!
有用的链接
- graphql.org
- spec.graphql.org
- graphql.org/learn/best-practices
- howtographql.com
- Async-graphql
- Async-graphql使用手册
- Awesome GraphQL
- Public GraphQL APIs
- Apollo Federation demo
图解 Rust 所有权与生命周期
作者:肖猛
后期编辑:高宪凤
作者简介:
肖猛
二十年从桌面到云端到嵌入式的软件架构经验,跨通讯、游戏、金融、智能网联汽车多个行业,领域系统分析专家、全栈软件架构专家。
目前致力于智能驾驶基础软件开发。历任吉利亿咖通自动驾驶软件平台总监,国汽智控自动驾驶软件研发总监。对在汽车领域推广 Rust 技术栈有浓厚兴趣,并有实际的量产实践。
1.引言
所有权与生命周期是 Rust
语言非常核心的内容。其实不仅仅是 Rust
有这两个概念,在C/C++
中也一样是存在的。而几乎所有的内存安全问题也源于对所有权和生命周期的错误使用。只要是不采用垃圾回收来管理内存的程序语言,都会有这个问题。只是 Rust
在语言级明确了这两个概念,并提供了相关的语言特性让用户可以显式控制所有权的转移与生命周期的声明。同时编译器会对各种错误使用进行检查,提高了程序的内存安全性。
所有权和生命周期其涉及的语言概念很多,本文主要是对梳理出与“所有权与生命周期”相关的概念,并使用 UML
的类图表达概念间的关系,帮助更好的理解和掌握。
图例说明
本文附图都是 UML
类图,UML
类图可以用来表示对概念的分析。表达概念之间的依赖、继承、聚合、组成等关系。图中的每一个矩形框都是一个语义概念,有的是抽象的语言概念,有的是 Rust
库中的结构和 Trait
。
所有图中使用的符号也只有最基础的几个。图 1 对符号体系做简单说明,主要解释一下表达概念之间的关系的符号语言。
依赖关系:
依赖是 UML
中最基础的关系语义。 以带箭头的虚线表示,A
依赖与 B
表达如下图。直观理解可以是 A
“看的见” B
,而 B
可以对 A
一无所知。比如在代码中 结构体 A
中有 结构体 B
的成员变量,或者 A
的实现代码中有 B
的局部变量。这样如果找不到 B
,A
是无法编译通过的。
关联关系:
一条实线连接表示两个类型直接有关联,有箭头表示单向"可见",无箭头表示相互之间可见。关联关系也是一种依赖,但是更具体。有时候两个类型之间的关联关系太复杂,需要用一个类型来表达,叫做关联类型,如例图中的 H
.
聚合与组成:
聚合与组成都是表示的是整体和部分的关系。差别在于“聚合”的整体与部分可以分开,部分可以在多个整体之间共享。而“组成”关系中整体对部分有更强的独占性,部分不能被拆开,部分与整体有相同的生命周期。
继承与接口实现:
继承与接口实现都是一种泛化关系,C
继承自 A
,表示 A
是更泛化的概念。UML
中各种关系语义也可以用 UML
自身来表达,如图 2:“关联”和“继承”都是“依赖”的具体体现方式。
总图
图 3 是本文的总图,后续各节分局部介绍。
2.所有权与生命周期期望解决的问题
我们从图中间部分开始看起,所谓“所有权”是指对一个变量拥有了一块“内存区域”。这个内存区域,可以在堆上,可以在栈上,也可以在代码段,还有些内存地址是直接用于 I/O
地址映射的。这些都是内存区域可能存在的位置。
在高级语言中,这个内存位置要在程序中要能被访问,必然就会与一个或多个变量建立关联关系(低级语言如汇编语言,可以直接访问内存地址)。也就是说,通过这一个或多个变量,就能访问这个内存地址。
这就引出三个问题:
- 内存的不正确访问引发的内存安全问题
- 由于多个变量指向同一块内存区域导致的数据一致性问题
- 由于变量在多个线程中传递,导致的数据竞争的问题
由第一个问题引发的内存安全问题一般有 5 个典型情况:
- 使用未初始化的内存
- 对空指针解引用
- 悬垂指针(使用已经被释放的内存)
- 缓冲区溢出
- 非法释放内存(释放未分配的指针或重复释放指针)
这些问题在 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
关键字是绑定, 没有就是赋值。
这里我们能看出 Rust
与 C++
的一个不同之处。C++
里是没有“绑定”概念的。Rust
的变量绑定概念是一个很关键的概念,它是所有权的起点。有了明确的绑定才有了所有权的归属,同时解绑定的时机也确定了资源释放的时机。
所有权规则:
- 每一个值都有其所有者变量
- 同一时间所有者变量只能有一个
- 所有者离开作用域,值被丢弃(释放/析构)
作为所有者,它有如下权利:
- 控制资源的释放
- 出借所有权
- 转移所有权
4.所有权的转移
所有者的重要权利之一就是“转移所有权”。这引申出三个问题:
- 为什么要转移?
- 什么时候转移?
- 什么方式转移?
相关的语言概念如下图。
为什么要转移所有权? 我们知道,C/C++/Rust 的变量关联了某个内存区域,但变量总会在表达式中进行操作再赋值给另一个变量,或者在函数间传递。实际上期望被传递的是变量绑定的内存区域的内容,如果这块内存区域比较大,复制内存数据到给新的变量就是开销很大的操作。所以需要把所有权转移给新的变量,同时当前变量放弃所有权。所以归根结底,转移所有权还是为了性能。
所有权转移的时机总结下来有以下两种情况:
- 位置表达式出现在值上下文时转移所有权
- 变量跨作用域传递时转移所有权
第一条规则是一个精确的学术表达,涉及到位置表达式,值表达式,位置上下文,值上下文等语言概念。它的简单理解就是各种各样的赋值行为。能明确指向某一个内存区域位置的表达式是位置表达式,其它的都是值表达式。各种带有赋值语义的操作的左侧是位置上下文,右侧是值上下文。
当位置表达式出现在值上下文时,其程序语义就是要把这边位置表达式所指向的数据赋给新的变量,所有权发生转移。
第二条规则是“变量跨作用域时转移所有权”。
图上列举出了几种常见的跨作用域行为,能涵盖大多数情况,也有简单的示例代码
- 变量被花括号内使用
- 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
的内存空间的释放,fun1
和 fun2
之间需要有个商量,由谁来释放。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
拥有所有权的变量借出其所有权有“引用”和“智能指针”两种方式:
-
引用(包含可变借用和不可变借用)
-
智能指针
- 独占式智能指针
Box<T>
- 非线程安全的引用计数智能指针
Rc<T>
- 线程安全的引用计数智能指针
Arc<T>
- 弱指针
Weak<T>
- 独占式智能指针
引用实际上也是指针,指向的是实际的内存位置。
借用有两个重要的安全规则:
- 代表借用的变量,其生命周期不能比被借用的变量(所有者)的生命周期长
- 同一个变量的可变借用只能有一个
第一条规则就是确保不出现“悬垂指针”的内存安全问题。如果这条规则被违反,例如:变量 a
拥有存储区域的所有权,变量 b
是 a
的某种借用形式,如果 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
的核心包中有两个泛型 trait
,core::borrow::Borrow 与 core::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)->&T
是 fn borrow(self: &Self)->&T
的缩写,所以 self
的类型就是 &T
,可以直接被返回。在 Borrow for &T
的实现中,fn borrow(&self)->&T
是 fn borrow(self: &Self)->&T
的缩写,所以 self
的类型就是 &&T
, 需要被两次解引用得到 T
, 再返回其引用。
智能指针 Box<T>
,Rc<T>
,Arc<T>
,都实现了 Borrow<T>
,其获取 &T
实例的方式都是两次解引用在取引用。Weak<T>
没有实现 Borrow<T>
, 它需要升级成 Rc<T>
才能获取数据。
6.生命周期参数
变量的生命周期主要跟变量的作用域有关,在大部分程序语言中都是隐式定义的。Rust
中能显式声明变量的生命周期参数,这是非常独特的设计,其语法特性在其他语言也是不太可能见到的。以下是生命周期概念相关的图示。
生命周期参数的作用
生命周期参数的核心作用就是解决悬垂指针问题。就是让编译器帮助检查变量的生命周期,防止出现变量指向的内存区域被释放后,变量仍然可以使用的问题。那么什么情况下会让编译器无法判断生命周期,而必须引入一个特定语法来对生命周期进行标识?
我们来看看最常见的悬垂指针问题,函数以引用方式返回函数内部的局部变量:
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
的指针或引用起到类似的效果,运行时被确定具体类型。所以编译期间不知道大小。
Rust
的 Trait
不能有非静态数据成员,所以 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 只能接收静态参数
}
check0
和 check1
说明将 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
。但是什么时候调用 free
或 delete
就是一个难题。尤其当代码复杂,分配内存的代码和释放内存的代码不在同一个代码文件,甚至不在同一个线程的时候,仅仅靠人工跟踪代码的逻辑关系来维护分配与释放就难免出错。
智能指针的核心思想是让系统自动帮我们决定回收内存的时机。其主要手段就是“将内存分配在堆上,但指向该内存的指针变量本身是在栈上,这样编译器就可以捕捉指针变量离开作用域的时机。在这时决定内存回收动作,如果该指针变量拥有内存区的所有权就释放内存,如果是一个引用计数指针就减少计数值,计数为 0 就回收内存”。
Rust
的 Box<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
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_v
是 v
的不可变借用,闭包捕获的是 ref_v
,那么所有权转移的事情跟 v
没有关系,v
不会发生与闭包相关的所有权转移事件。
明确了被捕获的变量后,是否转移所有权受三个因素联合影响:
- 变量被捕获的方式(值,不可变借用,可变借用)
- 闭包是否有 move 限定
- 被捕获变量的类型是否实现了 "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}
, 它的值可以在多个线程间传递(转移所有权),它的多个不可变借用可以在多个线程间同时存在。同时 &Value
和 Arc<Value>
可以在多个线程间传递(转移引用变量或指针变量自身的所有权),但是 Rc<T>
不行。
要知道,Rc<T>
和 Arc<T>
只是 Rust
标准库(std
)实现的,甚至不在核心库(core
)里。也就是说,它们并不是 Rust
语言机制的一部分。那么,编译器是如何来判断 Arc
Rust
核心库 的 marker.rs
文件中定义了两个标签 Trait
:
pub unsafe auto trait Sync{}
pub unsafe auto trait Send{}
标签 Trait
的实现是空的,但编译器会分析某个类型是否实现了这个标签 Trait
.
- 如果一个类型
T
实现了“Sync”,其含义是T
可以安全的通过引用可以在多个线程间被共享。 - 如果一个类型
T
实现了“Send”,其含义是T
可以安全的跨线程边界被传递。
那么上面的例子中的类型,Value
,&Value
,Arc<Value>
类型一定都实现了“Send
”Trait
. 我们看看如何实现的。
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
图。
编辑简介:
高宪凤(.nil?),软件开发工程师,Rust 语言爱好者,喜欢有计划、有条理、有效率的工作,热爱开源文化,愿意为 Rust 中文社区的发展尽绵薄之力。
嵌入式领域的Rust语言
作者:洛佳
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和内核开发也有较好的相容度。
你的架构和指令集
嵌入式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 月份记录的前五个安全漏洞来进行分析。
01 | Mdbook XSS 漏洞 (RUSTSEC-2021-0001)
正好《Rust 中文精选(RustMagazine)》也用了 mdbook,不过读者朋友不用害怕,本刊用的 mdbook 是修补了该漏洞的版本。
该漏洞并非 Rust 导致,而是生成的网页中 JS 函数使用错误的问题。
漏洞描述:
问题版本的 mdBook 中搜索功能(在版本0.1.4
中引入)受到跨站点脚本漏洞的影响,该漏洞使攻击者可以通过诱使用户键入恶意搜索查询或诱使用户进入用户浏览器来执行任意JavaScript
代码。
漏洞成因分析:
XSS的漏洞主要成因是后端接收参数时未经过滤,导致参数改变了HTML的结构。而mdbook
中提供的js
函数encodeURIComponent
会转义除'
之外的所有可能允许XSS
的字符。 因此,还需要手动将'
替换为其url
编码表示形式(%27)才能解决该问题。
修复 PR 也很简单。
02 | 暴露裸指针导致段错误 (RUSTSEC-2021-0006)
该漏洞诞生于第三方库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)
该漏洞诞生于 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)
该漏洞诞生于第三方库[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)
该漏洞诞生于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 编译器做贡献
- 业余时间充足
如何参与
- 认领感兴趣到章节
- 找到对应到 markdown 文件
- 直接发 PR
- 或者帮忙审校别人的 PR
Q & A:
-
如何避免每个人翻译上的冲突呢,需要提前pr说翻译哪一章节吗?
其实没必要怕冲突,对于参与翻译的来说,翻译本身也是一次学习过程,是有收获的。了解编译器工作原理对理解 Rust 概念也有帮助的。如果同一篇有多个翻译,那我这边选翻译更好的就可以了。
这个项目倡导参与者自组织,但为了更加方便大家协作,还是来设置一个规则避免大家冲突。为了大家认领方便,特别创建了认领打卡的 issues,都去这里打一下卡:【翻译认领】避免翻译冲突,来此打卡。
如果你想发一个自己专属的「认领issue」也没问题,可以给该issue打上「已认领」标签。开一个独立的issue好处是可以有一个专属的地方讨论你翻译章节内容里的各种问题。
-
为什么要翻译 《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 编译器与语言设计》系列文章特点:
- 重在图解。图解的目的,是为了帮助开发者从整体结构、语义层面来掌握 Rust 编译器与语言设计。
- 边实践边总结,不一定会每月都有,但争取吧。
- 希望是众人合力编写,我只是抛砖引玉。硬骨头,一起啃。
引子
想必读者朋友们都已经看到了 《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 ,我把未来五年划分成三个阶段:
- 第一阶段:2021 年。该阶段的目标是「上道」。
- 第二阶段:2022 ~ 2023 年。该阶段的目标是「进阶」。
- 第三阶段:2024 ~ 2025 年。该阶段目标是「达标」。
也就是说,今年是想要「上道」的一年。那么要达成这个目标,我做了以下计划:
- 组织社区力量来翻译官方的《Rust 编译器开发指南》。
- 组织 Rust 编译器小组,开始为 Rust 语言做点贡献,并且将在此过程中自己的学习和经验沉淀为《图解 Rust 编译器与语言设计》系列文章。
通过这两份文档,希望可以帮助和影响到更多的人,来为 Rust 语言做贡献。
我知道,编译器作为程序员的三大浪漫之一,水很深。你也可能会说,人家搞编译器的都是 PL 出生,一般人哪有那种本事。诚然如你所想,编译器很难。但幸亏,难不等于不可能。不会,我们可以学。况且,也不是让你从零开始去实现一个 Rust 编译器。
为 Rust 语言做贡献,并不是 KPI 驱动,而是兴趣驱动。可能你看完了编译原理龙书虎书鲸书三大经典,也可能你实现过自己的一门语言。但其收获可能永远也比不上实际参与到 Rust 这样一个现代化语言项目中来。
所以,《图解 Rust 编译器与语言设计》系列文章,不仅仅会记录我自己学习 Rust 编译器的沉淀,还会记录你的沉淀,如果你愿意投稿的话。在这浮躁的世界,给自己一片净土,找回技术初心。
图解 Rust 编译过程
对于学习,我通常习惯先从整体和外围下手,去了解一个东西的全貌和结构之后,再逐步深入细节。否则的话,很容易迷失到细节中。
所以,必须先来了解 Rust 编译过程。如下图:
上图中间部分为 Rust 代码的整体编译过程,左右两边分别为过程宏和声明宏的解释过程。
Rust 语言是基于 LLVM 后端实现的编程语言。在编译器层面来说,Rust编译器仅仅是一个编译器前端,它负责从文本代码一步步编译到LLVM
中间码(LLVM IR
),然后再交给LLVM
来最终编译生成机器码,所以LLVM
就是编译后端。
Rust 语言编译整体流程
- Rust 文本代码首先要经过「词法分析」阶段。
将文本语法中的元素,识别为对 Rust 编译器有意义的「词条」,即token
。
-
经过词法分析之后,再通过语法分析将词条流转成「抽象语法树(AST)」。
-
在得到 AST 之后,Rust 编译器会对其进行「语义分析」。
一般来说,语义分析是为了检查源程序是否符合语言的定义。在 Rust 中,语义分析阶段将会持续在两个中间码层级中进行。
- 语义分析 HIR 阶段。
HIR 是抽象语法树(AST)对编译器更友好的表示形式,很多 Rust 语法糖在这一阶段,已经被脱糖(desugared)处理。比如 for
循环在这个阶段会被转为loop
,if let
被转为match
,等等。HIR 相对于 AST 更有利于编译器的分析工作,它主要被用于 「类型检查(type check)、推断(type inference)」。
- 语义分析 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 代码的一些行为。
-
图中没有画出来的,还有一个从 HIR 到 MIR 的一个过渡中间代码表示 THIR(Typed HIR) 。THIR 是对 HIR 的进一步降级简化,用于更方便地构建 MIR 。在源码层级中,它属于 MIR 的一部分。
-
生成
LLVM IR
阶段。LLVM IR
是LLVM
中间语言。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 生态中被应用最多的一个库。
话说回来。过程宏的工作机制就如上面图中左侧展示的那样。主要是利用三个库,我称之为 「过程宏三件套」:
- proc_macro2。该库是对 proc_macro 的封装,是由 Rust 官方提供的。
- syn。该库是 dtolnay 实现的,基于 proc_macro2 中暴露的 TokenStream API 来生成 AST 。该库提供来方便的 AST 操作接口。
- quote。该库配合 syn,将 AST 转回 TokenSteam,回归到普通文本代码生成的 TokenSteam 中。
过程宏的整个过程,就像是水的生态循环。 蒸汽从大海(TokenSteam)中来,然后通过大雨(Syn),降到地上(Quote),形成涓涓细流(proc_macro2::TokenStream)最终汇入大海(TokenSteam)。
理解过程宏的展开原理,将有助于你学习过程宏。
小结
本篇文章主要介绍了 Rust 代码的编译过程,以及 Rust 宏代码的展开机制,学习这些内容,将有助于你深入理解 Rust 的概念。不知道这篇内容是否激发起你对 Rust 编译器对兴趣呢?编译器是一个深坑,让我们慢慢挖掘它。
感谢阅读。
二月刊
发刊通告
本月社区动态简报
精选自《Rust日报》
Rust 问答精选
Rust in Production
- 华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景
- PingCAP | TiKV 高性能追踪的实现解析
- 蚂蚁集团 CeresDB 团队 | 关于 Rust 错误处理的思考
- 华为 | Rust中的错误传递和日志记录
学习园地
- 新年新人新气象 | Rust 学习笔记
- 「译」使用 Rust 实现命令行生命游戏
- 「译」使用 Tokio 实现 Actor 系统
- 解读 Rust 1.50 稳定版
- 解读 Rust 2021 Edition RFC
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。
本次会议的主要内容:
- 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的愿景)
-
const generics 常量泛型
-
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
关于 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 版本限制如下:
-
目前唯一可以用作 const 泛型参数类型的类型是整数(即有符号和无符号整数,包括
isize
和usize
)以及char
和bool
的类型。 这已经可以涵盖 const 泛型的主要用例,即对数组进行抽象。 将来会取消此限制,以允许使用更复杂的类型,例如&str
和 用户定义的类型。 -
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); } }
未来计划
- 解决默认参数和常量泛型位置冲突的问题。
Rust 目前的泛型参数必须按特定顺序排列:生命周期(lifetime),类型(type),常量(const)。 但是,这会在尝试将默认参数与const参数一起使用时造成困难。为了使编译器知道哪个泛型参数,任何默认参数都必须放在最后。 接下来将解决这个问题。
- 为自定义类型支持常量泛型
从理论上讲,要使一个类型有效作为const参数的类型,我们必须能够在编译时比较该类型的值。所以在 const泛型 RFC 中引入了结构相等的概念:本质上,它包括任何带有#[derive(PartialEq,Eq)]
且其成员也满足结构相等的类型。
- 为复杂类型支持常量泛型
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项目。
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。
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任务调度切换所需时间及其各自在使用时的内存使用总量,并且还做出了总结。
使用 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了,感兴趣的朋友可以一起参与!
微软的员工发布的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 期。想学习的小伙伴可以跟着视频了解一下。
经典 Rust 面试题六道
在电报群由 @wayslog 提出的六道面试题目,wayslog 老师称之为“经典六道”:
-
RwLock
对想要在多线程下正确使用,T的约束是? -
如下代码:
trait A{ fn foo(&self) -> Self; } Box<Vec<dyn A>>;
是否可以通过编译?为什么?
-
Clone与 Copy 的区别是什么?
-
deref 的被调用过程?
-
Rust里如何实现在函数入口和出口自动打印一行日志?
-
Box<dyn (Fn() + Send +'static)> 是什么意思?
@wayslog 提供的答案:
- 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。
- 不可以,参考object safe 三条规则。
- Copy是marker trait,告诉编译器需要move的时候copy。Clone表示拷贝语义,有函数体。不正确的实现Clone可能会导致Copy出BUG。
- Deref 是一个trait,由于rust在调用的时候会自动加入正确数量的 * 表示解引用。则,即使你不加入*也能调用到Deref。
- 调用处宏调用、声明时用宏声明包裹、proc_macro包裹函数、邪道一点用compiler plugin、llvm插桩等形式进行。(Go:我用snippet也行)
- 一个可以被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 循环优化
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 有什么区别?
- RefCell、Cell、UnsafeCell 的区别是什么?什么时候用什么?
扩展资料:
-
官方文档
- 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
P1【4 点场】 Rust1.50 最新改动讲解
嘉宾:张汉东
张汉东老师以一段 Rust
宏代码开启欢乐的 Rust 牛年春晚。随后汉东老师着重讲解了这次 Rust1.50 版本更新的主要内容。这次更新主要包括: 语言级特性
、编译器
, 标准库
、 稳定的 API
、Cargo 相关
、其他
、兼容性提示
几个方面。
扩展资料
- 暖场代码
macro_rules! m {
($($s:stmt)*) => {
$(
{ stringify!($s); 1 }
)<<*
};
}
fn main() {
print!(
"{}{}{}",
m! { return || true },
m! { (return) || true },
m! { {return} || true },
);
}
P2【5 点场】 Delay-Timer 分享
嘉宾:炮炮
Delay-Timer 是一个类似于管理周期性任务的库,目前支持同步、异步任务进行周期化交付,支持一些任务在调度过程中动态添加和动态提交任务的操作。炮炮老师分享了开发过程中的心路历程。
扩展资料:
- 暖场代码
fn main() {
let a = 4;
println!("{},{}", --a, --a);
}
P3【5 点场】Libra 代码分析讲解
嘉宾:Shara
Libra Facebook 开发的一个 Rust 区块链项目,它的使命是为全球数十亿人建立一个简单的全球货币和金融基础设施。Share 老师分享了分析 Libra 代码的思路。
扩展资料: Libra
P4【6 点场】Rust 开发嵌入式烂苹果
嘉宾:王 Mono
王老师现场撸代码,使用 Rust 一步一步完成开发嵌入式烂苹果。
扩展资料
- 暖场代码
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();
}
P5【8 点场】来自 go 社区大佬的视角
嘉宾:云喝酒
Go 和 Rust 作为两门新生语言,Go 的开发者人数大约是 Rust 的64倍。几位来自 Go 社区大佬以不同的视角一起聊聊。
扩展资料
- Cloubhouse
P6【9 点场】程序员的吉他课
嘉宾:MiskoLee
MiskoLee 老师现场教授弹吉他,妥妥的程序员吉他速成班。
P7【9 点场】SNMP 项目介绍
嘉宾:Robin
SNMP 是专门设计用于在 IP 网络管理网络节点(服务器、工作站、路由器、交换机及HUBS等)的一种标准协议,它是一种应用层协议。 SNMP 使网络管理员能够管理网络效能,发现并解决网络问题以及规划网络增长。通过 SNMP 接收随机消息(及事件报告)网络管理系统获知网络出现问题。Robin 老师分享 SNMP 在自己工作中实际应用。
P8【10 点场】Maya-rs 分享
嘉宾:JungWoo
在 Maya 中运用 Rust 实现噪声效果的案例。原理:使用 Rust 调用 Python API,然后再将结果给到 Python API。
扩展资料
P9【10 点场】关于数据库研究和开发的一些话
嘉宾:金明剑
金明剑老师结合自己实际经验聊了聊对 Rust 的理解,既有深度又有广度。
P10【11 点场】wasm 与 rust 及 vitejs-rs 分享
嘉宾:夏歌&lencx
夏歌老师根据自己整理的 WebAssembly 生态图,对其整体状况进行简单介绍。
Lencx 老师现场演示,通过一个标准的 Vite 脚手架开始项目,集成进 Rust,最后打包生成 Wasm 项目。
扩展资料
- https://github.com/second-state/tencent-tensorflow-scf
- https://mtc.nofwl.com/tech/post/wasm-start.html#rust
- https://vitejs.dev/
知乎 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社区添砖加瓦。我国的互联网发展和欧美一些国家一直都有较大的差距。但是众所周知,我们的传统艺能就是弯道超车。
还有很多回答,可以去知乎查看。
@韩朴宇:
链接:https://www.zhihu.com/question/438833112/answer/1673155747
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
我在rustbuild(即src/bootstrap)
上提过几个pr,因此说几个和rustc
相关的(或者说和语言无关的工程问题)。
-
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开发 -
std aware cargo
也就是cargo -Z build-std
,这个功能在优化二进制大小上很有用,在操作系统开发上是必需品。由std Aware Cargo working group负责。 -
core::error::Error
,core::io::Error
和backtrace
支持这是Error handling project group
的工作重点,目前已有demo可用。有了这个wasm,嵌入式和操作系统开发也可以用常用的错误处理库了。 -
chalk
。trait 系统的改进全靠这个,包括GAT
由traits 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.8
,tokio 1.0
,希望更多常用的库不再犹豫,赶紧1.0。
希望 tracing 加快速度到 0.2,异步上下文追踪就指望它了。
生态中很多常见领域都已经有了至少一两个占主导地位的库,但还需要打磨。希望做到商业级、工业级可用。
希望 2021 Rust 多出一些杀手级产品,最好是国产的。
@dontpanic:
链接:https://www.zhihu.com/question/438833112/answer/1673710125
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
我比较没出息,我只想要糖…… 最想要的几个:
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,但也可以接受。
-
let_chains_2,啥也不说了,羡慕 Swift。
-
标准库里面有很多 unstable 的函数,经常会一用上来发现还是 unstable 需要开 feature。自己的项目随便开开倒是无所谓,但生产环境必定要谨慎的多。希望能够尽快 stable,比如 drain_filter。
longfangsong:
链接:https://www.zhihu.com/question/438833112/answer/1674659637
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
其他答主说的已经很好了,尤其是语言设计上的(GAT什么的大家都久等了),我再补充几点:
语言设计:
- 看Rust Internals的时候看到的一个感觉有点意思的idea:更细粒度的unsafe。
- 看到前面有人说的enumerate variant as type,我想要的一个和这个比较像的一个糖是 Typescript 那样的 (untagged) union type,目前我习惯是用enum_dispatch crate来部分模拟这个特性。
工具方面的:
- IDE支持,CLion 现在index不了编译时生成的代码(即使开了RA也一样)。vsc可以但是RA有时会莫名其妙地hang住。
- 能不能修修
cargo
的[patch]
只认repo的url而不管rev的问题,即cargo#7497
- 求编译能再快一点……编译产物能再多复用一点……
社区建设方面:
- 现在感觉很多还没有入门rust的人都被它“传言中”的难度吓到了,实际上rust也就是一门普通的语言,可能所有权检查、强制性的线程安全这些特性确实是别的语言没有的,但掌握这些特性其实也不比掌握指针之类的概念困难,还有其实很多看着很长很可怕的写法(
Option<Rc<RefCell>>>
)虽然第一眼看上去劝退实际上却更合理更可读(分离了是否可空、是否多个所有者、是否可变三个关注点,相比其他某些语言一个指针走天下其实更容易分析)。其实宣传的时候可以更多的去给新人一种rust并不难的印象,可以更好地壮大社区。 - 有没有入口可以给rust foundation捐钱啊(x
还有很多回答,可以去知乎查看。
还有很多精彩的问题等待你的探索和回答:
- Rust相较于Haskell除了效率还有何优势?
- 如何看待 Rust 的应用前景?
- 写 wasm 项目选 C++ 还是 Rust?
- 学Rust之前,是不是应该先学C++?
- 学习Rust, 可以绕开C语言吗?
- 在中国有多少开发者使用Rust编程语言?
- 只学过 C 语言适合学 Rust 吗?
- GitHub 上有哪些值得关注的 Rust 项目?
- 如何开始学习 Rust 语言?
- 学习Rust适合写什么练手项目?
- Rust程序员都做什么项目?
- 本科毕业论文想写点 Rust 语言相关的内容,什么样的题目比较好?
- 我应该放弃 C++,学习 Rust 吗?
华为 | 可信编程 -- 华为引领Rust语言开发的实践和愿景
作者:(俞一峻、Amanieu d'Antras、Nghi D. Q. Bui) / 后期编辑:张汉东
本文结构
- 可信编程 -- 华为引领 Rust 语言开发的实践和愿景
- Rust带来的创新
- Rust在华为的初步推进
- 华为对 Rust 社区的贡献
- 配置华为的端到端 Rust 工具链
- tokei
- cargo-geiger
- 通过深度代码学习研究 Rust
- 结论
Rust带来的创新
StackOverflow 的调查表明, 自 2015 年以来,Rust 一直是开发者最爱的编程语言。
学术界对于Rust也越来越重视,在编程语言和软件工程顶会上发表的关于Rust的论文正逐年增加。
不仅如此,《自然》杂志2020年尾的文章《Why Scientists are Turning to Rust》中也强调:科学家极为推崇Rust。
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 项目涉及了多种编程语言,下面的统计信息显示有多少行不同语种的代码:
C、C++、Rust 代码在 Fuchhia 项目的占比,可以绘制成如下演进图:
为了在大型项目中满足处理多种编程语言的场景需求,我们提交代码到tokei支持识别编程语言的批处理。
cargo-geiger
为了提高安全性,我们经常想知道有多少代码已经被 Rust 编译器检查过。幸运的是,通过统计"Unsafe"项目,如fn
、expr
,struct
、impl
、trait
及其在各相关库实现中的出现次数, cargo-geiger几乎做到了这点。
不过,统计数字中并没有反映安全性,所以没办法展现Rust项目总体上取得了多少进展的比例。因此,我们 提交了代码,在改进的 cargo-geiger 计数器报告中提供 Rust 项目的安全检查比率。这个代码采纳后,我们的产品团队现在每天定期都在使用这个工具,一份典型的报告能够更容易理解哪些代码库还没被 Rust 编译器完全检查到。
通过深度代码学习研究 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
上,我们开发扩展插件,使得程序员可以得到合适的算法推荐和可解释性的帮助。
结论
综上所述,华为可信开源软件工程实验室正在开展的 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),它们之间的关系如下:
- 日志:离散的错误信息和状态信息。
- 指标:记录和呈现可聚合的数据。
- 追踪:单个请求的一系列事件。
TiKV 实现了完备的日志和指标系统,但缺失了追踪,导致在诊断 TiKV 和 TiDB 问题时会遇到以下困难:
- 观测数据之间的没有关联:只有熟悉请求链路上每个操作对应什么监控指标的同学才能完整追溯和诊断问题。
- 请求抖动难以追溯:TiKV 节点往往同时处理不同模式的业务,零星请求的性能抖动无法体现在 AVG / P99 / MAX 等监控指标中,从而无法诊断抖动原因。
追踪可以有效解决上述场景中遇到的问题。以下详细介绍 TiKV 中高性能追踪的实现。追踪功能在 TiKV 中尚为实验性特性,需要特定代码分支开启,感兴趣的同学可以关注 GitHub issue Introduce tracing framework (#8981)。
基本概念
追踪(Trace)呈现系统中的一个请求的执行路径。例如追踪一个 SQL 语句从 TiDB 到 TiKV 的执行全过程后可以得到下图:
从图中可以直观看到 SQL 语句“INSERT INTO
tVALUES (1), (2), (3);”
有趣的信息:
- TiDB 处理这个请求时依次进行了 compile、plan、execute 三个步骤
- TiDB 在 execute 阶段调用了 TiKV 的 Prewrite RPC 和 Commit RPC
- 请求共耗时 5ms
图中每个方框代表一个事件,称之为 Span。每个 Span 包含:
- 事件名称
- 事件起始时间戳和结束时间戳
Span 之间有层级,可以构成父子关系或先后关系,如下图所示:
实现
本文所有性能测试结果,若特别说明测试环境,均在以下平台完成:
CPU: Intel Core i7-8700 Linux distros: Ubuntu 20.04 Linux kernel: 5.4 Memory: 32G Disk: NVMe SSD
TiKV 使用 Rust 编写。Rust 生态中有几个现成的追踪库,分别是 tokio-tracing, rustracing 和 open-telemetry,它们都兼容 OpenTracing 规范,但性能不够理想,引入后会降低 TiKV 50% 以上性能。TiKV 目前的实现能将性能的影响控制在 5% 以内。这主要来自于单个 Span 追踪收集仅耗时 20ns
:
以下具体介绍 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
。
低精度的计时对于短耗时请求的追踪会产生让人困惑的结果。如下图所示,从观测的角度来看已经损失了相当一部分的细节信息:
当然在多数情况下,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 递增速率不稳定:
另外,一些 CPU 在休眠状态时不会递增 TSC:
比较现代的 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 值存在偏差。
在追踪中,完整的计时操作会读取两次时间戳,分别代表事件的始末。由于操作系统的线程调度,这两个时间戳的读取可能发生在不同的核心上。若我们简单地以 TSC 值差值进行计时,会在多核 TSC 不同步的情况下造成耗时计算的偏差。
举个例子:
- t1 时刻,线程在 Core 1 上运行,读取了较大的 tsc1
- 操作系统将线程从 Core 1 调度至 Core 2
- t2 时刻,线程在 Core 2 上运行,读取了较小的 tsc2
此时计算的 TSC 差值甚至成为了负数,无法换算为耗时。
为了解决这个问题,TiKV 会同步各个核心的原始 TSC 值,计算出 TSC 值在各个核心的偏移量,使用同步过后的 TSC 值用于计算耗时。具体算法为在各个核心上任取两次 TSC 和物理时间,以物理时间作为 x 轴、核心上的 TSC 作为 y 轴计算截距,差值即为各个核心的 TSC 偏移,如下图所示:
在计算初始 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。这三者之间的关系和各自的职责如下图。
由于 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,用以接收事件结束的通知,进而完成后续结束时间的回填
在 foo 结束的时刻,即 09:03
,用户提交 index
,向 SpanQueue 通知 foo 事件结束,于是 SpanQueue 开始回填工作:
- 通过
index
索引到 foo 事件所在记录 - 将结束时间回填为
09:03
- 将
next_parent_id
更新为该记录的parent
以上的例子描述了单个事件的记录过程,很简单也很有效。而实际上多个事件的记录也仅仅只是上述过程的重复。比如下面的过程,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:
在 bar 结束时,会按照上面提到的回填步骤,更新 bar 记录的结束时间以及 next_parent_id
变量:
重复以上步骤,最终 SpanQueue 以一种高效的方式,完整记录了这三个事件的信息:
将这些记录串连起来,最终形成如下的 Trace 树状结构:
Normal Span
虽然 LocalSpan 的记录比较高效,但是由于其本身基于线程本地的实现方式,使得灵活性不足。比如在异步场景下,一些 Span 的产生和结束发生在不同的线程,线程本地的实现就不再能发挥作用。
针对上述问题,TiKV 保留了前文最开始所描述的线程安全的 Span 记录方式,即采用 crossbeam channel 每次进行单个 Span 的收集,这样的 Span 下文称之为 NormalSpan。
从实现的角度看,NormalSpan 的信息不会记录在线程本地的容器当中,而是由相应的变量自行维护,以便于跨线程的移动。同时,NormalSpan 之间的父子关系不再由线程本地隐式构建,而需由用户手动指定。
但是,NormalSpan 和 LocalSpan 并非完全隔离,TiKV 通过以下的交互方式将这两者联系起来:从 LocalCollector 收集而来的一组 LocalSpan,可以挂载在 NormalSpan 上作为子树,如下图所示。同时,挂载的数量不受限制,通过允许进行多对多的挂载方式,TiKV 在一定程度上支持了对 batch 场景的追踪,这是社区中大部分追踪库没有覆盖到的。
上述实现方式形成了 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)
还返回了一个 Collector
。 Collector
与根 Span 一一对应。在请求完成时,可调用 Collector
的 collect
方法,从而完成对执行路径上产生的所有 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 提供的 trace
和 trace_async
宏进行函数级别的事件记录。通过上述方式为单个函数记录的执行信息如下:
- 调用的发生时刻
- 调用的返回时刻
- 直接(或间接)调用者的引用
- 直接(或间接)调用的子函数的引用
例如,追踪两个同步函数 foo
和 bar
,通过添加 trace(event)
作为这两个函数的 attribute,即可记录函数的执行信息。如下所示。
#![allow(unused)] fn main() { #[trace("foo")] fn foo() -> u32 { bar(); 42 } #[trace("bar")] fn bar() { } }
最终记录下来的信息,包括这两个函数各自的起始和完成时刻,以及函数调用关系:foo
调用了 bar
。
对于异步函数的记录,步骤略有不同。首先须将 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 追踪的结果:
结语
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
上述代码中,第一行和第二行分别返回了io:Error和ParseIntError,都可以被转换成Box
虽然Rust提供的downcast方法可以将Boxe.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 }
这种,这种格式有且只能有两个字段,source
和backtrace
,不能有其他字段。它会自动将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中。
所以,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
建议使用 trunk,妥~
Trunk 的探索之旅
跳转到 Trunk Repo:
https://github.com/thedodd/trunk
发现里面有examples,于是直接 clone 下来运行:
执行没问题,很好!
但是只有一个简单的实例,没法基于这个进行学习,怎么办?
我们回到 yew 的 Repo 里面,看下有没啥实例。
https://github.com/yewstack/yew/tree/master/examples
Examples 很多,也都能跑通,赞:
魔改出 Base64 Encoder!
在入门一个新的计算机技术的时候,千万不要一开始就从0到1!因为从0到1的难度对新手来说太高。最开始应该先去魔改一个已有的项目。
我选择的是todomvc,原始是长这样:
目的是把它修改成一个 Base64-Encoder:
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!
这个函数失效了!
在trunk
和yew
的 Repo 中进行检索,均未找到解决方案。
但是随即发现yew
有 Discord Chatroom,于是乎进去搜索聊天记录。
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"); ...... }
妥了!
接下来找到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就做好了!
效果:
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
成功:
在这个过程里,我们学习到了 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
。
在wagyu
的main.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() }) } }
很好,就拿这个改造了!
复制必要文件到新项目
- 新建项目
$ cargo new hello-crypto-rust
或者直接把上一个项目复制一份。
- 把
wagyu
的Cargo.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 }
- 把
ethereum
与model
两个文件夹复制到hello-crypto-rust
目录下
此时的文件目录是这个样子的:
.
├── Cargo.lock
├── Cargo.toml
├── ethereum
├── model
├── src
└── target
补充代码
- 补充
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
- 编写
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
,里面会囊括AddressError
、PrivateKeyError
与PublicKeyError
。
#![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!
实现成功:
本篇所涉及的知识点
- cargo 的更多用法
lib.rs
的用法- 函数与函数返回值
pretty_env_logger
的用法- 枚举类型,以
CreateError
为例
作者简介:
李大狗(李骜华),上海对外经贸大学区块链技术与应用研究中心副主任、柏链教育 CTO、FISCO BCOS(微众银行区块链框架)区块链认证讲师、5 年区块链工程师、北京大学硕士。 研究领域包括:区块链系统、共识机制、智能合约、区块链应用、数字身份等。
「译」使用 Rust 实现命令行生命游戏
译者:m1zzx2
原文:
- https://dev.to/jbarszczewski/rust-cli-game-of-life-tutorial-part-1-57pp
- https://dev.to/jbarszczewski/rust-cli-game-of-life-tutorial-part-2-16j3
介绍
你好!如果你看到了这篇文章,说明你对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(())
}
好的让我们拆解一下在做的事情:
- main函数现在返回了Result类型。这能让用户随时退出。
- 我们在execute!宏里面设置临时终端,它的第一个参数是std::io::Writer(这个case里面的输入)类,后面的参数是一些命令。
- 在这个循环里面,我们用poll去读取用户的输入,这样不会阻塞execution去绘画终端。当用户输入回车按钮时,这个循环就会退出,如果用户在500ms内没有输入,我们将会根据tick计算的状态重新绘画Universe。
- 循环结束以后,我们就会离开这个临时终端。 现在我们可以跑脚本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里面:
-
我们把execute!替换成 queue! 宏。
-
遍历Universe的每一行,queue! 会直接打印结果,你会看到返回Option
有多方便!我们不需要任何额外的处理,这个代码看起来会很干净。 -
在所有文本都准备好之后,我们调用 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
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代表或者。现在这里有两个地方你可以放置配置文件:
- 项目的根目录,一些文件像是Cargo.toml就在这个里面,并且你能通过脚本cargo run -- -i INPUT跑你的应用。使用cargo运行之后的内容,都可以作为参数传递给你的项目。
- ./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
}
这看起来很长而且有一定重构的空间,但是比较容易理解:
- 打开文件,写入BufReader。
- 创建变量line读取每一行。
- 尝试去解析行数和列数。
- 创建新的 Universe。
- 遍历剩余行,解析cell,写入vector。
- 调用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 系统。事实上这甚至是更容易的,但是还是有一些细节需要注意:
tokio::spawn
的调用位置。- 使用带有
run
方法的结构体还是裸函数。 - Actor 的 Handle 函数。
- 背压( Backpressure ) 和 有界信道。
- 优雅的关闭。
本文概述的技术适用于任何执行器,但为简单起见,我们仅讨论Tokio。与Tokio教程中的 spawning 和channel 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") } } }
让我们仔细看一下本示例中的不同部分。
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); } }
当所有发送到receiver
的 sender
都被丢弃时,我们就知道将不会再收到其他信息了,因此可以关闭 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 }
这个示例存在两个问题:
tokio::spawn
在run
方法中被调用。- 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 :: spawn
在run
内的那种 , 但是我并不推荐。
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::Index
和ops::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::Index
和 ops::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]; }
上面None
和Some(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
类型 中 Copy
或ManuallyDrop
的字段不会调用析构函数,所以不必加 unsafe
块。
进一步,当 Drop 一个 Union 类型的时候,需要手工去实现 Drop。因为 共用体 本身的特性,它不会知道该 drop 哪个字段才是安全的,所以才需要字段都是 Copy
或 ManuallyDrop
的。
#![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_exchange
和 compare_exchange_weak
。过期这个cas方法一方面是为了和 cpp
的 compare_exchange_strong
和 compare_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_weak
和compare_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,开发工具等)来解决。
目标与设计原则
顺序代表优先级
Edition
不能分裂生态系统。
最重要的一条规则是:一个Edition
中的 crate 可以与其他Edition
中编译的 crate 无缝地互操作。不管Edition
如何,所有 Rust 代码最终都会在编译器中编译为相同的内部 IR。
Edition
迁移应该很方便且尽最大可能自动化完成。
在发布新Edition
的同时也会发布一些工具帮助自动升级Edition
。并且维护《Edition
迁移指南》以便手动迁移之需。
-
由用户来控制何时使用新的
Edition
-
Edition
注定是要被使用的。目标是看到所有Rust用户都采用新Edition
。 -
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
快速开始
- 在原有
vite
项目中使用,只需安装配置vite-plugin-rsw
插件即可。 - 新项目可以使用
vite
提供的@vitejs/app
初始化项目,然后安装配置vite-plugin-rsw
。 - 或者使用脚手架
create-xc-app
初始化项目,模板包含wasm-react
和wasm-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>
项目配置
以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'));
相关链接
- Wasm学习项目: lencx/learn-wasm
- Vite插件Rsw - lencx/vite-plugin-rsw
- 项目脚手架 - lencx/create-xc-app
- WebAssembly相关资源清单
- WebAssembly官网
- Rust官网 - 一门赋予每个人 构建可靠且高效软件能力的语言
- Nodejs官网 - 基于Chrome的V8 JavaScript引擎构建的JavaScript运行时
- Vite官网 - 下一代前端工具
- wasm-pack - Rust => WebAssembly
- rust-to-wasm
- wasm-bindgen
作者简介:
陈鑫(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>
,其中Mutated
是Added
和Changed
的集合,也就是说新添加的和改变了的都可以用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
中的查询相互冲突时,编译后运行会触发一个panic
:xxx 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>)>>, ){ ... } }
同时查询了Transform
和Point
,并且,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的代码补全体验可以说是很糟糕。而且出于减少总编译时间的考虑,这部分的过程宏只预备了五个参数的位置,也就说说除了q0
到q4
多出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<()> { ... } }
可能只看函数参数并不能感受到哪里不好用,可是如果你注意到这是一个事件处理系统,传递进来的参数居然同时需要Events
和EventReader
,并且使用的时候是这样的:
#![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
使用的,也就是说存在Res
、ResMut
两种状态。其中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, } }
看上去我们的游戏有StartMenu
、Game
、Temporary
三个状态,实际上只需要考虑前两个状态就好了,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
,我的建议是先看官方文档,然后再去discord的bevy_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_GROUPS
和PLAYER_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
实装上,我们能够参考的已有的bevy
的wasm
版本项目基本上都是基于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 库的优缺点分列如下:
优点:
- 纯 Rust 封装,安全性更好。
- 比 C 语言库封装高层,使用起来接口更加简单。
缺点:
- 维护成本更高,需要根据kernel的更新手动追加新 feature,包括新数据结构。
- 封装还不够彻底,暴露了底层实现的两个队列,用户使用难度较高。
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 库的优缺点分列如下:
优点:
- 接口丰富且使用简单。
- 有实际使用的项目验证。
缺点:
- 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(); }); }
该库也并非完美无缺,它也具有下列缺陷:
- 并发不友好,在 Submission Queue 上有一把大锁,每个提交任务的线程都会被串行化。
- 读写操作会导致内存在用户态被拷贝,对于大数据量的操作而言,多余的内存拷贝会带来明显的性能下降。之所以要进行内存拷贝,是为了保证传给内核的memory buffer不会被用户态异步修改,保证安全性。
作者也在 Readme 文件中说明了最上层的 ringbahn 封装只是一次尝试,并不适合在正式生产上使用。
DatenLord ring-io
基于上述讨论,我们团队 Datenlord 也实现了自己的 io_uring Rust lib, 名称是 ring-io。现阶段的实现吸取了 Tokio io-uring 和 iou 的经验,同样实现了Submission Queue 和 Completion Queue 的抽象。具体的实现细节请参见王徐旸同学写的文章。
现阶段的实现也具有下列问题:
- 暴露了一些unsafe接口,提醒用户某些操作需要注意,和内核的错误交互会带来无法预知的结果。
- 抽象层偏低,使用起来不方便。
接下去,我们会针对一些特定的 buffer 类型实现异步 I/O 接口,方便用户的使用,且暴露 safe 的接口。在实现的过程中,我们也会将高效考虑在内,避免不必要的内存拷贝。和ringbahn 的方法不同,我们保证内存安全的方式为 Rust 提供的内存所有权转移,即用户在发送 I/O 请求之后就不在拥有 buffer 的所有权,直到 request 返回所有权才被归还。具体的实现细节我们会在下一篇文章中进行讨论,这里先给出设计的架构图:
- 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
后:UringReadFuture
会创建一个固定在内存中的对象UringTask
,然后把读文件任务放进队列里,将UringTask
的地址作为 读操作的用户数据。UringTask
里面有个 channel。UringPollFuture
在后台提交任务。UringPollFuture
在后台轮询已经完成的任务。UringPollFuture
取出其中的用户数据,还原成UringTask
对象,通过 channel 通知UringReadFuture
I/O 操作已经完成。
整个流程如下图所示。
这样,我们就可以方便地调用 io_uring
实现文件的异步读取。这么做还顺便带来了一个好处:任务提交可以自动 batching。
通常来说,一次 I/O 操作会产生一次 syscall。但由于我们使用一个单独的 Future 来提交、轮询任务,在提交的时候,
队列里可能存在多个未提交的任务,可以一次全部提交。这样可以减小 syscall 切上下文的开销 (当然也增大了 latency)。
从 benchmark 的结果观察来看,每次提交都可以打包 20 个左右的读取任务。
Benchmark
将包装后的 io_uring
和 mmap
的性能作对比。测试的负载是 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)。
case | throughput | p50 | p90 | p999 | p9999 | max |
---|---|---|---|---|---|---|
uring_8 | 104085.77710777053 | 83166 | 109183 | 246416 | 3105883 | 14973666 |
uring_32 | 227097.61356918357 | 142869 | 212730 | 1111491 | 3321889 | 14336132 |
uring_512 | 212076.5160505447 | 1973421 | 3521119 | 19478348 | 25551700 | 35433481 |
mmap_8 | 109697.87025744558 | 78971 | 107021 | 204211 | 1787823 | 18522047 |
mmap_32 | 312829.53428971884 | 100336 | 178914 | 419955 | 4408214 | 55129932 |
mmap_512 | 235368.9890904751 | 2556429 | 3265266 | 15946744 | 50029659 | 156095218 |
发现 mmap 吊打 io_uring
。嗯,果然这个包装做的不太行,但是勉强能用。下面是一分钟 latency 的 heatmap。每一组数据的展示顺序是先 mmap 后 io_uring
。
mmap_8 / uring_8
mmap_32 / uring_32
mmap_512 / uring_512
一些可能的改进
- 看起来现在
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
主要由下列三部分构成:
- 第一部分是现有的技术积累,包括设计文档、代码仓库、文档教程和技术讨论的积淀。
- 第二部分是 Rust 的项目组织及其延伸,包括整个 Rust 开发者社区。
- 第三部分是 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 是⼀个⾃举的编译器,需要通过旧的编译器来构建最新的版本。所以⼀般是分阶段来完成:
Stage0
阶段。下载最新beta
版的编译器,这些x.py
会⾃动完成。你也可以通过修改配置⽂件来使⽤其他版本的Rust。Stage1
阶段,使⽤Stage0
阶段下载的beta
版编译器来编译从Git
仓库⾥下载的代码。最终⽣成Stage1
版编译器。但是为了对其优化,还需要进⾏下⼀阶段。Stage2
,⽤Stage1
版编译器继续对源码进⾏编译,以便⽣成Stage2版编译器。
理论上,Stage1
和Stage2
编译器在功能上是相同的,但实际上还有些细微的差别。
官⽅推荐的具体构建流程如下:
./x.py check
,先执⾏此命令,检查编译器是否可以构建。./x.py build -i --stage 1
,进⾏Stage 0
和Stage 1
阶段的构建,最终构建完成Stage1的编译器。./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
。还有一种情况是这段时间里代码更新导致了合并冲突。机器人会来留言告诉你有合并冲突。这个时候你需要执行一个git
的rebase
操作,完成对合并冲突的解决,然后更新你的 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又不会怀孕》上写一些文章。
三月刊
发刊通告
本月社区动态简报
精选自《Rust日报》
Rust in Production
- 华为 | 基于Rust的下一代虚拟化平台-StratoVirt
- 华为 | Rust 科学计算多维数组运算库的分析与实践
- 华为 | 基于 TVM Rust Runtime 和 WASM 沙箱运行 AI 模型
- 蚂蚁集团 CeresDB 团队 | Rust CPU 亲和性(Affinity) 初探
- DatenLord | Rust实现RDMA
学习园地
no_std
环境下的可执行文件- 用 Rust 写智能合约 | Hello, Ink!
- 「算法」蓄水池算法改进 - 面向抽奖场景保证等概率性
- Rust 中使用 MySQL
- 「系列」Rust 设计模式 | 工厂模式
- 「译」数据操作:Rust vs Pandas
- 「译」Unsafe Rust 的取舍
- 「译」基于 Rust 用 Bevy 实现节奏大师游戏
- 「译」Arenas in Rust
- 「译」用 Rust 编写 LLVM 的玩具编译器
【系列】透过 Rust 探索系统的本原
Rust 编译器专题
发刊通告
编辑:张汉东
三月发刊通告
三月,万物复苏,万象更新。不知不觉,三月的最后一天到了,《 RustMagazine 中文精选 》2021 年第三期发布了!
每次发布月刊,都会体会到时光流逝的无情。毕竟一年才十二个月,所以月刊一年一共才十二期,现在第三期已经发布了。更重要的是,当月刊发布的时候,也意味着三月即将过去。为了迎接四月的到来,大地作了太多的准备,你呢?你的四月又将为什么样的目标做准备呢?无论如何,加油吧!
社区协作项目动态介绍
介绍两个新创建的协作项目:
- Star Rust。该项目用于记录 Rust 开源生态中的明星项目。不同于 awesome-rust ,该项目侧重于记录明星项目,及其介绍、架构、应用、源码解读。
- 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
该版本主要是带来了 :
- Const Generics MVP : https://blog.rust-lang.org/2021/02/26/const-generics-mvp-beta.html
- 顺便 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> { } }
- 新的 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" }
- 针对 MacOS 平台对 Debug 模式构建时间做了优化。去掉了之前通过 dsymutil 工具将debug信息收集到.dSYM目录下的方式,而使用新的方式,从而减少debuginfo的构建时间,并显着减少所使用的磁盘空间量。但还期待macOS 用户的更多构建报告。
#![allow(unused)] fn main() { [profile.dev] split-debuginfo = "unpacked" }
这样设置就可以启用新的行为
- 稳定了很多 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 加入新的 trait
:TryFrom / 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. 感兴趣的小伙伴可以尝试联系一下.
Miri运行在wasm上!
现在已经有方法可以将miri编译到wasm了。
社区热点
- 来源: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 的场景
关键内容:
- Coreutils 的 Rust 版本已经被 Mozilla 主管 Sylvestre Ledru 移植到了 Linux 。有了这些,Ledru启动了Linux并运行了最受欢迎的Debian软件包。
- Linux Rust的支持者并不是: “提议将Linux内核重写为Rust“ 。 他们只是专注于向可能编写新代码的世界迈进。
- Rust支持的三个潜在方面是:利用内核中的现有API,体系结构支持,以及处理Rust和C之间的应用程序二进制接口(ABI)兼容性。
- Linus 目前到态度是: 等待和观察。 他对 Rust for Linux 是感兴趣的,就个人而言,他绝不会排挤 Rust,但Linus 认为目前这个事情是那些对 Rust 抱有强烈兴趣的人推动的(Linus比较冷静),他想看看最终 Rust 在实践中如何发挥作用。
- linux 认为 Rust 可能的场景:Rust的主要首要目标似乎是驱动程序,仅是因为在那里可以找到许多不同的可能目标,并且内核的这些各个部分相当小且独立。这可能不是一个非常有趣的目标。对某些人来说,但这是显而易见的。
- Kroah-Hartman 的观点:“一切都归结为如何将用C编写的内核核心结构和生存期规则之间的交互映射到Rust结构和生存期规则中”
- 尽管几乎可以肯定不会很快看到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/
Linux 基金会 和 RISCV 基金会 共同推出的 免费 RISCV 课程
课程发布在 edx.org 上,包括两个课程:
- Introduction to RISC-V (LFD110x)
- Building a RISC-V CPU Core (LFD111x)
Rust and LLVM in 2021
作者是 Rust 的核心团队成员, 之前就职于 Mozilla, 现就职于 Facebook. 写过最初的基于 LLVM 的 Rust 代码生成器, 以及很多 Rust 相关的工作.
该 keynote 讲述的是 Rust 中 LLVM 相关工作:
新的特性. 将LLVM 的提升带到 Rust 中. LLVM 相关的提升和修复. 未来的挑战. 对于 Rust 编译器层面感兴趣的小伙伴可以深入了解.
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。
INTELLIJ RUST CHANGELOG #143
为类似函数的程序宏提供初步支持。现在,插件可以扩展这种程序性宏调用;因此,它们自动获得声明性宏已经具备的一些功能:高亮显示、名称解析、有限的代码完成、意图等。
Veloren 0.9
一款开源多人RPG游戏,今天发布了!会在3月20日格林威治时间18:00发布在公共服务器上!
《Veloren》是一款多人体素RPG游戏。它的灵感来自《魔方世界》、《塞尔达传说:荒野之息》、《矮人要塞》和《我的世界》等游戏。
Veloren是完全开源的,使用GPL 3授权。它使用原始图形,音乐和其他资产社区创建的资产。它的开发社区和用户社区都是受贡献者驱动的:开发者、玩家、艺术家和音乐家一起开发游戏。
Actix Actor Framework v0.11 出來了
~40%
的效能改善,升级到 Tokio v1
知乎| 搜索引擎研发(Rust) 工程师
岗位职责
- 负责搜索引擎平台架构建设,优化系统稳定性,设计良好的架构支持业务快速迭代
- 抽象通用的搜索引擎部署方案,用于快速支持各大垂直搜索引擎
- 参与知乎搜索业务优化
任职要求:
- 有扎实的编程能力,有良好的数据结构和算法基础
- 良好的团队合作精神,较强的沟通能力
- 熟悉 Linux 开发环境,熟悉 Go/Rust 语言,熟悉网络编程、多线程编程
- 熟悉搜索引擎,对 Elasticsearch、Kubernetes 有使用经验者优先
- 有高可靠分布式系统架构设计经验者优先
知乎搜索Rust 开源项目: https://github.com/zhihu/rucene
联系邮箱:
蚂蚁集团校招开启:Rust 实习生看过来
@2021.11.1~2022.10.31毕业的应届生可看 ,要推荐的可以找我咨询 ,也可直接联系。
招聘部门:
-
蚂蚁智能监控团队JD(内有联系方式): https://mp.weixin.qq.com/s/mi5woh-btWEEsc8ruSww7Q
-
蚂蚁机密计算部门: 直接联系方式:微信32713933, email shoumeng.ysm@antgroup.com
部门相关信息看下面链接:
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
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 的 runtimelambda-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 常见疑问汇总。
本期摘录:
- 如何在特质(trait)里添加异步函数?
目前 Rust 不支持在特质里直接添加异步函数,但可以使用 async-trait 这个库来实现。这个库会将异步函数改写为返回 Pin<Box<dyn Future>>
的普通函数以绕过目前语言层面的限制,但也因此有堆分配以及动态分发这两个额外的代价,所以不会被直接添加到 Rust 语言中。
在特质里不支持使用异步函数是由于异步函数本质上是一个返回 impl Future<Output = T>
的函数,而目前 Rust 的类型系统还无法表达在特质的方法的返回类型上使用 impl Trait。有两个已经通过的 RFC 旨在解决这一问题:RFC 1598 泛型关联类型和 RFC 2071 impl Trait
存在类型,但它们的编译器支持还在实现中。
- 为什么 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异常有效,因为数据结构本质上知道如何序列化或反序列化自身,并且它们通过实现Serialize
orDeserialize
特性来实现。幸运的是,这要归功于大多数类型,这要归功于derive宏。您可能会像我一样遇到陷阱,在这篇博客文章中,作者想特别谈一谈:转义序列。
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代码的新出现的原理,约定,抽象和最佳实践。
避免使用Rust进行单线程内存访问错误
在本文中,我们将研究单线程C ++中的几种内存访问错误,以及Rust如何防止我们犯这些错误。我写了一篇简短的文章,展示了C ++中的内存访问错误以及Rust如何阻止我们访问这些错误。目录:Returning references to temporariesShort lifetimesReferenes to container contentsTricky lifetime extensions
Rust Web开发人员路线图
超详细 Rust Web 开发人员的路线图。
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
。
教你如何用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开发游戏!
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异步不是有色函数!
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
内容:软件选型方法
跟大家聊聊软件选型的方法,内容:
- 基础模型:天上不会掉馅饼定理、丑小鸭定理、康威定律、包线;
- 抽象:函数、对象、类型、Trait;
- 架构:服务端开源组件的取舍;
- 实例: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
华为 | 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的依赖。
主要方法有三条:
- 在使用语言核心功能时,使用核心库代替标准库
- 当需要使用核心库没有的功能时,手动引入额外功能模块
- 使用条件编译进行功能裁剪。
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运行时
作为Apache基金会的顶级开源项目,TVM是用于深度学习领域的一个全栈编译器,旨在高效地在任何硬件平台进行模型的编译优化和部署工作。通过统一的中间表示层(包括Relay和Tensor IR两层),TVM可将AI框架训练的模型编译成与后端硬件架构无关的计算图表达,然后基于统一运行时实现不同环境下的计算图加载和执行操作。
为实现上述的图加载执行操作,TVM制定了一套抽象的运行时接口,并根据不同的运行时环境提供多种编程语言的接口实现(包括C++、Python、Rust、Go及Javascript等),本文主要介绍TVM Rust运行时的接口定义。TVM Rust运行时接口主要包含tvm_rt
和tvm_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
andndarray::Array
.Tensor
is primarily a holder of data which can be operated on via TVM (viaDLTensor
) or converted tondarray::Array
for non-TVM processing. | -
枚举定义
| 枚举名称 | 功能介绍 | | :------- | :------- | | ArgValue | A borrowed TVMPODValue. Can be constructed using
into()
but the preferred way to obtain aArgValue
is automatically viacall_packed!
. | | RetValue | An owned TVMPODValue. Can be converted from a variety of primitive and object types. Can be downcasted usingtry_from
if it contains the desired type. | | Storage | AStorage
is a container which holdsTensor
data. | -
常量定义
-
trait定义
WASM与WASI
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扩展。
- Web领域通过
-
WASM算子库
WASM算子库相对来说比较好理解,就是把单个算子编译成WASM字节码,然后对上层框架提供一种封装好的算子调用接口。但是和传统手写算子的调用方式不同,框架需要通过一种类似于动态链接的方式来加载WASM算子,但考虑到当前WASM本身不支持动态链接的方式,因此需要提前将所有编译好的WASM算子进行整合,然后对框架层提供算子库的调用接口。
通过对上述两种思路进行分析比较,同时在借鉴了TVM社区已有工作的情况下,我们决定首先从WASM计算图
这条路开始进行深入探索,最大程度地利用TVM全栈编译的能力快速实现方案的原型。
方案实现
-
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算子库真正实现了端边云全场景中算子的可移植性。
如上图所示,我们计划从WASM算子库这个层面梳理出一套端到到的集成方案(优先覆盖上述几个场景),真正实现WASM技术在AI领域全场景的结合。
加入我们
为了更好地推动Rust编程语言生态在AI领域的落地,我们发起了一个叫Rusted AI的非商业性组织,任何对Rust和AI技术感兴趣的开发者均可申请加入,社区当前提供如下几种交流渠道:
- Rusted AI微信群:欢迎添加小助手的微信(微信号:
mindspore0328
,备注:Rusted AI
),认证通过后小助手会将您拉进Rusted AI讨论群 - GitHub Teams:社区当前依托GitHub Teams提供公开讨论的渠道,由于GitHub Teams仅对组织成员开放,请以邮件形式发送
个人GitHub ID
至wanghui71leon@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-rs 和 affinity-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 去调度任务执行,完成之后给我们结果,我们不用关心这个任务具体是怎样被调度的,产生的计算发生在哪个核上。而且我们底下的所有结构都付出了代价让它们 Send
和 Sync
,也不用去担心一个对象同时被多个东西操作会出现奇怪的结果。
LocalSet
这里是使用 tokio 的 LocalSet 来实现的。它能将指定的任务绑在同一个线程上进行执行。这样子带来的好处就是我们可以使用 !Send
的东西了。
具体来说,由上面我们知道不同的 id 之间的操作不会互相影响,所以能够将锁粒度变小。同样的,不同 id 的任务计算所需要用到的数据也不会重叠,也就是避免了一份数据可能被多个内核同时访问的场景,从而不需要考虑我们的修改对其他内核的可见性。基于这一点,之前付出的性能代价来给数据实现 Send
和 Sync
也可以被节省下来。比如引用计数可以从 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-sys和async-rdma,前者是对RDMA接口的unsafe封装,后者是safe封装(尚未完成)。
RDMA编程理念
先首先简要介绍下RDMA编程,因为本文重点不是如何用RDMA编程,所以主要介绍下RDMA的编程理念。RDMA的全称是Remote Direct Memory Access,从字面意思可以看出,RDMA要实现直接访问远程内存,RDMA的很多操作就是关于如何在本地节点和远程节点之间实现内存访问。
RDMA的数据操作分为“单边”和“双边”,双边为send/receive,单边是read/write,本质都是在本地和远程节点之间共享内存。对于双边来说,需要双方节点的CPU共同参与,而单边则仅仅需要一方CPU参与即可,对于另一方的CPU是完全透明的,不会触发中断。根据上述解释,大家可以看出“单边”传输才是被用来传输大量数据的主要方法。但是“单边”传输也面临这下列挑战:
-
由于RDMA在数据传输过程中不需要内核参与,所以内核也无法帮助RDMA缓存数据,因此RDMA要求在写入数据的时候,数据的大小不能超过接收方准备好的共享内存大小,否则出错。所以发送方和接收方在写数据前必须约定好每次写数据的大小。
-
此外,由于RDMA在数据传输过程中不需要内核参与,因此有可能内核会把本地节点要通过RDMA共享给远程节点的内存给交换出去,所以RDMA必须要跟内核申请把共享的内存空间常驻内存,这样保证远程节点通过RDMA安全访问本地节点的共享内存。
-
再者,虽然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_t
和sockaddr_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-Write和Read-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 / 后期编辑: 张汉东
由于作者身边只有 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
如果顺利的话,目录下会生成target/ink
文件夹,里面包含如下文件:
其中,flipper.contract
是部署时要用的合约文件,可以视为solidity
合约中的bin
文件。
metadata.json
是元数据,可以视为solidity
合约中的abi
文件。
合约部署
通过canvas
启动一个本地运行的开发节点!
canvas --dev --tmp
打开如下网址,会这个页面会自动连接本地启动的开发节点:
上传flipper.contract
这个文件:
一路点击进行部署:
合约调用
点击Execute
:
选择get():bool
函数,点击「调用」:
返回调用结果:
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. cfg
和cfg_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 \) 为参与参与抽奖的人的序列
- 令 \( 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 \)
- 当 \( 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 \),证明如下:
我们采用数学归纳法来证明:
- 奠基:当 \( k = 1 \) 时,易知该中奖者一定会抽中第一个奖项,前一个奖项中只有第一个 选项,所以此时每一位中奖者抽中第 \( k = 1 \) 的概率为 \( 1 = { 1 \over 1 } = { 1 \over k } \);
- 归纳:
- 假设当 \(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 \)为当前参与抽奖的人数
- 在抽奖结束前,每次遇到一个新的 \( 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 \)
- 在抽奖结束时,如果 \( 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
的时候再一次对奖品顺序进行随机的打乱。 - 这种抽奖方式还限定了每人只能抽取一次奖品,否则会出现一个人占有多个奖项的情况。
可以参考博主以前的博客
下一步可能展开的工作
目前所有抽奖者都按照相等的概率抽奖,而在一些场景下可能按照一些规则给与某些抽奖者优惠 (例如绩效越高的员工中奖概率越大),因此下一步可能考虑如何按照权重赋予每位抽奖者各自的 中奖概率。
致谢
感谢茶壶君(@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 / 后期编辑:张汉东
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) | |
---|---|---|
pandas | 3.0s | 2.5 Gb |
rust | 1.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) | |
---|---|---|
pandas | 2.78s | 2.5 Gb |
rust | 2.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) | |
---|---|---|
pandas | 12.82s | 4.7Gb |
rust | 1.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) | |
---|---|---|
pandas | 22.47s | 11.8Gb |
rust | 5.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 的取舍
译者: ( MATRIXKOO 和 NiZerin ) / 后期编辑: 张汉东
在本中,我将说明您需要了解的有关unsafe Rust
的所有信息。我将专注于以下几个方面来讲解。
- 有关于
unsafe Rust
代码的误解 - 什么时候不使用
unsafe
代码 - 处理未初始化的内存
- 不可处理的异常
- 内在机制
- 内联汇编
- 接口外部功能
- 编写
unsafe Rust
代码的工具
关于unsafe Rust
代码的几个误解
在解释如何以及何时使用unsafe Rust
(或不使用)之前,我想先说明一