另一种语言
另一种语言
作者:Marek Cermak,后端 Go Lead @ STRV
听说过这种新的包罗万象的编程语言吗?一种易于学习、快速编译、高性能和多平台的语言,并且是我们需要的最后一种语言?好吧,那么请告诉我它是哪一个,因为我还没有听说过。我只是来谈论 Go 的。
尽管 Go 不是一种可以取代所有其他语言的万能编程语言(而且我很确定这种语言永远不会存在),但 Go 至少可以解决你的永恒困境:“我接下来应该学习哪种语言?”
也许,一旦你学会了它,你就不需要再问自己这个问题了。
一点围棋历史
从 2007 年的第一个设计到 2009 年谷歌发布的第一个公告,再到 2012 年 1.0 版的第一个版本,Go 的实现大约花了五年时间。
Go 是由 Robert Griesemer、Rob Pike 和 Ken Thompson 等人精心设计的,他们是在编程语言设计方面拥有 50 多年经验的工程师。我不希望这听起来厚颜无耻,但将其与 JavaScript 相比,它是在 10 天内开发的。
去还是成长?
你可能想知道为什么我一直叫这种语言 去 代替 戈兰, 这是您可能熟悉的绰号。让我们在一开始就说明这一点:语言被称为 去 .另一个绰号可能归功于 Go 官方网站 golang.org 的一个不幸的命名决定——因为 go.org 已经被采用,当时还没有 .dev 域。
我喜欢这样想名字:名字 去 非常有意义,是一个美丽的文字游戏。不仅这个词 去 意思是 语 在日语中,但您也可以拆分单词 谷歌 (设计 Go)成 去 和 眄 ——当你发现用 Go 编写代码是多么容易时的样子。很棒,不是吗?
尽管如此, 戈朗 标签非常方便;现在,这种误称的广泛使用允许更轻松的 Google 搜索、标记和引用,而不会与动词“go”发生冲突。
因挫折而生
有趣的是,Go 是出于对现有语言和环境的挫败感而创建的。编程变得太难了,必须选择高效编译、高效执行或易于编程;所有这三种语言都没有以相同的主流语言提供。
程序员可以通过转向动态类型语言(例如 Python 和 JavaScript(而不是 C++)或在较小程度上使用 Java(Google,2022a))来选择轻松而不是安全性和效率。
但上述问题并不能通过库或工具很好地解决。是时候使用一种新语言了——Go 并不是唯一一种作为回应的语言。其中包括 Rust、Swift 以及后来的 Kotlin。编程语言开发成为主流领域。
谷歌直接遇到了编译速度慢、效率低的问题。其中一个问题是编译期间的 I/O 过多,编译器可能被指示处理相同的头文件数百甚至数千次。
例如,在 2007 年,当谷歌对一个主要的谷歌二进制文件进行编译时,它由数千个文件组成,如果简单地连接在一起,总共有 4.2 MB。到扩展包含(当前源文件的依赖项)时,超过 8 GB 被传递到编译器的输入。这是源代码大小的 2,000 倍!
这导致需要一个涉及许多机器、大量缓存和非常复杂的分布式构建系统来构建单个二进制文件。即便如此,二进制文件的编译也需要 45 分钟(Pike,2012)。可以想象,在如此长时间的构建中,有相当多的时间来思考——并且出现了对一种新的编程语言的需求。
定义设计
对于具有大量依赖项和大型团队的大型程序,新语言必须快速编译和可扩展。它必须让人感觉熟悉且易于学习,以便工程师能够快速适应。而且它必须是现代的——特别是适用于并发和 Web 开发等现代方法。
就这样,基于这些要求,在 45 分钟累积的挫败感(双关语)和咖啡过量的边缘,围棋的想法诞生了。
作为一个地鼠
如前所述,我想说服你 Go 是你接下来应该学习的语言。让我们对此进行扩展。我实际上认为 Go 是你的语言 想 下一步要学习,您可能还不知道。
在我们讨论更棘手的话题之前,让我们更深入地了解一下语言的本质,让您了解作为 Gopher 的全部意义。
协作与社区
从一开始,Go 可以说是一种协作的努力。谷歌领导了开发工作,但 Go 本质上是开源的,这很明显——只要打开 GitHub存储库 .截至 2022 年 7 月,它拥有超过 100,000 颗星、超过 15,000 个分叉和 1,000 多个提案(其中超过 250 个已被接受)。
Go 仍在积极开发中。随着大约每六个月发布一次,该语言不断发展、变化并适应现代需求——Go 社区在这方面发挥着巨大的作用。作为一名 Go 开发人员(又名 地鼠 ) 也意味着成为该社区的一部分。
Go 的设计目的是让工程师熟悉(大致类似于 C)。当时,在 Google 工作的程序员处于职业生涯的早期,并且最熟悉 C 家族的过程语言(Pike,2012 年)。出于显而易见的原因,我们的目标是让工程师尽快提高工作效率,这意味着设计不能过于激进。
任何曾经用 C/C++、Java 或 TypeScript 甚至 Pascal 等语言编写过代码的人都会觉得 Go 的语法很熟悉。当然,也有区别。 Go 不是一种面向对象的语言,也不严格遵循 OOP 范式。话虽如此,该语言相对容易学习。我说 相对地 , 尽管…
语法和语义相当容易掌握(Go 只有 25 个关键字)。然而,能用 Go 写代码的人和 Gopher 的区别是 如何 是编写和结构化的代码。尽管 Go 强制执行简洁的代码风格——这是 Go 的一个很酷的特性;不需要代码风格检查和美化——代码风格只是冰山一角。
“症状”
出于某种原因,成为 Gopher 似乎伴随着一定程度的心理健康问题。症状包括对代码设计的痴迷;突然对界面产生情感依恋;由于不尊重共同的模式和准则,偶尔会爆发愤怒;以及对其他编程语言的
Gophers 也倾向于遵循常见的模式和最佳实践,如存储库结构、命名语义和错误处理。了解这些模式并遵循它们可以让 Go 工程师更有效率——这就是用 Go 编写代码的工程师和 Gopher 之间的区别所在。
成为 Gopher 意味着成为清晰、好奇和渴望改进的体现。
特征
在 2000 年代初期,计算机的速度变得非常快——但编程本身并没有那么先进。多处理器正在成为主流,但 C/C++ 或 Java 等语言对多处理的支持很少。
Go 通过尝试将解释型、动态类型语言的编程易用性与一种静态类型的编译语言。它还旨在实现现代化,支持网络和多核计算。
最后,使用 Go 的目的是为了快速。在单台计算机上构建大型可执行文件最多需要几秒钟。为了实现这些目标,需要解决几个语言问题:一个富有表现力但轻量级的类型系统;并发和垃圾收集;刚性依赖规范;等等。
让我们讨论一下哪些 Go 特性可以解决其中的一些问题。
汇编
我有没有提到创造 Go 的想法是喝咖啡过量的结果?虽然这可能是真的,也可能不是,但肯定 是 至少可以说,编译要求很高。
当时,谷歌的代码库包含超过 20亿 代码行。最重要的是,它主要是单体架构(Metz,2015)。
Go 编译 显著地 比 C/C++ 更快。我们已经提到了标头导入的低效率。另一方面,Go 没有依赖循环,也没有未使用的依赖。编译器明确禁止未使用的依赖项; Go 工具甚至会在开发过程中自动删除它们(假设您使用现代 IDE 之一,或者您是具有大量插件的适当 VIM 极简主义者)。
速度还有其他原因:没有复杂性(只有 25 个关键字),没有符号表(与 C/C++ 不同)和优化功能,例如在文件开头列出的导入和函数调用的内联,仅举几例。
Go 使用一个名为 GC .编译器最初是用 C 语言编写的,以避免引导困难——即,需要 Go 编译器来设置 Go 环境。然而,随着 Go 1.5 的发布,编译器被转换为 Go,有效地使 Go 自托管 (谷歌,2022a)。
并发
现代计算环境使应用程序能够通过异步处理指令来提高其效率。特别是对于同时为多个客户端提供服务的 Web 服务器(想想数千甚至数百万个客户端),能够异步处理客户端而不是一个一个地处理客户端,这不仅对应用程序性能而且对稳定性都至关重要。
没有多少语言在语言级别为并发或并行提供一流的支持。 Go 体现了通信顺序进程 (CSP) 的一种变体,它是一种描述并发系统中交互模式的正式语言。 Go 使用 渠道 和 协程 .该方法涉及独立执行其他常规程序代码的功能的组合(Pike,2012)。
与 C++ 等语言相比,Go 使用了一种非常不同的并发方法。作为一个 Gopher,被归于 Rob Pike 的口头禅是:“不要通过共享内存进行通信,而是通过通信来共享内存。”
为了扩展这一点,在 Go 中,状态是共享的 渠道 .通道允许 goroutine 通过使用 上下文 (超出本文的范围)并且通常用于同步 协程 .这是类固醇的并发性。
Goroutines 是异步执行的轻量级线程。它们是可以使用单个关键字生成的原生结构,并且非常轻量级,以至于 Go 可以一次运行数万个。
另一方面,在 C++ 中,状态是通过使用共享内存来共享的。 C++ 中的线程也相当耗费资源,尤其是线程的创建和上下文切换会带来一些开销。
一致性
让我问你几个问题。有没有忘记在代码中加分号?忘记一个未使用的变量怎么样?您是否重构了代码并留下了未使用的导入?在打开新存储库时遇到不同的代码风格?
如果您对这些问题中的任何一个回答“是”,那么您可能是正确的人,可以理解 Go 编程可以让您摆脱所有这些担忧。
Go 附带了一组工具,其中包括一个格式化程序,该格式化程序强制执行代码样式并删除未使用的依赖项。编译器禁止未使用的变量,因此代码甚至无法成功编译。
尽管强制执行的代码风格对某些人来说可能听起来有争议,但它可以轻松浏览任何代码,强制执行最佳实践并减少歧义。
依赖项
我们已经讨论过 C/C++ 中包含语句以及依赖关系的巨大开销。值得一提的是 Go 如何改进这个过程。
为了使 Go 扩展依赖,该语言将未使用的依赖定义为编译时错误(而不是警告)。如果源文件导入了一个它不使用的包,程序将无法编译。这保证了任何 Go 程序的依赖树都是精确的并且没有多余的边缘。而这反过来又保证了在构建程序时不会编译额外的代码——这最大限度地减少了编译时间(Pike,2012)。
Go 编译器如此高效和快速的一个最大原因是导入的设计和实现。 Pike (2012) 在下面的例子中证明了这一点。
考虑三个包——A、B 和 C。包 A 导入 B,B 导入 C。给定 Go 的设计,A 是 不是 允许 导入包C;它将创建一个依赖循环,这是编译器禁止的。但是,A 仍然可以通过包 B 传递使用包 C。要构建程序,首先编译 C,然后编译 B,然后编译 C。
在单个包的编译过程中, 目标文件 生成(在链接和重定位过程中使用,由依赖项、调试信息、索引符号列表等组成)。这些目标文件包含编译器执行导入语句所需的所有类型信息。这意味着当 B 被编译时,生成的目标文件包含所有影响 B 的公共接口的 B 依赖项的类型信息(Pike,2012)。编译 A 时,编译器会读取 B 的目标文件,而不是其源代码!
这种编译器设计意味着当编译器执行一个 import 子句时,它会打开 正好一个文件 (与 C/C++ 编译器打开的多达数百个文件相比)。此外,鉴于导入列在每个源文件的开头,编译器可以真正优化读取依赖图所需的 I/O。
对于那些坚持到这个沉重话题结束的人来说,作为一个甜蜜的樱桃,让我分享一些数据。 2012 年,在 Go 的最初阶段(我可以向你保证,从那时起,Go 已经有了很大的发展),Google 测量了一个用 Go 编写的大型 Google 程序的编译,并将其与迁移到之前进行的 C++ 分析进行了比较。去。编译的 Go 代码 快五十倍 比它的 C++ 等价物。
这就是在构建过程中喝咖啡过量和喝一杯令人愉快的咖啡(也许是一块蛋糕)之间的区别。
内存管理
到目前为止,我们已经介绍了二进制文件的准备(编译和构建)。然而,运行时是奇迹发生的地方,尤其是在内存管理和垃圾收集方面。
在我看来,Go 的内存管理是该语言最令人印象深刻的特性之一——尽管我是个极客,但要小心。对于那些在那里的极客,本节适合您。
Go 运行时必须将对象存储在某个地方,当然, 某处 是记忆。更准确地说,有两个内存位置: 堆 和 堆 .内存最好分配在堆栈上(LIFO 数据结构)。出于显而易见的原因,首选这种内存分配方式 - 它简单有效,对象以 LIFO 方式分配,一旦超出范围,内存就会立即释放。
您可能会问这个“范围”是什么以及程序如何知道对象何时超出范围。 Go 编译器通过执行来完成繁重的工作 逃逸分析 .逻辑非常简单:如果编译器可以确定一个对象的词法范围——即它的生命周期——那么该对象将被分配到堆栈上。
Go 每个 goroutine 都有一个堆栈,它会尽可能将对象分配给堆栈。但是,它只能用于在给定范围内(即,在函数内)引用的对象。相比之下, 堆 用于为引用的对象分配内存 外部 范围的或无法确定范围的。这些可能是静态定义的常量、结构,尤其是指针。
通常,如果 Go 中有一个指向对象的指针,则该对象存储在堆中,必须由 垃圾收集器 . Go 标准工具链提供了一个运行时库,随每个应用程序一起提供,这个运行时库包含一个垃圾收集器。
垃圾收集
垃圾收集(缩写为 GC ) 指 追踪 垃圾收集,它标识 居住 (换句话说,使用中的)对象通过传递地跟随指针。准确地说,对象是一个动态分配的内存块,其中包含 Go 值。指针是一个内存地址,它引用对象内的任何值(Google,2022b)。
这是它开始变得越来越怪异的地方,但是请耐心等待我。这很有趣,我保证!
我提到的对象,连同指向其他对象的指针,形成了一个 对象图 .为了识别活动内存,GC 从程序的根开始遍历对象图——这些指针标识了程序肯定在使用的对象。根的两个示例是局部变量和全局变量。遍历对象图的过程称为 扫描 .
Go 使用非分代并发三色标记和清除垃圾收集器(试着说快五倍!)。基于世代假设,短期对象最常被回收,因此 Java 或 Python 等语言使用的世代垃圾收集器首先收集最近分配的对象。由于 Go 假设短期对象与词法范围相关联,因此很可能在堆栈上分配,因此 Go 使用非分代 GC。
GC 有两个组件: 突变体 和 集电极 . Mutator 执行应用程序代码,在堆上分配新对象并更新现有对象(导致某些对象不再可访问)。收集器检测这些不再可访问的超出范围的对象并释放分配的内存。两个都跑 同时 .
GC 使用标记扫描技术,这意味着为了跟踪其进度,GC 还将它遇到的值标记为活动的。跟踪完成后,GC 将遍历堆中的所有内存,并使所有未标记的内存可用于分配。这个过程称为清扫。 (谷歌,2022b)这个过程非常困难,我不会在这里继续深入,但我鼓励你自己研究——这当然是值得的!
这种解释的重点是强调有一些值得理解的成本。 GC 本质上是非常昂贵的,它确实会影响应用程序运行时——例如,运行时会在 GC 执行时暂停。仔细想想,这很直观,但是垃圾回收周期发生的次数越多,程序暂停的次数就越多,这会影响性能。
附带说明一下,GC 本身使用的 CPU 和物理内存(活堆)相比之下相当小,因此我们可以完全不考虑它。
表现
通常与 Go 相关的首要功能之一是性能。虽然 Go 在语法方面确实接近高级语言,但在性能方面也接近低级语言。
在基准测试方面,Go 经常与 C++ 和 Rust 等语言进行比较。听起来好得令人难以置信,不是吗? Go 表现出色的背后有多种原因。强大的静态类型;高效的内存管理;编译成机器码——即没有虚拟机——和编译器优化(见下一节)。
与其用令人信服的语言,不如用数字来代替。下图显示了截至 2022 年 8 月 9 日生成的基准测试的数据。这些基准测试的主要目标是比较各种编程语言之间的性能差异。
需要注意的是,各种实现可能会使用不同的优化,但基准测试往往在受控环境中得到促进,从而使结果尽可能可靠。您可以找到更多详细信息 这里 .
图 1 和图 2 显示了两个选定基准的结果:频谱规范和 HTTP 服务器。选择这些基准是为了代表 Go 在各种情况下的性能的实际差异。
正如我们在频谱规范基准中看到的那样,Go 在这种设置中的表现并不令人兴奋。事实上,它比最快的 C++ 基准测试慢了大约 2.5 倍。显然,对于这个基准测试(基于矩阵乘法),Go 并不是完成这项工作的最佳工具。尽管它在资源方面非常有效——点的大小代表内存使用——即使与 Go 的直接竞争对手 Rust 相比,计算时间仍然相当长。
另一方面,Go 和 Rust 在 HTTP 服务基准测试中占主导地位,它们的性能大大优于其他编程语言。有趣的是,Go 的性能比 Rust 的可靠得多。就数字而言,Go 在基准测试中的标准偏差为 4.1 毫秒,而 Rust 表现出大约 10 倍的偏差 - 42 毫秒。鉴于 Go 和 Rust 之间 14 毫秒的性能差异,从长远来看,Go 可能会胜过 Rust。
Fig. 1: Benchmark: Spectral Norm, August 09 2022 (Data: 基准 )
Fig. 2: Benchmark: HTTP Server, August 09 2022 (Data: 基准 )
简而言之(值得破解)
我坚信为工作选择正确工具的概念。 Go 并不适合所有事情。它不是编程语言的全部和全部。话虽如此,它旨在以最小的开发开销构建高性能、快速编译、轻量级的服务。
它的存在是为了让事情变得更好、更快、更健壮,并且总的来说,让我们的生活更轻松,让工程师更快乐。而且我认为它在所有这些方面都做得很好。
我看到了与其他语言的相似之处,例如 JavaScript、Swift 或 Kotlin。就像这些语言应该是 PHP、Objective-C 和 Java 的替代品(甚至替代品)一样,Go 在 Web 开发方面应该是 C++ 的更好替代品。
在语法方面接近高级语言,但在性能方面与低级语言竞争,Go 提供了一组特定的功能,使工程师能够编写具有本机支持并发的轻量级服务并强制执行一致和明确的代码。
用语言来描述一种编程语言有点困难。就像一首歌不唱就很难形容,食物不尝就很难形容。但是,我鼓励您体验一下 Go。
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明