mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 06:33:48 +08:00
del
This commit is contained in:
155
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐1 | 远程办公,需要你我具备什么样的素质?.md
Normal file
155
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐1 | 远程办公,需要你我具备什么样的素质?.md
Normal file
@@ -0,0 +1,155 @@
|
||||
<audio id="audio" title="不定期加餐1 | 远程办公,需要你我具备什么样的素质?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/66/2b/667b7c82116a5961a1f9a7749637652b.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。到这里,咱们课程的第一模块“预备知识篇”就已经更新完了。通过这么多讲的学习,这些编译技术的核心基础知识,你掌握得怎么样了呢?是不是感觉自己已经构建了一个编译原理的知识框架了?
|
||||
|
||||
不过我也知道,要理解编译技术的这些核心概念和算法,可能不是一件很容易的事儿,在跟随我一起探索编译之旅的过程中,估计也耗费了你不少的脑细胞,那咱们是时候来轻松一下了。
|
||||
|
||||
今天,我就想借着这个加餐的环节,跟你聊一聊一个很有意思的话题:**远程办公**。
|
||||
|
||||
之所以选择这个话题,主要有两方面的原因。
|
||||
|
||||
首先,由于疫情的影响,春节之后,很多公司都采取了远程办公的方式。所以,如何在远程办公的情况下做好工作,对于员工和公司来说,都是一个挑战。
|
||||
|
||||
第二个原因,是我个人一直对于远程办公这种工作模式很感兴趣,这些年来也一直在做这方面的思考,关注这方面的实践,所以有了一些心得体会,想跟你分享一下。
|
||||
|
||||
不过,要想把远程办公这个话题聊清楚,确实不容易,分歧也比较大。有一些朋友会比较悲观,觉得远程办公根本不切实际;而另一些朋友则会很乐观,觉得远程办公马上就会普及。
|
||||
|
||||
今天,我就来分享一下我看待远程办公的一些视角。我会从公司和员工这两个角度,来分析远程办公带来的机遇和挑战,希望能给你带来一些启发,让你以更积极和务实的姿态,迎接远程办公的浪潮,甚至在这种工作模式转换的趋势下,抓住更多的发展机遇。
|
||||
|
||||
首先,我来聊一聊远程办公的那些吸引人的地方。
|
||||
|
||||
## 远程办公的好处
|
||||
|
||||
我对远程办公的了解,最早是透过开源圈的朋友了解了一些故事,后来自己也接触了一些酷酷的公司。很多做开源软件产品和技术服务的公司都是远程办公的,他们的员工可能来自世界各地。我曾经接触过一个芬兰公司的CEO,他们主要做嵌入式Linux的技术服务。一百多人的公司,平常办公室是没什么人的。据说他们公司有的员工,可以一边上班,一边去全世界旅游。
|
||||
|
||||
我当时认为,一百多人的公司,全部都能远程办公,并且管理良好,就已经很不错了。但后来看了[一篇文章](http://www.geekpark.net/news/255260),讲到WordPress的母公司Automattic有上千名员工,分布在全球75个国家,全部都是远程办公。这就有点令人吃惊了!我才意识到,在互联网时代,原来远程办公可以适用于任何规模的企业。
|
||||
|
||||
这次疫情中,IT领域的很多公司都大量地采用了远程办公模式,包括谷歌、Facebook、微软等大型企业。
|
||||
|
||||
现在新闻上说,疫情之后,世界再也回不到过去了。其实我觉得,在很多领域,我倒是宁愿它回不去了。比如,远程教育;再比如,远程工作。
|
||||
|
||||
因为远程,意味着你获得了一个难得的自由:**位置自由**。
|
||||
|
||||
现代社会,我们苦“位置”久已!因为很多资源都是跟位置绑定在一起的,比如说,教育资源与学区房。
|
||||
|
||||
我在北京的很多朋友,他们在孩子上学期间,一直都是租房子住的,因为要住得离孩子学校近,而自己的房子会租出去。这种状态要持续到孩子上大学为止。
|
||||
|
||||
而对于若干都市白领来说,在大城市上班,就意味着要把整个肉体在办公室和家之间移动,所以每天可能要在路上花两三个小时,很多时候还会在路上堵个半天。
|
||||
|
||||
如果我们真的获得了位置自由,那么整个生活的幸福指数会提高一大截吧!
|
||||
|
||||
对于远程教育来说,我比较希望见到的现象,是在偏远的乡村,一样能够通过线上教育获得最优质的知识资源。至于线下的老师,更多的是关注孩子的健康成长,多带着孩子亲近大自然,扮演“育”的角色,而不是“教”的角色。
|
||||
|
||||
工作也是一样。现在越来越多的工作,都可以在网上进行了。互联网电商的发展,虽然让一些线下店铺的营业状况受到了影响,但只要能通网络,很多人在网上就可以卖东西了呀。另外,随着外卖的兴起,很多餐饮企业也不再需要临街的店面了。
|
||||
|
||||
所以,通过远程办公,我们可能就不需要北漂、深漂等各种漂了,可以选择离自己的亲人更近一些,或者可以反过来,四海为家。并且,你还可能获得更多、更好的工作机会,你可以从全世界的公司里选择你喜欢的那份工作,并且也不需要离开你喜欢居住的地方。
|
||||
|
||||
并且,伴随着位置自由,往往也会给我们带来**时间自由**。因为远程后不再需要按时上班打卡了(很多在全球都招揽人才的公司,大家的作息时间都不一样,当然不可能统一打卡),所以管理体系会更加面向绩效,而不会管你到底是在什么时间来完成这些工作的(通常也没法管理)。这就意味着,你可以在家人需要你的时候,出现在他们身边(比如接孩子),然后选择自己合适的时间段来工作。
|
||||
|
||||
上面说的是远程办公对员工的好处。从企业的角度来看,远程办公其实也会带来一些潜在的好处。
|
||||
|
||||
**首先,有些员工可能会在工作上做更多的投入**(这跟某些员工会偷懒恰恰相反,所以可能出现两极分化)。这些人很享受自己的工作,每天上下班反倒降低了他可能做出的贡献。如果公司有一套良好的管理体系,那就可能会因此筛选出更适合自己的员工,而避开那些混日子、划水的员工,整个团队的素质反倒会得到提高。
|
||||
|
||||
我曾经跟MySQL的前CEO Mårten Mickos聊天。我问他,**管理远程办公的员工,需要注意些什么?**
|
||||
|
||||
他思考了一下,说要建议员工跟家人一起住,至少要养条狗什么的。因为家人能帮助管理这些极客们的作息。不然由着这些极客们的性子,他们会昏天黑地、毫无规律地作息,不利于健康。就算养条狗,你也会因为要照料它们,而让自己的生活节奏健康一点。
|
||||
|
||||
他的回答其实出乎我的意料,我原本以为他会说什么公司的管理措施之类的。你体会一下,如果你是公司老板,你是不是会因为拥有这样热爱工作的员工而感到欣慰呢?
|
||||
|
||||
**第二,因为没有了地域限制,公司也就可以充分任用全球各地的人才。**这个方面在很多做开源软件的公司那里,得到了很好的体现。如果你喜欢某个开源产品,在社区里贡献自己的力量,那你很可能就会被邀请加入到该公司。
|
||||
|
||||
在互联网时代,企业的组织方式也正在重构。滴滴打车、美团外卖这些采用新雇佣方式的公司,不但可以更好地利用各地的人力资源,TA们也提供了一些自由工作的机会。
|
||||
|
||||
**第三,没有了地域的限制,公司也可能更容易拓展自己的市场。**这个好处也是显而易见的,就不用我多说了。
|
||||
|
||||
## 远程办公的挑战
|
||||
|
||||
上面是我对远程的一些美好的憧憬。还是回到现实吧,因为更改现有的教育体制,可能是很难的。而让企业老板们改变公司的管理方式,难度也不低。
|
||||
|
||||
老板们都是理性的。真金白银投入做企业,是要见到效益的。可是,**如何能保证采用远程办公模式,不会让企业变成一团散沙,纪律涣散、效率低下呢?**
|
||||
|
||||
你可以问问,在春节后不得已实行远程办公的企业,对经营有没有产生影响。
|
||||
|
||||
说实话,在没做好充分的准备之前,仓促地采用远程办公,肯定会产生各种不适。
|
||||
|
||||
**因为远程办公,对于管理体系,有着更高的要求。**很多工作是难以直接度量绩效的,比如说研发工作就比销售工作更难衡量绩效。
|
||||
|
||||
而没有良好的管理体系,仅凭员工的自觉,是不可能产生良好的效果的。其实,硅谷有一些IT公司很早就实行过远程办公,但后来又取消了,原因就是绩效不理想,混日子的员工太多。
|
||||
|
||||
反过来,站在员工的角度,你真能做好自己的工作管理吗?在办公室工作的时候,迫于同事们的目光,你总得做点事情吧。可是,如果脱离了直接的监督,有多少人能够真正管好自己呢?
|
||||
|
||||
好,你说你的自我管理能力强,那么请问,有多少人能控制住自己每天刷手机的时间呢?据说,超过50%的成年人,都有手机上瘾症。在办公室的时候,尚且见缝插针地刷手机。如果在家办公,又会怎样呢?
|
||||
|
||||
有过远程工作经历的人,都会经历这么一个时期。即使是你很有责任心、很有事业心,但也要每天花费很多的精力来管理自己的行为。我认识的一个朋友,她在IT行业,主要做售前支持工作。之前跟她闲聊的时候,她说自己花了3年的时间跟自己搏斗,才养成了良好的居家工作习惯。而管理自己这件事情,也是消耗注意力的。注意力本身,又是个稀缺资源。所以在初期,你会觉得,对比在办公室里,居家办公会更累,在公司你不需要花精力来控制自己的行为,因为环境和同事帮忙做了这件事情,实际上节省了你的注意力。
|
||||
|
||||
我也听说,有的工程师会在网上直播自己编码的过程。这样做的一个原因,就是为了帮助管理自己的行为,因为这时候你必须更加集中注意力在自己的工作上。
|
||||
|
||||
**还有一个是办公环境的因素。**我们中国人的居住状态比较拥挤,在自己家里开辟出一个安静的、不被打搅的书房并不容易,这可能还跟中国的文化有关。而西方的文化,可能会更尊重个人的空间。
|
||||
|
||||
再说了,我们跟外国人的居住条件也确实不同。西方发达国家很早就开始了郊区化发展,大部分人会住在郊区和小城镇,自然环境比较好。而我们中国呢,大部分住在小区的楼房里。
|
||||
|
||||
不过,如果真的远程工作了,你也可以不住在大城市呀。网上有些视频经常会吸引我,某夫妇在乡村翻新出一栋漂亮的别墅,还拥有美丽的花园,等等。其实我目前就住在一个自然环境良好的山上,只不过从这个村子去办公室也很方便就是了。
|
||||
|
||||
**远程办公还会产生心理上的挑战:白天晚上都在家里,会容易心理疲劳。**而换个环境,反倒会让人兴奋起来。我就有个感觉,在家里工作久了,效率就会降低。而这时候再回公司工作的话,反倒更容易集中注意力。
|
||||
|
||||
而且,**远程办公肯定也会降低沟通的效率。**一些互联网公司,在设计办公室的时候,会故意设计一些空间,方便大家偶遇,闲聊几句。而做研发工作的同学都知道,这种看似随意的交流,有时候能激发出很多创新的思维。而如果总是自己苦思冥想,往往很快就会走入死胡同。这种线下偶遇式的沟通,往往见到了就会聊个几句。但在远程办公时,如果大家互相见不到面,还真就不聊了。
|
||||
|
||||
## 面对远程办公,我们要做好什么准备?
|
||||
|
||||
所以,我们需要实行一些积极的操作,来更好地应对远程办公给我们带来的挑战,这样也能更好地抓住远程办公给我们带来的机遇。
|
||||
|
||||
### 从公司的角度出发
|
||||
|
||||
那首先,我们来看看,对于企业来说,都需要做好什么准备。
|
||||
|
||||
**第一,我觉得企业管理者要建立一个意识:远程办公是企业必须要面对的管理考验。**
|
||||
|
||||
其实只要企业做大了,几乎都要面对远程管理的问题。比如你有了分公司,或者在各个城市设门店,甚至把生意做到国外。那么,突破地域的限制拓展业务,本来就是对企业能力的考验,是企业发展中必须踏过的门槛。
|
||||
|
||||
所以说,企业也一样需要获得位置自由。这些分公司、派出机构工作的人员,对于总部来说,本来就是远程工作的。有了这个意识,管理者就会开始放弃旧的思维,拥抱远程办公。
|
||||
|
||||
**第二,从看住人,转换到管绩效。**
|
||||
|
||||
很多比较传统的企业,他们的绩效标准都比较模糊,所以在远程办公的形势下,我们需要把绩效标准的清晰化、准确化放到第一位。像滴滴、外卖这些新职业,之所以能够迅速扩展规模,充分利用社会化人力资源,就是因为他们的工作绩效的标准是清晰而准确的。
|
||||
|
||||
**第三,建立拥抱远程办公的文化,给员工授权和赋能。**
|
||||
|
||||
像软件研发类的工作,它是知识密集型的,对员工的绩效评估比较难,人员更换的成本也相对较高。那么对于这类工作,我们可以多向那些开源软件公司学习,建立一个拥抱远程办公的公司文化,去吸引那些对工作充满兴趣和热爱的人参与进来。这些人,也会更加珍视公司给予的授权和自由。
|
||||
|
||||
**第四,充分利用IT技术。**
|
||||
|
||||
管理,一定要落实在工具上。我接触的那家芬兰公司,就花了很多年的时间,积累了一套成熟的内部管理系统。比如说,作为软件公司,你肯定要对项目进度、代码量、Bug数等基础指标有所管理才行吧?
|
||||
|
||||
信息技术成本的降低,也大大降低了远程管理的开销。这次疫情,促进了视频会议在全世界的普及。对于中国的中小企业来说,甚至可以0成本享受高品质的远程会议服务,这真是一个了不起的福利!
|
||||
|
||||
### 从员工的角度出发
|
||||
|
||||
OK,说完了公司,那我们再来看看,从员工的角度出发,我们都要具备什么素质,才能更好地迎接远程办公模式。
|
||||
|
||||
**第一,员工也要建立一个意识:无论是否远程办公,都要向绩效负责,管理好自己的工作。**
|
||||
|
||||
即使你仍然在传统的办公模式下工作,如果你能像一个远程工作者那样对绩效负责,管理好自己的注意力,我想你很快就会获得领导的注意,从而赋予你更大的工作自由。你有没有听说过,张小龙经常睡懒觉迟到,而马化腾从来不管他?因为马化腾需要的是一个能做出微信来的张小龙,而不是一个每天按时打卡的张小龙。
|
||||
|
||||
**第二,正视远程办公对自我管理的高要求,养成良好的工作习惯。**
|
||||
|
||||
在办公室工作,会有环境来约束你。而当真的给了你位置自由以后,你其实要珍视这种自由,给自己定一些规矩,甚至给自己找一些监督(就像前面说的在网上直播),从而养成良好的工作习惯。
|
||||
|
||||
**第三,建立激进的协作习惯。**
|
||||
|
||||
由于远程工作对于协作的挑战,你必须建立激进的协作习惯,而不是仅仅停留在我们目前使用即时通讯工具和视频会议工具的习惯上。比如,你可以全时间视频在线、主动找人线上闲聊一小会儿、主动创造一些与人沟通的机会,等等。
|
||||
|
||||
**第四,可能是最重要的:为兴趣而工作,为自己而工作。**
|
||||
|
||||
人在没有很多督促的情况下,真正能驱动自己前行的动力,就是兴趣了。这个时候,你会把工作看作是促进自己成长的必要因素,从工作中成长,从成长中获得快乐。这个时候,你已经不是在为公司工作,而是为自己而工作。这样的人,才算获得了真正的自由。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我们讨论了远程办公对公司和员工的好处、挑战,以及我们需要做好的准备。我讲了两个主要的观点。第一个观点是对企业的:远程办公管理能力是企业未来必须具备的能力。第二个观点是对个人的:只有能够管理好自己的人,才能抓住远程办公带来的机遇。
|
||||
|
||||
那总体来说呢,信息技术的进步是不可阻挡的,它对于工作和生活方式的重塑,也绝不会停止。所以,远程办公一定会越来越成为现实,不管是对于企业还是员工,我们都要积极拥抱这样的未来。
|
||||
|
||||
## 讨论一下
|
||||
|
||||
**你有想过,有一天会通过远程办公的方式上班吗?远程办公有哪些地方会吸引你?你有通过远程办公的方式上班的朋友吗?你觉得,为什么远程办公现在还没有真正普及开呢?**你可以在留言区里,跟大家交流交流你的看法。
|
||||
|
||||
感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。
|
||||
156
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐2 | 学习技术的过程,其实是训练心理素质的过程.md
Normal file
156
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐2 | 学习技术的过程,其实是训练心理素质的过程.md
Normal file
@@ -0,0 +1,156 @@
|
||||
<audio id="audio" title="不定期加餐2 | 学习技术的过程,其实是训练心理素质的过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/36/f3/36188878f0byyd7f4e9e7af8fef568f3.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
最近,高考刚刚结束。每年一度的高考都牵动了很多人的心,学生和家长们都把高考看作是人生的一大关键关口。可是,等上了大学以后呢?很多同学也会感到不适应,因为缺少了一个像高考那样明确的学习目标,也没有老师和家长在旁边不停地鞭策和关注。到了工作以后,就更是如此了。
|
||||
|
||||
对于进入计算机领域的人而言呢,很多人迫于找一份好工作的压力,会刻苦学习一段时间,包括参加各种学习班。而一旦获得了一份工作,融入职场以后,很容易就进入舒适区。**反正当前的工作也能应付过去,为什么还要费力再去学呢?毕竟,工作已经够辛苦了。**
|
||||
|
||||
在这种情况下,人生的第二次转折点就出现了。
|
||||
|
||||
有的人,能够管理好自己,充分利用各种时间和机会,不断地加深自己对技术的理解。虽然短时间看上去进步并不大,但成年累月地积累下来,效果就逐渐出现了,TA们开始能够胜任一些关键岗位,成了技术领头人。而另一些人,则只能掌握那些比较容易掌握的技术,时间一长就会显得平庸,等年轻人赶上来的时候,就更加没有竞争优势了。虽然这不是像高考一样,能马上分出重点大学和普通大学的差别来,但在进入职场5年、10年以后,这两类人在发展上的差别并不比高考带来的差别小。
|
||||
|
||||
我说这些,不是在贩卖焦虑,而是想引出我们今天要讨论的话题:**从心理的角度看待学习技术的过程**。特别是自己管理自己学习的过程、跟自己相处的过程。
|
||||
|
||||
学习没有轻松的。尤其是学习像编译原理这样的基础技术,就会显得挑战更大。想要学好它,调整和保持一个良好的心态是非常重要的。而通常,我们在心理上会面对三大问题:
|
||||
|
||||
- 第一,我为什么要学习这么难的技术?学一些比较容易的、应用层面的技术不就行了吗?**这是学习的目的和动力问题**。
|
||||
- 第二,以我的能力,能学会这么难的技术吗?**这是自信心和勇气的问题**。
|
||||
- 第三,如何看待学习中经常遇到的挫折?总是找不到时间怎么办?等等。**这是学习过程中的心态调节和习惯养成问题**。
|
||||
|
||||
如果对这三方面的问题,你都获得了清晰的答案,那么你应该就能保持好持续学习、终生学习的心态,从而对自己的人生有更好的掌控力。
|
||||
|
||||
那接下来,我就给你解读一下,我对于这三类问题的理解。
|
||||
|
||||
首先,我们来说说**学习目的**问题。
|
||||
|
||||
## 为什么要学这么难的技术?
|
||||
|
||||
在做课程设计的时候,我和编辑同学都会尽量想着如何让这样的基础技术和你的日常工作关联起来,让你觉得它不是屠龙之术,而是能够在日常工作中发挥实际效用的。这确实是学习基础技术的收获之一。
|
||||
|
||||
不过,如果想长期坚持下去,我会建议你把心态调整成一种更高级的模式。用中国文化中的一句话来形容,就是“用出世的态度,做入世的事情”。如果一件事情你觉得应该去做,那就去做,不要太斤斤计较一些功利层面的东西。
|
||||
|
||||
那么对于学计算机而言,什么是我们应该去做的呢?那当然是要了解计算机领域的那些最基础的原理呀。如果搞了一辈子IT技术,却不明白其中的道理,那岂不是一辈子活得稀里糊涂的?
|
||||
|
||||
我知道,大部分人不注重基础性知识的原因,可能是觉得它们不会马上发挥作用。可是,**那些最重要的知识、那些构成你知识结构的底蕴的东西,往往就是那些看上去不会马上有用的东西**。
|
||||
|
||||
我个人非常欣赏复旦大学做教育的一种态度,就是教给学生**无用之学**。哲学、艺术、写作、演讲、逻辑学、历史等知识,在西方教育中被称作Liberal Arts,我们有时候翻译成通识教育,或者博雅教育。这些教育对于我们从事任何专业的工作,都是有用的。
|
||||
|
||||
比如说,美学素养。一个设计良好的系统架构,一定是优美的。新东方的元老之一王强,在美国学习计算机的时候,会把写完的程序拉开一定的距离看。如果看上去不够美观,那一定是程序设计得不够好。
|
||||
|
||||
你乍一听上去,可能会感觉是无稽之谈,但有经验的程序员一定会认同这个看法。那些写得有问题的程序,往往本身就是又臭又长、非常难读;而高质量的程序,往往是模块划分清晰、简洁易读的。做不出好的系统设计的人,肯定美学素养也不够高。像爱因斯坦等大科学家,往往驱动他们做出某个研究成果的动力,就是想去发现一条更加简洁、更具优美感的公式,因为真理往往是简洁的、优美的。
|
||||
|
||||
我之前公司的一名股东,他以前是一位很厉害的软件工程师,后来被一个外企挖走,担任了多年的销售副总。挖他去外企的原因,就是因为当时该外企刚开始在中国推广中间件的概念,他听了介绍以后就说,那不就跟我写的某软件的原理是一样的吗?并且一下子就说出了这类软件的关键技术点。于是,该外企下定决心要把他挖过去,并且是去负责销售。去年,他突然又写了一套科幻小说,名称是《云球》。我这里不是为他打广告,我是想说,做一个优秀的软件工程师、担任销售副总和小说家,这三个职业从表面上看相差很大,但其实背后依赖的基础素质都是一样的,都是那些乍一看上去没用的基础知识、基础素质。
|
||||
|
||||
所以,从这个角度,我是同意素质教育的理念的。**一个缺乏美学素养、哲学素养和沟通能力等素质的软件工程师,潜力可能是有限的。**
|
||||
|
||||
说到基础素养,我补充一个例子。有一次,我和前面说到的这位朋友在一起聊天,结果一个软件公司的老总给我们吹嘘他们公司开发的某软件平台。在说到一些特性的时候,听得我俩目瞪口呆。后来我们告诉这位老总,他声称的东西违背了基本的物理学和信息学的规律。在违背科学的底层规律的方向上做事情,那就相当于去造永动机,根本是虚妄的。这是很多项目失败的根本原因。
|
||||
|
||||
而另一些人,却具备抓住事情本质的能力。众所周知,马云并不懂技术。但就是不懂技术的马云,在懂技术的马化腾、李彦宏都认为云计算不是趋势,只不过是新瓶装旧酒的时候,果断拍板做云计算技术。期间,来自内部的反对声一直很强,大家都不愿意在内部使用尚不成熟的云计算技术。然而时间证明,马云的眼光更准。并且,力主开发云计算技术的王坚博士,他自己的专业也不是计算机专业。那么,为什么一拨非科班人士会比科班的技术大佬们看问题还准呢?我想可能是他们的无用之学学得更好,基础素质更全面吧。
|
||||
|
||||
所以,这就是我对于像编译原理、操作系统、算法等基础知识的态度。你就把它们看做是无用之学好了。我不仅鼓励你把这些基础知识学明白,并且我也希望你可以尽量再往深里挖一挖。比如,像图灵那样去思考一下,计算的本质到底是什么;编译原理用到的形式语言,也可以被继续深挖,从而跟整个西方科学体系底层的形式逻辑体系挂钩,以此去深入地理解希尔伯特猜想和哥德尔定理;了解面向对象、函数式编程这样的编程范式,跟人类的认知和思维模式的关系,跟Lamda计算、范畴论等数学工具的关系;你还可以去了解复杂科学领域的成果,并用这样的思维去看待大型复杂的信息系统。
|
||||
|
||||
如果你觉得编译原理这样的技术没啥用,那你一定会觉得我刚才说的那些更加没用。但我知道,一个优秀的软件工程师,其实一定是对我说的那些话题有所涉猎、有兴趣的。
|
||||
|
||||
总结起来,**一个人的基础素质,决定了他的思维方式、思维质量和眼光,那些看上去没用的基础知识、基础原理,其实是真正做大事、承担重任所需要的素质。**那,你到底要不要去学习呢?
|
||||
|
||||
好,如果你认可我的观点,那么我们接下来再探讨第二个话题,关于**学习的信心**问题。
|
||||
|
||||
## 我能学得会吗?
|
||||
|
||||
很多人都会有一个担心,说某些基础技术、基础原理太难,自己的基础又不够好,那么能学得会吗?如果学了半天又学不会,那不是白费力气吗?
|
||||
|
||||
从能力角度,我必须承认,**我们每个人都是有天赋的差异的**。你让一个普通人去跟姚明比赛打篮球,那不是难为人吗?
|
||||
|
||||
**学习这件事情也一样有天赋的问题**。
|
||||
|
||||
我本人当年在高考的时候,是省里的前几名,但是等我到了北大,看到周围的同学通常也都是身手不凡;在记忆力方面,我也比不过很多同学,有的同学对普通的词汇书根本不感兴趣,会去背词典,甚至背专业领域的词典;在数学等需要逻辑思维的领域,我又比不过另一些同学,直到今天,对于一些涉及数学的问题,我都会去咨询这些同学的意见。
|
||||
|
||||
但从另一个角度讲,**一些基础知识、基础原理,一定要有很强的天赋才能学会吗?**
|
||||
|
||||
不是的。在人类知识的殿堂中,你要想增加一点新的理论、新的原理,那是非常难的。所以我们必须对那些大科学家们,那些计算机领域的先驱们顶礼膜拜。那些顶尖的工作,确实需要天赋,再加上努力和机缘。
|
||||
|
||||
不过,即使狭义相对论和广义相对论发明起来那么困难,但一般的理工科学生只要想学,都是可以弄明白的。这就证明了,发现知识和学习知识所需要的能力,是极大的不对称的。在高考季,经常会出现妈妈级、奶奶级的考生,从陪考到变成跟儿孙辈一起上大学的故事。人家奶奶都能考上大学,我们年轻大学生学不会本专业的一些基础原理,这个道理说得通吗?
|
||||
|
||||
同理,你常常会听到的一个理由也是不成立的,这个理由就是:我不是科班出身。这个我就不认真去反驳了。你想想看吧,费马的本职是律师,而他“业余”是个大数学家;数学家罗素却获得过诺贝尔文学奖;比尔·盖茨进的是哈佛大学商学院;我前面说的王坚博士是学心理的;罗永浩的专业也肯定跟IT没关系;刘慈欣是业余写小说的。
|
||||
|
||||
所以,那些所谓的困难,只是你给自己设的玻璃天花板。这不是个能力问题,而是个心理问题。**儒家提倡“智、仁、勇”三种最高的道德标准,勇气是其中之一,它也是我们应该训练的一种品质呀。**
|
||||
|
||||
好,如果你又一次认同了我的观点,那么我们再来讨论第三个问题,**如何克服学习过程中的困难**。
|
||||
|
||||
## 如何持之以恒?
|
||||
|
||||
在我看来,如果理顺了前两个问题,也就是为什么要学,以及信心和勇气的问题,那么你最大的心魔其实就破除了。
|
||||
|
||||
但毕竟,学习贵在持之以恒的坚持。在这个过程中,我们可能会遇到很多的困难。但对于这些困难,我们也要用正确的心法来对待。所以,接下来我就针对如何面对学习中的困难、如何保证学习时间、如何找到学习的乐趣等常见问题,谈谈我的看法。
|
||||
|
||||
### 困难是必须的
|
||||
|
||||
首先你得明白,有价值的东西,一定是要克服困难才能得到的,这是公平的。所以**不要指望学知识而不需要付出努力,再好的教程和老师,也只是起到辅助作用**。这里你得注意一个问题,就是不要被某些书籍和课程收了智商税,比如说,“7天学会XXX”,“学英语其实不用背单词”,等等。这种标题,就是违背学习的基本规律的。
|
||||
|
||||
所以,当你知道了苦难不可避免这个道理,那你剩下的就只有面对这些苦难。在学习中,你可能经常会被一个难点阻碍住,这很正常。你正确的心态应该是这样的:
|
||||
|
||||
- 没有我拿不下的山头,正面拿不下从侧面,侧面不行走背面。多换几个角度,多几次尝试,多看点参考资料,总会成功;
|
||||
- 那么多人都能学会,我没有道理学不会,一定有更好的方法;
|
||||
- 这个问题既然有难度,那价值一定也大,所以一定不要放弃。
|
||||
|
||||
有了这样的心态,其实再苦再难的事儿都好说了。
|
||||
|
||||
### 在旅途中发现乐趣
|
||||
|
||||
我一个朋友最近正在从新疆骑行到西藏,全程3000公里,中间需要穿越无人区。这是他第三次做这样的骑行,之前已经骑过川藏线、青藏线。虽然过程很艰苦,但沿途美丽的风景,和跟自己相处的过程,就是这个旅途给他的回报。
|
||||
|
||||
我自己也喜欢户外。我家人有时不理解我,问我为什么要开着一辆大房车去那么远,累不累呀。我说,这就是旅行的意义呀。如果直接飞机过去,那有什么意思。
|
||||
|
||||
我用这两个例子作类比,是想告诉你:**当我们学习那些有难度的知识的时候,其实肯定能发现出其中的乐趣来。**比如,在学编译原理的时候,你去动手实现几个小例子,哪怕还不到实用的程度,但是好玩呀!当你找到了其中的乐趣,那么别人看你是在艰苦地学习,但其实你是乐在其中呢。就好像,别人看着一个人是在顶风冒雪一个人骑行,但他也是乐在其中呢!
|
||||
|
||||
另外呢,在互联网时代,各种不需要动脑的娱乐方式层出不穷。普通的人会在这种廉价的快乐中流连忘返。**而如果你的目标是持续进步,那要培养自己另一种习惯,就是习惯于获得那些艰难的乐趣,这种乐趣是真正的充实的乐趣。**
|
||||
|
||||
### 跟自己相处
|
||||
|
||||
我前面举的朋友骑行的例子,他是自己一个人。我也喜欢自己开车出去,因为没有了其他人,也就避免了因为人际关系而导致的分神,你只需要关注大自然和你自己。你能感受到自己跟自己对话的过程,自己跟大自然对话的过程。
|
||||
|
||||
**学习在大多数情况下也是一个人前行的过程,学到的知识也只属于你一个人。**在这个时候,就只剩下了你要去攻克的知识,和你自己。你能感受到自己跟自己对话的过程,自己跟知识对话的过程。当遇到困难了,你能发现自己的苦闷和焦虑;当解决问题了,你能感受到自己的欣喜。
|
||||
|
||||
真正有价值的成绩,都是在这样的跟自己独处、跟自己对话的过程中做出来的。这是一种值得追求的素质。
|
||||
|
||||
### 跟志同道合者相伴
|
||||
|
||||
独行难,众行易。除了那些内心特别强大的、从来都不屑于与普通人同行的天才,我们大部分普通人还是愿意有一些同伴一起结伴而行的,这样会大大降低驱动自己所需的努力。
|
||||
|
||||
我在读研时曾报过GRE的培训班。我感觉报班的最大作用,其实不是跟着老师学到多少知识,而是培训班乌泱乌泱的一大堆的同学,给我提供了一种气场,让我每天不想别的,赶紧学习就是了。
|
||||
|
||||
这样的群体还会有效改变自己的学习标准。在学GRE之前,我觉得一天背几十个单词已经挺辛苦的了。但到了GRE班,我很快就接受了每天背200个的新标准,因为其他人也是采用这个标准的。关键是,就算每天背200个,我也没觉得有多困难。所以你看,人的潜力有多大的弹性,而一个好的群体就是能无形中给人提供这种心理上的能量。
|
||||
|
||||
而且那时的同学都会有这种体会,就是每天如果不背单词就不舒服,上瘾。那段时间,随便看到一个单词,脑子里就会出现几个近义词和反义词,这种感觉很奇妙。再次印证了我前面说到的那种奋斗中的乐趣。
|
||||
|
||||
在软件领域,有很多技术社区,这些社区也能起到对人的心理加持作用,你可以善加利用。
|
||||
|
||||
最后,如果有要好的朋友和导师,能够鞭策你,那也非常难得。有管理经验的人都知道,虽然我们希望每个员工都有自我驱动的能力,但**合适的外部驱动能降低员工驱动自己所需要消耗的努力**。毕竟,我们大部分人其实是愿意工作在“低功耗模式”,能节省能量就节省能量。
|
||||
|
||||
### 使用运营思维
|
||||
|
||||
在互联网时代,各种App在功能设计和运营上,充满了心理学的套路,以便培养用户的习惯。游戏公司更是会雇佣心理学专家,来设计各种套路。
|
||||
|
||||
那么,与其让别人套路你,不如自己套路自己,同样利用心理学的知识来培养自己的学习习惯,把自己的时间、自己的命运把握在自己手里,不是更好吗?
|
||||
|
||||
心理学的基础原理并不难,你自己就能从各种App的使用套路里体会到一些。比如说对取得的成绩即时给予奖励。从心理学的角度、从各种App背后的运营者的角度来看,我们每个人其实就是巴甫洛夫实验室里的动物而已。通过这样的自我训练,你可以达到一些很好的效果:
|
||||
|
||||
- 建立良好的学习流程,有明确的开始和结束时间;确认一下每天的学习目标和学习成果,或者可以建立学习过程的仪式感;给自己一个良好的环境。
|
||||
- 没有学习的时间?那是不可能的。这是因为你没有给学习安排出专门的时间来。
|
||||
- 以输出带动输入。很多同学有写技术博客的习惯,这个习惯非常好。因为你要写出东西来,所以会逼迫自己把思路理清楚。
|
||||
- 激进一点的:直播自己的学习过程,给自己提供外部监督和激励机制。
|
||||
|
||||
## 小结
|
||||
|
||||
今天这一讲,我聊了聊对于学习比较难的、比较基础的知识的心法的理解。总结起来,主要有三点:
|
||||
|
||||
- 第一,那些基础知识的素养,决定了一个人的发展潜力,这是你要学习它们的原因;
|
||||
- 第二,没有学不懂的知识,真正的障碍是心理上的自我设限;
|
||||
- 第三,学习的过程,就是砥砺前行的过程,经常能自省和调整自己的状态,就能养成自己的学习能力。
|
||||
|
||||
那么,你对于学习,有没有什么好的心法?欢迎在留言区跟大家交流!
|
||||
|
||||
感谢阅读。如果你也觉得很有收获,非常欢迎你把今天的内容分享出去,跟你身边的朋友一起做会学习的人。
|
||||
|
||||
我们接下来就要进入到期中复习周了,到时候你就可以来看看,在前半段的课程中,你都学习得怎么样!
|
||||
110
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐3 | 这几年,打动我的两本好书.md
Normal file
110
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐3 | 这几年,打动我的两本好书.md
Normal file
@@ -0,0 +1,110 @@
|
||||
<audio id="audio" title="不定期加餐3 | 这几年,打动我的两本好书" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e8/1e/e822885f4a624b0297c776ba44b0cf1e.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在互联网时代,读书好像变成了一件挺奢侈的事情。因为我们现在获取信息的主要渠道,已经变成了网络。不过,在互联网统治力如此强劲的今天,我偶尔仍能发现一些好书,让我可以放下电脑和手机,对着厚厚的一大本,从头看到尾,甚至还会看很多遍。可见这些书确实是真正打动了我,让我这个理科背景的人,能以新的视角来看待世界,理解这个世界背后的运行规律。
|
||||
|
||||
我觉得一本书籍能达到这个阅读境界就很值得推荐了,因为这相当于是在帮助我们树立世界观、沉淀方法论。所以今天的加餐环节,我想给你分享两本打动我的好书,或者说是以其为代表的两类好书,跟你聊聊我读这些书的感受和收获,希望也能给你一些启迪。
|
||||
|
||||
那第一本书呢,就是《失控》。
|
||||
|
||||
## 《失控》
|
||||
|
||||
失控这本书的作者是《连线》杂志的主编凯文 · 凯利,于1994年出版。这本书被很多人推崇,据说张小龙就曾说过,谁能看懂《失控》这本书,谁就可以到他那里工作。
|
||||
|
||||
这本书的神奇之处,在于它虽然成书于上个世纪90年代初,但准确预测了后来互联网时代的一系列的创新,更厉害的是它揭示了互联网时代背后蕴藏的道理。就如这本书的副标题所说的,它揭示了“全人类的最终命运和结局”。
|
||||
|
||||
我自己呢,是在读过这本书后,对其中的内容感觉极为惊讶。我甚至怀疑,凯文 · 凯利到底是何方神圣,为何他能够写出这样的惊世之作。
|
||||
|
||||
我就拿《失控》中第二章的内容,跟你一起分享一下,做一次管中窥豹。
|
||||
|
||||
第二章的标题是“蜂群思维”。蜜蜂是一种社会性昆虫,它们总是一大群一起生活。在研究蜂群的时候,你会发现,一群蜜蜂相当于构成了一个单独的生命体,这个生命体比单只的蜜蜂更加高级。举个例子,单只蜜蜂只有6天的记忆力,而一个蜂群却拥有三个月的记忆时间(这是个体记忆与群体记忆的区别之处)。另外这个生命体会比单只蜜蜂拥有更长的寿命,且具有繁殖能力,能分化出新的蜂群。
|
||||
|
||||
这样看起来,它似乎符合一个生命体的所有特征。而这种把很多个体连接起来,构成一个更高级的存在的现象,就叫做**涌现(Emergence)**。
|
||||
|
||||
另一个能很好地解释涌现的例子,就是人类的大脑。大脑中的神经元,实际上就是一个很简单的个体,它们只知道在接收到信号的时候,对其他神经元发送信号。而基于很多亿的神经元所涌现出来的大脑,却具备记忆、推理、情感等很高级的能力。试想,如果你是一个神经元,你其实是根本无法理解,以亿万个你构成的一个超级生命体,竟然会拥有情感、逻辑推理这种东西。因为在一个神经元的世界里,它只有接收信号和发送信号这两件事情。
|
||||
|
||||
你再往下思考,就会发现人类社会正是由亿万个你我构成的,那人类社会是不是一个超级生命体呢?这个生命体在思考什么,我们作为一个神经元级别的存在,如何能理解呢?或者说,思考仅仅是我们这个级别的个体所能理解的事情。而这个超级生命体所做的事情,可能已经根本不是人类的思考这种层面的事情了呢?早期人类的宗教,以及宗教中的神,也是高于单个的人类个体的。那么,它们是不是这个超级生命体在人类历史中早期的一种呈现方式呢?
|
||||
|
||||
我们再来假设一下,当前的互联网时代,连接网络的计算机、各种智能手机、智能设备越来越多,甚至已经开始接近大脑神经元的数量了。那么,它们会不会涌现出一个超级生命体?这个生命体是否会具备自己难以撼动的意志,而我们必须屈服于这种意志呢?
|
||||
|
||||
怎么样?这本书里的观点,是否也能同样给你带来启发,开一个大大的脑洞?是不是也引起了你去一读的兴趣呢?
|
||||
|
||||
这个级别的内容,在《失控》里还有很多。再举一个例子:活系统的特征是“摇摇晃晃的平衡”,而处于稳定的系统就进入了死寂。从这个角度看,如果我们的生活中问题不断,其实正是活系统的特征,因为我们要谋求持续的不均衡,这样我们才有机会去改变它,这总好过稳定的、死寂的生活。你看,这样的结论都已经带有了哲学的特征,让我们在面对生活中的挫折时,会采取更加积极的心态。
|
||||
|
||||
于是,还是回到我前头提到的那个疑惑:为什么凯文 · 凯利会有这么深刻的洞察力,远远超越我们这些普通人呢?
|
||||
|
||||
经过研究,我发现原来书中的很多观点,其实是对从上个世纪中叶以来,各学科的科研成果的总结,然后通过一个资深科技编辑的叙述普及给大众。看到这里,我才放心了:原来并不是出了一个多么逆天的天才,而是我自己对科技发展的新成果,以及其中蕴含的新思想缺少了解。这些思想或理论呢,包括了很多同学都知道的系统论、控制论和信息论三大论,以及后来的协同学、博弈论、突变论、混沌理论、分型理论、耗散结构和复杂性理论,等等。它们在过去的几十年间不断地发展,并正在形成一个宏大的、崭新的世界观和方法论体系。**现在的一个新兴学科——复杂科学,似乎就是这些元素的集大成者。**
|
||||
|
||||
我以前对复杂科学了解得不多,但我觉得其实也不能怪我。因为我们在中学、大学学的那些知识,大部分都是用来描述简单系统的。比如在大多数情况下,天体的运行就是一个简单系统,我们用相对论这样的理论就能准确地预测天体的行为。
|
||||
|
||||
而复杂系统,其构成部分之间的相互作用关系比较复杂,难以预测。我还是拿天体来说,三颗星体的相互作用,就变得难以预测了,这就是著名的三体现象,也是刘慈欣小说名称的来源。蝴蝶效应、混沌系统,等等,说的也是这个现象。
|
||||
|
||||
可以说,**复杂系统破除了对还原论的迷信**。也就是,即使你把一个系统分割成一个个的“零件”,并且完全理解了每个“零件”,你也可能无法理解整体系统。因为整体不等于部分的简单相加,比如说,就算你理解了一个社会的经济体中的每个企业和消费者的行为,你也无法准确掌控宏观经济。
|
||||
|
||||
**可是,了解这些,对你我有什么意义呢?**
|
||||
|
||||
我先讲一个小的用途。作为软件架构师,你其实应该知道,当一个软件系统复杂到一定程度的时候,你要把它看成一个动态演化的有机体。你对系统做的任何改动,都可能会引起一些你完全预料不到的结果。这就是为什么,你可以花一点儿钱甚至是免费就能搭建一套简单的电商系统,但是像淘宝这样的大型系统,则需要几千人来建设和维护它。
|
||||
|
||||
再举个例子。我们现在都非常熟悉的微服务架构,它的理念是,一个大型软件系统是从一个个分布式的、自治的单元中涌现出来的。流媒体巨头NetFlix,他们也是微服务架构的首批推动者之一。在NetFlix,软件工程师们会设计一些叫做Monkey的程序,随机地杀死一些服务,看看系统能否正常地自动修复。发现了吗?像微服务这样的复杂系统,它的冗余和自愈的能力已经像是一个生命体了,即使出现了一些突发的故障,比如某些服务的宕机,它也不会一下子全部瘫痪。
|
||||
|
||||
除了软件领域,与人类社会密切相关的系统,包括天气系统、生态系统、经济系统、社会系统,甚至包括人体本身,它们也都是复杂系统,所以现在的很多学科都在采用复杂系统的思维做研究。比如,采用演化的思维做心理学的研究,就形成了进化心理学的分支(其实更恰当的翻译方法是演化心理学,因为演化是没有方向性的)。这个学科的基本逻辑,就是现在人类具有的某种心理特质(比如为什么恋爱中男人更主动,女人更矜持),都是在进化中形成的。因为凡是没有这种心理特质的人类,都已经在进化过程中被淘汰了。
|
||||
|
||||
再进一步,其实你根据上面的信息可以得出一个结论:原来文科生研究的对象都是复杂系统。**你一旦意识到这一点,你就可以通过复杂系统的研究成果,去研究原来属于文科生的研究范畴,比如说社会学、经济学、文学和哲学,从而拥有方法论上的优势。**
|
||||
|
||||
给你简单举个例子,经济学中的宏观经济学部分,就是针对复杂系统的。这也是为什么大家总是说经济学家都是事后诸葛亮的原因:复杂系统是很难被简单地驾驭的。
|
||||
|
||||
甚至,你也可以用复杂科学的视角来重新审视哲学,特别是一些古代的哲学思维。因为基本上这些古老的哲学思想都是复杂系统的描述,是让你能够更好地适应自然系统和人类社会这两个复杂系统的一些解。比如说,儒家的思想,是理顺人际间的互动关系,从而缔造一个稳定的社会系统;而道家的思想,则是描述了包含人类社会和自然界的一个更大的系统规律。
|
||||
|
||||
有意思的是,凯文 · 凯利在《失控》的最后一讲,总结了复杂系统的特征,有很多地方跟道家的思想非常契合。比如说,“世界是从无中创造出来的”“从无数神经元的连接中,涌现出了大脑;而分子层面的连接,则涌现出了细胞”。
|
||||
|
||||
可以说,从《失控》这本书开始,就引起了我对复杂科学的兴趣,这个主题下的其他书籍,比如《复杂》,也非常值得你一读。
|
||||
|
||||
好,接下来,我再给你分享另一类好书,是关于文化的。而且它跟复杂科学这个主题,也是存在联系的。
|
||||
|
||||
## 文化与地方志
|
||||
|
||||
我从大学起,就对“文化”这个主题非常感兴趣,跟东西方文化有关的东西我都乱看了一气。大学时我读过一本书,是房龙的《人类的故事》,非常喜欢,因为它不但描述了历史事实,还描述了推动历史发展背后的原因和规律。我当时想,如果历史都这么写,那么大家学历史的时候肯定不会觉得枯燥。
|
||||
|
||||
因为我的思维特点是非常理科生的,我很难记住那些相互之间没有逻辑关系的事实,我也很难接受强加过来的一套体系,除非我能弄清楚它背后的逻辑。而如果一本书,它能讲清楚事实背后的因果关系的脉络,就比较令人愉悦了。
|
||||
|
||||
而我前面所说的复杂系统的一些研究成果,就可以用来理解这些文化背后的逻辑规律。我挺喜欢的一个独立学者王东岳,他写了一本书叫做《物演通论》。王东岳很喜欢解读东西方文化背后的脉络,看他写的书就让人有一下子把厚厚的书读薄的感受,非常过瘾。
|
||||
|
||||
不过我想,如果我没有读过《失控》及其相关理论,我可能又会对王东岳此人惊为天人,对其著作惊为天书。但在有了前面的知识积累以后,我就不会那么惊讶了。因为王东岳先生的思考,也是建立在大半个世纪以来的科研成果的基础上的。物演的“演”字,就是演化思维的体现。当然,他能够进行提炼和再创造,构造一个完整的知识体系,也相当值得敬佩。
|
||||
|
||||
其实说了这么多,我的意思是,**文化可以用复杂科学的思维来解构**。这个方法,特别适合像我这样的、擅长逻辑思维的理科生们。每当你观察到一个文化现象,你都能解构出这背后的原因,岂不是很有意思呢?
|
||||
|
||||
作为一个北方人,我这几年大部分的时间都在厦门,对这里的闽南文化做了饶有兴趣的观察。去过厦门旅游的同学,应该都知道厦门的文艺气氛还挺浓厚的。那为什么厦门会有这种调调呢?还有,你在旅游的时候,应该会发现厦门的一种小吃,叫做沙茶面。那为什么沙茶面会在厦门文化中涌现出来呢?
|
||||
|
||||
这就需要结合闽南这个地方的地理、历史等各个要素及其互动关系来做分析。不过,我准备在课后的留言里,再分享我对这几个问题的看法。你有兴趣的话,也可以发表你的观点。
|
||||
|
||||
类似的文化方面的问题,还能提出很多来,比如:
|
||||
|
||||
- 为什么泉州会成为海上丝绸之路的起点?
|
||||
- 为什么孔圣人出在山东,而历代出状元最多的省份,却都在南方?
|
||||
- 中国有很多古镇,每个古镇在历史上肯定都是富甲一方的地方,那究竟是什么因素才促使它们兴盛起来的?
|
||||
- 如果某个地方有一个地理标识产品,是某种柿子,你能推测出那里的地质特点吗?
|
||||
- ……
|
||||
|
||||
去年的时候,我因为一个项目,翻阅了某县的县志,结果没想到我会对县志如此感兴趣,读得津津有味。我才发现,通过县志我能了解一个地方的地理、历史、经济、文化、重要人物等各种信息。通过这些信息,我基本上就能看到一个由很多要素相互作用构造出来的一个复杂系统,就能读懂当地各种文化的成因,这非常有意思。
|
||||
|
||||
中国的很多文化积淀很丰富。如果有机会能够一点一点地解读过去,那该多好。我估计我会一直保持阅读并解读地方志的兴趣。最近回老家,家人又给了我一本我们县在民国时代的县志。看着这些书籍,我有一种强烈的感觉:即使你是在这里生、这里长的,你也不一定真的了解本地的文化。
|
||||
|
||||
**我为什么会推荐你去读地方志和其他讲解文化现象的书,读懂自己的本地文化呢?**
|
||||
|
||||
第一层原因,是呼应我在加餐2“[学习技术的过程,其实是训练心理素质的过程](https://time.geekbang.org/column/article/269322)”中,提倡你多学点“无用之学”的观点。哪怕只是让你的灵魂更有趣,不是也挺好的吗?
|
||||
|
||||
第二层原因,是我作为一个理科生的思维方式。把自己所处的社会系统理解清楚,能够透过现象看到后面的逻辑,不是很有意思吗?
|
||||
|
||||
第三层原因,如果你能够运用复杂科学的思维,来理解现在的社会系统,其实是有实际意义的。举个例子,如果你要撰写一个商业计划,或者想给一个企业写一套软件,这就需要你理解其当前的商业系统、理解一个企业组织具体是如何运行的。而你之前的这些阅读积累,就会成为你的底蕴,成为你的智慧源泉呀!
|
||||
|
||||
## 小结
|
||||
|
||||
今天这一讲,我推荐了两本书,或者说是两类书。一类书,是以《失控》为代表,讲述与复杂性相关的话题。另一类书,是以地方志为代表的文化载体。之所以给你推荐这两类书,是因为它们给我如何观察和理解这个世界开启了一扇窗户,并且给我这样一个严谨的理科生,提供了一条去打开文史哲的大门的独特的、有优势的途径,希望能对你有所启发。
|
||||
|
||||
## 思考一下
|
||||
|
||||
1. 你有没有阅读过《失控》?你对复杂科学有什么了解?复杂科学在你的领域里有什么应用?
|
||||
1. 你对自己出生地的文化了解吗?你有没有曾经发现一个文化现象背后的逻辑脉络?你觉得多研究点文化现象对于自己的职业生涯有好处吗?
|
||||
|
||||
欢迎在留言区发表你的观点。如果今天的加餐让你有所启发,也欢迎把它分享给你的朋友。
|
||||
125
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐4 | 从身边的牛人身上,我学到的一些优秀品质.md
Normal file
125
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐4 | 从身边的牛人身上,我学到的一些优秀品质.md
Normal file
@@ -0,0 +1,125 @@
|
||||
<audio id="audio" title="不定期加餐4 | 从身边的牛人身上,我学到的一些优秀品质" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e2/73/e2edbd0d30aa4322013537674bfb9d73.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
今天的加餐环节,我想跟你分享一下让我很敬佩的那些牛人,以及从他们身上我所能学到的优秀品质。我希望你也能从这些人的故事上得到启发。这里为了叙述方便,我就不提具体的名字了。你只需要了解他的故事,从中有所感悟就好了。
|
||||
|
||||
我把这些牛人分为了两类,一类是搞技术的,一类是创业的。由于我自己也身兼两重身份,所以我很关注这两类人中能带给我启发的人。
|
||||
|
||||
首先来说说第一类人,搞技术的,也就是我们常说的极客们。
|
||||
|
||||
## 我所理解的极客
|
||||
|
||||
我曾经在技术圈子里参加过比较多的活动,特别是开源圈子的活动,因此也接触了不少技术大牛,国内国外的都有。
|
||||
|
||||
早在2000年的时候,我就听过理查德·斯托曼(Richard Stallman)的讲座,听他布道自由软件。Stallman是GNU这个组织的创始人,他也发起了GPL开源协议。更重要的是,他是GCC编译器的主要作者,所以跟我们这门课也是很有渊源的。记得当时他给我们放一个幻灯片的时候,用的是Linux上的一个软件,界面没有微软的PowerPoint那么酷炫。但你能想到,Stallman是绝对不会用PowerPoint的。
|
||||
|
||||
后来在参加和组织开源技术活动的过程中,我也接触了不少国内国外的技术团队,他们在很多方面刷新了我的认知、拓宽了我的视野,也让我更理解极客都是一些什么样的人。
|
||||
|
||||
在我看来,这些人应该就是合格的极客。那么,怎样才能被称为极客?是技术水平高吗?我想不是的。技术水平高,其实只是一个结果。真正让极客显得与众不同的,其实是他们对待技术的态度,乃至是对待人生的态度。这些特质,也能给所有做技术的人一些启发。
|
||||
|
||||
首先,是**热爱技术**。
|
||||
|
||||
跟普通人只是把技术作为一个谋生的手段不同,极客们是真心喜欢技术,热衷于钻研和探讨各种技术细节。他们在对待工作的时候,绝不会把某项工作做到能交差就行,他们想要做到完美。
|
||||
|
||||
我之前公司的一位股东,他在做程序员的时候,曾经接到领导的一项任务,给了他一块语音板子,让他研究一下能否做呼叫中心的功能。两个星期以后,再问他,技术上是否可行?他说,已经做完了。不仅做完了,他还考虑了各种扩展性。比如,给他的板子只有八个语音口,但他写的程序考虑了用不同的板子,有不同的口的情况。以至于后来很多年,基于他的程序做的呼叫中心系统,底层都不用做很大的改动。
|
||||
|
||||
我这位朋友,我在[加餐2](https://time.geekbang.org/column/article/269322)中也提到过。他因为对于底层软件的深刻理解力,被挖到中间件公司做老总。后来又在创业什么的,最近又写了一套科幻小说。不管什么时候,我总能从他身上吸取到一些东西。
|
||||
|
||||
另一个例子,是我一个在苹果工作的同学提到的。这位同学负责苹果的文件系统的开发,我下面还会给你讲他的故事。这里是他讲的另一件事情。一次,一位博士分配到他们组,一时没有合适的工作给他干,就先让他做一阵测试。结果这位老兄,彻底升级了测试系统,对于大量的工作都实现了自动化测试,给整个团队带来了巨大的价值。
|
||||
|
||||
这个故事也让我更新了看待测试工作的视角。我现在基本上不会去招聘那些因为对自己的技术能力没有信心,而选择去做测试工作的人。我认为测试工作需要极大的技术热情才能做好。
|
||||
|
||||
我想,不管是从事什么岗位,能够热爱自己所做的事情,都是非常值得庆幸的。反过来,如果不喜欢自己所做的事情,为什么要去凑合呢?
|
||||
|
||||
换句话说,能够做自己喜欢的事情,其实是有所取舍、有所牺牲的。林纳斯·托瓦兹(Linus Torvalds)就喜欢领着一拨人折腾Linux。如果他去做某个大公司的CTO甚至是创业合伙人,也无不可。但他选择的是自己喜欢的生活方式。他没有太去想自己因此损失了多少发财的机会。
|
||||
|
||||
这就涉及到了第二点,就是极客们**洒脱的生活态度**。
|
||||
|
||||
极客们所展现出来的这个特质,从某种意义上来看是更具魅力的。很多极客,都是不愿意以“生活所迫”为借口,选择自己不喜欢的工作和生活方式。
|
||||
|
||||
我在[加餐1](https://time.geekbang.org/column/article/251164)分享远程办公话题的时候,就提到过一家芬兰公司。这家公司都是远程办公的,其中有的员工,是一边全球旅游,一边工作的。这些技术型的公司,正是以这种方式,吸引那些真正的极客加入。
|
||||
|
||||
还有一次我参加一个技术活动,我的朋友C指着一个老外说,这家伙在泰国买了一个小岛自己住,还弄了个度假村什么的。说实话,这样的归园田居的生活方式,对像我们这样浸染在中华文化中的人来说,是有很大的吸引力的。但我们有多少人敢于不从众,去选择自己喜欢的生活方式呢?
|
||||
|
||||
我还有的朋友是依托自己的技术创业的。创业这件事当然很不容易,但他们通常都会保持积极乐观的态度,并没有因为自己的项目没有及时被社会认可,就变得垂头丧气。
|
||||
|
||||
那第三点,就是极客们看待这个世界的方式:**用代码说话**。
|
||||
|
||||
极客这群人,是不大讲政治的。他们一般只认真实的本事。Linus就有一句名言“Talk is cheap, show me the code.”,这也代表了极客们的精神。一个人的水平如何,看看他写的代码,或者至少看看他发表的文章,其实差不多就有数了,这个是伪装不了的。
|
||||
|
||||
早在智能手机流行前,有一次聚会,我一个搞Linux的朋友F,就拿出了一台手机,里面装着Linux、图形界面、App什么的,看着都还行。这都是他鼓捣出来的。其实再加把劲,比如支持用Java开发应用,这就是一个Android系统了。而Android的创始人安迪·鲁宾(Andy Rubin),差不多也是这样一个极客。前一阵,我跟一个公司的老总聊天。他问我,为什么中国搞不出安卓来?我给他解释了原因。其实不是我们没有这样的技术,在极客们的眼里,最早的那个安卓版本也没什么大不了的。只不过我们没有掌握技术生态而已。
|
||||
|
||||
极客们一般对系统底层的技术都比较熟悉。像安卓系统这种看似很高大上的东西,不会让他们心生畏惧。这些人在互相交流的时候,也会谈论一些底层技术。几句话下来,心里已经有数。
|
||||
|
||||
然后呢?他们之间会缔结惺惺相惜的友谊。两个极客之间的交往可以极其简单,他们甚至不需要见过面,只需要见过对方的代码,或者读过对方的文章,就会认可彼此。如果有事情,直接打招呼就行。
|
||||
|
||||
某互联网大厂是如何把自己的底层技术搞扎实的呢?据我了解,就是找到一个开源圈的大牛。这位大牛进去了以后,又给技术社区的其他人打招呼,说这里有什么技术难题需要解决,过来吧。于是就聚集了一个小组的牛人,搞出了非常好的成绩。这就是极客们的特殊的社交方式:他们知道who is who,并且志同道合的人愿意聚到一起。如果光靠HR部门和猎头公司来做,要想达成这样的结果是很难的。
|
||||
|
||||
Github在某种意义上也是把极客们的这种倾向给充分地引导了出来。它从一个代码托管工具,几乎已经变成了程序员的社交网站。
|
||||
|
||||
这里我是想说明一个观点,那就是技术人并没有怀才不遇这一说。**把真本事亮出来,所有的事情会变得简单很多。**
|
||||
|
||||
好了,这就是我总结的极客们给我的三点印象:热爱技术、生活洒脱、凭本事说话。这些特质,都是我很欣赏的,也常常作为参照来调整自己。
|
||||
|
||||
比如说,我觉得自己也挺热爱技术的,但是在前些年,我觉得自己不够洒脱,做不好取舍,总是想各方面都兼顾,结果哪方面都顾不好。所以还不如在自己喜欢的事情上全情投入,不去计较太多得失,反倒会更加心情愉快,做事情的结果也更好。
|
||||
|
||||
你可能会问,那这些极客都发展得怎么样呢?
|
||||
|
||||
我所认识的极客,有的是在小公司工作,有的是在大公司工作,还有的是在创业。不过,不管从事什么岗位,似乎都发展得不错。我想,这是因为他们从底层上选择了一个更好的发展逻辑:首先是做好取舍,让自己能够专注技术;在拥有了比较好的技术底蕴以后,他们也有更好的施展自己才华的平台;在专注于技术价值的同时,他们的生活也变得简单和健康。
|
||||
|
||||
OK,讲完了搞技术的,我再讲讲搞创业的朋友的故事,以及他们给我的启迪。
|
||||
|
||||
## 创业者这个物种
|
||||
|
||||
我周围的朋友有不少是搞创业的。这些人往往都有一些很特别的点,让我欣赏、赞叹乃至仰慕。
|
||||
|
||||
首先一点,是**坚韧不拔的意志力**。
|
||||
|
||||
我们都知道,创业肯定不是简单的事情。而让一个企业能够穿越惊涛骇浪,最重要的就是创始人坚韧不拔的意志品质。
|
||||
|
||||
我本科的同班同学中,就有一个创业者,他公司的主营业务是户外运动用品,目前已经做到上市了。他的性格就很坚韧,我给你讲两个故事。
|
||||
|
||||
第一个,在他成为我北大的同学之前,其实曾经考上了一所技术类的军校。但后来他发现自己并不喜欢那里,于是就想退学。可是,军校岂可以当作儿戏,想来就来,想走就走?为了能够退学,他想了很多办法,包括自己注射大剂量的抗生素,产生精神恍惚的效果,以便让校医诊断为精神疾病;此法不成,又从3楼的阳台上滚下来,想把胳膊摔断,以此理由退学……后来校领导实在看他态度坚决,也就同意了他退学。他又重新参加了高考,选择了他喜欢的学校和专业。
|
||||
|
||||
第二个,大约在2006年,我们一些同学因为毕业十周年又聚到了一起,去内蒙古草原上玩,其中一项活动就是骑马。我的这位同学骑术很好,在草原上策马狂奔。不过,在一个地方,突然马失前蹄,他从马背上摔了下来。这真是很惊险的一个意外,我们在场一群人看了都心惊肉跳。不过,他休息了一会以后,又要了一匹马,上马继续策马狂奔。晚上,我们问他,为什么刚摔了又骑?他说,如果今天不骑,以后就不敢骑了。
|
||||
|
||||
说到这,我想再多讲一个例子。这是我同一级的另一个同学的故事,他是社会学系的。如果我说他的名字,很多同学应该都会知道。他从2000年开始做一个与汽车有关的网站,结果后来互联网泡沫破裂,然后投资人撤资。他就自己筹了2000万买下了投资人的股份,坚持做了下去,直到2011年上市。想想看,那个年头的2000万,是多大的压力。但他就是咬着牙挺过来了。
|
||||
|
||||
我不知道有多少人能拥有像他们这样钢铁般的意志力。并且,令我沮丧的是,我怀疑这种个性可能主要是天生的?反正我是万万难以做到的。所以,创业这件事情,其实不是每个人都适合去做。而我这两个同学能做到上市,也绝不是偶然。
|
||||
|
||||
不过,为了不让自己的希望完全破灭,我还是倾向于相信意志和勇气这样的事情,至少在部分上是可以后天磨炼的。我在大学的时候练习过拳击,因为我觉得拳击可以锻炼人的勇气。来拳的时候不能眨眼,是拳击运动员的基本素质。那么在创业中,如果我们每次都去积极地面对挑战,那面对困难的能力也会越来越强。
|
||||
|
||||
我认识的其他几个创业者,虽然不像这两位那么夸张,但在意志力方面,也都属于罕见的。比如,某个技术社区,其创始人能够做到天天更新内容,十年如一日,这就是常人所不及的。最近我通过写编译原理的课程,也对内容编写这件事有了一定的体会。这样的事情,做一个星期、一个月、一个季度,是凭着兴趣和热情就可以做到的。而长年累月地去做,你要说没有意志力的因素,那是不可能的。
|
||||
|
||||
说完了强大的意志力,我再来说说我钦佩的这些人的第二点品质,就是**有主见,不从众**。
|
||||
|
||||
我观察,这些创业成功的人,往往判断事情都有自己的标准,这些标准往往与大众是不一致的。
|
||||
|
||||
还是说我同班的那个同学。在学校读书的时候,他经常就会消失不见了,过一阵再重新出现,他告诉我们,这次去陕北了,有什么感受,怎样怎样。过一段时间又会消失,回来后,说自己在新疆沙漠里独自走了几天,遇到被人追赶,差点殒命,等等等等。
|
||||
|
||||
等到快期末考试的时候,他拉着我在未名湖边给他补习高数,说能及格就行。几年以后,在创业的过程中,他还读了个清华的MBA班,也是连毕业证都没要。按他的意思来说,就是:学到知识就行了。证书什么的,不重要。
|
||||
|
||||
而我们这些俗人,天天使劲读书。等到毕业以后,又根据习惯和潮流,很多人又去出国,虽然我敢说,大部分同学那时候都想不清楚出国到底要干嘛。
|
||||
|
||||
所以从某种意义上来讲,比尔·盖茨、马克·扎克伯格等人敢于辍学创业,本身就意味着他们不是一般人。
|
||||
|
||||
而作为对比,还有一些人,都不管自己什么年纪了还在花高价去混文凭,不停地想往自己身上贴一些标签,来为自己壮胆。我觉得,这些人不要说创业了,给他一个重要的职位都是一件很冒险的事情。
|
||||
|
||||
前面我也提到了有些极客,会基于自己的兴趣爱好来创业。他们喜欢的技术和产品,往往在很长的时间内都不会得到社会的认可,不能变成有经济价值的商品。然而他们就是会坚持自己的方向。这些人,也是我学习的榜样。
|
||||
|
||||
这些技术创业者,有的发展比较顺利,但似乎也不是刻意为之。比如上海的小X,我跟他在技术活动上有几面之缘。他搞了一个用于物联网的小小的OS,搞了很多年了,前两年突然听说融了很多资,估值不错。我觉得资本投在这些人身上是投对了。
|
||||
|
||||
也有的朋友,会经历一些坎坷。但是他们总是按照自己的方式去折腾,保持对科技发展趋势的敏锐观察。每隔一段时间,我总能从他们那里听到一些新的思想和动态。就拿我一个做移动端底层平台朋友来说,他做这个方向已经很多年了。我相信他肯定会做成。不过先不说未来结果如何,至少我觉得他的生活状态是洒脱的、阳光的、不纠结的。
|
||||
|
||||
## 小结
|
||||
|
||||
今天的加餐,我给你分享了周围搞技术的和做创业的一些朋友的故事。这些故事跟你有什么关联呢?
|
||||
|
||||
首先,你选择了编译原理这门课程,基本上已经说明你有成为一名极客的潜质,否则也不会给自己这个挑战。但是在这个过程中呢,你可能会遇到很多的困难和心理上的纠结。我希望通过我分享的故事,能够帮助你做好取舍,丢掉包袱,健康阳光地拥抱作为一个技术从业者的职业生涯。
|
||||
|
||||
而如果你不小心选择了创业这条路,我也希望你能够像故事中的人物一样,去磨炼自己的意志力,以及坚持自己的主见。成功不成功不敢保证,至少你的生活会是很有价值的,不会后悔的。
|
||||
|
||||
以上也是对我自己的勉励,希望能跟你共勉。如果你或你身边也有类似的故事,欢迎在留言区分享出来。同样,也非常欢迎你把这一讲分享出去。
|
||||
|
||||
感谢你的阅读,我们结束语见!
|
||||
278
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐5 | 借助实例,探究C++编译器的内部机制.md
Normal file
278
极客时间专栏/geek/编译原理实战课/不定期加餐/不定期加餐5 | 借助实例,探究C++编译器的内部机制.md
Normal file
@@ -0,0 +1,278 @@
|
||||
<audio id="audio" title="不定期加餐5 | 借助实例,探究C++编译器的内部机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/30/34/30fc0f334fe58e3bdcee736fef24e234.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。欢迎来到编译原理实战课的加餐环节,今天我们来探讨一下C++的编译器。
|
||||
|
||||
在前面的课程中,我们已经一起解析了很多语言的编译器了,但一直没有讨论C和C++的编译器。并不是因为它们不重要,而是因为C语言家族的编译器实现起来要更复杂一些,阅读代码的难度也更高一些,会对初学者造成比较大的挑战。
|
||||
|
||||
不过,没有解析C和C++语言的特性及其编译器的实现,其实在我心里也多多少少有点遗憾,因为C和C++是很经典的语言。至今为止,我们仍然有一些编程任务是很难用其他语言来代替的,比如,针对响应时间和内存访问量,需要做精确控制的高性能的服务端程序,以及一些系统级的编程任务,等等。
|
||||
|
||||
**C和C++有很多个编译器,今天我们要研究的是Clang编译器**。其实它只是前端编译器,而后端用的是LLVM。之所以选择Clang,是因为它的模块划分更清晰,更便于理解,并且还可以跟课程里介绍过的LLVM后端工具串联起来学习。
|
||||
|
||||
另外,因为C++语言的特性比较多,编译器实现起来也比较复杂一些,下手阅读编译器的源代码会让人觉得有点挑战。所以今天这一讲,我的主要目的,就是给你展示如何借助调试工具,深入到Clang的内部,去理解它的运行机制。
|
||||
|
||||
**我们会具体探究哪个特性呢?我选择了C++的模板技术**。这个技术是很多人学习C++时感觉有困难的一个技术点。通过探究它在编译器中的实现过程,你不仅会加深了解编译器是如何支持元编程的,也能够加深对C++模板技术本身的了解。
|
||||
|
||||
那么下面,我们就先来认识一下Clang这个前端。
|
||||
|
||||
## 认识Clang
|
||||
|
||||
Clang是LLVM的一个子项目,它是C、C++和Objective-C的前端。在llvm.org的官方网站上,你可以下载Clang+LLVM的源代码,这次我用的是10.0.1版本。为了省事,你可以下载带有全部子项目的代码,这样就同时包含了LLVM和Clang。然后你可以参考官网的文档,用Cmake编译一下。
|
||||
|
||||
我使用的命令如下,你可以参考:
|
||||
|
||||
```
|
||||
cd llvm-project-10.0.1
|
||||
|
||||
#创建用于编译的目录
|
||||
mkdir build
|
||||
cd build
|
||||
|
||||
#生成用于编译的文件
|
||||
cmake -DCMAKE_BUILD_TYPE=Debug -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_BUILD_EXAMPLES=ON ../llvm
|
||||
|
||||
#调用底层的build工具去执行具体的build
|
||||
cmake --build .
|
||||
|
||||
```
|
||||
|
||||
**这里你要注意的地方,是我为Cmake提供的一些变量的值**。我让Cmake只为x86架构生成代码,这样可以大大降低编译工作量,也减少了对磁盘空间的占用;并且我是编译成了**debug的版本**,这样的话,我就可以用LLDB或其他调试工具,来跟踪Clang编译C++代码的过程。
|
||||
|
||||
编译完毕以后,你要把llvm-project-10.0.1 /build/bin目录加到PATH中,以便在命令行使用Clang和LLVM的各种工具。你可以写一个简单的C++程序,比如说foo.cpp,然后就可以用“clang++ foo.cpp”来编译这个程序。
|
||||
|
||||
>
|
||||
<p>补充:如果你像我一样,是在macOS上编译C++程序,并且使用了像iostream这样的标准库,那么可能编译器会报找不到头文件的错误。这是我们经常会遇到的一个问题。<br>
|
||||
<br>
|
||||
这个时候,你需要安装Xcode的命令行工具。甚至还要像我一样,在.zshrc文件中设置两个环境变量:</p>
|
||||
|
||||
|
||||
```
|
||||
export CPLUS_INCLUDE_PATH="/Library/Developer/CommandLineTools/usr/include/c++/v1:$CPLUS_INCLUDE_PATH"
|
||||
export SDKROOT="/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk"
|
||||
|
||||
```
|
||||
|
||||
好,到目前为止,你就把Clang的环境配置好了。那回过头来,你可以先去看看Clang的源代码结构。
|
||||
|
||||
你会看到,**Clang的源代码主要分为两个部分**:头文件(.h文件)全部放在include目录下,而.cpp文件则都放在了lib目录下。这两个目录下的子目录结构是一致的,每个子目录代表了一个模块,模块的划分还是很清晰的。比如:
|
||||
|
||||
- AST目录:包含了AST的数据结构,以及对AST进行遍历处理的功能。
|
||||
- Lex目录:词法分析功能。
|
||||
- Parse目录:语法分析功能。
|
||||
- Sema目录:语义分析功能(Sema是Sematic Analysis的缩写)。
|
||||
|
||||
接下来,你可以进入这些目录,去寻找一下词法分析、语法分析、语义分析等功能的实现。由于Clang的代码组织很清晰,你可以很轻松地根据源代码的名称猜到它的功能,从而找到语法分析等功能的具体实现。
|
||||
|
||||
现在,你可以先猜测一下,**Clang的词法分析和语法分析都是如何实现的呢?**
|
||||
|
||||
如果你已经学过了第二个模块中几个编译器的实现,可能就会猜测得非常准确,因为你已经在Java编译器、Go的编译器、V8的编译器中多次见到了这种实现思路:
|
||||
|
||||
- **词法分析:**手写的词法分析器,也就是用手工的方法构造有限自动机。
|
||||
- **语法分析:**总体上,采用了手写的递归下降解析器;在表达式解析部分,采用的是运算符优先级解析器。
|
||||
|
||||
所以,针对词法分析和语法分析的内容,我们就不多展开了。
|
||||
|
||||
那么,**Clang的语义分析有什么特点呢?**
|
||||
|
||||
通过前面课程的学习,现在你已经知道,语义分析首先要做的是建立符号表,并做引用消解。C和C++在这方面的实现比较简单。简单在哪里呢?因为它**要求必须声明在前,使用在后**,这就让引用消解变得很简单。
|
||||
|
||||
而更现代一些的语言,在声明和使用的顺序上可以更加自由,比如Java类中,方法中可以引用类成员变量和其他方法,而被引用的成员变量和方法可以在该方法之后声明。这种情况,对引用消解算法的要求就要更高一些。
|
||||
|
||||
然后,现在你也知道,在语义分析阶段,编译器还要做类型检查、类型推导和其他很多的语义检查。这些方面Clang实现得也很清晰,你可以去看它的StaticAnalysis模块。
|
||||
|
||||
最后,在语义分析阶段,Clang还会做一些更加复杂的工作,比如C++的模板元编程机制。
|
||||
|
||||
我在[探究元编程](https://time.geekbang.org/column/article/282919)的那一讲中,介绍过C++的模板机制,它能有效地提高代码的复用能力。比如,你可以实现一个树的容器类,用来保存整型、浮点型等各种不同类型的数据,并且它不会像Java的泛型那样浪费额外的存储空间。因为C++的模板机制,会根据不同的模板类型生成不同的代码。
|
||||
|
||||
**那么,C++具体是如何实现这一套机制的呢**?接下来我就带你一起去深入了解一下,从而让你对模板元编程技术的理解也更加深入。
|
||||
|
||||
## 揭秘模板的实现机制
|
||||
|
||||
首先,我们通过一个示例程序,来观察一下Clang是如何编译模板程序的。假设,你写了一个简单的函数min,用来比较两个参数的大小,并返回比较小的那个参数。
|
||||
|
||||
```
|
||||
int min(float a, float b){
|
||||
return a<b ? a : b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以用clang++命令带上“-ast-dump”参数来编译这个示例程序,并显示编译后产生的AST。
|
||||
|
||||
```
|
||||
clang++ -Xclang -ast-dump min.cpp
|
||||
|
||||
```
|
||||
|
||||
下图中展示的是min函数对应的AST。你能发现AST节点的命名都很直观,一下子就能看明白每个节点的含义。其中,函数声明的节点是FunctionDecl,也就是Function Declaration的缩写。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/a8/50e994b96ef6fbc57f8afd0969yy6ba8.jpg" alt="">
|
||||
|
||||
min函数是一个普通的函数,只适用于参数为浮点型的情况。那么我们再增加一个使用模板的版本,并且函数名称一样,这样就可以支持用多种数据类型来比较大小,比如整型、双精度型等。
|
||||
|
||||
```
|
||||
template <typename T> T min(T a, T b){
|
||||
return a<b ? a : b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这时,顶层的AST节点是FunctionTemplateDecl,也就是函数模板声明。它有两个子节点,一个是模板类型参数声明(TemplateTypeParmDecl),也就是尖括号里面的部分;第二个子节点其实是一个普通的函数声明节点,其AST的结构几乎跟普通的min函数版本是一样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/51/a65d293573d52eyy6b69894dbda6f851.jpg" alt="">
|
||||
|
||||
这样,通过查看AST,你就能了解函数模板和普通函数的联系和区别了。接下来就要进入重点了:**函数模板是如何变成一个具体的函数的?**
|
||||
|
||||
为此,我们在main函数里调用一下min函数,并传入两个整型的参数min(2,3):
|
||||
|
||||
```
|
||||
int main(){
|
||||
min(2,3);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个时候,我们再看一下它生成的AST,就会发现函数模板声明之下,增加了一个新的函数声明。这个函数的名称仍然是min,但是参数类型具体化了,是整型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/39/f9b26cbab65beca0eab565cdfcf93c39.jpg" alt="">
|
||||
|
||||
这说明,当编译器发现有一个min(2,3)这样的函数调用的时候,就会根据参数的类型,在函数模板的基础上生成一个参数类型确定的函数,然后编译成目标代码。**这个过程叫做特化(Specialization),也就是从一般到具体的过程。**函数模板可以支持各种类型,而特化后的版本只针对某个具体的数据类型。
|
||||
|
||||
那么,特化过程是怎样发生的呢?我们目前只看到了AST,AST反映了编译的结果,但它并没有揭示编译的过程。而只有搞清楚这个过程,我们才能真正理解模板函数的编译机制。
|
||||
|
||||
**要揭示编译过程,最快的方法是用调试器来跟踪程序的执行过程。**最常用的调试器就是LLDB和GDB。这里我使用的是LLDB,你可以参考我给出的命令来设置断点、调试程序。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/71/117d0c937bd8c0dacdf1f98f2b54d671.jpg" alt="">
|
||||
|
||||
>
|
||||
小提示:如果你像我一样,是在macOS中运行LLDB,可能会遇到报错信息,即操作系统不让LLDB附加到被调试的程序上。这是出于安全上的考虑。你需要重启macOS,并在启动时按住command-R键进入系统恢复界面,然后在命令行窗口里输入“csrutil disable”来关闭这个安全选项。
|
||||
|
||||
|
||||
不过,在跟踪clang++执行的时候,你会发现,clang++只是一个壳,真正的编译工作不是在这个可执行文件里完成的。实际上,clang++启动了一个子进程来完成编译工作,这个子进程执行的是clang-10。所以,你需要另外启动一个LLDB,来调试新启动的进程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/27/03b9199c070ea23955493f331bcc8927.jpg" alt="">
|
||||
|
||||
在使用LLDB的时候,你会发现,确定好在什么位置上设置断点是特别重要的,这能大大节省单步跟踪所花费的时间。
|
||||
|
||||
**那么现在,我们想要探究函数模板是什么时候被特化的,应该在哪里设置断点呢?**
|
||||
|
||||
在研究前面示例程序的AST的时候,我们发现编译器会在函数特化的时候,创建一棵新的函数声明的子树,这就需要建立一个新的FunctionDecl节点。因此,我们可以监控FunctionDecl的构建函数都是什么时候被调用的,就可以快速得到整个调用过程。
|
||||
|
||||
那怎么查看调用过程呢?当clang-10在FunctionDecl断点停下以后,你可以用“bt”命令打印出调用栈。我把这个调用栈整理了一下,并加了注释,你可以很容易看清楚编译器的运行过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/1b/bb0afc7e0e9ab5d4b2021d3cc0b6301b.jpg" alt="" title="图1:模板函数特化时的调用栈">
|
||||
|
||||
接着,分析这个调用栈,你会发现其主要的处理过程是这样的:
|
||||
|
||||
- 第一,语法分析器在解析表达式“min(2,3)”的时候,会去做引用消解,弄清楚这个min()函数是在哪里定义的。在这里,你又一次看到语法分析和语义分析交错起来的情况。在这个点上,编译器并没有做完所有的语法分析工作,但是语义分析的功能会被按需调用。
|
||||
- 第二,由于函数允许重载,所以编译器会在所有可能的重载函数中,去匹配参数类型正确的那个。
|
||||
- 第三,编译器没有找到与参数类型相匹配的普通函数,于是就去函数模板中找,结果找到了以T作为类型参数的函数模板。
|
||||
- 第四,根据min(2,3)中参数的类型,对函数模板的类型参数进行推导,结果推导出T应该是整型。这里你要注意,min(2,3)的第一个参数和第二个参数的类型需要是一样的,这样才能推导出正确的模板参数。如果一个是整型,一个是浮点型,那么类型推导就会失败。
|
||||
- 最后,把推导出来的类型,也就是整型,去替换函数模板中的类型参数,就得到了一个新的函数定义。不过在这里,编译器只生成了函数声明的节点,缺少函数体,是个空壳子。
|
||||
|
||||
注意,这里最后一句的说法只是目前我自己的判断,所以我们要来验证一下。
|
||||
|
||||
Clang在重要的数据结构中都有dump()函数,AST节点也有这个函数。因此,你可以在LLDB中调用dump()函数,来显示一棵AST子树的信息。
|
||||
|
||||
```
|
||||
(lldb) expr Function->dump()
|
||||
|
||||
```
|
||||
|
||||
这个时候,在父进程的LLDB窗口中会显示出被dump出的信息,输出格式跟我们在编译的时候使用-ast-dump参数显示的AST是一样的。从输出的信息中,你会看到当前的函数声明是缺少函数体的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/72/12b70df96e84a6f55a0fdbb0cdd6d372.jpg" alt="">
|
||||
|
||||
**那么,函数体是什么时候被添加进来的呢?**这个也不难,你仍然可以用调试器来找到答案。
|
||||
|
||||
从前面函数模板的AST中你已经知道,函数体中包含了一个ConditionalOperator节点。所以,我们可以故技重施,在ConditionalOperator()上设置断点来等着。因为编译器要实例化函数体,就一定会新创建一个ConditionalOperator节点。
|
||||
|
||||
事实证明,这个策略是成功的。程序会按照你的预期在这个断点停下,然后你会得到下面的调用栈:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4c/a5/4c86365e55460021fc40b5d005c41aa5.jpg" alt="" title="图2:创建函数体的过程">
|
||||
|
||||
研究这个调用栈,你会得到两个信息:
|
||||
|
||||
1. 从函数模板实例化出具体的函数,是被延后执行的,程序是在即将解析完毕AST之后才去执行这项任务的。
|
||||
1. Clang使用了TreeTransform这样的工具类,自顶向下地遍历一棵子树,来完成对AST的变换。
|
||||
|
||||
这样,经过上述处理以后,函数的特化才算最终完成。这个时候你再dump一下这个函数声明节点的信息,就会发现它已经是一个完整的函数声明了。
|
||||
|
||||
好了,到此为止,你就知道了Clang对函数模板的处理过程。我再给你强调一下其中的关键步骤,你需要好好掌握:
|
||||
|
||||
- 在处理函数调用时,要去消解函数的引用,找到这个函数的定义;
|
||||
- 如果有多个重载的函数,需要找到参数类型匹配的那个;
|
||||
- 如果找不到符合条件的普通函数,那就去找函数模板;
|
||||
- 找到函数模板后,推导出模板参数,也就是正确的数据类型;
|
||||
- 之后,根据推导出的模板参数来生成一个具体的函数声明。
|
||||
|
||||
**其中的关键点,是特化的过程。编译器总是要把模板做特化处理,然后才能被程序的其他部分使用。**
|
||||
|
||||
抓住了这个关键点,你还可以进一步在大脑中推演一下编译器是如何处理类模板的。然后你可以通过打印AST和跟踪执行这两个技术手段,来验证你的想法。
|
||||
|
||||
不过,模板技术可不仅仅能够支持函数模板和类模板,它还有很多其他的能力。比如,在[第36讲](https://time.geekbang.org/column/article/282919)我介绍元编程的时候,曾经举过一个计算阶乘的例子。在那个例子中,模板参数不是类型,而是一个整数,这样程序就可以在编译期实现对阶乘值的计算。
|
||||
|
||||
好了,现在你已经知道,**对于类型参数,编译器的主要工作是进行类型推导和特化**。
|
||||
|
||||
**那么针对非类型参数,编译器是如何处理的呢?**如何完成编译期的计算功能的呢?接下来,我们就一起来分析一下。
|
||||
|
||||
## 使用非类型模板参数
|
||||
|
||||
首先,你可以看看我新提供的这个示例程序,这个程序同样使用了模板技术,来计算阶乘值。
|
||||
|
||||
```
|
||||
template<int n>
|
||||
struct Fact {
|
||||
static const int value = n*Fact<n-1>::value; //递归计算
|
||||
};
|
||||
|
||||
template<>
|
||||
struct Fact<1> {
|
||||
static const int value =1; //当参数为1时,阶乘值是1
|
||||
};
|
||||
|
||||
int main(){
|
||||
int a = Fact<3>::value; //在编译期就计算出阶乘值
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在Fact这个结构体中,value是一个静态的常量。在运行时,你可以用Fact<3>::value这样的表达式,直接使用一个阶乘值,不需要进行计算。而这个值,其实是在编译期计算出来的。
|
||||
|
||||
**那编译期具体的计算过程是怎样的呢?**你可以像我们在前面研究函数模板那样如法炮制,马上就能探究清楚。
|
||||
|
||||
比如,你可以先看一下示例程序在编译过程中形成的AST,我在其中做了一些标注,方便你理解:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/d6/7a1884bd2b01ccf65c42e80acc55ecd6.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/66/dd/66376b3d937aeba74e7bf33b6ba94fdd.jpg" alt="">
|
||||
|
||||
可以看到,在AST中,首先声明了一个结构体的模板,其AST节点的类型是ClassTemplateDecl。
|
||||
|
||||
接着,是针对这个模板做的特化。由于在main函数中引用了Fact<3>::value,所以编译器必须把Fact<3>特化。特化的结果,是生成了一棵ClassTemplateSpecializationDecl子树,此时模板参数为3。而这个特化版本又引用了Fact<2>::value。
|
||||
|
||||
那么,编译器需要再把Fact<2>特化。进一步,这个特化版本又引用了Fact<1>::value。
|
||||
|
||||
而Fact<1>这个特化版本,在程序中就已经提供了,它的value字段的值是常数1。
|
||||
|
||||
那么,经过这个分析过程,Fact<3>的值就可以递归地计算出来了。如果`Fact<n>`中,n的值更大,那计算过程也是一样的。
|
||||
|
||||
```
|
||||
Fact<3>::value = 3 * Fact<2>::value
|
||||
= 3 * 2 * Fact<1>::value
|
||||
= 3 * 2 * 1
|
||||
|
||||
```
|
||||
|
||||
另外,你还可以用这节课中学到的debug方法,跟踪一下上述过程,验证一下你的想法。在这个过程中,你仍然要注意设置最合适的断点。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天我们一起探讨了C++的模板机制的部分功能,并借此了解了Clang编译C++程序的机制。通过这节课,你会发现编译器是通过特化的机制,来生成新的AST子树,也就是生成新的程序,从而支持模板机制的。另外你还要明确,特化的过程是递归的,直到不再有特化任务为止。
|
||||
|
||||
模板功能是一个比较复杂的功能。而你发现,当你有能力进到编译器的内部时,你会更快、更深刻地掌握模板功能的实质。这也是编译原理知识对于学习编程的帮助。
|
||||
|
||||
探究C++的编译器是一项有点挑战的工作。所以在这节课里,我更关注的是如何带你突破障碍,掌握探究Clang编译器的方法。这节课我只带你涉及了Clang编译器一个方面的功能,你可以用这节课教给你的方法,继续去探究你关心的其他特性是如何实现的,可能会有很多惊喜的发现呢!
|
||||
|
||||
## 一课一思
|
||||
|
||||
在计算阶乘的示例程序中,当n是正整数时,都是能够正常编译的。而当n是0或者负数时,是不能正常编译的。你能否探究一下,编译器是如何发现和处理这种类型的编译错误的呢?
|
||||
|
||||
欢迎在留言区分享你的发现。如果你使用这节课的方法探究了C++编译器的其他特性,也欢迎你分享出来。
|
||||
211
极客时间专栏/geek/编译原理实战课/期中复习周/划重点 | 7种编译器的核心概念与算法.md
Normal file
211
极客时间专栏/geek/编译原理实战课/期中复习周/划重点 | 7种编译器的核心概念与算法.md
Normal file
@@ -0,0 +1,211 @@
|
||||
|
||||
你好,我是编辑王惠。
|
||||
|
||||
阶段性的总结复习和验证成果是非常重要的。所以,在8月7日到8月12日这为期一周的期中复习时间里,我们先来巩固一下“真实编译器解析篇”中的重点知识。你可以通过学习委员朱英达总结梳理的**划重点内容,以及涵盖了关键知识点的7张思维导图**,来回顾7种语言编译器的核心概念与算法。
|
||||
|
||||
另外,宫老师还精心策划了10道考试题,让你能在行至半程之时,做好自检,及时发现知识漏洞,到时候一起来挑战一下吧!
|
||||
|
||||
在期中复习周的最后,我还会邀请一位优秀的同学来做一次学习分享。通过他的学习故事,你也可以借此对照一下自己的编译原理学习之路。
|
||||
|
||||
好,下面我们就一起来复习这些核心的编译原理概念与算法知识吧。
|
||||
|
||||
## Java编译器(javac)
|
||||
|
||||
Java是一种广泛使用的计算机编程语言,主要应用于企业级Web应用开发、大型分布式系统以及移动应用开发(Android)。到现在,Java已经是一门非常成熟的语言了,而且它也在不断进化、与时俱进,泛型、函数式编程、模块化等特性陆续都增加了进来。与此同时,Java的编译器和虚拟机中所采用的技术,也比 20 年前发生了天翻地覆的变化。
|
||||
|
||||
Java的字节码编译器(javac)是用Java编写的,它实现了自举。启动Java编译器需要Java虚拟机(默认是HotSpot虚拟机,使用C++编写)作为宿主环境。
|
||||
|
||||
javac编译器的编译过程,主要涉及到了这样一些关键概念和核心算法:
|
||||
|
||||
- 词法分析阶段:基于有限自动机的理论实现。在处理标识符与关键字重叠的问题上,采用了先都作为标识符识别出来,然后再把其中的关键词挑出来的方式。
|
||||
- 语法分析阶段:使用了自顶向下的递归下降算法、LL(k)方式以及多Token预读;处理左递归问题时,采用了标准的改写文法的方法;处理二元表达式时,采用了自底向上的运算符优先级解析器。
|
||||
- 语义分析阶段:会分为多个小的阶段,且并不是顺序执行的,而是各阶段交织在一起。
|
||||
- 语义分析阶段主要包含:ENTER(建立符号表)、PROCESS(处理注解)、ATTR(属性分析)、FLOW(数据流分析)、TRANSTYPES(处理泛型)、TRANSPATTERNS(处理模式匹配)、UNLAMBDA(处理 Lambda)和 LOWER(处理其他所有的语法糖,比如内部类、foreach 循环等)、GENERATE 阶段(生成字节码)等。在ATTR和FLOW这两个阶段,编译器完成了主要的语义检查工作。
|
||||
- 注意:生成字节码是一个比较机械的过程,编译器只需要对 AST 进行深度优先的遍历即可。在这个过程中会用到前几个阶段形成的属性信息,特别是类型信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/27/3e8d282f1479671407ab81b576fb0c27.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. 关于注解的官方教程,参考[这个链接](https://docs.oracle.com/javase/tutorial/java/annotations/)。
|
||||
1. 关于数据流分析的理论性内容,参考龙书(Compilers Principles, Techniques and Tools)第二版的9.2和9.3节。也可以参考《编译原理之美》 的第[27](https://time.geekbang.org/column/article/155338)、[28](https://time.geekbang.org/column/article/156878)讲,那里进行了比较直观的讲述。
|
||||
1. 关于半格这个数学工具,可以参考龙书第二版的9.3.1部分,也可以参考《编译原理之美》的[第28讲](https://time.geekbang.org/column/article/156878)。
|
||||
1. Java语言规范第六章,参考[Java虚拟机指令集](https://docs.oracle.com/javase/specs/jvms/se13/html/jvms-6.html)。
|
||||
|
||||
## Java JIT编译器(Graal)
|
||||
|
||||
对于编译目标为机器码的Java后端的编译器来说,主要可以分AOT和JIT两类:如果是在运行前一次性生成,就叫做提前编译(AOT);如果是在运行时按需生成机器码,就叫做即时编译(JIT)。Java以及基于JVM的语言,都受益于JVM的JIT编译器。
|
||||
|
||||
在[JDK的源代码](https://hg.openjdk.java.net/jdk/jdk11/raw-file/tip/doc/building.html)中,你能找到src/hotspot目录,这是 JVM 的运行时:HotSpot虚拟机,它是用C++编写的,其中就包括JIT编译器。
|
||||
|
||||
Graal是Oracle公司推出的一个完全用Java语言编写的JIT编译器。Graal编译器有两个特点:内存安全(相比C++实现的Java JIT编译器而言);与Java配套的各种工具(比如ID)更友好、更丰富。
|
||||
|
||||
Java JIT编译器的编译过程,主要涉及到了这样一些关键概念和核心算法:
|
||||
|
||||
- 分层编译:C0(解释器)、C1(客户端编译器)、C2(服务端编译器)。不同阶段的代码优化激进的程度不同,且存在C2降级回C1的逆优化。
|
||||
- IR采用了“节点之海(Sea of Nodes)”,整合了控制流图与数据流图,符合 SSA 格式,有利于优化算法的编写和维护。
|
||||
- 两个重要的优化算法:内联优化和逃逸分析。
|
||||
- 几个重要的数据结构:HIR(硬件无关的IR)、LIR(硬件相关的IR)、CFG(控制流图)。
|
||||
- 寄存器分配算法:LinearScan。
|
||||
|
||||
**金句摘录:“编译器开发的真正的工作量,都在中后端。”**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/6c/2b7478eyyf5422d8398a7ce3baac656c.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. GraalVM项目的[官方网站](https://www.graalvm.org/);Graal的[Github地址](https://github.com/oracle/graal);Graal项目的[出版物](https://github.com/oracle/graal/blob/master/docs/Publications.md)。
|
||||
1. 基于图的IR的必读论文:程序依赖图-[J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization](https://www.cs.utexas.edu/users/less/reading/spring00/ferrante.pdf). July 1987;Click的论文-[A Simple Graph-Based Intermediate Representation](https://www.oracle.com/technetwork/java/javase/tech/c2-ir95-150110.pdf);介绍Graal IR的论文-[Graal IR: An Extensible Declarative Intermediate Representation](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.726.5496&rep=rep1&type=pdf)。
|
||||
1. 关于优化算法:多态内联-[Inlining of Virtual Methods](http://extras.springer.com/2000/978-3-540-67660-7/papers/1628/16280258.pdf);逃逸分析-[Escape Analysis for Java](https://www.cc.gatech.edu/~harrold/6340/cs6340_fall2009/Readings/choi99escape.pdf);部分逃逸分析-[Partial Escape Analysis and Scalar Replacement for Java](http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf)。
|
||||
|
||||
## Python编译器(CPython)
|
||||
|
||||
Python诞生于上个世纪90年代初,作者是荷兰计算机程序员吉多·范罗苏姆(Guido van Rossum)。Python语言的特点是:自身语法简单,容易掌握,强调一件事情只能用一种方法去做;具备丰富的现代语言特性,如OOP、FP等;其实现机制决定了易于集成C++扩展,不仅便于利用一些已有的、经典开源的高性能的C/C++库,同时也可以很方便地编写自己的C++扩展,实现一些高性能模块。
|
||||
|
||||
另外,Python使用了pgen这样的生成编译器的工具。pgen能够基于语法规则生成解析表(Parse Table),供语法分析程序使用。你可以通过修改规则文件来修改Python语言的语法,pgen能给你生成新的语法解析器。它是把EBNF转化成一个NFA,然后再把这个NFA转换成DFA。基于这个DFA,在读取Token的时候,编译器就知道如何做状态迁移,并生成解析树。Python用的是 LL(1) 算法。
|
||||
|
||||
CPython编译器编译器的编译过程,主要涉及到了这样一些关键概念和核心算法:
|
||||
|
||||
- 语法分析:首先是生成CST(Concret Syntax Tree,具体语法树),接着生成AST(抽象语法树)。CST的特点是完全基于源程序结构构建出树结构,它比AST啰嗦,但更精确地反映了语法推导的过程。而AST的特点是去掉了树结构上繁杂冗余的树枝节点,更简明扼要,更准确地表达了程序的结构。
|
||||
- 语义分析:Python通过一个建立符号表的过程来做相关的语义分析,包括做引用消解等。Python语言使用变量的特点是不存在变量声明,每个变量都是赋值即声明,因此在给一个变量赋值时需要检查作用域,确认当前操作是给全局的变量赋值,还是在局部给声明新变量。
|
||||
- 生成字节码:这个工作实际上包含了生成 CFG、为每个基本块生成指令,以及把指令汇编成字节码,并生成 PyCodeObject 对象的过程。另外,生成的字节码在最后放到解释器里执行之前,编译器还会再做一步窥孔优化工作。
|
||||
- 运行时机制:Python的运行时设计的核心,就是PyObject对象,Python对象所有的特性都是从PyObject的设计中延伸出来的。其虚拟机采用了栈机的架构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/a6/7868ac14a4d585a1897c6aece4c3dda6.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. [python.org网站](https://www.python.org/):下载[3.8.1版本的源代码](https://www.python.org/ftp/python/3.8.1/Python-3.8.1.tgz)。
|
||||
1. GDB的安装和配置:参考[这篇文章](https://github.com/RichardGong/CompilersInPractice/edit/master/python/GDB.md)。
|
||||
1. Python的[开发者指南](https://devguide.python.org/)网站。
|
||||
<li>pgen的工具程序:[Parser/pgen](https://github.com/python/cpython/blob/3.9/Parser/pgen/pgen.py)。<br>
|
||||
注:由于CPython最新的Master分支上的代码调整,此处pgen的链接地址调整为CPython3.9版本分支上的pgen相关代码。</li>
|
||||
1. Python的[字节码的说明](https://docs.python.org/zh-cn/3/library/dis.html#python-bytecode-instructions)。
|
||||
1. Python的[内置类型](https://docs.python.org/3.8/library/stdtypes.html)。
|
||||
|
||||
## JavaScript编译器(V8)
|
||||
|
||||
V8是谷歌公司在2008年推出的一款JavaScript编译器,主要由C++编写而成。V8主要应用于Chrome浏览器,后来也被开源社区中诸如Node.js等项目所使用。其最为突出的特点就是“快”,由于JavaScript是在浏览器下载完页面后马上编译并执行,它对编译速度有更高的要求。因此,V8采用了一系列技术手段优化编译和启动阶段运行速度。
|
||||
|
||||
在设计上,V8结合了分阶段懒解析、空间换时间等设计思路,突出了解析、启动阶段运行的时间开销。
|
||||
|
||||
- 对源代码的Parse,进行了流(Stream)化处理,也就是边下载边解析。
|
||||
- 预解析(PreParse)处理,也就是所谓懒解析最核心的设计思想,每个JS函数只有被执行的时候才会解析函数体的内部逻辑。
|
||||
|
||||
另外,V8的很多地方体现出了与Java编译器异曲同工之处。比如,它将JavaScript源代码的编译,分为了由Ignition字节码解释执行和TurboFan的JIT编译机器代码执行两部分组成,类似于Java编译器的字节码解释执行和Graal优化编译后执行两阶段;TurboFan编译器的IR也采用了Sea of Nodes,这一点类似于Java的Graal编译器,且也涉及到了内联优化和逃逸分析算法。
|
||||
|
||||
其运行方式分为两类:
|
||||
|
||||
- 常规情况下,Ignition字节码解释执行;
|
||||
- 编译器判定热点代码,TurboFan JIT编译成机器码执行,并且TurboFan会依赖一些Ignition解释执行过程中的运行时数据,来进行进一步优化,使机器码尽可能高效。
|
||||
|
||||
因为JavaScript是动态类型语言,因此对函数参数类型的推断以及针对性优化是一个V8的核心技术。V8涉及到的其他优化算法有:
|
||||
|
||||
- 隐藏类(Hidden Class)。相同“形状”的JS对象会被以同一个隐藏类维护其数据结构。
|
||||
- 内联缓存(Inline Caching)。针对隐藏类查找属性值时的指针偏移量,进行内联缓存,这属于结合隐藏类技术做进一步性能的优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/yy/05/yyf5a620aa7bbe31f710c3351f987d05.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. V8项目的[官网](https://v8.dev/),以及V8的源代码-[官方文档](https://v8.dev/docs/build)。
|
||||
1. 了解V8的解析器为什么速度非常快:[Blazingly fast parsing, part 1: optimizing the scanner](https://v8.dev/blog/scanner);[Blazingly fast parsing, part 2: lazy parsing](https://v8.dev/blog/preparser)。
|
||||
1. 了解Ignition的设计:[Ignition Design Doc](https://docs.google.com/document/d/11T2CRex9hXxoJwbYqVQ32yIPMh0uouUZLdyrtmMoL44/mobilebasic),宫老师在Github上也放了一个[拷贝](https://github.com/RichardGong/CompilersInPractice/blob/master/v8/Ignition%20Design%20Doc.pdf)。
|
||||
1. 了解Ignition的字节码:[Understanding V8’s bytecode](https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775)。
|
||||
1. V8的指针压缩技术:[Pointer Compression in V8](https://v8.dev/blog/pointer-compression)。
|
||||
1. 介绍V8基于推理的优化机制:[An Introduction to Speculative Optimization in V8](https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8)。
|
||||
1. 关于Ignition字节码做优化的论文:[Register equivalence optimization](https://docs.google.com/document/d/1wW_VkkIwhAAgAxLYM0wvoTEkq8XykibDIikGpWH7l1I/edit?ts=570d7131#heading=h.6jz9dj3bnr8t),宫老师在Github上也放了一份[拷贝](https://github.com/RichardGong/CompilersInPractice/blob/master/v8/Ignition_%20Register%20Equivalence%20Optimization.pdf)。
|
||||
|
||||
## Julia的编译器
|
||||
|
||||
Julia语言最初发行于2012年,其最初是为了满足高性能数值分析和计算科学的需要而设计的。Julia同时兼具了静态编译型和动态解释型语言的优点:一方面它的性能很高,可以跟Java和C语言媲美;另一方面,它又是动态类型的,编写程序时不需要指定类型。
|
||||
|
||||
Julia编译器的特点是:
|
||||
|
||||
- 作为动态类型语言,却能拥有静态类型语言的执行速度,最关键的原因是使用了LLVM作为编译器后端,针对动态类型的变量,在运行时由编译器JIT编译生成多个版本的目标代码,保证程序的高性能;
|
||||
- 由C、C++、Lisp和Julia四种语言开发而成,编译器前端主要采用Lisp实现;
|
||||
- 考虑到对函数式编程的支持,有别于面向对象编程的“单一分派”,Julia的编译器提供了“多重分派”的功能。
|
||||
|
||||
Julia编译器的编译过程,主要涉及到了这样一些关键概念和核心算法:
|
||||
|
||||
- Julia的编译器也是采用了递归下降算法来实现语法分析。
|
||||
- 其内部IR采用了SSA格式,主要作用是完成类型推断和内联优化。
|
||||
- Julia的IR会被转化成LLVM的IR,从而进一步利用LLVM的功能。在转换过程中,会用Julia的内建函数,这些内建函数代表了Julia语言中抽象度比较高的运算功能。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/e5/060397e2f5de051bf9474f2725f277e5.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. LLVM的[官网](https://llvm.org/),以及LLVM的[源代码](https://github.com/llvm/llvm-project)。
|
||||
1. Julia的开发者文档中有对如何使用LLVM的介绍:[Working with LLVM](https://docs.julialang.org/en/v1/devdocs/llvm/)。
|
||||
1. 对LLVM中的各种Pass的介绍:[LLVM’s Analysis and Transform Passes](https://llvm.org/docs/Passes.html)。
|
||||
1. 《编译原理之美》的[第25讲](https://time.geekbang.org/column/article/153192)和[第26讲](https://time.geekbang.org/column/article/154438):宫老师对LLVM后端及其命令行工具做了介绍,并且还手工调用LLVM的API,示范了针对不同的语法结构(比如if结构)应该如何生成LLVM IR,最后即时编译并运行。你可以参考一下。
|
||||
|
||||
## Go语言编译器(gc)
|
||||
|
||||
Go语言是Google开发的一种静态强类型、编译型、并发型,并具有垃圾回收功能的编程语言,又名Golang。Go广泛应用于Google的产品以及许多其他组织和开源项目,其创建的初衷就是主要面向于部署于大量服务器之间的分布式程序,也就是我们今天所说的“云”。因此,Go的主要优势聚焦于服务端高并发场景。
|
||||
|
||||
Go语言编译器的特点是:
|
||||
|
||||
- gc编译器除了少量标准库的内容是用汇编写的以外,其绝大部分逻辑,都是用Go语言本身写的,因此实现了较为完整的自举(Bootstraping),从前端到后端的整个流程都使用Go语言实现,在编程语言中是较为罕见的;
|
||||
- 教科书级别的设计,源代码完全自举、代码干净利索,因此非常适合作为学习参考。
|
||||
|
||||
Go语言编译器的编译过程,主要涉及到了这样一些关键概念和核心算法:
|
||||
|
||||
- 编译器前端:gc编译器的词法分析和语法分析使用的都是非常经典、传统的算法,如手写的递归下降算法、处理二元表达式时使用操作符优先级算法。
|
||||
- 中间代码阶段:SSA格式的IR;基于CFG的IR,利于“死代码”的发现与删除;多遍(Pass)的优化框架。
|
||||
- 机器码生成阶段:线性扫描算法;官方的gc编译器并没做指令重排,这是基于编译过程中时间开销的考虑。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/47/b4d6d2e094c9d2485303065945781047.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. 介绍gc编译器的主要结构:[Introduction to the Go compiler](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/README.md)官方文档。
|
||||
1. 介绍gc编译器的SSA:[Introduction to the Go compiler’s SSA backend](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/README.md)官方文档。
|
||||
1. Go compiler internals: adding a new statement to Go - [Part 1](https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-1/)、[Part2](https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-2/)。在这两篇博客里,作者做了一个实验:如果往Go里面增加一条新的语法规则,需要做哪些事情。我们能够很好地、贯穿性地了解一个编译器的方法。
|
||||
1. 介绍gc编译器的SSA优化规则描述语言的细节:[Go compiler: SSA optimization rules description language](https://quasilyte.dev/blog/post/go_ssa_rules/)。
|
||||
1. 介绍Go汇编的细节:[A Primer on Go Assembly](https://github.com/teh-cmc/go-internals/blob/master/chapter1_assembly_primer/README.md)和[A Quick Guide to Go’s Assembler](https://golang.org/doc/asm)。gc编译器采用的汇编语言是它自己的一种格式,是“伪汇编”。
|
||||
|
||||
## MySQL的编译器
|
||||
|
||||
MySQL是一个开放源码的关系数据库管理系统,原开发者为瑞典的MySQL AB公司,后几经辗转,目前归属于Oracle旗下产品。在过去,MySQL性能高、成本低、可靠性好,因此成为了最流行的开源数据库。SQL可以称得上是最成功的DSL(特定领域语言)之一,MySQL中的SQL解析模块则是这门DSL的非常具有可参考性的一个实现。MySQL使用C++编写,有少量几个代码文件是用C语言编写的。
|
||||
|
||||
MySQL的编译器的特点是:
|
||||
|
||||
- SQL作为DSL中最具有代表性的一种语言,学习它的编译器的实现,可以为我们自己设计面向业务的DSL提供参考。
|
||||
- 手写的词法分析、用工具(bison,GNU的yacc)生成的语法分析。
|
||||
- 基于LALR处理二元表达式。
|
||||
- 中后端优化方面:MySQL是解释执行,并没有做太多的机器相关优化;在机器无关优化方面,除了一些编译领域传统的优化技术之外,还做了一些针对数据库特定场景的优化方式。
|
||||
|
||||
MySQL的编译器的编译过程,主要涉及到了这样一些关键概念和核心算法:
|
||||
|
||||
**词法分析和语法分析**
|
||||
|
||||
- 词法分析:手写的词法分析器。
|
||||
- 语法分析:由bison生成。bison是一种基于EBNF生成语法分析程序的工具,可视为GNU版的yacc。
|
||||
- bison支持的语法分析算法:LALR算法。
|
||||
|
||||
**语义分析**
|
||||
|
||||
- MySQL中一些重要的数据结构:THD线程对象、Table_ident对象。
|
||||
- 上下文处理:基于contextualize的上下文处理。
|
||||
- 基于数据库“业务逻辑”的引用消解:库名、表名、列名、入口、查找和作用域(子查询)
|
||||
|
||||
**机器无关优化**
|
||||
|
||||
- 典型的优化:常量传播、死代码消除、常量折叠。
|
||||
- 针对数据库场景的优化:产生执行计划、生成JOIN对象。
|
||||
|
||||
**机器相关优化**
|
||||
|
||||
- MySQL实际上对表达式是解释执行,所以并没有真正意义上做任何机器相关优化。
|
||||
- 列举了PG另一种类似MySQL的DB,有通过LLVM的JIT优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/f0/bf7c421b3afa2a84dbdc8f3ae0c357f0.jpg" alt="">
|
||||
|
||||
参考资料:
|
||||
|
||||
1. 下载[MySQL的源代码](https://github.com/mysql/mysql-server);跟踪MySQL的执行过程,要用Debug模式编译MySQL,具体步骤可以参考这篇[开发者文档](https://dev.mysql.com/doc/internals/en/cmake.html)。
|
||||
1. MySQL的内行手册:[MySQL Internals Manual](https://dev.mysql.com/doc/internals/en/)。它能给我们提供一些重要的信息,但文档内容经常跟源代码的版本不同步,比如介绍源代码的目录结构的信息就过时了。需要注意一下。
|
||||
1. bison的[手册](http://www.gnu.org/software/bison/manual/)。
|
||||
1. 如果要加深对MySQL内部机制的了解,宫老师推荐了两本书:OReilly的《Understanding MySQL Internals》,以及《Expert MySQL》。
|
||||
8
极客时间专栏/geek/编译原理实战课/期中复习周/期中考试 | 这些编译原理知识,你都掌握了吗?.md
Normal file
8
极客时间专栏/geek/编译原理实战课/期中复习周/期中考试 | 这些编译原理知识,你都掌握了吗?.md
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
到这里,我们的课程就已经更新一半了,今天,我们来进行一场期中考试。我出了一套测试题,共有5道判断题、5道多选题,满分100,核心考点都出自前面的“预备知识”模块和“真实编译器解析”模块。
|
||||
|
||||
我建议你来认真地做一下这套题目,检验一下自己的学习效果。答完题之后,你也可以回顾试卷的内容,对不太理解或答错的问题,进行深入思考和学习。如果有不明白的,欢迎随时在留言区提问,我会知无不言。还等什么?一起来做下这套题吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=202&exam_id=539)
|
||||
94
极客时间专栏/geek/编译原理实战课/期中复习周/用户故事 | 易昊:程序员不止有Bug和加班,还有诗和远方.md
Normal file
94
极客时间专栏/geek/编译原理实战课/期中复习周/用户故事 | 易昊:程序员不止有Bug和加班,还有诗和远方.md
Normal file
@@ -0,0 +1,94 @@
|
||||
|
||||
你好,我是编辑王惠。在处理这门课的留言时,我注意到易昊同学一直在跟随着宫老师的脚步,学习和实践编译原理的相关知识,留言的内容十分有见地、提出的问题也能看出是经过了他深入的思考。同时,咱们这门课也具有很强的互动性,所以我邀请他来和我们分享一下他的心得体会。
|
||||
|
||||
Hi,我是易昊,目前在武汉做Android开发,已经工作12年了。很高兴能在这里跟你分享,关于我学习编译原理的一些心得体会。
|
||||
|
||||
## 为什么我要再学编译原理?
|
||||
|
||||
首先,我想给你解释一下,我为什么会起“程序员不止有Bug和加班,还有诗和远方”这样一个标题呢?
|
||||
|
||||
这是因为,作为一名应用开发者,我经常会觉得,自己只是在和源源不断的Bug以及项目进度作斗争,日常工作好像已经无法给我带来技术上的成就感了。但我能肯定的是,我对于技术的情怀并没有消失。我也认为,我不应该只满足于完成日常的普通开发任务,**而是应该去做点更有挑战性的事情,来满足自己精神上的追求**。
|
||||
|
||||
那么,我为什么会选择学习编译技术呢?
|
||||
|
||||
首要的原因,是这门课的内容不像编程语言、数据结构那样清晰直观。加上在大学时期,学校安排的课时较短,只有半个学期,自己又没有对它足够重视起来,导致这门课只学了个一知半解,从而造成自己对计算机底层工作原理没有掌握透彻,留下了遗憾。
|
||||
|
||||
《程序员的自我修养–链接、装载与库》里,有句话让我印象深刻:“真正了不起的程序员对自己程序的每一个字节都了如指掌。”虽然说得有点夸张,但一个优秀的程序员确实应该理解自己的程序为什么能在计算机上运行起来。而不是在写完代码后,忐忑不安地看着IDE在进行编译,等终于能运行起来时,大喊一声:“哇,能编译运行了,好神奇”。所以工作了几年之后,我一直想找机会能够弥补一下大学时期的遗憾,把编译知识学扎实。
|
||||
|
||||
另外,**编译技术的巨大挑战性,也是我想重拾学习的重要原因之一**。
|
||||
|
||||
你可能听过一个段子:程序员的三大浪漫,是自己实现编译器、操作系统和渲染引擎。
|
||||
|
||||
其实一开始,我并不理解为什么编译器会在这其中,我猜大概是因为特别困难吧。
|
||||
|
||||
我刚接触编程的时候,觉得能学好C、Java这样的编程语言,已经很不容易了,更何况要自己去用编译器实现一门语言。
|
||||
|
||||
而且编译原理这门课中,还有相当多比较深奥、晦涩的理论,想要掌握起来非常困难,它完全不像学习普通技术那样,几行代码就能运行一个hello world。同时你光有理论又完全不够,编译器中包含了很多巧妙的工程化处理。比如,会用抽象语法树来表示整个代码的语法结构;在函数调用时,参数和返回值在栈帧中的内存地址顺序等。
|
||||
|
||||
所以,不得不说,编译器是一个非常复杂的工程,编译原理是一个非常不容易消化、掌握的基础技术,让人望而生畏。
|
||||
|
||||
但是一旦掌握了之后,可以说就打通了计算机技术的任督二脉,因为你已经掌握了计算机运行中相当底层部分的原理,那么再去看其他技术就不会有什么大的障碍了。
|
||||
|
||||
而且在工作中,即使没有什么机会需要自己去创造语言或者写编译器,编译原理对我也很有帮助。举个例子吧,Android有很多动态化的技术,像ReactNative、Weex都会使用JavaScript作为脚本语言来调用原生接口,那么为了实现原生语言(如Java、Objective-C)和JavaScript的通信,ReactNative和Weex都在框架中嵌入了JavaScriptCore这个组件,因为JavaSciptCore包含了一个JavaScript的解释器,可以在运行时执行JavaScript。
|
||||
|
||||
那么,如果你也想在自己的项目中使用类似的动态化技术,但又不愿意被ReactNative和Weex技术绑死,就需要自己去剖析JavaScriptCore的工作原理,甚至要去实现一个类似的脚本语言执行框架。
|
||||
|
||||
**而真的要自己去下载JavaScriptCore的源码来看,你就会发现,如果没有一定的编译原理知识作为基础,是很难看懂的**。但是如果你具备了编译原理知识基础,其实会发现,这些源码里面也不外乎就是词法分析、语法分析、IR等,这些都是编译原理中的常用概念和算法。
|
||||
|
||||
还有就是日常工作中会碰到的某些比较棘手的问题,**如果你不理解编译技术,可能就无法找到出现问题的根源**。
|
||||
|
||||
比如我早期的工作中,在开发C++代码的时候,经常会遇到链接时找不到符号的问题,那个时候我对“符号是怎么产生的”并不理解,所以遇到这类问题,我就会在IDE或者代码里盲目地尝试修改。
|
||||
|
||||
后来重新学习了编译技术之后,我就理解了编译器在编译过程中会产生符号表,每个符号包含了符号的名称、类型、地址等信息。之所以出现这类问题,可能是依赖的静态库没有包含这些符号,或者是类型不正确,再或者是这些符号的地址无法被正确解析,所以我用相应的工具去检查一下静态库文件的符号表,一般就可以解决问题了。
|
||||
|
||||
你看,编译技术总能帮我解决一些,我之前挠破头都想不出解决方案的问题。到现在我工作了十几年以后,就会有一个越来越强烈的感悟:**最重要的还是底层知识**。以前我也曾经感慨过计算机技术发展之快,各种技术层出不穷,就拿Android技术来说,各种什么插件化、组件化、动态化技术让人眼花缭乱,要不就是今天谷歌在提倡用Kotlin,明天又开始推Flutter了。
|
||||
|
||||
所以有一段时间我比较迷茫,“我究竟该学什么呢”,但我后来发现,虽然那些新技术层出不穷,但万变不离其宗,计算机核心的部分还是编译原理、操作系统那些底层技术。如果你对底层技术真正吃透了,再来看这些时髦的新技术,就不会感到那么神秘了,甚至是可以马上就弄明白它背后的技术原理。**原理都搞懂了,那掌握起来也就非常快了。**
|
||||
|
||||
## 我是怎么学习专栏的?
|
||||
|
||||
我开始意识到自己需要重新学习编译技术是在2015年,当时为了能够在Android的原生代码里运行JavaScript脚本,我依次尝试了WebView、JavaScriptCore、Rhino等方案,觉得这种多语言混合开发的技术挺强大,也许能够改变主流的应用开发模式,于是就想继续研究这些框架是怎么工作的。但是我发现,自己对编译原理知识掌握得不牢,导致学习这些技术的时候有点无从下手。
|
||||
|
||||
那个时候还没有极客时间这样针对性较强的学习平台,我是自己买了些书,有权威的龙书和虎书,也有《两周自制脚本语言》这样的速成书籍,但总是不得要领。
|
||||
|
||||
像龙书、虎书这样的,主要花了大量的篇幅在讲理论。但面对工作和家庭的压力,又不允许我有那么大片的时间去学习理论,而且没有实践来练习的话,也很容易忘掉。
|
||||
|
||||
像速成书籍这样的,虽然实现了一个很简单的编译器,但它大量的篇幅是在讲代码的一些细节,原理又讲得太少,知其然而不知其所以然。后来我就没有继续深入地学下去了。
|
||||
|
||||
直到偶然地在极客时间上看到了《编译原理之美》这门课,我简单看了下目录之后,就立马买了下来,因为感觉非常实用,**既对理论部分的重点内容有深入浅出地讲解,也有与实际问题的紧密联系**。学完这门课后,我感觉有信心去尝试写一点东西了,正好临近春节,就计划着在春节期间**自己写些代码来验证学习的成果**。结果不成想遇到了疫情封城,因为没法复工,就索性在家自己照着龙书和课程中给出的思路,看看能否自己实现个编译器出来。
|
||||
|
||||
**结果我花了快两个月的时间,真的写出来了一个简单的编译器,能够把类似C语言风格的代码编译成汇编执行。**
|
||||
|
||||
回想那段时间,虽然疫情很让人焦虑,但当我全身心地投入到对编译技术的钻研时,可以暂时忘记疫情带来的困扰。通过写这个小项目,我算是对编译器的工作过程有了个切身的体会,还把多年未碰的汇编又重新拾起来投入使用,可以说是收获颇丰,疫情带给我的回忆也没有那么痛苦了,从另一个角度看,甚至还有一定的成就感。
|
||||
|
||||
这个简单的编译器项目完成了之后,就激发了我更大的兴趣。因为我毕竟只是实现了一个玩具型的编译器,那么工业界的编译器是如何工作的呢?
|
||||
|
||||
这个编译器,我是用LL算法来实现的语法分析,但龙书上还大篇幅地讲了LR、SLR、LALR,那么实际中到底是使用LL算法还是LR算法呢?
|
||||
|
||||
还有,我的编译器没有什么优化的功能,真实的编译器都有哪些优化措施呢?
|
||||
|
||||
这些问题吸引着我要去寻找答案。结果正巧,宫老师又推出了《编译原理实战课》,**深入浅出地讲解各大编译器的工作原理**,这可正好对我的胃口,于是又毫不犹豫地买下了。
|
||||
|
||||
上了几节课之后,觉得收获很大,特别是讲解javac和Graal编译器的部分。这两部分都给出了如何基于源码去剖析编译器的原理,实际操作性很强,**我跟着宫老师给出的步骤,下载和一步步地调试、跟踪源码**,印象十分深刻。特别是宫老师介绍的javac在处理运算符优先级时,引入了LR算法,从而避免了教科书上介绍的:当使用LL方式时,为了消除左递归,要写多级Tail函数的问题,这些都让我对编译技术的实际应用理解得更加深刻了。在学习Graal时,我也是花了不少的时间去配置Windows环境,包括下载安装Kali Linux、OpenJDK等,才终于把Graal跑起来。通过这样的实际操作,体验到了“折腾”的乐趣,对动手能力也是一种锻炼。
|
||||
|
||||
除此之外,**课程中还有丰富的流程图、类图和思维导图,因此我可以按图索骥,去研究自己感兴趣的知识点,不用再苦苦地从海量的源码中去大海捞针,学习效率得到了很大提升**。
|
||||
|
||||
## 如何更好地学习编译原理?
|
||||
|
||||
通过这段时间的学习后,我发现,编译原理其实并没有想象中的那么困难。我觉得计算机技术的一个特点就是像在搭积木,有时候只是知识点多、层次关系复杂而已。
|
||||
|
||||
编译技术尤其如此,从前端到后端,从词法分析到语法分析到语义分析、IR,最后到优化。这些地方包含着各种知识点,但这些知识也不是凭空变出来的,而是环环相扣、层层叠加出来的。**因此你在学习编译技术时一定要静下心来,一点一点地去吃透里面的技术细节,并且还需要不断地总结和提炼,否则对知识的理解可能就不够透彻,浮于表面。**
|
||||
|
||||
**另外,你需要多去动手实践,特别是和算法相关的部分。**比如,在编译器的语法分析阶段,有个内容是计算First集合和Follow集合,其实理解起来并不困难,但它包含的细节不少,容易算错,所以需要在课后多去练习。
|
||||
|
||||
我就是在课下,把宫老师布置的习题和龙书上相关的题目都算了一遍,还写了计算First集合和Follow集合的程序。不过就算是做到了这些,我感觉对这部分的理解还不够透彻,但是我对编译技术的恐惧感已经消除了,对后续进一步的深入挖掘也打下了基础。所以说,静下心来学,勤动手去练,对学习编译原理这门课程来说非常重要。
|
||||
|
||||
我还有一个体会就是,**学习编译原理没有捷径可走**。因为编译技术是一个复杂而精密的工程,它的知识是环环相扣的。比如说,如果你对词法分析和语法分析的知识掌握得不够牢固,不熟悉一些常用语法规则的推导过程,那后面的IR、三地址代码、CFG等,你就会学得一头雾水。
|
||||
|
||||
所以,我们只能够一步一个脚印地去学习。
|
||||
|
||||
当然了,如果你和我一样是一个有家有口的上班族,只利用碎片时间可能不好做到连续学习,那我建议你在学习课程的时候,可以稍微花点时间去**参考一下宫老师介绍的开源编译器代码**,比如[JavaCompiler的源码](https://hg.openjdk.java.net/jdk8/jdk8/langtools/)、[Graal的源码](https://github.com/oracle/graal)。这样就可以和课程中的内容结合起来。因为看代码和调试代码会更加直观,也更容易理解。
|
||||
|
||||
同时,**你也可以看看别人的代码设计**,有哪些地方是可以做成组件的,哪些函数是可以自己去实现来练手的。然后,你可以先给自己定一个小目标,就是利用业余时间去完成这些组件或者函数的开发,因为单个组件的工作量并不是太大,因此还是可以尝试完成的。这样在巩固理论知识的同时,还能锻炼自己的动手能力,我想热爱编程的你,应该可以从中得到不少快乐。
|
||||
|
||||
我相信,只要最终坚持下来,你和我,都可以掌握好编译这门“屠龙”技术。
|
||||
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语言比较简单,所以实现后端的工作量应该也相对较小。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我主要回答了几个比较宏观的问题,它们都涉及到了编译原理这门课的作用。
|
||||
|
||||
第一个问题,我是从提升算法素养的角度来展开介绍的。编译原理知识里面涉及了大量的算法,我把它总结成了三大类,每类都有自己的特点,希望能对你宏观把握它们有所帮助。
|
||||
|
||||
第二个问题,其实是这门课程的一条暗线。我并没有在课程里去情绪化地鼓吹,一定要有自己的编译器、自己的语言。我的方式其实是想做一点具体的事情,所以在第二个模块中,我带着你一起探究了现有语言的编译器都是怎么实现的,破除你对编译器的神秘感、距离感;在第三个模块,我们又一起探讨了一下实现一门语言中的那些关键技术点,比如垃圾收集、并行等,它们都是如何实现的。
|
||||
|
||||
在课程最后呢,我又带你了解了一下具有中国血统的方舟编译器。我想说的是,其实我们不但能做出编译器和语言来,而且可能会做得更好。虽然我们对方舟编译器的分析还没有做完,但通过分析它的技术思路,你应该或多或少地感受到了它的优秀。所以,针对“我们真的需要一门新语言吗”这个问题,我的回答是确定的。并且,即使你不去参与实现一门通用的语言,在实现自己领域的语言,以及把自己的软件做得更具通用性这点上,编译原理仍然能发挥巨大的作用,对你的职业生涯也会有切实的帮助。
|
||||
|
||||
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。
|
||||
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/09 | Java编译器(一):手写的编译器有什么优势?.md
Normal file
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/09 | Java编译器(一):手写的编译器有什么优势?.md
Normal file
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="09 | Java编译器(一):手写的编译器有什么优势?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/0f/a7/0f267cc3e8a87343eec1425225b971a7.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
从今天开始呢,我会带着你去考察实际编译器的具体实现机制,你可以从中学习和印证编译原理的基础知识,进而加深你对编译原理的理解。
|
||||
|
||||
我们探险的第一站,是很多同学都很熟悉的Java语言,我们一起来看看它的编译器里都有什么奥秘。我从97年就开始用它,算是比较早了。当时,我就对它的“一次编译,到处运行”留下了很深的印象,我在Windows下写的程序,编译完毕以后放到Solaris上就能跑。现在看起来这可能不算什么,但在当年,我在Windows和Unix下写程序用的工具可是完全不同的。
|
||||
|
||||
到现在,Java已经是一门非常成熟的语言了,而且它也在不断进化,与时俱进,泛型、函数式编程、模块化等特性陆续都增加了进来。在服务端编程领域,它也变得非常普及。
|
||||
|
||||
与此同时,Java的编译器和虚拟机中所采用的技术,也比20年前发生了天翻地覆的变化。对于这么一门成熟的、广泛普及的、又不断焕发新生机的语言来说,研究它的编译技术会带来两个好处:一方面,Java编译器所采用的技术肯定是比较成熟的、靠谱的,你在实现自己的编译功能时,完全可以去参考和借鉴;另一方面,你可以借此深入了解Java的编译过程,借此去实现一些高级的功能,比方说,按需生成字节码,就像Spring这类工具一样。
|
||||
|
||||
因此,我会花4讲的时间,跟你一起探索Java的前端编译器(javac)。然后再花4讲的时间在Java的JIT编译器上。
|
||||
|
||||
那么,针对Java编译器,你可能会提出下面的问题:
|
||||
|
||||
- **Java的编译器是用什么语言编写的?**
|
||||
- **Java的词法分析器和语法分析器,是工具生成的,还是手工编写的?为什么会这样选择?**
|
||||
- **语法分析的算法分为自顶向下和自底向上的。那么Java的选择是什么呢?有什么道理吗?**
|
||||
- **如何自己动手修改Java编译器?**
|
||||
|
||||
这些问题,在今天的旅程结束后,你都会获得解答。并且,你还会获得一些额外的启发:噢,原来这个功能是可以这样做的呀!这是对你探险精神的奖励。
|
||||
|
||||
好吧,让我们开始吧。
|
||||
|
||||
第一步,我们先初步了解一下Java的编译器。
|
||||
|
||||
## 初步了解Java的编译器
|
||||
|
||||
大多数Java工程师是通过javac命令来初次接触Java编译器的。假设你写了一个MyClass类:
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public int a = 2+3;
|
||||
public int foo(){
|
||||
int b = a + 10;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以用javac命令把MyClass.java文件编译成字节码文件:
|
||||
|
||||
```
|
||||
javac MyClass.java
|
||||
|
||||
```
|
||||
|
||||
那这个javac的可执行文件就是Java的编译器吗?并不是。javac只是启动了一个Java虚拟机,执行了一个Java程序,跟我们平常用“java”命令运行一个程序是一样的。换句话说,Java编译器本身也是用Java写的。
|
||||
|
||||
这就很有趣了。我们知道,计算机语言是用来编写软件的,而编译器也是一种软件。所以,一门语言的编译器,竟然可以用自己来实现。这种现象,叫做“**自举**”(Bootstrapping),这就好像一个人抓着自己的头发,要把自己提起来一样,多么神奇!实际上,一门语言的编译器,一开始肯定是要用其他语言来实现的。但等它成熟了以后,就会尝试实现自举。
|
||||
|
||||
既然Java编译器是用Java实现的,那意味着你自己也可以写一个程序,来调用Java的编译器。比如,运行下面的示例代码,也同样可以编译MyClass.java文件,生成MyClass.class文件:
|
||||
|
||||
```
|
||||
import javax.tools.JavaCompiler;
|
||||
import javax.tools.ToolProvider;
|
||||
|
||||
public class CompileMyClass {
|
||||
public static void main(String[] args) {
|
||||
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
|
||||
int result = compiler.run(null, null, null, "MyClass.java");
|
||||
System.out.println("Compile result code = " + result);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
其中,javax.tools.JavaCompiler就是Java编译器的入口,属于**java.compiler模块**。这个模块包含了Java语言的模型、注解的处理工具,以及Java编译器的API。
|
||||
|
||||
javax.tools.JavaCompiler的实现是com.sun.tools.javac.main.JavaCompiler。它在**jdk.compiler模块中**,这个模块里才是Java编译器的具体实现。
|
||||
|
||||
不过,在探索Java编译器的实现原理之前,你还需要从openjdk.java.net下载JDK的源代码,我使用的版本是JDK14。在IDE中跟踪JavaCompiler的执行过程,你就会看到它一步一步地都是使用了哪个类的哪个方法。Java的IDE工具一般都比较友好,给我们的探索提供了很多便利。
|
||||
|
||||
不仅如此,你还可以根据openjdk的[文档](https://hg.openjdk.java.net/jdk/jdk11/raw-file/tip/doc/building.html),从源代码构建出JDK。你还可以修改源代码并构建你自己的版本。
|
||||
|
||||
获得了源代码以后,我建议你重点关注这几个地方的源代码,这能帮助你迅速熟悉Java编译器的源代码结构。
|
||||
|
||||
**首先是com.sun.source.tree包,**这个包里面是Java语言的AST模型。我们在写一个编译器的时候,肯定要设计一个数据结构来保存AST,那你就可以去参考一下Java是怎么做的。接下来,我就挑其中几个比较常用的节点,给你解释一下:
|
||||
|
||||
- ExpressionTree指的是表达式,各种不同的表达式继承了这个接口,比如BinaryTree代表了所有的二元表达式;
|
||||
- StatementTree代表了语句,它的下面又细分了各种不同的语句,比如,IfTree代表了If语句,而BlockTree代表的是一个语句块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/0e/d69600c7a6b2c6aa288277690eaacb0e.jpg" alt="">
|
||||
|
||||
**然后是com.sun.tools.javac.parser.Lexer(词法解析器接口)**,它可以把字符流变成一个个的Token,具体的实现在Scanner和JavaTokenizer类中。
|
||||
|
||||
**接下来是com.sun.tools.javac.parser.Parser(语法解析器接口)**,它能够解析类型、语句和表达式,具体的实现在JavacParser类中。
|
||||
|
||||
总结起来,Java语言中与编译有关的功能放在了两个模块中:其中,java.compiler模块主要是对外的接口,而jdk.compiler中有具体的实现。**不过你要注意,**像com.sun.tools.javac.parser包中的类,不是Java语言标准的组成部分,如果你直接使用这些类,可能导致代码在不同的JDK版本中不兼容。
|
||||
|
||||
现在,我们已经熟悉了Java编译器的概要信息。在浏览这两个模块的代码时,我们会发现里面的内容非常多。为了让自己不会迷失在其中,我们需要找到一个方法。你已经知道,编译器的前端分为词法分析、语法分析、语义分析等阶段,那么我们就可以按照这个阶段一块一块地去探索。
|
||||
|
||||
首先,我们看看Java的词法分析器。
|
||||
|
||||
## 词法分析器也是构造了一个有限自动机吗?
|
||||
|
||||
通过跟踪执行,你会发现词法分析器的具体实现在JavaTokenizer类中。你可以先找到这个类,在readToken()方法里打个断点,让程序运行到这里,然后查看词法分析的执行过程。
|
||||
|
||||
在学词法分析的时候,你肯定知道要构造一个有限自动机,而且当输入的字符发生变化的时候,自动机的状态也会产生变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/14/ac4f8932b2488ad0f3815853a4180114.jpg" alt="">
|
||||
|
||||
**那么实战中,Java做词法分析的逻辑是什么呢?**你可以先研究一下readToken()方法,这个方法实现了主干的词法分析逻辑,它能够从字符流中识别出一个个的Token来。
|
||||
|
||||
readToken的逻辑变成伪代码是这样的:
|
||||
|
||||
```
|
||||
循环读取字符
|
||||
case 空白字符
|
||||
处理,并继续循环
|
||||
case 行结束符
|
||||
处理,并继续循环
|
||||
case A-Za-z$_
|
||||
调用scanIden()识别标识符和关键字,并结束循环
|
||||
case 0之后是X或x,或者1-9
|
||||
调用scanNumber()识别数字,并结束循环
|
||||
case , ; ( ) [ ]等字符
|
||||
返回代表这些符号的Token,并结束循环
|
||||
case isSpectial(),也就是% * + - | 等特殊字符
|
||||
调用scanOperator()识别操作符
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
如果画成有限自动机,大致是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/aa/9df3399ca53df76ca9b5e5b4f4d953aa.jpg" alt="">
|
||||
|
||||
在[第2讲](https://time.geekbang.org/column/article/243685)中我提到过,关键字和标识符的规则是冲突的:
|
||||
|
||||
- 标识符的规则是以`A-Za-z$_`开头,后续字符可以是`A-Za-z$_`、数字和其他的合法字符;
|
||||
- 关键字(比如if)也符合标识符的规则,可以说是标识符的子集。
|
||||
|
||||
这种冲突是词法分析的一个技术点,因为不到最后你不知道读入的是一个关键字,还是一个普通的标识符。如果单纯按照有限自动机的算法去做词法分析,想要区分int关键字和其他标识符的话,你就会得到图4那样的一个有限自动机。
|
||||
|
||||
当输入的字符串是“int”的时候,它会进入状态4。如果这个时候遇到结束字符,就会提取出int关键字。除此之外,“i”(状态2)、“in”(状态3)和“intA”(状态5)都属于标识符。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/1c/31d083e8e33b25c1b6ea721f20e7ce1c.jpg" alt="">
|
||||
|
||||
但是关键字有很多,if、else、int、long、class…如果按照这个方式构造有限自动机,就会很啰嗦。那么java是怎么处理这个问题的呢?
|
||||
|
||||
Java编译器的处理方式比较简单,分成了两步:首先把所有的关键字和标识符都作为标识符识别出来,然后再从里面把所有预定义的关键字挑出来。这比构造一个复杂的有限自动机实现起来更简单!
|
||||
|
||||
通过这样的代码分析,你可以发现:Java的词法解析程序在主干上是遵循有限自动机的算法的,但在很多局部的地方,为了让词法分析的过程更简单高效,采用了手写的算法。
|
||||
|
||||
我建议你在IDE中,采用调试模式跟踪执行,看看每一步的执行结果,这样你能对Java词法分析的过程和结果有更直观的理解。另外,你还可以写一个程序,直接使用词法分析器做解析,并打印出一个个Token。这会很有趣,你可以试试看!
|
||||
|
||||
接下来,我们进一步研究一下Java的语法分析器。
|
||||
|
||||
## 语法分析器采用的是什么算法?
|
||||
|
||||
跟所有的语法分析器一样,Java的语法分析器会把词法分析器生成的Token流,生成一棵AST。
|
||||
|
||||
下面的AST就是MyClass.java示例代码对应的AST(其中的JCXXX节点都是实现了com.sun.source.tree中的接口,比如JCBinary实现了BinaryTree接口,而JCLiteral实现了LiteralTree接口)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/10/540c482ffb8914ebfb2b17dbb7584410.jpg" alt="">
|
||||
|
||||
我想你应该知道,语法分析的算法分为自顶向下和自底向上两种:
|
||||
|
||||
- 以LL算法为代表的自顶向下的算法,比较直观、容易理解,但需要解决左递归问题;
|
||||
- 以LR算法为代表的自底向上算法,能够避免左递归问题,但不那么直观,不太容易理解。
|
||||
|
||||
**那么,Java编译器用的是什么算法呢?**
|
||||
|
||||
你可以打开com.sun.tools.javac.parser.JavacParser这个类看一下代码。比如,你首先查看一下parseExpression()方法(也就是解析一个表达式)。阅读代码,你会看到这样的调用层次:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/9d/53181f61a99adb806a11b836feae7d9d.jpg" alt="">
|
||||
|
||||
我们以解析“2+3”这样一个表达式,来一层一层地理解下这个解析过程。
|
||||
|
||||
**第1步,需要匹配一个term。**
|
||||
|
||||
term是什么呢?其实,它就是赋值表达式,比如“a=2”或“b=3”等。算法里把这样一个匹配过程又分为两部分,赋值符号左边的部分是term1,其他部分是termRest。其中,term1是必须匹配上的,termRest是可选的。如果匹配上了termRest,那么证明这是个赋值表达式;否则就只是左边部分,也就是term1。
|
||||
|
||||
如果你比较敏感的话,那仅仅分析第一步,你差不多就能知道这是什么算法了。
|
||||
|
||||
另外,你可能还会对Rest这个单词特别敏感。你还记得我们在什么地方提到过Rest这个词汇吗?是的,在[第3讲](https://time.geekbang.org/column/article/244906)中,我把左递归改写成右递归的时候,那个右递归的部分,我们一般就叫做XXXRest或XXXTail。
|
||||
|
||||
不过没关系,你可以先保留着疑问,我们继续往下看,来印证一下看法是不是对的。
|
||||
|
||||
**第2步,匹配term1。**
|
||||
|
||||
term1又是什么呢?term1是一个三元表达式,比如a > 3 ? 1 : 2。其中,比较操作符左边的部分是term2,剩下的部分叫做term1Rest。其中term2是必须匹配的,term1Rest是可选的。
|
||||
|
||||
**第3步,匹配term2。**
|
||||
|
||||
term2代表了所有的二元表达式。它再次分为term3和term2Rest两部分,前者是必须匹配的,后者是可选的。
|
||||
|
||||
**第4步,匹配term3。**
|
||||
|
||||
term3往下我就不深究了,总之,是返回一个字面量2。
|
||||
|
||||
**第5步,匹配term2Rest。**
|
||||
|
||||
首先匹配“+”操作符;然后匹配一个term3(),这里是返回一个字面量3。
|
||||
|
||||
**第6步,回到term1()方法,试图匹配term1Rest,没有匹配上。**
|
||||
|
||||
**第7步,回到term()方法,试图匹配termRest,也没有匹配上。**
|
||||
|
||||
**第8步,从term()方法返回一个代表“2+3”的AST**,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/cb/891f0fe35a9df5ddc02c773229298bcb.jpg" alt="">
|
||||
|
||||
讲到这儿,我想问问你:你从这样的分析中,得到了什么信息?
|
||||
|
||||
**第一,这是一个递归下降算法。**因为它是通过逐级下降的方法来解析,从term到term1、term2、term3,直到最后是字面量这样最基础的表达式。
|
||||
|
||||
在第3讲里我说过,递归下降算法是每个程序员都应该掌握的语法分析算法。**你看,像Java这么成熟的语言,其实采用的也是递归下降算法。**
|
||||
|
||||
**第二,Java采用了典型的消除左递归的算法。**我带你回忆一下,对于:
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
|
||||
```
|
||||
|
||||
这样的左递归的文法,它可以改成下面的非左递归文法:
|
||||
|
||||
```
|
||||
add -> mul add'
|
||||
add' -> + add' | ε
|
||||
|
||||
```
|
||||
|
||||
如果我再换一下表达方式,就会变成Java语法解释器里的代码逻辑:
|
||||
|
||||
```
|
||||
term2 -> term3 term2Rest
|
||||
term2Rest -> + term3 | ε
|
||||
|
||||
```
|
||||
|
||||
**第三,Java编译器对优先级和结合性的处理,值得深究。**
|
||||
|
||||
首先看看优先级。我们通常是通过语法逐级嵌套的方式来表达优先级的。比如,按照下面的语法规则生成的AST,乘法节点会在加法节点下面,因此先于加法节点计算,从而优先级更高。实际上,Java做语法分析的时候,term1->term2->term3的过程,也是优先级逐步提高的过程。
|
||||
|
||||
```
|
||||
add -> mul add'
|
||||
add' -> + mul add' | ε
|
||||
mul -> pri mul'
|
||||
mul' -> * pri mul' | ε
|
||||
|
||||
```
|
||||
|
||||
可是,在term2中,实际上它解析了所有的二元表达式,在语法规则上,它把使用“&&”“ >”“+”“*” 这些不同优先级的操作符的表达式,都同等看待了。
|
||||
|
||||
```
|
||||
term2 -> term3 term2Rest
|
||||
term2Rest -> (&& | > | + | * |...) term3 | ε
|
||||
|
||||
```
|
||||
|
||||
不过,这里面包含了多个优先级的运算符,却并没有拆成很多个级别,这是怎么实现的呢?
|
||||
|
||||
我们再来看看结合性。对于“2+3+4”这样一个表达式,我在[第3讲](https://time.geekbang.org/column/article/244906),是把右递归调用转换成一个循环,让新建立的节点成为父节点,从而维护正确的结合性。
|
||||
|
||||
如果你阅读term2Rest的代码,就会发现它的处理逻辑跟第3讲是相同的,也就是说,它们都是用循环的方式,来处理连续加法或者连续乘法,并生成结合性正确的AST。
|
||||
|
||||
不过,Java编译器的算法更厉害。它不仅能用一个循环处理连续的加法和连续的乘法,对于“2+3*5”这样采用了多种不同优先级的操作符的表达式,也能通过一个循环就处理掉了,并且还保证了优先级的正确性。
|
||||
|
||||
在term2Rest中,可以使用多个优先级的操作符,从低到高的顺序如下:
|
||||
|
||||
```
|
||||
"||"
|
||||
"&&"
|
||||
"|"
|
||||
"^"
|
||||
"&"
|
||||
"==" | "!="
|
||||
"<" | ">" | "<=" | ">="
|
||||
"<<" | ">>" | ">>>"
|
||||
"+" | "-"
|
||||
"*" | "/" | "%"
|
||||
|
||||
```
|
||||
|
||||
如果按照常规的写法,我们处理上面10级优先级的操作符,需要写10级嵌套的结构。而Java用一级就解决了。这个秘密就在term2Rest()的实现中。我们以“`2*3+4*5`”为例分析一下。
|
||||
|
||||
term2Rest()算法维护了一个操作数的栈(odStack)和操作符的栈(opStack),作为工作区。算法会根据odStack、opStack和后续操作符这三个信息,决定如何生成优先级正确的AST。我把解析“`2*3+4*5`”时栈的变化,画成了一张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e4/01/e44da65b1941adf47423b9550eccf101.jpg" alt="">
|
||||
|
||||
在一步一步解析的过程中,当opStack的栈顶运算符的优先级大于等于后续运算符的优先级时,就会基于odStack栈顶的两个元素创建一棵二元表达式的子树,就像第2步那样。
|
||||
|
||||
反过来的话,栈顶运算符的优先级小于后续运算符的优先级(像第4步那样),就会继续把操作数和操作符入栈,而不是创建二元表达式。
|
||||
|
||||
这就可以保证,优先级高的操作符形成的子树,总会在最后的AST的下层,从而优先级更高。
|
||||
|
||||
再仔细研究一下这个算法,你会发现,它是借助一个工作区,自底向上地组装AST。**是不是觉得很眼熟?是不是想到了LR算法?**没错,这就是一个简单LR算法。操作数栈和操作符栈是工作区,然后要向后预读一个运算符,决定是否做规约。只不过做规约的规则比较简单,依据相邻的操作符的优先级就可以了。
|
||||
|
||||
其实,这种处理表达式优先级的解析方法,有一个专有的名字,就叫做**“运算符优先级解析器(Operator-Precedence Parser)”**。Java编译器用这一个算法处理了10个优先级的二元表达式的解析,同时又不用担心左递归问题,确实很棒!
|
||||
|
||||
## 课程小结
|
||||
|
||||
本节课,我带你揭秘了Java编译器的一角,我想强调这样几个重点。
|
||||
|
||||
第一,你要大致熟悉一下Java语言中与编译有关的模块、包和类。这样,在你需要的时候,可以通过编程来调用编译器的功能,在运行时动态编译Java程序,并动态加载运行。
|
||||
|
||||
第二,Java的词法分析总体上是遵循有限自动机的原理,但也引入了不少的灵活性。比如,在处理标识符和关键字的词法规则重叠的问题上,是先都作为标识符识别出来,然后再把其中的关键词挑出来。
|
||||
|
||||
第三,Java的语法分析总体上是**自顶向下**的递归下降算法。在解决左递归问题时,也采用了标准的改写文法的方法。但是,在处理二元表达式时,局部采用了**自底向上**的**运算符优先级解析器**,使得算法更简洁。
|
||||
|
||||
当然了,我没有覆盖所有的词法解析和语法解析的细节。但你按照今天这一讲的分析思路,完全能看懂其他部分的代码。通过我帮你开的这个头,我期待你继续钻研下去,搞清楚Java的词法和语法解析功能的每个细节。
|
||||
|
||||
比如,递归下降算法中最重要的是要减少试错次数,一下子就能精准地知道应该采用哪个产生式。**而你通过阅读代码,会了解Java的编译器是如何解决这个问题的:**它在一些语法上会预读一个Token,在另外的语法上会预读两个、三个Token,以及加上一些与上下文有关的代码,通过种种方式来减少回溯,提高编译性能。这,实际上就是采用了LL(k)算法的思路,而k值是根据需要来增加的。
|
||||
|
||||
通过今天的分析,你会发现Java编译器在做词法和语法分析的时候,总体上遵循了编译原理中的知识点,比如构造有限自动机、改写左递归文法等等,但又巧妙地引入了不少的变化,包括解决词法规则冲突、融合了自顶向下算法和自底向上算法、根据情况灵活地预读1到多个Token等。我相信对你会大有启发!像这样的实战知识,恐怕只有分析实际编译器才能获得!更进一步地,你以后也可以用这样漂亮的方法解决问题。这就是对你这次探险的奖励。
|
||||
|
||||
我把这一讲的知识点用思维导图整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/c9/9cfa983900dddad7c9146efbacc35cc9.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
运算符优先级解析器非常实用,我们通过练习巩固一下对它的认识。你能推导一下解析“a>b*2+3”的时候,odStack、opStack和后续运算符都是什么吗?你也可以跟踪Java编译器的执行过程,验证一下你的推导结果。
|
||||
|
||||
你可以在留言区交一下作业。比如像这样:
|
||||
|
||||
```
|
||||
step1: a
|
||||
step2: a,b > * //用逗号分隔栈里的多个元素
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
我会在下一讲的留言区,通过置顶的方式公布标准答案。好了,这节课就到这里,感谢你的阅读,欢迎你把今天的内容分享给更多的朋友。
|
||||
372
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/10 | Java编译器(二):语法分析之后,还要做些什么?.md
Normal file
372
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/10 | Java编译器(二):语法分析之后,还要做些什么?.md
Normal file
@@ -0,0 +1,372 @@
|
||||
<audio id="audio" title="10 | Java编译器(二):语法分析之后,还要做些什么?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b4/a0/b4687fc5a4aa5977a03a68f2b77bd1a0.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲,我带你了解了Java语言编译器的词法分析和语法分析功能,这两项工作是每个编译器都必须要完成的。那么,根据[第1讲](https://time.geekbang.org/column/article/242479)我对编译过程的介绍,接下来就应该是语义分析和生成IR了。对于javac编译器来说,生成IR,也就是字节码以后,编译器就完成任务了。也就是说,javac编译器基本上都是在实现一些前端的功能。
|
||||
|
||||
不过,由于Java的语法特性很丰富,所以即使只是前端,它的编译功能也不少。那么,除了引用消解和类型检查这两项基础工作之外,你是否知道注解是在什么时候处理的呢?泛型呢?还有各种语法糖呢?
|
||||
|
||||
所以,今天这一讲,我就带你把Java编译器的总体编译过程了解一遍。然后,我会把重点放在语义分析中的引用消解、符号表的建立和注解的处理上。当你学完以后,你就能真正理解以下这些问题了:
|
||||
|
||||
- **符号表是教科书上提到的一种数据结构,但它在Java编译器里是如何实现的?编译器如何建立符号表?**
|
||||
- **引用消解会涉及到作用域,那么作用域在Java编译器里又是怎么实现的?**
|
||||
- **在编译期是如何通过注解的方式生成新程序的?**
|
||||
|
||||
为了方便你理解Java编译器内部的一些对象结构,我画了一些类图(如果你不习惯看类图的话,可以参考下面的图表说明,比如我用方框表示一个类,用小圆圈表示一个接口,几种线条分别代表继承关系、引用关系和接口实现)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/7e/27802f28fe7ee30308bc28edcc45997e.jpg" alt="">
|
||||
|
||||
在课程开始之前,我想提醒几点:建议你在一个良好的学习环境进入今天的学习,因为你需要一步步地,仔细地跟住我的脚步,避免在探索过程中迷路;除此之外,你的手边还需要一个电脑,这样随时可以查看我在文章中提到的源代码。
|
||||
|
||||
## 了解整个编译过程
|
||||
|
||||
现在,你可以打开jdk.compiler模块中的**com.sun.tools.javac.comp包**对应的源代码目录。
|
||||
|
||||
comp应该是Compile的缩写。这里面有一个com.sun.tools.javac.comp.CompileStates类,它的意思是编译状态。其中有一个枚举类型CompileState,里面列出了所有的编译阶段。
|
||||
|
||||
你会看到,词法和语法分析只占了一个环节(PARSE),生成字节码占了一个环节,而剩下的8个环节都可以看作是语义分析工作(建立符号表、处理注解、属性计算、数据流分析、泛型处理、模式匹配处理、Lambda处理和去除其他语法糖)。
|
||||
|
||||
```
|
||||
public enum CompileState {
|
||||
INIT(0), //初始化
|
||||
PARSE(1), //词法和语法分析
|
||||
ENTER(2), //建立符号表
|
||||
PROCESS(3), //处理注解
|
||||
ATTR(4), //属性计算
|
||||
FLOW(5), //数据流分析
|
||||
TRANSTYPES(6), //去除语法糖:泛型处理
|
||||
TRANSPATTERNS(7), //去除语法糖:模式匹配处理
|
||||
UNLAMBDA(8), //去除语法糖:LAMBDA处理(转换成方法)
|
||||
LOWER(9), //去除语法糖:内部类、foreach循环、断言等。
|
||||
GENERATE(10); //生成字节码
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外,你还可以打开**com.sun.tools.javac.main.JavaCompiler**的代码,看看它的**compile()方法**。去掉一些细节,你会发现这样的代码主干,从中能看出编译处理的步骤:
|
||||
|
||||
```
|
||||
processAnnotations( //3:处理注解
|
||||
enterTrees(stopIfError(CompileState.PARSE, //2:建立符号表
|
||||
initModules(stopIfError(CompileState.PARSE,
|
||||
parseFiles(sourceFileObjects)) //1:词法和语法分析
|
||||
))
|
||||
),classnames);
|
||||
|
||||
...
|
||||
case SIMPLE:
|
||||
generate( //10:生成字节码
|
||||
desugar( //6~9:去除语法糖
|
||||
flow( //5:数据流分析
|
||||
attribute(todo)))); //4:属性计算
|
||||
|
||||
```
|
||||
|
||||
其中,PARSE阶段的成果就是生成一个AST,后续的语义分析阶段会基于它做进一步的处理:
|
||||
|
||||
- **enterTrees()**:对应ENTER,这个阶段的主要工作是建立符号表。
|
||||
- **processAnnotations()**:对应PROCESS阶段,它的工作是处理注解。
|
||||
- **attribute()**:对应ATTR阶段,这个阶段是做属性计算,我会在下一讲中给你做详细的介绍。
|
||||
- **flow()**:对应FLOW阶段,主要是做数据流分析。我在[第7讲](https://time.geekbang.org/column/article/248770)中就提到过数据流分析,那时候是用它来做代码优化。那么,难道在前端也需要做数据流分析吗?它会起到什么作用?这些问题的答案我也会在下一讲中为你揭晓。
|
||||
- **desugar()**:去除语法糖,其实这里包括了TRANSTYPES(处理泛型)、TRANSPATTERNS(处理模式匹配)、UNLAMBDA(处理Lambda)和LOWER(处理其他所有的语法糖,比如内部类、foreach循环等)四个阶段,我会在第12讲给你介绍。
|
||||
- **generate()**:生成字节码,对应了GENERATE阶段,这部分内容我也会在第12讲详细介绍。
|
||||
|
||||
在今天这一讲,我会给你介绍前两个阶段的工作:建立符号表和处理注解。
|
||||
|
||||
首先,我们来看看Enter阶段,也就是建立符号表的过程。
|
||||
|
||||
## ENTER阶段:建立符号表
|
||||
|
||||
Enter阶段的主要代码在**com.sun.tools.javac.comp.Enter类**中。在这个阶段,会把程序中的各种符号加到符号表中。
|
||||
|
||||
### 建立符号表
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/246281)中,我已经介绍了符号表的概念。符号表是一种数据结构,它保存了程序中所有的定义信息,也就是你定义的每个标识符,不管是变量、类型,还是方法、参数,在符号表里都有一个条目。
|
||||
|
||||
那么,我们再深入看一下,什么是符号。
|
||||
|
||||
其实,符号代表了一门语言的基础构成元素。在java.compiler模块中定义了Java语言的构成元素(Element),包括模块、包、类型、可执行元素、变量元素等。这其中的每个元素,都是一种符号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6a/e5/6aacb5d391a4343677c35c3c12dedee5.jpg" alt="">
|
||||
|
||||
而在jdk.compiler模块中,定义了这些元素的具体实现,也就是Symbol,符号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/d6/04e06cec9075d4c3955007daf67853d6.jpg" alt="">
|
||||
|
||||
符号里记录了一些重要的属性信息,比如名称(name)、类型(type)、分类(kind)、所有者(owner)等,还有一些标记位,标志该符号是否是接口、是否是本地的、是否是私有的,等等,这些信息在语义分析和后续编译阶段都会使用。另外,不同的符号还有一些不同的属性信息,比如变量符号,会记录其常数值(constValue),这在常数折叠优化时会用到。
|
||||
|
||||
**那么,Enter过程是怎样发生的呢?**你可以看一下com.sun.tools.javac.comp.MemberEnter类中的 **visitVarDef()方法**。
|
||||
|
||||
实际上,当看到一个方法使用visit开头的时候,你应该马上意识到,这个方法是被用于一个Visitor模式的调用中。也就是说,Enter过程是一个对AST的遍历过程,遍历的时候,会依次调用相应的visit方法。visitVarDef()是用于处理变量声明的。
|
||||
|
||||
我们还以MyClass的编译为例来探索一下。MyClass有一个成员变量a,在Enter阶段,编译器就会为a建立符号。
|
||||
|
||||
我们来看看它的创建过程:
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public int a = 2+3;
|
||||
public int foo(){
|
||||
int b = a + 10;
|
||||
return b;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我从visitVarDef()中挑出了最重要的三行代码,需要你着重关注。
|
||||
|
||||
```
|
||||
...
|
||||
//创建Symbol
|
||||
VarSymbol v = new VarSymbol(0, tree.name, vartype, enclScope.owner);
|
||||
...
|
||||
tree.sym = v; //关联到AST节点
|
||||
...
|
||||
enclScope.enter(v); //添加到Scope中
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
第一行,是**创建Symbol**。
|
||||
|
||||
第二行,是**把Symbol关联到对应的AST节点**(这里是变量声明的节点JCVaraibleDecl)。
|
||||
|
||||
你可以看一下各个AST节点的定义,其中的类、方法、变量声明、编译单元,以及标识符,都带有一个sym成员变量,用来关联到一个符号。这样后续在遍历树的时候,你就很容易查到这个节点对应的Symbol了。
|
||||
|
||||
不过你要注意,**各种声明节点(类声明、方法声明等)对应的符号,是符号的定义。而标识符对应的Symbol,是对符号的引用。**找到每个标识符对应的定义,就是语义分析中的一项重要工作:引用消解。不过,引用消解不是在Enter阶段完成的,而是在ATTR阶段。
|
||||
|
||||
你在跟踪编译器运行的时候,可以在JCClassDecl等**AST节点的sym变量上**打个中断标记,这样你就会知道sym是什么时候被赋值的,从而也就了解了整个调用栈,这样会比较省事。
|
||||
|
||||
延伸一句:当你调试一个大的系统时,选择好恰当的断点很重要,会让你事半功倍。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/49/1843fac0c5689b4f9f0cf5fd7dbffa49.jpg" alt="">
|
||||
|
||||
最后来看一下第三行代码,这行代码是**把Symbol添加到Scope中**。
|
||||
|
||||
什么是Scope?Scope就是作用域。也就是说,在Enter过程中,作用域也被识别了出来,每个符号都是保存在相应的作用域中的。
|
||||
|
||||
在[第4讲](https://time.geekbang.org/column/article/245754),我们曾经说过,符号表可以采用与作用域同构的带层次的表格。Java编译器就是这么实现的。符号被直接保存进了它所在的词法作用域。
|
||||
|
||||
在具体实现上,Java的作用域所涉及的类比较多,我给你整理了一个类图,你可以参考一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/d1/61074a180a7dabcf1642c9c6ed8af8d1.jpg" alt="">
|
||||
|
||||
其中有几个关键的类和接口,需要给你介绍一下。
|
||||
|
||||
首先是**com.sun.tools.javac.code.Scope$ScopeImpl**类:这是真正用来存放Symbol的容器类。通过next属性来指向上一级作用域,形成嵌套的树状结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/d4/b188f2baa5d8f2e9c8185971921925d4.jpg" alt="">
|
||||
|
||||
但是,在处理AST时,如何找到当前的作用域呢?这就需要一个辅助类:**Env< AttrContext>**。Env的意思是环境,用来保存编译过程中的一些上下文信息,其中就有当前节点所处的作用域(Env.info.scope)。下图展示的是在编译过程中,所使用的Env的情况,这些Env也构成了一个树状结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/01/f5/014f30bb63f82e109f63a93ee7f41ef5.jpg" alt="">
|
||||
|
||||
然后是**com.sun.source.tree.Scope接口:**这是对作用域的一个抽象,可以获取当前作用域中的元素、上一级作用域、上一级方法以及上一级类。
|
||||
|
||||
好了,这就是与符号表有关的数据结构,后续的很多处理工作都要借助这个数据结构。比如,你可以思考一下,如何基于作用域来做引用消解?在下一讲,我会给你揭晓这个问题的答案。
|
||||
|
||||
### 两阶段的处理过程
|
||||
|
||||
前面讨论的是符号表的数据结构,以及建立符号表的大致过程。接下来,我们继续深究一下建立符号表算法的一个重要特点:**Enter过程是分两个阶段完成的**。
|
||||
|
||||
你可以打开Enter类,看看Enter类的头注释,里面对这两个阶段做了说明。
|
||||
|
||||
1. 第一个阶段:只是扫描所有的类(包括内部类),建立类的符号,并添加到作用域中。但是每个类定义的细节并没有确定,包括类所实现的接口、它的父类,以及所使用的类型参数。类的内部细节也没有去扫描,包括其成员变量、方法,以及方法的内部实现。
|
||||
1. 第二个阶段:确定一个类所缺失的所有细节信息,并加入到符号表中。
|
||||
|
||||
这两个阶段,第一个阶段做整个程序的扫描,把所有的类都识别出来。而第二个阶段是在需要的时候才进行处理的。
|
||||
|
||||
这里的问题是:**为什么需要两个阶段?只用一个阶段不可以吗?**
|
||||
|
||||
我借一个例子给你解释一下原因。你看看下面这段示例代码,在Enter过程中,编译器遍历了MyClass1的AST节点(JCClassDecl),并建立了一个ClassSymbol。但在遍历到它的成员变量a的时候,会发现它不认识a的类型MyClass2,因为MyClass2的声明是在后面的。
|
||||
|
||||
```
|
||||
public class MyClass1{
|
||||
MyClass2 a;
|
||||
}
|
||||
class MyClass2{
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
怎么办呢?我们只好分成两个阶段去完成扫描。在第一个阶段,我们为MyClass1和MyClass2都建立符号,并且都放到符号表中;第二阶段,我们再去进一步扫描MyClass1的内部成员的时候,就能为成员变量a标注正确的类型,也就是MyClass2。
|
||||
|
||||
我在[第4讲](https://time.geekbang.org/column/article/245754)中说过,语义分析的特点是上下文相关的。通过对比,你会发现,处理上下文相关情况和上下文无关情况的算法,它们是不一样的。
|
||||
|
||||
语法解析算法处理的是上下文无关的情况,因此无论自顶向下还是自底向上,整个算法其实是线性执行的,它会不断地消化掉Token,最后产生AST。对于上下文相关的情况,算法就要复杂一些。对AST各个节点的处理,会出现相互依赖的情况,并且经常会出现环形依赖,因为两个类互相引用在Java语言里是很常见的。加上这些依赖关系以后,AST就变成了一张图。
|
||||
|
||||
而语义分析算法,实质上就是对图的遍历算法。我们知道,图的遍历算法的复杂度是比较高的。编译器一般要采用一定的**启发式(Heuristic)**的算法,人为地找出代价较低的遍历方式。Java编译器里也采用了启发式的算法,我们尽量把对图的遍历简化为对树的遍历,这样工作起来就会简单得多。
|
||||
|
||||
对AST的遍历采用了Visitor模式。下图中我列出了一些采用Visitor模式对AST进行处理的程序。Enter程序是其中的一个。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/73/6a/733cee5d3744f2675966c3a6c0a33f6a.jpg" alt="">
|
||||
|
||||
所以,语义分析就是由各种对AST进行遍历的算法构成的。在跟踪Java编译器执行的过程中,你还会发现多个处理阶段之间经常发生交错。比如,对于方法体中声明的局部变量,它的符号不是在ENTER阶段创建的,而是在ATTR阶段又回过头来调用了与建立符号表有关的方法。你可以先想想这又是什么道理。这里留下一个伏笔,我会在下一讲中给你解答。
|
||||
|
||||
### 系统符号表
|
||||
|
||||
前面说的符号表,保存了用户编写的程序中的符号。可是,还有一些符号,是系统级的,可以在不同的程序之间共享,比如原始数据类型、java.lang.Object和java.lang.String等基础对象、缺省的模块名称、顶层的包名称等。
|
||||
|
||||
Java编译器在Symtab类中保存这些系统级的符号。系统符号表在编译的早期就被初始化好,并用于后面的编译过程中。
|
||||
|
||||
以上就是ENTER阶段的所有内容。接着,编译器就会进入下一个阶段:PROCESS阶段,也就是处理注解。
|
||||
|
||||
## PROCESS阶段:处理注解
|
||||
|
||||
注解是Java语言中的一个重要特性,它是Java元编程能力的一个重要组成部分。所谓元编程,简单地说,就是用程序生成或修改程序的能力。
|
||||
|
||||
Java的注解需要在编译期被解析出来。在Java编译器中,注解被看作是符号的元数据,所以你可以看一下SymbolMetadata类,它记录了附加在某个符号上的各种注解信息。
|
||||
|
||||
然后呢,编译器可以在三个时机使用这些注解:一是在编译时,二是在类加载时,三是在类运行时。
|
||||
|
||||
对于后两者,编译器需要做的工作比较简单,把注解内容解析出来,放到class文件中。这样的话,PROCESS阶段不需要做什么额外的工作。
|
||||
|
||||
而有些注解是要在编译期就处理的,这些注解最后就没必要保存到class文件。因为它们的使命在编译阶段就完成了。
|
||||
|
||||
那在编译阶段会利用注解做什么呢?最主要的用法,是根据注解动态生成程序,并且也被编译器编译。在后面探索Java的JIT编译器时,你会看到采用这种思路来生成程序的实例。你可以用简单的注解,就让注解处理程序生成很长的、充满“刻板代码”的程序。
|
||||
|
||||
我写了一个非常简单的示例程序,来测试Java编译器处理注解的功能。该注解叫做HelloWorld:
|
||||
|
||||
```
|
||||
@Retention(RetentionPolicy.SOURCE) //注解用于编译期处理
|
||||
@Target(ElementType.TYPE) //注解是针对类型的
|
||||
public @interface HelloWorld {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
针对这个注解,需要写一个注解处理程序。当编译器在处理该注解的时候,就会调用相应的注解处理程序。你可以看一下HelloWorldProcessor.java程序。它里面的主要逻辑是获取被注解的类的名称,比如说叫Foo,然后生成一个HelloFoo.java的程序。这个程序里有一个sayHello()方法,能够打印出“Hello Foo”。如果被注解的类是Bar,那就生成一个HelloBar.java,并且打印“Hello Bar”。
|
||||
|
||||
我们看一下Foo的代码。你注意,这里面有一个很有意思的现象:在Foo里调用了HelloFoo,但HelloFoo其实当前并没有生成!
|
||||
|
||||
```
|
||||
@HelloWorld
|
||||
public class Foo {
|
||||
//HelloFoo类是处理完注解后才生成的。
|
||||
static HelloFoo helloFoo = new HelloFoo();
|
||||
public static void main(String args[]){
|
||||
helloFoo.sayHello();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以在命令行编译这三个程序。其中编译Foo的时候,要用-processor选项指定所采用的注解处理器。
|
||||
|
||||
```
|
||||
javac HelloWorld.java
|
||||
javac HelloWorldProcessor.java
|
||||
javac -processor HelloWorldProcessor Foo.java
|
||||
|
||||
```
|
||||
|
||||
在这个编译过程中,你会发现当前目录下生成了HelloFoo.java文件,并且在编译Foo.java之前就被编译了,这样在Foo里才能调用HelloFoo的方法。
|
||||
|
||||
你可以在IDE里跟踪一下编译器对注解的处理过程。借此,你也可以注意一下编译器是如何管理编译顺序的,因为HelloFoo一定要在Foo之前被编译。
|
||||
|
||||
扩展:Debug对注解的处理过程需要有一定的技巧,请参考我为你整理的[配置指南](https://github.com/RichardGong/CompilersInPractice/tree/master/javac)。
|
||||
|
||||
你会发现,在Enter之后,声明helloFoo这个成员变量的语句的vartype节点的类型是ErrorType,证明这个时候编译器是没有找到HelloFoo的定义的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/92/ca/9235588eb06fc348f7d049ab2472cfca.jpg" alt="">
|
||||
|
||||
不过,在编译器处理完毕注解以后,HelloFoo就会被生成,Foo类的ENTER过程还会重走一遍,这个时候相关类型信息就正确了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
好了,本讲我们首先对Java的编译过程做了一个顶层的介绍,然后分析了ENTER和PROCESS阶段所做的工作。希望你能有以下收获:
|
||||
|
||||
1. 对前端编译过程可以有更加细致的了解,特别是对语义分析阶段,会划分成多个小阶段。由于语法分析的本质是对图做处理,所以实际执行过程不是简单地逐个阶段顺序执行,而是经常交织在一起,特别是ENTER阶段和ATTR阶段经常互相交错。
|
||||
1. ENTER阶段建立了符号表这样一个重要的数据结构。我们现在知道Java的符号表是按照作用域的结构建立的,而AST的每个节点都会对应某个作用域。
|
||||
1. PROCESSOR阶段完成了对注解的处理。你可以在代码里引用将要生成的类,做完注解处理后,这些类会被生成并编译,从而使得原来的程序能够找到正确的符号,不会报编译错误。
|
||||
|
||||
在最后,为了帮你将今天的内容做一个梳理,我提供一张思维导图,供你参考,整理知识:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/ed/2d98c9a7536e598d3ee7c86322e772ed.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在Java语言中,对于下面的示例代码,会产生几个作用域,你觉得它们分别是什么?
|
||||
|
||||
```
|
||||
public class ScopeTest{
|
||||
public int foo(int a){
|
||||
if(a>0){
|
||||
//一些代码
|
||||
}
|
||||
else{
|
||||
//另一些代码
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区分享你的答案,如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
关于注解的官方教程,你可以参考[这个链接](https://docs.oracle.com/javase/tutorial/java/annotations/)。
|
||||
|
||||
## 扩展知识
|
||||
|
||||
Java编译器的功能很多。如果你有精力,还可以探索一些有趣的细节功能,比如:你知道Java在编译阶段,会自动生成缺省的构造函数吗?
|
||||
|
||||
ENTER的第二个阶段任务是由TypeEnter类来完成的,你可以查看一下这个类的说明。它内部划分成了4个小的阶段,每个阶段完成一个更小一点的任务。其中的**MemberPhase阶段**,会把类的成员都建立符号,但MemberPhase还会做一件有趣的事情,就是**生成缺省的构造函数**。
|
||||
|
||||
为什么说这个细节很有趣呢?因为这是你第一次遇到在语义分析的过程中,还要对AST做修改。下面我们看看这个过程。
|
||||
|
||||
首先,你需要重新回顾一下缺省构造函数的意思。
|
||||
|
||||
在编写Java程序时,你可以不用写构造函数。对于下面这个MyClass5类,我们没有写构造函数,也能正常地实例化:
|
||||
|
||||
```
|
||||
public class MyClass5{
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但在语义分析阶段,实际上编译器会修改AST,插入一个缺省构造函数(相当于下面的代码)。缺省的构造函数不带参数,并且调用父类的一个不带参数构造方法(对于MyClass5类来说,父类是java.lang.Object类,“super()”引用的就是Object类的不带参数的构造方法)。
|
||||
|
||||
```
|
||||
public class MyClass3{
|
||||
public MyClass3(){
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的AST如下,其中**JCMethodDecl**这棵子树,就是语义分析程序插入的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/81/f64c5134193e489023d694b747216281.jpg" alt="">
|
||||
|
||||
新插入的构造方法是以JCMethodDecl为根节点的一棵子树。对于这个JCMethodDecl节点,在属性标注做完以后,形成了下面的属性。
|
||||
|
||||
- 名称:`<init>`。
|
||||
- 类型:()void,也就是没有参数,返回值为void。
|
||||
- 符号:生成了一个方法型的符号(sym属性),它的名称是`<init>`,如果调用sym.isConstructor()方法,返回true,也就是说,这个符号是一个构造方法。
|
||||
|
||||
在这个缺省构造方法里,调用了“super();”这样一个语句,让父类有机会去做初始化工作,进而也让父类的父类有机会去做初始化工作,依次类推。
|
||||
|
||||
“super()”语句的叶子节点是一个JCIndent节点,也就是标识符。这个标识符的名称是”super“,而符号(sym属性),则通过变量引用消解,指向了Object类的构造方法。
|
||||
|
||||
最后,我们声明MyClass5类的时候,也并没有声明它继承自Object。这个信息也是自动推断出来的,并且会在类节点(JCClassDecl)的type属性中标注清楚。在图中你可以看到,type.supertype_field指向了Object这个类型。
|
||||
|
||||
除了在自动生成的缺省构造函数里会调用super(),你还知道,当我们手写一个构造函数的时候,也可以在第一句里调用父类的一个构造方法(并且必须是在第一句)。
|
||||
|
||||
```
|
||||
public class MyClass4 extends MyClass3{
|
||||
public MyClass4(int a){
|
||||
super(a); //这句可以省略,编译器可以自动生成
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你不显式地调用super(),编译器也会自动加入这样的一个调用,并生成相应的AST。这个时候,父类和子类的构造方法的参数也必须一致。也就是说,如果子类的构造方法的签名是(int, String),那么父类也必须具备相同签名的一个构造方法,否则没有办法自动生成对父类构造方法的调用语句,编译器就会报错。**我相信你很可能在编程时遇到过这种编译信息,不过现在你应该就能清晰地了解,为什么编译器会报这些类型的编译错误了。**
|
||||
|
||||
总体来说,Java的编译器会根据需要加入一些AST节点,实现一些缺省的功能。其中包括缺省的构造方法、对父类构造方法的缺省调用,以及缺省的父类(Object)。
|
||||
421
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/11 | Java编译器(三):属性分析和数据流分析.md
Normal file
421
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/11 | Java编译器(三):属性分析和数据流分析.md
Normal file
@@ -0,0 +1,421 @@
|
||||
<audio id="audio" title="11 | Java编译器(三):属性分析和数据流分析" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ea/d3/eac7851daf0e66333c09ddacd31bbed3.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在上一讲,我们主要讨论了语义分析中的ENTER和PROCESS阶段。今天我们继续往下探索,看看ATTR和FLOW两个阶段。
|
||||
|
||||
**ATTR的字面意思是做属性计算。**在[第4讲](https://time.geekbang.org/column/article/245754)中,我已经讲过了属性计算的概念,你应该还记得什么是S属性,什么是I属性。那么,Java编译器会计算哪些属性,又会如何计算呢?
|
||||
|
||||
**FLOW的字面意思是做数据流分析。**通过[第7讲](https://time.geekbang.org/column/article/248770),你已经初步了解了数据流分析的算法。但那个时候是把数据流分析用于编译期后端的优化算法,包括删除公共子表达式、变量传播、死代码删除等。而这里说的数据流分析,属于编译器前端的工作。那么,前端的数据流分析会做什么工作呢?
|
||||
|
||||
这些问题的答案,我今天都会为你一一揭晓。好了,我们进入正题,首先来看看ATTR阶段的工作:属性分析。
|
||||
|
||||
## ATTR:属性分析
|
||||
|
||||
现在,你可以打开**com.sun.tools.javac.comp.Attr类**的代码。在这个类的头注释里,你会发现原来ATTR做了四件事,分别在4个辅助类里实现:
|
||||
|
||||
1. Check:做类型检查。
|
||||
1. Resolve:做名称的消解,也就是对于程序中出现的变量和方法,关联到其定义。
|
||||
1. ConstFold:常量折叠,比如对于“2+3”这种在编译期就可以计算出结果的表达式,就直接计算出来。
|
||||
1. Infer:用于泛型中的类型参数推导。
|
||||
|
||||
我们首先来看Check,也就是类型检查。
|
||||
|
||||
### 类型检查
|
||||
|
||||
类型检查是语义分析阶段的一项重要工作。静态类型系统的语言,比如Java、C、Kotlin、Swift,都可以通过类型检查,避免很多编译错误。
|
||||
|
||||
那么,一个基础的问题是:**Java都有哪些类型?**
|
||||
|
||||
你是不是会觉得这个问题挺幼稚?Java的类型,不就是原始数据类型,再加上类、接口这些吗?
|
||||
|
||||
说得对,但是并不全面。你已经看到,Java编译器中每个AST节点都有一个type属性。那么,一个模块或者一个包的类型是什么?一个方法的类型又是什么呢?
|
||||
|
||||
在java.compile模块中,定义了Java的语言模型,其中有一个包,是对Java的类型体系做了设计,你可以看一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/ae/81e4126c7d121c4239ed5d96a31430ae.jpg" alt="">
|
||||
|
||||
这样你就能理解了:原来模块和包的类型是NoType,而方法的类型是可执行类型(ExecutableType)。你可以看一下源代码,会发现要刻画一个可执行类型是比较复杂的,竟然需要5个要素:
|
||||
|
||||
- returnType:返回值类型;
|
||||
- parameterTypes:参数类型的列表;
|
||||
- receiverType:接收者类型,也就是这个方法是定义在哪个类型(类、接口、枚举)上的;
|
||||
- thrownTypes:所抛出异常的类型列表;
|
||||
- typeVariables:类型参数的列表。
|
||||
|
||||
如果你学过C语言,你应该记得描述一个函数的类型只需要这个列表中的前两项,也就是返回值类型和参数类型就可以了。通过这样的对比,想必你会对Java的可执行类型理解得更清楚。
|
||||
|
||||
然而,通过一个接口体系来刻画类型还是不够细致,Java又提供了一个TypeKind的枚举类型,把某些类型做进一步的细化,比如原始数据类型进一步细分为BOOLEAN、BYTE、SHORT等。这种设计方式可以减少接口的数量,使类型体系更简洁。你也可以在编程中借鉴这种设计方式,避免产生过多的、没有什么实际意义的子类型。
|
||||
|
||||
同样,在jdk.compiler模块中,有一些具体的类实现了上述类型体系的接口:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/e2/b06b582d22336658ffa412fc7905d8e2.jpg" alt="">
|
||||
|
||||
好了,现在你已经了解了Java的类型体系。**那么,编译器是如何实现类型检查的呢?**
|
||||
|
||||
我用一个Java程序的例子,来给你做类型检查的说明。在下面这段代码中,变量a的声明语句是错误的,因为等号右边是一个字符串字面量“Hello”,类型是java.lang.String,跟变量声明语句的类型“int”不相符。在做类型检查的时候,编译器应该检查出这个错误来。
|
||||
|
||||
而后面那句“`float b = 10`”,虽然变量b是float型的,而等号右边是一个整型的字面量,但Java能够自动把整型字面量转化为浮点型,所以这个语句是合法的。
|
||||
|
||||
```
|
||||
public class TypeCheck{
|
||||
int a = "Hello"; //等号两边的类型不兼容,编译报错
|
||||
float b = 10; //整型字面量可以赋值给浮点型变量
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对于“`int a = "hello"`”这个语句,它的类型检查过程分了四步,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/30/52a37499a6503c390c832b569b355830.jpg" alt="">
|
||||
|
||||
**第1步,计算vartype子节点的类型。**这一步是在把a加入符号表的时候(MemberEnter)就顺便一起做了(调用的是“Attr.attribType()方法”)。计算结果是int型。
|
||||
|
||||
**第2步,在ATTR阶段正式启动以后,深度优先地遍历整棵AST,自底向上计算每个节点的类型。**自底向上是S属性的计算方式。你可以看一下Attr类中的各种attribXXX()方法,大多数都是要返回一个类型值,也就是处理完当前子树后的类型。这个时候,能够知道init部分的类型是字符串型(java.lang.String)。
|
||||
|
||||
**第3步,检查init部分的类型是否正确**。这个时候,比对的就是vartype和init这两棵子树的类型。具体实现是在Check类的**checkType()**方法,这个方法要用到下面这两个参数。
|
||||
|
||||
- **final Type found**:“发现”的类型,也就是“Hello”字面量的类型,这里的值是java.lang.String。这个是自底向上计算出来的,属于S属性。
|
||||
- **final Type req**:“需要”的类型,这里的值是int。也就是说,a这个变量需要初始化部分的类型是int型的。这个变量是自顶向下传递下来的,属于I属性。
|
||||
|
||||
所以你能看出,所谓的类型检查,就是所需类型(I属性)和实际类型(S属性)的比对。
|
||||
|
||||
这个时候,你就会发现类型不匹配,从而记录下错误信息。
|
||||
|
||||
下面是在做类型检查时整个的调用栈:
|
||||
|
||||
```
|
||||
JavaCompiler.compile()
|
||||
->JavaCompiler.attribute()
|
||||
->Attr.attib()
|
||||
->Attr.attribClass() //计算TypeCheck的属性
|
||||
->Attr.attribClassBody()
|
||||
->Attr.attribStat() //int a = "Hello";
|
||||
->Attr.attribTree() //遍历声明成员变量a的AST
|
||||
->Attr.visitVarDef() //访问变量声明节点
|
||||
->Attr.attribExpr(TCTree,Env,Type)//计算"Hello"的属性,并传入vartype的类型
|
||||
->Attr.attribTree() //遍历"Hello"AST,所需类型信息在ResultInfo中
|
||||
->Attr.visitLiteral() //访问字面量节点,所需类型信息在resultInfo中
|
||||
->Attr.check() //把节点的类型跟原型类型(需要的类型)做比对
|
||||
->Check.checkType() //检查跟预期的类型是否一致
|
||||
|
||||
```
|
||||
|
||||
**第4步:继续自底向上计算类型属性。**这个时候会把变量声明语句JCVariableDecl的类型设置为vartype的类型。
|
||||
|
||||
上面是对变量a的声明语句的检查过程。对于“`float b = 10`”的检查过程也类似,但整型是允许赋值给浮点型的,所以编译器不会报错。
|
||||
|
||||
说完了类型检查,我们继续看一下Resolve,也就是引用的消解。
|
||||
|
||||
### 引用消解
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/246281)中,我就介绍过了引用消解的概念。给你举个例子,当我们在程序中用到一个变量的时候,必须知道它确切的定义在哪里。比如下面代码中,第4行和第6行都用到了一个变量a,但它们指的不是同一个变量。**第4行的a是类的成员变量,第6行的a是foo()函数中的本地变量。**
|
||||
|
||||
```
|
||||
public class RefResolve extends RefResolveParent {
|
||||
int a = 2;
|
||||
void foo(int d){
|
||||
int b = a + f; //这里的a是RefResolve的成员变量
|
||||
int a = 3; //本地变量a,覆盖了类的成员变量a
|
||||
int c = a + 10; //这里的a是前一句中声明的本地变量
|
||||
}
|
||||
}
|
||||
|
||||
class RefResolveParent{
|
||||
int f = 4; //父类中的成员变量
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在编译器中,这两行中的a变量,都对应一个标识符(JCIdent)节点,也都会关联一个Symbol对象。但这两个Symbol对象不是同一个。第4行的a指的是类的成员变量,而第6行的a指的是本地变量。
|
||||
|
||||
**所以,具体到Java编译器,引用消解实际上就是把标识符的AST节点关联到正确的Symbol的过程。**
|
||||
|
||||
引用消解不仅仅针对变量,还针对类型、包名称等各种用到标识符的地方。如果你写了“System.out.println()”这样一个语句,就要引用正确的包符号。
|
||||
|
||||
你可以打开com.sun.tools.javac.comp.Resolve类的**findIdentInternal方法**,能看到对几种不同的符号做引用消解的入口。
|
||||
|
||||
```
|
||||
...
|
||||
|
||||
if (kind.contains(KindSelector.VAL)) { //变量消解
|
||||
sym = findVar(env, name);
|
||||
...
|
||||
}
|
||||
|
||||
if (kind.contains(KindSelector.TYP)) { //类型消解
|
||||
sym = findType(env, name);
|
||||
...
|
||||
}
|
||||
|
||||
if (kind.contains(KindSelector.PCK)) //包名称消解
|
||||
return lookupPackage(env, name);
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
引用消解的实现思路也很清晰。在上一讲,你知道编译器在Enter阶段已经建立了作用域的嵌套结构。那么在这里,**编译器只需要沿着这个嵌套结构逐级查找就行了**。
|
||||
|
||||
比如,对于“`int b = a + f`”这个变量声明语句,在查找变量a时,沿着Scope的嵌套关系往上查找两级就行。但对于变量f,还需要沿着类的继承关系,在符号表里找到父类(或接口),从中查找有没有名称为f的成员变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/f3/d875ececc422f5e102f73cd5c14a37f3.jpg" alt="">
|
||||
|
||||
不过,这里还有一个细节需要深究一下。还记得我在前一讲留了一个问题吗?这个问题是:**对于方法体中的本地变量,不是在ENTER阶段创建符号,而是在ATTR阶段。**具体来说,就是在ATTR的Resolve环节。这是为什么呢?为什么不在ENTER环节把所有的符号都识别出来,并且加到作用域中就行了?
|
||||
|
||||
我来解答一下这个问题。我们把RefResolve类中的“`int a = 2;`”这行注释掉,会发生什么事情呢?foo()函数的第一行“`int b = a + f`”应该报错,因为找不到a的定义。
|
||||
|
||||
```
|
||||
public class RefResolve extends RefResolveParent{
|
||||
//int a = 2; //把这行注释掉
|
||||
void foo(int d){
|
||||
int b = a + f; //这里找不到a,应该报错
|
||||
int a = 3; //本地变量a,覆盖了类的成员变量a
|
||||
int c = a + 10; //这里的a是前一句中声明的本地变量
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但是,如果编译器在ENTER阶段就把所有的符号建立起来了,**那么会发生什么情况呢**?foo()的方法体所对应的Scope就会有一个符号a。按照前面描述的逐级查找算法,它就会认为“`int b = a + f`”里的这个a,就是本地变量a。这当然是错误的。
|
||||
|
||||
所以,为了保证消解算法不出错,必须保证在做完“`int b = a + f`”这句的引用消解之后,才会启动下一句“`int a = 3`”的ENTER过程,把符号a添加的foo()方法体的作用域中。引用消解都处理完毕以后,符号表才会填充完整,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1f/cc/1fc9eae0c6941834d276c4b071ea04cc.jpg" alt="">
|
||||
|
||||
### 常数折叠
|
||||
|
||||
在ATTR阶段,还会做一项优化工作:Constant Fold,即常数折叠。
|
||||
|
||||
我们知道,优化工作通常是在编译器的后端去做的。但因为javac编译器只是个前端编译器,生成字节码就完成任务了。不过即使如此,也要保证字节码是比较优化的,减少解释执行的消耗。
|
||||
|
||||
因为常数折叠借助属性计算就可以实现,所以在ATTR阶段顺便就把这个优化做了。
|
||||
|
||||
**Java在什么情况下会做常数折叠呢?**我们来看看下面这个例子。变量a和b分别是一个整型和字符串型的常数。这样的话,“`c=b+a*3`”中c的值,是可以在编译期就计算出来的。这要做两次常数折叠的计算,最后生成一个“`Hello 6`”的字符串常数。
|
||||
|
||||
```
|
||||
public class ConstFold {
|
||||
public String foo(){
|
||||
final int a = 2; //int类型的常数
|
||||
final String b = "Hello "; //String类型的常数
|
||||
String c = b + a * 3; //发生两次折叠
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
触发上述常数折叠的代码,在com.sun.tools.javac.comp.Attr类的**visitBinary()方法**中,具体实现是在com.sun.tools.javac.comp.ConstFold类。它的计算逻辑是:针对每个AST节点的type,可以通过Type.constValue()方法,看看它是否有常数值。如果二元表达式的两个子节点都有常数值,那么就可以做常数折叠,计算出的结果保存在父节点的type属性中。你可以看看下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0y/d7/0yyd23af4a5fff193bb37c4ecd9753d7.jpg" alt="">
|
||||
|
||||
扩展:你看了这个图,可能会有一个疑问:常数值为什么不是保存在AST节点中,而是保存在类型对象中?类型带上一个值是什么意思?常数值为2的整型和常数值为3的整型是不是一个类型?<br>
|
||||
这是因为Type里保存的信息本来就比较杂。我们前面分析过,一个可执行类型(比如方法)里包含返回值、参数类型等各种信息。一个类型的元数据信息(通常指标注),也是存在Type里面的。所以,一个方法的类型信息,跟另一个方法的类型信息,是迥然不同的。在这里,不要把Type叫做“类型”,而是叫“类型信息”比较好。每个类型信息对象只针对某个AST节点,包含了该节点与类型有关的各种信息。因此,在这里面再多放一个常数值,也就无所谓了。
|
||||
|
||||
你能看出,常数折叠实质上是针对AST节点的常数值属性来做属性计算的。
|
||||
|
||||
### 推导类型参数
|
||||
|
||||
ATTR阶段做的最后一项工作,也是跟类型相关,那就是对泛型中的类型参数做推导。
|
||||
|
||||
这是什么意思呢?在Java语言中,如果你前面声明了一个参数化类型的变量,那么在后面的初始化部分,你不带这个参数化类型也是可以的,编译器会自动推断出来。
|
||||
|
||||
比如下面这句:
|
||||
|
||||
```
|
||||
List<String> lines = new ArrayList<String>();
|
||||
|
||||
```
|
||||
|
||||
你可以去掉初始化部分中的类型参数,只保留一对尖括号就行了:
|
||||
|
||||
```
|
||||
List<String> lines = new ArrayList<>();
|
||||
|
||||
```
|
||||
|
||||
甚至更复杂的参数化类型,我们也可以这样简化:
|
||||
|
||||
```
|
||||
Map<String, List<String>> myMap = new HashMap<String, List<String>>();
|
||||
//简化为:
|
||||
Map<String, List<String>> myMap = new HashMap<>();
|
||||
|
||||
```
|
||||
|
||||
你可以在Infer.instantiateMethod()方法中打个断点,观察一下泛型的推断。关于泛型这个主题,我会在“揭秘泛型编程的实现机制”这一讲,去展开讲一些关于类型计算的算法,这里就不详细展开了。
|
||||
|
||||
好了,到这里,你已经知道了属性分析所做的四项工作,它们分别针对了四个属性:
|
||||
|
||||
- 类型检查针对的是类型属性;
|
||||
- 引用消解是针对标识符节点的符号(sym)属性,也就是要找到正确的符号定义;
|
||||
- 常数折叠针对的是常数值属性;
|
||||
- 类型参数的推导,针对的是类型参数属性。
|
||||
|
||||
所以,现在你就可以解答出学教科书时通常会遇到的一个疑问:属性计算到底是计算了哪些属性。我们用实战知识印证了理论 。
|
||||
|
||||
接下来我们看看编译器下一个阶段的工作:数据流分析。
|
||||
|
||||
## FLOW:数据流分析
|
||||
|
||||
Java编译器在FLOW阶段做了四种数据流分析:活跃性分析、异常分析、赋值分析和本地变量捕获分析。我以其中的活跃性分析方法为例,来给你做讲解,这样其他的几个分析方法,你就可以举一反三了。
|
||||
|
||||
**首先,我们来看看活跃性分析方法对return语句的检测。**
|
||||
|
||||
举个最简单的例子。下面这段代码里,foo函数的返回值是int,而函数体中,只有在if条件中存在一个return语句。这样,代码在IDE中就会报编译错误,提示缺少return语句。
|
||||
|
||||
```
|
||||
public class NoReturn{
|
||||
public int foo(int a){ //在a<=0的情况下,不会执行return语句
|
||||
if (a> 0){
|
||||
return a;
|
||||
}
|
||||
/*
|
||||
else{
|
||||
return -a;
|
||||
}
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
想要检查是否缺少return语句,我们就要进行活跃性分析。活跃性分析的具体实现是在Flow的一个内部类LiveAnalyzer中。
|
||||
|
||||
在分析过程中,编译器用了一个**alive变量**来代表代码是否会执行到当前位置。打开**Flow$LiveAnalyzer类**,你会看到**visitMethodDef**中的部分代码,如下所示。如果方法体里有正确的return语句,那么扫描完方法体以后,alive的取值是“DEAD”,也就是这之后不会再有可执行的代码了;否则就是“ALIVE”,这意味着AST中并不是所有的分支,都会以return结束。
|
||||
|
||||
```
|
||||
public void visitMethodDef(JCMethodDecl tree) {
|
||||
...
|
||||
alive = Liveness.ALIVE; //设置为ALIVE
|
||||
scanStat(tree.body); //扫描所有的语句
|
||||
|
||||
//如果仍然是ALIVE,但返回值不是void,那么说明缺少Return语句
|
||||
if (alive == Liveness.ALIVE && !tree.sym.type.getReturnType().hasTag(VOID))
|
||||
log.error(TreeInfo.diagEndPos(tree.body), Errors.MissingRetStmt);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以看到下面的代码示例中,当递归下降地扫描到if语句的时候,只有同时存在**then**的部分和**else**的部分,并且两个分支的活跃性检查的结果都是“DEAD”,也就是两个分支都以return语句结束的时候,if节点执行后alive就会变成“DEAD”,也就是后边的语句不会再被执行。除此之外,都是“ALIVE”,也就是if后边的语句有可能被执行。
|
||||
|
||||
```
|
||||
public void visitIf(JCIf tree) {
|
||||
scan(tree.cond); //扫描if语句的条件部分
|
||||
//扫描then部分。如果这里面有return语句,alive会变成DEAD
|
||||
scanStat(tree.thenpart);
|
||||
if (tree.elsepart != null) {
|
||||
Liveness aliveAfterThen = alive;
|
||||
alive = Liveness.ALIVE;
|
||||
scanStat(tree.elsepart);
|
||||
//只有then和else部分都有return语句,alive才会变成DEAD
|
||||
alive = alive.or(aliveAfterThen);
|
||||
} else { //如果没有else部分,那么把alive重新置为ALIVE
|
||||
alive = Liveness.ALIVE;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
看代码还是比较抽象。我把数据流分析的逻辑用控制流图的方式表示出来,你看着会更直观。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/30/2c/300d7c8abb0d9bbb97e08eab49d9712c.jpg" alt="">
|
||||
|
||||
我们通过活跃性分析,可以学习到数据流分析框架的5个要素:
|
||||
|
||||
1. **V**:代表被分析的值,这里是alive,代表了控制流是否会到达这里。
|
||||
1. **I**:是V的初始值,这里的初始值是LIVE;
|
||||
1. **D**:指分析方向。这个例子里,是从上到下扫描基本块中的代码;而有些分析是从下往上的。
|
||||
1. **F**:指转换函数,也就是遇到每个语句的时候,V如何变化。这里是在遇到return语句的时候,把alive变为DEAD。
|
||||
1. **Λ**:meet运算,也就是当控制流相交的时候,从多个值中计算出一个值。你看看下图,在没有else块的时候,两条控制流中alive的值是不同的,最后的取值是LIVE。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/f5/486f781695d928dab6acf76bcf5c1bf5.jpg" alt="">
|
||||
|
||||
在做meet运算的时候,会用到一个叫做半格的数学工具。你可以参考本讲末尾的链接。
|
||||
|
||||
好了,我借助活跃性分析给你简要地讲解了数据流分析框架,我们接着往下看。
|
||||
|
||||
**再进一步,活跃性分析还可以检测不可到达的语句**。
|
||||
|
||||
如果我们在return语句后面再加一些代码,那么这个时候,alive已经变成“DEAD”,编译器就会报“语句不可达”的错误。
|
||||
|
||||
Java编译器还能检测更复杂的语句不可达的情况。比如在下面的例子中,a和b是两个final类型的本地变量,final修饰词意味着这两个变量的值已经不会再改变。
|
||||
|
||||
```
|
||||
public class Unreachable{
|
||||
public void foo(){
|
||||
final int a=1;
|
||||
final int b=2;
|
||||
while(a>b){ //a>b的值可以在编译期计算出来
|
||||
System.out.println("Inside while block");
|
||||
}
|
||||
System.out.println("Outside while block");
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这种情况下,在扫描 **while语句**的时候,条件表达式“`a>b`”会被计算出来,是false,这意味着while块内部的代码不会被执行。注意,在第7讲的优化算法中,这种优化叫做**稀疏有条件的常数折叠**。因为这里是用于编译器前端,所以只是报了编译错误。如果是在中后端做这种优化,就会直接把不可达的代码删除。
|
||||
|
||||
```
|
||||
//Flow$AliveAnalyzer
|
||||
public void visitWhileLoop(JCWhileLoop tree) {
|
||||
ListBuffer<PendingExit> prevPendingExits = pendingExits;
|
||||
pendingExits = new ListBuffer<>();
|
||||
scan(tree.cond); //扫描条件
|
||||
alive = Liveness.from(!tree.cond.type.isFalse()); //如果条件值为false,那么alive为DEAD
|
||||
scanStat(tree.body); //扫描while循环体
|
||||
alive = alive.or(resolveContinues(tree));
|
||||
alive = resolveBreaks(tree, prevPendingExits).or(
|
||||
!tree.cond.type.isTrue());
|
||||
}
|
||||
|
||||
void scanStat(JCTree tree) { //扫描语句
|
||||
//如果在扫描语句的时候,alive是DEAD,那么该语句就不可到达了
|
||||
if (alive == Liveness.DEAD && tree != null) {
|
||||
log.error(tree.pos(), Errors.UnreachableStmt);
|
||||
if (!tree.hasTag(SKIP)) alive = Liveness.RECOVERY;
|
||||
}
|
||||
scan(tree);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
还有一种代码不可达的情况,就是无限循环后面的代码。你可以思考一下,在上面的例子中,**如果把while条件的“a>b”改成“a<b”,会发生什么情况呢?**
|
||||
|
||||
编译器会扫描while里面有没有合适的break语句(通过**resolveBreaks()方法**)。如果找不到,就意味着这个循环永远不会结束,那么循环体后面的语句就永远不会到达,从而导致编译器报错。
|
||||
|
||||
除了活跃性分析,Flow阶段还做了其他三项分析:**异常分析、赋值分析和本地变量捕获分析。**
|
||||
|
||||
为了方便你的学习,我把Java编译器用到的几个数据流分析方法整理了一下,放在下面的表格中:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/91/762b26278939d25b7354fbcbfdc39891.jpg" alt="">
|
||||
|
||||
这几种分析方法,我建议你可以做几个例子,跟踪代码并研究一下,会加深你对数据流分析的直观理解。
|
||||
|
||||
异常分析、赋值分析和本地变量捕获的思路,与活跃性分析类似,它们都是按照数据流分析框架来处理的。也就是说,对于每个分析方法,你都要注意识别出它的五大要素:值、初始值、转换规则、扫描方向,以及meet运算规则。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们研究了Java编译过程中的属性分析和数据流分析两个阶段。
|
||||
|
||||
**在属性分析阶段**,你能够看到Java是如何做类型检查、引用消解、常量折叠和推导类型参数的,它们实际上是对类型(type)、符号(sym)、常量值(constValue)和类型参数这4类属性的处理工作。
|
||||
|
||||
我们也见识到了在编译器前端的**数据流分析阶段**,是如何使用数据流分析方法的。通过数据流分析,编译器能够做一些更加深入的语义检查,比如检查控制流是否都经过了return语句,以及是否有不可到达的代码、每个异常是否都被处理,变量在使用前是否肯定被赋值,等等。
|
||||
|
||||
总体来说,在ATTR和FLOW这两个阶段,编译器完成了主要的语义检查工作。如果你在设计一门语言的时候,遇到了如何做语义检查的问题,那你就可以参考一下这一讲的内容。
|
||||
|
||||
在最后,是本节课程知识点的思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/8f/fcc07e719bdef1a3c5d7b7fef11b0c8f.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
数据流分析框架很重要,你可以借助实例对它熟悉起来。那么,你能针对赋值分析,把它的5个元素列出来吗?欢迎在留言区分享你的思考,我会在下一讲的留言区,通过置顶的方式公布标准答案。
|
||||
|
||||
如果你觉得有收获,欢迎你把今天的内容分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 关于数据流分析的理论性内容,可以参考龙书(Compilers Principles, Techniques and Tools)第二版的9.2和9.3节。你也可以参考《编译原理之美》 的第[27](https://time.geekbang.org/column/article/155338)、[28](https://time.geekbang.org/column/article/156878)讲,那里进行了比较直观的讲述。
|
||||
1. 关于半格这个数学工具,你可以参考龙书第二版的9.3.1部分,也同样可以参考《编译原理之美》的[第28讲](https://time.geekbang.org/column/article/156878)。
|
||||
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/12 | Java编译器(四):去除语法糖和生成字节码.md
Normal file
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/12 | Java编译器(四):去除语法糖和生成字节码.md
Normal file
@@ -0,0 +1,298 @@
|
||||
<audio id="audio" title="12 | Java编译器(四):去除语法糖和生成字节码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/68/13/68de7726b468e4yy962e90f89ac04413.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天是Java编译器的最后一讲,我们来探讨编译过程最后的两个步骤:**去除语法糖和生成字节码**。
|
||||
|
||||
其实今天要讲的这两个编译步骤,总体上都是为生成字节码服务的。在这一阶段,编译器首先会把语法糖对应的AST,转换成更基础的语法对应的AST,然后基于AST和符号表,来生成字节码。
|
||||
|
||||
从AST和符号表,到变成字节码,这可是一个很大的转变,就像把源代码转化成AST一样。**那么,这个过程的实现思路是什么?有什么难点呢?**
|
||||
|
||||
今天这一讲,我们就一起来解决以上这些问题,在这个过程中,你对Java编译器的认识会变得更加完整。
|
||||
|
||||
好了,我们首先来看看去除语法糖这一处理步骤。
|
||||
|
||||
## 去除语法糖(Syntactic Sugar)
|
||||
|
||||
Java里面提供了很多的语法糖,比如泛型、Lambda、自动装箱、自动拆箱、foreach循环、变长参数、内部类、枚举类、断言(assert),等等。
|
||||
|
||||
你可以这么理解语法糖:它就是提高我们编程便利性的一些语法设计。既然是提高便利性,那就意味着语法糖能做到的事情,用基础语法也能做到,只不过基础语法可能更啰嗦一点儿而已。
|
||||
|
||||
不过,我们最终还是要把语法糖还原成基础语法结构。比如,foreach循环会被还原成更加基础的for循环。那么,问题来了,**在编译过程中,究竟是如何去除语法糖的?基础语法和语法糖又有什么区别呢?**
|
||||
|
||||
在[第10讲](https://time.geekbang.org/column/article/252828)中,我提到过,在JDK14中,去除语法糖涵盖了编译过程的四个小阶段。
|
||||
|
||||
- **TRANSTYPES**:泛型处理,具体实现在TransTypes类中。
|
||||
- **TRANSPATTERNS**:处理模式匹配,具体实现在TransPattern类中。
|
||||
- **UNLAMBDA**:把LAMBDA转换成普通方法,具体实现在LambdaToMethod类中。
|
||||
- **LOWER**:其他所有的语法糖处理,如内部类、foreach循环、断言等,具体实现在Lower类中。
|
||||
|
||||
以上去除语法糖的处理逻辑都是相似的,它们的**本质都是对AST做修改和变换**。所以,接下来我挑选了两个比较有代表性的语法糖,泛型和foreach循环,和你分析它们的处理过程。
|
||||
|
||||
**首先是对泛型的处理。**
|
||||
|
||||
Java泛型的实现比较简单,`LinkedList<String>`和`LinkedList`对应的字节码其实是一样的。泛型信息`<String>`,只是用来在语义分析阶段做类型的检查。检查完之后,这些类型信息就会被去掉。
|
||||
|
||||
所以,Java的泛型处理,就是把AST中与泛型有关的节点简单地删掉(相关的代码在TransTypes类中)。
|
||||
|
||||
对于“ `List<String> names = new ArrayList<String>()` ”这条语句,它对应的AST的变化过程如下,其中,橙色的节点就是被去掉的泛型。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/6d/06a1e54b561b09dce936834ec311816d.jpg" alt="">
|
||||
|
||||
**然后,我们分析下对foreach循环的处理。**
|
||||
|
||||
foreach循环的意思是“遍历每一个成员”,它能够以更简洁的方式,遍历集合和数组等数据结构。在下面的示例代码中,foreach循环和基础for循环这两种处理方式的结果是等价的,但你可以看到,foreach循环会更加简洁。
|
||||
|
||||
```
|
||||
public static void main(String args[]) {
|
||||
List<String> names = new ArrayList<String>();
|
||||
...
|
||||
//foreach循环
|
||||
for (String name:names)
|
||||
System.out.println(name);
|
||||
|
||||
|
||||
//基础for循环
|
||||
for ( Iterator i = names.iterator(); i.hasNext(); ) {
|
||||
String name = (String)i.next();
|
||||
System.out.println(name);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Java编译器把foreach循环叫做**增强for循环**,对应的AST节点是**JCEnhancedForLoop**。
|
||||
|
||||
针对上面的示例代码,我们来对比一下增强for循环的AST和去除语法糖之后的AST,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3e/90/3ee6304103f7e41e3fc32c668f1e3490.jpg" alt="">
|
||||
|
||||
你可以通过**反编译**,来获得这些没有语法糖的代码,它跟示例代码中用到的**基础for循环语句**是一样的。
|
||||
|
||||
对foreach循环的处理,是在**Lower类的visitForeachLoop方法**中。
|
||||
|
||||
其实,你在阅读编译技术相关的文献时,应该经常会看到Lower这个词。它的意思是,让代码从对人更友好的状态,变换到对机器更友好的状态。比如说,语法糖对编程人员更友好,而基础的语句则相对更加靠近机器实现的一端,所以去除语法糖的过程是Lower。除了去除语法糖,**凡是把代码向着机器代码方向所做的变换,都可以叫做Lower。**以后你再见到Lower的时候,是不是就非常清楚它的意思了呢。
|
||||
|
||||
好了,通过对泛型和foreach循环的处理方式的探讨,现在你应该已经大致了解了去除语法糖的过程。总体来说,去除语法糖就是把AST做一些变换,让它变成更基础的语法要素,从而离生成字节码靠近了一步。
|
||||
|
||||
那么接下来,我们看看编译过程的最后一个环节:生成字节码。
|
||||
|
||||
## 生成字节码(Bytecode Generation)
|
||||
|
||||
一般来说,我们会有一个错觉,认为生成字节码比较难。
|
||||
|
||||
实际情况并非如此,因为通过前面的建立符号表、属性计算、数据流分析、去除语法糖的过程,我们已经得到了一棵标注了各种属性的AST,以及保存了各种符号信息的符号表。最难的编译处理工作,在这几个阶段都已经完成了。
|
||||
|
||||
在[第8讲](https://time.geekbang.org/column/article/249261)中,我就介绍过目标代码的生成。其中比较难的工作,是指令选择、寄存器分配和指令排序。而这些难点工作,在生成字节码的过程中,基本上是不存在的。在少量情况下,编译器可能会需要做一点指令选择的工作,但也都非常简单,你在后面可以看到。
|
||||
|
||||
我们通过一个例子,来看看生成字节码的过程:
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public int foo(int a){
|
||||
return a + 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个例子中,foo函数对应的字节码有四个指令:
|
||||
|
||||
```
|
||||
public int foo(int);
|
||||
Code:
|
||||
0: iload_1 //把下标为1的本地变量(也就是参数a)入栈
|
||||
1: iconst_3 //把常数3入栈
|
||||
2: iadd //执行加法操作
|
||||
3: ireturn //返回
|
||||
|
||||
```
|
||||
|
||||
生成字节码,基本上就是对AST做深度优先的遍历,逻辑特别简单。我们在[第5讲](https://time.geekbang.org/column/article/246281)曾经介绍过栈机的运行原理,也提到过栈机的一个优点,就是生成目标代码的算法比较简单。
|
||||
|
||||
你可以看一下我画的示意图,里面有生成字节码的步骤:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/21/05461yy0f34c9db1d35b83yyf4049721.jpg" alt="">
|
||||
|
||||
- 第1步,把a的值入栈(iload_1)。
|
||||
- 第2步,把字面量3入栈(iconst_3)。
|
||||
- 第3步,生成加法运算指令(iadd)。这个操作会把前两个操作数出栈,把结果入栈。
|
||||
- 第4步,生成return指令(ireturn)。
|
||||
|
||||
这里面有没有指令选择问题?有的,但是很简单。
|
||||
|
||||
首先,你注意一下iconst_3指令,这是把一个比较短的操作数压缩到了指令里面,这样就只需要生成一个字节码。如果你把3改成一个稍微大一点的数字,比如7,那么它所生成的指令就要改成“bipush 7”,这样就需要生成两个字节的字节码,一个字节是指令,一个字节是操作数。但这个操作数不能超过“2^7-1”,也就是127,因为一个字节只能表示-128~127之间的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/f2/9b88397b574e8f37d415ab1byycbecf2.jpg" alt="">
|
||||
|
||||
如果字面量值变成128,那指令就要变成“sipush 128”,占据三个字节,表示往栈里压入一个short数据,其中操作数占据两个字节。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/26/cd9dc78269b769f56ff236e9b2e3b426.jpg" alt="">
|
||||
|
||||
如果该常数超过了两个字节能表示的范围,比如“32768”,那就要改成另一个指令“ldc #2”,这是把常数放到常量池里,然后从常量池里加载。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/76/53ebd4d4be1fdcc0bdddf847d0463a76.jpg" alt="">
|
||||
|
||||
这几个例子反映了**由于字面量的长度不同,而选用了不同的指令**。接着,我们再来看看**数据类型对指令的影响。**
|
||||
|
||||
前面例子中生成的这四个指令,全部都是针对整数做运算的。这是因为我们已经在语义分析阶段,计算出了各个AST节点的类型,它们都是整型。但如果是针对长整型或浮点型的计算,那么生成的字节码就会不一样。下面是针对单精度浮点型所生成的字节码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e9/41/e9c61861bc83c3e58390741649fa6b41.jpg" alt="">
|
||||
|
||||
**第三,数据类型影响指令生成的另一个情况,是类型转换。**
|
||||
|
||||
**一方面,阅读字节码的规范,你会发现对byte、short、int这几种类型做运算的时候,使用的指令其实是一样的,都是以i开头的指令。**比如,加载到栈机都是用iload指令,加法都是用iadd指令。
|
||||
|
||||
在示例代码中,我们把foo函数的参数a的类型改成byte,生成的字节码与之前也完全一样,你可以自己去试一下。
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public int foo(byte a){
|
||||
return a + 3;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**另一方面,在Java里把整型和浮点型做混合运算的时候,编译器会自动把整型转化成浮点型。**比如我们再把示例代码改成下面这样:
|
||||
|
||||
```
|
||||
public class MyClass {
|
||||
public double foo(int a){
|
||||
return a + 3.0;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个时候,foo函数对应的字节码如下,其中 **i2d指令**就是把参数a从int型转换成double型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/64/6cfd01bdfea327ba064fbb71b2f93464.jpg" alt="">
|
||||
|
||||
OK,到这里,我已经总结了影响指令生成的一些因素,包括字面量的长度、数据类型等。你能体会到,这些指令选择的逻辑都是很简单的,基于当前AST节点的属性,编译器就可以做成正确的翻译了,所以它们基本上属于“直译”。而我们在[第8讲](https://time.geekbang.org/column/article/249261)中介绍指令选择算法的时候,遇到的问题通常是结合了多个AST节点生成一条指令,它的难度要高得多。所以在第16讲,讲解Java的JIT编译器生成目标代码的时候,我会带你去看看这种复杂的指令选择算法的实现方式。
|
||||
|
||||
现在你对生成字节码的基本原理搞清楚了以后,再来看Java编译器的具体实现,就容易多了。
|
||||
|
||||
生成字节码的程序入口在**com.sun.tools.javac.jvm.Gen类**中。这个类也是AST的一个visitor。这个visitor把AST深度遍历一遍,字节码就生成完毕了。
|
||||
|
||||
**在com.sun.tools.javac.jvm包中,有两个重要的辅助类。**
|
||||
|
||||
**第一个辅助类是Item。**包的内部定义了很多不同的Item,代表了在字节码中可以操作的各种实体,比如本地变量(LocalItem)、字面量(ImmediateItem)、静态变量(StaticItem)、带索引的变量(IndexedItem,比如数组)、对象实例的变量和方法(MemberItem)、栈上的数据(StackItem)、赋值表达式(AssignItem),等等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/12/afda81ece1d912be6effd6570fa09f12.jpg" alt="">
|
||||
|
||||
每种Item都支持一套标准的操作,能够帮助生成字节码。我们最常用的是load()、store()、invoke()、coerce()这四个。
|
||||
|
||||
- **load():生成把这个Item加载到栈上的字节码。**
|
||||
|
||||
我们刚才已经见到了两种Item的load操作,一个是本地变量a的(LocalItem),一个是立即数3的(ImmediateItem。在字节码和汇编代码里,如果一个指令的操作数是一个常数,就叫做立即数)。
|
||||
|
||||
你可以看一下ImmediateItem的load()方法,里面准确反映了我们前面分析的指令选择逻辑:根据字面量长度的不同,分别选择iconst_X、bipush、sipush和ldc指令。
|
||||
|
||||
```
|
||||
Item load() {
|
||||
switch (typecode) {
|
||||
//对int、byte、short、char集中类型来说,生成的load指令是相同的。
|
||||
case INTcode: case BYTEcode: case SHORTcode: case CHARcode:
|
||||
int ival = numericValue().intValue();
|
||||
if (-1 <= ival && ival <= 5)
|
||||
code.emitop0(iconst_0 + ival); //iconst_X指令
|
||||
else if (Byte.MIN_VALUE <= ival && ival <= Byte.MAX_VALUE)
|
||||
code.emitop1(bipush, ival); //bipush指令
|
||||
else if (Short.MIN_VALUE <= ival && ival <= Short.MAX_VALUE)
|
||||
code.emitop2(sipush, ival); //sipush指令
|
||||
else
|
||||
ldc(); //ldc指令
|
||||
break;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
load()方法的返回值,是一个StackItem,代表加载到栈上的数据。
|
||||
|
||||
- **store():生成从栈顶保存到该Item的字节码。**
|
||||
|
||||
比如LocalItem的store()方法,能够把栈顶数据保存到本地变量。而MemberItem的store()方法,则会把栈顶数据保存到对象的成员变量中。
|
||||
|
||||
<li>
|
||||
**invoke() : 生成调用该Item代表的方法的字节码。**
|
||||
</li>
|
||||
<li>
|
||||
**coerce():强制类型转换。**
|
||||
</li>
|
||||
|
||||
我们之前讨论的类型转换功能,就是在coerce()方法里完成的。
|
||||
|
||||
**第二个辅助类是Code类。<strong>它里面有各种**emitXXX()方法,会生成各种字节码的指令。</strong>
|
||||
|
||||
总结起来,字节码生成的总体框架如下面的类图所示:
|
||||
|
||||
- Gen类以visitor模式访问AST,生成字节码;最后生成的字节码保存在Symbol的code属性中。
|
||||
- 在生成字节码的过程中,编译器会针对不同的AST节点,生成不同的Item,并调用Item的load()、store()、invoke()等方法,这些方法会进一步调用Code对象的emitXXX()方法,生成实际的字节码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/52/d8/52d4abb0eb9654c22024378ba91cb5d8.jpg" alt="">
|
||||
|
||||
好了,这就是生成字节码的过程,你会发现它的思路是很清楚的。你可以写一些不同的测试代码,观察它生成的字节码,以及跟踪生成字节码的过程,很快你就能对各种字节码是如何生成的了然于胸了。
|
||||
|
||||
## 代码优化
|
||||
|
||||
到这里,我们把去除语法糖和生成字节码两部分的内容都讲完了。但是,在Java编译器里,还有一类工作是分散在编译的各个阶段当中的,它们也很重要,这就是代码优化的工作。
|
||||
|
||||
总的来说,Java编译器不像后端编译器那样会做深度的优化。比如像下面的示例代码,“`int b = a + 3`”这行是无用的代码,用一个“死代码删除”的优化算法就可以去除掉。而在Java编译器里,这行代码照样会被翻译成字节码,做一些无用的计算。
|
||||
|
||||
```
|
||||
int foo(){
|
||||
int a = 2;
|
||||
int b = a + 3; //这行是死代码,可以优化掉
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,Java编译器还是在编译过程中,顺便做了一些优化:
|
||||
|
||||
**1.ATTR阶段:常数折叠**
|
||||
|
||||
在属性分析阶段做了常数折叠优化。这样,在生成字节码的时候,如果一个节点有常数值,那么就直接把该常数值写入字节码,这个节点之下的子树就都被忽略。
|
||||
|
||||
**2.FLOW阶段:不可达的代码**
|
||||
|
||||
在FLOW阶段,通过活跃性分析,编译器会发现某些代码是不可达的。这个时候,Java编译器不是悄悄地优化掉它们,而是会报编译错误,让程序员自己做调整。
|
||||
|
||||
**3.LOWER阶段:代数简化**
|
||||
|
||||
在LOWER阶段的代码中,除了去除语法糖,你还能看到一些代数简化的行为。给你举个例子,在Lower.visitBinary()方法中,也就是处理二元操作的AST的时候,针对逻辑“或(OR)”和“与(AND)”运算,有一些优化代码。比如,针对“或”运算,如果左子树的值是true,那么“或”运算对应的AST用左子树代替;而如果左子树是的值是false,那么AST可以用右子树代替。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/43/97/43ba9cc3f2dd4e8b3ca61624e7306c97.jpg" alt="">
|
||||
|
||||
**4.GEN阶段:代数简化和活跃性分析**
|
||||
|
||||
在生成字节码的时候,也会做一些代数简化。比如在**Gen.visitBinary()方法中**,有跟Lower.visitBinary()类似的逻辑。而整个生成代码的过程,也有类似FLOW阶段的活跃性分析的逻辑,对于不可达的代码,就不再生成字节码。
|
||||
|
||||
看上去GEN阶段的优化算法是冗余的,跟前面的阶段重复了。但是这其实有一个好处,也就是可以把生成字节码的部分作为一个单独的库使用,不用依赖前序阶段是否做了某些优化。
|
||||
|
||||
总结起来,Java编译器在多个阶段都有一点代码优化工作,但总体来看,代码优化是很不足的。真正的高强度的优化,还是要去看Java的JIT编译器。这些侧重于做优化的编译器,有时就会被叫做“优化编译器(Optimizing Compiler)”。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天,我带你分析了Java编译过程的最后两个步骤:去除语法糖和字节码生成。你需要记住以下几点:
|
||||
|
||||
- 语法糖是现代计算机语言中一个友好的特性。Java语言很多语法上的升级,实际上都只是增加了一些语法糖而已。语法糖在Java编译过程中的去除语法糖环节会被还原成基础的语法。**其实现机制,是对AST做修改和转换。**
|
||||
- 生成字节码是一个比较机械的过程,**编译器只需要对AST进行深度优先的遍历即可。<strong>在这个过程中会用到前几个阶段形成的属性信息,特别是**类型信息</strong>。
|
||||
|
||||
我把本讲的知识点整理成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/ff/b1a76404ecb1ab138e3d9bce9b6949ff.jpg" alt="">
|
||||
|
||||
之所以我花了4讲去介绍Java编译器的核心机制,是因为像Java这样成熟的静态类型语言,它的编译器的实现思路有很多借鉴意义,比如词法分析和语法分析采用的算法、语义分析中多个阶段的划分和之间的相互关系、如何用各种方法检查语义错误、符号表的实现、语法糖和基础语法的关系,等等。当你把Java编译器的脉络看清楚以后,再去看其他静态类型语言的编译器的代码,就会发现其中有很多地方是共通的,你就能更快地熟悉起来。这样下来,你对静态语言编译器的前端,都会有个清晰的了解。
|
||||
|
||||
当然,只了解前端部分是不够的,Java还有专注于中后端功能的编译器,也就是JIT编译器。我们这讲也已经说过了,前端编译器的优化功能是有限的。那么,如果想让Java代码高效运行,就要依靠JIT编译器的优化功能和生成机器码的功能了。在后面的四讲中,我会接着给你揭秘Java的JIT编译器。
|
||||
|
||||
## 一课一思
|
||||
|
||||
针对Java编译器这4讲的内容,我们来做一个综合的思考题。假设你现在要写一个简单的DSL引擎,比如让它能够处理一些自定义的公式,最后要生成字节码,你会如何让它最快地实现?是否可以复用Java编译器的功能?
|
||||
|
||||
欢迎你留言分享自己的观点。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
Java语言规范第六章:[Java虚拟机指令集](https://docs.oracle.com/javase/specs/jvms/se13/html/jvms-6.html)。
|
||||
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/13 | Java JIT编译器(一):动手修改Graal编译器.md
Normal file
329
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/13 | Java JIT编译器(一):动手修改Graal编译器.md
Normal file
@@ -0,0 +1,329 @@
|
||||
<audio id="audio" title="13 | Java JIT编译器(一):动手修改Graal编译器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3a/3a/3a29fb2e334ec19ea4b0408c04801a3a.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在前面的4讲当中,我们已经解析了OpenJDK中的Java编译器,它是把Java源代码编译成字节码,然后交给JVM运行。
|
||||
|
||||
用过Java的人都知道,在JVM中除了可以解释执行字节码以外,还可以通过即时编译(JIT)技术生成机器码来执行程序,这使得Java的性能很高,甚至跟C++差不多。反之,如果不能达到很高的性能,一定会大大影响一门语言的流行。
|
||||
|
||||
但是,对很多同学来说,对于编译器中后端的了解,还是比较模糊的。比如说,你已经了解了中间代码、优化算法、指令选择等理论概念,**那这些知识在实际的编译器中是如何落地的呢?**
|
||||
|
||||
所以从今天开始,我会花4讲的时间,来带你了解Java的JIT编译器的组成部分和工作流程、它的IR的设计、一些重要的优化算法,以及生成目标代码的过程等知识点。在这个过程中,你还可以印证关于编译器中后端的一些知识点。
|
||||
|
||||
今天这一讲呢,我首先会带你理解JIT编译的基本原理;然后,我会带你进入Graal编译器的代码内部,一起去修改它、运行它、调试它,让你获得第一手的实践经验,消除你对JIT编译器的神秘感。
|
||||
|
||||
## 认识Java的JIT编译器
|
||||
|
||||
我们先来探究一下JIT编译器的原理。
|
||||
|
||||
在[第5讲](https://time.geekbang.org/column/article/246281)中,我讲过程序运行的原理:把一个指令指针指向一个内存地址,CPU就可以读取其中的内容,并作为指令来执行。
|
||||
|
||||
所以,Java后端的编译器只要生成机器码就行了。如果是在运行前一次性生成,就叫做提前编译(AOT);如果是在运行时按需生成机器码,就叫做即时编译(JIT)。Java以及基于JVM的语言,都受益于JVM的JIT编译器。
|
||||
|
||||
在JDK的源代码中,你能找到src/hotspot目录,这是JVM的运行时,它们都是用C++编写的,其中就包括JIT编译器。标准JDK中的虚拟机呢,就叫做HotSpot。
|
||||
|
||||
实际上,HotSpot带了两个JIT编译器,一个叫做**C1**,又叫做**客户端编译器**,它的编译速度快,但优化程度低。另一个叫做**C2**,又叫做**服务端编译器**,它的编译速度比较慢,但优化程度更高。这两个编译器在实际的编译过程中,是被结合起来使用的。而**字节码解释器**,我们可以叫做是**C0**,它的运行速度是最慢的。
|
||||
|
||||
在运行过程中,HotSpot首先会用C0解释执行;接着,HotSpot会用C1快速编译,生成机器码,从而让运行效率提升。而对于运行频率高的热点(HotSpot)代码,则用C2深化编译,得到运行效率更高的代码,这叫做**分层编译**(Tiered Compilation)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/ba/984004f06874e5a28571082c72c080ba.jpg" alt="">
|
||||
|
||||
由于C2会做一些激进优化,比如说,它会根据程序运行的统计信息,认为某些程序分支根本不会被执行,从而根本不为这个分支生成代码。不过,有时做出这种激进优化的假设其实并不成立,那这个时候就要做一个**逆优化(Deoptimization)**,退回到使用C1的代码,或退回到用解释器执行。
|
||||
|
||||
触发即时编译,需要检测**热点代码**。一般是以方法为单位,虚拟机会看看该方法的运行频次是否很高,如果运行特别频繁,那么就会被认定为是热点代码,从而就会被触发即时编译。甚至如果一个方法里,有一个循环块是热点代码(比如循环1.5万次以上),这个时候也会触发编译器去做即时编译,在这个方法还没运行完毕的时候,就被替换成了机器码的版本。由于这个时候,该方法的栈帧还在栈上,所以我们把这个技术叫做**栈上替换**(On-stack Replacement,OSR)。栈上替换的技术难点,在于让本地变量等数据无缝地迁移,让运行过程可以正确地衔接。
|
||||
|
||||
## Graal:用Java编写的JIT编译器
|
||||
|
||||
如果想深入地研究Java所采用的JIT编译技术,我们必须去看它的源码。可是,对于大多数Java程序员来说,如果去阅读C++编写的编译器代码,肯定会有些不适应。
|
||||
|
||||
一个好消息是,Oracle公司推出了一个完全用Java语言编写的JIT编译器:Graal,并且也有开放源代码的社区版,你可以[下载](https://github.com/graalvm/graalvm-ce-builds/releases)安装并使用。
|
||||
|
||||
用Java开发一款编译器的优点是很明显的。
|
||||
|
||||
1. 首先,Java是内存安全的,而C++程序的很多Bug都与内存管理有关,比如可能不当地使用了指针之类的。
|
||||
1. 第二,与Java配套的各种工具(比如IDE)更友好、更丰富。
|
||||
1. 第三,Java的性能并不低,所以能够满足对编译速度的需求。
|
||||
1. 最后,用Java编译甚至还能节省内存的占用,因为Java采用的是动态内存管理技术,一些对象没用了,其内存就会被回收。而用C++编写的话,可能会由于程序员的疏忽,导致一些内存没有被及时释放。
|
||||
|
||||
从Java9开始,你就可以用Graal来替换JDK中的JIT编译器。这里有一个**JVMCI**(JVM Compiler Interface)接口标准,符合这个接口标准的JIT编译器,都可以被用于JVM。
|
||||
|
||||
Oracle公司还专门推出了一款JVM,叫做**GraalVM**。它除了用Graal作为即时编译器以外,还提供了一个很创新的功能:在一个虚拟机上支持多种语言,并且支持它们之间的互操作。你知道,传统的JVM上已经能够支持多种语言,比如Scala、Clojure等。而新的GraalVM会更进一步,它通过一个Truffle框架,可以支持JavaScript、Ruby、R、Python等需要解释执行的语言。
|
||||
|
||||
再进一步,它还通过一个Sulong框架支持LLVM IR,从而支持那些能够生成LLVM IR的语言,如C、C++、Rust等。想想看,在Java的虚拟机上运行C语言,还是有点开脑洞的!
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7b/cc/7b5e9041787e6b098ef62e6cf42671cc.jpg" alt="">
|
||||
|
||||
最后,GraalVM还支持AOT编译,这就让Java可以编译成本地代码,让程序能更快地启动并投入高速运行。我听说最近的一些互联网公司,已经在用Graal做AOT编译,来生成本地镜像,提高应用的启动时间,从而能够更好地符合云原生技术的要求。
|
||||
|
||||
## 修改并运行Graal
|
||||
|
||||
好,那接下来,我就带你一起动手修改一下Graal编译器,在这个过程中,你就能对Graal的程序结构熟悉起来,消除对它的陌生感,有助于后面深入探索其内部的实现机制。
|
||||
|
||||
在本课程中,我采用了Graal的20.0.1版本的源代码。你可以参考Graal中的[文档](https://github.com/oracle/graal/tree/master/compiler)来做编译工作。
|
||||
|
||||
首先,下载源代码(指定了代码的分支):
|
||||
|
||||
```
|
||||
git clone -b vm-20.0.1 https://github.com/oracle/graal.git
|
||||
|
||||
```
|
||||
|
||||
接着,下载GraalVM的构建工具mx,它是用Python2.7编写的,你需要有正确的Python环境:
|
||||
|
||||
```
|
||||
git clone https://github.com/graalvm/mx.git
|
||||
export PATH=$PWD/mx:$PATH
|
||||
|
||||
```
|
||||
|
||||
你需要在自己的机器上设置好JDK8或11的环境。我这里是在macOS上,采用JDK8。
|
||||
|
||||
```
|
||||
export PATH="/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home/bin:$PATH"
|
||||
export JAVA_HOME=/Library/Java/JavaVirtualMachines/openjdk1.8.0_252-jvmci-20.1-b02-fastdebug/Contents/Home
|
||||
|
||||
```
|
||||
|
||||
好了,现在你就可以编译Graal了。你可以在Graal源代码的compiler子目录中,运行mx build:
|
||||
|
||||
```
|
||||
mx build
|
||||
|
||||
```
|
||||
|
||||
编译完毕以后,你可以写一个小小的测试程序,来测试Graal编译器的功能。
|
||||
|
||||
```
|
||||
javac Foo.java //编译Foo.java
|
||||
mx vm Foo //运行Foo.java,相当于执行java Foo
|
||||
|
||||
```
|
||||
|
||||
“mx vm”命令在第一次运行的时候,会打包出一个新的GraalVM,它所需要的HotSpot VM,是从JDK中拷贝过来的,然后它会把Graal编译器等其他模块也添加进去。
|
||||
|
||||
Foo.java的源代码如下。在这个示例程序中,main方法会无限次地调用add方法,所以add方法就成为了热点代码,这样会逼迫JIT编译器把add方法做即时编译。
|
||||
|
||||
```
|
||||
public class Foo{
|
||||
public static void main(String args[]){
|
||||
int i = 0;
|
||||
while(true){
|
||||
if(i%1000==0){
|
||||
System.out.println(i);
|
||||
try{
|
||||
Thread.sleep(100); //暂停100ms
|
||||
}catch(Exception e){}
|
||||
}
|
||||
|
||||
i++;
|
||||
add(i,i+1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static int add(int x, int y){
|
||||
return x + y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
由于我们现在已经有了Graal的源代码,所以我们可以在源代码中打印一点信息,来显示JIT是什么时候被触发的。
|
||||
|
||||
**org.graalvm.compiler.hotspot.HotspotGraalCompiler.compileMethod()方法**,是即时编译功能的入口,你可以在里面添加一行输出功能,然后用“mx build”命令重新构建。
|
||||
|
||||
```
|
||||
public CompilationRequestResult compileMethod(CompilationRequest request) {
|
||||
//打印被编译的方法名和字节码
|
||||
System.out.println("Begin to compile method: " + request.getMethod().getName() + "\nbytecode: " + java.util.Arrays.toString(request.getMethod().getCode()));
|
||||
|
||||
return compileMethod(request, true, graalRuntime.getOptions());
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你在compiler目录里,打出“mx ideinit”命令,就可以为Eclipse、IntelliJ Idea等编译器生成配置信息了。你可以参照[文档](https://github.com/oracle/graal/blob/master/compiler/docs/IDEs.md)来做好IDE的配置。
|
||||
|
||||
注意:我用Eclipse和IntelliJ Idea都试了一下。Idea的使用体验更好一些。但用mx ideinit命令为Idea生成的配置文件,只是针对JDK8的,如果要改为JDK11,还需要手工修改不少配置信息。<br>
|
||||
在使用Idea的时候,你要注意安装python插件,文档中建议的其他插件可装可不装。<br>
|
||||
在使用Eclipse时,我曾经发现有一些报错信息,是因为IDE不能理解一些注解。你如果也遇到了类似情况,稍微修改一下头注释就能正常使用了。
|
||||
|
||||
```
|
||||
mx ideinit
|
||||
|
||||
```
|
||||
|
||||
然后,你可以运行下面的命令来执行示例程序:
|
||||
|
||||
```
|
||||
mx vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:CompileOnly=Foo.add \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
你会看到,命令中包含了很多不同的参数,它们分别代表了不同的含义。
|
||||
|
||||
- -XX:+UnlockExperimentalVMOptions:启用试验特性。
|
||||
- -XX:+EnableJVMCI:启用JVMCI功能。
|
||||
- -XX:+UseJVMCICompiler :使用JVMCI编译器,也就是Graal。
|
||||
- -XX:-TieredCompilation :禁用分层编译。
|
||||
- -XX:CompileOnly=Foo.add:只编译add方法就行了。
|
||||
|
||||
当程序运行以后,根据打印的信息,你就能判断出JIT编译器是否真的被调用了。实际上,它是在add方法执行了15000次以后才被调用的。这个时候,JVM会认为add方法是一个热点。因为JIT是在另一个线程启动执行的,所以输出信息要晚一点。
|
||||
|
||||
好了,通过这个实验,你就能直观地了解到,JVM是如何判断热点并启动JIT机制的了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/41/c757696ac327f3e3d0ecf10d69cf8241.jpg" alt="">
|
||||
|
||||
另外,在这个实验中,你还可以通过“-XX:CompileThreshold”参数,来修改热点检测的门槛。比如说,你可以在“-XX:CompileThreshold=50”,也就是让JVM在被add方法执行了50次之后,就开始做即时编译。你还可以使用“-Xcomp”参数,让方法在第一次被调用的时候就开始做编译。不过这样编译的效果会差一些,因为让方法多运行一段时间再编译,JVM会收集一些运行时的信息,这些信息会有助于更好地做代码优化。**这也是AOT编译的效果有时会比JIT差的原因,因为AOT缺少了运行时的一些信息。**
|
||||
|
||||
好了,接下来,我们再来看看JIT编译后的机器码是什么样子的。
|
||||
|
||||
JIT所做的工作,本质上就是把字节码的Byte数组翻译成机器码的Byte数组,在翻译过程中,编译器要参考一些元数据信息(符号表等),再加上运行时收集的一些信息(用于帮助做优化)。
|
||||
|
||||
前面的这个示例程序,它在运行时就已经打印出了字节码:[26, 27, 96, -84]。如果我们转换成16进制,就是[1a, 1b, 60, ac]。它对应的字节码是:[iload_0, iload_1, iadd, ireturn]。
|
||||
|
||||
我们暂时忽略掉这中间的编译过程,先来看看JIT编译后生成的机器码。
|
||||
|
||||
Graal编译完毕以后,是在org.graalvm.compiler.hotspot.CompilationTask的**performCompilation方法**中,把编译完毕的机器码安装到缓存区,用于后续执行。在这里,你可以加一点代码,打印编译后的结果。
|
||||
|
||||
```
|
||||
...
|
||||
installMethod(debug, result); //result是编译结果
|
||||
System.out.println("Machine code: " + java.util.Arrays.toString(result.getTargetCode()));
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
打印输出的机器码数组如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/9f/a6d3f9yy43f400be22d564a20eeeba9f.jpg" alt="">
|
||||
|
||||
我们光看这些机器码数组,当然是看不出来有什么含义的,但JDK可以把机器码反编译成汇编码,然后打印输出,就会更方便被我们解读。这就需要一个**反汇编工具hsdis**。
|
||||
|
||||
运行“mx hsdis”命令,你可以下载一个动态库(在macOS上是hsdis-amd64.dylib,在Linux上以so结尾,在Windows上以dll结尾)。这个动态库会被拷贝到JDK的lib目录下,这样我们就可以通过命令行参数,让JVM输出编译生成的汇编码。
|
||||
|
||||
```
|
||||
sudo -E mx hsdis #用sudo是为了有权限把动态库拷贝到系统的JDK的lib目录
|
||||
|
||||
```
|
||||
|
||||
注:由于我使用mx命令来运行示例程序,所以使用的JDK实际上是GraalVM从系统JDK中拷贝过来的版本,因此我需要手工把hsdis.dylib拷贝到graal-vm-20.0.1/compiler/mxbuild/darwin-amd64/graaljdks/jdk11-cmp/lib目录下。
|
||||
|
||||
```
|
||||
mx vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:+PrintCompilation \
|
||||
-XX:+UnlockDiagnosticVMOptions \
|
||||
-XX:+PrintAssembly \
|
||||
-XX:CompileOnly=Foo.add \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
输出的汇编码信息如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/f8/70d6ba99d1330bdfa59be99dbab4d3f8.jpg" alt="">
|
||||
|
||||
我来解释一下这段汇编代码的含义:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/56/77fc4ce3ff1ecaec8ca927e537796056.jpg" alt="">
|
||||
|
||||
好了,现在你已经能够直观地了解JIT启动的时机:检测出热点代码;以及它最后生成的结果:机器码。
|
||||
|
||||
但我们还想了解一下中间处理过程的细节,因为这样才能理解编译器的工作机制。所以这个时候,如果能够跟踪Graal的执行过程就好了,就像调试一个我们自己编写的程序那样。那么我们能做到吗?
|
||||
|
||||
当然是可以的。
|
||||
|
||||
## 跟踪Graal的运行
|
||||
|
||||
Graal是用Java编写的,因此你也可以像调试普通程序一样调试它。你可以参考源代码中的这篇与调试有关的[文档](https://github.com/oracle/graal/blob/master/compiler/docs/Debugging.md)。
|
||||
|
||||
由于Graal是在JVM中运行的,所以你要用到JVM的远程调试模式。我们仍然要运行Foo示例程序,不过要加个“-d”参数,表示让JVM运行在调试状态下。
|
||||
|
||||
```
|
||||
mx -d vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:CompileOnly=Foo.add \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
这个时候,在JVM启动起来之后,会在8000端口等待调试工具跟它连接。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/48/1bd338412260c145ba1b944a786c9648.jpg" alt="">
|
||||
|
||||
你可以使用Eclipse或Idea做调试工具。我以Eclipse为例,在前面运行“mx ideinit”的时候,我就已经设置了一个远程调试的配置信息。
|
||||
|
||||
你可以打开“run>debug configurations…”菜单,在弹出的对话框中,选择Remote Java Application,可以看到几个预制好的配置。
|
||||
|
||||
然后,点击“compiler-attach-localhost-8000”,你可以看到相关属性。其中,连接信息正是本机的8000端口。
|
||||
|
||||
把Project改成“org.graalvm.compiler.hotspot”,然后点击Debug按钮。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/05/b0/05024fc7b348d197934162b41c5d5fb0.jpg" alt="">
|
||||
|
||||
补充:如果你使用的是Idea,你也会找到一个预制好的远程调试配置项:GraalDebug。直接点击就可以开始调试。
|
||||
|
||||
为了方便调试,我在org.graalvm.compiler.hotspot.compileMethod()方法中设置了断点,所以到了断点的时候,程序就会停下来,而不是一直运行到结束。
|
||||
|
||||
当你点击Debug按钮以后,Foo程序会继续运行。在触发了JIT功能以后,JVM会启动一个新线程来运行Graal,而Foo则继续在主线程里运行。因为Foo一直不会结束,所以你可以从容地进行调试,不用担心由于主线程的退出,而导致运行Graal的线程也退出。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/cb/795f4a22afc0b478e4e84e09fc6926cb.jpg" alt="">
|
||||
|
||||
现在,你可以跟踪Graal的编译过程,看看能发现些什么。在这个过程中,你需要一点耐心,慢慢理解整个代码结构。
|
||||
|
||||
Graal执行过程的主要结构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/9b/77355ff69b5092e21ed486397288a79b.jpg" alt="">
|
||||
|
||||
首先,你会发现,在编译开始的时候,Graal编译器要把字节码转化成一个图的数据结构。而后续的编译过程,都是对这个图的处理。这说明了这个图很重要,而这个图就是Graal要用到的IR,在Graal编译器中,它也被叫做HIR。
|
||||
|
||||
接着,你会看到,整个编译过程被分成了前端和后端两个部分。前端部分使用的IR是HIR。而且在前端部分,HIR又分成了高(HighTier)、中(MidTier)、低(LowTier)三层。在每个层次里,都要执行很多遍(Phase)对图的处理。这些处理,指的就是各种的优化和处理算法。而从高到低过渡的过程,就是不断Lower的过程,也就是把IR中,较高抽象度的节点替换成了更靠近底层实现的节点。
|
||||
|
||||
在后端部分,则要生成一种新的IR,也就是我们在[第6讲](https://time.geekbang.org/column/article/247700)中提到过的LIR,并且Graal也要对它进行多遍处理。最后一步,就是生成目标代码。
|
||||
|
||||
下图中,我举了一个例子,列出了编译器在前端的三个层次以及在后端所做的优化和处理工作。
|
||||
|
||||
**你要注意的是,**在编译不同的方法时,所需要的优化工作也是不同的,具体执行的处理也就不一样。并且这些处理执行过程也不是线性执行的,而可能是一个处理程序调用了另一个处理程序,嵌套执行的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6f/f1/6f5d357fc7df9319060ede8f5a39d4f1.jpg" alt="">
|
||||
|
||||
不过通过跟踪Graal的运行过程,你可以留下一个直观的印象:**Graal编译器的核心工作,就是对图(IR)的一遍遍处理。**
|
||||
|
||||
在下一讲中,我就会进一步讲述Graal的IR,并会带你一起探讨优化算法的实现过程,你可以了解到一个真实编译器的IR是怎样设计的。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我带你大致了解了Java的JIT编译器。你需要重点关注以下几个核心要点:
|
||||
|
||||
- JIT可能会用到多个编译器,有的编译速度快但不够优化(比如C1,客户端编译器),有的够优化但编译速度慢(比如C2,服务端编译器),所以在编译过程中会结合起来使用。
|
||||
- 你还需要理解逆优化的概念,以及导致逆优化的原因。
|
||||
- 另外,我还带你了解了Graal这个用Java编写的Java JIT编译器。最重要的是,通过查看它的代码、修改代码、运行和调试的过程,你能够建立起对Graal编译器的亲切感,不会觉得这些技术都是很高冷的,不可接近的。当你开始动手修改的时候,你就踏上了彻底掌握它的旅途。
|
||||
- 你要熟练掌握调试方法,并且熟练运用GraalVM的很多参数,这会有利于你去做很多实验,来深入掌握Graal。
|
||||
|
||||
本讲的思维导图我放在这里了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/44/09/4420e3679bb8yy26d6ef663f7fe05609.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你能否把示例程序的add函数改成一个需要计算量的函数,然后,你可以比较一下,看看JIT前后性能相差了多少倍?通过这样的一个例子,你可以获得一些感性认识。
|
||||
|
||||
有相关的问题或者是思考呢,你都可以给我留言。如果你觉得有收获,你也可以把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. GraalVM项目的官方网站:[graalvm.org](https://www.graalvm.org/)。
|
||||
1. Graal的[Github地址](https://github.com/oracle/graal)。
|
||||
1. Graal项目的[出版物](https://github.com/oracle/graal/blob/master/docs/Publications.md)。有很多围绕这个项目来做研究的论文,值得一读。
|
||||
@@ -0,0 +1,268 @@
|
||||
<audio id="audio" title="14 | Java JIT编译器(二):Sea of Nodes为何如此强大?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/bf/67/bfa3853c0030c9cd6aa50ayyc02c1167.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。这一讲,我们继续来研究Graal编译器,重点来了解一下它的IR的设计。
|
||||
|
||||
在上一讲中,我们发现Graal在执行过程中,创建了一个图的数据结构,这个数据结构就是Graal的IR。之后的很多处理和优化算法,都是基于这个IR的。可以说,这个IR是Graal编译器的核心特性之一。
|
||||
|
||||
**那么,为什么这个IR采用的是图结构?它有什么特点和优点?编译器的优化算法又是如何基于这个IR来运行的呢?**
|
||||
|
||||
今天,我就带你一起来攻破以上这些问题。在揭晓问题答案的过程中,你对真实编译器中IR的设计和优化处理过程,也就能获得直观的认识了。
|
||||
|
||||
## 基于图的IR
|
||||
|
||||
IR对于编译器非常重要,因为它填补了高级语言和机器语言在语义上的巨大差别。比如说,你在高级语言中是使用一个数组,而翻译成最高效的x86机器码,是用间接寻址的方式,去访问一块连续的内存。所以IR的设计必须有利于实现这种转换,并且还要有利于运行优化算法,使得生成的代码更加高效。
|
||||
|
||||
在上一讲中,通过跟踪Graal编译器的执行过程,我们会发现它在一开始,就把字节码翻译成了一种新的IR,这个IR是用图的结构来表示的。那这个图长什么样子呢?非常幸运的是,我们可以用工具来直观地看到它的结构。
|
||||
|
||||
你可以从Oracle的[网站](https://www.oracle.com/downloads/graalvm-downloads.html)上,下载一个**idealgraphvisualizer**的工具。下载之后,解压缩,并运行它:
|
||||
|
||||
```
|
||||
export PATH="/<上级目录>/idealgraphvisualizer/bin:$PATH"
|
||||
idealgraphvisualizer &
|
||||
|
||||
```
|
||||
|
||||
这时,程序会启动一个图形界面,并在4445端口上等待GraalVM发送数据过来。
|
||||
|
||||
接着,还是运行Foo示例程序,不过这次你要增加一个参数“`-Dgraal.Dump`”,这会让GraalVM输出编译过程的一些中间结果。并且在这个示例程序当中,我还增加了一个“`-Xcomp`”参数,它能让JIT编译器在第一次使用某个方法的时候,就去做编译工作。
|
||||
|
||||
```
|
||||
mx vm \
|
||||
-XX:+UnlockExperimentalVMOptions \
|
||||
-XX:+EnableJVMCI \
|
||||
-XX:+UseJVMCICompiler \
|
||||
-XX:-TieredCompilation \
|
||||
-XX:CompileOnly=Foo \
|
||||
-Dgraal.Dump \
|
||||
-Xcomp \
|
||||
Foo
|
||||
|
||||
```
|
||||
|
||||
GraalVM会在终端输出“`Connected to the IGV on 127.0.0.1:4445`”,这表明它连接上了idealgraphvisualizer。接着,在即时编译之后,idealgraphvisualizer就接收到了编译过程中生成的图,你可以点击显示它。
|
||||
|
||||
这里我展示了其中两个阶段的图,一个是刚解析完字节码之后(After parsing),一个是在处理完中间层之后(After mid tier)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/2b/28a6cd4180b3a28ce59098a2f5a4c82b.jpg" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/77/9448a684d1b3e04b695bc0761a6b7c77.jpg" alt="">
|
||||
|
||||
Graal IR其实受到了“程序依赖图”的影响。我们在[第6讲](https://time.geekbang.org/column/article/247700)中提到过程序依赖图(PDG),它是用图来表示程序中的数据依赖和控制依赖。并且你也知道了,这种IR还有一个别名,叫做**节点之海(Sea of Nodes)**。因为当程序稍微复杂一点以后,图里的节点就会变得非常多,我们用肉眼很难看得清。
|
||||
|
||||
基于Sea of Nodes的IR呢,算是后起之秀。在HotSpot的编译器中,就采用了这种IR,而且现在Java的Graal编译器和JavaScript的V8编译器中的IR的设计,都是基于了Sea of Nodes结构,所以我们必须重视它。
|
||||
|
||||
这也不禁让我们感到好奇了:**Sea of Nodes到底强在哪里?**
|
||||
|
||||
我们都知道,数据结构的设计对于算法来说至关重要。IR的数据结构,会影响到算法的编写方式。好的IR的设计,会让优化算法的编写和维护都更加容易。
|
||||
|
||||
**而Sea of Nodes最大的优点,就是能够用一个数据结构同时反映控制流和数据流,并且尽量减少它们之间的互相依赖**。
|
||||
|
||||
怎么理解这个优点呢?在传统的编译器里,控制流和数据流是分开的。控制流是用控制流图(Control-flow Graph,CFG)来表示的,比如GNU的编译器、LLVM,都是基于控制流图的。而IR本身,则侧重于表达数据流。
|
||||
|
||||
以LLVM为例,它采用了SSA格式的IR,这种IR可以很好地体现值的定义和使用关系,从而很好地刻画了数据流。
|
||||
|
||||
而问题在于,采用这种比较传统的方式,控制流和数据流会耦合得比较紧,因为IR指令必须归属于某个基本块。
|
||||
|
||||
举个例子来说明一下吧。在下面的示例程序中,“`int b = a*2;`”这个语句,会被放到循环体的基本块中。
|
||||
|
||||
```
|
||||
int foo(int a){
|
||||
int sum = 0;
|
||||
for(int i = 0; i< 10; i++){
|
||||
int b = a*2; //这一句可以提到外面
|
||||
sum += b;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
可是,从数据流的角度看,变量b只依赖于a。所以这个语句没必要放在循环体内,而是可以提到外面。在传统的编译器中,这一步是要分析出循环无关的变量,然后再把这条语句提出去。而如果采用Sea of Nodes的数据结构,变量b一开始根本没有归属到特定的基本块,所以也就没有必要专门去做代码的移动了。
|
||||
|
||||
另外,我们之前讲[本地优化和全局优化](https://time.geekbang.org/column/article/248770)的时候,也提到过,它们的区别就是,在整个函数范围内,优化的范围是在基本块内还是会跨基本块。而Sea of Nodes没有过于受到基本块的束缚,因此也就更容易做全局优化了。
|
||||
|
||||
好,那在概要地理解了Graal IR的数据结构之后,接下来,我们就具体了解一下Graal IR,包括认识一下数据流与控制流的特点,了解两种不同的节点:浮动节点和固定节点,以及认识一种特殊的节点:FrameState。
|
||||
|
||||
### 数据流和控制流
|
||||
|
||||
我们已经知道,Graal IR整合了两种图结构:数据流图和控制流图。
|
||||
|
||||
**首先,我们来看看它的数据流。**
|
||||
|
||||
在下图中,蓝色的边代表的是数据流,也就是数据之间的依赖关系。参数1(“`P(0)`”节点)和参数2(“`P(1)`”节点)的值流入到+号节点,再流入到Return节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5d/26/5d45ebd21fed3b59db52c4e5416b2b26.jpg" alt="">
|
||||
|
||||
在Graal IR的设计中,Add节点有两个输入,分别是x和y,这两个输入是AddNode的两个属性。**注意**,这个图中的箭头方向代表的是**数据依赖关系**,也就是Add节点保持着对它的两个输入节点的引用,这其实跟AST是一致的。**而数据流向,则是反过来的**,从x和y流向Add节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/8f/3fb26f079c4eee298dca7954c55a4f8f.jpg" alt="">
|
||||
|
||||
查看AddNode的设计,你会发现其父类中有两个成员变量,x和y。它们用@input做了注解,这就意味着,这两个成员变量代表的是数据流图中的两条边。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/b0/96408d7ab3721593981172b249daf8b0.jpg" alt="">
|
||||
|
||||
另外,Graal IR的数据流图是符合SSA格式的。也就是说,每个节点代表了SSA中的一个值,它只被定义一次,也就相当于SSA中的每个变量只被赋值一次。
|
||||
|
||||
**我们再来看看控制流。**
|
||||
|
||||
下图中,红色的边代表的是控制流,控制流图代表的是程序执行方向的改变。进入或退出一个函数、条件分支语句、循环语句等,都会导致程序的执行从一个地方跳到另一个地方。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/d2/f6d826358b5f1cc2d4048a310273a6d2.jpg" alt="">
|
||||
|
||||
数据流加上控制流,就能完整表达程序的含义,它等价于字节码,也等价于更早期的AST。你可以从Start节点,沿着控制流遍历这个图。当到达Return节点之前,Return所依赖的数据(x+y)也需要计算出来。
|
||||
|
||||
add()方法的控制流很简单,只有Start和Return两个节点。我们做一个稍微复杂一点的例子,在Foo.add2()示例程序中,调用两个函数getX()和getY(),分别获取x和y成员变量。
|
||||
|
||||
```
|
||||
public int add2(){
|
||||
return getX() + getY();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的Graal图如下。它增加了两个节点,分别是调用方法getX和getY,这就导致了控制流发生变化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/38/ce0fb1e90cb4938197d3fe502ef37c38.jpg" alt="">
|
||||
|
||||
注意:对于这个例子,在使用GraalVM时,要使用-XX:-Inline选项,避免编译器做内联优化,否则Foo.getX()和Foo.getY()会被内联。我们在下一讲中就会探讨内联优化。
|
||||
|
||||
除了调用其他函数,if语句、循环语句等,也会导致控制流的变化。我们看看这个例子:
|
||||
|
||||
```
|
||||
public int doif(int x, int y){
|
||||
int z;
|
||||
if (x < 2)
|
||||
z=x+y;
|
||||
else
|
||||
z=x*y;
|
||||
return z;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它对应的Graal图如下,if语句会让控制流产生分支,分别对应if块和else块,最后在Merge节点合并起来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/5f/ffa4a5ab2fb85437085fac2c67333f5f.jpg" alt="">
|
||||
|
||||
IfNode作为一种控制流节点,它保存着对下级节点的引用,并用@Successor注解来标注。这意味着trueSuccessor和falseSuccessor两个成员变量,代表着控制流中的两条边。当然,你也会注意到,If节点有一个数据流的输入,这就是If的判断条件。IR会基于这个判断条件,来决定控制流的走向。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/d2/1dce8ca28447b2b61f31e4b40d1f37d2.jpg" alt="">
|
||||
|
||||
跟控制流类似,数据流也产生了两个分支,分别是`x+y`和`x*y`。最后用一个Phi节点合并到一起。
|
||||
|
||||
Phi节点是SSA的一个特性。在doif示例程序中,z可能有两个取值。如果控制流走的是if块,那么`z=x+y`;而如果走的是else块,则`z=x*y`。Phi节点就起到这个作用,它根据控制流来选择值。
|
||||
|
||||
总结一下:控制流图表达的是控制的流转,而数据流图代表的是数据之间的依赖关系。二者双剑合璧,代表了源程序完整的语义。
|
||||
|
||||
接下来,我再给你介绍一下浮动节点和固定节点的概念。
|
||||
|
||||
### 浮动节点和固定节点
|
||||
|
||||
注意,在Graal IR,数据流与控制流是相对独立的。你看看前面的doif示例程序,会发现`x+y`和`x*y`的计算,与if语句的控制流没有直接关系。所以,你其实可以把这两个语句挪到if语句外面去执行,也不影响程序运行的结果(要引入两个临时变量z1和z2,分别代表z的两个取值)。
|
||||
|
||||
对于这些在执行时间上具有灵活性的节点,我们说它们是浮动的(Floating)。你在AddNode的继承层次中,可以看到一个父类:FloatingNode,这说明这个节点是浮动的。它可以在最后生成机器码(或LIR)的环节,再去确定到底归属哪个基本块。
|
||||
|
||||
除了浮动节点以外,还有一些节点是固定在控制流中的,前后顺序不能乱,这些节点叫做固定节点。除了那些流程控制类的节点(如IfNode)以外,还有一些节点是固定节点,比如内存访问的节点。当你访问一个对象的属性时,就需要访问内存。
|
||||
|
||||
内存是个共享资源,同一个内存地址(比如对象的属性),可以被多次读写。也就是说,内存位置不是SSA中的值,所以也不受单赋值的约束。
|
||||
|
||||
对同一个内存地址的读写操作,顺序是不能乱的。比如下面代码中,第二行和第三行的顺序是不能变的,它们被固定在了控制流中。
|
||||
|
||||
```
|
||||
x := 10
|
||||
store x to 地址a
|
||||
y := load 地址a
|
||||
z := y + 10
|
||||
|
||||
```
|
||||
|
||||
不过,在运行某些优化算法的时候,某些固定节点会被转化成浮动节点,从而提供了更大的代码优化空间。我们在下一讲的“内联和逃逸分析”中,会见到这样的例子。
|
||||
|
||||
### FrameState节点
|
||||
|
||||
在看Graal IR的时候,你经常会遇到一个绿色的节点插在图中。为避免你产生困惑,接下来我就专门给你解释一下这个节点,我们一起来认识一下它。
|
||||
|
||||
在Foo.add()新生成的IR中,如果你不勾选“Remove State”选项,就会显示出一个绿色的节点。这个节点就是FrameState节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/2f/74159a7a56ed706335caf8e5fb3c5c2f.jpg" alt="">
|
||||
|
||||
FrameState比较特殊。它保存了栈帧的状态,而且这里我指的是Java字节码解释器的栈帧的状态,包括了本地变量和操作数栈里的值。
|
||||
|
||||
**为什么要保存栈帧的状态呢?**
|
||||
|
||||
**第一个用途,是用于逆优化。**上一讲我们说过,编译器有时候会基于推测做一些激进的优化,比如忽略掉某些分支。但如果推测依据的前提错了,那么就要做逆优化,重新回到解释器去执行。而FrameState的作用就是在代码中一些叫做安全点的地方,记录下栈帧的状态,便于逆优化以后,在解释器里去接着执行程序。
|
||||
|
||||
**第二个用途,是用于debug。**编译器会用FrameState,来记录程序执行的一些中间状态值,以方便程序的调试。
|
||||
|
||||
对于Foo.add()方法的IR,通过后面的一些优化处理,你会发现Foo.add()并不需要逆优化,那么FrameState节点就会被去掉。否则,FrameState就会转化成一个逆优化节点,生成与逆优化有关的代码。
|
||||
|
||||
如果你并不关心逆优化,那你在平常看IR的过程中,可以勾选“Remove State”选项,不用关注FrameState节点就行了。
|
||||
|
||||
好了,我们已经大致了解了Graal IR。进一步,编译器要基于IR做各种处理和优化。
|
||||
|
||||
## 对Graal IR的处理和优化
|
||||
|
||||
通过上一讲,我们已经知道在编译过程中要对图进行很多遍的处理。还是以Foo.add()示例程序为例,在运行GraalVM的时候,我们加上“`-Dgraal.Dump=:5`”选项,程序就会详细地dump出所做的处理步骤,你可以在idealgraphvisualizer中看到这些处理环节,点击每个环节可以看到相对应的IR图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/92/7462377c2df0c782dc6524d9ac583b92.jpg" alt="">
|
||||
|
||||
在这些处理阶段的名称中,你会看到我们在[第7讲](https://time.geekbang.org/column/article/248770)中提到的一些代码优化算法的名称(如死代码删除)。有了前面课程的铺垫,你现在看它们应该就消除了一些陌生感。
|
||||
|
||||
另外,你会发现,在这些处理阶段中,有一个Canonicalizer的阶段出现了好几次,并且你可能对这个词也比较陌生,所以下面我们不妨来看看,这个阶段都做了些什么。
|
||||
|
||||
### 规范化(Canonicalizer)
|
||||
|
||||
Canonicalize的意思是规范化。如果某段程序有多种写法,那么编译器会处理成一种统一的、标准的写法。
|
||||
|
||||
比如,对于下面这个简单的函数,它是把a乘以2。在CanonicalizerPhase运行之后,乘法运算被替换成了移位运算,也就是`a<<1`。它的效果与乘以2是相同的,但运行效率更高。
|
||||
|
||||
```
|
||||
public int doDouble(int a){
|
||||
return 2*a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/67/9723be4d7a727f8a4eb229d151e13367.jpg" alt="">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/f9/f81af2861253a6ce25337df40f1399f9.jpg" alt="">
|
||||
|
||||
你还可以试一下对某个变量取两次负号的操作。在规范化阶段以后,两个负号就会被去掉,直接返回a。
|
||||
|
||||
```
|
||||
public int negneg(int a){
|
||||
return -(-a);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
规范化需要的操作,都是对本节点进行修改和替换,一般都不太复杂。某节点如果实现了Canonicalizable接口,在CanonicalizerPhase就会对它做规范化。
|
||||
|
||||
在规范化阶段实现的优化算法包括:常数折叠(Constant Folding)、强度折减(Strength reduction)、全局值编号(Global Value Numbering,GVN),等等。它们的原理,我在[第7讲](https://time.geekbang.org/column/article/248770)都介绍过,这里就不赘述了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我给你介绍了Graal的IR:它整合了控制流图与数据流图,符合SSA格式,有利于优化算法的编写和维护。
|
||||
|
||||
我还带你了解了对IR的一个优化处理过程:规范化。规范化所需要的操作一般并不复杂,它都是对本节点进行修改和替换。在下一讲中,我会带你分析另外两个重要的算法,内联和逃逸分析。
|
||||
|
||||
另外,Graal的IR格式是声明式的(Declarative),它通过描述一个节点及其之间的关系来反映源代码的语义。而我们之前见到的类似[三地址代码](https://time.geekbang.org/column/article/247700)那样的格式,是命令式的(Imperative),它的风格是通过命令直接告诉计算机,来做一个个的动作。
|
||||
|
||||
声明式和命令式是编程的两种风格,在Graal编译器里,我们可以看到声明式的IR会更加简洁,对概念的表达也更加清晰。我们在后面介绍MySQL编译器的实现机制当中,在讲DSL的时候,还会再回到这两个概念,到时你还会有更加深刻的认识。
|
||||
|
||||
本讲的思维导图我也放在了这里,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4f/88/4fd67086601a6dc4deacc794101d9188.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
了解了Graal IR的特点以后,通过对比我们在第7讲中学过的优化算法,你觉得哪些优化算法在Graal IR上实现起来会更方便?为什么?欢迎在留言区分享你的看法。
|
||||
|
||||
如果你觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
基于图的IR,有三篇论文必须提到:
|
||||
|
||||
1. 程序依赖图:[J. Ferrante, K. J. Ottenstein, and J. D. Warren. The program dependence graph and its use in optimization](https://www.cs.utexas.edu/users/less/reading/spring00/ferrante.pdf). July 1987。有关程序依赖图的概念在1987年就提出来了。
|
||||
1. Click的论文:[A Simple Graph-Based Intermediate Representation](https://www.oracle.com/technetwork/java/javase/tech/c2-ir95-150110.pdf)。这篇文章比较易读,属于必读文献。Click还发表了一些论文,讲述了基于图的IR上的优化算法。
|
||||
1. 介绍Graal IR的论文:[Graal IR: An Extensible Declarative Intermediate Representation](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.726.5496&rep=rep1&type=pdf)。这篇论文也很易读,建议你一定要读一下。
|
||||
@@ -0,0 +1,260 @@
|
||||
<audio id="audio" title="15 | Java JIT编译器(三):探究内联和逃逸分析的算法原理" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/dc/f6/dcb2a02ae500a3651f3067d603e3cff6.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
基于Graal IR进行的优化处理有很多。但有些优化,针对Java语言的特点,会显得更为重要。
|
||||
|
||||
今天这一讲,我就带你来认识两个对Java来说很重要的优化算法。如果没有这两个优化算法,你的程序执行效率会大大下降。而如果你了解了这两个算法的机理,则有可能写出更方便编译器做优化的程序,从而让你在实际工作中受益。这两个算法,分别是**内联和逃逸分析**。
|
||||
|
||||
另外,我还会给你介绍一种JIT编译所特有的优化模式:**基于推理的优化**。这种优化模式会让某些程序比AOT编译的性能更高。这个知识点,可能会改变你对JIT和AOT的认知,因为通常来说,你可能会认为AOT生成的机器码速度更快,所以通过这一讲的学习,你也会对“全生命周期优化”的概念有所体会。
|
||||
|
||||
好,首先,我们来看看内联优化。
|
||||
|
||||
## 内联(Inlining)
|
||||
|
||||
内联优化是Java JIT编译器非常重要的一种优化策略。简单地说,内联就是把被调用的方法的方法体,在调用的地方展开。这样做最大的好处,就是省去了函数调用的开销。对于频繁调用的函数,内联优化能大大提高程序的性能。
|
||||
|
||||
**执行内联优化是有一定条件的。**第一,被内联的方法要是热点方法;第二,被内联的方法不能太大,否则编译后的目标代码量就会膨胀得比较厉害。
|
||||
|
||||
在Java程序里,你经常会发现很多短方法,特别是访问类成员变量的getter和setter方法。你可以看看自己写的程序,是否也充斥着很多对这种短方法的调用?这些调用如果不做优化的话,性能损失是很厉害的。你可以做一个性能对比测试,通过“`-XX:-Inlining`”参数来阻止JVM做内联优化,看看性能降低得会有多大。
|
||||
|
||||
**但是这些方法有一个好处:它们往往都特别短,内联之后,实际上并不会显著增加目标代码长度。**
|
||||
|
||||
比如,针对add2示例方法,我们采用内联选项优化后,方法调用被替换成了LoadField(加载成员变量)。
|
||||
|
||||
```
|
||||
public int add2(){
|
||||
return getX() + getY();
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/d2/bf9488262141720de83ff05a05942fd2.jpg" alt="">
|
||||
|
||||
在做了Lower处理以后,LoadField会被展开成更底层的操作:根据x和y的地址相对于对象地址的偏移量,获取x和y的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/33/3bdef5cd633e39bc53d188015e82de33.jpg" alt="">
|
||||
|
||||
而要想正确地计算字段的偏移量,我们还需要了解Java对象的**内存布局**。
|
||||
|
||||
在64位平台下,每个Java对象头部都有8字节的标记字,里面有对象ID的哈希值、与内存收集有关的标记位、与锁有关的标记位;标记字后面是一个指向类定义的指针,在64位平台下也是8位,不过如果堆不是很大,我们可以采用压缩指针,只占4个字节;在这后面才是x和y字段。因此,x和y的偏移量分别是12和16。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/23/13766183508ae9b5668ec5bb816aac23.jpg" alt="">
|
||||
|
||||
在Low Tier编译完毕以后,图2还会进一步被Lower,形成AMD64架构下的地址。这样的话,编译器再进一步翻译成汇编代码就很容易了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/3f/70022d915f78d49d2a9b871b4891fe3f.jpg" alt="">
|
||||
|
||||
内联优化除了会优化getter、setter这样的短方法,它实际上还起到了另一个重要的作用,即**跨过程的优化**。一般的优化算法,只会局限在一个方法内部。而启动内联优化后,多个方法会合并成一个方法,所以就带来了更多的优化的可能性。
|
||||
|
||||
我们来看看下面这个inlining示例方法。它调用了一个atLeastTen方法,这个方法对于<10的参数,会返回10;对于≥10的参数,会返回该参数本身。所以你用肉眼就可以看出来,inlining方法的返回值应该是10。
|
||||
|
||||
```
|
||||
public int inliningTest(int a){
|
||||
return atLeastTen(3); //应该返回10
|
||||
}
|
||||
|
||||
//至少返回10
|
||||
public int atLeastTen(int a){
|
||||
if (a < 10)
|
||||
return 10;
|
||||
else
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果不启用编译器的内联选项,那么inliningTest方法对应的IR图,就是常规的方法调用而已:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/06/12/060cbc2c3a8a68086f4cd9077df46f12.jpg" alt="">
|
||||
|
||||
而一旦启用了内联选项,就可以触发一系列的优化。在把字节码解析生成IR的时候,编译器就启动了内联分析过程,从而会发现this参数和常量3对于inliningTest方法根本是无用的,在图里表现成了一些孤岛。在Mid Tier处理完毕之后,inliningTest方法就直接返回常量10了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/58/d63a8ac893a03c9c3714580f61742258.jpg" alt="">
|
||||
|
||||
另外,方法的类型也会影响inlining。如果方法是final的,或者是private的,那么它就不会被子类重载,所以可以大胆地内联。
|
||||
|
||||
但如果存在着多重继承的类体系,方法就有可能被重载,这就会导致**多态**。在运行时,JVM会根据对象的类型来确定到底采用哪个子类的具体实现。这种运行时确定具体方法的过程,叫做**虚分派**(Virtual Dispatch)。
|
||||
|
||||
在存在多态的情况下,JIT编译器做内联就会遇到困难了。因为它不知道把哪个版本的实现内联进来。不过编译器仍然没有放弃。这时候所采用的技术,就叫做“**多态内联**(Polymorphic inlining)”。
|
||||
|
||||
它的具体做法是,在运行时,编译器会统计在调用多态方法的时候,到底用了哪几个实现。然后针对这几个实现,同时实现几个分支的内联,并在一开头根据对象类型判断应该走哪个分支。**这个方法的缺陷是生成的代码量会比较大,但它毕竟可以获得内联的好处。**最后,如果实际运行中遇到的对象,与提前生成的几个分支都不匹配,那么编译器还可以继续用缺省的虚分派模式来做函数调用,保证程序不出错。
|
||||
|
||||
这个案例也表明了,JIT编译器是如何充分利用运行时收集的信息来做优化的。对于AOT模式的编译来说,由于无法收集到这些信息,因此反倒无法做这种优化。
|
||||
|
||||
如果你想对多态内联做更深入的研究,还可以参考这一篇经典论文《[Inlining of Virtual Methods](http://extras.springer.com/2000/978-3-540-67660-7/papers/1628/16280258.pdf)》。
|
||||
|
||||
总结起来,内联优化不仅能降低由于函数调用带来的开销,还能制造出新的优化机会,因此带来的优化效果非常显著。接下来,我们看看另一个能带来显著优化效果的算法:逃逸分析。
|
||||
|
||||
## 逃逸分析(Escape Analysis, EA)
|
||||
|
||||
逃逸分析是JVM的另一个重要的优化算法,它同样可以起到巨大的性能提升作用。
|
||||
|
||||
**逃逸分析能够让编译器判断出,一个对象是否能够在创建它的方法或线程之外访问。**如果只能在创建它的方法内部访问,我们就说这个对象不是方法逃逸的;如果仅仅逃逸出了方法,但对这个对象的访问肯定都是在同一个线程中,那么我们就说这个对象不是线程逃逸的。
|
||||
|
||||
**判断是否逃逸有什么用呢?**用处很大。只要我们判断出了该对象没有逃逸出方法或线程,就可以采用更加优化的方法来管理该对象。
|
||||
|
||||
以下面的示例代码为例。我们有一个escapeTest()方法,这个方法可以根据输入的年龄,返回年龄段:小于20岁的返回1,否则返回2。
|
||||
|
||||
在示例程序里,我们创建了一个Person对象,并调用它的ageSegment方法,来返回年龄段。
|
||||
|
||||
```
|
||||
public int escapeTest(int age){
|
||||
Person p = new Person(age);
|
||||
return p.ageSegment();
|
||||
}
|
||||
|
||||
public class Person{
|
||||
private int age;
|
||||
private float weight;
|
||||
|
||||
public Person(int age){
|
||||
this.age = age;
|
||||
}
|
||||
|
||||
//返回年龄段
|
||||
final public int ageSegment(){
|
||||
if (age < 20)
|
||||
return 1;
|
||||
else
|
||||
return 2;
|
||||
}
|
||||
|
||||
public void setWeight(float weight){
|
||||
this.weight = weight;
|
||||
}
|
||||
|
||||
public float getWeidht(){
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以分析一下,针对这段程序,我们可以做哪些优化工作?
|
||||
|
||||
首先是**栈上分配内存**。
|
||||
|
||||
在Java语言里,对象的内存通常都是在堆中申请的。对象不再被访问以后,会由垃圾收集器回收。但对于这个例子来说,Person对象的生命周期跟escapeTest()方法的生命周期是一样的。在退出方法后,就不再会有别的程序来访问该对象。
|
||||
|
||||
换句话说,这个对象跟一个int类型的本地变量没啥区别。那么也就意味着,我们其实可以在栈里给这个对象申请内存就行了。
|
||||
|
||||
你已经知道,在栈里申请内存会有很多好处:可以自动回收,不需要浪费GC的计算量去回收内存;可以避免由于大量生成小对象而造成的内存碎片;数据的局部性也更好,因为在堆上申请内存,它们的物理地址有可能是不相邻的,从而降低高速缓存的命中率;再有,在并发计算的场景下,在栈上分配内存的效率更高,因为栈是线程独享的,而在堆中申请内存可能需要多线程之间同步。所以,我们做这个优化是非常有价值的。
|
||||
|
||||
再进一步,还可以做**标量替换**(Scalar Replacement)。
|
||||
|
||||
这是什么意思呢?你会发现,示例程序仅仅用到了Person对象的age成员变量,而weight根本没有涉及。所以,我们在栈上申请内存的时候,根本没有必要为weight保留内存。同时,在一个Java对象的标准内存布局中,还要有一块固定的对象头的内存开销。在64位平台,对象头可能占据了16字节。这下倒好,示例程序本来只需要4个字节的一个整型,最后却要申请24个字节,是原需求的6倍,这也太浪费了。
|
||||
|
||||
通过标量替换的技术,我们可以根本不生成对象实例,而是把要用到的对象的成员变量,作为普通的本地变量(也就是标量)来管理。
|
||||
|
||||
这么做还有一个好处,就是编译器可以尽量把标量放到寄存器里去,连栈都不用,这样就避免了内存访问所带来的性能消耗。
|
||||
|
||||
Graal编译器也确实是这么做的。在Mid Tier层处理完毕以后,你查看IR图,会发现它变成了下面的这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/4d/fa291d3ccb2bc3a865cdfb4a46ed704d.jpg" alt="">
|
||||
|
||||
你会看到,编译器连Person的ageSegement方法也内联进来了。最后优化后的函数相当于:
|
||||
|
||||
```
|
||||
public int escapeTest(int age){
|
||||
return age<20 ? 1 : 2;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
图7中的Conditional是一个条件计算的三元表达式。你看到这个优化结果的时候,有没有感到震惊?是的。善用编译器的优化算法,就是会达到如此程度的优化。优化前后的代码的功能是一样的,但优化后的代码变得如此简洁、直奔最终计算目标,忽略中间我们自己编程所带来的冗余动作。
|
||||
|
||||
上面讲的都是没有逃逸出方法的情况。这种情况被叫做**NoEscape**。还有一种情况,是虽然逃逸出了方法,但没有逃逸出当前线程,也就是说不可能被其他线程所访问,这种逃逸叫做**ArgEscape**,也就是它仅仅是通过方法的参数逃逸的。最后一种情况就是**GlobalEscape**,也就是能够被其他线程所访问,因此没有办法优化。
|
||||
|
||||
对于ArgEscape的情况,虽然编译器不能做内存的栈上分配,但也可以做一定的优化,这就是**锁消除或者同步消除**。
|
||||
|
||||
我们知道,在并发场景下,锁对性能的影响非常之大。而很多线程安全的对象,比如一些集合对象,它们的内部都采用了锁来做线程的同步。如果我们可以确定这些对象没有逃逸出线程,那么就可以把这些同步逻辑优化掉,从而提高代码的性能。
|
||||
|
||||
好了,现在你已经理解了逃逸分析的用途。那么逃逸分析的算法是怎么实现的呢?这方面你可以去参考这篇经典论文《[Escape Analysis for Java](https://www.cc.gatech.edu/~harrold/6340/cs6340_fall2009/Readings/choi99escape.pdf)》。论文里的算法利用了一种数据结构,叫做**连接图**(Connection Graph)。简单地说,就是分析出了程序中对象之间的引用关系。
|
||||
|
||||
整个分析算法是建立在这样一种直觉认知上的:**基于一个连接图,也就是对象之间的引用关系图,如果A引用了B,而A能够被线程外的程序所访问(线程逃逸),那么也就意味着B也是线程逃逸的。**也就是说,**逃逸性是有传染能力的**。通过这样的分析,那些完全没被传染的对象,就是NoEscape的;只被ArgEscape对象传染的,那就也是ArgEscape的。原理说起来就是这么简单。
|
||||
|
||||
另外,我们前面所做的分析都是静态分析,也就是基于对代码所做的分析。对于一个对象来说,只要存在任何一个控制流,使得它逃逸了,那么编译器就会无情地把它标注为是逃逸对象,也就不能做优化了。但是,还会出现一种情况,就是有可能这个分支的执行频率特别少,大部分情况下该对象都是不逃逸的。
|
||||
|
||||
所以,Java的JIT编译器实际上又向前迈进了一步,实现了**部分逃逸分析**(Partial Escape Analysis)。它会根据运行时的统计信息,对不同的控制流分支做不同的处理。对于没有逃逸的分支,仍然去做优化。在这里,你能体会到,编译器为了一点点的性能提升,简直无所不用其极呀。
|
||||
|
||||
如果你还想对部分逃逸分析做进一步的研究,那你可以参考这篇论文:《[Partial Escape Analysis and Scalar Replacement for Java](http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf)》。
|
||||
|
||||
总结起来,逃逸分析能够让对象在栈上申请内存,做标量替换,从而大大减少对象处理的开销。这个算法对于对象生命周期比较短的场景,优化效果是非常明显的。
|
||||
|
||||
在讲内联和逃逸算法的时候,我们都发现,编译器会根据运行时的统计信息,通过推断来做一些优化,比如多态内联、部分逃逸分析。而这种优化模式,就叫做基于推理的优化。
|
||||
|
||||
## 基于推理的优化(Speculative Optimization)
|
||||
|
||||
我刚刚说过,一般情况下,编译器的优化工作是基于对代码所做的分析,也就是静态分析。而JIT编译还有一个优势,就是会根据运行时收集的统计信息来做优化。
|
||||
|
||||
我还是以Foo.atLeastTen()方法举例。在正常情况下,它生成的HIR是下面的样子,根据条件表达式的值(a<10),分别返回10和a。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/95/d8a140e575dac91yye441ea207e6ff95.jpg" alt="">
|
||||
|
||||
而如果我们在主程序里调用atLeastTen方法是采用下面示例代码中的逻辑,在启动JIT编译器时,已经循环了上万次。而在这上万次当中,只有9次i的值是小于10的,那么编译器就会根据运行时的统计信息判断,i的值大概率是大于10的。所以,它会仅针对大于10的分支做编译。
|
||||
|
||||
而如果遇到小于10的情况,则会导致逆优化。你会看到,IR中有一个绿色的FrameState节点,这个节点保存了栈帧的状态,在逆优化时会被用到。
|
||||
|
||||
```
|
||||
int i = 0;
|
||||
while(true){
|
||||
i++;
|
||||
foo.atLeastTen(i);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/7a/b0ba99f24e3df80748e98df1da93187a.jpg" alt="">
|
||||
|
||||
我们把主程序修改一下,再做一次实验。这次,我们传给Foo.atLeastTen方法的参数是i%10,也就是参数a的取值范围永远是在0到9之间。这一次,JIT编译器会反过来,仅针对a小于10的分支做编译,而对a大于10的情况做逆优化处理。
|
||||
|
||||
```
|
||||
int i = 0;
|
||||
while(true){
|
||||
i++;
|
||||
foo.atLeastTen(i%10);
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/72/31/726aa67de052ec01f3907b4f5329ed31.jpg" alt="">
|
||||
|
||||
通过这个简单的例子,你对JIT编译器基于推理的优化情况就有了一个直观的了解。对于atLeastTen这个简单的方法,这样的优化似乎并不会带来太多的好处。但对于比较复杂的程序,那既可以节省编译时间,也能减少编译后的代码大小,收益是很高的。比如对于程序中仅用于输出Debug信息的分支,就根本不需要生成代码。
|
||||
|
||||
另外,这种基于推理的优化,还会带来其他额外的优化机会。比如对于逃逸分析而言,去掉了一些导致逃逸的分支以后,在剩下的分支中,对象并没有逃逸,所以也就可以做优化了!
|
||||
|
||||
总结起来,基于运行时的统计信息进行推理的优化,有时会比基于静态分析的AOT产生出性能更高的目标代码。所以,现代编译技术的实践,会强调“全生命周期”优化的概念。甚至即使是AOT产生的目标代码,仍然可以在运行期通过JIT做进一步优化。LLVM项目的发起人之一,Chris Lattner就曾写了一篇[论文](https://llvm.org/pubs/2003-09-30-LifelongOptimizationTR.pdf)来提倡这个理念,这也是LLVM的设计目标之一。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天我带你了解了Java JIT编译器中两个重要的优化算法,这两个优化算法都会大大提升程序的执行效率。另外,你还了解了在JIT编译中,基于运行时的信息做推理优化的技术。
|
||||
|
||||
在课程中,我不可能带你去分析所有的优化算法,但你可以根据课程的内容去举一反三,研究一下里面的其他算法。如果你对这些算法掌握得比较清晰,那你就可以胜任编译器的开发工作了。因为编译器开发的真正的工作量,都在中后端。
|
||||
|
||||
另外,熟悉这些重要的优化算法的原理,还有助于你写出性能更高的程序。比如说,你要让高频使用的代码易于内联;在使用对象的时候,让它的作用范围清晰一些,不要做无用的关联,尽量不要逃逸出方法和线程之外,等等。
|
||||
|
||||
本讲的思维导图我也放在下面了,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/11/cd/1189f3bb094dcaba28ba44be41bff7cd.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
今天的思考题,还是想请你设计一个场景,测试内联 vs 无内联,或者做逃逸优化 vs 无逃逸优化的性能差异。借此,你也可以熟悉一下如何控制JVM的优化选项。欢迎在留言区分享你在测试中的发现。
|
||||
|
||||
>
|
||||
<p>关闭内联优化: -XX:-Inlining。JDK8缺省是打开的。<br>
|
||||
显示内联优化详情:-XX:+PrintInlining。<br>
|
||||
关闭逃逸分析:-XX:-DoEscapeAnalysis。JDK8缺省是打开的。<br>
|
||||
显示逃逸分析详情:-XX:+PrintEscapeAnalysis。<br>
|
||||
关闭标量替换:-XX:-EliminateAllocations。JDK8缺省是打开的。<br>
|
||||
显示标量替换详情:-XX:+PrintEliminateAllocations。</p>
|
||||
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 多态内联:[Inlining of Virtual Methods](http://extras.springer.com/2000/978-3-540-67660-7/papers/1628/16280258.pdf)。
|
||||
1. 逃逸分析:[Escape Analysis for Java](https://www.cc.gatech.edu/~harrold/6340/cs6340_fall2009/Readings/choi99escape.pdf)。
|
||||
1. 部分逃逸分析:[Partial Escape Analysis and Scalar Replacement for Java](http://www.ssw.uni-linz.ac.at/Research/Papers/Stadler14/Stadler2014-CGO-PEA.pdf)。
|
||||
@@ -0,0 +1,251 @@
|
||||
<audio id="audio" title="16 | Java JIT编译器(四):Graal的后端是如何工作的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/92/f987fe6382e6f6f8775c2506b2667492.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
前面两讲中,我介绍了Sea of Nodes类型的HIR,以及基于HIR的各种分析处理,这可以看做是编译器的中端。
|
||||
|
||||
可编译器最终还是要生成机器码的。那么,这个过程是怎么实现的呢?与硬件架构相关的LIR是什么样子的呢?指令选择是怎么做的呢?
|
||||
|
||||
这一讲,我就带你了解Graal编译器的后端功能,回答以上这些问题,破除你对后端处理过程的神秘感。
|
||||
|
||||
首先,我们来直观地了解一下后端处理的流程。
|
||||
|
||||
## 后端的处理流程
|
||||
|
||||
在[第14讲](https://time.geekbang.org/column/article/256914)中,我们在运行Java示例程序的时候(比如`atLeastTen()`方法),使用了“`-Dgraal.Dump=:5`”的选项,这个选项会dump出整个编译过程最详细的信息。
|
||||
|
||||
对于HIR的处理过程,程序会通过网络端口,dump到IdealGraphVisualizer里面。而后端的处理过程,缺省则会dump到工作目录下的一个“`graal_dumps`”子目录下。你可以用文本编辑器打开查看里面的信息。
|
||||
|
||||
```
|
||||
//至少返回10
|
||||
public int atLeastTen(int a){
|
||||
if (a < 10)
|
||||
return 10;
|
||||
else
|
||||
return a;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
不过,你还可以再偷懒一下,使用一个图形工具[c1visualizer](http://lafo.ssw.uni-linz.ac.at/c1visualizer/)来查看。
|
||||
|
||||
补充:c1visualizer原本是用于查看Hopspot的C1编译器(也就是客户端编译器)的LIR的工具,这也就是说,Graal的LIR和C1的是一样的。另外,该工具不能用太高版本的JDK运行,我用的是JDK1.8。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/0c/a5a9a3f2135b0cba3e5945158d72df0c.jpg" alt="">
|
||||
|
||||
在窗口的左侧,你能看到后端的处理流程。
|
||||
|
||||
- 首先是把HIR做最后一次排序(HIR Final Schedule),这个处理会把HIR节点分配到基本块,并且排序;
|
||||
- 第二是生成LIR,在这个过程中要做指令选择;
|
||||
- 第三,寄存器分配工作,Graal采用的算法是线性扫描(Linear Scan);
|
||||
- 第四,是基于LIR的一些优化工作,比如ControlFlowOptimizer等;
|
||||
- 最后一个步骤,是生成目标代码。
|
||||
|
||||
接下来,我们来认识一下这个LIR:它是怎样生成的,用什么数据结构保存的,以及都有什么特点。
|
||||
|
||||
## 认识LIR
|
||||
|
||||
在对HIR的处理过程中,前期(High Tier、Mid Tier)基本上都是与硬件无关。到了后期(Low Tier),你会看到IR中的一些节点逐步开始带有硬件的特征,比如上一讲中,计算AMD64地址的节点。而LIR就更加反映目标硬件的特征了,基本上可以跟机器码一对一地翻译。所以,从HIR生成LIR的过程,就要做指令选择。
|
||||
|
||||
我把与LIR相关的包和类整理成了类图,里面划分成了三个包,分别包含了与HIR、LIR和CFG有关的类。你可以重点看看它们之间的相互关系。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/55/a0/55fb0e71e492d921c2ea0df13b433aa0.jpg" alt="">
|
||||
|
||||
在HIR的最后的处理阶段,程序会通过一个Schedule过程,把HIR节点排序,并放到控制流图中,为生成LIR和目标代码做准备。我之前说过,HIR的一大好处,就是那些浮动节点,可以最大程度地免受控制流的约束。但在最后生成的目标代码中,我们还是要把每行指令归属到某个具体的基本块的。而且,基本块中的HIR节点是按照顺序排列的,在ScheduleResult中保存着这个顺序(blockToNodesMap中顺序保存了每个Block中的节点)。
|
||||
|
||||
**你要注意**,这里所说的Schedule,跟编译器后端的指令排序不是一回事儿。这里是把图变成线性的程序;而编译器后端的指令排序(也叫做Schedule),则是为了实现指令级并行的优化。
|
||||
|
||||
当然,把HIR节点划分到不同的基本块,优化程度是不同的。比如,与循环无关的代码,放在循环内部和外部都是可以的,但显然放在循环外部更好一些。把HIR节点排序的Schedule算法,复杂度比较高,所以使用了很多**启发式**的规则。刚才提到的把循环无关代码放在循环外面,就是一种启发式的规则。
|
||||
|
||||
图2中的ControlFlowGraph类和Block类构成了控制流图,控制流图和最后阶段的HIR是互相引用的。这样,你就可以知道HIR中的每个节点属于哪个基本块,也可以知道每个基本块中包含的HIR节点。
|
||||
|
||||
做完Schedule以后,接着就会生成LIR。**与声明式的HIR不同,LIR是命令式的,由一行行指令构成。**
|
||||
|
||||
图1显示的是Foo.atLeatTen方法对应的LIR。你会看到一个控制流图(CFG),里面有三个基本块。B0是B1和B2的前序基本块,B0中的最后一个语句是分支语句(基本块中,只有最后一个语句才可以是导致指令跳转的语句)。
|
||||
|
||||
LIR中的指令是放到基本块中的,LIR对象的LIRInstructions属性中,保存了每个基本块中的指令列表。
|
||||
|
||||
OK,那接下来,我们来看看LIR的指令都有哪些,它们都有什么特点。
|
||||
|
||||
LIRInstruction的子类,主要放在三个包中,你可以看看下面的类图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2c/87/2cda9c39ab9ff5a7b2f1b100c24b2687.jpg" alt="">
|
||||
|
||||
首先,在**org.graalvm.compiler.lir包**中,声明了一些与架构无关的指令,比如跳转指令、标签指令等。因为无论什么架构的CPU,一定都会有跳转指令,也一定有作为跳转目标的标签。
|
||||
|
||||
然后,在**org.graalvm.compiler.lir.amd64包**中,声明了几十个AMD64架构的指令,为了降低你的阅读负担,这里我只列出了有代表性的几个。这些指令是LIR代码中的主体。
|
||||
|
||||
最后,在**org.graalvm.compiler.hotspot.amd64包**中,也声明了几个指令。这几个指令是利用HotSpot虚拟机的功能实现的。比如,要获取某个类的定义的地址,只能由虚拟机提供。
|
||||
|
||||
好了,通过这样的一个分析,你应该对LIR有更加具体的认识了:**LIR中的指令,大多数是与架构相关的。**这样才适合运行后端的一些算法,比如指令选择、寄存器分配等。你也可以据此推测,其他编译器的LIR,差不多也是这个特点。
|
||||
|
||||
接下来,我们就来了解一下Graal编译器是如何生成LIR,并且在这个过程中,它是如何实现指令选择的。
|
||||
|
||||
## 生成LIR及指令选择
|
||||
|
||||
我们已经知道了,Graal在生成LIR的过程中,要进行指令选择。
|
||||
|
||||
我们先看一下Graal对一个简单的示例程序Foo.add1,是如何生成LIR的。
|
||||
|
||||
```
|
||||
public static int add1(int x, int y){
|
||||
return x + y + 10;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这个示例程序,在转LIR之前,它的HIR是下面这样。其中有两个加法节点,操作数包括了参数(ParameterNode)和常数(ConstantNode)两种类型。最后是一个Return节点。这个例子足够简单。实际上,它简单到只是一棵树,而不是图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/b2/68dc38a051c4481f1ab73c195be574b2.jpg" alt="">
|
||||
|
||||
你可以想一下,对于这么简单的一棵树,编译器要如何生成指令呢?
|
||||
|
||||
最简单的方法,是做一个语法制导的简单翻译。我们可以深度遍历这棵树,针对不同的节点,分别使用不同的规则来生成指令。比如:
|
||||
|
||||
- 在遇到参数节点的时候,我们要搞清楚它的存放位置。因为参数要么是在寄存器中,要么是在栈中,可以直接用于各种计算。
|
||||
- 遇到常数节点的时候,我们记下这个常数,用于在下一条指令中作为立即数使用。
|
||||
- 在遇到加法节点的时候,生成一个add指令,左右两棵子树的计算结果分别是其操作数。在处理到6号节点的时候,可以不用add指令,而是生成一个lea指令,这样可以直接把结果写入rax寄存器,作为返回值。这算是一个优化,因为可以减少一次从寄存器到寄存器的拷贝工作。
|
||||
- 遇到Return节点的时候,看看其子树的计算结果是否放在rax寄存器中。如果不是,那么就要生成一个mov指令,把返回值放入rax寄存器,然后再生成一条返回指令(ret)。通常,在返回之前,编译器还要做一些栈帧的处理工作,把栈指针还原。
|
||||
|
||||
对于这个简单的例子来说,按照这个翻译规则来生成代码,是完全没有问题的。你可以看下,Graal生成LIR,然后再基于LIR生成的目标代码的示例程序,它只有三行,足够精简和优化:
|
||||
|
||||
```
|
||||
add esi,edx #将参数1加到参数0上,结果保存在esi寄存器
|
||||
lea eax,[rsi+0xa] #将rsi加10,结果放入eax寄存器
|
||||
ret #返回
|
||||
|
||||
```
|
||||
|
||||
补充:<br>
|
||||
1.我去掉了一些额外的汇编代码,比如用于跟JVM握手,让JVM有机会做垃圾收集的代码。<br>
|
||||
2. lea指令原本是用于计算地址的。上面的指令的意思是把rsi寄存器的值作为地址,然后再偏移10个字节,把新的地址放到eax寄存器。<br>
|
||||
x86计算机支持间接寻址方式:**偏移量(基址,索引值,字节数)**<br>
|
||||
其地址是:**基址 + 索引值*字节数 + 偏移量**<br>
|
||||
所以,你可以利用这个特点,计算出`a+b*c+d`的值。但c(也就是字节数)只能取1、2、4、8。就算让c取1,那也能完成`a+b+c`的计算。并且,它还可以在另一个操作数里指定把结果写到哪个寄存器,而不像add指令,只能从一个操作数加到另一个操作数上。这些优点,使得x86汇编中经常使用lea指令做加法计算。
|
||||
|
||||
Graal编译器实际上大致也是这么做的。
|
||||
|
||||
首先,它通过Schedule的步骤,把HIR的节点排序,并放入基本块。对于这个简单的程序,只有一个基本块。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/4c/592dcea33424a5547bdfafd287cd604c.jpg" alt="">
|
||||
|
||||
接着,编译器会对基本块中的节点做遍历(参考:NodeLIRBuilder.java中的[代码](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/gen/NodeLIRBuilder.java#L363))。针对每个节点转换(Lower)成LIR。
|
||||
|
||||
- 把参数节点转换成了MoveFromRegOp指令,在示例程序里,其实这是冗余的,因为可以直接把存放参数的两个寄存器,用于加法计算;
|
||||
- 把第一个加法节点转换成了CommutativeTwoOp指令;
|
||||
- 把第二个加法节点转换成了LeaOp指令,并且把常数节点变成了LeaOp指令的操作数;
|
||||
- Return节点生成了两条指令,一条是把加法运算的值放到rax寄存器,作为返回值,这条我们知道是冗余的,所以就要看看后面的优化算法如何消除这个冗余;第二条是返回指令。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7f/39/7f84ef267efbb8f3493f386fa5a83939.jpg" alt="">
|
||||
|
||||
一开始生成的LIR,使用的寄存器都是虚拟的寄存器名称,用v1、v2、v3这些来表示。等把这些虚拟的寄存器对应到具体的物理寄存器以后,就可以消除掉刚才提到的冗余代码了。
|
||||
|
||||
我们在c1visualizer中检查一下优化过程,可以发现这是在LinearScanAssignLocationsPhase做的优化。加法指令中对参数1和参数2的引用,变成了对物理寄存器的引用,从而优化掉了两条指令。lea指令中的返回值,也直接赋给了rax寄存器。这样呢,也就省掉了把计算结果mov到rax的指令。这样优化后的LIR,基本上已经等价于目标代码了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/06/963f345b185734fd59270d67cec7e806.jpg" alt="">
|
||||
|
||||
好了,通过这样一个分析,你应该理解了从HIR生成LIR的过程。但是还有个问题,这中间似乎也没有做什么指令选择呀?唯一的一处,就是把加法操作优化成了lea指令。而这个也比较简单,基于单独的Add节点就能做出这个优化选择。**那么,更复杂的模式匹配是怎么做的呢?**
|
||||
|
||||
不要着急,我们接下来就看看Graal是如何实现复杂一点的指令选择的。这一次,我们用了另一个示例程序:Foo.addMemory方法。它把一个类成员变量m和参数a相加。
|
||||
|
||||
```
|
||||
public class Foo{
|
||||
static int m = 3;
|
||||
public static int addMemory(int a){
|
||||
return m + a;
|
||||
}
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这跟add1方法有所不同,因为它要使用一个成员变量,所以一定要访问内存。而add1方法的所有操作,都是在寄存器里完成的,是“空中作业”,根本不在内存里落地。
|
||||
|
||||
我们来看一下这个示例程序对应的HIR。其中一个黄色节点“Read#Foo.m”,是读取内存的节点,也就是读取成员变量m的值。而这又需要通过AMD64Address节点来计算m的地址。由于m是个静态成员,所以它的地址要通过类的地址加上一定的偏移量来计算。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/64/1f/64d1f9933ace9171331100a9b570861f.jpg" alt="">
|
||||
|
||||
这里有一个小的知识点,我在第14讲中也提过:对内存操作的节点(如图中的ReadNode),是要加入控制流中的。因为内存里的值,会由于别的操作而改变。如果你把它变成浮动节点,就有可能破坏对内存读写的顺序,从而出现错误。
|
||||
|
||||
回到主题,我们来看看怎么为addMemory生成LIR。
|
||||
|
||||
如果还是像处理add1方法一样,那么你就会这么做:
|
||||
|
||||
- **计算m变量的地址,并放入一个寄存器;**
|
||||
- **基于这个地址,取出m的值,放入另一个寄存器;**
|
||||
- **把m的值和参数a做加法。**
|
||||
|
||||
不过这样做,至少要生成3条指令。
|
||||
|
||||
在[第8讲](https://time.geekbang.org/column/article/249261)中我曾经讲过,像AMD64这样使用复杂指令集(CICS)的架构,具有强大的地址表达能力,并且可以在做算术运算的时候,直接使用内存。所以上面的三条指令,其实能够缩减成一条指令。
|
||||
|
||||
这就需要编译器把刚才这种基于内存访问做加法的模式识别出来,以便生成优化的LIR,进而生成优化的目标代码。这也是指令选择算法要完成的任务。可是,**如何识别这种模式呢?**
|
||||
|
||||
跟踪Graal的执行,你会发现HIR在生成LIR之前,有一个对基本块中的节点做[模式匹配](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/LIRGenerationPhase.java#L72)的操作,进而又调用匹配复杂表达式([matchComplexExpressions](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/gen/NodeLIRBuilder.java#L430))。在这里,编译器会把节点跟一个个匹配规则([MatchStatement](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core/src/org/graalvm/compiler/core/match/MatchStatement.java))做匹配。**注意**,匹配的时候是逆序做的,相当于从树根开始遍历。
|
||||
|
||||
在匹配加法节点的时候,Graal匹配上了一个MatchStatement,这个规则的名字叫“addMemory”,是专门针对涉及内存操作的加法运算提供的一个匹配规则。这个MatchStatement包含了一个匹配模式(MatchPattern),该模式的要求是:
|
||||
|
||||
- 节点类型是AddNode;
|
||||
- 第一个输入(也就是子节点)是一个值节点(value);
|
||||
- 第二个输入是一个ReadNode,而且必须只有一个使用者(singleUser=true)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/3d/78c06c07d6524fd01ac08f7978da863d.jpg" alt="">
|
||||
|
||||
这个MatchStatement是在[AMD64NodeMatchRules](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeMatchRules.java#L487)中用注解生成的。利用这样的一个匹配规则,就能够匹配示例程序中的Add节点。
|
||||
|
||||
匹配上以后,Graal会把AddNode和ReadNode做上特殊标记,这样在生成LIR的时候,就会按照新的生成规则。生成的LIR如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f7/98/f77fa43149d505951ac29378f25b7998.jpg" alt="">
|
||||
|
||||
你可以发现,优化后,编译器把取参数a的指令省略掉了,直接使用了传入参数a的寄存器rsi:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/5f/f66ea988f8b69b46a466e944acb9015f.jpg" alt="">
|
||||
|
||||
最后生成的目标代码如下:
|
||||
|
||||
```
|
||||
movabs rax,0x797b00690 #把Foo类的地址放入rax寄存器
|
||||
add esi,DWORD PTR [rax+0x68] #偏移0x68后,是m的地址。做加法
|
||||
mov eax,esi #设置返回值
|
||||
ret #返回
|
||||
|
||||
```
|
||||
|
||||
到目前为止,你已经了解了Graal是如何匹配一个模式,并选择优化的指令的了。
|
||||
|
||||
你可以看看[AMD64NodeMatchRules](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeMatchRules.java#L487)类,它的里面定义了不少这种匹配规则。通过阅读和理解这些规则,你就会对为什么要做指令选择有更加具体的理解了。
|
||||
|
||||
Graal的指令选择算法算是比较简单的。在HotSpot的C2编译器中,指令选择采用的是BURS(Bottom-Up Rewrite System,自底向上的重写系统)。这个算法会更加复杂一点,消耗的时间更长,但优化效果更好一些。
|
||||
|
||||
这里我补充一个分享,我曾经请教过ARM公司的研发人员,他们目前认为Graal对针对AArch64的指令选择是比较初级的,你可以参考这个[幻灯片](https://static.linaro.org/connect/san19/presentations/san19-514.pdf)。所以,他们也正在帮助Graal做改进。
|
||||
|
||||
## 后端的其他功能
|
||||
|
||||
出于突出特色功能的目的,这一讲我着重讲了LIR的特点和指令选择算法。不过在考察编译器的后端的时候,我们通常还要注意一些其他功能,比如寄存器分配算法、指令排序,等等。我这里就把Graal在这些功能上的实现特点,给你简单地介绍一下,你如果有兴趣的话,可以根据我的提示去做深入了解:
|
||||
|
||||
- **寄存器分配**:Graal采用了线性扫描(Linear Scan)算法。这个算法的特点是速度比较快,但优化效果不如图染色算法。在HotSpot的C2中采用的是后者。
|
||||
- **指令排序**:Graal没有为了实现指令级并行而去做指令排序。这里一个主要原因,是现在的很多CPU都已经支持乱序(out-of-order)执行,再做重排序的收益不大。
|
||||
- **窥孔优化**:Graal在生成LIR的时候,会做一点窥孔优化(AMD64NodeLIRBuilder类的[peephole](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeLIRBuilder.java#L62)方法)。不过它的优化功能有限,只实现了针对除法和求余数计算的一点优化。
|
||||
- **从LIR生成目标代码**:由于LIR已经跟目标代码很接近了,所以这个翻译过程已经比较简单,没有太难的算法了,需要的只是了解和熟悉汇编代码和调用约定。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我带你对Graal的后端做了一个直观的认识,让你的后端知识有了第一个真实世界中编译器的参考系。
|
||||
|
||||
第一,把LIR从比较抽象的概念中落地。你现在可以确切地知道哪些指令是必须跟架构相关的,而哪些指令可以跟架构无关。
|
||||
|
||||
第二,把指令选择算法落地。虽然Graal的指令选择算法并不复杂,但这毕竟提供了一个可以借鉴的思路,是你认知的一个阶梯。如果你仔细阅读代码,你还可以具象地了解到,符合哪些模式的表达式,是可以从指令选择中受益的。这又是一个理论印证实践的点。
|
||||
|
||||
我把这讲的思维导图也放在了下面,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/8f/d9003105d0d392ed71a30fe5f64ed48f.jpg" alt="">
|
||||
|
||||
同时,这一讲之后,我们对Java编译器的探讨也就告一段落了。但是,我希望你对它的研究不要停止。
|
||||
|
||||
我们讨论的两个编译器(javac和Graal)中的很多知识点,你只要稍微深入挖掘一下,就可以得出不错的成果了。比如,我看到有国外的硕士学生研究了一下HotSpot,就可以发表不错的[论文](http://ssw.jku.at/Research/Papers/Schwaighofer09Master/schwaighofer09master.pdf)。如果你是在校大学生,我相信你也可以通过顺着这门课程提供的信息做一些研究,从而得到不错的成果。如果是已经工作的同学,我们可以在极客时间的社群(比如留言区和部落)里保持对Java编译技术的讨论,也一定会对于你的工作有所助益。
|
||||
|
||||
## 一课一思
|
||||
|
||||
请你阅读[AMD64NodeMatchRules](https://github.com/oracle/graal/blob/vm-20.0.1/compiler/src/org.graalvm.compiler.core.amd64/src/org/graalvm/compiler/core/amd64/AMD64NodeMatchRules.java#L487)中的匹配规则,自己设计另一个例子,能够测试出指令选择的效果。如果降低一下工作量的话,你可以把它里面的某些规则解读一下,在留言区发表你的见解。
|
||||
|
||||
好,就到这里。感谢你的阅读,欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。
|
||||
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/17 | Python编译器(一):如何用工具生成编译器?.md
Normal file
298
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/17 | Python编译器(一):如何用工具生成编译器?.md
Normal file
@@ -0,0 +1,298 @@
|
||||
<audio id="audio" title="17 | Python编译器(一):如何用工具生成编译器?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/5f/62/5fd73ba3ff552e7fb58762c4a4448262.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
最近几年,Python在中国变得越来越流行,我想可能有几个推动力:第一个是因为人工智能热的兴起,用Python可以很方便地使用流行的AI框架,比如TensorFlow;第二个重要的因素是编程教育,特别是很多面对青少年的编程课程,都是采用的Python语言。
|
||||
|
||||
不过,Python之所以变得如此受欢迎,虽然有外在的机遇,但也得益于它内在的一些优点。比如说:
|
||||
|
||||
- Python的语法比较简单,容易掌握,它强调一件事情只能用一种方法去做。对于老一代的程序员来说,Python就像久远的BASIC语言,很适合作为初学者的第一门计算机语言去学习,去打开计算机编程这个充满魅力的世界。
|
||||
- Python具备丰富的现代语言特性,实现方式又比较简洁。比如,它既支持面向对象特性,也支持函数式编程特性,等等。这对于学习编程很有好处,能够带给初学者比较准确的编程概念。
|
||||
- 我个人比较欣赏Python的一个原因,是它能够充分利用开源世界的一些二进制的库,比如说,如果你想研究计算机视觉和多媒体,可以用它调用OpenCV和FFmpeg。Python跟AI框架的整合也是同样的道理,这也是Python经常用于系统运维领域的原因,因为它很容易调用操作系统的一些库。
|
||||
- 最后,Python还有便于扩展的优势。如果你觉得Python有哪方面能力的不足,你也可以用C语言来写一些扩展。而且,你不仅仅可以扩展出几个函数,你还能扩展出新的类型,并在Python里使用这些新类型。比如,Python的数学计算库是NumPy,它的核心代码是用C语言编写的,性能很高。
|
||||
|
||||
看到这里,你自然会好奇,这么一门简洁有力的语言,是如何实现的呢?吉多·范罗苏姆(Python初始设计者)在编写Python的编译器的时候,脑子里是怎么想的呢?
|
||||
|
||||
从这一讲开始,我们就进入到Python语言的编译器内部,去看看它作为一门动态、解释执行语言的代表,是如何做词法分析、语法分析和语义分析的,又是如何解释执行的,以及它的运行时有什么设计特点,让它可以具备这些优势。你在这个过程中,也会对编译技术的应用场景了解得更加全面。这也正是我要花3讲的时间,带领你来解析Python编译器的主要原因。
|
||||
|
||||
今天这一讲,我们重点来研究Python的词法分析和语法分析功能,一起来看看它在这两个处理阶段都有什么特点。你会学到一种新的语法分析实现思路,还能够学到CST跟AST的区别。
|
||||
|
||||
好了,让我们开始吧。
|
||||
|
||||
## 编译源代码,并跟踪调试
|
||||
|
||||
首先,你可以从[python.org网站](https://www.python.org/)下载[3.8.1版本的源代码](https://www.python.org/ftp/python/3.8.1/Python-3.8.1.tgz)。解压后你可以先自己浏览一下,看看能不能找到它的词法分析器、语法分析器、符号表处理程序、解释器等功能的代码。
|
||||
|
||||
Python源代码划分了多个子目录,每个子目录的内容整理如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/28/b4dffdcfc258350fa6ce81a2dcae7128.jpg" alt="">
|
||||
|
||||
**首先,你会发现Python编译器是用C语言编写的。**这跟Java、Go的编译器不同,Java和Go语言的编译器是支持自举的编译器,也就是这两门语言的编译器是用这两门语言自身实现的。
|
||||
|
||||
实际上,用C语言实现的Python编译器叫做**CPython**,是Python的几个编译器之一。它的标准库也是由C语言和Python混合编写的。**我们课程中所讨论的就是CPython,它是Python语言的参考实现,也是macOS和Linux缺省安装的版本。**
|
||||
|
||||
不过,Python也有一个编译器是用Python本身编写的,这个编译器是PyPy。它的图标是一条咬着自己尾巴的衔尾蛇,表明这个编译器是自举的。除此之外,还有基于JVM的Jython,这个版本的优势是能够借助成熟的JVM生态,比如可以不用自己写垃圾收集器,还能够调用丰富的Java类库。如果你觉得理解C语言的代码比较困难,你也可以去看看这两个版本的实现。
|
||||
|
||||
在Python的“[开发者指南](https://devguide.python.org/)”网站上,有不少关于Python内部实现机制的技术资料。**请注意**,这里的开发者,指的是有兴趣参与Python语言开发的程序员,而不是Python语言的使用者。这就是像Python这种开源项目的优点,它欢迎热爱Python的程序员来修改和增强Python语言,甚至你还可以增加一些自己喜欢的语言特性。
|
||||
|
||||
根据开发者指南的指引,你可以编译一下Python的源代码。注意,你要用**调试模式**来编译,因为接下来我们要跟踪Python编译器的运行过程。这就要使用**调试工具GDB**。
|
||||
|
||||
GDB是GNU的调试工具,做C语言开发的人一般都会使用这个工具。它支持通过命令行调试程序,包括设置断点、单步跟踪、观察变量的值等,这跟你在IDE里调试程序的操作很相似。
|
||||
|
||||
开发者指南中有如何用调试模式编译Python,并如何跟GDB配合使用的信息。实际上,GDB现在可以用Python来编写扩展,从而给我们带来更多的便利。比如,我们在调试Python编译器的时候,遇到Python对象的指针(PyObject*),就可以用更友好的方式来显示Python对象的信息。
|
||||
|
||||
好了,接下来我们就通过跟踪Python编译器执行过程,看看它在编译过程中都涉及了哪些主要的程序模块。
|
||||
|
||||
在tokenizer.c的tok_get()函数中打一个断点,通过GDB观察Python的运行,你会发现下面的调用顺序(用bt命令打印输出后整理的结果):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0e/c4/0eedee1683034d95e4380e2b4769dac4.jpg" alt="">
|
||||
|
||||
这个过程是运行Python并执行到词法分析环节,你可以看到完整的程序执行路径:
|
||||
|
||||
1. 首先是python.c,这个文件很短,只是提供了一个main()函数。你运行python命令的时候,就会先进入这里。
|
||||
1. 接着进入Modules/main.c文件,这个文件里提供了运行环境的初始化等功能,它能执行一个python文件,也能启动REPL提供一个交互式界面。
|
||||
1. 之后是Python/pythonrun.c文件,这是Python的解释器,它调用词法分析器、语法分析器和字节码生成功能,最后解释执行。
|
||||
1. 再之后来到Parser目录的parsetok.c文件,这个文件会调度词法分析器和语法分析器,完成语法分析过程,最后生成AST。
|
||||
1. 最后是toknizer.c,它是词法分析器的具体实现。
|
||||
|
||||
拓展:REPL是Read-Evaluate-Print-Loop的缩写,也就是通过一个交互界面接受输入并回显结果。
|
||||
|
||||
通过上述的跟踪过程,我们就进入了Python的词法分析功能。下面我们就来看一下它是怎么实现的,再一次对词法分析的原理做一下印证。
|
||||
|
||||
## Python的词法分析功能
|
||||
|
||||
首先,你可以看一下tokenizer.c的tok_get()函数。你一阅读源代码,就会发现,这是我们很熟悉的一个结构,它也是通过有限自动机把字符串变成Token。
|
||||
|
||||
你还可以用另一种更直接的方法来查看Python词法分析的结果。
|
||||
|
||||
```
|
||||
./python.exe -m tokenize -e foo.py
|
||||
|
||||
```
|
||||
|
||||
补充:其中的python.exe指的是Python的可执行文件,如果是在Linux系统,可执行文件是python。
|
||||
|
||||
运行上面的命令会输出所解析出的Token:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/66/c3934c23760c13884c98d979fc250c66.jpg" alt="">
|
||||
|
||||
其中的第二列是Token的类型,第三列是Token对应的字符串。各种Token类型的定义,你可以在Grammar/Tokens文件中找到。
|
||||
|
||||
我们曾在研究[Java编译器](https://time.geekbang.org/column/article/251937)的时候,探讨过如何解决关键字和标识符的词法规则冲突的问题。**那么Python是怎么实现的呢?**
|
||||
|
||||
原来,Python在词法分析阶段根本没有区分这两者,只是都是作为“NAME”类型的Token来对待。
|
||||
|
||||
补充:Python里面有两个词法分析器,一个是用C语言实现的(tokenizer.c),一个是用Python实现的(tokenizer.py)。C语言版本的词法分析器由编译器使用,性能更高。
|
||||
|
||||
所以,Python的词法分析功能也比较常规。其实你会发现,每个编译器的词法分析功能都大同小异,你完全可以借鉴一个比较成熟的实现。Python跟Java的编译器稍微不同的一点,就是没有区分关键字和标识符。
|
||||
|
||||
接下来,我们来关注下这节课的重点内容:语法分析功能。
|
||||
|
||||
## Python的语法分析功能
|
||||
|
||||
在GDB中继续跟踪执行过程,你会在parser.c中找到语法分析的相关逻辑:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/f7/0d96372c3f18fe45cb6f0bbc12fc77f7.jpg" alt="">
|
||||
|
||||
**那么,Python的语法分析有什么特点呢?它采用的是什么算法呢?是自顶向下的算法,还是自底向上的算法?**
|
||||
|
||||
首先,我们到Grammar目录,去看一下Grammar文件。这是一个用EBNF语法编写的Python语法规则文件,下面是从中节选的几句,你看是不是很容易读懂呢?
|
||||
|
||||
```
|
||||
//声明函数
|
||||
funcdef: 'def' NAME parameters ['->' test] ':' [TYPE_COMMENT] func_body_suite
|
||||
//语句
|
||||
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
|
||||
small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
|
||||
import_stmt | global_stmt | nonlocal_stmt | assert_stmt)
|
||||
|
||||
```
|
||||
|
||||
通过阅读规则文件,你可以精确地了解Python的语法规则。
|
||||
|
||||
**这个规则文件是给谁用的呢**?实际上Python的编译器本身并不使用它,它是给一个**pgen**的工具程序([Parser/pgen](https://github.com/python/cpython/blob/3.9/Parser/pgen/pgen.py))使用的。这个程序能够基于语法规则生成**解析表**(Parse Table),供语法分析程序使用。有很多工具能帮助你生成语法解析器,包括yacc(GNU版本是bison)、ANTLR等。
|
||||
|
||||
有了pgen这个工具,你就可以通过修改规则文件来修改Python语言的语法,比如,你可以把函数声明中的关键字“def”换成“function”,这样你就可以用新的语法来声明函数。
|
||||
|
||||
pgen能给你生成新的语法解析器。parser.c的注释中讲解了它的工作原理。它是把EBNF转化成一个NFA,然后再把这个NFA转换成DFA。基于这个DFA,在读取Token的时候,编译器就知道如何做状态迁移,并生成解析树。
|
||||
|
||||
这个过程你听上去是不是有点熟悉?实际上,我们在[第2讲](https://time.geekbang.org/column/article/243685)讨论正则表达式工具的时候,就曾经把正则表达式转化成了NFA和DFA。基于这个技术,我们既可以做词法解析,也可以做语法解析。
|
||||
|
||||
实际上,Python用的是LL(1)算法。我们来回忆一下LL(1)算法的[特点](https://time.geekbang.org/column/article/244906):**针对每条语法规则,最多预读一个Token,编译器就可以知道该选择哪个产生式。**这其实就是一个DFA,从一条语法规则,根据读入的Token,迁移到下一条语法规则。
|
||||
|
||||
我们通过一个例子来看一下Python的语法分析特点,这里采用的是我们熟悉的一个语法规则:
|
||||
|
||||
```
|
||||
add: mul ('+' mul)*
|
||||
mul: pri ('*' pri)*
|
||||
pri: IntLiteral | '(' add ')'
|
||||
|
||||
```
|
||||
|
||||
我把这些语法规则对应的DFA画了出来。你会看到,它跟采用递归下降算法的思路是一样的,只不过换了种表达方式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/06/def9c3178ca00a1ebc5471b4a74acb06.jpg" alt="" title="add: mul ('+' mul)*对应的DFA">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/0d/f6b24be02983c724b945e8a1674yya0d.jpg" alt="" title="mul: pri ('*' pri)*对应的DFA">
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/1d/d0a8af5d54571f07b6df4eb965031e1d.jpg" alt="" title="pri: IntLiteral | '(' add ')'对应的DFA">
|
||||
|
||||
不过,跟手写的递归下降算法为解析每个语法规则写一个函数不同,parser.c用了一个通用的函数去解析所有的语法规则,它所依据的就是为每个规则所生成的DFA。
|
||||
|
||||
主要的实现逻辑是在parser.c的PyParser_AddToken()函数里,你可以跟踪它的实现过程。为了便于你理解,我模仿Python编译器,用上面的文法规则解析了一下“`2+3*4+5`”,并把整个解析过程画成图。
|
||||
|
||||
在解析的过程,我用了一个栈作为一个工作区,来保存当前解析过程中使用的DFA。
|
||||
|
||||
**第1步,匹配add规则。**把add对应的DFA压到栈里,此时该DFA处于状态0。这时候预读了一个Token,是字面量2。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/54/2a9318064fa07108f5484235fb824454.jpg" alt="">
|
||||
|
||||
**第2步,根据add的DFA,走mul-1这条边,去匹配mul规则。**这时把mul对应的DFA入栈。在示意图中,栈是从上往下延伸的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6d/59/6d3222404b30yy08d29943a321e6ac59.jpg" alt="">
|
||||
|
||||
**第3步,根据mul的DFA,走pri-1这条边,去匹配pri规则。**这时把pri对应的DFA入栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/77/78fca50b5a414fbf74f6229aa4c0a877.jpg" alt="">
|
||||
|
||||
**第4步,根据pri的DFA,因为预读的Token是字面量2,所以移进这个字面量,并迁移到状态3。同时,为字面量2建立解析树的节点。**这个时候,又会预读下一个Token,`'+'`号。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/ff/4a2daba678f9f8fe476e94403267d2ff.jpg" alt="">
|
||||
|
||||
**第5步,从栈里弹出pri的DFA,并建立pri节点。**因为成功匹配了一个pri,所以mul的DFA迁移到状态1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/41/5a204c08609187584d88894b5388d741.jpg" alt="">
|
||||
|
||||
**第6步,因为目前预读的Token是`'+'`号,所以mul规则匹配完毕,把它的DFA也从栈里弹出**。而add对应的DFA也迁移到了状态1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/a6/a6a49e70fa5216f0cc516981978a5fa6.jpg" alt="">
|
||||
|
||||
**第7步,移进`'+'`号,把add的DFA迁移到状态2,预读了下一个Token:字面量3**。这个Token是在mul的First集合中的,所以就走mul-2边,去匹配一个mul。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/3c/8f446e247ecab4e9224f59130de4013c.jpg" alt="">
|
||||
|
||||
按照这个思路继续做解析,直到最后,可以得到完整的解析树:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/b5/be5cd83e4c545a9d29c4f41a13fae5b5.jpg" alt="">
|
||||
|
||||
总结起来,Python编译器采用了一个通用的语法分析程序,以一个栈作为辅助的数据结构,来完成各个语法规则的解析工作。当前正在解析的语法规则对应的DFA,位于栈顶。一旦当前的语法规则匹配完毕,那语法分析程序就可以把这个DFA弹出,退回到上一级的语法规则。
|
||||
|
||||
所以说,语法解析器生成工具,会基于不同的语法规则来生成不同的DFA,但语法解析程序是不变的。这样,你随意修改语法规则,都能够成功解析。
|
||||
|
||||
上面我直观地给你解读了一下解析过程。你可以用GDB来跟踪一下PyParser_AddToken()函数,从而了解得更具体。你在这个函数里,还能够看到像下面这样的语句,这是对外输出调试信息。
|
||||
|
||||
```
|
||||
D(printf(" Push '%s'\n", d1->d_name)); //把某DFA入栈
|
||||
|
||||
```
|
||||
|
||||
你还可以用“-d”参数运行python,然后在REPL里输入程序,这样它就能打印出这些调试信息,包括什么时候把DFA入栈、什么时候出栈,等等。我截取了一部分输出信息,你可以看一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/f1/d6e523e506687846f7d13a1eaff211f1.jpg" alt="">
|
||||
|
||||
在Python的语法规则里,arith_expr指的是加减法的表达式,term指的是乘除法的表达式,atom指的是基础表达式。这套词汇也经常被用于语法规则中,你可以熟悉起来。
|
||||
|
||||
好了,现在你已经知道了语法解析的过程。不过你可能注意到了,上面的语法解析过程形成的结果,我没有叫做是AST,而是叫做**解析树**(Parse Tree)。看到这里,你可能会产生疑问:**解析源代码不就会产生AST吗?怎么这里是生成一个叫做解析树的东西?什么是解析树,它跟AST有啥区别?**别着急,下面我就来为你揭晓答案。
|
||||
|
||||
## 解析树和AST的区别
|
||||
|
||||
解析树又可以叫做**CST**(Concrete Syntax Tree,具体语法树),与AST(抽象语法树)是相对的:一个具体,一个抽象。
|
||||
|
||||
它俩的区别在于:**CST精确地反映了语法规则的推导过程,而AST则更准确地表达了程序的结构。如果说CST是“形似”,那么AST就是“神似”。**
|
||||
|
||||
你可以看看在前面的这个例子中,所形成的CST的特点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/be/b5/be5cd83e4c545a9d29c4f41a13fae5b5.jpg" alt="">
|
||||
|
||||
首先,加法是个二元运算符,但在这里add节点下面对应了两个加法运算符,跟原来加法的语义不符。第二,很多节点都只有一个父节点,这个其实可以省略,让树结构更简洁。
|
||||
|
||||
所以,我们期待的AST其实是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/ce/7aa1ea17abafdba3f0cd68f6d14b6ace.jpg" alt="">
|
||||
|
||||
这就是CST和AST的区别。
|
||||
|
||||
理解了这个知识点以后,我们拿Python实际的CST和AST来做一下对比。在Python的命令行中,输入下面的命令:
|
||||
|
||||
```
|
||||
>>> from pprint import pprint
|
||||
>>> import parser
|
||||
>>> cst = parser.expr('2+3+4') //对加法表达式做解析
|
||||
>>> pprint(parser.st2list(cst)) //以美观的方式打印输出CST
|
||||
|
||||
```
|
||||
|
||||
你会得到这样的输出结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/6f/508d14e74211a1a0bbf6e8c0b282b76f.jpg" alt="">
|
||||
|
||||
这是用缩进的方式显示了CST的树状结构,其中的数字是符号和Token的编号。你可以从Token的字典(dict)里把它查出来,从而以更加直观的方式显示CST。
|
||||
|
||||
我们借助一个lex函数来做美化的工作。现在再显示一下CST,就更直观了:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/fe/104aa190ab9a4c118d1fb5a73187fafe.jpg" alt="">
|
||||
|
||||
**那么,Python把CST转换成AST,会是什么样子呢?**
|
||||
|
||||
你可以在命令行敲入下面的代码,来显示AST。它虽然是以文本格式显示的,但你能发现它是一个树状结构。这个树状结构就很简洁:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/22/bd/2232eb547e255f88e5d867ec147867bd.jpg" alt="">
|
||||
|
||||
如果你嫌这样不够直观,还可以用另一个工具“instaviz”,在命令行窗口用pip命令安装instaviz模块,以图形化的方式更直观地来显示AST。instaviz是“Instant Visualization”(立即可视化)的意思,它能够图形化显示AST。
|
||||
|
||||
```
|
||||
$ pip install instaviz
|
||||
|
||||
```
|
||||
|
||||
然后启动Python,并敲入下面的代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/fb/41808237c525885d28534fc9514329fb.jpg" alt="">
|
||||
|
||||
instaviz会启动一个Web服务器,你可以在浏览器里通过http://localhost:8080来访问它,里面有图形化的AST。你可以看到,这个AST比起CST来,确实简洁太多了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/bf/e1700c27cec63f492f5cdb68809d42bf.jpg" alt="">
|
||||
|
||||
点击代表“`2+3*4+5`”表达式的节点,你可以看到这棵子树的各个节点的属性信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/c5/28ab9da7c9c4cd005d13fca4d44e69c5.jpg" alt="">
|
||||
|
||||
总结起来,在编译器里,我们经常需要把源代码转变成CST,然后再转换成AST。生成CST是为了方便编译器的解析过程。而转换成AST后,会让树结构更加精简,并且在语义上更符合语言原本的定义。
|
||||
|
||||
**那么,Python是如何把CST转换成AST的呢?**这个过程分为两步。
|
||||
|
||||
**首先,Python采用了一种叫做ASDL的语言,来定义了AST的结构。**[ASDL](https://www.cs.princeton.edu/research/techreps/TR-554-97)是“抽象语法定义语言(Abstract Syntax Definition Language)”的缩写,它可以用于描述编译器中的IR以及其他树状的数据结构。你可能不熟悉ASDL,但可能了解XML和JSON的Schema,你可以通过Schema来定义XML和JSON的合法的结构。另外还有DTD、EBNF等,它们的作用都是差不多的。
|
||||
|
||||
这个定义文件是Parser/Python.asdl。CPython编译器中包含了两个程序(Parser/asdl.py和Parser/asdl_c.py)来解析ASDL文件,并生成AST的数据结构。最后的结果在Include/Python-ast.h文件中。
|
||||
|
||||
到这里,你可能会有疑问:**这个ASDL文件及解析程序不就是生成了AST的数据结构吗?为什么不手工设计这些数据结构呢?有必要采用一种专门的DSL来做这件事情吗?**
|
||||
|
||||
确实如此。Java语言的AST,只是采用了手工设计的数据结构,也没有专门用一个DSL来生成。
|
||||
|
||||
但Python这样做确实有它的好处。上一讲我们说过,Python的编译器有多种语言的实现,因此基于统一的ASDL文件,我们就可以精准地生成不同语言下的AST的数据结构。
|
||||
|
||||
在有了AST的数据结构以后,**第二步,是把CST转换成AST,这个工作是在Python/ast.c中实现的,入口函数是PyAST_FromNode()。**这个算法是手写的,并没有办法自动生成。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们开启了对Python编译器的探究。我想给你强调下面几个关键要点:
|
||||
|
||||
- **非自举**。CPython的编译器是用C语言编写的,而不是用Python语言本身。编译器和核心库采用C语言会让它性能更高,并且更容易与各种二进制工具集成。
|
||||
- **善用GDB**。使用GDB可以跟踪CPython编译器的执行过程,加深对它的内部机制的理解,加快研究的速度。
|
||||
- **编译器生成工具pgen**。pgen能够根据语法规则生成解析表,让修改语法的过程变得更加容易。
|
||||
- **基于DFA的语法解析过程**。基于pgen生成的解析表,通过DFA驱动完成语法解析过程,整个执行过程跟递归下降算法的原理相同,但只需要一个通用的解析程序即可。
|
||||
- **从CST到AST**。语法分析首先生成CST,接着生成AST。CST准确反映了语法推导的过程,但会比较啰嗦,并且可能不符合语义。AST同样反映了程序的结构,但更简洁,并且支持准确的语义。
|
||||
|
||||
本讲的思维导图我也放在这里了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/34/1e11c1bb92669152c725a35c919b4534.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
这一讲我们提到,Python的词法分析器没有区分标识符和关键字,但这样为什么没有影响到Python的语法分析的功能呢?你可以结合语法规则文件和对语法解析过程的理解,谈谈你的看法。如果你能在源代码里找到确定的答案,那就更好了!
|
||||
|
||||
欢迎你在留言区中分享你的见解,也欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。
|
||||
|
||||
## 参考资料
|
||||
|
||||
GDB的安装和配置:参考[这篇文章](https://github.com/RichardGong/CompilersInPractice/edit/master/python/GDB.md)。
|
||||
296
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/18 | Python编译器(二):从AST到字节码.md
Normal file
296
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/18 | Python编译器(二):从AST到字节码.md
Normal file
@@ -0,0 +1,296 @@
|
||||
<audio id="audio" title="18 | Python编译器(二):从AST到字节码" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/61/75/61281ce750eebc407f758251a7750875.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
今天这一讲,我们继续来研究Python的编译器,一起来看看它是如何做语义分析的,以及是如何生成字节码的。学完这一讲以后,你就能回答出下面几个问题了:
|
||||
|
||||
- **像Python这样的动态语言,在语义分析阶段都要做什么事情呢,跟Java这样的静态类型语言有什么不同?**
|
||||
- **Python的字节码有什么特点?生成字节码的过程跟Java有什么不同?**
|
||||
|
||||
好了,让我们开始吧。首先,我们来了解一下从AST到生成字节码的整个过程。
|
||||
|
||||
## 编译过程
|
||||
|
||||
Python编译器把词法分析和语法分析叫做“**解析(Parse)**”,并且放在Parser目录下。而从AST到生成字节码的过程,才叫做“**编译(Compile)**”。当然,这里编译的含义是比较狭义的。你要注意,不仅是Python编译器,其他编译器也是这样来使用这两个词汇,包括我们已经研究过的Java编译器,你要熟悉这两个词汇的用法,以便阅读英文文献。
|
||||
|
||||
Python的编译工作的主干代码是在Python/compile.c中,它主要完成5项工作。
|
||||
|
||||
**第一步,检查**[**future语句**](https://docs.python.org/3/reference/simple_stmts.html#future-statements)。future语句是Python的一个特性,让你可以提前使用未来版本的特性,提前适应语法和语义上的改变。这显然会影响到编译器如何工作。比如,对于“8/7”,用不同版本的语义去处理,得到的结果是不一样的。有的会得到整数“1”,有的会得到浮点数“1.14285…”,编译器内部实际上是调用了不同的除法函数。
|
||||
|
||||
**第二步,建立符号表。**
|
||||
|
||||
**第三步,为基本块产生指令。**
|
||||
|
||||
**第四步,汇编过程:把所有基本块的代码组装在一起。**
|
||||
|
||||
**第五步,对字节码做窥孔优化。**
|
||||
|
||||
其中的第一步,它是Python语言的一个特性,但不是我们编译技术关注的重点,所以这里略过。我们从建立符号表开始。
|
||||
|
||||
## 语义分析:建立符号表和引用消解
|
||||
|
||||
通常来说,在语义分析阶段首先是建立符号表,然后在此基础上做引用消解和类型检查。
|
||||
|
||||
而Python是动态类型的语言,类型检查应该是不需要了,但引用消解还是要做的。并且你会发现,Python的引用消解有其独特之处。
|
||||
|
||||
首先,我们来看看Python的符号表是一个什么样的数据结构。在**Include/symtable.h**中定义了两个结构,分别是符号表和符号表的条目:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/b9/f2d30eaa6yy3fd1e5dc5589f2ff197b9.jpg" alt="">
|
||||
|
||||
在编译的过程中,针对每个模块(也就是一个Python文件)会生成一个**符号表**(symtable)。
|
||||
|
||||
Python程序被划分成“块(block)”,块分为三种:模块、类和函数。每种块其实就是一个作用域,而在Python里面还叫做命名空间。**每个块对应一个符号表条目(PySTEntryObject),每个符号表条目里存有该块里的所有符号(ste_symbols)。**每个块还可以有多个子块(ste_children),构成树状结构。
|
||||
|
||||
在符号表里,有一个st_blocks字段,这是个字典,它能通过模块、类和函数的AST节点,查找到Python程序的符号表条目,通过这种方式,就把AST和符号表关联在了一起。
|
||||
|
||||
我们来看看,对于下面的示例程序,它对应的符号表是什么样子的。
|
||||
|
||||
```
|
||||
a = 2 #模块级变量
|
||||
class myclass:
|
||||
def __init__(self, x):
|
||||
self.x = x
|
||||
def foo(self, b):
|
||||
c = a + self.x + b #引用了外部变量a
|
||||
return c
|
||||
|
||||
```
|
||||
|
||||
这个示例程序有模块、类和函数三个级别的块。它们分别对应一条符号表条目。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/6a/4e5475a404a12f0b15yy096deae46d6a.jpg" alt="">
|
||||
|
||||
你可以看到,每个块里都有ste_symbols字段,它是一个字典,里面保存了本命名空间涉及的符号,以及每个符号的各种标志位(flags)。关于标志位,我下面会给你解释。
|
||||
|
||||
然后,我们再看看针对这个示例程序,符号表里的主要字段的取值:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/a5/53dbb4c2c6620yy777c4b7dd7bb4a1a5.jpg" alt="">
|
||||
|
||||
好了,通过这样一个例子,你大概就知道了Python的符号表是怎样设计的了。下面我们来看看符号表的建立过程。
|
||||
|
||||
建立符号表的主程序是Python/symtable.c中的**PySymtable_BuildObject()函数**。
|
||||
|
||||
Python建立符号表的过程,需要做两遍处理,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/8a/e1f46cf122deb05ec1091d3e7619de8a.jpg" alt="">
|
||||
|
||||
**第一遍**,主要做了两件事情。第一件事情是建立一个个的块(也就是符号表条目),并形成树状结构,就像示例程序那样;第二件事情,就是给块中的符号打上一定的标记(flag)。
|
||||
|
||||
我们用GDB跟踪一下第一遍处理后生成的结果。你可以参考下图,看一下我在Python的REPL中的输入信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/51/f6/51531fb61d5d69b65e6e626dbc2d87f6.jpg" alt="">
|
||||
|
||||
我在symtable_add_def_helper()函数中设置了断点,便于调试。当编译器处理到foo函数的时候,我在GDB中打印输出了一些信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/4c/951750f5e311fa1714ac8b815df7e64c.jpg" alt="">
|
||||
|
||||
在这些输出信息中,你能看到前面我给你整理的表格中的信息,比如,符号表中各个字段的取值。
|
||||
|
||||
我重点想让你看的,是foo块中各个符号的标志信息:self和b是20,c是2,a是16。这是什么意思呢?
|
||||
|
||||
```
|
||||
ste_symbols = {'self': 20, 'b': 20, 'c': 2, 'a': 16}
|
||||
|
||||
```
|
||||
|
||||
这就需要看一下symtable.h中,对这些标志位的定义:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/44/81250354f166061cae4dd0967398ac44.jpg" alt="">
|
||||
|
||||
我给你整理成了一张更容易理解的图,你参考一下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/96/f2/968aa4e62c158d2d0cf3592a77bda3f2.jpg" alt="">
|
||||
|
||||
根据上述信息,你会发现self和b,其实是被标记了3号位和5号位,意思是这两个变量是函数参数,并且在foo中被使用。而a只标记了5号位,意思是a这个变量在foo中被使用,但这个变量又不是参数,所以肯定是来自外部作用域的。我们再看看c,c只在2号位被标记,表示这个变量在foo里被赋值了。
|
||||
|
||||
到目前为止,第一遍处理就完成了。通过第一遍处理,我们会知道哪些变量是本地声明的变量、哪些变量在本块中被使用、哪几个变量是函数参数等几方面的信息。
|
||||
|
||||
但是,现在还有一些信息是不清楚的。比如,在foo中使用了a,那么外部作用域中是否有这个变量呢?这需要结合上下文做一下分析。
|
||||
|
||||
还有,变量c是在foo中赋值的。那它是本地变量,还是外部变量呢?
|
||||
|
||||
在这里,你能体会出**Python语言使用变量的特点:由于变量在赋值前,可以不用显式声明。**所以你不知道这是新声明了一个变量,还是引用了外部变量。
|
||||
|
||||
正由于Python的这个特点,所以它在变量引用上有一些特殊的规定。
|
||||
|
||||
比如,想要在函数中给全局变量赋值,就必须加**global关键字**,否则编译器就会认为这个变量只是一个本地变量。编译器会给这个符号的1号位做标记。
|
||||
|
||||
而如果给其他外部作用域中的变量赋值,那就必须加**nonlocal关键字**,并在4号位上做标记。这时候,该变量就是一个自由变量。在闭包功能中,编译器还要对自由变量的存储做特殊的管理。
|
||||
|
||||
接下来,编译器会**做第二遍的分析**(见symtable_analyze()函数)。在这遍分析里,编译器会根据我们刚才说的Python关于变量引用的语义规则,分析出哪些是全局变量、哪些是自由变量,等等。这些信息也会被放到符号的标志位的第12~15位。
|
||||
|
||||
```
|
||||
ste_symbols = {'self': 2068, 'b': 2068, 'c': 2050, 'a': 6160}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/7b/7a7a05f76f4d72e20d075eb5d732267b.jpg" alt="">
|
||||
|
||||
以变量a为例,它的标志值是6160,也就是二进制的1100000010000。其标记位设置如下,其作用域的标志位是3,也就是说,a是个隐式的全局变量。而self、b和c的作用域标志位都是1,它们的意思是本地变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/2f/d52bd8bfe3412da62b289fbdeab7942f.jpg" alt="">
|
||||
|
||||
在第二遍的分析过程中,Python也做了一些语义检查。你可以搜索一下Python/symtable.c的代码,里面有很多地方会产生错误信息,比如“nonlocal declaration not allowed at module level(在模块级不允许非本地声明)”。
|
||||
|
||||
另外,Python语言提供了访问符号表的API,方便你直接在REPL中,来查看编译过程中生成的符号表。你可以参考我的屏幕截图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/46/20364439b1a4e664b5c868032767a546.jpg" alt="">
|
||||
|
||||
好了,现在符号表已经生成了。基于AST和符号表,Python编译器就可以生成字节码。
|
||||
|
||||
## 生成CFG和指令
|
||||
|
||||
我们可以用Python调用编译器的API,来观察字节码生成的情况:
|
||||
|
||||
```
|
||||
>>> co = compile("a+2", "test.py", "eval") //编译表达式"a+2"
|
||||
>>> dis.dis(co.co_code) //反编译字节码
|
||||
0 LOAD_NAME 0 (0) //装载变量a
|
||||
2 LOAD_CONST 0 (0) //装载常数2
|
||||
4 BINARY_ADD //执行加法
|
||||
6 RETURN_VALUE //返回值
|
||||
|
||||
```
|
||||
|
||||
其中的LOAD_NAME、LOAD_CONST、BINARY_ADD和RETURN_VALUE都是字节码的指令。
|
||||
|
||||
对比一下,Java的字节码的每个指令只有一个字节长,这意味着指令的数量不会超过2的8次方(256)个。
|
||||
|
||||
Python的指令一开始也是一个字节长的,后来变成了一个字(word)的长度,但我们仍然习惯上称为字节码。Python的在线文档里有对所有[字节码的说明](https://docs.python.org/zh-cn/3/library/dis.html#python-bytecode-instructions),这里我就不展开了,感兴趣的话你可以自己去看看。
|
||||
|
||||
并且,Python和Java的虚拟机一样,都是基于栈的虚拟机。所以,它们的指令也很相似。比如,加法操作的指令是不需要带操作数的,因为只需要取出栈顶的两个元素相加,把结果再放回栈顶就行了。
|
||||
|
||||
进一步,你可以对比一下这两种语言的字节码,来看看它们的异同点,并且试着分析一下原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/98/9bc680cf4da741d31eea1df105356498.jpg" alt="">
|
||||
|
||||
这样对比起来,你可以发现,它们主要的区别就在于,**Java的字节码对不同的数据类型会提供不同的指令,而Python则不加区分。**因为Python对所有的数值,都会提供统一的计算方式。
|
||||
|
||||
所以你可以看出,一门语言的IR,是跟这门语言的设计密切相关的。
|
||||
|
||||
生成CFG和字节码的代码在**Python/compile.c**中。调用顺序如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/e9/d367326a1117ecef5d2c6ce71b6736e9.jpg" alt="">
|
||||
|
||||
**总的逻辑是:以visit模式遍历整个AST,并建立基本块和指令。**对于每种AST节点,都由相应的函数来处理。
|
||||
|
||||
以compiler_visit_expr1()为例,对于二元操作,编译器首先会递归地遍历左侧子树和右侧子树,然后根据结果添加字节码的指令。
|
||||
|
||||
```
|
||||
compiler_visit_expr1(struct compiler *c, expr_ty e)
|
||||
{
|
||||
switch (e->kind) {
|
||||
...
|
||||
.
|
||||
case BinOp_kind:
|
||||
VISIT(c, expr, e->v.BinOp.left); //遍历左侧子树
|
||||
VISIT(c, expr, e->v.BinOp.right); //遍历右侧子树
|
||||
ADDOP(c, binop(c, e->v.BinOp.op)); //添加二元操作的指令
|
||||
break;
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**那么基本块是如何生成的呢?**
|
||||
|
||||
编译器在进入一个作用域的时候(比如函数),至少要生成一个基本块。而像循环语句、if语句,还会产生额外的基本块。
|
||||
|
||||
所以,编译的结果,会在compiler结构中保存一系列的基本块,这些基本块相互连接,构成CFG;基本块中又包含一个指令数组,每个指令又包含操作码、参数等信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fc/a3/fc9d9bf9526f14b217f2912c35658ea3.jpg" alt="">
|
||||
|
||||
为了直观理解,我设计了一个简单的示例程序。foo函数里面有一个if语句,这样会产生多个基本块。
|
||||
|
||||
```
|
||||
def foo(a):
|
||||
if a > 10 :
|
||||
b = a
|
||||
else:
|
||||
b = 10
|
||||
return b
|
||||
|
||||
```
|
||||
|
||||
通过GDB跟踪编译过程,我们发现,它生成的CFG如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/62/7159fe5e9baa1c1d6401644e21a67f62.jpg" alt="">
|
||||
|
||||
在CFG里,你要注意两组箭头。
|
||||
|
||||
**实线箭头**是基本块之间的跳转关系,用b_next字段来标记。**虚线箭头**能够基于b_list字段把所有的基本块串起来,形成一个链表,每一个新生成的基本块指向前一个基本块。只要有一个指针指向最后一个基本块,就能访问所有的基本块。
|
||||
|
||||
你还可以通过GDB来查看每个基本块中的指令分别是什么,这样你就会理解每个基本块到底干了啥。不过我这里先给你留个小伏笔,在下一个小节讲汇编的时候一起给你介绍。
|
||||
|
||||
到目前为止,我们已经生成了CFG和针对每个基本块的指令数组。但我们还没有生成最后的字节码。这个任务,是由汇编(Assembly)阶段负责的。
|
||||
|
||||
## 汇编(Assembly)
|
||||
|
||||
汇编过程是在Python/compiler.c中的assemble()函数中完成的。听名字,你会感觉这个阶段做的事情似乎应该比较像汇编语言的汇编器的功能。也确实如此。汇编语言的汇编器,能够生成机器码;而Python的汇编阶段,是生成字节码,它们都是生成目标代码。
|
||||
|
||||
具体来说,汇编阶段主要会完成以下任务:
|
||||
|
||||
- 把每个基本块的指令对象转化成字节码。
|
||||
- 把所有基本块的字节码拼成一个整体。
|
||||
- 对于从一个基本块跳转到另一个基本块的jump指令,它们有些采用的是相对定位方式,比如往前跳几个字的距离。这个时候,编译器要计算出正确的偏移值。
|
||||
- 生成PyCodeObject对象,这个对象里保存着最后生成的字节码和其他辅助信息,用于Python的解释器执行。
|
||||
|
||||
我们还是通过示例程序,来直观地看一下汇编阶段的工作成果。你可以参照下图,使用instaviz工具看一下foo函数的编译结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3b/a8/3bc3086cf0acc70c854ea8bcf732c3a8.jpg" alt="">
|
||||
|
||||
在PyCodeObject对象中,co_code字段是生成的字节码(用16进制显示)。你还能看到常量表和变量表,这些都是在解释器中运行所需要的信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a5/0a/a58759f12bd9fa20514e53c58bd6ee0a.jpg" alt="">
|
||||
|
||||
如果把co_code字段的那一串字节码反编译一下,你会得到下面的信息:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/77/279aeb6e24fa2351d46f3774efa1cc77.jpg" alt="">
|
||||
|
||||
你会看到,一共11条指令,其中BB1是7条,BB2和BB3各2条。BB1里面是If条件和if块中的内容,BB2对应的是else块的内容,BB3则对应return语句。
|
||||
|
||||
不过,如果你对照基本块的定义,你其实会发现,BB1不是一个标准的基本块。因为一般来说,标准的基本块只允许在最后一个语句做跳转,其他语句都是顺序执行的。而我们看到第4个指令“POP_JUMP_IF_FALSE 14”其实是一个条件跳转指令。
|
||||
|
||||
尽管如此,因为Python把CFG只是作为生成字节码的一个中间结构,并没有基于CFG做数据流分析和优化,所以虽然基本块不标准,但是对Python的编译过程并无影响。
|
||||
|
||||
你还会注意到第7行指令“JUMP_FORWARD”,这个指令是一个基于相对位置的跳转指令,它往前跳4个字,就会跳到BB3。这个跳转距离就是在assemble阶段去计算的,这个距离取决于你如何在代码里排列各个基本块。
|
||||
|
||||
好了,到目前为止,字节码已经生成了。不过,在最后放到解释器里执行之前,编译器还会再做一步窥孔优化工作。
|
||||
|
||||
## 窥孔优化
|
||||
|
||||
说到优化,总体来说,在编译的过程中,Python编译器的优化功能是很有限的。在compiler.c的代码中,你会看到一点优化逻辑。比如,在为if语句生成指令的时候,编译器就会看看if条件是否是个常数,从而不必生成if块或者else块的代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f9/44/f93947d0ed0a74d08f78eeacabe1c744.jpg" alt="">
|
||||
|
||||
另一个优化机会,就是在字节码的基础上优化,这就是窥孔优化,其实现是在**Python/peephole.c**中。它能完成的优化包括:
|
||||
|
||||
- 把多个LOAD_CONST指令替换为一条加载常数元组的指令。
|
||||
- 如果一个跳转指令,跳到return指令,那么可以把跳转指令直接替换成return指令。
|
||||
- 如果一个条件跳转指令,跳到另一个条件跳转指令,则可以基于逻辑运算的规则做优化。比如,“x:JUMP_IF_FALSE_OR_POP y”和“y:JUMP_IF_FALSE_OR_POP z”可以直接简化为“x:JUMP_IF_FALSE_OR_POP z”。这是什么意思呢?第一句是依据栈顶的值做判断,如果为false就跳转到y。而第二句,继续依据栈顶的值做判断,如果为false就跳转到z。那么,简化后,可以直接从第一句跳转到z。
|
||||
- 去掉return指令后面的代码。
|
||||
- ……
|
||||
|
||||
在做优化的时候,窥孔优化器会去掉原来的指令,替换成新的指令。如果有多余出来的位置,则会先填充NOP指令,也就是不做任何操作。最后,才扫描一遍整个字节码,把NOP指令去掉,并且调整受影响的jump指令的参数。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们继续深入探索Python的编译之旅。你需要记住以下几点:
|
||||
|
||||
- Python通过一个**建立符号表**的过程来做相关的语义分析,包括做引用消解和其他语义检查。由于Python可以不声明变量就直接使用,所以编译器要能识别出正确的“定义-使用”关系。
|
||||
- **生成字节码**的工作实际上包含了生成CFG、为每个基本块生成指令,以及把指令汇编成字节码,并生成PyCodeObject对象的过程。
|
||||
- **窥孔优化器**在字节码的基础上做了一些优化,研究这个程序,会让你对窥孔优化的认识变得具象起来。
|
||||
|
||||
按照惯例,我把这一讲的思维导图也整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/4e/0966a177eeb50a2e0d7bea71e1e1914e.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在语义分析过程中,针对函数中的本地变量,Python编译器没有像Java编译器那样,一边添加符号,一边做引用消解。这是为什么?请在留言区分享你的观点。
|
||||
|
||||
如果你觉得有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
Python[字节码的说明](https://docs.python.org/zh-cn/3/library/dis.html#python-bytecode-instructions)。
|
||||
399
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/19 | Python编译器(三):运行时机制.md
Normal file
399
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/19 | Python编译器(三):运行时机制.md
Normal file
@@ -0,0 +1,399 @@
|
||||
<audio id="audio" title="19 | Python编译器(三):运行时机制" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3c/0e/3cb15a945720d148294407d9f0c15a0e.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
在前面两讲中,我们已经分析了Python从开始编译到生成字节码的机制。但是,我们对Python只是了解了一半,还有很多问题需要解答。比如:**Python字节码是如何运行的呢?它是如何管理程序所用到的数据的?它的类型体系是如何设计的,有什么特点?**等等。
|
||||
|
||||
所以今天这一讲,我们就来讨论一下Python的运行时机制。其中的**核心,是Python对象机制的设计**。
|
||||
|
||||
我们先来研究一下字节码的运行机制。你会发现,它跟Python的对象机制密切相关。
|
||||
|
||||
## 理解字节码的执行过程
|
||||
|
||||
我们用GDB跟踪执行一个简单的示例程序,它只有一行:“`a=1`”。
|
||||
|
||||
这行代码对应的字节码如下。其中,前两行指令实现了“`a=1`”的功能(后两行是根据Python的规定,在执行完一个模块之后,缺省返回一个None值)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8a/50/8ae69517e1bfec78fddea57e4da89e50.jpg" alt="">
|
||||
|
||||
你需要在**_PyEval_EvalFrameDefault()函数**这里设置一个断点,在这里实际解释指令并执行。
|
||||
|
||||
**首先是执行第一行指令,LOAD_CONST。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/de/48/def47ca68f979af8cf684e47c0889848.jpg" alt="">
|
||||
|
||||
你会看到,解释器做了三件事情:
|
||||
|
||||
1. 从常数表里取出0号常数。你知道,编译完毕以后会形成PyCodeObject,而在这个对象里会记录所有的常量、符号名称、本地变量等信息。常量1就是从它的常量表中取出来的。
|
||||
1. 把对象引用值加1。对象引用跟垃圾收集机制相关。
|
||||
1. 把这个常数对象入栈。
|
||||
|
||||
从这第一行指令的执行过程,你能得到什么信息呢?
|
||||
|
||||
第一个信息,常量1在Python内部,它是一个对象。你可以在GDB里显示这个对象的信息:该对象的类型是PyLong_Type型,这是Python的整型在内部的实现。
|
||||
|
||||
另外,该对象的引用数是126个,说明这个常量对象其实是被共享的,LOAD_CONST指令会让它的引用数加1。我们用的常数是1,这个值在Python内部也是会经常被用到,所以引用数会这么高。你可以试着选个不那么常见的常数,看看它的引用数是多少,都是在哪里被引用的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/e1/028e92c61293d4fd2eb7c6acc00b5ce1.jpg" alt="">
|
||||
|
||||
进一步,我们会发现,往栈里放的数据,其实是个对象指针,而不像Java的栈机那样,是放了个整数。
|
||||
|
||||
总结上述信息,我其实可以告诉你一个结论:**在Python里,程序中的任何符号都是对象,包括整数、浮点数这些基础数据,或者是自定义的类,或者是函数,它们都是对象。**在栈机里处理的,是这些对象的引用。
|
||||
|
||||
**我们再继续往下分析一条指令,也就是STORE_NAME指令**,来加深一下对Python运行机制的理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/36/c89af4691c41517545acc20486648c36.jpg" alt="">
|
||||
|
||||
执行STORE_NAME指令时,解释器做了5件事情:
|
||||
|
||||
1. 根据指令的参数,从名称表里取出变量名称。这个名称表也是来自于PyCodeObject。前面我刚说过了,Python程序中的一切都是对象,那么name也是对象。你可以查看它的类型,是PyUnicode_Type,也就是Unicode的字符串。
|
||||
1. 从栈顶弹出上一步存进去的常量对象。
|
||||
1. 获取保存了所有本地变量的字典,这也是来自PyCodeObject。
|
||||
1. 在字典里,设置a的值为该常量。如果你深入跟踪其执行过程,你会发现在存入字典的时候,name对象和v对象的引用都会加1。这也是可以理解的,因为它们一个作为key,一个作为value,都要被字典所引用。
|
||||
1. 减少常量对象的引用计数。意思是栈机本身不再引用该常量。
|
||||
|
||||
好了,通过详细解读这两条指令的执行过程,我相信你对Python的运行机制摸到一点头绪了,但可能还是会提出很多问题来,比如说:
|
||||
|
||||
- **既然栈里的操作数都是对象指针,那么如何做加减乘除等算术运算?**
|
||||
- **如果函数也是对象,那么执行函数的过程又是怎样的?**
|
||||
- ……
|
||||
|
||||
别着急,我在后面会带你探究清楚这些问题。不过在此之前,我们有必要先加深一下对Python对象的了解。
|
||||
|
||||
## Python对象的设计
|
||||
|
||||
Python的对象定义在object.h中。阅读文件头部的注释和对各类数据结构的定义,你就可以理解Python对象的设计思路。
|
||||
|
||||
首先是PyObject和PyVarObject两个基础的数据结构,它们分别表示定长的数据和变长的数据。
|
||||
|
||||
```
|
||||
typedef struct _object { //定长对象
|
||||
Py_ssize_t ob_refcnt; //对象引用计数
|
||||
struct _typeobject *ob_type; //对象类型
|
||||
} PyObject;
|
||||
|
||||
typedef struct { //变长对象
|
||||
PyObject ob_base;
|
||||
Py_ssize_t ob_size; //变长部分的项目数量,在申请内存时有确定的值,不再变
|
||||
} PyVarObject;
|
||||
|
||||
```
|
||||
|
||||
PyObject是最基础的结构,所有的对象在Python内部都表示为一个PyObject指针。它里面只包含两个成员:对象引用计数(ob_refcnt)和对象类型(ob_type),你在用GDB跟踪执行时也见过它们。可能你会问,**为什么只有这两个成员呢?对象的数据(比如一个整数)保存在哪里?**
|
||||
|
||||
实际上,任何对象都会在一开头包含PyObject,其他数据都跟在PyObject的后面。比如说,Python3的整数的设计是一个变长对象,会用一到多个32位的段,来表示任意位数的整数:
|
||||
|
||||
```
|
||||
#define PyObject_VAR_HEAD PyVarObject ob_base;
|
||||
struct _longobject {
|
||||
PyObject_VAR_HEAD //PyVarObject
|
||||
digit ob_digit[1]; //数字段的第一个元素
|
||||
};
|
||||
typedef struct _longobject PyLongObject; //整型
|
||||
|
||||
```
|
||||
|
||||
它在内存中的布局是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/24/3d80b4e2750cdf5701yy5fa1d68fde24.jpg" alt="">
|
||||
|
||||
所以你会看出,**`PyObject*`、`PyVarObject*`和`PyLongObject*`指向的内存地址是相同的**。你可以根据ob_type的类型,把`PyObject*`强制转换成`PyLongObject*`。
|
||||
|
||||
实际上,像C++这样的面向对象语言的内存布局也是如此,父类的成员变量在最前面,子类的成员变量在后面,父类和子类的指针之间可以强制转换。懂得了这个原理,我们用C语言照样可以模拟出面向对象的继承结构出来。
|
||||
|
||||
你可以注意到,我在图1中标出了每个字段所占内存的大小,总共是28个字节(这是64位macOS下的数值,如果是另外的环境,比如32位环境,数值会有所不同)。
|
||||
|
||||
你可以用sys.getsizeof()函数,来测量对象占据的内存空间。
|
||||
|
||||
```
|
||||
>>> a = 10
|
||||
>>> import sys
|
||||
>>> sys.getsizeof(a)
|
||||
28 #ob_size = 1
|
||||
>>> a = 1234567890
|
||||
>>> sys.getsizeof(a)
|
||||
32 #ob_size = 2,支持更大的整数
|
||||
|
||||
```
|
||||
|
||||
到这里,我们总结一下Python对象设计的三个特点:
|
||||
|
||||
**1.基于堆**
|
||||
|
||||
Python对象全部都是在堆里申请的,没有静态申请和在栈里申请的。这跟C、C++和Java这样的静态类型的语言很不一样。
|
||||
|
||||
C的结构体和C++的对象都既可以在栈里,也可以在堆里;Java也是一样,除了原生数据类型可以在栈里申请,未逃逸的Java对象的内存也可以在栈里管理,我们在讲[Java的JIT编译器](https://time.geekbang.org/column/article/257504)的时候已经讲过了。
|
||||
|
||||
**2.基于引用计数的垃圾收集机制**
|
||||
|
||||
每个Python对象会保存一个引用计数。也就是说,Python的垃圾收集机制是基于引用计数的。
|
||||
|
||||
它的优点是可以实现增量收集,只要引用计数为零就回收,避免出现周期性的暂停;缺点是需要解决循环引用问题,并且要经常修改引用计数(比如在每次赋值和变量超出作用域的时候),开销有点大。
|
||||
|
||||
**3.唯一ID**
|
||||
|
||||
每个Python对象都有一个唯一ID,它们在生存期内是不变的。用id()函数就可以获得对象的ID。根据Python的[文档](https://docs.python.org/3/library/functions.html#id),这个ID实际就是对象的内存地址。所以,实际上,你不需要在对象里用一个单独的字段来记录对象ID。这同时也说明,Python对象的地址在整个生命周期内是不会变的,这也符合基于引用计数的垃圾收集算法。对比一下,如果采用“停止和拷贝”的算法,对象在内存中会被移动,地址会发生变化。所以你能看出,ID的算法与垃圾收集算法是环环相扣的。
|
||||
|
||||
```
|
||||
>>> a = 10
|
||||
>>> id(a)
|
||||
140330839057200
|
||||
|
||||
```
|
||||
|
||||
接下来,我们看看ob_type这个字段,它指向的是对象的类型。以这个字段为线索,我们就可以牵出Python的整个类型系统的设计。
|
||||
|
||||
## Python的类型系统
|
||||
|
||||
Python是动态类型的语言。它的类型系统的设计相当精巧,Python语言的很多优点,都来自于它的类型系统。我们来看一下。
|
||||
|
||||
首先,Python里每个PyObject对象都有一个类型信息。保存类型信息的数据结构是PyTypeObject(定义在Include/cpython/object.h中)。PyTypeObject本身也是一个PyObject,只不过这个对象是用于记录类型信息的而已。它是一个挺大的结构体,包含了对一个类型的各种描述信息,也包含了一些函数的指针,这些函数就是对该类型可以做的操作。可以说,只要你对这个结构体的每个字段的作用都了解清楚了,那么你对Python的类型体系也就了解透彻了。
|
||||
|
||||
```
|
||||
typedef struct _typeobject {
|
||||
PyObject_VAR_HEAD
|
||||
const char *tp_name; /* 用于打印的名称格式是"<模块>.<名称>" */
|
||||
Py_ssize_t tp_basicsize, tp_itemsize; /* 用于申请内存 */
|
||||
|
||||
/* 后面还有很多字段,比如用于支持数值计算、序列、映射等操作的函数,用于描述属性、子类型、文档等内容的字段,等等。 */
|
||||
...
|
||||
} PyTypeObject
|
||||
|
||||
```
|
||||
|
||||
因为这个结构非常重要,所以我把一些有代表性的字段整理了一下,你可以重点关注它们:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/16/2bd631d8b2c0c7fcc700dce65af8a916.jpg" alt="">
|
||||
|
||||
你会看到,这个结构里的很多部分都是一个函数插槽(Slot),你可以往插槽里保存一些函数指针,用来实现各种标准操作,比如对象生命周期管理、转成字符串、获取哈希值等。
|
||||
|
||||
在上面的表格中,你还会看到像“`__init__`”这样的方法,它的两边都是有两个下划线的,也就是“double underscore”,简称dunder方法,也叫做“**魔术方法**”。在用Python编写自定义的类的时候,你可以实现这些魔术方法,它们就会被缺省的`tp_*`函数所调用,比如,“`__init__`”会被缺省的`tp_init`函数调用,完成类的初始化工作。
|
||||
|
||||
现在我们拿整型对象来举个例子,一起来看看它的PyTypeObject是什么样子。
|
||||
|
||||
对于整型对象来说,它的ob_type会指向一个PyLong_Type对象。这个对象是在longobject.c中初始化的,它是PyTypeObject的一个实例。从中你会看到一些信息:类型名称是“int”,转字符串的函数是`long_to_decimal_string`,此外还有比较函数、方法描述、属性描述、构建和析构函数等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/ab/aabb1ce39b937b38c7e34bc23ca65cab.jpg" alt="">
|
||||
|
||||
我们运行type()函数,可以获得一个对象的类型名称,这个名称就来自PyTypeObject的`tp_name`。
|
||||
|
||||
```
|
||||
>>> a = 10
|
||||
>>> type(a)
|
||||
<type 'int'>
|
||||
|
||||
```
|
||||
|
||||
我们用dir()函数,可以从PyTypeObject中查询出一个对象所支持的所有属性和方法。比如,下面是查询一个整型对象获得的结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/df/5ea14dda16663f78705fd37897e403df.jpg" alt="">
|
||||
|
||||
好,我们刚才讲了整型,它对应的PyTypeObject的实例是PyLong_Type。Python里其实还有其他一些内置的类型,它们分别都对应了一个PyTypeObject的实例。你可以参考一下这个表格。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/91/d3c8c591b89832b5ff71a08856247491.jpg" alt="">
|
||||
|
||||
上面列出的这些都是Python的内置类型。有些内置类型跟语法是关联着的,比如说,“`a = 1`”就会自动创建一个整型对象,“`a = [2, 'john', 3]`”就会自动创建一个List对象。**这些内置对象都是用C语言实现的。**
|
||||
|
||||
**Python比较有优势的一点,是你可以用C语言,像实现内置类型一样实现自己的类型,并拥有很高的性能。**
|
||||
|
||||
当然,如果性能不是你最重要的考虑因素,那么你也可以用Python来创建新的类型,也就是以class关键字开头的自定义类。class编译以后,也会形成一个PyTypeObject对象,来代表这个类。你为这个类编写的各种属性(比如类名称)和方法,会被存到类型对象中。
|
||||
|
||||
好了,现在你已经初步了解了Python的类型系统的特征。接下来,我就带你更深入地了解一下类型对象中一些重要的函数插槽的作用,以及它们所构成的一些协议。
|
||||
|
||||
## Python对象的一些协议
|
||||
|
||||
前面在研究整型对象的时候,你会发现PyLong_Type的tp_as_number字段被赋值了,这是一个结构体(PyNumberMethods),里面是很多与数值计算有关的函数指针,包括加减乘除等。这些函数指针是实现Python的数值计算方面的协议。任何类型,只要提供了这些函数,就可以像整型对象一样进行计算。这实际上是Python定义的一个针对数值计算的协议。
|
||||
|
||||
既然如此,我们再次用GDB来跟踪一下Python的执行过程,看看整数的加法是怎么实现的。我们的示例程序增加了一行代码,变成:
|
||||
|
||||
```
|
||||
a = 1
|
||||
b = a + 2
|
||||
|
||||
```
|
||||
|
||||
它对应的字节码如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/4e/d0409d6d751cb403fb722fae14ba724e.jpg" alt="">
|
||||
|
||||
我们重点来关注BINARY_ADD指令的执行情况,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/de/b387350911c24493f73722667847efde.jpg" alt="">
|
||||
|
||||
可以看到,如果+号两边是字符串,那么编译器就会执行字符串连接操作。否则,就作为数字相加。
|
||||
|
||||
我们继续跟踪进入PyNumber_Add函数。在这个函数中,Python求出了加法函数指针在PyNumberMethods结构体中的偏移量,接着就进入了binary_op1()函数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/a3/a61339a2e5ae1b3b48b3f55dc780b8a3.jpg" alt="">
|
||||
|
||||
在binary_op1函数中,Python首先从第一个参数的类型对象中,取出了加法函数的指针。你在GDB中打印出输出信息,就会发现它是binaryfunc类型的,函数名称是long_add。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/6b/d2566a006c6a3367c89c372d2526f86b.jpg" alt="">
|
||||
|
||||
binaryfunc类型的定义是:
|
||||
|
||||
```
|
||||
typedef PyObject * (*binaryfunc)(PyObject *, PyObject *);
|
||||
|
||||
```
|
||||
|
||||
也就是说,它是指向的函数要求有两个Python对象(的指针)作为参数,返回值也是一个Python对象(的指针)。
|
||||
|
||||
你再继续跟踪下去,会发现程序就进入到了long_add函数。这个函数是在longobject.c里定义的,是Python整型类型做加法计算的内置函数。
|
||||
|
||||
这里有一个隐秘的问题,**为什么是使用了第一个参数(也就是加法左边的对象)所关联的加法函数,而不是第二个参数的加法函数?**
|
||||
|
||||
在我们的示例程序中,由于加法两边的对象的类型是相同的,都是整型,所以它们所关联的加法函数是同一个。但是,如果两边的类型不一样怎么办呢?这个其实是一个很有意思的函数分派问题,你可以先思考一下答案,我会在后面讲Julia的编译器时再回到这个问题上。
|
||||
|
||||
好了,现在我们就能理解了,像加减乘除这样运算,它们在Python里都是怎么实现的了。Python是到对象的类型中,去查找针对这些运算的函数来执行。
|
||||
|
||||
除了内置的函数,我们也可以自己写这样的函数,并被Python所调用。来看看下面的示例程序,我们定义了一个“`__add__`”魔术方法。这个方法会被Python作为SimpleComplex的加法函数所使用,实现了加法操作符的重载,从而支持复数的加法操作。
|
||||
|
||||
```
|
||||
class SimpleComplex(object):
|
||||
def __init__(self, x, y):
|
||||
self.x = x
|
||||
self.y = y
|
||||
def __str__(self):
|
||||
return "x: %d, y: %d" % (self.x, self.y)
|
||||
def __add__(self, other):
|
||||
return SimpleComplex(self.x + other.x, self.y + other.y)
|
||||
|
||||
a = SimpleComplex(1, 2)
|
||||
b = SimpleComplex(3, 4)
|
||||
c = a + b
|
||||
print(c)
|
||||
|
||||
```
|
||||
|
||||
**那么对于这么一个自定义类,在执行BINARY_ADD指令时会有什么不同呢?**通过GDB做跟踪,你会发现几点不同:
|
||||
|
||||
首先,在SimpleComplex的type对象中,add函数插槽里放了一个slot_nb_add()函数指针,这个函数会到对象里查找“`__add__`”函数。因为Python的一切都是对象,因此它找到的是一个函数对象。
|
||||
|
||||
所以,接下来,Python需要运行这个函数对象,而不是用C语言写的内置函数。**那么怎么运行这个函数对象呢?**
|
||||
|
||||
这就需要用到Python的另一个协议,**Callable协议**。这个协议规定,只要为对象的类型中的tp_call属性定义了一个合法的函数,那么该对象就是可被调用的。
|
||||
|
||||
对于自定义的函数,Python会设置一个缺省的tp_call函数。这个函数所做的事情,实际上就是找到该函数所编译出来的PyCodeObject,并让解释器执行其中的字节码!
|
||||
|
||||
好了,通过上面的示例程序,我们加深了对类型对象的了解,也了解了Python关于数值计算和可调用性(Callable)方面的协议。
|
||||
|
||||
Python还有其他几个协议,比如枚举协议和映射协议等,用来支持对象的枚举、把对象加入字典等操作。你可以利用这些协议,充分融入到Python语言的框架中,比如说,你可以重载加减乘除等运算。
|
||||
|
||||
接下来,我们再运用Callable协议的知识,来探究一下Python对象的创建机制。
|
||||
|
||||
## Python对象的创建
|
||||
|
||||
用Python语言,我们可以编写class,来支持自定义的类型。我们来看一段示例代码:
|
||||
|
||||
```
|
||||
class myclass:
|
||||
def __init__(self, x):
|
||||
self.x = x
|
||||
def foo(self, b):
|
||||
c = self.x + b
|
||||
return c
|
||||
a = myclass(2);
|
||||
|
||||
```
|
||||
|
||||
其中,myclass(2)是生成了一个myclass对象。
|
||||
|
||||
可是,你发现没有,Python创建一个对象实例的方式,其实跟调用一个函数没啥区别(不像Java语言,还需要new关键字)。如果你不知道myclass是一个自定义的类,你会以为只是在调用一个函数而已。
|
||||
|
||||
不过,我们前面已经提到了Python的Callable协议。所以,利用这个协议,任何对象只要在其类型中定义了tp_call,那么就都可以被调用。
|
||||
|
||||
我再举个例子,加深你对Callable协议的理解。在下面的示例程序中,我定义了一个类型Bar,并创建了一个对象b。
|
||||
|
||||
```
|
||||
class Bar:
|
||||
def __call__(self):
|
||||
print("in __call__: ", self)
|
||||
b = Bar()
|
||||
b() #这里会打印对象信息,并显示对象地址
|
||||
|
||||
```
|
||||
|
||||
现在,我在b对象后面加一对括号,就可以调用b了!实际执行的就是Bar的“`__call__`”函数(缺省的tp_call函数会查找“`__call__`”属性,并调用)。
|
||||
|
||||
所以,我们调用myclass(),那一定是因为myclass的类型对象中定义了tp_call。
|
||||
|
||||
你还可以把“myclass(2)”这个语句编译成字节码看看,它生成的是CALL_FUNCTION指令,与函数调用没有任何区别。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/a0/1aac8d9cc637f06aa480d86e722dc8a0.jpg" alt="">
|
||||
|
||||
可是,我们知道,示例程序中a的类型对象是myclass,但myclass的类型对象是什么呢?
|
||||
|
||||
换句话说,一个普通的对象的类型,是一个类型对象。**那么一个类型对象的类型又是什么呢?**
|
||||
|
||||
答案是**元类(metaclass)**,元类是类型的类型。举例来说,整型的metaclass是PyType_Type。其实,大部分类型的metaclass是PyType_Type。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/3e/6135aa92c8f7787f664307ab3cf2a83e.jpg" alt="">
|
||||
|
||||
所以说,调用类型来实例化一个对象,就是调用PyType_Type的tp_call函数。**那么PyType_Type的tp_call函数都做了些什么事情呢?**
|
||||
|
||||
这个函数是type_call(),它也是在typeobject.c中定义的。Python以type_call()为入口,会完成创建一个对象的过程:
|
||||
|
||||
- **创建**
|
||||
|
||||
tp_call会调用类型对象的tp_new插槽的函数。对于PyLong_Type来说,它是long_new。
|
||||
|
||||
如果我们是创建一个Point对象,如果你为它定义了一个“`__new__`”函数,那么就将调用这个函数来创建对象,否则,就会查找基类中的tp_new。
|
||||
|
||||
- **初始化**
|
||||
|
||||
tp_call会调用类型对象的tp_init。对于Point这样的自定义类型来说,如果定义了“`__init__`”函数,就会执行来做初始化。否则,就会调用基类的tp_init。对于PyBaseType_Object来说,这个函数是object_init。
|
||||
|
||||
除了自定义的类型,内置类型的对象也可以用类型名称加括号的方式来创建。我还是以整型为例,创建一个整型对象,也可以用“int(10)”这种格式,其中int是类型名称。而且,它的metaclass也是PyType_Type。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/3d/eac9e78929331459d160018703c7c53d.jpg" alt="">
|
||||
|
||||
当然,你也可以给你的类型指定另一个metaclass,从而支持不同的对象创建和初始化功能。虽然大部分情况下你不需要这么做,但这种可定制的能力,就为你编写某些特殊的功能(比如元编程)提供了可能性。
|
||||
|
||||
好了,现在你已经知道,类型的类型是元类(metaclass),它能为类型的调用提供支持。你可能进一步会问,**那么元类的类型又是什么呢?是否还有元元类?直接调用元类又会发生什么呢?**
|
||||
|
||||
缺省情况下,PyType_Type的类型仍然是PyType_Type,也就是指向它自身。对元类做调用,也一样会启动上面的tp_call()过程。
|
||||
|
||||
到目前为止,我们谈论Python中的对象,还没有谈论那些面向对象的传统话题:继承啦、多态啦,等等。这些特性在Python中的实现,仍然只是在类型对象里设置一些字段即可。你可以在tp_base里设定基类(父类)来支持继承,甚至在tp_bases中设置多个基类来支持多重继承。所有对象缺省的基类是object,tp_base指向的是一个PyBaseObject_Type对象。
|
||||
|
||||
```
|
||||
>>> int.__base__ #查看int类型的基类
|
||||
<class 'object'>
|
||||
|
||||
```
|
||||
|
||||
到目前为止,我们已经对于对象的类型、元类,以及对象之间的继承关系有了比较全面的了解,为了方便你重点复习和回顾,我把它们画成了一张图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/5f/3a24633cf6a4637224bd84faac641f5f.jpg" alt="">
|
||||
|
||||
你要注意,图中我用两种颜色的箭头区分了两种关系。**一种是橙色箭头,代表的是类型关系**,比如PyLong_Type是PyLongObject的类型,而PyType_Type是PyLong_Type的类型;**另一种是黑色箭头,代表的是继承关系**,比如int的基类是object,所以PyLong_Type的tp_base指向PyBaseObject_Type。
|
||||
|
||||
到这里,你可能会觉得有点挑战认知。因为通常我们谈面向对象的体系结构,只会涉及图中的继承关系线,不太会考虑其中的类型关系线。Python的类型关系,体现了“数据即程序”的概念。Java语言里,某个类型对应于一个class的字节码,而在Python里,一个类型只是一个Python对象而已。
|
||||
|
||||
并且,在Java里也不会有元类,因为对象的创建和初始化过程都是语言里规定死的。而在Python里,你却拥有一定的掌控能力。
|
||||
|
||||
这些特点,都体现了Python类型体系的强大之处。
|
||||
|
||||
## 课程小结
|
||||
|
||||
好了,我们来总结一下Python的运行时的特征。你会发现,Python的运行时设计的核心,就是**PyObject对象**,Python对象所有的特性都是从PyObject的设计中延伸出来的,给人一种精巧的美感。
|
||||
|
||||
- Python程序中的符号都是Python对象,栈机中存的也都是Python对象指针。
|
||||
- 所有对象的头部信息是相同的,而后面的信息可扩展。这就让Python可以用PyObject指针来访问各种对象,这种设计技巧你需要掌握。
|
||||
- 每个对象都有类型,类型描述信息在一个类型对象里。系统内有内置的类型对象,你也可以通过C语言或Python语言创建新的类型对象,从而支持新的类型。
|
||||
- 类型对象里有一些字段保存了一些函数指针,用于完成数值计算、比较等功能。这是Python指定的接口协议,符合这些协议的程序可以被无缝集成到Python语言的框架中,比如支持加减乘除运算。
|
||||
- 函数的运行、对象的创建,都源于Python的Callable协议,也就是在类型对象中制定tp_call函数。面向对象的特性,也是通过在类型对象里建立与基类的链接而实现的。
|
||||
|
||||
我照例把本讲的重点知识,整理成了一张思维导图,供你参考和回顾:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/5d/0a4916368758e1a1608408bc89a93e5d.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
今天给你的思考题是很有意思的。
|
||||
|
||||
我前面讲到,当Python做加法运算的时候,如果对象类型相同,那么只有一个加法函数可选。但如果两边的对象类型是不同的,该怎么办呢?你可以看看Python是怎么实现的。这其实是编译技术的一个关注点,我们在后面课程中还会提及这个问题。
|
||||
|
||||
## 参考资料
|
||||
|
||||
Python的[内置类型](https://docs.python.org/3.8/library/stdtypes.html)。
|
||||
229
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/20 | JavaScript编译器(一):V8的解析和编译过程.md
Normal file
229
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/20 | JavaScript编译器(一):V8的解析和编译过程.md
Normal file
@@ -0,0 +1,229 @@
|
||||
<audio id="audio" title="20 | JavaScript编译器(一):V8的解析和编译过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ba/a2/bafd3183a764b3d6db8c083b8f0b65a2.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。从这一讲开始,我们就进入另一个非常重要的编译器:V8编译器。
|
||||
|
||||
V8是谷歌公司在2008年推出的一款JavaScript编译器,它也可能是世界上使用最广泛的编译器。即使你不是编程人员,你每天也会运行很多次V8,因为JavaScript是Web的语言,我们在电脑和手机上浏览的每个页面,几乎都会运行一点JavaScript脚本。
|
||||
|
||||
扩展:V8这个词,原意是8缸的发动机,换算成排量,大约是4.0排量,属于相当强劲的发动机了。它的编译器,叫做Ignition,是点火装置的意思。而它最新的JIT编译器,叫做TurboFan,是涡轮风扇发动机的意思。
|
||||
|
||||
在浏览器诞生的早期,就开始支持JavaScript了。但在V8推出以后,它重新定义了Web应用可以胜任的工作。到今天,在浏览器里,我们可以运行很多高度复杂的应用,比如办公套件等,这些都得益于以V8为代表的JavaScript引擎的进步。2008年V8发布时,就已经比当时的竞争对手快10倍了;到目前,它的速度又已经提升了10倍以上。从中你可以看到,编译技术有多大的潜力可挖掘!
|
||||
|
||||
对JavaScript编译器来说,它最大的挑战就在于,当我们打开一个页面的时候,源代码的下载、解析(Parse)、编译(Compile)和执行,都要在很短的时间内完成,否则就会影响到用户的体验。
|
||||
|
||||
**那么,V8是如何做到既编译得快,又要运行得快的呢?**所以接下来,我将会花两讲的时间,来带你一起剖析一下V8里面的编译技术。在这个过程中,你能了解到V8是如何完成前端解析、后端优化等功能的,它都有哪些突出的特点;另外,了解了V8的编译原理,对你以后编写更容易优化的程序,也会非常有好处。
|
||||
|
||||
今天这一讲,我们先来透彻了解一下V8的编译过程,以及每个编译阶段的工作原理,看看它跟我们已经了解的其他编译器相比,有什么不同。
|
||||
|
||||
## 初步了解V8
|
||||
|
||||
首先,按照惯例,我们肯定要下载V8的源代码。按照[官方文档](https://v8.dev/docs/build)中的步骤,你可以下载源代码,并在本地编译。注意,你最好把它编译成Debug模式,这样便于用调试工具去跟踪它的执行,所以你要使用下面的命令来进行编译。
|
||||
|
||||
```
|
||||
tools/dev/gm.py x64.debug
|
||||
|
||||
```
|
||||
|
||||
编译完毕以后,进入v8/out/x64.debug目录,你可以运行./d8,这就是编译好的V8的命令行工具。如果你用过Node.js,那么d8的使用方法,其实跟它几乎是完全一样的,因为Node.js就封装了一个V8引擎。你还可以用GDB或LLDB工具来调试d8,这样你就可以知道,它是怎么编译和运行JavaScript程序了。
|
||||
|
||||
而v8/src目录下的,就是V8的源代码了。V8是用C++编写的。你可以重点关注这几个目录中的代码,它们是与编译有关的功能,而别的代码主要是运行时功能:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ce/ee/ce80ee2ace64988a8d332e0e545ef0ee.jpg" alt="">
|
||||
|
||||
V8的编译器的构成跟Java的编译器很像,它们都有从源代码编译到字节码的编译器,也都有解释器(叫Ignition),也都有JIT编译器(叫TurboFan)。你可以看下V8的编译过程的图例。在这个图中,你能注意到两个陌生的节点:**流处理节点(Stream)和预解析器(PreParser)**,这是V8编译过程中比较有特色的两个处理阶段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/fb/095613e2515fc6d6cc36705e6d952afb.jpg" alt="">
|
||||
|
||||
注意:这是比较新的V8版本的架构。在更早的版本里,有时会用到两个JIT编译器,类似于HotSpot的C1和C2,分别强调编译速度和优化效果。在更早的版本里,还没有字节码解释器。现在的架构,引入了字节码解释器,其速度够快,所以就取消了其中一级的JIT编译器。
|
||||
|
||||
下面我们就进入到V8编译过程中的各个阶段,去了解一些编译器的细节。
|
||||
|
||||
## 超级快的解析过程(词法分析和语法分析)
|
||||
|
||||
首先,我们来了解一下V8解析源代码的过程。我在开头就已经说过,V8解析源代码的速度必须要非常快才行。源代码边下载边解析完毕,在这个过程中,用户几乎感觉不到停顿。**那它是如何实现的呢?**
|
||||
|
||||
有两篇文章就非常好地解释了V8解析速度快的原因。
|
||||
|
||||
一个是“[optimizing the scanner](https://v8.dev/blog/scanner)”这篇文章,它解释了V8在词法分析上做的优化。V8的作者们真是锱铢必较地在每一个可能优化的步骤上去做优化,他们所采用的技术很具备参考价值。
|
||||
|
||||
那我就按照我对这篇文章的理解,来给你解释一下V8解析速度快的原因吧:
|
||||
|
||||
第一个原因,是**V8的整个解析过程是流(Stream)化的**,也就是一边从网络下载源代码,一边解析。在下载后,各种不同的编码还被统一转化为UTF-16编码单位,这样词法解析器就不需要处理多种编码了。
|
||||
|
||||
第二个原因,是**识别标识符时所做的优化**,这也让V8的解析速度更快了一点。你应该知道,标识符的第一个字符(ID_START)只允许用字母、下划线和$来表示,而之后的字符(ID_CONTINUE)还可以包括数字。所以,当词法解析器遇到一个字符的时候,我们首先要判断它是否是合法的ID_START。
|
||||
|
||||
**那么,这样一个逻辑,通常你会怎么写?**我一般想也不想,肯定是这样的写法:
|
||||
|
||||
```
|
||||
if(ch >= 'A' && ch <= 'Z' || ch >='a' && ch<='z' || ch == '$' || ch == '_'){
|
||||
return true;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
但你要注意这里的一个问题,**if语句中的判断条件需要做多少个运算?**
|
||||
|
||||
最坏的情况下,要做6次比较运算和3次逻辑“或”运算。不过,V8的作者们认为这太奢侈了。所以他们通过查表的方法,来识别每个ASCII字符是否是合法的标识符开头字符。
|
||||
|
||||
这相当于准备了一张大表,每个字符在里面对应一个位置,标明了该字符是否是合法的标识符开头字符。这是典型的牺牲空间来换效率的方法。虽然你在阅读代码的时候,会发现它调用了几层函数来实现这个功能,但这些函数其实是内联的,并且在编译优化以后,产生的指令要少很多,所以这个方法的性能更高。
|
||||
|
||||
第三个原因,是**如何从标识符中挑出关键字**。
|
||||
|
||||
与Java的编译器一样,JavaScript的Scanner,也是把标识符和关键字一起识别出来,然后再从中挑出关键字。所以,你可以认为这是一个最佳实践。那你应该也会想到,识别一个字符串是否是关键字的过程,使用的方法仍然是查表。查表用的技术是“**完美哈希(perfect hashing)**”,也就是每个关键字对应的哈希值都是不同的,不会发生碰撞。并且,计算哈希值只用了三个元素:前两个字符(ID_START、ID_CONTINUE),以及字符串的长度,不需要把每个字符都考虑进来,进一步降低了计算量。
|
||||
|
||||
文章里还有其他细节,比如通过缩窄对Unicode字符的处理范围来进行优化,等等。从中你能体会到V8的作者们在提升性能方面,无所不用其极的设计思路。
|
||||
|
||||
除了词法分析,在语法分析方面,V8也做了很多的优化来保证高性能。其中,最重要的是“**懒解析**”技术([lazy parsing](https://v8.dev/blog/preparser))。
|
||||
|
||||
一个页面中包含的代码,并不会马上被程序用到。如果在一开头就把它们全部解析成AST并编译成字节码,就会产生很多开销:占用了太多CPU时间;过早地占用内存;编译后的代码缓存到硬盘上,导致磁盘IO的时间很长,等等。
|
||||
|
||||
所以,所有浏览器中的JavaScript编译器,都采用了懒解析技术。在V8里,首先由预解析器,也就是Preparser粗略地解析一遍程序,在正式运行某个函数的时候,编译器才会按需解析这个函数。你要注意,Preparser只检查语法的正确性,而基于上下文的检查则不是这个阶段的任务。你如果感兴趣的话,可以深入阅读一下这篇[介绍Preparser的文章](https://v8.dev/blog/preparser),我在这里就不重复了。
|
||||
|
||||
你可以在终端测试一下懒解析和完整解析的区别。针对foo.js示例程序,你输入“./d8 – ast-print foo.js”命令。
|
||||
|
||||
```
|
||||
function add(a,b){
|
||||
return a + b;
|
||||
}
|
||||
|
||||
//add(1,2) //一开始,先不调用add函数
|
||||
|
||||
```
|
||||
|
||||
得到的输出结果是:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/58/b0/58491fc107d5227fbdaa2ab996ed9bb0.jpg" alt="">
|
||||
|
||||
里面有一个没有名字的函数(也就是程序的顶层函数),并且它记录了一个add函数的声明,仅此而已。你可以看到,Preparser的解析结果确实够粗略。
|
||||
|
||||
而如果你把foo.js中最后一行的注释去掉,调用一下add函数,再次让d8运行一下foo.js,就会输出完整解析后的AST,你可以看看二者相差有多大:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/75/6ec0dc2aedc384d34924848a1eeab575.jpg" alt="">
|
||||
|
||||
最后,你可以去看看正式的Parser(在parser.h、parser-base.h、parser.cc代码中)。学完了这么多编译器的实现机制以后,以你现在的经验,打开一看,你就能知道,这又是用手写的递归下降算法实现的。
|
||||
|
||||
在看算法的过程中,我一般第一个就会去看它是如何处理二元表达式的。因为二元表达式看上去很简单,但它需要解决一系列难题,包括左递归、优先级和结合性。
|
||||
|
||||
V8的Parser中,对于二元表达式的处理,采取的也是一种很常见的算法:**操作符优先级解析器**(Operator-precedence parser)。这跟Java的Parser也很像,它本质上是自底向上的一个LR(1)算法。所以我们可以得出结论,在手写语法解析器的时候,遇到二元表达式,采用操作符优先级的方法,算是最佳实践了!
|
||||
|
||||
好了,现在我们了解了V8的解析过程,那V8是如何把AST编译成字节码和机器码并运行的呢?我们接着来看看它的编译过程。
|
||||
|
||||
## 编译成字节码
|
||||
|
||||
我们在执行刚才的foo.js文件时,加上“–print-bytecode”参数,就能打印出生成的字节码了。其中,add函数的字节码如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/4e/4afe3a33df386d66b4dc7838ef7a304e.jpg" alt="">
|
||||
|
||||
怎么理解这几行字节码呢?我来给你解释一下:
|
||||
|
||||
- Ldar a1:把参数1从寄存器加载到累加器(Ld=load,a=accumulator, r=register)。
|
||||
- Add a0, [0]:把参数0加到累加器上。
|
||||
- Return:返回(返回值在累加器上)。
|
||||
|
||||
不过,要想充分理解这几行简单的字节码,你还需要真正理解Ignition的设计。因为这些字节码是由Ignition来解释执行的。
|
||||
|
||||
Ignition是一个基于寄存器的解释器。它把函数的参数、变量等保存在寄存器里。不过,这里的寄存器并不是物理寄存器,而是指栈帧中的一个位置。下面是一个示例的栈帧:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ea/3f/ea627032db67ea4yy0898897707f273f.jpg" alt="">
|
||||
|
||||
这个栈帧里包含了执行函数所需要的所有信息:
|
||||
|
||||
- 参数和本地变量。
|
||||
- 临时变量:它是在计算表达式的时候会用到的。比如,计算2+3+4的时候,就需要引入一个临时变量。
|
||||
- 上下文:用来在函数闭包之间维护状态。
|
||||
- pc:调用者的代码地址。
|
||||
|
||||
栈帧里的a0、a1、r0、r1这些都是寄存器的名称,可以在指令里引用。而在字节码里,会用一个操作数的值代替。
|
||||
|
||||
整个栈帧的长度是在编译成字节码的时候就计算好了的。这就让Ignition的栈帧能适应不同架构对栈帧对齐的要求。比如AMD64架构的CPU,它就要求栈帧是16位对齐的。
|
||||
|
||||
Ignition也用到了一些物理寄存器,来提高运算的性能:
|
||||
|
||||
- **累加器:**在做算术运算的时候,一定会用到累加器作为指令的其中一个操作数,所以它就不用在指令里体现了;指令里只要指定另一个操作数(寄存器)就行了。
|
||||
- **字节码数组寄存器:**指向当前正在解释执行的字节码数组开头的指针。
|
||||
- **字节码偏移量寄存器:**当前正在执行的指令,在字节码数组中的偏移量(与pc寄存器的作用一样)。
|
||||
- …
|
||||
|
||||
Ignition是我们见到的第一个寄存器机,它跟我们之前见到的Java和Python的栈机有明显的不同。所以,你可以先思考一下,Ignition会有什么特点呢?
|
||||
|
||||
我来给你总结一下吧。
|
||||
|
||||
1. 它在指令里会引用寄存器作为操作数,寄存器在进入函数时就被分配了存储位置,在函数运行时,栈帧的结构是不变的。而对比起来,栈机的指令从操作数栈里获取操作数,操作数栈随着函数的执行会动态伸缩。
|
||||
1. Ignition还引入了累加器这个物理寄存器作为缺省的操作数。这样既降低了指令的长度,又能够加快执行速度。
|
||||
|
||||
当然,Ignition没有像生成机器码那样,用一个寄存器分配算法,让本地变量、参数等也都尽量采用物理寄存器。这样做的原因,一方面是因为,寄存器分配算法会增加编译的时间;另一方面,这样不利于代码在解释器和TurboFan生成的机器代码之间来回切换(因为它要在调用约定之间做转换)。采用固定格式的栈帧,Ignition就能够在从机器代码切换回来的时候,很容易地设置正确的解释器栈帧状态。
|
||||
|
||||
我把更多的字节码指令列在了下面,你可以仔细看一看Ignition都有哪些指令,从而加深对Ignition解释运行机制的理解。同时,你也可以跟我们已经学过的Java和Python的字节码做个对比。这样呀,你对字节码、解释器的了解就更丰富了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/01/e1ee2400d3447088156959068f4bef01.jpg" alt="">
|
||||
|
||||
## 编译成机器码
|
||||
|
||||
好,前面我提到了,V8也有自己的JIT编译器,叫做TurboFan。在学过Java的JIT编译器以后,你可以预期到,TurboFan也会有一些跟Java JIT编译器类似的特性,比如它们都是把字节码编译生成机器码,都是针对热点代码才会启动即时编译的。那接下来,我们就来验证一下自己的想法,并一起来看看TurboFan的运行效果究竟如何。
|
||||
|
||||
我们来看一个示例程序add.js:
|
||||
|
||||
```
|
||||
function add(a,b){
|
||||
return a+b;
|
||||
}
|
||||
|
||||
for (i = 0; i<100000; i++){
|
||||
add(i, i+1);
|
||||
if (i%1000==0)
|
||||
console.log(i);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
你可以用下面的命令,要求V8打印出优化过程、优化后的汇编代码、注释等信息。其中,“–turbo-filter=add”参数会告诉V8,只优化add函数,否则的话,V8会把add函数内联到外层函数中去。
|
||||
|
||||
```
|
||||
./d8 --trace-opt-verbose \
|
||||
--trace-turbo \
|
||||
--turbo-filter=add \
|
||||
--print-code \
|
||||
--print-opt-code \
|
||||
--code-comments \
|
||||
add.js
|
||||
|
||||
```
|
||||
|
||||
注释:你用./d8 --help,就能列出V8可以使用的各种选项及其说明,我把上面几个选项的含义解释一下。<br>
|
||||
–trace-opt-verbose:跟踪优化过程,并输出详细信息<br>
|
||||
–trace-turbo:跟踪TurboFan的运行过程<br>
|
||||
–print-code:打印生成的代码<br>
|
||||
–print-opt-code:打印优化的代码<br>
|
||||
–code-comment:在汇编代码里输出注释
|
||||
|
||||
程序一开头是解释执行的。在循环了24000次以后,V8认为这是热点代码,于是启动了Turbofan做即时编译。
|
||||
|
||||
最后生成的汇编代码有好几十条指令。不过你可以看到,大部分指令是用于初始化栈帧,以及处理逆优化的情况。真正用于计算的指令,是下面几行指令:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/42/63/42623960a371d97b95540343f5bf4363.jpg" alt="">
|
||||
|
||||
对这些汇编代码的解读,以及这些指令的产生和优化过程,我会在下一讲继续给你讲解。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这讲,我们从总体上考察了V8的编译过程,我希望你记住几个要点:
|
||||
|
||||
- 首先,是**编译速度**。由于JavaScript是在浏览器下载完页面后马上编译并执行,它对编译速度有更高的要求。因此,V8使用了一边下载一边编译的技术:懒解析技术。并且,在解析阶段,V8也比其他编译器更加关注处理速度,你可以从中学到通过查表减少计算量的技术。
|
||||
- 其次,我们认识了一种新的**解释器Ignition**,它是基于寄存器的解释器,或者叫寄存器机。Ignition比起栈机来,更有性能优势。
|
||||
- 最后,我们初步使用了一下V8的**即时编译器TurboFan**。在下一讲中,我们会更细致地探讨TurboFan的特性。
|
||||
|
||||
按照惯例,这一讲的思维导图我也给你整理出来了,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/07/ae/07771c2c05ab77f1d01268cee44a36ae.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你能否把Ignition的字节码和Java、Python的字节码对比一下。看看它们有哪些共同之处,有哪些不同之处?
|
||||
|
||||
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 这两篇文章分析了V8的解析器为什么速度非常快:[Blazingly fast parsing, part 1: optimizing the scanner](https://v8.dev/blog/scanner),[Blazingly fast parsing, part 2: lazy parsing](https://v8.dev/blog/preparser)
|
||||
1. 这篇文章描述了Ignition的设计:[Ignition Design Doc](https://docs.google.com/document/d/11T2CRex9hXxoJwbYqVQ32yIPMh0uouUZLdyrtmMoL44/mobilebasic),我在GitHub上也放了一个[拷贝](https://github.com/RichardGong/CompilersInPractice/blob/master/v8/Ignition%20Design%20Doc.pdf)
|
||||
1. 这篇文章有助于你了解Ignition的字节码:[Understanding V8’s bytecode](https://medium.com/dailyjs/understanding-v8s-bytecode-317d46c94775)
|
||||
1. V8项目的[官网](https://v8.dev/),这里有一些重要的博客文章和文档
|
||||
@@ -0,0 +1,279 @@
|
||||
<audio id="audio" title="21 | JavaScript编译器(二):V8的解释器和优化编译器" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/d0/c847bc9edd939a495ce01cc15e1ac3d0.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。通过前一讲的学习,我们已经了解了V8的整个编译过程,并重点探讨了一个问题,就是**V8的编译速度为什么那么快**。
|
||||
|
||||
V8把解析过程做了重点的优化,解析完毕以后就可以马上通过Ignition解释执行了。这就让JavaScript可以快速运行起来。
|
||||
|
||||
今天这一讲呢,我们重点来讨论一下,**V8的运行速度为什么也这么快**,一起来看看V8所采用的优化技术。
|
||||
|
||||
上一讲我也提及过,V8在2008年刚推出的时候,它提供了一个快速编译成机器码的编译器,虽然没做太多优化,但性能已经是当时其他JavaScript引擎的10倍了。而现在,V8的速度又是2008年刚发布时候的10倍。那么,是什么技术造成了这么大的性能优化的呢?
|
||||
|
||||
这其中,**一方面原因,是TurboFan这个优化编译器,采用了很多的优化技术。**那么,它采用了什么优化算法?采用了什么IR?其优化思路跟Java的JIT编译器有什么相同点和不同点?
|
||||
|
||||
**另一方面,最新的Ignition解释器,虽然只是做解释执行的功能,但竟然也比一个基础的编译器生成的代码慢不了多少。**这又是什么原因呢?
|
||||
|
||||
所以今天,我们就一起把这些问题都搞清楚,这样你就能全面了解V8所采用的编译技术的特点了,你对动态类型语言的编译,也就能有更深入的了解,并且这也有助于你编写更高效的JavaScript程序。
|
||||
|
||||
好,首先,我们来了解一下TurboFan的优化编译技术。
|
||||
|
||||
## TurboFan的优化编译技术
|
||||
|
||||
TurboFan是一个优化编译器。不过它跟Java的优化编译器要完成的任务是不太相同的。因为JavaScript是动态类型的语言,所以如果它能够推断出准确的类型再来做优化,就会带来巨大的性能提升。
|
||||
|
||||
同时,TurboFan也会像Java的JIT编译器那样,基于IR来运行各种优化算法,以及在后端做指令选择、寄存器分配等优化。所有的这些因素加起来,才使得TurboFan能达到很高的性能。
|
||||
|
||||
我们先来看看V8最特别的优化,也就是通过对类型的推理所做的优化。
|
||||
|
||||
### 基于推理的优化(Speculative Optimazition)
|
||||
|
||||
对于基于推理的优化,我们其实并不陌生。在研究[Java的JIT编译器](https://time.geekbang.org/column/article/257504)时,你就发现了Graal会针对解释器收集的一些信息,对于代码做一些推断,从而做一些激进的优化,比如说会跳过一些不必要的程序分支。
|
||||
|
||||
而JavaScript是动态类型的语言,所以对于V8来说,最重要的优化,就是能够在运行时正确地做出类型推断。举个例子来说,假设示例函数中的add函数,在解释器里多次执行的时候,接受的参数都是整型,那么TurboFan就处理整型加法运算的代码就行了。这也就是上一讲中我们生成的汇编代码。
|
||||
|
||||
```
|
||||
function add(a,b){
|
||||
return a+b;
|
||||
}
|
||||
|
||||
for (i = 0; i<100000; i++){
|
||||
if (i%1000==0)
|
||||
console.log(i);
|
||||
|
||||
add(i, i+1);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/07/b3256505bef28fb15c37dcb36117f507.jpg" alt="">
|
||||
|
||||
但是,**如果不在解释器里执行,直接要求TurboFan做编译,会生成什么样的汇编代码呢?**
|
||||
|
||||
你可以在运行d8的时候,加上“–always-opt”参数,这样V8在第一次遇到add函数的时候,就会编译成机器码。
|
||||
|
||||
```
|
||||
./d8 --trace-opt-verbose \
|
||||
--trace-turbo \
|
||||
--turbo-filter=add \
|
||||
--print-code \
|
||||
--print-opt-code \
|
||||
--code-comments \
|
||||
--always-opt \
|
||||
add.js
|
||||
|
||||
```
|
||||
|
||||
这一次生成的汇编代码,跟上一讲生成的就不一样了。由于编译器不知道add函数的参数是什么类型的,所以实际上,编译器是去调用实现Add指令的内置函数,来生成了汇编代码。
|
||||
|
||||
这个内置函数当然支持所有加法操作的语义,但是它也就无法启动基于推理的优化机制了。这样的代码,跟解释器直接解释执行,性能上没太大的差别,因为它们本质上都是调用一个全功能的内置函数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/82/bb27e6dece4cf36d5f3f9f8d87c15082.jpg" alt="">
|
||||
|
||||
而推理式优化的版本则不同,它直接生成了针对整型数字进行处理的汇编代码:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/43/503abd5987b387750b31574a37bc0943.jpg" alt="">
|
||||
|
||||
我来给你解释一下这几行指令的意思:
|
||||
|
||||
- 第1行和第3行,是把参数1和参数2分别拷贝到r8和r9寄存器。**注意**,这里是从物理寄存器里取值,而不是像前一个版本一样,在栈里取值。前一个版本遵循的是更加保守和安全的调用约定。
|
||||
- 第2行和第4行,是把r8和r9寄存器的值向右移1位。
|
||||
- 第5行,是把r8和r9相加。
|
||||
|
||||
看到这里,你可能就发现了一个问题:**只是做个简单的加法而已,为什么要做移位操作呢?**实际上,如果你熟悉汇编语言的话,要想实现上面的功能,其实只需要下面这两行代码就可以了:
|
||||
|
||||
```
|
||||
movq rax, rdi #把参数1拷贝到rax寄存器
|
||||
addq rax, rcx #把参数2加到rax寄存器上,作为返回值
|
||||
|
||||
```
|
||||
|
||||
那么,多出来的移位操作是干什么的呢?
|
||||
|
||||
这就涉及到了V8的**内存管理机制**。原来,V8对象都保存在堆中。在栈帧中保存的数值,都是指向堆的指针。垃圾收集器可以通过这些指针,知道哪些内存对象是不再被使用的,从而把它们释放掉。我们前面学过,Java的虚拟机和Python对于对象引用,本质上也是这么处理的。
|
||||
|
||||
但是,这种机制对于基础数据类型,比如整型,就不太合适了。因为你没有必要为一个简单的整型数据在堆中申请内存,这样既浪费内存,又降低了访问效率,V8需要访问两次内存才能读到一个整型变量的值(第一次读到地址,第二次根据该地址到堆里读到值)。你记得,Python就是这么访问基础数据的。
|
||||
|
||||
V8显然不能忍受这种低效的方式。它采用的优化机制,是一种被广泛采用的技术,叫做**标记指针**(Tagged Pointer)或者**标记值**(Tagged Value)。《[Pointer Compression in V8](https://v8.dev/blog/pointer-compression)》这篇文章,就介绍了V8中采用Tagged Pointer技术的细节。
|
||||
|
||||
比如说,对于一个32位的指针值,它的最低位是标记位。当标记位是0的时候,前31位是一个短整数(简写为Smi);而当标记位是1的时候,那么前31位是一个地址。这样,V8就可以用指针来直接保存一个整数值,用于计算,从而降低了内存占用,并提高了运行效率。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/00/8dcef79f95f098yy29283a65f4e19000.jpg" alt="">
|
||||
|
||||
好了,现在你已经理解了V8的推理式编译的机制。那么,还有什么手段能提升代码的性能呢?
|
||||
|
||||
当然,还有基于IR的各种优化算法。
|
||||
|
||||
### IR和优化算法
|
||||
|
||||
在讲[Java的JIT编译器](https://time.geekbang.org/column/article/256914)时我就提过,V8做优化编译时采用的IR,也是基于Sea of Nodes的。
|
||||
|
||||
你可以回忆一下Sea of Nodes的特点:合并了数据流图与控制流图;是SSA形式;没有把语句划分成基本块。
|
||||
|
||||
它的重要优点,就是优化算法可以自由调整语句的顺序,只要不破坏数据流图中的依赖关系。在Sea of Nodes中,没有变量(有时也叫做寄存器)的概念,只有一个个数据节点,所以对于死代码删除等优化方法来说,它也具备天然的优势。
|
||||
|
||||
**说了这么多,那么要如何查看TurboFan的IR呢?<strong>一个好消息是,V8也像GraalVm一样,提供了一个图形化的工具,来查看TurboFan的IR。这个工具是**turbolizer</strong>,它位于V8源代码的tools/turbolizer目录下。你可以按照该目录下的README.md文档,构建一下该工具,并运行它。
|
||||
|
||||
```
|
||||
python -m SimpleHTTPServer 8000
|
||||
|
||||
```
|
||||
|
||||
它实际启动了一个简单的Web服务。你可以在浏览器中输入“0.0.0.0:8000”,打开turbolizer的界面。
|
||||
|
||||
在运行d8的时候,如果带上参数“–trace-turbo”,就会在当前目录下输出一个.json文件,打开这个文件就能显示出TurboFan的IR来。比如,上一讲的示例程序add.js,所显示出的add函数的IR:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9d/a9/9dd569674080e7f58455b0f1432276a9.jpg" alt="">
|
||||
|
||||
界面中最左边一栏是源代码,中间一栏是IR,最右边一栏是生成的汇编代码。
|
||||
|
||||
上图中的IR只显示了控制节点。你可以在工具栏中找到一个按钮,把所有节点都呈现出来。在左侧的Info标签页中,还有一些命令的快捷键,你最好熟悉一下它们,以便于控制IR都显示哪些节点。这对于一个大的IR图来说很重要,否则你会看得眼花缭乱:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/77/d8/771f9962ed9788656124d63b14c1a2d8.jpg" alt="">
|
||||
|
||||
在这个图中,不同类型的节点用了不同的颜色来区分:
|
||||
|
||||
- 黄色:控制流的节点,比如Start、End和Return;
|
||||
- 淡蓝色:代表一个值的节点;
|
||||
- 红色:JavaScript层级的操作,比如JSEqual、JSToBoolean等;
|
||||
- 深蓝色:代表一种中间层次的操作,介于JavaScript层和机器层级中间;
|
||||
- 绿色:机器级别的语言,代表一些比较低层级的操作。
|
||||
|
||||
在turbolizer的界面上,还有一个下拉菜单,里面有多个优化步骤。你可以挨个点击,看看经过不同的优化步骤以后,IR的变化是什么样子的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/fe/3f37d37bfa29b0fa0531ebbaea5a3afe.jpg" alt="">
|
||||
|
||||
你可以看到,在第一步“v8.TFBytecodeGraphBuilder”阶段显示的IR中,它显示的节点还是有点儿多。我们先隐藏掉与计算功能无关的节点,得到了下面的主干。**你要注意其中的绿色节点**,这里已经进行了类型推测,因此它采用了一个整型计算节点:SpeculativeSafeIntegerAdd。
|
||||
|
||||
这个节点的功能是:当两个参数都是整数的时候,那就符合类型推断,做整数加法操作;如果不符合类型推断,那么就执行逆优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/94/159b51ee21c0yy129d251c5ee3660e94.jpg" alt="">
|
||||
|
||||
你可以再去点击其他的优化步骤,看看图形会有什么变化。
|
||||
|
||||
**在v8.TFGenericLowering阶段**,我们得到了如下所示的IR图,这个图只保留了计算过程的主干。里面增加了两个绿色节点,这两个节点就是把标记指针转换成整数的;还增加了一个深蓝色的节点,这个节点是在函数返回之前,把整数再转换成标记指针。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a6/3a/a6f4a7ba782c38e604469bda41310c3a.jpg" alt="">
|
||||
|
||||
**在v8.TFLateGraphTrimming阶段**,图中的节点增加了更多的细节,它更接近于具体CPU架构的汇编代码。比如,我们把前面图6中的标记指针,转换成32位整数的操作,就变成了两个节点:
|
||||
|
||||
- TruncateInt64ToInt32:把64位整型截短为32位整型;
|
||||
- Word32Sar:32位整数的移位运算,用于把标记指针转换为整数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5y/a4/5yydb5b9e989b15c39c19585035765a4.jpg" alt="">
|
||||
|
||||
这三个阶段就形象地展示出了TurboFan的IR是如何Lower的,从比较抽象的、机器无关的节点,逐步变成了与具体架构相关的操作。
|
||||
|
||||
所以,基本上IR的节点可以分为四类:顶层代表复杂操作的JavaScript节点、底层代表简单操作的机器节点、处于二者之间做了一定简化的节点,以及可以被各个层次共享的节点。
|
||||
|
||||
刚才我们对V8做优化编译时,所采用的IR的分析,只关注了与加法计算有关的主干节点。你还可以用同样的方法,来看看其他的节点。这些节点主要是针对异常情况的处理。比如,如果发现参数类型不是整型,那么就要去执行逆优化。
|
||||
|
||||
在做完了所有的优化之后,编译器就会进入指令排序、寄存器分配等步骤,最后生成机器码。
|
||||
|
||||
现在,你就了解了TurboFan是如何借助Sea of Nodes来做优化和Lower的了。但我们还没有涉及具体的优化算法。**那么,什么优化算法会帮助V8提升性能呢?**
|
||||
|
||||
前面在研究[Java的JIT编译器](https://time.geekbang.org/column/article/257504)的时候,我们重点关注了内联和逃逸分析的优化算法。那么,对于JavaScript来说,这两种优化也同样非常重要,一样能带来巨大的优化效果。
|
||||
|
||||
我们先来看看**内联优化**。对于之前的示例程序,由于我们使用了“–turbo-filter=add”选项来运行代码,因此TurboFan只会编译add方法,这就避免了顶层函数把add函数给内联进去。而如果你去掉了这个选项,就会发现TurboFan在编译完毕以后,程序后面的运行速度会大大加快,一闪而过。这是因为整个顶层函数都被优化编译了,并且在这个过程中,把add函数给内联进去了。
|
||||
|
||||
然后再说说**逃逸分析**。V8运用逃逸分析算法,也可以像Java的编译器一样,把从堆中申请的内存优化为从栈中申请(甚至使用寄存器),从而提升性能,并避免垃圾收集带来的消耗。
|
||||
|
||||
不过,JavaScript和Java的对象体系设计毕竟是不一样的。在Java的类里,每个成员变量相对于对象指针的偏移量都是固定的;而JavaScript则在内部用了**隐藏类**来表示对象的内存布局。这也引出V8的另一个有特色的优化主题:**内联缓存**。
|
||||
|
||||
那接下来,我就带你详细了解一下V8的隐藏类和内联缓存机制。
|
||||
|
||||
### 隐藏类(Shapes)和内联缓存(Inline Caching)
|
||||
|
||||
隐藏类,学术上一般叫做Hidden Class,但不同的编译器的叫法也不一样,比如Shapes、Maps,等等。
|
||||
|
||||
**隐藏类有什么用呢?**你应该知道,在JavaScript中,你不需要先声明一个类,才能创建一个对象。你可以随时创建对象,比如下面的示例程序中,就创建了几个表示坐标点的对象:
|
||||
|
||||
```
|
||||
point1 = {x:2, y:3};
|
||||
point2 = {x:4, y:5};
|
||||
point3 = {y:7, x:6};
|
||||
point4 = {x:8, y:9, z:10};
|
||||
|
||||
```
|
||||
|
||||
那么,V8在内部是怎么来存储x和y这些对象属性的呢?
|
||||
|
||||
如果按照Java的内存布局方案,一定是在对象头后面依次存放x和y的值;而如果按照Python的方案,那就需要用一个字典来保存不同属性的值。但显然用类似Java的方案更加节省内存,访问速度也更快。
|
||||
|
||||
所以,V8内部就采用了隐藏类的设计。如果两个对象,有着相同的属性,并且顺序也相同,那么它们就对应相同的隐藏类。
|
||||
|
||||
在上面的程序中,point1和point2就对应着同一个隐藏类;而point3和point4要么是属性的顺序不同,要么是属性的数量不同,对应着另外的隐藏类。
|
||||
|
||||
所以在这里,你就会得到一个**编写高性能程序的要点**:对于相同类型的对象,一定要保证它们的属性数量和顺序完全一致,这样才会被V8当作相同的类型,从而那些基于类型推断的优化才会发挥作用。
|
||||
|
||||
此外,V8还用到了一种叫做**内联缓存**的技术,来**加快对象属性的访问时间**。它的原理是这样的:当V8第一次访问某个隐藏类的属性的时候,它要到隐藏类里,去查找某个属性的地址相对于对象指针的偏移量。但V8会把这个偏移量缓存下来,这样下一次遇到同样的shape的时候,直接使用这个缓存的偏移量就行了。
|
||||
|
||||
比如下面的示例代码,如果对象o是上面的point1或point2,属性x的地址偏移量就是相同的,因为它们对应的隐藏类是一样的:
|
||||
|
||||
```
|
||||
function getX(o){
|
||||
return o.x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
有了内联优化技术,那么V8只有在第一次访问某个隐藏类的属性时,速度会慢一点,之后的访问效率就跟Java的差不多了。因为Java这样的静态类型的代码,在编译期就可以确定每个属性相对于对象地址的偏移量。
|
||||
|
||||
好,现在你已经了解了TurboFan做优化的一些关键思路。接下来,我们再返回来,重新探讨一下Ignition的运行速度问题。
|
||||
|
||||
## 提升Ignition的速度
|
||||
|
||||
最新版本的V8已经不需要多级的编译器了,只需要一个解释器(Ignition)和一个优化编译器(TurboFan)就行。在公开的测试数据中,Ignition的运行速度,已经接近一个基线编译器生成的机器码的速度了,也就是那种没有做太多优化的机器码。
|
||||
|
||||
这听上去似乎不符合常理,毕竟,解释执行怎么能赶得上运行机器码呢?所以,这里一定有一些值得探究的技术原理。
|
||||
|
||||
让我们再来看看Ignition解释执行的原理吧。
|
||||
|
||||
在上一讲中你已经了解到,V8的字节码是很精简的。比如,对于各种加法操作,它只有一个Add指令。
|
||||
|
||||
但是我们知道,Add指令的语义是很复杂的,比如在ECMAScript标准中,就对加法的语义有很多的规定,如数字相加该怎么做、字符串连接该怎么做,等等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0b/4a/0b8cc0fd4ef25512aa4f3b657f5c014a.jpg" alt="">
|
||||
|
||||
这样的话,**V8在解释执行Add指令的时候,就要跳到一个内置的函数去执行,其他指令也是如此。这些内置函数的实现质量,就会大大影响解释器的运行速度。**
|
||||
|
||||
那么如果换做你,你会怎么实现这些内置函数呢?
|
||||
|
||||
**选择1:用汇编语言去实现**。这样,我们可以针对每种情况写出最优化的代码。但问题是,这样做的工作量很大。因为V8现在已经支持了9种架构的CPU,而要为每种架构编写这些内置功能,都需要敲几万行的汇编代码。
|
||||
|
||||
**选择2:用C++去实现**。这是一个不错的选择,因为C++代码最后编译的结果也是很优化的。不过这里也有一个问题:C++有它自己的调用约定,跟V8中JavaScript的调用约定是不同的。
|
||||
|
||||
比如,在调用C++的内置函数之前,解释器要把自己所使用的物理寄存器保护起来,避免被C++程序破坏,在调用完毕以后还要恢复。这使得从解释器到内置函数,以及从内置函数回到解释器,都要做不少的转换工作。你还要写专门的代码,来对标记指针进行转换。而如果要使用V8的对象,那要处理的事情就更多了,比如它要去隐藏类中查找信息,以及能否通过优化实现栈上内存分配,等等。
|
||||
|
||||
**那么,我们还有别的选择吗?**
|
||||
|
||||
有的。你看,V8已经有了一个不错的优化编译器TurboFan。**既然它能产生很高效的代码,那么我们为什么不直接用TurboFan来生成机器码呢?**这个思路其实是可行的。这可以看做是V8编译器的一种自举能力,用自己的编译器,来生成自己内部要使用的内置函数的目标代码。
|
||||
|
||||
毕竟,TurboFan本来就是要处理标记指针、JavaScript对象的内存表示等这些问题。这个方案还省去了做调用约定的转换的工作,因为本来V8执行的过程中,就要不断在解释执行和运行机器码之间切换,V8内部对栈桢和调用约定的设计,就是要确保这种切换的代价最低。
|
||||
|
||||
在具体实现的时候,编写这些内置函数是用JavaScript调用TurboFan提供的一些宏。这些宏可以转化为TurboFan的IR节点,从而跟TurboFan的优化编译功能无缝衔接。
|
||||
|
||||
好了,分析到这里,你就知道为什么Ignition的运行速度会这么快了:**它采用了高度优化过的内置函数的实现,并且没有调用约定转换的负担。**而一个基线编译器生成的机器码,因为没有经过充分的优化,反倒并没有那么大的优势。
|
||||
|
||||
再补充一点,V8对字节码也提供了一些优化算法。比如,通过优化,可以减少对临时变量的使用,使得代码可以更多地让累加器起到临时变量的作用,从而减少内存访问次数,提高运行效率。如果你有兴趣在这个话题上去做深入研究,可以参考我在文末链接中给出的一篇论文。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲,我围绕运行速度这个主题,给你讲解了V8在TurboFan和Ignition上所采用的优化技术。你需要记住以下几个要点:
|
||||
|
||||
- 第一,由于JavaScript是动态类型的语言,所以优化的要点,就是推断出函数参数的类型,并形成有针对性的优化代码。
|
||||
- 第二,同Graal一样,V8也使用了Sea of Nodes的IR,而且对V8来说,内联和逃逸优化算法仍然非常重要。我在[解析Graal编译器](https://time.geekbang.org/column/article/257504)的时候已经给你介绍过了,所以这一讲并没有详细展开,你可以自己去回顾复习一下。
|
||||
- 第三,V8所采用的内联缓存技术,能够在运行期提高对象属性访问的性能。另外你要注意的是,在编写代码的时候,一定要避免对于相同的对象生成不同的隐藏类。
|
||||
- 第四,Ignition采用了TurboFan来编译内置函数,这种技术非常聪明,既省了工作量,又简化了系统的结构。实际上,在Graal编译器里也有类似的技术,它叫做Snippet,也是用自身的中后端功能来编译内置函数。所以,你会再次发现,多个编译器之间所采用的编译技术,是可以互相印证的。
|
||||
|
||||
这节课的思维导图我同样帮你整理出来了,供你参考和复习:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/94/45/9450db3e9fcba439e249bdcfabd62145.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们已经学了两种动态类型的语言的编译技术:Python和JavaScript。那我现在问一个开脑洞的问题:如果你要给Python加一个JIT编译器,那么你可以从JavaScript这里借鉴哪些技术呢?在哪些方面,编译器会得到巨大的性能提升?
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. V8的指针压缩技术:[Pointer Compression in V8](https://v8.dev/blog/pointer-compression)
|
||||
1. 介绍V8基于推理的优化机制:[An Introduction to Speculative Optimization in V8](https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8)
|
||||
1. 对Ignition字节码做优化的论文:[Register equivalence optimization](https://docs.google.com/document/d/1wW_VkkIwhAAgAxLYM0wvoTEkq8XykibDIikGpWH7l1I/edit?ts=570d7131#heading=h.6jz9dj3bnr8t),我在GitHub上也放了一份[拷贝](https://github.com/RichardGong/CompilersInPractice/blob/master/v8/Ignition_%20Register%20Equivalence%20Optimization.pdf)
|
||||
324
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/22 | Julia编译器(一):如何让动态语言性能很高?.md
Normal file
324
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/22 | Julia编译器(一):如何让动态语言性能很高?.md
Normal file
@@ -0,0 +1,324 @@
|
||||
<audio id="audio" title="22 | Julia编译器(一):如何让动态语言性能很高?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/81/41/81eb38fdc579584de51134af35850941.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
Julia这门语言,其实是最近几年才逐渐获得人们越来越多的关注的。有些人会拿它跟Python进行比较,认为Julia已经成为了Python的劲敌,甚至还有人觉得它在未来可能会取代Python的地位。虽然这样的说法可能是有点夸张了,不过Julia确实是有它的可取之处的。
|
||||
|
||||
为什么这么说呢?前面我们已经研究了Java、Python和JavaScript这几门主流语言的编译器,这几门语言都是很有代表性的:Java语言是静态类型的、编译型的语言;Python语言是动态类型的、解释型的语言;JavaScript是动态类型的语言,但可以即时编译成本地代码来执行。
|
||||
|
||||
而Julia语言却声称同时兼具了静态编译型和动态解释型语言的优点:**一方面它的性能很高,可以跟Java和C语言媲美;而另一方面,它又是动态类型的,编写程序时不需要指定类型。**一般来说,我们很难能期望一门语言同时具有动态类型和静态类型语言的优点的,那么Julia又是如何实现这一切的呢?
|
||||
|
||||
原来它是充分利用了LLVM来实现即时编译的功能。因为LLVM是Clang、Rust、Swift等很多语言所采用的后端工具,所以我们可以借助Julia语言的编译器,来研究如何恰当地利用LLVM。不过,Julia使用LLVM的方法很有创造性,使得它可以同时具备这两类语言的优点。我将在这一讲中给你揭秘。
|
||||
|
||||
此外,Julia编译器的类型系统的设计也很独特,它体现了函数式编程的一些设计哲学,能够帮助你启迪思维。
|
||||
|
||||
还有一点,Julia来自MIT,这里也曾经是Lisp的摇篮,所以Julia有一种学术风和极客风相结合的品味,也值得你去仔细体会一下。
|
||||
|
||||
所以,接下来的两讲,我会带你来好好探究一下Julia的编译器。你从中能够学习到Julia编译器的处理过程,如何创造性地使用LLVM的即时编译功能、如何使用LLVM的优化功能,以及它的独特的类型系统和方法分派。
|
||||
|
||||
那今天这一讲,我会先带你来了解Julia的编译过程,以及它高性能背后的原因。
|
||||
|
||||
## 初步认识Julia
|
||||
|
||||
**Julia的性能有多高呢?**你可以去它的网站上看看与其他编程语言的[性能对比](https://julialang.org/benchmarks/):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/aa/a4/aa683bb1d95381b88d2041d4102968a4.jpg" alt="">
|
||||
|
||||
可以看出,它的性能是在C、Rust这一个级别的,很多指标甚至比Java还要好,比起那些动态语言(如Python、R和Octave),那更是高了一到两个数量级。
|
||||
|
||||
所以,Julia的编译器声称它具备了静态类型语言的性能,确实是不虚此言的。
|
||||
|
||||
你可以从[Julia的官网](https://julialang.org/)下载Julia的二进制版本和源代码。如果你下载的是源代码,那你可以用make debug编译成debug版本,这样比较方便用GDB或LLDB调试。
|
||||
|
||||
Julia的设计目的主要是用于**科学计算**。过去,这一领域的用户主要是使用R语言和Python,但麻省理工(MIT)的研究者们对它们的性能不够满意,同时又想要保留R和Python的友好性,于是就设计出了这门新的语言。目前这门语言受到了很多用户的欢迎,使用者也在持续地上升中。
|
||||
|
||||
我个人对它感兴趣的点,正是因为它打破了静态编译和动态编译语言的边界,我认为这体现了未来语言的趋势:**编译和优化的过程是全生命周期的,而不局限在某个特定阶段。**
|
||||
|
||||
好了,让我们先通过一个例子来认识Juia,直观了解一下这门语言的特点:
|
||||
|
||||
```
|
||||
julia> function countdown(n)
|
||||
if n <= 0
|
||||
println("end")
|
||||
else
|
||||
print(n, " ")
|
||||
countdown(n-1)
|
||||
end
|
||||
end
|
||||
countdown (generic function with 1 method)
|
||||
|
||||
julia> countdown(10)
|
||||
10 9 8 7 6 5 4 3 2 1 end
|
||||
|
||||
```
|
||||
|
||||
所以从这段示例代码中,可以看出,Julia主要有这样几个特点:
|
||||
|
||||
- 用function关键字来声明一个函数;
|
||||
- 用end关键字作为块(函数声明、if语句、for语句等)的结尾;
|
||||
- 函数的参数可以不用指定类型(变量声明也不需要),因为它是动态类型的;
|
||||
- Julia支持递归函数。
|
||||
|
||||
**那么Julia的编译器是用什么语言实现的呢?又是如何支持它的这些独特的特性的呢?**带着这些好奇,让我们来看一看Julia编译器的源代码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7c/9f/7cdbbdbe9a95d0077331ayy8b8608f9f.jpg" alt="">
|
||||
|
||||
其实Julia的实现会让人有点困扰,因为它使用了4种语言:C、C++、Lisp和Julia自身。相比而言,CPython的实现只用了两种语言:C语言和Python。这种情况,就对社区的其他技术人员理解这个编译器和参与开发,带来了不小的困难。
|
||||
|
||||
Julia的作者用C语言实现了一些运行时的核心功能,包括垃圾收集器。他们是比较偏爱C语言的。C++主要是用来实现跟LLVM衔接的功能,因为LLVM是用C++实现的。
|
||||
|
||||
但是,为什么又冒出了一个Lisp语言呢?而且前端部分的主要功能都是用Lisp实现的。
|
||||
|
||||
原来,Julia中用到Lisp叫做[femtolisp](https://github.com/JeffBezanson/femtolisp)(简称flisp),这是杰夫·贝赞松(Jeff Bezanson)做的一个开源Lisp实现,当时的目标是做一个最小的、编译速度又最快的Lisp版本。后来Jeff Bezanson作为Julia的核心开发人员,又把flisp带进了Julia。
|
||||
|
||||
实际上,Julia语言本身也宣称是继承了Lisp语言的精髓。在其核心的设计思想里,在函数式编程风格和元编程功能方面,也确实是如此。Lisp在研究界一直有很多的追随者,Julia这个项目诞生于MIT,同时又主要服务于各种科研工作者,所以它也就带上了这种科学家的味道。它还有其他特性,也能看出这种科研工作者的倾向,比如说:
|
||||
|
||||
- 对于类型系统,Julia的开发者们进行了很好的形式化,是我在所有语言中看到的最像数学家做的类型系统。
|
||||
- 在它的语法和语义设计上,带有Metalab和Mathematics这些数学软件的痕迹,科研工作者们应该很熟悉这种感觉。
|
||||
- 在很多特性的实现上,都带有很强的前沿探索的特征,锋芒突出,不像我们平常使用的那些商业公司设计的计算机语言一样,追求四平八稳。
|
||||
|
||||
以上就是我对Julia的感觉,一种**结合了数据家风格的自由不羁的极客风**。实际上,Lisp最早的设计者约翰·麦卡锡(John McCarthy)就是一位数学博士,所以数学上的美感是Lisp给人的感受之一。而且,Lisp语言本身也是在MIT发源的,所以Julia可以说是继承了这个传统、这种风格。
|
||||
|
||||
## Julia的编译过程
|
||||
|
||||
刚刚说了,Julia的前端主要是用Lisp来实现的。你在启动Julia的时候,通过“–lisp”参数就可以进入flisp的REPL:
|
||||
|
||||
```
|
||||
./julia --lisp
|
||||
|
||||
```
|
||||
|
||||
在这个REPL界面中调用一个julia-parse函数,就可以把一个Julia语句编译成AST。
|
||||
|
||||
```
|
||||
> (julia-parse "a = 2+3*5")
|
||||
(= a (call + 2
|
||||
(call * 3 5)))
|
||||
> (julia-parse "function countdown(n)
|
||||
if n <= 0
|
||||
println(\"end\")
|
||||
else
|
||||
print(n, \" \")
|
||||
countdown(n-1)
|
||||
end
|
||||
end")
|
||||
(function (call countdown n) (block (line 2 none)
|
||||
(if (call <= n 0)
|
||||
(block (line 3 none)
|
||||
(call println "end"))
|
||||
(block (line 5 none)
|
||||
(call print n " ")
|
||||
(line 6 none)
|
||||
(call countdown (call - n 1))))))
|
||||
|
||||
```
|
||||
|
||||
编译后的AST,采用的也是Lisp那种括号嵌套括号的方式。
|
||||
|
||||
Julia的编译器中,主要用到了几个“`.scm`”结尾的代码,来完成词法和语法分析的功能:julia-parser.scm、julia-syntax.scm和ast.scm。(`.scm`文件是Scheme的缩写,而Scheme是Lisp的一种实现,特点是设计精简、语法简单。著名的计算机教科书[SICP](http://mitpress.mit.edu/sicp/)就是用[Scheme](http://www.gnu.org/software/mit-scheme/)作为教学语言,而SICP和Scheme也都是源自MIT。)它的词法分析和语法分析的过程,主要是在parser.scm文件里实现的,我们刚才调用的“julia-parse”函数就是在这个文件中声明的。
|
||||
|
||||
Julia的语法分析过程仍然是你非常熟悉的递归下降算法。因为Lisp语言处理符号的能力很强,又有很好的元编程功能(宏),所以Lisp在实现词法分析和语法分析的任务的时候,代码会比其他语言更短。但是不熟悉Lisp语言的人,可能会看得一头雾水,因为这种括号嵌套括号的语言对于人类的阅读不那么友好,不像Java、JavaScript这样的语言一样,更像自然语言。
|
||||
|
||||
julia-parser.scm输出的成果是比较经典的AST,Julia的文档里叫做“[表面语法AST](http://mortenpi.eu/documenter-html/devdocs/ast.html#Surface-syntax-AST-1)”(surface syntax AST)。所谓表面语法AST,它是跟另一种IR对应的,叫做[Lowered Form](http://mortenpi.eu/documenter-html/devdocs/ast.html#Lowered-form-1)。
|
||||
|
||||
“Lowered”这个词你应该已经很熟悉了,它的意思是**更靠近计算机的物理实现机制**。比如,LLVM的IR跟AST相比,就更靠近底层实现,也更加不适合人类阅读。
|
||||
|
||||
julia-syntax.scm输出的结果就是Lowered Form,这是一种内部IR。它比AST的节点类型更少,所有的宏都被展开了,控制流也简化成了无条件和有条件跳转的节点(“goto”格式)。这种IR后面被用来做类型推断和代码生成。
|
||||
|
||||
你查看julia-syntax.scm的代码,会发现Julia编译器的处理过程是由多个Pass构成的,包括了去除语法糖、识别和重命名本地变量、分析变量的作用域和闭包、把闭包函数做转换、转换成线性IR、记录Slot和标签(label)等。
|
||||
|
||||
这里,我根据Jeff Bezanson在JuliaCon上讲座的内容,把Julia编译器的工作过程、每个阶段涉及的源代码和主要的函数给你概要地梳理了一下,你可以只看这张图,就能大致把握Julia的编译过程,并且可以把它跟你学过的其他几个编译器做一下对比:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/ca/74a68334b5d1661c94a2477982b374ca.jpg" alt="">
|
||||
|
||||
Julia有很好的[反射(Reflection)和自省(Introspection)](http://mortenpi.eu/documenter-html/devdocs/reflection.html#Reflection-and-introspection-1)的能力,你可以调用一些函数或者宏来观察各个阶段的成果。
|
||||
|
||||
比如,采用@code_lowered宏,来看countdown(10)产生的代码,你会看到if…else…的结构被转换成了“goto”语句,这个IR看上去已经有点像LLVM的IR格式了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/dc/102687d427f716eb792545ddef01eddc.jpg" alt="">
|
||||
|
||||
进一步,你还可以用@code_typed宏,来查看它做完类型推断以后的结果,每条语句都标注了类型:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/c3/3363dc9cb96e3d545f9485a900b90fc3.jpg" alt="">
|
||||
|
||||
接下来,你可以用@code_llvm和@code_native宏,来查看生成的LLVM IR和汇编代码。这次,我们用一个更简单的函数foo(),让生成的代码更容易看懂:
|
||||
|
||||
```
|
||||
julia> function foo(x,y) #一个简单的函数,把两个参数相加
|
||||
x+y #最后一句的结果就是返回值,这里可以省略return
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
通过@code_llvm宏生成的LLVM IR,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/35/c1bb01e06ed7262107b24ab99b7b1f35.jpg" alt="">
|
||||
|
||||
通过@code_native宏输出的汇编代码是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/df/3c/dfc49ac304b06700a484886e4bf1cf3c.jpg" alt="">
|
||||
|
||||
最后生成的汇编代码,可以通过汇编器迅速生成机器码并运行。
|
||||
|
||||
通过上面的梳理,你应该已经了解了Julia的编译过程脉络:**通过Lisp的程序,把程序变成AST,然后再变成更低级一点的IR,在这个过程中编译器要进行类型推断等语义层面的处理;最后,翻译成LLVM的IR,并生成可执行的本地代码。**
|
||||
|
||||
对于静态类型的语言来说,我们根据准确的类型信息,就可以生成高效的本地代码,这也是C语言性能高的原因。比如,我们用C语言来写一下foo函数:
|
||||
|
||||
```
|
||||
long foo(long x, long y){
|
||||
return x+y;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Clang的LLVM IR跟Julia生成的基本一样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/39/e3/3962f55b94074a305f5a33226a67c6e3.jpg" alt="">
|
||||
|
||||
生成的汇编代码也差不多:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/65/64/65c786870b31ac1bd831407daa8fa064.jpg" alt="">
|
||||
|
||||
所以,对于这样的程序,Julia的编译后的本地代码,跟C语言的比起来可以说是完全一样。那性能高也就不足为奇了。
|
||||
|
||||
你可能由此就会下结论:因为Julia能够借助LLVM生成本地代码,这就是它性能高的原因。
|
||||
|
||||
**且慢!事情没有这么简单。**为什么这么说?因为在基于前面生成的机器码的这个例子中,当参数是整型的时候,运行效率自然是会比较快。但是,你别忘了Julia是动态类型的语言。我们在Julia中声明foo函数的时候,并没有指定参数的数据类型。如果参数类型变了,会怎样呢?
|
||||
|
||||
## Julia的最大突破:生成多个版本的目标代码
|
||||
|
||||
实际上,我们可以给它传递不同的参数,比如可以传递两个浮点数给它,甚至传递两个向量或者矩阵给它,都能得到正确的结果:
|
||||
|
||||
```
|
||||
julia> foo(2.1, 3.2)
|
||||
5.300000000000001
|
||||
|
||||
julia> foo([1,2,3], [3,4,5])
|
||||
3-element Array{Int64,1}:
|
||||
4
|
||||
6
|
||||
8
|
||||
|
||||
```
|
||||
|
||||
显然,如果上面两次对foo()函数的调用,我们也是用之前生成的汇编代码,那是行不通的。因为之前的汇编代码只能用于处理64位的整数。
|
||||
|
||||
实际上,如果我们观察调用foo(2.1, 3.2)时,Julia生成的LLVM IR和汇编代码,就会发现,它智能地适应了新的数据类型,生成了用于处理浮点数的代码,使用了不同的指令和寄存器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/51/6ea3a61f54ffb74c66d6c5386f0e7c51.jpg" alt="">
|
||||
|
||||
你可以用同样的方法,来试一下 `foo([1,2,3], [3,4,5])` 对应的LLVM IR和汇编代码。这个就要复杂一点了,因为它要处理数组的存储。但不管怎样,Julia生成的代码确实是适应了它的参数类型的。
|
||||
|
||||
数学中的很多算法,其实是概念层面的,它不关心涉及的数字是32位整数、64位整数,还是一个浮点数。但同样是实现一个加法操作,对于计算机内部实现来说,不同的数据类型对应的指令则是完全不同的,那么编译器就要弥合抽象的算法和计算机的具体实现之间的差别。
|
||||
|
||||
对于C语言这样的静态语言来说,它需要针对x、y的各种不同的数据类型,分别编写不同的函数。这些函数的逻辑是一样的,但就是因为数据类型不同,我们就要写很多遍。这是不太合理的,太啰嗦了。
|
||||
|
||||
对于Python这样的动态类型语言来说呢,倒是简洁地写一遍就可以了。但在运行时,对于每一次运算,我们都要根据数据类型来选择合适的操作。这样就大大拉低了整体的运行效率。
|
||||
|
||||
所以,这才是Julia真正的突破:**它能针对同一个算法,根据运行时获得的数据,进行类型推断,并编译生成最优化的本地代码。**在每种参数类型组合的情况下,只要编译一次,就可以被缓存下来,可以使用很多次,从而使得程序运行的总体性能很高。
|
||||
|
||||
你对比一下JavaScript编译器基于类型推断的编译优化,就会发现它们之间的明显的不同。JavaScript编译器一般只会针对类型推断生成一个版本的目标代码,而Julia则会针对每种参数类型组合,都生成一个版本。
|
||||
|
||||
不过,既然Julia编译器存在多个版本的目标代码,那么在运行期,就要有一个程序来确定到底采用哪个版本的目标代码,这就是Julia的一个非常重要的功能:**函数分派算法**。
|
||||
|
||||
函数分派,就是指让编译器在编译时或运行时来确定采用函数的哪个实现版本。针对函数分派,我再给你讲一下Julia的一个特色功能,多重分派。这个知识点有助于你加深对于函数分派的理解,也有助于你理解函数式编程的特点。
|
||||
|
||||
## Julia的多重分派功能
|
||||
|
||||
我们在编程的时候,经常会涉及同一个函数名称的多个实现。比如在做面向对象编程的时候,同一个类里可以有多个相同名称的方法,但参数不同,这种现象有时被叫做**重载(Overload)**;同时,在这个类的子类里,也可以定义跟父类完全相同的方法,这种现象叫做**覆盖(Override)**。
|
||||
|
||||
而程序在调用一个方法的时候,到底调用的是哪个实现,有时候我们在编译期就能确定下来,有时候却必须到运行期才能确定(就像多态的情形),这两种情形就分别叫做**静态分派(Static Dispatch)和动态分派(Dynamic Dispatch)**。
|
||||
|
||||
方法的分派还有另一个分类:**单一分派(Single Dispatch)和多重分派(Multiple Dispatch)**。传统的面向对象的语言使用的都是单一分派。比如,在面向对象语言里面,实现加法的运算:
|
||||
|
||||
```
|
||||
a.add(b)
|
||||
|
||||
```
|
||||
|
||||
这里我们假设a和b都有多个add方法的版本,但实际上,无论怎样分派,程序的调用都是分派到a对象的方法上。这是因为,对于add方法,实质上它的第一个参数是对象a(编译成目标代码时,a会成为第一个参数,以便访问封装在a里面的数据),也就是相当于这样一个函数:
|
||||
|
||||
```
|
||||
add(a, b)
|
||||
|
||||
```
|
||||
|
||||
所以,**面向对象的方法分派相当于是由第一个参数决定的。这种就是单一分派**。
|
||||
|
||||
实际上,采用面向对象的编程方式,在方法分派时经常会让人觉得很别扭。你回顾一下,我在讲[Python编译器](https://time.geekbang.org/column/article/261063)的时候,讲到加法操作采用的实现是第一个操作数对象的类型里,定义的与加法有关的函数。**但为什么它是用第一个对象的方法,而不是第二个对象的呢?如果第一个对象和第二个对象的类型不同怎么办呢?**(这就是我在那讲中留给你的问题)
|
||||
|
||||
还有一个很不方便的地方。如果你增加了一种新的数据类型,比如矩阵(Matrix),它要能够跟整数、浮点数等进行加减乘除运算,但你没有办法给Integer和Float这些已有的类增加方法。
|
||||
|
||||
所以,针对这些很别扭的情况,Julia和Lisp等函数式语言,就支持**多重分派**的方式。
|
||||
|
||||
你只需要定义几个相同名称的函数(在Julia里,这被叫做同一个函数的多个方法),编译器在运行时会根据参数,决定分派给哪个方法。
|
||||
|
||||
我们来看下面这个例子,foo函数有两个方法,根据调用参数的不同,分别分派给不同的方法。
|
||||
|
||||
```
|
||||
julia> foo(x::Int64, y::Int64) = x + y #第一个方法
|
||||
foo (generic function with 1 method)
|
||||
|
||||
julia> foo(x, y) = x - y #第二个方法
|
||||
foo (generic function with 2 methods)
|
||||
|
||||
julia> methods(foo) #显示foo函数的所有方法
|
||||
# 2 methods for generic function "foo":
|
||||
[1] foo(x::Int64, y::Int64) in Main at REPL[38]:1
|
||||
[2] foo(x, y) in Main at REPL[39]:1
|
||||
|
||||
julia> foo(2, 3) #分派到第一个方法
|
||||
5
|
||||
|
||||
julia> foo(2.0, 3) #分派到第二个方法
|
||||
-1.0
|
||||
|
||||
```
|
||||
|
||||
你可以发现,这种分派方法会**公平对待函数的所有参数,而不是由一个特殊的参数来决定。这种分派方法就叫做多重分派。**
|
||||
|
||||
在Julia中,其实“+”操作符(以及其他操作符)也是函数,它有上百个不同的方法,分别处理不同数据类型的加法操作。
|
||||
|
||||
```
|
||||
julia> methods(+)
|
||||
# 166 methods for generic function "+":
|
||||
[1] +(x::Bool, z::Complex{Bool}) in Base at complex.jl:282
|
||||
[2] +(x::Bool, y::Bool) in Base at bool.jl:96
|
||||
[3] +(x::Bool) in Base at bool.jl:93
|
||||
[4] +(x::Bool, y::T) where T<:AbstractFloat in Base at bool.jl:104
|
||||
[5] +(x::Bool, z::Complex) in Base at complex.jl:289
|
||||
[6] +(a::Float16, b::Float16) in Base at float.jl:398
|
||||
[7] +(x::Float32, y::Float32) in Base at float.jl:400
|
||||
[8] +(x::Float64, y::Float64) in Base at float.jl:401
|
||||
[9] +(z::Complex{Bool}, x::Bool) in Base at complex.jl:283
|
||||
...
|
||||
[165] +(J::LinearAlgebra.UniformScaling, F::LinearAlgebra.Hessenberg) in LinearAlgebra ... at hessenberg.jl:518
|
||||
[166] +(a, b, c, xs...) in Base at operators.jl:529
|
||||
|
||||
```
|
||||
|
||||
最重要的是,当你引入新的数据类型,想要支持加法运算的时候,你只需要为加法函数定义一系列新的方法,那么编译器就可以正确地分派了。这种实现方式就方便多了。这也是某些函数式编程语言的一个优势,你可以体会一下。
|
||||
|
||||
而且在Julia中,因为方法分派是动态实现的,所以分派算法的性能就很重要。你看,不同的语言特性的设计,它的运行时就要完成不同的任务。这就是真实世界中,各种编译器的魅力所在。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲我给你介绍了一门并不是那么大众的语言,Julia。介绍它的原因,就是因为这门语言有自己非常独特的特点,非常值得我们学习。我希望你能记住以下几点核心的知识:
|
||||
|
||||
- **编译器的实现语言**:编译器在选择采用什么实现的语言上,拥有很大的自由度。Julia很别具一格地采用了Lisp作为主要的前端语言。不过,我个人猜测,既然Julia本身也算是一种Lisp实现,未来可能就可以用Julia取代flisp,来实现前端的功能,实现更大程度的自举(Bootstraping)了。当然,这仅仅是我自己的猜测。
|
||||
- **又是递归下降算法**:一如既往地,递归下降算法仍然是最常被用来实现语法分析的方法,Julia也不例外。
|
||||
- **多种IR**:Julia在AST之外,采用了“goto”格式的IR,还用到了LLVM的IR(实际上,LLVM内部在转换成本地代码之前,还有一个中间格式的IR)。
|
||||
- **多版本的目标代码**:Julia创造性地利用了LLVM的即时编译功能。它可以在运行期通过类型推断确定变量的类型,进行即时编译,根据不同的参数类型生成多个版本的目标代码,让程序员写一个程序就能适应多种数据类型,既降低了程序员的工作量,同时又保证了程序的高性能。这使得Julia同时拥有了动态类型语言的灵活性和静态类型语言的高性能。
|
||||
- **多重分派功能**:多重分派能够根据方法参数的类型,确定其分派到哪个实现。它的优点是容易让同一个操作,扩展到支持不同的数据类型。
|
||||
|
||||
你学了这讲有什么体会呢?深入探究Julia这样的语言的实现过程,真的有助于我们大开脑洞,突破思维的限制,更好地融合编译原理的各方面的知识,从而为你的实际工作带来更加创新的思路。
|
||||
|
||||
这一讲的思维导图我也给你整理出来了,供你参考和复习回顾:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2a/c6/2a0a7a0b377f70599f98e668d3f172c6.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
一个很有意思的问题:为什么Julia会为一个函数,根据不同的参数类型组合,生成多个版本的目标代码,而JavaScript的引擎一般只会保存一个版本的目标代码?这个问题你可以从多个角度进行思考,欢迎在留言区分享你的观点。
|
||||
|
||||
感谢你的阅读,如果你觉得有收获,欢迎把今天的内容分享给更多的朋友。
|
||||
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="23 | Julia编译器(二):如何利用LLVM的优化和后端功能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/89/e7/897b2766cedaec6e4499664ccd046ae7.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲,我给你概要地介绍了一下Julia这门语言,带你一起解析了它的编译器的编译过程。另外我也讲到,Julia创造性地使用了LLVM,再加上它高效的分派机制,这就让一门脚本语言的运行速度,可以跟C、Java这种语言媲美。更重要的是,你用Julia本身,就可以编写需要高性能的数学函数包,而不用像Python那样,需要用另外的语言来编写(如C语言)高性能的代码。
|
||||
|
||||
那么今天这一讲,我就带你来了解一下Julia运用LLVM的一些细节。包括以下几个核心要点:
|
||||
|
||||
- **如何生成LLVM IR?**
|
||||
- **如何基于LLVM IR做优化?**
|
||||
- **如何利用内建(Intrinsics)函数实现性能优化和语义个性化?**
|
||||
|
||||
这样,在深入解读了这些问题和知识点以后,你对如何正确地利用LLVM,就能建立一个直观的认识了,从而为自己使用LLVM打下很好的基础。
|
||||
|
||||
好,首先,我们来了解一下Julia做即时编译的过程。
|
||||
|
||||
## 即时编译的过程
|
||||
|
||||
我们用LLDB来跟踪一下生成IR的过程。
|
||||
|
||||
```
|
||||
$ lldb #启动lldb
|
||||
(lldb)attach --name julia #附加到julia进程
|
||||
c #让julia进程继续运行
|
||||
|
||||
```
|
||||
|
||||
首先,在Julia的REPL中,输入一个简单的add函数的定义:
|
||||
|
||||
```
|
||||
julia> function add(a, b)
|
||||
x = a+b
|
||||
x
|
||||
end
|
||||
|
||||
```
|
||||
|
||||
接着,在LLDB或GDB中设置一个断点“br emit_funciton”,这个断点是在codegen.cpp中。
|
||||
|
||||
```
|
||||
(lldb) br emit_function #添加断点
|
||||
|
||||
```
|
||||
|
||||
然后在Julia里执行函数add:
|
||||
|
||||
```
|
||||
julia> add(2,3)
|
||||
|
||||
```
|
||||
|
||||
这会触发Julia的编译过程,并且程序会停在断点上。我整理了一下调用栈的信息,你可以看看,即时编译是如何被触发的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/75/c87ce502a6299f9ec36920a3f5358c75.jpg" alt="">
|
||||
|
||||
通过跟踪执行和阅读源代码,你会发现Julia中最重要的几个源代码:
|
||||
|
||||
- **gf.c**:Julia以方法分派快速而著称。对于类似加法的这种运算,它会有上百个方法的实现,所以在运行时,就必须能迅速定位到准确的方法。分派就是在gf.c里。
|
||||
- **interpreter.c**:它是Julia的解释器。虽然Julia中的函数都是即时编译的,但在REPL中的简单的交互,靠解释执行就可以了。
|
||||
- **codegen.cpp**:生成LLVM IR的主要逻辑都在这里。
|
||||
|
||||
我希望你能自己动手跟踪执行一下,这样你就会彻底明白Julia的运行机制。
|
||||
|
||||
## Julia的IR:采用SSA形式
|
||||
|
||||
在上一讲中,你已经通过@code_lowered和@code_typed宏,查看过了Julia的IR。
|
||||
|
||||
[Julia的IR](https://github.com/JuliaLang/julia/blob/v1.4.1/base/compiler/ssair/ir.jl)也经历了一个发展演化过程,它的IR最早不是SSA的,而是后来才改成了[SSA形式](https://docs.julialang.org/en/v1/devdocs/ssair/)。这一方面是因为,SSA真的是有优势,它能简化优化算法的编写;另一方面也能看出,SSA确实是趋势呀,我们目前接触到的Graal、V8和LLVM的IR,都是SSA格式的。
|
||||
|
||||
Julia的IR主要承担了两方面的任务。
|
||||
|
||||
**第一是类型推断**,推断出来的类型被保存到IR中,以便于生成正确版本的代码。
|
||||
|
||||
**第二是基于这个IR做一些**[**优化**](https://github.com/JuliaLang/julia/blob/v1.4.1/base/compiler/optimize.jl),其实主要就是实现了[内联](https://github.com/JuliaLang/julia/blob/v1.4.1/base/compiler/ssair/inlining.jl)优化。内联优化是可以发生在比较早的阶段,你在Go的编译器中就会看到类似的现象。
|
||||
|
||||
你可以在Julia中写两个短的函数,让其中一个来调用另一个,看看它所生成的LLVM代码和汇编代码是否会被自动内联。
|
||||
|
||||
另外,你还可以查看一下传给emit_function函数的Julia IR是什么样子的。在LLDB里,你可以用下面的命令来显示src参数的值(其中,`jl_(obj)`是Julia为了调试方便提供的一个函数,它能够更好地[显示Julia对象的信息](https://docs.julialang.org/en/v1/devdocs/debuggingtips/#Displaying-Julia-variables-1),注意显示是在julia窗口中)。src参数里面包含了要编译的Julia代码的信息。
|
||||
|
||||
```
|
||||
(lldb) expr jl_(src)
|
||||
|
||||
```
|
||||
|
||||
为了让你能更容易看懂,我稍微整理了一下输出的信息的格式:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/dc/3c618c6594d3f021fe36cafc28b222dc.jpg" alt="">
|
||||
|
||||
你会发现,这跟用**@code_typed(add(2,3))命令**打印出来的信息是一致的,只不过宏里显示的信息会更加简洁:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/25/f5be1674c76593192ae65c00f3402325.jpg" alt="">
|
||||
|
||||
接下来,查看emit_function函数,你就能够看到生成LLVM IR的整个过程。
|
||||
|
||||
## 生成LLVM IR
|
||||
|
||||
LLVM的IR有几个特点:
|
||||
|
||||
- 第一,它是SSA格式的。
|
||||
- 第二,LLVM IR有一个类型系统。类型系统能帮助生成正确的机器码,因为不同的字长对应的机器码指令是不同的。
|
||||
- 第三,LLVM的IR不像其他IR,一般只有内存格式,它还有文本格式和二进制格式。你完全可以用文本格式写一个程序,然后让LLVM读取,进行编译和执行。所以,LLVM的IR也可以叫做LLVM汇编。
|
||||
- 第四,LLVM的指令有丰富的元数据,这些元数据能够被用于分析和优化工作中。
|
||||
|
||||
基本上,生成IR的程序没那么复杂,就是用简单的语法制导的翻译即可,从AST或别的IR生成LLVM的IR,属于那种比较幼稚的翻译方法。
|
||||
|
||||
采用这种方法,哪怕一开始生成的IR比较冗余,也没有关系,因为我们可以在后面的优化过程中继续做优化。
|
||||
|
||||
在生成的IR里,会用到Julia的**内建函数**(Intrinsics),它代表的是一些基础的功能。
|
||||
|
||||
在[JavaScript的编译器](https://time.geekbang.org/column/article/263523)里,我们已经接触过**内置函数**(Built-in)的概念了。而在Julia的编译器中,内建函数和内置函数其实是不同的概念。
|
||||
|
||||
内置函数是标准的Julia函数,它可以有多个方法,根据不同的类型来分派。比如,取最大值、最小值的函数max()、min()这些,都是内置函数。
|
||||
|
||||
而内建函数只能针对特定的参数类型,没有多分派的能力。Julia会把基础的操作,都变成对内建函数的调用。在上面示例的IR中,就有一个add_in()函数,也就是对整型做加法运算,它就是内建函数。内建函数的目的是生成LLVM IR。Julia中有近百个内置函数。在[intrinsics.cpp](https://github.com/JuliaLang/julia/blob/v1.4.1/src/intrinsics.cpp)中,有为这些内置函数生成LLVM IR的代码。
|
||||
|
||||
这就是Julia生成LLVM IR的过程:遍历Julia的IR,并调用LLVM的IRBuilder类,生成合适的IR。在此过程中,会遇到很多内建函数,并调用内建函数输出LLVM IR的逻辑。
|
||||
|
||||
## 运行LLVM的Pass
|
||||
|
||||
我们之所以会使用LLVM,很重要的一个原因就是利用它里面的丰富的优化算法。
|
||||
|
||||
LLVM的优化过程被标准化成了一个个的Pass,并由一个PassManager来管理。你可以查看jitlayers.cpp中的[addOptimizationPasses](https://github.com/JuliaLang/julia/blob/v1.4.1/src/jitlayers.cpp#L113)()函数,看看Julia都使用了哪些Pass。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/7f/7894d70bbd53dc60f70feb0b63c1097f.jpg" alt="">
|
||||
|
||||
上面表格中的Pass都是LLVM中自带的Pass。你要注意,运用好这些Pass,会产生非常好的优化效果。比如,某个开源项目,由于对性能的要求比较高,所以即使在Windows平台上,仍然强烈建议使用Clang来编译,而Clang就是基于LLVM的。
|
||||
|
||||
除此之外,Julia还针对自己语言的特点,写了几个个性化的Pass。比如:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/12/56/12381d4dce04315f25cfb0fc40819256.jpg" alt="">
|
||||
|
||||
这些个性化的Pass是针对Julia本身的语言特点而编写的。比如对于垃圾收集,每种语言的实现策略都不太一样,因此就必须自己实现相应的Pass,去插入与垃圾收集有关的代码。再比如,Julia是面向科学计算的,比较在意数值计算的性能,所以自己写了两个Pass来更好地利用CPU的一些特殊指令集。
|
||||
|
||||
emit_function函数最后返回的是一个**模块(Module)对象**,这个模块里只有一个函数。这个模块会被加入到一个[JuliaOJIT](https://github.com/JuliaLang/julia/blob/v1.4.1/src/jitlayers.h#L105)对象中进行集中管理。Julia可以从JuliaOJIT中,查找一个函数并执行,这就是Julia能够即时编译并运行的原因。
|
||||
|
||||
不过,我们刚才说的都是生成LLVM IR和基于IR做优化。**那么,LLVM的IR又是如何生成机器码的呢?对于垃圾收集功能,LLVM是否能给予帮助呢?在使用LLVM方面还需要注意哪些方面的问题呢?**
|
||||
|
||||
## 利用LLVM的正确姿势
|
||||
|
||||
在这里,我给你总结一下LLVM的功能,并带你探讨一下如何恰当地利用LLVM的功能。
|
||||
|
||||
通过这门课,你其实已经能够建立这种认识:编译器后端的工作量更大,某种意义上也更重要。如果我们去手工实现每个优化算法,为每种架构、每种ABI来生成代码,那不仅工作量会很大,而且还很容易遇到各种各样需要处理的Bug。
|
||||
|
||||
使用LLVM,就大大降低了优化算法和生成目标代码的工作量。LLVM的一个成功的前端是Clang,支持对C、C++和Objective-C的编译,并且编译速度和优化效果都很优秀。既然它能做好这几种语言的优化和代码生成,那么用来支持你的语言,你也应该放心。
|
||||
|
||||
总体来说,LLVM能给语言的设计者提供这样几种帮助:
|
||||
|
||||
- **程序的优化功能**
|
||||
|
||||
你可以通过LLVM的API,从你的编译器的前端生成LLVM IR,然后再调用各种分析和优化的Pass进行处理,就能达到优化目标。
|
||||
|
||||
LLVM还提供了一个框架,让你能够编写自己的Pass,满足自己的一些个性化需求,就像Julia所做的那样。
|
||||
|
||||
LLVM IR还有元数据功能,来辅助一些优化算法的实现。比如,在做基于类型的别名分析(TPAA)的时候,需要用到在前端解析中获得类型信息的功能。你在生成LLVM IR的时候,就可以把这些类型信息附加上,这样有助于优化算法的运行。
|
||||
|
||||
- **目标代码生成功能**
|
||||
|
||||
LLVM支持对x86、ARM、PowerPC等各种CPU架构生成代码的功能。同时,你应该还记得,在[第8讲](https://time.geekbang.org/column/article/249261)中,我说过ABI也会影响代码的生成。而LLVM,也支持Windows、Linux和macOS的不同的ABI。
|
||||
|
||||
另外,你已经知道,在目标代码生成的过程中,一般会需要三大优化算法:指令选择、寄存器分配和指令排序算法。LLVM对此同样也给予了很好的支持,你直接使用这些算法就行了。
|
||||
|
||||
最后,LLVM的代码生成功能对CPU厂家也很友好,因为这些算法都是**目标独立**(Target-independent)的。如果硬件厂家推出了一个新的CPU,那它可以用LLVM提供的**TableGen工具**,来描述这款新CPU的架构,这样我们就能使用LLVM来为它生成目标代码了。
|
||||
|
||||
- **对垃圾收集的支持**
|
||||
|
||||
LLVM还支持垃圾收集的特性,比如会提供安全点、读屏障、写屏障功能等。这些知识点我会在第32讲“垃圾收集”的时候带你做详细的了解。
|
||||
|
||||
- **对Debug的支持**
|
||||
|
||||
我们知道,代码的跟踪调试对于程序开发是很重要的。如果一门语言是生成机器码的,那么要实现跟踪调试,我们必须往代码里插入一些调试信息,比如目标代码对应的源代码的位置、符号表等。这些调试信息是符合**DWARF**(Debugging With Attributed Record Formats,使用有属性的记录格式进行调试)标准的,这样GDB、LLDB等各种调试工具,就可以使用这些调试信息进行调试了。
|
||||
|
||||
- **对JIT的支持**
|
||||
|
||||
LLVM内置了对JIT的支持。你可以在运行时编译一个模块,生成的目标代码放在内存里,然后运行该模块。实际上,Julia的编译器能够像普通的解释型语言那样运行,就是运用了LLVM的JIT机制。
|
||||
|
||||
- **其他功能**
|
||||
|
||||
LLVM还在不断提供新的支持,比如支持在程序链接的时候进行过程间的优化,等等。
|
||||
|
||||
总而言之,研究Julia的编译器,就为我们使用LLVM提供了一个很好的样本。你在有需要的时候,也可以作为参考。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我们主要研究了Julia如何实现中后端功能的,特别是在这个过程中,它是如何使用LLVM的,你要记住以下要点:
|
||||
|
||||
- Julia自己的IR也是采用**SSA格式**的。这个IR的主要用途是**类型推断和内联优化**。
|
||||
- Julia的IR会被**转化成LLVM的IR**,从而进一步利用LLVM的功能。在转换过程中,会用到Julia的**内建函数**,这些内建函数代表了Julia语言中,抽象度比较高的运算功能,你可以拿它们跟V8的IR中,代表JavaScript运算的高级节点作类比,比如加法计算节点。这些内建函数会生成体现Julia语言语义的LLVM IR。
|
||||
- 你可以使用**LLVM的Pass来实现代码优化**。不过使用哪些Pass、调用的顺序如何,是由你自己安排的,并且你还可以编写自己个性化的Pass。
|
||||
- **LLVM为程序优化和生成目标代码提供了可靠的支持**,值得重视。而Julia为使用LLVM,就提供了一个很好的参考。
|
||||
|
||||
本讲的思维导图我也给你整理出来了,供你参考和复习回顾知识点:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0c/58/0cc76aeee4ced068a99227c0e74bc858.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
LLVM强调全生命周期优化的概念。那么我们来思考一个有趣的问题:能否让Julia也像Java的JIT功能一样,在运行时基于推理来做一些激进的优化?如何来实现呢?欢迎在留言区发表你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. LLVM的官网:[llvm.org](https://llvm.org/)。如果你想像Julia、Rust、Swift等语言一样充分利用LLVM,那么应该会经常到这里来查阅相关资料。
|
||||
1. LLVM的[源代码](https://github.com/llvm/llvm-project)。像LLVM这样的开源项目,不可能通过文档或者书籍来获得所有的信息。最后,你还是必须去阅读源代码,甚至要根据Clang等其他前端使用LLVM的输出做反向工程,才能掌握各种细节。LLVM的核心作者也推荐开发者源代码当作文档。
|
||||
1. [Working with LLVM](https://docs.julialang.org/en/v1/devdocs/llvm/):Julia的开发者文档中,有对如何使用LLVM的介绍。
|
||||
1. [LLVM’s Analysis and Transform Passes](https://llvm.org/docs/Passes.html):对LLVM中的各种Pass的介绍。要想使用好LLVM,你就要熟悉这些Pass和它们的使用场景。
|
||||
1. 在《编译原理之美》的[第25讲](https://time.geekbang.org/column/article/153192)和[第26讲](https://time.geekbang.org/column/article/154438),我对LLVM后端及其命令行工具做了介绍,并且还手工调用LLVM的API,示范了针对不同的语法结构(比如if结构)应该如何生成LLVM IR,最后即时编译并运行。你可以去参考看看。
|
||||
243
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/24 | Go语言编译器:把它当作教科书吧.md
Normal file
243
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/24 | Go语言编译器:把它当作教科书吧.md
Normal file
@@ -0,0 +1,243 @@
|
||||
<audio id="audio" title="24 | Go语言编译器:把它当作教科书吧" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/15/2a/151e7d604cc8686457b5f1cb8fyyb12a.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天这一讲,我来带你研究一下Go语言自带的编译器,它可以被简称为gc。
|
||||
|
||||
我之所以要来带你研究Go语言的编译器,一方面是因为Go现在确实非常流行,很多云端服务都用Go开发,Docker项目更是巩固了Go语言的地位;另一方面,我希望你能把它当成编译原理的教学参考书来使用。这是因为:
|
||||
|
||||
- Go语言的编译器完全用Go语言本身来实现,它完全实现了从前端到后端的所有工作,而不像Java要分成多个编译器来实现不同的功能模块,不像Python缺少了后端,也不像Julia用了太多的语言。所以你研究它所采用的编译技术会更方便。
|
||||
- Go编译器里基本上使用的都是经典的算法:经典的递归下降算法、经典的SSA格式的IR和CFG、经典的优化算法、经典的Lower和代码生成,因此你可以通过一个编译器就把这些算法都贯穿起来。
|
||||
- 除了编译器,你还可以学习到一门语言的其他构成部分的实现思路,包括运行时(垃圾收集器、并发调度机制等)、标准库和工具链,甚至连链接器都是用Go语言自己实现的,从而对实现一门语言所需要做的工作有更完整的认识。
|
||||
- 最后,Go语言的实现继承了从Unix系统以来形成的一些良好的设计哲学,因为Go语言的核心设计者都是为Unix的发展,做出过重要贡献的极客。因此了解了Go语言编译器的实现机制,会提高你的软件设计品味。
|
||||
|
||||
扩展:每种语言都有它的个性,而这个个性跟语言设计者的背景密切相关。Go语言的核心设计者,是Unix领域的极客,包括Unix的创始人和C语言的共同发明人之一,Ken Tompson。Rob Pike也是Unix的核心作者。
|
||||
|
||||
Go语言的作者们显然希望新的语言体现出他们的设计哲学和口味。比如,致力于像Unix那样的简洁和优雅,并且致力于让Go再次成为一款经典作品。
|
||||
|
||||
所以,在已经研究了多个高级语言的编译器之后,我们可以拿Go语言的编译器,把整个编译过程再重新梳理和印证一遍。
|
||||
|
||||
好了,现在就开始我们今天探索的旅途吧。
|
||||
|
||||
首先,我们来看看Go语言编译器的前端。
|
||||
|
||||
重要提示:照例,你要下载Go语言的[源代码](https://github.com/golang/go/tree/release-branch.go1.14),本讲采用的是1.14.2版本。并且,你最好使用一个IDE,便于跟踪调试编译器的执行过程。<br>
|
||||
Go的源代码中附带的[介绍编译器的文档](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/README.md),写得很好、很清晰,你可以参考一下。
|
||||
|
||||
## 词法分析和语法分析
|
||||
|
||||
Go的编译器的词法分析和语法分析功能的实现,是在cmd/compile/internal/syntax目录下。
|
||||
|
||||
**词法分析器是scanner.go。**其实大部分编程语言的词法分析器的算法,都已经很标准了,我们在[Java编译器](https://time.geekbang.org/column/article/251937)里就曾经分析过。甚至它们处理标识符和关键字的方式也都一致,都是先作为标识符识别出来,然后再查表挑出关键字来。Go的词法分析器并没有像V8那样在不遗余力地压榨性能,它跟你平常编码的方式是很一致的,非常容易阅读。
|
||||
|
||||
**语法分析器是parser.go。**它是一个标准的手写的递归下降算法。在[解析二元表达式](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/syntax/parser.go#L656)的时候,Go的语法分析器也是采用了运算符优先级算法,这个已经是我们第N次见到这个算法了,所以你一定要掌握!不过,每个编译器的实现都不大一样,而Go的实现方式相当的简洁,你可以去自己看一下,或者用调试器来跟踪一下它的执行过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1e/4c/1e0b9ae47048ed77329d941c4a8e374c.jpg" alt="">
|
||||
|
||||
Go的AST的节点,是在nodes.go中定义的,它异常简洁,可以说简洁得让你惊讶。你可以欣赏一下。
|
||||
|
||||
**Go的语法分析器还有一个很有特色的地方,就是对错误的处理。**它在处理编译错误时,有一个原则,就是不要遇到一个错误就停止编译,而是要尽可能跳过当前这个出错的地方,继续往下编译,这样可以一次多报几个语法错误。
|
||||
|
||||
parser.go的处理方式是,当语法分析器在处理某个产生式的时候,如果发现了错误,那就记录下这个错误,并且往下跳过一些Token,直到找到一个Token是属于这个产生式的Follow集合的。这个时候编译器就认为找到了这个产生式的结尾。这样分析器就可以跳过这个语法单元,继续处理下面的语法单元。
|
||||
|
||||
比如,在[解析函数声明语句](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/syntax/parser.go#L614)时,如果Go的语法分析器没有找到函数名称,就报错“expecting name or (”,然后往后找到“{”或者“;”,这样就跳过了函数名称的声明部分,继续去编译后面的函数体部分。
|
||||
|
||||
在cmd/compile/internal/syntax目录下,还有词法分析器和语法分析器的测试程序,你可以去运行测试一下。
|
||||
|
||||
最后,如果你还想对Go语言的语法分析有更加深入地了解,我建议你去阅读一下[Go语言的规范](https://golang.org/ref/spec),它里面对于每个语法单元,都有EBNF格式的语法规则定义,比如对[语句的定义](https://golang.org/ref/spec#Statements)。你通过看代码、看语言规范,积累语法规则的第一手经验,以后再看到一段程序,你的脑子里就能反映出它的语法规则,并且能随手画出AST了,这是你学习编译原理需要建立的硬功夫。比如说,这里我节选了一段Go语言的规范中针对语句的部分语法规则。
|
||||
|
||||
```
|
||||
Statement =
|
||||
Declaration | LabeledStmt | SimpleStmt |
|
||||
GoStmt | ReturnStmt | BreakStmt | ContinueStmt | GotoStmt |
|
||||
FallthroughStmt | Block | IfStmt | SwitchStmt | SelectStmt |
|
||||
ForStmt | DeferStmt .
|
||||
|
||||
SimpleStmt = EmptyStmt | ExpressionStmt | SendStmt | IncDecStmt |
|
||||
Assignment | ShortVarDecl .
|
||||
|
||||
```
|
||||
|
||||
好,在了解了Go语言编译器的语法分析工作以后,接下来,我们再来看看它的语义分析阶段。
|
||||
|
||||
## 语义分析(类型检查和AST变换)
|
||||
|
||||
语义分析的程序,是在cmd/compile/internal/gc目录下(注意,gc的意思是Go Compiler,不是垃圾收集的意思)。在入口代码main.go中,你能看到整个编译过程的主干步骤。
|
||||
|
||||
语义分析的主要程序是在[typecheck.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/typecheck.go)中。**这里你要注意,**不要被“typecheck”的名称所误导,它其实不仅是做类型检查,还做了名称消解(Name Resolution)和类型推导。
|
||||
|
||||
你已经知道,名称消解算法的特点,是分阶段完成。举个例子,在给表达式“a=b”中的变量b做引用消解之前,编译器必须先处理完b的定义,比如“var b Person”,这样才知道符号b指的是一个Person对象。
|
||||
|
||||
另外,在前面学习[Java编译器](https://time.geekbang.org/column/article/253750)的时候,你已经知道,对方法体中的本地变量的消解,必须放在最后,才能保证变量的使用总是引用到在它前面的变量声明。Go的编译器也是采用了相同的实现思路,你可以借此再回顾一下这个知识点,加深认识。
|
||||
|
||||
在语义分析阶段,Go的编译器还做了一些AST变换的工作。其中就有[内联优化](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/inl.go)和[逃逸分析](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/escape.go)这两项工作。在我们之前解析的编译器当中,这两项工作都是基于专门做优化的IR(比如Sea of Nodes)来做的,而在Go的编译器里,却可以基于AST来做这两项优化。你看,是不是真实世界中的编译器,才能让你如此开阔眼界?
|
||||
|
||||
你可以用“-m”参数来编译程序,它会打印出与内联和逃逸方面有关的优化。你可以带上多个“-m”参数,打印出嵌套层次更深的算法步骤的决策。
|
||||
|
||||
```
|
||||
go build -gcflags '-m -m' hello.go
|
||||
|
||||
```
|
||||
|
||||
好了,现在我们借gc编译器,又复习了一遍语义分析中的一些关键知识点:名称消解算法要分阶段,在语义分析阶段会对AST做一些变换。我们继续来看gc编译器下一步的处理工作。
|
||||
|
||||
## 生成SSA格式的IR
|
||||
|
||||
gc编译器在做完语义分析以后,下一步就是生成IR了。并且,gc的IR也是SSA格式的。你可以通过gc,来进一步了解如何生成和处理SSA格式的IR。
|
||||
|
||||
好,首先,我们来看看Go语言的IR是什么样子的。针对下面的示例代码foo.go,我们来看下它对应的SSA格式的IR:
|
||||
|
||||
```
|
||||
package main
|
||||
|
||||
func Foo(a int) int {
|
||||
var b int
|
||||
if (a > 10) {
|
||||
b = a
|
||||
} else {
|
||||
b = 10
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在命令行中输入下面的命令,让gc打印出为foo函数生成的IR。在当前目录下,你能看到一个ssa.html文件,你可以在浏览器中打开它。
|
||||
|
||||
```
|
||||
GOSSAFUNC=Foo go build -gcflags '-S' foo.go
|
||||
|
||||
```
|
||||
|
||||
在这个文件当中,你能看到编译器对IR做了多步的处理,也能看到每次处理后所生成的IR。
|
||||
|
||||
gc的IR是基于控制流图(CFG)的。一个函数会被分成多个基本块,基本块中包含了一行行的指令。点击某个变量,你能看到它的定义和使用情况(def-use链,图中显示成绿色)。你还能看到,图中灰色的变量,根据定义和使用关系,会发现它们没有被使用,所以是死代码,可以删除。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/66/a8c648560c0b03b23c3b39a95cbd5b66.jpg" alt="">
|
||||
|
||||
针对第一个阶段(Start阶段),我来给你解释一下每行指令的含义(可参考genericOps.go),帮助你了解Go语言的IR的设计特点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/a5/b1d104eaac5d7f04a3dc59f7a6d2a2a5.jpg" alt="">
|
||||
|
||||
你可以参考代码库中[介绍SSA的文档](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/README.md),里面介绍了Go的SSA的几个主要概念。
|
||||
|
||||
下面我来给你解读一下。
|
||||
|
||||
**首先是**[**值(Value)**](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/value.go#L16)**。**Value是SSA的最主要构造单元,它可以定义一次、使用多次。在定义一个Value的时候,需要一个标识符(ID)作为名称、产生该Value的操作码([Op](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/op.go))、一个类型([Type](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/types/type.go#L118),就是代码中<>里面的值),以及一些参数。
|
||||
|
||||
操作码有两类。一类是机器无关的,其定义在[genericOps.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/genericOps.go)中;一类是机器相关的,它是面向特定的CPU架构的,其定义在XXXOps.go中。比如,[AMD64Ops.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/AMD64Ops.go#L166)中是针对AMD64架构CPU的操作码信息。
|
||||
|
||||
在做Lower处理时,编译器会把机器无关的操作码转换为机器相关的操作码,有利于后序生成目标代码。机器无关的优化和机器相关的优化,分别作用在采用这两类不同操作码的IR上。
|
||||
|
||||
Value的类型信息,通常就是Go语言中的类型。但有几个类型是只会在SSA中用到的[特殊类型](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/types/type.go#L1472),就像上面语句中的<mem>,即内存([TypeMem](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/types/type.go#L1475))类型;以及TypeFlags,也就是CPU的标志位类型。</mem>
|
||||
|
||||
这里我要特别讲一下**内存类型**。内存类型代表的是全局的内存状态。如果一个操作码带有一个内存类型的参数,那就意味着该操作码依赖该内存状态。如果一个操作码的类型是内存类型,则意味着它会影响内存状态。
|
||||
|
||||
SSA的介绍文档中有一个例子,能帮助你理解内存类型的用法。
|
||||
|
||||
在这个例子中,程序首先会向地址a写入3这个值。这个时候,内存状态就修改了(从v1到了v10)。接着,把地址a的值写入地址b,内存状态又发生了一次修改。在IR中,第二行代码依赖第一行代码的内存状态(v10),因此就导致这行代码只能出现在定义了v10之后。
|
||||
|
||||
```
|
||||
// *a = 3 //向a地址写入3
|
||||
// *b = *a //向b地址写入a的值
|
||||
v10 = Store <mem> {int} v6 v8 v1
|
||||
v14 = Store <mem> {int} v7 v8 v10
|
||||
|
||||
```
|
||||
|
||||
这里你需要注意,对内存的读和写(各种IR一般都是使用Load和Store这两个词汇)是一类比较特殊的指令。其他的Value,我们都可以认为它们是在寄存器中的,是计算过程中的临时变量,所以它们在代码中的顺序只受数据流中依赖关系的制约。而一旦中间有读写内存的操作,那么代码顺序就会受到一定的限制。
|
||||
|
||||
我们可以跟在[Graal编译器](https://time.geekbang.org/column/article/256914)中学到的知识印证一下。当你读写一个Java对象的属性的时候,也会涉及内存的读写,这些操作对应的IR节点,在顺序上也是受到限制的,我们把它们叫做固定节点。
|
||||
|
||||
此外,Value结构中还包含了两个辅助信息字段:AuxInt和Aux。AuxInt是一个整型值,比如,在使用Const64指令中,AuxInt保存了常量的值;而Aux则可能是个复杂的结构体,用来保存每个操作码的个性化的信息。
|
||||
|
||||
**在IR中你还能看到基本块(**[**Block**](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/block.go)**),这是第二个重要的数据结构。**Go编译器中的基本块有三种:简单(Plain)基本块,它只有一个后继基本块;退出(Exit)基本块,它的最后一个指令是一个返回指令;还有if基本块,它有一个控制值,并且它会根据该值是true还是false,跳转到不同的基本块。
|
||||
|
||||
**第三个数据结构是函数(**[**Func**](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/func.go)**)。**函数是由多个基本块构成的。它必须有一个入口基本块(Entry Block),但可以有0到多个退出基本块,就像一个Go函数允许包含多个Return语句一样。
|
||||
|
||||
现在,你已经知道了Go的IR的关键概念和相关的数据结构了。Go的IR在运行时就是保存在Value、Block、Func等内存结构中,就像AST一样。它不像LLVM的bitcode还有文本格式、二进制格式,可以保存在文件中。
|
||||
|
||||
那么接下来,编译器就可以基于IR,来做优化了。
|
||||
|
||||
## 基于SSA格式的IR做优化
|
||||
|
||||
SSA格式的IR对编译器做优化很有帮助。
|
||||
|
||||
以死代码删除为例,Value结构中有一个Uses字段,记录了它的使用数。如果它出现在另一个Value的操作码的参数里,或者是某个基本块的控制变量,那么使用数就会加1;而如果Uses字段的值是0,那就证明这行代码没什么用,是死代码,可以删掉。
|
||||
|
||||
而你应该记得,在[第7讲](https://time.geekbang.org/column/article/248770)中曾提到过,我们需要对一个函数的所有基本块都扫描一遍甚至多遍,才能知道某个变量的活跃性,从而决定是否可以删除掉它。那相比起来,采用SSA格式,可以说简单太多了。
|
||||
|
||||
基于这样的IR来做优化,就是对IR做很多遍(Pass)的处理。在[cmd/compile/internal/ssa/compile.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/compile.go#L398)的代码里,列出了所有这些Pass,有将近50个。你能看到每个处理步骤执行的是哪个优化函数,你还可以在ssa.html中,看到每个Pass之后,IR都被做了哪些修改。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/yy/f8/yy0a23bdf773d2yy1d7234d95578eff8.jpg" alt="">
|
||||
|
||||
这些处理算法都是在[cmd/compile/internal/ssa](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/compile.go#L398)目录下。比如[cse.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/cse.go)里面是消除公共子表达式的算法,而[nilcheck.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/nilcheck.go)是被用来消除冗余的nil检查代码。
|
||||
|
||||
有些算法还带了测试程序(如[cse_test.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/cse_test.go),[nilcheck_test.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/nilcheck_test.go))。你可以去阅读一下,看看测试程序是如何构造测试数据的,并且你还可以通过Debugger来跟踪测试程序的执行过程,从而理解相关优化算法是如何实现的,这是一个很有效的学习方式。
|
||||
|
||||
另外,gc还有一些比较简单的优化算法,它们是基于一些规则,对IR做一些重写(rewrite)。Go的编译器使用了自己的一种DSL,来描述这些重写规则:针对机器无关的操作码的重写规则,是在[generic.rules](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/generic.rules)文件中;而针对机器有关的操作码的重写规则是在XXX.rules中,比如[AMD64.rules](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/AMD64.rules)。
|
||||
|
||||
我们来看几个例子:在generic.rules中,有这样一个机器无关的优化规则,它是把x*1的运算优化为x。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/fb/c091e058e96d6f166fa5737b8d9a80fb.jpg" alt="">
|
||||
|
||||
在AMD64.rules中,有一个机器相关的优化规则,这个规则是把MUL指令转换为LEA指令,LEA指令比MUL指令消耗的时钟周期更少。
|
||||
|
||||
```
|
||||
(MUL(Q|L)const [ 3] x) -> (LEA(Q|L)2 x x)
|
||||
|
||||
```
|
||||
|
||||
generic.rules中的规则会被[rulegen.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/gen/rulegen.go)解析,并生成Go代码[rewritegeneric.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/rewritegeneric.go)。而AMD64.rules中的规则,被解析后会生成[rewriteAMD64.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/rewriteAMD64.go)。其中,Lower的过程,也就是把机器无关的操作码转换为机器相关的操作码,它也是用这种重写规则实现的。
|
||||
|
||||
通过gc这种基于规则做指令转换的方法,你应该产生一个感悟,也就是在写软件的时候,我们经常要设计自己的DSL,让自己的软件更具灵活性。比如,gc要增加一个新的优化功能,只需要增加一条规则就行了。我们还可以再拿Graal编译器印证一下。你还记得,Graal在生成LIR的时候,要进行指令的选择,那些选择规则是用注解来生成的,而那些注解规则,也是一种DSL。
|
||||
|
||||
好了,谈完了优化,我们继续往下看。
|
||||
|
||||
## 生成机器码
|
||||
|
||||
最后,编译器就可以调用gc/ssa.go中的[genssa](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/gc/ssa.go#L5899)方法,来生成汇编码了。
|
||||
|
||||
在ssa.html的最右边一栏,就是调用genssa方法以后生成的汇编代码(采用的是Go编译器特有的格式,其中有些指令,如PCDATA和FUNCDATA是用来与垃圾收集器配合的)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/60/93/60798f64cbcd63d45671412712b49893.jpg" alt="">
|
||||
|
||||
你可能会问,**编译器在生成机器码之前,不是还要做指令选择、寄存器分配、指令排序吗?**那我们看看gc是如何完成这几项任务的。
|
||||
|
||||
**寄存器分配**([regalloc.go](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/regalloc.go))作为一个Pass,已经在生成机器码之前执行了。它采用的是线性扫描算法(Linear Scan Register Allocator)。
|
||||
|
||||
**指令选择会分为两部分的工作。**一部分工作,是在优化算法中已经做了一些指令选择,我们前面提到的重写规则,就蕴含了根据IR的模式,来生成合适的指令的规则;另一部分工作,则放到了汇编器当中。
|
||||
|
||||
这就是Go的编译器与众不同的地方。原来,gc生成的汇编代码,是一种“伪汇编”,它是一种半抽象的汇编代码。在生成特定CPU的机器码的时候,它还会做一些转换,这个地方可以完成另一些指令选择的工作。
|
||||
|
||||
至于**指令排序**,我没看到过在gc编译器中的实现。我请教了谷歌的一位研究员,他给我的信息是:像AMD64这样的CPU,已经能够很好地支持乱序执行了,所以指令重排序给gc编译器的优化工作,带来的好处很有限。
|
||||
|
||||
而gc目前没有做指令排序,还有一个原因就是,指令重排序算法的实现代价比较高,而gc的一个重要设计目标,就是要求编译速度要快。
|
||||
|
||||
扩展:Go语言的另外两个编译器,gccgo和GoLLVM都具备指令重排序功能。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我给你介绍了gc编译器的主要特点。之所以能压缩在一讲里面,是因为你已经见识了好几款编译器,渐渐地可以触类旁通、举一反三了。
|
||||
|
||||
在gc里面,你能看到很多可以借鉴的成熟实践:
|
||||
|
||||
- **语法分析**:递归下降算法,加上针对二元表达式的运算符优先级算法;
|
||||
- **语义分析**:分阶段的名称消解算法,以及对AST的转换;
|
||||
- **优化**:采用了SSA格式的IR、控制流图(CFG)、多个Pass的优化框架,以及通过DSL支持的优化规则。
|
||||
|
||||
所以在这一讲的开头,我还建议你把Go语言的编译器作为你学习编译原理的“教学参考书”,建议你在图形化的IDE界面里,来跟踪调试每一个功能,这样你就能很方便去观察它的算法执行过程。
|
||||
|
||||
本讲的思维导图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/47/b4d6d2e094c9d2485303065945781047.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
在gc编译器里面,内联优化是基于AST去做的。那么,它为什么没有基于SSA格式的IR来做呢?这两种不同的实现会有什么差异?欢迎你在留言区发表你的看法。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. [Introduction to the Go compiler](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/README.md) 官方文档,介绍了gc的主要结构。
|
||||
1. [Introduction to the Go compiler’s SSA backend](https://github.com/golang/go/blob/release-branch.go1.14/src/cmd/compile/internal/ssa/README.md) 官方文档,介绍了gc的SSA。
|
||||
1. Go compiler internals: adding a new statement to Go - [Part 1](https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-1/)、[Part2](https://eli.thegreenplace.net/2019/go-compiler-internals-adding-a-new-statement-to-go-part-2/)。在这两篇博客里,作者做了一个实验:如果往Go里面增加一条新的语法规则,需要做哪些事情。你能贯穿性地了解一个编译器的方法。
|
||||
1. [Go compiler: SSA optimization rules description language](https://quasilyte.dev/blog/post/go_ssa_rules/)这篇博客,详细介绍了gc编译器的SSA优化规则描述语言的细节。
|
||||
1. [A Primer on Go Assembly](https://github.com/teh-cmc/go-internals/blob/master/chapter1_assembly_primer/README.md)和[A Quick Guide to Go’s Assembler](https://golang.org/doc/asm) 。gc编译器采用的汇编语言是它自己的一种格式,是“伪汇编”。这两篇文章中有Go汇编的细节。
|
||||
348
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/25 | MySQL编译器(一):解析一条SQL语句的执行过程.md
Normal file
348
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/25 | MySQL编译器(一):解析一条SQL语句的执行过程.md
Normal file
@@ -0,0 +1,348 @@
|
||||
<audio id="audio" title="25 | MySQL编译器(一):解析一条SQL语句的执行过程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9b/96/9bffa131cff817f913137d5cd368b696.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。现在,就到了我们编译之旅的最后一站了,我们一起来探索一下MySQL编译器。
|
||||
|
||||
数据库系统能够接受SQL语句,并返回数据查询的结果,或者对数据库中的数据进行修改,可以说几乎每个程序员都使用过它。
|
||||
|
||||
而MySQL又是目前使用最广泛的数据库。所以,解析一下MySQL编译并执行SQL语句的过程,一方面能帮助你加深对数据库领域的编译技术的理解;另一方面,由于SQL是一种最成功的DSL(特定领域语言),所以理解了MySQL编译器的内部运作机制,也能加深你对所有使用数据操作类DSL的理解,比如文档数据库的查询语言。另外,解读SQL与它的运行时的关系,也有助于你在自己的领域成功地使用DSL技术。
|
||||
|
||||
**那么,数据库系统是如何使用编译技术的呢?**接下来,我就会花两讲的时间,带你进入到MySQL的内部,做一次全面的探秘。
|
||||
|
||||
今天这一讲,我先带你了解一下如何跟踪MySQL的运行,了解它处理一个SQL语句的过程,以及MySQL在词法分析和语法分析方面的实现机制。
|
||||
|
||||
好,让我们开始吧!
|
||||
|
||||
## 编译并调试MySQL
|
||||
|
||||
按照惯例,你要下载[MySQL的源代码](https://github.com/mysql/mysql-server)。我下载的是8.0版本的分支。
|
||||
|
||||
源代码里的主要目录及其作用如下,我们需要分析的代码基本都在sql目录下,它包含了编译器和服务端的核心组件。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b8/c6/b8c9a108f1370bace3b1d8b3300b7ec6.jpg" alt="">
|
||||
|
||||
MySQL的源代码主要是.cc结尾的,也就是说,MySQL主要是用C++编写的。另外,也有少量几个代码文件是用C语言编写的。
|
||||
|
||||
为了跟踪MySQL的执行过程,你要用Debug模式编译MySQL,具体步骤可以参考这篇[开发者文档](https://dev.mysql.com/doc/internals/en/cmake.html)。
|
||||
|
||||
如果你用单线程编译,大约需要1个小时。编译好以后,先初始化出一个数据库来:
|
||||
|
||||
```
|
||||
./mysqld --initialize --user=mysql
|
||||
|
||||
```
|
||||
|
||||
这个过程会为root@localhost用户,生成一个缺省的密码。
|
||||
|
||||
接着,运行MySQL服务器:
|
||||
|
||||
```
|
||||
./mysqld &
|
||||
|
||||
```
|
||||
|
||||
之后,通过客户端连接数据库服务器,这时我们就可以执行SQL了:
|
||||
|
||||
```
|
||||
./mysql -uroot -p #连接mysql server
|
||||
|
||||
```
|
||||
|
||||
最后,我们把GDB调试工具附加到mysqld进程上,就可以对它进行调试了。
|
||||
|
||||
```
|
||||
gdb -p `pidof mysqld` #pidof是一个工具,用于获取进程的id,你可以安装一下
|
||||
|
||||
```
|
||||
|
||||
提示:这一讲中,我是采用了一个CentOS 8的虚拟机来编译和调试MySQL。我也试过在macOS下编译,并用LLDB进行调试,也一样方便。
|
||||
|
||||
**注意**,你在调试程序的时候,有两个**设置断点**的好地方:
|
||||
|
||||
- **dispatch_command**:在sql/sql_parse.cc文件里。在接受客户端请求的时候(比如一个SQL语句),会在这里集中处理。
|
||||
- **my_message_sql**:在sql/mysqld.cc文件里。当系统需要输出错误信息的时候,会在这里集中处理。
|
||||
|
||||
这个时候,我们在MySQL的客户端输入一个查询命令,就可以从雇员表里查询姓和名了。在这个例子中,我采用的数据库是MySQL的一个[示例数据库employees](https://github.com/datacharmer/test_db),你可以根据它的文档来生成示例数据库。
|
||||
|
||||
```
|
||||
mysql> select first_name, last_name from employees; #从mysql库的user表中查询信息
|
||||
|
||||
```
|
||||
|
||||
这个命令被mysqld接收到以后,就会触发断点,并停止执行。这个时候,客户端也会老老实实地停在那里,等候从服务端传回数据。即使你在后端跟踪代码的过程会花很长的时间,客户端也不会超时,一直在安静地等待。给我的感觉就是,MySQL对于调试程序还是很友好的。
|
||||
|
||||
在GDB中输入bt命令,会打印出调用栈,这样你就能了解一个SQL语句,在MySQL中执行的完整过程。为了方便你理解和复习,这里我整理成了一个表格:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/5e/c8115701536a1d0ba093e804bf13735e.jpg" alt="">
|
||||
|
||||
我也把MySQL执行SQL语句时的一些重要程序入口记录了下来,这也需要你重点关注。它反映了执行SQL过程中的一些重要的处理阶段,包括语法分析、处理上下文、引用消解、优化和执行。你在这些地方都可以设置断点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/90/829ff647ecefed1ca2653696085f7a90.jpg" alt="">
|
||||
|
||||
好了,现在你就已经做好准备,能够分析MySQL的内部实现机制了。不过,由于MySQL执行的是SQL语言,它跟我们前面分析的高级语言有所不同。所以,我们先稍微回顾一下SQL语言的特点。
|
||||
|
||||
## SQL语言:数据库领域的DSL
|
||||
|
||||
SQL是结构化查询语言(Structural Query Language)的英文缩写。举个例子,这是一个很简单的SQL语句:
|
||||
|
||||
```
|
||||
select emp_no, first_name, last_name from employees;
|
||||
|
||||
```
|
||||
|
||||
其实在大部分情况下,SQL都是这样一个一个来做语句执行的。这些语句又分为DML(数据操纵语言)和DDL(数据定义语言)两类。前者是对数据的查询、修改和删除等操作,而后者是用来定义数据库和表的结构(又叫模式)。
|
||||
|
||||
我们平常最多使用的是DML。而DML中,执行起来最复杂的是select语句。所以,在本讲,我都是用select语句来给你举例子。
|
||||
|
||||
那么,SQL跟我们前面分析的高级语言相比有什么不同呢?
|
||||
|
||||
**第一个特点:SQL是声明式(Declarative)的**。这是什么意思呢?其实就是说,SQL语句能够表达它的计算逻辑,但它不需要描述控制流。
|
||||
|
||||
高级语言一般都有控制流,也就是详细规定了实现一个功能的流程:先调用什么功能,再调用什么功能,比如if语句、循环语句等等。这种方式叫做**命令式(imperative)编程**。
|
||||
|
||||
更深入一点,声明式编程说的是“要什么”,它不关心实现的过程;而命令式编程强调的是“如何做”。前者更接近人类社会的领域问题,而后者更接近计算机实现。
|
||||
|
||||
**第二个特点:SQL是一种特定领域语言(DSL,Domain Specific Language),专门针对关系数据库这个领域的**。SQL中的各个元素能够映射成关系代数中的操作术语,比如选择、投影、连接、笛卡尔积、交集、并集等操作。它采用的是表、字段、连接等要素,而不需要使用常见的高级语言的变量、类、函数等要素。
|
||||
|
||||
所以,SQL就给其他DSL的设计提供了一个很好的参考:
|
||||
|
||||
- **采用声明式,更加贴近领域需求。**比如,你可以设计一个报表的DSL,这个DSL只需要描述报表的特征,而不需要描述其实现过程。
|
||||
- **采用特定领域的模型、术语,甚至是数学理论。**比如,针对人工智能领域,你完全就可以用张量计算(力学概念)的术语来定义DSL。
|
||||
|
||||
好了,现在我们分析了SQL的特点,从而也让你了解了DSL的一些共性特点。那么接下来,顺着MySQL运行的脉络,我们先来了解一下MySQL是如何做词法分析和语法分析的。
|
||||
|
||||
## 词法和语法分析
|
||||
|
||||
词法分析的代码是在sql/sql_lex.cc中,入口是MYSQLlex()函数。在sql/lex.h中,有一个symbols[]数组,它定义了各类关键字、操作符。
|
||||
|
||||
**MySQL的词法分析器也是手写的,这给算法提供了一定的灵活性。**比如,SQL语句中,Token的解析是跟当前使用的字符集有关的。使用不同的字符集,词法分析器所占用的字节数是不一样的,判断合法字符的依据也是不同的。而字符集信息,取决于当前的系统的配置。词法分析器可以根据这些配置信息,正确地解析标识符和字符串。
|
||||
|
||||
**MySQL的语法分析器是用bison工具生成的,bison是一个语法分析器生成工具,它是GNU版本的yacc**。bison支持的语法分析算法是LALR算法,而LALR是LR算法家族中的一员,它能够支持大部分常见的语法规则。bison的规则文件是sql/sql_yacc.yy,经过编译后会生成sql/sql_yacc.cc文件。
|
||||
|
||||
sql_yacc.yy中,用你熟悉的EBNF格式定义了MySQL的语法规则。我节选了与select语句有关的规则,如下所示,从中你可以体会一下,SQL语句的语法是怎样被一层一层定义出来的:
|
||||
|
||||
```
|
||||
select_stmt:
|
||||
query_expression
|
||||
| ...
|
||||
| select_stmt_with_into
|
||||
;
|
||||
query_expression:
|
||||
query_expression_body opt_order_clause opt_limit_clause
|
||||
| with_clause query_expression_body opt_order_clause opt_limit_clause
|
||||
| ...
|
||||
;
|
||||
query_expression_body:
|
||||
query_primary
|
||||
| query_expression_body UNION_SYM union_option query_primary
|
||||
| ...
|
||||
;
|
||||
query_primary:
|
||||
query_specification
|
||||
| table_value_constructor
|
||||
| explicit_table
|
||||
;
|
||||
query_specification:
|
||||
...
|
||||
| SELECT_SYM /*select关键字*/
|
||||
select_options /*distinct等选项*/
|
||||
select_item_list /*select项列表*/
|
||||
opt_from_clause /*可选:from子句*/
|
||||
opt_where_clause /*可选:where子句*/
|
||||
opt_group_clause /*可选:group子句*/
|
||||
opt_having_clause /*可选:having子句*/
|
||||
opt_window_clause /*可选:window子句*/
|
||||
;
|
||||
...
|
||||
|
||||
```
|
||||
|
||||
其中,query_expression就是一个最基础的select语句,它包含了SELECT关键字、字段列表、from子句、where子句等。
|
||||
|
||||
你可以看一下select_options、opt_from_clause和其他几个以opt开头的规则,它们都是SQL语句的组成部分。opt是可选的意思,也就是它的产生式可能产生ε。
|
||||
|
||||
```
|
||||
opt_from_clause:
|
||||
/* Empty. */
|
||||
| from_clause
|
||||
;
|
||||
|
||||
|
||||
```
|
||||
|
||||
另外,你还可以看一下表达式部分的语法。在MySQL编译器当中,对于二元运算,你可以大胆地写成左递归的文法。因为它的语法分析的算法用的是LALR,这个算法能够自动处理左递归。
|
||||
|
||||
一般研究表达式的时候,我们总是会关注编译器是如何处理结合性和优先级的。那么,bison是如何处理的呢?
|
||||
|
||||
原来,bison里面有专门的规则,可以规定运算符的优先级和结合性。在sql_yacc.yy中,你会看到如下所示的规则片段:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/20/4e0d2706eb5e26143ae125c05bd2e720.jpg" alt="">
|
||||
|
||||
你可以看一下bit_expr的产生式,它其实完全把加减乘数等运算符并列就行了。
|
||||
|
||||
```
|
||||
bit_expr :
|
||||
...
|
||||
| bit_expr '+' bit_expr %prec '+'
|
||||
| bit_expr '-' bit_expr %prec '-'
|
||||
| bit_expr '*' bit_expr %prec '*'
|
||||
| bit_expr '/' bit_expr %prec '/'
|
||||
...
|
||||
| simple_expr
|
||||
|
||||
```
|
||||
|
||||
如果你只是用到加减乘除的运算,那就可以不用在产生式的后面加%prec这个标记。但由于加减乘除这几个还可以用在其他地方,比如“-a”可以用来表示把a取负值;减号可以用在一元表达式当中,这会比用在二元表达式中有更高的优先级。也就是说,为了区分同一个Token在不同上下文中的优先级,我们可以用%prec,来说明该优先级是[上下文依赖](https://www.gnu.org/software/bison/manual/html_node/Contextual-Precedence.html)的。
|
||||
|
||||
好了,在了解了词法分析器和语法分析器以后,我们接着来跟踪一下MySQL的执行,看看编译器所生成的解析树和AST是什么样子的。
|
||||
|
||||
在sql_class.cc的sql_parser()方法中,编译器执行完解析程序之后,会返回解析树的根节点root,在GDB中通过p命令,可以逐步打印出整个解析树。你会看到,它的根节点是一个PT_select_stmt指针(见图3)。
|
||||
|
||||
解析树的节点是在语法规则中规定的,这是一些C++的代码,它们会嵌入到语法规则中去。
|
||||
|
||||
下面展示的这个语法规则就表明,编译器在解析完query_expression规则以后,要创建一个PT_query_expression的节点,其构造函数的参数分别是三个子规则所形成的节点。对于query_expression_body和query_primary这两个规则,它们会直接把子节点返回,因为它们都只有一个子节点。这样就会简化解析树,让它更像一棵AST。关于AST和解析树(也叫CST)的区别,我在解析Python的编译器中讲过了,你可以回忆一下。
|
||||
|
||||
```
|
||||
query_expression:
|
||||
query_expression_body
|
||||
opt_order_clause
|
||||
opt_limit_clause
|
||||
{
|
||||
$$ = NEW_PTN PT_query_expression($1, $2, $3); /*创建节点*/
|
||||
}
|
||||
| ...
|
||||
|
||||
query_expression_body:
|
||||
query_primary
|
||||
{
|
||||
$$ = $1; /*直接返回query_primary的节点*/
|
||||
}
|
||||
| ...
|
||||
|
||||
query_primary:
|
||||
query_specification
|
||||
{
|
||||
$$= $1; /*直接返回query_specification的节点*/
|
||||
}
|
||||
| ...
|
||||
|
||||
|
||||
```
|
||||
|
||||
最后,对于“select first_name, last_name from employees”这样一个简单的SQL语句,它所形成的解析树如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/26/007f91d9f3fe4c3349722201bec44226.jpg" alt="">
|
||||
|
||||
而对于“select 2 + 3”这样一个做表达式计算的SQL语句,所形成的解析树如下。你会看到,它跟普通的高级语言的表达式的AST很相似:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/da/db/da090cf1095e2aef738a69a5851ffcdb.jpg" alt="">
|
||||
|
||||
图4中的PT_query_expression等类,就是解析树的节点,它们都是Parse_tree_node的子类(PT是Parse Tree的缩写)。这些类主要定义在sql/parse_tree_nodes.h和parse_tree_items.h文件中。
|
||||
|
||||
其中,Item代表了与“值”有关的节点,它的子类能够用于表示字段、常量和表达式等。你可以通过Item的val_int()、val_str()等方法获取它的值。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/04/cfa126a6144186deafe7d9caff56f304.jpg" alt="">
|
||||
|
||||
由于SQL是一个个单独的语句,所以select、insert、update等语句,它们都各自有不同的根节点,都是Parse_tree_root的子类。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4a/2a/4a1152566c2ccab84d2f5022f44a022a.jpg" alt="">
|
||||
|
||||
好了,现在你就已经了解了SQL的解析过程和它所生成的AST了。前面我说过,MySQL采用的是LALR算法,因此我们可以借助MySQL编译器,来加深一下对LR算法家族的理解。
|
||||
|
||||
## 重温LR算法
|
||||
|
||||
你在阅读yacc.yy文件的时候,在注释里,你会发现如何跟踪语法分析器的执行过程的一些信息。
|
||||
|
||||
你可以用下面的命令,带上“-debug”参数,来启动MySQL服务器:
|
||||
|
||||
```
|
||||
mysqld --debug="d,parser_debug"
|
||||
|
||||
```
|
||||
|
||||
然后,你可以通过客户端执行一个简单的SQL语句:“select 2+3*5”。在终端,会输出语法分析的过程。这里我截取了一部分界面,通过这些输出信息,你能看出LR算法执行过程中的移进、规约过程,以及工作区内和预读的信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/69/91/69e4644e93a5156a6695eff41d162891.jpg" alt="">
|
||||
|
||||
我来给你简单地复现一下这个解析过程。
|
||||
|
||||
**第1步,编译器处于状态0,并且预读了一个select关键字。**你已经知道,LR算法是基于一个DFA的。在这里的输出信息中,你能看到某些状态的编号达到了一千多,所以这个DFA还是比较大的。
|
||||
|
||||
**第2步,把select关键字移进工作区,并进入状态42。**这个时候,编译器已经知道后面跟着的一定是一个select语句了,也就是会使用下面的语法规则:
|
||||
|
||||
```
|
||||
query_specification:
|
||||
...
|
||||
| SELECT_SYM /*select关键字*/
|
||||
select_options /*distinct等选项*/
|
||||
select_item_list /*select项列表*/
|
||||
opt_from_clause /*可选:from子句*/
|
||||
opt_where_clause /*可选:where子句*/
|
||||
opt_group_clause /*可选:group子句*/
|
||||
opt_having_clause /*可选:having子句*/
|
||||
opt_window_clause /*可选:window子句*/
|
||||
;
|
||||
|
||||
|
||||
```
|
||||
|
||||
为了给你一个直观的印象,这里我画了DFA的局部示意图(做了一定的简化),如下所示。你可以看到,在状态42,点符号位于“select”关键字之后、select_options之前。select_options代表了“distinct”这样的一些关键字,但也有可能为空。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/0b/474af6c5761e157cb82987fcd87a3c0b.jpg" alt="">
|
||||
|
||||
**第3步,因为预读到的Token是一个数字(NUM),这说明select_options产生式一定生成了一个ε,因为NUM是在select_options的Follow集合中。**
|
||||
|
||||
这就是LALR算法的特点,它不仅会依据预读的信息来做判断,还要依据Follow集合中的元素。所以编译器做了一个规约,也就是让select_options为空。
|
||||
|
||||
也就是,编译器依据“select_options->ε”做了一次规约,并进入了新的状态920。**注意**,状态42和920从DFA的角度来看,它们是同一个大状态。而DFA中包含了多个小状态,分别代表了不同的规约情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/e1/8f2444e7c1f100485d679cc543073de1.jpg" alt="">
|
||||
|
||||
**你还需要注意**,这个时候,老的状态都被压到了栈里,所以栈里会有0和42两个状态。栈里的这些状态,其实记录了推导的过程,让我们知道下一步要怎样继续去做推导。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c3/76/c3a585e8a1c3753137ff83fac5368576.jpg" alt="">
|
||||
|
||||
**第4步,移进NUM。**这时又进入一个新状态720。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/b9/048df36542d61ba8f8f688c58e00a3b9.jpg" alt="">
|
||||
|
||||
而旧的状态也会入栈,记录下推导路径:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/51/bcd744e5d278ce37d0abb1583ceccb51.jpg" alt="">
|
||||
|
||||
**第5~8步,依次依据NUM_literal->NUM、literal->NUM_literal、simple_expr->literal、bit_expr->simple_expr这四条产生式做规约。**这时候,编译器预读的Token是+号,所以你会看到,图中的红点停在+号前。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/33/7a/33b3f6b88214412b6d29b2ce2b03dc7a.jpg" alt="">
|
||||
|
||||
**第9~10步,移进+号和NUM**。这个时候,状态又重新回到了720。这跟第4步进入的状态是一样的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/8d/e3976970cd368c8e9c1547bbc2c6f48d.jpg" alt="">
|
||||
|
||||
而栈里的目前有5个状态,记录了完整的推导路径。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/55/142e374173e90ba657579f67566bb755.jpg" alt="">
|
||||
|
||||
到这里,其实你就已经了解了LR算法做移进和规约的思路了。不过你还可以继续往下研究。由于栈里保留了完整的推导路径,因此MySQL编译器最后会依次规约回来,把栈里的元素清空,并且形成一棵完整的AST。
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲,我带你初步探索了MySQL编译SQL语句的过程。你需要记住几个关键点:
|
||||
|
||||
- **掌握如何用GDB来跟踪MySQL的执行的方法**。你要特别注意的是,我给你梳理的那些关键的程序入口,它是你理解MySQL运行过程的地图。
|
||||
- SQL语言是**面向关系数据库的一种DSL,它是声明式的**,并采用了领域特定的模型和术语,可以为你设计自己的DSL提供启发。
|
||||
- **MySQL的语法分析器是采用bison工具生成的**。这至少说明,语法分析器生成工具是很有用的,连正式的数据库系统都在使用它,所以你也可以大胆地使用它,来提高你的工作效率。我在最后的参考资料中给出了bison的手册,希望你能自己阅读一下,做一些简单的练习,掌握bison这个工具。
|
||||
- 最后,**你一定要知道LR算法的运行原理**,知其所以然,这也会更加有助于你理解和用好工具。
|
||||
|
||||
我依然把本讲的内容给你整理成了一张知识地图,供你参考和复习回顾:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/04/5b/04cc0ce4fb5d78d7d9aa18e03088f95b.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们今天讲到了DSL的概念。你能分享一下你的工作领域中的DSL吗?它们是否也是采用声明式的?里面用到了哪些特定领域的术语?欢迎在留言区分享。
|
||||
|
||||
感谢你的阅读。如果有收获,欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. MySQL的内行手册([MySQL Internals Manual](https://dev.mysql.com/doc/internals/en/))能提供一些重要的信息。但我发现文档内容经常跟源代码的版本不同步,比如介绍源代码的目录结构的信息就过时了,你要注意这点。
|
||||
1. bison的[手册](http://www.gnu.org/software/bison/manual/)。
|
||||
@@ -0,0 +1,220 @@
|
||||
<audio id="audio" title="26 | MySQL编译器(二):编译技术如何帮你提升数据库性能?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a4/12/a4fd5b8320728c2358f4c08996702112.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天这一讲,我们继续来探究MySQL编译器。
|
||||
|
||||
通过上一讲的学习,你已经了解了MySQL编译器是怎么做词法和语法分析的了。那么在做完语法分析以后,MySQL编译器又继续做了哪些处理,才能成功地执行这个SQL语句呢?
|
||||
|
||||
所以今天,我就带你来探索一下MySQL的实现机制,我会把重点放在SQL的语义分析和优化机制上。当你学完以后,你就能真正理解以下这些问题了:
|
||||
|
||||
- 高级语言的编译器具有语义分析功能,那么MySQL编译器也会做语义分析吗?它有没有引用消解问题?有没有作用域?有没有类型检查?
|
||||
- MySQL有没有类似高级语言的那种优化功能呢?
|
||||
|
||||
好,让我们开始今天的探究吧。不过,在讨论MySQL的编译过程之前,我想先带你了解一下MySQL会用到的一些重要的数据结构,因为你在解读代码的过程中经常会见到它们。
|
||||
|
||||
## 认识MySQL编译器的一些重要的数据结构
|
||||
|
||||
**第一组数据结构**,是下图中的几个重要的类或结构体,包括线程、保存编译上下文信息的LEX,以及保存编译结果SELECT_LEX_UNIT和SELECT_LEX。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/b7/ccd4a2dcae0c974b0b5254c51440f9b7.jpg" alt="">
|
||||
|
||||
**首先是THD,也就是线程对象。**对于每一个客户端的连接,MySQL编译器都会启动一个线程来处理它的查询请求。
|
||||
|
||||
THD中的一个重要数据成员是**LEX对象**。你可以把LEX对象想象成是编译SQL语句的工作区,保存了SQL语句编译过程中的上下文信息,编译器会把编译的成果放在这里,而编译过程中所需要的信息也是从这里查找。
|
||||
|
||||
在把SQL语句解析完毕以后,编译器会形成一些结构化的对象来表示一个查询。其中**SELECT_LEX_UNIT结构体**,就代表了一个**查询表达式**(Query Expression)。一个查询表达式可能包含了多个查询块,比如使用UNION的情况。
|
||||
|
||||
而**SELECT_LEX**则代表一个**基本的查询块**(Query Block),它里面的信息包括了所有的列和表达式、查询用到的表、where条件等。在SELECT_LEX中会保存查询块中涉及的表、字段和表达式等,它们也都有对应的数据结构。
|
||||
|
||||
**第二组需要了解的数据结构**,是表示表、字段等信息的对象。**Table_ident对象**保存了表的信息,包括数据库名、表名和所在的查询语句(SELECT_LEX_UNIT对象)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/10/d0/10567f1112bf04c0240c0ebc277067d0.jpg" alt="">
|
||||
|
||||
而字段和表达式等表示一个值的对象,用Item及其子类来表示。SQL语句中的每个字段、每个计算字段,最后都对应一个Item。where条件,其实也是用一个Item就能表示。具体包括:
|
||||
|
||||
- 字段(Item_field)。
|
||||
- 各种常数,包括数字、字符和null等(Item_basic_constant)。
|
||||
- 能够产生出值的运算(Item_result_field),包括算术表达式(Item_num_op)、存储过程(Item_func_sp)、子查询(Item_subselect)等。
|
||||
- 在语法分析过程中产生的Item(Parse_tree_item)。它们是一些占位符,因为在语法分析阶段,不容易一下子创建出真正的Item,这些Parse_tree_item需要在上下文分析阶段,被替换成真正的Item。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/c3/7aed46a44dcfb3bb6908db30cyy815c3.jpg" alt="">
|
||||
|
||||
好了,上面这些就是MySQL会用到的最核心的一些数据结构了。接下来的编译工作,就会生成和处理上述的数据结构。
|
||||
|
||||
## 上下文分析
|
||||
|
||||
我们先来看一下MySQL编译器的上下文分析工作。
|
||||
|
||||
你已经知道,语法分析仅仅完成的是上下文无关的分析,还有很多的工作,需要基于上下文来做处理。这些工作,就属于语义分析。
|
||||
|
||||
MySQL编译器中,每个AST节点,都会有一个**contextualize()方法**。从这个方法的名称来看,你就能知道它是做上下文处理的(contextualize,置于上下文中)。
|
||||
|
||||
对一个Select语句来说,编译器会调用其根节点PT_select_stmt的contextualize()方法,从而深度遍历整个AST,并调用每个节点的contextualize()方法。
|
||||
|
||||
**那么,MySQL编译器的上下文处理,都完成了什么工作呢?**
|
||||
|
||||
**首先,是检查数据库名、表名和字段名是否符合格式要求(在table.cc中实现)。**
|
||||
|
||||
比如,MySQL会规定表名、字段名等名称不能超过64个字符,字段名不能包含ASCII值为255的字符,等等。这些规则在词法分析阶段是不检查的,要留在语义分析阶段检查。
|
||||
|
||||
**然后,创建并填充SELECT_LEX_UNIT和SELECT_LEX对象。**
|
||||
|
||||
前面我提到了,SELECT_LEX_UNIT和SELECT_LEX中,保存了查询表达式和查询块所需的所有信息,依据这些信息,MySQL就可以执行实际的数据库查询操作。
|
||||
|
||||
那么,在contextualize的过程中,编译器就会生成上述对象,并填充它们的成员信息。
|
||||
|
||||
比如,对于查询中用到的表,在语法分析阶段就会生成Table_ident对象。但其中的数据库名称可能是缺失的,那么在上下文的分析处理当中,就会被编译器设置成当前连接所采用的默认数据库。这个信息可以从线程对象(THD)中获得,因为每个线程对应了一个数据库连接,而每个数据库连接是针对一个具体的数据库的。
|
||||
|
||||
好了,经过上下文分析的编译阶段以后,我们就拥有了可以执行查询的SELECT_LEX_UNIT和SELECT_LEX对象。可是,你可能会注意到一个问题:**为什么在语义分析阶段,MySQL没有做引用的消解呢?**不要着急,接下来我就给你揭晓这个答案。
|
||||
|
||||
## MySQL是如何做引用消解的?
|
||||
|
||||
我们在SQL语句中,会用到数据库名、表名、列名、表的别名、列的别名等信息,编译器肯定也需要检查它们是不是正确的。这就是引用消解(或名称消解)的过程。一般编译器是在语义分析阶段来做这项工作的,而MySQL是在执行SQL命令的时候才做引用消解。
|
||||
|
||||
引用消解的入口是在SQL命令的的prepare()方法中,它会去检查表名、列名都对不对。
|
||||
|
||||
通过GDB调试工具,我们可以跟踪编译器做引用消解的过程。你可以在my_message_sql()函数处设个断点,然后写个SQL语句,故意使用错误的表名或者列名,来看看MySQL是在什么地方检查出这些错误的。
|
||||
|
||||
比如说,你可以执行“select * from fake_table”,其中的fake_table这个表,在数据库中其实并不存在。
|
||||
|
||||
下面是打印出的调用栈。你会注意到,MySQL在准备执行SQL语句的过程中,会试图去打开fake_table表,这个时候编译器就会发现这个表不存在。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9c/9a/9c2e987a6fc797926593cd1f6c001d9a.jpg" alt="">
|
||||
|
||||
你还可以再试一下“select fake_column from departments”这个语句,也一样会查出,fake_column并不是departments表中的一列。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/9f/a283dcc105f240c6760a78913a4d069f.jpg" alt="">
|
||||
|
||||
**那么,MySQL是如何知道哪些表和字段合法,哪些不合法的呢?**
|
||||
|
||||
原来,它是通过查表的定义,也就是数据库模式信息,或者可以称为数据字典、元数据。MySQL在一个专门的库中,保存了所有的模式信息,包括库、表、字段、存储过程等定义。
|
||||
|
||||
你可以跟高级语言做一下类比。高级语言,比如说Java也会定义一些类型,类型中包含了成员变量。那么,MySQL中的表,就相当于高级语言的类型;而表的字段(或列)就相当于高级语言的类型中的成员变量。所以,在这个方面,MySQL和高级语言做引用消解的思路其实是一样的。
|
||||
|
||||
但是,**高级语言在做引用消解的时候有作用域的概念,那么MySQL有没有类似的概念呢?**
|
||||
|
||||
有的。举个例子,假设一个SQL语句带了子查询,那么子查询中既可以引用本查询块中的表和字段,也可以引用父查询中的表和字段。这个时候就存在了两个作用域,比如下面这个查询语句:
|
||||
|
||||
```
|
||||
select dept_name from departments where dept_no in
|
||||
(select dept_no from dept_emp
|
||||
where dept_name != 'Sales' #引用了上一级作用域中的字段
|
||||
group by dept_no
|
||||
having count(*)> 20000)
|
||||
|
||||
```
|
||||
|
||||
其中的dept_name字段是dept_emp表中所没有的,它其实是上一级作用域中departments表中的字段。
|
||||
|
||||
提示:这个SQL当然写得很不优化,只是用来表现作用域的概念。
|
||||
|
||||
好。既然要用到作用域,那么MySQL的作用域是怎么表示的呢?
|
||||
|
||||
这就要用到**Name_resolution_context对象**。这个对象保存了当前作用域中的表,编译器可以在这些表里查找字段;它还保存了**对外层上下文的引用**(outer_context),这样MySQL就可以查找上一级作用域中的表和字段。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/40/a8/40114ec45281b96a0c34fd0a237251a8.jpg" alt="">
|
||||
|
||||
好了,现在你就对MySQL如何做引用消解非常了解了。
|
||||
|
||||
我们知道,对于高级语言的编译器来说,接下来它还会做一些优化工作。**那么,MySQL是如何做优化的呢?它跟高级语言编译器的优化工作相比,又有什么区别呢?**
|
||||
|
||||
## MySQL编译器的优化功能
|
||||
|
||||
MySQL编译器的优化功能主要都在sql_optimizer.cc中。就像高级语言一样,MySQL编译器会支持一些常见的优化。我来举几个例子。
|
||||
|
||||
**第一个例子是常数传播优化**(const propagation)。假设有一个表foo,包含了x和y两列,那么SQL语句:“select * from foo where x = 12 and y=x”,会被优化成“select * from foo where x = 12 and y = 12”。你可以在propagate_cond_constants()函数上加个断点,查看常数传播优化是如何实现的。
|
||||
|
||||
**第二个例子是死代码消除**。比如,对于SQL语句:“select * from foo where x=2 and y=3 and x<y”,编译器会把它优化为“select * from foo where x=2 and y=3”,把“x<y”去掉了,这是因为x肯定是小于y的。该功能的实现是在remove_eq_conds()中。
|
||||
|
||||
**第三个例子是常数折叠**。这个优化工作我们应该很熟悉了,主要是对各种条件表达式做折叠,从而降低计算量。其实现是在sql_const_folding.cc中。
|
||||
|
||||
你要注意的是,上述的优化主要是针对条件表达式。因为MySQL在执行过程中,对于每一行数据,可能都需要执行一遍条件表达式,所以上述优化的效果会被放大很多倍,这就好比针对循环体的优化,是一个道理。
|
||||
|
||||
不过,**MySQL还有一种特殊的优化,是对查询计划的优化**。比如说,我们要连接employees、dept_emp和departments三张表做查询,数据库会怎么做呢?
|
||||
|
||||
最笨的办法,是针对第一张表的每条记录,依次扫描第二张表和第三张表的所有记录。这样的话,需要扫描多少行记录呢?是三张表的记录数相乘。基于我们的示例数据库的情况,这个数字是**8954亿**。
|
||||
|
||||
上述计算其实是做了一个**笛卡尔积**,这会导致处理量的迅速上升。而在数据库系统中,显然不需要用这么笨的方法。
|
||||
|
||||
你可以用explain语句,让MySQL输出执行计划,下面我们来看看MySQL具体是怎么做的:
|
||||
|
||||
```
|
||||
explain select employees.emp_no, first_name,
|
||||
departments.dept_no dept_name
|
||||
from employees, dept_emp, departments
|
||||
where employees.emp_no = dept_emp.emp_no
|
||||
and dept_emp.dept_no = departments.dept_no;
|
||||
|
||||
```
|
||||
|
||||
这是MySQL输出的执行计划:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/13/b0e1e8e2b1f9ef11dbd0150b8ae2cd13.jpg" alt="">
|
||||
|
||||
从输出的执行计划里,你能看出,MySQL实际的执行步骤分为了3步:
|
||||
|
||||
- 第1步,通过索引,遍历departments表;
|
||||
- 第2步,通过引用关系(ref),找到dept_emp表中,dept_no跟第1步的dept_no相等的记录,平均每个部门在dept_emp表中能查到3.7万行记录;
|
||||
- 第3步,基于第2步的结果,通过等值引用(eq_ref)关系,在employees表中找到相应的记录,每次找到的记录只有1行。这个查找可以通过employees表的主键进行。
|
||||
|
||||
根据这个执行计划来完成上述的操作,编译器只需要处理大约63万行的数据。因为通过索引查数据相比直接扫描表来说,处理每条记录花费的时间比较长,所以我们假设前者花费的时间是后者的3倍,那么就相当于扫描了63*3=189万行表数据,这仍然仅仅相当于做笛卡尔积的47万分之一。**我在一台虚拟机上运行该SQL花费的时间是5秒,而如果使用未经优化的方法,则需要花费27天!**
|
||||
|
||||
通过上面的例子,你就能直观地理解做查询优化的好处了。MySQL会通过一个JOIN对象,来为一个查询块(SELECT_LEX)做查询优化,你可以阅读JOIN的方法,来看看查询优化的具体实现。关于查询优化的具体算法,你需要去学习一下数据库的相关课程,我在本讲末尾也推荐了一点参考资料,所以我这里就不展开了。
|
||||
|
||||
从编译原理的角度来看,我们可以把查询计划中的每一步,看做是一条指令。MySQL的引擎,就相当于能够执行这些指令的一台虚拟机。
|
||||
|
||||
如果再做进一步了解,你就会发现,**MySQL的执行引擎和存储引擎是分开的**。存储引擎提供了一些基础的方法(比如通过索引,或者扫描表)来获取表数据,而做连接、计算等功能,是在MySQL的执行引擎中完成的。
|
||||
|
||||
好了,现在你就已经大致知道了,一条SQL语句从解析到执行的完整过程。但我们知道,普通的高级语言在做完优化后,生成机器码,这样性能更高。那么,**是否可以把SQL语句编译成机器码,从而获得更高的性能呢?**
|
||||
|
||||
## 能否把SQL语句编译成机器码?
|
||||
|
||||
MySQL编译器在执行SQL语句的过程中,除了查找数据、做表间连接等数据密集型的操作以外,其实还有一些地方是需要计算的。比如:
|
||||
|
||||
- **where条件**:对每一行扫描到的数据都需要执行一次。
|
||||
- **计算列**:有的列是需要计算出来的。
|
||||
- **聚合函数**:像sum、max、min等函数,也是要对每一行数据做一次计算。
|
||||
|
||||
在研究MySQL的过程中,你会发现上述计算都是解释执行的。MySQL做解释执行的方式,基本上就是深度遍历AST。比如,你可以对代表where条件的Item求值,它会去调用它的下级节点做递归的计算。这种计算过程和其他解释执行的语言差不多,都是要在运行时判断数据的类型,进行必要的类型转换,最后执行特定的运算。因为很多的判断都要在运行时去做,所以程序运行的性能比较低。
|
||||
|
||||
另外,由于MySQL采用的是解释执行机制,所以它在语义分析阶段,其实也没有做类型检查。在编译过程中,不同类型的数据在运算的时候,会自动进行类型转换。比如,执行“`select'2' + 3`”,MySQL会输出5,这里就把字符串`'2'`转换成了整数。
|
||||
|
||||
**那么,我们能否把这些计算功能编译成本地代码呢?**
|
||||
|
||||
因为我们在编译期就知道每个字段的数据类型了,所以编译器其实是可以根据这些类型信息,生成优化的代码,从而提升SQL的执行效率。
|
||||
|
||||
这种思路理论上是可行的。不过,目前我还没有看到MySQL在这方面的工作,而是发现了另一个数据库系统PostgreSQL,做了这方面的优化。
|
||||
|
||||
PostgreSQL的团队发现,如果解释执行下面的语句,表达式计算所用的时间,占到了处理一行记录所需时间的56%。而基于LLVM实现JIT以后(编译成机器码执行),所用的时间只占到总执行时间的6%,这就使得SQL执行的整体性能整整提高了一倍。
|
||||
|
||||
```
|
||||
select count(*) from table_name where (x + y) > 100
|
||||
|
||||
```
|
||||
|
||||
中国用户对MySQL的用量这么大,如果能做到上述的优化,那么仅仅因此而减少的碳排放,就是一个很大的成绩!所以,你如果有兴趣的话,其实可以在这方面尝试一下!
|
||||
|
||||
## 课程小结
|
||||
|
||||
这一讲我们分析了MySQL做语义分析、优化和执行的原理,并探讨了一下能否把SQL编译成本地代码的问题。你要记住以下这些要点:
|
||||
|
||||
- MySQL也会做上下文分析,并生成能够代表SQL语句的内部数据结构;
|
||||
- MySQL做引用消解要基于数据库模式信息,并且也支持作用域;
|
||||
- MySQL会采用常数传播等优化方法,来优化查询条件,并且要通过查询优化算法,形成高效的执行计划;
|
||||
- 把SQL语句编译成机器码,会进一步提升数据库的性能,并降低能耗。
|
||||
|
||||
我把相应的知识点总结成了思维导图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/61/d8dfbb4d207cd6a27fffe8ab860f6861.jpg" alt="">
|
||||
|
||||
总结这两讲对MySQL所采用的编译技术介绍,你会发现这样几个很有意思的地方:
|
||||
|
||||
- 第一,编译技术确实在数据库系统中扮演了很重要的作用。
|
||||
- 第二,数据库编译SQL语句的过程与高级语言有很大的相似性,它们都包含了词法分析、语法分析、语义分析和优化等处理。你对编译技术的了解,能够指导你更快地看懂MySQL的运行机制。另外,如果你也要设计类似的系统级软件,这就是一个很好的借鉴。
|
||||
|
||||
## 一课一思
|
||||
|
||||
关系数据库是通过内置的DSL编译器来驱动运行的软件。那么,你还知道哪些软件是采用这样的机制来运行的?你如果去实现这样的软件,能从MySQL的实现思路里借鉴哪些思路?欢迎在留言区分享你的观点。
|
||||
|
||||
## 参考资料
|
||||
|
||||
如果要加深对MySQL内部机制的了解,我推荐两本书:一本是OReilly的《Understanding MySQL Internals》,第二本是《Expert MySQL》。
|
||||
158
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/热点问题答疑 | 如何吃透7种真实的编译器?.md
Normal file
158
极客时间专栏/geek/编译原理实战课/真实编译器解析篇/热点问题答疑 | 如何吃透7种真实的编译器?.md
Normal file
@@ -0,0 +1,158 @@
|
||||
<audio id="audio" title="热点问题答疑 | 如何吃透7种真实的编译器?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/99/47/9996c4f40442dc71ebbee2a962ce6547.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
到这里,咱们就已经解析完7个编译器了。在这个过程中,你可能也积累了不少问题。所以今天这一讲,我就把其中有代表性的问题,给你具体分析一下。这样,能帮助你更好地掌握本课程的学习思路。
|
||||
|
||||
## 问题1:如何真正吃透课程中讲到的7种编译器?
|
||||
|
||||
在课程中,我们是从解析实际编译器入手的。而每一个真实的编译器里面都包含了大量的实战技术和知识点,所以你在学习的时候,很容易在某个点被卡住。那第一个问题,我想先给你解答一下,“真实编译器解析篇”这个模块的学习方法。
|
||||
|
||||
我们知道,学习知识最好能找到一个比较缓的坡,让自己可以慢慢爬上去,而不是一下子面对一面高墙。**那么对于研究真实编译器,这个缓坡是什么呢?**
|
||||
|
||||
我的建议是,你可以把掌握一个具体的编译器的目标,分解成四个级别的任务,逐步提高难度,直到最后吃透。
|
||||
|
||||
**第一个级别,就是听一听文稿,看一看文稿中给出的示例程序和源代码的链接就可以了。**
|
||||
|
||||
这个级别最重要的目标是什么?是掌握我给你梳理出来的这个编译器的技术主线,掌握一张地图,这样你就能有一个宏观且直观的把握,并且能增强你对编译原理的核心基础知识点的认知,就可以了。
|
||||
|
||||
小提示:关于编译器的技术主线和知识地图,你可以期待一下在期中复习周中,即将发布的“划重点:7种编译器的核心概念和算法”内容。
|
||||
|
||||
在这个基础上,如果你还想再进一步,那么就可以挑战第二级的任务。
|
||||
|
||||
**第二个级别,是要动手做实验。**
|
||||
|
||||
你可以运行一下我给出的那些使用编译器的命令,打印输出调试信息,或者使用一下课程中提到的图形化工具。
|
||||
|
||||
比如,在Graal和V8编译器,你可以通过修改命令行的参数,观察生成的IR是什么样子。这样你就可以了解到,什么情况下才会触发即时编译、什么时候才会触发内联优化、生成的汇编代码是什么样子的,等等。
|
||||
|
||||
这样,通过动手做练习,你对这些编译器的认识就会更加具体,并且会有一种自己可以驾驭的感觉,赢得信心。
|
||||
|
||||
**第三个级别,是看源代码,并跟踪源代码的运行过程,从而进入到编译器的内部,去解析一个编译器的真相。**
|
||||
|
||||
完成这一级的任务,对你动手能力的要求更高。你最容易遇到的问题,是搭建一个调试环境。比如,调试Graal编译器要采用远程调试的模式,跟你调试一个普通应用还是不大一样的。而采用GDB、LLDB这样的工具,对很多同学来说可能也是一个挑战。
|
||||
|
||||
而且,你在编译源代码和调试的过程中也会遇到很多与配置有关的问题。比如,我用GDB来调试Julia和MySQL的时候,就发现最好是使用一个Linux虚拟机,因为macOS对GDB的支持不够好。
|
||||
|
||||
不过,上述困难都不是说真的有多难,而是需要你的耐心。遇到问题就解决问题,最终搭建出一个你能驾驭的环境,这个过程也会大大提升你的动手实践能力。
|
||||
|
||||
环境搭建好了,在跟踪程序执行的过程中,一样要需要耐心。你可能要跟踪执行很多步,才能梳理出程序的执行脉络和实现思路。我在课程中建议的那些断点的位置和梳理出来程序的入口,可以给你提供一些帮助。
|
||||
|
||||
可以说,只要你能做好第三级的工作,终归是能吃透编译器的运行机制的。这个时候,你其实已经差不多进入了高手的行列。比如,在实际编程工作中,当遇到一个特别棘手的问题的时候,你可以跟踪到编译器、虚拟机的内部实现机制上去定位和解决问题。
|
||||
|
||||
而我前面说了,掌握一个具体的编译器的目标,是有四个级别的任务。那你可能要问,都能剖析源代码了,还要进一步挑战什么呢?
|
||||
|
||||
**这第四个级别呢,就是把代码跟编译原理和算法结合起来,实现认识的升华。**
|
||||
|
||||
在第三级,当你阅读和跟踪程序执行的时候,会遇到一个认知上的挑战。对于某些程序,你每行代码都能看懂,但为什么这么写,你其实不明白。
|
||||
|
||||
像编译器这样的软件,在解决每一个关键问题的时候,肯定都是有理论和算法支撑的。这跟我们平常写一些应用程序不大一样,这些应用程序很少会涉及到比较深入的原理和算法。
|
||||
|
||||
我举个例子,在讲Java编译器中的[语法分析器](https://time.geekbang.org/column/article/252828)的时候,我提到几点。第一,它是用递归下降算法的;第二,它在避免左递归时,采用了经典的文法改写的方法;第三,在处理二元表达式时,采用了运算符优先级算法,它是一种简单的LR算法。
|
||||
|
||||
我提到的这三点中的每一点,都是一个编译原理的知识点或算法。如果对这些理论没有具体的了解,那你看代码的时候就看不出门道来。类似的例子还有很多。
|
||||
|
||||
所以,如果你其实在编译原理的基础理论和算法上都有不错的素养的话,你会直接带着自己的假设去代码里进行印证,这样你就会发现每段程序,其实都是有一个算法去对应的,这样你就真的做到融会贯通了。
|
||||
|
||||
**那如何才能达到第四级的境界,如何才能理论和实践兼修且互相联系呢?**
|
||||
|
||||
- 第一,你要掌握“预备知识”模块中的编译原理核心基础知识和算法。
|
||||
- 第二,你要阅读相关的论文和设计文档。有一些论文是一些经典的、奠基性的论文。比如,在讲[Sea of Nodes类型的IR](https://time.geekbang.org/column/article/256914)的时候,我介绍了三篇重要的论文,需要你去看。还有一些论文或设计文档是针对某个编译器的具体的技术点的,这些论文对于你掌握该编译器的设计思路也很有帮助。
|
||||
|
||||
达到第四级的境界,你其实已经可以参与编译器的开发,并能成为该领域的技术专家了。针对某个具体的技术点加以研究和钻研,你也可以写出很有见地的论文。
|
||||
|
||||
当然,我不会要求每个同学都成为一个编译器的专家,因为这真的要投入大量的精力和实践。你可以根据自己的技术领域和发展规划,设定自己的目标。
|
||||
|
||||
**我的建议是:**
|
||||
|
||||
1. 首先,每个同学肯定要完成第一级的目标。这一级目标的要求是能理解主线,有时候要多读几遍才行。
|
||||
1. 对于第二级目标,我建议你针对2~3门你感兴趣的语言,上手做一做实验。
|
||||
1. 对于第三级目标,我希望你能够针对1门语言,去做一下深入探索,找一找跟踪调试一个编译器、甚至修改编译器的源代码的感觉。
|
||||
1. 对于第四级目标,我希望你能够针对那些常见的编译原理算法,比如前端的词法分析、语法分析,能够在编译器里找到并理解它们的实现。至于那些更加深入的算法,可以作为延伸任务。
|
||||
|
||||
总的来说呢,“真实编译器”这个模块的课程内容,为你的学习提供了开放式的各种可能性。
|
||||
|
||||
好,接下来,我就针对同学们的提问和课程的思考题,来做一下解析。
|
||||
|
||||
## 问题2:多重分派是泛型实现吗?
|
||||
|
||||
>
|
||||
@d:“多重分派能够根据方法参数的类型,确定其分派到哪个实现。它的优点是容易让同一个操作,扩展到支持不同的数据类型。”宫老师,多重分派是泛型实现吗?
|
||||
|
||||
|
||||
由于大多数同学目前使用的语言,采用的都是面向对象的编程范式,所以会比较熟悉像这样的一种函数或方法派发的方式:
|
||||
|
||||
```
|
||||
Mammal mammal = new Cow(); //Cow是Mammal的一个子类
|
||||
mammal.speak();
|
||||
|
||||
```
|
||||
|
||||
**这是调用了mammal的一个方法:speak。**那这个speak方法具体指的是哪个实现呢?根据面向对象的继承规则,这个方法可以是在Cow上定义的。如果Cow本身没有定义,就去它的父类中去逐级查找。所以,**speak()具体采用哪个实现,是完全由mammal对象的类型来确定的**。这就是单一分派。
|
||||
|
||||
我们认为,mammal对象实际上是speak方法的第一个参数,虽然在语法上,它并没有出现在参数列表中。而[Java的运行时机制](https://time.geekbang.org/column/article/257504),也确实是这么实现的。你可以通过查看编译生成的字节码或汇编代码来验证一下。你如果在方法中使用“this”对象,那么实际上访问的是方法的0号参数来获取对象的引用或地址。
|
||||
|
||||
在采用单一分派的情况下,对于二元(或者更多元)的运算的实现是比较别扭的,比如下面的整型和浮点型相加的方法,你需要在整型和浮点型的对象中,分别去实现整型加浮点型,以及浮点型加整型的计算:
|
||||
|
||||
```
|
||||
Integer a = 2;
|
||||
Float b = 3.1;
|
||||
a.add(b); //采用整型对象的add方法。
|
||||
b.add(a); //采用浮点型对象的add方法。
|
||||
|
||||
```
|
||||
|
||||
**但如果再增加新的类型怎么办呢?那么所有原有的类都要去修改,以便支持新的加法运算吗?**
|
||||
|
||||
多重分派的情况,就不是仅仅由第一个参数来确定函数的实现了,而是会依赖多个参数的组合。这就能很优雅地解决上述问题。在增加新的数据类型的时候,你只需要增加新的函数即可。
|
||||
|
||||
```
|
||||
add(Integer a, Float b);
|
||||
add(Float b, Integer a);
|
||||
add(Integer a, MyType b); //支持新的类型
|
||||
|
||||
```
|
||||
|
||||
不过,这里又有一个问题出现了。如果对每种具体的类型,都去实现一个函数的话,那么实现的工作量也很大。这个时候,我们就可以用上泛型了,或者叫参数化类型。
|
||||
|
||||
通过泛型的机制,我们可以让相同的实现逻辑只书写一次。在第三个模块“现代语言设计篇”中,专门有一讲给你进一步展开**泛型的实现机制**,到时你可以去深入学习下。
|
||||
|
||||
## 问题3:安全点是怎么回事?为什么编译器生成的某些汇编代码我看不懂?
|
||||
|
||||
>
|
||||
@智昂张智恩震:请问老师,和JVM握手就是插入safepoint的过程吗?具体的握手是在做什么?
|
||||
|
||||
|
||||
你在查看编译器生成的汇编代码的时候,经常会看到一些辅助性的代码逻辑。它们的作用不是要把你的代码翻译成汇编代码才生成的,而是要去实现一些运行时机制。
|
||||
|
||||
我举几个例子。
|
||||
|
||||
**第一个例子,是做逆优化。**比如V8中把函数编译成机器码,是基于对类型的推断。如果实际执行的时候,编译器发现类型跟推断不符,就要执行逆优化,跳转到解释器去执行。这个时候,你就会看到汇编代码里有一些指令,是用于做逆优化功能的。
|
||||
|
||||
**第二个例子,是在并行中会遇到的抢占式调度问题。**协程这种并发机制,是应用级的并发。一个线程上会有多个协程在运行。但是,如果其中一个协程的运行时间很长,就会占据太多的计算资源,让这个线程上的其他协程没有机会去运行。对于一些比较高级的协程调度器,比如Go语言的调度器,就能够把一个长时间运行的协程暂停下来,让其他协程来运行。怎么实现这种调度呢?那就要编译器在生成的代码里,去插入一些逻辑,配合调度器去做这种调度。
|
||||
|
||||
**第三个例子,是垃圾收集。**根据编译器所采用的垃圾收集算法,在进行垃圾收集时,可能会做内存的拷贝,把一个对象从一个地方拷贝到另一地方。这在某些情况下,会导致程序出错。比如,当你读一个Java对象的成员变量的值的时候,生成的汇编代码会根据对象的地址,加上一定的偏移量,得到该成员变量的地址。但这个时候,这个对象的地址被垃圾收集器改变了,那么程序的逻辑就错了。所以在做垃圾回收的时候,相关的线程一定要停在叫做“安全点(safepoint)”的地方,在这些地方去修改对象的地址,程序就不会出错。
|
||||
|
||||
@智昂张智恩震 同学提出的问题,就针对垃圾收集这种场景的。在Java生成的汇编代码里,程序要在安全点去跟运行时做一下互动(握手)。如果需要的话,当前线程就会被垃圾收集器停下,以便执行垃圾收集操作。
|
||||
|
||||
所以你看,只有了解了一门语言的运行时机制,才能懂得为什么要生成这样的代码。关于垃圾收集和并发机制,我也会在第三个模块中跟你去做进一步的探讨。
|
||||
|
||||
## 问题4:SSA只允许给变量赋一次值,循环中的变量是多次赋值的,不是矛盾了吗?
|
||||
|
||||
>
|
||||
@qinsi:关于思考题,SSA只允许给变量赋一次值,如果是循环的话就意味着要创建循环次数那么多的临时变量了?
|
||||
|
||||
|
||||
@qinsi 同学问的这个问题其实挺深入,也很有意思。
|
||||
|
||||
是这样的。我们在做编译的时候,大部分时候是做静态的分析,也就是仅仅基于程序从词法角度(Lexically)的定义,不看它运行时的状态。**注意**,我这里说的词法,不是指词法分析的词法,而是指程序文本中体现的“使用和定义”(use-def)关系、控制流等。词法作用域(Lexical Scope)中的词法,也是同一个意思。
|
||||
|
||||
所以,SSA中说的赋值,实际上是对该变量(或称作值)做了一个定义,体现了变量之间的“使用和定义”(use-def)关系,也就是体现了变量之间的数据依赖,或者说是数据流,因此可以用来做数据流分析,从而实现各种优化算法。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲的答疑,我首先帮你梳理了学习真实世界编译器的方法。一个真实的编译器里涉及的技术和知识点确实比较多,但有挑战就有应对方法。我给你梳理了四级的学习阶梯,你探索内容的多少,也可以根据自己的需求和兴趣来把握。按照这个学习路径,你既可以去做一些宏观的了解,也可以在某个具体点上去做深入,这是一个有弹性的学习体系。
|
||||
|
||||
另外,我也挑了几个有意思的问题做了解答,在解答中也对涉及的知识点做了延伸和扩展。其中一些知识点,我还会在第三个模块中做进一步的介绍,比如垃圾收集机制、并发机制,以及泛型等。等你学完第三个模块,再回头看实际编译器的时候,你的认知会再次迭代。
|
||||
|
||||
好,请你继续给我留言吧,我们一起交流讨论。同时我也希望你能多多地分享,做一个知识的传播者。感谢你的阅读,我们下一讲再见。
|
||||
10
极客时间专栏/geek/编译原理实战课/结束语/期末考试 | “编译原理实战课”100分试卷等你来挑战!.md
Normal file
10
极客时间专栏/geek/编译原理实战课/结束语/期末考试 | “编译原理实战课”100分试卷等你来挑战!.md
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
咱们课程到这里就算正式更新完了,在临近告别前,我还给你准备了一份期末考试题,这套试卷共有8道单选题和12道多选题,满分100,核心考点都出自前面讲到的所有重要知识,希望可以帮助你进行一场自测。
|
||||
|
||||
除此之外,我也很想知道你对这门课的建议,所以我也给你准备了一份[问卷](https://jinshuju.net/f/pcwmct)。欢迎你在问卷里聊一聊你的想法,也许就有机会获得礼物或者是课程阅码哦。
|
||||
|
||||
好了,话不多说,请你来做一做这套期末测试题吧!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=211&exam_id=637)
|
||||
101
极客时间专栏/geek/编译原理实战课/结束语/结束语 | 实战是唯一标准!.md
Normal file
101
极客时间专栏/geek/编译原理实战课/结束语/结束语 | 实战是唯一标准!.md
Normal file
@@ -0,0 +1,101 @@
|
||||
<audio id="audio" title="结束语 | 实战是唯一标准!" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/92/bc/92d025d4cdd9593ba1d758fd166054bc.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
转眼之间,“编译原理实战课”计划中的内容已经发布完毕了。在这季课程中,你的感受如何?有了哪些收获?遇到了哪些困难?
|
||||
|
||||
很多同学可能会觉得这一季的课程比上一季的“编译原理之美”要难一些。不过为什么一定要推出这么一门课,来研究实际编译器的实现呢?这是因为我相信,**实战是检验你是否掌握了编译原理的唯一标准,也是学习编译原理的真正目标。**
|
||||
|
||||
计算机领域的工程性很强。这决定了我们学习编译原理,不仅仅是掌握理论,而是要把它付诸实践。在我们学习编译原理的过程中,如果遇到内心有疑惑的地方,那不妨把实战作为决策的标准。
|
||||
|
||||
这些疑惑可能包括很多,比如:
|
||||
|
||||
- 词法分析和语法分析工具,应该手写,还是用工具生成?
|
||||
- 应该用LL算法,还是LR算法?
|
||||
- 后端应该用工具,还是自己手写?
|
||||
- 我是否应该学习后端?
|
||||
- IR应该用什么数据结构?
|
||||
- 寄存器分配采用什么算法比较好?
|
||||
- ……
|
||||
|
||||
上述问题,如果想在教科书里找到答案,哪怕是“读万卷书”,也是比较难的。而换一个思路,“行万里路”,那就很容易了。你会发现每种语言,因为其适用的领域和设计的目标不同,对于上述问题会采用不同的技术策略,而每种技术策略都有它的道理。从中,你不仅仅可以为上述问题找到答案,更重要的是学会权衡不同技术方案的考虑因素,从而对知识点活学活用起来。
|
||||
|
||||
我们说实战是标准。那你可能会反问,难道掌握基础理论和原理就不重要了吗?这是在很多领域都在争论的一个话题:理论重要,还是实践重要。
|
||||
|
||||
## 理论重要,还是实践重要?
|
||||
|
||||
理论和原理当然重要,在编译原理中也是如此。形式语言有关的理论,以及前端、中端和后端的各个经典算法,构筑了编译原理这门课坚实的理论基础。
|
||||
|
||||
但是,在出现编译原理这门课之前,在出现龙书虎书之前,工程师们已经在写编译器了。
|
||||
|
||||
你在工作中,有时候就会遇到理论派和实践派之争。举例来说,有时候从理论角度,某一个方案会“看上去很美”。那到底是否采用该方案呢?这个时候,就需要拿实践来说话了。
|
||||
|
||||
我拿Linux内核的发展举个例子。当年Linus推出Linux内核的时候,并没有采用学术界推崇的微内核架构,为此Linus还跟Minix的作者有一场著名的辩论。而实践证明,Linux内核发展得很成功,而GNU的另一个采用微架构的内核Hurd发展了20多年还没落地。
|
||||
|
||||
客观地说,Linux内核后来也吸收了很多微内核的设计理念。而声称采用微内核架构的Windows系统和macOS系统,其实在很多地方也已经违背了微内核的原则,而具备Linux那样的单内核的特征。之所以有上述的融合,其实都是一个原因,就是为了得到更好的实用效果。所以,实践会为很多历史上的争论划上句号。
|
||||
|
||||
在编译技术和计算机语言设计领域,也存在着很多的理论与实践之争。比如,理论上,似乎函数式编程更简洁、更强大,学术界也很偏爱它,但是纯函数的编程语言,至今没有成为主流,这是为什么呢?
|
||||
|
||||
再比如,是否一定要把龙书虎书都读明白,才算学会了编译原理呢?
|
||||
|
||||
再进一步,如果你使用编译技术的时候,遇到一个实际的问题,是跟着龙书、虎书还有各种课本走,还是拿出一个能解决问题的方案就行?
|
||||
|
||||
在课程里,我鼓励你抛弃一些传统上学习编译原理的困扰。如果龙书、虎书看不明白,那也不用过于纠结,这并不会挡住你学习的道路。多看实际的编译器,多自己动手实践,在这个过程中,你自然会明白课本里原来不知所云的知识点。
|
||||
|
||||
那么如何以实践为指导,从而具备更好的技术方案鉴别力呢?在本课程里,我们有三个重点。包括研究常用语言的编译器、从计算机语言设计的高度来理解编译原理,以及从运行时的实现来理解编译原理。
|
||||
|
||||
## 对于你所使用的语言,应该把它的编译器研究透
|
||||
|
||||
这门课程的主张是,你最好把自己所使用语言的编译器研究透。这个建议有几个理由。
|
||||
|
||||
**第一,因为这门语言是你所熟悉的,所以你研究起来速度会更快。**比如,可以更快地写出测试用的程序。并且,由于很多语言的编译器都已经实现了自举,比如说Go语言和Java语言的编译器,所以你可以更快地理解源代码,以及对编译器本身做调试。
|
||||
|
||||
**第二,这门语言的编译器所采用的实现技术,一定是体现了该语言的特性的。**比如V8会强调解析速度快,Java编译器要支持注解特性等,值得你去仔细体会。
|
||||
|
||||
**第三,研究透编译器,会加深你对这门语言的理解。**比如说,你了解清楚了Java的编译器是如何处理泛型的,那你就会彻底理解Java泛型机制的优缺点。而C++的模板机制,对于学习C++的同学是有一定挑战的。但一旦你把它在编译期的实现机制弄明白,就会彻底掌握模板机制。我也计划在后续用一篇加餐,把C++的模板机制给你拆解一下。
|
||||
|
||||
那么,既然编译器是为具体语言服务的,所以,我们也在课程里介绍了计算机语言设计所考虑的那些关键因素,以及它们对编译技术的影响。
|
||||
|
||||
## 从计算机语言设计的高度,去理解编译技术
|
||||
|
||||
在课程里你已经体会到了,语言设计的不同,必然导致采用编译技术不同。
|
||||
|
||||
其实,从计算机语言设计的高度上看,编译器只是实现计算机语言的一块底层基石。计算机语言设计本身有很多的研究课题,比如类型系统、所采用的编程范式、泛型特性、元编程特性等等,我们在课程里有所涉猎,但并没有在理论层面深挖。有些学校会从这个方向上来培养博士生,他们会在理论层面做更深入的研究。
|
||||
|
||||
什么样的计算机语言是一个好的设计?这是一个充满争议的话题,我们这门课程尽量不参与这个话题的讨论。我们的任务,是**要知道当采用不同的语言设计时,应该如何运用编译技术来落地**。特别是,**要了解自己所使用的语言的实现机制**。
|
||||
|
||||
如果说计算机语言设计,是一种偏理论性的视角,那么程序具体的运行机制,则是更加注重落地的一种视角。
|
||||
|
||||
## 从程序运行机制的角度,去理解编译技术
|
||||
|
||||
学习编译原理的一个挑战,就在于你必须真正理解程序是如何运行的,以及程序都可以有哪几种运行方式。这样,你才能理解如何生成服务于这种运行机制的目标代码。
|
||||
|
||||
最最基础的,你需要了解像C语言这样的编译成机器码直接运行的语言,它的运行机制是怎样的。代码放在哪里,又是如何一步步被执行的。在执行过程中,栈是怎么变化的。函数调用的过程中,都发生了些什么事情。什么数据是放在栈里的,什么数据是放在堆里的,等等。
|
||||
|
||||
在此基础上,如果从C语言换成C++呢?C++多了个对象机制,那对象在内存里是一个什么结构?多重继承的时候是一个什么结构?在存在多态的时候,如何实现方法的正确绑定?这些C++比C语言多出来的语义,你也要能够在运行时机制中把它弄清楚。
|
||||
|
||||
再进一步,到了Go语言,仍然是编译成机器码运行的,但跟C和C++又有本质区别。因为Go语言的运行时里包含了垃圾收集机制和并发调度机制,这两个机制要跟你的程序编译成的代码互相配合,所以编译器生成的目标代码里要体现内存管理和并发这两大机制。像Go语言这种特殊的运行机制,还导致了跨语言调用的难度。用Go语言调用C语言的库,要有一定的转换和开销。
|
||||
|
||||
然后呢,语言运行时的抽象度进一步增加。到了Java语言,就用到一个虚拟机。字节码也正式登台亮相。你需要知道栈机和寄存器机这两种不同的运行字节码的解释器,也要知道它们对应的字节码的差别。而为了提升运行速度,JIT、分层编译和逆优化机制又登场,栈上替换(OSR)技术也产生。这个时候,你需要理解解释执行和运行JIT生成的本地代码,是如何无缝衔接的。这个时候的栈桢,又有何不同。
|
||||
|
||||
然后是JavaScript的运行时机制,就更加复杂了。V8不仅具备JVM的那些能力,在编译时还要去推断变量的类型,并且通过隐藏类的方式安排对象的内存布局,以及通过内联缓存的技术去加快对象属性的访问速度。
|
||||
|
||||
这样从最简单的运行时,到最复杂的虚拟机,你都能理解其运行机制的话,你其实不仅知道在不同场景下如何使用编译技术,甚至可以参与虚拟机等底层软件的研发了。
|
||||
|
||||
## 不再是谈论,来参与实战吧!
|
||||
|
||||
今天,我们学习编译原理,目标不能放在考试考多少分上。中国的技术生态,使得我们已经能够孕育自己的编译器、自己的语言、自己的虚拟机。方舟编译器已经带了个头。我想,中国不会只有方舟编译器孤军奋战的!
|
||||
|
||||
就算是开发普通的应用软件,我们也要运用编译技术,让它们平台化,让中国制造的软件,具有更高的技术含量,颠覆世界对于“中国软件”的品牌认知。这样的颠覆,在手机、家电等制造业已经发生了,也应该轮到软件业了。
|
||||
|
||||
而经验告诉我们,一旦中国的厂商和工程师开始动起来,那么速度会是非常快的。编译技术并没有多么难。我相信,只要短短几年时间,中国软件界就会在这个领域崭露头角!
|
||||
|
||||
这就是我们这门课程的目的。不是为了学习而学习,而是为了实战而学习。
|
||||
|
||||
当然,课程虽然看似结束了,但也代表着你学习的重新开始。后面我计划再写几篇加餐,会针对C++、Rust等编译器再做一些解析,拓展你的学习地图。并且,针对方舟编译器,我还会进一步跟你分享我的一些研究成果,希望我们可以形成一个持续不断地对编译器进行研究的社群,让学习和研究不断深入下去,不断走向实用。
|
||||
|
||||
另外,我还给你准备了一份[毕业问卷](https://jinshuju.net/f/pcwmct),题目不多,希望你能在问卷里聊一聊你对这门课的看法。欢迎你点击下面的图片,用1~2分钟时间填写一下,期待你畅所欲言。当然了,如果你对课程内容还有什么问题,也欢迎你在留言区继续提问,我会持续回复你的留言。
|
||||
|
||||
我们江湖再见!
|
||||
|
||||
[<img src="https://static001.geekbang.org/resource/image/43/0d/432997f0854ecc3a9557430a1bd1df0d.jpg" alt="">](https://jinshuju.net/f/pcwmct)
|
||||
69
极客时间专栏/geek/编译原理实战课/课前必读/学习指南 | 如何学习这门编译原理实战课?.md
Normal file
69
极客时间专栏/geek/编译原理实战课/课前必读/学习指南 | 如何学习这门编译原理实战课?.md
Normal file
@@ -0,0 +1,69 @@
|
||||
<audio id="audio" title="学习指南 | 如何学习这门编译原理实战课?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/91/1d/91186c66ff3e80b2113247194ae50b1d.mp3"></audio>
|
||||
|
||||
你好,欢迎来到《编译原理实战课》,我是专栏编辑王惠,很高兴认识你。
|
||||
|
||||
我们都知道,“编译原理”是一门特别硬核的计算机基础专业课。你是不是也觉得编译原理知识就像是一片望不到头的大海,任自己在里面怎么扑腾、怎么挣扎都游不到学成的对岸。但是没关系,现在我们可以跟着宫老师的脚步一起探索编译的旅程了。
|
||||
|
||||
不过在正式开始学习这门课程之前,我想先和你聊聊这门课程的一些设计思路和特设板块,帮你找到最适合自己的学习方式,让你后面的学习能达到事半功倍的效果。
|
||||
|
||||
## 我们有“学习委员”了
|
||||
|
||||
首先来说个好消息,咱这门课呢有学习委员陪伴我们一起学。担当学委的是我们的资深用户朱英达同学,他曾就职于百度,履任资深研发工程师,擅长Web前后端相关领域技术,对编译技术在业务场景下的应用也有自己的理解。
|
||||
|
||||
他的经历可能和你很相似:作为一名计算机科班出身的程序员,在大学课堂中学习过编译原理这门课,但面对教科书上庞杂的知识体系、晦涩的抽象概念、陈旧的代码用例,无奈只学了个一知半解;工作以后,作为一名一线的Coder,在大厂的环境里,看惯了层出不穷的“造轮子怪象”,最终才发现只有掌握像编译原理这样的底层技术,才是真正的精进之道。所以,他想把编译原理这门课重新捡起来,再学一次。
|
||||
|
||||
然而,目前市面上编译方面的技术资料却非常匮乏,被学界奉为经典的“龙书”“虎书”“鲸书”,对初学者来说又不够友好。后来,他遇到了宫老师的《编译原理之美》,跟着老师的思路重走了一遭编译之旅,发现自己之前对于编译技术的很多困惑点都迎刃而解了。比如说,宫老师在阐释虚拟机架构时,谈到了栈机和寄存器架构的优劣,这就对他理解V8引擎在虚拟机架构选型上提供了非常好的参考。
|
||||
|
||||
朱学委最终也发现,**编译技术的学习绝对不能纸上谈兵,只有把学到的理论知识与自己从事的相关技术领域结合起来,才会真正有所感悟。**
|
||||
|
||||
你看,编译原理或者说所有的技术,都有这么一个反复学习、反复印证的过程。所以在这门课程中,学委将会基于积累的编译原理基础,以及对这门新课程内容的学习,不定期地分享他学习编译原理的方法和思路,和你一起探讨课程要掌握的要点和难点。当然了,学委也会在留言区督促你交作业,和你一起交流讨论。
|
||||
|
||||
有了学委的陪伴,相信你再学习这门课,一定可以事半功倍。
|
||||
|
||||
## 如何学习预备知识模块?
|
||||
|
||||
接下来,我来说说怎么利用好预备知识模块。
|
||||
|
||||
那我先来交代下为什么要特别设计这个模块。就像老师在开篇词中所说的,这门课程会带你一起阅读真实语言编译器的源码,跟踪它们的运行过程,分析编译过程的每一步是如何实现的,并会对有特点的编译技术点加以分析和点评。
|
||||
|
||||
但在解析编译器的过程中,一定会涉及到很多编译原理的基础概念、理论和算法,如果你从来没有接触过或者不够了解这些编译原理知识,那必然会在一定程度上影响你后面的学习效果。所以,预备知识模块就是帮你先建立起一个初步的编译原理知识体系,打好基础,为后面的学习做好准备。
|
||||
|
||||
如果你已经学过老师的第一季课程[《编译原理之美》](https://time.geekbang.org/column/intro/219?utm_term=zeusGI5E3&utm_source=app&utm_medium=geektime&utm_campaign=219-end&utm_content=xuexizhinan0601),预备篇的内容也建议你不要跳过。和第一季课程相比,在这个模块里宫老师会以更加高屋建瓴的方式,来重新交付编译基础知识。所以,你一定要利用这个模块来查漏补缺。
|
||||
|
||||
那具体怎么做呢?**建议你先看每一讲的标题,然后回顾自己已经学过的、掌握了的知识要点,写下来,写好后再开始学习,学完后对比总结心得。**千万不要错过这个再学一次的机会。我们都知道重复是学习的关键一环,相信通过这个模块,你一定能在编译技术的理解上更上层楼。
|
||||
|
||||
你可以把预备知识理解为编译基础的一个串讲,涉及到的概念会比较多。所以学习这个模块的时候,我建议你每学完一讲都要自己动手画一下这一讲的知识地图。等8篇结束后,学习委员也会总结一张**编译原理的核心基础知识大地图**。到时候你可以对比来看,给自己一个直接的反馈。然后一定要利用这张图,在脑子里构建起编译原理的知识框架。这样,你就做好了进入下个模块的学习准备啦。
|
||||
|
||||
## 解析7种语言编译器的过程中,你需要做什么?
|
||||
|
||||
下面我来说说课程的重头戏,也就是解析7种语言的编译器,包括Java编译器(javac)、Java的JIT编译器(Graal)、Python编译器(CPython)、JavaScript编译器(V8)、Julia语言的编译器、Go语言的编译器(gc),以及MySQL的编译器。
|
||||
|
||||
这些编译器都是宫老师精选出来的,具有一定的代表性、采用了不同的编译技术,而且其中某一门语言也非常可能就是你在使用的。我们的课程就是从实战的角度切入,用你最擅长的方式(写代码、读代码)带你分析这些编译器。所以学好这门课的关键就是要**动手实践**,跟随老师的脚步来亲身体验不同编译器的实现机制。
|
||||
|
||||
我建议你最好在学习的过程中手边备着一台电脑,或者是一台能查看到源代码的其他设备,工具不重要,趁手最有效。你在自己上手修改源码的时候,就会发现对编译原理的概念理解得更加深入了。
|
||||
|
||||
## 期中复习周,停下来是为了跑得更快
|
||||
|
||||
接着来说说期中复习周。这一周安排在“真实编译器解析篇”之后,也就是建立在你已经学习并理解了7种不同语言编译器的运行机制之后。设置复习周的目的,就是想要让你能及时、系统地了解自己前半段课程内容的掌握情况,发现学习上的漏洞,并及时弥补。
|
||||
|
||||
在这一周,学委首先会帮你划出复习的重点,给你总结前面解析的7种语言编译器所涉及到的核心知识。总结复习的过程,也就是你在提高编译技术能力的过程。
|
||||
|
||||
接下来,老师会给你出一套考试题。通过这次测试,你可以验证一下自己的学习方式是否有效,希望你能够及时调整学习心态和方法,更有效率地进行下一阶段的学习。
|
||||
|
||||
另外,在消化知识的同时,你还可以通过其他同学分享的心得,去看看他是如何学习、掌握编译原理知识的,毕竟通过借鉴别人来完善自己也是一种很好的学习方法嘛。
|
||||
|
||||
## Learning by Sharing,分享了才知道自己那么优秀
|
||||
|
||||
再接下来,我必须得说说“一课一思”这个学习环节了。
|
||||
|
||||
一课一思是每一讲最后的固定模块,具体内容呢,要么是给你留了一道动手实践的作业,要么就是抛出一个开放性的问题,引导你发散思考。如果你对这些问题都有自己的见解或者看法,那就不妨在留言区分享出来。这样渐渐地,你会发现自己就能解答一些同学的问题了,这是非常好的自检学习成果的方式。
|
||||
|
||||
另外别忘记了,极客时间还有一个社区交流的版块“**部落**”。在日常工作中,你一定会经常接触各种代码,也一定有自己非常熟悉的一门或多门编程语言。那么在解析了不同语言的编译器以后,你可以在部落里分享自己对于熟悉的或不熟悉的语言编译器的理解。
|
||||
|
||||
比如说,你原来深耕在Java领域,那么在学完了javac编译器和Graal编译器以后,你对Java是不是就有更深刻的理解了?在学完了Python的编译器以后,你是不是对这两门语言之间的共性和特性都更加清晰了?这些思考你都可以分享在部落里,通过分享自己所习得的知识,你会获得更好的成长。
|
||||
|
||||
## 如何验收学习成果?
|
||||
|
||||
最后,在课程的收尾阶段呢,老师还会跟你一起关注一个热点话题,那就是华为的方舟编译器。相信很多同学对于国产的编译器,一直都是翘首以盼的。华为已经公开了一部分源代码,虽然资料仍然很缺乏,但是通过我们课程的学习,你是否有能力看懂华为的编译器呢?从掌握书本上的原理,到读懂流行的语言,再到理解方舟编译器的实现思路,这会是你能力一步步提升的过程。最终,你甚至可以参与到一款严肃的编译器的研发当中了。
|
||||
|
||||
好了,以上就是我想让你重点关注的课程设计和特设板块内容。编译原理是个难啃的硬骨头,但是我相信,只要你保有这份一定要吃透编译技术核心知识的决心,有计划、有重点,结合实践进行学习,就没有什么是看不懂、学不会的了。加油吧,祝你学有所成!
|
||||
99
极客时间专栏/geek/编译原理实战课/课前必读/开篇词 | 在真实世界的编译器中游历.md
Normal file
99
极客时间专栏/geek/编译原理实战课/课前必读/开篇词 | 在真实世界的编译器中游历.md
Normal file
@@ -0,0 +1,99 @@
|
||||
<audio id="audio" title="开篇词 | 在真实世界的编译器中游历" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d9/05/d9e55215151ee00a1c9f5c6ad15b3f05.mp3"></audio>
|
||||
|
||||
你好,我是宫文学,一名技术创业者,现在是北京物演科技的CEO,很高兴在这里跟你见面。
|
||||
|
||||
我在IT领域里已经工作有20多年了。这其中,我个人比较感兴趣的,也是倾注时间精力最多的,是做基础平台类的软件,比如国内最早一批的BPM平台、BI平台,以及低代码/无代码开发平台(那时还没有这个名字)等。这些软件之所以会被称为平台,很重要的原因就是拥有很强的定制能力,比如流程定制、界面定制、业务逻辑定制,等等。而这些定制能力,依托的就是编译技术。
|
||||
|
||||
在前几年,我参与了一些与微服务有关的项目。我发现,前些年大家普遍关注那些技术问题,比如有状态的服务(Stateful Service)的横向扩展问题,在云原生、Serverless、FaaS等新技术满天飞的时代,不但没能被很好地解决,反而更恶化了。究其原因就是,状态管理还是被简单地交给数据库,而云计算的场景使得数据库的压力更大了,数据库原来在性能和扩展能力上的短板,就更加显著了。
|
||||
|
||||
而比较好的解决思路之一,就是大胆采用新的计算范式,发明新的计算机语言,所以我也有意想自己动手搞一下。
|
||||
|
||||
我从去年开始做设计,已经鼓捣了一阵了,采用了一些很前卫的理念,比如云原生的并发调度、基于Actor的数据管理等。总的目标,是要让开发云原生的、有状态的应用,像开发一个简单的单机应用一样容易。那我们就最好能把云架构和状态管理的细节给抽象掉,从而极大地降低成本、减少错误。而为编程提供更高的抽象层次,从来就是编译技术的职责。
|
||||
|
||||
Serverless和FaaS已经把无状态服务的架构细节透明掉了。但针对有状态的服务,目前还没有答案。对我而言,这是个有趣的课题。<br>
|
||||
在我比较熟悉的企业应用领域,ERP的鼻祖SAP、SaaS的鼻祖SalesForce,都用自己的语言开发应用,很可惜国内的企业软件厂商还没有做到这一点。而在云计算时代,设计这样一门语言绕不过去的一个问题,就是解决有状态服务的云化问题。我希望能为解决这个问题提供一个新工具。当然,这个工具必须是开源的。
|
||||
|
||||
正是因为给自己挖了这么大一个坑,也促使我更关心编译技术的各种前沿动态,也非常想把这些前沿的动态、理念,以及自己的一些实战经验都分享出来。
|
||||
|
||||
所以去年呢,我在极客时间上开了一门课程[《编译原理之美》](https://time.geekbang.org/column/intro/219?utm_term=zeusGI5E3&utm_source=app&utm_medium=geektime&utm_campaign=219-end&utm_content=xuexizhinan0601),帮你系统梳理了编译技术最核心的概念、理论和算法。不过在做第一季的过程中呢,我发现很多同学都跟我反馈:我确实理解了编译技术的相关原理、概念、算法等,但是有没有更直接的方式,能让我更加深入地把知识与实践相结合呢?
|
||||
|
||||
## 为什么要解析真实编译器?
|
||||
|
||||
说到把编译技术的知识与实践相结合,无外乎就是解决以下问题:
|
||||
|
||||
- 我已经知道,语法分析有自顶向下的方法和自底向上的方法,但要自己动手实现的话,到底该选择哪个方法呢?是应该自己手写,还是用工具生成呢?
|
||||
- 我已经知道,在语义分析的过程中要做引用消解、类型检查,并且会用到符号表。那具体到自己熟悉的语言,这些工作是如何完成的呢?有什么难点和实现技巧呢?符号表又被设计成什么样子呢?
|
||||
- 我已经知道,编译器中会使用IR,但实际使用中的IR到底是什么样子的呢?使用什么数据结构呢?完成不同的处理任务,是否需要不同的IR呢?
|
||||
- 我已经知道,编译器要做很多优化工作,但针对自己熟悉的语言,这些优化是如何发生的?哪些优化最重要?又要如何写出便于编译器优化的代码呢?
|
||||
|
||||
类似的问题还有很多,但总结起来其实就是:**真实世界的编译器,到底是怎么写出来的?**
|
||||
|
||||
那弄明白了这个问题,到底对我们有什么帮助呢?
|
||||
|
||||
**第一,研究这些语言的编译机制,能直接提高我们的技术水平。**
|
||||
|
||||
一方面,深入了解自己使用的语言的编译器,会有助于你吃透这门语言的核心特性,更好地运用它,从而让自己向着专家级别的工程师进军。举个例子,国内某互联网公司的员工,就曾经向Oracle公司提交了HotSpot的高质量补丁,因为他们在工作中发现了JVM编译器的一些不足。那么,你是不是也有可能把一门语言吃得这么透呢?
|
||||
|
||||
另一方面,IT技术的进化速度是很快的,作为技术人,我们需要迅速跟上技术更迭的速度。而这些现代语言的编译器,往往就是整合了最前沿的技术。比如,Java的JIT编译器和JavaScript的V8编译器,它们都不约而同地采用了“Sea of Nodes”的IR来做优化,这是为什么呢?这种IR有什么优势呢?这些问题我们都需要迅速弄清楚。
|
||||
|
||||
**第二,阅读语言编译器的源码,是高效学习编译原理的重要路径。**
|
||||
|
||||
传统上,我们学习编译原理,总是要先学一大堆的理论和算法,理解起来非常困难,让人望而生畏。
|
||||
|
||||
这个方法本身没有错,因为我们学习任何知识,都要掌握其中的原理。不过,这样可能离实现一款实用的编译器还有相当的距离。
|
||||
|
||||
那么根据我的经验,学习编译原理的一个有效途径,就是阅读真实世界中编译器的源代码,跟踪它的执行过程,弄懂它的运行机制。因为只要你会写程序,就能读懂代码。既然能读懂代码,那为什么不直接去阅读编译器的源代码呢?在开源的时代,源代码就是一个巨大的知识宝库。面对这个宝库,我们为什么不进去尽情搜刮呢?想带走多少就带走多少,没人拦着。
|
||||
|
||||
当然,你可能会犯嘀咕:**编译器的代码一般都比较难吧?以我的水平,能看懂吗?**
|
||||
|
||||
是会有这个问题。当我们面对一大堆代码的时候,很容易迷路,抓不住其中的重点和核心逻辑。不过没关系,有我呢。在本课程中,我会给你带路,并把地图准备好,带你走完这次探险之旅。而当你确实把握了编译器的脉络以后,你对自己的技术自信心会提升一大截。这些计算机语言,就被你摘掉了神秘的面纱。
|
||||
|
||||
俗话说“读万卷书,行万里路”。如果说了解编译原理的基础理论和算法是读书的过程,那么探索真实世界里的编译器是什么样子,就是行路的过程了。根据我的体会,**当你真正了解了身边的语言的编译器是怎样编写的之后,那些抽象的理论就会变得生动和具体,你也就会在编译技术领域里往前跨出一大步了。**
|
||||
|
||||
## 我们可以解析哪些语言的编译器?
|
||||
|
||||
那你可能要问了,在本课程中,**我都选择了哪些语言的编译器呢?选择这些编译器的原因又是什么呢?**
|
||||
|
||||
这次,我要带你解析的编译器还真不少,包括了Java编译器(javac)、Java的JIT编译器(Graal)、Python编译器(CPython)、JavaScript编译器(V8)、Julia语言的编译器、Go语言的编译器(gc),以及MySQL的编译器,并且在讲并行的时候,还涉及了Erlang的编译器。
|
||||
|
||||
我选择剖析这些语言的编译器,有三方面的原因:
|
||||
|
||||
- 第一,它们足够有代表性,是你在平时很可能会用到的。这些语言中,除了Julia比较小众外,都比较流行。而且,虽然Julia没那么有名,但它使用的LLVM工具很重要。因为LLVM为Swift、Rust、C++、C等多种语言提供了优化和后端的支持,所以Julia也不缺乏代表性。
|
||||
- 第二,它们采用了各种不同的编译技术。这些编译器,有的是编译静态类型的语言,有的是动态类型的语言;有的是即时编译(JIT),有的是提前编译(AOT);有高级语言,也有DSL(SQL);解释执行的话,有的是用栈机(Stack Machine),有的是用寄存器机,等等。不同的语言特性,就导致了编译器采用的技术会存在各种差异,从而更加有利于你开阔视野。
|
||||
- 第三,通过研究多种编译器,你可以多次迭代对编译器的认知过程,并通过分析对比,发现这些编译器之间的异同点,探究其中的原因,激发出更多的思考,从而得到更全面的、更深入的认知。
|
||||
|
||||
看到这里,你可能会有所疑虑:**有些语言我没用过,不怎么了解,怎么办?**其实没关系。因为现代的高级语言,其实相似度很高。
|
||||
|
||||
一方面,对于不熟悉的语言,虽然你不能熟练地用它们来做项目,但是写一些基本的、试验性的程序,研究它的实现机制,是没有什么问题的。
|
||||
|
||||
另一方面,学习编译原理的人会练就一项基本功,那就是更容易掌握一门语言的本质。特别是我这一季的课程,就是要帮你成为钻到了铁扇公主肚子里的孙悟空。研究某一种语言的编译器,当然有助于你通过“捷径”去深入地理解它。
|
||||
|
||||
## 我是如何规划课程模块的?
|
||||
|
||||
这门课程的目标,是要让你对现代语言的编译器的结构、所采用的算法以及设计上的权衡,都获得比较真切的认识。其最终结果是,如果要你使用编译技术来完成一个项目,你会心里非常有数,知道应该在什么地方使用什么技术。因为你不仅懂得原理,更有很多实际编译器的设计和实现的思路作为你的决策依据。
|
||||
|
||||
为了达到本课程的目标,我仔细规划了课程的内容,将其划分为预备知识篇、真实编译器解析篇和现代语言设计篇三部分。
|
||||
|
||||
在**预备知识篇**,我会简明扼要地帮你重温一下编译原理的知识体系,让你对这些关键概念的理解变得更清晰。磨刀不误砍柴工,你学完预备知识篇后,再去看各种语言编译器的源代码和相关文档时,至少不会被各种名词、术语搞晕,也能更好地建立具体实现跟原理之间的关联,能互相印证它们。
|
||||
|
||||
在**真实编译器解析篇**,我会带你研究语言编译器的源代码,跟踪它们的运行过程,分析编译过程的每一步是如何实现的,并对有特点的编译技术点加以分析和点评。这样,我们在研究了Java、Java JIT、Python、JavaScript、Julia、Go、MySQL这7个编译器以后,就相当于把编译原理印证了7遍。
|
||||
|
||||
在**现代语言设计篇**,我会带你分析和总结前面已经研究过的编译器,进一步提升你对相关编译技术的认知高度。学完这一模块以后,你对于如何设计编译器的前端、中端、后端、运行时,都会有比较全面的了解,知道如何在不同的技术路线之间做取舍。
|
||||
|
||||
好了,以上就是这一季课程的模块划分思路了。你会发现,这次的课程设计,除了以研究真实编译器为主要手段外,会更加致力于扩大你的知识版图、增加你的见识,达到“行万里路”的目的。
|
||||
|
||||
可以说,我在设计和组织这一季课程时,花了大量的时间准备。因此这一季课程的内容,不说是独一无二的,也差不多了。你在市面上很少能找到解析实际编译器的书籍和资料,这里面的很多内容,都是在我自己阅读源代码、跟踪源代码执行过程的基础上梳理出来的。
|
||||
|
||||
## 写在最后
|
||||
|
||||
近些年,编译技术在全球范围内的进步速度很快。比如,你在学习Graal编译器的时候,你可以先去看看,市面上有多少篇围绕它的高质量论文。所以呢,作为老师,我觉得我有责任引导你去看到、理解并抓住这些技术前沿。
|
||||
|
||||
我也有一个感觉,在未来10年左右,中国在编译技术领域,也会逐步有拿得出手的作品出来,甚至会有我们独特的创新之处,就像我们当前在互联网、5G等领域中做到的一样。
|
||||
|
||||
虽然这个课程不可能涵盖编译技术领域所有的创新点,但我相信,你在其中投入的时间和精力是值得的。你通过我课程中教给你的方法,可以对你所使用的语言产生更加深入的认知,对编译器的内部结构和原理有清晰理解。最重要的是,对于如何采用编译技术来解决实际问题,你也会有能力做出正确的决策。
|
||||
|
||||
这样,这个课程就能起到抛砖引玉的作用,让我们能够成为大胆探索、勇于创新的群体的一份子。未来中国在编译技术的进步,就很可能有来自我们的贡献。我们一起加油!
|
||||
|
||||
**最后,我还想正式认识一下你。**你可以在留言区里做个自我介绍,和我聊聊,你目前学习编译原理的最大难点在哪?或者,你也可以聊聊你对编译原理都有哪些独特的思考和体验,欢迎在留言区和我交流讨论。
|
||||
|
||||
好了,让我们正式开始编译之旅吧!
|
||||
258
极客时间专栏/geek/编译原理实战课/预备知识篇/01 | 编译的全过程都悄悄做了哪些事情?.md
Normal file
258
极客时间专栏/geek/编译原理实战课/预备知识篇/01 | 编译的全过程都悄悄做了哪些事情?.md
Normal file
@@ -0,0 +1,258 @@
|
||||
<audio id="audio" title="01 | 编译的全过程都悄悄做了哪些事情?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/53/f8f7692d5f6b0e7c243e3584da40f553.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
正如我在开篇词中所说的,这一季课程的设计,是要带你去考察实际编译器的代码,把你带到编译技术的第一现场,让你以最直观、最接地气的方式理解编译器是怎么做出来的。
|
||||
|
||||
但是,毕竟编译领域还是有很多基本概念的。对于编译原理基础不太扎实的同学来说,在跟随我出发探险之前,最好还是做一点准备工作,磨刀不误砍柴工嘛。所以,在正式开始本课程之前,我会先花8讲的时间,用通俗的语言,帮你把编译原理的知识体系梳理一遍。
|
||||
|
||||
当然,对于已经学过编译原理的同学来说,这几讲可以帮助你复习以前学过的知识,把相关的知识点从遥远的记忆里再调出来,重温一下,以便更好地进入状态。
|
||||
|
||||
今天这一讲,我首先带你从宏观上理解一下整个编译过程。后面几讲中,我再针对编译过程中的每个阶段做细化讲解。
|
||||
|
||||
好了,让我们开始吧。
|
||||
|
||||
**编译,其实就是把源代码变成目标代码的过程。**如果源代码编译后要在操作系统上运行,那目标代码就是汇编代码,我们再通过汇编和链接的过程形成可执行文件,然后通过加载器加载到操作系统里执行。如果编译后是在解释器里执行,那目标代码就可以不是汇编代码,而是一种解释器可以理解的中间形式的代码即可。
|
||||
|
||||
我举一个很简单的例子。这里有一段C语言的程序,我们一起来看看它的编译过程。
|
||||
|
||||
```
|
||||
int foo(int a){
|
||||
int b = a + 3;
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这段源代码,如果把它编译成汇编代码,大致是下面这个样子:
|
||||
|
||||
```
|
||||
.section __TEXT,__text,regular,pure_instructions
|
||||
.globl _foo ## -- Begin function foo
|
||||
_foo: ## @foo
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
movl %edi, -4(%rbp)
|
||||
movl -4(%rbp), %eax
|
||||
addl $3, %eax
|
||||
movl %eax, -8(%rbp)
|
||||
movl -8(%rbp), %eax
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
你可以看出,源代码和目标代码之间的差异还是很大的。那么,我们怎么实现这个翻译呢?
|
||||
|
||||
其实,编译和把英语翻译成汉语的大逻辑是一样的。前提是你要懂这两门语言,这样你看到一篇英语文章,在脑子里理解以后,就可以把它翻译成汉语。编译器也是一样,你首先需要让编译器理解源代码的意思,然后再把它翻译成另一种语言。
|
||||
|
||||
表面上看,好像从英语到汉语,一下子就能翻译过去。但实际上,大脑一瞬间做了很多个步骤的处理,包括识别一个个单词,理解语法结构,然后弄明白它的意思。同样,编译器翻译源代码,也需要经过多个处理步骤,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/d4/0aa7939fbdb80f923ae7a8090ec7f3d4.jpg" alt="">
|
||||
|
||||
我来解释一下各个步骤。
|
||||
|
||||
## 词法分析(Lexical Analysis)
|
||||
|
||||
首先,编译器要读入源代码。
|
||||
|
||||
在编译之前,源代码只是一长串字符而已,这显然不利于编译器理解程序的含义。所以,编译的第一步,就是要像读文章一样,先把里面的单词和标点符号识别出来。程序里面的单词叫做Token,它可以分成关键字、标识符、字面量、操作符号等多个种类。**把字符串转换为Token的这个过程,就叫做词法分析。**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/9a/d80623403c912ab43c328623df8a0e9a.jpg" alt="">
|
||||
|
||||
## 语法分析(Syntactic Analysis)
|
||||
|
||||
识别出Token以后,离编译器明白源代码的含义仍然有很长一段距离。下一步,**我们需要让编译器像理解自然语言一样,理解它的语法结构。**这就是第二步,**语法分析**。
|
||||
|
||||
上语文课的时候,老师都会让你给一个句子划分语法结构。比如说:“我喜欢又聪明又勇敢的你”,它的语法结构可以表示成下面这样的树状结构。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/a7/6cca65a7d46f1d96e278bd53027a12a7.jpg" alt="">
|
||||
|
||||
那么在编译器里,语法分析阶段也会把Token串,转换成一个**体现语法规则的、树状的数据结构**,这个数据结构叫做**抽象语法树**(AST,Abstract Syntax Tree)。我们前面的示例程序转换为AST以后,大概是下面这个样子:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/6b/6801cb9b637afae44e728fa08267756b.jpg" alt="">
|
||||
|
||||
这样的一棵AST反映了示例程序的语法结构。比如说,我们知道一个函数的定义包括了返回值类型、函数名称、0到多个参数和函数体等。这棵抽象语法树的顶部就是一个函数节点,它包含了四个子节点,刚好反映了函数的语法。
|
||||
|
||||
再进一步,函数体里面还可以包含多个语句,如变量声明语句、返回语句,它们构成了函数体的子节点。然后,每个语句又可以进一步分解,直到叶子节点,就不可再分解了。而叶子节点,就是词法分析阶段生成的Token(图中带边框的节点)。对这棵AST做深度优先的遍历,你就能依次得到原来的Token。
|
||||
|
||||
## 语义分析(Semantic Analysis)
|
||||
|
||||
生成AST以后,程序的语法结构就很清晰了,编译工作往前迈进了一大步。但这棵树到底代表了什么意思,我们目前仍然不能完全确定。
|
||||
|
||||
比如说,表达式“a+3”在计算机程序里的完整含义是:“获取变量a的值,把它跟字面量3的值相加,得到最终结果。”但我们目前只得到了这么一棵树,完全没有上面这么丰富的含义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/da/83e78e9e6ba8506b9e26a00fc77ef1da.jpg" alt="">
|
||||
|
||||
这就好比西方的儿童,很小的时候就能够给大人读报纸。因为他们懂得发音规则,能念出单词来(词法分析),也基本理解语法结构(他们不见得懂主谓宾这样的术语,但是凭经验已经知道句子有不同的组成部分),可以读得抑扬顿挫(语法分析),但是他们不懂报纸里说的是什么,也就是不懂语义。这就是编译器解读源代码的下一步工作,**语义分析**。
|
||||
|
||||
**那么,怎样理解源代码的语义呢?**
|
||||
|
||||
实际上,语言的设计者在定义类似“a+3”中加号这个操作符的时候,是给它规定了一些语义的,就是要把加号两边的数字相加。你在阅读某门语言的标准时,也会看到其中有很多篇幅是在做语义规定。在ECMAScript(也就是JavaScript)标准2020版中,Semantic这个词出现了657次。下图是其中[加法操作的语义规则](https://tc39.es/ecma262/2020/#sec-additive-operators),它对于如何计算左节点、右节点的值,如何进行类型转换等,都有规定。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bc/8b/bc77f55a2801542999b7eee10e6f1c8b.jpg" alt="">
|
||||
|
||||
所以,我们可以在每个AST节点上附加一些语义规则,让它能反映语言设计者的本意。
|
||||
|
||||
- add节点:把两个子节点的值相加,作为自己的值;
|
||||
- 变量节点(在等号右边的话):取出变量的值;
|
||||
- 数字字面量节点:返回这个字面量代表的值。
|
||||
|
||||
这样的话,如果你深度遍历AST,并执行每个节点附带的语义规则,就可以得到a+3的值。这意味着,我们正确地理解了这个表达式的含义。运用相同的方法,我们也就能够理解一个句子的含义、一个函数的含义,乃至整段源代码的含义。
|
||||
|
||||
这也就是说,AST加上这些语义规则,就能完整地反映源代码的含义。这个时候,你就可以做很多事情了。比如,你可以深度优先地遍历AST,并且一边遍历,一边执行语法规则。那么这个遍历过程,就是解释执行代码的过程。你相当于写了一个基于AST的解释器。
|
||||
|
||||
不过在此之前,编译器还要做点语义分析工作。**那么这里的语义分析是要解决什么问题呢?**
|
||||
|
||||
给你举个例子,如果我把示例程序稍微变换一下,加一个全局变量的声明,这个全局变量也叫a。那你觉得“a+3”中的变量a指的是哪个变量?
|
||||
|
||||
```
|
||||
int a = 10; //全局变量
|
||||
int foo(int a){ //参数里有另一个变量a
|
||||
int b = a + 3; //这里的a指的是哪一个?
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们知道,编译程序要根据C语言在作用域方面的语义规则,识别出“a+3”中的a,所以这里指的其实是函数参数中的a,而不是全局变量的a。这样的话,我们在计算“a+3”的时候才能取到正确的值。
|
||||
|
||||
而把“a+3”中的a,跟正确的变量定义关联的过程,就叫做**引用消解**(Resolve)。这个时候,变量a的语义才算是清晰了。
|
||||
|
||||
变量有点像自然语言里的代词,比如说,“我喜欢又聪明又勇敢的你”中的“我”和“你”,指的是谁呢?如果这句话前面有两句话,“我是春娇,你是志明”,那这句话的意思就比较清楚了,是“春娇喜欢又聪明又勇敢的志明”。
|
||||
|
||||
引用消解需要在上下文中查找某个标识符的定义与引用的关系,所以我们现在可以回答前面的问题了,**语义分析的重要特点,就是做上下文相关的分析。**
|
||||
|
||||
在语义分析阶段,编译器还会识别出数据的类型。比如,在计算“a+3”的时候,我们必须知道a和3的类型是什么。因为**即使同样是加法运算,对于整型和浮点型数据,其计算方法也是不一样的。**
|
||||
|
||||
语义分析获得的一些信息(引用消解信息、类型信息等),会附加到AST上。这样的AST叫做**带有标注信息的AST**(Annotated AST/Decorated AST),用于更全面地反映源代码的含义。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/b7/3685791207d4430e5a48bc7d96aa99b7.jpg" alt="">
|
||||
|
||||
好了,前面我所说的,都是如何让编译器更好地理解程序的语义。不过在语义分析阶段,编译器还要做很多语义方面的检查工作。
|
||||
|
||||
在自然语言里,我们可以很容易写出一个句子,它在语法上是正确的,但语义上是错误的。比如,“小猫喝水”这句话,它在语法和语义上都是对的;而“水喝小猫”这句话,语法是对的,语义上则是不对的。
|
||||
|
||||
计算机程序也会存在很多类似的语义错误的情况。比如说,对于“int b = a+3”的这个语句,语义规则要求,等号右边的表达式必须返回一个整型的数据(或者能够自动转换成整型的数据),否则就跟变量b的类型不兼容。如果右边的表达式“a+3”的计算结果是浮点型的,就违背了语义规则,就要报错。
|
||||
|
||||
总结起来,在语义分析阶段,编译器会做**语义理解和语义检查**这两方面的工作。词法分析、语法分析和语义分析,统称编译器的**前端**,它完成的是对源代码的理解工作。
|
||||
|
||||
**做完语义分析以后,接下来编译器要做什么呢?**
|
||||
|
||||
本质上,编译器这时可以直接生成目标代码,因为编译器已经完全理解了程序的含义,并把它表示成了带有语义信息的AST、符号表等数据结构。
|
||||
|
||||
**生成目标代码的工作,叫做后端工作**。做这项工作有一个前提,就是编译器需要懂得目标语言,也就是懂得目标语言的词法、语法和语义,这样才能保证翻译的准确性。这是显而易见的,只懂英语,不懂汉语,是不可能做英译汉的。通常来说,目标代码指的是汇编代码,它是汇编器(Assembler)所能理解的语言,跟机器码有直接的对应关系。汇编器能够将汇编代码转换成**机器码**。
|
||||
|
||||
熟练掌握汇编代码对于初学者来说会有一定的难度。但更麻烦的是,对于不同架构的CPU,还需要生成不同的汇编代码,这使得我们的工作量更大。所以,我们通常要在这个时候增加一个环节:先翻译成中间代码(Intermediate Representation,IR)。
|
||||
|
||||
## 中间代码(Intermediate Representation)
|
||||
|
||||
中间代码(IR),是处于源代码和目标代码之间的一种表示形式。
|
||||
|
||||
我们倾向于使用IR有两个原因。
|
||||
|
||||
第一个原因,是很多解释型的语言,可以直接执行IR,比如Python和Java。这样的话,编译器生成IR以后就完成任务了,没有必要生成最终的汇编代码。
|
||||
|
||||
第二个原因更加重要。我们生成代码的时候,需要做大量的优化工作。而很多优化工作没有必要基于汇编代码来做,而是可以基于IR,用统一的算法来完成。
|
||||
|
||||
## 优化(Optimization)
|
||||
|
||||
**那为什么需要做优化工作呢?**这里又有两大类的原因。
|
||||
|
||||
**第一个原因,是源语言和目标语言有差异。**源语言的设计目的是方便人类表达和理解,而目标语言是为了让机器理解。在源语言里很复杂的一件事情,到了目标语言里,有可能很简单地就表达出来了。
|
||||
|
||||
比如“I want to hold your hand and with you I will grow old.” 这句话挺长的吧?用了13个单词,但它实际上是诗经里的“执子之手,与子偕老”对应的英文。这样看来,还是中国文言文承载信息的效率更高。
|
||||
|
||||
同样的情况在编程语言里也有。以Java为例,我们经常为某个类定义属性,然后再定义获取或修改这些属性的方法:
|
||||
|
||||
```
|
||||
Class Person{
|
||||
private String name;
|
||||
public String getName(){
|
||||
return name;
|
||||
}
|
||||
public void setName(String newName){
|
||||
this.name = newName
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
如果你在程序里用“**person.getName()**”来获取Person的name字段,会是一个开销很大的操作,因为它涉及函数调用。在汇编代码里,实现一次函数调用会做下面这一大堆事情:
|
||||
|
||||
```
|
||||
#调用者的代码
|
||||
保存寄存器1 #保存现有寄存器的值到内存
|
||||
保存寄存器2
|
||||
...
|
||||
保存寄存器n
|
||||
|
||||
把返回地址入栈
|
||||
把person对象的地址写入寄存器,作为参数
|
||||
跳转到getName函数的入口
|
||||
|
||||
#_getName 程序
|
||||
在person对象的地址基础上,添加一个偏移量,得到name字段的地址
|
||||
从该地址获取值,放到一个用于保存返回值的寄存器
|
||||
跳转到返回地
|
||||
|
||||
```
|
||||
|
||||
你看了这段伪代码,就会发现,简单的一个**getName()方法**,开销真的很大。保存和恢复寄存器的值、保存和读取返回地址,等等,这些操作会涉及好几次读写内存的操作,要花费大量的时钟周期。但这个逻辑其实是可以简化的。
|
||||
|
||||
怎样简化呢?就是**跳过方法的调用**。我们直接根据对象的地址计算出name属性的地址,然后直接从内存取值就行。这样优化之后,性能会提高好多倍。
|
||||
|
||||
这种优化方法就叫做**内联**(inlining),也就是把原来程序中的函数调用去掉,把函数内的逻辑直接嵌入函数调用者的代码中。在Java语言里,这种属性读写的代码非常多。所以,Java的JIT编译器(把字节码编译成本地代码)很重要的工作就是实现内联优化,这会让整体系统的性能提高很大的一个百分比!
|
||||
|
||||
总结起来,我们在把源代码翻译成目标代码的过程中,没有必要“直译”,而是可以“意译”。这样我们完成相同的工作,对资源的消耗会更少。
|
||||
|
||||
**第二个需要优化工作的原因,是程序员写的代码不是最优的,而编译器会帮你做纠正**。比如下面这段代码中的**bar()函数**,里面就有多个地方可以优化。甚至,整个对bar()函数的调用,也可以省略,因为bar()的值一定是101。这些优化工作都可以在编译期间完成。
|
||||
|
||||
```
|
||||
int bar(){
|
||||
int a = 10*10; //这里在编译时可以直接计算出100这个值,这叫做“常数折叠”
|
||||
int b = 20; //这个变量没有用到,可以在代码中删除,这叫做“死代码删除”
|
||||
|
||||
|
||||
if (a>0){ //因为a一定大于0,所以判断条件和else语句都可以去掉
|
||||
return a+1; //这里可以在编译器就计算出是101
|
||||
}
|
||||
else{
|
||||
return a-1;
|
||||
}
|
||||
}
|
||||
int a = bar(); //这里可以直接换成 a=101
|
||||
|
||||
```
|
||||
|
||||
综上所述,在生成目标代码之前,需要做的优化工作可以有很多,这通常也是编译器在运行时,花费时间最长的一个部分。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/82/26ad195418f2ce03155a625043348982.jpg" alt="">
|
||||
|
||||
而采用中间代码来编写优化算法的好处,是可以把大部分的优化算法,写成与具体CPU架构无关的形式,从而大大降低编译器适配不同CPU的工作量。并且,如果采用像LLVM这样的工具,我们还可以让多种语言的前端生成相同的中间代码,这样就可以复用中端和后端的程序了。
|
||||
|
||||
## 生成目标代码
|
||||
|
||||
编译器最后一个阶段的工作,是生成高效率的目标代码,也就是汇编代码。这个阶段,编译器也有几个重要的工作。
|
||||
|
||||
第一,是要选择合适的指令,生成性能最高的代码。
|
||||
|
||||
第二,是要优化寄存器的分配,让频繁访问的变量(比如循环变量)放到寄存器里,因为访问寄存器要比访问内存快100倍左右。
|
||||
|
||||
第三,是在不改变运行结果的情况下,对指令做重新排序,从而充分运用CPU内部的多个功能部件的并行计算能力。
|
||||
|
||||
目标代码生成以后,整个编译过程就完成了。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲我从头到尾概要地讲解了编译的过程,希望你能了解每一个阶段存在的原因(Why),以及要完成的主要任务(What)。编译是一个比较复杂的过程,但如果我们能够分而治之,那么每一步的挑战就会降低很多。这样最后针对每个子任务,我们就都能找到解决的办法。
|
||||
|
||||
我希望这一讲能帮你在大脑里建立起一个概要的地图。在后面几讲中,我会对编译过程的各个环节展开讨论,让你有越来越清晰的理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/70/a5/706f4e0f50ab6ce77fd6246cb8cea0a5.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你觉得做计算机语言的编译和自然语言的翻译,有哪些地方是相同的,哪些地方是不同的?
|
||||
|
||||
欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。感谢阅读,我们下一讲再见。
|
||||
222
极客时间专栏/geek/编译原理实战课/预备知识篇/02 | 词法分析:用两种方式构造有限自动机.md
Normal file
222
极客时间专栏/geek/编译原理实战课/预备知识篇/02 | 词法分析:用两种方式构造有限自动机.md
Normal file
@@ -0,0 +1,222 @@
|
||||
<audio id="audio" title="02 | 词法分析:用两种方式构造有限自动机" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2b/3e/2b367effc121c8d0eee6c9539acc493e.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
上一讲,我带你把整个编译过程走了一遍。这样,你就知道了编译过程的整体步骤,每一步是做什么的,以及为什么要这么做。
|
||||
|
||||
进一步地,你就可以研究一下每个环节具体是如何实现的、有哪些难点、有哪些理论和算法。通过这个过程,你不仅可以了解每个环节的原理,还能熟悉一些专有词汇。这样一来,你在读编译原理领域的相关资料时,就会更加顺畅了。
|
||||
|
||||
不过,编译过程中涉及的算法和原理有些枯燥,所以我会用尽量通俗、直观的方式来给你解读,让你更容易接受。
|
||||
|
||||
本讲,我主要跟你讨论一下词法分析(Lexical Analysis)这个环节。通过这节课,你可以掌握词法分析这个阶段是如何把字符串识别成一个个Token的。进而,你还会学到如何实现一个正则表达式工具,从而实现任意的词法解析。
|
||||
|
||||
## 词法分析的原理
|
||||
|
||||
首先,我们来了解一下词法分析的原理。
|
||||
|
||||
通过上一讲,你已经很熟悉词法分析的任务了:输入的是字符串,输出的是Token串。所以,词法分析器在英文中一般叫做Tokenizer。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/9a/d80623403c912ab43c328623df8a0e9a.jpg" alt="">
|
||||
|
||||
但具体如何实现呢?这里要有一个计算模型,叫做**有限自动机**(Finite-state Automaton,FSA),或者叫做有限状态自动机(Finite-state Machine,FSM)。
|
||||
|
||||
有限自动机这个名字,听上去可能比较陌生。但大多数程序员,肯定都接触过另一个词:**状态机**。假设你要做一个电商系统,那么订单状态的迁移,就是一个状态机。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/91/62/91093cbeaeb516a9b7bb7b393a53dc62.jpg" alt="">
|
||||
|
||||
有限自动机就是这样的状态机,它的状态数量是有限的。当它收到一个新字符的时候,会导致状态的迁移。比如说,下面的这个状态机能够区分标识符和数字字面量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/14/ac4f8932b2488ad0f3815853a4180114.jpg" alt="">
|
||||
|
||||
在这样一个状态机里,我用单线圆圈表示临时状态,双线圆圈表示接受状态。接受状态就是一个合格的Token,比如图3中的状态1(数字字面量)和状态2(标识符)。当这两个状态遇到空白字符的时候,就可以记下一个Token,并回到初始态(状态0),开始识别其他Token。
|
||||
|
||||
可以看出,**词法分析的过程,其实就是对一个字符串进行模式匹配的过程。**说起字符串的模式匹配,你能想到什么工具吗?对的,**正则表达式工具**。
|
||||
|
||||
大多数语言,以及一些操作系统的命令,都带有正则表达式工具,来帮助你匹配合适的字符串。比如下面的这个Linux命令,可以用来匹配所有包含“sa”“sb” … “sh”字符串的进程。
|
||||
|
||||
```
|
||||
ps -ef | grep 's[a-h]'
|
||||
|
||||
```
|
||||
|
||||
在这个命令里,“**s[a-h]**”是用来描述匹配规则的,我们把它叫做一个**正则表达式**。
|
||||
|
||||
同样地,正则表达式也可以用来描述词法规则。这种描述方法,我们叫做**正则文法**(Regular Grammar)。比如,数字字面量和标识符的正则文法描述是这样的:
|
||||
|
||||
```
|
||||
IntLiteral : [0-9]+; //至少有一个数字
|
||||
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
|
||||
|
||||
```
|
||||
|
||||
与普通的正则表达式工具不同的是,词法分析器要用到很多个词法规则,每个词法规则都采用**“Token类型: 正则表达式”**这样一种格式,用于匹配一种Token。
|
||||
|
||||
然而,当我们采用了多条词法规则的时候,有可能会出现词法规则冲突的情况。比如说,int关键字其实也是符合标识符的词法规则的。
|
||||
|
||||
```
|
||||
Int : int; //int关键字
|
||||
For : for; //for关键字
|
||||
Id : [A-Za-z][A-Za-z0-9]*; //以字母开头,后面可以是字符或数字
|
||||
|
||||
```
|
||||
|
||||
所以,词法规则里面要有优先级,比如排在前面的词法规则优先级更高。这样的话,我们就能够设计出区分int关键字和标识符的有限自动机了,可以画成下面的样子。其中,状态1、2和3都是标识符,而状态4则是int关键字。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/1c/31d083e8e33b25c1b6ea721f20e7ce1c.jpg" alt="">
|
||||
|
||||
## 从正则表达式生成有限自动机
|
||||
|
||||
现在,你已经了解了如何构造有限自动机,以及如何处理词法规则的冲突。基本上,你就可以按照上面的思路来手写词法分析器了。但你可能觉得,这样手写词法分析器的步骤太繁琐了,**我们能否只写出词法规则,就自动生成相对应的有限自动机呢?**
|
||||
|
||||
当然是可以的,实际上,正则表达式工具就是这么做的。此外,词法分析器生成工具lex(及GNU版本的flex)也能够基于规则自动生成词法分析器。
|
||||
|
||||
它的具体实现思路是这样的:**把一个正则表达式翻译成NFA,然后把NFA转换成DFA。**对不起,我这里又引入了两个新的术语:NFA和DFA。
|
||||
|
||||
先说说**DFA**,它是“Deterministic Finite Automaton”的缩写,即**确定的有限自动机**。它的特点是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。前面例子中的有限自动机,都属于DFA。
|
||||
|
||||
再说说**NFA**,它是“Nondeterministic Finite Automaton”的缩写,即**不确定的有限自动机**。它的特点是:该状态机中存在某些状态,针对某些输入,不能做一个确定的转换。
|
||||
|
||||
这又细分成两种情况:
|
||||
|
||||
1. 对于一个输入,它有两个状态可以转换。
|
||||
1. 存在ε转换的情况,也就是没有任何字符输入的情况下,NFA也可以从一个状态迁移到另一个状态。
|
||||
|
||||
比如,“a[a-zA-Z0-9]*bc”这个正则表达式,对字符串的要求是以a开头,以bc结尾,a和bc之间可以有任意多个字母或数字。可以看到,在图5中,状态1的节点输入b时,这个状态是有两条路径可以选择的:一条是迁移到状态2,另一条是仍然保持在状态1。所以,这个有限自动机是一个NFA。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/d6/41e29db09305197dda72697e97aa83d6.jpg" alt="">
|
||||
|
||||
这个NFA还有引入ε转换的画法,如图6所示,它跟图5的画法是等价的。实际上,图6表示的NFA可以用我们下面马上要讲到的算法,通过正则表达式自动生成出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/9a/4776f1393ea7d700668f42a6065e059a.jpg" alt="">
|
||||
|
||||
需要注意的是,无论是NFA还是DFA,都等价于正则表达式。也就是说,**所有的正则表达式都能转换成NFA或DFA;而所有的NFA或DFA,也都能转换成正则表达式。**
|
||||
|
||||
理解了NFA和DFA以后,接下来我再大致说一下算法。
|
||||
|
||||
首先,一个正则表达式可以机械地翻译成一个NFA。它的翻译方法如下:
|
||||
|
||||
- **识别字符i的NFA。**
|
||||
|
||||
当接受字符i的时候,引发一个转换,状态图的边上标注i。其中,第一个状态(i,initial)是初始状态,第二个状态(f,final)是接受状态。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/37/13c0df37440d701b4ec1484dd900ec37.jpg" alt="">
|
||||
|
||||
- **转换“s|t”这样的正则表达式。**
|
||||
|
||||
它的意思是,或者s,或者t,二者选一。s和t本身是两个子表达式,我们可以增加两个新的状态:开始状态和接受状态。然后,用ε转换分别连接代表s和t的子图。它的含义也比较直观,要么走上面这条路径,那就是s,要么走下面这条路径,那就是t:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/31/b7c2a649036b35b45b1f2af597b45e31.jpg" alt="">
|
||||
|
||||
- **转换“st”这样的正则表达式。**
|
||||
|
||||
s之后接着出现t,转换规则是把s的开始状态变成st整体的开始状态,把t的结束状态变成st整体的结束状态,并且把s的结束状态和t的开始状态合二为一。这样就把两个子图衔接了起来,走完s接着走t。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/03/f52985c394f156ff477f91a8b7f83303.jpg" alt="">
|
||||
|
||||
- **对于“?”“*”和“+”这样的符号,它们的意思是可以重复0次、0到多次、1到多次,转换时要增加额外的状态和边**。以“s*”为例,我们可以做下面的转换:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5b/09/5bb1470f0a07b6decfc8ac4fdbf5eb09.jpg" alt="">
|
||||
|
||||
你能看出,它可以从i直接到f,也就是对s匹配0次,也可以在s的起止节点上循环多次。
|
||||
|
||||
如果是“s+”,那就没有办法跳过s,s至少要经过一次:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/de/543966ed03d9b25cec569d80f8b304de.jpg" alt="">
|
||||
|
||||
通过这样的转换,所有的正则表达式,都可以转换为一个NFA。
|
||||
|
||||
基于NFA,你仍然可以实现一个词法分析器,只不过算法会跟基于DFA的不同:当某个状态存在一条以上的转换路径的时候,你要先尝试其中的一条;如果匹配不上,再退回来,尝试其他路径。这种试探不成功再退回来的过程,叫做**回溯(Backtracking)**。
|
||||
|
||||
小提示:下一讲的**递归下降算法**里,也会出现回溯现象,你可以对照着理解。
|
||||
|
||||
基于NFA,你也可以写一个正则表达式工具。实际上,我在示例程序中已经写了一个简单的正则表达式工具,使用了[Regex.java](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/Regex.java)中的**regexToNFA方法**。如下所示,我用了一个测试用的正则表达式,它能识别int关键字、标识符和数字字面量。在示例程序中,这个正则表达式首先被表示为一个内部的树状数据结构,然后可以转换成NFA。
|
||||
|
||||
```
|
||||
int | [a-zA-Z][a-zA-Z0-9]* | [0-9]*
|
||||
|
||||
```
|
||||
|
||||
示例程序也会将生成的NFA打印输出,下面的输出结果中列出了所有的状态,以及每个状态到其他状态的转换,比如“0 ε -> 2”的意思是从状态0通过 ε 转换,到达状态2 :
|
||||
|
||||
```
|
||||
NFA states:
|
||||
0 ε -> 2
|
||||
ε -> 8
|
||||
ε -> 14
|
||||
2 i -> 3
|
||||
3 n -> 5
|
||||
5 t -> 7
|
||||
7 ε -> 1
|
||||
1 (end)
|
||||
acceptable
|
||||
8 [a-z]|[A-Z] -> 9
|
||||
9 ε -> 10
|
||||
ε -> 13
|
||||
10 [0-9]|[a-z]|[A-Z] -> 11
|
||||
11 ε -> 10
|
||||
ε -> 13
|
||||
13 ε -> 1
|
||||
14 [0-9] -> 15
|
||||
15 ε -> 14
|
||||
ε -> 1
|
||||
|
||||
```
|
||||
|
||||
我用图片来直观展示一下输出结果,分为上、中、下三条路径,你能清晰地看出解析int关键字、标识符和数字字面量的过程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/38/71/38332d3ad4bbedc1979f5fe2936efc71.jpg" alt="">
|
||||
|
||||
**那么生成NFA之后,我们要如何利用它,来识别某个字符串是否符合这个NFA代表的正则表达式呢?**
|
||||
|
||||
还是以图12为例,当我们解析“intA”这个字符串时,首先选择最上面的路径进行匹配,匹配完int这三个字符以后,来到状态7,若后面没有其他字符,就可以到达接受状态1,返回匹配成功的信息。
|
||||
|
||||
**可实际上,int后面是有A的,所以第一条路径匹配失败。**失败之后不能直接返回“匹配失败”的结果,因为还有其他路径,所以我们要回溯到状态0,去尝试第二条路径,在第二条路径中,我们尝试成功了。
|
||||
|
||||
运行Regex.java中的**matchWithNFA()方法**,你可以用NFA来做正则表达式的匹配。其中,在匹配“intA”时,你会看到它的回溯过程:
|
||||
|
||||
```
|
||||
NFA matching: 'intA'
|
||||
trying state : 0, index =0
|
||||
trying state : 2, index =0 //先走第一条路径,即int关键字这个路径
|
||||
trying state : 3, index =1
|
||||
trying state : 5, index =2
|
||||
trying state : 7, index =3
|
||||
trying state : 1, index =3 //到了末尾,发现还有字符'A'没有匹配上
|
||||
trying state : 8, index =0 //回溯,尝试第二条路径,即标识符
|
||||
trying state : 9, index =1
|
||||
trying state : 10, index =1 //在10和11这里循环多次
|
||||
trying state : 11, index =2
|
||||
trying state : 10, index =2
|
||||
trying state : 11, index =3
|
||||
trying state : 10, index =3
|
||||
true
|
||||
|
||||
```
|
||||
|
||||
**从中你可以看到用NFA算法的特点**:因为存在多条可能的路径,所以需要试探和回溯,在比较极端的情况下,回溯次数会非常多,性能会变得非常差。特别是当处理类似“s*”这样的语句时,因为s可以重复0到无穷次,所以在匹配字符串时,可能需要尝试很多次。
|
||||
|
||||
NFA的运行可能导致大量的回溯,**那么能否将NFA转换成DFA,让字符串的匹配过程更简单呢?**如果能的话,那整个过程都可以自动化,从正则表达式到NFA,再从NFA到DFA。
|
||||
|
||||
方法是有的,这个算法就是**子集构造法**。不过我这里就不展开介绍了,如果你想继续深入学习的话,可以去看看本讲最后给出的参考资料。
|
||||
|
||||
总之,只要有了准确的正则表达式,是可以根据算法自动生成对字符串进行匹配的程序的,这就是正则表达式工具的基本原理,也是有些工具(比如ANTLR和flex)能够自动给你生成一个词法分析器的原理。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲涵盖了词法分析所涉及的主要知识点。词法分析跟你日常使用的正则表达式关系很密切,你可以用正则表达式来表示词法规则。
|
||||
|
||||
在实际的编译器中,词法分析器一般都是手写的,依据的基本原理就是构造有限自动机。不过有一些地方也会用手工编码的方式做一些优化(如javac编译器),有些编译器会做用一些特别的技巧来提升解析速度(如JavaScript的V8编译器),你在后面的课程中会看到。
|
||||
|
||||
基于正则表达式构造NFA,再去进行模式匹配,是一个很好的算法思路,它不仅仅可以用于做词法分析,其实还可以用于解决其他问题(比如做语法分析),值得你去做举一反三的思考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/bb/90af03a17ffc5fb441327198a753ecbb.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你可以试着写出识别整型字面量和浮点型字面量的词法规则,手工构造一个有限自动机。
|
||||
|
||||
欢迎在留言区谈谈你的实践体会,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
关于从NFA转DFA的算法,你可以参考_Compilers - Principles, Techniques & Tools_(龙书,第2版)第3.7.1节,或者《编译原理之美》的[第16讲](https://time.geekbang.org/column/article/137286)。
|
||||
391
极客时间专栏/geek/编译原理实战课/预备知识篇/03 | 语法分析:两个基本功和两种算法思路.md
Normal file
391
极客时间专栏/geek/编译原理实战课/预备知识篇/03 | 语法分析:两个基本功和两种算法思路.md
Normal file
@@ -0,0 +1,391 @@
|
||||
<audio id="audio" title="03 | 语法分析:两个基本功和两种算法思路" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fd/b2/fde19baf67bb15e4abf77a5abcf43eb2.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
通过[第1讲](https://time.geekbang.org/column/article/242479)的学习,现在你已经清楚了语法分析阶段的任务:依据语法规则,把Token串转化成AST。
|
||||
|
||||
今天,我就带你来掌握语法分析阶段的核心知识点,也就是两个基本功和两种算法思路。理解了这些重要的知识点,对于语法分析,你就不是外行了。
|
||||
|
||||
- **两个基本功**:第一,必须能够阅读和书写语法规则,也就是掌握上下文无关文法;第二,必须要掌握递归下降算法。
|
||||
- **两种算法思路**:一种是自顶向下的语法分析,另一种则是自底向上的语法分析。
|
||||
|
||||
## 上下文无关文法(Context-Free Grammar)
|
||||
|
||||
在开始语法分析之前,我们要解决的第一个问题,就是**如何表达语法规则**。在上一讲中,你已经了解了,我们可以用正则表达式来表达词法规则,语法规则其实也差不多。
|
||||
|
||||
我还是以下面这个示例程序为例,里面用到了变量声明语句、加法表达式,我们看看语法规则应该怎么写:
|
||||
|
||||
```
|
||||
int a = 2;
|
||||
int b = a + 3;
|
||||
return b;
|
||||
|
||||
```
|
||||
|
||||
第一种写法是下面这个样子,它看起来跟上一讲的词法规则差不多,都是左边是规则名称,右边是正则表达式。
|
||||
|
||||
```
|
||||
start:blockStmts ; //起始
|
||||
block : '{' blockStmts '}' ; //语句块
|
||||
blockStmts : stmt* ; //语句块中的语句
|
||||
stmt = varDecl | expStmt | returnStmt | block; //语句
|
||||
varDecl : type Id varInitializer? ';' ; //变量声明
|
||||
type : Int | Long ; //类型
|
||||
varInitializer : '=' exp ; //变量初始化
|
||||
expStmt : exp ';' ; //表达式语句
|
||||
returnStmt : Return exp ';' ; //return语句
|
||||
exp : add ; //表达式
|
||||
add : add '+' mul | mul; //加法表达式
|
||||
mul : mul '*' pri | pri; //乘法表达式
|
||||
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
|
||||
|
||||
```
|
||||
|
||||
在语法规则里,我们把冒号左边的叫做**非终结符**(Non-terminal),又叫**变元**(Variable)。非终结符可以按照右边的正则表达式来逐步展开,直到最后都变成标识符、字面量、运算符这些不可再展开的符号,也就是**终结符**(Terminal)。终结符其实也是词法分析过程中形成的Token。
|
||||
|
||||
提示:<br>
|
||||
1.在本课程,非终结符以小写字母开头,终结符则以大写字母开头,或者是一个原始的字符串格式。<br>
|
||||
2.在谈论语法分析的时候,我们可以把Token和终结符这两个术语互换使用。
|
||||
|
||||
像这样左边是非终结符,右边是正则表达式的书写语法规则的方式,就叫做**扩展巴科斯范式(EBNF)。**你在ANTLR这样的语法分析器生成工具中,经常会看到这种格式的语法规则。
|
||||
|
||||
对于EBNF的严格定义,你可以去参考[Wikipedia](https://zh.wikipedia.org/wiki/%E6%89%A9%E5%B1%95%E5%B7%B4%E7%A7%91%E6%96%AF%E8%8C%83%E5%BC%8F)上的解释。
|
||||
|
||||
在教科书中,我们还经常采用另一种写法,就是**产生式**(Production Rule),又叫做**替换规则**(Substitution Rule)。产生式的左边是非终结符(变元),它可以用右边的部分替代,中间通常会用箭头连接。
|
||||
|
||||
为了避免跟EBNF中的“*”号、“+”号等冲突,在本节课中,凡是采用EBNF格式,就给字符串格式的终结符加引号,左右两边用“::=”或冒号分隔开;凡是采用产生式,字符串就不加引号,并且采用“->”分隔产生式的左右两侧。
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
add -> mul
|
||||
mul -> mul * pri
|
||||
mul -> pri
|
||||
|
||||
```
|
||||
|
||||
也有个偷懒的写法,就是把同一个变元的多个产生式写在一起,用竖线分隔(但这时候,如果产生式里面原本就要用到“|”终结符,那么就要加引号来进行区分)。但也就仅此为止了,不会再引入“*”和“+”等符号,否则就成了EBNF了。
|
||||
|
||||
```
|
||||
add -> add + mul | mul
|
||||
mul -> mul * pri | pri
|
||||
|
||||
```
|
||||
|
||||
产生式不用“ * ”和“+”来表示重复,而是用迭代,并引入“ε”(空字符串)。所以“blockStmts : stmt*”可以写成下面这个样子:
|
||||
|
||||
```
|
||||
blockStmts -> stmt blockStmts | ε
|
||||
|
||||
```
|
||||
|
||||
总结起来,语法规则是由4个部分组成的:
|
||||
|
||||
- 一个有穷的非终结符(或变元)的集合;
|
||||
- 一个有穷的终结符的集合;
|
||||
- 一个有穷的产生式集合;
|
||||
- 一个起始非终结符(变元)。
|
||||
|
||||
那么符合这四个特点的文法规则,就叫做**上下文无关文法**(Context-Free Grammar,CFG)。
|
||||
|
||||
你可能会问,**上下文无关文法和词法分析中用到的正则文法是否有一定的关系?**
|
||||
|
||||
**是的,正则文法是上下文无关文法的一个子集。**其实,正则文法也可以写成产生式的格式。比如,数字字面量(正则表达式为“[0-9]+”)可以写成:
|
||||
|
||||
```
|
||||
IntLiteral -> Digit IntLiteral1
|
||||
IntLiteral1 -> Digit IntLiteral1
|
||||
IntLiteral1 -> ε
|
||||
Digit -> [0-9]
|
||||
|
||||
```
|
||||
|
||||
但是,在上下文无关文法里,产生式的右边可以放置任意的终结符和非终结符,而正则文法只是其中的一个子集,叫做**线性文法**(Linear Grammar)。它的特点是产生式的右边部分最多只有一个非终结符,比如X->aYb,其中a和b是终结符。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/99/29/99a69f477f20f1a4eae194116adb7829.jpg" alt="">
|
||||
|
||||
你可以试一下,把上一讲用到的正则表达式“a[a-zA-Z0-9]*bc”写成产生式的格式,它就符合线性文法的特点。
|
||||
|
||||
```
|
||||
S0 -> aS1bc
|
||||
S1 -> [a-zA-Z0-9]S1
|
||||
S1 -> ε
|
||||
|
||||
```
|
||||
|
||||
但对于常见的语法规则来说,正则文法是不够的。比如,你最常用的算术表达式的规则,就没法用正则文法表示,因为有的产生式需要包含两个非终结符(如“add + mul”)。你可以试试看,能把“2+3”“2+3*5”“2+3+4+5”等各种可能的算术表达式,用一个正则表达式写出来吗?实际是不可能的。
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
add -> mul
|
||||
mul -> mul * pri
|
||||
mul -> pri
|
||||
|
||||
```
|
||||
|
||||
好,现在你已经了解了上下文无关文法,以及它与正则文法的区别。可是,**为什么它会叫“上下文无关文法”这样一个奇怪的名字呢?难道还有上下文相关的文法吗?**
|
||||
|
||||
答案的确是有的。举个例子来说,在高级语言里,本地变量必须先声明,才能在后面使用。这种制约关系就是上下文相关的。
|
||||
|
||||
不过,在语法分析阶段,我们一般不管上下文之间的依赖关系,这样能使得语法分析的任务更简单。而对于上下文相关的情况,则放到语义分析阶段再去处理。
|
||||
|
||||
好了,现在你已经知道,用上下文无关文法可以描述程序的语法结构。学习编译原理,阅读和书写语法规则是一项基本功。针对高级语言中的各种语句,你要都能够手写出它们的语法规则来才可以。
|
||||
|
||||
接下来,我们就要**依据语法规则,编写语法分析程序,把Token串转化成AST。**语法分析的算法有很多,但有一个算法也是你必须掌握的一项基本功,这就是递归下降算法。
|
||||
|
||||
## 递归下降算法(Recursive Descent Parsing)
|
||||
|
||||
递归下降算法其实很简单,它的基本思路就是按照语法规则去匹配Token串。比如说,变量声明语句的规则如下:
|
||||
|
||||
```
|
||||
varDecl : types Id varInitializer? ';' ; //变量声明
|
||||
varInitializer : '=' exp ; //变量初始化
|
||||
exp : add ; //表达式
|
||||
add : add '+' mul | mul; //加法表达式
|
||||
mul : mul '*' pri | pri; //乘法表达式
|
||||
pri : IntLiteral | Id | '(' exp ')' ; //基础表达式
|
||||
|
||||
```
|
||||
|
||||
如果写成产生式格式,是下面这样:
|
||||
|
||||
```
|
||||
varDecl -> types Id varInitializer ';'
|
||||
varInitializer -> '=' exp
|
||||
varInitializer -> ε
|
||||
exp -> add
|
||||
add -> add + mul
|
||||
add -> mul
|
||||
mul -> mul * pri
|
||||
mul -> pri
|
||||
pri -> IntLiteral
|
||||
pri -> Id
|
||||
pri -> ( exp )
|
||||
|
||||
```
|
||||
|
||||
而基于这个规则做解析的算法如下:
|
||||
|
||||
```
|
||||
匹配一个数据类型(types)
|
||||
匹配一个标识符(Id),作为变量名称
|
||||
匹配初始化部分(varInitializer),而这会导致下降一层,使用一个新的语法规则:
|
||||
匹配一个等号
|
||||
匹配一个表达式(在这个步骤会导致多层下降:exp->add->mul->pri->IntLiteral)
|
||||
创建一个varInitializer对应的AST节点并返回
|
||||
如果没有成功地匹配初始化部分,则回溯,匹配ε,也就是没有初始化部分。
|
||||
匹配一个分号
|
||||
创建一个varDecl对应的AST节点并返回
|
||||
|
||||
```
|
||||
|
||||
用上述算法解析“int a = 2”,就会生成下面的AST:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/31/ed/3102dff3c43e5bcd40ddf6442947dced.jpg" alt="">
|
||||
|
||||
那么总结起来,递归下降算法的特点是:
|
||||
|
||||
- 对于一个非终结符,要从左到右依次匹配其产生式中的每个项,包括非终结符和终结符。
|
||||
- 在匹配产生式右边的非终结符时,要下降一层,继续匹配该非终结符的产生式。
|
||||
- 如果一个语法规则有多个可选的产生式,那么只要有一个产生式匹配成功就行。如果一个产生式匹配不成功,那就回退回来,尝试另一个产生式。这种回退过程,叫做**回溯**(Backtracking)。
|
||||
|
||||
所以说,递归下降算法是非常容易理解的。它能非常有效地处理很多语法规则,但是它也有两个缺点。
|
||||
|
||||
**第一个缺点,就是著名的左递归(Left Recursion)问题。**比如,在匹配算术表达式时,产生式的第一项就是一个非终结符add,那么按照算法,要下降一层,继续匹配add。这个过程会一直持续下去,无限递归下去。
|
||||
|
||||
```
|
||||
add -> add + mul
|
||||
|
||||
```
|
||||
|
||||
所以,递归下降算法是无法处理左递归问题的。那么有什么解决办法吗?
|
||||
|
||||
你可能会说,把产生式改成右递归不就可以了吗?也就是add这个递归项在右边:
|
||||
|
||||
```
|
||||
add -> mul + add
|
||||
|
||||
```
|
||||
|
||||
这样确实可以避免左递归问题,但它同时也会导致**结合性**的问题。
|
||||
|
||||
举个例子来说,我们按照上面的语法规则来解析“2+3+4”这个表达式,会形成如下所示的AST。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/20/08df3cff28b8b53a4b3dd1e30d282820.jpg" alt="">
|
||||
|
||||
它会先计算“3+4”,而不是先计算“2+3”。这破坏了加法的结合性规则,加法运算本来应该是左结合的。
|
||||
|
||||
其实有一个标准的方法,能避免左递归问题。我们可以改写原来的语法规则,也就是引入`add'`,把左递归变成右递归:
|
||||
|
||||
```
|
||||
add -> mul add'
|
||||
add' -> + mul add' | ε
|
||||
|
||||
```
|
||||
|
||||
接下来,我们用刚刚改写的规则再次解析一下 “2+3+4”这个表达式,会得到下图中的AST:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/46/861f47308498c402dfab6798c3b7d246.jpg" alt="">
|
||||
|
||||
你能看出,这种改写方法虽然能够避免左递归问题,但由于`add'`的规则是右递归的,采用标准的递归下降算法,仍然会出现运算符结合性的错误。那么针对这点,我们有没有解决办法呢?
|
||||
|
||||
有的,方法就是**把递归调用转化成循环**。这里利用了很多同学都知道的一个原理,即递归调用可以转化为循环。
|
||||
|
||||
其实我把上面的规则换成用EBNF方式来表达就很清楚了。在EBNF格式里,允许用“*”号和“+”号表示重复:
|
||||
|
||||
```
|
||||
add : mul ('+' mul)* ;
|
||||
|
||||
```
|
||||
|
||||
所以说,对于`('+'mul)*`这部分,我们其实可以写成一个循环。而在循环里,我们可以根据结合性的要求,手工生成正确的AST。它的伪代码如下:
|
||||
|
||||
```
|
||||
左子节点 = 匹配一个mul
|
||||
while(下一个Token是+){
|
||||
消化掉+
|
||||
右子节点 = 匹配一个mul
|
||||
用左、右子节点创建一个add节点
|
||||
左子节点 = 该add节点
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
采用上面的算法,就可以创建正确的AST,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/5b/fdf3da5d525ddd949e318b1a6fa5895b.jpg" alt="">
|
||||
|
||||
**递归下降算法的第二个缺点,就是当产生式匹配失败的时候,必须要“回溯”,这就可能导致浪费。**
|
||||
|
||||
这个时候,我们有个针对性的解决办法,就是预读后续的一个Token,判断该选择哪个产生式。
|
||||
|
||||
以stmt变元为例,考虑它的三个产生式,分别是变量声明语句、表达式语句和return语句。那么在递归下降算法中,我们可以在这里预读一个Token,看看能否根据这个Token来选择某个产生式。
|
||||
|
||||
经过仔细观察,你发现如果预读的Token是Int或Long,就选择变量声明语句;如果是IntLiteral、Id或左括号,就选择表达式语句;而如果是Return,则肯定是选择return语句。因为这三个语句开头的Token是不重叠的,所以你可以很明确地做出选择。
|
||||
|
||||
如果我们手写递归下降算法,可以用肉眼识别出每次应该基于哪个Token,选择用哪个产生式。但是,对于一些比较复杂的语法规则,我们要去看好几层规则,这样比较辛苦。
|
||||
|
||||
**那么能否有一个算法,来自动计算出选择不同产生式的依据呢?**当然是有的,这就是LL算法家族。
|
||||
|
||||
## LL算法:计算First和Follow集合
|
||||
|
||||
LL算法的要点,就是计算First和Follow集合。
|
||||
|
||||
**First集合是每个产生式开头可能会出现的Token的集合。**就像stmt有三个产生式,它的First集合如下表所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/63/b3/6316103438404e64f89e402ef28498b3.jpg" alt="">
|
||||
|
||||
而stmt的First集合,就是三个产生式的First集合的并集,也是Int Long IntLiteral Id ( Return。
|
||||
|
||||
总体来说,针对非终结符x,它的First集合的计算规则是这样的:
|
||||
|
||||
- 如果产生式以终结符开头,那么把这个终结符加入First(x);
|
||||
- 如果产生式以非终结符y开头,那么把First(y)加入First(x);
|
||||
- 如果First(y)包含ε,那要把下一个项的First集合也加入进来,以此类推;
|
||||
- 如果x有多个产生式,那么First(x)是每个产生式的并集。
|
||||
|
||||
在计算First集合的时候,具体可以采用“**不动点法**”。相关细节这里就不展开了,你可以参考示例程序[FirstFollowSet](https://github.com/RichardGong/PlayWithCompiler/blob/master/lab/16-18/src/main/java/play/parser/FirstFollowSet.java)类的CalcFirstSets()方法,运行示例程序能打印各个非终结符的First集合。
|
||||
|
||||
不过,这样是不是就万事大吉了呢?
|
||||
|
||||
其实还有一种特殊情况我们需要考虑,那就是对于某个非终结符,它自身会产生ε的情况。比如说,示例文法中的blockStmts,它是可能产生ε的,也就是块中一个语句都没有。
|
||||
|
||||
```
|
||||
block : '{' blockStmts '}' ; //语句块
|
||||
blockStmts : stmt* ; //语句块中的语句
|
||||
stmt = varDecl | expStmt | returnStmt; //语句
|
||||
|
||||
```
|
||||
|
||||
语法解析器在这个时候预读的下一个Token是什么呢?是右花括号。这证明blockStmts产生了ε,所以才读到了后续跟着的花括号。
|
||||
|
||||
**对于某个非终结符后面可能跟着的Token的集合,我们叫做Follow集合。**如果预读到的Token在Follow中,那么我们就可以判断当前正在匹配的这个非终结符,产生了ε。
|
||||
|
||||
Follow的算法也比较简单,以非终结符x为例:
|
||||
|
||||
- 扫描语法规则,看看x后面都可能跟着哪些符号;
|
||||
- 对于后面跟着的终结符,都加到Follow(x)集合中去;
|
||||
- 如果后面是非终结符y,就把First(y)加Follow(x)集合中去;
|
||||
- 最后,如果First(y)中包含ε,就继续往后找;
|
||||
- 如果x可能出现在程序结尾,那么要把程序的终结符$加入到Follow(x)中去。
|
||||
|
||||
这样在计算了First和Follow集合之后,你就可以通过预读一个Token,来完全确定采用哪个产生式。这种算法,就叫做**LL(1)算法**。
|
||||
|
||||
LL(1)中的第一个L,是Left-to-right的缩写,代表从左向右处理Token串。第二个L,是Leftmost的缩写,意思是最左推导。**最左推导是什么呢?**就是它总是先把产生式中最左侧的非终结符展开完毕以后,再去展开下一个。这也就相当于对AST从左子节点开始的深度优先遍历。LL(1)中的1,指的是预读一个Token。
|
||||
|
||||
## LR算法:移进和规约
|
||||
|
||||
前面讲的递归下降和LL算法,都是自顶向下的算法。还有一类算法,是自底向上的,其中的代表就是**LR算法**。
|
||||
|
||||
自顶向下的算法,是从根节点逐层往下分解,形成最后的AST;而LR算法的原理呢,则是从底下先拼凑出AST的一些局部拼图,并逐步组装成一棵完整的AST。**所以,其中的关键之处在于如何“拼凑”。**
|
||||
|
||||
假设我们采用下面的上下文无关文法,来推演一个实例,具体语法规则如下所示:
|
||||
|
||||
```
|
||||
start->add
|
||||
add->add+mul
|
||||
add->mul
|
||||
mul->mul*pri
|
||||
mul->pri
|
||||
pri->Int
|
||||
pri->(add)
|
||||
|
||||
```
|
||||
|
||||
如果用于解析“2+3*5”,最终会形成下面的AST:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/8e/c7dc8d39cdbd32785e785c53e21e738e.jpg" alt="">
|
||||
|
||||
那算法是怎么从底部凑出这棵AST来的呢?
|
||||
|
||||
LR算法和LL算法一样,也是从左到右地消化掉Token。在第1步,它会取出“2”这个Token,放到一个栈里,这个栈是用来组装AST的工作区。同时,它还会预读下一个Token,也就是“+”号,用来帮助算法做判断。
|
||||
|
||||
在下面的示意图里,我画了一条橙色竖线,竖线的左边是栈,右边是预读到的一个Token。在做语法解析的过程中,竖线会不断地往右移动,把Token放到栈里,这个过程叫做“**移进**”(Shift)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6c/97/6c6ecc3391053afdbff110a4ada60d97.jpg" alt="">
|
||||
|
||||
注意,我在图7中还用虚线框推测了AST的其他部分。也就是说,如果第一个Token遇到的是整型字面量,而后面跟着一个+号,那么这两个Token就决定了它们必然是这棵推测出来的AST的一部分。而图中右边就是它的推导过程,其中的每个步骤,都使用了一个产生式加了一个点(如“.add”)。这个点,就相当于图中左边的橙色竖线。
|
||||
|
||||
所以你就可以根据这棵假想的AST,也就是依据假想的推导过程,给它反推回去。把Int还原为pri。这个还原过程,就叫做“**规约**”(Reduce)。工作区里的元素也随之更新成pri。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a1/81/a1189673c2bc6964b43f32b7db2aa781.jpg" alt="">
|
||||
|
||||
按照这样的思路,不断地移进和规约,这棵AST中推测出来的节点会不断地被证实。而随着读入的Token越来越多,这棵AST也会长得越来越高,整棵树变得更大。下图是推导过程中间的一个步骤。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/41/82/412af5303189b7c80b0de5960cf85982.jpg" alt="">
|
||||
|
||||
最后,整个AST构造完毕,而工作区里也就只剩了一个Start节点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5a/b7/5ae146b8f2b622741daff8ca97f995b7.jpg" alt="">
|
||||
|
||||
通过上面的介绍,你应该已经建立了对LR算法的直觉认识。如果要把这个推导过程写成严密的算法,你可以参考《编译原理之美》的[第18讲](https://time.geekbang.org/column/article/139628)。
|
||||
|
||||
从示例中,你应该已经看出来了,相对于LL算法,LR算法的优点是能够处理左递归文法。但它也有缺点,比如不利于输出全面的编译错误信息。因为在没有解析完毕之前,算法并不知道最后的AST是什么样子,所以也不清楚当前的语法错误在整体AST中的位置。
|
||||
|
||||
最后我再提一下LR的意思,来帮你更完整地理解LR算法。L还是代表从左到右读入Token,而R是最右推导(Rightmost)的意思。我把“2+3*5”最右推导的过程写在了下面,而如果你从最后一行往前一步步地看,它恰好就是规约的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/6f/ec26627ee1ca27094227c53a42d7476f.jpg" alt="">
|
||||
|
||||
如果你见到LR(k),那它的意思就是会预读k个Token,我们在示例中采用的是LR(1)。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天花了一讲的时间,把语法分析的要点给你讲解了一下。
|
||||
|
||||
对于上下文无关的文法,你要知道产生式、非终结符、终结符、EBNF这几个基本概念,能够熟练阅读各种语言的语法规则,这是一个基本功。
|
||||
|
||||
递归下降算法是另一项基本功,所以也一定要掌握。**你要注意,递归下降是深度优先的,只有最左边的子树都生成完了,才会往右生成它的兄弟节点。**有的同学会在没有把左侧的非终结符匹配完毕的情况下,就开始匹配右边的项,从而不自觉地采用了宽度优先的思路,这是我发现很多同学会容易陷入的一个思维误区。
|
||||
|
||||
对于LL算法和LR算法,我只做了简单的讲解,目的是为了帮助你建立直观的理解。我们在后面的课程中,还会遇到使用它们的实际例子,到时你可以与这一讲的内容相互印证。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/24/18/249343a116119f7d9e1e1e803d6c5318.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你可以计算一下示例文法中block、blockStmts、stmt、varDecl、returnStmt和expStmt的First和Follow集合吗?这样,你也可以熟悉一下First和Follow集合的计算方法。
|
||||
|
||||
欢迎在留言区分享你的答案。如果觉得有收获,也欢迎你把这节课分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1.线性文法(Linear Grammar):参见[Wikipedia](https://en.wikipedia.org/wiki/Linear_grammar)。<br>
|
||||
2.左递归及其消除方法:参见[Wikipedia](https://en.wikipedia.org/wiki/Left_recursion)。
|
||||
217
极客时间专栏/geek/编译原理实战课/预备知识篇/04 | 语义分析:让程序符合语义规则.md
Normal file
217
极客时间专栏/geek/编译原理实战课/预备知识篇/04 | 语义分析:让程序符合语义规则.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<audio id="audio" title="04 | 语义分析:让程序符合语义规则" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/35/bc/35e87316ad38293af7e9ed1a38f625bc.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。这一讲,我们进入到语义分析阶段。
|
||||
|
||||
对计算机程序语义的研究,是一个专门的学科。要想很简单地把它讲清楚,着实不是太容易的事情。但我们可以退而求其次,只要能直观地去理解什么是语义就可以了。**语义,就是程序要表达的意思**。
|
||||
|
||||
因为计算机最终是用来做计算的,那么理解程序表达的意思,就是要知道让计算机去执行什么计算动作,这样才好翻译成目标代码。
|
||||
|
||||
那具体来说,语义分析要做什么工作呢?我们在[第1讲](https://time.geekbang.org/column/article/242479)中说过,每门计算机语言的标准中,都会定义很多语义规则,比如对加法运算要执行哪些操作。而在语义分析阶段,就是去检查程序是否符合这些语义规则,并为后续的编译工作收集一些语义信息,比如类型信息。
|
||||
|
||||
再具体一点,这些**语义规则可以分为两大类**。
|
||||
|
||||
第一类规则与上下文有关。因为我们说了,语法分析只能处理与上下文无关的工作。而与上下文有关的工作呢,自然就放到了语义分析阶段。
|
||||
|
||||
第二类规则与类型有关。在计算机语言中,类型是语义的重要载体。所以,语义分析阶段要处理与类型有关的工作。比如,声明新类型、类型检查、类型推断等。在做类型分析的时候,我们会用到一个工具,就是属性计算,也是需要你了解和掌握的。
|
||||
|
||||
补充:某些与类型有关的处理工作,还必须到运行期才能去做。比如,在多态的情况,调用一个方法时,到底要采用哪个子类的实现,只有在运行时才会知道。这叫做动态绑定。
|
||||
|
||||
在语义分析过程中,会使用**两个数据结构**。一个还是AST,但我们会把语义分析时获得的一些信息标注在AST上,形成带有标注的AST。另一个是符号表,用来记录程序中声明的各种标识符,并用于后续各个编译阶段。
|
||||
|
||||
那今天这一讲,我就会带你看看如何完成与上下文有关的分析、与类型有关的处理,并带你认识符号表和属性计算。
|
||||
|
||||
首先,我们来学习如何处理与上下文有关的工作。
|
||||
|
||||
## 上下文相关的分析
|
||||
|
||||
那什么是与上下文有关的工作呢?在解析一个程序时,会有非常多的分析工作要结合上下文来进行。接下来,我就以控制流检查、闭包分析和引用消解这三个场景和你具体分析下。
|
||||
|
||||
**场景1:控制流检查**
|
||||
|
||||
像return、break和continue等语句,都与程序的控制流有关,它们必须符合控制流方面的规则。在Java这样的语言中,语义规则会规定:如果返回值不是void,那么在退出函数体之前,一定要执行一个return语句,那么就要检查所有的控制流分支,是否都以return语句结尾。
|
||||
|
||||
**场景2:闭包分析**
|
||||
|
||||
很多语言都支持闭包。而要正确地使用闭包,就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量,但被这个函数中的代码所使用。这样,在运行期,编译器就会用特殊的内存管理机制来管理这些变量。所以,对闭包的分析,也是上下文敏感的。
|
||||
|
||||
**场景3:引用消解**
|
||||
|
||||
我们重点说一下引用消解,以及相关的作用域问题。
|
||||
|
||||
引用消解(Reference Resolution),有时也被称作名称消解(Name Resolution)或者标签消解(Label Resolution)。对变量名称、常量名称、函数名称、类型名称、包名称等的消解,都属于引用消解。因此,引用消解是一种非常重要的上下文相关的语义规则,我来重点讲解下。
|
||||
|
||||
在高级语言里,我们会做变量、函数(或方法)和类型的声明,然后在其他地方使用它们。这个时候,我们要找到定义和使用之间的正确引用关系。
|
||||
|
||||
我们来看一个例子。在语法分析阶段,对于“int b = a + 3”这样一条语句,无论a是否提前声明过,在语法上都是正确的。而在实际的计算机语言中,如果引用某个变量,这个变量就必须是已经声明过的。同时,当前这行代码,要处于变量a的作用域中才行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/50/03/5092c6f4103a967ea0609dceea873d03.jpg" alt="">
|
||||
|
||||
对于变量来说,为了找到正确的引用,就需要用到**作用域**(Scope)这个概念。在编译技术里面,作用域这个词,有两个稍微有所差异的使用场景。
|
||||
|
||||
作用域的第一个使用场景,指的是变量、函数等标识符可以起作用的范围。下图列出了三个变量的作用域,每个变量声明完毕以后,它的下一句就可以引用它。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a8/ba/a82fbf11da007a038ed2ee282b30c9ba.jpg" alt="">
|
||||
|
||||
作用域的第二个使用场景,是词法作用域(Lexical Scope),也就是程序中的不同文本区域。比如,一个语句块、参数列表、类定义的主体、函数(方法)的主体、模块主体、整个程序等。
|
||||
|
||||
到这里,咱们来总结下这两个使用场景。**标识符和词法的作用域的差异**在于:一个本地变量(标识符)的作用域,虽然属于某个词法作用域(如某个函数体),但其作用范围只是在变量声明之后的语句。而类的成员变量(标识符)的作用域,跟词法作用域是一致的,也就是整个类的范围,跟声明的位置无关。如果这个成员变量不是私有的,它的作用域还会覆盖到子类。
|
||||
|
||||
那具体到不同的编程语言,它们的作用域规则是不同的。比如,C语言里允许你在一个if语句块里定义一个变量,覆盖外部的变量,而Java语言就不允许这样。所以,在给Java做语义分析时,我们要检查出这种错误。
|
||||
|
||||
```
|
||||
void foo(){
|
||||
int a = 2;
|
||||
if (...){
|
||||
int a = 3; //在C语言里允许,在Java里不允许
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在做引用消解的时候,为了更好地查找变量、类型等定义信息,编译器会使用一个辅助的数据结构:**符号表**。
|
||||
|
||||
## 符号表(Symbol Table)
|
||||
|
||||
在写程序的时候,我们会定义很多标识符,比如常量名称、变量名称、函数名称、类名称,等等。在编译器里,我们又把这些标识符叫做符号(Symbol)。用来保存这些符号的数据结构,就叫做符号表。
|
||||
|
||||
比如,对于变量a来说,符号表中的基本信息可以包括:
|
||||
|
||||
- 名称:a
|
||||
- 分类:变量
|
||||
- 类型:int
|
||||
- 作用域:foo函数体
|
||||
- 其他必要的信息。
|
||||
|
||||
符号表的具体实现,每个编译器可能都不同。比如,它可能是一张线性的表格,也可能是按照作用域形成的一种有层次的表格。以下面这个程序为例,它包含了两个函数,每个函数里面都定义了多个变量:
|
||||
|
||||
```
|
||||
void foo(){
|
||||
int a;
|
||||
int b;
|
||||
if (a>0){
|
||||
int c;
|
||||
int d;
|
||||
}
|
||||
else{
|
||||
int e;
|
||||
int f;
|
||||
}
|
||||
}
|
||||
|
||||
void bar(){
|
||||
int g;
|
||||
{
|
||||
int h;
|
||||
int i;
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
它的符号表可能是下面这样的,分成了多个层次,每个层次对应了一个作用域。在全局作用域,符号表里包含foo和bar两个函数。在foo函数体里,有两个变量a和b,还有两个内部块,每个块里各有两个变量。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/6e/6c/6ef69555fc2fc1abe06e206fab52ac6c.jpg" alt="">
|
||||
|
||||
那针对引用消解,其实就是从符号表里查找被引用的符号的定义,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1a/4e/1aca158938cf6e9895dce3f954b3db4e.jpg" alt="">
|
||||
|
||||
更进一步地,符号表除了用于引用消解外,还可以辅助完成语义分析的其他工作。比如,在做类型检查的时候,我们可以从符号表里查找某个符号的类型,从而检查类型是否兼容。
|
||||
|
||||
其实,不仅仅是在语义分析阶段会用到符号表,其他的编译阶段也会用到。比如,早在词法分析阶段,你就可以为符号表建立条目;在生成IR、做优化和生成目标代码的时候,都会用到符号表里的信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/31/3608f553a443cd9a4a59934b7e937e31.jpg" alt="">
|
||||
|
||||
有的编译器,在前期做语法分析的时候,如果不依赖符号表的话,它是不可能完整地做语法分析的。甚至,除了编译阶段,在链接阶段,我们也要用到符号表。比如,在foo.c中定义了一个函数foo(),并编译成目标文件foo.o,在bar.c中使用了这个foo()函数。那么在链接的时候,链接器需要找到foo()函数的地址。为了满足这个场景,你必须在目标文件中找到foo符号的相关信息。
|
||||
|
||||
同样的道理,在Java的字节码文件里也需要保存符号信息,以便在加载后我们可以定位其中的类、方法和成员变量。
|
||||
|
||||
好了,以上就是语义分析的第一项重要工作上下文相关的分析,以及涉及的数据结构符号表的重点内容了。我们再来考察一下语义分析中第二项重要的工作:类型分析和处理。
|
||||
|
||||
## 类型分析和处理
|
||||
|
||||
语义分析阶段的一个重要工作就是做类型检查,现代语言还普遍增加了类型推断的能力。那什么是类型呢?
|
||||
|
||||
通常来说,**在计算机语言里,类型是数据的一个属性,它的作用是来告诉编译器或解释器,程序可以如何使用这些数据。**比如说,对于整型数据,它可能占32或者64位存储,我们可以对它做加减乘除操作。而对于字符串,它可能占很多个字节,并且通过一定的编码规则来表示字符。字符串可以做连接、查找、获取子字符串等操作,但不能像整数一样做算术运算。
|
||||
|
||||
一门语言的类型系统是包含了与类型有关的各种规则的一个逻辑系统。类型系统包含了一系列规则,规定了如何把类型用于变量、表达式和函数等程序元素,以及如何创建自定义类型,等等。比如,如果你定义了某个类有哪些方法,那你就只能通过调用这些方法来使用这个类,没有别的方法。这些强制规定减少了程序出错的可能性。
|
||||
|
||||
所以在语义分析阶段,一个重要的工作就是做类型检查。
|
||||
|
||||
**那么,类型检查是怎样实现的呢?我们要如何做类型检查呢?**
|
||||
|
||||
关于类型检查,编译器一般会采用**属性计算**的方法,来计算出每个AST节点的类型属性,然后检查它们是否匹配。
|
||||
|
||||
## 属性计算
|
||||
|
||||
以“int b = a+3”为例,它的AST如下图所示。编译器会计算出b节点所需的类型和init节点的实际类型,比较它们是否一致(或者可以自动转换)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/60/16772e733d25f008332dcc507b544960.jpg" alt="">
|
||||
|
||||
我们首先要计算等号右边“a+3”的类型。其中,3是个整型字面量,我们可以据此把它的类型标注为整型;a是一个变量,它的类型可以从符号表中查到,也是整型。
|
||||
|
||||
**那么“a+3”是什么类型呢**?根据加法的语义,两个整型数据相加,结果仍然是整型,因此“a+3”这个表达式整体是整型的。因为init只有一个子节点(add),所以init的类型也一样是整型。
|
||||
|
||||
在刚才这段推理中,我们实际上是依据“a+3”的AST,从下级节点的类型计算出上级节点的类型。
|
||||
|
||||
**那么,我们能否以同样的方法计算b节点的类型呢?**答案是不可以。因为b根本没有子节点。但声明变量b的时候,有个int关键字,所以在AST中,b有一个兄弟节点,就是int关键字。根据变量声明的语义,b的类型就是int,因此它的类型是从AST的兄弟节点中获得的。
|
||||
|
||||
你看,同样是计算AST节点的类型,等号右边和左边的计算方法是不一样的。
|
||||
|
||||
实际上,我们刚才用的分析方法,就是**属性计算**。其中,有些属性是通过子节点计算出来的,这叫做 S属性(Synthesized Attribute,综合出来的属性),比如等号右边的类型。而另一些属性,则要根据父节点或者兄弟节点计算而来,这种属性叫做 I属性(Inherited Attribute,继承到的属性),比如等号左边的b变量的类型。
|
||||
|
||||
计算出来的属性,我们可以标注在AST上,这就形成我[第1讲](https://time.geekbang.org/column/article/242479)曾经提过的带有标注信息的AST,(Annotated Tree),也有人称之为Decorated Tree,或者Attributed Tree。虽然叫法有很多,但都是一个意思,都是向AST中添加了语义信息。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/a2/7a72cb24e044c68c4ccda78ee40fafa2.jpg" alt="">
|
||||
|
||||
属性计算的方法,就是基于语法规则,来定义一些属性计算的规则,在遍历AST的时候执行这些规则,我们就可以计算出属性值。**这种基于语法规则定义的计算规则,被叫做属性文法(Attribute Grammar)。**
|
||||
|
||||
补充:基于属性计算的方法可以做类型检查,那其实也可以做类型推断。有些现代语言在声明一个变量的时候,可以不明确指定的类型,那么它的类型就可以通过变量声明语句的右边部分推断出来。
|
||||
|
||||
你可能会问,属性计算的方法,除了计算类型,还可以计算什么属性呢?
|
||||
|
||||
根据不同语言的语义,可能有不同的属性需要计算。其实,value(值)也可以看做是一个属性,你可以给每个节点定义一个“value”属性。对表达式求值,也就是对value做属性计算,比如,“a + 3”的值,我们就可以自下而上地计算出来。这样看起来,value是一个S属性。
|
||||
|
||||
针对value这个属性的属性文法,你可以参考下面这个例子,在做语法解析(或先解析成AST,再遍历AST)的时候,执行方括号中的规则,我们就可以计算出AST的值了。
|
||||
|
||||
```
|
||||
add1 → add2 + mul [ add1.value = add2.value + mul.value ]
|
||||
add → mul [ add.value = mul.value ]
|
||||
mul1 → mul2 * primary [ mul1.value = mul2.value * primary.value ]
|
||||
mul → primary [ mul.value = primary.value ]
|
||||
primary → ( add ) [ primary.value = add.value ]
|
||||
primary → integer [ primary.value = strToInt(integer.str) ]
|
||||
|
||||
```
|
||||
|
||||
这种在语法规则上附加一系列动作,在解析语法的时候执行这些动作的方式,是一种编译方法,在龙书里有一个专门的名字,叫做**语法制导的翻译**(Syntax Directed Translation,SDT)。使用语法制导的翻译可以做很多事情,包括做属性计算、填充符号表,以及生成IR。
|
||||
|
||||
## 课程小结
|
||||
|
||||
在实际的编译器中,语义分析相关的代码量往往要比词法分析和语法分析的代码量大。因为一门语言有很多语义规则,所以要做的语义分析和检查工作也很多。
|
||||
|
||||
并且,因为每门语言之间的差别主要都体现在语义上,所以每门语言在语义处理方面的工作差异也比较大。比如,一门语言支持闭包,另一门语言不支持;有的语言支持泛型,另一门语言不支持;一门语言的面向对象特性是基于继承实现的,而另一门语言则是基于组合实现的,等等。
|
||||
|
||||
不过,这没啥关系。我们主要抓住它们的共性就好了。这些共性,就是我们本讲的内容:
|
||||
|
||||
- 做好上下文相关的分析,比如对各种引用的消解、控制流的检查、闭包的分析等;
|
||||
- 做好与类型有关的分析和处理,包括类型检查、类型推断等;
|
||||
- 掌握属性计算这个工具,用于计算类型、值等属性;
|
||||
- 最后,把获得的语义信息保存到符号表和AST里。
|
||||
|
||||
我把本讲的知识点也整理成了脑图,供你参考:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f5/1e/f56cdb074915e743e9c21fd3c5eff11e.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
你能否阅读你所熟悉的编程语言的标准,查看其中的语义规则,并选择一组有意思的语义规则(比如,第1讲提到的ECMAScript中加法操作符的语义规则),分析一下在语义分析阶段要针对这组语义规则做哪些处理工作?
|
||||
|
||||
欢迎在留言区分享你的答案,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 关于计算机程序的语义进行处理的形式化方法,你可以参考:[The Formal Semantics of Programming Languages: An Introduction](https://www.amazon.com/Formal-Semantics-Programming-Languages-Introduction/dp/0262231697/ref=sr_1_3?crid=2YNEXB86EUVNY&dchild=1&keywords=semantics+of+programming+languages&qid=1590211662&sprefix=the+semantics+programming+%2Caps%2C1148&sr=8-3)
|
||||
1. 关于[数据类型](https://en.wikipedia.org/wiki/Data_type)、[类型系统](https://en.wikipedia.org/wiki/Type_system)、[类型理论](https://en.wikipedia.org/wiki/Type_theory)的定义,你可以参考Wikipedia。
|
||||
1. 《编译原理之美》的[第8讲](https://time.geekbang.org/column/article/128623)中,有关于如何在计算机语言里实现作用域的介绍,可以加深你对作用域的理解。
|
||||
237
极客时间专栏/geek/编译原理实战课/预备知识篇/05 | 运行时机制:程序如何运行,你有发言权.md
Normal file
237
极客时间专栏/geek/编译原理实战课/预备知识篇/05 | 运行时机制:程序如何运行,你有发言权.md
Normal file
@@ -0,0 +1,237 @@
|
||||
<audio id="audio" title="05 | 运行时机制:程序如何运行,你有发言权" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3d/de/3d14870659061280987c96defc20c5de.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。在语义分析之后,编译过程就开始进入中后端了。
|
||||
|
||||
经过前端阶段的处理分析,编译器已经充分理解了源代码的含义,准备好把前端处理的结果(带有标注信息的AST、符号表)翻译成目标代码了。
|
||||
|
||||
我在[第1讲](https://time.geekbang.org/column/article/242479)也说过,如果想做好翻译工作,编译器必须理解目标代码。而要理解目标代码,它就必须要理解目标代码是如何被执行的。通常情况下,程序有两种执行模式。
|
||||
|
||||
第一种执行模式是在物理机上运行。针对的是C、C++、Go这样的语言,编译器直接将源代码编译成汇编代码(或直接生成机器码),然后生成能够在操作系统上运行的可执行程序。为了实现它们的后端,编译器需要理解程序在底层的运行环境,包括CPU、内存、操作系统跟程序的互动关系,并要能理解汇编代码。
|
||||
|
||||
第二种执行模式是在虚拟机上运行。针对的是Java、Python、Erlang和Lua等语言,它们能够在虚拟机上解释执行。这时候,编译器要理解该语言的虚拟机的运行机制,并生成能够被执行的IR。
|
||||
|
||||
理解了这两种执行模式的特点,我们也就能弄清楚用高级语言编写的程序是如何运行的,进而也就理解了编译器在中后端的任务是什么。接下来,我们就从最基础的物理机模式开始学习吧。
|
||||
|
||||
## 在物理机上运行
|
||||
|
||||
在计算机发展的早期,科学家们确立了计算机的结构,并一直延续至今,这种结构就是**冯·诺依曼结构**。它的主要特点是:数据和指令不加区别,混合存储在同一个存储器中(即主存,或叫做内存);用一个指令指针指向内存中指令的位置,CPU就能自动加载这个位置的指令并执行。
|
||||
|
||||
在x86架构下,这个指针是eip寄存器(32位模式)或rip寄存器(64位模式)。一条指令执行完毕,指令指针自动增加,并执行下一条指令。如果遇到跳转指令,则跳转到另一个地址去执行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9e/fe/9e4026ca0798caf346131d24586940fe.jpg" alt="">
|
||||
|
||||
这其实就是计算机最基本的运行原理。这样,你就可以在大脑中建立起像图1那样的直观结构。
|
||||
|
||||
通过图1,你会看到,**计算机指令的执行基本上只跟两个硬件相关:一个是CPU,一个是内存。**
|
||||
|
||||
### CPU
|
||||
|
||||
CPU是计算机的核心。从硬件构成方面,我们需要知道它的三个信息:
|
||||
|
||||
- 第一,CPU上面有寄存器,并且可以直接由指令访问。寄存器的读写速度非常快,大约是内存的100倍。所以我们**编译后的代码,要尽量充分利用寄存器**,而不是频繁地去访问内存。
|
||||
- 第二,CPU有高速缓存,并且可能是多级的。高速缓存也比内存快。CPU在读取指令和数据的时候,不是一次读取一条,而是读取相邻的一批数据,放到高速缓存里。接下来要读取的数据,很可能已经在高速缓存里了,通过这种机制来提高运行性能。因此,**编译器要尽量提高缓存的命中率**。
|
||||
- 第三,CPU内部有多个功能单元,有的负责计算,有的负责解码,等等。所以,一个指令可以被切分成多个执行阶段,每个阶段在不同的功能单元上运行,这为实现指令级并行提供了硬件基础。在第8讲,我还会和你详细解释这个话题。
|
||||
|
||||
好了,掌握了这个知识点,我们可以继续往下学习了。我们说,CPU是运行指令的地方,**那指令到底是什么样子的呢?**
|
||||
|
||||
我们知道,CPU有多种不同的架构,比如x86架构、ARM架构等。不同架构的CPU,它的指令是不一样的。不过它们的共性之处在于,指令都是01这样的机器码。为了便于理解,我们通常会用汇编代码来表示机器指令。比如,b=a+2指令对应的汇编码可能是这样的:
|
||||
|
||||
```
|
||||
movl -4(%rbp), %eax #把%rbp-4内存地址的值拷贝到%eax寄存器
|
||||
addl $2, %eax #把2加到%eax寄存器
|
||||
movl %eax, -8(%rbp) #把%eax寄存器的值保存回内存,地址是%rbp-8
|
||||
|
||||
```
|
||||
|
||||
上面的汇编代码采用的是GNU汇编器规定的格式。每条指令都包含了两部分:**操作码(opcode)和操作数(oprand)**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/0a/7e5aea959b228c635ee92f899748ea0a.jpg" alt="">
|
||||
|
||||
**操作码是让CPU执行的动作**。这段示例代码中,movl、addl是助记符(Assembly Mnemonic),其中的mov和add是指令,l是后缀,表示操作数的位数。
|
||||
|
||||
**而操作数是指令的操作对象**,它可以是常数、寄存器和某个内存地址。图2示例的汇编代码中,“$2”就是个常数,在指令里我们把它叫做立即数;而“%eax”是访问一个寄存器,其中eax是寄存器的名称;而带有括号的“-4(%rbp)”,则是对内存的访问方式,这个内存的地址是在rbp寄存器的值的基础上减去4。
|
||||
|
||||
如果你还想对指令、汇编代码有更多的了解,可以再去查阅些资料学习,比如去参考下我的《编译原理之美》中的第[22](https://time.geekbang.org/column/article/147854)、[23](https://time.geekbang.org/column/article/150798)、[31](https://time.geekbang.org/column/article/160990)这几讲。
|
||||
|
||||
**这里要提一下**,虽然程序觉得自己一直在使用CPU,但实际上,背后有操作系统在做调度。操作系统是管理系统资源的,而CPU是计算机的核心资源,操作系统会把CPU的时间划分成多个时间片,分配给不同的程序使用,每个程序实际上都是在“断断续续”地使用CPU,这就是操作系统的**分时调度机制**。在后面课程里讨论并发的时候,我们会更加深入地探讨这个机制。
|
||||
|
||||
### 内存
|
||||
|
||||
好了,接下来我说说执行指令相关的另一个硬件:内存。
|
||||
|
||||
程序在运行时,操作系统会给它分配一块虚拟的内存空间,让它可以在运行期内使用。内存中的每个位置都有一个地址,地址的长度决定了能够表示多大空间,这叫做**寻址空间**。我们目前使用的都是64位的机器,理论上,你可以用一个64位的长整型来表示内存地址。
|
||||
|
||||
不过,由于我们根本用不了这么大的内存,所以AMD64架构的寻址空间只使用了48位。但这也有256TB,远远超出了一般情况下的需求。所以,像Windows这样的操作系统还会给予进一步的限制,缩小程序的寻址空间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4e/c3/4e4406dfa49bef594d7de9783ed287c3.jpg" alt="">
|
||||
|
||||
但即使是在加了限制的情况下,程序在逻辑上可使用的内存一般也会大于实际的物理内存。不过进程不会一下子使用那么多的内存,只有在向操作系统申请内存的时候,操作系统才会把一块物理内存,映射成进程寻址空间内的一块内存。对应到图4中,中间一条是物理内存,上下两条是两个进程的寻址空间,它们要比物理内存大。
|
||||
|
||||
对于有些物理内存的内容,还可以映射进多个进程的地址空间,以减少内存的使用。比如说,如果进程1和进程2运行的是同一个可执行文件,那么程序的代码段是可以在两个进程之间共享的。你在图中可以看到这种情况。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/32/9a/3245928f1986ae440399ca58d340709a.jpg" alt="">
|
||||
|
||||
另外,对于已经分配给进程的内存,如果进程很长时间不用,操作系统会把它写到磁盘上,以便腾出更多可用的物理内存。在需要的时候,再把这块空间的数据从磁盘中读回来。这就是操作系统的**虚拟内存机制**。
|
||||
|
||||
当然,也存在没有操作系统的情况,这个时候你的程序所使用的内存就是物理内存,我们必须自己做好内存的管理。
|
||||
|
||||
**那么从程序角度来说,我们应该怎样使用内存呢?**
|
||||
|
||||
本质上来说,你想怎么用就怎么用,并没有什么特别的限制。一个编译器的作者,可以决定在哪儿放代码,在哪儿放数据。当然了,别的作者也可能采用其他的策略。比如,C语言和Java虚拟机对内存的管理和使用策略就是不同的。
|
||||
|
||||
不过尽管如此,大多数语言还是会采用一些通用的内存管理模式。以C语言为例,会把内存划分为代码区、静态数据区、栈和堆,如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/27/8b/27685617695d64ad5488189921ad478b.jpg" alt="">
|
||||
|
||||
其中,代码区(也叫做文本段),主要存放编译完成后的机器码,也就是CPU指令;静态数据区会保存程序中的全局变量和常量。这些内存是静态的、固定大小的,在编译完毕以后就能确定清楚所占用空间的大小、代码区每个函数的地址,以及静态数据区每个变量和常量的地址。这些内存在程序运行期间会一直被占用。
|
||||
|
||||
而堆和栈,属于程序动态、按需获取的内存。我来和你分析下这两种内存。
|
||||
|
||||
我们先看看**栈**(Stack)。使用栈的一个好处是,操作系统会根据程序使用内存的需求,自动地增加或减少栈的空间。通常来说,操作系统会用一个寄存器保存栈顶的地址,程序可以修改这个寄存器的值,来获取或者释放空间。有的CPU,还有专门的指令来管理栈,比如x86架构,会使用push和pop指令,把数据写入栈或弹出栈,并自动修改栈顶指针。
|
||||
|
||||
在程序里使用栈的场景是这样的,程序的运行可以看做是在逐级调用函数(或者叫过程)。像下面的示例程序,存在着**main->bar->foo**的调用结构,这也就是**控制流转移**的过程。
|
||||
|
||||
```
|
||||
int main(){
|
||||
int a = 1;
|
||||
foo(3);
|
||||
bar();
|
||||
}
|
||||
|
||||
int foo(int c){
|
||||
int b = 2;
|
||||
return b+c;
|
||||
}
|
||||
|
||||
int bar(){
|
||||
return foo(4) + 1;
|
||||
|
||||
```
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/16/2b3b5c34c9026499365c1c0c4bbd5c16.jpg" alt="">
|
||||
|
||||
每次调用函数的过程中,都需要一些空间来保存一些信息,比如参数、需要保护的寄存器的值、返回地址、本地变量等,这些信息叫做这个过程的**活动记录**(Activation Record)。
|
||||
|
||||
**注意,活动记录是个逻辑概念。**在物理实现上,一些信息可以保存在寄存器里,使得性能更高。比如说依据一些约定,返回值和少于6个的参数,是通过寄存器传递的。这里所说的“依据约定”,是指在调用一个函数时,如何传递参数、如何设定返回地址、如何获取返回值的这种约定,我们把它称之为ABI(Application Binary Interface,应用程序二进制接口)。利用ABI,使得我们可以用一种语言写的程序,去调用另外的语言写的程序。
|
||||
|
||||
另一些信息会保存在栈里。每个函数(或过程)在栈里保存的信息,叫做**栈帧**(Stack Frame)。我们可以自由设计栈帧的结构,比如,下图就是一种常见的设计:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/10/acb100c9ba680f32bf117d2cf4856410.jpg" alt="">
|
||||
|
||||
- **返回值:**一般放在最顶上,这样它的地址是固定的。foo函数返回以后,它的调用者可以到这里来取到返回值。在实际情况中,ABI会规定优先通过寄存器来传递返回值,比通过内存传递性能更高。
|
||||
- **参数:**在调用foo函数时,我们把它所需要一个整型参数写到栈帧的这个位置。同样,我们也可以通过寄存器来传递参数,而不是通过内存。
|
||||
- **控制链接:**就是上一级栈帧(也就是main函数的栈帧)的地址。如果该函数用到了上一级作用域中的变量,那么就可以顺着这个链接找到上一级作用域的栈帧,并找到变量的值。
|
||||
- **返回地址:** foo函数执行完毕以后,继续执行哪条指令。同样,我们可以用寄存器来保存这个信息。
|
||||
- **本地变量:** foo函数的本地变量b的存储空间。
|
||||
- **寄存器信息:**我们还经常在栈帧里保存寄存器的数据。如果在foo函数里要使用某个寄存器,可能需要先把它的值保存下来,防止破坏了别的代码保存在这里的数据。**这种约定叫做被调用者责任,**也就是使用寄存器的函数要保护好寄存器里原有的信息。某个函数如果使用了某个寄存器,但它又要调用别的函数,为了防止别的函数把自己放在寄存器中的数据覆盖掉,这个函数就要自己把寄存器信息保存在栈帧中。**这种约定叫做调用者责任。**
|
||||
|
||||
对于示例程序,在多级调用以后,栈里的信息可能是下面这个样子。如果你想看到这个信息,通常可以在调试程序的时候打印出来。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/12/2db7cdca7fdf1478be6aa89f2bb2d212.jpg" alt="">
|
||||
|
||||
理解了栈的机制以后,我们再来看看动态获取内存的第二种方式:**堆**(Heap)。
|
||||
|
||||
操作系统一般会提供一个API,供应用申请内存。当应用程序用完之后,要通过另一个API释放。如果忘记释放,就会造成内存越用越少,这叫做**内存泄漏**。
|
||||
|
||||
相对于栈来说,这是堆的一个缺点。不过,相应的好处是,**应用在堆里申请的对象的生存期,可以由自己控制,不会像栈里的内存那样,在退出作用域之后就被自动收回。**所以,如果数据的生存期超过了创建它的作用域的生存期,就必须在堆中申请内存。
|
||||
|
||||
扩展:反之,如果数据的生存期跟创建它的作用域一致的话,那么在栈里和堆里申请都是可以的。当然,肯定在栈里申请更划算。所以,编译优化中的逃逸分析,本质就是分析出哪些对象的生存期是跟函数或方法的生存期一致的,那么就不需要到堆里申请了。
|
||||
|
||||
另外,在并发的场景下,由于栈是线程独享的,而堆是多个线程共享的,所以在堆里申请内存的效率会更低,因为需要在多个线程之间同步,避免出现竞争。
|
||||
|
||||
那为了避免内存泄漏,在设计一门语言的时候,通常需要提供内存管理的方案。
|
||||
|
||||
一种方案是像C和C++那样,由程序员自己负责内存的释放,这对程序员的要求就比较高。另一种方案是,像Java语言那样自动地管理内存,这个特性也叫做**垃圾收集**。垃圾收集是语言的运行时功能,能够通过一定的算法来回收不用的内存。
|
||||
|
||||
总结起来,在计算机上运行一个程序,我们需要跟两个硬件打交道:一个是CPU,它能够从内存中读取指令并顺序执行;第二个硬件是内存,内存使用模式有栈和堆两种方式,两种方式有各自的优点和适用场景。
|
||||
|
||||
### 运行时系统
|
||||
|
||||
除了硬件支撑,程序的运行还需要软件,这些软件叫做**运行时系统**(Runtime System),或者叫**运行时**(Runtime)。前面我们提到的垃圾收集器,就是一个运行时的软件。进行并发调度的软件,也是运行时的组成部分。
|
||||
|
||||
**实际上,对于把源代码编译成机器码在操作系统上运行的语言来说(比如C、C++),操作系统本身就可以看做是它们的运行时系统**。它可以帮助程序调度CPU资源、内存资源,以及其他一些资源,如IO端口。
|
||||
|
||||
但也有很多语言,比如Java、Python、Erlang和Lua等,它们不是直接在操作系统上运行的,而是运行在虚拟机上。那么它们的执行模式有什么特点?对编译有什么影响呢?
|
||||
|
||||
## 在虚拟机上运行
|
||||
|
||||
虚拟机是计算机语言的一种运行时系统。虚拟机上运行的是**中间代码**,而不是CPU可以直接认识的指令。
|
||||
|
||||
虚拟机有两种模型:一种叫做**栈机**(Stack Machine),一种叫做**寄存器机**(Register Machine)。它们的区别,主要在于如何获取指令的操作数。
|
||||
|
||||
栈机是从栈里获取,而寄存器机是从寄存器里获取。这两种虚拟机各有优缺点。
|
||||
|
||||
### 基于栈的虚拟机
|
||||
|
||||
首先说说栈机。JVM和Python中的解释器,都采用了栈机的模型。在本讲中,我主要介绍Java的虚拟机的运行机制。
|
||||
|
||||
JVM中,每一个线程都有一个JVM栈,每次调用一个方法都会生成一个栈帧,来支持这个方法的运行。这跟C语言很相似。但JVM的栈帧比C语言的复杂,它包含了一个本地变量数组(包括方法的参数和本地变量)、操作数栈、到运行时常量池的引用等信息。
|
||||
|
||||
对比JVM的栈帧和C语言栈帧的设计,你应该得到一些启示:栈帧的结构是语言的作者可以自己设计的,没有什么死规定。所以我们学知识也不要学死了,以为栈帧只有一种结构。
|
||||
|
||||
注意,我们这里提到了两个栈,一个是类似于C语言的栈的方法栈,另一个是方法栈里每个栈帧中的操作数栈。而我们说的栈机中的“栈”,指的是这个操作数栈,不要弄混了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2e/04/2e2ef9a3b11a80b2882b77f3bb8b3404.jpg" alt="">
|
||||
|
||||
对于每个指令,解释器先要把它的操作数压到栈里。在执行指令时,从栈里弹出操作数,计算完毕以后,再把结果压回栈里。
|
||||
|
||||
以“2+3*5”为例,它对应的栈机的代码如下:
|
||||
|
||||
```
|
||||
push 2 //把操作数2入栈
|
||||
push 3 //把操作数3入栈
|
||||
push 5 //把操作数5入栈, 栈里目前是2、 3、 5
|
||||
imul //弹出5和3,执行整数乘法运算,得到15,然后把结果入栈,现在栈里是2、15
|
||||
iadd //弹出15和2,执行整数加法运算,得到17,然后把结果入栈,最后栈里是17
|
||||
|
||||
```
|
||||
|
||||
提示:对于不同大小的常量操作数,实际上生成的指令会不同。这里只是示意。
|
||||
|
||||
注意一点,要从AST生成上面的代码,你只需要对AST做深度优先的遍历即可。先后经过的节点是:**2->3->5->*->+**(注:这种把操作符放在后面的写法,叫做**逆波兰表达式**,也叫**后缀表达式**)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/d5/71f7d40150ebfeb2bf880e4c846003d5.jpg" alt="">
|
||||
|
||||
生成上述栈机代码,只需要深度优先地遍历AST,并且只需要进行两种操作:
|
||||
|
||||
- 在遇到字面量或者变量的时候,生成push指令;
|
||||
- 在遇到操作符的时候,生成相应的操作指令即可。
|
||||
|
||||
你能看出,这个算法相当简单,这也是栈机最大的优点。
|
||||
|
||||
你还会注意到,像imul和iadd这样的指令,不需要带操作数,因为指令所需的操作数就在栈顶。这是栈机的指令跟汇编语言的指令的最大区别。
|
||||
|
||||
注意:imul和iadd中的i,代表这两个指令是对整型值做操作。对浮点型、长整型等不同类型,分别对应不同的指令前缀。
|
||||
|
||||
好了,现在你已经了解了栈机的原理。基于对栈机的认知,你再去阅读Java和Python的字节码,就会更加容易了。而关于Python的虚拟机,我还会在后续课程中详细展开。
|
||||
|
||||
### 基于寄存器的虚拟机
|
||||
|
||||
除了栈机之外,另一种虚拟机是寄存器机。寄存器机使用寄存器名称来表示操作数,所以它的指令也跟汇编代码相似,像add这样的操作码后面要跟操作数。
|
||||
|
||||
在实践中,早期版本的安卓系统中,用于解释执行代码的Dalvik虚拟机,就采用了寄存器模式,而Erlang和Lua语言的虚拟机也是寄存器机。JavaScript引擎V8的比较新的版本中,也引入了一个解释器Ignition,它也是个寄存器机。
|
||||
|
||||
**与栈机相比**,利用寄存器机编译所生成的代码更少,因为省去了很多push指令。
|
||||
|
||||
不过,寄存器机所指的寄存器,不一定是真正的物理寄存器,有可能只是栈帧中的一个位置。当然,有的寄存器机在实现的时候,确实会用到物理寄存器,从而提高计算性能。我们在后面研究V8的Ignition解释器时,会看到这种实现。
|
||||
|
||||
## 课程小结
|
||||
|
||||
本讲我带你了解了代码是如何被运行的,以及是在什么样的环境中运行的。这样,你才会知道如何让编译器生成正确的代码。
|
||||
|
||||
现有的程序有两大类执行模式。**一类是编译成本地代码(机器码),运行在物理机和操作系统上**,这时候你需要掌握目标机器的汇编代码,知道指令是如何跟CPU和内存打交道的,也需要知道操作系统在其中扮演了什么角色。**另一大类是在虚拟机上运行的**,虚拟机又分为栈机和寄存器机两大类,你需要明确它们之间的区别,才能知道为什么它们的IR是不同的,又分别有什么优缺点。
|
||||
|
||||
不过,现代程序的运行环境往往比较复杂。像Java等语言,既可以解释执行字节码,又能够即时编译成本地代码运行,所以它们的运行时机制就更复杂一些。你要综合两种运行时机制的知识,才能完整地理解JVM。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/72/f4535fcf01b83075b24096091b0ddb72.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
我们现在已经知道,栈是一种自动管理内存的机制,你只要修改栈顶指针,就可以获得所需的内存。那么,你能否结合操作系统的知识,研究一下这个过程是如何实现的呢?
|
||||
|
||||
欢迎在留言区分享你的答案,如果这节课对你有帮助,也欢迎你把它分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1.关于JVM栈帧的结构,可以参考[JVM Specification](https://docs.oracle.com/javase/specs/jvms/se14/html/jvms-2.html#jvms-2.6)。<br>
|
||||
2. 关于Java字节码的指令集,可以参考[Java Language Specification](https://docs.oracle.com/javase/specs/jvms/se14/html/jvms-6.html)。
|
||||
316
极客时间专栏/geek/编译原理实战课/预备知识篇/06 | 中间代码:不是只有一副面孔.md
Normal file
316
极客时间专栏/geek/编译原理实战课/预备知识篇/06 | 中间代码:不是只有一副面孔.md
Normal file
@@ -0,0 +1,316 @@
|
||||
<audio id="audio" title="06 | 中间代码:不是只有一副面孔" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/60/c9580d8876c9995173164ea21045e260.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。今天这一讲,我来带你认识一下中间代码(IR)。
|
||||
|
||||
IR,也就是中间代码(Intermediate Representation,有时也称Intermediate Code,IC),它是编译器中很重要的一种数据结构。编译器在做完前端工作以后,首先就是生成IR,并在此基础上执行各种优化算法,最后再生成目标代码。
|
||||
|
||||
所以说,编译技术的IR非常重要,它是运行各种优化算法、代码生成算法的基础。不过,鉴于IR的设计一般与编译器密切相关,而一些教科书可能更侧重于讲理论,所以对IR的介绍就不那么具体。这就导致我们对IR有非常多的疑问,比如:
|
||||
|
||||
- **IR都有哪些不同的设计,可以分成什么类型?**
|
||||
- **IR有像高级语言和汇编代码那样的标准书写格式吗?**
|
||||
- **IR可以采用什么数据结构来实现?**
|
||||
|
||||
为了帮助你把对IR的认识从抽象变得具体,我今天就从全局的视角和你一起梳理下IR有关的认知。
|
||||
|
||||
首先,我们来了解一下IR的用途,并一起看看由于用途不同导致IR分成的多个层次。
|
||||
|
||||
## IR的用途和层次
|
||||
|
||||
设计IR的目的,是要满足编译器中的各种需求。需求的不同,就会导致IR的设计不同。通常情况下,IR有两种用途,一种是用来做分析和变换的,一种是直接用于解释执行的。我们先来看第一种。
|
||||
|
||||
编译器中,基于IR的分析和处理工作,一开始可以基于一些抽象层次比较高的语义,这时所需要的IR更接近源代码。而在后面,则会使用低层次的、更加接近目标代码的语义。
|
||||
|
||||
基于这种从高到低的抽象层次,IR可以归结为HIR、MIR和LIR三类。
|
||||
|
||||
### HIR:基于源语言做一些分析和变换
|
||||
|
||||
假设你要开发一款IDE,那最主要的功能包括:发现语法错误、分析符号之间的依赖关系(以便进行跳转、判断方法的重载等)、根据需要自动生成或修改一些代码(提供重构能力)。
|
||||
|
||||
这个时候,你对IR的需求,是能够准确表达源语言的语义就行了。这种类型的IR,可以叫做High IR,简称HIR。
|
||||
|
||||
其实,AST和符号表就可以满足这个需求。也就是说,AST也可以算作一种IR。如果你要开发IDE、代码翻译工具(从一门语言翻译到另一门语言)、代码生成工具、代码统计工具等,使用AST(加上符号表)就够了。
|
||||
|
||||
当然,有些HIR并不是树状结构(比如可以采用线性结构),但一般会保留诸如条件判断、循环、数组等抽象层次比较高的语法结构。
|
||||
|
||||
基于HIR,可以做一些高层次的代码优化,比如常数折叠、内联等。在Java和Go的编译器中,你可以看到不少基于AST做的优化工作。
|
||||
|
||||
### MIR:独立于源语言和CPU架构做分析和优化
|
||||
|
||||
大量的优化算法是可以通用的,没有必要依赖源语言的语法和语义,也没有必要依赖具体的CPU架构。
|
||||
|
||||
这些优化包括部分算术优化、常量和变量传播、死代码删除等,我会在下一讲和你介绍。实现这类分析和优化功能的IR可以叫做Middle IR,简称MIR。
|
||||
|
||||
因为MIR跟源代码和目标代码都无关,所以在讲解优化算法时,通常是基于MIR,比如三地址代码(Three Address Code,TAC)。
|
||||
|
||||
TAC的特点是,最多有三个地址(也就是变量),其中赋值符号的左边是用来写入的,而右边最多可以有两个地址和一个操作符,用于读取数据并计算。
|
||||
|
||||
我们来看一个例子,示例函数foo:
|
||||
|
||||
```
|
||||
int foo (int a){
|
||||
int b = 0;
|
||||
if (a > 10)
|
||||
b = a;
|
||||
else
|
||||
b = 10;
|
||||
return b;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
对应的TAC可能是:
|
||||
|
||||
```
|
||||
BB1:
|
||||
b := 0
|
||||
if a>10 goto BB3 //如果t是false(0),转到BB3
|
||||
BB2:
|
||||
b := 10
|
||||
goto BB4
|
||||
BB3:
|
||||
b := a
|
||||
BB4:
|
||||
return b
|
||||
|
||||
```
|
||||
|
||||
可以看到,TAC用goto语句取代了if语句、循环语句这种比较高级的语句,当然也不会有类、继承这些高层的语言结构。但是,它又没有涉及数据如何在内存读写等细节,书写格式也不像汇编代码,与具体的目标代码也是独立的。
|
||||
|
||||
所以,它的抽象程度算是不高不低。
|
||||
|
||||
### LIR:依赖于CPU架构做优化和代码生成
|
||||
|
||||
最后一类IR就是Low IR,简称LIR。
|
||||
|
||||
这类IR的特点,是它的指令通常可以与机器指令一一对应,比较容易翻译成机器指令(或汇编代码)。因为LIR体现了CPU架构的底层特征,因此可以做一些与具体CPU架构相关的优化。
|
||||
|
||||
比如,下面是Java的JIT编译器输出的LIR信息,里面的指令名称已经跟汇编代码很像了,并且会直接使用AMD64架构的寄存器名称。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/8f/d9ebaec471be87da8afff32c0718f48f.jpg" alt="">
|
||||
|
||||
好了,以上就是根据不同的使用目的和抽象层次,所划分出来的不同IR的关键知识点了。
|
||||
|
||||
HIR、MIR和LIR这种划分方法,主要是参考“鲸书(Advanced Compiler Design and Implementation)”的提法。对此有兴趣的话,你可以参考一下这本书。
|
||||
|
||||
在实际操作时,有时候IR的划分标准不一定跟鲸书一致。在有的编译器里(比如Graal编译器),把相对高层次的IR叫做HIR,相对低层次的叫做LIR,而没有MIR。你只要知道它们代表了不同的抽象层次就足够了。
|
||||
|
||||
其实,在一个编译器里,有时候会使用抽象层次从高到低的多种IR,从便于“人”理解到便于“机器”理解。而编译过程可以理解为,抽象层次高的IR一直lower到抽象层次低的IR的过程,并且在每种IR上都会做一些适合这种IR的分析和处理工作,直到最后生成了优化的目标代码。
|
||||
|
||||
扩展:lower这个词的意思,就是把对计算机程序的表示,从抽象层次比较高的、便于人理解的格式,转化为抽象层次比较低的、便于机器理解的格式。
|
||||
|
||||
有些IR的设计,本身就混合了多个抽象层次的元素,比如Java的Graal编译器里就采用了这种设计。Graal的IR采用的是一种图结构,但随着优化阶段的进展,图中的一些节点会逐步从语义比较抽象的节点,lower到体现具体架构特征的节点。
|
||||
|
||||
## P-code:用于解释执行的IR
|
||||
|
||||
好了,前3类IR是从抽象层次来划分的,它们都是用来做分析和变换的。我们继续看看第二种直接用于解释执行的IR。这类IR还有一个名称,叫做P-code,也就是Portable Code的意思。由于它与具体机器无关,因此可以很容易地运行在多种电脑上。这类IR对编译器来说,就是做编译的目标代码。
|
||||
|
||||
到这里,你一下子就会想到,Java的字节码就是这种IR。除此之外,Python、Erlang也有自己的字节码,.NET平台、Visual Basic程序也不例外。
|
||||
|
||||
其实,你也完全可以基于AST实现一个全功能的解释器,只不过性能会差一些。对于专门用来解释执行IR,通常会有一些特别的设计,跟虚拟机配合来尽量提升运行速度。
|
||||
|
||||
需要注意的是,P-code也可能被进一步编译,形成可以直接执行的机器码。Java的字节码就是这样的例子。因此,在这门课程里,我会带你探究Java的两个编译器,一个把源代码编译成字节码,一个把字节码编译成目标代码(支持JIT和AOT两种方式)。
|
||||
|
||||
好了,通过了解IR的不同用途,你应该会对IR的概念更清晰一些。用途不同,对IR的需求也就不同,IR的设计自然也就会不同。这跟软件设计是由需求决定的,是同一个道理。
|
||||
|
||||
接下来的一个问题是,**IR是怎样书写的呢?**
|
||||
|
||||
## IR的呈现格式
|
||||
|
||||
虽然说是中间代码,但总得有一个书写格式吧,就像源代码和汇编代码那样。
|
||||
|
||||
其实IR通常是没有书写格式的。一方面,大多数的IR跟AST一样,只是编译过程中的一个数据结构而已,或者说只有内存格式。比如,LLVM的IR在内存里是一些对象和接口。
|
||||
|
||||
另一方面,为了调试的需要,你可以把IR以文本的方式输出,用于显示和分析。在这门课里,你也会看到很多IR的输出格式。比如,下面是Julia的IR:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/8d/0dc758c3d40f9c41f026663271a4858d.jpg" alt="">
|
||||
|
||||
在少量情况下,IR有比较严格的输出格式,不仅用于显示和分析,还可以作为结果保存,并可以重新读入编译器中。比如,LLVM的bitcode,可以保存成文本和二进制两种格式,这两种格式间还可以相互转换。
|
||||
|
||||
我们以C语言为例,来看下fun1函数,及其对应的LLVM IR的文本格式和二进制格式:
|
||||
|
||||
```
|
||||
//fun1.c
|
||||
int fun1(int a, int b){
|
||||
int c = 10;
|
||||
return a+b+c;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
LLVM IR的文本格式(用“clang -emit-llvm -S fun1.c -o fun1.ll”命令生成,这里只节选了主要部分):
|
||||
|
||||
```
|
||||
; ModuleID = 'fun1.c'
|
||||
source_filename = "function-call1.c"
|
||||
target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128"
|
||||
target triple = "x86_64-apple-macosx10.14.0"
|
||||
|
||||
; Function Attrs: noinline nounwind optnone ssp uwtable
|
||||
define i32 @fun1(i32, i32) #0 {
|
||||
%3 = alloca i32, align 4
|
||||
%4 = alloca i32, align 4
|
||||
%5 = alloca i32, align 4
|
||||
store i32 %0, i32* %3, align 4
|
||||
store i32 %1, i32* %4, align 4
|
||||
store i32 10, i32* %5, align 4
|
||||
%6 = load i32, i32* %3, align 4
|
||||
%7 = load i32, i32* %4, align 4
|
||||
%8 = add nsw i32 %6, %7
|
||||
%9 = load i32, i32* %5, align 4
|
||||
%10 = add nsw i32 %8, %9
|
||||
ret i32 %10
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
二进制格式(用“clang -emit-llvm -c fun1.c -o fun1.bc”命令生成,用“hexdump -C fun1.bc”命令显示):
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/4b/9830e3f7bb6e250a56805d8802eb564b.jpg" alt="">
|
||||
|
||||
## IR的数据结构
|
||||
|
||||
既然我们一直说IR会表现为内存中的数据结构,那它到底是什么结构呢?
|
||||
|
||||
在实际的实现中,有线性结构、树结构、有向无环图(DAG)、程序依赖图(PDG)等多种格式。编译器会根据需要,选择合适的数据结构。在运行某些算法的时候,采用某个数据结构可能会更顺畅,而采用另一些结构可能会带来内在的阻滞。所以,**我们一定要根据具体要处理的工作的特点,来选择合适的数据结构。**
|
||||
|
||||
那我们接下来,就具体看看每种格式的特点。
|
||||
|
||||
### 第一种:类似TAC的线性结构(Linear Form)
|
||||
|
||||
你可以把代码表示成一行行的指令或语句,用数组或者列表保存就行了。其中的符号,需要引用符号表,来提供类型等信息。
|
||||
|
||||
这种线性结构有时候也被称作goto格式。因为高级语言里的条件语句、循环语句,要变成用goto语句跳转的方式。
|
||||
|
||||
### 第二种:树结构
|
||||
|
||||
树结构当然可以用作IR,AST就是一种树结构。
|
||||
|
||||
很多资料中讲指令选择的时候,也会用到一种树状的结构,便于执行树覆盖算法。这个树结构,就属于一种LIR。
|
||||
|
||||
树结构的缺点是,可能有冗余的子树。比如,语句“`a=5; b=(2+a)+a*3;` ”形成的AST就有冗余。如果基于这个树结构生成代码,可能会做两次从内存中读取a的值的操作,并存到两个临时变量中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/2f/74e6c6d63d3c49656ecee767a8d8b52f.jpg" alt="">
|
||||
|
||||
### 第三种:有向无环图(Directed Acyclic Graph,DAG)
|
||||
|
||||
DAG结构,是在树结构的基础上,消除了冗余的子树。比如,上面的例子转化成DAG以后,对a的内存访问只做一次就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/16/a2/168f1617889bbe76095e65d4968333a2.jpg" alt="">
|
||||
|
||||
在LLVM的目标代码生成环节,就使用了DAG来表示基本块内的代码。
|
||||
|
||||
### 第四种:程序依赖图(Program Dependence Graph,PDG)
|
||||
|
||||
程序依赖图,是显式地把程序中的数据依赖和控制依赖表示出来,形成一个图状的数据结构。基于这个数据结构,我们再做一些优化算法的时候,会更容易实现。
|
||||
|
||||
所以现在,有很多编译器在运行优化算法的时候,都基于类似PDG的数据结构,比如我在课程后面会分析的Java的JIT编译器和JavaScript的编译器。
|
||||
|
||||
这种数据结构里,因为会有很多图节点,又被形象地称为“**节点之海(Sea of Nodes)**”。你在很多文章中,都会看到这个词。
|
||||
|
||||
以上就是常用于IR的数据结构了。接下来,我再介绍一个重要的IR设计范式:SSA格式。
|
||||
|
||||
## SSA格式的IR
|
||||
|
||||
SSA是Static Single Assignment的缩写,也就是静态单赋值。这是IR的一种设计范式,它要求一个变量只能被赋值一次。我们来看个例子。
|
||||
|
||||
“y = x1 + x2 + x3 + x4”的普通TAC如下:
|
||||
|
||||
```
|
||||
y := x1 + x2;
|
||||
y := y + x3;
|
||||
y := y + x4;
|
||||
|
||||
```
|
||||
|
||||
其中,y被赋值了三次,如果我们写成SSA的形式,就只能写成下面的样子:
|
||||
|
||||
```
|
||||
t1 := x1 + x2;
|
||||
t2 := t1 + x3;
|
||||
y := t2 + x4;
|
||||
|
||||
```
|
||||
|
||||
**那我们为什么要费力写成这种形式呢,还要为此多添加t1和t2两个临时变量?**
|
||||
|
||||
原因是,使用SSA的形式,体现了精确的“**使用-定义(use-def)**”关系。并且由于变量的值定义出来以后就不再变化,使得基于SSA更容易运行一些优化算法。在后面的课程中,我会通过实际的例子带你体会这一点。
|
||||
|
||||
在SSA格式的IR中,还会涉及一个你经常会碰到的,但有些特别的指令,叫做 **phi指令**。它是什么意思呢?我们看一个例子。
|
||||
|
||||
同样对于示例代码foo:
|
||||
|
||||
```
|
||||
int foo (int a){
|
||||
int b = 0;
|
||||
if (a > 10)
|
||||
b = a;
|
||||
else
|
||||
b = 10;
|
||||
return b;
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
它对应的SSA格式的IR可以写成:
|
||||
|
||||
```
|
||||
BB1:
|
||||
b1 := 0
|
||||
if a>10 goto BB3
|
||||
BB2:
|
||||
b2 := 10
|
||||
goto BB4
|
||||
BB3:
|
||||
b3 := a
|
||||
BB4:
|
||||
b4 := phi(BB2, BB3, b2, b3)
|
||||
return b4
|
||||
|
||||
```
|
||||
|
||||
其中,变量b有4个版本:b1是初始值,b2是else块(BB2)的取值,b3是if块(BB3)的取值,最后一个基本块(BB4)要把b的最后取值作为函数返回值。很明显,b的取值有可能是b2,也有可能是b3。这时候,就需要phi指令了。
|
||||
|
||||
phi指令,会根据控制流的实际情况确定b4的值。如果BB4的前序节点是BB2,那么b4的取值是b2;而如果BB4的前序节点是BB3,那么b4的取值就是b3。所以你会看到,如果要满足SSA的要求,也就是一个变量只能赋值一次,那么在遇到有程序分支的情况下,就必须引入phi指令。关于这一点,你也会在课程后面经常见到它。
|
||||
|
||||
最后我要指出的是,**由于SSA格式的优点,现代语言用于优化的IR,很多都是基于SSA的了,包括我们本课程涉及的Java的JIT编译器、JavaScript的V8编译器、Go语言的gc编译器、Julia编译器,以及LLVM工具等。**所以,你一定要高度重视SSA。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我希望你能记住关于IR的几个重要概念:
|
||||
|
||||
- **根据抽象层次和使用目的不同,可以设计不同的IR;**
|
||||
- **IR可能采取多种数据结构,每种结构适合不同的处理工作;**
|
||||
- **由于SSA格式的优点,主流的编译器都在采用这种范式来设计IR。**
|
||||
|
||||
通过学习IR,你会形成看待编译过程的一个新视角:整个编译过程,就是生成从高抽象度到低抽象度的一系列IR,以及发生在这些IR上的分析与处理过程。
|
||||
|
||||
我还展示了三地址代码、LLVM IR等一些具体的IR设计,希望能给你增加一些直观印象。在有的教科书里,还会有三元式、四元式、逆波兰格式等不同的设计,你也可以参考。而在后面的课程里,你会接触到每门编译器的IR,从而对IR的理解更加具体和丰满。
|
||||
|
||||
本讲的思维导图如下:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/19/55/19a96336d4b579a86263f82d98c72255.jpg" alt="">
|
||||
|
||||
### 一课一思
|
||||
|
||||
你能试着把下面这段简单的程序,改写成TAC和SSA格式吗?
|
||||
|
||||
```
|
||||
int bar(a){
|
||||
int sum = 0;
|
||||
for (int i = 0; i< a; i++){
|
||||
sum = sum+i;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
欢迎在留言区分享你的见解,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
### 参考资料
|
||||
|
||||
1. 关于程序依赖图的论文参考:[The Program Dependence Graph and its Use in Optimization](https://www.cs.utexas.edu/~pingali/CS395T/2009fa/papers/ferrante87.pdf)。
|
||||
1. 更多的关于LLVM IR的介绍,你可以参考《编译原理之美》的第[25](https://time.geekbang.org/column/article/153192)、[26](https://time.geekbang.org/column/article/154438)讲,以及[LLVM官方文档](https://llvm.org/docs/LangRef.html)。
|
||||
1. 对Java字节码的介绍,你可以参考《编译原理之美》的[第32讲](https://time.geekbang.org/column/article/161944),还可以参考[Java Language Specification](https://docs.oracle.com/javase/specs/jvms/se14/html/jvms-6.html)。
|
||||
1. 鲸书(Advanced Compiler Design and Implementation)第4章。
|
||||
400
极客时间专栏/geek/编译原理实战课/预备知识篇/07 | 代码优化:跟编译器做朋友,让你的代码飞起来.md
Normal file
400
极客时间专栏/geek/编译原理实战课/预备知识篇/07 | 代码优化:跟编译器做朋友,让你的代码飞起来.md
Normal file
@@ -0,0 +1,400 @@
|
||||
<audio id="audio" title="07 | 代码优化:跟编译器做朋友,让你的代码飞起来" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/63/15/6382793ffb771f8220a7bfbad8fe7b15.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。
|
||||
|
||||
一门语言的性能高低,是它能否成功的关键。拿JavaScript来说,十多年来,它的性能多次得到成倍的提升,这也是前端技术栈如此丰富和强大的根本原因。
|
||||
|
||||
因此,编译器会无所不用其极地做优化,而优化工作在编译器的运行时间中,也占据了很大的比例。
|
||||
|
||||
不过,对编译技术的初学者来说,通常会搞不清楚编译器到底做了哪些优化,这些优化的实现思路又是怎样的。
|
||||
|
||||
所以今天这一讲,我就重点给你普及下编译器所做的优化工作,及其工作原理。在这个过程中,你还会弄明白很多似曾相识的术语,比如在前端必须了解的AST、终结符、非终结符等,在中后端必须熟悉的常数折叠、值编号、公共子表达式消除等。只有这样,你才算是入门了。
|
||||
|
||||
首先,我带你认识一些常见的代码优化方法。
|
||||
|
||||
## 常见的代码优化方法
|
||||
|
||||
对代码做优化的方法有很多。如果要把它们分一下类的话,可以按照下面两个维度:
|
||||
|
||||
- **第一个分类维度,是机器无关的优化与机器相关的优化。**机器无关的优化与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。而机器相关的优化则需要利用某硬件特有的特征,比如SIMD指令可以在一条指令里完成多个数据的计算。
|
||||
- **第二个分类维度,是优化的范围。**本地优化是针对一个基本块中的代码,全局优化是针对整个函数(或过程),过程间优化则能够跨越多个函数(或过程)做优化。
|
||||
|
||||
但优化算法很多,仅仅按照这两个维度分类,仍显粗糙。所以,我就按照优化的实现思路再分分类,让你了解起来更轻松一些。
|
||||
|
||||
### 思路1:把常量提前计算出来
|
||||
|
||||
程序里的有些表达式,肯定能计算出一个常数值,那就不要等到运行时再去计算,干脆在编译期就计算出来。比如 “`x=2*3`”可以优化成“`x=6`”。这种优化方法,叫做**常数折叠(Constant Folding)**。
|
||||
|
||||
而如果你一旦知道x的值其实是一个常量,那你就可以把所有用到x的地方,替换成这个常量,这叫做**常数传播(Constant Propagation)**。如果有“`y=x*2`”这样一个语句,那么就能计算出来“`y=12`”。所以说,常数传播会导致更多的常数折叠。
|
||||
|
||||
就算不能引起新的常数折叠,比如说“`z=a+x`”,替换成“`z=a+6`”以后,计算速度也会更快。因为对于很多CPU来说,“`a+x`”和“`a+6`”对应的指令是不一样的。前者可能要生成两条指令(比如先把a放到寄存器上,再把x加上去),而后者用一条指令就行了,因为常数可以作为操作数。
|
||||
|
||||
更有用的是,常数传播可能导致分支判断条件是常量,因此导致一个分支的代码不需要被执行。这种优化叫做**稀疏有条件的常数传播(Sparse Conditional Constant Propagation)**。
|
||||
|
||||
```
|
||||
a = 2
|
||||
b = 3
|
||||
if(a<b){ //判断语句去掉
|
||||
... //直接执行这个代码块
|
||||
}
|
||||
else{
|
||||
... //else分支会去掉
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 思路2:用低代价的方法做计算
|
||||
|
||||
完成相同的计算,可以用代价更低的方法。比如“`x=x+0`”这行代码,操作前后x没有任何变化,所以这样的代码可以删掉;又比如“`x=x*0`” 可以简化成“`x=0`”。这类利用代数运算的规则所做的简化,叫做**代数简化(Algebra Simplification)。**
|
||||
|
||||
对于很多CPU来说,乘法运算改成移位运算,速度会更快。比如,“`x*2`”等价于“`x<<1`”,“`x*9`”等价于“`x<<3+x`”。这种采用代价更低的运算的方法,也叫做**强度折减(Strength Reduction)**。
|
||||
|
||||
### 思路3:消除重复的计算
|
||||
|
||||
下面的示例代码中,第三行可以被替换成“`z:=2*x`”, 因为y的值就等于x。这个时候,可能x的值已经在寄存器中,所以直接采用x,运算速度会更快。这种优化叫做**拷贝传播(Copy Propagation)。**
|
||||
|
||||
```
|
||||
x := a + b
|
||||
y := x
|
||||
z := 2 * y
|
||||
|
||||
```
|
||||
|
||||
**值编号(Value Numbering)**也能减少重复计算。值编号是把相同的值,在系统里给一个相同的编号,并且只计算一次即可。比如,[Wikipedia](https://en.wikipedia.org/wiki/Value_numbering)上的这个案例:
|
||||
|
||||
```
|
||||
w := 3
|
||||
x := 3
|
||||
y := x + 4
|
||||
z := w + 4
|
||||
|
||||
```
|
||||
|
||||
其中w和x的值是一样的,因此编号是相同的。这会进一步导致y和z的编号也是相同的。进而,它们可以简化成:
|
||||
|
||||
```
|
||||
w := 3
|
||||
x := w
|
||||
y := w + 4
|
||||
z := y
|
||||
|
||||
```
|
||||
|
||||
值编号又可以分为两种,本地值编号(在一个基本块中)和全局值编号(GVN,在一个函数范围内)。
|
||||
|
||||
还有一种优化方法叫做**公共子表达式消除(Common Subexpression Elimination,CSE),**也会减少计算次数。下面这两行代码,x和y右边的形式是一样的,如果这两行代码之间,a和b的值没有发生变化(比如采用SSA形式),那么x和y的值一定是一样的。
|
||||
|
||||
```
|
||||
x := a + b
|
||||
y := a + b
|
||||
|
||||
```
|
||||
|
||||
那我们就可以让y等于x,从而减少了一次对“`a+b`”的计算,这就是公共子表达式消除。
|
||||
|
||||
```
|
||||
x := a + b
|
||||
y := x
|
||||
|
||||
```
|
||||
|
||||
**部分冗余消除(Partial Redundancy Elimination,PRE)**,是公共子表达式消除的一种特殊情况。比如,这个来自[Wikipedia](https://en.wikipedia.org/wiki/Partial_redundancy_elimination)的例子中,一个分支有“`x+4`”这个公共子表达式,而另一个分支则没有。
|
||||
|
||||
```
|
||||
if (some_condition) {
|
||||
// some code that does not alter x
|
||||
y = x + 4;
|
||||
}
|
||||
else {
|
||||
// other code that does not alter x
|
||||
}
|
||||
z = x + 4;
|
||||
|
||||
```
|
||||
|
||||
但是,上述代码仍然可以优化,使得在if结构中,“`x+4`”这个值肯定会被计算一次,因此“`z=x+4`”就可以被优化。
|
||||
|
||||
```
|
||||
if (some_condition) {
|
||||
// some code that does not alter x
|
||||
t = x + 4;
|
||||
y = t;
|
||||
}
|
||||
else {
|
||||
// other code that does not alter x
|
||||
t = x + 4;
|
||||
}
|
||||
z = t;
|
||||
|
||||
```
|
||||
|
||||
### 思路4:化零为整,向量计算
|
||||
|
||||
很多CPU支持向量运算,也就是SIMD(Single Instruction Multiple Data)指令。这就可以在一条指令里计算多个数据。比如AVX-512指令集,可以使用512位的寄存器做运算,这个指令集的一条add指令相当于一次能把16个整数加到另16个整数上,以1当16呀。
|
||||
|
||||
比如,把16万个整数相加,应该怎样写程序呢?普通方法,是循环16万次,每次读1个数据,并做累加。向量化的方法,是每次读取16个,用AVX-512指令做加法计算,一共循环计算1万次,最后再把得到的16个数字相加就行了。
|
||||
|
||||
向量优化的一个例子是**超字级并行(Superword-Level Parallelism,SLP)**。它是把基本块中的多个变量组成一个向量,用一个指令完成多个变量的计算。
|
||||
|
||||
向量优化的另一个例子是**循环向量化(Loop Vectorization)**,我会在下面针对循环的优化思路中讲到它。
|
||||
|
||||
### 思路5:化整为零,各个优化
|
||||
|
||||
另一个思路是反着的,是化整为零。
|
||||
|
||||
很多语言都有结构和对象这样的复合数据类型,内部包含了多个成员变量,这种数据类型叫做**聚合体(aggregates)**。通常,为这些对象申请内存的时候,是一次就申请一整块,能放下里面的所有成员。但这样做,非常不利于做优化。
|
||||
|
||||
通常的优化算法都是针对标量(Scalar)的。如果经过分析,发现可以把聚合体打散,像使用单个本地变量(也就是标量)一样使用聚合体的成员变量,那就有可能带来其他优化的机会。比如,可以把聚合体的成员变量放在寄存器中进行计算,根本不需要访问内存。
|
||||
|
||||
这种优化叫做**聚合体的标量替换(Scalar Replacement of Aggregates,SROA)。**在研究Java的JIT编译器时,我们会见到一个这类优化的例子。
|
||||
|
||||
### 思路6:针对循环,重点优化
|
||||
|
||||
在编译器中,对循环的优化从来都是重点,因为程序中最多的计算量都是被各种循环消耗掉的。所以,对循环做优化,会起到事半功倍的效果。如果一个循环执行了10000次,那么你的优化效果就会被扩大10000倍。
|
||||
|
||||
对循环做优化,有很多种方法,我来和你介绍几种常用的。
|
||||
|
||||
**第一种:归纳变量优化(Induction Variable Optimization)。**
|
||||
|
||||
看下面这个循环,其中的变量j是由循环变量派生出来的,这种变量叫做该循环的归纳变量。归纳变量的变化是很有规律的,因此可以尝试做**强度折减**优化。示例代码中的乘法可以由加法替代。
|
||||
|
||||
```
|
||||
int j = 0;
|
||||
for (int i = 1; i < 100; i++) {
|
||||
j = 2*i; //2*i可以替换成j+2
|
||||
}
|
||||
return j;
|
||||
|
||||
```
|
||||
|
||||
**第二种:边界检查消除(Unnecessary Bounds-checking Elimination)。**
|
||||
|
||||
当引用一个数组成员的时候,通常要检查下标是否越界。在循环里面,如果每次都要检查的话,代价就会相当高(例如做多个数组的向量运算的时候)。如果编译器能够确定,在循环中使用的数组下标(通常是循环变量或者基于循环变量的归纳变量)不会越界,那就可以消除掉边界检查的代码,从而大大提高性能。
|
||||
|
||||
**第三种:循环展开(Loop Unrolling)。**
|
||||
|
||||
把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。比如:
|
||||
|
||||
```
|
||||
for (int i = 0; i< 100; i++){
|
||||
sum = sum + i;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
优化后可以变成:
|
||||
|
||||
```
|
||||
for (int i = 0; i< 100; i+=5){
|
||||
sum = sum + i;
|
||||
sum = sum + i + 1;
|
||||
sum = sum + i + 2;
|
||||
sum = sum + i + 3;
|
||||
sum = sum + i + 4;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
进一步,循环体内的5条语句就可以优化成1条语句:“`sum = sum + i*5 + 10;`”。
|
||||
|
||||
减少循环次数,本身就能减少循环条件的执行次数。同时,它还会增加一个基本块中的指令数量,从而为指令排序的优化算法创造机会。指令排序会在下一讲中介绍。
|
||||
|
||||
**第四种:循环向量化(Loop Vectorization)。**
|
||||
|
||||
在循环展开的基础上,我们有机会把多次计算优化成一个向量计算。比如,如果要循环16万次,对一个包含了16万个整数的数组做汇总,就可以变成循环1万次,每次用向量化的指令计算16个整数。
|
||||
|
||||
**第五种:重组(Reassociation)。**
|
||||
|
||||
在循环结构中,使用代数简化和重组,能获得更大的收益。比如,如下对数组的循环操作,其中数组`a[i,j]`的地址是“`a+i*N+j`”。但这个运算每次循环就要计算一次,一共要计算`M*N`次。但其实,这个地址表达式的前半截“`a+i*N`”不需要每次都在内循环里计算,只要在外循环计算就行了。
|
||||
|
||||
```
|
||||
for (i = 0; i< M; i++){
|
||||
for (j = 0; j<N; j++){
|
||||
a[i,j] = b + a[i,j];
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
优化后的代码相当于:
|
||||
|
||||
```
|
||||
for (i = 0; i< M; i++){
|
||||
t=a+i*N;
|
||||
for (j = 0; j<N; j++){
|
||||
*(t+j) = b + *(t+j);
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
**第六种:循环不变代码外提(Loop-Invariant Code Motion,LICM)。**
|
||||
|
||||
在循环结构中,如果发现有些代码其实跟循环无关,那就应该提到循环外面去,避免一次次重复计算。
|
||||
|
||||
**第七种:代码提升(Code Hoisting,或Expression Hoisting)。**
|
||||
|
||||
在下面的if结构中,then块和else块都有“`z=x+y`”这个语句,它可以提到if语句的外面。
|
||||
|
||||
```
|
||||
if (x > y)
|
||||
...
|
||||
z = x + y
|
||||
...
|
||||
}
|
||||
else{
|
||||
z = x + y
|
||||
...
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这样变换以后,至少代码量会降低。但是,如果这个if结构是在循环里面,那么可以继续借助**循环不变代码外提**优化,把“`z=x+y`”从循环体中提出来,从而降低计算量。
|
||||
|
||||
```
|
||||
z = x + y
|
||||
for(int i = 0; i < 10000; i++){
|
||||
if (x > y)
|
||||
...
|
||||
}
|
||||
else{
|
||||
...
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
另外,前面说过的部分冗余优化,也可能会产生可以外提的代码,借助这一优化方法,可以形成进一步优化的效果。
|
||||
|
||||
针对循环能做的优化还有不少,因为对循环做优化往往是收益很高的!
|
||||
|
||||
### 思路7:减少过程调用的开销
|
||||
|
||||
你知道,当程序调用一个函数的时候,开销是很大的,比如保存原来的栈指针、保存某些寄存器的值、保存返回地址、设置参数,等等。其中很多都是内存读写操作,速度比较慢。
|
||||
|
||||
所以,如果能做一些优化,减少这些开销,那么带来的优化效果会是很显著的,具体的优化方法主要有下面几种。
|
||||
|
||||
**第一种:尾调用优化(Tail-call Optimization)和尾递归优化(Tail-recursion Elimination)。**
|
||||
|
||||
尾调用就是一个函数的最后一句,是对另一个函数的调用。比如,下面这段示例代码:
|
||||
|
||||
```
|
||||
f(){
|
||||
...
|
||||
return g(a,b);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
而如果g()本身就是f()的最后一行代码,那么f()的栈帧已经没有什么用了,可以撤销掉了(修改栈顶指针的值),然后直接跳转到g()的代码去执行,就像f()和g()是同一个函数一样。这样可以让g()复用f()的栈空间,减少内存消耗,也减少一些内存读写操作(比如,保护寄存器、写入返回地址等)。
|
||||
|
||||
如果f()和g()是同一个函数,这就叫做**尾递归**。很多同学都应该知道,尾递归是可以转化为一个循环的。我们在[第3讲](https://time.geekbang.org/column/article/244906)改写左递归文法为右递归文法的时候,就曾经用循环代替了递归调用。尾递归转化为循环,不但可以节省栈帧的开销,还可以进一步导致针对循环的各种优化。
|
||||
|
||||
**第二种:内联(inlining)。**
|
||||
|
||||
内联也叫做过程集成(Procedure Integration),就是把被调用函数的代码拷贝到调用者中,从而避免函数调用。
|
||||
|
||||
对于我们现在使用的面向对象的语言来说,有很多短方法,比如getter、settter方法。这些方法内联以后,不仅仅可以减少函数调用的开销,还可以带来其他的优化机会。在探究Java的JIT编译器时,我就会为你剖析一个内联的例子。
|
||||
|
||||
**第三种:内联扩展(In-Line Expansion)。**
|
||||
|
||||
内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。
|
||||
|
||||
**第四种:叶子程序优化(Leaf-Routine Optimization)。**
|
||||
|
||||
叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。比如,你甚至可以不用生成栈帧,因为根据某些调用约定,程序可以访问栈顶之外一定大小的内存。这样就省去了保存原来栈顶、修改栈顶指针等一系列操作。
|
||||
|
||||
### 思路8:对控制流做优化
|
||||
|
||||
通过对程序的控制流分析,我们可以发现很多优化的机会。这就好比在做公司管理,优化业务流程,就会提升经营效率。我们来看一下这方面的优化方法有哪些。
|
||||
|
||||
**第一种:不可达代码消除(Unreacheable-code Elimination)**。根据控制流的分析,发现有些代码是不可能到达的,可以直接删掉,比如return语句后面的代码。
|
||||
|
||||
**第二种:死代码删除(Dead-code Elimination)**。通过对流程的分析,发现某个变量赋值了以后,后面根本没有再用到这个变量。这样的代码就是死代码,就可以删除。
|
||||
|
||||
**第三种:If简化(If Simplification)**。在讲常量传播时我们就见到过,如果有可能if条件肯定为真或者假,那么就可以消除掉if结构中的then块、else块,甚至整个消除if结构。
|
||||
|
||||
**第四种:循环简化(Loop Simplification)**。也就是把空循环或者简单的循环,变成直线代码,从而增加了其他优化的机会,比如指令的流水线化。
|
||||
|
||||
**第五种:循环反转(Loop Inversion)**。这是对循环语句常做的一种优化,就是把一个while循环改成一个repeat…until循环(或者do…while循环)。这样会使基本块的结构更简化,从而更有利于其他优化。
|
||||
|
||||
**第六种:拉直(Straightening)**。如果发现两个基本块是线性连接的,那可以把它们合并,从而增加优化机会。
|
||||
|
||||
**第七种:反分支(Unswitching)**。也就是减少程序分支,因为分支会导致程序从一个基本块跳到另一个基本块,这样就不容易做优化。比如,把循环内部的if分支挪到循环外面去,先做if判断,然后再执行循环,这样总的执行if判断的次数就会减少,并且循环体里面的基本块不那么零碎,就更加容易优化。
|
||||
|
||||
这七种优化方法,都是对控制流的优化,有的减少了基本块,有的减少了分支,有的直接删除了无用的代码。
|
||||
|
||||
## 代码优化所依赖的分析方法
|
||||
|
||||
前面我列举了很多优化方法,目的是让你认识到编译器花费大量时间去做的,到底都是一些什么工作。当然了,我只是和你列举了最常用的一些优化方法,不过这已经足够帮助你建立对代码优化的直觉认知了。我们在研究具体的编译器的时候,还会见到其他一些优化方法。不过你不用担心,根据上面讲到的各种优化思路,你可以举一反三,非常快速地理解这些新的优化方法。
|
||||
|
||||
上述优化方法,有的比较简单,比如常数折叠,依据AST或MIR做点处理就可以完成。但有些优化,就需要比较复杂的分析方法做支撑才能完成。这些分析方法包括控制流分析、数据流分析、依赖分析和别名分析等。
|
||||
|
||||
**控制流分析(Control-Flow Analysis,CFA)**。控制流分析是帮助我们建立对程序执行过程的理解,比如哪里是程序入口,哪里是出口,哪些语句构成了一个基本块,基本块之间跳转关系,哪个结构是一个循环结构(从而去做循环优化),等等。
|
||||
|
||||
前面提到的控制流优化,就是要基于对控制流的正确理解。下面要讲的数据流分析算法,在做全局分析的时候,也要基于控制流图(CFG),所以也需要以控制流分析为基础。
|
||||
|
||||
**数据流分析(Data-Flow Analysis,DFA)**。数据流分析,能够帮助我们理解程序中的数据变化情况。我们看一个分析变量活跃性的例子。
|
||||
|
||||
如下图所示,它从后到前顺序扫描代码,花括号中的是在当前位置需要的变量的集合。如果某个变量不被需要,那就可以做死代码删除的优化。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/8f/6812816f06d89014070633aed1ee3f8f.jpg" alt="">
|
||||
|
||||
经过多遍扫描和删除后,最后的代码会精简成一行:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/b9/00e66d72db71e2fd1f674af2589e89b9.jpg" alt="">
|
||||
|
||||
关于数据流分析框架的详细描述,你可以再参考下其他资料(比如,《编译原理之美》专栏第[27](https://time.geekbang.org/column/article/155338)和[28](https://time.geekbang.org/column/article/156878)两讲)。
|
||||
|
||||
除了做变量活跃性分析以外,数据流分析方法还可以做很多有用的分析。比如,可达定义分析(Reaching Definitions Analysis)、可用表达式分析(Available Expressions Analysis)、向上暴露使用分析(Upward Exposed Uses Analysis)、拷贝传播分析(Copy-Propagation Analysis)、常量传播分析(Constant-Propagation Analysis)、局部冗余分析(Partial-Redundancy Analysis)等。
|
||||
|
||||
就像基于变量活跃性分析可以做死代码删除的优化一样,上述分析是做其他很多优化的基础。
|
||||
|
||||
**依赖分析(Dependency Analysis)**。依赖分析,就是分析出程序代码的控制依赖(Control Dependency)和数据依赖(Data Dependency)关系。这对指令排序和缓存优化很重要。
|
||||
|
||||
指令排序会在下一讲介绍。它能通过调整指令之间的顺序来提升执行效率。但指令排序不能打破指令间的依赖关系,否则程序的执行就不正确。
|
||||
|
||||
**别名分析(Alias Analysis)**。在C、C++等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。
|
||||
|
||||
好了,你已经了解了优化的方法和所依赖的分析方法。那么,这些方法这么多,哪些优化方法更重要,优化的顺序又是什么呢?
|
||||
|
||||
## 优化方法的重要性和顺序
|
||||
|
||||
我们先看看哪些优化方法更重要。
|
||||
|
||||
有些优化,比如对循环的优化,对每门语言都很重要,因为循环优化的收益很大。
|
||||
|
||||
而有些优化,对于特定的语言更加重要。在课程后面分析像Java、JavaScript这样的面向对象的现代语言时,你会看到,内联优化和逃逸分析的收益就比较大。而对于某些频繁使用尾递归的函数式编程语言来说,尾递归的优化就必不可少,否则性能损失太大。
|
||||
|
||||
至于优化的顺序,有的优化适合在早期做(基于HIR和MIR),有的优化适合在后期做(基于LIR和机器代码)。并且,你通过前面的例子也可以看到,一般做完某个优化以后,会给别的优化带来机会,所以经常会在执行某个优化算法的时候,调用了另一个优化算法,而同样的优化算法也可能会运行好几遍。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这讲,我带你认识了很多常见的优化方法和背后的分析方法。我们很难一下子记住所有的方法,但完全可以先对这些概念建立总体印象。这样可以避免在研究具体编译器时,我们产生“瞎子摸象”的感觉。
|
||||
|
||||
另外,熟悉我提到的那些名词术语也很重要,因为它们经常在代码注释和相关文献里出现。这些名词要成为你的一项基本功。
|
||||
|
||||
我把今天的课程内容,也整理成了思维导图,供你复习、参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/a0/ec83e01a9471d19b285aac365694c3a0.jpg" alt="">
|
||||
|
||||
在课程的第二个模块“真实编译器解析篇”的时候,我会和你分析某些优化算法具体的实现细节,并带你跟踪编译优化的过程。
|
||||
|
||||
根据我的经验,当你写的程序对性能要求很高的时候,你需要能够跟踪了解编译优化的过程,看看如何才能达到最好的优化效果。我之前写过与内存计算有关的程序,就特别关注如何才能让编译器做向量优化。因为是否使用向量,性能差别很大。现在做AI工作的同学,一定也有类似的需求。
|
||||
|
||||
还有些开源项目,它们的性能与内联关系密切。这就要做一定的调优,以确保使用频率最高、性能影响最大的函数全部内联。
|
||||
|
||||
还有,Chrome、Android和Flutter共同使用的二维图形引擎Skia对性能很敏感,所以即使在Windows平台上,仍然要求用Clang编译。为啥坚持用Clang编译呢?因为Skia跟LLVM的优化方法是紧密配合的,换了其他编译器就达不到这么好的优化效果。
|
||||
|
||||
类似的例子还有很多。了解优化,能够充分利用编译器的优化能力,应该是我们想拥有的一项高级技能。
|
||||
|
||||
## 一课一思
|
||||
|
||||
你可以比较一下值编号和公共子表达式消除这两个优化方法,说说它们的相同点和不同点吗?你能举出一个例子来,是其中一个算法能做优化,而另一个算法不能的吗?
|
||||
|
||||
欢迎在留言区中分享你的思考,也欢迎你把这节课分享给你的朋友。
|
||||
|
||||
## 参考资料
|
||||
|
||||
1. 龙书(Compilers Principles, Techniques and Tools):第9章,机器无关的优化,里面介绍了各种优化算法。
|
||||
1. 鲸书(Advanced Compiler Design and Implementation)中讲优化的算法有很多,第7~15章你都可以看看。
|
||||
320
极客时间专栏/geek/编译原理实战课/预备知识篇/08 | 代码生成:如何实现机器相关的优化?.md
Normal file
320
极客时间专栏/geek/编译原理实战课/预备知识篇/08 | 代码生成:如何实现机器相关的优化?.md
Normal file
@@ -0,0 +1,320 @@
|
||||
<audio id="audio" title="08 | 代码生成:如何实现机器相关的优化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/ba/9af81d5c8381d98758b1ccb6504f23ba.mp3"></audio>
|
||||
|
||||
你好,我是宫文学。我们继续来学习编译器后端的技术。
|
||||
|
||||
在编译过程的前几个阶段之后,编译器生成了AST,完成了语义检查,并基于IR运行了各种优化算法。这些工作,基本上都是机器无关的。但编译的最后一步,也就是生成目标代码,则必须是跟特定CPU架构相关的。
|
||||
|
||||
这就是编译器的后端。不过,后端不只是简单地生成目标代码,它还要完成与机器相关的一些优化工作,确保生成的目标代码的性能最高。
|
||||
|
||||
这一讲,我就从机器相关的优化入手,带你看看编译器是如何通过指令选择、寄存器分配、指令排序和基于机器代码的优化等步骤,完成整个代码生成的任务的。
|
||||
|
||||
首先,我们来看看编译器后端的任务:生成针对不同架构的目标代码。
|
||||
|
||||
## 生成针对不同CPU的目标代码
|
||||
|
||||
我们已经知道,编译器的后端要把IR翻译成目标代码,那么要生成的目标代码是什么样子的呢?
|
||||
|
||||
我以foo.c函数为例:
|
||||
|
||||
```
|
||||
int foo(int a, int b){
|
||||
return a + b + 10;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
执行“**clang -S foo.c -o foo.x86.s**”命令,你可以得到对应的x86架构下的汇编代码(为了便于你理解,我进行了简化):
|
||||
|
||||
```
|
||||
#序曲
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp #%rbp是栈底指针
|
||||
|
||||
#函数体
|
||||
movl %edi, -4(%rbp) #把第1个参数写到栈里第一个位置(偏移量为4)
|
||||
movl %esi, -8(%rbp) #把第2个参数写到栈里第二个位置(偏移量为8)
|
||||
movl -4(%rbp), %eax #把第1个参数写到%eax寄存器
|
||||
addl -8(%rbp), %eax #把第2个参数加到%eax
|
||||
addl $10, %eax #把立即数10加到%eax,%eax同时是放返回值的地方
|
||||
|
||||
#尾声
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
小提示:上述汇编代码采用的是GNU汇编器的代码格式,源操作数在前面,目的操作数在后面。
|
||||
|
||||
我在[第1讲](https://time.geekbang.org/column/article/242479)中说过,要翻译成目标代码,编译器必须要先懂得目标代码,就像做汉译英一样,我们必须要懂得英语。可是,**通常情况下,我们会对汇编代码比较畏惧,觉得汇编语言似乎很难学。**其实不然。
|
||||
|
||||
补充说明:有些编译器,是先编译成汇编代码,再通过汇编器把汇编代码转变成机器码。而另一些编译器,是直接生成机器码,并写成目标文件,这样编译速度会更快一些。但这样的编译器一般就要带一个反汇编器,在调试等场合把机器码转化成汇编代码,这样我们看起来比较方便。<br>
|
||||
因此,在本课程中,我有时会不区分机器码和汇编代码。我可能会说,编译器生成了某机器码,但实际写给你看的是汇编代码,因为文本化的汇编代码才方便阅读。你如果看到这样的表述,不要感到困惑。
|
||||
|
||||
那为什么我说汇编代码不难学呢?你可以去查阅下各种不同CPU的指令。然后,你就会发现这些指令其实主要就那么几种,一类是做加减乘除的(如add指令),一类是做内存访问的(如mov、lea指令),一类是控制流程的(如jmp、ret指令),等等。说得夸张一点,这就是个复杂的计算器。
|
||||
|
||||
只不过,相比于高级语言,汇编语言涉及的细节比较多。它是啰嗦,但并不复杂。那我再分享一个我学习汇编代码的高效方法:**让编译器输出高级语言的汇编代码,多看一些各种情况下汇编代码的写法,自然就会对汇编语言越来越熟悉了。**
|
||||
|
||||
不过,虽然针对某一种CPU的汇编并不难,但问题是**不同架构的CPU,其指令是不同的。编译器的后端每支持一种新的架构,就要有一套新的代码。**这对写一个编译器来说,就是很大的工作量了。
|
||||
|
||||
我来举个例子。我们使用“**clang -S -target armv7a-none-eabi foo.c -o foo.armv7a.s**”命令,生成一段针对ARM芯片的汇编代码:
|
||||
|
||||
```
|
||||
//序曲
|
||||
sub sp, sp, #8 //把栈扩展8个字节,用于放两个参数,sp是栈顶指针
|
||||
|
||||
//函数体
|
||||
str r0, [sp, #4] //把第1个参数写到栈顶+4的位置
|
||||
str r1, [sp] //把第2个参数写到栈顶位置
|
||||
ldr r0, [sp, #4] //把第1个参数从栈里加载到r0寄存器
|
||||
ldr r1, [sp] //把第2个参数从站立加载到r1寄存器
|
||||
add r0, r0, r1 //把r1加到r0,结果保存在r0
|
||||
add r0, r0, #10 //把常量10加载到r0,结果保存在r0,r0也是放返回值的地方
|
||||
|
||||
//尾声
|
||||
add sp, sp, #8 //缩减栈
|
||||
bx lr //返回
|
||||
|
||||
```
|
||||
|
||||
把这段代码,与前面生成的针对x86架构的汇编代码比较一下,你马上就会发现一些不同。这两种CPU,完成相同功能所使用的汇编指令和寄存器都不同。我们来分析一下其中的原因。
|
||||
|
||||
x86的汇编,mov指令的功能很强大,可以从内存加载到寄存器,也可以从寄存器保存回内存,还可以从内存的一个地方拷贝到另一个地方、从一个寄存器拷贝到另一个寄存器。add指令的操作数也可以使用内存地址。
|
||||
|
||||
而在ARM的汇编中,从寄存器到内存要使用str(也就是Store)指令,而从内存到寄存器要使用ldr(也就是Load)指令。对于加法指令add而言,两个操作数及计算结果都必须使用寄存器。
|
||||
|
||||
知识扩展:ARM的这种指令风格叫做**Load-Store架构**。在这种架构下,指令被分为内存访问(Load和Store)和ALU操作两大类,而后者只能在寄存器上操作。各种RISC指令集都是Load-Store架构的,比如PowerPC、RISC-V、ARM和MIPS等。<br>
|
||||
而像x86这种CISC指令,叫做**Register-Memory架构**,在指令里可以混合使用内存地址和寄存器。
|
||||
|
||||
为了支持不同的架构,你可以通过手写算法来生成目标代码,但这样工作量显然会很大,维护负担也比较重。
|
||||
|
||||
另一种方法,是编写“代码生成器的生成器”。也就是说,你可以把CPU架构的各种信息(比如有哪些指令、指令的特点、有哪些寄存器等)描述出来,然后基于这些信息生成目标代码的生成器,就像根据语法规则,用ANTLR、bison这样的工具来生成语法解析器一样。
|
||||
|
||||
经过这样的处理,虽然我们生成的目标代码是架构相关的,但中间的处理算法却可以尽量做成与架构无关的。
|
||||
|
||||
## 生成目标代码时的优化工作
|
||||
|
||||
生成目标代码的过程要进行多步处理。比如,你一定注意到了,前面foo.c函数示例程序生成的汇编代码是不够优化的:它把参数信息从寄存器写到栈里,然后再从栈里加载到寄存器,用于计算。实际上,改成更优化的算法,是不需要内存访问的,从而节省了内存读写需要花费的大量时间。
|
||||
|
||||
所以接下来,我就带你一起了解在目标代码生成过程中进行的优化处理,包括指令选择、寄存器分配、指令排序、基于机器代码的优化等步骤。在这个过程中,你会知道编译器的后端,是如何充分发挥硬件的性能的。
|
||||
|
||||
首先,我们看看指令选择,它的作用是在完成相同功能的情况下,选择代价更低的指令组合。
|
||||
|
||||
### 指令选择
|
||||
|
||||
为了理解指令选择有什么用,这里我和你分享三个例子吧。
|
||||
|
||||
第一个例子:对于foo.c示例代码,在编译时加上“-O2”指令,就会得到如下的优化代码:
|
||||
|
||||
```
|
||||
#序曲
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp
|
||||
|
||||
#函数体
|
||||
leal 10(%rdi,%rsi), %eax
|
||||
|
||||
#尾声
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
它使用了lea指令,可以一次完成三个数的相加,并把结果保存到%eax。这样一个lea指令,代替了三条指令(一条mov,两条add),显然更优化。
|
||||
|
||||
这揭示了我们生成代码时面临的一种情况:**对于相同的源代码和IR,编译器可以生成不同的指令,而我们要选择代价最低的那个。**
|
||||
|
||||
第二个例子:对于“a[i]=b”这样一条语句,要如何生成代码呢?
|
||||
|
||||
你应该知道数组寻址的原理,a[i]的地址就是从数组a的起始地址往后偏移i个单位。对于整型数组来说,a[i]的地址就是a+i*4。所以,我可以用两条指令实现这个赋值操作:第一条指令,计算a[i]的地址;第二条指令,把b的值写到这个地址。
|
||||
|
||||
数组操作是很常见的现象,于是x86芯片专门提供了一种寻址方式,简化了数组的寻址,这就是**间接内存访问。**间接内存访问的完整形式是:偏移量(基址,索引值,字节数),其地址是:基址 + 索引值*字节数 + 偏移量。
|
||||
|
||||
所以,如果我们把a的地址放到%rdi,i的值放到%rax,那么a[i]的地址就是(%rdi,%rax,4)。这样的话,a[i]=b用一条mov指令就能完成。
|
||||
|
||||
第三个例子。我们天天在用的x86家族的芯片,它支持很多不同的指令集,比如SSE、AVX、FMA等,每个指令集里都有能完成加减乘除运算的指令。当然,每个指令集适合使用的场景也不同,我们要根据情况选择最合适的指令。
|
||||
|
||||
好了,现在你已经知道了指令选择的作用了,它在具体实现上有很多算法,比如树覆盖算法,以及BURS(自底向上的重写系统)等。
|
||||
|
||||
我们再看一下刚刚这段优化后的代码,你是不是发现了,优化后的算法对寄存器的使用也更加优化了。没错,接下来我们就分析下寄存器分配。
|
||||
|
||||
### 寄存器分配
|
||||
|
||||
优化后的代码,去掉了内存操作,直接基于寄存器做加法运算,比优化之前的运行速度要快得多(我在[第5讲](https://time.geekbang.org/column/article/246281)提到过,内存访问比寄存器访问大约慢100倍)。
|
||||
|
||||
同样的,ARM的汇编代码也可以使用“-O2”指令优化。优化完毕以后,最后剩下的代码只有三行。而且因为不需要访问内存,所以连栈顶指针都不需要挪动,进一步减少了代码量。
|
||||
|
||||
```
|
||||
add r0, r0, r1
|
||||
add r0, r0, #10
|
||||
bx lr
|
||||
|
||||
```
|
||||
|
||||
对于编译器来说,肯定要尽量利用寄存器,不去读写内存。因为内存读写对于CPU来说就是IO,性能很低。特别是像函数中用到的本地变量和参数,它们在退出作用域以后就没用了,所以能放到寄存器里,就放寄存器里吧。
|
||||
|
||||
在IR中,通常我们会假设寄存器是无限的(就像LLVM的IR),但实际CPU中的寄存器是有限的。所以,我们就要用一定的算法,把寄存器分配给使用最频繁的变量,比如循环中的变量。而对于超出物理寄存器数量的变量,则“溢出”到栈里,通过内存来保存。
|
||||
|
||||
寄存器分配的算法有很多种。一个使用比较广泛的算法是寄存器染色算法,它的特点是计算结果比较优化,但缺点是计算量比较大。
|
||||
|
||||
另一个常见的算法是线性扫描算法,它的优点是计算速度快,但缺点是有可能不够优化,适合需要编译速度比较快的场景,比如即时编译。在解析Graal编译器的时候,你会看到这种算法的实现。
|
||||
|
||||
寄存器分配算法对性能的提升是非常显著的。接下来我要介绍的指令排序,对性能的提升同样非常显著。
|
||||
|
||||
### 指令排序
|
||||
|
||||
首先我们来看一个例子。下面示例程序中的params函数,有6个参数:
|
||||
|
||||
```
|
||||
int params(int x1,int x2,int x3,int x4,int x5,int x6){
|
||||
return x1 + x2 + x3 + x4 + x5 + x6 + 10;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
把它编译成ARM汇编代码,如下:
|
||||
|
||||
```
|
||||
//序曲
|
||||
push {r11, lr} //把r11和lr保存到栈中,lr里面是返回地址
|
||||
mov r11, sp //把栈顶地址保存到r11
|
||||
|
||||
//函数体
|
||||
add r0, r0, r1 //把参数2加到参数1,保存在r0
|
||||
ldr lr, [r11, #8] //把栈里的参数5加载到lr,这里是把lr当通用寄存器用
|
||||
add r0, r0, r2 //把参数3加到r0
|
||||
ldr r12, [r11, #12] //把栈里的参数6加载到r12
|
||||
add r0, r0, r3 //把参数4加到r0
|
||||
add r0, r0, lr //把参数5加到r0
|
||||
add r0, r0, r12 //把参数6加到r0
|
||||
add r0, r0, #10 //把立即数加到r0
|
||||
|
||||
//尾声
|
||||
pop {r11, pc} //弹出栈里保存的值。注意,原来lr的值直接赋给了pc,也就是程序计数器,所以就跳转到了返回地址
|
||||
|
||||
```
|
||||
|
||||
根据编译时使用的调用约定,其中有4个参数是通过寄存器传递的(r0~r3),还有两个参数是在栈里传递的。
|
||||
|
||||
值得注意的是,在把参数5和参数6用于加法操作之前,它们就被提前执行加载(ldr)命令了。那,为什么会这样呢?这就涉及到CPU执行指令的一种内部机制:**流水线(Pipeline)。**
|
||||
|
||||
原来,CPU内部是分成多个功能单元的。对于一条指令,每个功能单元处理完毕以后,交给下一个功能单元,然后它就可以接着再处理下一条指令。所以,在同一时刻,不同的功能单元实际上是在处理不同的指令。这样的话,多条指令实质上是并行执行的,从而减少了总的执行时间,这种并行叫做**指令级并行。**
|
||||
|
||||
在下面的示意图中,每个指令的执行被划分成了5个阶段,每个阶段占用一个时钟周期,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/0c/af8fd31e7e2e4af759569882a0b0730c.jpg" alt="">
|
||||
|
||||
因为每个时钟周期都可以开始执行一条新指令,所以虽然一条指令需要5个时钟周期才能执行完,但在同一个时刻,却可以有5条指令并行执行。
|
||||
|
||||
**但是有的时候,指令之间会存在依赖关系,后一条指令必须等到前一条指令执行完毕才能运行**(在上一讲,我们曾经提到过依赖分析,指令排序就会用到依赖分析的结果)。比如,前面的示例程序中,在使用参数5的值做加法之前,必须要等它加载到内存。这样的话,指令就不能并行了,执行时间就会大大延长。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/e3/ae0a9bb26533c3e153753cc92d843ee3.jpg" alt="">
|
||||
|
||||
讲到这里,你就明白了,为什么在示例程序中,要把ldr指令提前执行,目的就是为了更好地利用流水线技术,实现指令级并行。
|
||||
|
||||
补充:这里我把执行阶段分为5段,只是给你举个例子。很多实际的CPU架构,划分了更多的阶段。比如,某类型的奔腾芯片支持21段,那理论上也就意味着可以有21条指令并行执行,但它的前提是必须做好指令排序的优化。<br>
|
||||
另外,现代一些CISC的CPU在硬件层面支持乱序执行(Out-of-Order)。一批指令给到CPU后,它也会在内部打乱顺序去优化执行。而RISC芯片一般不支持乱序执行,所以像ARM这样的芯片,做指令排序就更加重要。
|
||||
|
||||
另外,在上一讲,我提到过对循环做优化的一种技术,叫做**循环展开(Loop Unroll)**,它会把循环体中的代码重复多次,与之对应的是减少循环次数。这样一个基本块中就会有更多条指令,增加了通过指令排序做优化的机会。
|
||||
|
||||
指令排序的算法也有很多种,比如基于数据依赖图的List Scheduling算法。在后面的课程中,我会带你考察一下真实世界中的编译器都使用了什么算法。
|
||||
|
||||
OK,了解完指令排序以后,还有什么优化可以做呢?
|
||||
|
||||
### 窥孔优化(Peephole Optimization)
|
||||
|
||||
基于LIR或目标代码,代码还有被进一步优化的可能性。这就是代码优化的特点。比如,你在前面做了常数折叠以后,后面的处理步骤修改了代码或生成新的代码以后,可能还会产生出新的常数折叠的机会。另外,有些优化也只有在目标代码的基础上才方便做。
|
||||
|
||||
给你举个例子吧:假设相邻两条指令,一条指令从寄存器保存数据到栈里,下一条指令又从栈里原封不动地把数据加载到原来的寄存器,那么这条加载指令就是冗余的,可以去掉。
|
||||
|
||||
```
|
||||
str r0, [sp, #4] //把r0的值保存到栈顶+4的位置
|
||||
ldr r0, [sp, #4] //把栈顶+4位置的值加载到r0寄存器
|
||||
|
||||
```
|
||||
|
||||
基于目标代码的优化,最常用的方法是**窥孔优化(Peephole Optimization)**。窥孔优化的思路,是提供一个固定大小的窗口,比如能够容纳20条指令,并检查窗口内的指令,看看是否可以优化。然后再往下滑动窗口,再次检查优化机会。
|
||||
|
||||
最后,还有一个因素会影响目标代码的生成,就是调用约定。
|
||||
|
||||
## 调用约定的影响
|
||||
|
||||
还记得前面示例的x86的汇编代码吗?其中的%edi寄存器用来传递第一个参数,%esi寄存器用来传递第二个参数,这就是遵守了一种广泛用于Unix和Linux系统的调用约定“[System V AMD64 ABI](https://github.com/hjl-tools/x86-psABI/wiki/x86-64-psABI-1.0.pdf)”。这个调用约定规定,对于整型参数,前6个参数可以用寄存器传递,6个之后的参数就要基于栈来传递。
|
||||
|
||||
```
|
||||
#序曲
|
||||
pushq %rbp
|
||||
movq %rsp, %rbp #%rbp是栈底指针
|
||||
|
||||
#函数体
|
||||
movl %edi, -4(%rbp) #把第1个参数写到栈里第一个位置(偏移量为4)
|
||||
movl %esi, -8(%rbp) #把第2个参数写到栈里第二个位置(偏移量为8)
|
||||
movl -4(%rbp), %eax #把第1个参数写到%eax寄存器
|
||||
addl -8(%rbp), %eax #把第2个参数加到%eax
|
||||
addl $10, %eax #把立即数10加到%eax,%eax同时是放返回值的地方。
|
||||
|
||||
#尾声
|
||||
popq %rbp
|
||||
retq
|
||||
|
||||
```
|
||||
|
||||
知识扩展:ABI是Application Binary Interface的缩写,也就是应用程序的二进制接口。通常,ABI里面除了规定调用约定外,还要包括二进制文件的格式、进程初始化的方式等更多内容。
|
||||
|
||||
而在看ARM的汇编代码时,我们会发现,它超过了4个参数就要通过栈来传递。实际上,它遵循的是一种不同ABI,叫做EABI(嵌入式应用程序二进制接口)。在调用Clang做编译的时候,-target参数“**armv7a-none-eabi**”的最后一部分,就是指定了EABI。
|
||||
|
||||
```
|
||||
//序曲
|
||||
sub sp, sp, #8 //把栈扩展8个字节,用于放两个参数,sp是栈顶指针
|
||||
|
||||
//函数体
|
||||
str r0, [sp, #4] //把第1个参数写到栈顶+4的位置
|
||||
str r1, [sp] //把第2个参数写到栈顶位置
|
||||
ldr r0, [sp, #4] //把第1个参数从栈里加载到r0寄存器
|
||||
ldr r1, [sp] //把第2个参数从站立加载到r1寄存器
|
||||
add r0, r0, r1 //把r1加到r0,结果保存在r0
|
||||
add r0, r0, #10 //把常量10加载到r0,结果保存在r0,r0也是放返回值的地方
|
||||
|
||||
//尾声
|
||||
add sp, sp, #8 //缩减栈
|
||||
bx lr //返回
|
||||
|
||||
```
|
||||
|
||||
在实现编译器的时候,你可以发明自己的调用约定,比如哪些寄存器用来放参数、哪些用来放返回值,等等。但是,如果你要使用别的语言编译好的目标文件,或者你想让自己的编译器生成的目标文件被别人使用,那你就要遵守某种特定的ABI标准。
|
||||
|
||||
## 后端处理的整体过程
|
||||
|
||||
好了,到这里,我已经介绍完了生成目标代码过程中所做的各种优化处理。**那么,我们怎么把它们串成一个整体呢?**
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/06/e500f1602aa03fd6893933517bf71a06.jpg" alt="">
|
||||
|
||||
在实际实现时,我们通常是先做指令选择,然后做一次指令排序。在分配完寄存器以后,还要再做一次指令排序,因为寄存器分配算法会产生新的指令排序优化的机会。比如,一些变量会溢出到栈里,从而增加了一些内存访问指令。
|
||||
|
||||
这个处理过程,其实也是IR不断lower的过程。一开始是MIR,在做了指令选择以后,就变成了具体架构相关的LIR了。在没做寄存器分配之前,我们在LIR中用到寄存器还是虚拟的,数量是无限的,做完分配以后,就变成具体的物理寄存器的名称了。
|
||||
|
||||
与机器相关的优化(如窥孔优化)也会穿插在整个过程中。最后一个步骤,是通过一个Emit目标代码的程序生成目标代码。因为IR已经被lower得很接近目标代码了,所以这个翻译程序是比较简单的。
|
||||
|
||||
## 课程小结
|
||||
|
||||
今天这一讲,我带你认识了编译器在后端的主要工作,也就是生成目标代码时,所需要的各种优化和处理。你需要注意理解每一步处理的原理,比如到底为什么需要做指令选择,形成直观认识。
|
||||
|
||||
这一讲,我没有带你去深入算法的细节,而是希望先带你建立一个整体的认知。在我们考察真实的编译器时,你要注意研究它们的后端是如何实现的。
|
||||
|
||||
我把今天的课程内容,也整理成了思维导图,供你参考。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b7/62/b71852806b0a7531c62c2ddc3fe51e62.jpg" alt="">
|
||||
|
||||
## 一课一思
|
||||
|
||||
用Clang(或gcc)来生成汇编代码,对研究生成目标代码时的优化效果非常有帮助。你可以设计一个C语言的简单函数,测试出编译器在指令选择、寄存器分配或指令排序的任意方面的优化效果吗?
|
||||
|
||||
你可以比较下,带和不带“-O2”参数生成的汇编代码,有什么不同。你还可以查看手册,使用更多的选项(比如对于[x](https://clang.llvm.org/docs/ClangCommandLineReference.html#x86)[86架构](https://clang.llvm.org/docs/ClangCommandLineReference.html#x86),你可以控制是否使用AVX指令集)。这个练习,会帮助你获得更多的直观理解。
|
||||
|
||||
在留言区,把你动手实验的成果分享出来吧,我们一起交流讨论。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。
|
||||
|
||||
## 参考链接
|
||||
|
||||
1. 关于汇编代码、寄存器、调用约定等内容更详细的介绍,你可以参考《编译原理之美》的第[23](https://time.geekbang.org/column/article/150798)、[24](https://time.geekbang.org/column/article/151939)讲。
|
||||
1. 关于指令选择的算法,你可以参考《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),我介绍了一个树覆盖算法。
|
||||
1. 关于寄存器分配的算法,你也可以参考《编译原理之美》的[第29讲](https://time.geekbang.org/column/article/158315),我介绍了一个寄存器染色算法。
|
||||
1. 关于指令排序的算法,你可以参考《编译原理之美》的[第30讲](https://time.geekbang.org/column/article/159552),深入去看一下基于数据依赖图的List Scheduling算法。
|
||||
355
极客时间专栏/geek/编译原理实战课/预备知识篇/知识地图 | 一起来复习编译技术核心概念与算法.md
Normal file
355
极客时间专栏/geek/编译原理实战课/预备知识篇/知识地图 | 一起来复习编译技术核心概念与算法.md
Normal file
@@ -0,0 +1,355 @@
|
||||
<audio id="audio" title="知识地图 | 一起来复习编译技术核心概念与算法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ca/04/cab42ab557be6fb1f47819f2696ca104.mp3"></audio>
|
||||
|
||||
你好,我是学习委员朱英达。
|
||||
|
||||
在“预备知识篇”这个模块,宫老师系统地梳理了编译过程中各个阶段的核心要点,目的就是让我们建立一个编译原理的基础知识体系。那到今天为止,我们就学完了这部分内容,迈出了编译之旅中扎实的第一步。不知道你对这些知识掌握得怎样了?
|
||||
|
||||
**为了复习,也为了检测我们的学习成果,我根据自己的知识积累和学习情况,整理了一张知识大地图,你可以根据这张地图中标记的七大编译阶段,随时速查常用的编译原理概念和关键算法。**
|
||||
|
||||
如果你也总结了知识地图,那你可以对照着我这个,给自己一个反馈,看看它们之间有哪些异同点,我们可以在留言区中一起交流和讨论。
|
||||
|
||||
不过知识地图的形式,虽然便于你保存、携带、速查,但考虑到图中涉及的概念等内容较多,不方便查看和检索。**所以,我还把地图上的知识点,用文字的形式帮你梳理出来了。**你可以对照着它,来复习和回顾编译技术的核心概念和算法的知识点,构建自己的知识框架。
|
||||
|
||||
你在学习这些预备知识的过程中,可能会发现,宫老师并没有非常深入地讲解编译原理的具体概念、理论和算法。所以,如果你想继续深入学习这些基础知识,可以根据宫老师在每讲最后给出的参考资料,去学习龙书、虎书、鲸书等经典编译原理书籍。当然,你也可以去看看宫老师的第一季专栏课《编译原理之美》。
|
||||
|
||||
在我看来,相较于编译方面的教科书而言,《编译原理之美》这门课的优势在于,更加通俗易懂、与时俱进,既可以作为新手的起步指导,也能够帮助已经熟悉编译技术的工程师扩展视野,我很推荐你去学习这门课。所以,我邀请编辑添加了相应的知识点到《编译原理之美》的文章链接,如果你有深入学习的需要,你会很方便地找到它。
|
||||
|
||||
好了,一起开始复习吧!
|
||||
|
||||
## 一、词法分析:根据词法规则,把字符串转换为Token
|
||||
|
||||
### 核心概念:正则文法
|
||||
|
||||
- 正则文法:词法分析工作的主要文法,它涉及的重要概念是正则表达式。
|
||||
- 正则表达式:正则文法的一种直观描述,它是按照指定模式匹配字符串的一种字符组合。
|
||||
- 正则表达式工具:字符串的模式匹配工具。大多数程序语言都内置了正则表达式的匹配方法,也可以借助一些编译工具,自动化根据正则表达式生成字符串匹配程序,例如C++的Lex/Yacc以及Java的ANTLR。
|
||||
|
||||
### 具体实现:手工构造词法分析器、自动生成词法分析器
|
||||
|
||||
[**手工构造词法分析器**](https://time.geekbang.org/column/article/118378)
|
||||
|
||||
- 构造词法分析器使用的计算模型:有限自动机(FSA)。它是用于识别正则文法的一种程序实现方案。
|
||||
- 其组成的词法单元是Token,也就是指程序中标记出来的单词和标点符号,它可以分成关键字、标识符、字面量、操作符号等多个种类。
|
||||
- 在实际的编译器中,词法分析器一般都是手写的。
|
||||
|
||||
[**自动生成词法分析器**](https://time.geekbang.org/column/article/137286)
|
||||
|
||||
- 具体实现思路:把一个正则表达式翻译成NFA,然后把NFA转换成DFA。
|
||||
- DFA:确定的有限自动机。它的特点是:该状态机在任何一个状态,基于输入的字符,都能做一个确定的状态转换。
|
||||
- NFA:不确定的有限自动机。它的特点是:该状态机中存在某些状态,针对某些输入,不能做一个确定的转换。这里可以细分成两种情况:一种是对于一个输入,它有两个状态可以转换;另一种是存在ε转换的情况,也就是没有任何字符输入的情况下,NFA也可以从一个状态迁移到另一个状态。
|
||||
|
||||
### 技术难点
|
||||
|
||||
**首先,你需要注意,NFA和DFA都有各自的优缺点,以及不同的适用场景。**
|
||||
|
||||
- NFA:优点是在设计上更简单直观,缺点是它无法避免回溯问题,在某些极端的情况下可能会造成编译器运行的性能低下。主要适用于状态较为简单,且不存在回溯的场景。
|
||||
- DFA:优点是它可以避免回溯问题,运行性能较高,缺点是DFA通常不容易直接设计出来,需要通过一系列方案,基于NFA的转换而得到,并且需要占用额外的空间。主要适用于状态较为复杂,或者对时间复杂度要求较为严苛的工业级词法分析器。
|
||||
|
||||
**其次,你需要了解基于正则表达式构造NFA,再去进行模式匹配的算法思路。**
|
||||
|
||||
- 从正则表达式到NFA:这是自动生成词法分析器的一种算法思路。它的翻译方法是,匹配一个字符i —>匹配“或”模式s|t —> 匹配“与”模式st —> 重复模式,如“?”“*”和“+”等符号,它们的意思是可以重复0次、0到多次、1到多次,注意在转换时要增加额外的状态和边。
|
||||
- 从NFA到DFA:NFA的运行可能导致大量的回溯,所以我们可以把NFA转换成DFA,让字符串的匹配过程更简单。从NFA转换成DFA的算法是子集构造法,具体的算法思路你可以参考第16讲。
|
||||
|
||||
## 二、语法分析:依据语法规则,编写语法分析程序,把 Token 串转化成 AST
|
||||
|
||||
### 核心概念:上下文无关文法
|
||||
|
||||
- 上下文无关的意思:在任何情况下,文法的推导规则都是一样的。
|
||||
- 语法规则由4个部分组成:一个有穷的非终结符(或变元)的集合、一个有穷的终结符的集合、一个有穷的产生式集合、一个起始非终结符(变元)。符合这四个特点的文法规则就是上下文无关文法。
|
||||
- 两种描述形式:一种是巴科斯范式(BNF),另一种是巴科斯范式的一种扩展形式(EBNF),它更利于自动化生成语法分析器。其中,产生式、终结符、非终结符、开始符号是巴科斯范式的基本组成要素。
|
||||
- 上下文无关文法与正则文法的区别:上下文无关文法允许递归调用,而正则文法不允许。上下文无关文法比正则文法的表达能力更强,正则文法是上下文无关文法的一个子集。
|
||||
|
||||
### 具体实现:自顶向下、自底向上
|
||||
|
||||
**一种是自顶向下的算法思路,它是指从根节点逐层往下分解,形成最后的AST。**
|
||||
|
||||
- [递归下降算法](https://time.geekbang.org/column/article/119891):它的算法思路是按照语法规则去匹配Token串。优点:程序结构基本上是跟文法规则同构的。缺点:会造成[左递归](https://time.geekbang.org/column/article/120388)和[回溯](https://time.geekbang.org/column/article/125926)问题。**注意**,递归下降是深度优先(DFS)的,只有最左边的子树都生成完了,才会往右生成它的兄弟节点。
|
||||
- [LL算法](https://time.geekbang.org/column/article/138385):对于一些比较复杂的语法规则来说,这个算法可以自动计算出选择不同产生式的依据。方法:从左到右地消化掉 Token。要点:计算 First 和 Follow 集合。
|
||||
|
||||
**另一种是**[**自底向上的算法思路**](https://time.geekbang.org/column/article/139628)**,它是指从底下先拼凑出AST的一些局部拼图,并逐步组装成一棵完整的AST。**
|
||||
|
||||
- 自底向上的语法分析思路:移进,把token加入工作区;规约,在工作区内组装AST的片段。
|
||||
- LR算法和 LL 算法一样,也是从左到右地消化掉 Token。
|
||||
|
||||
### 技术难点
|
||||
|
||||
**首先,你需要掌握LL算法的要点,也就是**[**计算First和Follow集合**](https://time.geekbang.org/column/article/138385)。
|
||||
|
||||
**其次,你要了解LL算法与LR算法的异同点。**
|
||||
|
||||
- LL算法:优点是较为直观、容易实现,缺点是在一些情况下不得不处理左递归消除和提取左因子问题。
|
||||
- LR算法:优点是不怕左递归,缺点是缺少完整的上下文信息,编译错误显示不友好。
|
||||
|
||||
## 三、语义分析:检查程序是否符合语义规则,并为后续的编译工作收集语义信息
|
||||
|
||||
### 核心概念:[上下文相关文法](https://time.geekbang.org/column/article/133737)
|
||||
|
||||
- 属性文法:上下文相关文法对EBNF进行了扩充,在上下文无关的推导过程中,辅助性解决一些上下文相关的问题。
|
||||
- 注意:上下文相关文法没有像状态图、BNF那样直观的分析范式。
|
||||
- 应用场景:控制流检查、闭包分析、引用消解等。
|
||||
|
||||
### 场景案例
|
||||
|
||||
**1.控制流检查**
|
||||
|
||||
像return、break 和continue等语句,都与程序的控制流有关,它们必须符合控制流方面的规则。在 Java 这样的语言中,语义规则会规定:如果返回值不是 void,那么在退出函数体之前,一定要执行一个 return 语句,那么就要检查所有的控制流分支,是否都以 return 语句结尾。
|
||||
|
||||
**2.**[**闭包分析**](https://time.geekbang.org/column/article/131317)
|
||||
|
||||
很多语言都支持闭包。而要正确地使用闭包,就必须在编译期知道哪些变量是自由变量。这里的自由变量是指在本函数外面定义的变量,但被这个函数中的代码所使用。这样,在运行期,编译器就会用特殊的内存管理机制来管理这些变量。所以,对闭包的分析,也是上下文敏感的。
|
||||
|
||||
### 具体实现:引用消解、符号表、类型系统、属性计算
|
||||
|
||||
[**引用消解**](https://time.geekbang.org/column/article/133737)
|
||||
|
||||
- 概念解释:引用消解是一种非常重要的上下文相关的语义规则,它其实就是从符号表里查找被引用的符号的定义。
|
||||
- [作用域](https://time.geekbang.org/column/article/128623):指计算机语言中变量、函数、类等起作用的范围。对于变量来说,为了找到正确的引用,就需要用到作用域。一般来说,它有两种使用场景,一种是标识符作用域,一种是词法作用域。
|
||||
|
||||
[**符号表**](https://time.geekbang.org/column/article/130422)
|
||||
|
||||
- 符号表内包含的信息:名称、分类、类型、作用域等。
|
||||
- 存储形式:线性表格、层次化表格。
|
||||
- 符号表的作用:维护程序中定义的标识符(ID类Token),提供给编译器的各个环节进行操作。
|
||||
- 建立符号表的过程:整个编译器前端都会涉及到,词法分析阶段将ID类Token收集到符号表中,语法分析阶段可进行读取和补充,语义分析阶段做引用消解时符号表的作用至关重要。
|
||||
- 注意:符号表跟编译过程的多个阶段都相关。
|
||||
|
||||
[**类型系统**](https://time.geekbang.org/column/article/132693)
|
||||
|
||||
- 类型:在计算机语言里,类型是数据的一个属性,它的作用是来告诉编译器或解释器,程序可以如何使用这些数据。
|
||||
- 类型系统:类型系统是一门语言所有的类型的集合,操作这些类型的规则,以及类型之间怎么相互作用的(比如一个类型能否转换成另一个类型)。
|
||||
- 类型检查:这是与类型有关的分析和处理工作之一。主要用于对源程序代码中的一些类型不匹配的情况进行隐式类型转换或直接抛错。
|
||||
- [子类型](https://time.geekbang.org/column/article/134978):面向对象编程时,我们可以给某个类创建不同的子类,实现一些个性化的功能;写程序时,我们可以站在抽象度更高的层次上,不去管具体的差异。把这里的结论抽象成一般意义上的类型理论,就是子类型。
|
||||
- 类型转换:比如说,表达式“`a=b+10`”,如果 a 的类型是浮点型,而右边传过来的是整型,那么一般就要进行缺省的类型转换。
|
||||
- 参数化类型/泛型:泛型是程序设计语言的一种风格或范式。泛型允许程序员在强类型程序设计语言中编写代码时使用一些以后才指定的类型,在实例化时作为参数指明这些类型。
|
||||
|
||||
[**属性计算**](https://time.geekbang.org/column/article/133737)
|
||||
|
||||
- 编译器一般会采用属性计算的方法,来计算出每个 AST 节点的类型属性,然后检查它们是否匹配。
|
||||
- 属性文法:属性计算的方法,就是基于语法规则,来定义一些属性计算的规则,在做语法解析或遍历AST的时候执行这些规则,我们就可以计算出属性值。这种基于语法规则定义的计算规则,被叫做属性文法(Attribute Grammar)。
|
||||
- 属性计算:S属性(综合属性)、I属性(继承属性)。
|
||||
- 形成的数据结构:Annotated AST(带有标注信息的AST)。
|
||||
- 语法制导的翻译:属性计算的特点是会基于语法规则,增加一些与语义处理有关的规则,我们把这种语义规则的定义叫做语法制导的定义,如果变成计算动作,就叫做语法制导的翻译。
|
||||
|
||||
## 四、运行时机制:程序的两种不同的执行模式
|
||||
|
||||
通常情况下,程序有两种执行模式:基于物理机、基于虚拟机。
|
||||
|
||||
### [在物理机上运行](https://time.geekbang.org/column/article/146635)
|
||||
|
||||
**举例**:C、C++、Golang。
|
||||
|
||||
**程序运行的原理**:基于指令指针寄存器的控制,顺序从内存读取指令执行。
|
||||
|
||||
[**CPU**](https://time.geekbang.org/column/article/147854):运行指令的地方。
|
||||
|
||||
- 多种架构:x86、ARM、MIPS、RISC-V、PowerPC等。
|
||||
- 关键构成:寄存器、高速缓存、功能单元。
|
||||
- [汇编代码](https://time.geekbang.org/column/article/150798):操作码(让 CPU 执行的动作)、操作数(指令的操作对象,可以是常数、寄存器和某个内存地址)。
|
||||
|
||||
**内存**:执行指令相关的另一个硬件。
|
||||
|
||||
1.代码区:存放编译完成以后的机器码。<br>
|
||||
2.静态数据区:保存程序中全局的变量和常量。<br>
|
||||
3.栈:适合保存生存期比较短的数据,比如函数和方法里的本地变量。
|
||||
|
||||
- 重要知识点:栈帧的构成、活动记录、逐级调用过程。
|
||||
- 栈的特点:申请和释放—修改栈顶指针,生存期与作用域相同。
|
||||
|
||||
4.堆:适合管理生存期较长的一些数据,这些数据在退出作用域以后也不会消失。
|
||||
|
||||
- 重要知识点:通过操作系统API手动申请和释放。
|
||||
- 管理机制:自动管理、手动管理。
|
||||
|
||||
**操作系统**:除了硬件支撑,程序的运行还需要软件,这就是运行时系统。
|
||||
|
||||
- 定义:除了硬件支撑,程序的运行还需要软件,这些软件叫做运行时系统。
|
||||
- 操作系统:对于把源代码编译成机器码在操作系统上运行的语言来说(比如 C、C++),操作系统本身就可以看做是它们的运行时系统。
|
||||
- 管理CPU资源:分时执行。比如,时间片轮转算法,将CPU以时钟周期为单元执行多进程任务,实现并发。
|
||||
- 管理内存资源:逻辑内存(系统内核对内存地址的划定)、物理内存(硬件中具体每一个bit实际的硬件存储情况)、虚拟内存(基于操作系统内核对内存管理问题的抽象,通常有一部分虚拟内存实际上是存在磁盘上的)。
|
||||
|
||||
### [在虚拟机上运行](https://time.geekbang.org/column/article/161944)
|
||||
|
||||
**举例**:Java、Python、Erlang、Lua。
|
||||
|
||||
**程序运行的原理**:虚拟机是计算机语言的另一种运行时系统。虚拟机上运行的是中间代码,而不是 CPU 可以直接认识的指令。
|
||||
|
||||
**基于栈的虚拟机**:指令在操作数栈的栈顶获取操作数(如JVM、Python虚拟机)。
|
||||
|
||||
- 优点:易于生成代码。
|
||||
- 缺点:代码数量较多、不能充分利用寄存器。
|
||||
|
||||
**基于寄存器的虚拟机**:类似于物理机,从寄存器取操作数(如Erlang、Lua、Dalvik、Ignition)。
|
||||
|
||||
- 优点与缺点:与栈机相反。
|
||||
|
||||
**二者的区别:**主要在于如何获取指令的操作数。
|
||||
|
||||
## 五、中间代码:运行各种优化算法、代码生成算法的基础
|
||||
|
||||
在这门课程中,宫老师主要从用途和层次、解释执行、呈现格式和数据结构等角度来给你讲解IR这一关键概念。如果你想要更深入地了解IR的特点,理解如何生成IR来实现静态编译的语言,你可以去看《编译原理之美》的第[24](https://time.geekbang.org/column/article/151939)、[25](https://time.geekbang.org/column/article/153192)、[26](https://time.geekbang.org/column/article/154438)讲。
|
||||
|
||||
### IR的用途和层次(从抽象层次的角度来划分)
|
||||
|
||||
- 第一类用途:基于源语言做一些分析和变换(HIR)。
|
||||
- 第二类用途:独立于源语言和CPU架构做分析和优化(MIR)。
|
||||
- 第三类用途:依赖于CPU架构做优化和代码生成(LIR)。
|
||||
|
||||
### IR的解释执行
|
||||
|
||||
- P-code:直接用于解释执行的IR。由于它与具体机器无关,因此可以很容易地运行在多种电脑上。这类IR对编译器来说,就是做编译的目标代码。
|
||||
- 注意:P-code也可能被进一步编译,形成可以直接执行的机器码。如Java的字节码。
|
||||
|
||||
### IR的呈现格式
|
||||
|
||||
大部分IR没有像源代码和汇编代码那样的书写格式。
|
||||
|
||||
- 大多数的IR跟AST一样,只是编译过程中的一个数据结构而已,或者说只有内存格式。比如,LLVM的IR在内存里是一些对象和接口。
|
||||
- 为了调试的需要,你可以把IR以文本的方式输出,用于显示和分析。
|
||||
|
||||
### IR的数据结构
|
||||
|
||||
- 第一种:类似TAC的线性结构。
|
||||
- 第二种:树结构。
|
||||
- 第三种:DAG-有向无环图。
|
||||
- 第四种:PDG-程序依赖图。
|
||||
|
||||
### SSA格式的IR
|
||||
|
||||
- 概念:SSA,即静态单赋值。这是IR的一种设计范式,它要求一个变量只能被赋值一次。
|
||||
- 要点:使用SSA的形式,体现了精确的“**使用-定义(Use-def)**”关系,并且由于变量的值定义出来以后就不再变化,使得基于SSA更容易运行一些优化算法。
|
||||
- 注意:现代语言用于优化的IR,很多都是基于SSA的,包括Java的JIT编译器、JavaScript的V8编译器、Go语言的gc编译器、Julia编译器,以及LLVM工具等。
|
||||
|
||||
## 六、代码分析与优化:优化程序对计算机资源的使用,以提高程序的性能
|
||||
|
||||
### 优化分类
|
||||
|
||||
**是否与机器有关**
|
||||
|
||||
- 机器无关:指与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)。
|
||||
- 机器有关:需要利用某硬件特有的特征,比如 SIMD 指令可以在一条指令里完成多个数据的计算。
|
||||
|
||||
**优化范围**
|
||||
|
||||
- 本地优化/局部优化:基本块内。
|
||||
- 全局优化:函数(过程)内。
|
||||
- 过程间优化:跨函数(过程)。
|
||||
|
||||
### [**优化方法**](https://time.geekbang.org/column/article/155338)
|
||||
|
||||
**1.把常量提前计算出来**
|
||||
|
||||
- 常数折叠:程序里的有些表达式,肯定能计算出一个常数值,那就不要等到运行时再去计算,干脆在编译期就计算出来。比如“`x=2*3`”可以优化成“`x=6`” 。
|
||||
- 常数传播:如果你一旦知道 x 的值其实是一个常量,那你就可以把所有用到 x 的地方,替换成这个常量。
|
||||
- 稀疏有条件的常数传播:基于常数传播,还可以进一步导致分支判断条件转化为常量,导致一个分支的代码不会被执行。
|
||||
|
||||
**2.用低代价的方法做计算**
|
||||
|
||||
- 代数简化:利用代数运算规则所做的简化,比如“`x=x*0`”可以简化成“`x=0`”。
|
||||
|
||||
**3.消除重复的计算**
|
||||
|
||||
- 拷贝传播:遇到相同引用的变量,拷贝替换为同一个,节省内存到寄存器的操作,以此提升运算速度。
|
||||
- 值编号(VN和GVN):把相同的值,在系统里给一个相同的编号,并且只计算一次。
|
||||
- 公共子表达式消除(CSE):也会减少程序的计算次数。比如“`x:=a+b`”和“`y:=a+b`”,x和y右边的形式是一样的,就可以让y等于x,从而减少了一次对“`a+b`”的计算。
|
||||
- 部分冗余消除(PRE):是公共子表达式消除的一种特殊情况。
|
||||
|
||||
**4.化零为整,向量计算**
|
||||
|
||||
- 超字级并行(SLP):把基本块中的多个变量组成一个向量,用一个指令完成多个变量的计算。
|
||||
- 循环向量化:在循环展开的基础上,把多次计算优化成一个向量计算。
|
||||
|
||||
**5.化整为零,各个优化**
|
||||
|
||||
- 聚合体的标量替换(SROA):很多语言都有结构和对象这样的复合数据类型,内部包含了多个成员变量,这种数据类型叫做聚合体(aggregates)。
|
||||
- 编译器可以把聚合体的成员变量放在寄存器中进行计算,不需要访问内存。
|
||||
|
||||
**6.针对循环,重点优化**
|
||||
|
||||
- 归纳变量优化:归纳变量是指在循环体内由循环变量派生出来的变量,其变化是很有规律的,因此可以尝试做强度折减优化。
|
||||
- 边界检查消除:在循环体内每次循环都会执行的边界检查代码,将其整合抽离出来,避免每次重复判断。
|
||||
- 循环展开:通过把循环次数减少,但在每一次循环里,完成原来多次循环的工作量。
|
||||
- 循环向量化:在循环展开的基础上,把多次计算优化成一个向量计算。
|
||||
- 重组:在循环结构中,使用代数简化和重组,能获得更大的收益。
|
||||
- 循环不变代码外提(LICM):在循环结构中,如果发现有些代码其实跟循环无关,那就应该提到循环外面去,避免一次次重复计算。
|
||||
- 代码提升:在条件语句中,如果多个分支条件里都执行了同一句代码,可将其提升至判断条件之前;如果是在循环体内,还可以继续借助循环不变代码外提优化,进一步提升到循环体之外,从而降低计算量。
|
||||
|
||||
**7.减少过程调用的开销**
|
||||
|
||||
- 尾调用优化和尾递归优化:尾调用就是一个函数的最后一句,是对另一个函数的调用。如果函数最后一句调用的函数是自己,就称为尾递归。尾调用可以将函数调用栈合并,尾递归还可以转换成循环,从而进一步做一系列针对循环语句的优化工作。
|
||||
- 内联:内联也叫做过程集成,就是把被调用函数的代码拷贝到调用者中,从而避免函数调用。
|
||||
- 内联扩展:内联扩展跟普通内联类似,也是在调用的地方展开代码。不过内联扩展被展开的代码,通常是手写的、高度优化的汇编代码。
|
||||
- 叶子程序优化:叶子程序,是指不会再调用其他程序的函数(或过程)。因此,它也可以对栈的使用做一些优化。
|
||||
|
||||
**8.对控制流做优化**
|
||||
|
||||
- 不可达代码的消除:根据控制流的分析,发现有些代码是不可能到达的,可以直接删掉,比如 return 语句后面的代码。
|
||||
- 死代码删除:通过对流程的分析,发现某个变量赋值了以后,后面根本没有再用到这个变量。这样的代码就是死代码,就可以删除。
|
||||
- if简化:在讲常量传播时我们就见到过,如果有可能if条件肯定为真或者假,那么就可以消除掉 if 结构中的then块、else块,甚至整个消除if结构。
|
||||
- 循环简化:也就是把空循环或者简单的循环,变成直线代码,从而增加了其他优化的机会,比如指令的流水线化。
|
||||
- 循环反转:这是对循环语句常做的一种优化,就是把一个 while 循环改成一个 repeat…until 循环(或者 do…while 循环)。这样会使基本块的结构更简化,从而更有利于其他优化。
|
||||
- 拉直:如果发现两个基本块是线性连接的,那可以把它们合并,从而增加优化机会。
|
||||
- 反分支:也就是减少程序分支,因为分支会导致程序从一个基本块跳到另一个基本块,这样就不容易做优化。比如,把循环内部的 if 分支挪到循环外面去,先做 if 判断,然后再执行循环,这样总的执行 if 判断的次数就会减少,并且循环体里面的基本块不那么零碎,就更加容易优化。
|
||||
|
||||
### [**分析方法**](https://time.geekbang.org/column/article/156878)
|
||||
|
||||
1. **控制流分析**(CFA): 基于程序的控制语句(如条件语句、循环语句、分支语句和基本块语句等)进行分析,建立对程序执行过程的理解,从而进一步做出优化。
|
||||
1. **数据流分析**(DFA):基于数据流分析框架(包含“方向(D)”“值(V)”“转换函数(F)”“初始值(I)”和“交运算(Λ)”5 个元素)等方式,建立对程序中数据变化情况的理解,从而进一步做出优化。
|
||||
1. **依赖分析**:分析出程序代码的控制依赖(Control Dependency)和数据依赖(Data Dependency)关系。这对指令排序和缓存优化很重要。
|
||||
1. **别名分析**:在 C、C++ 等可以使用指针的语言中,同一个内存地址可能会有多个别名,因为不同的指针都可能指向同一个地址。编译器需要知道不同变量是否是别名关系,以便决定能否做某些优化。
|
||||
|
||||
### 优化方法的重要性和顺序
|
||||
|
||||
**重要性**
|
||||
|
||||
- 对所有语言都重要:循环优化等。
|
||||
- 面向对象语言:内联、逃逸等。
|
||||
- 函数式语言:尾递归优化等。
|
||||
|
||||
**顺序**
|
||||
|
||||
- 要点:机器无关-早期,机器相关-后期。
|
||||
- 注意:一个优化会导致另一个优化,同一个优化会多遍运行。
|
||||
|
||||
## 七、目标代码生成:编译器最后一个阶段的工作,生成针对不同架构的目标代码,也就是生成汇编代码
|
||||
|
||||
### 生成针对不同架构的目标代码
|
||||
|
||||
- **x86**:CISC指令,Register-Memory架构。在指令里可以混合使用内存地址和寄存器。
|
||||
- **ARM**:RISC指令,Load-Store架构。在ARM的汇编中,从寄存器到内存要使用str(也就是Store)指令,而从内存到寄存器要使用ldr(也就是Load)指令。在这种架构下,指令被分为内存访问(Load和Store)和ALU操作两大类,且后者只能在寄存器上操作。
|
||||
- **策略**:编写“代码生成器的生成器”。也就是把CPU架构的各种信息描述出来,基于这些信息生成目标代码的生成器,就像根据语法规则,用ANTLR、bison这样的工具来生成语法解析器一样。
|
||||
|
||||
### [**生成目标代码时的优化工作**](https://time.geekbang.org/column/article/158315)
|
||||
|
||||
**1.指令选择**
|
||||
|
||||
- 做指令选择的原因:生成更精简、性能更高的代码;使得同一个功能可以由多种方式实现。
|
||||
- 算法:树覆盖算法、自底向上的重写系统(BURS)
|
||||
|
||||
**2.寄存器分配**
|
||||
|
||||
- 原理:两个变量,不同时活跃,可以共享寄存器。
|
||||
- 算法:图染色算法(优点-更优化,缺点-速度慢)、线性扫描算法(优点-不够优化,缺点-速度快)
|
||||
|
||||
**3.**[**指令排序**](https://time.geekbang.org/column/article/159552)
|
||||
|
||||
- 原理:CPU内部的单元可并行。
|
||||
- 实现:基于数据依赖图的List Scheduling算法。
|
||||
|
||||
**4.窥孔优化**
|
||||
|
||||
思路:提供一个固定大小的窗口,比如能够容纳10条指令,并检查窗口内的指令,看看是否可以优化;然后往下滑动窗口,再次检查优化机会。
|
||||
|
||||
### 调用约定的影响
|
||||
|
||||
- 调用约定:你可以发明自己的调用约定,比如哪些寄存器用来放参数、哪些用来放返回值。但是如果要使用别的语言编译好的目标文件,或者想让自己的编译器生成的目标文件被别人使用,那就要遵守某种特定的ABI标准。
|
||||
- Unix和Linux系统的调用约定:System V AMD64 ABI。
|
||||
- ABI:即Application Binary Interface,应用程序的二进制接口。
|
||||
|
||||
### 整体的处理过程
|
||||
|
||||
- 典型过程:指令选择->指令排序->寄存器分配->指令排序->Emit目标代码
|
||||
- 要点:基于LIR,并且不断lower。
|
||||
|
||||
## 知识地图
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a2/22/a260d574faae4a033b9ae3f616d45222.jpg" alt="">
|
||||
Reference in New Issue
Block a user