过去打CTF的时候就接触过go语言,一直没有系统性地学习过,故抽空好好学习了一下Go语言基础,后面应该还会学习Go后端开发的相关内容。
Go
本文的大部分内容来自于欢迎使用 Go 指南-A tour of go,一部分内容来自于作者查阅网络资料。教程其中的练习代码为作者自己编写,如有问题欢迎与作者讨论更优写法。一些作者学习过程中认为重要的部分进行了加粗标明。
包
每个 Go 程序都由包构成。
程序从 main
包开始运行。
本程序通过导入路径 "fmt"
和 "math/rand"
来使用这两个包。
1 | package main |
按照约定,包名与导入路径的最后一个元素一致。例如,"math/rand"
包中的源码均以 package rand
语句开始。
导出名
在 Go 中,如果一个名字以大写字母开头,那么它就是已导出的。例如,Pizza
就是个已导出名,Pi
也同样,它导出自 math
包。
pizza
和 pi
并未以大写字母开头,所以它们是未导出的。
在导入一个包时,你只能引用其中已导出的名字。 任何「未导出」的名字在该包外均无法访问。
执行代码,观察错误信息。
要修复错误,请将 math.pi
改名为 math.Pi
,然后再试着执行一次。
函数
函数可接受零个或多个参数。
1 | package main |
在本例中,add
接受两个 int
类型的参数。
注意类型在变量名的 后面。
变量
var
语句用于声明一系列变量。和函数的参数列表一样,类型在最后。
如例中所示,var
语句可以出现在包或函数的层级。
1 | package main |
函数外的每个语句都 必须 以关键字开始(var
、func
等),因此 :=
结构不能在函数外使用。
基本类型
Go 的基本类型有
1 | bool |
本例展示了几种类型的变量。 和导入语句一样,变量声明也可以「分组」成一个代码块。
int
、uint
和 uintptr
类型在 32-位系统上通常为 32-位宽,在 64-位系统上则为 64-位宽。当你需要一个整数值时应使用 int
类型, 除非你有特殊的理由使用固定大小或无符号的整数类型。
类型转换
表达式 T(v)
将值 v
转换为类型 T
。
一些数值类型的转换:
1 | var i int = 42 |
或者,更加简短的形式:
1 | i := 42 |
与 C 不同的是,Go 在不同类型的项之间赋值时需要显式转换。
常量
常量的声明与变量类似,只不过使用 const
关键字。
常量可以是字符、字符串、布尔值或数值。
常量不能用 :=
语法声明。
1 | package main |
for 循环
Go 只有一种循环结构:for
循环。
基本的 for
循环由三部分组成,它们用分号隔开:
- 初始化语句:在第一次迭代前执行
- 条件表达式:在每次迭代前求值
- 后置语句:在每次迭代的结尾执行
初始化语句通常为一句短变量声明,该变量声明仅在 for
语句的作用域中可见。
一旦条件表达式求值为 false
,循环迭代就会终止。
1 | package main |
if 判断
Go 的 if
语句与 for
循环类似,表达式外无需小括号 ( )
,而大括号 { }
则是必须的。
和 for
一样,if
语句可以在条件表达式前执行一个简短语句。
该语句声明的变量作用域仅在 if
之内。在 if
的简短语句中声明的变量同样可以在对应的任何 else
块中使用。
1 | package main |
练习:循环与函数
为了练习函数与循环,我们来实现一个平方根函数:给定一个数 x,我们需要找到一个数 z 使得 z² 尽可能地接近 x。
计算机通常使用循环来计算 x 的平方根。从某个猜测的值 z 开始,我们可以根据 z² 与 x 的近似度来改进 z,产生一个更好的猜测:
1 | z -= (z*z - x) / (2*z) |
重复调整的过程,猜测的结果会越来越精确,得到的答案也会尽可能接近实际的平方根。
请在提供的 func Sqrt
中实现它。无论输入是什么,可以先猜测 z 为 1。 首先,重复计算 10 次并连续打印每次的 z 值。观察对于不同的 x 值(1、2、3 …), 你得到的答案是如何逼近结果的,以及猜测改进的速度有多快。
提示:用类型转换或浮点数语法来声明并初始化一个浮点数值:
1 | z := 1.0 |
然后,修改循环条件,使得当值停止改变(或改变非常小)的时候退出循环。 观察迭代次数大于还是小于 10。尝试改变 z 的初始猜测,如 x 或 x/2。 你的函数结果与标准库中的 math.Sqrt 有多接近?
( 注: 如果你对该算法的细节感兴趣,上面的 z² − x 是 z² 到它所要到达的值(即 x) 的距离,除数 2z 为 z² 的导数,我们通过 z² 的变化速度来改变 z 的调整量。 这种通用方法叫做牛顿法, 它对很多函数,特别是平方根而言非常有效。)
1 | package main |
switch 分支
switch
语句是编写一连串 if - else
语句的简便方法。它运行第一个 case
值 值等于条件表达式的子句。
Go 的 switch
语句类似于 C、C++、Java、JavaScript 和 PHP 中的,不过 Go 只会运行选定的 case
,而非之后所有的 case
。 在效果上,Go 的做法相当于这些语言中为每个 case
后面自动添加了所需的 break
语句。在 Go 中,除非以 fallthrough
语句结束,否则分支会自动终止。 Go 的另一点重要的不同在于 switch
的 case
无需为常量,且取值不限于整数。
switch
的 case
语句从上到下顺次执行,直到匹配成功时停止。
无条件的 switch
同 switch true
一样。
这种形式能将一长串 if-then-else
写得更加清晰。
defer 推迟
defer 语句会将函数推迟到外层函数返回之后执行。
推迟调用的函数其参数会立即求值,但直到外层函数返回前该函数都不会被调用。
1 | package main |
推迟调用的函数调用会被压入一个栈中。 当外层函数返回时,被推迟的调用会按照后进先出的顺序调用。
指针
Go 拥有指针。指针保存了值的内存地址。
类型 *T
是指向 T
类型值的指针,其零值为 nil
。
1 | var p *int |
&
操作符会生成一个指向其操作数的指针。
1 | i := 42 |
*
操作符表示指针指向的底层值。
1 | fmt.Println(*p) // 通过指针 p 读取 i |
这也就是通常所说的「解引用」或「间接引用」。
与 C 不同,Go 没有指针运算。
结构体
一个 结构体(struct
)就是一组 字段(field)。
1 | package main |
结构体指针
结构体字段可通过结构体指针来访问。
如果我们有一个指向结构体的指针 p
那么可以通过 (*p).X
来访问其字段 X
。 不过这么写太啰嗦了,所以语言也允许我们使用隐式解引用,直接写 p.X
就可以。
1 | package main |
结构体字面量
使用 Name:
语法可以仅列出部分字段(字段名的顺序无关)。
特殊的前缀 &
返回一个指向结构体的指针。
切片
每个数组的大小都是固定的。而切片则为数组元素提供了动态大小的、灵活的视角。 在实践中,切片比数组更常用。
类型 []T
表示一个元素类型为 T
的切片。.
切片通过两个下标来界定,一个下界和一个上界,二者以冒号分隔:
1 | a[low : high] |
它会选出一个半闭半开区间,包括第一个元素,但排除最后一个元素。
以下表达式创建了一个切片,它包含 a
中下标从 1 到 3 的元素:
1 | a[1:4] |
切片类似数组的引用
切片就像数组的引用 切片并不存储任何数据,它只是描述了底层数组中的一段。
更改切片的元素会修改其底层数组中对应的元素。
和它共享底层数组的切片都会观测到这些修改。
切片字面量
切片字面量类似于没有长度的数组字面量。
这是一个数组字面量:
1 | [3]bool{true, true, false} |
下面这样则会创建一个和上面相同的数组,然后再构建一个引用了它的切片:
1 | []bool{true, true, false} |
在进行切片时,你可以利用它的默认行为来忽略上下界。
切片下界的默认值为 0,上界则是该切片的长度。
对于数组
1 | var a [10]int |
来说,以下切片表达式和它是等价的:
1 | a[0:10] |
切片拥有 长度 和 容量。
切片的长度就是它所包含的元素个数。
切片的容量是从它的第一个元素开始数,到其底层数组元素末尾的个数。
切片 s
的长度和容量可通过表达式 len(s)
和 cap(s)
来获取。
注意:切片是不可寻址,不占内存的,即是便宜的
nil 切片
切片的零值是 nil
。
nil 切片的长度和容量为 0 且没有底层数组。
用 make 创建切片
切片可以用内置函数 make
来创建,这也是你创建动态数组的方式。
make
函数会分配一个元素为零值的数组并返回一个引用了它的切片:
1 | a := make([]int, 5) // len(a)=5 |
要指定它的容量,需向 make
传入第三个参数:
1 | b := make([]int, 0, 5) // len(b)=0, cap(b)=5 |
向切片追加元素
为切片追加新的元素是种常见的操作,为此 Go 提供了内置的 append
函数。内置函数的文档对该函数有详细的介绍。
1 | func append(s []T, vs ...T) []T |
append
的第一个参数 s
是一个元素类型为 T
的切片,其余类型为 T
的值将会追加到该切片的末尾。
append
的结果是一个包含原切片所有元素加上新添加元素的切片。
当 s
的底层数组太小,不足以容纳所有给定的值时,它就会分配一个更大的数组。 返回的切片会指向这个新分配的数组。
(要了解关于切片的更多内容,请阅读文章 Go 切片:用法和本质。)
1 | package main |
range 遍历
for
循环的 range
形式可遍历切片或映射。
当使用 for
循环遍历切片时,每次迭代都会返回两个值。 第一个值为当前元素的下标,第二个值为该下标所对应元素的一份副本(这意味着改变第二个值并不会改变原值,如果想改变原值应该使用下标访问原切片)。
1 | package main |
可以将下标或值赋予 _
来忽略它。
1 | for i, _ := range pow |
若你只需要索引,忽略第二个变量即可。
1 | for i := range pow |
练习:切片
实现 Pic
。它应当返回一个长度为 dy
的切片,其中每个元素是一个长度为 dx
,元素类型为 uint8
的切片。当你运行此程序时,它会将每个整数解释为灰度值 (好吧,其实是蓝度值)并显示它所对应的图像。
图像的解析式由你来定。几个有趣的函数包括 (x+y)/2
、x*y
、x^y
、x*log(y)
和 x%(y+1)
。
(提示:需要使用循环来分配 [][]uint8
中的每个 []uint8
。)
(请使用 uint8(intValue)
在类型之间转换;你可能会用到 math
包中的函数。)
1 | package main |
map 映射
map
映射将键映射到值。
映射的零值为 nil
。nil
映射既没有键,也不能添加键。(当var m map[string]int
定义map时初值为nil
)
make
函数会返回给定类型的映射,并将其初始化备用。
1 | package main |
映射的字面量和结构体类似,只不过必须有键名。
1 | package main |
若顶层类型与内层类型相同,那么你可以在字面量的元素中省略它。
1 | package main |
修改映射
在映射 m
中插入或修改元素:
1 | m[key] = elem |
获取元素:
1 | elem = m[key] |
删除元素:
1 | delete(m, key) |
通过双赋值检测某个键是否存在:
1 | elem, ok = m[key] |
若 key
在 m
中,ok
为 true
;否则,ok
为 false
。
若 key
不在映射中,则 elem
是该映射元素类型的零值。
注:若 elem
或 ok
还未声明,你可以使用短变量声明:
1 | elem, ok := m[key] |
练习:映射
实现 WordCount
。它应当返回一个映射,其中包含字符串 s
中每个“单词”的个数。 函数 wc.Test
会为此函数执行一系列测试用例,并输出成功还是失败。
你会发现 strings.Fields 很有用。
1 | package main |
函数值
函数也是值。它们可以像其他值一样传递。
函数值可以用作函数的参数或返回值。
1 | package main |
函数闭包
Go 函数可以是一个闭包。闭包是一个函数值,它引用了其函数体之外的变量。 该函数可以访问并赋予其引用的变量值,换句话说,该函数被“绑定”到了这些变量。
例如,函数 adder
返回一个闭包。每个闭包都被绑定在其各自的 sum
变量上。闭包捕获的变量(如 sum
)会被 “逃逸”到堆(heap)上,由 Go 的垃圾回收器(GC)管理。
如果没有显式解除引用(如 pos = nil
),闭包变量会一直存活到程序结束(例如全局变量持有闭包时)。
1 | package main |
练习:斐波纳契闭包
让我们用函数做些好玩的。
实现一个 fibonacci
函数,它返回一个函数(闭包),该闭包返回一个斐波纳契数列 (0, 1, 1, 2, 3, 5, …)。
1 | package main |
方法
Go 没有类。不过你可以为类型定义方法。
方法就是一类带特殊的 接收者 参数的函数。
方法接收者在它自己的参数列表内,位于 func
关键字和方法名之间。
在此例中,Abs
方法拥有一个名字为 v
,类型为 Vertex
的接收者。
1 | package main |
你也可以为非结构体类型声明方法。
在此例中,我们看到了一个带 Abs
方法的数值类型 MyFloat
。
你只能为在同一个包中定义的接收者类型声明方法,而不能为其它别的包中定义的类型 (包括 int
之类的内置类型)声明方法。
(译注:就是接收者的类型定义和方法声明必须在同一包内。)
1 | package main |
指针类型的接收者
你可以为指针类型的接收者声明方法。
这意味着对于某类型 T
,接收者的类型可以用 *T
的文法。 (此外,T
本身不能是指针,比如不能是 *int
。)
例如,这里为 *Vertex
定义了 Scale
方法。
指针接收者的方法可以修改接收者指向的值(如这里的 Scale
所示)。 由于方法经常需要修改它的接收者,指针接收者比值接收者更常用。
试着移除第 16 行 Scale
函数声明中的 *
,观察此程序的行为如何变化。
若使用值接收者,那么 Scale
方法会对原始 Vertex
值的副本进行操作。(对于函数的其它参数也是如此。)Scale
方法必须用指针接收者来更改 main
函数中声明的 Vertex
的值。
1 | package main |
带指针参数的函数必须接受一个指针:
1 | var v Vertex |
而接收者为指针的的方法被调用时,接收者既能是值又能是指针:
1 | var v Vertex |
对于语句 v.Scale(5)
来说,即便 v
是一个值而非指针,带指针接收者的方法也能被直接调用。 也就是说,由于 Scale
方法有一个指针接收者,为方便起见,Go 会将语句 v.Scale(5)
解释为 (&v).Scale(5)
。
反之也一样:
接受一个值作为参数的函数必须接受一个指定类型的值:
1 | var v Vertex |
而以值为接收者的方法被调用时,接收者既能为值又能为指针:
1 | var v Vertex |
这种情况下,方法调用 p.Abs()
会被解释为 (*p).Abs()
。
选择值或指针作为接收者
使用指针接收者的原因有二:
首先,方法能够修改其接收者指向的值。
其次,这样可以避免在每次调用方法时复制该值。若值的类型为大型结构体时,这样会更加高效。
在本例中,Scale
和 Abs
接收者的类型为 *Vertex
,即便 Abs
并不需要修改其接收者。
通常来说,所有给定类型的方法都应该有值或指针接收者,但并不应该二者混用。 (我们会在接下来几页中明白为什么。)
1 | package main |
接口
接口类型 的定义为一组方法签名。
接口类型的变量可以持有任何实现了这些方法的值。
注意: 示例代码的第 22 行存在一个错误。由于 Abs
方法只为 *Vertex
(指针类型)定义,因此 Vertex
(值类型)并未实现 Abser
。
1 | package main |
类型通过实现一个接口的所有方法来实现该接口。既然无需专门显式声明,也就没有“implements”关键字。
隐式接口从接口的实现中解耦了定义,这样接口的实现可以出现在任何包中,无需提前准备。
因此,也就无需在每一个实现上增加新的接口名称,这样同时也鼓励了明确的接口定义。
接口值
接口也是值。它们可以像其它值一样传递。
接口值可以用作函数的参数或返回值。
在内部,接口值可以看做包含值和具体类型的元组:
1 | (value, type) |
接口值保存了一个具体底层类型的具体值。
接口值调用方法时会执行其底层类型的同名方法。
1 | package main |
即便接口内的具体值为 nil,方法仍然会被 nil 接收者调用。
在一些语言中,这会触发一个空指针异常,但在 Go 中通常会写一些方法来优雅地处理它(如本例中的 M
方法)。
注意: 保存了 nil 具体值的接口其自身并不为 nil。
1 | package main |
nil 接口值既不保存值也不保存具体类型。
为 nil 接口调用方法会产生运行时错误,因为接口的元组内并未包含能够指明该调用哪个 具体 方法的类型。
1 | package main |
输出报错如下:
(<nil>, <nil>)
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x492bb9]goroutine 1 [running]:
main.main()
/tmp/sandbox3136123901/prog.go:12 +0x19
空接口
指定了零个方法的接口值被称为 空接口:
1 | interface{} |
空接口可保存任何类型的值。(因为每个类型都至少实现了零个方法。)
空接口被用来处理未知类型的值。例如,fmt.Print
可接受类型为 interface{}
的任意数量的参数。
类型断言
类型断言 提供了访问接口值底层具体值的方式。
1 | t := i.(T) |
该语句断言接口值 i
保存了具体类型 T
,并将其底层类型为 T
的值赋予变量 t
。
若 i
并未保存 T
类型的值,该语句就会触发一个 panic。
为了 判断 一个接口值是否保存了一个特定的类型,类型断言可返回两个值:其底层值以及一个报告断言是否成功的布尔值。
1 | t, ok := i.(T) |
若 i
保存了一个 T
,那么 t
将会是其底层值,而 ok
为 true
。
否则,ok
将为 false
而 t
将为 T
类型的零值,程序并不会产生 panic。
请注意这种语法和读取一个映射时的相同之处。
类型选择
类型选择 是一种按顺序从几个类型断言中选择分支的结构。
类型选择与一般的 switch 语句相似,不过类型选择中的 case 为类型(而非值), 它们针对给定接口值所存储的值的类型进行比较。
1 | switch v := i.(type) { |
类型选择中的声明与类型断言 i.(T)
的语法相同,只是具体类型 T
被替换成了关键字 type
。
此选择语句判断接口值 i
保存的值类型是 T
还是 S
。在 T
或 S
的情况下,变量 v
会分别按 T
或 S
类型保存 i
拥有的值。在默认(即没有匹配)的情况下,变量 v
与 i
的接口类型和值相同。
Stringer
1 | type Stringer interface { |
Stringer
是一个可以用字符串描述自己的类型。fmt
包(还有很多包)都通过此接口来打印值。
练习:Stringer
通过让 IPAddr
类型实现 fmt.Stringer
来打印点号分隔的地址。
例如,IPAddr{1, 2, 3, 4}
应当打印为 "1.2.3.4"
。
1 | package main |
错误
Go 程序使用 error
值来表示错误状态。
与 fmt.Stringer
类似,error
类型是一个Go语言标准库中定义的接口:
1 | type error interface { |
(与 fmt.Stringer
类似,fmt
包也会根据对 error
的实现来打印值。)
通常函数会返回一个 error
值,调用它的代码应当判断这个错误是否等于 nil
来进行错误处理。
实现 error
接口:任何实现了 Error()
方法的类型都可以作为错误。
1 | i, err := strconv.Atoi("42") |
error
为 nil 时表示成功;非 nil 的 error
表示失败。
1 | package main |
练习:错误
从之前的练习(练习:循环与函数)中复制 Sqrt
函数,修改它使其返回 error
值。
Sqrt
接受到一个负数时,应当返回一个非 nil 的错误值。复数同样也不被支持。
创建一个新的类型
1 | type ErrNegativeSqrt float64 |
并为其实现
1 | func (e ErrNegativeSqrt) Error() string |
方法使其拥有 error
值,通过 ErrNegativeSqrt(-2).Error()
调用该方法应返回 "cannot Sqrt negative number: -2"
。
注意: 在 Error
方法内调用 fmt.Sprint(e)
会让程序陷入死循环。可以通过先转换 e
来避免这个问题:fmt.Sprint(float64(e))
。这是为什么呢?
修改 Sqrt
函数,使其接受一个负数时,返回 ErrNegativeSqrt
值。
1 | package main |
Readers
io
包指定了 io.Reader
接口,它表示数据流的读取端。
Go 标准库包含了该接口的许多实现,包括文件、网络连接、压缩和加密等等。
io.Reader
接口有一个 Read
方法:
1 | func (T) Read(b []byte) (n int, err error) |
Read
用数据填充给定的字节切片并返回填充的字节数和错误值。在遇到数据流的结尾时,它会返回一个 io.EOF
错误。
示例代码创建了一个 strings.Reader
并以每次 8 字节的速度读取它的输出。
1 | package main |
练习:Reader
实现一个 Reader
类型,它产生一个 ASCII 字符 'A'
的无限流。
1 | package main |
练习:rot13Reader
有种常见的模式是一个 io.Reader
包装另一个 io.Reader
,然后通过某种方式修改其数据流。
例如,gzip.NewReader
函数接受一个 io.Reader
(已压缩的数据流)并返回一个同样实现了 io.Reader
的 *gzip.Reader
(解压后的数据流)。
编写一个实现了 io.Reader
并从另一个 io.Reader
中读取数据的 rot13Reader
,通过应用 rot13 代换密码对数据流进行修改。
rot13Reader
类型已经提供。实现 Read
方法以满足 io.Reader
。
1 | package main |
注意io.Copy函数会一直调用Reader直到遇到抛出错误(
io.EOF
),故推荐在Read()函数中直接将数据读到arr中再对其进行处理,即一个io.Reader
包装另一个io.Reader
,这样更加方便
图像
image
包定义了 Image
接口:
1 | package image |
注意: Bounds
方法的返回值 Rectangle
实际上是一个 image.Rectangle
,它在 image
包中声明。
(请参阅文档了解全部信息。)
color.Color
和 color.Model
类型也是接口,但是通常因为直接使用预定义的实现 image.RGBA
和 image.RGBAModel
而被忽视了。这些接口和类型由 image/color
包定义。
1 | package main |
练习:图像
还记得之前编写的图片生成器 吗?我们再来编写另外一个,不过这次它将会返回一个 image.Image
的实现而非一个数据切片。
定义你自己的 Image
类型,实现必要的方法并调用 pic.ShowImage
。
Bounds
应当返回一个 image.Rectangle
,例如 image.Rect(0, 0, w, h)
。
ColorModel
应当返回 color.RGBAModel
。
At
应当返回一个颜色。上一个图片生成器的值 v
对应于此次的 color.RGBA{v, v, 255, 255}
。
1 | package main |
类型参数
可以使用类型参数编写 Go 函数来处理多种类型。 函数的类型参数出现在函数参数之前的方括号之间。
1 | func Index[T comparable](s []T, x T) int |
此声明意味着 s
是满足内置约束 comparable
的任何类型 T
的切片。 x
也是相同类型的值。
comparable
是一个有用的约束,它能让我们对任意满足该类型的值使用 ==
和 !=
运算符。在此示例中,我们使用它将值与所有切片元素进行比较,直到找到匹配项。 该 Index
函数适用于任何支持比较的类型。
1 | package main |
泛型类型
除了泛型函数之外,Go 还支持泛型类型。 类型可以使用类型参数进行参数化,这对于实现通用数据结构非常有用。
此示例展示了能够保存任意类型值的单链表的简单类型声明。
作为练习,请为此链表的实现添加一些功能。
1 | package main |
注意到代码中直接使用将字面量赋值给指针来实现C语言家族中类似分配内存的操作,故好奇Go语言是如何进行内存分配和内存回收的。一般来说局部变量存储在栈中,全局变量存储在堆中,Go语言是否也是如此?那么复杂的结构变量是否也保存于堆中?这些变量的内存什么时候被回收?
在 Go 语言中,内存分配和回收是由 编译器 和 运行时(runtime) 共同管理的,主要通过 栈(stack) 和 堆(heap) 来实现。Go 的垃圾回收(GC, Garbage Collection)机制会自动管理堆内存,开发者无需手动释放内存(类似于 Java/Python,而不同于 C/C++)。下面详细解释 Go 的内存管理机制:
1. Go 的内存分配:栈 vs 堆
Go 编译器使用 逃逸分析(Escape Analysis) 来决定变量应该分配在 栈 还是 堆 上:
- 栈(Stack):存储 局部变量(如函数内的变量),生命周期与函数调用绑定,函数返回时自动释放。
- 堆(Heap):存储 长期存活的对象 或 可能被其他作用域引用的对象,由 垃圾回收器(GC) 自动回收。
逃逸分析(Escape Analysis)
Go 编译器在编译阶段会分析变量的 生命周期,决定它是否 逃逸(escape) 到堆上:
- 未逃逸(栈分配):变量仅在函数内部使用,不会被外部引用。
- 逃逸到堆(堆分配):
- 变量被 返回 或 传递到外部作用域(如返回指针、闭包引用)。
- 变量 大小未知 或 动态增长(如
slice
、map
、channel
等)。 - 变量被 全局变量或 goroutine 共享。
示例:逃逸分析
1 | func foo() *int { |
运行 go build -gcflags="-m"
可以查看逃逸分析结果:
1 | ./main.go:3:6: moved to heap: x |
2. Go 的垃圾回收(GC)
Go 使用 并发标记-清除(Concurrent Mark-Sweep, CMS) 垃圾回收器,主要特点:
- 三色标记法:标记可达对象(白色→灰色→黑色),清除不可达对象。
- 并发执行:大部分 GC 工作与用户代码并发运行,减少 STW(Stop-The-World)时间。
- 自动触发:当堆内存达到一定阈值时触发 GC。
GC 触发条件
- 手动触发:调用
runtime.GC()
(通常不需要)。 - 自动触发:
- 堆内存增长到
GOGC
设定的阈值(默认100%
,即堆大小翻倍时触发)。 - 定时检查(2 分钟一次,防止内存泄漏)。
- 堆内存增长到
GC 对性能的影响
- 短暂停(STW):在标记阶段结束时短暂暂停所有 goroutine。
- 可调优:
GOGC=50
(更频繁 GC,减少内存占用)GOGC=200
(减少 GC 频率,提高吞吐量)
3. 复杂结构(如 struct
、slice
、map
)的内存分配
如果结构体逃逸,则分配在堆上:
1
2
3func createList() *List[int] {
return &List[int]{val: 42} // 逃逸到堆
}如果结构体未逃逸,则分配在栈上:
1
2
3
4func sum(a, b int) int {
list := List[int]{val: a + b} // 栈分配
return list.val
}
slice
、map
、channel
的内存分配
它们底层是 引用类型,数据存储在堆上,但变量本身(指针)可能在栈上:
1
2
3
4func makeSlice() []int {
s := make([]int, 100) // 底层数组逃逸到堆
return s
}
4. 内存何时被回收?
- 栈变量:函数返回时立即释放。
- 堆变量:当 不再被引用 时,由 GC 自动回收。
- 特殊情况:
- 循环引用:Go 的 GC 能检测并回收循环引用(不同于传统引用计数)。
- 文件/网络资源:需要手动关闭(
defer file.Close()
),GC 不管这些。
5. 最佳实践
- 减少逃逸:避免不必要的指针返回,减少堆分配。
- 复用内存:使用
sync.Pool
缓存对象,减少 GC 压力。 - 监控 GC:
GODEBUG=gctrace=1
查看 GC 日志。runtime.ReadMemStats
获取内存统计。
总结
特性 | Go 语言 |
---|---|
栈分配 | 局部变量,未逃逸时使用 |
堆分配 | 逃逸变量、动态结构(slice /map )、全局变量 |
GC 机制 | 并发标记-清除(CMS),自动管理堆内存 |
回收时机 | 不可达时由 GC 自动回收 |
手动管理 | 仅适用于文件/网络等非内存资源 |
Go 的内存管理让开发者更专注于业务逻辑,而无需手动管理内存(类似 Java/Python),同时通过逃逸分析和高效 GC 提供接近 C 的性能。
Go 协程
Go 程(goroutine)是由 Go 运行时管理的轻量级线程。
1 | go f(x, y, z) |
会启动一个新的 Go 协程并执行
1 | f(x, y, z) |
f
, x
, y
和 z
的求值发生在当前的 Go 协程中,而 f
的执行发生在新的 Go 协程中。
Go 程在相同的地址空间中运行,因此在访问共享的内存时必须进行同步。sync
包提供了这种能力,不过在 Go 中并不经常用到,因为还有其它的办法——
信道
信道是带有类型的管道,你可以通过它用信道操作符 <-
来发送或者接收值。
1 | ch <- v // 将 v 发送至信道 ch。 |
(“箭头”就是数据流的方向。)
和映射与切片一样,信道在使用前必须创建:
1 | ch := make(chan int) |
默认情况下,发送和接收操作在另一端准备好之前都会阻塞。这使得 Go 程可以在没有显式的锁或竞态变量的情况下进行同步。
以下示例对切片中的数进行求和,将任务分配给两个 Go 程。一旦两个 Go 程完成了它们的计算,它就能算出最终的结果。
1 | package main |
带缓冲的信道
信道可以是 带缓冲的。将缓冲长度作为第二个参数提供给 make
来初始化一个带缓冲的信道:
1 | ch := make(chan int, 100) |
仅当信道的缓冲区填满后,向其发送数据时才会阻塞。当缓冲区为空时,接受方会阻塞。
range 和 close
发送者可通过 close
关闭一个信道来表示没有需要发送的值了。接收者可以通过为接收表达式分配第二个参数来测试信道是否被关闭:若没有值可以接收且信道已被关闭,那么在执行完
1 | v, ok := <-ch |
此时 ok
会被设置为 false
。
循环 for i := range c
会不断从信道接收值,直到它被关闭(且没有可接受的值)。
注意: 只应由发送者关闭信道,而不应油接收者关闭。向一个已经关闭的信道发送数据会引发程序 panic。
还要注意: 信道与文件不同,通常情况下无需关闭它们。只有在必须告诉接收者不再有需要发送的值时才有必要关闭,例如终止一个 range
循环。
1 | package main |
select 语句
select
语句使一个 Go 程可以等待多个通信操作。
select
会阻塞到某个分支可以继续执行为止,这时就会执行该分支。当多个分支都准备好时会随机选择一个执行。
1 | package main |
默认选择
当 select
中的其它分支都没有准备好时,default
分支就会执行。
为了在尝试发送或者接收时不发生阻塞,可使用 default
分支:
1 | select { |
练习:等价二叉查找树
不同二叉树的叶节点上可以保存相同的值序列。例如,以下两个二叉树都保存了序列 1,1,2,3,5,8,13
。
在大多数语言中,检查两个二叉树是否保存了相同序列的函数都相当复杂。 我们将使用 Go 的并发和信道来编写一个简单的解法。
本例使用了 tree
包,它定义了类型:
1 | type Tree struct { |
1. 实现 Walk
函数。
2. 测试 Walk
函数。
函数 tree.New(k)
用于构造一个随机结构的已排序二叉查找树,它保存了值 k
, 2k
, 3k
, …, 10k
。
创建一个新的信道 ch
并且对其进行步进:
1 | go Walk(tree.New(1), ch) |
然后从信道中读取并打印 10 个值。应当是数字 1, 2, 3, …, 10.
3. 用 Walk
实现 Same
函数来检测 t1
和 t2
是否存储了相同的值。
4. 测试 Same
函数。
Same(tree.New(1), tree.New(1))
应当返回 true
,而 Same(tree.New(1), tree.New(2))
应当返回 false
。
Tree
的文档可在这里找到。
1 | package main |
注意:这里代码中必须关闭信道才能让
ok
值为false,否则程序将一直等待读取信道,造成死锁
sync.Mutex
我们已经看到信道非常适合在各个 Go 程间进行通信。
但是如果我们并不需要通信呢?比如说,若我们只是想保证每次只有一个 Go 程能够访问一个共享的变量,从而避免冲突?
这里涉及的概念叫做 互斥(mutualexclusion)* ,我们通常使用 互斥锁(Mutex) 这一数据结构来提供这种机制。
Go 标准库中提供了 sync.Mutex
互斥锁类型及其两个方法:
Lock
Unlock
我们可以通过在代码前调用 Lock
方法,在代码后调用 Unlock
方法来保证一段代码的互斥执行。参见 Inc
方法。
我们也可以用 defer
语句来保证互斥锁一定会被解锁。参见 Value
方法。
1 | package main |
WaitGroup
在做下一个练习前,需要引入sync.WaitGroup
sync.WaitGroup
用于等待多个 Goroutine 完成。
同步多个 Goroutine:
1 | package main |
以上代码,执行输出结果如下:
1 | Worker 1 started |
练习:Web 爬虫
在这个练习中,我们将会使用 Go 的并发特性来并行化一个 Web 爬虫。
修改 Crawl
函数来并行地抓取 URL,并且保证不重复。
提示: 你可以用一个 map 来缓存已经获取的 URL,但是要注意 map 本身并不是并发安全的!
注意:如果主线程没有实现等待子线程完成的机制,在主线程运行结束后,主线程连同所有子线程都会直接退出!故我们需要
sync.WaitGroup
1 | package main |