diff --git a/hzh/GolangStar/Go环境搭建/Go环境搭建.md b/hzh/GolangStar/Go环境搭建/Go环境搭建.md new file mode 100644 index 0000000..8b25dbb --- /dev/null +++ b/hzh/GolangStar/Go环境搭建/Go环境搭建.md @@ -0,0 +1,184 @@ +--- +tags: + - Go + - 环境搭建 +--- +# Go环境搭建 +Golang是云原生时代的宠儿,它最大的优点在于简单有效,简单在于上手迅速、代码规范、部署方便;有效在于它能很容易写出高并发的代码,处理能力强。 +Golang能适用于web后台、数据库、云原生、区块链等大多数场景,大厂与其相关的招聘岗位也在逐年增加,因此,学习Golang这样相对较新、发展前景很好的语言,我们是可以实现弯道超车的。 +本文环境安装部分,每个步骤都贴出了执行命令,希望小伙伴们读完此文,自己动手安装Go环境,实现快速入门,用Golang开启新的旅程。 +下面我们就从最基础的环境部署开始,开启我们的Golang之旅吧~ + +## 1. 安装Golang +Go语言可以运行在Linux,Windows以及Mac系统上,对于开发者而言,一般是在本地Windows或者Mac上开发,然后将Go服务部署到Linux上去运行,所以在安装上来说的话,会这三个平台的安装就可以了。 +下面将分别介绍这三个平台的安装方式。Go语言官方安装包地址:[https://golang.org/dl/](https://golang.org/dl/)大家可以上去选择自己需要的版本,通常来说选择次新版本即可,下面的演示选择的是go1.22.10版本 + +![](https://golangstar.cn/assets/img/go语言安装/img/go语言安装/go安装3.png) + +打开[https://go.dev/doc/install](https://go.dev/doc/install)可以看到各个系统下的安装介绍 + +![](https://golangstar.cn/assets/img/go语言安装/go安装4.png) + +### 1.1 Linux 安装方式 +1. 下载安装包下载安装包到当前目录,这里以go1.22.10为例 +```shell +wget https://go.dev/dl/go1.22.10.linux-arm64.tar.gz +``` +2. 将go压缩包解压到指定目录,Linux环境下一般解压到/usr/local目录下 +```shell +rm -rf /usr/local/go && tar -C /usr/local -xzf go1.16.2.linux-amd64.tar.gz +``` +3. 设置环境变量PATH + Linux下有两个文件可以配置环境变量,/etc/profile文件配置所有用户生效;$HOME/.profile对当前用户生效,为了简单起见,选择/etc/profile配置就可以了。 + 当然如果 +5. 根据自己的情况自行选择一个文件打开,添加如下两行代码,保存退出。 +```shell +export PATH=$PATH:/usr/local/go/bin +``` +4. 检查Go版本 +```shell +go version +``` +检查Go的版本,如果执行上述命令能够成功显示go的版本,则说明Go环境安装成功 + +### 1.2 Mac安装 +Mac系统下的安装就很简单了,直接选择对应版本的安装包,图形界面下直接点击下一步可视化安装就好了 + +![](https://golangstar.cn/assets/img/go语言安装/go安装2.png) + +### 1.3 Windows安装 +Windows系统的安装和Mac一样简单,也是直接点击安装包进入安装界面,图形话安装即可。 + +![](https://golangstar.cn/assets/img/go语言安装/go安装5.png) + +### 1.4 GOPROXY设置 +Go1.14版本之后,推荐使用go mod模式来管理依赖,GOPROXY是Go语言官方提供的一种通过中间代理商来为用户提供依赖包下载服务的方式,它的值是一个以英文逗号“,”分割的 Go module proxy 列表。 +它的默认值是:https://proxy.golang.org,direct,但是在国内可能会存在下载慢或者无法访问的情况,所以我们需要换一个PROXY,这里推荐使用https://goproxy.cn。执行以下命令更改GOPROXY设置即可 +``` + go env -w GOPROXY=https://goproxy.cn,direct +``` +注意, 后面有两个选项:direce和off。设置为 “off” 表示禁止Go在后续操作中使用任何Go module proxy,“direct” 为特殊指示符,表示Go模块下载的备选方案或回退选项,这里配置了direct后, +当下载依赖包时,首先从代理站点https://proxy.golang.org处进行下载,如果下载失败,则会回退到direct,直接从代码源下载,所以一般最后逗号后都会加上direct。 +设置完GOPROXY之后,通过`go env`命令查看 + +![](https://golangstar.cn/assets/img/go语言安装/go安装6.png) + +### 1.5 GOPRIVATE设置 +前面的GOPROXY设置后,G程序拉取依赖都会走代理,有时候我们的程序开发需要拉取一些内部的私有依赖,比如公司或者小组内开发的公共组件,这些依赖没有对外公开,在外部的代理站点拉取不到。 +我们希望可以不走代理,直接下载。所以就需要配置GOPRIVATE环境变量。比如配置了以下GOPRIVATE +``` +go env -w GOPRIVATE="*.gitee.cn" +``` +那么Go程序在拉取gitee.cn上的依赖包,就会绕过代理直接下载,不会去GOPROXY中的代理站点下载;而其他的依赖包则会去GOPROXY中的代理站点下载. +设置完GOPROXY之后,通过`go env`命令查看 + +![](https://golangstar.cn/assets/img/go语言安装/go安装7.png) + +## 2. Go开发编辑器 +### 2.1 Goland安装 + +Go语言推荐使用GoLand在本地进行开发,Goland是Jetbrains家族出品,有强大的Plugin,代码跳转,自动格式化,错误检测等用起来都十分方便 + +GoLand下载官网地址: https://www.jetbrains.com/go/download/, 根据操作系统选择对应包下载安装即可 +![](https://golangstar.cn/assets/img/go语言安装/go安装8.png) + +### 2.2 Goland设置 +#### 2.2.1 设置GOROOT +在安装完Goland后,首先需要设置GOROOT,用来编译运行Go代码。在Goland的settings配置起来十分方便。打开Settings → Go → GOROOT设置即可 + +![](https://golangstar.cn/assets/img/go语言安装/go安装9.png) + +#### 2.2.1 设置Go Modules +Go1.14版本之后,Go项目推荐使用Go Modules来管理依赖,在Goland中要使用Go Modules,同样要把Go Modules设置开启,打开Settings → Go → Go Moudles,勾选打开即可 + +![](https://golangstar.cn/assets/img/go语言安装/go安装10.png) + +## 3. 第一个Go程序 +按照惯例,环境搭建好之后,我们就来创建第一个Go程序:Hello World +### 3.1 命令行方式 +1. 首先创建一个空的文件夹hello +``` +mkdir hello +``` +2. 进入到hello文件夹,将该文件夹初始化为一个go module项目 +``` +cd hello +go mod init hello +``` +执行完上述命令后,可以看到在hello文件夹下多出了一个go.mod文件,内容如下 + +![](https://golangstar.cn/assets/img/go语言安装/go安装11.png) + +可以查看下go.mod的文件内容,第一行表示module名为hello,go的版本为1.20 + +![](https://golangstar.cn/assets/img/go语言安装/go安装12.png) + + +3. 在hello文件夹创建一个main.go文件,文件内容如下: +```go +package main // 声明 main 包,表明当前是一个可执行程序 + +import "fmt" // 导入内置 fmt 包 + +func main(){ // main函数,是程序执行的入口 + fmt.Println("Hello World!") // 在终端打印 Hello World! +} +``` + +4. 在当前目录下执行以下命令,直接运行程序 +``` +go run main.go +``` +可以看到终端输出hello world。 + +![](https://golangstar.cn/assets/img/go语言安装/go安装13.png) + +5. 也可以直接编译工程,然后在执行。在当前hello目录下执行 +``` +go build +``` +执行完会成成一个二进制的可执行文件hello,然后执行以下命令直接运行 +``` +./hello +``` +同样可以看到终端输出hello world。 + +![](https://golangstar.cn/assets/img/go语言安装/go安装14.png) + +### 3.1 Goland方式 +#### 3.1.1 新建Go项目 +打开Goland应用,或者事在已经打开的Goland的最上方选择File → New → Project, 继而弹出New Project设置向导: + +![](https://golangstar.cn/assets/img/go语言安装/go安装15.png) + +点击create创建完成,注意使用Goland创建完项目后,项目下自动就有go.mod文件 + +![](https://golangstar.cn/assets/img/go语言安装/go安装16.png) + +接着新建hello1.go文件,代码如下 +```go +package main + +import "fmt" // 导入内置 fmt 包 + +func main() { // main函数,是程序执行的入口 + fmt.Println("Hello World!") // 在终端打印 Hello World! +} +``` +然后直接点击运行,即可看到运行结果,打印出Hello World。 + +![](https://golangstar.cn/assets/img/go语言安装/go安装17.png) + + +#### 3.1.2 导入已有Go项目 +如果你本地已有Go项目代码,只需在主菜单选择File → Open,打开你的项目目录即可。比如我们打开刚刚命令行方式常见的hello工程 + +![](https://golangstar.cn/assets/img/go语言安装/go安装18.png) + +同样可以直接运行,打印出Hello World + +![](https://golangstar.cn/assets/img/go语言安装/go安装19.png) + + + + diff --git a/hzh/GolangStar/Go编码规范/Go编码规范.md b/hzh/GolangStar/Go编码规范/Go编码规范.md new file mode 100644 index 0000000..27dff01 --- /dev/null +++ b/hzh/GolangStar/Go编码规范/Go编码规范.md @@ -0,0 +1,941 @@ +--- +tags: + - Go + - golang + - go编码规范 + - 程序格式化 +--- + +# Go编码规范 + +## 1.代码规范 + +代码范旨在为日常Go项目开发提供一个代码的规范指导,方便形成一个良好的代码,提高代码的可读性,规范性和统一性。 + +## 2. 代码风格 + +### 2.1 【必须】格式化 + +* 代码都必须用 `gofmt` 格式化。 + +### 2.2 【推荐】换行 + +* 建议一行代码不要超过`120列`,超过的情况,使用合理的换行方法换行。 + +* 例外场景: + + * 函数签名(当然,这可能意味着你需要重新考虑是否传递了过多参数) + + ```go + // 长的函数签名可以超过列数限制 + func (i *webImpl) GenerateAgentInstallLink(ctx context.Context, req *pb.GenerateAgentInstallLinkRequest) (*pb.GenerateAgentInstallLinkResponse, error) { + ... + } + + // 不要在函数签名中为了满足推荐列数换行! + func (i *webImpl) GenerateAgentInstallLink(ctx context.Context, + req *pb.GenerateAgentInstallLinkRequest) (*pb.GenerateAgentInstallLinkResponse, error) { + ... // gofmt 会使签名与函数内语句对齐,导致代码可读性降低 + } + ``` + + * 长字符串文字(如果存在换行符 `\n`,应考虑使用原始字符串字面量 `` `raw string literal` ``) + + ```go + // 长的字符串可以超过列数限制 + pubkey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDPqE4yo8w7lezTpCkTt40whHoubL+0Qhsq1L3hF/lDW8zx1NpFTR9fC1HOqmUXj7tqPVg2xOAH+..." + + // 不要为了满足推荐列数换行并拼接字符串! + pubkey := "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDPqE4yo8w7lezTpCkTt40whHoubL+0Qhsq1L3hF/lDW8zx1NpFTR9fC1HOqmUXj7tqPVg2xOAH+"+ + "zi2SqaZVeeXmsF5GAGFJcUylujr78Wf6od8//SApYx8RCSkRhGo8cTsxADlBCoTttJvk6Ocmy+uqEFXulsI0j+nh2x352eCExlDSqr0Me0J0LIGq/u9eqwhNN5k"+ + ... // 按列换行不会使代码更加可读,但会使字符串的搜索和修改更加困难! + + // 长的多行文本可以考虑使用原始字符串字面量 + tmpl := `some + long + tedious + template` + ``` + + import 模块语句 + + * 工具生成代码 + + * struct tag + +### 2.3 【必须】括号和空格 + +* 遵循 `gofmt` 的逻辑。 + +* 运算符和操作数之间要留空格。 + +* 作为输入参数或者数组下标时,运算符和运算数之间不需要空格,紧凑展示。 + +### 2.4 【必须】import 规范 + +* 使用 `goimports` 自动格式化引入的包名,import 规范原则上以 `goimports` 规则为准。 + +* `goimports` 会自动把依赖包按首字母排序,并对包进行分组管理,通过空行隔开,默认分为本地包(标准库、内部包)、第三方包。 + +* 标准包永远位于最上面的第一组。 + +* 内部包是指不能被外部 import 的包,如 GoPath 模式下的包名或者非域名开头的当前项目的 GoModules 包名。 + +* 带域名的包名都属于第三方包,如 github.com/xxx/xxx,不用区分是否是当前项目内部的包。 + +* `goimports` 默认最少分成本地包和第三方包两大类,这两类包必须分开不能放在一起。本地包或者第三方包内部可以继续按实际情况细分不同子类。 + +* 不要使用相对路径引入包: + +```go +// 不要采用这种方式 +import ( + "../net" +) +``` + +* 应该使用完整的路径引入包: + +```plaintext +import ( + "xxxx.com/proj/net" +) +``` + +* 包名和 git 路径名不一致时,或者多个相同包名冲突时,使用别名代替,别名命名规范和包命名规范保持一致: + +```go +// 合理用法:包名和 git 路径名不一致,使用别名 +import ( + opentracing "github.com/opentracing/opentracing-go" +) + +// 合理用法:多个相同包名冲突,使用别名 + import ( + "fmt" "os" "runtime/trace" + nettrace "golang.net/x/trace" + ) + + // 不合理用法:包名和路径名一致,也不存在多包名冲突,并且原包名符合规范,则不应该使用别名 + import ( + "fmt" + "os" + + nettrace "golang.net/x/trace" + ) +``` + +* 【可选】第三方包的包名不符合规范可使用别名修正: + +```plaintext +import ( + qzone "git.code.oa.com/trpcprotocol/qqconnect/share_to_qzone" +) +``` + +* 【可选】匿名包的引用建议使用一个新的分组引入,并在匿名包上写上注释说明。 + +完整示例如下: + +```go +import ( + // standard package & inner package + "encoding/json" + "myproject/models" + "myproject/controller" + "strings" + + // third-party package + "git.obc.im/obc/utils" + "git.obc.im/dep/beego" + "git.obc.im/dep/mysql" + opentracing "github.com/opentracing/opentracing-go" + + // anonymous import package + // import filesystem storage driver + _ "git.code.oa.com/org/repo/pkg/storage/filesystem" +) +``` + +* 例外包 + + * embed 当使用embed内嵌外部数据时不需要注释 + +### 2.5 【必须】错误处理 + +阅读 学习 go 错误处理机制的设计。 + +#### 2.5.1 【必须】error 处理 + +* `error` 作为函数的值返回,必须对 `error` 进行处理, 或将返回值赋值给明确忽略。对于 `defer xx.Close()`可以不用显式处理。 + +* `error` 作为函数的值返回且有多个返回值的时候,`error` 必须是最后一个参数。 + +```go +// 不要采用这种方式 +func do() (error, int) { + +} + +// 要采用下面的方式 +func do() (int, error) { + +} +``` + +* 错误描述不需要标点结尾。 + +* 采用独立的错误流进行处理。 + +```go +// 不要采用这种方式 +if err != nil { + // error handling +} else { + // normal code +} + +// 而要采用下面的方式 +if err != nil { + // error handling + return // or continue, etc. + } + // normal code +``` + +* 如果返回值需要初始化,则采用下面的方式: + +```go +x, err := f() +if err != nil { + // error handling + return + // or continue, etc. +} +// use x +``` + +* 错误返回的判断独立处理,不与其他变量组合逻辑判断。 + +```go +// 不要采用这种方式: +x, y, err := f() +if err != nil || y == nil { + return err // 当y与err都为空时,函数的调用者会出现错误的调用逻辑 +} + +// 应当使用如下方式: +x, y, err := f() +if err != nil { + return err +} + +if y == nil { + return errors.New("some error") +} +``` + +* 【推荐】对于不需要格式化的错误,生成方式为:`errors.New("xxxx")`。 + +* 【推荐】建议go1.13 以上,error 生成方式为:`fmt.Errorf("module xxx: %w", err)`。 + +#### 2.5.2 【必须】panic 处理 + +* 不要使用 `panic` 进行一般的错误处理,使用 `error` 和多返回值。 + +* 可使用 panic 对不变量([invariant](https://en.wikipedia.org/wiki/Invariant_%28mathematics%29#Invariants_in_computer_science))进行断言。 + +```plaintext +// 不要对用户输入进行断言,使用错误返回! +v, err := strconv.Atoi(userInputFromKeyboard) +if err != nil { + panic(fmt.Errorf("invalid user input: %v", err)) +} + +// 可以对不变量进行断言// readText 读取 n 中的文本内容。Node 必须为 TextNode 或 CommentNode。 +func readText(n Node) string { + switch n := n.(type) { + case *TextNode: + return n.Text + case *CommentNode: + return n.Comment + default: + panic(fmt.Errorf("unexpected node type: %T", n)) + } +} +``` + +* 在 `func init()` 及其调用的函数中,当初始化失败影响程序运行时,可以 `panic`,例如:读取本地文件系统中的配置文件。 + +* 在全局变量初始时调用的函数中,当初始化失败影响程序运行时,可以 `panic`,例如:`regexp.MustCompile`,`template.Must`。 + +* 导出的方法一般不允许 `panic`。因特殊情况必须 `panic` 的方法应使用 `MustXXX` 的方式进行命名,并在文档中加以说明。 + +* 不建议使用 `log.Fatal` 进行断言:log 库必须初始化才可以使用;log 库可能无法正确显示堆栈信息;log 库在 `panic` 时可能无法有效的保留错误信息。 + +#### 2.5.3 【必须】recover 处理 + +* 必须在 `defer` 中使用。 + +* 在业务逻辑中一般不需要使用 `recover`。 + +* `recover` 用于捕获具有明确类型的 `panic`,禁止滥用 `recover` 捕获全部类型的异常。 + +```go +// FatalError 为包内定义的错误类型 +type FatalError string + +func (e FatalError) Error() string { + return string(e) +} + +func main() { + defer func() { + e := recover() // 注意,recover 返回的是 interface{},不要假设其是 error,因此不要将返回值命名为 err。 + if e != nil { + err, ok := e.(FatalError) + if !ok { + // 继续抛出不认识的异常 + panic(e) + } + // 响应抛出的错误 + } + }() + panic(FatalError("错误信息")) +} +``` + +### 2.6 【必须】单元测试 + +* 单元测试文件名命名规范为 `example_test.go`。 + +* 测试用例的函数名称必须以 `Test` 开头,例如 `TestExample`。 + +* 如果存在 `func Foo`,单测函数可以带下划线,为 `func Test_Foo`。如果存在 `func (b *Bar) Foo`,单测函数可以为 `func TestBar_Foo`。下划线不能出现在前面描述情况以外的位置。 + +* 单测文件行数限制是普通文件的2倍,即`1600行`。单测函数行数限制也是普通函数的2倍,即为`160行`。圈复杂度、列数限制、 import 分组等其他规范细节和普通文件保持一致。 + +* 由于单测文件内的函数都是不对外的,所有可导出函数可以没有注释,但是结构体定义时尽量不要导出。 + +* 每个重要的可导出函数都要首先编写测试用例,测试用例和正规代码一起提交方便进行回归测试。 + +## 3. 注释 + +* 在编码阶段同步写好变量、函数、包注释,注释可以通过 `godoc` 导出生成文档。 + +* 程序中每一个被导出的(大写的)名字,都应该有一个文档注释。注意,非导出类型的方法(`func (*privateType) Method()`)可以没有文档注释。 + +* 所有注释掉的代码在提交 code review 前都应该被删除,除非添加注释讲解为什么不删除, 并且标明后续处理建议(比如删除计划)。 + +### 3.1 【必须】包注释 + +* 每个包都应该有一个包注释。 + +* 包如果有多个 go 文件,只需要出现在一个 go 文件中(一般是和包同名的文件)即可,格式为:"// Package 包名 包信息描述"。 + +```go +// Package math provides basic constants and mathematical functions. +package math + +// 或者 +/*Package template implements data-driven templates for generating textual +output such as HTML. +.... +*/ +package template +``` + +### 3.2 【必须】结构体注释 + +* 每个需要导出的自定义结构体或者接口都必须有注释说明。 + +* 注释对结构进行简要介绍,放在结构体定义的前一行。 + +* 格式为:"// 结构体名 结构体信息描述"。 + +* 结构体内的可导出成员变量名,如果是个生僻词,或者意义不明确的词,就必须要给出注释,放在成员变量的前一行或同一行的末尾。 + +```go +// User 用户结构定义了用户基础信息 +type User struct { + Name string + Email string + // Demographic 族群 + Demographic string +} +``` + +## 3.3 【必须】方法注释 + +* 每个需要导出的函数或者方法(结构体或者接口下的函数称为方法)都必须有注释。注意,如果方法的接收器为不可导出类型,可以不注释,但需要质疑该方法可导出的必要性。 + +* 注释描述函数或方法功能、调用方等信息。 + +* 格式为:"// 函数名 函数信息描述"。 + +```go +// NewtAttrModel 是属性数据层操作类的工厂方法 +func NewAttrModel(ctx *common.Context) *AttrModel { + // TODO +} +``` + +* 例外方法: + + * Write Read 用于常见IO + + * ServeHTTP 用于HTTP服务 + + * String 用于打印 + + * Unwrap Error 用于错误处理 + + * Len Less Swap 用于排序 + +## 3.4 【必须】变量和常量注释 + +* 每个需要导出的常量和变量都必须有注释说明。 + +* 该注释对常量或变量进行简要介绍,放在常量或者变量定义的前一行。 + +* 大块常量或变量定义时,可在前面注释一个总的说明,然后每一行常量的末尾详细注释该常量的定义。 + +* 格式为:"// 变量名 变量信息描述",斜线后面紧跟一个空格。 + +```go +// FlagConfigFile 配置文件的命令行参数名 +const FlagConfigFile = "--config" + +// 命令行参数 +const ( + FlagConfigFile1 = "--config" // 配置文件的命令行参数名1 + FlagConfigFile2 = "--config" // 配置文件的命令行参数名2 + FlagConfigFile3 = "--config" // 配置文件的命令行参数名3 + FlagConfigFile4 = "--config" // 配置文件的命令行参数名4 +) + +// FullName 返回指定用户名的完整名称 +var FullName = func(username string) string { + return fmt.Sprintf("fake-%s", username) +} +``` + +## 3.5 【必须】类型注释 + +* 每个需要导出的类型定义(type definition)和类型别名(type aliases)都必须有注释说明。 + +* 该注释对类型进行简要介绍,放在定义的前一行。 + +* 格式为:"// 类型名 类型信息描述"。 + +```go +// StorageClass 存储类型 +type StorageClass string + +// FakeTime 标准库时间的类型别名 +type FakeTime = time.Time +``` + +# 4. 命名规范 + +命名是代码规范中很重要的一部分,统一的命名规范有利于提高代码的可读性,好的命名仅仅通过命名就可以获取到足够多的信息。 + +## 4.1 【推荐】包命名 + +* 保持 `package` 的名字和目录一致。 + +* 尽量采取有意义、简短的包名,尽量不要和标准库冲突。 + +* 包名应该为小写单词,不要使用下划线或者混合大小写,使用多级目录来划分层级。 + +* 包名可谨慎地使用缩写。当缩写是程序员广泛熟知的词时,可以使用缩写。例如: + + * strconv (string conversion) + + * syscall (system call) + + * fmt (formatted I/O) + +* 如果缩写有歧义或不清晰,不用缩写。 + +* 项目名可以通过中划线来连接多个单词。 + +* 简单明了的包命名,如:`time`、`list`、`http`。 + +* 不要使用无意义的包名,如:`util`、`common`、`misc`、`global`。`package`名字应该追求清晰且越来越收敛,符合‘单一职责’原则。而不是像`common`一样,什么都能往里面放,越来越膨胀,让依赖关系变得复杂,不利于阅读、复用、重构。注意,`xx/util/encryption`这样的包名是允许的。 + +## 4.2 【必须】文件命名 + +* 采用有意义,简短的文件名。 + +* 文件名应该采用小写,并且使用下划线分割各个单词。 + +## 4.3 【必须】结构体命名 + +* 采用驼峰命名方式,首字母根据访问控制采用大写或者小写。 + +* 结构体名应该是名词或名词短语,如 `Customer`、`WikiPage`、`Account`、`AddressParser`,它不应是动词。 + +* 避免使用 `Data`、`Info` 这类意义太宽泛的结构体名。 + +* 结构体的声明和初始化格式采用多行,例如: + +```go +// User 多行声明 +type User struct { + Name string + Email string +} + +// 多行初始化 +u := User{ + Name: "john", + Email: "john@example.com", +} +``` + +## 4.4 【推荐】接口命名 + +* 命名规则基本保持和结构体命名规则一致。 + +* 单个函数的接口名以 `er` 作为后缀,例如 `Reader`,`Writer`。 + +```go +// Reader 字节数组读取接口 +type Reader interface { + // Read 读取整个给定的字节数据并返回读取的长度 + Read(p []byte) (n int, err error) +} +``` + +* 两个函数的接口名综合两个函数名。 + +* 三个以上函数的接口名,类似于结构体名。 + +```go +// Car 小汽车结构申明 +type Car interface { + // Start ... + Start([]byte) + // Stop ... + Stop() error + // Recover ... + Recover() +} +``` + +## 4.5 【必须】变量命名 + +* 变量名必须遵循驼峰式,首字母根据访问控制决定使用大写或小写。 + +* 特有名词时,需要遵循以下规则: + + * 如果变量为私有,且特有名词为首个单词,则使用小写,如 `apiClient`; + + * 其他情况都应该使用该名词原有的写法,如 `APIClient`、`repoID`、`UserID`; + + * 错误示例:`UrlArray`,应该写成 `urlArray` 或者 `URLArray`; + + * 详细的专有名词列表可参考[这里](https://github.com/golang/lint/blob/738671d3881b9731cc63024d5d88cf28db875626/lint.go#L770)。 + +* 私有全局变量和局部变量规范一致,均以小写字母开头。 + +* 代码生成工具自动生成的代码可排除此规则(如 xxx.pb.go 里面的 Id)。 + +* 变量名更倾向于选择短命名。特别是对于局部变量。 `c`比`lineCount`要好,`i`比`sliceIndex`要好。基本原则是:变量的使用和声明的位置越远,变量名就需要具备越强的描述性。 + +## 4.6 【必须】常量命名 + +* 常量均需遵循驼峰式。 + +```go +// AppVersion 应用程序版本号定义 +const AppVersion = "1.0.0" +``` + +* 如果是枚举类型的常量,需要先创建相应类型: + +```go +// Scheme 传输协议 +type Scheme stringconst ( + // HTTP 表示HTTP明文传输协议 + HTTP Scheme = "http" + // HTTPS 表示HTTPS加密传输协议 + HTTPS Scheme = "https" +) +``` + +* 私有全局常量和局部变量规范一致,均以小写字母开头。 + +```go +const appVersion = "1.0.0" +``` + +## 4.7 【必须】函数命名 + +* 函数名必须遵循驼峰式,首字母根据访问控制决定使用大写或小写。 + +* 代码生成工具自动生成的代码可排除此规则(如协议生成文件 xxx.pb.go , gotests 自动生成文件 xxx\_test.go 里面的下划线)。 + +# 5. 控制结构 + +## 5.1 【推荐】if + +* `if` 接受初始化语句,约定如下方式建立局部变量: + +```go +if err := file.Chmod(0664); err != nil { + return err +} +``` + +* `if` 对两个值进行判断时,约定如下顺序:变量在左,常量在右: + +```go +// 不要采用这种方式 +if nil != err { + // error handling +} + +// 不要采用这种方式 +if 0 == errorCode { + // do something +} + +// 而要采用下面的方式 +if err != nil { + // error handling +} + +// 而要采用下面的方式 +if errorCode == 0 { + // do something +} +``` + +* `if` 对于bool类型的变量,应直接进行真假判断: + +```go +var allowUserLogin bool + +// 不要采用这种方式 +if allowUserLogin == true { + // do something +} + +// 不要采用这种方式 +if allowUserLogin == false { + // do something +} + +// 而要采用下面的方式 +if allowUserLogin { + // do something +} + +// 而要采用下面的方式 +if !allowUserLogin { + // do something +} +``` + +## 5.2 【推荐】for + +* 采用短声明建立局部变量: + +```go +sum := 0 +for i := 0; i < 10; i++ { + sum += 1 +} +``` + +## 5.3 【必须】range + +* 如果只需要第一项(key),就丢弃第二个: + +```go +for key := range m { + if key.expired() { + delete(m, key) + } +} +``` + +* 如果只需要第二项,则把第一项置为下划线: + +```go +sum := 0 +for _, value := range array { + sum += value +} +``` + +## 5.4 【必须】switch + +* 要求必须有 `default`: + +```go +switch os := runtime.GOOS; os { + case "darwin": + fmt.Println("OS X.") + case "linux": + fmt.Println("Linux.") + default: + // freebsd, openbsd, + // plan9, windows... + fmt.Printf("%s.\n", os) +} +``` + +## 5.5 【推荐】return + +* 尽早 `return`,一旦有错误发生,马上返回: + +```go +f, err := os.Open(name) +if err != nil { + return err +} +defer f.Close() + +d, err := f.Stat() +if err != nil { + return err +} + +codeUsing(f, d) +``` + +## 5.6 【必须】goto + +* 业务代码禁止使用 `goto`,其他框架或底层源码推荐尽量不用。 + +# 6. 函数 + +## 6.1 【推荐】函数参数 + +* 函数返回相同类型的两个或三个参数,或者如果从上下文中不清楚结果的含义,使用命名返回,其它情况不建议使用命名返回。 + +```go +// Parent1 ... +func (n *Node) Parent1() *Node + +// Parent2 ... +func (n *Node) Parent2() (*Node, error) + +// Location ... +func (f *Foo) Location() (lat, long float64, err error) +``` + +* 传入变量和返回变量以小写字母开头。 + +* 参数数量均不能超过`5个`。 + +* 尽量用值传递,非指针传递。 + +* 传入参数是 `map`,`slice`,`chan`,`interface` 不要传递指针。 + +## 6.2 【必须】defer + +* 当存在资源管理时,应紧跟 `defer` 函数进行资源的释放。 + +* 判断是否有错误发生之后,再 `defer` 释放资源。 + +```go +resp, err := http.Get(url) +if err != nil { + return err +} +// 如果操作成功,再defer Close() +defer resp.Body.Close() +``` + +* 禁止在循环中使用 `defer`(因为这样的defer得filterSomething函数结束才能统一调用),举例如下: + +```go +// 不要这样使用 +func filterSomething(values []string) { + for _, v := range values { + fields, err := db.Query(v) // 示例,实际不要这么查询,防止sql注入 + if err != nil { + // xxx + } + defer fields.Close() + // 继续使用fields + } +} + +// 应当使用如下的方式:在每个闭包return之前处理闭包中的defer +func filterSomething(values []string) { + for _, v := range values { + func() { + fields, err := db.Query(v) // 示例,实际不要这么查询,防止sql注入 + if err != nil { + ... + } + defer fields.Close() + // 继续使用fields + }() + } +} +``` + +## 6.3 【推荐】方法的接收器 + +* 【推荐】推荐以类名第一个英文首字母的小写作为接收器的命名。 + +* 【推荐】接收器的命名在函数超过`20行`的时候不要用单字符。 + +* 【必须】命名不能采用 `me`,`this`,`self` 这类易混淆名称。 + +## 6.4 【推荐】代码行数 + +* 【必须】文件长度不能超过`800行`。 + +* 【推荐】函数长度不能超过`80行`(函数长度为函数签名左括号下一行开始到右括号上一行结束部分的行数,包括代码行,注释行,空行)。 + +## 6.5 【必须】嵌套 + +* 嵌套深度不能超过`4层`: + +```go +// AddArea 添加成功或出错 +func (s *BookingService) AddArea(areas ...string) error { + s.Lock() + defer s.Unlock() + for _, area := range areas { + for _, has := range s.areas { + if area == has { + return srverr.ErrAreaConflict + } + } + s.areas = append(s.areas, area) + s.areaOrders[area] = new(order.AreaOrder) + } + return nil +} +``` + +```go +// 建议调整为这样: + +// AddArea 添加成功或出错 +func (s *BookingService) AddArea(areas ...string) error { + s.Lock() + defer s.Unlock() + for _, area := range areas { + if s.HasArea(area) { + return srverr.ErrAreaConflict + } + s.areas = append(s.areas, area) + s.areaOrders[area] = new(order.AreaOrder) + } + return nil +} + +// HasArea ...func (s *BookingService) +HasArea(area string) bool { + for _, has := range s.areas { + if area == has { + return true + } + } + return false +} +``` + +## 6.6 【推荐】变量声明 + +* 变量声明尽量放在变量第一次使用前面,就近原则。 + +## 6.7 【必须】魔法数字 + +魔数应使用常量或变量做替代。 + +魔数常常具有以下特征: + +* 缺乏解释或命名的表示相同含义的独特数值,对读者来说难以理解,影响可读性 + +* 在程序中出现多次,当数值改变时,可能要改不只一个地方 + +例如: + +```go +total := 1.05 * price + +// 应改写为 +const TaxRate = 0.05 +total := (1.0 + TaxRate) * price +``` + +正确的使用命名量替代魔数可以改善可读性,但是也存在代价: + +* 如果命名量与使用处距离很远,则会破坏代码的局部性,使代码的理解更加困难 + +```go +// 不要这样做! +const ( + ... + appNameFormatErrorMsgFormat = "the app name is incorrect: %q" + userNameFormatErrorMsgFormat = "the user name is invalid: %q" + ... +) + +... 省略 100 行 ... +func ParseAppName(v string) error { + ... + return fmt.Errorf(appNameFormatErrorMsgFormat, v) // 开发者无法直接知道需要传入几个参数,也无法通过错误信息直接找到产生错误的位置 +} +``` + +* 它可能使代码更加冗长 + +* 它可能使代码运行更慢(从编译时计算变成运行时计算) + +因此,正确识别魔数非常重要。一般而言,只要在上下文中能让人一眼明白其含义,并且基本没有需要改变的可能,就不会被认为是魔术数字。常见的例子包括: + +```go +d = b*b - 4*a*c // 一元二次方程判别式公式中的 4 +if x%2 == 0 {} // 中的 2 +for i := 0; i < max; i += 1 {} // 中的 0 和 1 +os.Exit(1) // 表示程序错误的 1(当然,如果需要区分多种不同的错误,则应该为不同的退出值进行命名) +scn.Buffer(nil, 10<<20) // 中的 10<<20 +strings.IndexOf(s, ":") == -1 // 中的 ":" 和 -1 +``` + +# 7. 依赖管理 + +## 7.1 【必须】go1.11 以上必须使用 `go modules` 模式: + +```go +go mod init git.woa.com/group/myrepo +``` + +## 7.2 【推荐】代码提交 + +* 建议所有不对外开源的工程的 `module name` 使用 `git.woa.com/group/repo` ,方便他人直接引用。 + +* 建议使用 `go modules` 作为依赖管理的项目不提交 `vendor` 目录。 + +* 建议使用 `go modules` 管理依赖的项目, `go.sum` 文件必须提交,不要添加到 .gitignore 规则中。 + +# 8. 应用服务 + +## 8.1 【推荐】应用服务接口建议有 `README.md` + +* 其中建议包括服务基本描述、使用方法、部署时的限制与要求、基础环境依赖(例如最低 go 版本、最低外部通用包版本)等。 + +## 8.2 【必须】应用服务必须要有接口测试。 + +## 附:常用工具 + +go 语言本身在代码规范性这方面也做了很多努力,很多限制都是强制语法要求,例如左大括号不换行,引用的包或者定义的变量不使用会报错,此外 go 还是提供了很多好用的工具帮助我们进行代码的规范。 + +* `gofmt` ,大部分的格式问题可以通过 `gofmt` 解决, `gofmt` 自动格式化代码,保证所有的 go 代码与官方推荐的格式保持一致,于是所有格式有关问题,都以 `gofmt` 的结果为准。 + +* `goimports` ,此工具在 `gofmt` 的基础上增加了自动删除和引入包。 + +* `go vet` ,`vet` 工具可以帮我们静态分析我们的源码存在的各种问题,例如多余的代码,提前 `return` 的逻辑, `struct` 的 `tag` 是否符合标准等。编译前先执行代码静态分析。 + +* `golint` ,类似 `javascript` 中的 `jslint` 的工具,主要功能就是检测代码中不规范的地方。 diff --git a/hzh/GolangStar/Go语言前景/Go语言前景.md b/hzh/GolangStar/Go语言前景/Go语言前景.md new file mode 100644 index 0000000..0d0a727 --- /dev/null +++ b/hzh/GolangStar/Go语言前景/Go语言前景.md @@ -0,0 +1,84 @@ +# Go语言前景 + +当下BAT大厂正在把Go作为新项目开发的首选语言,今天给大家带来一个系统的go语言相关知识梳理,也希望能够在未来和大家一起学习和探讨。 + +思考再三,为什么是go呢? + +其实,选择go语言,也是顺应当下形势了,作为程序员的我们,在选择吃饭的家伙上,当然也要顺势而为嘛。下面就跟大家一起分析go语言到底有啥优势,未来趋势又如何? + +## 市场行情 + +首先来看一下go语言的市场行情,因为谈钱,永不过时。截止到2023年5月Tiobe编程语言排行榜 + +![](https://golangstar.cn/assets/img/go语言前景/go语言前景1.png) + +go语言是排在第12位,从排名上看是远不如传统的几大热门语言比如python,c,c++还有java。那为什么却又说go预言是趋势呢? +排名上不如上述几大热门语言,这正说明了当前go语言开发者比较少,不像java开发者在后端市场的白热化竞争,现在go语言开发者的竞争没有那么激烈。 +接下来看一下go语言岗位在市场上的需求 + +![](https://golangstar.cn/assets/img/go语言前景/go语言前景2.png) + +可以看到,go语言招聘市场的需求也是很大,大小中厂其实都有一定的岗位需求。 +当然不得不承认,现在整个后端市场可以说还是java的天下,在大中小公司都占据着绝对地位,像阿里, 京东,美团等,而且银行也基本全是java的技术栈。既然行情如此,为什么还说go语言有不错的市场前景呢? + +java岗位多归多,但是求职者也多,可以说是人满为患。说java是计算机求职领域最卷的应该没有人会反对,从几年前的了解jvm特性,会点基本的多线程特性以及调优就能取得不错的offer,到如今各种java全家桶框架原理深入掌握,没有最卷,只有更卷。随着求职者的增多,对学历的要求也是逐年攀升,今年拿到java中大厂岗位的一般都得是985/211起步了,而且随着互联网行整体行情的相对固定,往后只会越来越难。 + +而go作为编程界的新秀,go语言虽说不如java岗位那么多,但是岗位数量其实是比c++多了,像大厂字节跳动所有后端语言都是go,腾讯除了ieg和wxg还用的c++技术栈以外,其他所有bg都转向了go语言,而且这两个bg目前正在往go方向转型。像中厂比如b站也是go语言的技术栈,百度目前也是由phh在向go转型,向新型的互联网公司,比如shopee,其后端技术栈也是go,可见go语言未来的在后端市场上发展趋势。来看看哪些公司正在使用go呢? + +![](https://golangstar.cn/assets/img/go语言前景/go语言前景3.png) + +对于应届生而言,现阶段拥抱go语言可以说是再合适不过了,为啥这么说呢?go语言作为一门新型的开发语言,在大学里学生接触的可以说很少,除非是自己有这种自驱性。因为大学所教的内容跟社会需求往往是脱节的,大学所学的东西往往很难满足真实岗位的需要,一般大学,即便是计算机专业现在所开的也都还是两门主流语言c++和java。c++其实现在后台岗位很有限了,一般只有做游戏,量化开发或者系统开发需要,岗位相比于后端少了很多。java岗位竞争又大,而此时选择go语言其实是一个不错的选择。 + +## 应用场景 +说完go语言的整体市场行情,紧接着我们来看一下go语言具体的应用场景,这么多大小公司都在go语言上有所应用,那它究竟可以应用在哪些场景中呢? + +首先在传统后端这块,go语言是天然支持的,因为go语言的协程机制在处理高并发上有着天然的优势,所以在后端领域go语言必有一席之地。除了后端场景,近年来大火的云原生,容器技术也是go语言的用武之地,现在几乎所有的公司都是在做服务上云的,这里就少不了需要go语言的人才。还有近年来炒得很火爆的web3,区块链,go语言也是再合适不过。 + +![](https://golangstar.cn/assets/img/go语言前景/go语言前景4.png) + +## 语言优势 +go作为一门新型语言,能够脱颖而出,有这么多的应用场景,且大小中厂都在积极拥抱go语言,自然少不了它作为语言本身的巨大优势。 + +### 语法简洁 +语法简单可以说是go语言最直接的一个优势了,Go 语言被称为“互联网时代的C语言”,从这个称呼就可以看出其语法是真的简单,相比于c++难度可以说是天壤之别,不像c++,要想真的掌握起码至少得半年甚至几年的学习周期。而go的学习路线就会平滑很多,学习周期也短,最多几个月就能完全掌握 + +### 代码统一 +代码风格的统一可以说是go另一个天然优势,由于其简洁的语法,并且Go 语言提供了一套专门的格式化工具——go fmt,这就使得对于不同的开发者提交的代码风格都是统一的格式,加强了可读性 + +### 效率高 +这里的效率既指开发效率,也指运行效率。go语言不仅有c语言级别的编译,运行效率,容易部署,同时也兼顾了python级别的开发效率,这使得在做业务开发的时候事半功倍,也是成为不少公司首选的主要原因 + +## 学习路线 +整体来说,go语言简单易上手,学习起来路线比较平滑,学习的成本比起c++和java来说也是要低不少的。 + +首先,了解Golang有哪些知识点,对整理路线有个把握,下面先通过一个golang学习指引图看一下学习go需要掌握哪些知识 + +![](https://golangstar.cn/assets/img/go语言前景/Go语言学习路线封面.png) + + +根据这个golang学习路线图,从右上角开始顺时针45度走一圈,差不多就对go语言有了一个比较全面的学习,而这整个学习周期也不会太久,即便是0基础,两到三个月也基本能够走完这个学习路线,并且对go也会有一个相对比较深入的了解了 + +以上只是对go语言本身以及go在传统后台方面的学习,学完这些,下一步就可以接着学习容器相关的知识了,毕竟随着docker,k8s的兴起,现在正处在云时代,万物皆可上云,掌握一些云原生相关的知识,在这个时代也是不可获取的。 + +## 小结 +随着云时代的到来,Golang的地位将会越来越高,越来越成为程序员必会的编程语言之一,在未来,golang成为和java分庭抗礼的大众化编程语言也是大势所趋。所以,选择go语言,学习go语言实在是太有必要了,我也将会在后面和大家一起学习和探索go语言相关知识。 + + + + + + + + + + + + + + + + + + + + diff --git a/hzh/GolangStar/Go语言单测/Go语言单测.md b/hzh/GolangStar/Go语言单测/Go语言单测.md new file mode 100644 index 0000000..b6db3e9 --- /dev/null +++ b/hzh/GolangStar/Go语言单测/Go语言单测.md @@ -0,0 +1,1543 @@ +--- +tags: + - Go + - golang + - go单测 + - 单元测试 +--- + +# Go单元测试 + + +## 1. 什么是单元测试 + +单元测试是软件开发中的一种测试方法,旨在验证代码中最小可测试单元(如函数、方法、类)的行为是否符合预期,它是开发流程的重要组成部分。单元测试的目标是发现代码中的缺陷和错误,并确保代码的正确性和稳定性。 + +Go语言中自带有一个轻量级的测试框架testing和自带的go test命令来实现单元测试和性能测试。在包目录内,以\_test.go为后缀名的源文件都是go test的一部分,而不是go build的构建部分。 + +## 2. Go单元测试命名规范 + +1. 在 Go 中,测试文件的命名规则非常重要。测试文件**必须以 `_test.go` 结尾**,否则 Go 的测试框架在执行时将不会识别这些文件。 + +举个例子,如果你的主程序文件名是 `hello.go`,那么对应的测试文件应命名为 `hello_test.go`。 + +注意: + +* 测试方法名以`Test`开头,参数要用`testing` ,例如`func TestXxx(t *testing.T)` + +* 在测试的时候通过`go test`命令进行测试 + +## 3. Go语言的测试框架 + +Go语言有以下几种常见的测试框架: + +| 测试框架 | 推荐指数 | +| ------------ | ----- | +| Go原生testing包 | ★★★☆☆ | +| GoConvey | ★★★★★ | +| testify | ★★★☆☆ | + +从测试用例编写的复杂度来看:testify比GoConvey简单;GoConvey比Go自带的testing包简单。然而在测试框架的选择上,我们更推荐使用GoConvey,主要原因有: + +* GoConvey与其他Stub/Mock框架的兼容性比Testify更好 + +* Testify虽然自带Mock功能,但需要手动编写Mock类;而GoMock可以一键自动生成这些重复的代码 + +接下来也会重点讲一下Go原生testing包的单测写法和GoConvey的主要用法 + +## 4. Go自带的testing包 + +`testing`包为Go语言的package提供了自动化测试支持。通过`go test`命令,可以自动执行如下形式的任何函数: + +```go +func TestXxx(*testing.T) +``` + +注意:`Xxx`可以是任何字母数字字符串,但第一个字母不能是小写字母。在这些测试函数中,可以使用`Error`、`Fail`等方法来指示测试失败。 + +要创建一个新的测试套件,需要创建一个名称以`_test.go`结尾的文件,该文件包含上述`TestXxx`函数。将该文件放在与被测试文件相同的包中。该文件会在正常的程序包构建中被排除,但在运行`go test`命令时会被包含。更多详情可执行`go help test`和`go help testflag`查看。 + +> 创建一个新的文件夹hello1,作为项目的根目录。在项目中根目录创建一个文件夹gotest,在gotest目录中创建一个example.go用于编写被测试代码,然后同样在gotest目录中创建一个example\_test.go,用于编写测试代码。后续的所有测试用例都是在此基础上进行 + +### 4.1 基础示例 + +被测试代码: + +```go +package gotest + +func Factorial(n int) int { + if n <= 0 { + return 1 + } + return n * Factorial(n-1) +} +``` + +测试代码: + +```go +func TestFactorial(t *testing.T) { + var ( + input = 5 + expected = 120 + ) + actual := Factorial(input) + if actual != expected { + t.Errorf("Factorial(%d) = %d; expected %d", input, actual, expected) + } +} +``` + +在gotest目录下执行`go test .`,输出: + +```go +➜ gotest go test . +ok hello1/gotest 0.332s +``` + +注意:➜ gotest表示是在gotest执行的后面的命令。上述当输入为`inut`时,结果实际结果`actual`和预期结果`expected`相等,表示测试通过。如果我们将`Factorial`函数修改为错误的实现: + +```go +func Factorial(n int) int { + if n <= 0 { + return 1 + } + return n * Factorial(n-2) // 错误的递归调用 +} +``` + +再执行`go test .`,将输出: + +```go +➜ gotest go test . +--- FAIL: TestFactorial (0.00s) + example_test.go:12: Factorial(5) = 15; expected 120 +FAIL +FAIL hello1/gotest 0.334s +FAIL +``` + +### 4.2 Table-Driven测试 + +Table-Driven方式可以在同一个测试函数中测试多个用例,将`TestFactorial`函数改为最初正确的形式,再次测试 + +```go +func TestFactorial(t *testing.T) { + var factorialTests = []struct { + input int // 输入值 + expected int // 预期结果 + }{ + {0, 1}, + {1, 1}, + {2, 2}, + {3, 6}, + {4, 24}, + {5, 120}, + {6, 720}, + } + + for _, tt := range factorialTests { + actual := Factorial(tt.input) + if actual != tt.expected { + t.Errorf("Factorial(%d) = %d; expected %d", tt.input, actual, tt.expected) + } + } +} +``` + +程序输出 + +```go +➜ gotest go test . +ok hello1/gotest 0.345s +``` + +Go自带testing包的更多用法可以参考[Go标准库文档](https://golang.org/pkg/testing/)。 + +## 5. GoConvey + +GoConvey适用于编写单元测试用例,并且可以兼容到testing框架中。可以通过`go test`命令或使用`goconvey`命令访问`localhost:8080`的Web测试界面来查看测试结果。GoConvey的基本用法如下: + +```go +Convey("测试描述", t, func() { + So(...) +}) +``` + +GoConvey通常使用`So`函数进行断言,断言方式可以传入一个函数,或者使用内置的`ShouldBeNil`、`ShouldEqual`、`ShouldNotBeNil`等函数。 + +### 5.1 基本示例 + +被测试代码: + +```go +package gotest + +func SlicesEqual(a, b []int) bool { + if len(a) != len(b) { + return false + } + + if (a == nil) != (b == nil) { + return false + } + + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} +``` + +测试代码: + +```go +package gotest + +import ( + . "github.com/smartystreets/goconvey/convey" + "testing" +) + +func TestSlicesEqual(t *testing.T) { + Convey("测试切片相等性函数", t, func() { + a := []int{1, 2, 3, 4} + b := []int{1, 2, 3, 4} + So(SlicesEqual(a, b), ShouldBeTrue) // a和b相等,这个判定应该为true,如果确实相等,则单测绘PASS,否侧不通过 + }) +} +``` + +这次我们不再使用`go test .`命令,而用`go test -v`命令来查看一下具体的单测执行详情 + +```go +➜ gotest go test -v +=== RUN TestSlicesEqual + + 测试切片相等性函数 ✔ + + +1 total assertion + +--- PASS: TestSlicesEqual (0.00s) +PASS +ok hello1/gotest 0.231s + +``` + +总共执行了一个断言,测试结果跟我们预测的结果相同,a和b相等,这个判定应该为true,如果确实相等,则单测会PASS,否侧不通过。测试结果为PASS,表示通过。执行耗时为0.231s + +### 5.2 嵌套测试 + +测试代码: + +```go +package gotest + +import ( + . "github.com/smartystreets/goconvey/convey" + "testing" +) + +func TestSlicesEqual(t *testing.T) { + Convey("测试切片相等性函数", t, func() { + Convey("当两个非空切片内容相同时", func() { + a := []int{1, 2, 3, 4} + b := []int{1, 2, 3, 4} + So(SlicesEqual(a, b), ShouldBeTrue) + }) + + Convey("当两个都是nil切片时", func() { + So(SlicesEqual(nil, nil), ShouldBeTrue) + }) + + Convey("当两个切片长度不同时", func() { + a := []int{1, 2, 3} + b := []int{1, 2, 3, 4} + So(SlicesEqual(a, b), ShouldBeFalse) + }) + }) +} +``` + +测试结果: + +```go +➜ gotest go test -v +=== RUN TestSlicesEqual + + 测试切片相等性函数 + 当两个非空切片内容相同时 ✔ + 当两个都是nil切片时 ✔ + 当两个切片长度不同时 ✔ + + +3 total assertions + +--- PASS: TestSlicesEqual (0.00s) +PASS +ok hello1/gotest 0.235s + +``` + +内层的Convey不需要再传入`t *testing.T`参数,这个例子测试了三种情况,当两个非空切片内容相同时,当两个都是nil切片时当两个切片长度不同时的预期情况和真实的代码测试情况,三种情况都是测试通过的 + +GoConvey的更多用法可以参考[官方文档](https://github.com/smartystreets/goconvey)。 + +## 6. Stub/Mock框架 + +在单元测试中,我们往往需要**隔离外部依赖**(如数据库、网络、文件系统、第三方服务等),这时就会用到 **Stub** 和 **Mock** 框架。它们帮助我们**模拟依赖组件的行为**,让测试只聚焦于目标函数的逻辑本身。 + +### 6.1 Stub 是什么? + +**Stub(桩)** 是一种最基础的替代品,它通常是你手动实现的函数或对象,用来返回**固定的值或行为**。 + +假设你有一个函数 `GetUserName(id)`,会从数据库中查询用户姓名。但在测试中你不想真的连数据库: + +```go +func GetUserNameFromDB(id int) string { + return "RealNameFromDB" // 真实实现,测试中不想调用 +} + +func GetUserName(id int, dbFunc func(int) string) string { + return dbFunc(id) +} + +``` + +测试中则可以写一个 Stub: + +```go +func StubGetUserName(id int) string { + return "StubUser" +} + +func TestGetUserName(t *testing.T) { + name := GetUserName(1, StubGetUserName) + if name != "StubUser" { + t.Fail() + } +} + +``` + +**Stub的特点是简单、手动、只模拟"结果"。** + +### 6.2 Mock 是什么? + +**Mock(模拟)** 是一种更高级的替代品,通常配合框架使用(如:GoMock、Testify)。除了返回值,它还可以**验证调用过程**,比如: + +* 被调用了几次? + +* 参数是否正确? + +* 调用顺序对不对? + +比如同样,假设我们有一个函数 `GetUserName(id)`,它依赖一个数据库查询函数 `GetUser(id)`,我们想在测试中 mock 这个函数的行为。 + +```go + +package main + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/mock" +) + +type DBMock struct { + mock.Mock +} + +func (m *DBMock) GetUser(id int) string { + args := m.Called(id) + return args.String(0) +} + +func GetUserName(id int, getUser func(int) string) string { + return getUser(id) +} + +func TestGetUserName(t *testing.T) { + Convey("给定一个用户ID,应该返回对应的用户名", t, func() { + db := new(DBMock) + db.On("GetUser", 1).Return("MockUser") + + result := GetUserName(1, db.GetUser) + + So(result, ShouldEqual, "MockUser") + + // 验证 mock 调用是否正确 + db.AssertExpectations(t) + }) +} + +``` + +Golang有以下Stub/Mock框架: + +* GoStub + +* GoMock + +* Monkey + +一般来说,GoConvey可以和GoStub、GoMock、Monkey中的一个或多个搭配使用。 + +### 6.3 GoStub + +GoStub框架有多种使用场景: + +* 基本场景:为全局变量打桩 + +* 基本场景:为函数打桩 + +* 基本场景:为过程打桩 + +* 复合场景:由多个基本场景组合而成 + +#### 6.3.1 为全局变量打桩 + +假设在被测函数中使用了一个全局整型变量count,当前测试用例需要将count的值固定为150: + +```go + +stubs := Stub(&count, 150) +defer stubs.Reset() +``` + +stubs是GoStub框架函数接口Stub返回的对象,该对象有Reset方法可以将全局变量恢复为原值。 + +#### 6.3.2 为函数打桩 + +设我们的代码中有以下函数定义: + +```go +func Execute(cmd string, args ...string) (string, error) { + // 实际实现... +} +``` + +我们可以对Execute函数打桩,代码如下: + +```go +stubs := StubFunc(&Execute, "command-output", nil) +defer stubs.Reset() +``` + +#### 6.3.3 为过程打桩 + +当函数没有返回值时,我们通常称之为过程。例如,一个资源清理函数: + +```go +func CleanupResources() { + // 清理资源的代码... +} +``` + +我们对CleanupResources过程的打桩代码为: + +```go +stubs := StubFunc(&CleanupResources) +defer stubs.Reset() +``` + +GoStub的更多用法可以参考[官方文档](https://github.com/prashantv/gostub)。 + +### 6.4 GoMock + +GoMock是由Go官方开发维护的测试框架,提供了基于接口的Mock功能,能够与Go内置的testing包良好集成。GoMock包含两个主要部分:GoMock库和mockgen工具,其中GoMock库管理桩对象的生命周期,mockgen工具用于生成接口对应的Mock类源文件。 + +#### 6.4.1 定义接口 + +```go +package db + +type DataStore interface { + Create(key string, value []byte) error + Retrieve(key string) ([]byte, error) + Update(key string, value []byte) error + Delete(key string) error +} +``` + +#### 6.4.2 生成Mock类文件 + +mockgen工具有两种操作模式:源文件模式和反射模式。 + +1. 源文件模式通过包含接口定义的文件生成Mock类: + +```go +mockgen -source=datastore.go [其他选项] +``` + +* 反射模式通过构建程序并使用反射理解接口生成Mock类: + +```go +mockgen database/sql/driver Conn,Driver +``` + +生成的mock\_datastore.go文件内容大致如下: + +```go +// 自动生成的代码 - 请勿手动修改! +// Source: db (interfaces: DataStore) + +package mock_db + +import ( + gomock "github.com/golang/mock/gomock" +) + +// MockDataStore 是DataStore接口的模拟实现 +type MockDataStore struct { + ctrl *gomock.Controller + recorder *MockDataStoreMockRecorder +} + +// MockDataStoreMockRecorder 是MockDataStore的记录器 +type MockDataStoreMockRecorder struct { + mock *MockDataStore +} + +// NewMockDataStore 创建一个新的模拟实例 +func NewMockDataStore(ctrl *gomock.Controller) *MockDataStore { + mock := &MockDataStore{ctrl: ctrl} + mock.recorder = &MockDataStoreMockRecorder{mock} + return mock +} + +// EXPECT 返回一个对象,允许调用者指示预期的用法 +func (_m *MockDataStore) EXPECT() *MockDataStoreMockRecorder { + return _m.recorder +} + +// Create 模拟基础方法 +func (_m *MockDataStore) Create(_param0 string, _param1 []byte) error { + ret := _m.ctrl.Call(_m, "Create", _param0, _param1) + ret0, _ := ret[0].(error) + return ret0 +} +// ... 其他方法实现 +``` + +#### 6.4.3 使用Mock对象进行测试 + +1. 导入相关包 + +```go +import ( + "testing" + . "github.com/golang/mock/gomock" + "myapp/mock/db" + // 其他导入... +) +``` + +* 创建Mock控制器 + +Mock控制器通过NewController接口生成,是Mock生态系统的顶层控制,它定义了Mock对象的作用域和生命周期,以及期望行为。 + +```go +ctrl := NewController(t) +defer ctrl.Finish() +``` + +创建Mock对象时需要注入控制器: + +```go +ctrl := NewController(t) +defer ctrl.Finish() +mockDB := mock_db.NewMockDataStore(ctrl) +mockAPI := mock_api.NewMockHttpClient(ctrl) +``` + +* 定义Mock对象行为 + +假设有这样一个场景:首先尝试获取数据失败,然后创建数据成功,再次获取就能成功。这个场景的Mock行为设置如下: + +```sql +mockDB.EXPECT().Retrieve(Any()).Return(nil, errors.New("不存在")) +mockDB.EXPECT().Create(Any(), Any()).Return(nil) +mockDB.EXPECT().Retrieve(Any()).Return(dataBytes, nil) +``` + +其中dataBytes是测试数据的序列化结果: + +```go +data := MyData{Field1: "value", Field2: 123} +dataBytes, _ := json.Marshal(data) +``` + +批量操作可以使用Times指定次数: + +```go +mockDB.EXPECT().Create(Any(), Any()).Return(nil).Times(5) +``` + +多次获取不同数据时,需要设置多个行为: + +```go +mockDB.EXPECT().Retrieve(Any()).Return(dataBytes1, nil) +mockDB.EXPECT().Retrieve(Any()).Return(dataBytes2, nil) +mockDB.EXPECT().Retrieve(Any()).Return(dataBytes3, nil) +``` + +GoMock的更多用法可以参考[官方文档](https://github.com/golang/mock)。 + +### 6.5 Monkey + +前面我们已经了解到: + +* 全局变量可通过GoStub框架打桩 + +* 过程可通过GoStub框架打桩 + +* 函数可通过GoStub框架打桩 + +* 接口可通过GoMock框架打桩 + +但还有两个问题较难解决: + +1. 方法(成员函数)无法通过GoStub框架打桩,特别是当代码的OO设计较多时 + +2. 通过GoStub框架打桩时,对产品代码有侵入性 + +Monkey是Go的一个猴子补丁(monkeypatching)框架,通过在运行时重写可执行文件,将待打桩函数或方法的实现重定向到桩实现。原理类似于热补丁技术。但需要注意的是,Monkey不是线程安全的,不应用于并发测试。 + +Monkey框架的使用场景: + +* 基本场景:为函数打桩 + +* 基本场景:为过程打桩 + +* 基本场景:为方法打桩 + +* 复合场景:由多个基本场景组合而成 + +* 特殊场景:桩中桩的案例 + +#### 6.5.1 为函数打桩 + +假设Execute是一个执行命令的函数: + +```go +func Execute(cmd string, args ...string) (string, error) { + cmdPath, err := exec.LookPath(cmd) + if err != nil { + log.Printf("exec.LookPath err: %v, cmd: %s", err, cmd) + return "", errors.New("command not found") + } + + output, err := exec.Command(cmdPath, args...).CombinedOutput() + if err != nil { + log.Printf("exec.Command.CombinedOutput err: %v, cmd: %s", err, cmd) + return "", errors.New("command execution failed") + } + + log.Printf("CMD[%s]ARGS[%v]OUT[%s]", cmdPath, args, string(output)) + return string(output), nil +} +``` + +使用Monkey打桩的代码: + +```go +import ( + "testing" + . "github.com/smartystreets/goconvey/convey" + . "github.com/bouk/monkey" + "myapp/utils" +) + +func TestExecute(t *testing.T) { + Convey("测试命令执行", t, func() { + Convey("成功执行", func() { + expectedOutput := "command output" + guard := Patch( + utils.Execute, + func(_ string, _ ...string) (string, error) { + return expectedOutput, nil + }) + defer guard.Unpatch() + + output, err := utils.Execute("any", "any") + So(output, ShouldEqual, expectedOutput) + So(err, ShouldBeNil) + }) + }) +} +``` + +`Patch`是`Monkey`提供的函数打桩API: + +1. 第一个参数是目标函数 + +2. 第二个参数是桩函数,通常使用匿名函数或闭包 + +3. 返回值是PatchGuard对象指针,用于在测试结束时移除补丁 + +#### 6.5.2 为过程打桩 + +对于没有返回值的函数(过程),打桩代码如下: + +```go +guard := Patch(CleanupResources, func() { + // 空实现或测试所需的行为 +}) +defer guard.Unpatch() +``` + +#### 6.5.3 为方法打桩 + +假设在分布式系统中,需要模拟从配置中心获取配置的行为: + +```go +type ConfigCenter struct { + // 字段... +} + +func (c *ConfigCenter) GetConfig(key string) (string, error) { + // 实际实现... + return "", nil +} +``` + +使用Monkey对方法打桩: + +```go +var cc *ConfigCenter +guard := PatchInstanceMethod( + reflect.TypeOf(cc), + "GetConfig", + func(_ *ConfigCenter, _ string) (string, error) { + return "{\"feature\":\"enabled\",\"timeout\":30}", nil + }) +defer guard.Unpatch() +``` + +PatchInstanceMethod API是Monkey提供的方法打桩API: + +* 首先定义目标类的指针变量x + +* 第一个参数是reflect.TypeOf(x) + +* 第二个参数是方法名的字符串 + +* 第三个参数是替换方法 + +* 返回值是PatchGuard对象指针,用于移除补丁 + +Monkey的更多用法可以参考[官方文档](https://github.com/bouk/monkey)。 + +## 7. Mock场景最佳实践 + +### 7.1 实例函数Mock:Monkey。 + +Monkey框架可用于对依赖函数进行替换,完成针对当前模块的单元测试。 + +有如下例子,\`helper\`包是实际功能实现,\`mock\_helper\`包是用于mock的替代实现。 + +helper.go: + +```go +package helper + +import "fmt" + +func FormatSum(a, b int) string { + return fmt.Sprintf("a:%v+b:%v", a, b) +} + +type Calculator struct { +} + +func (*Calculator) FormatResult(a, b int) string { + return fmt.Sprintf("a:%v+b:%v", a, b) +} +``` + +mock\_helper.go: + +```go +package mock_helper + +import ( + "fmt" + "myapp/helper" +) + +func FormatSum(a, b int) string { + return fmt.Sprintf("a:%v+b:%v=%v", a, b, a+b) +} + +// 对应helper包中的FormatResult +func FormatResult(_ *helper.Calculator, a, b int) string { + return fmt.Sprintf("a:%v+b:%v=%v", a, b, a+b) +} +``` + +测试代码: + +```go +func TestFormatting() { + // 替换函数 + monkey.Patch(helper.FormatSum, mock_helper.FormatSum) + result := helper.FormatSum(1, 2) + fmt.Println(result) + + monkey.UnpatchAll() // 解除所有替换 + result = helper.FormatSum(1, 2) + fmt.Println(result) +} + +func TestMethodFormatting() { + calc := &helper.Calculator{} + // 参数1: 获取实例的反射类型, 参数2: 被替换的方法名, 参数3: 替换方法 + monkey.PatchInstanceMethod(reflect.TypeOf(calc), "FormatResult", mock_helper.FormatResult) + + result := calc.FormatResult(1, 2) + fmt.Println(result) + + monkey.UnpatchAll() // 解除所有替换 + result = calc.FormatResult(1, 2) + fmt.Println(result) +} +``` + +### 7.2 未实现函数Mock:GoMock + +假设场景:`Company`(公司)和`Person`(人)之间的关系: + +1. 公司可以举行会议 + +2. 公司内部的人实现了`Speaker`接口,拥有`SayHello`方法 + +若所有类都已实现,测试代码如下: + +```go +func TestCompany_Meeting(t *testing.T) { + // 直接创建一个Person对象 + speaker := NewPerson("小张", "工程师") + company := NewCompany(speaker) + t.Log(company.Meeting("张三", "实习生")) +} +``` + +但如果`Person`类尚未实现,可以通过GoMock模拟一个符合`Speaker`接口的对象 + +定义`Speaker.go`接口: + +```go +package domain + +type Speaker interface { + SayHello(name, role string) (response string) +} +``` + +用`mockgen`命令生成Mock对象: + +```go +mockgen -source=Speaker.go -destination=mock_speaker.go -package=mock_domain +``` + +测试代码: + +```go +func TestCompany_Meeting(t *testing.T) { + // 创建Mock控制器 + ctrl := gomock.NewController(t) + // 创建Mock对象 + speaker := mock_domain.NewMockSpeaker(ctrl) + + // 设置期望行为 + speaker.EXPECT().SayHello(gomock.Eq("张三"), gomock.Eq("实习生")).Return( + "你好,张三(角色:实习生),欢迎加入公司会议。我是会议主持人。") + + // 将Mock对象传入测试对象 + company := NewCompany(speaker) + + // 执行测试 + t.Log(company.Meeting("张三", "实习生")) +} +``` + +### 7.3 系统内置函数Mock:Monkey + +使用Monkey可以mock系统内置函数,例如json.Unmarshal: + +```go +monkey.Patch(json.Unmarshal, mockUnmarshal) + +func mockUnmarshal(b []byte, v interface{}) error { + // 强制设置为指定值,无视输入 + *(v.(*models.LoginMessage)) = models.LoginMessage{ + UserID: 1, + Username: "admin", + Password: "admin", + } + return nil +} +``` + +取消替换: + +```go +monkey.Unpatch(json.Unmarshal) // 解除单个Patch +monkey.UnpatchAll() // 解除所有Patch +``` + +### 7.4 数据库行为Mock + +使用sqlmock库模拟数据库操作: + +```go +func TestDatabaseQuery(t *testing.T) { + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Fatalf("创建sqlmock失败: %v", err) + } + defer db.Close() + + // 模拟查询结果 + rows := sqlmock.NewRows([]string{"id", "username"}). + AddRow(1, "user1"). + AddRow(2, "user2") + + // 设置期望的SQL查询 + mock.ExpectQuery("SELECT id, username FROM users").WillReturnRows(rows) + + // 执行查询 + result, err := db.Query("SELECT id, username FROM users") + if err != nil { + t.Fatalf("查询执行失败: %v", err) + } + defer result.Close() + + // 处理结果 + var users []struct { + ID int + Username string + } + + for result.Next() { + var id int + var username string + result.Scan(&id, &username) + users = append(users, struct { + ID int + Username string + }{id, username}) + t.Logf("查询结果: ID=%d, 用户名=%s", id, username) + } + + if result.Err() != nil { + t.Fatalf("结果处理错误: %v", result.Err()) + } + + // 验证所有期望都已满足 + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("有未满足的期望: %v", err) + } +} +``` + +### 7.5 服务器行为Mock + +使用net/http/httptest模拟HTTP服务器: + +```go +func TestHTTPRequest(t *testing.T) { + // 创建处理器 + handler := func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, `{"status": "success", "data": {"message": "Hello World"}}`) + } + + // 创建请求和响应记录器 + req := httptest.NewRequest("GET", "/api/hello", nil) + w := httptest.NewRecorder() + + // 处理请求 + handler(w, req) + + // 获取响应 + resp := w.Result() + body, _ := ioutil.ReadAll(resp.Body) + + // 验证结果 + t.Logf("状态码: %d", resp.StatusCode) + t.Logf("内容类型: %s", resp.Header.Get("Content-Type")) + t.Logf("响应体: %s", string(body)) + + // 可以进一步解析JSON并断言 + var result struct { + Status string `json:"status"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } + + json.Unmarshal(body, &result) + assert.Equal(t, "success", result.Status) + assert.Equal(t, "Hello World", result.Data.Message) +} +``` + +对于涉及方法的情况,需要使用`PatchInstanceMethod`: + +```go +func TestHTTPClient(t *testing.T) { + var client *http.Client + + // 替换http.Client的Do方法 + monkey.PatchInstanceMethod(reflect.TypeOf(client), "Do", func(_ *http.Client, _ *http.Request) (*http.Response, error) { + // 创建模拟响应 + resp := &http.Response{ + StatusCode: 200, + Body: ioutil.NopCloser(bytes.NewBufferString(`{"result": "mocked response"}`)), + Header: make(http.Header), + } + resp.Header.Set("Content-Type", "application/json") + return resp, nil + }) + defer monkey.UnpatchAll() + + // 测试使用http.Client的函数 + result, err := FetchData("https://api.example.com/data") + assert.NoError(t, err) + assert.Equal(t, "mocked response", result.Value) +} +``` + +## 8. 实战案例:消息通讯系统 + +### 8.1 项目概览 + +假设该项目是一个具有用户登录、查看在线用户、私聊、群聊等功能的命令行通讯系统。项目分为Client和Server两个子模块,都采用Model-Controller(Processor)-View(Main)的架构进行功能划分。另外还有一个Common模块存放通用工具类和数据结构。 + +```go +├─Client +│ ├─main +│ ├─model +│ ├─processor +│ └─utils +├─Common +└─Server + ├─main + ├─model + ├─processor + └─utils +``` + +测试目标:为核心功能模块编写单元测试,确保各模块功能的正确性、完整性和健壮性,并在代码变更后能快速验证。 + +单元测试应包括: + +* 模块接口测试:验证参数传递、处理和返回值 + +* 模块数据结构测试:确保局部数据在处理过程中的完整性和正确性 + +* 异常处理测试:验证各种异常情况下的错误处理是否合理 + +接口测试应全面考察参数合法性、必要性、参数间的冗余性,以及指针引用的正确性等。数据结构测试应关注临时存储在模块内的数据结构的正确性,因为局部数据结构往往是错误的根源。 + +异常处理测试应关注几种常见问题: + +1. 错误信息提示不足 + +2. 异常未被处理 + +3. 错误信息与实际不符 + +4. 错误信息未能准确定位问题 + +在本案例中,假设Model层向服务层提供的接口较少,只有`WritePkg`和`ReadPkg`两个核心函数,服务层基于这些基础函数封装具体业务逻辑。由于涉及网络连接,需要编写桩函数进行测试。服务层涉及多个网络连接调用和数据库操作,同样需要Mock。 + +鉴于需要编写Mock和桩函数,我们使用`GoStub`和`Monkey`包来简化测试,只需要编写替代接口和Mock函数,就能在测试过程中替换系统函数或依赖模块。 + +### 8.2 Model层与数据库测试 + +由于是单元测试,我们需要创建Mock数据库实例,测试CRUD操作的SQL语句执行: + +```go +const ( + sqlSelect = "SELECT id, username FROM users" + sqlDelete = "DELETE FROM users WHERE id > 100 AND id < 200" + sqlUpdate = "UPDATE users SET status = 'active' WHERE id = 1" + sqlInsert = "INSERT INTO users (id, username) VALUES (101, 'newuser')" +) + +func TestUserRepository(t *testing.T) { + // 创建sqlmock数据库连接 + db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual)) + if err != nil { + t.Fatalf("创建sqlmock失败: %v", err) + } + defer db.Close() + + // 模拟查询结果 + rows1 := sqlmock.NewRows([]string{"id", "username"}). + AddRow(1, "admin"). + AddRow(2, "user") + rows2 := sqlmock.NewRows([]string{"id", "username"}). + AddRow(101, "temp1"). + AddRow(102, "temp2") + rows3 := sqlmock.NewRows([]string{"id", "username"}). + AddRow(1, "admin") + rows4 := sqlmock.NewRows([]string{"id", "username"}). + AddRow(101, "newuser") + + // 设置SQL执行预期 + mock.ExpectQuery(sqlSelect).WillReturnRows(rows1) + mock.ExpectQuery(sqlDelete).WillReturnRows(rows2) + mock.ExpectQuery(sqlUpdate).WillReturnRows(rows3) + mock.ExpectQuery(sqlInsert).WillReturnRows(rows4) + + // 测试用例 + var tests = []struct{ + querySql string + expected interface{} + }{ + {sqlSelect, nil}, + {sqlDelete, nil}, + {sqlUpdate, nil}, + {sqlInsert, nil}, + } + + for _, test := range tests { + // 执行查询 + res, err := db.Query(test.querySql) + assert.Equal(t, err, test.expected) // 验证无错误 + + // 处理结果 + var users []struct { + ID int + Username string + } + + for res.Next() { + var id int + var username string + res.Scan(&id, &username) + users = append(users, struct { + ID int + Username string + }{id, username}) + t.Logf("查询结果: ID=%d, 用户名=%s", id, username) + } + + assert.Equal(t, res.Err(), test.expected) // 验证结果处理无错误 + } +} +``` + +### 8.3 私聊功能测试 + +私聊功能涉及JSON编码和发送消息的底层操作(`WritePkg`函数),我们使用Monkey进行Mock: + +```go +func TestMessageSender_SendPrivateMessage(t *testing.T) { + var conn net.Conn + transfer := &utils.Transfer{ + Conn: conn, + } + + // Mock WritePkg方法 + monkey.PatchInstanceMethod(reflect.TypeOf(transfer), "WritePkg", func(_ *utils.Transfer, _ []byte) error { + return nil + }) + + convey.Convey("测试发送私聊消息", t, func() { + msg := &models.PrivateMessage{ + From: "user1", + To: "user2", + Content: "你好!", + } + + sender := &MessageSender{Transfer: transfer} + err := sender.SendPrivateMessage(msg) + + convey.So(err, convey.ShouldBeNil) + }) + + monkey.UnpatchAll() +} +``` + +### 8.4 登录功能测试 + +登录功能涉及服务器连接和数据处理,我们可以使用多种Mock技术结合测试: + +```go +func TestMessageSender_SendPrivateMessage(t *testing.T) { + var conn net.Conn + transfer := &utils.Transfer{ + Conn: conn, + } + + // Mock WritePkg方法 + monkey.PatchInstanceMethod(reflect.TypeOf(transfer), "WritePkg", func(_ *utils.Transfer, _ []byte) error { + return nil + }) + + convey.Convey("测试发送私聊消息", t, func() { + msg := &models.PrivateMessage{ + From: "user1", + To: "user2", + Content: "你好!", + } + + sender := &MessageSender{Transfer: transfer} + err := sender.SendPrivateMessage(msg) + + convey.So(err, convey.ShouldBeNil) + }) + + monkey.UnpatchAll() +} +``` + +### 4. 登录功能测试 + +登录功能涉及服务器连接和数据处理,我们可以使用多种Mock技术结合测试: + +```go +func mockJsonUnmarshal(b []byte, v interface{}) error { + // 强制设置登录消息对象的值 + *(v.(*models.LoginMessage)) = models.LoginMessage{ + UserID: 1, + Username: "admin", + Password: "password123", + } + return nil +} + +func mockJsonMarshal(v interface{}) ([]byte, error) { + // 简化的JSON序列化,返回固定内容 + return []byte(`{"status":"success"}`), nil +} + +func TestUserProcessor_Login(t *testing.T) { + // 创建测试消息 + message := &models.Message{ + Type: models.LoginMessageType, + Data: "mock_login_data", + } + + userProcessor := &UserProcessor{ + Conn: nil, + } + + // Mock系统函数 + monkey.Patch(json.Unmarshal, mockJsonUnmarshal) + monkey.Patch(json.Marshal, mockJsonMarshal) + + // Mock用户数据访问对象 + var userDao *model.UserDao + monkey.PatchInstanceMethod(reflect.TypeOf(userDao), "Login", func(_ *model.UserDao, _ int, _ string) (*models.User, error) { + return &models.User{ + UserID: 1, + Username: "admin", + Password: "password123", + }, nil + }) + + // Mock传输层 + var transfer *utils.Transfer + monkey.PatchInstanceMethod(reflect.TypeOf(transfer), "WritePkg", func(_ *utils.Transfer, _ []byte) error { + return nil + }) + + // 执行测试 + convey.Convey("测试用户登录处理", t, func() { + err := userProcessor.HandleLogin(message) + convey.So(err, convey.ShouldBeNil) + }) + + // 清理Mock + monkey.UnpatchAll() +} +``` + +## 8.5 工具类测试 + +测试网络传输工具类: + +```go +func mockNetRead(conn net.Conn, _ []byte) (int, error) { + // 模拟读取4字节数据 + return 4, nil +} + +func mockJsonMarshal(v interface{}) ([]byte, error) { + return []byte{1, 2, 3, 4}, nil +} + +func mockJsonUnmarshal(data []byte, v interface{}) error { + return nil +} + +func TestTransfer_ReadPackage(t *testing.T) { + // Mock网络读取 + monkey.Patch(net.Conn.Read, mockNetRead) + monkey.Patch(json.Marshal, mockJsonMarshal) + monkey.Patch(json.Unmarshal, mockJsonUnmarshal) + + // 创建测试服务器 + listener, _ := net.Listen("tcp", "localhost:9999") + defer listener.Close() + + // 创建客户端连接 + go net.Dial("tcp", "localhost:9999") + + // 接受连接 + var conn net.Conn + for { + conn, _ = listener.Accept() + if conn != nil { + break + } + } + + // 创建测试对象 + transfer := &Transfer{ + Conn: conn, + Buf: [8096]byte{1, 2, 3, 4}, + } + + // 执行测试 + convey.Convey("测试数据包读取", t, func() { + message, err := transfer.ReadPackage() + convey.So(err, convey.ShouldBeNil) + convey.So(message, convey.ShouldNotBeNil) + }) + + // 清理Mock + monkey.UnpatchAll() +} + +func TestTransfer_WritePackage(t *testing.T) { + // Mock JSON操作 + monkey.Patch(json.Marshal, mockJsonMarshal) + monkey.Patch(json.Unmarshal, mockJsonUnmarshal) + + // 创建测试对象 + transfer := &Transfer{ + Conn: nil, + Buf: [8096]byte{}, + } + + // 执行测试 + convey.Convey("测试数据包写入", t, func() { + err := transfer.WritePackage([]byte{1, 2}) + convey.So(err, convey.ShouldBeNil) + }) + + // 清理Mock + monkey.UnpatchAll() +} +``` + +在编写单元测试的时候,推荐使用第三方包来完成,虽然原生包能满足基本需求,但不提供断言语法,导致要写大量重复的错误检查代码,因此引入convey和assert包简化判断逻辑,可以使代码更简洁易读 + +更多测试实践案例可参考: + +* [go-sqlmock](https://github.com/DATA-DOG/go-sqlmock) + +* [GoMock实践指南](https://github.com/golang/mock) + +## 9. 基准测试 + +除了前面提到的单元测试,测试代码单元的正确性之外,Go语言还提供了基准测试框架,可以测试一段程序的性能、CPU消耗,可以对代码做性能分析,测试方法与单元测试类似。 + +基准测试规则: + +* 基准测试以Benchmark为前缀 + +* 需要一个\*testing.B类型的参数b + +* 基准测试必须要执行b.N次 + +常见的基准测试函数写法: + +```go +func BenchmarkTest(b *testing.B) { + ... +} +``` + +执行基准测试时,需要添加`-bench`参数 + +```go +go test -bench="." +``` + +下面通过一个模拟负载均衡的例子,来看下基准测试: + +在gotest包下准备一个`Abs` 函数作为被测试的代码位于example.go,代码如下: + +```go +package gotest + +import "math" + +func Abs(x float64) float64 { + return math.Abs(x) +} +``` + +然后在example\_test.go文件中为 `Abs` 函数编写的基准测试,代码如下: + +```go +package gotest + +func BenchmarkAbs(b *testing.B) { + for i := 0; i < b.N; i++ { + Abs(-1) + } +} +``` + +注意基准测试的时候参数不再是 `*testing.T`,而是 `*testing.B`,在测试函数中,我们循环了 `b.N` 次调用 `Abs(-1)`,`b.N` 的值是一个动态值,我们无需操心,`testing` 框架会为其分配合理的值,以使测试函数运行足够多的次数,可以准确的计时。 + +默认情况下,执行 `go test` 命令时不会自动运行基准测试,需要显式指定 `-bench` 参数 + +```go +➜ gotest go test -bench="." +... +3 total assertions + +goos: darwin +goarch: arm64 +pkg: hello1/gotest +BenchmarkAbs-12 1000000000 0.2954 ns/op +PASS +ok hello1/gotest 1.513s + +``` + +`-bench` 的参数接收一个正则表达式,`.` 匹配所有基准测试。重点看一下执行结果的这一行 + +```go +BenchmarkAbs-12 1000000000 0.2954 ns/op +``` + +`BenchmarkAbs-`12 中,`BenchmarkAbs` 是测试函数名,12 是 `GOMAXPROCS` 的值,即参与执行的 CPU 核心数。`1000000000` 表示测试执行了这么多次。`0.5096 ns/op` 表示每次循环平均消耗的纳秒数。 + +如果想查看基准测试的内存占用情况,可以通过 `-benchmem` 参数指定: + +```bash +➜ gotest go test -bench="BenchmarkAbs$" -benchmem +... +3 total assertions + +goos: darwin +goarch: arm64 +pkg: hello1/gotest +BenchmarkAbs-12 1000000000 0.2932 ns/op 0 B/op 0 allocs/op +PASS +ok hello1/gotest 0.683s + +``` + +可以发现,加上`-benchmem` 参数后,`BenchmarkAbs-8` 这行打印了更多输出内容: + +```go +BenchmarkAbs-12 1000000000 0.2932 ns/op 0 B/op +``` + +`0 B/op` 表示每次执行测试代码分配了多少字节内存。`0 allocs/op` 表示每次执行测试代码分配了多少次内存。 + +此外,在执行 `go test` 命令时,我们可以使用 `-benchtime=Ns` 参数指定基准测试函数执行时间为 `N` 秒: + +```go +➜ gotest go test -bench="BenchmarkAbs$" -benchtime=0.1s +... +3 total assertions + +goos: darwin +goarch: arm64 +pkg: hello1/gotest +BenchmarkAbs-12 385589265 0.3081 ns/op +PASS +ok hello1/gotest 0.686s +``` + +`-benchtime` 参数值为 `time.Duration` 类型支持的时间格式。此外,`-benchtime` 参数还有一个特殊语法 `-benchtime=Nx` 参数,可以指定基准测试函数执行次数为 `N` 次: + +```go +➜ gotest go test -bench="BenchmarkAbs$" -benchtime=10x +... +3 total assertions + +goos: darwin +goarch: arm64 +pkg: hello1/gotest +BenchmarkAbs-12 10 25.00 ns/op +PASS +ok hello1/gotest 0.466s +``` + +有时在进行基准测试时,目标函数可能依赖一些预处理步骤,比如数据准备,这些数据准备的时间不应被计入函数本身的性能统计。这时候,我们可以调用 `(*testing.B).ResetTimer` 来重新开始计时,从而确保测试只衡量核心逻辑的执行时间。 + +```go +func BenchmarkAbsResetTimer(b *testing.B) { + time.Sleep(100 * time.Millisecond) // 模拟数据准备阶段的耗时 + b.ResetTimer() + for i := 0; i < b.N; i++ { + Abs(-1) + } +} +``` + +这样,在调用 `b.ResetTimer()` 之前进行的耗时操作将不会被纳入最终的基准测试时间统计中。 + +另外,还有一种更灵活的做法是:先调用 `b.StopTimer()` 来暂停计时,等准备工作完成后,再通过 `b.StartTimer()` 恢复计时,这样也能避免将准备过程的耗时计算在内。 + +```go +func BenchmarkAbsStopTimerStartTimer(b *testing.B) { + b.StopTimer() + time.Sleep(100 * time.Millisecond) // 模拟数据准备阶段的耗时 + b.StartTimer() + for i := 0; i < b.N; i++ { + Abs(-1) + } +} +``` + +默认情况下,基准测试中的 `for` 循环是串行方式执行的。如果想要对被测代码进行并发性能测试,可以将其封装在 `(*testing.B).RunParallel` 方法中,实现并行调用 + +```go +func BenchmarkAbsParallel(b *testing.B) { + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + Abs(-1) + } + }) +} +``` + +还可以使用 `(*testing.B).SetParallelism` 控制并发协程数: + +```go +func BenchmarkAbsParallel(b *testing.B) { + b.SetParallelism(2) // 设置并发 Goroutines 数量为 2 * GOMAXPROCS + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + Abs(-1) + } + }) +} +``` + +可以通过 `-cpu` 参数为 `go test` 指定 GOMAXPROCS 的值,用来控制使用的 CPU 核心数量。如果想了解更多 `go test` 支持的参数选项,可以执行命令 `go help testflag` 来获取完整的帮助信息。 + +## 10. 小结 + +单元测试(Unit Test,简称 UT)是高质量软件项目中不可缺少的一个组成部分。它的核心目标是对程序中**最小的功能单位**进行验证,通常是一个函数或者方法,确保其行为符合预期。Go语言对单元测试提供了很好的支持,其自身就带有一个轻量级的测试框架testing,可以用自带的go test命令来实现单元测试和性能测试。同时也有非常多好用的第三方测试包,比如GoConvey,testify等,可以更加简洁的写测试用例。写好Go程序的单测,不仅仅可以确保代码的完整性和正确性,也是一个Gopher基本功的重要体现 + diff --git a/hzh/GolangStar/Go语言原理/channel原理.md b/hzh/GolangStar/Go语言原理/channel原理.md new file mode 100644 index 0000000..c710520 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/channel原理.md @@ -0,0 +1,588 @@ +--- +tags: + - Go + - golang + - go原理深入 + - context原理 +--- + +# channel原理 + +## channel是什么 + +顾名思义,channel就是一个通信管道,作用是用来在goroutine中传递信息,使不同的goroutine能够通信。 + +go语言遵循CSP并发编程模式,提倡通过通信来实现内存共享,而不提倡通过共享内存来实现通信,而channel就是这种并发编程思想的实现。正是由于channel的存在,才使go语言的并发编程变得简单快捷。并且,在go语言中,通过channel与select的搭配使用以及调度器对goroutine的调度,可以很高效的实现协程的阻塞和唤醒以及多路复用。 + +### channel的分类 + +channel 又分为两类:有缓冲 channel 和无缓冲 channel + +其创建方式如下: + +```go +// 创建一个无缓冲的int类型的channel +ch1 := make(chan int) +// 创建一个缓冲区长度为3的int类型的channel +ch2 := make(chan int, 3) +``` + +无缓冲 channel在读写的时候是阻塞的,即向channel写入一条数据,如果这条数据还没被消费掉,再写入就会阻塞。读取也是一样,当读取的channel没有消息往里面发送的时候,这时读取这个channel也会阻塞。 + +有缓冲 channel 在读写的时候是非阻塞的。当然这个非阻塞得满足一定的条件。当向一个channel写入消息之后,即使还没被消费,只要队列没满(条件),就可继续写入,非阻塞。读取也一样,当从一个channel读取数据,即使没有生产者往channel发送消息,只要channel里面有数据,就可以继续读取消息消费,不会阻塞 + +关于channel的具体操作在golang并发编程实战中已经介绍,这里就不重复讲解了。 + +## channel的数据结构 + +channel用make函数创建初始化的时候会在堆上分配一个runtime.hchan类型的数据结构,并返回指针指向堆上这块hchan内存区域,所以channel是一个引用类型 + +为什么要在堆上创建这个hchan结构而不是栈上?我是这样理解的,channel是用来实现goroutine间通信的,其生命周期和作用域几乎都不太可能仅仅局限于某个具体的函数内,所以在设计的时候就直接在堆上创建。 + +runtime.hchan的类型定义在源码 src/runtime/chan.go中: + +```go +type hchan struct { + qcount uint // 循环队列中的数据总数 + dataqsiz uint // 循环队列大小 + buf unsafe.Pointer // 指向循环队列的指针 + elemsize uint16 // 循环队列中的每个元素的大小 + closed uint32 // 标记位,标记channel是否关闭 + elemtype *_type // 循环队列中的元素类型 + sendx uint // 已发送元素在循环队列中的索引位置 + recvx uint // 已接收元素在循环队列中的索引位置 + recvq waitq // 等待从channel接收消息的sudog队列 + sendq waitq // 等待向channel写入消息的sudog队列 + lock mutex // 互斥锁,对channel的数据读写操作加锁,保证并发安全 +} +``` + +hchan 中的sendq 和 recvq 字段存储了当前 channel 由于缓冲区空间(buf)不足而阻塞的要读取或者写入当前channel的goroutine 列表,这些等待队列使用双向链表 waitq 表示,waitq是对一个sudog链表进行封装之后的一个结构,其字段为这个sudog队列的首位指针,链表中所有的元素都是 sudog 结构,看一下waitq这个数据类型 + +```go +type waitq struct { + first *sudog // sudog队列的队头指针 + last *sudog // sudog队列的队尾指针 +} +``` + +su'do + +我们知道channel是用于两个不同的goroutine之间传递消息的,但是这里的recvq和sendq确是sudog列表,sudog和goroutine之间是不是又某种关系呢?goroutine其实是绑定正在sudog这个结构上,所以recvq可以简单理解为读操作阻塞在 channel 的 goroutine 列表,sendq 是写操作阻塞在 channel 的 goroutine 列表 + +下面看一下sudog结构: + +```go +type sudog struct { + g *g // 绑定的goroutine + next *sudog // 指向sudog链表中的下一个节点 + prev *sudog // 指向sudog链表中的下前一个节点 + elem unsafe.Pointer // 数据对象 + acquiretime int64 + releasetime int64 + ticket uint32 + isSelect bool + success bool + parent *sudog // semaRoot binary tree + waitlink *sudog // g.waiting list or semaRoot + waittail *sudog // semaRoot + c *hchan // channel +} +``` + +这里着关注下elem字段,当向channel发送数据时,elem代表将要保存进channel的元素,当从channel读取数据时,elem代表从channel接受的元素 + +channel的底层结构图: + +![](https://golangstar.cn/assets/img/go语言系列/channel原理/image.png) + +在这个例子中,channel的长度buf为8,元素个数为4,其中有四个元素100,200,300,400。可以发送的索引sendx为4,可以接受的索引为recvq为0 + +## channel操作 + +### channel初始化 + +在程序中我们通过make函数来初始化一个channel,而在运行时其实是调用的makechan函数来完成初始化工作 + +其源码位于src/runtime/chan.go中: + +```go +func makechan(t *chantype, size int) *hchan { + elem := t.elem + if elem.size >= 1<<16 { + throw("makechan: invalid channel element type") + } + if hchanSize%maxAlign != 0 || elem.align > maxAlign { + throw("makechan: bad alignment") + } + + mem, overflow := math.MulUintptr(elem.size, uintptr(size)) + // 如果内存超了,或者分配的内存大于channel最大分配内存,或者分配的size小于0,直接Panic + if overflow || mem > maxAlloc-hchanSize || size < 0 { + panic(plainError("makechan: size out of range")) + } + + var c *hchan + switch { + case mem == 0: + // 没有缓冲区buf,只分配hchan这个结构的内存,不分配buf的内存 + c = (*hchan)(mallocgc(hchanSize, nil, true)) + // Race detector uses this location for synchronization. + c.buf = c.raceaddr() + case elem.ptrdata == 0: // 有缓冲区buf,元素类型不含指针,为当前的 hchan结构和buf数组分配一块连续的内存空间 + c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) + c.buf = add(unsafe.Pointer(c), hchanSize) + default: + // 有缓冲区,且元素包含指针类型,hchan结构和buf数组各自分配内存,分两次分配内存 + c = new(hchan) + c.buf = mallocgc(mem, elem, true) + } + + c.elemsize = uint16(elem.size) + c.elemtype = elem + c.dataqsiz = uint(size) + lockInit(&c.lock, lockRankHchan) + + if debugChan { + print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n") + } + return c +} +``` + +makechan函数有两个参数`t *chantype, size int`,第一个参数代表要创建的channel的类型,即通道可以传递的消息类型,第二个参数代表通道中元素的大小。 + +从源码可以看出创建的主要逻辑位于switch逻辑中,可以看出为channel开辟内存分为三种情况: + +1. **没有缓冲区buf,即创建无缓冲区的channel,只分配hchan本身结构体大小的内存** + +2. **有缓冲区buf, 但元素类型不含指针,一次为当前的 hchan结构和buf数组分配一块连续的内存空间** + +3. **有缓冲区,且元素包含指针类型,分两次分配内存,先为hchan结构和分配内存,再为buf数组元素分配内存** + +针对channel的不同状态,向channel写入结果如下: + +| **操作** | **channel状态** | **结果** | +| ------------- | -------------- | --------- | +| **发送(write)** | **nil** | **阻塞** | +| **发送(write)** | **有缓冲区,缓冲区未满** | **成功写入** | +| **发送(write)** | **无缓冲区或者缓冲区满** | **阻塞** | +| **发送(write)** | **关闭** | **panic** | + +### channel写入 + +往channel写入数据在编码上很简单 + +```go +ch := make(chan, int) +ch <- 1 // 往管道里写入1 +``` + +在运行时其实是调用了`runtime.chansend`函数,源码如下: + +```go +func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { + if c == nil { // channel=nil,当前goroutine会被挂起 + if !block { + return false + } + gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2) + throw("unreachable") + } + + if debugChan { + print("chansend: chan=", c, "\n") + } + + if raceenabled { + racereadpc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(chansend)) + } + // 非阻塞,channel未关闭且channel是非缓冲型,并且等待接收队列为空;或者缓冲型,并且循环数组已经满了 + if !block && c.closed == 0 && full(c) { + return false + } + + var t0 int64 + if blockprofilerate > 0 { + t0 = cputicks() + } + // 加锁,控制并发 + lock(&c.lock) + // 管道已经关闭,向关闭的channel发送数据,直接panic + if c.closed != 0 { + unlock(&c.lock) + panic(plainError("send on closed channel")) + } + // 接收队列非空,直接操作两个goroutine + // 什么意思? 就是当前 channel 有正在阻塞等待的接收方,就是之将数据由一个goroutine发往另一个goroutine + // 直接将待发送数据直接copy到接收处 + // 直接从一个用一个goroutine操作另一个goroutine的栈 + if sg := c.recvq.dequeue(); sg != nil { + // Found a waiting receiver. We pass the value we want to send + // directly to the receiver, bypassing the channel buffer (if any). + send(c, sg, ep, func() { unlock(&c.lock) }, 3) + return true + } + // 如果等待队列为空,并且缓冲区未满,channel必然有缓冲区 + if c.qcount < c.dataqsiz { + // Space is available in the channel buffer. Enqueue the element to send. + qp := chanbuf(c, c.sendx) // 将元素放在sendx处 + if raceenabled { + racenotify(c, c.sendx, nil) + } + typedmemmove(c.elemtype, qp, ep) + c.sendx++ // sendx加1 + if c.sendx == c.dataqsiz { + c.sendx = 0 + } + c.qcount++ // channel总量加1 + unlock(&c.lock) + return true + } + // 走到这里,说明上述情况为命中,channel已经满了,如果是非阻塞的直接返回,否则需要调用gopack将这个goroutine挂起,等待被唤醒 + if !block { + unlock(&c.lock) + return false + } + + gp := getg() // 获取发送数据的goroutine + mysg := acquireSudog() // 获取sudog 结构 + mysg.releasetime = 0 + if t0 != 0 { + mysg.releasetime = -1 + } + + mysg.elem = ep // 设置待发送数据的内存地址 + mysg.waitlink = nil + mysg.g = gp // 绑定发送goroutine + mysg.isSelect = false + mysg.c = c + gp.waiting = mysg // 设置到发送goroutine的waiting上 + gp.param = nil + c.sendq.enqueue(mysg) // 将mysg这个sudog加入到当前channel的发送等待队列,等待被唤醒 + + atomic.Store8(&gp.parkingOnChan, 1) + gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2) + + KeepAlive(ep) + + // someone woke us up. + if mysg != gp.waiting { + throw("G waiting list is corrupted") + } + gp.waiting = nil + gp.activeStackChans = false + closed := !mysg.success + gp.param = nil + if mysg.releasetime > 0 { + blockevent(mysg.releasetime-t0, 2) + } + mysg.c = nil + releaseSudog(mysg) + if closed { + if c.closed == 0 { + throw("chansend: spurious wakeup") + } + panic(plainError("send on closed channel")) + } + return true +} +``` + +从源码分析可以看出,往channel发送数据分为三种方式:**直接发送,缓冲发送还有阻塞发送** + +**直接发送:**&##x5F53;前 channel 有正在阻塞等待接收数据的goroutine,那么直接发送数据,直接从一个goroutine操作另一个goroutine的栈,将待发送数据直接copy到接收处 + +**缓冲发送:**&##x4F1A;判定缓冲区的剩余空间,如果有剩余空间,则将数据拷贝到channel&##x4E2D;**,**&##x73;endx 索引自行自增 1(若sendx 等于 dataqsiz ,则将sendx 置0,原因是buf是一个环形数组),自增完成之后,队列总数自增 1 + +**阻塞发送:**&##x5F53;前 channel 没有正在阻塞等待接收数据的goroutine并且是channel的缓冲区满了之后,发送goroutine就就会阻塞,首先获取`sudog` ,将发松平的goroutine绑定到sudog上,加入到当前channel的发送阻塞队列,调用 `gopark` 方法挂起当前 goroutine,等待被唤醒 + +直接看释义可能比较抽象,下面我们通过我们结合发送流程图,来看看一下channel底层是怎么运作的: + +![](https://golangstar.cn/assets/img/go语言系列/channel原理/image-1.png) + +我们先从发送视角来看,如果阻塞会发生什么? + +当 g1 发送一个消息导致阻塞时,g1 会在状态切换前创建一个 sudog 的结构(等待发送的协程信息),然后加入 sendq 对应的这个双向链表。 + +然后会将 g1 设置为 waiting 状态,然后解除 g1 和 m 的联系,等待被唤醒。 + +这里m表示内核线程,goroutine只有绑定到内核线程才能被执行,这里解绑m就会让出内核线程。调度模型相关后再后面做详细介绍 + +![](https://golangstar.cn/assets/img/go语言系列/channel原理/boxcn9ULtLcD2jviAdDERfbrNNb.png) + +那么什么时候再激活呢?需要等到有其它协程消费这个 channel,我们以 g2 为例子,即 g2 通过channel 操作,从 buf 中获得一个元素。 + +![](https://golangstar.cn/assets/img/go语言系列/channel原理/image-2.png) + +这时候会查看 sendq 的情况,将第一个节点的 elem 数据直接放入 buf。然后将处于等待中的 g1 变为可执行状态,等待下次调度。做完这些之后,会将 g1 的等待信息从 sendq 移除。 + +![](https://golangstar.cn/assets/img/go语言系列/channel原理/boxcnd31UrNwnvthDJwEnkwotle.png) + +这是有缓冲管道的例子,如果是无缓冲管道,elem 会直接写入到 g2 的内存再激活g1,流程是一致的 + +### channel读取 + +从channel读取数据的编码形式如下 + +```go +ch := make(chan, int) +v := <- ch // 直接读取 +v, ok <- ch // ok判断读取的v是否有效 +``` + +上述两种读取方式在运行时最后都是调用的`chanrecv`函数做数据接收,下面分析一下`chanrecv`源码: + +```go +func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { + if debugChan { + print("chanrecv: chan=", c, "\n") + } + // channel是nil + if c == nil { + // 如果是非阻塞模式,直接返回false,false + if !block { + return + } + // 如果是阻塞模式,调用goprak挂起goroutine,等待被唤醒 + gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2) + throw("unreachable") + } + + // 在非阻塞模式下 + // 如果是非缓冲型channel并且当前channel的等待发送链表为空或者是缓冲型channel并且buf中没有数据 + if !block && empty(c) { + // 如果chan没有关闭,则返回 false, false + if atomic.Load(&c.closed) == 0 { + return + } + // 如果channel关闭了,双重检查,看channel是不是无缓冲chan或者是chan中没有数据,如果是则返回 true, false + if empty(c) { + if raceenabled { + raceacquire(c.raceaddr()) + } + // 清除ep指针中的数据并立刻返回true,false + if ep != nil { + typedmemclr(c.elemtype, ep) + } + return true, false + } + } + + var t0 int64 + if blockprofilerate > 0 { + t0 = cputicks() + } + + lock(&c.lock) + // 如果channel已经关闭,并且chan中没有数据,返回 (true,false) + if c.closed != 0 && c.qcount == 0 { + if raceenabled { + raceacquire(c.raceaddr()) + } + unlock(&c.lock) + // // 清除ep指针中的数据并立刻返回true,false + if ep != nil { + typedmemclr(c.elemtype, ep) + } + return true, false + } + // 优先从发送队列中取数据,如果有等待发送数据的groutine,直接从发送数据的goroutine中取出数据 + if sg := c.sendq.dequeue(); sg != nil { + // 从当前channel的发送队列对头取出goroutine,说明有等待发送的goroutine + // 查看recv发现这里有两种情况 + // 1. 如果是非缓冲型channel,那么直接将数据从发送者的栈copy到接收者的栈接收区 + // 2. 如果是缓冲型channel,但是buf已经满了,首先将recvx处的元素拷贝到接收地址,然后将下一个写入元素拷贝到recvx,recvx和sendx都自增1 + // 拷贝完数据以后,唤醒发送队列中的的goroutine,等待调度器调度 + recv(c, sg, ep, func() { unlock(&c.lock) }, 3) + return true, true + } + // 没有等待发送的队列,并且buf中有元素,从channel的缓冲区中接收数据 + if c.qcount > 0 { + // 直接从缓冲区buf取出数据 + qp := chanbuf(c, c.recvx) + if raceenabled { + racenotify(c, c.recvx, nil) + } + // 将数据放到目标地址 + if ep != nil { + typedmemmove(c.elemtype, ep, qp) + } + // 清空缓冲队列buf中对应的元素 + typedmemclr(c.elemtype, qp) + c.recvx++ // 接收索引自增1 + if c.recvx == c.dataqsiz { + c.recvx = 0 + } + c.qcount-- // 队列元素数量减1 + unlock(&c.lock) + return true, true + } + // 同步非阻塞模式,直接返回false,false + if !block { + unlock(&c.lock) + return false, false + } + + // 走到这里说明是阻塞模式 + // 没有任何数据可以获取到,阻塞住当前读goroutine,并加入channel的接收队列中 + gp := getg() + mysg := acquireSudog() + mysg.releasetime = 0 + if t0 != 0 { + mysg.releasetime = -1 + } + // No stack splits between assigning elem and enqueuing mysg + // on gp.waiting where copystack can find it. + mysg.elem = ep + mysg.waitlink = nil + gp.waiting = mysg + mysg.g = gp + mysg.isSelect = false + mysg.c = c + gp.param = nil + c.recvq.enqueue(mysg) // 加入到接收者队列 + + atomic.Store8(&gp.parkingOnChan, 1) + gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2) + + if mysg != gp.waiting { + throw("G waiting list is corrupted") + } + gp.waiting = nil + gp.activeStackChans = false + if mysg.releasetime > 0 { + blockevent(mysg.releasetime-t0, 2) + } + success := mysg.success + gp.param = nil + mysg.c = nil + releaseSudog(mysg) // 阻塞的goroutine被唤醒 + return true, success +} +``` + +通过对源码的分析,总结一下从channel读取数据的流程: + +1. **从一个空 Channel 接收数据,goroutine会被挂起,并阻塞等待** + +2. **当前channel的发送者队列有goroutine等待发送数据时** + + 1. **如果是无缓冲的 channel,当有接收者到来时,会直接从等待发送的goroutine拷贝数据到接收goroutine的接收区** + + 2. **如果是有缓冲的 channel,此时缓冲区满,当有接收者到来时,会先从缓冲区把数据拷贝到接收者(注意,此时recvx和sendx相等,拷贝完之后,recvx和sendx都自增1),然后把等待的发送者的数据拷贝到缓冲区** + +3. **当 channel 有缓冲区,并且缓冲区为空,且没有发送者时,这时 channel 阻塞,接收的goroutin会被挂起,等待被唤醒。** + +4. **当 channel 有缓冲区,并且缓冲区有数据但未满,当有接收者来接收数据时,直接把缓冲区把数据拷贝到接收者** + + + +消费流程如下图所示 + +![](https://golangstar.cn/assets/img/go语言系列/channel原理/image-3.png) + +针对channel的不同状态,从channel读取数据结果如下: + +| **操作** | **channel状态** | **结果** | +| ------------ | ------------- | -------------- | +| **接收(read)** | **nil** | **阻塞** | +| **接收(read)** | **打开,有元素** | **读取到正常值** | +| **接收(read)** | **打开,没有元素** | **阻塞** | +| **接收(read)** | **关闭** | **读取到默认值(空值)** | + +### channel关闭 + +管道的关闭很简单 + +```go +ch := make(chan int) +close(ch) +``` + +在运行时,调用`runtime.closechan`函数对channel进行关闭,`closechan`函数源代码如下: + +```go +func closechan(c *hchan) { + // channel为nil,会直接panic + if c == nil { + panic(plainError("close of nil channel")) + } + // 加锁 + lock(&c.lock) + // 如果channel已经被关闭,panic + if c.closed != 0 { + unlock(&c.lock) + panic(plainError("close of closed channel")) + } + + if raceenabled { + callerpc := getcallerpc() + racewritepc(c.raceaddr(), callerpc, abi.FuncPCABIInternal(closechan)) + racerelease(c.raceaddr()) + } + + c.closed = 1 // 设置关闭状态closed为1 + + // 申明一个存放g的list,用于存放所有的goroutine + // 目的是尽快释放锁,因为队列中可能还有数据需要处理,可能用到锁 + var glist gList + + // 唤醒所有等待从channel接收数据的goroutine + for { + sg := c.recvq.dequeue() + if sg == nil { // 接收者队列处理完,退出 + break + } + if sg.elem != nil { + typedmemclr(c.elemtype, sg.elem) + sg.elem = nil + } + if sg.releasetime != 0 { + sg.releasetime = cputicks() + } + gp := sg.g + gp.param = unsafe.Pointer(sg) + sg.success = false + if raceenabled { + raceacquireg(gp, c.raceaddr()) + } + glist.push(gp) // 将goroutine加入到临时队列 + } + + // 处理所有向channel中写入数据的goroutine,抛异常,报panic + for { + sg := c.sendq.dequeue() + if sg == nil { // 发送者队列处理完,退出 + break + } + sg.elem = nil + if sg.releasetime != 0 { + sg.releasetime = cputicks() + } + gp := sg.g + gp.param = unsafe.Pointer(sg) + sg.success = false + if raceenabled { + raceacquireg(gp, c.raceaddr()) + } + glist.push(gp) // 将goroutine加入到临时队列 + } + unlock(&c.lock) + + // 放入调度队列,等待被调度. + for !glist.empty() { + gp := glist.pop() + gp.schedlink = 0 + goready(gp, 3) + } +} +``` + +对channel的关闭很简单,但是也容易造成错误,从源码中分析我们知道, + +* **如果channel为nil,对其关闭将会panic** + +* **重复关闭一个已经关闭的channel会panic** + +所以在关闭channel时,我们要特别小心,除了上述两种情况外,还要保证没有发送者往channel里面发送数据了,否则发送者也会panic diff --git a/hzh/GolangStar/Go语言原理/context原理.md b/hzh/GolangStar/Go语言原理/context原理.md new file mode 100644 index 0000000..6b60bbe --- /dev/null +++ b/hzh/GolangStar/Go语言原理/context原理.md @@ -0,0 +1,468 @@ +--- +tags: + - Go + - golang + - go原理深入 + - channel原理 +--- + +# context原理 + +## context是什么 + +context是go语言在1.7引入的一个用于goroutine之间传递信息的并发安全的包,context可以翻译为上下文,其在项目中主要是用于上下与下层goroutine的取消控制以及数据共享,也是go语言中goroutine之间通信的一种方式,其底层是借助channl与sync.Mutex实现的。 + +关于context的用法我们在前一章节并发实践里已经做过介绍,本章主要介绍一下context的底层原理 + +## context的底层实现 + +与context相关的源码基本都在src/context/context.go中,我们通过源码来看一下,context的底层究竟做了些什么 + +context在底层实现上其实用到了2个接口,对这个接口的4种实现,以及提供了6个方法 + +**接口:** + +| 接口名 | 说明 | +| -------- | ------------------------------------ | +| Context | context的接口定义,规定context的实现必须包含的四个基本方法 | +| canceler | context的取消接口,其中定义了两个方法 | + +**实现:** + +context接口的四种实现 + +| 结构名 | 说明 | +| ---------- | ------------------------------------ | +| emptyCtx | 一个空的context,用作根context | +| cancelCtx | 可以通过取消函数来取消context | +| timerCtx | 可以通过定时器和deadline来定时取消contextvalueCtx | +| valueCtx | 类似于map,可以用来存储key/valuez键值对 | + +**方法:** + +| 函数名 | 说明 | +| ------------- | ----------------------- | +| Background | 返回一个根context即emptyCtx | +| TODO | 也是返回一个根context即emptyCtx | +| WithCancel | 派生出一个cancelCtx | +| WithDeadline | 派生出一个timerCtx | +| WithTimeout | 派生出一个timerCtx | +| WithValue | 派生出一个valueCtx | + +下面我们将逐一解读这几个结构及其实现方法 + +## 接口说明 + +### context接口 + +首先还是回顾一下context接口,context的接口定义如下: + +```go +type Context interface { + Deadline() (deadline time.Time, ok bool) + Done() <-chan struct{} + Err() error + Value(key interface{}) interface{} +} +``` + +接口提供了四个方法 + +* `Deadline`: 返回 context.Context 被取消的时间,即截止时间; + +* `Done`: 返回一个 Channel,当Context被取消或者到达截止时间,这个 Channel 就会被关闭,表示context结束,多次调用 Done 方法返回的channel是同一个 + +* `Err`: 返回 context.Context 结束的原因 + +* 4.`Value` :从 context.Context 中获取键对应的值,类似于map的get方法,对于同一个context,多次调用 Value 并传入相同的 Key 会返回相同的结果,如果没有对应的`key`,则返回`nil`,键值对是通过WithValue方法写入 + +### canceler接口 + +**canceler接口的源码定义如下** + +```go +type canceler interface { + cancel(removeFromParent bool, err error) // 创建cancel接口实例的goroutine 调用cancel方法通知被创建的goroutine退出 + Done() <-chan struct{} // 返回一个channel,后续被创建的goroutine通过监听这个channel的信号来完成退出 +} +``` + +canceler接口主要用于取消方法的实现,如果一个示例既实现了context接口又实现了canceler接口,那么这个context就是可以本取消的,比如cancelCtx 和timerCtx。如果仅仅只是实现了context接口,而没有实现canceler,就是不可取消的,比如emptyCtx 和valueCtx。 + +## contex实现 + +在context报下对context接口有四种基本的实现,即emptyCtx ,cancelCtx ,timerCtx,valueCtx + +### emptyCtx + +首先看一下emptyCtx 这个最基本的实现,emptyCtx 虽然实现了context接口,但是不具备任何功能,因为实现很简单,基本都是直接返回空值。虽然emptyCtx 没有任何功能,但他还是有作用的,一般用它作为根context来派生出有实际用处的context。要想创建有实际功能的context,要使用后续提供的一系列with方法来派生出新的context,这个在前面讲context用法的时候已经做过介绍,就不再过多赘述。 + +emptyCtx 的相关源码: + +```go +// An emptyCtx is never canceled, has no values, and has no deadline. It is not +// struct{}, since vars of this type must have distinct addresses. +type emptyCtx int + +func (*emptyCtx) Deadline() (deadline time.Time, ok bool) { + return +} + +func (*emptyCtx) Done() <-chan struct{} { + return nil +} + +func (*emptyCtx) Err() error { + return nil +} + +func (*emptyCtx) Value(key any) any { + return nil +} +``` + +可以看到emptyCtx 的实现没有做任何操作,就是一个整形结构。这个空的emptyCtx 会在两个创建根context得函数被用到 + +```go +func Background() Context { + return background +} + +func TODO() Context { + return todo +} +``` + +而这里background和todo其实就是返回一个emptyCtx&##x20; + +```go +var ( + background = new(emptyCtx) + todo = new(emptyCtx) +) +``` + +在写代码的时候,我么你调用这两个函数其实Background()函数或者TODO()函数创建最顶层的context其实就是获取一个emptyCtx。 + +###cancelCtx + +cancelCtx结构定义如下: + +```go +type cancelCtx struct { + Context // 组合了一个Context ,所以cancelCtx 一定是context接口的一个实现 + mu sync.Mutex // 互斥锁,用于保护以下三个字段 + // value是一个chan struct{}类型,原子操作做锁优化 + done atomic.Value + // key是一个取消接口的实现,map其实存储的是当前canceler接口的子节点,当前context被取消时,会遍历子节点发送取消信号 + children map[canceler]struct{} + err error // context被取消的原因 +} +``` + +下面看一下其各个方法的具体实现,首先看一下`Done()`方法: + +```go +func (c *cancelCtx) Done() <-chan struct{} { + d := c.done.Load() + if d != nil { + return d.(chan struct{}) + } + c.mu.Lock() + defer c.mu.Unlock() + d = c.done.Load() + if d == nil { + d = make(chan struct{}) + c.done.Store(d) + } + return d.(chan struct{}) +} +``` + +代码很简单,其实就是采用”懒汉模式“创建一个struct{}类型的管道返回,从类型可以看出这个channel是只读的,不能往里面写数据,所以应该避免直接读取这个channel,会发生阻塞。所以在使用上要配合select来非阻塞读取,由于是只读的,所以只有在一种情况下会读到值,那就是关闭这个channel的时候会读到零值。利用这个而特性就可以实现关闭的消息通知。 + +再看一下其 cancel() 方法的实现: + +```go +// cancel closes c.done, cancels each of c's children, and, if +// removeFromParent is true, removes c from its parent's children. +func (c *cancelCtx) cancel(removeFromParent bool, err error) { + if err == nil { // context被取消的原因,必传,否则panic + panic("context: internal error: missing cancel error") + } + c.mu.Lock() + if c.err != nil { // 在赋值这个err之前,c.err已经有值了,说明已经被调用过cancel函数了,c这个context已经被取消 + c.mu.Unlock() + return // already canceled + } + c.err = err // 赋值err信息 + d, _ := c.done.Load().(chan struct{}) // 获取通知管道 + if d == nil { + c.done.Store(closedchan) + } else { + close(d) // 关闭管道 + } + // 遍历当前context的所有子节点,调用取消函数 + for child := range c.children { + // NOTE: acquiring the child's lock while holding parent's lock. + child.cancel(false, err) // 递归取消子context + } + c.children = nil // 取消动作完成之后,孩子节点置空 + c.mu.Unlock() + + if removeFromParent { + removeChild(c.Context, c) // 将自身从父节点children map种移除 +} +``` + +cancel不仅取消当前context,还会遍历当前context的所有子context,递归取消,递归取消玩当前context的所有子context后,会将自身从父节点children map种移除,移除函数removeChild源码如下: + +```go +// removeChild removes a context from its parent. +func removeChild(parent Context, child canceler) { + p, ok := parentCancelCtx(parent) + if !ok { + return + } + p.mu.Lock() + if p.children != nil { + delete(p.children, child) // 从父context的children中移除 + } + p.mu.Unlock() +} +``` + +移除前后效果如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/context原理/image.png) + +在用户层面,创建cancelCtx的方法其实我们你之前也接触过,就是withCancel方法,在平常代码中,我们一般用这个方法来派生一个可以用cancel取消函数取消的context,常规用法如下: + +```go +ctx,cancel := context.WithCancel(context.Background()) +``` + +下面继续跟一下这个WithCancel函数的源码: + +```go +func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { + if parent == nil { // 传入的父context不能为空,否则报panic + panic("cannot create context from nil parent") + } + c := newCancelCtx(parent) // 这里就会创建一个cancelCtx + propagateCancel(parent, &c) // 这里主要是关联父context ctx和子congtxt c的逻辑 + return &c, func() { c.cancel(true, Canceled) } // 具体的取消函数cancel的实现 +} +``` + +前面说了调用cancelFunc函数可以级联取消子context,那么为什么可以级联取消呢?propagateCancel函数就是用来做这个工作的,他将父context和子context关联起来,具体的关联逻辑,我们通过源码来分析: + +```go +// propagateCancel arranges for child to be canceled when parent is. +func propagateCancel(parent Context, child canceler) { + done := parent.Done() // 获取父context的通信管道 chan struct{} + if done == nil { // done为空,说明父context不会被取消 + return // parent is never canceled + } + + select { + case <-done: // 通信管道收到了消息,说明父context已经被取消,不用重复取消了 + // parent is already canceled + child.cancel(false, parent.Err()) // 但是父context已经取消,这里子context也应该要取消,由于还没有关联上,所以主动调用cancel取消关联 + return + default: + } + + if p, ok := parentCancelCtx(parent); ok { // 从父context中提取出cancelCtx结构 + p.mu.Lock() + if p.err != nil { // 加锁后双重检查,再次检查父context有没有被取消 + // parent has already been canceled + child.cancel(false, p.err) // 父context被取消,主动取消子context + } else { // 父context没有被取消 + if p.children == nil { + p.children = make(map[canceler]struct{}) // 创建父context的children map + } + p.children[child] = struct{}{} // 把当前子context加入到children map里面 + } + p.mu.Unlock() + } else { // 从父context中没有提取出cancelCtx结构 + atomic.AddInt32(&goroutines, +1) + go func() { // 新起一个goroutine监控父子context的通信管道有没有取消信号 + select { + case <-parent.Done(): + child.cancel(false, parent.Err()) + case <-child.Done(): + } + }() + } +} +``` + +看一下这个提取父context的cancelCtx结构的parentCancelCtx方法: + +```go +func parentCancelCtx(parent Context) (*cancelCtx, bool) { + done := parent.Done() + // 从父context的取消信息管道为空,说明父context不会被取消 + // closedchan is a reusable closed channel. + // var closedchan = make(chan struct{}) + // done == closedchan,表明ctx不是标准的 cancelCtx,可能是自定义的结构实现了 context.Context 接口 + if done == closedchan || done == nil { // + return nil, false + } + p, ok := parent.Value(&cancelCtxKey).(*cancelCtx) // 通过context的value方法从父context中提取出cancelCtx + if !ok { + return nil, false + } + pdone, _ := p.done.Load().(chan struct{}) // 判断父context里的通信管道和cancelCtx里的管道是否一致 + if pdone != done { // 不一致,表明parent不是标准的cancelCtx + return nil, false + } + return p, true // 返回cancelCtx +} +``` + +总结一下通过WithCancel函数在派生可取消的子context的过程中,通过propagateCancel函数关联父子context可能遇到的几种情形: + +1. 父context的通信管道done为空或者已经被取消,就不用关联了,直接取消当前子context即可‘’ + +2. 父context可以被取消,但是还未被取消,并且父context可以提取出标准的cancelCtx结构,则创建父context的children map,将当前子context加入到这个map中 + +3. 父context可以被取消,但是还未被取消,父context不能提取出标准的cancelCtx结构,新起一个goroutine监控父子context的通信管道有没有取消信号 + +### timerCtx + +timerCtx在cancelCtx 的基础上,又提供了截止时间的功能,不仅拥有像cancelCtx 一样,可以通过调用取消函数cancelFun来取消子context的方式,还可以设置一个截止时间deadline ,在 deadline 到来时,自动取消 context。 + +首先看一下timerCtx的结构定义: + +```go +type timerCtx struct { + cancelCtx + timer *time.Timer // Under cancelCtx.mu. + deadline time.Time +} +``` + +看到它内置了cancelCtx,所以cancelCtx拥有的功方法,他可以调用cancelCtx的方法,能够主动取消context,再看一下timerCtx自身的cancel方法实现: + +```go +func (c *timerCtx) cancel(removeFromParent bool, err error) { + c.cancelCtx.cancel(false, err) // 直接调用cancelCtx的cancel方 + if removeFromParent { + // Remove this timerCtx from its parent cancelCtx's children. + removeChild(c.cancelCtx.Context, c) // 将当前子context从父context中删除 + } + c.mu.Lock() + if c.timer != nil { // 要关闭掉定时器,因为手动取消过一次了,如果不关闭,在deadline 到来时,不会再次取消,造成错误 + c.timer.Stop() + c.timer = nil + } + c.mu.Unlock() +} +``` + +同样在用户层面,我们一般通过WithTimeout或者WithDeadline来创建一个timerCtx + +```go + ctx, cancel := context.WithDeadline(context.Background(),time.Now().Add(4*time.Second)) // 截止时间当前时间4s后 + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) // 超时时间为4s后 +``` + +在WithTimeout内部其实也是调用了WithDeadline,所以只用分析WithDeadline方法即可: + +```go +func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { + if parent == nil { // 父context为空,直接报panic + panic("cannot create context from nil parent") + } + // 如果父context的deadline早于这里要设置的子context的截止时间 + if cur, ok := parent.Deadline(); ok && cur.Before(d) { + // 直接取消父context即可,不需要再管子context的取消时间,直接构建一个可以取消的子context + // 因为父context的到期时间早于子context,当父context被取消的时候,这个子context肯定会被级联取消 + return WithCancel(parent) + } + // 创建timerCtx对象 + c := &timerCtx{ + cancelCtx: newCancelCtx(parent), + deadline: d, + } + propagateCancel(parent, c) // 关联父子context + dur := time.Until(d) // 获取距离设置的子context过期时间的时间差 + if dur <= 0 { // 时间差小于0,表示已经过期了,直接取消 + c.cancel(true, DeadlineExceeded) // deadline has already passed + return c, func() { c.cancel(false, Canceled) } + } + c.mu.Lock() + defer c.mu.Unlock() + if c.err == nil { + // 根据时间差,创建一个定时器,到deadline的时候定时触发取消 + c.timer = time.AfterFunc(dur, func() { + c.cancel(true, DeadlineExceeded) + }) + } + return c, func() { c.cancel(true, Canceled) } +} +``` + +所以,父context未取消的情况下,在创建timerCtx的时候有两种情况: + +设置的截止时间晚于父context的截止时间,则不会创建timerCtx,会直接创建一个可取消的context,因为父context的截止时间更早,会先被取消,父context被取消的时候会级联取消这个子context + +设置的截止时间早于父context的截止时间,会创建一个正常的timerCtx + +### valueCtx + +valueCtx的作用与上述三个context有点不同,他不是用于父子context之间的取消的,而是用于数据共享。作用类似于一个map,不过数据的存储和读取是在两个context,用于goroutine之间的数据传递。 + +valueCtx的结构定义如下: + +```go +type valueCtx struct { + Context + key, val interface{} +} +``` + +valueCtx内置了Context,所以他也是一个context接口的实现,但是其没有实现canceler接口,所以他不能用作context的取消,valueCtx实现了`String()`方法和`Value`方法,`String()`比较简单,就不细看了,下面看一下 + +`Value`方法 + +```go +func (c *valueCtx) Value(key interface{}) interface{} { + if c.key == key { + return c.val + } + return c.Context.Value(key) +} +``` + +方法很简单,就是向上递归的查找key所对应的value,如果找到则直接返回 value,否则查找该context的父context,一直顺着 context 向上,最终找到根节点(一般是 emptyCtx),直接返回一个 nil。查找过程如下图: + +![](https://golangstar.cn/assets/img/go语言系列/context原理/image-1.png) + +从定义可以出valueCtx中存储着一对键值对,具体是怎么用的呢?同样我们一般使用withValue方法派生出一个valueCtx + +```go +ctx := context.WithValue(context.Background(),"key1","value1") +``` + +withValue函数源码如下: + +```go +func WithValue(parent Context, key, val interface{}) Context { + if parent == nil { + panic("cannot create context from nil parent") + } + if key == nil { + panic("nil key") + } + if !reflectlite.TypeOf(key).Comparable() { + panic("key is not comparable") + } + return &valueCtx{parent, key, val} +} +``` + +withValue的方法实现很简单,就是创建一个valueCtx,将key和value设置到valueCtx返回。 + diff --git a/hzh/GolangStar/Go语言原理/defer原理.md b/hzh/GolangStar/Go语言原理/defer原理.md new file mode 100644 index 0000000..7b40b91 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/defer原理.md @@ -0,0 +1,321 @@ +--- +tags: + - Go + - golang + - go原理深入 + - defer原理 +--- + +# defer原理 + +## defer是什么 + +defer是go语言的一个关键字,用来修饰函数,其作用是让defer后面跟的函数或者方法调用能够延迟到当前所在函数return或者panic的时候再执行。 + +## defer的使用形式 + +```go +defer func(args) +``` + +defer在使用的时候,只需要在其后面加上具体的函数调用即可,这样就会注册一个延迟执行的函数func,并且会把函数名和参数都确定,等到从当前函数退出的时候在执行 + +## defer的底层结构 + +进行defer 函数调用的时候其实会生成一个\_defer结构,一个函数中可能有多次defer调用,所以会生成多个这样的\_defer结构,这些\_defer结构链式存储构成一个\_defer链表,当前goroutine的\_defer指向这个链表的头节点, + +\_defer 的结构定义在src/src/runtime/runtime2.go中,源码如下: + +```go +type _defer struct { + started bool // 标志位,标识defer函数是否已经开始执行,默认为false + heap bool // 标记位,标志当前defer结构是否是分配在堆上 + openDefer bool // 标记位,标识当前defer是否以开放编码的方式实现 + sp uintptr // 调用方的sp寄存器指针,即栈指针 + pc uintptr // 调用方的程序计数器指针 + fn func() // defer注册的延迟执行的函数 + _panic *_panic // 标识是否panic时触发,非panic触发时,为nil + link *_defer // defer链表 + fd unsafe.Pointer // defer调用的相关参数 + varp uintptr // value of varp for the stack frame + framepc uintptr +} +``` + +底层存储如下图: + +![](https://golangstar.cn/assets/img/go语言系列/defer原理/image.png) + +defer函数在注册的时候,创建的\_defer结构会依次插入到\_defer链表的表头,在当前函数return的时候,依次从\_defer链表的表头取出\_defer结构执行里面的fn函数 + +## defer的执行过程 + +在探究defer的执行过程之前,先简单看一下go语言程序的编译过程,go语言程序由.go文件编译成最终的二进制机器码主要有以下结果步骤 + +![](https://golangstar.cn/assets/img/go语言系列/defer原理/image-1.png) + +defer关键字的处理在生成SSA中间代码阶段,编译器遇到 defer 语句的时候,会插入两种函数: + +1. defer内存分配函数:`deferproc`(堆分配) 或 `deferprocStack`(栈分配) + +2. 执行函数:`deferreturn` + +下面分别看一下这两种函数的执行过程 + +defer的处理逻辑在cmd/compile/internal/ssagen/ssa.go文件中的state.stmt()方法中,由于源码过长,这里只贴部分重要代码: + +```go +case ir.ODEFER: // 如果节点时defer节点 + n := n.(*ir.GoDeferStmt) + if base.Debug.Defer > 0 { + var defertype string + if s.hasOpenDefers { + defertype = "open-coded" // 开放编码 + } else if n.Esc() == ir.EscNever { + defertype = "stack-allocated" // 栈分配 + } else { + defertype = "heap-allocated" // 堆分配 + } + base.WarnfAt(n.Pos(), "%s defer", defertype) + } + if s.hasOpenDefers { // 如果可以开放编码,即内联实现 + s.openDeferRecord(n.Call.(*ir.CallExpr)) // 就使用开放编码这种方式 + } else { + d := callDefer // 否则先默认使用堆分配的模式 + if n.Esc() == ir.EscNever { // 没有内存逃逸,使用栈分配的方式实现 + d = callDeferStack + } + s.callResult(n.Call.(*ir.CallExpr), d) + } +``` + +从上述代码可以看出,defer的是现有三种实现方式,在栈上分配内存,在堆上分配内存以及使用开放编码的方式。会优先使用内联方式,当内联不满足,且没有发生内存逃逸的情况下,使用栈分配的方式,这两种情况都不符合的情况下在使用堆分配,这样做的好处是提升性能。 + +### \_defer内存分配 + +在上面的分析中我们可以看出在不同的情况下,\_defer结构分配在不同的地方,可能分配在堆上也可能分配在栈上,这两种分配方式调用的函数是不同的,堆上分配实际调用的是`runtime.deferproc`函数,栈上分配内存调用的是`runtime.deferprocStack`函数,下面分别来看看这两个函数都做了些什么工作? + +#### 堆上分配 + +`先看deferproc`函数,在堆上分配内存,go 1.13 之前只有这个函数,说明go 1.13 之前,\_defer只能在堆上分配。 + +src/runtime/panic.go + +```go +func deferproc(fn func()) { + gp := getg() // 获取goroutine,defer在哪个goroutine中执行 + if gp.m.curg != gp { + // go code on the system stack can't defer + throw("defer on system stack") + } + + d := newdefer() // 在堆中新建一个_defer对象 + if d._panic != nil { + throw("deferproc: d.panic != nil after newdefer") + } + d.link = gp._defer // 将这个新建的defer对象加入到goroutine的defer链表头部 + gp._defer = d + d.fn = fn + d.pc = getcallerpc() + d.sp = getcallersp() + return0() + +} +``` + +重点看一下newdefer()这个函数 + +```go +func newdefer() *_defer { + var d *_defer + mp := acquirem() + pp := mp.p.ptr() // 获取逻辑处理器p + // p的本地defer缓存池为空且全局defer缓存池不为空,从全局defer缓存池取出一个defer结构加入到p的本地defer缓存池 + if len(pp.deferpool) == 0 && sched.deferpool != nil { + lock(&sched.deferlock) + for len(pp.deferpool) < cap(pp.deferpool)/2 && sched.deferpool != nil { + d := sched.deferpool + sched.deferpool = d.link + d.link = nil + pp.deferpool = append(pp.deferpool, d) + } + unlock(&sched.deferlock) + } + // p的本地defer缓存池取出一个defer结构 + if n := len(pp.deferpool); n > 0 { + d = pp.deferpool[n-1] + pp.deferpool[n-1] = nil + pp.deferpool = pp.deferpool[:n-1] + } + releasem(mp) + mp, pp = nil, nil + // p的本地defer缓存池和全局defer缓存池都没有可用的defer结构,在堆上创建一个 + if d == nil { + // Allocate new defer. + d = new(_defer) + } + d.heap = true + return d +} +``` + +可以看出堆上defer的创建思想借助了内存复用,用到了内存池的思想,创建defer的过程是:优先在p的本地和全局的defer缓存池里找到一个可用的defer结构返回,找不到在去堆上创建 + +#### 栈上分配 + +下面看一下`runtime.deferprocStack`函数,在栈上分配\_defer,这个函数是go 1.13 之后引入的,优化defer性能的,显然在栈上分配的效率更高。`runtime.deferprocStack`源码如下: + +```go +// 在调用这个函数之前,defer结构已经站在栈上创建好,这里只是作为参数传进来赋值 +func deferprocStack(d *_defer) { + gp := getg() // // 获取goroutine,defer在哪个goroutine中执行 + if gp.m.curg != gp { + // go code on the system stack can't defer + throw("defer on system stack") + } + d.started = false + d.heap = false // 堆上分配置为false + d.openDefer = false + d.sp = getcallersp() + d.pc = getcallerpc() + d.framepc = 0 + d.varp = 0 + *(*uintptr)(unsafe.Pointer(&d._panic)) = 0 + *(*uintptr)(unsafe.Pointer(&d.fd)) = 0 + *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer)) + *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d)) + return0() + +} +``` + +Go 在编译的时候在 SSA中间代码阶段,如果判断出\_defer需要在站上分配,则编译器会直接在函数调用栈上初始化 \_defer 记录,并作为参数传递给 deferprocStack函数。 + +#### 开放编码 + +再看一下defer的第三种实现方式,开放编码。这种方式是在go1.14 引入的继续优化defer实现性能的方式。在go1.14 中通过代码内联优化,使得函数末尾直接对`defer`函数进行调用,减少了函数调用开销。其主要逻辑位于 cmd/compile/internal/walk/stmt.go文件的 walkStmt()函数和 cmd/compile/internal/ssagen/ssa.go 的 buildssa()函数,函数较长,这里看下关键代码。 + +walkStmt()函数: + +```go +case ir.ODEFER: + n := n.(*ir.GoDeferStmt) + ir.CurFunc.SetHasDefer(true) + ir.CurFunc.NumDefers++ + if ir.CurFunc.NumDefers > maxOpenDefers { // maxOpenDefers = 8 + // defer函数的个数多余8个时,不能用开放编码模式 + ir.CurFunc.SetOpenCodedDeferDisallowed(true) + } + if n.Esc() != ir.EscNever { + // If n.Esc is not EscNever, then this defer occurs in a loop, + // so open-coded defers cannot be used in this function. + ir.CurFunc.SetOpenCodedDeferDisallowed(true) + } + fallthrough +``` + + 这里分析一下`n.Esc() != ir.EscNever`这个条件: + +通过源码注释可以看到,这里其实就是判断defer是否在循环体内,因为 defer 在 for 循环中调用,编译器不确定会执行多少次,会逃逸到堆上,这样defer就只能分配在堆中了。所以在使用defer 延迟调用的时候,尽量不要在循环中使用,否则可能导致性能问题。 + +buildssa()函数: + +```go +// build时候的没有设置-N,允许内联 +s.hasOpenDefers = base.Flag.N == 0 && s.hasdefer && !s.curfn.OpenCodedDeferDisallowed() +switch { +case base.Debug.NoOpenDefer != 0: + s.hasOpenDefers = false +case s.hasOpenDefers && (base.Ctxt.Flag_shared || base.Ctxt.Flag_dynlink) && base.Ctxt.Arch.Name == "386": + // Don't support open-coded defers for 386 ONLY when using shared + // libraries, because there is extra code (added by rewriteToUseGot()) + // preceding the deferreturn/ret code that we don't track correctly. + s.hasOpenDefers = false +} +if s.hasOpenDefers && len(s.curfn.Exit) > 0 { + // Skip doing open defers if there is any extra exit code (likely + // race detection), since we will not generate that code in the + // case of the extra deferreturn/ret segment. + s.hasOpenDefers = false +} +if s.hasOpenDefers { + // Similarly, skip if there are any heap-allocated result + // parameters that need to be copied back to their stack slots. + for _, f := range s.curfn.Type().Results().FieldSlice() { + if !f.Nname.(*ir.Name).OnStack() { + s.hasOpenDefers = false + break + } + } +} +if s.hasOpenDefers && + // defer所在函数返回值个数和defer函数个数乘积不能大于15 + s.curfn.NumReturns*s.curfn.NumDefers > 15 { + // Since we are generating defer calls at every exit for + // open-coded defers, skip doing open-coded defers if there are + // too many returns (especially if there are multiple defers). + // Open-coded defers are most important for improving performance + // for smaller functions (which don't have many returns). + s +``` + +总结一下:在g1.14之后,go会优先采用内联的方式处理defer函数调用,但是需要满足以下几个条件: + +* build编译的时候没有设置-N + +* defer 函数个数没有超过 8 个 + +* defer所在函数返回值个数和defer函数个数乘积不超过15 + +* defer没有出现在循环语句中时 + +### defer函数执行 + +在给defer分配好内存之后,剩下的就是执行了。在函数退出的时候,`deferreturn` 来执行defer链表上的各个defer函数。函数源码如下: + +```go +func deferreturn() { + gp := getg() + // 遍历goroutine的defer链表 + for { + d := gp._defer + if d == nil { + return + } + sp := getcallersp() // 获取调用栈的栈顶指针 + if d.sp != sp { + return + } + // 开放编码模式,内联处理 + if d.openDefer { + done := runOpenDeferFrame(gp, d) + if !done { + throw("unfinished open-coded defers in deferreturn") + } + gp._defer = d.link + freedefer(d) + // If this frame uses open defers, then this + // must be the only defer record for the + // frame, so we can just return. + return + } + // 非内联模式 + fn := d.fn // 获取defer的执行函数 + d.fn = nil // defer上的函数指针置空 + gp._defer = d.link // 遍历下一个defer结构 + freedefer(d) // 释放defer结构,优先归还到defer缓冲池中 + fn() // 执行函数调用 + } +} +``` + +当 go函数 的 `return` 关键字执行的时候,触发 `call` 调用 `deferreturn`函数,deferreturn函数的执行逻辑也很简单,就是遍历goroutine上的defer链表,从表头开始遍历,依次取出defer结构执行defer结构中的函数执行。 + +总结: + +1. 遇到defer关键字,编译器会在编译阶段注册defer函数的时候插入`deferproc()`函数或者`deferprocStack`函数,在return之前插入deferreturn()函数 + +2. defer函数的执行顺序是LIFO的,因为每次创建的defer结构都是插入到goroutine的defer链表表头 + +3. defer结构的有三种实现方式,堆上分配,栈上分配还有内联实现 + diff --git a/hzh/GolangStar/Go语言原理/gmp调度原理.md b/hzh/GolangStar/Go语言原理/gmp调度原理.md new file mode 100644 index 0000000..f114292 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/gmp调度原理.md @@ -0,0 +1,1406 @@ +--- +tags: + - Go + - golang + - go原理深入 + - GMP调度 + - 协程调度 +--- + +# GMP调度原理 + + +聊到Go语言,大家最津津乐道的可能就是它那"天生强大"的并发能力了。一个简单的 `go` 关键字,就能开启一个并发执行单元,这酸爽,谁用谁知道。但是,你有没有想过,这背后到底藏着什么样的魔法?为什么Go的并发可以如此轻盈、如此高效? + +答案,就藏在它核心的 **GMP调度模型**里。 + +很多Gopher对GMP可能只是略知一二,知道有G、M、P这三个角色,但它们之间是如何协作的,一个goroutine又是如何被创建、调度、甚至是被"抢占"的,可能就有点模糊了。 + +不怕!今天,就带着大家把GMP这块硬骨头彻底啃下来。咱们不光要搞懂理论,还要深入`v1.19`的源码,把它的底层设计看个底朝天。这篇文章会分成两大部分,从宏观到微观,带你彻底搞懂Go语言的设计精髓,GMP调度。 + +* **第一部分:宏观视角** + + * **第一小节:从基础聊起**:咱们先热个身,聊聊线程、协程这些基本概念,看看Go的goroutine是如何站在巨人肩膀上的。 + + * **第二小节:GMP设计图纸**:直接上源码,看看G、M、P这三个核心组件在底层到底长啥样。 + +* **第二部分:微观之旅** + + * **第三小节:一个G的诞生与执行**:跟着一个goroutine的视角,看它是如何被创建并被调度器翻牌子执行的。 + + * **第四小节:G的主动让贤**:看看一个正在运行的goroutine是如何主动让出CPU,把机会留给其他G的。 + + * **第五小节:霸道的调度器**:当一个G"占着茅坑不拉屎",长期占用CPU时,我们的监控者是如何强制把它"请"下来的。 + +## 1. 故事的开始:了解基础概念 + +### 1.1 从线程到协程 + +在聊GMP之前,我们得先搞明白两个老朋友:**线程(Thread)** 和 **协程(Coroutine)**。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/image-4.png) + +* **线程(Thread)**:这家伙是操作系统的大内总管——内核(Kernel)眼里的最小执行单元。它的生老病死、工作调度,全得听内核的号令。你可以把它想象成一个正式工,有编制,但每次调度(切换)都得走一套复杂的流程,成本比较高。 + +* **协程(Coroutine)**:这家伙更像是用户自己请的临时工。它活在用户态,比线程更轻量,可以理解为用户态线程。多个协程可以在一个线程上跑,它们的调度切换由用户程序自己说了算,不用去麻烦内核这个大忙人。所以,协程的切换开销极小,非常灵活。 + +简单总结一下:线程是内核级的,重而稳;协程是用户级的,轻而快。 + +### 1.2 Go的答案:goroutine + +Go语言选择的并发实现,就是我们所熟知的 **goroutine**。你可以把它看作是Go对协程的"超级魔改版"。它并不是一个孤立的概念,而是整个 **GMP调度体系** 的核心产物。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/image-3.png) + +正是因为有了GMP这套精妙的架构,goroutine才拥有了超越原生协程的两大核心优势: + +1. **灵活的调度**:G(goroutine)、M(Machine,内核线程)、P(Processor,处理器)三者之间可以动态地绑定和解绑,整个调度过程充满了弹性。 + +2. **动态的栈空间**:每个goroutine的栈空间可以根据需要自动伸缩,既方便使用,又极大地节约了内存资源。 + +更牛的是,Go语言在顶层完全屏蔽了线程这个概念,所有的并发操作都是围绕着goroutine来的,就像秦始皇统一了度量衡,Go也用goroutine统一了并发江湖的秩序。 + +### 1.3 GMP架构全景图 + +好了,主角登场!GMP,顾名思义,就是 **G**oroutine + **M**achine + **P**rocessor。 + +* **G(Goroutine)**:就是我们的"任务单元"。它有自己的执行栈、生命状态,以及要完成的具体工作(就是你 `go` 后面跟的那个函数)。G需要绑定到M上才能运行,你可以把M想象成G的CPU。 + +* **M(Machine)**:你可以把它看作Go对系统线程的封装,是真正干活的"工人"。M需要和P"绑定"后,才能进入GMP的调度循环。M的工作很简单,就是在`g0`(一个特殊的goroutine,负责调度)和普通的G之间反复横跳:执行`g0`时,它在找任务;执行普通G时,它在处理任务。 + +* **P(Processor)**:P是调度器,是GMP模型中的"中枢大脑"。M必须获取到一个P,才能开始调度和执行G。P的数量决定了同一时间最多有多少个M可以处于运行状态,这个数量通常由 `GOMAXPROCS` 环境变量决定。P还有一个非常重要的职责:它自带一个本地的goroutine队列,我们称之为 **LRQ (Local Run Queue)**。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/image-2.png) + +现在,我们把目光聚焦到存放G的"容器"上。Go的设计非常巧妙,它有两种队列: + +1. **P的本地队列(LRQ - Local Run Queue)**:这是每个P私有的G队列。当一个M想执行G时,会优先从自己绑定的P的LRQ里找。因为是私有的,所以大部分时间不需要加锁,通过高效的CAS(Compare-And-Swap)操作就能完成存取,大大减少了并发冲突。当然,也并非完全没有冲突,因为当一个P的LRQ空了的时候,它可能会从其他P的LRQ里"偷"一些G过来,这就是著名的 **work-stealing** 机制。 + +2. **全局队列(GRQ - Global Run Queue)**:这是一个全局共享的G队列。当一个P的LRQ满了,新创建的G就会被放到GRQ里。因为是全局共享的,所以所有M都可能来访问,竞争激烈,因此访问它需要加一把全局大锁。 + +**G的存放与获取逻辑**: + +* **放G(put g)**:当你在一个goroutine里通过 `go func(){...}` 创建一个新的goroutine时,它会优先被放到当前P的LRQ里。如果LRQ满了,没办法,只能加个全局锁,把它扔到GRQ里去。这遵循的是"就近原则"。 + +* **取G(get g)**:当M上的`g0`开始找活干时,它会遵循一个"负载均衡"的策略,按以下顺序来寻找G: + + 1. 先从当前P的LRQ里找(无锁,速度最快)。 + + 2. 如果LRQ没有,就去全局GRQ里看看(需要加锁)。 + + 3. 如果GRQ也没有,就去网络轮询器(netpoll)里找找有没有因为IO操作而就绪的G。 + + 4. 如果还是没有,就只能去"偷"了,从别的P的LRQ里偷一半过来(work-stealing,无锁)。 + +这里有个小细节:为了防止GRQ里的G被"饿死"(因为M上的`g0`总是优先从LRQ取),调度器规定,每进行61次调度循环,就必须强制去下一次去`grq`取 。这样做是为了避免`lrq`过于繁忙,而导致`grq`中的`g`"饿死"。 + +### 1.4 GMP生态圈 + +在Go的世界里,GMP是绝对的基石。所有上层的建筑,比如内存管理、并发工具等,都是围绕着GMP模型来精心设计的。 + +#### 1.4.1 内存管理 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/内存管理修改.png) + +Go的内存管理借鉴了Google自家的TCMalloc思想,并为GMP模型量身定做了优化。它为每个P都配备了一个私有的内存缓存——`mcache`。当一个P上的G需要分配小对象时,可以直接从这个私有的`mcache`里拿,完全无锁,速度飞快。 + +#### 1.4.2 并发工具(Mutex, Channel) + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/并发工具修改.png) + +你有没有想过,为什么在Go里一个channel的读写阻塞了,或者一个Mutex锁住了,并不会把整个线程都卡死? + +这就是因为Go的并发工具都是"G级别"的。当一个G因为这些操作需要阻塞时,它会被挂起,让出M的执行权。M会立刻去寻找并执行其他的G,整个过程都在用户态完成,无需内核介入。这极大地提升了并发性能。 + +我最近在用C++尝试模拟GMP时,就深有感触。C++标准库里的锁,一旦锁住,阻塞的是整个线程,这会导致线程上所有其他的协程都得干等着。想要实现Go这种效果,就得重写所有并发工具,成本巨大。这也反向证明了Go在并发设计上的优越性。 + +#### 1.4.3 IO多路复用(netpoll) + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/IO多路复用修改.png) + +对于网络IO,Go采用了Linux下性能强悍的epoll技术。但为了避免epoll的等待操作阻塞整个M,Go设计了一套巧妙的`netpoll`机制。它将IO阻塞操作转换成了G级别的阻塞(`gopark`),当IO就绪时,再通过`goready`唤醒对应的G。这样,IO操作也被完美地融入了GMP的调度体系中。 + +可以说,不理解GMP,就无法真正理解Go语言的精髓。 + +## 2. 深入源码:GMP的底层结构 + +理论说了一大堆,我们现在就潜入源码,看看G、M、P在 `runtime/runtime2.go` 文件里到底长什么样。 + +### 2.1 G的结构(goroutine) + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/g的结构修改.png) + +`g`结构体是goroutine的实体,我们来看看它的关键字段: + +* `stack`: 描述了goroutine的栈空间信息(起始和结束地址)。 + +* `stackguard0`: 栈的警戒线。当goroutine的栈使用量将要越过这条线时,就会触发栈扩容。同时,它也被用来标记"抢占请求"。 + +* `_panic`: 用来记录goroutine中发生的panic。 + +* `_defer`: 用链表的形式存储了goroutine中的defer操作(后进先出)。 + +* `m`: 指向当前正在执行它的M。如果G没在运行,这个字段就是`nil`。 + +* `atomicstatus`: G的生命周期状态,比如 `_Gidle`、`_Grunnable`、`_Grunning`、`_Gwaiting` 等。 + +```go +// g represents a goroutine. +type g struct { + // stack describes the goroutine's stack. The bounds are + // [stack.lo, stack.hi). + stack stack // goroutine的执行栈空间 + // stackguard0 is the stack pointer compared in the Go stack growth prologue. + // It is stack.lo + _StackGuard. + // It is also used to signal a request to preempt the goroutine. + stackguard0 uintptr // 栈空间保护区边界,也用于传递抢占标识 + + // ... + + _panic *_panic // 记录g执行过程中遇到的异常 + _defer *_defer // g中挂载的defer函数,是一个LIFO的链表结构 + m *m // 当前执行本g的m + + // atomicstatus is the status of the goroutine. + // It is changed atomically with casgstatus. + // This field is read and written atomically, and the values are not in the + // GStatus enum. + atomicstatus uint32 // g的状态 + + // ... + + // schedlink is a link in the global run queue, idle g list, or gfree list. + schedlink guintptr // 进入全局队列grq时指向相邻g的next指针 +} +``` + +### 2.2 M的结构(Machine) + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/m的结构修改.png) + +`m`结构体是内核线程的抽象,核心字段如下: + +* `g0`: 一个非常特殊的G。每个M都有一个自己的`g0`,这个`g0`不执行用户代码,它的任务就是执行调度逻辑,为M寻找下一个要运行的普通G。 + +* `gsignal`: 另一个特殊的G,专门用来处理分配给这个M的信号。 + +* `curg`: 指向当前M上正在运行的那个普通的用户G。 + +* `p`: 指向当前与M绑定的P。 + +```go +// m represents an OS thread. +type m struct { + g0 *g // 专门用于调度的g,每个M都有一个 + // ... + procid uint64 // M的唯一ID + gsignal *g // 用于处理信号的g + + curg *g // M上正在运行的普通g + p puintptr // M关联的p + + // ... + + schedlink muintptr // M在空闲链表中的下一个M +} +``` + +你可以把M的运行过程想象成两个状态的切换:当它在执行 `g0` 时,它在扮演"调度者"的角色;当它在执行 `curg` 时,它在扮演"执行者"的角色。 + +### 2.3 P的结构(Processor) + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/p的结构修改.png) + +`p`结构体是调度器,是连接G和M的桥梁,核心字段如下: + +* `status`: P的生命周期状态,如 `_Pidle`、`_Prunning` 等。 + +* `m`: 指向当前与它绑定的M。 + +* `runq`: P的私有G队列,也就是我们前面说的LRQ,它是一个定长的数组,可以存放256个G。 + +* `runqhead`, `runqtail`: LRQ的头尾索引,用来实现一个环形队列。 + +* `runnext`: LRQ里的一个"VIP通道"。通过 `runqput` 放入的下一个G会优先放在这里,调度器会首先检查 `runnext` 是否有G,有的话直接拿来执行,可以省去操作`runq`队列的开销。 + +```go +// p represents a processor. +type p struct { + id int32 // P的ID + status uint32 // P的状态 (pidle, prunning, etc.) + link puintptr + schedtick uint32 // 每执行一次schedule,该值+1 + syscalltick uint32 // 每进行一次系统调用,该值+1 + m muintptr // 回指到关联的M (如果idle则为nil) + + // Queue of runnable goroutines. Accessed without lock. + runqhead uint32 + runqtail uint32 + runq [256]guintptr // 本地G队列,即LRQ + // runnext, if non-nil, is a runnable G that was ready'd by + // the current G and should be run next instead of what's in + // runq. + runnext guintptr // 下一个要调度的G,可以看作是LRQ中的一个特权位置 + + // ... +} +``` + +### 2.4 全局调度器(schedt) + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/schedt修改.png) + +除了G、M、P这三大组件,还有一个全局的 `schedt` 结构体,它掌管着全局资源,访问它需要加锁。 + +* `lock`: 全局互斥锁。 + +* `midle`: 空闲的M队列,没活干的M会在这里排队。 + +* `pidle`: 空闲的P队列,没活干的P也在这里排队。 + +* `runq`: 全局G队列,也就是GRQ。 + +* `runqsize`: GRQ里G的数量。 + +```go +// 全局调度模块 +type schedt struct{ + // ... + // 互斥锁 + lock mutex + + // 空闲 m 队列 + midle muintptr // idle m's waiting for work + // ... + // 空闲 p 队列 + pidle puintptr // idle p's + // ... + + // 全局 g 队列——grq + runq gQueue + // grq 中存量 g 的个数 + runqsize int32 + // ... +} +``` + +> `midle`和 `pidle`\`的设计是为了资源的复用和节能。当系统不忙时,空闲的M和P会被放进这两个队列里"休眠",避免CPU空转,等到有新任务时再被唤醒。 + +## 3. 正向追踪:一个G的诞生与调度 + +好了,基础结构我们都看完了。现在,让我们切换到第一人称视角,看看一个我们用 `go func(){...}` 创建的goroutine,是如何一步步被调度并执行的。这个过程,可以看作是从 `g0`到 `g`的转换。 + +### 3.1 main函数的特殊性 + +`main`函数是所有Go程序的入口,它比较特殊。它是由一个全局唯一的 m0(主线程)来执行的。源码位于 runtime.proc.go + +```go +// The main goroutine. +func main() { + // ... + // 获取用户定义的 main.main 函数 + fn := main_main + // 执行用户的 main 函数 + fn() + // ... +} +``` + +### 3.2 普通G的创建之旅 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/g的创建修改.png) + +除了`main`这个特例,我们自己启动的goroutine都会经历一个标准的创建流程。比如这段代码: + +```go +func handle() { + // 异步启动一个goroutine + go func() { + // do something ... + }() +} +``` + +编译器会把 `go func()` 转换成对 `runtime.newproc` 函数的调用。这个函数的核心逻辑如下(我们跟着代码走一遍): + +1. **切换到**`g0`**栈**:`newproc`会先通过`systemstack`把自己从当前的用户G栈切换到M的`g0`调度栈上。因为创建G是调度层面的工作,得由专业的`g0`来干。 + +2. **创建G实例**:在`g0`栈上,调用`newproc1`来创建一个新的`g`结构体实例,并做好初始化工作,比如设置好要执行的函数入口地址、程序计数器等。 + +3. **放入就绪队列**:新创建的G需要被放到一个就绪队列里,等待被调度。这里会调用`runqput`函数。 + +4. `runqput`**的逻辑**: + + * 它会优先尝试把新的G放到当前P的`runnext`这个VIP位置。 + + * 如果`runnext`被占了,它会尝试把G放到当前P的LRQ的队尾。 + + * 如果LRQ也满了,那没办法,只能加个全局锁,把这个G和LRQ里的一半G都转移到全局队列GRQ里去(这个操作叫`runqputslow`)。 + +5. **唤醒休眠的P**:如果此时有P因为没事干而处于休眠状态,`wakep`函数会负责唤醒一个P来处理这个新任务。 + +6. **切回用户G栈**:`systemstack`执行完毕,切回到原来的用户G,继续执行它自己的代码。 + +```go +// 创建一个新的g,并将其投递到就绪队列中。fn是用户指定的函数。 +// 当前的执行者还是某个普通的g。 +func newproc(fn *funcval) { + // 获取当前正在执行的普通g和程序计数器 + gp := getg() + pc := getcallerpc() + + // systemstack会临时切换到g0栈,执行完闭包函数后,再切回原来的普通g + systemstack(func() { + // 此时执行方为g0 + // 构造一个新的g实例 + newg := newproc1(fn, gp, pc) + + // 获取当前P + _p_ := getg().m.p.ptr() + + // 将newg添加到队列中: + // 1) 优先添加到P的本地队列LRQ + // 2) 如果LRQ满了,则添加到全局队列GRQ + runqput(_p_, newg, true) + + // 如果有因为空闲而被阻塞的P和M,需要唤醒它们 + if mainStarted { + wakep() + } + }) + // 切回到原来的普通g继续执行 +} +``` + +### 3.3 从 `g0` 到 `g` 的切换 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/g0与g修改.png) + +每个M都有一个自己的`g0`,`g0`的工作就是不断地调用`schedule`函数来寻找可执行的G。所以,一个M的生命周期,就是在执行`g0`(找任务)和执行普通`g`(做任务)之间循环往复。 + +这个切换过程有两个关键的"桩函数": + +* `mcall`, `systemstack`: 实现从 `g` 切换到 `g0`。 + +* `gogo`: 实现从 `g0` 切换到 `g`。 + +我们从`g0`的视角来看,它主要做两件事: + +1. `schedule()`: 调用`findrunnable()`方法,从各个队列里找到一个可执行的G。 + +2. `execute()`: 找到G之后,更新上下文信息(比如把`m.curg`指向找到的G),然后调用`gogo`,把M的CPU执行权从`g0`交到这个G手上。 + +上述方法均实现于 runtime/proc.go 文件中: + +```go +// 执行方为g0 +func schedule() { + _g_ := getg() // 获取当前g0 + +top: + pp := _g_.m.p.ptr() // 获取当前P + + // ... + + // 核心方法: 获取一个可调度的g + // - 按照优先级,依次从本地队列LRQ、全局队列GRQ、netpoll、其他P的LRQ中寻找 + // - 如果都找不到,就把P和M都休眠掉 + gp, inheritTime, tryWakeP := findRunnable() // 这个函数会阻塞直到找到任务 + + // ... + + // 执行g,这个方法会把执行权从g0切换到gp + execute(gp, inheritTime) +} + +// 执行指定的g。当前执行方还是g0,但会通过gogo方法切换到gp +func execute(gp *g, inheritTime bool) { + _g_ := getg() // 获取g0 + + // 建立g0和gp的关系 + _g_.m.curg = gp + gp.m = _g_.m + + // 更新gp的状态:runnable -> running + casgstatus(gp, _Grunnable, _Grunning) + + // 设置gp的栈保护区边界 + gp.stackguard0 = gp.stack.lo + _StackGuard + + // 执行gogo方法,M的执行权会切换到gp + gogo(&gp.sched) +} +``` + +### 3.4 寻找G的漫漫长路:`findrunnable` + +`findrunnable`是调度循环中最核心的函数,它寻找G的策略体现了Go调度器的智慧。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/寻找g修改.png) + +我们来梳理一下它的寻找步骤: + +1. **检查全局队列(GRQ)**:还记得吗?每61次调度循环,必须先从全局队列`globrunqget`拿一个G,防止GRQ饥饿。(需要加锁) + +2. **检查本地队列(LRQ)**:从当前P的LRQ里`runqget`一个G。(无锁CAS) + +3. **再次检查全局队列(GRQ)**:如果本地没有,再去全局队列里`globrunqget`找。(需要加锁) + +4. **检查网络轮询器(netpoll)**:如果还没有,就去`netpoll`里看看有没有因为网络IO就绪的G。(非阻塞模式) + +5. **从别的P偷(steal work)**:如果还找不到,就只能启动`stealwork`机制,随机找一个别的P,从它的LRQ里偷一半的G过来。 + +6. **再次double check全局队列**:偷完之后,再最后看一眼全局队列。 + +7. **进入休眠**:如果以上所有努力都失败了,说明系统现在真的很闲,`findrunnable`会: + + * 把当前的P设置为`_Pidle`状态,并把它放到全局的`pidle`队列里。 + + * 在把当前M也休眠之前,会做最后一次挣扎:以**阻塞模式**调用`netpoll`,看看能不能等到一个IO事件。 + + * 如果连阻塞等待IO都没用,那就彻底死心了,把当前M也放到全局的`midle`队列里,然后调用`stopm`让M休眠,交出线程控制权。 + +这个过程设计得非常精妙,既保证了任务获取的高效性(优先无锁操作),又实现了负载均衡(work-stealing),还能在系统空闲时自动缩容,节省资源。 + +### 3.5 findRunnable函数详解 + +Go调度器的核心在于`findRunnable`函数,这个函数负责为当前的处理器P找到一个可执行的goroutine。整个过程遵循着严格的优先级顺序,确保系统的公平性和效率。 + +```go +// 寻找可执行的goroutine,返回时必定已经找到目标g +func findRunnable()(gp *g, inheritTime, tryWakeP bool){ + // 获取当前P下的g0(调度协程) + _g_ := getg() + // ... +top: + // 获取当前的处理器P + _p_ := _g_.m.p.ptr() + // ... + + // 防饥饿机制:每61次调度检查一次全局队列 + if _p_.schedtick%61==0 && sched.runqsize > 0{ + lock(&sched.lock) + gp = globrunqget(_p_, 1) + unlock(&sched.lock) + if gp != nil{ + return gp, false, false + } + } + // ... + + // 第一优先级:从本地运行队列获取goroutine + if gp, inheritTime := runqget(_p_); gp != nil{ + return gp, inheritTime, false + } + + // 第二优先级:从全局队列获取goroutine + if sched.runqsize != 0{ + lock(&sched.lock) + gp := globrunqget(_p_, 0) + unlock(&sched.lock) + if gp != nil{ + return gp, false, false + } + } + + // 第三优先级:处理网络I/O就绪的goroutine + if netpollinited() && atomic.Load(&netpollWaiters) > 0 && + atomic.Load64(&sched.lastpoll) != 0{ + if list := netpoll(0); !list.empty(){ // 非阻塞调用 + gp := list.pop() + injectglist(&list) + casgstatus(gp, _Gwaiting, _Grunnable) + // ... + return gp, false, false + } + } + // ... + + // 第四优先级:从其他P的本地队列偷取goroutine + gp, inheritTime, tnow, w, newWork := stealWork(now) + if gp != nil{ + return gp, inheritTime, false + } + + // 若有GC标记任务,参与协作而非直接回收P + // ... + + // 最后检查:再次确认全局队列 + lock(&sched.lock) + // ... + if sched.runqsize != 0{ + gp := globrunqget(_p_, 0) + unlock(&sched.lock) + return gp, false, false + } + // ... + + // 无事可做时:解绑P和M,将P放入空闲队列 + releasep() + now = pidleput(_p_, now) + unlock(&sched.lock) + // ... + + // 网络轮询保障机制:确保有M专门处理I/O事件 + if netpollinited() && (atomic.Load(&netpollWaiters) > 0 || pollUntil != 0) && + atomic.Xchg64(&sched.lastpoll, 0) != 0{ + atomic.Store64(&sched.pollUntil, uint64(pollUntil)) + // ... + + // 阻塞模式执行网络轮询 + delay := int64(-1) + // ... + list := netpoll(delay) // 阻塞直到有新任务 + + // 恢复轮询标识 + atomic.Store64(&sched.lastpoll, uint64(now)) + // ... + + lock(&sched.lock) + // 尝试获取空闲的P + _p_, _ = pidleget(now) + unlock(&sched.lock) + + // 如果没有可用的P,将就绪的goroutine放入全局队列 + if _p_ == nil{ + injectglist(&list) + } else { + // 重新绑定P和M + acquirep(_p_) + // 取第一个goroutine用于调度,其余放入全局队列 + if !list.empty(){ + gp := list.pop() + injectglist(&list) + casgstatus(gp, _Gwaiting, _Grunnable) + // ... + return gp, false, false + } + // ... + goto top + } + } + // ... + + // 最终手段:阻塞当前M,加入空闲队列 + stopm() + goto top +} +``` + +#### 3.5.1 本地队列获取策略 + +从本地队列获取goroutine是最高效的方式,因为不需要加锁。`runqget`函数采用了巧妙的双重策略: + +```go +// 无锁方式从P的本地队列获取goroutine +func runqget(_p_ *p)(gp *g, inheritTime bool){ + // 优先获取runnext位置的goroutine(高优先级位置) + next := _p_.runnext + if next != 0 && _p_.runnext.cas(next, 0){ + return next.ptr(), true + } + + // 从队列头部获取普通goroutine + for{ + // 原子操作获取头部索引 + h := atomic.LoadAcq(&_p_.runqhead) // load-acquire语义,与其他消费者同步 + // 获取尾部索引 + t := _p_.runqtail + + // 队列为空的情况 + if t == h { + return nil, false + } + + // 根据索引取出对应的goroutine + gp := _p_.runq[h%uint32(len(_p_.runq))].ptr() + + // CAS操作更新头部索引 + if atomic.CasRel(&_p_.runqhead, h, h+1){ // cas-release语义,提交消费操作 + return gp, false + } + } +} +``` + +这里有个有趣的设计:`runnext`是一个特殊位置,专门存放高优先级的goroutine,比如刚刚创建的新goroutine。这样设计可以提高响应性。 + +#### 3.5.2 全局队列的公平调度 + +当本地队列为空时,调度器会转向全局队列。但这里有个重要的防饥饿机制: + +```go +// 从全局队列获取goroutine,调用前必须持有全局锁 +func globrunqget(_p_ *p, max int32)*g { + // 确保持有锁的断言检查 + assertLockHeld(&sched.lock) + + // 队列空检查 + if sched.runqsize == 0{ + return nil + } + // ... + + // 根据max参数可能会批量转移goroutine到本地队列 + // 这里简化显示核心逻辑 + // ... + + // 从全局队列头部弹出一个goroutine + gp := sched.runq.pop() + // ... + return gp +} +``` + +#### 3.5.3 网络I/O事件处理机制 + +在 gmp 调度流程中,如果 lrq 和 grq 都为空,则会执行 netpoll 流程,尝试以非阻塞模式下的 epoll\_wait 操作获取 io 就绪的 g。该方法位于 runtime/netpoll\_epoll.go: + +```go +func netpoll(delay int64) gList { + // ... + // 调用系统的epoll_wait获取就绪事件 + var events [128]epollevent + n := epollwait(epfd, &events[0], int32(len(events)), waitms) + // ... + + var toRun gList + for i := int32(0); i < n; i++{ + ev := &events[i] + // 将就绪事件对应的goroutine加入待运行列表 + netpollready(...) + } + return toRun +} +``` + +这个机制让Go程序能够高效处理大量并发连接,而不需要为每个连接分配单独的线程。 + +#### 3.5.4 从其他的P队列窃取g + +当本地队列和全局队列都为空时,并且执行完 netpoll 流程后仍未获得 g,则会尝试从其他 p 的 lrq 中窃取半数 g 补充到当前 p 的 lrq 中。工作窃取算法是负载均衡的关键,它确保了系统中的处理器都能保持忙碌状态。 + +```go +func stealWork(now int64) (gp *g, inheritTime bool, rnow, pollUntil int64, newWork bool){ + // 获取当前P + pp := getg().m.p.ptr() + // ... + + // 最多尝试4轮窃取 + const stealTries = 4 + for i := 0; i < stealTries; i++{ + // ... + + // 随机选择窃取目标,避免热点竞争 + for enum := stealOrder.start(fastrand()); !enum.done(); enum.next(){ + // ... + + // 获取目标P + p2 := allp[enum.position()] + + // 不能从自己这里偷 + if pp == p2 { + continue + } + // ... + + // 只要目标P不是空闲状态就尝试窃取 + if !idlepMask.read(enum.position()){ + // 窃取目标P本地队列中的一半goroutine + if gp := runqsteal(pp, p2, stealTimersOrRunNextG); gp != nil{ + return gp, false, now, pollUntil, ranTimer + } + } + } + } + + // 窃取失败 + return nil, false, now, pollUntil, ranTimer +} +``` + +#### 3.5.5 回收空闲p和m + +再执行完上述逻辑之后,如果还是未能获取到可运行的g,系统需要妥善处理空闲的P和M,此时会将 p 和 m 添加到 schedt 的 pidle 和 midle 队列中并停止 m 的运行,避免产生资源浪费 + +```go +// 将P加入空闲队列 +func pidleput(_p_ *p, now int64)int64{ + assertLockHeld(&sched.lock) + // ... + + // 将P插入空闲队列头部 + _p_.link = sched.pidle + sched.pidle.set(_p_) + atomic.Xadd(&sched.npidle, 1) + // ... +} + +// 停止当前M的运行 +func stopm(){ + _g_ := getg() + // ... + + lock(&sched.lock) + // 将M加入空闲队列 + mput(_g_.m) + unlock(&sched.lock) + + // 让M进入休眠状态 + mPark() + // ... +} +``` + +## 4. 逆向追踪:G的让渡艺术 + +有借有还,再借不难。G拿到了M的执行权,也得在适当的时候还回去。这个"还"的过程,我们称之为**让渡(yield)**。让渡是一个主动的行为,由G自己发起,目的是把执行权交还给`g0`,让`g0`可以去调度其他的G。这是一个从 `g` 到 `g0` 的转换。 + +### 4.1 功成身退:执行结束 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/image.png) + +当一个G的任务执行完毕,它会调用`goexit1`,这是一个主动的"退休"申请。 + +1. 在`goexit1`里,它会调用`mcall(goexit0)`,这个`mcall`指令会把执行权从当前的G切换到M的`g0`上,并让`g0`去执行`goexit0`函数。 + +2. `goexit0`函数(此时由`g0`执行)会负责给这个退休的G办"后事": + + * 把G的状态从`_Grunning`更新为`_Gdead`。 + + * 清理G内部的数据。 + + * 解除G和M的绑定关系(`dropg`)。 + + * 把这个G的结构体放到P的`gfree`队列里,方便下次创建新G时复用,避免了内存的反复申请和释放。 + + * 最后,调用`schedule()`,开始新一轮的调度。 + +```go +// goroutine运行结束,此时执行方是普通g +func goexit1() { + // 通过mcall,将执行方转为g0,调用goexit0方法 + mcall(goexit0) +} + +// 此时执行方为g0,入参gp为已经运行结束的g +func goexit0(gp *g) { + _g_ := getg() // 获取g0 + _p_ := _g_.m.p.ptr() + + // 将gp的状态由running更新为dead + casgstatus(gp, _Grunning, _Gdead) + // ... 清理工作 ... + + // 将g和p解除关系 + dropg() + + // 将g添加到p的gfree队列中以供复用 + gfput(_p_, gp) + + // 发起新一轮调度流程 + schedule() +} +``` + +### 4.2 高风亮节:主动让渡 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/image-1.png) + +我们可以通过在代码里调用 `runtime.Gosched()` 来手动让一个G让出CPU。这个函数会做和`goexit1`类似的事情: + +1. 调用`mcall(gosched_m)`,把执行权从当前G切换到`g0`。 + +2. `g0`执行`gosched_m`函数,它的逻辑是: + + * 把G的状态从`_Grunning`改回`_Grunnable`。 + + * 解除G和M的绑定。 + + * 把这个G直接扔到**全局队列GRQ**中,等待下一次被调度。 + + * 调用`schedule()`,开始新一轮调度。 + +```go +// 主动让渡出执行权,此时执行方还是普通g +func Gosched() { + // 通过mcall,将执行方转为g0,调用gosched_m方法 + mcall(gosched_m) +} + +// 此时执行方为g0 +func gosched_m(gp *g) { + // ... + goschedImpl(gp) +} + +func goschedImpl(gp *g) { + // 将g状态由running改为runnable就绪态 + casgstatus(gp, _Grunning, _Grunnable) + // 解除g和m的关系 + dropg() + // 将g添加到全局队列grq + lock(&sched.lock) + globrunqput(gp) + unlock(&sched.lock) + // 发起新一轮调度 + schedule() +} +``` + +### 4.3 情非得已:阻塞让渡 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/阻塞让渡修改.png) + +这是最常见的一种让渡方式。当G执行到需要等待某个外部条件的地方(比如读一个空的channel,或者等待一个锁),它就会被阻塞。 + +这个过程的核心是`gopark`函数: + +1. 当G需要阻塞时,上层函数(比如channel的读写逻辑)会调用`gopark`。 + +2. `gopark`同样会调用`mcall(park_m)`,把执行权交给`g0`。 + +3. `g0`执行`park_m`,它会: + + * 把G的状态从`_Grunning`改为`_Gwaiting`。 + + * 解除G和M的绑定。 + + * **注意**:`_Gwaiting`状态的G不会被放到任何就绪队列里!它会被上层调用者(比如channel)自己保管。 + + * `g0`调用`schedule()`,寻找下一个G来执行。 + +当外部条件满足时(比如channel里有了数据),另一个G会调用`goready`函数来唤醒这个处于`_Gwaiting`状态的G。 + +`goready`会: + +1. 把目标G的状态从`_Gwaiting`改回`_Grunnable`。 + +2. 调用`runqput`,把这个G重新放回到就绪队列(LRQ或GRQ)中。 + +3. 调用`wakep`,尝试唤醒一个空闲的P来处理这个刚被唤醒的G。 + +这一`park`一`ready`,完美地实现了G级别的阻塞和唤醒,整个过程高效且对用户透明。 + +以下是具体的代码分析: + +```go +// 此时执行方为普通 g +func gopark(unlockf func(*g, unsafe.Pointer)bool,lockunsafe.Pointer, reason waitReason, traceEv byte, traceskip int){ + // 获取 m 正在执行的 g,也就是要阻塞让渡的 g + gp := mp.curg + // ... + // 通过 mcall,将执行方由普通 g -> g0 + mcall(park_m) +} + +// 此时执行方为 g0. 入参 gp 为需要执行 park 的普通 g +func park_m(gp *g){ + // 获取 g0 + _g_ := getg() + + // 将 gp 状态由 running 变更为 waiting + casgstatus(gp,_Grunning,_Gwaiting) + // 解绑 g 与 m 的关系 + dropg() + + // g0 发起新一轮调度流程 + schedule() +} +``` + +与 gopark 相对的,是用于唤醒 g 的 goready 方法,其中会通过 systemstack 压栈切换至 g0 执行 ready 方法——将目标 g 状态由 waiting 改为 runnable,然后添加到就绪队列中. + +```go +// 此时执行方为普通 g. 入参 gp 为需要唤醒的另一个普通 g +func goready(gp *g, traceskip int) { + // 调用 systemstack 后,会切换至 g0 亚展调用传入的 ready 方法. 调用结束后则会直接切换回到当前普通 g 继续执行. + systemstack(func() { + ready(gp, traceskip, true) + }) + + // 恢复成普通 g 继续执行 ... +} +``` + +```go +// 此时执行方为 g0. 入参 gp 为拟唤醒的普通 g +func ready(gp *g, traceskip int, next bool){ + // ... + + // 获取当前 g0 + _g_ := getg() + // ... + // 将目标 g 状态由 waiting 更新为 runnable + casgstatus(gp,_Gwaiting,_Grunnable) + /* + 1) 优先将目标 g 添加到当前 p 的本地队列 lrq + 2)若 lrq 满了,则将 g 追加到全局队列 grq + */ + runqput(_g_.m.p.ptr(), gp,next) + // 如果有 m 或 p 处于 idle 状态,将其唤醒 + wakep() + // ... +} +``` + +## 5. 第三方视角:抢占式调度 + +前面说的"让渡"都是G的主动行为。但如果一个G是个"老赖",执行一个超长的计算任务,一直不主动让出CPU怎么办?难道要让整个系统都等它一个吗? + +当然不行!Go调度器还有一个"霸道总裁"的角色来强制干预,就是**抢占(Preemption)**。一个由外部力量发起的、为了维护整个系统公平和效率的"强制让位"过程。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/抢占设计修改.png) + +### 5.1 幕后英雄:无处不在的sysmon + +在我们的Go程序启动时,除了我们熟知的主线程外,runtime还会悄悄启动一个非常关键的后台线程——`sysmon`(System Monitor,系统监控)。 + +你可以把它想象成一个永不休息的"巡逻兵",它独立于普通的G-P-M调度模型,持续地在后台循环执行。这个线程在整个程序生命周期里是全局唯一的,就像一个大管家,不知疲倦地监视着整个Go程序的运行状态。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/监控线程修改.png) + +`sysmon` 的工作是一个永不停歇的循环,它主要关心三件大事儿: + +* **网络轮询(netpoll)**:检查有没有已经完成IO操作的网络连接,唤醒那些等待IO的Goroutine。 + +* **抢占(retake)**:找出那些运行时间太长的Goroutine,毫不留情地把它"踹"下CPU。 + +* **GC触发检查**:看看是不是时候该进行垃圾回收(GC)了。 + +这个 `sysmon` 线程是在哪里创建的呢?答案就在 `main` 函数启动的深处。Go运行时会通过 `newm` 创建一个新的系统线程(M)专门来跑 `sysmon` 这个函数。 + +它的核心工作逻辑大致如下: + +```go +// The main goroutine. +// main goroutine的入口 +func main(){ + systemstack(func() { + // 创建一个新的M(系统线程)来执行sysmon函数 + // 这个M不关联任何P,是一个专门用于系统监控的线程 + newm(sysmon, nil, -1) + }) + // ... +} + +// sysmon是系统监控函数,它在一个独立的M上无限循环运行 +func sysmon() { + //.. + for { + // 根据程序的繁忙程度,动态调整休眠时间 + // 如果程序比较空闲,会休眠长一点,最长10毫秒 + usleep(delay) + // ... + + // 记录上次网络轮询的时间 + lastpoll := int64(atomic.Load64(&sched.lastpoll)) + // 如果网络轮询器已初始化,并且距离上次轮询超过10ms + if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now { + //... + // 执行非阻塞的网络轮询,返回一个就绪的goroutine列表 + list := netpoll(0) + // ... + } + + // 执行抢占工作,这是我们的重点 + retake(now) + //... + + // 检查是否需要触发GC + if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && atomic.Load(&forcegc.idle) != 0 { + // ... + } + // ... + } +} +``` + +可以看到,`sysmon` 的核心就是一个 `for` 死循环,每次循环都会执行一遍它的"三板斧"。而我们的抢占逻辑,就藏在 `retake` 这个函数里。`retake` 会根据Goroutine的不同状态,采取不同的抢占策略,主要分为两种:**系统调用抢占**和**运行超时抢占**。 + +### 5.2 系统调用抢占 + +我们知道,系统调用(syscall)是连接用户态程序和操作系统内核的桥梁。但当一个M(系统线程)陷入系统调用时,它就会被操作系统挂起,暂时无法执行任何用户态代码。这对Go的调度器来说是个大问题,因为如果M上还绑定着一个P(处理器),那这个P也就跟着被闲置了,它所管理的本地Goroutine队列就得不到执行,造成了资源浪费。 + +Go的策略非常聪明:**人走可以,但办公桌得留下!** + +当一个Goroutine即将发起系统调用时,调度器会做几件事: + +1. **解除P与M的绑定**:把当前线程M和处理器P分离开。 + +2. **状态更新**:把Goroutine和P的状态都更新为 `_Gsyscall` 和 `_Psyscall`。 + +3. **保留弱联系**:虽然P和M分开了,但M会记住这个P(存放在`m.oldp`),方便回来的时候能"再续前缘"。 + +4. **寻找新机会**:脱离了M的P,可以去和其他空闲的M结合,继续执行其他Goroutine,一点都不耽误事儿。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/系统调用修改.png) + +这个过程主要发生在 `reentersyscall` 函数中: + +```go +// reentersyscall 在goroutine进入系统调用时被调用 +func reentersyscall(pc, sp uintptr) { + _g_ := getg() // 获取当前的goroutine + + // ... + // 保存当前的程序计数器(PC)和栈指针(SP)等上下文信息 + save(pc, sp) + // ... + + // 1. 将goroutine的状态从 _Grunning 更新为 _Gsyscall + casgstatus(_g_, _Grunning, _Gsyscall) + + // ... + // 2. 解除 P 和 M 的绑定关系 + pp := _g_.m.p.ptr() + pp.m = 0 // P的m指针置空 + _g_.m.p = 0 // M的p指针置空 + + // 3. 将P设置为M的oldp,建立一个弱引用关系 + _g_.m.oldp.set(pp) + + // 4. 将P的状态更新为 _Psyscall + atomic.Store(&pp.status, _Psyscall) + + // ... +} +``` + +等系统调用结束,Goroutine从内核态返回时,会执行 `exitsyscall` 函数。这时它会尝试"复位归来": + +* **快速路径**:先看看之前那个P(`oldp`)是不是还单身(没有和其他M结合)。如果是,太好了,直接拿回来用,光速恢复执行。 + +* **慢速路径**:如果P已经被别的M"拐走"了,那就没办法了。当前Goroutine会被切换到`g0`栈,执行`exitsyscall0`,尝试为自己所在的M寻找一个新的空闲P。如果找到了,就继续执行;如果找不到,说明现在很忙,M就会被挂起,这个Goroutine则被放到全局队列中,等待下一次被调度 + +```go +// exitsyscall 在goroutine退出系统调用时执行 +func exitsyscall() { + _g_ := getg() // 获取当前goroutine + + // ... + // 尝试快速路径:如果oldp没有被其他M绑定,就直接复用 + oldp := _g_.m.oldp.ptr() + _g_.m.oldp = 0 + if exitsyscallfast(oldp) { + // ... + // 快速恢复成功,将g的状态改回_Grunning + casgstatus(_g_, _Gsyscall, _Grunning) + // ... + return // 直接返回,继续执行g + } + + // 快速路径失败,切换到g0栈,执行慢速路径逻辑 + mcall(exitsyscall0) + // ... +} + +// exitsyscall0 在g0栈上为当前M寻找一个新的P +func exitsyscall0(gp *g) { + // 将goroutine的状态从 _Gsyscall 改为 _Grunnable 就绪态 + casgstatus(gp, _Gsyscall, _Grunnable) + // 解除g和当前M的绑定 + dropg() + lock(&sched.lock) + + // 尝试从空闲列表获取一个P + var _p_ *p + _p_, _ = pidleget(0) + // ... + + // 如果没有找到空闲的P + if _p_ == nil { + // 将g放入全局运行队列 + globrunqput(gp) + // ... + } + // ... + unlock(&sched.lock) + + // 如果找到了P + if _p_ != nil { + // 绑定P,然后立即执行这个goroutine + acquirep(_p_) + execute(gp, false) // 不会返回 + } + + // 如果没找到P,M只能进入休眠 + stopm() + // 当M被唤醒后,重新开始调度循环 + schedule() // 不会返回 +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/retake方法修改.png) + +你可能会问,这和 `sysmon` 有什么关系?关系大了!`sysmon` 会在它的 `retake` 检查中,遍历所有的P。如果发现某个P长时间处于 `_Psyscall` 状态(默认超过10ms),或者这个P虽然在syscall,但它的本地队列里还有其他Goroutine在排队,`sysmon` 就会认为不能再等了,必须执行抢占。 它会调用 `handoffp`,强制把这个P从syscall的M那里"抢"过来,分配给一个新的或者空闲的M,去执行P本地队列里的其他任务。 + +```go +// retake 函数由 sysmon 线程周期性调用 +func retake(now int64) uint32{ + n :=0 + // 加锁 + lock(&allpLock) + // 遍历所有 p + for i :=0; i 0&& pd.syscallwhen+10*1000*1000> now { + continue + } + unlock(&allpLock) + // 将 p 的状态由 syscall 更新为 idle + if atomic.Cas(&_p_.status, s,_Pidle){ + // ... + // 让 p 拥有和其他 m 结合的机会 + handoffp(_p_) + } + // ... + lock(&allpLock) + } + } + unlock(&allpLock) + return uint32(n) +} +``` + +```javascript +func handoffp(_p_ *p) { + // 如果 p lrq 中还有 g 或者全局队列 grq 中还有 g,则立即分配一个新 m 与该 p 结合 + if!runqempty(_p_)|| sched.runqsize !=0{ + // 分配一个 m 与 p 结合 + startm(_p_,false) + return + } + // ... + // 若系统空闲没有 g 需要调度,则将 p 添加到 schedt 中的空闲 p 队列 pidle 中 + pidleput(_p_,0) + // ... +} +``` + +### 5.3 运行超时抢占 + +除了系统调用,另一种需要抢占的场景就是Goroutine运行时间过长。比如一个纯计算的循环,没有任何IO或channel操作,它就会像个"钉子户"一样霸占着CPU。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/运行超时修改.png) + +`sysmon` 在 `retake` 函数中同样会检查每个处于 `_Prunning` 状态的P。它会看当前P上的Goroutine从何时开始执行(`schedwhen`),如果执行时间超过了一个阈值(`forcePreemptNS`,通常是10ms),`sysmon` 就会认为需要抢占了。 + +```go +// retake 函数的一部分 +func retake(now int64) uint32 { + // ... + for i := 0; i < len(allp); i++ { + _p_ := allp[i] + // ... + // 如果P正在运行 + if s == _Prunning { + // ... + // 检查当前goroutine的执行时间是否超过了10ms + if _p_.schedwhen+forcePreemptNS <= now { + // 发起抢占 + preemptone(_p_) + } + } + } + // ... +} +``` + +这里的抢占又分为两种方式:一种是"好言相劝",一种是"强行执法"。 + +#### 5.3.1 协作式抢占 + +这是Go早期版本就有的抢占方式,比较"温柔"。`sysmon` 在决定抢占后,会调用 `preemptone` 函数。这个函数首先会给目标Goroutine打上一个"抢占标记"。具体来说,就是把 `gp.preempt` 设置为 `true`,同时把 `gp.stackguard0` 设置为一个特殊值 `stackPreempt`。 + +```go +// preemptone 抢占指定P上正在运行的g +func preemptone(_p_ *p) bool { + // 获取P上绑定的M + mp := _p_.m.ptr() + // 获取M上正在运行的g,也就是我们的抢占目标 + gp := mp.curg + + // ... + // 1. 设置协作式抢占标志 + gp.preempt = true + + // 2. 修改栈保护标志,这是协作式抢占的关键 + // 当g进行函数调用(特别是涉及栈检查)时,会检查这个值 + gp.stackguard0 = stackPreempt + + // ... +} +``` + +这个 `stackguard0` 标志位非常关键。Goroutine在执行函数调用时,尤其是可能导致栈扩容的场景下,会检查这个标志位。当它发现 `stackguard0` 变成了 `stackPreempt`,就知道:"哦,调度器想让我让位了"。于是,它就会很"自觉"地停止当前工作,调用 `gopreempt_m`,将自己重新放回全局队列,让出CPU。这个过程就叫做**协作式抢占**。 + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/协作式抢占修改.png) + +这个检查点通常在 `newstack` 函数中,也就是栈扩容的逻辑里: + +```go +// newstack 在g0栈上为goroutine扩展栈空间时执行 +func newstack() { + // 获取当前需要扩容栈的goroutine + gp := thisg.m.curg + + // 读取g的栈保护标志 + stackguard0 := atomic.Loaduintptr(&gp.stackguard0) + + // 如果标志被设置为stackPreempt,说明被标记为需要抢占 + if stackguard0 == stackPreempt { + // 检查当前g是否满足被抢占的条件(比如没有持有锁等) + if canPreemptM(thisg.m) { + // 条件满足,响应抢占,执行让渡操作 + gopreempt_m(gp) // 这个函数不会返回 + } + } + // ... +} + +// gopreempt_m 会走到 goschedImpl,后续流程和主动让渡(gosched)一样 +func gopreempt_m(gp *g) { + // ... + goschedImpl(gp) +} + +func goschedImpl(gp *g) { + // g状态从_Grunning变为_Grunnable + casgstatus(gp, _Grunning, _Grunnable) + // 解绑M + dropg() + // 加锁后放入全局队列 + lock(&sched.lock) + globrunqput(gp) + unlock(&sched.lock) + // 触发新一轮调度 + schedule() +} +``` + +但协作式抢占有个明显的缺点:如果一个Goroutine是个铁憨憨,一直在执行纯计算的死循环,没有任何函数调用,那它就永远没有机会去检查 `stackguard0`,也就无法响应抢占意图。这可怎么办? + +#### 5.3.2 非协作式抢占 + +为了解决协作式抢占的短板,Go 1.14 版本引入了基于信号的抢占机制,也就是**非协作式抢占**。这种方式就非常"硬核"了。 + +在 `preemptone` 函数中,除了设置协作标记,还会做一件事:向目标Goroutine所在的M(线程)发送一个信号 `sigPreempt`。 + +```go +// preemptone 函数的另一部分 +func preemptone(_p_ *p) bool { + // ... (前面设置协作标记的代码) + + // 3. 基于信号实现非协作式抢占 + if preemptMSupported && debug.asyncpreemptoff == 0 { + _p_.preempt = true + // 向目标M发送抢占信号 + preemptM(mp) + } + return true +} + +func preemptM(mp *m) { + // ... + // 向指定的线程(由mp.procid标识)发送sigPreempt信号 + signalM(mp, sigPreempt) + // ... +} + +func signalM(mp *m, sig int) { + // 底层通过pthread_kill实现向线程发送信号 + pthread_kill(pthread(mp.procid), uint32(sig)) +} +``` + +Go程序启动时,会注册一个信号处理器 `sighandler` 来处理各种信号,其中就包括了我们的 `sigPreempt` + +![](https://golangstar.cn/assets/img/go语言系列/gmp调度/非协作式抢占修改.png) + +当M接收到 `sigPreempt` 信号后,操作系统会中断M的当前执行,转而去执行`sighandler`。 信号处理函数会发现这是一个抢占信号,然后检查当前的Goroutine是否满足被抢占的条件(例如,没有在执行一些敏感的运行时代码)。 + +如果条件满足,最关键的一步来了:`sighandler`会像一个黑客一样,直接修改G的寄存器信息,主要是程序计数器(PC)和栈顶指针(SP)。它会强行在G的执行流中"注入"一段代码,这段代码就是 `asyncPreempt` 函数。 + +```go +// sighandler 是go的信号处理总入口 +// 它在gsignal这个特殊的goroutine上执行 +func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) { + // ... + // 如果收到了抢占信号 + if sig == sigPreempt { + // 执行抢占处理 + doSigPreempt(gp, ctxt) + } + // ... +} + +// doSigPreempt 执行具体的信号抢占逻辑 +func doSigPreempt(gp *g, ctxt *sigctxt) { + // 判断g是否满足抢占条件 + if wantAsyncPreempt(gp) { + if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok { + // 通过修改g的寄存器,强行让它下一条指令去执行asyncPreempt + ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc) + } + } + // ... +} + +// pushCall 修改栈指针和程序计数器,实现"指令注入" +func (c *sigctxt) pushCall(targetPC, resumePC uintptr) { + // 获取当前栈顶指针 sp (rsp寄存器) + sp := uintptr(c.rsp()) + // 栈向下移动一个指针大小,为返回地址腾出空间 + sp -= goarch.PtrSize + // 将原始的下一条指令地址(resumePC)存入新的栈顶 + *(*uintptr)(unsafe.Pointer(sp)) = resumePC + // 更新栈顶指针 + c.set_rsp(uint64(sp)) + // 将程序计数器(rip寄存器)设置为我们要注入的函数的地址 (targetPC) + c.set_rip(uint64(targetPC)) +} +``` + +这样一来,当信号处理结束,G恢复执行时,它下一条要执行的指令不再是原来被打断的地方,而是被篡改为了 `asyncPreempt` 函数。这个函数会立即调用 `mcall` 切换到 `g0` 栈,执行 `gopreempt_m`,最终完成让渡操作,和协作式抢占殊途同归。 + +```go +// asyncPreempt2 是被强行注入的代码逻辑 +// 此时的执行方是被抢占的g自己 +func asyncPreempt2() { + gp := getg() + // ... + // 切换到g0栈,调用gopreempt_m完成让渡 + mcall(gopreempt_m) + // ... +} +``` + +至此,哪怕是最顽固的"钉子户"Goroutine,也会被这种强制手段给请下CPU,保证了调度器的公平性。 + +**抢占**是Go调度器为了公平和效率,由`sysmon`线程发起的强制性调度行为。 + +1. **系统调用抢占**:通过解绑P和M,让P可以继续服务其他Goroutine,避免因单个M阻塞导致整个P被浪费。 + +2. **运行超时抢占**:针对长时间运行的Goroutine,Go提供了两手准备: + + * **协作式抢占**:温柔地打个标记,让Goroutine在函数调用时"自觉"让出CPU。 + + * **非协作式抢占**:对于不自觉的Goroutine,直接发送信号,通过修改PC和SP寄存器的方式,强行中断其执行,注入让渡逻辑。 + +正是有了这套精密的、软硬兼施的抢占机制,Go的并发调度才能如此健壮和高效,让我们能够放心地创建和使用海量的Goroutine。 + +## 6. 小结 + +本文从宏观的架构,到微观的源码实现,再到正向、逆向、第三方三种视角,全方位地把GMP给解剖了一遍。 + +希望通过这篇文章,你能对Go的并发调度有一个更深刻、更系统的理解。GMP模型无疑是Go语言设计的精髓所在,它优雅、高效地解决了并发调度中的种种难题,是我们每个Gopher都应该掌握的核心知识。 + + + diff --git a/hzh/GolangStar/Go语言原理/interface原理.md b/hzh/GolangStar/Go语言原理/interface原理.md new file mode 100644 index 0000000..8c6d9d7 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/interface原理.md @@ -0,0 +1,239 @@ +--- +tags: + - Go + - golang + - go原理深入 + - defer原理 +--- + +# interface原理 + +go语言并非传统意义上的面向对象的语言,他不像Java或者c++一样有类,继承等一些特性,但是我们也可以借助go语言中的struct和interface来实现这种面向对象的编程。在前面的基础章节我们了解到go语言中interface其实就是一组方法的声明,任何类型的对象实现了接口的全部方法就是这个接口的一个实现。这一章节我们主要分析一下interface的底层实现。 + +## interface的底层原理 + +### 空接口interface{} + +没有定义任何方法的接口为空接口,空接口可以接收任意数据类型,就是说可以将任意类型的数据赋值给一个空接口,空接口的结构定义位于`src/runtime/runtime2.go`,定义如下: + +```go +type eface struct { + _type *_type + data unsafe.Pointer +} +``` + +\_type:指向接口的动态类型元数据,即接口变量的类型 + +data:指向接口的动态值,data是一个指向变量本身的指针 + +#### \_type是什么 + + \_type 是 go 里面所有类型的一个抽象,里面包含了类型的大小,哈希,对齐以及k类型编号等信息,决定了data如何解释和操作,Go语言中几乎所有的数据结构都可以抽象成`_type`。关于\_type的定义在源文件`src/runtime/type.go`,具体定义如下: + +```go +type _type struct { + size uintptr // 数据类型占用的空间大小 + ptrdata uintptr // 前缀持有所有指针的内存大小 + hash uint32 // 类型的hash值 + tflag tflag // 信息标志 + align uint8 // 这种类型在内存中的对齐方式 + fieldAlign uint8 + kind uint8 // 类型编号 + equal func(unsafe.Pointer, unsafe.Pointer) bool // 类型的比较函数 + gcdata *byte + str nameOff + ptrToThis typeOff +} +``` + +什么是动态类型和动态值呢,举个例子 + +```go +package main + +import "fmt" + +type Apple struct { + PhoneName string +} + +func main() { + + a := Apple{PhoneName: "apple"} + var efc interface{} + efc = a + fmt.Println(efc) +} +``` + +这里在第12行定义了一个接口类型实例efc,此时还未对efc赋值,它的结构如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/interface原理/image.png) + +在第13行,对efc赋值了一个Apple类型的变量之后,其底层结构表现如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/interface原理/image-1.png) + +其中\_type指针指向a变量的类型元数据,data指针指向a变量的值 + +### 非空接口 + +包含方法列表的接口就是非空接口,例如下面定义的接口Phone就是一个非空接口: + +```go +type Phone interface { + Call() +} +``` + +非空接口的底层实现按与空接口有所不同,因为其多了方法列表,在底层实现中显然我们需要有地方来存储方法列表,非空接口的结构定义位于`src/runtime/runtime2.go`,定义如下: + +```go +type iface struct { + tab *itab + data unsafe.Pointer +} +``` + +data:指向接口的动态值,这里跟空接口一样 + +tab:指向一个itab的结构,itab结构里面存储值接口要求的方法列表和 data对应动态类型信息 + + 下面看一下itab的结构定义,itab结构定义在`src/runtime/runtime2.go`,定义如下: + +```go +type itab struct { + inter *interfacetype + _type *_type + hash uint32 // copy of _type.hash. Used for type switches. + _ [4]byte + fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter. +} +``` + +inter :指向interfacetype结构的指针,interfacetype结构记录了这个接口类型的描述信息,主要是接口的方法列表 + +\_type:实际类型的指针,指向\_type结构,\_type结构保存了接口的动态类型信息,跟空接口的\_type一样,即赋值给这个接口的具体类型信息的元数据 + +hash:该类型的hash值,itab中的hash和itab.\_type中的hash相等,其实是从itab.\_type中拷贝出来的,目的是用于快速判断类型是否相等 + +fun:fun是一个指针数组,里面保存了实现了该接口的实际类型的方法(只包含接口中的方法)地址,这些方法地址实际上是从interfacetype结构中的mhdr拷贝出来的,为了在调用的时候快速定位到方法。如果该接口对应的动态类型没有实现接口的所有方法,那么itab.fun\[0]=0,表示断言失败,该类型不能赋值给该接口 + +interfacetype 保存了接口自身的元信息,下面看一下interfacetype结构 + +```go +type interfacetype struct { + typ _type // 类型信息 + pkgpath name // 包路径 + mhdr []imethod // 接口的方法列表 +} +``` + +这里主要关注的是mhdr这个字段,定义的接口的方法里表就保存在mhdr数组里 + +下面还是通过例子看一下,赋值一个非空接口对应的底层结构变化 + +```go +package main + +import "fmt" + +type Apple struct { + PhoneName string +} + +func (a Apple) Call() { + fmt.Printf("%s有打电话功能\n", a.PhoneName) +} + +func (a Apple) SendMessage() { + fmt.Printf("%s有发短信功能\n", a.PhoneName) +} + +func (a Apple) SendEmail() { + fmt.Printf("%s有发邮件功能\n", a.PhoneName) +} + +type Phone interface { + Call() + SendMessage() +} + +func main() { + a := Apple{PhoneName: "apple"} + var ifc Phone + ifc = a + fmt.Println(ifc) +``` + +在程序第28行,赋值之前,ifc的机构如下图所: + +![](https://golangstar.cn/assets/img/go语言系列/interface原理/image-2.png) + +在第29行,给ifc赋值一个包含方法的结构体a之后,ifc的结构如下图: + +![](https://golangstar.cn/assets/img/go语言系列/interface原理/image-3.png) + +赋值过程中,data指针其实还是和空接口一样指向具体类型值,这里指向变量a。tab指针则是指向itab这个结构体,itab结构创建的创建主要就分为3部分: + +1. \_type字段保存接口的动态类型信息,本例中,\_type指针指向Apple类型的元数据 + +2. inter保存接口本身的一些信息,这里重要处理方法列表,本质上其实是求接口类型(Phone)和具体类型(Apple)的方法列表的交集,将具体类型(Apple)这部分交集的方法地址保存到interfacetype的mhdr数组中,假设具体类型(Apple)没有实现接口(Phone),那么这里mhdr数组将不包含任何方法的指针 + +3. 最后再将mhdr数组中的方法地址拷贝到itab的fun数组中,方便调用方法的时候快速找到方法地址,如果具体类型(Apple)没有实现接口(Phone),那么这里itab.fun\[0]=0 + +#### itab缓存 + +通过前面的分析我们知道,在给一个非空接口赋值的时候,itab里面主要是保存具体类型的类型元数据和方法列表,但是我们在给接口赋值的时候,我们可以赋值多个类型相同的动态类型,比如我们可以由如下代码: + +```go + var ifc Phone + a := Apple{PhoneName: "apple1"} + b := Apple{PhoneName: "apple2"} + C := Apple{PhoneName: "apple3"} + ifc = a + ifc = b + ifc = c +``` + +同类型的接口多次赋值,虽然具体类型的值不同,但是他们的类型相同,方法列表也相同,显然这个itab结构体是可以被复用的,如果我们每次都创建一个新的itab的话,性能无疑会大大下降。所以可以把用到的itab结构体缓存起来,每个非空的interface的接口类型和具体类型就可以唯一确定一个类型的itab。 + +##### itabTable + +go语言采用itabTable这个结构来缓存所有的itab结构,itabTable的结构定义在`src/runtime/iface.go`,定义如下: + +```go +type itabTableType struct { + size uintptr // entries数组的长度 + count uintptr // 当前数组中实际itab的数量 + entries [itabInitSize]*itab // 哈希表 +} +``` + +itabTable实际用来存储itab结构的其实是这个entries 结构,entries 是一个hash表,key为接口类型与实际类型分别哈希后的异或值 + +```go +func itabHashFunc(inter *interfacetype, typ *_type) uintptr { + return uintptr(inter.typ.hash ^ typ.hash) +} +``` + +所以key为整形,所以在实现的时候,go语言采用了空间换时间的思想,通过一个数组来实现,数组的每个元素为itab指针,其结构如下: + +![](https://golangstar.cn/assets/img/go语言系列/interface原理/image-4.png) + +我们在查找一个itab是否存在的时候, + +1. 先计算接口类型的哈希值hash1和实际类型的哈希值hash2 + +2. hash1与hash2做异或运算得到最终哈希值hash + +3. 在entries数组中找到下标为hash的位置 + +4. 如果能查询到对应的itab指针(这里需要比较接口类型和实际类型,因为可能出现产生hash冲突,槽位被占用的情况),就直接拿来使用。若没有就要再创建,然后添加到itabTable中。 + +对于hash冲突问题,采用的是开放地址法,根据计算的空位发现槽位被占用,则采用**二次寻址法**在数组后面寻找空位插入 + + + diff --git a/hzh/GolangStar/Go语言原理/map原理.md b/hzh/GolangStar/Go语言原理/map原理.md new file mode 100644 index 0000000..9c682f8 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/map原理.md @@ -0,0 +1,840 @@ +--- +tags: + - Go + - golang + - go原理深入 + - map原理 +--- + +# map原理 + +## map是什么 + +map就是一个key/value键值对的集合,可以根据key在O(1)的时间复杂度内取到value,有点类似与我们常用的数组或者切片结构,可以把数组看作是一种特殊的map,数组的key为数组的下标,而map的key可以为任意的可比较结构。在map中key不允许重复且要能够比较。 + +在go语言中,map的底层采用hash表,用变种拉链法来解决hash冲突问题。 + +## 哈希冲突 + +哈希表的原理是将多个k-v键值对散列的存储在buckets中,buckets可以理解为一个连续的数组,所以给定一个key/value键值对,我们要将其存储到合适的位置需要经过两步骤: + +1. 计算hash值:`hash = hashFunc(key)` + +2. 计算索引位置: `index = hash % len(buckets)` + +第一步是根据hash函数将key转化为一个hash值 + +第二步用hash值对 桶的数量 取模得到一个索引值,这样就得到了我们要插入的键值对的位置。 + +但是这里会出现一个问题,比如我们有两个键值对`key1/value1`和`key2/value2`,经过哈希函数 hashFunc的计算得到的哈希值`hash1`和`hash2`相同,那么这两个hash值的索引也必然相同,那将会存放到同一个位置,那这样怎么处理呢?丢弃后来的键值对?或者是覆盖之前的键值对? + +但是这都是不可取的,因为`key1`和`key2`是不同的。他们就是两个不同的键值对,理论上都应该被存储,那应该怎么存储呢?这就是我们所说的哈希碰撞问题。 + +解决哈希碰撞一般有两种方式;拉链法和开放寻址法 + +### 拉链法 + +拉链法是一种最常见的解决哈希冲突的方法,很多语言都是用拉链法哈希冲突。拉链法的主要实现是底层不直接使用连续数组来直接存储数据元素,而是使用通过数组和链表组合连使用,数组里存储的其实是一个指针,指向一个链表。当出现两个`key`比如`key1`和`key2`的哈希值相同的情况,就将数据链接到链表上,如果没有发现有冲突的`key`,显然链表上就只有一个元素。拉链法处理冲突简单,可以动态的申请内存,删除增加节点都很方便,当冲突严重,链表长度过长的时候也⽀持更多的优化策略,**⽐如⽤红⿊树代替链表。** + +拉链法结构如下图: + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-6.png) + +左边为一个连续数组,数组每个元素存储一个指针,指向一个链表,链表里每个节点存储的是发生hash冲突的数据 + +### 开放地址法 + +开放地址法是另外一种非常常用的解决哈希冲突的策略,与拉链法不同,开放地址法是将具体的数据元素存储在数组桶中,在要插入新元素时,先根据哈希函数算出hash值,根据hash值计算索引,如果发现冲突了,计算出的数组索引位置已经有数据了,就继续向后探测,直到找到未使用的数据槽为止。哈希函数可以简单地理解为: + +`hash(key)=(hash1(key)+i)%len(buckets)` + +开放地址法结构如下图 + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-7.png) + +在存储键值对``的时候,经过hash计算,发现原本应该存放在数组下标为2的位置已经有值了,存放了``,就继续向后探测,发现数组下标为3的位置是空槽,未被使用,就将``存放在这个位置。 + +## go语言map的底层结构 + +go语言中的map其实就是一个指向`hmap`的指针,占用8个字节。所以map底层结构就是`hmap`,`hmap`包含多个结构为`bmap`的`bucket`数组,当发生冲突的时候,会到正常桶里面的overflow指针所指向的溢出桶里面去找,Go语言中溢出桶也是一个动态数组形式,它是根据需要动态创建的。Go语言中处理冲突其实是采用了优化的拉链法,链表中每个节点存储的不是一个键值对,而是8个键值对。其整体的结构如下图: + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-8.png) + +看一下hmap的结构体定义: + +```go +// A header for a Go map. +type hmap struct { + // Note: the format of the hmap is also encoded in cmd/compile/internal/reflectdata/reflect.go. + // Make sure this stays in sync with the compiler's definition. + count int // ## live cells == size of map. Must be first (used by len() builtin) + flags uint8 + B uint8 // log_2 of ## of buckets (can hold up to loadFactor * 2^B items) + noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details + hash0 uint32 // hash seed + + buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0. + oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing + nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated) + + extra *mapextra // optional fields +} +``` + +字段含义: + +| 字段 | 释义 | +| ----------- | ------------------------------------------------------------------------------------ | +| count | map中元素个数,对应于len(map)的值 | +| flags | 状态标志位,标记map的一些状态 | +| B | 桶数以2为底的对数,即B=log\_2(len(buckets)),比如B=3,那么桶数为2^3=8 | +| noverflow | 溢出桶数量近似值 | +| hash0 | 哈希种子 | +| buckets | 指向buckets数组的指针,buckets数组的元素为bmap,如果数组元素个数为0,其值为nil | +| oldbuckets | 是一个指向buckets数组的指针,在扩容时,oldbuckets 指向老的buckets数组(大小为新buckets数组的一半),非扩容时,oldbuckets 为空 | +| nevacuate | 表示扩容进度的一个计数器,小于该值的桶已经完成迁移 | +| extra | 指向mapextra 结构的指针,mapextra 存储map中的溢出桶 | + +mapextra 结构定义如下: + +```go +// mapextra holds fields that are not present on all maps. +type mapextra struct { + // If both key and elem do not contain pointers and are inline, then we mark bucket + // type as containing no pointers. This avoids scanning such maps. + // However, bmap.overflow is a pointer. In order to keep overflow buckets + // alive, we store pointers to all overflow buckets in hmap.extra.overflow and hmap.extra.oldoverflow. + // overflow and oldoverflow are only used if key and elem do not contain pointers. + // overflow contains overflow buckets for hmap.buckets. + // oldoverflow contains overflow buckets for hmap.oldbuckets. + // The indirection allows to store a pointer to the slice in hiter. + overflow *[]*bmap + oldoverflow *[]*bmap + + // nextOverflow holds a pointer to a free overflow bucket. + nextOverflow *bmap +} +``` + +| 字段 | 释义 | +| ------------- | ---------- | +| overflow | 溢出桶链表地址 | +| oldoverflow | 老的溢出桶链表地址 | +| nextOverflow | 下一个空闲溢出桶地址 | + +`hmap`中真正用于存储数据的是`buckets`指向的这个`bmap`(桶)数组,每一个 `bmap` 都能存储 8 个键值对,当map中的数据过多,`bmap`数组存不下的时候就会存储到extra指向的溢出bucket(桶)里面 + +下面看一下bmap的结构定义: + +```go +type bmap struct { + topbits [8]uint8 + keys [8]keytype + values [8]valuetype + overflow uintptr +} +``` + +| 字段 | 释义 | +| ----------- | ------------------------------------------------- | +| topbits | 存储了bmap里8个key/value键值对的每个key根据哈希函数计算出的hash值的高 8 位 | +| keys | 存储了bmap里8个key/value键值对的key | +| values | 存储了bmap里8个key/value键值对的value | +| overflow | 指向溢出桶的指针 | + +再解释一下这个`tophash`,go语言的`map`会根据每一个key计算出一个`hash`值,有意思的是,对这个`hash`值的使用,go语言并不是一次性使用的,而是分开使用的,在使用中,把求得的这个`hash`值按照用途一分为二:高位和低位 + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image.png) + +假设我们对一个`key`做`hash`计算得到了一个hash值如图所示,蓝色就是这个hash值的高8位,红色就是这个hash值的低8位。而每个bmap中其实存储的就是8个这个蓝色的数字。 + +通过上图map的底层结构图我们可以看到,bmap的结构,bmap显示存储了8个`tohash`值,然后存储了8个键值对,注意,这8个键值对并不是按照`key/value`这样`key`和`value`放在一起存储的,而是先连续存完8个`key`,之后再连续存储8个`value`这样,当键值对不够8个时,对应位置就留空。这样存储的好处是可以消除字节对齐带来的空间浪费。 + +### map的访问原理 + +对map的访问有两种方式: + +```go +v := map[key] // 当map中没有对应的key时,会返回value对应类型的零值 +v, ok := map[key] // 当map中没有对应的key时,除了会返回value对应类型的零值,还会返回一个值存不存在的布尔值 +``` + +虽然这两种方法在返回值上很接近,后者只是多出了一个key存不存在的布尔值,但是在运行时调用的方法却不一样。 + +源码链接:[ Map 源码梳理](https://ls8sck0zrg.feishu.cn/docx/XJfDdQ6pgoYIjqxzgpJcFycTn1d##YHtod9JyPoVyNbxrbKhc78m9nCm)&##x20; + +大致原理如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-1.png) + +这里梳理一下步骤: + +1. 判断map是否为空或者无数据,若为空或者无数据返回对应的空值 + +2. map写检测,如果正处于写状态,表示此时不能进行操作,报fatal error + +3. 计算出hash值和掩码 + +4. 判断当前map是否处于扩容状态,如果在扩容执行下面步骤: + + * 根据状态位算判断当前桶是否被迁移 + + * 如果迁移,在新桶中查找 + + * 未被迁移,在旧桶中查找 + + * 根据掩码找到的位置 + +5. 依次遍历桶以及溢出桶来查找key + + * 遍历桶内的8个槽位 + + * 比较该槽位的tophash和当前key的tophash是否相等 + + * 相同,继续比较key是否相同,相同则直接返回对应value + + * 不相同,查看这个槽位的状态位是否为"后继空状态" + + * 是,key在以后的槽中也没有,这个key不存在,直接返回零值 + + * 否,遍历下一个槽位 + +6. &##x20;当前桶没有找到,则遍历溢出桶,用同样的方式查找 + +### map的赋值原理 + +map的赋值操作很简单 + +```go +map[key] = value +``` + +原`map`中存在`key`是,则更新对应的值为`value`,若`map`中不存在`key`时,则插入键值对`key/value`。 + +但是有两点需要注意: + +1. 在对map进行赋值操作的时候,map一定要先进行初始化,否则会panic + +```go +var m map[int]int +m[1] = 1 +``` + +m只是做了声明为一个map,并未初始化,所以程序会panic + +* map是非线程安全的,不支持并发读写操作。当有其他线程正在读写map时,执行map的赋值会报为并发读写错误 + +```go +package main + +import ( + "fmt" +) + +func main() { + m := make(map[int]int) + go func() { + for { + m[1] = 1 + } + }() + go func() { + for { + v := m[1] + fmt.Printf("v=%d\n", v) + } + }() + select {} +} +``` + +运行结果: + +```go +fatal error: concurrent map read and map write +``` + +源码链接:[ Map 源码梳理](https://ls8sck0zrg.feishu.cn/docx/XJfDdQ6pgoYIjqxzgpJcFycTn1d##VELzdF4BRo12UHxeEZGckzJ9nXc)&##x20; + +大致流程: + +1. map写检测,如果正处于写状态,表示此时不能进行读取,报fatal error + +2. 计算出hash值,将map置为写状态 + +3. 判断同数组是否为空,若为空,初始化桶数组 + +4. 目标桶查找 + + 1. 根据hash值找到桶的位置 + + 2. 判断该当前是否处于扩容: + + 1. 若正在扩容:迁移这个桶,并且还另外帮忙多迁移一个桶以及它的溢出桶 + + 3. 获取目标桶的指针,计算出tophash,开始后面的key查找过程 + +5. key查找 + + 1. 遍历桶和它的溢出桶的每个槽位,按下述方式查找 + + 2. 判断槽位的tophash和目标tophash + + 1. 不相等 + + 1. 槽位tophash为空,标记这个位置为侯选位置 + + 2. 槽位tophash的标志位为“后继空状态”,说明这个key之前没有被插入过,插入key/value + + 3. tophash标志位不为空,说明存储着其他key,说明当前槽的tophash不符合,继续遍历下一个槽 + + 2. 相等 + + 1. 判断当前槽位的key与目标key是否相等 + + 1. 不相等,继续遍历下一个槽位 + + 2. 相等,找到了目标key的位置,原来已存在键值对,则修改key对应的value,然后执行收尾程 + +6. key插入 + + 1. 若map中既没有找到key,且根据这个key找到的桶及其这个桶的溢出桶中没有空的槽位了,要申请一个新的溢出桶,在新申请的桶里插入 + + 2. 否则在找到的位置插入 + +7. 收尾程序 + + 1. 再次判断map的写状态 + + 2. 清除map的写状态 + +这里需要注意一点:申请一个新的溢出桶的时候并不会一开始就创建一个溢出桶,因为map在初始化的时候会提前创建好一些溢出桶存储在extra\*mapextra字段,样当出现溢出现象时候,这些下溢出桶会优先被使用,只有预分配的溢出桶使用完了,才会新建溢出桶。 + +### map的扩容 + +在上面介绍map的写入操作的时候,其实忽略了一个点,那就是随着不断地往map里写入元素,会导致map的数据量变得很大,hash性能会逐渐变差,而且溢出桶会越来越多,导致查找的性能变得很差。所以,需要更多的桶和更大的内存保证哈希的读写性能,这时map会自动触发扩容,在[`runtime.mapassign`](https://draveness.me/golang/tree/runtime.mapassign) 可以看到这条语句: + +```go +func mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { + ... + if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { + hashGrow(t, h) + goto again + } + ... +} +``` + +可以看到map会在两种情况下触发扩容: + +* **map的负载因子已经超过 6.5** + +* **溢出桶的数量过多** + +在扩容的时候还有一个条件`!h.growing()`,这是因为map的扩容并不是一个原子操作不是一次性完成的,所以需要判断一下,当前map是否正处于扩容状态,避免二次扩容造成混乱。 + +而这两种情况下,扩容策略是不同的 + +* 负载因子已经超过 6.5: 双倍扩容 + +* 溢出桶的数量过多:等量扩容(一般认为溢出桶数量接近正常桶数量时) + +什么是负载因子? + +```plain&##x20;text +负载因子 = 哈希表中的元素数量 / 桶的数量 +``` + +**为什么负载因子是6.5?** + +源码里对负载因子的定义是6.5,是经过测试后取出的一个比较合理的值, + +每个 bucket 有 8 个空位,假设map里所有的数组桶都装满元素,没有一个数组有溢出桶,那么这时的负载因子刚好是8。而负载因子是6.5的时候,说明数组桶快要用完了,存在溢出的情况下,查找一个key很可能要去遍历溢出桶,会造成查找性能下降,所以有必要扩容了 + +**溢出桶的数量过多?** + +可以想象一下这种情况,先往一个map插入很多元素,然后再删除很多元素?再插入很多元素。会造成什么问题? + +由于插入了很多元素,在不是完全理想的情况下,肯定会创建一些溢出桶,但是,又由于没有达到负载因子的临界值,所以不会触发扩容,在删除很多元素,这个时候负载因子又会减小,再插入很多元素,会继续创建更多的溢出桶,导致查找元素的时候要去遍历很多的溢出桶链表,性能下降,所以在这种情况下要进行扩容,新建一个桶数组,把原来的数据拷贝到里面,这样数据排列更紧密,查找性能更快。 + +##### 扩容过程 + +扩容过程中大概需要用到两个函数,**hashGrow()和growWork()。** + +扩容函数: + +```go +func hashGrow(t *maptype, h *hmap) { + // If we've hit the load factor, get bigger. + // Otherwise, there are too many overflow buckets, + // so keep the same number of buckets and "grow" laterally. + bigger := uint8(1) + if !overLoadFactor(h.count+1, h.B) { + bigger = 0 + h.flags |= sameSizeGrow + } + oldbuckets := h.buckets + newbuckets, nextOverflow := makeBucketArray(t, h.B+bigger, nil) + + flags := h.flags &^ (iterator | oldIterator) + if h.flags&iterator != 0 { + flags |= oldIterator + } + // commit the grow (atomic wrt gc) + h.B += bigger + h.flags = flags + h.oldbuckets = oldbuckets + h.buckets = newbuckets + h.nevacuate = 0 + h.noverflow = 0 + + if h.extra != nil && h.extra.overflow != nil { + // Promote current overflow buckets to the old generation. + if h.extra.oldoverflow != nil { + throw("oldoverflow is not nil") + } + h.extra.oldoverflow = h.extra.overflow + h.extra.overflow = nil + } + if nextOverflow != nil { + if h.extra == nil { + h.extra = new(mapextra) + } + h.extra.nextOverflow = nextOverflow + } + + // the actual copying of the hash table data is done incrementally + // by growWork() and evacuate(). +} +``` + +go语言在对map进行过扩容的时候,并不是一次性将map的所有数据从旧的桶搬到新的桶,如果map的数据量很大,会非常影响性能,而是采用一种“渐进式”的数据转移技术,遵循写时复制(copy on write)的规则,每次只对使用到的数据做迁移。 + +简单分析一下扩容过程: + +通过代码分析,`hashGrow()`函数是在mapassign函数中被调用,所以,扩容过程会发生在map的赋值操作,在满足上述两个扩容条件时触发。 + +扩容过程中大概需要用到两个函数,**`hashGrow()`和`growWork()`。**&##x5176;中hashGrow()函数只是分配新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上,并未参与真正的数据迁移,而数据迁移的功能是由growWork()函数完成的。 + +##### 迁移时机 + +**`growWork()`** 函数会在 `mapassign `和 `mapdelete `函数中被调用,所以数据的迁移过程一般发生在插入或修改、删除 key 的时候。在扩容完毕后(预分配内存),不会马上就进行迁移。而是采取**写时复制**的方式,当有访问到具体 bukcet 时,才会逐渐的将 oldbucket 迁移到 新bucket中。 + +**`growWork()`函数定义如下:** + +```go +func growWork(t *maptype, h *hmap, bucket uintptr) { + // 首先把需要操作的bucket迁移 + evacuate(t, h, bucket&h.oldbucketmask()) + // 再顺带迁移一个bucket + if h.growing() { + evacuate(t, h, h.nevacuate) + } +} +``` + +下面分析一下`evacuate`函数,大致迁移过程如下: + +```go + b := (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) + newbit := h.noldbuckets() + // 判断旧桶是否已经被迁移了 + if !evacuated(b) { + do... // 做转移操作 + } +``` + +1. `evacuated`函数直接通过tophash中第一个hash值判断当前bucket是否被转移 + +```go +func evacuated(b *bmap) bool { + h := b.tophash[0] + return h > emptyOne && h < minTopHash +} +``` + +* 数据迁移时,根据扩容规则,可能是迁移到大小相同的buckets上,也可能迁移到2倍大的buckets上。 + +如果迁移到等量数组上,则迁移完的目标桶位置还是在原先的位置上的,如果是双倍扩容迁移到2倍桶数组上,迁移完的目标桶位置有可能在原位置,也有可能在原位置+偏移量。(偏移量大小为原桶数组的长度)。xy 标记目标迁移位置,x 标识的是迁移到相同的位置,y 标识的是迁移到2倍桶数组上的位置。 + +解释一下: + +```go +var xy [2]evacDst +x := &xy[0] +x.b = (*bmap)(add(h.buckets, oldbucket*uintptr(t.bucketsize))) +x.k = add(unsafe.Pointer(x.b), dataOffset) +x.e = add(x.k, bucketCnt*uintptr(t.keysize)) + +if !h.sameSizeGrow() { + // Only calculate y pointers if we're growing bigger. + // Otherwise GC can see bad pointers. + y := &xy[1] + y.b = (*bmap)(add(h.buckets, (oldbucket+newbit)*uintptr(t.bucketsize))) + y.k = add(unsafe.Pointer(y.b), dataOffset) + y.e = add(y.k, bucketCnt*uintptr(t.keysize)) +} +``` + +evacDst结构如下: + +```go +type evacDst struct { + b *bmap // 迁移桶 + i int // 迁移桶槽下标 + k unsafe.Pointer // 迁移桶Key指针 + e unsafe.Pointer // 迁移桶Val指针 +} +``` + +* 确定完bucket之后,就会按照bucket内的槽位逐条迁移key/value键值对。 + +* 迁移完一个桶后,迁移标记位nevacuate+1,当nevacuate等于旧桶数组大小时,迁移完成,释放*旧的桶数组和旧的溢出桶数组* + +扩容过程大概如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-2.png) + +等量扩容 + +等量扩容,目标桶再扩容后还在原位置处 + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-3.png) + +双倍扩容 + +双倍扩容,目标桶扩容后的位置可能在原位置也可能在原位置+偏移量处。 + +### map的删除原理 + +map的delete原理很简单,其核心代码位于`runtime.mapdelete函`数中,这里就不贴完整函数了,删除动作前整体逻辑和前面map的访问差不多,也是map的写检测,以及寻找bucket和key的过程。 + +清空key/value的核心代码如下: + +```go +for { + b.tophash[i] = emptyRest + if i == 0 { + if b == bOrig { + break // beginning of initial bucket, we're done. + } + // Find previous bucket, continue at its last entry. + c := b + for b = bOrig; b.overflow(t) != c; b = b.overflow(t) { + } + i = bucketCnt - 1 + } else { + i-- + } + if b.tophash[i] != emptyOne { + break + } +} +``` + +如果在找到了目标key,则把当前桶该槽位对应的key和value删除,将该槽位的tophash置为emptyOne,如果发现当前槽位后面没有元素,则将tophash设置为emptyRest,并循环向前检查前一个元素,若前一个元素也为空,槽位状态为emptyOne,则将前一个元素的tophash也设置为emptyRest。这样做的目的是将emptyRest状态尽可能地向前面的槽推进,这样做是为了增加效率,因为在查找的时候发现了emptyRest状态就不用继续往后找了,因为后面没有元素了。 + +举个例子: + +假设当前map的状态如下图所示,溢出桶2后面没有在接溢出桶,或者是溢出桶2后面接的溢出桶中没有数据,溢出桶2中有三个空槽,即第2,3,6处为emptyOne, + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-4.png) + +在删除了溢出桶1的key2和key4,以及溢出桶2的key7之后,对应map状态如下: + +![](https://golangstar.cn/assets/img/go语言系列/map原理/image-5.png) + + + +从delete map单个key/value的原理可以看出,当我们删除一个键值对的时候,这个键值对在桶中的内存并不会被释放,所以对与map的频繁写入和删除可能会造成内存泄漏。 + +### map的遍历 + +go语言中map的遍历尤其要引起注意,因为每次遍历的数据顺序都是不同的。这是因为go在每次开始遍历前,都会随机选择一个桶下标,一个桶内遍历的起点槽下标,遍历的时候从这个桶开始,在遍历每个桶的时候,都从这个槽下标开始 + +go语言为什么要用这种随机开始的位置开始遍历呢? + +一方面:因为go的扩容不是一个原子操作,是渐进式的,所以在遍历map的时候,可能发生扩容,一旦发生扩容,key 的位置就发生了重大的变化,下次遍历map的时候结果就不可能按原来的顺序了。 + +另一方面:hash 表中数据每次插入的位置是变化的,同一个 map 变量内,数据删除再添加的位置也有可能变化,因为在同一个桶及溢出链表中数据的位置不分先后 + +所以理论上,map的遍历结果就是不同的,所以Go防止用户错误的依赖于每次迭代的顺序,索性每次遍历时,搜是随机选取的一个遍历开始位置。 + +##### 遍历过程 + +###### 迭代器 + +运行时,map的遍历是依靠一个迭代器来完成的,迭代器的代码定义如下: + +```go +type hiter struct { + key unsafe.Pointer // Must be in first position. Write nil to indicate iteration end (see cmd/compile/internal/walk/range.go). + elem unsafe.Pointer // Must be in second position (see cmd/compile/internal/walk/range.go). + t *maptype + h *hmap + buckets unsafe.Pointer // bucket ptr at hash_iter initialization time + bptr *bmap // current bucket + overflow *[]*bmap // keeps overflow buckets of hmap.buckets alive + oldoverflow *[]*bmap // keeps overflow buckets of hmap.oldbuckets alive + startBucket uintptr // bucket iteration started at + offset uint8 // intra-bucket offset to start from during iteration (should be big enough to hold bucketCnt-1) + wrapped bool // already wrapped around from end of bucket array to beginning + B uint8 + i uint8 + bucket uintptr + checkBucket uintptr +} +``` + +字段释义: + +| 字段 | 释义 | +| ------------ | -------------------------------------------------------------- | +| key | 键值对的键,键必须放置在第一个字段,key为空指针nil说明遍历结束 | +| elem | 键值对的值,值放置在第二个字段 | +| buckets | 桶数组指针,指向迭代器初始化之后要遍历的桶数组 | +| h | map的地址 | +| t | map的类型信息 | +| bptr | 指向当前遍历到的桶的指针 | +| overflow | hmap中正常桶的溢出桶指针 | +| oldoverflow | 发生扩容时,hmap中旧桶的溢出桶指针 | +| startBucket | 开始遍历时,初始化的桶下标 | +| offset | 开始遍历时,初始化的槽位下标 | +| wrapped | 表示是否遍历完了,true表示遍历完了 | +| B | 初始化迭代器时,h.B | +| i | 当前桶已经遍历的键值对数量,i=0时,开始遍历当前桶的第一个槽位,i=8时,当前桶已经遍历完,将 it.bptr指向下一个桶 | +| bucket | 当前遍历桶的偏移量 | +| checkBucket | 桶状桶状态标记位,如果不是noCheck,则表明当前桶还没有迁移, | + +整个遍历的过程大致可以分为两步 + +1. 初始化迭代器 + +2. 开始一轮遍历 + +初始化迭代器的工作主要在函数`mapiterinit(t `*`maptype, h `*`hmap, it *hiter)`中完成,源代码如下: + +```go +// mapiterinit initializes the hiter struct used for ranging over maps. +// The hiter struct pointed to by 'it' is allocated on the stack +// by the compilers order pass or on the heap by reflect_mapiterinit. +// Both need to have zeroed hiter since the struct contains pointers. +func mapiterinit(t *maptype, h *hmap, it *hiter) { + if raceenabled && h != nil { + callerpc := getcallerpc() + racereadpc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapiterinit)) + } + + it.t = t + if h == nil || h.count == 0 { + return + } + + if unsafe.Sizeof(hiter{})/goarch.PtrSize != 12 { + throw("hash_iter size incorrect") // see cmd/compile/internal/reflectdata/reflect.go + } + it.h = h + + // grab snapshot of bucket state + it.B = h.B + it.buckets = h.buckets + if t.bucket.ptrdata == 0 { + // Allocate the current slice and remember pointers to both current and old. + // This preserves all relevant overflow buckets alive even if + // the table grows and/or overflow buckets are added to the table + // while we are iterating. + h.createOverflow() + it.overflow = h.extra.overflow + it.oldoverflow = h.extra.oldoverflow + } + + // decide where to start + r := uintptr(fastrand()) + if h.B > 31-bucketCntBits { + r += uintptr(fastrand()) << 31 + } + it.startBucket = r & bucketMask(h.B) + it.offset = uint8(r >> h.B & (bucketCnt - 1)) + + // iterator state + it.bucket = it.startBucket + + // Remember we have an iterator. + // Can run concurrently with another mapiterinit(). + if old := h.flags; old&(iterator|oldIterator) != iterator|oldIterator { + atomic.Or8(&h.flags, iterator|oldIterator) + } + + mapiternext(it) // 进行单次遍历 +} +``` + +主要工作分为以下几步: + +1. 判断map是否为空 + +2. 随机一个开始遍历的起始桶下标 + +3. 随机一个槽位下标,后续每个桶内的遍历都从这个槽位开始 + +4. 把map置为遍历状态 + +5. 开始执行一次遍历过程 + +单次遍历的工作主要在`mapiternext(it *hiter)`函数中完成,源代码如下: + +```go +func mapiternext(it *hiter) { + h := it.h + if raceenabled { + callerpc := getcallerpc() + racereadpc(unsafe.Pointer(h), callerpc, abi.FuncPCABIInternal(mapiternext)) + } + if h.flags&hashWriting != 0 { // map的并发写判断 + throw("concurrent map iteration and map write") + } + t := it.t // 获取上次迭代进度 + bucket := it.bucket + b := it.bptr + i := it.i + checkBucket := it.checkBucket + +next: // 开始一次迭代 + if b == nil { // 遍历未开始或者当前正常桶(桶数组中的桶)的溢出桶(同数组的溢出表链表中的桶)经遍历完了,开始遍历下一个正常桶 + if bucket == it.startBucket && it.wrapped { // 遍历位置是起始桶,并且wrapped为true,说明遍历完了,直接返回 + // end of iteration + it.key = nil + it.elem = nil + return + } + // 如果map正在扩容,判断当前遍历的桶数据是否已经迁移完,迁移完了则使用新桶,否则使用旧桶 + if h.growing() && it.B == h.B { + // Iterator was started in the middle of a grow, and the grow isn't done yet. + // If the bucket we're looking at hasn't been filled in yet (i.e. the old + // bucket hasn't been evacuated) then we need to iterate through the old + // bucket and only return the ones that will be migrated to this bucket. + oldbucket := bucket & it.h.oldbucketmask() + b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize))) + if !evacuated(b) { + checkBucket = bucket + } else { + b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize))) + checkBucket = noCheck + } + } else { + b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize))) + checkBucket = noCheck + } + bucket++ + if bucket == bucketShift(it.B) { // 遍历到了数组最后,从头开始继续遍历 + bucket = 0 + it.wrapped = true + } + i = 0 + } + for ; i < bucketCnt; i++ { // 遍历当前桶和当前桶的溢出桶里的数据 + offi := (i + it.offset) & (bucketCnt - 1) // 通过初始化的槽位下表确定将要遍历的槽位的tophash + if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty { + // TODO: emptyRest is hard to use here, as we start iterating + // in the middle of a bucket. It's feasible, just tricky. + continue + } + k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize)) // 根据偏移量i确定key和value的地址 + if t.indirectkey() { + k = *((*unsafe.Pointer)(k)) + } + e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.elemsize)) + if checkBucket != noCheck && !h.sameSizeGrow() { + // Special case: iterator was started during a grow to a larger size + // and the grow is not done yet. We're working on a bucket whose + // oldbucket has not been evacuated yet. Or at least, it wasn't + // evacuated when we started the bucket. So we're iterating + // through the oldbucket, skipping any keys that will go + // to the other new bucket (each oldbucket expands to two + // buckets during a grow). + // 这里处于增量扩容,需要进一步判断 + // 如果数据还没有从旧桶迁移到新桶,需要计算这个key重新hash计算后是否与oldbucket的索引一致,不一致则跳过 + if t.reflexivekey() || t.key.equal(k, k) { + // If the item in the oldbucket is not destined for + // the current new bucket in the iteration, skip it. + hash := t.hasher(k, uintptr(h.hash0)) + if hash&bucketMask(it.B) != checkBucket { + continue + } + } else { + // Hash isn't repeatable if k != k (NaNs). We need a + // repeatable and randomish choice of which direction + // to send NaNs during evacuation. We'll use the low + // bit of tophash to decide which way NaNs go. + // NOTE: this case is why we need two evacuate tophash + // values, evacuatedX and evacuatedY, that differ in + // their low bit. + if checkBucket>>(it.B-1) != uintptr(b.tophash[offi]&1) { + continue + } + } + } + if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) || + !(t.reflexivekey() || t.key.equal(k, k)) { // 这里的数据没有处在扩容中,直接使用 + // This is the golden data, we can return it. + // OR + // key!=key, so the entry can't be deleted or updated, so we can just return it. + // That's lucky for us because when key!=key we can't look it up successfully. + it.key = k + if t.indirectelem() { + e = *((*unsafe.Pointer)(e)) + } + it.elem = e + } else { + // The hash table has grown since the iterator was started. + // The golden data for this key is now somewhere else. + // Check the current hash table for the data. + // This code handles the case where the key + // has been deleted, updated, or deleted and reinserted. + // NOTE: we need to regrab the key as it has potentially been + // updated to an equal() but not identical key (e.g. +0.0 vs -0.0). + rk, re := mapaccessK(t, h, k) // 走到这里,表明这条数据已经被迁移或者删除,使用mapaccessK去找回这部分的数据 + if rk == nil { + continue // key has been deleted 数据没找到,说明已经删除 + } + it.key = rk + it.elem = re + } + it.bucket = bucket // 记录本次遍历进度 + if it.bptr != b { // avoid unnecessary write barrier; see issue 14921 + it.bptr = b + } + it.i = i + 1 + it.checkBucket = checkBucket + return + } + b = b.overflow(t) // 遍历溢出桶链表:继续遍历下一个溢出桶 + i = 0 + goto next +} +``` + +遍历的流程大致是以下几个步骤: + +1. map的并发写检测,判断map是否处于并发写状态,是则panic + +2. 判断是否已经遍历完了,遍历完了直接退出 + +3. 开始遍历 + +4. 首选确定一个随机开始遍历的起始桶下标作为startBucket,然后确定一个随机的槽位下标作为offset + +5. 根据startBucket和offset开始遍历当前桶和当前桶的溢出桶,如果当前桶正在扩容,则进行步骤6,否则进行步骤7 + +6. 在遍历处于扩容状态的bucket的时候,因为当前bucket正在扩容,我们并不会遍历这个桶,而是会找到这个桶的旧桶old\_bucket,遍历旧桶中的一部分key,这些key重新hash计算后能够散列到bucket中,对那些key经过重新hash计算不散列到bucket中的key,则跳过 + +7. 根据遍历初始化的时候选定的随机槽位开始遍历桶内的各个key/value + +8. 继续遍历bucket溢出指针指向的溢出链表中的溢出桶 + +9. 假如遍历到了起始桶startBucket,则说明遍历完了,结束遍历 + + + + + diff --git a/hzh/GolangStar/Go语言原理/slice原理.md b/hzh/GolangStar/Go语言原理/slice原理.md new file mode 100644 index 0000000..2635c8a --- /dev/null +++ b/hzh/GolangStar/Go语言原理/slice原理.md @@ -0,0 +1,298 @@ +--- +tags: + - Go + - golang + - go原理深入 + - slice原理 +--- + +# slice原理 + +## slice是什么 + +go语言中slice可以理解为动态数组,区别于数组,其长度不是固定的,在使用时,我们不必担心其容量大小不够。当需要往切片追加元素时,如果切片容量大小不足,会自动扩容。基于其这个特性,我们在真正编程过程中,更多的会使用切片,而不是数组。 + +在使用过程中,区别于数组,声明切片时,也只需要制定声明数据类型,而不需要指定大小 + +```go +var arrs []int +``` + +## slice的数据结构 + +切片的底层数据结构定义如下: + +```go +type slice struct { + array unsafe.Pointer + len int + cap int +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/slice原理/image.png) + +slice结构 包含三个字段,array 类型为unsafe.Pointer,还有两个int类型的字段len和cap。 + +* **array:是一个指针变量,指向一块连续的内存空间,即底层数组结构** + +* **len:当前切片中数据长度** + +* **cap:切片的容量** + +**注意:cap是大于等于len的,当cap大于len的时候,说明切片未满,它们之间的元素并不属于当前切片。** + +## slice初始化 + +切片的初始化有三种方式: + +1. 通过已有切片初始化(通过 : 操作进行截取) + +2. 通过字面量初始化 + +3. 通过make关键字初始化 + +```go +slice := slice1[1:5] // 从切片slice1初始化一个新的切片slice,slice的元素为slice1中的下标为1到下标为4的元素 +slice := []int{1,1,1} +slice := make([]int, 3) // 第一个参数为类型,第二个参数为len +slice := make([]int, 3, 4) // 第一个参数为类型,第二个参数为len,第三个参数为cap +``` + +注意这里两种make创建的方式,当传递了容量参数时,若传递的参数比前面len的参数值大,那么实际创建的切片大小时是多余元素个数的,说明预留了空间。如果不指定cap,则默认创建cap和len大小相同的切片。 + +示例: + +```go +package main + +import ( + "fmt" +) + +func main(){ + arr1 := make([]int,3,5) + arr2 := make([]int,3) + fmt.Printf("arr1,len=%d, cap=%d\n",len(arr1),cap(arr1)) + fmt.Printf("arr2,len=%d, cap=%d\n",len(arr2),cap(arr2)) + + arr3 := []int{1,2,3,4,5} + arr4 := arr3[1:3] + fmt.Printf("arr3=%v\n",arr3) + fmt.Printf("arr4=%v\n",arr4) +} +``` + +运行结果: + +```go +arr1,len=3, cap=5 +arr2,len=3, cap=3 +arr3=[1 2 3 4 5] +arr4=[2 3] +``` + + + +## Slice操作 + +#### slice截取 + +从前面切片的数据结构分析我们可以知道切片在内存底层其实是一块连续的内存空间,跟数组一样,所以也可以通过下标的方式对切片进行截取。 + +```go +package main + +import ( + "fmt" +) + +func main(){ + arr1 := []int{1,2,3,4,5,6,7,8} + arr2 := arr1[2:4] // 前闭后开,不包括下标为4的元素 + fmt.Printf("arr1=%v, cap=%d\n",arr1,cap(arr1)) + fmt.Printf("arr2=%v, cap=%d\n",arr2,cap(arr2)) + arr2[0] = 100 + fmt.Printf("arr1=%v, cap=%d\n",arr1,cap(arr1)) + fmt.Printf("arr2=%v, cap=%d\n",arr2,cap(arr2)) +} +``` + +运行结果: + +```go +arr1=[1 2 3 4 5 6 7 8], cap=8 +arr2=[3 4], cap=6 +arr1=[1 2 100 4 5 6 7 8], cap=8 +arr2=[100 4], cap=6 +``` + +可以看到,在切片截取的时候遵循的原则是前闭后开的,且截取之后的切片,容量大小为从新切片的第一个元素开始算,到原切片的容量末尾。 + +注意:被截取出来的新切片底层仍然指向原切片的底层数据 + +这一点尤其需要注意,在我们更改新切片的值的时候,可能会改变原切片。 + +![](https://golangstar.cn/assets/img/go语言系列/slice原理/image-1.png) + +如上图所示,比如通过截取的方式由slice派生出一个新额的切片slice1,其实底层他们都是指向的同一块数据区域,只是两个切片的下表对应的底层数组的数据不同,`slice[1]=`2,而`slice1[0]=`2,他们指向同一个元素,所以当修改`slice1[0]`的值,势必会影响原始数组。 + +#### slice复制 + +下面来看一下slice的复制操作,在前面分析中我们知道,通过截取创建的新的切片其底层指向的是同一块内存区域,那么通过复制创建的切片是否也是这样呢? + +看个具体示例: + +```go +package main + +import "fmt" + +func main() { + arr1 := []int{1, 2, 3, 4, 5} + arr2 := arr1 + arr2[0] = 100 + fmt.Printf("arr1=%v\n", arr1) + fmt.Printf("arr2=%v\n", arr2) + + arr3 := [3]int{1, 2, 3} + arr4 := arr3 + arr4[0] = 100 + fmt.Printf("arr3=%v\n", arr3) + fmt.Printf("arr4=%v\n", arr4) +} +``` + +运行结果: + +```go +arr1=[100 2 3 4 5] +arr2=[100 2 3 4 5] +arr3=[1 2 3] +arr4=[100 2 3] +``` + +从程序结果可以看到,修改arr2影响了arr1,复制后的切片也和原切片指向同一块内存区域,会互相影响。但是数组的复制却不会出现这种情况,说明数组的复制会在底层会连同复制一块内存区域,与原数组互不影响 + +切片的赋值可以这样理解,如下如 + +![](https://golangstar.cn/assets/img/go语言系列/slice原理/image-2.png) + +复制操作原理其实很好理解,复制其实是复制了前面所说的slice 这个结构体,而在slice结构中真正与数据相关的其实是一个只想指向底层数组的一个array指针,所以这里其实是复制了一个指针而已,但是两个不同的指针还是指向同一块区域,所以对一个切片的修改会影响到另一个。 + +但是两个不同的指针还是指向同一块区域,所以对一个切片的修改会影响到另一个。 + +#### slice追加扩容 + +slice是动态数组,大小不固定,可以往后追加元素,追加的方法是通过append函数来实现,看一个有意思的例子: + +```go +package main + +import "fmt" + +func main() { + arr1 := make([]int, 0, 4) + arr1 = append(arr1, 1) + arr2 := append(arr1, 2) + arr3 := append(arr1, 3) + fmt.Printf("arr1=%v, addr1=%p\n", arr1, &arr1) + fmt.Printf("arr2=%v, addr2=%p\n", arr2, &arr2) + fmt.Printf("arr3=%v, addr3=%p\n", arr3, &arr3) +} +``` + +运行结果: + +```go +arr1=[1], addr1=0xc000098060 +arr2=[1 3], addr2=0xc000098078 +arr3=[1 3], addr3=0xc000098090 +``` + +这里为什么arr2和arr3的结果是一样都是`[1,3]`呢,为什么arr2不是`[1,2]`? + + + +原理是这样的,在前面的分析中我们知道了切片的结构定义,`type slice struct {...}`, Go 语言内置函数 append 参数是值传递,所以 append 函数在追加新元素到切片时,append 会生成一个新切片,并且将原切片的值拷贝到新切片。注意这里的新切片并不是指底层的数据结构,而是指`slice `这个结构体。所以我们每调用一次append函数,都会产生一个新的slice结构体,但是他们底层都指向同一块连续的内存区域,即共享底层数组,所以执行`arr3 := append(arr1, 3)`将`arr2`底层的数据`1,2`给覆盖了。 + +原理是这样的,在前面的分析中我们知道了切片的结构定义,`type slice struct {...}`, Go 语言内置函数 append 参数是值传递,所以 append 函数在追加新元素到切片时,append 会生成一个新切片,并且将原切片的值拷贝到新切片。注意这里的新切片并不是指底层的数据结构,而是指`slice `这个结构体。所以我们每调用一次append函数,都会产生一个新的slice结构体,但是他们底层都指向同一块连续的内存区域,即共享底层数组,所以执行`arr3 := append(arr1, 3)`将`arr2`底层的数据`1,2`给覆盖了。 + +假设原切片`arr1`中由4个元素`1,2,3,4`,我们执行语句`arr2=append(arr1,5)`,其过程如下图: + +![](https://golangstar.cn/assets/img/go语言系列/slice原理/image-3.png) + +最终会有两个slice结构体,但是他们都指向同一块内存区域。 + +###### **在追加元素时,slice容量不足怎么办?** + +当往切片追加元素时,如果切片容量不足,会自动扩容,具体的扩容策略如下: + +1.17之前 + +1. 首先看新的容量是否超过原容量的两倍,若超过原容量两倍,则扩容后的容量是用新容量大小 + +2. 新容量未超过原容量两倍,则看原切片容量是否小于1024,若小于1024,则新切片容量为原切片容量的两倍,若大于等于1024,则会反复地再原切片容量上增加1/4,直到新容量大于等于需要的容量 + +1.18及以后 + +![](https://golangstar.cn/assets/img/go语言系列/slice原理/image-4.png) + +扩容公式: +``` +newcap = oldcap+(oldcap+3*256)/4 +``` + + +简单看个样例: + +```go +package main + +import "fmt" + +func main() { + arr := make([]int, 4, 4) + fmt.Printf("cap = %d\n", cap(arr)) + arr = append(arr, 1) + fmt.Printf("cap = %d\n", cap(arr)) +} +``` + +运行结果: + +```go +cap = 4 +cap = 8 +``` + +容量做了翻倍 + +#### slice完全复制 + +在前面我们分析了slice的复制其底层仍然执行同一块内存区域,这样在使用中可能会带来问题,比如有时候我们想要完全复制出一个新的切片,二者用不同的底层数组,这样使用起来互不干扰,我们可以使用copy函数实现这个功能。 + +```go +package main + +import "fmt" + +func main() { + arr := []int{1, 2, 3, 4} + arr1 := make([]int, 3) + cnt := copy(arr1, arr) + fmt.Printf("cnt=%d\n", cnt) + fmt.Printf("arr1=%v\n", arr1) +} +``` + +运行结果: + +```go +cnt=3 +arr1=[1 2 3] +``` + +copy函数将第二个参数切片中的元素拷贝到第一个参数切片中,如果第一个参数切片容量不够,则返回的cnt的值为第一个切片容量大小,只会成功复制cnt个元素。 + + diff --git a/hzh/GolangStar/Go语言原理/string原理.md b/hzh/GolangStar/Go语言原理/string原理.md new file mode 100644 index 0000000..513ef43 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/string原理.md @@ -0,0 +1,365 @@ +--- +tags: + - Go + - golang + - go原理深入 + - string原理 +--- + +# string原理 + +## string是什么 + +在我们编程过程中,字符串可以说是我们使用的最多的一个数据结构了,凡是涉及到文本处理的地方,我们都会用到字符串。在go语言中,字符串其实就是一串由UTF-8编码的字符序列。 + +接下来我们看一下官方库对`string`的一个描述。 +```go +var text string +``` +在IDE里面定义一个 `text` 的 `string` 变量,点击 `string` 跳转到其类型定义的地方,可从源代码看到对 `string` 的描述,源代码的位置在 `src/builtin/builtin.go`,描述如下: + +```go +// string is the set of all strings of 8-bit bytes, conventionally but not +// necessarily representing UTF-8-encoded text. A string may be empty, but +// not nil. Values of string type are immutable. +type string string +``` +翻译一下,可以这样理解: +- 字符串是所有`8bit`字节的集合,但不一定是 `UTF-8` 编码的文本 +- 字符串可以为`empty`,但不能为 `nil` ,`empty`字符串就是一个没有任何字符的空串`""` +- 字符串不可以被修改,所以字符串类型的值是不可变的 + +所以字符串的本质是一串字符数组,每个字符在存储时都对应一个整数,也有可能对应多个整数,具体要看字符串的编码方式。可以看个例子: + +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + ss := "Hello" + for _, v := range ss { + fmt.Printf("%d\n", v) + } +} +``` + +运行结果: + +```go +s[0]: 72 +s[1]: 101 +s[2]: 108 +s[3]: 108 +s[4]: 111 +``` +可以看到,字符串的位置每个字符对应这个1个整数,这个整数就是字符的`UTF-8`编码值。 + +## string数据结构 + +在Go语言中,`string`类型在底层是一个结构体,这个结构体在`src/runtime/string.go`文件中定义,如下: + +```go +type stringStruct struct { + str unsafe.Pointer + len int +} +``` + +`stringStruct` 包含两个字段,`str`类型为`unsafe.Pointer`,还有一个`int`类型的`len`字段。 + +- `str` 指向字符串的首地址 +- `len` 表示字符串的长度 + +定义一个 `word` 字符串,并打印其数据结构: + +```go +word := "Hello" +``` +其底层结构如下图: + +![](https://golangstar.cn/assets/img/go语言系列/string原理/string原理1.png) + +在本例中,`len` 的长度为5,表示 `word` 这个字符串占用的字节数,每个字节的值如图中所示。这里需要注意,`len` 字段存储的是实际的字节数,而不是字符数,所以对于非单字节编码的字符,其结果可能多于字符个数。 + +我们知道了在 `runtime` 里 `string` 的定义,但是我们平常写代码似乎并没有用到 `stringStruct` 结构,它是在什么地方被用到呢? + +其实 `stringStruct`是字符串在运行时状态下的表现,当我们创建一个 `string` 的时候,可以理解为有两步: + +1. 根据给定的字符创建出 `stringStruct` 结构 +2. 将 `stringStruct` 结构转化为 `string` 类型 + +通过观察字符串的结构定义我们可以发现,其定义中并没有一个表示容量(Cap)的字段,所以意味着字符串类型并不能被扩容,字符换上的写操作包括拼接,追加等等都是通过拷贝来实现的。 + +## string与[]byte的互相转换 + +前面我们说了,`string`是只读的,不可以被改变,但是我们在编码过程中,进行重新赋值也是很正常的,既然可以重新赋值,为什么说不能被修改呢,这不是互相矛盾吗? + +这里要弄弄清楚一个概念,字符串修改并不等于重新赋值。我们在开发中所使用的,其实是对字符串的重新赋值,而不是修改。 + +```go +str := "Hello" +str = "Golang" // 重新赋值 +str[0] = "I" // 修改,不允许 +``` + +示例: + +```go +package main + +import "fmt" + +func main() { + var ss string + ss = "Hello" + ss[1] = "A" + fmt.Println(ss) +} +``` + +运行结果: + +```go +./main.go:10:12: cannot assign to ss[1] +``` +程序会报错,提示 `string`是不可修改的。这样一分析,那么可不可以将字符串转化为字节数组,然后通过下标修改字节数组,再转化回字符串呢,答案是可行的。 + +相互转化的语法如下例所示: + +```go +package main + +import "fmt" + +func main() { + var ss string + ss = "Hello" + strByte := []byte(ss) + strByte[1] = 65 + fmt.Println(string(strByte)) +} +``` + +运行结果: + +```go +HAllo +``` + +`Hello`变成了`HAllo`,好像达到了我们的目的。这里需要注意,虽然这种方式看似可行,修改了字符串`Hello`,但其实并不是我们所见的这样。最终得到的只是`ss`字符串的一个拷贝,源字符串并没有变化。 + +### string与[]byte的转化原理 + +`string`与`[]byte`的转化其实会发生一次内存拷贝,并申请一块新的切片内存空间 + +`byte`切片转化为`string`,大致过程分为两步: + +1. 新申请切片内存空间,构建内存地址为`addr`,长度为`len` +2. 构建 `string`对象,指针地址为`addr`,`len`字段赋值为`len`(`string.str = addr;string.len = len;`) +3. 将原切片中数据拷贝到新申请的`string`中指针指向的内存空间 + +![string与[]byte的转化](https://golangstar.cn/assets/img/go语言系列/string原理/string原理2.png) + +`string`转化为`byte`数组同样简单,大致分为两步: + +1. 新申请切片内存空间 +2. 将`string`中指针执行内存区域的内容拷贝到新切片 + +![string与[]byte的转化](https://golangstar.cn/assets/img/go语言系列/string原理/string原理3.png) + +## []byte转化为string是否一定会发生内存拷贝 + +很多场景中会用到[]byte转化为string,但是并不是每一次转化,都会像上述过程一样,发生一次内存拷贝。在什么情况下不会发生拷贝呢? + +转化为的字符串被用于临时场景,举几个例子: + +1. 字符串比较:`string(ss) == "Hello"` +2. 字符串拼接:`"Hello" + string(ss) + "world"` +3. 用作查找,比如`map`的`key`,`val := map[string(ss)]` + +这几种情况下,`[]byte`转化成的字符串并不会被后面程序用到,只是在当前场景下被临时用到,所以并不会拷贝内存,而是直接返回一个 `string`,这个 `string` 的指针 (`string.str`) 指向切片的内存。 + +## 字符串声明 + +Go语言中以字面量来声明字符串有两种方式,双引号和反引号: + +```go +str1 := "Hello World" +str2 := `Hello +Golang` +``` + +使用双引号声明的字符串和其他语言中的字符串没有太多的区别,但是这种使用双引号的字符串只能用于单行字符串的初始化,当字符串里使用到一些特殊字符,比如双引号,换行符等等需要用`\`进行转义。但是,反引号声明的字符串没有这些限制,字符内容即为字符串里的原始内容,所以一般用反引号来声明的比较复杂的字符串,比如`json串`。 + + +```go +json := `{"hello": "golang", "name": ["zhangsan"]}` +``` + +## 为什么这么设计 + +可能大家都会考虑到,为什么一个普通的字符串要设计这么复杂,还需要使用指针。暂时没找到官方文档的说明。 + +个人猜想,当遇到一个非常长的字符时,这样做使得`string`变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。虽然在Go 中,不管是引用类型还是值类型参数传递都是值传递。但指针明显比值传递更节省内存。 + +## 字符串拼接 + +Go语言中字符串是不可改变的,所以我们在对字符串进行拼接的时候会有内存的拷贝,存在性能损耗。常见的你字符串拼接有以下几种方式: + +- +操作符 +- fmt.Sprintf +- bytes.Buffer +- strings.Builder +- append +- string.Join + +### 性能测试 + +采用`testing`包下`benchmark`测试其性能 + +```go +package main + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +var loremIpsum = ` +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas non odio eget quam gravida laoreet vitae id est. Cras sit amet porta dui. Pellentesque at pulvinar ante. Pellentesque leo dolor, tristique a diam vel, posuere rhoncus ex. Mauris gravida, orci eu molestie pharetra, mi nibh bibendum arcu, in bibendum augue neque ac nulla. Phasellus consectetur turpis et neque tincidunt molestie. Vestibulum diam quam, sodales quis nulla eget, volutpat euismod mauris. +` + +var strSLice = make([]string, LIMIT) + +const LIMIT = 1000 + +func init() { + for i := 0; i < LIMIT; i++ { + strSLice[i] = loremIpsum + } +} + +func BenchmarkConcatenationOperator(b *testing.B) { + for i := 0; i < b.N; i++ { + var q string + for _, v := range strSLice { + q = q + v + } + } + b.ReportAllocs() +} + +func BenchmarkFmtSprint(b *testing.B) { + for i := 0; i < b.N; i++ { + var q string + for _, v := range strSLice { + q = fmt.Sprint(q, v) + } + } + b.ReportAllocs() +} + +func BenchmarkBytesBuffer(b *testing.B) { + for i := 0; i < b.N; i++ { + var q bytes.Buffer + + q.Grow(len(loremIpsum) * len(strSLice)) + + for _, v := range strSLice { + q.WriteString(v) + } + _ = q.String() + } + b.ReportAllocs() +} + +func BenchmarkStringBuilder(b *testing.B) { + for i := 0; i < b.N; i++ { + var q strings.Builder + + q.Grow(len(loremIpsum) * len(strSLice)) + + for _, v := range strSLice { + q.WriteString(v) + } + _ = q.String() + } + b.ReportAllocs() +} + +func BenchmarkAppend(b *testing.B) { + for i := 0; i < b.N; i++ { + // var q = make([]byte, 0, len(loremIpsum)*len(strSLice)) + var q []byte + for _, v := range strSLice { + q = append(q, v...) + } + _ = string(q) + } + b.ReportAllocs() +} + +func BenchmarkJoin(b *testing.B) { + for i := 0; i < b.N; i++ { + var q string + q = strings.Join(strSLice, "") + _ = q + } + b.ReportAllocs() +} +``` + +运行结果: + +```go +goos: windows +goarch: amd64 +pkg: gostudy/test +cpu: AMD Ryzen 7 7745HX with Radeon Graphics +BenchmarkConcatenationOperator +BenchmarkConcatenationOperator-16 54 18706593 ns/op + 238063081 B/op 1010 allocs/op +BenchmarkFmtSprint +BenchmarkFmtSprint-16 24 63046967 ns/op + 488318771 B/op 4275 allocs/op +BenchmarkBytesBuffer +BenchmarkBytesBuffer-16 15199 74857 ns/op + 950280 B/op 2 allocs/op +BenchmarkStringBuilder +BenchmarkStringBuilder-16 30217 37815 ns/op + 475140 B/op 1 allocs/op +BenchmarkAppend +BenchmarkAppend-16 5632 307936 ns/op + 3011173 B/op 24 allocs/op +BenchmarkJoin +BenchmarkJoin-16 32806 42271 ns/op + 475140 B/op 1 allocs/op +PASS +``` + +可以看到,采用`sprintf`拼接字符串性能是最差的,性能最好的方式是`string.Builder`和`string.Join`。 + +所以平时代码中,我们在拼接字符串的时候,最好采用后面几种方式,不要直接采用`+`或者`sprintf`,`sprintf`一般用于字符串的格式化而不用于拼接。 + +### 性能原理分析 + +| 方法 | 说明 | +| --- | --- | +| + | + 拼接 2 个字符串时,会生成一个新的字符串,开辟一段新的内存空间,新空间的大小是原来两个字符串的大小之和,所以没拼接一次买就要开辟一段空间,性能很差 | +| Sprintf | Sprintf 会从临时对象池中获取一个 对象,然后格式化操作,最后转化为string,释放对象,实现很复杂,性能也很差 | +| strings.Bulider | 底层存储使用[] byte,转化为字符串时可复用,每次分配内存的时候,支持预分配内存并且自动扩容,所以总体来说,开辟内存的次数就少,性能最好 | +| bytes.Buffer | 底层存储使用[] byte,转化为字符串时不可复用,底层实现和strings.Builder差不多,性能比strings.Builder略差一点,区别是bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来,性能仅次于strings.Builder | +| append | 直接使用[]byte扩容机制,可复用,支持预分配内存和自动扩容,性能只比+和Sprintf好,但是如果能提前分配好内存的话,性能将会仅次于strings.Bulider | +| string.Join | strings.join的性能约等于strings.builder,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是会有性能损耗的 | + +**最终做一下总结:** +> 性能对比:`strings.builder` ≈ `strings.join` > `bytes.buffer` > `append` > `+` > `fmt.sprintf` + - 如果进行少量的字符串拼接时,直接使用`+`操作符是最方便也算是性能最高的,就无需使用`strings.builder`。 + - 如果进行大量的字符串拼接时,使用`strings.builder`是最佳选择。 + diff --git a/hzh/GolangStar/Go语言原理/sync.map原理.md b/hzh/GolangStar/Go语言原理/sync.map原理.md new file mode 100644 index 0000000..1a962ca --- /dev/null +++ b/hzh/GolangStar/Go语言原理/sync.map原理.md @@ -0,0 +1,485 @@ +--- +tags: + - Go + - golang + - go原理深入 + - sync.map原理 +--- + +# sync.map原理 + +## sync.map是什么 + +sync.map是go语言在sync包下提供的一个可以提供并发访问的map。我们知道go语言的map是非线程安全的,对map的操作不是原子操作,所以在对原生的map进行并发读写的时候,很容易造成panic。 + + + +这里我们可能会想到,我们可以对map加一个锁来让其的每一次操作都受到保护,这样就实现了并发安全。但是这样对一整个map都加锁,无疑在性能上会大打折扣。所以,go语言在其1.9的版本中提供了一个并发安全的字段类型sync.Map。 + +从功能上看,sync.map是一个读写分离的map,采用了空间换时间的策略来提高数据的读写性能,其内部其实用了两个map来实现,一个read map和一个dirty map。在并发处理上,相比于我们前面提到的普通map的无脑加锁操作,sync.map将读和写分开,读数据优先从read中读取,对read的操作是不会加锁的,当read读取不到才会去dirty读,而写数据只会在dirty写,只有对dirty操作时需要加锁的,这样区分加锁时机,就提升了并发性能。 + +## sync.map的数据结构 + +先来看一下sync.Map的结构定义,sync.Map定义在源文件src/sync/map.go里面,我这里去掉了注释 + +```go +type Map struct { + mu Mutex // 用于保护dirty字段的锁 + read atomic.Value // 只读字段,其实际的数据类型是一个readOnly结构 + dirty map[interface{}]*entry //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素以及新加入的元素 + misses int // 计数器,记录在从read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read +} +``` + +这里主要看一下read这个字段,read字段的类型是atomic.Value,但是在使用中里面其实存储的是readOnly结构,readOnly结构定义如下: + +```go +// readOnly is an immutable struct stored atomically in the Map.read field. +type readOnly struct { + m map[interface{}]*entry // key为任意可比较类型,value位为entry指针的一个map + amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中都存在 +``` + +下面看一下entry这个结构 + +```go +type entry struct { + p unsafe.Pointer // p指向真正的value所在的地址 +} +``` + +这个p有三种取值: + +* p为nil,标记删除状态,代表这个key被删除了,此时dirty要么为nil,要么也存在这个key + +* p为expunged:标记删除状态,此时dirty非空,p存在于read,但不存在于dirty + +* p为正常值:entry表示正常的value + +entry这个字段在sync.map里被频繁的用到,他作为dirty这个map的value类型以及readOnly结构体中m这个字段对应的map的value类型 + + + +这里其实很巧妙,在dirty和readOnly里面的两个map的value并不是一个对象,而是一个指向任意类型的对象的指针,所以,在这两个map都非空的情况下,map的read字段和dirty字段会包含相同的数据项,如果通过read字段更改了这个项的值,dirty字段会读取到这个项的新值,因为它们指向的是同一个地址 + +sync.Map的底层结构如下图: + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image.png) + +在map的结构定义中介绍dirty字段的时候说到,dirty这个map包含在read中除了被expunged(删除)以外的所有元素,下面重点说一下**expunged**这个字段 + +**expunged**这个字段的作用是用来标识map中的某个key是否被删除,注意,这里知识标记删除,并没有真正的删除,所以**expunged**是map中用来对某个key做假删除动作的,当从sync.Map删除某个key的时候,将这个key对应的value标记为nil或者**expunged,**&##x540E;面在对这个key进行删除 + +## sync.map方法 + +sync.map跟map一样,提供了数据的增删改查功能,这里我们对照map从源代码来分析一下sync.map各个功能的具体实现 + +* `Store()`:更新/插入一个键值对 + +* `Load()`:返回一个key对应的value + +* `Delete()`:删除一个键值对 + +* `Range()`:对sync.Map进行遍历 + +### **`Store`** + +sync.map.Store()方法既可以用来新增键值对,也可用用来更新键值对。 + +* 更新键值对: + + * key存在于read中,那么这时key对应的p有三种情况 + + * p==expunged,当前key存在于read,但是key不存在于dirty,dirty也不为空,read包含dirty中不存在的key,dirty也包含read中不存在的key,这种情况下p的值为expunged,形式如下图。在这种情况下,不能单单只操作read,还要加锁同步更新dirty,将这个key加入到dirty中,将e.p的保存新传入的value + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-1.png) + + * p==nil,key存在于read,此时被标记删除,此时还没有完成dirty的重塑 + + * p==\&value,p指向的是一个正常值,没有被标记删除 + +* 新增键值对(只操作dirty) + + * dirty为nil,由于read的misses次数达到了dirty的长度,dirty刚刚提升为read,还没有被插入过新的key,此时为nil,此时要插入新的key,则要根据read重建dirty,在重建的时候,原来被标记删除未nil的键值对过滤掉,不复制到dirty,剩余的键值对都copy到dirty。并且还要改一下read里被标记删除的key的状态,由原来的nil改为expunged。 + + * dirty不为nil,直接在dirty中插入新的键值对 + +其源码如下: + +```go +// Store 用于保存或者事更新一个键值对 +func (m *Map) Store(key, value any) { + read, _ := m.read.Load().(readOnly) // 判断key是否存在于read中 + /***** + 假设key存在于read中,此时p有三种情况,这里先记住,后面会图解分析这三种情况 + 1. p == nil, key被标记删除了,此时dirty为空:dirty==nil,这个dirty中没有任何数据元素 + 2. p == expunged, key被标记删除,但此时dirty不为空,且dirty中存在一些key,这些key不再read中 + 3. p == &value,p指针指向一个正常的value, + ******/ + // key存在与read中,且此时read中m这个map的key对应的entry的p指针不满足p == expunged,这里走tryStore逻辑,直接使用cas原子操作修改value + if e, ok := read.m[key]; ok && e.tryStore(&value) { + return + } + m.mu.Lock() // 加锁 + read, _ = m.read.Load().(readOnly) //双重检查,怕执行后续逻辑的时候,read已被其他goroutine修改 + if e, ok := read.m[key]; ok { + if e.unexpungeLocked() { + // key被标记删除,p处于expunged状态,表明这个key在read中,但不再dirty中 + // 并且此时dirty非空,所以要保证dirty中包含map所有的key,就要把这个key加入到dirty中,并且修改e.p指向新的value + m.dirty[key] = e // 把这个key加入到dirty中 + } + e.storeLocked(&value) // 修改e.p指向新的value + } else if e, ok := m.dirty[key]; ok { // key不再read中,但存在于dirty中 + e.storeLocked(&value) // 直接修改e.p指向新的value + } else { // key既不在read中,也不再dirty中,是一个新的key, + if !read.amended { // amended为false,表明dirty中的keu在read中都存在 + // 先判断dirty是否为空,因为有一种情况,当read的未命中次数几misses达到dirty长度的时候,会拷贝整个dirty到read,即重塑过程 + // 发生重塑的时候,dirty中的所有key都会存在于read中,但是dirty在重塑完之后,会被立马置为nil,所以这里要做一个判断 + m.dirtyLocked() // 根据read重建dirty对象,将read中的key/value全部复制到dirty中,保证dirty中包含map中所有的key + m.read.Store(readOnly{m: read.m, amended: true}) // 更改read的amended为true,因为接下来要在dirty中插入一个新的keu,这个keu存在与dirty,但不存在于read + } + m.dirty[key] = newEntry(value) // 在dirty中加入新key/value + } + m.mu.Unlock() // 解锁 +} +``` + +`tryStore()`方法 + +```go +// 将entry中的p指针,e.p指向value,即尝试将value的值存在e的p指针中 +func (e *entry) tryStore(i *interface{}) bool { + for { + p := atomic.LoadPointer(&e.p) + // 上面解释过,出现这种情况,说明dirty不为空,且dirty中存在一些key,这些key不再read中,不能仅仅只更新read,还要加锁更新dirty,所以直接返回false + if p == expunged { + return false + } + // 原子操作更新value的值 + if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) { + return true + } + } +} +``` + +`unexpungeLocked() `方法 + +```go +func (e *entry) unexpungeLocked() (wasExpunged bool) { + // e.p从expunged修改为nil,操作成功返回true + // 这里为什么要改为nil,通过上面的调用逻辑可以知道,后续会将这个key加入到dirty中,这样这个key就既存在与read,又存在与dirty,所以状态要改一下 + return atomic.CompareAndSwapPointer(&e.p, expunged, nil) +} +``` + +`storeLocked()`方法 + +```go +// 原子操作,将i存储到e.p中 +func (e *entry) storeLocked(i *interface{}) { + atomic.StorePointer(&e.p, unsafe.Pointer(i)) +} +``` + +`dirtyLocked()`方法 + +```go +func (m *Map) dirtyLocked() { + if m.dirty != nil { // 如果dirty对象已经存在,则不需要再创建了,直接返回 + return + } + // 根据read重新创建一个dirty对象 + read, _ := m.read.Load().(readOnly) + m.dirty = make(map[interface{}]*entry, len(read.m)) // 新建一个dirty对象 + // 遍历read中的key/value,复制到dirty中 + for k, e := range read.m { + if !e.tryExpungeLocked() { + m.dirty[k] = e + } + } +} +``` + +`tryExpungeLocked()`方法 + +```go +func (e *entry) tryExpungeLocked() (isExpunged bool) { + p := atomic.LoadPointer(&e.p) + for p == nil { + // 如果p被标记删除为nil,在进行重塑的时候,即copy read到dirty的时候 + // 将nil的值改为expunged,这样做是为了表明在对dirty重新构建之前,这个key只存在于read,不存在于dirty + // 后续访问这个key的时候就不用加锁,加快了访问速度 + if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { + return true + } + p = atomic.LoadPointer(&e.p) + } + return p == expunged +} +``` + +store的流程如下图: + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-2.png) + +### **`Load`** + +Load()方法很简单,就是返回一个key对应的value,value不存在就返回nil。读取的时候,先从read中读取,读到了key,就直接返回结果,没有读取到,就加锁从dirty中读取,所以读取不在read中的key会因为加锁而导致性能下降。 + +在读取的过程中,可能发生read被重构的过程,即将dirty提升为read的过程,比如当读取某个key的时候,这个key存在与dirty中,但不存在与read中,所以,misses会加1,当misses刚好达到dirty的长度时,就会重塑read,拷贝dirty的数据到read中,将dirty提升为read,并将dirty只为nil。 + +load()方法源码如下: + +```go +// load返回keu对应的value值,ok表示key是否存在与map中 +func (m *Map) Load(key interface{}) (value interface{}, ok bool) { + read, _ := m.read.Load().(readOnly) + e, ok := read.m[key] // 先从read中读取 + if !ok && read.amended { // read中不存在,并且dirty中存在read中不存在可key + m.mu.Lock() // 加锁到dirty中读取 + // 双重检查,原理跟store的时候相同 + // 可能存在一个 goroutine 在执行完 if !ok && read.amended 但还没有加锁完成时,另一个 goroutine 将 dirty 提升成了 read 的情况, + // 修改了read这种的key集合,导致keu发生变化,所以在加锁之后还需要再从 read 中检查一遍 + read, _ = m.read.Load().(readOnly) + e, ok = read.m[key] + if !ok && read.amended { // key不存在与read中,且dirty中存在read中不存在可key + e, ok = m.dirty[key] // 直接从dirty读取 + // 不管key在不在dirty中,命中记录数misses都会加1 + // 当misses大于等于dirty的长度时,发生重塑,将dirty提升为read + // 将misses清0 + m.missLocked() + } + m.mu.Unlock() // 解锁 + } + if !ok { // 没有读取到,key既不存在于read,也不存在与dirty + return nil, false + } + // 读取到了,返回value + // 走到这里,因为ok会在dirty中再做一次赋值,既可能是直接从read读取到,也可能是从dirty读取到 + return e.load() +} +``` + +`missLocked()`方法 + +```go +func (m *Map) missLocked() { + m.misses++ // read未命中次数加1 + if m.misses < len(m.dirty) { // misses未达到dirty长度,直接返回 + return + } + // misses达到dirty长度 + m.read.Store(readOnly{m: m.dirty}) // 将dirty的内容存储到read中,提升dirty未read + m.dirty = nil // 将dirty置为nil,因未dirty为一个map类型,置为nil后,会被垃圾回收 + m.misses = 0 // misses清0 +} +``` + +load流程如图: + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-3.png) + +### **`Delete`** + +`Delete`方法是从sync.Map中删除一个元素,delete方法也很简单,还是优先检查read,若key在read中存在,则只会操作read。若在read中不存在,回去dirty中删除这个键值对`delete(m.dirty, key)`。所以分两种情况讨论: + +1. key存在于read中 + + 1. key只存在read,不存在于dirty。直接将key对应的e的e.p设置为nil,这种情况dirty中根本就没有这个key,所以不用管 + + 2. key既存在于read,也存在于dirt。也是直接将key对应的e的e.p设置为nil,这种情况下read中m的e和dirty中的e指向同一个,将read中e的e.p设置为nil,其实dirty中的p也指向了nil + +但是这两种方式删除键值对的时候其实都没有像从dirty中删除那样调用delete函数从map中删除这个key/value,所以这里并没有真的删除,只是标记删除了,真正删除要等到read中的misses大于等于dirty的时候,dirty提升为read的时候,这些key才回被垃圾回收掉。 + +* key不存在于read + +这种情况很简单,直接去dirty中这个map删除这个键值对就行了,这里是直接删除 + +所以:从read中删除是延迟删除,从dirty中删除是直接删除 + +其源代码如下: + +```go +// Delete 删除一个key +func (m *Map) Delete(key interface{}) { + read, _ := m.read.Load().(readOnly) // 检查key是否存在于read + e, ok := read.m[key] + if !ok && read.amended { + m.mu.Lock() + read, _ = m.read.Load().(readOnly) // 双重检查 + e, ok = read.m[key] + // 如果key不在read中,并且此时dirty中存在read中不存在的keu,则继续检查dirty,从dirty中删除这个键值对 + if !ok && read.amended { + delete(m.dirty, key) + } + m.mu.Unlock() + } + // ok为true,表示read中有这个key,而dirty有两种情况 + // 1. dirty中没有这个key,直接将e.p设置为nil + // 2. dirty中有这个key,也是将e.p设置为nil,因为read中m的e和dirty中的e指向同一个,所以将read里的p只为nil后,dirty中其实也指向了nil + if ok { + e.delete() + } +} + +// Delete内部调用LoadAndDelete完成delete +func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) { + read, _ := m.read.Load().(readOnly) // 检查key是否存在于read + e, ok := read.m[key] + if !ok && read.amended { + m.mu.Lock() + // 双重检测:若加锁前Map.dirty被替换为readonly,则前面m.read.Load().(readOnly)无 + // 效,需要再次检查 + read, _ = m.read.Load().(readOnly) + e, ok = read.m[key] + // 如果key不在read中,并且此时dirty中存在read中不存在的keu,则继续检查dirty,从dirty中删除这个键值对 + if !ok && read.amended { + e, ok = m.dirty[key] + delete(m.dirty, key) + m.missLocked() + } + m.mu.Unlock() + } + // ok为true,表示read中有这个key,而dirty有两种情况 + // 1. dirty中没有这个key,直接将e.p设置为nil + // 2. dirty中有这个key,也是将e.p设置为nil,因为read中m的e和dirty中的e指向同一个,所以将read里的p只为nil后,dirty中其实也指向了nil + if ok { + // 如果e.p不为nil或者expunged,软删除,把e.p置为nil + return e.delete() + } + return nil, false +} +``` + +delete流程如图: + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-4.png) + + +`e.delete()`方法 + +```go +func (e *entry) delete() (value interface{}, ok bool) { + for { + p := atomic.LoadPointer(&e.p) + if p == nil || p == expunged { // p已经被标记删除过了,返回false,表示未实际删除 + return nil, false + } + // 进行标记删除,但是key并未从map中移除,若key位于read中,key没有被移除,若key在dirty中,key也没有被移除 + if atomic.CompareAndSwapPointer(&e.p, p, nil) { + return *(*interface{})(p), true + } + } +} +``` + +### `Range` + +Range方法是对sync.Map进行遍历,其参数是一个`func(key, value interface{}) bool`类型的函数f,f的作用是对sync.Map中遍历到的每一个key/value键值对进行处理,当f返回false的时候,遍历停止。 + +在sync.Map中当dirty不为nil时,dirty会包含map中所有非删除的key。在遍历的时候会先看read的amended字段,当amended为true时,表示dirty中有read中没有的字段,将dirty提升为read,在遍历read即可,这样就避免了访问dirty会加锁导致性能低下,如果amended为false时,表示read和dirty中的key一致,这时直接遍历read即可。 + +其源代码如下: + +```go +func (m *Map) Range(f func(key, value interface{}) bool) { + read, _ := m.read.Load().(readOnly) + if read.amended { + m.mu.Lock() + // 跟前面方法的原理一样,同样是对read进行双检查 + read, _ = m.read.Load().(readOnly) + if read.amended { // dirty中包含read中不存在的key + // 将dirty提升为read,因为dirty中包含map中的所有key,所以直接便利dirty即可, + // 将ready提升了read之后,就不用加锁访问了,提升了效率 + read = readOnly{m: m.dirty} + m.read.Store(read) + m.dirty = nil // dirty置为nil + m.misses = 0 // read的未命中数置为0 + } + m.mu.Unlock() + } + + for k, e := range read.m { + v, ok := e.load() + if !ok { + continue + } + if !f(k, v) { + break + } + } +} +``` + +range流程如下图: + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-5.png) + +## p的状态变化 + +在sync.Map中,不论是read中还是dirty中,其底层的存储都是一个map,回顾一下这个map的结构: + +```go +m map[interface{}]*entry // read中的m字段 +dirty map[interface{}]*entry +``` + +再回顾一下这个entry的结构: + +```go +type entry struct { + p unsafe.Pointer // p指向真正的value所在的地址,value可以是任意类型interface{} +} +``` + +通过前面的方法分析,我们知道了这个p可能存在三种状态,nil,expunged,或者是指向一个正常value。那么他在这三种状态下是怎么切换的呢?还有为什么要有expunged这个字段呢?下面就通过简单的map图解操作来看一下p的状态是怎么变化的 + +1. 在一个空的map中加入两个元素假设为key1/value1和key2/value2,由于新加入元素,只会去dirty里面加入,所以加入完了以后,read还是空,dirty含有两个key,key1和key2 + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-6.png) + +* 此时,在读取连续读取两次,读取调用`load`方法,因为read中没有,所以回去dirty中读取,read未命中次数misses变为2,等于了dirty的长度,这个时候要将dirty提升为read,read中就包含了key1和key2,dirty置为nil,misses清0 + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-7.png) + + + +* 然后执行一次删除操作,删除key1,因为key1在read中存在,所以直接操作read即可,把key1标记删除,所以key1的p对应的状态就变为了nil,此时read.amended为false,并且此时dirty为空,并不包含任何key,所以不需要操作 + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-8.png) + +* 此时再插入一个新的键值对key3,由于是插入操作,要在dirty中插入,此时发现dirty为nil,所以要重塑dirty,重塑过程是这样,首先创建一个新的空dirty map,然后将read中标记删除为nil的key对应的p标记为expunged,最后将不是expunged状态的键值对都copy到dirty,然后将read.amended置为true,此时可以看到expunged状态出现了 + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-9.png) + +* 修改key1的值为value0,发现key1存在于read中,此时key1对应的p的状态是expunged,表明key1不存在与dirty,所以不能单单指操作read,还要加锁操作dirty,首先将key1对应p的状态由expunged改为nil,然后将key1加入到dirty中,将p的值修改为新的value,即e.p=\&value0,,此时read中只包含key1和key2,而dirty中包含map的全量key,key1,key2和key3 + +![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-10.png) + +通过上面的流程分析,走了一遍map的增删改查,分析了read到dirty中key集合的变化过程,以及key1对应的p的状态变化,可以看到key1的nil和expunged都表示标记删除,二者只有一个区别,就是当p为nil时,此时dirty对应的状态是nil或者dirty不为空且包含这个key,而当p的状态时expunged时,dirty不为nil,且dirty中包含read中没有的key。这里就可以知道,当p的状态为expunged时,对key1的操作不能只操作read,还要加锁操作dirty,而p的状态为nil时,只用操作read即可,不用加锁,性能更高。所以可以看出,虽然二者都表示标记删除,但分为两个状态之后,可以更细粒度的区分操作复杂度,在p的状态为nil时不加锁,尽量保证在能不加锁的时候就不加锁,提升程序性能。从这里分析也可以得知,没有expunged这个状态行不行呢,其实也可以,不过那样就不能根据区分度来判断是不是不用加锁直接操作read就可以了,还要加锁去read中检查一次,这样就降低了程序的性能。 + +## sync.Map总结 + +* **sync.Map是一个线程安全的map,可以多线程并发安全执行** + +* **sync.Map的核心思想是采用空间换时间,内置了两个map来存储数据,read和dirty,其中read支持原子操作,read的操作不加锁,dirty操作需要加锁** + +* **sync.Map将增删改查四个操作都做了细分,只有新增操作直接加锁操作dirty,其余的改,查,还有删除都是优先不加锁操作read,在发现read中没有对应key或者需要同步数据到dirty的时候才会加锁操作dirty,这样尽可能减少加锁次数,提升程序性能** + +* **在删除一个key的时候,如果key存在于read中则是延迟删除,key存在于dirty,不存在于read会立即删除** + +* **dirty和read都会依靠另一个进行重建,在dirty不为空的时候,dirty包含map中的所有有效key,在dirty为空的时候,read包含map中的所有有效key** + +* **read中的key在dirty中可能存在,也可能不存在;dirty中的key在read中也可能存在,可能不存在** + +* **sync.Map中的entry里的p指针有三种状态,nil,正常值还有expunged** + + + + + + + + + diff --git a/hzh/GolangStar/Go语言原理/内存管理.md b/hzh/GolangStar/Go语言原理/内存管理.md new file mode 100644 index 0000000..69b7875 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/内存管理.md @@ -0,0 +1,247 @@ +--- +tags: + - Go + - golang + - go原理深入 + - 内存管理 +--- + +# 内存管理 + +关于go堆栈内存不是很清楚可先看这篇文章:https://juejin.cn/post/7135670650353483783 + +> **| 导语现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而Java 和Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,开发者只需关注业务代码而无需关注底层内存分配和回收,虽然语言帮我们处理了这部分但是还是有必要去了解一下底层的架构设计和执行逻辑,这样可以更好的掌握一门语言,本文主要以go内存管理为切入点再到go垃圾回收,系统的讲解一下go自动内存管理系统的设计和原理,由于篇幅有限略去了go垃圾回收三色标记屏障技术这一块,有兴趣的推荐去看下《go语言设计和实现》。此文目的是抛砖引玉,欢迎各位交流探讨。** + +## **1. TCMalloc** + +go内存管理是借鉴了TCMalloc的设计思想,TCMalloc全称Thead-Caching Malloc,是google开发的内存分配器,为了方便理解下面的go内存管理,有必要要先熟悉一下TCMalloc。 + +![](https://golangstar.cn/assets/img/go语言系列/内存管理/image.png) + +### **1.1 Page** + +操作系统对内存管理以页为单位,TCMalloc也是这样,只不过TCMalloc里的Page大小与操作系统里的大小并不一定相等,而是倍数关系。 + +### **1.2 Span** + +一组连续的Page被称为Span,比如可以有4个页大小的Span,也可以有8个页大小的Span,Span比Page高一个层级,是为了方便管理一定大小的内存区域,Span是TCMalloc中内存管理的基本单位。 + +### **1.3 ThreadCache** + +每个线程各自的Cache,一个Cache包含多个空闲内存块链表,每个链表连接的都是内存块,同一个链表上内存块的大小是相同的,也可以说按内存块大小,给内存块分了个类,这样可以根据申请的内存大小,快速从合适的链表选择空闲内存块。由于每个线程有自己的ThreadCache,所以ThreadCache访问是无锁的。 + +### **1.4 CentralCache** + +是所有线程共享的缓存,也是保存的空闲内存块链表,链表的数量与ThreadCache中链表数量相同,当ThreadCache内存块不足时,可以从CentralCache取,当ThreadCache内存块多时,可以放回CentralCache。由于CentralCache是共享的,所以它的访问是要加锁的。 + +### **1.5 PageHeap** + +PageHeap是堆内存的抽象,PageHeap存的也是若干链表,链表保存的是Span,当CentralCache没有内存的时,会从PageHeap取,把1个Span拆成若干内存块,添加到对应大小的链表中,当CentralCache内存多的时候,会放回PageHeap + +### **1.6 TCMalloc对象分配** + +小对象直接从ThreadCache分配,若ThreadCache不够则从CentralCache中获取内存,CentralCache内存不够时会再从PageHeap获取内存,大对象在PageHeap中选择合适的页组成span用于存储数据。 + +## **2. Go内存管理** + +经过上一节对TCMalloc内存管理的描述,对接下来理解go的内存管理会有大致架构的熟悉,go内存管理架构取之TCMalloc不过在细节上有些出入,先来看一张go内存管理的架构图 + +![](https://golangstar.cn/assets/img/go语言系列/内存管理/image-1.png) + +### **2.1 Page** + +和TCMalloc中page相同,上图中最下方浅蓝色长方形代表一个page + +### **2.2 Span** + +与TCMalloc中的Span相同,Span是go内存管理的基本单位,代码中为 `mspan` ,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。 + +### **2.3 mcache** + +mcache与TCMalloc中的ThreadCache类似,mcache保存的是各种大小的Span,并按Span class分类,小对象直接从mcache分配内存,它起到了缓存的作用,并且可以无锁访问。 但mcache与ThreadCache也有不同点,TCMalloc中是每个线程1个ThreadCache,Go中是每个P拥有1个mcach,因为在Go程序中,当前最多有GOMAXPROCS个线程在运行,所以最多需要GOMAXPROCS个mcache就可以保证各线程对mcache的无锁访问,下图是G,P,M三者之间的关系 + +![](https://golangstar.cn/assets/img/go语言系列/内存管理/image-2.png) + +### **2.4 mcentral** + +mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。 但mcentral与CentralCache也有不同点,CentralCache是每个级别的Span有1个链表,mcache是每个级别的Span有2个链表。 + +### **2.5 mheap** + +mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,把从OS(系统)申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。 但mheap与PageHeap也有不同点:mheap把Span组织成了树结构,而不是链表,并且还是2棵树,然后把Span分配到heapArena进行管理,它包含地址映射和span是否包含指针等位图,这样做的主要原因是为了更高效的利用内存:分配、回收和再利用。 + +### **2.6 内存分配** + +Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分,其中小对象大小在16Byte到32KB之间,大对象大小大于32KB。 **span规格分类** 上面说到go的内存管理基本单位是span,且span有不同的规格,要想区分出不同的的span,我们必须要有一个标识,每个span通过spanclass标识属于哪种规格的span,golang的span规格一共有67种,具体如下: + +```go +//from runtime.gosizeclasses.go + +// class bytes/obj bytes/span objects tail waste max waste +// 1 8 8192 1024 0 87.50% +// 2 16 8192 512 0 43.75% +// 3 32 8192 256 0 46.88% +// 4 48 8192 170 32 31.52% +// 5 64 8192 128 0 23.44% +// 6 80 8192 102 32 19.07% +// 7 96 8192 85 32 15.95% +// 8 112 8192 73 16 13.56% +// 9 128 8192 64 0 11.72% +// 10 144 8192 56 128 11.82% +// 11 160 8192 51 32 9.73% +// 12 176 8192 46 96 9.59% +// 13 192 8192 42 128 9.25% +// 14 208 8192 39 80 8.12% +// 15 224 8192 36 128 8.15% +// 16 240 8192 34 32 6.62% +// 17 256 8192 32 0 5.86% +// 18 288 8192 28 128 12.16% +// 19 320 8192 25 192 11.80% +// 20 352 8192 23 96 9.88% +// 21 384 8192 21 128 9.51% +// 22 416 8192 19 288 10.71% +// 23 448 8192 18 128 8.37% +// 24 480 8192 17 32 6.82% +// 25 512 8192 16 0 6.05% +// 26 576 8192 14 128 12.33% +// 27 640 8192 12 512 15.48% +// 28 704 8192 11 448 13.93% +// 29 768 8192 10 512 13.94% +// 30 896 8192 9 128 15.52% +// 31 1024 8192 8 0 12.40% +// 32 1152 8192 7 128 12.41% +// 33 1280 8192 6 512 15.55% +// 34 1408 16384 11 896 14.00% +// 35 1536 8192 5 512 14.00% +// 36 1792 16384 9 256 15.57% +// 37 2048 8192 4 0 12.45% +// 38 2304 16384 7 256 12.46% +// 39 2688 8192 3 128 15.59% +// 40 3072 24576 8 0 12.47% +// 41 3200 16384 5 384 6.22% +// 42 3456 24576 7 384 8.83% +// 43 4096 8192 2 0 15.60% +// 44 4864 24576 5 256 16.65% +// 45 5376 16384 3 256 10.92% +// 46 6144 24576 4 0 12.48% +// 47 6528 32768 5 128 6.23% +// 48 6784 40960 6 256 4.36% +// 49 6912 49152 7 768 3.37% +// 50 8192 8192 1 0 15.61% +// 51 9472 57344 6 512 14.28% +// 52 9728 49152 5 512 3.64% +// 53 10240 40960 4 0 4.99% +// 54 10880 32768 3 128 6.24% +// 55 12288 24576 2 0 11.45% +// 56 13568 40960 3 256 9.99% +// 57 14336 57344 4 0 5.35% +// 58 16384 16384 1 0 12.49% +// 59 18432 73728 4 0 11.11% +// 60 19072 57344 3 128 3.57% +// 61 20480 40960 2 0 6.87% +// 62 21760 65536 3 256 6.25% +// 63 24576 24576 1 0 11.45% +// 64 27264 81920 3 128 10.00% +// 65 28672 57344 2 0 4.91% +// 66 32768 32768 1 0 12.50% +``` + +由上表可见最大的对象是32KB大小,超过32KB大小的由特殊的class表示,该class ID为0,每个class只包含一个对象。所以上面只有列出了1-66。 **内存大小转换** 下面还要三个数组,分别是:`class_to_size` ,`size_to_class` 和 `class_to_allocnpages`3个数组,对应下图上的3个箭头: + +![](https://golangstar.cn/assets/img/go语言系列/内存管理/image-3.png) + +以第一列为例,类别1的对象大小是8bytes,所以 `class_to_size[1]=8` ;span大小是8KB,为1页,所以 `class_to_allocnpages[1]=1`,下为go源码中大小转换数组。 + +```go +// size class to obj size (byte) +var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, + 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536, 1792, + 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, + 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768} + +// siez class to pages (page) +var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, + 7, 5, 8, 3, 10, 7, 4} + +// obj size to size class +var size_to_class8 = [smallSizeMax/smallSizeDiv + 1]uint8{0, 1, 2, 3, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, + 12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 22, 22, 22, + 22, 23, 23, 23, 23, 24, 24, 24, 24, 25, 25, 25, 25, 26, 26, 26, 26, 27, 27, 27, 27, 27, 27, 27, 27, 28, 28, 28, 28, + 28, 28, 28, 28, 29, 29, 29, 29, 29, 29, 29, 29, 30, 30, 30, 30, 30, 30, 30, 30, 31, 31, 31, 31, 31, 31, 31, 31, 31, + 31, 31, 31, 31, 31, 31, 31, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32, 32} +var size_to_class128 = [(_MaxSmallSize-smallSizeMax)/largeSizeDiv + 1]uint8{32, 33, 34, 35, 36, 37, 37, 38, 38, 39, 39, + 40, 40, 40, 41, 41, 41, 42, 43, 43, 44, 44, 44, 44, 44, 45, 45, 45, 45, 45, 45, 46, 46, 46, 46, 47, 47, 47, 47, 47, + 47, 48, 48, 48, 49, 49, 50, 51, 51, 51, 51, 51, 51, 51, 51, 51, 51, 52, 52, 52, 52, 52, 52, 52, 52, 52, 52, 53, 53, + 54, 54, 54, 54, 55, 55, 55, 55, 55, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 56, 57, 57, 57, 57, 57, 57, 57, 57, 57, + 57, 58, 58, 58, 58, 58, 58, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 59, 60, 60, 60, 60, 60, 60, + 60, 60, 60, 60, 60, 60, 60, 60, 60, 60, 61, 61, 61, 61, 61, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 62, 63, 63, 63, + 63, 63, 63, 63, 63, 63, 63, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, 64, + 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 65, 66, 66, 66, 66, 66, 66, 66, 66, + 66, 66, 66, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, 67, + 67, 67, 67, 67, 67, 67} +``` + +**为对象寻找span** 寻找span的流程如下: + +1. 计算对象所需内存大小size + +2. 根据size到size class映射,计算出所需的size class + +3. 根据size class和对象是否包含指针计算出span class + +4. 获取该span class指向的span + + 以分配一个包含指针大小为20Byte的对象为例,根据映射表: + +```go +// class bytes/obj bytes/span objects tail waste max waste +// 1 8 8192 1024 0 87.50% +// 2 16 8192 512 0 43.75% +// 3 32 8192 256 0 46.88% +``` + +size class 3,它的对象大小范围是(16,32]Byte,20Byte刚好在此区间,所以此对象的size class为3,Size class到span class的计算如下: + +```go +// noscan为false代表对象包含指针 +func makeSpanClass(sizeclass uint8, noscan bool) spanClass { + return spanClass(sizeclass<<1) | spanClass(bool2int(noscan)) +} +``` + +所以,对应的span class为: + +```go +span class = 3 << 1 | 0 = 6 +``` + +所以该对象需要的是span class 6指向的span,自此,小对象内存分配完成。 + +```go +//from runtime.gomalloc.go + +var sizeclass uint8 +//step1: 确定规格sizeClass +if size <= smallSizeMax-8 { + sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)] +} else { + sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)] +} +size = uintptr(class_to_size[sizeclass]) +// size class到span class +spc := makeSpanClass(sizeclass, noscan) +//step2: 分配对应spanClass 的 span +span = c.alloc[spc] +v := nextFreeFast(span) +if v == 0 { + v, span, shouldhelpgc = c.nextFree(spc) +} +x = unsafe.Pointer(v) +if needzero && span.needzero != 0 { + memclrNoHeapPointers(unsafe.Pointer(v), size) +} +``` + +大对象(>32KB)的分配则简单多了,直接在 mheap 上进行分配,首先计算出需要的内存页数和span class级别,然后优先从 `free` 中搜索可用的span,如果没有找到,会从 `scav` 中搜索可用的span,如果还没有找到,则向OS申请内存,再重新搜索2棵树,必然能找到span。如果找到的span比需求的span大,则把span进行分割成2个span,其中1个刚好是需求大小,把剩下的span再加入到 `free` 中去。 + diff --git a/hzh/GolangStar/Go语言原理/垃圾回收.md b/hzh/GolangStar/Go语言原理/垃圾回收.md new file mode 100644 index 0000000..bef028e --- /dev/null +++ b/hzh/GolangStar/Go语言原理/垃圾回收.md @@ -0,0 +1,913 @@ +--- +tags: + - Go + - golang + - go原理深入 + - 内存管理 +--- + +# 垃圾回收 + +## 1. 什么是GC + +GC的全称是 Garbage Collection,字面意思是垃圾回收,其实可以理解为垃圾内存回收。GC是编程语言实现的一种自动内存管理机制,用来找到程序中不再使用的那些“垃圾”内存,然后把它们清理掉,让这些内存重新可用。这里所说的“垃圾”内存更确切一点说其实是堆上的不再使用的内存,因为栈上的内存是由编译器自动分配和释放的,不需要GC参与 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-13.png) + +如上图所示,红色1标示的堆内存块就是垃圾内存,需要被回收清理,因为这块内存已经被分配,但是并没被使用,而剩下的两个黑色1标示的堆内存块不能被gc回收,因为还有两个栈上对象指向他们,它们还处于被引用状态中,处于使用阶段,不是垃圾内存 + +## 2. 为什么需要GC + +先来看个经典的c代码示例 + +```c +int * func(void) { + int a = 100; + /* + ... + */ + return &a; +} +``` + +`return &a;`会出错,这就是经典的悬挂指针问题,c语言是不允许返回一般局部变量地址的,其原因是一般局部变量的作用域只在函数内,其存储位置在栈区中,当程序调用完函数后,局部变量会随此函数一起被释放。 其地址指向的内容不明,就会成为一个悬挂指针,可能会引起严重的段错误 + +在上述例子中,代码比较简单,我们在写代码的时候可以轻松的发现悬挂指针问题,但是真实业务场景下的代码往往很复杂,比如下图 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-12.png) + +我们在手动free对象的时候,根据当前程序链路A-->C-->D-->E,依次free对象,当free掉D,E对象之后,只剩下C对象,貌似没有问题,但是其实还有其他的对象B引用了D对象,这样就造成了悬挂指针问题 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-10.png) + +所以在真实业务写代码场景中,程序员要free对象的时候必须要非常小心,对于当前对象的所有引用关系要很清楚,否则一旦误删,就会导致严重错误。但是真实业务代码中的对象有太多了,引用关系又极其复杂,所以这就导致很难避免这种悬挂指针错误,而且由于程序员写代码的时候要特别注意这些问题,导致开发效率又很低。 + +所以一门可以自动识别并且回收垃圾内存的语言会大大的提升程序的开发效率,同时也会减少很多不必要的错误,这就是程序语言的GC机制。现代语言中,包括java,go,python都有集成了自己的gc机制,用他们进行开发的时候,效率都比c,c++要高的多 + +## 3. **常见的GC算法** + +### 3.1 **引用计数法** + +引用计数法会为程序中的每个对象在对象头中分配一个空间来保存该对象被引用的次数,如果有其他对象引用了该对象,那么它的引用计数加1。如果其他对象删除了对该对象的引用,则它的引用计数减1,当该对象的引用计数为0时,此时他已经没有任何对象引用了,该对象就可以被回收 + +注意:当创建一个新对象时,引用计数器从 1 开始,表示有一个地方正在使用这个对象 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-14.png) + +如上图所示,对象c由于有两个对象B,F引用,所以计数为2,其余节点计数都是1 + +#### 3.1.1 优点 + +简单直接,回收速度快,不会出现内存耗尽或达到某个阀值时才回收 + +#### 3.1.2 缺点 + +不能很好的处理循环引用,而且需要有额外的空间实时维护引用计数,有也一定的代价 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-11.png) + +比如上图,再删除了对象A和D之后,由于对象B和E之间存在循环引用,所以上下B,C,E,F四个对象都存再引用关系,计数都不为0,所以都不能被回收,如果大量存在这种循环对象的话,就会造成一定的内存泄漏 + +### 3.2 **标记清除法** + +标记清除算法主要分为两个阶段来完成对垃圾内存的回收 + +**标记阶段**:遍历所有活动对象,并标记那些还在使用的对象标签 + +**清除阶段**:遍历内存中所有对象,清除那些没有被标记的对象 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image.png) + +#### 3.2.1 优点 + +简单易实现,不需要额外的数据结构,只需遍历一次对象图即可完成标记和清除操作,适合可回收对象不多的场景 + +#### 3.2.2 缺点 + +主要是会造成内存碎片,导致后续需要创建大对象的时候,由于没有连续的足够大的内存空间而创建不了,降低了内存的使用率 + +### 3.3 **复制法** + +复制算法将内存分成两个相等的区域,并且一次只使用其中一个。当这个区域被用完时,程序会把所有还在使用的对象复制到另一个空区域(保留区域),然后将已使用的区域全部清除。 + +这样,每次只需对一个区域进行垃圾回收,分配内存时也无需处理碎片问题,只需按顺序移动指针进行分配 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-1.png) + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-2.png) + +#### 3.3.1 优点 + +主要是避免了内存碎片问题,每次清除针对的都是整块内存 + +#### 3.3.2 缺点 + +内存利用率降低,使用的内存会缩小为了原来的一半 + +如果存活的对象很多,将这些对象都复制一遍,并且更新它们的应用地址,这一过程耗时可能较长,回收效率不够高 + +### 3.4 **标记整理** + +标记整理算法与标记清除算法非常相似,只是做了一点稍稍的改进,其过程可以分为以下两步: + +标记:这一步骤和标记清除算法一样,都是从GC 根节点开始扫描,并标记存活对象 + +整理:接下来,所有存活对象会被移动到一端的空闲空间,按照内存地址进行排序,并更新相应的引用指针。然后,清理掉末端内存地址之外的所有内存空间。 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-3.png) + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-4.png) + +### 3.5 **分代法** + +分代收集算法可以被视为上述内容的扩展。它的实现思路是根据对象生命周期的差异,将内存划分为多个区域,例如将堆空间划分为新生代和老年代,然后根据每个区域的特点选择最合适的收集算法。 + +在新生代中,由于存在大量对象很快被销毁、少量对象存活的特点,通常使用“复制算法”。这种算法只需付出少量存活对象的复制成本就可以完成垃圾对象的收集,效率非常高。而在老年代中,由于对象的存活率高,且没有额外空间进行分配担保,通常使用“标记-清理”或“标记-整理”算法来进行回收。 + +## 4. **Go语言GC算法** + +目前go语言GC在实现上采用的是一种并发三色标记法(GC程序与用户代码并发执行)加上屏障技术来实现的。并发三色标记法本质上也是一种标记清理算法,并且这种并发三色标记没有前面垃圾回收算法中提到的分代与整理过程,仅仅就是标记和清理过程。三色标记只是对其标记过程的一种简单描述,下面将详细介绍这种算法。 + +### 4.1 **为什么go语言gc不选择分代与标记整理** + +通过前面的gc算法分析知道基于整理的 GC 算法和基于复制的 GC 算法能够降低内存碎片,提高内存的使用率,但是go语言为什么不采用这种方式呢?主要有以下几点原因: + +1. **go为什么没有选用标记整理或者复制?** + +标记整理算法或者是复制算法主要是为了减少内存碎片,然而,Go 运行时使用的内存分配算法是基于 TCMalloc,虽然不能像复制算法那样消除掉内存碎片化的问题,但也极大地降低了碎片率,所以标记整理显得有时并不是那么明显,并且Thread Cache 的分配机制使得 Go 在大多数分配场景下可以避免使用锁,从而在高并发情况下展现出强大的性能优势。 + +* **go为什么没有选择分代?** + +go语言的struct类型创建是可以分配到栈上的,而不是像java一样new出来的对象在堆上。go会在编译期做静态逃逸分析,可以将生命周期很短的对象直接安排在栈上分配(比如函数内未发生逃逸的struc类型变量),分代GC最大的优势就是可以区分长生命周期和短生命周期对象,从而快速回收生命周期短的对象,但是由于go语言在编译期会做逃逸分析,所以Go 语言中的短生命周期对象并没有那么多,使得分代 GC 在 Go 语言中收益并不明显。此外,分代GC 还需要额外的写屏障来维护老年代对新生代的引用关系,这也增加了 GC 的开销 + +## 5. **GO语言GC的主要发展过程** + +go语言也不是一开始就是使用的并发三色标记法(GC程序与用户代码并发执行)加上屏障技术来实现,其主要经历以下三个过程: + +* **V1.3及之前:标记清除算法** + +* **V1.5:三色并发标记法** + +* **V1.8:混合写屏障机制** + +下面将逐一介绍这三个过程的GC算法 + +### 5.1 **什么样的内存GC不能回收?** + +在开始正式讲GO语言GC之前,我们先来看看GC是否够可以回收掉堆上的任意的垃圾内存呢? + +先来看一个简单的例子 + +```go +package main + +import "fmt" + +type Student struct { + ID int64 + Name string + Age int + Score float64 +} + +func main() { + slice := make([]Student, 6) + slice = slice[:3] + fmt.Println(slice) +} +``` + +程序创建了一个Student类型的切片slice,包含6个元素 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-5.png) + +当slice发生缩小,大小缩为原来的一半,只有三个元素的时候,底层数组的3两个元素已经无法再访问了 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-6.png) + +但是堆上的slice3,slice4,slice5的内存是不会被gc的,因为根据标记清理方法从根结点开始标记的话,这块内存还是属于可达的,所以不会被gc掉。同样的情况还存在于Map中,当创建一个Map之后,delete掉Map中的某个key之后,对应部分的内存也是不会被gc的 + +### 5.2 **标记清除算法** + +从前面标记清除算法可知,标记清除算法(Mark Sweep Algorithm),包括标记(Mark)和清除(Sweep)两个阶段: + +**标记阶段:**从根对象开始,查找并标记堆中所有存活的对象 + +**清除阶段**:遍历堆中所有对象,回收未被标记的对象 + +假设程序开始阶段对象分布如下图所示,下面用图示的方式展示标记清除的过程 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-7.png) + +1. 从根对象开始,逐一遍历每个对象及其子对象,并标记它们的可达状态,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-8.png) + +* 可达对象:对象A、对象B、对象C、对象D、对象F、对象G、对H + +* 不可达对象:对象E + +- 遍历堆上的所有对象,回收没有被标记的不可达对象,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-9.png) + +清除完成之后,剩下可达对象A,B,C,D,F,G,H,不可达对象E被清除,内存释放 + +注意:以上只是回收器(GC程序)的工作过程,还没有考虑赋值器(用户程序),其实在回收器在进行标记和清扫的过程中是要暂停整个用户程序的,暂停整个用户程序也称作STW。 + +如果不STW,会出现什么情况呢? + +假设现在标记阶段已经完成,对象A,B,C,D,F,G,H被打上标记,而对象E是未被标记的准备回收的对象,就在此时,清除之前对象H又指向了对象E + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-15.png) + +那么接下来回收器就会将对象E错误删除,其实对象E此时已经是可达对象了,是存在引用关系,再被使用的,是不能被GC的,所以完整的标记清除算法需要在进行GC前进行STW,即暂停整个用户程序。这样的话,性能必然很低,很难有人可以接受这种性能损耗 + +### 5.3 **三色标记法** + +简单的标记清除算法会带来长时间的STW,为了解决这个问题,Go语言在V1.5版本,使用三色并发标记法来优化这个问题 + +三色标记法将程序中的对象分为三类:白色、灰色和黑色 + +* 白色:未被垃圾收集器访问到的对象,也就是潜在的垃圾对象。在回收开始阶段 ,所有对象都标记为白色;在回收结束后,所有白色对象均不可达,其内存将被释放 + +* 灰色:已被垃圾收集器访问到的对象,但是垃圾收集器需要继续扫描它们的子对象,因为其可能存在指向白色对象的外部指针 + +* 黑色:已被垃圾收集器访问到的对象,且其引用都已被扫描到,黑色对象中任何一个指针都不可能直接指向白色对象 + +标记过程如下: + +1. **初始状态**:所有对象都是白色的 + +2. **扫描根对象**:从根对象开始扫描,将所有可达对象标记为灰色,并放入待处理集合中 + +3. **处理灰色对象**:从待处理集合中取出灰色对象,将它们引用的对象标记为灰色,并将这些新标记的对象加入待处理集合中,同时将自身标记为黑色。 + +4. **重复扫描**:重复第3步,直到待处理集合为空。此时,所有白色对象都是不可达的垃圾对象,可以进行回收 + +假设应用程序中的对象关系如下图所示,下面用图示的方式展示三色标记的过程 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-16.png) + +1. 应用程序开始运行时,所有对象默认标记为白色,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-17.png) + +用3个集合来存储被标记为3种颜色的对象,程序开始运行时,所有对象都存放在白色标记集合中 + +* 从根节点开始遍历,把遍历到的对象标记为灰色,放到灰色标记集合中,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-18.png) + +* 对象A、对象E,被标记为灰色 + +- 遍历灰色标记集合,将灰色对象本身标记为黑色,并将它们移动到黑色标记集合中;将黑色对象引用的白色对象标记为灰色,放到灰色标记集合中,如稀土所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-19.png) + +* 对象A、对象E,被标记为黑色 + +* 对象B、对象C、对象F,被标记为灰色 + +- 重复第三步,直到灰色标记表为空,如下图所示 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-20.png) + +* 清除所有的白色对象,完成垃圾回收,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-21.png) + +明白了三色标记的过程,还需要搞清楚垃圾回收最开始扫描的根集合究竟包含哪些对象,通常情况下根节点包含以下几个部分: + +1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量 + +2. 执行栈上的对象或指针:每个 goroutine 都包含自己的执行栈,这些执行栈上的对象包含栈上的变量及指向分配的堆内存区块的指针。 + +3. 寄存器中的变量:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块 + +### 5.4 **没有STW的三色标记法** + +从前面的分析了解到,STW会大大的降低用户程序的执行效率,甚至达到用户难以接受的地步。那么go经过优化后的并行三色标记法需要STW吗?先看一下假设不进行STW会不会有什么问题? + +以下图的程序对象关系进行分析,假设当前已经完成第一轮扫描,如下图所示。此时灰色对象F通过p指针指向白色对象H: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-22.png) + +由于没有进行STW,所以对象都有可能发生读写操作。假设还没扫描到对象F时,已经标记为黑色的对象E,创建q指针指向白色对象H,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-23.png) + +在新增q指针的同时,灰色对象F移除p指针,此时白色对象H只挂在已经完成扫描的黑色对象E下,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-24.png) + +然后,按照三色标记的规则继续执行,最终会得到如下图所示的结果: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-25.png) + +对象H其实是被对象E正确引用的,但却被垃圾收集器“错误”回收了,这将会导致程序严重错误,是不允许的。 + +从上面的分析来看,在不进行STW时,如果同时满足下面两个条件将会破坏垃圾收集器的正确性 + +* 一个白色对象被黑色对象引用(白色对象被挂在黑色对象下) + +* 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色对象同时丢失了该白色对象的引用) + +### **5.5 屏障技术** + +#### **5.5.1 强弱三色不变性** + +根据前面分析垃圾回收器会出现错误回收的两个条件,很容易可以总结出:只要破坏这两个必要条件,就可以保证对象不会被错误回收。也就是接下来讲到的两种三色不变性:强三色不变性和弱三色不变性 + +* **强三色不变性**:不存在黑色对象引用到白色对象的指针,即黑色对象不会指向白色对象,只会指向灰色对象或黑色对象 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-26.png) + +* **弱三色不变性**:所有被黑色对象引用的白色对象都处于灰色对象的保护状态,黑色对象可以指向的白色对象,但是必须包含一条从灰色对象到这个白色对象的可达路径 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-27.png) + +只要满足了这两种三色不变性其一,其实就破坏了垃圾收集器“错误回收”两个条件同时存在,这样就能保证垃圾回收的正确性了,go语言其实是引入内存写屏障技术来实现这两个三色不变性的,接下来将详细讨论什么是内存屏障以及go语言中涉及的3种屏障技术: + +1. 插入写屏障 + +2. 删除写屏障 + +3. 混合写屏障 + +#### **5.5.2 什么是内存屏障** + +内存屏障其实是一种赋值器技术,对应于在编译期生成的一段特殊代码,用于在程序运行时拦截内存写操作,相当于一个 hook 调用。其作用是通过 hook 内存的写操作时机, 做一些标记工作, 从而保证垃圾回收的正确性 + +下面通过一个具体的例子来看在屏障技术在汇编时期是怎么做的 + +```go +package main + +type Student struct { + Name string + Age *int +} + +func fun1(s *Student) { + s.Age = new(int) +} + +func fun2(s *Student) { + var st = new(Student) + st.Name = "google" +} + +func main() { + st1 := new(Student) + st2 := new(Student) + + go fun1(st1) + go fun2(st2) +} +``` + +对于上述代码,我们先看一下他的内存逃逸情况 + +回顾一下内存逃逸的原则: + +1. **尽可能分配到栈上**:在确保程序正确性的前提下,如果可以,将对象分配到栈上。栈上的对象生命周期与 goroutine 紧密相关,当 goroutine 结束时,这些对象会自动释放,避免了垃圾回收的负担,从而提高性能。 + +2. **堆分配的必要性**:当对象需要在函数调用返回后继续存在,或者对象的大小和结构无法在编译期确定时,就必须将其分配到堆上。这些对象的生命周期由程序的业务逻辑决定,最终由垃圾回收器管理和回收。 + +3. **不确定性时选择堆分配**:如果无法明确判断对象是否可以分配到栈上,为了保证程序的正确性和稳定性,通常会选择将其分配到堆上。堆上的对象在业务程序的创建时分配,最终由垃圾回收器负责回收 + +执行以下命令,查看其内存逃逸情况 + +```go +go build -gcflags "-N -l -m" ./main.go +``` + +输出 + +```go +# command-line-arguments +./main.go:8:11: s does not escape +./main.go:9:13: new(int) escapes to heap +./main.go:12:11: s does not escape +./main.go:13:14: new(Student) does not escape +./main.go:18:12: new(Student) escapes to heap +./main.go:19:12: new(Student) escapes to heap +``` + +程序当中一共 执行了4次new操作, 但只有 3 次逃逸到了堆 + +```go +'s.Age = new(int)' 逃逸到堆上, 因为其生命周期需要超越该 goroutine +'var st = new(Student)' 分配到栈上, st对象就只是在栈上使用 +'st1 := new(Student)', 逃逸到堆上, 因为其他的goroutine要使用该参数 +'st2 := new(Student)', 逃逸到堆上, 同st1 +``` + +接着来看下这段代码的汇编代码,执行以下命令 + +```go +go tool compile -S ./main.go +``` + +选取`s.Age = new(int)`这个int变量的创建汇编代码来看一下 + +```go +main.fun1 STEXT size=112 args=0x8 locals=0x28 funcid=0x0 align=0x0 + 0x0000 00000 (~/go_tourist/gcdemo/main.go:8) TEXT main.fun1(SB), ABIInternal, $48-8 + 0x0000 00000 (main.go:8) MOVD 16(g), R16 //将全局变量 g 的偏移量 16 处的数据移动到寄存器 R16 中 + 0x0004 00004 (main.go:8) PCDATA $0, $-2 //记录调试信息和垃圾收集信息。PCDATA 是跟踪垃圾回收和调试信息的数据指令 + 0x0004 00004 (main.go:8) CMP R16, RSP //将寄存器 R16 的值与栈指针 RSP 的值进行比较 + 0x0008 00008 (main.go:8) BLS 92 //如果 RSP 小于等于 R16,跳转到地址 0x005c,即指令地址 92 + 0x000c 00012 (main.go:8) PCDATA $0, $-1 //更新调试和垃圾回收信息 + 0x000c 00012 (main.go:8) MOVD.W R30, -48(RSP) //将寄存器 R30 的值移动到栈指针 RSP 的偏移量 -48 的位置 + 0x0010 00016 (main.go:8) MOVD R29, -8(RSP) //将寄存器 R29 的值保存到栈指针 RSP 的偏移量 -8 的位置 + 0x0014 00020 (main.go:8) SUB $8, RSP, R29 //将 RSP 减去 8,结果存储到 R29 中 + 0x0018 00024 (main.go:8) FUNCDATA $0, gclocals·wgcWObbY2HYnK2SU/U22lA==(SB) //提供函数的局部变量的垃圾收集信息 + 0x0018 00024 (main.go:8) FUNCDATA $1, gclocals·J5F+7Qw7O7ve2QcWC7DpeQ==(SB) //提供函数的参数的垃圾收集信息 + 0x0018 00024 (main.go:8) FUNCDATA $5, main.fun1.arginfo1(SB) //提供函数参数的其它信息 + 0x0018 00024 (main.go:8) FUNCDATA $6, main.fun1.argliveinfo(SB) //提供函数参数的生命周期信息 + 0x0018 00024 (main.go:8) PCDATA $3, $1 //调试和垃圾回收相关的指令 + 0x0018 00024 (main.go:8) MOVD R0, main.s(FP) //将寄存器 R0 的值存储到帧指针 FP 的 main.s 中 + 0x001c 00028 (main.go:8) PCDATA $3, $-1 //更新调试和垃圾回收信息 + 0x001c 00028 (main.go:9) MOVD $type:int(SB), R0 //将 int 类型的全局变量地址移动到 R0 中 + 0x0024 00036 (main.go:9) PCDATA $1, $0 #更新调试和垃圾回收信息 + 0x0024 00036 (main.go:9) CALL runtime.newobject(SB) //调用 runtime.newobject 函数,为新对象分配内存 + 0x0028 00040 (main.go:9) MOVD main.s(FP), R1 //将 main.s 的值从 FP 中移动到寄存器 R1 + 0x002c 00044 (main.go:9) PCDATA $0, $-2 //更新调试和垃圾回收信息 + 0x002c 00044 (main.go:9) MOVB (R1), R27 // 将 R1 地址中的字节数据移动到 R27 中 + 0x0030 00048 (main.go:9) PCDATA $0, $-1 //更新调试和垃圾回收信息 + 0x0030 00048 (main.go:9) PCDATA $0, $-2 //更新调试和垃圾回收信息 + 0x0030 00048 (main.go:9) MOVWU runtime.writeBarrier(SB), R2 //将 runtime.writeBarrier 地址的数据移动到 R2 中 + 0x0038 00056 (main.go:9) CBNZW R2, 68 //如果 R2 不为零,则跳转到地址 0x0044 + 0x003c 00060 (main.go:9) MOVD R0, 16(R1) //将 R0 的值存储到 R1 偏移 16 处 + 0x0040 00064 (main.go:9) JMP 80 //跳转到地址 0x0050。 + 0x0044 00068 (main.go:9) ADD $16, R1, R2 //将 R1 加 16 的结果存储到 R2 中 + 0x0048 00072 (main.go:9) MOVD R0, R3 //将寄存器 R0 的值移动到寄存器 R3 + 0x004c 00076 (main.go:9) CALL runtime.gcWriteBarrier(SB) //调用 runtime.gcWriteBarrier 函数。这个函数处理写屏障,确保在垃圾回收时正确处理写入 + 0x0050 00080 (main.go:10) PCDATA $0, $-1 //更新调试和垃圾回收信息 + 0x0050 00080 (main.go:10) PCDATA $1, $-1 //更新调试和垃圾回收信息 + 0x0050 00080 (main.go:10) LDP -8(RSP), (R29, R30) //从 RSP 偏移量 -8 处加载两个寄存器的值到 R29 和 R30 中。LDP 表示加载双字 + 0x0054 00084 (main.go:10) ADD $48, RSP //将栈指针 RSP 加 48 + 0x0058 00088 (main.go:10) RET (R30) //从 R30 寄存器中返回,结束函数 main.fun1 的执行 + 0x005c 00092 (main.go:10) NOP //无操作指令(No Operation),通常用于对齐或占位 + 0x005c 00092 (main.go:8) PCDATA $1, $-1 //更新调试和垃圾回收信息 + 0x005c 00092 (main.go:8) PCDATA $0, $-2 //更新调试和垃圾回收信息 + 0x005c 00092 (main.go:8) MOVD R0, 8(RSP) //将 R0 寄存器的值移动到 RSP 偏移量 8 处 + 0x0060 00096 (main.go:8) MOVD R30, R3 //将寄存器 R30 的值移动到寄存器 R3 + 0x0064 00100 (main.go:8) CALL runtime.morestack_noctxt(SB) //调用 runtime.morestack_noctxt 函数。此函数用于检查是否需要更多的栈空间 + 0x0068 00104 (main.go:8) MOVD 8(RSP), R0 //将 RSP 偏移量 8 处的值移动到寄存器 R0 + 0x006c 00108 (main.go:8) PCDATA $0, $-1 //更新调试和垃圾回收信息 + 0x006c 00108 (main.go:8) JMP 0 // 跳转到地址 0x0000,即函数的开始位置。这个跳转通常在栈空间不足的情况下发生 +``` + +汇编代码中红色背景的语句注释主要就是关于`s.Age = new(int)`这条语句的对象创建以及过程中开启的写屏障机制。 + +可以看到在写对象(这里是创建一个新对象)在编译期有写屏障的hook操作,写屏障是一段编译器在编译期插入的特殊代码,代码函数名字叫做 `gcWriteBarrier` + +屏障代码的运行,而是需要条件判断的,并不是只要是堆上内存赋值就会运行 `gcWriteBarrier`代码。在回收器开始扫描前,STW程序会设置这个判断语句,只有当判断条件符合的时候`gcWriteBarrier`代码才会运行 + +可以简单用伪代码描述一下上述屏障功能 + +```go +if runtime.writeBarrier.enabled { + runtime.gcWriteBarrier(ptr, val) +} else { + *ptr = val +} +``` + +下面简单看一下在 `amd64` 架构下,`gcWriteBarrier`的汇编代码,具体文件可以在src/runtime/asm\_amd64.s查看,看看`gcWriteBarrier`函数具体做了些什么工作 + +```go +// gcWriteBarrier 执行堆指针的写操作并通知垃圾回收器(GC)。 + // + // gcWriteBarrier 不遵循 Go ABI(应用二进制接口)。它接收两个参数: + // - DI 是写入操作的目标地址 + // - AX 是要写入 DI 地址的值 + // 它会修改 FLAGS(标志寄存器)。它不会修改任何通用寄存器 + // 但可能会修改其他寄存器(例如,SSE 寄存器). + // 定义为 ABIInternal,因为它不使用基于堆栈的 Go ABI. + TEXT runtime·gcWriteBarrier(SB),NOSPLIT,$112 + // Save the registers clobbered by the fast path. This is slightly + // faster than having the caller spill these. + MOVQ R12, 96(SP) //将寄存器 R12 的值保存到栈中偏移 96 的位置 + MOVQ R13, 104(SP) //将寄存器 R13 的值保存到栈中偏移 104 的位置 + // TODO: Consider passing g.m.p in as an argument so they can be shared + // across a sequence of write barriers. + MOVQ g_m(R14), R13 //从寄存器 R14 指向的位置加载 g_m 的值(全局变量),并存储到寄存器 R13 + MOVQ m_p(R13), R13 //从寄存器 R13 指向的位置加载 m_p(当前线程的堆指针) + MOVQ (p_wbBuf+wbBuf_next)(R13), R12 //从 R13 指向的 p_wbBuf 结构中的 wbBuf_next 偏移处加载数据到寄存器 R12,这是获取写屏障缓冲区的位置 + // Increment wbBuf.next position. + LEAQ 16(R12), R12 //计算 R12 + 16 的地址,并将其存储回 R12,这一步更新了写屏障缓冲区的写入位置 + MOVQ R12, (p_wbBuf+wbBuf_next)(R13) //将更新后的缓冲区写位置存储回 p_wbBuf 结构中的 wbBuf_next + CMPQ R12, (p_wbBuf+wbBuf_end)(R13) //比较写屏障缓冲区的当前位置 R12 与缓冲区的结束位置。用于检查缓冲区是否已满 + // Record the write. + MOVQ AX, -16(R12) //将寄存器 AX(要写入的值)存储到缓冲区当前位置的偏移 -16 处。 // Record value + // Note: This turns bad pointer writes into bad + // pointer reads, which could be confusing. We could avoid + // reading from obviously bad pointers, which would + // take care of the vast majority of these. We could + // patch this up in the signal handler, or use XCHG to + // combine the read and the write. + MOVQ (DI), R13 //将 DI 寄存器中的地址内容加载到 R13。DI 指向要写入的地址 + MOVQ R13, -8(R12) // 将 R13 的值(即要写入的地址)存储到缓冲区中的偏移 -8 处 // Record *slot + // Is the buffer full? (flags set in CMPQ above) + JEQ flush //如果上面比较结果表明缓冲区已满(R12 等于 wbBuf_end),则跳转到 flush 标签,意思就是 wbBuffer 队列满了,那么就下刷处理,比如置灰,置黑等操作 + ret: // 其实就是执行赋值:*slot = val,直接置灰色,置黑色 + MOVQ 96(SP), R12 // 从栈中恢复寄存器 R12 的值 + MOVQ 104(SP), R13 // 从栈中恢复寄存器 R13 的值 + // Do the write. + MOVQ AX, (DI) // 将 AX 寄存器中的值写入到 DI 指定的地址 + RET // 从函数中返回 + flush: + ... + // This takes arguments DI and AX + CALL runtime·wbBufFlush(SB) // 批量刷新操作, 最终调用 wbBufFlush1, 将 mspan.gcmarkBits 进行标记 + ... + JMP ret +``` + +按照上面汇编代码以及写屏障的作用来看,写屏障最后不就是简单执行**`*slot = value`这个赋值操作,给对象加颜色吗,wbBuf队列的作用是什么呢?** + +写屏障函数`gcWriteBarrier`的**wbBuf队列其实是go1.10 左右为了性能优化而引入的,每个 P 都有一个 wbBuf 队列,**主要是将赋值的前后两个值记录下来,攒满一个 buffer ,然后批量处理,来提升效率 + +总结一下写屏障函数`gcWriteBarrier`功能: + +1. 首先写屏障会拦截写操作 + +2. 拦截完写操作之后,会记录下赋值前后的旧值与新值,存储在 p.wbBuf 队列 + +3. 当 p.wbBuf 满了之后, 批量刷入到扫描队列(置灰) + +#### **5.5.3 go语言中的屏障技术** + +在 GC 领域中,常见的写屏障(write barrier )有两种: + +1. **插入写屏障(Dijistra Insertion Barrier)** + +2. **删除写屏障(Yuasa Deletion Barrier)** + +##### 5.5.3.1 **插入写屏障(Dijistra Insertion Barrier)** + +插入写屏障的原理可以用如下伪代码表示 + +```go + // ptr 赋值给 slot +func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) { + shade(ptr) // shade(ptr) 会将新的下游对象ptr 标记为灰色。通过保守的假设 *slot 可能会变为黑色, 并确保 ptr 不会在将赋值为 *slot 前变为白色,进而确保了强三色不变性。 + *slot = ptr // 将新的下游对象ptr赋值给当前下游对象slot + } +``` + +上述伪代码可以翻译如下: + +```go +添加下游对象(当前下游对象slot, 新下游对象ptr) { + //step 1 + 将新的下游对象ptr标记为灰色 + + //step 2 + 当前下游对象slot = 新下游对象ptr +} +``` + +插入写屏障一般有两种适用场景: + +* A添加新下游对象,比如A 之前没有下游, 新添加一个下游对象B, B被标记为灰色 + +* A变更下游对象,比如A 将下游对象C 更换为B, B被标记为灰色 + +由于**指针修改时,指向的新对象要被强制标灰,**所以插入写屏障满足强三色不变式,不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色 + +> 注意:虽然插入写屏障实现非常的简单,并且也能保证强三色不变性,但却没有在栈上使用,因为栈空间的特点是容量小,但要求响应速度快,在栈上的对象增加写屏障,会大幅度增加写入指针的额外开销,所以栈空间的对象操作中不使用. 而仅仅使用在堆空间对象的操作中 + +go语言在Go 1.7 之前其实就使用的是 插入写屏障(Dijkstra Write barrier) + +假设应用程序中的对象关系如下图所示,下面用图示的方式展示插入写屏障的过程 + +1. 程序初始阶段,所有对象均标记为白色,将所有对象放入到白色集合 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-28.png) + +注意:对象E的箭头是由堆指过来,并不是表示是从堆上开始扫描,其实也是从栈上扫描的, 只不过是栈上根节点直接引用的它,它位于堆上,这里方便画图,就用堆上箭头指向它 + +* 从根节点开始遍历,把遍历到的对象标记为灰色(只遍历一次),放到灰色标记集合中,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-29.png) + +* 遍历灰色集合,将可达对象从白色标记为灰色, 放入灰色集合,并将自身标记为黑色放入黑色集合 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-30.png) + +* 由于是并发进行标记,此时对象E指向一个新的对H,对象A指向一个新的对象I,对象E在堆区,出发插入写屏障,对象A在栈区,不触发 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-31.png) + +* 插入写屏障的作用(黑色对象添加白色对象,将白色对象标记为灰色),对象H被标记为灰色,由于栈上没有开启写屏障,所以对象I仍然为白色 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-32.png) + +* 循环执行上述流程,直到灰色集合没有节点 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-33.png) + +可以看到出现了黑色对象A指向白色对象I的情况,会出现错误回收,这是由于在栈上没有开启写屏障导致的,所以在全部三色标记扫描之后,要对栈重新进行三色标记扫描, 但这次为了对象不丢失, 要对本次标记扫描启动STW. 直到栈空间的三色标记结束 + +* 在正式开始回收工作之前,此时开启STW,开始重新扫描一次栈上对象 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-34.png) + +* 对栈上的对象进行三色标记,直至没有灰色对象 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-35.png) + +* 停止STW,这次STW大约的时间在10\~100ms之间 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-36.png) + +* 将剩余的白色节点全部清除 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-37.png) + +##### 5.5.3.2 **删除写屏障(Yuasa Deletion Barrier)** + +删除写屏障的原理可以用如下伪代码表示 + +```go + func YuasaWB(slot *unsafe.Pointer, ptr unsafe.Pointer){ + shade(*slot) // 将*slot标记为灰色,即指针修改时,修改前指向的对象要标灰 + *slot = ptr // 将新的下游对象ptr赋值给当前下游对象slot + } +``` + +上述伪代码可以翻译如下: + +```go +添加下游对象(当前下游对象slot, 新下游对象ptr) { + //step 1 + if (当前下游对象slot是灰色 || 当前下游对象slot是白色){ + 标记灰色(当前下游对象slot) //slot为当前下游对象,即将被删除, 标记为灰色 + } + + } + //step 2 + 当前下游对象slot = 新下游对象ptr +} +``` + +删除写屏障一般有两种适用场景: + +* A删除下游对象,比如A 之前的下游对象B被删除, B被标记为灰色 + +* A变更下游对象,比如A 将下游对象C 更换为B, C被标记为灰色 + +由于**指针修改时,修改前指向的对象要标灰,即被删除的对象,如果自身为灰色或者白色,那么被标记为灰色;**所以满足弱三色不变性,因为保护灰色对象到白色对象的路径不会断 + +注意,虽然删除写屏障也是GC领域常见的屏障技术,但是go语言 在GC中从来没有直接使用删除写屏障 + +假设应用程序中的对象关系如下图所示,下面用图示的方式展示删除写屏障的过程 + +1. 程序初始阶段,所有对象均标记为白色,将所有对象放入到白色集合 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-38.png) + +* 从根节点开始按照三色标记遍历,标记到如下图所示的状态: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-39.png) + +* 此时用户程序将原本对象E指向对象F的指针指向对象G,即对象E删除对象F,添加对象;触发删除写屏障,由于对象F已经被标记为灰色,所以不做改变,如下图所示 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-40.png) + +* 用户程序将对象F指向对象G的指针删除,触发删除写屏障,对象G被标记为灰色,如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-41.png) + +* 按照三色标记法和删除写屏障的的步骤继续往下执行,最终得到的结果如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-42.png) + +可以看到由于有删除写屏障的存在,可以准确回收掉不需要的内存,不会出现错误回收的情况,但是却会出现回收精度低的问题。同时注意看上图,对象F其实是一个孤立的对象,不会再使用了,却最终被标记为了黑色,没有被回收掉,只能通过下一轮GC的时候将其回收,所以,删除写屏障虽然不需要在标记结束后开启STW,但却存在回收精度低的问题 + +###### 5.5.3.2.1 **删除写屏障是否真的不需要STW** + +前面分析了,删除写屏障虽然不需要在标记结束后开启STW,是否意味着单独使用删除写屏障进行GC就不需要STW了呢? + +其实并非如此,删除写屏障又叫做基于其实快照的解决方案(snapshot-at-the-begining)。删除写屏障(基于起始快照的写屏障)在使用的时候有一个前提条件,就是必须起始的时候,把整个根部扫描一遍,做一次快照,让所有的可达对象全都在灰色保护下(根对象全为黑,下一级在堆上的对象全灰),之后利用删除写屏障拦截内存写操作,确保弱三色不变式不被破坏,这样就绝对不会有被误回收的对象,就可以保证垃圾回收的正确性。而在起始阶段,扫描整个根节点,做快照的时候是要进行STW的,所以从整体上看删除写屏障同样要进行STW + +下面通过一个简单的图示看一下,经过了STW快照之后的,删除写屏障过程 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-43.png) + +1. 起始阶段,根节点全黑,堆上一级节点全灰 + +2. 开始三色标记 + +3. 新增黑色对象A指向白色对象C + +4. 对象B删除对象C,触发删除写屏障,对象C置灰,避免黑色对象引用白色对象 + +**总结**: + +1. 删除写屏障必须在起始阶段,开启STW来 扫描整个栈(注意所有的 goroutine 栈,所以整个用户程序所有的goroutine都要扫描,所以是STW,STW是让用户所有的goroutine全部停止),来确保堆上的一级对象全灰,保证**所有堆上在用的对象**都处于灰色保护下,才能保证弱三色不变性 + +2. 删除写屏障需要在起始阶段执开启STW 扫描,因此不适合具有大栈内存的场景,栈越大,扫描时间越久,比如现代服务器程序栈内存都相对较大,所以都不太适合删除写屏障。相反,它更适合栈内存较小的环境,如嵌入式系统和物联网设备,因为它们的栈空间有限,STW 时间较短 + +3. 删除写屏障的可能会有遗漏对象需要在下一轮GC才可能被回收,所以税收精度相对较低,不如插入写屏障 + +**思考题:前面分析在使用删除写屏障之前需要STW来扫描整个栈空间,获取快照?那是否可以不一次性暂停所有的goroutine,而是一个栈一个栈的暂停获取快照呢?这样就不用STW了** + +先看个具体例子: + +1. 对象A 是 goroutine1 里栈上的一个对象,goroutine1里的栈空间已经扫描完了,并且 对象C 也扫描完标记为黑色的对象; + +2. 对象B 是 goroutine2里 栈上的对象,指向了goroutine1里的对象 C 和goroutine2里的对象 D,由于goroutine2 还没有完全扫描,对象B 是一个灰色对象,对象D 是白色对象 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-44.png) + +此时假设执行以下步骤: + +第一步:goroutine2 进行赋值变更,把 对象C 指向 D 对象,此时黑色对象 C 就指向了白色的 D(这里是删除屏障,而不是插入写屏障,所以新增引用不会触发任何操作) + +第二步:删除对象 B 到对象D的引用,由于是栈对象操作,不会触发删除写屏障 + +执行完上述两个,结果如下图: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-45.png) + +由于对象C 已经是黑色对象了,所以不会再扫描,所以对象 D 就会被错误的清理掉 + +**所以**,**一个栈一个栈的暂停获取快照是不可行的,必须STW,暂停用户程序的所有goroutine的栈空间,来打快照,把所有的堆对象都处于灰色保护中才行** + +##### 5.5.3.3 **混合写屏障** + +仔细思考下删除写平展的弊端和流程,发现它最大的问题就是起始阶段的栈空间扫描,要进行STW暂停整个的用户程序,而一个一个的栈暂停又会出现问题,那有没有什么办法可以解决这个问题呢? + +回顾上面删除写屏障的例子的第一步,在把对象C 指向 D 对象的时候,正是由于此时是删除写屏障,所以导致黑色对象 C 就指向了白色的 D而没有任何操作,那如果在这里加入插入写屏障的逻辑,此时对象D就会被置灰,尽管后面对象C已经是黑色不会再被扫描,也不会出现黑色对象指向白色对象的情况了 + +仔细想想,这里引入的插入写屏障还是在一个一个的栈暂停的情况下的,是不需要进行STW的。这里既用到了删除写屏障,又用到了插入写屏障,这不就是混合写屏障吗? + +**混合写屏障 = 删除写屏障 + 插入写屏障** + +**混合写屏障模式下,利用删除写屏障避免了插入写屏障的STW问题(全部三色标记扫描之后,要STW对栈重新进行三色标记扫描),又利用插入写屏障避免了删除写屏障的STW问题(使用删除写屏障之前需要STW垃圾扫描整个栈空间,获取快照,把所有的堆对象都处于灰色保护中),这样就完美解决了屏障技术带来的STW问题** + +上面只是从感官上分析了插入写屏障和删除写屏障的结合,解决了STW的问题,但其实混合写屏障不仅仅是做了这两点 + +Go 在Go V1.8版本时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,引入了混合写屏障机制(hybrid write barrier)。 + +混合写屏障的基本思想是:**对正在被覆盖的对象进行着色,且如果当前栈未扫描完成,则同样对指针进行着色。** + +混合写屏障的原理可以用如下伪代码表示 + +```go +func HybridWritePointerSimple(slot *unsafe.Pointer, ptr unsafe.Pointer) { + shade(*slot) + shade(ptr) + *slot = ptr +} +``` + +可以做如下翻译 + +```go +添加下游对象(当前下游对象slot, 新下游对象ptr) { +//step1 +标记灰色(当前下游对象slot) //只要当前下游对象被移走,就标记灰色 + +//step2 +标记灰色(新下游对象ptr) //新下游对象标记为灰色 + +//3step +当前下游对象slot = 新下游对象ptr +} +``` + +意思就是在垃圾回收阶段,只要是堆上的一个赋值 `*slot = ptr` 那么都会被拦截住,然后把旧值(`*slot`)指向的对象,和新值(ptr)指向的对象都置灰(投到扫描队列) + +所以混合写屏障的流程可以总结为: + +* **GC 开始将栈上的对象全部扫描并标记为黑色(注意是一个一个栈标记,不是所有栈全部暂停标记)** + +* **GC 期间,任何在栈上创建的新对象,均标记为黑色** + +* **被删除的堆对象标记为灰色** + +* **被添加的堆对象标记为灰色** + +假设应用程序中的对象关系如下图所示,下面用图示的方式展示混合写屏障的过程 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-46.png) + +1. GC开始扫描站对象,将所有栈上可达对象A,B,C标记为黑色 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-47.png) + +* GC期间栈上新创建的对象K被标记为黑色 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-48.png) + +* 进行三色标记到如下阶段,对象E被标记为黑色,对象F被标记为灰色 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-49.png) + +* 此时,对象E新增指针指向对象I,对象F删除对对象H的指针引用,触发混合写屏障,对象I标记为灰色,对象H标记为灰色 + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-50.png) + +* 按照三色标记法和混合写屏障的的步骤继续往下执行,最终得到的结果如下图所示: + +![](https://golangstar.cn/assets/img/go语言系列/垃圾回收/image-51.png) + +最终垃圾对象为白色对象D,G,将会被垃圾回收器清理掉 + +**总结:** + +1. 混合写屏障结合了插入和删除写屏障的优势。它不需要在开始时进行全局的STW操作来创建快照,也不需要在三色标记扫描之后,进行STW对栈重新进行三色标记扫描 + +2. 混合写屏障保留了删除写屏障的功能,所以其扫描精度比插入写屏障低,但它实现了整个GC过程中无STW + +3. 虽然混合写屏障整个GC过程中无STW,然而在扫描某个特定的栈时,还是需要暂时停止该goroutine的赋值器操作,以确保状态的原子切换 + +### **5.6 三色标记对应的程序本质** + +其实在go语言的GC程序里面是没有所谓的黑色,白色,灰色属性的,这三个颜色属性认为抽象出来的概念,帮助我们理解三色标记的过程 + +在go语言GC程序内部,对这三种颜色的对象状态的描述,是通过一个队列 + 掩码位图 来实现的: + +* 白色对象:对象所在 span 的 gcmarkBits 中对应的 bit 为 0,并且该对象不在扫描队列中 + +* 灰色对象:对象所在 span 的 gcmarkBits 中对应的 bit 为 1,并且该对象在扫描队列中 + +* 黑色对象:对象所在 span 的 gcmarkBits 中对应的 bit 为 1,并且该对象已经从扫描队列中处理并剔除掉 + +### **5.7 GC的完整流程** + +GO语言GC 相关的代码在`runtime/mgc.go`文件中,一共分为分为`清除终止`(SweepTermination), `标记`(Mark), `标记终止`(MarkTermination), 和 `清除`(Sweep) 四个不同阶段, 它们分别完成了不同的工作: + +1. **清除终止(SweepTermination)** + +这个阶段的主要任务是结束上一个GC周期的清除工作,并为新的GC周期做准备。 + +* **标记(Mark)** + + * 将GC状态从**\``` _GCoff` ``**`更改为`**`` `_GCmark ``\`**,并且开启写屏障(Write Barrier)和协助线程(mutator assists),紧接着将根对象入队 + + * 恢复用户程序的正常执行,标记进程(mark workers)和协助线程(mutator assists)开始发地标记内存中的对象,写屏障会将被覆盖的指针和新指针都标记成灰色, 而所有新创建的对象都会直接标记成黑色 + + * 遍历灰色对象集合,将灰色对象标记为黑色,并将该对象指向的对象标记为灰色 + + * 使用分布式终止算法(distributed termination algorithm)来检测剩余工作,即何时不再有根标记作业或灰色对象,如果没有了则转为标记终止(MarkTermination)阶段 + +* **标记终止(MarkTermination)** + + * 执行STW + + * 将GC状态切换至** \``_GCmarktermination`\`**,关闭 GC 工作线程以及 mutator assists(协助线程) + + * 清理处理器P上的缓存(mcache) + +* **清除(Sweep)** + + * 将 GC 状态切换至 **\``` _GCoff` ``**,初始化清理状态并关闭写屏障(Write Barrier) + + * 恢复用户程序的正常执行,从此时开始所有新创建的对象会标记成白色 + + * 后台并发清理所有的内存管理单元,当应用程序goroutine尝试在堆内存中分配新内存时,会触发该操作 + +### **5.8 GC的触发时机** + +GO语言中GC的触发分为手动触发和被动触发两种 + +1. **手动触发**,通过调用 runtime.GC() 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕 + +2. **被动触发**,分为两种方式: + + * go后台有一系统监控线程,当超过两分钟没有产生任何 GC 时,强制触发 GC。 + + * 使用步调算法,通过内存增长的比例来触发GC,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC + + * 我们可以通过debug.SetGCPercent(500)来修改步调,这里表示,如果当前堆大小超过了上次标记的堆大小的500%,就会触发 + + * 而第一次GC的触发的堆内存临界值是4MB + +总结: + +1、监控线程 runtime.sysmon 定时调用 + +2、手动调用 runtime.GC 函数进行垃圾收集 + +3、申请内存时 runtime.mallocgc 会根据堆大小判断是否调用 + +### **5.9 小结** + +GO语言GC总体上来说是采用的并行三色标记法+混合写屏障机制来实现的,内存写屏障是由插入写屏障向混合写屏障过渡的,go语言在Go 1.7 之前其实就使用的是插入写屏障(Dijkstra Write barrier),在Go V1.8版本引入了混合写屏障。 + +1. 插入写屏障没有完全保证完整的强三色不变式,由于性能影响,栈上对象没有开启写屏障,所以三色标记完成之后,最后必须 STW 重新扫描栈 + +2. 混合写屏障消除了屏障过程中所有的 STW,不用 STW 扫描栈,但由于引入了删除想屏障,所以损失了一定的回收精度,其回收精度和删除写屏障的一致,比插入写屏障要低 + +3. 混合写屏障扫描栈的方式是逐个暂停扫描的,不需要STW + + + diff --git a/hzh/GolangStar/Go语言原理/程序初始化.md b/hzh/GolangStar/Go语言原理/程序初始化.md new file mode 100644 index 0000000..acdf068 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/程序初始化.md @@ -0,0 +1,132 @@ +--- +tags: + - Go + - golang + - go原理深入 + - 程序初始化 +--- + +# 程序初始化 + +Go应用程序的初始化是在单一的`goroutine`中执行的。对于包这一级别的初始化来说,在一个包里会先进行包级别变量的初始化。一个包下可以有多个`init`函数,每个文件也可以有多个`init` 函数,多个 `init` 函数按照它们的文件名顺序逐个初始化。但是程序不可能把所有代码都放在一个包里,通常都是会引入很多包。如果`main`包引入了`pkg1`包,`pkg1`包本身又导入了包`pkg2`,那么应用程序的初始化会按照什么顺序来执行呢? + +对于这个初始化过程我粗略的画了一个示意图,理解起来更直观些。 + +![程序初始化](https://golangstar.cn/assets/img/go语言系列/程序初始化/程序初始化.png) + +图的上半部分表示了`main`包导入了`pkg1`包,`pkg1`包又导入了`pkg2`包这样一个包之间的依赖关系。图的下半部分表示了,这个应用初始化工作的执行时间顺序是从被导入的最深层包开始进行初始化,层层递出最后到`main`包,每个包内部的初始化程序依然是先执行包变量初始化再进行`init`函数的执行。 + +下面通过示例来验证一下这个初始化顺序,在`go_tour`目录下有三个包`package1`和`package2`和`utils`,代码目录如下: + +``` +├─package1 +├─package2 +└─utils +``` + +分别定义测试函数,在`utils`包下有文件`utils.go`,在`package1`包下有文件`package1.go`,在`package2`包下有文件`package2.go`。 + +```go +package utils + +import "fmt" + +func TraceLog(t string, v int) int { + fmt.Printf("TraceLog-----%s--------%d\n", t, v) + return v +} +``` + +package1包下有如下程序package1.go: + +```go +package package1 + +import ( + "fmt" + "go_tour/package2" + "go_tour/utils" +) + +var V1 = utils.TraceLog("init package1 value1", package2.Value1+10) +var V2 = utils.TraceLog("init package1 value2", package2.Value2+10) + +func init() { + fmt.Println("init func in package1") +} +``` + +package2包下有如下程序package2.go: + +```go +package package2 + +import ( + "fmt" + "go_tour/utils" +) + +var Value1 = utils.TraceLog("init package2 value1", 20) +var Value2 = utils.TraceLog("init package2 value2", 30) + +func init() { + fmt.Println("init func1 in package2") +} + +func init() { + fmt.Println("init func2 in package2") +} +``` + +主程序`main.go`: + +```go +package main + +import ( + "fmt" + "go_tour/package1" + "go_tour/utils" +) + +func init() { + fmt.Println("init func1 in main") +} + +func init() { + fmt.Println("init func2 in main") +} + +var MainValue1 = utils.TraceLog("init M_v1", package1.V1+10) +var MainValue2 = utils.TraceLog("init M_v2", package1.V2+10) + +func main() { + fmt.Println("main func in main") +} +``` + +执行`go run main.go`,输出结果如下: + +``` +TraceLog-----init package2 value1--------20 +TraceLog-----init package2 value2--------30 +init func1 in package2 +init func2 in package2 +TraceLog-----init package1 value1--------30 +TraceLog-----init package1 value2--------40 +init func in package1 +TraceLog-----init M_v1--------40 +TraceLog-----init M_v2--------50 +init func1 in main +init func2 in main +main func in main +``` + +实验与结论相符合,按照i导入包的层次,最先被依赖的包最先被初始化,且初始化的顺序是先初始化包变量,再说初始化`init`函数。初始化过程总结如下: + +- **包级别变量的初始化先于包内`init`函数的执行。** +- **一个包下可以有多个`init`函数,每个文件也可以有多个`init` 函数。** +- **多个 `init` 函数按照它们的文件名顺序逐个初始化。** +- **应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到main包。** +- **不管包被导入多少次,包内的`init`函数只会执行一次。** +- **应用在所有初始化工作完成后才会执行`main`函数。** diff --git a/hzh/GolangStar/Go语言原理/逃逸分析.md b/hzh/GolangStar/Go语言原理/逃逸分析.md new file mode 100644 index 0000000..c511d4c --- /dev/null +++ b/hzh/GolangStar/Go语言原理/逃逸分析.md @@ -0,0 +1,184 @@ +--- +tags: + - Go + - golang + - go原理深入 + - 逃逸分析 +--- + +# 逃逸分析 + +## **什么是逃逸** + +> **`逃逸分析是编译器用于决定变量分配到堆上还是栈上的一种行为。`** + +函数的运行都是在栈上面运行的,在栈上面声明临时变量,分配内存,函数运行完毕之后,回收内存,每个函数的栈空间都是独立的,其他函数是无法进行访问,但是在某些情况下栈上面的数据需要在函数结束之后还能被访问,这时候就会设计到内存逃逸了,什么是逃逸,就是抓不住 + +如果变量从栈上面逃逸,会跑到堆上面,栈上面的变量在函数结束的时候回自动回收,回收代价比较小,栈的内存分配和使用一般只需要两个CPU指令“PUSH”和“RELEASE”,分配和释放,而堆分配内存,则是首先需要找到一块大小合适的内存,之后通过GC回收才能释放,对于这种情况,频繁的使用垃圾回收,则会占用比较大的系统开销,所以尽量分配内存到栈上面,减少gc的压力,提高程序运行速度 + +## **逃逸分析过程** + +Go语言最基本的逃逸分析原则:如果一个函数返回一个对变量的引用,那么它就会发生逃逸。 + +在任何情况下,如果一个值被分配到了栈之外的地方,那么一定是到了堆上面。简而概之:编译器会分析代码的特征和代码生命周期,Go中的变量只有在编译器可以证明在函数返回后不会再被引用的,才分配到栈上,其他情况下都是分配到堆上 + +Go语言里面没有一个关键字或者函数可以直接让变量被编译器分配到堆上,相反,编译器是通过分析代码来决定将变量分配到何处。 + +简单来说,编译器会根据变量是否被外部引用来决定是否逃逸: + +如果函数外部没有引用,则优先放到栈中; +如果函数外部存在引用,则必定放到堆中; + +## **指针逃逸** + +我们知道传递指针可以减少底层值的拷贝,可以提高效率,但是如果拷贝的数据量小,由于指针传递会产生逃逸,可能会使用堆,也可能会增加GC的负担,所以传递指针不一定是高效的。 + +如下实例 + +```go +package main + +type Student struct { + Name string + Age int +} + +func StudentRegister(name string, age int) *Student { + s := new(Student) //局部变量s逃逸到堆 + + s.Name = name + s.Age = age + + return s +} + +func main() { + StudentRegister("Jim", 18) +} +``` + +虽然在函数 StudentRegister() 内部 s 为局部变量,其值通过函数返回值返回,s 本身为一指针,其指向的内存地址不会是栈而是堆,这就是典型的逃逸案例。 + +> **使用命令 go build -gcflags '-m -l' main.go** + +![](https://golangstar.cn/assets/img/go语言系列/逃逸分析/image-4.png) + + +”escapes to heap”,代表该行内存分配发生了逃逸现象。 + +## 栈空间不足 + +```go +package main + +func MakeSlice() { + s := make([]int, 100, 100) + + for index, _ := range s { + s[index] = index + } +} + +func main() { + MakeSlice() +} +``` + +执行 + +```go +go build --gcflags '-m -l' trip.go +``` + +分析结果: + +![](https://golangstar.cn/assets/img/go语言系列/逃逸分析/image.png) + +此时栈空间充足,slice分配在栈上,未发生逃逸,假设将slice扩大100倍,再看一下 + +```go +package main + +func MakeSlice() { + s := make([]int, 10000, 10000) + + for index, _ := range s { + s[index] = index + } +} + +func main() { + MakeSlice() +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/逃逸分析/image-1.png) + +此时,分配的slice容量太大,当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中 + +## **动态类型逃逸** + +很多函数参数为interface类型。比如: + +```go +func Printf(format string, a ...interface{}) (n int, err error) +func Sprintf(format string, a ...interface{}) string +func Fprint(w io.Writer, a ...interface{}) (n int, err error) +func Print(a ...interface{}) (n int, err error) +func Println(a ...interface{}) (n int, err error) +``` + +编译期间很难确定其参数的具体类型,也能产生逃逸。 + +![](https://golangstar.cn/assets/img/go语言系列/逃逸分析/image-2.png) + +## **变量大小不确定** + +在创建切片的时候,初始化切片容量的时候,传入一个变量来指定其大小,由于变量的值不能在编译器确定,所以就不能确定其占用空间的大小,直接将对象分配在堆上 + +```go +package main + +func MakeSlice() { + length := 1 + a := make([]int, length, length) + for i := 0; i < length; i++ { + a[i] = i + } +} + +func main() { + MakeSlice() +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/逃逸分析/image-3.png) + +## **逃逸常见情况** + +1. 指针逃逸,函数内部返回一个局部变量指针 + +2. 分配大对象,导致栈空间不足,不得不分配到堆上 + +3. 调用接口类型的方法。接口类型的方法调用是动态调度 - 实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上。 + +4. 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上 + +## **如何避免** + +1. go 中的接口类型的方法调用是动态调度,因此不能够在编译阶段确定,所有类型结构转换成接口的过程会涉及到内存逃逸的情况发生。如果对于性能要求比较高且访问频次比较高的函数调用,应该尽量避免使用接口类型 + +2. 由于切片一般都是使用在函数传递的场景下,而且切片在 append 的时候可能会涉及到重新分配内存,如果切片在编译期间的大小不能够确认或者大小超出栈的限制,多数情况下都会分配到堆上 + +## **总结** + +1. 堆上动态分配内存比栈上静态分配内存,开销大很多。 + +2. 变量分配在栈上需要能在编译期确定它的作用域,否则会分配到堆上。 + +3. Go编译器会在编译期对考察变量的作用域,并作一系列检查,如果它的作用域在运行期间对编译器一直是可知的,那么就会分配到栈上。简单来说,编译器会根据变量是否被外部引用来决定是否逃逸。 + +4. 对于Go程序员来说,编译器的这些逃逸分析规则不需要掌握,我们只需通过go build -gcflags '-m’命令来观察变量逃逸情况就行了 + +5. 不要盲目使用变量的指针作为函数参数,虽然它会减少复制操作。但其实当参数为变量自身的时候,复制是在栈上完成的操作,开销远比变量逃逸后动态地在堆上分配内存少的多。 + diff --git a/hzh/GolangStar/Go语言基础/Go语言Map.md b/hzh/GolangStar/Go语言基础/Go语言Map.md new file mode 100644 index 0000000..19705f4 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言Map.md @@ -0,0 +1,356 @@ +--- +tags: + - Go + - golang + - go基础语法 + - map + - 哈希表 +--- + +# Go语言Map + +Map是Go语言中的一个重要的数据结构,它提供了键值对的存储方式,类似其他语言中的哈希表或字典。 + +## Map的本质:"键值对储物间" + +### 什么是Map? +可以把Map想象成一个特殊的储物间,每个物品(值)都有自己的标签(键)。通过这个标签,我们可以快速找到对应的物品。比如:一个存储学生成绩的系统,学号就是键,分数就是值。 + +### Map的定义方式 +在Go语言中,定义Map有几种常用方式: + +```go +// 1. 使用make函数创建 +scoreMap := make(map[string]int) + +// 2. 创建时直接初始化 +studentScores := map[string]int{ + "张三": 95, + "李四": 88, + "王五": 92, +} + +// 3. 声明一个空map +var prices map[string]float64 +// 注意:声明后需要通过make初始化才能使用 +prices = make(map[string]float64) +``` + +### Map的基本操作 + +```go +package main + +import "fmt" + +func main() { + // 创建一个存储水果价格的map + fruitPrices := make(map[string]float64) + + // 添加键值对 + fruitPrices["苹果"] = 5.5 + fruitPrices["香蕉"] = 3.8 + fruitPrices["橙子"] = 4.2 + + // 获取值 + applePrice := fruitPrices["苹果"] + fmt.Printf("苹果的价格是:%.2f元\n", applePrice) + + // 修改值 + fruitPrices["苹果"] = 5.8 + + // 删除键值对 + delete(fruitPrices, "香蕉") + + // 遍历map + for fruit, price := range fruitPrices { + fmt.Printf("%s的价格是:%.2f元\n", fruit, price) + } +} +``` +运行结果: +``` +苹果的价格是:5.50元 +苹果的价格是:5.80元 +橙子的价格是:4.20元 +``` + +### 判断键是否存在 +在Go语言中,访问map中不存在的键会返回该类型的零值。因此,我们需要一种方法来判断键是否真实存在: + +```go +package main + +import "fmt" + +func main() { + userAge := map[string]int{ + "Alice": 25, + "Bob": 30, + } + + // 使用两个变量接收返回值 + age, exists := userAge["Tom"] + if exists { + fmt.Printf("Tom的年龄是:%d\n", age) + } else { + fmt.Println("Tom不在map中") + } +} +``` +运行结果: +``` +Tom不在map中 +``` + +## Map的进阶用法 + +### 1. 嵌套Map +Map的值可以是另一个Map,这样就形成了嵌套结构: + +```go +package main + +import "fmt" + +func main() { + // 创建一个存储学生成绩的嵌套map + studentScores := map[string]map[string]int{ + "张三": { + "数学": 95, + "英语": 88, + "语文": 92, + }, + "李四": { + "数学": 90, + "英语": 85, + "语文": 88, + }, + } + + // 获取张三的英语成绩 + englishScore := studentScores["张三"]["英语"] + fmt.Printf("张三的英语成绩是:%d\n", englishScore) +} +``` +运行结果: +``` +张三的英语成绩是:88 +``` + +### 2. Map作为函数参数 +Map是引用类型,作为函数参数时传递的是底层指针的拷贝: + +```go +func updatePrice(prices map[string]float64, fruit string, price float64) { + prices[fruit] = price +} + +func main() { + fruitPrices := map[string]float64{ + "苹果": 5.5, + "香蕉": 3.8, + } + + updatePrice(fruitPrices, "苹果", 6.0) + fmt.Printf("更新后苹果的价格:%.2f\n", fruitPrices["苹果"]) +} +``` + +## 使用技巧和注意事项 + +1. 初始化容量 +```go +// 如果知道map大约需要存储多少键值对,可以在创建时指定容量 +userAges := make(map[string]int, 100) +``` + +2. 并发安全 +```go +// map不是并发安全的,需要使用sync.Map或互斥锁来保证并发安全 +import "sync" +var mutex sync.Mutex +mutex.Lock() +map操作 +mutex.Unlock() +``` + +3. 常见陷阱 +- map的零值是nil,需要先初始化才能使用 +- 不能对map的元素取地址 +- map的遍历顺序是随机的,不要依赖遍历顺序 + +### 1. Map的随机遍历 +```go +package main + +import "fmt" + +func main() { + // 创建一个简单的map + m := map[string]int{ + "a": 1, + "b": 2, + "c": 3, + "d": 4, + } + + // 连续遍历三次,观察输出顺序 + for i := 0; i < 3; i++ { + fmt.Printf("第%d次遍历:\n", i+1) + for k, v := range m { + fmt.Printf("key: %s, value: %d\n", k, v) + } + fmt.Println() + } +} +``` +运行结果: +``` +第1次遍历: +key: a, value: 1 +key: d, value: 4 +key: b, value: 2 +key: c, value: 3 + +第2次遍历: +key: c, value: 3 +key: a, value: 1 +key: d, value: 4 +key: b, value: 2 + +第3次遍历: +key: b, value: 2 +key: c, value: 3 +key: d, value: 4 +key: a, value: 1 +``` +从输出可以看到,每次遍历的顺序都是不同的,这就是map遍历的随机性。 + +### 2. Map的并发安全问题 +这个部分涉及到并发编程,将在后续章节中详细介绍,这里只做简单介绍,如果不是很理解的话,可以等后面学习了并发编程之后再回来看。 +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +func main() { + // 不安全的map操作 + unsafeMap := make(map[int]int) + + // 使用WaitGroup等待所有goroutine完成 + var wg sync.WaitGroup + + // 启动10个goroutine同时写map + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + unsafeMap[n] = n // 可能导致panic + time.Sleep(time.Millisecond) + }(i) + } + + // 安全的map操作 + var mutex sync.Mutex + safeMap := make(map[int]int) + + // 再次启动10个goroutine,这次使用互斥锁保护 + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + mutex.Lock() + safeMap[n] = n // 安全的写入 + mutex.Unlock() + time.Sleep(time.Millisecond) + }(i) + } + + wg.Wait() + fmt.Printf("安全的map最终内容: %v\n", safeMap) +} +``` +运行结果: +``` +fatal error: concurrent map writes // 第一个map可能会panic +安全的map最终内容: map[0:0 1:1 2:2 3:3 4:4 5:5 6:6 7:7 8:8 9:9] // 第二个map安全执行 +``` + +或者使用sync.Map: +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + var sm sync.Map + var wg sync.WaitGroup + + // 启动10个goroutine同时操作sync.Map + for i := 0; i < 10; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + sm.Store(n, n) // 存储键值对 + }(i) + } + + wg.Wait() + + // 遍历sync.Map + sm.Range(func(key, value interface{}) bool { + fmt.Printf("key: %v, value: %v\n", key, value) + return true + }) +} +``` +运行结果: +``` +key: 5, value: 5 +key: 0, value: 0 +key: 1, value: 1 +key: 2, value: 2 +key: 3, value: 3 +key: 4, value: 4 +key: 6, value: 6 +key: 7, value: 7 +key: 8, value: 8 +key: 9, value: 9 +``` + +## 实际应用场景 + +1. 缓存系统 +```go +cache := make(map[string]interface{}) +``` + +2. 计数器 +```go +wordCount := make(map[string]int) +for _, word := range words { + wordCount[word]++ +} +``` + +3. 去重 +```go +seen := make(map[string]bool) +for _, item := range items { + seen[item] = true +} +``` + +## 小结 + +Map是Go语言中一个强大而灵活的数据结构,它提供了高效的键值对存储方案。在实际开发中,Map的应用非常广泛,从简单的数据存储到复杂的缓存系统都能见到它的身影。掌握Map的使用,对于Go程序开发来说是必不可少的。 + +记住:合理使用Map可以让我们的代码更加简洁高效,但也要注意并发安全等问题。选择合适的数据结构和使用方式,才能写出更好的程序。 \ No newline at end of file diff --git a/hzh/GolangStar/Go语言基础/Go语言defer.md b/hzh/GolangStar/Go语言基础/Go语言defer.md new file mode 100644 index 0000000..188bb14 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言defer.md @@ -0,0 +1,301 @@ +--- +tags: + - Go + - golang + - go基础语法 + - defer +--- + +# Go语言defer + +## defer关键字 +`defer`顾名思义,延迟。它是go语言中的一个关键字,主要用在函数或方法前面,作用是用于函数和方法的延迟调用,在语法上,`defer`与普通的函数调用没有什么区别。 +在使用上非常简单,只需要弄清楚以下几点即可: +1. 延迟的函数的什么时候被调用? + 1. 函数`return`的时候 + 2. 发生`panic`的时候 +2. 延迟调用的语法规则 + 1. `defer`关键字后面表达式必须是函数或者方法调用 + 2. 延迟内容不能被括号括起来 + + ## defer执行顺序 +上一小节说到`defer`关键字后面的函数调用会在函数`return`或者发生`panic`的时候执行,这个在单个`defer`的时候很好理解,但当一个函数中有多个`defer`的时候,他们的顺序是怎么样的呢? +`defer`语句的执行顺序是先进后出LIFO。下面请看具体例子: + ```go + package main + +import "fmt" + +func defer1() { + fmt.Println("defer1") +} + +func defer2() { + fmt.Println("defer2") +} + +func defer3() { + fmt.Println("defer3") +} + +func main() { + defer defer1() + defer defer2() + defer defer3() +} +``` +运行结果: +``` +defer3 +defer2 +defer1 +``` +可以看到执行顺序跟栈是一样的,先调用,后执行 + +## defer的使用场景 +通过前面的小节我们知道了`defer`关键字主要是用于延迟调用,那么什么场景下需要我们用到延迟调用了,有过Go语言基础的同学在一些代码中经常看到`defer`关键字。`defer`关键字一般用在以下两个场景中 + +### 资源的释放 +通过`defer`延迟调用机制,我们可以简洁优雅处理资源回收问题,从而避免在复杂的代码逻辑情况下,遗漏相关的资源回收问题,用的比较多的就是类似网络连接,数据库连接,以及文件句柄的资源的释放。 +看看下面一个复制文件的函数: +```go +func CopyFile(dstFile, srcFile string) (wr int64, err error) { + src, err := os.Open(srcFile) + if err != nil { + return + } + dst, err := os.Create(dstFile) + if err != nil { + return + } + wr, err = io.Copy(dst, src) + dst.Close() + src.Close() + return +} +``` +仔细看这段代码,其实是有问题的,比如当地6行执行失败,程序直接返回了,但我们并没有关闭前面打开的文件资源src,这样就造成了资源的浪费。 +那么用`defer`关键字,我们可以怎么做呢? +```go +func CopyFile(dstFile, srcFile string) (wr int64, err error) { + src, err := os.Open(srcFile) + if err != nil { + return + } + defer src.Close() + + dst, err := os.Create(dstFile) + if err != nil { + return + } + defer dst.Close() + + wr, err = io.Copy(dst, src) + return wr, err +} +``` +只要我们正确打开了某个资源,比如`src`和`dst`,没返回`err`的情况下,都可以用`defer`延迟调用来关闭资源,注意,这是`go`语言中非常常见的一种资源关闭方式。 + +### 配合recover一起处理panic +`defer`另一个常用的地方就是在处理程序`panic`的时候,关于程序的异常捕获我们将在下一个小节讲到,这里大家可以先了解一下,`go`语言中用`panic`来抛出异常,用`recover`来捕获异常,所以当我们的程序出现异常的时候,我们需要知道是发生了什么异常的时候,就可以用`defer recover`来捕获异常 + +```go +package main + +import "fmt" + +func main() { + defer func() { + if r := recover(); r != nil { + fmt.Println(r) + } + }() + a := 1 + b := 0 + fmt.Println("result:", a/b) +} +``` +运行结果: +``` +result: runtime error: integer divide by zero +``` +可以看到,程序并没有输出`result`,这是因为我们尝试对一个除数为0的数做除法,这是不允许的,所以程序回panic,但我们用`defer`在程序发生`panic`的时候捕获了这个异常,打印出异常信息:`runtime error: integer divide by zero`。 + +## defer与return +前面第一小节我们介绍过`defer`函数的执行是在`return`的时候,那么在具体一点,在`return`的时候,`defer`具体做了什么?又会带来什么结果?这是一个非常值得探讨的问题,也是面试官在面试中经常会问的问题,往往通过这个问题就可一看出一个面试者对`go`语言掌握的扎不扎实。 +1. 例子1: +```go +package main + +import "fmt" + + +func deferRun() { + var num = 1 + defer fmt.Printf("num is %d", num) + + num = 2 + return +} + +func main(){ + deferRun() +} +``` +运行结果: +``` +num is 1 +``` +为什么? +延迟函数 `defer fmt.Printf("num is %d", num)` 的参数`num`在`defer`语句出现的时候就已经确定,`num=1`,所以不管后面怎么修改 `a` 的值,最终调用`defer`函数传递给`defer`函数的参数已经固定是`1`了,不会再变化。 + +2. 例子2: +```go +package main + +import "fmt" + +func main() { + deferRun() +} + +func deferRun() { + var arr = [4]int{1, 2, 3, 4} + defer printArr(&arr) + + arr[0] = 100 + return +} + +func printArr(arr *[4]int) { + for i := range arr { + fmt.Println(arr[i]) + } +} +``` +运行结果: +``` +100 +2 +3 +4 +``` +为什么? +通过前一个地址,我们知道在defer出现的时候,参数已经确定,但是这里传递的是地址,地址没变,但是地址对应的内容被修改了,所以输出会被修改。 + +3. 例子3: +```go +package main + +import "fmt" + +func main() { + res := deferRun() + fmt.Println(res) +} + +func deferRun() (res int) { + num := 1 + + defer func() { + res++ + }() + + return num +} +``` +运行结果: +``` +2 +``` +为什么? +这是一个非常经典的例子,要想准确的的只程序的执行结果,需要我们对函数return的执行有一个细致的了解。其实函数的return并非一个原子操作,return的过程可以被分解为以下三步: +1. 设置返回值 +2. 执行defer语句 +3. 将结果返回 +所以,在本例中,第一步是将`result`的值设置为`num`,此时还未执行`defer`,`num`的值是`1`,所以`result`被设置为`1`,然后再执行`defer`语句将`result+1`,最终将`result`返回,所以会打印出`2`。 + +4. 例子4: +```go +package main + +import "fmt" + +func main() { + res := deferRun() + fmt.Println(res) +} + +func deferRun() int { + var num int + defer func() { + num++ + }() + + return 1 +} +``` +运行结果: +``` +1 +``` +为什么? +本例和前面的区别返回值是匿名的,但是我们可以同样运用上面的思路,自己创建一个返回值,这里假设为`res`,运用前面的思路分析,第一步将`res`设置为1,第二步执行`defer`将`num+1`,第三步将`res`返回,所以最终结果是`1`。 + +5. 例子5: +```go +package main + +import "fmt" + +func main() { + res := deferRun() + fmt.Println(res) +} + +func deferRun() int { + num := 1 + defer func() { + num++ + }() + + return num +} +``` +运行结果: +``` +1 +``` +同样的思路不难分析:自己创建一个返回值,这里假设为`res`,第一步将`res`设置为`num`,所以`res`的值为`1`,第二步执行`defer`将`num+1`,此时`num`为`2`,但是`res`为`1`,第三步将`res`返回,所以最终结果是`1`。 + +6. 例子6: +```go +package main + +import "fmt" + +func main() { + res := deferRun() + fmt.Println(res) +} + +func deferRun() (res int) { + num := 1 + defer func() { + num++ + }() + + return num +} +``` +运行结果: +``` +1 +``` +不难分析运行结果还是1,同样的三步分析法,因为`defer`改变的是`num`的值,而不是改变的`res`的值,所以结果不会变,不过`defer`函数里变为`res++`,那么结果就是`2`了。 +所以,当我们碰到defer与return确定最终的返回值,可以总结为以下两点: +1. defer 定义的延迟函数的参数在`defer`语句出时就已经确定下来了 +2. `return`不是原子级操作,执行过程是: 设置返回值—>执行`defer`语句—>将结果返回 + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言error.md b/hzh/GolangStar/Go语言基础/Go语言error.md new file mode 100644 index 0000000..eded02d --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言error.md @@ -0,0 +1,145 @@ +--- +tags: + - Go + - golang + - go基础语法 + - error +--- + +# Go语言error +在`Go`语言开发中,我们会多次用到`error`这个东西,尤其是在函数的返回值上,由于`go`的函数支持多返回值,所以一般会用`error`作为其中一个返回值,代表该函数执行过程中或者逻辑有出错,那究竟`error`是个什么东西呢,下面我们就来一探究竟。 + +## error是什么 +`error`其实是Go语言的一个接口类型,就是一个普通的接口,并且也不会携带任何的堆栈信息。 +接口的定义如下: +```go +type error interface { + Error() string +} +``` +通常我们会使用`errors.New()`或者`fmt.Errorf()`来返回一个`error`对象,但是需要注意,通过这两种方式返回的`error`对象都是不可以进行比较的,因为`errors.New()`返回的其实是一个地址,不能用来做等值判断,如果要是先等值判断,需要自己实现,而`fmt.Error()`的内部其实也是用到了`errors.New()`。 + +1. `errors.New()`函数实现: +```go +func New(text string) error { + return &errorString{text} +} +``` +2. `fmt.Errorf()`实现: +```go +func Errorf(format string, a ...interface{}) error { + p := newPrinter() + p.wrapErrs = true + p.doPrintf(format, a) + s := string(p.buf) + var err error + if p.wrappedErr == nil { + err = errors.New(s) + } else { + err = &wrapError{s, p.wrappedErr} + } + p.free() + return err +} +``` +代码展示: +```go +package main + +import ( + "fmt" + "errors" +) +// 定义一个正数自加的函数,当传入的整数小于等于0的时候报错 +func getPositiveSelfAdd(num int) (int,error) { + if num <=0 { + return -1,fmt.Errorf("num is not a positive number") + } + return num+1,nil +} + +func main() { + num1,err1 := getPositiveSelfAdd(1) + fmt.Printf("nums is %d, err is %v\n",num1 ,err1) + + num2,err2 := getPositiveSelfAdd(-2) + fmt.Printf("nums is %d, err is %v\n",num2 ,err2) + + err3 := errors.New("hello") + err4 := errors.New("hello") + fmt.Println(err3==err4) + + fmt.Println(err3.Error()==err4.Error()) +} +``` +运行结果: +``` +nums is 2, err is +nums is -1, err is num is not a positive number +false +false +``` +通过样例可以看到,通过上述方法创建出的两个`error`对象是不能直接比较的,即便是`error`信息一样,也会返回`false`,如果我们想要比较,可以通过`Error()`方法拿到其中的`error`字符串信息,比较字符串。 + +## 自定义error对象 +Go语言内置的`error`创建方法非常简单,也易上手,但是有时候并不能满足我们的业务要求,因为他只返回了错误信息,类似于`error`的`message`,但是很多时候我们业务上还需要错误码,即`error code`。所以,很多时候我们可以自定义`error`对象。 +```go +type MyError struct { + code int + msg string +} +``` +对象定义完之后,接下来只需要实现`error`接口的`Error`方法即可,这样,我们就自定义了一个同时带有错误码和错误信息的error对象。 +下面请看具体例子: +```go +package main + +import "fmt" + +type MyError struct { + code int + msg string +} + +func (m MyError) Error() string { + return fmt.Sprintf("code:%d,msg:%v", m.code, m.msg) +} + +func NewError(code int, msg string) error { + return MyError{ + code: code, + msg: msg, + } +} + +func Code(err error) int { + if e, ok := err.(MyError); ok { + return e.code + } + return -1 +} + +func Msg(err error) string { + if e, ok := err.(MyError); ok { + return e.msg + } + return "" +} + +func main() { + err := NewError(100, "test MyError") + fmt.Printf("code is %d, msg is %s", Code(err), Msg(err)) +} +``` +运行结果: +``` +code is 100, msg is test MyError +``` + + + + + + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言代码结构.md b/hzh/GolangStar/Go语言基础/Go语言代码结构.md new file mode 100644 index 0000000..26bee6e --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言代码结构.md @@ -0,0 +1,118 @@ +--- +tags: + - Go + - golang + - go基础语法 + - go程序结构 +--- + +# Go语言结构 + +> 初看,可能觉得文档有点复杂,但是有空写写程序,一切都豁然开朗了 + +每一门语言都有各自特有的组织结构,同样,在学习go语言的基础语法之前,我们先来了解Go语言最简单程序的结构。 +下面以hello world为例来简单讲解一下Go语言的组织结构 + +## Hello World 实例 +Go 语言的基础组成有以下几个部分: +- 包声明 +- 引入包 +- 函数 +- 变量 +- 语句 & 表达式 +- 注释 +```go +package main + +import "fmt" + +func main() { + /* 这是我的第一个简单的程序 */ + fmt.Println("Hello, World!") +} +``` +1. 第一行代码 package main 定义了包名。你必须在源文件中非注释的第一行指明这个文件属于哪个包,如:package main。package main表示一个可独立执行的程序,每个 Go 应用程序都包含一个名为 main 的包。 +2. 下一行 import "fmt" 告诉 Go 编译器这个程序需要使用 fmt 包(的函数,或其他元素),fmt 包实现了格式化 IO(输入/输出)的函数。 +3. 下一行 func main() 是程序开始执行的函数。main 函数是每一个可执行程序所必须包含的,一般来说都是在启动后第一个执行的函数(如果有 init() 函数则会先执行该函数)。 +4. 下一行 /*...*/ 是注释,在程序执行时将被忽略。单行注释是最常见的注释形式,你可以在任何地方使用以 // 开头的单行注释。多行注释也叫块注释,均已以 /* 开头,并以 */ 结尾,且不可以嵌套使用,多行注释一般用于包的文档描述或注释成块的代码片段。 +5. 下一行 fmt.Println(...) 可以将字符串输出到控制台,并在最后自动增加换行字符 \n。 + 使用 fmt.Print("hello, world\n") 可以得到相同的结果。 + Print 和 Println 这两个函数也支持使用变量,如:fmt.Println(arr)。如果没有特别指定,它们会以默认的打印格式将变量 arr 输出到控制台。 +6. 当标识符(包括常量、变量、类型、函数名、结构字段等等)以一个大写字母开头,如:Group1,那么使用这种形式的标识符的对象就可以被外部包的代码所使用(客户端程序需要先导入这个包),这被称为导出(像面向对象语言中的 public);标识符如果以小写字母开头,则对包外是不可见的,但是他们在整个包的内部是可见并且可用的(像面向对象语言中的 protected ) + +## 执行Go程序 +让我们来看下如何编写Go代码并执行它。步骤如下: +1. 打开编辑器如Goland(参考前面环境安装章节),创建一个project,名字叫做gostudy +2. 将以上代码添加到编辑器中。 +3. 将以上代码保存为 hello.go +4. 打开命令行,并进入程序文件保存的目录中。 +5. 输入命令 go run hello.go 并按回车执行代码。 +6. 如果操作正确你将在屏幕上看到 "Hello World!" 字样的输出 +```shell +$ go run hello.go +Hello, World! +``` +我们还可以使用 go build 命令来生成二进制文件: +```shell +$ go build hello.go +$ ls +hello hello.go +$ ./hello +Hello, World! +``` + +## 文件结构 +关于包,有以下几点需要注意: +- 文件名与包名没有直接关系,不一定要将文件名与包名定成同一个。 +- 文件夹名与包名没有直接关系,并非需要一致。 +- 同一个文件夹下的文件只能有一个包名,否则编译报错。 + +现在在hello目录下有一个`helloworld.go`文件,在myMath目录下有两个文件,分别为`myMath1.go`和`myMath2.go` + +`helloworld.go`的包名为`main`,不必和文件夹名hello一致。`myMath1.go`和`myMath2.go`的包名必须一致,都为`mathClass` + +```shell +hello +--helloworld.go + +myMath +--myMath1.go +--myMath2.go +``` + +helloworld.go +```go +// helloworld.go +package main + +import ( + "fmt" + "gostudy/mymath" +) + +func main() { + fmt.Println("Hello World!") + fmt.Println(mathClass.Add(1, 1)) + fmt.Println(mathClass.Sub(1, 1)) +} +``` + +myMath1.go +```go +// myMath1.go +package mathClass +func Add(x,y int) int { + return x + y +} +``` + +myMath2.go +```go +// myMath2.go +package mathClass +func Sub(x,y int) int { + return x - y +} +``` + + diff --git a/hzh/GolangStar/Go语言基础/Go语言依赖管理.md b/hzh/GolangStar/Go语言基础/Go语言依赖管理.md new file mode 100644 index 0000000..217d6e7 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言依赖管理.md @@ -0,0 +1,348 @@ +--- +tags: + - Go + - golang + - go基础语法 + - modules + - go modules + - 依赖管理 +--- + +# Go语言依赖管理 + +在工程代码中,每种语言基本上都有自己的依赖管理工具,比如python的`pip`、node.js的`npm`,java的`maven`,rust的`cargo`,Go语言也有提供自己的依赖库管理工具。Go语言从v1.5开始开始引入vendor模式,如果项目目录下有vendor目录,那么go工具链会优先使用vendor内的包进行编译、测试等。在go1.11之后,Go语言主要使用`Go modules`对代码依赖进行管理。 + +## Go Modules要点 + +### GO111MODULE环境变量 +这个环境变量是Go Modules的开关,主要有以下参数: +- auto:只在项目包含了`go.mod`文件时启动go modules,在Go1.13版本中是默认值 +- on:无脑启动Go Modules,推荐设置,Go1.14版本以后的默认值 +- off:禁用Go Modules,一般没有使用`go modules`的工程使用; + + +### GOPROXY +该环境变量用于设置Go模块代理,Go后续在拉取模块版本时能够脱离传统的VCS方式从镜像站点快速拉取,`GOPROXY`的值要以英文逗号分割,默认值是`https://proxy.golang.org,direct`,但是该地址在国内无法访问,所以可以使用`goproxy.cn`来代替(七牛云配置),设置命令: +```bash +go env -w GOPROXY=https://goproxy.cn,direct +``` +也可以使用其他配置,例如阿里配置: +```bash +go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/ +``` +该环境变量也可以关闭,可以设置为"off",禁止Go在后续操作中使用任何Go module proxy; +上面的配置中我们用逗号分割后面的值是`direct`,它是什么意思呢? +`direct`为特殊指示符,因为我们指定了镜像地址,默认是从镜像站点拉取,但是有些库可能不存在镜像站点中,`direct`可以指示Go回源到模块版本的源地址去抓取,比如`github`,当`go module proxy`返回`404、410`这类错误时,其会自动尝试列表中的下一个,遇见`direct`时回源地址抓取; + + +### GOSUMDB +`GOSUMDB`(go checksum database)是Go官方为了go modules安全考虑,设定的module校验数据库,你在本地对依赖进行变动(更新/添加)操作时,Go 会自动去这个服务器进行数据校验,保证你下的这个代码库和世界上其他人下的代码库是一样的,保证Go在拉取模块版本时拉取到的模块版本数据未经篡改 + +GOSUMDB的值自定义格式如下: +- 格式 1:+。 +- 格式 2:+ 。 + +`GOSUMDB`的默认值是`sum.golang.org`,默认值与自定义值的格式不一样,默认值在国内是无法访问,这个值我们一般不用动,因为我们一般已经设置好了`GOPROXY`,`goproxy.cn`支持代理`sum.golang.org`; +所以,环境变量`GOSUMDB`可以用来配置你使用哪个校验服务器和公钥来做依赖包的校验 +但是在使用的时候需要注意,如果你的代码仓库或者模块是私有的,那么它的校验值不应该出现在互联网的公有数据库里面,但是我们本地编译的时候默认所有的依赖下载都会去尝试做校验,这样不仅会校验失败,更会泄漏一些私有仓库的路径等信息,我们可以使用`GONOSUMDB`这个环境变量来设置不做校验的代码仓库, 它可以设置多个匹配路径,用逗号相隔. + +例如: +```bash +go env -w GONOSUMDB=*.example.com,test.xyz/com +``` +这样的话,像 `git.example.com`, `test.xyz/com` 这些公司和自己的私有仓库就都不会做校验了。 + + +### GONOPROXY/GONOSUMDB/GOPRIVATE +这三个环境变量放在一起说,一般在项目中不经常使用,这三个环境变量主要用于私有模块的拉取,在GOPROXY、GOSUMDB中无法访问到模块的场景中,例如拉取git上的私有仓库; +GONOPROXY、GONOSUMDB的默认值是GOPRIVATE的值,所以我们一般直接使用GOPRIVATE即可,其值也是可以设置多个,以英文逗号进行分割;例如: +```bash +go env -w GOPRIVATE="github.com/asong2020/go-localcache,git.xxxx.com" +``` +也可以使用通配符的方式进行设置,对域名设置通配符号,这样子域名就都不经过`Go module proxy`和`Go checksum database` + + +### 全局缓存 +`go mod download`会将依赖缓存到本地,缓存的目录是`GOPATH/pkg/mod/cache`、`GOPATH/pkg/sum`,这些缓存依赖可以被多个项目使用,未来可能会迁移到`$GOCACHE`下面; +可以使用`go clean -modcache`清理所有已缓存的模块版本数据; + + +## Go Modules命令 +我们可以使用`go help mod`查看可以使用的命令: +```bash +go help mod +Go mod provides access to operations on modules. + +Note that support for modules is built into all the go commands, +not just 'go mod'. For example, day-to-day adding, removing, upgrading, +and downgrading of dependencies should be done using 'go get'. +See 'go help modules' for an overview of module functionality. + +Usage: + + go mod [arguments] + +The commands are: + + download download modules to local cache + edit edit go.mod from tools or scripts + graph print module requirement graph + init initialize new module in current directory + tidy add missing and remove unused modules + vendor make vendored copy of dependencies + verify verify dependencies have expected content + why explain why packages or modules are needed + +Use "go help mod " for more information about a command. +``` +命令 | 作用 +--- | --- +go mod init | 生成go.mod文件 +go mod download | 下载go.mod文件中指明的所有依赖放到全局缓存 +go mod tidy | 整理现有的依赖,添加缺失或移除不使用的modules +go mod graph | 查看现有的依赖结构 +go mod edit | 编辑go.mod文件 +go mod vendor | 导出项目所有的依赖到vendor目录 +go mod verify | 校验一个模块是否被篡改过 +go mod why | 解释为什么需要依赖某个模块 + + +### go.mod文件 +`go.mod`是启用Go modules的项目所必须且最重要的文件,其描述了当前项目的元信息,每个`go.mod`文件开头符合包含如下信息: + +**module**:用于定义当前项目的模块路径(突破$GOPATH路径) +**go**:当前项目Go版本,目前只是标识作用 +**require**:用于设置一个特定的模块版本 +**exclude**:用于从使用中排除一个特定的模块版本 +**replace**:用于将一个模块版本替换为另外一个模块版本,例如chromedp使用[golang.org/x/image](https://golang.org/x/image)这个package一般直连是获取不了的,但是它有一个[github.com/golang/image](https://github.com/golang/image)的镜像,所以我们要用replace来用镜像替换它 +**retract**:用来声明该第三方模块的某些发行版本不能被其他模块使用,在Go1.16引入 + +示例如下: +```go +module rotatebot + +go 1.17 + +require ( + github.com/gin-gonic/gin v1.8.1 + github.com/sirupsen/logrus v1.9.0 + gorm.io/driver/sqlite v1.4.3 + gorm.io/gorm v1.24.2 +) + +require ( + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.0 // indirect +) + +exclude ( + github.com/json-iterator/go v1.1.12 +) + +replace ( + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd ==> github.com/modern-go/concurrent v0.0.0-20190606011245-iyfhlkiuhydg +) + +retract v0.2.0 +``` +假设我们有上述`go.mod`文件,接下来我们分模块详细介绍一下各个部分 + + +### module +`go.mod`文件的第一行是`module`, 表示工程里的依赖的基路径,例如上面的项目: +```go +module rotatebot +``` +工程里的`import`的路径都是以`rotatebot`开头的字符串 + + +### go version +`go.mod`文件的第二行是`go version`,其是用来指定你的代码所需要的最低版本: +```go +go 1.17 +``` + + +### require +`require`用来指定该项目所需要的各个依赖库以及他们的版本,从上面的例子中我们看到版本部分有不同的写法,还有注释,接下来我们来解释一下这部分; + + +#### indirect注释 +```go +github.com/gin-contrib/sse v0.1.0 // indirect +github.com/go-playground/locales v0.14.0 // indirect +``` +以下场景才会添加`indirect`注释: +- 当前项目依赖包A,A又依赖包B,但是A的 `go.mod`文件中缺失B,所以在当前项目`go.mod`中补充B并添加`indirect`注释 +- 当前项目依赖包A,A又依赖包B,但是依赖包A没有`go.mod`文件,所以在当前项目`go.mod`中补充B并添加`indirect`注释 +- 当前项目依赖包A,A又依赖包B,当依赖包A降级不再依赖B时,这个时候就会标记indirect注释,可以执行go mod tidy移除该依赖; + +Go1.17版本对此做了优化,`indirect`的module将被放在单独`require`块的,这样看起来更加清晰明了。 + +#### incompatible标记 +`incompatible`标记其实是一个module标签归规范约束,Go module 的版本选择机制规定,Module 的版本号需要遵循 v\.\.\ 的格式,此外,如果major版本号大于`1`时,其版本号还需要体现在Module名字中。 +比如[Module github.com/RainbowMango/m](https://github.com/RainbowMango/m),如果其版本号增长到 v2.x.x 时,其 Module 名字也需要相应的改变为:[github.com/RainbowMango/m/v2](https://github.com/RainbowMango/m/v2)。即,如果major版本号大于`1`时,需要在Module名字中体现版本。 +那么如果 Module 的 major 版本号虽然变成了 v2.x.x,但 Module 名字仍保持原样会怎么样呢? 其他项目是否还可以引用呢? +假设[github.com/gin-contrib/sse](https://github.com/gin-contrib/sse)的当前版本为v3.5.0,按照Go module 的版本选择机制,其 Module 名字需要相应的改变为: [github.com/gin-contrib/sse/v3](https://github.com/gin-contrib/sse/releases/tag/v3.5.0),但是如果module名没改,还是[github.com/gin-contrib/sse](https://github.com/gin-contrib/sse),则在被形目引用的时候,就会在后面加上incompatible标记,变成 +```go +require ( + github.com/gin-contrib/sse v3.6.0+incompatible +) +``` +除了增加 +incompatible(不兼容)标识外,在其使用上没有区别 + + +#### 版本号 +go module拉取依赖包本质也是go get行为,go get主要提供了以下命令: + +命令 | 作用 +--- | --- +go get | 拉取依赖,会进行指定性拉取(更新),并不会更新所依赖的其它模块 +go get -u | 更新现有的依赖,会强制更新它所依赖的其它全部模块,不包括自身 +go get -u -t ./... | 更新所有直接依赖和间接依赖的模块版本,包括单元测试中用到的 + +go get拉取依赖包取决于依赖包是否有发布的tags: + +1. 拉取的依赖包没有发布tags + - 默认取主分支最近一次的commit的commit hash,生成一个伪版本号 +2. 拉取的依赖包有发布tags + - 如果只有单个模块,那么就取主版本号最大的那个tag + - 如果有多个模块,则推算相应的模块路径,取主版本号最大的那个tag + +没有发布的tags: +```go +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd +``` +v0.0.0:根据commit的base version生成的: +- 如果没有base version,那么就是vx.0.0的形式 +- 如果base version是一个预发版本,那么就是vx.y.z-pre.0的形式 +- 如果base version是一个正式发布的版本,那么它就patch号加1,就是vx.y.(z+1)-0的形式 + +20190718012654:是这次提交的时间,格式是yyyyMMddhhmmss +fb15b899a751:是这个版本的commit id,通过这个可以确定这个库的特定的版本 + +有发布的tags: +```go +github.com/gin-contrib/sse v0.1.0 +``` + + +### replace +因为某些未知原因,并不是所有的包都能直接用`go get`获取到,或者说是我们想要在官方的依赖库中集成一些我们自己的功能,这时我们就需要使用`go modules`的`replace`功能了 +`replace`顾名思义,就是用新的`package`去替换另一个`package`,他们可以是不同的`package`,也可以是同一个`package`的不同版本。看一下基本的语法: +```bash +go mod edit -replace=old[@v]=new[@v] +``` +`old`是要被替换的package,`new`就是用于替换的package。 + +replace的使用步骤: +1. 首先`go get new-package`(如果你知道package的版本tag,那么这一步其实可以省略,如果想使用最新的版本而不想确认版本号,则需要这一步) +2. 然后查看`go.mod`,手动复制`new-package`的版本号(如果你知道版本号,则跳过) +3. `go mod edit -replace=old[@v]=new[@v]` +4. 接着`go mod tidy`或者`go build`或者使用其他的`go tools`,他们会去获取`new-package`然后替换掉`old-package` +5. 最后,在你的代码里直接使用`old-package`的名字,golang会自动识别出`replace`,然后实际你的程序将会使用`new-package`,替换成功 + + +### exclude +这个特性是在Go1.16版本中引入,用来声明该第三方模块的某些发行版本不能被其他模块使用; +使用场景:发生严重问题或者无意发布某些版本后,模块的维护者可以撤回该版本,支持撤回单个或多个版本; +这种场景以前的解决办法: +维护者删除有问题版本的`tag`,重新打一个新版本的`tag`; +使用者发现有问题的版本`tag`丢失,手动介入升级,并且不明真因; +引入`retract`后,维护者可以使用`retract`在`go.mod`中添加有问题的版本: +```go +// 严重bug... +retract ( + v0.1.0 + v0.2.0 +) +``` +重新发布新版本后,在引用该依赖库的使用执行`go list`可以看到 版本和"严重bug..."的提醒。该特性的主要目的是将问题更直观的反馈到开发者的手中; + + +### go.sum文件 +Go 在做依赖管理时会创建两个文件,`go.mod` 和 `go.sum`,`go.mod` 的重要性不言而喻,这个文件几乎提供了依赖版本的全部信息。而 `go.sum` 则是记录了所有依赖的 module 的校验信息,以防下载的依赖被恶意篡改,主要用于安全校验。这个文件我们一般不需要编辑,更新以来的时候会自动更新。 +每行的格式如下: +``` + + /go.mod +``` +比如: +``` +github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +``` +其中 `module` 是依赖的路径,`version` 是依赖的版本号。如果 `version` 后面跟 `/go.mod` 表示对哈希值是 `module` 的 `go.mod` 文件;否则,哈希值是 `module` 的 `.zip` 文件。 +`hash` 是以 `h1:` 开头的字符串,表示生成 checksum 的算法是第一版的HASH算法(SHA256)。如果将来在SHA-256中发现漏洞,将添加对另一种算法的支持,可能会命名为`h2`。 + +## Go Modules使用 + +使用 `go modules` 的一个前置条件是Go语言版本大于等于Go1.11;然后我们要检查环境变量 `GO111MODULE` 是否开启,执行 `go env` 查看: +```bash +go env | grep GO111MODULE +GO111MODULE="on" +``` +如果GO111MODULE=off,可以执行一下命令打开 +```bash +go env -w GO111MODULE=on +``` +接下来就可以使用GO MODULE管理项目工程了,先创建一个项目目录 +```bash +mkdir -p go_tour/main +cd go_tour +``` +执行: +```bash +go mod init go_tour +``` +运行结果 : +``` +go: creating new go.mod: module go_tour +``` +会在go_tour目录下生成一个go.mod文件。在main包下建立main.go文件 +```go +package main + +import "github.com/tidwall/gjson" + +const json = `{"name":{"hello":"golang","key1":"value1"},"id":12345}` + +func main() { + value := gjson.Get(json, "name.key1") + println(value.String()) +} +``` + +然后在 `go_tour` 目录下执行 `go mod tidy` 命令,可以看到: +``` +go: finding module for package github.com/tidwall/gjson +go: downloading github.com/tidwall/gjson v1.14.4 +go: found github.com/tidwall/gjson in github.com/tidwall/gjson v1.14.4 +``` +可以看到此时的go.mod文件内容: +```go +module go_tour + +go 1.17 + +require github.com/tidwall/gjson v1.14.4 + +require ( + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect +) +``` +依赖已经安装好,并且可以使用了,在 `go.mod` 下还可以看到一个 `go.sum` 文件对依赖包的校验,`go.sum` 我们一般不用管 +``` +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +``` +此时执行 `go run main.go` 就可以看到执行结果了 +``` +value1 +``` +以上就是用`go module`管理依赖库的简单用法。 \ No newline at end of file diff --git a/hzh/GolangStar/Go语言基础/Go语言函数.md b/hzh/GolangStar/Go语言基础/Go语言函数.md new file mode 100644 index 0000000..e3641ea --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言函数.md @@ -0,0 +1,222 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 函数 +--- + +# Go语言函数 + +同其他语言一样,Go语言也有函数的概念,主要是为了代码复用,函数是基本的代码块,用于执行一个任务。 +Go 语言最少有`main()`函数,同时`main()`函数也是整个程序执行的入口。 + +## 函数定义 +Go语言函数定义格式如下: +```go +func function_name([parameter list]) [return_types] { + function_body +} +``` +- func:函数关键字,任何一个函数都有func关键字开始声明。 +- function_name:函数名称,参数列表和返回值类型构成了函数签名。 +- parameter list:参数列表,参数列表指定的是参数类型、顺序、及参数个数。参数是可选的,也就是说函数也可以不包含参数。 +- return_types:返回类型,函数返回一列值。`return_types`是该列值的数据类型。有些功能不需要返回值,这种情况下return_types不是必须的。 +- 函数体:函数定义的代码集合。 +- 注意:Go语言函数区别C++和和Java语言的地方是可以有多个返回值,多个返回值用小括号括起来,中间用逗号分隔。 + +代码展示: +```go +package main + +import "fmt" + +func swap(x, y string) (string, string) { + return y, x +} + +func main() { + a, b := swap("Google", "Hello") + fmt.Println(a, b) +} +``` +运行结果: +``` +Hello Google +``` + +## 函数调用 +当创建函数时,你定义了函数需要做什么,通过调用该函数来执行指定任务。 +调用函数,向函数传递参数,并返回值,例如: +```go +package main + +import "fmt" + +func main() { + /* 定义局部变量 */ + var a int = 100 + var b int = 200 + var ret int + + /* 调用函数并返回最大值 */ + ret = max(a, b) + + fmt.Printf( "最大值是 : %d\n", ret ) +} + +/* 函数定义:返回两个数的最大值 */ +func max(num1, num2 int) int { + /* 定义局部变量 */ + var result int + + if num1 > num2 { + result = num1 + } else { + result = num2 + } + return result +} +``` +运行结果: +``` +最大值是 : 200 +``` + +## 参数传递 +在Go语言中函数的参数传递都是值传递,不存在引用传递(区别于c++) +**注意:使用值传递的时候,虽然会改变形参的值,但并不会改变函数外变量即实参的值** +代码展示: +```go +package main + +import "fmt" + +func main() { + /* 定义局部变量 */ + var a int = 100 + + fmt.Printf("自增前 a 的值为 : %d\n", a ) + + add(a) + + fmt.Printf("自增后 a 的值 : %d\n", a ) +} + +/* 函数定义:自增1 */ +func add(a int) { + a++; + fmt.Printf("add里a的值:%d\n", a) +} +``` +运行结果: +``` +自增前 a 的值为 : 100 +add里a的值:101 +自增后 a 的值 : 100 +``` + +## 函数用法 + +### 函数变量 +一切皆变量,Go语言里什么都可以当作变量来使用,当然函数也不例外,函数可以作为函数变量。Go语言可以很灵活的创建函数,并作为另外一个函数的实参。以下实例中我们在定义的函数中初始化一个变量,该函数仅仅是为了使用内置函数 +代码展示: +```go +package main + +import ( + "fmt" + "math" +) + +func main(){ + /* 声明函数变量 */ + getSquareRoot := func(x float64) float64 { + return math.Sqrt(x) // 求一个数的平方根 + } + + /* 使用函数变量调用函数 */ + fmt.Println(getSquareRoot(9)) +} +``` +运行结果: +``` +3 +``` + +再举一个用函数变量作回调函数的例子: + +```go +package main + +import "fmt" + +// 声明一个函数类型 +type fc func(int) int + +func main() { + CallBack(1, callBack)//执行函数---CallBack +} + +func CallBack(x int, f fc) { //定义了一个函数 testCallBack + f(x) //由于传进来的是callBack函数,该函数执行需要传入一个int类型参数,因此传入x +} + +func callBack(x int) int { + fmt.Printf("我是回调,x:%d\n", x) + return x +} +``` +运行结果: +``` +我是回调,x:1 +``` + +### 函数闭包 +所谓闭包,就是匿名函数,Go语言支持匿名函数调用。匿名函数就是一个"内联"语句或表达式。匿名函数的优越性在于可以直接使用函数内的变量,不必申明,这样有时候可以使代码更简单,增强代码的可读性。 +代码展示: +```go +package main + +import "fmt" + +func getNumber() func() int { + i:=0 + return func() int { + i+=1 + return i + } +} + +func main(){ + /* nextNumber 为一个函数,函数中 i 为 0 */ + nextNumber := getNumber() + + /* 调用 nextNumber 函数,i 变量自增 1 并返回 */ + fmt.Println(nextNumber()) + fmt.Println(nextNumber()) + fmt.Println(nextNumber()) + + /* 创建新的函数 nextNumber1,并查看结果 */ + nextNumber1 := getNumber() + fmt.Println(nextNumber1()) + fmt.Println(nextNumber1()) +} +``` +运行结果: +``` +1 +2 +3 +1 +2 +``` +> **注意:多次调用同一个函数`nextNumber`,其中的i变量是同一个,初始值为0,每调用一次,i自增1** + +代码解释: + +首先,`getNumer`这个函数会返回一个闭包函数,我们先理解闭包是啥,题库里面我也写过的,闭包就是等于 匿名函数+捕获的变量,我们可以看到第7行-第10行,闭包函数里面捕获了i变量,因为i不是在闭包函数里面定义的, +第15行代码,通过`getNumber`获取了一个闭包函数——`nextNumber`,18,19,20行分别执行一次`nextNumber`函数,每次i就会自增1,并且因为每次调用`i`都是固定的,结果会累计,所以会输出1,2,3。23行重新获取了一个闭包函数,`i`是独立于上面闭包函数的,所以重新输出1,2,3 + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言变量.md b/hzh/GolangStar/Go语言基础/Go语言变量.md new file mode 100644 index 0000000..d351d75 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言变量.md @@ -0,0 +1,283 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 变量 +--- + +# Go语言变量 + +Go 语言变量名由字母、数字、下划线组成,其中首个字符不能为数字。 +声明变量的一般形式是使用 var 关键字: +```go +var identifier type +``` +也可以同时声明多个变量 +```go +var identifier1, identifier2 type +``` +```go +package main +import "fmt" +func main() { + var a string = "Hello" + fmt.Println(a) + + var b, c int = 1, 2 + fmt.Println(b, c) +} +``` +以上实例输出结果为: +```shell +Hello +1 2 +``` + +## 变量声明 + +### 第一种,指定变量类型,如果没有初始化,则变量默认为零值 +零值就是变量没有做初始化时系统默认设置的值。 +```go +package main +import "fmt" +func main() { + var a string = "Hello" // 声明一个变量并初始化 + fmt.Println(a) + + var b int // 没有初始化就为零值 + fmt.Println(b) + + var c bool // bool 零值为 false + fmt.Println(c) +} +``` +以上实例执行结果为: +```shell +Hello +0 +false +``` +- 数值类型(包括complex64/128)为 0 +- 布尔类型为 false +- 字符串为 ""(空字符串) +- 以下几种类型为 nil: +```go +package main + +import "fmt" + +var a *int +var b []int +var c map[string]int +var d chan int +var e func(string) int +var f error + +func main() { + fmt.Println("Hello World!") + fmt.Println(a == nil) + fmt.Println(b == nil) + fmt.Println(c == nil) + fmt.Println(d == nil) + fmt.Println(e == nil) + fmt.Println(f == nil) +} +``` +输出结果是 +```shell +Hello World! +true +true +true +true +true +true +``` + +```go +package main + +import "fmt" + +func main() { + var i int + var f float64 + var b bool + var s string + fmt.Printf("%v %v %v %q\n", i, f, b, s) +} +``` +输出结果是 +```shell +0 0 false "" +``` + +### 第二种,根据值自行判定变量类型 +```go +var v_name = value +``` +```go +package main +import "fmt" +func main() { + var d = true + fmt.Println(d) +} +``` +输出结果是: +```shell +true +``` + +### 第三种,:= 声明变量 +```go +v_name := value +``` +**intVal := 1** 等价于: +```go +var intVal int = 1 +``` +可以将 `var intVal int = 1"` 简写为 `ntVal := 1`。如果变量已经使用 `var` 声明过了,再使用 `:=` 声明变量,就产生编译错误 +```go +var intVal int +intVal :=1 // 这时候会产生编译错误,因为 intVal 已经声明,不需要重新声明 +``` +直接使用下面的语句即可: +```go +intVal := 1 // 此时不会产生编译错误,因为有声明新的变量,因为 := 是一个声明语句 +``` + +```go +package main +import "fmt" +func main() { + f := "Hello" // var f string = "Hello" + fmt.Println(f) +} +``` +输出结果是: +```shell +Hello +``` + +### 多变量声明 +```go +//类型相同多个变量, 非全局变量 +var vname1, vname2, vname3 type +vname1, vname2, vname3 = v1, v2, v3 + +var vname1, vname2, vname3 = v1, v2, v3 // 和 python 很像,不需要显示声明类型,自动推断 + +vname1, vname2, vname3 := v1, v2, v3 // 出现在 := 左侧的变量不应该是已经被声明过的,否则会导致编译错误 + + +// 这种因式分解关键字的写法一般用于声明全局变量 +var ( + vname1 v_type1 + vname2 v_type2 +) +``` +```go +package main + +var x, y int +var ( // 这种因式分解关键字的写法一般用于声明全局变量 + a int + b bool +) + +var c, d int = 1, 2 +var e, f = 123, "hello" + +//这种不带声明格式的只能在函数体中出现 +//g, h := 123, "hello" + +func main() { + g, h := 123, "hello" + + println(x, y, a, b, c, d, e, f, g, h) +} +``` +以上实例执行结果为: +```shell +0 0 0 false 1 2 123 hello 123 hello +``` +### 注意事项 +我们知道可以在变量的初始化时省略变量的类型而由系统自动推断,声明语句写上 var 关键字其实是显得有些多余了,因此我们可以将它们简写为`a := 50` 或 `b := false`。`a` 和 `b` 的类型(int 和 bool)将由编译器自动推断。 + +这是使用变量的首选形式,但是它只能被用在函数体内,而不可以用于全局变量的声明与赋值。使用操作符 `:=` 可以高效地创建一个新的变量,称之为初始化声明。 + +如果在相同的代码块中,我们不可以再次对于相同名称的变量使用初始化声明,例如:`a := 20` 就是不被允许的,编译器会提示错误 `no new variables on left side of :=`,但是 `a = 20` 是可以的,因为这是给相同的变量赋予一个新的值。 + +如果你在定义变量 `a` 之前使用它,则会得到编译错误 `undefined: a`。 如果你声明了一个局部变量却没有在相同的代码块中使用它,同样会得到编译错误,例如下面这个例子当中的变量 `a`: +```go +package main + +import "fmt" + +func main() { + var a string = "abc" + fmt.Println("hello, world") +} +``` +尝试编译这段代码将得到错误:**`a declared but not used`**,此外,单纯地给 `a` 赋值也是不够的,这个值必须被使用,所以使用 +```go +fmt.Println("hello, world", a) +``` +会移除错误。但是全局变量是允许声明但不使用的。 同一类型的多个变量可以声明在同一行,如: +```go +var a, b, c int +``` +多变量可以在同一行进行赋值,如: +```go +var a, b int +var c string +a, b, c = 5, 7, "abc" +``` +上面这行假设了变量 `a`,`b` 和 `c` 都已经被声明,否则的话应该这样使用: +```go +a, b, c := 5, 7, "abc" +``` +右边的这些值以相同的顺序赋值给左边的变量,所以 `a` 的值是 `5`, `b` 的值是 `7`,`c` 的值是 `abc`。 这被称为并行或同时赋值。 + +如果你想要交换两个变量的值,则可以简单地使用 `a, b = b, a`,两个变量的类型必须是相同。 + +空白标识符`_`也被用于抛弃值,如值`5`在`_, b = 5, 7`中被抛弃。`_`实际上是一个只写变量,你不能得到它的值。这样做是因为Go语言中你必须使用所有被声明的变量,但有时你并不需要使用从一个函数得到的所有返回值。 + +并行赋值也被用于当一个函数返回多个返回值时,比如这里的`val`和错误`err`是通过调用`Func1`函数同时得到:`val, err = Func1(var1)`。 + +## 变量的生命周期 +全局变量生命周期是程序存活时间,在不发生内存逃逸的情况下,局部变量是函数存活时间 +```go +package main + +import "fmt" + +var globalStr string +var globalInt int + +func main() { + var localStr string + var localInt int + localStr = "first local" + localInt = 2021 + globalInt = 1024 + globalStr = "first global" + fmt.Printf("globalStr is %s\n", globalStr) //globalStr is first global + fmt.Printf("globalStr is %d\n", globalInt) //globalStr is 1024 + fmt.Printf("localInt is %s\n", localStr) //localInt is first local + fmt.Printf("localInt int is %d\n", localInt) //localInt int is 2021 +} +``` + + + + + + + + + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言命名规范.md b/hzh/GolangStar/Go语言基础/Go语言命名规范.md new file mode 100644 index 0000000..236d5a4 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言命名规范.md @@ -0,0 +1,135 @@ +--- +tags: + - Go + - golang + - go基础语法 + - go命名规范 +--- + +# Go语言命名规范 + +在Go语言中,任何标识符,包括变量,常量,函数名,方法名,接口名,以及自定义类型等,都应该遵循以下规则 + +## 1. 命名要区分大小写 +Go语言是一种区分大小写的编程语言。其命名规则涵盖了变量、常量、全局函数、结构体、接口、方法等元素。在 Go 的语法规定中,对于需要暴露给外部使用的标识符,必须以大写字母开头,而那些不需要外部访问的则应使用小写字母开头。 + +1. 当一个名称(如常量、变量、类型、函数名或结构体字段等)以大写字母开头时,例如:Analysize,它将成为外部包代码可以访问的对象(前提是外部包需要先导入此包),这种方式被称为“导出”(类似于面向对象语言中的 public)。 +2. 如果名称以小写字母开头,则该标识符仅对当前包内可见并可使用,无法被包外访问(类似于面向对象语言中的 private)。 + +## 1. 包名称 +包名必须全部为小写单词,无下划线,也不要混合大小写,越短越好,尽量不要与标准库重名。并且包名最好和目录保持一致,这样可读性强 +```go +package domain +package service +package modle +``` + +## 2. 文件名 +文件名同样遵循简短有意义的原则,文件名必须为小写单词,允许加下划线‘_’组合方式,但是头尾不能为下划线 +```go +order_service.go +``` +虽然go文件命名允许出现下划线,但是为了代码的整洁性,以及避免与一些系统规定的特定后缀冲突,还是建议少使用下划线,能不用尽量不用 + +比如以 `_test`为后缀的go编译器会认为是测试文件,不会编译到工程里面。除了`_test`为后缀的测试文件外,还有不少系统的文件,我们在命名的时候也应当尽量避免 +```go +_386.go、_amd64.go、_arm.go、_arm64.go、_android.go、_darwin.go、_dragonfly.go、_freebsd.go、_linux.go、_nacl.go、_netbsd.go、_openbsd.go、_plan9.go、_solaris.go、_windows.go、_android_386.go、_android_amd64.go、_android_arm.go、_android_arm64.go、_darwin_386.go、_darwin_amd64.go、_darwin_arm.go、_darwin_arm64.go、_dragonfly_amd64.go、_freebsd_386.go、_freebsd_amd64.go、_freebsd_arm.go、_linux_386.go、_linux_amd64.go、_linux_arm.go、_linux_arm64.go、_linux_mips64.go、_linux_mips64le.go、_linux_ppc64.go、_linux_ppc64le.go、_linux_s390x.go、_nacl_386.go、_nacl_amd64p32.go、_nacl_arm.go、_netbsd_386.go、_netbsd_amd64.go、_netbsd_arm.go、_openbsd_386.go、_openbsd_amd64.go、_openbsd_arm.go、_plan9_386.go、_plan9_amd64.go、_plan9_arm.go、_solaris_amd64.go、_windows_386.go +_windows_amd64.go +``` + +## 3. 常量名 +1. +常量&枚举名规范起见,采用大小写混排的驼峰模式(Golang官方要求),不要出现下划线。比如 +```go +const ( + TypeBooks = iota // 0 + TypePhone // 1 + TypeCoin // 2 + ) +``` + +2. 常量的定义应根据功能对类型进行分类,而不是将所有类型归为一组。此外,建议将公共常量放在私有常量之前。 +```go +const ( + TypePage = "page" + + // The rest are node types; home page, sections etc. + TypeHome = "home" + TypeSection = "section" + TypeTaxonomy = "taxonomy" + TypeTaxonomyTerm = "taxonomyTerm" + + // Temporary state. + TypeUnknown = "unknown" + + // The following are (currently) temporary nodes, + // i.e. nodes we create just to render in isolation. + TypeRSS = "RSS" + TypeSitemap = "sitemap" + TypeRoboTypeXT = "roboTypeXT" + Type404 = "404" + ) +``` + +3. 如果常量类型是枚举类型,需要先创建相应类型。示例如下: +```go + type TypeCompareType int + + const ( +     TypeEq TypeCompareType = iota +     TypeNe +     TypeGt +     TypeGe +     TypeLt +     TypeLe + ) +``` + +## 4. 变量名 +变量名称通常遵循驼峰命名法,首字母根据访问控制原则决定是大写还是小写。但对于特定名词,需遵循以下规则: +1. 如果变量是私有的,且特定名词位于名称的首位,则应使用小写字母(例如 appService)。 +2. 如果变量类型为 bool,则名称应以 Has、Is、Can 或 Allow 开头。 + +```go +var isExist bool +var hasConflict bool +var canManage bool +var allowGitHook bool +``` + +## 5. 结构体名 +结构体命名同样应使用驼峰命名法,变量名的首字母根据访问控制规则决定是大写还是小写。 +对于 struct 的声明和初始化,建议采用多行格式,示例如下: + +```go +type ServiceConfig struct { + Port string `json:"port"` + Address string `json:"address"` +} +config := ServiceConfig{"8080", "111.222.333.444"} +``` + +## 6. 接口名 +接口的命名规则几乎和结构体相同,只是需要多注意一点,通常只包含单个函数的接口名以 “er” 作为后缀,例如 Reader , Writer + +```go +type Reader interface { + Read(p []byte) (n int, err error) +} +``` + +## 7. 函数名 +1. 函数名必须为大小写混排的驼峰模式,注意,函数名开头字母大写表示该函数是可导出的,可以在包外的其他地方被调用,如果函数名开头是小写的,则只能在包内被调用。另外还需要注意一点的是:函数名不应该与标准库中的函数名冲突 +```go +func DoJob     // 暴露给包外部函数 + +func doJob    // 包内部函数 +``` + +2. 函数名要求精简准确,并采用用动词或动词短,比如 +```go +func save(){} +func delete(){} +func getUser(){} +``` + diff --git a/hzh/GolangStar/Go语言基础/Go语言常量.md b/hzh/GolangStar/Go语言基础/Go语言常量.md new file mode 100644 index 0000000..ec013d7 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言常量.md @@ -0,0 +1,147 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 常量 +--- + +# Go语言常量 + +## 常量的定义 + +Go语言常量的定义跟其他语言类似,是一个简单值的标识符,在程序运行时,不可以被修改。常量中的数据类型只可以是布尔型、数字型(整数型、浮点型和复数)和字符串型。常量的定义方式: + +```go +const identifier [type] = value +``` +其中类型说明符[type]可以省略,因为编译器可以根据变量的值来推断其类型。 + +```go +const b string = "abc" // 显式常量定义 +const b = "abc" // 隐式常量定义 +``` +多个相同类型的声明可以简写为:`const c_name1, c_name2 = value1, value2` +```go +const a, b = "abc", "def" +``` + +## 常量用作枚举 + +Go语言不像C++Java一样有专门的枚举类型,Go语言的枚举一般用常量表示。 +```go +const ( + Unknown = 0 + Success = 1 + Fail = 2 +) +``` +数字 0、1 和 2 分别代表未知成功和失败 +常量可以用len(), cap(), unsafe.Sizeof()函数计算表达式的值。常量表达式中,必须是编译期可以确定的值,否则编译不过 +```go +package main + +import ( + "fmt" + "unsafe" +) + +const ( + a = "abc" + b = len(a) + c = unsafe.Sizeof(a) +) + +func main() { + fmt.Printf("a = %s, b = %d, c = %d\n", a, b, c) +} +``` +以上实例运行结果为: +``` +abc 3 16 +``` +**思考题:为什么字符串"abc"的 `unsafe.Sizeof()` 是16呢?** +实际上字符串类型的`unsafe.Sizeof()`一直是16,这又是为什么呢?因为字符串类型对应一个结构体,该结构体有两个域,第一个域是指向该字符串的指针,第二个域是字符串的长度,每个域占8个字节,但是并不包含指针指向的字符串的内容,这也就是为什么`sizeof`始终返回的是16。 +字符串可以理解成此结构体 +```c +typedef struct { + char* buffer; // 8字节 + size_tlen; // 8字节 +} string; +``` + +## iota +`iota`是一个特殊常量,可以认为是一个可以被编译器修改的常量。`iota`在`const`关键字出现时将被重置为`0`(const 内部的第一行之前),`const`中每新增一行常量声明将使`iota`计数一次(`iota`可理解为`const`语句块中的行索引) +看例子: + +```go +const ( + a = iota + b = iota + c = iota +) +``` +第一个`iota`等于`0`,每当`iota`在新的一行被使用时,它的值都会自动加1;所以`a=0, b=1, c=2` 可以简写为如下形式: +```go +const ( + a = iota + b + c +) +``` +具体使用: +```go +package main + +import "fmt" + +func main() { + const ( + a = iota // 0 + b // 1 + c // 2 + d = "ha" // 独立值,iota = 3 + e // "ha",iota = 4 + f = 100 // iota = 5 + g // 100,iota = 6 + h = iota // 7,恢复计数 + i // 8 + ) + fmt.Println(a, b, c, d, e, f, g, h, i) +} +``` +以上实例运行结果为: +``` +0 1 2 ha ha 100 100 7 8 +``` +关于iota的一个有趣的例子: +```go +package main + +import "fmt" +const ( + i=1<testPanic1-->testPanic2-->testPanic3`,但是在`testPanic3`中发现了一个`panic`,由于`testPanic3`没有`recover`,向上找,在`testPanic2`中找到了`recover`,`panic`被捕获了,程序接着运行,由于`testPanic3`发生了`panic`,所以不再继续运行,函数跳出返回到`testPanic2`,`testPanic2`中捕获到了`panic`,也不会再继续执行,跳出函数`testPanic2`,到了`testPanic1`接着运行。 + +所以`recover`和`panic`可以总结为以下两点: +1. `recover()`只能恢复当前函数级或以当前函数为首的调用链中的函数中的`panic()`,恢复后调用当前函数结束,但是调用此函数的函数继续执行 +2. 函数发生了`panic`之后会一直向上传递,如果直至`main`函数都没有`recover()`,程序将终止,如果是碰见了`recover()`,将被`recover`捕获。 diff --git a/hzh/GolangStar/Go语言基础/Go语言循环.md b/hzh/GolangStar/Go语言基础/Go语言循环.md new file mode 100644 index 0000000..079a880 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言循环.md @@ -0,0 +1,454 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 循环 +--- + +# Go语言循环 +Go语言的循环不像其他语言一样有多种,比如c++有`for`,`while`,`do-while`。在go语言中循环就只有`for`一种,所以用起来也是十分的方便。 + +## for循环的形式 +Go语言的`for`循环有 3 种形式,只有其中的一种使用分号。 + +1. 第一种:类似于c语言的for循环 +```go +for init; condition; post { + +} +``` + +2. 第二种:类似于c语言的while +```go +for condition { + +} +``` + +3. 第三种:类似于c语言的 for(;;) +```go +for { + +} +``` +- init: 一般为赋值表达式,给控制变量赋初值; +- condition: 关系表达式或逻辑表达式,循环控制条件; +- post: 一般为赋值表达式,给控制变量增量或减量。 +```go +for i := 0; i < 10; i++ { + fmt.Println(i) +} +``` + +## for range +for 循环的`range`格式可以对`slice`、`map`、数组、字符串等进行迭代循环。格式如下: +```go +for key, value := range oldMap { + newMap[key] = value +} +``` +以上代码中的`key`和`value`是可以省略的,如果省略`key`,则返回索引,如果省略`value`,则返回元素值。 +如果只想读取 key,格式如下: +```go +for key := range oldMap +``` +如果只想读取 value,格式如下: +```go +for _, value := range oldMap +``` +代码展示: +```go +package main + +import "fmt" + +func main() { + for i := 0; i < 5; i++ { + fmt.Printf("current i %d\n", i) + } + j := 0 + for { + if j == 5 { + break + } + fmt.Printf("current j %d\n", j) + j++ + } + var strAry = []string{"aa", "bb", "cc", "dd", "ee"} //是的,不指定初始个数也ok + //切片初始化 + var sliceAry = make([]string, 0) + sliceAry = strAry[1:3] + for i, str := range sliceAry { + fmt.Printf("slice i %d, str %s\n", i, str) + } + //字典初始化 + var dic = map[string]int{ + "apple": 1, + "watermelon": 2, + } + for k, v := range dic { + fmt.Printf("key %s, value %d\n", k, v) + } +} +``` +输出如下: +``` +current i 0 +current i 1 +current i 2 +current i 3 +current i 4 +current j 0 +current j 1 +current j 2 +current j 3 +current j 4 +slice i 0, str bb +slice i 1, str cc +key apple, value 1 +key watermelon, value 2 +``` + +## for range的坑 +这块可能需要点`golang`基础,也可以把后面的先看了再来看这块内容。 +通过上面例子不难发现`for range`对于数组,map的遍历非常方便,但是`for range`也有不好的坑,其中不乏一些熟悉`golang`的开发人员,往往也会掉到坑里,下面就给大家来捋一捋`golang for range`中那些容易遇到的坑,这也是面试`golang`基础时通常会被面试官问到的 + +### for range取不到所有元素的地址 +> **PS:如果你用的是Go 1.22版本以及之后的话,那是能取到地址的,而且要注意,这个地址是临时变量的地址,不是原元素的地址** +```go +package main + +import "fmt" + +func main() { + arr := [2]int{1, 2} + res := []*int{} + for _, v := range arr { + res = append(res, &v) + } + // expect: 1 2 预期的结果 + // but + // result: 2 2 实际的结果 + fmt.Println(*res[0], *res[1]) +} +``` +上述代码通过定义一个数组`arr`,数组元素为`1`,`2`。然后试图通过取到数组的这两个元素的地址放到切片`res`中,最后通过取地址操作符`*res[0]`和`*res[1]`打印出切片中的元素,希望结果输出`1`和`2`,但结果恰恰不是我们所预期的那样。 +代码实际输出: +``` +2 2 +``` +实际输出的是两个2,那么问题在哪里呢? +我们可以在每次for range 循环打印出v的地址,会发现v是不变的,那么我们每次将v的地址加入 到res中,res中最终所有的元素都是一个地址,这一个地址最终指向的是v最后遍历得到的值——也就是2 + + +```go +package main + +import "fmt" + +func main() { + arr := [2]int{1, 2} + res := []*int{} + for _, v := range arr { + // v 每次都是同一个变量 + fmt.Println(&v) + res = append(res, &v) + } + // expect: 1 2 + // but + // result: 2 2 + fmt.Println(*res[0], *res[1]) +} +``` +代码输出: +``` +0xc000018050 +0xc000018050 +2 2 +``` + +那如果我们想要得到预期的结果`1`和`2`,应该怎么改呢?两种方式: +1. 第一种:使用局部变量`v1`拷贝`v` +```go +for _, v := range arr { + //局部变量v替换了v,也可用别的局部变量名 + v1 := v + res = append(res, &v1) +} +``` +2. 第二种:直接使用索引获取原来的元素 +```go +for k := range arr { + res = append(res, &arr[k]) +} +``` + +### 循环是否会停止? +```go +v := []int{1, 2, 3} +for i := range v { + v = append(v, i) +} +``` +在循环遍历的同时往遍历的切片追加元素,循环会停止吗? +答案是:**会**。 +在Go语言中,`for i := range v`语句会在循环开始前对切片`v`的长度进行一次评估,并将这个长度用于控制循环的迭代次数。之后,如果在循环体内修改了切片`v`的长度(比如通过`append`函数),这个修改并不会影响已经确定的循环迭代次数。 +上述例子可以看作是下面这个代码: +```go +v := []int{1, 2, 3} +length := len(v) +for i := 0; i < length; i++ { + v = append(v, i) +} +``` + +### 使用迭代变量时的闭包问题 +#### 3.3.1 问题 +在`for range`循环中,如果在闭包中使用迭代变量,可能会遇到意想不到的结果。因为闭包会捕获迭代变量的引用,而不是它的值。 +```go +package main + +import ( + "fmt" +) + +func main() { + var funcs []func() + + for i := 0; i < 3; i++ { + funcs = append(funcs, func() { + fmt.Println(i) + }) + } + + for _, f := range funcs { + f() + } +} +``` +代码输出: +``` +3 +3 +3 +``` +#### 解决方法 +使用局部变量保存当前迭代变量的值。 +```go +package main + +import ( + "fmt" +) + +func main() { + var funcs []func() + + for i := 0; i < 3; i++ { + i := i // 创建新的局部变量 i + funcs = append(funcs, func() { + fmt.Println(i) + }) + } + + for _, f := range funcs { + f() + } +} +``` +代码输出: +``` +0 +1 +2 +``` + +### 修改切片中的元素 +#### 问题 +`for range`会创建每个元素的副本,而不是直接操作原始切片中的元素。因此,修改迭代变量不会影响原始切片。 +```go +package main + +import ( + "fmt" +) + +func main() { + slice := []int{1, 2, 3} + + for _, v := range slice { + v *= 10 + } + + fmt.Println(slice) // 输出: [1 2 3] +} +``` +代码输出: +``` +[1 2 3] +``` +#### 解决方法 +使用索引访问并修改原始切片中的元素 +```go +package main + +import ( + "fmt" +) + +func main() { + slice := []int{1, 2, 3} + + for i := range slice { + slice[i] *= 10 + } + + fmt.Println(slice) // 输出: [10 20 30] +} +``` +代码输出: +``` +[10 20 30] +``` + +### 遍历字典时的顺序 +#### 问题 +在 Go 中,使用`for range`遍历字典时,遍历顺序是随机的。每次运行程序时,顺序可能不同。 +```go +package main + +import ( + "fmt" +) + +func main() { + dic := map[string]int{"a": 1, "b": 2, "c": 3} + + for k, v := range dic { + fmt.Printf("key: %s, value: %d\n", k, v) + } +} +``` +代码输出: +``` +key: a, value: 1 +key: c, value: 3 +key: b, value: 2 +``` +每次输出的顺序可能不同 + +#### 解决方法 +使用`for`循环遍历字典,并使用`sort.Strings`函数对键进行排序,再遍历 +```go +package main + +import ( + "fmt" + "sort" +) + +func main() { + dic := map[string]int{"a": 1, "b": 2, "c": 3} + keys := make([]string, 0, len(dic)) + + for k := range dic { + keys = append(keys, k) + } + + sort.Strings(keys) + + for _, k := range keys { + fmt.Printf("key: %s, value: %d\n", k, dic[k]) + } +} +``` +代码输出: +``` +key: a, value: 1 +key: b, value: 2 +key: c, value: 3 +``` +**对比学习** +> C++: + • std::map:有序容器,遍历顺序是键的升序。(内部使用红黑树实现) + • std::unordered_map:无序容器,遍历顺序不可预测。(内部使用哈希表实现) + Python: + • Python 3.7 及更高版本:字典遍历顺序是插入顺序。 + • Python 3.6 及更早版本:字典遍历顺序可能是插入顺序,但并不保证。 +> + + ### 字符串遍历 + #### 问题 + `for range`遍历字符串时,每次迭代会返回`Unicode`代码点`(rune)`,而不是字节。如果字符串包含多字节字符,这一点尤其重要。 +```go +package main + +import ( + "fmt" +) + +func main() { + str := "hello 世界" + + for i, r := range str { + fmt.Printf("index: %d, rune: %c\n", i, r) + } +} +``` +代码输出: +``` +index: 0, rune: h +index: 1, rune: e +index: 2, rune: l +index: 3, rune: l +index: 4, rune: +index: 5, rune: 世 +index: 6, rune: 界 +``` + +#### 解决方法 +理解`for range`返回的是 `Unicode` 代码点,而不是字节。如果需要按字节遍历,可以使用常规的`for`循环。 +```go +package main + +import ( + "fmt" +) + +func main() { + str := "hello 世界" + + for i := 0; i < len(str); i++ { + fmt.Printf("index: %d, byte: %x\n", i, str[i]) + } +} +``` +代码输出: +``` +index: 0, byte: 68 +index: 1, byte: 65 +index: 2, byte: 6c +index: 3, byte: 6c +index: 4, byte: 6f +index: 5, byte: 20 +index: 6, byte: e4 +index: 7, byte: b8 +index: 8, byte: 96 +index: 9, byte: e7 +index: 10, byte: 95 +index: 11, byte: 8c +``` +### 总结 +1. Go 1.22之前通过`for _, v := range arr`遍历切片取不到所有变量的地址,而是同一个临时变量的地址 +2. 闭包中的迭代变量:迭代变量在闭包中被捕获,导致所有闭包共享同一个变量。 +3. 切片元素修改: `for range` 会创建元素的副本,直接修改迭代变量不会影响原切片。 +4. 字典遍历顺序:遍历字典时,顺序是随机的,每次运行可能不同。 +5. 字符串遍历:`for range` 遍历字符串时返回的是 `Unicode` 代码点(`rune`),而不是字节,可能导致多字节字符处理复杂。 +6. 删除切片元素: 在 `for range` 中删除切片元素可能导致意外行为或漏掉某些元素。 +7. 遍历修改映射(字典):在`for range`循环中修改字典(如添加或删除键值对)可能导致未定义行为或错误。 + + + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言指针.md b/hzh/GolangStar/Go语言基础/Go语言指针.md new file mode 100644 index 0000000..7239427 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言指针.md @@ -0,0 +1,83 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 指针 +--- + +# Go语言指针 + +## 指针是什么 + +像c语言一样,Go语言也有指针的概念。简单理解,指针就是地址,指针变量就是存放地址的变量。在一个变量前加上*,那么这个变量就是指针变量,指针变量只能存放地址。 +1个指针变量可以指向任何一个值的内存地址,它所指向的值的内存地址在32和64位机器上分别占用4或8个字节,占用字节的大小与所指向的值的大小无关。 + +## 创建指针 + +### 声明指针 +我们可以像声明其它类型的变量一样,声明一个指针变量 +```go +var a *int +``` +上述方法声明了一个整形指针变量a,但是该变量还没有存放任何地址,操作系统并未为其指向的内容分配内存,即该指针没有指向任何内容,所以它是一个空指针。 +```go +var b int = 3 +a = &b +``` +此时a不再是一个空指针,a指向一个整形变量,该变量的值是3。 +要获取指针变量所指向的内容,很简单,在变量前加*即可 +```go +package main + +import "fmt" + +func main() { + var a *int + fmt.Printf("赋值前a: %v\n", a) + + var b int = 3 + a = &b // a存放的是b的地址 + fmt.Printf("赋值后a: %v\n", a) // 打印出b的地址 + fmt.Printf("a指向的内容是: %v\n", *a) // *a表示a存放的地址对应的内存存放的内容,这里为整数3 +} +``` +运行结果: +``` +赋值前a: +赋值后a: 0x14000018000 +a指向的内容是: 3 +``` + +### new()函数 +Go语言还提供了另外一种方法来创建指针变量,使用`new`函数创建 +```go +new(type) +``` +代码展示: +```go +package main + +import "fmt" + +func main() { + s := new(string) + fmt.Printf("赋值前s: %v\n", s) + fmt.Printf("赋值前s的内容: %v\n", *s) + + *s = "Golang学习之路" + fmt.Printf("赋值h后s: %v\n", s) + fmt.Printf("赋值后s的内容: %v\n", *s) +} +``` +运行结果: +``` +赋值前s: +赋值前s的内容: +赋值后s: 0x14000018000 +赋值后s的内容: Golang学习之路 +``` +可以看到,通过new函数创建指针的时候,系统会为其分配内存,所以两次都能打印出地址,但是,在没有对其赋值的时候,系统会存放默认值,string类型的就是空字符串""。 + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言接口.md b/hzh/GolangStar/Go语言基础/Go语言接口.md new file mode 100644 index 0000000..31ba18d --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言接口.md @@ -0,0 +1,353 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 接口 +--- + +# Go语言接口 + +## 什么是接口 +Go语言中的接口很简单,就是一组方法的声明。当某一种类型实现了所有这些声明的方法,那么就称这种类型为该接口的一种实现。 + +## 接口定义 +Go语言中同样用关键字``interfac`e`来定义,接口的定义格式如下: +```go +type 接口名 interface { + 方法名1(参数列表1) (返回值列表1) + 方法名2(参数列表2) (返回值列表2) +} +``` +- 接口名:表示接口的名称 +- 方法名:表示接口的方法名称 +- 参数列表:表示方法的参数列表 +- 返回值列表:表示方法的返回值列表 +下面看一个具体的interface的例子 +```go +package main + +import "fmt" + +type Phone interface { + Call() + SendMessage() +} + +type Apple struct{ + PhoneName string +} + +func (a Apple) Call() { + fmt.Printf("%s有打电话功能\n", a.PhoneName) +} + +func (a Apple) SendMessage() { + fmt.Printf("%s有发短信功能\n", a.PhoneName) +} + + +type HuaWei struct{ + PhoneName string +} + +func (h HuaWei) Call() { + fmt.Printf("%s有打电话功能\n", h.PhoneName) +} + +func (h HuaWei) SendMessage() { + fmt.Printf("%s有发短信功能\n", h.PhoneName) +} + + +func main() { + a := Apple{"apple"} + b := HuaWei{"huawei"} + a.Call() + a.SendMessage() + b.Call() + b.SendMessage() + + var phone Phone // 声明一个接口类型phone + phone = new(Apple) // 注意这种创建方式,new函数参数是接口的实现 + phone.(*Apple).PhoneName = "Apple" // 这里使用断言给phone的成员赋值,后面会讲到接口的断言 + phone.Call() + phone.SendMessage() +} +``` +运行结果: +``` +apple有打电话功能 +apple有发短信功能 +huawei有打电话功能 +huawei有发短信功能 +Apple有打电话功能 +Apple有发短信功能 +``` +上述声明了一个`Phone`的接口,有两个方法`Call()`和`Sendmessage()`,然后定义了两个结构`Apple`和`HuaWei`,这两个结构都实现了`Phone`接口定义的所有方法,但是实现的方法不同,所以`Apple`和`HuaWei`是`Phone`接口的两种实现。 +注意:上述例子的`phone`变量的定义,首先声明`phone`为一个接口,接着用`new`方法为这个`phone`定义,注意,这里`new`的参数必须是`phone`的一种实现,假设这里`Apple`结构没有实现`SendMessage`方法,那么`Apple`结构就不是`Phone`接口的一个实现,上述代码的45行用`new`定义`phone`的时候就会报错。 + +## 实现多个接口 +有上述接口的定义我们知道,类型可以实现接口,那么一种类型可以实现多个接口吗,答案是:**可以的** 。 +请看下面例子: +```go + package main + +import "fmt" + +type MyWriter interface { + MyWriter(s string) +} + +type MyRead interface { + MyReader() +} + +type MyWriteReader struct { +} + +func (r MyWriteReader) MyWriter(s string) { + fmt.Printf("call MyWriteReader MyWriter %s\n", s) +} + +func (r MyWriteReader) MyReader() { + fmt.Printf("call MyWriteReader MyReader\n") +} + +func main() { + var myRead MyRead + myRead = new(MyWriteReader) + myRead.MyReader() + + var myWriter MyWriter + myWriter = MyWriteReader{} + myWriter.MyWriter("hello") +} +``` +运行结果: +``` +call MyWriteReader MyReader +call MyWriteReader MyWriter hello +``` +上述例子我们定义了一个`MyWriter`接口,该接口有一个方法`MyWriter`,`MyWriter` 方法接受一个 string 类型的 参数。接着,我们定义了一个 `MyReader` 接口,该接口有一个方法 `MyReader`。然后定义了一个`ReadWriter`结构,实现了接口 `MyWriter` 和接口 `MyReader`中的所有方法,所以`ReadWriter`分别实现了两个不同的接口`MyWriter`和`MyReader`。 + +# 空接口 +没有任何方法声明的接口称之为空接口`interface{}`。所有的类型都实现了空接口,因此空接口可以存储任意类型的数值。 +Golang很多库的源代码都会以空接口作为参数,表示接受任意类型的参数,比如`fmt` 包下的 `Print` 系列函数。 +```go +func Println(a ...interface{}) (n int, err error) +func Print(a ...interface{}) (n int, err error) +``` +看下面例子: +```go +package main + +import ( + "fmt" +) + +func main() { + var any interface{} + any = 10 + fmt.Println(any) + + any = "golang" + fmt.Println(any) + + any = true + fmt.Println(any) +} +``` +运行结果: +``` +10 +golang +true +``` +上述例子首先声明了一个空接口`any`,首先用来存储整形变量10,然后又用来存储字符串·golang,最后用来存储布尔型变量true,所以空接口可以存储任意类型的数值。`fmt.Println`函数的参数是空接口类型`interface{}`,所以能将结果正确打印出来。 + +# 类型断言 +在介绍断言之前,先看一个例子 +```go +package main + +func main() { + var a int = 1 + var i interface{} = a + var b int = i +} +``` +运行结果: +``` +./prog.go:6:14: cannot use i (variable of type interface{}) as type int in variable declaration: + need type assertion +``` +上述代码中,声明了一个整形变量`a`,然后声明了一个空接口`i`,并赋值为`a`,接着声明了一个整形变量`b`,并赋值为`i`,但是编译器会报错,不能将`interface{}`类型的变量`i`赋值给整型变量`b`。 +所以在写代码的时候我们需要注意,可以将任意类型的变量赋值给空接口`interface{}`类型,但是反过来不行。 +那为了让这个操作能够完成,我们需要怎么做呢?就是**断言**。类型断言(Type Assertion)接口操作,用来检查接口变量的值是否实现了某个接口或者是否是某个具体的类型 +断言的一般格式为: +```go +value, ok := x.(T) +``` +- T 是具体某个类型,类型断言会检查 x 的动态类型是否等于具体类型 T。如果检查成功,类型断言返回的结果是 x 的值,其类型是 T。 +- x 是接口类型,类型断言会检查 x 的动态类型是否满足 T。如果检查成功,返回值是一个类型为 T 的接口值。 +- 无论 T 是什么类型,如果 x 是 nil 接口值,类型断言都会失败。 +请看下面例子: +```go +package main + +import ( + "fmt" +) + +func main() { + var x interface{} + x = 8 + val, ok := x.(int) + fmt.Printf("val is %d, ok is %t\n", val, ok) +} +``` +运行结果: +``` +val is 8, ok is true +``` +上述代码中,首先声明了一个空接口`x`,并赋值为8,然后使用类型断言将`x`断言为`int`类型,并返回断言后的值和断言是否成功。**注意:如果在断言的过程中,没有bool这个值的判断,如果断言成功程序正常运行,假设interface{}存储的值跟要断言的类型不一致,则程序会报panic** +看下面例子: +```go +package main + +import ( + "fmt" +) + +func main() { + var x interface{} + x = "golang" + val := x.(int) + fmt.Println(val) +} +``` +运行结果: +``` +panic: interface conversion: interface {} is string, not int +``` + +## 接口作函数参数 +接口做函数参数,在函数定义的时候,形参为接口类型,在函数调用的时候,实参为该接口的具体实现 +具体看下面看例子: +```go +package main + +import "fmt" + +type Reader interface { + Read() int +} + +type MyReader struct { + a, b int +} + +func (m *MyReader) Read() int { + return m.a + m.b +} + +func DoJob(r Reader) { + fmt.Printf("myReader is %d\n", r.Read()) +} + +func main() { + myReader := &MyReader{2, 5} + DoJob(myReader) +} +``` +运行结果: +``` +myReader is 7 +``` +上述代码中,首先定义了一个`Reader`接口,然后定义了一个`MyReader`结构,并实现了`Reader`接口的`Read`方法,接着定义了一个`DoJob`函数,该函数的参数为`Reader`接口类型,在函数调用的时候,实参为`MyReader`结构,所以`DoJob`函数可以调用`MyReader`结构实现的`Read`方法。 + +若函数的形参为空接口,则实参可以为任意类型,因为空接口没有定义任何方法,任意类型都是空接口的一种实现。 +```go +package main + +import "fmt" + + +func DoJob(value interface{}) { + fmt.Printf("value is %v\n", value) +} + +func main() { + val := 10 + DoJob(val) +} +``` +运行结果: +``` +value is 10 +``` + +## 接口嵌套 +接口嵌套就是一个接口中包含了其他接口,如果要实现外部接口,则需要实现内部嵌套的接口对应的所有方法。 +看具体例子: +```go +package main + +import "fmt" + +type A interface { + run1() +} + +type B interface { + run2() +} + +// 定义嵌套接口C +type C interface { + A + B + run3() +} + +type Runner struct {} + +// 实现嵌套接口A的方法 +func (r Runner ) run1() { + fmt.Println("run1!!!!") +} + +// 实现嵌套接口B的方法 +func (r Runner ) run2() { + fmt.Println("run2!!!!") +} + +func (r Runner ) run3() { + fmt.Println("run3!!!!") +} + +func main() { + var runner C + runner = new(Runner) // runner实现了C接口的所有方法 + runner.run1() + runner.run2() + runner.run3() +} +``` +运行结果: +``` +run1!!!! +run2!!!! +run3!!!! +``` + + + + + + + + +## 接口的嵌套 + diff --git a/hzh/GolangStar/Go语言基础/Go语言数组与切片.md b/hzh/GolangStar/Go语言基础/Go语言数组与切片.md new file mode 100644 index 0000000..5080b84 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言数组与切片.md @@ -0,0 +1,141 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 数组 + - 切片 +--- + +# Go语言数组与切片 + +在Go语言中,数组和切片是两种重要的数据结构,它们看起来很相似,但使用场景和特点却大不相同。今天就让我们通过一些生动的例子,深入理解这两个概念。 + +## 数组:固定长度的"储物柜" + +### 什么是数组? +想象一下,数组就像一个固定大小的储物柜,有着明确数量的格子,每个格子只能存放相同类型的物品。一旦这个储物柜建好了,格子的数量就不能改变了。 + +### 如何定义数组 +在Go语言中,定义数组有多种方式: + +```go +// 1. 最基础的定义方式 +var scores [3]int // 定义一个能存放3个整数的数组 + +// 2. 定义时直接赋值 +var prices = [3]float64{10.99, 20.99, 30.99} + +// 3. 让编译器自动计算长度 +names := [...]string{"张三", "李四", "王五"} + +// 4. 指定特定位置的值 +colors := [5]string{0: "红", 2: "蓝", 4: "绿"} // [红 "" 蓝 "" 绿] +``` + +### 数组的特点 +1. 长度固定:一旦定义,长度不可改变 +2. 类型固定:同一个数组只能存储相同类型的元素 +3. 长度是类型的一部分:`[3]int`和`[5]int`是不同的类型 + +### 实战小例子 +```go +package main + +import "fmt" + +func main() { + // 成绩管理系统 + var scores [5]int = [5]int{95, 89, 92, 88, 96} + + // 计算平均分 + sum := 0 + for _, score := range scores { + sum += score + } + average := float64(sum) / float64(len(scores)) + fmt.Printf("平均分:%.2f\n", average) +} +``` +运行结果: +``` +平均分:92.00 +``` + +## 切片:灵活的"动态储物柜" + +### 为什么需要切片? +如果说数组是固定大小的储物柜,那么切片就像是一个可以随时扩展的"魔法储物柜"。它的大小是可以动态调整的,这让我们在处理不确定数量的数据时更加灵活。 + +### 切片的创建方式 +```go +// 1. 直接创建 +fruits := []string{"苹果", "香蕉", "橙子"} + +// 2. 使用make函数创建 +numbers := make([]int, 3, 5) // 长度为3,容量为5的切片 + +// 3. 从数组创建 +arr := [5]int{1, 2, 3, 4, 5} +slice := arr[1:4] // [2 3 4] +``` + +### 切片的三个核心概念 +1. 指针:指向底层数组的第一个可见元素 +2. 长度:切片当前的元素个数(len) +3. 容量:从切片起始位置到底层数组末尾的元素个数(cap) + +### 实用的切片操作 +```go +package main + +import "fmt" + +func main() { + // 创建一个购物清单 + shoppingList := []string{"牛奶"} + + // 添加商品 + shoppingList = append(shoppingList, "面包") + shoppingList = append(shoppingList, "水果", "蔬菜") + + fmt.Printf("购物清单:%v\n", shoppingList) + fmt.Printf("清单长度:%d\n", len(shoppingList)) + fmt.Printf("清单容量:%d\n", cap(shoppingList)) + + // 复制切片 + backup := make([]string, len(shoppingList)) + copy(backup, shoppingList) + + fmt.Printf("备份清单:%v\n", backup) +} +``` +运行结果: +``` +购物清单:[牛奶 面包 水果 蔬菜] +清单长度:4 +清单容量:4 +备份清单:[牛奶 面包 水果 蔬菜] +``` + +### 切片的注意事项 +1. 切片是引用类型,多个切片可能共享同一个底层数组 +2. append可能导致重新分配内存,生成新的底层数组 +3. 使用make创建切片时,可以指定容量来减少内存重新分配的次数 + +## 实际应用场景对比 + +1. 什么时候用数组? + - 当你确切知道数据的长度,且不会改变时 + - 例如:存储一周七天的温度数据 + +2. 什么时候用切片? + - 处理动态数据,如用户输入的列表 + - 需要对数据进行频繁的添加和删除操作 + - 作为函数参数传递(更加灵活) + +## 小结 + +数组和切片各有特点:数组像是一个固定大小的储物柜,适合存放固定数量的元素;而切片则像是一个可伸缩的储物空间,更适合处理动态数据。在实际开发中,我们更多地使用切片,因为它提供了更好的灵活性和便利性。 + +记住:选择数组还是切片,关键要看具体的使用场景。理解它们的特点,才能在编程时做出正确的选择。 \ No newline at end of file diff --git a/hzh/GolangStar/Go语言基础/Go语言方法.md b/hzh/GolangStar/Go语言基础/Go语言方法.md new file mode 100644 index 0000000..28ecf87 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言方法.md @@ -0,0 +1,173 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 方法 +--- + +# Go语言方法 + +## 方法的定义 +方法是一类特殊的函数,方法是绑定在某种类型的变量上的函数,它有一定的约束范围。而变量的类型不仅仅局限于结构体类型,可以是任意类型,比如`int`、`bool`等的别名类型。 +有过c++或者java基础的同学可以这样理解,一个`struct`加上绑定在这个类型上的方法就等价于一个类。 +方法的定义格式如下 + +```go +func (变量 类型) 方法名(参数列表)(返回值列表){ + +} +``` + +- 变量:表示方法接收者,即方法所属的对象 +- 类型:表示变量的类型 +- 方法名:表示方法的名称 +- 参数列表:表示方法的参数 +- 返回值列表:表示方法的返回值 + +对比前面我们学习过的函数,方法跟函数的唯一区别就是在`func`关键字和函数名之间加了一个接收者类型和接收者,同样,这个接收者的类型可以是普通变量或者指针变量 +方法的调用跟结构体属性的调用方式相同,都是通过`.`操作符来完成 +下面给`Student`结构定义一个方法 +```go +package main + +import "fmt" + +type Student struct { + ID int + Name string + Age int + Score int +} + +func (st *Student) GetName() string { + return st.Name +} + +func main() { + st := &Student{ + ID : 100, + Name : "zhangsan", + Age : 18, + Score : 98, + } + fmt.Printf("学生st的姓名是: %s\n", st.GetName()) // 调用Student的方法GetName +} +``` +运行结果: +``` +学生st的姓名是: zhangsan +``` + +## 方法的调用 +定义在指针类型或者值类型上的方法可以通过指针变量或者值变量来调用,可能读起来有点绕,下面看例子,会更清晰 +```go +package main + +import "fmt" + +type Student struct { + ID int + Name string + Age int + Score int +} + +func (st *Student) SetScore(score int) { + st.Score = score +} + +func (st Student) GetScore() int{ + return st.Score +} + +func main() { + st := &Student{ + ID : 100, + Name : "zhangsan", + Age : 18, + Score : 98, + } + fmt.Printf("设置前,学生st的分数是: %d\n", st.GetScore()) // 通过指针调用定义在值类型的方法GetScore + st.SetScore(100) // 通过指针调用定义在指针类型上的方法 + fmt.Printf("设置后,学生st的分数是: %d\n", st.GetScore()) // 通过指针调用定义在值类型的方法GetScore +} +``` +运行结果: +``` +设置前,学生st的分数是: 98 +设置后,学生st的分数是: 100 +``` +## 继承 +go语言不像c++或者java一样有显示的继承关系,因为go语言没有类的概念,所以自然就不存在父类,子类一说,自然就没有继承的关系。那么,go语言是如何实现功能的呢? +答案是:**组合**,go语言中不论是属性,还是方法的继承都是通过组合来实现的。 +请看下面例子 +```go +type People struct { + Name string + Age int +} + +type Student struct { + ID int + Score int + People // 将People作为Student的一个属性,注意不要加类型,这就是隐式继承 +} +``` +将原来`Studen`t的定义改为`Student`和`People`两个结构来定义,通过这种组合的方式,其实`Student`就拥有了`People`的`Name`属性和`Age`属性 +代码展示 +```go +package main + +import "fmt" + +type People struct { + Name string + Age int +} + +func (p *People) GetName() string{ + return p.Name +} + +type Student struct { + ID int + Score int + People // 将People作为Student的一个属性,注意不要加类型,这就是隐式继承 +} + +func (st *Student) SetScore(score int) { + st.Score = score +} + +func (st Student) GetScore() int{ + return st.Score +} + +func main() { + st := &Student{ + ID : 100, + Score: 98, + People : People { + Name : "zhangsan", + Age : 18, + }, + } + fmt.Printf("学生st的分数是: %d\n", st.GetScore()) // 通过指针调用定义在值类型的方法GetScore // 通过指针调用定义在指针类型上的方法 + fmt.Printf("学生st的姓名是: %s\n", st.GetName()) +} +``` +运行结果: +``` +学生st的分数是: 98 +学生st的姓名是: zhangsan +``` +这个例子中,`Student`通过内嵌`People`获得了`People`的`Name`和`Age`属性以及`GetName`方法。 + + + + + + + + diff --git a/hzh/GolangStar/Go语言基础/Go语言条件句.md b/hzh/GolangStar/Go语言基础/Go语言条件句.md new file mode 100644 index 0000000..930d993 --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言条件句.md @@ -0,0 +1,295 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 条件语句 + - 流程控制 +--- + +# Go语言条件语句 + +条件语句是编程语言中最基本也是最常用的语句之一,它让程序可以根据不同的条件执行不同的代码块。Go语言提供了灵活而强大的条件语句支持。 + +## if 语句 + +### 基本语法 +Go语言中的if语句不需要使用括号将条件包含起来,但是程序体必须使用大括号。基本语法如下: + +```go +if condition { + // code to execute if condition is true +} +``` + +例如: +```go +package main + +import "fmt" + +func main() { + age := 18 + if age >= 18 { + fmt.Println("你已经成年了") + } +} +``` +运行结果: +``` +你已经成年了 +``` + +### if-else 语句 +当条件不满足时,可以使用else执行另一段代码: + +```go +package main + +import "fmt" + +func main() { + score := 85 + if score >= 90 { + fmt.Println("优秀") + } else { + fmt.Println("继续努力") + } +} +``` +运行结果: +``` +继续努力 +``` + +### if-else if-else 语句 +当需要判断多个条件时,可以使用if-else if-else结构: + +```go +package main + +import "fmt" + +func main() { + score := 85 + if score >= 90 { + fmt.Println("优秀") + } else if score >= 80 { + fmt.Println("良好") + } else if score >= 60 { + fmt.Println("及格") + } else { + fmt.Println("不及格") + } +} +``` +运行结果: +``` +良好 +``` + +### if 的特殊写法 +Go语言支持在if条件判断之前执行一个简单的语句: + +```go +package main + +import "fmt" + +func main() { + if num := 9; num < 10 { + fmt.Println(num, "小于10") + } else { + fmt.Println(num, "大于等于10") + } + // 注意:num变量的作用域仅在if-else语句块内 +} +``` +运行结果: +``` +9 小于10 +``` + +## switch 语句 + +### 基本用法 +switch语句是多条件判断的另一种选择,相比于if-else更加清晰和简洁。Go语言的switch比传统的C/C++语言更加灵活: +- 不需要写break语句,匹配到case后自动中断 +- 可以使用任意类型作为条件值,不限于常量或整数 +- case语句可以使用表达式 + +基本语法示例: +```go +package main + +import "fmt" + +func main() { + day := 3 + switch day { + case 1: + fmt.Println("星期一") + case 2: + fmt.Println("星期二") + case 3: + fmt.Println("星期三") + case 4: + fmt.Println("星期四") + case 5: + fmt.Println("星期五") + default: // 可选的默认分支,当所有case都不匹配时执行 + fmt.Println("周末") + } +} +``` +运行结果: +``` +星期三 +``` + +### switch的特殊用法 + +1. 一个case可以有多个值,多个值之间使用逗号分隔: +```go +switch day { +case 1, 2, 3, 4, 5: // 可以匹配多个值,只需要满足其中一个即可 + fmt.Println("工作日") +case 6, 7: + fmt.Println("周末") +} +``` + +2. 省略switch后的表达式,这种形式更接近于if-else结构: +```go +score := 85 +switch { // 省略条件表达式,默认为true +case score >= 90: + fmt.Println("优秀") +case score >= 80: + fmt.Println("良好") +case score >= 60: + fmt.Println("及格") +default: + fmt.Println("不及格") +} +``` + +3. fallthrough关键字: +Go语言中的switch默认带有break效果,但如果需要继续执行下一个case,可以使用fallthrough: +```go +switch { +case score >= 60: + fmt.Println("及格") + fallthrough // 继续执行下一个case,不管下一个case的条件是否满足 +case score >= 0: + fmt.Println("分数有效") +} +``` +运行结果: +``` +及格 +分数有效 +``` +注意:fallthrough必须是case中的最后一条语句,并且会强制执行下一个case的代码块,而不判断条件。 + +4. switch可以用于类型判断: +```go +var x interface{} = 25.0 + +switch v := x.(type) { +case int: + fmt.Printf("x是整数,值为%d\n", v) +case float64: + fmt.Printf("x是浮点数,值为%.2f\n", v) +case string: + fmt.Printf("x是字符串,值为%s\n", v) +default: + fmt.Printf("x的类型未知\n") +} +``` +运行结果: +``` +x是浮点数,值为25.00 +``` + +### switch注意事项 +1. case的值必须是唯一的,不能重复 +2. 每个case默认带有break,不用显式写出 +3. case后的表达式必须与switch的表达式类型一致 +4. 使用fallthrough时要特别小心,确保逻辑正确 +5. default分支可选,但建议添加以处理未考虑的情况 + +## 实际应用示例 + +### 1. 错误处理 +```go +if err := doSomething(); err != nil { + // 处理错误 + fmt.Println("发生错误:", err) + return +} +``` + +### 2. 类型判断 +```go +var i interface{} = "Hello" + +switch v := i.(type) { +case string: + fmt.Printf("字符串: %s\n", v) +case int: + fmt.Printf("整数: %d\n", v) +case bool: + fmt.Printf("布尔值: %v\n", v) +default: + fmt.Printf("未知类型\n") +} +``` + +### 3. 状态机 +```go +type State int + +const ( + Idle State = iota + Running + Paused + Stopped +) + +func handleState(state State) { + switch state { + case Idle: + fmt.Println("系统空闲中") + case Running: + fmt.Println("系统运行中") + case Paused: + fmt.Println("系统已暂停") + case Stopped: + fmt.Println("系统已停止") + } +} +``` + +## 使用技巧和注意事项 + +1. 代码风格 + - if和else后的`{`必须和if或else在同一行 + - 优先使用正向逻辑,避免多重否定 + - 优先处理错误情况,让主逻辑更清晰 + +2. 性能考虑 + - 多条件判断时,将最可能满足的条件放在前面 + - 对于多个条件的判断,考虑使用switch替代if-else链 + +3. 常见陷阱 + - 注意if语句中初始化变量的作用域 + - 不要在switch的case中漏写break(Go自动break) + - 使用fallthrough时要特别小心,确保逻辑正确 + +## 小结 + +条件语句是程序流程控制的基础,Go语言通过简洁的语法和一些特殊的设计,让条件语句的使用更加灵活和高效。掌握好条件语句的各种用法,对于编写清晰、可维护的代码至关重要。 + +记住:选择合适的条件语句形式,让代码逻辑更清晰,可读性更好。过于复杂的条件判断应考虑重构,可能需要重新设计数据结构或使用其他模式。 + diff --git a/hzh/GolangStar/Go语言基础/Go语言结构体.md b/hzh/GolangStar/Go语言基础/Go语言结构体.md new file mode 100644 index 0000000..fab291b --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言结构体.md @@ -0,0 +1,118 @@ +# Go语言结构体 + +有时候内置的基本类型并不能满足我们的业务需求,我们需要一些复合结构。比如我们想要描述一个学生,1个学生既有学号,年龄,性别,分数等这些属性,而单一的数据类型往往只能描述其中一个属性,我们想要描述这个学生,就需要把这些属性都要描述出来,这个时候就需要用到结构体了。 + +## 结构体定义 +像很多其他的高级语言一样,go语言也支持结构体来定义复合数据类型 +定义方式如下: +```go +type Student struct { + ID int + Name string + Age int + Score int +} +``` +上述方法定义了一个`Student`类型的结构体,`Student`包含四个属性,分别是`string`类型的`Name`和`int`类型的`ID`,`Age`和`Score`。 + +## 结构体初始化 +### 键值对初始化 +在初始化的时候以属性:值的方式完成,如果有的属性不写,则为默认值 +```go +package main + +import "fmt" + +type Student struct { + ID int + Name string + Age int + Score int +} + + +func main() { + st := Student{ + ID : 100, + Name : "zhangsan", + Age : 18, + Score : 98, + } + fmt.Printf("学生st: %v\n", st) +} +``` +运行结果: +``` +学生st: {100 zhangsan 18 98} +``` + +### 值列表初始化 +在初始化的时候直接按照属性顺序以属性值来初始化,看下面例子 +```go +package main + +import "fmt" + +type Student struct { + ID int + Name string + Age int + Score int +} + + +func main() { + st := Student{ + 101, + "lisi", + 20, + 97, + } + fmt.Printf("学生st: %v\n", st) +} +``` +运行结果: +``` +学生st: {101 lisi 20 97} +``` +**注意:以值列表的方式初始化结构体,值列表的个数必须等于结构体属性个数,且要按顺序,否则会报错** + +## 结构体成员访问 +使用点号 `.` 操作符来访问结构体的成员,`.` 前可以是结构体变量或者结构体指针 +```go +package main + +import "fmt" + +type Student struct { + ID int + Name string + Age int + Score int +} + + +func main() { + st1 := Student{ + ID : 100, + Name : "zhangsan", + Age : 18, + Score : 98, + } + fmt.Printf("学生1的姓名是: %s\n", st1.Name) + + st2 := &Student{ + ID : 101, + Name : "lisi", + Age : 20, + Score : 97, + } + fmt.Printf("学生2的分数是: %d\n", st2.Score) +} +``` +运行结果: +``` +学生1的姓名是: zhangsan +学生2的分数是: 97 +``` + diff --git a/hzh/GolangStar/Go语言基础/Go语言运算符.md b/hzh/GolangStar/Go语言基础/Go语言运算符.md new file mode 100644 index 0000000..dc2427a --- /dev/null +++ b/hzh/GolangStar/Go语言基础/Go语言运算符.md @@ -0,0 +1,373 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 运算符 +--- + +# Go语言运算符 +Go语言跟其他语言一样,运算符大体上也分为以下几种: +- 算术运算符 +- 关系运算符 +- 逻辑运算符 +- 位运算符 +- 赋值运算符 +- 其他运算符 + +## 算术运算符 +A=10,B=20 +| 运算符 | 描述 | 实例 | +| :---: | :---: | :---: | +| + | 相加 | A + B 输出结果 30 | +| - | 相减 | A - B 输出结果 -10 | +| * | 相乘 | A * B 输出结果 200 | +| / | 相除 | B / A 输出结果 2 | +| % | 求余 | B % A 输出结果 0 | +| ++ | 自增 | A++ 输出结果 11 | +| -- | 自减 | A-- 输出结果 9 | + +代码展示: +```go +package main + +import "fmt" + +func main() { + + var a int = 21 + var b int = 10 + var c int + + c = a + b + fmt.Printf("第一行 - c 的值为 %d\n", c ) + c = a - b + fmt.Printf("第二行 - c 的值为 %d\n", c ) + c = a * b + fmt.Printf("第三行 - c 的值为 %d\n", c ) + c = a / b + fmt.Printf("第四行 - c 的值为 %d\n", c ) + c = a % b + fmt.Printf("第五行 - c 的值为 %d\n", c ) + a++ + fmt.Printf("第六行 - a 的值为 %d\n", a ) + a=21 // 为了方便测试,a 这里重新赋值为 21 + a-- + fmt.Printf("第七行 - a 的值为 %d\n", a ) +} +``` +运算结果: +``` +第一行 - c 的值为 30 +第二行 - c 的值为 10 +第三行 - c 的值为 210 +第四行 - c 的值为 2 +第五行 - c 的值为 1 +第六行 - a 的值为 22 +第七行 - a 的值为 20 +``` +## 关系运算符 +A=10,B=20 +| 运算符 | 描述 | 实例 | +| :---: | :---: | :---: | +| == | 相等 | A == B 输出结果 false | +| != | 不相等 | A != B 输出结果 true | +| > | 大于 | A > B 输出结果 false | +| < | 小于 | A < B 输出结果 true | +| >= | 大于等于 | A >= B 输出结果 false | +| <= | 小于等于 | A <= B 输出结果 true | + +代码展示: +```go +package main + +import "fmt" + +func main() { + var a int = 21 + var b int = 10 + + if( a == b ) { + fmt.Printf("第一行 - a 等于 b\n" ) + } else { + fmt.Printf("第一行 - a 不等于 b\n" ) + } + if ( a < b ) { + fmt.Printf("第二行 - a 小于 b\n" ) + } else { + fmt.Printf("第二行 - a 不小于 b\n" ) + } + + if ( a > b ) { + fmt.Printf("第三行 - a 大于 b\n" ) + } else { + fmt.Printf("第三行 - a 不大于 b\n" ) + } + /* Lets change value of a and b */ + a = 5 + b = 20 + if ( a <= b ) { + fmt.Printf("第四行 - a 小于等于 b\n" ) + } + if ( b >= a ) { + fmt.Printf("第五行 - b 大于等于 a\n" ) + } +} +``` +运算结果: +``` +第一行 - a 不等于 b +第二行 - a 小于 b +第三行 - a 大于 b +第四行 - a 小于等于 b +第五行 - b 大于等于 a +``` + +## 逻辑运算符 +A=10,B=20 +| 运算符 | 描述 | 实例 | +| :---: | :---: | :---: | +| && | 逻辑 AND 运算符。 如果两边的操作数都是 true,则条件 True,否则为 false。 | (A && B) 为 false | +| \|\| | 逻辑 OR 运算符。 如果两边的操作数有一个 true,则条件 True,否则为 false。 | (A \|\| B) 为 true | +| ! | 逻辑 NOT 运算符。 如果条件为 true,则逻辑 NOT 条件 False,否则为 true。 | !(A && B) 为 true | + +代码展示: +```go +package main + +import "fmt" + +func main() { + var a bool = true + var b bool = false + if ( a && b ) { + fmt.Printf("第一行 - 条件为 true\n" ) + } + if ( a || b ) { + fmt.Printf("第二行 - 条件为 true\n" ) + } + /* 修改 a 和 b 的值 */ + a = false + b = true + if ( a && b ) { + fmt.Printf("第三行 - 条件为 true\n" ) + } else { + fmt.Printf("第三行 - 条件为 false\n" ) + } + if ( !(a && b) ) { + fmt.Printf("第四行 - 条件为 true\n" ) + } +} +``` +运算结果: +``` +第一行 - 条件为 true +第二行 - 条件为 true +第三行 - 条件为 false +第四行 - 条件为 true +``` + +## 位运算符 +位运算符是对内存中的二进制数进行按位运算,包括&,|, ^ ,<<,>>这么几种 +A=10,B=20 +| 运算符 | 描述 | 实例 | +| :---: | :---: | :---: | +| & | 按位与运算符"&"是双目运算符。 其功能是参与运算的两数各对应的二进位相与。 | (A & B) 结果为 0 0 0 0 1 0 0 0 | +| \| | 按位或运算符"|"是双目运算符。 其功能是参与运算的两数各对应的二进位相或。 | (A \| B) 结果为 0 0 1 1 1 1 1 1 | +| ^ | 按位异或运算符"^"是双目运算符。 其功能是参与运算的两数各对应的二进位相异或,当两对应的二进位相异时,结果为1。 | (A ^ B) 结果为 0 0 1 1 0 0 0 0 | +| << | 左移运算符"<<"是双目运算符。左移n位就是乘以2的n次方。 其功能把"<<"左边的运算数的各二进位全部左移若干位,由"<<"右边的数指定移动的位数,高位丢弃,低位补0。 | A << 2 结果为 40 , 2 左移 2 位 = 40 | +| >> | 右移运算符">>"是双目运算符。右移n位就是除以2的n次方。 其功能是把">>"左边的运算数的各二进位全部右移若干位,">>"右边的数指定移动的位数。 | A >> 2 结果为 10 , 10 右移 2 位 = 10 | + +代码展示: +```go +package main + +import "fmt" + +func main() { + + var a uint = 60 /* 60 = 0011 1100 */ + var b uint = 13 /* 13 = 0000 1101 */ + var c uint = 0 + + c = a & b /* 12 = 0000 1100 */ + fmt.Printf("第一行 - c 的值为 %d\n", c ) + + c = a | b /* 61 = 0011 1101 */ + fmt.Printf("第二行 - c 的值为 %d\n", c ) + + c = a ^ b /* 49 = 0011 0001 */ + fmt.Printf("第三行 - c 的值为 %d\n", c ) + + c = a << 2 /* 240 = 1111 0000 */ + fmt.Printf("第四行 - c 的值为 %d\n", c ) + + c = a >> 2 /* 15 = 0000 1111 */ + fmt.Printf("第五行 - c 的值为 %d\n", c ) +} +``` +运算结果: +``` +第一行 - c 的值为 12 +第二行 - c 的值为 61 +第三行 - c 的值为 49 +第四行 - c 的值为 240 +第五行 - c 的值为 15 +``` +## 赋值运算符 +A=10,B=20 +| 运算符 | 描述 | 实例 | +| :---: | :---: | :---: | +| = | 简单的赋值运算符,将一个表达式的值赋给一个左值 | C = A + B 将 A + B 表达式结果赋值给 C | +| += | 相加后再赋值 | C += A 等于 C = C + A | +| -= | 相减后再赋值 | C -= A 等于 C = C - A | +| *= | 相乘后再赋值 | C *= A 等于 C = C * A | +| /= | 相除后再赋值 | C /= A 等于 C = C / A | +| %= | 求余后再赋值 | C %= A 等于 C = C % A | +| <<= | 左移后赋值 | C <<= 2 等于 C = C << 2 | +| >>= | 右移后赋值 | C >>= 2 等于 C = C >> 2 | +| &= | 按位与后赋值 | C &= 2 等于 C = C & 2 | +| ^= | 按位异或后赋值 | C ^= 2 等于 C = C ^ 2 | +| \|= | 按位或后赋值 | C \|= 2 等于 C = C \| 2 | + +代码展示: +```go +package main + +import "fmt" + +func main() { + var a int = 21 + var c int + + c = a + fmt.Printf("第 1 行 - = 运算符实例,c 值为 = %d\n", c ) + + c += a + fmt.Printf("第 2 行 - += 运算符实例,c 值为 = %d\n", c ) + + c -= a + fmt.Printf("第 3 行 - -= 运算符实例,c 值为 = %d\n", c ) + + c *= a + fmt.Printf("第 4 行 - *= 运算符实例,c 值为 = %d\n", c ) + + c /= a + fmt.Printf("第 5 行 - /= 运算符实例,c 值为 = %d\n", c ) + + c = 200; + + c <<= 2 + fmt.Printf("第 6行 - <<= 运算符实例,c 值为 = %d\n", c ) + + c >>= 2 + fmt.Printf("第 7 行 - >>= 运算符实例,c 值为 = %d\n", c ) + + c &= 2 + fmt.Printf("第 8 行 - &= 运算符实例,c 值为 = %d\n", c ) + + c ^= 2 + fmt.Printf("第 9 行 - ^= 运算符实例,c 值为 = %d\n", c ) + + c |= 2 + fmt.Printf("第 10 行 - |= 运算符实例,c 值为 = %d\n", c ) +} +``` +运算结果: +``` +第 1 行 - = 运算符实例,c 值为 = 21 +第 2 行 - += 运算符实例,c 值为 = 42 +第 3 行 - -= 运算符实例,c 值为 = 21 +第 4 行 - *= 运算符实例,c 值为 = 441 +第 5 行 - /= 运算符实例,c 值为 = 21 +第 6行 - <<= 运算符实例,c 值为 = 800 +第 7 行 - >>= 运算符实例,c 值为 = 200 +第 8 行 - &= 运算符实例,c 值为 = 0 +第 9 行 - ^= 运算符实例,c 值为 = 2 +第 10 行 - |= 运算符实例,c 值为 = 2 +``` + +## 其他运算符 +Go语言跟c++语言一样也有指针和地址的概念,所以也会有取地址运算符&和指针运算符*。 +A=10,B=20 +| 运算符 | 描述 | 实例 | +| :---: | :---: | :---: | +| & | 返回变量存储地址 | &a; 将给出变量的实际地址。 | +| * | 指针变量。 | *a; 是一个指针变量 | + +代码展示: +```go +package main + +import "fmt" + +func main() { + var a int = 4 + var b int32 + var c float32 + var ptr *int + + /* 运算符实例 */ + fmt.Printf("第 1 行 - a 变量类型为 = %T\n", a ); + fmt.Printf("第 2 行 - b 变量类型为 = %T\n", b ); + fmt.Printf("第 3 行 - c 变量类型为 = %T\n", c ); + + /* & 和 * 运算符实例 */ + ptr = &a /* 'ptr' 包含了 'a' 变量的地址 */ + fmt.Printf("a 的值为 %d\n", a); + fmt.Printf("*ptr 为 %d\n", *ptr); +} +``` +运算结果: +``` +第 1 行 - a 变量类型为 = int +第 2 行 - b 变量类型为 = int32 +第 3 行 - c 变量类型为 = float32 +a 的值为 4 +*ptr 为 4 +``` + +## 运算符优先级 +有些运算符拥有较高的优先级,二元运算符的运算方向均是从左至右。下表列出了所有运算符以及它们的优先级,由上至下代表优先级由高到低: +| 优先级 | 运算符 | +| :---: | :---: | +| 1 | * / % << >> & &^ | +| 2 | + - | ^ | +| 3 | == != < <= > >= | +| 4 | &&| +| 5 | \|\| | + +跟其他语言一样,你可以通过使用括号来临时提升某个表达式的整体运算优先级。 +代码展示: +```go +package main + +import "fmt" + +func main() { + var a int = 20 + var b int = 10 + var c int = 15 + var d int = 5 + var e int; + + e = (a + b) * c / d; // ( 30 * 15 ) / 5 + fmt.Printf("(a + b) * c / d 的值为 : %d\n", e ); + + e = ((a + b) * c) / d; // (30 * 15 ) / 5 + fmt.Printf("((a + b) * c) / d 的值为 : %d\n" , e ); + + e = (a + b) * (c / d); // (30) * (15/5) + fmt.Printf("(a + b) * (c / d) 的值为 : %d\n", e ); + + e = a + (b * c) / d; // 20 + (150/5) + fmt.Printf("a + (b * c) / d 的值为 : %d\n" , e ); +} +``` +运算结果: +``` +(a + b) * c / d 的值为 : 90 +((a + b) * c) / d 的值为 : 90 +(a + b) * (c / d) 的值为 : 90 +a + (b * c) / d 的值为 : 50 +``` diff --git a/hzh/GolangStar/Go语言框架/gin.md b/hzh/GolangStar/Go语言框架/gin.md new file mode 100644 index 0000000..7445eec --- /dev/null +++ b/hzh/GolangStar/Go语言框架/gin.md @@ -0,0 +1,362 @@ +--- +tags: + - Go + - golang + - gin + - web框架 + - http +--- + +# Gin + +Gin是一个用Go语言编写的高性能Web框架,以其高效的路由系统和优秀的性能著称。本文将详细介绍如何使用Gin框架开发Web应用。 + +## 安装Gin + +首先,我们需要安装Gin框架。可以使用以下命令: + +```bash +go get -u github.com/gin-gonic/gin +``` + +## 基本使用 + +### 创建HTTP服务器 + +使用Gin创建一个基本的HTTP服务器非常简单: + +```go +package main + +import ( + "github.com/gin-gonic/gin" + "net/http" +) + +func main() { + // 创建默认的路由引擎 + r := gin.Default() + + // 注册一个GET路由 + r.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "message": "pong", + }) + }) + + // 启动HTTP服务,默认在0.0.0.0:8080启动服务 + r.Run() +} +``` + +### 路由参数 + +Gin支持多种路由参数形式: + +```go +// 获取get请求的路径参数,参数名为name +r.GET("/user/:name", func(c *gin.Context) { + name := c.Param("name") + c.String(http.StatusOK, "Hello %s", name) +}) + +// 获取get请求的路径参数,参数名为name,age,age的默认值为20 +r.GET("/users", func(c *gin.Context) { + name := c.Query("name") // 获取name参数 + age := c.DefaultQuery("age", "20") // 获取age参数,默认值为20 + c.JSON(http.StatusOK, gin.H{ + "name": name, + "age": age, + }) +}) +``` + +### 处理表单数据 + +Gin可以轻松处理POST请求中的表单数据: + +```go +r.POST("/form", func(c *gin.Context) { + // 获取表单参数 + username := c.PostForm("username") + password := c.DefaultPostForm("password", "") + + c.JSON(http.StatusOK, gin.H{ + "username": username, + "password": password, + }) +}) +``` + +### 处理JSON数据 + +Gin可以轻松处理POST请求中的JSON数据: + +```go +// 定义请求体结构 +type LoginRequest struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +r.POST("/login", func(c *gin.Context) { + var login LoginRequest + + // 将请求体绑定到结构体 + if err := c.BindJSON(&login); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + // 处理登录逻辑 + c.JSON(http.StatusOK, gin.H{ + "message": "登录成功", + "username": login.Username, + }) +}) + +// 也可以直接使用map接收JSON数据 +r.POST("/raw", func(c *gin.Context) { + var data map[string]interface{} + + if err := c.BindJSON(&data); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, data) +}) +``` + +在上面的示例中: +1. 使用结构体接收JSON数据时,可以通过tag来指定字段的验证规则 +2. `binding:"required"`表示该字段是必需的 +3. 也可以使用map来接收不固定结构的JSON数据 +4. `c.BindJSON()`会自动检查Content-Type是否为application/json + +### 上传文件 + +Gin支持单文件和多文件上传: + +```go +// 单文件上传 +r.POST("/upload", func(c *gin.Context) { + file, _ := c.FormFile("file") + + // 保存文件 + c.SaveUploadedFile(file, "./"+file.Filename) + + c.String(http.StatusOK, "File %s uploaded successfully", file.Filename) +}) + +// 多文件上传 +r.POST("/uploads", func(c *gin.Context) { + form, _ := c.MultipartForm() + files := form.File["files"] + + for _, file := range files { + c.SaveUploadedFile(file, "./"+file.Filename) + } + + c.String(http.StatusOK, "%d files uploaded successfully", len(files)) +}) +``` + +### 中间件 + +Gin的中间件可以在请求处理过程中执行一些公共的操作: + +```go +// 定义一个中间件 +func Logger() gin.HandlerFunc { + return func(c *gin.Context) { + t := time.Now() + + // 在请求之前 + + c.Next() + + // 在请求之后 + latency := time.Since(t) + log.Printf("请求处理时间: %v", latency) + } +} + +// 使用中间件 +r.Use(Logger()) +``` + +### 分组路由 + +Gin支持路由分组,可以更好地组织API: + +```go +// 创建v1版本的路由组 +v1 := r.Group("/v1") +{ + v1.POST("/login", loginEndpoint) + v1.POST("/submit", submitEndpoint) + v1.POST("/read", readEndpoint) +} + +// 创建v2版本的路由组 +v2 := r.Group("/v2") +{ + v2.POST("/login", loginEndpointV2) + v2.POST("/submit", submitEndpointV2) + v2.POST("/read", readEndpointV2) +} +``` + +## 完整示例 + +以下是一个完整的示例,展示了如何使用Gin框架创建一个简单的RESTful API服务: + +```go +package main + +import ( + "github.com/gin-gonic/gin" + "log" + "net/http" +) + +// User 用户模型 +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Age int `json:"age"` +} + +// 模拟数据库 +var users = []User{ + {ID: 1, Name: "张三", Age: 25}, + {ID: 2, Name: "李四", Age: 30}, +} + +func main() { + // 创建默认的路由引擎 + r := gin.Default() + + // 注册路由 + r.GET("/users", getUsers) + r.GET("/users/:id", getUserByID) // 这里的冒号表示路径参数,可以通过c.Param("id")获取 + r.POST("/users", createUser) + r.PUT("/users/:id", updateUser) + r.DELETE("/users/:id", deleteUser) + + // 启动http服务器,监听8080端口 + if err := r.Run(":8080"); err != nil { + log.Fatalf("Server error: %v", err) + } +} + +// 获取所有用户 +func getUsers(c *gin.Context) { + c.JSON(http.StatusOK, users) +} + +// 根据ID获取用户 +func getUserByID(c *gin.Context) { + id := c.Param("id") + for _, user := range users { + if string(user.ID) == id { + c.JSON(http.StatusOK, user) + return + } + } + c.JSON(http.StatusNotFound, gin.H{"message": "User not found"}) +} + +// 创建用户 +func createUser(c *gin.Context) { + var newUser User + if err := c.BindJSON(&newUser); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + users = append(users, newUser) + c.JSON(http.StatusCreated, newUser) +} + +// 更新用户 +func updateUser(c *gin.Context) { + // 获取路径参数中的ID + id := c.Param("id") + var updatedUser User + + // 绑定请求体中的JSON数据到updatedUser + if err := c.BindJSON(&updatedUser); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + for i, user := range users { + if string(user.ID) == id { + users[i] = updatedUser + c.JSON(http.StatusOK, updatedUser) + return + } + } + + c.JSON(http.StatusNotFound, gin.H{"message": "User not found"}) +} + +// 删除用户 +func deleteUser(c *gin.Context) { + id := c.Param("id") + for i, user := range users { + if string(user.ID) == id { + users = append(users[:i], users[i+1:]...) + c.JSON(http.StatusOK, gin.H{"message": "User deleted"}) + return + } + } + c.JSON(http.StatusNotFound, gin.H{"message": "User not found"}) +} +``` + +### 程序测试 + +使用curl命令测试API: + +```bash +# 获取所有用户 +curl http://localhost:8080/users + +# 获取指定用户 +curl http://localhost:8080/users/1 + +# 创建用户 +curl -X POST -H "Content-Type: application/json" -d '{"id":3,"name":"王五","age":35}' http://localhost:8080/users + +# 更新用户 +curl -X PUT -H "Content-Type: application/json" -d '{"id":1,"name":"张三丰","age":26}' http://localhost:8080/users/1 + +# 删除用户 +curl -X DELETE http://localhost:8080/users/2 +``` + +### 代码说明 + +1. **路由设置**:使用`gin.Default()`创建路由引擎,并注册了基本的RESTful API路由。 + +2. **数据模型**:定义了`User`结构体作为数据模型,并使用切片模拟数据库存储。 + +3. **请求处理**:实现了增删改查(CRUD)的基本操作,包括: + - GET /users:获取所有用户 + - GET /users/:id:获取指定用户 + - POST /users:创建新用户 + - PUT /users/:id:更新用户 + - DELETE /users/:id:删除用户 + +4. **错误处理**:对各种可能的错误情况进行了处理,并返回适当的HTTP状态码。 + +## 小结 + +这篇文章简单介绍了Gin框架的基本使用方法,包括路由配置、参数获取、中间件使用等内容。如需了解Gin框架更详细的用法,可以参考:[Gin框架官方文档](https://gin-gonic.com/zh-cn/docs/) \ No newline at end of file diff --git a/hzh/GolangStar/Go语言框架/gorm.md b/hzh/GolangStar/Go语言框架/gorm.md new file mode 100644 index 0000000..e19ba08 --- /dev/null +++ b/hzh/GolangStar/Go语言框架/gorm.md @@ -0,0 +1,274 @@ +--- +tags: + - Go + - golang + - gorm + - mysql + - 数据库 +--- + +# GORM + +GORM是一个功能强大的Go语言ORM库,提供了简洁的API来操作数据库。本文将详细介绍如何使用GORM与MySQL数据库进行交互。 + +## 安装GORM和MySQL驱动 + +首先,我们需要安装GORM和MySQL驱动。可以使用以下命令: + +```bash +go get -u gorm.io/gorm +go get -u gorm.io/driver/mysql +``` + +## 连接数据库 + +在使用GORM连接MySQL数据库时,我们需要提供数据源名称(DSN),其中包含用户名、密码、主机地址、端口号和数据库名称等信息。 + +```go +package main + +import ( + "gorm.io/driver/mysql" + "gorm.io/gorm" + "log" +) + +func main() { + // 定义数据源名称 + dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" + + // 连接数据库 + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } +} +``` + +## 初始化连接 + +GORM会自动管理数据库连接池,因此我们不需要手动设置最大连接数和最大空闲连接数。不过,如果有特殊需求,可以通过`sql.DB`对象进行设置。 + +```go +sqlDB, err := db.DB() +if err != nil { + log.Fatalf("failed to get database object: %v", err) +} + +// 设置最大打开连接数 +sqlDB.SetMaxOpenConns(100) + +// 设置最大空闲连接数 +sqlDB.SetMaxIdleConns(10) +``` + +## 定义模型 + +在GORM中,我们通过定义结构体来映射数据库表。每个字段对应表中的一列。 + +```go +// User 结构体映射到数据库中的users表 +type User struct { + ID uint `gorm:"primaryKey;column:id"` // ID为主键 + Name string `gorm:"size:255;column:name"` // Name字段最大长度为255 + Age int `gorm:"column:age"` // Age字段 +} +``` + +## 自动迁移 + +GORM提供了自动迁移功能,可以根据模型结构自动创建或更新数据库表。 + +```go +// 自动迁移User结构体 +db.AutoMigrate(&User{}) +``` + +## CRUD操作 + +### 创建记录 + +使用`Create`方法可以向数据库中插入一条新记录。 + +```go +// 创建一个新的User记录 +user := User{Name: "张三", Age: 25} +result := db.Create(&user) +if result.Error != nil { + log.Fatalf("failed to create user: %v", result.Error) +} +``` + +### 查询记录 + +#### 单行查询 + +使用`First`方法可以查询符合条件的第一条记录。 + +```go +// 查询ID为1的User记录 +var user User +result := db.First(&user, 1) // 根据主键查询 +if result.Error != nil { + log.Printf("failed to find user: %v", result.Error) +} +``` + +#### 多行查询 + +使用`Find`方法可以查询符合条件的多条记录。 + +```go +// 查询年龄大于20的所有User记录 +var users []User +result := db.Where("age > ?", 20).Find(&users) +if result.Error != nil { + log.Printf("failed to find users: %v", result.Error) +} +``` + +### 更新记录 + +使用`Save`方法可以更新记录。 + +```go +// 更新User记录的Age字段 +user.Age = 26 +result := db.Save(&user) +if result.Error != nil { + log.Fatalf("failed to update user: %v", result.Error) +} +``` + +### 删除记录 + +使用`Delete`方法可以删除记录。 + +```go +// 删除User记录 +result := db.Delete(&user) +if result.Error != nil { + log.Fatalf("failed to delete user: %v", result.Error) +} +``` + +## 事务支持 + +GORM支持事务操作,可以通过`Transaction`方法来执行事务。 + +```go +// 使用事务创建两个User记录 +err := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&User{Name: "李四", Age: 30}).Error; err != nil { + return err + } + if err := tx.Create(&User{Name: "王五", Age: 35}).Error; err != nil { + return err + } + return nil +}) +if err != nil { + log.Fatalf("transaction failed: %v", err) +} +``` + +## 预处理和SQL注入 + +GORM会自动处理SQL注入问题,因此我们不需要手动拼接SQL语句。所有的查询条件都可以通过链式调用来实现。 + +```go +// 查询Name为"张三"的User记录 +db.Where("name = ?", "张三").First(&user) +``` + +## 完整示例 +以下是一个完整的示例代码,展示了如何使用GORM连接MySQL数据库并进行CRUD操作。 +```go +package main + +import ( + "fmt" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "log" +) + +// User 结构体映射到数据库中的users表 +type User struct { + ID uint `gorm:"primaryKey;column:id"` // ID为主键 + Name string `gorm:"size:255;column:name"` // Name字段最大长度为255 + Age int `gorm:"column:age"` // Age字段 +} + +func main() { + // 定义数据源名称 + dsn := "user:password@tcp(127.0.0.1:3306)/dbname?charset=utf8mb4&parseTime=True&loc=Local" + + // 连接数据库 + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + log.Fatalf("failed to connect database: %v", err) + } + + // 自动迁移User结构体 + db.AutoMigrate(&User{}) + + // 创建一个新的User记录 + user := User{Name: "张三", Age: 25} + result := db.Create(&user) + if result.Error != nil { + log.Fatalf("failed to create user: %v", result.Error) + } + + // 查询ID为1的User记录 + var queriedUser User + result = db.First(&queriedUser, user.ID) // 根据主键查询 + if result.Error != nil { + log.Printf("failed to find user: %v", result.Error) + } else { + fmt.Printf("Queried User: %+v\n", queriedUser) + } + + // 更新User记录的Age字段 + queriedUser.Age = 26 + result = db.Save(&queriedUser) + if result.Error != nil { + log.Fatalf("failed to update user: %v", result.Error) + } + + // 删除User记录 + result = db.Delete(&queriedUser) + if result.Error != nil { + log.Fatalf("failed to delete user: %v", result.Error) + } +} +``` + +### 程序输出 + +假设数据库连接成功,程序将输出如下信息: + +``` +Queried User: {ID:1 Name:张三 Age:25} +``` + +这表示程序成功创建、查询、更新并删除了一条用户记录。 + +### 代码说明 + +1. **连接数据库**:使用`gorm.Open`方法连接MySQL数据库,数据源名称(DSN)中包含了数据库连接的必要信息。 + +2. **自动迁移**:使用`AutoMigrate`方法根据`User`结构体自动创建或更新数据库表。 + +3. **创建记录**:使用`Create`方法向数据库中插入一条新记录。 + +4. **查询记录**:使用`First`方法查询符合条件的第一条记录。 + +5. **更新记录**:使用`Save`方法更新记录。 + +6. **删除记录**:使用`Delete`方法删除记录。 + +--- + +## 小结 +这篇文章简单介绍了GORM的基本使用方法,包括连接数据库、定义模型、CRUD操作、事务支持等内容。希望对您有所帮助!,如需了解GORM详细的用法,可以参考:[GORM中文官网](https://gorm.io/zh_CN/docs/) \ No newline at end of file diff --git a/hzh/GolangStar/Go语言进阶/Channel.md b/hzh/GolangStar/Go语言进阶/Channel.md new file mode 100644 index 0000000..5262658 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/Channel.md @@ -0,0 +1,287 @@ +--- +tags: + - Go + - golang + - go进阶语法 + - Channel +--- + +# Channel +我们知道可以通过`go`关键字来开启一个`goroutine`,我们的样例代码逻辑很简单,都是在各个`goroutine`各自处理自己的逻辑,但有时候我们需要不同的`goroutine`之间能够通信,这里就要用到`channel`。 + +## channel是什么 +官方定义: +Channels are a typed conduit through which you can send and receive values with the channel operator +Channel是一个可以收发数据的管道 + +## channel初始化 +channel的声明方式如下: +```go +var channel_name chan channel_type +var channel_name [size]chan channel_type // 声明一个channel,其容量大小为size +``` +声明之后的管道,并没有进行初始化为其分配空间,其值是`nil`,我们要使用还要配合`make`函数来对其初始化,之后才可以在程序中使用该管道。 +```go +channel_name := make(chan channel_type) +channel_name := make(chan channel_type, size) +``` +或者我们可以直接一步完成声明和初始化,如下: +```go +channel_name := make(chan channel_type) +channel_name := make(chan channel_type, size) //创建带有缓存的管道,size为缓存大小 +``` + +## channel操作 +channel的操作主要有以下几种 +```go +ch := make(chan int) // 创建一个管道ch +ch <- v // 向管道ch中发送数据v. +v := <-ch // 从管道中读取数据存储到变量v +close(ch) // 关闭管道ch +``` +在这里需要注意`close(ch)`这个操作,管道用完了,需要对其进行关闭,避免程序一直在等待以及资源的浪费。但是关闭的管道,仍然可以从中接收数据,只是接收到的的数据永远是零值。 +看下面例子: +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + ch := make(chan int, 5) + ch <- 1 + close(ch) + go func() { + for i := 0; i < 5; i++ { + v := <-ch + fmt.Printf("v=%d\n", v) + } + }() + time.Sleep(2 * time.Second) +} +``` +运行结果: +``` +v=1 +v=0 +v=0 +v=0 +v=0 +``` +创建一个缓存为`5`的`int`类型的管道,向管道里写入一个`1`之后,将管道关闭,然后开启一个`gortoutine`从管道读取数据,读取`5`次,可以看到即便管道关闭之后,他仍然可以读取数据,在读完数据之后,将一直读取零值。 +但是,上述读取方式还有一个问题?比如我们创建一个`int`类型的`channel`,我们需要往里面写入零值,用另一个`goroutine`读取,此时我们就无法区两种常用的读取方式 + +### 判定读取 +还是以上面的例子来看,稍作修改 +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + ch := make(chan int, 5) + ch <- 1 + close(ch) + go func() { + for i := 0; i < 5; i++ { + v, ok := <-ch // 判断句式读取 + if ok { + fmt.Printf("v=%d\n", v) + } else { + fmt.Printf("channel数据已读完,v=%d\n", v) + } + } + }() + time.Sleep(2 * time.Second) +} +``` +运行结果: +``` +v=1 +channel数据已读完,v=0 +channel数据已读完,v=0 +channel数据已读完,v=0 +channel数据已读完,v=0 +``` +在读取`channel`数据的时候,用`ok`做了判断,当管道内还有数据能读取的时候,`ok`为`true`,当管道关闭后,`ok`为`false`。 + +### for range读取 +在上面例子中,我们明确了读取的次数是5次,但是我们往往在更多的时候,是不明确读取次数的,只是在`channel`的一端读取数据,有数据我们就读,直到另一端关闭了这个`channel`,这样就可以用`for range`这种优雅的方式来读取`channel`中的数据了 +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + ch := make(chan int, 5) + ch <- 1 + ch <- 2 + close(ch) + go func() { + for v := range ch { + fmt.Printf("v=%d\n", v) + } + }() + time.Sleep(2 * time.Second) +} +``` +运行结果: +``` +v=1 +v=2 +``` +主`goroutine`往`channel`里写了两个数据`1`和`2`,然后关闭,子`goroutine`也只能读取到`1`和`2`。这里在主`goroutine`关闭了`channel`之后,子`goroutine`里的`for range`循环才会结束。 + +## 双向channel和单向channel +channel根据其功能又可以分为双向`channel`和单向`channel`,双向`channel`即可发送数据又可接收数据,单向`channel`要么只能发送数据,要么只能接收数据。 +定义单向读`channel` +```go +var ch = make(chan int) +type RChannel= <-chan int // 定义类型 +var rec RChannel = ch +``` +定义单向写`channel` +```go +var ch = make(chan int) +type SChannel = chan<- int // 定义类型 +var send SChannel = ch +``` +注意写`channel`与读`channel`在定义的时候只是`<-`的位置不同,前者在`chan`关键字后面,后者在`chan`关键字前面。 +代码示例: +```go +import ( + "fmt" + "time" +) + +type SChannel = chan<- int +type RChannel = <-chan int + +func main() { + var ch = make(chan int) // 创建channel + + go func() { + var send SChannel = ch + fmt.Println("send: 100") + send <- 100 + }() + + go func() { + var rec RChannel = ch + num := <- rec + fmt.Printf("receive: %d", num) + }() + time.Sleep(2*time.Second) +} +``` +运行结果: +``` +send: 100 +receive: 100 +``` +创建一个`channel ch`,分别定义两个单向`channel`类型`SChannel`和`RChannel` ,根据别名类型给`ch`定义两个别名`send`和`rec`,一个只用于发送,一个只用于读取。 + +## 扩展 +`channel`非常重要,Go语言中有个重要思想:不以共享内存来通信,而以通信来共享内存。 +说得更直接点,协程之间可以利用`channel`来传递数据,如下的例子,可以看出父子协程如何通信的,父协程通过`channel`拿到了子协程执行的结果。 +```go +package main + +import ( + "fmt" + "time" +) + +func sum(s []int, c chan int) { + sum := 0 + for _, v := range s { + sum += v + } + c <- sum // send sum to c +} + +func main() { + s := []int{7, 2, 8, -9, 4, 0} + + c := make(chan int) + go func() { + sum(s[:len(s)/2], c) + //time.Sleep(1 * time.Second) + }() + go sum(s[len(s)/2:], c) + x, y := <-c, <-c // receive from c + + fmt.Println(x, y, x+y) +} +``` +运行结果: +``` +-5 17 12 +``` +`channel`又分为两类:有缓冲`channel` 和无缓冲`channel`,这个在前面的代码示例中也有简单的描述了。为了协程安全,无论是有无缓冲的`channel`,内部都会有一把锁来控制并发访问。同时`channel`底层一定有一个队列,来存储数据。 +无缓冲`channel`可以理解为同步模式,即写入一个,如果没有消费者在消费,写入就会阻塞。 +有缓冲`channel`可以理解为异步模式。即写入消息之后,即使还没被消费,只要队列没满,就可继续写入。如图所示: + +![](https://golangstar.cn/assets/img/go语言系列/channel/channel1.png) + +这里可能会问,如果有缓冲`channel`队列满了,那不就退化到同步了么?是的,如果队列满了,发送还是会阻塞。 + +![](https://golangstar.cn/assets/img/go语言系列/channel/channel2.png) + +但是我们来反向思考下,如果有缓冲`channel`长期都处于满队列情况,那何必用有缓冲。所以预期在正常情况下,有缓冲`channel`都是异步交互的。 + +## channel实现锁操作 +前面分析了当缓冲队列满了以后,继续往`channel`里面写数据,就会阻塞,那么利用这个特性,我们可以实现一个`goroutine`之间的锁。(对并发安全比较模糊的可以把后面`sync`小节看完再来看这里) +直接看示例 +```go +package main + +import ( + "fmt" + "time" +) + +func add(ch chan bool, num *int) { + ch <- true + *num = *num + 1 + <-ch +} + +func main() { + // 创建一个size为1的channel + ch := make(chan bool, 1) + + var num int + for i := 0; i < 100; i++ { + go add(ch, &num) + } + + time.Sleep(2) + fmt.Println("num 的值:", num) +} +``` +运行结果: +``` +num 的值: 100 +``` +`ch <- true`和`<- ch`就相当于一个锁,将 `*num = *num + 1`这个操作锁住了。因为`ch`管道的容量是1,在每个`add`函数里都会往`channel`放置一个`true`,直到执行完+1操作之后才将`channel`里的`true`取出。由于`channel`的`size`是1,所以当一个`goroutine`在执行`add`函数的时候,其他`goroutine`执行`add`函数,执行到`ch <- true`的时候就会阻塞,`*num = *num + 1`不会成功,直到前一个+1操作完成,`<-ch`,读出了管道的元素,这样就实现了并发安全 + + + +## 小结 +- 关闭一个未初始化的`channel` 会产生`panic` +- `channel`只能被关闭一次,对同一个`channel`重复关闭会产生`panic` +- 向一个已关闭的 `channel` 发送消息会产生 `panic` +- 从一个已关闭的`channel`读取消息不会发生`panic`,会一直读取所有数据,直到零值 +- `channel`可以读端和写端都可有多个`goroutine`操作,在一端关闭`channel`的时候,该`channel`读端的所有`goroutine` 都会收到`channel`已关闭的消息 +- `channel`是并发安全的,多个`goroutine`同时读取`channel`中的数据,不会产生并发安全问题 + +`channel`在我们的并发编程中发挥着巨大作用,使用起来也很方便,关于`channel`的具体实现原理可以到后面的Go语言原理篇学习。本章只介绍`channel`的具体用法 \ No newline at end of file diff --git a/hzh/GolangStar/Go语言进阶/Context.md b/hzh/GolangStar/Go语言进阶/Context.md new file mode 100644 index 0000000..e39e600 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/Context.md @@ -0,0 +1,267 @@ +--- +tags: + - Go + - golang + - go进阶 + - context + - 并发 +--- + +# Context + +## Context是什么 +`context`是Go语言在1.7版本中引入的一个标准库接口,其定义如下: +```go +type Context interface { + Deadline() (deadline time.Time, ok bool) + Done() <-chan struct{} + Err() error + Value(key interface{}) interface{} +} +``` +这个接口定义了四个方法: +- `Deadline`: 设置 `context.Context` 被取消的时间,即截止时间 +- `Done`: 返回一个只读`Channel`,当Context被取消或者到达截止时间,这个`Channel`就会被关闭,表示Context的链路结束,多次调用`Done`方法会返回同一个`Channel` +- `Err`: 返回`context.Context`结束的原因,它只会在`Done`返回的`Channel`被关闭时才会返回非空的值,返回值有以下两种情况: + - 如果是`context.Context`被取消,返回`Canceled` + - 如果是`context.Context`超时,返回`DeadlineExceeded` +- `Value`: 从`context.Context`中获取键对应的值,类似于map的get方法,对于同一个context,多次调用`Value`并传入相同的Key会返回相同的结果,如果没有对应的key,则返回nil,键值对是通过`WithValue`方法写入的 + +## Context创建 +### 根Context创建 +主要有以下两种方式创建根context: +```go +context.Background() +context.TODO() +``` + +从源代码分析`context.Background`和`context.TODO`并没有太多的区别,都是用于创建根context,根context是一个空的context,不具备任何功能。但是一般情况下,如果当前函数没有上下文作为入参,我们都会使用`context.Background`创建一个根context作为起始的上下文向下传递。 + +### Context派生 +根`context`在创建之后,不具备任何的功能,为了让`context`在我们的程序中发挥作用,我们要依靠`context`包提供的`With`系列函数来进行派生。 +主要有以下几个派生函数: +```go +func WithCancel(parent Context) (ctx Context, cancel CancelFunc) +func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) +func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) +func WithValue(parent Context, key, val interface{}) Context +``` +基于当前`context`,每个`with`函数都会创建出一个新的`context`,这样类似于我们熟悉的树结构,当前`context`称为父`context`,派生出的新`context`称为子`context`。就像下面的`context`树结构: + +![Context树结构](https://golangstar.cn/assets/img/go语言系列/context/context1.png) + +通过根`context`,通过四个`with`系列方法可以派生出四种类型的`context`,每种`context`又可以通过同样的方式调用`with`系列方法继续向下派生新的`context`,整个结构像一棵树。 + +## Context有什么用 +`context`主要有两个用途,也是在项目中经常使用的: +1. 用于并发控制,控制协程的优雅退出 +2. 上下文的信息传递 +总的来说,`context`就是用来在父子`goroutine`间进行值传递以及发送`cancel`信号的一种机制。 + +### 并发控制 +对于一般的服务器而言,都是一致运行着的,等待接收来自客户端或者浏览器的请求做出响应,思考这样一种场景,后台微服务架构中,一般服务器在收到一个请求之后,如果逻辑复杂,不会在一个`goroutine`中完成,而是会创建出很多的`goroutine`共同完成这个请求,就像下面这种情况: + +![并发场景](https://golangstar.cn/assets/img/go语言系列/context/context2.png) + +有一个请求过来之后,先经过第一次`rpc`调用,然后再到`rpc2`,后面创建执行两个`rpc`,`rpc4`里又有一次`rpc`调用`rpc5`,等所有`rpc`调用成功后,返回结果。假如在整个调用过程中,`rpc1`发生了错误,如果没有`context`存在的话,我们还是得等所有的`rpc`都执行完才能返回结果,这样其实浪费了不少时间,因为一旦出错,我们完全可以直接在`rpc1`这里就返回结果了,不用等到后续的`rpc`都执行完。 +假设我们在`rpc1`直接返回失败,不等后续的`rpc`继续执行,那么其实后续的`rpc`执行就是没有意义的,浪费计算和IO资源而已。引入`context`之后,就可以很好的处理这个问题,在不需要子`goroutine`执行的时候,可以通过`context`通知子`goroutine`优雅的关闭。 + +#### context.WithCancel +方法定义如下: +```go +func WithCancel(parent Context) (ctx Context, cancel CancelFunc) +``` +`context.WithCancel`函数是一个取消控制函数,只需要一个`context`作为参数,能够从`context.Context`中衍生出一个新的子`context`和取消函数`CancelFunc`,通过将这个子`context`传递到新的`goroutine`中来控制这些`goroutine`的关闭,一旦我们执行返回的取消函数`CancelFunc`,当前上下文以及它的子上下文都会被取消,所有的`Goroutine`都会同步收到取消信号。 +使用示例: +```go +package main + +import ( + "context" + "fmt" + "time" +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + go Watch(ctx, "goroutine1") + go Watch(ctx, "goroutine2") + + time.Sleep(6 * time.Second) // 让goroutine1和goroutine2执行6s + fmt.Println("end watching!!!") + cancel() // 通知goroutine1和goroutine2关闭 + time.Sleep(1 * time.Second) +} + +func Watch(ctx context.Context, name string) { + for { + select { + case <-ctx.Done(): + fmt.Printf("%s exit!\n", name) // 主goroutine调用cancel后,会发送一个信号到ctx.Done()这个channel,这里就会收到信息 + return + default: + fmt.Printf("%s watching...\n", name) + time.Sleep(time.Second) + } + } +} +``` +运行结果: +``` +goroutine2 watching... +goroutine1 watching... +goroutine1 watching... +goroutine2 watching... +goroutine2 watching... +goroutine1 watching... +goroutine1 watching... +goroutine2 watching... +goroutine2 watching... +goroutine1 watching... +goroutine1 watching... +goroutine2 watching... +end watching!!! +goroutine1 exit! +goroutine2 exit! +``` +`ctx, cancel := context.WithCancel(context.Background())`派生出了一个带有返回函数`cancel`的`ctx`,并把它传入到子`goroutine`中,接下来在6s时间内,由于没有执行`cancel`函数,子`goroutine`将一直执行`default`语句,打印监控。6s之后,调用`cancel`,此时子`goroutine`会从`ctx.Done()`这个`channel`中收到消息,执行`return`结束。 + +#### context.WithDeadline +方法定义如下: +```go +func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) +``` +`context.WithDeadline`也是一个取消控制函数,方法有两个参数,第一个参数是一个`context`,第二个参数是截止时间,同样会返回一个子`context`和一个取消函数`CancelFunc`。在使用的时候,没有到截止时间,我们可以通过手动调用`CancelFunc`来取消子`context`,控制子`goroutine`的退出,如果到了截止时间,我们都没有调用`CancelFunc`,子`context`的`Done()`管道也会收到一个取消信号,用来控制子`goroutine`退出。 +使用示例: +```go +package main + +import ( + "context" + "fmt" + "time" +) + +func main() { + ctx, cancel := context.WithDeadline(context.Background(),time.Now().Add(4*time.Second)) // 设置超时时间4当前时间4s之后 + defer cancel() + go Watch(ctx, "goroutine1") + go Watch(ctx, "goroutine2") + + time.Sleep(6 * time.Second) // 让goroutine1和goroutine2执行6s + fmt.Println("end watching!!!") +} + +func Watch(ctx context.Context, name string) { + for { + select { + case <-ctx.Done(): + fmt.Printf("%s exit!\n", name) // 4s之后收到信号 + return + default: + fmt.Printf("%s watching...\n", name) + time.Sleep(time.Second) + } + } +} +``` +运行结果: +``` +goroutine1 watching... +goroutine2 watching... +goroutine2 watching... +goroutine1 watching... +goroutine1 watching... +goroutine2 watching... +goroutine1 exit! +goroutine2 exit! +end watching!!! +``` +我们并没有调用`cancel`函数,但是在过了4s之后,子`groutine`里`ctx.Done()`收到了信号,打印出`exit`,子`goroutine`退出,这就是`WithDeadline`派生子`context`的用法。 + +#### context.WithTimeout +方法定义: +```go +func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) +``` +`context.WithTimeout`和`context.WithDeadline`的作用类似,都是用于超时取消子`context`,只是传递的第二个参数有所不同,`context.WithTimeout`传递的第二个参数不是具体时间,而是时间长度。 +使用示例: +```go +package main + +import ( + "context" + "fmt" + "time" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + go Watch(ctx, "goroutine1") + go Watch(ctx, "goroutine2") + + time.Sleep(6 * time.Second) // 让goroutine1和goroutine2执行6s + fmt.Println("end watching!!!") +} + +func Watch(ctx context.Context, name string) { + for { + select { + case <-ctx.Done(): + fmt.Printf("%s exit!\n", name) // 主goroutine调用cancel后,会发送一个信号到ctx.Done()这个channel,这里就会收到信息 + return + default: + fmt.Printf("%s watching...\n", name) + time.Sleep(time.Second) + } + } +} +``` +运行结果: +``` +goroutine2 watching... +goroutine1 watching... +goroutine1 watching... +goroutine2 watching... +goroutine2 watching... +goroutine1 watching... +goroutine1 watching... +goroutine2 watching... +goroutine1 exit! +goroutine2 exit! +end watching!!! +``` +程序很简单,与上个`context.WithDeadline`的样例代码基本一样,只是改变了下派生`context`的方法为`context.WithTimeout`,具体体现在第二个参数不再是具体时间,而是变为了4s这个具体的时间长度,执行结果也是一样。 + +#### context.WithValue +方法定义: +```go +func WithValue(parent Context, key, val interface{}) Context +``` +`context.WithValue`函数从父`context`中创建一个子`context`用于传值,函数参数是父`context`,`key`,`val`键值对。返回一个`context`。 +项目中这个方法一般用于上下文信息的传递,比如请求唯一`id`,以及`trace_id`等,用于链路追踪以及配置透传。 +使用示例: +```go +package main + +import ( + "context" + "fmt" + "time" +) + +func func1(ctx context.Context) { + fmt.Printf("name is: %s", ctx.Value("name").(string)) +} + +func main() { + ctx := context.WithValue(context.Background(), "name", "zhangsan") + go func1(ctx) + time.Sleep(time.Second) +} +``` +运行结果: +``` +name is: zhangsan +``` diff --git a/hzh/GolangStar/Go语言进阶/Goroutine.md b/hzh/GolangStar/Go语言进阶/Goroutine.md new file mode 100644 index 0000000..1cc16b7 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/Goroutine.md @@ -0,0 +1,112 @@ +--- +tags: + - Go + - golang + - go进阶语法 + - Goroutine +--- + +# Goroutine +`goroutine`是Go语言对于协程的支持,可以把它理解为Go语言的协程。这是一个Go语言并发编程的终极杀器,它让我们的并发编程变得简单。 + +Go语言的并发只会用到`goroutine`,并不需要我们去考虑用多进程或者是多线程。有过C++或者Java经验的同学可能知道,线程本身是有一定大小的,一般OS线程栈大小为2MB,且线程在创建和上下文切换的时候是需要消耗资源的,会带来性能损耗,所以在我们用到多线程技术的时候,我们往往会通过池化技术,即创建线程池来管理一定数量的线程。 +在Go语言中,一个`goroutine`栈在其生命周期开始时占用空间很小(一般2KB),并且栈大小可以按需增大和缩小,`goroutine`的栈大小限制可以达到1GB,但是一般不会用到这么大。所以在Go语言中一次创建成千上万,甚至十万左右的`goroutine`理论上也是可以的。 +在Go语言中,我们用多`goroutine`来完成并发,在某个任务需要并发执行的时候,只需要把这个任务包装成一个函数,开启一个`goroutine`去执行这个函数就可以了。并不需要我们来维护一个类似于线程池的东西,也不需要我们去关心协程是怎么切换和调度的,因为这些都已经有Go语言内置的调度器帮我们做了,并且效率还非常高。 + +## Goroutine使用 +`goroutine`使用起来非常方便,通常我们会将需要并发的任务封装成一个函数,然后再该函数前加上`go`关键字就行了,这样就开启了一个`goroutine`。 +```go +func() +go func() // 会并发执行这个函数 +``` + +## 主协程 +和其它语言一样,Go程序的入口也是`main`函数。在程序开始执行的时候,Go程序会为`main`函数创建一个默认的`goroutine`,我们称之为主协程,我们后来人为的创建的一些`goroutine`,都是在这个主`goroutine`的基础上进行的。 +下面请看个例子: +```go +package main + +import "fmt" + +func myGroutine() { + fmt.Println("myGroutine") +} + +func main() { + go myGroutine() + fmt.Println("end!!!") +} +``` +运行结果: +``` +end!!! +myGroutine +``` +很奇怪,明明是多协程任务,为什么只打印了主协程里的"end!!!",而没有打印我们开启的协程里的输出"myGroutine",按理不是应该都打印出来吗? +这是因为:当`main`函数返回的时候该`goroutine`就结束了,当主协程退出的时候,其他剩余的`goroutine`不管是否运行完,都会跟着结束。所以,这里主协程打印完"end!!!"之后就退出了,`myGroutine`协程可能还没运行到`fmt.Println("myGroutine")`语句也跟着退出了。 +接下来我们让主`goroutine`执行完`fmt.Println("end!!!")`之后不立刻退出,而是等待2s,看一下运行结果: +```go +package main + +import ( + "fmt" + "time" + ) + +func myGroutine() { + fmt.Println("myGroutine") +} + +func main() { + go myGroutine() + fmt.Println("end!!!") + time.Sleep(2*time.Second) +} +``` +运行结果: +``` +end!!! +myGroutine +``` +此时打印出了我们想要的结果,这里我们通过让主协程睡眠2s来等待子协程执行完了之后再退出,后面我们会学习到更好的方法,这里就不再过多阐述。 + +## 多协程调用 +在Go语言中,我们可以通过`go`关键字来开启多个协程,每个协程可以并发执行,互不干扰。 +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +func myGoroutine(name string, wg *sync.WaitGroup) { + defer wg.Done() + + for i := 0; i < 5; i++ { + fmt.Printf("myGroutine %s\n", name) + time.Sleep(10 * time.Millisecond) + } +} + +func main() { + var wg sync.WaitGroup + wg.Add(2) + + go myGoroutine("goroutine1", &wg) + go myGoroutine("goroutine2", &wg) + + wg.Wait() +} +``` +运行结果: +``` +myGroutine goroutine1 +myGroutine goroutine2 +``` +从结果中可以看到,两个协程并发执行,互不干扰。注意在上述例子中,我们使用了`sync.WaitGroup`来等待所有协程执行完毕之后再退出。关于`sync.WaitGroup`的详细介绍,可以参考[sync.WaitGroup](https://pkg.go.dev/sync#WaitGroup)。后续也会在`sync`章节详细介绍。 + + + + diff --git a/hzh/GolangStar/Go语言进阶/Select.md b/hzh/GolangStar/Go语言进阶/Select.md new file mode 100644 index 0000000..5f133cc --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/Select.md @@ -0,0 +1,190 @@ +--- +tags: + - Go + - golang + - go进阶 + - select + - 并发 +--- + +# Select + +## select是什么 +`select`是Go语言层面提供的一种多路复用机制,用于检测当前`goroutine`连接的多个`channel`是否有数据准备完毕,可用于读或写。 + +## IO多路复用 +看到`select`,很自然的会联想到Linux提供的IO多路复用模型:`select`、`poll`、`epoll`,IO复用主要用于提升程序处理IO事件的性能。Go语言中的`select`与Linux中的`select`有一定的区别。操作系统中的IO多路复用简单理解就是用一个或者是少量线程处理多个IO事件。 + +### 传统阻塞IO vs IO多路复用 + +#### 传统阻塞IO +对于每一个网络IO事件,操作系统都会起一个线程去处理,在IO事件没准备好的时候,当前线程就会一直阻塞。 + +![传统阻塞IO](https://golangstar.cn/assets/img/go语言系列/select/select1.png) + +优缺点: +- 优点:逻辑简单,在阻塞等待期间线程会挂起,不会占用 CPU 资源 +- 缺点:每个连接需要独立的线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大 + +#### IO多路复用 +IO多路复用的基本原理如下图所示: + +![IO多路复用](https://golangstar.cn/assets/img/go语言系列/select/select2.png) + +优缺点: +- 优点:通过复用一个线程处理了多个IO事件,无需对额外过多的线程维护管理,资源和效率上都获得了提升 +- 缺点:当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低 + +Go语言的`select`语句,是用来起一个`goroutine`监听多个`Channel`的读写事件,提高从多个`Channel`获取信息的效率,相当于也是单线程处理多个IO事件,其思想基本相同。 + +## select用法 +`select`的基本使用模式如下: +```go +select { + case <- channel1: // 如果从channel1读取数据成功,执行case语句 + do ... + case channel2 <- 1: // 如果向channel2写入数据成功,执行case语句 + do ... + default: // 如果上面都没有成功,进入default处理流程 + do ... +} +``` +可以看到,`select`的用法形式类似于`switch`,但是区别于`switch`的是,`select`各个`case`的表达式必须都是`channel`的读写操作。`select`通过多个`case`语句监听多个`channel`的读写操作是否准备好可以执行,其中任何一个`case`可以执行了则选择该`case`语句执行,如果没有可以执行的`case`,则执行`default`语句,如果没有`default`,则当前`goroutine`会阻塞。 + +### 空select永久阻塞 +当一个`select`中什么语句都没有,没有任何`case`,将会永久阻塞: +```go +package main + +func main() { + select { + } +} +``` +运行结果: +``` +fatal error: all goroutines are asleep - deadlock! +``` +程序因为`select`语句导致永久阻塞,当前`goroutine`阻塞之后,由于Go语言自带死锁检测机制,发现当前`goroutine`永远不会被唤醒,会报上述死锁错误。 + +### 没有default且case无法执行的select永久阻塞 +看下面示例: +```go +package main + +import ( + "fmt" +) + +func main() { + ch1 := make(chan int, 1) + ch2 := make(chan int, 1) + select { + case <-ch1: + fmt.Printf("received from ch1") + case num := <-ch2: + fmt.Printf("num is: %d", num) + } +} +``` +运行结果: +``` +fatal error: all goroutines are asleep - deadlock! +``` +程序中 `select`从两个`channel`,`ch1`和`ch2`中读取数据,但是两个`channel`都没有数据,且没有`goroutine`往里面写数据,所以不可能读到数据,这两个`case`永远无法执行到,`select`也没有`default`,所以会出现永久阻塞,报死锁。 + +### 有单一case和default的select +```go +package main + +import ( + "fmt" +) + +func main() { + ch := make(chan int, 1) + select { + case <-ch: + fmt.Println("received from ch") + default: + fmt.Println("default!!!") + } +} +``` +运行结果: +``` +default!!! +``` +执行到`select`语句的时候,由于`ch`中没有数据,且没有`goroutine`往`channel`中写数据,所以`case`不可能执行到,就会执行`default`语句,打印出`default!!!`。 + +### 有多个case和default的select +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + ch1 := make(chan int, 1) + ch2 := make(chan int, 1) + go func() { + time.Sleep(time.Second) + for i := 0; i < 3; i++ { + select { + case v := <-ch1: + fmt.Printf("Received from ch1, val = %d\n", v) + case v := <-ch2: + fmt.Printf("Received from ch2, val = %d\n", v) + default: + fmt.Println("default!!!") + } + time.Sleep(time.Second) + } + }() + ch1 <- 1 + time.Sleep(time.Second) + ch2 <- 2 + time.Sleep(4 * time.Second) +} +``` +运行结果: +``` +Received from ch1, val = 1 +Received from ch2, val = 2 +default!!! +``` +主`goroutine`中向后往管道`ch1`和`ch2`中发送数据,在子`goroutine`中执行两个`select`,可以看到,在执行`select`的时候,那个`case`准备好了就会执行当下`case`的语句,最后没有数据可接受了,没有`case`可以执行,则执行`default`语句。 + +> 注意:当多个case都准备好了的时候,会随机选择一个执行 + +```go +package main + +import ( + "fmt" +) + +func main() { + ch1 := make(chan int, 1) + ch2 := make(chan int, 1) + ch1 <- 5 + ch2 <- 6 + select { + case v := <-ch1: + fmt.Printf("Received from ch1, val = %d\n", v) + case v := <-ch2: + fmt.Printf("Received from ch2, val = %d\n", v) + default: + fmt.Println("default!!!") + } +} +``` +运行结果: +``` +Received from ch2, val = 6 +``` +多次执行,2个`case`都有可能打印,这就是`select`选择的随机性。 + + diff --git a/hzh/GolangStar/Go语言进阶/Sync.md b/hzh/GolangStar/Go语言进阶/Sync.md new file mode 100644 index 0000000..bdab347 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/Sync.md @@ -0,0 +1,768 @@ +--- +tags: + - Go + - golang + - go进阶 + - sync + - 并发安全 +--- + +# sync包 +在前面学习`channel`的时候,我们说到在Go语言并发编程中,倡导使用通信共享内存,不要使用共享内存通信,`goroutine`之间尽量通过`channel`来协作。而在其他的传统语言中,都是通过共享内存加上锁机制来保证并发安全的,同样Go语言也提供了对共享内存并发安全机制的支持,这些功能都存在于`sync`包下。 + +## sync.WaitGroup +在前面很多`goroutine`的示例中,我们都是通过`time.Sleep()`方法让主`goroutine`等待一段时间以便子`gortoutine`能够执行完打印结果,显然这不是一个很好的办法,因为我们不知道所有的子`gortoutine`要多久才能执行完,不能确切的知道需要等待多久。那要怎么处理呢? + +### 使用channel实现等待 +看下面例子: +```go +package main + +import ( + "fmt" +) + +func main() { + ch := make(chan struct{}, 10) + for i := 0; i < 10; i++ { + go func(i int) { + fmt.Printf("num:%d\n",i) + ch <- struct{}{} + }(i) + } + + for i := 0; i < 10; i++ { + <-ch + } + + fmt.Println("end") +} +``` +运行结果: +``` +num:0 +num:2 +num:1 +num:4 +num:6 +num:7 +num:5 +num:8 +num:9 +num:3 +end +``` +我们在每个`goroutine`中,向管道里发送一条数据,这样我们在程序最后,通过`for`循环将管道里的数据全部取出,直到数据全部取出完毕才能继续后面的逻辑,这样就可以实现等待各个`goroutine`执行完。 +但是,这样使用`channel`显得并不优雅,其次,我们得知道具体循环的次数,来创建管道的大小,假设次数非常的多,则需要申请同样数量大小的管道出来,对内存也是不小的开销。 + +### 使用WaitGroup实现等待 +这里我们可以用`sync`包下的`WaitGroup`来实现,Go语言中可以使用`sync.WaitGroup`来实现并发任务的同步以及协程任务等待。 +`sync.WaitGroup`是一个对象,里面维护者一个计数器,并且通过三个方法来配合使用: +- (wg * WaitGroup) Add(delta int) 计数器加delta +- (wg *WaitGroup) Done() 计数器减1 +- (wg *WaitGroup) Wait() 会阻塞代码的运行,直至计数器减为0 +先看示例: +```go +package main + +import ( + "fmt" + "sync" +) + +var wg sync.WaitGroup + +func myGoroutine() { + defer wg.Done() + fmt.Println("myGoroutine!") +} + +func main() { + wg.Add(10) + for i := 0; i < 10; i++ { + go myGoroutine() + } + wg.Wait() + fmt.Println("end!!!") +} +``` + +运行结果: +``` +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +myGoroutine! +end!!! +``` +程序首先把`wg`的计数设置为10,每个`for`循环运行完毕都把计数器减1,`main`函数中执行到`wg.Wait()`会一直阻塞,直到`wg`的计数器为零。最后打印了10个`myGoroutine!`,是所有子`goroutine`任务结束后主`goroutine`才退出。 +**注意:`sync.WaitGroup`对象的计数器不能为负数,否则会panic,在使用的过程中,我们需要保证`add()`的参数值,以及执行完`Done()`之后计数器大于等于零** + +## sync.Once +在我们写项目的时候,程序中有很多的逻辑只需要执行一次,最典型的就是项目工程里配置文件的加载,我们只需要加载一次即可,让配置保存在内存中,下次使用的时候直接使用内存中的配置数据即可。这里就要用到`sync.Once`。 +`sync.Once`可以在代码的任意位置初始化和调用,并且线程安全。`sync.Once`最大的作用就是延迟初始化,对于一个`sync.Once`变量我们并不会在程序启动的时候初始化,而是在第一次用的它的时候才会初始化,并且只初始化这一次,初始化之后驻留在内存里,这就非常适合我们之前提到的配置文件加载场景,设想一下,如果是在程序刚开始就加载配置,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间,而`sync.Once`就刚好解决了这个问题。 +使用示例: +```go +// 声明配置结构体Config +type Config struct{} + +var instance *Config +var once sync.Once // 声明一个sync.Once变量 + +// 获取配置结构体 +func InitConfig() *Config { + once.Do(func(){ + instance = &Config{} + }) + return instance +} +``` +只有在第一次调用InitConfig()获取Config 指针的时候才会执行once.Do(func(){instance = &Config{} })语句,执行完之后instance就驻留在内存中,后面再次执行InitConfig()的时候,就直接返回内存中的instance。 + +### sync.Once与init()的区别 +有时候我们使用init()方法进行初始化,init()方法是在其所在的package首次加载时执行的,而sync.Once可以在代码的任意位置初始化和调用,是在第一次用的它的时候才会初始化。 + +## sync.Lock +说到并发编程,就不得不谈一个老生常谈的问题,那就是资源竞争,也就是我们这节要讲的并发安全。因为一旦开启了多个`goroutine`去处理问题,那么这些`goroutine`就有可能在同一时间操作同一个系统资源,比如同一个变量,同一份文件等等,这里我们如果不加控制的话,可能会出现并发安全问题,在Go语言中,有两种方式来控制并发安全,锁和原子操作 +举个例子,看下面代码 +```go +package main + +import ( + "fmt" + "sync" +) + +var ( + num int + wg = sync.WaitGroup{} +) + +func add() { + defer wg.Done() + num += 1 +} + +func main() { + var n = 10 * 10 * 10 * 10 + wg.Add(n) + + for i := 0; i < n; i++ { + // 启动n个goroutine去累加num + go add() + } + + // 等待所有goroutine执行完毕 + wg.Wait() + + // 不出意外的话,num应该等于n,但是,但是,但是实际上不一致! + fmt.Println(num == n) +} +``` +运行结果: +``` +false +``` +我们用`n`(这里是10000,可以自行修改,尽量数字大一点)个`goroutine`去给`num`做累加,最后并`num`并不等于`n`,这就是并发问题,同一时间有多个`goroutine`都在对`num`做`+1`操作,但是后一个并不是在前一次执行完的基础之上运行的,可能两次运行`num`的初始相同,这样前一个`num+1`的结果就被后一个覆盖了,看起来好像只做了一个加法。为了避免类似的并发安全问题,我们一般会采用下面两种方式处理,在go语言中并发相关的都在`sync`包下面。 + +### 锁 +#### 互斥锁Mutex +互斥锁是一种最常用的控制并发安全的方式,它在同一时间只允许一个goroutine对共享资源进行访问。 +互斥锁的声明方式如下: +```go +var lock sync.Mutex +``` +互斥锁有两个方法 +```go +func (m *Mutex) Lock() // 加锁 +func (m *Mutex) Unlock() // 解锁 +``` +一个互斥锁只能同时被一个`goroutine`锁定,其它`goroutine`将阻塞直到互斥锁被解锁才能加锁成功。`sync.Mutex`在使用的时候要注意:**对一个未锁定的互斥锁解锁将会产生运行时错误**。 +对上面的例子稍作修改,加上互斥锁: +```go +package main + +import ( + "fmt" + "sync" +) + +var ( + num int + wg = sync.WaitGroup{} + // 我们用锁来保证num的并发安全 + mu = sync.Mutex{} +) + +func add() { + mu.Lock() + defer wg.Done() + num += 1 + mu.Unlock() +} + +func main() { + var n = 10 * 10 * 10 * 10 + wg.Add(n) + + for i := 0; i < n; i++ { + // 启动n个goroutine去累加num + go add() + } + + // 等待所有goroutine执行完毕 + wg.Wait() + + fmt.Println(num == n) +} +``` +运行结果: +``` +true +``` +我们可以自行修改n的值,在我们能开启足够多的`goroutine`的情况下,他结果一定会是`true`。 +本例使用了前面介绍的`sync.WaitGroup`来等待所有协程执行结束。并且在`add`函数里使用了互斥锁来保证`num += 1`操作的并发安全,但是注意不要忘了用`mu.Unlock`来进行解锁,否则其他`goroutine`将一直等待加锁造成阻塞。 + + +#### 读写锁RWMutex +读写锁就是将读操作和写操作分开,可以分别对读和写进行加锁,一般用在大量读操作、少量写操作的情况。 +读写锁的声明方式如下: +```go +var rw sync.RWMutex +``` +读写锁有两个方法: +```go +func (rw *RWMutex) Lock() // 对写锁加锁 +func (rw *RWMutex) Unlock() // 对写锁解锁 + +func (rw *RWMutex) RLock() // 对读锁加锁 +func (rw *RWMutex) RUnlock() // 对读锁解锁 +``` +读写锁的使用遵循以下几个法则: +1. 同时只能有一个 goroutine 能够获得写锁定。 +2. 同时可以有任意多个 gorouinte 获得读锁定。 +3. 同时只能存在写锁定或读锁定(读和写互斥)。 +通俗理解就是可以多个`goroutine`同时读,但是只有一个`goroutine`能写,共享资源要么在被一个或多个`goroutine`读取,要么在被一个`goroutine`写入, 读写不能同时进行。 +读写锁示例: +```go +package main + +import ( + "fmt" + "sync" + "time" +) + +var cnt = 0 + +func main() { + var mr sync.RWMutex + for i := 1; i <= 3; i++ { + go write(&mr, i) + } + for i := 1; i <= 3; i++ { + go read(&mr, i) + } + + time.Sleep(time.Second) + fmt.Println("final count:", cnt) +} + +func read(mr *sync.RWMutex, i int) { + fmt.Printf("goroutine%d reader start\n", i) + mr.RLock() + fmt.Printf("goroutine%d reading count:%d\n", i, cnt) + time.Sleep(time.Millisecond) + mr.RUnlock() + + fmt.Printf("goroutine%d reader over\n", i) +} + +func write(mr *sync.RWMutex, i int) { + fmt.Printf("goroutine%d writer start\n", i) + mr.Lock() + cnt++ + fmt.Printf("goroutine%d writing count:%d\n", i, cnt) + time.Sleep(time.Millisecond) + mr.Unlock() + + fmt.Printf("goroutine%d writer over\n", i) +} +``` +运行结果: +``` +goroutine3 reader start +goroutine3 reading count:0 +goroutine1 writer start +goroutine2 writer start +goroutine1 reader start +goroutine2 reader start +goroutine3 writer start +goroutine3 reader over +goroutine1 writing count:1 +goroutine1 writer over +goroutine1 reading count:1 +goroutine2 reading count:1 +goroutine2 reader over +goroutine2 writing count:2 +goroutine1 reader over +goroutine2 writer over +goroutine3 writing count:3 +goroutine3 writer over +final count: 3 +``` +简单分析:首先`goroutine3`开始加了读锁,开始读取,读到count的值为0,然后`goroutine1`尝试写入,`goroutine2`尝试写入,但是都会阻塞,因为`goroutine3`加了读锁,不能再加写锁,在第8行`goroutine3` 读取完毕之后,`goroutine1`争抢到了锁,加了写锁,写完释放写锁之后,`goroutine1`和`goroutine2`同时加了读锁,读到count的值为1。可以看到读写锁是互斥的,写写锁是互斥的,读读锁可以一起加。 + + +#### 死锁 +提到锁,就有一个绕不开的话题:死锁。死锁就是一种状态,当两个或以上的`goroutine`在执行过程中,因争夺共享资源处在互相等待的状态,如果没有外部干涉将会一直处于这种阻塞状态,我们称这时的系统发生了死锁。思索场景一般有以下两种 +1. **Lock/Unlock不成对**。这类情况最常见的场景就是对锁进行拷贝使用 +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + var mu sync.Mutex + mu.Lock() + defer mu.Unlock() + copyMutex(mu) +} + +func copyMutex(mu sync.Mutex) { + mu.Lock() + defer mu.Unlock() + fmt.Println("ok") +} +``` +运行结果: +``` +fatal error: all goroutines are asleep - deadlock! + +goroutine 1 [semacquire]: +sync.runtime_SemacquireMutex(0xc0000160ac, 0x0, 0x1) + D:/Program Files/Go/src/runtime/sema.go:71 +0x4e +sync.(*Mutex).lockSlow(0xc0000160a8) + D:/Program Files/Go/src/sync/mutex.go:138 +0x10f +sync.(*Mutex).Lock(...) + D:/Program Files/Go/src/sync/mutex.go:81 +main.copyTest(0xc0000160a8) +``` +会报死锁,为什么呢?有的同学可能会注意到,这里`mu sync.Mutex`当作参数传入到函数`copyMutex`,锁进行了拷贝,不是原来的锁变量了,那么一把新的锁,在执行`mu.Lock()`的时候应该没问题。这就是要注意的地方,如果将带有锁结构的变量赋值给其他变量,锁的状态会复制。所以多锁复制后的新的锁拥有了原来的锁状态,那么在`copyMutex`函数内执行`mu.Lock()`的时候会一直阻塞,因为外层的`main`函数已经`Lock()`了一次,但是并没有机会`Unlock()`,导致内层函数会一直等待`Lock()`,而外层函数一直等待`Unlock()`,这样就造成了死锁 +所以在使用锁的时候,我们应当尽量避免锁拷贝,并且保证Lock()和Unlock()成对出现,没有成对出现容易会出现死锁的情况,或者是Unlock 一个未加锁的Mutex而导致 panic。尽量养成如下使用习惯 +```go +mu.Lock() +defer mu.Unlock() +``` + +2. **循环等待** +另一个容易造成死锁的场景就是循环等待,A等B,B等C,C等A,循环等待 +```go +package main + +import ( + "sync" + "time" +) + +func main() { + var mu1, mu2 sync.Mutex + var wg sync.WaitGroup + + wg.Add(2) + go func() { + defer wg.Done() + mu1.Lock() + defer mu1.Unlock() + time.Sleep(1 * time.Second) + + mu2.Lock() + defer mu2.Unlock() + }() + + go func() { + defer wg.Done() + mu2.Lock() + defer mu2.Unlock() + time.Sleep(1 * time.Second) + mu1.Lock() + defer mu1.Unlock() + }() + wg.Wait() +} +``` +运行结果: +``` +fatal error: all goroutines are asleep - deadlock! +``` +死锁了,代码很简单,两个`goroutine`,一个`goroutine`先锁`mu1`,再锁`mu2`,另一个`goroutine`先锁`mu2`,再锁`mu1`,但是在它们进行第二次枷锁操作的时候,彼此等待对方释放锁,这样就造成了循环等待,一直阻塞,形成死锁。 + +## sync.Map +Go语言内置的Map并不是并发安全的,在多个`goroutine`同时操作map的时候,会有并发问 +具体看下面例子 +```go +package main + +import ( + "fmt" + "strconv" + "sync" +) + +var m = make(map[string]int) + +func getVal(key string) int { + return m[key] +} + +func setVal(key string, value int) { + m[key] = value +} + +func main() { + wg := sync.WaitGroup{} + wg.Add(10) + for i := 0; i < 10; i++ { + go func(num int) { + defer wg.Done() + key := strconv.Itoa(num) + setVal(key, num) + fmt.Printf("key=:%v,val:=%v\n", key, getVal(key)) + }(i) + } + wg.Wait() +} +``` +运行结果: +``` +fatal error: concurrent map writes +``` +程序报错了,说明`map`不能同时被多个`goroutine`读写。要解决`map`的并发写问题一种方式使用我们前面学到的对`map`加锁,这样就可以了 +```go +package main + +import ( + "fmt" + "strconv" + "sync" +) + +var m = make(map[string]int) +var mu sync.Mutex + +func getVal(key string) int { + return m[key] +} + +func setVal(key string, value int) { + m[key] = value +} + +func main() { + wg := sync.WaitGroup{} + + wg.Add(10) + for i := 0; i < 10; i++ { + go func(num int) { + defer func() { + wg.Done() + mu.Unlock() + }() + key := strconv.Itoa(num) + mu.Lock() + setVal(key, num) + fmt.Printf("key=:%v,val:=%v\n", key, getVal(key)) + }(i) + } + wg.Wait() +} +``` +运行结果: +``` +key=:9,val:=9 +key=:4,val:=4 +key=:0,val:=0 +key=:1,val:=1 +key=:2,val:=2 +key=:3,val:=3 +key=:6,val:=6 +key=:7,val:=7 +key=:5,val:=5 +key=:8,val:=8 +``` +另外一种方式是使用`sync`包中提供的一个开箱即用的并发安全版`map`–`sync.Map`,在 Go 1.9 引入。`sync.Map`不用初始化就可以使用,同时`sync.Map`内置了诸如`Store`、`Load`、`LoadOrStore`、`Delete`、`Range`等操作方法。 +具体使用方法看示例: +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + var m sync.Map + // 1. 写入 + m.Store("name", "zhangsan") + m.Store("age", 18) + + // 2. 读取 + age, _ := m.Load("age") + fmt.Println(age.(int)) + + // 3. 遍历 + m.Range(func(key, value interface{}) bool { + fmt.Printf("key is:%v, val is:%v\n", key, value) + return true + }) + + // 4. 删除 + m.Delete("age") + age, ok := m.Load("age") + fmt.Println(age, ok) + + // 5. 读取或写入 + m.LoadOrStore("name", "zhangsan") + name, _ := m.Load("name") + fmt.Println(name) +} +``` +运行结果: +``` +18 +key is:name, val is:zhangsan +key is:age, val is:18 + false +zhangsan +``` +1. 通过store方法写入两个键值对 +2. 读取key为age的值,读出来age为18 +3. 通过range方法遍历map的key和value +4. 删除key为age的键值对,删除完之后,再次读取age,age为空,ok为false表示map里没有这个key +5. LoadOrStore尝试读取key为name的值,读取不到就写入键值对name-zhangsan,能读取到就返回原来map里的name对应的值 +**注意:`sync.Map` 没有提供获取 map 数量的方法,需要我们在对 `sync.Map`进行遍历时自行计算,`sync.Map` 为了保证并发安全有一些性能损失,因此在非并发情况下,使用 `map` 相比使用 `sync.Map` 会有更好的性能** + +## sync/Atomic +除了前面介绍的锁`mutex`以外,还有一种解决并发安全的策略,就是原子操作。所谓原子操作就是这一系列的操作在`cpu`上执行是一个不可分割的整体,显然要么全部执行,要么全部不执行,不会受到其他操作的影响,也就不会存在并发问题。 + +### atomic和mutex的区别 +1. 使用方式:通常`mutex`用于保护一段执行逻辑,而`atomic`主要是对变量进行操作 +2. 底层实现:`mutex`由操作系统调度器实现,而`atomic`操作有底层硬件指令支持,保证在`cpu`上执行不中断。所以`atomic`的性能也能随`cpu`的个数增加线性提升 + +#### `atomic`提供的方法: +```go +func AddT(addr *T, delta T)(new T) +func StoreT(addr *T, val T) +func LoadT(addr *T) (val T) +func SwapT(addr *T, new T) (old T) +func CompareAndSwapT(addr *T, old, new T) (swapped bool) +T的类型是int32、int64、uint32、uint64和uintptr中的任意一种 +``` +这里就不一一演示各个方法了,以`AddT`方法为例简单看一个例子 +```go +package main + +import ( + "fmt" + "sync" + "sync/atomic" +) + +func main() { + + var sum int32 = 0 + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + atomic.AddInt32(&sum, 1) + }() + } + wg.Wait() + fmt.Printf("sum is %d\n",sum) +} +``` +100个goroutine,每个goroutine都对sum+1,最后结果为100。 + +### atomic.value +上面展示的`AddT`,`StoreT`等方法都是针对的基本数据类型做的操作,假设想对多个变量进行同步保护,即假设想对一个`struct`这样的复合类型用原子操作,也是支持的吗?也可以做支持,go语言里的`atomic.value`支持任意一种接口类型进行原子操作,且提供了`Load`、`Store`、`Swap`和`CompareAndSwap`四种方法: +- `Load`:func (v *Value) Load() (val any),从value读出数据 +- `Store`:func (v *Value) Store(val any),向value写入数据 +- `Swap`:func (v *Value) Swap(new any) (old any),用new交换value中存储的数据,返回value原来存储的旧数据 +- `CompareAndSwap`:func (v *Value) CompareAndSwap(old, new any) (swapped bool),比较value中存储的数据和old是否相同,相同的话,将value中的数据替换为new +代码示例 +```go +package main + +import ( + "fmt" + "sync/atomic" +) + +type Student struct { + Name string + Age int +} + +func main() { + st1 := Student{ + Name: "zhangsan", + Age: 18, + } + st2 := Student{ + Name: "lisi", + Age: 19, + } + st3 := Student{ + Name: "wangwu", + Age: 20, + } + var v atomic.Value + v.Store(st1) + fmt.Println(v.Load().(Student)) + + old := v.Swap(st2) + fmt.Printf("after swap: v=%v\n", v.Load().(Student)) + fmt.Printf("after swap: old=%v\n", old) + + swapped := v.CompareAndSwap(st1, st3) // v中存储的和st1不相同,交换失败 + fmt.Println("compare st1 and v\n", swapped, v) + + swapped = v.CompareAndSwap(st2, st3) // v中存储的和st2相同,交换成功,v中变为st3 + fmt.Println("compare st2 and v\n", swapped, v) +} +``` +运行结果 +``` +{zhangsan 18} +after swap: v={lisi 19} +after swap: old={zhangsan 18} +compare st1 and v + false {{lisi 19}} +compare st2 and v + true {{wangwu 20}} +``` + +## sync.pool +`sync.Pool`是在`sync`包下的一个内存池组件,用来实现对象的复用,避免重复创建相同的对象,造成频繁的内存分配和gc,以达到提升程序性能的目的。虽然池子中的对象可以被复用,但是是`sync.Pool`并不会永久保存这个对象,池子中的对象会在一定时间后被gc回收,这个时间是随机的。所以,用`sync.Pool`来持久化存储对象是不可取的。 +另外,`sync.Pool`本身是并发安全的,支持多个`goroutine`并发的往`sync.Pool`存取数据 + +### sync.pool使用方法 +关于`sync.Pool`的使用,一般是通过三个方法来完成的 + +| 方法 | 说明 | +| --- | --- | +| New() | sync.Pool的构造函数,用于指定sync.Pool中缓存的数据类型,当调用Get方法从对象池中获取对象的时候,对象池中如果没有,会调用New方法创建一个新的对象 | +| Get() | 从对象池取对象 | +| Put() | 往对象池放对象,下次Get的时候可以复用 | + +下面通过例子看一下`sync.Pool`的使用方式 +```go +package main + +import ( + "fmt" + "sync" +) + +type Student struct { + Name string + Age int +} + +func main() { + pool := sync.Pool{ + New: func() interface{} { + return &Student{ + Name: "zhangsan", + Age: 18, + } + }, + } + + st := pool.Get().(*Student) + println(st.Name, st.Age) + fmt.Printf("addr is %p\n", st) + + pool.Put(st) + + st1 := pool.Get().(*Student) + println(st1.Name, st1.Age) + fmt.Printf("addr1 is %p\n", st1) +} +``` +程序输出 +``` +zhangsan 18 +addr is 0x140000a0018 +zhangsan 18 +addr1 is 0x140000a0018 +``` +在程序中,首先初始化一个`sync.Pool`对象,初始化里面的`New`方法,用于创建对象,这里是返回一个`Student`类型的指针。第一次调用`pool.Get().(*Student)`的时候,由于池子内没有对象,所以会通过`New`方法创建一个,注意`pool.Get()`返回的是一个`interface{}`,所以我们需要断言成`*Student`类型,在我们使用完,打印出`Name`和`Age`之后,再调用`Put`方法,将这个对象放回到池子内,后面我们紧接着又调用`pool.Get()`取对象,可以看到两次去除的对象地址是同一个,说明是同一个对象,表明`sync.Pool`有缓存对象的功能。 +>注意 +>我们在第一次`pool.Get()`取出`*Student`对象打印完地址之后,put进池子的时候没有进行一个Reset的过程,这里是因为我们取出`*Student`对象之后,仅仅是读取里面的字段,并没有修改操作,假设我们有修改操作,那么这里就需要在`pool.Put(st)`之前执行`Reset`,将对象的值复原,如果不这样做,那么下一次`pool.Get()`取出的`*Student`对象就不是我们希望复用的初始对象 + +假设我们对`*Student`做修改 +```go +package main + +import ( + "fmt" + "sync" +) + +type Student struct { + Name string + Age int +} + +func main() { + pool := sync.Pool{ + New: func() interface{} { + return &Student{ + Name: "zhangsan", + Age: 18, + } + }, + } + + st := pool.Get().(*Student) + println(st.Name, st.Age) + fmt.Printf("addr is %p\n", st) + + // 修改 + st.Name = "lisi" + st.Age = 20 + + // 回收 + pool.Put(st) + + st1 := pool.Get().(*Student) + println(st1.Name, st1.Age) + fmt.Printf("addr1 is %p\n", st1) +} +``` +程序输出 +``` +zhangsan 18 +addr is 0x1400000c030 +lisi 20 +addr1 is 0x1400000c030 +``` +可以看到,我们第二次取出的对象虽然和第一次是同一个,地址形同,但是对象的字段值却发生了变化,不是我们初始化的对象了,我们想要一只重复使用一个相同的对象的话,显然这里有问题。所以,我们需要在`pool.Put(st)`回收对象之前,进行对象的`Reset`操作,将对象值复原,同时在每次我们`pool.Get()`取出完对象使用完毕之后,也不要忘了调用`pool.Put`方法把对象再次放入对象池,以便对象能够复用。 + +### sync.pool使用场景 +1. `sync.pool`主要是通过对象复用来降低gc带来的性能损耗,所以在高并发场景下,由于每个`goroutine`都可能过于频繁的创建一些大对象,造成gc压力很大。所以在高并发业务场景下出现 GC 问题时,可以使用 `sync.Pool` 减少 GC 负担 +2. `sync.pool`不适合存储带状态的对象,比如socket 连接、数据库连接等,因为里面的对象随时可能会被gc回收释放掉 +3. 不适合需要控制缓存对象个数的场景,因为`Pool` 池里面的对象个数是随机变化的,因为池子里的对象是会被gc的,且释放时机是随机的 + diff --git a/hzh/GolangStar/Go语言进阶/协程池.md b/hzh/GolangStar/Go语言进阶/协程池.md new file mode 100644 index 0000000..53df46f --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/协程池.md @@ -0,0 +1,194 @@ +--- +tags: + - Go + - golang + - go进阶 + - 协程池 + - 并发 +--- + +# 协程池 +Go语言虽然有着高效的GMP调度模型,理论上支持成千上万的`goroutine`,但是`goroutine`过多,对调度,gc以及系统内存都会造成压力,这样会使我们的服务性能不升反降。常用做法可以用池化技术,构造一个协程池,把进程中的协程控制在一定的数量,防止系统中`goroutine`过多,影响服务性能。 + +## 协程池模型 +协程池简单理解就是有一个池子一样的东西,里面装这个固定数量的`goroutine`,当有一个任务到来的时候,会将这个任务交给池子里的一个空闲的`goroutine`去处理,如果池子里没有空闲的`goroutine`了,任务就会阻塞等待。所以协程池有三个角色`Worker`,`Task`,`Pool`。 + +### 属性定义 +- `Worker`:用于执行任务的`goroutine` +- `Task`: 具体的任务 +- `Pool`: 池子 + +下面看一下各个角色的定义: + +#### Task定义 +`Task`有一个函数成员,表示这个task具体的执行逻辑: + +```go +type Task struct { + f func() error // 具体的执行逻辑 +} +``` + +#### Pool定义 +`Pool`有两个成员,`Capacity`表示池子里的worker的数量,即工作的`goroutine`的数量,`JobCh`表示任务队列用于存放任务,`goroutine`从这个`JobCh`获取任务执行任务逻辑: +```go +type Pool struct { + RunningWorkers int64 + Capacity int64 // goroutine数量 + JobCh chan *Task // 用于worker取任务 + sync.Mutex +} +``` + +#### Worker定义 +```go +// p为Pool对象指针 +for task := range p.JobCh { + do ... +} +``` +执行任务单元,简单理解就是干活的`goroutine`,这个worker其实只做一件事情,就是不断的从任务队列里面取任务执行,而worker的数量就是协程池里协程的数量,由`Pool`的参数`WorkerNum`指定。 + +### 方法定义 +```go +func NewTask(funcArg func() error) *Task +``` +`NewTask`用于创建一个任务,参数是一个函数,返回值是一个`Task`类型。 + +```go +func NewPool(Capacity int, taskNum int) *Pool +``` +`NewPool`返回一个协程数量固定为`workerNum`协程池对象指针,其任务队列的长度为`taskNum`。 + +接下来主要介绍协程池的各个方法: +```go +func (p *Pool) AddTask(task *Task) +``` +`AddTask`方法是往协程池添加任务,如果当前运行着的worker数量小于协程池worker容量,则立即启动一个协程worker来处理任务,否则将任务添加到任务队列。 + +```go +func (p *Pool) Run() +``` +将协程池跑起来,启动一个worker来处理任务。 + +协程池处理任务流程图: + +![协程池流程](https://golangstar.cn/assets/img/go语言系列/协程池/协程池1.png) + +### 协程池实现 +```go +package main + +import ( + "fmt" + "sync" + "sync/atomic" + "time" +) + +type Task struct { + f func() error // 具体的任务逻辑 +} + +func NewTask(funcArg func() error) *Task { + return &Task{ + f: funcArg, + } +} + +type Pool struct { + RunningWorkers int64 // 运行着的worker数量 + Capacity int64 // 协程池worker容量 + JobCh chan *Task // 用于worker取任务 + sync.Mutex +} + +func NewPool(capacity int64, taskNum int) *Pool { + return &Pool{ + Capacity: capacity, + JobCh: make(chan *Task, taskNum), + } +} + +func (p *Pool) GetCap() int64 { + return p.Capacity +} + +func (p *Pool) incRunning() { // runningWorkers + 1 + atomic.AddInt64(&p.RunningWorkers, 1) +} + +func (p *Pool) decRunning() { // runningWorkers - 1 + atomic.AddInt64(&p.RunningWorkers, -1) +} + +func (p *Pool) GetRunningWorkers() int64 { + return atomic.LoadInt64(&p.RunningWorkers) +} + +func (p *Pool) run() { + p.incRunning() + go func() { + defer func() { + p.decRunning() + }() + for task := range p.JobCh { + task.f() + } + }() +} + +// AddTask 往协程池添加任务 +func (p *Pool) AddTask(task *Task) { + // 加锁防止启动多个 worker + p.Lock() + defer p.Unlock() + + if p.GetRunningWorkers() < p.GetCap() { // 如果任务池满, 则不再创建 worker + // 创建启动一个 worker + p.run() + } + + // 将任务推入队列, 等待消费 + p.JobCh <- task +} + +func main() { + // 创建任务池 + pool := NewPool(3, 10) + + for i := 0; i < 20; i++ { + // 任务放入池中 + pool.AddTask(NewTask(func() error { + fmt.Printf("I am Task\n") + return nil + })) + } + + time.Sleep(1e9) // 等待执行 +} +``` +运行结果: +``` +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +I am Task +``` +程序创建了一个`WorkerNum`为3,任务队列长度为10的协程池,往里面添加了20个任务,可以看到输出,一直只有3个`worker`在做任务,起到了控制`goroutine`数量的作用。 diff --git a/hzh/GolangStar/Go语言进阶/反射.md b/hzh/GolangStar/Go语言进阶/反射.md new file mode 100644 index 0000000..3686649 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/反射.md @@ -0,0 +1,866 @@ +--- +tags: + - Go + - golang + - go进阶 + - 反射 +--- + +# 反射 + +## 什么是反射 +反射可以认为是程序在运行时的一种能力,反射可以在程序运行时访问、检测和修改它本身状态,比如在程序运行时可以检查变量的类型和值,调用它们的方法,甚至修改它们的值。使用反射可以增加程序的灵活性,简单来说,反射就是程序在运行时能够检测自身和修改自身的一种能力。 + +## Go语言反射 +对于很多的高级语言都实现了反射,像Java,Python。在Go语言中,反射在Go语言内置的`reflect`包下实现。Go语言中的反射建立在Go的类型系统之上,并且与接口密切相关。通过前面的学习我们知道Go语言的空接口包含类型(`Type`)和值(`Value`)两个部分,在反射里,也要用到类型(`Type`)和值(`Value`)。 +`reflect`包中定义了`reflect.Type`和`reflect.Value`,正好对应我们前面所说的`Type`和`Value`。要注意的是`reflect.Type`是一个接口而`reflect.Value`是一个具体的结构体。在`reflect.Type`接口中定义了很多跟类型相关的方法,而`reflect.Value`则是绑定了很多跟值相关的方法。 + +### reflect.TypeOf() +由于`reflect.Type`是一个接口,所以只有当某个类型实现了这个接口,我们才能获取到它的类型,同时,在`reflect`包内,类型描述符是未导出类型,所以我们只能通过`reflect.TypeOf()`方法获取`reflect.Type`类型的值。 +我们首先看一个例子,看下`reflect.TypeOf()`的常用用法: +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int +} + +func main() { + var num int64 = 100 + t1 := reflect.TypeOf(num) + fmt.Println(t1.String()) + + st := Student{ + Name: "zhangsan", + Age: 18, + } + t2 := reflect.TypeOf(st) + fmt.Println(t2.String()) +} +``` +运行结果: +``` +int64 +main.Student +``` +可以看到对于基础类型和`struct`类型通过调用`reflect.TypeOf()`都打印出了对应的类型信息。注意`reflect.TypeOf`返回的是一个`reflect.Type`接口类型,我们通过调用这个接口的`String()`方法,得到最终的字符串信息。 + +在前面学习`interface`的章节中,我们知道一个具体的数据类型是可以赋值给一个`interface`类型的,反过来则不行,要用到`interface`的断言。在一个`interface`赋值之后,其实是对应了两个类型,一个是静态类型,就是在程序编译期就确定的类型,`interface`的静态类型就是接口`interface`,同时当`interface`赋值之后,他还有一个动态类型,就是被赋值的那个数据的具体类型,假设在上例中,我们将`st`赋值给一个空`interface`,那么这个`interface`的动态类型就是`Student`。 + +对一个数据对象进行反射操作,其实是首先将具体对象类型转化为一个`interface`类型,然后再将`interface`类型转化为`reflect`包下的反射类型,反射类型里的类型信息和值信息其实就是对应着这个中间类型`interface`的类型和值。 + +![反射原理](https://golangstar.cn/assets/img/go语言系列/反射/反射1.png) + +`reflect.TypeOf()`方法获取的就是这个`interface{}`中的类型部分。 + +### reflect.ValueOf() +同理,`reflect.ValueOf()`方法自然就是获取接口中的值部分,`reflect.ValueOf()`的返回值其实就是一个`reflect.Value`结构。 +```go +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int +} + +func main() { + var num int64 = 100 + v1 := reflect.ValueOf(num) + fmt.Println(v1) + fmt.Println(v1.String()) + + st := Student{ + Name: "zhangsan", + Age: 18, + } + v2 := reflect.ValueOf(st) + fmt.Println(v2) + fmt.Println(v2.String()) +} +``` +运行结果: +``` +100 + +{zhangsan 18} + +``` +注意到这里`fmt.Println(v1)`和`fmt.Println(v1.String())`打印的不一样,上面说了`reflect.ValueOf()`的返回值就是一个`reflect.Value`结构,但是`fmt.Println(v1)`却打印出了具体的值,这是因为`fmt.Println`的参数是一个接口类型,在执行过程中有一些类型转换,对`reflect.Value`结构做了特殊处理。 + +### Go语言数据种类 +在Go语言中常用的数据类型有26种,以枚举的方式定义在`src/reflect/type.go`文件中: + +![数据种类](https://golangstar.cn/assets/img/go语言系列/反射/反射2.png) + +这些类型中包含`int`,`bool`之类的基础数据类型,也包含`Struct`,`Array`,`Map`等复合类型,有了这些类型,我们用`type struct`自定义的任何类型都可以由他们组合完成。 +看个`type struct`定义的数据类型使用反射的例子: +```go +package main + +import ( + "fmt" + "reflect" +) + +type WrapInt int + +func main() { + var num1 int = 100 + var num2 WrapInt = 1000 + + num1 = int(num2) // 不同类型的type赋值,这里要强转 + + typeNum1 := reflect.TypeOf(num1) + fmt.Printf("type of num1 is %s\n", typeNum1.String()) + + typeNum2 := reflect.TypeOf(num2) + fmt.Printf("type of num2 is %s\n", typeNum2.String()) + + fmt.Printf("kind of num1 is %v\n", typeNum1.Kind()) + fmt.Printf("kind of num2 is %v\n", typeNum2.Kind()) +} +``` +运行结果: +``` +type of num1 is int +type of num2 is main.WrapInt +kind of num1 is int +kind of num2 is int +``` +通过`WrapInt`的定义可以看到,`WrapInt`其实就是用`type`给`int`去了个别名,二者底层其实都是`int`类型,但是通过`reflect.TypeOf`获取到各自的`type`其实是不一样的,不同`type`之间的变量赋值是需要类型强制转换的,但是深层次的去分析`type`的种类,即`Kind`确是一样的。 + +## 反射使用 + +### 值对象 +`reflect`包下跟值对象相关的常用函数或方法: + +| 函数/方法 | 说明 | +|----------|------| +| `reflect.TypeOf()` | 获取某个对象的反射类型实现(`reflect.Type`) | +| `reflect.ValueOf()` | 获取某个对象的反射值对象(`reflect.Value`) | +| `reflect.Value.NumField()` | 获取结构体的反射值对象中的字段个数,只对结构体类型有效 | +| `reflect.Value.Field(i)` | 获取结构体的反射值对象中的第i个字段,只对结构体类型有效 | +| `reflect.Kind()` | 从反射值对象中获取该值的种类 | +| `reflect.Value.MapKeys()` | 对map的每个键的`reflect.Value`对象组成的一个切片 | +| `reflect.Value.MapIndex(i)` | 根据map的某个键的`reflect.Value`对象,返回值的`reflect.Value`对象 | +| `reflect.Value.Len()` | 对切片或数组的反射对象求切片或数组的长度 | +| `reflect.Value.Index(i)` | 返回切片或数组第i个元素的`reflect.Value`值 | +| `reflect.Int()/reflect.Uint()/reflect.String()/reflect.Bool()` | 从反射的值对象中取出对应值,注意`reflect.Int()/reflect.Uint()`方法对种类做了合并处理,它们只返回相应的最大范围的类型,`Int()`返回`Int64`类型,`Uint()`返回`Uint64`类型 | + +#### 获取struct反射值 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + + st := Student{ + Name: "zhangsan", + Age: 18, + Score: 95.5, + } + v := reflect.ValueOf(st) + fmt.Printf("the field num of Student is %d\n", v.NumField()) + fmt.Printf("field1 type is %v, value is %s\n", v.Field(0).Type().Name(), v.Field(0).String()) + fmt.Printf("field2 type is %v, value is %d\n", v.Field(1).Type().Name(), v.Field(1).Int()) + fmt.Printf("field2 type is %v, value is %f\n", v.Field(2).Type().Name(), v.Field(2).Float()) +} +``` +运行结果: +``` +the field num of Student is 3 +field1 type is string, value is zhangsan +field2 type is int, value is 18 +field2 type is float64, value is 95.500000 +``` +`v := reflect.ValueOf(st)`,`v`是一个`Student`类型的反射值对象,通过`v.NumField()`可以得出`Student`类型的字段个数,然后`v.Field(i).Type().Name()`打印出各个字段值的类型,`v.Field(i)`打印出各个字段值 +注意:`NumField()`和`Field()`方法只有原对象是结构体时才能调用,否则会panic + +#### 获取map反射值 +```go +package main + +import ( + "fmt" + "reflect" +) + +func main() { + m := map[int]uint32{ + 1: 100, + 2: 200, + } + v := reflect.ValueOf(m) + for _, k := range v.MapKeys() { + field := v.MapIndex(k) + fmt.Printf("key type is %v, key = %d; value type is %v, value = %d\n", k.Type().Name(), k.Int(), field.Type().Name(), field.Uint()) + } +} +``` +运行结果: +``` +key type is int, key = 1; value type is uint32, value = 100 +key type is int, key = 2; value type is uint32, value = 200 +``` +`v := reflect.ValueOf(m)`对map类型的对象m进行反射,通过`v.MapKeys()`的到m中所有key的`reflect.Value`对象k,然后通过`v.MapIndex(k)`的到对应key反射值对象的value反射值对象,然后通过`reflect.Value`的`Type().Name()`方法获取map中key,value的类型,然后打印出对应值 + +#### 获取slice反射值 +```go + +import ( + "fmt" + "reflect" +) + +func main() { + slice := []int{1, 2, 3} + v1 := reflect.ValueOf(slice) + for i := 0; i < v1.Len(); i++ { + elem := v1.Index(i) + fmt.Printf("%v ", elem.Interface()) + } + + fmt.Println() + + nums := [3]int{4, 5, 6} + v2 := reflect.ValueOf(nums) + for i := 0; i < v2.Len(); i++ { + elem := v2.Index(i) + fmt.Printf("%v ", elem.Interface()) + } +} +``` +运行结果: +``` +1 2 3 +4 5 6 +``` +`v1`,`v2`分别是切片和数组的反射值对象,通过`Len()`获取到数组或切片中的元素个数,然后通过`v.Index(i)`获取对应元素的`reflect.value`对象,打印出其值 +> **注意:`Len()`和`Index(i)`方法只能在原对象是切片,数组或字符串时才能调用,其他类型会panic**。 + +### 类型对象 +reflect包下跟类型相关的常用函数或方法 + +| 函数/方法 | 说明 | +|----------|------| +| `reflect.Value.NumField()` | 获取结构体的反射值对象中的字段个数,只对结构体类型有效 | +| `reflect.Value.Field(i)` | 获取结构体的反射值对象中的第i个字段,只对结构体类型有效 | +| `reflect.Value.Elem()` | 根据指针获取对应的具体类型 | +| `reflect.Value.NumIn()` | 获取函数反射类型的参数个数 | +| `reflect.Value.In(i)` | 获取函数反射类型的第i个参数 | +| `reflect.Value.NumOut()` | 获取函数反射类型的返回值个数 | +| `reflect.Value.Out(i)` | 获取函数反射类型的第i个返回值 | +| `reflect.Value.NumMethod()` | 获取struct上绑定的方法个数 | +| `reflect.Value.Method(i)` | 获取struct上绑定的第i个方法 | + +#### struct反射类型 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + t := reflect.TypeOf(st) + fmt.Println(t.Name()) + fmt.Println(t.Kind()) + fmt.Println(t.NumField()) + for i := 0; i < t.NumField(); i++ { + fmt.Printf("field1 name is %s, field1 type is %s\n", t.Field(i).Name, t.Field(i).Type.String()) + } +} +``` +运行结果: +``` +Student +struct +3 +field1 name is Name, field1 type is string +``` +通过`reflect.Type`的`Name()`方法可以获取对应的`Type`类型,`Kind()`方法获取底层的数据种类,即`kind`,跟`reflect.Value`一样,`reflect.Type`也提供了`NumField()`方法用于获取结构体对象中的字段个数,通过`t.Field(i).Name`可以获取对应字段的名字。同样,`Field(i)`和`NumField()`也只能对结构体反射使用 + +#### 指针反射类型 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + t := reflect.TypeOf(st) + + fmt.Println(t.Kind()) + fmt.Println(t.Elem().Name()) // 这里一定要加Elem(),根据指针获取到具体类型后,才能或者具体的type名 + fmt.Println(t.Elem().NumField()) // 这里一定要加Elem(),根据指针获取到具体类型后,才能字段个数 + for i := 0; i < t.Elem().NumField(); i++ { + fmt.Printf("field1 name is %s, field1 type is %s\n", t.Elem().Field(i).Name, t.Elem().Field(i).Type.String()) + } + +} +``` +运行结果: +``` +ptr +Student +3 +field1 name is Name, field1 type is string +field2 name is Age, field2 type is int +field3 name is Score, field3 type is float64 +``` +可以看到,跟上面直接获取`struct`有一点点小小的区别,那就是`fmt.Println(t.Kind())`打印出的是一个`ptr`指针类型,而不再是`struct`类型,正是因为这里是一个`ptr`,所以我们不能直接在这个`ptr`上调用.Name()以及其他的.NumField()之类的方法,要根据`ptr`的.Elem()获取到具体类型之后,才能用这些方法,否则程序就回报`panic`,这点一定要注意 + +#### 函数反射类型 +```go +package main + +import ( + "fmt" + "reflect" +) + +func Add(num1, num2 int) (int, error) { + return num1 + num2, nil +} + +func main() { + + fmt.Println("input:") + t := reflect.TypeOf(Add) + for i := 0; i < t.NumIn(); i++ { + tIn := t.In(i) + fmt.Print(tIn.Name()) + fmt.Printf(" ") + } + fmt.Printf("\n--------------------------------") + + fmt.Println("output:") + for i := 0; i < t.NumOut(); i++ { + tOut := t.Out(i) + fmt.Print(tOut.Name()) + fmt.Print(" ") + } + +} +``` +运行结果: +``` +input: +int int +-------------------------------- +output: +int error +``` +`t := reflect.TypeOf(Add)`获取到Add函数的`type`类型,然后通过`NumIn()`方法获得Add函数的参数个数,依次打印出参数的类型。通过`NumOut()`方法获得Add函数的返回值个数,依次打印出返回值的类型 + +#### 3.2.4 反射获取struct方法 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func (s *Student) GetName() string { + return s.Name +} + +func (s *Student) SetName(name string) { + s.Name = name +} + +func (s *Student) GetAge() int { + return s.Age +} + +func (s *Student) SetAge(age int) { + s.Age = age +} + +func (s *Student) GetScore() float64 { + return s.Score +} + +func (s *Student) SetScore(score float64) { + s.Score = score +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + t := reflect.TypeOf(st) + + for i := 0; i < t.NumMethod(); i++ { + m := t.Method(i) + fmt.Printf("%+v\n", m) + } +} +``` +运行结果: +``` +{GetName func(*main.Student) string} +{SetName func(*main.Student, string)} +{GetAge func(*main.Student) int} +{SetAge func(*main.Student, int)} +{GetScore func(*main.Student) float64} +{SetScore func(*main.Student, float64)} +``` +- `reflect.Type.NumMethod()`:返回struct所绑定的的方法个数 +- `reflect.Type.Method(i)`:返回第i个方法的`reflect.Method`对象 + +`reflect.Method`定义在`src/reflect/type.go`文件: +```go +type Method struct { + Name string // 方法名 + PkgPath string + Type Type // 方法类型( + Func Value // 方法值(方法的接收器作为第一个参数) + Index int // 是结构体中的第几个方法 +} +``` +所以,通过`reflect.Method`对象,我们可以获取到`struct`所绑定的对应方法的方法名,方法类型等信息 + +### 通过反射调用方法 +在上一小节我们知道了`reflect.Type.Method(i)`可以获取到`struct`所绑定的具体的方法对象`reflect.Method`,通过这个对象,我们不仅可以获取方法的详细信息,还可以动态的调用方法。 +其实在`reflect.Value`里我们也可以使用`NumMethod()`/`Method(i)`方法获取到对应的方法信息,不同的是`reflect.Value.Method(i)`返回的使一个`reflect.Value`对象,但是同样可以根据这个对象来动态调用方法,只是两者调用方法的方式有所区别 +请看具体例子: +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func (s *Student) GetName() string { + return s.Name +} + +func (s *Student) SetName(name string) { + s.Name = name +} + +func (s *Student) GetAge() int { + return s.Age +} + +func (s *Student) SetAge(age int) { + s.Age = age +} + +func (s *Student) GetScore() float64 { + return s.Score +} + +func (s *Student) SetScore(score float64) { + s.Score = score +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + fmt.Printf("st === %+v\n", st) + + t := reflect.TypeOf(st) + v := reflect.ValueOf(st) + + m1, ok := t.MethodByName("SetName") // 获取SetName方法 + fmt.Printf("t get func by name:%t\n", ok) + + argsV1 := make([]reflect.Value, 0) + argsV1 = append(argsV1, v) + argsV1 = append(argsV1, reflect.ValueOf("lisi")) + m1.Func.Call(argsV1) // + fmt.Printf("st === %+v\n", st) + + m2 := v.MethodByName("SetName") // 获取SetName方法 + argsV2 := make([]reflect.Value, 0) + argsV2 = append(argsV2, reflect.ValueOf("wangwu")) + m2.Call(argsV2) + fmt.Printf("st === %+v\n", st) +} +``` +运行结果: +``` +st === &{Name:zhangsan Age:18 Score:90.5} +t get func by name:true +st === &{Name:lisi Age:18 Score:90.5} +st === &{Name:wangwu Age:18 Score:90.5} +``` +可以看到通过`reflect.Type.MethodByName()`方法获取到的`reflect.Method`对象和`reflect.Value.MethodByName()`方法获取到的`reflect.Method`获取到的`reflect.Value`对象都可以在程序运行时动态的调用方法修改结构本身,`student`的`name`由`zhangsan`------>`lisi`------>`wangwu`。 +但是二者的调用存在一个区别:通过`reflect.Method`调用方法,必须使用`Func`字段,而且要传入接收器的`reflect.Value`作为第一个参数 +```go +m1.Func.Call(argsV1) +``` +`reflect.Value.MethodByName()`返回一个`reflect.Value`对象,它不需要接收器的`reflect.Value`作为第一个参数,而且直接使用`Call()`发起方法调用: +```go +m2.Call(argsV2) +``` + +### 通过反射设置值 +在介绍通过反射设置或者说是修改值的方法之前,首先介绍一个概念,反射寻址。简单的说,可寻址就是可以根据地址找到值,在反射里面,`reflect.Value`由`reflect.ValueOf()`方法得到,根据命名就可以知道`reflect.ValueOf()`是得到一个值对象,显然他不能得到这个值的地址。所以通过`reflect.ValueOf()`方法得到的`reflect.Value`都是不可寻址的。在`reflect`包下有一个`CanAddr()`方法可以用于验证一个对象是否可寻址 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + v := reflect.ValueOf(st) + fmt.Println(v.CanAddr()) +} +``` +运行结果: +``` +false +``` +尽管这里`st`是一个`Student`类型的指针,但是经过`reflect.ValueOf()`之后得到的对象`v`,仍然是不能寻址的,他只能反映出当前指针指向的具体元素的地址,而当前指针自身所在的内存地址是无从得知的 +经过前面学习指针反射类型的学习,我们知道了假设`reflect.ValueOf()`返回的是一个指针的`reflect.Value`对象,那么我们可以调用`reflect.Value.Elem()`方法得到具体的类型,而此时得到的这个反射具体类型就是可寻址的,我们可以知道他的地址,道理很简单,因为`reflect.Value.Elem()`获取到的值,记录了根据指针获取到值这个获取路径,显然我们可以根据值追溯到地址 + +1. reflect.Value.Elem()方法得到具体的类型可寻址 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + v := reflect.ValueOf(st) + fmt.Println(v.Elem().CanAddr()) +} +``` +运行结果: +``` +true +``` +可以看到,通过`reflect.Value.Elem()`方法得到具体的类型之后,这个具体的类型就是可寻址的,所以`CanAddr()`方法返回`true` + +2. 对切片进行反射时,通过reflect.Value.Index(i)获取到的reflect.Value对象是可以寻址的 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + slice := []int{1, 2, 3, 4, 5} + v := reflect.ValueOf(slice) + fmt.Println(v.Index(0).CanAddr()) + fmt.Println(v.Index(1).CanAddr()) +} +``` +运行结果: +``` +true +true +``` +可以看到,通过`reflect.Value.Index(i)`获取到的`reflect.Value`对象是可以寻址的,所以`CanAddr()`方法返回`true`。但是对数组`reflect.Value.Index(i)`获取到的`reflect.Value`对象是不可寻址的 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + nums := [3]int{1, 2, 3} + v1 := reflect.ValueOf(nums) + fmt.Println(v1.Index(0).CanAddr()) +} +``` +运行结果: +``` +false +``` +可以看到,对数组`reflect.Value.Index(i)`获取到的`reflect.Value`对象是不可寻址的 +通过`reflect.ValueOf`的到的其实是原始数据的一份拷贝,切片底层实现其实是一个`stuct`类型,`struct`里包含一个执行具体数组的指针,对切片拷贝,虽然拷贝了这个`struct`,自然`struct`里的指针也拷贝了一份,但是两个指针执行同一个内存区域,所以修改拷贝的切片,也会影响原值 + +3. 通过结构体的指针获取到的字段也是可寻址的 +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + v := reflect.ValueOf(st) + f := v.Elem().Field(0) + fmt.Println(f.CanAddr()) +} +``` +运行结果: +``` +true +``` +可以看到,通过结构体的指针获取到的字段也是可寻址的。通过上面分析,可寻址就是可以找到数据本身,而不是找到数据的副本,既然我们找到了数据本身,那么就可以对原数据修改,即设置值了吗? +大体上是这样,但是条件可能还要更严格一些,刚是可寻址还不够,比如当`struct`里含有未导出字段时,这个未导出字段时不可设置的。所以,在`reflect`包下有一个专门的方法用于判断这个值是否可修改:`CanSet()` +假设我们将`Student`的`score`字段改为未导出的,然后运行以下代码: +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + score: 90.5, + } + v := reflect.ValueOf(st) + f := v.Elem().Field(0) + fmt.Println(f.CanSet()) + f2 := v.Elem().Field(2) + fmt.Println(f2.CanSet()) +} +``` +运行结果: +``` +true +false +``` +可以看到,可导出字段`Name`是可设置的,而不可导出字段`score`是不可设置的。当我们判定一个值是可设置之后,接下来要怎么修改这个值呢? +`reflect.Value`为基础类型提供了一系列特殊的`Set`方法:`SetInt`、`SetUint`、`SetFloat`等在反射的时候修改对应类型值。 +下面看个具体例子: +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + v := reflect.ValueOf(st) + f := v.Elem().Field(0) + f.SetString("lisi") + fmt.Printf("st = %+v\n", st) +} +``` +运行结果: +``` +st = &{Name:lisi Age:18 Score:90.5} +``` +可以看到,通过调用第一个字段`reflect.value`的`SetString`方法,将`st`对象的`Name`改为了`lisi`。 + +## 结构体标签 + + +当我们判定一个值是可设置之后,接下来要怎么修改这个值呢? + +`reflect.Value`为基础类型提供了一系列特殊的`Set`方法:`SetInt`、`SetUint`、`SetFloat`等在反射的时候修改对应类型值。 + +下面看个具体例子: + +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string + Age int + Score float64 +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + v := reflect.ValueOf(st) + f := v.Elem().Field(0) + f.SetString("lisi") + fmt.Printf("st = %+v\n", st) +} +``` + +运行结果: +``` +st = &{Name:lisi Age:18 Score:90.5} +``` + +可以看到,通过调用第一个字段`reflect.value`的`SetString`方法,将`st`对象的`Name`改为了`lisi`。 + +### 结构体标签 +我们在定义结构体的时候,可以为每个字段后面加一个标签,即`StructTag`,标签其实就是一组键值对,每个键值对用空格分开,这些标签信息可以通过反射获取: +```go +package main + +import ( + "fmt" + "reflect" +) + +type Student struct { + Name string `json:"name"` + Age int `json:"age"` + Score float64 `json:"score"` +} + +func main() { + st := &Student{ + Name: "zhangsan", + Age: 18, + Score: 90.5, + } + t := reflect.TypeOf(st).Elem() + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + fmt.Println(f.Tag) + } +} +``` +运行结果: +``` +json:"name" +json:"age" +json:"score" +``` + +## 反射的优缺点 +优点: +- 可以提升程序代码的灵活性,根据条件在程序运行时灵活的调用函数,并且修改源代码结构 + +缺点: +- 主要是性能影响,反射过程中会有大量的内存开辟和gc过程,导致程序的性能降低 diff --git a/hzh/GolangStar/Go语言进阶/定时器.md b/hzh/GolangStar/Go语言进阶/定时器.md new file mode 100644 index 0000000..55bc775 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/定时器.md @@ -0,0 +1,253 @@ +--- +tags: + - Go + - golang + - go进阶 + - 定时器 + - 并发 +--- + +# 定时器 +在我们项目中,常常会有这样的场景,比如到了未来某一时刻,需要某个逻辑或者某个任务执行一次,或者是周期性的的执行多次,有点类似定时任务。这种场景就需要用到定时器,Go语言中也内置了定时器的实现,`timer`和`ticker`。 + +## Timer +`Timer`是一种一次性时间定时器,即在未来某个时刻,触发的事件只会执行一次。 + +### Timer的结构定义 +```go +type Timer struct { + C <-chan Time + r runtimeTimer +} +``` +`Timer`结构里有一个`Time`类型的管道`C`,主要用于事件通知。在未到达设定时间的时候,管道内没有数据写入,一直处于阻塞状态,到达设定时间后,会向管道内写入一个系统时间,触发事件。 + +### 创建Timer +```go +func NewTimer(d Duration) *Timer +``` +使用示例: +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + timer := time.NewTimer(2 * time.Second) //设置超时时间2s + <-timer.C + fmt.Println("after 2s Time out!") +} +``` +运行结果: +``` +after 2s Time out! +``` +程序在`2s`后打印`"after 2s Time out!"`,因为创建了一个定时器`timer`,设置了超时时间为`2s`,执行`<-timer.C`会一直阻塞,直到`2s`后,程序继续执行。 + +### 停止Timer +```go +func (t *Timer) Stop() bool +``` +返回值: +- `true`:执行`stop()`时`timer`还没有到达超时时间,即超时时间内停止了`timer` +- `false`:执行`stop()`时`timer`到达了超时时间,过了超时时间才停止`timer` + +使用示例: +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + timer := time.NewTimer(2 * time.Second) //设置超时时间2s + res := timer.Stop() + fmt.Println(res) +} +``` +运行结果: +``` +true +``` + +### 重置Timer + +```go +func (t *Timer) Reset(d Duration) bool +``` + +对于已经过期或者是已经停止的`timer`,可以通过重置方法激活使其继续生效。 +使用示例: +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + timer := time.NewTimer(time.Second * 2) + + <-timer.C + fmt.Println("time out1") + + res1 := timer.Stop() + fmt.Printf("res1 is %t\n", res1) + + timer.Reset(time.Second * 3) + + res2 := timer.Stop() + fmt.Printf("res2 is %t\n", res2) +} +``` +运行结果: +``` +time out1 +res1 is false +res2 is true +``` +程序2s之后打印"time out1",此时`timer`已经过期了,所以`res1`的值为`false`,接下来执行`timer.Reset(time.Second * 3)`又使`timer`生效了,并且重设超时时间为3s,但是紧接着执行了`timer.Stop()`,还未到超时时间,所以`res2`的值为`true`。 + +### time.AfterFunc +方法定义: +```go +func AfterFunc(d Duration, f func()) *Timer +``` +`time.AfterFunc`参数为超时时间`d`和一个具体的函数`f`,返回一个`Timer`的指针,作用在创建出`timer`之后,在当前`goroutine`,等待一段时间`d`之后,将执行`f`。 +使用示例: +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + duration := time.Duration(1) * time.Second + + f := func() { + fmt.Println("f has been called after 1s by time.AfterFunc") + } + + timer := time.AfterFunc(duration, f) + defer timer.Stop() + + time.Sleep(2 * time.Second) +} +``` +运行结果: +``` +f has been called after 1s by time.AfterFunc +``` +1s之后打印语句。 + +### time.After +方法定义: +```go +func After(d Duration) <-chan Time { + return NewTimer(d).C +} +``` +根据函数定义可以看到,`after`函数会返回`timer`里的管道,并且这个管道会在经过时段`d`之后写入数据,调用这个函数,就相当于实现了定时器。一般`time.After`会配合`select`一起使用,使用示例如下: + +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + ch := make(chan string) + + go func() { + time.Sleep(time.Second * 3) + ch <- "test" + }() + + select { + case val := <-ch: + fmt.Printf("val is %s\n", val) + case <-time.After(time.Second * 2): + fmt.Println("timeout!!!") + } +} +``` +运行结果: +``` +timeout!!! +``` +程序创建了一个管道`ch`,并且在主`goroutine`用`select`监听两个管道,一个是刚刚创建的`ch`,一个是`time.After`函数返回的管道`c`,`ch`管道3s之后才会有数据写入,而`time.After`函数是2s超时,所以2s后就会有数据写入,这样`select`会先收到管道`c`里的数据,执行`timeout`退出。 + +## Ticker + +### Ticker创建 +方法定义如下: +```go +func NewTicker(d Duration) *Ticker +``` +`NewTicker`用于返回一个`Ticker`对象。 + +### Ticker对象定义 +```go +type Ticker struct { + C <-chan Time // The channel on which the ticks are delivered. + r runtimeTimer +} +``` +`Ticker`对象的字段和`Timer`是一样的,也包含一个通道字段,并会每隔时间段`d`就向该通道发送当时的时间,根据这个管道消息来触发事件,但是`ticker`只要定义完成,就从当前时间开始计时,每隔固定时间都会触发,只有关闭`Ticker`对象才不会继续发送时间消息。 +使用示例: +```go +package main + +import ( + "fmt" + "time" +) + +func Watch() chan struct{} { + ticker := time.NewTicker(1 * time.Second) + + ch := make(chan struct{}) + go func(ticker *time.Ticker) { + defer ticker.Stop() + for { + select { + case <-ticker.C: + fmt.Println("watch!!!") + case <-ch: + fmt.Println("Ticker Stop!!!") + return + } + } + }(ticker) + return ch +} + +func main() { + ch := Watch() + time.Sleep(5 * time.Second) + ch <- struct{}{} + close(ch) +} +``` +运行结果: +``` +watch!!! +watch!!! +watch!!! +watch!!! +watch!!! +Ticker Stop!!! +``` +`Watch`函数里创建一个`ticker`,将它传递到子`goroutine`函数,每隔1s打印"watch!!!",主函数创建一个管道`ch`,通过`ch`来控制`go func()`函数的退出,在5s之后主函数发送一个信号到`ch`,`watch`函数`select`收到`ch`信号,将`return`,在`return`之前将执行`defer ticker.Stop()`语句关闭`ticker`。在这5s之间,`select`将每个1s收到`ticker.C`管道里的消息,打印"watch!!!"。 + +> 注意:调用`ticker.Stop()`只会停止`ticker`,但并不会关闭`ticker.C`这个管道,所以我们需要用这个`channel`来控制`watch`函数中的`goroutine`能够退出。 \ No newline at end of file diff --git a/hzh/GolangStar/Go语言进阶/并发概述.md b/hzh/GolangStar/Go语言进阶/并发概述.md new file mode 100644 index 0000000..fd9d213 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/并发概述.md @@ -0,0 +1,33 @@ +--- +tags: + - Go + - golang + - go基础语法 + - 并发概述 +--- + +# 并发概述 + +## 进程与线程 +谈到并发或者并行一个绕不开的话题就是进程和线程,弄清楚进程与线程的本质是并发编程的前提,那么究竟什么是进程,什么是线程呢? +可以这样理解: +- 进程就是运行着的程序,它是程序在操作系统的一次执行过程,是一个程序的动态概念,进程是操作系统分配资源的基本单位 +- 线程可以理解为一个进程的执行实体,它是比进程粒度更小的执行单元,也是真正运行在cpu上的执行单元,线程是CPU调度资源的基本单位 +进程中可以包含多个线程,需要记住进程和线程一个是操作系统分配资源的基本单位(进程),一个是操作系统调度资源的基本单位(线程) + +## 协程 +协程可以理解为用户态线程,是更微量级的线程。区别于线程,协程的调度在用户态进行,不需要切换到内核态,所以不由操作系统参与,由用户自己控制。在一些支持协程高级语言中,往往这些语言都实现了自己的协程调度器,比如go语言就有自己的协程调度器,这个会在后面专门讲协程调度原理的时候讲。 +- 协程有独立的栈空间,但是共享堆空间。 +- 一个进程上可以跑多个线程,一个线程上可以跑多个协程 + +## 并发与并行 +很多时候大家对于并行和并发的概念还比较模糊,其实只需要根据一点来判断即可,能不能同时运行。两个任务能同时运行就是并行,不能同时运行,而是每个任务执行一小段,交叉执行,这种模式就是并发。 + +![并行](https://golangstar.cn/assets/img/go语言系列/并发概述/并发概述1.png) + +![并发](https://golangstar.cn/assets/img/go语言系列/并发概述/并发概述2.png) + +如图(并行)所示,两个任务一直运行,切实同时运行着,这就是并行模式,要注意并行的话一定要有多个核的支持,因为只有一个cpu的话,同一时间只能跑一个任务,如图(并发)所示,两个任务,每次只执行一小段,这样交叉的执行,就是并发模式,并发模式在单核cpu上是可以完成的 + + + diff --git a/hzh/GolangStar/Go语言进阶/范型.md b/hzh/GolangStar/Go语言进阶/范型.md new file mode 100644 index 0000000..2200ed5 --- /dev/null +++ b/hzh/GolangStar/Go语言进阶/范型.md @@ -0,0 +1,345 @@ +--- +tags: + - Go + - golang + - go进阶 + - 泛型 +--- + +# 泛型 +在Go 1.18版本中,泛型特性被引入,这是Go语言自发布以来最重要的更新之一。 + +## 什么是泛型 +泛型允许开发者在编写代码时不必立即指定具体的数据类型,而是在使用时再确定。换句话说,泛型使得我们可以编写适用于多种数据类型的通用代码。 +泛型是一种编写与具体类型无关的代码的方法,它使得我们可以创建适用于多种类型的函数和数据结构。 + +## 为什么需要泛型 +假设我们需要实现一个函数来计算切片中元素的总和: +```go +func sumInts(numbers []int) int { + total := 0 + for _, num := range numbers { + total += num + } + return total +} + +fmt.Println(sumInts([]int{1, 2, 3, 4})) // 10 +``` +然而,这个函数只能处理`[]int`类型的切片。如果我们想支持`[]float64`类型的切片,就需要再定义一个类似的函数: +```go +func sumFloats(numbers []float64) float64 { + total := 0.0 + for _, num := range numbers { + total += num + } + return total +} +``` +如果我们还需要支持其他类型的切片,就需要再定义相应的函数。这种重复的代码显然是低效的,因为计算总和的逻辑并不依赖于具体的元素类型。 + +在Go 1.18之前,我们可以通过反射来解决这个问题,但反射会降低代码的执行效率,并且失去了编译期的类型检查,同时大量的反射代码也会让程序变得难以理解。 + +类似这样的场景非常适合使用泛型。从Go 1.18开始,我们可以使用泛型来编写一个适用于所有元素类型的通用`sum`函数: + +```go +func sumGeneric[T int | float64](numbers []T) T { + var total T + for _, num := range numbers { + total += num + } + return total +} +``` + +## 泛型语法 +泛型为Go语言引入了三个重要的新特性: + +1. 支持在函数和类型定义中使用类型参数,使其更加通用。 +2. 扩展了接口的概念,使其可以表示一组类型的集合,不再局限于方法集。 +3. 引入智能类型推导机制,在很多场景下可以省略显式的类型参数。 + +### 类型参数 + +#### 类型形参和类型实参 + +函数在使用上需要在函数定义时指定形参,函数调用时需要传入实参。 + +![类型形参和类型实参](https://golangstar.cn/assets/img/go语言系列/范型/范型1.png) + +在引入范型后,Go语言的函数和类型现在可以包含类型参数。类型参数列表的语法类似于普通参数列表,但使用方括号(`[]`)而不是圆括号(`()`)。 + +![类型参数](https://golangstar.cn/assets/img/go语言系列/范型/范型2.png) + +借助泛型,我们可以声明一个适用于**一组类型**的`max`函数。 + +```go +func max[T int | float64](a, b T) T { + if a > b { + return a + } + return b +} +``` + +#### 类型实例化 + +这次定义的`max`函数同时支持`int`和`float64`两种类型,也就是说当调用`max`函数时,我们既可以传入`int`类型的参数。 + +```go +m1 := max[int](3, 5) // 5 +``` + +也可以传入`float64`类型的参数。 + +```go +m2 := max[float64](2.5, 3.5) // 3.5 +``` + +向 `max` 函数提供类型参数(在本例中为`int`和`float64`)称为实例化( _instantiation_ )。 + +类型实例化分两步进行: + +1. 首先,编译器在整个泛型函数或类型中将所有类型形参(type parameters)替换为它们各自的类型实参(type arguments)。 +2. 其次,编译器验证每个类型参数是否满足相应的约束。 + +在成功实例化之后,我们将得到一个非泛型函数,它可以像任何其他函数一样被调用。例如: + +```go +fmax := max[float64] // 类型实例化,编译器生成T=float64的max函数 +m2 = fmax(1.2, 2.3) // 2.3 +``` + +`max[float64]`得到的是类似我们之前定义的`maxFloat64`函数——`fmax`,我们可以在函数调用中使用它。 + +#### 类型参数的使用 + +除了函数中支持使用类型参数列表外,类型也可以使用类型参数列表。 + +```go +type List[T any] []T + +type Pair[K comparable, V any] struct { + Key K + Value V +} + +type Node[T any] struct { + next *Node[T] + value T +} +``` + +在上述泛型类型中,`T`、`K`、`V`都属于类型形参,类型形参后面是类型约束,类型实参需要满足对应的类型约束。 + +泛型类型可以有方法,例如为上面的`Node`实现一个添加元素的`Add`方法。 + +```go +func (n *Node[T]) Add(value T) { + newNode := &Node[T]{value: value} + n.next = newNode +} +``` + +要使用泛型类型,必须进行实例化。`Node[string]`是使用类型实参`string`实例化 `Node` 的示例。 + +```go +var stringNode Node[string] +``` + +#### 类型约束 + +普通函数中的每个参数都有一个类型;该类型定义一系列值的集合。例如,我们上面定义的非泛型函数`maxFloat64`那样,声明了参数的类型为`float64`,那么在函数调用时允许传入的实际参数就必须是可以用`float64`类型表示的浮点数值。 + +类似于参数列表中每个参数都有对应的参数类型,类型参数列表中每个类型参数都有一个**类型约束**。类型约束定义了一个类型集——只有在这个类型集中的类型才能用作类型实参。 + +Go语言中的类型约束是接口类型。 + +就以上面提到的`max`函数为例,我们来看一下类型约束常见的两种方式。 + +类型约束接口可以直接在类型参数列表中使用。 + +```go +// 类型约束字面量,通常外层interface{}可省略 +func max[T interface{ int | float64 }](a, b T) T { + if a > b { + return a + } + return b +} +``` + +作为类型约束使用的接口类型可以事先定义并支持复用。 + +```go +// 事先定义好的类型约束类型 +type Number interface { + int | float64 +} +func max[T Number](a, b T) T { + if a > b { + return a + } + return b +} +``` + +在使用类型约束时,如果省略了外层的`interface{}`会引起歧义,那么就不能省略。例如: + +```go +type PtrSlice[T *int] []T // T*int ? + +type PtrSlice[T *int,] []T // 只有一个类型约束时可以添加`,` +type PtrSlice[T interface{ *int }] []T // 使用interface{}包裹 +``` + +### 类型集 + +**从Go1.18版本开始,接口类型的定义发生了变化,不再仅仅定义方法集(method set),而是定义类型集(type set)。** 这意味着接口类型不仅可以作为值的类型,还可以作为类型约束使用。 + +![类型集](https://golangstar.cn/assets/img/go语言系列/范型/范型3.png) + +将接口类型视为类型集而不是方法集的一个好处是:我们可以显式地向类型集中添加类型,从而以新的方式控制类型集。 + +事实上,Go语言扩展了接口类型的语法,使我们能够在接口中添加类型。例如 + +```go +type Value interface { + int | string | bool +} +``` +上述的代码定义了一个包含 `int`、 `string` 和 `bool` 类型的类型集。 + +![类型集](https://golangstar.cn/assets/img/go语言系列/范型/范型4.png) + +自 Go 1.18 起,接口不仅可以嵌入其他接口,还可以嵌入任意类型、类型的联合或具有相同底层类型的无限类型集合。当接口用作类型约束时,其定义的类型集会精确地指定允许作为相应类型参数的类型。 + +1. `|`符号 +使用`|`运算符可以将多个类型组合成一个类型集合,例如`T1 | T2`表示一个包含类型T1和T2的类型集。下面的`Numeric`接口就定义了一个由`Integer`和`Float`类型组成的类型集。 +```go +type Numeric interface { + Integer | Float +} +``` + +2. `~`符号 +`~T`运算符用于匹配所有以T为底层类型的类型集合。比如`~string`不仅匹配string类型本身,还会匹配所有以string为底层类型的自定义类型。 +```go +type CustomString string // CustomString的底层类型是string +``` +**注意:**`~`符号后面只能是基本类型。 +接口作为类型集合的新机制为Go语言带来了强大的类型约束能力。需要注意的是,目前这种使用新语法定义的接口类型仅限于作为类型约束使用,不能作为普通的接口类型使用。 + +#### any接口 +Go 1.18版本中引入了一个新的预声明标识符`any`,它是空接口类型`interface{}`的别名。这个别名的引入可以在类型参数列表中使用时提供更简洁的语法。 +```go +// src/builtin/builtin.go +type any = interface{} +``` + +由此,我们可以使用如下代码: + +```go +func process[S ~[]E, E any](data S) { + // 处理数据 +} +``` + +### 类型推断 + +类型推断可以让编译器自动推导出类型参数的具体类型,从而简化泛型函数的调用语法。虽然类型推断的实现机制比较复杂,但它极大地提升了泛型代码的使用体验,让开发者可以更自然地编写和调用泛型函数。 + +#### 函数参数类型推断 + +在使用泛型函数时,如果每次都需要显式指定类型参数会比较麻烦。以我们以之前的 `max` 函数为例: +```go +func max[T int | float64](a, b T) T { + if a > b { + return a + } + return b +} +``` +在这个函数中,`T`是一个类型形参,它定义了`a`和`b`参数的类型。调用这个函数时,我们可以明确指定类型实参: +```go +var x, y, result float64 +result = max[float64](x, y) // 显式指定类型实参 +``` + +在大多数情况下,编译器能够根据传入的参数自动推导出类型参数 `T` 的具体类型。这种类型推导机制让我们可以省略显式的类型参数声明,使代码更加简洁优雅。 + +```go +var x, y, result float64 +result = max(x, y) // 无需指定类型实参 +``` +这种类型推断机制被称为函数实参类型推断,它可以根据传入的实参自动推导出函数的类型参数。但需要注意的是,这种推断只对函数参数中使用的类型参数有效,对于那些仅在返回值或函数体内部使用的类型参数则无法推断。比如对于 `CreateT[T any]() T` 这样的函数,由于类型参数 `T` 只用在返回值中,编译器就无法进行类型推断。 + +#### 约束类型推断 + +Go 语言还提供了一种称为_约束类型推断_的机制。为了更好地理解这个概念,让我们通过一个处理整数缩放的示例来说明: + +```go +// MultiplyEach 返回切片中每个元素都乘以factor的副本切片 +func MultiplyEach[E constraints.Integer](s []E, factor E) []E { + result := make([]E, len(s)) + for i, v := range s { + result[i] = v * factor + } + return result +} +``` + +上面的泛型函数可以处理任何整数类型的切片数据。 + +让我们来看一个具体的应用场景。假设我们需要处理一个表示多维坐标的 `Vector` 类型。从本质上说,`Vector` 就是一个存储坐标值的整数切片。为了方便使用,我们为这个类型添加了一些额外的功能,比如一个用于格式化输出的 `String` 方法。 + +```go +type Vector []int32 + +func (v Vector) String() string { + b, _ := json.Marshal(v) + return string(b) +} +``` + +因为`Vector`类型本质上是一个整数切片,所以我们可以尝试直接使用之前定义的`MultiplyEach`函数来处理它: + +```go +func MultiplyAndDisplay(v Vector) { + result := MultiplyEach(v, 3) + fmt.Println(result.String()) // 编译失败 +} +``` + +这段代码在编译时会失败,错误信息为`result.String undefined (type []int32 has no field or method String`。 + +这个问题的根源在于泛型函数`MultiplyEach`的返回值类型。该函数返回一个`[]E`类型的切片,其中`E`是切片元素的类型。当我们传入`Vector`类型(底层是`[]int32`)时,函数返回的是一个普通的`[]int32`切片,而不是我们期望的`Vector`类型。这导致返回值无法调用`Vector`类型特有的`String`方法。 + +要修复这个问题,我们需要修改`MultiplyEach`函数的定义,让它能够保持输入切片的具体类型。我们需要为切片本身引入一个新的类型参数。 + +```go +func MultiplyEach[S ~[]E, E constraints.Integer](s S, factor E) S { + result := make(S, len(s)) + for i, v := range s { + result[i] = v * factor + } + return result +} +``` + +我们添加了一个新的类型参数`S`,它代表切片的具体类型。通过约束`~[]E`,我们指定了`S`必须是一个元素类型为`E`的切片类型。函数返回值类型也改为`S`,这样就能保持输入切片的原始类型。在函数实现中,唯一的变化是使用`S`而不是`[]E`来创建结果切片。 + +这样修改后的`MultiplyEach`函数既可以处理普通的整数切片,也可以处理`Vector`这样的自定义切片类型。 + +这里有一个有趣的问题:为什么我们可以直接调用`MultiplyEach(v, 3)`而不需要显式指定类型参数,即不需要写成`MultiplyEach[Vector, int32](v, 3)`? + +`MultiplyEach`函数定义了两个类型参数:`S`和`E`。当我们调用`MultiplyEach(v, 3)`时,编译器通过函数参数类型推断可以确定`S`的类型是`Vector`。但对于类型参数`E`,由于`3`是一个无类型常量,仅通过参数类型推断无法确定其具体类型(它可能被推断为默认的`int`类型,这与`Vector`的底层类型`[]int32`不匹配)。这时,编译器会使用一种叫做**约束类型推断**的机制。 + +约束类型推断是指编译器根据类型参数的约束关系来推导类型参数。当一个类型参数的约束是基于另一个类型参数定义的,并且其中一个类型参数已知时,就可以通过约束关系推断出另一个类型参数。 + +在我们的例子中,`S`的约束是`~[]E`,这表明`S`必须是一个元素类型为`E`的切片类型。当编译器知道`S`是`Vector`(即`[]int32`)时,就可以通过这个约束关系推断出`E`必须是`int32`。这就是为什么我们可以省略显式的类型参数。 + +## 小结 +泛型的引入为Go语言带来了更强大的抽象能力。当我们在项目中遇到需要为不同类型编写相似逻辑的场景时,泛型可以帮助我们优雅地解决这个问题。它不仅可以减少代码重复,还能保持类型安全。 + +泛型和接口各有其适用场景。接口更适合定义对象的行为规范,而泛型则更适合处理与具体类型无关的通用算法和数据结构。两者结合使用,可以让我们的代码更加简洁、安全和可维护。 \ No newline at end of file diff --git a/hzh/GolangStar/Go面试题库/Channel面试题.md b/hzh/GolangStar/Go面试题库/Channel面试题.md new file mode 100644 index 0000000..27c6e47 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/Channel面试题.md @@ -0,0 +1,243 @@ +--- +tags: + - Go + - golang + - channel + - 面试题 + - channel面试题 +--- + +# Channel面试题 + +## 1. 什么是CSP? + +CSP(Communicating Sequential Processes,通信顺序进程)并发编程模型,它的核心思想是:通过通信共享内存,而不是通过共享内存来通信。Go 语言的Goroutine 和 Channel机制,就是 CSP 的经典实现,具有以下特点: + +1. 避免共享内存:协程(Goroutine)不直接修改变量,而是通过 Channel 通信 + +2. 天然同步:Channel 的发送/接收自带同步机制,无需手动加锁 + +3. 易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制) + +## 2. Channel的底层实现原理是怎样的? + +Channel的底层是一个名为`hchan`的结构体,核心包含几个关键组件: + +**环形缓冲区:**有缓冲channel内部维护一个固定大小的环形队列,用`buf`指针指向缓冲区,`sendx`和`recvx`分别记录发送和接收的位置索引。这样设计能高效利用内存,避免数据搬移。 + +**两个等待队列`sendq和recvq`:**用来管理阻塞的goroutine。`sendq`存储因channel满而阻塞的发送者,`recvq`存储因channel空而阻塞的接收者。这些队列用双向链表实现,当条件满足时会唤醒对应的goroutine。 + +**互斥锁:**`hchan`内部有个mutex,所有的发送、接收操作都需要先获取锁,用来保证并发安全。虽然看起来可能影响性能,但Go的调度器做了优化,大多数情况下锁竞争并不激烈。 + +分析: + +hchan定义如下: + +```go +type hchan struct { + // chan 里元素数量 + qcount uint + // chan 底层循环数组的长度 + dataqsiz uint + // 指向底层循环数组的指针 + // 只针对有缓冲的 channel + buf unsafe.Pointer + // chan 中元素大小 + elemsize uint16 + // chan 是否被关闭的标志 + closed uint32 + // chan 中元素类型 + elemtype *_type // element type + // 已发送元素在循环数组中的索引 + sendx uint // send index + // 已接收元素在循环数组中的索引 + recvx uint // receive index + // 等待接收的 goroutine 队列 + recvq waitq // list of recv waiters + // 等待发送的 goroutine 队列 + sendq waitq // list of send waiters + + // 保护 hchan 中所有字段 + lock mutex +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image.png) + +## 3. 向channel发送数据的过程是怎样的? + +向channel发送数据的整个过程都会在mutex保护下进行,保证并发安全。会经历几个关键步骤: + +1. **首先是检查是否有等待的接收者**。如果`recvq`队列不为空,说明有goroutine在等待接收数据,这时会直接把数据传递给等待的接收者,跳过缓冲区,这是最高效的路径。同时会唤醒对应的goroutine继续执行。 + +2. **如果没有等待接收者,就尝试写入缓冲区**。检查缓冲区是否还有空间,如果`qcount < dataqsiz`,就把数据复制到`buf[sendx]`位置,然后更新`sendx`索引和`qcount`计数。这是无缓冲或缓冲区未满时的正常流径。 + +3. **当缓冲区满了就需要阻塞等待**。创建一个`sudog`结构体包装当前goroutine和要发送的数据,加入到`sendq`等待队列中,然后调用`gopark`让当前goroutine进入阻塞状态,让出CPU给其他goroutine。 + +**被唤醒后继续执行**。当有接收者从channel读取数据后,会从`sendq`中唤醒一个等待的发送者,被唤醒的goroutine会完成数据发送并继续执行。 + +**还有个特殊情况是向已关闭的channel发送数据会直接panic**。这是Go语言的设计原则,防止向已关闭的通道写入数据。 + +**分析:** + +```go +package main + +import ( + "fmt" + "time" +) + +func goroutineA(a <-chan int) { + val := <-a + fmt.Println("goroutine A received data: ", val) + return +} + +func goroutineB(b <-chan int) { + val := <-b + fmt.Println("goroutine B received data: ", val) + return +} + +func main() { + ch := make(chan int) + go goroutineA(ch) + go goroutineB(ch) + ch <- 3 + time.Sleep(time.Second) + + ch1 := make(chan struct{}) +} +``` + +在第 17 行,主协程向 ch 发送了一个元素 3,来看下接下来会发生什么。 + +sender 发现 ch 的 recvq 里有 receiver 在等待着接收,就会出队一个 sudog,把 recvq 里 first 指针的 sudo “推举”出来了,并将其加入到 P 的可运行 goroutine 队列中。然后,sender 把发送元素拷贝到 sudog 的 elem 地址处,最后会调用 goready 将 G1 唤醒,状态变为 runnable。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image-1.png) + +当调度器光顾 G1 时,将 G1 变成 running 状态,执行 goroutineA 接下来的代码。G 表示其他可能有的 goroutine。 + +这里其实涉及到一个协程写另一个协程栈的操作。有两个 receiver 在 channel 的一边虎视眈眈地等着,这时 channel 另一边来了一个 sender 准备向 channel 发送数据,为了高效,用不着通过 channel 的 buf “中转”一次,直接从源地址把数据 copy 到目的地址就可以了,效率高啊! + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image-3.png) + +上图是一个示意图,`3` 会被拷贝到 G1 栈上的某个位置,也就是 val 的地址处,保存在 elem 字段。 + +## 4. 从Channel读取数据的过程是怎样的? + +从channel读取数据也有几个关键步骤: + +1. **首先检查是否有等待的发送者**。如果`sendq`队列不为空,说明有goroutine在等待发送数据。对于无缓冲channel,会直接从发送者那里接收数据;对于有缓冲channel,会先从缓冲区取数据,然后把等待发送者的数据放入缓冲区,这样保持FIFO顺序。 + +2. **如果没有等待发送者,尝试从缓冲区读取**。检查`qcount > 0`,如果缓冲区有数据,就从`buf[recvx]`位置取出数据,然后更新`recvx`索引和`qcount`计数。这是缓冲区有数据时的正常路径。 + +**缓冲区为空时需要阻塞等待**。创建`sudog`结构体包装当前goroutine,加入到`recvq`等待队列,调用`gopark`进入阻塞状态。当有发送者写入数据时会被唤醒继续执行。 + +**从已关闭channel读取有特殊处理**。如果channel已关闭且缓冲区为空,会返回零值和false标志;如果缓冲区还有数据,可以正常读取直到清空。这就是为什么`v, ok := <-ch`中的ok能判断channel状态的原因。 + +## 5. 从一个已关闭Channel仍能读出数据吗? + +从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。 + +**示例:** + +```go +func main() { + ch := make(chan int, 5) + ch <- 18 + close(ch) + x, ok := <-ch + if ok { + fmt.Println("received: ", x) + } + + x, ok = <-ch + if !ok { + fmt.Println("channel closed, data invalid.") + } +} + +``` + +程序输出: + +```go +received: 18 +channel closed, data invalid. +``` + +先创建了一个有缓冲的 channel,向其发送一个元素,然后关闭此 channel。之后两次尝试从 channel 中读取数据,第一次仍然能正常读出值。第二次返回的 ok 为 false,说明 channel 已关闭,且通道里没有数据。 + +## 6. Channel在什么情况下会引起内存泄漏? + +Channel引起内存泄漏最常见的是引起goroutine泄漏从而导致的间接内存泄漏,当goroutine阻塞在channel操作上永远无法退出时,goroutine本身和它引用的所有变量都无法被GC回收。比如一个goroutine在等待接收数据,但发送者已经退出了,这个接收者就会永远阻塞下去。或者**select语句使用不当,**在没有default分支的select中,如果所有case都无法执行,goroutine会永远阻塞。出现内存泄漏 + +## 7. 关闭Channel会产生异常吗? + +试图重复关闭一个channel、,关闭一个nil值的channel、关闭一个只有接收方向的channel都将导致panic异常。 + +## 8. 往一个关闭的Channel写入数据会发生什么? + +往已关闭的channel写入数据会直接panic。 + +向已关闭的channel发送数据时,runtime会检测到channel的`closed`标志位已经设置,立即抛出"send on closed channel"的panic。这个检查发生在发送操作的最开始阶段,甚至在获取mutex锁之前就会进行判断,所以不会有任何数据写入的尝试,直接就panic了。 + +## 9. 什么是select? + +select是Go语言专门为channel操作设计的多路复用控制结构,类似于网络编程中的select系统调用。 + +核心作用是同时监听多个channel操作。当有多个channel都可能有数据收发时,select能够选择其中一个可执行的case进行操作,而不是按顺序逐个尝试。比如同时监听数据输入、超时信号、取消信号等。 + +## 10. select的执行机制是怎样的? + +select的执行机制是随机选择。如果多个case同时满足条件,Go会随机选择一个执行,这避免了饥饿问题。如果没有case能执行就会执行default,如果没有default,当前goroutine会阻塞等待。 + +```go +select { +case data := <-ch1: + // 处理ch1的数据 +case ch2 <- value: + // 向ch2发送数据 +case <-timeout: + // 超时处理 +default: + // 所有channel都不可用时执行 +} +``` + +## 11. select的实现原理是怎样的? + +Go语言实现`select`时,定义了一个数据结构scase表示每个`case`语句(包含`default`)。scase结构包含channel指针、操作类型等信息。select操作的整个过程通过selectgo函数在runtime层面实现。 + +Go运行时会将所有case进行**随机排序**,这是为了避免饥饿问题。然后执行**两轮扫描策略**:**第一轮**直接检查每个channel是否可读写,如果找到就绪的立即执行;如果都没就绪,第二轮就把当前goroutine加入到所有channel的发送或接收队列中,然后调用gopark进入睡眠状态,使**当前goroutine**让出CPU。 + +当某个channel变为可操作时,调度器会唤醒对应的goroutine,此时需要从其他channel的等待队列中清理掉这个goroutine,然后执行对应的case分支。 + +其核心原理是:case随机化 + 双重循环检测 + +**分析:** + +scase结构定义: + +```go +type scase struct { + c *hchan // channel指针 + elem unsafe.Pointer // 数据元素指针,用于存放发送/接收的数据 + kind uint16 // case类型:caseNil、caseRecv、caseSend、caseDefault + pc uintptr // 程序计数器,用于调试 + releasetime int64 // 释放时间,用于竞态检测 +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image-2.png) + +在默认的情况下,select 语句会在编译阶段经过如下过程的处理: + +1. 将所有的 `case` 转换成包含` Channel `以及类型等信息的 scase 结构体; + +2. 调用运行时函数 `selectgo `获取被选择的`scase` 结构体索引,如果当前的` scase `是一个接收数据的操作,还会返回一个指示当前`case` 是否是接收的布尔值; + +3. 通过` for `循环生成一组` if `语句,在语句中判断自己是不是被选中的 `case`。 + diff --git a/hzh/GolangStar/Go面试题库/Context面试题.md b/hzh/GolangStar/Go面试题库/Context面试题.md new file mode 100644 index 0000000..2ecc2b5 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/Context面试题.md @@ -0,0 +1,68 @@ +--- +tags: + - Go + - golang + - context + - 面试题 + - context面试题 +--- + +# Context面试题 + +## 1. Go语言里的Context是什么? + +go语言里的context实际上是一个接口,提供了Deadline(),Done(),Err()以及Value()四种方法。它在Go 1.7 标准库被引入。 + +它本质上是一个**信号传递和范围控制的工具**。它的核心作用是在一个请求处理链路中(跨越多个函数和goroutine),优雅地传递**取消信号(cancellation)、超时(timeout)和截止日期(deadline)**,并能携带一些范围内的键值对数据。 + +**分析** + +```go +type Context interface { + Deadline() (deadline time.Time, ok bool) // Deadline方法的第一个返回值表示还有多久到 期, 第二个返回值代表是否被超时时间控制 + Done() <-chan struct{} // Done() 返回一个 只读channel,当这个channel被关闭时,说明这个 context被取消 + Err() error // Err() 返回一个错误,表示channel被关闭的原因,例如是被取消,还是超时关闭 + Value(key interface{}) interface{}) // value方法返回指定key对应的value,这是context携带 的值 +} +``` + +这个接口定义了四个核心方法,它们共同构成了一套关于**截止时间、取消信号和请求范围值**的协定: + +* `Deadline()` - 返回一个时间点,告知任务何时应该被取消。 + +* `Done()` - 返回一个channel,当`Context`被取消或超时,这个channel会被关闭。这是goroutine监听取消信号的核心。 + +* `Err()` - 在`Done()`的channel关闭后,它会解释关闭的原因,是主动取消(`Canceled`)还是超时(`DeadlineExceeded`)。 + +* `Value()` - 允许`Context`在调用链中携带请求范围的键值对数据。 + +## 2. Go语言的Context有什么作用? + +Go的Context主要解决三个核心问题:**超时控制、取消信号传播和请求级数据传递** + +在实际项目中,我们最常用的是超时控制。比如一个HTTP请求需要调用多个下游服务,我们通过`context.WithTimeout`设置整体超时时间,当超时发生时,所有子操作都会收到取消信号并立即退出,避免资源浪费。取消信号的传播是通过Context的层级结构实现的,父Context取消时,所有子Context都会自动取消。 + +另外Context还能传递请求级的元数据,比如用户ID、请求ID等,这在分布式链路追踪中特别有用。需要注意的是,Context应该作为函数的第一个参数传递,不要存储在结构体中,并且传递的数据应该是请求级别的,不要滥用。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Context面试题/image-1.png) + +## 3. Context.Value的查找过程是怎样的 + +Context.Value的查找过程是一个**链式递归查找的过程**,从当前Context开始,沿着父Context链一直向上查找直到找到对应的key或者到达根Context。 + +具体流程是:当调用`ctx.Value(key)`时,首先检查当前Context是否包含这个key,如果当前层没有,就会调用`parent.Value(key)`继续向上查找。这个过程会一直递归下去,直到找到匹配的key返回对应的value,或者查找到根Context返回nil。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Context面试题/image.png) + +## 4. Context如何被取消 + +Context的取消是通过**channel关闭信号**实现的,主要有三种取消方式。 + +首先是**主动取消**,通过`context.WithCancel`创建的Context会返回一个cancel函数,调用这个函数就会关闭内部的done channel,所有监听这个Context的goroutine都能通过`ctx.Done()`收到取消信号。 + +其次是**超时取消**,`context.WithTimeout`和`context.WithDeadline`会启动一个定时器,到达指定时间后自动调用cancel函数触发取消。 + +最后是**级联取消**,当父Context被取消时,所有子Context会自动被取消,这是通过Context树的结构实现的。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Context面试题/image-2.png) + diff --git a/hzh/GolangStar/Go面试题库/GMP面试题.md b/hzh/GolangStar/Go面试题库/GMP面试题.md new file mode 100644 index 0000000..66db8cd --- /dev/null +++ b/hzh/GolangStar/Go面试题库/GMP面试题.md @@ -0,0 +1,118 @@ +--- +tags: + - Go + - golang + - GMP + - 面试题 + - GMP面试题 +--- + +# GMP面试题 + +## 1. Go语言的GMP模型是什么? + +GMP是Go运行时的核心调度模型 + +**GMP含义**:G是goroutine协程;M是machine系统线程,真正干活的;P是processor,逻辑处理器,它是G和M之间的桥梁。它负责调度G + +调度逻辑是这样的,M必须绑定P才能执行G。每个P维护一个自己的本地G队列(长度256),M从P的本地队列取G执行。当本地队列空时,M会按优先级从**全局队列、网络轮询器、其他P队列**中窃取goroutine,这是work-stealing机制。 + +就是这个模型让Go能在少量线程上调度海量goroutine,是Go高并发的基础。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/GMP面试题/image-1.png) + +## 2. 什么是Go scheduler + +Go scheduler就是Go运行时的**协程调度器**,负责在系统线程上调度执行goroutine。它 是 Go runtime 的一部分,它内嵌在 Go 程序里,和 Go 程序一起运行。它的主要工作是决定哪个goroutine在哪个线程上运行,以及何时进行上下文切换。scheduler的核心是`schedule()`函数,它在无限循环中寻找可运行的goroutine。当找到后通过`execute()`函数切换到goroutine执行,goroutine主动让出或被抢占时再回到调度循环。 + +## 3. Go语言在进行goroutine调度的时候,调度策略是怎样的? + +Go语言采用的是抢占式调度策略。Go 会启动一个线程,一直运行着“sysmon”函数,sysmon 运行在 M上,且不需要P。当 sysmon 发现 M 已运行同一个 G(Goroutine)10ms 以上时,它会将该 G 的内部参数 `preempt` 设置为 true,表示需要被抢占,让出CPU了。只是在Go 1.14之前和Go 1.14之后有所不同 + +**Go 1.14之前**:调度策略是“协作式”抢占调度,这种调度方式主要是通过函数调用来实现的,在编译期,编译器会在几乎所有的函数调用的入口处,插入一小段检查代码。这段代码会检查当前goroutine是否已经被标记为需要被抢占。如果是,当 G 进行函数调用时,G 会检查自己的 `preempt` 标志,如果它为 true,则它将自己与 M 分离并推入goroutine的全局队列,抢占完成。但这种模式有个明显的缺陷:如果一个goroutine执行了一个不包含任何函数调用的**超大循环**,那么调度器的“抢占”标记就永远得不到检查,这个goroutine就会一直霸占着M,导致同一个P队列里的其他G全都没机会执行,造成**调度延迟**。 + +**Go 1.14之后**:调度策略**基于信号的异步抢占**机制,sysmon 会检测到运行了 10ms 以上的 G(goroutine)。然后,sysmon 向运行 G 的 M发送信号(SIGURG)。Go 的信号处理程序会调用M上的一个叫作 gsignal 的 goroutine 来处理该信号,并使其检查该信号。gsignal 看到抢占信号,停止正在运行的 G。 + +## 4. 发生调度的时机有哪些? + +* 等待读取或写入未缓冲的通道 + +* 由于 time.Sleep() 而等待 + +* 等待互斥量释放 + +* 发生系统调用 + +## 5. M寻找可运行G的过程是怎样的? + +**M会优先检查本地队列(LRQ)**:从当前P的LRQ里`runqget`一个G。(无锁CAS),如果本地队列没有可运行G,**再次检查全局队列(GRQ)**去全局队列里`globrunqget`找。(需要加锁);如果还没有,就**检查网络轮询器(netpoll),**就去`netpoll`里看看有没有因为网络IO就绪的G。(非阻塞模式),依然没有获取到可运行G,则会**从别的P偷(steal work),这个偷的过程是**随机找一个别的P,从它的LRQ里偷一半的G过来。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/GMP面试题/image-2.png) + +## 6. GMP能不能去掉P层?会怎么样? + +GMP中的P层理论上可以去掉,但会带来严重的性能问题。 + +**掉P的后果**:如果直接变成GM模型,所有M都需要从**全局队列**中获取goroutine,这就需要全局锁保护。在高并发场景下,大量M争抢同一把锁会造成严重的**锁竞争**,CPU大部分时间都浪费在等锁上,调度效率急剧下降。 + +**P层的价值**:P的存在实现了**无锁的本地调度**。每个P维护独立的本地队列,M绑定P后可以直接从本地队列取G执行,大部分情况下都不需要全局锁。只有本地队列空了才去偷取,这大大减少了锁竞争。 + +## 7. P和M在什么时候会被创建? + +**P的创建时机**:P在调度器初始化时**一次性创建**。在`schedinit()`函数中会调用`procresize()`,根据`GOMAXPROCS`值创建对应数量的P对象,存储在全局的`allp`数组中。之后P的数量基本固定,只有在调用`runtime.GOMAXPROCS()`动态调整时才会重新分配P。 + +**M的创建时机**:M采用**按需创建**策略。初始只有m0存在,当出现以下情况时会创建新的M: + +* 所有现有M都在执行阻塞的系统调用,但还有可运行的goroutine需要执行 + +* 通过`startm()`函数发现没有空闲M可以绑定P执行goroutine + +* M的数量受`GOMAXTHREADS`限制,默认10000个 + +**创建流程**:新M通过`newm()`函数创建,它会调用`newosproc()`创建新的系统线程,并为这个M分配独立的g0。创建完成后,新M会进入`mstart()`开始调度循环。 + +## 8. m0是什么,有什么用 + +m0是在Go启动时创建的第一个M,m0对应程序启动时的主系统线程,它在Go程序的整个生命周期中都存在。与其他通过`runtime.newm()`动态创建的M不同,m0是在程序初始化阶段静态分配的,有专门的全局变量存储。 + +m0主要负责执行Go程序的**启动流程**,包括调度器初始化、内存管理器初始化、垃圾回收器设置等。它会创建并运行第一个用户goroutine来执行`main.main`函数。在程序运行期间,m0也参与正常的goroutine调度,和其他M没有本质区别。m0在程序退出时还负责处理清理工作,比如等待其他goroutine结束、执行defer函数等。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/GMP面试题/image.png) + +## 9. g0是一个怎样的协程,有什么用? + +g0是一个特殊的goroutine,不是普通的用户协程,而是**调度协程**,每个M都有自己的g0。它使用系统线程的原始栈空间,而不是像普通goroutine那样使用可增长的分段栈。g0的栈大小通常是8KB,比普通goroutine的2KB初始栈要大。 + +**核心作用**:g0专门负责**执行调度逻辑**,包括goroutine的创建、销毁、调度决策等。当M需要进行调度时,会从当前运行的用户goroutine切换到g0上执行`schedule()`函数。g0还负责处理垃圾回收、栈扫描、信号处理等运行时操作。 + +**运行机制**:正常情况下M在用户goroutine上运行用户代码,当发生调度事件时(如goroutine阻塞、抢占、系统调用返回等),M会切换到g0执行调度器代码,选出下一个要运行的goroutine后再切换过去。 + +**为什么需要g0**:因为调度器代码不能在普通goroutine的栈上执行,那样会有栈空间冲突和递归调度的问题。g0提供了一个独立的执行环境,确保调度器能安全稳定地工作。 + +## 10. g0栈和用户栈是如何进行切换的? + +g0和用户goroutine之间的栈切换,本质是**SP寄存器和栈指针的切换。**当用户goroutine需要调度时,通过`mcall()`函数切换到g0。这个过程会保存当前用户goroutine的PC、SP等寄存器到其gobuf中,然后将SP指向g0的栈,PC指向传入的调度函数。调度完成后,通过`gogo()`函数从g0切换回用户goroutine,恢复其保存的寄存器状态。 + +切换逻辑在汇编文件中实现,比如`runtime·mcall`和`runtime·gogo`。这些函数直接操作CPU寄存器,确保切换的原子性和高效性。切换过程中会更新g.sched字段记录goroutine状态。 + +**分析:** + +goroutine的结构如下: + +```go +structG +{ + uintptr stackguard; // 分段栈的可用空间下界 + uintptr stackbase; // 分段栈的栈基址 + Gobuf sched; //协程切换时,利用sched域来保存上下文 + uintptr stack0; + FuncVal* fnstart; // goroutine运行的函数void* param; // 用于传递参数,睡眠时其它goroutine设置param,唤醒时此goroutine可以获取 + int16 status; // 状态 Gidle,Grunnable,Grunning,Gsyscall,Gwaiting,Gdead + int64 goid; // goroutine的id号 + G* schedlink; + M* m; // for debuggers, but offset not hard-coded + M* lockedm; // G被锁定只能在这个m上运行 + uintptr gopc; // 创建这个goroutine的go表达式的pc... + }; +``` + diff --git a/hzh/GolangStar/Go面试题库/Interface面试题.md b/hzh/GolangStar/Go面试题库/Interface面试题.md new file mode 100644 index 0000000..ae11802 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/Interface面试题.md @@ -0,0 +1,146 @@ +--- +tags: + - Go + - golang + - interface + - 面试题 + - interface面试题 +--- + +# Interface面试题 + +## 1. Go语言中,interface的底层原理是怎样的? + +Go的interface底层有两种数据结构:**eface和iface**。 + +**eface是空interface{}的实现**,只包含两个指针:`_type`指向类型信息,`data`指向实际数据。这就是为什么空接口能存储任意类型值的原因,通过类型指针来标识具体类型,通过数据指针来访问实际值。 + +**iface是带方法的interface实现**,包含`itab`和`data`两部分。`itab`是核心,它存储了接口类型、具体类型,以及方法表。方法表是个函数指针数组,保存了该类型实现的所有接口方法的地址。 + +**分析:** + +eface定义: + +```go +type eface struct { + _type *_type + data unsafe.Pointer +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Interface面试题/eface修改.png) + + + +iface定义: + +```go +type iface struct { + tab *itab + data unsafe.Pointer +} +``` + +其中itab的结构定义如下: + +```go +type itab struct { + inter *interfacetype + _type *_type + hash uint32 // copy of _type.hash. Used for type switches. + _ [4]byte + fun [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter. +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Interface面试题/iface修改.png) + +## 2. iface和eface的区别是什么? + +iface和eface的核心区别在于是否包含方法信息。 + +eface是空接口interface{}的底层实现,结构非常简单,只有两个字段:`_type`指向类型信息,`data`指向实际数据。因为空接口没有方法约束,所以不需要存储方法相关信息。 + +iface是非空接口的底层实现,结构相对复杂,包含`itab`和`data`。关键是这个`itab`,它不仅包含类型信息,还包含了一个方法表,存储着该类型实现的所有接口方法的函数指针。 + +## 3. 类型转换和断言的区别是什么? + +`类型转换`、`类型断言`本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。对于**类型转换**而言,类型转换是在编译期确定的强制转换,转换前后的两个类型要相互兼容才行,语法是`T(value)`。而**类型断言**是运行期的动态检查,专门用于从接口类型中提取具体类型,语法是`value.(T)` + +**安全性差别很大**:类型转换在编译期保证安全性,而类型断言可能在运行时失败。所以实际开发中更常用安全版本的类型断言`value, ok := x.(string)`,通过ok判断是否成功。 + +**使用场景不同**:类型转换主要解决数值类型、字符串、切片等之间的转换问题;类型断言主要用于接口编程,当你拿到一个interface{}需要还原成具体类型时使用。 + +**底层实现也不同**:类型转换通常是简单的内存重新解释或者数据格式调整;类型断言需要检查接口的底层类型信息,涉及到runtime的类型系统。 + +## 4. Go语言interface有哪些应用场景 + +Go语言的interface主要有几个核心应用场景: + +1. **依赖注入和解耦**。通过定义接口抽象,让高层模块不依赖具体实现,比如定义一个`UserRepo`接口,具体可以是MySQL、Redis或者Mock实现。这样代码更容易测试和维护,也符合SOLID原则。 + +2. **多态实现**。比如定义一个`Shape`接口包含`Area()`方法,不同的图形结构体实现这个接口,就能用统一的方式处理各种图形。这让代码更加灵活和可扩展。 + +3. **标准库中大量使用interface来提供统一API**。像`io.Reader`、`io.Writer`让文件、网络连接、字符串等都能用统一的方式操作;`sort.Interface`让任意类型都能使用标准库的排序算法。 + +4. **还有类型断言和反射的配合使用**,比如JSON解析、ORM映射等场景,先用`interface{}`接收任意类型,再通过类型断言或反射处理具体逻辑。 + +5. **插件化架构也heavily依赖interface**。比如Web框架的中间件、数据库驱动、日志组件等,都通过接口定义规范,让第三方能够轻松扩展功能。 + +## 5. 接口之间可以相互比较吗? + +1. 接口值之间可以使用 `==`和 `!=`来进行比较。两个接口值相等仅当它们都是nil值,或者它们的动态类型相同并且动态值也根据这个动态类型的==操作相等。如果两个接口值的动态类型相同,但是这个动态类型是不可比较的(比如切片),将它们进行比较就会失败并且panic。 + +2. 接口值在与非接口值比较时,Go会先将非接口值尝试转换为接口值,再比较。 + +3. 接口值很特别,其它类型要么是可比较类型(如基本类型和指针)要么是不可比较类型(如切片,映射类型,和函数),但是接口值视具体的类型和值,可能会报出潜在的panic。 + +**分析:** + +接口类型和 `nil` 作比较 + +接口值的零值是指`动态类型`和`动态值`都为 `nil`。当仅且当这两部分的值都为 `nil` 的情况下,这个接口值就才会被认为 `接口值 == nil`。 + +```go +package main + +import "fmt" + +type Coder interface { + code() +} + +type Gopher struct { + name string +} + +func (g Gopher) code() { + fmt.Printf("%s is coding\n", g.name) +} + +func main() { + var c Coder + fmt.Println(c == nil) + fmt.Printf("c: %T, %v\n", c, c) + + var g *Gopher + fmt.Println(g == nil) + + c = g + fmt.Println(c == nil) + fmt.Printf("c: %T, %v\n", c, c) +} + +``` + +程序输出: + +```go +true +c: , +true +false +c: *main.Gopher, +``` + +一开始,`c` 的 动态类型和动态值都为 `nil`,`g` 也为 `nil`,当把 `g` 赋值给 `c` 后,`c` 的动态类型变成了 `*main.Gopher`,仅管 `c` 的动态值仍为 `nil`,但是当 `c` 和 `nil` 作比较的时候,结果就是 `false` 了。 diff --git a/hzh/GolangStar/Go面试题库/Map面试题.md b/hzh/GolangStar/Go面试题库/Map面试题.md new file mode 100644 index 0000000..32eae3d --- /dev/null +++ b/hzh/GolangStar/Go面试题库/Map面试题.md @@ -0,0 +1,164 @@ +--- +tags: + - Go + - golang + - map + - 面试题 + - map面试题 +--- + +# Map面试题 + +## 1. Go语言Map的底层实现原理是怎样的? + +map的就是一个hmap的结构。Go Map的底层实现是一个**哈希表**。它在运行时表现为一个指向`hmap`结构体的指针,`hmap`中记录了**桶数组指针`buckets`**、**溢出桶指针**以及**元素个数**等字段。每个桶是一个`bmap`结构体,能存储**8个键值对**和**8个`tophash`**,并有指向下一个**溢出桶的指针`overflow`**。为了**内存紧凑**,`bmap`中采用的是先存8个键再存8个值的存储方式。 + +**分析:** +hmap结构定义: +```go +// A header for a Go map. +type hmap struct { + count int // map中元素个数 + flags uint8 // 状态标志位,标记map的一些状态 + B uint8 // 桶数以2为底的对数,即B=log_2(len(buckets)),比如B=3,那么桶数为2^3=8 + noverflow uint16 //溢出桶数量近似值 + hash0 uint32 // 哈希种子 + + buckets unsafe.Pointer // 指向buckets数组的指针 + oldbuckets unsafe.Pointer // 是一个指向buckets数组的指针,在扩容时,oldbuckets 指向老的buckets数组(大小为新buckets数组的一半),非扩容时,oldbuckets 为空 + nevacuate uintptr // 表示扩容进度的一个计数器,小于该值的桶已经完成迁移 + + extra *mapextra // 指向mapextra 结构的指针,mapextra 存储map中的溢出桶 +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Map面试题/image-2.png) + +bmap结构如下: + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Map面试题/image-1.png) + +## 2. Go语言Map的遍历是有序的还是无序的? + +Go语言里Map的遍历是**完全随机**的,并没有固定的顺序。map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。 + +## 3. Go语言Map的遍历为什么要设计成无序的? + +map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。 + +Go团队为了避免开发者写出依赖底层实现细节的脆弱代码,而**有意为之**的一个设计。通过在遍历时引入随机数,Go从根本上杜绝了程序员依赖特定遍历顺序的可能性,强制我们写出更健壮的代码。 + +## 4. Map如何实现顺序读取? + +如果业务上确实需要有序遍历,最规范的做法就是将Map的键(Key)取出来放入一个切片(Slice)中,用`sort`包对切片进行排序,然后根据这个有序的切片去遍历Map。 + +```go +package main + +import ( + "fmt" + "sort" +) + +func main() { + keyList := make([]int, 0) + m := map[int]int{ + 3: 200, + 4: 200, + 1: 100, + 8: 800, + 5: 500, + 2: 200, + } + for key := range m { + keyList = append(keyList, key) + } + sort.Ints(keyList) + for _, key := range keyList { + fmt.Println(key, m[key]) + } +} +``` + +## 5. Go语言的Map是否是并发安全的? + +map 不是线程安全的。 + +在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于1),则直接 panic。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。 + +检测写标志: + +```go +if h.flags&hashWriting == 0 { + throw("concurrent map writes") + } +``` + +设置写标志: + +```go +h.flags |= hashWriting +``` + +## 6. Map的Key一定要是可比较的吗?为什么? + +Map的Key必须要可比较。 + +首先,Map会对我们提供的Key进行哈希运算,得到一个哈希值。这个哈希值决定了这个键值对大概存储在哪个位置(也就是哪个“桶”里)。然而,不同的Key可能会产生相同的哈希值,这就是“哈希冲突”。当多个Key被定位到同一个“桶”里时,Map就没法只靠哈希值来区分它们了。此时,它必须在桶内进行逐个遍历,用我们传入的Key和桶里已有的每一个Key进行\*\*相等(==)\*\*比较。这样才能确保我们操作的是正确的键值对。 + +## 7. Go语言Map的扩容时机是怎样的? + +向 map 插入新 key 的时候,会进行条件检测,符合下面这 2 个条件,就会触发扩容 + +1. 装载因子超过阈值,源码里定义的阈值是 6.5,这个时候会触发双倍扩容 + +2. overflow 的 bucket 数量过多: + + 1. 当 B 小于 15,也就是 bucket 总数 2^B 小于 2^15 时,如果 overflow 的 bucket 数量超过 2^B; + + 2. 当 B >= 15,也就是 bucket 总数 2^B 大于等于 2^15,如果 overflow 的 bucket 数量超过 2^15 + +这两种情况下会触发双倍扩容 + +## 8. Go语言Map的扩容过程是怎样的? + +Go的扩容是**渐进式(gradual**)的。它不会在触发扩容时“stop the world”来一次性把所有数据搬迁到新空间,而是只分配新空间,然后在后续的每一次插入、修改或删除操作时,才会顺便搬迁一两个旧桶的数据。这种设计将庞大的扩容成本分摊到了多次操作中,极大地减少了服务的瞬间延迟(STW),保证了性能的平滑性。 + +如果是触发双倍扩容,会新建一个buckets数组,新的buckets数量大小是原来的2倍,然后旧buckets数据搬迁到新的buckets。如果是等量扩容,buckets数量维持不变,重新做一遍类似双倍扩容的搬迁动作,把松散的键值对重新排列一次,使得同一个 bucket 中的 key 排列地更紧密,这样节省空间,存取效率更高 + +## 9. 可以对Map的元素取地址吗? + +无法对 map 的 key 或 value 进行取址。会发生编译报错,这样设计主要是因为map一旦发生扩容,key 和 value 的位置就会改变,之前保存的地址也就失效了。 + +示例: + +```go +package main + +import "fmt" + +func main() { + m := make(map[string]int) + + fmt.Println(&m["qcrao"]) +} +``` + +会出现编译报错: + +```go +./main.go:8:14: cannot take the address of m["qcrao"] +``` + +## 10. Map 中删除一个 key,它的内存会释放么? + +不会,`delete`一个key,并不会立刻释放或收缩Map占用的内存。具体来说,`delete(m, key)` 这个操作,只是把key和value对应的内存块标记为“空闲”,让它们的内容可以被后续的垃圾回收(GC)处理掉。但是,Map底层为了存储这些键值对而分配的“桶”(buckets)数组,它的规模是不会缩小的。只有在置空这个map的时候,整个map的空间才会被垃圾回后释放 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Map面试题/image.png) + +## 11. Map可以边遍历边删除吗 + +map 并不是一个线程安全的数据结构。如果多个线程边遍历,边删除,同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。 + +如果是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。这种情况下,可以通过加读写锁sync.RWMutex来保证 + diff --git a/hzh/GolangStar/Go面试题库/Slice面试题.md b/hzh/GolangStar/Go面试题库/Slice面试题.md new file mode 100644 index 0000000..e7374e4 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/Slice面试题.md @@ -0,0 +1,195 @@ +--- +tags: + - Go + - golang + - 切片 + - 面试题 + - slice面试题 +--- + +# Slice面试题 + +## 1. slice的底层结构是怎样的? + +slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。 + + + +```go +// runtime/slice.go +type slice struct { + array unsafe.Pointer // 元素指针 + len int // 长度 + cap int // 容量 +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Slice面试题/image.png) + +## 2. Go语言里slice是怎么扩容的? + +1.17及以前 + +1. 如果期望容量大于当前容量的两倍就会使用期望容量; + +2. 如果当前切片的长度小于 1024 就会将容量翻倍; + +3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量; + +Go1.18及以后,引入了新的扩容规则: + +当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3\*256)/4 + +## 3. 从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗 + +在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。 + +示例: + +```go +package main + +import "fmt"func main() { + slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} + s1 := slice[2:5] + s2 := s1[2:6:7] + + s2 = append(s2, 100) + s2 = append(s2, 200) + + s1[2] = 20 + + fmt.Println(s1) + fmt.Println(s2) + fmt.Println(slice) +} +``` + +运行结果: + +```shell +[2 3 20] +[4 5 6 7 100 200] +[0 1 2 3 20 5 6 7 100 9] +``` + +`s1` 从 `slice` 索引2(闭区间)到索引5(开区间,元素真正取到索引4),长度为3,容量默认到数组结尾,为8。 `s2` 从 `s1` 的索引2(闭区间)到索引6(开区间,元素真正取到索引5),容量到索引7(开区间,真正到索引6),为5。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Slice面试题/image-1.png) + +接着,向 `s2` 尾部追加一个元素 100: + +```go +s2 = append(s2, 100) +``` + +`s2` 容量刚好够,直接追加。不过,这会修改原始数组对应位置的元素。这一改动,数组和 `s1` 都可以看得到。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Slice面试题/image-2.png) + +再次向 `s2` 追加元素200 + +```go +s2 = append(s2, 200) +``` + +这时,`s2` 的容量不够用,该扩容了。于是,`s2` 另起炉灶,将原来的元素复制新的位置,扩大自己的容量。并且为了应对未来可能的 `append` 带来的再一次扩容,`s2` 会在此次扩容的时候多留一些 `buffer`,将新的容量将扩大为原始容量的2倍,也就是10了。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Slice面试题/image-3.png) + +最后,修改 `s1` 索引为2位置的元素: + +```go +s1[2] = 20 +``` + +这次只会影响原始数组相应位置的元素。它影响不到 `s2` 了,人家已经远走高飞了。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Slice面试题/image-4.png) + +再提一点,打印 `s1` 的时候,只会打印出 `s1` 长度以内的元素。所以,只会打印出3个元素,虽然它的底层数组不止3个元素。 + +## 4. slice作为函数参数传递,会改变原slice吗? + +当 slice 作为函数参数时,因为会拷贝一份新的slice作为实参,所以原来的 slice 结构并不会被函数中的操作改变,也就是说,slice 其实是一个结构体,包含了三个成员:len, cap, array并不会变化。但是需要注意的是,尽管slice结构不会变,但是其底层数组的数据如果有修改的话,则会发生变化。若传的是 slice 的指针,则原 slice 结构会变,底层数组的数据也会变。 + +示例: + +```go +package main + +func main() { + s := []int{1, 1, 1} + f(s) + fmt.Println(s) +} + +func f(s []int) { + // i只是一个副本,不能改变s中元素的值 + /*for _, i := range s { + i++ + } + */ + + for i := range s { + s[i] += 1 + } +} + +``` + +程序输出: + +```go +[2 2 2] +``` + +果真改变了原始 slice 的底层数据。这里传递的是一个 slice 的副本,在 `f` 函数中,`s` 只是 `main` 函数中 `s` 的一个拷贝。在`f` 函数内部,对 `s` 的作用并不会改变外层 `main` 函数的 `s`的结构。 + +要想真的改变外层 `slice`,只有将返回的新的 slice 赋值到原始 slice,或者向函数传递一个指向 slice 的指针。我们再来看一个例子: + +```go +package main + +import "fmt" + +func myAppend(s []int) []int { + // 这里 s 虽然改变了,但并不会影响外层函数的 s + s = append(s, 100) + return s +} + +func myAppendPtr(s *[]int) { + // 会改变外层 s 本身 + *s = append(*s, 100) + return +} + +func main() { + s := []int{1, 1, 1} + newS := myAppend(s) + + fmt.Println(s) + fmt.Println(newS) + + s = newS + + myAppendPtr(&s) + fmt.Println(s) +} + +``` + +程序输出 + +```go +[1 1 1] +[1 1 1 100] +[1 1 1 100 100] +``` + +`myAppend` 函数里,虽然改变了 `s`,但它只是一个值传递,并不会影响外层的 `s`,因此第一行打印出来的结果仍然是 `[1 1 1]`。 + +而 `newS` 是一个新的 `slice`,它是基于 `s` 得到的。因此它打印的是追加了一个 `100` 之后的结果: `[1 1 1 100]`。 + +最后,将 `newS` 赋值给了 `s`,`s` 这时才真正变成了一个新的slice。之后,再给 `myAppendPtr` 函数传入一个 `s 指针`,这回它真的被改变了:`[1 1 1 100 100]` diff --git a/hzh/GolangStar/Go面试题库/Sync面试题.md b/hzh/GolangStar/Go面试题库/Sync面试题.md new file mode 100644 index 0000000..3206eae --- /dev/null +++ b/hzh/GolangStar/Go面试题库/Sync面试题.md @@ -0,0 +1,219 @@ +--- +tags: + - Go + - golang + - sync + - 面试题 + - sync面试题 +--- + +# Sync面试题 +## 1. 除了 mutex 以外还有那些方式安全读写共享变量? + +除了Mutex,主要还有**信号量**、**通道(Channel),原子操作(atomic)**这几种方式。 + +信号量的实现其实跟mutex差不多,实现起来也很方便,主要通过信号量计数来保证。chanenl是Go最推崇的方式,它通过通信来传递数据所有权,从根源上避免竞争,更适合复杂的业务逻辑;而原子操作则针对最简单的整型或指针等进行无锁操作,性能最高,常用于实现计数器或状态位。选择哪种,完全取决于数据结构的复杂度和业务的读写模型。 + +## 2. Go 语言是如何实现原子操作的? + +Go语言实现原子操作,其根本是**依赖底层CPU硬件提供的原子指令**,而不是通过操作系统或更上层的锁机制。 + +具体来说,Go的`sync/atomic`包中的函数,在编译时会被编译器识别,并直接转换成对应目标硬件平台(如x86、ARM)的单条原子机器指令。例如,在x86架构上,`atomic.AddInt64`这类操作会对应到像`LOCK; ADD`这样的指令。前面的`LOCK`前缀是关键,它会锁住总线或缓存行,确保后续的`ADD`指令在执行期间,其他CPU核心不能访问这块内存,从而保证了整个操作的原子性。 + +## 3. **聊聊原子操作和锁的区别?** + +原子操作和锁最核心的区别在于它们的**实现层级**和**保护范围**。 + +**原子操作**是CPU硬件层面的“微观”机制,它保证对单个数据(通常是整型或指针)的单次读改写操作是绝对不可分割的,性能极高,因为它不涉及操作系统内核的介入和goroutine的挂起。 + +**锁**则是操作系统或语言运行时提供的“宏观”机制,它保护的是一个**代码块**(临界区),而不仅仅是单个变量。当获取锁失败时,它会让goroutine休眠,而不是空耗CPU。虽然锁的开销远大于原子操作,但它能保护一段复杂的、涉及多个变量的业务逻辑。 + +所以,对于简单的计数器或标志位更新,用原子操作追求极致性能;而只要需要保护一段逻辑或多个变量的一致性,就必须用锁。 + +## 4. Go语言互斥锁mutex底层是怎么实现的? + +mutex底层是通过原子操作加信号量来实现的,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒 + +**分析** + +互斥锁对应的是底层结构是sync.Mutex结构体 + +```go +type Mutex struct { + state int32 + sema uint32 + } +``` + +state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Sync面试题/0c8666c9f2a2a24045c7eb16c163a8e9.png) + +sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Sync面试题/cacc64449f25f934740cbdf201affbf3.png) + +## 5. Mutex 有几种模式? + +Go的`Mutex`主要有两种模式:**正常模式(Normal Mode)和饥饿模式(Starvation Mode)**。 + +1. **正常模式**:这是默认模式,讲究的是性能。新请求锁的goroutine会和等待队列头部的goroutine竞争,新来的goroutine有几次“自旋”的机会,如果在此期间锁被释放,它就可以直接抢到锁。这种方式吞吐量高,但可能会导致队列头部的goroutine等待很久,即“不公平”。 + +2. **饥饿模式**:当一个goroutine在等待队列中等待超过1毫сан(1ms)后,Mutex就会切换到此模式,讲究的是公平。在此模式下,锁的所有权会直接从解锁的goroutine移交给等待队列的头部,新来的goroutine不会自旋,必须排到队尾。这样可以确保队列中的等待者不会被“饿死”。 + +当等待队列为空,或者一个goroutine拿到锁时发现它的等待时间小于1ms,饥饿模式就会结束,切换回正常模式。这两种模式的动态切换,是Go在性能和公平性之间做的精妙平衡。 + +## 6. 在Mutex上自旋的goroutine 会占用太多资源吗 + +并不会,因为Go的自旋设计得非常“克制”和“智能”。 + +首先,自旋不是无休止的空转,它有严格的次数和时间限制,通常只持续几十纳秒。其次,自旋仅仅在特定条件下才会发生,比如CPU核数大于1,并且当前机器不算繁忙(没有太多goroutine在排队)。它是在赌,与其付出“goroutine挂起和唤醒”这种涉及内核调度的巨大代价,不如原地“稍等一下”,因为锁可能马上就释放了。 + +所以,这种自旋是一种机会主义的短线优化,目的是用极小的CPU开销去避免一次昂贵的上下文切换,在锁竞争不激烈、占用时间极短的场景下,它反而是**节省**了资源。 + +## 7. Mutex 已经被一个 Goroutine 获取了, 其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex 呢? + +取决于Mutex当前处于正常模式还是饥饿模式。 + +在**正常模式**下,锁的分配是“不公平”的。当锁被释放时,等待队列中的第一个goroutine会被唤醒,但它**不一定**能拿到锁。它需要和那些此刻刚刚到达、正在自旋的新goroutine进行竞争。新来的goroutine因为正在CPU上运行,很有可能“插队”成功,直接抢到锁。这种策略的优点是吞吐量高,但缺点是可能导致等待队列中的goroutine被饿死。 + +而一旦Mutex进入**饥饿模式**,锁的分配就变得“绝对公平”。锁被释放后,会直接移交给等待队列的队头goroutine,任何新来的goroutine都不会参与竞争,必须乖乖排到队尾。 + +## 8. sync.Once 的作用是什么,讲讲它的底层实现原理? + +`sync.Once`的作用是**确保一个函数在程序生命周期内,无论在多少个goroutine中被调用,都只会被执行一次**。它常用于单例对象的初始化或一些只需要执行一次的全局配置加载 + +`sync.Once`保证代码段只执行1次的原理主要是其内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了 + +**分析** + +Once其实是一个结构体 + +```go +type Once struct { + done uint32 // 标识位 + m Mutex +} + +``` + +核心依赖一个`uint32`的`done`标志位和一个互斥锁`Mutex`, + +当`Once.Do(f)`首次被调用时: + +1. 它首先会通过原子操作(`atomic.LoadUint32`)快速检查`done`标志位。如果`done`为1,说明初始化已完成,直接返回,这个路径完全无锁,开销极小。 + +2. 如果`done`为0,说明可能是第一次调用,这时它会进入一个慢路径(`doSlow`)。 + +3. 在慢路径里,它会先**加锁**,然后**再次检查**`done`标志位。这个“双重检查”(Double-Checked Locking)是关键,它防止了在多个goroutine同时进入慢路径时,函数`f`被重复执行。 + +4. 如果此时`done`仍然为0,那么当前goroutine就会执行传入的函数`f`。执行完毕后,它会通过原子操作(`atomic.StoreUint32`)将`done`标志位置为1,最后**解锁**。 + +之后任何再调用`Do`的goroutine,都会在第一步的原子`Load`操作时发现`done`为1而直接返回。整个过程结合了原子操作的速度和互斥锁的安全性,高效且线程安全地实现了“仅执行一次”的保证 + +## 9. WaiGroup 是怎样实现协程等待? + +`WaitGroup`实现等待,本质上是**一个原子计数器和一个信号量的协作**。 + +调用`Add`会增加计数值,`Done`会减计数值。而`Wait`方法会检查这个计数器,如果不为零,就利用信号量将当前goroutine高效地挂起。直到最后一个`Done`调用将计数器清零,它就会通过这个信号量,一次性唤醒所有在`Wait`处等待的goroutine,从而实现等待目的。 + +**分析:** + +waitgroup的结构定义: + +```go +// A WaitGroup waits for a collection of goroutines to finish. +// The main goroutine calls Add to set the number of goroutines to wait for. +// Then each of the goroutines runs and calls Done when finished. At the same +// time, Wait can be used to block until all goroutines have finished. +// +// A WaitGroup must not be copied after first use. +type WaitGroup struct { + noCopy noCopy // 用于vet工具检查是否被复制 + + // 64位的值:高32位是计数器,低32位是等待的goroutine数量。 + // 通过原子操作访问,保存了状态和等待者数量。 + state atomic.Uint64 + + // 用于等待者休眠的信号量。 + sema uint32 +} +``` + +**`noCopy`**: 这是一个特殊的字段,用于静态分析工具(`go vet`)在编译时检查`WaitGroup`实例是否被复制。`WaitGroup`被复制后会导致状态不一致,可能引发程序错误,因此该字段的存在旨在防止此类问题的发生。 + +**`state`**: 这是`WaitGroup`的核心,一个64位的无符号整型,通过`sync/atomic`包进行原子操作,以保证并发安全。这个64位的空间被巧妙地分成了两部分: + +* **高32位**: 作为**计数器(counter)**,记录了需要等待的 goroutine 的数量。 + +* **低32位**: 作为**等待者计数器(waiter count)**,记录了调用`Wait()`方法后被阻塞的 goroutine 的数量。 + +**`sema`**: 这是一个信号量,用于实现 goroutine 的阻塞和唤醒。当主 goroutine 调用`Wait()`方法且计数器不为零时,它会通过这个信号量进入休眠状态。当所有子 goroutine 完成任务后,会通过这个信号量来唤醒等待的主 goroutine。 + +## 10. 讲讲sync.Map的底层原理 + +`sync.Map`的底层核心是**“空间换时间”,**通过两个Map(`read`和`dirty`)\*\* 的冗余结构,实现“读写分离”,最终达到**针对特定场景的“读”操作无锁优化**。 + +它的`read`是一个只读的`map`,提供无锁的并发读取,速度极快。写操作则会先操作一个加了锁的、可读写的`dirty` map。当`dirty` map的数据积累到一定程度,或者`read` map中没有某个key时,`sync.Map`会将`dirty` map里的数据“晋升”并覆盖掉旧的`read` map,完成一次数据同步。 + +**分析:** + +`sync.Map`的结构定义 + +```go +type Map struct { + mu Mutex // 用于保护dirty字段的锁 + read atomic.Value // 只读字段,其实际的数据类型是一个readOnly结构 + dirty map[interface{}]*entry //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素以及新加入的元素 + misses int // 计数器,记录在从read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read +} +``` + +`read`字段的类型是`atomic.Value`,但是在使用中里面其实存储的是`readOnly`结构,`readOnly`结构定义如下: + +```go +// readOnly is an immutable struct stored atomically in the Map.read field. +type readOnly struct { +   m       map[interface{}]*entry   // key为任意可比较类型,value为entry指针 +   amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中都存在 +} +``` + +`entry`这个结构: + +```go +type entry struct { +    p unsafe.Pointer  // p指向真正的value所在的地址 +} +``` + + + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Sync面试题/10a8fc3bc4fe8c3b2d23a0832aabb5f2.png) + +## 11. read map和dirty map之间有什么关联? + +它们之间是**“只读缓存”**和**“最新全集”**的关联。 + +`read` map是`dirty` map的一个不完全、且可能是过期的只读快照。`dirty` map则包含了所有的最新数据。 + +具体来说,`read` map中的所有数据,在`dirty` map里一定存在。一个key如果在`read` map里,那它的value要么就是最终值,要么就是一个特殊指针,指向`dirty` map里对应的条目。而`dirty` map里有,`read` map里却可能没有,因为`dirty`是最新、最全的。 + +当`dirty` map积累了足够多的新数据后,它会“晋升”为新的`read` map,旧的`read` map则被废弃。这个过程,就完成了“缓存”的更新。 + +## 12. 为什么要设计nil和expunged两种删除状态? + +设计`nil`和`expunged`这两个状态,是为了解决**在`sync.Map`的“读写分离”架构下,如何高效、无锁地处理“删除”操作**。 + +因为`read` map本身是只读的,我们不能直接从中删除一个key。所以,当用户调用`Delete`时,如果这个key只存在于`read` map中,系统并不会真的删除它,而是将它的值标记为一个特殊的“已删除”状态,这个状态就是`expunged`。后续的读操作如果看到这个`expunged`标记,就知道这个key其实已经不存在了,直接返回`nil, false`。 + +而`nil`则是一个中间状态,主要用于`dirty` map和`read` map的同步过程,表示这个key正在被删除或迁移。 + +简单来说,这两个状态就像是在只读的`read` map上打的“逻辑删除”补丁。它避免了因为一次`Delete`操作就引发加锁和map的整体复制,把真正的物理删除延迟到了`dirty` map“晋升”为`read` map的那一刻,是典型的用状态标记来换取无锁性能的设计。 + +## 13. sync.Map 适用的场景? + +`sync.Map`适合读多写少的场景,而不是和写多读少的场景。 + +因为我们期望将更多的流量在read map这一层进行拦截,从而避免加锁访问dirty map +对于更新,删除,读取,read map可以尽量通过一些原子操作,让整个操作变得无锁化,这样就可以避免进一步加锁访问dirty map。倘若写操作过多,sync.Map 基本等价于一把互斥锁 + map,其读写效率会大大下降 diff --git a/hzh/GolangStar/Go面试题库/代码面试题.md b/hzh/GolangStar/Go面试题库/代码面试题.md new file mode 100644 index 0000000..1a373f1 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/代码面试题.md @@ -0,0 +1,473 @@ +--- +tags: + - Go + - golang + - 代码面试题 +--- + +# 代码面试题 + +## 1.开启100个协程,顺序打印1-1000,且保证协程号1的,打印尾数为1的数字 + +```go +// 同时开启100个协程(分别为1号协程 2号协程 ... 100号协程, +// 1号协程只打印尾数为1的数字,2号协程只打印尾数为2的数, +// 以此类推),请顺序打印1-1000整数以及对应的协程号; + +func main() { + s := make(chan struct{}) + //通过map的key来保证协程的顺序 + m := make(map[int]chan int, 100) + //填充map,初始化channel + for i := 1; i <= 100; i++ { + m[i] = make(chan int) + } + //开启100个协程,死循环打印 + //go func() { 这个协程不加也可以的 + for i := 1; i <= 100; i++ { + go func(id int) { + for { + num := <-m[id] + fmt.Println(num) + s <- struct{}{} + } + }(i) + } + //}() + //循环1-1000,并把值传递给匹配的map + //然后通过s限制循序打印 + for i := 1; i <= 1000; i++ { + id := i % 100 + if id == 0 { + id = 100 + } + m[id] <- i + //通过s这个来控制打印顺序。每次遍历一次i + //都通过s阻塞协程的打印,最后打印完毕 + <-s + } + + time.Sleep(10 * time.Second) +} +``` + +## 2.三个goroutinue交替打印abc 10次 + +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + // 定义3个channel + ch1 := make(chan struct{}) + ch2 := make(chan struct{}) + ch3 := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(3) + // 打印a + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + <-ch1 + fmt.Println("a") + ch2 <- struct{}{} + } + // 第10次的时候,打印c的goroutine写入了ch1 + // 为了防止阻塞,要消费以下ch1 + <-ch1 + }() + // 打印b + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + <-ch2 + fmt.Println("b") + ch3 <- struct{}{} + } + }() + // 打印c + go func() { + defer wg.Done() + for i := 0; i < 10; i++ { + <-ch3 + fmt.Println("c") + ch1 <- struct{}{} + } + }() + // 启动 + ch1 <- struct{}{} + wg.Wait() + close(ch1) + close(ch2) + close(ch3) + fmt.Println("end") +} +``` + +## 3.用不超过10个goroutine不重复的打印slice中的100个元素 + +```go +package main + +import ( + "fmt" + "sync" +) + +// 用不超过10个goroutine不重复的打印slice中的100个元素 +// 容量为10的有缓冲channel实现 +// 每次启动10个,累计启动100个goroutine,且无序打印 +func main() { + var wg sync.WaitGroup + // 创建切片 + ss := make([]int, 100) + for i := 0; i < 100; i++ { + ss[i] = i + } + ch := make(chan struct{}, 10) + for i := 0; i < 100; i++ { + wg.Add(1) + ch <- struct{}{} + // 写10个就阻塞了,此时goroutine中打印 + go func(idx int) { + defer wg.Done() + fmt.Printf("val: %d \n", ss[idx]) + // 打印结束,从缓冲channel中删除一个 + <-ch + }(i) + + } + wg.Wait() + // 关闭channel + close(ch) + fmt.Println("end") +} + +// 用不超过10个goroutine不重复的打印slice中的100个元素 +// 创建10个无缓冲channel和10个goroutine +// 固定10个goroutine,且顺序打印 +func test9() { + var wg sync.WaitGroup + // 创建切片 + ss := make([]int, 100) + for i := 0; i < 100; i++ { + ss[i] = i + } + // 创建channel和goroutine + hashMap := make(map[int]chan int) + sort := make(chan struct{}) + for i := 0; i < 10; i++ { + hashMap[i] = make(chan int) + wg.Add(1) + go func(idx int) { + defer wg.Done() + for val := range hashMap[idx] { + fmt.Printf("go id: %d, val: %d \n", idx, val) + sort <- struct{}{} + } + }(i) + } + // 循环切片,对10取模,找到对应channel的key,写入值 + for _, v := range ss { + id := v % 10 + hashMap[id] <- v + // 有序 + <-sort + } + // 循环结束关闭channel,删除map的key + for k, _ := range hashMap { + close(hashMap[k]) + delete(hashMap, k) + } + wg.Wait() + close(sort) + fmt.Println("end") +} +``` + +## 4.两个协程交替打印奇偶数 + +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + //golang交替打印奇偶数 + //交替打印,可以通过channel来实现 + chan1 := make(chan struct{}) + //偶数 + go func() { + for i := 0; i < 10; i++ { + chan1 <- struct{}{} + if i%2 == 0 { + fmt.Println("打印偶数:", i) + } + } + }() + //奇数 + go func() { + for i := 0; i < 10; i++ { + <-chan1 + if i%2 == 1 { + fmt.Println("打印奇数数:", i) + } + } + }() + //阻塞 + select { + case <-time.After(time.Second * 10): + } +} +``` + +## 5.用单个channel实现0,1的交替打印 + +```go +package main + +import ( + "fmt" + "time" +) + +func main() { + msg := make(chan struct{}) + go func() { + for { + <-msg + fmt.Println("0") + msg <- struct{}{} + } + }() + go func() { + for { + <-msg + fmt.Println("1") + msg <- struct{}{} + } + }() + msg <- struct{}{} + time.Sleep(3 * time.Minute) + +} +``` + +## 6.sync.Cond实现多生产者多消费者 + +```go +package main + +import ( + "context" + "fmt" + "math/rand" + "sync" + "time" +) + +func main() { + var wg sync.WaitGroup + var cond sync.Cond + cond.L = new(sync.Mutex) + msgCh := make(chan int, 5) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + rand.Seed(time.Now().UnixNano()) + + // 生产者 + producer := func(ctx context.Context, out chan<- int, idx int) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + // 每次生产者退出,都唤醒一个消费者处理,防止最后有消费者线程死锁 + // 生产者比消费者多,所以cond.Signal()就可以。不然的话建议Broadcast() + cond.Broadcast() + fmt.Println("producer finished") + return + default: + cond.L.Lock() + for len(msgCh) == 5 { + cond.Wait() + } + num := rand.Intn(500) + out <- num + fmt.Printf("producer: %d, msg: %d\n", idx, num) + cond.Signal() + cond.L.Unlock() + } + } + } + + // 消费者 + consumer := func(ctx context.Context, in <-chan int, idx int) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + // 消费者可以选择继续消费直到channel为空 + for len(msgCh) > 0 { + select { + case num := <-in: + fmt.Printf("consumer %d, msg: %d\n", idx, num) + default: + // 如果channel已经空了,跳出循环 + break + } + } + fmt.Println("consumer finished") + return + default: + cond.L.Lock() + for len(msgCh) == 0 { + cond.Wait() + } + num := <-in + fmt.Printf("consumer %d, msg: %d\n", idx, num) + cond.Signal() + cond.L.Unlock() + } + } + } + + // 启动生产者和消费者 + for i := 0; i < 5; i++ { + wg.Add(1) + go producer(ctx, msgCh, i+1) + } + for i := 0; i < 3; i++ { + wg.Add(1) + go consumer(ctx, msgCh, i+1) + } + + // 模拟程序运行一段时间 + wg.Wait() + close(msgCh) + fmt.Println("all finished") +} +``` + +## 7.使用go实现1000个并发控制并设置执行超时时间1秒 + +```go +package main + +import ( + "context" + "fmt" + "sync" + "time" +) + +func main() { + // 创建 1000 个协程,并且进行打印 + // 总共超时时间 1s,1s 没执行完就超时,使用 ctx 进行控制 + + // 定义任务 channel + tasks := make(chan int, 1000) + // 定义 ctx + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + var wg sync.WaitGroup + + // 启动 1000 个协程 + for i := 0; i < 1000; i++ { + wg.Add(1) + tasks <- i + go func(id int) { + defer wg.Done() + select { + case <-ctx.Done(): + return + default: + fmt.Printf("goroutine id: %d\n", id) + } + }(i) + } + + <-ctx.Done() + fmt.Println("exec done") + close(tasks) + wg.Wait() + fmt.Println("finish") +} +``` + +## 8.使用两个Goroutine,向标准输出中按顺序按顺序交替打出字母与数字,输出是a1b2c3 + +```go +package main + +import ( + "fmt" + "sync" +) + +func main() { + // 定义两个channel,一个打印数字,一个打印字母 + numCh := make(chan struct{}) + strCh := make(chan struct{}) + var wg sync.WaitGroup + wg.Add(2) + // 打印字符 + go func() { + defer wg.Done() + for i := 'a'; i <= 'z'; i++ { + fmt.Println(string(i)) + // 通知打印数字 + numCh <- struct{}{} + // 阻塞等待打印字母 + <-strCh + } + }() + // 打印字母 + go func() { + defer wg.Done() + for i := 1; i <= 26; i++ { + <-numCh + fmt.Println(i) + // 通知打印字母 + strCh <- struct{}{} + } + }() + wg.Wait() + fmt.Println("finished") +} +``` + +## 9.编写一个程序限制10个goroutine执行,每执行完一个goroutine就放一个新的goroutine进来 + +```go +package main + +import ( + "fmt" + "sync" +) + +// 编写一个程序限制10个goroutine执行,每执行完一个goroutine就放一个新的goroutine进来 +func main() { + var wg sync.WaitGroup + ch := make(chan struct{}, 10) + for i := 0; i < 20; i++ { + wg.Add(1) + ch <- struct{}{} + go func(id int) { + defer wg.Done() + fmt.Println("id: %d", id) + <-ch + }(i) + } + wg.Wait() + +} +``` + diff --git a/hzh/GolangStar/Go面试题库/内存管理面试题.md b/hzh/GolangStar/Go面试题库/内存管理面试题.md new file mode 100644 index 0000000..e8318f6 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/内存管理面试题.md @@ -0,0 +1,90 @@ +--- +tags: + - Go + - golang + - 内存管理 + - 面试题 + - 内存管理面试题 +--- + +# 内存管理面试题 + +## 1. 讲讲Go语言是如何分配内存的? + +Go语言的内存分配采用了**TCMalloc算法**的改进版本,核心是分级分配和本地缓存。 + +**分配器架构**:Go内存分配有三个层级:**mcache(线程缓存)、mcentral(中央缓存)、mheap(页堆)**。每个P都有独立的mcache,避免了锁竞争;mcentral按对象大小分类管理;mheap负责从操作系统申请大块内存。 + +**对象分类分配**:根据对象大小分为三类处理: + +* **微小对象**(<16字节):在mcache的tiny分配器中分配,多个微小对象可以共享一个内存块 + +* **小对象**(16字节-32KB):通过size class机制,预定义了67种大小规格,优先从P的mcache对应的mspan中分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。 + +* **大对象**(>32KB):直接从mheap分配,跨越多个页面 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/内存管理面试题/image.png) + +## 2. 知道 golang 的内存逃逸吗?什么情况下会发生内存逃逸? + +内存逃逸是编译器在程序编译时期根据逃逸分析策略,将原本应该分配到栈上的对象分配到堆上的一个过程 + +**主要逃逸场景**: + +* **返回局部变量指针**:函数返回内部变量的地址,变量必须逃逸到堆上 + +* **interface{}类型**:传递给interface{}参数的具体类型会逃逸,因为需要运行时类型信息 + +* **闭包引用外部变量**:被闭包捕获的变量会逃逸到堆上 + +* **切片/map动态扩容**:当容量超出编译期确定范围时会逃逸 + +* **大对象**:超过栈大小限制的对象直接分配到堆上 + +## 3. **内存逃逸有什么影响?** + +因为堆对象需要垃圾回收机制来释放内存,栈对象会跟随函数结束被编译器回收,所以大量的内存逃逸会给gc带来压力 + +## 4. Channel是分配在栈上,还是堆上? + +channel分配在堆上,Channel 被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以 一般情况下golang 直接将其分配在堆上 + +## 5. Go语言在什么情况下会发生内存泄漏? + +以下是一些内存泄漏的场景场景: + +**goroutine泄漏**:这是最常见的泄漏场景。goroutine没有正常退出会一直占用内存,比如从channel读取数据但channel永远不会有数据写入,或者死循环没有退出条件。我在项目中遇到过,启动了处理任务的goroutine但没有合适的退出机制,导致随着请求增加goroutine越来越多。 + +**channel泄漏**:未关闭的channel和等待channel的goroutine会相互持有引用。比如生产者已经结束但没有关闭channel,消费者goroutine会一直阻塞等待,造成内存无法回收。 + +**slice引用大数组**:当slice引用一个大数组的小部分时,整个底层数组都无法被GC回收。解决方法是使用copy创建新的slice。 + +**map元素过多**:map中删除元素只是标记删除,底层bucket不会缩减。如果map曾经很大后来元素减少,内存占用仍然很高。 + +**定时器未停止**:`time.After`或`time.NewTimer`创建的定时器如果不手动停止,会在heap中持续存在。 + +**循环引用**:虽然Go的GC能处理循环引用,但在某些复杂场景下仍可能出现问题。 + +## 6. Go语言发生了内存泄漏如何定位和优化? + +**定位工具**: + +* **pprof**:最重要的工具,通过`go tool pprof http://localhost:port/debug/pprof/heap`分析堆内存分布,`go tool pprof http://localhost:port/debug/pprof/goroutine`分析goroutine泄漏 + +* **trace工具**:`go tool trace`可以看到goroutine的生命周期和阻塞情况 + +* **runtime统计**:通过`runtime.ReadMemStats()`监控内存使用趋势,`runtime.NumGoroutine()`监控协程数量 + +**定位方法**:我通常先看内存增长曲线,如果内存持续上涨不回收,就用pprof分析哪个函数分配内存最多。如果是goroutine泄漏,会看到goroutine数量异常增长,然后分析这些goroutine阻塞在哪里。 + +**常见优化手段**: + +* **goroutine泄漏**:使用context设置超时,确保goroutine有退出机制,避免无限阻塞 + +* **channel泄漏**:及时关闭channel,使用select+default避免阻塞 + +* **slice引用优化**:对大数组的小slice使用copy创建独立副本 + +* **定时器清理**:手动调用`timer.Stop()`释放资源 + +7. diff --git a/hzh/GolangStar/Go面试题库/反射面试题.md b/hzh/GolangStar/Go面试题库/反射面试题.md new file mode 100644 index 0000000..0ff917e --- /dev/null +++ b/hzh/GolangStar/Go面试题库/反射面试题.md @@ -0,0 +1,38 @@ +--- +tags: + - Go + - golang + - 反射 + - 面试题 + - 反射面试题 +--- + +# 反射面试题 + +## 1. 什么是反射? + +反射是指计算机程序在运行时(Run time)可以访问、检测和修改它本身状态或行为的一种能力。用比喻来说,反射就是程序在运行的时候能够“观察”并且修改自己的行为。 + +## 2. Go语言如何实现反射? + +Go语言反射是通过接口来实现的,一个接口变量包含两个指针结构:一个指针指向**类型信息**,另一个指针指向**实际的数据**。当我们将一个具体类型的变量赋值给一个接口时,Go就会把这个变量的类型信息和数据地址都存到这个接口变量里。 + +有了这个前提,Go语言就可以通过再由`reflect`包的`Type`和`ValueOf`这两个函数读取接口变量里的类型信息和数据信息。把这些内部信息“解包”成可供我们检查和操作的对象,完成在运行时对程序本身的动态访问和修改 + +## 3. Go语言中的反射应用有哪些 + +**JSON序列化是最常见的应用**,像`encoding/json`包通过反射动态获取结构体字段信息,实现任意类型的序列化和反序列化。这也是为什么我们能直接用`json.Marshal`处理各种自定义结构体的原因。 + +**ORM框架是另一个重点应用**,比如GORM通过反射分析结构体字段,自动生成SQL语句和字段映射。它能动态读取struct tag来确定数据库字段名、约束等信息,大大简化了数据库操作。 + +**Web框架的参数绑定也大量使用反射**,像Gin框架的`ShouldBind`方法,能够根据请求类型自动将HTTP参数绑定到结构体字段上,这背后就是通过反射实现的类型转换和赋值。 + +**还有配置文件解析、RPC调用、测试框架等场景**。比如Viper配置库用反射将配置映射到结构体,gRPC通过反射实现服务注册和方法调用。 + +## 4. 如何比较两个对象完全相同 + +**最直接的是用reflect.DeepEqual**,这是标准库提供的深度比relatively方法,能递归比较结构体、切片、map等复合类型的所有字段和元素。比如`reflect.DeepEqual(obj1, obj2)`,它会逐层比较内部所有数据,包括指针指向的值。 + +**对于简单类型可以直接用==操作符**,但这只适合基本类型、数组、结构体等可比较类型。需要注意slice、map、function这些类型是不能直接用==比较的,会编译报错。 + +**实际项目中更推荐自定义Equal方法**,根据业务需求定义相等的标准。比如对于用户对象,可能只需要比较ID和关键字段,而不需要比较时间戳这种辅助字段。这样既提高了性能,又符合业务语义。 diff --git a/hzh/GolangStar/Go面试题库/垃圾回收面试题.md b/hzh/GolangStar/Go面试题库/垃圾回收面试题.md new file mode 100644 index 0000000..453e7b9 --- /dev/null +++ b/hzh/GolangStar/Go面试题库/垃圾回收面试题.md @@ -0,0 +1,373 @@ +--- +tags: + - Go + - golang + - 垃圾回收 + - 面试题 + - 垃圾回收面试题 +--- + +# 垃圾回收面试题 + +## 1. 常见的 GC 实现方式有哪些? + +所有的 GC 算法其存在形式可以归结为追踪(Tracing)GC和引用计数(Reference Counting)这两种形式的混合运用。 + +目前比较常见的实现方式有: + +* 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。 + +* 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。 + +* 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。 + +* 增量整理:在增量式的基础上,增加对对象的整理过程。 + +* 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。 + +* 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。 + +## 2. Go 语言的 GC 使用的是什么? + +Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。 + +## 3. 三色标记法是什么? + +三色标记法是Go垃圾回收器使用的核心算法 + +**三色定义**: + +* **白色**:未被访问的对象,垃圾回收结束后白色对象会被清理 + +* **灰色**:已被访问但其引用对象还未完全扫描的对象,是待处理队列 + +* **黑色**:已被访问且其所有引用对象都已扫描完成的对象,确认存活 + +**标记流程**:GC开始时所有对象都是白色,从GC Root(全局变量、栈变量等)开始将直接可达对象标记为灰色。然后不断从灰色队列中取出对象,扫描其引用的对象:如果引用对象是白色就标记为灰色,当前对象所有引用扫描完成后标记为黑色。重复这个过程直到灰色队列为空。 + +**分析:** + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/垃圾回收面试题/image-3.png) + +当垃圾回收开始时,只有白色对象。随着标记过程开始进行时,灰色对象开始出现(着色),这时候波面便开始扩大。当一个对象的所有子节点均完成扫描时,会被着色为黑色。当整个堆遍历完成时,只剩下黑色和白色对象,这时的黑色对象为可达对象,即存活;而白色对象为不可达对象,即死亡。这个过程可以视为以灰色对象为波面,将黑色对象和白色对象分离,使波面不断向前推进,直到所有可达的灰色对象都变为黑色对象为止的过程。如上图所示 + +## 4. Go语言GC的根对象到底是什么? + +根对象在垃圾回收的术语中又叫做根集合,它是垃圾回收器在标记过程时最先检查的对象,包括: + +1. 全局变量:程序在编译期就能确定的那些存在于程序整个生命周期的变量。 + +2. 执行栈:每个 goroutine 都包含自己的执行栈,这些执行栈上包含栈上的变量及指向分配的堆内存区块的指针。 + +3. 寄存器:寄存器的值可能表示一个指针,参与计算的这些指针可能指向某些赋值器分配的堆内存区块。 + +## 5. STW 是什么意思? + +`STW` 是 `Stop the World` 的缩写,通常意义上指的是用户代码被完全停止运行,`STW` 越长,对用户代码造成的影响(例如延迟)就越大,早期 Go 对垃圾回收器的实现中 `STW` 长达几百毫秒,对时间敏感的实时通信等应用程序会造成巨大的影响。 + +## 6. 并发标记清除法的难点是什么? + +并发标记清除法的核心难点在于如何保证在用户程序并发修改对象引用时,垃圾回收器仍能正确识别存活对象。 + +**主要难点**: + +* **对象消失问题**:在标记过程中,如果用户程序删除了从黑色对象到白色对象的引用,同时从灰色对象到该白色对象的引用也被删除,这个白色对象就会被错误回收,但它实际上还是可达的 + +* **新对象处理**:标记期间新分配的对象如何着色?如果标记为白色可能被误回收,标记为黑色可能造成浮动垃圾 + +**以如下例子来分析:** +| 时序 | 回收器 | 赋值器| |说明| +| ----------- | ----------- | ----------- |----------- | +| 1 | shade(A, gray) | | 回收器:根对象的子节点着色为灰色对象| +| 2 | shade(C, black) | | 回收器:当所有子节点着色为灰色后,将节点着为黑色| +| 3 | |C.ref3 = C.ref2.ref1 |赋值器:并发的修改了 C 的子节点 | +| 4 | | A.ref1 = nil| 赋值器:并发的修改了 A 的子节点| +| 5 | shade(A.ref1, gray) | |回收器:进一步灰色对象的子节点并着色为灰色对象,这时由于 A.ref1 为 nil,什么事情也没有发生| +| 6 | shade(A, black) | | 回收器:由于所有子节点均已标记,回收器也不会重新扫描已经被标记为黑色的对象,此时 A 被着色为黑色,scan(A) 什么也不会发生,进而 B 在此次回收过程中永远不会被标记为黑色,进而错误地被回收 | + + +* 初始状态:假设某个黑色对象 C 指向某个灰色对象 A ,而 A 指向白色对象 B; + +* `C.ref3 = C.ref2.ref1`:赋值器并发地将黑色对象 C 指向(ref3)了白色对象 B; + +* `A.ref1 = nil`:移除灰色对象 A 对白色对象 B 的引用(ref2); + +* 最终状态:在继续扫描的过程中,白色对象 B 永远不会被标记为黑色对象了(回收器不会重新扫描黑色对象),进而对象 B 被错误地回收。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/垃圾回收面试题/image.png) + +## 7. Go语言是如何解决并发标记清除时,用户程序并发修改对象引用问题的? + +Go通过**写屏障技术**和**三色不变性维护**来解决这个并发安全问题。 + +核心挑战是防止"对象消失"现象:当黑色对象新增对白色对象的引用,同时灰色到白色的引用被删除时,白色对象可能被错误回收。Go采用**混合写屏障**策略,在指针赋值时执行额外逻辑:新建引用时将目标对象着为灰色,删除引用时将被删对象标为灰色,这样确保关键对象不会丢失在标记过程中。 + +同时Go维护了**弱三色不变性**:允许黑色对象指向白色对象,但要保证从白色对象出发存在全灰色路径可达根对象。栈操作因为频繁且开销敏感,没有采用写屏障结束,而是做了特殊处理:标记开始和结束时分别扫描栈,中间过程不加写屏障。 + +这套机制让Go实现了微秒级STW时间,大部分GC工作都与用户程序并发执行,在保证回收正确性的同时将性能影响降到最低。 + +## 8. 什么是写屏障、混合写屏障,如何实现? + +写屏障的本质是在编译器在指针赋值操作中插入的额外很短的指令,当执行`*slot = ptr`这样的指针赋值时,写屏障会在赋值前后执行特定逻辑来标记相关对象,防止并发标记过程中对象被错误回收。 + +首先Dijkstra**插入写屏障**在建立新引用时将目标对象标为灰色,但删除引用时无保护;**Yuasa删除写屏障**在删除引用时将原对象标为灰色,但新建引用时无保护。两者各有局限性 + +Go 1.8后采用的混合写屏障,结合两者优点,在堆上在建立新引用和删除引用时分别采用**插入写屏障和删除写屏障的做法。但同时他会做了优化,**它不再需要STW去重扫了。它的新规则是,任何在GC标记阶段,被创建于栈上的新对象,默认都标记为黑色。这样一来,GC就不需要关心栈上的指针指向堆里的哪个白色对象了,因为栈本身就被看作是黑色的,它指向的对象必须是可达的。 + +## 9. Go 语言中 GC 的流程是什么? + +| 阶段 | 说明 | 赋值器状态 | +| ----------- | ----------- | ----------- | +| SweepTermination | 清扫终止阶段,为下一个阶段的并发标记做准备工作,启动写屏障 | STW | +| Mark | 扫描标记阶段,与赋值器并发执行,写屏障开启 | 并发 | +| MarkTermination | 标记终止阶段,保证一个周期内标记任务完成,停止写屏障 | STW | +| GCoff | 内存清扫阶段,将需要回收的内存归还到堆中,写屏障关闭 | 并发 | +| GCoff | 内存归还阶段,将过多的内存归还给操作系统,写屏障关闭 | 并发 | + +**分析:** + +具体而言,各个阶段的触发函数分别为: + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/垃圾回收面试题/image-1.png) + +## 10. GC触发的时机有哪些? + +1. **主动触发**,通过调用 runtime.GC() 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。 + +2. **被动触发**,分为两种方式: + + * go后台有一系统监控线程,当超过两分钟没有产生任何 GC 时,强制触发 GC。 + + * 内存使用增长一定比例时有可能会触发,每次内存分配时检查当前内存分配量是否已达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启用GC + + * 我们可以通过debug.SetGCPercent(500)来修改步调,这里表示,如果当前堆大小超过了上次标记的堆大小的500%,就会触发 + + * 而第一次GC的触发的临界值是4MB + +## 11. GC 关注的指标有哪些? + +* CPU 利用率:回收算法会在多大程度上拖慢程序?有时候,这个是通过回收占用的 CPU 时间与其它 CPU 时间的百分比来描述的。 + +* GC 停顿时间:回收器会造成多长时间的停顿?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。 + +* GC 停顿频率:回收器造成的停顿频率是怎样的?目前的 GC 中需要考虑 STW 和 Mark Assist 两个部分可能造成的停顿。 + +* GC 可扩展性:当堆内存变大时,垃圾回收器的性能如何?但大部分的程序可能并不一定关心这个问题。 + +## 12. 有了 GC,为什么还会发生内存泄露? + +有GC机制的话,内存泄漏其实是预期的能很快被释放的内存其生命期意外地被延长,导致预计能够立即回收的内存而长时间得不到回收。 + +Go用语言主要有以下两种: + +1. 内存被根对象引用而没有得到迅速释放 ,比如某个局部变量被赋值到了一个全局变量map中 + +2. goroutine 泄漏,一些不当的使用,导致goroutine不能正常退出,也会造成内存泄漏 + +## 13. Go 的 GC 如何调优? + +1. 合理化内存分配的速度、提高赋值器的 CPU 利用率 + +2. 降低并复用已经申请的内存 ,比如使用sync.pool复用经常需要创建的重复对象 + +3. 调整 GOGC ,可以适量将 GOGC 的值设置得更大,让 GC 触发的时间变得更晚,从而减少其触发频率,进而增加用户代码对机器的使用率 + +## 14. 如何观察 Go GC? + +主要有以下几种方式: + +1. `GODEBUG=gctrace=1` + +2. `go tool trace` + +3. `debug.ReadGCStats` + +4. `runtime.ReadMemStats` + +**分析:** + +有如下代码: +```go +package main + +func allocate() { + _ = make([]byte, 1<<20) +} + +func main() { + for n := 1; n < 100000; n++ { + allocate() + } +} + +``` +方式一:`GODEBUG=gctrace=1` +我们首先可以通过 +```go +$ go build -o main +$ GODEBUG=gctrace=1 ./main + +gc 1 @0.000s 2%: 0.009+0.23+0.004 ms clock, 0.11+0.083/0.019/0.14+0.049 ms cpu, 4->6->2 MB, 5 MB goal, 12 P +scvg: 8 KB released +scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB) +gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P +scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) +gc 3 @0.003s 2%: 0.018+0.59+0.011 ms clock, 0.22+0.073/0.008/0.042+0.13 ms cpu, 5->6->1 MB, 6 MB goal, 12 P +scvg: 8 KB released +scvg: inuse: 2, idle: 61, sys: 63, released: 56, consumed: 7 (MB) +gc 4 @0.003s 4%: 0.019+0.70+0.054 ms clock, 0.23+0.051/0.047/0.085+0.65 ms cpu, 4->6->2 MB, 5 MB goal, 12 P +scvg: 8 KB released +scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) +scvg: 8 KB released +scvg: inuse: 4, idle: 59, sys: 63, released: 56, consumed: 7 (MB) +gc 5 @0.004s 12%: 0.021+0.26+0.49 ms clock, 0.26+0.046/0.037/0.11+5.8 ms cpu, 4->7->3 MB, 5 MB goal, 12 P +scvg: inuse: 5, idle: 58, sys: 63, released: 56, consumed: 7 (MB) +gc 6 @0.005s 12%: 0.020+0.17+0.004 ms clock, 0.25+0.080/0.070/0.053+0.051 ms cpu, 5->6->1 MB, 6 MB goal, 12 P +scvg: 8 KB released +scvg: inuse: 1, idle: 62, sys: 63, released: 56, consumed: 7 (MB) +``` +在这个日志中可以观察到两类不同的信息: +```go +gc 1 @0.000s 2%: 0.009+0.23+0.004 ms clock, 0.11+0.083/0.019/0.14+0.049 ms cpu, 4->6->2 MB, 5 MB goal, 12 P +gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P +``` +以及 +```go +scvg: 8 KB released +scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB) +scvg: inuse: 3, idle: 60, sys: 63, released: 56, consumed: 7 (MB) +``` +对于用户代码向运行时申请内存产生的垃圾回收: +```go +gc 2 @0.001s 2%: 0.018+1.1+0.029 ms clock, 0.22+0.047/0.074/0.048+0.34 ms cpu, 4->7->3 MB, 5 MB goal, 12 P +``` +含义由下表所示: +|字段 |含义| +| ----------- | ----------- | +| gc 2 | 第二个 GC 周期 | +| 0.001 | 程序开始后的 0.001 秒 | +| 2% | 该 GC 周期中 CPU 的使用率 | +| 0.018 | 标记开始时, STW 所花费的时间(wall clock) | +| 1.1 | 标记过程中,并发标记所花费的时间(wall clock) | +| 0.029 | 标记终止时, STW 所花费的时间(wall clock) | +| 0.22 | 标记开始时, STW 所花费的时间(cpu time) | +| 0.047 | 标记过程中,标记辅助所花费的时间(cpu time) | +| 0.074 | 标记过程中,并发标记所花费的时间(cpu time) | +| 0.048 | 标记过程中,GC 空闲的时间(cpu time) | +| 0.34 | 标记终止时, STW 所花费的时间(cpu time) | +| 4 | 标记开始时,堆的大小的实际值 | +| 7 | 标记结束时,堆的大小的实际值 | +| 3 | 标记结束时,标记为存活的对象大小 | +| 5 | 标记结束时,堆的大小的预测值 | +| 12 | P 的数量 | +> wall clock 是指开始执行到完成所经历的实际时间,包括其他程序和本程序所消耗的时间; cpu time 是指特定程序使用 CPU 的时间; 他们存在以下关系: +> +> * wall clock < cpu time: 充分利用多核 +> +> * wall clock ≈ cpu time: 未并行执行 +> +> * wall clock > cpu time: 多核优势不明显 + +对于运行时向操作系统申请内存产生的垃圾回收(向操作系统归还多余的内存): +```go +scvg: 8 KB released +scvg: inuse: 3, idle: 60, sys: 63, released: 57, consumed: 6 (MB) +``` +含义由下表所示: + +|字段 |含义| +| ----------- | ----------- | +| 8 KB released | 向操作系统归还了 8 KB 内存 | +| 3 | 已经分配给用户代码、正在使用的总内存大小 (MB) | +| 60 | 空闲以及等待归还给操作系统的总内存大小(MB) | +| 63 | 通知操作系统中保留的内存大小(MB) | +| 57 | 已经归还给操作系统的(或者说还未正式申请)的内存大小(MB) | +| 6 | 已经从操作系统中申请的内存大小(MB) | + +方式二:`go tool trace` + +`go tool trace` 的主要功能是将统计而来的信息以一种可视化的方式展示给用户。要使用此工具,可以通过调用 trace API: +```go +package main + +func main() { + f, _ := os.Create("trace.out") + defer f.Close() + trace.Start(f) + defer trace.Stop() + (...) +} +``` +并通过 +```go +$ go tool trace trace.out +2019/12/30 15:50:33 Parsing trace... +2019/12/30 15:50:38 Splitting trace... +2019/12/30 15:50:45 Opening browser. Trace viewer is listening on http://127.0.0.1:51839 +``` +来启动可视化界面: + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/垃圾回收面试题/image-2.png) + +方式三:`debug.ReadGCStats` + +此方式可以通过代码的方式来直接实现对感兴趣指标的监控,例如我们希望每隔一秒钟监控一次 GC 的状态: +```go +func printGCStats() { + t := time.NewTicker(time.Second) + s := debug.GCStats{} + for { + select { + case <-t.C: + debug.ReadGCStats(&s) + fmt.Printf("gc %d last@%v, PauseTotal %v\n", s.NumGC, s.LastGC, s.PauseTotal) + } + } +} +func main() { + go printGCStats() + (...) +} +``` +我们能够看到如下输出: +```go +$ go run main.go + +gc 4954 last@2019-12-30 15:19:37.505575 +0100 CET, PauseTotal 29.901171ms +gc 9195 last@2019-12-30 15:19:38.50565 +0100 CET, PauseTotal 77.579622ms +gc 13502 last@2019-12-30 15:19:39.505714 +0100 CET, PauseTotal 128.022307ms +gc 17555 last@2019-12-30 15:19:40.505579 +0100 CET, PauseTotal 182.816528ms +gc 21838 last@2019-12-30 15:19:41.505595 +0100 CET, PauseTotal 246.618502ms +``` + +方式四:`runtime.ReadMemStats` + +除了使用 debug 包提供的方法外,还可以直接通过运行时的内存相关的 API 进行监控: +```go +func printMemStats() { + t := time.NewTicker(time.Second) + s := runtime.MemStats{} + + for { + select { + case <-t.C: + runtime.ReadMemStats(&s) + fmt.Printf("gc %d last@%v, next_heap_size@%vMB\n", s.NumGC, time.Unix(int64(time.Duration(s.LastGC).Seconds()), 0), s.NextGC/(1<<20)) + } + } +} +func main() { + go printMemStats() + (...) +} +``` + +```go +$ go run main.go + +gc 4887 last@2019-12-30 15:44:56 +0100 CET, next_heap_size@4MB +gc 10049 last@2019-12-30 15:44:57 +0100 CET, next_heap_size@4MB +gc 15231 last@2019-12-30 15:44:58 +0100 CET, next_heap_size@4MB +gc 20378 last@2019-12-30 15:44:59 +0100 CET, next_heap_size@6MB +``` + diff --git a/hzh/GolangStar/Go面试题库/基础面试题.md b/hzh/GolangStar/Go面试题库/基础面试题.md new file mode 100644 index 0000000..193d7bf --- /dev/null +++ b/hzh/GolangStar/Go面试题库/基础面试题.md @@ -0,0 +1,459 @@ +--- +tags: + - Go + - golang + - go语言基础 + - 面试题 + - 基础面试题 +--- + +# 基础面试题 + +## 1. 与其他语言相比,使用 Go 有什么好处? + +* 与其他语言不同,Go 代码的设计是务实的,Go的语法更简洁。每个功能和语法决策都旨在让程序员的开发效率更高 + +* Golang 针对并发进行了优化,支持协程,并且实现了高效的GMP调度模型。 + +* 由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。 + +* 有高效的垃圾回收机制,支持并行垃圾回收,垃圾回收效率比比 Java 或 Python 更高 + +## 2. 什么是协程? + +协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。 + +## 3. 协程和线程和进程的区别? + +* 进程:进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。 + +* 线程:线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。 + +* 协程:协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。 + +## 4. Golang 中 make 和 new 的区别? + +**回答:** + +`make` 和 `new` 都是用于内存分配的内建函数,但它们的使用场景和功能有所不同: + +1. **`make`**: + + * 用于初始化并分配内存,只能用于创建 `slice`、`map` 和 `channel` 三种类型。 + + * 返回的是初始化后的数据结构,而不是指针。 + +2. **`new`**: + + * 用于分配内存,但不初始化,返回的是指向该内存的指针。 + + * 可以用于任何类型的内存分配。 + +**分析:** + +```go +// 使用 make 创建 slice +s := make([]int, 5) // 创建一个长度为 5 的 slice +fmt.Println(s) // 输出: [0 0 0 0 0]// 使用 new 创建 int 指针 +p := new(int) // 分配内存给 int 类型 +fmt.Println(*p) // 输出: 0 (初始值) +``` + +`make` 函数创建的是数据结构(`slice`、`map`、`channel`)本身,且返回初始化后的值。而`new` 函数创建的是可以指向任意类型的指针,返回指向未初始化零值的内存地址。 + +## 5. Golang 中数组和切片的区别? + +**数组: ** + +数组固定长度。数组长度是数组类型的一部分,所以\[3]int 和\[4]int 是两种不 同的数组类型数组需要指定大小,不指定也会根据初始化,自动推算出大小, 大小不可改变。数组是通过值传递的 +**切片: ** + +切片可以改变长度。切片是轻量级的数据结构,三个属性,指针,长度,容量 不需要指定大小切片是地址传递(引用传递)可以通过数组来初始化,也可以通过内置函数 make()来初始化,初始化的时候 len=cap,然后进行扩容 + +**分析:** + +slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。 + +```go +// runtime/slice.go +type slice struct { + array unsafe.Pointer // 元素指针 + len int // 长度 + cap int // 容量 +} +``` + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/基础面试题/image.png) + + +## 6. 使用for range 的时候,它的地址会发生变化吗? + +在Go1.22之前,对于 `for range` 循环中的迭代变量,其内存地址是不会发生变化的。但是,Go1.22之后的地址是临时的,是变化的,不一样的,不再是共享内存了 + +**分析:** + +Go1.22之前: + +```go +for index, value := range collection { + // ... +} +``` + +这里 `value` 是一个**副本**。在每次迭代中,`collection` 中的当前元素值会被**复制**到 `value` 这个变量中。Go 编译器通常会为 `value` 分配一块固定的内存地址,然后在每次迭代时,将当前元素的值覆盖到这块内存中。所以,当你打印 `&value` 时,你会发现它的内存地址在整个循环过程中都是保持不变的。 + +但是在Go1.23及以后,使用 `for range` 遍历一个集合时,**迭代变量的地址会发生变化**。这是因为 `for range` 每次迭代时都会重新生成迭代变量(如 `value`),这些变量在内存中是不同的地址 + +## 7. 如何高效地拼接字符串? + +拼接字符串的方式有:`+` , `fmt.Sprintf` , [`strings.Builder`](https://zhida.zhihu.com/search?content_id=192906787\&content_type=Article\&match_order=1\&q=strings.Builder\&zhida_source=entity), [`bytes.Buffer`](https://zhida.zhihu.com/search?content_id=192906787\&content_type=Article\&match_order=1\&q=bytes.Buffer\&zhida_source=entity), `strings.Join` + +1. "+" + +使用`+`操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。 + +2. fmt.Sprintf + +由于采用了接口参数,必须要用反射获取值,因此有性能损耗。 + +3. strings.Builder: + +用WriteString()进行拼接,内部实现是指针+切片,同时String()返回拼接后的字符串,它是直接把\[]byte转换为string,从而避免变量拷贝。 + +4. bytes.Buffer + +`bytes.Buffer`是一个一个缓冲`byte`类型的缓冲器,这个缓冲器里存放着都是`byte`, + +`bytes.buffer`底层也是一个`[]byte`切片。 + +5. strings.join + +`strings.join`也是基于`strings.builder`来实现的,并且可以自定义分隔符,在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。 + +**性能比较:** + +strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf + +5种拼接方法的实例代码 + +```go +func main(){ + a := []string{"a", "b", "c"} + //方式1:+ + ret := a[0] + a[1] + a[2] + //方式2:fmt.Sprintf + ret := fmt.Sprintf("%s%s%s", a[0],a[1],a[2]) + //方式3:strings.Builder + var sb strings.Builder + sb.WriteString(a[0]) + sb.WriteString(a[1]) + sb.WriteString(a[2]) + ret := sb.String() + //方式4:bytes.Buffer + buf := new(bytes.Buffer) + buf.Write(a[0]) + buf.Write(a[1]) + buf.Write(a[2]) + ret := buf.String() + //方式5:strings.Join + ret := strings.Join(a,"") +} +``` + +## 8. defer 的执行顺序是怎样的?defer 的作用或者使用场景是什么? + +defer执行顺序和调用顺序相反,类似于栈后进先出(LIFO) + +defer 的作用是:当 defer 语句被执行时,跟在 defer 后面的函数会被延迟执行。直到 包含该 defer 语句的函数执行完毕时,defer 后的函数才会被执行,不论包含 defer 语句的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。 你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。 + +defer 的常用场景: + +* defer语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、 加锁、释放锁。 + +* 通过defer机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资 源被释放。 + +* 释放资源的defer应该直接跟在请求资源的语句后。 + +**分析:** + +```go +func test() int { + i := 0 + defer func() { + fmt.Println("defer1") + }() + defer func() { + i += 1 + fmt.Println("defer2") + }() + return i +} + +func main() { + fmt.Println("return", test()) +} + +// 输出: +// defer2 +// defer1 +// return 0 +``` + +上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值`func test() (i int)`) + +```go +func test() (i int) { + i = 0 + defer func() { + i += 1 + fmt.Println("defer2") + }() + return i +} + +func main() { + fmt.Println("return", test()) +} +// 输出: +// defer2 +// return 1 + +``` + +这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。 + +## 9. 什么是 rune 类型? + +Go 语言的字符有以下两种: + +* uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。 + +* rune 类型,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型等价于 int32 类型。 + +```go +package main +import "fmt" + +func main() { + var str = "hello 你好" //思考下 len(str) 的长度是多少? + + //golang中string底层是通过byte数组实现的,直接求len 实际是在按字节长度计算 + //所以一个汉字占3个字节算了3个长度 + fmt.Println("len(str):", len(str)) // len(str): 12 + + //通过rune类型处理unicode字符 + fmt.Println("rune:", len([]rune(str))) //rune: 8 +} +``` + +## 10. Go 语言 tag 有什么用? + +tag可以为结构体成员提供属性。常见的: + +1. json序列化或反序列化时字段的名称 + +2. db: sqlx模块中对应的数据库字段名 + +3. form: gin框架中对应的前端的数据字段名 + +4. binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端 + +## 11. go 打印时 %v %+v %#v 的区别? + +* %v 只输出所有的值; + +* %+v 先输出字段名字,再输出该字段的值; + +* %#v 先输出结构体名字值,再输出结构体(字段名字+字段的值); + +```go +package main +import "fmt" + +type student struct { + id int32 + name string +} + +func main() { + a := &student{id: 1, name: "微客鸟窝"} + + fmt.Printf("a=%v \n", a) // a=&{1 微客鸟窝} + fmt.Printf("a=%+v \n", a) // a=&{id:1 name:微客鸟窝} + fmt.Printf("a=%#v \n", a) // a=&main.student{id:1, name:"微客鸟窝"} +} +``` + +## 12. Go语言中空 struct{} 占用空间么? + +可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数,空struct{}不占用任何空间 + +```go +package main + +import ( + "fmt" + "unsafe" +) + +func main() { + fmt.Println(unsafe.Sizeof(struct{}{})) //0 +} +``` + +## 13. Go语言中,空 struct{} 有什么用? + +* 用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。 + +```go +type Set map[string]struct{} + +func main() { + set := make(Set) + + for _, item := range []string{"A", "A", "B", "C"} { + set[item] = struct{}{} + } + fmt.Println(len(set)) // 3 + if _, ok := set["A"]; ok { + fmt.Println("A exists") // A exists + } +} +``` + +* 有时候给通道发送一个空结构体,channel<-struct{}{},可以节省空间 + +```go +func main() { + ch := make(chan struct{}, 1) + go func() { + <-ch + // do something + }() + ch <- struct{}{} + // ... +} +``` + +* 表示仅有方法的结构体 + +```go +type Lamp struct{} +``` + +## 14. init() 函数是什么时候执行的? + +**简答:** 在main函数之前执行。 + +**详细:**init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。 + +每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的`init()`函数。同一个包,甚至是同一个源文件可以有多个`init()`函数。`init()`函数没有入参和返回值,不能被其他函数调用,同一个包内多个`init()`函数的执行顺序不作保证。 + +执行顺序:import –> const –> var –>`init()`–>`main()` + +一个文件可以有多个`init()`函数! + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/基础面试题/image-1.png) + +## 15. 2 个 interface 可以比较吗 ? + +Go 语言中,interface 的内部实现包含了 2 个字段,类型 `T` 和 值 `V`,interface 可以使用 `==` 或 `!=` 比较。2 个 interface 相等有以下 2 种情况 + +1. 两个 interface 均等于 nil(此时 V 和 T 都处于 unset 状态) + +2. 类型 T 相同,且对应的值 V 相等。 + +```go +type Stu struct { + Name string +} + +type StuInt interface{} + +func main() { + var stu1, stu2 StuInt = &Stu{"Tom"}, &Stu{"Tom"} + var stu3, stu4 StuInt = Stu{"Tom"}, Stu{"Tom"} + fmt.Println(stu1 == stu2) // false + fmt.Println(stu3 == stu4) // true +} +``` + +`stu1` 和 `stu2` 对应的类型是 `*Stu`,值是 Stu 结构体的地址,两个地址不同,因此结果为 false。 +`stu3` 和 `stu4` 对应的类型是 `Stu`,值是 Stu 结构体,且各字段相等,因此结果为 true。 + +## 16. 2 个 nil 可能不相等吗? + +可能不等。interface在运行时绑定值,只有值为nil接口值才为nil,但是与指针的nil不相等。举个例子: + +```go +var p *int = nil +var i interface{} = nil +if(p == i){ + fmt.Println("Equal") +} +``` + +两者并不相同。总结:两个nil只有在类型相同时才相等。 + +## 17. Go 语言函数传参是值类型还是引用类型? + +* 在 Go 语言中只存在值传递,要么是值的副本,要么是指针的副本。无论是值类型的变量还是引用类型的变量亦或是指针类型的变量作为参数传递都会发生值拷贝,开辟新的内存空间。 + +* 另外值传递、引用传递和值类型、引用类型是两个不同的概念,不要混淆了。引用类型作为变量传递可以影响到函数外部是因为发生值拷贝后新旧变量指向了相同的内存地址。 + +# 18. 如何知道一个对象是分配在栈上还是堆上? + +Go和C++不同,Go局部变量会进行逃逸分析。如果变量离开作用域后没有被引用,则优先分配到栈上,否则分配到堆上。那么如何判断是否发生了逃逸呢? + +`go build -gcflags '-m -m -l' xxx.go`. + +关于逃逸的可能情况:变量大小不确定,变量类型不确定,变量分配的内存超过用户栈最大值,暴露给了外部指针。 + +## 19. Go语言的多返回值是如何实现的? + +Go 语言的多返回值是通过在函数调用栈帧上预留空间并进行**值复制**来实现的。在函数调用发生时,Go 编译器会计算出函数所有返回值的总大小。在为该函数创建**栈帧**时,就会在调用方(caller)的栈帧上,为这些返回值预留出连续的内存空间。 + +当函数执行到 `return` 语句时,它会将其要返回的各个值**复制**到这些预留好的栈空间中。函数执行完毕后,控制权返回给调用方。此时,调用方可以直接从它自己的栈帧上(即之前为返回值预留的空间)获取这些返回的值。 + +![](https://golangstar.cn/assets/img/go语言系列/go面试题库/基础面试题/image-2.png) + +## 20. Go语言中"\_"的作用 + +1. 忽略多返回值:在 Go 语言中,函数可以返回多个值。如果你只关心其中的一部分返回值,而不需要使用其余的,就可以用 `_` 来忽略它们,从而避免编译器报错 + +2. 当你导入一个包时,通常会使用它的某个功能。但有时你可能只想执行包的 `init()` 函数(例如,注册驱动、初始化全局变量等),而不需要直接使用包中的任何导出成员。这时,你就可以使用 `_` 来进行**匿名导入** + +示例: + +```go +package main + +import ( + "fmt" + _ "net/http/pprof" // 导入 pprof 包,只为了执行其 init 函数注册 profiling 接口 +) + +func main() { + fmt.Println("Application started. Profiling tools are likely registered.") + // 实际应用中,你可能还会启动一个 HTTP 服务器来暴露 pprof 接口 + // go func() { + // log.Println(http.ListenAndServe("localhost:6060", nil)) + // }() +} +``` + +## 21. Go语言普通指针和unsafe.Pointer有什么区别? + +普通指针比如`*int`、`*string`,它们有明确的类型信息,编译器会进行类型检查和垃圾回收跟踪。不同类型的指针之间不能直接转换,这是Go类型安全的体现。 + +而**unsafe.Pointer**是Go的通用指针类型,可以理解为C语言中的`void*`,它绕过了Go的类型系统。unsafe.Pointer可以与任意类型的指针相互转换,也可以与uintptr进行转换来做指针运算。 + +另外,通指针受GC管理和类型约束,unsafe.Pointer不受类型约束但仍受GC跟踪 + +## 22. unsafe.Pointer与uintptr有什么区别和联系 + +unsafe.Pointer和uintptr可以相互转换,这是Go提供的唯一合法的指针运算方式。典型用法是先将unsafe.Pointer转为uintptr做算术运算,然后再转回unsafe.Pointer使用。 + +最关键的区别在于**GC跟踪**。unsafe.Pointer会被垃圾回收器跟踪,它指向的内存不会被错误回收;而uintptr只是一个普通整数,GC完全不知道它指向什么,如果没有其他引用,对应内存可能随时被回收。 + +所以记住:unsafe.Pointer有GC保护,uintptr没有,这是它们最本质的区别。