2 Go基础
2.1 Go的25个关键字和36个预定义标识符
Go语言的关键字保留很少,只有25个,之所以刻意地将 Go 代码中的关键字保持的这么少,是为了简化在编译过程第一步中的代码解:
break | default | func | interface | select |
case | defer | go | map | struct |
chan | else | goto | package | switch |
const | fallthrough | if | range | type |
continue | for | import | return | var |
除了以上介绍的这些关键字,Go 语言还有 36 个预定义标识符,其中包含了基本类型的名称和一些基本的内置函数(第 6.5 节),它们的作用都将在接下来的章节中进行进一步地讲解。
append | bool | byte | cap | close | complex | complex64 | complex128 | uint16 |
copy | false | float32 | float64 | imag | int | int8 | int16 | uint32 |
int32 | int64 | iota | len | make | new | nil | panic | uint64 |
println | real | string | true | uint | uint8 | uintptr |
2.2 包的概念、导入与可见性
包是结构化代码的一种方式:每个程序都由包(通常简称为 pkg)的概念组成,可以使用自身的包或者从其它包中导入内容,同一个包的go文件必须在同一目录。
一个包可以由许多以 .go 为扩展名的源文件组成,因此文件名和包名一般来说都是不相同的。必须源文件中非注释的第一行指明这个文件属于哪个包: 1
package main
如果想要构建一个程序,则包和包内的文件都必须以正确的顺序进行编译。包的依赖关系决定了其构建顺序。
属于同一个包的源文件必须全部被一起编译,一个包即是编译时的一个单元,因此根据惯例,项目的每个目录都建议只包含一个包。
如果对一个包进行更改或重新编译,所有引用了这个包的客户端程序都必须全部重新编译。
Go 中的包模型采用了显式依赖关系的机制来达到快速编译的目的,编译器会从后缀名为 .o 的对象文件(需要且只需要这个文件)中提取传递依赖类型的信息。
如果 A.go
依赖 B.go
,而B.go
又依赖 C.go
:
- 编译 C.go, B.go, 然后是 A.go.
- 为了编译 A.go, 编译器读取的是 B.o 而不是 C.o.
这种机制对于编译大型的项目时可以显著地提升编译速度。每一段代码只会被编译一次
2.2.1 包的导入
包的导入使用import
,下面使用因式分解关键字导入包 1
2
3
4
5import (
"fmt"
"os"
pt1 "gotest/src/pkgtest1" //本地包导入,<moudle名>/<路径>/<包名>
)
2.2.2 可见性规则
go中的变量分为了包级变量(全局变量)、包级静态变量(静态变量)、局部变量:
- 大写字母开头:外部包能够直接访问(全局变量或大写开头函数)
- 小写字母开头:外部包不能访问(静态变量或小写开头函数)
因此,在导入一个外部包后,能够且只能够访问该包中导出的对象。
假设在包 pack1
中我们有一个变量或函数叫做Thing
(以 T 开头,所以它能够被导出),那么在当前包中导入pack1
包,Thing
就可以像面向对象语言那样使用点标记来调用:pack1.Thing
(pack1 在这里是不可以省略的)。 1
2
3import pack1
pack1.Thing()
如果你导入了一个包却没有使用它,则会在构建程序时引发错误,如
imported and not used: xx
,这正是遵循了 Go 的格言:“没有不必要的代码!”。
2.3 函数
这是定义一个函数最简单的格式:
你可以在括号 () 中写入 0 个或多个函数的参数(使用逗号 , 分隔),每个参数的名称后面必须紧跟着该参数的类型。1
func functionName()
main()
函数是每一个可执行程序所必须包含的,一般来说都是在启动后第一个执行的函数(如果有 init() 函数则会先执行该函数)。如果你的 main 包的源代码没有包含 main() 函数,则会引发构建错误undefined: main.main
go中的main() 函数既没有参数,也没有返回类型(与 C 家族中的其它语言恰好相反)。如果你不小心为 main() 函数添加了参数或者返回类型,将会引发构建错误:
1
func main must have no arguments and no return values results.
go的函数体必须使用大括号 {} 括起来。,而且左大括号
{
必须与方法的声明放在同一行,这是编译器的强制规定,否则你在使用 gofmt 时就会出现错误提示:>(这是因为编译器会产生 func main() ; 这样的结果,很明显这是错误的) > >究其原因是因为:Go 语言虽然看起来不使用分号作为语句的结束,但实际上这一过程是由编译器自动完成,因此才会引发像上面这样的错误1
build-error: syntax error: unexpected semicolon or newline before {
- 符合规范的函数一般写成如下的形式:
1
2
3func functionName(parameter_list) (return_value_list) {
…
}parameter_list
的形式为(param1 type1, param2 type2, …)
return_value_list
的形式为(ret1 type1, ret2 type2, …)
一个函数可以拥有多返回值,返回类型之间需要使用逗号分割,并使用小括号 () 将它们括起来,如:
2.4 注释
go中的注释沿用了c/c++
的注释风格,并且在此基础上提供了一个命令godoc
,该命令从** Go 程序和包文件中提取顶级声明的首行注释以及每个对象的相关注释,并生成相关文档。**
一般用法
go doc package
获取包的文档注释,例如:go doc fmt 会显示使用 godoc 生成的 fmt 包的文档注释。go doc package/subpackage
获取子包的文档注释,例如:go doc container/list。go doc package function
获取某个函数在某个包中的文档注释,例如:go doc fmt Printf 会显示有关 fmt.Printf() 的使用说明。
这个工具只能获取在 Go 安装目录下 ../go/src 中的注释内容。此外,它还可以作为一个本地文档浏览 web 服务器。在命令行输入 godoc -http=:6060,然后使用浏览器打开 http://localhost:6060 后,你就可以看到本地文档浏览服务器提供的页面。
2.5 类型
使用 var
声明的变量的值会自动初始化为该类型的零值。类型定义了某个变量的值的集合与可对其进行操作的集合。 >var 类型推导本质是编译器根据初始化表达式的类型(或常量默认类型)确定变量类型。这种设计简化了代码,同时保持了静态类型的安全性。
- 类型可以是基本类型,如:int、float、bool、string;
- 结构化的(复合的),如:struct、array、切片 (slice)、map、通道 (channel);
- 只描述类型的行为的,如:interface。
结构化的类型没有真正的值,它使用 nil
作为默认值(在 Objective-C 中是 nil,在 Java 中是 null,在 C 和 C++ 中是 NULL 或 0)。值得注意的是,Go 语言中不存在类型继承。
函数也可以是一个确定的类型,就是以函数作为返回类型。这种类型的声明要写在函数名和可选的参数列表之后 1
2//返回一个typeFunc类型的函数类型
func FunctionName (a typea, b typeb) typeFuncret
返回: 1
return var
使用 type 关键字可以定义你自己的类型,你可能想要定义一个结构体(第 10 章),但是也可以定义一个已经存在的类型的别名,如: 1
type IZ int
1
2
3
4
5type (
IZ int
FZ float64
STR string
)
注意:由于Golang具有强大的类型系统,因此不允许在表达式中混合使用数字类型(例如加,减,乘,除等),并且不允许在两个混合类型之间执行赋值类型。
2.6 Go 程序的一般结构
go的编写结构:
- 在完成包的
import
之后,开始对常量、变量和类型的定义或声明。 - 如果存在
init()
函数的话,则对该函数进行定义(这是一个特殊的函数,每个含有该函数的包都会首先执行这个函数)。 - 如果当前包是
main
包,则定义main()
函数。 - 然后定义其余的函数,首先是类型的方法,接着是按照
main()
函数中先后调用的顺序来定义相关函数,如果有很多函数,则可以按照字母顺序来进行排序。
Go 程序的执行(程序启动)顺序如下:
- 按顺序导入所有被 main 包引用的其它包,然后在每个包中执行如下流程:
- 如果该包又导入了其它的包,则从第一步开始递归执行,但是每个包只会被导入一次。
- 然后以相反的顺序在每个包中初始化常量和变量,如果该包含有 init() 函数的话,则调用该函数。
- 在完成这一切之后,main 也执行同样的过程,最后调用 main() 函数开始执行程序。
2.7 类型转换
在必要以及可行的情况下,一个类型的值可以被转换成另一种类型的值。由于 Go 语言不存在隐式类型转换,因此所有的转换都必须显式说明,就像调用一个函数一样(类型在这里的作用可以看作是一种函数): 1
valueOfTypeB = typeB(valueOfTypeA)
2.8 常量
常量使用关键字 const 定义,用于存储不会改变的数据。存储在常量中的数据类型只可以是布尔型、数字型(整数型、浮点型和复数)和字符串型。常量的定义格式:const identifier [type] = value
,例如: 1
const Pi = 3.14159
:=
声明初始化符号,因为已经用const
定义为常量了
- 显式类型定义: const b string = "abc"
- 隐式类型定义: const b = "abc"
但是这种隐式定义必须是能够在编译时就能够确定的;你可以在其赋值表达式中涉及计算过程,但是所有用于计算的值必须在编译期间就能获得,否则就会出错 1
2
3
4正确的做法:
const c1 = 2/3
错误的做法:
const c2 = getNumber() // 引发构建错误: getNumber() used as value
- 常量可以并行赋值
1
2
3
4
5
6const beef, two, c = "eat", 2, "veg"
const Monday, Tuesday, Wednesday, Thursday, Friday, Saturday = 1, 2, 3, 4, 5, 6
const (
Monday, Tuesday, Wednesday = 1, 2, 3
Thursday, Friday, Saturday = 4, 5, 6
) - 常量还可以用作枚举:
1
2
3
4
5const (
Unknown = 0
Female = 1
Male = 2
)
2.9 变量
声明变量的一般形式是使用var
关键字:var identifier type
Go 和许多编程语言不同,它在声明变量时将变量的类型放在变量的名称之后。Go 为什么要选择这么做呢?
首先,它是为了避免像 C 语言中那样含糊不清的声明形式,例如:
int* a, b;
。在这个例子中,只有 a 是指针而 b 不是。如果你想要这两个变量都是指针,则需要将它们分开书写。而在 Go 中,则可以很轻松地将它们都声明为指针类型1
var a, b *int
其次,这种语法能够按照从左至右的顺序阅读,使得代码更加容易理解。
1
2
3var a int
var b bool
var str string
Go 编译器的智商已经高到可以根据变量的值来自动推断其类型,这有点像 Ruby 和 Python 这类动态语言,只不过它们是在运行时进行推断,而 Go 是在编译时完成推断过程。因此,你还可以省略类型使用下面的这些形式来声明及初始化变量: 1
2
3var a = 15
var b = false
var str = "Go says hello to the world!"
2.9.1 值类型和引用类型
在go中的引用类型不同于C++
中的引用定义,在 Go 语言中,指针就是引用类型,两者区别如下:
特性 | 值类型(Value Types) | 引用类型(Reference Types) |
---|---|---|
存储内容 | 直接存储数据本身 | 存储指向数据的指针(内存地址) |
赋值行为 | 创建完整的副本(深拷贝) | 复制指针(浅拷贝),共享底层数据 |
函数传参 | 传递数据副本(函数内修改不影响原值) | 传递指针副本(函数内修改影响原数据) |
比较操作 | 可比较(内容相同即相等) | 不可比较(除非与 nil 比较) |
值类型:
整型(int, int8, int16, int32, int64)、无符号整型uint, uint8, uint16, uint32, uint64, uintptr、浮点型float32, float64、复数型complex64, complex128、布尔型bool、字符型byte (=uint8), rune (=int32)、数组、结构体(Struct)、字符串(String)
引用类型:切片(Slice)、映射(Map)、通道(Channel)、函数(Function)、接口(Interface)、指针(Pointer)(未初始化时,则初始化为 nil)
2.9.2 :=初始化声明操作符
我们知道可以在变量的初始化时省略变量的类型而由系统自动推断,因此有什么我们对一个变量直接做初始化,此时var
关键句就显得多余了。所以当声明并初始化一个变量的时候可以使用:=
初始化声明操作符,这样可以省略var
1
a,b:=50,false
:=
不允许对以及声明过的变量使用,换一句话就是说:=
只允许声明事初始化 - :=
只允许对局部变量使用,不允许对全局变量进行声明赋值
2.10 特殊函数:init()函数
变量除了可以在全局声明中初始化,也可以在 init() 函数中初始化,该函数是隐身声明的。这是一类非常特殊的函数,它不能够被人为调用,而是在每个包完成初始化后自动执行,并且执行优先级比 main() 函数高。
每个go文件可以包含多个 init() 函数,同一个go文件中的 init() 函数会按照从上到下的顺序执行;另外,如果一个包有多个go文件包含 init() 函数的话,则官方鼓励但不保证以文件名的顺序调用。初始化总是以单线程并且按照包的依赖关系顺序执行。
一个可能的用途是在开始执行程序之前对数据进行检验或修复,以保证程序状态的正确性。
3. 基本类型和运算符
go中的基本类型与C++差不多,都有布尔型bool、数字型(int和float32/64)和字符型(byte)。Go 语言支持整型和浮点型数字,并且原生支持复数,其中位的运算采用补码。
Go 也有基于架构的类型,例如:int、uint 和 uintptr。这些类型的长度都是根据运行程序所在的操作系统类型所决定的:
- int 和 uint 在 32 位操作系统上,它们均使用 32 位(4 个字节),在 64 位操作系统上,它们均使用 64 位(8 个字节)。
- uintptr 的长度被设定为足够存放一个指针即可。
Go 语言中没有 float 类型。(Go语言中只有 float32 和 float64)没有 double 类型。
3.1 复数类型
这里值得一提的是go引入的复数complex
。Go 拥有以下复数类型 1
2complex64 (32 位实数和虚数)
complex128 (64 位实数和虚数)1
2
3
4fvar c1 complex64 = 5 + 10i
//在使用格式化说明符时,可以使用 %v 来表示复数,但当你希望只表示其中的一个部分的时候需要使用 %f
fmt.Printf("The value is: %v", c1)
// 输出: 5 + 10i1
c = complex(re, im)
real(c)
和 imag(c)
可以分别获得相应的实数和虚数部分。
复数支持和其它数字类型一样的运算。当你使用等号 == 或者不等号 != 对复数进行比较运算时,注意对精确度的把握。cmath 包中包含了一些操作复数的公共方法。如果你对内存的要求不是特别高,最好使用 complex128 作为计算类型,因为相关函数都使用这个类型的参数。
3.2 随机数
一些像游戏或者统计学类的应用需要用到随机数。math/rand
包实现了伪随机数的生成。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23package main
import (
"fmt"
"math/rand"
"time"
)
func main() {
for i := 0; i < 10; i++ {
a := rand.Int() //随机生成Int范围随机数
fmt.Printf("%d / ", a)
}
for i := 0; i < 5; i++ {
r := rand.Intn(8) //随机生成[0,8)随机数
fmt.Printf("%d / ", r)
}
fmt.Println()
timens := int64(time.Now().Nanosecond())
rand.Seed(timens) //随机数种子
for i := 0; i < 10; i++ {
fmt.Printf("%2.2f / ", 100*rand.Float32())
}
}
你可以使用 rand.Seed(value) 函数来提供伪随机数的生成种子,一般情况下都会使用当前时间的纳秒级数字
3.3 字符类型
严格来说,字符类型并不是 Go 语言的一个类型,字符只是整数的特殊用例。byte 类型是 uint8 的别名,对于只占用 1 个字节的传统 ASCII 编码的字符来说,完全没有问题。例如:var ch byte = 'A'
;字符使用单引号括起来。
在 ASCII 码表中,'A' 的值是 65,而使用 16 进制表示则为 41,所以下面的写法是等效的: 1
2//`\x`是十六进制表示法
var ch byte = 65 或 var ch byte = '\x41'\
后面紧跟着长度为 3 的 八进制数,例如:\377。
不过 Go 同样支持 Unicode(UTF-8),因此字符同样称为 Unicode 代码点或者 runes,并在内存中使用 int 来表示。在文档中,一般使用格式 U+hhhh
来表示,其中 h 表示一个 16 进制数。其实 rune 也是 Go 当中的一个类型,并且是 int32 的别名。
在书写 Unicode 字符时,需要在 16 进制数之前加上前缀 \u
或者 \U
。
因为 Unicode 至少占用 2 个字节,所以我们使用 int16 或者 int 类型来表示。如果需要使用到 4 字节,则会加上
\U
前缀;前缀\u
则总是紧跟着长度为 4 的 16 进制数,前缀\U
紧跟着长度为 8 的 16 进制数。
3.4 字符串类型
字符串是go中的值类型,字符串的底层结构:在 Go 的运行时(runtime)内部,一个字符串变量实际上由一个结构体表示,它包含两个组件:
- 一个指向底层字节数组([]byte)的指针-->只读
- 一个表示字符串长度(字节数)的整数,**
1
2
3
4
5// 这是一个高度简化的内部表示
type stringStruct struct {
str *byte // 指向字节数组的指针
len int // 字符串的长度(字节数)
}
Go 语言提供了丰富的字符串处理功能,主要通过以下方式:
内置操作:
+,+=, ==, <, >, len(), []索引
等** strings 包:**提供大部分字符串操作函数
strconv 包:字符串与基本类型的转换
unicode/utf8 包:处理 Unicode 和 UTF-8 编码
regexp 包:正则表达式处理
内置操作:
1 | var b = "trluper" |
3.4.1 详解字符串的底层
上面提到string的底层字节数组是只读的,因为其具有不可变性:字符串一旦创建,其底层字节数组的内容就无法被修改。任何看似修改的操作(如拼接、替换)实际上都是创建了一个包含新数据的新字符串,也是为什么字符串是值类型的。这就意味着:
- 安全性与并发性:
- 不可变性意味着字符串可以在多个 goroutine 之间安全地共享,无需加锁。
- 因为底层指针的缘故,作为函数参数传递时,成本很低,因为只需要复制指针和长度(大约 16 字节),而不是整个数据。
高效的子串操作:由于字符串不可变,截取子串(slicing)的成本极低,因为子串可以和原字符串共享底层数组。
字符串 vs. 字节切片 ([]byte):如何选择?
特性 | 字符串 (string) | 字节切片 ([]byte) |
---|---|---|
内容 | 只读的字节序列 | 可读写的字节序列 |
编码假设 | 通常被解释为 UTF-8 文本 | 原始的、未解释的字节 |
用途 | 存储和表示文本信息 | I/O 操作(网络、文件)、加密、处理二进制协议 |
可变性 | 不可变 | 可变 |
性能 | 共享安全,子串操作快 | 协程不安全,可直接修改,避免分配新内存 |
语法支持 | 有字面量(" "),支持 + 拼接 | 无字面量,使用 append 添加元素 |
选择指南:
当你处理的是文本(如消息、文件名、JSON)时,使用 string。
当你处理的是原始数据(从网络读取的数据、文件内容、加密数据)或需要修改内容时,使用 []byte。
在两者间转换是常见的操作,但要注意转换带来的内存复制开销。
3.4.2 strings包支持的函数
函数分类 | 函数签名示例 | 描述 | 示例 |
---|---|---|---|
Contains |
func Contains(s, substr string) bool |
检查字符串 s 是否包含子串 substr | strings.Contains("Gopher", "Go") // true |
HasPrefix / HasSuffix |
func HasPrefix(s, prefix string) bool |
检查字符串是否以指定前缀/后缀开头/结尾 | strings.HasSuffix("main.go", ".go") // true |
Index / LastIndex |
func Index(s, sep string) int |
返回子串第一次/最后一次出现的索引,未找到返回 -1 | strings.Index("chicken", "ken") // 4 |
Count |
func Count(s, sep string) int |
统计子串 sep 在 s 中出现的非重叠次数 | strings.Count("cheese", "e") // 3 |
ToUpper / ToLower |
func ToUpper(s string) string |
返回将所有字母转为大写/小写的新字符串 | strings.ToUpper("Gopher") // "GOPHER" |
EqualFold |
func EqualFold(s, t string) bool |
比较字符串(忽略大小写) | strings.EqualFold("Go", "GO") // true |
Split / SplitAfter |
func Split(s, sep string) []string |
用分隔符 sep 分割字符串,返回切片。SplitAfter 会保留分隔符 | strings.Split("a,b,c", ",") // ["a","b","c"] |
Join |
func Join(elems []string, sep string) string |
用分隔符 sep 连接字符串切片 | strings.Join([]string{"a","b"}, "-") // "a-b" |
Replace / ReplaceAll |
func Replace(s, old, new string, n int) string |
替换字符串。n 为替换次数(-1 代表全部),ReplaceAll 替换所有 | strings.Replace("oink oink", "k", "ky", 2) // "oinky oink" |
Trim / TrimSpace |
func Trim(s, cutset string) string |
去除字符串首尾在 cutset 字符集中的所有字符。TrimSpace 去除首尾空白 | strings.Trim("!!!Hello!!!", "!") // "Hello" |
Fields |
func Fields(s string) []string |
按一个或多个空白字符(空格、制表符等)分割字符串 | strings.Fields(" foo bar baz ") // ["foo","bar","baz"] |
Builder (类型) |
func (b *Builder) WriteString(s string) |
高效构建字符串,避免多次拼接的性能损耗 | 见下方示例 |
1 | var sb strings.Builder |
3.4.3 strconv 包支持的函数
支持字符串与基本数据类型的转换
函数分类 | 函数签名示例 | 描述 | 示例 |
---|---|---|---|
Atoi / Itoa |
func Atoi(s string) (int, error) |
ASCII to Integer / Integer to ASCII | i, _ := strconv.Atoi("42") // i=42 |
Parse 系列 |
func ParseBool(str string) (bool, error) |
将字符串解析为指定类型 | b, _ := strconv.ParseBool("true") f, _ := strconv.ParseFloat("3.14", 64) |
Format 系列 |
func FormatBool(b bool) string |
将指定类型格式化为字符串 | s := strconv.FormatBool(true) // "true" s := strconv.FormatInt(-42, 10) // "-42" |
Append 系列 |
func AppendBool(dst []byte, b bool) []byte |
将转换后的值直接追加到字节切片中,性能更高 | buf := []byte("Value: ") buf = strconv.AppendBool(buf, true) // buf -> []byte("Value: true") |
示例: 1
2
3
4
5
6
7
8
9
10
11
12// 字符串 -> 数字
input := "123"
num, err := strconv.Atoi(input)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Number is %d\n", num) // Number is 123
// 数字 -> 字符串(带格式)
pi := 3.1415926
str := strconv.FormatFloat(pi, 'f', 2, 64) // 格式:浮点数,保留2位小数,64位精度
fmt.Println(str) // "3.14"
3.4.4 unicode/utf8包支持的函数
当需要处理中文等多字节字符时,这个包至关重要。(略,使用时搜索)
3.4.5 regexp 包支持的函数
用于复杂的模式匹配和文本提取。(略,使用时搜索)
4 go中的区别控制语句
在go中,虽然在控制语句上与c++、java相差不大,但还是有一些区别的。 ## 4.1 循环语句 - 简单循环,与cpp、java、python类似 1
2
3for i := 0; i < 4; i++{
fmt.Printf("cainiaojc\n")
}
- 将for循环作为无限循环:移除上述三个表达式即可
1
2
3for{
// 语句...
} - for循环用作while循环: for循环也可以用作while循环
1
2
3
4i:= 0
for i < 3 {
i += 2
} - for循环中使用
range
关键字range
:对于数组、切片、字符串,range会返回两个值:索引、值(其中字符串会返回相应的unicode代码点)range
:对于map映射,则返回键、值1
2
3
4
5
6
7
8
9
10
11
12rvariable:= []string{"GFG", "Geeks", "cainiaojc"}
for i, j:= range rvariable {
fmt.Println(i, j)
}
mmap := map[int]string{
22: "Geeks",
33: "GFG",
44: "cainiaojc",
}
for key, value := range mmap {
fmt.Println(key, value)
}
- For通道: for循环可以遍历通道上发送的顺序值,直到关闭为止。
1
2
3
4
5
6
7
8
9
10
11
12// 使用 channel
chnl := make(chan int)
go func() {
chnl <- 100
chnl <- 1000
chnl <- 10000
chnl <- 100000
close(chnl)
}()
for i := range chnl {
fmt.Println(i)
}
4.2 switch语句
与cpp的switch的expreesion只能支持整数和枚举类型不同,go中的switch更加强
特性 | C++ | Go |
---|---|---|
表达式类型 | 仅限整型、枚举 | 任意类型(int, string, float64, 自定义类型等) |
多值匹配 | 不支持(每个 case 只能有一个值) | 支持(case val1, val2:) |
无表达式形式 | 不支持 | 支持(switch { ... } 替代 if-else 链) |
类型判断 | 不支持(需要 typeid 等复杂机制) | 支持(switch v := i.(type) { ... }) |
Fallthrough | 默认 fallthrough(需 break 阻止) | 默认 break(需 fallthrough 语句开启) |
1. 基于值的 switch(最常见) 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18func main() {
day := 3
switch day {
case 1:
fmt.Println("Monday")
case 2:
fmt.Println("Tuesday")
case 3:
fmt.Println("Wednesday") // 输出: Wednesday
case 4:
fmt.Println("Thursday")
case 5:
fmt.Println("Friday")
default:
fmt.Println("Weekend")
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14func main() {
month := 2
switch month {
case 1, 2, 12:
fmt.Println("Winter") // 输出: Winter
case 3, 4, 5:
fmt.Println("Spring")
case 6, 7, 8:
fmt.Println("Summer")
case 9, 10, 11:
fmt.Println("Autumn")
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16func main() {
score := 85
switch {
case score >= 90:
fmt.Println("A")
case score >= 80:
fmt.Println("B") // 输出: B
case score >= 70:
fmt.Println("C")
case score >= 60:
fmt.Println("D")
default:
fmt.Println("F")
}
}1
2
3
4
5
6
7
8
9
10
11func main() {
// 初始化语句 + 条件判断
switch hour := time.Now().Hour(); {
case hour < 12:
fmt.Println("Good morning!")
case hour < 17:
fmt.Println("Good afternoon!")
default:
fmt.Println("Good evening!")
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18func checkType(i interface{}) {
switch v := i.(type) {
case int:
fmt.Printf("Integer: %d\n", v)
case string:
fmt.Printf("String: %s\n", v)
case bool:
fmt.Printf("Boolean: %v\n", v)
default:
fmt.Printf("Unknown type: %T\n", v)
}
}
func main() {
checkType(42) // Integer: 42
checkType("hello") // String: hello
checkType(true) // Boolean: true
checkType(3.14) // Unknown type: float64
}
go中的switch的每个case是与cpp、java相反的,默认是break的,如果需要接着执行,需要
fallthrough
4.3 Select语句和deadlock死锁
4.3.1 Select语句
因为go中有groutine,通道常与其搭配使用。因此select语句就像switch语句,但是在select语句中,case语句引用通信,即通道上的发送或接收操作。
关键特性:
随机选择:当有多个
case
同时准备好(即多个channel
同时可操作)时,select
会随机、公平地选择其中之一执行,从而避免饥饿。阻塞等待:如果没有
default
子句,且所有case
的 channel 操作都未准备好,select
语句会阻塞,直到至少有一个case
准备好。非阻塞检查:如果有
default
子句,并且所有case
都未准备好,则立即执行default
语句。这使得select
可用于非阻塞的channel
操作。
1. 多路复用(Multiplexing
1 | //多路复用,从多个 channel 中接收数据,处理最先到达的消息。 |
2. 超时控制(Timeout) 防止操作无限期阻塞,是处理超时的标准做法。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16func main() {
ch := make(chan string)
go func() {
time.Sleep(2 * time.Second) // 模拟耗时操作
ch <- "result"
}()
select {
case res := <-ch:
fmt.Println(res)
case <-time.After(1 * time.Second): // time.After 返回一个 channel,在指定时间后发送一个值
fmt.Println("timeout")
}
}
// 输出: timeout (因为 goroutine 睡了 2 秒,但 select 只等 1 秒)
3. 非阻塞操作(Non-blocking Operations) 使用 default 检查 channel 是否就绪,而不阻塞当前 goroutine。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19func main() {
messages := make(chan string)
// 非阻塞接收
select {
case msg := <-messages:
fmt.Println("received message", msg)
default:
fmt.Println("no message received") // 立即执行
}
// 非阻塞发送
select {
case messages <- "hello":
fmt.Println("sent message")
default:
fmt.Println("no message sent") // 因为无接收者,立即执行
}
}
4. 循环监听(Looping with select) 通常将 select 放在 for 循环中,以持续处理多个 channel 的事件。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17func main() {
tick := time.Tick(100 * time.Millisecond) // 每隔一段时间发送一个值
boom := time.After(500 * time.Millisecond) // 一段时间后发送一个值
for {
select {
case <-tick:
fmt.Println("tick.")
case <-boom:
fmt.Println("BOOM!")
return // 退出循环和函数
default:
fmt.Println(" .")
time.Sleep(50 * time.Millisecond)
}
}
}
4.3.2 groutine和Select场景下造成的死锁deadlock
1. 无缓冲 channel 的单一 goroutine 阻塞 这是最常见的死锁场景:一个 goroutine 在等待一个永远不会发生的事件。 1
2
3
4
5
6
7func main() {
ch := make(chan int) // 无缓冲 channel
ch <- 42 // 发送操作:阻塞,等待接收者
// 执行不到这里
fmt.Println(<-ch) // 接收操作
}
// fatal error: all goroutines are asleep - deadlock!
2. 循环等待(Circular Wait) 多个 goroutine 之间形成资源等待的环。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19func main() {
chA := make(chan int)
chB := make(chan int)
go func() { // Goroutine 1
<-chA // 等待 chA 有数据
chB <- 1 // 向 chB 发送数据
}()
go func() { // Goroutine 2
<-chB // 等待 chB 有数据
chA <- 1 // 向 chA 发送数据
}()
// 主 goroutine 退出,上面的两个 goroutine 永远相互等待
time.Sleep(time.Second) // 防止主 goroutine 退出太快
// 但睡眠结束后,程序退出,不会报 deadlock,但两个 goroutine 被泄露了
// 如果主 goroutine 也参与等待,就会报 deadlock
}
3. 空的 select 语句 一个空的 select{} 语句会永久阻塞,没有任何 case 可以执行。 1
2
3
4func main() {
select {} // 阻塞 forever,直接死锁
}
// fatal error: all goroutines are asleep - deadlock!http.ListenAndServe
或 sync.WaitGroup
)。
4. 所有 goroutine 都在 select 中阻塞 如果程序中所有活跃的goroutine
(包括主 goroutine
)都在执行一个没有 default
分支的 select
语句,并且所有 case
的 channel
都无人操作,就会发生死锁。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16func main() {
ch := make(chan int)
go func() {
select {
case <-ch: // 等待接收
case ch <- 10: // 等待发送
}
// 两个 case 都无人匹配,这个 goroutine 永远阻塞
}()
time.Sleep(time.Second)
// 主 goroutine 睡眠后退出,不会报 deadlock
// 但如果去掉 time.Sleep,主 goroutine 直接退出,也不会报 deadlock
// 只有在所有 goroutine 都阻塞时,运行时才会检测到死锁
}
4.3.3 Go 运行时对死锁的检测
Go 运行时(runtime)有一个强大的死锁检测器。它不是静态分析的,而是在程序运行时进行检测。
- 触发条件:当程序中发现所有的
goroutine
都处于休眠(asleep
)状态(即都在阻塞等待channel
操作或锁),并且没有任何机会被唤醒时,运行时就会panic
,并抛出fatal error: all goroutines are asleep - deadlock!
。
注意:如果还有非阻塞的
goroutine
(例如在运行for
循环,或正在执行default
分支),即使其他goroutine
被阻塞,也不会被判定为死锁。
4.3.4 如何避免和调试死锁
设计清晰的通信流程:规划好 channel 的发送方和接收方,确保数据流有始有终。
使用带缓冲的 Channel:在某些场景下,使用
make(chan int, N)
可以解耦发送和接收的时机,避免瞬时阻塞,但需谨慎,它可能掩盖设计问题。- 使用超时机制:这是避免死锁最有效的手段之一。总是为可能阻塞的操作设置超时。
1
2
3
4
5
6
7select {
case res := <-ch:
// 正常处理
case <-time.After(3 * time.Second):
// 超时处理:记录日志、重试、返回错误等
log.Println("operation timed out")
} 使用 Context:对于更复杂的并发控制(如取消、截止时间),使用 context 包是 Go 的现代最佳实践。
>代码审查 > >1. 使用 go run -race 或 go build -race 进行数据竞争检测,这有助于发现并发问题。 >2. 使用 go vet 进行静态分析,它能发现一些明显的错误。 >3. 使用 pprof 等工具分析 goroutine 的运行状况,查看是否有 goroutine 被意外阻塞。1
2
3
4
5
6
7
8ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
select {
case <-ch:
fmt.Println("work done")
case <-ctx.Done(): // context 超时或取消时,ctx.Done() channel 会关闭
fmt.Println("work cancelled or timed out:", ctx.Err())
}
5 函数
函数可以分为三类:内置函数、包级公共函数、方法(相当于cpp中的成员函数),在介绍他们之前,还是先熟悉go的函数特征
5.1 go函数特点
1. 定义: 1
2
3
4
5
6
7func MyFunc(a int, b int)int{
// function body.....
}
等价于
func MyFunc(a, b int)int{
// function body.....
}
- 返回值可选。有声明返回值时必须有
return
语句
2. 变参函数:允许用户在可变函数中传递零个或多个参数的函数。fmt.Printf是可变参数函数的示例,它在开始时需要一个固定的参数,之后它可以接受任意数量的参数。
- 最后一个参数的类型前面带有省略号
…
。它表明该函数可以调用任意数量的这种类型的参数
1 | //可变参数函数联接字符串 |
3. 匿名函数:匿名函数是不包含任何名称的函数。当您要创建内联函数时,此函数很有用。在Go语言中,匿名函数可以形成闭包。匿名函数也称为函数字面量。它也可以像普通函数一样作为参数传递 1
2
3
4
5
6
7func main() {
// 分配一个匿名函数到一个变量
value := func(){
fmt.Println("Welcome! to (cainiaojc.com)")
}
value()
}
4. Go 语言函数支持返回多个值:go允许return语句从一个函数返回多个值。返回值的类型类似于参数列表中定义的参数的类型。 1
2
3
4
5
6
7func Myfunc(p, q int) (int, string, []string) {
a := 10
str := "trluper"
slstr := make([]string, 0, 10)
slstr = append(slstr, "trluper")
return a, str, slstr
}
5. 返回参数命令:命名返回参数通常称为命名参数。Golang允许直接以命名返回参数名称的形式返回返回值,但要求是必须使用“裸返”的return语句 1
2
3
4
5
6
7
8
9// 具有命名参数为mul、dev的函数
func calculator(a, b int) (mul int, div int) {
//并初始化命名参数的值
mul = a * b
div = a / b
//return关键字
//但没有任何结果参数
return
}
- 附件的意思怎么理解:defer语句会这个被defer修饰该函数及其参数维护在一个链表中,在调用者函数**返回时,再从链表头依次取出执行。(后进先出)
- 即使函数发生严重错误(如 panic),defer 也会执行,这为资源清理提供了便利:关闭文件句柄、锁的释放、数据库连接释放、捕获 panic 并恢复
1 | func DeferTest() { |
Go 的 defer 在底层是通过链表实现的。每个
goroutine
都有一个defer
链表,每当遇到defer
语句时,会将函数和参数等信息封装成一个_defer
结构体实例,然后将其插入链表头部。函数返回时,从链表头部依次执行,因此表现出后进先出的特性。
5.2 defer关键字底层探析
1. 数据结构:在 Go 运行时中,每个 goroutine 都有一个 _defer 结构体
的链表。这个结构体大致如下(简化版): 1
2
3
4
5
6
7
8
9type _defer struct {
siz int32 // 参数和结果的内存大小
started bool // 是否已开始执行
sp uintptr // 栈指针(用于判断defer是否属于当前函数)
pc uintptr // 程序计数器
fn *funcval // 要执行的函数
_panic *_panic // 关联的panic(如果有)
link *_defer // 指向下一个defer的指针
}
运行时:创建一个 _defer 结构体,并将其添加到当前 goroutine 的 defer 链表头部
调用者函数返回时:从 defer 链表头部开始依次执行各个 defer 函数(LIFO顺序)
执行完成后:从链表中移除已执行的 defer
3. 参数预计算
- 关键特性:defer 语句的参数会在声明时立即求值,而不是在执行时求值
1
2i := 0
defer fmt.Println(i) // 此时 i=0 被捕获并保存
5.2.1 defer 与 panic
panic 时的 defer 执行,存在如下的问题,因为第二个defer还没执行就panic了所以无法正常调用: 1
2
3
4
5
6
7
8func main() {
defer fmt.Println("This will be printed")
panic("Something went wrong")
defer fmt.Println("This won't be printed")
}
// 输出:
// This will be printed
// panic: Something went wrong
5.2.2 defer、panic与recover、
recover()
是 Go 语言内置的异常恢复函数,用于捕获并处理运行时的 panic
,使程序能够从严重错误中恢复并继续执行。与 panic()
和 defer
共同构成异常处理体系。(相当于try-catch
)
recover关键特性:
- 作用域限制:仅在 defer 函数中有效
- 状态感知:
- 无 panic 时返回 nil
- ** 处理 panic 时返回 panic 传递的值(类型为 interface{})**
- 执行时机:在 defer 函数实际执行时生效,而非定义时
- panic-recover 执行流程:
- 触发
panic
:- 运行时错误(如数组越界、空指针引用)
- 主动调用 panic("错误信息")
- 程序立即停止当前函数执行,逐层展开调用栈
- 执行每一层的
defer
函数链 - 若
defer
中存在recover()
则返回非nil
值:- 终止
panic
传播 - 程序控制权转移至
recover()
后的代码
- 终止
- 若所以defer链执行后都未能捕获:程序崩溃并输出堆栈信息
1
2
3
4
5
6
7
8
9
10
11
12
13func DeferRecoverPanic() {
defer d1()
defer func() {
if r := recover(); r != nil {
fmt.Println("Recovered from:", r)
}
}()
panic("Something went wrong")
fmt.Println("This won't be printed")
}
// 输出:
// Recovered from: Something went wrong
// 1
- 触发
5.2.3 defer修饰函数的返回值接收
在 Go 语言中,被 defer 的函数的返回值处理是一个需要特别注意的话题。简单来说:被 defer 的函数的返回值因存储在栈通常会被忽略,除非你以下的技巧来捕获它们:
1. 通过命名返回值接收(Named Return Values) 1
2
3
4
5
6
7
8
9
10
11
12func DeferReturn() (result1, result2 int) {
result1 += 10
result2 += 10
defer func() {
result1 += 42 // 直接修改命名返回值
result2 = 42
}()
result1 += 10
result2 += 10
return
}
//返回62 42
2. 通过闭包捕获变量:使用闭包特性,defer 函数可以修改外部变量: 1
2
3
4
5
6
7
8
9
10func main() {
var result int
defer func() {
result = 42 // 修改外部变量
}()
fmt.Println("Before defer:", result) // 输出: 0
// defer 函数将在 main 返回前执行
}
// 注意:在这个例子中,main 函数没有返回值,
// 所以无法在 main 外部看到修改后的 result 值
5.2.4 性能考虑与优化
defer关键字强大,但谨慎使用,因为其有些情况场景的开销大:
1. 早期实现的性能问题:在 Go 1.13 之前,defer 的实现有较大的性能开销,主要因为需要堆分配 _defer
结构体,涉及多次内存分配和释放
2. 现代优化技术(Go 1.14+):Go 1.14 引入了开放编码式 defer(Open-coded defers)大幅提升性能:
- 栈上分配:对于大多数 defer,直接在栈上分配空间,避免堆分配
- 代码内联:在函数返回点直接插入 defer 调用,避免链表操作
- 条件执行:只在需要时(如发生 panic)才使用传统链表方式
3. 优化条件:不是所有 defer 都能被优化,以下情况会回退到堆分配:1)循环中的 defer;2)条件语句中的 defer(数量不确定);3)defer 数量超过 8 个;4)函数中有 panic/recover
5.3 内置函数
像 len()
这样的函数我们称为内置函数:
无需导入任何包即可使用,它们是 Go 语言本身的一部分,由编译器直接提供。
它们的名称是预定义的,你不能创建同名函数。
它们通常用于操作 Go 的基本数据结构,提供最基础、最核心的操作。
常用到的有: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19len(): 获取长度(字符串、切片、数组、映射、通道)。
cap(): 获取容量(切片、数组、通道)。
make(): 为切片、映射、或通道类型分配内存并初始化(返回类型本身,而不是指针)。
new(): 为任何类型分配零值内存(返回指向该类型的指针 *T)。
append(): 向切片中追加元素。
copy(): 复制切片。
delete(): 从映射中删除键值对。
close(): 关闭通道。
panic() 和 recover(): 用于错误处理机制。
用于复数操作的 complex(), real(), imag()1
2
3
4s := []int{1, 2, 3}
fmt.Println(len(s)) // 使用内置函数 len
m := make(map[string]int) // 使用内置函数 make
ch := make(chan int)
5.4 方法
Go语言支持方法。Go方法与Go函数相似,但有一点不同,就是方法中包含一个接收者参数。在接收者参数的帮助下,该方法可以访问接收者的属性。在这里,接收方可以是结构类型或非结构类型。在代码中创建方法时,接收者和接收者类型必须出现在同一个包中。
5.4.1 结构类型接收器的方法
在Go语言中,允许您定义其接收者为结构类型的方法。可以在方法内部访问此接收器,如以下示例所示(): 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20//Author 结构体
type author struct {
name string
branch string
particles int
salary int
}
//值接收器方法
func (a author) show() {
fmt.Println("Author's Name: ", a.name)
fmt.Println("Branch Name: ", a.branch)
fmt.Println("Published articles: ", a.particles)
fmt.Println("Salary: ", a.salary)
}
//方法,使用指针接收者
func (a *author) show(abranch string) {
(*a).branch = abranch
}
- 值接收器:使用值接收器的方法在调用时,会使用接收器值的一个副本。因此,方法内对接收器的任何修改都不会影响原始值。
- 指针接收器:在指针接收器的帮助下,方法内对接收器的修改会影响原始值。
5.4.2 非结构类型接收器的方法
在Go语言中,只要类型和方法定义存在于同一包中,就可以使用非结构类型接收器创建方法。但如果int,string等不同的包中,则编译器将抛出错误,因为它们是在不同的包中定义的。 1
2
3
4
5
6
7
8
9
10
11//类型定义,不要直接使用int
type data int
//非结构类型的接收器。不报错,因为type成data
func (d1 data) multiply(d2 data) data {
return d1 * d2
}
//报错代码,编译器将抛出错误
func(d1 int)multiply(d2 int)int{
return d1 * d2
6 结构体
Golang中的结构(struct)是一种用户定义的类型,允许将可能不同类型的项分组/组合成单个类型。与java、cpp相比,golang的结构体是不支持继承但支持组合的轻量级类 1
2
3
4
5type User struct {
Name, Sex, Live string
Age int
}1
2
3
4
5
6var a User; //什么条件下都可使用的默认定义方式
//使用结构字面量来初始化结构类型的变量
obj1 := studypkg.User{Name: "Trluper"} //部分初始化时,必须以key:value形式,其他赋予默认值
obj2 := studypkg.User{"Trluper", "male", "GuangDong", 27}
obj3 := studypkg.User{Name:"Trluper", Sex: "male", Live: "GuangDong", Age: 27}
a
:默认情况下将其设置为零。对于结构,零表示所有字段均设置为其对应的零值。因此,字段Name,Sex,Live都设置为“”,而Age设置为0,若是引用类型则为nil
obj1
:部分初始化时,必须以key:value形式,其他默认值obj2和obj3
:两者等价
注意结构体中的变量要想包外可见或者在包外能够使用上方结构字面量来初始化结构类型的变量,必须首字母大写;小写的不能被包外访问
如果存在一个小写的,你不可以在包外按上面obj2-obj3
的全赋值方式,如: 1
2
3
4
5type User struct {
Name, Sex, Live string
Age int
birthday string
}1
obj := studypkg.User{Name:"Trluper", Sex: "male", Live: "GuangDong", Age: 27}
函数可以作为结构体的字段,如下先声明函数类型,再像类型一样使用它即可
1
2
3
4
5
6
7
8
9
10 >// Finalsalary函数类型
type Finalsalary func(int, int) int
//创建结构
type User struct {
Name, Sex, Live string
Age int
birthday string
//函数作为字段
salary Finalsalary
}
6.1 结构体的比较
可以通过==
运算符或DeeplyEqual()
方法比较两个结构相同的类型并包含相同的字段值的结构。如果结构彼此相等(就其字段值而言),则运算符和方法均返回true;否则,返回false。如果比较的变量属于不同的结构,则编译器将给出错误
1 | obj := studypkg.User{Name: "Trluper", Sex: "male", Live: "GuangDong", Age: 27} |
均输出为true。 >注意,==
运算符是建立在结构体的字段都是可比较的前提下的,结构体包含不可比较的字段(如切片、映射、函数等),则不能直接使用 == 进行比较,只能使用DeeplyEqual()
6.2 嵌套结构体
o语言允许嵌套结构。一个结构是另一个结构的字段,称为嵌套结构。换句话说,另一个结构中的结构称为嵌套结构。 1
2
3
4
5
6
7
8
9
10type User struct {
Name, Sex, Live string
Age int
birthday string
Addr Address
}
type Address struct {
City, Street string
}1
obj2 := studypkg.User{Name: "Trluper", Sex: "male", Live: "GuangDong", Age: 27, Addr: studypkg.Address{"Guangzhou", "nanshitou"}}
go中允许在结构体中声明匿名字段,即没有字段名字,只有字段类型的字段。但结构体只允许存在一个相同类型的匿名字段。在编译时,你只需要提到字段的类型,然后Go就会自动使用该类型作为字段的名称
1
2
3
4
5
6 type student struct{
int
string
float64
}
value := student{123, "Bud", 8900.23}
6.3 匿名结构体
在Go语言中,允许创建匿名结构。匿名结构是不包含名称的结构。当要创建一次性可用结构时,它很有用。可以使用以下语法创建匿名结构: 1
2
3variable_name := struct{
// fields
}{// Field_values}1
2
3
4
5
6
7
8
9
10
11
12// 创建和初始化匿名结构
Element := struct {
name string
branch string
language string
Particles int
}{
name: "詹三",
branch: "开发部",
language: "C++",
Particles: 498,
}
7 切片Slice
切片是对数组的抽象,提供更灵活、强大的序列接口。切片是引用类型,其底层数据结构包含三个组件: 1
2
3
4
5
6// 切片在runtime包中的表示
type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切片长度
cap int // 切片容量
}
unsafe.Pointer
: 是一个通用指针类型,可以指向任意类型的内存地址(相当于cpp的void*
),但本身不包含类型信息。它通过uintptr
(无符号整数类型)与内存地址关联,**但无法直接通过 *p 访问值(需转换为具体类型指针**- 主要用途
- 类型转换:将任意类型指针转换为 unsafe.Pointer,再转回其他类型指针(如 int32、float64 等)。
- 内存操作:直接访问或修改内存数据,例如读取浮点数的位模式或修改结构体的未导出字段。
- 与 C 交互:传递 Go 指针给 C 语言库时使用。
在Go中,
unsafe.Pointer
需先转换为uintptr才能进行地址计算。unsafe.Pointer
会阻止垃圾回收器回收其指向的对象(存在引用关系),而uintptr仅是地址数值,不持有引用,对象可能被回收
7.1 切片存储区域
切片依照它执行初始化操作的不同会有不同的表现过程,但最终是存储在堆上的:
字面量创建:"当使用字面量创建切片时,编译器会在只读数据段存储初始值,然后在运行时在堆上分配可写的数组内存,并将初始值拷贝到堆内存中,最后创建指向堆内存的切片结构。"
Go 编译器会进行以下操作:1
s := []int{1, 2, 3, 4}
- 静态初始化:编译器会在可执行文件的只读数据段(.rodata) 中创建初始的数组数据
- 运行时分配:在程序运行时,会在堆上分配一个适当大小的数组
- 数据拷贝:将只读数据段中的数据拷贝到堆上分配的数组中
- 创建切片头:创建一个切片结构体,指向堆上的数组
- make创建:运行时在堆上分配底层数组,然后创建切片结构
1
slic2 := make([]int, 2, 6) //len为2,cap为6, [0,0]
从数组/切片创建:共享或部分共享底层数组
1
2//slice2: [1,2,3,4,5]
slic3 := slic2[2:4] //slice3 [3,4]
7.2 切片的扩容
在Go 1.18及以后版本,扩容策略更加平滑,量计算:
- 当前容量 < 256,新容量 = 旧容量 × 2
- 当前容量 ≥ 256,新容量 = 旧容量 + (旧容量 + 3×256) / 4
- 内存对齐:会根据元素大小进行内存对齐
1 | slic2 := make([]int, 5, 5) |
注意:一个切片的扩容操作会导致切片共享分离,两者内存独立了: 1
2
3
4
5
6
7
8
9original := make([]int, 3, 3)
slice1 := original[:2] // 共享底层数组
slice1 = append(slice1, 100) // 仍在容量内,共享
fmt.Println(original) // [0, 0, 100]
slice1 = append(slice1, 200) // 需要扩容,创建新数组
slice1[0] = 999
fmt.Println(original) // [0, 0, 100] 原数组未受影响
最佳实践和建议
- 预分配容量:如果知道大致大小,使用
make([]T, len, capacity)
预分配 - 避免内存泄漏:大切片不再使用时设为nil,以便垃圾回收
- 小心切片共享:注意多个切片共享底层数组可能导致的意外修改
- 批量处理:尽量减少
append
操作,批量添加元素
多维切片示例: 1
2
3
4
5
6
7
8
9
10
11slic4 :=make([][]string,2)
slic4[0] = append(slic4[0], "nihao")
slic4[1] =append(slic4[1], "trluper")
slic4[1] =append(slic4[1], "go go go")
// 创建一个 3x4 的二维切片(3行4列)
rows, cols := 3, 4
matrix := make([][]int, rows) // 第一维分配3个元素
for i := range matrix {
matrix[i] = make([]int, cols) // 每个第二维分配4个元素
}
7.3 常与切片搭配使用的内置函数和包级函数
func copy(dst, src []Type) int
:将一个切片复制到另一个切片中,内存独立。它将返回要复制的元素数量,该数量应为len(dst)或len(src)的最小值func Compare(slice_1, slice_2 []byte) int
:可以使用Compare()函数将两个字节类型的切片彼此进行比较,整数值表示这些切片相等或不相等;- 如果结果为
0
,则slice_1 == slice_2
。 - 如果结果为
-1
,则slice_1 <slice_2
。 - 如果结果为
+1
,则slice_1> slice_2
。
- 如果结果为
在
sort
包下有许多各类型的比较函数:如func Ints(slc []int)
,排序整型切片1
2scl2 := []int{-23, 567, -34, 67, 0, 12, -5}
sort.Ints(scl2)func Split(o_slice, sep []byte) [][]byte
:使用Split()函数分割给定的切片。此函数将字节的切片拆分为由给定分隔符分隔的所有子切片,并返回包含所有这些子切片的切片- o_slice是字节片,sep是分隔符。如果sep为空,则它将在每个UTF-8序列之后拆分
1
2
3
4slice_1 := []byte{'!', '!', 'G', 'e', 'e', 'k', 's',
'f', 'o', 'r', 'G', 'e', 'e', 'k', 's', '#', '#'}
res1 := bytes.Split(slice_1, []byte("eek"))
//Slice 1: [!!G sforG s##]
- o_slice是字节片,sep是分隔符。如果sep为空,则它将在每个UTF-8序列之后拆分
8 接口
在Go语言中接口(interface)是一种类型,一种抽象的类型,它只有方法声明,没有实现,没有数据字段。因此不同于结构体,它不关心属性(数据),只关心行为(方法)。它描述类型必须实现的方法,规定了类型的行为契约。就如定义一台洗衣机,只要一台机器有洗衣服和甩干的功能,我就称它为洗衣机。
Go接口将所有具有共性的方法定义放在一起,任何其他类型(注意是类型)只要实现了这些方法就是实现了这个接口。
Go的接口设计简单但功能强大,是实现多态和解耦的重要工具。接口可以让我们将不同的类型绑定到一组公共的方法上,从而实现多态和灵活的设计。
8.1 接口特点
- 隐式实现:Go 中没有关键字显式声明某个类型实现了某个接口。只要一个类型实现了接口要求的所有方法,该类型就自动被认为实现了该接口。
- 接口类型变量:
- 接口变量可以存储实现该接口的任意值。
- 接口变量底层上上包含了两个部分:
- 动态类型:存储实际的值类型,可以通过类型断言
对象变量.(type)
看这个接口变量是不是类型type
- 动态值:存储具体的值。
- 动态类型:存储实际的值类型,可以通过类型断言
- 零值接口:接口的零值为nil。一个未初始化的接口标记其值nil,且不包含任何动态类型或值。
- 空接口:定义为interface{},可以表示任何类型。
用法
- 多态:不同类型实现相同接口,实现多态行为。
- 解耦合:通过接口定义依赖关系,降低模块之间的耦合。
- 泛化:使用空接口interface{}表示任意类型。
8.2 接口和实现
我们有一个Mover
接口和一个dog
结构体。对于使用值接收器实现接口和使用指针接收器实现接口的区别是:因为go对指针类型变量求值的语法糖,因此值接收器能够接收值类型和引用类型,而指针接收器只能接收指针 1
2
3
4
5type Mover interface {
move()
}
type dog struct {}
值接收者实现接口 1
2
3
4
5
6
7
8
9
10
11
12func (d dog) move() {
fmt.Println("狗会动")
}
func main() {
var x Mover
var wangcai = dog{} // 旺财是dog类型
x = wangcai // x可以接收dog类型
var fugui = &dog{} // 富贵是*dog类型
x = fugui // x可以接收*dog类型
x.move()
}1
2
3
4
5
6
7
8
9
10func (d *dog) move() {
fmt.Println("狗会动")
}
func main() {
var x Mover
var wangcai = dog{} // 旺财是dog类型
x = wangcai // x不可以接收dog类型
var fugui = &dog{} // 富贵是*dog类型
x = fugui // x可以接收*dog类型
}
一个类型可以同时实现多个接口,而接口间相互独立,不知道对方的实现。例如,狗可以叫,也可以动 同样的,一个接口也可以被多个类型各自实现,
8.3 空接口
空接口是指没有定义任何方法的接口,因此底层上任何类型都实现了空接口。所以空接口interface{}
可以表示任何类型。因此常常用在:
- 函数的参数,使用空接口实现可以接收任何类型的函数参数。
- 使用空接口实现可以保存任意值的字典。(空接口(interface{})本身不可比较,因此不能作为map的key)
1 | // 空接口作为函数参数 |
8.4 断言
1 | a.(type) |
类型断言用于检查其操作数的动态类型是否匹配已断言的类型。则类型断言检查a
的给定动态类型是否等于type
,这里,如果检查成功进行,则类型断言返回a
的动态值。在上面的语句因为没有接收返回值,因此如果检查失败,则操作将出现panic
异常。最好的用法是: 1
value, ok := a.(T)
a
的类型等于T
,则该值包含a
的动态值,并且ok
将设置为true
。并且如果a
的类型不等于T
,则ok
设置为false
并且value
包含零值,并且程序不会抛出panic
异常
因此通过断言,可以访问类型存储的值: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24type People interface {
Speak(string) string
}
type Student struct{
Name string
}
func (stu Student) Speak(think string) (talk string) {
if think == "sb" {
talk = "你是个大帅比"
} else {
talk = "您好"
}
return
}
//示例
var peo People = Student{Name: "trluper"}
think := "bitch"
fmt.Println(peo.Speak(think))
fmt.Println(peo.(Student))
if t, ok:= peo.(Student);ok{
fmt.Println(t.Name) //访问类型内部的变量
}
除了断言外,还有一种反射机制也可以访问类型内的具体数据:、
反射提供了极大的灵活性,但性能较低,且代码可读性较差。通常建议优先使用类型断言。
1
2
3
4
5
6
7
8
9
10
11 >v := reflect.ValueOf(peo) //使用反射获取接口的动态值
>if v.Kind() == reflect.Struct {
// 获取字段数量
numFields := v.NumField()
for i := 0; i < numFields; i++ {
field := v.Field(i)
fmt.Printf("Field %d: %v\n", i, field.Interface())
}
} else {
fmt.Println("Not a struct")
}
8.4 Go 语言接口嵌套
接口是类型,这就意味着可以创建接口类型的变量。Go语言不支持继承,但是Go接口完全支持嵌套。在嵌套过程中,一个接口可以嵌套其他接口,或者一个接口可以在其中嵌套其他接口的方法签名,而且,如果对接口的方法进行了任何更改,则在将一个接口嵌套其他接口时,该接口也将反映在嵌套式接口中 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15type interface_name1 interface {
Method1()
}
type interface_name2 interface {
Method2()
}
type finalinterface_name interface {
interface_name1
interface_name2
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15type interface_name1 interface {
Method1()
}
type interface_name2 interface {
Method2()
}
type finalinterface_name interface {
Method1()
Method2()
}
9 Go 并发(Goroutines)
Go语言提供了称为Goroutines的特殊功能。Goroutine是一种函数或方法,可与程序中存在的任何其他Goroutine一起独立且同时执行。换句话说,每个Go语言中同时执行的活动称为Goroutines,您可以将Goroutine视为轻量级线程。与线程相比,创建Goroutines的成本非常小。每个程序至少包含一个Goroutine,并且该Goroutine被称为主Goroutine。
如果主Goroutine终止,则所有Goroutine在主Goroutine之下运行,那么程序中存在的所有goroutine也将终止;
9.1 groutine的底层原理
goroutine 的底层原理可以概括为:用户态的轻量级线程,由 Go 运行时(runtime)进行调度和管理,基于一种称为M:N
调度模型的技术,即在M
个内核线程上调度执行 N
个 goroutine。
9.1.1 核心概念:G, M, P(高并发的基础)
Go 调度器的实现依赖于三个核心结构,这也是理解其原理的基础:
G (Goroutine):代表一个 goroutine。它包含该用户态线程的的执行栈(stack)、指令指针(IP)、状态等信息。Goroutine 的栈初始大小很小(通常为 2KB),但其可以动态扩容和缩容,这使得创建大量 goroutine(成千上万个)成为可能。而线程栈通常默认为 MB 级别。
M (Machine):代表一个操作系统线程(OS Thread)。它是真正在 CPU 上执行代码的实体。M 必须持有一个 P 才能执行 Go 代码。runtime 会创建与 CPU 核心数相当或者稍多的 M 以最大化性能。
P (Processor):代表一个“逻辑处理器”或“调度上下文”。它是实现 M:N 调度的关键。P 维护着一个本地 运行队列(Run Queue),里面是该 P 负责调度的 G 的队列。p量默认等于机器的 CPU 逻辑核心数(GOMAXPROCS 环境变量可设置)。在你和CPU核心允许下,决定了 Go 程序同时并行(Parallel)运行的 goroutine 数量上限是p,即
最大并行数<min(p,cpu核心数)
M 和 P 是动态绑定的关系,但一个 M 在任何一个时刻,最多只能绑定一个 P。反之,一个 P 在任何一个时刻,也只能绑定一个 M
9.1.2 Go 调度器 (Scheduler)的工作原理(高并发的调度原因)
Go 的运行时调度器负责管理 G、M、P 三者之间的关系,其设计目标是减少线程阻塞和切换,最大化 CPU 利用率。
- 窃取式调度 (Work Stealing):当一个 P 的本地运行队列为空(没有可运行的 G)时,它不会立刻挂起对应的 M,而是会进行如下尝试,这保证了所有的 CPU 核心都能始终处于忙碌状态,避免了资源闲置。:
- 从全局运行队列中获取 G。
- 从其他 P 的运行队列中“偷” 一半的 G 过来。
- 切换式调度 (Hand Off):
- 当一个 G 在 M 上发生系统调用(如文件IO、网络IO)而阻塞时,整个 M 会被操作系统挂起,这会导致其绑定的 P 也闲置下来。
- 为避免这样,go的调度器会感知到这一点,并立即将这个 P 从阻塞的 M 上剥离,然后分配一个新的 M 来接管这个 P,继续执行该 P 本地队列里的其他 G。
- 当之前阻塞的系统调用完成后,那个 G 会尝试:
- 找到一个空闲的 P 来恢复执行。
- 如果找不到,它会被放回全局运行队列。
- 对应的 M 也会因为无事可做而进入休眠。
- 这个机制确保了即使有 goroutine 阻塞,调度器依然可以保持其他 goroutine 的并行执行,极大地提高了 CPU 利用率。
- 网络轮询器 (Netpoller):
- 这是 Go 处理网络 IO 的“王牌”。它使用操作系统提供的异步 IO 机制(如 epoll on Linux, kqueue on BSD)。
- 当 goroutine 进行网络操作时,调度器会将其重定向到网络轮询器。该 goroutine 会被挂起,但不会被系统调用阻塞。
- M 可以解放出来,去执行 P 队列里的其他 G,而不会被阻塞。
- 当网络数据就绪后,网络轮询器会通知调度器,相应的 G 会被重新放回某个 P 的运行队列中等待执行。
- 这使得 Go 可以轻松处理海量的网络并发连接(如实现高性能Web服务器),而无需创建大量线程。
- 系统监控 (sysmon):
- 运行时有一个特殊的后台线程 sysmon(系统监控),它不绑定任何 P。它的职责包括:
- 抢占式调度:防止一个 G 长时间(>10ms)占用 CPU,确保调度是公平的。
- 强制GC。
- retake P:向长时间运行(如陷入系统调用)的 G 发出抢占请求,并回收因系统调用而阻塞的 P。
- 运行时有一个特殊的后台线程 sysmon(系统监控),它不绑定任何 P。它的职责包括:
9.1.3 与内核态线程对比
特性 | Goroutine (用户态线程) | OS Thread (内核态线程) |
---|---|---|
创建和销毁 | 开销极小,由 Go runtime 管理 | 开销大,需要陷入内核,分配大量资源 |
栈大小 | 可变栈,初始仅 2KB,按需扩缩容 | 固定栈,通常为 MB 级别(如 2MB) |
切换开销 | 用户态完成,仅需保存少量寄存器,极快 | 需要陷入内核态,上下文切换开销大,需要刷新CPU缓存 |
调度方式 | 协作式 + 抢占式,由 Go runtime 在用户态调度 | 抢占式,由操作系统内核调度,时间片到期即切换 |
并发数量 | 轻松创建数十万个 | 通常最多数千个,受限于内存和调度开销 |
Goroutine 的“回收”是自动的,由 Go 运行时管理,无需程序员手动干预。其生命周期管理如下:
- 创建:通过 go 关键字创建。
- 运行:被调度器调度到某个 M 上执行。
- 结束:
- 正常结束:Goroutine 函数执行到 return 语句。
- 异常结束:函数发生 panic 且未被恢复。
- 清理:
- 当一个 Goroutine 执行结束后,它所使用的执行栈内存会被释放。
- 负责执行它的 M 会负责将相关的资源标记为可用。
- Goroutine 本身的结构体(g 结构)不会被立即销毁,而是会被放回一个空闲 G 池中。下次需要创建新的 Goroutine 时,可以直接从池中复用,避免反复申请内存,提高性能。
9.2 groutine的使用
只需使用go关键字作为函数或方法调用的前缀,即可创建自己的Goroutine 1
2
3
4
5
6func name(){
// 语句
}
// 在函数名前面,使用go关键字
go name()
10 通道(Channel)
通道是goroutine与另一个goroutine通信的媒介,并且这种通信是无锁的。换句话说,通道是一种技术,它允许一个goroutine将数据发送到另一个goroutine。默认情况下,通道是双向的,这意味着goroutine可以通过同一通道发送或接收数据。
10.1 chan的原理
通道的源代码主要位于 Go 运行时库的 runtime/chan.go
文件中。其核心是一个名为 hchan
的结构体。每个通道在底层都是一个** hchan 结构体,它包含了管理通道所需的所有元数据和存储空间。 1
2
3
4
5
6
7
8
9
10
11
12
13type hchan struct {
qcount uint // 当前队列中剩余的元素个数 (len)
dataqsiz uint // 环形队列的大小,即可以存放的元素个数 (cap)
buf unsafe.Pointer // 指向环形队列的指针 (有缓冲通道才有意义)
elemsize uint16 // 每个元素的大小
closed uint32 // 通道是否已关闭的标志
elemtype *_type // 元素类型,用于在赋值时进行类型检查
sendx uint // 发送索引(send index),指向环形队列中下一个发送的位置
recvx uint // 接收索引(receive index),指向环形队列中下一个接收的位置
recvq waitq // 等待接收的 goroutine 队列(sudog 链表)
sendq waitq // 等待发送的 goroutine 队列(sudog 链表)
lock mutex // 互斥锁,保护 hchan 中的所有字段,以及此通道上被阻塞的 goroutines
}make
创建通道时,编译器会将其转换为 runtime.makechan
或runtime.makechan64
的调用。主要工作包括:
- 计算通道和元素所需的内存大小。
- 根据通道是有缓冲还是无缓冲,以及元素是否包含指针,来决定内存分配方式:
- 如果元素不包含指针或缓冲区大小为 0(无缓冲),则一次性分配 hchan 结构体和缓冲区所需的内存。
- 如果元素包含指针且缓冲区较大,则分别分配
hchan
结构体和缓冲区的内存,以便垃圾回收器(GC
)能正确跟踪指针。
2. 发送数据 (ch <- value):发送操作 ch <- value
在底层会调用 runtime.chansend
函数。其执行逻辑是一个大型的状态机,遵循以下步骤:
- 快速路径 (Fast Path):
- 首先会加锁
lock.lock()
,保护通道结构体的所有字段。 - 检查通道是否已关闭,如果已关闭,则直接
panic
。 - 尝试从
recvq
(接收等待队列)中取出一个等待的接收者(sudog)
。- 如果找到:这意味着有一个 goroutine 已经在等待接收数据。此时无需经过缓冲区,直接将数据从发送者拷贝到接收者的栈上。然后唤醒这个接收的 goroutine。这是最高效的方式,相当于直接交付。
- 如果没找到接收者,但缓冲区还有空位
(qcount < dataqsiz)
:- 将数据拷贝到环形缓冲区中(buf)。更新 sendx 索引和 qcount。
- 如果以上两步成功,释放锁并返回。
- 2. 阻塞路径
Blocking Path
:- 如果缓冲区已满(或无缓冲通道没有立即可用的接收者),发送操作无法立即完成。、
- 当前
goroutine
会被打包成一个sudog
结构体,并被放入sendq
(发送等待队列)。 - 然后调用
runtime.gopark
函数,挂起当前goroutine
,释放锁并让出CPU
。 - 当这个
goroutine
之后被唤醒时(因为有接收者取走了数据),会继续执行后续代码,并检查通道状态,最后释放sudog
资源。
- 3.唤醒时机:
- 当一个接收操作到来时,它会首先检查
sendq
队列。 - 如果
sendq
中有等待的发送者,接收者会直接从最先阻塞的发送者那里接收数据(对于无缓冲通道),或者从缓冲区取出头部的数据,再把发送者的数据放入缓冲区尾部,并唤醒这个发送者。
- 当一个接收操作到来时,它会首先检查
3. 接收数据 (<-ch 或 val := <-ch
):接收操作在底层会调用 runtime.chanrecv
函数,其逻辑与发送操作高度对称。
- 1.快速路径 (Fast Path):
- 加锁。
- 检查通道是否已关闭且缓冲区无数据,如果是,则返回零值和 false。
- 尝试从
sendq
(发送等待队列)中取出一个等待的发送者。- 如果找到:对于无缓冲通道,直接从发送者那里拷贝数据。对于有缓冲通道,需要稍微绕一下:先从缓冲区头部取出一个值给接收者,再把发送者的数据放入缓冲区尾部(这保持了通道的 FIFO 语义)。然后唤醒这个发送者。
- 如果没找到发送者,但缓冲区有数据:
- 直接从缓冲区(buf)中拷贝数据到接收变量。
- 更新 recvx 索引和 qcount。
- 如果以上两步成功,释放锁并返回。
- 2.阻塞路径 (Blocking Path):
- 如果缓冲区为空且没有立即可用的发送者,接收操作无法立即完成。
- 当前 goroutine 被打包成 sudog,放入 recvq(接收等待队列)。
- 调用 runtime.gopark 挂起 goroutine。
- 3.唤醒时机:
- 当一个发送操作到来时,它会检查 recvq 队列。
- 如果 recvq 中有等待的接收者,发送者会直接将数据拷贝给最先阻塞的接收者,并唤醒它。
4. 关闭通道 (close(ch)):关闭操作会调用 runtime.closechan
。
- 加锁。
- 设置 closed 标志为 1。
- 遍历 recvq 队列,唤醒所有等待接收的 goroutine。这些被唤醒的接收操作会收到该元素类型的零值,并且 ok 标志为 false。
- 遍历 sendq 队列,唤醒所有等待发送的 goroutine。这些被唤醒的发送操作会立即 panic(因为不能向已关闭的通道发送数据)。
- 释放锁。
简单点说通道的底层是一个受互斥锁保护的环形队列,以及两个用于存储等待 goroutine 的链表。其高效性源于:1) 在可能的情况下进行直接数据投递;2) 在必须等待时,将 goroutine 挂起并放入等待队列,完美融入调度器。
10.2 chan的使用
通道的使用和简单,创建、接收、发送关闭
1
2
3
4
5
6
7
8
9
10
11
12func myfunc(ch chan int) {
fmt.Println(234 + <-ch)
}
func main() {
fmt.Println("主方法开始")
//创建通道l
ch := make(chan int)
go myfunc(ch)
ch <- 23
time.Sleep(3500 * time.Millisecond)
close(ch)
fmt.Println("主方法结束")- len()函数找到通道的长度。在此,长度表示在通道缓冲区中排队的值的数量;在通道中,您可以使用cap()函数找到通道的容量。在此,容量表示缓冲区的大小
1
2len(ch)
cap(ch) 通道默认是双向的,但也可以创建单向通道。只能接收数据的通道或只能发送数据的通道,通过make创建
1
2
3
4//仅接收数据
c1:= make(<- chan bool)
//仅用于发送数据
c2:= make(chan<-bool)
11 go的垃圾回收机制
go的垃圾回收(GC)机制目的是实现自动管理内存,防止内存泄漏。在讨论垃圾回收之前,理解内存是如何被分配的是关键。
11.1 go的内存管理(高并发的内存分配支持)
Go 的内存分配器深受 Google 的 TCMalloc (Thread-Caching Malloc) 影响。其核心思想是通过多层次、细粒度的内存池减少锁竞争,从而实现高效的多线程内存分配。即TCMalloc 风格的内存分配:无锁的 mcache、线程共享的 mcentral 和 mheap 层级结构
Go 的内存管理主要分为以下几个组件:
- mspan (内存跨度、管理单位): 是基本单位,由一个或多个连续的内存页组成(内存页page通常为 8KB)。每个 mspan 被划分为特定大小级别的内存块,有多种不同级别的
mspan
。对于每个span有两个重要的属性,分别是sizeclass、object元素大小
。go一共有67个级别sizeclass
span的大小:每个mspan
都带有一个sizeclass
,标记着该级别的span
中的规模object元素大小
:如果说sizeclass规定了当前这个级别span的总分配内存大小,那么object元素大小则规定这块内存每个存储数据的大小是多少
mcache (线程缓存): 每个逻辑处理器(P)都有一个本地 mcache。当协程需要分配小对象时,直接从本地的 mcache 获取对应的 mspan。这个过程完全无锁,速度极快。
mcentral (中心缓存): 在go,对于每种大小级别的mspan,都有一个全局的
mcentral
。当mcache
中某个级别的 mspan 用完了不够用,它会向mcentral
申请新的mspan
;当mspan
完全空闲时,会归还给mcentral
。在访问mcentral
过程中,因为这个是所有共享的,需要加锁。mheap (堆内存): Go 程序管理的整个堆空间。当 mcentral 也没有可用的 mspan 时,会向 mheap 申请新的内存页。mheap 最终会向操作系统申请内存。
11.2 mcache、mcentral、mheap三者关系(高并发的内存分配支持)
首先,mheap
是程序启动时初始化的,但 mcentral
和 mcache
的初始化与工作方式不同.
1. mheap
- 首先,mheap 是全局唯一的,并且在程序启动时初始化。它管理着进程从操作系统申请的所有虚拟内存,这些内存被组织成一个巨大的mspan数组(mspan是内存管理的基本单位)。
- 在初始化时,
mheap
主要是初始化其各种数据结构(如mspan
的空闲链表、位图等),并预留一大段虚拟地址空间(512GB或更大)。注意,“预留”不等于“提交”。Go会先向操作系统申请一大段地址空间的使用权,但实际物理内存的分配(提交)是发生在程序真正使用内存时(惰性分配)。
2. mcentral (中心缓存)
mcentral
数组也是在程序启动时初始化的。mcentral 是针对每个跨度类(span class) 的。Go定义了67个固定大小的内存规格(size class),每个规格都有对应的mcentral
。- 在启动时,Go会为所有这67个
mcentral
初始化好它们的数据结构(如两个mspan
链表:nonempty链表存储可分配的空闲对象的mspan,empty存放不包含任何空闲对象的mspan)。 - 但在启动之初,所有这些
mcentral
都是空的,里面并没有真正的mspan
。当一个mcentral
需要为分配请求服务时,它会向全局的mheap
申请一个全新的、属于它这个规格的mspan
。
当一个mspan上的所有对象都被分配完毕,它就会被
mcentral
从nonempty
链表移动到empty
链表。注意,这并不意味着这个mspan可以被立即释放回操作系统。它只是暂时“退休”了,它在等待两件事:
- 等待被回收:垃圾回收(GC)阶段会扫描这些mspan。如果GC发现这个mspan上的所有对象都已经变成了垃圾(不可达),那么整个mspan就可以被标记为空闲,从而归还给mheap,最终可能被操作系统回收。
- 等待被复活:在GC扫描之前,如果之前分配出去的某个对象被释放(但Go是GC语言,所以这通常指的是GC标记后),使得这个mspan中至少又有了一个空闲对象,那么它又会被从empty链表移回到nonempty链表,重新参与分配。
3. mcache (线程缓存)
mcache
不是在程序启动时一次性申请的,而是按需动态创建和管理的。mcache
与Go
调度模型中的P(Processor)
绑定。每个P都有一个自己的mcache
。- 程序启动时,会根据
GOMAXPROCS
(默认是CPU核心数)来初始化相应数量的P
。每个P在初始化时,会同时初始化一个与之绑定的mcache
。 所以,初始mcache
的数量等于初始P
的数量。如果运行时发生了GOMAXPROCS
的动态调整(虽然不常见),增加了P
的数量,那么新的P被
创建时,也会同时创建一个新的mcache
给它。同样,如果一个P
被销毁,其对应的mcache
也会被回收。 - 和
mcentral
类似,mcache
在刚创建时,其所有规格的缓存槽位都是空的(nil)
。当协程需要分配内存时,如果对应规格的mcache
槽位是空的,它会去对应的mcentral
申请一个mspan
来填充自己的槽位,然后从这个本地mspan
上进行分配。
11.3 go的GC(高并发的内存回收支持)
Go 的 GC 是一个并发的、三色的、标记-清除 (Mark-Sweep) 收集器。
1. 三色抽象标记法 (Tri-Color Marking)(这是现代垃圾回收器的理论基础,JVM 的很多收集器也使用类似思想。)
- 白色对象: 潜在的垃圾。GC 开始时,所有对象都是白色。
- 灰色对象: 存活对象,但其引用的其他对象还未被扫描。
- 黑色对象: 存活对象,且其引用的所有对象都已被扫描。
标记过程:从根对象(全局变量、栈变量等)开始,将其置为灰色。然后递归地将灰色对象引用的白色对象变为灰色,自身变为黑色。当没有灰色对象时,标记阶段结束,所有剩余的白色对象即为可回收的垃圾。
2. 并发性 (Concurrency) - 与 JVM 的关键区别:这是 Go GC 设计的精髓。它的大部分工作是与用户协程并发执行的,而不是 “Stop-The-World” (STW)。
- 并发标记 (Concurrent Marking): GC 的标记工作与用户程序同时运行。
- 并发清扫 (Concurrent Sweeping): 标记完成后,清扫(回收内存)的工作也是并发的。
- 但完全并发会带来一个问题:在标记过程中,用户程序可能修改对象的引用关系,导致本应存活的对象被误标为垃圾(丢失标记或漏标)。为了解决这个问题,Go 使用了写屏障 (Write Barrier)。
写屏障:在用户代码执行写操作
a.field = b
时,编译器会插入一段特殊的代码(屏障)。这段代码会确保在并发标记期间,如果将一个白色对象的引用写入一个黑色对象,这个白色对象b
会被标记为灰色(从而保护起来,防止被误清)。(其实写屏障涉及知识很细,这里没有细讲)
3. GC 周期与触发机制
- 触发条件:
- 定时触发:默认每 2 分钟。
- 根据堆增长触发:这是最主要的触发方式。当自上次 GC 后,堆内存的活对象(live memory)大小 增长达到一定比例时触发。这个比例由环境变量 GOGC 控制(默认值 100)。
- 公式:\(下一次触发GC的堆大小 = 当前活对象大小 + (当前活对象大小 * GOGC / 100)\),例如:当前活对象占 10MB,GOGC=100,则堆达到 20MB 时触发下一次 GC。
11.4 与 JVM (如 HotSpot) 的深度对比
特性 | Go (Golang) | JVM (HotSpot) | 分析与说明 |
---|---|---|---|
设计哲学 | 低延迟优先 | 选择多样,权衡吞吐/延迟 | Go 从语言诞生之初就为高并发服务,追求极低的 GC 停顿时间(通常 < 1ms)。JVM 提供了多种收集器(如 Parallel GC【吞吐】、CMS【旧低延迟】、G1/ZGC/Shenandoah【新低延迟】),可选择不同策略。 |
内存模型 | 无分代 | 通常分代 | JVM 绝大多数收集器采用分代假说(对象朝生夕死),将堆分为新生代(Young)和老年代(Old),采用不同回收策略(Minor/Full GC)。Go 在 1.16 版本之前完全没有分代。Go 1.19 引入了实验性的分代 GC,但默认未开启,主流仍是无分代设计。 |
GC 算法 | 并发标记清除 | 多样(标记复制/清除/整理) | Go 使用 Mark-Sweep,会产生内存碎片(但有大对象和分配策略优化)。JVM 的年轻代多用标记-复制(无碎片),老年代多用标记-清除或标记-整理(解决碎片)。 |
停顿时间 | 非常短(微秒级) | 因器而异(ZGC/Shenandoah 也极短) | Go 的 STW 阶段只发生在 GC 周期的开始和结束,极其短暂。现代的 JVM 低延迟收集器(ZGC, Shenandoah)也能达到类似水平,但传统的 CMS 或 G1 可能会有更长的停顿。 |
吞吐量 | 相对较低 | 通常更高(尤其是 Parallel GC) | 这是权衡。Go 为了低延迟,将大量 GC工作并发进行,与用户程序争抢 CPU 资源,可能会降低整体吞吐量。JVM 的 Parallel GC 会暂停应用,全力做 GC,单位时间内处理任务更快,吞吐量更高。 |
调优复杂度 | 极其简单 | 非常复杂 | Go 的调优参数极少,主要就是一个 GOGC(控制触发时机)。JVM 有海量的调优参数,需要对内存结构、收集器原理有极深理解才能有效调优,门槛很高。 |
对象布局 | 极度简单 | 复杂 | Go 的对象头极小,几乎没有元数据开销。JVM 的对象头较大(包含 Mark Word、Klass 指针等),为各种高级特性(如偏向锁)服务。 |
11.5 go的GC的STW
存在 Stop-The-World (STW) 的阶段。然而,Go 团队的核心成就就是将 STW 的时间从传统 GC 的毫秒甚至秒级别优化到了微秒 (μs) 级别。
一个完整的 Go GC 周期主要包括四个阶段,其中两个阶段需要 STW:
- GC 开始 (Mark Termination) - STW
- 并发标记 (Concurrent Marking) - 与用户协程并行
- 标记结束 (Mark Termination) - STW (这是最主要的 STW 阶段)
- 并发清扫 (Concurrent Sweeping) - 与用户协程并行
1. GC 开始时的 STW (非常短):为并发标记阶段做准备。
- 启用写屏障 (Write Barrier)。这意味着在接下来的并发标记阶段,所有对指针的写操作都会被写屏障代码“拦截”和处理,以确保标记的正确性。
- 扫描所有根对象(例如:所有 Goroutine 的栈、全局变量等)。根对象是标记过程的起点。
- 持续时间:极短。通常只有 10-30 微秒。因为它的任务非常轻量,只是开启一个开关和快速抓取一下初始的根对象。
2. 标记结束时的 STW (相对较长,但仍非常短)
- 目的:关闭写屏障,并完成一些最终的清理工作,确保标记阶段真正结束。
- 主要工作:
- 关闭写屏障。
- 执行各种状态的清理和切换,宣布标记阶段正式完成。
- 持续时间:这是整个 GC 周期中最长的一次停顿,但通常也仅在 50-100 微秒 左右。对于绝大多数应用程序来说,这个停顿是根本无法感知的。
观察和测量 STW 时间 运行程序时设置 GODEBUG=gctrace=1。
你会在控制台看到类似下面的输出:
1 >GODEBUG=gctrace=1 ./your_go_program
1 >gc 8 @0.251s 0%: 0.015+0.38+0.014 ms clock, 0.12+0.33/0.35/0.15+0.11 ms cpu, 4->4->0 MB, 5 MB goal, 8 P0.015ms:STW 清理和开启写屏障的时间。 0.38ms:并发标记所用的时间。 0.014ms:STW 标记结束的时间