Go 设计哲学:少即是多,哪里来的?
Posted 脑子进煎鱼了
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go 设计哲学:少即是多,哪里来的?相关的知识,希望对你有一定的参考价值。
大家好,我是煎鱼。
之前在 Go 社区分享知识和经验时,经常会听见诸如:less is more、少即是多,大道至简、大道不停地至简等黑话。
甚至讨论 Go issues 和提案时,都会有人用 “less is more” 来反驳或做为论点支撑,非常的有意思。大家都会很好奇,出处是哪里,是什么意思?
翻译,我这里重新整理、修剪、引用、配图,就不重复造轮子了,明确声明。如下图:
背景这是我(下指 Rob Pike)在 2012 年六月,旧金山 Go 会议上的演讲内容。
这是一个私人演讲。我并未代表 Go 项目团队的任何人在此演讲,但我首先要感谢团队为 Go 的诞生和发展所做的一切。
同时,我也要感谢旧金山 Go 社区给我这个演讲机会。
Go 让我最惊讶的地方在几个星期之前我被问到:“在推出 Go 之后,什么令你感到最为惊奇?”
我立刻有了一个答案:尽管我们希望 C++ 程序员来了解 Go 并作为一个可选的语言,但是更多的 Go 程序员来自如于 Python、Ruby,只有很少来自 C++。
我们(Ken,Robert 和我)自己曾经是 C++ 程序员,我们设计新的语言是为了解决那些我们编写的软件中遇到的问题。
而这些问题,其他 C++ 程序员似乎并不怎么在意,这看起来有些矛盾。
为什么要开发 Go今天我想要谈谈是什么促使我们创建了 Go,以及为什么本不应该是这样会我们惊讶的结果。
我承诺讨论 Go 会比讨论 C++ 多,即便你不了解 C++ 也仍然完全跟得上主题。
答案可以概括为:你认为少既是多,还是少就是少?
贝尔实验室的故事这里有一个真实的故事作为隐喻。如下:
贝尔实验室最初用三个数字标识:111 表示物理研究,127 表示计算机科学研究,等等。 在上世纪八十年代早期,一篇如期而至的备忘录声明由于我们所了解的研究正在增长,为了便于识别我们的工作,必须再添加一位数。因此,我们的中心变为 1127。 Ron Hardin 开玩笑半认真的说道,如果我们真的更好的了解了这个世界,我们可以减少一位数,使得 127 仅为 27。
当然管理层没有听到这个笑话,又或者他们不愿意听到,但是我想这其中确有大的智慧。少既是多。你理解得越好,越含蓄。
请务必记住这个思路。
开发 Go 的背景C++ 编译等待
回到 2007 年 9 月,我在一个巨大的 Google C++ 程序(就是你们都用过的那个)上做一些琐碎但是很核心的工作。
我在那个巨大的分布式集群上需要花大约 45 分钟进行编译。
C++ 新特性改进
收到一个通知说几个受雇于 Google 的为 C++ 标准化委员会工作的人将会做一场报告。
他们将向我们介绍那时还被称作 C++0x(就是现在众所周知的 C++11)中将会有哪些改进。
在长达一个小时的报告中,我们听说了诸如有已经在计划中的 35 个特性之类的事情。
事实上有更多,但仅有 35 个特性在报告中进行了描述。当然一些特性很小,但是意义重大,值得在报告中提出。
还有一些非常微妙和难以理解,如:
这时我问了自己一个问题:C++ 委员会真得相信 C++ 的问题在于没有足够的特性?
肯定的说,在另一个 Ron Hardin 的玩笑中,简化语言的成就远远大于添加功能。当然这有点可笑,不过请务必记住这个思路。
实验性语言尝试
就在这个 C++ 报告会的数月前,我自己也进行了一场演讲,你可以在 YouTube 上看到,关于我在上世纪 80 年代开发的一个玩具性质的并发语言。这个语言被叫做 Newsqueak,它是 Go 的前辈了。
我进行这次报告是因为在 Newsqueak 中缺失的一些想法,在为 Google 工作的时候我再次思考了这些它们。我当时确信它们可以使得编写服务端代码变得更加轻松,使得 Google 能从中获得收益。
事实上我曾尝试在 C++ 中实现这些思路,但是失败了。要将 C++ 控制结构和并发操作联系起来太困难了,最终这导致很难看到真正的优势。
虽然我承认我从未真正熟练的使用 C++,但是纯粹的 C++ 仍然让所有事情看起来过于笨重。所以我放弃了这个想法。
但是那场 C++0x 报告让我再次思考这个问题。有一件令我十分困扰的事情(同时我相信也在困扰着 Ken 和 Robert)是新的 C++ 内存模型有原子类型。
感觉上在一个已经负担过重的类型系统上加入如此微观的描述细节的集合是绝对的错误。这同样是目光短浅的,几乎能确信硬件在接下来的十年中将迅速发展,将语言和当今的硬件结合的过于紧密是非常愚蠢的。
Go 初始团队
在报告后我们回到了办公室。我启动了另一个编译,将椅子转向 Robert,然后开始沟通关键的问题。
在编译结束前,我们已经把 Ken 拉了进来,并且决定做些什么。
我们不准备继续写 C++ 了,并且我们——尤其是我,希望在写 Google 代码的时候能够做轻松的编写并发。
同时我们也想勇往直前的驾驭“大编程”,后面会谈到。
Go 特性讨论
我们在白板上写了一堆想要的东西,和其必要条件。忽略了语法和语义细节,设想了蓝图和全局。
我这里还有那时的一个令人神混魂颠倒的邮件。
这里摘录了一部分:
Robert:起点是 C,修复一些明显的缺陷,移除杂物,添加一些缺失的特性。
Rob:命名为 “go”。你们可以编造这个名字的来由,不过它有很好的底子。它很短,容易拼写。工具:goc, gol, goa。如果有交互式调试器/解释器,可以就叫做 “go”。扩展名是 .go。
Robert:空接口为 interface。它们实现了所有的接口,所以这个可以用来代替 void *。
我们并没有正确描绘全部的东西。例如,描绘 array 和 slice 用了差不多一年的时间。但是这个语言特色的大多数重要的东西都在开始的几天里确定下来。
注意 Robert 说 C 是起点,而不是 C++。我不确定,不过我相信他是指 C,尤其是 Ken 在的情况下。
不过事实是,最终我们没有从 C 作为起点。我们从头开始,仅仅借鉴了如运算符、括号、大括号、和部分关键字。(当然也从我们知道的其他语言中吸取了精髓。)
无论如何,我们现在同 C++ 做着相反的事情,解构全部,回到原点重新开始。我们并未尝试去设计一个更好的 C++,甚至更好的 C。仅仅是一个对于我们在意的那种类型的软件来说更好的语言。
最终,它成为了一个与 C 和 C++ 完全不同的语言。每个发布版本都越来越不同。
Go 特性清单
我制作了一个 Go 中对 C 和 C++ 进行的重要简化的清单:
除了这个简化清单和一些未提及的琐碎内容,我相信,Go 相比 C 或者 C++ 是更加有表达力的。少既是多。
但是即便这样也不能丢掉所有东西。仍然需要构建类型工作的方式,在实践中恰当的语法,以及让库的交互更好这种令人感到忌讳不可言喻的事情。
我们也添加了一些 C 或者 C++ 没有的东西,例如 slice 和 map,复合声明,每个文件的顶级表达式(一个差点被忘记的重要东西),反射,垃圾收集,等等。当然,还有并发。
无法想象没有泛型当然明显缺少的是类型层次化。请允许我对此爆那么几句粗口。
在 Go 最初的版本中,有人告诉我他无法想像用一个没有泛型范型的语言来工作。就像之前在某些地方提到过的,我认为这绝对是神奇的评论。
公平的说,他可能正在用其自己的方式来表达非常喜欢 STL 在 C++ 中为他做的事情。在辩论的前提下,让我们先相信他的观点。
他说编写像 int 列表或 map string 这样的容器是一个无法忍受的负担。我觉得这是个神奇的观点。
即便是那些没有泛型范型的语言,我也只会花费很少的时间在这些问题上。
面向对象的方式但是更重要的是,他说类型是放下这些负担的解决途径。类型。不是函数多态,不是语言基础,或者其他协助,仅仅用类型。
这就是卡住我的细节问题。
从 C++ 和 Java 转过来 Go 的程序员怀念工作在类型上的编程方式,尤其是继承和子类,以及所有相关的内容。可能对于类型来说,我是门外汉,不过我真得从未发现这个模型十分具有表达力。
我已故的朋友 Alain Fournier 有一次告诉我说他认为学术的最低级形式就是分类。那么你知道吗?类型层次化就是分类。
你必须对哪块进哪个盒子作出决策,包括每个类型的父级,不论是 A 继承自 B,还是 B 继承自 A。
一个可排序的数组是一个排序过的数组还是一个数组表达的排序器?如果你坚信所有问题都是由类型驱动设计的,那么你就必须作出决策。
我相信这样思考编程是荒谬可笑的。核心不是东西之间的祖宗关系,而是它们可以为你做什么。
当然,这就是接口进入 Go 的地方。但是它们已经是蓝图的一部分,那是真正的 Go 哲学。
如果说 C++ 和 Java 是关于类型继承和类型分类的,Go 就是关于组合的。
Unix pipe 的最终发明人 Doug McIlroy 在 1964 (!) 这样写到:
我们应当像连接花园里的龙头和软管一样,用某种方式一段一段的将消息数据连接起来。这同样是 IO 使用的办法。
这也是 Go 使用的办法。Go 用了这个主意,并且将其向前推进了一大步。这是一个关于组合与连接的语言。
一个显而易见的例子就是接口为我们提供的组合元件的方式。只要它实现了方法 M,就可以放在合适的地方,而不关心它到底是什么东西。
另一个重要的例子是并发如何连接独立运行的计算。并且也有一个不同寻常(却非常简单)的类型组合模式:嵌入。
这就是 Go 特有的组合技术,滋味与 C++ 或 Java 程序完全不同。
C++/Java 的大编程模式有一个与此无关的 Go 设计我想要提一下:Go 被设计用于帮助编写大程序,由大团队编写和维护。
有一个观点叫做“大编程”,不知怎么回事 C++ 和 Java 主宰了这个领域。我相信这只是一个历史的失误,或者是一个工业化的事故。但是一个广泛被接受的信念是面向对象的设计可以做些事情。
我完全不相信那个。大软件确实需要方法论保驾护航,但是用不着如此强的依赖管理和如此清晰的接口抽象,甚至如此华丽的文档工具,但它不比强大的依赖管理、清晰的接口抽象和优秀的文档工具来得更重要,而这些没有一样是 C++ 做好的事情(尽管 Java 明显做得更好一些)。
我们还不知道,因为没有足够的软件采用 Go 来编写,不过我有自信 Go 将在大编程领域脱颖而出。时间证明一切。
为什么 Go 不被 C++ 程序员喜欢现在,回到我演讲一开始提到的那个令人惊奇的问题:
为什么 Go,一个被设计为用于摧毁 C++ 的语言,并为并未获得 C++ 程序员的芳心?
撇开玩笑不说,我认为那是因为 Go 和 C++ 有着完全不同的哲学。
C++ 是让你的指尖解决所有的问题。
我在 C++11 的 FAQ 上引用了这段内容:
C++ 与那些巨大增长的特别编写的手工代码相比,具有更加广泛的抽象,优雅、灵活并且零成本的表达能力。
这个思考的方向与 Go 的不同。零成本不是目标,至少不是零 CPU 成本。Go 的主张更多考虑的是最小化程序员的工作量。
Go 不是无所不包的。你无法通过内建获得所有东西。你无法精确控制每个细微的执行。例如没有 RAII。而可以用垃圾收集作为代替。也没有内存释放函数。
你得到的是功能强大,但是容易理解的,容易用来构建一些用于连接组合解决问题的模块。
这可能最终不像你使用其他语言编写的解决方案那么快,那么精致,在思想体系上那么明确,但它确实会更加容易编写,容易阅读,容易理解,容易维护,并且更加安全。
换句话说,当然,有些过于简单:
那么问题是,Go 的成功能否反驳他们的世界观?我们应当在一开始的时候就意识到了一点。
那些为 C++11 的新特性而兴奋的人们是不会在意一个没有这么多特性的语言。即便最后发现这个语言能够比他们所想象的提供更多。
谢谢大家。
总结一直以来对 Go 的哲学 “less is more” 非常的好奇,来源是何处,含义又是什么?
在春节期间阅读和梳理了一遍,虽然演讲内容比较多,也偏向口语化。但本质上 Rob Pike 所说的 “less is more” 是一个比较有趣的东西。
核心观点在于:“Go 与 C++ 的观念完全不同,希望程序员的工作量最小化,自身少量的特性,应当能够连接组合解决问题,更具表达力,而不是堆功能”。
你觉得呢?:)
参考资料Less is exponentially more: https://commandcenter.blogspot.com/2012/06/less-is-exponentially-more.html
[2]Simplicity is Complicated: https://www.youtube.com/watch?v=rFejpH_tAHM
[3]Less is exponentially more: https://commandcenter.blogspot.com/2012/06/less-is-exponentially-more.html
[4]@MIKESPOOK: https://mikespook.com/2012/06/%E7%BF%BB%E8%AF%91%E5%B0%91%E6%98%AF%E6%8C%87%E6%95%B0%E7%BA%A7%E7%9A%84%E5%A4%9A/
关注煎鱼,获取业内第一手消息和知识 [GO专栏-2]Go语言的设计哲学
[GO专栏-2]Go语言的设计哲学
简单
第一条原则:追求简单,少即是多
所谓大道至简,就是这个道理,语言终归只是个工具,能用简单的方式处理问题为什么要复杂起来呢?
本人深有体会最深的就是当年做c++的时候,看STL的源码,那变量命名,又臭又长一堆下划线,根本让人看不懂,或许有人会说,那是你技术太菜,看不懂大神写的代码,我的理解正好相反,写成这样的代码,真的是大神吗?或者把源码写的让人看不懂就是大神了吗?
所以个人很喜欢go的简单,用着简单,定义简单,自带的标准库源码容易阅读,看着简单等等。
和那些通过相互借鉴而不断增加新特性来吸引程序员眼球的主流编程语言相比,比如 C++、Java 等,Go 的设计者们在语言设计之初就选择拒绝走语言特性融合的道路,选择了“做减法”,选择了“简单”,他们把复杂性留给了语言自身的设计和实现,留给了 Go 核心开发组自身,而将简单、易用和清晰留给了广大 gopher 们。因此,今天呈现在我们在眼前的是这样一门 Go 语言:
-
简洁、常规的语法(不需要解析符号表),它仅有 25 个关键字。
-
内置垃圾收集,降低开发人员内存管理的心智负担。
-
没有头文件。
-
显式依赖(package)。
-
没有循环依赖(package)。
-
常量只是数字。
-
头母大小写决定可见性。
-
任何类型都可以拥有方法(没有类)。
-
没有子类型继承(没有子类)。
-
没有算术转换。
-
接口是隐式的(无需“implements”声明)。
-
方法就是函数。
-
接口只是方法集合(没有数据)。
-
方法仅按名称匹配(不是按类型)。
-
没有构造函数或析构函数。
-
n++和 n–是语句,而不是表达式。
-
没有++n 和–n。
-
赋值不是表达式。
-
在赋值和函数调用中定义的求值顺序(无“序列点”概念)。
-
没有指针算术。
-
内存总是初始化为零值。
-
没有 const 或其他类型注解语法。
-
没有模板/泛型。
-
没有异常(exception)。
-
内置字符串、切片(slice)、字典(map)类型。
-
内置数组边界检查。
-
内置并发支持。
组合
偏好组合,正交解耦
先说说组合与继承的概念。对设计模式有过了解的同学对这两个名词应该都有初步的理解,我们来总结一下:
官方解释就不说了,组合一般理解为 has-a 的关系
,继承是is-a的关系,两者都能起到代码复用的作用。以java为例,组合可以理解为类里边包含一个其他类型的属性值,继承是extends
。
这里我引用一篇文章的段落浅谈组合与继承:
继承的优缺点
优点:
1,类继承简单粗爆,直观,关系在编译时静态定义。
2,被复用的实现易于修改,sub可以覆盖super的实现。
缺点:
1,无法在运行时变更从super继承来的实现(也不一定是缺点)
2,sub的部分实现通常定义在super中。
3,sub直接面对super的实现细节,因此破坏了封装。
4,super实现的任何变更都会强制子类也进行变更,因为它们的实现联系在了一起。
5,如果在新的问题场景下继承来的实现已过时或不适用,所以必须重写super或继承来的实现。
由于在类继承中,实现的依存关系,对子类进行复用可能会有问题。有一个解决办法是,只从协议或抽象基类继承(子类型化),国为它们只对很少的实现,而协议则没有实现。
组合的优缺点
对象组合让我们同时使用多个对象,而每个对象都假定其他对象的接口正常运行。因此,为了在系统中正常运行,它们的接口都需要经过精心的设计。下面我就来说说他的优缺点
优点:
1,不会破坏封装,因为只通过接口来访问对象;
2,减少实现的依存关系,因为实面是通过接口来定义的;
3,可以在运行时将任意对象替换为其他同类型的对象;
4,可以保持类的封装以专注于单一任务;
5,类和他的层次结构能保持简洁,不至于过度膨胀而无法管理;
缺点:
1,涉及对象多;
2,系统的行为将依赖于不同对象间的关系,而不是定义于单个类中;
3,现成的组件总是不太够用,从而导致我们要不停的定义新对象。
总结来看,我认为,组合相对于继承的优点在于:
-
可以利用面向接口编程原则的一系列优点,封装性好,耦合性低。
-
相对于继承的编译期确定实现,组合的运行态指定实现,更加灵活。
-
组合是非侵入式的,继承是侵入式的。
理解golang的结构体嵌入
golang中
是没有class的
,但是有一个结构体struct
,有点类似,他没有像java,c++中继承的概念,但是他有一个类似功能的结构嵌入
简单的结构体声明和使用。
type User struct
name string
age int
address string
user:= Username:"测试",age:10
user.address="苏州市"
f.Println(user)
匿名结构体:
person:= struct //匿名结构
name string
age int
name:"匿名",age:1
f.Println("person:",person)
函数中结构体作为参数,如果不是用结构指针,函数内参数属性的改变不影响原来对象的属性的改变:
//值拷贝,不改变原来的User对象值
func us(user User)
user.name="值拷贝";
user.age=12
user.address="盐城市"
f.Println("user in us:",user)
//声明调用
user:=User
us(user)
//指针,改变原来的User对象值
func use(user *User)
user.name="指针"
user.age=15
user.address="苏州市"
f.Println("user in use:",*user)
//声明调用
user:=User
use(&user)
go语言中虽然没有继承,但是可以结构内嵌,达到类似继承的效果:
type Info struct
sex int
name string
age int
address string
type User struct
like string
Info
type Admin struct
unlike string
Info
user:= User
user.sex=0
user.address="苏州市"
user.like="游戏"
f.Println(user)
admin:= AdminInfo:Infosex:1//还可以这样声明一些属性值,因为Info是结构体,匿名,所以需要这样声明
admin.address="苏州市"
admin.unlike="游戏"
f.Println(admin)
如果嵌入结构的字段和外部结构的字段相同,那么,想要修改嵌入结构的字段值需要加上外部结构中声明的嵌入结构名称:
type Info struct
sex int
name string
age int
address string
type User struct
like string
sex int
Info
user:=User
user.sex=1//这里修改的外部结构User里面的sex字段值
user.Info.sex=2//这里修改的是嵌入结构Info的sex字段值
为什么嵌入语法是组合而非继承
网上很少有例子解释清楚golang所提倡的组合的优势,一般就是将一个struct嵌入
到另外一个struct里
,这种仅仅是类似于继承提供的代码复用。
package main
import (
"fmt"
)
type A struct
func (*A) Hello(name string)
fmt.Println("hello " + name + ", i am a")
type B struct
*A
func main()
name := "Lee"
a := A
a.Hello(name) //hello Lee, i am a
b := B&A
b.Hello(name) //hello Lee, i am a
通过这个例子,我们先来理解为什么go语言的嵌入语法是组合而不是继承。
来看这个语句,b := B&A
, b在赋值的时候,值语义里需要创建一个A类型的指针,赋值给B中的匿名变量。这就明显是has-a
的关系了。
活用组合和接口让代码更加优雅
如上文所述,我认为组合需要与接口结合使用才能体现其精髓。
让我们来看一段改造后的代码:
package main
import (
"fmt"
)
type IHello interface
Hello(name string)
type A struct
func (*A) Hello(name string)
fmt.Println("hello " + name + ", i am a")
type D struct
func (*D) Hello(name string)
fmt.Println("hello " + name + ", i am d")
type B struct
IHello
func (*B) Hello(name string)
fmt.Println("hello " + name + ", i am b")
type C struct
IHello
func main()
name := "Lee"
a := A
a.Hello(name) //hello Lee, i am a
b := B&A
b.Hello(name) //hello Lee, i am b
b.IHello.Hello(name) //hello Lee, i am a
c := C&A
c.Hello(name) //hello Lee, i am a
c.IHello = &D
c.Hello(name) //hello Lee, i am d
发现不同了吗?我们来总结一下:
-
A的指针继承了接口
IHello,
B,C中嵌入了接口IHello,
-
B C两者在赋值时,同时可以根据运行时上下文指定其他具体实现,比如D,更加灵活。
-
B中写了一个与IHello同名的方法Hello,此时直接访问b.Hello是访问的b的方法,想访问A的方法需要b.IHello.Hello(name)。我们可以把组合方式直接访问被嵌入类型方法看做一个语法糖。
-
所以让我们面向接口编程,提倡共用组合与接口的优雅代码。
与其他语言对比
再补充一点便于理解go在组合上的努力。golang从语言级别对组合做了充分的语法糖,使得组合更加高效。我们来看一段java的组合实现:
public interface IHello
public void hello();
public class A implements IHello
@Override
public void hello()
System.out.println("Hello, I am A.");
public class B implements IHello
@Override
public void hello()
System.out.println("Hello, I am B.");
public class C
IHello h;
public void hello()
h.hello();
public static void main(String args[])
C c = new C();
c.h = new A();
c.hello();
c.h = new B();
c.hello();
并发
原生并发,轻量高效
Go 语言自身实现层面支持面向多核硬件的并发执行和调度提到并发执行与调度,我们首先想到的就是操作系统对进程、线程的调度。操作系统调度器会将系统中的多个线程按照一定算法调度到物理 CPU 上去运行。传统的编程语言比如 C、C++ 等的并发实现实际上就是基于操作系统调度的,即程序负责创建线程(一般通过 pthread 等函数库调用实现),操作系统负责调度。这种传统支持并发的方式有诸多不足:
1、复杂
-
创建容易,退出难:使用 C 语言的开发人员都知道,创建一个
thread
(比如利用 pthread
)虽然参数也不少,但好歹可以接受。但一旦涉及到thread
的退出,就要考虑thread
是detached
,还是需要parent thread
去join
?是否需要在thread
中设置cancel point
,以保证join
时能顺利退出? -
并发单元间通信困难,易错:多个
thread
之间的通信虽然有多种机制可选,但用起来是相当复杂;并且一旦涉及到shared memory
,就会用到各种lock
,死锁便成为家常便饭; -
thread stack size
的设定:是使用默认的,还是设置的大一些,或者小一些呢?
2、难于扩展
-
一个
thread
的代价已经比进程小了很多了,但我们依然不能大量创建thread
,因为除了每个thread
占用的资源不小之外,操作系统调度切换thread
的代价也不小; -
对于很多网络服务程序,由于不能大量创建
thread
,就要在少量thread
里做网络多路复用,即:使用epoll/kqueue/IoCompletionPort
这套机制,即便有libevent、libev
这样的第三方库帮忙,写起这样的程序也是很不易的,存在大量callback
,给程序员带来不小的心智负担。
为此,Go 采用了用户层轻量级 thread
或者说是类coroutine
的概念来解决这些问题,Go 将之称为"goroutine"
。goroutine
占用的资源非常小
,每个 goroutine stack
的size
默认设置是 2k
,goroutine
调度的切换也不用陷入(trap)
操作系统内核层完成,代价很低。因此,一个 Go 程序中可以创建成千上万个并发的goroutine
。所有的 Go 代码都在 goroutine
中执行,哪怕是 go 的runtime
也不例外。将这些goroutines
按照一定算法放到“CPU”
上执行的程序就称为goroutine
调度器或goroutine scheduler
。
不过,一个 Go 程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有thread
,它甚至不知道有什么叫 Goroutine
的东西的存在。goroutine
的调度全要靠 Go 自己完成,实现 Go 程序内 goroutine
之间“公平”的竞争“CPU”资源,这个任务就落到了 Go runtime
头上。
Go 语言实现了G-P-M 调度模型和 work stealing 算法,这个模型一直沿用至今,如下图所示:
-
G
:表示goroutine
,存储了goroutine
的执行stack 信息
、goroutine
状态以及goroutine
的任务函数等;另外 G 对象是可以重用的。 -
P:表示逻辑
processor
,P 的数量决定了系统内最大可并行的 G 的数量(前提:系统的物理 cpu 核数>=P 的数量);P 的最大作用还是其拥有的各种 G 对象队列、链表、一些 cache 和状态。每个 G 要想真正运行起来,首先需要被分配一个 P(进入到 P 的 local runq 中)。对于 - G 来说,P 就是运行它的“CPU”,可以说:G 的眼里只有 P。 -
M:M 代表着真正的执行计算资源,一般对应的是操作系统的线程。从
Goroutine
调度器的视角来看,真正的“CPU”是 M,只有将 P 和 M 绑定才能让 P 的runq
中 G 得以真实运行起来。这样的 P 与 M 的关系,就好比Linux 操作系统
调度层面用户线程(user thread)
与核心线程(kernel thread)
的对应关系那样(N x M)。M 在绑定有效的 P 后,进入schedule 循环
;而schedule 循环
的机制大致是从各种队列、p 的本地队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用goexit
做清理工作并回到 m,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
Go 语言为开发者提供的支持并发的语法元素和机制
我们先来看看那些设计并诞生于单核年代的编程语言,诸如:C、C++、Java 在语法元素和机制层面是如何支持并发的。
-
执行单元:线程;
-
创建和销毁的方式:调用库函数或调用对象方法;
-
并发线程间的通信:多基于操作系统提供的 IPC 机制,比如:共享内存、Socket、Pipe 等,当然也会使用有并发保护的全局变量。
和上述传统语言相比,Go 为开发人员提供了语言层面内置的并发语法元素和机制:
-
执行单元:
goroutine
; -
创建和销毁方式:go+函数调用;函数退出即
goroutine
退出; -
并发
goroutine
的通信:通过语言内置的channel 传递消息
或实现同步
,并通过select
实现多路channel
的并发控制。
对比来看,Go 对并发的原生支持将大大降低开发人员在开发并发程序时的心智负担。
并发原则对 Go 开发者在程序结构设计层面的影响
由于 goroutine 的开销很小(相对线程),Go 官方是鼓励大家使用 goroutine 来充分利用多核资源的。但并不是有了 goroutine 就一定能充分的利用多核资源,或者说即便使用 Go 也不一定能设计编写出一个好的并发程序。
为此 Rob Pike
曾有过一次关于“并发不是并行”1的主题分享,在那次分享中,这位 Go 语言之父图文并茂地讲解了并发(Concurrency)
和并行(Parallelism)
的区别。Rob Pike 认为:
-
并发是有关结构的,它是一种将一个程序分解成小片段并且每个小片段都可以独立执行的程序设计方法; 并发程序的小片段之间一般存在通信联系并且通过通信相互协作;
-
并行是有关执行的,它表示同时进行一些计算任务 。
划重点:并发是一种程序结构设计的方法,它使得并行成为可能。
不过这依然很抽象,我们这里也借用 Rob Pike 分享中的那个“搬运书问题”来重新诠释一下并发的含义。
搬运书问题要求设计一个方案,使得 gopher 能更快地将一堆废弃的语言手册搬到垃圾回收场烧掉。
最简单的方案莫过于下图:
这个方案显然不是并发设计方案,它没有对问题进行任何分解,所有事情都是由一个 gopher 从头到尾按顺序完成的。但即便这样一个并非并发的方案,我们也可以将其放到多核的硬件上并行的执行,只是需要多建立几个 gopher 例程(procedure)的实例罢了:
但和并发方案相比,这种方案是缺乏自动扩展为并行的能力的。Rob Pike 在分享中给出了两种并发方案,也就是该问题的两种分解方案,两种方案都是正确的,只是分解粒度的细致程度不同。
搬书问题并发方案。
并发方案 1 将原来单一的 gopher 例程执行拆分为 4 个执行不同任务的 gopher 例程,每个例程更简单:
-
将书搬运到车上(loadBooksToCart);
-
推车到垃圾焚化地点(moveCartToIncinerator);
-
将书从车上搬下送入焚化炉(unloadBookIntoIncinerator);
-
将空车送返(returnEmptyCart)。
理论上并发方案 1 的处理性能能达到初始方案的四倍,并且不同 gopher 例程可以在不同的处理器核上并行执行,而无需像最初方案那样需要建立新实例实现并行。
和并发方案 1 相比,并发方案 2 增加了“暂存区域”,分解的粒度更细,每个部分的 gopher
例程各司其责,这样的程序在单核处理器上也是正常运行的(在单核上可能处理能力不如非并发方案)。但随着处理器核数的增多,并发方案可以自然地提高处理性能,提升吞吐。而非并发方案在处理器核数提升后,也仅仅能使用其中的一个核,无法自然扩展,这一切都是程序的结构所决定的。这也告诉我们:并发程序的结构设计不要局限于在单核情况下处理能力的高低,而是以在多核情况下能够充分提升多核利用率、获得性能的自然提升为最终目的。
除此之外,并发与组合的哲学是一脉相承的,并发是一个更大的组合的概念,它在程序设计的层面对程序进行拆解组合,再映射到程序执行层面上:goroutines
各自执行特定的工作,通过 channel+select
将 goroutines
组合连接起来。并发的存在鼓励程序员在程序设计时进行独立计算的分解,而对并发的原生支持让 Go 语言更适应现代计算环境。
面向工程
原则: 面向工程,“自带电池”
要想理解这条设计哲学,我们依然需要回到三位 Go 语言之父在设计 Go 语言时的初衷:**面向解决真实世界中 Google 内部大规模软件开发存在的各种问题,为这些问题提供答案。**主要的问题包括:
-
程序构建慢
-
失控的依赖管理
-
开发人员使用编程语言的不同子集(比如 C++支持多范式,这样有些人用 OO,有些人用泛型)
-
代码可理解性差(代码可读性差,文档差等)
-
功能重复实现
-
升级更新消耗大
-
实现自动化工具难度高
-
版本问题
-
跨语言构建问题
很多编程语言设计者或其拥趸认为这些问题并不是一门编程语言应该去解决的,但 Go 语言的设计者并不这么看,他们以更高更广阔的视角去审视软件开发领域尤其是大规模软件开发过程中遇到的各种问题,并在 Go 语言最初设计阶段就将解决工程问题作为 Go 的设计原则之一去考虑 Go 语法、工具与标准库的设计,这也是 Go 与其他偏学院派、偏研究性编程语言在设计思路上的一个重大差异。
Go 语言取得阶段性成功后,这种思路也在开始影响着后续新编程语言的设计,并且一些现有的主流编程语言也在借鉴 Go 的一些设计,比如:越来越多的语言认可统一代码风格的优越之处,并开始提供官方统一的 fmt 工具(例如:rust 的 rustfmt);又比如:Go 创新提出的最小版本选择(minimal version selection,缩写为 mvs)也在被其他语言的包依赖工具所支持(比如:rust 的 cargo 支持 mvs)。
Go 设计者将所有工程问题浓缩为一个词"scale"
,总觉得将 scale
这个词翻译为任何中文词汇都无法传神地表达其含义,暂译为“规模”吧。这里的规模化有两层含义:
-
用 Go 构建的软件系统的并发规模,比如:这类系统并发关注点的数量、处理数据的量级、同时与之交互的服务的数量等;
-
开发过程的规模,包括代码库大小、参与开发、相互协作的工程师的数量等。
从 Go1 开始,Go 的目标就是为了让开发者能够更容易、更高效地构建规模化(scale)的软件
。Go 设计者期望 Go 可以游刃有余地应对产品规模和过程规模变大后带来的各种复杂性问题。Go
语言的演进方向也是继续优化和消除 Go 语言自身面对规模化问题时应对不好的地方,比如:Go 1.9
引入的type alias
以应对大型代码仓库代码重构、Go 1.11
引入的 go module
机制解决不完善的包依赖问题等。
这种设计哲学的落地让 Go 语言具有广泛的规模适应性:既可以被仅有 5 人的初创团队用于开发终端工具,也能够满足像 Google 这样的超巨型公司大规模团队开发大规模网络服务程序的需要。
那么 Go 是如何解决工程领域规模化所带来的问题的呢?我们从语言、标准库和工具三个方面来看一下。
语言
语法是编程语言的用户接口,它直接影响开发人员对于这门语言的使用体验。Go 语言首先是一门简单的语言,简单意味着可读性好,容易理解,容易上手工作,容易修复错误,节省开发者时间,提升开发者间的沟通效率。但作为面向工程的编程语言,光有简单的设计哲学还不够,每个语言设计细节还都要经过“工程规模化”的考验和打磨,需要在细节上做好充分的思考和讨论。
比如 Rob Pike 就曾谈到过 Go 当初为何没有使用 Python 那样的代码缩进来表示程序结构,而是选择了与 C 语言相同的大括号,就是因为他们经过调查发现 Python 的缩进结构在构建小规模程序时的确很方便,但是当代码库变得更大的时候,缩进式的结构非常容易出错。从工程的安全性和可靠性角度考虑,Go 团队最终选择了大括号代码块结构。
类似的面向工程的语音设计细节考量还包括:
-
重新设计编译单元和目标文件格式,实现 Go 源码快速构建,让大工程的构建时间缩短到类似 Python 的交互式编译的编译速度;
-
如果源文件导入它不使用的包,则程序将无法编译。这可以充分保证任何 Go 程序的依赖树是精确的。这也可以保证在构建程序时不会编译额外的代码,从而最大限度地缩短编译时间;
-
去除包的循环依赖,循环依赖会在大规模的代码中引发问题,因为它们要求编译器同时处理更大的源文件集,这会减慢增量构建;
-
在处理依赖关系时,有时会通过允许一部分重复代码来避免引入较多依赖关系。比如:net 包具有其自己的整数到十进制转换实现,以避免依赖于较大且依赖性较大的格式化 io 包;
-
包路径是唯一的,而包名不必唯一的。导入路径必须唯一标识要导入的包,而名称只是包的使用者如何引用其内容的约定。包名称不必是唯一的约定大大降低了开发人员给包起唯一名字的心智负担;
-
故意不支持默认函数参数。因为在规模工程中,很多开发者利用默认函数参数机制,向函数添加过多的参数以弥补函数 API 的设计缺陷,这会导致函数拥有太多的参数,降低清晰度和可读性;
-
首字母大小写定义标识符可见性,这是 Go 的一个创新。它让开发人员通过名称即可知晓其可见性,而无需回到标识符定义的位置查找并确定其可见性,这提升了开发人员阅读代码的效率;
-
在语义层面,相对于 C,Go 做了很多改动,提升了语言的健壮性,比如:去除指针算术、去除隐式的数字转型等;
-
内置垃圾收集,这对于大型工程项目来说,大大降低了程序员在内存管理方面的负担,程序员使用 GC 感受到的好处超过了付出的成本,并且这些成本主要由语言实现者来承担;
-
内置并发支持,为网络软件带来了简单性,简单又带来了健壮,这是大型工程软件开发所需要的;
-
增加 type alias,支持大规模代码库的重构。
标准库
Go
被称为“内置电池(battery-included)”
的编程语言。“内置电池”原指购买了电子设备后,在包装盒中包含了电池,电子设备可以开箱即用,无需再次出去购买电池。如果说一门编程语言是“自带电池”,则说明这门语言标准库功能丰富,多数功能无需依赖外部的第三方包或库,Go 语言恰是这类编程语言。
由于诞生年代较晚,且目标较为明确,Go 在标准库中提供了各类高质量且性能优良的功能包,其中的net/http
、crypto/xx
、encoding/xx
等包充分迎合了云原生时代的关于 API/RPC Web 服务
的构建需求,Go 开发者可以直接基于标准库提供的这些包实现一个满足生产要求的 API 服务,从而减少对外部第三方包或库的依赖,降低工程代码依赖管理的复杂性,也降低了开发人员学习第三方库的心智负担。
仅使用标准库来构建系统,这对于开发人员还是蛮有吸引力的。在很多关于选用何种 Go Web 开发框架的调查中,选择标准库的依然占大多数,这也是 Go 社区显著区别于其他编程语言社区的一点。Go
团队还在 golang.org/x
路径下面提供了暂未放入标准库的扩展库/补充库供广大 Gopher
使用,包括:text
、net
、crypto
等,这些库的质量也是非常高的,标准库中部分包也将 golang.org/x
下的text
、net
和 crypto
包作为依赖包 vendor
到 Go
标准库中。
Go 语言目前在 GUI
、mobile
开发领域占有的份额较低,这很可能与 Go 标准库
没有内置这类包不无关系。在 2016 年的 Gopher 用户调查中,Gopher
们最希望标准库增加的功能中,GUI
和 mobile
包就排名靠前。
这也或多或少从反向证明了“内置电池”对于工程领域问题解决的重要性。
工具
开发人员在工程过程中需要使用工具。而 Go 语言提供了这个星球上最全面、最贴心的编程语言官方工具链,涵盖了编译、编辑、依赖获取、调试、测试、文档、性能剖析等方方面面。
-
构建和运行:
go build/go run
。 -
依赖包查看与获取:
go list/go get/go mod xx
。 -
编辑辅助格式化:
go fmt/gofmt
。 -
文档查看:
go doc/godoc
。 -
单元测试/基准测试/测试覆盖率:
go test
。 -
代码静态分析:
go vet
。 -
性能剖析与跟踪结果查看:
go tool pprof/go tool trace
。 -
升级到新 Go 版本 API 的辅助工具:
go tool fix
。 -
报告 Go 语言 bug:
go bug
。
值得重点提及的是 gofmt 统一了 Go 语言的编码风格,在其他语言开发者还在为代码风格争论不休的时候,Go 开发者可以更加专注于领域业务中。同时,相同的代码风格让以往困扰开发者的代码阅读、理解和评审工作变得容易了很多,至少 Go 开发者再也不会有那种因代码风格的不同而产生的陌生感。
在提供丰富的工具链的同时,Go 语言的语法、package 系统以及命名惯例的设计也让针对 Go 的工具更容易编写,并且 Go 在标准库中提供了官方的词法分析器、语法解析器和类型检查器相关 package,开发者可以基于这些包快速构建 Go 工具。
我们可以说 Go 构建了一个开放的工具链生态系统,它鼓励社区和开发人员为 Go 添加更多、更实用的工具,而更多、更好的工具反过来又帮助 Go 更好地解决工程上的“规模化”问题,这是一个良性的生态循环。
💫点击直接资料领取💫
这里有各种学习资料还有有有趣好玩的编程项目,更有难寻的各种资源。
以上是关于Go 设计哲学:少即是多,哪里来的?的主要内容,如果未能解决你的问题,请参考以下文章
《Go语言精进之路,从新手到高手的编程思想方法和技巧1》读书笔记和分享