这是我参与「第五届青训营」伴学笔记创作活动的第 2 天
本系列文章试图从一名 Java 开发者(有时也会穿插其他语言)的角度窥探 Go 语言,并以注释的方式提及 Go 与 Java 的一些区别,方便 Java 开发者迅速入门 Go 语言。
这是该系列的第二章,将从并发和依赖管理的角度带领读者进行 Go 语言的工程实践。
在理解什么是“并发编程”前,让我们先理解一下“并发”和“并行”的区别。
我们都知道,对于一个单线程环境,单个程序只能同时执行一个操作,其他操作必须等待该程序完成当前操作后,才能执行。简单来说,如果将程序执行任务当作开车通过高速公路的收费站,程序本身当作高速公路,那么以前的单线程程序便是只有一个车道的高速公路,同一时刻只能有一辆汽车通过收费站,后面的汽车都必须等待前面的汽车。对于很多程序来说,这种单线程的情况很显然是不够用的:考虑一个 Web 服务器程序,当我们与一个用户建立连接并进行通讯时,如果有其他用户也想访问这个网站,那么他就必须等待这个用户和网站结束通讯!这很显然是不可接受的。
对于大部分语言来说,他们使用了一种内核态的,由操作系统提供的解决方案来实现并行 —— 使用线程(Thread)。线程允许你并行的运行多个程序。如果还拿上面的例子距离,就等于是为高速公路开拓了更多的车道,此时,便可以有多个汽车同时通过收费站了。
但是,这种解决方案一定是最好的吗?想象一下,在现实世界,修路是很费钱的,那么对于计算机来说,维护一个线程的运行也是十分昂贵的(单个线程栈可能带来 MB 级别的内存占用!)。因此,在部分情况下,我们可以使用一种更加廉价的解决方案。
这种解决方案就是“并发”。并发与并行不同,其仅是通过对多个不同程序的有效调度,实现了一种在同一时刻完成多个任务的错觉 —— 事实上,同一时刻仍然仅有一个程序在运行。依然拿汽车和收费站举例,假设一辆私家车通过收费站需要 5 秒钟,一辆大卡车通过收费站需要 1 分钟,如果此时一辆大卡车正在通过收费站,后面有多辆小汽车,那么支持“并行”的收费站可能会让大卡车先退出来,让小汽车先通过收费站,然后再继续为大卡车办理通关手续。当然,这只是一个例子,不同语言的并发模型是不同的,也会导致不同的结果。
这种看似是线程实际上不是线程的东西通常被我们叫做“协程”,由于协程本身的特质,其对操作系统的资源占用也可以非常小。
当然,对于并发本身来说,也可以配合并行进行。比如多个协程可以在一个以上的线程上进行调度,提升执行效率。
主流语言都有自己的协程支持,并提供了不同的操作给开发者使用,诸如 Java 19 引入的虚拟线程(Virtual Thread),Kotlin 的协程,Rust,JavaScript 和 C# 的 Async/Await(这可能不太准确,因为 JavaScript 和 C# 的 async/await 关键字本质上只是一个异步任务语法糖),以及 Go 为我们提供的 Goroutine。
与其他大部分语言提供的协程支持相同,Go 的 Goroutine 是用户态的,其协程栈占用仅有 KB 级别,十分节约系统资源;但不同的是,Goroutine 将协程和并发简化到了仅需一个 go
关键字即可完成,而不像其他语言的协程一样及其繁琐复杂。Goroutine 是有栈协程,而不是如 Kotlin 协程那样的无栈协程。
接下来,让我们看一个典型的 Goroutine 例子,来感受一下 Goroutine 的魅力:
package main
import (
"fmt"
"time"
)
func say(s string) {
for i := 0; i < 5; i++ {
time.Sleep(100 * time.Millisecond)
fmt.Println(s)
}
}
func main() {
go say("world")
say("hello")
time.Sleep(time.Second)
}
首先,要注意到,即使你在你的程序中不使用任何一个 go
关键字,也依然存在一个协程在运行:这个协程就是 main
函数自己。
上文的代码中,定义了一个 say
函数,接受一个字符串形参,作用是每隔 100 毫秒打印一次传入的字符串,重复 5 次;接下来,在 main
函数,调用了两次 say
函数,并传入不同的参数 "hello"
和 "world"
,最后,调用 time.Sleep()
函数令当前协程(也就是 main
函数的主协程)休眠 1 秒。与以往不同的是,第一个 say
函数前被标上了一个 go
关键字 —— 这意味着该函数将在一个新的 Goroutine 协程中运行。
来看看输出结果:
hello
world
world
hello
hello
world
world
hello
hello
是不是很意外?看起来 hello
和 world
两个字符串在以未知的顺序交替打印,看上去就好像有两个线程在同时打印字符串一样。
上述代码中的输出结果看上去就好像有两个线程在并行的打印字符串,但事实上,是这样吗?尝试注释掉 time.Sleep(100 * time.Millisecond)
,看看又会有什么结果:
world
world
world
world
world
hello
hello
hello
hello
hello
打印结果看起来又和单线程一样了!这是怎么回事?
事实上,也正如我在上一节所说的,并发只是一种错觉,程序并没有真正的并行运行,也就是说,只有在一段程序有空闲时间的时候,另一端程序才有机会抢过执行权,执行自己。
因此,在上例中,由于我们并没有调用 time.Sleep
来告知其他程序有空闲时间可以给你执行,因此其他程序只能等待这个协程内的程序执行完成,然后才有机会执行自己。因此,我们得到了一段顺序执行的打印输出。
你可能会注意到,在上述代码的 main
中,有一个 time.Sleep(time.Second)
函数将主协程休眠了 1 秒,这有什么用?让我们试试注释掉这段代码(以及上文中的 time.Sleep(100 * time.Millisecond)
),看看输出结果:
hello
hello
hello
hello
hello
只有 hello
,没有 world
!这是怎么回事。
事实上,主协程是一个特殊的协程,如果主协程执行完毕,那么其他子协程也会停下手上的活,直接退出。由于我们注释掉了 time.Sleep(time.Second)
,那么在 say("hello")
函数执行完毕后,程序便会直接退出,不会再等待接着执行的 say("world")
。
因此,当我们有多个子协程执行时,应该等待这些协程全部执行完毕后,再结束主协程。
当然,要想做到这一点,绝不是用 time.Sleep()
这样的函数,因为我们无法获知其他协程的执行时间。实际上 Go 语言标准库为我们提供了更好的解决方案,那就是 WaitGroup
。
看看如下的例子:
func hello(i int) {
println("hello goroutine : " + fmt.Sprint(i))
}
func HelloGoRoutine() {
for i := 0; i < 5; i++ {
go func(j int) {
hello(j)
}(i)
}
time.Sleep(time.Second)
}
func main() {
HelloGoRoutine()
}
一个简单的协程程序,其中 HelloGoRoutine
函数开启了 5 个 go 协程,用于打印输出 1-5 的数字,最后,对主协程进行休眠以确保子协程执行完成。
如果您对这段代码有所疑惑,那么对于:
func(j int) {
hello(j)
}
这部分代码,其实是 Go 的匿名函数语法,他创建了一个未定义名称的函数,声明了一个 int
类型的形参 j
,并在该函数的函数体内调用了 hello
函数。接下来在其后面添加()
并传入实参 i
以直接调用这个匿名函数。
为了保证子协程执行完毕后主协程才会退出,改造 HelloGoRoutine
函数:
func HelloGoRoutine() {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < 5; i++ {
go func(j int) {
defer wg.Done()
hello(j)
}(i)
}
wg.Wait()
}
time.Sleep(time.Second)
被移除,取而代之的是 wg.Wait()
,除此之外,函数还增加了许多其他调用,它们都是什么意思?让我们一步一步解析:
var wg sync.WaitGroup
声明了一个名为 wg
的 WaitGroup
变量;WaitGroup
实例的 Add
方法,传入数字 5
;WaitGroup
实例的 Done
方法;WaitGroup
实例的 Wait
方法。如果您还不了解 defer
关键字,其实该关键字代表“在函数末尾执行”,被 defer
关键字标注的代码会以 先进后出 的顺序被移动到函数末尾执行,这就意味着:
defer fmt.Println("1")
defer fmt.Println("2")
fmt.Println("3")
实际上等价于:
fmt.Println("3")
fmt.Println("2")
fmt.Println("1")
defer
关键字常用于资源释放等用处:比如,我们打开了一个 IO 流,需要在函数结束时释放流(调用 Close
方法),这时,我们便可以直接在打开的流实例下直接填写:
defer stream.Close()
来确保流一定会在函数结束时释放,避免遗忘。
回到开头,WaitGroup
其实内部维护了一个计数器,并以如下方式工作:
Add
方法,向 WaitGroup
的计数器添加指定值;
Wait
方法阻塞当前协程,这会使得协程陷入无限的等待;
Done
方法使 WaitGroup
内部的计数器 -1,直到计数器值为 0 时,先前被阻塞的协程便会被释放,继续执行接下来的代码或是直接结束运行。
因此,上述代码为 WaitGroup
的计数器 +5,随后阻塞主协程,当所有 5 个子协程纷纷调用 Done
方法后,主协程便会被释放,然后结束程序运行。怎么样,是不是很优雅?
讲完了 WaitGroup
,让我们将一个经典的并发安全问题,运行如下代码:
var x int64
func addWithoutLock() {
for i := 0; i < 2000 ; i++ {
x += 1
}
}
func main(){
x = 0
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock:",x)
}
这是一个很简单的程序,由 5 个协程各自将 x
值 +2000。按理来说,期望的输出结果应该是 10000
。
但是运行后,得到结果(并不是每次都会得到这样的结果,如果没能复现建议多试几次):
WithoutLock: 9446
这很奇怪,本应输出 10000
的代码,为什么输出结果始终不足 10000
呢。
这就要扯到并发安全这件事情了。事实上对一个变量进行修改并不是一个 原子操作。原子操作意味着,该操作是一步到位的,但是修改一个变量需要经过取出变量值,修改取出的值,将值存回变量三个步骤,考虑这么一个情况:i
值当前是 1000
,当协程 1 将这个值取出时,协程 2 和协程 3 已经将该值取出,修改为 1001
和 1002
并存回 i
,这时当协程 1 将他拿到的那个 1000
+1 并存回变量 i
时,变量又变回了 1001
,于是就产生了错误。
为了解决该问题,我们引入并发锁 sync.Mutex
,修改代码:
var (
x int64
lock sync.Mutex
)
func addWithLock() {
for i := 0; i < 2000; i++ {
lock.Lock()
x += 1
lock.Unlock()
}
}
func main() {
x = 0
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock:", x)
}
并发锁的原理是,当第一次调用 Lock
方法时,什么都不会发生,但当第二次调用 Lock
方法时,该调用便会立刻阻塞协程,直到有程序调用 Unlock
方法解锁。
并发锁的使用要十分谨慎,请尽在有并发安全的代码中使用并发锁,因为并发锁的使用实际上将并行的程序串行化,会导致显著降低性能;同时,不当的锁使用也可能导致死锁(DeadLock)等问题发生。
除了采用锁的方式保证并发安全,对于单个值的修改而不是一段代码的执行,更推荐引入标准库中的 sync/atomic
包来实现。其他语言也有类似的机制来确保并发安全,例如 Java 的 AtomicInteger
等类。
你是否有想过如何在协程与协程之间进行数据传输和通信?你可能会说,这不是很容易的事吗,直接维护一个数组或者结构体实例之类的,然后不同协程按需取用内部的数据就行啦。但是事实上,这种通过共享内存实现的数据通信是十分危险的,可能会遇到位置的问题。Go 语言建议我们通过其内置的 通道(Channel) 功能来进行数据通信,从而共享内存。
可以通过如下方式声明一个 Channel:
ch := make(chan int)
声明了一个名为 ch
的无缓冲 Channel,并指定 Channel 的传输数据类型为 int
;
无缓存 Channel 意味着,一个数据的发送必须等待另一端代码的接收,如果没有人接收发送的数据,那么发送端便会被永远阻塞。
可以通过如下方式声明一个带缓冲区的 Channel:
ch := make(chan int, 3)
声明一个名为 ch
的有三个缓冲区的 Channel,并指定 Channel 的传输数据类型为 int
;
这意味着,该 Channel 内可以有最多 3 个数据未被接收方接收,此时,发送方可以直接发送数据而不必收到阻塞,如果超出缓冲区(在本例中为发送第四个数据,且缓冲区被前三个数据占满),则依旧会被阻塞。
可以通过如下方式向 Channel 发送数据:
ch <- v // 假设 v 是一个 int 变量
然后,通过如下方式从 Channel 中接受数据:
v := <-ch // 赋值给 v 变量
可以通过使用 for range
的方式来从一个 Channel 中取出所有数据:
for i := range ch {
fmt.Println(i)
}
for range
将会始终读取一个 Channel 中发送的数据,直到该 Channel 被关闭。
当一个 Channel 被使用完毕,应当调用 close
函数关闭 Channel:
close(ch)
以下代码是一个由三个协程组成的程序:第一个协程负责生成 0-9 的数字并通过 Channel 发送给第二个协程;第二个协程接收收到的数字,并将数字进行平方计算,然后将结果发送给主协程;主协程遍历接收到的结果并输出:
func CalSquare() {
src := make(chan int)
dest := make(chan int, 3)
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i
}
}()
go func() {
defer close(dest)
for i := range src {
dest <- i * i
}
}()
for i := range dest {
println(i)
}
}
要想了解依赖管理,首先我们必须知道为什么需要它。考虑这么一个情况:你的代码不可能只引用自系统库和语言标准库,很大概率的,你要使用来自网络的,第三方的,其他开发者的依赖库,例如某些 ORM 框架,HTTP 框架等。这些依赖库来自不同站点的不同存储库,拥有不同的版本;这些依赖库本身也拥有许多依赖…… 这些事情总能使得每一位程序员焦头烂额。为了解决依赖的管理问题,Go 引入了依赖管理。
依赖管理并不是 Go 的独创,早在 Go 之前,就有许多不同语言的依赖管理工具了,例如 Java 的 Maven/Gradle,.NET 的 NuGet,Python 的pip,Rust 的 Cargo,JavaScript(NodeJS)的 npm/yarn/pnpm。对于 Go 的依赖管理来说,经历了 GOPATH,Go Vender,Go Module 三部分的演进。
最初,Go 直接将依赖库源码扔进 GOPATH
的 src
文件夹以作为项目依赖。
GOPATH
是一个环境变量,指向一个目录,作为项目的编译产出目录和依赖目录。这是一个公共环境变量,也就意味着,所有项目都依赖于同一个 GOPATH
,这就会导致这样的问题:如果项目 A 依赖于依赖库 Lib
的版本 1,而项目 B 依赖于同一个依赖库的版本 2,由于 GOPATH
并没有任何版本管理措施,就会导致编译出错。
于是,Go 引入了 Go Vendor
,通过在项目目录下新建 vendor
文件夹,并存放依赖库文件副本的方式,使得不同项目可以依赖不同的依赖库版本,解决了版本冲突的问题。
值得一提的是,如果无法在 vendor
文件夹中找到项目所需的依赖文件,那么 Go 会尝试回到 GOPATH
查找。
Go Vendor 的引入看似解决了版本问题,但是实际上依然造成了问题:如果项目 A 引入了 项目 B 和项目 C 作为依赖库,而后两者又共同依赖了项目 D 的不同版本,那么由于 B,C,D 作为项目 A 的依赖依然被同时存在同一个 vendor
文件夹中,依旧导致了依赖冲突。
因此经过了六道轮回,终于,Go 采用了和 JavaScript(NodeJS)的 npm 类似的包管理方案 —— 这就是 Go Module。
Go Module 通过项目路径中的 go.mod
文件(类似于 npm 的 pakcage.json
声明所需依赖的名称和版本范围),然后,通过 go.sum
文件记录项目实际使用的依赖和版本(类似于 npm 的 package-lock.json
)。
我们没有必要像 Java 的 Maven/Gradle 那样手动编辑配置文件指定依赖,Go 为我们提供了 go get
和 go mod
两条指令来方便的添加和移除项目中的依赖。
一个合法的 go.mod
文件可能长这样:
module example/project/app
go 1.16
require (
example/lib1 v1.0.2
example/lib2 v1.0.0 // indirect
example/lib3 v0.1.0-20190725025543-5a5fe074e612
example/lib4 0.0.0-20180306012644-bacd9c7efldd // indirect
example/lib5/v3 v3.0.2
example/lib6 v3.2.0+incompatible
)
首先,module example/project/app
标识了依赖管理的基本单元;
然后,go 1.16
指定了 Go 原生库(标准库)的版本,此处我们指定版本为 1.16
require
内则指定了单元依赖,格式是 [Module Path] [Version/Pseudo-version]
对于一个依赖项,我们首先指定其名称(路径),然后指定所需的版本。版本号应当按照语义化版本(MAJOR.MINOR.PATCH
)的格式填入,例如 v1.0.2
;
或者,我们可以填入一个基于 commit 的伪版本,代表我们需要来自某个 commit 的依赖库版本,它的格式是 vx.0.0-yyyymmddhhmmss-abcdefgh1234
,其中 yyyymmddhhmmss
是提交 Commit 的时间,而 abcdefgh1234
则是该 commit 的哈希值。
有的依赖可能会使用 // indirect
注释标识,这意味着该依赖并非由项目直接引入,而是透过其他依赖间接引入(例如 A 项目引入了 B 依赖,B 依赖依赖于依赖 C,那么依赖 C 就是 项目 A 的间接依赖)
有的依赖可能会在版本末尾添加 +incompatible
标识,这是为了兼容非语义化版本所致。
如上所述,我们的依赖可能来自世界各地的不同依赖库,他们可能来自不同的代码托管网站,例如 GitHub,GitLab,这些代码托管网站可能无法接受来自数以百万计的 Go 开发者的依赖拉取请求。为了解决这个问题,Go 引入了 Proxy 系统,通过指定 Proxy 服务器,优先从 Proxy 服务器拉取依赖,这不仅减轻了源站负担,也保证了依赖的可用性,避免依赖库开发者删库跑路。
可以通过指定 GOPROXY
环境变量的方式指定 Proxy 服务器,其值为一个由逗号分隔的网址列表,例如 "https://proxy1.cn, https://proxy2.cn, direct"
。当需要拉取依赖时,Go 便会按顺序从依赖服务器拉取代码,如果找不到指定的依赖,那么就前往下一个依赖服务器拉取,直到前往源站(即direct
)拉取代码。
对于中国大陆的朋友们,不妨可以试试 https://goproxy.cn/
和 https://goproxy.io
这两个 Proxy 服务器,可以大幅加速依赖拉取速度。
go get
是一个命令行指令,可用于添加和移除依赖,在项目目录执行它以为项目配置依赖:
基本语法:go get example.org/pkg????
其中,example.org/pkg
是所需依赖的仓库地址,????
可以取以下值:
值得一提的是,在过去,go get
指令还可用于安装二进制可执行程序,但是在 Go 1.17 后,使用 go get
指令安装可执行包的操作已被弃用,取而代之的是 go install
指令。阅读 Deprecation of ‘go get’ for installing executables – The Go Programming Language 以获得更多信息。
go mod
是一个命令行指令,可用于初始化项目和管理依赖,在项目目录执行它以为项目配置依赖:
go mod init
,初始化项目,这将创建 go.mod 文件,类似于 npm 的 npm init
go mod download
,下载模块到本地缓存go mod tidy
,添加需要的依赖,删除不需要的依赖(有点类似于 apt autoremove
)通常,对于一个玩具项目,我们喜欢将其写完后直接部署到生产环境,有 Bug 在修,但是,这对于企业开发来说是完全不能忍受的,因为一个小小的 Bug 便可带来天文数字的损失。因此,在程序上线前进行测试对于企业开发来说便是一个必要的活动。
通常,有多种测试方法可以使用,例如回归测试,集成测试,单元测试,而单元测试(Unit Test)是成本最低,覆盖率最高的测试方法。所谓单元测试,便是为代码的每一个模块,函数定制单独的测试,保证输入指定值后输出正常值。通过多个单元测试合并运作,我们便可得知项目的每一个细节都在正确运行,最终得知项目整体运作正常。
Go 内置单元测试支持。所有以 _test.go
结尾的代码会被 Go 识别为单元测试文件。
一个单元测试函数的函数名应当以 Test
开头,并包含 *testing.T
形参。
可通过 func TestMain(m *testing.M)
函数对测试数据进行初始化,并调用 m.Run()
运行单元测试。
以下代码是一个简单的单元测试例子,测试 HelloTom
函数是否正常返回值 Tom
:
// In xxx.go:
func HelloTom() string {
return "Jerry"
}
// In xxx_test.go:
func TestHelloTom(t *testing.T) {
output := HelloTom()
expectOutput := "Tom"
if output != expectOutput {
t.Errorf("Expected %s do not match actual %s", expectOutput, output)
}
}
并使用 go test
指令运行单元测试,得到运行失败的输出:
=== RUN TestHelloTom
xxx_test.go:9: Expected Tom do not match actual Jerry
--- FAIL: TestHelloTom (0.00s)
当然,我们也可以引入社区提供的依赖库来加快单元测试开发,诸如通过 testify/assert
库进行覆盖率测试,或通过 bouk/monkey
库对数据进行 Mock。此处不再赘述。
有时,我们想要测量一个函数执行不同次数,或是在不同环境下执行函数的性能,这时,就需要进行基准测试。Go 内置基准测试支持。
以下代码模拟了一个负载均衡的例子,在 10 个服务器中随机返回数据,我们将对 Select
方法分别进行串行和并行的基准测试:
// In xxx.go:
import "math/rand"
var ServerIndex [10]int
func InitServerIndex() {
for i := 0; i < 10; i++ {
ServerIndex[i] = i + 100
}
}
func Select() int {
return ServerIndex[rand.Intn(10)]
}
// In xxx_test.go:
package main
import "testing"
func BenchmarkSelect(b *testing.B) {
InitServerIndex()
b.ResetTimer() // 重置计数器是因为 InitServerIndex 不应包含在测试时间内
for i := 0; i < b.N; i++ {
Select()
}
}
func BenchmarkSelectParallel(b *testing.B) {
InitServerIndex()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Select()
}
})
}
并使用 go test -bench=
指令运行基准测试,得到结果:
GOROOT=F:\Program Files\Go #gosetup
GOPATH=C:\Users\shaok\go #gosetup
"F:\Program Files\Go\bin\go.exe" test -c -o C:\Users\shaok\AppData\Local\Temp\GoLand\___gobench_helloWorld.test.exe helloWorld #gosetup
C:\Users\shaok\AppData\Local\Temp\GoLand\___gobench_helloWorld.test.exe -test.v -test.paniconexit0 -test.bench . -test.run ^$ #gosetup
goos: windows
goarch: amd64
pkg: helloWorld
cpu: AMD Ryzen 7 5800H with Radeon Graphics
BenchmarkSelect
BenchmarkSelect-16 164467417 7.332 ns/op
BenchmarkSelectParallel
BenchmarkSelectParallel-16 26950542 43.85 ns/op
PASS
根据基准测试结果,我们可以进行针对性的优化。
在这一部分,字节内部课:Go 语言入门 – 工程实践通过使用高性能 go web 框架 Gin 配合 MVC(Model-View-Controller)模式,开发了一个有一个路由的简易论坛后端。
该文章部分内容来自于以下课程或网页:
This work is licensed under CC BY-SA 4.0