关注这个 up 主已经很久了,已经经过了很长时间的检验,在这里也推荐给大家。
这个 up 主发的所有曲子我都会第一时间听。
另外,记得一定要看简介,一定要看简介,一定要看简介!
这里我尝试使用嵌入代码给大家推荐三首曲子,不过推荐大家跳转到原页面,一边看简介一边听。
很可惜的是,11 月 4 日的第一期,由于太过火爆并且 Zoom 人数限制 100 人,导致主持人 Niko 自己进不来所以取消了……等待看看官方后续会怎么搞吧,还是很期待官方组织的活动的。
Rust 中文社群的张汉东大佬也紧跟着官方的活动,在社群里面组织了 Rustc 源码阅读的活动,今天(11 月 7 日)举办了第一期,在这期中我跟着吴翱翔大佬的思路,从一个错误出发,学习了一部分 rustc_resolve 的逻辑,于是想着写一篇博客总结一下。
【小广告】下一期 11 月 14 日下午会由刘翼飞大佬带领大家一起去阅读类型推导相关的代码,有兴趣的同学可以下载飞书,注册一个个人账号,然后扫描二维码加入:
言归正传,在阅读 Rustc 源代码之前,我们需要先做一些准备工作,主要是先 clone 下来 Rust 的代码,然后配置好 IDE(虽然但是,Clion 到现在正式版还不支持远程,EAP 又各种 bug……),具体可以参考官方的 guide:https://rustc-dev-guide.rust-lang.org/getting-started.html。跟着这章做完就行:https://rustc-dev-guide.rust-lang.org/building/how-to-build-and-run.html。
这次我们的阅读主要的对象是rustc_resolve
,顾名思义应该是做名称解析的,更加详细的信息可以来这瞅一眼:https://rustc-dev-guide.rust-lang.org/name-resolution.html。
我们打开rustc_resolve
的lib.rs
一看,妈呀,光这个文件就接近 4000 行代码,直接这么硬看肯定不现实;不过吴翱翔大佬提出了一个思路:从一个我们最常见的错误the name xx is defined multiple times
出发,顺着这条路去学习一下相关的代码。
这是一个很好的办法,当你不知道从哪入手的时候,你可以构造一个场景,由点切入,最终由点及面看完所有代码。
废话少说,我们先祭出搜索大法,在rustc_resolve
里面搜一下这个错误是在哪出现的:
非常巧,正好就在rustc_resolve
的lib.rs
中,于是我们跳转过去,发现确实是这个我们想找的错误:
1 | let msg = format!("the name `{}` is defined multiple times", name); |
所在的这个函数名也正好是report_conflict
,完美!
让我们接着看看这个函数在哪被调用到了:
这个函数除了定义外,被调用到了两次,其中下面这次是在自己函数内部递归调用,我们直接无视掉;还有一次是在build_reduced_graph.rs
中,让我们跟着去看看:
在这里是被define
方法调用到,看着很符合预期,看来我们找对地方了。
这段代码先通过to_name_binding
方法把传入的def
转换成一个NameBinding
,让我们看看这段干了啥:
NameBinding
是一个记录了一个值、类型或者模块定义的结构体,其中kind
我们大胆猜测是类型,ambiguity
看不懂先放着,expansion
也是(如果看过 rustc-dev-guide 能大致知道是和卫生宏展开有关,这里我们也先无视),然后是span
也不知道干啥的,点进去研究下感觉和增量编译有关,也先放着,最后vis
估摸着应该表示的是可见性。
然后我们再点ResolverArenas
看看是干啥的:
1 | /// Nothing really interesting here; it just provides memory for the rest of the crate. |
嗯,好,没啥值得关注的,只是用来提供内存的,直接无视。
我们再接着回到上面的define
方法中:
1 | impl<'a> Resolver<'a> { |
第二句let key = self.new_key(ident, ns);
看着也没啥特殊的,就是根据当前所在的namespace
给ident
(表示标识符)新建一个key
,那么 value 应该就是上面的binding
了。
然后这里调用了try_define
,如果返回了 Err 就调用report_conflict
,让我们接着进入try_define
看看(先不用仔细看):
1 | // Define the name or return the existing binding if there is a collision. |
看着比较长,让我们一点一点来。
第一句let res = binding.res();
就有点懵了,res
是啥?result?response?其实都不是,我们点进去看看,一直点到底,会发现其实是resolution
的缩写:
1 | /// The resolution of a path or export. |
好的,这条语句就是获得了我们刚才初始化的binding
的resolution
,我们接着看:
1 | self.check_reserved_macro_name(key.ident, res); |
先看第一行的check_reserved_macro_name
:
1 | crate fn check_reserved_macro_name(&mut self, ident: Ident, res: Res) { |
好像也没啥特殊的,就是看看有没有用到保留关键字,先无视掉吧;
再看看第二行set_binding_parent_module
:
1 | fn set_binding_parent_module(&mut self, binding: &'a NameBinding<'a>, module: Module<'a>) { |
hmmm……好像是绑定了所在的 module,看着也没啥特殊的,也跳过吧。
接着往下看,这一段是重头戏了,让我们先进入update_resolution
看看:
这里我们只关注:
1 | let resolution = &mut *self.resolution(module, key).borrow_mut(); |
这两行,这两行应该是主要逻辑。
首先,我们调用了self.resolution
,我们进去看看:
这里又调用了resolutions
:
这里我们发现又有一段新的逻辑,我们看下字段的注释:
会发现其实 module 的 resolution 是 lazy 计算的,ok,具体的build_reduced_graph_external
想必就是计算的部分,我们在这里先跳过,作为一个黑盒,之后再去探究。
好了,现在回过头继续看刚才的代码:
在resolution
方法中,我们获取到了当前模块的所有resolutions
,然后看看key
是否存在,不存在就创建一个新的,并返回这个resolution
。
再回到上层代码:
1 | let resolution = &mut *self.resolution(module, key).borrow_mut(); |
这里我们拿到了resolution
后调用了传入的 f,让我们回到try_define
中,先看 else 部分:
1 | self.update_resolution(module, key, |this, resolution| { |
这里如果返回的resolution
的binding
是None
(对应上面resolution
方法中新建的resolution
,之前不存在),那么就把resolution
的binding
设为当前的binding
然后返回Ok
,逻辑还是比较简单的。
好了,让我们再接着看看如果原来已经有了一个binding
,rustc 会如何处理:
1 | let res = binding.res(); |
这里如果之前返回的 res 本身就是 Err 的话,就直接返回,我们看一下 Err 的注释:
嗯,这部分直接无视吧,我们接着看:
1 | let res = binding.res(); |
如果说新的和旧的都是glob_import
,那么我们判断一下当前的res
和之前的res
是否是同一个,如果不是就说明出现了模糊性,我们把resolution
的binding
设置成ambiguity
(模糊的意思);如果两个res
是同一个,那我们再判断一下可见性,如果说新的可见性更大,那我们就直接替换。
这里大家就会疑惑了,glob_import
是啥?我们来插入一个小插曲:
1 | fn import_kind_to_string(import_kind: &ImportKind<'_>) -> String { |
看到这大家应该都知道了吧,我就不过多解释了。
好的,回归正题,看起来这段是处理use
相关的,我们可以简单略过,接着往下看:
1 | let res = binding.res(); |
这一段我们处理了一个glob_import
和一个非glob_import
的情况,简单来说原则就是,非glob
的优先,但是有个例外:如果非glob
的是在宏中的,那么这里就会导致“模糊”(Rust 是卫生宏),这里会像上文一样把binding
设为ambiguity
。
这部分的逻辑涉及到宏的相关知识,我们先作为一个黑盒跳过,反正大概了解到了非glob
优先,会shadow
掉glob
就完事,这也符合我们的编码经验和人体工程学。
好,我们最后看最简单的一部分:
1 | let res = binding.res(); |
如果两个名字都不是glob
引入的,那么就说明在当前的命名空间中我们出现了俩一样的名字(要注意在这里解析的不是变量名,所以不允许有一样的),那么就说明出错了,返回错误抛给上层,也就是我们的define
方法中,并报错:
1 | /// Defines `name` in namespace `ns` of module `parent` to be `def` if it is not yet defined; |
好了,至此,我们看完了我们开头所说的the name xx is defined multiple times
相关的逻辑啦。
不过我们仍然遗留了一些问题,大家可以继续深入探究一下:
binding
被标记为ambiguity
后,会发生什么?module
的resolution
是怎么被解析出来的?也就是我们略过的build_reduced_graph_external
干了啥?大家可以顺着以上的问题继续探究,欢迎大家留言评论或者加入 Rust 中文社群一起讨论学习 Rust~
]]>正好发现《The Rustonomicon》(也称为 Rust 秘典、死灵书)之前的一版中文翻译(感谢@tjxing)是更新到了 2018 年,之后就再也没再更新维护过了;而这三年官方也对于这本书进行了大量的迭代升级,于是想着重新翻译一版,并尽可能持续跟进迭代,贡献给社区,也算是尽一份绵薄之力。
在线阅读地址:https://nomicon.purewhite.io/
github 地址:https://github.com/PureWhiteWu/nomicon-zh-Hans
首先,限于译者自身姿势水平,翻译有可能无法做到完全信达雅,并且有一些专业术语不知道如何翻译到中文,在这里先向大家道歉,请多包涵。
不过,译者保证所有翻译的内容都是译者阅读并调整过多次的,并且译者会努力将内容调整到满足能看懂的要求,并且做到不遗漏原文内容。
如果大家对于翻译有更好的建议或者想法,欢迎直接 PR~
目前翻译基于 commit:2747c4bb2cbc0639b733793ddb0bf4e9daa2634e,基于时间:2021/9/19
Q:为什么不基于之前已有的中文版进行改进?
A:因为翻译成中文版后,很难再回过头去看和现在的英文版原文到底差了啥,所以还不如完全重新翻译一遍。
Q:那会不会有一天你的这个版本也过期了?
A:希望没有那一天。我 watch 了英文原版的所有 PR,如果有变更(希望)能及时更新。当然,也欢迎大家一起贡献 PR。
也欢迎大家集思广益,一起建设 Rust 社区。
]]>本文档的存在是为了回答有关 Rust 编程语言的常见问题。它不是一个完整的语言指南,也不是一个教授该语言的工具。它只是一个参考,用来回答 Rust 社区中人们经常遇到的问题,并澄清 Rust 的一些设计决定背后的原因。
如果你觉得有一些常见的或重要的问题在这里没有得到解答,请在 GitHub 上针对这个 repo提一个 issue!
这些内容大部分以前都在 rust-lang/rust 库中,并且在网站上有一个专门的 FAQ 页面。但是在 2018 年的网站重新设计中,它被删除了。我在这里把它恢复了,因为这些问题中的许多问题仍然被频繁询问。
设计并实现一种安全的、并发的、实用的系统级语言。
Rust 之所以存在,是因为在这个抽象和效率水平上的其他语言并不令人满意。特别是:
Rust 作为一种替代方案存在,它既能提供高效的代码,又能提供舒适的抽象水平,同时在上述四点上都有改进。
Rust 在 2006 年作为 Graydon Hoare 的兼职项目开始,并保持了 3 年多。2009 年,当该语言成熟到可以运行基本测试并展示其核心概念时,Mozilla 参与其中。虽然它仍然由 Mozilla 赞助,但 Rust 是由来自世界各地不同地方的爱好者组成的一个多样化社区开发的。Rust 团队由 Mozilla 和非 Mozilla 成员组成,GitHub 上的rust
到目前为止已经有超过2300 个独特的贡献者。
就项目管理而言,Rust 由一个核心团队管理,为项目设定愿景和优先级。从全球角度来指导它。还有一些子团队来指导和促进特定兴趣领域的发展,包括核心语言、编译器、Rust 库、Rust 工具和 Rust 官方社区的管理。这些领域的设计都是通过[RFC](https://github.com/rust-lang/rfcs)来推进的。对于不需要 RFC 的变化,通过rustc
仓库的 PR 来决定。
主要的项目是Servo,这是 Mozilla 正在进行的实验性浏览器引擎。他们也在努力将Rust 组件整合到 Firefox 中。
现在最大的两个 Rust 开源项目是Servo和Rust 编译器本身。
尝试Rust的最简单方法是通过playpen,这是一个用于编写和运行 Rust 代码的在线应用程序。如果你想在你的系统上尝试 Rust,安装它并通过书中的猜谜游戏教程。
有几种方法。你可以。
Rust 最初的目标是创造一种安全但可用的系统编程语言。在追求这一目标的过程中,它探索了很多想法,其中一些被保留了下来(生命周期,Trait),而另一些则被抛弃了(类型状态系统,绿色线程)。另外,在 1.0 之前,很多标准库都被重写了,因为早期的设计被更新以最好地使用 Rust 的特性,并提供高质量的、一致的跨平台 API。现在 Rust 已经达到了 1.0,该语言被保证是“稳定的”;虽然它可能继续发展,但在当前 Rust 上运行的代码应该继续在未来的版本上运行。
Rust 的语言版本管理遵循SemVer,只有当需要进行编译器错误的修复、安全漏洞的修补或者需要更多注释以改变类型推断和分发的时候,才允许在小版本中对稳定的 API 进行向后不兼容的修改。更详细的小版本修改指南可参考语言和标准库的 RFC。
Rust 有三个“发布 channel”:稳定版、测试版和 nightly 版。稳定版和测试版每六周更新一次,当前的 nightly 版成为新的测试版,而当前的 nightly 版成为新的稳定版。标记为不稳定的语言和标准库功能或隐藏在特性开关后面的功能只能在 nightly 中使用。新功能以不稳定的形式出现,一旦被核心团队和相关的子团队批准,就会被“解禁”。这种方法允许实验,同时为稳定频道提供强大的向后兼容性保证。
更多的细节,请阅读 Rust 的博文“Stability as a Deliverable”。
不,你不能。Rust 努力为测试版和稳定版频道上提供的功能的稳定性提供强有力的保证。当某项功能不稳定时,这意味着我们还不能为它提供这些保证,并且不希望人们依赖它保持不变。这使我们有机会在 nightly 上尝试改变,同时仍然为寻求稳定的人保持强有力的保证。
事情一直在变稳定,测试版和稳定版频道每六周更新一次,其他时候偶尔也会接受测试版的修复。如果你在等待一个功能,而不使用 nightly,你可以通过检查问题追踪器上的B-unstable
标签来定位其追踪问题。
“特性开关”是 Rust 用来稳定编译器、语言和标准库的特性的机制。一个被“开关控制”的特性只有在 nightly 上才能访问,而且只有在通过#[feature]
属性或-Z unstable-options
命令行参数明确启用后才能访问。当一个特性被稳定化后,它就可以在稳定发布通道上使用,并且不需要明确启用,这时候这个特性就被认为是稳定的。特性开关允许开发者在开发中的实验性功能在它们在稳定语言中可用之前进行测试。
Apache 许可证包括对专利侵犯的重要保护,但它与 GPL 第 2 版不兼容。为了避免 Rust 与 GPL2 的使用出现问题,Rust 采用了 MIT 许可。
这一方面是由于原始开发者(Graydon)的偏好,另一方面是由于语言往往比网络浏览器等产品有更广泛的受众和更多样化的可能嵌入和最终用途。我们希望尽可能多地吸引这些潜在的贡献者。
非常快! 在许多基准测试中,Rust 已经可以与 C 和 C++ 竞争(比如基准游戏和其他)。
像 C++ 一样,Rust 把零成本抽象作为它的核心原则之一:Rust 的抽象没有一个施加全局性能惩罚,也没有传统意义上的任何运行时系统的开销。
鉴于 Rust 是建立在 LLVM 之上的,并努力从 LLVM 的角度类似于 Clang,任何 LLVM 的性能改进也有助于 Rust。从长远来看,Rust 的类型系统中更丰富的信息也应该能够实现 C/C++ 代码难以实现或无法实现的优化。
不,Rust 的关键创新之一是在不需要垃圾收集的同时保证内存安全(无 segfault)。
通过避免 GC,Rust 可以提供许多好处:可预测的资源清理,较低的内存管理开销,以及基本上没有运行时系统。所有这些特征都使 Rust 变得精干,并且容易嵌入到任意的上下文中,并使其更容易将 Rust 代码与有 GC 的语言集成。
Rust 通过其所有权和借用系统避免了对 GC 的需求,但同样的系统也有助于解决一系列其他问题,包括
一般的资源管理和并发性。
当单一所有权不够用时,Rust 程序依靠标准的引用计数智能指针类型Rc
,以及它的线程安全对应类型Arc
,而不是 GC。
然而,我们正在研究可选的垃圾收集作为未来的扩展。我们的目标是使其能够顺利地与垃圾收集的运行时,例如那些由Spidermonkey和V8的 JavaScript 引擎提供的。最后,一些人已经在没有编译器的支持情况下研究了实现纯 Rust 垃圾收集器。
Rust 编译器不会用优化来编译,除非被要求这样做,因为优化会降低编译速度,而且在开发过程中通常是不可取的。
如果你用cargo
编译,请使用--release
标志。如果你直接用rustc
编译,使用-O
标志。这两个标志中的任何一个都会打开优化功能。
代码翻译和优化。Rust 提供了高水平的抽象,可以编译成高效的机器代码,这些翻译需要时间来运行,特别是在优化时。
但是 Rust 的编译时间并不像看起来那么糟糕,而且有理由相信它会有所改善。当比较 C++ 和 Rust 之间类似规模的项目时,一般认为整个项目的编译时间是相当的。人们普遍认为 Rust 的编译速度很慢,这在很大程度上是由于 C++ 和 Rust 的编译模型的不同。C++ 的编译单元是文件,而 Rust 的编译单元是由许多文件组成的 crate。因此,在开发过程中,修改一个 C++ 文件可能会导致比 Rust 少得多的重新编译。目前正在努力重构编译器以引入增量编译,这将为 Rust 提供 C++ 模型的编译时间优势。
除了编译模型之外,Rust 的语言设计和编译器实现还有其他几个方面会影响编译时的性能。
首先,Rust 有一个适度复杂的类型系统,必须花费不可忽视的编译时间来执行约束,使 Rust 在运行时安全。
其次,Rust 编译器有长期的技术债务,特别是产生了质量很差的 LLVM IR,LLVM 必须花时间“修复”。在 Rust 编译器中加入一个新的内部表示法,称为MIR,有可能进行更多的优化,提高生成的 LLVM IR 的质量,但这项工作还没有发生过。
第三,Rust 使用 LLVM 来生成代码是一把双刃剑:虽然它使 Rust 拥有世界一流的运行时性能,但 LLVM 是一个大型框架,不注重编译时的性能,特别是在处理质量差的输入时。
最后,虽然 Rust 的首选策略是单态泛型(类似于 C++),但它要求生成的代码比其他翻译策略多得多。Rust 的程序员可以使用特征对象,通过使用动态调度来换取这种代码的膨胀。
HashMap
很慢?默认情况下,Rust 的HashMap
使用 SipHash 散列算法,该算法旨在防止散列表碰撞攻击,同时提供各种工作负载下的合理性能。
虽然 SipHash 在许多情况下表现出有竞争力的性能,但它比其他散列算法明显慢的一种情况是在短键,如整数。这就是为什么 Rust 程序员经常观察到HashMap
的性能缓慢。在这种情况下,经常推荐使用 FNV hasher,但要注意它不具备与 SipHash 一样的抗碰撞特性。
有,但它只在 nightly 上可用。我们最终计划建立一个可插拔的系统来进行综合基准测试,但与此同时,目前的系统被认为是不稳定的。
一般来说,不会。在有限的情况下可能会进行尾部调用优化,但不保证。由于这个功能一直是人们所希望的,Rust 保留了一个关键字(become
),尽管目前还不清楚它在技术上是否可行,也不清楚它是否会被实现。曾经有一个拟议的扩展,允许在某些情况下消除尾随调用,但目前被推迟了。
不是 Java 等语言所使用的典型意义上的运行时,但是 Rust 标准库的一部分可以被认为是“运行时”,它提供了一个堆、回溯、解开和堆栈守护。有一个少量的初始化代码,在用户的main
函数之前运行。Rust 标准库还链接了 C 标准库,它也做了类似的运行时初始化。Rust 代码可以在没有标准库的情况下进行编译,在这种情况下,运行时与 C 语言大致相当。
使用大括号来表示块是各种编程语言中常见的设计选择,而 Rust 的一致性对于已经熟悉这种风格的人来说是很有用的。
大括号还可以为程序员提供更灵活的语法,并在编译器中提供更简单的解析器。
if
条件上不加小括号,那么为什么我必须在单行块周围加上大括号?为什么不允许使用 C 语言的风格?C 语言要求“if”语句的条件必须有小括号,但大括号是可选的,而 Rust 对其“if”表达式做出了相反的选择。这使得条件语句与语句主体明确分开,并避免了可选大括号的危害,这可能导致在重构过程中出现容易被忽略的错误,比如苹果的 goto fail 错误。
Rust 的整体设计倾向于限制语言的大小,同时启用强大的库。虽然 Rust 确实为数组和字符串字面提供了初始化语法,但这是语言中唯一的集合类型。其他库定义的类型,包括无处不在的Vec
集合类型,都使用宏进行初始化,如vec!
宏。
这种使用 Rust 的宏设施来初始化集合的设计选择在未来可能会被通用地扩展到其他集合,不仅可以简单地初始化HashMap
和Vec
,还可以初始化其他集合类型,如BTreeMap
。同时, 如果你想要一个更方便的初始化集合的语法, 你可以创建你自己的宏来提供它.
Rust 是一种非常面向表达式的语言,而“隐式返回”是这种设计的一部分。像if
s, match
es, 和普通块这样的结构在 Rust 中都是表达式。例如,下面的代码检查一个i64
是否为奇数,通过简单地将其作为一个值来返回结果。
1 | fn is_odd(x: i64) -> bool { |
虽然它可以进一步简化,比如说。
1 | fn is_odd(x: i64) -> bool { |
在每个例子中,函数的最后一行是该函数的返回值。需要注意的是,如果一个函数以分号结束,其返回类型将是()
,表示没有返回值。隐式返回必须省略分号,才能发挥作用。
显式返回只有在隐式返回不可能时才会使用,因为你要在函数主体结束前返回。虽然上面的每个函数都可以用return
关键字和分号来写,但这样做是不必要的冗长,而且与 Rust 代码的惯例不一致。
在 Rust 中,声明往往带有明确的类型,而实际代码的类型是推断出来的。这种设计有几个原因:
match
必须是详尽的?为了帮助重构和清晰化。
首先,如果每一种可能性都被match
所覆盖,那么将来在enum
中增加变体将导致编译失败,而不是在运行时出错。这种类型的编译器帮助使得 Rust 中的无畏重构成为可能。
其次,穷举式检查使默认情况的语义变得明确:一般来说,非穷举式match
的唯一安全方式是在没有匹配到任何东西时让线程恐慌。Rust 的早期版本并不要求match
情况是详尽的,而且发现它是一个很大的 bug 来源。
通过使用_
通配符,可以很容易地忽略所有未指定的情况。
1 | match val.do_something() { |
f32
和f64
中的哪一个?选择哪种方式取决于程序的目的。
如果你对浮点数的最大精度感兴趣, 那么就选择f64
. 如果你对保持数值的大小或最大的效率更感兴趣,并且不关心每个数值的位数较少所带来的误差,那么f32
更好。对f32
的操作通常更快,即使是在 64 位硬件上。作为一个常见的例子,图形编程通常使用f32
,因为它需要高性能,而 32 位浮点数足以代表屏幕上的像素。
如果有疑问,可以选择f64
以获得更大的精度。
HashMap
或BTreeMap
的键?浮点数可以用==
, !=
, <
, <=
, >
, 和>=
运算符,以及partial_cmp()
函数进行比较。==
和!=
是PartialEq
特性的一部分,而<
、<=
、>
、>=
和partial_cmp()
是PartialOrd
特性的一部分。
浮点数不能用cmp()
函数进行比较,它是Ord
特性的一部分,因为浮点数没有总排序。此外,浮点数没有全等关系,所以它们也没有实现Eq
特性。
由于浮点数NaN
不小于、大于或等于任何其他浮点数或其本身,所以浮点数没有总排序或平等关系。
因为浮点数没有实现Eq
或Ord
,所以它们不能被用于特质边界需要这些特质的类型,例如BTreeMap
或[HashMap
]。这一点很重要,因为这些类型假设它们的键提供了一个总排序或总等价关系,否则会出现故障。
有一个crate包装了f32
和f64
以提供Ord
和Eq
的实现,这在某些情况下可能很有用。
有两种方法:as
关键字,它为原始类型做简单的转换,以及Into
和From
特性,它们是为一些类型转换而实现的(你也可以为你自己的类型实现)。Into
和From
特性只在转换无损的情况下实现,所以例如,f64::from(0f32)
会被编译,而f32::from(0f64)
不会。另一方面,as
将在任何两个原始类型之间进行转换,必要时截断数值。
Preincrement 和 Postincrement(以及与之对应的 Decrement)虽然方便,但也相当复杂。它们需要对计算顺序的了解,并经常导致 C 和 C++ 中的微妙错误和未定义行为。和x = x + 1
相比x += 1
只是稍微长一点,但不明确。
String
或Vec<T>
转换为一个片断(&str
和&[T]
)?通常情况下,你可以在期望有片断的地方传递一个对String
或Vec<T>
的引用。使用Deref coercions,String
s和Vec
s在用&
或&mut
传递引用时,将自动联合到各自的片上。
在&str
和&[T]
上实现的方法可以直接访问String
和Vec<T>
。例如,some_string.trim()
可以工作,尽管trim
是&str
上的方法,而some_string
是一个String
。
在某些情况下,例如通用代码,有必要进行手动转换。手动转换可以使用切片操作符来实现,像这样。&my_vec[...]
。
&str
转换到String
或反过来?to_string()
方法可以将&str
转换为String
,当你借用一个引用时,String
自动转换为&str
。这两种情况在下面的例子中都有演示。
1 | fn main() { |
String
是一个在堆上分配的 UTF-8 字节的自有缓冲区。可变的String
可以被修改,根据需要增加其容量。&str
是在其他地方分配的String
的一个固定容量的“视图”,如果是从String
中引用的片断,通常在堆上,如果是字符串字面,在静态内存中。
&str
是由 Rust 语言实现的原始类型,而String
是由标准库实现的。
String
中进行 O(1) 的字符访问?你不能。至少在你不清楚“字符”是什么意思的情况下,以及在对字符串进行预处理以找到所需字符的索引的情况下是不行的。
Rust 字符串是 UTF-8 编码的。UTF-8 中的单个视觉字符不一定是一个字节,因为它在 ASCII 编码的字符串中是一个字节。每个字节被称为“代码单元”(在 UTF-16 中,代码单元是 2 个字节;在 UTF-32 中是4个字节)。“代码点”由一个或多个代码单元组成,并组合成最接近于字符的“字素群”。
因此,即使你可以对 UTF-8 字符串中的字节进行索引,你也无法在恒定时间内访问第 i 个码位或字母群。然而,如果你知道所需的码位或字形群从哪个字节开始,那么你就可以在恒定时间内访问它。包括str::find()和 regex 匹配在内的函数都会返回字节索引,以方便这种访问。
str
类型是 UTF-8,因为我们在野外观察到更多的文本是用这种编码的–特别是在网络传输中,它是 endian-agnostic 的–而且我们认为最好不要让 I/O 的默认处理涉及到在每个方向重新编码代码点。
这确实意味着在一个字符串中定位一个特定的 Unicode 编码点是一个 O(n) 操作,尽管如果开始的字节索引已经知道,那么它们可以在 O(1) 中被访问。一方面,这显然是不可取的;另一方面,这个问题充满了权衡,我们想指出几个重要的限定条件。
扫描一个str
的 ASCII 范围的代码点仍然可以安全地逐个字节地进行。如果你使用.as_bytes()
,取出一个u8
只需花费O(1)
,并产生一个可以被转换并与 ASCII 范围的char
比较的值。因此,如果你(比如)在\n
上断行,基于字节的处理方法仍然有效。UTF-8 就是这样被精心设计的。
大多数“面向字符”的文本操作只有在非常有限的语言假设下才能工作,如“仅 ASCII 范围的代码点”。在 ASCII 范围之外,你往往不得不使用复杂的(非恒定时间)算法来确定语言单位(字形、单词、段落)的边界。我们建议使用一种“诚实的”具有语言意识的、经 Unicode 批准的算法。
char
类型是 UTF-32。如果你确定你需要做一个代码点的算法,写一个type wstr = [char]
,并将一个str
一次性解压到其中,然后用wstr
工作,这是非常容易的。换句话说:如果你需要使用这种编码,语言没有默认解码为 UTF32
的事实不应该阻止你解码(或以任何其他方式重新编码)。
关于为什么 UTF-8 通常比 UTF-16 或 UTF-32 更受欢迎,请阅读 UTF-8 Everywhere 宣言。
Rust 有四对字符串类型,每一对都有不同的用途。在每一对中,都有一个“自有”的字符串类型,和一个“分片”的字符串类型。这个组织看起来像这样。
“Slice” type | “Owned” type | |
---|---|---|
UTF-8 | str | String |
OS-compatible | OsStr | OsString |
C-compatible | CStr | CString |
System path | Path | PathBuf |
Rust 的不同字符串类型有不同的用途。String
和str
是 UTF-8 编码的通用字符串。OsString
和OsStr
是根据当前平台编码的,在与操作系统交互时使用。CString
和CStr
相当于C 语言中的字符串,在 FFI 代码中使用。PathBuf
和Path
是对OsString
和OsStr
的方便包装,提供特定于路径操作的方法。
&str
又接受String
的函数?有几种选择,取决于函数的需要。
Into<String>
绑定。AsRef<str>
绑定。Cow<str>
作为输入类型。Into<String>
在这个例子中,该函数将同时接受自有字符串和字符串片,要么不做任何事情,要么在函数主体内将输入的字符串转换为自有字符串。注意,转换需要明确进行,否则不会发生。
1 | fn accepts_both<S: Into<String>>(s: S) { |
AsRef<str>
在这个例子中,该函数将接受拥有的字符串和字符串片断,要么不做任何事情,要么将输入的字符串片断转换为字符串。这可以通过引用输入来自动完成,像这样。
1 | fn accepts_both<S: AsRef<str>>(s: &S) { |
Cow<str>
在这个例子中,函数接收了一个Cow<str>
,它不是一个通用类型,而是一个容器,根据需要包含一个自有的字符串或字符串片断。
1 | fn accepts_cow(s: Cow<str>) { |
如果你实现这些数据结构的原因是为了在其他程序中使用它们,那就没有必要了,因为这些数据结构的有效实现已经由标准库提供了。
然而,如果你的理由只是为了学习,那么你很可能需要涉足不安全代码。虽然这些数据结构可以完全用安全的 Rust 来实现,但其性能可能会比使用不安全的代码要差。原因很简单,向量和链接列表等数据结构依赖于指针和内存操作,而这些操作在安全 Rust 中是不允许的。
例如,一个双链接列表需要对每个节点有两个可变引用,但这违反了 Rust 的可变引用别名规则。你可以用Weak<T>
来解决这个问题,但是性能会比你想要的差。使用不安全的代码,你可以绕过可变引用别名规则的限制,但必须手动验证你的代码是否引入了内存安全违规。
最简单的方法是通过使用集合的IntoIterator
实现。下面是一个关于&Vec
的例子。
1 | let v = vec! [1,2,3,4,5]; |
Rust 的for
循环对它们要迭代的东西调用into_iter()
(定义在IntoIterator
trait 上)。任何实现了IntoIterator
trait 的东西都可以用for
循环进行循环。IntoIterator
是为&Vec
和&mut Vec
实现的,导致来自into_iter()
的迭代器借用集合的内容,而不是移动/消费它们。这对其他标准集合也是如此。
如果需要一个移动/消耗的迭代器,编写for
循环时不要在迭代中使用&
或&mut
。
如果你需要直接访问一个借用的迭代器,你通常可以通过调用iter()
方法得到它。
你不一定要这样做。如果你直接声明一个数组,大小是根据元素的数量推断出来的。但是如果你声明的是一个接收固定大小的数组的函数,编译器就必须知道这个数组有多大。
有一点需要注意的是,目前 Rust 并没有对不同大小的数组提供泛型。如果你想接受一个连续的可变数量的值的容器,使用Vec
或 slice(取决于你是否需要所有权)。
至少有四种选择(在Too Many Linked Lists中详细讨论过)。
Rc
和Weak
实现它,以允许节点的共享所有权。尽管这种方法需要付出内存管理的代价。UnsafeCell
使用借用的引用。对于这种方法有解释和代码。这是有可能的,但是这样做没有用。该结构会被自己永久借用,因此不能被移动。下面是一些说明这个问题的代码。
1 | use std::cell::Cell; |
这些是同一事物的不同术语。在所有的情况下,这意味着值已经被转移到另一个所有者那里,并且脱离了原所有者的占有,原所有者不能再使用它。如果一个类型实现了Copy
特性,那么原所有者的值就不会被废止,仍然可以使用。
如果一个类型实现了Copy
特性,那么它在传递给函数时就会被复制。Rust 中的所有数字类型都实现了Copy
,但结构类型默认不实现Copy
,所以它们被移动。这意味着该结构不能再被用于其他地方,除非通过返回将其移回函数之外。
这个错误意味着你要使用的值已经被转移到一个新的所有者那里。首先要检查的是有关的移动是否是必要的:如果它移动到一个函数中,也许可以重写该函数以使用一个引用,而不是移动。否则,如果被移动的类型实现了Clone
,那么在移动前对其调用clone()
将移动它的一个副本,留下原始的仍然可以继续使用。但是请注意,克隆一个值通常应该是最后的手段,因为克隆可能很昂贵,会导致进一步的分配。
如果移动的值是你自己的自定义类型,考虑实现Copy
(用于隐式复制,而不是移动)或Clone
(显式复制)。Copy
最常用的实现方式是#[derive(Copy, Clone)]
(Copy
需要Clone
),而Clone
则是`#[derive(Clone)]。
如果这些都不可能,你可能想修改获得所有权的函数,以便在函数退出时返回数值的所有权。
self
、&self
或&mut self
的规则是什么?self
。&self
。&mut self
。借用检查器在评估 Rust 代码时只应用一些规则,这些规则可以在 Rust 书的借用部分中找到。这些规则是:
首先,任何借用必须持续的范围不大于所有者的范围。第二,你可以有这两种借用中的一种或另一种,但不能同时存在:
- 对一个资源的一个或多个引用(&T)。
- 一个可变的引用(&mut T)。
虽然这些规则本身很简单,但持续地遵守这些规则并不容易,特别是对于那些不习惯推理寿命和所有权的人来说。
了解借用检查器的第一步是阅读它产生的错误。为了确保借用检查器在解决它所发现的问题方面提供高质量的帮助,我们做了大量的工作。当你遇到借用检查器的问题时,第一步是慢慢地、仔细地阅读所报告的错误,只有在理解了所描述的错误之后,才能接近代码。
第二步是熟悉 Rust 标准库提供的所有权和可变性相关的容器类型,包括Cell
、RefCell
和Cow
。这些都是表达某些所有权和可变性情况的有用和必要的工具,并且被写成性能代价最小。
理解借用检查器最重要的一个部分是实践。Rust 的强静态分析保证是严格的,与许多程序员之前的工作有很大不同。需要一些时间才能完全适应一切。
如果你发现自己在借用检查器上挣扎,或者没有耐心了,请随时联系 Rust 社区寻求帮助。
Rc
有用?这在Rc
的官方文档中有所涉及,Rust 的非原子引用计算的指针类型。简而言之,Rc
和它的线程安全表亲Arc
对于表达共享所有权是很有用的,当没有人访问相关内存时,系统会自动将其取消。
要从一个函数中返回一个闭包,它必须是一个“移动闭包”,也就是说,闭包是用move
关键字声明的。正如 Rust 书中所解释的,这使得闭包拥有自己的捕获变量的副本,独立于其父级堆栈框架。否则,返回一个闭包将是不安全的,因为它将允许访问不再有效的变量;换句话说:它将允许读取可能无效的内存。闭包还必须被包裹在一个Box
中,这样它就被分配在堆上。阅读更多关于这个的内容在书中。
deref coercion 是一个很方便的 coercion。自动将对指针的引用(例如, &Rc<T>
或 &Box<T>
)转换为对其内容的引用(例如,&T
)。Deref coercion 的存在是为了使 Rust 的使用更符合人体工程学,并通过Deref
特性实现。
Deref 的实现表明实现类型可以通过调用deref
方法转换为目标类型,该方法接收对调用类型的不可变的引用,并返回对目标类型的引用(具有相同的生命周期)。*
前缀操作符是deref
方法的简写。
它们被称“coercions”,因为下面的规则,这里引用了 Rust 书。
如果你有一个类型
U
,并且它实现了Deref<Target=T>
,那么&U
的值将自动被强制为T
。
例如,如果你有一个&Rc<String>
,它将通过这个规则联合成一个&String
,然后以同样的方式联合成一个&str
。因此,如果一个函数需要一个&str
参数,你可以直接传入一个&Rc<String>
,所有的强制都通过Deref
特性自动处理。
最常见的 Derefcoercions 种类是:
&Rc<T>
到&T
。&Box<T>
到&T
。&Arc<T>
到&T
。&Vec<T>
改为&[T]
。&String
改为&str
。生命周期是 Rust 对内存安全问题的回答。它允许 Rust 确保内存安全而不需要付出垃圾回收的性能代价。它们是基于各种学术工作的。
'a
语法来自于 ML 系列编程语言,其中'a
用于表示一个通用类型参数。对于 Rust 来说,这种语法必须是明确的、明显的,并且适合在类型声明中与 traits 和 reference 一起使用。其他的语法已经被讨论过了,但是还没有其他的语法被证明是更好的。
你需要确保借来的东西会超过函数的寿命。这可以通过将输出寿命与一些输入寿命绑定来实现,比如说。
1 | type Pool = TypedArena<Thing>; |
另一种方法是通过返回一个自有类型如String
来完全消除引用。
1 | fn happy_birthday(name: &str, age: i64) -> String { |
这种方法比较简单,但往往会导致不必要的分配。
&'a T
,而有些则没有,如&T
?事实上, 所有引用类型都有一个寿命, 但大多数时候你不必明确写出
它是明确的。规则如下。
在一个函数体中,你永远不需要明确地写出生命周期;正确的值应该总是被推断出来的。
在一个函数的签名中(例如,在其参数的类型或其返回类型中),你可能会需要写一个生命周期。这里的生命周期使用一个简单的默认方案,称为“lifetime elision”。它由以下三条规则组成:
最后,在“结构”或“枚举”的定义中,所有的生命周期必须被明确地声明。
如果这些规则导致了编译错误,Rust 编译器将提供一个错误信息,指出所造成的错误,并根据推理过程的哪一步造成的错误,提出一个潜在的解决方案。
构造一个&Foo
或&mut Foo
类型的值的唯一方法是指定一个引用所指向的Foo
类型的现有值。引用在给定的代码区域内(引用的生命周期)“借用”原始值,在借用期间,被借用的值不能被移动或销毁。
你可以用Option
类型来做,它可以是Some(T)
或None
。Some(T)
表示其中包含一个T
类型的值,而None
表示没有值。
单态化是将泛型函数(或结构)的每一次使用都基于调用该函数(或使用该结构)的参数类型用特定的实例进行单态化。
在单态化过程中,泛型函数的一个新副本被翻译为该函数实例化的每一组独特类型。这与 C++ 使用的策略相同。它的结果是为每个调用点专门设计的快速代码,并且是静态调度的,其代价是用许多不同类型实例化的函数会导致“代码膨胀”,即多个函数实例会导致比用其他翻译策略创建的二进制文件更大。
接受 Trait Object 而不是类型参数的函数不进行单态化。相反,特质对象上的方法在运行时被动态地分配。
函数和闭包在操作上是等价的,但由于它们的实现方式不同,所以有不同的运行时表示。
函数是语言的内置基元,而闭包本质上是三种特征之一的语法糖。Fn
, FnMut
, 和 FnOnce
。当你创建一个闭包时,Rust 编译器会自动创建一个结构,实现这三个结构的相应特性,并将捕获的环境变量作为成员,并使该结构可以作为一个函数被调用。裸露的函数不能捕获环境。
这些特征之间的最大区别是它们如何接受“self”参数。Fn
使用&self
,FnMut
使用&mut self
,而FnOnce
使用self
。
即使一个闭包没有捕获任何环境变量,它在运行时也被表示为两个指针,与其他闭包相同。
高等类型是指具有未填充参数的类型。类型构造器,如Vec
,Result
,和HashMap
都是高类型类型的例子:每个类型都需要一些额外的类型参数,以便实际表示一个特定的类型,如Vec<u32>
。对高类型的支持意味着这些“不完整”的类型可以在任何可以使用“完整”类型的地方使用,包括作为函数的泛型。
任何完整的类型,像i32
,bool
或char
都属于*
类型(这个符号来自类型理论领域)。一个有一个参数的类型,像Vec<T>
是属于* -> *
,意思是Vec<T>
接收一个完整的类型,像i32
,并返回一个完整类型Vec<i32>
。一个有三个参数的类型,如HashMap<K, V, S>
是一种* -> * -> * -> *
,并接收三个完整的类型(如i32
,String
,和RandomState
),产生一个新的完整类型HashMap<i32, String, RandomState>
。
除了这些例子之外,类型构造函数还可以接受生命周期参数,我们将其表示为Lt
。例如,slice::Iter
的种类是Lt -> * -> *
,因为它必须像Iter<'a, u32>
一样被实例化。
由于缺乏对高阶类型的支持,因此很难编写某些类型的通用代码。对于像迭代器这样的概念的抽象来说,这尤其成问题,因为迭代器通常至少要在一个生命周期内进行参数化。这反过来又阻碍了对 Rust 的集合进行抽象的 traits 的创建。
另一个常见的例子是像 functors 或 monads 这样的概念,它们都是类型构造函数,而不是单一类型。
Rust 目前并不支持高类型的类型,因为与我们想做的其他改进相比,这并不是一个优先事项。由于该设计是一个重大的、跨领域的变化,我们也想谨慎地对待它。但是目前缺乏支持并没有什么内在的原因。
<T=Foo>
这样的命名类型参数是什么意思?这些被称为关联类型,它们允许表达不能用where
子句表达的特征边界。例如,一个泛型约束X: Bar<T=Foo>
意味着”X
必须实现 trait Bar
,在Bar
的实现中,X
必须选择Foo
作为Bar
的关联类型T
“。这种约束不能通过where
子句来表达的例子包括像Box<Bar<T=Foo>>
这样的 trait object。
关联类型的存在是因为泛型经常涉及类型家族,其中一个类型决定了一个家族中的所有其他类型。例如,一个图的 trait 可能将图本身作为其Self
类型,并有节点和边的关联类型。每个图的类型唯一地决定了相关的类型。使用关联类型使这些类型族的工作更加简洁,并且在许多情况下提供更好的类型推理。
你可以使用它们的关联特性为各种运算符提供自定义的实现。Add
代表+
,Mul
代表*
,等等。它看起来像这样。
1 | use std::ops::Add。 |
以下操作符可以被重载。
Operation | Trait |
---|---|
+ | Add |
+= | AddAssign |
binary - | Sub |
-= | SubAssign |
* | Mul |
*= | MulAssign |
/ | Div |
/= | DivAssign |
unary - | Neg |
% | Rem |
%= | RemAssign |
& | BitAnd |
&= | BitAndAssign |
| | BitOr |
|= | BitOrAssign |
^ | BitXor |
^= | BitXorAssign |
! | Not |
<< | Shl |
<<= | ShlAssign |
>> | Shr |
>>= | ShrAssign |
* | Deref |
mut * | DerefMut |
[] | Index |
mut [] | IndexMut |
Eq
/PartialEq
和Ord
/PartialOrd
之间划分?在 Rust 中,有一些类型的值只有部分排序,或者只有部分相等。部分排序的意思是,在给定的类型中可能存在既不小于也不大于对方的值。部分平等意味着可能有给定类型的值不等于自己。
浮点类型(f32
和f64
)是每种类型的很好的例子。任何浮点类型都可以有NaN
(意思是“不是一个数字”)的值。NaN
不等于自己(NaN == NaN
是 false),也不小于或大于任何其他浮点值。因此,f32
和[f64
]都实现了PartialOrd
和PartialEq
,但没有实现Ord
和``Eq`]Eq。
正如在先前关于 floats 的问题中解释的那样,这些区别很重要,因为有些集合依赖于总排序/equality,以便给出正确的结果。
使用read_to_string()
方法, 这个方法是在std::io
中的Read
特性上定义。
1 | use std::io::Read; |
File
类型实现了Read
特性,它有多种函数用于读写数据,包括read()
, read_to_end()
, bytes()
, chars()
, 和take()
。这些函数中的每一个都从一个给定的文件中读取一定量的输入。read()
在一次调用中读取底层系统所能提供的输入量。read_to_end()
将整个缓冲区读入一个向量,需要多少空间就分配多少。bytes()
和chars()
分别允许你对文件的字节和字符进行迭代。最后,take()
允许你从文件中读取任意数量的字节。总的来说,这些应该允许你有效地读入任何你需要的数据。
对于缓冲读取,使用BufReader
结构,这有助于减少读取时的系统调用数量。
使用 tokio。
最简单的方法是使用Args
,它提供了一个输入参数的迭代器。
如果你正在寻找更强大的库,在 crates.io 上有一些选项。
异常使控制流的理解复杂化,它们在类型系统之外表达有效性/无效性,而且它们与多线程代码(Rust 的主要焦点)的互操作性很差。
Rust 更倾向于采用基于类型的错误处理方法,这在书中有详细介绍。这与 Rust 的控制流、并发性和其他一切都更加吻合。
unwrap()
是一个提取Option
或Result
里面的值的函数,如果没有值就会 panic。
unwrap()
不应该是你处理预期出现的错误的默认方式,例如用户输入不正确。在生产代码中,它应该被视为一个断言,即该值是非空的,如果违反,将使程序崩溃。
它对快速原型也很有用,在那里你还不想处理错误,或者在博客文章中,错误处理会分散对重点的注意力。
try!
宏的示例代码时,为什么我得到一个错误?这可能是函数的返回类型的问题。try!
宏要么从Result
中提取数值,要么提前返回,错误是Result
携带的。这意味着try
只对返回Result
本身的函数有效,其中Err
构造的类型实现了From::from(err)
。特别是,这意味着try!
宏不能在main
函数中工作。
如果你正在寻找一种方法来避免在其他人的代码中处理Result
,总是有unwrap()
,但这可能不是你想要的。Result
是一个指标,表明某些计算可能会或可能不会成功完成。要求你明确地处理这些失败是 Rust 鼓励健壮性的方式之一。Rust 提供了像try!
宏这样的工具,使处理失败的过程符合人体工程学。
如果你真的不想处理错误,可以使用unwrap()
,但要知道,这样做意味着代码在失败时 panic,这通常会导致关闭进程。
如果是同步的,修改是安全的。修改一个静态的Mutex
(通过lazy-static crate 懒惰地初始化)不需要一个unsafe
块,修改一个静态的AtomicUsize
(可以不用 lazy_static 初始化)也是如此。
更一般地说,如果一个类型实现了Sync
,并且没有实现Drop
,它可以在static
中使用。
目前不能。Rust 的宏是“卫生宏”,它有意避免捕捉或创建可能与其他标识符发生意外碰撞的标识符。它们的功能与通常与 C 预处理器相关的宏的风格明显不同。宏调用只能出现在被明确支持的地方:项目、方法声明、语句、表达式和模式。这里,“方法声明”指的是可以放置方法的空白处。它们不能被用来完成部分方法声明。按照同样的逻辑,它们也不能用来完成一个部分变量声明。
Rust 程序可以使用 gdb 或 lldb 进行调试,与 C 和 C++ 相同。事实上,每一个 Rust 的安装都带有 rust-gdb 和 rust-lldb 中的一个或两个(取决于平台支持)。这些是对 gdb 和 lldb 的封装,并启用了 Rust pretty-printing。
rustc
说标准库代码中发生了 panic。我如何定位我的代码中的错误?这个错误通常是由客户端代码中unwrap()
ing一个None
或Err
引起的。通过设置环境变量RUST_BACKTRACE=1
来启用回溯,有助于获得更多信息。在调试模式下编译(默认为“cargo build”)也有帮助。使用调试器,如提供的rust-gdb
或rust-lldb
也很有帮助。
Rust 的开发环境有很多选择,所有这些都在非官方的 IDE 支持页面上有详细说明。
memcpy
字节?如果你想安全地克隆一个现有的分片,你可以使用clone_from_slice
。
要复制可能重叠的字节,使用copy
。要复制不重叠的字节,使用copy_nonoverlapping
。这两个函数都是“不安全”的,因为它们都可以被用来破坏语言的安全保证。在使用它们时要注意。
当然可以。Rust 程序可以使用#![no_std]
属性设置为不加载标准库。设置了这个属性后,你可以继续使用 Rust 核心库,它只是平台无关的原语。因此,它不包括 IO、并发性、堆分配等。
是的!事实上,有几个正在进行的项目就是这样。
i32
或f64
?你应该看看 byteorder crate,它提供了相应的实用程序。
默认情况下不是。在一般情况下,enum
和struct
的布局是未定义的。这允许编译器进行潜在的优化,比如为判别式重新使用填充物,压缩嵌套的enum
的变体,重新排序字段以移除填充物,等等。不携带数据的enum
(“C-like”)有资格拥有一个定义的表示。这种枚举
很容易区分,因为它们只是一个没有数据的名字列表。
1 | snum CLike { |
#[repr(C)]
属性可以应用于这些“enum”,使它们在同等的 C 代码中具有相同的表示。这允许在 FFI 代码中使用 Rust 的“enum”,而在大多数情况下也使用 C 的“enum”。该属性也可以应用于struct
,以获得与C struct
相同的布局。
平台特定行为可以用条件编译属性来表达,如target_os
, target_family
, target_endian
,等等。
是的,它可以! 在 Android和 iOS 中都已经有使用 Rust 的例子。它确实需要一些工作来设置,但 Rust 在这两个平台上的功能都很好。
有可能。Rust 对asm.js和WebAssembly都有实验性支持。
在 Rust 中可以进行交叉编译,但需要一点工作来设置。每个 Rust 编译器都是一个交叉编译器,但是库需要针对目标平台进行交叉编译。
Rust 确实为每个支持的平台分发了标准库的副本,这些副本包含在分发页面上找到的每个构建目录的rust-std-*
文件中,但目前还没有自动安装的方法。
使用
的这个库?有很多可能的答案,但一个常见的错误是没有意识到use
声明是相对于 crate root 的。试着改写你的声明,使用它们在你的项目根文件中定义的路径,看看是否能解决这个问题。
还有“self”和“super”,它们分别将“use”路径区分为相对于当前 mod 或父 mod。
关于use
库的完整信息,请阅读 Rust 书中的“Packages, Crates, and Modules”一章。
mod
声明 mod 文件,而不是直接use
它们?在 Rust 中,有两种方法来声明模块,内联或在另一个文件中。下面是各自的一个例子。
1 | // In main.rs |
1 | // In main.rs |
在第一个例子中,模块被定义在它所使用的同一文件中。在第二个例子中,主文件中的模块声明告诉编译器寻找hello.rs
或hello/mod.rs
,并加载该文件。
注意mod
和use
之间的区别:mod
声明一个模块的存在,而use
引用一个在其他地方声明的模块,将其内容纳入当前模块的范围。
对于定义在 trait 上的方法,你必须明确导入 trait 声明。这意味着仅仅导入一个结构实现 trait 的模块是不够的,你还必须导入 trait 本身。
use
声明?它可能可以,但你也不希望它这样做。虽然在很多情况下,编译器有可能通过简单地寻找给定标识符的定义位置来确定导入的正确模块,但在一般情况下可能不是这样的。rustc
中任何用于选择竞争性选项的决策规则,在某些情况下可能会引起惊讶和混乱,Rust 更倾向于明确说明名称的来源。
例如,编译器可以说,在标识符定义相互竞争的情况下,会选择最早导入的模块的定义。所以如果模块foo
和模块bar
都定义了标识符baz
,但是foo
是第一个注册的模块,编译器会插入use foo::baz;
。
1 | mod foo; |
如果你知道这种情况会发生,也许它可以节省少量的按键,但它也大大增加了当你真正想把baz()
变成bar::baz()
时出现令人惊讶的错误信息的可能性,而且它通过使函数调用的意义依赖于模块声明而降低了代码的可读性。这些都是我们不愿意做的折衷。
然而,IDE 可以帮助管理声明,这将给你带来两方面的好处:机器协助拉入名字,但明确声明这些名字的来源。
用 libloading 导入 Rust 中的动态库,它提供了一个跨平台的动态链接系统。
引用 crates.io 设计的官方解释:
在使用 crates.io 的第一个月里,很多人问我们是否有可能引入命名空间。
虽然 namespace 允许多个作者使用单一的、通用的名称,但它们增加了包在 Rust 代码中的引用和人类对包的交流的复杂性。乍一看,它们允许多个作者使用“http”这样的名字,但这仅仅意味着人们需要将这些包称为“wycats’http”或“reem’http”,与“wycats-http”或“reem-http”这样的包名相比没有什么好处。
当我们研究没有命名空间的软件包生态系统时,我们发现人们倾向于使用更有创意的名字(如
nokogiri
而不是tenderlove's libxml2
)。这些有创意的名字往往简短易记,部分原因是缺乏任何层次结构。它们使人们更容易简洁明了地交流软件包。他们创造了令人兴奋的品牌。我们已经看到了一些 10,000+ 软件包生态系统的成功,如 NPM 和 RubyGems,它们的社区在一个单一的命名空间内蓬勃发展。简而言之,我们认为如果 Piston 选择
bvssvni/game-engine
这样的名字(允许其他用户选择wycats/game-engine
)而不是简单的piston
,那么 Cargo 的生态系统就不会好转。因为命名空间在很多方面严格来说都比较复杂,而且如果将来有必要的话,还可以兼容添加,所以我们要坚持使用单一的共享命名空间。
标准库不包括 HTTP 的实现,所以你要使用一个外部的 crate。
reqwest 是最简单的。它建立在hyper上,用 Rust 编写,但也有一些其他的。curl crate 被广泛使用,它提供了与 curl 库的绑定。
有多种方法可以在 Rust 中编写 GUI 应用程序。只要看看这个 GUI 框架的列表。
Serde是推荐的 Rust 数据序列化和反序列化的库,可以从许多不同的格式中获取。
还没有! 想写一个吗?
Glium 是 Rust 中 OpenGL 编程的主要库。GLFW 也是一个可靠的选择。
是的,你可以。Rust 的主要游戏编程库是Piston,而且还有一个 Rust 游戏编程的 subreddit 和一个 IRC 频道(#rust-gamedev
on Mozilla IRC)。
它是多范式的。很多在 OO 语言中可以做的事情在 Rust 中也可以做,但不是所有的事情,也不总是使用你所习惯的那种抽象方式。
这取决于。有一些方法可以将面向对象的概念,如多重继承翻译成 Rust,但由于 Rust 不是面向对象的,所以翻译的结果可能与它在 OO 语言中的外观有很大不同。
最简单的方法是在你用来构建结构实例的任何函数中使用Option
类型(通常是new()
)。另一种方法是使用构建器模式,在构建所构建的类型之前,只必须调用某些实例化成员变量的函数。
Rust 中的全局变量可以使用const
声明来实现编译时计算的全局常量,而static
可以用来实现可变的全局变量。请注意,修改static mut
变量需要使用unsafe
,因为它允许数据竞争,而在安全的 Rust 中保证不会发生这种情况。const
和static
值之间的一个重要区别是,你可以对static
值进行引用,但不能对const
值进行引用,后者没有指定的内存位置。关于const
与static
的更多信息,请阅读 Rust 书。
Rust 目前对编译时常量的支持有限。你可以使用“const”声明来定义基元(类似于“static”,但是是不可变的,在内存中没有指定的位置),也可以定义“const”函数和固有方法。
要定义不能通过这些机制定义的程序性常量,可以使用lazy-static
crate,它通过在第一次使用时自动计算常量来模拟编译时计算。
Rust 没有“在main
之前的生命”的概念。最接近的是通过lazy-static
crate 来完成,它通过在静态变量第一次使用时懒散地初始化静态变量来模拟“main之前”。
不允许。全局变量不能有一个非结构表达式的构造函数,也不能有一个析构函数。静态构造函数是不可取的,因为确保静态初始化顺序的可移植性是很困难的。main 之前的生命通常被认为是一个错误的功能,所以 Rust 不允许它。
参见 C++ FQA 中关于“静态初始化顺序惨败”的内容,以及 Eric Lippert 的博客中关于 C# 的挑战,它也有这种特性。
你可以用 lazy-static 工具箱来近似非内容表达式的 globals。
struct X { static int X; };
的东西呢?Rust 没有上面代码片断中所示的静态
字段。相反,你可以在一个给定的模块中声明一个静态
变量,这个变量对该模块是私有的。
将 C 风格的枚举转换为整数可以用as
表达式来完成,比如e as i64
(其中e
是某个枚举)。
另一个方向的转换可以用match
语句来完成, 它将不同的数字值映射到枚举的不同潜在值上.
有几个因素导致 Rust 程序默认比功能相当的 C 程序有较大的二进制大小。一般来说,Rust 更倾向于对现实世界的程序性能进行优化,而不是对小程序的大小进行优化。
Rust 对泛型进行了单态化处理,这意味着在程序中每使用一个具体类型,就会生成一个新的泛型函数或类型。这类似于 C++ 中模板的工作方式。例如,在下面的程序中:
1 | fn foo<T>(t: T) { |
两个不同版本的foo
将出现在最终的二进制文件中,一个专门用于i32
输入,一个专门用于&str
输入。这使得通用函数的静态调度更加有效,但代价是一个更大的二进制文件。
Rust 程序在编译时保留了一些调试符号,即使是在 release 模式下编译。这些符号用于提供 panic 时的 backtrace,可以用strip
或其他调试符号移除工具移除。值得注意的是,用 Cargo 在 release 模式下编译,相当于用 rustc 设置优化级别 3。另一个优化级别(称为s
或z
)已被添加,它告诉编译器为大小而不是性能进行优化。
Rust 默认不做链接时优化,但可以被指示这样做。这增加了 Rust 编译器可能做的优化量,并对二进制的大小有小的影响。与之前提到的尺寸优化模式相结合,这种影响可能更大。
Rust 标准库包括 libbacktrace 和 libunwind,这在某些程序中可能是不可取的。因此,使用#![no_std]
可以带来更小的二进制文件,但通常也会对你正在编写的那种 Rust 代码造成实质性的改变。请注意,在没有标准库的情况下使用 Rust,通常在功能上更接近于同等的 C 代码。
举个例子,下面的 C 程序读入一个名字,并对有这个名字的人说“你好”。
1 |
|
用Rust重写这个,你可能会得到如下的东西。
1 | use std::io; |
这个程序在编译后与 C 程序相比,会有更大的二进制,使用更多的内存。但是这个程序并不完全等同于上面的 C 代码。等价的 Rust 代码反而会是这样的。
1 |
|
这确实应该在内存使用方面与 C 语言大致相同,但代价是更多的程序员复杂性,以及缺乏通常由 Rust 提供的静态保证(在这里通过使用unsafe
来避免)。
对 ABI 的承诺是一个重大的决定,会限制未来潜在的有利的语言变化。鉴于 Rust 在 2015 年 5 月才达到 1.0,现在做出像稳定 ABI 这样大的承诺还为时过早。但这并不意味着未来不会发生。(尽管 C++ 已经成功地运行了很多年而没有指定一个稳定的 ABI)。
extern
关键字允许 Rust 使用特定的 ABI,例如定义明确的 C ABI,以便与其他语言互操作。
可以。从 Rust 中调用 C 代码的设计与从 C++ 中调用 C 代码一样高效。
是的,Rust 代码必须通过“extern”声明公开,这使得它与 C-ABI 兼容。这样的函数可以作为一个函数指针传递给 C 代码,或者,如果赋予#[no_mangle]
属性以禁用符号纠缠,可以直接从 C 代码中调用。
现代 C++ 包含了许多使编写安全和正确的代码不容易出错的特性,但它并不完美,而且仍然很容易引入不安全因素。这是 C++ 的核心开发人员正在努力克服的问题,但是 C++ 受限于悠久的历史,它比他们现在试图实现的很多想法都要早。
Rust 从第一天起就被设计成一种安全的系统编程语言,这意味着它不会受到历史上的设计决定的限制,而这些决定使 C++ 的安全问题变得如此复杂。在 C++ 中,安全是通过谨慎的个人纪律实现的,而且很容易出错。在 Rust 中,安全是默认的。它让你有能力在一个包括不如你完美的人在内的团队中工作,而不必花时间反复检查他们的代码是否存在安全漏洞。
Rust 目前还没有与模板专业化完全对等的东西,但它正在研究中,希望能很快加入。然而,类似的效果可以通过关联类型实现。
底层的概念是相似的,但这两个系统在实践中的工作方式是非常不同的。在这两个系统中,“move”一个值都是一种为了转移其底层资源的所有权的方式。例如,移动一个字符串会转移字符串的缓冲区,而不是复制它。
在 Rust 中,所有权转移是默认行为。例如,如果我编写了一个以“String”为参数的函数,这个函数将对其调用者提供的String
值拥有所有权。
1 | fn process(s: String) { } |
正如你在上面的片段中看到的,在函数caller
中,对process
的第一次调用转移了变量s
的所有权。编译器会跟踪所有权,所以第二次调用process
会导致一个错误,因为将同一个值的所有权转让两次是非法的。如果一个值有一个未完成的引用,Rust 也会阻止你移动这个值。
C++ 采取了一种不同的方法。在 C++ 中,默认的做法是复制一个值(更确切地说,是调用复制构造函数)。然而,被调用者可以使用一个“rvalue reference”来声明他们的参数,例如string&&
,以表明他们将获得该参数所拥有的一些资源的所有权(在这个例子中,字符串的内部缓冲区)。然后调用者必须传递一个临时表达式或使用std::move
进行明确的移动。大致相当于上面的函数process
的粗略等价物是:
1 | void process(string&& s) { } |
C++ 编译器没有义务去跟踪移动。例如,上面的代码在编译时没有任何警告或错误,至少在使用默认的设置的情况下,上述代码在编译时没有任何警告或错误。此外,在C++中,字符串s
本身的所有权(如果不是它的内部缓冲区的话)仍然属于caller
,所以s
的析构函数会在caller
返回时运行,即使它已经被移动了(相反,在 Rust 中,被移动的值只被其新主人丢弃)。
Rust 和 C++ 可以通过 C 语言进行互操作。Rust 和 C++ 都为 C 语言提供了一个外来函数接口,并可以用它来进行相互之间的通信。如果编写 C 语言绑定太过繁琐,你可以使用rust-bindgen来帮助自动生成可行的 C 语言绑定。
不,函数的作用与构造函数相同,不会增加语言的复杂性。在 Rust 中,相当于构造函数的通常名称是new()
,尽管这只是一个惯例而不是语言规则。new()
函数实际上就像其他函数一样。它的一个例子是这样的。
1 | struct Foo { |
不完全是。实现了Copy
的类型会做一个标准的类似于 C 语言的“浅拷贝”,不需要额外的工作(类似于 C++ 中的 trivially copyable 类型)。不可能实现需要自定义复制行为的Copy
类型。相反,在 Rust 中,“复制构造器”是通过实现Clone
特性,并明确调用clone
方法来创建的。将用户定义的复制操作符显性化,使开发者更容易识别潜在的昂贵操作。
没有。所有类型的值都是通过memcpy
移动的。这使得编写通用的不安全代码变得更加简单,因为赋值、传递和返回都是已知的,不会产生像解绑(unwinding)那样的副作用。
Rust 和 Go 的设计目标有很大不同。以下的差异并不是唯一的差异(这些差异太多,无法一一列举),但却是其中几个比较重要的差异:
Rust traits 类似于 Haskell 的 typeclasses,但目前还没有那么强大,因为 Rust 不能表达更高类型的类型。Rust 的关联类型等同于 Haskell 类型族。
Haskell typeclasses 和 Rust traits 之间的一些具体区别包括:
Self
。Rust 中的trait Bar
对应于 Haskell 中的class Bar self
,而 Rust 中的trait Bar<Foo>
对应于 Haskell 中的class Bar foo self
。trait Sub: Super
,而 Haskell 中的为class Super self => Sub self
。impl
解析在决定两个impl
是否重叠或在潜在的impl
之间进行选择时,会考虑相关的where
条款和特质约束条件。Haskell 只考虑instance
声明中的约束,不考虑其他地方提供的任何约束。Rust 语言已经存在了很多年,在 2015 年 5 月才达到 1.0 版本。在这之前的时间里,语言发生了很大的变化,而 Stack Overflow 的一些答案是在语言的旧版本时给出的。
随着时间的推移,越来越多的答案将提供给当前的版本,从而改善这个问题,因为过时的答案的比例减少了。
你可以在 Rust 编译器issue tracker上报告 Rust 文档中的问题。请务必先阅读贡献指南。
当你使用cargo doc
为你自己的项目生成文档时,它也会为活动的依赖版本生成文档。这些文档会被放到你的项目的target/doc
目录下。使用cargo doc --open
来打开这些文档,或者自己打开target/doc/index.html
。
那让我们来看看,Rust 的 Safe 边界在哪里。
什么是安全的 Rust 相信大家都了解,这里不再赘述;实际上,有一些行为虽然我们会认为是预期之外甚至不安全的,但是 Rust 不会:
前四个都好理解,特别是内存泄漏这个,在 The Book 中就有提到(而且可以看下,标准库的std::mem:leak
都不是 unsafe 的);这里特别要讨论的是,整型溢出和逻辑错误这两个问题。
如果一段代码包含算术溢出,那是程序员的锅。在下面的讨论中,我们需要区分算术溢出和包装算术(wrapping arithmetic)。前者是错误的,而后者是预期之中的。
当程序员启用了debug_assert!
断言(例如,debug 模式下的编译),编译器会在运行时插入动态检查,如果发生了溢出会 panic。其他类型的构建(如 release 模式下)可能会导致 panic 或在溢出时啥都不做。
在隐式包装溢出的情况下,实现者必须通过使用二补数的溢出约定来提供定义明确的(即使仍然被认为是错误的)结果。
Rust 标准库为整型提供了一些方法,允许程序员明确地执行包装算术。例如,i32::wrapping_add
提供了二补、包装加法。
标准库还提供了一个Wrapping<T>
类型,确保T
的所有标准算术操作都有包装语义。
关于整数溢出的错误条件、原理和更多细节,可以参考RFC 560。
安全代码可以有一些额外的逻辑约束,这些约束在编译时和运行时都无法检查。如果一个程序破坏了这样的约束,其行为可能是未指定的,但不会导致未定义行为。这可能包括 panic、不正确的结果、非预期的中止或者死循环。这种行为也可能在不同的运行、构建或构建种类之间有所不同。
例如,实现Hash
和Eq
要求相等的值一定要有相等的哈希值。另一个例子是像BinaryHeap
、BTreeMap
、BTreeSet
、HashMap
和HashSet
这样的数据结构,它们针对在它们 Key 中的对象的修改定义了一些约束。违反这样的约束不被认为是不安全的,然而程序的行为是不可预测的,随时有可能挂。
未定义(Undefined Behaviour)是一个很有意思的定义,算是写 C 和 C++ 程序员的老朋友了,甚至很多代码会依赖未定义行为。
如果 Rust 代码有以下列表中的任何行为,那么它就是不正确的,包括 unsafe 中的代码。unsafe 只意味着避免未定义的行为是由程序员负责的;它没有改变任何关于 Rust 程序决不能引起未定义行为的要求。换言之,无论是否使用 unsafe,都不应该有未定义的行为出现。
在编写 unsafe 代码时,程序员有责任确保任何与不安全代码交互的安全代码不能触发这些行为。对于任何安全的调用者来说,满足这一属性的不安全代码被称为健全(sound)的;如果不安全代码可以被安全代码滥用而表现出未定义的行为,那么它就是不健全的。
要注意,下面的列表并不是详尽的。对于不安全代码中允许和不允许的行为,Rust 的语义并没有正式的模型,所以可能有更多的行为被认为是不安全的。下面的列表只是我们确定的未定义行为。在编写不安全代码之前,请阅读死灵书(Rustonomicon)。
数据竞争(Data races)
在一个悬空或不对齐的原始指针上执行解引用表达式(*expr),即使是在地址表达式上下文中(例如addr_of!(&*expr)
)。
破坏了指针别名规则。&mut T
和&T
遵循 LLVM 的作用域noalias
模型,除非&T
包含一个UnsafeCell<U>
。
修改不可变的数据。const 项中的所有数据都是不可变的。此外,所有共享引用的数据或由不可变的绑定所拥有的数据都是不可变的,除非该数据包含在一个UnsafeCell<U>
中。
通过编译器的内建指令调用未定义的行为。
执行当前平台不支持的平台特性编译的代码(见target_feature
,这通常会导致 SIGILL)。
调用具有错误调用规约(ABI)的函数或 unwind 具有错误 unwind ABI 的函数。
产生一个无效的值,哪怕是在私有字段和局部字段中。一个值被分配到一个地方或从一个地方读出、传递到一个函数 / 原始操作(primitive operation)或从一个函数 / 原始操作返回都会“产生”一个值。以下的值是无效的:
bool 中除 false(0)或 true(1)以外的值。
类型定义中没有包括的枚举中的判别式。
一个空的 fn 指针。
char 中的一个值是代用的(surrogate)或高于char::MAX
的。
!
(所有的值对这个类型来说都是无效的)。
一个整数、浮点值,或从未初始化的内存中获得的原始指针,或str
中未初始化的内存。
一个引用或Box<T>
是悬空的、不对齐的,或者指向一个无效的值。
泛引用、Box<T>
或原始指针中无效的元数据。
dyn Trait
指针 / 引用指向的 vtable 和对应 Trait 的 vtable 不匹配,那么dyn Trait
的元数据是无效的。对于一个具有自定义的无效值的类型来说是无效的值(看着有点绕),比如在标准库中的NonNull<T>
和NonZero*
。
注:Rustc 通过不稳定的
rustc_layout_scalar_valid_range_*
属性实现了这一点。
注意:对于任何具有受限的有效值集的类型,未初始化的内存也是隐式无效的。换句话说,唯一允许读取未初始化内存的情况是在 union 内和padding
中(一个类型的字段 / 元素之间的空隙)。
注:未定义行为会影响整个程序。例如,在 C 语言中调用一个表现出 C 语言未定义行为的函数,意味着你的整个程序包含未定义行为,这也会影响 Rust 代码。反之亦然,Rust 中的未定义行为会对其他语言的任何 FFI 调用所执行的代码造成不良影响。
如果一个引用 / 指针是空的,或者它所指向的所有地址并非合法的地址(比如 malloc 分配出的内存),那么它就是悬垂
的。它所指向的范围是由指针值和被指向类型的大小决定的(使用size_of_val
)。因此,如果指向的范围是空的,悬垂
与非空
是一样的。
要注意,切片和字符串指向它们的整个范围,所以它们的长度不可能很大。内存分配的长度、切片和字符串的长度不能大于isize::MAX
字节。
写在前面:
最近看到了一篇讲 Rust 如何对框架进行抽象的文章,写得非常好,这两天抽空翻译了一下。
原文:https://tokio.rs/blog/2021-05-14-inventing-the-service-trait
Tower
是一个模块化和可重复使用的用来构建 client 和 server 的组件库。其核心是Service
特性。一个Service
是一个异步函数,它接受一个请求并产生一个响应。然而,Tower
设计的某些方面可能不是那么一目了然。
与其解释今天存在于Tower
中的Service
特性,不如来看看Service
背后的设计考量。让我们试试看,如果今天重新设计实现它,我们会怎么做。
想象一下,你正在用 Rust 构建一个简单的 HTTP 框架。这个框架将允许用户提供接收请求并返回响应的处理逻辑来实现一个 HTTP 服务器。你可能会有这么一个 API:
1 | // 创建一个在 3000 端口监听的服务器 |
现在问题来了,the_users_application
应该是什么?
最简单的一个实现,可能是这样的:
1 | fn handle_request(request: HttpRequest) -> HttpResponse { |
其中HttpRequest
和HttpResponse
是由我们的框架提供的一些结构。有了这个,我们就可以这样实现Server::run
:
1 | impl Server { |
在这里,我们有一个异步函数run
,它接受一个闭包,这个闭包接受一个HttpRequest
并返回HttpResponse
。用户可以像这样使用我们的server
:
1 | fn handle_request(request: HttpRequest) -> HttpResponse { |
感觉还行,它让用户可以很容易地运行 HTTP 服务器而不必担心任何低层次的细节。
然而,我们目前的设计有一个问题:我们无法处理异步地处理请求。想象一下,我们的用户需要查询一个数据库,或者在处理请求的同时发送一个请求给其他服务器。目前,这样会导致我们需要同步等待 handler 的返回结果,从而导致了阻塞。
如果我们希望我们的服务器能够处理大量的并发连接,我们需要在等待该请求异步完成的同时为其他请求提供服务。我们可以通过让 handler 返回一个future
来解决这个问题。
1 | impl Server { |
API 的用法和之前差不多:
1 | // 现在是一个异步函数 |
这就比之前要好很多了,因为我们的 handler 现在可以调用其他异步函数啦。然而,我们仍然缺了点啥——如果我们的处理程序出错了怎么办?我们可以让 Handler 返回一个Result
:
1 | impl server { |
现在,假设我们想确保所有的请求都能及时完成或失败,而不是让客户端无限期地等待一个可能永远不会有的响应。
我们可以通过给每个请求添加一个超时来做到这一点。一个超时设置了handler
允许持续的最大时间的限制。如果它在这个时间内没有产生响应,就会返回一个错误。这使得客户端可以重试该请求或向用户报告错误,而不是永远等待。
最简单的方法可能是去修改Server
,使其可以配置一个超时,然后在每次调用handler
时应用该超时。然而,其实你也可以在不修改Server
的情况下添加一个超时。使用tokio::time::timeout
,我们可以写一个新的处理函数,让它调用我们之前的handle_request
,并且设置超时时间为 30 秒:
1 | async fn handler_with_timeout(request: HttpRequest) -> Result<HttpResponse, Error> { |
这提供了一个相当好的抽象,我们能够添加一个超时器而不改变任何现有的代码。
让我们用这种方式再增加一个功能。想象一下,我们正在写一个 JSON API,并且希望在所有的响应上有一个Content-Type: application/json
的头。我们可以用类似的方式包装handler_with_timeout
:
1 | async fn handler_with_timeout_and_content_type( |
我们现在有了一个处理程序,它将处理一个 HTTP 请求,超时为 30 秒,并且会设置好正确的Content-Type
头,所有这些都不需要修改我们原来的handle_request
函数或Server
结构。
设计可以以这种方式扩展的库是非常强大的,因为它允许用户通过增加一层新行为来扩展库的功能,而不需要等待库的维护者为其添加支持。
它也使测试变得更容易,因为你可以把你的代码分解成小的隔离的孤立的单元,并为它们编写细粒度的测试,而不必担心其他的部分。
然而,又有了一个问题:我们目前的设计是套娃,也就是实现一个处理函数来实现功能,并在其内部调用其他处理函数。这能 work,但如果我们想增加更多的额外功能,它并不能很好地扩展。
想象一下,我们有许多handle_with_*
函数,每一个都增加了一点儿新的行为。要硬编码谁调用谁的这个调用链将成为一种挑战。我们目前的调用链是:
handler_with_timeout_and_content_type
,调用handler_with_timeout
,调用handle_request
,实际处理请求。如果我们能以某种方式组合这三个函数而不需要硬编码确切的顺序,那就更好了,就像这样:
1 | let final_handler = with_content_type(with_timeout(handle_request)); |
同时仍然能够像以前一样运行我们的处理程序。
1 | server.run(final_handler).await? |
你可以把with_content_type
和with_timeout
作为函数来实现,该函数接受一个F: Fn(HttpRequest) -> Future<Output = Result<HttpResponse, Error>
的参数并返回一个impl Fn(HttpRequest) -> Future<Output = Result<HttpResponse, Error>>
的闭包。这也不是不行,但所有这些闭包类型会很快变得难以处理。
Handler
trait让我们来尝试另一种方法。与其让Server::run
接受了一个闭包(Fn(HttpRequest) -> …
),不如让我们定义一个新的 trait 来封装async fn(HttpRequest) -> Result<HttpResponse, Error>
:
1 | trait Handler { |
有了这样一个 trait,我们就可以编写实现它的具体类型,这样我们就不必到处用Fn
了。
然而,Rust 目前不支持 async trait 方法,所以我们有两个选择:
call
返回一个 Boxed Future,如Pin<Box<dyn Future<Output = Result<HttpResponse, Error>>
。这也就是async-trait
干的事。Handler
中添加一个关联的type Future
,这样用户就可以指定自己的类型。我们采用方案二,因为它是最灵活的。有一个具体的 Future 类型的用户可以避免Box
的开销,而不在乎的用户也可以使用Pin<Box<...>>
。
1 | trait handler { |
我们仍然要求Handler::Future
实现输出为Result<HttpResponse, Error>
的Future
,因为那是Server::run
的要求。
让call
接受&mut self
是有用的,因为它允许处理程序在必要时更新他们的内部状态1。
让我们把原来的handle_request
函数转换为使用这个特性的实现:
1 | struct RequestHandler; |
那我们如何基于这个实现超时呢?请记住,我们的目标是允许我们在不修改每个单独部分的情况下,将不同的功能组合在一起。
我们可以定义一个通用的Timeout
结构,就像这样:
1 | struct Timeout<T> { |
然后我们可以为Timeout<T>
实现Handler
并委托给T
的Handler
实现。
1 | impl<T> Handler for Timeout<T> |
这里重要的一行是self.inner_handler.call(request)
,在这我们继续调用内部处理程序,让它做自己的事情而不管关它是什么。我们只需要知道它完成后会返回一个Result<HttpResponse, Error>
。
但是,这段代码编译不过:
1 | error[E0759]: `self` has an anonymous lifetime `'_` but it needs to satisfy a `'static` lifetime requirement |
编译出错的原因是,我们正在捕获一个&mut self
并将其移到一个异步的代码块中。这意味着我们的 Future 和&mut self
的生命周期是相同的。但是这并不符合我们的预期,因为我们可能想在多个线程上运行我们的 Future 以获得更好的性能,或者产生多个 Future 并将它们全部并行运行。如果对 handler 的引用存在于 Future2 中,这就不可能了。
因此,我们需要将&mut self
转换为一个有所有权的self
。这正是Clone
所做的。
1 | // 这必须是 Clone,才能使 Timeout<T> 成为 Clone |
请注意,在这种情况下,clone 是非常便宜的,因为RequestHandler
没有任何数据,Timeout<T>
只增加了一个Duration
(也就是实际上是Copy
)。
好,我们现在更进一步了,现在我们得到了另一个错误:
1 | error[E0310]: the parameter type `T` may not live long enough |
现在的问题是,因为T
可以是任何类型。它甚至可以是一个包含引用的类型,比如Vec<&'a str>
。然而这就拉胯了,原因和之前一样。我们需要返回的 Future 有一个'static
的生命周期,这样我们可以更容易地传递它。
编译器实际上已经告诉了我们该如何解决——加个T: 'static'
:
1 | impl<T> Handler for Timeout<T> |
返回的 Future 现在满足了'static'
寿命的要求,因为它不包含引用(并且任何T
包含的引用都是'static'
的)。现在,我们的代码可以编译了!
让我们创建一个类似的 handler 在响应中添加Content-Type
头:
1 |
|
这与Timeout
的模式非常相似。
接下来我们修改Server::run
以接受我们新的Handler Trait
。
1 | impl Server { |
我们现在可以将我们的三个 handler 组合在一起:
1 | JsonContentType { |
如果我们给我们的类型添加一些new
方法,那就更容易构建啦:
1 | let handler = RequestHandler; |
搞定!我们现在可以为RequestHandler
增加额外的功能而不必修改它的实现。理论上,我们可以把我们的JsonContentType
和Timeout
handler 放到一个crate
中,然后在crates.io
上把它作为一个库发布供其他用户使用!
Handler
更加灵活我们的Handler trait
看着还不错,但目前它只支持我们的HttpRequest
和HttpResponse
类型。如果这些变成了泛型,用户就可以使用他们想要的任何类型。
我们将 Request 作为 Trait 的泛型参数,这样服务就可以接受许多不同类型的请求。这样,我们的 handler 就可以用于不同的协议,而不仅仅是 HTTP 了。我们定义 Response 为一个关联类型,因为对于任意给定的请求类型,只能有且只有一种(相关的)响应类型:对应的调用返回的类型。
1 |
|
我们对RequestHandler
的实现现在变成了:
1 | impl Handler<HttpRequest> for RequestHandler { |
Timeout<T>
则有点不同,因为它包装了一些其他的Handler
,并添加了一个异步超时,它实际上并不关心请求或响应类型是什么,只要它所包装的Handler
使用相同的类型。
而Error
类型则有点不同。因为tokio::time::timeout
会返回Result<T, tokio::time::error::Elapsed>
,我们必须能够把tokio::time::error::Elapsed
转换成内部Handler
的错误类型。
如果我们把所有这些东西组合在一起,我们就能获得:
1 | // `Timeout`接受任何类型的`R`的请求,只要和`T`接受相同类型的请求 |
JsonContentType
也有点不同。它不关心请求或错误类型,但它关心响应类型。它必须是Response
,这样我们才能调用set_header
。
因此,实现如下:
1 | // 还是一个通用的请求类型 |
最后,传递给Server::run
的Handler
必须使用HttpRequest
和HttpResponse
。
1 | impl Server { |
创建 server 的代码不需要变:
1 | let handler = RequestHandler; |
到目前为止,我们有了一个Handler trait
,这可以将我们的应用程序分解成独立的小部分,并可以复用。看着不错!
到目前为止,我们只讨论了 server 方面的事情。但是实际上,我们的Handler trait
也适用于 HTTP 客户端。比如,我们可以想象有个客户端的Handler
接受一些请求并异步地将其发送给互联网上的某 server,我们的Timeout
包装器在这里也很有用。JsonContentType
可能没啥用,因为设置响应头不是客户端的工作。
由于我们的Handler trait
对于定义服务器和客户端都很有用,Handler
可能不是一个合适的名字,毕竟客户端并不处理一个请求,它将请求发送给服务器,然后由服务器来处理它。让我们改称我们的 trait 为Service
:
1 | trait Service<Request> { |
这实际上几乎就是Tower
中定义的Service trait
了。如果你已经跟着看到了这里,你现在已经了解了Tower
的大部分内容了。除了Service trait
,Tower
还提供了一些实用工具,通过包装其它的Service
并实现一个Service
,就像我们对Timeout
和JsonContentType
所做的那样。这些Service
的组成方式与我们到目前为止所做的类似。
以下是一些由Tower
提供的Service
示例:
像Timeout
和JsonContentType
这样的类型通常被称为中间件,因为它们包裹着另一个Service
并以某种方式对请求或响应进行处理。像RequestHandler
这样的类型通常被称为叶子服务
,因为它们位于嵌套服务树的叶子上。实际的响应通常是在叶子服务中产生,并由中间件修改。
好了,到这里唯一(唯二?)我们剩下还没聊的是backpressure和poll_ready
。
想象一下,现在你想写一个限制请求速率的中间件,来包装一个Service
,以对底层服务的最大并发请求数进行限制。如果你的服务对它的负载量有一个硬性的上限,这将是非常有用的。
在我们目前的Service trait
中,我们并没有一个好的方法来实现这样的东西,我们可以尝试这样做:
1 | impl<R, T> Service<R> for ConcurrencyLimit<T> { |
如果没有剩余的容量,我们必须等待,并在容量可用时以某种方式得到通知。此外,我们必须在等待时将请求保留在内存中(也称为缓冲)。这意味着,等待的请求越多,我们的程序就会使用更多的内存——如果产生的请求超过我们的服务所能处理的数量,我们可能会耗尽内存。
只有当我们确定服务有能力处理请求时,才为请求分配空间,这将是更稳健的做法。否则,在我们等待我们的服务准备好时,我们有可能使用大量的内存来缓冲请求。
如果说Service
有这样一个方法,那就完美了:
1 | trait Service<R> { |
ready
将是一个异步函数,当服务有足够的容量来接收一个新的请求时,它就会完成并返回。我们将要求用户首先调用service.ready().await
,然后再进行service.call(require).await
。
将“调用服务”与“预留容量”分开,还可以有新的用法:比如我们可以维护一组“准备好的服务”,并在后台保持更新。这样,当一个请求到来时,我们已经有了一个可以使用的服务,而不需要首先等待它准备好。
通过这种设计,ConcurrencyLimit
可以在ready
内部计算容量,而不允许用户调用call
,直到有足够的容量。
不关心容量的服务可以从ready
中立即返回,或者如果它们包含了一些内部的Service
,它们可以委托给它内部的ready
方法。
然而,现在我们仍然不能在 trait 中定义异步函数。因此,我们可以给Service
定义另一个关联类型,叫做ReadyFuture
,但是必须返回一个Future
会给我们带来我们之前遇到的同样的生命周期问题。如果有一些方法可以解决这个问题就好了。
作为替代,我们可以从Future
特性中获得一些灵感,定义一个方法叫做poll_ready
。
1 | use std::task::{Context, Poll}; |
如果服务没有容量,poll_ready
将返回Poll::Pending
;当容量变得可用时,使用Context
中的waker
通知调用者。这时,可以再次调用poll_ready
,如果它返回Poll::Ready(())
,那么容量就被保留了,就可以调用call
了。
请注意,从技术上来说,没有任何东西可以阻止用户在没有确定服务准备好的情况下调用call
,然而,这样做被认为是违反了Service
的 API 调用约定。这时候call
可以panic
如果服务没有准备好。
poll_ready
不返回Future
也意味着我们能够快速检查一个服务是否准备好了,而不需要被迫等待它准备好。如果我们
调用poll_ready
并返回Poll::Pending
,我们可以决定去做其他事情而不是等待。举个例子,这允许你写个负载均衡器,通过服务返回Poll::Pending
的频率来估计服务的负载,并将请求发送到负载最小的服务。
使用类似于futures::future::poll_fn
或者tower::ServiceExt::ready
的东西,仍然可以获得一个等待服务容量可用的 Future。
这种服务与它们的调用者沟通其容量的概念被称为“反压传播”。你可以把它看作是服务向后反推他们的调用者,并且如果他们产生的请求太快了时,告诉他们需要放慢速度。其基本思想是,你不应该向一个没有能力处理的服务发送请求。相反,你应该等待(缓冲),放弃请求(减负),或以其他方式处理能力不足的问题。你可以在这里和这里了解更多关于背压的一般概念。
最后,在预留容量时也可能发生一些错误,所以poll_ready
也许应该返回Poll<Result<(), Self::Error>
。
有了这一改变,我们现在已经有了完整的tower::Service
特性。
1 | pub trait Service<Request> { |
许多中间件不添加自己的背压,而只是委托给被封装的服务的poll_ready
实现。然而,中间件的背压确实可以实现一些有趣的用例,例如各种速率限制、负载均衡和自动扩容。
由于你永远不知道一个Service
可能由哪些中间件组成,所以重要的是不要忘记调用poll_ready
。
有了这一切,调用服务的最常用方法是:
1 | use tower::{ |
1: 关于call
是否应该使用Pin<&mut Self>
,已经有了一些讨论。但是到目前为止,我们决定采用一个普通的 &mut self
,这意味着 handler(咳,Services)必须是Unpin
。在实践中,这很少出现问题。更多细节可以看这里。
2: 说得更准确一点,这要求响应返回的 Future 必须是'static'
的,因为写Box<dyn Future>
实际上会被 desugar 成Box<dyn Future + 'static>
,因此在fn call(&'_ mut self, ...)
中的匿名lifetime
并不满足这个要求。在未来,Rust编译器团队计划增加一个名为泛型关联类型(GAT)的功能,这将解决这个问题。泛型关联类型允许我们将响应的 future 定义为type Future<'a>
,call
定义为fn call<'a>(&'a mut self, ...) -> Self::Future<'a>
,但现在响应返回的 Future 必须是'static
的。
最近想尝试在 Golang 里面实现clock_gettime
的CLOCK_REALTIME_COARSE
和CLOCK_MONOTONIC_COARSE
,正好深入研究了下 time.Now
的实现,还机缘巧合下顺便优化了一把time.Now
(虽然最终提交的是 Ian 大佬的版本)。
在这里记录下来整个过程,以供查阅。
首先我们来看看 time.Now
的实现原理,从代码(以下代码基于 Go <= 1.16 版本)入手:
1 | // Provided by package runtime. |
可以看到,time.Now
里面实际上是调用了now
来获得对应的时间数值,然后进行了一系列的处理。这部分处理就不说了,网上有较多资料,也不是本文重点。我们接着去runtime
包里面找找now
是怎么实现的:
1 | //go:linkname time_now time.now |
根据关键字搜索,很快能搜到在runtime
的timestub.go
文件中的以上代码,可以看到实际上调用了两个方法:walltime
和nanotime
,这两个方法又调用了walltime1
和nanotime1
,并且是以汇编实现的,让我们继续深入看下这两个方法的汇编实现,因为代码基本相同,这边以walltime1
作为例子:
1 | // func walltime1() (sec int64, nsec int32) |
这段代码的注释非常的清晰,根据这段代码,可以看到,实际上是使用的vdso call
来获取到当前的时间信息。只不过,由于 Go 是自己维护的协程的栈,而这个栈在某些内核上调用vdso
会出问题,所以需要先切换到g0
(也就是系统线程的栈)上才行。所以这里在开头和结尾有很多额外的操作,需要制造和清理作案现场。
有同学可能对vdso
不了解,这里简单介绍下,实际上一开始获取时间信息是需要通过系统调用的,也就是要 syscall 才行,但是众所周知,syscall 的性能较差,同时获取时间戳又是个高频操作,所以大家也想办法优化了几版,最终是现在采用的vdso
的方案。vdso
全称是virtual dynamic shared object
,简单来说就是把这段原本需要系统调用的方法,像动态链接库(so
库)一样加载到用户内存空间里面,这样用户的进程就可以像调用一个普通方法一样调用这个方法了,可以避免系统调用的额外开销。具体可以参考一下:https://man7.org/linux/man-pages/man7/vdso.7.html。
看完walltime1
之后我们来看下nanotime1
,由于开头的切换到g0
的代码都是一样的,所以这里只截取后续部分的代码:
1 | noswitch: |
可以看到,唯二修改的就是调用的clockid
——CLOCK_MONOTONIC
和RET
之前的处理逻辑——将返回结果转换成纳秒。
说到这里,大家应该就能发现问题所在了——time.Now
调用了一次walltime
和一次nanotime
,这两次调用都有几乎一样的切换到g0
栈再恢复的代码,而且这段代码量还比较多。如果我们把这两次调用给合并到一起,就可以节省一次切换栈和准备工作导致的额外开销了!
Go 官方团队的 Ian 大佬和我(几乎)同时提了对应的 pr 来优化这部分的逻辑,最终 Ian 大佬实现的性能更好(-20%,我的版本是 -17%),于是最终采用的是 Ian 大佬的版本:https://go-review.googlesource.com/c/go/+/314277/。
runtime
外调用vdso
?回到开头,我是想自己实现clock_gettime
的CLOCK_REALTIME_COARSE
和CLOCK_MONOTONIC_COARSE
,这就需要我在runtime
包外部实现以上的一系列操作。但是如果要这么干,就需要把所有runtime
包里面的结构体定义全部复制一份(这样在汇编代码里面 include 的 go_asm.h
才有对应的偏移量),这样可维护性太差了,而且如果某个版本调整了结构体的顺序,行为就不可定义,太危险了,要不就得每个版本单独复制一份出来。
针对这个问题,也和 Go 官方进行了讨论,最终确实没有什么太好的思路,Go 目前不支持在runtime
外部安全地调用vdso
。
不过不管怎么样,在这个讨论的过程中,促成了time.Now
的优化,还是不枉此行。
这里不过多讲解泛型的语法,具体可以参考一下 https://github.com/golang/go/issues/43651 这个 issue。
简单来说,在 struct 和 func 的名字后面可以加一个 [] 里面包含泛型的名字和限制条件,比如:
1 | type container[T any] struct{ |
any 是个特殊的关键字,表示所有类型都可以。
这里我们写一个示例程序来编译成汇编,来看看泛型到底是怎么实现的:
1 | package main |
我们先基于 master 分支来编译一个 go 出来,然后用这个 go 来执行以下命令:
1 | go build -gcflags="-G=3 -l -S" main.go > main.s 2>&1 |
接下来去main.s
这个文件看看,就会发现有这么一段代码:
1 | "".#loop[int] STEXT nosplit size=18 args=0x18 locals=0x0 funcid=0x0 |
再看 main 中调用的地方:
1 | 0x008c 00140 (/Users/purewhite/go/src/local/study/main.go:39)CALL"".#loop[int](SB) |
基本可以确定,go 的泛型目前的实现方案是在编译时进行代码生成,这个方案虽然会降低编译速度,但是在运行时是没有性能损耗的。
]]>可以先看下我之前在 JTalk 上分享的实践:https://www.bilibili.com/video/BV1UZ4y1g7ju
这篇文章是对于其中我最后说的“使用 SIMD 优化”部分的详细说明。
List<i64> 场景下提升六倍,List<i32> 提升十二倍。
基于 FastRead/Write 接口,由于我们已经拿到了所有的内存,所以我们可以尝试采用 SIMD 来进一步的优化。
最容易想到的优化点也是公司内最常见的用法 list<i64/i32>,这个比较容易想到使用 SIMD 进行优化。
在 thrift binary 里面,int 类型在复制到 buffer 之前需要先转成大端,也就是 binary.BigEndian.PutInt 一次,这个操作原本需要比较多语句,通过软件来模拟,但是在 amd64 下有一个 BSWAP 指令可以直接完成,这个优化 Go 编译器已经做了,所以现在的伪代码如下:
1 | var src, dst |
可以看出来,这个操作实际上是很有规律的,并且全都是相邻的操作,符合 SIMD 指令的模式。
先使用了 C++ 做了一个 POC(只贴了关键代码,完整代码见 https://gist.github.com/PureWhiteWu/e88f241fc8b62df06ae1eb04923a88ae):
1 | const long long int MASK = 0x0001020304050607; |
编译命令如下:
1 | $ g++ little_2_big_gcc.cpp -o ll2 -mavx512f -mavx512bw -mavx2 -mavx -O3 |
在 linux 物理机上进行测试,结果如下:
1 | avx512 time: 27009 us |
可以得出结论:
bswap 做的事情是将整个字节序进行倒序,以 int32 为例,包含 4 字节,假设原来数据如下:
00000000 00000001 00000010 00000011
那么 bswap 之后,数据为:
00000011 00000010 00000001 00000000
在 avx2 中,也有一个指令 vpshufb 能够达到类似的效果,不过不是纯粹的 bswap,详见:https://software.intel.com/content/www/us/en/develop/documentation/cpp-compiler-developer-guide-and-reference/top/compiler-reference/intrinsics/intrinsics-for-intel-advanced-vector-extensions-2/intrinsics-for-shuffle-operations-1/mm256-shuffle-epi8.html
shuffle 的意思是“洗牌”,作用是可以根据一个传入的 mask 来重排对应 byte 的位置。所以这里最关键的就是代码示例中最上面那行:
1 | const long long int MASK = 0x0001020304050607; |
为什么用这个 mask 就行了呢?我们得复习一下大小端的知识。
大端字节序是符合人类阅读习惯的顺序,高位在前,还是以刚才的 int32 作为例子,假如大端序表示如下:
00000011(高位在这里) 00000010 00000001 00000000
那么在我们电脑上,小端字节序就是这么存的:
内存地址 | 0 | 1 | 2 | 3(高位在这里) |
---|---|---|---|---|
值 | 00000000 | 00000001 | 00000010 | 00000011 |
这时候对应的 MASK 是 0x00010203,在内存中以小端序表示为:
内存地址 | 0 | 1 | 2 | 3(高位在这里) |
---|---|---|---|---|
值 | 3 | 2 | 1 | 0 |
我们的机器都是小端序的,所以,在做 shuffle 的时候,内存地址 0 对应的是 内存地址 3 处的值,内存地址 1 对应的是 内存地址 2 处的值,以此类推。
这样,shuffle 计算下来之后,内存中的值就变成了:
内存地址 | 0 | 1 | 2 | 3 |
---|---|---|---|---|
值 | 00000011 | 00000010 | 00000001 | 00000000 |
这时候,也就相当于成功完成了一次 bswap 的操作了。
由于 int64 有 8 位,所以 MASK 为 0x00 01 02 03 04 05 06 07 就可以完成一次 int64 的 bswap。
(注:没有 0 键在编写此节时遭到虐待)
最后附上 Go 中的测试结果,我们测试了 List 中有 12345 个元素的 benchmark:
1 | BenchmarkWriteListI64 |
可以看出,在 Go 上的性能提升非常巨大,List<i64> 场景下提升六倍,List<i32> 更是提升了十几倍。
究其原因,应该是 Go 做的优化太少太差,远远比不上 gcc。
]]>https://www.bilibili.com/video/BV1UZ4y1g7ju
链接: https://pan.baidu.com/s/1w8TKFZcFbAi-ug26pzkxug 密码: vvbh
解压密码:purewhite.io
]]>有多少人工就有多少智能。——鲁迅
众所周知,字节跳动内部主要使用 Thrift,为了更好地掌控生成代码,我们用 Go 自己实现了 Thrift 代码生成工具。
而我们的故(shi)事(gu),正是由一次重构开始……
在一次平淡无奇的重构发版后,正当我拎着电脑包往外冲心里已经盘算好了回去之后要拿出我熟练度 30W 的至臻 KDA 卡莎大杀四方时,业务方拉住了我,告诉我他们在用了新版的生成代码后,性能下降了10%。
内心 os:What???你们是不是有其它逻辑变更?我写的代码怎么可能有 bug 逻辑一模一样的生成代码怎么可能会有性能差异?
好吧,为了避免突然哪一天账号已停用,我还是耐心地问了业务方一个问题:
于是在一通如此这般地各种标准对齐、环境对齐等等一通操作(此处省略 2^10^10 字)后,我们终于搞清楚了状况:
重构后的生成代码比重构之前,在该业务方的 idl 上,性能真的要差 10%!
What???虽然重构过生成代码,但是新的生成代码无论从语义上还是实现上都是(几乎)和旧的一致的,怎么可能性能会差???
好吧,为了发扬我大 IG 不加班的光辉传统,我们决定直接十五投就完事——把生成代码 revert 回旧版的。好了,问题解决。(第二天,HR:小吴啊,财务室工资结一下)。
先附上我们用来讲解生成代码的 IDL:
1 | struct Example { |
首先,对比一下新旧生成代码(由于代码较多,就不直接贴在文章中了)。
旧代码:https://gist.github.com/PureWhiteWu/bdd28734ab1f675bb7b73ecf0c57e994
新代码:https://gist.github.com/PureWhiteWu/63ac02ee613695213fe9eac4e22493ba
可以看到,新旧生成代码,在编解码逻辑上是完全等价的!不过旧代码采用了局部变量,新代码是直接用的对应结构体的字段。我们怀疑是不是这里的差异导致的(这可能会导致计算 offset 的开销),于是生成了汇编进行比较(由于汇编较大,不直接贴了,有兴趣的同学建议自行生成一下看一下),发现确实是多了一条 MOVQ 语句用来计算偏移量!
1 | MOVQ"".p+144(SP), AX |
看来罪魁祸首好像找到了?不过又感觉哪儿不太对,毕竟现代 CPU 都是有多级流水线的,就多这么一条 MOVQ 语句,对于多级流水线架构的 CPU 来说,性能差距再怎么不可能导致 10% 这么大,特别是尽管这个语句是在 for 循环中的,但是在总的执行的指令占比中也没有 10% 这么多。
为了验证我们的疑问,我们改了一版生成代码,改为了和原先生成的一样使用临时变量,发现确实去掉了这条语句后,性能没有任何变化。也就是说,性能的问题并不是这个间接寻址导致的。
随后,根据生成代码的汇编差异,我们提出了许多猜想,花了大量时间进行验证,但是均不是性能变差的原因(此处过于心酸略过不表)。
最终,我们定位到了是由于在新的生成代码中,相比旧版本的生成代码,在返回错误的时候会额外包装一下:
1 | if err := ...; err != nil { |
而旧版本的生成代码是直接返回的错误:
1 | if err := ...; err != nil { |
虽然这些只是在发生错误的时候才会调用到,在正常流程中不会用到,但是生成的汇编代码中这段逻辑占了相当大的比例:
而 Go 的编译器并没有帮我们重排这些指令,导致在真正运行的时候,L1 cache miss 大大提高,极大地降低了性能,参考如下实验结果:
针对这种编译器太弱智导致的问题,只能上人工智能来解决了——有多少人工就有多少智能。
既然编译器不会自动做指令重排,那就我们来帮编译器干这事,改造完成后的生成代码见:https://gist.github.com/PureWhiteWu/296f2bdac6051e4052a68c2bb1de1c07
比较关键的方法是,我们在所有原先return thrift.PrependError
的地方,都改为了goto XXXError
,如下:
1 | func (p *Example) Read(iprot thrift.TProtocol) error { |
通过这种方式,使得我们正常流程中,如果判断 err 出错的情况之下,不再有之前的一大段处理的指令,而仅仅是变成了一条简单的 jmp 指令;而对应的错误处理逻辑,则尽可能放在正常流程 return 之后,使得尽可能减少 cpu load 指令的次数并降低 L1 icache miss;同时,使得所有的错误处理的逻辑在最终的汇编中只会出现一次,而不是出现多次。
这里必须吐槽一波,Go 编译器有时候会“贴心”地帮你把这些代码挪回到上面,但是由于只会出现一次而其它错误处理的地方都会直接 jmp,所以问题也不大,后续可以考虑试一下把这些逻辑扔到一个独立的函数中并标记 noinline 是否可以再度提高性能(使得在主流程中完全不出现)。
经过这个调整,perf 的性能明显好了很多,并且可能比旧版本更优:
至此,这个问题算是搞明白了,在这个过程中,最大的收获是:Go 编译器竟然如此的弱智 人工指令重排竟然能带来如此之大的提升。
谨以此文分享我们的经验,希望能够抛砖引玉,为性能优化提出一个新的思路,毕竟鲁迅曾说过:
]]>最近 Go 1.15 发布了,我也第一时间更新了这个版本,毕竟对 Go 的稳定性还是有一些信心的,于是直接在公司上了生产。
结果,上线几分钟,就出现了 OOM,于是 pprof 了一下 heap,然后赶紧回滚,发现某块本应该在一次请求结束时被释放的内存,被保留了下来而且一直在增长,如图(图中的 linkBufferNode):
这次上线的变更只有 Go 版本的升级,没有任何其它变动,于是在本地开始测试,发现在本地也能百分百复现。
看了 Go 1.15 的 Release Note,发现有俩高度疑似的东西:
于是改 runtime,关闭新的内存分配算法,切换回旧的,等等一顿操作猛如虎下来,发现问题还是没解决,现象仍然存在。
于是实在不行,祭出了GODEBUG="allocfreetrace=1
大法,肉眼从100MB+的日志文件里面看啊看啊看啊看啊看啊看啊看啊看啊看啊看啊……(此处省略心酸过程)
最终直觉告诉我,这个问题可能和 Go 1.15 中 sync.Map 的改动有关(别问我为啥,真的是直觉,我也说不出来)。
为了方便讲解,我写了一个最小可复现的代码,如下:
1 | package main |
在 Go 1.15 中,sync.Map 增加了一个方法LoadAndDelete
,具体的 issue 在这:sync: add new Map method LoadAndDelete,CL 在这:CL。
为什么我确认是这个改动导致的呢?很简单:我在本地把这个改动 revert 掉了,问题就没了,好了关机下班……
当然没这么简单,知其然要知其所以然,于是开始看到底改了哪块……(此处省略100000字)
最终发现,关键代码是这段:
1 | // LoadAndDelete deletes the value for a key, returning the previous value if any. |
在这段代码中,会发现在 Delete 的时候,并没有真正删除掉 key,而是从 key 中取出了 entry,然后把 entry 设为 nil……
所以,在我们场景中,我们把一个连接作为 key 放了进去,于是和这个连接相关的比如 buffer 的内存就永远无法释放了……
那么为什么在 Go 1.14 中没有问题呢?以下是 Go 1.14 的代码:
1 | // Delete deletes the value for a key. |
在 Go 1.14 中,如果 key 在 dirty 中,是会被删除的;而凑巧,我们其实“误用”了 sync.Map,在我们的使用过程中没有读操作,导致所有的 key 其实都在 dirty 里面,所以当调用 Delete 的时候是会被真正删除的。
要注意,无论哪个版本的 Go,一旦 key 升级到了 read 中,在没有 miss 到一定的值让 dirty 提升为 read 时,key 都是永远不会被删除的。也就是说,极端情况之下,key 是会泄露的。
在 Go <= 1.15 版本中,sync.Map 中的 key 在极端情况下是不会被删除的,如果在 Key 中放了一个大的对象,或者关联有内存,就会导致内存泄漏。
针对这个问题,我已经向 Go 官方提出了Issue,目前来看这个 behaviour 定义为了 bug(因为违背了 Go 1 兼容性承诺,和 1.14 中的 behaviour 不同了),已经由 @ChangKun Ou 大佬提了 pr 修复了,并且 backport 到了 1.15.1 中。
而针对 read 中的 key 在没有 dirty 被提升时不会删除的问题,目前看来是一个设计上的 trade-off,如果有真实世界中的程序(real-world program)出问题的话,再提 issue,看看是否要解决。
]]>这几天在重构某段代码后,做了一次性能测试,火焰图中发现了一个十分奇怪的runtime.newobject
的调用,大致占用2%,而找遍了整段代码都没有发现有新建对象相关的逻辑。于是迫不得已,祭出了汇编大法,终于定位到了问题所在。这篇文章会使用一段最小可复现的代码来分享这个问题以及背后的原因。
1 | package main |
这段代码中,初看起来貌似在 main 函数中(不考虑 newFuncContainer 函数中导致的内存分配)没有运行时内存分配(m 会被优化成全局区,所以不会真的导致运行时内存分配),但是实际上在 main 中是有两次运行时内存分配的,这是怎么回事呢?
我们用-gcflags="-m"
来打印一下编译器的优化信息,可以看到:
1 | ./main.go:13:7: m does not escape |
竟然说 42、43 两行中的m.myFunc
和m2.myFunc2
“逃逸到了堆上”?一个函数还能逃逸到堆上???
虽然看起来貌似真的是这里导致的,但是我们说话做事要有证据,于是祭出汇编大法(-gcflags="-S"
),看一下生成的汇编代码是啥样的:
1 | "".main STEXT size=160 args=0x0 locals=0x18 |
这下子实锤了,真的是这里导致的,但是为啥呢?我把一个函数赋值给某个变量,为什么会导致一次内存分配呢?函数名不是一个指针,指向函数所在的代码地址么?
在 Golang 中,函数调用其实并不像 C 那么简单,有一定的分类:
在 Go 中,一共有 4 种类型的函数:
有 5 种类型的函数调用:
以下的示例程序展示了所有可能的函数调用方式:
1 | package main |
如上程序所示,一共有 10 种可能的调用组合:
以上列表中,斜杠 / 左侧是在编译时就已知的信息,右侧是在运行时才知道的信息。在编译时生成的代码是不知道运行时的信息的,所以在运行时需要生成一些额外的适配器函数(adapter functions)来达成间接调用。
看到这里,大家应该能隐约猜测到原因了,正如你所猜测,在我们开头的程序中,存在着间接调用,Go 分配的这个对象和间接调用脱不了关系。由于直接调用没啥可说的,所以我们略过不谈,只说间接调用。
在 Go 里面,间接调用的实现如下图:
实际上,Go 分配了一个额外的对象,其第一个字段是一个指向我们真实函数的指针,第二个对象是与函数强相关的一些数据(对,没错,说的就是接收者 receiver)。于是,一次函数调用实际上会生成类似如下的代码:
1 | MOV …, R0 |
有一个例外,就是当一个函数并没有相关数据,如仅仅会捕获外部的局部变量的函数字面量,那么这个函数就不会有相关联的数据,于是内存布局如下:
在这个场景下,Go 会将这个变量的分配优化在只读区,不会在每次调用时都进行分配,也就是生成如下代码:
1 | MOV $MyFunc·f(SB), f1 |
所以我们其实不必太过担心这种场景下的性能损耗,在这种场景下是 0 损耗的。
对于非例外的场景,一个适配器函数生成的代码类似下面这样:
1 | type funcValue struct { |
在调用时,调用的实际上是适配器函数,适配器函数随后去调用真实的函数。
其实想想也很简单,对于值接收者和指针接收者函数,调用时第一个参数为 self,那么如果我现在是需要把某个关联在特定值 / 指针上的函数作为一个函数值赋值给某个函数变量时,我也需要一起把对应的值 / 指针信息一起带上,不然等我真正调用的时候,我怎么知道应该调用的是哪个值 / 指针上的方法呢?也就是说,传入函数的 self 值应该是多少呢?
回到我们开头的问题,可以看到造成两次内存分配的罪魁祸首已然找到,在汇编代码里面其实也已经能看出端倪:
1 | 0x0031 00049 (main.go:42)PCDATA$0, $1 |
注意上述 LEAQtype.noalg.struct { F uintptr; R *"".myFuncImplStruct }(SB), AX
这段代码,咱也别管啥意思,反正看到了一个和之前说的适配器很像的一个 struct,这个 struct 有两个字段,第一个是F uintptr
,第二个是R *myFuncImplStruct
;下面还有一个LEAQtype.noalg.struct { F uintptr; R "".myFuncImplStruct }(SB), AX
,只不过这里的 R 是myFuncImplStruct
的值而不是指针,这正好和我们代码吻合。
好了,到这基本上这个问题清楚了,要优化的话也很简单,只要把实际上并不需要有值接收者或者指针接收者的函数改为顶层函数即可,或者尽可能不要将一个值接收者 / 指针接收者函数进行间接调用。
由此可以看出,有接收者的函数是有代价的,不能乱用啊,代码设计还是要合理,否则是会引入额外的性能开销的。
HACKING.md
觉得颇有意思,读完之后觉得对于 runtime 的理解更上一层,于是想着翻译一下。本章内容会有一定深度,需要有一定基础的读者,限于篇幅在这里不可能完全展开各个细节。
这一篇文档面向的读者是 runtime 的开发者,所以有很多内容在我们普通使用中是接触不到的。
这篇文档是会被经常编辑的,并且随着时间推移目前的内容可能会过时。这篇文档旨在说明写 runtime 代码和普通的 go 代码有什么不同,所以关注于一些普遍的概念而不是一些细节的实现。
调度器管理三个在 runtime 中十分重要的类型:G
、M
和P
。哪怕你不写 scheduler 相关代码,你也应当要了解这些概念。
一个G
就是一个 goroutine,在 runtime 中通过类型g
来表示。当一个 goroutine 退出时,g
对象会被放到一个空闲的g
对象池中以用于后续的 goroutine 的使用(译者注:减少内存分配开销)。
一个M
就是一个系统的线程,系统线程可以执行用户的 go 代码、runtime 代码、系统调用或者空闲等待。在 runtime 中通过类型m
来表示。在同一时间,可能有任意数量的M
,因为任意数量的M
可能会阻塞在系统调用中。(译者注:当一个M
执行阻塞的系统调用时,会将M
和P
解绑,并创建出一个新的M
来执行P
上的其它G
。)
最后,一个P
代表了执行用户 go 代码所需要的资源,比如调度器状态、内存分配器状态等。在 runtime 中通过类型p
来表示。P
的数量精确地(exactly)等于GOMAXPROCS
。一个P
可以被理解为是操作系统调度器中的 CPU,p
类型可以被理解为是每个 CPU 的状态。在这里可以放一些需要高效共享但并不是针对每个P
(Per P
)或者每个M
(Per M
)的状态(译者注:意思是,可以放一些以P
级别共享的数据)。
调度器的工作是将一个G
(需要执行的代码)、一个M
(代码执行的地方)和一个P
(代码执行所需要的权限和资源)结合起来。当一个M
停止执行用户代码的时候(比如进入阻塞的系统调用的时候),就需要把它的P
归还到空闲的P
池中;为了继续执行用户的 go 代码(比如从阻塞的系统调用退出的时候),就需要从空闲的P
池中获取一个P
。
所有的g
、m
和p
对象都是分配在堆上且永不释放的,所以它们的内存使用是很稳定的。得益于此,runtime 可以在调度器实现中避免写屏障(译者注:垃圾回收时需要的一种屏障,会带来一些性能开销)。
getg()
和getg().m.curg
如果想要获取当前用户的g
,需要使用getg().m.curg
。
getg()
虽然会返回当前的g
,但是当正在系统栈或者signal
栈上执行的时候,会返回的是当前M
的g0
或者gsignal
,而这很可能不是你想要的。
如果要判断当前正在系统栈上执行还是用户栈上执行,可以使用getg() == getg().m.curg
。
每个存活着的(non-dead)G
都会有一个相关联的用户栈,用户的代码就是在这个用户栈上执行的。用户栈一开始很小(比如 2K),并且动态地生长或者收缩。
每一个M
都有一个相关联的系统栈(也被称为g0
栈,因为这个栈也是通过g
实现的);如果是在 Unix 平台上,还会有一个 signal
栈(也被称为gsignal
栈)。系统栈和signal
栈不能生长,但是足够大到运行任何 runtime 和 cgo 的代码(在纯 go 二进制中为 8K,在 cgo 情况下由系统分配)。
runtime 代码经常通过调用systemstack
、mcall
或者asmcgocall
临时性的切换到系统栈去执行一些特殊的任务,比如:不能被抢占的、不应该扩张用户栈的和会切换用户 goroutine 的。在系统栈上运行的代码隐含了不可抢占的含义,同时垃圾回收器不会扫描系统栈。当一个M
在系统栈上运行时,当前的用户栈是没有被运行的。
大多数函数都以检查堆栈指针和当前 G 的堆栈边界的 prologue 开始,并在堆栈需要增长时调用 morestack。
可以使用//go:nosplit
(或者在汇编中使用NOSPLIT
)标记功能,以指示它们不应该具有此 prologue。这有几个用途:
必须在用户堆栈上运行的功能,但不能调用堆栈增长。例如因为这会导致死锁,或者因为它们在堆栈上有无类型的 words。
在进入时不可被抢占的功能。
可能没有有效 G 的功能。例如,runtime 初始化代码中的功能,或者可能从 C 代码进入的功能,例如 cgo 回调或信号处理程序。
可拆分函数确保堆栈上有一定数量的空间,以便在其中运行不可拆分函数,链接器检查任何静态链的不可拆分函数调用是否不超过此限制。
任何具有//go:nosplit
注释的函数都应在其文档注释中解释为什么是不可拆分的。
在用户代码中,有一些可以被合理地(reasonably)恢复的错误可以像往常一样使用panic
,但是有一些情况下,panic
可能导致立即的致命的错误,比如在系统栈中调用或者当执行mallocgc
时。
大部分的 runtime 的错误是不可恢复的,对于这些不可恢复的错误应该使用throw
,throw
会打印出traceback
并立即终止进程。throw
应当被传入一个字符串常量以避免在该情况下还需要为 string 分配内存。根据约定,更多的信息应当在throw
之前使用print
或者println
打印出来,并且应当以runtime.
开头。
对于不可恢复的错误,如果用户代码有可能导致故障(例如并发 map 写入),请使用 fatal。
为了进行 runtime 的错误调试,可以使用GOTRACEBACK=system
或GOTRACEBACK=crash
运行。panic
和fatal
的输出由GOTRACEBACK
描述。throw
的输出始终包括 runtime stack、元数据和所有 goroutines,无论GOTRACEBACK
是什么(即与GOTRACEBACK=system
等效)。是否让throw
崩溃仍然受GOTRACEBACK
控制。
runtime 中有多种同步机制,这些同步机制不仅是语义上不同,和 go 调度器以及操作系统调度器之间的交互也是不一样的。
最简单的就是mutex
,可以使用lock
和unlock
来操作。这种方法主要用来短期(长期的话性能差)地保护一些共享的数据。在mutex
上阻塞会直接阻塞整个M
,而不会和 go 的调度器进行交互。因此,在 runtime 中的最底层使用 mutex
是安全的,因为它还会阻止相关联的G
和P
被重新调度(M
都阻塞了,无法执行调度了)。rwmutex
也是类似的。
如果是要进行一次性的通知,可以使用note
。note
提供了notesleep
和notewakeup
。不像传统的 UNIX 的sleep/wakeup
,note
是无竞争的(race-free),所以如果notewakeup
已经发生了,那么notesleep
将会立即返回。note
可以在使用后通过noteclear
来重置,但是要注意noteclear
和notesleep
、notewakeup
不能发生竞争。类似mutex
,阻塞在note
上会阻塞整个M
。然而,note
提供了不同的方式来调用sleep
:notesleep
会阻止相关联的G
和P
被重新调度;notetsleepg
的表现却像一个阻塞的系统调用一样,允许P
被重用去运行另一个G
。尽管如此,这仍然比直接阻塞一个G
要低效,因为这需要消耗一个M
。
如果需要直接和 go 调度器交互,可以使用gopark
和goready
。gopark
挂起当前的 goroutine——把它变成waiting
状态,并从调度器的运行队列中移除——然后调度另一个 goroutine 到当前的M
或者P
。goready
将一个被挂起的 goroutine 恢复到runnable
状态并将它放到运行队列中。
总结起来如下表:
Blocks | |||
---|---|---|---|
Interface | G | M | P |
(rw)mutex | Y | Y | Y |
note | Y | Y | Y/N |
park | Y | N | N |
runtime 使用runtime/internal/atomic
中自有的一些原子操作。这个和sync/atomic
是对应的,除了方法名由于历史原因有一些区别,并且有一些额外的 runtime 需要的方法。
总的来说,我们对于 runtime 中 atomic 的使用非常谨慎,并且尽可能避免不需要的原子操作。如果对于一个变量的访问已经被另一种同步机制所保护,那么这个已经被保护的访问一般就不需要是原子的。这么做主要有以下原因:
当然,所有对于一个共享变量的非原子的操作都应当在文档中注明该操作是如何被保护的。
有一些比较普遍的将原子操作和非原子操作混合在一起的场景有:
话虽如此,Go Memory Model
给出的建议仍然成立Don't be [too] clever
。runtime 的性能固然重要,但是鲁棒性(robustness)却更加重要。
一般情况下,runtime 会尝试使用普通的方法来申请内存(堆上内存,gc 管理的),然而在某些情况 runtime 必须申请一些不被 gc 所管理的堆外内存(unmanaged memory)。这是很必要的,因为有可能该片内存就是内存管理器自身,或者说调用者没有一个P
(译者注:比如在调度器初始化之前,是不存在P
的)。
有三种方式可以申请堆外内存:
sysAlloc
直接从操作系统获取内存,申请的内存必须是系统页表长度的整数倍。可以通过sysFree
来释放。persistentalloc
将多个小的内存申请合并在一起为一个大的sysAlloc
以避免内存碎片(fragmentation)。然而,顾名思义,通过persistentalloc
申请的内存是无法被释放的。fixalloc
是一个SLAB
风格的内存分配器,分配固定大小的内存。通过fixalloc
分配的对象可以被释放,但是内存仅可以被相同的fixalloc
池所重用。所以fixalloc
适合用于相同类型的对象。一般来说,使用任何这些分配的类型应通过嵌入runtime/internal/sys.NotInHeap
来标记为非堆上类型。
在堆外内存所分配的对象不应该包含堆上的指针对象,除非同时遵守了以下的规则:
runtime.markroot
来标记。Zero-initialization versus zeroing
.在 runtime 中有两种类型的零初始化,取决于内存是否已经初始化为了一个类型安全的状态。
如果内存不在一个类型安全的状态,意思是可能由于刚被分配,并且第一次初始化使用,会含有一些垃圾值(译者注:这个概念在日常的 Go 代码中是遇不到的,如果学过 C 语言的同学应该能理解什么意思),那么这片内存必须使用memclrNoHeapPointers
进行zero-initialized
或者无指针的写。这不会触发写屏障(译者注:写屏障是 GC 中的一个概念)。
内存可以通过typedmemclr
或者memclrHasPointers
来写入零值,设置为类型安全的状态。这会触发写屏障。
除了go doc compile
中注明的//go:
编译指令外,编译器在 runtime 包中支持了额外的一些指令。
go:systemstack
表明一个函数必须在系统栈上运行,这个会通过一个特殊的函数前引(prologue)动态地验证。
go:nowritebarrier
告知编译器如果以下函数包含了写屏障,触发一个错误(这不会阻止写屏障的生成,只是单纯一个假设)。
一般情况下你应该使用go:nowritebarrierrec
。go:nowritebarrier
当且仅当“最好不要”写屏障,但是非正确性必须的情况下使用。
go:nowritebarrierrec
告知编译器如果以下函数以及它调用的函数(递归下去),直到一个go:yeswritebarrierrec
为止,包含了一个写屏障的话,触发一个错误。
逻辑上,编译器会在生成的调用图上从每个go:nowritebarrierrec
函数出发,直到遇到了go:yeswritebarrierrec
的函数(或者结束)为止。如果其中遇到一个函数包含写屏障,那么就会产生一个错误。
go:nowritebarrierrec
主要用来实现写屏障自身,用来避免死循环。
这两种编译指令都在调度器中所使用。写屏障需要一个活跃的P
(getg().m.p != nil
),然而调度器相关代码有可能在没有一个活跃的P
的情况下运行。在这种情况下,go:nowritebarrierrec
会用在一些释放P
或者没有P
的函数上运行,go:yeswritebarrierrec
会用在重新获取到了P
的代码上。因为这些都是函数级别的注释,所以释放P
和获取P
的代码必须被拆分成两个函数。
//go:uintptrkeepalive 指令后面必须跟随一个函数声明。
它指定函数的 uintptr 参数可能是已转换为 uintptr 的指针值,并且在整个调用期间必须保持活动状态,即使从类型本身看在调用期间对象不再需要。
该指令类似于 //go:uintptrescapes,但不强制逃逸参数。由于堆栈增长不理解这些参数,此指令必须与 //go:nosplit 一起使用(在标记函数中以及所有传递参数的函数中),以防止堆栈增长。
从指针到 uintptr 的转换必须出现在此函数的任何调用参数列表中。此指令用于某些低级系统调用实现。
]]>一共拥有以下几个(种)branch:
branch name 应当采用 下划线命名法。会在 ci 中对于 branch name 做强制检查,如果不合规会直接 fail。留出 test/* 的 branch 也是为了能够支持一些测试性的工作能够通过 ci 检查。
首先,确认自己在 develop 分支上;
git checkout -b feature/your_feature
;
开发完成后,push 到 origin;
提 mr(如果是 性能优化,请在 description 中附带上 benchcmp 的结果),target branch 为 develop,并勾选最下方两个选项:
等待 review 通过,通过后点击 merge,请再次确认 squash 和 delete branch 被勾选:
如果 merge request 有 description,可以点击 “Modify commit message” 并点击最下方的 include description,然后再点击 merge:
done。
git checkout -b bugfix/your_bugfix
;这里不需要直接 merge 回 develop 是因为 release/* 最终会 merge 回 develop。
git checkout -b bugfix/your_bugfix
;git checkout -b hotfix/your_hotfix
;适用于需要修复的 bug 在 develop 分支上已不存在的情况。
版本号的更新不需要同步到develop,在下次merge的时候解决冲突即可。
git checkout -b hotfix/your_hotfix
;git checkout -b hotfix/your_hotfix
;发版流程比较特殊,和其它流程有较大区别,请注意细节。
这么做的原因是,如果先把 release branch merge 到 develop 分支上,再将 develop 分支 merge 进 master 的话,可能会带上预料之外的 commit(在整理 release 的时候有新的 mr 被 merge 到 develop)。
git checkout -b release/vX.Y.Z
;1 | !/usr/bin/env bash |
简单示意图如下:
1 | +-------------------------------------------+ |
Transport层提供了一个读写底层网络的简单抽象,这使得Thrift可以把底层的网络传输和其它部分(比如序列化、反序列化)解耦开。
Transport主要包含以下接口:
除了上面这个Transport的接口,Thrift还提供了一个ServerTransport的接口,用来accept或者create上面的Transport对象。顾名思义,ServerTransport主要用在服务端,用来接受连接并创建Transport对象。
ServerTransport主要包含以下接口:
Thrift主要支持的语言中有的部分接口示例如下:
Protocol层定义了序列化、反序列化的格式和方法,比如json、xml、plain text、compact binary等等。
Protocol的接口定义如下:
1 | writeMessageBegin(name, type, seq) |
Thrift Protocol在设计上就是以流为目标的,所以不需要任何显式的帧。比如,当我们在序列化一个string之前,我们不需要知道它有多长;同样的,当我们序列化一个list之前,不需要知道里面有几个item。部分Thrift主要支持语言所常用的Protocol如下:
Processor提供了从输入流读取数据以及写出到输出流的能力,输入和输出流都是由Protocol层实现,Processor本身很简单:
1 | interface TProcessor { |
每个服务的Processor都是由compiler生成的,Processor从输入流读取数据,扔给用户的handler处理,再把response写回输出流。
Server把上述所有的特性组合在一起:
1 | package main |
根据逃逸分析可以看出来f和f2这两个函数中的d变量分别分配在哪里:
1 | go build -gcflags '-m -m' unsafe.go |
可以看出来在函数f中,d逃逸到堆上;但是在函数f2中,d没有发生逃逸,uintptr没有持有对象。
再来看看汇编的结果:
1 | $ go tool compile -S unsafe.go | grep unsafe.go:24 |
可以看出来,结果也是一样的,f中的d调用了newobject,但是f2中没有。
所以为什么说unsafe包不安全呢,原因之一就是因为go不保证地址一定是有效的,当然还有其它的原因,有时间再验证分享。
]]>unsafe.Pointer
的讨论,觉得应当记录一下。问题1:如果一个对象只被unsafe.Pointer
所指向,那么这个对象会被回收么?
回答1:不会。如果unsafe.Pointer
指向了一个对象,那么go的GC会知道有这个对象,并且不会释放这个对象的内存。
但是注意,有一个例外:如果这个对象的内存是在go外被分配的(比如C.malloc
),那么以上的规则不生效。
问题2:如果这个对象内部也有一些指针,那么GC会如何处理这些指针?
回答2:如果这个对象是在go内部分配的,那么GC也会遍历这些指针(也就是不会被释放)。
问题3:如果在以上两个问题中,对象都不会被释放,那么GC是怎么处理的?unsafe.Pointer
会存对象的类型信息么?
回答3:不会存类型信息,但是如果对象是在go中申请的,那么在对应的内存中是会存有类型信息的;如果没有类型信息,那么GC会采用非常保守的策略:遍历整个对象,只要其中有8bit的值是合法的内存地址(在栈范围内,或者在堆上),就认为是指针,不会进行回收。
问题4:有没有一种情况unsafe.Pointer
会变成非法的(野指针)?
回答4:在go中,只要unsafe.Pointer
有一刻是合法的,并且它的值没有修改,那么go会保证它在整个程序的生命周期中都是合法的。在unsafe.Pointer
和unsafe.Pointer
间的赋值一定是安全的,但是间接的赋值(比如同过uintptr)可能是非法的,因为uintptr不被认为持有了对象。
go会忽视所有非go分配的对象(比如C.malloc),所以如果在C中有一个指针指向的地址包含了go的对象,那么必须保证这个指针在go中也被一个对象存储下来。
]]>sync.Mutex
这把互斥锁来保证临界资源的访问互斥。既然经常会用这把锁,那么了解一下其内部实现,就能了解这把锁适用什么场景,特性如何了。
在看sync.Mutex
的代码的时候,一定要记住,同时会有多个goroutine会来要这把锁,所以锁的状态state
是可能会一直更改的。
先说结论:sync.Mutex
是把公平锁。
在源代码中,有一段注释:
1 | // Mutex fairness. |
看懂这段注释对于我们理解mutex这把锁有很大的帮助,这里面讲了这把锁的设计理念。大致意思如下:
1 | // 公平锁 |
在下一步真正看源代码之前,我们必须要理解一点:当一个goroutine获取到锁的时候,有可能没有竞争者,也有可能会有很多竞争者,那么我们就需要站在不同的goroutine的角度上去考虑goroutine看到的锁的状态和实际状态、期望状态之间的转化。
sync.Mutex
只包含两个字段:
1 | // A Mutex is a mutual exclusion lock. |
其中state
是一个表示锁的状态的字段,这个字段会同时被多个goroutine所共用(使用atomic.CAS来保证原子性),第0个bit(1)表示锁已被获取,也就是已加锁,被某个goroutine拥有;第1个bit(2)表示有goroutine被唤醒,尝试获取锁;第2个bit(4)标记这把锁是否为饥饿状态。
sema
字段就是用来唤醒goroutine所用的信号量。
在看代码之前,我们需要有一个概念:每个goroutine也有自己的状态,存在局部变量里面(也就是函数栈里面),goroutine有可能是新到的、被唤醒的、正常的、饥饿的。
先看一下最基础的一行代码加锁的CAS操作:
1 | // Lock locks m. |
这是第一段代码,这段代码调用了atomic
包中的CompareAndSwapInt32
这个方法来尝试快速获取锁,这个方法的签名如下:
1 | // CompareAndSwapInt32 executes the compare-and-swap operation for an int32 value. |
意思是,如果addr指向的地址中存的值和old一样,那么就把addr中的值改为new并返回true;否则什么都不做,返回false。由于是atomic
中的函数,所以是保证了原子性的。
我们来具体看看CAS的实现(src/runtime/internal/atomic/asm_amd64.s
):
1 | // bool Cas(int32 *val, int32 old, int32 new) |
如果看不懂也没太大关系,只要知道这个函数的作用,以及这个函数是原子性的即可。
那么这段代码的意思就是:先看看这把锁是不是空闲状态,如果是的话,直接原子性地修改一下state
为已被获取就行了。多么简洁(虽然后面的代码并不是……)!
接下来具体看主流程的代码,代码中有一些位运算看起来比较晕,我会试着用伪代码在边上注释。
1 | // Lock locks m. |
以上为什么CAS能拿到锁呢?因为CAS会原子性地判断old state
和当前锁的状态是否一致;而总有一个goroutine会满足以上条件成功拿锁。
接下来我们来看看上文提到的canSpin
条件如何:
1 | // Active spinning for sync.Mutex. |
所以可以看出来,并不是一直无限自旋下去的,当自旋次数到达4次或者其它条件不符合的时候,就改为信号量拿锁了。
然后我们来看看doSpin
的实现(其实也没啥好看的):
1 | //go:linkname sync_runtime_doSpin sync.runtime_doSpin |
这是一个汇编实现的函数,简单看两眼amd64上的实现:
1 | TEXT runtime·procyield(SB),NOSPLIT,$0-0 |
看起来没啥好看的,直接跳过吧。
接下来我们来看看Unlock的实现,对于Unlock来说,有两个比较关键的特性:
1 | func (m *Mutex) Unlock() { |
根据以上代码的分析,可以看出,sync.Mutex
这把锁在你的工作负载(所需时间)比较低,比如只是对某个关键变量赋值的时候,性能还是比较好的,但是如果说对于临界资源的操作耗时很长(特别是单个操作就大于1ms)的话,实际上性能上会有一定的问题,这也就是我们经常看到“的锁一直处于饥饿状态”的问题,对于这种情况,可能就需要另寻他法了。
好了,至此整个sync.Mutex
的分析就此结束了,虽然只有短短200行代码(包括150行注释,实际代码估计就50行),但是其中的算法、设计的思想、编程的理念却是值得感悟,所谓大道至简、少即是多可能就是如此吧。
首先需要知道,我们说的堆和栈是啥。这个可不是数据结构里面的”堆”和”栈”,而是操作系统里面的概念。
在程序中,每个函数块都会有自己的内存区域用来存自己的局部变量(内存占用少)、返回地址、返回值之类的数据,这一块内存区域有特定的结构和寻址方式,大小在编译时已经确定,寻址起来也十分迅速,开销很少。这一块内存地址称为栈。栈是线程级别的,大小在创建的时候已经确定,所以当数据太大的时候,就会发生”stack overflow”。
在程序中,全局变量、内存占用大的局部变量、发生了逃逸的局部变量存在的地方就是堆,这一块内存没有特定的结构,也没有固定的大小,可以根据需要进行调整。简单来说,有大量数据要存的时候,就存在堆里面。堆是进程级别的。当一个变量需要分配在堆上的时候,开销会比较大,对于go这种带GC的语言来说,也会增加gc压力,同时也容易造成内存碎片。
这个问题要从C++说起了。在C++中,假设我们有以下代码:
1 | int* f1() { |
这时候程序结果是无法预期的,因为在函数f1中,i是一个局部变量,会分配在栈上,而栈在函数返回之后就失效了(Plan9 汇编中SP指针被修改),于是i的地址所存的值是不可预期的,后续在main中对返回的i的地址中的值的修改可能会修改掉程序运行的数据,造成结果无法预期。
所以对于需要返回一个地址回去的情况,在C++中需要用new来分配一块堆上的内存才行,因为堆是进程级别的,也就是全局的,除非程序猿手动释放,否则不会被回收(释放不好会段错误,忘了释放会内存泄漏),于是就可以使得这个地址不会再被使用到,可以安全地返回。
在golang中,所有内存都是由runtime管理的,程序猿不需要关心具体变量分配在哪里,什么时候回收,但是编译器需要知道这一点,这样才能确定函数栈帧大小、哪些变量需要”new”在堆上,所以编译器需要进行逃逸分析
。简单来说,逃逸分析
决定了一个变量是分配在栈上还是分配在堆上。
golang逃逸分析最基本的原则是:如果一个函数返回的是一个(局部)变量的地址,那么这个变量就发生逃逸
。
在golang里面,变量分配在何处和是否使用new无关,意味着程序猿无法手动指定某个变量必须分配在栈上或者堆上(自己撸asm的当我没说),所以我们需要通过一些方法来确定某个变量到底是分配在了栈上还是堆上。
我们用以下代码作为例子:
1 | package main |
在以上代码中,给f1增加了noinline标记,让go编译器不要将函数内联。
golang提供了编译的参数让我们可以直观地看到变量是否发生了逃逸,只需要在go build时指定 -gcflags '-m'
即可:
1 | go build -gcflags '-m' escape.go |
这样可以很直观地看到在第10、11行,i发生了逃逸,内存会分配在堆上。
除了使用编译参数之外,我们还可以使用一种更底层的,更硬核,也更准确的方式来判断一个对象是否逃逸,那就是:直接看汇编!
我们使用go tool compile -S
生成汇编代码:
1 | $ go tool compile -S escape.go | grep escape.go:10 |
可以看到,这里的00040有调用runtime.newobject(SB)
这个方法,看到这个方法大家就应该懂了!
以上提供了两种方法可以用来判断某个变量是否发生了逃逸,其中使用编译参数比较简单,使用汇编比较硬核。通过这两种方法分析完逃逸,就能进一步优化堆上内存数量,减轻GC压力了。
]]>给定一个长度为n的数列a0, a1, a2...an-1
,求出这个序列中的最长的上升子序列的长度,上升子序列的定义为:对于任意的i<j
,都满足ai<aj
。
限制条件:1≤n≤1000,0≤a≤1000000
样例:
输入:
1 | n = 5 |
输出:
1 | 3(a1, a2, a4构成的子序列2,3,5最长) |
这个问题就是著名的最长上升子序列(LIS,Longest Increasing Subsequence)问题,这个问题有两种解法,第一种解法是O(n²)的DP解法,第二种解法是O(nlogn)的DP加二分解法。
首先我们可以来建立一下DP的递推关系:
1 | 定义dp[i]:=以ai为末尾的最长上升子序列的长度 |
以ai结尾的上升子序列是:
1 | 只包含ai的子序列 |
这二者之一。这样就能得到如下的递推关系:
1 | dp[i]=max{1, dp[j]+1|j<i且aj<ai} |
使用这个递推公式可以在O(n²)时间内解决这个问题。
代码如下:
1 | // 输入 |
这个方法比较简单,但是时间复杂度也比较高。下面我们来看看更优的解法。
之前我们的思路是求出以第i个元素为结尾的最长上升子序列长度,我们可以换个思路,考虑一下dp[i]
为最长上升子序列长度为i情况下最小的元素
,这样我们就可以通过二分来进行优化,代码如下:
1 | int dp[MAX_N]; |
实现 pow(x,n)
不用担心精度,当答案和标准输出差绝对值小于1e-3
时都算正确
1 | Pow(2.1, 3) = 9.261 |
从数学上来说,(x)的4次方
等于 (x的平方)的平方
。我们使用这个思想来做这道题就行了。
其实就和把十进制数转成二进制的思想是一样的。
需要注意的地方是,n可能为负数。
1 | class Solution: |
给一个目标数 target, 一个非负整数 k
, 一个按照升序排列的数组 A。在A中找与target最接近的k个整数。返回这k个数并按照与target的接近程度从小到大排序,如果接近程度相当,那么小的数排在前面。
The value k is a non-negative integer and will always be smaller than the length of the sorted array.
Length of the given array is positive and will not exceed 10^4
Absolute value of elements in the array and x will not exceed 10^4
如果 A = [1, 2, 3]
, target = 2
and k = 3
, 那么返回 [2, 1, 3]
.
如果 A = [1, 4, 6, 8]
, target = 3
and k = 3
, 那么返回 [4, 1, 6]
.
这道题的一般解法都很容易想出来,暴力出奇迹嘛,这里我们只说最优解,也就是 O(logn + k) 的时间复杂度 的解法。
这道题的解题关键是数组A是有序的,只要有序就可以考虑用二分。
我们通过对A进行二分,找到最接近target的数,找到之后用双指针的思想,依次从找到的那个数向两边扩散,直到满足k个数为止。
思路较为简单,编码过程注意一些边界条件的判断即可。
1 | class Solution: |
首先,我们需要了解一下什么是Service Mesh。今天我们的主角是Istio,Istio的背景我不过多介绍,G家等大厂搞出来并且在后面推动支持的肯定不会弱。
根据Istio的官方文档,是这么定义自己的:一个用来连接、管理和加密微服务(流量)的开放平台。
an open platform to connect, manage, and secure microservices
Istio可以让你在不修改微服务源代码的情况之下,很轻松地给微服务加上诸如负载均衡、身份验证、监控等等的功能。Istio通过在你的微服务中部署一个sidecar作为所有流量的代理来达成这个目标。
总结下来,Istio提供了以下功能:
除了这些之外,Istio还支持很多不同的平台(尤其是Kubernetes),并且支持自定义的组件和集成。
通过这些功能,微服务的开发和迁移会变得非常容易,而运维人员也可以更方便的更改部署的策略。
Istio是两层架构的,分别是数据层和控制层:
总体的架构如下图:
Istio用了一个扩展版本的Envoy作为底层的代理。Envoy是一个用C++开发的高性能的代理,具有非常多功能,具体的可以参考官方文档,在此不做赘述。
Envoy在Istio中是以sidecar模式部署在pod里面的,Istio通过控制Envoy来控制所有的流量,获取监控数据等。
Mixer是一个平台无关的组件,用来控制访问策略和使用策略,同时会收集监控信息,将收集到的信息传给用户可以自定义的后端进行处理。
Pilot为Envoy提供服务发现、智能路由(如AB测试、金丝雀部署)和弹性流量管理功能(如超时、重试、熔断)。它负责将高层的抽象的路由规则转化成低级的envoy的配置。
Citadel提供了服务间和服务到终端用户的认证,同时可以直接将http流量升级成https流量。具体的可以查看官方文档。
在这里我打算使用helm进行安装。
首先,你得有一个可运行的k8s集群,我是在gke上开了一个三节点的集群作为测试使用。
其次,你得需要有helm的客户端。mac用户可以通过brew来安装。
Istio提供了一个很方便的脚本来下载并解压最新版的Istio,如下:
1 | curl -L https://git.io/getLatestIstio | sh - |
等下载完之后,我们可以进入文件夹,并把bin目录加到path里面:
1 | cd istio-0.8.0 |
要使用helm来安装istio,首先需要在集群里面配置好helm和tiller,如下:
1 | kubectl create -f install/kubernetes/helm/helm-service-account.yaml |
等helm和tiller配置完之后,就可以使用helm来一键安装Istio了:
1 | helm install install/kubernetes/helm/istio --name istio --namespace istio-system |
这样,Istio就安装好了。
为了验证安装是否成功,我们可以看一下是否部署了以下的service:
1 | kubectl get svc -n istio-system |
并且确认以下的Pod是否在running状态:
1 | kubectl get pods -n istio-system |
当然,我们也可以自定义一些参数,具体的请看[官方文档]($ helm install install/kubernetes/helm/istio –name istio –namespace istio-system)。
让我们部署我们的一个样例应用来看看Istio到底干了啥。
我们的样例应用叫做BookInfo,这个应用由四个微服务所组成,具体架构图如下:
这个应用是用不同的语言所写的,让我们来见识一下Istio的魔力吧。
安装这个应用非常简单,我们只要执行以下命令即可:
1 | kubectl apply -f samples/bookinfo/kube/bookinfo.yaml |
我们可以注意一下,在bookinfo.yaml
中的manifest如下:
1 | # Copyright 2017 Istio Authors |
但是我们真正部署出来后,变成了这样:
1 | apiVersion: v1 |
可以看到,本来只有一个container的,现在里面多了一个container和initContainer。这个就是Istio的Auto Injection,可以自动把sidecar注入到Pod里面,让我们不需要手动一个一个修改yaml文件,也防止手动修改过程中出错的可能。
这里我们以路由设置为例子。
首先我们打开刚才部署好的这个应用的网页,可以看到页面右方的Book Reviews部分里面每次刷新都会随机性地出现黑星星、红星星和没有星星三种情况,这是因为我们有三个不同的backend,路由在默认情况下会随机路由到任意一个backend上。
我们先尝试把所有的路由都路由到v1版本上(就是没有星星的版本),路由规则如下:
1 | apiVersion: networking.istio.io/v1alpha3 |
命令如下:
1 | istioctl create -f samples/bookinfo/routing/route-rule-all-v1.yaml |
然后我们再去刷新,就会发现不管怎么刷新星星都不见了。
接着,假如我们有一个用户是jason,我们希望他能测试v2的backend,就可以用下面的路由规则:
1 | kind: VirtualService |
命令如下:
1 | istioctl replace -f samples/bookinfo/routing/route-rule-reviews-test-v2.yaml |
这时候,我们打开网页,以jason这个用户登录(密码随便填),就会发现每一次访问到的都是带有黑星星的版本。
这就是Istio提供的路由功能。
本文中我们简单讲了Service Mesh的概念,如何创建Istio以及简单的使用过程,如果大家有兴趣探索Istio更多的功能,可以直接访问Istio的官网。
]]>程序员是手艺人,是靠手艺靠技术吃饭的人,那么怎么样能通过自己的手艺自己的技术赚钱呢?简单来说,就是别人不行的,你可以,这才是核心。在别的文章中也看到过类似概念,大同小异,强调的都是不可替代性。
那么问题就变成了:如何让自己的“手艺”更值钱,更无法替代?
耗子哥总结下来,一共是有以下几点:
千里之行,积于跬步。不可能一蹴而就,肯定需要脚踏实地一点一点积累才可以,厚积而薄发。需要自己比别人更多地去学习新的技术和技能,有别人没有的经验和经历。
关注有价值的东西。这一段我认为我无法总结得更好,所以直接引用原文:
什么是有价值的东西?价值其实是受供需关系影响的,供大于求,就没什么价值,供不应求,就有价值。这意味着你不仅要看到市场,还要看到技术的趋势,能够分辨出什么是主流技术,什么是过渡式的技术。当你比别人有更好的嗅觉时,你就能起动得更快,也就比别人有先发优势。
- 关于市场需求。要看清市场,就需要看看各个公司在做什么,他们的难题是什么。简单来说,现在的每家公司无论大小都缺人。是真的缺人吗?中国是人口大国,不缺写代码搬砖的,真正缺的是有能力能够解决技术难题的人,能够提高团队人效的人。所以,从这些方面思考,你会知道哪些技能才是真正的“供不应求”,这样可以让你更有价值。
- 关于技术趋势。要看清技术趋势,你需要了解历史,就像一个球运动一样,你要知道这个球未来运动的地方,是需要观察球的已经完成运动的轨迹才知道的。因此,了解技术发展轨迹是一件很重要的事。要看一个新的技术是否顺应技术发展趋势,你需要将一些老技术的本质吃得很透。
因此,在学习技术的过程一定要多问自己两个问题:“1. 这个技术解决什么问题?为什么别的同类技术做不到?2. 为什么是这样解决的?有没有更好的方式?”另外,还有一个简单的判断方法,如果一个新的技术顺应技术发展趋势,那么在这个新的技术出现时,后面一定会有大型的商业公司支持,这类公司支持得越多,就说明你越需要关注。
找到能体现价值的地方。在一家高速发展的公司中,技术人员的价值可以最大化。这就要求自己一定要能找到一个高速发展的公司以及一个高速发展的领域。
动手能力很重要。简单来说,就是要会写代码!细节是魔鬼!而不是做一个ppt架构师!
关注技术付费点。技术付费点在耗子哥的总结里面有两个地方:一个是能帮别人挣钱的地方,一个是能帮别人省钱的地方。这也是所有技术的核心竞争力。
提升自己的能力和经历。别人要付费给你,前提是信任你,所以你需要提升自己的能力和经历才可以使得别人愿意信任你付费给你。
找到有价值的信息源。这个是程序猿基本功了,不再赘述。
输出观点和价值观。同样的,需要积跬步,厚积而薄发。
朋友圈很重要。你和谁在一起,就会成为什么样的人。物以类聚,人以群分。
最后套用耗子哥的结束语吧,我也认为无法提炼地更好了:
总之,就一句话,会挣钱的人一定是会投资的人。我一直认为,最宝贵的财富并不是钱,而是你的时间,时间比钱更宝贵,因为钱你不用还在那里,而时间你不用就浪费掉了。你把你的时间投资在哪些地方,就意味着你未来会走什么样的路。所以,利用好你的时间,投到一些有意义的地方吧。
最后附上耗子哥专栏海报:
]]>很久之前就看到大学室友有吃过若饭,当时还推荐给我不过我当时忙于写(撸)代(啊)码(撸)并没有理,昨天在网上又碰巧看到了若饭,于是抱着吃螃蟹的心态打算买来尝一尝。
若饭是一种代餐食品(饮料?),是为了那些忙的没时间吃饭的人所设计的,据说创始人原来也是程序猿,忙的没法吃饭,于是自己瞎鼓捣出了一些用来解决吃饭问题的混合物,自己吃下来觉得不错,于是想以此创业。
若饭目前有三个产品线,分别是:
昨天我买了粉末版和液体版的,付完钱已经4点了,联系客服说3点30快递走得第二天发货,但是我就说我之后不方便收快递,于是客服马上联系了仓库,仓库说已经下班了,客服说给我叫个顺丰发货,在这里赞一个 饭桶@若饭 。
发了顺丰今天早上8点多就送到了,速度飞快,一共就花了大概十几个小时从湖州发货到上海。
本来以为再怎么也得等下班回家才能拿到,没想到一早上刚准备出门上班就遇上了来送若饭的快递小哥。
若饭包装如下:
粉末版的包装如下:
买了粉末版,套餐里面还送了一个搅拌杯和量勺:
对于粉末版而言,需要自己冲泡。由于时间关系,今天打算使用的是V3.1液体版:
总算等到中午了,可以开始吃若饭了。今天带了一瓶液体版的来公司当做午餐。液体版的瓶口有密封措施,不过有缺口,很容易打开,设计的不错。
摇晃均匀后,打开瓶盖,颜色是咖啡色的。尝了第一口,感觉里面有点粉末状的东西没有溶解在液体中,应该是各种蛋白粉之类的物质,口感由于有粉末状的物质在里面混着,所以多多少少受了一点影响,不是我预计中的丝滑或者类似饮料的口感。味道比较淡,带有一点点的咖啡的味道,还有豆奶的味道混杂在一起,其中豆制品的味道较为明显,应该和其中有豆类成分是相关的。
虽然前几口喝下去并没有什么惊艳的感觉,味道也不是那种特别出众的味道,但是还是挺经喝的,因为在喝完几口之后品味了一下,有种欲望想要接着去再喝几口。仔细思考下来,应该是口味特意做成这样的,不容易让人腻。
人体每天需要多少营养,若饭配比如何在这里不论,官网和包装上都有很多数据,而且在没有各种仪器测量的情况之下无法得出结论;但是若饭确实能给人带来饱腹感——虽然不知道原理是什么——在喝了半瓶之后就已经觉得自己饱了,这样看来可能确实一瓶的量是能给人带来3-4小时饱腹感的,并没有夸大其词。
若饭作为新型代餐类食品(饮料),抓住了人们吃饭的这个痛点问题,并且提出了一种解决方案,从方便和健康的角度尝试解决这个问题,主要的好处还是食用方便而且营养科学合理,能够节省下来吃饭的时间,比如像我现在可以写一段代码,在思考的时候喝几口若饭就能解决午餐,同时也不用去考虑吃的是否健康。如果是对口味口感上要求很高的话,也许若饭并不能在口味口感上做得很好。
接着,聊一下价格相关的事情,除了若饭的V2.x的袋装粉末版(需要自己冲泡及清洗杯子)价格还属于尚可之外,别的产品(比如瓶装粉末版,喝完直接扔瓶子)的价格对我这种工薪阶层的人来说还是偏贵一些,不过这也正常,毕竟现在越来越多的消费是拿去买了方便的体验,而并非仅仅是物质上的价值。若饭的本质其实就是医院肠胃科的肠内营养素,只不过医院不会把这种东西做得这么方便人们食用,也不会卖给健康人,而若饭做到了将科学合理配比的饮食做得非常方便去食用,从这个角度来说我认为若饭其实属于附加价值高的商品,而不是类似于大多数的别的零食饮料,是以成本为主的。
总而言之,若饭可以使得吃饭变得简单、健康、快速,但是吃久了可能会觉得口腹之欲没能满足,还是会想要吃一些好吃的口味重的东西。若饭可以在工作特别忙的时候用来应急当做快速午餐,平时不忙的时候去吃普通的饭菜,这样交替的去食用可能是更好的方案。
]]>给定一个字符串,判断其是否为一个回文串。只包含字母和数字,忽略大小写。
你是否考虑过,字符串有可能是空字符串?这是面试过程中,面试官常常会问的问题。
在这个题目中,我们将空字符串判定为有效回文。
"A man, a plan, a canal: Panama"
是一个回文。
"race a car"
不是一个回文。
这道题的思路很简单,先把给定字符串预处理一下,先只选择其中的字母和数据,再全部变成小写(大写),然后根据回文串的性质左右两边进行比较即可。
坑点在于题意中的注意事项说的,如果是空串的情况。
1 | class Solution: |
给出一个包含大小写字母的字符串。求出由这些字母构成的最长的回文串的长度是多少。
数据是大小写敏感的,也就是说,"Aa"
并不会被认为是一个回文串。
假设字符串的长度不会超过 1010
。
给出 s = "abccccdd"
返回 7
一种可以构建出来的最长回文串方案是 "dccaccd"
。
看到题目,第一个关键是看到,这道题只要求长度即可,不需要求出具体的回文串,所以会方便很多。
既然只要求出长度,那么一定是有一些简单的方法算出来不用求出具体的回文串到底如何的。
那我们就思考,要组成回文串需要什么样的条件呢?
这里的关键是第二点,两个相同的字母一定能组成回文串,所以我们就先考虑一下,如果一个字母在给定字符串中出现了偶数次数,那么一定能组成回文串。
那如果一个字母出现了奇数次呢?
思考一下就能想到,奇数次的出现次数,等于偶数次+1。
根据上面的第一和第三点,如果说有出现奇数次的字母,那么这些字母中可以选择一个放在回文串中间,这样长度可以+1。
最后剩下的就是一些边界情况处理了,比如,如果所有的字母都出现偶数次。
1 | class Solution: |
Helm is the best way to find, share, and use software built for Kubernetes.
可以看出helm在kubernetes社区中的定位。这篇文章并不是helm的入门文章,而是着重于如何在本地开发chart。希望进行helm入门的同学可以参考官方文档。
本文会分为两个部分来探讨如何在本地开发chart,分别是:
根据定义,一个Chart是一些有相关性的Kubernetes资源的集合。一个chart可以是一个简单的应用,比如memcached,或者是一个复杂的集合,比如一个full-stack的web的应用,含有server,ui,database,cache等等。
Chart从本质上只不过是一些文件,不过这些文件需要满足一定的规范,比如目录的规范和文件名的规范。
根据规定,符合如下目录结构的目录就是一个Chart,目录名即为Chart名(不包含版本信息):
1 | wordpress/ |
虽然这里看到charts和templates文件夹都是optional的,但是至少需要有一个存在,chart才是合法的。
每个Chart都必须有一个Chart.yaml
文件,这个文件的内容如下:
1 | name: The name of the chart (required) |
每个Chart都必须有一个版本号,版本号必须遵守语义化版本规范V2。每个package(Chart打包后的东西)同时由name和version来唯一确定。
比如,一个叫做nginx
的版本为1.2.3
的Chart,打包后就是nginx-1.2.3.tgz
。
更复杂的语义化版本号是被支持的,比如version: 1.2.3-alpha.1+ef365
但是非语义化的版本是不被允许的。
Helm和Tiller都会使用Chart的名称+版本来唯一标识一个package,所以Chart.yaml里面的版本一定要对应package的文件名。
appVersion其实并没啥用,只是指定了Chart包含的应用的版本,对helm和tiller来说并不会有啥影响,也不需要和Chart的version一致。自己随便写都可以……
可以通过在Chart.yaml
里面把deprecated
设为true
来标识一个Chart已经是deprecated状态。
一个Chart还可以有License来标识License信息,README.md来包含一些介绍信息,以及一个templates/NOTES.txt文件来指导如何去安装或者使用。
templates/NOTES.txt文件会被当做普通的template来对待(意味着其中可以有变量),并且会在每次helm status
之后和helm install
之后被打印到STDOUT。
比如stable/mysql
的NOTES.txt如下:
1 | MySQL can be accessed via port 3306 on the following DNS name from within your cluster: |
可以看出来,NOTES.txt是用来给用户作使用上的指导的。
我们都知道,软件开发过程中,复用是一个很重要的概念,同样的,Chart也可以依赖于其它的Chart,可以复用其它的Chart的内容。
Helm提供了两种对Chart复用的方法,第一种是在requirements.yaml
中指定依赖的Chart,如下:
1 | dependencies: |
如果说需要对一个chart复用多次,可以这么干:
1 | # parentchart/requirements.yaml |
除此之外,Helm还可以选择性的去使用依赖的chart,具体可以参考tags and condition。
第二种是直接把需要用的Chart放到charts
文件夹下。一般情况下推荐使用第一种,第二种是在需要对依赖的chart做魔改的情况下用到的。
Helm还提供了helm dep
这个命令来方便对依赖的管理,之后会介绍到。
在helm install
和helm upgrade
的时候,helm会把依赖和当前chart打包成一个集合一起送给tiller,然后(目前是)按照类型+字母顺序来apply,并不是先去install依赖再去install当前的chart。
例如,我们有一个chart,会有以下三个东西:
这个chart依赖于另一个chart,有如下三个东西:
那么在安装或者升级的过程中,顺序如下:
Helm的客户端提供了一些和本地开发相关的命令,这里简单介绍一下。
顾名思义,提供了命令补全,使用方式也比较简单:
1 | source <(helm completion zsh) |
可以通过这个命令直接创建出一个符合Chart规范的目录出来,比如:
1 | helm create myweb |
顾名思义,是用来进行依赖管理的,可以被简写为helm dep
,具体使用如下:
1 | helm dep |
一看这就是个下载别的chart的命令,为啥我要说和本地开发有关系呢?
因为我认为,helm的官方repo里面的chart最大的作用就是作为一个best practice来展示给使用者一个示例。
所以,当不知道该怎么写的时候,去抄吧😁。
顾名思义,用来检查一个Chart是否存在问题。
如果说有错误,会报出error,并返回非零值。
我们就用刚才的myweb来试手:
1 | helm lint myweb |
这个命令是当一个chart写完后用来把一个chart打包成chartName-version.tgz
的。一般只有在发布的时候使用,提供了比较多的功能,比如sign之类的,如下:
1 | helm package --help |
我们还是用刚才的myweb作为例子:
1 | helm package myweb |
这个命令是用来在本地开启一个repo server的,可以用来本地测试使用。
这个命令可以在本地渲染出template来检查是否正确,具体使用如下:
1 | helm template --help |
我们仍然以myweb作为例子:
1 | helm template myweb |
这个命令是用来验证一个给定的chart是否被sign。在对安全性要求高的环境下有用。
最后是这个helm plugin
,看到这个我们就能感觉到,helm瞬间有了无数的扩展性,需要什么功能如果helm不提供咱们就自己干一个加上去。
helm目前现在已经有了一些比较好的plugin,比如有一个plugin支持用template render出来之后再进行验证查错之类的。
如果有一些别的定制化的需求也可以通过自己写个plugin来完成。
]]>Helm is the best way to find, share, and use software built for Kubernetes.
可以看出helm在kubernetes社区中的定位。这篇文章并不是helm的入门文章,而是着重于helm中的chart之间如何传递value。希望进行helm入门的同学可以参考官方文档。
在helm的使用过程中,经常会出现两种需求:
helm对于这两种场景提供了比较完备的支持,下面我们来具体讲一下解决方案。
helm提供了两种方法来应对这种情况:
如果说一个child的chart在values的root下有一个叫做export
的key,那么它的parent chart就可以直接在requirements里面通过指定需要import的key来将值import到自身的values里面,例子如下:
1 | # parent's requirements.yaml file |
1 | # child's values.yaml file |
helm会发现,我们指定了要import data
这个key,所以就去child的values.yaml里面寻找,发现了这个key有被export,于是就import了它的内容。
这时候的parent的values如下:
1 | # parent's values file |
需要注意,在parent的values中data
这个key不会被import进来,只会import data
的内容。如果希望把这个key也一起import进来,可以使用下面说的方法。
如果我们想要获得一些不在exports这个key下面的值,我们就必须指定在child中要import的路径,以及在parent中的对应路径,如下:
1 | # parent's requirements.yaml file |
根据如上的这个requirements文件,helm将会在child的chart中寻找default.data的值,并导入到parent中的myimports这个路径下。
假设parent和child初始的values如下:
1 | # parent's values.yaml file |
1 | # subchart1's values.yaml file |
那么导入之后,真正渲染出来的parent的values的值为:
1 | # parent's final values |
可以看出来,parent中的values把myint和mybool从subchart1里面import了进来。
想要再父chart中修改子chart的值比较容易,假设子chart的名字是mychartabc
,那么我们可以很简单地在父chart的values中通过以下方式进行修改:
1 | # in parent's values.yaml |
这样就可以修改子chart的值了。
https://docs.helm.sh/developing_charts/#importing-child-values-via-requirements-yaml
https://docs.helm.sh/chart_template_guide/#overriding-values-from-a-parent-chart
]]>Kubernetes 假设 Pod 之间可以互相通信,无论它们在哪个主机上。我们给每个Pod一个单独的IP地址,那么我们就不用专门在Pod之间创建链接,或者映射container的port到主机的port来使得外部可以访问到container了。这使得我们创建了一个非常干净,向后兼容的模型,在这个模型里面Pod可以就被当做为一个VM或者甚至一个物理机,这给了我们很多方面的方便,比如port的分配,命名,服务注册、发现,负载均衡,应用程序设置和迁移等。
为了达成这个目标,我们必须规定如何设置集群的网络。
在讨论Kubernetes处理网络的方法之前,我们需要先复习一下Docker是如何处理网络的。在默认情况下,Docker用的是主机私有的网络,默认情况下会创建一个叫做docker0的虚拟网桥,并且分配一段子网给它。对于每个Docker创建的container,都会分配一个虚拟的附加于这个网桥的网络设备(被称为veth
),这个veth
其实是通过linux的namespace来映射到container里面的eth0
的。这个容器里的eth0
会被分配一个虚拟网桥的网段里面的IP地址。
结果就是,Docker的容器只能和在同一个机器(也就是在同一个网桥)里面的容器交流,不能和外部别的机器上的容器之间建立连接。事实上,不同机器上的容器,可能会有同样的网段和IP地址。
如果说要让Docker容器能跨Node交流,那么必须给他们分配主机上的port,并通过这个port和主机IP来唯一确定一个容器的地址,然后主机会把请求转发给container。这显然会带来很多的问题。
在大量的开发者之间协调port的使用很明显是非常难以扩展和管理的。动态分配port又会给系统带来很大的复杂性——每一个应用程序都必须把port作为一个flag,API Server必须知道如何去把动态的port插入到配置块里面,Service必须知道如何去找到彼此,等等。与其解决这么多的问题,不如咱们自己干,重头设计。
Kubernetes规定了如下的网络实现规范(除非有意不这么做):
这些要求其实就是说,你不能直接在两台机器上装上Docker,然后指望Kubernetes会工作,你必须保证这些基础要求被满足。
这个模型不止简单了很多,而且还吻合了Kubernetes对于把app从vm迁移到container的方便性要求。意思是,如果你之前的app是运行在vm里面的,那么vm和vm之间能通过IP地址互相通信是一个基本的要求。反之,放到container里面也是这样。
不过事实上,Kubernetes中并不是每一个container都会有自己的IP地址,其实Kubernetes是以Pod作为最小的分配IP地址的单位的——Pod中的container会共享同一个IP地址——也就是共享同一个network namespace。这使得所有的同一个Pod里面的container都能通过localhost直接访问到彼此。不过这个带来的问题是每个Pod里面的container需要协调好port的使用,防止冲突,但是这个和在VM里面是相同的,所以并不是什么太大的问题。我们称之为“IP-per-pod”模型。
在Docker里面,请求一个host port是可行的,但是这个模型使得操作更加简单。我们会在每个host Node上分配一个port,并把所有的traffic都转发给Pod。Pod本身并不需要知道这些,只当自己是一个vm或者甚至物理机就好了。
目前有很多方法能实现这个网络模型,比如说如下的这些方案:
Cilium是一个开源的网络模型,实现了L3-L7层的安全策略,具体的可以看一下文档。
Contiv提供了可设置的网络模型。
Flannel是一个非常简单的网络层,不过很多人都说好用。
实现的方案非常多,我就不一一列举了,大家可以直接去参考官方文档中的内容。
网络是个很复杂的东西,很多时候问题都会出在网络上,不同的业务模型需要使用不同的网络插件,没有万金油的解决方案。
]]>万能的程序猿总是有解决方案,ingress应运而生。
通过使用Service,路由的规则是直接附属到一个特定的Service上,并且生命周期和Service一样。如果说,我们能把路由规则和应用解耦,那么我们就可以随意的去更新应用而不影响访问,或者随意的去更改路由规则了。Ingress正是做这个的。
根据Kubernetes官方文档:
An Ingress is a collection of rules that allow inbound connections to reach the cluster Services.
Ingress实际上做了一个Layer 7的HTTP load balancer,并且提供了以下功能:
通过Ingress,用户不需要直接连接到Service,用户可以直接访问到ingress的endpoint,然后通过Ingress再转发到Service。样例Ingress配置如下:
1 | apiVersion: extensions/v1beta1 |
根据这个配置,用户访问blue.myweb.com和green.myweb.com将会访问到同一个ingress的endpoint,并且再被转发到blue-service和green-service中。这个就是之前说的Name-based virtual hosting
。
我们也可以用Fan Out Ingress rules,比如我们访问myweb.com/blue和myweb.com/green,然后这些也会被转发到blue-service和green-service:
Ingress这个Resource其实并不做转发,而是由Ingress Controller来做的。
Ingress Controller其实就是一个监听master node上API Server对Ingress Resource的改变然后改变这个Layer 7 Load Balancer的Controller。Kubernetes有好多种不同的Ingress Controllers,比如说GCE L7 Load Balancer和Nginx Ingress Controller。当然,如果我们需要的话也可以写一个自己的。
需要保证Ingress Controller被启用,Ingress才可以使用。
我们可以通过kubectl create
来创建一个ingress资源,比如假设我们有一个叫做myweb-ingress.yaml的文件,内容如下:
1 | apiVersion: extensions/v1beta1 |
我们可以通过:
1 | kubectl create -f myweb-ingress.yaml |
来创建这个ingress的资源。然后只要修改我们的域名dns,指向ingress的endpoint即可(在本机上可以通过修改/etc/hosts来达成目的)。
]]>我们在kubernetes上部署应用的时候,经常会需要传一些配置给我们的应用,比如数据库地址啊,用户名密码啊之类的。我们要做到这个,有好多种方案,比如:
当然还有别的方案,但是各种方案都有各自的问题。
而且,还有一个问题就是,如果说我的一个配置,是要多个应用一起使用的,以上除了第三种方案,都没办法进行配置的共享,就是说我如果要改配置的话,那得一个一个手动改。假如我们有100个应用,就得改100份配置,以此类推……
kubernetes对这个问题提供了一个很好的解决方案,就是用ConfigMap和Secret。
ConfigMap让我们能够从容器镜像中把配置的详细信息给解耦出来。通过ConfigMap我们能够把配置以key-value对的形式传递到container或者别的系统组件(比如Controller)里面。我们可以通过两种方式来创建ConfigMap:
我们可以用kubectl create
来创建一个ConfigMap,然后通过kubectl get
来获取:
1 | Create the ConfigMap |
-o yaml
的作用是通过yaml的形式来返回我们所要求的配置信息。
除了上面的方式,我们还可以直接通过配置文件来创建(好吧,虽然我感觉是同一种,只不过是放到文件里面了而已……),首先,我们得有一个配置文件,假设名字叫做myconfigmap.yaml
:
1 | apiVersion: v1 |
然后,我们可以通过kubectl create -f
来创建:
1 | kubectl create -f myconfigmap.yaml |
我们可以有两种方法来使用ConfigMap:
我们可以设置env从ConfigMap读取:
1 | .... |
这样,我们的container就可以读取到ConfigMap里面存储的信息了。
不过一般情况下,我个人推荐使用另一种方式:
这种方式我比较推荐,因为随着ConfigMap被修改(比如你想要更新一些设置),container里面对应的文件内容也会被修改,这样可以不用重启Container就让应用能够得到最新的配置信息。
这个内容需要一些Volume相关的知识,在此不做更多讲解,大家可以去参考官方文档。
通过上面的部分,我们可以看到ConfigMap是用来做一些配置信息的,那么如果我们有一些机密信息比如说密钥、密码之类的信息,应该存在哪里呢?看到这个名字大家应该就明白了吧,kubernetes提供了Secret来存储相关的信息。
具体为什么要存在Secret里面,Secret和ConfigMap有什么区别,后面会讲到。
我们可以通过kubectl create secret
来通过一个文件创建一个secret,如下:
1 | Create a file with password |
我们也可以手动创建一个Secret,不过要注意,所有的secret的data都要以base64进行加密:
1 | cat password.txt | base64 |
我们可以通过get
和describe
来获取Secret,不过我们发现,kubectl
并没有向我们返回Secret具体的内容:
1 | kubectl get secret my-password |
和ConfigMap一样,我们可以通过设置成env或者挂载成volume来使容器可以使用我们的secret。
具体格式如下:
1 | ..... |
关于如何在Volume中使用的还是需要自行查询文档学习。
好了,总算正文部分完了,可以讲讲Secret和ConfigMap的关系了,以及讲讲Secret到底有多扯淡……
其实目前Secret的实现,就是ConfigMap把value用base64 encode了一下……
所以,其实不存在任何安全性……
只要decode一下就能出现原来结果,相当于明文存储……
base64这玩意儿都不能叫做加密,只能叫做编码……
所以我们都不说encrypt,而是encode和decode……
当然,k8s社区有在计划对Secret进行下一步的安全性增强,当然这是后话了……
反正目前为止,Secret基本和ConfigMap一样是明文存储……
知道有多扯淡了吧……
]]>为了解决上面这个问题,kubernetes提供了Volume。一个Volume其实就是由一个存储中间件锁支持的一个directory,具体是什么存储中间件是由Volume的类型确定的。
如上图,在k8s里面,一个Volume会attach到一个Pod上,我们之前也有说过在Pod里面网络和存储是共享的,所以这个Volume可以被Pod中所有的container所共享。一个Volume和Pod的生命周期是一样的,不过却比containers要更长,这样可以使得数据可以在容器之间共享。
一个mount到Pod里面的directory是由底层的Volume Type支持的,Volume Type决定了这个directory的属性,比如大小,内容等等。下面列举一部分的Volume Type:
顾名思义,这就是一个“空的”Volume。这个空的Volume会在Pod被调度到node上的时候被创建。这种类型的Volume的生命周期和Pod一样,如果Pod挂了,那么这种Volume里面的所有数据也就没了。
同样顾名思义,这就是把主机上的某个path映射到pod里面,如果Pod挂了,数据还在host上,不过如果host挂了,数据也就没了。
顾名思义,强耦合gce,不多说了。
同上
通过nfs,我们可以mount一个nfs share到pod里。
同上
我们可以用这个type来把我们放在secret里面的那些比如密码呀token呀之类的信息挂载到pod上,让应用可以使用。
这个是最重要的一种,也是最常用的一种,我们可以把一个Persistent Volume(PV)
挂载到Pod里面,通过persistentVolumeClaim(PVC)
。
在传统的IT环境中,一般存储是由系统管理员来管理的,终端用户只是获得如何去使用的指导,但是不用管底层到底存储是怎么管理的。
在容器世界里面,也是一样的。Kubernetes有一个叫做Persistent Volumes的子系统,管理员通过Persistent Volume API向其中添加和管理Persistent Volume,然后用户使用Persistent Volume Claim API来使用。
一个PV就是一个通过网络挂载到集群上的存储。
PV可以通过StorageClass这个resource被静态地创建,也可以动态地被添加。一个StorageClass包含了预定义好的创建PV的初始化器和参数。
一些支持使用PV进行管理的Volume Types是:
一个Persistent Volume Claim(PVC)就是一个用户想要使用storage的请求。用户通过指定比如大小、访问权限等来申请PV资源,当有一个合适的资源(PV)被找到的时候,就会和PVC绑定在一起:
当bind成功之后,这个PVC就可以在Pod里面使用了:
当一个用户结束使用之后,绑定的PV就可以被归还(release)了,就可以重新被申明(reclaimed)和使用了。
]]>kube-proxy
提供了对service的访问。如果我们要让一个用户能够使用应用程序,用户需要能访问到pod,但是pod是一个短暂存在的东西,很可能突然挂了然后重启,这时候ip地址就会改变,所以pod的ip地址并不是静态的。比如说:
用户在这张图里面通过ip地址访问到了4个pod,突然其中有一个pod挂了,然后controller又起了一个pod:
这时候用户就访问不到了,因为用户不知道新的ip地址是多少。
kubernetes为了解决这个问题,提供了一个高层的抽象,叫做Service。Service从逻辑上把pod进行分组,并且设置访问的策略。一般我们是通过label和selector来达到分组的目的的。
比如,我们用app作为key,db和frontend作为value来区分pod:
通过selector(app=frontend和app=db),我们就可以把这些pod分为两个逻辑组了。
这个时候,我们再给这两个逻辑组加上一个名称,比如frontend-svc
和db-svc
,就是service了:
一个service对象模型大致如下:
1 | kind: Service |
在这个对象模型中,我们创建了一个叫做frontend-svc
的Service,这个service选择了所有的app=frontend
的pod。在默认情况下,每个service都会有一个cluster内部可以访问到的ip地址,也被称为ClusterIP
:
用户现在可以通过service的ip地址来访问到pod了,service会负责做负载均衡。
当转发请求的时候,我们可以选择pod上的目标端口,比如在我们的例子里面,frontend-svc通过80端口来接受用户的请求,然后转发到pod的5000端口。如果目标端口没有被显式声明,那么会默认转发到service接受请求的端口(和service端口一样)。
一个pod、ip地址和目标端口的元组代表了一个service的endpoint,比如在这个例子里面,frontend-svc有3个endpoints,分别是10.0.1.3:5000
, 10.0.1.4:5000
和10.0.1.5:5000
。
所有的worker node都有一个后台任务,叫做kube-proxy
。这个kube-proxy会检测API Server上对于service和endpoint的新增或者移除。对于每个新的service,在每个node上,kube-proxy都会设置相应的iptables的规则来记录应该转发的地址。当一个service被删除的时候,kube-proxy会在所有的pod上移除这些iptables的规则。
我们已经知道,Service是和kubernetes进行沟通的主要方式,那么我们就需要有一个办法来在运行的时候能够对已有的服务进行发现。Kubernetes提供了两种方法:
每个pod在worker node上启动的时候,kubelet都会通过环境变量把所有目前可用的service的信息传进去。举个例子,我们有一个叫做redis-master
的service,这个service expose了6379的端口,并且ClusterIP是172.17.0.6,那么在一个新创建的pod上,我们可以看到以下环境变量:
1 | REDIS_MASTER_SERVICE_HOST=172.17.0.6 |
如果使用这个解决方案,我们必须非常小心启动服务的顺序,因为pod不会获得自己启动之后的service的env。
kubernetes有一些dns的addon,这些addon会自动为所有service创建一个类似my-svc.my-namespace.svc.cluster.local
的dns解析,并且在同一个namespace里面的service可以直接用service name进行访问。这是最为推荐的方法。
当我们定义一个service的时候,我们可以选择可访问的范围,比如:
可访问的范围由service的类型决定,service的类型可以在创建service的时候声明。
ClusterIP是默认的service type,一个service通过ClusterIP来获取自己的Virtual IP,这个IP是用来和别的service通信的,只能在集群内部被访问。
NodePort的service type除了会创建一个ClusterIP之外,还会把所有worker node上的一个30000-32767之间的端口映射到这个service,比如假设32233
端口映射到了frontend-svc
,那么不管我们连接到哪个worker node,我们都会被转发到service分配的ClusterIP——172.17.0.4。
默认情况下,当expose到有一个nodeport的时候,kubernetes master会自动随机选择一个30000-32767之间的port,当然,我们自己也可以手动指定这个port。
NodePort的这个service type在我们想要让外网访问我们服务的时候非常有用,用户通过访问node上指定的port就可以访问到这个service。管理员可以在kubernetes集群外再搭一个反向代理就可以更方便地进行访问了。
对于LoadBalancer这个Servicetype:
LoadBalancer这个service type只有在底层的基础架构支持了自动创建load balancer的时候kubernetes才支持,比如Google Cloud Platform和aws。
如果一个service可以路由到一个或者多个worker node上,那么它可以被映射到一个ExternalIP地址。通过这个ExternalIP进入到集群的流量会被路由到其中一个endpoint上。
需要注意的是,ExternalIP并不是由k8s自动管理的,是由管理员手动设置路由到其中的一个node上的。
ExternalName是一个特定的service type,这种service type没有任何的selector也没有任何声明的endpoint。当在集群中访问到这个service的时候,会返回一个外部服务的CNAME。
这个service一般是用来让一个外部的服务在集群内部可以访问到的,比如我们有一个外部服务叫做my-database.example.com
,那么我们可以通过设置ExternalName类型的Service,让内部的其它service通过my-database
之类的名字访问到这个服务。
kubernetes有一个非常完善的对象模型,kubernetes集群可以通过这个对象模型来表现出不同的持久化的整体,比如:
对于每个对象,我们用spec
这个field声明我们期望的状态,随后kubernetes会通过status
这个field记录对象实际的状态并加以管理。随后,kubernetes的controller manager会想办法让这个对象实际的状态和我们声明期望的状态相同。
kubernetes中的例子比如:Pods,Deployments,ReplicaSets之类。
如果我们要创建一个对象,我们需要把spec
这个field提供给API Server,这个field会描述我们期望的状态以及一些基础的信息,比如名称。创建对象的API请求必须有spec
这个field以及其它详细信息,并且需要是JSON的格式。一般情况下,我们用yaml格式来提供一个对象的声明,kubectl会把这个声明转换成JSON格式,然后传给API Server。
下面是一个Deployment对象的例子:
1 | apiVersion: apps/v1beta1 |
插播一条广告:
Apps
The core workloads API, which is composed of the DaemonSet, Deployment, ReplicaSet, and StatefulSet kinds, has been promoted to GA stability in the apps/v1 group version. As such, the apps/v1beta2 group version is deprecated, and all new code should use the kinds in the apps/v1 group version.
接着说,apiVersion指定了我们调用的api的endpoint;通过kind field,我们指定了我们要创建的对象的类型;通过metadata,我们给对象附加上了最基本的信息,比如名字;你可以发现这里面有两个spec
的field(spec
和spec.template.spec
),通过 spec
,我们定义了我们对deployment的期望状态,在我们的例子中,我们想要确认,在任何时候,都有至少3个pod在运行。我们再在spec.template.spec
里面定义我们要运行的每个pod都应该是什么状态,所以这就是为啥这里会有两个spec的原因。
一旦这个对象被创建了,kubernetes会直接给对象添加一个status
的field,如下:
Pod是kubernetes中最简单也是最小的一个对象,是kubernetes部署的一个单元,代表了应用的一个单一实例。一个Pod是一个或者多个容器的逻辑上的集合,这些容器拥有以下的特性:
Pod并非一个持久化的东西,很有可能突然挂了,并且没有能力自我修复,这就是为啥我们把它们和controller一起用,这样可以来控制pod的replica,容错,自我修复等等。比较有名的例子比如Deployments,ReplicaSets等。我们通过把Pod的定义(specification,也就是spec
)附加到别的对象(也就是之前用的template.spec
)来完成。
Labels都是键值对,这些键值对可以被attach到kubernetes的对象上,比如Pod。Labels一般被用来组织和选择一些符合条件的对象。label不提供唯一性。
通过这个图片,我们可以看到我们用了两个label:app
和env
。基于我们的需求,我们可以给我们的pod不同的值。
通过Label Selectors,我们可以选择一系列的对象,Kubernetes支持两种Selector类型:
顾名思义,这种selector通过 ==
或者 !=
来进行选择,比如我们选择一个 env==dev
的对象,就会找出所有有env label,并且值为dev的。
这种selector支持通过一系列的值来进行过滤,比如通过in
, notin
和exist
。
举例:env in (dev, qa)
一个 ReplicationController(rc)是master node上Controller Manager的一部分,主要作用是保证每个pod的replica都达到了预期值。不然的话会通过杀死或者新建pod的办法来达到。不过现在已经被ReplicaSet(rs)取代了。
Replica Set是下一代的Replication Controller,好处在于同时支持equality 和 set based selector(rc只支持equality-based)。目前这是唯一的区别。
Rs可以单独使用,不过一般是配合deployment一起用。Deployment会自动创建rs来管理下面的pod。
deployment提供了对于pod和rs的陈述性更新。DeploymentController是master node上Controller Manager的一部分,作用和Controller manager别的一样——确保当前的状态和期望的状态相同。
在下面这个例子中,我们的deployment创建了一个 rs A,然后rs A又创建了3个pod,并且在每个pod中,都有一个跑了nginx:1.7.9镜像的容器。
接下来,在下一个deployment中,我们修改了pod的template,把nginx从1.7.9升级到了1.9.1。因为我们升级了期望的状态,所以deployment会创建一个新的rs B,这个过程被称为Deployment rollout:
当rs B创建完毕的时候,deployment开始指向它:
在rs之上,deployment提供了很多特性比如recording,通过这个特性,如果说更新出错,或者更新后的应用出了bug,我们可以rollback到原先的状态。
如果我们有无数个用户,我们想把这些用户组织到不同的team或者project,我们可以通过namespace把kubernetes集群分成好多个小集群。所有在namespace中创建的resources/objects都是唯一的,不会跨命名空间。
一般来说,k8s会有两个默认namespace:kube-system和default。kube-system一般会用来放一些kubernetes系统的组件,default会用来放一些属于其它namespace的对象。我们默认情况下是会连接到default命名空间。kube-public是一个特殊的namespace,可以被所有的用户读,一般用于特殊情况比如初始化一个集群。
我们可以通过使用资源配额(Resource Quotas)来限制每个命名空间的资源。
最后再插播一条广告:
]]>
Kubernetes可以通过不同的设置安装,比较普遍的四种安装方法如下:
在这种模式下,所有的master和worker组件都被安装在一个node上,这对学习、开发和测试非常有用,但是不应该被用在生产环境中。minikube就是一个例子。
在这种模式下,我们有一个单独的master node,在这个master node上同时也跑了一个单节点的etcd实例。多个worker node都连接到这一个master node。
在这种模式下,我们有多个Master node,master node将会在HA模式下工作,但是我们只有一个单节点的etcd实例。多个的worker node都会连接到多个master node上去。
在这种模式下,etcd被设置成了集群模式,并且在kubernetes集群之外。所有的Node都会连接到它上面去。所有的master node都被设置为HA模式,并且连接到所有的worker node上。Production都应该这么玩。
当我们决定了安装的类型,我们同时需要决定一下基础架构相关的决定,比如:
本地安装推荐使用 minikube。
kubernetes都支持安装在虚拟机或者裸机上,有很多工具比如ansible和kubeadm同时支持这两种安装。
这个就不用多说了,交保护费即可。
目前比较有名的有三个:kubeadm, kubespray, kops。
区别在于,kubeadm支持任何环境,kubespray是基于ansible的,kops目前和aws和gce强耦合。
]]>从高层看,kubernetes是由如下东西组成的:
etcd
Master node 是集群管理者,我们发出的所有请求都是到master node的api server上。
一个集群可以有多个master node做HA,当有多个master node的时候,只有一个会提供服务,剩下的都是follower。
集群的状态一般存储在etcd
里面,所有的master node都会连接到etcd。etcd是一个分布式k-v存储。etcd可以是master内部的,也可以是外部的。
master node一般都有如下组件:
所有的操作都是通过 API Server 去完成的。每个用户/操作者通过发送REST请求到api server,然后api server先验证然后执行这些操作。在执行完之后把集群的状态存到etcd里面。
顾名思义,Scheduler的作用是调度,Scheduler拥有所有worker node的资源使用情况,同时也知道用户设置的资源需求,比如说一个 disk=ssd
的label。在调度之前,scheduler还会考虑到service requirements,data locality,affinity,anti-affinity等。scheduler负责的是service和pod的调度。
简单来说,Controller Manager是负责启动和关闭pod的。Controller Manager的任务是让集群维持在期望的状态上。Controller Manager知道每个Pod的状态应该是什么样,然后会不断检测是否有不达标的pod。
Worker Node就是一个被master node控制的机器,Pod一般都是调度到worker node里面的。Worker node会有一些可以运行以及连接容器的工具。Pod是kubernetes里面的调度单元,是一个或多个容器组成的通常一起调度的逻辑上的集合。
一个worker node一般会有以下组件:
不用多说了,运行容器必备的,默认用的是Docker
kubelet是在每个worker node上都会运行的,用来和master node通信的。kubelet从master接收pod的定义,然后启动里面的容器,并监控容器是否一直正常运行。
kube-proxy简单来说,就是对外提供代理服务的。换句话说,没有kube-proxy,我们要访问其中的application,就得直接访问到worker node上,这显然是不合理的。我们可以通过kube-proxy来做load balancer等。以前版本的Service也借助了kube-proxy。
在kubernetes里面,都是用的etcd来管理所有的状态。除了集群的状态之外,还会用来存放一些信息,比如configmap,secret。
为了启动一个全功能的kubernetes集群,我们需要先确认以下信息:
这些问题都是需要在部署之前被解决的。
我们一个个看:
在kubernetes里面,每个Pod都要有一个独立的IP。一般容器网络有两种规格:
Kubernetes用CNI来给Pod分配IP
简单来说,容器运行时向CNI申请IP,然后CNI通过其下面指定的plugin来获取到IP,并且返回给容器运行时。
一般基于底层操作系统的帮助,所有的容器运行时都会给每个容器创建一个独立的隔离的网络整体。在Linux上,这个整体被称为Network Namespace,这些Network Namespace可以在容器之间共享。
在一个Pod里面,容器共享Network Namespace,所以所有在同一个Pod里面的容器可以通过localhost来互相访问。
在一个集群的环境下,每个Pod可以被调度到任何一个Node上,我们需要让在不同机器上的Pod也可以相互通信,并且任何Node都可以访问到任何Pod。Kubernetes设定了一个条件:不能有任何的NAT转换,我们可以通过以下方式来达成:
更多的信息可以看看kubernetes的官方文档。
我们可以通过kube-proxy
来暴露我们的service,然后就能从外面访问到我们集群里面的应用了。
奇怪
的东西好吧,原文意思是,大脑总是渴求一些新奇的东西,或者不寻常的事物发生,我们的大脑不会注意一些习以为常的东西,比如我们不会注意很平常的路人,但是会注意到很多“特立独行”的人。
比如说,当你拿到一本500页的教科书,书上密密麻麻都是文字,你的大脑肯定想着“**,又是这种玩意儿,无聊……”
但是如果当你拿到一本 日本H二次元漫画,还是 时崎狂三 或者 穹妹 的这个时候你的大脑就会……
或者再举个例子,如果你就普通的在路上走,你的大脑会努力的去排除那些不重要的东西,但是如果你走着走着,突然你面前蹦出一个大老虎,你的大脑肯定一下子就情绪爆发(原文)了。
Head first系列通过一些最新的认知科学、神经生物学和教育心理学来创作,有以下一些原则:
kubeadm
是一个kubernetes
官方提供的快速安装和初始化拥有最佳实践(best practice)的kubernetes
集群的工具,虽然目前还处于 beta 和 alpha 状态,还不能用在生产环境,但是我们可以通过学习这种部署方法来体会一些官方推荐的kubernetes最佳实践的设计和思想。
kubeadm
的目标是提供一个最小可用的可以通过Kubernetes一致性测试
的集群,所以并不会安装任何除此之外的非必须的addon。
kubeadm
默认情况下并不会安装一个网络解决方案,所以用kubeadm
安装完之后 需要自己来安装一个网络的插件。
kubeadm
支持多种系统,这里简单介绍一下需要的系统要求:
kubelet
会出错!具体的详细信息可以在官方网站上看到。
本篇内容基于aws的ap-northeast-1的ec2,CentOS 7
的操作系统(ami-4dd5522b),实例类型t2.medium 2核4GB,3台机器,1 master,2 nodes,kubernetes 1.9 版本。为了方便起见,在安全组里面打开了所有的端口和IP访问。
机器配置:
1 | [centos@ip-172-31-24-49 ~]$ uname -a |
首先 ,我们关闭selinux:
1 | sudo vim /etc/sysconfig/selinux |
把SELINUX改成disabled,然后保存退出。
在我用的ami中,swap是默认关闭的,所以不需要我手动关闭,大家需要确认 自己的环境中swap是否有关闭掉,否则会在之后的环节中出问题。
为了方便我们安装,我们将sshd设置为keepalive:
1 | sudo -i |
接下来我们重启一下机器:
1 | sudo sync |
至此,准备阶段结束。
首先,我们需要在所有机器上都安装docker
, kubeadm
, kubelet
和kubectl
。
切记:**kubeadm
不会自动去安装和管理 kubelet
和kubectl
,所以需要自己去确保安装的版本和你想要安装的kubernetes
版本相同。**
安装docker
:
1 | sudo yum install -y docker |
在RHEL/CentOS 7 系统上可能会路由失败,我们需要设置一下:
1 | sudo -i |
接下来我们需要安装kubeadm
, kubelet
和kubectl
了,我们需要先加一个repo:
1 | cat <<EOF > /etc/yum.repos.d/kubernetes.repo |
然后安装:
1 | sudo yum install -y kubelet kubeadm kubectl |
至此,在所有机器上安装所需的软件已经结束。
安装完所有的依赖之后,我们就可以用kubeadm
初始化master了。
最简单的初始化方法是:
1 | kubeadm init |
除此之外,kubeadm
还支持多种方法来配置,具体可以查看一下官方文档。
我们在初始化的时候指定一下kubernetes版本,并设置一下pod-network-cidr(后面的flannel会用到):
1 | sudo -i |
在这个过程中kubeadm
执行了一系列的操作,包括一些pre-check,生成ca证书,安装etcd和其它控制组件等。
界面差不多如下:
最下面的这行kubeadm join
什么的,就是用来让别的node加入集群的,可以看出非常方便。我们要保存好这一行东西,这是我们之后让node加入集群的凭据,一会儿会用到。
这个时候,我们还不能通过kubectl
来控制集群,要让kubectl
可用,我们需要做:
1 | 对于非root用户 |
接下来要注意,我们必须自己来安装一个network addon。
network addon必须在任何app部署之前安装好。同样的,kube-dns
也会在network addon安装好之后才启动。kubeadm
只支持CNI-based networks(不支持kubenet
)。
比较常见的network addon有:Calico
, Canal
, Flannel
, Kube-router
, Romana
, Weave Net
等。这里我们使用Flannel
。
1 | kubectl apply -f https://raw.githubusercontent.com/coreos/flannel/v0.9.1/Documentation/kube-flannel.yml |
安装完network之后,你可以通过kubectl get pods --all-namespaces
来查看kube-dns
是否在running来判断network是否安装成功。
默认情况下,为了保证master的安全,master是不会被调度到app的。你可以取消这个限制通过输入:
1 | kubectl taint nodes --all node-role.kubernetes.io/master- |
终于部署完了我们的master!
现在我们开始加入一些node到我们的集群里面吧!
ssh到我们的node节点上,执行刚才下面给出的那个 kubeadm join
的命令(每个人不同):
1 | sudo -i |
输出差不多如下图:
这时候,我们去master上输入kubectl get nodes
查看一下:
1 | [root@i-071abd86ed304dc84 ~]# kubectl get nodes |
成功!
我们可以看到,用kubeadm
部署可以让我们比手动部署方便得多,虽然比不上kops
这样的一键部署生产Kubernetes集群的工具,但是kubeadm
最初的设计也并非是傻瓜式使用。
kubeadm
给了用户很多的灵活性,让用户可以完全自定义地去配置自己的集群。
不过目前(截止博客发布为止),kubeadm
还只是在测试,官方还不建议在生产环境中使用,不过预计会在2018年春季可以投入生产使用。
最后,我们总结一下kubeadm
最核心的几个概念:
CAS——Central Authentication Service,集中式认证服务,顾名思义就是把一个网站群的用户认证挪到同一个地方去进行。
CAS架构如下图:
可以看出来,CAS主要是用在网站群里面。想想也是,如果有好多个网站都需要用户认证,不可能每个网站自己维护一套用户认证系统,不然维护和开发起来不是太麻烦了,所以需要把用户认证挪到同一个地方去集中地进行,这就是CAS的思想。
CAS服务器和App服务器通过协议进行交互,其实也就是相当于我们经常说的“解耦”,把用户认证的体系给单独剥离出来,使得用户认证体系可以在所有网站中复用。这么说来还有点微服务的意思?其实很多想法都是殊途同归的。
这是CAS主要的流程,简单来说就是在访问服务器的时候,如果发现没有session,就去CAS Server验证一下,CAS的TGT是为了不让用户重复登录的一个ticket。
CAS Server验证完了身份,就给一个ST,让用户拿给app,app用ST去CAS Server获取到用户的信息,于是创建session。
]]>因为有些人已经解决你的问题了。你的问题别人已经遇到过了,也解决了,我们应该学习别人的经验并进行复用。
设计模式大都是一些良好的OO实践,其中能反映出很多OO的设计原则。
使用模式最好的方法是:“把模式装进脑子里,然后在你的设计和已有的应用中,寻找何处可以使用它们。”
有趣的事情发生时,可千万别错过了!
给爱用继承的人一个全新的设计眼界
装备好开始烘烤某些松耦合的OO设计。
单实例模式:用来创建独一无二的,只能有一个实例的对象的入场券。
把封装带到一个全新的境界:把方法调用封装起来。
把方块放进圆洞中。
封装完对象……接下来呢?
有许多种方法可以把对象堆起来成为一个集合。
基本常识:策略模式和状态模式是双胞胎,在出生时才分开。
玩过扮白脸、扮黑脸的游戏吗?
谁料得到模式居然可以携手合作?
现在你已经准备好迎接一个充满设计模式的崭新世界。
略……
]]>Remove the Python 2.7 framework
sudo rm -rf /Library/Frameworks/Python.framework/Versions/2.7
Remove the Python 2.7 applications directory
sudo rm -rf "/Applications/Python 2.7"
Remove the symbolic links in /usr/local/bin
that point to this Python version see ls -l /usr/local/bin | grep '../Library/Frameworks/Python.framework/Versions/2.7'
and then run the following command to remove all the links:
1 | cd /usr/local/bin/ |
If necessary, edit your shell profile file(s) to remove adding /Library/Frameworks/Python.framework/Versions/2.7
to your PATH
environment file. Depending on which shell you use, any of the following files may have been modified: ~/.bash_login
, ~/.bash_profile
, ~/.cshrc
, ~/.profile
, ~/.tcshrc
, and/or ~/.zprofile
.
我们可以通过show engines
命令来看到我们的mysql server提供了哪些引擎:
1 | show engines; |
InnoDB是事务性数据库的首选引擎,支持事务安全表(ACID),支持行锁定和外键。MySQL5.5.5之后,InnoDB作为默认存储引擎。InnoDB主要特性有:
MyISAM是基于ISAM的存储引擎,并对其进行扩展。它是在Web、数据存储和其他应用环境下最常使用的存储引擎之一。MyISAM拥有较高的插入、查询速度,但不支持事务。在MySQL5.5.5之前的版本中,MyISAM是默认存储引擎。MyISAM主要特性有:
MEMORY存储引擎将表中的数据存储到内存中,为查询和引用其他表数据提供快速访问。MEMORY主要特性有:
比如使用下面的代码测试:
1 | package main |
编译它: $ GOOS=darwin GOARCH=386 go build test.go
就可以生成运行在OS X
上的程序。
可用的OS和ARCH的值如下:
$GOOS | $GOARCH | |
---|---|---|
darwin | 386 | |
darwin | amd64 | |
darwin | arm | |
darwin | arm64 | |
dragonfly | amd64 | |
freebsd | 386 | |
freebsd | amd64 | |
freebsd | arm | |
linux | 386 | |
linux | amd64 | |
linux | arm | |
linux | arm64 | |
linux | ppc64 | |
linux | ppc64le | |
netbsd | 386 | |
netbsd | amd64 | |
netbsd | arm | |
openbsd | 386 | |
openbsd | amd64 | |
openbsd | arm | |
plan9 | 386 | |
plan9 | amd64 | |
solaris | amd64 | |
windows | 386 | |
windows | amd64 |
不同的操作系统下的库可能有不同的实现, 比如syscall库。go build没有内置的#define
或者预处理器之类的处理平台相关的代码取舍, 而是采用tag和文件后缀的方式实现。
tag方式
tag遵循一下规则
在文件的头部增加tag:
1 | // +build darwin freebsd netbsd openbsd |
可以有多个tag,之间是AND的关系
1 | // +build linux darwin |
注意tag和package中间需要有空行分隔,下面的例子是不对的:
1 | // +build !linux |
文件后缀方式
以*_$GOOS.go*为后缀的文件只在此平台上编译,其它平台上编译时就当此文件不存在。完整的后缀如:
1 | _$GOOS_$GOARCH.go |
如syscall_linux_amd64.go,syscall_windows_386.go,syscall_windows.go等。
]]>1 | echo "deb http://http.debian.net/debian jessie-backports main" > /etc/apt/sources.list.d/jessie-backports.list |
这样就OK啦!
如果需要装oracle java并自动选择同意的话:
1 | echo oracle-java8-installer shared/accepted-oracle-license-v1-1 select true | sudo /usr/bin/debconf-set-selections |
就可以了!
]]>由于项目一开始用的是1.6,所以用python manage.py startapp
默认没有migrations这个package,而之前又有一些model是使用syncdb的,并且之后再没修改过,所以在用1.7的时候一直都没什么问题,而且1.7会自动去侦测没有makemigrations的model并自动migrate,导致了在升级1.8的过程中出现了一些小插曲,这里来记录一下。
1.7和1.8在migrate时的顺序不同(具体可以看一下源代码),所以导致了1.7能正常migrate,但是在1.8的时候会报错ColoumDoesNotExist,解决方案是看看报错信息中到底说的是哪个表没有渲染成功。我们只要先给这个app makemigrations就可以了,如果还出错的话就追根溯源到第一个报错的表,然后按顺序一个一个去makemigrations即可。
解决了migrations的差异之后,1.7和1.8基本是完全兼容的,别的都不需要进行修改。不过升级到1.8之后就算在debug模式下127.0.0.1
默认也不在settings
中的ALLOWED_HOSTS
中了,所以需要添加进去才能在本地访问。
还有就是1.8用了新的TEMPLATES的设置方法,具体的看看文档稍微修改下就好了,非常简单问题不大。
附上1.8要回退1.7的脚本(经测试有效):
1 | python manage.py migrate auth 0001 |
本文基于ubuntu16.04、nginx环境
第一步是安装letsencrypt
提供的certbot工具
1 | sudo add-apt-repository ppa:certbot/certbot |
我们使用WebRoot
这个插件。
这里以nginx的default的site作为示例:
1 | vim /etc/nginx/sites-available/default |
在server的块中,加入以下内容
1 | location ~ /.well-known { |
确认root是你网站的根目录,比如默认情况下是/var/www/html
保存退出之后,测试并重启你的nginx:
1 | sudo nginx -t |
然后我们获取到相关的SSL证书:
1 | sudo certbot certonly --webroot --webroot-path=/var/www/html -d example.com -d www.example.com -d third.another.com |
记得把上面的/var/www/html
改成你自己的网站根目录。如果需要同时对多个域名进行认证的话只要同时使用多个-d
就可以了,并且这些域名并不一定都需要为example.com
,可以为别的域名。
然后根据提示,输入对应的信息,如果完成后应该会看到类似的信息:
1 | IMPORTANT NOTES: |
认证成功后,我们来生成一下更强的dhparam
:
1 | sudo openssl dhparam -out /etc/ssl/certs/dhparam.pem 2048 |
这一步应该会消耗一定的时间
我们先创建一个新的脚本:
1 | sudo vim /etc/nginx/snippets/ssl-example.com.conf |
内容如下:
1 | ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; |
保存退出后,再创建一个脚本用来设置ssl的参数:
1 | sudo vim /etc/nginx/snippets/ssl-params.conf |
内容如下:
1 | # from https://cipherli.st/ |
保存退出。
然后修改一下site的配置文件:
1 | sudo vim /etc/nginx/sites-available/default |
改成这样:
1 | server { |
保存退出。
执行以下脚本:
1 | sudo ufw allow 'Nginx Full' |
因为letsencrypt提供的证书是有期限的,所以我们需要设置自动更新证书。
执行命令:
1 | sudo crontab -e |
在最后加上这么一行:
1 | 30 0 * * 1 /usr/bin/certbot renew --quiet --renew-hook "/bin/systemctl reload nginx" |
完成!
]]>AUFS是一种Union File System,所谓UnionFS就是把不同物理位置的目录合并mount到同一个目录中。UnionFS的一个最主要的应用是,把一张CD/DVD和一个硬盘目录给联合 mount在一起,然后,你就可以对这个只读的CD/DVD上的文件进行修改(当然,修改的文件存于硬盘上的目录里)。
![](http://coolshell.cn//wp-content/uploads/2015/08/docker-filesystems-busyboxrw.png)AUFS又叫Another UnionFS,后来叫Alternative UnionFS,后来可能觉得不够霸气,叫成Advance UnionFS。是个叫Junjiro Okajima(岡島順治郎)在2006年开发的,AUFS完全重写了早期的UnionFS 1.x,其主要目的是为了可靠性和性能,并且引入了一些新的功能,比如可写分支的负载均衡。AUFS在使用上全兼容UnionFS,而且比之前的UnionFS在稳定性和性能上都要好很多,后来的UnionFS 2.x开始抄AUFS中的功能。但是他居然没有进到Linux主干里,就是因为Linus不让,基本上是因为代码量比较多,而且写得烂(相对于只有3000行的union mount和10000行的UnionFS,以及其它平均下来只有6000行代码左右的VFS,AUFS居然有30000行代码),所以,岡島不断地改进代码质量,不断地提交,不断地被Linus拒掉,所以,到今天AUFS都还进不了Linux主干(今天你可以看到AUFS的代码其实还好了,比起OpenSSL好N倍,要么就是Linus对代码的质量要求非常高,要么就是Linus就是不喜欢AUFS)。
不过,好在有很多发行版都用了AUFS,比如:Ubuntu 10.04,Debian6.0, Gentoo Live CD支持AUFS,所以,也OK了。
好了,扯完这些闲话,我们还是看一个示例吧(环境:Ubuntu 14.04)
首先,我们建上两个目录(水果和蔬菜),并在这两个目录中放上一些文件,水果中有苹果和蕃茄,蔬菜有胡萝卜和蕃茄。
然后,我们输入以下命令:
我们可以看到在./mnt目录下有三个文件,苹果apple、胡萝卜carrots和蕃茄tomato。水果和蔬菜的目录被union到了./mnt目录下了。
我们来修改一下其中的文件内容:
上面的示例,我们可以看到./mnt/apple的内容改了,./fruits/apple的内容也改了。
上面的示例,我们可以看到,我们修改了./mnt/carrots的文件内容,./vegetables/carrots并没有变化,反而是./fruits/carrots的目录中出现了carrots文件,其内容是我们在./mnt/carrots里的内容。
也就是说,我们在mount aufs命令中,我们没有指它vegetables和fruits的目录权限,默认上来说,命令行上第一个(最左边)的目录是可读可写的,后面的全都是只读的。(一般来说,最前面的目录应该是可写的,而后面的都应该是只读的)
所以,如果我们像下面这样指定权限来mount aufs,你就会发现有不一样的效果(记得先把上面./fruits/carrots的文件删除了):
现在,在这情况下,如果我们要修改./mnt/tomato这个文件,那么究竟是哪个文件会被改写?
可见,如果有重复的文件名,在mount命令行上,越往前的就优先级越高。
你可以用这个例子做一些各种各样的试验,我这里主要是给大家一个感性认识,就不展开试验下去了。
那么,这种UnionFS有什么用?
历史上,有一个叫Knoppix的Linux发行版,其主要用于Linux演示、光盘教学、系统急救,以及商业产品的演示,不需要硬盘安装,直接把CD/DVD上的image运行在一个可写的存储设备上(比如一个U盘上),其实,也就是把CD/DVD这个文件系统和USB这个可写的系统给联合mount起来,这样你对CD/DVD上的image做的任何改动都会在被应用在U盘上,于是乎,你可以对CD/DVD上的内容进行任意的修改,因为改动都在U盘上,所以你改不坏原来的东西。
我们可以再发挥一下想像力,你也可以把一个目录,比如你的源代码,作为一个只读的template,和另一个你的working directory给union在一起,然后你就可以做各种修改而不用害怕会把源代码改坏了。有点像一个ad hoc snapshot。
Docker把UnionFS的想像力发挥到了容器的镜像。你是否还记得我在介绍Linux Namespace上篇中用mount namespace和chroot山寨了一镜像。现在当你看过了这个UnionFS的技术后,你是不是就明白了,你完全可以用UnionFS这样的技术做出分层的镜像来。
下图来自Docker的官方文档Layer,其很好的展示了Docker用UnionFS搭建的分层镜像。
关于docker的分层镜像,除了aufs,docker还支持btrfs, devicemapper和vfs,你可以使用 -s 或 –storage-driver= 选项来指定相关的镜像存储。在Ubuntu 14.04下,docker默认Ubuntu的 aufs(在CentOS7下,用的是devicemapper,关于devicemapper,我会以以后的文章中讲解)你可以在下面的目录中查看相关的每个层的镜像:
在docker执行起来后(比如:docker run -it ubuntu /bin/bash ),你可以从/sys/fs/aufs/si_[id]目录下查看aufs的mount的情况,下面是个示例:
你会看到只有最顶上的层(branch)是rw权限,其它的都是ro+wh权限只读的。
关于docker的aufs的配置,你可以在/var/lib/docker/repositories-aufs这个文件中看到。
AUFS有所有Union FS的特性,把多个目录,合并成同一个目录,并可以为每个需要合并的目录指定相应的权限,实时的添加、删除、修改已经被mount好的目录。而且,他还能在多个可写的branch/dir间进行负载均衡。
上面的例子,我们已经看到AUFS的mount的示例了。下面我们来看一看被union的目录(分支)的相关权限:
权限中,我们看到了一个术语:whiteout,下面我来解释一下这个术语。
一般来说ro的分支都会有wh的属性,比如 “[dir]=ro+wh”。所谓whiteout的意思,如果在union中删除的某个文件,实际上是位于一个readonly的分支(目录)上,那么,在mount的union这个目录中你将看不到这个文件,但是read-only这个层上我们无法做任何的修改,所以,我们就需要对这个readonly目录里的文件作whiteout。AUFS的whiteout的实现是通过在上层的可写的目录下建立对应的whiteout隐藏文件来实现的。
看个例子:
假设我们有三个目录和文件如下所示(test是个空目录):
我们如下mount:
现在我们在权限为rw的test目录下建个whiteout的隐藏文件.wh.apple,你就会发现./mnt/apple这个文件就消失了:
上面这个操作和 rm ./mnt/apple是一样的。
Branch – 就是各个要被union起来的目录(就是我在上面使用的dirs的命令行参数)
Whiteout 和 Opaque
如果UnionFS中的某个目录被删除了,那么就应该不可见了,就算是在底层的branch中还有这个目录,那也应该不可见了。
Whiteout就是某个上层目录覆盖了下层的相同名字的目录。用于隐藏低层分支的文件,也用于阻止readdir进入低层分支。
Opaque的意思就是不允许任何下层的某个目录显示出来。
在隐藏低层档的情况下,whiteout的名字是’.wh.
在阻止readdir的情况下,名字是’.wh..wh..opq’或者 ’.wh.__dir_opaque’。
看到上面这些,你一定会有几个问题:
其一、你可能会问,要有文件在原来的地方被修改了会怎么样?mount的目录会一起改变吗?答案是会的,也可以是不会的。因为你可以指定一个叫udba的参数(全称:User’s Direct Branch Access),这个参数有三个取值:
其二、如果有多个rw的branch(目录)被union起来了,那么,当我创建文件的时候,aufs会创建在哪里呢? aufs提供了一个叫create的参数可以供你来配置相当的创建策略,下面有几个例子。
create=rr | round−robin 轮询。下面的示例可以看到,新创建的文件轮流写到三个目录中
create=mfs[:second] | most−free−space[:second] 选一个可用空间最好的分支。可以指定一个检查可用磁盘空间的时间。
create=mfsrr:low[:second] 选一个空间大于low的branch,如果空间小于low了,那么aufs会使用 round-robin 方式。
更多的关于AUFS的细节使用参数,大家可以直接在Ubuntu 14.04下通过 man aufs 来看一下其中的各种参数和命令。
AUFS的性能慢吗?也慢也不慢。因为AUFS会把所有的分支mount起来,所以,在查找文件上是比较慢了。因为它要遍历所有的branch。是个O(n)的算法(很明显,这个算法有很大的改进空间的)所以,branch越多,查找文件的性能也就越慢。但是,一旦AUFS找到了这个文件的inode,那后以后的读写和操作原文件基本上是一样的。
所以,如果你的程序跑在在AUFS下,open和stat操作会有明显的性能下降,branch越多,性能越差,但是在write/read操作上,性能没有什么变化。
IBM的研究中心对Docker的性能给了一份非常不错的性能报告(PDF)《An Updated Performance Comparison of Virtual Machinesand Linux Containers》
我截了两张图出来,第一张是顺序读写,第二张是随机读写。基本没有什么性能损失的问题。而KVM在随机读写的情况也就有点慢了(但是,如果硬盘是SSD的呢?)
原文出自:coolshell
(转载文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)
]]>