深入浅出Go语言编程从原理解析到实战进阶

978-7-115-61978-5
作者: 阮正平
译者:
编辑: 杨绣国

图书目录:

详情

本书是一部从核心概念、设计原理、应用场景、操作方法和实战技巧等维度全面、深入探讨 Go 语言的著 作。书中首先介绍 Go 语言的基本概念,并通过“hello world”程序引导读者熟悉 Go 的工具链。接下来逐步深 入,介绍面向包的设计、测试框架、错误与异常处理等内容。第 8 章开始探讨指针和内存逃逸分析,这对于理 解 Go 语言的内存模型至关重要。随后的章节涉及数据结构、面向对象和接口编程等核心知识。从第 15 章开始, 重点转向并发编程,从基本的并发模式到复杂的并发原理,再到内存管理和垃圾回收等高级主题。最后几 章关注实际开发中的问题,如使用标准库和第三方库、性能问题分析与追踪,以及重构“hello world”示 例代码。 本书适合想要掌握 Go 语言的基本使用方法,以及了解其底层工作原理和设计实现的初、中级读者阅读。

图书摘要

版权信息

书名:深入浅出Go语言编程:从原理解析到实战进阶

ISBN:978-7-115-61978-5

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。

版  权

著    阮正平 杜 军

责任编辑 杨绣国

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315

内 容 提 要

本书是一部从核心概念、设计原理、应用场景、操作方法和实战技巧等维度全面、深入探讨Go语言的著作。书中首先介绍Go语言的基本概念,并通过“hello world”程序引导读者熟悉Go的工具链。接下来逐步深入,介绍面向包的设计、测试框架、错误与异常处理等内容。第8章开始探讨指针和内存逃逸分析,这对于理解Go语言的内存模型至关重要。随后的章节涉及数据结构、面向对象和接口编程等核心知识。从第 15 章开始,重点转向并发编程,从基本的并发模式到复杂的并发原理,再到内存管理和垃圾回收等高级主题。最后几章关注实际开发中的问题,如使用标准库和第三方库、性能问题分析与追踪,以及重构“hello world”示例代码。

本书适合想要掌握Go语言的基本使用方法,以及了解其底层工作原理和设计实现的初、中级读者阅读。

前  言

为什么要写这本书

在我们所处的“技术宇宙”中,Go语言犹如一颗熠熠生辉的新星,它已经在云计算、微服务以及众多知名的开源项目中证明了自己的能量和潜力。当你推开Docker、Kubernetes、Prometheus等技术架构的大门时,你会发现它们的“心脏部位”都被Go语言这个强有力的“脉搏”所驱动。在云原生技术的浪潮中,Go语言已成为一股不可忽视的力量。在我看来,掌握Go语言,便是掌握了深入理解和解构这些技术的钥匙。

想要系统学习一门技术,自然少不了阅读相关技术图书。然而,我注意到,目前市面上关于Go语言的书籍大多关注的是其语法和使用,对设计原理和底层实现探讨得较少。这可能会让读者只知其然,就如同我们只看到了山的外表,却无法洞察其内部的岩层和矿脉一样。为了帮助更多的读者深入理解和掌握Go语言的精髓,我与另一位作者杜军商量了一下,决定合著一本介绍Go语言的使用方法并剖析其底层实现的书,让读者知其然,且知其所以然。

2016年,我在研究数据库容器化的过程中接触到Docker和Kubernetes,自此步入了Go语言的世界。在学习Go语言的过程中,我基于官方文档、技术博客以及许多Gopher的作品逐步深入了解相关开源项目的源码,这个过程就像在探索一片未知的森林,其中的每一个发现都让我惊叹不已。Go语言如同游戏《我的世界》中的工具,它强大且灵活,每一行代码都有可能改变世界。不过,想要改变世界,首先要打好坚实的基础。万丈高楼平地起,基础是重中之重。这本书介绍的核心概念、语法规则、应用场景以及编程技巧,都是用来帮助读者打好基础的。

撰写本书并不仅仅是为了给读者提供一本关于 Go 语言语法和功能的教程,我们希望走得更远一些。我们的目标是让这本书成为读者学习和理解Go语言的全面指南,无论是基础的语法和功能,还是深层的设计哲学和原则,以及可能被忽略的特性,都可以为读者呈现。我们渴望读者能在阅读本书的过程中感受到Go语言的精髓,并以此指导自己的编程实践。

这本书的创作于我而言,既是一次挑战,也是一次成长。尽管我曾写过许多技术文章,但我发现一本书的创作过程与之截然不同。这是一次基于全局性知识的挑战,也是一次将深度思考后获得的启发以文字的形式表达出来的尝试。一篇文章就像一块拼图,一本书则是将零散的拼图拼接在一起,形成一个完整的图案。在这个过程中,我一遍遍地调整写作思路,不断地补充和完善章节内容,希望基于自己的理解用浅显的文句把内容讲透,方便读者更深入地掌握知识点。

在写作的过程中,我也参考了其他书的写作风格,比如《Oracle Database 11g数据库管理艺术》这本书,它的构思独特,在讲解常规的知识之外,还揭示了许多非常规的Oracle特性,这让我对Oracle有了更深的理解。受此启发,我们也把那些可能容易被遗忘或者忽略的Go语言特性整理并展示出来,以便读者更全面地了解Go语言,也能更好地在应用中有的放矢。

在过去的三年中,我对人生有了更深的思考。我想,我们应该为这个世界带来一些价值,这本书就是我们为这个世界所做的一份贡献。我希望这本书能够帮助所有渴望深入理解Go语言的读者,帮助他们找到方向,让他们开启自己的技术旅程。

就像未知的宝藏等待探险家探寻一样,Go语言也有其独特的风景和挑战。每一行代码、每一个功能,都是我们探索Go语言的证明。我很开心能通过这本书与大家共同探索Go语言,发现更多的宝藏、惊喜和可能性,让我们一起用代码改变世界!

读者对象

本书的目标读者主要包括以下几类。

编程初学者:对于刚开始接触编程的读者,本书将是一把钥匙,它会引领你开启Go语言的大门。本书详尽地介绍了Go语言的基础知识,包括基本语法和核心编程概念等,可助你快速步入编程世界。

具有其他编程语言基础的开发者:如果你已经熟悉了C++、Java或Python等编程语言,现在希望扩充自己的技术栈,了解并学习Go语言,那么本书将是你理想的选择。它将帮助你迅速了解Go语言的特点和优势,并掌握Go语言的语法和功能,使你在新的语言领域游刃有余。

希望提高编程能力的资深开发者:对于已经在编程世界里摸爬滚打多年,积累了丰富经验的开发者来说,本书将为你揭示Go语言的设计原则和源码结构,帮助你深入理解Go语言的内部机制,进而提高你的编程技能。

如何阅读本书

如果你是初学者,需要了解Go语言的基本使用方式,可以重点阅读第1~7章及第11章,这几章重点讲解了Go语言的背景、基本概念、数据类型、结构化语法、测试框架、错误与异常处理机制、字符串以及函数在Go语言中的各种应用场景。此外,第8章中指针的使用、第9章中Go语言的三种核心数据结构,以及第 10 章中结构体的使用也是初学者必读的内容。你还能通过阅读第 21章了解Go语言丰富的标准库和常用的第三方库,尤其是io包和net包的使用。

对于已经拥有一定编程基础的读者,可以重点关注第12~17章,以及第22章和第23章。这些章详细介绍了Go语言在面向对象编程中的独特实现方式,如接口的使用和设计原则,以及与接口紧密相关的反射技术等,这些内容可以帮助你在Go语言环境中快速地理解和运用面向对象的编程思想。其中,第15~17章全面地阐述了Go语言中的并发技术,如协程、通道等,以及它们的使用模式,这将为你编写高效的并发代码提供实质性帮助。第22章则着重介绍了性能问题分析与追踪的工具,能让你了解评估和提升Go代码性能的方法。第23章则对“hello world”程序进行了重构,你可以更深入地理解和应用前面所学的技术和思想。

对于希望提高编程能力的资深开发者来说,可以关注第 8 章的活动帧、值语义和内存逃逸分析,第9章中面向数据的设计和可预测的内存访问模式,以及第10章中内存对齐与使用unsafe.Pointer类型等的内容。此外,第18~20章深入探讨了Go语言的核心设计,基于源码分析了并发编程与通道的实现,以及Go语言的内存管理和垃圾回收设计等,这些内容将帮助你更深入地理解Go语言的底层工作原理,并能够让你在实际编程中更好地优化和调试自己的代码。

本书对Go语言的使用和设计实现都有深入的讲解,按照上述指南阅读本书,相信大家可以逐步提升自己的Go语言编程能力。

勘误和支持

尽管对本书进行了多次调整和修改,但由于作者的水平有限,书中难免会出现一些错误或者不恰当的地方。如果你在阅读过程中发现了问题,包括但不限于错别字、语法错误、代码错误或者对Go语言的误解,我们都非常欢迎你向我们反馈并指正,我们的电子邮件地址是golang1_qa@163.com。

如果你对本书的具体内容或在Go语言的使用和理解上存有疑惑,我们也非常乐意提供帮助和解答。希望通过大家的共同努力,这本书能够更加完善,能更好地服务于想要学习和提升Go语言技能的读者。

此外,我们也提供了一些额外的支持资源,包括本书附带的源码、示例项目等,希望能帮助你更好地理解和掌握相关知识。

致谢

本书的撰写是一次既充实又激动人心的旅程,其中有很多人给予了支持和帮助,没有他们,这本书是无法完成的。在这里,我想对他们表达我最深的谢意。

首先,我要感谢本书的编辑杨绣国(Lisa),谢谢她的付出、耐心和包容,她的专业精神和对工作的执着,为本书的出版起到了至关重要的作用。她的严谨评审和宝贵建议,使本书的内容更为准确、深入和易于理解。

其次,我要感谢本书的另一位作者杜军,除了在专业上给予我支持,他还像精神导师一样时刻给我鞭策,让我始终保持清晰的头脑和高昂的斗志。

同时,我要感谢所有Gopher以及Go官方社区团队。他们精心编写的文档和技术博客,以及对技术的无私分享,为我们创作这本书提供了宝贵的参考。没有他们的智慧和经验,这本书的内容将不会如此丰富和精准。

我要特别感谢我的家人。他们的支持和奉献是我完成这项工作的最大动力。他们的爱给了我信心和力量,使我有勇气和决心在任何困难面前坚持下去。

最后,我要向所有的读者表示感谢。是你们的存在和对知识的渴求,使这本书充满了生命力和价值。我希望这本书能够帮助你们更好地理解和应用Go语言。

谨以此书,献给我最亲爱的家人,以及热爱Go语言的所有朋友。再次感谢为这本书付出过努力的所有人,你们的贡献将被我永远铭记。

阮正平

2024年5月写于成都

资源与支持

资源获取

本书提供如下资源:

本书源代码;

示例项目。

要获得以上资源,您可以扫描下方二维码,根据指引领取。

提交错误信息

作者和编辑虽已尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区(https://www.epubit.com/),按书名搜索,进入本书页面,依次单击“图书勘误”“发表勘误”,输入错误信息,单击“提交勘误”按钮即可(见下图)。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。

与我们联系

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给本书的责任编辑(yangxiuguo@ptpress.com.cn)。

如果您所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值内容的动力之源。

关于异步社区和异步图书

“异步社区”(www.epubit.com)是由人民邮电出版社创办的IT专业图书社区,于2015年8月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作者与读者在线交流互动,以及传统出版与数字出版的融合发展。

“异步图书”是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域40余年的发展与积淀。异步图书面向IT行业以及各行业使用IT技术的用户。

第1章 Go语言初探

Google公司有一个传统,允许员工利用20%的工作时间开发自己的实验项目。2007年9月,UTF-8的设计者之一Rob Pike(罗布·皮克)在Google的分布式编译平台上进行C++编译时,与同事Robert Griesemer(罗布·格里泽默)在漫长的等待中讨论了编程语言面临的主要问题。他们一致认为,相较于在已经臃肿的语言上不断增加新特性,简化编程语言将会带来更大的进步。随后,他们又说服UNIX的发明人Ken Thompson(肯·汤普森)一同来为此做点事情。几天后,他们三人启动了名为“Go语言”的开发项目,这标志着Go语言的诞生。

1.1 Go语言的发展里程碑

下面看一下Go语言发展过程中的里程碑。

(1)2007年9月,Go语言设计草稿在白板上诞生。

(2)2008年1月,Ken Thompson开发了Go语言编译器,并将Go代码编译成C代码。

(3)2009年11月,Go语言正式对外公开,Google开源了该编程语言的源码。

(4)2012年3月,Go1.0版本发布,从这个版本开始,Go语言承诺对API保持兼容性,也就是确保未来的版本升级不会破坏现有的代码。

(5)2015年8月,Go1.5版本实现了自举。这个版本的编译器不再依赖C编译器,而是使用Go编译Go,其中有少量代码是使用汇编语言实现的。

(6)2016年,内存管理领域权威专家Rick Hudson(里克·赫德森)加入团队,重新设计了垃圾回收机制。在这一阶段,Go语言开始支持并发垃圾回收,这极大地提高了垃圾回收的性能。此外,这次改进还解决了Go语言一直被诟病的垃圾回收停顿时间(Stop-The-World,STW)问题。这一改进在Go1.5和Go1.6版本中得到了体现,这标志着Go语言在内存管理方面迈出了重要的一步。

(7)2017年2月,Go1.8版本发布,垃圾回收的效率进一步提高,延迟时间降低到毫秒级别。比如,在相同的业务场景下,垃圾回收的延迟时间已经可以控制在1毫秒内。可以说,在解决垃圾回收延迟时间长的问题后,Go语言在服务器端的开发方面几乎没有短板了。

(8)2020年2月,Go1.14版本发布,包管理工具Go Module被正式推荐在生产环境中使用。

(9)2022年3月,Go1.18版本发布,此版本中引入了被争论多年的泛型。

在Go语言的版本迭代过程中,由于官方承诺新版本与老版本的代码兼容,因此,每次版本迭代时,Go语言开发团队都会把重心放在代码的优化、稳定性、编译速度、执行效率、垃圾回收的性能上,而对于是否增加新的语言特性则较为谨慎,这在一定程度上避免了由新特性带来的不兼容问题。

具体的版本特性可以查阅官方文档了解,地址为https://golang.org/doc/go<版本号>。

1.2 云时代Go语言的发展趋势

Go语言诞生至今(2023年)已有十多年,越来越多以Go语言为基础的成功案例让用户和决策者信心倍增。近年来,热门云原生项目(Docker、Kubernetes、Prometheus等)为Go语言的发展提供了很大的帮助。

Go语言正式发布后,初期虽获得关注,但在TIOBE Index[1]中的排名较低。之后,Go语言逐渐得到开发者和企业的认可,并被应用到云计算、微服务等领域,其在TIOBE Index中的排名也上升并稳定在前20名左右。值得一提的是在2009年和2016年,Go语言被评为年度最佳编程语言。

[1]TIOBE Index是一个被广泛认可的编程语言流行度指标,但它并不是绝对权威的。实际的编程语言使用情况可能受多种因素的影响,包括地区、行业等。

图1-1是TIOBE Index发布的Go语言发展趋势图。从该图中可以看出,Go语言目前处于理性接受阶段。这意味着越来越多的开发者和企业开始认识到Go语言的优势,并将其应用于各种软件项目中。

图1-1 TIOBE Index发布的Go语言发展趋势图

1.3 Go语言优秀的语言特性

在编写代码时,我们最关心的往往是编码效率,其次是程序的执行效率。Go语言在编码效率和执行效率方面实现了极佳的平衡,且其代码的设计理念与计算机的运行方式高度契合,因此我们可以准确地判断程序的走向和运行速度,清晰地了解其工作原理。编码效率与执行效率的平衡、代码的设计理念与计算机运行方式的契合,正是Go语言的核心优势所在。

笔者结合自己的理解和使用Go语言进行软件开发的经验,并根据重要性将Go语言的特点进行了整理,具体如下。

采用了“少即是多”的设计哲学。

拥有强大的运行时(runtime)。

支持面向接口编程。

它是为工程服务的语言。

自带标准化的测试框架。

拥有丰富的标准库和第三方库。

1.3.1 “少即是多”的设计哲学

Go语言的设计哲学是“少即是多”,它没有太多的创新,语法糖也少,只有25个关键字,但是它却支持面向接口编程、并发、垃圾回收、内存自动管理等功能,同时它还像C语言一样简洁,且支持C语言函数的调用。

Go语言的“少”使得开发者可以快速上手,且开发效率很高。Go语言的代码简洁易读,编译速度也很快。

下面的代码实现了调用API查询天气预报并返回最高和最低温度的功能。

type Weather struct{}
 
//调用 API 查询天气预报并返回最高和最低温度
func (w *Weather) GetTemperature(cityId string) (float64, float64, error) {
        var url string
        resp, err := http.Get(url)
        if err != nil {
      return 0, 0, err
        }
        defer resp.Body.Close()
        var data struct {
                Main struct {
                                city        string    `json:"city"`
                                tempHigh    float64   `json:"tem1"`
                                tempLow     float64   `json:"tem2"`
                                updateTime  time.Time `json:"update_time"`
                } `json:"main"`
        }
        if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
             return 0, 0, err
        }
 
        return data.Main.tempHigh, data.Main.tempLow, nil
}

这段代码虽行数不多,但清晰地展示了Go语言的各种特性。它使用了结构体、方法、多返回值函数、defer关键字、指针和标准库,且实现了简单声明变量、错误处理和序列化等功能。

1.3.2 强大的runtime

Go语言具有强大的runtime,支持语言级别的并发、高效的内存管理和自动垃圾回收机制。

1.语言级别的并发

一些编程语言需要通过包装库来实现并发,这可能会降低代码的可读性。然而,Go在语言层面就实现了并发,Go语言的并发特性体现在能够充分利用多核提高CPU的并发利用率上,并且从单核转换为多核的成本也较低。编写并发代码的过程很简单,即使是刚接触Go语言的开发者也能轻松编写出高并发程序,这也是众多开发人员选择Go语言的原因之一。

下面的代码用于开启1000个协程(goroutine),且并发输出传入的i值。

func Print(i int) {
        fmt.Println("goroutine:", i)
}
 
func main() {
        for i := 0; i < 1000; i++ {
                go Print(i)
        }
        time.Sleep(time.Second)
}

从上述代码可知,使用Go语言进行并发编程非常简单,仅仅使用一个关键字go即可。其中涉及的CPU绑定、时间切片优化等工作,已由Go语言底层进行了封装。另外,Go并发源码的可读性非常强。

Go语言能实现高并发得益于其实现的GPM调度模型。G、P、M这三个字母分别指模型中的协程、逻辑处理器和工作线程,Go语言在用户层实现了GPM调度模型。Go语言中的协程是轻量级的线程,每个协程占用约2KB的内存,因此可以有效利用多核进行大规模的并发处理。在Go语言中实现并发的原因很简单,就是为了高效地完成协程对CPU资源的调度。因此,Go语言调度的核心是根据特定的算法将协程投放到操作系统的线程中执行。因为Go程序在runtime中就实现了自己的调度器,所以我们常说Go在语言级别支持并发性。

在高并发场景中,可能会出现临界资源安全(数据冲突)问题。为了解决这个问题,除了使用传统的锁,Go语言还实现了极具特色的通信顺序进程(Communicating Sequential Processes,CSP)模型。该模型吸收了管道通信机制的优点,形成了独特的通道(channel)数据类型。

2.高效的内存管理

Go语言的内存管理借鉴了Google公司为C语言开发的TCMalloc算法。TCMalloc算法的核心思想是将内存分级管理,从而降低锁的粒度,还可以通过分配连续的内存来减少内存碎片。它可以替代系统中与内存分配相关的函数(如malloc、free、new等)。Go语言借鉴这个思路,将内存按照大小分配给不同的对象,并通过一些高效的内存分配算法来实现内存管理。内存分配、管理的大致过程如下。

(1)程序启动时,向操作系统申请一大块虚拟内存。

(2)根据规则将申请的虚拟内存切割成小的内存块,并根据对象的大小分配对应规格的内存块。

(3)回收时不直接将空闲内存返还给操作系统,而是自己管理,若有新对象进入内存并提出分配申请,则直接进行分配,从而减轻操作系统的工作负担。

内存使用多级分配的管理方式,每个线程维护自己的本地内存池。进行内存分配时,优先从本地内存池中分配,这种方式不需要加锁,可以避免或减少因频繁向全局内存池申请内存而引发的竞争。当本地内存池中的内存不足时,再向上一级内存池申请。

3.自动垃圾回收机制

C语言虽然性能好,但对开发人员的要求也很高,因为开发人员需要负责管理内存(包括分配和释放内存等),稍不注意就可能导致内存泄漏等问题。在Go语言中,栈和堆是Go程序在运行过程中分配和管理内存的两个主要区域,它们有各自的特点且承担着不同的责任。对于栈中的数据,系统会在当前函数执行结束后自动清理;对于堆中的数据,则通过自动垃圾回收机制统一回收管理。当然,自动进行垃圾回收的语言其性能赶不上手动管理内存的语言。

自动垃圾回收机制的好坏取决于STW的长短。Go语言使用三色标记、混合写屏障、并发增量回收等机制来提高垃圾回收的性能。在Go1.14版本中,垃圾回收的时间已经达到了亚毫秒级。

1.3.3 面向接口编程

在Go语言中,接口扮演着重要的角色。接口提供了一种抽象机制,使得程序员可以编写更灵活、可扩展的代码。通过接口,不同的类型可以在共享相同行为(方法)的情况下实现松耦合的交互,从而提高代码的可维护性和可重用性。

Go语言中没有类的概念,因此它将面向对象的三个基本特征(封装、继承和多态)以自己的方式实现。Go语言通过接口实现了鸭子类型(Duck Typing),这是一种动态类型的编程范式。鸭子类型基于一个简单的原则实现:如果一个对象走起来像鸭子,叫起来像鸭子,那么它就可以被认为是鸭子。换句话说,鸭子类型关注的是对象的行为,而不是对象的实际类型。在Go语言中,接口是一个或多个方法签名的集合,任何实现了这些方法的类型都可以被认为实现了该接口。这是一种隐式的实现,没有显式地使用implement之类的关键字。需要注意的是,尽管Go语言是静态类型的,但因为接口实现了鸭子类型,所以它的类型处理更为灵活。Go语言官方建议使用组合方式实现继承这个特征。一般情况下,组合是“has-a”关系,继承是“is-a”关系,相比之下,组合的耦合更松。

在笔者看来,Go语言就是一门基于连接与组合实现的编程语言。在Go语言中,连接指的是对各个函数或方法进行串联的方式,组合指的是将简单对象组合成复杂对象的方式。编程的目标就是化繁为简,充分运用组合的思想大幅简化了开发模式和项目代码,实现返璞归真。

1.3.4 为工程服务的语言

从Go语言的“出身”来看,它更像学院派语言,但实际上设计它的初衷是将其用作为工程服务的语言。Go语言具有开发效率高、代码规范统一、写并发相对容易等特点,非常适合团队开发。它的应用场景包括基础架构、中间件、云原生开发等。Go语言简化了指针设计,实现了将变量初始化为零值、通过 runtime 分配内存、内存逃逸分析以及自动垃圾回收等功能,让内存使用变得更为简单和安全。从这些实现细节中,我们可以深刻地认识到Go语言是一门为工程服务的编程语言。

1.静态强类型编译型语言

Go语言是一种静态强类型编译型语言,它具有以下特征。

(1)因为是静态语言,所以在程序运行之前就已经知道值类型是什么。这是一件很好的事情,可以在编译时就检查出隐藏的大多数问题。如果我们在错误的位置使用了错误的类型,Go语言就会及时告诉我们(比如常见的类型转换错误)。这减少了隐藏的缺陷(Bug),使线上的稳定运行成为可能。

(2)因为是强类型语言,所以类型之间的转换是显式的。

(3)因为是编译型语言,所以在运行前需要先生成二进制机器码。

提示:与静态语言相对的是动态语言。因为动态语言没有编译器,所以在执行过程中只能逐条地判断对错,如果出现问题,可能会立即崩溃或产生异常。静态语言会在编译过程中直接将源码转换为机器码,这意味着程序在运行时不需要实现额外的解释过程,因此可以提供更快的运行速度。此外,它还可以在编译过程中进行代码优化,如函数内联优化、消除死代码、展开循环等,以提高机器码的性能。

2.静态链接

关于静态链接和动态链接的争议一直存在。在笔者看来,在存储已经很便宜的今天,静态链接可能是更好的选择。Go语言正好属于静态链接库阵营。在Go语言中,一个几千字节大小的“hello world”程序编译后的可执行文件会达到几兆字节大小,这是因为Go语言在编译时会将一些静态链接库以及runtime环境打包到可执行文件中,虽然体积变大了,但程序更具可移植性和独立性了。不要低估了这一点,因为在复杂的生产系统中,使用动态链接可能会存在程序依赖于多个版本库的问题。此外,静态链接还具有另一个优势,即编译一次即可在任何地方运行。众所周知,部署Java程序需要在运行环境中安装JRE,而编译后的Go语言可执行文件并不依赖于任何运行环境,也就是说,它可以直接运行。这进一步证明了Go语言是以实用为主的工程语言。

3.编译速度快

“天下武功唯快不破”,程序的编译与执行也是如此。但是,对于快速编译和快速执行,我们往往只能选择其一,就像鱼与熊掌不可兼得一样。C语言运行速度快,但编译速度慢,依赖库运行时错误很多。Java的执行速度和编译速度都不错,但在不同的环境下,需要使用不同的 JVM运行代码。Go 语言一开始就考虑实现快速编译,就像解释型语言一样,我们不会注意到它正在编译。用Go语言编写的项目可以在秒级完成编译,这对提高生产力很重要!

4.执行性能高

Go语言虽然简单,语法糖少,但性能不差。它的性能略低于C++,相当于Java,比Python快几十倍。表1-1是The Benchmarks Game[2]中列出的编程语言性能数据,其中给出了在10种性能测试方法下多种语言的运行时间。

[2]The Benchmarks Game的官方地址为https://benchmarksgame-team.pages.debian.net/benchmarksgame,它是目前较为权威的为多种编程语言提供性能测试结果和评估的网站。

表1-1 10种性能测试方法下多种语言的运行时间对比(单位:秒)

性能测试方法

Go

Java

C++

Python

fannkuch-redux

8.28

11.00

8.08

367.49

pidigits

0.86

0.79

0.6

1.28

fasta

1.20

1.20

0.78

39.10

n-body

6.38

6.75

4.09

586.17

spectral-norm

1.43

1.68

0.72

118.40

reverse-complement

1.43

1.54

0.63

7.16

regex-redux

3.61

5.70

1.08

1.36

k-nucleotide

8.29

5.00

1.95

46.37

mandelbrot

3.75

4.11

0.84

172.58

binary-trees

6.74

2.50

1.04

49.35

从表1-1可以看出,在这10种性能测试方法下,C++的性能最好,Go与Java差不多,Python要慢几个数量级。

5.支持跨平台交叉编译

Go语言支持跨平台交叉编译,可以轻松地根据指定的平台编译源码并运行。

6.拥有丰富的工具链

Go语言拥有丰富的工具链(命令行工具),包括编译、运行调试、下载和代码格式化等工具。

1.3.5 自带标准化的测试框架

Go语言自带标准化的测试框架,可以很好地执行单元、黑盒、白盒、压力和模糊(模糊测试是Go1.18版本中新增的功能)等多种测试。除了标准化的测试框架,它还有很多第三方优秀的测试框架。

说明:建议一个项目仅使用一套测试框架,以确保项目的完整性和一致性。

1.3.6 丰富的标准库和第三方库

Go语言拥有非常丰富的标准库和第三方库,这些库几乎涵盖了所有常用的功能和操作,涉及网络编程、数据处理、文件操作、并发处理、加密算法、图片处理、数据库操作等。借助这些库,我们可以轻松地编写出高可用、高性能且具有良好可维护性的代码!

更进一步说,Go 语言的活跃社区和开源生态系统使得开发者可以快速找到合适的库来满足特定的需求。Go语言简洁的语法和清晰的代码组织结构也使得第三方库易于理解和集成。以上这些因素都有助于提高我们的开发效率,这进一步体现了 Go 语言在工程实践中的优势。

1.4 强大的生态圈和成功案例

最初Go语言的定位是成为系统编程语言,但由于自动垃圾回收机制会影响性能,所以在系统层面人们更多还是会选择C和C++语言。如今,在大数据和电商领域,Java占据主导地位;在人工智能领域,Python占据主导地位。Go语言则常被选择用于构建位于上层应用和底层操作系统之间的中间层。

在云原生领域,大多数项目是使用Go语言实现的,包括著名的容器运行时代表Docker、事实上的容器编排标准Kubernetes、分布式KV存储系统Etcd、监视的首选Prometheus等。目前,一些大型IT公司也在使用Go语言重构部分业务。可以说,Go语言是云原生的第一开发语言。

Go语言赶上了云时代,促进了云的发展,云原生和Go语言互相成就。

1.5 Go程序是如何运行的

想了解Go程序在计算机上是如何运行的,就得了解该程序运行可能涉及的步骤,此步骤一般包括编译、连接和执行等环节。

对源码进行编译后,可执行文件如何由操作系统加载到内存中并运行呢?事实上,操作系统已将整个内存划分为多个区域,每个区域用于执行不同的任务。内存区域的名称与作用如表1-2所示。

表1-2 内存区域的名称与作用

内存区域

名称

作用

stack

栈空间

栈是一种自动管理的内存区域,用于存储局部变量、函数调用的参数和返回地址。栈具有先进后出的特性。当函数被调用时,栈会自动为函数的局部变量和参数分配空间;当函数返回时,这些空间会被自动释放。栈内存分配和回收的速度较快,但栈提供的内存空间有限

heap

堆空间

堆是一种手动管理的内存区域,用于存储程序在运行时动态分配的内存。相较于栈,堆提供了更大的内存空间,但其分配和回收的速度较慢。在使用堆内存时,需要注意内存泄漏和内存碎片问题。回收此部分内存需要自动垃圾回收机制的介入

bss段

bss段

用于存放未被初始化的全局变量和静态变量。在程序启动时,操作系统会将bss段中的所有变量初始化为零值。bss段中的变量不会占用磁盘空间,只在程序加载到内存中时分配空间

data段

data段

用于存储已被初始化的全局变量和静态变量。这些变量在程序编译时就已经被赋予了初始值。data段可以进一步细分为只读数据段(如常量)和可读写数据段(如普通已初始化的变量)

text段

text段

存储程序的二进制指令,以及其他的一些静态内容。它通常是只读的,以防止程序在运行时意外地修改自身代码。这个段也称为代码段

Go源码文本转换为二进制可执行文件涉及以下两个步骤。

(1)编译:将文本代码编译为目标文件(.o、.a)。

(2)连接:将目标文件合并为可执行文件。

可以使用命令go build -x main.go查看Go源码的编译和连接过程,具体如下,请注意其中的关键字compile和link。

go build -x main.go
WORK=/var/folders/dw/hlkj1z4166l8ml089msv27q40000gp/T/go-build3987574679
mkdir -p $WORK/b038/
...
cd ../golang-1/1-intro-golang/helloworld/v1
/usr/local/go/pkg/tool/darwin_amd64/compile -o $WORK/b038/_pkg_.a -trimpath "$WORK/b038=>" -p golang-1/1-intro-golang/helloworld/v1/mytask -lang=go1.17 -complete -buildid _AQjJb_ oKnpP5p-Roetq/_AQjJb_oKnpP5p-Roetq -goversion go1.17.1 -importcfg $WORK/b038/importcfg -pack -c=4 ./mytask/mystruct.go ./mytask/taskprocess.go
/usr/local/go/pkg/tool/darwin_amd64/buildid -w $WORK/b038/_pkg_.a # internal
cp $WORK/b038/_pkg_.a /Users/makesure10/Library/Caches/go-build/c2/c23aaafabe1f90ef18 755a4aa4a017ae2a3b5cd48fcb5e65a77722b1a47f262f-d # internal
mkdir -p $WORK/b001/
...
mkdir -p $WORK/b001/exe/
cd .
/usr/local/go/pkg/tool/darwin_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/ importcfg.link -buildmode=exe -buildid=-hKqHVTOB_jOb7Jh11JS/HTWG1gA5dVlOMQ10XUm4/ jng5Q6XZtNSFKpOfgseT/-hKqHVTOB_jOb7Jh11JS -extld=clang $WORK/b001/_pkg_.a
/usr/local/go/pkg/tool/darwin_amd64/buildid -w $WORK/b001/exe/a.out # internal
mv $WORK/b001/exe/a.out main
rm -r $WORK/b001/

以Linux系统为例,生成二进制可执行文件后,操作系统执行该文件的步骤为解析EFL Hearder,加载文件内容到内存中,从Entry point处开始执行代码。

在Linux系统中,我们可以使用工具readelf查找程序的入口地址。通过关键字Entry point找到Go进程的执行入口后,就可以知道Go进程开始的位置。示例代码如下。

[root ~]# readelf -h main 
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x45c220 //程序的入口地址
  Start of program headers:          64 (bytes into file)
  Start of section headers:          456 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         7
  Size of section headers:           64 (bytes)
  Number of section headers:         23
  Section header string table index: 3

从上面的结果可以看到,程序的入口地址是0x45c220。接着使用dlv调试器查看汇编代码。

# dlv exec ./main
2022-03-04T16:26:02+08:00 error layer=deBugger can't find build-id note on binary
Type 'help' for list of commands.
(dlv) b *0x45c220
Breakpoint 1 set at 0x45c220 for _rt0_amd64_linux() /usr/local/go/src/runtime/rt0_linux_amd64.s:8

可以看到,与地址0x45c220对应的汇编代码是rt0_linux_amd64.s(此入口文件因平台而异)。下面这段代码显示了汇编代码rt0_linux_amd64.s涉及的一些指令。

TEXT _rt0_amd64(SB),NOSPLIT,$-8
        MOVQ     0(SP), DI        // argc
        LEAQ     8(SP), SI        // argv
        JMP      runtime·rt0_go(SB)

rt0_go的功能可分为两部分。第一部分是获取系统参数和检查runtime,第二部分则是启动Go程序,大致启动流程为从rt0_linux_amd64.s中进入程序,创建主协程,创建runtime.main,调用main.main。

汇编语言是高级语言与操作系统之间的桥梁,所有的汇编指令都可以转换为二进制机器码序列,以被CPU理解。Go语言在编译时也会转换成汇编语言,所以了解一些汇编知识可以更好地帮助我们深入理解Go语言的一些底层机制。

1.6 plan9与Go语言

Go语言使用的是plan9汇编。最初Go语言是在plan9操作系统上开发的,后来才在Linux系统和macOS系统上实现。虽然plan9汇编与传统汇编有一些差异,但汇编知识基本是通用的,我们可以参考《plan9汇编手册》和Go官方提供的《Go汇编快速引导手册》来了解更详细的知识。

1.6.1 寄存器

寄存器是CPU内部存储容量有限的高速存储部件,可临时存放参与运算的指令、数据和地址。

与amd64有关的通用寄存器都可以在plan9操作系统中使用,应用代码级别的通用寄存器主要是rax、rbx、rcx、rdx、rdi、rsi、r8~r15这14个,管理栈顶和栈底的则为bp和sp,不建议使用这两个寄存器进行运算。

在plan9操作系统中使用寄存器时不需要带r或e前缀,例如rax,只需要写成AX即可,示例命令如下:

MOVQ $101, AX = mov rax, 101

表1-3是在x64和plan9操作系统中通用寄存器名字的对照表。

表1-3 在x64与plan9操作系统中通用寄存器名字的对照表

x64

rax

...

rdx

rdi

rsi

rbp

rsp

r8

...

r14

rip

plan9

AX

...

DX

DI

SI

BP

SP

R8

...

R14

PC

常用汇编指令的对应关系如下(左边为plan9,右边为x64):

plan9  ->  x64
MOVB $1, DI -> mov dil, 0x1    // 将 1 字节的立即数 0x1 存入寄存器 DI 的低 8 位
MOVW $0x10 BX -> mov bx, 0x10 // 将 2 字节的立即数 0x10 存入寄存器 BX 中
MOVD $100, DX -> mov edx, 100  // 将 4 字节的立即数 100 存入寄存器 DX 中
MOVQ $-10, AX -> mov rax, -10  // 将 8 字节的立即数 -10 存入寄存器 AX(RAX)中
ADDL $5, CX -> add ecx, 5 // 将寄存器 CX(ECX)的值加上立即数 5
SUBQ $20, SP -> sub rsp, 20 // 将寄存器 SP(RSP)的值减去立即数 20
ANDW $0xFF, SI -> and si, 0xFF // 将寄存器 SI 与立即数 0xFF 进行按位与操作
ORL $0xF0F0, DI -> or edi, 0xF0F0 // 将寄存器 DI(EDI)与立即数 0xF0F0 进行按位或操作

可以看到,plan9汇编操作数的方向与AT&T类似,与Intel的相反。

Go语言的汇编还引入了4个伪寄存器,分别如下。

FP(Frame Pointer):帧指针,用于指向当前函数栈帧的基址。在Go语言的汇编中,FP可用于访问局部变量和函数的参数。通常,FP会指向栈帧的底部(高地址处),而局部变量和参数都位于FP以下的地址中。

PC(Program Counter):程序计数器,用于表示当前正在执行的指令地址。在Go语言的汇编中,PC通常用于计算相对跳转地址。这个伪寄存器主要用于控制程序的流程,如条件跳转、循环等。

SB(Static Base Pointer):静态基址指针,主要用于访问全局变量和静态变量。在Go语言的汇编中,全局变量和静态变量的地址都是相对于SB的偏移量。SB使得访问这些变量更加方便,同时避免了硬编码绝对地址。

SP(Stack Pointer):栈指针,用于指向当前栈帧的栈顶(低地址处)。在Go语言的汇编中,SP用于管理函数栈帧,如分配临时变量、调用其他函数等。SP和FP一起维护了函数调用过程中的栈结构。

1.6.2 Go语言的反汇编方法

Go语言提供了多种反汇编方法,具体如下。

使用objdump工具实现,命令为“objdump -S Go二进制文件”。

使用gdb的反汇编实现,命令为disassemble/disass。

将Go代码编译成汇编代码,命令为go tool compile。

将Go语言的二进制文件反编译成汇编代码,命令为go tool objdump。

在构建Go程序的同时生成汇编代码文件,命令为go build -gcflags。

在上述反汇编方法中,前两种主要用在操作系统层面,我们常用的是后面三种,下面具体介绍这三种。

1.使用go tool compile命令将Go代码编译成汇编代码

将Go代码编译成汇编代码时,输出的汇编代码没有链接,呈现的地址都是偏移量。go tool compile命令的使用方式如下。

$ go tool compile -N -l -S main.go
"".plan9ExampleFunc STEXT size=80 args=0x0 locals=0x28 funcid=0x0 align=0x0
    0x0000 00000 (main.go:3)  TEXT   "".plan9ExampleFunc(SB), ABIInternal, $40-0
    0x0000 00000 (main.go:3)  CMPQ   SP, 16(R14)
    ...
    0x0024 00036 (main.go:4)  CALL   runtime.newobject(SB)
    ...
"".main STEXT size=86 args=0x0 locals=0x20 funcid=0x0 align=0x0
    0x0000 00000 (main.go:8)  TEXT "".main(SB), ABIInternal, $32-0
    0x0000 00000 (main.go:8)  CMPQ   SP, 16(R14)
    ...
    0x004f 00079 (main.go:8)  CALL   runtime.morestack_noctxt(SB)
    0x0054 00084 (main.go:8)  PCDATA $0, $-1
    0x0054 00084 (main.go:8)  JMP    0

2.使用go tool objdump命令将Go语言的二进制文件反汇编成汇编代码

go tool objdump命令的使用方式如下。

$ go tool objdump main.o
TEXT "".plan9ExampleFunc(SB) gofile..../main.go
  main.go:3   0x723   493b6610    CMPQ 0x10(R14), SP 
  ...                  
  main.go:5   0x76b   c3          RET                            
  main.go:3   0x76c   e800000000  CALL 0x771   [1:5]R_CALL:runtime.morestack_noctxt
  main.go:3   0x771   ebb0        JMP "".plan9ExampleFunc(SB)    
TEXT "".main(SB) gofile...../main.go
  main.go:8   0x773   493b6610    CMPQ 0x10(R14), SP      
  ...                   
  main.go:8   0x7c2   e800000000  CALL 0x7c7   [1:5]R_CALL:runtime.morestack_noctxt
  main.go:8   0x7c7   ebaa        JMP "".main(SB)         

要说明的是,反编译Go二进制文件成汇编代码时会丢失很多信息。

3.在构建Go程序的同时使用go build -gcflags命令生成汇编代码

go build -gcflags命令的使用方式如下。

$ go build -gcflags -S main.go
"".plan9ExampleFunc STEXT size=61 args=0x0 locals=0x18 funcid=0x0 align=0x0
       0x0000 00000 (..../main.go:3)   TEXT   "".plan9ExampleFunc(SB), ABIInternal, $24-0
       0x0000 00000 (..../main.go:3)    CMPQ   SP, 16(R14)
       ...
       0x0036 00054 (..../main.go:3)    CALL   runtime.morestack_noctxt(SB)
       0x003b 00059 (..../main.go:3)    PCDATA  $0, $-1
       0x003b 00059 (..../main.go:3)    JMP    0
 
"".main STEXT size=66 args=0x0 locals=0x10 funcid=0x0 align=0x0
       0x0000 00000 (..../main.go:8)    TEXT   "".main(SB), ABIInternal, $16-0
       0x0000 00000 (..../main.go:8)    CMPQ   SP, 16(R14)
       ...
       0x0040 00064 (..../main.go:8)    JMP    0

也可以使用上述命令查看某个具体的函数。

$ go build -gcflags '-l' -o main main.go
$ go tool objdump -s "main\.plan9ExampleFunc" main
TEXT main.plan9ExampleFunc(SB) ..../main.go
main.go:3    0x1053f60      493b6610CMPQ 0x10(R14), SP      
...
main.go:3    0x1053f96      e865d0ffff     CALL runtime.morestack_noctxt.abi0(SB)
main.go:3    0x1053f9b      ebc3    JMP main.plan9ExampleFunc(SB)   

小技巧:命令go build -gcflags -S用于生成正在处理的汇编代码,命令go tool objdump则用于生成最终机器码的汇编代码。

1.6.3 反汇编的查看示例

利用反汇编可以查看映射被转换为哪种runtime函数,也可以确定内存是否在堆上分配。

1.查看映射被转换为哪种runtime函数

先使用Go语言编写一段包含映射(map)初始化的代码。

package main
 
import "fmt"
 
func main() {
        var a = map[int]int{}
        a[1] = 1
        fmt.Println(a)
}

接着通过前面提到的go tool compile命令将Go代码编译成汇编代码,这段代码初始化时调用了runtime.makemap_small(SB)函数。如果想看映射被转换为哪种runtime函数,就去源码src/runtime中寻找makemap_small的相关定义,示例代码如下。

$ go tool compile -S map.go | grep 'map.go:6'
0x0014 00020 (map.go:6) PCDATA  $1, $0
0x0014 00020 (map.go:6) CALL    runtime.makemap_small(SB)
0x0019 00025 (map.go:6) MOVQ    AX, "".a+40(SP)

2.确定内存是否在堆上分配

先使用Go语言编写一段涉及内存分配的示例代码。

package main
 
import "fmt"
 
func main() {
        var a = new([]int)
        fmt.Println(a)
}

内存是否在堆上分配,取决于初始化时是否调用了runtime.newobject(SB)函数。确定内存是否在堆上分配的示例代码如下。

$ go tool compile -S heap.go|grep 'heap.go:6' 
0x0014 00020 (heap.go:6)       LEAQ    type.[]int(SB), AX
0x001b 00027 (heap.go:6)       PCDATA  $1, $0
0x001b 00027 (heap.go:6)       NOP
0x0020 00032 (heap.go:6)       CALL    runtime.newobject(SB)

相关图书

Rust游戏开发实战
Rust游戏开发实战
仓颉编程快速上手
仓颉编程快速上手
Go语言编程指南
Go语言编程指南
JUnit实战(第3版)
JUnit实战(第3版)
Scala速学版(第3版)
Scala速学版(第3版)
Kafka实战
Kafka实战

相关文章

相关课程