普通视图

发现新文章,点击刷新页面。
昨天以前首页

假装是个设计师2——避雷针

作者 王隐
2018年9月3日 23:58

好的设计大同小异,不好的设计各有各的问题。

本文介绍一些网页和文章排版中的常见错误。

展示过程中会给出错误和正确的对比用例图片,错误用例也会进行具体标注,所以文字描述部分会比较少。

多图预警。

一、网页中的排版错误

1.内容未按逻辑分块

使用背景色将网页不同部分分块,更适于读者阅读。

2.标题上下方间隙不同

不同间隙会产生一种错觉:文章内容由标题决定,尽管每篇文章的重要性均等。也会让网站看起来很乱,

等间隙则会让不同标题显得一样重要。

3.padding 过小

图二中间部分的文字没被「撑」起来,读者很难将上部分与其分开。

插多大的空白呢?至少120px吧。

4.图片上放低对比度文字

原因很简单,看不清字。

图片太亮了,上面文字就看不清。

两个办法:

  • 文字的对比度要大过图,比如用黑色字体
  • 图片暗一点,并且把字放在图对比度最低的地方

5.太多样式

样式太多有一个问题——你到底想强调什么?

尽量一个页面用一个大小的字体,一个颜色,最多两套字体,这样看起来比较简洁。

6.颜色块太窄

避免使用窄颜色快来强调你的内容。比如标题已经很大了,这个时候就不要用颜色块了。背景色行不行?不止标题,还有相关的文字。

7.很窄还放放大量文字

没有可读性,因为读的人看时总要换行。

文字又长,又居中,可读性就更低了。

尽量不要在窄区域放很多的字。

8.居中文字过多

居中的文字很少时,显示效果很好,但一旦字多就不行了。

用户很难有效率地阅读,建议你字体设置大些。(24px以上)

如果你真的有很多的文字,弄个折叠显示,比如「点击显示全文」。

标题后带一段短文字,显示效果还不错。

9.文字对背景图片喧宾夺主

文字尽量不要遮挡图片(下图一文字挡了女孩的脸。),这样会看不清图的细节,也会让文字更不好读。

多尝试不同的位置,比如文字居中或竖着放左边。

10.误用字的行高

大标题的字最起码要比其他部分的标题字高。

图一的大标题要比二级标题小,人们会很困惑:二级标题的内容要比一级标题重要吗?

网页整体要有结构性, 从上到下,从大到小。

这有助于帮读者分清重要信息和不重要信息

11.不必要的逻辑切分

全屏图后面接文字,在视觉上给人感觉图前图后应该是两部分。

这个时候在文字和图都加上 padding,那它们看起来就很统一,像一个整体。

12.标题又长又大

大字体适用于短文字,标题要是太长,字体就要小一些,可读性也更高,也能给网页上其他部分留空间。

讲究平衡,网页不只是为了突出标题,还有其他的元素。

13.按钮用边框

透明按钮用边框有意义,彩色按钮别加。

14.颜色太多

太乱,一两个颜色就够了。

15.复杂的导航栏

导航栏目的是「导航」。

不要放没用的信息上去,5到7个足够了。

这个导航栏放的 tab 就太多了。

二、文章中的排版错误

1.不分段

大量篇幅不要写在一段。

经常分段。

多插图,插引用,给读者心里上的缓冲。

公众号现在都恨不得一句一段了。

2.标题上下距离一样

标题上下空出来的距离不要一样,因为标题是对下一部分的启下,即标题属于下部分。

标题上空白要比下空白大两到三倍。

同时,标题下间隔最好和段落间间隔相同,这样给人感觉标题和下部分短路是一个整体。

3.小标题比标题都大

4.间隔不一

图一的作者头像和网页顶栏过近,看起来就像作者属于顶栏部分,而不是下边的文字。

解决方法也是在头像上加 padding。

5.图片说明和图片距离过近

6.小标题(h2,h3)和文字段落过近

段落间隔>小标题与段落间隔,会让人觉得文章整体很松散。

段落间隔<副标题与段落的间隔,看起来好多了。

7.强调的内容离正文太近

关键段,引用,最起码要距离正文75到120px。

还是加 padding。

8.低对比度的内容

如果你想强调你的内容,最起码要讲强调段字体比正文大10到15px,字体也要再深点。

9.字少还用背景色

如果想强调作者信息,可以加 padding,不要用背景色。

也不要在小标题用背景色。

10.全屏图之间有空隙

空隙意义是什么?

11.太多加粗

太多加粗,文章好像断裂了一样,如果通篇都是加粗强调,那加粗的意义也就没有了。

12.太多样式

太多样式很容易分散读者注意力。

13.居中的段落太长

还是可读性不好。

14.标题和图片距离过近

标题和图片距离过近,会让人觉得标题和图片是一部分,其实标题应该是独立的。

设置 padding=60px,再加个副标题吧。

15.滥用斜体

16.居中

图一的标题和副标题都太靠左了。

原文连接:blog-en.tilda.cc

作者:Nikita Obukhov

现代摄影改变了我们什么?

作者 王隐
2018年8月26日 02:12

你按快门,剩下的交给我们。

1888年,柯达为业余爱好者发布了一款便携性个人相机,于是我们怀念过去的方式改变了。

相机开始成为记录我们生活不可缺少的一部分。那些宝贵的高光时刻被打印出来永久珍藏,不喜欢的时刻则被我们扔掉。胶片摄影在1999年达到巅峰,那一年,全世界总共出现800亿张照片。

随着智能手机的普及,手机摄影在过去十年帮助我们创造的照片要大于我们以前一共拍的照片。2017年有1.2万亿张照片,现在几乎每天有30亿张照片在社交媒体上分享。

照片实时呈现,回忆实时发生

很少有人可以预见我们与摄影的关系会如此亲密。甚至会影响我们体验和记录世界的方式。我们通过相机看到了更多的瞬间,我们也花了更多的时间看自己的手机,观察别人的生活。

手机的发展也影响了我们体验世界的方式,我们走遍世界去找寻那些值得捕捉的时刻,我们拍了这么多的照片,难怪有人会担心我们的「现实生活」。人们都在说要放下手机,活在当下。这确实也有科学依据。

研究人员发现,强社交习惯会影响我们存储记忆的方式。2018年的一项研究证实,相比于简单的观察物体,人们不太能记得住他们拍了什么。这被称为「拍照削弱效应」(photo-taking-impairment effect)。于14年第一次被提出。

研究人员发现,照片记忆会影响我们其他形式的理解能力。当我们通过相机镜头和屏幕观察现实时,我们只能获得其中的一小部分体验。换句话说,虽然我们「看见」了,但是却失去了其他重要的感官信息。

拍照也是认知减弱的一种形式。我们放心设备会记录信息,我们将部分记忆让渡给数字内存。加州大学圣克鲁兹分校 Jennifer Soares 博士表示:「你不需要记住,因为你知道相机可以帮你记住,就像你拍摄停车位号码一样,你不会费心去记住它」。

我们确实是这样做的,但在大多数情况下,我们不会拍照来记住细节。或许会有摄影师争辩他们并没有认知减弱,尤其是他们认为自己的摄影作品是为展现细节的艺术。

然而在这个照片十分丰富的时代,结果却出乎意料:帮助我们记录更多经历的同时,大量的照片和社交平台也很容易让我们忘记它们。拍照,分享,手指下滑,重复——照片的寿命变短了,手指的一次下滑,那些你没注意到的照片很难再让你看到了。这样一来,摄影开始回归它最初的样子

1290年,Arnaud de Villeneuve 在一个昏暗的房间聚集了一群人。他们围看墙上的一个光点,这个光点显示出一个图像,但它既不突出也不明亮,却足以让他们回味起残酷的战斗和后来的动物狩猎情景。

Villeneuve 业余时间是一名执业医师和表演者。他创造的那些画面即遥远又亲近。观众会抓住照片消失前稍纵即逝的瞬间来搞清其背后含义。演出结束后,人们欣喜若狂,互相窃窃私语。感叹 Villeneuve 的神奇技术。短时间人们不会忘记这些。

但 Villeneuve 不是魔术师。他只是最初拥有摄影暗箱的摄影师。那个时候的观众体验是最初的摄影感受。

相机将物体转化成了可以连接过去和将来的载体:好的回忆,不好的回忆。那个时候拍照还很奢侈,很少有人可以在镜头前精挑细选,除非我们主动丢弃,否则照片就是永久的。

门槛降低便不再永久

拍摄和分享的门槛变低之后,照片也就不那么珍贵了。我们每天都要接触大量怀旧的照片,以至于它们的影响力也大打折扣——我们并不关心手指下滑看到的大部分照片。难怪社交媒体需要动用算法来帮我们搞清楚什么重要。

然而 Snapchat 的照片和视频很快就会消失,这可能会赋予拍照新的意义。

即将出版的《The Social Photo》作者 Nathan Jurgenson 在2013年写道「短暂的照片是其爆炸增长后的一种文化反应。它可以激起你的回忆,因为它可以让你更快的忘记。」换句话说,照片寿命缩短——改变了它被创造和欣赏的方式。你发布一个 Instagram 故事。24小时就会消失。这种方式下,照片又变得珍贵和有话题性,你需要新的方式来珍视它。

Jurgenson 表示,作为永久性记录的传统照片和社交照片要分开来看,后者更倾向于「短暂性,游戏性和表现力」。当照片容易获得和分享时,它们不再永恒,它们会尽力捕捉当下瞬间的感受,就像人们耳语相传一样。

照片的记录功能仍会继续存在,比如文件记录、摄影艺术和回忆保存。但我们和这个世界交互方式变了,我们不再想捕捉那些美好瞬间,而是直接和它对话。当照片和其拍摄的来龙去脉同样重要时,这种体验是不同的,记下或忘记的瞬间也是不同的。当我在 Snap 上给朋友发照片时,感觉和以前祖母拿出相册回忆过去有着奇怪的共鸣。

这是个摄影新时代, 但也许这个新时代与 Villeneuve 的13世纪并无二致。照片被制作成「故事」,他们让我们?,让我们?。我们记录、描绘并筛选它们。

在它们消失前,我们挖掘每张照片背后的含义——就像很久以前 Villeneuve 的观众在那个黑暗的房间里所做的那样。


Medium · by Stephane Lavoie

焦虑与创造力

作者 王隐
2018年8月24日 17:14

大脑生来就很焦虑,并且它们还运转的不错。

之所以这样设计是为了「修复」一些认知上的劣势,这是一件好事,可以促使我们不断发展。比如我们恐惧饥饿、死亡和无意义的生活。于是我们从这些恐惧中诞生了农业,医药和宗教。控制思考能力的大脑分区也会控制人们的创造力,这可不是巧合。

你感到焦虑,觉得自己有问题,焦虑完这件事还有那件事,不能停下来享受生活感受快乐,这不是你的问题,因为或许你对人类大脑和幸福的理解出现了偏差。

我们并不是为快乐而生,比如无忧无虑,感恩,兴奋这些。

我们生来是为了生存而存在,而这种生存表现在创造上。

当我们专注于创造而不是感觉时,痛苦就会减弱。比如专注于如何从已有的存在中创造出我们想要的东西,而不是世界让我们感受到什么。

当关注的焦点不是「我能享受什么?」而是「我能创造什么?」时,好坏变得不重要。

阻碍会变成机会。

人无时无刻都在创造,在阅读这篇文章时,你正在创造细胞和想法。你的呼吸在制造二氧化碳。和爱人共度时光时,你在建立自己的关系。工作时,你在挣钱,创造技能,你总是在创造。

停止创造时,痛苦就会出现。因为你不是在规划人生的下一步,而是在反思你上次做事情的最后一步,你不再去想那些有创造性的机会,你假设已经没有更好的可能性。生活开始失控,你开始采取无能为力的态度,变得无助,但这种痛苦毫无意义。

当我们专注于创造时,痛苦就成为了整个过程的一部分。痛苦也变得有价值。我们不再将自己的情感体验分为「感觉良好的事情」和「感觉不好的事情」。我们将它们划分为「值得做的事」和「不值得做的事」。我们会正视痛苦,也因此进化成长,增强自己应变与思考的能力。

创造力并不是艺术所独有,它也不一定是一时情感的爆发,它可以是一种习惯。

当我们选择养成这种习惯时,我们不再被动,而变得积极活跃。我们不再从别人创造的世界中获得利益和快乐,而是认清自己的使命,完善自己。


原文链接:Medium · by Brianna Wiest

假装是个设计师

作者 王隐
2018年8月13日 22:25

每个 Web 开发人员都会遇到要做 UI 的情况,无论他喜欢与否。

也许你公司没有全职 UI 设计,所以你得自己来。也或许你在做一个 side-project,但你想让它比 Bootstrap 好看点。

你可以说「我又不是艺术家,别要求我太多」,但事实表明,你还是有很多技巧来提高自己的设计水平,当然了,这并不需要你有很好的 UI 技术。

以下是七个提升设计感的简单实践。

一、 用颜色和粗细来划分重点,而不是字体大小

文本样式的一个常见错误是过度依赖字体大小来划分重点结构。

「这段文字重要吗,字大点。」

「这段文字不太重要,字小点。」

不要总用字体来控制这些,尝试使用颜色和 font-wight 也可以完成同样的工作。比如:

「这段文字重要,加粗。」

「这段文字不重要,细一点。」

用两种或三种颜色划分你的内容:

  • 重要内容用深色(但不是黑色)(如文章标题)
  • 次重要内容用灰色(如文章发布日期)
  • 不重要内容用浅灰色(如页脚的版权声明)

大多情况,两种 font-weight 就够了:

  • 大部分的文字用普通 weight(400或500)
  • 你强调的文字 weight(600或700)

400 以下的 weight 就别用了;大标题下还可以看清,小字体基本看不清。如果你想用更小的 weight 来弱化你的文字,用更淡的颜色或更小的字体吧。

二、彩色背景别用灰色字体

在白色背景下使用浅灰色字体可以弱化内容,但是在彩色背景下就不好了。

这是因为我们在白色背景下看灰色其实是降低了文字的对比度。

相比于使用灰色字体,让文本接近背景色更能突出文字重点。

彩色背景下,你可以通过两种方法降低对比度。

1.降低白字不透明度(opacity)

白色,低不透明度,再让背景色透过来一点,这样可以弱化文字,又不会在彩色背景下显得很违和。

2.根据背景色自己选一个合适的颜色

当你背景是图片或图案时,这种方法比降低不透明度要好一些。而且降低不透明度显得文字太暗了,就像水洗的一样。

选个和背景色相同的颜色,再慢慢调饱和度和亮度,直到看起来还不错。

3.正确使用阴影

相比于使用大阴影(blur)和暗角来让阴影更明显,不如添加垂直偏移 (vertical offset)。

这样看起来会更自然,因为它模拟了光源从上面照下来的效果,这和我们在现实世界看到的一样。

输入框和表格也可以这么用:

阴影设计是门艺术,感兴趣可以看看 Material Design Guidelines

四、少用 border

当你想划分两个元素的边界时,先别考虑 border。

border 确实在划分边界上做的很不错,但这不是唯一的方法,滥用 border 会让你的设计特别杂乱。

你可以试试下面的方法:

1.box shadow

box shadow 不仅可以像 border那样做到划分边界,而且这种方法更精细,也不会太分散你的注意力。

2.两种背景色

相邻元素用不同的背景色就可以划分它们。

3.加空隙

有没有更好的办法划分不同元素的边界,而不只是简单地增加分割部分?当然有,把元素隔得更远就行了。

五、少用大图标,如果它们本身意义不大的话

如果你在设计一些大图标(比如登录页面的「功能」部分),试试 Font Awesome 或 Zondicons 这样的免费图标集,然后加大尺寸直到满足你的要求。

它们都是矢量图,所以不会在放大的过程中丢失细节。

虽然矢量图不会在拉伸时丢失细节,但本来在 16-24px绘制的图标非要拉伸到三倍四倍之大,真的不会显得很好看,他们会缺乏一些细节,比如不成比例的「矮胖」。

如果小图标可以满足你的要求,可以将它们包在另外一个形状中,并且为形状添加背景色。

这可以让实际图标更加接近预期的大小,同时可以填充更大的空间。

如果有预算,可以用大尺寸的高级矢量图,比如Heroicons or Iconic

六、边框特写,给平淡的设计增加颜色

如果你不是 UI,怎么才能从你的用户界面中和其他那些好看的设计中脱颖而出呢。

简单却有效的一个技巧就是为某些部分添加色彩鲜艳的边框,这样不会感到乏味。

举个例子,在提醒信息的左边做文章。

高亮导航栏

顶端都加上也行。。

它不需要任何专业的 UI 设计人员帮你,但可以让你网站看起来更「大气」。

是不是不好选颜色,Dribbble’s color search 可以帮你。

七、不是每个按钮都需要颜色

当用户可以在一个页面有很多的选择时,很容易陷入语义化设计的陷阱。

比如 Bootstrap,无论何时添加新按钮,都可以选择不同的语义。

「积极动作,绿按钮。」

「删除数据,红按钮。」

语义是按钮设计的一个重要部分,但有一个更常见的重要维度被忽略了:重要性。

页面上的每个操作都处于金字塔的某个重要位置。但大多数页面只有一个真正重要的操作,一些不太重要的次要操作,以及一些很少使用的三级操作。

设计这些动作的时候,更重要的是理清这些按钮谁更重要。

  • 重要动作应该突出,加粗,高对比度。
  • 不太重要的动作应该清楚显示,但别太主导。用轮廓线,低对比度背景色。
  • 三级操作可见即可,但不要引人注意。像链接一样处理吧。

「删除按钮呢,应不应该总是红色?」

没必要,如果它们不是页面中的主要操作,处理成二级或三级按钮即可。

保留大、红,粗样式在那些以负面操作为主的页面上,比如确认对话框。

原文链接:7 Practical Tips for Cheating at Design – Refactoring UI – Medium

作者:Adam Wathan & Steve Schoger

编译:@王隐在录音

博客毁了Web?

作者 王隐
2018年8月3日 13:55

1993 年,我第一次上网,那个时候的 web 还是三个大写字母 WWW,网速也不快。

每次上线,打开的第一个页面都是 Netscape 的 What’s Cool

What’s Cool 自诩是发现酷网页的最佳入口。在很长的一段时间里,它确实做的不错。

那个时候整个 Web 规模还不大,有多小呢,只要一个页面就可以罗列出当时所有的网站。

使用一分钟一帧的网络摄像头来监视另外一个大陆的咖啡机,点击一个没有任何功能的大红按钮,在那个时候可太酷太潮了。

那个时候,我们没有任何的平台,内容流,社交网络和博客。只有主页。

主页的背景是灰色。字体 Times New Roman。链接中蓝色。那个时候还没有页面滚动的视觉特效,但有水平的 GIF。

你要是知道些实用 tag 的话,做一个新网页出来大约只需要一分钟。

没有数据库让你配置,没有脚本让你安装。没有插件,没有安全补丁。也没有 cookies。没有 iframes,没有 js,也没有 Web App。

我们就这么纯手工创建每一个网页。有很多网页时,再手工创建导航栏。我们手工管理自己的内容目录。我们打破了代码的计算边界来进行图像映射(image maps),那个时候我们还会正式地谈论「超链接」。

网页更新时,我们会放一个小小的「新」图标在上面。

如果不是新链接呢?那就将点过的链接用粉色表示。

那个时候的主页都各不类似。也没有内容管理系统(CMS)。但这并不意味着主页的内容杂乱无章。

井井有条的主页会让你(公司)更有自豪感——即使它只是一系列有趣的 GIF,或教你怎么制作最好的土豆枪,关于沙鼠遗传研究等。

日期不重要,因为内容可以留在主页很长时间,旧的内容也可以看到。相比于现在时间流的展示方式,以前的 Web 更像是目录(体现在列表)。

有主页的人就像一个业余图书馆管理员。

可惜,好景不长。

一、

1994 年,一位名叫贾斯汀霍尔的大学生开始拒绝传统的目录格式。他每天都会从主页顶部添加新的内容,并在每篇都标注日期。涉及内容很广,从有趣的链接和他接触到的性和毒品,全都包括。

贾斯汀的这种做法就是第一代的网络日志。

也是他们所谓的网络日记。(几年之后,博客这个名字就出现了,因为有些作家不在局限于只写私人内容。)

大部分日志都是作为单独部分,再关联到个人主页上。这些日记都是内联内容(无需访问者点击任何内容,亦无需点击导航栏),就像贾斯丁的那样,要不就有一个公共页来链接所有的日记。

但这样有一个问题:这种排序的日记并不按重要性或类别展示。

只是按照时间先后顺序。

就像这样:

这种形式就像 web 本身一样古怪并且很个人化,通常是「忏悔」型的内容,篇幅也不长。

但这种形式并不流行。

二、

1993 年初,jjg (知名博客主)统计他的「网络日志」时,发现只有 23 个。没错,只有 23 个。毫无疑问,他丢失了很多内容,那应该是多少呢?扩大五倍,十倍?230 篇日志?

到 2000 年底,根据 Eatonweb 的数据,所有的网络博客总只有 1285 个。

博客的世界太小了。

当然,早期的网络本身非常独特:首先你得能上网,还得知道 HTML,还要有一个托管账户,并知道怎么使用它。这都没有捷径,每个上网的人都必须全身心投入进来。

但是,除了博客之外,还有成千上万的个人主页。

主页永不过时,它是关于某个主题或某个人的有趣/相关事物的索引。你没必要为了追求新鲜而每天都刷新主页(这就是Netscape的酷炫之处!)

时间流排序还是少数。

当时的互联网主要由学者,专业人士和大学生组成。并不是每个人都想发表他们的愤怒诗歌,性骚扰内容和上网习惯(即当时互联网内容质量很高);时间流排序很大的限制是它需要投入大量时间和精力。坚持写日记并不简单。首先,你必须要有说的内容,然后写下来,编辑,格式化,添加剪贴画,编辑 Index.html,编辑上一篇/下一篇的链接,并检查,最后上传这些文件。

无聊,枯燥又复杂。

Blogger 和 Livejournal 都在 1999 年出现,与 jjg 的 23 个列表同年,但他们都没有改变写日记在技术上很枯燥的现状,也就没有立刻成功。对于习惯了 HTML 中自由格式的先驱者来说,博客有限的功能和缺乏自控性并不能让他们满意。

三、

第一次真正颠覆性浪潮发生在 2001 年。一项发明将彻底改变人们在网上分发书写内容的方式。

Movable Type.

看这个:

MT 并不是第一个简化内容分发的工具,但它是第一个吸引 tweakers——爱 DIY 的极客们的强大工具。

它也是第一个基于 Web 的 CMS,可以在自己的 Web 主机上免费下载,安装和运行。

这是第一个完全不需要你手工写网页的替代方案,博客更新变得简单,而且可控。

这也是许多网民们第一次接触到 CMS ,包括我自己。

像早期的网络日记迷一样,我模仿 Mena Trott(知名博客主)。她的Dollar Short 页面看起来像一个基本博客应有的样子,功能性和可维护性都很好,也没有多余的装饰,一页上有很多帖子,但因为都是纯手工制作这些不同的网页,样式上可不太好看。

人力做了这么多的内容,才能做出一个博客,这可不合适。

但有一天,它突然变得精简又漂亮。

人们赞叹, 这就像你在一个无聊郊区的杂乱屋子度过你整个人生,周围也都是这样住在无聊屋子的无聊人。然后突然有一天,你发现自己的屋子入选了《建筑精选》。

还能这么活?我也行吧?

答案是,在这些清爽简洁的页面底部,有一个小小的徽章:

由 Movable Type 强力驱动

含义很明显,这种简约之美,你也可以拥有。

没人会拒绝。

四、

Movable Type 不是一场技术革命。它不像今天的 CMS(wordpress)这样的实时网络应用,它都没有提供动态内容。这一点都不花俏。

它所做的只是利用 Perl 脚本的强大功能来完成我们以前手工做的工作——生成静态 HTML 文件。

但在文化层面,它是毁灭性的。

突然之间,人们不需要创建主页甚至网页,而只需要在网页中的表单字段和文本区域编写 Web 内容。

突然间,人们不需要建立自己的系统,而是在某个特定系统内部操作即可。

当然了,这个系统是别人创建的。

当然,你可以再定制你的 Movable Type 网站。比如编写模板。它比 HTML更难,但对于做过网站的人难度并不大。如果自己不会编程,也可以复制粘贴代码。似乎整个博客圈都在分享他们的最爱的 Calendar 侧边栏。

但这是个陷阱,那些 calendars 侧边栏就是诱饵。

五、

这就是问题的症结所在:当事情变得容易时,人们会做更多的事情。

纯手工制作整个网页时,从 HEAD 到/ BODY,这是一个无限可能的世界。你可以根据自己的喜好定制,并用任意方式组织。每一个设计决定工作量都不小,你都要手工完成它,无论是反向时间顺序还是整齐的目录。但都是按照你的想法去做的。

当一切都可以通过工具去做,Web 可以毫不费力地运作起来,但是这些只能通过一种方式来做,这种方式的标准就是如何能更少成本去建立 Web。

这就是 Movable Type 毁掉博客圈时发生的事情。

  1. 使用系统的默认格式:什么都不改。
  2. 根据你的格式自定义系统:比纯 HTML 复杂点。

从简入奢易,从奢入俭难。

系统默认赢了,时间流赢了。

六、

高产的博主们设计出了 Movable Type。但它的展现形式机械性地遵循博客功能。这种格式不仅没有更好的管理好自由格式的内容,而且还很僵硬:标题,类别,条目。除了单一条目本身,你只有四种排序方式:按日(chrono),按周(chrono),按月(chrono)和奇怪的通配符类别。

类别归档中,文章会自动按照日期排序,无需人工参与。

时间流时代开始了。或者说:时间流掌控一切。

七、

Movable Type 不仅扼杀了博客多样性。

它(和它的竞争者)还扼杀了别的 Web 产品。

那些不写日记的人——即那些 old school 主页的人,和博客主们一样,也想要那些超酷的 calendars 侧边栏。他们很容易被这些东西诱惑。于是也花了很多时间和精力迁移到这个新平台上。

他们很快发现,时间流不只是个不错的仆人,也是一个可怕的主人。

土豆枪女孩,沙鼠遗传学家发现他们不想更新了。因为觉得没意义。他们的网站应该是一个目录,一个参考工具,一个奇怪的,有点发霉的个人图书馆。新的「帖子」格式根本不适合他们,即苛刻,又压抑。

但他们还是迁过来了。花了许多的时间,精力,乐观来转移平台,他们必须重新经历这个过程。更糟糕的是,他们必须从头开始构建新(旧)站点,却没有合适的工具帮助他们。

从简入奢易,从奢入俭难。

一旦你尝到了不费力写网页的甜头,就很难回到纯手工建站时代了。

他们也一样,成千上万的人们也一样。

手工不值得。

这种惯性太强了。

老 Web ,酷 Web,奇奇怪怪 Web,手工 Web,全消失了。

时间流仍在不断扩散,Myspace. Facebook. Twitter. Instagram. Pinterest。这些社交网络都在使用时间流排序;他们还引入了算法排序,内容的展现不按照时间而是按照流行性,还通过你看过的历史内容猜你更想看的东西。这是一种从没有过的失控(对比按照列表,重要性排序)。

再也没有奇怪的主页了。

再也没有业余图书馆了。

而这都是因为方便软件的出现,迎合了最开始那些觉得写网页很痛苦的少部分人。

这可一点都不酷。


原文链接:How the Blog Broke the Web

作者:AMY HOY

译者:王隐在录音

Zig 语义分析

作者 tison
2023年12月13日 08:00

本文承接《Zig 中间表示》的内容,继续讨论 Zig 程序编译的下一步:从 ZIR 指令序列,经过语义分析的过程,生成 AIR 指令序列。

本文翻译自 Mitchell Hashimoto 关于 Zig 的系列博客第四篇:

语义分析是 Zig 程序编译的核心环节,且它包括了 Zig 语言独特的设计:编译时求值。不同于其他语言常常需要使用额外的语法来定义和计算类型(泛型),Zig 采用编译时求值的方式来完成类型计算。这使得很多原本需要宏或者模板的逻辑,现在可以使用跟主语言相同的语法来编写代码完成。

我在推特上发文讲过:

Zig 的泛型是用编译时类型计算来实现的。这有一个挺有趣的提示:如果 Zig 的编码体验提升,很多类型计算方法(类型论的实现)可以作为 Zig 库来提供,而不用像之前一样嵌入到编译器里作为编译器的内部实现。这可能是一个开放编译时计算的方向。

本文讨论了 AIR 制导生成的主要流程,但是没有深入讨论编译时求值的细节,也没有包括变量活性分析的内容,比较可惜。

以下原文。

AstGen 阶段之后是 Sema 阶段。这一编译阶段接收 AstGen 阶段输出的 ZIR 指令序列,并生成 AIR 指令序列。AIR 是“经过分析的中间表示(Analyzed Intermediate Representation)”的缩写,是一个完全类型化的中间表示。作为对比,ZIR 是一个无类型的中间表示。AIR 可以直接转换为机器代码。

正如 AstGen 一文中所指出的,ZIR 不完全类型化的一个原因,是完全类型化一个 Zig 程序需要进行编译时求值,才能完全实现泛型类型等功能。因此,Sema 阶段还会执行 Zig 程序的所有编译时求值。这就是魔法发生的地方!

AIR 是按函数而不是按文件生成的,就像 ZIR 或 AST 一样。本文将重点介绍如何将函数体从 ZIR 指令转换为 AIR 指令。

原注:有一些 AIR 指令是在文件范围内生成的,因此不能武断地说 AIR 是按函数生成的。然而,理解文件范围的 AIR 过程还需要更深入地讨论编译过程,本文将作为理解那些过程的重要基石。

AIR 是什么样的?

让我们看一个 AIR 的例子:

1
2
3
export fn add(a: u32, b: u32) u32 {
return a + b;
}
1
2
3
4
5
6
7
# Begin Function AIR: add:
%0 = arg("a", u32)
%1 = arg("b", u32)
%2!= dbg_stmt(2:5)
%3 = add(%0!, %1!)
%4!= ret(%3!)
# End Function AIR: add

你可以使用 zig build-obj --verbose-air <file.zig> 指令查看任意程序的 AIR 指令序列。这需要一个 DEBUG 模式构建的 Zig 编译器,可以查看 Zig 仓库的 Wiki 页面了解如何构建。

AIR 是按函数生成和打印的。上面的输出包含了以注释形式注明的 add 函数的 AIR 输出的行。请注意,只有导出或被引用的函数才会生成 AIR 指令,因为 AIR 是按需生成的。因此,为了调试目的,我通常会导出我想要查看 AIR 的函数。

如果你已经阅读了关于编译器先前阶段的内容,你会注意到 AIR 的打印形式跟 ZIR 非常相似。虽然如此,并且在许多情况下 ZIR 和 AIR 甚至有相同的指令标签名称,但是 AIR 是一个完全独立的中间表示。

%1 是指令的索引。当它后面跟着 ! 时,意味着该指令未被使用或未被任何其他已知部分的 Zig 程序引用。在上面的示例中,加法对应的指令 %0%1 用于构造 %3 指令,而 %3 则被返回指令使用。但是,调试语句 %2 和返回结果 %4 都未被使用。在编译器后端将 AIR 转换为最终格式时,可以按需使用这些信息。

原注:如前所述,有一些 AIR 是在文件范围而不是函数范围内生成的。例如,文件范围的变量初始化、编译时块等。目前无法打印此类 AIR 的内容。文件范围的 AIR 通常只是一系列常量指令,因为它总是在编译时求值。

剖析 AIR 结构

在讨论 ZIR 如何转换成 AIR 之前,我将首先介绍 AIR 的格式并剖析单条 AIR 指令。理解 AIR 的结构有助于理解 AIR 是如何构造的。

AIR 的结构和 ZIR 非常相似,但也有一些细微的差别:

1
2
3
4
5
pub const AIR = struct {
instructions: std.MultiArrayList(Inst).Slice,
extra: []const u32,
values: []const Value,
};

跟 ZIR 一样,AIR 在 instructions 字段中存储了一系列指令。再有,跟 ZIR 和 AST 一样,指令可能在 extra 字段存储相关的额外数据。这两个字段的工作方式跟 ZIR 和 AST 中的同名字段一样。如果你对这部分知识还不熟悉,我建议你回顾前几篇文章相关的内容,尤其是 AST 构造是如何填充 extra 字段的细节。

原注:我略过了对 MultiArrayList 的介绍,因为这部分信息在前面的文章里已经详细解释过了。

values 是 AIR 结构独有的新字段。它包含了从 ZIR 的编译时执行中获得的已知值。AIR 指令可以引用编译时已知的值。例如,如果源文件中有一个 const a = 42; 语句,那么值 42 将存储在 values 列表中,因为它是编译时已知的。我们稍后将看到更多关于编译期值的例子。

请注意,诸如字符串常量的字符串表等字段不存在。AIR 构建过程和使用 AIR 的代码生成过程仍然可以访问 ZIR 指令序列,也就可以访问 ZIR 的字符串表中的数据。

剖析单条 AIR 指令

Inst 代表了单条 AIR 指令的结构:

1
2
3
4
pub const Inst = struct {
tag: Tag,
data: Data,
};

这个结构跟 ZIR 的 Inst 结构是一致的:枚举类型的 tag 字段 + 标签对应的 data 字段。AIR 的标签和数据类型不同于 ZIR 的类型,但在功能上是相同的。

让我们看一个简单的例子。当语义分析确定一个值是常量时,它创建了 .constant 指令。常量标记指令的数据是 ty_pl 字段,其中包含常量的类型,而有效载荷是一个索引,指向值数组中的编译时已知值。

1
const c = 42;
1
%0 = constant(comptime_int, 42)
1
2
3
4
5
6
7
8
9
Inst{
.tag = .constant,
.data = .{
.ty_pl = . {
.ty = Type.initTag(.comptime_int),
.pl = 7, // index into values
},
},
};

上面的代码块展示了一段 Zig 代码,它对应的 AIR 文本表示和内部结构表示。注意在 AIR 中不存在 c 标识符,因为用于产生 AIR 的 ZIR 只包括赋值语句的右值。

值、类型和带类型的值

Sema 阶段经常使用以下三种类型:值(Value)、类型(Type)和带类型的值(TypedValue)。

Value 表示编译时已知的值,例如整数、结构体等。Type 是编译时已知的类型,例如 u8 等(注意,所有类型都是编译时已知的)。而 TypedValue 是一个具有确切已知类型的值:例如将值 42 与类型 u16 配对。

可能看起来有些奇怪的是,类型在 Zig 中也是有效的值。类型是类型为 type 的值。例如,在 Zig 中,可以编写 const c = u8 语句,其中 c 是类型为 type 的值,u8 是 c 的值。我们将在下面展示许多这些情况的示例,以使其更加直观。

Value 结构定义如下所示:

1
2
3
4
pub const Value = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}

一个类型(type)的值具有描述其值类型(kind)的标签。我在这里故意使用 kind 而不是 type 是因为一个值是无类型的。尽管在某些情况下,类型可以从值中直接推断出来。某些标签值没有有效载荷,而其他情况下则需要有效载荷。ptr_otherwise 字段是指向有效载荷的指针,其中包含获取该值所需的更多信息。

Payload 类型是指向更具体有效载荷类型的 Payload-typed 字段的指针。这是 Zig 中使用多态类型的一种方式。然后,Zig 代码可以使用 @fieldParentPtr 指令确定完整类型。这是 Zig 中常见的模式,详细解释超出了本文的范围。请搜索 @fieldParentPtr 指南以了解其工作原理。

译注:这段相关代码有一个大重构,可以查阅 PR-15569 了解细节。

整数

接下来看一个整数值的例子,42 可以被表示成一个 Value 结构的实例:

1
2
3
4
5
6
Value{
.ptr_otherwise = &Payload.U64{
.tag = .int_u64,
.data = 42,
},
};

原注:这个值不是完全正确的。ptr_otherwise 字段指向 Payload 结构而不是完整的 Payload.U64 结构。不过,这种表现形式能更好的展示指针背后的内容,所以我会在本文中一致使用这种形式。

可以看到,42 对应到 int_u64 标签。这是因为 int_u64 被用于表示所有可以用 u64 表示的值。这并不意味着实际使用的 42u64 类型,它可能是 u8 或者 u16 或者其他。不与 Type 关联的 Value 是无类型的。或者,如果你觉得我们这里总归是有一个类型标签,你可以认为这个值没有对应到一个准确的类型。

类型的值

Zig 的类型也是值。例如,语句 const c = u8 是完全有效的 Zig 代码:它将类型 u8 赋值到常量 c 上,而 c 本身是 type 类型的。这可能会非常令人困惑,随着类型的值在实际语义分析中的使用,它会变得更加令人困惑。因此,我将在这里提前解释。

作为一个值,常量 u8 可以用下面的 Value 表示:

1
2
3
Value{
.tag_if_small_enough = .u8_type,
};

这个值没有 payload 内容,因为它可以被 .u8_type 完全清楚的表示:值是 u8 类型。

这个值本身仍然是无类型的。在这个场景里,我们可以简单的知道值的类型,因为 u8_type 的类型除了 type 没有别的可能。但是,从技术上说,u8 对应的 Value 实例仍然是无类型的。

原注:例如,这个事实允许 Zig 在未来定义一个 inttype 关键字来代表所有整数类型,此时 u8_type 就可能被解释成 typeinttype 之一。

我们来看一个更复杂的类型的例子,const c = [4]bool 对应的 Value 表示如下:

1
2
3
4
5
6
7
8
9
Value{
.ptr_otherwise = &Payload.Ty{
.tag = .ty,
.data = Type{
// .tag = .array
// type data including element type (bool), length (4), etc.
},
},
};

这个值具有 .ty 标签(type 的缩写),这代表这个值是某种类型。值的有效载荷是一个 Type 结构,其值描述了一个由四个 bool 元素组成的数组。我们将在下一节讨论 Type 结构的细节。这里的关键点是,上述 Value 实例的值是一个数组类型,而不是一个数组值。

如果还有不明白的地方,你可以查看源代码中 Type 结构的 toValue 函数。Type 实例总是可以被转变成一个 Value 实例,因为类型在 Zig 中总是一个值,但是值不一定总是类型。

译注:原文解释了很多,反而把事情搞得有点复杂了。应该说,在绝大多数语言里,类型都不是值,它们需要各种特殊的语法来进行类型计算和泛型标记。Zig 把类型作为编译时已知的值,暴露了一系列操作类型的函数,使得泛型和函数重载可以使用编译时求值技术编写 Zig 代码完成,而不需要独特的语法。

类型

Type 结构的定义和 Value 结构是一样的,但是定义成一个新的类型以做区分:

1
2
3
4
pub const Type = extern union {
tag_if_small_enough: Tag,
ptr_otherwise: *Payload,
}

这里我们没有太多补充,因为大部分内容跟 Value 结构一样。让我们看一看 [4]bool 类型是怎么表示的,免得我们完全不介绍一个具体例子:

1
2
3
4
5
6
7
8
9
Type{
.ptr_otherwise = &Payload.Array{
.tag = .array,
.data = .{
.len = 4,
.elem_type = Type{.tag_if_small_enough = .bool},
},
}
}

带类型的值

TypedValue 其实就是一个 Type 和一个 Value 组成的结构,从而将值绑定到一个确切的类型上。只有这样,值的类型才是准确已知的:

1
2
3
4
pub const TypedValue = struct {
ty: Type,
val: Value,
};

剖析 Sema 结构

在 AstGen 阶段之后,Zig 编译器将进行 Sema 阶段,Sema 是语义分析(Semantic Analysis)的缩写。Sema 也是主要负责此阶段的结构体的名称。Sema 的源代码位于 src/Sema.zig 文件内。这是一个非常大的文件(在编写本文时超过 18000 行),它的文档注释自称为 “Zig 编译器的核心”。

译注:0.11 版本上,Sema.zig 已经超过 36000 行。

Sema 有许多公共接口,但最重要的是 analyzeBody 函数。Sema 结构体有许多用于内部状态的字段。我们不会列举所有字段,但下面列出了一些主要的字段。为了便于解释类似的字段,这些字段的顺序与源代码中的顺序不同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pub const Sema = struct {
mod: *Module,
gpa: Allocator,
arena: Allocator,
perm_arena: Allocator,

code: Zir,
owner_decl: *Decl,
func: ?*Module.Fn,
fn_ret_ty: Type,

air_instructions: std.MultiArrayList(Air.Inst) = .{},
air_extra: std.ArrayListUnmanaged(u32) = .{},
air_values: std.ArrayListUnmanaged(Value) = .{},
inst_map: InstMap = .{},

// other fields...
};

第一组字段是 Sema 过程的主要输入:

  • gpa 用于分配在 Sema 过程和声明生命周期之后仍然存在的数据。
  • arena 用于分配在 Sema 之后将被释放的临时数据。
  • perm_arena 用于分配与正在进行语义分析的声明的生命周期相关联的数据。
  • mod 是正在分析的模块。本文内容不涵盖模块,但模块封装了单个程序中的所有 Zig 代码。本文将忽略所有跟模块相关的接口。

第二组字段是 Sema 进行语义分析的输入:

  • code 是包含正在分析的声明的文件的 ZIR 指令序列。
  • owner_decl 通常是当前正在分析的声明,例如函数、comptime 块、测试等。

当分析函数时,funcfn_ret_ty 存储了可能得额外信息。

第三组字段是 Sema 过程的输出。这些输出主要是构建 Air 结构的字段。这些字段在整个 Sema 过程中被填充,并用于构建最终的 Air 结果。

其中,inst_map 字段最为重要。它是一个 ZIR 到 AIR 的映射,在整个 Sema 过程中都会使用。并非所有的 ZIR 指令都会产生 AIR 指令,但是 Sema 还是经常使用这个映射以使 AIR 指令可以引用稍后解析的特定 ZIR 指令:例如加法操作数、函数参数等。

分析函数体

Sema 结构上的核心接口是 analyzeBody 函数。这个函数用于分析函数体、循环体或代码块体等对应的 ZIR 指令序列,并产生对应的 AIR 指令序列。最简单的例子是函数体。下面是一个具体的例子:

1
2
3
export fn add() u32 {
return 40 + 2;
}
1
2
3
4
5
6
7
8
9
# Begin Function AIR: add:
%1 = constant(comptime_int, 40)
%2 = constant(comptime_int, 2)
%3 = constant(comptime_int, 42)
%4 = constant(u32, 42)

%0!= dbg_stmt(2:5)
%5!= ret(%4!)
# End Function AIR: add

原注:可以使用 zig build-obj --verbose-air example.zig 指令打印导出函数的 AIR 指令序列。

从上述 AIR 指令序列中,我们可以直观地知道发生了什么事情。粗略地说,这些指令大致执行了以下步骤:

  1. 我们看到一个无类型的整数常数 40 - 对应 %1 指令。
  2. 我们看到一个无类型的整数常数 2 - 对应 %2 指令。
  3. 我们执行了编译时求值,得到加法的结果:无类型的整数常数 42 - 对应 %3 指令。
  4. 常量 42 关联到类型 u32 上(%4 指令)。这不需要任何转换,因为值 42 可以自动匹配到 u32 类型上。这个类型关联是必要的,因为返回类型被定义成 u32 类型。
  5. 返回 %4 输出的值 - 对应 %5 指令。

太棒了!这里有一些冗长之处,但很明显可以看出 add 函数的编译过程。此外,可以看到在这个阶段进行了编译时求值:40 + 2 对应的加法操作已经完成,因此结果已经预先知道。当这段代码最终被转换为机器代码时,实际的加法结果已经预先计算好了。

译注:在新版 Zig 编译器中,所有中间 AIR 都能被优化,最终只产生一条 %4!= ret(<u32, 42>) 指令。

接下来,让我们逐步了解这个 AIR 是如何生成的。

逐步分析函数

函数体 AIR 主要通过 analyzeBodyInner 生成。这个函数会按顺序迭代分析每个 ZIR 指令,并为每条 ZIR 指令生成零或多条 AIR 指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const result = while (true) {
const inst = body[i];
const air_inst: Air.Inst.Ref = switch (tags[inst]) {
.alloc => try sema.zirAlloc(block, inst),
.alloc_inferred => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_mut => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_mut)),
.alloc_inferred_comptime => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_const)),
.alloc_inferred_comptime_mut => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_mut)),
.alloc_mut => try sema.zirAllocMut(block, inst),
// hundreds more...
};

try sema.inst_map.put(sema.gpa, inst, air_inst);
i += 1;
}

这段代码清楚展示了 ZIR 翻译成 AIR 的过程。你可以打印 Zig 程序对应的 ZIR 指令序列并逐个分析每个指令如何生成对应的 AIR 指令。上述 add 函数的 ZIR 指令序列如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
%0 = extended(struct_decl(parent, Auto, {
[25] export add line(6) hash(4ca8d4e33898374bdeee80480f698dad): %1 = block_inline({
%10 = func(ret_ty={
%2 = break_inline(%10, @Ref.u32_type)
}, body={
%3 = dbg_stmt(2, 5)
%4 = extended(ret_type()) node_offset:8:5
%5 = int(40)
%6 = int(2)
%7 = add(%5, %6) node_offset:8:15
%8 = as_node(%4, %7) node_offset:8:15
%9 = ret_node(%8) node_offset:8:5
}) (lbrace=1:21,rbrace=3:1) node_offset:7:8
%11 = break_inline(%1, %10)
}) node_offset:7:8
}, {}, {})

其中,函数体从 %3 指令开始,到 %9 指令结束。因此,analyzeBodyInner 分析 add 函数体时,看到的第一条指令是 %3 指令。%2%10 将会在早些时候被分析,我们会在后面讨论这个过程。

%3: dbg stmt

第一条分析的指令是一个 .dbg_stmt 指令。在 analyzeBodyInner 的主循环代码中,我们可以看到这将引导向 zirDbgStmt 分支。该分支的代码经简化后如下所示:

1
2
3
4
5
6
7
8
9
10
fn zirDbgStmt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!void {
const inst_data = sema.code.instructions.items(.data)[inst].dbg_stmt;
_ = try block.addInst(.{
.tag = .dbg_stmt,
.data = .{ .dbg_stmt = .{
.line = inst_data.line,
.column = inst_data.column,
} },
});
}

这是一个很好的、简单的翻译 ZIR 到 AIR 的例子。没有比这更简单的了。在这种情况下,dbg_stmt ZIR 指令几乎一比一的被翻译成 dbg_stmt AIR 指令。这生成了之前显示的 %0 AIR 指令:

1
%0!= dbg_stmt(2:5)

%4: extended(ret type())

类似地,ZIR 指令 .extended 会引导调用 zirExtended 函数,其主循环分析子操作码(child opcode)并在遇到 .ret_type 时调用 zirRetType 函数。zirRetType 函数内容如下:

1
2
3
4
5
6
7
8
9
fn zirRetType(
sema: *Sema,
block: *Block,
extended: Zir.Inst.Extended.InstData,
) CompileError!Air.Inst.Ref {
const src: LazySrcLoc = .{ .node_offset = @bitCast(i32, extended.operand) };
try sema.requireFunctionBlock(block, src);
return sema.addType(sema.fn_ret_ty);
}

被分析函数中的返回类型可以在 Sema 的 fn_ret_ty 字段中找到。addType 函数将为类型定义添加一条指令。对于我们的函数,结果类型是 u32 类型。这是一个常用类型,因此不会生成额外的指令。

为了进行实验,如果您将返回值更改为一个不常用的类型 u9 那么 AIR 会生成以下指令:

1
%5 = const_ty(u9)

%5: int(40)

下一条指令是对应常数 40 的 %5 指令。它会引导调用 zirInt 函数。zirInt 函数读取整数值,并调用 addConstant 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn zirInt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.Inst.Ref {
const int = sema.code.instructions.items(.data)[inst].int;
return sema.addConstant(ty, try Value.Tag.int_u64.create(sema.arena, int));
}

pub fn addConstant(sema: *Sema, ty: Type, val: Value) SemaError!Air.Inst.Ref {
const gpa = sema.gpa;
const ty_inst = try sema.addType(ty);
try sema.air_values.append(gpa, val);
try sema.air_instructions.append(gpa, .{
.tag = .constant,
.data = .{ .ty_pl = .{
.ty = ty_inst,
.payload = @intCast(u32, sema.air_values.items.len - 1),
} },
});
return Air.indexToRef(@intCast(u32, sema.air_instructions.len - 1));
}

addConstant 函数在 Sema 阶段的很多地方都会被调用,用于加入一个编译时已知的常量。其内部逻辑首先通过 addType 添加对应到最后一条指令的类型。对于我们这里的例子,它是 comptime_int 类型。因为它是一个常用类型,所以没有额外的指令产生。紧接着,常量值被加入到 air_values 中。最后,constant AIR 指令被加入到 air_instructions 中。最终结果就是下述 AIR 指令:

1
%1 = constant(comptime_int, 40)

需要注意的一点是,.constant 指令的有效负载引用了 air_values 中的索引。所有在编译时已知的值都存储在 air_values 中,指令中的任何引用都存储为 air_values 切片中的索引。

指令 %6 不做讨论,因为它跟 %5 基本相同,只是有一个不同的常量值。

%7: add(%5, %6)

下一条指令是我们遇到的第一条有实际逻辑操作的指令:执行加法。.add 指令引导调用 zirArithmetic 函数。这个函数用于许多二元数学操作中,其函数体如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn zirArithmetic(
sema: *Sema,
block: *Block,
inst: Zir.Inst.Index,
zir_tag: Zir.Inst.Tag,
) CompileError!Air.Inst.Ref {
const inst_data = sema.code.instructions.items(.data)[inst].pl_node;
sema.src = .{ .node_offset_bin_op = inst_data.src_node };
const lhs_src: LazySrcLoc = .{ .node_offset_bin_lhs = inst_data.src_node };
const rhs_src: LazySrcLoc = .{ .node_offset_bin_rhs = inst_data.src_node };
const extra = sema.code.extraData(Zir.Inst.Bin, inst_data.payload_index).data;
const lhs = sema.resolveInst(extra.lhs);
const rhs = sema.resolveInst(extra.rhs);

return sema.analyzeArithmetic(block, zir_tag, lhs, rhs, sema.src, lhs_src, rhs_src);
}

这段代码中,需要特别关注 lhs 和 rhs 的复制。resolveInst 函数用于查找给定 ZIR 指令的 AIR 指令索引。因此,对于给定的 %5%6 这两条 ZIR 指令,分别将 lhs 和 rhs 设置为它们的结果 AIR 索引。这些 AIR 指令是在先前的循环迭代中创建 .constant 指令时设置的。

ZIR 到 AIR 的映射在 analyzeBodyInner 循环的 inst_map 字段中维护。虽然也有其他一些逻辑可以更新这个状态,但是 body 循环是主要的逻辑。

下一步,代码逻辑进到 analyzeArithmetic 函数里。这是一个很长的函数,几乎所有代码都用于确定是否能够对此算术操作进行编译时分析。下面是关键的代码行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const maybe_lhs_val = try sema.resolveMaybeUndefVal(block, lhs_src, casted_lhs);
const maybe_rhs_val = try sema.resolveMaybeUndefVal(block, rhs_src, casted_rhs);

if (maybe_lhs_val) |lhs_val| {
if (maybe_rhs_val) |rhs_val| {
if (is_int) {
return sema.addConstant(
scalar_type,
try lhs_val.intAdd(rhs_val, sema.arena),
);
}
}
}

return block.addBinOp(.add, casted_lhs, casted_rhs);

这段代码首先调用 resolveMaybeUndefVal 函数。它接受一个 AIR 指令的索引,并尝试加载其编译时已知的值。这个表达式返回一个可空值,因为如果该值不能在编译时确定,则返回 null 值。

接下来,我们尝试解包这些可空值。如果我们能够找到 lhs 和 rhs 的编译时已知值,并且它们都是整数类型,那么我们可以进行编译时加法,得到最终的常量。这就是我们的程序生成结果为 42 的 .constant AIR 指令的过程:

1
%3 = constant(comptime_int, 42)

我在代码中包含了 block.addBinOp 语句,以避免值不是编译时已知的情况。这个语句将添加一个 .add AIR 指令进行运行时计算(而不是编译时)。为了进行实验:我们将常量 2 更改为变量,例如 var b: u32 = 2 语句,并将其用于加法运算。这将产生一个 .add 操作,因为变量不能在编译时操作。

%8: as_node(%4, %7)

下一条指令实现了安全类型转换。从 ZIR 的角度来看,这将加法结果转换为返回类型的值。具体来说,根据已知信息,我们需要将编译时整数 42 转换为 u32 类型。

.as_node 指令引导调用 zirAsNode 函数,最终调用到 coerce 函数的核心逻辑。coerce 函数在 Sema 过程中被广泛用于执行从一种类型到另一种类型的安全类型转换。

我将让读者自行研究这个函数。逻辑相当直接,但由于需要处理许多类型转换情况,所以会比较冗长。对于 comptime_int 到 u32 的转换,他首先确定该值能被 u32 类型表示,并将该值原样返回。类型转换不需要进行额外的处理。

%9: ret_node(%8)

最后,返回指令在 ZIR 中被编码为 ret_node 指令。这将引导调用 zirRetNode 函数,它创建一个带有结果的 .ret AIR 指令。创建该指令的所有相关知识点前面都已经讲过。

zirRetNode 函数始终返回 always_noreturn 值。这个值强制 analyzeBodyInner 循环退出,从而完成函数体 AIR 生成。

原注:返回语句是否总是完成函数体 AIR 生成?如果多个返回语句,又该怎么办?

返回语句总是完成当前块的 AIR 生成。不可达代码是非法的,并在 AstGen 期间被捕获,这意味着多个返回语句的唯一方式是它们位于不同的块中。因此,在函数的上下文中,多个返回语句是可以的,因为它将递归进入多个 analyzeBodyInner 调用。

编译时不可知

上面第一个示例有点无聊,因为所有的逻辑都是编译时可知的。编译时可知仅适用于 const 值,而不适用于 var 值,因此我们可以通过使用 var 来查看运行时加法的 AIR 指令序列:

1
2
3
4
export fn add() u32 {
var b: u32 = 2;
return 40 + b;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Begin Function AIR: add:
%2 = constant(comptime_int, 2)
%3 = constant(u32, 2)
%6 = constant(comptime_int, 40)
%8 = constant(u32, 40)

%0!= dbg_stmt(2:5)
%1 = alloc(*u32)
%4!= store(%1, %3!)
%5!= dbg_stmt(3:5)
%7 = load(u32, %1!)
%9 = add(%8!, %7!)
%10!= ret(%9!)
# End Function AIR: add

译注:新版 Zig 编译器会报错 var b: u32 = 2 应该是一个常量,需要其他手段来写一个真正的变量做这个实验。

将常量 2 更改为变量赋值的值 2 会产生更多的 AIR 指令!现在我们有了使用 .alloc 进行分配的指令、使用 .store 进行值存储的指令,还可以看到运行时的 .add 操作。

这是一个很好的程序,可以用来学习和追踪 AIR 指令的生成过程。由于它遵循了我们之前示例中的许多相同的代码路径,我将把这个函数编译过程的分析作为读者的练习。

编译时目标平台仿真

对于编译时计算的代码,Zig 编译器将在必要时模拟目标平台的特性。这是一个至关重要的功能,使得在 Zig 中进行编译时计算变得安全且可行。

@floatCast 的实现中可以看到这个功能的一个例子:

1
2
3
4
5
6
7
8
const target = sema.mod.getTarget();
const src_bits = operand_ty.floatBits(target);
const dst_bits = dest_ty.floatBits(target);
if (dst_bits >= src_bits) {
return sema.coerce(block, dest_ty, operand, operand_src);
}

return block.addTyOp(.fptrunc, dest_ty, operand);

该函数通过 getTarget 获取有关目标平台的信息,然后确定目标平台上浮点数支持的位数。如果目标类型的位数多于源类型,可以安全地强制转换类型。否则,浮点数将被截断。

完成语义分析过程

Sema 过程为每个函数调用一次,且仅针对每个被引用的声明调用一次,而不是遍历每个声明。为了确定所有被引用的声明,Sema 从处理入口点函数开始查找。

这实现了 Zig 的延迟分析能力,意味着除非引用了声明,否则某些错误不会产生编译器错误。这个特性使 Zig 的编译过程非常快速,生成的代码更小,因为只编译了被引用的代码,但有时会导致令人困惑的行为。

译注:为了绕过这里的引用问题,Zig 有一个内部开洞的 std.testing.refAllDecls 函数。可以阅读 ISSUE-12838 了解更多细节。

基于本文介绍的基本知识,你现在应该能够跟踪任何 Zig 程序并确定它如何转换为 AIR 指令序列。请记住经常使用 zig ast-checkzig build-obj --verbose-air 命令来查看 Zig 编译器生成的内容。

语义分析完成后,AIR 指令序列将传递给 CodeGen 过程,并在该过程中被转换为最终格式。CodeGen 是编译器前端和多个后端之间的边界。后端可以是 LLVM 或诸如 WASM 之类的本机后端。

Zig 中间表示

作者 tison
2023年12月12日 08:00

本文承接《Zig 词法分析和语法解析》的内容,继续讨论 Zig 程序编译的下一步:从抽象语法树(AST)中生成中间表示(IR)。

本文翻译自 Mitchell Hashimoto 关于 Zig 的系列博客第三篇:

翻译本文的过程中,我越来越回想起自己使用 Perl 6 做编译实习作业的时候。通过 Perl 6 内嵌的 Grammar 语法,我基本把词法分析和语法分析的内容给快速解决了。余下的大部分时间都在完成从 AST 到课程定义的中间表示的翻译,数据流分析和生成 riscv 汇编代码的工作。应该说,北京大学仿照虎书内容做的编译实习课程还是很有含金量的。

感兴趣的读者可以查看我当时的代码仓库,其中包括一个完整的 PDF 报告。

以下原文。

构建出抽象语法树(AST)后,许多编译器的下一步是生成中间表示(IR)。AST 是一棵树,而 IR 通常开始为程序的各个块,例如文件、函数等,创建一系列指令。这种指令序列的格式更容易进行优化分析和转换为可执行的机器码。

Zig 编译器具有多个中间表示。AST 首先通过 AstGen 的阶段转换为 Zig 中间表示(ZIR)。ZIR 是一种无类型的 IR 形式。每个 Zig 文件对应一系列 ZIR 序列。

ZIR 是什么样的?

讨论 ZIR 的结构及其创建过程之前,我们先看一个简单的例子来初步建立对 ZIR 的印象。目前,你不需要理解下面展示的 ZIR 是什么含义。

给定一个简单的 Zig 源文件:

1
2
3
4
5
const result = 42;

export fn hello() u8 {
return result;
}

其对应的 ZIR 指令序列打印如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
%0 = extended(struct_decl(parent, Auto, {
[24] result line(0) hash(193c9ec04fd3f33e5e849a85f0c3b7fd): %1 = block_inline({
%2 = int(42)
%3 = break_inline(%1, %2)
}) node_offset:1:1
[32] export hello line(2) hash(322ad1340dd6faf97c80f152e26dfdd4): %4 = block_inline({
%11 = func(ret_ty={
%5 = break_inline(%11, @Ref.u8_type)
}, body={
%6 = dbg_stmt(2, 5)
%7 = extended(ret_type()) node_offset:4:5
%8 = decl_val("result") token_offset:4:12
%9 = as_node(%7, %8) node_offset:4:12
%10 = ret_node(%9) node_offset:4:5
}) (lbrace=1:22,rbrace=3:1) node_offset:3:8
%12 = break_inline(%4, %11)
}) node_offset:3:8
}, {}, {})

ZIR 只是一个内部中间表示,并不需要是一个稳定的格式,因此今天的 Zig 编译器未必对上述源文件产生和这里展示的 ZIR 指令序列相同的输出。不过,对于 ZIR 的初见来说,上面展示的内容应该是足够接近的了。

需要注意的是,ZIR 将程序的实际逻辑分解为更细粒度的指令。ZIR 指令可以通过它们的索引进行引用。如上面所示,可以使用 % 符号来索引。例如,指令 9 将结果引用转换为 hello 函数的返回类型。

原注:如何打印 ZIR 指令序列?

如果你从源码中用 DEBUG 模式构建得到了 Zig 编译器,你可以通过 zig ast-check -t <file> 命令生成任意 Zig 源文件对应的 ZIR 指令序列。编写一些简单的 Zig 程序并使用这一技巧生成对应的 ZIR 指令序列,是学习 AstGen 步骤的一个很好的方法。

为什么 ZIR 是无类型的?

ZIR 是一种无类型的中间格式。这并不意味着 ZIR 不知道类型,而是类型没有被完全计算。Zig 有两个语言定义特性与此相关。其一是 Zig 将类型作为一等值,其二是 Zig 将编译时求值作为泛型类型的机制。因此,在 AST 和 ZIR 格式中,类型可能仍然是未知的。

这很容易 ZIR 输出示例中验证。首先,让我们看一个大部分已经确定类型的形式:

1
const result: u32 = 42;
1
2
3
4
5
6
7
%0 = extended(struct_decl(parent, Auto, {
[10] a line(0) hash(63c8310741c228c128abf6692d07292d): %1 = block_inline({
%2 = int(42)
%3 = as_node(@Ref.u32_type, %2) node_offset:1:16
%4 = break_inline(%1, %3)
}) node_offset:1:1
}, {}, {})

指令 %3 将无类型的 42 转换为 u32 类型。在这个简单的例子里,ZIR 看起来是完全具备类型的。

不过,仍然有不具备类型的指令。例如,指令 %2 没有对应的类型。虽然它对应了一个值为 int(42) 的常量,但是这个常量仍然可以被解释成 u8/u16/u32 等类型。直到 Zig 代码将无类型常量分配给有类型常量时,ZIR 才会产生类型强制转换指令,即 as_node 指令。

接下来,我们看一个明确无类型的形式:

1
2
const t = bool;
const result: t = 42;
1
2
3
4
5
6
7
8
9
10
11
12
%0 = extended(struct_decl(parent, Auto, {
[16] t line(0) hash(243086356f9a6b0669ba4a7bb4a990e4): %1 = block_inline({
%2 = break_inline(%1, @Ref.bool_type)
}) node_offset:1:1
[24] result line(1) hash(8c4cc7d2e9f1310630b3af7c4e9162bd): %3 = block_inline({
%4 = decl_val("t") token_offset:2:10
%5 = as_node(@Ref.type_type, %4) node_offset:2:10
%6 = int(42)
%7 = as_node(%5, %6) node_offset:2:14
%8 = break_inline(%3, %7)
}) node_offset:2:1
}, {}, {})

result 的类型是常量 t 被赋予的值。这种情况下,常量 t 的值是显而易见的。但是 Zig 允许为 t 赋值为任何编译时表达式。只要所有值都是编译时已知的,那么这意味着 t 的值几乎可以是任意 Zig 代码执行的结果。这是 Zig 的一个非常强大的特性。

鉴于这种动态的可能性,你可以看到分配给 result 的 ZIR 更加复杂。指令 %4%5 加载由 t 标识的值,并将其强制转换为类型 type 的值。type 是类型对应的类型,而不是值的类型。然后指令 %7 做 as_node 强制转换,但这次类型操作数引用的是 ZIR 指令 %5 的结果,而不是静态值。

最后,我们看一个极端的例子:

1
2
const std = @import("std");
const result: std.ArrayListUnmanaged(u8) = .{};

这里我们不展示这两行代码对应的 ZIR 指令序列,因为它将是一大段文本。在这个例子中,result 的类型是通过函数 ArrayListUnmanaged 编译时求值生成的泛型类型。ZIR 无法指向 result 的预定义类型,因为在编译时求值之前根本不知道它的类型。

这就是为什么 ZIR 是无类型的:ZIR 是用于编译时求值和进一步语义分析的预备中间形式。在编译时求值阶段后,所有的类型都将被确定,并且可以形成完全具备类型的中间表示(AIR)。

AstGen 过程的解析

AstGen 是一个把 AST 转换为 ZIR 的阶段,其源代码位于 src/AstGen.zig 文件中。AstGen 不是一个公开导出的结构体,它只用于管理 ZIR 生成过程的内部状态。外部调用方调用的是 generate 函数,该函数接受一个 AST 树并返回整个树的 ZIR 指令序列。

AstGen 结构体有许多用于内部状态的字段。我们不会列举所有的字段,但下面显示了一些重要的字段。以下结构体的字段顺序与源代码中的顺序不同:

1
2
3
4
5
6
7
8
9
10
11
const Astgen = struct {
gpa: Allocator,
arena: Allocator,
tree: *const Ast,

instructions: std.MultiArrayList(Zir.Inst) = .{},
extra: ArrayListUnmanaged(u32) = .{},
string_bytes: ArrayListUnmanaged(u8) = .{},

// other fields, not covered...
};

第一组字段是 AstGen 过程的输入:

  • gpa 用于分配在 AstGen 过程之外还需存活的数据
  • arena 用于分配仅在 ZIR 生成期间使用的临时数据,这些数据在返回 ZIR 之前被释放
  • tree 是要转换为 ZIR 的 AST 示例

原注:为什么 arena 叫这个名字?

Arena allocator 是一种内存分配器的分类,它一次性分配和释放整个内存区域,而不是逐个追踪和释放单个项。它通常用于具有共同生命周期的分配,因为相比于追踪单个项,释放整个内存块要更容易且性能更好。有关分配器和 Zig 的基础知识,可以参考 What’s a Memory Allocator Anyways? 主题演讲。

第二组字段是 AstGen 过程的输出。这一组字段非常重要,因为它是生成的 ZIR 的核心结构:

  • instructions 存储 ZIR 指令序列,该序列中的每个条目对应一个单独的指令。例如,前文 ZIR 输出中,这一字段的第 9 个条目将是 as_node(%7, %8) 指令。
  • extra 是 ZIR 指令可能需要存储的额外数据。这与 AST 节点及其 extra_data 字段遵循相同的模式。如果您不了解 AST 中 extra_data 的存储访问模式,请查看上一篇文章进行复习,因为在 AstGen 过程中到处都使用了这种模式!
  • string_bytes 是用于标识符、字符串字面量、文档注释等的字符串池。所有静态字符串都作为 AstGen 的一部分进行了池化,并且 ZIR 指令引用了该字符串字节列表中的偏移量,而不是存储字符串本身的副本。

ZIR 指令结构的解析

在深入介绍 AST 转换为 ZIR 的过程之前,我将花费大量篇幅来讨论单个 ZIR 指令的格式。我建议仔细阅读和理解本节,因为这是理解 ZIR 构建的基本构件。

ZIR 实际上是一个指令列表。ZIR 指令的格式与 AST 节点使用的模式很相似,因此本文可能会忽略一些结构细节。在这些情况下,我将特别指出结构之间的相似之处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub const Inst = struct {
tag: Tag,
data: Data,

pub const Tag = enum(u8) {
add,
addwrap,
// many more...
};

pub const Data = union {
// various fields that can be set depending on tag
};
};

这个结构跟 AST 节点非常相似。tag 的存储访问模式与 AST 节点相同,但标签是完全不同的集合。例如,整数字面量的 AST 是 .integer_literal 而无论整数的大小如何。但在 ZIR 中,具体取决于整数是否在 u64 表示范围内,对应的 tag 将会是 .int.int_big 等。

每个指令都与数据相关联,数据存储都 Data 联合字段上。Data 联合中的有效字段取决于标签值。数据可以直接存储在 Data 字段中,例如整数字面量值。也有可能 Data 字段是指向其他信息的位置的引用。

与 AST 节点类似,ZIR 也有一个 extra 字段。它的行为与 AST 节点的 extra_data 字段几乎相同:一些数据条目可能指向 extra 字段中的一个条目,从那里开始的一些字段将包含与 ZIR 指令相关的特定值。有关更多详细信息,请参阅 Zig 语法解析器源码以及下面的示例。

静态值

最简单的 ZIR 指令是存储静态值的指令,让我们看一个例子:

1
const x = 42;
1
2
3
4
5
6
%0 = extended(struct_decl(parent, Auto, {
[7] x line(0) hash(5b108956afe84d38689dcd3f2d652f04): %1 = block_inline({
%2 = int(42)
%3 = break_inline(%1, %2)
}) node_offset:1:1
}, {}, {})

静态值对应的是 %2 指令(int(42))。如你所见,静态值 42 被直接内联到指令当中。这个指令对应的内部结构如下:

1
2
3
4
5
6
Zir.Inst{
.tag = .int,
.data = .{
.int = 42,
},
}

这是所有可能的 ZIR 指令中最简单形式:数据直接嵌入到指令中。在这种情况下,活跃数据的标签是 .int 标签,活跃数据是内联的静态整数值。

更常见的情况是数据是一个引用,或者标签包含更复杂的信息,无法直接适应 Inst 结构。下面是一些示例。

引用

许多 ZIR 指令包含对值的引用。例如,一元运算符 ! 可以是任何表达式的前缀,如静态值 !true 或变量 !myVar 或函数调用 !myFunc() 等。理解 ZIR 如何编码引用非常重要,因为它们非常常见。

ZIR 引用对应到 Zir.Inst.Ref 类型,这是一个未穷尽的枚举。类型定义的标签用于表示原始值或非常常见的值。否则,该值是对另一个 ZIR 指令索引的引用。

带标签的引用

让我们看一个具体的例子:

1
const x = !true;
1
2
3
4
5
6
%0 = extended(struct_decl(parent, Auto, {
[7] result line(0) hash(0be0bb45d9cb29941abcc19d4176dde6): %1 = block_inline({
%2 = bool_not(@Ref.bool_true) node_offset:1:16
%3 = break_inline(%1, %2)
}) node_offset:1:1
}, {}, {})

关注指令 %2 的内容,它包括一个对应 ! 运算符的 bool_not 指令,而 @Ref.bool_true 为其参数。bool_not 指令接受一个参数,它大致的内部表示如下(部分字段省略,它们跟本例关系不大):

1
2
3
4
5
6
Zir.Inst{
.tag = .bool_not,
.data = .{
.un_node = .{ .operand = Ref.bool_true },
},
}

Ref.bool_true 是表示静态值 true 的一个 Ref 枚举的标签。Ref 枚举还有许多其他标签。例如,每种内置类型都有一个标签。一个具体地例子是,尽管下面的示例毫无意义,但它确实产生有效的 ZIR 指令(编译器稍后会报错):

1
const x = !u8; // ZIR: bool_not(@Ref.u8_type)

对于常见的原始类型和值,ZIR 使用带标签的 Ref 来表示。

指令的引用

对于不常见的值,Ref 代表的是一个指令索引。下面是一个具体的例子:

1
2
const input = true;
const x = !input;
1
2
3
4
5
6
7
8
9
10
11
%0 = extended(struct_decl(parent, Auto, {
[13] input line(0) hash(1af5eb6ed7d8836d8d54ff390bb38c7d): %1 = block_inline({
%2 = break_inline(%1, @Ref.bool_true)
}) node_offset:1:1
[21] result line(1) hash(4ba2a2df9e05a2d7963b6c1eb80fdcea): %3 = block_inline({
%4 = decl_val("input") token_offset:2:17
%5 = as_node(@Ref.bool_type, %4) node_offset:2:17
%6 = bool_not(%5) node_offset:2:16
%7 = break_inline(%3, %6)
}) node_offset:2:1
}, {}, {})

%6 是查找指令引用的关键指令。我们看到了熟悉的 bool_not 指令,但这一次它的参数是 %5 即另一条指令。通读所有的指令序列,你应该能凭直觉理解到这个参数对应从 input 变量中读到的值。

这段 ZIR 指令的内部表示大致表示如下:

1
2
3
4
5
6
Zir.Inst{
.tag = .bool_not,
.data = .{
.un_node = .{ .operand = Ref.typed_value_map.len + 5 },
},
}

要确定 Ref 值是标记还是指令索引,我们可以检查该值是否大于标记的数量。如果是,则值减去标记长度等于指令。这个操作非常常用,因此 Zig 内部定义了 indexToRefrefToIndex 两个辅助函数来执行对应操作。在上面的示例中,操作数 %5 对应的是 indexToRef(5) 的值。如果调用 refToIndex(operand) 则可以得到 5 作为返回值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const ref_start_index: u32 = Inst.Ref.typed_value_map.len;

pub fn indexToRef(inst: Inst.Index) Inst.Ref {
return @intToEnum(Inst.Ref, ref_start_index + inst);
}

pub fn refToIndex(inst: Inst.Ref) ?Inst.Index {
const ref_int = @enumToInt(inst);
if (ref_int >= ref_start_index) {
return ref_int - ref_start_index;
} else {
return null;
}
}

译注:这两个函数现在重构了,分别变成了 Zir.Inst.Index.toRefZir.Inst.Ref.toIndex 方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
pub const Index = enum(u32) {
// ...
pub fn toRef(i: Index) Inst.Ref {
return @enumFromInt(@intFromEnum(Index.ref_start_index) + @intFromEnum(i));
}
}

pub const Ref = enum(u32) {
// ...

pub fn toIndex(inst: Ref) ?Index {
assert(inst != .none);
const ref_int = @intFromEnum(inst);
if (ref_int >= @intFromEnum(Index.ref_start_index)) {
return @enumFromInt(ref_int - @intFromEnum(Index.ref_start_index));
} else {
return null;
}
}
}

额外数据

一些 ZIR 指令包含对额外字段的引用,有时也称为“尾部”数据。这与 AST 节点中的 extra_data 字段非常相似,因此在这里不会详细介绍其编解码过程。如果您对 AST 节点中的 extra_data 字段不熟悉或不理解,我建议您复习一下该部分。

我们来看一个额外数据的例子:

1
const x = 1 + 2;
1
2
3
4
5
6
7
%0 = extended(struct_decl(parent, Auto, {
[10] x line(0) hash(48fa081b63af0a1c7f2d11a7bf9fbbc3): %1 = block_inline({
%2 = int(2)
%3 = add(@Ref.one, %2) node_offset:1:13
%4 = break_inline(%1, %3)
}) node_offset:1:1
}, {}, {})

这个输出的 ZIR 指令包括了我们在上面已经学到的一切:指令 %2 是一个静态值,指令 %3 中的 @Ref.one 是一个带标签的引用,指令 %3 中的 %2 引用了另一个指令。

二进制加法操作使用了额外字段。在 ZIR 指令的文本输出中,这并不明显,因为 ZIR 渲染器理解 add 指令并从额外字段(指令 %3)中以美化形式打印了数据。实际上,它的内部表示如下:

1
2
3
4
5
6
7
8
9
10
// Instruction
Zir.Inst{
.tag = .add,
.data = .{
.pl_node = .{ .payload_index = 7 },
},
}

// Exra data
[ ..., Ref.one, %2, ... ]

该指令使用了 pl_node 数据标签(pl_node 是 payload node 的缩写)。其中包含一个 payload_index 字段,它指向 extra 数组中附加数据的起始位置。通过查看 .add 标签的注释,可以了解到 extra 字段存储了一个 Zir.Inst.Bin 结构,该结构包含 lhs 和 rhs 字段。在这种情况下,我们有:

  • lhs = Ref.one
  • rhs = %2

跟 AST 节点类似,AstGen 源代码包含一个名为 addExtra 的辅助函数,以类型安全的方式编码结构化的额外数据。

额外数据本身可以包含静态值或其他 Zig.Inst.Ref 值等等。你需要查看每个标签的源代码才能理解它所编码的值。

其他数据类型

ZIR 还有许多其他的数据类型,但是我不会在本文中穷举其所有。我个人的感受是,上面这些类型囊括了编译器中编码 ZIR 指令的信息的绝大部分模式。

AstGen 的组件

AstGen 过程中有许多用于构建 ZIR 的共享组件。这些组件包含了通用的共享逻辑,了解这些逻辑对于理解 AstGen 的完整行为非常关键。

这些组件大多数是 AstGen 中函数的参数,下面介绍其中的一部分。我们不会介绍 AstGen 的所有功能,但是会强调一些关键要点。

作用域

AstGen 可以感知作用域,这使它可以引用父级作用域中的标识符,在使用未定义的标识符时抛出错误,或检测标识符被隐藏的问题。

AstGen.zig 文件中的 Scope 结构定义了作用域。作用域是多态的,可以是多个子类型之一。Zig 使用 @fieldParentPtr 模式来实现这一点。解释这个模式超出了本文的范围,但它 Zig 中很常见。

译注:以下材料可以帮助理解 @fieldParentPtr 模式。

在撰写本文时,有七种作用域类型:

  • Scope.Top 表示文件的最外层作用域。它始终是最顶层的作用域,没有父级作用域。该作用域不跟踪任何附加数据。
  • GenZir 通常表示 Zig 中的一个块,但也用于跨 AST 节点进行大量的额外状态跟踪。它跟踪当前块标签(如果有)、指令列表、是否在编译时位置等。
  • Scope.Namespace 该作用域包含一个无序的可以进行引用的声明集合。这里的关键词是“无序”。该作用域通常是结构体、联合等块的子作用域。例如:结构体变量可以引用文件中稍后定义的变量,而函数体则不行。因为结构体是命名空间,但函数体不是。
  • Scope.LocalValScope.LocalPtr 表示已定义的单个标识符,例如变量或常量。随着标识符的定义,这将创建一个新的该类型的子作用域,使其“在作用域内”。这与命名空间不同,因为它表示确切的一个声明,而不是一个无序列表。
  • Scope.Defer 表示 defer 或 errdefer 周围的作用域。它用于跟踪 defer 的存在,以在所有的退出点生成指令。

作用域具有一个 parent 字段,可用于通过作用域向上遍历。这就是标识符解析的工作原理。

字符串内化

字符串(字节数组)不直接存储在 ZIR 指令中。字符串会被内化,并存储在一个连续的 string_bytes 数组中。字符串的起始索引存储在 ZIR 指令中。这意味着共享的字符串(例如标识符)只存储一次。

到目前为止,ZIR 是编译流水线上第一个存储字符串的结构。AST 节点存储 Token 索引,而 Token 存储其在源代码中的起始和结束偏移量。这要求 AST 和源代码保持可用。在 AstGen 之后,可以释放 AST 和源代码。这个设计使得编译器可以解析非常大的 Zig 程序,并且只在内存中存储所需的内容。

结果的位置

ResultLoc 结构用于跟踪表达式树的最终结果应该写入的位置。在遍历 AST 并生成 ZIR 时,某些写操作的值可能嵌套得很深,AstGen 需要知道最终值应该写入的位置。

考虑以下示例:

1
2
3
4
5
6
const x = blk1: {
switch (someUnion) {
.someTag => break :blk1 42,
else => break :blk1 0,
}
}

在最外层作用域中,常量 x 正在定义。在这段代码的 AST 树中,我们需要嵌套进入一个块,然后是一个 switch 语句,然后是一个 switch 的匹配,最后是一个带标签的 break 语句。在递归超过四个函数以后,AstGen 需要依靠 ResultLoc 结构来定位写入标记跳出值的位置。

ResultLoc 是一个带有许多可能结果类型的标记联合。它有很好的注释,我建议阅读源代码。这里将解释一些示例标记:

  • .discard 表示赋值被丢弃,因为赋值左侧是 _ 标识符。在这种情况下,AstGen 知道不需要生成任何存储指令,我们可以直接丢弃该值。
  • .ty 表示赋值是给某个类型化的值。这会生成一个 as_node 指令来强制转换返回之前的结果值。
  • .ptr 表示赋值被写入某个内存位置。这会生成一个 store 指令,以便将结果写入该内存位置。

ZIR 的生成

现在让我们来了解一下 AstGen 是如何将 AST 转换为 ZIR 的。抽象地说,这个过程将遍历 AST 并为每个 AST 节点发出指令,从而将其转换为 ZIR 指令序列。AstGen 采用立即求值的模式将整个 AST 转换为 ZIR 指令序列,这意味着 AstGen 不会延迟求值(在后续编译阶段我们将看到延迟求值的例子)。

重要的是,AstGen 引入了我们第一个重要的语义验证过程。例如,AstGen 验证标识符是否被定义,是否不会遮蔽外部作用域,并且知道如何遍历父作用域。回想一下之前提到的 AST 构建引入了结构验证:它确保像 x pub == 7 这样的 Token 序列会抛出语法错误,但它并没有验证任何标识符 x 是否被定义。而词法分析本身只是逐个验证 Token 的有效性,例如 72 是一个有效的字面量,而 72a 不是一个有效的标识符。

学习 ZIR 的生成过程最简单的方式是查看 expr 函数。这个函数为 Zig 语言中任何有效的表达式生成对应的 ZIR 指令序列。我发现从语言的最简单组件开始,然后逐步构建起来是最容易理解这一过程的方式,因为 IR 生成是一个深度递归的过程。

整数字面量

让我们看一个简单的静态整数值的例子:

1
42

这将被解析成带有 .integer_literal 标签的 AST 节点,进而引导 expr 函数调用 integerLiteral 函数。integerLiteral 函数的内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
fn integerLiteral(gz: *GenZir, rl: ResultLoc, node: Ast.Node.Index) InnerError!Zir.Inst.Ref {
const tree = astgen.tree;
const main_tokens = tree.nodes.items(.main_token);
const int_token = main_tokens[node];
const prefixed_bytes = tree.tokenSlice(int_token);
if (std.fmt.parseInt(u64, prefixed_bytes, 0)) |small_int| {
const result: Zir.Inst.Ref = switch (small_int) {
0 => .zero,
1 => .one,
else => try gz.addInt(small_int),
};

return rvalue(gz, rl, result, node);
} else |err| switch (err) {
error.InvalidCharacter => unreachable, // Caught by the parser.
error.Overflow => {},
}

// ... other paths
}

这里有很多内容!这就是为什么我从最简单的事物开始。如果我从函数声明之类的东西开始,我会陷入太多细节中,学起来会很困难。即使看整数字面量,你可能仍然感到有些不知所措,但这已经是最简单的情况了,所以让我们深入了解一下。

首先,这个函数在 AST 中查找整数字面量对应的 Token 值。然后,我们可以使用 Token 的起始位置,通过 tree.tokenSlice 获取与 Token 关联的文本。这将得到 “42” 字符串,或者更具体地说,是 4 和 2 两个字节。

接下来,我们尝试将该字符数组解析为无符号 64 位整数。如果数字太大,代码就会走到 other parts 注释的逻辑,其中涉及存储“大整数”的复杂内容,我们在这里不进行探讨。在这个示例中,我们假设所有的整数常量都小于或等于无符号 64 位整数的最大值(18446744073709551615)。

原注:无符号?那负数怎么办?

负数前面的符号 - 会作为一个单独的 AST 节点存储,并在 ZIR 中单独生成一个一元操作。整数字面量始终为正数。

上述过程最终成功解析数字,因为 42 可以解析为有效的 u64 类型的值。接下来,我们处理特殊情况,即值为 0 或 1 的情形,因为它们具有特殊的标记引用。否则,我们生成一个 .int 标签的 ZIR 指令,并将指令索引存储在 result 中。

最后,我们调用 rvalue 函数,它将 ResultLoc 的语义应用于该值,以确定我们是否需要原样返回它,将其转换为已知类型,或将其存储在内存位置等等。在许多情况下,这将是一个空操作,并且只会简单地返回带 .int 标签的 ZIR 指令。

译注:翻译时,integerLiteral 函数已被重构为 numberLiteral 函数

加法

理解静态值对应 ZIR 的生成过程,我们在进一步分析一个加法的生成过程:

1
42 + 1

这将被解析成带有 .add 标签的 AST 节点,并引导 expr 函数调用 simpleBinOp 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fn simpleBinOp(
gz: *GenZir,
scope: *Scope,
rl: ResultLoc,
node: Ast.Node.Index,
op_inst_tag: Zir.Inst.Tag,
) InnerError!Zir.Inst.Ref {
const astgen = gz.astgen;
const tree = astgen.tree;
const node_datas = tree.nodes.items(.data);

const result = try gz.addPlNode(op_inst_tag, node, Zir.Inst.Bin{
.lhs = try reachableExpr(gz, scope, .none, node_datas[node].lhs, node),
.rhs = try reachableExpr(gz, scope, .none, node_datas[node].rhs, node),
});
return rvalue(gz, rl, result, node);
}

这是我们第一个递归的例子。它通过递归构建左表达式和右表达式的 ZIR 指令序列,进而生成一个带有 lhs 和 rhs 数据的 .add ZIR 指令。其中 lhs 对应 42 字面量,rhs 对应 1 字面量,根据我们对整数字面量的探索,我们知道这将进一步转化为 .int 指令。

生成的 ZIR 大致如下:

1
2
%1 = int(42)
%2 = add(%1, @Ref.one)

赋值

接下来,我们把上面加法的值赋值给一个未标识类型的常量:

1
const x = 42 + 1;

expr 函数不会处理赋值,因为它不是一个表达式,而是一个语句。statement 函数也不处理赋值,因为 Zig 的变量赋值只能在容器(结构体)或块(函数体)内进行。因此,处理赋值的是 containerMembersblockExprStmts 函数。前者用于结构体的主体,后者用于函数体。我认为首先看 blockExprStmts 会更容易,因为 containerMembers 比较复杂。

blockExprStmts 函数有一个 switch 语句,它在处理赋值时会跳转到 varDecl 函数处理变量或常量声明。varDecl 函数的代码量非常庞大!我不会在下面粘贴完整的函数,而只包括最关键的代码路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const type_node = var_decl.ast.type_node;
const result_loc: ResultLoc = if (type_node != 0) .{
.ty = try typeExpr(gz, scope, type_node),
} else .none;
const init_inst = try reachableExpr(gz, scope, result_loc, var_decl.ast.init_node, node);

const sub_scope = try block_arena.create(Scope.LocalVal);
sub_scope.* = .{
.parent = scope,
.gen_zir = gz,
.name = ident_name,
.inst = init_inst,
.token_src = name_token,
.id_cat = .@"local constant",
};
return &sub_scope.base;

首先,代码递归地计算类型表达式(如果有的话)和初始化表达式。对于常量 const x: t = init 来说,t 是类型表达式,init 是初始化表达式。对于我们的示例,我们没有类型表达式,而初始化表达式是一个 .add 指令。

接下来,代码创建一个新的 LocalVal 作用域来表示这个值。这个作用域定义了标识符 x 和值指令 init_inst 并指向 varDecl 函数给出的父作用域。新定义的作用域是函数的返回值,调用者 blockExprStmts 将当前作用域从该语句起替换为这个新作用域,以便将来的语句和表达式可以引用这个赋值。

此前的示例返回 ZIR 指令索引,而这个示例返回了一个作用域。请注意,对 x 的命名赋值不会生成 ZIR 指令。如果你查看生成的 ZIR,你不会在任何地方看到 x 的命名:

1
2
%2 = int(42)
%3 = add(%2, @Ref.one)

x 被实现为一个作用域,而这个作用域被 inst 值指令追踪。如果 x 曾经被引用,我们知道它的值对应到前述值指令,并且可以通过值指令来引用 x 的值。具体地说,我们看到函数体中的后续 ZIR 指令序列:

1
2
const x = 42 + 1;
const y = x;
1
2
3
%2 = int(42)
%3 = add(%2, @Ref.one) // x assignment
%4 = ensure_result_non_error(%3) // y assignment

注意指令 %4 对应的对 y 的赋值直接引用了指令 %3 作为参数。ZIR 指令不需要进行显式的数据存储和加载,因为它可以直接引用指令的结果。

请注意,当生成机器代码时,后端可能会确定需要进行加载或存储数据,这取决于计算机体系结构。但中间表示不需要做出这个决定。

作为读者的练习:下一步,我建议研究 const y = x 语句的 ZIR 生成过程,并学习标识符引用的工作原理。这将解释上述的 ZIR 是如何生成的,并为应对更复杂的编译过程打下良好的基础。

完成 AstGen 流程

AstGen 进程为每个 Zig 文件各运行一次,并递归地构建整个文件的 ZIR 指令序列,最终在函数的末尾返回一个 Zir 值。

通过本文介绍的细节,你应该能够跟踪任何 Zig 语言结构并学习 ZIR 是如何生成的。你可以经常使用 zig ast-check 命令来查看 Zig 编译器实际生成的内容,并从中知道应该深入分析哪些函数。

Zig 词法分析和语法解析

作者 tison
2023年12月11日 08:00

Zig 语言是近几年来逐渐声名鹊起的一个新编程语言,也是数目稀少的系统编程语言家族中的一个新成员。它由 Andrew Kelley 于 2015 年开始创造,至今已经开发了八个年头,但是仍然还未发布 1.0 版本。

不过,已经有不少新锐项目选择使用 Zig 开发,例如 JavaScript 运行时和完整开发套件 bun 和分布式金融数据库 tigerbeetle 等。

Hashicorp 的创始人 Mitchell Hashimoto 也在前年卸任 CEO 成为 IC 后开始投入大量时间开发 Zig 程序,包括开源的 libxev 库和目前尚未公开的 Ghostty 等等。

Mitchell 在开发 Zig 程序的过程中,撰写了系列博客介绍 Zig 程序的编译过程。这些内容有助于理解 Zig 语言的设计,以及它如何在 LLVM 提供的抽象和系统开发者之间建立起一层抽象。

我在取得 Mitchell 的同意后对这些文章做一个翻译,以飨读者。

本文翻译自系列第一篇和第二篇博客:

这一部分属于编译器的前端,相对而言比较简单。

以下原文。

词法分析

词法分析是典型编译器流程中的第一步。词法分析是将字节流,即编程语言的语法,转换为 Token 流的过程。

例如,Zig 的 comptime {} 语法经过词法分析后,将得到 [.keyword_comptime, .l_brace, .r_brace] 的结果。词法分析通常不处理语义问题,也不关心分析得到的 Token 是否有实际意义。例如,comptime [] 不是有效的 Zig 语法,但是词法分析过程仍然会将其解释为合法的 [.keyword_comptime, .l_bracket, .r_bracket] 输出。解析 Token 流的语义以及在无效情况下失败推出,是下一章要介绍的语法解析的内容。

词法分析器的实现方式多种多样。本文主要介绍 Zig 的词法分析工作原理,并不会跟其他的方案做对比。

词法分析器

Zig 语言的词法分析器是标准库的一部分,代码位于 lib/std/zig/tokenizer.zig 文件内,对外暴露为 std.zig.Tokenizer 结构。

Zig 词法分析器接受一个字节数组切片作为参数,不断产生 Token 直到遇见结束符(EOF)。词法分析过程没有任何内存分配。

在深入讲解词法分析过程前,我们先看到它的一个基本使用方式:

1
2
3
4
5
6
7
8
const std = @import("std");
const expect = std.testing.expect;

const tok = std.zig.Tokenizer.init("comptime {}");
try expect(tok.next() == .keyword_comptime);
try expect(tok.next() == .l_brace);
try expect(tok.next() == .r_brace);
try expect(tok.next() == .eof);

Zig 的词法分析有两个重要特点:

  1. 在字节数组切片上操作,而不是字节流。词法分析的参数类型是 [:0] const u8 而不是字节流。这意味着如果输入字符量巨大,调用方需要自己处理输入缓存和攒批处理的工作。实践当中,编程语言代码输入一般不会太大,所以整个源文件的词法分析可以一次性完成。这就是 Zig 当前的工作方式。
  2. 没有内存分配。Zig 的词法分析器不做任何的堆上内存分配。对于系统编程语言,理解接口的内存使用和分配行为总是有意义的。Zig 的所有分配都是显式的,因此我们可以立即从词法分析器的参数列表中没有 Allocator 得到它不会做内存分配的结论。

如下所示,词法分析器结构的定义是相对简单的:

1
2
3
4
5
6
7
pub const Tokenizer = struct {
buffer: [:0]const u8,
index: usize,
pending_invalid_token: ?Token,

// ...
};

即使你对词法分析一无所知,从上面结构定义中你也应该能理解词法分析的工作原理。词法分析器逐个处理切片上的字节,同时前移索引并产生 Token 输出。

Token 的结构

理解了词法分析的高级接口之后,紧接着下一个问题就是:Token 结构如何定义?

Zig 当中 Token 的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub const Token = struct {
tag: Tag,
loc: Loc,

pub const Loc = struct {
start: usize,
end: usize,
};

pub const Tag = enum {
invalid,
// ...
};
};

其中,tag 保存了 Token 的类型,可能的取值包括 .keyword_comptime, .l_brace, .r_brace, .eof 等等。目前,Zig 大约定义了 120 个不同的 Token 类型。

此外,loc 字段定义了 Token 的位置。它由 startend 两个索引组成。词法分析的调用者可以通过 source[tok.loc.start .. tok.loc.end] 来取得 Token 对应的文本内容。Token 结构中只保存两个索引是一种常见的效率优化手段。

Token 结构中只提供了最基本的信息:Token 的类型及其位置。不过,在不同的词法分析器中,Token 结构的定义可能有所不同。

例如,Golang 的词法分析器把 Token 定义成一系列整型常数。同时,对应到 next() 的函数返回代表 Token 类型的整数、代表偏移量的单一整数,以及对应 Token 文本内容的字符串。

1
2
3
4
5
6
7
8
9
10
func (s *Scanner) Scan() (pos token.Pos, tok token.Token, lit string)

type Pos int
type Token int

const (
ILLEGAL Token = iota

// ...
)

这与 Zig 大致相同。但是其中微妙的差异,对编译器的工效学和性能有重要影响。

查找下一个 Token

在词法分析器上每调用一次 next() 方法都将得到一个 Token 输出。

next() 函数从缓冲区的当前索引开始,逐个字节查看以构建 Token 输出。它使用一个状态变量来实现状态机,以跟踪可能出现的下一个状态。

在分析 Zig 词法分析器的实现细节之前,我们先从高层抽象上思考 next() 的工作流程。比如,输入包含空白符的语句 while 如何被分词。

首先,词法分析器会遇到 w 字符。此时,我们知道这个 Token 不会是数字,但它仍然可以是关键字或标识符。因此,我们不能确定 Token 的类型。词法分析器每次只看一个字符,所以我们接下来获取 h 字符。它仍然可以是标识符或关键字,所以我们继续读取字符,并依次读到 ile 三个字符。

现在,词法分析器得到了 while 输入,我们知道它是一个独立的关键字。但是,它仍然可以是标识符,因为可能还有更多字符,所以我们需要再向前看一个字符。

下一个输入是空格。我们现在明确知道它是 while 关键字,而不是标识符。于是,我们返回 .keyword_while Token 作为结果。反之,如果下一个字节是数字 1 这样的字符,我们会知道我们正在构建一个标识符,并且它绝不会是一个关键字,因为没有以 while1 开头的关键字。

这就是 Zig 词法分析器的工作原理。它维护当前状态,比如“正在构建一个标识符或关键字”,并逐个字符查看,直到明确确定了 Token 的类型和内容。

词法分析器只查看状态和当前字符:它不会向后查看,也不会向前查看。大多数词法分析器都是这样的。正如开头所提到的,词法分析器不关心语义,因此诸如 if while comptime x = 7 { else } 这样的无意义输入会产生一个有效的 Token 流。接下来,负责语法解析的解析器会分析 Token 流对应的语义含义。

词法分析的实现

next() 的实现基于嵌套的 whileswitch 语句,其中一个代码片段如下:

1
2
3
4
5
6
7
8
9
10
11
while (true) : (self.index += 1) {
const c = self.buffer[self.index];
switch (state) {
.start => switch (c) {
'a'...'z', 'A'...'Z', '_' => {
state = .identifier;
result.tag = .identifier;
},
}
}
}

外部的 while 是一个无限循环,每次处理缓冲区里的一个字符。循环体将在发现一个 Token 或字符耗尽时退出。

Zig 的一个好特性是缓冲区 buffer 具有 [:0] const u8 类型,其中的 :0 代表缓冲区以字节 0 结尾。因此,我们可以在发现 0 是退出循环。

接下来,词法分析器查看当前的 state 值。对于每个具体的 state 值,词法分析器再查看当前字符的值。结合这两者,词法分析器决定下一个动作应该是什么。

在上面的代码片段中,我们可以看到,如果词法分析器处于 .start 状态,下一个字符假设是 A 的话,词法分析器将进入到 .identifier 状态并试图得到一个标识符。

从 Token 到抽象语法树

词法分析完成后,下一个阶段是语法解析。语法解析器接收由词法分析器生成的 Token 流,并将其转换为更有意义的抽象语法树。

语法解析

语法解析是编译流程中紧随词法分析的下一步。语法解析负责从 Token 流构建抽象语法树。我之前已经写过关于 Zig 如何将字节流(源代码)转换为 Token 流的内容。

Zig 语法解析器的代码位于 lib/std/zig/parse.zig 文件中,对外暴露成标准库中的 std.zig.parse() 接口。语法解析器接受整个源代码文本,输出 std.zig.Ast 抽象语法树实例。Ast 结构定义在 lib/std/zig/Ast.zig 文件中。

译注:0.11 版本后,语法解析的接口有所变化,但是整体流程仍然是相似的。

MultiArrayList

从解析开始,Zig 编译器大量使用 MultiArrayList 结构。为了理解语法解析器的工作原理以及抽象语法树的结构,我们首先介绍 MultiArrayList 的设计。

在讲解 MultiArrayList 之前,我想先简要介绍一下常规的 ArrayList 的设计。ArrayList 是一个动态长度的值数组。当数组已满时,会分配一个更大的新数组,并将现有项复制到新数组中。这是一个典型的、动态分配的、可增长或缩小的项目列表。

例如,考虑如下一个 Zig 结构:

1
2
3
4
pub const Tree = struct {
age: u32, // trees can be very old, hence 32-bits
alive: bool, // is this tree still alive?
};

ArrayList 中,多个 Tree 结构会按如下形式存储:

1
2
3
       ┌──────────────┬──────────────┬──────────────┬──────────────┐
array: │ Tree │ Tree │ Tree │ ... │
└──────────────┴──────────────┴──────────────┴──────────────┘

每个 Tree 结构需要 8 字节内存存储,所以 4 个 Tree 值的数组需要 32 字节内存来存储。

原注:为什么是 8 字节?

一个 u32 值需要 4 字节存储,一个 bool 值需要 1 字节存储。但是结构体本身有一个 u32 字段,所以 bool 字段需要是 4 字节对齐的,也就占用了 4 字节的内存(其中 3 字节是浪费的)。总的就是 8 个字节。

MultiArrayList 同样是一个动态分配的值列表。然而,存储在数组列表中的类型的每个字段都存储在单独的连续数组中。这带来了两个主要优点:

  1. 由于对齐而减少了浪费的字节
  2. 具有更好的缓存局部性

这两个优点通常会带来更好的性能,以上面 Tree 结构为例,MultiArrayList(Tree) 的存储结构如下:

1
2
3
4
5
6
         ┌──────┬──────┬──────┬──────┐
age: │ age │ age │ age │ ... │
└──────┴──────┴──────┴──────┘
┌┬┬┬┬┐
alive: ││││││
└┴┴┴┴┘

结构体的每个字段都存储在单独的连续数组中。一个包含四个树的数组使用了 20 字节,相比 ArrayList(Tree) 的方案减少 37.5% 的内存占用。这忽略了多个数组指针的开销,但是随着列表中项目数量的增长,这种开销会分摊到总共减少了 37.5% 的内存需求上。

原注:为什么是 20 字节?

由于结构体被拆分,age 字段需要 4 字节,上面数组有 4 个 Tree 实例,因此是 16 字节。alive 字段不再需要 4 字节对齐,因为它不再是结构体的一部分,所以只需要 1 字节(没有浪费的字节!)。上面数组有 4 个 Tree 实例,因此 alive 字段一共需要 4 字节。这两个字段加起来总共是 20 字节。

本文不会深入介绍 MultiArrayList 的实现细节。对于理解 Zig 编译器来说,只需要知道几乎每个结构都存储为 MultiArrayList 就足够了。编译一个真实世界的程序通常会生成数万个“节点”,因此这样做可以节省大量内存,并提高缓存的局部性。

语法解析器的结构

语法解析器使用 Parser 结构体来存储当前解析状态。这不是一个公开导出的结构体,只用于管理解析操作的内部状态。调用者会得到一个 Ast 作为解析操作的结果,这将在本文后面进行探讨。

以下是本文写作时解析器的结构,我在其中插入了换行符,以将状态分成功能组:

1
2
3
4
5
6
7
8
9
10
11
12
13
const Parser = struct {
gpa: Allocator,
source: []const u8,

token_tags: []const Token.Tag,
token_starts: []const Ast.ByteOffset,
tok_i: TokenIndex,

errors: std.ArrayListUnmanaged(AstError),
nodes: Ast.NodeList,
extra_data: std.ArrayListUnmanaged(Node.Index),
scratch: std.ArrayListUnmanaged(Node.Index),
};

第一组状态包含了 gpa 和 source 两个字段。这是一个独立 Zig 文件的分配器和完整的原始源代码。

第二组状态中,token_tags 和 token_starts 是语法解析的结果。如前所述,语法解析器采用面向数据的设计,以获得更好的内存使用和缓存局部性。因此,我们有 token_tags.len == token_starts.len 的不变式,Zig 只是将结构体字段放置在单独的连续内存块中。tok_i 值是解析器正在查看的当前 Token 的索引(从 0 开始计数)。

第三组状态是语法解析器的实际工作状态。这是 Ast 部分累积的结果。第三组非常重要,因为它是语法解析器构建的核心部分:

  • errors 存储了语法解析器在进行解析过程中遇到的错误列表。大多数良定义的语法解析器在各种错误情况下都会尝试继续解析,Zig 的解析器也不例外。Zig 解析器会在这个字段中累积错误并尝试继续解析。
  • nodes 是 AST 节点的列表。初始为空,在语法解析器继续进行时逐渐构建起来。理解 AST 节点的结构非常重要,这将在下一节中介绍。
  • extra_data 是 AST 节点可能需要的附加信息的列表。例如,对于结构体,extra_data 包含了所有结构体成员的完整列表。这再次展示了面向数据的设计的例子;另一种方法是直接将额外数据放在一个节点上,但这会使整个解析器变慢,因为它会强制每个节点变得更大。
  • scratch 是语法解析器共享的临时工作空间,通常用于为节点或额外数据构建信息。一旦语法解析器完成工作,这些空间就会被释放。

AST Node 的结构

语法解析的结果是一个 AST 节点的列表。请注意,AST 在抽象定义上是一棵树,但解析器返回的结果是包含树中节点的列表,也就是上面介绍的 MultiArrayList 内存表示方式。

AST 节点结构可能会令人困惑,因为数据本身存成 MultiArrayList 结构,其中包括许多间接引用。我建议反复阅读本节,确保理解 AST 节点的结构。在理解 Zig 编译器的内部工作原理时,对这种数据模式理解不透彻会导致许多问题,因为这个模式在编译器的每个后续阶段都被重复使用。

AST 结构可以在 lib/std/zig/Ast.zig 中找到,下面是其核心 Node 结构的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pub const TokenIndex = u32;

pub const Node = struct {
tag: Tag,
main_token: TokenIndex,
data: Data,

pub const Data = struct {
lhs: Index,
rhs: Index,
};

pub const Index = u32;
};

请注意,语法解析器本身将节点存储在 NodeList 中,这对应的是 MultiArrayList(Node) 类型,意味着它是 Node 结构,其中每个字段都存储在单独的连续内存块中。从概念上讲,可以将节点视为上面显示的单个结构体,但在具体实现中,字段分解存储到不同的字段数组中。

tag 是可能的 AST 节点类型的枚举。例如,fn_decl 对应函数声明,integer_literal 对应整数字面量,builtin_call 对应调用内置函数,例如 @intFromEnum 等。

main_token 字段现在并不是非常重要。它存储跟当前 AST 节点密切相关的 Token 值。例如,对于函数声明,它可能是一个 fn Token 值。该值是一个索引,指向用于构建 AST 的 Token 列表。

data 字段非常重要。它包含与 AST 节点关联的数据信息。例如,函数声明(.tag == .fn_decl)使用 data 存储函数原型和函数体。data 字段将在下一节详细介绍。

AST Node 的数据

AST Node 的数据是关联到 AST Node 的关键数据。例如,函数声明具有函数名称、参数列表、返回类型和函数体等信息。Node 结构的定义没有立即解释这些信息存储的位置。

在我们深入了解语法解析器的具体工作方式之前,理解 AST Node 的数据存储模式非常重要。对于想要了解整个编译器流程的人来说,这是一个关键的细节,因为 AST 使用了一种贯穿整个编译流程的模式。

让我们来看看给定 Zig 代码的函数声明的 AST 结构会是怎样的:

1
2
3
fn add(a: u8, b: u8) callconv(.C) u8 {
return a + b;
}

函数声明

函数声明的根 AST 节点会被打上 fn_decl 标签。

对于 fn_decl 标签的节点,data 字段的 lhs 存储了函数原型在 NodeList 中的索引(名称、类型信息等),而 rhs 字段存储了函数体在 NodeList 中的索引。这些信息目前只能通过阅读 .fn_decl 标签上面的注释或源代码才能得知。

lhs 和 rhs 中的索引值是关联到 NodeList 上。因此,你可以通过 tree.nodes[lhs] 访问函数原型信息(伪代码,并非完全正确的写法)。

data 的两个字段都用于存储 NodeList 的索引。这是 data 的一种用法,用于存储与 AST 节点相关的信息。接下来,让我们看一下函数原型,了解 Zig 如何确定参数列表、返回类型和调用约定。

函数原型

通过 tree.nodes[lhs] 我们可以访问函数原型节点。函数原型节点对应的是 fn_proto 标签。这种类型的 AST 节点存储了有关参数、调用约定、返回类型等信息。

函数原型节点以不同的方式使用 lhs 和 rhs 字段。其中,rhs 字段的用法与函数声明相同,存储了返回类型表达式在 NodeList 中的索引。Zig 除了支持直接类型标识符,还支持使用各种表达式来在编译时计算返回类型。

然而,lhs 字段并不指向 AST 节点。相反,它是指向某个 extra_data 字段的索引。该索引是附加元数据的起始索引。附加元数据的长度根据 AST 节点类型事先已知。所以,lhs 字段对应的信息不是通过 tree.nodes[lhs] 来访问,而是通过 tree.extra_data[lhs] 来访问。

现在我们知道,lhs/rhs 可能指向一个 AST 节点,也可能指向一段额外数据。

此外,extra_data 具有 []Index 类型。这可能会让人误以为 tree.extra_data[lhs] 只是指向另一个索引。这是不正确的。这种情况下的 lhs 只是指向 extra_data 中的第一个索引。要读取的后续字段数量也取决于标签。例如,fn_proto 对应的 extra_data 有六个字段。要想知道 extra_data 的具体内容,当前只能通过阅读源代码及其注释来了解:

1
2
3
4
5
6
7
8
9
10
11
12
pub const FnProto = struct {
params_start: Index,
params_end: Index,
/// Populated if align(A) is present.
align_expr: Index,
/// Populated if addrspace(A) is present.
addrspace_expr: Index,
/// Populated if linksection(A) is present.
section_expr: Index,
/// Populated if callconv(A) is present.
callconv_expr: Index,
};

因此,fn_proto 的情形下,tree.extra_data[lhs] 指向的是一个 params_start 字段。对应地,tree.extra_data[lhs+1] 指向一个 params_end 字段。依此往下,直到 tree.extra_data[lhs+5] 指向 callconv_expr 字段。

Ast 结构提供了一个 extraData() 帮助函数来完成这些解码工作:传入一个 tree 实例,你可以简单的访问其 lhs/rhs 对应的数据:

1
2
3
const fnData = tree.extraData(idx, FnProto)
fnData.params_start
fnData.callconv_expr

其中,idx 是 NodeList 中 AST Node 的索引,该 AST Node 必须是 .fn_proto 标签的。

下一个问题是,params_startparams_end 等字段仍然是一些索引,那么它们所指向的数据是什么类型的呢?答案是,它们分别对应第一个参数、最后一个参数等的 AST 节点的 NodeList 索引。这是读取有关函数原型的所有额外信息的方法。

如前所述,这里存在很多间接索引。但是现在理解这一切非常重要,否则将来的阶段将更加混乱。

函数标识符

现在,我们只剩下 main_token 字段还未讲解,这也是某些数据可能被访问的最后一种方式。

在上面函数原型的例子中,tag 和 data 等字段都不存储函数名称。实际上,.fn_proto 类型的节点使用 main_token 字段来存储函数名称。fn_proto 的 main_token 存储了指向 Token 流中 fn 关键字的索引。Zig 函数的标识符总是紧随该关键字之后。因此,你可以通过查看索引 main_token + 1 的 Token 来提取函数标识符。这正是编译器后续阶段读取标识符的方式。

小结

如果你对了解 Zig 编译器的其余工作感兴趣,深刻理解上面介绍的信息存储模式非常重要。第一次阅读时,你可能会感到很难理解(甚至第二次或第三次也是如此)。本节再次回顾 AST 的数据布局方式。

AST 节点数据可能对应到以下三个位置的内容:

  1. Token 流的数据,其中存储标识符或其他值。
  2. NodeList 即节点列表,用于查找其他 AST 节点。
  3. extra_data 列表,即额外数据列表,用于查找额外的数据结构。

后续的 AST 节点或额外数据结构通常包含额外的索引,这些索引可能指向其他节点、某个 Token 或额外的数据结构。例如,fn_decl 节点存在一个索引指向 fn_proto 节点,fn_proto 节点存在一个索引指向额外的 FnProto 数据结构,FnProto 结构存储了指向参数的一系列 AST 节点。

确定数据是否可用以及如何访问数据取决于节点标签。你必须阅读 lib/std/zig/Ast.zig 中的注释或语法解析器的源代码,才能了解数据的确切用法。

语法解析的工作原理

现在,我们对语法解析器的内部状态和 AST 构造有了坚实的理解,可以进一步讨论语法解析器实际解析 Token 流的过程了。

抽象地说,语法解析器检查 Token 流中当前 Token 的内容,并以此作为上下文来确定下一个 Token 可以是或应该是什么。例如,在解析 Token 流 fn foo 时,第一个 Token .keyword_fn 期望的下一个 Token 应该是一个标识符。如果不是标识符,则会出现语法错误。

不同于词法分析,语法解析关心 Token 的顺序是否合理。词法分析只是单纯的从文本中产生 Token 流,因此无效的语法,例如 fn pub var foo i32 } { 也能产生一个有效的 Token 流。但是,语法解析该 Token 流将产生一个错误,因为它是无意义的。

接下来,让我们具体看一下解析器如何解析一个简单的 Zig 文件:

1
2
3
4
5
var x = 7;

pub fn inc() callconv(.C) void {
x += 1;
}

解析一个 Zig 文件

语法解析器的主要入口是 parse 函数,它接受一个完整的 Zig 文件源代码。这个函数会初始化解析器状态,并调用 parseContainerMembers() 来解析 Zig 文件的成员。Zig 文件隐式地是一个结构体,因此解析器实际上是在解析一个结构体。

语法解析过程对应一个 while 循环,根据当前的 Token 确定下一个期望的内容。大致结构如下:

1
2
3
4
5
while (true) {
switch (p.token_tags[p.tok_i]) {
.keyword_var => ...
}
}

根据当前 Token 的值,上述代码确定下一个期望的内容。第一个 Token 是 .keyword_var 因为示例文件正在定义一个变量。这将进一步调用 parseVarDecl 来解析变量声明。此外,Zig 还有许多其他有效的 Token 如 comptime/fn/pub 等。

解析变量声明

示例文件中第一个被解析的对象是一个变量声明。Zig 语法解析器最终调用 parseVarDecl 方法来产生对应的 AST 节点。其代码概略如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
fn parseVarDecl(p: *Parser) !Node.Index {
const mut_token = p.eatToken(.keyword_const) orelse
p.eatToken(.keyword_var) orelse
return null_node;

_ = try p.expectToken(.identifier);
const type_node: Node.Index = if (p.eatToken(.colon) == null) 0 else try p.expectTypeExpr();
const init_node: Node.Index = if (p.eatToken(.equal) == null) 0 else try p.expectExpr();

return p.addNode(.{
.tag = .simple_var_decl,
.main_token = mut_token,
.data = .{
.lhs = type_node,
.rhs = init_node,
},
});
}

这个函数会“吞掉”一个 constvar 对应的 Token 值。“吞掉”意味着 Token 被消费:返回当前 Token 并使语法解析器的 tok_i 状态值递增。如果 Token 不是对应 constvar 关键字,那么函数将返回 null_node 以代表一个语法错误。因为变量定义必须以 constvar 开头。

接下来,语法解析器期望一个变量名的标识符。如果 Token 不是标识符,expectToken 函数会返回一个错误。例如,如果我们写成 var 32 那么语法解析器会在这里产生一个错误,表示语法解析期望得到一个标识符,但实际得到的是整数字面量。

如果没有遇到错误,下一步有几种可能的情况。通过分析 eatToken 的结果,我们可以确定下一步该做什么。如果下一个 Token 是冒号,那么期望的是找到一个类型表达式。例如 var x: u32 这样的源码。但是,类型表达式是可选的。上面示例中没有类型表达式,所以 type_node 将被设置为零。

然后,我们检查下一个 Token 是否是等号。如果是,我们期望找到一个变量初始化表达式。我们的变量声明确实有这个,所以这将创建一个 AST 节点并返回节点列表中的索引。我们不再往下讲解 expectExpr 的细节。

注意,这段逻辑可以接受多种有效的变量声明语法:

  • var x
  • var x: i32
  • var x: i32 = 42

但是,同样也明确了无效的语法,比如:

  • var : i32 x
  • var = 32

最后,我们调用 addNode 方法来创建节点。这将返回节点列表中的索引。在这种情况下,我们创建了一个带有 .simple_var_decl 标签的节点。

如果你查看 .simple_var_decl 的注释,其中写到其对应节点的 lhs 指向类型表达式 AST 节点的索引(如果没有类型表达式,则为零),而 rhs 指向初始化表达式 AST 节点的索引(如果没有初始化,则为零)。

parseVarDecl 函数返回 .simple_var_decl AST 节点的索引。最终,这个索引会一直返回到我们开始的 parseContainerMembers 函数,并存储在 scratch 状态中。我们将在后面解释 scratch 状态的用途。现在,while 循环继续解析下一个 Token 值,它将发现 .keyword_pub Token 并开始定义一个函数。

解析函数定义

语法解析器看到 .keyword_pub 之后,将调用 parseFnProtoparseBlock 方法继续解析。让我们重点关注 parseFnProto 的行为,因为它执行了我们尚未见过的操作。

下面是 parseFnProto 的简化且不完整的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
fn parseFnProto(p: *Parser) !Node.Index {
const fn_token = p.eatToken(.keyword_fn) orelse return null_node;

// We want the fn proto node to be before its children in the array.
const fn_proto_index = try p.reserveNode();

_ = p.eatToken(.identifier);
const params = try p.parseParamDeclList();
const callconv_expr = try p.parseCallconv();
_ = p.eatToken(.bang);

const return_type_expr = try p.parseTypeExpr();
if (return_type_expr == 0) {
try p.warn(.expected_return_type);
}

return p.setNode(fn_proto_index, .{
.tag = .fn_proto_one,
.main_token = fn_token,
.data = .{
.lhs = try p.addExtra(Node.FnProtoOne{
.param = params.zero_or_one,
.callconv_expr = callconv_expr,
}),
.rhs = return_type_expr,
},
})
}

这里出现了一些新的模式。首先,你可以看到如果没有设置返回类型,上述逻辑会存储一个警告而不是停止所有解析。语法解析器通常会在面对错误时尝试继续解析,这是其中一种情况。

接下来,fn_proto_one 类型的节点,其 lhs 值是 extra_data 的索引。上述代码展示了额外数据的写入方式。addExtra 函数接受一个包含所有值的结构体,并以类型安全的方式将其编码到 extra_data 中,然后返回 extra_data 中的起始索引。正如我们之前展示的那样,AST 节点的使用者将确切地知道 fn_proto_one 类型对应的额外数据有多少个字段以及如何读取这些信息。

完成 AST 构造

语法解析会递归地继续进行,为整个程序构建 AST 节点。在解析结束时,解析器会返回一个结构化的 Ast 结构,如下所示:

1
2
3
4
5
6
7
pub const Ast = struct {
source: [:0]const u8,
tokens: TokenList.Slice,
nodes: NodeList.Slice,
extra_data: []Node.Index,
errors: []const Error,
};

现在,我们已经理解了 Zig 的词法分析和语法解析过程,也清楚 AST 节点的结构,上面这些字段的意义也不难推断。

  • source 存储了完整的源代码,可以用于确定标识符字符串的内容或提供错误消息
  • tokens 是 Token 的列表
  • nodes 是 AST 节点的列表
  • extra_data 是 AST 节点的额外数据列表
  • errors 存储了可能的错误累积结果

在后续文章中,我们将讨论如何从 AST 生成中间表示,以及如何进一步做语义分析。

Why Async Rust

作者 tison
2023年11月6日 08:00

昨天的文章里,我介绍了 Async Rust 当前的实现以及一个实现 Async Runtime 需要了解的概念和现有的一些实践。

文章发出后,有评论称 Async Rust 创造性工作,没有可借鉴的经验。诚然,Rust 系统编程语言的定位就决定了它与其他有运行时的语言在设计时存在巨大的不同,同类语言 C 和 C++ 在异步编程方面,受限于语言的历史包袱,相关的支持往往以三方库而不是语言级的支持出现。

不过,Rust 至少可以借鉴自己一路走来的经验。Async Rust 的主要开发者之一 @withoutboats 在今年十月份的时候写了一篇 Why Async Rust 的博文,介绍了 Async Rust 一路发展的沿革和背后的设计决策。也是这一篇文章,帮助我理解了大量 Async Rust 设计的理由,从而融会贯通地写出昨天的文章。

随着软件系统越来越复杂,一个完整的复杂软件动辄几十上百万、甚至上千万行代码,提交记录少说几千,动辄几万甚至几十万。个人再也很难有足够的时间,单纯依靠读源代码和提交历史来理解系统运行的机制。这个时候,设计文档和作者现身说法讨论设计过程,尤其是失败的设计过程,就显得尤为重要。

实际上,我认为国内软件行业前沿的水平已经能跟世界前沿竞争,差的是时间积累的底蕴和人才的绝对数量。例如,全球第一波做数据库的人,到现在几乎都是三五十年经验起步,他们通过师父带徒弟甚至带出徒孙的形式,培养出了一大批领域专业人才。这一壁垒是很难走旁门左道超越的。

所幸开源运动的发展使得我们能够无国界的接触最前沿的软件设计思想和技术实践,我们于是可以对照一个完全开放的软件系统,根据核心开源开发者的介绍,理解这些智慧和经验的结晶。

以下原文,文中的“我”是 @withoutboats 的自称。

Rust 语言首次发布 async/await 语法时引起了很大的轰动。引用当时 Hacker News 的评论

这将打开新世界的大门!我确信很多人都在等待这一特性,我自己就是其中之一。

此外,这个新趋势具备一切优点:开源、高质量的工程代码、公开设计,许多贡献者为这个复杂的软件做出了巨大贡献。真是鼓舞人心!

最近,Rust 语言中的 async/await 语法变得有些褒贬不一。再次引用一条在 Hacker News 上的评论

我真的无法理解那些认为 Rust 的 async 设计很好的人。这简直是一团糟,更不用说 Rust 语言早已有着广为人知的陡峭学习曲线。

我竭尽全力尝试理解它,真的。但是,那真是一团巨大的混乱。而且它还会污染它接触到的一切。我真的很喜欢 Rust 语言,现在大部分时间都在用它编码,但每次遇到大量异步逻辑的 Rust 代码时,我都会全身紧张,视力模糊。

当然,这些评论都不能完全代表全部观点:早在四年前就有人提出了一些担忧。在同一条评论中,有人抱怨异步代码让自己全身紧张、视力模糊,但也有很多人同样热情地为异步 Rust 进行辩护。但我认为,可以说反对者越来越多,他们的口气也越来越强硬。在某种程度上,这只是炒作周期的自然进展。但是,我也认为随着时间推移,很多人未必记得或者了解异步 Rust 最初的设计过程,一些背景信息已经丢失了。

2017 年到 2019 年间,我与他人合作,在前人的工作基础上推动了 async/await 语法的设计。如果有人说,真不知道怎么会有人认为这团乱麻是个好的设计,我可能会有点不高兴。因此,请原谅我在这里用这篇不完善且过长的文章,解释异步 Rust 的诞生过程,异步 Rust 的目的,以及为什么在我看来,Rust 没有其他可行的选择。希望在这个过程中,我能够在某种程度上以更广泛、更深入的方式阐明 Rust 的设计,而不仅是重复过去的辩解。

关于术语的一些背景

在这场关于异步 Rust 的辩论中,争议的焦点之一是 Rust 决定使用所谓无栈协程(Stackless coroutine)的技术来实现用户空间并发。相关讨论充斥着大量术语,不熟悉所有术语是可以理解的。

我们首先需要明确的概念是这个特性的目的:用户空间并发。主流操作系统提供了一组相似的接口来实现并发:你可以创建线程,并在这些线程上依赖系统调用进行 IO 操作,这会阻塞线程直到操作完成。这些接口的问题在于它们存在一些不可避免的开销,如果你想要达到某些性能目标,这些开销可能成为限制因素。这些开销主要有两个方面:

  1. 在内核和用户空间之间进行上下文切换会消耗可观的 CPU 运行周期。
  2. 操作系统线程具有不可忽视的预分配线程栈,这增加了每个线程的内存开销。

这些限制在一定程度上是可以接受的。但是对于大规模并发的程序来说,它们并不适用。解决方案是使用非阻塞的 IO 接口,并在单个操作系统线程上调度海量并发操作。

开发者当然可以手动编写调度代码,但是现代编程语言通常提供现成的语法和标准库支持来简化这个过程。从抽象的角度来看,这意味着编程语言设计了一种把工作划分为多个任务,并将这些任务调度到线程上的方式。对应到 Rust 语言的设计,这就是 async/await 语法。

在这个问题的设计空间中,第一个选择的维度,是选择协作式调度还是抢占式调度,即任务是“协作地”将控制权交还给调度子系统,还是在运行过程中在某个时刻被“抢占”,而任务本身并不知道这一点?

关于这个主题的讨论中,经常被提及的一个术语是协程(coroutine),它的使用方式有些矛盾。协程是一种可以被暂停,并在稍后恢复的函数。其中一个重要的模糊之处,是有些人使用“协程”一词来指代具有显式语法以暂停和恢复的函数(这对应于协作调度的任务),而有些人则使用它来指代任何可以暂停的函数,即使暂停是由语言运行时隐式执行的(这也包括抢占式调度的任务)。我更倾向于第一个定义,因为这个定义做了一些有意义的区分。

另一方面,Goroutines 是 Go 语言的一项特性,它实现了抢占式调度的并发任务。Goroutines 具有与线程相同的接口,但是它是作为语言的一部分而不是作为操作系统原语来实现的,在其他语言中,这种特性通常被称为虚拟线程或者绿色线程。所以按照我的定义,Goroutines 不是协程。但是其他人使用更广泛的定义说 Goroutines 是一种协程,而我将这种特性称为绿色线程,因为在 Rust 中一直使用这个术语。

第二个选择的维度,是选择有栈协程还是无栈协程

有栈协程和操作系统线程一样有程序栈:当函数作为协程的一部分被调用时,它们的栈帧会被推入栈;当协程暂停时,栈的状态会被保存,以便从同一位置恢复。

无栈协程采用不同的方式存储需要恢复的状态。它通常将状态存储在一个延续(continuation)或状态机中。当协程暂停时,它所使用的栈被接替协程的操作使用;当协程恢复执行时,协程重新持有当前栈,并使用先前存储的延续或状态机,从上一次暂停的位点恢复协程。

在 Rust 和其他编程语言中,关于 async/await 语法,经常提到的一个问题是“函数着色问题”。这个问题说的是,为了获得异步函数的结果,开发者需要使用不同的操作(例如使用 .await 语法)而不是正常调用它。绿色线程和有堆协程没有这个问题,因为只有无栈协程才需要特殊语法来管理无栈协程的状态,具体特殊语法表示什么行为则取决于语言的设计。

Rust 的 async/await 语法是无栈协程机制的一个例子。异步函数被编译为返回 Future 的函数,而该 Future 用于存储协程在暂停时的状态。关于异步 Rust 的辩论的基本问题是,Rust 是否正确采用了这种方法,或者它是否应该采用更类似 Go 的有栈协程或绿色线程方法。理想情况下,开发者希望不需要显式语法来“着色”函数。

异步 Rust 的开发历程

绿色线程

Hacker News 的另一个评论很好的展示了这场辩论中我经常看到的一种言论:

开发者期望的并发模型是基于有栈协程和管道实现的结构化并发,底层实现由工作窃取执行器来优化负载。

除非有人实现这个模型的一个原型,并将之与当前的 async/await 语法加 Future 的实现相比较,否则我认为不会有任何建设性的讨论。

抛开上面提到的结构化并发、通道和工作窃取执行器(因为这是完全是不相关的问题),这种言论令人困惑的地方在于,最初的 Rust 确实实现了有栈协程,即绿色线程。但在 2014 年底,也就是 1.0 版本发布即将之前,它被移除了。了解其中的原因将有助于我们弄清楚为什么 Rust 推出了 async/await 语法。

对于任何绿色线程系统(无论是 Rust、Go 还是其他语言),一个重要问题是如何处理这些线程的程序栈。请记住,用户空间并发机制的一个目标是减少操作系统线程使用的大型预分配栈的内存开销。因此,绿色线程库往往会尝试以较小的栈启动线程,并仅在需要时进行扩展。

实现这一目标的一种方法是所谓的分段栈,其中程序栈是一系列小栈段的链表;当栈增长超出段的边界时,会向列表中添加一个新段,而当栈缩小时,该段将被移除。

这种技术的问题在于,它引入了高度不可控的将栈帧推入栈的成本。如果帧适合当前段,这基本上没什么开销。但是一旦不适合,程序需要分配一个新段。最恶劣的情况是在热循环中的函数调用需要分配一个新段,这时,该循环的每次迭代都需要进行一次分配和释放,从而对性能产生重大影响。而且,这些开销对用户来说完全不透明,因为用户不知道在调用函数时栈的深度。Rust 和 Go 都开始使用分段栈,然后因为这些原因放弃了这种方法。

另一种方法称为栈复制。在这种情况下,栈更像是一个数组而不是链表:当栈达到限制大小时,它会重新分配一个更大的栈。这样可以使初始栈保持尽量小,只根据需要进行扩展。这种技术的问题在于,重新分配栈意味着对其进行复制,且新分配的栈位于内存中的新位置。同时,任何指向栈的指针在栈重新分配后就无效了,程序需要一些额外机制来更新这些指针。

Go 使用栈复制技术。这主要得益于以下事实:Go 中指向栈的指针只能存在于同一个栈中。因此,Go 运行时只需要扫描该栈以重写指针。然而,做到这点至少需要运行时类型信息,而 Rust 并不保留这些信息。此外,Rust 也允许指向栈外的栈指针,这些指针可能在堆中的某个位置,或者在另一个线程的栈中。跟踪这些指针的问题最终与垃圾回收的问题相同,只不过这次的目标不是释放内存,而是移动它。显然,Rust 没有垃圾回收器,也不打算内置某种垃圾回收器。上面两个理由的任一个都导致 Rust 无法采用栈复制的方法。

早期 Rust 采用分段栈的方案,并通过增加绿色线程的大小来缓解频繁分配分段栈的问题,这也是操作系统线程的做法。当然,这种方案失去了绿色线程的一个关键优势:比操作系统线程更小的栈帧。

即使 Go 使用了可调整大小的栈来解决开销问题,当 Go 程序尝试与其他语言编写的库集成时,绿色线程仍会带来一定的无法避免的成本。C ABI 和操作系统栈是任何编程语言共享的最低要求。把代码从绿色线程切换到运行在操作系统线程栈上,可能会导致无法承受的 FFI 成本。Go 只是接受了这种 FFI 成本,这也成为 Cgo 饱受诟病的一个问题。最近,C# 因为这个局限性中止了绿色线程的实验

这个问题对 Rust 同样是致命的。因为 Rust 的设计目标之一,是支持将 Rust 库嵌入到用其他语言编写的二进制文件中;另一个设计目标,是能够在资源有限的嵌入式设备上运行,即使这些设备没有足够的时钟周期或内存运行虚拟线程运行时。

早期 Rust 为了解决这个问题,曾经将绿色线程运行时设置成可选的:Rust 可以在编译时选择将绿色线程编译成在原生线程上使用使用阻塞式 IO 运行的形式。因此,有一段时间,Rust 有两种变体:一种使用阻塞式 IO 和原生线程,另一种使用非阻塞式 IO 和绿色线程,并且所有代码都打算与两种变体兼容。

然而,实际结果并不理想,出于 RFC 230 的原因,从 Rust 移除了绿色线程的支持:

  1. 在绿色线程和原生线程之间的抽象并不是零成本的。执行 IO 操作时,会有无法避免的虚拟调用和内存分配,这对于特别是原生代码来说是不可接受的。
  2. 这一设计强制原生线程和绿色线程支持相同的接口,即使在某些情况下这并没有意义。
  3. 绿色线程并不完全支持互操作,因为仍然可以通过 FFI 原生的 IO 接口,即使是在绿色线程的上下文里。

绿色线程被移除了,但高性能用户空间并发的问题仍然存在。为了解决这个问题,Rust 团队开发了 Future trait 和后来的 async/await 语法。但要理解这条道路,我们需要先介绍 Rust 对另一个问题的解决方案。

迭代器

我认为 Rust 开始目前的异步功能设计,其真正起点可以追溯到 2013 年一位名叫 Daniel Micay 的开发者在邮件列表中发布的一篇帖子。这篇帖子与 async/await 语法、Future trait 或非阻塞 IO 没有任何关系,它是关于迭代器的。Micay 提议 Rust 转向使用所谓的外部迭代器,正是这种转变以及它与 Rust 的所有权和借用模型的有效结合,不可避免地将 Rust 引向了 async/await 的道路。当然,当时没有人知道这一点。

Rust 一直禁止使用变量的别名绑定来修改状态。这个常被称为 mutable XOR aliased 的约束在早期 Rust 中和今天一样重要。但是,最初它是通过不同于生命周期分析的机制来实施的。当时,引用只是某种参数修饰符,类似于 Swift 中的 inout 修饰符。2012 年,Niko Matsakis 提出并实现了 Rust 生命周期分析的第一个版本,将引用提升为真正的类型,并使其能够嵌入到结构体中。

尽管人们公认采用生命周期分析对塑造现代 Rust 的巨大影响,但是这一功能与外部迭代器的共生发展,以及这一功能对于 Rust 定位到当前领域的支柱型作用,并没有得到足够的关注。

在采用外部迭代器之前,Rust 使用了一种基于回调的方法来定义迭代器,这在现代 Rust 中大致可以表示为:

1
2
3
4
5
6
7
8
9
enum ControlFlow {
Break,
Continue,
}

trait Iterator {
type Item;
fn iterate(self, f: impl FnMut(Self::Item) -> ControlFlow) -> ControlFlow;
}

以这种方式定义的迭代器,将在集合的每个元素上调用 iterate 方法传入的回调函数。在回调函数返回 ControlFlow::Break 之前,迭代会不断产生下一个结果。for 循环的主体被编译为传递给正在循环遍历的迭代器的闭包。这种迭代器比外部迭代器更容易编写,但是这种方法存在两个关键问题:

  1. 语言无法保证当循环中断时迭代实际上停止运行。因此,语言无法依赖此功能来确保内存安全。这意味着无法从循环中返回引用,因为循环实际上可能会继续执行。
  2. 无法实现交错多个迭代器的通用组合子,例如 zip 方法,因为这种传递回调函数的设计不支持交替迭代两个不同的迭代器。

相反,Daniel Micay 建议 Rust 改用外部迭代器模式,从而完全解决了这些问题。外部迭代器的方式对应了 Rust 用户今天所熟悉的接口:

1
2
3
4
trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}

原注:深入了解底层细节的读者可能会知道,Rust 的 Iterator 提供了 try_fold 的方法,该方法在功能上与内部迭代器的 API 非常相似,并且用在其他一些迭代器组合子中,因为它可以生成更好的代码。但 try_fold 不是定义迭代器的核心底层方法。

外部迭代器与 Rust 的所有权和借用系统完美地集成在一起,因为它们本质上编译为一个结构体,该结构体在自身内部保存迭代的状态,并且因此可以像任何其他结构体一样包含对正在迭代的数据结构的引用。而且由于单态化(monomorphization)的存在,通过结合多个组合子构建的复杂迭代器也编译成单个结构体,从而使优化器可以透明地优化这些结构。唯一的问题是,手动编写这些结构体更困难,因为开发者现在需要定义将用于迭代的状态机。

当时,Daniel Micay 当时写下这样一段话预示未来的发展:

在未来,Rust 可以使用像 C# 那样的 yield 语句来实现生成器。生成器被编译成一个快速的状态机,而无需进行上下文切换或依赖虚拟函数,甚至不需要闭包。这将消除使用外部迭代器模式时,手动编码递归遍历的难题。

生成器的进展不算快。不过,最近发布了一份关于 gen 语法的 RFC 提案,或许我们可能很快就会看到这个功能。

即使没有生成器,外部迭代器仍然被证明是非常成功的,人们普遍认识到了这项技术的价值。例如,Aria Beingessner 在设计字典的 Entry API 时就使用了类似的方法。值得注意的是,在 Entry API 的 RFC 中,她说这种 API 的设计“类似于迭代器”。她的意思是,Entry API 通过一系列组合子构建了一个状态机,这使得该 API 对编译器高度可读,从而能够进行深度优化。这项技术很有潜力。

Futures

在 Rust 团队寻找绿色线程的替代的过程里,Aaron Turon 和 Alex Crichton 首先复制了许多其他语言中使用的 Future/Promise 接口。这类接口基于所谓的传递延续风格(continuation passing style)设计。开发者可以向以这种方式实现的 Future 注册一个回调。这个回调被称为延续,它会在 Future 完成时被调用。

这就是大多数语言中定义 Future/Promise 的方式。这些语言会将 async/await 语法编译成传递延续风格的代码。

在 Rust 中,这类 API 大致会被定义成:

1
2
3
4
trait Future {
type Output;
fn schedule(self, continuation: impl FnOnce(Self::Output));
}

Aaron Turon 和 Alex Crichton 尝试了这种方法,但正如 Aaron Turon 在一篇具有启发性的博文中写的,他们很快遇到了一个问题,即使用传递延续风格需要为回调分配空间。Turon 举了一个 join 的例子:

1
2
fn join<F, G>(f: F, g: G) -> impl Future<Item = (F::Item, G::Item)>
where F: Future, G: Future

在这个 join 函数的定义中,注册到返回值 Future 上的回调需要被两个子 Future 拥有,因为只有后完成的子 Future 后才应该执行它。这最终需要实现某种引用计数,以及依赖内存分配来实现。而对于 Rust 来说,这些开销都是不可接受的。

于是,他们研究了 C 开发者实现异步编程的常见做法。在 C 语言的世界里,开发者通过构建状态机来处理非阻塞 IO 操作。由此,Aaron Turon 和 Alex Crichton 希望能够设计出一种可以将 Future 编译成 C 开发者手动编写的状态机的接口。经过几番尝试,他们最终采用了一种称为基于就绪状态(readiness-based)的方法:

1
2
3
4
5
6
7
8
9
10
enum Poll<T> {
Ready(T),
Pending,
}

trait Future {
type Output;

fn poll(&mut self) -> Poll<Self::Output>;
}

不同于存储延续的方案,在这个新方案中,Future 由某个外部执行器进行轮询。当一个 Future 处于挂起状态时,它会保存一种唤醒执行器的方式。当 Future 准备好被再次轮询时,执行该方式以通知执行器再次进行轮询。

译注:这里说得有点抽象。最终 Rust 的实现是 poll 方法接收一个 Context 参数,Context 持有一个 Waker 实例,如果 poll 返回 Pending 结果,那么返回前它需要以某种方式保存 Waker 实例。异步运行时需要实现一个监听状态变化的逻辑,在挂起的计算可以继续执行时,取出对应的 Waker 实例,调用 wake 或 wake_by_ref 方法通知执行器再次轮询这个 Future 计算。

通过以这种方式反转控制,Rust 不再需要为 Future 存储完成时需要调用的回调,而只需要将 Future 表示为一个单独的状态机。他们在这个接口之上构建了一系列组合子库,所有这些组合子都会被编译成一个单一的状态机。

从基于回调的方法切换到外部驱动程序,将一组组合子编译成单一的状态机,甚至是这两个 API 的确切规范:如果你是按顺序读到这一段的,所有这些都应该非常熟悉。从传递延续到外部轮询的转变,与 2013 年迭代器的转变完全相同!

再一次地,正是由于 Rust 能够处理具有生命周期的结构体,才使得它能够处理从外部借用状态的无栈协程,进而能够在保证内存安全的前提下,以最佳方式将 Future 表示为状态机。无论是应用于迭代器还是 Future 接口,将较小的组件组合成单个对象状态机的模式,是 Rust 的经典设计模式。这一模式几乎自然地融入到了语言当中。

我稍微停顿一下,强调迭代器和 Future 之间的一个区别:像 zip 这样交错两个迭代器的组合子,在基于回调的方法中甚至是不可能的,除非编程语言另外构建了某种协程的原生支持。另一方面,如果你想交错两个 Future 实例,比如实现 join 方法,基于延续的方法可以支持,只是会带来一些运行时成本。这就解释了为什么外部迭代器在其他语言中很常见,但 Rust 将这种转换应用于 Future 的做法却是独一无二的。

在最初的版本中,futures 库的设计原则是,尽可能使用户以类似构造迭代器的方式来构造 Future 实例:原语级别的 Future 库的作者将直接实现 Future trait 来定义原语级的 Future 结构,而编写应用程序的用户将使用 futures 库提供的一系列组合子,将简单的 futures 组合构建成更复杂的 Future 实例。不幸的是,当用户尝试遵循这种方法时,他们立即遇到了令人沮丧的编译器错误。问题在于,当 futures 被生成时,它们需要逃离(escape)当前上下文,因此无法从该上下文中借用状态。相反,任务必须拥有它的所有状态。

这对于 futures 组合子来说是个棘手的问题。因为通常情况下,任务状态需要在构成 Future 的一系列组合子中都能被访问。例如,用户通常会先调用一个 async 方法,然后调用另一个:

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

问题在于,foo 在 bar 方法中被借用,然后在传递给 and_then 的闭包中再次被借用。实际上,用户想要做的是在 await 点之间存储状态,这个 await 点是由 Future 组合子的链接形成的,而这通常会导致令人困惑和费解的借用检查错误。最容易理解的解决方案是将该状态存储在 Arc 和 Mutex 中。但使用 Arc 和 Mutex 不是零成本的,而且,随着系统变得越来越复杂,这种方法变得非常笨重和不灵活。例如:

1
2
3
let foo = Arc::new(Mutex::new(foo));
foo.clone().lock().bar()
.and_then(move |result| foo.lock().baz(result))

尽管最初的实验中 futures 展示了出色的基准测试结果,但由于这个限制,用户无法使用它们来构建复杂的系统。这个时候,我加入到 futures 的开发当中,着力改进这个状况。

async/await

在 2017 年末,由于上节末尾提到的用户体验不佳的原因,futures 生态系统并未得到好的反响。futures 项目的最终目标始终是实现所谓的无栈协程转换,即使用 async 和 await 语法的函数,可以转换为求值为 Future 的函数,避免用户手动构造 Future 实例及调用其方法。Alex Crichton 曾经开发了一个基于宏的 async/await 实现,但几乎没有引起任何关注。

是时候做出一些改变了。

Alex Crichton 实现的宏的核心问题之一,是一旦用户尝试在 await 点上保持对 Future 状态的引用,编译器就会报错。这实际上与用户在 futures 组合子中遇到的借用问题是相同的:在等待期间,Future 无法持有对自身状态的引用。因为这要求把 Future 编译成一个自引用的结构体,而 Rust 不支持这种实现。

如果把这个问题跟前文提到的绿色线程的问题进行比较,我们会得到一些有趣的结论。例如,我们说把 Future 编译成状态机的优势之一,是编译出来的状态机就是一个完美大小的栈。无论是手动实现、使用组合子或使用 async 函数构造出 Future 实例,编译后的 Future 大小都是它所需的最小大小。反观绿色线程,绿色线程的栈必须不断增长,以容纳任何线程栈可能具有的未知大小的状态。Future 状态机的大小在编译时确定后不再需要变化,因此,我们不会在运行时遇到栈增长的问题。

可以说,绿色线程的栈现在成了 Future 状态机结构保存的状态。由于 Rust 的语言约束要求移动结构体必须总是安全的,因此即使在执行期间,我们不会移动 Future 状态机,但是 Rust 的语言约束会要求它是能被移动的。这样,我们在状态机的方案里又遇到了绿色线程方案中遇到的栈指针问题。不过,这一次,我们有一个新的优势:我们不需要真的能够移动 Future 状态机,我们只需要表达 Future 状态机是不可移动的。

为了实现这一点,最初的尝试是引入 Move trait 的定义,这个 trait 用于将协程从可以移动它们的 API 中排除。不过,引入一个新的语言行为层面的 trait 遇到了我之前记录过的后向兼容性问题。关于 async/await 语法,当时有三个核心需求:

  1. Rust 语言层面就要实现 async/await 语法,从而支持用户使用类似协程的函数构建复杂的 Future 实例。
  2. async/await 语法需要支持将这些函数编译为自引用的结构体,从而支持用户在协程中使用引用。
  3. 这个功能需要尽快发布。

这三个需求的结合促使我寻找一种不对语言进行任何重大破坏性改变的替代方案。

我最初的计划比我们最终实现的方案要糟糕得多。我提议将 poll 方法标记为不安全的(unsafe),然后增加一个不变式,即一旦开始对一个 Future 进行轮询,就不能再次移动它。这个方案非常简单,可以立即实现,但是同时它也非常粗暴:它会使每个手写的 Future 都不安全,并且会施加一个难以验证的要求。更糟糕的是,编译器无法不安全的代码提供任何帮助。它很可能最终会遇到一些正确性问题,并且肯定会引起极大的争议。

所以,幸运的是,Eddy Burtescu 的几句话引导我思考另一个接口设计方向,这个 API 可以以更精细的方式强制执行所需的不变条件,也就是现在你所看到的 Pin 类型。Pin 类型本身有着一定的学习曲线,但我认为它还是显著优于当时我们考虑的其他选项,因为它是有针对性的、可强制执行的,而且,按照这个方案,我能按时发布 async/await 功能。

现在回顾起来,Pin 方案存在两类问题:

  1. 后向兼容性:一些已经存在的接口(特别是 Iterator 和 Drop 等)本应该支持不可移动类型。由于后向兼容性的要求,现在其实没有支持,这限制了语言进一步发展的可选项。
  2. 向终端用户暴露:我们的设计意图是,编写普通的异步 Rust 代码的开发者不需要处理 Pin 类型。在大多数情况下,这是正确的,但有一些典型的例外。几乎所有这些例外都可以通过一些语法改进来解决。唯一真正糟糕的问题是当前实现必须 Future 实例 Pin 住才能使用 .await 语法,这是一个不必要的错误,现在要修复它将是一个破坏性的改变(这着实让我感到尴尬)。

现在,关于 async/await 的设计决策只剩下语法的确定,我不会在这篇已经过长的文章中进行详细讨论。

组织上的考虑

我探索这段历史的原因是要证明一系列关于 Rust 的事实不可避免地将我们引入了特定的设计领域。

首先,Rust 没有运行时,所以绿色线程不是一个可行的解决方案。这不仅因为 Rust 需要支持嵌入到其他应用程序,或在嵌入式系统上运行,也因为 Rust 无法执行绿色线程所需的内存管理。

其次,Rust 的设计能够自然地把协程编译为高度可优化状态机,同时仍然保持内存安全性。我们不仅在 futures 和迭代器的设计中都利用了这一点。

但是,这段历史还有另一面:为什么我们要追求用户空间并发的运行时系统?为什么要引入 futures 和 async/await 语法?

提出这些问题的人通常有两种观点:

  1. 有些人习惯于手动管理用户空间并发,直接使用像 epoll 这样的接口;这些人有时会嘲笑 async/await 语法为“网络废物”。
  2. 另一些人只是说“你不需要它”,并提议使用更简单的操作系统并发机制,如线程和阻塞 IO 等。

在没有用户空间并发支持的语言,比如 C 语言中,实现高性能网络服务的人通常会使用手写的状态机。这正是 Future 抽象的设计目标,它可以自动编译成状态机,而无需手动编写状态机。实现无栈协程转换的目的,就是用户可以用顺序编程的方式编写代码,而编译器会在需要时生成状态转换以暂停执行。这样做有很大的好处。

例如,最近的一个 curl CVE 的底层原因就是代码在状态转换期间未能识别需要保存的状态。手动实现的状态机很容易出现这种逻辑错误,而 Rust 语言的 async/await 语法能在不损失性能的前提下避免这类逻辑错误。

2018 年初,Rust 团队决定在当年发布一个新的版本(edition),以解决一些在 1.0 版本中出现的语法问题。同时,Rust 团队计划以这个版本作为 Rust 进入主流市场的第一印象。Mozilla 团队主要由编译器黑客和类型理论学家组成,但我们对市场营销有一些基本的想法,并认识到这一版本可以成为产品广受关注的契机。我向 Aaron Turon 提议,我们应该专注于四个基本的用户故事,这些故事似乎是 Rust 的增长机会。这些故事包括:

  • 嵌入式系统
  • WebAssembly
  • 命令行接口(CLI)
  • 网络服务

这个建议成为建立领域工作组(Domain Working Groups)的起点,这些工作组旨在成为专注于特定使用领域的跨职能团队,区别于关注在具体技术或组织工作的团队(teams)。Rust 项目中工作组的概念后来有了很多改变,基本不再是最初设计的定义了,但我偏离了主题。

async/await 的工作开始于网络服务工作组,后来简称为 async 工作组并延续至今。然而,我们也非常清楚,由于 Rust 没有运行时依赖,Async Rust 也可以在其他领域发挥重要作用,尤其是嵌入式系统。我们设计这个功能时考虑到了这两种用例。

然而,尽管很少有人明说,Rust 要想成功,明显需要得到行业的采用。这样,即使 Mozilla 不再愿意资助一个实验性的新语言,Rust 也还能生存下去。而短期内行业采用的最有可能的途径,是在网络服务领域,特别是那些在当时被迫使用 C/C++ 编写的性能要求很高的系统。这个用例完全适合 Rust 的定位:这些系统需要细致的底层控制来满足性能要求,但是避免内存错误同样至关重要,因为它们面向网络。

网络服务的另一个优势是,这个软件行业领域具有快速采用新技术的灵活性和需求。其他领域当然也是 Rust 的长期发展的机会,但是它们不太愿意迅速采用新技术(嵌入式)、依赖于尚未广泛采用的新平台(WebAssembly)或者不是特别有利可图的工业应用,无法为 Rust 语言的发展带来资金支持(命令行界面)。

我以坚定的热情推动 async/await 的采用,因为我认为 Rust 的生存取决于这个功能。

在这方面,async/await 取得了巨大的成功。许多 Rust Foundation 最重要的赞助商,特别是那些雇佣开发者编写 Rust 代码的赞助商,重度依赖 async/await 编写高性能网络服务,这是他们愿意出资金支持的主要用例之一。在嵌入式系统或内核编程中使用 async/await 也是一个具有光明未来的领域。

async/await 在生态方面取得的巨大成功,甚至让开发者抱怨 Rust 生态系统滥用 async/await 而不是尽可能写“正常”的 Rust 代码。

我无法评价那些更愿意使用线程和阻塞 IO 的用户。当然,我认为有很多系统可以合理地采用这种方法,而且 Rust 语言本身并不阻止他们这样做。他们的反对意见似乎是,crates.io 上的生态系统,特别是用于编写网络服务的库,滥用 async/await 语法。我偶尔会看到一些使用 async/await 近乎船货崇拜(cargo cult)的库,但大多数情况下,可以合理地假设库作者实际上想要执行非阻塞 IO 以获得用户空间并发的性能优势。

我们无法控制每个人决定从事的工作。事实就是,大多数在 crates.io 上发布网络库的人都希望使用 async Rust 的能力,无论是出于商业原因还是出于兴趣。

当然,我希望在异步上下文以外也能方便的使用 async Rust 写成的库。这或许可以通过将类似于 pollster 的 API 引入标准库来实现。

不过,对于那些抱怨其他人发布的开源软件库不能恰好解决自己问题的人,我无言以对。

待续

虽然我认为 Rust 没有更好的 Async 解决方案,但是我并不认为 async/await 就是所有编程语言的最优解。特别是,我认为有可能有一种语言,它提供与 Rust 同等的可靠性保证,但是提供更少的对值的运行时表示的控制,同时使用有栈协程。我甚至认为,如果这种语言以一种可以同时用于迭代和并发的方式支持这些协程,那么该语言完全可以在不使用生命周期的情况下消除由于别名可变性而引起的错误。如果您阅读 Graydon Hoare 的笔记,您会发现这种语言正是他最初追求的目标。

我认为如果这种语言存在的话,有些 Rust 用户会非常愿意使用它,并且我理解他们为什么不喜欢处理底层细节的固有复杂性。以前,这些用户抱怨 Rust 语言有一大堆字符串类型,现在他们更有可能抱怨异步编程。我希望有一种提供与 Rust 同等保证的用于这些场景的语言存在,但是这不是 Rust 的问题。

尽管我认为 async/await 是 Rust 的正确演进路径,但我也同意对于当前的异步生态系统状况感到不满是合理的。我们在 2019 年发布了一个 MVP 实现,tokio 在 2020 年发布了 1.0 版本,自那时以来,Async Rust 的发展有些停滞不前。在后续的博文中,我希望讨论一下当前异步生态系统的状况,以及我认为项目可以做些什么来改善用户体验。但这已经是我发布过的最长的博客文章了,所以现在我只能到此为止了。

Elastic License 2.0 与开源协议的发展

作者 tison
2023年11月1日 08:00

译序

我在此前的多篇文章中讨论了商业开源的话题:

这些讨论当中观点的源头,除了我在商业开源公司的工作经历以外,也有对国外企业主和律师的内容的理解。其中,撰写了《Open Source for Business》(中文版为开放原子开源基金会律师刘伟翻译的《商业开源》)的大律师 Heather Meeker 的观点尤为重要。早在夜天之书 #6 一文里,我就引用过 Heather Meeker 的观点。

今年五月份前后,我读到了 Elastic License 2.0 and the Evolution of Open Source Licensing 一文。它是 Heather Meeker 律师带头撰写 Elastic License 2.0 协议背后的故事和自述。我深感它对于近五年商业开源软件形势发展的影响,于是向 Heather Meeker 申请了翻译本文的授权。今天终于有时间完成翻译,希望能帮助国内关注商业开源的企业家、开发者以及律师,了解发生在北美软件行业的一系列变化。

以下原文翻译。

2021 年 2 月,Elastic 发布了其软件产品的新协议,即 Elastic License 2.0 协议。通过这一举措,包括 ElasticSearch 和 Kibana 在内的一系列重要软件采用了一种新的、公开的以及简化的协议模型。这一变化是如何发生的?其背后的原因是什么?这些变化又意味着什么呢?

Elastic 的新协议是针对采用开放发展模式的公司在软件协议最佳实践方面的一个重要趋势的结晶。它并不是一个开源协议,但它旨在设定最低限度的限制,以在自由使用、共享和修改软件之间取得平衡,并防止对社群造成损害的行为的发生。

UNIX / Linux / 自由软件 / 开源

要想理解 Elastic License 2.0 所代表的新协议趋势,知道它是如何从开源协议运动中发展而来的至关重要。

开源运动或自由软件运动,源于开发者对软件私有化和软件开发分叉的担忧。UNIX 的一系列操作是这些担忧的来源。

UNIX 是当时最流行的操作系统。多年来,UNIX 的许可条件非常慷慨,因为它的开发者 AT&T 贝尔实验室受制于 1956 年的同意法令,不能从其研究项目中获利,其中包括 UNIX 和 C 语言。学者、研究人员和开发者开始分享他们的改动和改进,因此 UNIX 很快成为操作系统的领导者。

原注 “Modification of Final Judgment,” August 24, 1982, filed in case 82-0192, United States of America v. Western Electric Company, Incorporated, and American Telephone and Telegraph Company, U.S. District Court for the District of Columbia web.archive.org/web/20060827191354/members.cox.

然而,上述同意法令在 1983 年一经解除,AT&T 就立刻根据传统的商业条款设计不允许分享更改的软件协议。此后,UNIX 分裂成许多不兼容的版本,并且其专有软件协议禁止用户像以前那样通过分享改动进行合作。

自由软件运动以及随后的开源运动是对 UNIX 私有化的回应。它们试图防止基础设施软件再次走向封闭。这个运动以 UNIX 的自由软件替代品 Linux 为中心,并很快发展成一个认为“所有软件生而自由”的具有巨大影响力的运动。这个运动的核心理念包括用户有权访问源代码、改进软件和分享软件的改进版本。这些原则体现在 GNU 通用公共协议(GPL)中,该协议要求二进制文件的分发者必须向接受者免费提供相应的源代码。

随着时间的推移,尤其是 2000 年初互联网的兴起,开源协议变得越来越受欢迎。尽管部分开源协议(例如 GPL 协议)新颖且复杂,引发了一些法律上的担忧,但它们为企业间的合作铺平了道路。很快,开源以及它所促进的合作被整个技术行业全心接受。如今,开源是电子商务的支柱,企业经常合作开发基础软件。

云的兴起和 AGPL 协议

GPL 协议要求分享修改后的源代码,但是这个要求只在二进制分发时生效,即取得二进制的人有权索要源代码。但是,这意味着 GPL 允许制作和使用“私有版本”:如果不对外分发二进制,也就无需分享更改。在大多数软件仍然依靠本地分发的年代,这种方式有效地促使了分享。从 2000 年开始,软件交付开始向公共云迁移,软件服务提供商不再需要直接向客户分发任何二进制文件。相反,客户可以在不获取本地副本的情况下使用软件。

随着云服务的业务规模增长,这种范式转变激化了部分开源社群和 AWS 等企业之间的紧张关系。云厂商没有任何法律义务分享他们的改进。有点讽刺的是,这种情况有时被称为“Google 漏洞”。“Google 漏洞”这一称谓之所以说讽刺,是因为尽管谷歌依赖 Linux 来支持其搜索服务,但是谷歌和许多其他顶级云厂商(如 IBM 等)为包括 Linux 在内的开源社群做出过重大贡献。

自由软件社群对此的回应,是创造了一种名为 Affero GPL (AGPL) 的 GPL 替代形式。AGPL 3.0 与 GPL 3.0 几乎完全相同,但增加了一个远程网络交互条款,该条款规定:“如果你修改了程序,你的修改版本必须明显地向通过计算机网络与之远程交互的所有用户,提供通过某种标准或习惯的软件复制方式,无偿地从网络服务器获得您版本的相应源代码的支持。”这个新的协议旨在强制云厂商分享它们的源代码改进,从而再现 GPL 约束 Linux 发行版开放其源代码的成功。

If you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network … an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software….

AGPL 和双重许可

AGPL 自首次发布以来就备受争议。

在 GPL 3.0 起草并最终于 2007 年发布的过程中,有一派人想将 GPL 改为与如今的 AGPL 一样的网络共享模型。然而,自由软件社群最终决定保留 GPL 3.0 中的“漏洞”。

几个月后,自由软件基金会发布了 AGPL 作为解决该漏洞的替代方案。但是 AGPL 并没有得到广泛采用。不过,就像 GPL 有 Linux 这一杀手级应用,AGPL 也有自己的杀手级应用,这就是 MongoDB 数据库。

MongoDB 是一款非常受欢迎的分布式数据库产品。虽然一开始,很多企业难以理解和接受 AGPL 协议,但是大多数用户从未更改过软件,也没有将其作为服务提供,因此他们能够理性地决定在 AGPL 下使用该软件。

MongoDB 基于 AGPL 协议设计了它的双重许可商业模型,即软件可以根据被许可人选择的两种协议之一提供:(1)AGPL 协议(2)经过协商取得的商业软件协议。那些不希望遵守 AGPL 要求,或不愿进行法律分析以确定是否能够遵守的人,选择购买商业软件协议。

这种商业模型最初由 MySQL 开创,MySQL 当时使用了 GPL 的一个变种。随着时间的推移,AGPL 成为双重许可模式的首选软件协议。MongoDB 在这种协议模型下取得了相当的成功。AGPL 是常用软件协议里最强的强制共享(Copyleft)软件协议,因此在推动商业谈判方面最有用,也就被用在双重许可模型上。但是,AGPL 的起草者批评了这种使用 AGPL 的方式,称该商业模型是一种有害的勒索行为。尽管如此,APL 的源码分享条件并不足以阻止企业在不给开发者或用户社群带来任何回报的前提下大规模商用。

Strip-mining

译注:国外开源语境下的 strip-mining 含义和国内所谓的“白嫖开源”意义类似。

就像云计算的发展打破了基于 GPL 的双重许可模型一样,2010 年代云计算的进步,云交付模式开始对基于 AGPL 的双重许可模型施加压力。

这次问题有所不同,“漏洞”出现在 GPL 或 AGPL 的范围仅限于一个单独的程序可执行文件。这个“漏洞”是有意设计到 GPL 中的,理论上,版权协议只能为一个可受版权保护的作品规定条款。因此,GPL 对衍生作品(derivative works)有源代码共享的要求,但对集体作品(collective works)没有。在法律上,这两者之间的界线非常模糊,完全取决于观察者的主观看法。但在过去,随着 GPL 的普及,强大的行业实践已经形成:一个程序被定义为一个可执行进程。自由软件基金会在其 GPL FAQ 中长期阐述了这些原则。

然而,随着云服务的发展,发生了两件事:

  1. 软件工程越来越专注于云部署。云厂商曾经需要修改开源软件以使其能在云环境中正确运行,但软件工程的进步使现有的开源软件更加适应云厂商的“即插即用”需求。也就是说,不用修改一行代码,开源软件也可以与云基础设施良好的集成。
  2. 云厂商开始在核心开源软件之外进行创新。他们开发了额外的软件来管理、监控和部署软件。这些创新推动了云服务的业务增长。同时,它们是跟核心开源软件独立的软件,即使核心软件以 AGPL 发布,也无法强制云厂商分享这些辅助软件的源代码。

这两者相结合形成了这样一种形势:商业开源公司实际上成为大型云厂商“资产负债表以外的研发机构”。这个问题在开源的平台软件或中间件方面尤为突出,因为这些软件位于顶层应用程序和操作系统之间,在应用程序栈中起着重要作用,且对于云部署非常有用。

这种变化在商业界引起了对云厂商使用开源软件的强烈抗议。在 2018 年的一份具有里程碑意义的宣言中,贝恩资本的 Salil Deshpande 写道:“明确地说,这并不违法。但我们认为这是错误的,不利于开源社群的可持续发展。”另一位评论家写道:“AWS 正在攻击开源的阿喀琉斯之踵:窃取他人的工作,并销售租赁这些工作成果的服务。”问题在于,所有主要的开源协议都允许以这种方式使用软件。

商业开源公司及其投资者对开源模型的局限感到不满,他们手头没有任何软件协议可以利用版权法强制云厂商进行共享。即使是 GPL 和 AGPL 也对此无能为力。

同时,拥有庞大客户群的云厂商可以为开源软件提供更好的云平台集成。在 AWS、Azure 或 Google Cloud 平台上,客户可以轻松地一键添加软件。一些开源软件的开发者提供了自己的云服务,但是发现与免费使用他们开发的开源软件的大型云厂商竞争太困难了。即使开发者的服务更好,与云厂商建立合作关系也存在交易成本,而不仅仅是云厂商原生集成服务的一键集成体验。

SSPL 和源码可得协议

在 2018 年,整个行业的发展来到了一个临界点:随着 AWS 等云厂商持续不断地通过托管开源软件挣钱,开发者们开始采取应对措施,首先就是一系列快速的软件协议变更。

商业开源公司对 strip-mining 问题做出了两种不同的反应:一种是超强网络共享软件协议,另一种是带有限制条件的源码可得协议。在此之前,还没有人对这两类协议进行明确定义。这两类协议都旨在支持双重许可模型,即引导潜在客户经过协商购买商业软件协议,就像帮助构建 MySQL 和 MongoDB 的模式一样。

超强网络共享软件协议的方法由 MongoDB 推进发展,他们在 2018 年发布了 Server Side Public License (SSPL) 协议。SSPL 与 AGPL 几乎完全相同,但扩展了 AGPL 的远程网络条款,如下所述:

  1. Offering the Program as a Service.

If you make the functionality of the Program or a modified version available to third parties as a service, you must make the Service Source Code available via network download to everyone at no charge, under the terms of this License. Making the functionality of the Program or modified version available to third parties as a service includes, without limitation, enabling third parties to interact with the functionality of the Program or modified version remotely through a computer network, offering a service the value of which entirely or primarily derives from the value of the Program or modified version, or offering a service that accomplishes for users the primary purpose of the Program or modified version.

“Service Source Code” means the Corresponding Source for the Program or the modified version, and the Corresponding Source for all programs that you use to make the Program or modified version available as a service, including, without limitation, management software, user interfaces, application program interfaces, automation software, monitoring software, backup software, storage software and hosting software, all such that a user could run an instance of the service using the Service Source Code you make available. [emphasis added].

译注:法律文本翻译极其拗口,这里放原文。主要 SSPL 跟 AGPL 的区别就在于 AGPL 仅对衍生作品提出分享源码的要求,即前文所述的运行在同一进程的代码,而 SSPL 定义了 Service Source Code 的概念,即要求整个服务栈相关的代码都需要以 SSPL 的条款提供。

SSPL 的编写旨在为 strip-mining 问题提供一个开源解决方案。它的源码共享要求比 AGPL 更广泛。这种更广泛的源码共享描述被有意设计成类似于 GPL 对分发软件的要求的形式。MongoDB 继续采用双重许可模型,其软件可根据 SSPL 协议或经过协商的商业软件协议提供。

MongoDB 将 SSPL 提交给开源促进会(OSI)审议。经过数月的激烈争议后,SSPL 的 OSI 认证申请被驳回,但 MongoDB 继续在其双重许可模型的“开源”选项中使用 SSPL 协议。关于为什么 SSPL 符合或不符合开源定义,讨论很复杂。不过,符合开源定义并不是唯一的争论点,总体而言,这个要求共享范围如此广泛的软件协议是否能够“保证软件自由”,尚且没有一个明确的结论。

其他人选择了不同的道路。一些公司采用了由 Salil Deshpande 主持编写的 Commons Clause 条款,而其他公司则自行制定了软件协议,例如 RedisConfluentCockroachDB 等,以及 Elastic 公司的 Elastic License 1.0 协议。不同于 SSPL 协议,这些软件协议从未期望符合开源定义。相反,它们具有专门针对 strip-mining 的限制条款。

为什么选择了这些不同的道路?这与一个被称为 Freedom 0 的概念有关。Freedom 0 的内容是:用户按照自己的意愿运行程序,用于任何目的的自由。

原注:自由软件定义和开源定义类似,但是更加简短和清晰。

开源或自由软件协议的主要特点之一,就是它不包含任何许可限制或限制条件。

原注:开源协议可以包含条件,例如发布时包含 NOTICE 文件或要求共享源代码,但是这些并不是限制用户使用软件的规定。它们只要求如果用户选择执行某些操作,您也必须执行其他操作。(译注:例如,GPL 允许用户自由使用,但是如果一个人要分发修改版本的二进制,那么分发者需要保证接收者能够以和 GPL 相同的条款使用修改版本,其中包括取得修改版本的源代码。)

相比于典型的商业软件协议,终端用户协议只允许用户使用软件,但不允许分发或修改;企业软件协议通常限制软件使用的用户数、服务器数或物理位置,并要求公司对其使用进行审计。但是,开源协议不包含任何此类限制。因此,可能有些反直觉,虽然开源软件源代码总是免费提供,但是如果一个自称开源协议限制软件的非商业使用,那么它也违反了开源定义。

这意味着任何许可限制都使软件协议不再属于开源协议。

在 2018 年及以后的重新许可浪潮中发布的所有协议都具有大致相似的限制。虽然每个协议都有自己的条款,但它们都侧重于允许用户免费使用软件,同时禁止使用软件提供竞争性的托管服务。

Elastic License 2.0

在2021年初,Elastic 开辟了新的道路,同时选择了上述两种路径。它使用 SSPL 和 Elastic License 2.0 (ELv2) 对 ElasticSearch 进行双重许可。

ELv2 非常简短。它用通俗的语言编写,内容总共只有一页多。ELv2 授予用户一个典型开源协议授予的几乎所有自由。软件的接收者可以自由使用、更改和重新分发软件。即使您以前从未阅读过软件协议,也值得一读。

ELv2 在上述自由之外有两个关键限制:

You may not provide the software to third parties as a hosted or managed service, where the service provides users with access to any substantial set of the features or functionality of the software.

You may not move, change, disable, or circumvent the license key functionality in the software, and you may not remove or obscure any functionality in the software that is protected by the license key.

第一条限制是为了解决 strip-mining 问题。通过这条限制,ELv2 不授权接受者基于该软件提供托管服务。

第二条限制禁止破解软件许可密钥。这样的限制在软件许可中长期存在,但在源码可得协议中的使用刚刚开始。这些条款允许开发人员运行与软件交互的付费服务,或者保留一些软件组件用于付费功能。

ELv2 的其他条款非常直接,对于任何阅读过开源协议的人来说应该是熟悉的。

为什么选择双重许可?

在提供 SSPL 和 ELv2 两种选择给用户时,Elastic 选择了一条不寻常的道路。如今,许多公司采用“开放核心”模型,事实上,Elastic 之前也使用过这种模型。两者之间的区别可能很微妙。开放核心模型在开源协议下提供核心软件:通常使用 Apache License 2.0 这样的宽松开源协议。然后,这些公司开发额外的适用于大规模企业部署的功能,并只在限制型协议下提供,或者仅作为商业服务提供。但是,通过新的软件协议,Elastic 坚持采用双重许可模型,即相同的软件可在两种不同的软件协议下使用。这种模型由 MySQL 首创,通常使用类似 GPL、AGPL 或 SSPL 的强制共享协议作为免费软件协议选择。由于开源协议和云服务之间的紧张关系,这种模型在最近几年变得不太流行。

Elastic 的选择更加不寻常,因为它提供了两种免费的软件协议选择,SSPL 和 ELv2 都有免费使用的条款,而双重许可通常只提供一种免费选项。通过做出这个独特的选择,Elastic 强调了其灵活性,可以免费向几乎所有用户提供软件。

Elastic License 2.0 和软件协议的最新发展

Elastic 采用了新的软件协议模型,以尽可能保持开放性,同时保持对用户和开发人员公平可持续的商业模式。在这样做的过程中,它呼应了源代码可用运动中其他参与者的目标,并在创建软件协议时寻求同行的意见。

正如 ELv2 FAQ 提到的,Elastic 的软件协议变更预计不会对其客户群体产生影响,对社群用户的影响也很小。因为大多数用户在 Elastic 的软件上构建应用程序,并不从事“将软件作为托管或管理服务提供给第三方”的业务。

设计一个更好的软件协议

此外,通过投入资源起草 ELv2 协议,Elastic 努力推动软件协议起草的技术水平。从某种意义上说,源码可得协议的存在时间与软件一样长。事实上,仅二进制的软件协议是上世纪 80 年代 PC 平台标准化的产物;在那之前,几乎所有的软件都以源代码格式进行许可。但是随着时间的推移,软件协议的形式和部署方式发生了很大变化。

ELv2 是这一趋势的最终体现。在形式上,它采用了一些最受欢迎的开源协议特性:简单直观的起草和模板协议。它的密钥保留条款使得供应商发布同时包含免费功能和付费功能的软件事也能轻松地使用 ELv2 协议。

与几十年前专有 UNIX 的不兼容版本一样,专有软件协议是一个由自定义条款和条件组成的混乱合成体。即使是普通消费者软件产品的简单终端用户协议通常也很长,晦涩难懂,大多数用户无法理解。关于没人阅读用户协议的笑话屡见不鲜。但是,大部分复杂性是不必要的。这是开源协议的一个教训,特别是宽松开源协议:一套简单的规则就足够了,而且规则越易理解,用户越有可能遵守。

ELv2 不仅简短、简单和易理解,而且其他人也可以将其用作模板。自反对 strip-mining 的辩论开始以来,用户愈发希望出现一个能够支持流畅部署软件、可以具有合理限制,但是必须简单易懂的软件协议。但是,大多数小型软件公司没有资源来起草自己的协议。因此,毫不奇怪,许多软件初创公司都希望采用 ELv2 或 Confluent Community License 等现成软件协议来构成其软件协议模型。

这个趋势愈演愈烈,最终形成了一个名为 Fair Code 的倡议和标准,其中写到:

Fair-code is not a software license. It describes a software model where software:

  • is generally free to use and can be distributed by anybody
  • has its source code openly available
  • can be extended by anybody in public and private communities
  • is commercially restricted by its authors

虽然这项倡议还处于非常早期阶段,但显然行业开始意识到需要一个公平对待用户和开发者的范式。同时,这一范式还应当允许商业开发者,以一种比如今的开源协议更加灵活的方式,在两者之间取得平衡。一位评论员甚至将最近的软件协议发展称为后开源时代。不过事实上,在商业和软件协议模型不断发展的过程中,源码可得协议与开源协议通常是并行发展的。因此,这两种模型互补,而不是互为替代品。

同一时间,其他标准化的软件协议工作也在进行。2020 年,一群律师发起了 PolyForm 项目,起草了一系列源码可得协议模板。这些软件协议由在开源和专有协议方面经验丰富的律师进行同行评审。就像 Creative Commons 用于开放内容协议一样,它提供了一系列选项,如非商业、仅用于评估和反竞争协议。所有这些软件协议,就像 ELv2 一样,都允许免费使用、提供源代码,并授予必要的专利许可。PolyForm Perimeter 和 PolyForm Shield 与其前身 Confluent Community License 类似,而 ELv2 也遵循了这一趋势,推进了软件协议可选项的发展。

如果您有疑问或想要阅读更多信息,这里有一些资源:

注:本文的起草代表了我(Heather Meeker)的个人观点。然而,我想指出,撰写这篇白皮书的工作部分受到了 Elastic 的资助。

❌
❌