来谈谈 Golang 吗?

一、发展历史

既然要来谈谈 Golang,先来看看它的发展史中,几个关键时间节点:

  • 2007年9月:肯·汤普逊 (Ken Thompson) 罗布·派克(Rob Pike)罗伯特·格瑞史莫(Robert Griesemer) 在 Google 内部开始讨论新的编程语言项目,最初想要用 C++ 或 Java 来实现,但后来发现这些语言都有很多缺点和局限性。
  • 2007年10月:肯、罗布和罗伯特决定用 Go 来开发新的语言,他们给这门语言取名为 "go",并开始设计其基本结构和功能。
  • 2007年11月:肯、罗布和罗伯特向 Google 的技术委员会汇报了 Go 的初步想法,并得到了支持和和鼓励。他们开始在 Google 内部进行 Go 的测试和实验。
  • 2009年初:Go 完成了第一个版本,并在 Google 内部进行了内测。Go 获得了很多用户和开发者的好评,并被认为是一门有前途的新兴语言。
  • 2015、16年:垃圾回收器被完全重新设计实现

1.1 出现问题

互联网技术日新月异,当时的服务器软件系统规模庞大,程序都是由众多程序员共同创作,源代码也以数百万行计,而且实际上还需要每天都进行更新。

这种情况给编译器造成了极大的困难,因为即使使用大型编译集群之上进行一次 build,时间周期也是不可忍受的。

我们来从官方go起源来寻找问题:

  • 在编程世界变革之前,环境不同

At the time of Go's inception, only a decade ago, the programming world was different from today. Production software was usually written in C++ or Java, GitHub did not exist, most computers were not yet multiprocessors, and other than Visual Studio and Eclipse there were few IDEs or other high-level tools available at all, let alone for free on the Internet.

译文:在 Go 诞生之时,也就是十年前,编程世界与今天不同。 生产软件通常是用 C++或Java 编写的, GitHub 不存在,大多数计算机还不是多处理器, 除了 Visual Studio 和 Eclipse 之外,很少有 IDE 或其他高级工具可用,更不用说在互联网上免费了。

  • 编程语言的进步与计算机的速度不匹配

Meanwhile, we had become frustrated by the undue complexity required to use the languages we worked with to develop server software. Computers had become enormously quicker since languages such as C, C++ and Java were first developed but the act of programming had not itself advanced nearly as much. Also, it was clear that multiprocessors were becoming universal but most languages offered little help to program them efficiently and safely.

译文:与此同时,我们对使用我们所使用的语言来开发服务器软件所需的过度复杂性感到沮丧。自从 C、C++和Java 等语言首次开发以来,计算机的速度已经变得非常快,但编程行为本身却没有进步那么多。此外,很明显,多处理器正在变得普遍,但大多数语言对高效、安全地对其进行编程几乎没有提供帮助。

1.2 解决问题

2007年 C++ 委员会在 Google对C++ 的新特性做了一次分享演讲 Google 的技术大神们也在认真听讲座,罗布也是其中一位。随着会议的中场休息,大家开始了对这些 C++ 语言新特性是否带来更多的价值进行热烈的讨论。

说是讨论,倒不如说是吐槽更好,他们一致认为:与其在臃肿的语言上不断增加新的特性,不如简化编程语言,其他的编程语言老前辈,都是有严重的历史包袱,于是Go语言就是在这样的环境下出现的, Go 语言为了解决高难度、低效率、高耗资源的系统编程问题。

  • 考虑未来

We decided to take a step back and think about what major issues were going to dominate software engineering in the years ahead as technology developed, and how a new language might help address them. For instance, the rise of multicore CPUs argued that a language should provide first-class support for some sort of concurrency or parallelism. And to make resource management tractable in a large concurrent program, garbage collection, or at least some sort of safe automatic memory management was required.

译文:我们决定退后一步,思考随着技术的发展,未来几年哪些主要问题将主导软件工程,以及新语言如何帮助解决这些问题。例如,多核 CPU 的兴起表明,语言应该为某种并发或并行性提供一流的支持。为了使大型并发程序中的资源管理易于处理,需要垃圾收集,或者至少需要某种安全的自动内存管理。

1.3 关于Go

1.3.1名字由来

最初的讨论是在 2007年9月20日 星期四下午进行的。第二天下午 2点,Robert Griesemer、Rob Pike 和 Ken Thompson 在 Google 山上 43 号楼的雅温得会议室举行了有组织的会议查看校园。该语言的名称于 25 日出现,第一个邮件线程中出现了几条关于该设计的消息:

由罗布在 2007年9月25号 回复给 肯.罗伯特的有关新的编程语言讨论主题的邮件。

Subject: Re: prog lang discussion

From: Rob 'Commander' Pike

Date: Tue, Sep 25, 2007 at 3:12 PM

To: Robert Griesemer, Ken Thompson

i had a couple of thoughts on the drive home.

  1. name

'go'. you can invent reasons for this name but it has nice properties.

it's short, easy to type. tools: goc, gol, goa. if there's an interactive

debugger/interpreter it could just be called 'go'. the suffix is .go

邮件正文大意为:在开车回家的路上我得到了些灵感,给这门编程语言取名为 "go",它很简短,易书写。工具类可以命名为:goc、gol、goa。交互式的调试工具也可以直接命名为"go",语言文件后缀名为 .go 等等。

该语言是 Go 还是 Golang?

该语言称为 Go。"golang"这个绰号的出现是因为该网站最初是 golang.org 。(当时还没有 .dev域。)不过,许多人使用 golang 名称,并且它作为标签很方便。例如,该语言的 Twitter 标签是"#golang"。无论如何,该语言的名称就是简单的 Go。

p.s:虽然 官方标志 有两个大写字母,但语言名称写的是Go,而不是GO。

1.3.2 吉祥物由来

logo.png

吉祥物和标志由 Renée French 设计,她还设计了 Plan 9 兔子 Glenda 。一篇关于地鼠的 Blog文章 解释了它是如何从她 几年前 用于 WFMU T 恤设计的地鼠衍生出来的徽标和吉祥物受知识共享署名 4.0 许可证保护 。 地鼠有一张 模型表 , 说明了它的特征以及如何正确地表示它们。该模型表最初是在 2016 年 Renée 在 Gophercon 的 演讲中展示的 。他是Go地鼠,而不是普通的地鼠。

实物.png

1.3.3 设计理念

Go at Google: Language Design in the Service of Software Engineering

译:为软件工程服务的语言设计

这是由 罗布·派克 阐述的,在当时 Go 的设计就是为了 Google 公司的软件工程问题。

working with Go is intended to be fast: it should take at most a few seconds to build a large executable on a single computer.

译:使用 Go 的目的是快速:它应该 最多只需几秒钟即可在单台计算机上生成大型可执行文件。

软件工程指导了 Go 的设计:构建一种轻量级且令人愉悦的高效编译编程语言。

1.4 维护艰辛历程

Go语言 从开源到现在,已经有多年了,虽然 Go语言 作为一个标准的富二代,一开始就赢在了人生的起跑线上,有 Google 富爸爸撑腰,但是发展过程还是比较坎坷。

TIOBE 程序设计语言指数 是由 TIOBE 公司推出并进行维护的,这个指数将程序设计语言以排名列表的形式提供出来,并且每个月更新一次,用来表示程序设计语言的流行度。 在这张图可以看到,Go 刚发布时有一阵热度,随后就一直走下坡路,2015年 走到谷底,在 2016年 开始重新流行起来并且保持一个较高的热度。

2016年 发生了什么?为什么又重新热起来了呢?

我们来看看Go语言的历代版本

2012年3月---Go1.0: Go 第一个版本发布 还有一份兼容性说明文档。该文档承诺,Go 的未来版本会尽可能确保向后兼容性,不会破坏现有程序。

2013年5月---Go1.1:这个版本的 Go 致力于增强语言特性(编译器、垃圾回收机制、映射、goroutine 调度器)与性能。

2013年13月---Go1.2:"Three-index slices" Go1.2之前,切片的参数只允许有两个,在Go 1.2中因为引入了新的语法,即支持第三个参数,可以调整切片的容量....

2014年6月---Go1.3:堆栈管理在此版本中得到了重要改善。

2014年12月---Go1.4:该版本主要侧重于实现工作、改进垃圾收集器并为在接下来的几个版本中推出的完全并发收集器奠定基础。

2015年8月---Go1.5:"**垃圾回收器被完全重新设计实现" **。从该版本开始,Go的发布时间延迟两个月,从之前的每年6月和12月 调整为每年 8 月和 2 月发布新版本

2016年2月---Go1.6:增加对于 HTTP/2 协议的默认支持,再一次降低了垃圾回收器的延迟 runtime改变了打印程序结束恐慌的方式。现在只打印发生panic的 goroutine 的堆栈,而不是所有现有的 goroutine

2016年8月---Go1.7:Context包转正,垃圾收集器加速和标准库优化

.....

"垃圾回收器被完全重新设计实现",也叫 GC。 这个是重点,Go 在吃了这么多年苦后,也虚心学习古老的 GC技术,让它能重新回暖。

在随后的几个版本中,Go 都把 GC 的优化放在改进的重心点。

二、学习成本

2.1 语法特性

语法是构成编程语言的基石,Go语言 的语法与其他语言相比来看是比较简洁的,但也说明了 Go语言 的可读性或许不如其他语言强。以下列出了几点Go语法的特性:

2.1.1 自动格式化

在写代码的时候格式的统一是一个很头疼的问题,虽然这并不影响实际的开发和代码的运行,但如果所有的开发人员统一了代码格式,那无论对于读代码还是写代码都会减少一定的时间成本。为了解决这个头疼的问题,Go语言 底层内置了一个 Gofmt 的程序,你无需再花费时间调整代码格式和注释格式等,Gofmt 会自动处理这些问题。

在 C/C++、Java 的语法中,我们通常需要自己输入分号来结束一个语句,但 Go语言 的代码编写中,无需我们手动输入分号。Go 中的词法分析器会根据规则来自动检测是否需要插入分号(然而分号不会在你的代码中出现),通常情况下,在你按下回车后就会自动在行尾插入分号。

2.1.2 变量的定义

当讨论到编程中的变量我们可以分个内容:变量和常量。在 Go 中常量的定义和其他语言的语法几乎相似,就是简单的const 变量名 = 表达式就可以了。Go 中的变量就有所不同了,首先 Go语言 中的变量声明后才能使用且声明过的变量必须使用,格式如下:

  • var 变量名 变量类型 = 表达式

有时候不写变量类型其实也是可以的,Go语言 编译器会通过值自动判断该变量的类型。除此之外 Go语言 还有一个特殊的声明方式:

go 复制代码
// 变量名 :=  表达式
a := 100        // 自动判断为int
b := 100.11     // 自动判断为float
name := "Jack"  // 自动判断为string

使用:=可以简洁、快速地声明变量,Go语言 编译器会根据表达式的内容来自动判断变量的类型。但要注意的是这种方法只能用来声明局部变量,并且同一个作用域内不能重复声明一个变量。所以我们在函数中声明变量时候,用短声明方法是比较常见的。如果要一次声明多个变量或者只声明不进行初始化的时候用上面的 var 来进行声明比较常见。

2.1.3 没有枚举

枚举是用来创建有限的常量集,在很多语言中都有提供枚举类型或方法,但在 Go 并没有。在 Go 中有一个iota enumerator,翻译成中文大概就是 "iota枚举器",iota 是一个标识符,用于声明常量时候使用以达到枚举的效果。当在给一个常量集赋值时,给其中一个值赋值为 iota 时,iota 的值就为 0,并且将这个替换成a的值,给从 整数0 开始计数,每出现一个常量声明那么 iota 就会 +1 ,以此类推,以得到枚举的效果,例子如下:

ini 复制代码
const (
    a = iota // a = iota = 0
    b        // b = 1
    c        // c = 2
)

2.1.4 函数的返回值

Go 中的函数可以返回多个值,并且多个返回值的类型可以是不同的。在有需要的情况下我们给返回值命名,返回值得有意义才可以。除此之外,Go语言的多返回值也可以用来进行异常处理,函数调用出现错误可以返回一个 error 类型的值来指示错误。下面这段就展示了 Go 中多返回值的特性和相比单返回值的优势。

func Write(b []byte) (n int, err error) { ... }

Write 就是该方法的名称,那么后面的这个括号的 n和err 就是两个返回值,分别是 int 和 error 类型。

2.1.5 异常处理

在 Go语言 中其实并没有我们传统意义上类似 try catch 这种错误处理的工具和包。在 Go 中错误通过一个内置的结构类型来表示,程序员在使用这个接口的时候不仅可以用来判断错误,还可以自定义错误类型,提供一些上下文等。

Go语言 采用了把错误看作一个值的方式来进行处理,再加上 Go语言 多返回值的特性,这样使得函数在返回正常值的同时也能返回错误。Go 确实也提供两个可以来处理异常的内置函数(panic 和 recover),但这两个内置函数大多用来处理不可恢复的错误,并且各自都有一些特点并不像 error 那么通用。所以 Go语言 错误处理方式是相对繁琐一些的,但这种直截了当的显示设计让代码更加清晰易读抵消了它的冗长性。

go 复制代码
type error interface {
    Error() string
}

2.1.6 语法糖

语法糖 (Syntactic sugar) 这个名字挺有意思的,是一个应该的计算机科学家发明的词。指在编程语言中添加的一些语法,这些语法对语言本身的功能没有什么影响,而且还能为编程者提供便利。

  • 短变量声明

在上文提到的短变量声明的方法就是一种语法糖,这种不需要指定变量的类型的声明方式,帮助编程者更加省事了。

  • 多重赋值

在一行代码中对进行多个变量的赋值操作,例子如下: x, y = y, x //交换 x 和 y 的值

  • 字段忽略

当我们在定义一个函数,有两个返回值,但实际上只有一个用得上,那我们还可以用_将不需要的值忽略。例子如下:

go 复制代码
func divide(a, b float64) (float64, error) {
    if b == 0.0 {
        return 0.0, errors.New("division by zero")
    }
    return a / b, nil
}
​
func main() {
    result, _ := divide(10.0, 2.0) // 只关心结果,不关心错误
    fmt.Println(result)
}

除此之外还有一些其他语言也会见到的一些语法糖就不一一列举了,比如:自增(++)自减(--)、break continue等。总之这些语法糖可以让人们在使用 Go语言 编程时使代码更简洁清晰,减少代码冗余,让语言更具功能性更优雅。

2.1.7 内置的数据结构少

在 Go语言 中官方给出的数据结构有:数组(Array)、切片(Slice)、映射(Map)。数组和其他的编程语言类似,用于存储同一类型和固定长度的连续数据结构。切片像是更灵活的数据结构,可以看做是可动态的扩容和缩减数组。映射也与其他编程语言类似,是用于存储键值对的一个哈希表。

相比之下,Go语言 没有像链表、队列、栈、树等一些常用的数据结构,但 Go语言 的这三个基本数据结构足够强,可以用他们来实现这些数据结构。这也和Go语言的保持语言简单避免语言过度臃肿的设计理念相符合。

2.1.8 泛型

在几年前的 Go 是没有泛型这个概念的,直到 2022年 Go语言 的核心团队在 Go1.18 版本中引入了泛型的概念。初次看见 Go 泛型,文档给出的很多概念:Type parameter(类型形参)、Type argument(类型实参)、Type constraint(类型约束)、泛型类型(Generic type)...... 初次看见啊这些概念时候就算是翻译成中文大概率也不明白他们分别用处和意义是什么。

我们引入一个非常经典的泛型函数例子来介绍类型形参类型实参和类型约束

go 复制代码
func Print[T any](s []T) {
    for _, v := range s {
        fmt.Println(v)
    }
}
  • 类型形参:这段代码中 T 就是类型形参,他用在泛型的定义中,表示一个占位符。当调用该 Print 函数时候,这个 T 会被所指定的类型替代。
  • 类型实参:当调用该泛型函数时,所传递进来的具体类型就是类型实参。如下,
go 复制代码
ints := []int{1, 2, 3}
Print(ints) // 在这里,int 是类型实参
  • 类型约束:any 就是类型约束,这里 any 代表了该泛型函数可以为任何类型。所以类型约束就是用来限定类型参数可以被接受的类型范围。

我们再引入一段代码来解释什么是泛型类型

scss 复制代码
package main
​
import "fmt"
​
// 定义一个泛型切片类型
type GenericSlice[T any] []T
​
// 添加元素到泛型切片
func (s *GenericSlice[T]) Append(value T) {
    *s = append(*s, value)
}
​
// 获取泛型切片中的指定元素
func (s GenericSlice[T]) Get(index int) T {
    return s[index]
}
​
func main() {
    // 使用 int 类型实例化 GenericSlice
    intSlice := GenericSlice[int]{}
    intSlice.Append(1)
    intSlice.Append(2)
​
    fmt.Println(intSlice.Get(0)) // 输出: 1
    fmt.Println(intSlice.Get(1)) // 输出: 2
​
    // 使用 string 类型实例化 GenericSlice
    stringSlice := GenericSlice[string]{}
    stringSlice.Append("Hello")
    stringSlice.Append("World")
​
    fmt.Println(stringSlice.Get(0)) // 输出: Hello
    fmt.Println(stringSlice.Get(1)) // 输出: World
}
​

type GenericSlice[T any] []T 这段代码就是定义了一个泛型切片类型,那么 GenericSlice 个泛型类型,该类型可以存储任何类型的元素。如果我们想强制该类型只能存储 int、float64 和 string 的数据可以使用这样的方式来定义泛型类型:type GenericSlice[T int|float64|string ] []T,这里面的 int|float64|string 也叫做类型约束。

后面的代码就是一些对于不同的类型在泛型切片类型中的具体实例化和使用。 除此之外与其他语言相比,在Go中,我们可以使用泛型隐式调用接口 并且泛型支持类型判断。假设我们现在有一个 Adder 的接口,定义一个 Add 方法,实现了 Add 方法的类型可以被认为是 Adder 类型。

go 复制代码
package main
​
import "fmt"
​
// Adder 接口定义了 Add 方法
type Adder interface {
    Add(a, b int) int
}
​
// IntAdder 实现了 Adder 接口
type IntAdder struct{}
​
func (IntAdder) Add(a, b int) int {
    return a + b
}
​
// 使用泛型函数调用 Add 方法
func sum[T Adder](a, b int, adder T) int {
    return adder.Add(a, b)
}
​
func main() {
    adder := IntAdder{}
    result := sum(1, 2, adder) // 类型推断,不需要显式指定 IntAdder 为 T
    fmt.Println(result)        // 输出: 3
}
​

IntAdder 隐式实现了 Adder 及接口,因为他定义了接口要求的 Add 方法,这就是隐式接口实现。下面的代码,我们要调用 sum 函数时候,我们不需要显示地指出 IntAdder 是 T 的类型,编译器可以自动判断出来。

这些就是 Go 泛型中一些比较基本的用法和概念,更多的关于泛型的内容不在这里赘述了。

总的来说,在 Go 引入泛型后,Go语言 的函数和数据结构变得更加灵活。在一篇文章里看到一句话说的非常好:如果你经常要分别为不同的类型写完全相同逻辑的代码,那么使用泛型将是最合适的选择。泛型让开发者编写一次代码,适用于多种数据类型,提到了代码的重用性和维护性。

2.1.9 跨平台

Go语言 拥有强大的编译器工具链,其中包括 gobuild、gofmt 等,这些工具在给不用平台的用户提供了一致的体验和操作。Go语言 的标准库包中含有大量的操作系统抽象,如文件操作系统、网络通信等。Go语言 能在Winddows、Linux、MacOS 三个不同的操作系统下进行交叉编译都离不开上面这些工具。

一个简单的例子,如果我们在 MacOS 环境上开发的,而且希望编译一个能在 Windows 上运行的程序,我们可以在 go build 之前加上这么一串代码即可:GOOS=windows GOARCH=amd64 go build。这些特性使得 Go 语言在开发分布式系统和微服务时尤为受欢迎,因为它们允许开发者在一个平台上开发和测试,然后轻松部署到其他平台上运行。

2.2 生态&社区

2.2.1 工具

Go语言 的工具有很多,可以分为一下几类:

  1. 包管理器

    • Go Modules:Go Modules 是 Go语言 的官方包管理工具,用于管理项目的依赖关系。它已经成为 Go 开发中的标准做法,支持版本管理和依赖解析。
    • dep:虽然已经不再被推荐使用,但是在过去,dep 是一个流行的包管理器。
    • godepglide:也曾经是 Go 的包管理工具,但现在已经不再广泛使用。
  2. Web框架

    • Gin:轻量级、高性能的Web框架,可以用在 RESTful API。
    • Echo:简单、快速的 Web 框架,用于构建 Web 应用程序。
    • Beego:完整的 Web 框架,提供路由、模板等功能。
  3. 数据库驱动

    • gorm:流行的 ORM 库,用于操作数据库。
    • sqlx :对标准库 database/sql 的扩展,提供更方便的数据库操作。
  4. DevOps 工具

    • Docker:Go 开发者广泛使用的容器化工具,用于构建、部署和运行应用程序。
    • Kubernetes:用于容器编排和管理的开源平台,Go 语言编写。
  5. 测试框架

    • testing:Go语言 自带的测试框架,支持单元测试和性能测试。
    • testify:扩展了 testing 包,提供更丰富的断言和测试工具。
  6. 其他工具

    • cobra:命令行应用程序开发的库,用于创建命令行工具。
    • viper:用于配置管理的库,支持多种配置格式。

网络上也有很多 Golang 的优秀辅助工具,比如 Lancet(柳叶刀)

Lancet

lancet 受到了 java apache common 包和 lodash.js 的启发,lancet 有很多有帮助的特性。

  • 👏 全面、高效、可复用。
  • 💪 600+ 常用 go 工具函数,支持 string、slice、datetime、net、crypt...
  • 💅 只依赖 go 标准库和 golang.org/x。
  • 🌍 所有导出函数单元测试覆盖率 100%。

官方文档:lancet/README.md at main · duke-git/lancet

2.2.2 开发者生态系统

Golang 的生态系统怎么样?都在使用 Go 做什么,一般搭配着什么来使用呢?这里收集了一些统计图。

1、使用Go做什么?

看来大部分的开发者都是工作原因选择的 Go。

开发人员经常使用 Go 的那些版本?/ 经常使用Java的那些版本?

可以发现Go的新版本非常多人用,大部分人都愿意使用Go的新版本。而与此相反的Java语言就有明显区别。

要知道 Java8 可是 2014年3月18日 发布的,这个版本是一个伟大的里程碑,它为 Java 带来了更现代,更强大的功能。同时这也是不幸的事情,就连 Marit van Dijk (JetBrains 的技术布道师和 Java Champion)都说:

"It's unfortunate to see so many people still using Java 8 (and older). I wonder what's keeping them from upgrading to newer versions and getting access to great new language features, and how we can help them migrate their code to newer Java versions."

译文:很遗憾看到这么多人仍在使用 Java 8(和更早版本)。我想知道是什么阻止了他们升级到新版本,让他们无法使用出色的新语言功能,以及我们如何帮助他们将代码迁移到新的 Java 版本。

从 Go 版本使用情况和 Java 版本使用情况对比发现,Java是 "恋久 " 的、Go是 "追新" 的,这也体现出了Go 具有出色的向后兼容性。

开发人员经常使用那种Go Web框架

Gin 框架是最流行的 Web 开发解决方案,而标准库中的 net/http 软件包仍然保持着自己的地位。可以查看 Go REST 指南,详细了解 Gin 与 net/http 之间的差异。

2.2.3 社区

Golang 的优质中文社区比较稀少。

  1. Go语言中文网:一个问答型的 Go语言 技术交流中文社区,活跃度比较高。
  2. Go Forum:同样是一个问答型的 Go语言 技术交流社区,这是国外的社区
  3. 官方网站:官方的标准文档,官方博客 Go Blog,常见问题解答也在这里。

可以说 Golang 好的社区少得可怜,曾经的一些 GO 社区也 Game Over 了

同时 GitHub 也是学习 Go 的一个好网站,同时也支持多种语言,Go 开发者路线

稀土掘金也是一个可以讨论学习Golang的网站,还有奖励可以拿,很推荐。 尽管社区数量有限,但这些资源仍然可以帮助我们 深入学习和探索 Golang 。

三、高并发&高性能

谈到 Golang 的并发和性能,常常会听到两种声音:

  1. Go 的性能真的高!!!
  2. Go 的性能真的高???

甚至有很多争论的声音。我认为,我们对这两个说法都不要相信,而是应该考虑到,从各自的立场出发的话,他们都是有理由的。我们自己睁眼看,动手做,亲自去体会即可。那谈到 Go 的性能,我们从何谈起呢?从 Goroutine和 Channel 谈起吧!

3.1 协程和管道

Go 的设计初衷之一就是要简化并发编程,于是它引入了协程 (Goroutine) 和管道 (Channel) 这两个特性来控制并发。它们都是什么呢?怎么就简化了并发编程了呢?我们先来看看他们分别都是什么。

p.s: 我这里所提到的协程和管道,特指 Go 语言中的 Goroutine和Channel。

3.1.1 Goroutine

谈到协程,我先简单介绍进程和线程。如果都用一句话解释的话,可理解为:

  1. 进程:操作系统给程序分配资源的基本单元。
  2. 线程:CPU 调度的最小单位,一个进程可拥有多个线程。

那么什么是协程呢?Go 语言中的协程,是一个轻量的、用户级的线程。 由 Go 的运行时进行管理和调度,主要记录着程序的运行状态和上下文信息。我们可以将其理解为一段可以打包程序执行状态的 Go 代码 。 他们大致有如下图所示的关系:

一个进程可以有多个线程,它们可以共享所属进程的内存,不需要内存管理单元处理上下文的切换,既然有了相比于进程更为轻量的线程,那么 Go 为什么还出现了协程呢?大体是因为,每一个线程所占用的内存空间还是比较大的,还有就是 CPU 在多个线程间来回调度的时候,额外的开销还是比较大的。这些在 Go 语言看起来,都不能忍受。

所以它的调度器使用与计算机 CPU 数量相等的线程来减少线程间的频繁调度,就省去了线程间来回调度的额外开销。同时又在线程上面开了多个 Goroutine 来减少执行的开销,提升效率。

就好比,以前一个工厂 (进程) 只有一条流水线 (线程) ,老板想要提升效率,增加了一个工厂中的流水线数量。但是需要工人 (CPU) 来回在几个流水线上加工对应的产品 (执行程序) 。 而现在呢?一个工厂中有几个工人就有几条流水线,每条流水线上有多个产品需要加工,一个工人只负责一条流水线,就省去了工人跑来跑去的麻烦。

3.1.2 Channel

简单了解了什么是 Goroutine,那么什么是 Channel 呢? 由于 Go 语言推崇使用通信的方式来共享内存,底层抽象出了一个更高级的数据结构 Channel 用于协程间的通信,使模块间解耦并且致力于让并发变得更简单。

它是一种用于在多个 Goroutine 之间传递数据的同步机制。它可以灵活的传递各种数据或信号,包括基本数据类型(如浮点数、整数、布尔等)、自定义类型(如结构体、接口等)以及函数类型。

比如下面是利用 Channel 实现的,在两 Goroutine 中交替的输出奇数和偶数:

go 复制代码
func TestAlternatingOutputParity(t *testing.T) {
	// 准备一个等待组,等待输出完 1~10 的数
	wg := sync.WaitGroup{}
	// 准备一个 Channel,用于在两个协程间传递信号
	ch := make(chan struct{})

	// 等待两个协程执行完毕
	wg.Add(2)

	// 这个协程用于输出奇数
	go func() {
		defer wg.Done()

		for i := 1; i <= 10; i += 2 {
			// 打印奇数
			t.Logf("%d ", i)
			// 告知偶数协程打印
			ch <- struct{}{}
			// 等待偶数协程打印完毕
			<-ch
		}
	}()

	// 这个协程用于输出偶数
	go func() {
		defer wg.Done()
		for i := 2; i <= 10; i += 2 {
			// 等待奇数协程打印完毕
			<-ch
			t.Logf("%d ", i)
			// 告知奇数协程打印
			ch <- struct{}{}
		}
	}()

	wg.Wait()
}

执行上面的程序,会得到如下输出:

可以看到,我们利用 Channel,安全的在两个 Goroutine 之间传递了信号,使得并发在较为安全的前提下,变得如此简单。基本了解了 Goroutine和Channel 后,我们再来看看, Go 现在的调度模型,是如何高效调度的。

3.2 调度模型

Go 早期的调度模型是 G-M 调度模型,G 就是上面所述的 Goroutine,M 则是 Go 对操作系统线程的抽象表示,每个 M 应对一个操作系统线程,还有一个特殊的用于操作调度器的 g0 协程。因为存在一些问题,在很早的时候就被废弃了。 后面就演变成了 G-M-P 模型,在 G-M 模型的基础上,引入了处理器 P,用来作为 G 和 M 之间的中介、桥梁 ,每个 M 对应一个 P,主要维护与其关联的 M 的本地协程队列。演变关系如下图所示:

那么在它演变之后,怎么就可以高效的调度了呢?简单说来就是:每一个 P 维护了一个本地队列,关联了一组可调度的协程。使得需要调度的时候,优先从本地去获取一个协程。拿不到的时候才会去全局协程队列捞一批协程进入本地队列,这样就解决了锁竞争和饥饿的问题 ;除此外还有基于协作和基于信号两种抢占式调度方式,解决协程切换防止协程饥饿的问题 ;还有如 Hand Off和Steal Work 等机制使其调度更高效

如上图所示,Go runtime 的调度器维护了一个全局的协程队列,并且给每一个线程 M 上绑定了一个处理器 P。每个 P 的本地又维护了一组协程队列。那么什么是 Hand Off 和 Steal Work 呢?

我们可以将 Steal Work 看做是,某个处理器中没有协程需要处理了,与之绑定的线程空闲了,并且全局也没有需要执行的协程了,那么去帮帮兄弟线程。从兄弟那里去 "偷" 几个协程过来帮他执行。所以通过工作窃取方式,可以对任务进行再分配实现任务的平衡。

而 Hand Off 则是当线程执行某个协程的时候,需要进行阻塞式的系统调用,会将对应的 M 与 P 暂时解绑。避免长时间的调用,导致对应处理器中的其他协程无法调度。

如下图所示:

  • M2 空闲了,全局协程队列也是空的,但是兄弟 M1 的本地还有很多待调度的协程,那么从他那里 "偷" 几个过来,帮助它快速的调度。
  • M3 与对应的处理器解绑了,在进行阻塞式的系统调用。
Hand Off 和 Steal Work .png

当简单了解了 Go语言 runtime 的调度模型,来看看它大致是如何进行内存管理。

3.3 内存管理

要来管理内存,首先得了解 Go 的内存模型是怎样的。

3.3.1 内存模型

我们都知道,计算机拥有物理内存条,比如内存占用太高,不够时可以加一块内存条。但是为了最大化利用物理内存并实现进程间的内存隔离,操作系统将其划分为以页为单位的虚拟内存。因此,程序内部可以直接操作的内存都是虚拟内存,通过内存管理单元 (MMU) 将其映射到物理内存。

在 64 位操作系统中,虚拟内存的大小约为 256TB,远大于物理内存。面对这么大的内存,Go 语言是如何管理它的呢?先来看一幅综述的图:

首先了解一点点前置知识:Go 语言为了防止内存碎片化,采用了分级隔离策略来提高内存的利用率 。将其最小内存管理单元分为了 68个级别的 mspan。了解了这个点,来看看上图。为了提高访问效率、减少锁竞争等问题, Go 语言将其内存分成了由 HeapArena、MCentral、MCache 构成的三级缓存

我们从下至上,依次来看看是哪几层: 我们将物理内存转化为虚拟内存后,Go语言 将这大小为 256TB 的庞大虚拟内存转变成了 222 个大小为 64MB 的 HeapArena。为什么刚好这么多个呢?因为虚拟内存最大为 256TB,而 222*64MB,刚好等于这么大的内存。这所有的 HeapArena 共同构成了 Go 语言的堆内存 MHeap。

然后程序想要分配内存,需要获取 MSpan,因为这是最小的内存管理单元。想要获取 MSpan,我们得从 HeapArena 中获取,但由于有 68个 等级的 MSpan,想要获取对应等级的 MSpan,得挨个遍历才能获取。为了快速得到想要的 MSpan,所以创建了一个中心索引缓存 MCentral。将每个等级的 MSpan,分了需要 GC 和不需要 GC (scan和noscan) 扫描的两条链表,共 134条,为什么是 134 条呢?因为其中有一个等级的 mspan 很特殊 ------ 0级的MSpan ------ 它是直接放入 HeapArena 中的,所以 67*2,只有 134 条。

那么想要获取 MSpan,就优先会从 MCentral 中获取了。但是 MCentral 是全局的资源,是多个协程共享的,需要加锁才能安全的访问。为了防止过多的锁竞争,导致锁饥饿的问题,在每个 M 上添加了一个本地缓存 MCache 缓存,其中包含每一级别的 MSpan 各两个,也是分 scan和noscan 两种,那么就是 68*2,总共 136 组。有了 MCache,这样就不需要每次都从全局中心索引中去获取 MSpan 了。

当你大致了解了 Go 语言的内存模型,来看看它是如何进行内存分配的。

3.3.2 内存分配

Go 把所有需要申请内存的家伙都称为对象 Object,对于直接分配到堆上的对象,Go 会根据对象的大小进行分配。它将其分为三个等级的对象:

  • 微对象:大小为小于 16B 的对象,它会使用线程缓存上的微分配器提高对微对象的分配性能。
  • 小对象:大小为 16B ~ 32KB 的对象,以及所有小于 16B 的指针类型的对象。
  • 大对象:大小为大于 32KB 的对象,申请后直接存放至 HeapArena 中。

进行分配时,根据对象的大小,有不同的操作。如果大小为 0,会直接分配一个固定的地址 zerebase 给它。

其次会检查大小是否在 32KB 以内。 如果是小于 16B 的 tiny 对象,并且不需要被垃圾回收器扫描,它会尝试通过 MCache 中的 tiny 字段来分配地址。

如果 tiny 字段已满,它会将 tiny 放入 MCache 一个 2级 的 MSpan 中,并为 tiny 分配新的内存地址。如果 2级MSpan 也满了,将从 MCentral 中交换一个。

另一种情况是会根据对象的大小,从 MCache 中获取一个能容纳它的最小级别的 MSpan 来分配地址。如果对应级别的 MSpan 满了,则会加锁从 MCentral 中交换一个对应级别的 MSpan。如果连 Mcentral 对应等级的 MSpan 链表也满了,将从 HeapArena 中换一组对应大小的 MSpan。如果这一个 HeapArena 也满了,将向操作系统申请一块新的虚拟内存单元 HeapArena。

最后对于大于 32KB 的对象,首先会计算对象的大小,然后申请对应大小的 0级MSpan 后,直接放入 HeapArena 中。

从上面的分配过程,也能看出 Go 是对内存进行分级和分层管理的,这样不仅有助于提高内存的利用率,对内存的分配效率也有显著的提高。那么分配完内存,如何清理不需要的内存呢?

3.3.3 垃圾回收

这就得来谈谈 Go 的垃圾回收了 (Garbage Collection,也称为 GC) ,Go 目前采用的是 "三色标记法的标记清除 + 混合屏障" 的垃圾回收策略。那么什么是三色标记法呢?什么又是混合屏障呢?

① 三色标记法

三色标记清除法源于标记清除,思路非常简单:扫描内存标记出需要清理的对象,然后在回收掉即可。其中核心是标记,只要我们正确标记了需要清理的无用对象,清扫其实很简单。

而三色标记法是为了后面添加一些屏障保护策略出现的,具体是哪三色呢?

其过程简单来说就是:一开始将所有对象都置为白色,表示暂时无用的对象。然后将所有的 GCRoot 对象放入灰色集合,利用可达性分析扫描 GCRoot 能够到达的对象,将能到达的对象放入灰色集合的同时将扫描完成的对象放入黑色集合。

当灰色集合中的所有对象都被扫描分析完成时,意味着标记结束了,程序中只有白色和黑色两种颜色的对象,然后清理掉还是白色标记的对象即可。

其实这个过程,即使使用普通的 "标记" 方式,也能达到类似的效果,那么为什么还要费尽心思搞成三色标记法呢?其实是为了更好的增加混合屏障,让垃圾清理的效率和标记的安全性做一个平衡。

② 混合屏障

这里提到的混合屏障,包含插入写屏障和删除屏障两种。为什么要引入两种屏障呢?这个核心点还是在于 Go 想要提升垃圾回收的效率

我们可以试想一下,如果垃圾回收的过程和用户程序是串行化执行的。当需要进行垃圾回收的时候,直接暂停所有正在执行的程序,标记清理完成后再继续执行。这个过程叫做 STW (Stop The World) ,如它的名字表明的一样,暂停这个 "世界",我要开始进行垃圾回收了!

这其实就是 Go 最早期的垃圾回收策略。这样串行化的好处很明显:实现简单、安全... 唯一的缺点恐怕就是不够快。但这唯一的缺点也是它致命的缺点,Go 不断迭代的垃圾回收器,都在致力于平衡效率和安全的关系

那么我们继续试想一下:串行太慢,STW 时间太长了。那能不能尽量减少 STW 的时间呢?换成并行怎么样?基于这样的思路,我们将串行 GC 变成了并行 GC。

在三色标记的过程中,用户的程序正和垃圾回收在并发的执行,突然有个还未扫描的灰色对象的下游对象指向了一个已分析扫描完成的黑色对象。这样在此轮扫描结束后,那个乱跑的对象,就会被当做垃圾清理掉,但其实它是一个有用对象。

所以为了限制这样乱跑的对象,就增加了一种 "插入" 写屏障的机制。即当有对象莫名其妙插入黑色对象上时,直接将其设置为灰色,代表一会还要检查它。那么在此轮扫描结束后,最终也能保证不会误清理乱跑掉的对象。

但是 Go 对栈要求要有非常快的响应速度,以供函数调用频繁的入栈和出栈,在栈上使用屏障会影响性能,所以这种屏障只能在堆上使用。如果栈上不能使用屏障机制,那么有堆对象要跑到栈上,或者有栈对象跑到堆上,就可能会出现误清理的情况。

所以引入了删除屏障,配合写屏障一起使用,在堆上开启了混合屏障。

至于为什么呢?稍后解释,先来看看什么是删除屏障。当有一个待扫描对象突然删除了它某一下游对象时,防止跑掉的下游对象被添加到某已扫描的黑色对象上,我们将被删除的下游对象设置为灰色,放入灰色队列中等待分析扫描。

拥有了混合屏障,对于栈空间和堆空间上的对象,有这些可能:

  • 堆对象 -> 堆空间

    • 删除屏障 + 写屏障,都可以保证乱跑的对象是灰色,不会误清理
  • 堆对象 -> 栈空间

    • 会触发删除屏障,保证从堆上删除的对象是灰色,不会误清理
  • 栈对象 -> 堆空间

    • 会触发写屏障,保证插入堆的对象是灰色,不会误清理
  • 栈对象 -> 栈空间

    • 由于都在栈上,不会触发屏障机制,由对应协程栈的生命周期决定对象的生命周期。

所以当你了解了有这几种情况的时候,我们可以简单的理解为:

  • 删除屏障是当有堆对象乱跑到栈上时,保证安全性开启的
  • 写屏障是当有栈对象乱跑到堆上时,保证安全性开启的

所以,有了三色标记法 + 混合屏障机制的垃圾回收器,就可以使得 STW 的时间最少,更加高效的执行垃圾回收了。

四、谈谈使用 Golang 的感悟

当初初学 Golang 的时候,写过一篇文章《为什么很多公司都开始使用Go语言了?》 - 掘金,被 jym 喷的很惨。但不论怎样,仍能够从大佬们的评论中得到一些信息,比如说得最多的:

  1. Golang 适合写运维、中间件、不方便写业务
  2. Golang 的岗位很少,不好找工作。即使公司使用 Golang,也是内部转的。
  3. Golang 的高并发平时真能用到吗?
  4. Golang 的生态和社区不太好,根本没有像 Java Spring 一样的框架。
  5. Golang 的错误处理真令人感到 ex,语法很烂。
  6. ....

其实这些信息都对,我不反驳,但一个人和一件事能存在于世,定有他的作用与道理。语言技术也是一样的,Golang 可能如大家所言。

  • 生态不好,不方便写业务。
  • 岗位很少,不方便找工作。
  • 语法很烂,不方便写代码。
  • 并发再高,根本使用不到。
  • 比 Java 开发慢、比 C++ 性能低、比 Python 基础库要少,甚至后浪 Rust 跃跃欲试。

但就我自己工作了半年来看,现在的生态完全足矣应付企业级的项目开发了,我们团队其中包括核心引擎在内的 80% 的项目都是使用 Golang 编写的,更别谈区区 CRUD 的业务代码不能写了。要是使用 Golang 了,你连 CRUD 的代码都不会写了,阁下是否应该抓紧时间学习了。

在企业里,语言并没有那么重要,重要的是问题的解决方案,团队招人的时候,招的基本都是后端 Title,并没有限定语言为 Golang,如果语言那么重要,干嘛不精确一点。况且据我耳闻,经常听到大家谈论某一技术细节,基本没有拿着 xxx语法、xxx框架的使用来讨论的。

没有烂代码,只有垃圾代码,只有屎山代码,不知道我说这话你认不认同。在公司里,我见过很优秀的 Golang 代码,同样也见过很烂的 Java 代码。这你能说,代码写的烂,语法很特殊,就能让这个人的开发习惯、编码风格从好变成坏?从乐色变成牛逼?

说平时写代码根本用不到它并发的,我只能说你对它还不够熟悉。开箱即用的 Goroutine、Channel、各种同步工具,你都用不明白,还能把什么更高级的并发工具用好呢?我自己写过的两个较大的需求:批量推送、智能创建,列这个名字的目的,就是想说这就是很基础的业务逻辑,但无一没有用到它提供的并发工具,并且最终的性能,都是真金白银跑出来的。

关于其他的,其实我也不想多说,其实它好与不好,一点都不重要。每个人都有缺点和优点,人生如此,技术亦然。 如果你非要跟我争论谁好谁不好,没关系,一切以你为准。

相关推荐
蒙娜丽宁3 天前
Go语言错误处理详解
ios·golang·go·xcode·go1.19
qq_172805594 天前
GO Govaluate
开发语言·后端·golang·go
littleschemer4 天前
Go缓存系统
缓存·go·cache·bigcache
程序者王大川5 天前
【GO开发】MacOS上搭建GO的基础环境-Hello World
开发语言·后端·macos·golang·go
Grassto5 天前
Gitlab 中几种不同的认证机制(Access Tokens,SSH Keys,Deploy Tokens,Deploy Keys)
go·ssh·gitlab·ci
高兴的才哥6 天前
kubevpn 教程
kubernetes·go·开发工具·telepresence·bridge to k8s
少林码僧7 天前
sqlx1.3.4版本的问题
go
蒙娜丽宁7 天前
Go语言结构体和元组全面解析
开发语言·后端·golang·go
蒙娜丽宁7 天前
深入解析Go语言的类型方法、接口与反射
java·开发语言·golang·go
三里清风_7 天前
Docker概述
运维·docker·容器·go