mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-16 22:23:45 +08:00
del
This commit is contained in:
88
极客时间专栏/geek/编译原理实战课/现代语言设计篇/27 | 课前导读:学习现代语言设计的正确姿势.md
Normal file
88
极客时间专栏/geek/编译原理实战课/现代语言设计篇/27 | 课前导读:学习现代语言设计的正确姿势.md
Normal file
@@ -0,0 +1,88 @@
|
||||
<audio id="audio" title="27 | 课前导读:学习现代语言设计的正确姿势" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b6/b8/b68c22c06137ee17a4ac4017a9cd8db8.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
到目前为止,你就已经学完了这门课程中前两个模块的所有内容了。在第一个模块“预备知识篇”,我带你梳理了编译原理的关键概念、算法等核心知识点,帮你建立了一个直观的编译原理基础知识体系;在第二个模块“真实编译器解析篇”,我带你探究了7个真实世界的编译器,让你对编译器所实际采用的各种编译技术都有所涉猎。那么在接下来的第三个模块,我会继续带你朝着提高编译原理实战能力的目标前进。这一次,我们从计算机语言设计的高度,来印证一下编译原理的核心知识点。
|
||||
|
||||
对于一门完整的语言来说,编译器只是其中的一部分。它通常还有两个重要的组成部分:一个是**运行时**,包括内存管理、并发机制、解释器等模块;还有一个是**标准库**,包含了一些标准的功能,如算术计算、字符串处理、文件读写,等等。
|
||||
|
||||
再进一步来看,我们在实现一门语言的时候,首先要做的,就是确定这门语言所要解决的问题是什么,也就是需求问题;其次,针对需要解决的问题,我们要选择合适的技术方案,而这些技术方案正是分别由编译器、运行时和标准库去实现的。
|
||||
|
||||
所以,从计算机语言设计的高度来印证编译原理知识,我们也能更容易理解编译器的任务,更容易理解它是如何跟运行时环境去做配合的,这也会让你进一步掌握编译技术。
|
||||
|
||||
好了,那接下来就一起来看看,到底用什么样的方式,我们才能真正理解计算机语言的设计思路。
|
||||
|
||||
首先,我们来聊一聊实现一门计算机语言的关键因素:需求和设计。
|
||||
|
||||
## 如何实现一门计算机语言?
|
||||
|
||||
我们学习编译原理的一个重要的目标,就是能够实现一门计算机语言。这种语言可能是你熟悉的某些高级语言,也可能是某个领域、为了解决某个具体问题而设计的DSL。就像我们在第二个模块中见到的SQL,以及编译器在内部实现时用到的一些DSL,如Graal生成LIR时的模式匹配规则、Python编译器中的ASDL解析器,还有Go语言编译器中IR的重写规则等。
|
||||
|
||||
**那么要如何实现一门优秀的语言呢?**我们都知道,要实现一个软件,有两个因素是最重要的,一个是需求,一个是设计。**计算机语言作为一种软件,具有清晰的需求和良好的设计,当然也是至关重要的。**
|
||||
|
||||
我先来说说**需求问题**,也就是计算机语言要解决的问题。
|
||||
|
||||
这里你要先明确一件事,如果需求不清晰、目标不明确,那么想要实现这门语言其实是很难成功的。通常来说,我们不能指望任何一种语言是全能的,让它擅长解决所有类型的问题。所以,每一门语言都有其所要解决的针对性问题。
|
||||
|
||||
举个例子,JavaScript如果单从设计的角度来看,有很多细节值得推敲,有不少的“坑”,比如null、undefined和NaN几个值就很令人困惑,你知道“null==undefined”的值是true还是false吗?但是它所能解决的问题也非常清晰,就是作为浏览器的脚本语言,提供Web的交互功能。在这个方面,它比同时期诞生的其他竞争技术,如ActiveX和Java Applet,都更具优势,所以它才能胜出。
|
||||
|
||||
历史上的计算机语言,都是像JavaScript那样,在满足了那个时代的某个需求以后而流行起来的。其中,根据“硅谷创业之父”保罗·格雷厄姆(Paul Graham)在《黑客与画家》中的说法,这些语言往往是一个流行的系统的脚本。比如说,C语言是Unix系统的脚本,COBOL是大型机的脚本,SQL是数据库系统的脚本,JavaScript、Java和C#都是浏览器的脚本,Swift和Objective-C是苹果系统的脚本,Kotlin是Android的脚本。让一门语言成为某个流行的技术系统的脚本,为这个生态提供编程支持,就是一种定位很清晰的需求。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/f7/f9034be8cf1e452808154bb16b3c7df7.jpg" alt="" title="各种编程语言的产生,以及与技术生态的关联">
|
||||
|
||||
好,明确了语言的需求以后,我们再来说说**设计问题**。
|
||||
|
||||
设计是实现计算机语言这种软件所要做的技术选择。你已经看到,我们研究的不同语言,其实现技术都各有特点,分别服务于该语言的需求问题,或者说设计目标。
|
||||
|
||||
我还是拿JavaScript来举例子。JavaScript被设计成了一门解释执行的语言,这样的话,它就能很方便地嵌入到HTML文本中,随着HTML下载和执行,并且支持不同的客户端操作系统和浏览器平台。而如果是需要静态编译的语言,就没有这么方便。
|
||||
|
||||
再进一步,由于HTML下载完毕后,JavaScript就要马上执行,从而也对JavaScript的编译速度有了更高的要求,所以我们才会看到V8里面的那些有利于快速解析的技术,比如通过查表做词法分析、懒解析等。
|
||||
|
||||
另外,因为JavaScript早期只是在浏览器里做一些比较简单的工作,所以它一开始没有设计并发计算的能力。还有,由于每个页面运行的JavaScript都是单独的,并且在页面退出时就可以收回内存,因此JavaScript的垃圾收集功能也不需要太复杂。
|
||||
|
||||
作为对比,Go语言的设计主要是用来编写服务端程序的,那么它的关键特性也是与这个定位相适应。
|
||||
|
||||
- **并发**:服务端的软件最重要的一项能力就是支持大量并发任务。Go在语言设计上把并发能力作为第一等级的语言要素。
|
||||
- **垃圾收集**:由于垃圾收集会造成整个应用程序停下来,所以Go语言特别重视降低由于垃圾收集而产生的停顿。
|
||||
|
||||
那么总结起来,我们要想成功地实现一门语言,要把握两个要点:第一,要弄清楚该语言的需求,也就是它要去解决的问题;第二,要确定合适的技术方案,来更好地解决它要面对的问题。
|
||||
|
||||
计算机语言的设计会涉及到比较多的内容,为了防止你在学习时抓不到重点,我在第三个模块里,挑了一些重点的内容来做讲解,比如前面提到的垃圾收集的特性等。我会以第二个模块所研究的多门语言和编译器作为素材,一起探讨一下,各门语言都是采用了什么样的技术方案来满足各自的设计目标的,从而让你对计算机语言设计所考虑的因素、编译技术如何跟其他相关技术打配合,形成一个宏观的认识。
|
||||
|
||||
## “现代语言设计篇”都会讲哪些内容?
|
||||
|
||||
这个模块的内容,我根据计算机语言的组成和设计中的关键点,将其分成了三个部分。
|
||||
|
||||
**第一部分,是对各门语言的编译器的前端、中端和后端技术做一下对比和总结。**
|
||||
|
||||
这样,通过梳理和总结,我们就可以找出各种编译器之间的异同点。对于其共同的部分,我们可以看作是一些最佳实践,你在自己的项目中可以大胆地采用;而有差异的部分,则往往是某种编译器为了实现该语言的设计目标而采用的技术策略,你可以去体会各门语言是如何做取舍的,这样也能变成你自己的经验储备。
|
||||
|
||||
**第二部分,主要是对语言的运行时和标准库的实现技术做一个解析。**
|
||||
|
||||
我们说过,一门语言要包括编译器、运行时和标准库。在学习第二个模块的时候,你应该已经有了一些体会,你能发现编译器的很多特性是跟语言的运行时密切相关的。比如,Python有自己独特的对象体系的设计,那么Python的字节码就体现了对这些对象的操作,字节码中的操作数都是对象的引用。
|
||||
|
||||
那么在这一部分,我就分为了几个话题来进行讲解:
|
||||
|
||||
- 第一,是对语言的运行时和标准库的宏观探讨。我们一起来看看不同的语言的运行时和它的编译器之间是如何互相影响的。另外,我还会和你探讨语言的基础功能和标准库的实现策略,这是非常值得探讨的知识点,它让一门语言具备了真正的实用价值。
|
||||
- 第二,是垃圾收集机制。本课程分析、涉及的几种语言,它们所采用的垃圾收集机制都各不相同。那么,为什么一门语言会选择这个机制,而另一种语言会选择另一种机制呢?带着这样的问题所做的分析,会让你把垃圾收集方面的原理落到实践中去。
|
||||
- 第三,是并发模型。对并发的支持,对现代语言来说也越来越重要。在后面的课程中,我会带你了解线程、协程、Actor三种并发模式,理解它们的优缺点,同时你也会了解到,如何在编译器和运行时中支持这些并发特性。
|
||||
|
||||
**第三部分,是计算机语言设计上的4个高级话题。**
|
||||
|
||||
第一,是**元编程技术**。元编程技术是一种对语言做扩展的技术,相当于能够定制一门语言,从而更好地解决特定领域的问题。Java语言的注解功能、Python的对象体系的设计,都体现了元编程功能。而Julia语言,更是集成了Lisp语言在元编程方面的强大能力。因此我会带你了解一下这些元编程技术的具体实现机制和特点,便于你去采纳和做好取舍。
|
||||
|
||||
第二,是**泛型编程技术**。泛型,或者说参数化类型,大大增强了现代语言的类型体系,使得很多计算逻辑的表达变得更简洁。最典型的应用就是容器类型,比如列表、树什么的,采用泛型技术实现的容器类型,能够方便地保存各种数据类型。像Java、C++和Julia等语言都支持泛型机制,但它们各自实现的技术又有所不同。我会带你了解这些不同实现技术背后的原因,以及各自的特点。
|
||||
|
||||
第三,是**面向对象语言的实现机制**。面向对象特性是当前很多主流语言都支持的特性。那么要在编译器和运行时上做哪些工作,来支持面向对象的特性呢?对象在内存里的表示都有哪些不同的方式?如何实现继承和多态的特性?为什么Java支持基础数据类型和对象类型,而有些语言中所有的数据都是对象?要在编译技术上做哪些工作来支持纯面向对象特性?这些问题,我会花一讲的时间来带你分析清楚,让你理解面向对象语言的底层机制。
|
||||
|
||||
第四,是**函数式编程语言的实现机制**。函数式编程这个范式出现得很早,不少人可能不太了解或者不太关注它,但最近几年出现了复兴的趋势。像Java等面向对象语言,也开始加入对函数式编程机制的支持。在第三个模块中,我会带你分析函数式编程的关键特征,比如函数作为一等公民、不变性等,并会一起探讨函数式编程语言实现上的一些关键技术,比如函数类型的内部表示、针对函数式编程特有的优化算法等,让你真正理解函数式编程语言的底层机制。
|
||||
|
||||
该模块的最后一讲,也是本课程的最后一讲,是对我们所学知识的一个综合检验。这个检验的题目,就是**解析方舟编译器**。
|
||||
|
||||
方舟编译器,应该是第一个引起国内IT界广泛关注的编译器。俗话说,外行看热闹,内行看门道。做一个编译器,到底有哪些关键的技术点?它们在方舟编译器里是如何体现的?我们在学习了编译原理的核心基础知识,在考察了多个编译器之后,应该能够有一定的能力去考察方舟编译器了。这也是学以致用、紧密结合实际的表现。通过这样的分析,你能了解到中国编译技术崛起的趋势,甚至还可能会思考如何参与到这个趋势中来。这一讲,我希望同学们都能发表自己的看法,而我的看法呢,只是一家之言,你作为参考就好了。
|
||||
|
||||
## 小结
|
||||
|
||||
总结一下。咱们课程的名称是《编译原理实战课》,而最体现实战精神的,莫过于去实现一门计算机语言了。而在第三个模块,我就会带你解析实现一门计算机语言所要考虑的那些关键技术,并且通过学习,你也能够根据语言的设计目标来选择合适的技术方案。
|
||||
|
||||
从计算机语言设计的高度出发,这个模块会带你对编译原理形成更全面的认知,从而提高你把编译原理用于实战的能力。
|
||||
344
极客时间专栏/geek/编译原理实战课/现代语言设计篇/28 | 前端总结:语言设计也有人机工程学.md
Normal file
344
极客时间专栏/geek/编译原理实战课/现代语言设计篇/28 | 前端总结:语言设计也有人机工程学.md
Normal file
@@ -0,0 +1,344 @@
|
||||
<audio id="audio" title="28 | 前端总结:语言设计也有人机工程学" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6c/d5/6cc5f61f2593507bf90ac02507a301d5.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
正如我在上一讲的“课程导读”中所提到的,在“现代语言设计篇”,我们会开始探讨现代语言设计中的一些典型特性,包括前端、中后端、运行时的特性等,并会研究它们与编译技术的关系。
|
||||
|
||||
今天这一讲,我先以前面的“真实编译器解析篇”所分析的7种编译器作为基础,来总结一下它们的前端技术的特征,为你以后的前端工作做好清晰的指引。
|
||||
|
||||
在此基础上,我们还会进一步讨论语言设计方面的问题。近些年,各种新语言都涌现出了一个显著特征,那就是越来越考虑对程序员的友好性,运用了人机工程的思维。比如说,自动类型推导、Null安全性等。那么在这里,我们就一起来分析一下,要支持这些友好的语法特征,在编译技术上都要做一些什么工作。
|
||||
|
||||
好,首先,我们就来总结一下各个编译器的前端技术特征。
|
||||
|
||||
## 前端编译技术总结
|
||||
|
||||
通过前面课程中对7个编译器的解读分析,我们现在已经知道了,编译器的前端有一些共性的特征,包括:手写的词法分析器、自顶向下分析为主的语法分析器和差异化的语义分析功能。
|
||||
|
||||
### 手写的词法分析器
|
||||
|
||||
我们分析的这几个编译器,全部都采用了手写的词法分析器。主要原因有几个:
|
||||
|
||||
- 第一,手写的词法分析实现起来比较简单,再加上每种语言的词法规则实际上是大同小异的,所以实现起来也都差不多。
|
||||
- 第二,手写词法分析器便于做一些优化。典型的优化是把关键字作为标识符的子集来识别,而不用为识别每个关键字创建自动机。V8的词法分析器还在性能上做了调优,比如判断一个字符是否是合法的标识符字符,是采用了查表的方法,以空间换性能,提高了解析速度。
|
||||
- 第三,手写词法分析器便于处理一些特殊的情况。在 [MySQL的词法分析器](https://time.geekbang.org/column/article/266790)中,我们会发现,它需要根据当前字符集来确定某个字符串是否是合法的Token。如果采用工具自动生成词法分析器,则不容易处理这种情况。
|
||||
|
||||
**结论:如果你要实现词法分析器,可以参考这些编译器,来实现你自己手写的版本。**
|
||||
|
||||
### 自顶向下分析为主的语法分析器
|
||||
|
||||
在“解析篇”中,我们还见到了多个语法分析器。
|
||||
|
||||
**手写 vs 工具生成**
|
||||
|
||||
在前面解析的编译器当中,大部分都是手写的语法分析器,只有Python和MySQL这两个是用工具生成的。
|
||||
|
||||
一方面,手写实现能够在某些地方做一些优化的实现,比如在Java语言里,我们可以根据需要,预读一到多个Token。另外,手写实现也有利于编译错误的处理,这样可以尽量给用户提供更友好的编译错误信息,并且当一个地方发生错误以后,也能尽量不影响对后面的语句的解析。手写的语法分析器在这些方面都能提供更好的灵活性。
|
||||
|
||||
另一方面,Python和MySQL的编译器也证明了,用工具生成的语法分析器,也是完全可以用于高要求的产品之中的。所以,如果你的项目时间和资源有限,你要优先考虑用工具生成语法分析器。
|
||||
|
||||
**自顶向下 vs 自底向上**
|
||||
|
||||
我们知道,语法分析有两大算法体系。一是自顶向下,二是自底向上。
|
||||
|
||||
从我们分析过的7种编译器里可以发现,**自顶向下的算法体系占了绝对的主流**,只有MySQL的语法分析器,采用的是自底向上的LALR算法。
|
||||
|
||||
而在自顶向下的算法中,又几乎全是采用了递归下降算法,Java、JavaScript和Go三大语言的编译器都是如此。并且对于左递归这个技术点,我们用标准的改写方法就可以解决。
|
||||
|
||||
不过,我们还看到了自顶向下算法和自底向上算法的融合。Java语言和Go语言在处理二元表达式时,引入了运算符优先级解析器,从而避免了左递归问题,并且在处理优先级和结合性的问题上,也会更加容易。而运算符优先级解析器,实际上采用的是一种LR算法。
|
||||
|
||||
### 差异化的语义分析功能
|
||||
|
||||
不同编译器的语义分析功能有其共性,那就是都要建立符号表、做引用消解。对于静态类型的语言来说,还一定要做类型检查。
|
||||
|
||||
语义分析最大的特点是**上下文相关**,AST加上这些上下文相关的关系,就从树变成了图。由于处理图的算法一般比较复杂,这就给引用消解带来了困难,因此我们在算法上必须采用一定的启发式规则,让算法简化。
|
||||
|
||||
比如,我们可以先把类型加入符号表,再去消解用到这些类型的地方:变量声明、方法声明、类继承的声明,等等。你还需要注意的是,在消解本地变量的时候,还必须一边消解,一边把本地变量加入符号表,这样才能避免形成错误的引用关系。
|
||||
|
||||
不过,在建立符号表,并做完引用消解以后,上下文相关导致的复杂性就被消除了。所以,后续的语义分析算法,我们仍然可以通过简单地遍历AST来实现。所以,你会看到这些编译器当中,大量的算法都是实现了Visitor模式。
|
||||
|
||||
另外,除了建立符号表、做引用消解和类型检查等语义分析功能,不同的编译器还要去处理自己特有的语义。比如说,Java编译器花了很多的工作量在处理语法糖上,还有对注解的处理上;Julia的编译器会去做类型推断;Python的编译器会去识别变量的作用域范围,等等。
|
||||
|
||||
这其中,很多的语义处理功能,都是为了支持更加友好的语言特性,比如Java的语法糖。在现代语言中,还增加了很多的特性,能够让程序员的编程工作更加容易。接下来,我就挑几个共性的特性,跟你一起探讨一下它们的实现。
|
||||
|
||||
## 支持友好的语言特性
|
||||
|
||||
自动类型推导、Null安全性、通过语法糖提高语法的友好性,以及提供一些友好的词法规则,等等。这些都是现代语言努力提高其友好性的表现。
|
||||
|
||||
### 自动类型推导
|
||||
|
||||
**自动类型推导可以减少编程时与类型声明有关的工作量。**我们来看看下面这几门语言,都是如何声明变量的。
|
||||
|
||||
**C++语言**是一门不断与时俱进的语言。在C++ 11中,采用了auto关键字做类型推导。比如:
|
||||
|
||||
```
|
||||
int a = 10;
|
||||
auto b = a; //能够自动推导b的类型是int
|
||||
cout << typeid(b).name() << endl; //输出int
|
||||
|
||||
```
|
||||
|
||||
你可能会觉得,这看上去似乎也没啥呀,把int换成了auto好像并没有省多少事儿。但在下面这个例子中,你会发现用于枚举的变量的类型很长(`std::vector<std::string>::iterator`),那么你就大可以直接用一个auto来代替,省了很多事,代码也更加整洁。所以实际上,auto关键字也成为了在C++中使用枚举器的标准用法:
|
||||
|
||||
```
|
||||
std::vector<std::string> vs;
|
||||
for(std::vector<std::string>::iterator i=vs.begin(); i!=vs.end();i++){
|
||||
//...
|
||||
}
|
||||
//使用auto以后,简化为:
|
||||
fora(auto i=vs.begin(); i!=vs.end();i++){
|
||||
//...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们接着来看看其他的语言,都是如何做类型推导的。
|
||||
|
||||
**Kotlin**中用var声明变量,也支持显式类型声明和类型推导两种方式。
|
||||
|
||||
```
|
||||
var a : Int = 10; //显式声明
|
||||
var b = 10; //类型推导
|
||||
|
||||
```
|
||||
|
||||
**Go语言**,会用“:=” 让编译器去做类型推导:
|
||||
|
||||
```
|
||||
var i int = 10; //显示声明
|
||||
i := 10; //类型推导
|
||||
|
||||
```
|
||||
|
||||
而**Swift语言**是这样做的:
|
||||
|
||||
```
|
||||
let a : Int = 10; //常量类型显式声明
|
||||
let b = 10; //常量类型推导
|
||||
var c : Int = 10; //变量类型显式声明
|
||||
var c = 10; //变量类型推导
|
||||
|
||||
```
|
||||
|
||||
实际上,连**Java语言**也在Java 10版本加上了类型推导功能,比如:
|
||||
|
||||
```
|
||||
Map<String, User> a = new HashMap<String, User>(); //显式声明
|
||||
var b = new HashMap<String, User>(); //类型推导
|
||||
|
||||
```
|
||||
|
||||
你在学习了语义分析中,**基于属性计算做类型检查的机制**以后,就会发现实现类型推导,其实是很容易的。只需要把等号右边的初始化部分的类型,赋值给左边的变量就行了。
|
||||
|
||||
可以看到,在不同的编译器的实现当中,类型推导被如此广泛地接受,所以如果你要设计一门新的语言,你也一定要考虑类似的做法。
|
||||
|
||||
好,我们接着再来探讨下一个有趣的特性,它叫做“Null安全性”。
|
||||
|
||||
### Null安全性
|
||||
|
||||
在C++和Java等语言里,会用Null引用,来**表示某个变量没有指向任何对**象。这个特性使得语言里充满了Null检查,否则运行时就会报错。
|
||||
|
||||
给你举个例子。下面这段代码中,我们想要使用student.teacher.name这个成员变量,因此程序要逐级检查student、teacher和name是否为Null。不检查又不行,检查又太啰嗦。你在自己写程序的时候,肯定也遇到过这种困扰。
|
||||
|
||||
```
|
||||
if (student != null
|
||||
&& student.teacher != null
|
||||
&& student.teacher.name !=null){
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Null引用其实是托尼·霍尔(Tony Hoare)在1960年代在设计某一门语言(ALGOL W)时引入的,后来也纷纷被其他语言所借鉴。但Hoare后来却认为,这是一个“价值亿万美元的错误”,你可以看看他在[QCon上的演讲](https://www.infoq.com/presentations/Null-References-The-Billion-Dollar-Mistake-Tony-Hoare/)。因为大量的软件错误都是由Null引用引起的,而计算机语言的设计者本应该从源头上消除它。
|
||||
|
||||
其实我觉得Hoare有点过于自责了。因为在计算机语言发展的早期,很多设计决定的后果都是很难预料的,当时的技术手段也很有限。而在计算机语言已经进化了这么多年的今天,我们还是有办法消除或者减少Null引用的不良影响的。
|
||||
|
||||
以Kotlin为例,在缺省情况下,它不允许你把Null赋给变量,因此这些变量就不需要检查是否为Null。
|
||||
|
||||
```
|
||||
var a : String = "hello";
|
||||
a = null; //报编译错误
|
||||
|
||||
```
|
||||
|
||||
不过有的时候,**你确实需要用到Null,那该怎么办?**
|
||||
|
||||
你需要这样的声明变量,在类型后面带上问号,告诉编译器这个变量可为空:
|
||||
|
||||
```
|
||||
var a : String? = "hello";
|
||||
a = null; //OK
|
||||
|
||||
```
|
||||
|
||||
但接下来,如果你要使用a变量,就必须进行Null检查。这样,编译器会跟踪你是否做了所有的检查。
|
||||
|
||||
```
|
||||
val l = b.length; //编译器会报错,因为没有做null检查
|
||||
|
||||
if (b != null){
|
||||
println(b.length); //OK,因为已经进行了null检查
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
或者,你可以进行**安全调用**(Safe Call),采用**“?.”操作符**来访问b.length,其返回值是一个Int?类型。这样的话,即使b是Null,程序也不会出错。
|
||||
|
||||
```
|
||||
var l : Int? = b?.length;
|
||||
|
||||
```
|
||||
|
||||
并且,如果你下一步要使用l变量的话,就要继续进行Null的检查。编译器会继续保持跟踪,让整个过程不会有漏洞。
|
||||
|
||||
而如果你对一个本身可能为Null的变量赋值,编译器会生成Null检查的代码。如果该变量为Null,那么赋值操作就会被取消。
|
||||
|
||||
在下面的示例代码中,如果student或是teacher,或者是name的值为Null,赋值操作都不会发生。这大大减少了那种啰嗦的Null检查:
|
||||
|
||||
```
|
||||
student?.teacher?.name=course.getTeacherName();
|
||||
|
||||
```
|
||||
|
||||
你可以看到,Kotlin通过这样的机制,就大大降低了Null引用可能带来的危害,也大大减少了Null检查的代码量,简直是程序员的福音。
|
||||
|
||||
而且,不仅是Kotlin语言具有这个特性,Dart、Swift、Rust等新语言都提供了Null安全性。
|
||||
|
||||
**那么,Null安全性在编译器里应该怎样实现呢?**
|
||||
|
||||
最简单的,你可以给所有的类型添加一个属性:**Nullable**。这样就能区分开Int?和Int类型,因为对于后者来说,Null不是一个合法的取值。之后,你再运用正常的属性计算的方法,就可以实现Null安全性了。
|
||||
|
||||
接下来,我们再看看现代语言会采用的一些语法糖,让语法更友好。
|
||||
|
||||
### 一些友好的语法糖
|
||||
|
||||
**1.分号推断**
|
||||
|
||||
分号推断的作用是在编程的时候,让程序员省略掉不必要的分号。在Java语言中,我们用分号作为一个语句的结尾。而像Kotlin等语言,在一个语句的最后,可以加分号,也可以不加。但如果两个语句在同一行,那么就要加分号了。
|
||||
|
||||
**2.单例对象**
|
||||
|
||||
在程序中,我们经常使用单例的数据模式。在Java、C++等语言中,你需要写一些代码来确保只生成类的一个实例。而在Scala、Kotlin这样的语言中,可以直接声明一个单例对象,代码非常简洁:
|
||||
|
||||
```
|
||||
object MyObject{
|
||||
var field1...
|
||||
var field2...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**3.纯数据的类**
|
||||
|
||||
我们在写程序的时候,经常需要处理一些纯粹的数据对象,比如数据库的记录等。而如果用传统的类,可能编写起来会很麻烦。比如,使用Java语言的话,你需要为这些类编写toString()方法、hashCode()方法、equals()方法,还要添加很多的setter和getter方法,非常繁琐。
|
||||
|
||||
所以,在JDK 14版本,就增加了一个实验特性,它可以**支持Record类**。比如,你要想定义一个Person对象,只需要这样一句话就行了:
|
||||
|
||||
```
|
||||
public record Person(String firstName, String lastName, String gender, int age){}
|
||||
|
||||
```
|
||||
|
||||
这样一个语句,就相当于下面这一大堆语句:
|
||||
|
||||
```
|
||||
public final class Person extends Record{
|
||||
private final String firstName;
|
||||
private final String lastName;
|
||||
private final String gender;
|
||||
private final int age;
|
||||
|
||||
public Person(String firstName, String lastName, String gender, int age){
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.gender = gender;
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
public String getFirstName(){
|
||||
return this.firstName;
|
||||
}
|
||||
|
||||
public String getLastName(){
|
||||
return this.lastName;
|
||||
}
|
||||
|
||||
public String getGender(){
|
||||
return this.gender;
|
||||
}
|
||||
|
||||
public String getAge(){
|
||||
return this.age;
|
||||
}
|
||||
|
||||
pulic String toString(){
|
||||
...
|
||||
}
|
||||
|
||||
public boolean equals(Object o){
|
||||
...
|
||||
}
|
||||
|
||||
public int hashCode(){
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以你可以看到,Record类真的帮我们省了很多的事儿。Kotlin也有类似的data class,而Julia和Swift内置支持元组,对纯数据对象的支持也比较好。
|
||||
|
||||
**4.没有原始类型,一切都是对象**
|
||||
|
||||
像Java、Go、C++、JavaScript等面向对象的语言,既要支持基础的数据类型,如整型、浮点型,又要支持对象类型,它们对这两类数据的使用方式是不一致的,因此也就增加了我们的编程负担。
|
||||
|
||||
而像Scala、Kotlin等语言,它们可以把任何数据类型都看作是对象。比如在Kotlin中,你可以直接调用一个整型或浮点型数字的方法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/5f/64011ee94443f36b0189c4c243f8be5f.jpg" alt="">
|
||||
|
||||
**不过你要注意的是**,如果你要把基础数据类型也看作是对象,在编译器的实现上要做一些特殊的处理,因为如果把这些基础数据当作普通对象一样保存在堆里,那显然要占据太多的空间(你可以回忆一下[Java对象头所需要的空间](https://time.geekbang.org/column/article/257504)),并且访问性能也更低。
|
||||
|
||||
**那么要如何解决这些问题呢?**这里我先留一个伏笔,我们在“综合实现(一):如何实现面向对象编程?”这一讲再来讨论吧!
|
||||
|
||||
除了语法上的一些友好设计之外,一些现代语言还在词法规则方面,提供了一些友好的设计。我们一起来看一下。
|
||||
|
||||
### 一些友好的词法规则
|
||||
|
||||
**1.嵌套的多行注释**
|
||||
|
||||
编程语言一般都支持多行注释。比如,你可以把暂时用不到的一段代码给注释起来。这些代码里如果有单行注释也不妨碍。
|
||||
|
||||
但是,像Kotlin、Swift这些语言又更进了一步,它们可以支持在多行注释里嵌套多行注释。这是一个很贴心的功能。这样的话,你就可以把连续好几个函数或方法给一起注释掉。因为函数或方法的头部,一般都有多行的头注释。支持嵌套注释的话,我们就可以把这些头注释一起包含进去。
|
||||
|
||||
你可以去看看它们的词法分析器中处理注释的逻辑,了解下它们是如何支持嵌套的多行注释的。
|
||||
|
||||
**2.标识符支持Unicode**
|
||||
|
||||
现代的大部分语言,都支持用Unicode来声明变量,甚至可以声明函数或类。这意味着什么呢?**你可以用中文来声明变量和函数名称**。而对于科学工作者来说,你也可以使用π、α、β、θ这些希腊字母,会更符合自己的专业习惯。下面是我在Julia中使用Unicode的情况:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/23/c683b95e1ecec10455120b187c61c123.jpg" alt="">
|
||||
|
||||
**3.多行字符串字面量**
|
||||
|
||||
对于字符串字面量来说,支持多行的书写方式,也会给我们的编程工作带来很多的便利。比如,假设你要把一个JSON字符串或者一个XML字符串赋给一个变量,用多行的书写方式会更加清晰。如下所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/8f/d348c5e28b8d9c6be3821f2da93b228f.jpg" alt="">
|
||||
|
||||
现在,很多的编程语言都可以支持多行的字符串字面量,比如:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/77/0026edyyb9f82a188aec144e37283f77.jpg" alt="">
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我带你一起总结了一下编译原理的前端技术。在解析了这么多个编译器以后,你现在对于实现前端功能时,到底应该选择什么技术、不同的技术路线有什么优缺点,就都心里有数了。
|
||||
|
||||
另外,很多我们可以感知得到的现代语言特性,都是一些前端的功能。比如,更友好的词法特性、更友好的语法特性,等等。你可以借鉴当前语言的一些最佳实践。以你现在的知识积累来说,理解上述语言特性在前端的实现过程,应该不难了。如果你对哪个特性特别感兴趣,也可以按照课程的思路,去直接研究它的编译器。
|
||||
|
||||
最后,我把本讲的思维导图也整理了出来,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/a9/ab5684f9bcfd8912d5a86eefaae85da9.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你比较推崇哪些友好的前端语言特性?它们是怎么实现的?欢迎在留言区分享你的看法。另外,如果你觉得哪些前端特性的设计是失败的,也可以拿来探讨,我们共同吸取教训。
|
||||
|
||||
感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。
|
||||
259
极客时间专栏/geek/编译原理实战课/现代语言设计篇/29 | 中端总结:不遗余力地进行代码优化.md
Normal file
259
极客时间专栏/geek/编译原理实战课/现代语言设计篇/29 | 中端总结:不遗余力地进行代码优化.md
Normal file
@@ -0,0 +1,259 @@
|
||||
<audio id="audio" title="29 | 中端总结:不遗余力地进行代码优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6d/fd/6df9669f1c99dfd952926d09690b19fd.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
今天这一讲,我继续带你来总结前面解析的7种真实的编译器中,**中端部分**的特征和编译技术。
|
||||
|
||||
在课程的[第1讲](https://time.geekbang.org/column/article/242479),我也给你总结过编译器的中端的主要作用,就是实现各种优化。并且在中端实现的优化,基本上都是机器无关的。而优化是在IR上进行的。
|
||||
|
||||
所以,今天这一讲,我们主要来总结以下这两方面的问题:
|
||||
|
||||
- **第一,是对IR的总结。**我在[第6讲](https://time.geekbang.org/column/article/247700)中曾经讲过,IR分为HIR、MIR和LIR三个层次,可以采用线性结构、图、树等多种数据结构。那么基于我们对实际编译器的研究,再一起来总结一下它们的IR的特点。
|
||||
- **第二,是对优化算法的总结。**在[第7讲](https://time.geekbang.org/column/article/248770),我们把各种优化算法做了一个总体的梳理。现在就是时候,来总结一下编译器中的实际实现了。
|
||||
|
||||
通过今天的总结,你能够对中端的两大主题IR和优化,形成更加深入的理解,从而更有利于你熟练运用编译技术。
|
||||
|
||||
好了,我们先来把前面学到的IR的相关知识,来系统地梳理和印证一下吧。
|
||||
|
||||
## 对IR的总结
|
||||
|
||||
通过对前面几个真实编译器的分析,我们会发现IR方面的几个重要特征:SSA已经成为主流;Sea of Nodes展现出令人瞩目的优势;另外,一个编译器中的IR,既要能表示抽象度比较高的操作,也要能表示抽象度比较低的、接近机器码的操作。
|
||||
|
||||
### SSA成为主流
|
||||
|
||||
通过学习前面的课程,我们会发现,符合SSA格式的IR成为了主流。Java和JavaScript的Sea of Nodes,是符合SSA的;Golang是符合SSA的;Julia自己的IR,虽然最早不是SSA格式的,但后来也改成了SSA;而Julia所采用的LLVM工具,其IR也是SSA格式的。
|
||||
|
||||
**SSA意味着什么呢?<strong>源代码中的一个变量,会变成多个版本,每次赋值都形成一个新版本。在SSA中,它们都叫做一个**值(Value)</strong>,对变量的赋值就是**对值的定义(def)**。这个值定义出来之后,就可以在定义其他值的时候**被使用(use)**,因此就形成了清晰的“使用-定义”链(use-def)。
|
||||
|
||||
这种清晰的use-def链会给优化算法提供很多的便利:
|
||||
|
||||
- 如果一个值定义了,但没有被使用,那就可以做**死代码删除**。
|
||||
- 如果对某个值实现了常数折叠,那么顺着def-use链,我们就可以马上把该值替换成常数,从而实现**常数传播**。
|
||||
- 如果两个值的定义是一样的,那么这两个值也一定是一样的,因此就可以去掉一个,从而实现**公共子表达式消除**;而如果不采取SSA,实现CSE(公共子表达式消除)需要做一个**数据流分析**,来确定表达式的变量值并没有发生变化。
|
||||
|
||||
针对最后一种情况,也就是公共子表达式消除,我再给你展开讲解一下,让你充分体会SSA和传统IR的区别。
|
||||
|
||||
我们知道,基于传统的IR,要做公共子表达式消除,就需要专门做一个“可用表达式”的分析。像下图展示的那样,每扫描一遍代码,就要往一个集合里增加一个可用的表达式。
|
||||
|
||||
**为什么叫做可用表达式呢?**因为变量可能被二次赋值,就像图中的变量c那样。在二次赋值以后,之前的表达式“c:=a+b”就不可用了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/ba/6415ed5ce7c4e2f4d1ee7565d4381fba.jpg" alt="">
|
||||
|
||||
在后面,当替换公共子表达式的时候,我们可以把“e:=a+b”替换成“e:=d”,这样就可以少做一次计算,实现了优化的目标。
|
||||
|
||||
而如果采用SSA格式,上面这几行语句就可以改写为下图中的样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cb/77/cb940936a68c3ecf5e9f444cf1606177.jpg" alt="">
|
||||
|
||||
可以看到,原来的变量c被替换成了c1和c2两个变量,而c1、d和e右边的表达式都是一样的,并且它们的值不会再发生变化。所以,我们可以马上消除掉这些公共子表达式,从而减少了两次计算,这就比采用SSA之前的优化效果更好了。最重要的是,整个过程根本不需要做数据流分析。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/4a/43f0107ffec76d69deaeb61cd8a7094a.jpg" alt="">
|
||||
|
||||
好,在掌握了SSA格式的特点以后,我们还可以注意到,Java和JavaScript的两大编译器,在做优化时,竟然都不约而同地用到了Sea Of Nodes这种数据结构。它看起来非常重要,所以,我们再接着来总结一下,符合SSA格式的Sea of Nodes,都有什么特点。
|
||||
|
||||
### Sea of Nodes的特点总结
|
||||
|
||||
其实在[解析Graal编译器](https://time.geekbang.org/column/article/256914)的时候,我就提到过,**Sea of Nodes的特点是把数据流图和控制流图合二为一,从而更容易实现全局优化**。因为采用这种IR,代码并没有一开始就被限制在一个个的基本块中。直到最后生成LIR的环节,才会把图节点Schedule到各个基本块。作为对比,采用基于CFG的IR,优化算法需要让代码在基本块内部和基本块之间移动,处理起来会比较复杂。
|
||||
|
||||
在这里,我再带你把生成IR的过程推导一遍,你能从中体会到生成Sea of Nodes的思路,并且还会有一些惊喜的发现。
|
||||
|
||||
示例函数或方法是下面这样:
|
||||
|
||||
```
|
||||
int foo(int b){
|
||||
a = b;
|
||||
c = a + b;
|
||||
c = b;
|
||||
d = a + b;
|
||||
e = a + b;
|
||||
return e;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**那么,为它生成IR图的过程是怎么样的呢?**
|
||||
|
||||
第1步,对参数b生成一个节点。
|
||||
|
||||
第2步,对于a=b,这里并没有形成一个新的值,所以在后面在引用a和b的时候,都指向同一个节点就好。
|
||||
|
||||
第3步,对于c=a+b,生成一个加法节点,从而形成一个新的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/a2/5b6252680b4a2eb98e816556baa92fa2.jpg" alt="">
|
||||
|
||||
第4步,对于c=b,实际上还是直接用b这个节点就行了,并不需要生成新节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/59/fe507edf931c557fbb38cd68fe55c059.jpg" alt="">
|
||||
|
||||
第5步和第6步,对于d=a+b和e=a+b,你会发现它们都没有生成新的值,还是跟c1用同一个节点表示就行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/3d/5565188f1cfaba3662aed7f31c43d53d.jpg" alt="">
|
||||
|
||||
第7步,对于return语句,这时候生成一个return节点,返回上面形成的加法节点即可。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a0/bf/a082908d62e8c0ccbd0b5c2bbcdebdbf.jpg" alt="">
|
||||
|
||||
从这个例子中你发现了什么呢?原来,**采用Sea of Nodes作为IR,在生成图的过程中,顺带就可以完成很多优化了**,比如可以消除公共子表达式。
|
||||
|
||||
所以我希望,通过上面的例子,你能进一步抓住Sea of Nodes这种数据结构的特点。
|
||||
|
||||
但是,**Sea of Nodes只有优点,没有缺点吗?**也不是的。比如:
|
||||
|
||||
- 你在检查生成的IR、阅读生成的代码的时候,都会更加困难。因为产生的节点非常多,会让你头晕眼花。所以,这些编译器都会特别开发一个**图形化的工具**,来帮助我们更容易看清楚IR图的脉络。
|
||||
- 对图的访问,代价往往比较大。当然这也可以理解。因为你已经知道,对树的遍历是比较简单的,但对图的遍历算法就要复杂一些。
|
||||
- 还有,当涉及效果流的时候,也就是存在内存读写等操作的时候,我们对控制流做修改会比较困难,因为内存访问的顺序不能被打乱,除非通过优化算法把固定节点转换成浮动节点。
|
||||
|
||||
总体来说,**Sea of Nodes的优点和缺点都来自图这种数据结构**。一方面,图的结构简化了程序的表达;另一方面,要想对图做某些操作,也会更困难一些。
|
||||
|
||||
### 从高到低的多层次IR
|
||||
|
||||
对于IR来说,我们需要总结的另一个特点,就是编译器需要从高到低的多个层次的IR。在编译的过程中,高层次的IR会被不断地Lower到低层次的IR,直到最后翻译成目标代码。通过这样层层Lower的过程,程序的语义就从高级语言,一步步变到了汇编语言,中间跨越了巨大的鸿沟:
|
||||
|
||||
- 高级语言中**对一个数组元素的访问**,到汇编语言会翻译成对内存地址的计算和内存访问;
|
||||
- 高级语言中**访问一个对象的成员变量**,到汇编语言会以对象的起始地址为基础,计算成员变量相对于起始地址的偏移量,中间要计算对象头的内存开销;
|
||||
- 高级语言中对于**本地变量的访问**,到汇编语言要转变成对寄存器或栈上内存的访问。
|
||||
|
||||
在采用Sea of Nodes数据结构的时候,编译器会把图中的节点,从代表高层次语义的节点,逐步替换到代表低层次语义的节点。
|
||||
|
||||
以TurboFan为例,它的IR就包含了几种不同层次的节点:
|
||||
|
||||
- 抽象度最高的,是复杂的JavaScript节点;
|
||||
- 抽象度最低的,是机器节点;
|
||||
- 在两者之间的,是简化的节点。
|
||||
|
||||
伴随着编译的进程,我们有时还要进行IR的转换。比如GraalVM,会从HIR转换到LIR;而Julia的编译器则从自己的IR,转换成LLVM的IR;另外,在LLVM的处理过程中,其IR的内部数据结构也进行了切换。一开始使用的是**便于做机器无关的优化的结构,之后转换成适合生成机器码的结构**。
|
||||
|
||||
好,总结完了IR,我们再来看看编译器对IR的处理,比如各种分析和优化算法。
|
||||
|
||||
## 对优化算法的总结
|
||||
|
||||
编译器基于IR,主要做了三种类型的处理。第一种处理,就是我们前面说的**层层地做Lower**。第二种处理,就是**对IR做分析**,比如数据流分析。第三种处理,就是**实现各种优化算法**。编译器的优化往往是以分析为基础。比如,活跃性分析是死代码消除的基础。
|
||||
|
||||
前面我也说过,编译器在中端所做的优化,基本上都是机器无关的优化。那么在考察了7种编译器以后,我们来总结一下这些编译器做优化的特点。
|
||||
|
||||
**第一,有些基本的优化,是每个编译器都会去实现的。**
|
||||
|
||||
比如说,我们见过的常数折叠、代数简化、公共子表达式消除等。这些优化还可能发生在多个阶段,比如从比较早期的语义分析阶段,到比较晚期的基于目标代码的窥孔优化,都使用了这些优化算法。
|
||||
|
||||
**第二,对于解释执行的语言,其编译器能做的优化是有限的。**
|
||||
|
||||
前面我们见到了代码在JVM的解释器、Python的解释器、V8的解释器中运行的情况,现在我们来总结一下它们的运行时的特点。
|
||||
|
||||
**Python**对代码所做的优化非常有限,在解释器中执行的性能也很低。最重要的原因,是所有的类型检查都是在运行期进行的,并且会根据不同的类型选择执行不同的功能。另外,Python所有的对象都是在堆里申请内存的,没有充分利用栈来做基础数据类型的运算,这也导致了它的性能损耗。
|
||||
|
||||
**JVM**解释执行的性能要高一些,因为Java编译器已经做了类型检查,并针对不同数据类型生成了不同的指令。但它只做了一些简单的优化,一些无用的代码并没有被消除掉,对Java程序性能影响很大的内联优化和逃逸分析也都没有做。它基于栈的运行机制,也没有充分发挥寄存器的硬件能力。
|
||||
|
||||
**V8的Ignition解释器**在利用寄存器方面要比JVM的解释器有优势。不过,它的动态类型拖了后腿,这跟Python是一样的。
|
||||
|
||||
**第三,对于动态类型的语言,优化编译的首要任务是做类型推断。**
|
||||
|
||||
**以V8的TurboFan为例**,它对类型信息做不同的推断的时候,优化效果是不同的。如果你一开始运行程序,就逼着TurboFan马上做编译,那么TurboFan其实并不知道各个变量的类型信息,因此只能生成比较保守的代码,它仍然是在运行时进行类型检查,并执行不同的逻辑。
|
||||
|
||||
而一旦通过运行积累了一定的统计数据,TurboFan就可以大胆地做出类型的推断,从而生成针对某种类型的优化代码。不过,它也一定要为自己可能产生的推理错误做好准备,在必要的时候执行**逆优化功能**。
|
||||
|
||||
**Julia也是动态类型的语言,但它采取了另一个编译策略。**它会为一个函数不同的参数类型组合,编译生成对应的机器码。在运行时,根据不同的函数参数,分派到不同的函数版本上去执行,从而获得高性能。
|
||||
|
||||
**第四,JIT编译器可以充分利用推理性的优化机制,这样既节省了编译时间,又有可能带来比AOT更好的优化效果。**
|
||||
|
||||
**第五,对于面向对象的语言,内联优化和逃逸分析非常重要。**
|
||||
|
||||
在分析Graal编译器和V8的TurboFan编译器的过程中,我都特别强调了内联优化和逃逸分析的作用。内联优化不仅能减少对若干短方法调用的开销,还能导致进一步的优化;而逃逸分析能让很多对象在栈上申请内存,并实现标量替换、锁消除等优化,从而获得极大的性能提升。
|
||||
|
||||
**第六,对于静态类型的语言,不同的编译器的优化程度也是不同的。**
|
||||
|
||||
很多工程师经常会争论哪个语言的性能更高。不过在学了编译原理之后,其实可以发现这根本不用争论。你可以设计一些示例程序,测试不同的编译器优化后生成的汇编代码,从而自己得出结论。
|
||||
|
||||
现在,我用一个示例程序,来带你测试一下Graal、Go和Clang三个编译器处理数组加法的效率,你可以借此了解一下它们各自的优化力度,特别是看看它们有没有自动向量化的支持,并进一步了解不同语言的运行机制。
|
||||
|
||||
首先来看看**Java**,示例代码在SIMD.java中。其中的add方法,是把一个数组的所有值汇总。
|
||||
|
||||
```
|
||||
private static int add(int a[]){
|
||||
int sum = 0;
|
||||
for (int i=0; i<a.length; i++){
|
||||
sum = sum + a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们还是**用Graal做即时编译**,并打印出生成的汇编代码。这里我截取了其中的主要部分,给你做了分析:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/ed/53026bbf637749c84baa725b2f13e4ed.jpg" alt="">
|
||||
|
||||
分析这段汇编代码,你能得到下面的信息:
|
||||
|
||||
- Java中的数组,其头部(在64位环境下)占据16个字节,其中包含了数组长度的信息。
|
||||
- Java生成的汇编代码,在每次循环开始的时候,都要检查下标是否越界。这是一个挺大的运算开销。其实我们使用的数组下标**i**,永远不会越界,所以本来可以优化得更好。
|
||||
- 上述汇编代码并没有使用SIMD指令,没有把循环自动向量化。
|
||||
|
||||
我们再来看一下**Go语言**的优化效果,示例代码在SIMD.go中。
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
func add(a []int) int {
|
||||
sum := 0;
|
||||
for i:=0; i<len(a); i++{
|
||||
sum = sum + a[i]
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们生成Go语言特有的伪汇编以后,是下面这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/f6/a14f285204e8bd6742550e3460b965f6.jpg" alt="">
|
||||
|
||||
我们拿它跟Graal生成的汇编代码做比较,会发现其中最重要的差别,是Go的编译器消除了下标检查,这是一个挺大的进步,能够提升不少的性能。不过,你也可以测试一下,当代码中的“len(a)”替换成随意的一个整数的时候,Go的编译器会生成什么代码。它仍然会去做下标检查,并在下标越界的时候报错。
|
||||
|
||||
不过,**令人遗憾的是,Go语言的编译器仍然没有自动生成向量化的代码。**
|
||||
|
||||
最后,我们来看一下**Clang**是如何编译同样功能的一个C语言的程序的(SIMD.c)。
|
||||
|
||||
```
|
||||
int add(int a[], int length){
|
||||
int sum = 0;
|
||||
for (int i=0; i<length; i++){
|
||||
sum = sum + a[i];
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
编译生成的汇编代码在SIMD.s中。我截取了其中的骨干部分:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/2b/d5fd965231ba19e94595ecc2ae24be2b.jpg" alt="">
|
||||
|
||||
你已经知道,Clang是用LLVM做后端的。在它生成的汇编代码中,对循环做了三种优化:
|
||||
|
||||
- **自动向量化**:用movdqu指令,一次能把4个整数,也就是16个字节、128位数据拷贝到寄存器。用paddd指令,可以一次实现4个整数的加法。
|
||||
- **循环展开**:汇编代码里,在一次循环里执行了8次SIMD的add指令,因为每次相当于做了4个整数的加法,因此每个循环相当于做了源代码中32次循环的工作。
|
||||
- **指令排序**:你能看到,由于一个循环中有很多个指令,所以这就为指令排序提供了机会。另外你还能看到,在这段汇编代码中,集中做了多次的movdqu操作,这可以更好地让指令并行化。
|
||||
|
||||
通过这样的对比,你会发现LLVM做的优化是最深入的。所以,如果你要做计算密集型的软件,如果能做到像LLVM这样的优化程度,那就比较理想了。
|
||||
|
||||
不过,做比较深入的优化也不是没有代价的,那就是编译时间会更长。而Go语言的编译器,在设计之初,就把编译速度当成了一个重要的目标,因此它没有去实现自动向量化的功能也是可以理解的。
|
||||
|
||||
如果你要用Go语言开发软件,又需要做密集的计算,那么你有两个选择。一是用Go语言提供的内置函数(intrincics)去实现计算功能,这些内置函数是直接用汇编语言实现的。二是Go语言也提供了一个基于LLVM的编译器,你可以用这个编译器来获得更好的优化效果。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我带你全面系统地总结了一下“解析篇”中,各个实际编译器的IR和优化算法。通过这样的总结,你会对如何设计IR、如何做优化,有一个更加清晰的认识。
|
||||
|
||||
从IR的角度来看,你一定要采用SSA格式的IR,因为它有显著的优点,没有理由不采用。不过,如果你打算自己编写各种优化算法,也不妨进一步采用Sea of Nodes这样的数据结构,并借鉴Graal和V8的一些算法实现。
|
||||
|
||||
不过,自己编写优化算法的工作量毕竟很大。在这种情况下,你可以考虑复用一些后端工具,包括LLVM、GraalVM和GCC。
|
||||
|
||||
本讲的思维导图我也放在了下面,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/80/87/80e13cef697dc076c9646b49140b4787.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
今天我带你测试了Graal、Go和Clang三个编译器,在SIMD方面编译结果的差异。那么,你能否测试一下这几个编译器在其他方面的优化表现?比如循环无关代码外提,或者你比较感兴趣的其他优化。欢迎在留言区分享你的测试心得。
|
||||
|
||||
如果你还有其他的问题,欢迎在留言区提问,我会逐一解答。最后,感谢你的阅读,如果今天的内容让你有所收获,也欢迎你把它分享给更多的朋友。
|
||||
278
极客时间专栏/geek/编译原理实战课/现代语言设计篇/30 | 后端总结:充分发挥硬件的能力.md
Normal file
278
极客时间专栏/geek/编译原理实战课/现代语言设计篇/30 | 后端总结:充分发挥硬件的能力.md
Normal file
@@ -0,0 +1,278 @@
|
||||
<audio id="audio" title="30 | 后端总结:充分发挥硬件的能力" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f6/63/f61089067c087774220ee80c9f1a0d63.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
后端的工作,主要是针对各种不同架构的CPU来生成机器码。在[第8讲](https://time.geekbang.org/column/article/249261),我已经对编译器在生成代码的过程中,所做的主要工作进行了简单的概述,你现在应该对编译器的后端工作有了一个大致的了解,也知道了后端工作中的关键算法包括指令选择、寄存器分配和指令排序(又叫做指令调度)。
|
||||
|
||||
那么今天这一讲,我们就借助在第二个模块中解析过的真实编译器,来总结、梳理一下各种编译器的后端技术,再来迭代提升一下原有的认知,并加深对以下这些问题的理解:
|
||||
|
||||
- 首先,在第8讲中,我只讲了**指令选择**的必要性,但对于如何实现指令选择等步骤,我并没有展开介绍。今天这一讲,我就会带你探索一下指令选择的相关算法。
|
||||
- 其次,关于**寄存器分配算法**,我们探索过的好几个编译器,比如Graal、gc编译器等,采用的都是线性扫描算法,那么这个算法的原理是什么呢?我们一起来探究一下。
|
||||
- 最后,我们再回到**计算机语言设计**的主线上来,一起分析一下不同编译器的后端设计,是如何跟该语言的设计目标相匹配的。
|
||||
|
||||
OK,我们先来了解一下指令选择的算法。
|
||||
|
||||
## 指令选择算法
|
||||
|
||||
回顾一下,我们主要是在[Graal](https://time.geekbang.org/column/article/258162)和[Go语言](https://time.geekbang.org/column/article/266379)的编译器中,分析了与指令选择有关的算法。它们都采用了一种模式匹配的DSL,只要找到了符合模式的指令组合,编译器就生成一条低端的、对应于机器码的指令。
|
||||
|
||||
**那为什么这种算法是有效的呢?这种算法的原理是什么呢?都有哪些不同的算法实现?**接下来,我就给你揭晓一下答案。
|
||||
|
||||
我先给你举个例子。针对表达式“a[i]=b”,它是对数组a的第i个元素赋值。假设a是一个整数数组,那么地址的偏移量就是`a+4*i`,所以,这个赋值表达式用C语言可以写成“`*(a+4*i)=b`”,把它表达成AST的话,就是下图所示的样子。其中,赋值表达式的左子树的计算结果,是一个内存地址。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/d3/a234b0a6b2bb153f2857989e16106bd3.jpg" alt="">
|
||||
|
||||
那么,我们要如何给这个表达式生成指令呢?
|
||||
|
||||
如果你熟悉x86汇编,你就会知道,上述语句可以非常简单地表达出来,因为x86的指令对数组寻址做了优化(参见第8讲的内容)。
|
||||
|
||||
不过,这里为了让你更容易理解算法的原理,我设计了一个新的指令集。这个指令集中的每条指令,都对应了一棵AST的子树,我们把它叫做**模式树(Pattern Tree)**。在有的算法里,它们也被叫做**瓦片(Tiling)**。对一个AST生成指令,就是用这样的模式树或瓦片来覆盖整个AST的过程。所以,这样的算法也叫做**基于模式匹配的指令生成算法**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/e9/4ba05f685d18635767a7f8f3924b90e9.jpg" alt="">
|
||||
|
||||
你可以看到,在图2中,对于每棵模式树,它的根节点是这个指令产生的结果的存放位置。比如,Load_Const指令执行完毕以后,常数会被保存到一个寄存器里。这个寄存器,又可以作为上一级AST节点的操作数来使用。
|
||||
|
||||
图2中的指令包含:把常数和内存中的值加载到寄存器、加法运算、乘法运算等。其中有两个指令是特殊设计的,目的就是为了让你更容易理解接下来要探究的各种算法。
|
||||
|
||||
**第一个指令是#4(Store_Offset)**,它把值保存到内存的时候,可以在目的地址上加一个偏移量。你可以认为这是为某些场景做的一个优化,比如你在对象地址上加一个偏移量,就能获得成员变量的地址,并把数值保存到这个地址上。
|
||||
|
||||
**第二个指令是#9(Lea)**,它相当于x86指令集中的Lea指令,能够计算一个地址值,特别是能够利用间接寻址模式,计算出一个数组元素的地址。它能通过一条指令完成一个乘法计算和一个加法计算。如果你忘记了Lea指令,可以重新看看第8讲的内容。
|
||||
|
||||
基于上述的指令和模式树,我们就可以尝试来做一下模式匹配,从而选择出合适的指令。**那么都可以采用什么样的算法呢?**
|
||||
|
||||
**第一个算法,是一种比较幼稚的算法。我们采取深度优先的后序遍历,也就是按照“左子节点->右子节点->父节点”的顺序遍历,针对每个节点去匹配上面的模式。**
|
||||
|
||||
- 第1步,采用模式#2,把内存中a的值,也就是数组的地址,加载到寄存器。因为无论加减乘除等任何运算,都是可以拿寄存器作为操作数的,所以做这个决策是很安全的。
|
||||
- 第2步,同上,采用模式#1,把常量4加载到寄存器。
|
||||
- 第3步,采用模式#2,把内存中i的值加载到寄存器。
|
||||
- 第4步,采用模式#8,把两个寄存器的值相乘,得到(4*i)的值。
|
||||
- 第5步,采用模式#5,把两个寄存器的值相加,得到a+4*i的值,也就是a[i]的地址。
|
||||
- 第6步,采用模式#2,把内存中b的值加载到寄存器。
|
||||
- 第7步,采用模式#3,把寄存器中b的值写入a[i]的地址。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/5e/8afb39c83db4de7c6758f486ca27395e.jpg" alt="">
|
||||
|
||||
最后形成的汇编代码是这样的:
|
||||
|
||||
```
|
||||
Load_Mem a, R1
|
||||
Load_Const 4, R2
|
||||
Load_Mem i, R3
|
||||
Mul_Reg R2, R3
|
||||
Add_Reg R3, R1
|
||||
Load_Mem b, R2
|
||||
Store R2, (R1)
|
||||
|
||||
```
|
||||
|
||||
**这种方法,是自底向上的做树的重写。**它的优点是特别简单,缺点是性能比较差。它一共生成了7条指令,代价是19(3+1+3+4+1+3+4)。
|
||||
|
||||
在上述步骤中,我们能看到很多可以优化的地方。比如,4*i这个子表达式,我们是用了3条指令来实现的,总的Cost是1+3+4=8,而如果改成两条指令,也就是使用Mul_mem指令,就不用先把i加载到寄存器,Cost可以是1+6=7。
|
||||
|
||||
```
|
||||
Load_Const 4, R1
|
||||
Mul_Mem i, R1
|
||||
|
||||
```
|
||||
|
||||
**第二种方法,是类似Graal编译器所采用的方法,自顶向下的做模式匹配。**比如,当我们处理赋值节点的时候,算法会尽量匹配更多的子节点。因为一条指令包含的子节点越多,那么通过一条指令完成的操作就越多,从而总的Cost就更低。
|
||||
|
||||
所以,算法的大致步骤是这样的:
|
||||
|
||||
- 第1步,在#3和#4两个模式中做选择的话,选中了#4号。
|
||||
- 第2步,沿着AST继续所深度遍历,其中+号节点第1步被处理掉了,所以现在处理变量a,采用了模式#2,把变量加载到寄存器。
|
||||
- 第3步,处理*节点。这个时候要在#7和#8之间做对比,最后选择了#7,因为它可以包含更多的节点。
|
||||
- 第4步,处理常量4。因为上级节点在这里需要一个寄存器作为操作数,所以我们采用了模式#1,把常量加载到寄存器。
|
||||
- 第5步,处理变量b。这里也要把它加载到寄存器,因此采用了模式#2。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/1b/a4aac19471190a449bab1b947ae5711b.jpg" alt="">
|
||||
|
||||
到此为止,我们用了5条指令就做完了所有的运算,生成的汇编代码是:
|
||||
|
||||
```
|
||||
Load_Mem a, R1
|
||||
Load_Const 4, R2
|
||||
Mul_Mem R2, i
|
||||
Load_Mem b, R3
|
||||
Store_Offset R3, (R1,R2)
|
||||
|
||||
```
|
||||
|
||||
这5条指令总的Cost是18(3+1+6+3+5)。
|
||||
|
||||
上述算法的特点,是在每一步都采用了**贪婪策略**,这种算法策略有时候也叫做“Maximal Munch”,意思就是每一步都去咬最大的一口。
|
||||
|
||||
贪婪策略会生成比幼稚的算法更优化的代码,但它不一定是最优的。你看下图中的匹配策略,它也是用了5条指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/3b/69ffa14568a9f85654a92074458bb43b.jpg" alt="">
|
||||
|
||||
生成的汇编代码如下:
|
||||
|
||||
```
|
||||
Lead_Mem a, R1
|
||||
Load_Mem i, R2
|
||||
Lea (R1,R2,4), R1
|
||||
Load_Mem b, R2
|
||||
Store R2, (R1)
|
||||
|
||||
```
|
||||
|
||||
这个新的匹配结果,总的Cost是17(3+3+4+3+4),比前一个算法的结果更优化了。那我们用什么算法能得到这样一个结果呢?
|
||||
|
||||
一个思路,是**找出用模式匹配来覆盖AST的所有可能的模式,并找出其中Cost最低的**。你可以采用**暴力枚举**的方法,在每一个节点,去匹配所有可能的模式,从而找出多组解。但显然,这种算法的计算量太大,所需的时间会根据AST的大小呈指数级上升,导致编译速度无法接受。
|
||||
|
||||
所以我们需要找到一个代价更低的算法,这就是**BURS算法**,也就是“自底向上重写系统,Bottom-Up Rewriting System”。在[HotSpot的C2编译器](https://time.geekbang.org/column/article/258162)中,就采用了BURS算法。这个算法采用了**动态规划**(Dynamic Programming)的数学方法来获取最优解,同时保持了较低的算法复杂度。
|
||||
|
||||
**那么,要想理解BURS算法,你就必须要弄懂动态规划的原理。**如果你之前没有学过这个数学方法,请不要紧张,因为动态规划的原理其实是相当简单的。
|
||||
|
||||
我在网上发现了一篇能够简洁地说清楚动态规划的[文章](https://cloud.tencent.com/developer/article/1475703)。它举了一个例子,用最少张的纸币,来凑出某个金额。
|
||||
|
||||
比如说,假设你要凑出15元,怎么做呢?你还是可以继续采用贪婪算法。首先,拿出一张10元的纸币,也就是小于15的最大金额,然后再拿出5元来。这样你用两张纸币就凑出了15这个数值。这个时候,贪婪策略仍然是有效的。
|
||||
|
||||
但是,如果某个奇葩的国家发行的货币,不是按照中国货币的面额,而是发行1、5、11元三种面额的纸币。那么如果你仍然使用贪婪策略,一开始拿出一张11元的纸币,你就还需要再拿出4张1元的,这样就一共需要5张纸币。
|
||||
|
||||
但这显然不是最优解。最优解是只需要三张5元的纸币就可以了,这就像我们用贪婪算法去做指令生成,得到的可能不是最优解,是同样的道理。
|
||||
|
||||
那如何采用动态规划的方法来获取最优解呢?它的思路是这样的,假设我们用f(n)来代表凑出n元钱最少的纸币数,那么:
|
||||
|
||||
- 当一开始取11元的话,Cost = f(4) + 1;
|
||||
- 当一开始取5元的话,Cost = f(10) + 1;
|
||||
- 当一开始取1元的话,Cost = f(14) + 1。
|
||||
|
||||
所以,我们只需要知道f(4)、f(10)和f(14)哪个值最小就行了。也就是说,f(15)=min(f(4), f(10), f(14)) + 1。 而f(4)、f(10)和f(14)三个值,也可以用同样的方法递归地求出来,最后得到的值分别是4、2、4。所以f(15)=3,这就是最优解。
|
||||
|
||||
这个算法最棒的一点,是整个计算中会遇到的f(14)、f(13)、f(12)、f(11) … f(3)、f(2)这些值,**一旦计算过一遍,就可以缓存下来,不必重复计算,从而让算法的复杂性降低**。
|
||||
|
||||
所以,动态规划的特点,是通过子问题的最优解,得到总的问题的最优解。这种方法,也可以用于生成最优的指令组合。比如,对于示例程序来说,假设f(=)是以赋值运算符为根节点的AST所生成的指令的总的最低Cost,那么:
|
||||
|
||||
- 当采用#3的时候,Cost = 4 + f(+) + f(b);
|
||||
- 当采用#4的时候,Cost = 5 + f(a) + f(*) + f(b)。
|
||||
|
||||
所以你能看出,通过动态规划方法,也能像凑纸币一样,求出树覆盖的最优解。
|
||||
|
||||
BURS算法在具体执行的时候,需要进行三遍的扫描。
|
||||
|
||||
**第一遍扫描**是自底向上做遍历,也就是后序遍历,识别出每个节点可以进行的转换。我在图6中给你标了出来。以a节点为例,我们可以对它做两个操作,第一个操作是保持一个mem节点不动,第二个操作是按照模式#1把它转换成一个reg节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/dd/2d/dddd87f278c8111d4787756380e08b2d.jpg" alt="">
|
||||
|
||||
**第二遍扫描**是自顶向下的,运用动态规划的方法找出最优解。
|
||||
|
||||
**第三遍扫描**又是自底向上的,用于生成指令。
|
||||
|
||||
好了,那么到目前为止,你就已经了解了指令生成的算法思路了。这里我再补充几点说明:
|
||||
|
||||
- 示例中的指令和Cost值,是为了便于你理解算法而设计的。在这个示例中,最优解和最差解的Cost只差了2,也就是大约12%的性能提升。而在实际应用中,优化力度往往会远远大于这个值。
|
||||
- 在[第6讲](https://time.geekbang.org/column/article/247700)探究IR的数据结构时,我提到过有向无环图(DAG),它比起刚才例子中用到的树结构,能够消除一些冗余的子树,从而减少生成的代码量。LLVM里在做指令选择的时候,就是采用了DAG,但算法思路是一样的。
|
||||
- 示例中到的两个算法,贪婪算法和BURS算法,它们花费的时间都与节点数呈线性关系,所以性能都是很高的。其中BURS算法的线性系数更大一点,做指令选择所需的时间也更长一点。
|
||||
|
||||
OK,那么接下来,我们来探究第二个算法,寄存器分配算法。
|
||||
|
||||
## 寄存器分配算法
|
||||
|
||||
在解析Graal编译器和Go的编译器的时候,我都提到过它们的寄存器分配算法是线性扫描算法。我也提到过,线性扫描算法的性能比较高。
|
||||
|
||||
**那么,线性扫描算法的原理是什么呢?**总的来说,线性扫描算法理解起来其实相当简单。我用一个例子来带你了解下。
|
||||
|
||||
假设我们的程序里有从a到g共7个变量。通过数据流分析中的变量活跃性分析,你其实可以知道每个变量的生存期。现在,我们已知有4个物理寄存器可用,那么我们来看一下要怎么分配这几个物理寄存器。
|
||||
|
||||
**在第1个时间段**,a、b、c和d是活跃的,那我们刚好把4个物理寄存器分配给这四个变量就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/75/39dc35a5b607cd3066017c08d0191975.jpg" alt="">
|
||||
|
||||
**在第2个时间段**,a的生存期结束,而一个新的变量e变得活跃,那么我们就把a原来占用的寄存器刚好给到e就可以了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/4a/b64ae2b4cfd318f9eeeeaa54ab83984a.jpg" alt="">
|
||||
|
||||
**在第3个时间段**,我们把c占用的寄存器给到f,目前仍然是使用4个寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/63/48fb912ba5c59f8c04373a91510b5563.jpg" alt="">
|
||||
|
||||
**在第4个时间段**,b的生存期结束。这时候只需要用到3个寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ca/04/ca60eb79fedac105f3cbfd6f8c509404.jpg" alt="">
|
||||
|
||||
**在最后一个时间段**,只有变量d和g是活跃的,占用两个寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/0a/04b2d9a8cyy51a6916cc286b29ea9a0a.jpg" alt="">
|
||||
|
||||
可以看到,在上面这个例子中,所有的变量都可以分配到物理寄存器。而且你也会发现,这个例子中存在多个变量因为生存期是错开的,因此也可以共享同一个寄存器。
|
||||
|
||||
但是,如果没有足够的物理寄存器的话,我们要怎么办呢?那就需要把某个变量溢出到内存里了。也就是说,当用到这个变量的时候,才把这个变量加载到寄存器,或者有一些指令可以直接用内存地址作为操作数。
|
||||
|
||||
给你举另一个例子,我们来看看物理寄存器不足的情况会是什么样子。在这个例子中,我们有三个物理寄存器。
|
||||
|
||||
**在第1个时间段**,物理寄存器是够用的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/d1/0b511374c86b9a14c1bec1d52c718cd1.jpg" alt="">
|
||||
|
||||
**在第2个时间段**,变量d变得活跃,现在有4个活跃变量,所以必须选择一个溢出到内存。我们选择了a。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/cf/b81c0a71067d26f68f6902e26f1040cf.jpg" alt="">
|
||||
|
||||
**在第3个时间段**,e和f变得活跃,现在又需要溢出一个变量才可以。这次选择了c。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/88/3c6f242389253e6f5e9dc43f62a4fa88.jpg" alt="">
|
||||
|
||||
**在第4个时间段**,g也变得活跃,这次把d溢出了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/64/c6a906528d9d8331bb7de9b87163cc64.jpg" alt="">
|
||||
|
||||
以上就是**线性扫描算法的思路:线性扫描整个代码,并给活跃变量分配寄存器。如果物理寄存器不足,那么就选择一个变量,溢出到内存中。**你看,是不是很简单?
|
||||
|
||||
在掌握了线性扫描算法的思路以后,我再给你补充一点信息:
|
||||
|
||||
- 第一,线性扫描算法并不能获得寄存器分配的最优解。所谓最优解,是要让尽量多的操作在寄存器上实现,尽量少地访问内存。因为线性扫描算法并没有去确定一个最优值的目标,所以也就谈不上最优解。
|
||||
- 第二,线性扫描算法可以采用一些策略,让一些使用频率低的变量被溢出,而像高频使用的循环中的变量,就保留在寄存器里。
|
||||
- 第三,还有一些其他提升策略。比如,当存在多余的物理寄存器以后,还可以把之前已经溢出的变量重新复活到寄存器里。
|
||||
|
||||
好了,上述就是线性扫描的寄存器分配算法。另外我们再来复习一下,在第8讲中,我还提到了另一个算法,是图染色算法,这个算法的优化效果更好,但是计算量比较大,会影响编译速度。
|
||||
|
||||
接下来,让我们再回到计算机语言设计的主线上,一起讨论一下编译器的后端与语言设计的关系。
|
||||
|
||||
## 编译器后端与语言的设计
|
||||
|
||||
编译器后端的目的,是要能够针对不同架构的硬件来生成目标代码,并尽量发挥硬件的能力。那么为了更好地支持语言的设计,在编译器后端的设计上,我们需要考虑到三个方面的因素。
|
||||
|
||||
- **平衡编译速度和优化效果**
|
||||
|
||||
通常,我们都希望编译后的代码越优化越好。但是,在有些场景下,编译速度也很重要。比如像JVM这样需要即时编译的运行时环境,编译速度就比较重要。这可能就是Graal的指令选择算法和编译器分配算法都比较简单的原因吧。
|
||||
|
||||
Go语言一开始也把编译速度作为一个重要的设计考虑,所以它的后端算法也比较简单。我估计是因为Go语言的发起者(Robert Griesemer、Rob Pike和Ken Tompson)都具有C和C++的背景,甚至Ken Tompson还是C语言的联合发明人,他们都深受编译速度慢之苦。类似浏览器、操作系统这样比较大的软件,即使是用很多台机器做编译,还是需要编译很久。这可能也是他们为什么想让Go的编译速度很快的原因。
|
||||
|
||||
而Julia的设计目标是用于科学计算的,所以其使用场景主要就是计算密集型的。Julia采用了LLVM做后端,做了比较高强度的优化,即使会因此导致运行时由于JIT而引起短暂停顿。
|
||||
|
||||
- **确定所支持的硬件平台**
|
||||
|
||||
确定了一门语言主要运行在什么平台上,那么首先就要支持该平台上的机器码。由于Go语言主要是用于写服务端程序的,而服务端采用的架构是有限的,所以Go语言支持的架构也是有限的。
|
||||
|
||||
硬件平台也影响算法的选择,比如现在很多CPU都支持指令的乱序执行,那你在实现编译器的时候就可以省略指令重排序(指令调度)功能。
|
||||
|
||||
- **设计后端DSL**
|
||||
|
||||
虽然编译器后端要支持多种硬件,但我们其实会希望算法是通用的。所以,各个编译器通常会提供一种DSL,去描述硬件的特征,从而自动生成针对这种硬件的代码。
|
||||
|
||||
在Graal中,我们看到了与指令选择有关的注解,在Go的编译器中,我们也看到了对IR进行转换的DSL,而LLVM则提供了类似的机制。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我把后端的两个重要的算法拿出来给你单独介绍了一下,并一起讨论了后端技术策略与计算机语言的关系。你需要记住这几个知识点:
|
||||
|
||||
- **关于指令选择**:从IR生成机器码(或LIR),通常是AST或DAG中的多个节点对应一条指令,所以你要找到一个最佳的组合,把整个AST或DAG覆盖住,并且要找到一个较优的或最优的解。其中,你还要熟悉贪婪算法和动态规划这两种不同的算法策略,这两种算法不仅仅会用于指令选择,还会用于多种场景。理解了这两种算法之后,就会给你的工具库添加两个重要的工具。
|
||||
- **关于寄存器分配**:线性扫描算法比较简单。不过在一些技术点上我们去深入挖掘一下,其实会发现还挺有意思的。比如,当采用SSA格式的IR的时候,寄存器分配算法会有什么不同,等等。你可以参考看看文末我给出的资料。
|
||||
- **关于编译器后端的设计**:我们要考虑编译速度和优化程度的平衡,要考虑都能支持哪些硬件。因为要支持多种硬件,通常要涉及后端的DSL,以便让算法尽量中立于具体的硬件架构。
|
||||
|
||||
我把本讲的知识点也整理成了思维导图,供你复习和参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/93/82c8e25b80368f16cfb2173b4f00a193.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
动态规划算法是这节课的一个重要知识点。在学过了这个知识点以后,你能否发现它还可以被用于解决哪些问题?欢迎分享你的经验和看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- 对动态规划方法的理解,我建议你读一下[这篇文章](https://cloud.tencent.com/developer/article/1475703),通俗易懂。
|
||||
- 在《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),有对寄存器分配算法中的图染色算法的介绍,你可以去参考一下。
|
||||
- 这两篇关于线性扫描算法的经典论文,你可以去看一下:[论文1](http://web.cs.ucla.edu/~palsberg/course/cs132/linearscan.pdf),[论文2](https://dash.harvard.edu/bitstream/1/34325454/1/tr-21-97.pdf)。
|
||||
- 这篇文章介绍了针对[SSA格式的IR的线性扫描算法](http://cgo.org/cgo2010/talks/cgo10-ChristianWimmer.pdf),值得一看。
|
||||
188
极客时间专栏/geek/编译原理实战课/现代语言设计篇/31 | 运行时(一):从0到语言级的虚拟化.md
Normal file
188
极客时间专栏/geek/编译原理实战课/现代语言设计篇/31 | 运行时(一):从0到语言级的虚拟化.md
Normal file
@@ -0,0 +1,188 @@
|
||||
<audio id="audio" title="31 | 运行时(一):从0到语言级的虚拟化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/3f/2af9f2abc8dd3e523814275bfaf50d3f.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天,我会带你去考察现代语言设计中的运行时特性,并讨论一下与标准库有关的话题。
|
||||
|
||||
你可能要问了,咱们这门课是要讲编译原理啊,为什么要学运行时呢。其实,对于一门语言来说,除了要提供编译器外,还必须提供运行时功能和标准库:一是,编译器生成的目标代码,需要运行时的帮助才能顺利运行;二是,我们写代码的时候,有一些标准的功能,像是读写文件的功能,自己实现起来太麻烦,或者根本不可能用这门语言本身来实现,这时就需要标准库的支持。
|
||||
|
||||
其实,我们也经常会接触到运行时和库,但可能只是停留在使用层面上,并不太会关注它们的原理等。如果真要细究起来、真要对编译原理有更透彻的理解的话,你可能就会有下面这些问题了:
|
||||
|
||||
- 到底什么是运行时?任何语言都有运行时吗?运行时和编译器是什么关系?
|
||||
- 什么是标准库?标准库和运行时库又是什么关系?库一般都包含什么功能?
|
||||
|
||||
今天,我们就来探讨一下这些与运行时和标准库有关的话题。这样,你能更加充分地理解设计一门语言要完成哪些工作,以及这些工作跟编译技术又有什么关系,也就能对编译原理有更深一层的理解。
|
||||
|
||||
首先,我们来了解一下运行时,以及它和编译技术的关系。
|
||||
|
||||
## 什么是运行时(Runtime)?
|
||||
|
||||
我们在[第5讲](https://time.geekbang.org/column/article/246281)说过,每种语言都有一个特定的执行模型(Execution Model)。而这个执行模型就需要运行时系统(Runtime System)的支持。我们把这种可以支撑程序运行的运行时系统,简称为运行时。
|
||||
|
||||
那运行时都包含什么功能呢?通常,我们最关心的是三方面的功能:程序运行机制、内存管理机制和并发机制。接下来,我就分别以Java、Python以及C、C++、Go语言的运行时机制为例,做一下运行时的分析,因为它们的使用者比较多,并且体现了一些有代表性的运行时特征。
|
||||
|
||||
### Java的运行时
|
||||
|
||||
我们先看看Java语言的运行时系统,也就是JVM。
|
||||
|
||||
其实,JVM不仅为Java提供了运行时环境,还为其他所有基于JVM的语言提供了支撑,包括Scala、Clojure、Groovy等。我们可以通过[JVM的规范](https://docs.oracle.com/javase/specs/jvms/se12/html/index.html)来学习一下它的主要特点。
|
||||
|
||||
第一,**JVM规定了一套程序的运行机制**。JVM支持基于字节码的解释执行机制,还包括了即时编译成机器码并执行的机制。
|
||||
|
||||
针对基于字节码的解释执行机制,JVM规范定义下面这些内容:
|
||||
|
||||
- 定义了一套字节码来运行程序。这些字节码能支持一些基本的运算。超出这些基本运算逻辑的,就要自己去实现。比如,idiv指令用于做整数的除法,当除数为零的时候,虚拟缺省的操作是抛出异常。如果你自己的语言是专注于数学计算的,想让整数除以零的结果为无穷大,那么你需要自己去实现这个逻辑。
|
||||
- 规定了一套类型系统,包括基础数据类型、数组、引用类型等。所以说,任何运行在JVM上的语言,不管它设计的类型系统是什么样子,编译以后都会变成字节码规定的基础类型。
|
||||
- 定义了class文件的结构。class文件规定了把某个类的符号表放在哪里、把字节码放在哪里,所以写编译器的时候要遵守这个规范才能生成正确的class文件。JVM在运行时会加载class文件并执行。
|
||||
- 提供了一个基于栈的解释器,来解释执行字节码。编译器要根据这个执行模型来生成正确的字节码。
|
||||
|
||||
除了解释执行字节码的机制,JVM还支持即时编译成机器码并执行的机制。它可以调度多个编译器,生成不同优化级别的机器码,这就是分层编译机制。在需要的时候,还可以做逆优化,在不同版本的机器码以及解释执行模式之间做切换。
|
||||
|
||||
最后,Java程序之间的互相调用,需要遵循一定的调用约定或二进制标准,包括如何传参数等等。这也是运行机制的一部分。
|
||||
|
||||
总体来说,JVM代表了一种比较复杂的运行机制,既可以解释执行,又可以编译成机器码执行。V8的运行时机制也跟JVM也很类似。
|
||||
|
||||
第二,**JVM对内存做了统一的管理**。它把内存划分为程序计数器、虚拟机栈、堆、方法区、运行时常量池和本地方法栈等不同的区域。
|
||||
|
||||
对于栈来说,它的栈桢既可以服务于解释执行,又可以用于执行机器码,并且还可以在两种模式之间转换。在解释执行的时候,栈桢里会有一个操作数栈,服务于解释器。我们提到过OSR,也就是在运行一个方法的时候,把这个方法做即时编译,并且把它的栈桢从解释执行的状态切换成运行机器码的状态。而如果遇到逆优化的场景,栈桢又会从运行机器码的状态,切换成解释执行的状态。
|
||||
|
||||
对于堆来说,Java提供了垃圾收集器帮助进行内存的自动管理。减少整体的停顿时间,是垃圾收集器设计的重要目标。
|
||||
|
||||
第三,**JVM封装了操作系统的线程模型,为应用程序提供了并发处理的机制**。我会在讲并发机制的时候再展开。
|
||||
|
||||
以上就是JVM为运行在其上的任何程序提供的支撑了。在提供这些支撑的同时,运行时系统也给程序运行带来了一些限制。
|
||||
|
||||
第一,JVM实际上提供了一个基础的对象模型,JVM上的各种语言必须遵守。所以,虽然Clojure是一个函数式编程语言,但它在底层却不得不使用JVM规定的对象模型。
|
||||
|
||||
第二,基于JVM的语言程序要去调用C语言等生成的机器码的库,会比较难。不过,对于同样基于JVM的语言,则很容易实现相互之间的调用,因为它们底层都是类和字节码。
|
||||
|
||||
第三,在内存管理上,程序不能直接访问内存地址,也不能手动释放内存。
|
||||
|
||||
第四,在并发方面,JVM只提供了线程机制。如果你要使用其他并发模型,比如我们会在34讲中讲到的协程模型和35讲中的Actor模型,需要语言的实现者绕着弯去做,增加一些自己的运行时机制(我会在第34讲来具体介绍)。
|
||||
|
||||
好了,以上就是我要通过JVM的例子带你学习的Java的运行时,以及其编译器的影响了。我们再来看看Python的运行时。
|
||||
|
||||
### Python的运行时
|
||||
|
||||
在解析Python语言的时候,已经讲了Python的字节码和解释器,以及Python对象模型和程序调用的机制。这里,我再从程序运行机制、内存管理机制、并发机制这三个方面,给你梳理下。
|
||||
|
||||
第一,Python也提供了一套字节码,以及运行该字节码的解释器。这套字节码,也是跟Python的类型体系互相配合的。字节码中操作的那些标识符,都是Python的对象引用。
|
||||
|
||||
第二,在内存管理方面,Python也提供了自己的机制,包括对栈和堆的管理。
|
||||
|
||||
首先,我们看看栈。Python运行程序的时候,有些时候是运行机器码,比如内置函数,而有些时候是解释执行字节码。
|
||||
|
||||
运行机器码的时候,栈帧跟C语言程序的栈帧是没啥区别的。而在解释执行字节码的时候,栈帧里会包含一个操作数栈,这点跟JVM的栈机是一样的。如果你再进一步,去看看操作数栈的实现,会发现解释器本身主要就是一个C语言实现的函数,而操作数栈就是这个函数里用到的本地变量。因此操作数栈也会像其他本地变量一样,被优化成尽量使用物理寄存器,从而提高运行效率。这个知识点你要掌握,也就是说,**栈桢中的操作数栈,其实是有可能基于物理寄存器的**。
|
||||
|
||||
然后,Python还提供了对堆的管理机制。程序从堆里申请内存的时候,不是直接从操作系统申请,而是通过Python提供的一个Arena机制,使得内存的申请和释放更加高效、灵活。Python还提供了基于引用的垃圾收集机制(我会在下一讲为你总结垃圾收集机制)。
|
||||
|
||||
第三,是并发机制。Python把操作系统的线程进行了封装,让Python程序能支持基于线程的并发。同时,它也实现了协程机制(我会在34讲详细展开)。
|
||||
|
||||
好了,我们再继续看看第三类语言,也就是C、C++、Go这样的直接编译成二进制文件执行的语言的运行时。
|
||||
|
||||
### C、C++、Go的运行时
|
||||
|
||||
一个有意思的问题是,C语言有没有运行时呢?我们对C语言的印象,是一旦编译完成以后,就是一段完全可以自主运行的二进制代码了,你也可以看到输出的完整的汇编代码。除此之外没有其他,C语言似乎不需要运行时的支持。
|
||||
|
||||
所以,**C语言最主要的运行时,实际上就是操作系统**。C语言和现代的各种操作系统可以说是伴生关系,就像Java和JVM是伴生关系一样。所以,如果我们要深入使用C语言,某种意义上就是要深入了解操作系统的运行机制。
|
||||
|
||||
在程序执行机制方面,C语言编译完毕的程序是完全按照操作系统的运行机制来执行的。
|
||||
|
||||
在内存管理方面,C语言使用了操作系统提供的线程栈,操作系统能够自动帮助程序管理内存。程序也可以从堆里申请内存,但必须自己负责释放,没有自动内存管理机制。
|
||||
|
||||
在并发机制方面,当然也是直接用操作系统提供的线程机制。因为操作系统没有提供协程和Actor机制,所以C语言也没有提供这种并发机制。
|
||||
|
||||
**不过有一个程序crt0.o,有时被称作是C语言的运行时**。它是一段汇编代码(crt0.s),由链接器自动插入到程序里面,主要功能是在调用main函数之前做一些初始化工作,比如设置main函数的参数(argc和argv)、环境变量的地址、调用main函数、设置一些中断向量用于处理程序异常等。所以,这个所谓的运行时所做的工作也特别简单。
|
||||
|
||||
不同系统的crt0.s会不太一样,因为CPU架构和ABI是不同的。下面是一个crt0.s的示例代码:
|
||||
|
||||
```
|
||||
.text
|
||||
.globl _start
|
||||
_start: # _start是链接器需要用到的入口
|
||||
xor %ebp, %ebp # 让ebp置为0,标记栈帧的底部
|
||||
mov (%rsp), %edi # 从栈里获得argc的值
|
||||
lea 8(%rsp), %rsi # 从栈里获得argv的地址
|
||||
lea 16(%rsp,%rdi,8), %rdx # 从栈里获得envp的地址
|
||||
xor %eax, %eax # 按照ABI的要求把eax置为0,并与icc兼容
|
||||
call main # 调用main函数,%edi, %rsi, %rdx是传给main函数的三个参数
|
||||
|
||||
mov %eax, %edi # 把main函数的返回值提供给_exit作为第一个参数
|
||||
xor %eax, %eax # 按照ABI的要求把eax置为0,并与icc兼容
|
||||
call _exit # 终止程序
|
||||
|
||||
```
|
||||
|
||||
可以说,C语言的运行时是一个极端,提供了最少的功能。反过来呢,这也就是给了程序员最大的自由度。C++语言的跟C是类似的,我就不再展开了。总的来说,它们都没有Java和Python那种意义上的运行时。
|
||||
|
||||
不过,**Go语言虽然也是编译成二进制的可执行文件,但它的运行时要复杂得多**。比如,它有垃圾收集器;再比如,Go语言最显著的特点是提供了自己的并发机制,也就是goroutine。对goroutine的运行管理,也是go的运行时的一部分。
|
||||
|
||||
无独有偶,在Android平台上,你可以把Java程序以AOT的方式编译成可执行文件。但这个可执行文件其实仍然包含了一个运行时,比如垃圾收集功能,所以与C语言编译形成的可执行文件,也是不一样的。
|
||||
|
||||
总结起来,运行时系统提供了程序的运行机制、内存管理机制、并行机制等功能。运行时和编译器的关系就是,编译器要跟这些运行时做配合,生成符合运行时要求的目标代码。
|
||||
|
||||
接下来,我们再看看语言的另一个重要组成部分,也就是标准库,并看看它跟编译器的关系。
|
||||
|
||||
## 库和标准库
|
||||
|
||||
我们知道,任何一门编程语言,要想很好地投入实际应用,必须有良好的库来支撑。这些库的作用就是封装了常用的、标准的功能,让开发者可以直接使用。
|
||||
|
||||
根据库的使用场景和与编译器的关系,这些库可以分为**标准库、运行时库和内置函数**三类。
|
||||
|
||||
第一,标准库,供用户的程序调用。我们在写一段C语言程序的时候,总要在源代码一开头的部分include几个库进来,比如stdio.h、stdlib.h等等。C++的STL库和标准库让程序员拥有比C语言里面更多的工具,比如各种标准的容器类。Java刚面世的时候,就在JDK里打包了很多标准库。正是因为这些丰富又好用的库,使得Java能够被迅速接受。当然了,这些库也成了JDK标准的组成部分。而Python语言声称是“自带电池”的,也就是说有很多库的支持,可以迅速上手做很多事情。
|
||||
|
||||
第二类,运行时库,它们不是由用户直接调用的,而是运行时的组成部分。比如,Python实现整数运算的功能很强大,支持任意长度整数的加减乘除。这些功能是由一些库函数实现的,并由Python的解释器来调用,实现Python程序中的加减乘除操作。
|
||||
|
||||
第三类,是一些叫做Built-in或者Intrincics的内置函数,它们是用来辅助生成机器码的。它们往往由汇编代码实现,也有的是用编译器的LIR实现的,在编译的时候直接内联进去。这些函数有时开发者也可以调用,比如在C语言中,可以像调用普通函数一样,调用CPU厂家提供的与SIMD指令有关的Intrincics。但这些函数会直接生成汇编码,不像C语言编写的程序那样需要经过优化和代码生成的过程。
|
||||
|
||||
好了,我们了解了库的三种分类,也就是标准库、运行时库和内置函数。不过我要提醒你的是,这些分类有时候是模糊的,比如有的语言(比如微软的C和C++语言)谈到运行时库的时候,实际上就包括了标准库。
|
||||
|
||||
接下来,我们主要看看与标准库相关的几个问题。
|
||||
|
||||
### 标准库的特殊性
|
||||
|
||||
与普通程序相比,标准库主要有以下三个方面的不同。
|
||||
|
||||
第一,有的库可以用本语言来实现,而有的库必须要用其他语言来实现,因为用本语言实现有困难。这就要求库的编写者要具备更高的技能,能够掌握更加底层的语言。
|
||||
|
||||
比如,Java有少量库(比如网络通讯模块)就需要用C语言来编写,而Python、PHP、Node.js等语言的大量库都是用C语言编写的。甚至,标准库中的某些底层功能会采用汇编语言来写。
|
||||
|
||||
第二,标准库的接口不可以经常变化,甚至是要保持一直不变。因此,标准库的设计一定要慎重,这就要求设计者有更高的规划和设计能力。因为几乎每个程序都会用到标准库的功能,库的接口如果变化的话,就会影响到所有已经写好的程序。
|
||||
|
||||
第三,标准库往往集中体现了一门语言的核心特点。同样的功能,面向对象编程语言、函数式编程语言、基于Actor的语言,会采用各自的方式来实现。库的编写者要写出教科书级的代码,充分发挥这门语言的优势。这样的话,编程人员使用这些标准库的过程,实际上就是潜移默化地学习这门语言的编程思想的过程。
|
||||
|
||||
好了,看来编写一个好的标准库确实是有挑战的事情。但是标准库一般需要包含哪些内容呢?
|
||||
|
||||
### 标准库需要包含什么功能?
|
||||
|
||||
第一,包含IO功能,包括文件IO、网络IO等。
|
||||
|
||||
还记得吧,我们学习每一门新语言的时候,都会在终端上打印出一个“Hello World!”,这似乎已经成了一种具有仪式感的行为。可是你注意到没有,你在打印输出到终端的时候,通常就是调用了一个标准的IO库。因为终端本身就相当于一个文件,这实际上是用了文件IO功能。
|
||||
|
||||
除了文件IO,网络IO也必不可少,这样的话手机上的App程序才能够跟服务端的程序通讯。
|
||||
|
||||
第二,支持内置的数据类型。
|
||||
|
||||
首先是针对整型、浮点型等基础数据类型做运算的功能。比如有的数学库的数学计算功能支持任意长度的整数的运算,并支持准确的小数运算(计算机内置的浮点数计算功能是不精确的)。此外数据类型转换、对字符串操作等,也是必不可少的。
|
||||
|
||||
像Java、Python这样的语言,提供了一些标准的内置类型,比如String等。像Scala这种纯面向对象语言,连整型、浮点型等基础数据类型,也是通过标准库来提供的。
|
||||
|
||||
第三,支持各种容器型的数据结构。
|
||||
|
||||
有的语言(比如Go),会在语法层面提供map等容器型的数据结构,并通过运行时库做支持;还有些语言(比如Java、C++),是在标准库里提供这些数据结构。
|
||||
|
||||
此外,标准库还要包含一些其他功能,比如对日期、图形界面等各种不同的功能支持。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天,我们一起学习了一门语言除编译器之外的一些重要组成部分,包括运行时和各种库。编译器拥有运行时和库的知识,并根据这些知识作出正确的编译。当你设计一门语言的时候,应该首先要把它的运行机制设计清楚,然后才能设计出正确的语法、语义,并实现出相应的编译器。
|
||||
|
||||
所以,我们这一讲的目标,就是帮你从一个更高的维度来理解编译技术的使用环境,从而更加全面地理解和使用编译技术。
|
||||
|
||||
我把今天的知识点也整理成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/f0/51d273f47301db60b0dd19480acd9bf0.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
挑你熟悉的一门语言,分享一下它的运行时和标准库的设计特征,以及对编译器的影响。
|
||||
|
||||
欢迎你在留言区表达自己的见解,也非常欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。
|
||||
236
极客时间专栏/geek/编译原理实战课/现代语言设计篇/32 | 运行时(二):垃圾收集与语言的特性有关吗?.md
Normal file
236
极客时间专栏/geek/编译原理实战课/现代语言设计篇/32 | 运行时(二):垃圾收集与语言的特性有关吗?.md
Normal file
@@ -0,0 +1,236 @@
|
||||
<audio id="audio" title="32 | 运行时(二):垃圾收集与语言的特性有关吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a0/ae/a030fa552312a3972dbd3bc516b852ae.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天,我们继续一起学习垃圾收集的实现机制以及与编译器的关系。
|
||||
|
||||
对于一门语言来说,垃圾收集机制能够自动管理从堆中申请的内存,从而大大降低程序员的负担。在这门课的第二大模块“真实编译器解析篇”中,我们学习Java、Python、Go、Julia和JavaScript这几门语言,都有垃圾收集机制。那在今天这一讲,我们就来学习一下,这些语言的垃圾收集机制到底有什么不同,跟语言特性的设计又是什么关系,以及编译器又是如何配合垃圾收集机制的。
|
||||
|
||||
这样如果我们以后要设计一门语言的话,也能清楚如何选择合适的垃圾收集机制,以及如何让编译器来配合选定的垃圾收集机制。
|
||||
|
||||
在讨论不同语言的垃圾收集机制之前,我们还是需要先了解一下,通常我们都会用到哪些垃圾收集算法,以及它们都有什么特点。这样,我们才能深入探讨应该在什么时候采用什么算法。如果你对各种垃圾收集算法已经很熟悉了,也可以从这一讲的“Python与引用计数算法”开始学习;如果你还想理解垃圾收集算法的更多细节,也可以去看看我的第一季课程《编译原理之美》的[第33讲](https://time.geekbang.org/column/article/162854)的内容。
|
||||
|
||||
## 垃圾收集算法概述
|
||||
|
||||
垃圾收集主要有标记-清除(Mark and Sweep)、标记-整理(Mark and Compact)、停止-拷贝(Stop and Copy)、引用计数、分代收集、增量收集和并发收集等不同的算法,在这里我简要地和你介绍一下。
|
||||
|
||||
首先,我们先学习一下什么是内存垃圾。内存垃圾,其实就是一些保存在堆里的、已经无法从程序里访问的对象。
|
||||
|
||||
我们看一个具体的例子。
|
||||
|
||||
在堆中申请一块内存时(比如Java中的对象实例),我们会用一个变量指向这块内存。但是,如果给变量赋予一个新的地址,或者当栈桢弹出时,该栈桢的变量全部失效,这时,变量所指向的内存就没用了(如图中的灰色块)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/c0/7a9d301d4b1c9091d99cfe721d01d9c0.jpg" alt="">
|
||||
|
||||
另外,如果A对象有一个成员变量指向C对象,那么A不可达,C也会不可达,也就失效了。但D对象除了被A引用,还被B引用,仍然是可达的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/91/d7a15d401fbb3b31b3bd63c014b21391.jpg" alt="">
|
||||
|
||||
那么,所有不可达的内存就是垃圾。所以,垃圾收集的重点就是找到并清除这些垃圾。接下来,我们就看看不同的算法是怎么完成这个任务的。
|
||||
|
||||
### 标记-清除
|
||||
|
||||
标记-清除算法,是从**GC根节点**出发,顺着对象的引用关系,依次标记可达的对象。这里说的GC根节点,包括全局变量、常量、栈里的本地变量、寄存器里的本地变量等。从它们出发,就可以找到所有有用的对象。那么剩下的对象,就是内存垃圾,可以清除掉。
|
||||
|
||||
### 标记-整理
|
||||
|
||||
采用标记-清除算法,运行时间长了以后,会形成内存碎片。这样在申请内存的时候,可能会失败。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/d9/1b5af01923b40566d5fde1a25a0720d9.jpg" alt="">
|
||||
|
||||
**为了避免内存碎片,你可以采用变化后的算法,也就是标记-整理算法:**在做完标记以后,做一下内存的整理,让存活的对象都移动到一边,消除掉内存碎片。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/b0/5e7530840bfa8077d9f0944142d932b0.jpg" alt="">
|
||||
|
||||
### 停止-拷贝
|
||||
|
||||
停止和拷贝算法把内存分为新旧空间两部分。你需要保持一个堆指针,指向自由空间开始的位置。申请内存时,把堆指针往右移动就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/10/f9f8c968c6a4bb31c274b8eb0e0b6d10.jpg" alt="">
|
||||
|
||||
当旧空间内存不够了以后,就会触发垃圾收集。在收集时,会把可达的对象拷贝到新空间,然后把新旧空间互换。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/c9/e98d5746653d7dd0927f2006270a7dc9.jpg" alt="">
|
||||
|
||||
停止-拷贝算法,在分配内存的时候,不需要去查找一块合适的空闲内存;在垃圾收集完毕以后,也不需要做内存整理,因此速度是最快的。但它也有缺点,就是总有一半内存是闲置的。
|
||||
|
||||
### 引用计数
|
||||
|
||||
引用计数方法,是在对象里保存该对象被引用的数量。一旦这个引用数为零,那么就可以作为垃圾被收集走。
|
||||
|
||||
有时候,我们会把引用计数叫做自动引用计数(ARC),并把它作为跟垃圾收集(GC)相对立的一个概念。所以,如果你读到相关的文章,它把ARC和GC做了对比,也不要吃惊。
|
||||
|
||||
引用计数实现起来简单,并且可以边运行边做垃圾收集,不需要为了垃圾收集而专门停下程序。可是,它也有缺陷,就是不能处理循环引用(Reference Cycles)的情况。在下图中,四个对象循环引用,但没有GC根指向它们。它们已经是垃圾,但计数却都为1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/08/fe6c0082c0ff95780f5b11e935ea9e08.jpg" alt="">
|
||||
|
||||
另外,由于在程序引用一个对象的前后,都要修改引用计数,并且还有多线程竞争的可能性,所以引用计数法的性能开销比较大。
|
||||
|
||||
### 分代收集
|
||||
|
||||
在程序中,新创建的对象往往会很快死去,比如,你在一个方法中,使用临时变量指向一些新创建的对象,这些对象大多数在退出方法时,就没用了。这些数据叫做新生代。而如果一个对象被扫描多次,发现它还没有成为垃圾,那就会标记它为比较老的时代。这些对象可能Java里的静态数据成员,或者调用栈里比较靠近根部的方法所引用的,不会很快成为垃圾。
|
||||
|
||||
对于新生代对象,可以更频繁地回收。而对于老一代的对象,则回收频率可以低一些。并且,对于不同世代的对象,还可以用不同的回收方法。比如,新生代比较适合复制式收集算法,因为大部分对象会被收集掉,剩下来的不多;而老一代的对象生存周期比较长,拷贝的话代价太大,比较适合标记-清除算法,或者标记-整理算法。
|
||||
|
||||
### 增量收集和并发收集
|
||||
|
||||
垃圾收集算法在运行时,通常会把程序停下。因为在垃圾收集的过程中,如果程序继续运行,可能会出错。这种停下整个程序的现象,被形象地称作**“停下整个世界(STW)”**。
|
||||
|
||||
可是让程序停下来,会导致系统卡顿,用户的体验感会很不好。一些对实时性要求比较高的系统,根本不可能忍受这种停顿。
|
||||
|
||||
所以,在自动内存管理领域的一个研究的重点,就是如何缩短这种停顿时间。增量收集和并发收集算法,就是在这方面的有益探索:
|
||||
|
||||
- 增量收集可以每次只完成部分收集工作,没必要一次把活干完,从而减少停顿。
|
||||
- 并发收集就是在不影响程序执行的情况下,并发地执行垃圾收集工作。
|
||||
|
||||
好了,理解了垃圾收集算法的核心原理以后,我们就可以继续去探索各门语言是怎么运用这些算法的了。
|
||||
|
||||
首先,我们从Python的垃圾收集算法学起。
|
||||
|
||||
## Python与引用计数算法
|
||||
|
||||
Python语言选择的是引用计数的算法。除此之外,Swift语言和方舟编译器,采用的也是引用计数,所以值得我们重视。
|
||||
|
||||
### Python的内存管理和垃圾收集机制
|
||||
|
||||
首先我们来复习一下Python内存管理的特征。在Python里,每个数据都是对象,而这些对象又都是在堆上申请的。对比一下,在C和Java这样的语言里,很多计算可以用本地变量实现,而本地变量是在栈上申请的。这样,你用到一个整数的时候,只占用4个字节,而不像Python那样有一个对象头的固定开销。栈的优势还包括:不会产生内存碎片,数据的局部性又好,申请和释放的速度又超快。而在堆里申请太多的小对象,则会走向完全的反面:太多次系统调用,性能开销大;产生内存碎片;数据的局部性也比较差。
|
||||
|
||||
所以说,Python的内存管理方案,就决定了它的内存占用大、性能低。这是Python内存管理的短板。而为了稍微改善一下这个短板,Python采用了一套基于区域(Region-based)的内存管理方法,能够让小块的内存管理更高效。简单地说,就是Python每次都申请一大块内存,这一大块内存叫做Arena。当需要较小的内存的时候,直接从Arena里划拨就好了,不用一次次地去操作系统申请。当用垃圾回收算法回收内存时,也不一定马上归还给操作系统,而是归还到Arena里,然后被循环使用。这个策略能在一定程度上提高内存申请的效率,并且减少内存碎片化。
|
||||
|
||||
接下来,我们就看看Python是如何做垃圾回收的。回忆一下,在[第19讲](https://time.geekbang.org/column/article/261063)分析Python的运行时机制时,其中提到了一些垃圾回收的线索。Python里每个对象都是一个PyObject,每个PyObject都有一个ob_refcnt字段用于记录被引用的数量。
|
||||
|
||||
在解释器执行字节码的时候,会根据不同的指令自动增加或者减少ob_refcnt的值。当一个PyObject对象的ob_refcnt的值为0的时候,意味着没有任何一个变量引用它,可以立即释放掉,回收对象所占用的内存。
|
||||
|
||||
现在你已经知道,采用引用计数方法,需要解决循环引用的问题。那Python是如何实现的呢?
|
||||
|
||||
Python在gc模块里提供了一个循环检测算法。接下来我们通过一个示例,来看看这个算法的原理。在这个例子中,有一个变量指向对象A。你能用肉眼看出,对象A、B、C不是垃圾,而D和E是垃圾。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/bc/5a8efc25bca7b594f6284131674b4ebc.jpg" alt="">
|
||||
|
||||
在循环检测算法里,gc使用了两个列表。一个列表保存所有待扫描的对象,另一个列表保存可能的垃圾对象。注意,这个算法只检测容器对象,比如列表、用户自定义的类的实例等。而像整数对象这样的,就不用检测了,因为它们不可能持有对其他对象的引用,也就不会造成循环引用。
|
||||
|
||||
在这个算法里,我们首先让一个gc_ref变量等于对象的引用数。接着,算法假装去掉对象之间的引用。比如,去掉从A到B的引用,这使得B对象的gc_ref值变为了0。在遍历完整个列表以后,除了A对象以外,其他对象的gc_ref都变成了0。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/e8/d825f11087afcf1482dede2920c66ce8.jpg" alt="">
|
||||
|
||||
gc_ref等于零的对象,有的可能是垃圾对象,比如D和E;但也有些可能不是,比如B和C。那要怎么区分呢?我们先把这些对象都挪到另一个列表中,怀疑它们可能是垃圾。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/0e/3c0e14f3af25de89f96bc1e75fc57d0e.jpg" alt="">
|
||||
|
||||
这个时候,待扫描对象区只剩下了对象A。它的gc_ref是大于零的,也就是从gc根是可到达的,因此肯定不是垃圾对象。那么顺着这个对象所直接引用和间接引用到的对象,也都不是垃圾。而剩下的对象,都是从gc根不可到达的,也就是真正的内存垃圾。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/2b/2de8166a19741082b3aef0fb2b57502b.jpg" alt="">
|
||||
|
||||
另外,基于循环检测的垃圾回收算法是定期执行的,这会跟Java等语言的垃圾收集器一样,导致系统的停顿。所以,它也会像Java等语言的垃圾收集器一样,采用分代收集的策略来减少垃圾收集的工作量,以及由于垃圾收集导致的停顿。
|
||||
|
||||
好了,以上就是Python的垃圾收集算法。我们前面提过,除了Python以外,Swift和方舟编译器也使用了引用计数算法。另外,还有些分代的垃圾收集器,在处理老一代的对象时,也会采用引用计数的方法,这样就可以在引用计数为零的时候收回内存,而不需要一遍遍地扫描。
|
||||
|
||||
### 编译器如何配合引用计数算法?
|
||||
|
||||
对于Python来说,引用计数的增加和减少,是由运行时来负责的,编译器并不需要做额外的工作。它只需要生成字节码就行了。而对于Python的解释器来说,在把一个对象赋值给一个变量的时候,要把对象的引用数加1;而当该变量超出作用域的时候,要把对象的引用数减1。
|
||||
|
||||
不过,对于编译成机器码的语言来说,就要由编译器介入了。它要负责生成相应的指令,来做引用数的增减。
|
||||
|
||||
不过,这只是高度简化的描述。实际实现时,还要解决很多细致的问题。比如,在多线程的环境下,对引用数的改变,必须要用到锁,防止超过一个线程同时修改引用数。这种频繁地对锁的使用,会导致性能的降低。这时候,我们之前学过的一些优化算法就可以派上用场了。比如,编译器可以做一下逃逸分析,对于没有逃逸或者只是参数逃逸的对象,就可以不使用锁,因为这些对象不可能被多个线程访问。这样就可以提高程序的性能。
|
||||
|
||||
除了通过逃逸分析优化对锁的使用,编译器还可以进一步优化。比如,在一段程序中,一个对象指针被调用者通过参数传递给某个函数使用。在函数调用期间,由于调用者是引用着这个对象的,所以这个对象不会成为垃圾。而这个时候,就可以省略掉进入和退出函数时给对象引用数做增减的代码。
|
||||
|
||||
还有不少类似上面的情况,需要编译器配合垃圾收集机制,生成高效的、正确的代码。你在研究Swift和方舟编译器时,可以多关注一下它们对引用计数做了哪些优化。
|
||||
|
||||
接下来,我们再看看**其他语言是怎么做垃圾收集**的。
|
||||
|
||||
## 其他语言是怎么做垃圾收集的?
|
||||
|
||||
除了Python以外,我们在第二个模块研究的其他几门语言,包括Java、JavaScript(V8)和Julia,都没有采用引用计数算法(除了在分代算法中针对老一代的对象),它们基本都采用了分代收集的策略。针对新生代,通常是采用标记-清除或者停止拷贝算法。
|
||||
|
||||
它们不采用引用计数的原因,其实我们可以先猜测一下,那就是因为引用计数的缺点。比如增减引用计数所导致的计算量比较多,在多线程的情况下要用到锁,就更是如此;再比如会导致内存碎片化、局部性差等。
|
||||
|
||||
而采用像停止-拷贝这样的算法,在总的计算开销上会比引用计数的方法低。Java和Go语言主要是用于服务端程序开发的。尽量减少内存收集带来的性能损耗,当然是语言的设计者重点考虑的问题。
|
||||
|
||||
再进一步看,采用像停止-拷贝这样的算法,其实是用空间换时间,以更大的内存消耗换来性能的提升。如果你的程序需要100M内存,那么虚拟机需要给它准备200M内存,因为有一半空间是空着的。这其实也是为什么Android手机比iPhone更加消耗内存的原因之一。
|
||||
|
||||
在为iPhone开发程序的时候,无论是采用Objective C还是Swift,都是采用引用计数的技术。并且,程序员还负责采用弱引用等技术,来避免循环引用,从而进一步消除了在运行时进行循环引用检测的开销。
|
||||
|
||||
通过上面的分析,我们能发现移动端应用和服务端应用有不同的特点,因此也会导致采用不同的垃圾收集算法。那么方舟编译器采用引用计数的方法,来编译原来的Android应用,是否也是借鉴了iPhone的经验呢?我没有去求证过,所以不得而知。但我们可以根据自己的知识去做一些合理的猜测。
|
||||
|
||||
好,回过头来,我们继续分析一下用Java和Go语言来写服务端程序对垃圾收集的需求。对于服务器端程序来说,垃圾收集导致的停顿,是一个令程序员们头痛的问题。有时候,一次垃圾收集会让整个程序停顿一段非常可观的时间(比如上百毫秒,甚至达到秒级),这对于实时性要求较高或并发量较大的系统来说,就会引起很大的问题。也因此,一些很关键的系统很长时间内无法采用Java和Go语言编写。
|
||||
|
||||
所以,Java和Go语言一直在致力于减少由于垃圾收集而产生的停顿。最新的垃圾收集器,已经使得垃圾收集导致的停顿降低到了几毫秒内。
|
||||
|
||||
在这里,你需要理解的要点,是**为什么在垃圾收集时,要停下整个程序?**又有什么办法可以减少停顿的时间?
|
||||
|
||||
### 为什么在垃圾收集时,要停下整个程序?
|
||||
|
||||
其实,对于引用计数算法来说,是不需要停下整个程序的,每个对象的内存在计数为零的时候就可以收回。
|
||||
|
||||
而**采用标记-清除算法时,你就必须要停下程序**:首先做标记,然后做清除。在做标记的时候,你必须从所有的GC根出发,去找到所有存活的对象,剩下的才是垃圾。所以,看上去,这是一项完整的工作,程序要一直停顿到这项完整的工作做完。
|
||||
|
||||
让事情更棘手的是,**你不仅要停下当前的线程,扫描栈里的所有GC根,你还要停下其他的线程**,因为其他线程栈里的对象,也可能引用了相同的对象。最后的结果,就是你停下了整个世界。
|
||||
|
||||
当然也有例外,就是如果别的线程正在运行的代码,没有可能改变对象之间的引用关系,比如仅仅是在做一个耗费时间的数学计算,那么是不用停下来的。你可以参考Julia的[gc程序中的一段注释](https://github.com/JuliaLang/julia/blob/v1.4.1/src/gc.c#L152),来理解什么样的代码必须停下来。
|
||||
|
||||
更麻烦的是,不仅仅在扫描阶段你需要停下整个世界,**如果垃圾收集算法需要做内存的整理或拷贝,那么这个时候仍然要停下程序**。而且,程序必须停在一些叫做安全点(SafePoint)的地方。
|
||||
|
||||
在这些地方,修改对象的地址不会破坏程序数据的一致性。比如说,假设代码里有一段逻辑,是访问对象的某个成员变量,而这个成员变量的地址是根据对象的地址加上一个偏移量计算出来的。那么如果你修改了对象的地址,而这段代码仍然去访问原来的地址,那就出错了。而当代码停留在安全点上,就不会有这种不一致。
|
||||
|
||||
安全点是编译器插入到代码中一个片段。在查看[Graal生成的汇编代码](https://time.geekbang.org/column/article/255730)时,我们曾经看到过这样的指令片段。
|
||||
|
||||
好了,到目前为止,你了解了为什么要停下整个世界,以及要停在哪里才合适。那么我们继续研究,**如何能减少停顿时间**。
|
||||
|
||||
### 如何能减少停顿时间?
|
||||
|
||||
第一招,分代收集可以减少垃圾收集的工作量,不用每次都去扫描所有的对象,因此也会减少停顿时间。像Java、Julia和V8的垃圾收集器都是分代的。
|
||||
|
||||
第二招,可以尝试增量收集。你可能会问了,怎样才能实现增量呀?不是说必须扫描所有的GC根,才能确认一个对象是垃圾吗?
|
||||
|
||||
其实是有方法可以实现增量收集的,比如三色标记(Tri-color Marking)法。这种方法的原理,是用三种颜色来表示不同的内存对象的处理阶段:
|
||||
|
||||
- 白色,表示算法还没有访问的对象。
|
||||
- 灰色,表示这个节点已经被访问过,但子节点还没有被访问过。
|
||||
- 黑色,表示这个节点已经被访问过,子节点也已经被访问过了。
|
||||
|
||||
我们用一个例子来了解一下这个算法的原理。这个例子中有8个对象。你可以看出,其中三个对象是内存垃圾。在垃圾收集的时候,一开始所有对象都是白色的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/b0/990affb0e6d65610d768f4c6f471c5b0.jpg" alt="">
|
||||
|
||||
然后,扫描所有GC根所引用的对象,把这些对象加入到一个工作区,并标记为灰色。在例子中,我们把A和F放入了灰色区域。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/65/03472898d64854b6e55d013d81645365.jpg" alt="">
|
||||
|
||||
如果这个对象的所有子节点都被访问过之后,就把它标记为黑色。在例子中,A和F已经被标记为黑色,而B、C、D被标记为灰色。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/6b/3127a489a54d01fb7ba1f9e9317abf6b.jpg" alt="">
|
||||
|
||||
继续上面的过程,B、C、D也被标记为黑色。这个时候,灰色区域已经没有对象了。那么剩下的白色对象E、G和H就能确定是垃圾了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/01/2ed571bdaa8bca89bbdcf4d7f1c4a501.jpg" alt="">
|
||||
|
||||
回收掉E、G和H以后,就可以进入下一次循环。重新开始做增量收集。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/e6/0171377626c9d83bb9b55d6cd68b38e6.jpg" alt="">
|
||||
|
||||
从上面的原理还可以看出这个算法的特点:黑色对象永远不能指向白色对象,顶多指向灰色对象。我们只要始终保证这一条,就可以去做增量式的收集。
|
||||
|
||||
具体来说,垃圾收集器可以做了一段标记工作后,就让程序再运行一段。如果在程序运行期间,一个黑色对象被修改了,比如往一个黑色对象a里新存储了一个指针b,那么把a涂成灰色,或者把b涂成灰色,就可以了。等所有的灰色节点变为黑色以后,就可以做垃圾清理了。
|
||||
|
||||
总结起来,三色标记法中,黑色的节点是已经处理完毕的,灰色的节点是正在处理的。如果灰色节点都处理完,剩下的白色节点就是垃圾。而如果在两次处理的间隙,有黑色对象又被改了,那么要重新处理。
|
||||
|
||||
那在增量收集的过程中,需要编译器做什么配合?肯定是需要的,编译器需要往生成的目标代码中插入读屏障(Read Barrier)和写屏障(Write Barrier)的代码。也就是在程序读写对象的时候,要执行一些逻辑,保证三色的正确性。
|
||||
|
||||
好了,你已经理解了增量标识的原理,知道了它可以减少程序的整体停顿时间。那么,能否再进一步减少停顿时间呢?
|
||||
|
||||
这就涉及到第三招:并发收集。我们再仔细看上面的增量式收集算法:既然垃圾收集程序和主程序可以交替执行,那么是否可以一边运行主程序,一边用另一个或多个线程来做垃圾收集呢?
|
||||
|
||||
这是可以的。实际上,除了少量的时候需要停下整个程序(比如一开头处理所有的GC根),其他时候是可以并发的,这样就进一步减少了总的停顿时间。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我带你了解运行时中的一个重要组成部分:垃圾收集器。采用什么样的垃圾收集算法,是实现一门语言时要着重考虑的点。
|
||||
|
||||
垃圾收集算法包含的内容有很多,我们这一讲并没有展开所有的内容,而是聚焦在介绍常用的几种算法(比如引用计数、分代收集、增量收集等)的原理,以及几种典型语言的编译器是如何跟选定的垃圾收集算法配合的。比如,在生成目标代码的时候,生成安全点、写屏障和读屏障的代码,修改引用数的代码,以及能够减少垃圾收集工作的一些优化工作。
|
||||
|
||||
我把今天的知识点做成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/b7/9fc46118d16472ab4bd5a718e32041b7.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们说垃圾收集是跟语言的设计有关的。那么,你是否可以想一下,怎样设计语言可以减少垃圾收集工作呢?欢迎分享你的观点。
|
||||
349
极客时间专栏/geek/编译原理实战课/现代语言设计篇/33 | 并发中的编译技术(一):如何从语言层面支持线程?.md
Normal file
349
极客时间专栏/geek/编译原理实战课/现代语言设计篇/33 | 并发中的编译技术(一):如何从语言层面支持线程?.md
Normal file
@@ -0,0 +1,349 @@
|
||||
<audio id="audio" title="33 | 并发中的编译技术(一):如何从语言层面支持线程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/1b/947c1b645fb4107aae1899e3e904981b.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
现代的编程语言,开始越来越多地采用并发计算的模式。这也对语言的设计和编译技术提出了要求,需要能够更方便地利用计算机的多核处理能力。
|
||||
|
||||
并发计算需求的增长跟两个趋势有关:一是,CPU在制程上的挑战越来越大,逼近物理极限,主频提升也越来越慢,计算能力的提升主要靠核数的增加,比如现在的手机,核数越来越多,动不动就8核、12核,用于服务器的CPU核数则更多;二是,现代应用对并发处理的需求越来越高,云计算、人工智能、大数据和5G都会吃掉大量的计算量。
|
||||
|
||||
因此,在现代语言中,友好的并发处理能力是一项重要特性,也就需要编译技术进行相应的配合。现代计算机语言采用了多种并发技术,包括线程、协程、Actor模式等。我会用三讲来带你了解它们,从而理解编译技术要如何与这些并发计算模式相配合。
|
||||
|
||||
这一讲,我们重点探讨线程模式,它是现代计算机语言中支持并发的基础模式。它也是讨论协程和Actor等其他话题的基础。
|
||||
|
||||
不过在此之前,我们需要先了解一下并发计算的一点底层机制:并行与并发、进程和线程。
|
||||
|
||||
## 并发的底层机制:并行与并发、进程与线程
|
||||
|
||||
我们先来学习一下硬件层面对并行计算的支持。
|
||||
|
||||
假设你的计算机有两颗CPU,每颗CPU有两个内核,那么在同一时间,至少可以有4个程序同时运行。
|
||||
|
||||
后来CPU厂商又发明了超线程(Hyper Threading)技术,让一个内核可以同时执行两个线程,增加对CPU内部功能单元的利用率,这有点像我们之前讲过的[流水线技术](https://time.geekbang.org/column/article/249261)。这样一来,在操作系统里就可以虚拟出8个内核(或者叫做操作系统线程),在同一时间可以有8个程序同时运行。这种真正的同时运行,我们叫做**并行**(parallelism)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/77/487d4289970590947915689aeyy5a377.jpg" alt="">
|
||||
|
||||
可是仅仅8路并行,也不够用呀。如果你去查看一下自己电脑里的进程数,会发现运行着几十个进程,而线程数就更多了。
|
||||
|
||||
所以,操作系统会用分时技术,让一个程序执行一段时间,停下来,再让另一个程序运行。由于时间片切得很短,对于每一个程序来说,感觉上似乎一直在运行。这种“同时”能处理多个任务,但实际上并不一定是真正同时执行的,就叫做**并发**(Concurrency)。
|
||||
|
||||
实际上,哪怕我们的计算机只有一个内核,我们也可以实现多个任务的并发执行。这通常是由操作系统的一个调度程序(Scheduler)来实现的。但是有一点,操作系统在调度多个任务的时候,是有一定开销的:
|
||||
|
||||
- 一开始是以进程为单位来做调度,开销比较大。
|
||||
- 在切换**进程**的时候,要保存当前进程的上下文,加载下一个进程的上下文,也会有一定的开销。由于进程是一个比较大的单位,其上下文的信息也比较多,包括用户级上下文(程序代码、静态数据、用户堆栈等)、寄存器上下文(各种寄存器的值)和系统级上下文(操作系统中与该进程有关的信息,包括进程控制块、内存管理信息、内核栈等)。
|
||||
|
||||
相比于进程,**线程技术就要轻量级一些**。在一个进程内部,可以有多个线程,每个线程都共享进程的资源,包括内存资源(代码、静态数据、堆)、操作系统资源(如文件描述符、网络连接等)和安全属性(用户ID等),但拥有自己的栈和寄存器资源。这样一来,线程的上下文包含的信息比较少,所以切换起来开销就比较小,可以把宝贵的CPU时间用于执行用户的任务。
|
||||
|
||||
总结起来,线程是操作系统做并发调度的基本单位,并且可以跟同一个进程内的其他线程共享内存等资源。操作系统会让一个线程运行一段时间,然后把它停下来,把它所使用的寄存器保存起来,接着让另一个线程运行,这就是线程调度原理。你要在大脑里记下这个场景,这样对理解后面所探讨的所有并发技术都很有帮助。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/2c/13597dc8c5ea3124a1b85b040072242c.jpg" alt="">
|
||||
|
||||
我们通常**把进程作为资源分配的基本单元,而把线程作为并发执行的基本单元**。不过,有的时候,用进程作为并发的单元也是比较好的,比如谷歌浏览器每打开一个Tab页,就新启动一个进程。这是因为,浏览器中多个进程之间不需要有互动。并且,由于各个进程所使用的资源是独立的,所以一个进程崩溃也不会影响到另一个。
|
||||
|
||||
而如果采用线程模型的话,由于它比较轻量级,消耗的资源比较少,所以你可以在一个操作系统上启动几千个线程,这样就能执行更多的并发任务。所以,在一般的网络编程模型中,我们可以针对每个网络连接,都启动一条线程来处理该网络连接上的请求。在第二个模块中我们分析过的[MySQL](https://time.geekbang.org/column/article/267917)就是这样做的。你每次跟MySQL建立连接,它就会启动一条线程来响应你的查询请求。
|
||||
|
||||
采用线程模型的话,程序就可以在不同线程之间共享数据。比如,在数据库系统中,如果一个客户端提交了一条SQL,那么这个SQL的编译结果可以被缓存起来。如果另一个用户恰好也执行了同一个SQL,那么就可以不用再编译一遍,因为两条线程可以访问共享的内存。
|
||||
|
||||
但是共享内存也会带来一些问题。当多个线程访问同样的数据的时候,会出现数据处理的错误。如果使用并发程序会造成错误,那当然不是我们所希望的。所以,我们就要采用一定的技术去消除这些错误。
|
||||
|
||||
Java语言内置的并发模型就是线程模型,并且在语法层面为线程模型提供了一些原生的支持。所以接下来,我们先借助Java语言去了解一下,如何用编译技术来配合线程模型。
|
||||
|
||||
## Java的并发机制
|
||||
|
||||
Java从语言层面上对并发编程提供了支持,简化了程序的开发。
|
||||
|
||||
Java对操作系统的线程进行了封装,程序员使用Thread类或者让一个类实现Runnable接口,就可以作为一个线程运行。Thread类提供了一些方法,能够控制线程的运行,并能够在多个线程之间协作。
|
||||
|
||||
从语法角度,与并发有关的关键字有synchronized和volatile。它们就是用于解决多个线程访问共享内存的难题。
|
||||
|
||||
### synchronized关键字:保证操作的原子性
|
||||
|
||||
我们通过一个例子,来看看多个线程访问共享数据的时候,为什么会导致数据错误。
|
||||
|
||||
```
|
||||
public class TestThread {
|
||||
public static void main(String[] args) {
|
||||
Num num = new Num();
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
new NewThread(num).start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//线程类NewThread 对数字进行操作
|
||||
class NewThread extends Thread {
|
||||
private Num num;
|
||||
|
||||
public NewThread(Num num) {
|
||||
this.num = num;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
for (int i = 0; i < 1000; i++)
|
||||
num.add();
|
||||
System.out.println("num.num:" + num.value);
|
||||
}
|
||||
}
|
||||
|
||||
//给数字加1
|
||||
class Num {
|
||||
public int value = 0;
|
||||
public void add() {
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在这个例子中,每个线程对Num中的value加1000次。按说总有一个线程最后结束,这个时候打印出来的次数是3000次。可实际运行的时候,却发现很难对上这个数字,通常都要小几百。下面是几次运行的结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/71/7f3c7229a0c54e49b02edd52c9928271.jpg" alt="">
|
||||
|
||||
要找到其中的原因,最直接的方法,是从add函数的字节码入手研究。学习过编译原理之后,你要养成直接看字节码、汇编码来研究底层机制的习惯,这样往往会对问题的研究更加透彻。add函数的字节码如下:
|
||||
|
||||
```
|
||||
0: aload_0 #加载Num对象
|
||||
1: dup #复制栈顶对象(Num)
|
||||
2: getfield #弹出一个Num对象,从内存取出value的值,加载到栈
|
||||
5: iconst_1 #加载整数1到栈
|
||||
6: iadd #执行加法,结果放到栈中
|
||||
7: putfield #栈帧弹出加法的结果和Num对象,写字段值,即把value的值写回内存
|
||||
10: return
|
||||
|
||||
```
|
||||
|
||||
看着这一段字节码,你是不是会重新回忆起加法的计算过程?它实际上是4个步骤:
|
||||
|
||||
1. 从内存加载value的值到栈;
|
||||
1. 把1加载到栈;
|
||||
1. 从栈里弹出value的值和1,并做加法;
|
||||
1. 把新的value的值存回到内存里。
|
||||
|
||||
这是一个线程执行的过程。如果是两个以上的线程呢?你就会发现有问题了。线程1刚执行getfield取回value的值,线程2也做了同样的操作,那么它们取到的值是同一个。做完加法以后,写回内存的时候,写的也是同一个值,都是3。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/a9/abe403a774990ab20b2a0643ce34b8a9.jpg" alt="">
|
||||
|
||||
这样一分析,你就能理解数字计算错误的原因了。总结起来,出现这种现象是因为对value的加法操作不符合原子性(Atomic)。原子性的意思是一个操作或者多个操作,要么全部执行,并且执行的过程不会被任何因素打断,要么就都不执行。如果对value加1是一个原子操作,那么线程1一下子就操作完了,value的值从2一下子变成3。线程2只能接着对3再加1,无法在线程1执行到一半的时候,就已经介入。
|
||||
|
||||
解决办法就是,让这段代码每次只允许一个线程执行,不会出现多个线程交叉执行的情况,从而保证对value值修改的原子性。这个时候就可以用到synchronized关键字了:
|
||||
|
||||
```
|
||||
public void add() {
|
||||
synchronized(this){
|
||||
value += 1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样再运行示例程序,就会发现,总有一个线程打印出来的值是3000。这证明确实一共对value做了3000次加1的运算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/57/00/57b2472d34292fddb13b4e5a6418ac00.jpg" alt="">
|
||||
|
||||
那synchronized关键字作用的原理是什么呢?要回答这个问题,我们还是要研究一下add()方法的字节码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/46/ec/464918cdb6c8dcc6bd3a69yy19a437ec.jpg" alt="">
|
||||
|
||||
在字节码中,你会发现**两个特殊的指令:monitorenter和monitorexit指令**,就是它们实现了并发控制。
|
||||
|
||||
查看字节码的描述,我们可以发现monitorenter的作用,是试图获取某个对象引用的监视器(monitor)的所有权。什么是监视器呢?在其他文献中,你可能会读到“锁”的概念。监视器和锁其实是一个意思。这个锁是关联到一个Num对象的,也就是代码中的this变量。只有获取了这把锁的程序,才能执行块中的代码,也就是“value += 1”。
|
||||
|
||||
具体来说,当程序执行到monitorenter的时候,会产生下面的情况:
|
||||
|
||||
- 如果监视器的进入计数是0,线程就会进入监视器,并将进入计数修改为1。这个时候,该线程就拥有了该监视器。
|
||||
- 如果该线程已经拥有了该监视器,那么就重新进入,并将进入计数加1。
|
||||
- 如果其他线程拥有该监视器,那么该线程就会被阻塞(block)住,直到监视器的进入计数变为0,然后再重新试图获取拥有权。
|
||||
|
||||
monitorexit指令的机制则比较简单,就是把进入计数减1。如果一段程序被当前线程进入了多次,那么也会退出同样的次数,直到进入计数为0。
|
||||
|
||||
总结起来,**我们用了锁的机制,保证被保护的代码块在同一时刻只能被一个线程访问,从而保证了相关操作的原子性**。
|
||||
|
||||
到这里了,你可能会继续追问:如何保证获取锁的操作是原子性的?如果某线程看到监视器的进入计数是0,这个时候它就进去,但在它修改进入计数之前,如果另一个线程也进去了怎么办,也修改成1怎么办?这样两个线程会不会都认为自己获得了锁?
|
||||
|
||||
这个担心是非常有必要的。实际上,要实现原子操作,仅仅从软件角度做工作是不行的,还必须要有底层硬件的支持。具体是如何支持的呢?我们还是采用一贯的方法,直接看汇编代码。
|
||||
|
||||
你可以用[第13讲](https://time.geekbang.org/column/article/255730)学过的方法,获取Num.add()方法对应的汇编代码,看看在汇编层面,监视器是如何实现的。我截取了一段汇编代码,并标注了其中的一些关键步骤,你可以看看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/16/15fe669b54091fb3e41763b7e02a7116.jpg" alt="">
|
||||
|
||||
汇编代码首先会跳到一段代码去获取监视器。如果获取成功,那么就跳转回来,执行后面对value做加法的运算。
|
||||
|
||||
我们再继续看一下获取监视器的汇编代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9f/21/9f19dae9997e1e32fe69a77b59e7cb21.jpg" alt="">
|
||||
|
||||
你特别需要注意的是**cmpxchg指令**:它能够通过一条指令,完成比较和交换的操作。查看[Intel的手册](https://software.intel.com/en-us/download/intel-64-and-ia-32-architectures-sdm-combined-volumes-1-2a-2b-2c-2d-3a-3b-3c-3d-and-4),你会发现更详细的解释:把rax寄存器的值与cmpxchg的目的操作数的值做对比。如果两个值相等,那么就把源操作数的值设置到目的操作数;否则,就把目的操作数的值设置到rax寄存器。
|
||||
|
||||
那cmpxchg指令有什么用呢?原来,通过这样一条指令,计算机就能支持原子操作。
|
||||
|
||||
比如,监视器的计数器的值,一开始是0。我想让它的值加1,从而获取这个监视器。首先,我根据r11寄存器中保存的地址,从内存中读出监视器初始的计数,发现它是0;接着,我就把这个初始值放入rax;第三步,我把新的值,也就是1放入r10寄存器。最后,我执行cmpxchg指令:
|
||||
|
||||
```
|
||||
cmpxchg QWORD PTR [r11],r10
|
||||
|
||||
```
|
||||
|
||||
这个指令把当前的监视器计数,也就是内存地址是r11的值,跟rax的值做比较。如果它俩相等,仍然是0,那就意味着没有别的程序去修改监视器计数。这个时候,该指令就会把r10的值设置到监视器计数中,也就是修改为1。如果有别的程序已经修改了计数器的值,那么就会把计数器现在的值写到rax中。
|
||||
|
||||
补充:实际执行的时候,r10中的值并不是简单的0和1,而是获取了Java对象的对象头,并设置了其中与锁有关的标志位。
|
||||
|
||||
所以,通过cmpxchg指令,要么获得监视器成功,要么失败,肯定不会出现两个程序都以为自己获得了监视器的情况。
|
||||
|
||||
正因为cmpxchg在硬件级把原来的两个指令(比较指令和交换指令,Compare and Swap)合并成了一个指令,才能同时完成两个操作:首先看看当前值有没有被改动,然后设置正确的值。这也是Java语言中与锁有关的API得以运行的底层原理,也是操作系统和数据库系统加锁的原理。
|
||||
|
||||
不过,在汇编代码中,我们看到cmpxchg指令前面还有一个lock的前缀。这是起什么作用的呢?
|
||||
|
||||
原来呀,cmpxchg指令在一个内核中执行的时候,可以保证原子性。但是,如果两个内核同时执行这条指令,也可能再次发生两个内核都去写入,从而都认为自己写成功了的情况。lock前缀的作用,就是让这条指令在同一时间,只能有一个内核去执行。
|
||||
|
||||
所以说,要从根本上保证原子性,真不是一件容易的事情。不过,不管怎么说,通过CPU的支持,我们确实能够实现原子操作了,能让一段代码在同一个时间只让一个线程执行,从而避免了多线程的竞争现象。
|
||||
|
||||
上面说的synchronized关键字,是采用了锁的机制,保证被保护的代码块在同一时刻只能被一个线程访问,从而保证了相关操作的原子性。Java还有另一个与并发有关的关键字,就是volatile。
|
||||
|
||||
### volatile关键字:解决变量的可见性问题
|
||||
|
||||
那volatile关键字是针对什么问题的呢?我先来告诉你答案,它解决的是变量的可见性(Visibility)。
|
||||
|
||||
你可以先回想一下自己是不是遇到过这个问题:在并发计算的时候,如果两个线程都需要访问同一个变量,其中线程1修改了变量的值,那在多个CPU的情况下,线程2有的时候就会读不到最新的值。为什么呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/e4/94fe9bac8e754f33ac46e49270d737e4.jpg" alt="">
|
||||
|
||||
因为CPU里都有高速缓存,用来提高CPU访问内存数据的速度。当线程1写一个值的时候,它不一定会马上被写回到内存,这要根据高速缓存的写策略来决定(这有点像你写一个文件到磁盘上,其实不会即时写进去,而是会先保存到缓冲区,然后批量写到磁盘,这样整体效率是最高的)。同样,当线程2读取这个值的时候,它可能是从高速缓存读取的,而没有从内存刷新数据,所以读到的可能是个旧数据,即使内存中的数据已经更新了。
|
||||
|
||||
volatile关键字就是来解决这个问题的。它会告诉编译器:有多个线程可能会修改这个变量,所以当某个线程写数据的时候,要写回到内存,而不仅仅是写到高速缓存;当读数据的时候,要从内存中读,而不能从高速缓存读。
|
||||
|
||||
在下面的示例程序中,两个线程共享了同一个Num对象,其中线程2会去修改Num.value的值,而线程1会读取Num.value的值。
|
||||
|
||||
```
|
||||
public class TestVolatile {
|
||||
public static void main(String[] args) {
|
||||
new TestVolatile().doTest();
|
||||
}
|
||||
|
||||
public void doTest(){
|
||||
Num num = new Num();
|
||||
new MyThread1(num).start();
|
||||
new MyThread2(num).start();
|
||||
}
|
||||
|
||||
//线程1:读取Num.value的值。如果该值发生了变化,那么就打印出来。
|
||||
class MyThread1 extends Thread {
|
||||
private Num num;
|
||||
public MyThread1(Num num) {
|
||||
this.num = num;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
int localValue = num.value;
|
||||
while (localValue < 10){
|
||||
if (localValue != num.value){ //发现num.value变了
|
||||
System.out.println("Value changed to: " + num.value);
|
||||
localValue = num.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//线程2:修改Num.value的值。
|
||||
class MyThread2 extends Thread {
|
||||
private Num num;
|
||||
public MyThread2(Num num) {
|
||||
this.num = num;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
int localValue = num.value;
|
||||
while(num.value < 10){
|
||||
localValue ++;
|
||||
System.out.println("Change value to: " + localValue);
|
||||
num.value = localValue;
|
||||
try {
|
||||
Thread.sleep(500);
|
||||
} catch (InterruptedException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Num {
|
||||
public volatile int value = 0; //用volatile关键字修饰value
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果value字段的前面没有volatile关键字,那么线程1经常不能及时读到value的变化:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/5d/861b223beac9b5a837d0ab0dfd91225d.jpg" alt="">
|
||||
|
||||
而如果加了volatile关键字,那么每次value的变化都会马上被线程1检测到:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/cb/f989db5c941d67feaea299d0e56779cb.jpg" alt="">
|
||||
|
||||
通过这样一个简单的例子,你能更加直观地理解为什么可见性是一个重要的问题,并且能够看到volatile关键字的效果。所以,**volatile关键字的作用,是让程序在访问被修饰的变量的内存时,让其他处理器能够见到该变量最新的值**。那这是怎么实现的呢?
|
||||
|
||||
原来这里用到了一种叫做内存屏障(Memory Barriers)的技术。简单地说,编译器要在涉及volatile变量读写的时候,执行一些特殊的指令,让其他处理器获得该变量最新的值,而不是自己的一份拷贝(比如在高速缓存中)。
|
||||
|
||||
根据内存访问顺序的不同,这些内存屏障可以分为四种,分别是LoadLoad屏障、StoreStore屏障、LoadStore屏障和StoreLoad屏障。以LoadLoad屏障为例,它的指令序列是:
|
||||
|
||||
```
|
||||
Load1指令
|
||||
LoadLoad屏障
|
||||
Load2指令
|
||||
|
||||
```
|
||||
|
||||
在这种情况下,LoadLoad屏障会确保Load1的数据在Load2和后续Load指令之前,被真实地加载。
|
||||
|
||||
我们看一个例子。在下面的示例程序中,列出了用到Load1指令和Load2指令的场景。这个时候,编译器就要在这两条指令之间插入一个LoadLoad屏障:
|
||||
|
||||
```
|
||||
class Foo{
|
||||
volatile int a;
|
||||
int b, c;
|
||||
void foo(){
|
||||
int i, j;
|
||||
i = a; // Load1指令,针对volatile变量
|
||||
j = b; // Load2指令,针对普通变量
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
关于另几种内存屏障的说明,以及在什么时候需要插入内存屏障指令,你可以看下[这篇文章](http://gee.cs.oswego.edu/dl/jmm/cookbook.html)。
|
||||
|
||||
另外,不同的CPU,对于这四类屏障所对应的指令是不同的。下图也是从上面那篇文章里摘出来的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/fd/7db6a74c31023a1ab2aa3316637957fd.jpg" alt="">
|
||||
|
||||
可以看到,对于x86芯片,其中的LoadStore、LoadLoad和StoreStore屏障,都用一个no-op指令,等待前一个指令执行完毕即可。这就能确保读到正确的值。唯独对于StoreLoad的情况,也就是我们TestVolatile示例程序中一个线程写、另一个线程读的情况,需要用到几个特殊的指令之一,比如mfence指令、cpuid指令,或者在一个指令前面加锁(lock前缀)。
|
||||
|
||||
总结起来,其实**synchronized关键字也好,volatile关键字也好,都是用来保证线程之间的同步的。只不过,synchronized能够保证操作的原子性,但付出的性能代价更高;而volatile则只同步数据的可见性,付出的性能代价会低一点。**
|
||||
|
||||
在Java语言规范中,在多线程情况下与共享变量访问有关的内容,被叫做Java的内存模型,并单独占了[一节](https://docs.oracle.com/javase/specs/jls/se14/html/jls-17.html#jls-17.4)。这里面规定了在对内存(包括类的字段、数组中的元素)做操作的时候,哪些顺序是必须得到保证的,否则程序就会出错。
|
||||
|
||||
这些规定跟编译器的实现,有比较大的关系。编译器在做优化的时候,会对指令做重排序。在重排序的时候,一定要遵守Java内存模型中对执行顺序的规定,否则运行结果就会出错。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我主要以Java语言为例,讲解了多线程的原理,以及相关的程序语义。多个线程如果要访问共享的数据,通常需要进行同步。synchronized关键字能通过锁的机制,保证操作的原子性。而volatile关键字则能通过内存屏障机制,在不同的处理器之间同步共享变量的值。
|
||||
|
||||
你会发现,在写编译器的时候,只有正确地理解了这些语义和原理,才能生成正确的目标代码,所以这一讲的内容你必须要理解。学会今天这讲,还有一个作用,就是能够帮助你加深对多线程编程的底层机制的理解,更好地编写这方面的程序。
|
||||
|
||||
其他语言在实现多线程机制时,所使用的语法可能不同,但底层机制都是相同的。通过今天的讲解,你可以举一反三。
|
||||
|
||||
我把今天这讲思维导图也整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/45/fac71e0cfb6dd720b8a26f08c85f1e45.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在你之前的项目经验中,有没有遇到并发处理不当而导致的问题?你是怎么解决的呢?欢迎分享你的经验。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. [The JSR-133 Cookbook for Compiler Writers](http://gee.cs.oswego.edu/dl/jmm/cookbook.html),介绍了编译器如何为Java语言实现内存屏障。
|
||||
1. Java语言规范中对[内存模型的相关规定](https://docs.oracle.com/javase/specs/jls/se14/html/jls-17.html#jls-17.4)。
|
||||
288
极客时间专栏/geek/编译原理实战课/现代语言设计篇/34 | 并发中的编译技术(二):如何从语言层面支持协程?.md
Normal file
288
极客时间专栏/geek/编译原理实战课/现代语言设计篇/34 | 并发中的编译技术(二):如何从语言层面支持协程?.md
Normal file
@@ -0,0 +1,288 @@
|
||||
<audio id="audio" title="34 | 并发中的编译技术(二):如何从语言层面支持协程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0c/cc/0c34dcd5fc349da62ffdc8a234f199cc.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲我们提到了线程模式是当前计算机语言支持并发的主要方式。
|
||||
|
||||
不过,在有些情况下,线程模式并不能满足要求。当需要运行大量并发任务的时候,线程消耗的内存、线程上下文切换的开销都太大。这就限制了程序所能支持的并发任务的数量。
|
||||
|
||||
在这个背景下,一个很“古老”的技术重新焕发了青春,这就是协程(Coroutine)。它能以非常低的代价、友好的编程方式支持大量的并发任务。像Go、Python、Kotlin、C#等语言都提供了对协程的支持。
|
||||
|
||||
今天这一讲,我们就来探究一下如何在计算机语言中支持协程的奇妙功能,它与编译技术又是怎样衔接的。
|
||||
|
||||
首先,我们来认识一下协程。
|
||||
|
||||
## 协程(Coroutine)的特点与使用场景
|
||||
|
||||
我说协程“古老”,是因为这个概念是在1958年被马尔文 · 康威(Melvin Conway)提出来、在20世纪60年代又被高德纳(Donald Ervin Knuth)总结为两种子过程(Subroutine)的模式之一。一种是我们常见的函数调用的方式,而另一种就是协程。在当时,计算机的性能很低,完全没有现代的多核计算机。而采用协程就能够在这样低的配置上实现并发计算,可见它是多么的轻量级。
|
||||
|
||||
有的时候,协程又可能被称作绿色线程、纤程等,所采用的技术也各有不同。但总的来说,**它们都有一些共同点**。
|
||||
|
||||
首先,协程占用的资源非常少。你可以在自己的笔记本电脑上随意启动几十万个协程,而如果你启动的是几十万个线程,那结果就是不可想象的。比如,在JVM中,缺省会为每个线程分配1MB的内存,用于线程栈等。这样的话,几千个线程就要消耗掉几个GB的内存,而几十万个线程理论上需要消耗几百GB的内存,这还没算程序在堆中需要申请的内存。当然,由于底层操作系统和Java应用服务器的限制,你也无法启动这么多线程。
|
||||
|
||||
其次,协程是用户自己的程序所控制的并发。也就是说,协程模式,一般是程序交出运行权,之后又被另外的程序唤起继续执行,整个过程完全是由用户程序自己控制的。而线程模式就完全不同了,它是由操作系统中的调度器(Scheduler)来控制的。
|
||||
|
||||
我们看个Python的例子:
|
||||
|
||||
```
|
||||
def running_avg():
|
||||
total = 0.0
|
||||
count = 0
|
||||
avg = 0
|
||||
while True:
|
||||
num = yield avg
|
||||
total += num
|
||||
count += 1
|
||||
avg = total/count
|
||||
|
||||
#生成协程,不会有任何输出
|
||||
ra = running_avg()
|
||||
#运行到yield
|
||||
next(ra)
|
||||
|
||||
print(ra.send(2))
|
||||
print(ra.send(3))
|
||||
print(ra.send(4))
|
||||
print(ra.send(7))
|
||||
print(ra.send(9))
|
||||
print(ra.send(11))
|
||||
|
||||
#关掉协程
|
||||
ra.close
|
||||
|
||||
```
|
||||
|
||||
可以看到,使用协程跟我们平常使用函数几乎没啥差别,对编程人员很友好。实际上,它可以认为是跟函数并列的一种子程序形式。和函数的区别是,函数调用时,调用者跟被调用者之间像是一种上下级的关系;而在协程中,调用者跟被调用者更像是互相协作的关系,比如一个是生产者,一个是消费者。这也是“协程”这个名字直观反映出来的含义。
|
||||
|
||||
我们用一张图来对比下函数和协程中的调用关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/4d/5daca741e22f31520ff0d39b5acc8f4d.jpg" alt="">
|
||||
|
||||
细想一下,编程的时候,这种需要子程序之间互相协作的场景有很多,我们一起看两种比较常见的场景。
|
||||
|
||||
**第一种比较典型的场景,就是生产者和消费者模式**。如果你用过Unix管道或者消息队列编程的话,会非常熟悉这种模式。但那是在多个进程之间的协作。如果用协程的话,在一个进程内部就能实现这种协作,非常轻量级。
|
||||
|
||||
就拿编译器前端来说,词法分析器(Tokenizer)和语法分析器(Parser)就可以是这样的协作关系。也就是说,为了更好的性能,没有必要一次把词法分析完毕,而是语法分析器消费一个,就让词法分析器生产一个。因此,这个过程就没有必要做成两个线程了,否则就太重量级了。这种场景,我们可以叫做生成器(Generator)场景:主程序调用生成器,给自己提供数据。
|
||||
|
||||
**特别适合使用协程的第二种场景是IO密集型的应用**。比如做一个网络爬虫同时执行很多下载任务,或者做一个服务器同时响应很多客户端的请求,这样的任务大部分时间是在等待网络传输。
|
||||
|
||||
如果用同步阻塞的方式来做,一个下载任务在等待的时候就会把整个线程阻塞掉。而用异步的方式,协程在发起请求之后就把控制权交出,调度程序接收到数据之后再重新激活协程,这样就能高效地完成IO操作,同时看上去又是用同步的方式编程,不需要像异步编程那样写一大堆难以阅读的回调逻辑。
|
||||
|
||||
这样的场景在微服务架构的应用中很常见,我们来简化一个实际应用场景,分析下如何使用协程。
|
||||
|
||||
在下面的示例中,应用A从客户端接收大量的并发请求,而应用A需要访问应用B的服务接口,从中获得一些信息,然后返回给客户端。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/12/a3a7744f2f11c7ec57f4250d927a4112.jpg" alt="">
|
||||
|
||||
要满足这样的场景,我们最容易想到的就是,**编写同步通讯的程序**,其实就是同步调用。
|
||||
|
||||
假设应用A对于每一个客户端的请求,都会起一个线程做处理。而你呢,则在这个线程里发起一个针对应用B的请求。在等待网络返回结果的时候,当前线程会被阻塞住。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f0/25/f04b7058db480194fa80edd6849cyy25.jpg" alt="">
|
||||
|
||||
这个架构是最简单的,你如果采用Java的Servlet容器来编写程序的话,很可能会采用这个结构。但它有一些缺陷:
|
||||
|
||||
- 对于每个客户端请求,都要起一个线程。如果请求应用B的时延比较长,那么在应用A里会积压成千上万的线程,从而浪费大量的服务器资源。而且,当线程超过一定数量,应用服务器就会拒绝后续的请求。
|
||||
- 大量的请求毫无节制地涌向应用B,使得应用B难以承受负载,从而导致响应变慢,甚至宕机。
|
||||
|
||||
因为同步调用的这种缺点,近年来**异步编程模型**得到了更多的应用,典型的就是Node.js。在异步编程模型中,网络通讯等IO操作不必阻塞线程,而是通过回调来让主程序继续执行后续的逻辑。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/83/d69d2d5e6c220286716c9bc073523d83.jpg" alt="">
|
||||
|
||||
上图中,我们只用到了4个线程,对应操作系统的4个真线程,可以减少线程切换的开销。在每个线程里,维护一个任务队列。首先,getDataFromApp2()会被放到任务队列;当数据返回以后,系统的调度器会把sendBack()函数放进任务队列。
|
||||
|
||||
这个例子比较简单,只有一层回调,你还能读懂它的逻辑。但是,采用这种异步编程模式,经常会导致多层回调,让代码很难阅读。这种现象,被叫做“回调地狱(Callback Hell)”。
|
||||
|
||||
这时候,就显示出协程的优势了。**协程可以让你用自己熟悉的命令式编程的风格,来编写异步的程序**。比如,对于上面的示例程序,用协程可以这样写,看上去跟编写同步调用的代码没啥区别。
|
||||
|
||||
```
|
||||
requestHandler(){
|
||||
...;
|
||||
await getDataFromApp2();
|
||||
...;
|
||||
sendBack();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当然,我要强调一下,在协程用于同步和异步编程的时候,其调度机制是不同的。跟异步编程配合的时候,要把异步IO机制与协程调度机制关联起来。
|
||||
|
||||
好了,现在你已经了解了协程的特点和适用场景。那么问题来了,如何让一门语言支持协程呢?要回答这个问题,我们就要先学习一下协程的运行原理。
|
||||
|
||||
## 协程的运行原理
|
||||
|
||||
当我们使用函数的时候,简单地保持一个调用栈就行了。当fun1调用fun2的时候,就往栈里增加一个新的栈帧,用于保存fun2的本地变量、参数等信息;这个函数执行完毕的时候,fun2的栈帧会被弹出(恢复栈顶指针sp),并跳转到返回地址(调用fun2的下一条指令),继续执行调用者fun1的代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/ea/c1a859d94c48bbb245e3e3a51ee9f3ea.jpg" alt="">
|
||||
|
||||
但如果调用的是协程coroutine1,该怎么处理协程的栈帧呢?因为协程并没有执行完,显然还不能把它简单地丢掉。
|
||||
|
||||
这种情况下,程序可以从堆里申请一块内存,保存协程的活动记录,包括本地变量的值、程序计数器的值(当前执行位置)等等。这样,当下次再激活这个协程的时候,可以在栈帧和寄存器中恢复这些信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/7e/0d6986ebfe379694759b84b57eb2877e.jpg" alt="">
|
||||
|
||||
把活动记录保存到堆里,是不是有些眼熟?其实,这有点像闭包的运行机制。
|
||||
|
||||
程序在使用闭包的时候,也需要在堆里保存闭包中的自由变量的信息,并且在下一次调用的时候,从堆里恢复。只不过,闭包不需要保存本地变量,只保存自由变量就行了;也不需要保存程序计数器的值,因为再一次调用闭包函数的时候,还是从头执行,而协程则是接着执行yield之后的语句。
|
||||
|
||||
fun1通过resume语句,让协程继续运行。这个时候,协程会去调用一个普通函数fun2,而fun2的栈帧也会加到栈上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/de/f52a8758cbe86006a7400b29aa9bdede.jpg" alt="">
|
||||
|
||||
如果fun2执行完毕,那么就会返回到协程。而协程也会接着执行下一个语句,这个语句是一个专门针对协程的返回语句,我们叫它co_return吧,以便区别于传统的return。在执行了co_return以后,协程就结束了,无法再resume。这样的话,保存在堆里的活动记录也就可以销毁了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/a1/dfd44eac96784a283eac49750c6ebaa1.jpg" alt="">
|
||||
|
||||
通过上面的例子,你应该已经了解了协程的运行原理。那么我们学习编译原理会关心的问题是:**实现协程的调度,包括协程信息的保存与恢复、指令的跳转,需要编译器的帮忙吗?还是用一个库就可以实现?**
|
||||
|
||||
实际上,对于C和C++这样的语言来说,确实用一个库就可以实现。因为C和C++比较灵活,比如可以用setjmp、longjmp等函数,跨越函数的边界做指令的跳转。但如果用库实现,通常要由程序管理哪些状态信息需要被保存下来。为此,你可能要专门设计一个类型,来参与实现协程状态信息的维护。
|
||||
|
||||
而如果用编译器帮忙,那么就可以自动确定需要保存的协程的状态信息,并确定需要申请的内存大小。一个协程和函数的区别,就仅仅在于是否使用了yield和co_return语句而已,减轻了程序员编程的负担。
|
||||
|
||||
好了,刚才我们讨论了,在实现协程的时候,要能够正确保存协程的活动记录。在具体实现上,有Stackful和Stackless两种机制。采用不同的机制,对于协程能支持的特性也很有关系。所以接下来,我带你再进一步地分析一下Stackful和Stackless这两种机制。
|
||||
|
||||
## Stackful和Stackless的协程
|
||||
|
||||
到目前为止,看上去协程跟普通函数(子程序)的差别也不大嘛,你看:
|
||||
|
||||
- 都是由一个主程序调用,运行一段时间以后再把控制流交回给主程序;
|
||||
- 都使用栈来管理本地变量和参数等信息,只不过协程在没有完全运行完毕时,要用堆来保存活动记录;
|
||||
- 在协程里也可以调用其他的函数。
|
||||
|
||||
可是,在有的情况下,我们没有办法直接在coroutine1里确定是否要暂停线程的执行,可能需要在下一级的子程序中来确定。比如说,coroutine1函数变得太大,我们重构后,把它的功能分配到了几个子程序中。那么暂停协程的功能,也会被分配到子程序中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/93/dd/9317554e9243f9ef4708858fe22322dd.jpg" alt="">
|
||||
|
||||
这个时候,在helper()中暂停协程,会让控制流回到fun1函数。而当在fun1中调用resume的时候,控制流应该回到helper()函数中yield语句的下一条,继续执行。coroutine1()和helper()加在一起,起到了跟原来只有一个coroutine1()一样的效果。
|
||||
|
||||
这个时候,在栈里不仅要加载helper()的活动记录,还要加载它的上一级,也就是coroutine1()的活动记录,这样才能维护正确的调用顺序。当helper()执行完毕的时候,控制流会回到coroutine1(),继续执行里面的逻辑。
|
||||
|
||||
在这个场景下,不仅要从堆里恢复多个活动记录,还要维护它们之间的正确顺序。上面的示例中,还只有两级调用。如果存在多级的调用,那就更麻烦了。
|
||||
|
||||
那么,怎么解决这个技术问题呢?你会发现,其实协程的逐级调用过程,形成了自己的调用栈,这个调用栈需要作为一个整体来使用,不能拆成一个个单独的活动记录。
|
||||
|
||||
既然如此,那我们就加入一个辅助的运行栈好了。这个栈通常被叫做**Side Stack**。每个协程,都有一个自己专享的协程栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/c0/2bf6a16cf5d7e85ee1db921efe0d0bc0.jpg" alt="">
|
||||
|
||||
好了,现在是时候给你介绍两个术语了:这种需要一个辅助的栈来运行协程的机制,叫做**Stackful Coroutine**;而在主栈上运行协程的机制,叫做**Stackless Coroutine**。
|
||||
|
||||
对于Stackless的协程来说,只能在顶层的函数里把控制权交回给调用者。如果这个协程调用了其他函数或者协程,必须等它们返回后,才能去执行暂停协程的操作。从这种角度看,Stackless的特征更像一个函数。
|
||||
|
||||
而对于Stackful的协程来说,可以在协程栈的任意一级,暂停协程的运行。从这个角度看,Stackful的协程像是一个线程,不管有多少级的调用,随时可以让这个协程暂停,交出控制权。
|
||||
|
||||
除此之外,我们再仔细去想,因为设计上的不同,Stackless和Stackful的协程其实还会产生其他的差别:
|
||||
|
||||
- Stackless的协程用的是主线程的栈,也就是说它基本上会被绑定在创建它的线程上了。而Stackful的协程,可以从一个线程脱离,附加到另一个线程上。
|
||||
- Stackless的协程的生命周期,一般来说受制于它的创建者的生命周期。而Stackful的协程的生命周期,可以超过它的创建者的生命周期。
|
||||
|
||||
好了,以上就是对Stackless和Stackful的协程的概念和区别了。其实,对于协程,我们可能还会听说一种分类方法,就是对称的和非对称的。
|
||||
|
||||
到目前为止,我们讲到的协程都是非对称的。有一个主程序,而协程像是子程序。主程序和子程序控制程序执行的原语是不同的,一个用于激活协程,另一个用于暂停协程。而对称的协程,相互之间是平等的关系,它们使用相同的原语在协程之间移交控制权。
|
||||
|
||||
那么,C++、Python、Java、JavaScript、Julia和Go这些常见语言中,哪些是支持协程的?是Stackless的 ,还是Stackful的?是对称的,还是非对称的?需要编译器做什么配合?
|
||||
|
||||
接下来,我们就一起梳理下。
|
||||
|
||||
## 不同语言的协程实现和差异
|
||||
|
||||
### C++语言的协程实现
|
||||
|
||||
今年发布的C++20标准中,增加了协程特性。标准化组织经过长期的讨论,采用了微软的Stackless模式。采纳的原因也比较简单,就是因为它实现起来简单可靠,并且已经在微软有多年的成熟运行的经验了。
|
||||
|
||||
在这个方案里,采用了co_await、co_yield和co_return三个新的关键字让程序员使用协程,并在编译器层面给予了支持。
|
||||
|
||||
而我们说过,C和C++的协程功能,只用库也可以实现。其中,腾讯的微信团队就开源了一套协程库,叫做libco。这个协程库是支撑微信背后海量并发调用的基础。采用这个协程库,单机竟然可以处理千万级的连接!
|
||||
|
||||
并且,libco还做了一点创新。因为libco是Stackful的,对每个协程都要分配一块栈空间,在libco中给每个协程分配的是128KB。那么,1千万协程就需要1.2TB的内存,这样服务器的内存就会成为资源瓶颈。所以,**libco发明了共享栈的机制**:当一个协程不用栈的时候,把里面的活动记录保存到协程私有的内存中,把协程栈腾出来给其他协程使用。一般来说,一个活动记录的大小要远小于128KB,所以总体上节省了内存。
|
||||
|
||||
另外,libco还跟异步通讯机制紧密整合,实现了**用同步的编程风格来实现异步的功能**,使得微信后台的处理能力大大提升。微信后台用协程做升级的案例,你可以看看[这篇文章](https://www.infoq.cn/article/CplusStyleCorourtine-At-Wechat/)。
|
||||
|
||||
接下来,我们说说Python语言的协程实现。
|
||||
|
||||
### Python语言的协程实现
|
||||
|
||||
我们前面讲协程的运行原理用的示例程序,就是用Python写的。这是Python的一种协程的实现,支持的是同步处理,叫做generator模式。3.4版本之后,Python支持一种异步IO的协程模式,采用了async/await关键字,能够以同步的语法编写异步程序。
|
||||
|
||||
总体来说,Python是一种解释型的语言,而且内部所有成员都是对象,所以实现协程的机制是很简单的,保存协程的执行状态也很容易。只不过,你不可能把Python用于像刚才微信那样高并发的场景,因为解释型语言对资源的消耗太高了。尽管如此,在把Python当作脚本语言使用的场景中,比如编写网络爬虫,采用它提供的协程加异步编程的机制,还是能够带来很多好处的。
|
||||
|
||||
我们再来说说Java和JavaScript语言的协程实现。
|
||||
|
||||
### Java的协程实现
|
||||
|
||||
其实,Java原生是不支持协程的,但是也有几种方法可以让Java支持协程:
|
||||
|
||||
- 方法1:给虚拟机打补丁,从底层支持协程。
|
||||
- 方法2:做字节码操纵,从而改变Java缺省的控制流执行方式,并保存协程的活动记录。
|
||||
- 方法3:基于JNI。比如,C语言可以实现协程,然后再用JNI去调用C语言实现的功能。
|
||||
- 方法4:把线程封装成协程。这种实现技术太过于重量级,因为没有体现出协程占据资源少的优点。
|
||||
|
||||
现在有一些第三方库实现了协程功能,基本上都是基于方法2,也就是做字节码操纵。目前还没有哪一个库被广泛接受。如果你不想冒险的话,可能还是要等待官方的实现了。
|
||||
|
||||
### JavaScript中的协程
|
||||
|
||||
JavaScript从ES6(ECMAScript 6.0)引入了generator功能,ES7引入了支持异步编程的async/await。由于JavaScript本来就非常重视异步编程,所以协程的引入,会让异步编程变得更友好。
|
||||
|
||||
### Julia和Go语言的协程实现
|
||||
|
||||
Julia语言的协程机制,跟以上几种语言都不同。它提供的是对称的协程机制。多个协程可以通过channel通讯,当从channel里取不出信息时,或者channel已满不能再发信息时,自然就停下来了。
|
||||
|
||||
当我谈到channel的时候,熟悉Go语言的同学马上就会想到Goroutine。Goroutine是Go语言的协程机制,也是用channel实现协程间的协作的。
|
||||
|
||||
我把对Go语言协程机制的介绍放在最后,是因为Goroutine实在是很强大。我觉得,**所有对并发编程有兴趣的同学,都要看一看Goroutine的实现机制,都会得到很大的启发**。
|
||||
|
||||
我的感受是,Goroutine简直是实现轻量级并发功能的集大成者,几乎考虑到了你能想到的所有因素。介绍Goroutine的文章有很多,我就不去重复已有的内容了,你可以看看“[How Stacks are Handled in Go](https://blog.cloudflare.com/how-stacks-are-handled-in-go/)”这篇文章。现在,我就顺着本讲的知识点,对Goroutine的部分特点做一点介绍。
|
||||
|
||||
首先我们来看一下,Goroutine是Stackful还是Stackless?答案是**Stackful**的。就像我们前面已经总结过的,Stackful协程的特点主要是两点:协程的生命周期可以超过其创建者,以及协程可以从一个线程转移到另一个线程。后者在Goroutine里特别有用。当一个协程调用了一个系统功能,导致线程阻塞的时候,那么排在这条线程上的其他Goroutine岂不是也要被迫等待?为了避免这种尴尬,Goroutine的调度程序会把被阻塞的线程上的其他Goroutine迁移到其他线程上。
|
||||
|
||||
我们讲libco的时候还讲过,Stackful的缺点是要预先分配比较多的内存用作协程的栈空间,比如libco要为每个协程分配128K的栈。而Go语言只需要为每个Goroutine分配2KB的栈。你可能会问了,万一空间不够了怎么办,不会导致内存访问错误吗?
|
||||
|
||||
不会的。Go语言的函数在运行的时候,会有一小块序曲代码,用来检查栈空间够不够用。如果不够用,就马上申请新的内存。需要注意的是,像这样的机制,必须有编译器的配合才行,编译器可以为每个函数生成这样的序曲代码。如果你用库来实现协程,就无法实现这样的功能。
|
||||
|
||||
通过这个例子,你也可以体会到把某个特性做成语言原生的,以及用库去实现的差别。
|
||||
|
||||
我想说的Go语言协程机制的第二个特点,就是**channel机制**。channel提供了Goroutine之间互相通讯,从而能够协调行为的机制。Go语言的运行时保证了在同一个时刻,只有一个Goroutine能够读写channel,这就避免了我们前一讲提到的,用锁来保证多个线程访问共享数据的难题。当然,channel在底层也采用了锁的机制,毕竟现在不需要程序员去使用这么复杂且容易出错的机制了。
|
||||
|
||||
Go语言协程机制的第三个特点,是关于**协程的调度时机**。今天这一讲,我们其实看到了两种调度时机:对于generator类型的协程,基本上是同步调度的,协程暂停以后,控制立即就回到主程序;第二个调度机制,是跟异步IO机制配合。
|
||||
|
||||
而我关心的,是能否实现像线程那样的抢占式(preemptive)的调度。操作系统的线程调度器,在进行调度的时候,可以不管当前线程运行到了什么位置,直接中断它的运行,并把相关的寄存器的值保存下来,然后去运行另一个线程。这种抢占式的调度的一个最大的好处,是不会让某个程序霸占CPU资源不放,而是公平地分配给各个程序。而协程也存在着类似的问题。如果一个协程长时间运行,那么排在这条线程上的其他协程,就被剥夺了运行的机会。
|
||||
|
||||
Goroutine在解决这个问题上也做了一些努力。比如,在下面的示例程序中,foo函数中的循环会一直运行。这时候,编译器就可以在bar()函数的序曲中,插入一些代码,检查当前协程是否运行时间太久,从而主动让出控制权。不过,如果bar()函数被内联了,处理方式就要有所变化。但总的来说,由于有编译器的参与,这种类似抢占的逻辑是可以实现的。
|
||||
|
||||
```
|
||||
func foo(){
|
||||
while true{
|
||||
bar(); //可以在bar函数的序曲中做检查。
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Goroutine实现了各种丰富的调度机制以后,它已经变得不完全由用户的程序来主导协程的调度了,而是能够更加智能、更加优化地实现协程的调度,由操作系统的线程调度器、Go语言的调度器和用户程序三者配合实现。这也是Go语言的一个重要优点。
|
||||
|
||||
那么,我们从C、C++、Python、Java、JavaScript、Julia和Go语言中,就能总结出协程实现上的特点了:
|
||||
|
||||
- 除了Julia和Go,其他语言采用的都是非对称的协程机制。Go语言是采用协程最彻底的。在采用了协程以后,已经不需要用过去的线程。
|
||||
- 像C++、Go这样编译成机器码执行的语言,对协程栈的良好管理,能够大大降低内存占用,增加支持的协程的数量。
|
||||
- 协程与异步IO结合是一个趋势。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们学习了协程的定义、使用场景、实现原理和不同语言的具体实现机制。我们特别从编译技术的角度,关注了协程对栈的使用机制,看看它与传统的程序有什么不同。
|
||||
|
||||
在这个过程中,一方面,你会通过今天的课程对协程产生深入的认识;另一方面,你会更加深刻地认识到编译技术是如何跟语言特性的设计和运行时紧密配合的。
|
||||
|
||||
协程可以用库实现,也可以借助编译技术成为一门语言的原生特性。采用编译技术,能帮助我们自动计算活动记录的大小,实现自己独特的栈管理机制,实现抢占式调度等功能。
|
||||
|
||||
本讲的思维导图我也放在了下面,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/38/89f40bc89yyf16f0d855d43e85d9c838.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
上一讲我们讨论的是线程模式。我们知道,当并发访问量非常大的时候,线程模式消耗的资源会太多。那么你会如何解决这个问题?是否会采用协程?如果你使用的是Java语言,其原生并不支持协程,你会怎么办?欢迎发表你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. [How Stacks are Handled in Go](https://blog.cloudflare.com/how-stacks-are-handled-in-go/),这篇文章介绍了Goroutine使用栈的机制,你可以看看它是如何很节约地使用内存的。
|
||||
1. [Coroutines in Java](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.1041.3083&rep=rep1&type=pdf),这篇文章探讨了在Java中实现协程的各种技术考虑。
|
||||
195
极客时间专栏/geek/编译原理实战课/现代语言设计篇/35 | 并发中的编译技术(三):Erlang语言厉害在哪里?.md
Normal file
195
极客时间专栏/geek/编译原理实战课/现代语言设计篇/35 | 并发中的编译技术(三):Erlang语言厉害在哪里?.md
Normal file
@@ -0,0 +1,195 @@
|
||||
<audio id="audio" title="35 | 并发中的编译技术(三):Erlang语言厉害在哪里?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c0/19/c0a5ced3a0beaef1170aa16cc9826f19.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在前面两讲,我们讨论了各门语言支持的并发计算的模型。线程比进程更加轻量级,上下文切换成本更低;协程则比线程更加轻量级,在一台计算机中可以轻易启动几十万、上百万个并发任务。
|
||||
|
||||
但不论是线程模型、还是协程模型,当涉及到多个线程访问共享数据的时候,都会出现竞争问题,从而需要用到锁。锁会让其他需要访问该数据的线程等待,从而导致系统整体处理能力的降低。
|
||||
|
||||
并且,编程人员还要特别注意,避免出现死锁。比如,线程A持有了锁x,并且想要获得锁y;而线程B持有了锁y,想要获得锁x,结果这两个线程就会互相等待,谁也进行不下去。像数据库这样的系统,检测和消除死锁是一项重要的功能,以防止互相等待的线程越来越多,对数据库操作不响应,并最终崩溃掉。
|
||||
|
||||
既然使用锁这么麻烦,那在并发计算中,能否不使用锁呢?这就出现了Actor模型。那么,**什么是Actor模型?为什么它可以不用锁就实现并发?这个并发模型有什么特点?需要编译技术做什么配合?**
|
||||
|
||||
今天这一讲,我们就从这几个问题出发,一起学习并理解Actor模型。借此,我们也可以把用编译技术支持不同的并发模型的机制,理解得更深刻。
|
||||
|
||||
首先,我们看一下什么是Actor模型。
|
||||
|
||||
## 什么是Actor模型?
|
||||
|
||||
在线程和协程模型中,之所以用到锁,是因为两个线程共享了内存,而它们会去修改同一个变量的值。那,如果避免共享内存,是不是就可以消除这个问题了呢?
|
||||
|
||||
没错,这就是Actor模型的特点。Actor模型是1973年由Carl Hewitt提出的。在Actor模型中,并发的程序之间是不共享内存的。它们通过互相发消息来实现协作,很多个一起协作的Actor就构成了一个支持并发计算的系统。
|
||||
|
||||
我们看一个有三个Actor的例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d1/fb/d1494936866024b3c9a687da3a9de1fb.jpg" alt="">
|
||||
|
||||
你会注意到,每个Actor都有一个邮箱,用来接收其他Actor发来的消息;每个Actor也都可以给其他Actor发送消息。这就是Actor之间交互的方式。Actor A给Actor B发完消息后就返回,并不会等着Actor B处理完毕,所以它们之间的交互是异步的。如果Actor B要把结果返回给A,也是通过发送消息的方式。
|
||||
|
||||
这就是Actor大致的工作原理了。因为Actor之间只是互发消息,没有共享的变量,当然也就不需要用到锁了。
|
||||
|
||||
但是,你可能会问:如果不共享内存,能解决传统上需要对资源做竞争性访问的需求吗?比如,卖电影票、卖火车票、秒杀或者转账的场景。我们以卖电影票为例讲解一下。
|
||||
|
||||
在用传统的线程或者协程来实现卖电影票功能的时候,对票的状态进行修改,需要用锁的机制实现同步互斥,以保证同一个时间段只有一个线程可以去修改票的状态、把它分配给某个用户,从而避免多个线程同时访问而出现一张票卖给多个人的情况。这种情况下,多个程序是串行执行的,所以系统的性能就很差。
|
||||
|
||||
如果用Actor模式会怎样呢?
|
||||
|
||||
你可以把电影院的前半个场地和后半个场地的票分别由Actor B和 C负责销售:Actor A在接收到定前半场座位的请求的时候,就发送给Actor B,后半场的就发送给Actor C,Actor B和C依次处理这些请求;如果Actor B或C接收到的两个信息都想要某个座位,那么针对第二个请求会返回订票失败的消息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/2d/31838a322d9bddd811a53b44e63ac82d.jpg" alt="">
|
||||
|
||||
你发现没有?在这个场景中,Actor B和C仍然是顺序处理各个请求。但因为是两个Actor并发地处理请求,所以系统整体的性能会提升到原来的两倍。
|
||||
|
||||
甚至,你可以让每排座位、每个座位都由一个Actor负责,使得系统的性能更高。因为在系统中创建一个Actor的成本是很低的。Actor跟协程类似,很轻量级,一台服务器里创建几十万、上百万个Actor也没有问题。如果每个Actor负责一个座位,那一台服务器也能负责几十万、上百万个座位的销售,也是可以接受的。
|
||||
|
||||
当然,实际的场景要比这个复杂,比如一次购买多张相邻的票等,但原理是一样的。用这种架构,可以大大提高并发能力,处理海量订票、秒杀等场景不在话下。
|
||||
|
||||
其实,我个人比较喜欢Actor这种模式,因为它跟现实世界里的分工协作很相似。比如,餐厅里不同岗位的员工,他们通过互相发信息来实现协作,从而并发地服务很多就餐的顾客。
|
||||
|
||||
分析到这里,我再把Actor模式跟你非常熟悉的一个概念,面向对象编程(Object Oriented Programming,OOP)关联起来。你可能会问:Actor和面向对象怎么还有关联?
|
||||
|
||||
是的。面向对象语言之父阿伦 · 凯伊(Alan Kay),Smalltalk的发明人,在谈到面向对象时是这样说的:对象应该像生物的细胞,或者是网络上的计算机,它们只能通过消息互相通讯。对我来说OOP仅仅意味着消息传递、本地保留和保护以及隐藏状态过程,并且尽量推迟万物之间的绑定关系。
|
||||
|
||||
>
|
||||
<p>I thought of objects being like biological cells and/or individual computers on a network, only able to communicate with messages (so messaging came at the very beginning – it took a while to see how to do messaging in a programming language efficiently enough to be useful)<br>
|
||||
…<br>
|
||||
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.</p>
|
||||
|
||||
|
||||
总结起来,Alan对面向对象的理解,强调消息传递、封装和动态绑定,没有谈多态、继承等。对照这个理解,你会发现Actor模式比现有的流行的面向对象编程语言,更加接近面向对象的实现。
|
||||
|
||||
无论如何,通过把Actor和你熟悉的面向对象做关联,我相信能够拉近你跟Actor之间的距离,甚至会引发你以新的视角来审视目前流行的面向对象范式。
|
||||
|
||||
好了,到现在,你可以说是对Actor模型比较熟悉了,也可以这么理解:Actor有点像面向对象程序里的对象,里面可以封装一些数据和算法;但你不能调用它的方法,只能给它发消息,它会异步地、并发地处理这些消息。
|
||||
|
||||
但是,你可能会提出一个疑问:Actor模式不用锁的机制就能实现并发程序之间的协作,这一点很好,那么它有没有什么缺点呢?
|
||||
|
||||
我们知道,任何设计方案都是一种取舍。一个方案有某方面的优势,可能就会有其他方面的劣势。**采用Actor模式,会有两方面的问题**。
|
||||
|
||||
第一,由于Actor之间不共享任何数据,因此不仅增加了数据复制的时间,还增加了内存占用量。但这也不完全是缺点:一方面,你可以通过在编写程序时,尽量降低消息对象的大小,从而减少数据复制导致的开销;另一方面,消息传递的方式对于本机的Actor和集群中的Actor是一样的,这就使得编写分布式的云端应用更简单,从而在云计算时代可以获得更好的应用。
|
||||
|
||||
第二,基于消息的并发机制,基本上是采用异步的编程模式,这就和通常程序的编程风格有很大的不同。你发出一个消息,并不会马上得到结果,而要等待另一个Actor发送消息回来。这对于习惯于编写同步代码的同学,可能是一个挑战。
|
||||
|
||||
好了,我们已经讨论了Actor机制的特点。接下来我们再看看,什么语言和框架实现了Actor模式。
|
||||
|
||||
## 支持Actor模型的语言和框架
|
||||
|
||||
支持Actor的最有名的语言是Erlang。Erlang是爱立信公司发明的,它的正式版本是在1987年发布,其核心设计者是乔 · 阿姆斯特朗(Joe Armstrong),最早是用于开发电信领域的软件系统。
|
||||
|
||||
在Erlang中,每个Actor叫作一个进程(Process)。但这个“进程”其实不是操作系统意义上的进程,而是Erlang运行时的并发调度单位。
|
||||
|
||||
Erlang有两个显著的优点:首先,对并发的支持非常好,所以它也被叫做面向并发的编程语言(COP)。第二,用Erlang可以编写高可靠性的软件,可以达到9个9。这两个优点都与Actor模式有关:
|
||||
|
||||
- Erlang的软件由很多Actor构成;
|
||||
- 这些Actor可以分布在多台机器上,相互之间的通讯跟在同一台机器上没有区别;
|
||||
- 某个Actor甚至机器出现故障,都不影响整体系统,可以在其他机器上重新启动该Actor;
|
||||
- Actor的代码可以在运行时更新。
|
||||
|
||||
所以,由Actor构成的系统真的像一个生命体,每个Actor像一个细胞。细胞可以有新陈代谢,而生命体却一直存在。可以说,用Erlang编写的基于Actor模式的软件,非常好地体现了复杂系统的精髓。到这里,你是不是就能解释“Erlang语言厉害在哪里”这个问题了。
|
||||
|
||||
鉴于Actor为Erlang带来的并发能力和高可靠性,有一些比较流行的开源系统就是用Erlang编写的。比如,消息队列系统RabbitMQ、分布式的文档数据库系统CouchDB,都很好地体现了Erlang的并发能力和健壮性。
|
||||
|
||||
除了Erlang以外,Scala语言也提供了对Actor的支持,它是通过Akka库实现的,运行在JVM上。我还关注了微软的一个Orleans项目,它在.NET平台上支持Actor模式,并进一步做了一些有趣的创新。
|
||||
|
||||
那接下来我们继续探讨一下,这些语言和框架是如何实现Actor机制的,以及需要编译器做什么配合。
|
||||
|
||||
## Actor模型的实现
|
||||
|
||||
在上一讲研究过协程的实现机制以后,我们现在再分析Actor的实现机制时,其实就应该会把握要点了。比如说,我们会去看它的调度机制和内存管理机制等。鉴于Erlang算是支持Actor的最有名、使用最多的语言,接下来我会以Erlang的实现机制带你学习Actor机制是如何实现的。
|
||||
|
||||
首先,我们知道,肯定要有个调度器,把海量的Actor在多个线程上调度。
|
||||
|
||||
### 并发调度机制
|
||||
|
||||
那我们需要细究一下:对于Actor,该如何做调度呢?什么时候把一个Actor停下,让另一个Actor运行呢?
|
||||
|
||||
协程也好,Actor也好,都是在应用级做调度,而不是像线程那样,在应用完全不知道的情况下,就被操作系统调度了。对于协程,我们是通过一些像yield这样的特殊语句,触发调度机制。那,**Actor在什么时候调度比较好呢?**
|
||||
|
||||
前面我们也讲过了,Actor的运行规律,是每次从邮箱取一条消息并进行处理。那么,我们自然会想到,一个可选的调度时机,就是让Actor每处理完一条消息,就暂停一下,让别的Actor有机会运行。当然,如果处理一条消息所花费的时间太短,比如有的消息是可以被忽略的,那么处理多条消息,累积到一定时间再去调度也行。
|
||||
|
||||
了解了调度时机,我们再挑战第二个比较难的话题:如果处理一条消息就要花费很长时间怎么办呢?能否实现**抢占式的调度**呢,就像Goroutine那样?
|
||||
|
||||
当然可以,但这个时候就肯定需要编译器和运行时的配合了。
|
||||
|
||||
Erlang的运行机制,是基于一个寄存器机解释执行。这使得调度器可以在合适的时机,去停下某个Actor的运行,调度其他Actor过来运行。
|
||||
|
||||
Erlang做抢占式调度的机制是对Reduction做计数,Reduction可以看作是占时不长的一小块工作量。如果某个Actor运行了比较多的Reduction,那就可以对它做调度,从而提供了软实时的能力(具体可以参考[这篇文章](https://blog.stenmans.org/theBeamBook/#_scheduling_non_preemptive_reduction_counting))。
|
||||
|
||||
在比较新的版本中,Erlang也加入了编译成本地代码的特性,那么在生成的本地代码中,也需要编译器加入对Reduction计数的代码,这就有点像Goroutine了。
|
||||
|
||||
这也是Erlang和Scala/Akka的区别。Akka没有得到编译器和JVM在底层的支持,也就没办法实现抢占式的调度。这有可能让某些特别耗时的Actor影响了其他Actor,使得系统的响应时间不稳定。
|
||||
|
||||
最后一个涉及调度的话题,是**I/O与调度的关系**。这个关系如果处理得不好,那么对系统整体的性能影响会很大。
|
||||
|
||||
通常我们编写I/O功能时,会采用同步编程模式来获取数据。这个时候,操作系统会阻塞当前的线程,直到成功获取了数据以后,才可以继续执行。
|
||||
|
||||
```
|
||||
getSomeData(); //操作系统会阻塞住线程,直到获得了数据。
|
||||
do something else //继续执行
|
||||
|
||||
```
|
||||
|
||||
采用这种模式开发一个服务端程序,会导致大量线程被阻塞住,等待I/O的结果。由于每个线程都需要不少的内存,并且线程切换的成本也比较高,因此就导致一台服务器能够服务的客户端数量大大降低。如果这时候,你在运行时查看服务程序的状态,就会发现大量线程在等待,CPU利用率也不高,而新的客户端又连接不上来,造成服务器资源的浪费。
|
||||
|
||||
并且,如果采用协程等应用级的并发机制,一个线程被阻塞以后,排在这个线程上的其他协程也只能等待,从而导致服务响应时间变得不可靠,有时快,有时慢。我们在前一讲了解过Goroutine的调度器。它在遇到这种情况的时候,就会把这条线程上的其他Goroutine挪到没被阻塞的线程上,从而尽快得到运行机会。
|
||||
|
||||
由于阻塞式I/O的缺点,现在很多语言也提供了非阻塞I/O的机制。在这种机制下,程序在做I/O请求的时候并不能马上获得数据。当操作系统准备好数据以后,应用程序可以通过轮询或被回调的方式获取数据。Node.js就是采用这种I/O模式的典型代表。
|
||||
|
||||
上一讲提到的C++协程库libco,也把非阻塞的网络通讯机制和协程机制做了一个很好的整合,大大增加了系统的整体性能。
|
||||
|
||||
而Erlang在很早以前就解决了这个问题。在Erlang的最底层,所有的I/O都是用事件驱动的方式来实现的。系统收到了一块数据,就调用应用来处理,整个过程都是非阻塞的。
|
||||
|
||||
说完了并发调度机制,我们再来看看运行时的另一个重要特征,内存管理机制。
|
||||
|
||||
### 内存管理机制
|
||||
|
||||
内存管理机制要考虑栈、堆都怎么设计,以及垃圾收集机制等内容。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/fe/a36035d18b24bcebb8a4d0a0b191a3fe.jpg" alt="">
|
||||
|
||||
首先说栈。每个Actor也需要有自己的栈空间,在执行Actor里面的逻辑的时候,用于保存本地变量。这跟上一节讲过的Stateful的协程很像。
|
||||
|
||||
再来看看堆。Erlang的堆与其他语言有很大的区别,它的每个Actor都有自己的堆空间,而不是像其他编程模型那样,不同的线程共享堆空间。这也很容易理解,因为Actor模型的特点,就是并发的程序之间没有共享的内存,所以当然也就不需要共享的堆了。
|
||||
|
||||
再进一步,由于每个Actor都有自己的堆,因此会给垃圾收集带来很大的便利:
|
||||
|
||||
- 因为整个程序划分成了很多个Actor,每个Actor都有自己的堆,所以每个Actor的垃圾都比较少,不用一次回收整个应用的垃圾,所以回收速度会很快。
|
||||
- 由于没有共享内存,所以垃圾收集器不需要停下整个应用,而只需要停下被收集的Actor。这就避免了“停下整个世界(STW)”问题,而这个问题是Java、Go等语言面临的重大技术挑战。
|
||||
- 如果一个Actor的生命周期结束,那么它占用的内存会被马上释放掉。这意味着,对于有些生命周期比较短的Actor来说,可能压根儿都不需要做垃圾收集。
|
||||
|
||||
好了,基于Erlang,我们学习了Actor的运行时机制的两个重要特征:一是并发调度机制,二是内存管理机制。那么,与此相配合,需要编译器做什么工作呢?
|
||||
|
||||
## 编译器的配合工作
|
||||
|
||||
我们说过,Erlang首先是解释执行的,是用一个寄存器机来运行字节码。那么,**编译器的任务,就是生成正确的字节码。**
|
||||
|
||||
之前我们已经分别研究过Graal、Python和V8 Ignition的字节码了。我们知道,字节码的设计很大程度上体现了语言的设计特点,体现了与运行时的交互过程。Erlang的字节码设计当然也是如此。
|
||||
|
||||
比如,针对消息的发送和接收,它专门提供了send指令和receive指令,这体现了Erlang的并发特征。再比如,Erlang还提供了与内存管理有关的指令,比如分配一个新的栈桢等,体现了Erlang在内存管理上的特点。
|
||||
|
||||
不过,我们知道,仅仅以字节码的方式解释执行,不能满足计算密集型的需求。所以,Erlang也正在努力提供编译成机器码运行的特性,这也需要编译器的支持。那你可以想象出,生成的机器码,一定也会跟运行时配合,来实现Erlang特有的并发机制和内存管理机制。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们介绍了另一种并发模型:Actor模型。Actor模型的特点,是避免在并发的程序之间共享任何信息,从而程序就不需要使用锁机制来保证数据的一致性。但是,采用Actor机制也会因为数据拷贝导致更大的开销,并且你需要习惯异步的编程风格。
|
||||
|
||||
Erlang是实现Actor机制的典型代表。它被称为面向并发的编程语言,并且能够提供很高的可靠性。这都源于它善用了Actor的特点:**由Actor构成的系统更像一个生命体一般的复杂系统**。
|
||||
|
||||
在实现Actor模型的时候,你要在运行时里实现独特的调度机制和内存管理机制,这些也需要编译器的支持。
|
||||
|
||||
本讲的思维导图我也放在了下面,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/5d/c04c32c93280afbea3fdc112285a085d.jpg" alt="">
|
||||
|
||||
好了,今天这一讲加上[第33](https://time.geekbang.org/column/article/279019)和[34讲](https://time.geekbang.org/column/article/280269),我们用了三讲,介绍了不同计算机语言是如何实现并发机制的。不难看出,并发机制确实是计算机语言设计中的一个重点。不同的并发机制,会非常深刻地影响计算机语言的运行时的实现,以及所采用的编译技术。
|
||||
|
||||
## 一课一思
|
||||
|
||||
你是否也曾经采用过消息传递的机制,来实现多个系统或者模块之间的调度?你从中获得了什么经验呢?欢迎你和我分享。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. Carl Hewitt关于Actor的[论文](https://arxiv.org/vc/arxiv/papers/1008/1008.1459v8.pdf)
|
||||
1. 微软[Orleans项目介绍](https://www.microsoft.com/en-us/research/wp-content/uploads/2010/11/pldi-11-submission-public.pdf)
|
||||
1. 介绍Erlang虚拟机原理的[在线电子书](https://blog.stenmans.org/theBeamBook)
|
||||
1. 介绍Erlang字节码的[文章](http://beam-wisdoms.clau.se/en/latest/indepth-beam-instructions.html)
|
||||
233
极客时间专栏/geek/编译原理实战课/现代语言设计篇/36 | 高级特性(一):揭秘元编程的实现机制.md
Normal file
233
极客时间专栏/geek/编译原理实战课/现代语言设计篇/36 | 高级特性(一):揭秘元编程的实现机制.md
Normal file
@@ -0,0 +1,233 @@
|
||||
<audio id="audio" title="36 | 高级特性(一):揭秘元编程的实现机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/05/89/0506b2cbd06d4987e6a3de8d3331dd89.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
作为一名技术人员,我想你肯定知道什么是编程,那你有没有听说过“**元编程(Meta-Programming)**”这个概念呢?
|
||||
|
||||
元编程是计算机语言提供的一项重要能力。这么说吧,如果你要编写一些比较厉害的程序,像是Java世界里的Spring、Hibernate这样的库,以及C++的STL库等这样级别的程序,也就是那些通用性很强、功能强大的库,元编程功能通常会给予你巨大的帮助。
|
||||
|
||||
我还可以从另一个角度来评价元编程功能。那就是善用计算机语言的元编程功能,某种意义上能让你修改这门语言,让它更满足你的个性化需求,为你量身打造!
|
||||
|
||||
是不是觉得元编程还挺有意思的?今天这一讲,我就带你来理解元编程的原理,并一起探讨如何用编译技术来支持元编程功能的实现。
|
||||
|
||||
首先,我们需要透彻地了解一下什么是元编程。
|
||||
|
||||
## 什么是元编程(Meta-Programming)?
|
||||
|
||||
元编程是一种把程序当做数据来处理的技术。因此,采用元编程技术,你可以把一个程序变换成另一个程序。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/80/b760b926a4f4b5ab81ed55d1b5039c80.jpg" alt="">
|
||||
|
||||
那你可能要问了,既然把程序作为处理对象的技术就是元编程技术,那么编译器不就是把程序作为处理对象的吗?经过处理,编译器会把源代码转换成目标代码。类似的还有对源代码的静态分析工具、代码生成工具等,都算是采用了元编程技术。
|
||||
|
||||
不过,我们在计算机语言里说的元编程技术,通常是指用这门语言本身提供的功能,就能处理它自己的程序。
|
||||
|
||||
比如说,在C语言中,你可以用**宏功能**。经过C语言的预处理器处理以后,那些宏就被转换成了另外的代码。下面的MUL宏,用起来像一个函数,但其实它只是做了一些字符串的替换工作。它可以说是最原始的元编程功能了。你在阅读像Python和Julia的编译器时,就会发现有不少地方采用了宏的功能,能让代码更简洁、可读性更好。
|
||||
|
||||
```
|
||||
#define MUL(a,b) (a*b)
|
||||
MUL(2,3) //预处理后变成(2*3)
|
||||
|
||||
```
|
||||
|
||||
再拿Java语言举个例子。Java语言对元编程提供了多重支持,其中之一是**注解功能**。我们在解析[Java编译器](https://time.geekbang.org/column/article/252828)的时候已经发现,Java编译器会把所编译的程序表示成一个对象模型。而注解程序可以通过这个对象模型访问被注解的程序,并进行一些处理,比如生成新的程序。所以,这也是把程序作为数据来处理。
|
||||
|
||||
除了注解以外,Java还提供了**反射机制**。通过反射机制,Java程序可以在运行时获取某个类有哪些方法、哪些属性等信息,并可以动态地运行该程序。你看,这同样是把程序作为数据来处理。
|
||||
|
||||
像Python和JavaScript这样的脚本语言,其元编程能力就更强了。比如说,你用程序可以很容易地查询出某个对象都有哪些属性和方法,甚至可以给它们添加新的属性和方法。换句话说,你可以很容易地把程序作为数据进行各种变换,从而轻松地实现一些灵活的功能。这种灵活性,是很多程序员特别喜欢Python和JavaScript这样的语言的原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/74/b3ce49cc8d2f641f4d71c139673f9474.jpg" alt="">
|
||||
|
||||
好了,到现在为止,你已经了解了**元编程的基本特征:把程序当做数据去处理**。接下来,我再带你更深入地了解一下元编程,并把不同的元编程技术做做分类。
|
||||
|
||||
### 理解Meta的含义、层次以及作用
|
||||
|
||||
**首先,我们来注意一下Meta这个词缀的意思。**[维基百科](https://en.wikipedia.org/wiki/Meta)中的解释是,Meta来自希腊文,意思是“在……之后(after)”和“超越……(beyond)”。加上这个词缀后,Meta-Somthing所形成的新概念就会比原来的Somthing的概念的抽象度上升一层。
|
||||
|
||||
举例来说,Physics是物理学的意思,表示看得见摸得着的物理现象。而Metaphysics就代表超越了物理现象的学问,也就是形而上学。Data是数据,而Metadata是元数据,是指对数据特性的描述,比如它是什么数据类型、取值范围是什么,等等。
|
||||
|
||||
还有,一门语言我们叫做Language,而语法规则(Grammar)是对一门语言的特点的描述,所以语法规则可以看做是Metalanguage。
|
||||
|
||||
**其次,在理解了Meta的概念以后,我再进一步告诉你,Meta是可以分层次的。**你可以对Meta再超越一层、抽象一层,就是Meta-Meta。理解Meta的层次,对于你深入理解元编程的概念非常重要。
|
||||
|
||||
拿你很熟悉的关系数据库来举个例子吧,看看不同的Meta层次都是什么意思。
|
||||
|
||||
首先是M0层,也就是关系数据库中的数据。比如一条人员数据,编号是“001”,姓名是“宫文学”等。一个数据库的使用者,从数据库中查出了这条数据,我们说这个人是工作在M0层的。
|
||||
|
||||
比M0抽象一层的是M1层,也就是Metadata,它描述了数据库中表的结构。比如,它定义了一张人员表,并且规定里面有编号、姓名等字段,以及每个字段的数据类型等信息。这样看来,元数据实际上是描述了一个数据模型,所以它也被叫做Model。一个工程师设计了这个数据库表的结构,我们说这个工程师是工作在M1层的。基于该工程师设计的数据库表,你可以保存很多M0层的人员数据:张三、李四、王五,等等。
|
||||
|
||||
比M1再抽象一层的是M2层。因为M1层可以叫做Model,所以M2层可以叫做Metamodel,也就是元模型。在这个例子中,Metamodel描述的是关系数据模型:它是由一张张的表(Table)构成的;而每张表,都是由字段构成的;每个字段,都可以有数据类型、是否可空等信息。发明关系数据模型,以及基于这个模型设计出关系数据库的大师,是工作在M2层的。基于关系模型,你可以设计出很多M1层的数据库表:人员表、订单表、商品表,等等。
|
||||
|
||||
那么有没有比Metamodel更抽象的层次呢?有的。这就是M3层,叫做Meta-Metamodel。这一层要解决的问题是,如何去描述关系数据模型和其他的元模型?在UML标准中,有一个MOF(Meta Object Facility)的规范,可以用来描述关系数据库、数据仓库等元模型。它用类、关联、数据类型和包这些基本要素来描述一个元模型。
|
||||
|
||||
好,通过关系数据库这个例子,现在你应该理解了不同的Meta层次是什么概念。那我们再**把这个概念应用到计算机语言领域,也是一样的**。
|
||||
|
||||
假设你使用一门面向对象的语言写了一个程序。这个程序运行时,在内存里创建了一个Person对象。那这个对象属于M0层。
|
||||
|
||||
而为了创建这个Person对象,你需要用程序设计一个Person类。从这个意义上来看,我们平常写的程序属于M1层,也就是相当于建立了一个模型来描述现实世界。你编写的订票程序就是对真实世界中的购票行为建立了一个模型,而你编写的游戏当然也是建立了一个逼真的游戏模型。
|
||||
|
||||
那么,你要如何才能设计一个Person类,以及一个完整的程序呢?这就需要用到计算机语言。计算机语言对应着M2层。它提供了类、成员变量、方法、数据类型、本地变量等元素,用于设计你的程序。我们对一门计算机语言的词法规则、语法规则和语义规则等方面的描述,就属于M2层,也就是一门计算机语言的元模型。而编译器就是工作在M2层的程序,它会根据元模型,也就是词法规则、语法规则等,来做程序的翻译工作。
|
||||
|
||||
我们在描述词法规则、语法规则的时候,曾经用到产生式、EBNF这些工具。这些工具是属于M3层的。你可以用我们前面说过的一个词,也就是Metalanguage来称呼这一层次。
|
||||
|
||||
这里我用了一个表格,来给你展示下关系数据模型与Java程序中不同的Meta层次。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/13/f516f4289cd426af42025668bb92f313.jpg" alt="">
|
||||
|
||||
### 元编程技术的分类
|
||||
|
||||
理解了Meta层次的概念以后,我们再来总结一下元编程技术都有哪些分类。
|
||||
|
||||
**第一,元编程可以通过生成语义层对象来生成程序。**
|
||||
|
||||
当我们操纵M1层的程序时,我们通常需要透过M2层的对象来完成,比如读取类和方法的定义信息。类和方法就是M2层的对象。Java的注解功能和反射机制,就是通过读取和操纵M2层的对象来完成的。
|
||||
|
||||
在学习编译原理的过程中,你知道了类、方法这些都是语义层次的概念,编译器保证了编译后的程序在语义上的正确性,所以你可以大胆地使用这些信息,不容易出错。如果你要在运行时动态地调用方法,运行时也会提供一定的检查机制,减少出错的可能性。
|
||||
|
||||
**第二,元编程可以通过生成AST来生成程序。**
|
||||
|
||||
你同样知道,一个程序也可以用AST来表达。所以,我们能不能让程序直接读取、修改和生成AST呢?这样对AST的操纵,就等价于对程序的操纵。
|
||||
|
||||
答案是可以的。所有Lisp家族的语言都采用了这种元数据技术,Julia就是其中之一。Lisp语言可以用S表达式来表示程序。S表达式是那种括号嵌套括号的数据结构,其实就是一棵AST。你可以用宏来生成S表达式,也就是生成AST。
|
||||
|
||||
不过,让程序直接操作比较底层的数据结构,其代价是可能生成的AST不符合语义规则。毕竟,AST只表达了语法规则。所以,用这种方式做元编程需要小心一些,不要生成错误的程序。同时,这种元编程技术对程序员来说,学习的成本也更高,因为他们要在比较低的概念层次上工作。
|
||||
|
||||
**第三,元编程可以通过文本字符串来生成程序。**
|
||||
|
||||
当然,你还可以把程序表达成更加低端的格式,就是一些文本字符串而已。我们前面说过,C语言的宏,其实就是做字符串的替换。而一些脚本语言,通常也能接受一个文本字符串作为程序来运行,比如JavaScript的eval()函数就可以接受一个字符串作为参数,然后把字符串作为程序来运行。所以,在JavaScript里的一项非常灵活的功能,就是用程序生成一些字符串,然后用eval()函数来运行。当然你也能预料到,用越原始的模型来表示程序,出错的可能性就越大。所以有经验的程序员,都会很谨慎地使用类似eval()这样的功能。但无论如何,这也确实是一种元编程技术。
|
||||
|
||||
**第四,元编程可以通过字节码操纵技术来生成字节码。**
|
||||
|
||||
那么,除了通过生成语义层对象、AST和文本来生成程序以外,对于Java这种能够运行字节码的语言来说,你还可以**通过字节码操纵技术来生成字节码**。这种技术一般不是由语言本身提供的能力,而是由第三方工具来实现的,典型的就是Spring。
|
||||
|
||||
好,到这里,我们就探讨完了通过元编程技术由程序生成程序的各种方式。下面我们再通过另一个维度来讨论一下元编程技术。这个维度是**元编程技术起作用的时机**,我们可以据此分为静态元编程和动态元编程。
|
||||
|
||||
**静态元编程技术只在编译期起作用。**比如C++的模板技术和把Java注解技术用在编译期的情况(在下面会具体介绍这两种技术)。一旦编译完毕以后,元程序跟普通程序一样,都会变成机器码。
|
||||
|
||||
**动态元编程技术会在运行期起作用。**这方面的例子是Java的反射机制。你可以在运行期加载一个类,来查看它的名称、都有哪些方法,然后打印出来。而为了实现这种功能,Java程序必须在class文件里保存这个类的Model,比如符号表,并通过M2层的接口,来查询类的信息。Java程序能在运行期进行类型判断,也是基于同样的原理。
|
||||
|
||||
好,通过上面的介绍,我想你对元编程的概念应该有比较清晰的理解了。那接下来,我们就来看看不同语言具体实现元编程的方式,并且一起探讨下在这个过程中应该如何运用编译技术。
|
||||
|
||||
## 不同语言的元编程技术
|
||||
|
||||
我们讨论的语言包括几大类,首先是Java,接着是Python和JavaScript这样的脚本语言,然后是Julia这样的Lisp语言,最后是C++的模板技术等一些很值得探讨的元编程技术。
|
||||
|
||||
### Java的元编程技术
|
||||
|
||||
在分析[Java的编译器](https://time.geekbang.org/column/article/252828)的时候,我们已经解析了它是如何处理注解的,注解就是一种元编程技术。在我们举的例子中,注解是在编译期就被处理掉了。
|
||||
|
||||
```
|
||||
@Retention(RetentionPolicy.SOURCE) //注解用于编译期处理
|
||||
@Target(ElementType.TYPE) //注解是针对类型的
|
||||
public @interface HelloWorld {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
当时我们写了一个简单的注解处理程序,这个程序,能够获取被注解的代码的元数据(M1层的信息),比如类名称、方法名称等。这些元数据是由编译器提供的。然后,注解处理程序会基于这些元数据生成一个新的Java源代码,紧接着该源代码就会被编译器发现并编译掉。
|
||||
|
||||
通过这个分析,你会发现注解处理过程自始至终都借助了编译器提供的能力:先是通过编译器查询被注解的程序的元数据,然后生成的新程序也会被编译器编译掉。所以你能得出一个结论:**所谓元编程,某种意义上就是由程序来调用编译器提供的能力。**
|
||||
|
||||
刚刚我们探究的是在编译期使用元编程技术。那么在运行期,Java提供了反射机制,来动态地获取程序的元数据,并操纵程序的执行。
|
||||
|
||||
举个例子。假设你写了一个简单的ORM(Object-Relational Mapping)程序,能够把Java对象自动保存到数据库中。那么你就可以通过反射机制,来获取这个对象都有哪些属性,然后读取这些属性的值,并生成一个正确的SQL语句来完成对象的保存动作。比如,对于一个Person对象,ORM程序通过反射机制会得知它有name和country两个字段,再从对象里读取name和字段的值,就会生成类似"Insert into Person (name, age), values(“Richard”, “China”)"这样的SQL语句。
|
||||
|
||||
从这个例子中,你能看出元编程的强大:只需要写一个通用的程序,就能用于各种不同的类。这些类在你写ORM程序的时候,根本不需要提前知道,因为ORM程序工作在M2层。给你任何一个类,你都能获得它的方法和属性信息。
|
||||
|
||||
不过这种反射机制也是有短板的,就是性能比较低。基于反射机制编写的程序的效率,比同样功能的静态编译的程序要低好几倍。所以,如何提升运行期元编程功能的性能,是编译技术研究的一个重点。
|
||||
|
||||
OK,接下来我们看看Python、JavaScript等脚本语言的元编程技术。
|
||||
|
||||
### Python、JavaScript等脚本语言的元编程技术
|
||||
|
||||
对于像Python、JavaScript和Ruby这样的脚本语言,它们实现起元编程技术来就更加简单。
|
||||
|
||||
最简单的元编程方式,我们前面也提到过,就是动态生成程序的文本字符串,然后动态编译并执行。这种方式虽然简单粗暴,容易出错,有安全隐患,但在某些特殊场景下还确实很有用。
|
||||
|
||||
不过如有可能,我们当然愿意使用更优雅的元编程方式。这几种脚本语言都有几个特点,使得操纵和修改已有程序的步骤会变得特别简单:
|
||||
|
||||
- 第一个特点,就是用程序可以很方便地获取对象的元数据,比如某个对象有什么属性、什么方法,等等。
|
||||
- 第二个特点,就是可以很容易地为对象添加属性和方法,从而修改对象。
|
||||
|
||||
这些脚本语言做元编程究竟有多么容易呢?我给你举个Python语言的例子。
|
||||
|
||||
我们在[解析Python编译器](https://time.geekbang.org/column/article/261063)的时候,曾提到过metaclass(元类)。metaclass能够替代缺省的Type对象,控制一个类创建对象的过程。通过你自己的metaclass,你可以很容易地为所创建的对象的方法添加修饰,比如输出调试信息这样的AOP功能。
|
||||
|
||||
所以,很多喜欢Python、JavaScript和Ruby语言的工程师,很大一部分原因,都是因为这些语言非常容易实现元编程,因此能够实现出很多强大的库。
|
||||
|
||||
不过,在灵活的背后,脚本语言的元编程技术通常要付出性能的代价。比如,采用元编程技术,程序经常会用Decorator模式对原有的函数或方法做修饰,这样会增加函数调用的层次,以及其他一些额外的开销,从而降低程序的性能。
|
||||
|
||||
好,接下来,我们说说Julia等类Lisp语言的元编程技术。
|
||||
|
||||
### Julia等类Lisp语言的元编程技术
|
||||
|
||||
前面我们已经说过,像Julia等类似Lisp的语言,它本来就是把程序看做数据的。它的程序结构,本来就是一个嵌套的树状结构,其实跟AST没啥区别。因此,只要在语言里提供一种方式,能够生成这些树状结构的数据,就可以很好地实现元编程功能了。
|
||||
|
||||
比如,下面的一段示例程序是用Common Lisp编写的。你能看出,程序的结构完全是一层层的括号嵌套的结构,每个括号中的第一个单词,都是一个函数名称,后面跟着的是函数参数。这个例子采用了Lisp的宏功能,把pred替换成合适的函数名称。当替换成>时,实现的是求最大值功能;而替换成<时,实现的是求最小值功能。
|
||||
|
||||
```
|
||||
(defmacro maxmin(list pred) ;定义一个宏
|
||||
`(let ((rtn (first ,list))) ;`后面是作为数据的程序
|
||||
(do ((i 1 (1+ i)))
|
||||
((>= i (length ,list)) rtn)
|
||||
(when (,pred (nth i ,list) rtn);pred可以被替换成一个具体的函数名
|
||||
(setf rtn (nth i ,list))))))
|
||||
|
||||
(defun mymax2 (list) ;定义一个函数,取一个列表的最大值
|
||||
(maxmin list >))
|
||||
|
||||
(defun mymin2 (list) ;定义一个函数,取一个列表的最小值。
|
||||
(maxmin list <)
|
||||
|
||||
```
|
||||
|
||||
这种能够直接操纵AST的能力让Lisp特别灵活。比如,在Lisp语言里,根本没有原生的面向对象编程模型,但你完全可以用它的元编程功能,自己构造一套带有类、属性、方法、继承、多态的编程模型,这就相当于构建了一个新的M2层的元模型。通常一个语言的元模型,也就是编程时所能使用的结构,比如是否支持类呀什么的,在设计语言的时候就已经固定了。但Lisp的元编程功能竟然能让你自己去定义这些语言特性,这就是一些小众的程序员特别热爱Lisp的原因。
|
||||
|
||||
### C++的元编程技术
|
||||
|
||||
提到元编程,就不能不提一下C++的**模板元编程**(Template Metaprogramming)技术,它大大增强了C++的功能。
|
||||
|
||||
模板元编程技术属于静态元编程技术,也就是让编译器尽量在编译期做一些计算。这在很多场景中都有用。一个场景,就是提供泛型的支持。比如,List<int>是整型这样的值类型的列表,而List<student>是Student这种自定义类型的列表,你不需要为不同的类型分别开发List这样的容器类(在下一讲,我还会对泛型做更多的讲解)。</student></int>
|
||||
|
||||
但模板元编程技术不仅仅可以支持泛型,也就是模板的参数可以不仅仅是类型,还可以是普通的参数。模板引擎可以在编译期利用这些参数做一些计算工作。我们来看看下面这个例子。这个例子定义了一个数据结构,它可以根据你传入的模板参数获得阶乘值。
|
||||
|
||||
如果这个参数是一个编译期的常数,那么模板引擎会直接把这个阶乘值计算出来,而不是等到运行期才做这个计算。这样能降低程序在运行时的计算量,同时又保持编程的灵活性。
|
||||
|
||||
```
|
||||
template<int n>
|
||||
struct Fact {
|
||||
enum { RET = n * Fact<n-1>::RET }; //用一个枚举值代表阶乘的计算结果
|
||||
};
|
||||
|
||||
template<> //当参数为1时,阶乘值是1
|
||||
struct Fact<1> {
|
||||
enum { RET = 1 };
|
||||
};
|
||||
|
||||
int b = Fact<5>::RET; //在编译期就计算出阶乘值,为120
|
||||
|
||||
```
|
||||
|
||||
看到这里,利用你学过的编译原理,你能不能猜测出C++模板的实现机制呢?
|
||||
|
||||
我们也看到过在编译器里做计算的情况,比如说常数折叠,会在编译期计算出表达式的常数值,不用在运行期再去计算了。而在C++的模板引擎里,把这种编译器的计算能力大大地丰富了。不过,你仍然可以猜测出它的实现机制,它仍然是基于AST来做计算,生成新的AST。在这个过程中,像Fact<5>这种情况甚至会被计算出最终的值。C++模板引擎支持的计算如此复杂,以至于可以执行递归运算。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们围绕元编程这个话题做了比较深入的剖析。
|
||||
|
||||
元编程,对于我们大多数程序员来说,是一个听上去比较高深的概念。但是,在学过编译原理以后,你会更容易理解元编程技术,因为编译器就是做元编程的软件。而各门语言中的元编程特性,本质上就是对编译器的能力的释放和增强。编译器要获得程序的结构信息,并对它们进行修改、转换,元编程做的是同样的事情。
|
||||
|
||||
我们学好编译原理以后,在元编程方面其实拥有巨大的优势。一方面,我们可以更加了解某门语言的元编程机制是如何工作的;另一方面,即使某些语言没有提供原生的元编程功能,或者是元编程功能不够强大,我们也仍然可以自己做一些工具,来实现元编程功能,这就是类似Spring这样的工具所做的事情。
|
||||
|
||||
本讲中关于Meta的层次的概念,是我特别向你推荐的一个思维模型。采用这个模型,你就知道不同的工作,是发生在哪一个抽象层级上。因而你也就能明白,为什么学习编译原理中用到的那些形式语言会觉得更加抽象。因为计算机语言的抽象层级就挺高的了,而用于描述计算机语言的词法和语法规则的语言,当然抽象层级更高。
|
||||
|
||||
我把这讲的思维导图也放在了这里,供你复习和参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/87/f7fcc8bfee28014bbf173f0160003287.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我在本讲举了ORM的例子。如果用你熟悉的语言来实现ORM功能,也就是自动根据对象的类型信息来生成合适的SQL语句,你会怎么做?
|
||||
|
||||
欢迎分享你的观点,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。
|
||||
360
极客时间专栏/geek/编译原理实战课/现代语言设计篇/37 | 高级特性(二):揭秘泛型编程的实现机制.md
Normal file
360
极客时间专栏/geek/编译原理实战课/现代语言设计篇/37 | 高级特性(二):揭秘泛型编程的实现机制.md
Normal file
@@ -0,0 +1,360 @@
|
||||
<audio id="audio" title="37 | 高级特性(二):揭秘泛型编程的实现机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5c/ff/5c5b97a296bb9f8edd004d2b4b2dd1ff.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
对泛型的支持,是现代语言中的一个重要特性。它能有效地降低程序员编程的工作量,避免重复造轮子,写很多雷同的代码。像C++、Java、Scala、Kotlin、Swift和Julia这些语言都支持泛型。至于Go语言,它的开发团队也对泛型技术方案讨论了很久,并可能会在2021年的版本中正式支持泛型。可见,泛型真的是成为各种强类型语言的必备特性了。
|
||||
|
||||
那么,**泛型有哪些特点?在设计和实现上有哪些不同的方案?编译器应该进行什么样的配合呢?**今天这一讲,我就带你一起探讨泛型的实现原理,借此加深你对编译原理相关知识点的认知,让你能够在自己的编程中更好地使用泛型技术。
|
||||
|
||||
首先,我们来了解一下什么是泛型。
|
||||
|
||||
## 什么是泛型?
|
||||
|
||||
在日常编程中,我们经常会遇到一些代码逻辑,它们除了类型不同,其他逻辑是完全一样的。你可以看一下这段示例代码,里面有两个类,其中一个类是保存Integer的列表,另一个类是保存Student对象的列表。
|
||||
|
||||
```
|
||||
public class IntegerList{
|
||||
List data = new ArrayList();
|
||||
public void add(Integer elem){
|
||||
data.add(elem);
|
||||
}
|
||||
public Integer get(int index){
|
||||
return (Integer) data.get(index);
|
||||
}
|
||||
}
|
||||
|
||||
public class StudentList{
|
||||
List data = new ArrayList();
|
||||
public void add(Student elem){
|
||||
data.add(elem);
|
||||
}
|
||||
public Student get(int index){
|
||||
return (Student) data.get(index);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们都知道,程序员是很不喜欢重复的代码的。像上面这样的代码,如果要为每种类型都重新写一遍,简直会把人逼疯!
|
||||
|
||||
泛型的典型用途是针对集合类型,能够更简单地保存各种类型的数据,比如List、Map这些。在Java语言里,如果用通用的集合类来保存特定类型的对象,就要做很多强制转换工作。而且,我们还要小心地做类型检查。比如:
|
||||
|
||||
```
|
||||
List strList = new ArrayList(); //字符串列表
|
||||
strList.add("Richard");
|
||||
String name = (String)strList.get(i); //类型转换
|
||||
for (Object obj in strList){
|
||||
String str = (String)obj; //类型转换
|
||||
...
|
||||
}
|
||||
|
||||
strList.add(Integer.valueOf(1)); //类型错误
|
||||
|
||||
```
|
||||
|
||||
而Java里的泛型功能,就能完全消除这些麻烦工作,让程序更整洁,并且也可以减少出错机会。
|
||||
|
||||
```
|
||||
List<String> strList = new ArrayList<String>(); //字符串列表
|
||||
strList.add("Richard");
|
||||
String name = strList.get(i); //类型转换
|
||||
for (String str in strList){ //无需类型转换
|
||||
...
|
||||
}
|
||||
|
||||
strList.add(Integer.valueOf(1)); //编译器报错
|
||||
|
||||
```
|
||||
|
||||
像示例程序里用到的`List<String>`,是在常规的类型后面加了一个参数,使得这个列表变成了专门存储字符串的列表。如果你再查看一下List和ArrayList的源代码,会发现它们比普通的接口和类的声明多了一个类型参数`<E>`,而这个参数可以用在接口和方法的内部所有需要类型的地方:变量的声明、方法的参数和返回值、类所实现的接口,等等。
|
||||
|
||||
```
|
||||
public interface List<E> extends Collection<E>{
|
||||
E get(int index);
|
||||
boolean add(E e);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
所以说,泛型就是把类型作为参数,出现在类/接口/结构体、方法/函数和变量的声明中。**由于类型是作为参数出现的,因此泛型也被称作参数化类型。**
|
||||
|
||||
参数化类型还可以用于更复杂的情况。比如,你可以使用1个以上的类型参数,像Map就可以使用两个类型参数,一个是key的类型(K),一个是value的类型(V)。
|
||||
|
||||
```
|
||||
public interface Map<K,V> {
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外,你还可以对类型参数添加约束条件。比如,你可以要求类型参数必须是某个类型的子类,这是指定了上界(Upper Bound);你还可以要求类型参数必须是某个类型的一个父类,这是指定了下界(Lower Bound)。实际上,从语言设计的角度来看,你可以对参数施加很多可能的约束条件,比如必须是几个类型之一,等等。
|
||||
|
||||
**基于泛型的程序,由于传入的参数不同,程序会实现不同的功能。这也被叫做一种多态现象,叫做参数化多态(Parametric Polymorphism)。**它跟面向对象中的多态一样,都能让我们编写更加通用的程序。
|
||||
|
||||
好了,现在我们已经了解了泛型的含义了。那么,它们是如何在语言中实现的呢?需要用到什么编译技术?
|
||||
|
||||
## 泛型的实现
|
||||
|
||||
接下来,我们一起来看一看几种有代表性的语言实现泛型的技术,包括Java、C#、C++等。
|
||||
|
||||
### 类型擦除技术
|
||||
|
||||
**在Java里,泛型是通过类型擦除(Type Erasure)技术来实现的。**前面在分析[Java编译器](https://time.geekbang.org/column/article/255034)时,你就能发现,其实类型参数只存在于编译过程中,用于做类型检查和类型推断。在此之后,这些类型信息就可以被擦除。ArrayList和`ArrayList<String>`对应的字节码是一样的,在运行时没有任何区别。
|
||||
|
||||
所以,我们可以说,在Java语言里,泛型其实是一种语法糖,有助于减少程序员的编程负担,并能提供额外的类型检查功能。
|
||||
|
||||
除了Java以外,其他基于JVM的语言,比如Scala和Kotlin,其泛型机制基本上都是类型擦除技术。
|
||||
|
||||
**类型擦除技术的优点是实现起来特别简单。**运用我们学过的属性计算、类型检查和推断等相关技术基本就够用了。
|
||||
|
||||
不过类型擦除技术也有一定的**局限性**。
|
||||
|
||||
问题之一,是**它只能适用于引用类型**,也就是对象,而不适用于值类型,也就是Java中的基础数据类型(Primitive Type)。比如,你不能声明一个`List<int>`,来保存单纯的整型数据,你在列表里只能保存对象化的Integer。而我们学习过Java对象的内存模型,知道一个Integer对象所占的内存,是一个int型基础数据的好几倍,因为对象头要有十几个字节的固定开销。再加上由此引起的对象创建和垃圾收集的性能开销,导致用Java的集合对象来保存大量的整型、浮点型等基础数据是非常不划算的。我们在这种情况下,还是要退回到使用数组才行。
|
||||
|
||||
问题之二,就是因为类型信息在编译期被擦除了,所以**程序无法在运行时使用这些类型信息**。比如,在下面的示例代码中,如果你想要根据传入的类型T创建一个新实例,就会导致编译错误。
|
||||
|
||||
```
|
||||
public static <T> void append(ArrayList<T> a) {
|
||||
T b= new T(); // 编译错误
|
||||
a.add(b);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
同样,由于在运行期没有类型信息,所以如果要用反射机制来调用程序的时候,我们也没有办法像在编译期那样进行类型检查。所以,你完全可以往一个旨在保存String的列表里添加一个Interger对象。而缺少类型检查,可能会导致程序在执行过程中出错。
|
||||
|
||||
另外,还有一些由于类型擦除而引起的问题。比如,在使用参数化类型的情况下,方法的重载(Overload)会失败。再比如,下面的示例代码中,两个foo方法看似参数不同。但如果进行了类型擦除以后,它们就没什么区别,所以是不能共存的。
|
||||
|
||||
```
|
||||
public void foo(List<Integer> p) { ... }
|
||||
public void foo(List<Double> p) { ... }
|
||||
|
||||
```
|
||||
|
||||
你要注意,不仅仅是Java语言的泛型技术有这样的缺点,其他基于JVM实现的语言也有类似的缺点(比如没有办法在运行时使用参数化类型的信息)。这其实是由于JVM的限制导致的。为了理解这个问题,我们可以看一下基于.NET平台的语言 ,比如C#所采用的泛型技术。C#使用的不是类型擦除技术,而是一种叫做**具体化(reification)**的技术。
|
||||
|
||||
### 具体化技术(Reification)
|
||||
|
||||
说起来,C#语言的设计者,安德斯 · 海尔斯伯格(Anders Hejlsberg),是一位令人尊敬的传奇人物。像我这一代的程序员,差不多都使用过他在DOS操作系统上设计的Pascal编译器。后来他在此基础上,设计出了Delphi,也是深受很多人喜爱的一款开发工具。
|
||||
|
||||
出于对语言设计者的敬佩,虽然我自己从没用C#写过程序,但我从来没有低估过C#的技术。在泛型方面,C#的技术方案成功地避免了Java泛型的那些缺点。
|
||||
|
||||
C#语言编译也会形成IR,然后在.NET平台上运行。在C#语言中,对应于Java字节码的IR被叫做**IL**,是中间语言(Intermediate Language)的缩写。
|
||||
|
||||
我们知道了,在Java的泛型实现中,编译完毕以后类型信息就会被擦除。**而在C#生成的IL中,则保留了类型参数的类型信息**。所以,`List<Student>`和`List<Teacher>`是两个完全不同的类型。也因为IL保存了类型信息,因此我们可以在运行时使用这些类型信息,比如根据类型参数创建对象;而且如果通过反射机制来运行C#程序的话,也会进行类型检查。
|
||||
|
||||
还有很重要的一点,就是**C#的泛型能够支持值类型**,比如基础的整型、浮点型数据;再比如,针对`List<int>`和`List<long>`,C#的泛型能够真的生成一份完全不同的可运行的代码。它也不需要把值类型转换成对象,从而导致额外的内存开销和性能开销。
|
||||
|
||||
**把参数化类型变成实际的类型的过程,是在运行时通过JIT技术实现的。这就是具体化(Reification)的含义。**把一个参数化的类型,变成一个运行时真实存在的类型,它可以跟非参数化的类型起到完全相同的作用。
|
||||
|
||||
不过,为了支持泛型,其实.NET扩展了C#生成的IL,以便在IL里能够记录参数化类型信息。而JVM则没有改变它的字节码,从而完全是靠编译器来处理泛型。
|
||||
|
||||
好了,现在我们已经见识到了两种不同的泛型实现机制。还有一种泛型实现机制,也是经常被拿来比较的,这就是C++的泛型机制,它的泛型机制依托的是**模板元编程技术**。
|
||||
|
||||
### 基于元编程技术来支持泛型
|
||||
|
||||
在上一讲,我们介绍过C++的模板元编程技术。模板元编程很强大,程序里的很多要素都可以模板化,那么类型其实也可以被模板化。
|
||||
|
||||
你已经知道,元编程技术是把程序本身作为处理对象的。采用C++的模板元编程技术,我们实际上是为每一种类型参数都生成了新的程序,编译后生成的目标代码也是不同的。
|
||||
|
||||
所以,**C++的模板技术也能做到Java的类型擦除技术所做不到的事情**,比如提供对基础数据类型的支持。在C++的标准模板库(STL)中,提供了很多容器类型。它们能像保存对象一样保存像整型、浮点型这样的基础数据类型。
|
||||
|
||||
不过使用模板技术来实现泛型也有一些**缺点**。因为本质上,模板技术有点像宏,它是把程序中某些部分进行替换,来生成新的程序。在这个过程中,**它并不会检查针对参数类型执行的某些操作是否是合法的**。编译器只会针对生成后的程序做检查,并报错。这个时候,错误信息往往是比较模糊的,不太容易定位。这也是模板元编程技术固有的短板。
|
||||
|
||||
究其原因,是模板技术不是单单为了泛型的目的而实现的。不过,如果了解了泛型机制的原理,你会发现,其实可以通过增强C++编译器,来提升它的类型检查能力。甚至,对类型参数指定上界和下界等约束条件,也是可以的。不过这要看C++标准委员会的决定了。
|
||||
|
||||
总的来说,**C++的泛型技术像Java的一样,都是在运行期之前就完成了所有的工作,而不像.NET那样,在运行期针对某个参数化的类型产生具体的本地代码。**
|
||||
|
||||
好了,了解了泛型的几种实现策略以后,接下来,我们接着讨论一个更深入的话题:**把类型参数化以后,对于计算机语言的类型系统有什么挑战?**这个问题很重要,因为在语义分析阶段,我们已经知道如何做普通类型的分析和处理。而要处理参数化的类型,我们还必须更加清楚支持参数化以后,类型体系会有什么变化。
|
||||
|
||||
## 泛型对类型系统的增强
|
||||
|
||||
在现代语言中,通常会建立一个层次化的类型系统,其中一些类型是另一些类型的子类型。什么是子类型呢?就是在任何一个用到父类型的地方,都可以用其子类型进行替换。比如,Cat是Animal的子类型,在任何用到Animal的地方,都可以用Cat来代替。
|
||||
|
||||
不过,当类型可以带有参数之后,类型之间的关系就变得复杂了。比如说:
|
||||
|
||||
- `Collection<Cat>`和`List<Cat>`是什么关系呢?
|
||||
- `List<Animal>`和`List<Cat>`之间又是什么关系呢?
|
||||
|
||||
对于第一种情况,其实它们的类型参数是一样的,都是Cat。而List本来是Collection的子类型,那么`List<Cat>`也是`Collection<Cat>`的子类型,我们永远可以用`List<Cat>`来替换`Collection<Cat>`。这种情况比较简单。
|
||||
|
||||
但是对于第二种情况,`List<Cat>`是否是`List<Animal>`的子类型呢?这个问题就比较难了。不同语言的实现是不一样的。在Java、Julia等语言中,`List<Cat>`和`List<Animal>`之间没有任何的关系。
|
||||
|
||||
在由多个类型复合而形成的类型中(比如泛型),复合类型之间的关系随其中的成员类型的关系而变化的方式,分为**不变(Invariance)、协变(Covariance)和逆变(Contravariance)**三种情况。理解清楚这三种变化,对于我们理解引入泛型后的类型体系非常重要,这也是编译器进行正确的类型计算的基础。
|
||||
|
||||
**首先说说不变。**在Java语言中,`List<Animal>`和`List<Cat>`之间并没有什么关系,在下面的示例代码中,如果我们把`List<Cat>`赋值给`List<Animal>`,编译器会报错。因此,我们说`List<T>`基于T是不变的。
|
||||
|
||||
```
|
||||
List<Cat> catList = new ArrayList<>();
|
||||
List<Animal> animalList = catList; //报错,不是子类型
|
||||
|
||||
```
|
||||
|
||||
**那么协变是什么呢?**就是复合类型的变化方向,跟成员类型是相同的。我给你举两个在Java语言中关于协变的例子。
|
||||
|
||||
第一个例子。假设Animal有个reproduce()方法,也就是繁殖。而Cat覆盖(Override)了这个方法,但这个方法的返回值是Cat而不是Animal。因为猫肯定繁殖出的是小猫,而不是其他动物。这样,当我们调用Cat.reproduce()方法的时候,就不用对其返回值做强制转换。这个时候,我们说reproduce()方法的返回值与它所在类的类型,是协变的,也就是**一起变化**。
|
||||
|
||||
```
|
||||
class Animal{
|
||||
public abstract Animal reproduce();
|
||||
}
|
||||
|
||||
class Cat extends Animal{
|
||||
@Override
|
||||
public Cat reproduce() { //方法的返回值可以是Animal的子类型
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
第二个例子。在Java语言中,数组是协变的。也就是`Cat[]`其实是`Animal[]`的子类型,在下面的示例代码中,一个猫的数组可以赋值给一个动物数组。
|
||||
|
||||
```
|
||||
Cat[] cats = {new Cat(), new Cat()}; //创建Cat数组
|
||||
Animal[] animals = cats; //赋值给Animal数组
|
||||
animals[0] = new Dog(); //修改第一个元素的值
|
||||
Cat aCat = cats[0]; //运行时错误
|
||||
|
||||
```
|
||||
|
||||
但你接下来会看到,Animal数组中的值可以被修改为Dog,这会导致Cat数组中的元素类型错误。至于为什么Java语言要把数组设计为协变的,以及由此导致的一些问题,我们暂且不管它。我们要问的是,**`List<T>`这样的泛型可以变成协变关系吗?**
|
||||
|
||||
答案是可以的。我前面也提过,我们可以在类型参数中指定上界。`List<Cat>`是`List<? Extends Animal>`的子类型,`List<? Extends Animal>`的意思,是任何以Animal为祖先的子类。我们可以把一个`List<Cat>`赋值给`List<? Extends Animal>`。你可以看一下示例代码:
|
||||
|
||||
```
|
||||
List<Cat> catList = new ArrayList<>();
|
||||
List<? extends Animal> animalList = catList; //子类型
|
||||
catList.add(new Cat());
|
||||
Animal animal = animalList.get(0);
|
||||
|
||||
```
|
||||
|
||||
实际上,不仅仅`List<Cat>`是`List<? extends Animal>`的子类型,连`List<Animal>`也是`List<? extends Animal>`的子类型。你可以自己测试一下。
|
||||
|
||||
**我们再来说说逆变。**逆变的意思是:虽然Cat是Animal的子类型,但包含了Cat的复合类型,竟然是包含了Animal的复合类型的父类型!它们颠倒过来了?
|
||||
|
||||
这有点违反直觉。在真实世界里有这样的例子吗?当然有。
|
||||
|
||||
比如,假设有两个函数,`getWeight<Cat>()`函数是返回Cat的重量,`getWeight<Animal>()`函数是返回Animal的重量。你知道,从函数式编程的观点,每个函数也都是有类型的。那么这两个函数,谁是谁的子类型呢?
|
||||
|
||||
实际上,求Animal重量的函数,其实是求Cat重量的函数的子类型。怎么说呢?
|
||||
|
||||
来假设一下。如果你想用一个getTotalWeight()函数,求一群Cat的总重量,你会怎么办呢?你可以把求Cat重量的函数作为参数传进去,这肯定没问题。但是,你也可以把求Animal重量的函数传进去。因为既然它能返回普通动物的重量,那么也一定能返回猫的重量。
|
||||
|
||||
```
|
||||
//伪代码,求Cat的总重量
|
||||
getTotalWeight(List<Cat> cats, function fun)
|
||||
|
||||
```
|
||||
|
||||
而根据类型理论,**如果类型B能顶替类型A的位置,那么B就是A的子类型**。
|
||||
|
||||
所以,`getWeigh<Animal>()`反倒是`getWeight<Cat>()`的子类型,这种情况就叫做逆变。
|
||||
|
||||
总的来说,加入泛型以后,计算机语言的类型体系变得更加复杂了。我们在编写编译器的时候,一定要弄清楚这些变化关系,这样才能执行正确的类型计算。
|
||||
|
||||
那么,在了解了加入泛型以后对类型体系的影响后,我们接着借助Julia语言,来进一步验证一下如何进行正确的类型计算。
|
||||
|
||||
## Julia中的泛型和类型计算
|
||||
|
||||
Julia设计了一个精巧的类型体系。这个类型体系有着共同的根,也就是Any。在这个类型层次中,橙色的类型是叶子节点,它们是具体的类型,也就是可以创建具体的实例。而中间层次的节点(蓝色),都是抽象的,主要是用于类型的计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4b/38/4b88f681a305ecf5010606e3b3a68c38.jpg" alt="" title="Julia的类型体系">
|
||||
|
||||
你在[第22讲](https://time.geekbang.org/column/article/264333)中,已经了解到了Julia做函数编译的特点。在编写函数的时候,你可以根本不用指定参数的类型,编译器会根据传入的参数的实际类型,来编译成相应版本的机器码。另外,你也可以为函数编写多个版本的方法,每个版本的参数采用不同的类型。编译器会根据实际参数的类型,动态分派到不同的版本。而**这个动态分派机制,就需要用到类型的计算**。
|
||||
|
||||
比如说,有一个函数foo(),定义了三个版本的方法,其参数分别是没有指定类型(也就是Any)、Real类型和Float64类型。如果参数是Float64类型,那它当然会被分派到第三个方法。如果是Float32类型,那么就会被分派到第二个方法。如果是一个字符串类型呢,则会被分派到第一个方法。
|
||||
|
||||
```
|
||||
julia> function foo(x) #方法1
|
||||
...
|
||||
end
|
||||
|
||||
julia> function foo(x::Real) #方法2
|
||||
...
|
||||
end
|
||||
|
||||
julia> function foo(x::Float64) #方法3
|
||||
...
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
再进一步,**Julia还支持在定义结构体和函数的时候使用泛型。**比如,下面的一个Point结构中,坐标x和y的类型是参数化的。
|
||||
|
||||
```
|
||||
julia> struct Point{T}
|
||||
x::T
|
||||
y::T
|
||||
end
|
||||
|
||||
|
||||
julia> Point{Float64}
|
||||
Point{Float64}
|
||||
|
||||
julia> Point{Float64} <: Point #在Julia里,如果一个类型更具体,则<:为真
|
||||
true
|
||||
|
||||
julia> Point{Float64} <: Point{Real} #Invariant
|
||||
false
|
||||
|
||||
julia> p1 = Point(1.0,2.3) #创建一个Point实例
|
||||
Point{Float64}(1.0, 2.3) #自动推断出类型
|
||||
|
||||
```
|
||||
|
||||
如果我们再为foo()函数添加几个方法,其参数类型分别是Point类型、Point{Real}类型和Point{Float64}类型,那动态分派的算法也必须能够做正确的分派。所以,在这里,我们就必须能够识别出带有参数的类型之间的关系。
|
||||
|
||||
```
|
||||
julia> function foo(x::Point) #方法4
|
||||
...
|
||||
end
|
||||
|
||||
julia> function foo(x::Point{Real}) #方法5
|
||||
...
|
||||
end
|
||||
|
||||
julia> function foo(x::Point{Float64}) #方法6
|
||||
...
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
通过以上的示例代码你可以看到,Point{Float64} <: Point,也就是Point{Float64}是Point的子类型。这个关系是有意义的。
|
||||
|
||||
**Julia的逻辑是**,Point{Float64} 比Point更具体,能够在程序里替代Point。而Point{Float64} 和Point{Real}之间是没有什么关系的,虽然Float64是Real的子类型。这说明,Point{T}基于T是不变的(Invariant),这跟Java语言的泛型处理是一样的。
|
||||
|
||||
所以,在Julia编译的时候,如果我们给foo()传递一个Point{Float64}参数,那么应该被分派到方法6。而如果传递一个Point{Float32}参数呢?分派算法不会选择方法5,因为Point{Float32}不是Point{Real}的子类型。因此,分配算法会选择方法4,因为Point{Float32}是Point的子类型。
|
||||
|
||||
那么,**如何让Point{T}基于T协变呢**?这样我们就可以针对Real类型写一些通用的算法,让采用Float32、Float16等类型的Point,都按照这个算法去编译了。
|
||||
|
||||
**答案就是需要指定上界。**我们可以把Point{Real}改为Point{<:Real},它是Point{Float32}、Point{Float16}等的父类型。
|
||||
|
||||
好,总结起来,Julia的泛型和类型计算是很有特点的。泛型提供的参数化多态(Parametric Polymorphism)跟Julia原来的方法多态(Method Polymorphism)很好地融合在了一起,让我们能够最大程度地去编写通用的程序。而被泛型增强后的类型体系,也对动态分派算法提出了更高的要求。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我们学习了泛型这个现代语言中非常重要的特性的实现机制。在实现泛型机制的时候,我们首先必须弄清楚引入泛型以后,对类型体系的影响。你要掌握**不变、协变和逆变**这三个基本概念和它们的应用场景,从而能够正确地用于类型计算的过程中。
|
||||
|
||||
在泛型的具体实现机制上,有**类型擦除、具体化和模板元编程**等不同的方法。好的实现机制应该有能力同时兼顾值类型和复合类型,同时又便于调试。
|
||||
|
||||
按照惯例,我也把本讲的内容总结成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/d7/c7bf4642ebd4a0253b9ec3b174ef71d7.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
今天,我想给你留两道思考题,你可以根据你熟悉的语言,选择其一。
|
||||
|
||||
- 如果你对Java语言比较熟悉,那么针对Java的泛型不支持基础数据类型的问题,你能否想出一种技术方案,来弥补这个短板呢?你思考一下。我在下一讲会借助面向对象的话题,给出一个技术方案。
|
||||
- 而如果你对Go语言有所了解,那么你对Go语言的泛型技术方案会提出什么建议?能否避免已有语言在实现泛型上的短板呢?你也可以参考我在文末给出的Go语言泛型方案的草案,来印证你的想法。
|
||||
|
||||
欢迎在留言区分享你的观点,也非常欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. Go语言[泛型方案的草案](https://go.googlesource.com/proposal/+/refs/heads/master/design/go2draft-type-parameters.md)。
|
||||
1. [Julia的泛型](https://docs.julialang.org/en/v1/manual/types/#Parametric-Types)。
|
||||
1. [C#泛型的文档](https://docs.microsoft.com/en-us/dotnet/csharp/programming-guide/generics/),你可以看看它在运行期是如何支持泛型的,这跟Java有很大的不同。
|
||||
248
极客时间专栏/geek/编译原理实战课/现代语言设计篇/38 | 综合实现(一):如何实现面向对象编程?.md
Normal file
248
极客时间专栏/geek/编译原理实战课/现代语言设计篇/38 | 综合实现(一):如何实现面向对象编程?.md
Normal file
@@ -0,0 +1,248 @@
|
||||
<audio id="audio" title="38 | 综合实现(一):如何实现面向对象编程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/69/77/693b500c38cda61c61e4b01553277a77.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
从20世纪90年代起,面向对象编程的范式逐渐成为了主流。目前流行度比较高的几种语言,比如Java、JavaScript、Go、C++和Python等,都支持面向对象编程。
|
||||
|
||||
那么,为了支持面向对象编程,我们需要在语言的设计上,以及编译器和运行时的实现上,考虑到哪些问题呢?
|
||||
|
||||
这一讲,我就带你来探讨一下如何在一门语言里支持面向对象特性。这是一个很综合的话题,会涉及很多的知识点,所以很有助于帮你梳理和贯通与编译原理有关的知识。
|
||||
|
||||
那么,我们就先来分析一下,面向对象特性都包括哪些内容。
|
||||
|
||||
## 面向对象语言的特性
|
||||
|
||||
日常中,虽然我们经常会使用面向对象的语言,但如果要问,到底什么才是面向对象?我们通常会说得含含糊糊。最常见的情况,就是会拿自己所熟悉的某种语言的面向对象特性,想当然地认为这就是面向对象语言的全部特性。
|
||||
|
||||
不过,在我们的课程里,我想从计算机语言设计的角度,带你重新梳理和认识一下面向对象的编程语言,把面向对象按照清晰的逻辑解构,这样也便于讨论它的实现策略。在这个过程中,你可能会对面向对象产生新的认识。
|
||||
|
||||
### 特征1:对象
|
||||
|
||||
**面向对象编程语言的核心,是把世界看成了一个个的对象**,比如汽车、动物等。这些对象包含了数据和代码。数据被叫做字段或属性,而代码通常又被叫做是方法。
|
||||
|
||||
此外,**这些对象之间还会有一定的关系**。比如,汽车是由轮子、发动机等构成的,这叫做**聚合关系**。而某个班级会有一个班主任,那么班级和作为班主任的老师之间,会有一种**引用关系**。
|
||||
|
||||
**对象之间还可以互相发送消息。**比如,司机会“通知”汽车,让它加速或者减速。在面向对象的语言中,这通常是通过方法调用来实现的。但也并不局限于这种方式,比如对象之间还可以通过异步的消息进行互相通讯,不过一般的编程语言都没有原生支持这种通讯方式。我们在讨论[Actor模式](https://time.geekbang.org/column/article/280663)的时候,曾经提到过Actor之间互相通讯的方式,就有点像对象之间互发消息。
|
||||
|
||||
### 特征2:类和类型体系
|
||||
|
||||
**很多面向对象的语言都是基于类(class)的,并且类也是一种自定义的类型。**这个类型是对象的模板。而对象呢,则是类的实例。我们还可以再印证一下,前面在探究[元编程](https://time.geekbang.org/column/article/282919)的实现机制时,学过的Meta层次的概念。对象属于M0层,而类属于M1层,它为对象制定了一个标准,也就是对象中都包含了什么数据和方法。
|
||||
|
||||
其实,**面向对象的语言并不一定需要类这个概念**,这个概念更多是来自于类型理论,而非面向对象的语言一样可以支持类型和子类型。类型的好处主要是针对静态编译的语言的,因为这样就可以通过类型,来限制可以访问的对象属性和方法,从而减少程序的错误。
|
||||
|
||||
而有些面向对象的语言,比如JavaScript并没有类的概念。也有的像Python,虽然有类的概念,但你可以随时修改对象的属性和方法。
|
||||
|
||||
### 特征3:重用–继承(Inheritance)和组合(Composition)
|
||||
|
||||
在软件工程里,我们总是希望能重用已有的功能。像Java、C++这样的语言,能够让子类型重用父类型的一些数据和逻辑,这叫做**继承**。比如Animal有speak()方法,Cat是Animal的子类,那么Cat就可以继承这个speak()方法。Cat也可以重新写一个方法,把父类的方法覆盖掉,让叫声更像猫叫。
|
||||
|
||||
不过,并不是所有的面向对象编程语言都喜欢通过继承的方式来实现重用。你在网上可以找到很多文章,都在分析继承模式的缺陷。像Go语言,采用的是**组合方式**来实现重用。在这里,我引用了[一篇文章](https://golangbot.com/inheritance/)中的例子。在这个例子中,作者首先定义了一个author的结构体,并给这个结构体定义了一些方法:
|
||||
|
||||
```
|
||||
type author struct { //结构体:author(作者)
|
||||
firstName string //作者的名称
|
||||
lastName string
|
||||
bio string //作者简介
|
||||
}
|
||||
|
||||
func (a author) fullName() string { //author的方法:获取全名
|
||||
return fmt.Sprintf("%s %s", a.firstName, a.lastName)
|
||||
}
|
||||
|
||||
type post struct { //结构体:文章
|
||||
title string //文章标题
|
||||
content string //文章内容
|
||||
author //文章作者
|
||||
}
|
||||
|
||||
func (p post) details() { //文章的方法:获取文章的详细内容。
|
||||
fmt.Println("Title: ", p.title)
|
||||
fmt.Println("Content: ", p.content)
|
||||
fmt.Println("Author: ", p.author.fullName())
|
||||
fmt.Println("Bio: ", p.author.bio)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
关于struct,这里我想再给你强调几个知识点。熟悉C语言的同学应该都了解结构体(struct)。有一些比较新的语言,比如Go、Julia和Rust,也喜欢用结构体来作为复合数据类型,而不愿意用class这个关键字,而且它们也普遍摒弃了用继承来实现重用的思路。Go提倡的是组合;而我们上一讲提到的泛型,也开始在重用方面承担了越来越重要的角色,就像在Julia语言里那样;Rust和另外一些语言(如Scala),则使用了一种叫做**Trait(特征)**的技术。
|
||||
|
||||
Trait有点像Java和Go语言中的接口,它也是一种类型。不过它比接口还多了一些功能,那就是Trait里面的方法可以具体地实现。
|
||||
|
||||
我们用Trait可以替代传统的继承结构,比如,一个Cat可以实现一个Speakable的Trait,而不需要从Animal那里继承。如果Animal还有其他的特征,比如说reproduce(),繁殖,那也可以用一个Trait来代替。这样,Cat就可以实现多个Trait。这会让类型体系更加灵活,比如实现Speakable的不仅仅有动物,还可以是机器人。
|
||||
|
||||
在像Scala这样的语言中,Trait里不仅仅可以有方法,还可以有成员变量;而在Ruby语言中,我们把这种带有变量和方法的可重用的单元,叫做Mixin(混入)。
|
||||
|
||||
无论Trait还是Mixin,都是基于组合的原理来实现重用的。而且由于继承、接口、Trait和Mixin都可以看做是实现子类型的方式,因此也都可以支持多态。因为继承、接口、Trait和Mixin一般都有多个具体的实现类,所以在调用相同的方法时,会有不同的功能。
|
||||
|
||||
### 特征4:封装(Encapsulation)
|
||||
|
||||
我们知道,软件工程中的一个原则是信息隐藏,我们通常会称为**封装(encapsulation)**,意思是软件只把外界需要知道的信息和功能暴露出来,而内部具体的实现机制,只有作者才可以修改,并且不会影响到它的使用者。
|
||||
|
||||
同样的,实现信息封装其实也不是面向对象才有的概念。有的语言的模块(Module)和包(Package)等,都可以作为封装的单元。
|
||||
|
||||
在面向对象的语言里,通常对象的一些方法和属性可以被公共访问的,而另一些方法和属性是内部使用的,其访问是受限的。比如,Java语言会对可以公共访问的成员加public关键字,对只有内部可以访问的成员加private关键字。
|
||||
|
||||
好了,以上就是我们总结的面向对象语言的特征了。这里你要注意,面向对象编程其实是一个比较宽泛的概念。对象的概念是它的基础,然后语言的设计者再把类型体系、软件重用机制和信息封装机制给体现出来。在这个过程中,不同的设计者会有不同的取舍。所以,希望你不要僵化地理解面向对象的概念。比如,以为面向对象就必须有类,就必须有继承;以为面向对象才导致了多态,等等。这些都是错误的理解。
|
||||
|
||||
接下来,我们再来看看各门语言在实现这些面向对象的特征时,都要解决哪些关键技术问题。
|
||||
|
||||
## 如何实现面向对象的特性?
|
||||
|
||||
要实现一门面向对象的语言,我们重点要了解三个方面的关键工作:一是编译器在语法和语义处理方面要做哪些工作;二是运行期对象的内存布局的设计;三是在存在多态的情况下,如何实现方法的绑定。
|
||||
|
||||
我们首先从编译器在语法和语义处理上所做的工作开始学起。
|
||||
|
||||
### 编译器前端的工作
|
||||
|
||||
我们都知道,编译器的前端必须完成与类和对象有关的语法解析、符号表处理、引用消解、类型分析等工作。那么要实现一门面向对象的语言,编译器也需要完成这些工作。
|
||||
|
||||
**第一,从语法角度来看,语言的设计者要设计与类的声明和使用有关的语法。**
|
||||
|
||||
比如:
|
||||
|
||||
- 如何声明一个类?毕竟每种语言的风格都不同。
|
||||
- 如何声明类的构造方法?
|
||||
- 如何声明类与父类、接口、Trait等的关系?
|
||||
- 如何实例化一个对象?像Java语言就需要new关键字,而Python就不需要。
|
||||
- ……
|
||||
|
||||
也就是说,编译器在语法分析阶段,至少要能够完成上述的语法分析工作。
|
||||
|
||||
**第二,是要维护符号表,并进行引用消解。**
|
||||
|
||||
在语义分析阶段,每个类会作为自定义类型被加入到符号表里。这样,在其他引用到该类型的地方,比如用该类型声明了一个变量,或者一个方法的参数或返回值里用到了该类型,编译器就能够做正确的引用消解。
|
||||
|
||||
另外,面向对象程序的引用消解还有一个特殊之处。因为父类中的成员变量、方法甚至类型的作用域会延伸到子类,所以编译器要能够在正确的作用域内做引用消解。比如,在一个方法体内,如果发现某个变量既不是本地变量,又不是参数,那么程序就要去找类的成员变量。在当前的类里找不到,那就要到父类中逐级去找。
|
||||
|
||||
还有一点,编译器在做引用消解的时候,还可以完成访问权限的检查。我们知道,对象要能够实现信息封装。对于编译器来说,这个功能实现起来很简单。在做引用消解的时候,检查类成员的访问权限就可以了。举个例子,假设你用代码访问了某个私有的成员变量,或者私有的方法,此时程序就可以报错;而在这个类内部的代码中,就可以访问这些私有成员。这样就实现了封装的机制。
|
||||
|
||||
**第三,要做类型检查和推断。**
|
||||
|
||||
使用类型系统的信息,在变量赋值、函数调用等地方,会进行类型检查和推断。我们之前学过的关于子类型、泛型等知识,在这里都可以用上。
|
||||
|
||||
OK,以上就是编译器前端关于实现面向对象特性的重点工作了,我们接下来看看编译器在运行时的一个设计重点,就是对象的内存布局。
|
||||
|
||||
### 对象的内存布局
|
||||
|
||||
在第二个模块,研究几个不同的编译器的时候,我们已经考察过各种编译器在保存对象时所采用的内存布局。像Java、Python和Julia的对象,一般都有一个固定的对象头。对象头里可以保存各种信息,比如到类定义的指针、与锁有关的标志位、与垃圾收集有关的标志位,等等。
|
||||
|
||||
对象头之后,通常就是该类的数据成员。如果存在父类,那么就既要保存父类中的成员变量,也要保存子类中的成员变量。像Python这样的语言,对内存的使用比较浪费,通常是用一个内部字典来保存成员变量;但对于Java这样的语言,则要尽量节约着用内存。
|
||||
|
||||
我举个例子。假设某个Java类里有两个成员变量,那么这两个成员变量会根据声明的顺序,排在对象头的后面。如果成员变量是像Int这样的基础数据,那么程序就要保存它的值;而如果是String等对象类型,那么就要保存一个指向该对象的指针。
|
||||
|
||||
在Java语言中,当某个类存在父类的情况下,那么父类的数据成员一定要排在前面。
|
||||
|
||||
这是为什么呢?我给你举一个例子。在这个例子中,有一个父类Base,有两个子类分别是DerivedA和DerivedB。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/01/5b17bc0b506cb17afdd7ca2fcf4e9801.jpg" alt="">
|
||||
|
||||
如果两个子类分别有一个实例a和b,那么它们的内存布局就是下面的样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5c/6a/5cbcfb3888dyy3ee8b233667fa0d296a.jpg" alt="">
|
||||
|
||||
那么你可能要问了,**为什么父类的数据成员要放在前面,子类的要放在后面?**这也要从编译的角度说起。
|
||||
|
||||
我们知道,在生成的汇编代码里,如果要访问一个类的成员变量,其实都是从对象地址加上一个偏移量,来得到成员变量的地址。而这样的代码,针对父类和各种不同的子类的对象,要都能正常运行才行。所以,该成员变量在不同子类的对象中的位置,最好是固定的,这样才便于生成代码。
|
||||
|
||||
不过像C++这样的语言,由于它经常被用来编写系统级的程序,所以它不愿意浪费任意一点内存,因此就不存在对象头这样的开销。但是由于C++支持多重继承,所以当某个类存在多个父类的情况下,在内存里安排不同父类的成员变量,以及生成访问它们的正确代码,就要比Java复杂一些。
|
||||
|
||||
比如下面的示例代码中,c同时继承了a和b。你可以把对象obj的地址分别转换为a、b和c的指针,并把这个地址打印出来。
|
||||
|
||||
```
|
||||
class a { int a_; };
|
||||
class b { int b_; };
|
||||
class c : public a, public b { };
|
||||
int main(){
|
||||
c obj;
|
||||
printf("a=0x%08x, b=0x%08x, c=0x%08x\n", (a*)&obj,(b*)&obj,(c*)&obj);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看到这段代码,你发现什么了呢?
|
||||
|
||||
你会发现,a和c的指针地址是一样的,而b的地址则要大几个字节。这是因为,在内存里程序会先排a的字段,再排b的字段,最后再排c的字段。编译器做指针的类型转换(cast)的时候,要能够计算出指针的正确地址。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/61/d0893798d4fcb3459b8b4bd7c67daf61.jpg" alt="">
|
||||
|
||||
好,现在你就已经见识到了编译器在运行期针对对象的内存布局的设计了。接下来,我们再来看看针对多态的情况,编译器对实现方法的绑定是怎么做的。
|
||||
|
||||
### 方法的静态绑定和动态绑定
|
||||
|
||||
当你的程序调用一个对象的方法的时候,这个方法到底对应着哪个实现,有的时候在编译期就能确定了,比如说当这个方法带有private、static或final关键字的时候。这样,你在编译期就知道去执行哪段字节码,这被叫做**静态绑定(Static Binding)**,也可以叫做早期绑定(Early Binding)或者静态分派(Static Dispathing)。
|
||||
|
||||
另外,对于重载(Overload)的情况,也就是方法名称一样、参数个数或类型等不一样的情况,也是可以在编译期就识别出来的,所以也可以通过静态绑定。
|
||||
|
||||
而在存在子类型的情况下,我们到底执行哪段字节码,只有在运行时,根据对象的实际类型才能确定下来。这个时候就叫做**动态绑定(Dynamic binding)**,也可以叫做后期绑定(Late binding)或者动态分派(Dynamic Dispatching)。
|
||||
|
||||
动态绑定也是面向对象之父阿伦 · 凯伊(Alan Kay)所提倡的面向对象的特征:**绑定时机要尽量地晚**。绑定时机晚,意味着你在编程的时候,可以编写尽量通用的代码,也就是代码里使用的是类型树中更靠近树根的类型,这种类型更通用,也就可以让使用这种类型编写的代码能适用于更多的子类型。
|
||||
|
||||
**那么动态绑定在运行时是怎么实现的呢?**对于Java语言来说,其实现机制对于每个JVM可以是不同的。不过,我们可以参考C++的实现机制,就可以猜测出JVM的实现机制。
|
||||
|
||||
在C++语言中,动态绑定是通过一个**vtable的数据结构**来实现的。vtable是Virtual Method Table或Virtual Table的简称。在C++里,如果你想让基类中的某个方法可以被子类覆盖(Override),那么你在声明该方法的时候就要带上**virtual关键字**。带有虚方法的类及其子类的对象实例,都带有一个指针,指向一个表格,这也就是vtable。
|
||||
|
||||
vtable里保存的是什么呢?是每个虚方法的入口地址。我们来看一个例子,这个例子中有Base、DerivedA和DerivedB三个类:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/90/15c8d9a7bb55807ffb13b2e8d8c69d90.jpg" alt="">
|
||||
|
||||
在对象a和b的vtable中,各个方法的指针都会指向这个对象实际调用的代码的入口地址。
|
||||
|
||||
那么编译器的工作就简单了。因为它可以基于对象的地址,得到vtable的地址,不用去管这个对象是哪个子类的实例。
|
||||
|
||||
然后,编译器只需要知道,当前调用的方法在vtable中的索引值就行了,这个索引值对于不同的子类的实例,也都是一样的,但是具体指向的代码地址可能会不同。这种调用当然需要比静态绑定多用几条指令,因为对于静态绑定而言,只需要跳转到某个特定的方法的代码地址就行了,不需要通过vtable这个中间数据结构。不过,这种代价是值得的,因为它支持了面向对象中基于子类型的多态,从而可以让你编写更具通用性的代码。
|
||||
|
||||
图4中,我展示的只是一个示意结构。实际上,vtable中还包含了一些其他信息,比如能够在运行时确定对象类型的信息,这些信息也可以用于在运行时进行指针的强制转换。
|
||||
|
||||
上面的例子是单一继承的情况。**对于多重继承,还会有多个vptr指针,指向多个vtable。**你参考一下多重继承下的内存布局和指针转换的情况,应该就可以自行脑补出多重继承下的方法绑定技术。
|
||||
|
||||
我们接着回到Java。Java语言中调用这种可被重载的方法,生成的是**invokevirtual指令**,你在之前阅读Java字节码的时候一定遇到过这个指令。那么我现在可以告诉你,这个virtual就是沿用了C++中虚方法的概念。
|
||||
|
||||
OK,理解了实现面向对象特性所需要做的一系列重点工作以后,我们来挑战一个难度更高的目标,这也是一个有技术洁癖的人会非常想实现的目标,就是让一切数据都表达为对象。
|
||||
|
||||
## 如何实现一切数据都是对象?
|
||||
|
||||
在Java、C++和Go这些语言中,基础数据类型和对象类型是分开的。基础数据类型包括整型、浮点型等,它们不像对象类型那样有自己的方法,也没有类之间的继承关系。
|
||||
|
||||
这就给我们的编程工作造成了很多不便。比如,针对以上这两类不同数据类型的编程方式是不一致的。在Java里,你不能声明一个保存int数据的ArrayList。在这种情况下,你只能使用Integer类型。
|
||||
|
||||
不过,Java语言的编译器还是尽量提供了一些便利。举个例子,下面示例的两个语句都是合法的。在需要用到一个Interger对象的时候,你可以使用一个基础的int型数据;反过来亦然。
|
||||
|
||||
```
|
||||
Integer b = 2;
|
||||
int c = b + 1;
|
||||
|
||||
```
|
||||
|
||||
在研究[Java编译器](https://time.geekbang.org/column/article/255034)的时候,你已经发现它在语义分析阶段提供了自动装箱(boxing)和拆箱(unboxing)的功能。比如说,如果发现代码里需要的是一个Interger对象,而代码里提供的是一个int数据,那么程序就自动添加相应的AST节点,基于该int数据创建一个Integer对象,这就叫做**装箱功能**。反之呢,把Integer对象转换成一个int数据,就叫做**拆箱功能**。装箱和拆箱功能属于一种语法糖,它能让编程更方便一些。
|
||||
|
||||
说到这里,你可能会想到,**既然编译器可以实现自动装箱和拆箱,那么在Java语言里,是不是根本就不用提供基础数据类型了,全部数据都用对象表达行不行?**这样的话,语言的表达性会更好,我们写起程序来也更简单。
|
||||
|
||||
不过,现在要想从头修改Java的语法是不可能了。但也有其他基于JVM的语言做到了这一点,比如Scala。在Scala里,所有数据都是对象,各个类型构成了一棵有着相同根节点的类型树。对于对象化的整型和浮点型数据,编译器可以把它们直接编译成JVM的基础数据类型。
|
||||
|
||||
可仅仅这样还不够。**在Java里面,需要自动装箱和拆箱机制,很大一部分原因是Java的泛型机制。**那些使用泛型的List、Map等集合类,只能保存对象类型,不能保存基础数据类型。但这对于非常大的一个集合来说,用对象保存整型数据要多消耗几倍的内存。那么,我们能否优化集合类的实现,让它们直接保存基础数据,而不是保存一个个整型对象的引用呢?
|
||||
|
||||
通过上一讲的学习,我们也知道了,Java的泛型机制是通过**类型擦除**来实现的,所以集合类里面只能保存对象引用,无法保存基础数据。既然JVM平台缺省的类型擦除技术行不通,那么是否可以对类型参数是值类型的情况下做特殊处理呢?
|
||||
|
||||
这是可以做到的。你还记得,C++实现泛型采用的是元编程技术。那么在JVM平台上,你其实也可以**通过元编程技术,针对值类型生成不同的代码**,从而避免创建大量的小对象,降低内存占用,同时减少GC的开销。Scala就是这么做的,它会通过注解技术来完成这项任务。如果你对Scala的具体实现机制感兴趣,可以参考[这篇文章](https://scalac.io/specialized-generics-object-instantiation/)。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲我通过面向对象这个话题,带你一起综合性地探讨了语言设计、编译器和运行时的多个知识点。你可以带走这几个关键知识点:
|
||||
|
||||
- 第一,要正确地理解面向对象编程的内涵,知道其实面向对象的语言可以有多种不同的设计选择,体现在类型体系、重用机制和信息封装等多个方面。对于不同的设计选择,你要都能够把它们解构,并对应到编译期和运行时的设计上。
|
||||
- 第二,面向对象的各种特性,大多都是要在语义分析阶段进行检查和验证。
|
||||
- 第三,对于静态编译的面向对象语言来说,理解其内存布局是关键。编译期要保证能够正确地访问对象的属性,并且巧妙地实现方法的动态绑定。
|
||||
- 第四,如有可能,尽量让一切数据都表达为对象。让编译器完成自动装箱和拆箱的工作。
|
||||
|
||||
按照惯例,我把这节课的核心内容整理成了思维导图,供你参考和回顾知识点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6b/aa/6b0c1c2a2d4d045e019d3dcd0356e9aa.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
有人曾经在技术活动上问Java语言之父詹姆斯 · 高斯林(James Gosling),如果重新设计Java语言,他会怎么做?他回答说,他会去掉class,也就是会取消类的继承机制。那么,对于你熟悉的面向对象语言,如果有机会重新设计的话,你会怎么建议?为什么?欢迎分享你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 介绍Trait机制的[论文](http://rmod.inria.fr/archives/papers/Duca06bTOPLASTraits.pdf)。
|
||||
1. 在类型参数是值类型的情况下,Scala以特殊的方式做实例化的[文章](https://scalac.io/specialized-generics-object-instantiation/)。
|
||||
383
极客时间专栏/geek/编译原理实战课/现代语言设计篇/39 | 综合实现(二):如何实现函数式编程?.md
Normal file
383
极客时间专栏/geek/编译原理实战课/现代语言设计篇/39 | 综合实现(二):如何实现函数式编程?.md
Normal file
@@ -0,0 +1,383 @@
|
||||
<audio id="audio" title="39 | 综合实现(二):如何实现函数式编程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/9c/9330ecd0ec3f4b46374a054c63862d9c.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
近些年,函数式编程正在复兴。除了一些纯函数式编程语言,比如Lisp、Clojure、Erlang等,众多的主流编程语言,如Python、JavaScript、Go甚至Java,它们都有对函数式编程的支持。
|
||||
|
||||
你应该会发现,现在人们对于函数式编程的讨论有很多,比如争论函数式编程和面向对象编程到底哪个更强,在语言里提供混合的编程模式到底对不对等等。
|
||||
|
||||
这些论战一时半会儿很难停息。不过我们的这一讲,不会涉及这些有争议的话题,而是试图从编译技术的角度,来探讨如何支持函数式编程,包括如何让函数作为一等公民、如何针对函数式编程的特点做优化、如何处理不变性,等等。通过函数式编程这个综合的主题,我们也再一次看看,如何在实现一门语言时综合运用编译原理的各种知识点,同时在这个探究的过程中,也会加深你对函数式编程语言的理解。
|
||||
|
||||
好,我们先来简单了解一下函数式编程的特点。
|
||||
|
||||
## 函数式编程的特点
|
||||
|
||||
我想,你心里可能多多少少都会有一点疑问,为什么函数式编程开始变得流行了呢?为什么我在开篇的时候,说函数式编程正在“复兴”,而没有说正在兴起?为什么围绕函数式编程会有那么多的争论?
|
||||
|
||||
要回答这几个问题,我会建议你先去了解一点历史。
|
||||
|
||||
我们都知道,计算机发展历史上有一个重要的人物是阿兰 · 图灵(Alan Turing)。他在1936年提出了一种叫做**图灵机**的抽象模型,用来表达所有的计算。图灵机有一个无限长的纸带,还有一个读写头,能够读写数据并根据规则左右移动。这种计算过程跟我们在现代的计算机中,用一条条指令驱动计算机运行的方式很相似。
|
||||
|
||||
不过,计算模型其实不仅仅可以用图灵机来表达。早在图灵机出现之前,阿隆佐 · 邱奇(Alonzo Church)就提出了一套Lambda演算的模型。并且,计算机科学领域中的很多人,其实都认为用Lambda演算来分析可计算性、计算复杂性,以及用来编程,会比采用图灵机模型更加简洁。而**Lambda演算,就是函数式编程的数学基础**。
|
||||
|
||||
补充:实际上,邱奇是图灵的导师。当年图灵发表他的论文的时候,编辑看不懂,所以找邱奇帮忙,并推荐图灵成为他的学生,图灵机这个词也是邱奇起的。所以师生二人,对计算机科学的发展都做出了很大的贡献。
|
||||
|
||||
因为有Lambda演算的数学背景,所以函数式编程范式的历史很早。上世纪50年代出现的Lisp语言,就是函数式编程语言。Lisp的发明人约翰 · 麦卡锡(John McCarthy)博士,是一位数学博士。所以你用Lisp语言和其他函数式编程语言的时候,都会感觉到有一种数学思维的味道。
|
||||
|
||||
也正因如此,与函数式编程有关的理论和术语其实是有点抽象的,比如函子(Functor)、单子(Monad)、柯里化(Currying)等。当然,对它们的深入研究不是我们这门课的任务。这里我想带你先绕过这些理论和术语,从我们日常的编程经验出发,来回顾一下函数式编程的特点,反倒更容易一些。
|
||||
|
||||
我前面也说过,目前流行的很多语言,虽然不是纯粹的函数式编程语言,但多多少少都提供了对函数式编程的一些支持,比如JavaScript、Python和Go等。就连Java语言,也在Java8中加入了对函数式编程的支持,很多同学可能已经尝试过了。
|
||||
|
||||
我们使用函数式编程最多的场景,恐怕是对集合的处理了。举个例子,假设你有一个JavaScript的数组a,你想基于这个数组计算出另一个数组b,其中b的每个元素是a中对应元素的平方。如果用普通的方式写程序,你可能会用一个循环语句,遍历数组a,然后针对每个数组元素做处理:
|
||||
|
||||
```
|
||||
var b = [];
|
||||
for (var i = 0; i< a.length; i++){ //遍历数组a
|
||||
b.push(a[i]*a[i]); //把计算结果加到数组b中
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过你也可以采用更简单的实现方法。
|
||||
|
||||
这次我们使用了map方法,并给它传了一个回调函数。map方法会针对数组的每个元素执行这个回调函数,并把计算结果组合成一个新的数组。
|
||||
|
||||
```
|
||||
function sq(item){ //计算平方值的函数
|
||||
return item*item;
|
||||
}
|
||||
var b = a.map(sq); //把函数作为参数传递
|
||||
|
||||
```
|
||||
|
||||
它还可以写成一种更简化的方式,也就是Lambda表达式的格式:
|
||||
|
||||
```
|
||||
var b = a.map(item=>item*item);
|
||||
|
||||
```
|
||||
|
||||
通过这个简单的例子,我们可以体会出函数式编程的几个特点:
|
||||
|
||||
### 1.函数作为一等公民
|
||||
|
||||
也就是说,函数可以像一个数值一样,被赋给变量,也可以作为函数参数。如果一个函数能够接受其他函数作为参数,或者能够把一个函数作为返回值,那么它就是**高阶函数**。像示例程序中的map就是高阶函数。
|
||||
|
||||
那函数式编程语言的优势来自于哪里呢?就在于它可以像数学那样使用函数和变量,这会让软件的结构变得特别简单、清晰,运行结果可预测,不容易出错。
|
||||
|
||||
根据这个特点,我们先来看看函数式编程语言中的函数,跟其他编程语言中的函数有什么不同。
|
||||
|
||||
### 2.纯函数(Pure Function)
|
||||
|
||||
在函数式编程里面,有一个概念叫做纯函数。纯函数是这样一种函数,即**相同的输入,永远会得到相同的输出**。
|
||||
|
||||
其实你对纯函数应该并不陌生。你在中学时学到的函数,就是纯函数。比如对于f(x)=ax+b,对于同样的x,所得到的函数值肯定是一样的。所以说,纯函数不应该算是个新概念,而是可以回归到你在学习计算机语言之前的那个旧概念。
|
||||
|
||||
在C语言、Java等语言当中,由于函数或方法里面可以引用外面的变量,比如全局变量、对象的成员变量,使得其返回值与这些变量有关。因此,如果有其他软件模块修改了这些变量的值,那么该函数或方法的返回值也会受到影响。这就会让多个模块之间基于共享的变量耦合在一起,这种耦合也使得软件模块的依赖关系变得复杂、隐秘,容易出错,牵一发而动全身。这也是像面向对象语言这些命令式编程语言最令人诟病的一点。
|
||||
|
||||
而对于纯函数来说,它不依赖外部的变量,这个叫做**引用透明(Reference Transparency)**。纯函数的这种“靠谱”、可预测的特征,就给我们的编程工作带来了很多的好处。
|
||||
|
||||
举个例子。既然函数的值只依赖输入,那么就跟调用时间无关了。假设有一个函数式g(f(x)),如果按照传统的求值习惯,我们应该先把f(x)的值求出来,再传递给g()。但如果f(x)是纯函数,那么早求值和晚求值其实是无所谓的,所以我们可以**延迟求值(Lazy Evaluation)**。
|
||||
|
||||
延迟求值有很大的好处。比如,在下面的伪代码中,unless是一个函数,f(x)是传给它的一个参数。在函数式编程语言中,只有当condition为真时,才去实际对f(x)求值。这实际上就降低了工作量。
|
||||
|
||||
```
|
||||
//在满足条件时,执行f(x)
|
||||
unless(condition, f(x));
|
||||
|
||||
//伪代码
|
||||
int unless(bool condition, f(x)){
|
||||
if (condition)
|
||||
return f(x);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
再回到纯函数。我说纯函数的输出仅依赖输入,有一点需要说明,就是函数只有返回值这一种输出,没有其他的输出。换句话说,**纯函数没有副作用(Side Effect)**。
|
||||
|
||||
什么是副作用呢?简单地说,就是函数在运行过程中影响了外界环境。比如,修改了一个全局变量或者是对象的属性、往文件里写入内容、往屏幕上打印一行字、往数据库插入一条记录、做了一次网络请求,等等。也就是说,纯函数要求程序除了计算,其他的事情都不要做。
|
||||
|
||||
如果函数有副作用的话,那么我们前面说的时间无关性就被破坏了。比如说,原来a函数是在屏幕上打印“欢迎:”,b函数是屏幕输出你的名字,最后形成“欢迎:XXX”。那么a和b的前后顺序就不能颠倒。
|
||||
|
||||
你可能会说,一个有用的程序,哪能没有副作用呀。你说得对。在函数式编程里,程序会尽量把产生副作用的函数放在调用的外层,而完成内部功能的大部分函数,都保持是纯函数。比如,最外层的函数接受网络请求,并对客户端返回结果,它是有副作用的。而程序所使用的其他函数,都没有副作用。
|
||||
|
||||
纯函数的功能是如此地简单纯粹,以至于它还能继续带来一些好处。比如说,像Erlang这样的语言,可以在运行时给某些函数升级,而不用重启整个系统。为什么呢?因为这些升级后的函数,针对相同的输入,程序得到的结果是一样的,那么对这个函数的使用者来说,就没有任何影响。这也是用Erlang写的系统会具有很高的可靠性的原因之一。
|
||||
|
||||
不过,函数式编程语言里使用的也不全都是纯函数,比如有的函数要做一些IO操作。另外,闭包,是函数引用了词法作用域中的自由变量而引起的,所以也不是纯函数。
|
||||
|
||||
总结起来,在函数式编程中,会希望函数像数学中的函数那样纯粹,即**不依赖外部(引用透明),也不改变外部(无副作用),从而带来计算时间、运行时替换等灵活性的优势**。
|
||||
|
||||
好,说完了函数的不同,我们再来看看函数式编程语言里使用变量跟其他语言的不同。
|
||||
|
||||
### 3.不变性(Immutability)
|
||||
|
||||
我们都知道,在数学里面,当我们用到x和y这样的变量的时候,它所代表的值在计算过程中是不变的。
|
||||
|
||||
没错,这也是函数式编程的一个重要原则,**不变性**。它的意思是,程序会根据需要来创建对象并使用它们,但不会去修改对象的状态。如果有需要修改对象状态的情况,那么去创建一个新对象就好了。
|
||||
|
||||
在前面的示例程序中,map函数返回了一个新的数组,而原来的数组保持不变。这就体现了不变性的特点。
|
||||
|
||||
不变性也会带来巨大的好处。比如说,由于函数不会修改对象的状态,所以就不存在并发程序中的竞争情况,进而也就不需要采用锁的机制。所以说,**函数式编程更适合编写并发程序**。这个优势,也是导致这几年函数式编程复兴的重要原因。
|
||||
|
||||
好,那么最后,我们再来注意一下函数式编程语言在编程风格上的不同。
|
||||
|
||||
### 4.声明式(Declarative)的编程风格
|
||||
|
||||
在计算机语言中,实现编程的方式主要有几种。
|
||||
|
||||
第一种实现方式,我们会一步步告诉计算机该去怎么做计算:循环访问a的元素,计算元素的平方值,并加到b中。这种编程风格叫做**命令式(Imperative)编程**,即命令计算机按照你要求的步骤去做。命令式编程风格植根于现代计算机的结构,因为机器指令本质上就是命令式的。这也是图灵机模型的特点。
|
||||
|
||||
而第二种实现方式叫做**声明式(Declarative)编程**。这种编程风格,会要求计算机给出你想要的结果,而不关心过程。比如在前面的示例程序中,你关心的是对数组中的每个元素计算出平方值。至于具体的处理步骤,是对数组a的元素顺序计算,还是倒序计算,你并不关心。
|
||||
|
||||
声明式编程风格的另一个体现,是递归函数的大量使用。这是因为我们描述一个计算逻辑的时候,用递归的方式表达通常会更简洁。
|
||||
|
||||
举个例子。你可能知道,斐波纳契(Fibonacci)数列中的每个数,是前两个数字的和。这个表达方式就是递归式的。写成公式就是:Fibonacci(n)=Fibonacci(n-1)+Fibonacci(n-2)。这个公式与我们用自然语言的表达完全同构,也更容易理解。
|
||||
|
||||
我把计算斐波纳契数列的程序用Erlang这种函数式语言来写一下,你可以进一步体会到声明式编程的那种简洁和直观的特点:
|
||||
|
||||
```
|
||||
%% 计算斐波那契的第N个元素
|
||||
fibo(1) -> 1; %%第一个元素是1
|
||||
fibo(2) -> 1; %%第二个元素也是1
|
||||
fibo(N) -> fibo(N-1) + fibo(N-2). %%递归
|
||||
|
||||
```
|
||||
|
||||
好了,现在我们已经了解了函数式编程的一些关键特征。它的总体思想呢,就是像数学那样去使用函数和值,使可变动部分最小化,让软件的结构变得简单、可预测,从而获得支持并发、更简洁的表达等优势。那么下面,我们就一起来看看如何结合编译原理的相关知识点,来实现函数式编程的这些特征。
|
||||
|
||||
## 函数式编程语言的编译和实现
|
||||
|
||||
为了实现函数式语言,我们在编译期和运行时都要做很多工作。比如,要在编译器前端做分析和各种语义的检查; 要以合适的方式在程序内部表示一个函数;要针对函数式编程的特点做特别的优化,等等。接下来我们就从编译器的前端工作开始学起。
|
||||
|
||||
### 编译器前端的工作
|
||||
|
||||
函数式编程语言,在编译器的前端也一样要做很多的语法分析和语义分析工作。
|
||||
|
||||
你应该知道,语言的设计者,需要设计出**如何声明一个函数**。像是JavaScript语言会用function关键字,Go语言用func关键字,Rust语言用的是fn关键字,而C语言根本不需要一个关键字来标识一个函数的定义;另外,如何声明函数的参数和返回值也会使用不同的语法。编译器都要能够正确地识别出来。
|
||||
|
||||
语义分析的工作则更多,包括:
|
||||
|
||||
1. **符号表和引用消解**:当声明一个函数时,要把它加入到符号表。而当程序中用到某个函数的时候,要找到该函数的声明。
|
||||
1. **类型检查和推导**:既然函数可以被当做一个值使用,那么它一定也是有类型的,也要进行类型检查和推导。比如,在程序的某个地方只能接受返回值为int,有一个参数为String的函数,那么就需要被使用的函数是否满足这个要求。关于函数的类型,一会儿我还会展开讲解。
|
||||
1. **语法糖处理**:在函数式编程中经常会使用一些语法糖。最常见的语法糖就是Lambda表达式,Lambda表达式可以简化匿名函数的书写。比如,前面JavaScript的示例代码中,对数组元素求平方的函数可以写成一个Lambda表达式,从而把原来的代码简化成了一行:
|
||||
|
||||
```
|
||||
var d = a.map(item=>item*item); //括号中是一个lambda表达式
|
||||
|
||||
```
|
||||
|
||||
在这个示例程序中,=>左边的是匿名函数的参数,右边的是一个表达式,这个表达式的计算结果就是匿名函数的返回值。你看,通过一个Lambda表达式,代替了传统的函数声明,代码也变得更简洁了。
|
||||
|
||||
OK,因为在编译器前端还要对函数做类型分析,所以我们再来探究一下函数的类型是怎么一回事。
|
||||
|
||||
### 把函数纳入类型系统
|
||||
|
||||
这里我要先提一个问题,就是在函数式编程语言里,既然它能够把函数当做一个值一样去看待,那么也应该有相应的类型吧?这就要求语言的类型系统能够把函数包含进来。因此函数式编程语言在编译的时候,也要进行**类型检查和类型推断**。
|
||||
|
||||
不过,我们在谈论类型时,比较熟悉的是值类型(如整型、浮点型、字符型),以及用户自定义的类型(如结构、类这些),如果函数也是一种类型,那跟它们是什么关系呢?如果由你来设计,那么你会怎么设计这个类型体系呢?
|
||||
|
||||
在不同的语言里,设计者们是以不同的方式来解决这个问题的。拿Python来说,Python中一切都是对象,函数也不例外。函数对象的ob_type字段也被设置了合适的类型对象。这里,你可以再次回味一下,[Python的类型系统](https://time.geekbang.org/column/article/261063)设计得是如何精巧。
|
||||
|
||||
我们再看看Scala的类型系统。上一讲我提出过,Scala实现了一个很漂亮的类型系统,把值类型和引用类型(也就是自定义类)做了统一。它们都有一个共同的根,就是Any。由于Scala是基于JVM的,所以这些类型最后都是以Java的类来实现的。
|
||||
|
||||
那么函数也不例外。因为Scala的函数最多支持22个参数,所以Scala里有内置的Function1、Function2…Function22这些类,作为函数的类型,它们也都是Any的子类型。每个Scala函数实际上是这些类的实例。
|
||||
|
||||
另外,Swift语言的文档对类型的定义也比较清楚。它以产生式的方式列出了type的语法定义。根据该语法,类型可以是函数类型、数组类型、字典类型、元组类型等等,这些都是类型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/2a/59422fb82f57e8a877d66c6947cab22a.jpg" alt="" title="Swift语言中对类型的语法定义">
|
||||
|
||||
并且,它还把所有类型分成了两个大类别:命名类型(Named Type)和复合类型(Compound Type)。
|
||||
|
||||
- **命名类型**包括类、结构体、枚举等,它们都有一个名称,比如自定义类的类名就是类型名称。
|
||||
- **复合类型**则没有名称,它是由多个其他类型组合而成的。函数和元组都属于复合类型。函数的类型是由参数的类型和返回值的类型组合而成的,它们都是编译器对函数类型进行计算的依据。
|
||||
|
||||
举例来说,假设一个函数有两个参数,分别是类型A和B,而返回值的类型是C,那么这个函数的类型可以计为(A, B)->C。这就是对函数的类型的形式化的表达。
|
||||
|
||||
那么进一步,我们**如何在编译期里针对函数的类型做类型分析呢**?它跟非复合的类型还真不太一样,因为编译器需要检查复合类型中的多个元素。
|
||||
|
||||
举个例子。在一个高阶函数g()里,能够接收一个函数类型的参数f(A,B),要求其类型是(A, B)->C,而实际提供的函数f2的类型是(A1, B1)->C1,那么你在编译器里如何判断函数的类型是否合法呢?这里的算法要做多步的检查:
|
||||
|
||||
- 第一,f2也必须有两个参数,这点是符合的。
|
||||
- 第二,检查参数的类型。A1和B1必须是跟A和B相同的类型,或者是它们的父类型,这样f1才能正确地给f2传递参数。
|
||||
- 第三,检查返回值的类型。C1,则必须是C的子类型,这样f1才能接收f2的返回值。
|
||||
|
||||
好,说完了编译器的前端工作,我们再来看看函数在语言内部的实现。
|
||||
|
||||
### 函数的内部实现
|
||||
|
||||
在函数式编程里,所有一切都围绕着函数。但是在编译完毕以后,函数在运行时中是怎么表示的呢?
|
||||
|
||||
就像不同的面向对象的语言,在运行时是以不同的方式表示一个对象的,不同的函数式编程语言,在运行时中去实现一个函数的机制也是不太一样的。
|
||||
|
||||
- 在Python中,一切都是对象,所以函数也是一种对象,它是实现了Callable协议的对象,能够在后面加上一对括号去调用它。
|
||||
- 在Scala和Java这种基于JVM的语言中,函数在JVM这个层次没有获得原生支持,因此函数被编译完毕以后,其实会变成JVM中的类。
|
||||
- 在Julia、Swift、Go、Rust这样编译成机器码的语言中,函数基本上就是内存中代码段(或文本段)的一个地址。这个地址在编译后做链接的时候,会变成一个确定的地址值。在运行时,跳转到这个地址就可以执行函数的功能。
|
||||
|
||||
补充:再具体一点的话,**编译成机器码的函数有什么特点呢?**我们再来回顾一下。
|
||||
|
||||
首先,函数的调用者要根据调用约定,通过栈或者寄存器设置函数的参数,保护好自己负责保护的寄存器以及返回地址,然后调用函数。
|
||||
|
||||
在被调用者的函数体内,通常会分为三个部分。头尾两个部分叫做**序曲(prelude)<strong>和**尾声(epilogue)</strong>,分别做一些初始化工作和收尾工作。在序曲里会保存原来的栈指针,以及把自己应该保护的寄存器存到栈里、设置新的栈指针等,接着执行函数的主体逻辑。最后,到尾声部分,要根据调用约定把返回值设置到寄存器或栈,恢复所保护的寄存器的值和栈顶指针,接着跳转到返回地址。
|
||||
|
||||
返回到调用者以后,会有一些代码恢复被保护起来的寄存器,获取返回值,然后继续执行后面的代码。
|
||||
|
||||
这样,把上述整个过程的细节弄清楚了,你就知道如何为函数生成代码了。
|
||||
|
||||
最后,我们必须提到一种特殊情况,就是**闭包**。闭包是纯函数的对立面,它引用了上级作用域中的一些自由变量。闭包在运行时不仅是代码段中的一个函数地址,还必须保存自由变量的值。为了实现闭包的运行时功能,编译器需要生成相应的代码,以便在生成闭包的时候,可以在堆里申请内存来保存自由变量的值。而当该闭包不再被引用了,那么就会像不再被引用的对象一样,成为了内存垃圾,要被垃圾回收机制回收。
|
||||
|
||||
好了,到这里你可能会觉得,看上去函数的实现似乎跟命令式语言也没有什么不同。不过,接下来你就会看到不同点了,这就是延迟求值的实现。
|
||||
|
||||
### 延迟求值(Lazy Evaluation)
|
||||
|
||||
在命令式语言里,我们对表达式求值,是严格按照顺序对AST求值。但对于纯函数来说,由于在任何时候求值结果都是一样的,因此可以进行一定的优化,比如延迟求值(Lazy Evaluation),从而有可能减少计算工作量,或者实现像unless()那样的特别的控制结构。
|
||||
|
||||
那么针对这种情况,编译器需要做什么处理呢?
|
||||
|
||||
我举个例子,对于下面的示例程序(伪代码):
|
||||
|
||||
```
|
||||
g(condition, x){
|
||||
if (condition)
|
||||
return x;
|
||||
else return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们调用的时候,在x参数的位置传入的是另一个函数调用f(y),也就是g(condition, f(y)),那么编译器就会把g()的函数体内用到x的地方,都转换成对f(y)的调用:
|
||||
|
||||
```
|
||||
if (condition)
|
||||
return f(y);
|
||||
else return 0;
|
||||
|
||||
```
|
||||
|
||||
这种把对参数的引用替换成对函数调用的技术,叫做**换名调用**。
|
||||
|
||||
不过换名调用有一个缺点,就是f(y)有可能会被多次调用,而且每次调用的结果都是一样的。这就产生了浪费。那么这时,编译器就要更聪明一点。
|
||||
|
||||
怎么办呢?那就是在第一次调用的时候,记录下它的值。如果下次再调用,则使用第一次调用的结果。这种方式叫做**按需调用**。
|
||||
|
||||
总而言之,纯函数的特征就导致了延迟求值在编译上的不同。而函数式编程另一个重要的特征,不变性,也会对编译和运行过程造成影响。
|
||||
|
||||
### 不变性对编译和运行时的影响
|
||||
|
||||
在遵守不变性原则的情况下,对程序的编译也会有很大的不同。
|
||||
|
||||
第一,由于函数不会修改对象的状态,所以就不存在并发程序中的竞争情况,进而也就不需要采用锁的机制,编译器也不需要生成与锁有关的代码。Java、JavaScript等语言中关于参数逃逸的分析,也变得不必要了,因为反正别的线程获得了某个对象或结构体,也不会去修改它的状态。
|
||||
|
||||
第二,不变性就意味着,只可能是新的对象引用老的对象,老的对象不可能引用新的对象。这对于垃圾收集算法的意义很大。在分代收集的算法中,如果老对象被新对象引用,那必须等到新对象回收之后老对象才可能被回收,所以函数式编程的程序现在可以更容易做出决定,把老对象放到老一代的区域,从而节省垃圾收集算法的计算量;另外,由于对象不会被改变,因此更容易实现增量收集和并行收集;由于不可能存在循环引用,因此如果采用的是引用计数法的话,就没有必要进行循环引用的检测了。
|
||||
|
||||
第三,不变性还意味着,在程序运行过程中可能要产生更多的新对象。在命令式语言中,程序需要对原来的对象修改状态。而函数式编程,只能每次创建一个新对象。所以,垃圾收集算法需要能够尽快地收集掉新对象。
|
||||
|
||||
OK,了解了不变性,我们再来看看,针对函数式编程语言的优化算法。其中最重要的就是对递归函数的优化。
|
||||
|
||||
### 对递归函数的优化
|
||||
|
||||
虽然命令式的编程语言也会用到递归函数,但函数式编程里对递归函数的使用更加普遍,比如通常会用递归来代替循环。如果要对一个整型数组求和,命令式编程语言会做一个循环,而函数式编程语言则更习惯于用递归的方式表达:sum(a, i) = a[i] + sum(a, i-1)。
|
||||
|
||||
按照传统的函数调用的运行方式,对于每一次函数调用,程序都要增加一个栈桢。递归调用一千次,就要增加一千个栈桢。这样的话,程序的栈空间很快就会被耗尽。并且,函数调用的时候,每次都要有一些额外的开销,比如保护寄存器的值、保存返回地址、传递参数等等。
|
||||
|
||||
我在[第7讲](https://time.geekbang.org/column/article/248770)的优化算法里,提到过**尾调用优化**,也就是执行完递归函数后,马上用return语句返回的情况。
|
||||
|
||||
```
|
||||
f(x){
|
||||
....
|
||||
return g(...); //尾调用
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在尾调用的场景下,上一级函数的栈桢已经没什么用了,程序可以直接复用。函数调用的过程,可以被优化成指令的跳转,不需要那些函数调用的开销。
|
||||
|
||||
不过对于递归调用的情况,往往还需要对递归函数返回值做进一步的计算。比如在下面的求阶乘的函数示例中,返回值是x*fact(x-1)。
|
||||
|
||||
```
|
||||
//fact.c 求阶乘
|
||||
int fact(int x){
|
||||
if (x == 1)
|
||||
return 1;
|
||||
else
|
||||
return x*fact(x-1); //对递归值要做进一步的计算
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于编译器来说,它可以经过分析,把这种情况转换成一个单纯的尾调用。具体地说,就是它相当于引入了一个临时的递归函数fact2(),并且用第一个参数acc来记录累计值:
|
||||
|
||||
```
|
||||
int fact(x){
|
||||
if (x == 1)
|
||||
return 1;
|
||||
else
|
||||
return fact2(x, x-1); //调用一个临时的递归函数
|
||||
}
|
||||
|
||||
int fact2(int acc, int x){ //参数acc用来保存累计值
|
||||
if (x == 1){
|
||||
return acc;
|
||||
}
|
||||
else{
|
||||
return fact2(acc * x, x-1); //一个单纯的尾调用
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果我们调用fact(5),其实际执行过程就会在acc参数中连续地做乘法,从而实现阶乘:
|
||||
|
||||
```
|
||||
->fact(5)
|
||||
->fact2(5,4)
|
||||
->fact2(5*4,3)
|
||||
->fact2(5*4*3,2)
|
||||
->fact2(5*4*3*2,1)
|
||||
->5*4*3*2
|
||||
|
||||
```
|
||||
|
||||
你可以观察一下编译器实际生成的汇编程序,看看优化后的成果。如果用“clang -O1 -S -o fact.s fact.c”来编译fact函数,就会得到一个汇编代码文件。我对这段代码做了注释,你可以理解下它的逻辑。你可以发现,优化后的函数没有做任何一次递归调用。
|
||||
|
||||
```
|
||||
_fact: ## @fact
|
||||
pushq %rbp # 保存栈底指针
|
||||
movq %rsp, %rbp # 把原来的栈顶,设置为新栈桢的栈底
|
||||
movl $1, %eax # %eax是保存返回值的。这里先设置为1
|
||||
cmpl $1, %edi # %edi是fact函数的第一个参数,相当于if(x==1)
|
||||
je LBB0_3 # 如果相等,跳转到LBB0_3,就会直接返回1
|
||||
movl $1, %eax # 设置%eax为1,这里%eax会保存累计值
|
||||
LBB0_2:
|
||||
imull %edi, %eax # 把参数乘到%eax来
|
||||
decl %edi # x = x-1
|
||||
cmpl $1, %edi # x是否等于1?
|
||||
jne LBB0_2 # 如果不等,跳到LBB0_2,做连乘
|
||||
LBB0_3:
|
||||
popq %rbp # 回复原来的栈底指针
|
||||
retq # 返回
|
||||
|
||||
```
|
||||
|
||||
要想完成这种转换,就要求编译器能够基于IR分析出其中的递归结构,然后进行代码的变换。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我们一起讨论了实现函数式编程特性的一些要点。我希望你能记住这些关键知识点:
|
||||
|
||||
第一,函数式编程的理论根基,可以追溯到比图灵机更早的Lambda演算。要理解函数式编程的特点,你可以回想一下中学时代数学课中的内容。在函数式编程中,函数是一等公民。它通过强调纯函数和不变性,大大降低了程序的复杂度,使软件不容易出错,并且能够更好地支持并发编程。并且,由于采用声明式的编程风格,往往程序可以更简洁,表达性更好。
|
||||
|
||||
第二,不同的语言实现函数的机制是不同的。对于编译成机器码的语言来说,函数就是一个指向代码的指针。对于闭包,还需要像面向对象的语言那样,管理它在内存中的生存周期。
|
||||
|
||||
第三,函数仍然要纳入类型体系中,编译器要支持类型的检查和推断。
|
||||
|
||||
第四,针对函数式编程的特点,编译器可以做一些特别的优化,比如延迟求值、消除与锁有关的分析、对递归的优化等等。
|
||||
|
||||
同样,我把这一讲的知识点梳理成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/5c/018732d4dc9c1ddf9ef5e3860f9e465c.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
这节课中我提到,在很多情况下,用函数式编程表达一个计算逻辑会更简洁。那么,你能不能找到这样的一些例子?欢迎分享你的经验。
|
||||
|
||||
如果你身边也有对函数式编程感兴趣的朋友,那么也非常欢迎你把这节课分享给 TA。感谢你的阅读,下一讲我们会一起解析华为的方舟编译器,到时候再见!
|
||||
283
极客时间专栏/geek/编译原理实战课/现代语言设计篇/40 | 成果检验:方舟编译器的优势在哪里?.md
Normal file
283
极客时间专栏/geek/编译原理实战课/现代语言设计篇/40 | 成果检验:方舟编译器的优势在哪里?.md
Normal file
@@ -0,0 +1,283 @@
|
||||
<audio id="audio" title="40 | 成果检验:方舟编译器的优势在哪里?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/aa/dc5fcaec19fe920549d85e711a837faa.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。到这里,咱们的课程就已经进入尾声了。在这门课程里,通过查看真实的编译器,你应该已经积累了不少对编译器的直观认识。前面我们研究的各种编译器,都是国外的产品或项目。而这一讲呢,我们则要看看一个有中国血统的编译器:**方舟编译器**。
|
||||
|
||||
通过阅读方舟编译器已经公开的代码和文档,在解析它的过程中,你可以检验一下自己的所学,谈谈你对它的认识。比如,跟你了解的其他编译器相比,它有什么特点?先进性如何?你是否有兴趣利用方舟编译器做点实际项目?等等。
|
||||
|
||||
不过,到目前为止,由于方舟编译器开源的部分仍然比较有限,所以这一讲我们只根据已经掌握的信息做一些分析。其中涉及两个大的话题,一是对方舟编译器的定位和设计思路的分析,二是对方舟编译器所使用的Maple IR的介绍。
|
||||
|
||||
好,首先,我借助Android对应用开发支持的缺陷,来谈一下为什么方舟编译器是必要的。
|
||||
|
||||
## Android的不足
|
||||
|
||||
为什么要研发一款自己的编译器?**对于一个大的技术生态而言,语言的编译和运行体系非常重要。它处在上层应用和下层硬件之间,直接决定了应用软件能否充分地发挥出硬件的性能。**对于移动应用生态而言,我国拥有体量最大的移动用户和领先的移动应用,也有着最大的手机制造量。可是,对于让上层应用和底层硬件得以发挥最大能力的编译器和运行时,我们却缺少话语权。
|
||||
|
||||
实际上,我认为Android对应用开发的支持并不够好。我猜测,掌控Android生态的谷歌公司,对于移动应用开发和手机制造都没有关系到切身利益,因此创新的动力不足。
|
||||
|
||||
我之所以说Android对应用开发的支持不够好,这其实跟苹果的系统稍加对比就很清楚了。同样的应用,在苹果手机上会运行得更流畅,且消耗的内存也更低。所以Android手机只好增加更多的CPU内核和更多的内存。
|
||||
|
||||
你可能会问,谷歌不是也有自己的应用吗?对应用的支持也关系到谷歌自己的利益呀。那我这里其实要补充一下,我说的应用开发,指的是用**Java和Kotlin开发的应用**,这也是大部分Android平台上的应用开发者所采用的语言。而像谷歌这样拥有强大技术力量的互联网巨头们,通常对于性能要求比较高的代码,是用C开发的。比如微信的关键逻辑就是用C编写的;像手机游戏这种对性能要求比较高的应用,底层的游戏引擎也是基于C/C++实现的。
|
||||
|
||||
这些开发者们不采用Java的原因,是因为Java在Android平台上的编译和运行方式有待提高。Android为了提升应用的运行速度,一直在尝试升级其应用运行机制。从最早的仅仅解释执行字节码,到引入JIT编译机制,到当前版本的ART(Android Runtime)支持AOT、JIT和基于画像的编译机制。尽管如此,Android对应用的支持仍然存在明显的短板。
|
||||
|
||||
**第一个短板,是垃圾收集机制。**我们知道,Java基于标记-拷贝算法的垃圾收集机制有两个缺陷。一是要占据更多的内存,二是在垃圾收集的时候会有停顿,导致应用不流畅。在系统资源紧张的时候,更是会强制做内存收集,引起整个系统的卡顿。
|
||||
|
||||
实际上,Java的内存管理机制使得它一直不太适合编写客户端应用。就算在台式机上,用Java编写的客户端应用同样会占用很大的内存,并且时不时会有卡顿。你如果使用过Eclipse和IDEA,应该就会有这样的体会。
|
||||
|
||||
**第二个短板,是不同语言的融合问题。**Android系统中大量的底层功能都是C/C++实现,而Java应用只是去调用它们。比如,图形界面的绘制和刷新,是由一个叫做Skia的库来实现的,这个库是用C/C++编写的,各种窗口控件都是在Skia的基础上封装出来的。所以,用户在界面上的操作,背后就有大量的JNI调用。
|
||||
|
||||
问题是,Java通过JNI调用C语言的库的时候,实现成本是很高的,因为两种不同语言的数据类型、调用约定完全不同,又牵涉到跨语言的异常传播和内存管理,所以Java不得不通过虚拟机进行昂贵的处理,效率十分低下。
|
||||
|
||||
据调查,95%的顶级移动应用都是用Java和C、C++等混合开发的。所以,让不同语言开发的功能能够更好地互相调用,是一个具有普遍意义的问题。
|
||||
|
||||
**第三个短板,就是Android的运行时一直还是受Java虚拟机思路的影响,一直摆脱不了虚拟机。**虚拟机本身要占据内存资源和CPU资源。在做即时编译的时候,也要消耗额外的资源。
|
||||
|
||||
那么如何解决这些问题呢?我们来看看方舟编译器的解决方案。
|
||||
|
||||
## 方舟编译器的解决方案
|
||||
|
||||
方舟编译器的目标并不仅仅是为了替代Android上的应用开发和运行环境。但我们可以通过方舟是如何解决Android应用开发的问题,来深入了解一下方舟编译器。
|
||||
|
||||
我们先来看看,方舟编译器是怎么解决**垃圾收集的问题**的。
|
||||
|
||||
不过,在讨论方舟的方案之前,我们不妨先参考一下苹果的方案做个对照。苹果采用的开发语言,无论是Objective-C,还是后来的Swift,都是采用引用计数技术。引用计数可以实时回收内存垃圾,所以没有卡顿。并且它也不用像标记-拷贝算法那样,需要保留额外的内存。而方舟编译器,采用的是跟苹果一样的思路,同样采用了引用计数技术。
|
||||
|
||||
当然,这里肯定会有孰优孰劣的争论。我们之前也讲过,采用[引用计数法](https://time.geekbang.org/column/article/277707),每次在变量引用对象的时候都要增加引用计数,而在退出变量的作用域或者变量不再指向该对象时,又要减少引用计数,这会导致一些额外的性能开销。当对象在多个线程之间共享的时候,增减引用计数的操作还要加锁,从而进一步导致了性能的降低。
|
||||
|
||||
不过,针对引用计数对性能的损耗,我们可以在编译器中通过多种优化算法得到改善,尽量减少不必要的增减计数的操作,也减少不必要的锁操作。另外,有些语言在设计上也会做一些限制,比如引入弱引用机制,从而降低垃圾收集的负担。
|
||||
|
||||
无论如何,在全面考察了引用计数方法的优缺点以后,你仍然会发现它其实更适合开发客户端应用。
|
||||
|
||||
关于第二个问题,也就是**不同语言的融合问题**。华为采取的方法是,让Java语言的程序和基于C、C++等语言的程序按照同一套框架做编译。无论前端是什么语言,都统一编译成机器码,同时不同语言的程序互相调用的时候,也没有额外的开销。
|
||||
|
||||
下图是方舟编译器的文档中所使用的架构图。你能看到它的设计目标是支持多种语言,都统一转换成方舟IR,然后进行统一的优化处理,再生成机器码的可执行文件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/f4/3a33ec779e2e8b86ee375eaa197d56f4.jpg" alt="">
|
||||
|
||||
这个技术方案其实非常大胆。**它不仅解决了不同语言之间的互相调用问题,也彻底抛弃了植根于JVM的虚拟机思路。**方舟编译器新的思路是不要虚拟机,最大程度地以机器码的方式运行,再加上一个非常小的运行时。
|
||||
|
||||
我说这个技术方案大胆,是因为方舟编译器彻底抛弃了Java原有的运行方案,包括内存布局、调用约定、对象结构、分层编译机制等。我们在第二个模块讲过[Graal](https://time.geekbang.org/column/article/255730),在仍然基于JVM运行的情况下,JIT只是尽力做改良,它随时都有一个退路,就是退到用字节码解释器去执行。就算采用AOT以后,运行时可以变得小一些,但Java运行机制的大框架仍然是不变的。
|
||||
|
||||
我也介绍过,GraalVM支持对多种语言做统一编译,其中也包含了对C语言的支持,并且也支持语言之间的互相调用。但即便如此,它仍是改良主义,它不会抛弃Java原来的技术积累。
|
||||
|
||||
**而方舟编译器不是在做改良,而是在做革命。**它对Java的编译更像是对C/C++等语言的编译,抛弃了JVM的那一套思路。
|
||||
|
||||
这个方案不仅大胆,而且难度更高。因为这样就不再像分层编译那样有退路,方舟编译器需要把所有的Java语义都静态编译成机器码。而对于那些比较动态的语义,比如运行时的动态绑定、Reflection机制等,是挑战比较大的。
|
||||
|
||||
**那方舟编译器目前的成果如何呢?**根据华为官方的介绍,方舟编译器可以使安卓系统的操作流畅度提升24%,响应速度提升44%,第三方应用操作流畅度提升高达60%。这就是方舟编译器的厉害之处,这也证明方舟编译器的大胆革新之路是走对了的。
|
||||
|
||||
我们目前只讨论了方舟编译器对Android平台的改进。其实,方舟编译器的目标操作系统不仅仅是Android平台,它本质上可移植所有的操作系统,也包括华为自己的鸿蒙操作系统。对于硬件平台也一样,它可以支持从手机到物联网设备的各种硬件架构。
|
||||
|
||||
所以,你能看出,方舟编译器真的是志存高远。**它不是为了解决某一个小问题,而是致力于打造一套新的应用开发生态。**
|
||||
|
||||
好了,通过上面的介绍,你应该对方舟编译器的定位有了一个了解。接下来的问题是,方舟编译器的内部到底是怎样的呢?
|
||||
|
||||
## 方舟编译器的开源项目
|
||||
|
||||
要深入了解方舟编译器,还是必须要从它的源代码入手。从去年9月份开源以来,方舟编译器吸引了很多人的目光。不过方舟编译器是逐步开源的,由于开放出来的源代码必须在知识产权等方面能够经得起严格的审查,因此到现在为止,我们能看到的开源版本号还只是0.2版,开放出来的功能并不多。
|
||||
|
||||
我参照方舟的环境配置文档,在Ubuntu 16.04上做好了环境配置。
|
||||
|
||||
注意:请尽量完全按照文档的要求来配置环境,避免出现不必要的错误。不要嫌某些软件的版本不够新。
|
||||
|
||||
接着,你可以继续根据[开发者指南](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/files?ref=master&tab=content&filePath=doc%2FDeveloper_Guide.md&isFile=true)来编译方舟编译器本身。方舟编译器本身的代码是用C++写的,需要用LLVM加Clang编译,这说明它到目前还没有实现自举。然后,你可以编译一下示例程序。比如,用下面的四个命令,可以编译出HelloWorld样例。
|
||||
|
||||
```
|
||||
source build/envsetup.sh; make; cd samples/helloworld/; make
|
||||
|
||||
```
|
||||
|
||||
这个“hellowold”目录原来只有一个HelloWorld.java源代码,经过编译后,形成了下面的文件:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/8b/8aa0b196e426663d88b6672db7025d8b.jpg" alt="">
|
||||
|
||||
如果你跟踪查看编译过程,你会发现中间有几步的操作:
|
||||
|
||||
第一步,执行java2jar,这一步是调用Java的编译器,把Java文件先编译成class文件,然后打包成jar文件。
|
||||
|
||||
补充:java2jar实际上是一个简单的脚本文件,你可以查看里面的内容。
|
||||
|
||||
第二步,执行jbc2mpl,也就是把Java字节码转换成Maple IR。Maple IR是方舟编译器的IR,我下面会展开介绍。编译后生成的Maple IR保存到了HelloWorld.mpl中。
|
||||
|
||||
第三步,通过maple命令,执行mpl2mpl和mplme这两项对Maple IR做分析和优化的工作。这其中,很重要的一个步骤,就是把Java方法的动态绑定,用vtable做了实现,并生成了一个新的Maple IR文件:HelloWorld.VtableImpl.mpl。
|
||||
|
||||
最后一步,调用mplcg命令,将Maple IR转换成汇编代码,保存到一个以.s结尾的文件里面。
|
||||
|
||||
注意,我们目前还没有办法编译成直接可以执行的文件。当前开源的版本,既没有编译器前端部分的代码,也没有后端部分的代码,甚至基于Maple IR的一些常见的优化,比如内联、公共子表达式消除、常量传播等等,都是没有的。目前开源的版本主要展现了Maple IR,以及对Maple IR做的一些变换,比如转换成SSA格式,以便进行后续的分析处理。
|
||||
|
||||
到这里,你可能会有一点失望,因为当前开放出来的东西确实有点少。但是不要紧,方舟编译器既然选择首先开放Maple IR的设计,这说明Maple IR在整个方舟编译器的体系中是很重要的。
|
||||
|
||||
事实也确实如此。方舟编译器的首席科学家,Fred Chow(周志德)先生,曾发表过一篇论文:[The increasing significance of intermediate representations in compilers](https://link.zhihu.com/?target=https%3A//queue.acm.org/detail.cfm%3Fid%3D2544374)。他指出,IR的设计会影响优化的效果;IR的调整,会导致编译器实现的重大调整。他还提出:如果不同体系的IR可以实现转换的话,就可以加深编译器之间的合作。
|
||||
|
||||
基于这些思想,方舟编译器特别重视IR的设计,因为**方舟编译器的设计目标,是将多种语言翻译成统一的IR,然后共享优化算法和后端**。这就要求Maple IR必须要能够兼容各种不同语言的差异性才行。
|
||||
|
||||
那接下来,我们就具体看看Maple IR的特点。
|
||||
|
||||
## 志存高远的Maple IR
|
||||
|
||||
方舟开放的资料中有一个doc目录,[Maple IR的设计文档](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/file?ref=master&path=doc%2FMapleIRDesign.md)就在其中。这篇文档写得很细致,从中你能够学习到IR设计的很多思想,值得仔细阅读。
|
||||
|
||||
文档的开头一段指出,由于源代码中的任何信息,在后续的分析和优化过程中都可能有用,所以Maple IR的目标是尽可能完整地呈现源代码中的信息。
|
||||
|
||||
这里,我想提醒你注意不要忽略这一句话。它作为文档的第一段,可不是随意而为。实际上,像LLVM的作者Chris Lattner,就认为LLVM的IR损失了一些源代码的信息,而很多语言的编译器都会在转换到LLVM IR之前,先做一个自己的IR,做一些体现自己语言特色的分析工作。为了方便满足这些需求,他后来又启动了一个新项目:MLIR。你可以通过[这篇论文](https://arxiv.org/abs/2002.11054)了解Lattner的观点。
|
||||
|
||||
Maple IR则在一开头就注意到了这种需求,它提供了对高、中、低不同层次的IR的表达能力。我们在分析别的编译器的时候,比如Graal的编译器,也曾经讲过它的IR也是分层次的,但其实Graal对特定语言的高层次IR(HIR)的表达能力是不够强的。
|
||||
|
||||
HIR特别像高级语言,特定于具体语言的分析和优化都可以在HIR上进行。它的特点是提供了很多语言结构,比如if结构、循环结构等;因为抽象层次高,所以IR比较简洁。
|
||||
|
||||
与Graal和V8一样,Maple IR也用了一种数据结构来表达从高到低不同抽象层次的操作。不过不同于Graal和V8采用了图的结构,Maple IR采用的是树结构。在HIR这个层次,这个树结构跟原始语言的结构很相似。这听上去跟AST差不多。
|
||||
|
||||
随着编译过程的深化,抽象的操作被Lower成更低级的操作,代码也就变得更多,同时树结构也变得越来越扁平,最后变成了指令的列表。
|
||||
|
||||
那么,既然Maple IR是用同一个数据结构来表达不同抽象层次的语义,它是以什么来划分不同的抽象层次呢?答案是通过下面两个要素:
|
||||
|
||||
- **被允许使用的操作码**:抽象层次越高,操作码的种类就越多,有一些是某些语言特有的操作。而在最低的层次,只允许那些与机器码几乎一一对应的操作码。
|
||||
- **代码结构**:在较高的抽象层次上,树的层级也比较多;在最低的抽象层次上,会变成扁平的指令列表。
|
||||
|
||||
再进一步,Maple IR把信息划分成了两类。一类是声明性的信息,用于定义程序的结构,比如函数、变量、类型等,这些信息其实也就是符号表。另一类是用于执行的代码,它们表现为三类节点:叶子节点(常量或存储单元)、表达式节点、语句节点。
|
||||
|
||||
我用一个简单的示例程序Foo.java,带你看看它所生成的Maple IR是什么样子的。
|
||||
|
||||
```
|
||||
public class Foo{
|
||||
public int atLeastTen(int x){
|
||||
if (x < 10)
|
||||
return 10;
|
||||
else
|
||||
return x;
|
||||
}
|
||||
|
||||
public int exp(int x, int y){
|
||||
return x*3+y+1;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
示例程序编译后,会生成.mpl文件。这个文件是用文本格式来表示Maple IR,你甚至可以用这种格式来写程序,然后编译成可执行文件。当这个格式被读入方舟编译器后,就会变成内存格式。另外,Maple IR也可以表示成二进制格式。到这里,你是不是会有似曾相识的感觉,会联想到LLVM的IR?对了,LLVM也可以[用三种方式来表示IR](https://time.geekbang.org/column/article/264643)(文本格式、内存格式、二进制格式)。
|
||||
|
||||
打开.mpl文件,你首先会在文件顶部看到一些符号表信息,包括类、方法等符号。
|
||||
|
||||
```
|
||||
javaclass $LFoo_3B <$LFoo_3B> public
|
||||
func &LFoo_3B_7C_3Cinit_3E_7C_28_29V public constructor (var %_this <* <$LFoo_3B>>) void
|
||||
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32
|
||||
var $__cinf_Ljava_2Flang_2FString_3B extern <$__class_meta__>
|
||||
func &MCC_GetOrInsertLiteral () <* <$Ljava_2Flang_2FString_3B>>
|
||||
|
||||
```
|
||||
|
||||
接下来就是每个方法具体的定义了。比如,exp方法对应的IR如下:
|
||||
|
||||
```
|
||||
func &LFoo_3B_7Cexp_7C_28II_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32, var %Reg4_I i32) i32 {
|
||||
funcid 48155 #函数id
|
||||
var %Reg2_R43694 <* <$LFoo_3B>>
|
||||
var %Reg0_I i32 #伪寄存器
|
||||
var %Reg1_I i32
|
||||
|
||||
dassign %Reg2_R43694 0 (dread ref %_this)
|
||||
#INSTIDX : 0||0000: iload_1
|
||||
#INSTIDX : 1||0001: iconst_3
|
||||
dassign %Reg0_I 0 (constval i32 3)
|
||||
#INSTIDX : 2||0002: imul
|
||||
dassign %Reg0_I 0 (mul i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
|
||||
#INSTIDX : 3||0003: iload_2
|
||||
#INSTIDX : 4||0004: iadd
|
||||
dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg4_I))
|
||||
#INSTIDX : 5||0005: iconst_1
|
||||
dassign %Reg1_I 0 (constval i32 1)
|
||||
#INSTIDX : 6||0006: iadd
|
||||
dassign %Reg0_I 0 (add i32 (dread i32 %Reg0_I, dread i32 %Reg1_I))
|
||||
#INSTIDX : 7||0007: ireturn
|
||||
return (dread i32 %Reg0_I)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里我给你稍加解释一下示例代码中的IR。
|
||||
|
||||
**以func关键字开头定义一个函数。**函数名称里体现了原来Java类的名称和方法名称。public和virtual关键字也是继承自原来的Java方法,在Java中这类public的方法都是virtual的,需要动态绑定。
|
||||
|
||||
接下来要注意的是**用var声明,以%开头的三个伪寄存器**。伪寄存器相当于本地变量,它的数量是无限的,在后端做寄存器分配的时候才对应成物理寄存器。
|
||||
|
||||
在这后面的是**6个dassign语句**。这是6个赋值语句,其中的d,是直接寻址的意思。有的dassgin操作符后面跟着的是常数(constval),有的跟着的是加法(add)或乘法(mul)表达式。而加法和乘法表达式里面,又可能进一步用到其他的表达式。这里就体现出了Maple IR的特点,即它是树状结构的。
|
||||
|
||||
那么,总结起来,示例函数体现了**Maple IR最基本的结构特点**:程序被分成一个个的函数。函数里呢,是顺序的一条条语句,而每条语句都是一个树状结构,树的节点可以是叶子节点、表达式,或者其他的语句。如果把函数内的每条语句作为函数的子节点,那么整个函数就是一个树状的数据结构。
|
||||
|
||||
另外,在示例程序中,还有一些**以#开头的注释**。这些注释代表了原来class文件中的字节码。目前方舟编译器里有一个字节码的前端,能够把字节码翻译成Maple IR。这个注释就体现了字节码和Maple IR的对应关系。
|
||||
|
||||
不过,上面的示例函数并没有体现出流程控制类的语句。我们再来看一下atLeastTen()方法对应的IR。atLeastTen()方法中有一个if语句,它能否被翻译成Maple IR的if语句呢?
|
||||
|
||||
```
|
||||
func &LFoo_3B_7CatLeastTen_7C_28I_29I public virtual (var %_this <* <$LFoo_3B>>, var %Reg3_I i32) i32 {
|
||||
funcid 48154
|
||||
var %Reg2_R43694 <* <$LFoo_3B>>
|
||||
var %Reg0_I i32
|
||||
|
||||
dassign %Reg2_R43694 0 (dread ref %_this)
|
||||
#INSTIDX : 0||0000: iload_1
|
||||
#INSTIDX : 1||0001: bipush
|
||||
dassign %Reg0_I 0 (constval i32 10)
|
||||
#INSTIDX : 3||0003: if_icmpge
|
||||
brtrue @label0 (ge i32 i32 (dread i32 %Reg3_I, dread i32 %Reg0_I))
|
||||
#INSTIDX : 6||0006: bipush
|
||||
dassign %Reg0_I 0 (constval i32 10)
|
||||
#INSTIDX : 8||0008: ireturn
|
||||
return (dread i32 %Reg0_I)
|
||||
@label0 #INSTIDX : 9||0009: iload_1
|
||||
#INSTIDX : 10||000a: ireturn
|
||||
return (dread i32 %Reg3_I)
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Maple IR中,提供了if语句,其语法跟C语言或Java语言的语法差不多:
|
||||
|
||||
```
|
||||
if (<cond-expr>) {
|
||||
<then-part> }
|
||||
else {
|
||||
<else-part>}
|
||||
|
||||
```
|
||||
|
||||
像if这样的控制流语句,还有doloop、dowhile和while,它们都被叫做**层次化的控制流语句**。
|
||||
|
||||
不过,在阅读了atLeastTen()对应的IR以后,你可能要失望了。因为这里面并没有提供if语句,而是通过一个brture语句做了跳转。**brtrue被叫做平面化的控制流语句**,它在满足某个条件的时候,会跳转到另一个语句去执行。类似的控制流语句还有brfalse、goto、return、switch等。
|
||||
|
||||
补充:由于这个.mpl文件是从字节码直接翻译过来的,但字节码里已经没有HIR级别的if结构了,而是使用了比较低级的if_icmpge指令,所以方舟编译器也就把它翻译成了同样等级的brtrue指令。
|
||||
|
||||
好了,通过这样的示例,你就直观地了解了Maple IR的特点。那么问题来了,当前的开源项目都基于Maple IR做了哪些处理呀?
|
||||
|
||||
你可以打开源代码中的[src/maple_driver/defs/phases.def](https://code.opensource.huaweicloud.com/HarmonyOS/OpenArkCompiler/file?ref=master&path=src%252Fmaple_driver%252Fdefs%252Fphases.def)文件。这里面定义了一些对Maple IR的处理过程。比如:
|
||||
|
||||
- **classhierarchy**:对类的层次结构进行分析;
|
||||
- **vtableanalysis**:为实现动态绑定而做的分析;
|
||||
- **reflectionanalysis**:对使用Reflection的代码做分析,以便把它们静态化。
|
||||
- **ssa**:把IR变成SSA格式
|
||||
- ……
|
||||
|
||||
总的来说,当前对Maple IR的这些处理,有相当一部分是针对Java语言的特点,来做一些分析和处理,以便把Java完全编译成机器码。更多的分析和优化算法还没有开源,我们继续期待吧。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲我主要跟你探讨了方舟编译器的定位、设计思路,以及方舟编译器中最重要的数据结构:Maple IR。
|
||||
|
||||
对于方舟编译器的定位和设计思路,我认为它体现了一种大无畏的创新精神。与之相比,脱离不了JVM模子的Android运行时,倒有点裹足不前,使得Android在使用体验上多年来一直有点落后。
|
||||
|
||||
但在大胆创新的背后,也必须要有相应的实力支撑才行。据我得到的资料,华为的方舟编译器依托的是早年在美国设立的实验室所积累下来的团队,这个团队从2009年开始就依托编译技术做了很多研发,为内部的芯片设计也提供了一种语言。最重要的是,在这个过程中,华为积累了几百人来做编译和虚拟机的团队。前面提到的首席科学家,周志德先生,就是全球著名的编译技术专家,曾参与了Open64项目的研发。这些优秀的专家和人才,是华为和国内其他团队,未来可以在编译技术上有所作为的基础。那么,我也非常希望学习本课程的部分同学,以后也能参与其中呢。
|
||||
|
||||
对于Maple IR中分层设计的思想,我们在Graal、V8等编译器中都见到过。Maple IR的一个很大的优点,就是对HIR有更好地支持,从而尽量不丢失源代码中的信息,更好地用于分析和优化。
|
||||
|
||||
对于方舟编译器,根据已开源的资料和代码,我们目前只做了一些初步的了解。不过,只分享这么多的话,我觉得还不够满意,你也会觉得很不过瘾。并且,你可能还心存了很多疑问。比如说,Graal和V8都选择了图的数据结构,而Mapple IR选择了树。那么,在运行分析和优化算法上会有什么不同呢?我希望后续随着方舟编译器有更多部分的开源,我会继续跟你分享!
|
||||
|
||||
这节课的思维导图我也放在了这里,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/1c/0cf1bbc49f148e767f89c5e66d52e11c.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你认为,用一种IR来表示所有类型的语言的话,都会有哪些挑战?你能否通过对Maple IR的阅读,找到Maple IR是如何应对这种挑战的?欢迎在留言区分享你的观点。
|
||||
|
||||
如果你身边也有对华为的方舟编译器十分感兴趣的朋友,非常欢迎把这节课的内容分享给他,我们一起交流探讨。感谢你的阅读,我们期末答疑再见!
|
||||
119
极客时间专栏/geek/编译原理实战课/现代语言设计篇/期末答疑与总结 | 再次审视学习编译原理的作用.md
Normal file
119
极客时间专栏/geek/编译原理实战课/现代语言设计篇/期末答疑与总结 | 再次审视学习编译原理的作用.md
Normal file
@@ -0,0 +1,119 @@
|
||||
<audio id="audio" title="期末答疑与总结 | 再次审视学习编译原理的作用" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/yy/930f2c0306d924e8cbcb23f367d0c8yy.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。到这里,咱们这门课程的主要内容就要结束了。有的同学在学习课程的过程中呢,提出了他感兴趣的一些话题,而我自己也会有一些想讲的话题,这个我也会在后面,以加餐等方式再做一些补充。接下来,我还会给你出一套期末测试题,帮你检测自己在整个学习过程中的所学所得。
|
||||
|
||||
那么,在今天这一讲,我们就来做个期末答疑与总结。在这里,我挑选了同学们提出的几个有代表性的问题,给你解答一下,帮助你更好地了解和掌握本课程的知识内容。
|
||||
|
||||
## 问题1:学习了编译原理,对于我学习算法有什么帮助?
|
||||
|
||||
>
|
||||
@无缘消受人间富贵:老师,想通过编译器学算法,单独学算法总是不知道有什么意义,每次都放弃,老师有什么建议吗?但是看到评论说用到的都是简单的数据结构,编译器用不到复杂的数据结构和算法?
|
||||
|
||||
|
||||
针对这位同学提出的问题,我想谈一谈我对算法学习的感受。
|
||||
|
||||
前一阵,我在跟同事聊天时,提到了一个观点。我说,大部分的程序员,其实从来都没写过一个像样的算法。他们写的程序,都是把业务逻辑简单地翻译成代码。那么,如果一个公司写出来的软件全是这样的代码,就没有什么技术壁垒了,很容易被复制。
|
||||
|
||||
反之,一些优秀的软件,往往都是有几个核心的算法的。比如,对于项目管理软件,那么网络优化算法就很关键;对于字处理软件,那么字体渲染算法就很关键,当年方正的激光照排系统,就是以此为基础的;对于电子表格软件,公式功能和自动计算的算法很关键;对于视频会议系统,也必须掌握与音视频有关的核心算法。这样,因为有了算法的技术壁垒,很多软件就算是摆在你的面前,你也很难克隆它。
|
||||
|
||||
所以说,作为一名软件工程师,你就必须要有一定的算法素养,这样才能去挑战那些有难度的软件功能。而作为一个软件公司,其实要看看自己在算法上有多少积淀,这样才能构筑自己的技术壁垒。
|
||||
|
||||
**那么,编译原理对于提升你的算法素养,能带来什么帮助呢?**我给你梳理一下。
|
||||
|
||||
编译原理之所以硬核,也是因为它涉及了很多的算法。
|
||||
|
||||
在**编译器前端**,主要涉及到的算法有3个:
|
||||
|
||||
- **有限自动机构造算法**:这是在讲[词法分析](https://time.geekbang.org/column/article/243685)时提到的。这个算法可以根据正则文法,自动生成有限自动机。它是正则表达式工具的基础,也是像grep等强大的Linux命令能够对字符串进行模式识别的核心技术。
|
||||
- **LL算法**:这是在讲[自顶向下的语法分析](https://time.geekbang.org/column/article/244906)时涉及的。根据上下文无关文法,LL算法能够自动生成自顶向下的语法分析器,中间还涉及对First和Follow集合的计算。
|
||||
- **LR算法**:这是在讲[自底向上的语法分析](https://time.geekbang.org/column/article/244906)时涉及的。根据上下文无关文法,LR算法能自动生成自底向上的语法分析器,中间还涉及到有限自动机的构造算法。
|
||||
|
||||
总的来说,编译器前端的算法都是判断某个文本是否符合某个文法规则,它对于各种文本处理工作都很有效。有些同学也在留言区里分享,他在做全文检索系统时就会用到上述算法,使得搜索引擎能更容易地理解用户的搜索请求。
|
||||
|
||||
在**编译器后端**,主要涉及到的算法也有3个:
|
||||
|
||||
- **指令选择算法;**
|
||||
- **寄存器分配算法;**
|
||||
- **指令重排序(指令调度)算法。**
|
||||
|
||||
这三个算法也有共同点,它们都是寻找较优解或最优解,而且它们都是NP Complete(NP完全)的。简单地说,就是这类问题能够很容易验证一个解对不对(多项式时间内),但求解过程的效率却可能很低。对这类问题会采用各种方法求解。在讲解[指令选择算法](https://time.geekbang.org/column/article/274909)时,我介绍了**贪婪策略和动态规划**这两种不同的求解思路;而寄存器选择算法的图染色算法,则采用了一种**启发式算法**,这些都是求解NP完全问题的具体实践。
|
||||
|
||||
在日常工作中,我们其实也会有很多需要求较优解或最优解的需求。比如,在文本编辑软件中,需要把一个段落的文字分成多行。而如何分行,就需要用到一个这样的算法。再比如,当做一个报表软件,并且需要分页打印的时候,如何分页也是同类型的问题。
|
||||
|
||||
其他类似的需求还有很多。如果你没有求较优解或最优解的算法思路,对这样的问题就会束手无策。
|
||||
|
||||
而在**编译器的中端**部分,涉及的算法数量就更多了,但是由于这些算法都是对IR的各种分析和变换,所以IR采用不同的数据结构的时候,算法的实现也会不同。它不像前端和后端算法那样,在不同的编译器里都具有很高的一致性。
|
||||
|
||||
不过,IR基本上就是三种数据结构:树结构、图结构和基于CFG的指令列表。所以,这些算法会训练你处理树和图的能力,比如你可以在树和图中发现一些模式,以此对树和图进行变换,等等。这在你日常的很多编程工作中也是非常重要的,因为这两种数据结构是编程中最常使用的数据结构。
|
||||
|
||||
那么,总结起来,认真学好编译原理,一定会给你的算法素养带来不小的提升。
|
||||
|
||||
## 问题2:现代编程语言这么多,我们真的需要一门新语言吗?
|
||||
|
||||
>
|
||||
@蓝士钦:前不久看到所谓的国产编程语言“木兰”被扒皮后,发现是Python套层壳,真的是很气愤。想要掌握编译原理设计一门自己的语言,但同时又有点迷茫,现代编程语言这么多,真的需要再多一门新语言吗?从人机交互的角度来看,任何语言都是语法糖。
|
||||
|
||||
|
||||
关于是否需要一门新语言的话题,我也想跟你聊聊我自己的看法,主要有三个方面。当然,你也可以在此过程中思考一下,看看有没有什么跟我不同的见解,欢迎与我交流讨论。
|
||||
|
||||
**第一,编程语言其实比我们日常看到的要多,很多的细分领域都需要自己的语言。**
|
||||
|
||||
我们平常了解的都是一些广泛流行的通用编程语言,而进入到每个细分领域,其实都需要各自领域的语言。比如SaaS的鼻祖Salesforce,就设计了自己的Apex语言,用于开发商业应用。华为的实验室在研发方舟编译器之前,也曾经研发了一门语言Cm,服务于DSP芯片的研发。
|
||||
|
||||
**第二,中国技术生态的健康发展,都需要有自己的语言。**
|
||||
|
||||
每当出现一个新的技术生态的时候,总是有一门语言会成为这个技术生态的“脚本”,服务于这个技术生态。比如,C语言就是Unix系统的脚本语言;JavaScript、Java、PHP等等,本质上都是Web的脚本语言;而Objective-C和Swift显然是苹果设备的脚本语言;Android虽然一开始用了Java,但最近也在转成Kotlin,这样Google更容易掌控。
|
||||
|
||||
那么,从这个角度看,当中国逐步发展起自己的技术生态的时候,也一定会孕育出自己的语言。以移动计算生态而言,我们有全球最大的移动互联网用户群和最丰富的应用,手机的制造量也是全球最高的。而位于应用和硬件之间的应用开发平台,我们却没有话语权,这会使中国的移动互联网技术生态受到很大的掣肘。
|
||||
|
||||
我在[第40讲](https://time.geekbang.org/column/article/286097),也已经分析过了,Android系统经过了很多年的演化,但技术上仍然有明显的短板,使得Android平台的使用体验始终赶不上苹果系统。为了弥补这些短板,各个互联网公司都付出了很大的成本,比如一些头部应用的核心功能采用了C/C++开发。
|
||||
|
||||
并且,Android系统的编译器,在支持新的硬件上也颇为保守和封闭,让中国厂商难以参与。这也是华为之所以要做方舟编译器的另一个原因。因为华为现在自研的芯片越来越多,要想充分发挥这些芯片的能力,就必须要对编译器有更大的话语权。方舟编译器的问世,也证明了我们其实是有技术能力的,可以比国外的厂商做得更好。既然如此,我们为什么要受别人的制约?华为方舟编译器团队其实也很渴望,在方舟编译器之后推出自己的语言。至于华为内部是否已经立项,这就不太清楚了,但我觉得这是顺理成章的事情。
|
||||
|
||||
另外,除了在移动端的开发上会受到很多掣肘,在云端其实也一样。比如说,Java是被大量后端开发的工程师们所掌握的语言,但现在Java是被Oracle掌控的。你现在使用Java的时候,可能已经多多少少感受到了一种不愉快。先不说Java8之后的收费政策,就说我们渴望的特性(如协程、泛型中支持基础数据类型等),一直没有被满足,就会感觉不爽。
|
||||
|
||||
我在讲到[协程](https://time.geekbang.org/column/article/280269)的时候,就指出Java语言目前支持协程其实是很别扭的一种状态,它都是一些第三方的实现,并没有官方的支持。而如果Java的技术生态是由我们主导,可能就不是这样了。因为我国互联网的并发用户数如此之多,我们对更好的并发特性其实是更关切的。到目前为止,像微信团队解决高并发的问题,是用C++加上自己开发的协程库才实现的。而对于很多没有如此强大的技术能力的公司来说,就只能凑合了。
|
||||
|
||||
**第三,实现一款优秀的软件,一定会用到编译技术。**
|
||||
|
||||
每一款软件,当发展到极致的时候,都会变得像一个开发平台。这也是《黑客与画家》的作者保罗·格雷厄姆(Paul Graham)表达的思维。他原来的意思是,每个软件写到最后,都会包含一个Lisp的变种。实际上,他所要表达的意思就跟我说的一样。
|
||||
|
||||
我前一段时间,在北京跟某公司的老总探讨一个优秀的行业应用软件。这个软件在上世纪90年代就被开发出来了,也被我国广泛采用。一方面它是一个应用软件,另一方面它本身也是一个开发平台。所以它可以经过定制,满足不同行业的需求。
|
||||
|
||||
但是,我们国内的软件行业的情况是,在去客户那里实施的时候,几乎总是要修改源代码,否则就不能满足用户的个性化需求。
|
||||
|
||||
很多软件公司想去克隆一下我刚才说的那套软件,结果都放弃了。除了有对领域模型理解的困难以外,缺少把一个应用软件做成软件开发平台的能力,是其中很大的一个障碍。
|
||||
|
||||
实际上,目前在很多领域都是这样。国外的软件就是摆在那里,但中国的工程师就是做不出自己的来。而对于编译技术的掌握和运用,就是能够提升国内软件水平的重要途径。
|
||||
|
||||
我在开头跟同事交流的时候,也提出了软件工程师技术水平修养提升的几个境界。其中一个境界,就是要能够利用编译技术,做出在更大范围内具有通用性的软件。如果你能达到这个境界,那么也一定有更大的发展空间。
|
||||
|
||||
## 问题3:如何判断某门语言是否适合利用LLVM作为后端?
|
||||
|
||||
>
|
||||
@ヾ(◍°∇°◍)ノ゙:老师,很多语言都声称使用LLVM提升性能,但是在Lua领域好像一直是LuaJIT无法超越?
|
||||
|
||||
|
||||
这个问题涉及到了如何利用后端工具的问题,比较有代表性。
|
||||
|
||||
LLVM是一个通用的后端工具。在它诞生之初,首先是用于支持C/C++语言的。所以一门语言,在运行机制上越接近C/C++语言,用LLVM来做后端就越合适。
|
||||
|
||||
比如Rust用LLVM就很成功,因为Rust语言跟C/C++一样,它们的目标都是编写系统级的程序,支持各种丰富的基础数据类型,并且也都不需要有垃圾收集机制。
|
||||
|
||||
那么,如果换成Python呢?你应该记得,Python不会对基础数据类型进行细粒度的控制,不需要把整型区分成8位、16位、32位和64位的,它的整型计算可以支持任意长度。这种语义就跟C/C++的相差比较远,所以采用LLVM的收益相对就会小一些。
|
||||
|
||||
而对于JavaScript语言来说,浏览器的应用场景要求了编译速度要尽量地快,但在这方面LLVM并没有优势。像我们讲过的隐藏类(Shapes)和内联缓存(Inline Caching)这样的对JavaScript很重要的机制,LLVM也帮不上忙。所以,如果在项目时间比较紧张的情况下,你可以暂时拿LLVM顶一顶,Safari浏览器中的JavaScript引擎之前就这么干过。但是,要想达到最好的效果,你还是编写自己的后端更好一些。
|
||||
|
||||
那对于Lua语言,其实你也可以用这个思路来分析一下,是采用LLVM,还是自己写后端会更好一些。不过,由于Lua语言比较简单,所以实现后端的工作量应该也相对较小。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我主要回答了几个比较宏观的问题,它们都涉及到了编译原理这门课的作用。
|
||||
|
||||
第一个问题,我是从提升算法素养的角度来展开介绍的。编译原理知识里面涉及了大量的算法,我把它总结成了三大类,每类都有自己的特点,希望能对你宏观把握它们有所帮助。
|
||||
|
||||
第二个问题,其实是这门课程的一条暗线。我并没有在课程里去情绪化地鼓吹,一定要有自己的编译器、自己的语言。我的方式其实是想做一点具体的事情,所以在第二个模块中,我带着你一起探究了现有语言的编译器都是怎么实现的,破除你对编译器的神秘感、距离感;在第三个模块,我们又一起探讨了一下实现一门语言中的那些关键技术点,比如垃圾收集、并行等,它们都是如何实现的。
|
||||
|
||||
在课程最后呢,我又带你了解了一下具有中国血统的方舟编译器。我想说的是,其实我们不但能做出编译器和语言来,而且可能会做得更好。虽然我们对方舟编译器的分析还没有做完,但通过分析它的技术思路,你应该或多或少地感受到了它的优秀。所以,针对“我们真的需要一门新语言吗”这个问题,我的回答是确定的。并且,即使你不去参与实现一门通用的语言,在实现自己领域的语言,以及把自己的软件做得更具通用性这点上,编译原理仍然能发挥巨大的作用,对你的职业生涯也会有切实的帮助。
|
||||
|
||||
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。
|
||||
Reference in New Issue
Block a user