diff --git a/hzh/GolangStar/Go编码规范/Go编码规范.md b/hzh/GolangStar/Go编码规范/Go编码规范.md index 27dff01..51dcf93 100644 --- a/hzh/GolangStar/Go编码规范/Go编码规范.md +++ b/hzh/GolangStar/Go编码规范/Go编码规范.md @@ -1,941 +1,264 @@ --- -tags: - - Go - - golang - - go编码规范 - - 程序格式化 +tags: [go, golang, code-style, coding-standard] +create time: 2026-06-07 14:30 --- -# Go编码规范 +# Go 编码规范 -## 1.代码规范 +## 概述 -代码范旨在为日常Go项目开发提供一个代码的规范指导,方便形成一个良好的代码,提高代码的可读性,规范性和统一性。 +本文档整理自企业级 Go 项目开发规范,涵盖代码格式化、命名约定、错误处理、注释规范等核心方面。良好的编码规范是团队协作的基石,也是区分初级和中级开发者的关键标志。 -## 2. 代码风格 +> [!question] ❓ 思考一下 +> 在编写 Go 代码之前,你是否考虑过:为什么 Go 要强制 `gofmt` 而不像其他语言那样提供多种风格选择?这种"一刀切"的设计背后体现了怎样的哲学? -### 2.1 【必须】格式化 +## 正文 -* 代码都必须用 `gofmt` 格式化。 +### 1. 代码格式化 -### 2.2 【推荐】换行 +#### 1.1 【必须】使用 gofmt 格式化 -* 建议一行代码不要超过`120列`,超过的情况,使用合理的换行方法换行。 +所有 Go 代码必须使用 `gofmt` 格式化,这是 Go 社区的硬性要求。 -* 例外场景: +> [!tip] 💡 面试技巧 +> 面试中如果被问到"Go 如何保证代码风格统一",可以回答:Go 通过内置的 `gofmt` 工具强制统一格式,不需要像 Java(Google Style / Alibaba Style)或 JavaScript(Prettier / ESLint)那样额外配置,从语言层面杜绝了代码风格的争论。 - * 函数签名(当然,这可能意味着你需要重新考虑是否传递了过多参数) +#### 1.2 【推荐】行宽控制 - ```go - // 长的函数签名可以超过列数限制 - func (i *webImpl) GenerateAgentInstallLink(ctx context.Context, req *pb.GenerateAgentInstallLinkRequest) (*pb.GenerateAgentInstallLinkResponse, error) { - ... - } +建议一行代码不超过 **120 列**,超长时合理换行。 - // 不要在函数签名中为了满足推荐列数换行! - 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` 默认最少分成本地包和第三方包两大类,这两类包必须分开不能放在一起。本地包或者第三方包内部可以继续按实际情况细分不同子类。 - -* 不要使用相对路径引入包: +> [!warning] ⚠️ 高频陷阱 +> 函数签名**不要**为了凑列数而换行!`gofmt` 会使签名与函数内语句对齐,反而降低可读性。 ```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 (i *webImpl) GenerateAgentInstallLink(ctx context.Context, req *pb.GenerateAgentInstallLinkRequest) (*pb.GenerateAgentInstallLinkResponse, error) { + // ... } -// 要采用下面的方式 -func do() (int, error) { +// 错误的长字符串拼接 —— 不要这样做! +pubkey := "ssh-rsa AAAAB3NzaC1yc2E..." + + "zi2SqaZVeeXmsF5GAGFJcUylujr78Wf6od8//SApYx8RCSkRhGo8cTsxADlBCoTttJvk6Ocmy+uqEFXulsI0j+nh2x352eCExlDSqr0Me0J0LIGq/u9eqwhNN5k" -} +// 正确做法:多行文本用原始字符串字面量 +tmpl := `some +long +tedious +template` ``` -* 错误描述不需要标点结尾。 +### 2. Import 规范 -* 采用独立的错误流进行处理。 +#### 2.1 【必须】使用 goimports + +- 标准包永远在最上面的第一组 +- 内部包与第三方包之间用空行分隔 +- 不使用相对路径引入包 ```go -// 不要采用这种方式 -if err != nil { +import ( + // standard package & inner package + "encoding/json" + "myproject/models" + "strings" + + // third-party package + "github.com/opentracing/opentracing-go" + + // anonymous import package + // import filesystem storage driver + _ "git.code.oa.com/org/repo/pkg/storage/filesystem" +) +``` + +> [!note] 📝 核心考点 +> - 带域名的包名都属于第三方包(如 `github.com/xxx/xxx`),无论是否当前项目内部 +> - 匿名导入 `_` 通常用于执行包的 `init()` 函数(如注册驱动) + +### 3. 错误处理 + +#### 3.1 【必须】error 作为最后一个返回值 + +```go +// 正确 +func do() (int, error) { ... } + +// 错误 +func do() (error, int) { ... } +``` + +#### 3.2 【必须】独立错误流 + +```go +// 推荐:尽早 return +if err != nil { + return err +} +// normal code here + +// 不推荐:else 嵌套 +if err != nil { // error handling -} else { +} else { // normal code } - -// 而要采用下面的方式 -if err != nil { - // error handling - return // or continue, etc. - } - // normal code ``` -* 如果返回值需要初始化,则采用下面的方式: +#### 3.3 【必须】panic 的使用场景 + +> [!warning] ⚠️ 高频陷阱 +> 不要用 `panic` 处理用户输入错误!`panic` 仅用于: +> 1. 不变量断言(invariant assertion) +> 2. `init()` 函数中初始化失败 +> 3. 全局变量初始化中调用 `MustXXX` 系列函数 ```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都为空时,函数的调用者会出现错误的调用逻辑 +// 正确:对不变量断言 +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)) + } } -// 应当使用如下方式: -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 -// 不要对用户输入进行断言,使用错误返回! +// 错误:用户输入应该返回 error v, err := strconv.Atoi(userInputFromKeyboard) -if err != nil { - panic(fmt.Errorf("invalid user input: %v", err)) +if err != nil { + panic(fmt.Errorf("invalid user input: %v", err)) // DON'T! } +``` -// 可以对不变量进行断言// 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)) +#### 3.4 【必须】recover 的正确姿势 + +```go +defer func() { + e := recover() // 注意:返回 interface{},不要命名为 err + if e != nil { + err, ok := e.(FatalError) + if !ok { + panic(e) // 继续抛出不认识的异常 + } + // 处理已知类型的 panic } -} +}() ``` -* 在 `func init()` 及其调用的函数中,当初始化失败影响程序运行时,可以 `panic`,例如:读取本地文件系统中的配置文件。 +### 4. 命名规范 -* 在全局变量初始时调用的函数中,当初始化失败影响程序运行时,可以 `panic`,例如:`regexp.MustCompile`,`template.Must`。 +> [!tip] 💡 面试技巧 +> 面试中被问"go 命名规范"时,可以总结为:**驼峰式 + 首字母大小写控制可见性 + 专有名词保持原写法**。举例说明 `apiClient`(私有)vs `APIClient`(导出)。 -* 导出的方法一般不允许 `panic`。因特殊情况必须 `panic` 的方法应使用 `MustXXX` 的方式进行命名,并在文档中加以说明。 +| 规则 | 要点 | +|------|------| +| 包名 | 小写、简短、不与标准库冲突、避免 util/common/misc | +| 文件名 | 小写 + 下划线分割 | +| 结构体 | 驼峰、名词短语、避免 Info/Data | +| 接口 | 单方法以 `-er` 结尾(Reader/Writer) | +| 变量/常量 | 驼峰、短命名优先(局部变量 c > lineCount) | +| 函数 | 驼峰、首字母大小写控制导出 | -* 不建议使用 `log.Fatal` 进行断言:log 库必须初始化才可以使用;log 库可能无法正确显示堆栈信息;log 库在 `panic` 时可能无法有效的保留错误信息。 +### 5. 控制结构 -#### 2.5.3 【必须】recover 处理 - -* 必须在 `defer` 中使用。 - -* 在业务逻辑中一般不需要使用 `recover`。 - -* `recover` 用于捕获具有明确类型的 `panic`,禁止滥用 `recover` 捕获全部类型的异常。 +#### 5.1 【推荐】if 初始化语句 ```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 { +if err := file.Chmod(0664); err != nil { return err } ``` -* `if` 对两个值进行判断时,约定如下顺序:变量在左,常量在右: +#### 5.2 【必须】switch 必须有 default ```go -// 不要采用这种方式 -if nil != err { - // error handling -} - -// 不要采用这种方式 -if 0 == errorCode { - // do something -} - -// 而要采用下面的方式 -if err != nil { - // error handling -} - -// 而要采用下面的方式 -if errorCode == 0 { - // do something +switch os := runtime.GOOS; os { +case "darwin": + fmt.Println("OS X.") +case "linux": + fmt.Println("Linux.") +default: + fmt.Printf("%s.\n", os) } ``` -* `if` 对于bool类型的变量,应直接进行真假判断: +#### 5.3 【必须】range 丢弃不需要的值 ```go -var allowUserLogin bool - -// 不要采用这种方式 -if allowUserLogin == true { - // do something +for key := range m { // 只需要 key + delete(m, key) } - -// 不要采用这种方式 -if allowUserLogin == false { - // do something -} - -// 而要采用下面的方式 -if allowUserLogin { - // do something -} - -// 而要采用下面的方式 -if !allowUserLogin { - // do something +for _, v := range slice { // 只需要 value + _ = v } ``` -## 5.2 【推荐】for +### 6. 注释规范 -* 采用短声明建立局部变量: +> [!question] ❓ 思考一下 +> Go 要求每个导出的名字都必须有注释,但非导出类型的方法可以没有。你觉得这种设计反映了什么考量? + +**必填注释清单:** +- 包注释(`// Package xxx`) +- 导出的结构体和接口 +- 导出的函数和方法 +- 导出的常量和变量 +- 导出的类型定义和类型别名 + +### 7. 函数设计 + +| 规则 | 约束 | +|------|------| +| 参数数量 | 不超过 5 个 | +| 传递方式 | 优先值传递,非指针传递 | +| map/slice/chan/interface | 不要传指针 | +| 文件长度 | 不超过 800 行 | +| 函数长度 | 不超过 80 行 | +| 嵌套深度 | 不超过 4 层 | + +### 8. defer 使用 + +> [!warning] ⚠️ 高频陷阱 +> **禁止在循环中使用 `defer`!** 因为 defer 要到函数结束时才统一调用,会导致资源堆积。 ```go -sum := 0 -for i := 0; i < 10; i++ { - sum += 1 +// 错误:循环中 defer +for _, v := range values { + fields, _ := db.Query(v) + defer fields.Close() // 所有 Close 直到函数结束才执行! +} + +// 正确:闭包内 defer +for _, v := range values { + func() { + fields, _ := db.Query(v) + defer fields.Close() // 立即关联到当前迭代 + }() } ``` -## 5.3 【必须】range +### 9. 依赖管理 -* 如果只需要第一项(key),就丢弃第二个: +- Go 1.11+ 必须使用 `go modules` +- `go.sum` 必须提交,不要加入 `.gitignore` +- 不建议提交 `vendor` 目录 -```go -for key := range m { - if key.expired() { - delete(m, key) - } -} -``` +--- -* 如果只需要第二项,则把第一项置为下划线: +## 附录:常用工具速查表 -```go -sum := 0 -for _, value := range array { - sum += value -} -``` +> [!tip] 💡 工具速查 +> | 工具 | 作用 | +> |------|------| +> | `gofmt` | 自动格式化代码,保证格式统一 | +> | `goimports` | 在 gofmt 基础上自动增删 import | +> | `go vet` | 静态分析,检测多余代码、提前 return 等 | +> | `golint` | 检测不规范的地方(已迁移至 `staticcheck`) | -## 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` 的工具,主要功能就是检测代码中不规范的地方。 +- [[hzh/GolangStar/Go环境搭建/Go环境搭建]] — 环境搭建配合 lint 工具使用 +- [[hzh/GolangStar/Go面试题库/基础面试题]] — 编码规范相关面试题 +- [[hzh/GolangStar/Go语言前景/Go语言前景]] — Go 的语言优势之一就是代码统一 diff --git a/hzh/GolangStar/Go语言前景/Go语言前景.md b/hzh/GolangStar/Go语言前景/Go语言前景.md index 0d0a727..840326e 100644 --- a/hzh/GolangStar/Go语言前景/Go语言前景.md +++ b/hzh/GolangStar/Go语言前景/Go语言前景.md @@ -1,84 +1,111 @@ -# Go语言前景 +--- +tags: [go, golang, career, market-analysis] +create time: 2026-06-07 14:30 +--- -当下BAT大厂正在把Go作为新项目开发的首选语言,今天给大家带来一个系统的go语言相关知识梳理,也希望能够在未来和大家一起学习和探讨。 +# Go 语言前景分析 -思考再三,为什么是go呢? +## 概述 -其实,选择go语言,也是顺应当下形势了,作为程序员的我们,在选择吃饭的家伙上,当然也要顺势而为嘛。下面就跟大家一起分析go语言到底有啥优势,未来趋势又如何? +本文从市场行情、应用场景、语言优势三个维度系统分析 Go 语言的现状与未来趋势,帮助读者理解「为什么选择 Go」。 -## 市场行情 +> [!question] ❓ 思考一下 +> 在决定学习一门新语言之前,你是否想过:什么因素决定了这门语言的市场价值?是岗位数量?薪资水平?还是未来的增长潜力? -首先来看一下go语言的市场行情,因为谈钱,永不过时。截止到2023年5月Tiobe编程语言排行榜 +## 正文 -![](https://golangstar.cn/assets/img/go语言前景/go语言前景1.png) +### 一、市场行情 -go语言是排在第12位,从排名上看是远不如传统的几大热门语言比如python,c,c++还有java。那为什么却又说go预言是趋势呢? -排名上不如上述几大热门语言,这正说明了当前go语言开发者比较少,不像java开发者在后端市场的白热化竞争,现在go语言开发者的竞争没有那么激烈。 -接下来看一下go语言岗位在市场上的需求 +#### TIOBE 排行榜对比 -![](https://golangstar.cn/assets/img/go语言前景/go语言前景2.png) +截止 2023 年 5 月,Go 语言排在第 12 位。排名看似不如 Python、C、C++、Java,但这恰恰说明了 **Go 开发者相对较少,竞争没那么激烈**。 -可以看到,go语言招聘市场的需求也是很大,大小中厂其实都有一定的岗位需求。 -当然不得不承认,现在整个后端市场可以说还是java的天下,在大中小公司都占据着绝对地位,像阿里, 京东,美团等,而且银行也基本全是java的技术栈。既然行情如此,为什么还说go语言有不错的市场前景呢? +> [!tip] 💡 逆向思维 +> 排名高不等于好就业。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呢? +- 大小中厂都有一定岗位需求 +- 大厂后端技术栈转型趋势明显 +- 银行等传统行业仍以 Java 为主 -![](https://golangstar.cn/assets/img/go语言前景/go语言前景3.png) +#### 大厂技术栈动向 -对于应届生而言,现阶段拥抱go语言可以说是再合适不过了,为啥这么说呢?go语言作为一门新型的开发语言,在大学里学生接触的可以说很少,除非是自己有这种自驱性。因为大学所教的内容跟社会需求往往是脱节的,大学所学的东西往往很难满足真实岗位的需要,一般大学,即便是计算机专业现在所开的也都还是两门主流语言c++和java。c++其实现在后台岗位很有限了,一般只有做游戏,量化开发或者系统开发需要,岗位相比于后端少了很多。java岗位竞争又大,而此时选择go语言其实是一个不错的选择。 +| 公司 | 技术栈 | 备注 | +|------|--------|------| +| 字节跳动 | 全 Go | 所有后端语言都是 Go | +| 腾讯 | IEG/WXG 用 C++,其他 BG 转向 Go | 正在往 Go 方向转型 | +| B站 | Go | 中厂代表 | +| 百度 | PHP 向 Go 转型 | 老牌互联网公司转型 | +| Shopee | Go | 新型互联网公司 | -## 应用场景 -说完go语言的整体市场行情,紧接着我们来看一下go语言具体的应用场景,这么多大小公司都在go语言上有所应用,那它究竟可以应用在哪些场景中呢? +### 二、应届生视角 -首先在传统后端这块,go语言是天然支持的,因为go语言的协程机制在处理高并发上有着天然的优势,所以在后端领域go语言必有一席之地。除了后端场景,近年来大火的云原生,容器技术也是go语言的用武之地,现在几乎所有的公司都是在做服务上云的,这里就少不了需要go语言的人才。还有近年来炒得很火爆的web3,区块链,go语言也是再合适不过。 +> [!question] ❓ 思考一下 +> 大学教的 C++ 和 Java,毕业后能直接用在岗位上吗?如果脱节,你该选择哪条路? -![](https://golangstar.cn/assets/img/go语言前景/go语言前景4.png) +- **C++**:岗位有限,主要集中在游戏、量化开发、系统开发 +- **Java**:岗位最多但也最卷,大厂基本要求 985/211 +- **Go**:作为新秀,大学接触少,自驱学习者有机会弯道超车 -## 语言优势 -go作为一门新型语言,能够脱颖而出,有这么多的应用场景,且大小中厂都在积极拥抱go语言,自然少不了它作为语言本身的巨大优势。 +### 三、应用场景 -### 语法简洁 -语法简单可以说是go语言最直接的一个优势了,Go 语言被称为“互联网时代的C语言”,从这个称呼就可以看出其语法是真的简单,相比于c++难度可以说是天壤之别,不像c++,要想真的掌握起码至少得半年甚至几年的学习周期。而go的学习路线就会平滑很多,学习周期也短,最多几个月就能完全掌握 +Go 语言的核心应用领域: -### 代码统一 -代码风格的统一可以说是go另一个天然优势,由于其简洁的语法,并且Go 语言提供了一套专门的格式化工具——go fmt,这就使得对于不同的开发者提交的代码风格都是统一的格式,加强了可读性 +```mermaid +graph LR + A["Go 语言应用"] --> B["后端服务"] + A --> C["云原生 / 容器"] + A --> D["区块链 / Web3"] + A --> E["CLI 工具"] + A --> F["微服务架构"] + + style A fill:#4CAF50,color:#fff + style B fill:#2196F3,color:#fff + style C fill:#FF9800,color:#fff + style D fill:#9C27B0,color:#fff +``` -### 效率高 -这里的效率既指开发效率,也指运行效率。go语言不仅有c语言级别的编译,运行效率,容易部署,同时也兼顾了python级别的开发效率,这使得在做业务开发的时候事半功倍,也是成为不少公司首选的主要原因 +| 场景 | 说明 | +|------|------| +| 后端服务 | 协程机制天然适合高并发 | +| 云原生/容器 | Docker、K8s 等核心工具均为 Go 编写 | +| 区块链/Web3 | 大量公链和 DeFi 项目使用 Go | +| CLI 工具 | 编译为单二进制文件,部署方便 | +| 微服务 | 启动快、内存占用低,适合微服务场景 | -## 学习路线 -整体来说,go语言简单易上手,学习起来路线比较平滑,学习的成本比起c++和java来说也是要低不少的。 +### 四、语言优势 -首先,了解Golang有哪些知识点,对整理路线有个把握,下面先通过一个golang学习指引图看一下学习go需要掌握哪些知识 +#### 1. 语法简洁 -![](https://golangstar.cn/assets/img/go语言前景/Go语言学习路线封面.png) +被称为"互联网时代的 C 语言",学习周期短,几个月即可完全掌握。 +#### 2. 代码统一 -根据这个golang学习路线图,从右上角开始顺时针45度走一圈,差不多就对go语言有了一个比较全面的学习,而这整个学习周期也不会太久,即便是0基础,两到三个月也基本能够走完这个学习路线,并且对go也会有一个相对比较深入的了解了 +`gofmt` 强制统一格式,不同开发者的代码风格一致,增强可读性。 -以上只是对go语言本身以及go在传统后台方面的学习,学完这些,下一步就可以接着学习容器相关的知识了,毕竟随着docker,k8s的兴起,现在正处在云时代,万物皆可上云,掌握一些云原生相关的知识,在这个时代也是不可获取的。 +#### 3. 效率高 + +兼顾 C 级别的运行效率和 Python 级别的开发效率,业务开发事半功倍。 + +--- + +## 学习路线建议 + +> [!tip] 💡 学习路径 +> 整体学习周期约 2-3 个月(零基础),建议按以下顺序推进: +> +> 1. 先掌握基础语法(变量、类型、控制流、函数) +> 2. 再攻克并发编程(Goroutine、Channel、Sync) +> 3. 然后深入原理(GMP、内存管理、GC) +> 4. 最后通过面试题库检验学习效果 ## 小结 -随着云时代的到来,Golang的地位将会越来越高,越来越成为程序员必会的编程语言之一,在未来,golang成为和java分庭抗礼的大众化编程语言也是大势所趋。所以,选择go语言,学习go语言实在是太有必要了,我也将会在后面和大家一起学习和探索go语言相关知识。 - - - - - - - - - - - - - - - - - +随着云时代的到来,Go 的地位将持续上升,有望成为和 Java 分庭抗礼的大众化编程语言。选择 Go,就是选择了一条竞争较小、前景广阔的技术路线。 +## 关联笔记 +- [[hzh/GolangStar/Index]] — 完整学习路线图 +- [[hzh/GolangStar/Go环境搭建/Go环境搭建]] — 动手前准备好环境 +- [[hzh/GolangStar/Go面试题库/基础面试题]] — "与其他语言相比,使用 Go 有什么好处?" diff --git a/hzh/GolangStar/Go语言单测/Go语言单测.md b/hzh/GolangStar/Go语言单测/Go语言单测.md index b6db3e9..de83cc0 100644 --- a/hzh/GolangStar/Go语言单测/Go语言单测.md +++ b/hzh/GolangStar/Go语言单测/Go语言单测.md @@ -1,1543 +1,311 @@ --- -tags: - - Go - - golang - - go单测 - - 单元测试 +tags: [go, golang, 单元测试, testing, Mock] +create time: 2026-06-07 15:30 --- -# Go单元测试 +# Go 单元测试 +## 概述 -## 1. 什么是单元测试 +单元测试是保障代码质量的基石。Go 内置了 `testing` 包和 `go test` 命令,配合丰富的第三方生态(GoConvey、testify、GoMock),形成了一套完整的测试体系。本文从基础到高级覆盖所有核心技能。 -单元测试是软件开发中的一种测试方法,旨在验证代码中最小可测试单元(如函数、方法、类)的行为是否符合预期,它是开发流程的重要组成部分。单元测试的目标是发现代码中的缺陷和错误,并确保代码的正确性和稳定性。 +## 正文 -Go语言中自带有一个轻量级的测试框架testing和自带的go test命令来实现单元测试和性能测试。在包目录内,以\_test.go为后缀名的源文件都是go test的一部分,而不是go build的构建部分。 +### 为什么写测试? -## 2. Go单元测试命名规范 +> [!question] 💭 思考 +> 你花了三天写完一个函数,提交前觉得"应该没问题"——但上线后第一个用户就遇到了 panic。如果写了单测呢? -1. 在 Go 中,测试文件的命名规则非常重要。测试文件**必须以 `_test.go` 结尾**,否则 Go 的测试框架在执行时将不会识别这些文件。 +单元测试的核心价值: +1. **防回归**:修改旧代码时自动验证已有功能未被破坏 +2. **设计指导**:可测试的代码通常意味着清晰的接口边界 +3. **文档作用**:测试用例是最准确的"这个函数应该怎么用"的说明 +4. **信心保障**:重构时敢放手改,因为测试会捕获遗漏 -举个例子,如果你的主程序文件名是 `hello.go`,那么对应的测试文件应命名为 `hello_test.go`。 +> [!warning] ⚠️ 常见误区 +> - "项目太急没时间写测试"——短期省下的时间会在后期 bug 修复中加倍偿还 +> - "测试代码不需要维护"——烂测试比没测试更糟糕(假阳性让人失去信任) +> - "100% 覆盖率 = 好软件"——覆盖率为零的项目一定有问题,但 100% 不等于没有 bug -注意: +### 命名规范与目录结构 -* 测试方法名以`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) +``` +myproject/ +├── gotest/ +│ ├── example.go # 被测试代码 +│ └── example_test.go # 测试代码(必须以 _test.go 结尾) ``` -注意:`Xxx`可以是任何字母数字字符串,但第一个字母不能是小写字母。在这些测试函数中,可以使用`Error`、`Fail`等方法来指示测试失败。 +| 规则 | 说明 | +|------|------| +| 文件名 | `*_test.go` —— 只有这个后缀的文件才会被 `go test` 识别 | +| 测试函数 | `func TestXxx(t *testing.T)` — Xxx 首字母不能是小写 | +| 基准测试 | `func BenchmarkXxx(b *testing.B)` | +| 示例函数 | `func ExampleXxx()` — 带输出注释的示例 | -要创建一个新的测试套件,需要创建一个名称以`_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 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) + result := Factorial(5) + if result != 120 { + t.Errorf("Factorial(5) = %d; want 120", result) } } ``` -在gotest目录下执行`go test .`,输出: +运行: -```go -➜ gotest go test . -ok hello1/gotest 0.332s +```bash +$ go test -v +=== RUN TestFactorial +--- PASS: TestFactorial (0.00s) +PASS ``` -注意:➜ gotest表示是在gotest执行的后面的命令。上述当输入为`inut`时,结果实际结果`actual`和预期结果`expected`相等,表示测试通过。如果我们将`Factorial`函数修改为错误的实现: +### 表格驱动测试(Table-Driven Tests) -```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`函数改为最初正确的形式,再次测试 +> [!tip] 💡 Go 测试的黄金模板 +> 表格驱动测试是 Go 社区最推崇的测试模式——一条用例定义 + 一个循环覆盖,简洁且易扩展。 ```go func TestFactorial(t *testing.T) { - var factorialTests = []struct { - input int // 输入值 - expected int // 预期结果 + tests := []struct { + name string + input int + expected int }{ - {0, 1}, - {1, 1}, - {2, 2}, - {3, 6}, - {4, 24}, - {5, 120}, - {6, 720}, + {"zero", 0, 1}, + {"one", 1, 1}, + {"five", 5, 120}, + {"ten", 10, 3628800}, } - for _, tt := range factorialTests { - actual := Factorial(tt.input) - if actual != tt.expected { - t.Errorf("Factorial(%d) = %d; expected %d", tt.input, actual, tt.expected) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { // 子测试,可单独运行 + result := Factorial(tt.input) + if result != tt.expected { + t.Errorf("Factorial(%d) = %d; want %d", tt.input, result, tt.expected) + } + }) } } ``` -程序输出 +运行结果: -```go -➜ gotest go test . -ok hello1/gotest 0.345s +```bash +=== RUN TestFactorial +=== RUN TestFactorial/zero +=== RUN TestFactorial/one +=== RUN TestFactorial/five +=== RUN TestFactorial/ten +--- PASS: TestFactorial (0.00s) ``` -Go自带testing包的更多用法可以参考[Go标准库文档](https://golang.org/pkg/testing/)。 +> [!tip] 💡 可复用的表格驱动模板 +> ```go +> func TestName(t *testing.T) { +> type args struct { /* 参数结构 */ } +> tests := []struct { +> name string +> args args +> want 返回类型 +> wantErr bool +> }{ +> {"正常情况", args{...}, 期望值, false}, +> {"边界情况", args{...}, 期望值, false}, +> {"错误情况", args{...}, 期望值, true}, +> } +> for _, tt := range tests { +> t.Run(tt.name, func(t *testing.T) { +> got, err := FunctionName(tt.args.xxx) +> if (err != nil) != tt.wantErr { +> t.Errorf("error state = %v", err) +> return +> } +> if got != tt.want { +> t.Errorf("got = %v, want %v", got, tt.want) +> } +> }) +> } +> } +> ``` -## 5. GoConvey - -GoConvey适用于编写单元测试用例,并且可以兼容到testing框架中。可以通过`go test`命令或使用`goconvey`命令访问`localhost:8080`的Web测试界面来查看测试结果。GoConvey的基本用法如下: +### GoConvey — BDD 风格测试 ```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) + Convey("给定两个切片,比较它们是否相等", t, func() { + Convey("内容相同时", func() { + So(SlicesEqual([]int{1,2,3}, []int{1,2,3}), ShouldBeTrue) + }) + Convey("长度不同时", func() { + So(SlicesEqual([]int{1,2}, []int{1,2,3}), ShouldBeFalse) + }) + Convey("元素不同时报错", func() { + So(SlicesEqual([]int{1,3}, []int{1,2}), ShouldBeFalse) }) }) } ``` -`Patch`是`Monkey`提供的函数打桩API: +> [!note] 📝 断言速查 +> | 断言 | 含义 | +> |------|------| +> | `ShouldBeTrue / ShouldBeFalse` | 布尔值判断 | +> | `ShouldEqual / ShouldNotEqual` | 相等性判断 | +> | `ShouldBeNil / ShouldNotBeNil` | nil 检查 | +> | `ShouldContain / ShouldNotContain` | 集合包含检查 | -1. 第一个参数是目标函数 +### Stub / Mock 框架 -2. 第二个参数是桩函数,通常使用匿名函数或闭包 +> [!question] 💭 思考 +> 要测试一个调用数据库的函数,难道每次都要启动真实的数据库吗? -3. 返回值是PatchGuard对象指针,用于在测试结束时移除补丁 +测试的核心原则:**隔离被测单元**。外部依赖(DB、网络、文件系统)应被替换为可控的替代品。 -#### 6.5.2 为过程打桩 +#### Stub vs Mock 的区别 -对于没有返回值的函数(过程),打桩代码如下: +| 概念 | 目的 | 能力 | 复杂度 | +|------|------|------|--------| +| **Stub** | 提供固定返回值 | 只模拟"结果" | 低 | +| **Mock** | 验证交互过程 | 检查结果 + 调用次数 + 参数 + 顺序 | 高 | + +#### GoMock — 接口 Mock ```go -guard := Patch(CleanupResources, func() { - // 空实现或测试所需的行为 -}) -defer guard.Unpatch() -``` - -#### 6.5.3 为方法打桩 - -假设在分布式系统中,需要模拟从配置中心获取配置的行为: - -```go -type ConfigCenter struct { - // 字段... +// 1. 定义接口 +type DataStore interface { + Get(key string) ([]byte, error) + Set(key string, value []byte) error } -func (c *ConfigCenter) GetConfig(key string) (string, error) { - // 实际实现... - return "", nil -} -``` +// 2. 用 mockgen 生成 Mock +// mockgen -source=datastore.go -destination=mock_datastore.go -使用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控制器 +// 3. 在测试中使用 +func TestProcessor(t *testing.T) { ctrl := gomock.NewController(t) - // 创建Mock对象 - speaker := mock_domain.NewMockSpeaker(ctrl) - + defer ctrl.Finish() + + mockDS := NewMockDataStore(ctrl) + // 设置期望行为 - speaker.EXPECT().SayHello(gomock.Eq("张三"), gomock.Eq("实习生")).Return( - "你好,张三(角色:实习生),欢迎加入公司会议。我是会议主持人。") - - // 将Mock对象传入测试对象 - company := NewCompany(speaker) - - // 执行测试 - t.Log(company.Meeting("张三", "实习生")) + mockDS.EXPECT().Get("user_1").Return([]byte(`{"name":"Alice"}`), nil).Times(1) + mockDS.EXPECT().Set("cache_1", gomock.Any()).Return(nil).AnyTimes() + + proc := NewProcessor(mockDS) + result := proc.Process("user_1") + + // 验证期望是否满足 + // (ctrl 会自动检查) } ``` -### 7.3 系统内置函数Mock:Monkey - -使用Monkey可以mock系统内置函数,例如json.Unmarshal: +#### sqlmock — 数据库 Mock ```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) - } +func TestUserQuery(t *testing.T) { + db, mock, _ := sqlmock.New() 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) + AddRow(1, "alice"). + AddRow(2, "bob") + mock.ExpectQuery("SELECT.*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()) - } + results, _ := db.Query("SELECT id, username FROM users") + // ... 验证结果 // 验证所有期望都已满足 - if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("有未满足的期望: %v", err) - } + mock.ExpectationsWereMet() } ``` -### 7.5 服务器行为Mock - -使用net/http/httptest模拟HTTP服务器: +#### httptest — HTTP 服务器 Mock ```go -func TestHTTPRequest(t *testing.T) { - // 创建处理器 +func TestHandler(t *testing.T) { handler := func(w http.ResponseWriter, r *http.Request) { - io.WriteString(w, `{"status": "success", "data": {"message": "Hello World"}}`) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } - - // 创建请求和响应记录器 - req := httptest.NewRequest("GET", "/api/hello", nil) + + req := httptest.NewRequest("GET", "/api/test", nil) w := httptest.NewRecorder() - // 处理请求 handler(w, req) - - // 获取响应 + resp := w.Result() - body, _ := ioutil.ReadAll(resp.Body) + body, _ := io.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"` + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want %d", resp.StatusCode, http.StatusOK) } - - 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) // 验证结果处理无错误 + if !bytes.Contains(body, []byte(`"status"`)) { + t.Errorf("body missing 'status' field: %s", body) } } ``` -### 8.3 私聊功能测试 - -私聊功能涉及JSON编码和发送消息的底层操作(`WritePkg`函数),我们使用Monkey进行Mock: +### 基准测试(Benchmark) ```go -func TestMessageSender_SendPrivateMessage(t *testing.T) { - var conn net.Conn - transfer := &utils.Transfer{ - Conn: conn, +func BenchmarkFactorial(b *testing.B) { + for i := 0; i < b.N; i++ { + Factorial(10) } - - // 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 - +$ go test -bench=. -benchmem +BenchmarkFactorial-12 1000000000 0.29 ns/op 0 B/op 0 allocs/op ``` -可以发现,加上`-benchmem` 参数后,`BenchmarkAbs-8` 这行打印了更多输出内容: +> [!tip] 💡 基准测试进阶技巧 +> - `-benchmem`:显示内存分配信息 +> - `-benchtime=3s`:指定测试时长 +> - `b.ResetTimer()`:跳过初始化耗时,只计时核心逻辑 +> - `b.RunParallel(...)`:并行基准测试 -```go -BenchmarkAbs-12 1000000000 0.2932 ns/op 0 B/op -``` +### 最佳实践 Checklist -`0 B/op` 表示每次执行测试代码分配了多少字节内存。`0 allocs/op` 表示每次执行测试代码分配了多少次内存。 +> [!tip] 💡 测试编写指南 +> 1. **每个公共函数都应该有对应的测试** +> 2. **测试名描述场景而非函数名**:`TestLogin_EmptyPasswordReturnsError` 而非 `TestLogin` +> 3. **每个测试独立**:不依赖其他测试的执行顺序 +> 4. **使用表格驱动**:覆盖正常、边界、异常三类场景 +> 5. **Mock 外部依赖**:DB、HTTP、文件操作必须替换 +> 6. **测试失败信息要有可读性**:用 `t.Errorf("when X, expected Y, got Z")` 格式 +> 7. **定期运行 `go test -race ./...`**:检测并发 bug -此外,在执行 `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基本功的重要体现 +## 关联笔记 +- [[hzh/GolangStar/Go语言进阶/Goroutine]] — 并发测试需注意 race condition +- [[hzh/GolangStar/Go语言框架/gin]] — Gin 路由测试可用 httptest diff --git a/hzh/GolangStar/Go语言原理/channel原理.md b/hzh/GolangStar/Go语言原理/channel原理.md index c710520..9d6de0e 100644 --- a/hzh/GolangStar/Go语言原理/channel原理.md +++ b/hzh/GolangStar/Go语言原理/channel原理.md @@ -1,588 +1,230 @@ --- -tags: - - Go - - golang - - go原理深入 - - context原理 +tags: [go, golang, go-principle, channel] +create time: 2026-06-07 15:25 --- -# channel原理 +# Channel 底层原理 -## channel是什么 +## 概述 -顾名思义,channel就是一个通信管道,作用是用来在goroutine中传递信息,使不同的goroutine能够通信。 +本文从 runtime 源码角度深入解析 Go Channel 的数据结构、send/recv 全流程,以及无缓冲和有缓冲 channel 的行为差异。Channel 是 Go CSP 并发模型的基石,理解其底层实现能帮你正确设计并发程序、避免死锁。 -go语言遵循CSP并发编程模式,提倡通过通信来实现内存共享,而不提倡通过共享内存来实现通信,而channel就是这种并发编程思想的实现。正是由于channel的存在,才使go语言的并发编程变得简单快捷。并且,在go语言中,通过channel与select的搭配使用以及调度器对goroutine的调度,可以很高效的实现协程的阻塞和唤醒以及多路复用。 +> [!question] ❓ 思考 +> 为什么向关闭的 channel 发送数据会 panic,但从关闭的 channel 读取数据不会?当 sendq 和 recvq 同时有等待者时,数据是直接 goroutine 间传递还是经过缓冲区? -### channel的分类 +## 正文 -channel 又分为两类:有缓冲 channel 和无缓冲 channel +### 一、Channel 的数据结构:hchan -其创建方式如下: - -```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中: +Channel 在 runtime 中用 `hchan` 结构体表示: ```go +// src/runtime/chan.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的数据读写操作加锁,保证并发安全 + qcount uint // 队列中当前元素个数 + dataqsiz uint // 环形缓冲区大小 + buf unsafe.Pointer // 环形缓冲区指针 + elemsize uint16 // 每个元素的大小 + closed uint32 // 是否已关闭 + elemtype *_type // 元素类型 + sendx uint // 发送索引(环形) + recvx uint // 接收索引(环形) + recvq waitq // 等待接收的 goroutine 链表 + sendq waitq // 等待发送的 goroutine 链表 + lock mutex // 保护所有字段的互斥锁 } ``` -hchan 中的sendq 和 recvq 字段存储了当前 channel 由于缓冲区空间(buf)不足而阻塞的要读取或者写入当前channel的goroutine 列表,这些等待队列使用双向链表 waitq 表示,waitq是对一个sudog链表进行封装之后的一个结构,其字段为这个sudog队列的首位指针,链表中所有的元素都是 sudog 结构,看一下waitq这个数据类型 +用一个有缓冲 channel(buf=8, 已有4个元素)来描述: + +```mermaid +flowchart LR + H["hchan"] --> Q["qcount=4"] + H --> D["dataqsiz=8"] + H --> B["buf: [100][200][300][400][ ][ ][ ][ ]"] + H --> S["sendx=4"] + H --> R["recvx=0"] + H --> SQ["sendq: G1→G2→nil"] + H --> RQ["recvq: nil"] + style B fill:#fff9c4 + style SQ fill:#ffebee + style RQ fill:#e8f5e9 +``` + +#### Waitq 与 Sudog ```go type waitq struct { - first *sudog // sudog队列的队头指针 - last *sudog // sudog队列的队尾指针 + first *sudog + last *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 + g *g // 绑定的 goroutine + elem unsafe.Pointer // 传递的数据 + c *hchan // 所属 channel + next *sudog + prev *sudog + success bool // 操作是否成功 + // ... } ``` -这里着关注下elem字段,当向channel发送数据时,elem代表将要保存进channel的元素,当从channel读取数据时,elem代表从channel接受的元素 +`waitq` 是一个双向链表,存储的是 `sudog` 而非 `goroutine`——因为 `sudog` 额外携带了数据地址和成功标志。 -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中: +### 二、Channel 初始化:makechan ```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 + mem := elem.size * uintptr(size) + var c *hchan + switch { + case mem == 0: + // 无缓冲 channel:只分配 hchan + c = (*hchan)(mallocgc(hchanSize, nil, true)) + case elem.ptrdata == 0: + // 无指针元素:一次分配 hchan + buf + c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) + c.buf = add(unsafe.Pointer(c), hchanSize) + default: + // 有指针元素:分开分配 + c = new(hchan) + c.buf = mallocgc(mem, elem, true) + } + c.dataqsiz = uint(size) + lockInit(&c.lock, lockRankHchan) + return c } ``` -makechan函数有两个参数`t *chantype, size int`,第一个参数代表要创建的channel的类型,即通道可以传递的消息类型,第二个参数代表通道中元素的大小。 +> [!note] 📝 源码要点 +> 对于不含指针的小元素类型(如 `int`),Go 会把 hchan 和 buf 分配到同一片连续内存中,减少一次 malloc 开销。含指针的类型必须分开分配,否则 GC 无法正确扫描 buf 区域。 -从源码可以看出创建的主要逻辑位于switch逻辑中,可以看出为channel开辟内存分为三种情况: - -1. **没有缓冲区buf,即创建无缓冲区的channel,只分配hchan本身结构体大小的内存** - -2. **有缓冲区buf, 但元素类型不含指针,一次为当前的 hchan结构和buf数组分配一块连续的内存空间** - -3. **有缓冲区,且元素包含指针类型,分两次分配内存,先为hchan结构和分配内存,再为buf数组元素分配内存** - -针对channel的不同状态,向channel写入结果如下: - -| **操作** | **channel状态** | **结果** | -| ------------- | -------------- | --------- | -| **发送(write)** | **nil** | **阻塞** | -| **发送(write)** | **有缓冲区,缓冲区未满** | **成功写入** | -| **发送(write)** | **无缓冲区或者缓冲区满** | **阻塞** | -| **发送(write)** | **关闭** | **panic** | - -### channel写入 - -往channel写入数据在编码上很简单 +### 三、Send 操作流程 ```go -ch := make(chan, int) -ch <- 1 // 往管道里写入1 +ch <- value // 编译期转换为 chansend(ch, &value, true, getcallerpc()) ``` -在运行时其实是调用了`runtime.chansend`函数,源码如下: +```mermaid +flowchart TD + Start["chansend 开始"] --> NilCh{"ch == nil?"} + NilCh -->|是| ParkNil["gopark 永久挂起"] + NilCh -->|否| Closed{"channel 已关闭?"} + Closed -->|是| Panic["panic: send on closed channel"] + Closed -->|否| Lock["加锁"] + Lock --> RecvWait{"recvq 有等待者?"} + RecvWait -->|是| DirectTransfer["直接 G→G 传递
绕过缓冲区"] + RecvWait -->|否| SpaceAvail{"buf 有空位?"} + SpaceAvail -->|是| BufferCopy["拷贝到 buf
sendx++, qcount++"] + SpaceAvail -->|否| NoSpace{非阻塞?"} + NoSpace -->|是| ReturnFalse["返回 false"] + NoSpace -->|否| CreateSudog["创建 sudog, 加入 sendq"] + CreateSudog --> ParkChan["gopark 挂起"] + DirectTransfer --> Unlock["解锁"] + BufferCopy --> Unlock + ParkChan --> Woken["被 recv 唤醒后解锁"] + Unlock --> Done["返回 true"] + Woken --> Done + style DirectTransfer fill:#e8f5e9 + style BufferCopy fill:#fff9c4 + style Panic fill:#ffebee +``` + +关键场景分析: + +| 场景 | 行为 | +|------|------| +| ch == nil | 永远阻塞 | +| 已关闭 | panic | +| recvq 有等待者 | **G→G 直接传递**,不经过 buf | +| buf 有空位 | 拷贝到 buf,立即返回 | +| buf 满且 recvq 空 | sudog 入队,gopark 挂起 | + +> [!tip] 💡 理解要点 +> 无缓冲 channel 的 send/recv 本质上是"握手"过程——发送方和接收方同时准备好时,数据直接从发送方的栈拷贝到接收方的栈,完全不经过 hchan 内部。 + +### 四、Recv 操作流程 ```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 -} +value := <-ch // 编译期转换为 chanrecv(ch, &value, true) ``` -从源码分析可以看出,往channel发送数据分为三种方式:**直接发送,缓冲发送还有阻塞发送** +```mermaid +flowchart TD + Start["chanrecv 开始"] --> NilCh{"ch == nil?"} + NilCh -->|是| ParkNil["gopark 永久挂起"] + NilCh -->|否| Closed{"closed 且 buf 空?"} + Closed -->|是| ZeroValue["返回零值, ok=false"] + Closed -->|否| Lock["加锁"] + Lock --> SendWait{"sendq 有等待者?"} + SendWait -->|是| DirectRecv["G→G 直接接收"] + SendWait -->|否| BufHasData{"buf 有数据?"} + BufHasData -->|是| BufCopy["从 buf 取出
recvx++, qcount--"] + BufHasData -->|否| NoData{非阻塞?"} + NoData -->|是| ReturnFF["返回 false, false"] + NoData -->|否| CreateSudog["创建 sudog, 加入 recvq"] + CreateSudog --> ParkChan["gopark 挂起"] + DirectRecv --> Unlock["解锁"] + BufCopy --> Unlock + ParkChan --> Woken["被 send 唤醒后解锁"] + Unlock --> Done{"ok?"} + Woken --> Done + style DirectRecv fill:#e8f5e9 + style BufCopy fill:#fff9c4 + style ZeroValue fill:#fff3e0 +``` -**直接发送:**&##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读取数据的编码形式如下 +### 五、Close 流程 ```go -ch := make(chan, int) -v := <- ch // 直接读取 -v, ok <- ch // ok判断读取的v是否有效 +close(ch) // 编译期转换为 closechan(ch) ``` -上述两种读取方式在运行时最后都是调用的`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 c == nil { panic("close of nil channel") } + lock(&c.lock) + if c.closed != 0 { panic("close of closed channel") } + c.closed = 1 - 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) - } + var glist gList + // 1. 唤醒所有 recvq 中的 goroutine(返回零值) + for { sg := c.recvq.dequeue(); if sg == nil { break } + sg.elem = nil; sg.success = false; glist.push(sg.g) + } + // 2. 唤醒所有 sendq 中的 goroutine(触发 panic) + for { sg := c.sendq.dequeue(); if sg == nil { break } + sg.elem = nil; sg.success = false; glist.push(sg.g) + } + unlock(&c.lock) + // 3. 批量恢复调度 + for !glist.empty() { goready(glist.pop(), 3) } } ``` -对channel的关闭很简单,但是也容易造成错误,从源码中分析我们知道, +> [!warning] ⚠️ 注意 +> - 关闭已关闭的 channel → panic +> - 向已关闭的 channel 发送数据 → panic +> - 从已关闭的 channel 读取数据 → 返回零值和 `false`(直到 buf 排空) +> - 确保发送方全部完成后才关闭 channel,否则 panic -* **如果channel为nil,对其关闭将会panic** +### 六、性能建议 -* **重复关闭一个已经关闭的channel会panic** +1. **预知容量时指定 buffer size**:避免运行时动态分配 +2. **优先使用有缓冲 channel**:减少 goroutine 阻塞概率 +3. **关闭 channel 的责任归属**:通常由发送方负责关闭,接收方不应关闭 +4. **无缓冲 channel 适合同步信号**:`make(chan struct{})` 是最轻量的同步方式 -所以在关闭channel时,我们要特别小心,除了上述两种情况外,还要保证没有发送者往channel里面发送数据了,否则发送者也会panic +## 小结 + +- Channel 核心是 `(buf, sendx, recvx)` 环形队列 + `sendq/recvq` 等待链表 +- 无缓冲 channel 的 send/recv 是 G→G 直接传递,不经过 buf +- 关闭 channel 会批量唤醒所有等待者 +- 理解 channel 底层能有效避免死锁和误用 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Channel]] — Channel 的基础用法 +- [[hzh/GolangStar/Go语言进阶/Select]] — Select 多路复用 +- [[hzh/GolangStar/Go面试题库/Channel面试题]] — Channel 相关高频面试题 diff --git a/hzh/GolangStar/Go语言原理/context原理.md b/hzh/GolangStar/Go语言原理/context原理.md index 6b60bbe..29259cb 100644 --- a/hzh/GolangStar/Go语言原理/context原理.md +++ b/hzh/GolangStar/Go语言原理/context原理.md @@ -1,468 +1,275 @@ --- -tags: - - Go - - golang - - go原理深入 - - channel原理 +tags: [go, golang, go-principle, context] +create time: 2026-06-07 15:30 --- -# context原理 +# Context 底层原理 -## context是什么 +## 概述 -context是go语言在1.7引入的一个用于goroutine之间传递信息的并发安全的包,context可以翻译为上下文,其在项目中主要是用于上下与下层goroutine的取消控制以及数据共享,也是go语言中goroutine之间通信的一种方式,其底层是借助channl与sync.Mutex实现的。 +本文从源码角度解析 Go `context` 包的四种实现(emptyCtx / cancelCtx / timerCtx / valueCtx),以及取消传播链和值传递链的底层机制。Context 是 Go 并发编程中控制取消和数据共享的核心工具,理解其实现能让你写出更健壮的并发程序。 -关于context的用法我们在前一章节并发实践里已经做过介绍,本章主要介绍一下context的底层原理 +> [!question] ❓ 思考 +> 为什么 `WithValue` 派生的 context 层层嵌套形成链表,而不是用 map 存储?当父 context 被取消时,子 context 是如何级联收到信号的? -## context的底层实现 +## 正文 -与context相关的源码基本都在src/context/context.go中,我们通过源码来看一下,context的底层究竟做了些什么 +### 一、Context 的类型体系 -context在底层实现上其实用到了2个接口,对这个接口的4种实现,以及提供了6个方法 +```mermaid +graph TB + C["Context interface
Deadline/Done/Err/Value"] --> empty["emptyCtx
根 context"] + C --> cancel["cancelCtx
可取消"] + C --> timer["timerCtx
可取消 + 定时"] + C --> value["valueCtx
键值对传递"] -**接口:** + timer -.嵌入.-> cancel + cancel -.嵌入.-> C + value -.嵌入.-> C -| 接口名 | 说明 | -| -------- | ------------------------------------ | -| Context | context的接口定义,规定context的实现必须包含的四个基本方法 | -| canceler | context的取消接口,其中定义了两个方法 | + B["Background()"] --> empty + T["TODO()"] --> empty + WC["WithCancel(parent)"] --> cancel + WD["WithDeadline/Timeout(parent)"] --> timer + WV["WithValue(parent, key, val)"] --> value -**实现:** - -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{} -} + style C fill:#e3f2fd + style empty fill:#e8f5e9 + style cancel fill:#fff9c4 + style timer fill:#fff3e0 + style value fill:#fce4ec ``` -接口提供了四个方法 +四个结构体的核心职责: -* `Deadline`: 返回 context.Context 被取消的时间,即截止时间; +| 类型 | 可取消 | 有 Deadline | 存值 | 用途 | +|------|--------|-------------|------|------| +| `emptyCtx` | 否 | 否 | 否 | 根 context (Background/TODO) | +| `cancelCtx` | 是 | 否 | 否 | WithCancel 派生 | +| `timerCtx` | 是 | 是 | 否 | WithDeadline/Timeout 派生 | +| `valueCtx` | 否 | 否 | 是 | WithValue 派生 | -* `Done`: 返回一个 Channel,当Context被取消或者到达截止时间,这个 Channel 就会被关闭,表示context结束,多次调用 Done 方法返回的channel是同一个 +### 二、取消传播链:cancelCtx -* `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被取消的原因 + Context // 嵌入父 context + mu sync.Mutex // 保护以下字段 + done atomic.Value // chan struct{},nil 或未关闭 → 未取消;已关闭 → 已取消 + children map[canceler]struct{} // 子 canceler 集合 + err error // 取消原因 } ``` -下面看一下其各个方法的具体实现,首先看一下`Done()`方法: +#### 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{}) + 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的时候会读到零值。利用这个而特性就可以实现关闭的消息通知。 +注意这个 channel 是**只读的**——只有父 context 关闭它,子 goroutine 通过 `select` 监听它来感知取消信号。 -再看一下其 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种移除 + c.mu.Lock() + if c.err != nil { c.mu.Unlock(); return } // 已取消 + c.err = err + d, _ := c.done.Load().(chan struct{}) + if d == nil { + c.done.Store(closedchan) // 预创建的 closed chan + } else { + close(d) // 关闭通道,通知所有监听者 + } + for child := range c.children { + child.cancel(false, err) // 递归取消子节点 + } + c.children = nil + c.mu.Unlock() + if removeFromParent { + removeChild(c.Context, c) // 从父节点移除自己 + } } ``` -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() -} +```mermaid +flowchart TD + P["父 cancelCtx"] --> C1["子 cancelCtx 1"] + P --> C2["子 cancelCtx 2"] + C1 --> C1a["孙 cancelCtx"] + C2 --> C2a["孙 valueCtx"] + + style P fill:#ffebee + style C1 fill:#fff3e0 + style C2 fill:#fff3e0 + style C1a fill:#e8f5e9 + style C2a fill:#e8f5e9 + + click P "触发 cancel()" + click C1 "级联取消" + click C2 "级联取消" ``` -移除前后效果如下图所示: +调用 `cancel()` 后: +1. 关闭自己的 `done` channel → 所有监听该 channel 的 goroutine 收到信号 +2. 递归取消所有子节点 +3. 将自己从父节点的 children 中移除 -![](https://golangstar.cn/assets/img/go语言系列/context原理/image.png) - -在用户层面,创建cancelCtx的方法其实我们你之前也接触过,就是withCancel方法,在平常代码中,我们一般用这个方法来派生一个可以用cancel取消函数取消的context,常规用法如下: +### 三、父子关联:propagateCancel ```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(): - } - }() - } + done := parent.Done() + if done == nil { + return // 父节点永远不会被取消 + } + select { + case <-done: + child.cancel(false, parent.Err()) // 父已取消,子直接取消 + return + default: + } + // 尝试从父提取 cancelCtx + if p, ok := parentCancelCtx(parent); ok { + p.mu.Lock() + if p.err != nil { + child.cancel(false, p.err) + } else { + if p.children == nil { + p.children = make(map[canceler]struct{}) + } + p.children[child] = struct{}{} + } + p.mu.Unlock() + } else { + // 父不是标准 cancelCtx,启动 goroutine 监控 + atomic.AddInt32(&goroutines, +1) + go func() { + 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 -} -``` +| 父 context 情况 | 处理方式 | +|----------------|---------| +| `Done() == nil`(永远不取消) | 无需关联 | +| 能提取出 `cancelCtx` | 直接加入 children map | +| 不能提取 `cancelCtx`(如 valueCtx 链中的某层) | 起一个 goroutine 监控 | -总结一下通过WithCancel函数在派生可取消的子context的过程中,通过propagateCancel函数关联父子context可能遇到的几种情形: +### 四、值传递链:valueCtx -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{} + Context // 父 context + key, val interface{} // 当前层的键值对 } ``` -valueCtx内置了Context,所以他也是一个context接口的实现,但是其没有实现canceler接口,所以他不能用作context的取消,valueCtx实现了`String()`方法和`Value`方法,`String()`比较简单,就不细看了,下面看一下 +每个 `valueCtx` 只存**一对**键值对,多层 `WithValue` 会形成嵌套链表: -`Value`方法 +```mermaid +flowchart LR + V2["valueCtx2
key2=val2"] --> V1["valueCtx1
key1=val1"] + V1 --> E["emptyCtx
Background"] + style V2 fill:#fce4ec + style V1 fill:#fff3e0 + style E fill:#e8f5e9 +``` + +查找过程: ```go func (c *valueCtx) Value(key interface{}) interface{} { - if c.key == key { - return c.val - } - return c.Context.Value(key) + 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 +> [!warning] ⚠️ 性能提示 +> Value 查找是 O(n) 线性搜索(n = 嵌套层数)。过深的嵌套会影响性能,建议控制在合理范围内。同时,key 应避免使用容易碰撞的类型,推荐使用自定义不可比较类型。 ```go -ctx := context.WithValue(context.Background(),"key1","value1") +// 推荐的 key 定义方式 +type ctxKey string +const myKey ctxKey = "my-key" +ctx := context.WithValue(ctx, myKey, value) ``` -withValue函数源码如下: +### 五、TimerCtx:定时取消 ```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} +type timerCtx struct { + cancelCtx + timer *time.Timer + deadline time.Time } ``` -withValue的方法实现很简单,就是创建一个valueCtx,将key和value设置到valueCtx返回。 +`timerCtx` 嵌入了 `cancelCtx`,额外增加了一个定时器。在 `WithDeadline` 中: +```go +func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { + if cur, ok := parent.Deadline(); ok && cur.Before(d) { + return WithCancel(parent) // 父 deadline 更早,直接返回 cancelCtx + } + c := &timerCtx{cancelCtx: newCancelCtx(parent), deadline: d} + propagateCancel(parent, c) + dur := time.Until(d) + if dur <= 0 { + c.cancel(true, DeadlineExceeded) + return c, func() { c.cancel(false, Canceled) } + } + c.timer = time.AfterFunc(dur, func() { + c.cancel(true, DeadlineExceeded) + }) + return c, func() { c.cancel(true, Canceled) } +} +``` + +### 六、最佳实践 + +> [!tip] 💡 技巧 +> - **永远将 context 作为第一个参数**传入函数,命名为 `ctx` +> - **不要将 context 存入结构体**,只作为请求级别的传递工具 +> - **不要用 context 传值做业务数据交换**,它是跨 API 边界的取消信号机制 +> - **长生命周期对象不要用 context 传值**,会导致对象无法被 GC + +## 小结 + +- Context 是接口,四种实现各司其职:空、取消、定时、传值 +- 取消通过 `close(done channel)` + 递归遍历 children map 实现级联传播 +- 值传递通过嵌套链表实现,查找为 O(n) 线性搜索 +- `propagateCancel` 处理父子关联,支持三种场景 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Context]] — Context 的基础用法 +- [[hzh/GolangStar/Go语言进阶/协程池]] — Context 在 worker pool 中的应用 +- [[hzh/GolangStar/Go面试题库/Context面试题]] — Context 相关高频面试题 diff --git a/hzh/GolangStar/Go语言原理/defer原理.md b/hzh/GolangStar/Go语言原理/defer原理.md index 7b40b91..ea14abf 100644 --- a/hzh/GolangStar/Go语言原理/defer原理.md +++ b/hzh/GolangStar/Go语言原理/defer原理.md @@ -1,321 +1,203 @@ --- -tags: - - Go - - golang - - go原理深入 - - defer原理 +tags: [go, golang, go-principle, defer] +create time: 2026-06-07 15:35 --- -# defer原理 +# Defer 执行原理 -## defer是什么 +## 概述 -defer是go语言的一个关键字,用来修饰函数,其作用是让defer后面跟的函数或者方法调用能够延迟到当前所在函数return或者panic的时候再执行。 +本文从编译期和运行期两个层面解析 Go `defer` 的底层实现:_defer 链表结构、三种分配方式(堆/栈/开放编码)、以及 LIFO 执行机制。理解 defer 的原理能帮你避免循环中的性能陷阱,也能更深入地理解 panic/recover 的行为。 -## defer的使用形式 +> [!question] ❓ 思考 +> 为什么多个 defer 按后进先出(LIFO)顺序执行?defer 注册的函数参数是在注册时求值还是执行时求值?Go 1.14 引入的"开放编码"优化了什么? -```go -defer func(args) -``` +## 正文 -defer在使用的时候,只需要在其后面加上具体的函数调用即可,这样就会注册一个延迟执行的函数func,并且会把函数名和参数都确定,等到从当前函数退出的时候在执行 +### 一、Defer 的存储结构:_defer 链表 -## defer的底层结构 - -进行defer 函数调用的时候其实会生成一个\_defer结构,一个函数中可能有多次defer调用,所以会生成多个这样的\_defer结构,这些\_defer结构链式存储构成一个\_defer链表,当前goroutine的\_defer指向这个链表的头节点, - -\_defer 的结构定义在src/src/runtime/runtime2.go中,源码如下: +每次使用 `defer` 关键字都会创建一个 `_defer` 结构,同一函数中的多个 defer 通过链表串联: ```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 + started bool // 是否已开始执行 + heap bool // 是否分配在堆上 + openDefer bool // 是否使用开放编码模式 + sp uintptr // 调用方栈指针 + pc uintptr // 调用方程序计数器 + fn func() // 延迟执行的函数(注意:已经是 bound 过的) + _panic *_panic // 关联的 panic 对象 + link *_defer // 链表下一个节点 + fd unsafe.Pointer // defer 相关参数 } ``` -底层存储如下图: +存储示意: -![](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) - } +```mermaid +flowchart LR + GP["goroutine._defer"] --> D1["_defer #3
fn: Close()"] + D1 --> D2["_defer #2
fn: Unlock()"] + D2 --> D3["_defer #1
fn: Log()"] + D3 --> nil["nil"] + style GP fill:#e3f2fd + style D1 fill:#ffebee + style D2 fill:#fff9c4 + style D3 fill:#e8f5e9 ``` -从上述代码可以看出,defer的是现有三种实现方式,在栈上分配内存,在堆上分配内存以及使用开放编码的方式。会优先使用内联方式,当内联不满足,且没有发生内存逃逸的情况下,使用栈分配的方式,这两种情况都不符合的情况下在使用堆分配,这样做的好处是提升性能。 +每个新的 defer 插入到链表**头部**,执行时也从头部取出——这就是 LIFO 顺序的由来。 -### \_defer内存分配 +### 二、编译期处理:三种实现方式 -在上面的分析中我们可以看出在不同的情况下,\_defer结构分配在不同的地方,可能分配在堆上也可能分配在栈上,这两种分配方式调用的函数是不同的,堆上分配实际调用的是`runtime.deferproc`函数,栈上分配内存调用的是`runtime.deferprocStack`函数,下面分别来看看这两个函数都做了些什么工作? +Go 编译器在 SSA 阶段遇到 `defer` 时,会决定用哪种方式实现: -#### 堆上分配 +```mermaid +flowchart TD + CanOpen{"可开放编码?
函数≤8个defer
不在循环中
返回值数×defer数≤15"} + CanOpen -->|是| OpenCoded["开放编码: 内联到每个 exit path"] + CanOpen -->|否| NoEscape{内存逃逸?} + NoEscape -->|否 - 栈上| StackAlloc["栈分配: deferprocStack"] + NoEscape -->|是 - 堆上| HeapAlloc["堆分配: deferproc"] + style OpenCoded fill:#e8f5e9 + style StackAlloc fill:#fff9c4 + style HeapAlloc fill:#ffebee +``` -`先看deferproc`函数,在堆上分配内存,go 1.13 之前只有这个函数,说明go 1.13 之前,\_defer只能在堆上分配。 +#### 方式 1:开放编码(Open-Coded Defer)— Go 1.14+ -src/runtime/panic.go +当满足条件时,编译器将 defer 逻辑直接内联到函数的每个 return 路径中,省去了函数调用的开销。 + +```go +// 原始代码 +func f() (int, int) { + defer fmt.Println("first") + defer fmt.Println("second") + return 1, 2 +} +// 编译后等效于: +func f() (int, int) { + fmt.Println("second") + fmt.Println("first") + return 1, 2 +} +``` + +适用条件: +- 函数中 defer 数量 ≤ 8 +- 不在 for/while 循环体内 +- 返回值个数 × defer 个数 ≤ 15 +- 未使用 `-N` 编译标志 + +#### 方式 2:栈分配 — Go 1.13+ + +当 defer 不逃逸时,_defer 结构直接在函数调用栈上分配: + +```go +func deferprocStack(d *_defer) { + gp := getg() + d.started = false + d.heap = false + d.sp = getcallersp() + d.pc = getcallerpc() + // 链入 goroutine 的 _defer 链表头部 + *(*uintptr)(unsafe.Pointer(&d.link)) = uintptr(unsafe.Pointer(gp._defer)) + *(*uintptr)(unsafe.Pointer(&gp._defer)) = uintptr(unsafe.Pointer(d)) +} +``` + +栈分配避免了堆上的 malloc/free,性能优于堆分配。 + +#### 方式 3:堆分配 — 传统方式 + +当 defer 发生逃逸(如在循环中),_defer 分配在堆上: ```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() - + gp := getg() + d := newdefer() // 从 P 或全局 deferpool 获取,无则 mallocgc + d.link = gp._defer + gp._defer = d + d.fn = fn } ``` -重点看一下newdefer()这个函数 +> [!warning] ⚠️ 重要 +> **不要在循环中使用 defer!** 循环中的 defer 无法使用开放编码,且必定逃逸到堆上,导致每次迭代都触发 malloc/free。如果需要清理资源,手动在循环内调用 cleanup 函数。 -```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 -} -``` +### 三、运行期执行:deferreturn -可以看出堆上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函数。函数源码如下: +函数返回前,编译器插入 `deferreturn()` 调用: ```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() // 执行函数调用 - } + gp := getg() + for { + d := gp._defer + if d == nil { return } + if d.sp != getcallersp() { return } // 跨帧保护 + + if d.openDefer { + runOpenDeferFrame(gp, d) + gp._defer = d.link + freedefer(d) + return + } + + fn := d.fn // 取出函数指针 + d.fn = nil // 置空 + gp._defer = d.link // 移到下一个 + freedefer(d) // 释放(优先归还到 deferpool) + fn() // 执行 + } } ``` -当 go函数 的 `return` 关键字执行的时候,触发 `call` 调用 `deferreturn`函数,deferreturn函数的执行逻辑也很简单,就是遍历goroutine上的defer链表,从表头开始遍历,依次取出defer结构执行defer结构中的函数执行。 +执行流程: -总结: +```mermaid +flowchart TD + F["函数 return"] --> DR["deferreturn()"] + DR --> Loop{"有 _defer?"} + Loop -->|否| Ret["正常返回"] + Loop -->|是| Next["取头部 _defer"] + Next --> Exec{"开放编码?"} + Exec -->|是| RunOpen["runOpenDeferFrame"] + Exec -->|否| Call["fn() 调用函数"] + RunOpen --> Free["freedefer 释放"] + Call --> Free + Free --> Loop + style DR fill:#e3f2fd + style Call fill:#fff9c4 + style Ret fill:#e8f5e9 +``` -1. 遇到defer关键字,编译器会在编译阶段注册defer函数的时候插入`deferproc()`函数或者`deferprocStack`函数,在return之前插入deferreturn()函数 +### 四、DeferPool:内存复用 -2. defer函数的执行顺序是LIFO的,因为每次创建的defer结构都是插入到goroutine的defer链表表头 +`newdefer` 不会每次都分配新内存,而是采用三层缓存策略: -3. defer结构的有三种实现方式,堆上分配,栈上分配还有内联实现 +``` +P.local deferpool → sched.global deferpool → heap malloc +``` +1. 先从当前 P 的本地 deferpool 中取 +2. 如果本地为空且全局池非空,批量从全局池迁移到本地 +3. 如果都没有,才在堆上 `new(_defer)` + +执行完毕后,`freedefer` 会将 _defer 放回 P 的本地 deferpool 复用。 + +> [!tip] 💡 理解要点 +> 这个设计类似于 TCMalloc 的 ThreadCache 思想——通过本地缓存减少锁竞争和 GC 压力。 + +## 小结 + +- Defer 用 _defer 链表实现,新节点插入头部 → LIFO 执行顺序 +- 三种实现方式:开放编码(最快)> 栈分配 > 堆分配(最慢) +- 循环中用 defer 会导致逃逸到堆,应避免 +- defer 注册的函数和参数在**注册时即已确定**(包括命名返回值的快照) + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言defer]] — Defer 的基础用法 +- [[hzh/GolangStar/Go语言基础/Go语言异常捕获]] — Panic/Recover 与 Defer 的关系 +- [[hzh/GolangStar/Go语言原理/逃逸分析]] — 变量何时逃逸到堆 diff --git a/hzh/GolangStar/Go语言原理/escape分析原理.md b/hzh/GolangStar/Go语言原理/escape分析原理.md new file mode 100644 index 0000000..e4dce59 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/escape分析原理.md @@ -0,0 +1,151 @@ +--- +tags: [go, golang, go-principle, escape-analysis] +create time: 2026-06-07 15:45 +--- + +# 逃逸分析 + +## 概述 + +本文从编译器和 runtime 两个层面解析 Go 的逃逸分析机制:变量何时从栈分配到堆、为什么指针传递不一定是好事、以及如何通过 `go build -gcflags '-m'` 观察逃逸行为。理解逃逸分析是优化 Go 程序性能的关键一步。 + +> [!question] ❓ 思考 +> 既然"返回局部变量的指针会导致逃逸",那是不是永远不该用指针传参?如果一个小结构体每次都用指针传递,反而可能因为逃逸到堆上而变慢——这是为什么? + +## 正文 + +### 一、什么是逃逸分析 + +逃逸分析是编译器在**编译期**执行的一种静态分析:判断每个变量的生命周期是否超出其定义的作用域。如果会"逃出"当前函数栈帧,就分配到堆上;否则留在栈上。 + +```mermaid +flowchart TD + Start["编译期: 逃逸分析"] --> Rule{"变量会被外部引用吗?"} + Rule -->|否| Stack["分配到栈
分配 = PUSH / 释放 = POP
开销极小"] + Rule -->|是| Heap["分配到堆
分配 = mallocgc / 释放 = GC
开销大"] + style Stack fill:#e8f5e9 + style Heap fill:#ffebee +``` + +> [!tip] 💡 核心理解 +> 栈内存分配只需两条 CPU 指令(PUSH/POP),而堆分配需要调用 `mallocgc` 并等待 GC 回收。两者的性能差距可能是几个数量级。 + +### 二、常见的逃逸场景 + +#### 1. 返回局部变量的地址 + +```go +func newStudent(name string) *Student { + s := Student{Name: name} // 逃逸!s 被外部引用 + return &s +} +// 编译输出: ./main.go:5:10: &s escapes to heap +``` + +#### 2. 栈空间不足 + +```go +func bigSlice() { + s := make([]int, 100000) // 逃逸!栈放不下 +} +// 编译输出: ... makeslice allocates ... +``` + +#### 3. 动态类型(接口) + +```go +func printVal(v interface{}) { + fmt.Println(v) +} +// 参数 v 是 interface 类型,编译期无法确定具体类型 → 逃逸 +``` + +#### 4. 大小不确定 + +```go +func dynamicSlice(n int) { + s := make([]int, n) // n 是运行时确定的 → 逃逸 +} +``` + +### 三、逃逸决策流程图 + +```mermaid +flowchart TD + S["变量声明"] --> F1{函数返回值中引用?} + F1 -->|是| HEAP["堆"] + F1 -->|否| F2{变量大小 > 栈限制?} + F2 -->|是| HEAP + F2 -->|否| F3{大小在编译期可确定?} + F3 -->|否| HEAP + F3 -->|是| F4{通过 interface 传递?} + F4 -->|是| HEAP + F4 -->|否| F5{被 goroutine 捕获?
闭包引用了局部变量} + F5 -->|是| HEAP + F5 -->|否| STACK["栈"] + style HEAP fill:#ffebee + style STACK fill:#e8f5e9 +``` + +### 四、指针传参的陷阱 + +很多开发者认为"用指针传参减少拷贝"就是最优解,但这忽略了逃逸的成本: + +```go +type Point struct{ X, Y int } + +// 版本 A:值传递(不逃逸,栈上传递 16 字节) +func CalcA(p Point) int { return p.X + p.Y } + +// 版本 B:指针传递(可能导致逃逸!) +func CalcB(p *Point) int { return p.X + p.Y } + +// 如果 CalcB 被传给 interface{} 或存入全局变量, +// p 指向的 Point 就会逃逸到堆上 +``` + +栈上传递 16 字节的成本远低于一次堆分配 + 后续 GC 的成本。 + +> [!warning] ⚠️ 经验法则 +> - 小结构体(≤ 64 字节)优先值传递 +> - 大结构体或切片/map/指针本身才需要考虑指针传递 +> - 不要盲目用指针——先用 `-m` 观察实际逃逸情况 + +### 五、如何观察逃逸行为 + +```bash +# 查看每个变量的逃逸分析结果 +go build -gcflags '-m -l' main.go + +# 输出示例: +# ./main.go:5:10: &s escapes to heap +# ./main.go:12:13: make([]int, 100) does not escape +``` + +常见标志位含义: +- `escapes to heap`:分配到堆 +- `does not escape`:留在栈上 +- `moved to heap`:原本在栈上,运行时发现需要堆分配 + +> [!note] 📝 源码要点 +> 逃逸分析位于 `cmd/compile/internal/base/escape.go`。编译器使用一种基于 SSA(Static Single Assignment)形式的分析算法,在 IR 生成阶段完成。它不依赖运行时的信息,是完全静态的。 + +### 六、避免不必要逃逸的建议 + +1. **减少不必要的接口转换**:高频路径上使用具体类型 +2. **已知容量时预分配切片**:`make([]T, 0, n)` 比逐步 append 更可控 +3. **注意闭包捕获变量**:闭包引用的局部变量必定逃逸 +4. **对象池复用**:对频繁创建的大对象使用 `sync.Pool` + +## 小结 + +- 逃逸分析是编译期的静态分析,决定变量分配位置 +- 栈分配 ≈ PUSH/POP 两条指令,堆分配 ≈ malloc + GC +- 返回指针、栈溢出、接口传递、动态大小都会导致逃逸 +- 不要盲目用指针传参——小对象值传递反而更快 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言原理/内存管理]] — 内存管理机制与 TCMalloc 架构 +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 调度模型 +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — 内存管理相关面试题 diff --git a/hzh/GolangStar/Go语言原理/gmp调度原理.md b/hzh/GolangStar/Go语言原理/gmp调度原理.md index f114292..0cd0eed 100644 --- a/hzh/GolangStar/Go语言原理/gmp调度原理.md +++ b/hzh/GolangStar/Go语言原理/gmp调度原理.md @@ -1,17 +1,22 @@ --- -tags: - - Go - - golang - - go原理深入 - - GMP调度 - - 协程调度 +tags: [go, golang, go-principle, gmp-scheduler] +create time: 2026-06-07 15:50 --- -# GMP调度原理 +# GMP 调度原理 +## 概述 + +本文从宏观架构到微观源码,全方位解析 Go 的 GMP 调度模型。涵盖 G/M/P 三大组件的数据结构、goroutine 的创建与调度流程、主动让渡机制(yield)、以及 sysmon 线程发起的抢占式调度。这是理解 Go 并发性能的核心篇章。 + +> [!question] ❓ 思考 +> 为什么 Go 不直接使用操作系统线程来管理并发,而要发明 GMP 这套中间层?当一个 goroutine 阻塞在 IO 上时,整个程序的其他 goroutine 还会继续执行吗? + +--- 聊到Go语言,大家最津津乐道的可能就是它那"天生强大"的并发能力了。一个简单的 `go` 关键字,就能开启一个并发执行单元,这酸爽,谁用谁知道。但是,你有没有想过,这背后到底藏着什么样的魔法?为什么Go的并发可以如此轻盈、如此高效? + 答案,就藏在它核心的 **GMP调度模型**里。 很多Gopher对GMP可能只是略知一二,知道有G、M、P这三个角色,但它们之间是如何协作的,一个goroutine又是如何被创建、调度、甚至是被"抢占"的,可能就有点模糊了。 @@ -70,29 +75,72 @@ Go语言选择的并发实现,就是我们所熟知的 **goroutine**。你可 * **P(Processor)**:P是调度器,是GMP模型中的"中枢大脑"。M必须获取到一个P,才能开始调度和执行G。P的数量决定了同一时间最多有多少个M可以处于运行状态,这个数量通常由 `GOMAXPROCS` 环境变量决定。P还有一个非常重要的职责:它自带一个本地的goroutine队列,我们称之为 **LRQ (Local Run Queue)**。 +G、M、P 三者的关系可以用一张图来概括: + +```mermaid +graph TB + subgraph P1["P0 (Processor)"] + LRQ0["LRQ: [G5, G6, G7]"] + end + subgraph P2["P1 (Processor)"] + LRQ1["LRQ: [G8]"] + end + GRQ["GRQ 全局队列
[G1, G2, G3, G4]"] + M0["M0 (OS Thread)"] -->|绑定| P0_node["P0"] + M1["M1 (OS Thread)"] -->|绑定| P1_node["P1"] + P0_node --> LRQ0 + P1_node --> LRQ1 + LRQ0 -.偷取.-> LRQ1 + LRQ1 -.偷取.-> LRQ0 + GRQ -->|LRQ空时取| P0_node + GRQ -->|LRQ空时取| P1_node + + style GRQ fill:#fff9c4 + style LRQ0 fill:#e8f5e9 + style LRQ1 fill:#e8f5e9 +``` + +> [!tip] 💡 类比理解 +> 把 GMP 想象成一个工厂: +> - **G** = 工作任务(如"组装一台手机") +> - **M** = 工人(真正干活的 OS 线程) +> - **P** = 车间主管(管理工人、分配任务) +> - **LRQ** = 每个主管面前的待办清单 +> - **GRQ** = 工厂公共公告栏上的大清单 +> - **work-stealing** = 闲着的工人去隔壁忙不过来的车间"偷"活干 + ![](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** 机制。 +1. **P 的本地队列(LRQ - Local Run Queue)**:每个 P 私有的 G 队列,最多存 256 个 G。优先无锁 CAS 操作存取,极少加锁。当 LRQ 满了或需要负载均衡时,会触发 **work-stealing** 机制——从其他 P 的 LRQ 中"偷"一半过来。 -2. **全局队列(GRQ - Global Run Queue)**:这是一个全局共享的G队列。当一个P的LRQ满了,新创建的G就会被放到GRQ里。因为是全局共享的,所以所有M都可能来访问,竞争激烈,因此访问它需要加一把全局大锁。 +2. **全局队列(GRQ - Global Run Queue)**:所有 M 共享的 G 队列,访问需加全局锁 `sched.lock`。新创建的 G 在 LRQ 满时被放入 GRQ。 -**G的存放与获取逻辑**: +**G 的存放与获取逻辑**: -* **放G(put g)**:当你在一个goroutine里通过 `go func(){...}` 创建一个新的goroutine时,它会优先被放到当前P的LRQ里。如果LRQ满了,没办法,只能加个全局锁,把它扔到GRQ里去。这遵循的是"就近原则"。 +* **放 G(put g)**:`go func(){...}` 创建的新 goroutine 优先放入当前 P 的 LRQ。LRQ 满了才加锁放入 GRQ。遵循"就近原则"。 -* **取G(get g)**:当M上的`g0`开始找活干时,它会遵循一个"负载均衡"的策略,按以下顺序来寻找G: +* **取 G(get g)**:M 上的 `g0` 找任务时按以下优先级: - 1. 先从当前P的LRQ里找(无锁,速度最快)。 +```mermaid +flowchart TD + LRQ["1. 当前 P 的 LRQ
无锁 CAS,最快"] -->|空| GRQ{"2. 全局 GRQ
需加锁"} + GRQ -->|空| NET{"3. netpoll IO就绪
非阻塞 epoll_wait"} + GRQ -->|有| GOT_G["找到 G ✓"] + NET -->|有| GOT_G + NET -->|空| STEAL{"4. 从其他 P 偷一半
work-stealing"} + STEAL -->|成功| GOT_G + STEAL -->|失败| SLEEP["5. P/M 进入休眠"] + style LRQ fill:#e8f5e9 + style GRQ fill:#fff3e0 + style NET fill:#fff9c4 + style STEAL fill:#ffebee + style SLEEP fill:#eceff1 +``` - 2. 如果LRQ没有,就去全局GRQ里看看(需要加锁)。 - - 3. 如果GRQ也没有,就去网络轮询器(netpoll)里找找有没有因为IO操作而就绪的G。 - - 4. 如果还是没有,就只能去"偷"了,从别的P的LRQ里偷一半过来(work-stealing,无锁)。 - -这里有个小细节:为了防止GRQ里的G被"饿死"(因为M上的`g0`总是优先从LRQ取),调度器规定,每进行61次调度循环,就必须强制去下一次去`grq`取 。这样做是为了避免`lrq`过于繁忙,而导致`grq`中的`g`"饿死"。 +> [!note] 📝 防饥饿机制 +> 为了防止 GRQ 中的 G 被长期饿死(因为 M 总是优先从 LRQ 取),调度器规定:**每进行 61 次调度循环,就必须强制去 GRQ 取一次**。这保证了公平性。 ### 1.4 GMP生态圈 @@ -1402,5 +1450,68 @@ func asyncPreempt2() { 希望通过这篇文章,你能对Go的并发调度有一个更深刻、更系统的理解。GMP模型无疑是Go语言设计的精髓所在,它优雅、高效地解决了并发调度中的种种难题,是我们每个Gopher都应该掌握的核心知识。 +--- + +### GMP 全景回顾 + +```mermaid +flowchart TD + subgraph Create["创建 goroutine"] + NewProc["runtime.newproc
systemstack → newproc1
runqput → LRQ/GRQ"] + end + + subgraph Schedule["调度循环"] + ScheduleFn["schedule() → findRunnable()"] + FindR["findRunnable: LRQ→GRQ→netpoll→steal"] + Execute["execute() → gogo(g)"] + end + + subgraph Yield["让出执行权"] + End["goexit1 → mcall(goexit0)
status=Gdead, gfput"] + Gosched["Gosched → mcall(gosched_m)
status=Grunnable, globrunqput"] + Park["gopark → mcall(park_m)
status=Gwaiting, 由上层管理"] + end + + subgraph Preempt["抢占 (sysmon)"] + Sysmon["sysmon 线程"] + Retake["retake: 系统调用 + 超时检测"] + Collab["协作式: stackPreempt 标记"] + Signal["非协作式: sigPreempt 信号注入"] + end + + subgraph Recover["恢复执行"] + Ready["goready → ready()
status=Grunnable, runqput"] + end + + Create --> Schedule + Schedule --> Yield + Yield --> Recover + Recover --> Schedule + Sysmon --> Preempt + Preempt --> Yield + style Create fill:#e3f2fd + style Schedule fill:#e8f5e9 + style Yield fill:#fff3e0 + style Preempt fill:#ffebee + style Recover fill:#e8f5e9 +``` + +> [!note] 📝 核心要点总结 +> - **G** = 任务(goroutine),有自己的栈和执行状态 +> - **M** = 工人(OS 线程),真正执行 G 的代码 +> - **P** = 主管(逻辑处理器),管理 LRQ 并协调 M 与 G 的关系 +> - 调度优先级:LRQ(无锁)> GRQ(加锁)> netpoll(IO)> steal(工作窃取) +> - 防饥饿:每 61 次调度强制检查 GRQ +> - 抢占机制:协作式(栈检查标记)+ 非协作式(信号注入)+ 系统调用退出抢占 +> - sysmon 是永不休息的"巡逻兵",负责 IO 轮询、抢占检查和 GC 触发 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Goroutine]] — Goroutine 的基础概念 +- [[hzh/GolangStar/Go语言基础/Go语言函数]] — 函数的返回值与 defer +- [[hzh/GolangStar/Go语言原理/内存管理]] — GMP 与内存管理的协作 +- [[hzh/GolangStar/Go语言原理/垃圾回收]] — sysmon 如何触发 GC +- [[hzh/GolangStar/Go面试题库/GMP面试题]] — GMP 调度相关高频面试题 + diff --git a/hzh/GolangStar/Go语言原理/interface原理.md b/hzh/GolangStar/Go语言原理/interface原理.md index 8c6d9d7..cdefa72 100644 --- a/hzh/GolangStar/Go语言原理/interface原理.md +++ b/hzh/GolangStar/Go语言原理/interface原理.md @@ -1,239 +1,191 @@ --- -tags: - - Go - - golang - - go原理深入 - - defer原理 +tags: [go, golang, go-principle, interface] +create time: 2026-06-07 15:40 --- -# interface原理 +# Interface 底层原理 -go语言并非传统意义上的面向对象的语言,他不像Java或者c++一样有类,继承等一些特性,但是我们也可以借助go语言中的struct和interface来实现这种面向对象的编程。在前面的基础章节我们了解到go语言中interface其实就是一组方法的声明,任何类型的对象实现了接口的全部方法就是这个接口的一个实现。这一章节我们主要分析一下interface的底层实现。 +## 概述 -## interface的底层原理 +本文从 runtime 源码角度解析 Go 接口的两种内部表示:空接口(eface)和非空接口(iface),以及 itab 的创建、缓存机制。理解接口底层能让你写出更高效的类型断言代码,也能解释为什么 `nil interface` 不等于 `interface(nil)`。 -### 空接口interface{} +> [!question] ❓ 思考 +> 为什么 `var p *int = nil; var i io.Writer = p` 中 `i != nil`?空接口 `interface{}` 和非空接口在内存布局上有什么本质区别?itab 为什么需要缓存? -没有定义任何方法的接口为空接口,空接口可以接收任意数据类型,就是说可以将任意类型的数据赋值给一个空接口,空接口的结构定义位于`src/runtime/runtime2.go`,定义如下: +## 正文 + +### 一、空接口:eface + +没有任何方法声明的接口就是空接口 `interface{}`: ```go type eface struct { - _type *_type - data unsafe.Pointer + _type *_type // 动态类型元数据 + data unsafe.Pointer // 动态值(指向数据的指针) } ``` -\_type:指向接口的动态类型元数据,即接口变量的类型 +两个字段各 8 字节,共 16 字节。赋值前后对比: -data:指向接口的动态值,data是一个指向变量本身的指针 +```mermaid +flowchart LR + Before["var e interface{}
_type=nil, data=nil"] --> After["e = 42
_type→int元数据, data→&42"] + style Before fill:#ffebee + style After fill:#e8f5e9 +``` -#### \_type是什么 - - \_type 是 go 里面所有类型的一个抽象,里面包含了类型的大小,哈希,对齐以及k类型编号等信息,决定了data如何解释和操作,Go语言中几乎所有的数据结构都可以抽象成`_type`。关于\_type的定义在源文件`src/runtime/type.go`,具体定义如下: +#### _type:类型的"身份证" ```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 + size uintptr // 类型大小 + ptrdata uintptr // 前缀含指针的字节数 + hash uint32 // 类型的 hash 值 + tflag tflag // 类型标志 + align uint8 // 内存对齐 + fieldAlign uint8 + kind uint8 // 类型编号(struct/function/interface...) + equal func(unsafe.Pointer, unsafe.Pointer) bool // 比较函数 + gcdata *byte + str nameOff + ptrToThis typeOff } ``` -什么是动态类型和动态值呢,举个例子 +`_type` 是 Go 所有类型的抽象基类——int、string、struct 等所有类型都对应一个 `_type` 实例。 -```go -package main +### 二、非空接口:iface + itab -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 + 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 // 接口本身的描述(方法列表) + _type *_type // 实现类型的描述 + hash uint32 // _type.hash 的副本,用于类型 switch + _ [4]byte + fun [1]uintptr // 可变长数组:接口方法的实际地址 } ``` -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() + Call() + SendMessage() } -func main() { - a := Apple{PhoneName: "apple"} - var ifc Phone - ifc = a - fmt.Println(ifc) +type Apple struct { PhoneName string } +func (a Apple) Call() {} +func (a Apple) SendMessage() {} + +var ifc Phone = Apple{PhoneName: "iphone"} ``` -在程序第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 +```mermaid +graph TB + ifc["iface
tab → itab | data → &Apple"] + + itab["itab"] --> inter["interfacetype
Phone.Call, Phone.SendMessage"] + itab --> atype["_type
Apple 的元数据"] + itab --> fun["fun[0]=Call_addr
fun[1]=SendMessage_addr"] + + apple["Apple 实例
PhoneName='iphone'"] -.data指向.-> ifc + + style ifc fill:#e3f2fd + style itab fill:#fff9c4 + style inter fill:#e8f5e9 + style apple fill:#fce4ec ``` -同类型的接口多次赋值,虽然具体类型的值不同,但是他们的类型相同,方法列表也相同,显然这个itab结构体是可以被复用的,如果我们每次都创建一个新的itab的话,性能无疑会大大下降。所以可以把用到的itab结构体缓存起来,每个非空的interface的接口类型和具体类型就可以唯一确定一个类型的itab。 +> [!note] 📝 源码要点 +> itab.fun 是一个**可变长数组**。它保存的不是接口定义中的方法地址,而是具体类型(Apple)中对应方法的实际地址。这是通过求接口方法列表和具体类型方法列表的交集得到的。 -##### itabTable +### 三、Itab 缓存:itabTable -go语言采用itabTable这个结构来缓存所有的itab结构,itabTable的结构定义在`src/runtime/iface.go`,定义如下: +每次给接口赋值都要查找或创建 itab 吗?不会。Go 用哈希表缓存所有已创建的 itab: ```go type itabTableType struct { - size uintptr // entries数组的长度 - count uintptr // 当前数组中实际itab的数量 - entries [itabInitSize]*itab // 哈希表 + size uintptr + count uintptr + entries [itabInitSize]*itab // 哈希表,2^11 = 2048 个槽位 } ``` -itabTable实际用来存储itab结构的其实是这个entries 结构,entries 是一个hash表,key为接口类型与实际类型分别哈希后的异或值 +查找流程: + +```mermaid +flowchart TD + Start["给接口赋值"] --> Hash{"itab 已在缓存?"} + Hash -->|是| Return["直接复用 existing itab"] + Hash -->|否| Create["创建新 itab:
1. 填充 inter 和 _type
2. 求方法交集 → fun[]
3. CAS 插入缓存"] + Create --> Insert{"哈希冲突?"} + Insert -->|是| Quadratic["二次寻址法找空位"] + Insert -->|否| Store["存入计算出的槽位"] + Quadratic --> Store + Store --> Done["完成"] + Return --> Done + style Return fill:#e8f5e9 + style Create fill:#fff9c4 +``` + +哈希 key 的计算方式: ```go -func itabHashFunc(inter *interfacetype, typ *_type) uintptr { +func itabHashFunc(inter *interfacetype, typ *_type) uintptr { return uintptr(inter.typ.hash ^ typ.hash) } ``` -所以key为整形,所以在实现的时候,go语言采用了空间换时间的思想,通过一个数组来实现,数组的每个元素为itab指针,其结构如下: +同类型的多次赋值只创建一个 itab: -![](https://golangstar.cn/assets/img/go语言系列/interface原理/image-4.png) +```go +var ifc Phone +ifc = Apple{...} // 创建 itab,缓存起来 +ifc = Apple{...} // 直接从缓存取 +ifc = Nokia{...} // 为 Nokia 创建新的 itab +``` -我们在查找一个itab是否存在的时候, +### 四、常见陷阱:nil interface ≠ interface(nil) -1. 先计算接口类型的哈希值hash1和实际类型的哈希值hash2 +```go +var p *int = nil +var i io.Writer = p // i 的 data 是 nil,但 tab 不为 nil +fmt.Println(i == nil) // false! +``` -2. hash1与hash2做异或运算得到最终哈希值hash +原因:接口等于 nil 的判定条件是 `tab == nil && data == nil`。虽然 `data` 是 nil,但 `tab` 指向了一个有效的 itab(其中 fun 全为零),所以 `i != nil`。 -3. 在entries数组中找到下标为hash的位置 +```go +var i2 io.Writer = nil // 这才是真正的 nil interface +fmt.Println(i2 == nil) // true +``` -4. 如果能查询到对应的itab指针(这里需要比较接口类型和实际类型,因为可能出现产生hash冲突,槽位被占用的情况),就直接拿来使用。若没有就要再创建,然后添加到itabTable中。 +> [!warning] ⚠️ 面试高频 +> 这个知识点是 Go 面试中最常考的接口相关题目之一。核心结论:**接口由 (tab, data) 两个字段组成,只有两者都为 nil 时接口才等于 nil。** -对于hash冲突问题,采用的是开放地址法,根据计算的空位发现槽位被占用,则采用**二次寻址法**在数组后面寻找空位插入 +### 五、性能影响 +1. **接口赋值有轻微开销**:需要查找/创建 itab,但命中缓存后几乎无额外成本 +2. **接口比较比类型比较慢**:需要先比较 `_type.hash`,再比较完整 `_type` +3. **避免不必要的接口转换**:频繁的类型断言会增加运行时开销 +## 小结 +- 空接口 `eface` 是 `(_type, data)` 二元组;非空接口 `iface` 多了 `itab` +- itab 包含接口类型、实现类型和方法地址,创建后缓存到 itabTable +- 接口判 nil 要看 tab 和 data 是否同时为 nil +- 同类型的接口赋值复用同一个 itab,首次创建有开销,后续命中缓存 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言接口]] — 接口的基础用法与隐式实现 +- [[hzh/GolangStar/Go语言原理/interface原理]] — (同名文件) +- [[hzh/GolangStar/Go面试题库/Interface面试题]] — Interface 相关高频面试题 diff --git a/hzh/GolangStar/Go语言原理/map原理.md b/hzh/GolangStar/Go语言原理/map原理.md index 9c682f8..e0a286f 100644 --- a/hzh/GolangStar/Go语言原理/map原理.md +++ b/hzh/GolangStar/Go语言原理/map原理.md @@ -1,840 +1,210 @@ --- -tags: - - Go - - golang - - go原理深入 - - map原理 +tags: [go, golang, go-principle, map] +create time: 2026-06-07 15:15 --- -# map原理 +# Map 底层原理 -## map是什么 +## 概述 -map就是一个key/value键值对的集合,可以根据key在O(1)的时间复杂度内取到value,有点类似与我们常用的数组或者切片结构,可以把数组看作是一种特殊的map,数组的key为数组的下标,而map的key可以为任意的可比较结构。在map中key不允许重复且要能够比较。 +本文深入 Go runtime 源码,解析 `map` 的哈希表实现、桶(bucket)结构、渐进式扩容机制,以及遍历随机化的设计考量。Map 是 Go 中使用最频繁的数据结构之一,理解其底层原理对排查并发问题和优化性能至关重要。 -在go语言中,map的底层采用hash表,用变种拉链法来解决hash冲突问题。 +> [!question] ❓ 思考 +> 为什么同一个 map 变量多次遍历的顺序可能不同?Go 为什么要用"写时迁移"而不是在创建时就一次性建好所有桶?负载因子为什么是 6.5 而不是其他值? -## 哈希冲突 +## 正文 -哈希表的原理是将多个k-v键值对散列的存储在buckets中,buckets可以理解为一个连续的数组,所以给定一个key/value键值对,我们要将其存储到合适的位置需要经过两步骤: +### 一、Map 的整体架构:hmap + bmap -1. 计算hash值:`hash = hashFunc(key)` +Go 的 map 是一个指向 `hmap` 结构体的指针。真正存储数据的是 `buckets` 数组,每个桶(`bmap`)可容纳 8 个键值对: -2. 计算索引位置: `index = hash % len(buckets)` +```mermaid +graph TB + hmap["hmap
count/flags/B/buckets/oldbuckets..."] --> buckets["buckets 数组
[b0][b1][b2]..."] + buckets --> b0["b0: topbits+keys+vals
(8 kv pairs)"] + buckets --> b1["b1: topbits+keys+vals
(8 kv pairs)"] + buckets --> bo["overflow bucket
额外桶"] + hmap --> extra["extra: overflow 链表管理"] + style hmap fill:#e3f2fd + style buckets fill:#fff9c4 +``` -第一步是根据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的结构体定义: +#### hmap 核心字段 ```go -// A header for a Go map. +// src/runtime/map.go 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 int // 元素个数 (len(map)) + flags uint8 // 状态标志 + B uint8 // log2(桶数),桶数 = 2^B + noverflow uint16 // 溢出桶数量近似值 + hash0 uint32 // 哈希种子 + buckets unsafe.Pointer // 当前桶数组 + oldbuckets unsafe.Pointer // 旧桶数组(扩容期间使用) + nevacuate uintptr // 扩容进度计数器 + extra *mapextra // 溢出桶管理 } ``` -字段含义: - -| 字段 | 释义 | -| ----------- | ------------------------------------------------------------------------------------ | -| 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的结构定义: +#### bmap 结构 ```go type bmap struct { - topbits [8]uint8 - keys [8]keytype - values [8]valuetype - overflow uintptr + topbits [8]uint8 // 8 个 key 的 hash 高 8 位(快速比较用) + keys [8]keytype // 8 个 key(连续存储) + values [8]valuetype // 8 个 value(连续存储) + overflow *bmap // 指向溢出桶 } ``` -| 字段 | 释义 | -| ----------- | ------------------------------------------------- | -| topbits | 存储了bmap里8个key/value键值对的每个key根据哈希函数计算出的hash值的高 8 位 | -| keys | 存储了bmap里8个key/value键值对的key | -| values | 存储了bmap里8个key/value键值对的value | -| overflow | 指向溢出桶的指针 | +> [!note] 📝 源码要点 +> key 和 value 不是交错存储的(不是 k1,v1,k2,v2...),而是先存完 8 个 key 再存 8 个 value。这样可以消除字节对齐带来的空间浪费。 -再解释一下这个`tophash`,go语言的`map`会根据每一个key计算出一个`hash`值,有意思的是,对这个`hash`值的使用,go语言并不是一次性使用的,而是分开使用的,在使用中,把求得的这个`hash`值按照用途一分为二:高位和低位 +### 二、Hash 值的分段使用 -![](https://golangstar.cn/assets/img/go语言系列/map原理/image.png) +Go 将计算出的 hash 值分为两部分使用: -假设我们对一个`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对应类型的零值,还会返回一个值存不存在的布尔值 +```mermaid +flowchart LR + H["hash = 0xABCD1234"] --> high["高8位: 0xAB → topbits[0]"] + H --> low["低8位: 0x34 → 定位槽位"] + style H fill:#e1f5fe + style high fill:#fff9c4 + style low fill:#fff9c4 ``` -虽然这两种方法在返回值上很接近,后者只是多出了一个key存不存在的布尔值,但是在运行时调用的方法却不一样。 +查找流程: +1. 用 hash 的低 N 位找到目标桶 +2. 用 hash 的高 8 位与桶内 `topbits` 做**快速比较** +3. 只有 topbits 匹配时才做完整的 key 比较 -源码链接:[ Map 源码梳理](https://ls8sck0zrg.feishu.cn/docx/XJfDdQ6pgoYIjqxzgpJcFycTn1d##YHtod9JyPoVyNbxrbKhc78m9nCm)&##x20; +这种设计避免了每次都要比较完整 key 的开销。 -大致原理如下图所示: +### 三、读写操作流程 -![](https://golangstar.cn/assets/img/go语言系列/map原理/image-1.png) +#### 3.1 读取流程 -这里梳理一下步骤: - -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 +```mermaid +flowchart TD + Start["读 map[key]"] --> Empty{"map 为空?"} + Empty -->|是| Zero["返回零值"] + Empty -->|否| Writing{"正在写?"} + Writing -->|是| Panic["fatal error: concurrent map read and map write"] + Writing -->|否| Hashing["计算 hash"] + Hashing --> Growing{"正在扩容?"} + Growing -->|是| CheckBucket{桶已迁移?} + CheckBucket -->|是| NewBucket["在新桶中查找"] + CheckBucket -->|否| OldBucket["在旧桶中查找"] + Growing -->|否| NormalBucket["在当前桶中查找"] + NewBucket --> TopMatch["比较 topbits"] + OldBucket --> TopMatch + NormalBucket --> TopMatch + TopMatch --> KeyMatch{"key 相等?"} + KeyMatch -->|是| Found["返回 value"] + KeyMatch -->|否| NextSlot{"后继空状态?"} + NextSlot -->|是| NotFound["返回零值,key 不存在"] + NextSlot -->|否| NextTop{"topbits 匹配?"} + NextTop -->|是| KeyMatch + NextTop -->|否| Overflow{"溢出桶?"} + Overflow -->|有| OldBucket + Overflow -->|无| NotFound + style Panic fill:#ffebee + style Found fill:#e8f5e9 + style NotFound fill:#fff3e0 ``` -原`map`中存在`key`是,则更新对应的值为`value`,若`map`中不存在`key`时,则插入键值对`key/value`。 +#### 3.2 写入流程 -但是有两点需要注意: - -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 +// src/runtime/map.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 - } - ... + if !h.growing() && (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { + hashGrow(t, h) // 触发扩容 + goto again + } + // ... 正常插入逻辑 } ``` -可以看到map会在两种情况下触发扩容: +触发扩容的两个条件: +- **负载因子 > 6.5**:每个桶平均 6.5 个元素(接近满的 8 个) +- **溢出桶过多**:说明数据结构已经严重倾斜 -* **map的负载因子已经超过 6.5** +### 四、渐进式扩容:写时迁移 -* **溢出桶的数量过多** +这是 Go map 设计中最精妙的部分——**扩容不是一次性完成的,而是在每次写入时逐步迁移**。 -在扩容的时候还有一个条件`!h.growing()`,这是因为map的扩容并不是一个原子操作不是一次性完成的,所以需要判断一下,当前map是否正处于扩容状态,避免二次扩容造成混乱。 - -而这两种情况下,扩容策略是不同的 - -* 负载因子已经超过 6.5: 双倍扩容 - -* 溢出桶的数量过多:等量扩容(一般认为溢出桶数量接近正常桶数量时) - -什么是负载因子? - -```plain&##x20;text -负载因子 = 哈希表中的元素数量 / 桶的数量 +```mermaid +flowchart TD + GH["hashGrow: 分配新桶数组
大小为旧的 2 倍"] --> GW["growWork: 迁移一个旧桶"] + GW --> EVAC["evacuate: 逐个槽位迁移"] + EVAC --> DEST{"双倍扩容?"} + DEST -->|是| TwoDest["数据可能去两个目标桶
原位置 / 原位置+偏移量"] + DEST -->|否| OneDest["数据留在原位置"] + TwoDest --> Done{"所有旧桶迁移完?"} + OneDest --> Done + Done -->|否| GW + Done -->|是| Free["释放旧桶数组"] + style GH fill:#e3f2fd + style Free fill:#e8f5e9 ``` -**为什么负载因子是6.5?** +关键点: +- `hashGrow` 只负责分配新桶并挂到 `oldbuckets` 上 +- `growWork` 在每次 `mapassign` / `mapdelete` 时被调用,迁移当前桶 + 额外一个桶 +- `nevacuate` 记录迁移进度,小于该值的桶已完成迁移 +- 双倍扩容时,旧桶 i 的数据可能分散到新桶 i 或 i + 2^(B-1) +- 等量扩容时,数据位置不变 -源码里对负载因子的定义是6.5,是经过测试后取出的一个比较合理的值, +> [!tip] 💡 理解要点 +> "写时迁移"让扩容的成本分摊到每一次写入操作上,避免了一次性大量拷贝导致的 STW(Stop-The-World)。 -每个 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) +delete(m, key) // 标记为 emptyRest,但内存不释放 +``` - 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 +删除会将对应槽位的 `tophash` 设为 `emptyRest`,并向后推进这个状态(以便查找时提前终止)。但注意:**被删除的 key/value 占用的内存永远不会释放**,这是 map 频繁增删会导致内存泄漏的原因。 - 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 +for k, v := range m { + // 每次遍历的顺序可能不同! } ``` -go语言在对map进行过扩容的时候,并不是一次性将map的所有数据从旧的桶搬到新的桶,如果map的数据量很大,会非常影响性能,而是采用一种“渐进式”的数据转移技术,遵循写时复制(copy on write)的规则,每次只对使用到的数据做迁移。 +原因有两个: +1. **随机起始点**:遍历时随机选择一个桶和槽位作为起点 +2. **扩容导致位置变化**:如果遍历过程中触发了扩容,key 的位置会发生变化 -简单分析一下扩容过程: - -通过代码分析,`hashGrow()`函数是在mapassign函数中被调用,所以,扩容过程会发生在map的赋值操作,在满足上述两个扩容条件时触发。 - -扩容过程中大概需要用到两个函数,**`hashGrow()`和`growWork()`。**&##x5176;中hashGrow()函数只是分配新的 buckets,并将老的 buckets 挂到了 oldbuckets 字段上,并未参与真正的数据迁移,而数据迁移的功能是由growWork()函数完成的。 - -##### 迁移时机 - -**`growWork()`** 函数会在 `mapassign `和 `mapdelete `函数中被调用,所以数据的迁移过程一般发生在插入或修改、删除 key 的时候。在扩容完毕后(预分配内存),不会马上就进行迁移。而是采取**写时复制**的方式,当有访问到具体 bukcet 时,才会逐渐的将 oldbucket 迁移到 新bucket中。 - -**`growWork()`函数定义如下:** +这其实是 Go 有意为之的设计——防止程序员依赖遍历顺序,因为那本质上是不稳定的。 ```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) - } -} +// src/runtime/map.go +r := uintptr(fastrand()) +it.startBucket = r & bucketMask(h.B) +it.offset = uint8(r >> h.B & (bucketCnt - 1)) ``` -下面分析一下`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,则说明遍历完了,结束遍历 +### 七、线程安全性 +> [!warning] ⚠️ 重要 +> Go 原生 map **不是线程安全的**。并发读写会触发 `fatal error: concurrent map read and map write`。如果需要并发安全,请使用 `sync.Map` 或手动加锁。 +## 小结 +- Map 基于哈希表实现,每个桶存 8 个键值对 +- 使用 hash 高 8 位做快速比较,低 N 位定位桶 +- 扩容采用渐进式写时迁移,成本分摊到每次写入 +- 删除不释放内存,频繁增删需注意内存增长 +- 遍历顺序随机化是故意设计,不应依赖 +## 关联笔记 +- [[hzh/GolangStar/Go语言基础/Go语言Map]] — Map 的基础用法 +- [[hzh/GolangStar/Go语言原理/sync.map原理]] — 并发安全的 sync.Map +- [[hzh/GolangStar/Go面试题库/Map面试题]] — Map 相关高频面试题 diff --git a/hzh/GolangStar/Go语言原理/memory management原理.md b/hzh/GolangStar/Go语言原理/memory management原理.md new file mode 100644 index 0000000..7f53883 --- /dev/null +++ b/hzh/GolangStar/Go语言原理/memory management原理.md @@ -0,0 +1,159 @@ +--- +tags: [go, golang, go-principle, memory-management] +create time: 2026-06-07 15:55 +--- + +# 内存管理机制 + +## 概述 + +本文从 TCMalloc 的设计思想出发,解析 Go 堆内存管理的三层架构(mheap → mcentral → mcache),以及小对象和大对象的分配策略。理解内存管理能帮你优化程序的内存使用、减少 GC 压力。 + +> [!question] ❓ 思考 +> 为什么 Go 的内存管理器要分成三层?每个 P 都配一个 mcache 真的是为了无锁访问吗?超过 32KB 的大对象为什么不走 mcache 而直接去 mheap 拿? + +## 正文 + +### 一、设计渊源:TCMalloc + +Go 的内存管理借鉴了 Google 的 **TCMalloc**(Thread-Caching Malloc)思想。TCMalloc 的核心设计是"以空间换时间"——用多层缓存避免锁竞争。 + +```mermaid +flowchart LR + ThreadCache["ThreadCache
每个线程独立,无锁"] --> CentralCache["CentralCache
所有线程共享,加锁"] + CentralCache --> PageHeap["PageHeap
OS 内存页管理"] + style ThreadCache fill:#e8f5e9 + style CentralCache fill:#fff9c4 + style PageHeap fill:#ffebee +``` + +TCMalloc 的基本单位: +| 层级 | 名称 | 说明 | +|------|------|------| +| 最底层 | **Page** | 与 OS 内存页对应,通常是 4KB | +| 中层 | **Span** | 一组连续的 Page,是内存管理的基本单位 | +| 上层 | **ThreadCache** | 每个线程私有的空闲块链表,按大小分类 | +| 顶层 | **CentralCache** | 所有线程共享,加锁访问 | + +### 二、Go 的内存管理架构 + +Go 将 TCMalloc 改造为三层层级结构,适配 GMP 模型: + +```mermaid +graph TB + subgraph M["用户请求分配"] + Small["小对象 (≤32KB)"] + Large["大对象 (>32KB)"] + end + + subgraph SmallPath["小对象路径"] + MC["mcache
每P一个,无锁"] --> MSC["mcentral
全局共享,加锁"] + MSC --> MH["mheap
管理OS页面"] + end + + subgraph LargePath["大对象路径"] + MH2["mheap"] --> OS["直接向OS申请"] + end + + Small --> SmallPath + Large --> LargePath + style MC fill:#e8f5e9 + style MSC fill:#fff9c4 + style MH fill:#ffebee +``` + +#### 核心组件对照表 + +| Go 组件 | TCMalloc 对应 | 说明 | +|---------|-------------|------| +| `mcache` | ThreadCache | 每个 P 私有,无锁访问 | +| `mcentral` | CentralCache | 全局共享,需加锁 | +| `mheap` | PageHeap | 管理 OS 页面的树结构 | + +> [!tip] 💡 为什么是每个 P 而不是每个 M? +> Go 最多有 GOMAXPROCS 个 M 同时运行,每个 P 配一个 mcache 就能保证所有 M 在各自的 P 上执行时无锁分配。这比 TCMalloc 的"每个线程一个 cache"更精确。 + +### 三、Span Class:67 种规格 + +Go 对小对象按大小分成了 67 个 class,每个 class 对应固定的对象大小和 span 包含的对象数: + +``` +class bytes/obj bytes/span objects tail waste + 1 8 8192 1024 0 87.50% + 2 16 8192 512 0 43.75% + ... + 17 256 8192 32 0 5.86% + ... + 58 16384 16384 1 0 12.49% + 66 32768 32768 1 0 12.50% + 0 >32KB N/A 1 N/A N/A +``` + +> [!note] 📝 源码要点 +> Span class 由两个因素决定:(1) 对象大小所在的 size class;(2) 是否包含指针(noscan)。公式:`spanClass = sizeClass<<1 | noscan`。这意味着同一个 size class 会有 scan/noscan 两种变体。 + +#### 分配一个小对象的步骤 + +```go +// 以分配一个含指针的 20 字节对象为例 +size := 20 + +// Step 1: 根据大小找 size class +sizeclass = size_to_class8[divRoundUp(size, 8)] // = 3 (范围 (16, 32]) + +// Step 2: 计算 span class (含指针 => noscan=false) +spc = makeSpanClass(sizeclass, false) // = 3<<1|0 = 6 + +// Step 3: 从当前 P 的 mcache 中获取对应 span +span = c.alloc[spc] + +// Step 4: 从 span 中取一个空闲对象 +v = nextFreeFast(span) +if v == 0 { + v, span, _ = c.nextFree(spc) // span 不够时向 mcentral 申请 +} +``` + +### 四、大对象分配 (>32KB) + +大对象不走 mcache/mcentral,直接在 mheap 上分配: + +```mermaid +flowchart TD + Req["分配 >32KB 对象"] --> Calc["计算需要的页数和 span class=0"] + Calc --> SearchFree{"搜索 free 树"} + SearchFree -->|找到| Split{span > 需求?} + SearchFree -->|未找到| SearchScav{"搜索 scav 树
已扫描的空闲 span"} + SearchScav -->|找到| Split + SearchScav -->|未找到| OSAlloc["向 OS 申请 mmap"] + OSAlloc --> ReSearch["重新搜索 free + scav"] + ReSearch --> Split + Split -->|是| Divide["分割 span
一部分给请求,余下放回 free"] + Split -->|否| Done["直接使用"] + Divide --> Done + style Req fill:#e3f2fd + style Done fill:#e8f5e9 +``` + +> [!warning] ⚠️ 注意 +> 频繁分配超大对象会显著增加内存消耗和 GC 负担。建议对大数据使用 `[]byte` 复用或对象池(`sync.Pool`)。 + +### 五、Tiny Object 优化 + +Go 还有一个特殊类别:**Tiny Object**(1~16 字节且不含指针)。多个 tiny object 可以共用同一个 span 中的不同 slot,大幅减少小对象的数量浪费。 + +> [!info] ℹ️ 补充 +> Tiny object 从 Go 1.9 引入。对于常见的短字符串、小整数包装等场景,tiny object 能将内存利用率提升 50% 以上。 + +## 小结 + +- Go 内存管理基于 TCMalloc,分为 mcache(无锁)→ mcentral(加锁)→ mheap(OS 页面)三层 +- 小对象(≤32KB)按 67 种 span class 分类分配,每个 P 的 mcache 实现无锁 +- 大对象(>32KB)直接在 mheap 上分配,可能触发 OS mmap +- Tiny object(1~16 字节无指针)可合并存放,节省内存 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 模型中 P 与 mcache 的关系 +- [[hzh/GolangStar/Go语言原理/逃逸分析]] — 变量分配到栈还是堆 +- [[hzh/GolangStar/Go语言原理/垃圾回收]] — GC 如何配合内存管理 diff --git a/hzh/GolangStar/Go语言原理/slice原理.md b/hzh/GolangStar/Go语言原理/slice原理.md index 2635c8a..56e1639 100644 --- a/hzh/GolangStar/Go语言原理/slice原理.md +++ b/hzh/GolangStar/Go语言原理/slice原理.md @@ -1,298 +1,181 @@ --- -tags: - - Go - - golang - - go原理深入 - - slice原理 +tags: [go, golang, go-principle, slice] +create time: 2026-06-07 15:10 --- -# slice原理 +# Slice 底层原理 -## slice是什么 +## 概述 -go语言中slice可以理解为动态数组,区别于数组,其长度不是固定的,在使用时,我们不必担心其容量大小不够。当需要往切片追加元素时,如果切片容量大小不足,会自动扩容。基于其这个特性,我们在真正编程过程中,更多的会使用切片,而不是数组。 +本文从 runtime 源码角度深入解析 Go Slice 的三字段结构、扩容策略(含 1.18 版本变更),以及截取、复制、append 等操作背后的内存行为。Slice 是 Go 中最常用的数据结构之一,理解其底层机制能有效避免常见的"切片共享数组"陷阱。 -在使用过程中,区别于数组,声明切片时,也只需要制定声明数据类型,而不需要指定大小 - -```go -var arrs []int -``` - -## slice的数据结构 - -切片的底层数据结构定义如下: +> [!question] ❓ 思考 +> 为什么 `arr2 := arr1[1:3]` 之后修改 `arr2[0]` 会影响 `arr1`?Slice 赋值到底是深拷贝还是浅拷贝?如果容量满了再 append,Go 会怎么做? + +## 正文 + +### 一、Slice 的结构:一个指针 + 两个长度 + +Slice 在 runtime 层面的定义非常简洁: ```go +// src/runtime/slice.go type slice struct { - array unsafe.Pointer - len int - cap int + array unsafe.Pointer // 指向底层数组 + len int // 当前已使用的元素个数 + cap int // 底层数组的总容量 } ``` -![](https://golangstar.cn/assets/img/go语言系列/slice原理/image.png) +三个字段共 24 字节(64 位系统)。用一个图来描述: -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 +```mermaid +graph LR + S["slice struct
array | len=3 | cap=5"] --> A["底层数组
[1][2][3][4][5]"] + style S fill:#e1f5fe + style A fill:#fff9c4 ``` -注意这里两种make创建的方式,当传递了容量参数时,若传递的参数比前面len的参数值大,那么实际创建的切片大小时是多余元素个数的,说明预留了空间。如果不指定cap,则默认创建cap和len大小相同的切片。 +| 字段 | 含义 | 示例值 | +|------|------|--------| +| `array` | 指向底层连续内存的指针 | `0xc00001a180` | +| `len` | 可访问的元素数量 | `3` | +| `cap` | 底层数组总大小(不含偏移) | `5` | -示例: +> [!tip] 💡 理解要点 +> `cap >= len` 始终成立。`cap - len` 表示还能追加多少个元素而不触发扩容。 + +### 二、Slice 的三种初始化方式 ```go -package main +// 1. 从已有切片截取 +s1 := s[low : high : max] -import ( - "fmt" -) +// 2. 字面量初始化 +s2 := []int{1, 2, 3} -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)) +// 3. make 创建 +s3 := make([]int, 3) // len=3, cap=3 +s4 := make([]int, 3, 5) // len=3, cap=5 +``` - arr3 := []int{1,2,3,4,5} - arr4 := arr3[1:3] - fmt.Printf("arr3=%v\n",arr3) - fmt.Printf("arr4=%v\n",arr4) +注意截取操作 `s[low : high : max]` 中: +- 新切片的 `len = high - low` +- 新切片的 `cap = max - low` +- 新切片与原切片**共享同一块底层数组** + +### 三、截取与赋值:共享底层数组 + +这是初学者最容易踩坑的地方——截取和赋值都不会复制底层数据: + +```go +src := []int{1, 2, 3, 4, 5} +dst := src[1:3] // dst 指向 src 的底层数组 +dst[0] = 100 // src 变成了 [1, 100, 3, 4, 5] +``` + +这是因为 slice 赋值实际上只是复制了三个字段(24 字节),其中 `array` 指针指向同一片内存区域: + +```mermaid +flowchart LR + src["slice[src]
array→▲"] --> A["底层数组
[1][100][3][4][5]"] + dst["slice[dst]
array→▲"] --> A + style A fill:#fff9c4 +``` + +> [!warning] ⚠️ 常见陷阱 +> 函数间传递 slice 时,接收方对元素的修改会影响调用方。如果需要完全独立的副本,必须用 `copy()` 或手动分配新切片。 + +#### 如何真正复制一个 slice + +```go +src := []int{1, 2, 3, 4, 5} +dst := make([]int, len(src)) +copy(dst, src) // 真正的独立副本 +dst[0] = 100 // src 不受影响 +``` + +### 四、Append 与扩容策略 + +#### 4.1 Append 的行为 + +`append` 的本质是对 slice 结构体的**值拷贝**——每次调用都产生一个新的 slice 结构体(但共享底层数组),然后写入新元素: + +```go +arr1 := make([]int, 0, 4) +arr1 = append(arr1, 1) // arr1=[1] +arr2 := append(arr1, 2) // arr2=[1,2] +arr3 := append(arr1, 3) // arr3=[1,3] — arr2 被覆盖了! +``` + +为什么 `arr2` 变成了 `[1, 3]`?因为 `arr1`、`arr2`、`arr3` 共享同一底层数组,`arr3` 的写入覆盖了 `arr2` 写入的位置。 + +#### 4.2 扩容公式 + +当 `len == cap` 时,`append` 需要分配更大的底层数组。扩容策略经历了版本变化: + +**Go 1.17 及之前:** + +```mermaid +flowchart TD + Start["cap < 1024?"] -->|是| Doubles["newCap = oldCap * 2"] + Start -->|否| Quarter["newCap = oldCap + oldCap/4"] + Doubles --> Check{"newCap < 需要的容量?"} + Quarter --> Check + Check -->|是| Doubles2["newCap = newCap * 2"] + Check -->|否| Final["newCap = 需要的容量"] + Doubles2 --> Final +``` + +**Go 1.18 及之后(更平滑的增长曲线):** + +``` +newCap = oldCap + (oldCap + 3*256) / 4 +``` + +这个公式让小容量时接近翻倍,大容量时逐渐趋近于 1.25 倍,避免了大切片扩容时的内存浪费。 + +> [!note] 📝 源码要点 +> 扩容逻辑位于 `src/runtime/slice.go` 的 `growslice` 函数。它会根据元素类型(是否包含指针)选择不同的内存分配策略,并在某些情况下直接复用旧数组(当容量增长不超过 2 倍时)。 + +#### 4.3 growslice 核心流程 + +```go +// src/runtime/slice.go (伪代码简化) +func growslice(et *_type, old slice, cap int) slice { + if et.ptrdata == 0 { + // 无指针类型:一次 mallocgc 分配 hchan + buf + newBuf = mallocgc(newSize, nil, true) + memmove(newBuf, old.array, old.len) // 拷贝旧数据 + } else { + // 有指针类型:分别分配 + newBuf = mallocgc(newSize, et, true) + memmove(newBuf, old.array, old.len) + } + return slice{array: newBuf, len: old.len, cap: newCap} } ``` -运行结果: +关键点: +- 扩容一定会**分配新内存并拷贝旧数据** +- 如果元素包含指针,GC 会在拷贝后的新区间上重新扫描 +- 当请求的新容量不超过旧容量的 2 倍时,Go 会尝试直接扩展原有内存(零拷贝扩容) -```go -arr1,len=3, cap=5 -arr2,len=3, cap=3 -arr3=[1 2 3 4 5] -arr4=[2 3] -``` +### 五、性能建议 +1. **预分配容量**:已知最终大小的场景,用 `make([]T, 0, n)` 避免多次扩容 +2. **截取慎用**:截取后原数组无法被 GC 回收整片内存(只要有一个切片引用着) +3. **深拷贝需求**:用 `copy()` 而非切片赋值,避免隐式共享 +## 小结 -## 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个元素。 +- Slice 是 `(ptr, len, cap)` 三字段结构,本质是一个"窗口"指向底层数组 +- 截取和赋值都是浅操作,共享底层数组;深拷贝需用 `copy()` +- 扩容策略因版本而异:1.17 前是小容量翻倍/大容量加 1/4,1.18+ 用平滑公式 +- 预分配容量能显著减少扩容带来的开销 +## 关联笔记 +- [[hzh/GolangStar/Go语言基础/Go语言数组与切片]] — Slice 的基础用法 +- [[hzh/GolangStar/Go语言原理/string原理]] — String 的底层结构与转换 +- [[hzh/GolangStar/Go面试题库/Slice面试题]] — Slice 相关高频面试题 diff --git a/hzh/GolangStar/Go语言原理/string原理.md b/hzh/GolangStar/Go语言原理/string原理.md index 513ef43..8b41a5e 100644 --- a/hzh/GolangStar/Go语言原理/string原理.md +++ b/hzh/GolangStar/Go语言原理/string原理.md @@ -1,365 +1,167 @@ --- -tags: - - Go - - golang - - go原理深入 - - string原理 +tags: [go, golang, go-principle, string] +create time: 2026-06-07 15:00 --- -# string原理 +# String 底层原理 -## string是什么 +## 概述 -在我们编程过程中,字符串可以说是我们使用的最多的一个数据结构了,凡是涉及到文本处理的地方,我们都会用到字符串。在go语言中,字符串其实就是一串由UTF-8编码的字符序列。 +本文深入 Go runtime 源码,解析 `string` 类型的底层存储结构、与 `[]byte` 的转换机制、以及字符串拼接的性能差异。理解 string 的原理,能帮你写出更高效的文本处理代码——尤其是高频场景下的字符串拼接策略。 -接下来我们看一下官方库对`string`的一个描述。 -```go -var text string -``` -在IDE里面定义一个 `text` 的 `string` 变量,点击 `string` 跳转到其类型定义的地方,可从源代码看到对 `string` 的描述,源代码的位置在 `src/builtin/builtin.go`,描述如下: +> [!question] ❓ 思考 +> 为什么 Go 要设计一个"指针 + 长度"的结构来表示字符串,而不是直接把字符数组塞进去?如果 string 是可变的,会带来哪些安全隐患? + +## 正文 + +### 一、String 的本质:只读的字节序列 + +在 Go 语言中,字符串是一串由 UTF-8 编码(也可以不是)的字节序列。它的核心特性就三个字:**不可变**。 ```go +// src/builtin/builtin.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`字符串就是一个没有任何字符的空串`""` -- 字符串不可以被修改,所以字符串类型的值是不可变的 -所以字符串的本质是一串字符数组,每个字符在存储时都对应一个整数,也有可能对应多个整数,具体要看字符串的编码方式。可以看个例子: +注意三个关键点: +- 字符串是 8-bit 字节的集合,**不一定**是 UTF-8 文本 +- 可以为空字符串 `""`,但**不能为 nil** +- 值是不可变的(immutable) -```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`是只读的,不可以被改变,但是我们在编码过程中,进行重新赋值也是很正常的,既然可以重新赋值,为什么说不能被修改呢,这不是互相矛盾吗? - -这里要弄弄清楚一个概念,字符串修改并不等于重新赋值。我们在开发中所使用的,其实是对字符串的重新赋值,而不是修改。 +> [!tip] 💡 理解要点 +> "重新赋值"不等于"修改"。`str = "new"` 是创建新对象并改变引用,而 `str[0] = 'A'` 才是真正的修改,后者被编译器禁止。 ```go str := "Hello" -str = "Golang" // 重新赋值 -str[0] = "I" // 修改,不允许 +str = "Golang" // 合法:重新赋值,str 指向了新对象的内存 +str[0] = 'A' // 编译错误:cannot assign to str[0] ``` -示例: +### 二、String 的数据结构:两个字段就够了 + +在 runtime 层面,string 的内部表示用 `stringStruct` 描述: ```go -package main - -import "fmt" - -func main() { - var ss string - ss = "Hello" - ss[1] = "A" - fmt.Println(ss) +// src/runtime/string.go +type stringStruct struct { + str unsafe.Pointer // 指向底层字节数组的首地址 + len int // 字节数(不是字符数!) } ``` -运行结果: +两个字段,极简设计。以 `"Hello"` 为例: + +```mermaid +graph LR + ss["stringStruct"] --> ptr["str → 'H','e','l','l','o'"] + ss --> ln["len = 5"] + style ss fill:#e1f5fe + style ptr fill:#fff9c4 + style ln fill:#fff9c4 +``` + +| 字段 | 含义 | 示例值 | +|------|------|--------| +| `str` | 指向底层字节数据的指针 | `0x1040a124` | +| `len` | 字节数量 | `5` | + +> [!warning] ⚠️ 注意 +> `len` 存的是**字节数**而非字符数。对于中文等多字节字符,`len("你好")` 返回 6 而非 2。 + +既然没有容量(cap)字段,说明 string 根本无法扩容——这进一步印证了它的设计定位:轻量传递,不可修改。 + +### 三、String ↔ []byte 转换:何时拷贝? + +#### 3.1 常规转换总是发生内存拷贝 ```go -./main.go:10:12: cannot assign to ss[1] +ss := "Hello" +b := []byte(ss) // 分配新切片,拷贝数据 +s := string(b) // 再分配新空间,再拷贝回去 ``` -程序会报错,提示 `string`是不可修改的。这样一分析,那么可不可以将字符串转化为字节数组,然后通过下标修改字节数组,再转化回字符串呢,答案是可行的。 -相互转化的语法如下例所示: +转换过程可以概括为: + +```mermaid +flowchart LR + A["string
ptr + len"] -- 拷贝 --> B["[]byte
新分配"] + C["[]byte
原数据"] -- 拷贝 --> D["string
新分配"] + style A fill:#e3f2fd + style D fill:#e3f2fd +``` + +- `[]byte` → `string`:申请新空间,将数据从切片拷贝到 string 底层 +- `string` → `[]byte`:申请新切片,将 string 数据拷贝到切片 + +> [!note] 📝 源码要点 +> 这是 `runtime.stringtoslicebyte` 和 `runtime.slicetostring` 做的事情。每次调用都涉及一次 `mallocgc` + `memmove`。 + +#### 3.2 零拷贝优化:临时场景不拷贝 + +有些场景下,`[]byte` 转 `string` 不会触发内存拷贝——当转换结果仅用于临时比较或查找时: ```go -package main - -import "fmt" - -func main() { - var ss string - ss = "Hello" - strByte := []byte(ss) - strByte[1] = 65 - fmt.Println(string(strByte)) -} +// 以下场景不会发生内存拷贝: +if string(bytesSlice) == "Hello" // 临时比较 +_ = map[string(bytesSlice)]{...} // 用作 map key +fmt.Sprintf("%s", bytesSlice) // 作为函数参数 ``` -运行结果: +编译器识别出这些字符串不会被后续使用,直接构造一个指向原切片内存的 stringStruct 返回。 + +### 四、字符串声明方式 + +Go 提供两种声明方式: ```go -HAllo +// 双引号:普通字符串,支持转义 +s1 := "Hello\nWorld" + +// 反引号:原始字符串(raw string),内容即所见 +s2 := `{"key": "value"}` ``` -`Hello`变成了`HAllo`,好像达到了我们的目的。这里需要注意,虽然这种方式看似可行,修改了字符串`Hello`,但其实并不是我们所见的这样。最终得到的只是`ss`字符串的一个拷贝,源字符串并没有变化。 +反引号内的一切都是原始内容,适合 JSON、SQL 等包含大量特殊字符的场景。 -### string与[]byte的转化原理 +### 五、字符串拼接性能对比 -`string`与`[]byte`的转化其实会发生一次内存拷贝,并申请一块新的切片内存空间 +字符串不可变,拼接必然产生新内存分配。以下是 6 种常见方式的性能测试(1000 次拼接): -`byte`切片转化为`string`,大致过程分为两步: +| 方法 | ns/op | 分配/次 | 推荐度 | +|------|-------|---------|--------| +| `strings.Builder` | ~37K | 1 alloc | ⭐⭐⭐ | +| `strings.Join` | ~42K | 1 alloc | ⭐⭐⭐ | +| `bytes.Buffer` | ~75K | 2 alloc | ⭐⭐ | +| `append` | ~308K | 24 alloc | ⭐ | +| `+` 操作符 | ~18M | 1010 alloc | ⭐ | +| `fmt.Sprintf` | ~63M | 4275 alloc | ❌ | -1. 新申请切片内存空间,构建内存地址为`addr`,长度为`len` -2. 构建 `string`对象,指针地址为`addr`,`len`字段赋值为`len`(`string.str = addr;string.len = len;`) -3. 将原切片中数据拷贝到新申请的`string`中指针指向的内存空间 +> [!tip] 💡 实践建议 +> - **少量拼接**(2~3 个):直接用 `+`,简洁够用 +> - **大量拼接**:首选 `strings.Builder` 或 `strings.Join` +> - **永远不要**用 `Sprintf` 做拼接——它是格式化专用的 -![string与[]byte的转化](https://golangstar.cn/assets/img/go语言系列/string原理/string原理2.png) +### 六、为什么这样设计? -`string`转化为`byte`数组同样简单,大致分为两步: +string 用"指针 + 长度"而非直接存储数据,核心原因有两个: -1. 新申请切片内存空间 -2. 将`string`中指针执行内存区域的内容拷贝到新切片 +1. **传递开销极小**:无论字符串多长,复制一个 string 只需拷贝 16 字节(指针 + int64) +2. **安全性保障**:不可变性意味着任何 goroutine 都可以安全读取,无需加锁 -![string与[]byte的转化](https://golangstar.cn/assets/img/go语言系列/string原理/string原理3.png) +这也是 Go 并发编程哲学的一个缩影:**通过类型不变性来消除同步需求**。 -## []byte转化为string是否一定会发生内存拷贝 +## 小结 -很多场景中会用到[]byte转化为string,但是并不是每一次转化,都会像上述过程一样,发生一次内存拷贝。在什么情况下不会发生拷贝呢? +- String 底层是 `stringStruct{str, len}`,无 cap 字段,故不可变不可扩容 +- `[]byte` ↔ `string` 常规转换需拷贝;临时场景中编译器可做零拷贝优化 +- 大量拼接优先选 `strings.Builder` / `strings.Join`,避免 `+` 和 `Sprintf` +- "指针 + 长度"的设计让 string 成为 Go 中最轻量的引用类型之一 -转化为的字符串被用于临时场景,举几个例子: - -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`是最佳选择。 +## 关联笔记 +- [[hzh/GolangStar/Go语言基础/Go语言变量]] — 变量的声明与初始化 +- [[hzh/GolangStar/Go语言基础/Go语言数组与切片]] — Slice 与 Array 的区别 +- [[hzh/GolangStar/Go语言原理/slice原理]] — Slice 的扩容机制 diff --git a/hzh/GolangStar/Go语言原理/sync.map原理.md b/hzh/GolangStar/Go语言原理/sync.map原理.md index 1a962ca..b6a37a3 100644 --- a/hzh/GolangStar/Go语言原理/sync.map原理.md +++ b/hzh/GolangStar/Go语言原理/sync.map原理.md @@ -1,485 +1,270 @@ --- -tags: - - Go - - golang - - go原理深入 - - sync.map原理 +tags: [go, golang, go-principle, sync-map] +create time: 2026-06-07 15:20 --- -# sync.map原理 +# Sync.Map 底层原理 -## sync.map是什么 +## 概述 -sync.map是go语言在sync包下提供的一个可以提供并发访问的map。我们知道go语言的map是非线程安全的,对map的操作不是原子操作,所以在对原生的map进行并发读写的时候,很容易造成panic。 +本文深入解析 `sync.Map` 的三表架构(read / dirty / amended),以及 Store / Load / Delete / Range 四个核心操作的源码实现。与原生 map 不同,sync.Map 采用空间换时间的策略,在特定场景下(读多写少)性能远超加锁的原生 map。 +> [!question] ❓ 思考 +> 为什么 sync.Map 要设计 read 和 dirty 两个 map?如果一个 key 同时存在于两者中,它们指向的是同一个 value 吗?expunged 状态存在的意义是什么? +## 正文 -这里我们可能会想到,我们可以对map加一个锁来让其的每一次操作都受到保护,这样就实现了并发安全。但是这样对一整个map都加锁,无疑在性能上会大打折扣。所以,go语言在其1.9的版本中提供了一个并发安全的字段类型sync.Map。 +### 一、Sync.Map 的整体架构 -从功能上看,sync.map是一个读写分离的map,采用了空间换时间的策略来提高数据的读写性能,其内部其实用了两个map来实现,一个read map和一个dirty map。在并发处理上,相比于我们前面提到的普通map的无脑加锁操作,sync.map将读和写分开,读数据优先从read中读取,对read的操作是不会加锁的,当read读取不到才会去dirty读,而写数据只会在dirty写,只有对dirty操作时需要加锁的,这样区分加锁时机,就提升了并发性能。 +```mermaid +graph TB + subgraph Map["sync.Map"] + mu["mu: Mutex
保护 dirty"] + read["read: atomic.Value
readOnly{m, amended}"] + dirty["dirty: map[key]*entry
需加锁访问"] + misses["misses: int
未命中计数器"] + end -## sync.map的数据结构 + subgraph ReadOnly + rm["m: map[key]*entry"] + am["amended: bool"] + end -先来看一下sync.Map的结构定义,sync.Map定义在源文件src/sync/map.go里面,我这里去掉了注释 + subgraph Entry["entry.p 三种状态"] + nil_s["nil = 软删除
key 只存在于 read"] + exp["expunged = 假删除
key 在 read 但不在 dirty"] + val["正常值 = p → &value"] + end -```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 --> ReadOnly + ReadOnly --> rm + ReadOnly --> am + dirty --> Entry + mu -.保护.-> dirty + misses -.计数.-> dirty + style Map fill:#e3f2fd + style read fill:#e8f5e9 + style dirty fill:#fff9c4 ``` -这里主要看一下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中都存在 -``` +| 字段 | 类型 | 作用 | +|------|------|------| +| `mu` | `Mutex` | 保护 `dirty` map 的互斥锁 | +| `read` | `atomic.Value` | 原子读取的只读 map,类型为 `readOnly` | +| `dirty` | `map[interface{}]*entry` | 需要加锁的可变 map | +| `misses` | `int` | read 未命中次数,达到 dirty 长度时触发提升 | -下面看一下entry这个结构 +> [!tip] 💡 理解要点 +> read 中的 `*entry` 和 dirty 中的 `*entry` 可能指向**同一个 entry 对象**。所以通过 read 修改 value 后,dirty 也能读到新值。 + +### 二、Entry 的三种状态 + +这是 sync.Map 最精妙的设计之一——用 `entry.p` 的不同值表示不同的删除状态: ```go type entry struct { - p unsafe.Pointer // p指向真正的value所在的地址 + p unsafe.Pointer // 指向真实 value } ``` -这个p有三种取值: +| 状态 | p 的值 | 含义 | dirty 状态 | +|------|--------|------|-----------| +| 正常 | `&value` | 有效数据 | 包含该 key | +| 软删除 | `nil` | key 被删除,但未重建 dirty | dirty 可能为 nil 或包含该 key | +| 假删除 | `expunged` | key 在 read 中但不存在于 dirty | 不包含该 key,且 dirty 中有其他 read 没有的 key | -* p为nil,标记删除状态,代表这个key被删除了,此时dirty要么为nil,要么也存在这个key +> [!note] 📝 为什么需要 expunged? +> 如果只有 nil 表示删除,当从 read 读取到 nil 状态的 entry 时,无法判断 dirty 中是否也有这个 key。有了 expunged 后:nil 表示"不用查 dirty",expunged 表示"必须查 dirty",从而避免不必要的加锁。 -* 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中插入新的键值对 - -其源码如下: +#### 3.1 Store:先试无锁,失败再加锁 ```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() // 解锁 + // 第一步:无锁尝试——直接 CAS 更新 read 中的 entry + read, _ := m.read.Load().(readOnly) + if e, ok := read.m[key]; ok && e.tryStore(&value) { + return // 成功!全程无锁 + } + + // 第二步:加锁,处理各种边界情况 + m.mu.Lock() + read, _ = m.read.Load().(readOnly) // 双重检查 + if e, ok := read.m[key]; ok { + if e.unexpungeLocked() { // expunged → nil + m.dirty[key] = e // 加入 dirty + } + e.storeLocked(&value) // 更新 value + } else if e, ok := m.dirty[key]; ok { + e.storeLocked(&value) // dirty 中已有,直接更新 + } else { // 全新 key + if !read.amended { + m.dirtyLocked() // 根据 read 重建 dirty + m.read.Store(readOnly{m: read.m, amended: true}) + } + m.dirty[key] = newEntry(value) // 写入 dirty + } + 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 - } - } -} +```mermaid +flowchart TD + Start["Store key, value"] --> LockFree{"read 中存在且非 expunged?"} + LockFree -->|是| CAS["CAS 原子更新 entry.p"] + CAS --> Done["完成,无锁"] + LockFree -->|否| Lock["加锁 mu"] + Lock --> ReadExist{"read 中存在?"} + ReadExist -->|是| HandleExpunged{"p == expunged?"} + HandleExpunged -->|是| AddToDirty["expunged→nil, 加入 dirty"] + HandleExpunged -->|否| UpdateRead["更新 read 中的 entry"] + ReadExist -->|否| DirtyExist{"dirty 中存在?"} + DirtyExist -->|是| UpdateDirty["更新 dirty 中的 entry"] + DirtyExist -->|否| NewKey{"amended 标记?"} + NewKey -->|false| RebuildDirty["重建 dirty + 设 amended=true"] + NewKey -->|true| WriteDirty["直接写入 dirty"] + AddToDirty --> Unlock["解锁"] + UpdateRead --> Unlock + UpdateDirty --> Unlock + RebuildDirty --> Unlock + WriteDirty --> Unlock + Unlock --> Done + style CAS fill:#e8f5e9 + style Lock fill:#fff3e0 ``` -`unexpungeLocked() `方法 +#### 3.2 Load:优先无锁读 ```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() + read, _ := m.read.Load().(readOnly) + if e, ok := read.m[key]; ok && e != expunged { + return e.load() // 无锁命中! + } + // 未命中或需要查 dirty + m.mu.Lock() + read, _ = m.read.Load().(readOnly) // 双重检查 + if e, ok := read.m[key]; ok && e != expunged { + return e.load() + } else if ok, e := m.dirty[key]; ok { + m.missLocked() // misses++,可能触发 dirty 提升 + return e.load() + } + m.mu.Unlock() + return nil, false } ``` -`missLocked()`方法 +当 `misses >= len(dirty)` 时触发**dirty 提升**: ```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 + m.misses++ + if m.misses >= len(m.dirty) { + m.read.Store(readOnly{m: m.dirty}) // dirty → read + m.dirty = nil // dirty 被 GC + m.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中删除是直接删除 - -其源代码如下: +#### 3.3 Delete:延迟删除 vs 立即删除 ```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 + read, _ := m.read.Load().(readOnly) + if e, ok := read.m[key]; ok { + e.delete() // 软删除:p → nil,不实际移除 + return + } + // key 不在 read 中,直接从 dirty 删除 + m.mu.Lock() + read, _ = m.read.Load().(readOnly) + if e, ok := read.m[key]; ok { + e.delete() + } else if _, ok = m.dirty[key]; ok { + delete(m.dirty, key) // 真正的删除 + } + m.mu.Unlock() } ``` -delete流程如图: +> [!warning] ⚠️ 注意 +> 从 read 中删除是**延迟删除**(只改标记),从 dirty 中删除是**立即删除**(调用 `delete()`)。被软删除的 key 只有在 dirty 提升为 read 时才会真正释放。 -![](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即可。 - -其源代码如下: +#### 3.4 Range:遍历前先确保一致性 ```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 - } - } + read, _ := m.read.Load().(readOnly) + if read.amended { + m.mu.Lock() + // 双重检查 + read, _ = m.read.Load().(readOnly) + if read.amended { + read = readOnly{m: m.dirty} // 直接用 dirty 遍历 + m.read.Store(read) + m.dirty = nil + m.misses = 0 + } + m.mu.Unlock() + } + for k, e := range read.m { + v, ok := e.load() + if !ok { continue } + if !f(k, v) { break } + } } ``` -range流程如下图: +Range 会先将 dirty 提升到 read,然后对 read 进行无锁遍历,避免了遍历时加锁的性能损失。 -![](https://golangstar.cn/assets/img/go语言系列/sync.map原理/image-5.png) +### 四、P 状态流转示例 -## p的状态变化 +通过一个具体例子来理解 entry.p 的状态变化: -在sync.Map中,不论是read中还是dirty中,其底层的存储都是一个map,回顾一下这个map的结构: - -```go -m map[interface{}]*entry // read中的m字段 -dirty map[interface{}]*entry +```mermaid +flowchart LR + S1["初始: Store key1, key2 → dirty 有数据, read 为空"] + S2["Load 两次 → misses=2=len(dirty) → dirty 提升为 read"] + S3["Delete key1 → read 中 key1.p=nil"] + S4["Store key3 → dirty 重建: nil→expunged, copy 剩余"] + S5["Store key1 新值 → expunged→nil, 加入 dirty"] + style S1 fill:#e3f2fd + style S2 fill:#fff9c4 + style S3 fill:#fff3e0 + style S4 fill:#fce4ec + style S5 fill:#e8f5e9 ``` -再回顾一下这个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** - - +### 五、适用场景分析 +sync.Map 并非在所有场景都优于原生 map + mutex: +| 场景 | 推荐方案 | 原因 | +|------|---------|------| +| 读多写少 | `sync.Map` | 读操作几乎无锁 | +| 写多读少 | `map + mutex` | sync.Map 的锁竞争成本高 | +| 读写均衡 | `map + RWMutex` | 读写分离更灵活 | +| 每个 key 独立热点 | `分片 map + mutex` | 降低锁粒度 | +> [!info] ℹ️ 补充 +> sync.Map 在 Go 1.9 引入,最初设计用于反射等低频访问场景。Go 1.10+ 对其进行了大量优化,才逐渐成为通用并发 map 的首选方案。 +## 小结 +- sync.Map 通过 read(无锁)+ dirty(加锁)双表结构实现读写分离 +- entry.p 的三种状态(正常/nil/expunged)精确控制是否需要加锁 +- Store 优先无锁 CAS,Load 优先无锁读,最大限度减少锁竞争 +- 适合读多写少的场景;读写均衡时用 map + RWMutex 更合适 +## 关联笔记 +- [[hzh/GolangStar/Go语言进阶/Sync]] — Sync 包的基础用法 +- [[hzh/GolangStar/Go语言基础/Go语言Map]] — Map 的基础用法 +- [[hzh/GolangStar/Go面试题库/Sync面试题]] — Sync 同步相关面试题 diff --git a/hzh/GolangStar/Go语言原理/内存管理.md b/hzh/GolangStar/Go语言原理/内存管理.md index 69b7875..409a457 100644 --- a/hzh/GolangStar/Go语言原理/内存管理.md +++ b/hzh/GolangStar/Go语言原理/内存管理.md @@ -1,9 +1,6 @@ --- -tags: - - Go - - golang - - go原理深入 - - 内存管理 +tags: [go, golang, go-principle, memory-management] +create time: 2026-06-07 15:55 --- # 内存管理 diff --git a/hzh/GolangStar/Go语言原理/垃圾回收.md b/hzh/GolangStar/Go语言原理/垃圾回收.md index bef028e..79ca1a2 100644 --- a/hzh/GolangStar/Go语言原理/垃圾回收.md +++ b/hzh/GolangStar/Go语言原理/垃圾回收.md @@ -1,14 +1,18 @@ --- -tags: - - Go - - golang - - go原理深入 - - 内存管理 +tags: [go, golang, go-principle, garbage-collection] +create time: 2026-06-07 16:00 --- -# 垃圾回收 +# 垃圾回收算法 -## 1. 什么是GC +## 概述 + +本文从 GC 发展史出发,深入解析 Go 的并发三色标记法 + 混合写屏障机制。涵盖引用计数、标记清除、复制法等经典算法对比,以及插入写屏障、删除写屏障、混合写屏障的原理和演进。这是理解 Go 为什么能做到"低停顿、高吞吐"GC 的核心篇章。 + +> [!question] ❓ 思考 +> 为什么 Go 不采用 Java 那样的分代 GC?三色标记在并发的情况下怎么保证不会错误回收还在使用的对象?写屏障是如何在不暂停程序的情况下做到这一点的? + +--- GC的全称是 Garbage Collection,字面意思是垃圾回收,其实可以理解为垃圾内存回收。GC是编程语言实现的一种自动内存管理机制,用来找到程序中不再使用的那些“垃圾”内存,然后把它们清理掉,让这些内存重新可用。这里所说的“垃圾”内存更确切一点说其实是堆上的不再使用的内存,因为栈上的内存是由编译器自动分配和释放的,不需要GC参与 @@ -235,13 +239,23 @@ func main() { * 黑色:已被垃圾收集器访问到的对象,且其引用都已被扫描到,黑色对象中任何一个指针都不可能直接指向白色对象 +```mermaid +flowchart LR + White["白色
未访问 / 潜在垃圾"] -->|被根扫描发现| Gray["灰色
已访问,待扫描子对象"] + Gray -->|子对象全部扫描完| Black["黑色
已完全扫描"] + Black -.不可能指向.-> White + style White fill:#fff9c4 + style Gray fill:#ffe0b2 + style Black fill:#cfd8dc +``` + 标记过程如下: 1. **初始状态**:所有对象都是白色的 2. **扫描根对象**:从根对象开始扫描,将所有可达对象标记为灰色,并放入待处理集合中 -3. **处理灰色对象**:从待处理集合中取出灰色对象,将它们引用的对象标记为灰色,并将这些新标记的对象加入待处理集合中,同时将自身标记为黑色。 +3. **处理灰色对象**:从待处理集合中取出灰色对象,将它们引用的对象标记为灰色,同时将自身标记为黑色。 4. **重复扫描**:重复第3步,直到待处理集合为空。此时,所有白色对象都是不可达的垃圾对象,可以进行回收 @@ -313,6 +327,11 @@ func main() { * 灰色对象与它之间的可达关系的白色对象遭到破坏(灰色对象同时丢失了该白色对象的引用) +> [!warning] ⚠️ 核心矛盾 +> 并发三色标记的核心问题就是:用户程序(Mutator)在运行,GC 也在运行。用户程序的指针修改可能在 GC 扫描的间隙"偷偷"把一个黑色对象和白色对象连起来,而灰色对象恰好断开了对同一个白色对象的引用——这就导致白色对象被误杀。 +> +> 解决方案只有一个:**引入屏障技术**。 + ### **5.5 屏障技术** #### **5.5.1 强弱三色不变性** @@ -752,6 +771,13 @@ go语言在Go 1.7 之前其实就使用的是 插入写屏障(Dijkstra Write b **混合写屏障模式下,利用删除写屏障避免了插入写屏障的STW问题(全部三色标记扫描之后,要STW对栈重新进行三色标记扫描),又利用插入写屏障避免了删除写屏障的STW问题(使用删除写屏障之前需要STW垃圾扫描整个栈空间,获取快照,把所有的堆对象都处于灰色保护中),这样就完美解决了屏障技术带来的STW问题** +> [!tip] 💡 理解要点 +> 混合写屏障的精妙之处在于:它不是简单地把两种屏障叠加,而是巧妙地利用了两者的互补性—— +> - 删除写屏障保证了"旧引用标灰",避免灰色到白色的路径断裂 +> - 插入写屏障保证了"新引用也标灰",避免不需要 STW 重扫栈 +> +> 两者结合,GC 全程无需全局 STW! + 上面只是从感官上分析了插入写屏障和删除写屏障的结合,解决了STW的问题,但其实混合写屏障不仅仅是做了这两点 Go 在Go V1.8版本时候为了简化 GC 的流程,同时减少标记终止阶段的重扫成本,将 Dijkstra 插入屏障和 Yuasa 删除屏障进行混合,引入了混合写屏障机制(hybrid write barrier)。 @@ -909,5 +935,12 @@ GO语言GC总体上来说是采用的并行三色标记法+混合写屏障机制 3. 混合写屏障扫描栈的方式是逐个暂停扫描的,不需要STW +## 关联笔记 + +- [[hzh/GolangStar/Go语言原理/memory management原理]] — 内存管理三层架构 +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] — sysmon 线程如何触发 GC +- [[hzh/GolangStar/Go语言原理/逃逸分析]] — 哪些对象会分配到堆上被 GC +- [[hzh/GolangStar/Go面试题库/垃圾回收面试题]] — GC 相关高频面试题 + diff --git a/hzh/GolangStar/Go语言原理/程序初始化.md b/hzh/GolangStar/Go语言原理/程序初始化.md index acdf068..f7268d0 100644 --- a/hzh/GolangStar/Go语言原理/程序初始化.md +++ b/hzh/GolangStar/Go语言原理/程序初始化.md @@ -1,132 +1,162 @@ --- -tags: - - Go - - golang - - go原理深入 - - 程序初始化 +tags: [go, golang, go-principle, initialization] +create time: 2026-06-07 15:05 --- # 程序初始化 -Go应用程序的初始化是在单一的`goroutine`中执行的。对于包这一级别的初始化来说,在一个包里会先进行包级别变量的初始化。一个包下可以有多个`init`函数,每个文件也可以有多个`init` 函数,多个 `init` 函数按照它们的文件名顺序逐个初始化。但是程序不可能把所有代码都放在一个包里,通常都是会引入很多包。如果`main`包引入了`pkg1`包,`pkg1`包本身又导入了包`pkg2`,那么应用程序的初始化会按照什么顺序来执行呢? +## 概述 -对于这个初始化过程我粗略的画了一个示意图,理解起来更直观些。 +本文从 runtime 源码角度解析 Go 程序的启动过程:包如何导入、变量如何初始化、`init()` 函数按什么顺序执行。理解这些机制,能帮助你在复杂项目中正确组织初始化逻辑,避免隐式的依赖陷阱。 -![程序初始化](https://golangstar.cn/assets/img/go语言系列/程序初始化/程序初始化.png) +> [!question] ❓ 思考 +> 如果一个包的 `init()` 函数里调用了另一个包的变量,而这个变量依赖于当前包,会发生什么?Go 是如何保证初始化顺序正确的? -图的上半部分表示了`main`包导入了`pkg1`包,`pkg1`包又导入了`pkg2`包这样一个包之间的依赖关系。图的下半部分表示了,这个应用初始化工作的执行时间顺序是从被导入的最深层包开始进行初始化,层层递出最后到`main`包,每个包内部的初始化程序依然是先执行包变量初始化再进行`init`函数的执行。 +## 正文 -下面通过示例来验证一下这个初始化顺序,在`go_tour`目录下有三个包`package1`和`package2`和`utils`,代码目录如下: +### 一、初始化流程全景图 -``` -├─package1 -├─package2 -└─utils +Go 程序的初始化在**单一 goroutine** 中完成,遵循"先被依赖的包先初始化"的原则。整个过程可以概括为: + +```mermaid +flowchart TD + subgraph ImportPhase["导入阶段"] + A["最深层依赖包
import"] --> B["上层依赖包
import"] + B --> C["main 包
import"] + end + + subgraph InitPhase["初始化阶段"] + D["最深层包:
const → var → init()"] --> E["中间层包:
const → var → init()"] + E --> F["main 包:
const → var → init()"] + end + + subgraph RunPhase["运行阶段"] + G["main.main()"] + end + + ImportPhase --> InitPhase --> RunPhase + style A fill:#e8f5e9 + style D fill:#fff9c4 + style G fill:#ffebee ``` -分别定义测试函数,在`utils`包下有文件`utils.go`,在`package1`包下有文件`package1.go`,在`package2`包下有文件`package2.go`。 +一句话总结:**导入自底向上,初始化也是自底向上,最后才跑 main 函数。** -```go -package utils +### 二、包级初始化的顺序规则 -import "fmt" +#### 规则清单 -func TraceLog(t string, v int) int { - fmt.Printf("TraceLog-----%s--------%d\n", t, v) - return v -} +1. **常量先于变量**:每个包内,`const` 声明最早求值 +2. **变量按声明顺序求值**:同一文件内的变量从上到下依次初始化 +3. **多文件的排序**:按文件名字母序执行(由编译器决定) +4. **`init()` 函数**:在每个包的变量初始化完成后执行 +5. **多个 `init()`**:一个包可以有任意多个 `init()` 函数,按文件名字母序逐个调用 +6. **幂等性**:不管包被导入多少次,其 `init()` 只执行一次 +7. **main 最后**:所有依赖包初始化完毕后,才执行 `main.main()` + +#### 示例验证 + +假设有以下包依赖关系: + +``` +main 包 + ├── package1 包 (package1.go) + │ └── package2 包 (package2.go) + │ └── utils 包 (utils.go) + └── utils 包 (utils.go) ``` -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 +init func1 in package2 ← package2 的 init() +init func2 in package2 ← package2 的第二个 init() 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 -``` +init func in package1 ← package1 的 init() +TraceLog-----init M_v1---------40 +TraceLog-----init M_v2---------50 +init func1 in main ← main 的 init() +init func2 in main ← main 的第二个 init() +main func in main ← 终于到了! +``` -实验与结论相符合,按照i导入包的层次,最先被依赖的包最先被初始化,且初始化的顺序是先初始化包变量,再说初始化`init`函数。初始化过程总结如下: +### 三、Runtime 视角:init 函数从哪里来 -- **包级别变量的初始化先于包内`init`函数的执行。** -- **一个包下可以有多个`init`函数,每个文件也可以有多个`init` 函数。** -- **多个 `init` 函数按照它们的文件名顺序逐个初始化。** -- **应用初始化时初始化工作的顺序是,从被导入的最深层包开始进行初始化,层层递出最后到main包。** -- **不管包被导入多少次,包内的`init`函数只会执行一次。** -- **应用在所有初始化工作完成后才会执行`main`函数。** +在编译阶段,编译器会将每个 `.go` 文件中声明的 `init()` 函数收集起来,合并成一个内部的 `init_main` 函数。具体过程位于 `cmd/compile/internal/noder/initOrder.go`: + +```go +// 编译器内部逻辑(简化) +func (n *node) initOrder() []ir.Node { + // 1. 遍历所有包的所有文件 + // 2. 收集 init() 函数,按包依赖拓扑排序 + // 3. 同一文件内的 init() 按声明顺序排列 + return sortedInits +} +``` + +运行时,runtime 在 `runtime/proc.go` 中的 `runInit` 函数会依次调用这些初始化函数: + +```go +// src/runtime/proc.go (简化) +func runInit(inits []func()) { + for _, fn := range inits { + fn() // 逐个执行 init 函数 + } +} +``` + +> [!note] 📝 源码要点 +> 初始化顺序是**编译期确定**的,不是运行期动态计算。这保证了行为的可预测性——同一个项目在多次运行中初始化顺序完全一致。 + +### 四、常见陷阱 + +#### 陷阱 1:循环依赖 + +```go +// 包 A 导入了包 B,包 B 又导入了包 A +// 编译失败:import cycle not allowed +``` + +Go 不允许包之间形成环,这是初始化安全的第一道防线。 + +#### 陷阱 2:变量相互引用 + +```go +var A = B + 1 // B 还未初始化,值为 0 +var B = A + 1 // A 已初始化为 1,所以 B = 2, A = 2 +``` + +变量按声明顺序依次求值,而非同时。这个行为容易引发微妙的 bug。 + +#### 陷阱 3:init 中的 panic + +如果某个包的 `init()` 函数 panic 了,整个程序会在启动阶段崩溃,且错误信息不够友好。建议: + +> [!warning] ⚠️ 注意 +> `init()` 中不要做复杂逻辑或 I/O 操作。如果有需要,考虑用显式的 `NewXXX()` 构造函数替代。 + +### 五、初始化与并发的关系 + +由于初始化在单一 goroutine 中完成,**初始化期间不会有任何并发竞争**。这意味着: + +- `init()` 中可以安全地使用全局变量 +- 不需要 mutex 保护初始化逻辑 +- 但这也意味着大量初始化逻辑会串行执行,可能成为启动瓶颈 + +对于需要快速启动的场景(如 CLI 工具),可以考虑延迟初始化——在真正使用时再初始化资源。 + +## 小结 + +- Go 初始化遵循严格的拓扑排序:被依赖最深的包最先初始化 +- 每个包内顺序为:const → var → init() +- 初始化在单 goroutine 中完成,天然线程安全 +- 避免在 `init()` 中做复杂操作,优先使用显式构造函数 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言代码结构]] — package/import 的基本用法 +- [[hzh/GolangStar/Go语言进阶/并发概述]] — Goroutine 并发模型 +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 调度器如何管理 goroutine diff --git a/hzh/GolangStar/Go语言原理/逃逸分析.md b/hzh/GolangStar/Go语言原理/逃逸分析.md index c511d4c..2e4aac3 100644 --- a/hzh/GolangStar/Go语言原理/逃逸分析.md +++ b/hzh/GolangStar/Go语言原理/逃逸分析.md @@ -1,9 +1,6 @@ --- -tags: - - Go - - golang - - go原理深入 - - 逃逸分析 +tags: [go, golang, go-principle, escape-analysis] +create time: 2026-06-07 15:45 --- # 逃逸分析 diff --git a/hzh/GolangStar/Go语言基础/Go语言Map.md b/hzh/GolangStar/Go语言基础/Go语言Map.md index 19705f4..d9fb5c6 100644 --- a/hzh/GolangStar/Go语言基础/Go语言Map.md +++ b/hzh/GolangStar/Go语言基础/Go语言Map.md @@ -1,356 +1,168 @@ --- -tags: - - Go - - golang - - go基础语法 - - map - - 哈希表 +tags: [go, golang, go基础语法, map, 哈希表] +create time: 2026-06-07 15:00 --- -# Go语言Map +# Go 语言 Map -Map是Go语言中的一个重要的数据结构,它提供了键值对的存储方式,类似其他语言中的哈希表或字典。 +## 概述 -## Map的本质:"键值对储物间" +Map 是 Go 的内置哈希表,提供 O(1) 平均复杂度的键值对操作。本文覆盖创建、增删改查、遍历时序随机性、并发安全等核心要点。 -### 什么是Map? -可以把Map想象成一个特殊的储物间,每个物品(值)都有自己的标签(键)。通过这个标签,我们可以快速找到对应的物品。比如:一个存储学生成绩的系统,学号就是键,分数就是值。 +## 正文 -### Map的定义方式 -在Go语言中,定义Map有几种常用方式: +### 为什么 Go 的 Map 遍历顺序是随机的? + +这不是 bug,而是**有意设计**。Go 的 map 在每次遍历时会随机打乱起始位置,目的是**防止程序员依赖遍历顺序**——而依赖未定义行为正是很多 bug 的源头。 + +> [!warning] ⚠️ 记住这条规则 +> **永远不要依赖 map 的遍历顺序。** 如果需要有序输出,先收集 key 再排序。 + +### 创建与初始化 ```go -// 1. 使用make函数创建 -scoreMap := make(map[string]int) +// 方式一:make(空 map) +m := make(map[string]int) -// 2. 创建时直接初始化 -studentScores := map[string]int{ +// 方式二:字面量(带初始值) +scores := map[string]int{ "张三": 95, "李四": 88, - "王五": 92, } -// 3. 声明一个空map +// 方式三:var 声明(零值为 nil,需 make 后才能用) var prices map[string]float64 -// 注意:声明后需要通过make初始化才能使用 prices = make(map[string]float64) ``` -### Map的基本操作 +> [!note] 📝 nil map 不能写入 +> `var m map[string]int` 创建的 map 是 `nil`。对它做 `m["a"] = 1` 会 panic。必须先 `make`。 + +### 基本操作 ```go -package main +// 写入 / 更新 +m["apple"] = 5.5 -import "fmt" +// 读取(不存在的 key 返回零值) +price := m["apple"] // float64 → 0.0(如果 key 不存在) -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 +// 判断 key 是否存在 +price, exists := m["apple"] +if exists { + fmt.Println(price) +} else { + fmt.Println("不存在") } -func main() { - fruitPrices := map[string]float64{ - "苹果": 5.5, - "香蕉": 3.8, - } - - updatePrice(fruitPrices, "苹果", 6.0) - fmt.Printf("更新后苹果的价格:%.2f\n", fruitPrices["苹果"]) +// 删除 +delete(m, "apple") + +// 获取元素个数 +fmt.Println(len(m)) +``` + +> [!tip] 💡 单返回值惯用法 +> 判断 key 是否存在还有一种更简洁的写法: +> ```go +> if price, ok := m["apple"]; ok { +> // key 存在 +> } +> ``` +> `ok` 是 Go 中约定俗成的布尔变量名,表示"是否成功"。 + +### 嵌套 Map + +Map 的值可以是另一个 Map: + +```go +studentScores := map[string]map[string]int{ + "张三": {"数学": 95, "英语": 88}, + "李四": {"数学": 90, "英语": 85}, } + +// 获取张三的英语成绩 +score := studentScores["张三"]["英语"] ``` -## 使用技巧和注意事项 +> [!warning] ⚠️ 需要先初始化内层 map +> ```go +> studentScores["王五"] = make(map[string]int) // 必须先 make +> studentScores["王五"]["数学"] = 92 // 然后才能写入 +> ``` + +### Map 作为函数参数 + +Map 是**引用类型**——传入函数后修改会影响原始 map: -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() - } +func addScore(scores map[string]int, name string, score int) { + scores[name] = score // 直接修改原 map } -``` -运行结果: -``` -第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 +m := map[string]int{"张三": 95} +addScore(m, "李四", 88) +fmt.Println(m) // map[张三:95 李四:88] ``` -从输出可以看到,每次遍历的顺序都是不同的,这就是map遍历的随机性。 -### 2. Map的并发安全问题 -这个部分涉及到并发编程,将在后续章节中详细介绍,这里只做简单介绍,如果不是很理解的话,可以等后面学习了并发编程之后再回来看。 +### 性能优化:预分配容量 + +如果知道 map 的大致大小,传 `cap` 可以减少扩容开销: + ```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安全执行 +// 预计约 1000 个元素 +m := make(map[string]int, 1000) ``` -或者使用sync.Map: +> [!info] ℹ️ 注意 +> `make(map[T]U, 1000)` 中的第二个参数是**容量提示**(不是强制限制)。map 仍会自动扩容,但预分配可以减少重新分配的次数。 + +### 并发安全 + +**普通 map 不是并发安全的**。多个 goroutine 同时写会 panic: + +``` +fatal error: concurrent map writes +``` + +#### 方案一:sync.Mutex + ```go -package main +var mu sync.Mutex +m := make(map[string]int) -import ( - "fmt" - "sync" -) +mu.Lock() +m["key"] = 1 +mu.Unlock() -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 +mu.Lock() +val := m["key"] +mu.Unlock() ``` -## 实际应用场景 +#### 方案二:sync.Map + +适用于读多写少或 key 集合固定的场景: -1. 缓存系统 ```go -cache := make(map[string]interface{}) +var sm sync.Map + +sm.Store("key", 42) // 写入 +val, ok := sm.Load("key") // 读取 → (interface{}, bool) +sm.Delete("key") // 删除 ``` -2. 计数器 -```go -wordCount := make(map[string]int) -for _, word := range words { - wordCount[word]++ -} -``` +> [!note] 📝 sync.Map vs Mutex+map +> - `sync.Map` 适合 key 集固定、读远多于写的场景 +> - 写频繁时,`sync.Mutex + map` 通常性能更好 +> - 具体选择取决于你的读写比例 -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 +| 陷阱 | 说明 | 修复 | +|------|------|------| +| nil map 写入 | `var m map[K]V` 后直接赋值会 panic | 先用 `make` | +| 不存在的 key 返回零值 | `m["missing"]` 返回类型的零值 | 用 `v, ok := m[k]` 判断 | +| 遍历顺序随机 | 每次运行顺序不同 | 需要有序则先排序 key | +| 并发写 panic | 多 goroutine 同时写 | 加锁或用 `sync.Map` | +| 不能取元素地址 | `&m["key"]` 编译错误 | 无法直接取 map 值的地址 | diff --git a/hzh/GolangStar/Go语言基础/Go语言defer.md b/hzh/GolangStar/Go语言基础/Go语言defer.md index 188bb14..3c4abbf 100644 --- a/hzh/GolangStar/Go语言基础/Go语言defer.md +++ b/hzh/GolangStar/Go语言基础/Go语言defer.md @@ -1,301 +1,158 @@ --- -tags: - - Go - - golang - - go基础语法 - - defer +tags: [go, golang, go基础语法, defer] +create time: 2026-06-07 15:00 --- -# 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 +`defer` 是 Go 最优雅的特性之一——延迟函数执行直到外层函数返回。本文解释 defer 的执行顺序、参数求值时机,以及与 return 的交互关系。 -import "fmt" +## 正文 -func defer1() { - fmt.Println("defer1") -} +### 为什么 Go 需要 defer? -func defer2() { - fmt.Println("defer2") -} +在 C++ 中你用 RAII(析构函数)管理资源,在 Java/Python 中有 `try-finally`。**Go 选择了更简洁的方案:`defer`**。它让资源清理代码紧跟在资源申请代码后面,而不是藏在函数的末尾。 -func defer3() { - fmt.Println("defer3") -} +> [!question] ❓ 思考 +> 如果一个函数有多个返回路径(多个 `return`),如何保证每个路径都正确释放资源? +### defer 的基本用法 + +```go func main() { - defer defer1() - defer defer2() - defer defer3() + defer fmt.Println("第三行输出") + defer fmt.Println("第二行输出") + fmt.Println("第一行输出") } +// 输出: +// 第一行输出 +// 第二行输出 +// 第三行输出 ``` -运行结果: -``` -defer3 -defer2 -defer1 -``` -可以看到执行顺序跟栈是一样的,先调用,后执行 -## defer的使用场景 -通过前面的小节我们知道了`defer`关键字主要是用于延迟调用,那么什么场景下需要我们用到延迟调用了,有过Go语言基础的同学在一些代码中经常看到`defer`关键字。`defer`关键字一般用在以下两个场景中 +> [!note] 📝 LIFO 执行顺序 +> defer 栈遵循 **后进先出**(Last In First Out)。最后一个 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) +func CopyFile(dst, src string) (int64, error) { + s, err := os.Open(src) if err != nil { - return + return 0, err } - defer src.Close() + defer s.Close() // 无论多少条 return,s 都会被关闭 - dst, err := os.Create(dstFile) + d, err := os.Create(dst) if err != nil { - return + return 0, err } - defer dst.Close() - - wr, err = io.Copy(dst, src) - return wr, err + defer d.Close() // 同理 + + return io.Copy(d, s) } ``` -只要我们正确打开了某个资源,比如`src`和`dst`,没返回`err`的情况下,都可以用`defer`延迟调用来关闭资源,注意,这是`go`语言中非常常见的一种资源关闭方式。 -### 配合recover一起处理panic -`defer`另一个常用的地方就是在处理程序`panic`的时候,关于程序的异常捕获我们将在下一个小节讲到,这里大家可以先了解一下,`go`语言中用`panic`来抛出异常,用`recover`来捕获异常,所以当我们的程序出现异常的时候,我们需要知道是发生了什么异常的时候,就可以用`defer recover`来捕获异常 +如果没有 defer,你需要在每个 return 前手动 Close,代码容易遗漏且冗长。 + +> [!tip] 💡 defer 的最佳实践 +> - 打开资源后立即 defer 关闭 +> - 不要过度使用——仅在真正需要"函数退出时清理"的场景用 +> - 常见场景:文件关闭、锁释放、数据库连接归还 + +### ⚠️ defer 的三个经典陷阱 + +#### 陷阱 1:参数在 defer 声明时就求值 ```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) -} +num := 1 +defer fmt.Printf("num is %d\n", num) +num = 2 +// 输出:num is 1 ``` -运行结果: -``` -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: +**原因**:defer 的参数在声明语句执行时就已经确定并压入栈中了。后续修改 `num` 不影响已缓存的参数值。 + ```go -package main - -import "fmt" - - -func deferRun() { - var num = 1 - defer fmt.Printf("num is %d", num) - - num = 2 - return -} - -func main(){ - deferRun() -} +arr := [4]int{1, 2, 3, 4} +defer printArr(&arr) // 传入的是地址(指针值已确定) +arr[0] = 100 +// 输出:100 2 3 4 — 地址没变,但指向的内容变了 ``` -运行结果: -``` -num is 1 -``` -为什么? -延迟函数 `defer fmt.Printf("num is %d", num)` 的参数`num`在`defer`语句出现的时候就已经确定,`num=1`,所以不管后面怎么修改 `a` 的值,最终调用`defer`函数传递给`defer`函数的参数已经固定是`1`了,不会再变化。 -2. 例子2: +> [!warning] ⚠️ 值 vs 指针 +> - 传值 → 参数在 defer 声明时固化 +> - 传指针 → 指针值固化,但指向的内容可变 + +#### 陷阱 2:return 不是原子操作 + +Go 的 `return` 实际上分三步执行: + +```mermaid +graph LR + A["return 语句"] --> B["1. 设置返回值变量"] + B --> C["2. 执行 defer 栈"] + C --> D["3. 返回结果"] +``` + +这意味着:**如果使用了命名返回值,defer 可以修改返回值**。 + ```go -package main - -import "fmt" - -func main() { - deferRun() +func foo() (res int) { + defer func() { res++ }() + return 1 } - -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]) - } -} +fmt.Println(foo()) // 2 — defer 把 res 从 1 改成了 2 ``` -运行结果: -``` -100 -2 -3 -4 -``` -为什么? -通过前一个地址,我们知道在defer出现的时候,参数已经确定,但是这里传递的是地址,地址没变,但是地址对应的内容被修改了,所以输出会被修改。 -3. 例子3: +但如果返回值是匿名的: + ```go -package main - -import "fmt" - -func main() { - res := deferRun() - fmt.Println(res) +func bar() int { + defer func() {}() + return 1 } +fmt.Println(bar()) // 1 — defer 无法影响匿名返回值 +``` -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`。 +#### 陷阱 3:nil panic 不会触发 defer 中的 recover + +如果 defer 本身导致 panic(比如 nil 指针解引用),recover 可能捕获不到。确保 defer 体内的代码是安全的。 + +### defer + recover:异常捕获 + +配合 `recover()` 可以捕获 panic 并恢复程序: -4. 例子4: ```go -package main - -import "fmt" - -func main() { - res := deferRun() - fmt.Println(res) +func safeDivide(a, b int) (result int, recovered string) { + defer func() { + if r := recover(); r != nil { + recovered = fmt.Sprintf("panic: %v", r) + } + }() + result = a / b + return } -func deferRun() int { - var num int - defer func() { - num++ - }() - - return 1 -} +_, msg := safeDivide(10, 0) +fmt.Println(msg) // panic: runtime error: integer divide by zero ``` -运行结果: -``` -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`语句—>将结果返回 +> [!info] ℹ️ recover 的限制 +> - `recover()` 只能在 defer 中调用才有效 +> - 它只能恢复同一个 goroutine 中的 panic +> - 不能恢复跨 goroutine 的 panic +### 关键要点总结 +| 规则 | 说明 | +|------|------| +| 执行时机 | 外层函数 return 或 panic 时执行 | +| 执行顺序 | LIFO(后进先出) | +| 参数求值 | defer 声明时立即求值 | +| 可修改返回值 | 仅当使用命名返回值时 | +| 适用场景 | 资源清理、日志记录、性能监控 | diff --git a/hzh/GolangStar/Go语言基础/Go语言error.md b/hzh/GolangStar/Go语言基础/Go语言error.md index eded02d..5303a43 100644 --- a/hzh/GolangStar/Go语言基础/Go语言error.md +++ b/hzh/GolangStar/Go语言基础/Go语言error.md @@ -1,145 +1,180 @@ --- -tags: - - Go - - golang - - go基础语法 - - error +tags: [go, golang, go基础语法, error] +create time: 2026-06-07 15:00 --- -# Go语言error -在`Go`语言开发中,我们会多次用到`error`这个东西,尤其是在函数的返回值上,由于`go`的函数支持多返回值,所以一般会用`error`作为其中一个返回值,代表该函数执行过程中或者逻辑有出错,那究竟`error`是个什么东西呢,下面我们就来一探究竟。 +# Go 语言 error + +## 概述 + +`error` 是 Go 的内置接口(只有一个 `Error() string` 方法)。本文讲解 error 的本质、创建方式、自定义 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()`函数实现: +**error 就是一个接口**。任何实现了 `Error() string` 方法的类型都可以作为 error 返回。这意味着你可以完全自定义错误类型。 + +> [!question] ❓ 思考 +> 既然 error 只是一个接口,那为什么不能直接用 `==` 比较两个 error? + +### 创建 error 的两种方式 + +#### errors.New() — 简单错误 + +```go +err := errors.New("something went wrong") +``` + +底层实现: + ```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 + return &errorString{text} // 返回指针 } ``` -代码展示: -```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") +#### fmt.Errorf() — 带格式化的错误 + +```go +err := fmt.Errorf("failed to open file %s: %v", path, innerErr) +``` + +支持 `%w` 包装错误(见下文"错误包装"章节)。 + +> [!warning] ⚠️ error 不能用 == 直接比较 +> `errors.New("hello") == errors.New("hello")` 结果是 `false`,因为它们是不同的指针实例。需要比较字符串内容:`err.Error() == "hello"`。 + +### 标准错误处理模式 + +Go 约定:**error 作为最后一个返回值**: + +```go +func ReadFile(name string) (string, error) { + data, err := os.ReadFile(name) + if err != nil { + return "", err // 向上层传递错误 } - return num+1,nil + return string(data), 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()) +// 调用方 +content, err := ReadFile("config.json") +if err != nil { + log.Fatal(err) } ``` -运行结果: -``` -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`对象。 +> [!tip] 💡 防御性编程 +> ```go +> if err != nil { +> return err // 尽早返回,避免嵌套 +> } +> // 下面是正常逻辑 +> ``` +> 这就是著名的 "error laddering"——每个错误处理块都缩进一级,最终正常逻辑在最外层。 + +### 自定义 error + +实现 `Error() string` 方法即可: + ```go -type MyError struct { - code int - msg string +type NotFoundError struct { + Resource string } + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("resource not found: %s", e.Resource) +} + +// 使用 +err := &NotFoundError{Resource: "user_42"} +fmt.Println(err) // resource not found: user_42 ``` -对象定义完之后,接下来只需要实现`error`接口的`Error`方法即可,这样,我们就自定义了一个同时带有错误码和错误信息的error对象。 -下面请看具体例子: + +如果需要携带额外信息(如错误码): + ```go -package main - -import "fmt" - -type MyError struct { - code int - msg string +type AppError struct { + Code int + Msg string } -func (m MyError) Error() string { - return fmt.Sprintf("code:%d,msg:%v", m.code, m.msg) +func (e *AppError) Error() string { + return fmt.Sprintf("[%d] %s", e.Code, e.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)) +// 提取错误码 +func ErrorCode(err error) int { + if appErr, ok := err.(*AppError); ok { + return appErr.Code + } + return -1 } ``` -运行结果: + +> [!note] 📝 用类型断言提取信息 +> `err.(*AppError)` 将 error 接口还原为具体类型,可以访问额外的字段。 + +### 错误包装(Go 1.13+) + +`fmt.Errorf` 支持 `%w` 来**包装**错误,保留原始错误链: + +```go +func LoadConfig(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + return nil +} + +// 检查是否包含特定错误 +var fsErr *fs.PathError +if errors.As(err, &fsErr) { + fmt.Printf("文件系统错误: %v\n", fsErr) +} + +// 检查错误链中是否有某个错误 +if errors.Is(err, io.EOF) { + fmt.Println("到达文件末尾") +} ``` -code is 100, msg is test MyError -``` - - - +> [!info] ℹ️ 三个核心函数 +> | 函数 | 用途 | +> |------|------| +> | `errors.Is(err, target)` | 检查 err 是否在错误链中等于 target | +> | `errors.As(err, target)` | 将 err 链中的第一个匹配类型赋值给 target | +> | `fmt.Errorf("%w", err)` | 包装错误,保留原始错误 | +```mermaid +graph TD + A["load config 错误"] --> B["os.Open 错误"] + B --> C["permission denied"] + + style A fill:#f9f,stroke:#333 + style B fill:#bbf,stroke:#333 + style C fill:#ff9,stroke:#333 +``` +> [!warning] ⚠️ %w vs %v +> - `%w` — **包装**错误,可以用 `errors.Is/As` 追溯 +> - `%v` — 仅格式化输出,不保留错误链关系 +### 最佳实践总结 +| 场景 | 推荐做法 | +|------|---------| +| 简单错误 | `errors.New()` | +| 带上下文的错误 | `fmt.Errorf("context: %w", err)` | +| 需要提取错误码 | 自定义 struct + `Error() string` | +| 检查错误类型 | `errors.As(err, &target)` | +| 判断是否某类错误 | `errors.Is(err, target)` | +| 业务逻辑错误 | 定义专门的错误类型(如 `ErrNotFound`) | diff --git a/hzh/GolangStar/Go语言基础/Go语言代码结构.md b/hzh/GolangStar/Go语言基础/Go语言代码结构.md index 26bee6e..d8c9e56 100644 --- a/hzh/GolangStar/Go语言基础/Go语言代码结构.md +++ b/hzh/GolangStar/Go语言基础/Go语言代码结构.md @@ -1,118 +1,115 @@ --- -tags: - - Go - - golang - - go基础语法 - - go程序结构 +tags: [go, golang, go基础语法, go程序结构] +create time: 2026-06-07 15:00 --- -# Go语言结构 +# Go 语言代码结构 -> 初看,可能觉得文档有点复杂,但是有空写写程序,一切都豁然开朗了 +## 概述 -每一门语言都有各自特有的组织结构,同样,在学习go语言的基础语法之前,我们先来了解Go语言最简单程序的结构。 -下面以hello world为例来简单讲解一下Go语言的组织结构 +本文介绍 Go 程序的最基本组成:包声明、导入、`main` 函数和注释,并通过 Hello World 示例带你跑通第一个 Go 程序。 + +## 正文 + +### 一个 Go 程序由什么构成? + +在深入语法细节之前,先思考一个问题:**"如果让你写一个能打印 'Hello, World!' 的 Go 程序,你知道它最少需要几行代码?"** + +答案是 **5 行**(不算空行和注释)。下面这个最小可运行的 Go 程序展示了所有核心要素: -## Hello World 实例 -Go 语言的基础组成有以下几个部分: -- 包声明 -- 引入包 -- 函数 -- 变量 -- 语句 & 表达式 -- 注释 ```go -package main +package main // 1. 包声明 -import "fmt" +import "fmt" // 2. 导入包 -func main() { - /* 这是我的第一个简单的程序 */ - fmt.Println("Hello, World!") +func main() { // 3. main 函数(程序入口) + fmt.Println("Hello, World!") // 4. 调用函数 + 自动换行 } ``` -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!" 字样的输出 +> [!question] ❓ 思考 +> - `package main` 为什么必须是第一行非注释代码? +> - 如果去掉 `import "fmt"`,程序还能编译通过吗? +> - `main()` 函数可以带参数吗?如果不写 `()` 会怎样? + +每一行的作用如下: + +| 要素 | 说明 | +|------|------| +| `package main` | 声明包名。`main` 包表示这是一个**可执行程序**(而非库) | +| `import "fmt"` | 导入标准库 `fmt`,提供格式化输入输出功能 | +| `func main()` | 程序的**唯一入口**。可执行程序的 `main` 包必须有且仅有一个 `main` 函数 | +| `/* ... */` / `//` | 多行注释 / 单行注释,编译时被忽略 | +| `fmt.Println(...)` | 将内容输出到控制台,末尾自动添加 `\n` | + +> [!tip] 💡 Print vs Println +> `fmt.Print` 不会自动换行,`fmt.Println` 会在末尾追加 `\n`。两者都支持变量插值:`fmt.Println(arr, name, age)` 会用默认格式打印所有参数。 + +### 运行你的第一个程序 + +保存为 `hello.go` 后,在终端执行: + ```shell $ go run hello.go Hello, World! ``` -我们还可以使用 go build 命令来生成二进制文件: + +`go run` 会**编译并立即执行**。如果想单独生成二进制文件,用 `go build`: + ```shell -$ go build hello.go -$ ls -hello hello.go -$ ./hello +$ go build hello.go # 生成 ./hello 二进制文件 +$ ./hello # 运行 Hello, World! ``` -## 文件结构 -关于包,有以下几点需要注意: -- 文件名与包名没有直接关系,不一定要将文件名与包名定成同一个。 -- 文件夹名与包名没有直接关系,并非需要一致。 -- 同一个文件夹下的文件只能有一个包名,否则编译报错。 +> [!note] 📝 go run vs go build +> `go run` 适合快速调试;`go build` 生成独立二进制文件,方便部署和分发。 -现在在hello目录下有一个`helloworld.go`文件,在myMath目录下有两个文件,分别为`myMath1.go`和`myMath2.go` +### 包与文件的关系 -`helloworld.go`的包名为`main`,不必和文件夹名hello一致。`myMath1.go`和`myMath2.go`的包名必须一致,都为`mathClass` +这是新手最容易困惑的地方。记住三条规则: -```shell -hello ---helloworld.go +> [!warning] ⚠️ 常见误区 +> 1. **文件名 ≠ 包名**:`helloworld.go` 可以是 `package main` +> 2. **文件夹名 ≠ 包名**:目录叫 `myMath`,包名可以是 `mathclass` +> 3. **同目录 = 同包**:同一个文件夹下的 `.go` 文件必须属于同一个包,否则编译报错 -myMath ---myMath1.go ---myMath2.go +```mermaid +graph LR + A["项目根目录"] --> B["hello/"] + B --> B1["helloworld.go → package main"] + A --> C["mymath/"] + C --> C1["myMath1.go → package mathClass"] + C --> C2["myMath2.go → package mathClass"] ``` -helloworld.go +**关键点**:`myMath1.go` 和 `myMath2.go` 虽然在不同文件中,但包名都是 `mathClass`,它们共享这个包的命名空间。 + +### 导出与未导出标识符 + +Go 没有 `public` / `private` 关键字,而是用**大小写**控制可见性: + +> [!note] 📝 导出规则 +> - **大写字母开头** → 导出(exported),其他包可以访问,类似 `public` +> - **小写字母开头** → 未导出(unexported),仅限本包内部使用,类似 `private` + ```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 { + +// Add 是大写开头 → 其他包可以调用 +func Add(x, y int) int { return x + y } -``` -myMath2.go -```go -// myMath2.go -package mathClass -func Sub(x,y int) int { +// sub 是小写开头 → 只能在 mathClass 包内使用 +func sub(x, y int) int { return x - y } ``` +> [!warning] ⚠️ 注意 +> 如果你导入了一个包却没用它的任何导出符号,Go 编译器会直接报错:`imported and not used`。这强制你保持代码整洁。 +## 关联笔记 +- [[hzh/GolangStar/Go语言基础/Go语言命名规范]] +- [[hzh/GolangStar/Go语言基础/Go语言变量]] diff --git a/hzh/GolangStar/Go语言基础/Go语言依赖管理.md b/hzh/GolangStar/Go语言基础/Go语言依赖管理.md index 217d6e7..1d5b1eb 100644 --- a/hzh/GolangStar/Go语言基础/Go语言依赖管理.md +++ b/hzh/GolangStar/Go语言基础/Go语言依赖管理.md @@ -1,348 +1,157 @@ --- -tags: - - Go - - golang - - go基础语法 - - modules - - go modules - - 依赖管理 +tags: [go, golang, go基础语法, modules, go modules, 依赖管理] +create time: 2026-06-07 15:00 --- -# Go语言依赖管理 +# 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要点 +Go Modules 是 Go 1.11+ 引入的官方依赖管理方案。本文讲解 go.mod/go.sum 的核心结构、常用命令和关键环境变量的配置。 -### GO111MODULE环境变量 -这个环境变量是Go Modules的开关,主要有以下参数: -- auto:只在项目包含了`go.mod`文件时启动go modules,在Go1.13版本中是默认值 -- on:无脑启动Go Modules,推荐设置,Go1.14版本以后的默认值 -- off:禁用Go Modules,一般没有使用`go modules`的工程使用; +## 正文 +### 为什么 Go 需要 Modules? + +在 Modules 出现之前,Go 依赖 `GOPATH` 管理——所有代码必须放在 `$GOPATH/src` 下。这导致: +- 项目路径与代码仓库路径不一致 +- 依赖版本无法锁定 +- 多项目间依赖冲突 + +Go Modules 让每个项目**自包含**其依赖信息,不再依赖 GOPATH。 + +### 核心环境变量 + +| 变量 | 用途 | 推荐值 | +|------|------|--------| +| `GO111MODULE` | 开关(auto/on/off) | `on`(Go 1.14+ 默认) | +| `GOPROXY` | 模块代理地址 | `https://goproxy.cn,direct`(国内) | +| `GOSUMDB` | 校验数据库 | `sum.golang.org`(默认) | +| `GOPRIVATE` | 私有仓库域名 | 按需设置 | -### GOPROXY -该环境变量用于设置Go模块代理,Go后续在拉取模块版本时能够脱离传统的VCS方式从镜像站点快速拉取,`GOPROXY`的值要以英文逗号分割,默认值是`https://proxy.golang.org,direct`,但是该地址在国内无法访问,所以可以使用`goproxy.cn`来代替(七牛云配置),设置命令: ```bash +# 国内开发者推荐配置 +go env -w GO111MODULE=on go env -w GOPROXY=https://goproxy.cn,direct ``` -也可以使用其他配置,例如阿里配置: + +> [!info] ℹ️ `direct` 的含义 +> `GOPROXY=https://goproxy.cn,direct` 表示先从 goproxy.cn 拉取,如果返回 404/410,则回源到原始仓库(如 GitHub)。 + +### GOPRIVATE:私有仓库 + +对于不在公共代理中的私有仓库: + ```bash -go env -w GOPROXY=https://mirrors.aliyun.com/goproxy/ +go env -w GOPRIVATE="github.com/myorg/*,git.internal.company.com" ``` -该环境变量也可以关闭,可以设置为"off",禁止Go在后续操作中使用任何Go module proxy; -上面的配置中我们用逗号分割后面的值是`direct`,它是什么意思呢? -`direct`为特殊指示符,因为我们指定了镜像地址,默认是从镜像站点拉取,但是有些库可能不存在镜像站点中,`direct`可以指示Go回源到模块版本的源地址去抓取,比如`github`,当`go module proxy`返回`404、410`这类错误时,其会自动尝试列表中的下一个,遇见`direct`时回源地址抓取; +> [!tip] 💡 通配符支持 +> `github.com/myorg/*` 会匹配 `myorg` 下的所有子仓库。 -### GOSUMDB -`GOSUMDB`(go checksum database)是Go官方为了go modules安全考虑,设定的module校验数据库,你在本地对依赖进行变动(更新/添加)操作时,Go 会自动去这个服务器进行数据校验,保证你下的这个代码库和世界上其他人下的代码库是一样的,保证Go在拉取模块版本时拉取到的模块版本数据未经篡改 +### go.mod 文件结构 -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 +module example.com/myproject // 模块路径(import 的基础路径) -go 1.17 +go 1.21 // 所需最低 Go 版本 -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-gonic/gin v1.9.1 + gorm.io/gorm v1.25.5 ) -require ( - github.com/gin-contrib/sse v0.1.0 // indirect - github.com/go-playground/locales v0.14.0 // indirect +require ( // 间接依赖(自动传递) + github.com/gin-contrib/sse v0.1.0 // indirect ) -exclude ( - github.com/json-iterator/go v1.1.12 +replace ( // 替换某个依赖 + golang.org/x/image => github.com/golang/image v0.18.0 ) -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 +retract v0.2.0 // 撤回不推荐的版本 ``` -假设我们有上述`go.mod`文件,接下来我们分模块详细介绍一下各个部分 +> [!note] 📝 关键字段说明 + +| 字段 | 作用 | +|------|------| +| `module` | 当前模块的唯一路径,import 语句以此为前缀 | +| `go` | 声明兼容的 Go 最低版本 | +| `require` | 依赖列表,分直接和间接两类 | +| `replace` | 将 A 替换为 B(可用于本地开发或镜像) | +| `exclude` | 排除某个特定版本(Go 1.16+) | +| `retract` | 声明某版本不应被使用(Go 1.16+) | + +### indirect 标记 + +当你的项目依赖 A,A 又依赖 B,但 A 的 go.mod 缺少 B 时,你的项目中 B 会被标记为 `// indirect`。 + +执行 `go mod tidy` 后,无用的 indirect 依赖会被移除。 + +### +incompatible 标记 + +语义化版本要求:v2+ 的模块路径需包含 `/v2`。如果作者忘了改路径,版本号后面会加上 `+incompatible`: -### module -`go.mod`文件的第一行是`module`, 表示工程里的依赖的基路径,例如上面的项目: ```go -module rotatebot -``` -工程里的`import`的路径都是以`rotatebot`开头的字符串 - - -### go version -`go.mod`文件的第二行是`go version`,其是用来指定你的代码所需要的最低版本: -```go -go 1.17 +require github.com/pkg/errors v0.9.1+incompatible ``` +功能上与普通版本无异,只是提醒开发者这个模块没有遵循语义化版本规范。 -### require -`require`用来指定该项目所需要的各个依赖库以及他们的版本,从上面的例子中我们看到版本部分有不同的写法,还有注释,接下来我们来解释一下这部分; +### go.sum:安全校验 +`go.sum` 记录每个依赖的哈希值,确保下载的内容未被篡改: -#### 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= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqk= ``` -其中 `module` 是依赖的路径,`version` 是依赖的版本号。如果 `version` 后面跟 `/go.mod` 表示对哈希值是 `module` 的 `go.mod` 文件;否则,哈希值是 `module` 的 `.zip` 文件。 -`hash` 是以 `h1:` 开头的字符串,表示生成 checksum 的算法是第一版的HASH算法(SHA256)。如果将来在SHA-256中发现漏洞,将添加对另一种算法的支持,可能会命名为`h2`。 -## Go Modules使用 +> [!warning] ⚠️ 不要手动编辑 go.sum +> 它由 Go 工具链自动生成和维护。 + +### 常用命令速查 + +| 命令 | 作用 | +|------|------| +| `go mod init module/path` | 初始化新模块 | +| `go get package@version` | 添加/更新依赖 | +| `go mod tidy` | 清理无用依赖,补充缺失依赖 | +| `go mod download` | 下载依赖到本地缓存 | +| `go mod graph` | 打印依赖图 | +| `go mod vendor` | 导出依赖到 vendor/ 目录 | +| `go mod verify` | 校验依赖完整性 | +| `go clean -modcache` | 清除模块缓存 | + +> [!tip] 💡 日常开发工作流 +> ```bash +> go get github.com/example/pkg@latest # 添加/升级依赖 +> go mod tidy # 清理 +> go build # 编译(自动检查一致性) +> ``` + +### 版本选择规则 + +| 情况 | 行为 | +|------|------| +| 有语义化标签(v1.2.3) | 取最大主版本标签 | +| 无标签 | 取 main 分支最新 commit,生成伪版本号 | +| 伪版本号 | `v0.0.0-yyyyMMddHHmmss-abcdef123456` | + +### replace 的使用场景 + +当公共代理不可用时,用 replace 指向镜像或本地路径: -使用 `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()) -} +replace golang.org/x/image => github.com/golang/image v0.18.0 ``` -然后在 `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 -) +replace mycompany/internal/pkg => ../internal/pkg ``` -依赖已经安装好,并且可以使用了,在 `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 + +> [!info] ℹ️ replace 不影响 import 语句 +> 代码中仍然写 `import "golang.org/x/image"`,Go 会自动识别 replace 映射到实际路径。 diff --git a/hzh/GolangStar/Go语言基础/Go语言函数.md b/hzh/GolangStar/Go语言基础/Go语言函数.md index e3641ea..a7411aa 100644 --- a/hzh/GolangStar/Go语言基础/Go语言函数.md +++ b/hzh/GolangStar/Go语言基础/Go语言函数.md @@ -1,222 +1,198 @@ --- -tags: - - Go - - golang - - go基础语法 - - 函数 +tags: [go, golang, go基础语法, 函数] +create time: 2026-06-07 15:00 --- -# Go语言函数 +# Go 语言函数 -同其他语言一样,Go语言也有函数的概念,主要是为了代码复用,函数是基本的代码块,用于执行一个任务。 -Go 语言最少有`main()`函数,同时`main()`函数也是整个程序执行的入口。 +## 概述 + +Go 的函数是一等公民:可以赋值给变量、作为参数传递、返回匿名函数(闭包)。本文覆盖多返回值、变参、闭包、匿名函数和值传递机制。 + +## 正文 + +### 为什么 Go 函数支持多返回值? + +在多数语言中,函数只能返回一个值——错误信息需要单独处理(如异常或输出参数)。Go 的选择是:**让错误成为显式的返回值**。 -## 函数定义 -Go语言函数定义格式如下: ```go -func function_name([parameter list]) [return_types] { - function_body +name, age, err := GetUser(42) +if err != nil { + return err } ``` -- func:函数关键字,任何一个函数都有func关键字开始声明。 -- function_name:函数名称,参数列表和返回值类型构成了函数签名。 -- parameter list:参数列表,参数列表指定的是参数类型、顺序、及参数个数。参数是可选的,也就是说函数也可以不包含参数。 -- return_types:返回类型,函数返回一列值。`return_types`是该列值的数据类型。有些功能不需要返回值,这种情况下return_types不是必须的。 -- 函数体:函数定义的代码集合。 -- 注意:Go语言函数区别C++和和Java语言的地方是可以有多个返回值,多个返回值用小括号括起来,中间用逗号分隔。 -代码展示: -```go -package main +这比隐藏的错误处理更透明——编译器强制你处理每一个返回值。 -import "fmt" +### 基本定义与调用 +```go func swap(x, y string) (string, string) { - return y, x + return y, x +} + +a, b := swap("Google", "Hello") +fmt.Println(a, b) // Hello Google +``` + +无返回值时省略返回类型;有返回值时必须写: + +```go +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func greet(name string) { // 无返回值 + fmt.Printf("Hello, %s!\n", name) +} +``` + +### 命名返回值 + +Go 允许给返回值命名,`return` 可以不写具体值(直接返回): + +```go +func Split(sum int) (x, y int) { + x = sum * 4 / 9 + y = sum - x + return // 空白 return,返回当前 x, y 的值 +} +``` + +> [!tip] 💡 命名返回值的用途 +> 虽然不常用,但在复杂函数的"提前返回"场景中可以让代码更清晰——读者一眼就知道返回了什么。 + +### 变参 + +变参函数接受零个或多个同类型参数: + +```go +func Sum(nums ...int) int { + total := 0 + for _, n := range nums { + total += n + } + return total +} + +Sum(1, 2, 3) // 6 +Sum(1, 2, 3, 4, 5) // 15 + +// 将切片展开传入 +nums := []int{1, 2, 3, 4} +Sum(nums...) // 10 +``` + +> [!warning] ⚠️ `...int` 底层是 `[]int` +> 在函数内部,`nums` 就是一个 slice。用 `range nums` 遍历时要注意前面提到的闭包陷阱。 + +### 函数是一等公民 + +函数可以赋值给变量、作为参数传递: + +```go +// 函数变量 +getSquareRoot := func(x float64) float64 { + return math.Sqrt(x) +} +fmt.Println(getSquareRoot(9)) // 3 + +// 函数作为参数(回调) +type Calculator func(int, int) int + +func apply(a, b int, op Calculator) int { + return op(a, b) +} + +result := apply(3, 4, func(x, y int) int { + return x + y +}) // 7 +``` + +### 匿名函数与闭包 + +匿名函数是没有名称的函数,通常内联使用: + +```go +func() { + fmt.Println("我是匿名函数") +}() // 末尾 () 立即执行 +``` + +**闭包 = 匿名函数 + 捕获的外部变量**。闭包会"记住"它创建时的环境变量: + +```go +func counter() func() int { + i := 0 + return func() int { + i++ + return i + } +} + +next := counter() +fmt.Println(next()) // 1 +fmt.Println(next()) // 2 +fmt.Println(next()) // 3 + +next2 := counter() // 全新的计数器 +fmt.Println(next2()) // 1 — 独立于 next +``` + +> [!note] 📝 每次调用 counter() 都创建新的闭包环境 +> `next` 和 `next2` 各自维护独立的 `i` 变量,互不影响。这是闭包的核心特性。 + +```mermaid +graph LR + A["counter()"] --> B["i = 0"] + B --> C["返回闭包 → next"] + C --> D["next() 调用: i→1"] + D --> E["next() 调用: i→2"] + A --> F["counter() 再次调用"] + F --> G["i = 0 新变量"] + G --> H["返回闭包 → next2"] + H --> I["next2() 调用: i→1"] +``` + +> [!warning] ⚠️ 闭包捕获的是引用 +> 如果外部变量在闭包外被修改,闭包看到的也是修改后的值。配合 `for range` 使用时要特别注意(见「循环」章节)。 + +### 值传递:Go 只有值传递 + +Go 的所有参数传递都是**值传递**——参数被拷贝一份传给函数。但对于 slice、map、chan 这些引用类型,拷贝的是内部的指针,所以修改内容会影响原数据。 + +```go +func addOne(x int) { + x++ // 只修改了副本 } func main() { - a, b := swap("Google", "Hello") - fmt.Println(a, b) + a := 5 + addOne(a) + fmt.Println(a) // 5 — 没变! } ``` -运行结果: -``` -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 addOnePtr(x *int) { + *x++ } -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()) -} +a := 5 +addOnePtr(&a) +fmt.Println(a) // 6 ✅ ``` -运行结果: -``` -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 - +### 关键要点总结 +| 特性 | 说明 | +|------|------| +| 多返回值 | Go 独有,广泛用于错误处理 | +| 变参 `...T` | 底层是 `[]T`,可用 `...` 展开 slice | +| 闭包 | 匿名函数捕获外部变量,形成独立状态 | +| 值传递 | 所有参数都是值拷贝,引用类型的"引用"本质是指针拷贝 | diff --git a/hzh/GolangStar/Go语言基础/Go语言变量.md b/hzh/GolangStar/Go语言基础/Go语言变量.md index d351d75..d4f120f 100644 --- a/hzh/GolangStar/Go语言基础/Go语言变量.md +++ b/hzh/GolangStar/Go语言基础/Go语言变量.md @@ -1,283 +1,169 @@ --- -tags: - - Go - - golang - - go基础语法 - - 变量 +tags: [go, golang, go基础语法, 变量] +create time: 2026-06-07 15:00 --- -# 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 提供三种变量声明方式:`var`、类型推断、`:=` 短声明。本文覆盖零值规则、多变量赋值、生命周期和常见陷阱。 -## 变量声明 +## 正文 -### 第一种,指定变量类型,如果没有初始化,则变量默认为零值 -零值就是变量没有做初始化时系统默认设置的值。 -```go -package main -import "fmt" -func main() { - var a string = "Hello" // 声明一个变量并初始化 - fmt.Println(a) +### 为什么 Go 的变量总有"默认值"? - var b int // 没有初始化就为零值 - fmt.Println(b) +在 C / C++ 中,未初始化的局部变量是**垃圾值**——读它会导致未定义行为。Go 做了一个安全选择:**所有变量都有确定的零值**。 - var c bool // bool 零值为 false - fmt.Println(c) -} -``` -以上实例执行结果为: -```shell -Hello -0 -false -``` -- 数值类型(包括complex64/128)为 0 -- 布尔类型为 false -- 字符串为 ""(空字符串) -- 以下几种类型为 nil: -```go -package main +> [!note] 📝 Go 零值表 -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 -``` +| 类型 | 零值 | +|------|------| +| `int`, `int32`, `int64`, `float64` 等数值类型 | `0` | +| `bool` | `false` | +| `string` | `""`(空字符串) | +| 指针、slice、map、chan、func、error | `nil` | ```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 "" +var i int // i == 0 +var f float64 // f == 0.0 +var b bool // b == false +var s string // s == "" +var p *int // p == nil +var m map[string]int // m == nil +var sl []int // sl == nil ``` -### 第二种,根据值自行判定变量类型 -```go -var v_name = value -``` -```go -package main -import "fmt" -func main() { - var d = true - fmt.Println(d) -} -``` -输出结果是: -```shell -true -``` +> [!warning] ⚠️ nil 类型陷阱 +> `nil` 不是万能值。不同类型有各自不同的 `nil` 类型:`*int` 的 nil 不等于 `[]int` 的 nil。比较时务必类型一致。 -### 第三种,:= 声明变量 -```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 // 此时不会产生编译错误,因为有声明新的变量,因为 := 是一个声明语句 -``` +### 三种声明方式 + +#### 方式一:`var` + 类型 + 初始化 ```go -package main -import "fmt" -func main() { - f := "Hello" // var f string = "Hello" - fmt.Println(f) -} -``` -输出结果是: -```shell -Hello +var a string = "Hello" // 显式类型 + 初始化 +var b int // 不初始化 → 零值 0 +var c bool // 不初始化 → 零值 false ``` -### 多变量声明 +#### 方式二:`var` + 类型推断(省略类型) + ```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 -) +var d = true // 编译器推断为 bool ``` + +#### 方式三:`:=` 短声明(最常用) + ```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) -} +e := 123 // 编译器推断为 int +f := "hello" // 编译器推断为 string ``` -以上实例执行结果为: -```shell -0 0 0 false 1 2 123 hello 123 hello -``` -### 注意事项 -我们知道可以在变量的初始化时省略变量的类型而由系统自动推断,声明语句写上 var 关键字其实是显得有些多余了,因此我们可以将它们简写为`a := 50` 或 `b := false`。`a` 和 `b` 的类型(int 和 bool)将由编译器自动推断。 -这是使用变量的首选形式,但是它只能被用在函数体内,而不可以用于全局变量的声明与赋值。使用操作符 `:=` 可以高效地创建一个新的变量,称之为初始化声明。 +> [!tip] 💡 首选 `:=` +> 在函数体内,`:=` 是最常用的声明方式——简洁且不易出错。**但它只能用于函数体内**,不能用于包级别的变量声明。 -如果在相同的代码块中,我们不可以再次对于相同名称的变量使用初始化声明,例如:`a := 20` 就是不被允许的,编译器会提示错误 `no new variables on left side of :=`,但是 `a = 20` 是可以的,因为这是给相同的变量赋予一个新的值。 +> [!question] ❓ 思考 +> 下面这段代码会编译通过吗?为什么? +> ```go +> var x int +> x := 5 // ? +> ``` + +### 多变量声明与并行赋值 + +Go 支持一行声明多个变量: -如果你在定义变量 `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 + +// 类型推断 +var d, e = 123, "hello" + +// 短声明(仅限函数内) +f, g := 456, true ``` -多变量可以在同一行进行赋值,如: + +**并行赋值**是 Go 的一大亮点——右侧先全部求值,再一次性赋给左侧: + ```go -var a, b int -var c string -a, b, c = 5, 7, "abc" +x, y := 1, 2 +x, y = y, x // 交换!x 变成 2,y 变成 1 ``` -上面这行假设了变量 `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 +val, err := GetValue() +if err != nil { + // 处理错误 } ``` +> [!note] 📝 并行 vs 顺序 +> 在 `a[i], a[j] = a[j], a[i]` 中,Go 保证 `i` 和 `j` 的值在赋值前就被求值好,所以即使 `i == j` 也能正确工作。这是与许多语言的关键区别。 +### 空白标识符 `_` +当你不需要某个值时,用 `_` 丢弃: +```go +_, b := 5, 7 // 丢弃 5,只保留 b == 7 +_, err := GetValue() +if err != nil { + // 只需要错误,不需要返回值 +} +``` +> [!warning] ⚠️ `_` 不是变量 +> `_` 是一个**只写占位符**,你不能读取它的值,也不能对它做任何操作。每次使用 `_` 都会创建一个新的丢弃槽。 +### 变量声明 vs 赋值 +```go +var x int +x := 5 // ❌ 编译错误:no new variables on left side of := +x = 5 // ✅ 正确:给已声明的变量重新赋值 +``` +关键点: +- `:=` **必须**在左侧引入至少一个新变量 +- `=` 只是赋值,不能用来声明新变量 +- 在同一代码块中,同名变量不能被 `:=` 重复声明 +### 未使用变量的陷阱 +Go 编译器强制要求:**声明了就必须使用**。 + +```go +func main() { + var a string = "abc" + fmt.Println("hello") // ❌ 编译错误:a declared but not used +} +``` + +修复方法:使用这个变量,或者用 `_` 替代。 + +> [!note] 📝 全局变量例外 +> 包级别声明的变量允许不使用,但函数体内的局部变量不行。这是一个常见的新手坑。 + +### 生命周期 + +| 作用域 | 生命周期 | +|--------|---------| +| 全局变量(包级) | 程序启动到退出 | +| 局部变量(函数内) | 函数调用开始到结束(除非发生逃逸到堆) | + +```go +var globalVar int // 程序整个生命周期存在 + +func main() { + localVar := 42 // 函数返回后销毁(可能逃逸到堆) + _ = localVar +} +``` + +> [!info] ℹ️ 逃逸分析 +> 如果局部变量的地址被返回或被闭包捕获,Go 的逃逸分析会将它分配到**堆**上而非栈上,这样函数返回后它依然有效。具体机制见「逃逸分析」专题。 diff --git a/hzh/GolangStar/Go语言基础/Go语言命名规范.md b/hzh/GolangStar/Go语言基础/Go语言命名规范.md index 236d5a4..f23b22f 100644 --- a/hzh/GolangStar/Go语言基础/Go语言命名规范.md +++ b/hzh/GolangStar/Go语言基础/Go语言命名规范.md @@ -1,94 +1,102 @@ --- -tags: - - Go - - golang - - go基础语法 - - go命名规范 +tags: [go, golang, go基础语法, go命名规范] +create time: 2026-06-07 15:00 --- -# Go语言命名规范 +# Go 语言命名规范 -在Go语言中,任何标识符,包括变量,常量,函数名,方法名,接口名,以及自定义类型等,都应该遵循以下规则 +## 概述 -## 1. 命名要区分大小写 -Go语言是一种区分大小写的编程语言。其命名规则涵盖了变量、常量、全局函数、结构体、接口、方法等元素。在 Go 的语法规定中,对于需要暴露给外部使用的标识符,必须以大写字母开头,而那些不需要外部访问的则应使用小写字母开头。 +Go 的命名哲学可以概括为一句话:**一眼就能看懂,无需猜测。** 本文总结包名、文件名、变量、函数、结构体、接口等所有标识符的命名规则。 -1. 当一个名称(如常量、变量、类型、函数名或结构体字段等)以大写字母开头时,例如:Analysize,它将成为外部包代码可以访问的对象(前提是外部包需要先导入此包),这种方式被称为“导出”(类似于面向对象语言中的 public)。 -2. 如果名称以小写字母开头,则该标识符仅对当前包内可见并可使用,无法被包外访问(类似于面向对象语言中的 private)。 +## 正文 + +### 核心原则:大小写决定可见性 + +Go 没有 `public` / `private` 关键字,而是用**首字母大小写**来控制导出: + +> [!note] 📝 导出规则(最重要的一条) +> - **大写开头** → 导出(exported),其他包可访问 +> - **小写开头** → 未导出(unexported),仅限本包内部使用 -## 1. 包名称 -包名必须全部为小写单词,无下划线,也不要混合大小写,越短越好,尽量不要与标准库重名。并且包名最好和目录保持一致,这样可读性强 ```go -package domain -package service -package modle +package mathClass + +// Add 导出 → 其他包可调用 +func Add(x, y int) int { return x + y } + +// sub 未导出 → 仅在 mathClass 包内可用 +func sub(x, y int) int { return x - y } ``` -## 2. 文件名 -文件名同样遵循简短有意义的原则,文件名必须为小写单词,允许加下划线‘_’组合方式,但是头尾不能为下划线 -```go -order_service.go -``` -虽然go文件命名允许出现下划线,但是为了代码的整洁性,以及避免与一些系统规定的特定后缀冲突,还是建议少使用下划线,能不用尽量不用 +> [!question] ❓ 思考 +> 如果两个包分别导入了同一个第三方库,其中一个包的某个函数名和标准库重名,会发生什么? -比如以 `_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 -``` +### 包名 + +| 规则 | 示例 | +|------|------| +| 全小写,无下划线,简短有意义 | `package domain` / `package service` | +| 尽量与目录名一致 | 目录 `user/` → `package user` | +| 不与标准库重名 | 不要用 `package fmt` | + +### 文件名 + +- 全小写,可用下划线分隔(但建议少用) +- 头尾不能有下划线 +- 避免与系统保留后缀冲突 + +> [!warning] ⚠️ 特殊文件后缀 + +| 后缀 | 含义 | +|------|------| +| `_test.go` | 测试文件,不编译到正常构建中 | +| `_windows.go` / `_linux.go` | 平台特定文件,按 GOOS 条件编译 | +| `_386.go` / `_amd64.go` | 架构特定文件,按 GOARCH 条件编译 | + +> [!tip] 💡 实用建议 +> 虽然 Go 允许文件名含下划线,但为了整洁和避免踩坑,**能不用就不用**。多个单词用驼峰或连字符替代。 + +### 常量 & 枚举 + +采用驼峰命名,按功能分组: -## 3. 常量名 -1. -常量&枚举名规范起见,采用大小写混排的驼峰模式(Golang官方要求),不要出现下划线。比如 ```go const ( - TypeBooks = iota // 0 - TypePhone // 1 - TypeCoin // 2 - ) + TypePage = "page" + + // 页面类型 + TypeHome = "home" + TypeSection = "section" + TypeTaxonomy = "taxonomy" + + // 临时状态 + TypeUnknown = "unknown" +) ``` -2. 常量的定义应根据功能对类型进行分类,而不是将所有类型归为一组。此外,建议将公共常量放在私有常量之前。 +枚举型常量应先定义类型,再用 `iota` 递增: + ```go +type CompareType int + 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" - ) + TypeEq CompareType = iota + TypeNe + TypeGt + TypeGe + TypeLt + TypeLe +) ``` -3. 如果常量类型是枚举类型,需要先创建相应类型。示例如下: -```go - type TypeCompareType int - - const ( -     TypeEq TypeCompareType = iota -     TypeNe -     TypeGt -     TypeGe -     TypeLt -     TypeLe - ) -``` +> [!tip] 💡 iota 分组技巧 +> 用空行分隔不同用途的 `iota` 序列,每个序列重新从 0 开始,可读性更好。 -## 4. 变量名 -变量名称通常遵循驼峰命名法,首字母根据访问控制原则决定是大写还是小写。但对于特定名词,需遵循以下规则: -1. 如果变量是私有的,且特定名词位于名称的首位,则应使用小写字母(例如 appService)。 -2. 如果变量类型为 bool,则名称应以 Has、Is、Can 或 Allow 开头。 +### 变量名 + +- 驼峰命名,首字母根据导出需求定大小写 +- **布尔变量**以 `Is` / `Has` / `Can` / `Allow` 开头 ```go var isExist bool @@ -97,39 +105,63 @@ var canManage bool var allowGitHook bool ``` -## 5. 结构体名 -结构体命名同样应使用驼峰命名法,变量名的首字母根据访问控制规则决定是大写还是小写。 -对于 struct 的声明和初始化,建议采用多行格式,示例如下: +> [!warning] ⚠️ 常见陷阱 +> 布尔变量不要起成 `valid`、`ok` 这种含糊的名字——读者无法从名字判断它是什么状态的布尔值。 + +### 结构体名 + +驼峰命名,多字段时用多行声明更清晰: ```go type ServiceConfig struct { - Port string `json:"port"` + Port string `json:"port"` Address string `json:"address"` } -config := ServiceConfig{"8080", "111.222.333.444"} + +config := ServiceConfig{ + Port: "8080", + Address: "111.222.333.444", +} ``` -## 6. 接口名 -接口的命名规则几乎和结构体相同,只是需要多注意一点,通常只包含单个函数的接口名以 “er” 作为后缀,例如 Reader , Writer +> [!tip] 💡 初始化风格 +> 字段超过 2 个时,推荐用**键值初始化**(如上),比位置初始化更不易出错。 + +### 接口名 + +单方法接口的命名惯例是**词根 + er**: + +| 接口 | 方法 | 含义 | +|------|------|------| +| `Reader` | `Read(p []byte) (n int, err error)` | 读取器 | +| `Writer` | `Write(p []byte) (n int, err error)` | 写入器 | +| `Closer` | `Close() error` | 关闭器 | +| `Seeker` | `Seek(offset int64, whence int) (int64, error)` | 定位器 | ```go type Reader interface { - Read(p []byte) (n int, err error) + Read(p []byte) (n int, err error) } ``` -## 7. 函数名 -1. 函数名必须为大小写混排的驼峰模式,注意,函数名开头字母大写表示该函数是可导出的,可以在包外的其他地方被调用,如果函数名开头是小写的,则只能在包内被调用。另外还需要注意一点的是:函数名不应该与标准库中的函数名冲突 -```go -func DoJob     // 暴露给包外部函数 +> [!note] 📝 为什么是 "er" 后缀? +> 这是 Go 社区约定俗成的惯例,源自 stdlib。遵循它能让你写的接口与其他库无缝对接(比如 `io.Reader` 可以被任何 `fmt.Fprint` 消费)。 -func doJob    // 包内部函数 +### 函数名 + +- 驼峰命名,首字母控制导出 +- 名称应为**动词或动词短语**,简洁准确 + +```go +func Save() {} // 导出:保存 +func doJob() {} // 未导出:执行任务 +func GetUser(id) {} // 导出:获取用户 ``` -2. 函数名要求精简准确,并采用用动词或动词短,比如 -```go -func save(){} -func delete(){} -func getUser(){} -``` +> [!warning] ⚠️ 注意 +> 函数名不要与标准库函数重名,否则会造成混淆,甚至编译错误。 +## 关联笔记 +- [[hzh/GolangStar/Go语言基础/Go语言代码结构]] +- [[hzh/GolangStar/Go语言基础/Go语言变量]] +- [[hzh/GolangStar/Go语言基础/Go语言结构体]] diff --git a/hzh/GolangStar/Go语言基础/Go语言常量.md b/hzh/GolangStar/Go语言基础/Go语言常量.md index ec013d7..2ca5bfb 100644 --- a/hzh/GolangStar/Go语言基础/Go语言常量.md +++ b/hzh/GolangStar/Go语言基础/Go语言常量.md @@ -1,147 +1,129 @@ --- -tags: - - Go - - golang - - go基础语法 - - 常量 +tags: [go, golang, go基础语法, 常量] +create time: 2026-06-07 15:00 --- -# Go语言常量 +# Go 语言常量 -## 常量的定义 +## 概述 -Go语言常量的定义跟其他语言类似,是一个简单值的标识符,在程序运行时,不可以被修改。常量中的数据类型只可以是布尔型、数字型(整数型、浮点型和复数)和字符串型。常量的定义方式: +常量是编译期确定的不可变值。本文介绍常量的声明方式、`iota` 枚举技巧,以及常量与变量的关键区别。 + +## 正文 + +### 常量 vs 变量:本质区别是什么? + +**常量在编译期就确定了值,之后不可修改。** 这意味着编译器可以做更多优化,比如内联替换、死代码消除等。 ```go -const identifier [type] = value +const MaxSize = 1024 // 编译期确定,不可更改 +// MaxSize = 2048 // ❌ 编译错误:cannot assign to MaxSize + +var BufferSize = 1024 // 运行时可变 +BufferSize = 2048 // ✅ 合法 ``` -其中类型说明符[type]可以省略,因为编译器可以根据变量的值来推断其类型。 + +> [!note] 📝 常量类型限制 +> 只能是布尔型、数字型(整数/浮点/复数)、字符串。不能是 slice、map、struct 等复杂类型。 + +### 声明方式 ```go -const b string = "abc" // 显式常量定义 -const b = "abc" // 隐式常量定义 -``` -多个相同类型的声明可以简写为:`const c_name1, c_name2 = value1, value2` -```go -const a, b = "abc", "def" +const name type = value // 显式类型 +const name = value // 类型推断(更常用) + +// 多常量一行声明 +const a, b = "hello", "world" ``` -## 常量用作枚举 +### `iota`:Go 的枚举神器 -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`语句块中的行索引) -看例子: +`iota` 是 Go 最优雅的枚举机制之一——它是 `const` 块内的**行索引**,从 0 开始,每行自动 +1。 ```go const ( - a = iota - b = iota - c = iota + Sunday = iota // 0 + Monday // 1 + Tuesday // 2 + Wednesday // 3 + Thursday // 4 + Friday // 5 + Saturday // 6 ) ``` -第一个`iota`等于`0`,每当`iota`在新的一行被使用时,它的值都会自动加1;所以`a=0, b=1, c=2` 可以简写为如下形式: + +> [!tip] 💡 省略写法 +> 同一 `const` 块内,如果后续行的值和上一行一样(都是 `iota`),可以省略右侧表达式: + ```go const ( - a = iota - b - c + Sunday = iota // 0 + Monday // 1 ← 自动继承 iota + Tuesday // 2 ) ``` -具体使用: + +#### `iota` 的高级用法 + +当某行出现了**非 iota 的独立值**后,`iota` 继续计数,但其他常量需要重新指定表达式: + ```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< [!info] ℹ️ 为什么 `sizeof("abc")` 是 16? +> Go 的 `string` 底层是一个结构体:`{ptr *byte, len int}`,两个字段各占 8 字节(64 位平台),所以 `unsafe.Sizeof(string)` 恒为 16。它不包含字符串的实际内容,只包含指向内容的指针和长度。 + +### 常量分组示例(枚举最佳实践) + +```go +type StatusCode int + +const ( + OK StatusCode = 200 + NotFound StatusCode = 404 + ServerError StatusCode = 500 +) ``` -<<表示左移,学过计算机我们都知道,左移以为其实就是乘以2,左移n位其实就是乘以2的n次方。所以: -- i=1:左移 0 位,不变仍为 1,即1*2^0=1*1=1 -- j=3:左移 1 位,变为二进制 110,即 3*2^1=3*2=6 -- k=3:左移 2 位,变为二进制 1100,即3*2^ 2=3*4=12 -- l=3:左移 3 位,变为二进制 11000,即 3*2^ 3=3*8=24 \ No newline at end of file + +给枚举值绑定自定义类型(如 `StatusCode`)可以提供类型安全——不同枚举类型之间不能直接比较。 + +> [!warning] ⚠️ 常见陷阱 +> 不带类型的裸 `const` 会隐式转换为 `int`。如果需要类型安全,务必显式声明类型或先定义枚举类型。 diff --git a/hzh/GolangStar/Go语言基础/Go语言异常捕获.md b/hzh/GolangStar/Go语言基础/Go语言异常捕获.md index 24346a6..73b99af 100644 --- a/hzh/GolangStar/Go语言基础/Go语言异常捕获.md +++ b/hzh/GolangStar/Go语言基础/Go语言异常捕获.md @@ -1,85 +1,148 @@ --- -tags: - - Go - - golang - - go基础语法 - - 异常捕获 +tags: [go, golang, go基础语法, 异常捕获] +create time: 2026-06-07 15:00 --- -# Go语言异常捕获 +# Go 语言异常捕获 -## recover异常捕获 -异常其实就是指程序运行过程中发生了`panic`,那么我们为了不让程序报错退出,可以在程序中加入`recover`机制,将异常捕获,打印出异常,这样也方便我们定位错误。而捕获的方式我们之前在讲`defer`的时候也提到过,一般是用`recover`和`defer`搭配使用来捕获异常。 -下面请看个具体例子: +## 概述 + +Go 用 `panic` 表示不可恢复的错误,用 `recover` + `defer` 捕获 panic。本文讲解 panic 的传递规则、recover 的正确用法,以及何时不该用 recover。 + +## 正文 + +### panic vs error:该用哪个? + +这是 Go 开发者最常见的困惑之一: + +| | error | panic | +|--|-------|-------| +| 用途 | **预期内**的错误(文件不存在、参数无效) | **不应该发生**的严重错误(nil 指针解引用、数组越界) | +| 处理方式 | if err != nil 检查 | defer + recover 捕获 | +| 是否继续执行 | 是(处理后可继续) | 否(当前 goroutine 栈展开) | + +> [!tip] 💡 黄金法则 +> **99% 的场景你应该用 error,而不是 panic。** panic 应该留给"程序状态已损坏、无法安全继续"的情况。 + +### recover:唯一的安全网 ```go -func main() { - defer func() { - if error:=recover();error!=nil{ - fmt.Println("出现了panic,使用reover获取信息:",error) - } - }() - fmt.Println("11111111111") - panic("出现panic") - fmt.Println("22222222222") - } -``` -运行结果: -``` -11111111111 -出现了panic,使用reover获取信息: 出现panic -``` -**注意**,这里有了`recover`之后,程序不会在`panic`出中断,再执行完`panic`之后,会接下来执行`defer recover`函数,但是当前函数`panic`后面的代码不会被执行,但是调用该函数的代码会接着执行。 -如果我们在`main`函数中未加入`defer func(){...}`,当我们的程序运行到底8行时就会`panic`掉,而通常在我们的业务程序中对于程序`panic`是不可容忍的,我们需要程序健壮的运行,而不是是不是因为一些`panic`挂掉又被拉起,所以当发生`panic`的时候我们要让程序能够继续运行,并且获取到发生`panic`的具体错误,这就可以用上述方法。 - -## panic传递 -当一个函数发生了`panic`之后,若在当前函数中没有`recover`,会一直向外层传递直到主函数,如果迟迟没有`recover`的话,那么程序将终止。如果在过程中遇到了最近的`recover`,则将被捕获。 -看下面例子: - -```go -package main - -import "fmt" - -func testPanic1(){ - fmt.Println("testPanic1上半部分") - testPanic2() - fmt.Println("testPanic1下半部分") -} - -func testPanic2(){ - defer func() { - recover() - }() - fmt.Println("testPanic2上半部分") - testPanic3() - fmt.Println("testPanic2下半部分") -} - -func testPanic3(){ - fmt.Println("testPanic3上半部分") - panic("在testPanic3出现了panic") - fmt.Println("testPanic3下半部分") -} - func main() { - fmt.Println("程序开始") - testPanic1() - fmt.Println("程序结束") + defer func() { + if r := recover(); r != nil { + fmt.Println("捕获到 panic:", r) + } + }() + + panic("出事了!") + fmt.Println("这行不会执行") +} +// 输出: +// 捕获到 panic: 出事了! +``` + +> [!note] 📝 recover 的关键限制 +> - `recover()` **只能在 defer 中调用**才有效 +> - 它恢复的是**当前 goroutine** 的 panic +> - 被 recover 后,函数正常返回,调用方继续执行 + +### panic 的传递规则 + +```mermaid +graph TD + A["testPanic3 触发 panic"] --> B{"有 recover?"} + B -->|否| C["栈展开,向上找"] + C --> D["testPanic2 有 recover → 捕获"] + D --> E["testPanic2 返回"] + E --> F["testPanic1 继续执行"] + F --> G["main 继续执行"] +``` + +当 panic 没有被当前函数 recover 时,它会沿调用链向上传播,直到遇到 recover 或到达 `main`: + +```go +func testPanic3() { + fmt.Println("上半部分") + panic("boom!") + fmt.Println("下半部分") // ❌ 不会执行 +} + +func testPanic2() { + defer func() { recover() }() // ✅ 捕获了来自 testPanic3 的 panic + testPanic3() + fmt.Println("testPanic2 继续") // 会执行 +} + +func testPanic1() { + testPanic2() + fmt.Println("testPanic1 继续") // 会执行 } ``` -运行结果: -``` -程序开始 -testPanic1上半部分 -testPanic2上半部分 -testPanic3上半部分 -testPanic1下半部分 -程序结束 -``` -解析: -调用链:`main-->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`捕获。 +输出: +``` +上半部分 +testPanic2 继续 +testPanic1 继续 +``` + +> [!warning] ⚠️ 没有 recover 的后果 +> 如果 panic 一路传播到 `main` 都没有被 recover,程序会崩溃并打印堆栈信息: +> ``` +> panic: boom! +> goroutine 1 [running]: +> ... +> ``` + +### 实战:安全的数学运算 + +```go +func SafeDivide(a, b int) (result int, errMsg string) { + defer func() { + if r := recover(); r != nil { + errMsg = fmt.Sprintf("除零错误: %v", r) + } + }() + return a / b, "" +} + +val, msg := SafeDivide(10, 0) +if msg != "" { + log.Println(msg) // 除零错误: runtime error: integer divide by zero +} +``` + +### 常见使用场景 + +| 场景 | 说明 | +|------|------| +| 服务器中间件 | 捕获请求级 panic,避免整个服务崩溃 | +| 并发池 | Worker 协程中 recover,防止单个 goroutine 挂掉影响其他任务 | +| 插件系统 | 用户代码可能 panic,需要隔离保护 | + +```go +// 并发池中的典型模式 +func Worker(id int, jobs <-chan int, results chan<- int) { + defer func() { + if r := recover(); r != nil { + log.Printf("worker %d panicked: %v", id, r) + } + }() + for j := range jobs { + results <- process(j) + } +} +``` + +### 关键要点总结 + +> [!warning] ⚠️ 不要滥用 recover +> - 业务逻辑错误 → 用 error +> - 编程错误(bug)→ panic 是可以的 +> - 第三方库 panic → recover 保护你的程序 +> - 每个函数都加 recover → 掩盖 bug,不利于调试 + +> [!info] ℹ️ 最佳实践 +> 1. 在入口点(如 HTTP handler、main 函数顶层)加一层 recover +> 2. 在并发 goroutine 中加 recover,防止单个协程 crash 扩散 +> 3. 记录完整的 panic 堆栈(可用 `runtime.Stack()`)后再恢复 diff --git a/hzh/GolangStar/Go语言基础/Go语言循环.md b/hzh/GolangStar/Go语言基础/Go语言循环.md index 079a880..6cf22c0 100644 --- a/hzh/GolangStar/Go语言基础/Go语言循环.md +++ b/hzh/GolangStar/Go语言基础/Go语言循环.md @@ -1,454 +1,229 @@ --- -tags: - - Go - - golang - - go基础语法 - - 循环 +tags: [go, golang, go基础语法, 循环] +create time: 2026-06-07 15:00 --- -# Go语言循环 -Go语言的循环不像其他语言一样有多种,比如c++有`for`,`while`,`do-while`。在go语言中循环就只有`for`一种,所以用起来也是十分的方便。 +# Go 语言循环 -## for循环的形式 -Go语言的`for`循环有 3 种形式,只有其中的一种使用分号。 +## 概述 + +Go 只有一种循环关键字 `for`,但它能覆盖所有常见循环场景。本文介绍三种 for 形式、`for range` 用法,以及几个经典的 "range 陷阱"。 + +## 正文 + +### Go 为什么只有 for? + +在 C/Java 中有 `for`、`while`、`do-while` 三种循环,而 Go 选择**统一用 `for`**。这不是限制,而是简化——三种写法语义清晰,不需要多个关键字来增加记忆负担。 + +> [!question] ❓ 思考 +> 如果只用一个关键字实现所有循环,你觉得设计者可能考虑了什么? + +### 三种 for 形式 + +#### 形式一:经典 for(C 风格) -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++ { +// 示例:打印 0~4 +for i := 0; i < 5; i++ { fmt.Println(i) } ``` -## for range -for 循环的`range`格式可以对`slice`、`map`、数组、字符串等进行迭代循环。格式如下: +三部分都可以省略,但分号不能省(省略时分号也隐式存在)。 + +#### 形式二:while 风格 + ```go -for key, value := range oldMap { - newMap[key] = value +for condition { + // 只有条件判断,无 init 和 post } -``` -以上代码中的`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]) +j := 0 +for j < 5 { + fmt.Println(j) + j++ } ``` -### 循环是否会停止? +#### 形式三:无限循环 + +```go +for { + // 永远执行,需要内部 break +} +``` + +> [!note] 📝 三者等价关系 +> - `for init; cond; post {}` ≈ C 的 `for` +> - `for cond {}` ≈ C 的 `while` +> - `for {}` ≈ C 的 `for(;;)` + +### for range:遍历集合的优雅方式 + +`for range` 可以遍历 slice、map、array、string、channel: + +```go +// slice +for i, v := range []int{1, 2, 3} { + fmt.Printf("index:%d value:%d\n", i, v) +} + +// map +for k, v := range map[string]int{"a": 1, "b": 2} { + fmt.Printf("%s:%d\n", k, v) +} + +// 只取 index +for i := range slice { ... } + +// 只取 value +for _, v := range slice { ... } +``` + +> [!tip] 💡 省略技巧 +> `_` 是空白标识符,用来丢弃不需要的值。`for _, v := range` 是最常见的写法之一。 + +### ⚠️ for range 的经典陷阱 + +#### 陷阱 1:闭包捕获迭代变量 + +```go +var funcs []func() +for i := 0; i < 3; i++ { + funcs = append(funcs, func() { + fmt.Println(i) // 所有闭包共享同一个 i + }) +} +for _, f := range funcs { + f() // 输出:3 3 3 +} +``` + +**原因**:`i` 在整个循环中是同一个变量,闭包捕获的是它的**引用**而非值。循环结束后 `i == 3`,所以所有闭包都打印 3。 + +**修复方法**:在循环内创建局部副本: + +```go +for i := 0; i < 3; i++ { + i := i // 这一行创建了一个新的局部变量 i + funcs = append(funcs, func() { + fmt.Println(i) // 现在每个闭包捕获的是各自的 i + }) +} +// 输出:0 1 2 +``` + +> [!warning] ⚠️ Go 1.22+ 已修复 +> Go 1.22 起,`for range` 的迭代变量每次循环都会重新创建,上述问题不再出现。但如果要兼容旧版本,仍需用 `i := i` 手法。 + +#### 陷阱 2:修改迭代变量不影响原切片 + +```go +slice := []int{1, 2, 3} +for _, v := range slice { + v *= 10 // 修改的是副本 +} +fmt.Println(slice) // [1 2 3] — 没变! +``` + +**修复**:用索引访问原始元素: + +```go +for i := range slice { + slice[i] *= 10 +} +fmt.Println(slice) // [10 20 30] ✅ +``` + +#### 陷阱 3:遍历中追加元素不会扩展循环次数 + ```go v := []int{1, 2, 3} for i := range v { - v = append(v, i) + v = append(v, i) // 循环仍然只执行 3 次 } ``` -在循环遍历的同时往遍历的切片追加元素,循环会停止吗? -答案是:**会**。 -在Go语言中,`for i := range v`语句会在循环开始前对切片`v`的长度进行一次评估,并将这个长度用于控制循环的迭代次数。之后,如果在循环体内修改了切片`v`的长度(比如通过`append`函数),这个修改并不会影响已经确定的循环迭代次数。 -上述例子可以看作是下面这个代码: + +`for range` 在循环开始前就确定了长度,后续 `append` 不会影响迭代次数。 + +#### 陷阱 4:Map 遍历顺序随机 + ```go -v := []int{1, 2, 3} -length := len(v) -for i := 0; i < length; i++ { - v = append(v, i) +m := map[string]int{"a": 1, "b": 2, "c": 3} +for k, v := range m { + fmt.Println(k, v) // 每次运行顺序可能不同 } ``` -### 使用迭代变量时的闭包问题 -#### 3.3.1 问题 -在`for range`循环中,如果在闭包中使用迭代变量,可能会遇到意想不到的结果。因为闭包会捕获迭代变量的引用,而不是它的值。 +如果需要有序输出,先收集 key 再排序: + ```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() - } +keys := make([]string, 0, len(m)) +for k := range m { + keys = append(keys, k) +} +sort.Strings(keys) +for _, k := range keys { + fmt.Println(k, m[k]) } ``` -代码输出: -``` -3 -3 -3 -``` -#### 解决方法 -使用局部变量保存当前迭代变量的值。 + +### 字符串遍历:rune vs byte + +`for range` 遍历字符串时返回的是 **Unicode 代码点(rune)**,不是字节: + ```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() - } +str := "hello 世界" +for i, r := range str { + fmt.Printf("index: %d, rune: %c\n", i, r) } -``` -代码输出: -``` -0 -1 -2 +// '世' 占 3 个字节,index 从 5 开始 ``` -### 修改切片中的元素 -#### 问题 -`for range`会创建每个元素的副本,而不是直接操作原始切片中的元素。因此,修改迭代变量不会影响原始切片。 +如果需要按字节遍历,用传统 for: + ```go -package main - -import ( - "fmt" -) - -func main() { - slice := []int{1, 2, 3} - - for _, v := range slice { - v *= 10 - } - - fmt.Println(slice) // 输出: [1 2 3] +for i := 0; i < len(str); i++ { + fmt.Printf("byte: %x\n", str[i]) } ``` -代码输出: -``` -[1 2 3] -``` -#### 解决方法 -使用索引访问并修改原始切片中的元素 + +> [!info] ℹ️ string 底层结构 +> Go 的 string 是只读的字节序列。UTF-8 编码下,ASCII 字符占 1 字节,中文占 3 字节。`for range` 自动处理 UTF-8 解码,返回完整的 Unicode 码点。 + +### 循环控制语句 + +| 语句 | 作用 | +|------|------| +| `break` | 跳出当前循环 | +| `continue` | 跳过本次迭代,进入下一次 | +| `goto` | 跳转到标签(慎用) | + ```go -package main - -import ( - "fmt" -) - -func main() { - slice := []int{1, 2, 3} - - for i := range slice { - slice[i] *= 10 +for i := 0; i < 10; i++ { + if i == 5 { + break // 遇到 5 停止 } - - fmt.Println(slice) // 输出: [10 20 30] + if i%2 == 0 { + continue // 跳过偶数 + } + fmt.Println(i) // 只打印奇数 } ``` -代码输出: -``` -[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`循环中修改字典(如添加或删除键值对)可能导致未定义行为或错误。 - - - - +> [!tip] 💡 带标签的 break +> 跳出嵌套循环时,可以用标签指定跳出哪个循环: +> ```go +> Outer: +> for i := 0; i < 3; i++ { +> for j := 0; j < 3; j++ { +> if i == 1 && j == 1 { +> break Outer // 直接跳出外层循环 +> } +> } +> } +> ``` diff --git a/hzh/GolangStar/Go语言基础/Go语言指针.md b/hzh/GolangStar/Go语言基础/Go语言指针.md index 7239427..e47c93c 100644 --- a/hzh/GolangStar/Go语言基础/Go语言指针.md +++ b/hzh/GolangStar/Go语言基础/Go语言指针.md @@ -1,83 +1,123 @@ --- -tags: - - Go - - golang - - go基础语法 - - 指针 +tags: [go, golang, go基础语法, 指针] +create time: 2026-06-07 15:00 --- -# Go语言指针 +# Go 语言指针 -## 指针是什么 +## 概述 -像c语言一样,Go语言也有指针的概念。简单理解,指针就是地址,指针变量就是存放地址的变量。在一个变量前加上*,那么这个变量就是指针变量,指针变量只能存放地址。 -1个指针变量可以指向任何一个值的内存地址,它所指向的值的内存地址在32和64位机器上分别占用4或8个字节,占用字节的大小与所指向的值的大小无关。 +指针是存放内存地址的变量。Go 保留了这一底层能力,但做了简化——去掉了指针运算,只保留 `&`(取地址)和 `*`(解引用)。本文解释指针的工作原理、`new()` vs `make()`,以及何时该用指针。 -## 创建指针 +## 正文 + +### 指针到底是什么? + +简单说:**指针 = 内存地址**。它本身不存储数据,而是指向存储数据的位置。 -### 声明指针 -我们可以像声明其它类型的变量一样,声明一个指针变量 ```go -var a *int +b := 42 // b 在内存某处存了值 42 +ptr := &b // ptr 存的是 b 的地址(如 0x14000018000) +fmt.Println(*ptr) // 通过地址读取 → 42 +*ptr = 100 // 通过地址修改 → b 变成了 100 ``` -上述方法声明了一个整形指针变量a,但是该变量还没有存放任何地址,操作系统并未为其指向的内容分配内存,即该指针没有指向任何内容,所以它是一个空指针。 + +| 操作符 | 含义 | 示例 | +|--------|------|------| +| `&T` | 取变量的地址 | `&b` → `*int` | +| `*T` | 解引用指针 | `*ptr` → 读取/写入指向的值 | + +> [!question] ❓ 思考 +> 如果 `a := &b`,那么 `*a = 100` 会影响 `b` 吗?为什么? + +### 创建指针的两种方式 + +#### 方式一:`&` 取地址 + ```go -var b int = 3 -a = &b +var ptr *int +b := 3 +ptr = &b // ptr 指向 b 的地址 +fmt.Println(*ptr) // 3 ``` -此时a不再是一个空指针,a指向一个整形变量,该变量的值是3。 -要获取指针变量所指向的内容,很简单,在变量前加*即可 + +未初始化的指针值为 `nil`: + ```go -package main +var p *int +fmt.Println(p == nil) // true +``` -import "fmt" +#### 方式二:`new()` 分配零值内存 -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 +```go +p := new(int) // 分配 int 大小的内存,归零,返回 *int +*p = 42 +fmt.Println(*p) // 42 +``` + +> [!note] 📝 new(T) 做了什么? +> 1. 分配一块大小为 `sizeof(T)` 的内存 +> 2. 将内存清零(零值) +> 3. 返回 `*T` 指向这块内存 +> +> 等价于 `&T{}`,但后者更常用且可内联初始化。 + +### new() vs make():最常见的混淆 + +这是 Go 面试必问的问题: + +| | `new(T)` | `make(T, ...)` | +|--|----------|---------------| +| 适用类型 | 所有类型 | **仅限** slice、map、chan | +| 返回值 | `*T`(指针) | `T`(值本身) | +| 用途 | 分配零值内存 | 分配+初始化 | + +```go +// new:只分配,不归零以外的初始化 +p := new(int) // *int,指向 0 +s := new([]int) // *[]int,指向 nil slice(不能直接用!) + +// make:分配 + 初始化 +sl := make([]int, 3, 5) // len=3, cap=5 的切片 +mp := make(map[string]int) // 可用的空 map +ch := make(chan int, 10) // 缓冲为 10 的 channel +``` + +> [!warning] ⚠️ new([]int) 是陷阱 +> `new([]int)` 返回的是 `*[]int`,里面的 slice 头是 nil。对它做 `append` 会 panic。应该用 `make`。 + +### 结构体与指针 + +```go +type Point struct { + X, Y int } -``` -运行结果: -``` -赋值前a: -赋值后a: 0x14000018000 -a指向的内容是: 3 -``` -### new()函数 -Go语言还提供了另外一种方法来创建指针变量,使用`new`函数创建 -```go -new(type) +p := Point{X: 1, Y: 2} +ptr := &p +ptr.X = 10 // Go 自动解引用,等价于 (*ptr).X = 10 +fmt.Println(p.X) // 10 — 原值被修改了 ``` -代码展示: -```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类型的就是空字符串""。 +> [!tip] 💡 什么时候传指针? +> - **需要修改原值**:函数参数传 `*T` +> - **结构体较大**:避免拷贝开销(通常 > 64 字节就值得考虑) +> - **slice/map/chan**:它们本身就是引用语义,不需要额外加指针 +### 常见误区 +> [!warning] ⚠️ 不要对 map 元素取地址 +> ```go +> m := map[string]int{"a": 1} +> p := &m["a"] // ❌ 编译错误:cannot take address of map element +> ``` +> map 的元素不是稳定的内存位置——扩容时整个数据结构会迁移。 +> [!info] ℹ️ Go 没有指针运算 +> Go 去掉了 C 语言的 `ptr++`、`ptr + n` 等指针算术。你只能: +> - 取地址 `&v` +> - 解引用 `*p` +> - 比较 `p == nil` +> +> 这消除了缓冲区溢出等安全漏洞。 diff --git a/hzh/GolangStar/Go语言基础/Go语言接口.md b/hzh/GolangStar/Go语言基础/Go语言接口.md index 31ba18d..065a9e4 100644 --- a/hzh/GolangStar/Go语言基础/Go语言接口.md +++ b/hzh/GolangStar/Go语言基础/Go语言接口.md @@ -1,353 +1,197 @@ --- -tags: - - Go - - golang - - go基础语法 - - 接口 +tags: [go, golang, go基础语法, 接口] +create time: 2026-06-07 15:00 --- -# Go语言接口 +# Go 语言接口 -## 什么是接口 -Go语言中的接口很简单,就是一组方法的声明。当某一种类型实现了所有这些声明的方法,那么就称这种类型为该接口的一种实现。 +## 概述 + +Go 的接口是**隐式实现**——不需要 `implements` 关键字,只要类型拥有接口声明的所有方法,就自动实现了该接口。本文覆盖接口定义、空接口、类型断言、接口嵌套等核心概念。 + +## 正文 + +### 为什么 Go 接口是"鸭嘴兽"式的? + +> "如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。" + +Go 接口是**行为契约**:不要求类型显式声明"我实现了这个接口",只要你的类型有对应的方法签名,就默认实现。这种设计让新类型可以天然适配已有接口。 -## 接口定义 -Go语言中同样用关键字``interfac`e`来定义,接口的定义格式如下: ```go -type 接口名 interface { - 方法名1(参数列表1) (返回值列表1) - 方法名2(参数列表2) (返回值列表2) +type Reader interface { + Read(p []byte) (n int, err error) } + +// 任何有 Read([]byte) (int, error) 签名的类型都是 Reader +// 无需 implements 或 extends ``` -- 接口名:表示接口的名称 -- 方法名:表示接口的方法名称 -- 参数列表:表示方法的参数列表 -- 返回值列表:表示方法的返回值列表 -下面看一个具体的interface的例子 + +> [!question] ❓ 思考 +> 如果一个结构体有 `Read` 方法但没声明过要实现 `Reader` 接口,它能被接收 `Reader` 参数的函数调用吗?(答案:能!) + +### 接口定义与实现 + ```go -package main - -import "fmt" - type Phone interface { Call() SendMessage() } -type Apple struct{ - PhoneName string -} +type Apple struct{ Name string } +func (a Apple) Call() { fmt.Println(a.Name, "打电话") } +func (a Apple) SendMessage() { fmt.Println(a.Name, "发短信") } -func (a Apple) Call() { - fmt.Printf("%s有打电话功能\n", a.PhoneName) -} +type Huawei struct{ Name string } +func (h Huawei) Call() { fmt.Println(h.Name, "打电话") } +func (h Huawei) SendMessage() { fmt.Println(h.Name, "发短信") } -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 和 Huawei 都自动实现了 Phone 接口 +var p Phone = Apple{"iPhone"} +p.Call() // iPhone 打电话 +p = Huawei{"Mate"} +p.SendMessage() // Mate 发短信 ``` -运行结果: -``` -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 +type Reader interface { Read() } +type Writer interface { Write() } -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") -} +type ReadWriter struct{} +func (rw ReadWriter) Read() {} +func (rw ReadWriter) Write() {} +// ReadWriter 同时实现了 Reader 和 Writer ``` -运行结果: -``` -call MyWriteReader MyReader -call MyWriteReader MyWriter hello -``` -上述例子我们定义了一个`MyWriter`接口,该接口有一个方法`MyWriter`,`MyWriter` 方法接受一个 string 类型的 参数。接着,我们定义了一个 `MyReader` 接口,该接口有一个方法 `MyReader`。然后定义了一个`ReadWriter`结构,实现了接口 `MyWriter` 和接口 `MyReader`中的所有方法,所以`ReadWriter`分别实现了两个不同的接口`MyWriter`和`MyReader`。 -# 空接口 -没有任何方法声明的接口称之为空接口`interface{}`。所有的类型都实现了空接口,因此空接口可以存储任意类型的数值。 -Golang很多库的源代码都会以空接口作为参数,表示接受任意类型的参数,比如`fmt` 包下的 `Print` 系列函数。 +### 空接口 `interface{}` + +没有方法的接口就是空接口——**所有类型都实现了它**。 + ```go -func Println(a ...interface{}) (n int, err error) -func Print(a ...interface{}) (n int, err error) +var any interface{} +any = 42 // int +any = "hello" // string +any = true // bool + +// fmt.Println 的参数就是 ...interface{} +fmt.Println(any, 3.14, "world") ``` -看下面例子: + +> [!info] ℹ️ Go 1.20+ 的 `any` 别名 +> `any` 是 `interface{}` 的内置别名,语义等价但更简洁: +> ```go +> func Process(v any) { ... } +> ``` + +### 类型断言 + +从空接口中取出原始值需要**类型断言**: + ```go -package main +x := getValue() // 返回 interface{} -import ( - "fmt" -) - -func main() { - var any interface{} - any = 10 - fmt.Println(any) - - any = "golang" - fmt.Println(any) - - any = true - fmt.Println(any) +// 方式一:带 ok 判断(推荐) +val, ok := x.(int) +if ok { + fmt.Println("是 int:", val) +} else { + fmt.Println("不是 int") } -``` -运行结果: -``` -10 -golang -true -``` -上述例子首先声明了一个空接口`any`,首先用来存储整形变量10,然后又用来存储字符串·golang,最后用来存储布尔型变量true,所以空接口可以存储任意类型的数值。`fmt.Println`函数的参数是空接口类型`interface{}`,所以能将结果正确打印出来。 -# 类型断言 -在介绍断言之前,先看一个例子 +// 方式二:直接取值(失败会 panic) +val := x.(int) // ❌ 如果 x 不是 int,程序崩溃 +``` + +> [!warning] ⚠️ 忘记 ok 的危险 +> `x.(int)` 在类型不匹配时会 **panic**。生产代码中始终使用 `val, ok := x.(T)` 形式。 + +#### switch 类型断言 + +当不确定具体类型时,用 `switch` + `.(type)` 处理多种可能: + ```go -package main - -func main() { - var a int = 1 - var i interface{} = a - var b int = i +func describe(i interface{}) { + switch v := i.(type) { + case int: + fmt.Printf("int: %d\n", v) + case string: + fmt.Printf("string: %s\n", v) + case bool: + fmt.Printf("bool: %t\n", v) + default: + fmt.Printf("unknown: %T\n", i) // %T 打印动态类型 + } } + +describe(42) // int: 42 +describe("hi") // string: hi +describe(nil) // unknown: ``` -运行结果: -``` -./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 接口值,类型断言都会失败。 -请看下面例子: + +> [!note] 📝 nil interface vs interface(nil) +> - `var x interface{} = nil` → `x == nil` 为 true +> - `var x interface{} = (*int)(nil)` → `x != nil`(类型信息存在,只是值为 nil) + +### 接口作函数参数 + +这是 Go 中最常见的接口用法——让函数接受"行为"而非"具体类型": + ```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) +type Speaker interface { + Speak() string } + +func Greet(s Speaker) { + fmt.Println("Hello,", s.Speak()) +} + +type Dog struct{ Name string } +func (d Dog) Speak() string { return d.Name } + +type Human struct{ Name string } +func (h Human) Speak() string { return h.Name } + +Greet(Dog{"Rex"}) // Hello, Rex +Greet(Human{"Alice"}) // Hello, Alice ``` -运行结果: -``` -val is 8, ok is true -``` -上述代码中,首先声明了一个空接口`x`,并赋值为8,然后使用类型断言将`x`断言为`int`类型,并返回断言后的值和断言是否成功。**注意:如果在断言的过程中,没有bool这个值的判断,如果断言成功程序正常运行,假设interface{}存储的值跟要断言的类型不一致,则程序会报panic** -看下面例子: + +> [!tip] 💡 依赖倒置 +> 函数应该依赖接口而非具体类型。这样新增类型时无需修改现有函数代码——符合开闭原则。 + +### 接口嵌套 + +接口可以嵌入其他接口,组合出更大的契约: + ```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 + Read(p []byte) (n int, err error) } -type MyReader struct { - a, b int +type Writer interface { + Write(p []byte) (n int, err error) } -func (m *MyReader) Read() int { - return m.a + m.b +// Closer 组合了 Reader 和 Writer 的所有方法 +type Closer interface { + Reader + Writer + Close() error } -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 +// 实现 Closer 需要实现 Read/Write/Close 三个方法 ``` -## 接口嵌套 -接口嵌套就是一个接口中包含了其他接口,如果要实现外部接口,则需要实现内部嵌套的接口对应的所有方法。 -看具体例子: -```go -package main +> [!info] ℹ️ io 包的经典嵌套 +> `io.ReadCloser`、`io.WriteCloser`、`io.ReadWriteCloser` 都是 stdlib 中嵌套接口的实际应用。 -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!!!! -``` - - - - - - - - -## 接口的嵌套 +### 常见陷阱总结 +| 陷阱 | 说明 | 修复 | +|------|------|------| +| nil 接口 vs 含 nil 值的接口 | `(*T)(nil)` 赋值给接口后不为 nil | 注意区分 | +| 类型断言不加 ok | 类型不匹配会 panic | 始终用 `v, ok := x.(T)` | +| 接口不能作为 map key | 接口类型不可比较(除非底层类型可比较) | 用其他字段做 key | +| 空接口失去类型安全 | 取回时需要断言 | Go 1.20+ 用泛型替代部分场景 | diff --git a/hzh/GolangStar/Go语言基础/Go语言数组与切片.md b/hzh/GolangStar/Go语言基础/Go语言数组与切片.md index 5080b84..969689d 100644 --- a/hzh/GolangStar/Go语言基础/Go语言数组与切片.md +++ b/hzh/GolangStar/Go语言基础/Go语言数组与切片.md @@ -1,141 +1,165 @@ --- -tags: - - Go - - golang - - go基础语法 - - 数组 - - 切片 +tags: [go, golang, go基础语法, 数组, 切片] +create time: 2026-06-07 15:00 --- -# Go语言数组与切片 +# Go 语言数组与切片 -在Go语言中,数组和切片是两种重要的数据结构,它们看起来很相似,但使用场景和特点却大不相同。今天就让我们通过一些生动的例子,深入理解这两个概念。 +## 概述 -## 数组:固定长度的"储物柜" +数组是固定长度的同类型序列,切片是动态增长的视图。本文对比两者的核心差异,重点讲解切片的 append、copy 和底层共享机制。 -### 什么是数组? -想象一下,数组就像一个固定大小的储物柜,有着明确数量的格子,每个格子只能存放相同类型的物品。一旦这个储物柜建好了,格子的数量就不能改变了。 +## 正文 -### 如何定义数组 -在Go语言中,定义数组有多种方式: +### 数组 vs 切片:本质区别是什么? -```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`是不同的类型 +> [!question] ❓ 思考 +> 如果把数组传给函数,函数内修改元素会影响原数组吗?切片呢? + +### 数组:长度是类型的一部分 -### 实战小例子 ```go -package main +var scores [3]int // [0 0 0],零值初始化 +prices := [3]float64{10.99, 20.99, 30.99} +names := [...]string{"张三", "李四"} // [...] 让编译器自动计算长度 +colors := [5]string{0: "红", 2: "蓝"} // 指定位置赋值 → [红 "" 蓝 "" ] +``` -import "fmt" +**关键特性**:`[3]int` 和 `[5]int` 是**完全不同的类型**,不能互相赋值。 -func main() { - // 成绩管理系统 - var scores [5]int = [5]int{95, 89, 92, 88, 96} - - // 计算平均分 - sum := 0 - for _, score := range scores { - sum += score +数组是**值类型**——传给函数时会完整拷贝: + +```go +func sum(a [3]int) int { + total := 0 + for _, v := range a { + total += v } - average := float64(sum) / float64(len(scores)) - fmt.Printf("平均分:%.2f\n", average) + return total } -``` -运行结果: -``` -平均分:92.00 + +arr := [3]int{1, 2, 3} +fmt.Println(sum(arr)) // 6 — arr 被完整拷贝了一份 ``` -## 切片:灵活的"动态储物柜" +> [!warning] ⚠️ 大数组传参陷阱 +> 如果数组很大,拷贝代价很高。解决方案:传指针 `[3]*int` 或改用切片。 -### 为什么需要切片? -如果说数组是固定大小的储物柜,那么切片就像是一个可以随时扩展的"魔法储物柜"。它的大小是可以动态调整的,这让我们在处理不确定数量的数据时更加灵活。 +### 切片:Go 最常用的集合类型 + +#### 创建方式 -### 切片的创建方式 ```go -// 1. 直接创建 +// 直接字面量 fruits := []string{"苹果", "香蕉", "橙子"} -// 2. 使用make函数创建 -numbers := make([]int, 3, 5) // 长度为3,容量为5的切片 +// make:指定 len 和可选 cap +nums := make([]int, 3, 5) // len=3, cap=5 → [0 0 0] -// 3. 从数组创建 +// 从数组/切片截取 arr := [5]int{1, 2, 3, 4, 5} -slice := arr[1:4] // [2 3 4] +s := arr[1:4] // [2 3 4] — 仍是数组的视图 ``` -### 切片的三个核心概念 -1. 指针:指向底层数组的第一个可见元素 -2. 长度:切片当前的元素个数(len) -3. 容量:从切片起始位置到底层数组末尾的元素个数(cap) +> [!note] 📝 切片的三字段结构 +> 切片底层由三个字段组成: +> ``` +> 切片 = {指针 → 底层数组起始位置, 长度(len), 容量(cap)} +> ``` +> `len` 是可访问的元素个数,`cap` 是从起始位置到数组末尾的空间。 + +```mermaid +graph LR + S["切片 s"] --> P["ptr: 指向 arr[1]"] + S --> L["len: 3"] + S --> C["cap: 4"] + P --> A["底层数组: [2 3 4 5 _]"] +``` + +#### append:动态扩容 -### 实用的切片操作 ```go -package main +shopping := []string{"牛奶"} +shopping = append(shopping, "面包") // ["牛奶", "面包"] +shopping = append(shopping, "水果", "蔬菜") // 一次追加多个 +``` -import "fmt" +> [!tip] 💡 append 的规则 +> - 如果剩余容量足够,直接在原数组上扩展 +> - 如果不够,Go 会分配一块更大的新数组(通常是 2x),把数据复制过去 +> - **返回值必须接收**:`append` 可能返回新的切片头(因为底层数组换了) -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) +#### copy:安全复制 + +```go +src := []int{1, 2, 3, 4, 5} +dst := make([]int, 3) +n := copy(dst, src) // dst=[1 2 3], n=3 +``` + +> [!warning] ⚠️ copy 只拷贝 min(lenSrc, lenDst) 个元素 +> 如果目标比源短,只拷贝部分;如果目标更长,多余部分保持零值。 + +### ⚠️ 切片的共享陷阱 + +这是 Go 面试最高频的问题之一: + +```go +src := []int{1, 2, 3, 4, 5} +s1 := src[0:3] // [1 2 3], cap=5 +s2 := s1[0:3] // [1 2 3], cap=5 + +s2[0] = 99 // 修改 s2 +fmt.Println(s1) // [99 2 3] — s1 也被改了! +``` + +**原因**:`s1` 和 `s2` 共享同一个底层数组。修改一个会影响另一个。 + +> [!warning] ⚠️ 截取时的容量问题 +> 如果需要独立副本,务必用 `copy()` 或重新创建: +> ```go +> safe := make([]int, len(s1)) +> copy(safe, s1) +> ``` + +#### append 后的共享问题 + +```go +a := make([]int, 3, 5) // len=3, cap=5 +b := a[:3] // 共享底层数组 + +a = append(a, 99) // cap 够,不会重新分配 +b[0] = 1 // 影响 a +fmt.Println(a) // [1 99 0 99] — 注意 cap=5 全变了 +``` + +### 实战:遍历与处理 + +```go +// 计算平均分 +scores := []int{95, 89, 92, 88, 96} +sum := 0 +for _, score := range scores { + sum += score } -``` -运行结果: -``` -购物清单:[牛奶 面包 水果 蔬菜] -清单长度:4 -清单容量:4 -备份清单:[牛奶 面包 水果 蔬菜] +avg := float64(sum) / float64(len(scores)) +fmt.Printf("平均分:%.2f\n", avg) // 92.00 ``` -### 切片的注意事项 -1. 切片是引用类型,多个切片可能共享同一个底层数组 -2. append可能导致重新分配内存,生成新的底层数组 -3. 使用make创建切片时,可以指定容量来减少内存重新分配的次数 +### 何时用数组?何时用切片? -## 实际应用场景对比 +| 场景 | 推荐 | +|------|------| +| 长度固定且已知(如一周 7 天) | 数组 `[7]int` | +| 需要频繁增删元素 | 切片 `[]int` | +| 函数参数(避免拷贝) | 切片 | +| 需要共享数据视图 | 切片截取 | +| 内存布局严格可控 | 数组 | -1. 什么时候用数组? - - 当你确切知道数据的长度,且不会改变时 - - 例如:存储一周七天的温度数据 - -2. 什么时候用切片? - - 处理动态数据,如用户输入的列表 - - 需要对数据进行频繁的添加和删除操作 - - 作为函数参数传递(更加灵活) - -## 小结 - -数组和切片各有特点:数组像是一个固定大小的储物柜,适合存放固定数量的元素;而切片则像是一个可伸缩的储物空间,更适合处理动态数据。在实际开发中,我们更多地使用切片,因为它提供了更好的灵活性和便利性。 - -记住:选择数组还是切片,关键要看具体的使用场景。理解它们的特点,才能在编程时做出正确的选择。 \ No newline at end of file +> [!tip] 💡 实际开发经验 +> 90% 的场景下你应该用**切片**。数组在性能敏感的低层代码或需要固定内存布局时有用武之地,但日常业务逻辑中切片更灵活、更安全。 diff --git a/hzh/GolangStar/Go语言基础/Go语言方法.md b/hzh/GolangStar/Go语言基础/Go语言方法.md index 28ecf87..8bbd373 100644 --- a/hzh/GolangStar/Go语言基础/Go语言方法.md +++ b/hzh/GolangStar/Go语言基础/Go语言方法.md @@ -1,173 +1,156 @@ --- -tags: - - Go - - golang - - go基础语法 - - 方法 +tags: [go, golang, go基础语法, 方法] +create time: 2026-06-07 15:00 --- -# Go语言方法 +# Go 语言方法 -## 方法的定义 -方法是一类特殊的函数,方法是绑定在某种类型的变量上的函数,它有一定的约束范围。而变量的类型不仅仅局限于结构体类型,可以是任意类型,比如`int`、`bool`等的别名类型。 -有过c++或者java基础的同学可以这样理解,一个`struct`加上绑定在这个类型上的方法就等价于一个类。 -方法的定义格式如下 +## 概述 + +方法是绑定在特定类型上的函数。Go 中任何类型(包括内置类型的别名)都可以有方法。本文讲解值接收者 vs 指针接收者的选择规则,以及通过嵌入实现"继承"。 + +## 正文 + +### 方法 vs 函数:本质区别是什么? + +**方法 = 函数 + 接收者(receiver)**。接收者是该方法所绑定的类型实例。 ```go -func (变量 类型) 方法名(参数列表)(返回值列表){ - -} +type Point struct{ X, Y float64 } + +// 方法:注意 func 和 方法名之间多了 (p Point) +func (p Point) Distance(q Point) float64 { + dx := p.X - q.X + dy := p.Y - q.Y + return math.Sqrt(dx*dx + dy*dy) +} + +p := Point{3, 4} +fmt.Println(p.Distance(Point{0, 0})) // 5 — 调用方式像字段访问 ``` -- 变量:表示方法接收者,即方法所属的对象 -- 类型:表示变量的类型 -- 方法名:表示方法的名称 -- 参数列表:表示方法的参数 -- 返回值列表:表示方法的返回值 +> [!note] 📝 "struct + 方法 ≈ class" +> Go 没有类(class),但 `struct` 提供数据,`method` 提供行为,组合起来就是面向对象的核心概念。 + +### 值接收者 vs 指针接收者 + +这是 Go 方法设计中最关键的选择: + +| | 值接收者 `(t T)` | 指针接收者 `(t *T)` | +|--|-----------------|-------------------| +| 传入什么 | 拷贝一份数据 | 传入地址 | +| 能修改原值吗 | ❌ 不能 | ✅ 能 | +| 调用方式 | 两者皆可 | 两者皆可(Go 自动解引用/取地址) | -对比前面我们学习过的函数,方法跟函数的唯一区别就是在`func`关键字和函数名之间加了一个接收者类型和接收者,同样,这个接收者的类型可以是普通变量或者指针变量 -方法的调用跟结构体属性的调用方式相同,都是通过`.`操作符来完成 -下面给`Student`结构定义一个方法 ```go -package main +type Student struct{ Score int } -import "fmt" - -type Student struct { - ID int - Name string - Age int - Score int +// 值接收者:读取 +func (s Student) GetScore() int { + return s.Score } -func (st *Student) GetName() string { - return st.Name +// 指针接收者:修改 +func (s *Student) SetScore(score int) { + s.Score = score } +``` -func main() { - st := &Student{ - ID : 100, - Name : "zhangsan", - Age : 18, - Score : 98, +> [!tip] 💡 选择规则 +> 1. **需要修改原值** → 必须用指针接收者 +> 2. **结构体较大** → 优先用指针接收者(避免拷贝开销) +> 3. **一致性** → 如果类型有任何一个方法是指针接收者,**所有方法都应使用指针接收者**(避免混淆) + +#### Go 的自动适配 + +Go 会自动处理调用时的适配,你不需要手动关心: + +```go +st := Student{Score: 98} +st.GetScore() // 值变量调用值方法 ✅ +st.SetScore(100) // 值变量调用指针方法 ✅(Go 自动取地址 &st) + +ptr := &Student{Score: 98} +ptr.GetScore() // 指针调用值方法 ✅(Go 自动解引用 *ptr) +ptr.SetScore(100) // 指针调用指针方法 ✅ +``` + +> [!warning] ⚠️ 但是:不能对值取地址调用指针方法 +> ```go +> Student{Score: 98}.SetScore(100) // ❌ 编译错误:无法取字面量的地址 +> ``` + +### 任意类型都可以有方法 + +不仅限于 struct——可以为当前包内定义的**类型别名**添加方法: + +```go +type MyInt int + +func (m MyInt) Abs() MyInt { + if m < 0 { + return -m } - 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 + return m } -func (st *Student) SetScore(score int) { - st.Score = score -} +var x MyInt = -42 +fmt.Println(x.Abs()) // 42 +``` -func (st Student) GetScore() int{ - return st.Score -} +> [!info] ℹ️ 限制 +> 你不能为不在同一包定义的类型添加方法。所以不能给 `int` 或 `map[string]int` 直接加方法。 + +### 嵌入:Go 的"继承"机制 + +Go 没有继承,但通过**匿名嵌入**实现类似效果: -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 + Age int +} + +func (p *People) Greet() string { + return fmt.Sprintf("Hi, I'm %s", p.Name) } type Student struct { - ID int + ID int Score int - People // 将People作为Student的一个属性,注意不要加类型,这就是隐式继承 + People // 匿名嵌入 — 获得 Name、Age 属性和 Greet() 方法 } + +st := Student{ + People: People{Name: "Alice", Age: 18}, + ID: 100, +} +fmt.Println(st.Greet()) // Hi, I'm Alice — 方法"继承"了! +fmt.Println(st.Name) // Alice — 属性也提升了 ``` -将原来`Studen`t的定义改为`Student`和`People`两个结构来定义,通过这种组合的方式,其实`Student`就拥有了`People`的`Name`属性和`Age`属性 -代码展示 + +> [!note] 📝 方法提升(Method Embedding) +> 嵌入类型的方法会自动"提升"到外层类型上。调用 `st.Greet()` 等价于 `st.People.Greet()`,但前者更简洁。 + +#### 覆盖嵌入方法 + +外层类型可以定义同名方法,覆盖嵌入的方法: + ```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()) +func (s Student) Greet() string { + return fmt.Sprintf("Student %d says hi", s.ID) } +// st.Greet() 现在调用的是 Student 版本,而非 People 版本 ``` -运行结果: -``` -学生st的分数是: 98 -学生st的姓名是: zhangsan -``` -这个例子中,`Student`通过内嵌`People`获得了`People`的`Name`和`Age`属性以及`GetName`方法。 - - - - - +### 关键要点总结 +| 场景 | 推荐 | +|------|------| +| 方法需要修改 receiver | 指针接收者 `*T` | +| receiver 是 map/slice/chan | 值接收者即可(它们本身就是引用) | +| receiver 是大 struct | 指针接收者 `*T` | +| receiver 是小 struct 且不变 | 值接收者 `T` | +> [!tip] 💡 一致性原则 +> 如果一个类型的某些方法需要指针 receiver,那么**全部方法都应该用指针 receiver**。混合使用会让调用者困惑。 diff --git a/hzh/GolangStar/Go语言基础/Go语言条件句.md b/hzh/GolangStar/Go语言基础/Go语言条件句.md index 930d993..64baea5 100644 --- a/hzh/GolangStar/Go语言基础/Go语言条件句.md +++ b/hzh/GolangStar/Go语言基础/Go语言条件句.md @@ -1,295 +1,190 @@ --- -tags: - - Go - - golang - - go基础语法 - - 条件语句 - - 流程控制 +tags: [go, golang, go基础语法, 条件语句, 流程控制] +create time: 2026-06-07 15:00 --- -# Go语言条件语句 +# Go 语言条件语句 -条件语句是编程语言中最基本也是最常用的语句之一,它让程序可以根据不同的条件执行不同的代码块。Go语言提供了灵活而强大的条件语句支持。 +## 概述 -## if 语句 +Go 提供 `if` 和 `switch` 两种条件分支机制。本文聚焦 Go 与 C/Java 的不同设计:无括号、自动 break、类型 switch 等。 -### 基本语法 -Go语言中的if语句不需要使用括号将条件包含起来,但是程序体必须使用大括号。基本语法如下: +## 正文 + +### if:为什么 Go 不需要括号? ```go -if condition { - // code to execute if condition is true +age := 18 +if age >= 18 { + fmt.Println("你已经成年了") } ``` -例如: -```go -package main +Go 的 `if` **不需要括号**,但 `{` 必须与 `if` 在同一行(这是 gofmt 强制规则)。这一设计的核心好处是:**避免经典的 "dangling else" 歧义**。 -import "fmt" - -func main() { - age := 18 - if age >= 18 { - fmt.Println("你已经成年了") - } -} -``` -运行结果: -``` -你已经成年了 -``` - -### if-else 语句 -当条件不满足时,可以使用else执行另一段代码: +#### if-else if-else 链 ```go -package main - -import "fmt" - -func main() { - score := 85 - if score >= 90 { - fmt.Println("优秀") - } else { - fmt.Println("继续努力") - } +score := 85 +if score >= 90 { + fmt.Println("优秀") +} else if score >= 80 { + fmt.Println("良好") +} else if score >= 60 { + fmt.Println("及格") +} else { + fmt.Println("不及格") } ``` -运行结果: -``` -继续努力 -``` -### if-else if-else 语句 -当需要判断多个条件时,可以使用if-else if-else结构: +> [!tip] 💡 初始化语句用法 +> `if` 支持在条件前放一条初始化语句,变量的作用域仅限 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 num := 9; num < 10 { + fmt.Println(num, "小于10") } -``` -运行结果: -``` -良好 +// num 在这里不可用 → 作用域被严格限制 ``` -### 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语句块内 +if err := doSomething(); err != nil { + log.Printf("操作失败: %v", err) + return err } -``` -运行结果: -``` -9 小于10 +// err 的作用域仅限于 if 块 ``` -## switch 语句 +> [!question] ❓ 思考 +> 如果 `doSomething()` 返回多个值,比如 `(result, err)`,你能用一行 if 同时接收并判断吗? -### 基本用法 -switch语句是多条件判断的另一种选择,相比于if-else更加清晰和简洁。Go语言的switch比传统的C/C++语言更加灵活: -- 不需要写break语句,匹配到case后自动中断 -- 可以使用任意类型作为条件值,不限于常量或整数 -- case语句可以使用表达式 +### switch:Go 的条件分支利器 + +与 C/C++ 不同,Go 的 `switch` 有几个关键改进: + +| 特性 | Go vs C/C++ | +|------|------------| +| 自动 break | Go 匹配后**自动中断**,无需写 `break` | +| 任意类型 | 可以用 `string`、`struct` 等任意可比较类型 | +| 表达式 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("周末") - } +day := 3 +switch day { +case 1: + fmt.Println("星期一") +case 2: + fmt.Println("星期二") +case 3: + fmt.Println("星期三") +default: + fmt.Println("周末") } ``` -运行结果: -``` -星期三 -``` -### switch的特殊用法 +#### switch 的高级用法 + +**多值 case** — 一个 case 匹配多个值: -1. 一个case可以有多个值,多个值之间使用逗号分隔: ```go switch day { -case 1, 2, 3, 4, 5: // 可以匹配多个值,只需要满足其中一个即可 +case 1, 2, 3, 4, 5: fmt.Println("工作日") case 6, 7: fmt.Println("周末") } ``` -2. 省略switch后的表达式,这种形式更接近于if-else结构: +**省略表达式** — 接近 if-else if 的效果: + ```go -score := 85 -switch { // 省略条件表达式,默认为true +switch { case score >= 90: fmt.Println("优秀") case score >= 80: fmt.Println("良好") -case score >= 60: - fmt.Println("及格") default: - fmt.Println("不及格") + fmt.Println("其他") } ``` -3. fallthrough关键字: -Go语言中的switch默认带有break效果,但如果需要继续执行下一个case,可以使用fallthrough: +> [!note] 📝 这等价于 if-else if 链,但在分支较多时更易扩展。 + +**fallthrough** — 强制执行下一个 case(慎用): + ```go switch { case score >= 60: fmt.Println("及格") - fallthrough // 继续执行下一个case,不管下一个case的条件是否满足 + fallthrough // 继续执行下一个 case,不管条件 case score >= 0: fmt.Println("分数有效") } +// 输出: +// 及格 +// 分数有效 ``` -运行结果: -``` -及格 -分数有效 -``` -注意:fallthrough必须是case中的最后一条语句,并且会强制执行下一个case的代码块,而不判断条件。 -4. switch可以用于类型判断: +> [!warning] ⚠️ fallthrough 陷阱 +> `fallthrough` 必须是 case 中的**最后一条语句**,且会**无条件**跳到下一个 case 的代码块(不判断条件)。滥用会导致难以追踪的逻辑错误。 + +### 类型 switch + +`switch` 还有一个独特能力:**判断 interface{} 的动态类型**。 + ```go var x interface{} = 25.0 switch v := x.(type) { case int: - fmt.Printf("x是整数,值为%d\n", v) + fmt.Printf("整数: %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) { + fmt.Printf("浮点数: %.2f\n", v) 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") } +// 输出:浮点数: 25.00 ``` -### 3. 状态机 +> [!info] ℹ️ `x.(type)` 语法 +> 这只在 `switch` 体内可用。`v` 在每个 case 中被自动转换为对应类型,可以直接使用。 + +```mermaid +graph TD + A["interface{} 值"] --> B{"类型判断"} + B -->|"int"| C["case int"] + B -->|"float64"| D["case float64"] + B -->|"string"| E["case string"] + B -->|"其他"| F["default"] +``` + +### if vs switch:如何选择? + +> [!tip] 💡 选择建议 +> - **2~3 个分支**:用 `if-else` 更直观 +> - **3+ 个分支且比较同一变量**:用 `switch` 更清晰 +> - **判断 interface{} 类型**:只能用类型 switch +> - **错误处理**:`if err != nil` 是 Go 的标准模式 + +### 代码风格要点 + ```go -type State int +// ✅ 正确:{ 与 if 同行 +if condition { + doSomething() +} -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("系统已停止") - } +// ❌ 错误:{ 换行 +if condition +{ + doSomething() } ``` -## 使用技巧和注意事项 - -1. 代码风格 - - if和else后的`{`必须和if或else在同一行 - - 优先使用正向逻辑,避免多重否定 - - 优先处理错误情况,让主逻辑更清晰 - -2. 性能考虑 - - 多条件判断时,将最可能满足的条件放在前面 - - 对于多个条件的判断,考虑使用switch替代if-else链 - -3. 常见陷阱 - - 注意if语句中初始化变量的作用域 - - 不要在switch的case中漏写break(Go自动break) - - 使用fallthrough时要特别小心,确保逻辑正确 - -## 小结 - -条件语句是程序流程控制的基础,Go语言通过简洁的语法和一些特殊的设计,让条件语句的使用更加灵活和高效。掌握好条件语句的各种用法,对于编写清晰、可维护的代码至关重要。 - -记住:选择合适的条件语句形式,让代码逻辑更清晰,可读性更好。过于复杂的条件判断应考虑重构,可能需要重新设计数据结构或使用其他模式。 - +> [!warning] ⚠️ gofmt 强制规则 +> `{` 必须与前一句在同一行,否则编译报错。这不是风格偏好,是语法要求。 diff --git a/hzh/GolangStar/Go语言基础/Go语言结构体.md b/hzh/GolangStar/Go语言基础/Go语言结构体.md index fab291b..8fe0df9 100644 --- a/hzh/GolangStar/Go语言基础/Go语言结构体.md +++ b/hzh/GolangStar/Go语言基础/Go语言结构体.md @@ -1,118 +1,151 @@ -# Go语言结构体 +--- +tags: [go, golang, go基础语法, 结构体] +create time: 2026-06-07 15:00 +--- -有时候内置的基本类型并不能满足我们的业务需求,我们需要一些复合结构。比如我们想要描述一个学生,1个学生既有学号,年龄,性别,分数等这些属性,而单一的数据类型往往只能描述其中一个属性,我们想要描述这个学生,就需要把这些属性都要描述出来,这个时候就需要用到结构体了。 +# Go 语言结构体 + +## 概述 + +Go 没有类(class),但通过**结构体(struct)**实现数据的组合。本文介绍结构体的定义、初始化、成员访问,以及嵌入(匿名字段)机制。 + +## 正文 + +### 为什么 Go 不用 class? + +在 Java / C++ 中,你用 `class` 同时定义**数据**和**行为**。Go 把它们拆开了:`struct` 只负责数据,`method`(方法)是后来绑定到 struct 上的。这种设计让数据结构和方法可以独立演化。 + +> [!question] ❓ 思考 +> 如果一个 Student 有 Name、Age、Score 三个属性,用基本类型能表示吗?为什么需要组合? + +### 定义与初始化 + +#### 定义 -## 结构体定义 -像很多其他的高级语言一样,go语言也支持结构体来定义复合数据类型 -定义方式如下: ```go type Student struct { - ID int - Name string - Age int + 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 + 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) +// 等价于 +type Student struct { + ID, Age int + Name string + Score int } ``` -运行结果: -``` -学生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 := Student{ + ID: 100, + Name: "zhangsan", + Age: 18, + Score: 98, } +fmt.Printf("%v\n", st) // {100 zhangsan 18 98} ``` -运行结果: -``` -学生st: {101 lisi 20 97} -``` -**注意:以值列表的方式初始化结构体,值列表的个数必须等于结构体属性个数,且要按顺序,否则会报错** -## 结构体成员访问 -使用点号 `.` 操作符来访问结构体的成员,`.` 前可以是结构体变量或者结构体指针 +**位置初始化**(按字段顺序,字段少时可用): + ```go -package main +st := Student{101, "lisi", 20, 97} +``` -import "fmt" +> [!tip] 💡 零值初始化 +> 只写 `Student{}` 会创建一个所有字段为零值的结构体: +> ```go +> st := Student{} // {0 "" 0 0} +> ``` -type Student struct { - ID int - Name string - Age int - Score int +**new() vs 直接初始化**: + +```go +st1 := &Student{} // ✅ 推荐:简洁,可直接赋值字段 +st2 := new(Student) // 等价于 &Student{},返回 *Student +``` + +> [!note] 📝 new(T) 的作用 +> `new(T)` 分配零值内存并返回 `*T`。它与 `&T{}` 功能等价,但后者更灵活——可以直接内联初始化部分字段。 + +### 成员访问 + +使用 `.` 操作符。指针类型的 struct 也可以直接用 `.` 访问字段(Go 自动解引用): + +```go +st := Student{Name: "zhangsan", Age: 18} +fmt.Println(st.Name) // zhangsan + +ptr := &st +fmt.Println(ptr.Name) // zhangsan(Go 自动解引用,等价于 (*ptr).Name) +``` + +### 结构体嵌套(组合) + +Go 没有继承,但通过**组合**实现类似效果: + +```go +type Address struct { + City string + Postal string } +type Person struct { + Name string + Age int + Address // 匿名嵌入(anonymous field) +} -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 +p := Person{ + Name: "Alice", + Address: Address{City: "Beijing"}, +} +fmt.Println(p.City) // 直接访问嵌入字段! ``` +> [!info] ℹ️ 嵌入 vs 普通字段 +> - **匿名嵌入**(无字段名):外层可以直接访问内层字段,模拟"继承" +> - **命名字段**:需要通过字段名访问,如 `p.Address.City` + +```mermaid +graph LR + A["Person"] --> B["Name: string"] + A --> C["Age: int"] + A --> D["Address ← 嵌入"] + D --> E["City: string"] + D --> F["Postal: string"] +``` + +### 结构体作为函数参数 + +> [!warning] ⚠️ 值拷贝陷阱 +> Go 的结构体是**值类型**,传入函数时会被完整拷贝。修改函数内的参数不会影响原始 struct。 + +```go +func ChangeName(s Student) { + s.Name = "modified" // 只修改了副本 +} + +func ChangeNamePtr(s *Student) { + s.Name = "modified" // 通过指针修改原值 +} +``` + +> [!tip] 💡 大结构体传指针 +> 结构体较大时,传递 `*T` 而非 `T` 可以避免不必要的内存拷贝。即使你不打算修改它,传指针也是常见的性能优化手段。 diff --git a/hzh/GolangStar/Go语言基础/Go语言运算符.md b/hzh/GolangStar/Go语言基础/Go语言运算符.md index dc2427a..7eaa65a 100644 --- a/hzh/GolangStar/Go语言基础/Go语言运算符.md +++ b/hzh/GolangStar/Go语言基础/Go语言运算符.md @@ -1,373 +1,134 @@ --- -tags: - - Go - - golang - - go基础语法 - - 运算符 +tags: [go, golang, go基础语法, 运算符] +create time: 2026-06-07 15:00 --- -# 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 的运算符体系简洁而一致:算术、关系、逻辑、位运算、赋值和取地址。本文聚焦**与 C/Java 不同的设计决策**,以及实际开发中的常见用法。 + +## 正文 + +### Go 运算符有哪些"不一样"? + +先思考:**"Go 有没有 `++` 和 `--` 作为表达式?"** + +答案是:**没有。** `a++` 是语句而非表达式,不能出现在赋值或函数参数中: -代码展示: ```go -package main +a := 5 +a++ // ✅ 语句 +b := a++ // ❌ 编译错误:syntax error: unexpected ++ +``` -import "fmt" +这是 Go 刻意的设计——避免 `i++ + i++` 这类歧义代码。 -func main() { +### 算术运算符 - var a int = 21 - var b int = 10 - var c int +| 运算符 | 说明 | 示例 | +|--------|------|------| +| `+ - *` | 加减乘 | `3 + 5 → 8` | +| `/` | 整除(整数类型) | `7 / 2 → 3` | +| `%` | 取余 | `7 % 2 → 1` | +| `++ --` | 自增/自减(**仅语句**) | `a++` | - 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 ) +> [!warning] ⚠️ 整数除法截断 +> `7 / 2` 结果是 `3` 而非 `3.5`。需要浮点除法时,至少一个操作数必须是浮点类型:`float64(7) / 2 → 3.5`。 + +### 关系运算符 + +返回 `bool` 类型: + +| 运算符 | 说明 | +|--------|------| +| `== !=` | 相等 / 不等 | +| `< <= > >=` | 大小比较 | + +> [!tip] 💡 Go 没有三元运算符 +> `a > b ? a : b` 在 Go 中不合法。改用 if: +> ```go +> max := a +> if b > max { +> max = b +> } +> ``` + +### 逻辑运算符 + +短路求值——右侧只在必要时求值: + +| 运算符 | 说明 | +|--------|------| +| `&&` | 逻辑与(左为 false 时不eval右) | +| `\|\|` | 逻辑或(左为 true 时不eval右) | +| `!` | 逻辑非 | + +```go +if ptr != nil && ptr.IsValid() { + // 只有 ptr 不为 nil 时才调用 IsValid() } ``` -运算结果: -``` -第一行 - 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 +| 运算符 | 说明 | 示例 (60 & 13) | +|--------|------|---------------| +| `&` | 按位与 | `12` | +| `\|` | 按位或 | `61` | +| `^` | 按位异或 | `49` | +| `<< >>` | 左移 / 右移 | `60 << 2 = 240` | - 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 -``` +> [!note] 📝 异或的性质 +> `x ^ x = 0`,这个性质可用于无额外变量的交换、简单的校验码计算等场景。 -## 逻辑运算符 -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 | +> [!info] ℹ️ `&^` 按位清空 +> Go 独有运算符 `a &^ b`:将 `b` 中为 1 的位,把 `a` 对应位清零。常用于标志位操作。 + +### 赋值运算符 + +复合赋值运算符与多数语言一致: -代码展示: ```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 += 5 // 等价于 a = a + 5 +a <<= 2 // 等价于 a = a << 2 ``` -## 位运算符 -位运算符是对内存中的二进制数进行按位运算,包括&,|, ^ ,<<,>>这么几种 -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 +x := 42 +ptr := &x // ptr 类型为 *int,指向 x +fmt.Println(*ptr) // 输出 42,读取指针所指向的值 +*ptr = 100 // 通过指针修改 x 的值 +fmt.Println(x) // 输出 100 ``` -## 其他运算符 -Go语言跟c++语言一样也有指针和地址的概念,所以也会有取地址运算符&和指针运算符*。 -A=10,B=20 -| 运算符 | 描述 | 实例 | -| :---: | :---: | :---: | -| & | 返回变量存储地址 | &a; 将给出变量的实际地址。 | -| * | 指针变量。 | *a; 是一个指针变量 | +> [!question] ❓ 思考 +> `&` 既可以取地址又可以用于位与运算,Go 如何区分这两种用途?(提示:看上下文和空格) -代码展示: -```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 -``` +> [!tip] 💡 实用技巧 +> 不要依赖优先级记忆。`(a + b) * c` 比 `a + b * c` 更清晰,哪怕后者在语义上等价于你期望的结果。 diff --git a/hzh/GolangStar/Go语言框架/gin.md b/hzh/GolangStar/Go语言框架/gin.md index 7445eec..8b1fd89 100644 --- a/hzh/GolangStar/Go语言框架/gin.md +++ b/hzh/GolangStar/Go语言框架/gin.md @@ -1,362 +1,180 @@ --- -tags: - - Go - - golang - - gin - - web框架 - - http +tags: [go, golang, gin, Web框架, HTTP] +create time: 2026-06-07 15:20 --- # Gin -Gin是一个用Go语言编写的高性能Web框架,以其高效的路由系统和优秀的性能著称。本文将详细介绍如何使用Gin框架开发Web应用。 +## 概述 -## 安装Gin +Gin 是 Go 生态中最流行的 Web 框架,以高性能路由和简洁的 API 著称。本文从基础用法到中间件、分组路由,覆盖构建 RESTful API 的核心技能。 -首先,我们需要安装Gin框架。可以使用以下命令: +## 正文 + +### 安装 ```bash go get -u github.com/gin-gonic/gin ``` -## 基本使用 +### 快速开始 -### 创建HTTP服务器 +> [!question] 💭 思考 +> 如果让你从零实现一个 HTTP 服务器,需要处理哪些事情?(路由匹配、请求解析、响应格式化……) -使用Gin创建一个基本的HTTP服务器非常简单: +Gin 封装了这些底层细节,让你专注于业务逻辑: ```go -package main +r := gin.Default() // 创建默认引擎(含 Logger + Recovery 中间件) -import ( - "github.com/gin-gonic/gin" - "net/http" -) +r.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) +}) -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() -} +r.Run(":8080") // 监听 8080 端口 ``` -### 路由参数 +> [!tip] 💡 Default vs New +> - `gin.New()`:纯引擎,无日志和恢复中间件 +> - `gin.Default()`:预装 Logger 和 Recovery,生产环境推荐 +> - Recovery 中间件会自动 recover panic,避免服务崩溃 -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, - }) + name := c.Param("name") // 获取 :name 的值 + c.String(200, "Hello %s", name) }) ``` -### 处理表单数据 - -Gin可以轻松处理POST请求中的表单数据: +#### Query 参数 ```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, - }) +r.GET("/search", func(c *gin.Context) { + q := c.Query("q", "") // 获取 ?q=xxx,默认值 "" + page := c.DefaultQuery("page", "1") // 带默认值 + c.JSON(200, gin.H{"query": q, "page": page}) }) ``` -### 处理JSON数据 - -Gin可以轻松处理POST请求中的JSON数据: +#### 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(), - }) + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, 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) + // 处理登录... + c.JSON(200, gin.H{"message": "ok"}) }) ``` -在上面的示例中: -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)) -}) -``` +> [!warning] ⚠️ 绑定注意事项 +> - `ShouldBindJSON` 仅在 Content-Type 为 `application/json` 时生效 +> - `binding:"required"` 由内置验证器检查字段是否为空 +> - 对于复杂验证需求,建议使用 `github.com/go-playground/validator` ### 中间件 -Gin的中间件可以在请求处理过程中执行一些公共的操作: +> [!question] 💭 思考 +> 如果你的 API 需要对所有请求做日志记录、鉴权、CORS 处理,在每个 handler 里重复写代码合理吗? + +中间件是横切关注点的标准解决方案——它在请求到达 handler **之前**和返回响应**之后**执行: ```go -// 定义一个中间件 -func Logger() gin.HandlerFunc { +// 自定义中间件 +func RequestID() gin.HandlerFunc { return func(c *gin.Context) { - t := time.Now() - - // 在请求之前 - - c.Next() - - // 在请求之后 - latency := time.Since(t) - log.Printf("请求处理时间: %v", latency) + id := uuid.New().String() + c.Set("requestID", id) // 存入上下文 + c.Header("X-Request-ID", id) + c.Next() // ✅ 必须调用,继续处理后续中间件/handler + // 这里可以记录耗时等后处理逻辑 } } -// 使用中间件 -r.Use(Logger()) +// 注册方式 +r.Use(RequestID()) // 全局中间件 +r.GET("/api", MyHandler) // 受中间件保护 ``` -### 分组路由 +> [!info] ℹ️ 中间件执行顺序 +> 先注册的先执行(类似洋葱模型): +> ``` +> 请求 → Logger → Auth → CORS → Handler +> 响应 ← Logger ← Auth ← CORS ← Handler +> ``` -Gin支持路由分组,可以更好地组织API: +#### 分组路由 ```go -// 创建v1版本的路由组 -v1 := r.Group("/v1") +v1 := r.Group("/api/v1") { - v1.POST("/login", loginEndpoint) - v1.POST("/submit", submitEndpoint) - v1.POST("/read", readEndpoint) + v1.GET("/users", getUsers) + v1.POST("/users", createUser) + v1.PUT("/users/:id", updateUser) + v1.DELETE("/users/:id", deleteUser) } -// 创建v2版本的路由组 -v2 := r.Group("/v2") -{ - v2.POST("/login", loginEndpointV2) - v2.POST("/submit", submitEndpointV2) - v2.POST("/read", readEndpointV2) -} +// 为组注册中间件 +authGroup := r.Group("/api", AuthMiddleware()) ``` -## 完整示例 - -以下是一个完整的示例,展示了如何使用Gin框架创建一个简单的RESTful API服务: +### RESTful 完整示例 ```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}, -} +var users []User 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 + r.GET("/users", func(c *gin.Context) { + c.JSON(200, users) + }) + r.GET("/users/:id", func(c *gin.Context) { + id, _ := strconv.Atoi(c.Param("id")) + for _, u := range users { + if u.ID == id { + c.JSON(200, u) + return + } } - } - c.JSON(http.StatusNotFound, gin.H{"message": "User not found"}) -} + c.JSON(404, gin.H{"error": "not found"}) + }) + r.POST("/users", func(c *gin.Context) { + var u User + c.ShouldBindJSON(&u) + u.ID = len(users) + 1 + users = append(users, u) + c.JSON(201, u) + }) -// 创建用户 -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"}) + r.Run(":8080") } ``` -### 程序测试 +> [!tip] 💡 性能提示 +> - `gin.H` 是 `map[string]interface{}` 的别名,在高频场景中考虑使用自定义结构体替代,减少反射开销 +> - 启用 Gzip 压缩:`r.Use(gzip.Gzip(gzip.DefaultCompression))` +> - 生产环境建议关闭 Debug 模式:`gin.SetMode(gin.ReleaseMode)` -使用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 +- [[hzh/GolangStar/Go语言框架/gorm]] — Gin + GORM 是全栈开发的经典组合 +- [[hzh/GolangStar/Go语言进阶/Context]] — HTTP 请求天然适合用 context 控制超时和取消 diff --git a/hzh/GolangStar/Go语言框架/gorm.md b/hzh/GolangStar/Go语言框架/gorm.md index e19ba08..f2b7e3d 100644 --- a/hzh/GolangStar/Go语言框架/gorm.md +++ b/hzh/GolangStar/Go语言框架/gorm.md @@ -1,274 +1,200 @@ --- -tags: - - Go - - golang - - gorm - - mysql - - 数据库 +tags: [go, golang, gorm, ORM, 数据库] +create time: 2026-06-07 15:25 --- # GORM -GORM是一个功能强大的Go语言ORM库,提供了简洁的API来操作数据库。本文将详细介绍如何使用GORM与MySQL数据库进行交互。 +## 概述 -## 安装GORM和MySQL驱动 +GORM 是 Go 生态中最流行的 ORM 库,提供简洁的链式 API 操作数据库。本文从连接配置到 CRUD、关联关系和事务,覆盖日常开发的核心用法。 -首先,我们需要安装GORM和MySQL驱动。可以使用以下命令: +## 正文 + +### 安装 ```bash -go get -u gorm.io/gorm -go get -u gorm.io/driver/mysql +go get -gorm.io/gorm +go get -gorm.io/driver/mysql # 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() +dsn := "user:pass@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 get database object: %v", err) + log.Fatal("failed to connect database:", err) } -// 设置最大打开连接数 -sqlDB.SetMaxOpenConns(100) - -// 设置最大空闲连接数 -sqlDB.SetMaxIdleConns(10) +// 可选:调优连接池 +sqlDB, _ := db.DB() +sqlDB.SetMaxOpenConns(100) // 最大连接数 +sqlDB.SetMaxIdleConns(10) // 最大空闲连接 ``` -## 定义模型 +> [!tip] 💡 性能建议 +> - `SetMaxOpenConns` 应根据并发量和请求耗时调整——IO 密集型可适当放大 +> - `SetMaxIdleConns` 一般设为 MaxOpenConns 的 1/4~1/2 +> - 连接泄漏(未正确关闭)会导致连接池耗尽,务必注意 -在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字段 + ID uint `gorm:"primaryKey"` + Name string `gorm:"size:255;not null"` + Age int + Email string `gorm:"uniqueIndex"` + CreatedAt time.Time +} + +// 表名默认为复数形式 "users",可通过 TableName 自定义 +func (User) TableName() string { + return "app_users" } ``` -## 自动迁移 +> [!info] ℹ️ GORM 命名约定 +> - 表名:结构体名的蛇形复数(User → users) +> - 字段名:驼峰转蛇形(UserID → user_id) +> - 主键:自动使用 ID / uuid / uid 作为主键并自增 +> - 外键:关联字段名 + `_id` -GORM提供了自动迁移功能,可以根据模型结构自动创建或更新数据库表。 +### CRUD 操作 + +#### 创建 ```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) -} +result := db.Create(&user) // 自动回填 user.ID +fmt.Println(user.ID) // 查看插入后的 ID ``` -### 查询记录 - -#### 单行查询 - -使用`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) -} +// 根据主键查询 +var u1 User +db.First(&u1, 1) // SELECT * FROM users WHERE id = 1 + +// 条件查询 +var u2 []User +db.Where("age > ?", 18).Find(&u2) + +// 链式组合 +db.Where("name LIKE ?", "%张%").Order("age desc").Limit(10).Find(&u2) ``` -#### 多行查询 +> [!question] 💭 ORM vs 原生 SQL +> GORM 的链式调用确实优雅,但在复杂查询场景下是否值得牺牲 SQL 的表达能力?思考你的项目中哪些查询可以用 GORM 表达,哪些需要手写 SQL。 -使用`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) -} +// 更新单个字段 +db.Model(&user).Update("name", "李四") + +// 更新多个字段 +db.Model(&user).Updates(User{Name: "王五", Age: 30}) + +// 全量更新(注意:会忽略零值字段!) +db.Save(&user) ``` -### 更新记录 +> [!warning] ⚠️ Save 的零值陷阱 +> `Save()` 会用结构体的所有字段执行 UPDATE。如果某个字段是零值(如 `Age: 0`),会被更新为 0!建议使用 `Updates` 并只传需要更新的字段。 -使用`Save`方法可以更新记录。 +#### 删除 ```go -// 更新User记录的Age字段 -user.Age = 26 -result := db.Save(&user) -if result.Error != nil { - log.Fatalf("failed to update user: %v", result.Error) -} +db.Delete(&user, 1) // 软删除(需启用)或硬删除 +db.Where("age < ?", 18).Delete(&User{}) // 批量删除 ``` -### 删除记录 +> [!note] 📝 软删除 +> GORM 默认启用软删除——调用 Delete 后记录不会被物理删除,而是设置 `deleted_at` 时间戳。如需物理删除:`db.Unscoped().Delete(&user)` -使用`Delete`方法可以删除记录。 +### 关联关系 ```go -// 删除User记录 -result := db.Delete(&user) -if result.Error != nil { - log.Fatalf("failed to delete user: %v", result.Error) +type Profile struct { + ID uint + UserID uint `gorm:"index"` + Bio string } + +type Post struct { + ID uint + AuthorID uint `gorm:"index"` + Title string + Author User `gorm:"foreignKey:AuthorID"` // 指定外键 +} + +// 预加载关联数据 +db.Preload("Profile").First(&user, 1) +// SELECT * FROM profiles WHERE user_id = 1 ``` -## 事务支持 +> [!tip] 💡 N+1 问题 +> 在循环中逐个查询关联数据会产生 N+1 问题。始终使用 `Preload` 一次性加载: +> ```go +> // ❌ N+1 +> for _, post := range posts { +> db.First(&post.Author) +> } +> +> // ✅ 一次加载 +> db.Preload("Author").Find(&posts) +> ``` -GORM支持事务操作,可以通过`Transaction`方法来执行事务。 +### 事务 ```go -// 使用事务创建两个User记录 err := db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(&User{Name: "李四", Age: 30}).Error; err != nil { + if err := tx.Create(&User{Name: "Alice"}).Error; err != nil { + return err // 返回非零错误触发回滚 + } + if err := tx.Create(&User{Name: "Bob"}).Error; err != nil { return err } - if err := tx.Create(&User{Name: "王五", Age: 35}).Error; err != nil { - return err - } - return nil + return nil // 返回 nil 则提交 }) -if err != nil { - log.Fatalf("transaction failed: %v", err) -} ``` -## 预处理和SQL注入 +> [!warning] ⚠️ 事务中的常见错误 +> - 不要在事务中使用全局 `db` 变量,应使用传入的 `tx` +> - 事务内的所有操作都必须检查 `.Error` +> - 事务必须在同一个连接上执行(GORM 默认保证这一点) -GORM会自动处理SQL注入问题,因此我们不需要手动拼接SQL语句。所有的查询条件都可以通过链式调用来实现。 +### ORM vs NORM -```go -// 查询Name为"张三"的User记录 -db.Where("name = ?", "张三").First(&user) -``` +> [!question] 💭 思考 +> GORM 属于 ORM(对象关系映射),而 Gin 搭配 sqlx 属于 NORM(无映射)。什么时候该用 ORM,什么时候该用 NORM? -## 完整示例 -以下是一个完整的示例代码,展示了如何使用GORM连接MySQL数据库并进行CRUD操作。 -```go -package main +| 维度 | ORM (GORM) | NORM (sqlx/raw SQL) | +|------|-----------|---------------------| +| 开发效率 | 高(链式 API) | 低(需写 SQL) | +| 类型安全 | 编译期部分保障 | 完全依赖 SQL 编写 | +| 复杂查询 | 有限支持 | 完全自由 | +| 性能 | 有额外开销 | 最优 | +| 学习曲线 | 低 | 需熟悉 SQL | -import ( - "fmt" - "gorm.io/driver/mysql" - "gorm.io/gorm" - "log" -) +> [!tip] 💡 混合策略 +> 大多数项目可以采用混合模式:简单 CRUD 用 GORM,复杂报表/分析查询用原生 SQL: +> ```go +> // 简单查询用 GORM +> db.Find(&users) +> +> // 复杂查询用原生 SQL +> var results []struct { +> Department string +> AvgAge float64 +> } +> db.Raw(`SELECT department, AVG(age) as avg_age FROM users GROUP BY department`).Scan(&results) +> ``` -// 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 +- [[hzh/GolangStar/Go语言框架/gin]] — Gin + GORM 是 Go Web 全栈的经典搭档 +- [[hzh/GolangStar/Go语言进阶/Context]] — HTTP 请求生命周期与数据库事务结合 diff --git a/hzh/GolangStar/Go语言进阶/Channel.md b/hzh/GolangStar/Go语言进阶/Channel.md index 5262658..6107530 100644 --- a/hzh/GolangStar/Go语言进阶/Channel.md +++ b/hzh/GolangStar/Go语言进阶/Channel.md @@ -1,287 +1,242 @@ --- -tags: - - Go - - golang - - go进阶语法 - - Channel +tags: [go, golang, Channel, 并发, CSP] +create time: 2026-06-07 14:40 --- # 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 是 Go 中 goroutine 之间通信的核心机制。Go 倡导"以通信共享内存,而非共享内存来通信",channel 正是这一哲学(CSP 模型)的具象化体现。本文涵盖 channel 的创建、操作、单向/双向类型、缓冲语义以及实战技巧。 + +## 正文 + +### 什么是 Channel? + +> [!question] 💭 思考 +> 如果多个 goroutine 需要交换数据,除了用锁保护共享变量,还有没有更安全的方式? + +官方定义: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为缓存大小 +ch := make(chan int) // 创建一个传递 int 的 channel +ch <- 42 // 发送:向 ch 写入 42 +v := <-ch // 接收:从 ch 读取值到 v ``` -## channel操作 -channel的操作主要有以下几种 +> [!info] ℹ️ 核心理念 +> **"Don't communicate by sharing memory; share memory by communicating."** — Rob Pike +> 与其用锁保护共享数据,不如让数据在 goroutine 之间流动——拥有数据的 goroutine 就是唯一能修改它的那个。 + +### 创建与初始化 + ```go -ch := make(chan int) // 创建一个管道ch -ch <- v // 向管道ch中发送数据v. -v := <-ch // 从管道中读取数据存储到变量v -close(ch) // 关闭管道ch +// 无缓冲 channel(同步模式) +ch := make(chan int) + +// 有缓冲 channel(异步模式,容量为 3) +ch := make(chan int, 3) ``` -在这里需要注意`close(ch)`这个操作,管道用完了,需要对其进行关闭,避免程序一直在等待以及资源的浪费。但是关闭的管道,仍然可以从中接收数据,只是接收到的的数据永远是零值。 -看下面例子: + +> [!warning] ⚠️ 常见错误 +> - **未初始化的 channel 是 `nil`**,对 nil channel 发送/接收会永久阻塞 +> - **重复关闭 channel 会 panic** +> - **向已关闭的 channel 发送会 panic** + +### 基本操作 + +| 操作 | 语法 | 说明 | +|------|------|------| +| 发送 | `ch <- value` | 向 channel 写入,可能阻塞 | +| 接收 | `<-ch` | 从 channel 读取,可能阻塞 | +| 带值接收 | `v := <-ch` | 读取值并赋值 | +| 关闭 | `close(ch)` | 标记 channel 不再发送数据 | + ```go package main -import ( - "fmt" - "time" -) +import "fmt" 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) + ch := make(chan int) + 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) - } - } + ch <- 42 // 生产者:发送数据 + close(ch) // 生产完毕,关闭 channel }() - time.Sleep(2 * time.Second) + + v := <-ch // 消费者:接收数据 + fmt.Println(v) // 42 } ``` -运行结果: -``` -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`中的数据了 +### 关闭 Channel 后的行为 + +> [!question] 💭 思考 +> 关闭一个 channel 后还能读吗?还能写吗?读完已有数据后再读会怎样? + +| 操作 | 未关闭 | 已关闭(有数据) | 已关闭(空) | +|------|--------|-----------------|-------------| +| 发送 | ✅ 正常 | ❌ **panic** | ❌ **panic** | +| 接收 | ✅ 正常 | ✅ 返回已有数据 | ✅ 返回零值 | +| 关闭 | ✅ 正常 | ✅ 可重复关闭检查 | ❌ panic(重复关闭) | + ```go -package main +ch := make(chan int, 5) +ch <- 1 +close(ch) -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) +// 关闭后仍可读取 +for i := 0; i < 5; i++ { + v := <-ch + fmt.Println(v) // 第1次: 1, 之后全是 0 } ``` -运行结果: -``` -v=1 -v=2 -``` -主`goroutine`往`channel`里写了两个数据`1`和`2`,然后关闭,子`goroutine`也只能读取到`1`和`2`。这里在主`goroutine`关闭了`channel`之后,子`goroutine`里的`for range`循环才会结束。 -## 双向channel和单向channel -channel根据其功能又可以分为双向`channel`和单向`channel`,双向`channel`即可发送数据又可接收数据,单向`channel`要么只能发送数据,要么只能接收数据。 -定义单向读`channel` +#### 安全读取:ok 模式 + +当需要从已关闭的 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) +v, ok := <-ch +if ok { + fmt.Println("收到数据:", v) +} else { + fmt.Println("channel 已关闭,无更多数据") } ``` -运行结果: -``` -send: 100 -receive: 100 -``` -创建一个`channel ch`,分别定义两个单向`channel`类型`SChannel`和`RChannel` ,根据别名类型给`ch`定义两个别名`send`和`rec`,一个只用于发送,一个只用于读取。 -## 扩展 -`channel`非常重要,Go语言中有个重要思想:不以共享内存来通信,而以通信来共享内存。 -说得更直接点,协程之间可以利用`channel`来传递数据,如下的例子,可以看出父子协程如何通信的,父协程通过`channel`拿到了子协程执行的结果。 +#### for-range 读取 + +这是最优雅的 channel 消费方式——自动在 channel 关闭时退出循环: + ```go -package main +go func() { + for v := range ch { // channel 关闭后自动退出 + fmt.Println("收到:", v) + } + fmt.Println("channel 已关闭,循环结束") +}() -import ( - "fmt" - "time" -) +close(ch) // 触发 for-range 退出 +``` -func sum(s []int, c chan int) { - sum := 0 - for _, v := range s { - sum += v - } - c <- sum // send sum to c +> [!tip] 💡 最佳实践 +> **发送端负责关闭,接收端不负责关闭**。如果一个 channel 有多个发送方,谁该关闭它?这种模糊场景应尽量避免。 + +### 有缓冲 vs 无缓冲 + +```mermaid +flowchart LR + subgraph Unbuf["无缓冲 channel"] + S[发送方] -->|"阻塞直到接收方就绪"| R[接收方] + end + + subgraph Buf["有缓冲 channel (cap=3)"] + B["缓冲区 📦📦"] -.->|剩余容量| S2[发送方] + R2[接收方] -.->|有空位| S2 + R2 -->|"取出"| B + end +``` + +- **无缓冲 channel**:发送和接收同步发生——发送方阻塞直到接收方准备好,反之亦然。适用于需要严格配对的生产者-消费者场景。 +- **有缓冲 channel**:发送方在缓冲区未满时无需等待即可返回;接收方在缓冲区非空时无需等待即可拿到数据。适用于解耦生产速率和消费速率的场景。 + +> [!warning] ⚠️ 注意 +> 缓冲区满了以后,有缓冲 channel 也会退化为同步模式——发送方将被阻塞。长期满队列意味着消费者跟不上生产者的节奏,应考虑增加消费者数量或扩大缓冲区。 + +### 单向 Channel + +有时我们希望限制 channel 的使用方向,比如在函数签名中明确表达"这个参数只用于发送"或"只用于接收": + +```go +// 只发送 channel —— 只能 ch<-value +sendCh := make(chan int) +var sendOnly chan<- int = sendCh + +// 只接收 channel —— 只能 <-ch +var recvOnly <-chan int = sendCh +``` + +```go +func producer(out chan<- int) { + for i := 0; i < 5; i++ { + out <- i // 只能发,不能收 + } + close(out) +} + +func consumer(in <-chan int) { + for v := range in { // 只能收,不能发 + fmt.Println("received:", v) + } +} +``` + +> [!note] 📝 关键规则 +> 只有**接收方**才能关闭 channel。单向 channel 的类型转换是单向的:双向可以转为单向,单向不能转回双向。 + +### 经典模式:扇入扇出 + +> [!question] 💭 思考 +> 如果有 10 个任务要并行处理,但结果需要汇总到一个地方,怎么设计? + +```go +func worker(id int, jobs <-chan int, results chan<- int) { + for j := range jobs { + fmt.Printf("worker %d processing job %d\n", id, j) + results <- j * 2 + } } func main() { - s := []int{7, 2, 8, -9, 4, 0} + jobs := make(chan int, 100) + results := make(chan int, 100) - 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 + // 启动 3 个 worker(扇出) + for w := 1; w <= 3; w++ { + go worker(w, jobs, results) + } - fmt.Println(x, y, x+y) + // 发送任务 + for j := 1; j <= 9; j++ { + jobs <- j + } + close(jobs) + + // 等待所有 worker 完成 + close(results) + for r := range results { + fmt.Println("result:", r) + } } ``` -运行结果: -``` --5 17 12 -``` -`channel`又分为两类:有缓冲`channel` 和无缓冲`channel`,这个在前面的代码示例中也有简单的描述了。为了协程安全,无论是有无缓冲的`channel`,内部都会有一把锁来控制并发访问。同时`channel`底层一定有一个队列,来存储数据。 -无缓冲`channel`可以理解为同步模式,即写入一个,如果没有消费者在消费,写入就会阻塞。 -有缓冲`channel`可以理解为异步模式。即写入消息之后,即使还没被消费,只要队列没满,就可继续写入。如图所示: -![](https://golangstar.cn/assets/img/go语言系列/channel/channel1.png) +### Channel 实现互斥锁 -这里可能会问,如果有缓冲`channel`队列满了,那不就退化到同步了么?是的,如果队列满了,发送还是会阻塞。 +> [!question] 💭 思考 +> channel 本身已经是并发安全的了,能不能利用这一点来代替 mutex? -![](https://golangstar.cn/assets/img/go语言系列/channel/channel2.png) +一个容量为 1 的 channel 可以充当信号量: -但是我们来反向思考下,如果有缓冲`channel`长期都处于满队列情况,那何必用有缓冲。所以预期在正常情况下,有缓冲`channel`都是异步交互的。 - -## channel实现锁操作 -前面分析了当缓冲队列满了以后,继续往`channel`里面写数据,就会阻塞,那么利用这个特性,我们可以实现一个`goroutine`之间的锁。(对并发安全比较模糊的可以把后面`sync`小节看完再来看这里) -直接看示例 ```go -package main +ch := make(chan struct{}, 1) +ch <- struct{}{} // 获取"锁" -import ( - "fmt" - "time" -) +// 临界区:同时只有一个 goroutine 能执行 +*counter++ -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) -} +<-ch // 释放"锁" ``` -运行结果: -``` -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`,读出了管道的元素,这样就实现了并发安全 +> [!tip] 💡 何时用 Channel vs Mutex? +> - 优先用 **channel**:goroutine 间的数据传递、事件通知、生命周期管理 +> - 优先用 **mutex**:保护共享变量的并发访问 +> - 两者不互斥,复杂场景中经常配合使用 +## 关联笔记 -## 小结 -- 关闭一个未初始化的`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 +- [[hzh/GolangStar/Go语言进阶/Select]] +- [[hzh/GolangStar/Go语言进阶/Sync]] +- [[hzh/GolangStar/Go语言进阶/Goroutine]] +- [[hzh/GolangStar/Go语言原理/channel原理]] diff --git a/hzh/GolangStar/Go语言进阶/Context.md b/hzh/GolangStar/Go语言进阶/Context.md index e39e600..4b9e9ec 100644 --- a/hzh/GolangStar/Go语言进阶/Context.md +++ b/hzh/GolangStar/Go语言进阶/Context.md @@ -1,267 +1,194 @@ --- -tags: - - Go - - golang - - go进阶 - - context - - 并发 +tags: [go, golang, Context, 并发, 超时控制] +create time: 2026-06-07 14:55 --- # Context -## Context是什么 -`context`是Go语言在1.7版本中引入的一个标准库接口,其定义如下: +## 概述 + +Context 是 Go 中跨 goroutine 传递取消信号、截止时间、请求作用域值的标准机制。它是构建可取消、可超时、可追踪的并发程序的核心工具。 + +## 正文 + +### Context 接口 + ```go type Context interface { - Deadline() (deadline time.Time, ok bool) - Done() <-chan struct{} - Err() error - Value(key interface{}) 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: +> [!question] 💭 思考 +> 当一个 HTTP 请求被客户端断开连接时,如何通知所有正在处理的 goroutine 立即停止? + +四个方法各司其职: +- `Deadline`:返回截止时间(如果设置了) +- `Done`:返回只读 channel,关闭时表示应停止工作 +- `Err`:返回取消原因(`context.Canceled` 或 `context.DeadlineExceeded`) +- `Value`:获取请求级别的上下文信息(如 traceID、用户信息) + +### 创建根 Context + ```go -context.Background() -context.TODO() +ctx := context.Background() // 空 context,不可取消,作为所有 context 的祖先 +ctx := context.TODO() // 不确定该用哪个 context 时的占位 ``` -从源代码分析`context.Background`和`context.TODO`并没有太多的区别,都是用于创建根context,根context是一个空的context,不具备任何功能。但是一般情况下,如果当前函数没有上下文作为入参,我们都会使用`context.Background`创建一个根context作为起始的上下文向下传递。 +> [!note] 📝 Background vs TODO +> - `Background()`:已知没有父 context 时使用(如 main 函数入口) +> - `TODO()`:暂时不知道用什么 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 派生树 + +> [!info] ℹ️ Context 的传播链 +> Context 通过 With 系列函数层层派生,形成一棵树。取消一个父 context,所有子 context 都会收到取消信号。 + +```mermaid +flowchart TD + BG["background"] --> C1["WithCancel"] + C1 --> C2["WithTimeout"] + C1 --> C3["WithValue"] + C2 --> C4["WithValue"] + + style BG fill:#e8f5e9 + style C1 fill:#fff3e0 + style C2 fill:#e3f2fd + style C3 fill:#fce4ec + style C4 fill:#f3e5f5 ``` -基于当前`context`,每个`with`函数都会创建出一个新的`context`,这样类似于我们熟悉的树结构,当前`context`称为父`context`,派生出的新`context`称为子`context`。就像下面的`context`树结构: -![Context树结构](https://golangstar.cn/assets/img/go语言系列/context/context1.png) +| 派生函数 | 功能 | 典型场景 | +|----------|------|---------| +| `WithCancel` | 手动取消 | 业务逻辑主动终止 | +| `WithTimeout` | 超时自动取消 | RPC 调用、HTTP 请求 | +| `WithDeadline` | 指定时刻取消 | 定时任务截止 | +| `WithValue` | 传递请求级数据 | traceID、用户认证信息 | -通过根`context`,通过四个`with`系列方法可以派生出四种类型的`context`,每种`context`又可以通过同样的方式调用`with`系列方法继续向下派生新的`context`,整个结构像一棵树。 +### 并发控制模式 -## Context有什么用 -`context`主要有两个用途,也是在项目中经常使用的: -1. 用于并发控制,控制协程的优雅退出 -2. 上下文的信息传递 -总的来说,`context`就是用来在父子`goroutine`间进行值传递以及发送`cancel`信号的一种机制。 +#### WithCancel — 手动取消 -### 并发控制 -对于一般的服务器而言,都是一致运行着的,等待接收来自客户端或者浏览器的请求做出响应,思考这样一种场景,后台微服务架构中,一般服务器在收到一个请求之后,如果逻辑复杂,不会在一个`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) + ctx, cancel := context.WithCancel(context.Background()) + + go watch(ctx, "observer1") + go watch(ctx, "observer2") + + time.Sleep(5 * time.Second) + cancel() // 通知所有监听者退出 + time.Sleep(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) - } - } +func watch(ctx context.Context, name string) { + for { + select { + case <-ctx.Done(): + fmt.Printf("%s: %v\n", name, ctx.Err()) + return + default: + fmt.Printf("%s working...\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 -方法定义如下: +> [!tip] 💡 必记习惯:cancel 函数要调用 +> ```go +> ctx, cancel := context.WithTimeout(parent, 5*time.Second) +> defer cancel() // 延迟释放资源,即使提前返回也不会泄漏 +> ``` + +#### WithTimeout / WithDeadline + ```go -func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) +// 超时 5 秒 +ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +defer cancel() + +// 截止到明天中午 12 点 +ctx, cancel := context.WithDeadline(context.Background(), tomorrowNoon) +defer cancel() ``` -`context.WithDeadline`也是一个取消控制函数,方法有两个参数,第一个参数是一个`context`,第二个参数是截止时间,同样会返回一个子`context`和一个取消函数`CancelFunc`。在使用的时候,没有到截止时间,我们可以通过手动调用`CancelFunc`来取消子`context`,控制子`goroutine`的退出,如果到了截止时间,我们都没有调用`CancelFunc`,子`context`的`Done()`管道也会收到一个取消信号,用来控制子`goroutine`退出。 -使用示例: + +> [!note] 📝 Timeout vs Deadline +> - `WithTimeout(d)` = 从现在开始经过 d 时间后超时 +> - `WithDeadline(t)` = 在绝对时间点 t 超时 +> - 大多数情况下使用 `WithTimeout` 更直观 + +#### WithValue — 传递请求级数据 + ```go -package main +type contextKey string -import ( - "context" - "fmt" - "time" -) +const userIDKey contextKey = "userID" -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 handler(w http.ResponseWriter, r *http.Request) { + // 从请求头获取用户 ID,放入 context + ctx := context.WithValue(r.Context(), userIDKey, getUserID(r)) + + // 传递给下游 goroutine + processRequest(ctx) } -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) - } - } +func processRequest(ctx context.Context) { + if id := ctx.Value(userIDKey); id != nil { + log.Printf("processing for user %v", id) + } } ``` -运行结果: -``` -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 -方法定义: +> [!warning] ⚠️ Value 的使用禁忌 +> 1. **不要用 Context 传递可选参数**——那是函数参数的职责 +> 2. **key 不要使用 string/int 类型**——容易冲突,自定义 type 作为 key +> 3. **Value 是不可变的**——子 context 只能读取不能修改父的值 +> 4. **不用于传递业务核心数据**——这些数据应该作为函数参数显式传递 + +### 完整实战:带超时的 RPC 链路 + ```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) - } - } +func handleRequest(w http.ResponseWriter, r *http.Request) { + // 1. 基于请求创建 context(携带超时和 traceID) + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + ctx = context.WithValue(ctx, traceIDKey, generateTraceID()) + + // 2. 并行调用多个下游服务 + resultCh := make(chan Result, 2) + + go func() { + resultCh <- callServiceA(ctx) + }() + go func() { + resultCh <- callServiceB(ctx) + }() + + // 3. 等待结果或超时 + select { + case r1 := <-resultCh: + handleResult(r1) + case r2 := <-resultCh: + handleResult(r2) + case <-ctx.Done(): + http.Error(w, "request timeout", http.StatusGatewayTimeout) + } } ``` -运行结果: -``` -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 +> [!warning] ⚠️ Context 传播的最佳实践 +> - Context 必须作为函数的**第一个参数**,命名为 `ctx` +> - 永远用传入的 context 创建新的 context,不要自己新建 background +> - 所有网络调用、DB 查询都应接受 context 并检查取消信号 +> - 不要在 goroutine 中忽略 context.Done()——否则会导致 goroutine leak -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 -``` +- [[hzh/GolangStar/Go语言进阶/Select]] +- [[hzh/GolangStar/Go语言进阶/Goroutine]] +- [[hzh/GolangStar/Go语言原理/context原理]] diff --git a/hzh/GolangStar/Go语言进阶/Goroutine.md b/hzh/GolangStar/Go语言进阶/Goroutine.md index 1cc16b7..5bf92ae 100644 --- a/hzh/GolangStar/Go语言进阶/Goroutine.md +++ b/hzh/GolangStar/Go语言进阶/Goroutine.md @@ -1,112 +1,194 @@ --- -tags: - - Go - - golang - - go进阶语法 - - Goroutine +tags: [go, golang, Goroutine, 并发, CSP] +create time: 2026-06-07 14:35 --- # 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() // 会并发执行这个函数 +Goroutine 是 Go 语言对协程的原生支持,以极低的启动成本(约 2KB 栈)和灵活的栈扩容机制,让开发者可以轻易创建成千上万的并发任务。本文讲解 goroutine 的基本用法、生命周期管理以及最佳实践。 + +## 正文 + +### 为什么需要 Goroutine? + +> [!question] 💭 思考 +> 假设有 1000 个独立的 HTTP 请求要同时发出,用传统线程模型你会怎么做? + +在 C++/Java 等传统语言中,线程栈大小通常为 2MB,且创建/销毁涉及系统调用,开销较大。因此这些语言通常使用线程池来复用线程。 + +Go 的 Goroutine 则完全不同: + +| 对比项 | OS 线程 | Go Goroutine | +|--------|---------|-------------| +| 初始栈大小 | ~2MB | ~2KB | +| 最大栈大小 | 受限于系统内存 | 可达 1GB(动态扩容) | +| 创建方式 | 系统调用 | 用户态调度 | +| 典型并发量 | 数百 | 数十万 | + +```mermaid +flowchart TD + A["main goroutine"] -->|"go func()"| B["goroutine 1"] + A -->|"go func()"| C["goroutine 2"] + A -->|"go func()"| D["goroutine n..."] + + subgraph Runtime["Go Runtime GMP 调度器"] + G["Goroutine 队列"] + M["M: OS Thread"] + P["P: Processor"] + G --> M + P --> M + end + + B --> Runtime + C --> Runtime + D --> Runtime ``` -## 主协程 -和其它语言一样,Go程序的入口也是`main`函数。在程序开始执行的时候,Go程序会为`main`函数创建一个默认的`goroutine`,我们称之为主协程,我们后来人为的创建的一些`goroutine`,都是在这个主`goroutine`的基础上进行的。 -下面请看个例子: +> [!info] ℹ️ 补充 +> Goroutine 由 Go 运行时通过 **GMP 调度模型**管理——G(goroutine)、M(OS thread)、P(processor)。你只需 `go` 关键字即可交给运行时调度,无需关心底层细节。详见 [[hzh/GolangStar/Go语言原理/gmp调度原理]]。 + +### 基本用法 + +开启一个 goroutine 只需要在函数调用前加 `go` 关键字: + +```go +func task(name string) { + fmt.Printf("%s running\n", name) +} + +func main() { + go task("worker") // 异步执行,不阻塞主流程 + fmt.Println("main continues immediately") +} +``` + +> [!warning] ⚠️ 关键陷阱:主协程退出 = 全部终止 +> 当 `main` 函数返回时,程序立即结束——所有其他 goroutine 无论是否执行完都会被强制终止。这与 Java/C++ 中后台线程继续运行的行为不同。 + ```go package main import "fmt" -func myGroutine() { - fmt.Println("myGroutine") +func myTask() { + fmt.Println("子协程输出") } func main() { - go myGroutine() - fmt.Println("end!!!") + go myTask() + fmt.Println("end!!!") + // ❌ 这里如果没有等待,myTask 很可能来不及执行 } +// 可能只输出: end!!! ``` -运行结果: -``` -end!!! -myGroutine -``` -很奇怪,明明是多协程任务,为什么只打印了主协程里的"end!!!",而没有打印我们开启的协程里的输出"myGroutine",按理不是应该都打印出来吗? -这是因为:当`main`函数返回的时候该`goroutine`就结束了,当主协程退出的时候,其他剩余的`goroutine`不管是否运行完,都会跟着结束。所以,这里主协程打印完"end!!!"之后就退出了,`myGroutine`协程可能还没运行到`fmt.Println("myGroutine")`语句也跟着退出了。 -接下来我们让主`goroutine`执行完`fmt.Println("end!!!")`之后不立刻退出,而是等待2s,看一下运行结果: + +上面的代码大概率只打印 `"end!!!"`,因为主协程执行完就退出了,子协程还没来得及运行。**正确做法是使用同步原语等待**: + ```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" + "fmt" + "sync" ) -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 myTask(name string, wg *sync.WaitGroup) { + defer wg.Done() // 标记本 goroutine 完成 + fmt.Printf("%s running\n", name) } func main() { - var wg sync.WaitGroup - wg.Add(2) + var wg sync.WaitGroup + wg.Add(2) - go myGoroutine("goroutine1", &wg) - go myGoroutine("goroutine2", &wg) + go myTask("goroutine1", &wg) + go myTask("goroutine2", &wg) - wg.Wait() + wg.Wait() // 阻塞直到计数器归零 + fmt.Println("all done") } ``` -运行结果: + +> [!tip] 💡 最佳实践 +> - 始终使用 `sync.WaitGroup` / channel / context 来管理 goroutine 生命周期,而非 `time.Sleep` +> - `defer wg.Done()` 放在 goroutine 入口第一行,确保即使 panic 也能正确递减计数器 + +### 竞态条件与数据竞争 + +> [!question] 💭 思考 +> 多个 goroutine 同时读写同一个变量会发生什么?就像两个人同时修改同一张纸上的数字。 + +当两个或多个 goroutine 同时访问同一块内存,且至少有一个是写操作时,就会发生**数据竞争(Data Race)**。这是 Go 中最常见的并发 bug 之一。 + +```go +var counter int + +func increment() { + counter++ // ❌ 危险!多个 goroutine 同时读写 counter +} + +func main() { + for i := 0; i < 1000; i++ { + go increment() + } + time.Sleep(time.Second) + fmt.Println(counter) // 结果不确定! +} ``` -myGroutine goroutine1 -myGroutine goroutine2 + +检测数据竞争:Go 提供了内置工具——编译时加上 `-race` 标志即可启用 race detector: + +```bash +go run -race main.go +go test -race ./... ``` -从结果中可以看到,两个协程并发执行,互不干扰。注意在上述例子中,我们使用了`sync.WaitGroup`来等待所有协程执行完毕之后再退出。关于`sync.WaitGroup`的详细介绍,可以参考[sync.WaitGroup](https://pkg.go.dev/sync#WaitGroup)。后续也会在`sync`章节详细介绍。 +解决方式见 [[hzh/GolangStar/Go语言进阶/Sync]](Mutex / Atomic / Channel)。 +### Goroutine Leak +> [!warning] ⚠️ 隐形杀手:Goroutine Leak +> 如果一个 goroutine 永远无法结束(比如等待永远不会到来的 channel 消息),它就会一直驻留在内存中——这就是 goroutine leak。它比内存泄漏更难发现,因为 GC 不会回收仍在活跃的 goroutine。 +常见泄漏场景: + +```go +// 场景1:channel 未关闭,接收方永久阻塞 +func reader(ch <-chan int) { + v := <-ch // 如果 ch 永远不会关闭或写入,这里永久阻塞 +} + +// 场景2:select 中没有 default,且所有 case 都无响应 +func selectLoop() { + ch := make(chan int) + select { + case v := <-ch: // 无 goroutine 往 ch 写数据 + fmt.Println(v) + } // 死锁 +} + +// 场景3:超时未设置 +func fetchWithTimeout() { + result := make(chan string, 1) + go func() { + result <- doSlowWork() // 可能永远不返回 + }() + // ❌ 没有超时机制,goroutine 可能永远挂起 + <-result +} +``` + +> [!tip] 💡 避免泄漏的 checklist +> - 每个发送端应在合适时机调用 `close(ch)` +> - 使用 `context.WithTimeout` 为所有外部调用设置超时 +> - 在 select 中使用 `default` 实现非阻塞检查 +> - 使用 `goleak` 库在测试中检测泄漏:`go.uber.org/goleak` + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Channel]] +- [[hzh/GolangStar/Go语言进阶/Sync]] +- [[hzh/GolangStar/Go语言进阶/Context]] +- [[hzh/GolangStar/Go语言进阶/并发概述]] diff --git a/hzh/GolangStar/Go语言进阶/Select.md b/hzh/GolangStar/Go语言进阶/Select.md index 5f133cc..a2d1afd 100644 --- a/hzh/GolangStar/Go语言进阶/Select.md +++ b/hzh/GolangStar/Go语言进阶/Select.md @@ -1,190 +1,158 @@ --- -tags: - - Go - - golang - - go进阶 - - select - - 并发 +tags: [go, golang, Select, 并发, 多路复用] +create time: 2026-06-07 14:45 --- # Select -## select是什么 -`select`是Go语言层面提供的一种多路复用机制,用于检测当前`goroutine`连接的多个`channel`是否有数据准备完毕,可用于读或写。 +## 概述 -## IO多路复用 -看到`select`,很自然的会联想到Linux提供的IO多路复用模型:`select`、`poll`、`epoll`,IO复用主要用于提升程序处理IO事件的性能。Go语言中的`select`与Linux中的`select`有一定的区别。操作系统中的IO多路复用简单理解就是用一个或者是少量线程处理多个IO事件。 +`select` 是 Go 原生提供的多路复用机制,用于在多个 channel 操作中选择一个执行。它是构建超时控制、心跳检测、任务调度等并发模式的核心原语。 -### 传统阻塞IO vs IO多路复用 +## 正文 -#### 传统阻塞IO -对于每一个网络IO事件,操作系统都会起一个线程去处理,在IO事件没准备好的时候,当前线程就会一直阻塞。 +### select 是什么? -![传统阻塞IO](https://golangstar.cn/assets/img/go语言系列/select/select1.png) +> [!question] 💭 思考 +> 如果一个 goroutine 需要同时监听 3 个 channel,但一次只有一个有数据,怎么高效地处理而不阻塞? -优缺点: -- 优点:逻辑简单,在阻塞等待期间线程会挂起,不会占用 CPU 资源 -- 缺点:每个连接需要独立的线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大 +`select` 的语法类似于 `switch`,但每个 case 必须是 channel 操作: -#### 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 ... +case msg := <-ch1: // ch1 可读时执行 + fmt.Println("received", msg) +case ch2 <- data: // ch2 可写时执行 + fmt.Println("sent") +default: // 所有 case 都无法立即执行时 + fmt.Println("no channel ready") } ``` -可以看到,`select`的用法形式类似于`switch`,但是区别于`switch`的是,`select`各个`case`的表达式必须都是`channel`的读写操作。`select`通过多个`case`语句监听多个`channel`的读写操作是否准备好可以执行,其中任何一个`case`可以执行了则选择该`case`语句执行,如果没有可以执行的`case`,则执行`default`语句,如果没有`default`,则当前`goroutine`会阻塞。 -### 空select永久阻塞 -当一个`select`中什么语句都没有,没有任何`case`,将会永久阻塞: +**核心行为规则:** +1. 所有 case 的 channel 操作都会被求值 +2. 如果有多个 case 同时就绪,**随机选择一个**执行 +3. 没有 case 就绪且有 `default`,立即执行 default +4. 没有 case 就绪且无 `default`,**阻塞等待** + +> [!warning] ⚠️ 致命陷阱:空 select 导致死锁 +> ```go +> func main() { +> select {} // ❌ 没有任何 case,永久阻塞 +> } +> ``` +> Go 运行时检测到所有 goroutine 都阻塞时会报 `deadlock` 错误。这是调试并发程序时最常见的 panic 之一。 + +### 超时控制模式 + +> [!tip] 💡 最实用的 select 模式:超时控制 + ```go -package main +// 方式1:time.After(简洁) +select { +case result := <-doWork(): + fmt.Println("完成:", result) +case <-time.After(5 * time.Second): + fmt.Println("超时!") +} -func main() { +// 方式2:time.Timer(可取消,推荐) +timer := time.NewTimer(5 * time.Second) +defer timer.Stop() // 提前返回时释放资源 + +select { +case result := <-doWork(): + fmt.Println("完成:", result) + timer.Stop() // 完成后停止定时器 +case <-timer.C: + fmt.Println("超时!") +} +``` + +> [!note] 📝 Timer vs After +> - `time.After(d)` 内部创建一个 Timer,无法提前释放——超时期间定时器仍在运行 +> - `time.NewTimer(d)` 可随时调用 `.Stop()` 释放底层资源,适合长生命周期场景 + +### 随机选择 + +当多个 case 同时就绪时,Go 会伪随机选择一个: + +```go +ch1 := make(chan int, 1) +ch2 := make(chan int, 1) +ch1 <- 1 +ch2 <- 2 + +select { +case v := <-ch1: + fmt.Println("ch1:", v) // 可能是 ch1 或 ch2 +case v := <-ch2: + fmt.Println("ch2:", v) // 结果不确定 +} +``` + +> [!info] ℹ️ 补充 +> 这种随机性是为了避免"饥饿"——总是优先选择同一个 channel 会导致其他 channel 永远得不到服务。如果你需要确定性顺序,应使用其他方式编排。 + +### 非阻塞检查 + +利用 `default` 实现非阻塞的 channel 读写: + +```go +select { +case msg := <-ch: + fmt.Println("收到消息:", msg) +default: + fmt.Println("当前没有待处理的消息") // 不会阻塞 +} +``` + +这在轮询场景或实现高性能服务端中非常有用。 + +### 实际应用场景 + +#### 场景1:带超时的 RPC 调用 + +```go +func callRPC(ctx context.Context, query string) (string, error) { + resultCh := make(chan string, 1) + + go func() { + resultCh <- doRemoteCall(query) + }() + select { + case result := <-resultCh: + return result, nil + case <-ctx.Done(): + return "", ctx.Err() // 被取消 } } ``` -运行结果: -``` -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都准备好了的时候,会随机选择一个执行 +#### 场景2:优雅关闭 ```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!!!") +func monitor(ctx context.Context, ticker <-chan time.Time) { + for { + select { + case t := <-ticker: + fmt.Println("heartbeat at", t) + case <-ctx.Done(): + fmt.Println("shutting down...") + return + } } } ``` -运行结果: -``` -Received from ch2, val = 6 -``` -多次执行,2个`case`都有可能打印,这就是`select`选择的随机性。 +> [!warning] ⚠️ 并发安全提醒 +> - select 本身是并发安全的,多个 goroutine 可以同时执行不同的 select +> - 但不要在一个 goroutine 中对同一 channel 既读又写(除非你清楚自己在做什么) +> - 永远记得:关闭未初始化的 nil channel 和重复关闭都会 panic +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Channel]] +- [[hzh/GolangStar/Go语言进阶/Context]] +- [[hzh/GolangStar/Go语言进阶/定时器]] diff --git a/hzh/GolangStar/Go语言进阶/Sync.md b/hzh/GolangStar/Go语言进阶/Sync.md index bdab347..e1f7093 100644 --- a/hzh/GolangStar/Go语言进阶/Sync.md +++ b/hzh/GolangStar/Go语言进阶/Sync.md @@ -1,768 +1,282 @@ --- -tags: - - Go - - golang - - go进阶 - - sync - - 并发安全 +tags: [go, golang, Sync, 并发安全, Mutex, WaitGroup] +create time: 2026-06-07 14:50 --- -# sync包 -在前面学习`channel`的时候,我们说到在Go语言并发编程中,倡导使用通信共享内存,不要使用共享内存通信,`goroutine`之间尽量通过`channel`来协作。而在其他的传统语言中,都是通过共享内存加上锁机制来保证并发安全的,同样Go语言也提供了对共享内存并发安全机制的支持,这些功能都存在于`sync`包下。 +# sync 包 -## sync.WaitGroup -在前面很多`goroutine`的示例中,我们都是通过`time.Sleep()`方法让主`goroutine`等待一段时间以便子`gortoutine`能够执行完打印结果,显然这不是一个很好的办法,因为我们不知道所有的子`gortoutine`要多久才能执行完,不能确切的知道需要等待多久。那要怎么处理呢? +## 概述 + +当 channel 的"通信共享内存"哲学不完全适用时,`sync` 包提供了底层同步原语来保护共享资源的并发访问。本文覆盖 WaitGroup、Once、Mutex/RWMutex、Map、Atomic 和 Pool 六大组件的使用场景与最佳实践。 + +## 正文 + +### sync.WaitGroup — 等待一组 goroutine 完成 + +> [!question] 💭 思考 +> 你启动了 10 个 goroutine 并行处理任务,怎么知道它们全部执行完毕?用 `time.Sleep`?这显然是不靠谱的。 -### 使用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 +wg.Add(3) // 等待 3 个 goroutine -func myGoroutine() { - defer wg.Done() - fmt.Println("myGoroutine!") -} +go func() { + defer wg.Done() // 完成后计数器 -1 + doTask1() +}() +go func() { + defer wg.Done() + doTask2() +}() +go func() { + defer wg.Done() + doTask3() +}() -func main() { - wg.Add(10) - for i := 0; i < 10; i++ { - go myGoroutine() - } - wg.Wait() - fmt.Println("end!!!") -} +wg.Wait() // 阻塞直到计数器归零 +fmt.Println("all done") ``` -运行结果: -``` -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()`之后计数器大于等于零** +> [!warning] ⚠️ 三个常见陷阱 +> 1. **Add 必须在 goroutine 启动前调用**——否则可能漏计数 +> 2. **Done 要用 defer**——确保即使 panic 也能正确递减 +> 3. **计数器不能为负数**——否则会 panic -## sync.Once -在我们写项目的时候,程序中有很多的逻辑只需要执行一次,最典型的就是项目工程里配置文件的加载,我们只需要加载一次即可,让配置保存在内存中,下次使用的时候直接使用内存中的配置数据即可。这里就要用到`sync.Once`。 -`sync.Once`可以在代码的任意位置初始化和调用,并且线程安全。`sync.Once`最大的作用就是延迟初始化,对于一个`sync.Once`变量我们并不会在程序启动的时候初始化,而是在第一次用的它的时候才会初始化,并且只初始化这一次,初始化之后驻留在内存里,这就非常适合我们之前提到的配置文件加载场景,设想一下,如果是在程序刚开始就加载配置,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间,而`sync.Once`就刚好解决了这个问题。 -使用示例: ```go -// 声明配置结构体Config -type Config struct{} +// ❌ 危险:Add 在 goroutine 内,main 可能先执行 Wait +go func() { + wg.Add(1) // 太晚了! + defer wg.Done() + task() +}() +wg.Wait() +// ✅ 正确:Add 在启动前 +wg.Add(1) +go func() { + defer wg.Done() + task() +}() +wg.Wait() +``` + +### sync.Once — 只执行一次 + +> [!question] 💭 思考 +> 配置文件加载、数据库连接初始化这类操作,如何保证在多线程环境下只执行一次且线程安全? + +```go var instance *Config -var once sync.Once // 声明一个sync.Once变量 +var once sync.Once -// 获取配置结构体 -func InitConfig() *Config { - once.Do(func(){ - instance = &Config{} - }) - return instance +func GetConfig() *Config { + once.Do(func() { + instance = loadConfig() // 仅执行一次 + }) + return instance } ``` -只有在第一次调用InitConfig()获取Config 指针的时候才会执行once.Do(func(){instance = &Config{} })语句,执行完之后instance就驻留在内存中,后面再次执行InitConfig()的时候,就直接返回内存中的instance。 -### sync.Once与init()的区别 -有时候我们使用init()方法进行初始化,init()方法是在其所在的package首次加载时执行的,而sync.Once可以在代码的任意位置初始化和调用,是在第一次用的它的时候才会初始化。 +| 对比项 | `init()` | `sync.Once` | +|--------|----------|-------------| +| 执行时机 | package 首次加载时 | 第一次调用 Do() 时 | +| 延迟性 | 不支持 | 支持懒加载 | +| 可控性 | 不可控 | 完全可控 | + +> [!tip] 💡 使用场景 +> - 单例模式初始化 +> - 配置加载(懒加载避免启动开销) +> - 资源注册(如驱动注册) + +### sync.Mutex / RWMutex — 互斥锁 + +#### 基础用法 + +> [!question] 💭 思考 +> 多个 goroutine 同时对 `counter++` 操作,为什么结果总是不对? + +`counter++` 不是原子操作,它等价于:读取 → 加 1 → 写回。两个 goroutine 可能同时读到相同的旧值,导致其中一次修改被覆盖。 -## sync.Lock -说到并发编程,就不得不谈一个老生常谈的问题,那就是资源竞争,也就是我们这节要讲的并发安全。因为一旦开启了多个`goroutine`去处理问题,那么这些`goroutine`就有可能在同一时间操作同一个系统资源,比如同一个变量,同一份文件等等,这里我们如果不加控制的话,可能会出现并发安全问题,在Go语言中,有两种方式来控制并发安全,锁和原子操作 -举个例子,看下面代码 ```go -package main - -import ( - "fmt" - "sync" -) - var ( - num int - wg = sync.WaitGroup{} + counter int + mu sync.Mutex ) -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() { +func increment() { mu.Lock() - defer wg.Done() - num += 1 + counter++ // 临界区 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`将一直等待加锁造成阻塞。 +> [!tip] 💡 必记习惯:defer + Unlock +> ```go +> mu.Lock() +> defer mu.Unlock() +> // 临界区代码 +> ``` +> 这样可以确保任何退出路径(包括 return / panic)都能释放锁。 + +#### 读写锁 RWMutex + +当读多写少时,RWMutex 可以显著提升并发度: -#### 读写锁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 ( + data map[string]string + rwmu sync.RWMutex ) -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(key string) (string, bool) { + rwmu.RLock() + defer rwmu.RUnlock() // 允许多个读并发 + return data[key], true } -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) +func Write(key, val string) { + rwmu.Lock() + defer rwmu.Unlock() // 写时独占 + data[key] = val } ``` -运行结果: -``` -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。可以看到读写锁是互斥的,写写锁是互斥的,读读锁可以一起加。 +| 锁类型 | 读+读 | 读+写 | 写+写 | 说明 | +|--------|-------|-------|-------|------| +| Mutex | ❌ | ❌ | ❌ | 全部互斥 | +| RWMutex | ✅ | ❌ | ❌ | 读共享,写独占 | -#### 死锁 -提到锁,就有一个绕不开的话题:死锁。死锁就是一种状态,当两个或以上的`goroutine`在执行过程中,因争夺共享资源处在互相等待的状态,如果没有外部干涉将会一直处于这种阻塞状态,我们称这时的系统发生了死锁。思索场景一般有以下两种 -1. **Lock/Unlock不成对**。这类情况最常见的场景就是对锁进行拷贝使用 +> [!warning] ⚠️ 死锁:两种经典模式 + +**模式1:锁拷贝** ```go -package main - -import ( - "fmt" - "sync" -) - func main() { var mu sync.Mutex mu.Lock() - defer mu.Unlock() - copyMutex(mu) + copyMu := mu // ❌ 复制了锁的状态 + // mu 仍持有锁,但 copyMu 是全新的未锁定锁 + copyMu.Lock() // 这里永远不会返回(如果外层没 Unlock) + mu.Unlock() } +``` -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。尽量养成如下使用习惯 +**模式2:循环等待** ```go -mu.Lock() -defer mu.Unlock() +go func() { + mu1.Lock(); defer mu1.Unlock() + time.Sleep(100 * time.Millisecond) + mu2.Lock(); defer mu2.Unlock() // 等 mu2 +}() + +go func() { + mu2.Lock(); defer mu2.Unlock() + time.Sleep(100 * time.Millisecond) + mu1.Lock(); defer mu1.Unlock() // 等 mu1 —— 死锁! +}() ``` -2. **循环等待** -另一个容易造成死锁的场景就是循环等待,A等B,B等C,C等A,循环等待 +> [!tip] 💡 避免死锁的规则 +> 1. 始终通过指针传递 mutex +> 2. 统一锁的获取顺序(全局约定 mu1 < mu2 < mu3) +> 3. 尽量减少持锁时间,不要在临界区内做 IO 或网络调用 + +### sync.Map — 并发安全的 Map + +Go 原生 `map` 不是并发安全的,并发读写会 panic: + ```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() -} +m := make(map[string]int) +go m["key"] = 1 // ❌ concurrent map writes: panic! +_ = m["key"] // ❌ concurrent map reads AND writes: panic! ``` -运行结果: -``` -fatal error: all goroutines are asleep - deadlock! -``` -死锁了,代码很简单,两个`goroutine`,一个`goroutine`先锁`mu1`,再锁`mu2`,另一个`goroutine`先锁`mu2`,再锁`mu1`,但是在它们进行第二次枷锁操作的时候,彼此等待对方释放锁,这样就造成了循环等待,一直阻塞,形成死锁。 -## sync.Map -Go语言内置的Map并不是并发安全的,在多个`goroutine`同时操作map的时候,会有并发问 -具体看下面例子 +解决方案有两种: + ```go -package main +// 方案1:map + mutex(适合频繁读写) +mu.Lock(); m["key"] = 1; mu.Unlock() -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() -} +// 方案2:sync.Map(适合以下场景) +var sm sync.Map +sm.Store("key", 1) +v, ok := sm.Load("key") ``` -运行结果: -``` -fatal error: concurrent map writes -``` -程序报错了,说明`map`不能同时被多个`goroutine`读写。要解决`map`的并发写问题一种方式使用我们前面学到的对`map`加锁,这样就可以了 + +> [!note] 📝 sync.Map 的适用场景 +> - **读远多于写**:如缓存查找、路由表 +> - **不相交的 key 集合**:每个 goroutine 只访问自己的 key +> - **不适合的场景**:频繁写入、需要遍历计数、单一 key 高频竞争 +> +> 非并发场景下,普通 map + mutex 通常性能更好。 + +### sync/atomic — 原子操作 + +> [!question] 💭 思考 +> 如果只是对一个整数做累加,用 Mutex 是不是太重了? + +原子操作由 CPU 指令直接支持,无需操作系统介入,性能远高于 mutex: + ```go -package main +var counter int64 -import ( - "fmt" - "strconv" - "sync" -) +// 原子累加 +atomic.AddInt64(&counter, 1) -var m = make(map[string]int) -var mu sync.Mutex +// 原子读取 +val := atomic.LoadInt64(&counter) -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() -} +// 原子比较并交换 +atomic.CompareAndSwapInt64(&counter, oldVal, newVal) ``` -运行结果: -``` -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`等操作方法。 -具体使用方法看示例: + +对于复合类型的原子操作,使用 `atomic.Value`: + ```go -package main +var config atomic.Value -import ( - "fmt" - "sync" -) +// 初始化 +config.Store(loadConfig()) -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) -} +// 读取(线程安全) +cfg := config.Load().(ConfigStruct) ``` -运行结果: -``` -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`上执行是一个不可分割的整体,显然要么全部执行,要么全部不执行,不会受到其他操作的影响,也就不会存在并发问题。 +> [!tip] 💡 Mutex vs Atomic 选择指南 +> - **单个变量的简单操作**(累加、交换)→ atomic +> - **一段逻辑的互斥执行** → Mutex +> - **复杂结构体的原子更新** → atomic.Value -### atomic和mutex的区别 -1. 使用方式:通常`mutex`用于保护一段执行逻辑,而`atomic`主要是对变量进行操作 -2. 底层实现:`mutex`由操作系统调度器实现,而`atomic`操作有底层硬件指令支持,保证在`cpu`上执行不中断。所以`atomic`的性能也能随`cpu`的个数增加线性提升 +### sync.Pool — 对象复用池 -#### `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 +var pool = sync.Pool{ + New: func() interface{} { + return &Buffer{data: make([]byte, 0, 1024)} + }, } -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) +func useBuffer() { + buf := pool.Get().(*Buffer) + defer pool.Put(buf) // 用完放回 + + // 使用 buf... + + // 如果有修改,放回前需 Reset + buf.Reset() } ``` -运行结果 -``` -{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`存取数据 +> [!warning] ⚠️ sync.Pool 的重要限制 +> - Pool 中的对象**可能被 GC 随时回收**,不能依赖池中对象一定存在 +> - 不适合存储有状态的对象(如 DB 连接、Socket) +> - Put 之前务必 Reset 对象状态,否则下次 Get 拿到的是脏数据 -### sync.pool使用方法 -关于`sync.Pool`的使用,一般是通过三个方法来完成的 +> [!tip] 💡 最佳实践 +> 在高并发场景中,Pool 能显著降低 GC 压力——尤其是频繁创建销毁的大对象(如 byte slice、JSON encoder)。 -| 方法 | 说明 | -| --- | --- | -| 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的,且释放时机是随机的 +## 关联笔记 +- [[hzh/GolangStar/Go语言进阶/Goroutine]] +- [[hzh/GolangStar/Go语言进阶/Channel]] +- [[hzh/GolangStar/Go语言进阶/Select]] +- [[hzh/GolangStar/Go语言进阶/协程池]] diff --git a/hzh/GolangStar/Go语言进阶/协程池.md b/hzh/GolangStar/Go语言进阶/协程池.md index 53df46f..f823b54 100644 --- a/hzh/GolangStar/Go语言进阶/协程池.md +++ b/hzh/GolangStar/Go语言进阶/协程池.md @@ -1,194 +1,197 @@ --- -tags: - - Go - - golang - - go进阶 - - 协程池 - - 并发 +tags: [go, golang, 协程池, 并发, WorkerPool] +create time: 2026-06-07 15:00 --- # 协程池 -Go语言虽然有着高效的GMP调度模型,理论上支持成千上万的`goroutine`,但是`goroutine`过多,对调度,gc以及系统内存都会造成压力,这样会使我们的服务性能不升反降。常用做法可以用池化技术,构造一个协程池,把进程中的协程控制在一定的数量,防止系统中`goroutine`过多,影响服务性能。 -## 协程池模型 -协程池简单理解就是有一个池子一样的东西,里面装这个固定数量的`goroutine`,当有一个任务到来的时候,会将这个任务交给池子里的一个空闲的`goroutine`去处理,如果池子里没有空闲的`goroutine`了,任务就会阻塞等待。所以协程池有三个角色`Worker`,`Task`,`Pool`。 +## 概述 -### 属性定义 -- `Worker`:用于执行任务的`goroutine` -- `Task`: 具体的任务 -- `Pool`: 池子 +虽然 Go 可以轻松创建数十万个 goroutine,但无限制地创建反而会导致调度开销和 GC 压力。协程池通过将活跃 goroutine 数量控制在合理范围内,实现性能与资源的平衡。 -下面看一下各个角色的定义: +## 正文 -#### Task定义 -`Task`有一个函数成员,表示这个task具体的执行逻辑: +### 为什么需要协程池? -```go -type Task struct { - f func() error // 具体的执行逻辑 -} +> [!question] 💭 思考 +> Go 说可以开 10 万个 goroutine,那是不是意味着我应该每次都 `go func()`? + +理论上可以,但实际上: +- **过多 goroutine** → GMP 调度器负担加重、CPU 上下文切换频繁 +- **过多 goroutine** → GC 扫描对象增多、STW 时间变长 +- **过多 goroutine** → 系统内存压力增大 + +协程池的核心思想:**控制并发度,而非消除并发**。 + +```mermaid +flowchart TD + A["任务队列
Job Channel"] -->|"取任务"| B["Worker 1"] + A -->|"取任务"| C["Worker 2"] + A -->|"取任务"| D["Worker N"] + + E["AddTask
提交任务"] --> A + + subgraph Pool["协程池 (固定 N 个 worker)"] + B + C + D + end + + style A fill:#fff3e0 + style E fill:#e8f5e9 + style Pool fill:#e3f2fd ``` -#### 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`指定。 +| 角色 | 说明 | +|------|------| +| Task | 封装待执行的业务逻辑(函数 + 参数) | +| Worker | 固定的 goroutine,从任务队列循环取任务执行 | +| Pool | 管理 Worker 数量和任务队列的容器 | -### 方法定义 -```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" + "fmt" + "sync" + "sync/atomic" ) +// Task 封装一个可执行的任务 type Task struct { - f func() error // 具体的任务逻辑 + f func() error } -func NewTask(funcArg func() error) *Task { - return &Task{ - f: funcArg, - } +func NewTask(f func() error) *Task { + return &Task{f: f} } +// Pool 协程池 type Pool struct { - RunningWorkers int64 // 运行着的worker数量 - Capacity int64 // 协程池worker容量 - JobCh chan *Task // 用于worker取任务 - sync.Mutex + capacity int // worker 数量 + taskQueue chan *Task // 任务缓冲队列 + wg sync.WaitGroup // 等待所有 worker 结束 + running atomic.Int64 // 当前运行数 } -func NewPool(capacity int64, taskNum int) *Pool { - return &Pool{ - Capacity: capacity, - JobCh: make(chan *Task, taskNum), - } +func NewPool(capacity int, queueSize int) *Pool { + return &Pool{ + capacity: capacity, + taskQueue: make(chan *Task, queueSize), + } } -func (p *Pool) GetCap() int64 { - return p.Capacity +// Start 启动 pool +func (p *Pool) Start() { + for i := 0; i < p.capacity; i++ { + p.wg.Add(1) + go p.worker(i) + } } -func (p *Pool) incRunning() { // runningWorkers + 1 - atomic.AddInt64(&p.RunningWorkers, 1) +func (p *Pool) worker(id int) { + defer p.wg.Done() + for task := range p.taskQueue { + if task != nil && task.f != nil { + task.f() + } + } } -func (p *Pool) decRunning() { // runningWorkers - 1 - atomic.AddInt64(&p.RunningWorkers, -1) +// Submit 提交任务 +func (p *Pool) Submit(task *Task) bool { + select { + case p.taskQueue <- task: + return true + default: + return false // 队列已满,非阻塞拒绝 + } } -func (p *Pool) GetRunningWorkers() int64 { - return atomic.LoadInt64(&p.RunningWorkers) +// Stop 停止 pool,关闭任务队列等待所有 worker 完成 +func (p *Pool) Stop() { + close(p.taskQueue) + p.wg.Wait() } +``` -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 -} +使用示例: +```go func main() { - // 创建任务池 - pool := NewPool(3, 10) + pool := NewPool(3, 10) // 3 个 worker,10 个任务缓冲 + pool.Start() - for i := 0; i < 20; i++ { - // 任务放入池中 - pool.AddTask(NewTask(func() error { - fmt.Printf("I am Task\n") - return nil - })) - } + for i := 0; i < 20; i++ { + pool.Submit(NewTask(func() error { + fmt.Printf("处理任务 %d\n", i) + return nil + })) + } - time.Sleep(1e9) // 等待执行 + pool.Stop() // 优雅关闭 } ``` -运行结果: + +### 队列满了怎么办? + +> [!question] 💭 思考 +> 当任务生产速度超过消费速度,且队列也满了,你的协程池应该如何应对? + +三种策略各有取舍: + +| 策略 | 实现方式 | 优点 | 缺点 | +|------|---------|------|------| +| **阻塞等待** | `p.taskQueue <- task` | 不丢任务 | 生产者可能被卡住 | +| **非阻塞拒绝** | `select + default` | 响应快 | 可能丢任务 | +| **动态扩缩容** | 监控队列长度增减 worker | 自适应负载 | 实现复杂 | + +> [!tip] 💡 推荐方案 +> 大多数场景下,"有界队列 + 拒绝策略"是最实用的组合。拒绝时可以选择丢弃、记录日志告警,或降级处理。 + +### 优雅关闭 + +> [!warning] ⚠️ 协程池关闭的关键点 +> 关闭时必须确保:1) 不再接收新任务;2) 已有任务全部执行完毕;3) 所有 worker goroutine 都退出。 + +```go +// 正确关闭流程 +func (p *Pool) Shutdown() { + close(p.taskQueue) // 1. 关闭队列——worker 收到 range 退出信号 + p.wg.Wait() // 2. 等待所有 worker 完成剩余任务 +} ``` -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`数量的作用。 + +> [!note] 📝 不要直接调用 runtime.Goexit() 或 panic 来终止 goroutine +> 这会导致 defer 链不完整、资源泄漏等问题。始终用 channel close + WaitGroup 的方式优雅退出。 + +### 实战建议 + +> [!tip] 💡 协程池调参指南 +> - **CPU 密集型**:worker 数量 ≈ CPU 核心数 +> - **IO 密集型**:worker 数量 = CPU 核心数 × (1 + 等待时间/计算时间),通常 10~100 倍 +> - **混合场景**:根据压测结果调整,观察 CPU 利用率和延迟 P99 + +> [!warning] ⚠️ 避免在 worker 中 panic +> 单个 worker panic 会导致整个程序崩溃。应在 worker 中使用 recover: +> ```go +> func (p *Pool) worker(id int) { +> defer func() { +> if r := recover(); r != nil { +> log.Printf("worker %d recovered from panic: %v", id, r) +> } +> p.wg.Done() +> }() +> for task := range p.taskQueue { ... } +> } +> ``` + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Goroutine]] +- [[hzh/GolangStar/Go语言进阶/Sync]] +- [[hzh/GolangStar/Go语言进阶/Channel]] diff --git a/hzh/GolangStar/Go语言进阶/反射.md b/hzh/GolangStar/Go语言进阶/反射.md index 3686649..6eff393 100644 --- a/hzh/GolangStar/Go语言进阶/反射.md +++ b/hzh/GolangStar/Go语言进阶/反射.md @@ -1,866 +1,200 @@ --- -tags: - - Go - - golang - - go进阶 - - 反射 +tags: [go, golang, 反射, reflect] +create time: 2026-06-07 15:10 --- # 反射 -## 什么是反射 -反射可以认为是程序在运行时的一种能力,反射可以在程序运行时访问、检测和修改它本身状态,比如在程序运行时可以检查变量的类型和值,调用它们的方法,甚至修改它们的值。使用反射可以增加程序的灵活性,简单来说,反射就是程序在运行时能够检测自身和修改自身的一种能力。 +## 概述 -## 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`则是绑定了很多跟值相关的方法。 +反射让程序能够在运行时检查和操作自身结构。Go 的反射机制建立在 `reflect.Type`(类型信息)和 `reflect.Value`(值信息)之上,是许多框架(JSON 序列化、ORM、RPC)的底层基石。 + +## 正文 + +### 什么是反射? + +> [!question] 💭 思考 +> 如果一个函数需要处理任意类型的输入——可能是 int、string 或自定义 struct——在不使用泛型的情况下该怎么办? + +反射提供了一种在运行时"窥探"变量内部结构的能力: -### 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()) -} +var x float64 = 3.14 +t := reflect.TypeOf(x) // 获取类型: float64 +v := reflect.ValueOf(x) // 获取值: 3.14 ``` -运行结果: -``` -int64 -main.Student -``` -可以看到对于基础类型和`struct`类型通过调用`reflect.TypeOf()`都打印出了对应的类型信息。注意`reflect.TypeOf`返回的是一个`reflect.Type`接口类型,我们通过调用这个接口的`String()`方法,得到最终的字符串信息。 -在前面学习`interface`的章节中,我们知道一个具体的数据类型是可以赋值给一个`interface`类型的,反过来则不行,要用到`interface`的断言。在一个`interface`赋值之后,其实是对应了两个类型,一个是静态类型,就是在程序编译期就确定的类型,`interface`的静态类型就是接口`interface`,同时当`interface`赋值之后,他还有一个动态类型,就是被赋值的那个数据的具体类型,假设在上例中,我们将`st`赋值给一个空`interface`,那么这个`interface`的动态类型就是`Student`。 +> [!info] ℹ️ 核心概念 +> Go 的空接口 `interface{}` 存储了两部分信息:**类型** + **值**。反射正是通过这两部分信息来操作任意类型的对象。 +> - `reflect.Type`(接口)→ 描述变量的类型信息 +> - `reflect.Value`(结构体)→ 描述变量的实际值 -对一个数据对象进行反射操作,其实是首先将具体对象类型转化为一个`interface`类型,然后再将`interface`类型转化为`reflect`包下的反射类型,反射类型里的类型信息和值信息其实就是对应着这个中间类型`interface`的类型和值。 +### Type vs Kind -![反射原理](https://golangstar.cn/assets/img/go语言系列/反射/反射1.png) +> [!question] 💭 思考 +> `type WrapInt int` 定义了一个新类型,它和 `int` 是同一种类型吗? -`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()) + var a int = 100 + var b WrapInt = 1000 + + tA := reflect.TypeOf(a) // type: int + tB := reflect.TypeOf(b) // type: main.WrapInt + + kA := tA.Kind() // kind: int + kB := tB.Kind() // kind: int ← 底层种类相同! } ``` -运行结果: -``` -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`确是一样的。 -## 反射使用 +| 概念 | 说明 | 方法 | +|------|------|------| +| Type | 完整的类型描述,包含包路径和类型名 | `reflect.TypeOf()` | +| Kind | 底层数据结构类别(int/struct/slice 等) | `t.Kind()` | -### 值对象 -`reflect`包下跟值对象相关的常用函数或方法: +> [!tip] 💡 实用建议 +> 大多数情况下用 Kind 就够了——你关心的是"这是一个 slice 还是 map",而不是它的完整类型名。Type 在需要精确匹配类型时才有用。 -| 函数/方法 | 说明 | -|----------|------| -| `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 -#### 获取struct反射值 ```go -package main - -import ( - "fmt" - "reflect" -) - type Student struct { - Name string - Age int - Score float64 + Name string `json:"name"` + Age int `json:"age"` +} + +func inspect(s interface{}) { + v := reflect.ValueOf(s) + if v.Kind() != reflect.Struct { + return + } + + fmt.Printf("字段数: %d\n", v.NumField()) + for i := 0; i < v.NumField(); i++ { + field := v.Field(i) + tag := v.Type().Field(i).Tag // 读取 struct tag + fmt.Printf("%s (%s) = %v [%s]\n", + v.Type().Field(i).Name, + field.Kind(), + field.Interface(), + tag) + } +} +``` + +#### 操作 Map / Slice + +```go +// Map +m := map[string]int{"a": 1, "b": 2} +v := reflect.ValueOf(m) +for _, key := range v.MapKeys() { + val := v.MapIndex(key) + fmt.Printf("%v -> %v\n", key, val) +} + +// Slice +s := []int{1, 2, 3} +v = reflect.ValueOf(s) +for i := 0; i < v.Len(); i++ { + fmt.Println(v.Index(i).Int()) +} +``` + +### 可寻址与可设置 + +> [!question] 💭 思考 +> 反射能修改原始变量的值吗?什么条件下可以? + +这是反射最容易出错的地方,有三个关键规则: + +| 条件 | CanAddr() | CanSet() | 说明 | +|------|-----------|----------|------| +| `ValueOf(普通值)` | false | false | 拿到的是副本 | +| `ValueOf(指针)` → `.Elem()` | true | 取决于字段是否导出 | 可寻址 | +| `ValueOf(切片)` → `.Index(i)` | true | true | 切片元素可修改 | + +```go +st := &Student{Name: "zhangsan"} +v := reflect.ValueOf(st) // v 是指针 +elem := v.Elem() // elem 是 *Student 指向的具体值 + +fmt.Println(elem.CanAddr()) // true +fmt.Println(elem.CanSet()) // true + +// ✅ 可以修改 +elem.Field(0).SetString("lisi") +fmt.Println(st.Name) // "lisi" +``` + +> [!warning] ⚠️ 未导出字段不可设置 +> ```go +> type Student struct { +> Name string // ✅ 大写,可导出,CanSet() = true +> score float64 // ❌ 小写,未导出,CanSet() = false +> } +> ``` +> Go 的反射无法突破可见性规则——即使通过指针拿到了地址,也不能修改未导出字段。 + +### 动态调用方法 + +```go +type Calculator struct{} + +func (c *Calculator) Add(a, b int) int { + return a + b } func main() { - - st := Student{ - Name: "zhangsan", - Age: 18, - Score: 95.5, + c := &Calculator{} + v := reflect.ValueOf(c) + + // 通过名称查找方法 + method := v.MethodByName("Add") + args := []reflect.Value{ + reflect.ValueOf(3), + reflect.ValueOf(4), } - 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()) + result := method.Call(args) // 返回 []reflect.Value + fmt.Println(result[0].Int()) // 7 } ``` -运行结果: -``` -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 +### 结构体标签(Struct Tag) -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`等在反射的时候修改对应类型值。 - -下面看个具体例子: +结构体标签是反射最常见的应用场景之一——JSON 序列化、ORM 映射等都依赖它: ```go -package main - -import ( - "fmt" - "reflect" -) - -type Student struct { - Name string - Age int - Score float64 +type User struct { + ID int `json:"id" db:"user_id"` + Name string `json:"name" validate:"required"` } -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) +t := reflect.TypeOf(User{}) +for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + jsonTag := f.Tag.Get("json") // 获取 "id" / "name" + dbTag := f.Tag.Get("db") // 获取 "user_id" } ``` -运行结果: -``` -st = &{Name:lisi Age:18 Score:90.5} -``` +> [!tip] 💡 Tag.Get 的安全用法 +> `f.Tag.Get("xxx")` 在标签不存在时返回空字符串而非 panic,因此可以直接使用,无需先检查。 -可以看到,通过调用第一个字段`reflect.value`的`SetString`方法,将`st`对象的`Name`改为了`lisi`。 +### 性能警告 -### 结构体标签 -我们在定义结构体的时候,可以为每个字段后面加一个标签,即`StructTag`,标签其实就是一组键值对,每个键值对用空格分开,这些标签信息可以通过反射获取: -```go -package main +> [!warning] ⚠️ 反射的性能代价 +> 反射涉及大量的类型检查和间接访问,比直接操作慢 10~100 倍。以下场景应谨慎使用: +> - 热点路径中的高频调用 +> - 实时性要求高的系统 +> +> 如果能在编译期确定类型,优先使用泛型替代反射。 -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过程,导致程序的性能降低 +- [[hzh/GolangStar/Go语言进阶/范型]] — 泛型通常比反射更高效的类型抽象方案 +- [[hzh/GolangStar/Go语言原理/interface原理]] — 反射与接口的底层关系 diff --git a/hzh/GolangStar/Go语言进阶/定时器.md b/hzh/GolangStar/Go语言进阶/定时器.md index 55bc775..f24f12c 100644 --- a/hzh/GolangStar/Go语言进阶/定时器.md +++ b/hzh/GolangStar/Go语言进阶/定时器.md @@ -1,253 +1,153 @@ --- -tags: - - Go - - golang - - go进阶 - - 定时器 - - 并发 +tags: [go, golang, 定时器, Timer, Ticker, 并发] +create time: 2026-06-07 15:05 --- # 定时器 -在我们项目中,常常会有这样的场景,比如到了未来某一时刻,需要某个逻辑或者某个任务执行一次,或者是周期性的的执行多次,有点类似定时任务。这种场景就需要用到定时器,Go语言中也内置了定时器的实现,`timer`和`ticker`。 -## Timer -`Timer`是一种一次性时间定时器,即在未来某个时刻,触发的事件只会执行一次。 +## 概述 -### Timer的结构定义 -```go -type Timer struct { - C <-chan Time - r runtimeTimer -} -``` -`Timer`结构里有一个`Time`类型的管道`C`,主要用于事件通知。在未到达设定时间的时候,管道内没有数据写入,一直处于阻塞状态,到达设定时间后,会向管道内写入一个系统时间,触发事件。 +Go 标准库提供了 `time.Timer`(一次性定时器)和 `time.Ticker`(周期性定时器),它们是构建超时控制、心跳检测、定时任务等并发模式的基础工具。 -### 创建Timer -```go -func NewTimer(d Duration) *Timer -``` -使用示例: -```go -package main +## 正文 -import ( - "fmt" - "time" -) +### Timer — 一次性定时器 -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 +> [!question] 💭 思考 +> 如何在一个 goroutine 中等待指定时间后执行某个操作?用 `time.Sleep`?但如果需要提前取消呢? ```go -func (t *Timer) Reset(d Duration) bool +timer := time.NewTimer(2 * time.Second) // 2秒后触发 +<-timer.C // 阻塞直到触发 +fmt.Println("timeout!") ``` -对于已经过期或者是已经停止的`timer`,可以通过重置方法激活使其继续生效。 -使用示例: -```go -package main +`Timer` 的核心是 `C <-chan Time`——一个在触发时刻写入当前时间的 channel。 -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 +timer := time.NewTimer(5 * time.Second) -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 +// 尝试在触发前停止 +if !timer.Stop() { + <-timer.C // 如果已经触发,需要从 C 中读出值 } -func main() { - ch := Watch() - time.Sleep(5 * time.Second) - ch <- struct{}{} - close(ch) +// 重置定时器 +timer.Reset(3 * time.Second) // 3秒后重新触发 +``` + +> [!warning] ⚠️ Stop/Reset 的返回值陷阱 +> - `Stop()` 返回 `true`:定时器还未触发,成功停止 +> - `Stop()` 返回 `false`:定时器已触发,**C 通道中可能已有值** +> +> 当 `Stop()` 返回 false 时,如果后续代码仍需读取 timer.C,必须先消费掉那个值,否则会导致泄漏。 + +> [!tip] 💡 安全使用模式 +> ```go +> timer := time.NewTimer(d) +> select { +> case <-timer.C: +> // 超时处理 +> case <-someChannel: +> timer.Stop() +> if !timer.Stop() { +> <-timer.C // 清理残留值 +> } +> } +> ``` + +#### AfterFunc — 延迟执行回调 + +```go +// 1秒后执行 f,返回 Timer 以便取消 +timer := time.AfterFunc(1*time.Second, func() { + fmt.Println("after 1 second") +}) + +// 如果需要取消 +timer.Stop() +``` + +#### After — 便捷超时 + +```go +select { +case result := <-doWork(): + fmt.Println("完成:", result) +case <-time.After(5 * time.Second): + fmt.Println("超时!") } ``` -运行结果: -``` -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 +> [!note] 📝 After 的实现 +> `time.After(d)` 等价于 `time.NewTimer(d).C`。它创建了一个内部 Timer,但无法提前释放——所以长生命周期场景推荐手动管理 Timer。 + +### Ticker — 周期性定时器 + +Ticker 每隔固定间隔向通道发送信号,适用于轮询、心跳等场景: + +```go +ticker := time.NewTicker(1 * time.Second) + +// 务必在退出时停止 +defer ticker.Stop() + +for tick := range ticker.C { + fmt.Println("tick at", tick) + // 如果只需要执行一次 + break +} +``` + +> [!warning] ⚠️ Ticker 的资源泄漏 +> - `ticker.Stop()` **只停止发送新信号,不会关闭 ticker.C 通道** +> - 必须通过其他方式(如 context.Done / break)退出循环,否则 goroutine 泄漏 + +```go +func periodicWork(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() // ✅ 确保释放资源 + + for { + select { + case <-ticker.C: + doWork() + case <-ctx.Done(): + return // ✅ 正常退出 + } + } +} +``` + +### 常见模式对比 + +| 需求 | 推荐方案 | 说明 | +|------|---------|------| +| 等待 N 秒后执行一次 | `time.After` | 简洁,适合短生命周期 | +| 可取消的延迟执行 | `time.AfterFunc` | 返回 Timer 可调用 Stop | +| 周期性执行 + 可控退出 | `Ticker + context` | 灵活,资源可控 | +| 精确单次触发 | `time.NewTimer` | 可 Stop/Reset,资源可控 | + +### 精度问题 + +> [!info] ℹ️ 补充知识 +> - Timer/Ticker 的精度依赖于操作系统调度,不是严格精确的 +> - 在高精度场景中,应设置缓冲避免错过 tick:`time.NewTicker(d)` 如果消费跟不上会产生积压 +> - Ticker 消费不及时时,多余的 tick 会被丢弃(通道满时阻塞发送方) + +```go +// ❌ 危险:无缓冲,消费慢时会阻塞 +ticker := time.NewTicker(1 * time.Millisecond) + +// ✅ 推荐:有缓冲,允许短暂积压 +ticker := time.NewTicker(1 * time.Millisecond) +// 配合 context 控制生命周期 +``` + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Select]] +- [[hzh/GolangStar/Go语言进阶/Context]] +- [[hzh/GolangStar/Go语言进阶/Goroutine]] diff --git a/hzh/GolangStar/Go语言进阶/并发概述.md b/hzh/GolangStar/Go语言进阶/并发概述.md index fd9d213..dfa8aff 100644 --- a/hzh/GolangStar/Go语言进阶/并发概述.md +++ b/hzh/GolangStar/Go语言进阶/并发概述.md @@ -1,33 +1,91 @@ --- -tags: - - Go - - golang - - go基础语法 - - 并发概述 +tags: [go, golang, 并发概述, CSP] +create time: 2026-06-07 14:30 --- # 并发概述 -## 进程与线程 -谈到并发或者并行一个绕不开的话题就是进程和线程,弄清楚进程与线程的本质是并发编程的前提,那么究竟什么是进程,什么是线程呢? -可以这样理解: -- 进程就是运行着的程序,它是程序在操作系统的一次执行过程,是一个程序的动态概念,进程是操作系统分配资源的基本单位 -- 线程可以理解为一个进程的执行实体,它是比进程粒度更小的执行单元,也是真正运行在cpu上的执行单元,线程是CPU调度资源的基本单位 -进程中可以包含多个线程,需要记住进程和线程一个是操作系统分配资源的基本单位(进程),一个是操作系统调度资源的基本单位(线程) +## 概述 -## 协程 -协程可以理解为用户态线程,是更微量级的线程。区别于线程,协程的调度在用户态进行,不需要切换到内核态,所以不由操作系统参与,由用户自己控制。在一些支持协程高级语言中,往往这些语言都实现了自己的协程调度器,比如go语言就有自己的协程调度器,这个会在后面专门讲协程调度原理的时候讲。 -- 协程有独立的栈空间,但是共享堆空间。 -- 一个进程上可以跑多个线程,一个线程上可以跑多个协程 +本文梳理进程、线程、协程的核心概念,区分"并发"与"并行"的本质差异,为后续深入 Go 的并发编程(Goroutine / Channel / Sync)奠定理论基础。 -## 并发与并行 -很多时候大家对于并行和并发的概念还比较模糊,其实只需要根据一点来判断即可,能不能同时运行。两个任务能同时运行就是并行,不能同时运行,而是每个任务执行一小段,交叉执行,这种模式就是并发。 +## 正文 -![并行](https://golangstar.cn/assets/img/go语言系列/并发概述/并发概述1.png) +### 进程与线程 -![并发](https://golangstar.cn/assets/img/go语言系列/并发概述/并发概述2.png) +> [!question] 💭 思考 +> 一个正在运行的程序,操作系统需要分配哪些资源来支撑它?如果多个程序同时运行,它们之间如何互不干扰? -如图(并行)所示,两个任务一直运行,切实同时运行着,这就是并行模式,要注意并行的话一定要有多个核的支持,因为只有一个cpu的话,同一时间只能跑一个任务,如图(并发)所示,两个任务,每次只执行一小段,这样交叉的执行,就是并发模式,并发模式在单核cpu上是可以完成的 +**进程**是操作系统分配资源的基本单位。它是程序在操作系统上的一次执行过程——注意,这里是"执行过程"而非"程序本身",强调的是动态性。 +**线程**是 CPU 调度的基本单位,是进程中实际执行任务的实体。 +| 维度 | 进程 | 线程 | +|------|------|------| +| 角色 | 资源分配单位 | CPU 调度单位 | +| 开销 | 大(独立地址空间) | 小(共享父进程内存) | +| 隔离性 | 强(进程间需 IPC) | 弱(共享同一进程内存) | +> [!warning] ⚠️ 常见误区 +> "进程 = 重型,线程 = 轻型"——这并不准确。关键区别在于:**进程有独立地址空间,线程共享地址空间**。这也意味着线程间数据共享方便,但更容易出现竞态条件。 + +```mermaid +graph LR + P["进程 Process"] --> T1["线程 Thread 1"] + P --> T2["线程 Thread 2"] + P --> T3["线程 Thread 3"] + style P fill:#e1f5fe +``` + +一个进程可以包含多个线程,它们共享堆空间和文件描述符,但各自拥有独立的栈空间和寄存器状态。 + +### 协程 + +> [!question] 💭 思考 +> 如果线程的创建和切换需要操作系统参与(陷入内核态),有没有一种更轻量的方式来实现并发? + +**协程**是用户态线程,由应用程序自己管理调度,不需要操作系统参与。 + +| 对比项 | 线程 | 协程 | +|--------|------|------| +| 调度位置 | 内核态(OS 调度) | 用户态(语言运行时调度) | +| 切换开销 | 高(涉及上下文切换) | 低(仅保存/恢复栈指针) | +| 典型实现 | OS pthread / CreateThread | Go goroutine / Python asyncio | + +Go 语言的 Goroutine 就是协程的典型实现——它由 Go 运行时(runtime)在自己的 GMP 调度器下管理,而不是直接映射到 OS 线程。 + +> [!tip] 💡 技巧 +> 一个进程可以有多个线程,一个线程可以有多个协程。这意味着协程比线程更轻量:你可以轻松创建数十万个 goroutine,但开数十万个线程几乎必然耗尽系统资源。 + +### 并发与并行 + +> [!question] 💭 思考 +> 单核 CPU 上能实现"同时执行"吗?如果能,那"同时"是什么意思? + +这两个概念经常被混用,区分的关键只有一点:**能不能真正同时执行**。 + +- **并行(Parallelism)**:多个任务在同一时刻真正同时在多个核心上执行。必须有多个 CPU 核心支持。 +- **并发(Concurrency)**:多个任务交替执行,每个任务执行一小段时间片后让出 CPU,宏观上看起来像"同时"在进行。单核 CPU 也能做到。 + +```mermaid +timeline + title 并行 vs 并发 + section 并行 (多核) + Task A : 执行 : 执行 : 执行 + Task B : 执行 : 执行 : 执行 + section 并发 (单核) + Task A : 执行1 : : 执行3 + Task B : : 执行2 : +``` + +如图所示: +- **并行**模式下,Task A 和 Task B 在每个时间段都在同时运行——这需要多核 CPU 的支持。 +- **并发**模式下,两个任务轮流使用 CPU,每次只执行一小段,交叉进行——这在单核 CPU 上即可完成。 + +> [!note] 📝 关系总结 +> 并行一定是并发(因为并行也满足"交替执行"的广义定义),但并发不一定是并行。Go 的并发模型可以在单核上运行,也可以利用多核实现真正的并行——这取决于 `GOMAXPROCS` 的设置。 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Goroutine]] +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] diff --git a/hzh/GolangStar/Go语言进阶/范型.md b/hzh/GolangStar/Go语言进阶/范型.md index 2200ed5..17cbabe 100644 --- a/hzh/GolangStar/Go语言进阶/范型.md +++ b/hzh/GolangStar/Go语言进阶/范型.md @@ -1,323 +1,176 @@ --- -tags: - - Go - - golang - - go进阶 - - 泛型 +tags: [go, golang, 泛型, Generics] +create time: 2026-06-07 15:15 --- # 泛型 -在Go 1.18版本中,泛型特性被引入,这是Go语言自发布以来最重要的更新之一。 -## 什么是泛型 -泛型允许开发者在编写代码时不必立即指定具体的数据类型,而是在使用时再确定。换句话说,泛型使得我们可以编写适用于多种数据类型的通用代码。 -泛型是一种编写与具体类型无关的代码的方法,它使得我们可以创建适用于多种类型的函数和数据结构。 +## 概述 + +Go 1.18 引入的泛型是语言自发布以来最重要的更新。它允许编写与具体类型无关的通用代码,同时保留编译期类型检查的安全性。本文从动机、语法到约束机制全面讲解 Go 泛型。 + +## 正文 + +### 为什么需要泛型? + +> [!question] 💭 思考 +> 如果需要一个函数能同时处理 `[]int`、`[]float64`、`[]string`,在 Go 1.17 之前你会怎么做? + +没有泛型时,只能为每种类型写一个重复的函数: -## 为什么需要泛型 -假设我们需要实现一个函数来计算切片中元素的总和: ```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 +// ❌ 没有泛型:每种类型都需要一个函数 +func sumInts([]int) int { ... } +func sumFloats([]float64) float64 { ... } +func sumStrings([]string) string { ... } // 逻辑完全相同! ``` -然而,这个函数只能处理`[]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 +// ❌ 反射方案:运行时开销大 + 失去编译期类型检查 +func sum(v interface{}) interface{} { + rv := reflect.ValueOf(v) + // ... 各种 Kind 判断和类型断言 } ``` -## 泛型语法 -泛型为Go语言引入了三个重要的新特性: +> [!info] ℹ️ 泛型的优势 +> - **消除重复代码**:一套逻辑适配多种类型 +> - **编译期检查**:类型错误在编译时发现,而非运行时 panic +> - **零运行时开销**:泛型代码会被实例化为具体类型的机器码 -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 { +// [T int | float64] 声明类型参数 T,约束为 int 或 float64 +func Max[T int | float64](a, b T) T { if a > b { return a } return b } + +// 调用(可省略类型参数,编译器自动推断) +m1 := Max(3, 5) // T = int +m2 := Max(2.5, 3.5) // T = float64 +m3 := Max[int](3, 5) // 显式指定(通常不需要) ``` -#### 类型实例化 +方括号 `[...]` 用于类型参数列表,圆括号 `(...)` 用于值参数列表——这是最容易混淆的地方。 -这次定义的`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] +```mermaid +flowchart LR + A["func Max[T int|float64]
(a, b T)
T"] --> B["[T int|float64]
类型参数列表"] + A --> C["(a, b T)
值参数列表"] + A --> D["T
返回类型"] + + style B fill:#e3f2fd + style C fill:#fff3e0 + style D fill:#e8f5e9 ``` #### 类型约束 -普通函数中的每个参数都有一个类型;该类型定义一系列值的集合。例如,我们上面定义的非泛型函数`maxFloat64`那样,声明了参数的类型为`float64`,那么在函数调用时允许传入的实际参数就必须是可以用`float64`类型表示的浮点数值。 - -类似于参数列表中每个参数都有对应的参数类型,类型参数列表中每个类型参数都有一个**类型约束**。类型约束定义了一个类型集——只有在这个类型集中的类型才能用作类型实参。 - -Go语言中的类型约束是接口类型。 - -就以上面提到的`max`函数为例,我们来看一下类型约束常见的两种方式。 - -类型约束接口可以直接在类型参数列表中使用。 +约束定义了一个"允许的类型集合": ```go -// 类型约束字面量,通常外层interface{}可省略 -func max[T interface{ int | float64 }](a, b T) T { - if a > b { - return a - } - return b -} -``` +// 方式1:内联约束 +func Process[T int | string | bool](v T) {} -作为类型约束使用的接口类型可以事先定义并支持复用。 - -```go -// 事先定义好的类型约束类型 +// 方式2:预定义约束接口(推荐,可复用) type Number interface { - int | float64 + ~int | ~float64 | ~int64 } -func max[T Number](a, b T) T { - if a > b { - return a + +func SumSlice[S ~[]E, E Number](s S) E { + var total E + for _, v := range s { + total += v } - return b + return total } ``` -在使用类型约束时,如果省略了外层的`interface{}`会引起歧义,那么就不能省略。例如: +> [!tip] 💡 理解约束中的 ~ 符号 +> - `int`:只匹配 `int` 本身 +> - `~int`:匹配所有底层类型为 `int` 的类型(包括 `type MyInt int`) +> - `~[]E`:匹配所有底层类型为切片且元素类型为 E 的类型 + +#### any 别名 ```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 +// any 就是 interface{} 的别名 type any = interface{} + +// 以下两种写法等价 +func First[T any](s []T) T { return s[0] } +func First[T interface{}](s []T) T { return s[0] } ``` -由此,我们可以使用如下代码: +### 泛型类型 + +泛型不仅适用于函数,也适用于结构体: ```go -func process[S ~[]E, E any](data S) { - // 处理数据 +type Stack[T any] struct { + items []T } + +func (s *Stack[T]) Push(v T) { + s.items = append(s.items, v) +} + +func (s *Stack[T]) Pop() (T, bool) { + if len(s.items) == 0 { + var zero T + return zero, false + } + item := s.items[len(s.items)-1] + s.items = s.items[:len(s)-1] + return item, true +} + +// 使用 +intStack := Stack[int]{} +intStack.Push(42) + +strStack := Stack[string]{} +strStack.Push("hello") ``` +> [!warning] ⚠️ 常见陷阱 +> 1. **方法接收者必须是泛型类型本身**——不能给非泛型类型添加泛型方法 +> 2. **类型参数名只需在函数/类型内部一致**——`func Max[T comparable](a, b T)` 中参数名 T 可以换成任意字母 +> 3. **单个类型参数的约束只有一个成员时,末尾逗号可选但建议加上**以消除歧义 + ### 类型推断 -类型推断可以让编译器自动推导出类型参数的具体类型,从而简化泛型函数的调用语法。虽然类型推断的实现机制比较复杂,但它极大地提升了泛型代码的使用体验,让开发者可以更自然地编写和调用泛型函数。 +Go 支持两种类型推断: -#### 函数参数类型推断 +#### 函数参数推断 -在使用泛型函数时,如果每次都需要显式指定类型参数会比较麻烦。以我们以之前的 `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) // 显式指定类型实参 +func Swap[T any](a, b T) (T, T) { return b, a } + +var x, y int = 1, 2 +x, y = Swap(x, y) // ✅ 编译器推断 T = int,无需 Swap[int](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`函数的定义,让它能够保持输入切片的具体类型。我们需要为切片本身引入一个新的类型参数。 +> [!note] 📝 推断的限制 +> 当类型参数仅出现在返回值中时,无法推断: +> ```go +> func NewItem[T any]() T { /* ... */ } +> // NewItem() ❌ 无法推断 T +> // NewItem[int]() ✅ 必须显式指定 +> ``` + +#### 约束推断 ```go +// S 的约束是 ~[]E,当知道 S = Vector ([]int32),可推断 E = int32 func MultiplyEach[S ~[]E, E constraints.Integer](s S, factor E) S { result := make(S, len(s)) for i, v := range s { @@ -325,21 +178,40 @@ func MultiplyEach[S ~[]E, E constraints.Integer](s S, factor E) S { } return result } + +type Vector []int32 +v := Vector{1, 2, 3} +result := MultiplyEach(v, 3) // S=Vector, E=int32,全部自动推断 ``` -我们添加了一个新的类型参数`S`,它代表切片的具体类型。通过约束`~[]E`,我们指定了`S`必须是一个元素类型为`E`的切片类型。函数返回值类型也改为`S`,这样就能保持输入切片的原始类型。在函数实现中,唯一的变化是使用`S`而不是`[]E`来创建结果切片。 +### 何时使用泛型 vs 接口? -这样修改后的`MultiplyEach`函数既可以处理普通的整数切片,也可以处理`Vector`这样的自定义切片类型。 +> [!question] 💭 思考 +> 面对"既要灵活又要类型安全"的需求,该选泛型还是接口? -这里有一个有趣的问题:为什么我们可以直接调用`MultiplyEach(v, 3)`而不需要显式指定类型参数,即不需要写成`MultiplyEach[Vector, int32](v, 3)`? +| 场景 | 推荐方案 | 原因 | +|------|---------|------| +| 定义行为契约(如 Reader/Writer) | **接口** | 关注能力而非类型 | +| 通用算法(排序、查找、集合操作) | **泛型** | 操作的是数据本身 | +| 插件/扩展点架构 | **接口** | 使用者实现接口即可 | +| 容器类型(List、Map、Tree) | **泛型** | 需要持有任意类型的元素 | -`MultiplyEach`函数定义了两个类型参数:`S`和`E`。当我们调用`MultiplyEach(v, 3)`时,编译器通过函数参数类型推断可以确定`S`的类型是`Vector`。但对于类型参数`E`,由于`3`是一个无类型常量,仅通过参数类型推断无法确定其具体类型(它可能被推断为默认的`int`类型,这与`Vector`的底层类型`[]int32`不匹配)。这时,编译器会使用一种叫做**约束类型推断**的机制。 +> [!tip] 💡 组合使用效果最佳 +> ```go +> // 接口定义行为 +> type Sortable interface { +> Len() int +> Less(i, j int) bool +> Swap(i, j int) +> } +> +> // 泛型实现通用排序算法 +> func Sort[S ~[]E, E any](s S, less func(E, E) bool) { +> // 通用排序逻辑 +> } +> ``` -约束类型推断是指编译器根据类型参数的约束关系来推导类型参数。当一个类型参数的约束是基于另一个类型参数定义的,并且其中一个类型参数已知时,就可以通过约束关系推断出另一个类型参数。 +## 关联笔记 -在我们的例子中,`S`的约束是`~[]E`,这表明`S`必须是一个元素类型为`E`的切片类型。当编译器知道`S`是`Vector`(即`[]int32`)时,就可以通过这个约束关系推断出`E`必须是`int32`。这就是为什么我们可以省略显式的类型参数。 - -## 小结 -泛型的引入为Go语言带来了更强大的抽象能力。当我们在项目中遇到需要为不同类型编写相似逻辑的场景时,泛型可以帮助我们优雅地解决这个问题。它不仅可以减少代码重复,还能保持类型安全。 - -泛型和接口各有其适用场景。接口更适合定义对象的行为规范,而泛型则更适合处理与具体类型无关的通用算法和数据结构。两者结合使用,可以让我们的代码更加简洁、安全和可维护。 \ No newline at end of file +- [[hzh/GolangStar/Go语言进阶/反射]] — 泛型可以在编译期解决的问题,不要用反射在运行时解决 +- [[hzh/GolangStar/Go语言基础/Go语言接口]] — 接口的回顾 diff --git a/hzh/GolangStar/Go面试题库/Channel面试题.md b/hzh/GolangStar/Go面试题库/Channel面试题.md index 27c6e47..1bba838 100644 --- a/hzh/GolangStar/Go面试题库/Channel面试题.md +++ b/hzh/GolangStar/Go面试题库/Channel面试题.md @@ -1,243 +1,211 @@ --- -tags: - - Go - - golang - - channel - - 面试题 - - channel面试题 +tags: [go, golang, interview, channel-questions] +create time: 2026-06-07 14:30 --- -# Channel面试题 +# Channel 面试题 📡 -## 1. 什么是CSP? +## 概述 -CSP(Communicating Sequential Processes,通信顺序进程)并发编程模型,它的核心思想是:通过通信共享内存,而不是通过共享内存来通信。Go 语言的Goroutine 和 Channel机制,就是 CSP 的经典实现,具有以下特点: +本文件涵盖 Go Channel 的 11 道高频面试题,涉及 CSP 模型、底层原理、收发流程、select 机制等核心考点。Channel 是 Go 并发编程的灵魂,理解 Channel 是掌握 Go 并发的关键。 -1. 避免共享内存:协程(Goroutine)不直接修改变量,而是通过 Channel 通信 +## 关联笔记 -2. 天然同步:Channel 的发送/接收自带同步机制,无需手动加锁 +- [[hzh/GolangStar/Go语言进阶/Channel]] — Channel 详细讲解 +- [[hzh/GolangStar/Go语言进阶/Select]] — select 多路复用详解 +- [[hzh/GolangStar/Go语言进阶/Context]] — Context + Channel 结合使用 +- [[hzh/GolangStar/Go语言原理/channel原理]] — hchan 源码级分析 +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — goroutine/channel 泄漏 -3. 易于组合:Channel 可以嵌套使用,构建复杂并发模式(如管道、超时控制) +## 正文 -## 2. Channel的底层实现原理是怎样的? +### Q1:什么是 CSP 模型? 🟢简单 -Channel的底层是一个名为`hchan`的结构体,核心包含几个关键组件: +> [!question] ❓ 思考一下 +> "不要通过共享内存来通信,而要通过通信来共享内存"——这句话怎么理解? -**环形缓冲区:**有缓冲channel内部维护一个固定大小的环形队列,用`buf`指针指向缓冲区,`sendx`和`recvx`分别记录发送和接收的位置索引。这样设计能高效利用内存,避免数据搬移。 +## 参考答案 -**两个等待队列`sendq和recvq`:**用来管理阻塞的goroutine。`sendq`存储因channel满而阻塞的发送者,`recvq`存储因channel空而阻塞的接收者。这些队列用双向链表实现,当条件满足时会唤醒对应的goroutine。 +CSP(Communicating Sequential Processes)并发编程模型的核心思想:**通过通信共享内存,而不是通过共享内存来通信。** -**互斥锁:**`hchan`内部有个mutex,所有的发送、接收操作都需要先获取锁,用来保证并发安全。虽然看起来可能影响性能,但Go的调度器做了优化,大多数情况下锁竞争并不激烈。 +| 特点 | 说明 | +|------|------| +| 避免共享内存 | Goroutine 不直接修改变量,而是通过 Channel 传递数据 | +| 天然同步 | Channel 的 send/recv 自带同步,无需手动加锁 | +| 易于组合 | Channel 可以嵌套构建复杂模式(管道、超时控制) | -分析: +> [!tip] 💡 面试技巧 +> 对比 Java 的线程通信方式(wait/notify + synchronized),强调 Go 用 Channel 统一了"数据传递"和"同步"两个问题,设计更优雅。 -hchan定义如下: +--- + +### Q2:Channel 的底层实现原理? 🟡中等 + +## 参考答案 + +Channel 的底层是 `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 + qcount uint // 队列中元素数量 + dataqsiz uint // 环形缓冲区大小 + buf unsafe.Pointer // 指向缓冲区的指针(仅 buffered channel) + elemsize uint16 // 元素大小 + closed uint32 // 是否关闭 + elemtype *_type // 元素类型 + sendx uint // 发送索引 + recvx uint // 接收索引 + recvq waitq // 等待接收的 goroutine 队列 + sendq waitq // 等待发送的 goroutine 队列 + lock mutex // 保护所有字段 } ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image.png) +> [!note] 📝 核心考点 +> 三个关键点:**环形缓冲区**(高效利用内存)、**两个等待队列**(sendq/recvq,双向链表)、**互斥锁**(保证并发安全)。 -## 3. 向channel发送数据的过程是怎样的? +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/channel原理]] — send/recv 全流程图解 -向channel发送数据的整个过程都会在mutex保护下进行,保证并发安全。会经历几个关键步骤: +--- -1. **首先是检查是否有等待的接收者**。如果`recvq`队列不为空,说明有goroutine在等待接收数据,这时会直接把数据传递给等待的接收者,跳过缓冲区,这是最高效的路径。同时会唤醒对应的goroutine继续执行。 +### Q3-Q4:Channel 的发送和读取过程? 🟡中等 -2. **如果没有等待接收者,就尝试写入缓冲区**。检查缓冲区是否还有空间,如果`qcount < dataqsiz`,就把数据复制到`buf[sendx]`位置,然后更新`sendx`索引和`qcount`计数。这是无缓冲或缓冲区未满时的正常流径。 +## 参考答案 -3. **当缓冲区满了就需要阻塞等待**。创建一个`sudog`结构体包装当前goroutine和要发送的数据,加入到`sendq`等待队列中,然后调用`gopark`让当前goroutine进入阻塞状态,让出CPU给其他goroutine。 +#### 发送数据流程(按优先级) -**被唤醒后继续执行**。当有接收者从channel读取数据后,会从`sendq`中唤醒一个等待的发送者,被唤醒的goroutine会完成数据发送并继续执行。 +```mermaid +graph TD + A[发送数据] --> B{recvq 有等待者?} + B -->|是| C[直接传给等待者, 唤醒 receiver] + B -->|否| D{缓冲区有空位?} + D -->|是| E[写入 buf[sendx], 更新索引] + D -->|否| F[创建 sudog 加入 sendq, gopark 阻塞] + C --> G[继续执行] + E --> G + F --> H[被唤醒后继续] +``` -**还有个特殊情况是向已关闭的channel发送数据会直接panic**。这是Go语言的设计原则,防止向已关闭的通道写入数据。 +1. **最优路径**:recvq 有等待者 → 直接拷贝数据给 receiver,跳过缓冲区 +2. **正常路径**:缓冲区未满 → 写入 buf[sendx] +3. **阻塞路径**:缓冲区已满 → 加入 sendq,gopark 阻塞等待 -**分析:** +#### 读取数据流程(按优先级) + +1. **最优路径**:sendq 有等待者 → 从发送者直接接收数据 +2. **正常路径**:缓冲区有数据 → 从 buf[recvx] 取出 +3. **阻塞路径**:缓冲区为空 → 加入 recvq,gopark 阻塞 + +> [!warning] ⚠️ 高频陷阱 +> 向已关闭的 Channel 发送数据会 panic;从已关闭的 Channel 读取会返回零值和 false。 + +--- + +### Q5:从已关闭的 Channel 还能读出数据吗? 🟢简单 + +## 参考答案 + +**能!** 只要缓冲区还有数据,就能继续读到有效值。只有当 ok == false 时,读出的数据才是无效的。 ```go -package main +ch := make(chan int, 5) +ch <- 18 +close(ch) -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{}) -} +x, ok := <-ch // x=18, ok=true (还能读到) +x, ok = <-ch // x=0, ok=false (通道空了) ``` -在第 17 行,主协程向 ch 发送了一个元素 3,来看下接下来会发生什么。 +> [!tip] 💡 面试技巧 +> "range ch" 会自动在 ok==false 时退出循环,这是遍历 Channel 的标准写法。 -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) +### Q6:Channel 什么情况下引起内存泄漏? 🟡中等 -当调度器光顾 G1 时,将 G1 变成 running 状态,执行 goroutineA 接下来的代码。G 表示其他可能有的 goroutine。 +## 参考答案 -这里其实涉及到一个协程写另一个协程栈的操作。有两个 receiver 在 channel 的一边虎视眈眈地等着,这时 channel 另一边来了一个 sender 准备向 channel 发送数据,为了高效,用不着通过 channel 的 buf “中转”一次,直接从源地址把数据 copy 到目的地址就可以了,效率高啊! +最常见的泄漏场景:**goroutine 永久阻塞**。 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image-3.png) +| 泄漏原因 | 示例 | +|---------|------| +| 发送者退出,接收者永远等待 | producer 结束但没关 channel | +| select 无 default 分支 | 所有 case 都无法执行,goroutine 永远阻塞 | +| 未关闭的 channel | consumer 持有引用无法 GC | -上图是一个示意图,`3` 会被拷贝到 G1 栈上的某个位置,也就是 val 的地址处,保存在 elem 字段。 +> [!warning] ⚠️ 高频陷阱 +> goroutine 泄漏会导致它所引用的所有变量都无法被 GC 回收,间接造成内存泄漏。 -## 4. 从Channel读取数据的过程是怎样的? +> [!tip] 💡 面试技巧 +> 回答时给出解决方案:"使用 context.WithTimeout 设置超时"、"确保生产者结束后关闭 channel"、"select 加 default 分支避免死锁"。 -从channel读取数据也有几个关键步骤: +--- -1. **首先检查是否有等待的发送者**。如果`sendq`队列不为空,说明有goroutine在等待发送数据。对于无缓冲channel,会直接从发送者那里接收数据;对于有缓冲channel,会先从缓冲区取数据,然后把等待发送者的数据放入缓冲区,这样保持FIFO顺序。 +### Q7-Q8:关闭 Channel 的异常场景 🟢简单 -2. **如果没有等待发送者,尝试从缓冲区读取**。检查`qcount > 0`,如果缓冲区有数据,就从`buf[recvx]`位置取出数据,然后更新`recvx`索引和`qcount`计数。这是缓冲区有数据时的正常路径。 +## 参考答案 -**缓冲区为空时需要阻塞等待**。创建`sudog`结构体包装当前goroutine,加入到`recvq`等待队列,调用`gopark`进入阻塞状态。当有发送者写入数据时会被唤醒继续执行。 +以下操作都会导致 panic: -**从已关闭channel读取有特殊处理**。如果channel已关闭且缓冲区为空,会返回零值和false标志;如果缓冲区还有数据,可以正常读取直到清空。这就是为什么`v, ok := <-ch`中的ok能判断channel状态的原因。 +| 操作 | Panic 信息 | +|------|-----------| +| 重复关闭 | `panic: close of closed channel` | +| 关闭 nil Channel | `panic: close of nil channel` | +| 关闭只收 Channel | 编译错误 | +| 往已关闭 Channel 写数据 | `panic: send on closed channel` | -## 5. 从一个已关闭Channel仍能读出数据吗? +--- -从一个有缓冲的 channel 里读数据,当 channel 被关闭,依然能读出有效值。只有当返回的 ok 为 false 时,读出的数据才是无效的。 +### Q9-Q11:select 的执行机制 🟡中等 -**示例:** +> [!question] ❓ 思考一下 +> 如果多个 case 同时满足条件,select 选哪一个?为什么这样设计? -```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.") - } -} +**随机选择。** 如果多个 case 同时就绪,Go 会随机选择一个执行。 -``` - -程序输出: - -```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会阻塞等待。 +**设计目的**:避免饥饿问题——防止某个 channel 总是被忽略。 ```go select { case data := <-ch1: - // 处理ch1的数据 + // 处理 ch1 case ch2 <- value: - // 向ch2发送数据 + // 发送数据 case <-timeout: // 超时处理 default: - // 所有channel都不可用时执行 + // 所有 channel 都不可用时执行 } ``` -## 11. select的实现原理是怎样的? +#### 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 // 释放时间,用于竞态检测 -} +```mermaid +graph LR + A[编译阶段] --> B[生成 scase 结构体数组] + B --> C[运行时 selectgo] + C --> D[随机排序 case] + D --> E{第一轮扫描} + E -->|找到就绪| F[立即执行] + E -->|全阻塞| G[第二轮: 加入等待队列] + G --> H[gopark 睡眠] + H --> I[channel 就绪后唤醒] + I --> F ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Channel面试题/image-2.png) +1. **随机排序**:避免饥饿 +2. **两轮扫描**:第一轮直接检查可读写性,找不到则全部加入等待队列后睡眠 +3. **scase 结构**:每个 case 封装为 scase,包含 channel 指针、操作类型等信息 -在默认的情况下,select 语句会在编译阶段经过如下过程的处理: +> [!note] 📝 核心考点 +> select 的核心设计:**case 随机化 + 双重循环检测**。面试官可能会追问"为什么不按顺序一个个判断",答案是"为了避免某些 case 长期得不到执行(饥饿)"。 -1. 将所有的 `case` 转换成包含` Channel `以及类型等信息的 scase 结构体; - -2. 调用运行时函数 `selectgo `获取被选择的`scase` 结构体索引,如果当前的` scase `是一个接收数据的操作,还会返回一个指示当前`case` 是否是接收的布尔值; - -3. 通过` for `循环生成一组` if `语句,在语句中判断自己是不是被选中的 `case`。 +## 关联笔记 +- [[hzh/GolangStar/Go语言进阶/Channel]] +- [[hzh/GolangStar/Go语言进阶/Select]] +- [[hzh/GolangStar/Go语言原理/channel原理]] +- [[hzh/GolangStar/Go面试题库/代码面试题]] diff --git a/hzh/GolangStar/Go面试题库/Context面试题.md b/hzh/GolangStar/Go面试题库/Context面试题.md index 2ecc2b5..efe3c3b 100644 --- a/hzh/GolangStar/Go面试题库/Context面试题.md +++ b/hzh/GolangStar/Go面试题库/Context面试题.md @@ -1,68 +1,138 @@ --- -tags: - - Go - - golang - - context - - 面试题 - - context面试题 +tags: [go, golang, interview, context-questions] +create time: 2026-06-07 14:30 --- -# Context面试题 +# Context 面试题 🌐 -## 1. Go语言里的Context是什么? +## 概述 -go语言里的context实际上是一个接口,提供了Deadline(),Done(),Err()以及Value()四种方法。它在Go 1.7 标准库被引入。 +本文件涵盖 Go Context 的 4 道高频面试题,涉及接口定义、作用机制、Value 查找和取消传播。Context 是 Go 1.7 引入的标准库接口,是并发编程中控制请求生命周期的核心工具。 -它本质上是一个**信号传递和范围控制的工具**。它的核心作用是在一个请求处理链路中(跨越多个函数和goroutine),优雅地传递**取消信号(cancellation)、超时(timeout)和截止日期(deadline)**,并能携带一些范围内的键值对数据。 +## 关联笔记 -**分析** +- [[hzh/GolangStar/Go语言进阶/Context]] — Context 详细讲解 +- [[hzh/GolangStar/Go语言原理/context原理]] — cancel chain / value chain 源码分析 +- [[hzh/GolangStar/Go面试题库/代码面试题]] — Context + Channel 实战 + +## 正文 + +### Q1:Go 语言里的 Context 是什么? 🟡中等 + +> [!question] ❓ 思考一下 +> 如果一个 HTTP 请求处理函数调用了 5 个下游服务,每个下游又调了更多服务,你怎么保证当请求超时或取消时所有调用链都能及时退出? + +## 参考答案 + +Context 是一个**信号传递和范围控制的工具**,本质是一个接口: ```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() (time.Time, bool) // 返回截止时间 + Done() <-chan struct{} // 返回关闭 channel 作为取消信号 + Err() error // 返回取消原因 + Value(key interface{}) interface{} // 携带请求级键值对数据 } ``` -这个接口定义了四个核心方法,它们共同构成了一套关于**截止时间、取消信号和请求范围值**的协定: +四个方法构成的协定: -* `Deadline()` - 返回一个时间点,告知任务何时应该被取消。 +| 方法 | 作用 | +|------|------| +| `Deadline()` | 告知任务何时应该被取消 | +| `Done()` | 关闭后通知所有监听者收到取消信号 | +| `Err()` | 解释取消原因(Canceled / DeadlineExceeded) | +| `Value()` | 在调用链中传递请求范围的键值对 | -* `Done()` - 返回一个channel,当`Context`被取消或超时,这个channel会被关闭。这是goroutine监听取消信号的核心。 +> [!note] 📝 核心考点 +> Context 的核心作用是三件事:**超时控制**、**取消信号传播**、**请求级数据传递**。面试时可以按这个顺序展开。 -* `Err()` - 在`Done()`的channel关闭后,它会解释关闭的原因,是主动取消(`Canceled`)还是超时(`DeadlineExceeded`)。 +> [!warning] ⚠️ 高频陷阱 +> Context 应该作为函数的**第一个参数**传递,**不要存储在结构体中**,传递的数据应该是**请求级别**的(如 request ID),不要滥用。 -* `Value()` - 允许`Context`在调用链中携带请求范围的键值对数据。 +--- -## 2. Go语言的Context有什么作用? +### Q2:Context 有什么作用? 🟢简单 -Go的Context主要解决三个核心问题:**超时控制、取消信号传播和请求级数据传递** +## 参考答案 -在实际项目中,我们最常用的是超时控制。比如一个HTTP请求需要调用多个下游服务,我们通过`context.WithTimeout`设置整体超时时间,当超时发生时,所有子操作都会收到取消信号并立即退出,避免资源浪费。取消信号的传播是通过Context的层级结构实现的,父Context取消时,所有子Context都会自动取消。 +三大核心作用: -另外Context还能传递请求级的元数据,比如用户ID、请求ID等,这在分布式链路追踪中特别有用。需要注意的是,Context应该作为函数的第一个参数传递,不要存储在结构体中,并且传递的数据应该是请求级别的,不要滥用。 +| 作用 | 场景 | 示例 | +|------|------|------| +| **超时控制** | HTTP 请求调用多个下游服务 | `WithTimeout` | +| **取消信号传播** | 父请求取消时子操作自动退出 | `WithCancel` | +| **请求级数据传递** | 分布式链路追踪中的 request ID | `WithValue` | -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Context面试题/image-1.png) +```go +// 典型用法:HTTP handler 中 +func handler(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) + defer cancel() + + // 将 ctx 传递给所有下游调用 + result := fetchFromDownstream(ctx) +} +``` -## 3. Context.Value的查找过程是怎样的 +> [!tip] 💡 面试技巧 +> "Context 就像一条传送带上的标签——它从请求入口开始,沿着调用链一路传递,任何环节都可以读取上面的信息,也可以随时发出'停止'信号让整条线停下来。" -Context.Value的查找过程是一个**链式递归查找的过程**,从当前Context开始,沿着父Context链一直向上查找直到找到对应的key或者到达根Context。 +--- -具体流程是:当调用`ctx.Value(key)`时,首先检查当前Context是否包含这个key,如果当前层没有,就会调用`parent.Value(key)`继续向上查找。这个过程会一直递归下去,直到找到匹配的key返回对应的value,或者查找到根Context返回nil。 +### Q3:Context.Value 的查找过程是怎样的? 🟡中等 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Context面试题/image.png) +## 参考答案 -## 4. Context如何被取消 +**链式递归查找**,从当前 Context 沿父链向上直到找到 key 或到达根 Context。 -Context的取消是通过**channel关闭信号**实现的,主要有三种取消方式。 +```mermaid +graph TD + A[ctx.Value key] --> B{当前层有?} + B -->|是| C[返回 value] + B -->|否| D[parent.Value key] + D --> E{有?} + E -->|是| C + E -->|否| F[root.Value key] + F --> G{有?} + G -->|是| C + G -->|否| H[返回 nil] +``` -首先是**主动取消**,通过`context.WithCancel`创建的Context会返回一个cancel函数,调用这个函数就会关闭内部的done channel,所有监听这个Context的goroutine都能通过`ctx.Done()`收到取消信号。 +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/context原理]] — value chain 的实现细节 -其次是**超时取消**,`context.WithTimeout`和`context.WithDeadline`会启动一个定时器,到达指定时间后自动调用cancel函数触发取消。 +--- -最后是**级联取消**,当父Context被取消时,所有子Context会自动被取消,这是通过Context树的结构实现的。 +### Q4:Context 如何被取消? 🟡中等 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Context面试题/image-2.png) +## 参考答案 +通过 **channel 关闭信号**实现,三种取消方式: + +| 方式 | 创建函数 | 触发条件 | +|------|---------|---------| +| **主动取消** | `WithCancel` | 手动调用 cancel 函数 | +| **超时取消** | `WithTimeout` / `WithDeadline` | 到达指定时间自动 cancel | +| **级联取消** | 自动传播 | 父 Context 取消时子 Context 自动取消 | + +```mermaid +graph LR + A[Root Context] --> B[WithCancel → child1] + A --> C[WithTimeout → child2] + B --> D[WithCancel → grandchild] + C --> E[WithCancel → grandchild2] + + style A fill:#F44336,color:#fff + style B fill:#FF9800,color:#fff + style C fill:#2196F3,color:#fff +``` + +> [!tip] 💡 面试技巧 +> 回答时可以强调:"Context 是一棵树的结构,父节点取消时,整棵子树都会取消。Done() 返回的 channel 会在任意一层被 cancel 时关闭。" + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Context]] +- [[hzh/GolangStar/Go语言原理/context原理]] +- [[hzh/GolangStar/Go面试题库/代码面试题]] diff --git a/hzh/GolangStar/Go面试题库/GMP面试题.md b/hzh/GolangStar/Go面试题库/GMP面试题.md index 66db8cd..36edb90 100644 --- a/hzh/GolangStar/Go面试题库/GMP面试题.md +++ b/hzh/GolangStar/Go面试题库/GMP面试题.md @@ -1,118 +1,223 @@ --- -tags: - - Go - - golang - - GMP - - 面试题 - - GMP面试题 +tags: [go, golang, interview, gmp-questions] +create time: 2026-06-07 14:30 --- -# GMP面试题 +# GMP 面试题 ⚙️ -## 1. Go语言的GMP模型是什么? +## 概述 -GMP是Go运行时的核心调度模型 +本文件涵盖 Go GMP 调度模型的 10 道高频面试题,涉及 G/M/P 含义、调度策略、抢占机制、工作窃取等核心概念。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机制。 +- [[hzh/GolangStar/Go语言进阶/Goroutine]] — Goroutine 基础 +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 源码级详解 +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — 内存与调度的关系 -就是这个模型让Go能在少量线程上调度海量goroutine,是Go高并发的基础。 +## 正文 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/GMP面试题/image-1.png) +### Q1:Go 语言的 GMP 模型是什么? 🟡中等 -## 2. 什么是Go scheduler +> [!question] ❓ 思考一下 +> 如果让你设计一个能调度百万级协程的系统,你会怎么组织 M(系统线程)和 G(协程)的关系?直接 M-G 一对一行吗? -Go scheduler就是Go运行时的**协程调度器**,负责在系统线程上调度执行goroutine。它 是 Go runtime 的一部分,它内嵌在 Go 程序里,和 Go 程序一起运行。它的主要工作是决定哪个goroutine在哪个线程上运行,以及何时进行上下文切换。scheduler的核心是`schedule()`函数,它在无限循环中寻找可运行的goroutine。当找到后通过`execute()`函数切换到goroutine执行,goroutine主动让出或被抢占时再回到调度循环。 +## 参考答案 -## 3. Go语言在进行goroutine调度的时候,调度策略是怎样的? +GMP 是 Go 运行时的**核心调度模型**: -Go语言采用的是抢占式调度策略。Go 会启动一个线程,一直运行着“sysmon”函数,sysmon 运行在 M上,且不需要P。当 sysmon 发现 M 已运行同一个 G(Goroutine)10ms 以上时,它会将该 G 的内部参数 `preempt` 设置为 true,表示需要被抢占,让出CPU了。只是在Go 1.14之前和Go 1.14之后有所不同 +| 组件 | 含义 | 角色 | +|------|------|------| +| **G (Goroutine)** | 协程 | 干活的工作单元 | +| **M (Machine)** | 系统线程 | 真正执行代码的"手" | +| **P (Processor)** | 逻辑处理器 | G 和 M 之间的"桥梁",负责调度 G | -**Go 1.14之前**:调度策略是“协作式”抢占调度,这种调度方式主要是通过函数调用来实现的,在编译期,编译器会在几乎所有的函数调用的入口处,插入一小段检查代码。这段代码会检查当前goroutine是否已经被标记为需要被抢占。如果是,当 G 进行函数调用时,G 会检查自己的 `preempt` 标志,如果它为 true,则它将自己与 M 分离并推入goroutine的全局队列,抢占完成。但这种模式有个明显的缺陷:如果一个goroutine执行了一个不包含任何函数调用的**超大循环**,那么调度器的“抢占”标记就永远得不到检查,这个goroutine就会一直霸占着M,导致同一个P队列里的其他G全都没机会执行,造成**调度延迟**。 +**调度逻辑:** M 必须绑定 P 才能执行 G。每个 P 维护自己的本地队列(最多 256 个 G),M 从 P 的本地队列取 G 执行。当本地队列空时,M 按优先级从全局队列、网络轮询器或其他 P 中窃取 goroutine(work-stealing)。 -**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... - }; +```mermaid +graph LR + G1["G1"] --> P["P (逻辑处理器)"] + G2["G2"] --> P + G3["G3"] --> P + G4["G4"] --> P + + P --> M["M (系统线程)"] + + style P fill:#FF9800,color:#fff + style M fill:#2196F3,color:#fff ``` +> [!note] 📝 核心考点 +> GMP 的核心价值:**用少量 M(系统线程)调度海量 G(协程)**。P 层实现了无锁本地调度,避免了全局锁竞争。 + +--- + +### Q2:什么是 Go scheduler? 🟢简单 + +## 参考答案 + +Go scheduler 是 Go 运行时的**协程调度器**,内嵌在程序中一起运行。它的核心工作是决定哪个 goroutine 在哪个线程上运行,以及何时进行上下文切换。 + +核心函数 `schedule()` 在无限循环中寻找可运行的 goroutine,找到后通过 `execute()` 切换到 goroutine 执行。 + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — schedule() 源码分析 + +--- + +### Q3:Go 的 goroutine 抢占调度策略? 🟡中等 + +## 参考答案 + +Go 采用**抢占式调度**,由 sysmon 线程监控。不同版本的实现方式不同: + +#### Go 1.14 之前:协作式抢占 + +- 编译器在函数调用入口插入检查代码 +- G 进行函数调用时检查 `preempt` 标志 +- **缺陷**:不含函数调用的超大循环不会被抢占,造成调度延迟 + +#### Go 1.14 及之后:基于信号的异步抢占 + +- sysmon 检测到运行超过 10ms 的 G +- 向 M 发送 SIGURG 信号 +- gsignal goroutine 处理信号,停止正在运行的 G + +> [!warning] ⚠️ 高频陷阱 +> Go 1.14 之前的协作式抢占无法处理"纯计算密集型且无函数调用的 goroutine",这是最常见的面试追问点。 + +--- + +### Q4:发生调度的时机有哪些? 🟢简单 + +## 参考答案 + +| 触发条件 | 说明 | +|---------|------| +| Channel 操作 | 等待读写未缓冲 channel | +| time.Sleep() | 等待睡眠时间结束 | +| Mutex 等待 | 获取互斥量失败 | +| 系统调用 | 发起 syscall 进入内核态 | +| 主动让出 | `runtime.Gosched()` | +| 抢占 | sysmon 强制抢占(Go 1.14+) | + +--- + +### Q5:M 寻找可运行 G 的过程? 🟡中等 + +## 参考答案 + +M 按以下优先级顺序寻找 G: + +```mermaid +graph TD + A[M 需要执行 G] --> B{本地队列 LRQ?} + B -->|有| C[runqget 取出] + B -->|无| D{全局队列 GRQ?} + D -->|有| E[globrunqget 取一半] + D -->|无| F{网络轮询 netpoll?} + F -->|有| G[取出就绪的 G] + F -->|无| H[从其他 P 偷 work-steal] + H --> I[随机找一个 P 偷一半] + + style C fill:#4CAF50,color:#fff + style E fill:#2196F3,color:#fff + style G fill:#9C27B0,color:#fff + style I fill:#FF9800,color:#fff +``` + +1. **本地队列**(LRQ):无锁 CAS 操作 +2. **全局队列**(GRQ):需要加锁 +3. **网络轮询器**(netpoll):IO 就绪的 G +4. **Work-stealing**:随机找其他 P 偷一半 + +> [!tip] 💡 面试技巧 +> "Go 的调度器就像一个聪明的餐厅经理:先看自己桌有没有客人(本地队列),没有就去公共排队区(全局队列),再去门口看看有没有新到的人(netpoll),最后实在没人就从隔壁桌'借'几个客人来(steal)。" + +--- + +### Q6:GMP 能不能去掉 P 层? 🟡中等 + +## 参考答案 + +**理论上可以,但会带来严重的性能问题。** + +| 方案 | 问题 | +|------|------| +| 去掉 P(变成 GM) | 所有 M 争抢全局锁,高并发下锁竞争严重 | +| 保留 P | 每个 P 独立本地队列,大部分情况无需全局锁 | + +> [!note] 📝 核心考点 +> P 的价值在于实现了**无锁的本地调度**。M 绑定 P 后直接从本地队列取 G,只有队列空了才去偷取,大大减少了锁竞争。 + +--- + +### Q7:P 和 M 在什么时候被创建? 🟡中等 + +## 参考答案 + +| 组件 | 创建时机 | 说明 | +|------|---------|------| +| **P** | 初始化时一次性创建 | `schedinit()` → `procresize()`,数量为 GOMAXPROCS | +| **M** | 按需创建 | 初始只有 m0,阻塞场景或 startm() 触发 | + +**M 的创建条件:** +- 所有现有 M 都在执行系统调用,但还有可运行的 G +- 没有空闲 M 可以绑定 P + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — schedinit / procresize 源码分析 + +--- + +### Q8:m0 是什么? 🟢简单 + +## 参考答案 + +m0 是 Go 启动时创建的**第一个 M**,对应主系统线程,贯穿整个生命周期。与其他动态创建的 M 不同,m0 是静态分配的。 + +**职责:** +1. 执行 Go 程序的启动流程(调度器、内存管理器、GC 器初始化) +2. 创建并运行第一个用户 goroutine(执行 main.main) +3. 程序退出时负责清理工作 + +--- + +### Q9:g0 是什么? 🟡中等 + +## 参考答案 + +g0 是每个 M 拥有的**特殊调度协程**,不是普通用户协程: + +| 特性 | g0 | 普通 goroutine | +|------|----|--------------| +| 栈空间 | 系统线程原始栈(8KB) | 可增长的分段栈(2KB 起) | +| 用途 | 执行调度逻辑 | 执行业务代码 | +| 创建方式 | 静态分配 | runtime.newg() | + +**核心作用:** 专门负责执行调度器代码(goroutine 创建/销毁/调度决策)、垃圾回收、栈扫描、信号处理等运行时操作。 + +> [!warning] ⚠️ 高频陷阱 +> 为什么需要 g0?因为调度器不能在普通 goroutine 的栈上执行——会有栈空间冲突和递归调度的问题。g0 提供了独立的执行环境。 + +--- + +### Q10:g0 栈和用户栈如何切换? 🟡中等 + +## 参考答案 + +本质是 **SP 寄存器和栈指针的切换**: + +1. **用户 -> g0**:通过 `mcall()` 保存当前 PC/SP 到 gobuf,将 SP 指向 g0 的栈 +2. **g0 -> 用户**:通过 `gogo()` 恢复用户 goroutine 保存的寄存器状态 + +这些切换在汇编文件中实现(`runtime·mcall` 和 `runtime·gogo`),直接操作 CPU 寄存器,确保原子性和高效性。 + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — 栈切换的汇编实现细节 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/Goroutine]] +- [[hzh/GolangStar/Go语言原理/gmp调度原理]] +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] diff --git a/hzh/GolangStar/Go面试题库/Interface面试题.md b/hzh/GolangStar/Go面试题库/Interface面试题.md index ae11802..481253e 100644 --- a/hzh/GolangStar/Go面试题库/Interface面试题.md +++ b/hzh/GolangStar/Go面试题库/Interface面试题.md @@ -1,146 +1,154 @@ --- -tags: - - Go - - golang - - interface - - 面试题 - - interface面试题 +tags: [go, golang, interview, interface-questions] +create time: 2026-06-07 14:30 --- -# Interface面试题 +# Interface 面试题 🔌 -## 1. Go语言中,interface的底层原理是怎样的? +## 概述 -Go的interface底层有两种数据结构:**eface和iface**。 +本文件涵盖 Go Interface 的 5 道高频面试题,涉及底层原理(eface/iface)、类型断言、比较规则和实际应用。Interface 是 Go 多态和接口抽象的核心机制,理解其底层实现是区分中高级开发者的关键。 -**eface是空interface{}的实现**,只包含两个指针:`_type`指向类型信息,`data`指向实际数据。这就是为什么空接口能存储任意类型值的原因,通过类型指针来标识具体类型,通过数据指针来访问实际值。 +## 关联笔记 -**iface是带方法的interface实现**,包含`itab`和`data`两部分。`itab`是核心,它存储了接口类型、具体类型,以及方法表。方法表是个函数指针数组,保存了该类型实现的所有接口方法的地址。 +- [[hzh/GolangStar/Go语言基础/Go语言接口]] — Interface 基础用法 +- [[hzh/GolangStar/Go语言进阶/反射]] — 反射与 Interface 的配合 +- [[hzh/GolangStar/Go语言原理/interface原理]] — itab/witab 源码分析 +- [[hzh/GolangStar/Go面试题库/基础面试题]] — nil interface 陷阱 -**分析:** +## 正文 -eface定义: +### Q1:Interface 的底层原理? 🟡中等 + +> [!question] ❓ 思考一下 +> 为什么 `var i interface{} = 42` 能存储任意类型的值?它的内部是怎么做到这一点的? + +## 参考答案 + +Go 的 interface 有两种底层结构: + +#### eface(空接口 `interface{}`) ```go type eface struct { - _type *_type - data unsafe.Pointer + _type *_type // 指向类型信息 + data unsafe.Pointer // 指向实际数据 } ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Interface面试题/eface修改.png) +空接口只有两个指针:**类型指针 + 数据指针**。这就是它能存储任意类型的原因。 - - -iface定义: +#### iface(带方法的非空接口) ```go type iface struct { - tab *itab - data unsafe.Pointer + 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. + inter *interfacetype // 接口类型 + _type *_type // 具体类型 + hash uint32 // 用于类型 switch + fun [1]uintptr // 方法函数指针数组(变长) } ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Interface面试题/iface修改.png) +iface 比 eface 多了 **itab(方法表)**,它记录了该类型实现的所有接口方法的地址。 -## 2. iface和eface的区别是什么? +> [!note] 📝 核心考点 +> eface 只需要标识"是什么类型"和"值是什么";iface 还需要知道"有哪些方法可以调用"。方法表是 iface 的核心——它让 Go 在运行时通过函数指针间接调用方法,实现了动态分发。 -iface和eface的核心区别在于是否包含方法信息。 +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/interface原理]] — itab 的构建时机(编译期 vs 运行期) -eface是空接口interface{}的底层实现,结构非常简单,只有两个字段:`_type`指向类型信息,`data`指向实际数据。因为空接口没有方法约束,所以不需要存储方法相关信息。 +--- -iface是非空接口的底层实现,结构相对复杂,包含`itab`和`data`。关键是这个`itab`,它不仅包含类型信息,还包含了一个方法表,存储着该类型实现的所有接口方法的函数指针。 +### Q2:iface 和 eface 的区别? 🟢简单 -## 3. 类型转换和断言的区别是什么? +## 参考答案 -`类型转换`、`类型断言`本质都是把一个类型转换成另外一个类型。不同之处在于,类型断言是对接口变量进行的操作。对于**类型转换**而言,类型转换是在编译期确定的强制转换,转换前后的两个类型要相互兼容才行,语法是`T(value)`。而**类型断言**是运行期的动态检查,专门用于从接口类型中提取具体类型,语法是`value.(T)` +| | eface(空接口) | iface(非空接口) | +|--|---------------|-----------------| +| 定义 | `interface{}` | 包含至少一个方法的接口 | +| 字段 | `_type` + `data` | `itab` + `data` | +| 方法信息 | 无 | 有(itab 中的 fun 数组) | +| 内存占用 | 16 字节(两个指针) | 更大(itab 额外开销) | -**安全性差别很大**:类型转换在编译期保证安全性,而类型断言可能在运行时失败。所以实际开发中更常用安全版本的类型断言`value, ok := x.(string)`,通过ok判断是否成功。 +> [!tip] 💡 面试技巧 +> "空接口没有方法约束,所以不需要存方法表;非空接口需要方法表来实现动态分派。这是 iface 比 eface 复杂的原因。" -**使用场景不同**:类型转换主要解决数值类型、字符串、切片等之间的转换问题;类型断言主要用于接口编程,当你拿到一个interface{}需要还原成具体类型时使用。 +--- -**底层实现也不同**:类型转换通常是简单的内存重新解释或者数据格式调整;类型断言需要检查接口的底层类型信息,涉及到runtime的类型系统。 +### Q3:类型转换和类型断言的区别? 🟡中等 -## 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`。 +| 维度 | 类型转换 `T(value)` | 类型断言 `value.(T)` | +|------|-------------------|---------------------| +| 作用对象 | 普通变量 | **接口变量** | +| 检查时机 | **编译期** | **运行期** | +| 安全性 | 编译期保证 | 可能运行时失败 | +| 语法 | `T(value)` | `value.(T)` 或 `value, ok := x.(T)` | ```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) -} +// 类型转换:编译期检查 +var f float64 = 3.14 +var i int = int(f) +// 类型断言:运行期检查 +var i interface{} = "hello" +s, ok := i.(string) // s="hello", ok=true +n, ok := i.(int) // n=0, ok=false ``` -程序输出: +> [!warning] ⚠️ 高频陷阱 +> 类型断言在不匹配时会 panic!务必使用安全版本 `value, ok := x.(T)` 来避免。 + +--- + +### Q4:Interface 的应用场景? 🟢简单 + +## 参考答案 + +| 场景 | 说明 | 示例 | +|------|------|------| +| **依赖注入/解耦** | 高层模块不依赖具体实现 | `UserRepo` 接口 | +| **多态** | 统一处理不同类型的行为 | `Shape` 接口的 `Area()` | +| **标准库 API** | 提供统一操作接口 | `io.Reader` / `io.Writer` | +| **序列化/反序列化** | 配合反射处理任意类型 | `json.Marshal` | +| **插件化架构** | 第三方扩展遵循接口规范 | Web 中间件、DB 驱动 | + +> [!tip] 💡 面试技巧 +> 举例时可以用 `io.Reader` 来说明:"文件、网络连接、bytes.Buffer 都实现了 Reader 接口,所以可以用同一套代码读取它们的数据。这就是 Go 接口的优雅之处——隐式实现,无需显式声明 implements。" + +--- + +### Q5:接口之间可以相互比较吗? 🟡中等 + +## 参考答案 + +**可以。** 两个接口值相等的条件: + +1. 两者均为 nil(动态类型和动态值都为 nil) +2. 动态类型相同且动态值相等 + +**特殊情况:nil interface ≠ nil 值** ```go -true -c: , -true -false -c: *main.Gopher, +var c Coder = (*Gopher)(nil) // 动态类型为 *Gopher,值为 nil +c == nil // false!因为动态类型不为 nil ``` -一开始,`c` 的 动态类型和动态值都为 `nil`,`g` 也为 `nil`,当把 `g` 赋值给 `c` 后,`c` 的动态类型变成了 `*main.Gopher`,仅管 `c` 的动态值仍为 `nil`,但是当 `c` 和 `nil` 作比较的时候,结果就是 `false` 了。 +> [!warning] ⚠️ 高频陷阱 +> 这是 Go 面试中最经典的陷阱题之一。记住:**接口 == nil 的条件是动态类型 AND 动态值都为 nil**。当接口持有 nil 指针时,动态类型不为 nil,所以不等于 nil。 + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go面试题库/基础面试题]] — Q16 也是考察 nil 不相等问题 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言接口]] +- [[hzh/GolangStar/Go语言进阶/反射]] +- [[hzh/GolangStar/Go语言原理/interface原理]] +- [[hzh/GolangStar/Go面试题库/基础面试题]] diff --git a/hzh/GolangStar/Go面试题库/Map面试题.md b/hzh/GolangStar/Go面试题库/Map面试题.md index 32eae3d..6dd5e5b 100644 --- a/hzh/GolangStar/Go面试题库/Map面试题.md +++ b/hzh/GolangStar/Go面试题库/Map面试题.md @@ -1,164 +1,179 @@ --- -tags: - - Go - - golang - - map - - 面试题 - - map面试题 +tags: [go, golang, interview, map-questions] +create time: 2026-06-07 14:30 --- -# Map面试题 +# Map 面试题 🗺️ -## 1. Go语言Map的底层实现原理是怎样的? +## 概述 -map的就是一个hmap的结构。Go Map的底层实现是一个**哈希表**。它在运行时表现为一个指向`hmap`结构体的指针,`hmap`中记录了**桶数组指针`buckets`**、**溢出桶指针**以及**元素个数**等字段。每个桶是一个`bmap`结构体,能存储**8个键值对**和**8个`tophash`**,并有指向下一个**溢出桶的指针`overflow`**。为了**内存紧凑**,`bmap`中采用的是先存8个键再存8个值的存储方式。 +本文件涵盖 Go Map 的 11 道高频面试题,涉及底层原理、遍历顺序、并发安全、扩容机制等核心考点。Map 是 Go 中使用频率最高的数据结构之一,也是面试中的"常客"。 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言Map]] — Map 基础用法 +- [[hzh/GolangStar/Go语言原理/map原理]] — Map 底层源码详解 +- [[hzh/GolangStar/Go面试题库/Sync面试题]] — sync.Map 替代方案 +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — Map 删除不释放内存 + +## 正文 + +### Q1:Map 的底层实现原理? 🟡中等 + +> [!question] ❓ 思考一下 +> 如果让你设计一个哈希表,你会怎么组织存储结构来兼顾查找速度和内存利用率? + +## 参考答案 + +Go Map 的底层是一个指向 `hmap` 结构体的指针,本质是**哈希表**。 -**分析:** -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中的溢出桶 + count int // 元素个数 + flags uint8 // 状态标志位 + B uint8 // 桶数 = 2^B + noverflow uint16 // 溢出桶数量 + hash0 uint32 // 哈希种子 + buckets unsafe.Pointer // 主桶数组 + oldbuckets unsafe.Pointer // 扩容时的旧桶数组 + nevacuate uintptr // 扩容进度计数器 + extra *mapextra // 溢出桶(key/value 较大时) } ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Map面试题/image-2.png) +每个桶(`bmap`)能存储 **8 个键值对** + **8 个 tophash**,采用先存 8 个 key 再存 8 个 value 的紧凑布局。 -bmap结构如下: +> [!note] 📝 核心考点 +> 面试官追问时要点出:**tohash 高位优化**——每次比较前先对比 tophash 而不是直接比 key,减少昂贵的 key 比较次数。 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Map面试题/image-1.png) +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/map原理]] — 完整的 bucket/bmap 结构图解 -## 2. Go语言Map的遍历是有序的还是无序的? +--- -Go语言里Map的遍历是**完全随机**的,并没有固定的顺序。map每次遍历,都会从一个随机值序号的桶,在每个桶中,再从按照之前选定随机槽位开始遍历,所以是无序的。 +### Q2-Q3:Map 遍历为什么是无序的? 🟢简单 -## 3. Go语言Map的遍历为什么要设计成无序的? +## 参考答案 -map 在扩容后,会发生 key 的搬迁,原来落在同一个 bucket 中的 key,搬迁后,有些 key 就要远走高飞了(bucket 序号加上了 2^B)。而遍历的过程,就是按顺序遍历 bucket,同时按顺序遍历 bucket 中的 key。搬迁后,key 的位置发生了重大的变化,有些 key 飞上高枝,有些 key 则原地不动。这样,遍历 map 的结果就不可能按原来的顺序了。 +Go Map 遍历时会选择一个**随机起始桶**和**随机起始槽位**开始遍历,因此每次遍历的顺序都不同。 -Go团队为了避免开发者写出依赖底层实现细节的脆弱代码,而**有意为之**的一个设计。通过在遍历时引入随机数,Go从根本上杜绝了程序员依赖特定遍历顺序的可能性,强制我们写出更健壮的代码。 +**设计原因**:Go 团队**有意为之**——避免开发者写出依赖特定遍历顺序的脆弱代码,强制写出更健壮的代码。 -## 4. Map如何实现顺序读取? - -如果业务上确实需要有序遍历,最规范的做法就是将Map的键(Key)取出来放入一个切片(Slice)中,用`sort`包对切片进行排序,然后根据这个有序的切片去遍历Map。 +> [!tip] 💡 面试技巧 +> 如果面试官问"如何有序遍历 Map",回答:"将 key 取出放入 slice,用 sort 排序后再按序读取。"并给出示例代码。 ```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]) - } -} +// 有序遍历 Map +keys := make([]int, 0, len(m)) +for k := range m { keys = append(keys, k) } +sort.Ints(keys) +for _, k := range keys { fmt.Println(k, m[k]) } ``` -## 5. Go语言的Map是否是并发安全的? +--- -map 不是线程安全的。 +### Q4:Map 是否线程安全? 🟢简单 -在查找、赋值、遍历、删除的过程中都会检测写标志,一旦发现写标志置位(等于1),则直接 panic。赋值和删除函数在检测完写标志是复位之后,先将写标志位置位,才会进行之后的操作。 +## 参考答案 -检测写标志: +**不安全。** concurrent read + write 会导致 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"]) + throw("concurrent map writes") // 检测到并发写就 panic } +h.flags |= hashWriting // 设置写标志 ``` -会出现编译报错: +> [!warning] ⚠️ 高频陷阱 +> **多个 goroutine 同时读同一个 map 是安全的**,但"读+写"或"写+写"都会 panic。如果需要并发读写,使用 `sync.Map` 或加 `RWMutex`。 -```go -./main.go:8:14: cannot take the address of m["qcrao"] -``` +--- -## 10. Map 中删除一个 key,它的内存会释放么? +### Q5: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可以边遍历边删除吗 +1. **哈希运算**:Key 需要被哈希以确定存储在哪个桶 +2. **冲突解决**:同一桶内可能存在多个 key,需要通过 `==` 逐个比较找到目标 key -map 并不是一个线程安全的数据结构。如果多个线程边遍历,边删除,同时读写一个 map 是未定义的行为,如果被检测到,会直接 panic。 +> [!note] 📝 核心考点 +> slice、map、function 类型**不能作为 Map 的 Key**,因为它们不可比较。 -如果是发生在多个协程同时读写同一个 map 的情况下。 如果在同一个协程内边遍历边删除,并不会检测到同时读写,理论上是可以这样做的。但是,遍历的结果就可能不会是相同的了,有可能结果遍历结果集中包含了删除的 key,也有可能不包含,这取决于删除 key 的时间:是在遍历到 key 所在的 bucket 时刻前或者后。这种情况下,可以通过加读写锁sync.RWMutex来保证 +--- +### Q6:Map 的扩容时机? 🟡中等 + +## 参考答案 + +向 Map 插入新 key 时触发以下任一条件即扩容: + +| 条件 | 说明 | +|------|------| +| 装载因子 > 6.5 | 源码阈值,触发双倍扩容 | +| overflow 过多(B < 15) | overflow 数量 > 2^B | +| overflow 过多(B >= 15) | overflow 数量 > 2^15 | + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/map原理]] — 渐进式扩容的详细流程分析 + +--- + +### Q7:Map 的扩容过程是怎样的? 🟡中等 + +## 参考答案 + +Go Map 采用**渐进式扩容**,不会 STW 一次性搬迁: + +1. 分配新的 buckets 数组(双倍大小) +2. 在后续每次**插入/修改/删除**操作时,顺便搬迁一两个旧桶的数据 +3. `oldbuckets` 指向旧数组,`nevacuate` 记录已搬迁进度 + +> [!tip] 💡 面试技巧 +> 强调"渐进式"三个字。面试官最想知道的是:Go 如何在不停止用户代码的情况下完成大规模数据搬迁。 + +--- + +### Q8:可以对 Map 的元素取地址吗? 🟢简单 + +## 参考答案 + +**不能。** 编译报错:`cannot take the address of m["key"]` + +**原因**:Map 扩容时 key/value 的位置会改变,之前保存的地址就会失效。 + +> [!warning] ⚠️ 高频陷阱 +> 如果你需要在 Map 中存储可变数据,可以存指针:`map[string]*MyStruct`,这样即使 Map 扩容,指针本身仍然有效。 + +--- + +### Q9:Map 删除 key 后内存会释放吗? 🟡中等 + +## 参考答案 + +**不会立即释放。** `delete(m, key)` 只是将对应内存块标记为"空闲",允许后续写入复用,但桶数组规模不会缩小。只有将 Map 置空时,整个空间才会被 GC 回收。 + +> [!tip] 💡 面试技巧 +> 如果面试官问"如何真正释放 Map 内存",回答:"`m = make(map[K]V)` 创建新 Map,旧 Map 被 GC 回收。" + +--- + +### Q10:Map 可以边遍历边删除吗? 🟡中等 + +## 参考答案 + +| 场景 | 能否边遍历边删除 | 说明 | +|------|----------------|------| +| 单协程 | **可以** | 理论上可行,但遍历结果不确定 | +| 多协程 | **不行** | 会 panic(concurrent map writes) | + +> [!warning] ⚠️ 高频陷阱 +> 即使是单协程,边遍历边删除的结果也不确定——取决于删除发生在遍历到该 bucket 之前还是之后。如需确定性行为,建议收集待删除的 key 后再批量删除。 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言Map]] +- [[hzh/GolangStar/Go语言原理/map原理]] +- [[hzh/GolangStar/Go面试题库/Sync面试题]] diff --git a/hzh/GolangStar/Go面试题库/Slice面试题.md b/hzh/GolangStar/Go面试题库/Slice面试题.md index e7374e4..e2c09ff 100644 --- a/hzh/GolangStar/Go面试题库/Slice面试题.md +++ b/hzh/GolangStar/Go面试题库/Slice面试题.md @@ -1,195 +1,143 @@ --- -tags: - - Go - - golang - - 切片 - - 面试题 - - slice面试题 +tags: [go, golang, interview, slice-questions] +create time: 2026-06-07 14:30 --- -# Slice面试题 +# Slice 面试题 🍕 -## 1. slice的底层结构是怎样的? +## 概述 -slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。 +本文件涵盖 Go Slice 的 4 道高频面试题,涉及底层结构、扩容机制、截取共享和函数传参等核心考点。Slice 是 Go 中最常用的数据结构之一,也是面试中的"必考题"。 +## 关联笔记 +- [[hzh/GolangStar/Go语言基础/Go语言数组与切片]] — Slice 基础用法 +- [[hzh/GolangStar/Go语言原理/slice原理]] — Slice 底层扩容策略详解 +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — Slice 引用大数组导致的内存泄漏 + +## 正文 + +### Q1:slice 的底层结构是怎样的? 🟢简单 + +> [!question] ❓ 思考一下 +> 如果让你设计一个能动态增长的数组,你会在结构体中放哪些字段? + +## 参考答案 + +slice 是对数组的封装,描述一个数组的片段。其底层是一个包含三个字段的结构体: ```go // runtime/slice.go type slice struct { - array unsafe.Pointer // 元素指针 - len int // 长度 - cap int // 容量 + array unsafe.Pointer // 指向底层数组的指针 + len int // 长度(当前元素个数) + cap int // 容量(底层数组从起始位置可容纳的元素数) } ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Slice面试题/image.png) +> [!note] 📝 核心考点 +> **len ≠ cap** 是理解 slice 的关键。len 是你"能看到"的元素数量,cap 是底层数组的实际容量。当 len == cap 时,append 会触发扩容。 -## 2. Go语言里slice是怎么扩容的? +--- -1.17及以前 +### Q2:slice 是怎么扩容的? 🟡中等 -1. 如果期望容量大于当前容量的两倍就会使用期望容量; +> [!question] ❓ 思考一下 +> 如果一个 slice 的 cap 是 10,你 append 第 11 个元素时会发生什么?扩容后的 cap 是多少? -2. 如果当前切片的长度小于 1024 就会将容量翻倍; +## 参考答案 -3. 如果当前切片的长度大于 1024 就会每次增加 25% 的容量,直到新容量大于期望容量; +#### Go 1.17 及以前 -Go1.18及以后,引入了新的扩容规则: +| 条件 | 扩容规则 | +|------|---------| +| 期望容量 > 当前容量的 2 倍 | 使用期望容量 | +| len < 1024 | 容量翻倍(×2) | +| len >= 1024 | 容量增加 25%(×1.25),直到 >= 期望容量 | -当原slice容量(oldcap)小于256的时候,新slice(newcap)容量为原来的2倍;原slice容量超过256,新slice容量newcap = oldcap+(oldcap+3\*256)/4 +#### Go 1.18 及以后(新规则) -## 3. 从一个切片截取出另一个切片,修改新切片的值会影响原来的切片内容吗 +| 条件 | 扩容公式 | +|------|---------| +| oldcap < 256 | newcap = oldcap × 2 | +| oldcap >= 256 | newcap = oldcap + (oldcap + 3×256) / 4 | -在截取完之后,如果新切片没有触发扩容,则修改切片元素会影响原切片,如果触发了扩容则不会。 +> [!warning] ⚠️ 高频陷阱 +> Go 1.18 的扩容规则更保守,减少了过度分配内存的情况。面试中如果不确定版本,可以先说旧规则再补充新规则的变化。 -示例: +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/slice原理]] — 详细的扩容策略源码分析 + +--- + +### Q3:切片截取后修改会影响原切片吗? 🟡中等 + +> [!question] ❓ 思考一下 +> `s2 := s1[2:6:7]`,然后对 s2 做 append,什么时候会影响 s1,什么时候不会? + +## 参考答案 + +**关键判断标准**:新切片是否触发了扩容。 ```go -package main +slice := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} +s1 := slice[2:5] // len=3, cap=8, 底层指向 slice[2] +s2 := s1[2:6:7] // len=4, cap=5, 底层指向 slice[4] -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 - s2 = append(s2, 100) - s2 = append(s2, 200) +s1[2] = 20 // 只影响 s1 可见范围 - s1[2] = 20 - - fmt.Println(s1) - fmt.Println(s2) - fmt.Println(slice) -} +fmt.Println(s1) // [2 3 20] +fmt.Println(s2) // [4 5 6 7 100 200] +fmt.Println(slice) // [0 1 2 3 20 5 6 7 100 9] ``` -运行结果: +**解析思路:** +1. 第一次 append(100):s2 cap=5,追加后 len=5,未超 cap,直接写入底层数组 → s1 能看到变化 +2. 第二次 append(200):s2 cap 已满,触发扩容创建新底层数组 → s1 看不到变化 +3. 修改 s1[2]:通过原始底层数组修改,但 s2 已有独立数组 → s2 不受影响 -```shell -[2 3 20] -[4 5 6 7 100 200] -[0 1 2 3 20 5 6 7 100 9] -``` +> [!tip] 💡 面试技巧 +> 回答这道题时,先画出三张图展示每次操作后的底层状态变化。面试官最看重的不是背答案,而是你能否清晰地描述数据流向。 -`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) +### Q4:slice 作为函数参数会改变原 slice 吗? 🟡中等 -接着,向 `s2` 尾部追加一个元素 100: +> [!question] ❓ 思考一下 +> 在函数里 `append(s, 100)` 后返回新的 slice,外层的 s 会变吗?为什么? + +## 参考答案 + +**分两种情况:** + +| 操作 | 外层 slice 结构变吗? | 底层数据变吗? | +|------|---------------------|--------------| +| 修改元素值 `s[i] = x` | 否(传的是副本) | **是**(共享底层数组) | +| append 后赋值给 s | 否 | 否(s 只是副本) | +| 传递 `*[]int` 指针 | **是** | **是** | ```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" - +// 方法1:返回新 slice(推荐) func myAppend(s []int) []int { - // 这里 s 虽然改变了,但并不会影响外层函数的 s - s = append(s, 100) - return s + return append(s, 100) } +s = myAppend(s) // 需要重新赋值 +// 方法2:传递指针 func myAppendPtr(s *[]int) { - // 会改变外层 s 本身 - *s = append(*s, 100) - return + *s = append(*s, 100) } - -func main() { - s := []int{1, 1, 1} - newS := myAppend(s) - - fmt.Println(s) - fmt.Println(newS) - - s = newS - - myAppendPtr(&s) - fmt.Println(s) -} - +myAppendPtr(&s) // 直接修改 ``` -程序输出 +> [!note] 📝 核心考点 +> slice 本身是结构体(三个字段),传入函数时被复制了一份。函数内修改**底层数组的数据**会影响外部,但**修改 slice 结构本身**(如让它的 array 指向别处)不会影响外部。 -```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]` +- [[hzh/GolangStar/Go语言基础/Go语言数组与切片]] +- [[hzh/GolangStar/Go语言原理/slice原理]] +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] diff --git a/hzh/GolangStar/Go面试题库/Sync面试题.md b/hzh/GolangStar/Go面试题库/Sync面试题.md index 3206eae..37cdecd 100644 --- a/hzh/GolangStar/Go面试题库/Sync面试题.md +++ b/hzh/GolangStar/Go面试题库/Sync面试题.md @@ -1,219 +1,262 @@ --- -tags: - - Go - - golang - - sync - - 面试题 - - sync面试题 +tags: [go, golang, interview, sync-questions] +create time: 2026-06-07 14:30 --- -# Sync面试题 -## 1. 除了 mutex 以外还有那些方式安全读写共享变量? +# Sync 面试题 🔒 -除了Mutex,主要还有**信号量**、**通道(Channel),原子操作(atomic)**这几种方式。 +## 概述 -信号量的实现其实跟mutex差不多,实现起来也很方便,主要通过信号量计数来保证。chanenl是Go最推崇的方式,它通过通信来传递数据所有权,从根源上避免竞争,更适合复杂的业务逻辑;而原子操作则针对最简单的整型或指针等进行无锁操作,性能最高,常用于实现计数器或状态位。选择哪种,完全取决于数据结构的复杂度和业务的读写模型。 +本文件涵盖 Go `sync` 包的 13 道高频面试题,涉及 Mutex、Once、WaitGroup、sync.Map 等同步原语的原理与应用。这些是并发编程中保证数据一致性的核心工具。 -## 2. Go 语言是如何实现原子操作的? +## 关联笔记 -Go语言实现原子操作,其根本是**依赖底层CPU硬件提供的原子指令**,而不是通过操作系统或更上层的锁机制。 +- [[hzh/GolangStar/Go语言进阶/Sync]] — Sync 包详细讲解 +- [[hzh/GolangStar/Go语言原理/sync.map原理]] — sync.Map 三表结构详解 +- [[hzh/GolangStar/Go面试题库/代码面试题]] — 同步场景实战代码 -具体来说,Go的`sync/atomic`包中的函数,在编译时会被编译器识别,并直接转换成对应目标硬件平台(如x86、ARM)的单条原子机器指令。例如,在x86架构上,`atomic.AddInt64`这类操作会对应到像`LOCK; ADD`这样的指令。前面的`LOCK`前缀是关键,它会锁住总线或缓存行,确保后续的`ADD`指令在执行期间,其他CPU核心不能访问这块内存,从而保证了整个操作的原子性。 +## 正文 -## 3. **聊聊原子操作和锁的区别?** +### Q1:除了 Mutex 还有哪些方式安全读写共享变量? 🟢简单 -原子操作和锁最核心的区别在于它们的**实现层级**和**保护范围**。 +## 参考答案 -**原子操作**是CPU硬件层面的“微观”机制,它保证对单个数据(通常是整型或指针)的单次读改写操作是绝对不可分割的,性能极高,因为它不涉及操作系统内核的介入和goroutine的挂起。 +| 方式 | 适用场景 | 性能 | +|------|---------|------| +| **Channel** | Go 推崇的方式,通过通信传递所有权 | 中 | +| **原子操作(atomic)** | 简单的整型/指针操作(计数器、状态位) | 最高 | +| **Mutex/RWMutex** | 保护复杂数据结构或多变量一致性 | 中 | +| **sync.Map** | 读多写少的并发 Map | 高(特定场景) | -**锁**则是操作系统或语言运行时提供的“宏观”机制,它保护的是一个**代码块**(临界区),而不仅仅是单个变量。当获取锁失败时,它会让goroutine休眠,而不是空耗CPU。虽然锁的开销远大于原子操作,但它能保护一段复杂的、涉及多个变量的业务逻辑。 +> [!tip] 💡 面试技巧 +> 选择策略:"简单计数用 atomic,复杂逻辑用 Mutex,读多写少用 sync.Map,跨 goroutine 通信用 Channel。" -所以,对于简单的计数器或标志位更新,用原子操作追求极致性能;而只要需要保护一段逻辑或多个变量的一致性,就必须用锁。 +--- -## 4. Go语言互斥锁mutex底层是怎么实现的? +### Q2-Q3:原子操作 vs 锁的区别? 🟡中等 -mutex底层是通过原子操作加信号量来实现的,通过atomic 包中的一些原子操作来实现锁的锁定,通过信号量来实现协程的阻塞与唤醒 +> [!question] ❓ 思考一下 +> 为什么原子操作比锁快?它们各自能保护什么范围的内容? -**分析** +## 参考答案 -互斥锁对应的是底层结构是sync.Mutex结构体 +| 维度 | 原子操作 | 锁 | +|------|---------|---| +| 实现层级 | CPU 硬件指令(如 LOCK ADD) | 语言运行时 + 操作系统 | +| 保护范围 | 单个变量的单次读写 | 一段代码块(临界区) | +| 性能 | 极高,无内核介入 | 较低,失败时 goroutine 挂起 | +| 适用场景 | 计数器、标志位 | 多变量一致性、复杂逻辑 | ```go -type Mutex struct { - state int32 - sema uint32 - } +// 原子操作:高性能计数器 +var count int64 +atomic.AddInt64(&count, 1) + +// 锁:保护多个变量的一致性 +mu.Lock() +balance -= amount +transactions = append(transactions, tx) +mu.Unlock() ``` -state表示锁的状态,有锁定、被唤醒、饥饿模式等,并且是用state的二进制位来标识的,不同模式下会有不同的处理方式 +> [!note] 📝 核心考点 +> 原子操作是"微观"的,锁是"宏观"的。原子操作不挂起 goroutine,锁失败时会 park。 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Sync面试题/0c8666c9f2a2a24045c7eb16c163a8e9.png) +--- -sema表示信号量,mutex阻塞队列的定位是通过这个变量来实现的,从而实现goroutine的阻塞和唤醒 +### Q4:Mutex 底层怎么实现的? 🟡中等 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Sync面试题/cacc64449f25f934740cbdf201affbf3.png) +## 参考答案 -## 5. Mutex 有几种模式? +Mutex 通过**原子操作 + 信号量**实现: -Go的`Mutex`主要有两种模式:**正常模式(Normal Mode)和饥饿模式(Starvation Mode)**。 +```go +type Mutex struct { + state int32 // 锁的状态(锁定/唤醒/饥饿模式等,用二进制位标识) + sema uint32 // 信号量,用于 goroutine 的阻塞和唤醒 +} +``` -1. **正常模式**:这是默认模式,讲究的是性能。新请求锁的goroutine会和等待队列头部的goroutine竞争,新来的goroutine有几次“自旋”的机会,如果在此期间锁被释放,它就可以直接抢到锁。这种方式吞吐量高,但可能会导致队列头部的goroutine等待很久,即“不公平”。 +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 与 Mutex 的关系 -2. **饥饿模式**:当一个goroutine在等待队列中等待超过1毫сан(1ms)后,Mutex就会切换到此模式,讲究的是公平。在此模式下,锁的所有权会直接从解锁的goroutine移交给等待队列的头部,新来的goroutine不会自旋,必须排到队尾。这样可以确保队列中的等待者不会被“饿死”。 +--- -当等待队列为空,或者一个goroutine拿到锁时发现它的等待时间小于1ms,饥饿模式就会结束,切换回正常模式。这两种模式的动态切换,是Go在性能和公平性之间做的精妙平衡。 +### Q5:Mutex 有几种模式? 🟡中等 -## 6. 在Mutex上自旋的goroutine 会占用太多资源吗 +## 参考答案 -并不会,因为Go的自旋设计得非常“克制”和“智能”。 +两种模式,动态切换: -首先,自旋不是无休止的空转,它有严格的次数和时间限制,通常只持续几十纳秒。其次,自旋仅仅在特定条件下才会发生,比如CPU核数大于1,并且当前机器不算繁忙(没有太多goroutine在排队)。它是在赌,与其付出“goroutine挂起和唤醒”这种涉及内核调度的巨大代价,不如原地“稍等一下”,因为锁可能马上就释放了。 +| 模式 | 特点 | 公平性 | +|------|------|--------| +| **正常模式** | 新来的 goroutine 可以和队列头竞争,吞吐量高 | 不公平 | +| **饥饿模式** | 等待超过 1ms 后进入,锁直接交给队头,新来者排尾部 | 绝对公平 | -所以,这种自旋是一种机会主义的短线优化,目的是用极小的CPU开销去避免一次昂贵的上下文切换,在锁竞争不激烈、占用时间极短的场景下,它反而是**节省**了资源。 +**切换规则:** +- 进入饥饿:等待时间 > 1ms +- 退出饥饿:等待队列空 或 等待时间 < 1ms -## 7. Mutex 已经被一个 Goroutine 获取了, 其它等待中的 Goroutine 们只能一直等待。那么等这个锁释放后,等待中的 Goroutine 中哪一个会优先获取 Mutex 呢? +> [!tip] 💡 面试技巧 +> "Go 在性能和公平之间做了精妙的平衡:正常情况下追求吞吐,发现有人被饿死时就切换到公平模式。" -取决于Mutex当前处于正常模式还是饥饿模式。 +--- -在**正常模式**下,锁的分配是“不公平”的。当锁被释放时,等待队列中的第一个goroutine会被唤醒,但它**不一定**能拿到锁。它需要和那些此刻刚刚到达、正在自旋的新goroutine进行竞争。新来的goroutine因为正在CPU上运行,很有可能“插队”成功,直接抢到锁。这种策略的优点是吞吐量高,但缺点是可能导致等待队列中的goroutine被饿死。 +### Q6:自旋的 goroutine 会占用太多资源吗? 🟡中等 -而一旦Mutex进入**饥饿模式**,锁的分配就变得“绝对公平”。锁被释放后,会直接移交给等待队列的队头goroutine,任何新来的goroutine都不会参与竞争,必须乖乖排到队尾。 +## 参考答案 -## 8. sync.Once 的作用是什么,讲讲它的底层实现原理? +**不会。** Go 的自旋设计非常克制: -`sync.Once`的作用是**确保一个函数在程序生命周期内,无论在多少个goroutine中被调用,都只会被执行一次**。它常用于单例对象的初始化或一些只需要执行一次的全局配置加载 +1. 只在特定条件下触发(CPU 核数 > 1 且排队 goroutine 不多) +2. 持续时间极短(几十纳秒) +3. 目的是避免更昂贵的上下文切换开销 -`sync.Once`保证代码段只执行1次的原理主要是其内部维护了一个标识位,当它 == 0 时表示还没执行过函数,此时会加锁修改标识位,然后执行对应函数。后续再执行时发现标识位 != 0,则不会再执行后续动作了 +> [!warning] ⚠️ 高频陷阱 +> 自旋是"机会主义的短线优化"——赌锁马上就释放了,与其花大代价挂起+唤醒 goroutine,不如原地稍等一下。 -**分析** +--- -Once其实是一个结构体 +### Q7:锁释放后哪个 goroutine 优先获取? 🟡中等 + +## 参考答案 + +取决于当前模式: + +| 模式 | 谁先拿到锁 | +|------|-----------| +| 正常模式 | 队头 goroutine **和新来的自旋 goroutine 竞争**,新来的可能插队 | +| 饥饿模式 | **队头 goroutine 直接获得**,新来者排尾部 | + +--- + +### Q8:sync.Once 的作用和底层原理? 🟡中等 + +> [!question] ❓ 思考一下 +> 如果两个 goroutine 同时调用 Once.Do(f),f 会被执行几次?如何实现这个保证? + +## 参考答案 + +**作用**:确保一个函数在整个程序生命周期内只执行一次,常用于单例初始化。 ```go type Once struct { - done uint32 // 标识位 + done uint32 // 标识位:0=未执行,1=已执行 m Mutex } - ``` -核心依赖一个`uint32`的`done`标志位和一个互斥锁`Mutex`, +**执行流程:** -当`Once.Do(f)`首次被调用时: +```mermaid +graph TD + A[Once.Do f] --> B{atomic.Load done == 1?} + B -->|是| C[直接返回, 无锁] + B -->|否| D[加锁 doSlow] + D --> E{再次检查 done == 1?} + E -->|是| F[解锁, 返回] + E -->|否| G[执行 f] + G --> H[atomic.Store done = 1] + H --> I[解锁] +``` -1. 它首先会通过原子操作(`atomic.LoadUint32`)快速检查`done`标志位。如果`done`为1,说明初始化已完成,直接返回,这个路径完全无锁,开销极小。 +> [!note] 📝 核心考点 +> **双重检查(Double-Checked Locking)**是关键:第一次用原子操作快速判断(无锁路径),第二次加锁后再判断防止重复执行。 -2. 如果`done`为0,说明可能是第一次调用,这时它会进入一个慢路径(`doSlow`)。 +--- -3. 在慢路径里,它会先**加锁**,然后**再次检查**`done`标志位。这个“双重检查”(Double-Checked Locking)是关键,它防止了在多个goroutine同时进入慢路径时,函数`f`被重复执行。 +### Q9:WaitGroup 怎样实现协程等待? 🟡中等 -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的结构定义: +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 noCopy // vet 静态检查防复制 + state atomic.Uint64 // 64位:高32位=计数器, 低32位=等待者数量 + sema uint32 // 信号量 } ``` -**`noCopy`**: 这是一个特殊的字段,用于静态分析工具(`go vet`)在编译时检查`WaitGroup`实例是否被复制。`WaitGroup`被复制后会导致状态不一致,可能引发程序错误,因此该字段的存在旨在防止此类问题的发生。 +**工作流程:** +1. `Add(n)`:计数器 += n +2. `Done()`:计数器 -= 1 +3. `Wait()`:计数器 != 0 则通过信号量挂起当前 goroutine +4. 最后一个 `Done()` 将计数器归零 → 信号量一次性唤醒所有等待者 -**`state`**: 这是`WaitGroup`的核心,一个64位的无符号整型,通过`sync/atomic`包进行原子操作,以保证并发安全。这个64位的空间被巧妙地分成了两部分: +> [!warning] ⚠️ 高频陷阱 +> WaitGroup **不能被复制**!一旦使用过就不能拷贝,否则会导致状态不一致。`noCopy` 字段让 `go vet` 能在编译期检测这个问题。 -* **高32位**: 作为**计数器(counter)**,记录了需要等待的 goroutine 的数量。 +--- -* **低32位**: 作为**等待者计数器(waiter count)**,记录了调用`Wait()`方法后被阻塞的 goroutine 的数量。 +### Q10:sync.Map 的底层原理? 🟡中等 -**`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`的结构定义 +核心思想:**空间换时间**,通过 read/dirty 双 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 + mu Mutex // 保护 dirty + read atomic.Value // 实际存储 readOnly + dirty map[interface{}]*entry // 需要加锁访问 + misses int // read 未命中次数 +} + +type readOnly struct { + m map[interface{}]*entry + amended bool // true 表示 dirty 中有 read 没有的数据 +} + +type entry struct { + p unsafe.Pointer // 指向真正的 value } ``` -`read`字段的类型是`atomic.Value`,但是在使用中里面其实存储的是`readOnly`结构,`readOnly`结构定义如下: +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/sync.map原理]] — 完整的三表结构图解 -```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`这个结构: +### Q11:read map 和 dirty map 的关联? 🟢简单 -```go -type entry struct { -    p unsafe.Pointer  // p指向真正的value所在的地址 -} -``` +## 参考答案 +| Map | 角色 | 特性 | +|-----|------|------| +| read | 只读缓存 | 无锁读取,可能是过期快照 | +| dirty | 最新全集 | 需加锁,包含所有最新数据 | +当 `misses == len(dirty)` 时,dirty 晋升为新的 read。 -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/Sync面试题/10a8fc3bc4fe8c3b2d23a0832aabb5f2.png) +--- -## 11. read map和dirty map之间有什么关联? +### Q12:为什么设计 nil 和 expunged 两种删除状态? 🟡中等 -它们之间是**“只读缓存”**和**“最新全集”**的关联。 +## 参考答案 -`read` map是`dirty` map的一个不完全、且可能是过期的只读快照。`dirty` map则包含了所有的最新数据。 +为了解决 **"如何在只读的 read map 上高效删除"** 的问题: -具体来说,`read` map中的所有数据,在`dirty` map里一定存在。一个key如果在`read` map里,那它的value要么就是最终值,要么就是一个特殊指针,指向`dirty` map里对应的条目。而`dirty` map里有,`read` map里却可能没有,因为`dirty`是最新、最全的。 +- **expunged**:逻辑删除标记。key 只存在于 read 中时,不能物理删除,标记为 expunged,读操作看到它就直接返回 `nil, false` +- **nil**:中间状态,用于 dirty 和 read 同步过程中表示 key 正在被删除或迁移 -当`dirty` map积累了足够多的新数据后,它会“晋升”为新的`read` map,旧的`read` map则被废弃。这个过程,就完成了“缓存”的更新。 +> [!tip] 💡 面试技巧 +> "这是典型的用状态标记换取无锁性能的设计。一次 Delete 不需要加锁复制整个 read map,而是打一个标记,延迟到 dirty 晋升时再做物理删除。" -## 12. 为什么要设计nil和expunged两种删除状态? +--- -设计`nil`和`expunged`这两个状态,是为了解决**在`sync.Map`的“读写分离”架构下,如何高效、无锁地处理“删除”操作**。 +### Q13:sync.Map 适用的场景? 🟢简单 -因为`read` map本身是只读的,我们不能直接从中删除一个key。所以,当用户调用`Delete`时,如果这个key只存在于`read` map中,系统并不会真的删除它,而是将它的值标记为一个特殊的“已删除”状态,这个状态就是`expunged`。后续的读操作如果看到这个`expunged`标记,就知道这个key其实已经不存在了,直接返回`nil, false`。 +## 参考答案 -而`nil`则是一个中间状态,主要用于`dirty` map和`read` map的同步过程,表示这个key正在被删除或迁移。 +**读多写少**的场景。期望流量在 read map 层被拦截,避免频繁加锁访问 dirty map。 -简单来说,这两个状态就像是在只读的`read` map上打的“逻辑删除”补丁。它避免了因为一次`Delete`操作就引发加锁和map的整体复制,把真正的物理删除延迟到了`dirty` map“晋升”为`read` map的那一刻,是典型的用状态标记来换取无锁性能的设计。 +> [!warning] ⚠️ 高频陷阱 +> 如果写操作过多,sync.Map 基本等价于一把互斥锁 + map,效率反而比普通 map + Mutex 更低。 -## 13. sync.Map 适用的场景? +## 关联笔记 -`sync.Map`适合读多写少的场景,而不是和写多读少的场景。 - -因为我们期望将更多的流量在read map这一层进行拦截,从而避免加锁访问dirty map -对于更新,删除,读取,read map可以尽量通过一些原子操作,让整个操作变得无锁化,这样就可以避免进一步加锁访问dirty map。倘若写操作过多,sync.Map 基本等价于一把互斥锁 + map,其读写效率会大大下降 +- [[hzh/GolangStar/Go语言进阶/Sync]] +- [[hzh/GolangStar/Go语言原理/sync.map原理]] +- [[hzh/GolangStar/Go面试题库/代码面试题]] diff --git a/hzh/GolangStar/Go面试题库/代码面试题.md b/hzh/GolangStar/Go面试题库/代码面试题.md index 1a373f1..f9be914 100644 --- a/hzh/GolangStar/Go面试题库/代码面试题.md +++ b/hzh/GolangStar/Go面试题库/代码面试题.md @@ -1,105 +1,112 @@ --- -tags: - - Go - - golang - - 代码面试题 +tags: [go, golang, interview, code-questions] +create time: 2026-06-07 14:30 --- -# 代码面试题 +# 编程算法面试题 💻 -## 1.开启100个协程,顺序打印1-1000,且保证协程号1的,打印尾数为1的数字 +## 概述 + +本文件涵盖 Go 并发编程场景下的 9 道代码实现题,涉及协程同步、通道协作、并发控制等实战场景。这些题目在面试中通常要求"现场手写代码",考察对 Channel、Mutex、WaitGroup 的综合运用能力。 + +> [!tip] 💡 使用建议 +> 每道题先自己思考解题思路,再对照答案学习。重点理解"为什么用这种方式"而非死记代码。 + +## 关联笔记 + +- [[hzh/GolangStar/Go面试题库/Channel面试题]] — Channel 原理 +- [[hzh/GolangStar/Go面试题库/Sync面试题]] — Mutex/WaitGroup 原理 +- [[hzh/GolangStar/Go面试题库/Context面试题]] — Context 超时控制 + +## 正文 + +### Q1:100 个协程顺序打印 1-1000 🟡中等 + +> [!question] ❓ 思考一下 +> 100 个协程同时运行,如何让它们按 1-1000 的顺序依次打印?你会用什么机制来协调它们的执行顺序? + +## 参考答案 + +**核心思路**:用 map 为每个协程分配独立的 channel,主循环按序发送信号到对应协程的 channel,通过阻塞保证顺序。 ```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 + s := make(chan struct{}) // 控制打印顺序的信号 + m := make(map[int]chan int, 100) // 每个协程的入口 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 + go func(id int) { + for { + num := <-m[id] // 等待属于自己的数字 + fmt.Println(num) + s <- struct{}{} // 打印完成,通知主循环 + } + }(i) } - time.Sleep(10 * time.Second) + for i := 1; i <= 1000; i++ { + id := i % 100 + if id == 0 { id = 100 } + m[id] <- i // 发送给对应协程 + <-s // 阻塞等待该协程打印完毕 + } } ``` -## 2.三个goroutinue交替打印abc 10次 +> [!note] 📝 解析思路 +> - **map + channel 组合**:每个协程独占一个 channel,主循环精确投递 +> - **信号量 s**:每次只发一个数,收到打印完成的信号才发下一个,保证严格顺序 +> - **面试官可能会追问**:"如果不用 map 怎么做?"→ 可以用 100 个独立声明的 channel 或用 `sync.Mutex` 保护共享输出 + +--- + +### Q2:三个 goroutine 交替打印 abc 10 次 🟡中等 + +## 参考答案 + +**核心思路**:三个 channel 串联成环形,a -> b -> c -> a。 ```go -package main - -import ( - "fmt" - "sync" -) - func main() { - // 定义3个channel - ch1 := make(chan struct{}) - ch2 := make(chan struct{}) - ch3 := make(chan struct{}) + ch1 := make(chan struct{}) // a → b + ch2 := make(chan struct{}) // b → c + ch3 := make(chan struct{}) // c → a var wg sync.WaitGroup wg.Add(3) - // 打印a + + // 打印 a go func() { - defer wg.Done() - for i := 0; i < 10; i++ { - <-ch1 - fmt.Println("a") - ch2 <- struct{}{} - } - // 第10次的时候,打印c的goroutine写入了ch1 - // 为了防止阻塞,要消费以下ch1 - <-ch1 + defer wg.Done() + for i := 0; i < 10; i++ { + <-ch1 + fmt.Print("a") + ch2 <- struct{}{} + } + <-ch1 // 消费最后一次信号 }() - // 打印b + + // 打印 b go func() { - defer wg.Done() - for i := 0; i < 10; i++ { - <-ch2 - fmt.Println("b") - ch3 <- struct{}{} - } + defer wg.Done() + for i := 0; i < 10; i++ { + <-ch2 + fmt.Print("b") + ch3 <- struct{}{} + } }() - // 打印c + + // 打印 c go func() { - defer wg.Done() - for i := 0; i < 10; i++ { - <-ch3 - fmt.Println("c") - ch1 <- struct{}{} - } + defer wg.Done() + for i := 0; i < 10; i++ { + <-ch3 + fmt.Print("c") + ch1 <- struct{}{} + } }() - // 启动 - ch1 <- struct{}{} + + ch1 <- struct{}{} // 启动信号 wg.Wait() close(ch1) close(ch2) @@ -108,366 +115,276 @@ func main() { } ``` -## 3.用不超过10个goroutine不重复的打印slice中的100个元素 +> [!warning] ⚠️ 高频陷阱 +> 第 10 次打印 c 后会给 ch1 发信号,但 a 的循环已经结束了。需要额外消费一次 ch1 防止阻塞。 + +> [!tip] 💡 面试技巧 +> 回答时可以画图展示三个 channel 的环形关系,然后说明"每次只有收到信号的 goroutine 才能执行,执行完给下一个发信号"。 + +--- + +### Q3:不超过 10 个 goroutine 不重复打印 slice 中的 100 个元素 🟡中等 + +## 参考答案 + +**方案一**:有缓冲 channel 控制并发度(无序) ```go -package main +ch := make(chan struct{}, 10) // 容量 10 = 最大并发数 +for i := 0; i < 100; i++ { + wg.Add(1) + ch <- struct{}{} // 写满 10 个后阻塞 + go func(idx int) { + defer wg.Done() + fmt.Printf("%d\n", ss[idx]) + <-ch // 打印完释放一个槽位 + }(i) +} +``` -import ( - "fmt" - "sync" -) +**方案二**:固定 10 个 goroutine 顺序打印 -// 用不超过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) +```go +hashMap := make(map[int]chan int) // 10 个 channel 对应 10 个 goroutine +sort := make(chan struct{}) // 控制顺序 - } - wg.Wait() - // 关闭channel - close(ch) - fmt.Println("end") +for i := 0; i < 10; i++ { + hashMap[i] = make(chan int) + go func(idx int) { + for val := range hashMap[idx] { + fmt.Printf("go %d: %d\n", idx, val) + sort <- struct{}{} // 打印完通知 + } + }(i) } -// 用不超过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 _, v := range ss { + id := v % 10 + hashMap[id] <- v + <-sort // 等当前打印完再发下一个 +} +``` + +> [!info] 🔗 延伸阅读 +> - 方案一适合不需要顺序的场景(吞吐量优先) +> - 方案二适合需要顺序的场景(可控性优先) + +--- + +### Q4:两个协程交替打印奇偶数 🟢简单 + +## 参考答案 + +```go +chan1 := make(chan struct{}) + +// 偶数协程 +go func() { 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) + chan1 <- struct{}{} // 发出信号 + if i%2 == 0 { + fmt.Println("偶数:", i) + } } - // 循环切片,对10取模,找到对应channel的key,写入值 - for _, v := range ss { - id := v % 10 - hashMap[id] <- v - // 有序 - <-sort +}() + +// 奇数协程 +go func() { + for i := 0; i < 10; i++ { + <-chan1 // 等待信号 + if i%2 == 1 { + fmt.Println("奇数:", i) + } } - // 循环结束关闭channel,删除map的key - for k, _ := range hashMap { - close(hashMap[k]) - delete(hashMap, k) - } - wg.Wait() - close(sort) - fmt.Println("end") -} +}() ``` -## 4.两个协程交替打印奇偶数 +--- + +### Q5:用单个 channel 实现 0,1 交替打印 🟢简单 + +## 参考答案 ```go -package main +msg := make(chan struct{}) -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): +go func() { + for { + <-msg // 接收信号 + fmt.Println("0") + msg <- struct{}{} // 回传信号 } -} +}() + +go func() { + for { + <-msg // 接收信号 + fmt.Println("1") + msg <- struct{}{} // 回传信号 + } +}() + +msg <- struct{}{} // 初始信号 ``` -## 5.用单个channel实现0,1的交替打印 +> [!tip] 💡 面试技巧 +> "用一个 channel 作为信号灯,0 和 1 各持一半——谁拿到灯谁就打印,打印完把灯放回 channel。" + +--- + +### Q6:sync.Cond 实现多生产者多消费者 🟡中等 + +## 参考答案 ```go -package main +var cond sync.Cond +cond.L = new(sync.Mutex) +msgCh := make(chan int, 5) -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) +// 生产者:缓冲区满时等待 +producer := func(ctx context.Context, out chan<- int, idx int) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + cond.Broadcast() + return + default: + cond.L.Lock() + for len(msgCh) == 5 { + cond.Wait() // 缓冲区满,等待 + } + out <- rand.Intn(500) + cond.Signal() // 唤醒一个消费者 + cond.L.Unlock() + } + } +} +// 消费者:缓冲区空时等待 +consumer := func(ctx context.Context, in <-chan int, idx int) { + defer wg.Done() + for { + select { + case <-ctx.Done(): + for len(msgCh) > 0 { /* 继续消费 */ } + return + default: + cond.L.Lock() + for len(msgCh) == 0 { + cond.Wait() // 缓冲区空,等待 + } + num := <-in + cond.Signal() // 唤醒一个生产者 + cond.L.Unlock() + } + } } ``` -## 6.sync.Cond实现多生产者多消费者 +> [!note] 📝 解析思路 +> `sync.Cond` 的核心是:**Lock + Wait/Signal/Broadcast**。Wait 会释放锁并挂起 goroutine,Signal 唤醒一个等待者,Broadcast 唤醒全部。适合"条件满足才执行"的场景。 + +--- + +### Q7:1000 个并发控制 + 1 秒超时 🟡中等 + +## 参考答案 ```go -package main +tasks := make(chan int, 1000) +ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) +defer cancel() +var wg sync.WaitGroup -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") +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 %d\n", id) + } + }(i) } + +<-ctx.Done() +fmt.Println("exec done") +close(tasks) +wg.Wait() ``` -## 7.使用go实现1000个并发控制并设置执行超时时间1秒 +> [!warning] ⚠️ 高频陷阱 +> 这段代码的缺陷是 1000 个 goroutine 几乎同时启动,1 秒内可能来不及全部执行完。实际场景中应限制并发数量(如用 semaphore channel)。 + +> [!tip] 💡 面试技巧 +> 如果面试官指出问题,可以回答:"可以用有缓冲 channel 做信号量控制并发数:`sem := make(chan struct{}, 10)`,获取/释放 sem 来控制最多 10 个并发。" + +--- + +### Q8:两个 Goroutine 交替打印字母与数字 a1b2c3 🟡中等 + +## 参考答案 ```go -package main +numCh := make(chan struct{}) // 通知打印数字 +strCh := make(chan struct{}) // 通知打印字母 +var wg sync.WaitGroup +wg.Add(2) -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) +// 打印字符 a-z +go func() { + defer wg.Done() + for i := 'a'; i <= 'z'; i++ { + fmt.Print(string(i)) + numCh <- struct{}{} // 通知打印数字 + <-strCh // 等待对方完成 } +}() - <-ctx.Done() - fmt.Println("exec done") - close(tasks) - wg.Wait() - fmt.Println("finish") -} +// 打印数字 1-26 +go func() { + defer wg.Done() + for i := 1; i <= 26; i++ { + <-numCh // 等待对方完成 + fmt.Print(i) + strCh <- struct{}{} // 通知打印字母 + } +}() + +wg.Wait() ``` -## 8.使用两个Goroutine,向标准输出中按顺序按顺序交替打出字母与数字,输出是a1b2c3 +--- + +### Q9:限制 10 个 goroutine 执行,执行完一个放一个新进来 🟢简单 + +## 参考答案 ```go -package main +ch := make(chan struct{}, 10) // 容量 10 = 并发上限 +var wg sync.WaitGroup -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") +for i := 0; i < 20; i++ { + wg.Add(1) + ch <- struct{}{} // 写满 10 个后自动阻塞 + go func(id int) { + defer wg.Done() + fmt.Printf("id: %d\n", id) + <-ch // 执行完释放一个槽位 + }(i) } +wg.Wait() ``` -## 9.编写一个程序限制10个goroutine执行,每执行完一个goroutine就放一个新的goroutine进来 +> [!tip] 💡 面试技巧 +> 这是最经典的**并发控制模式**——用有缓冲 channel 做信号量(Semaphore)。面试中如果只写出这个,说明掌握了基础;如果能补充"用 context 加超时"或"用 errgroup 处理错误",则是加分项。 -```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() - -} -``` +## 关联笔记 +- [[hzh/GolangStar/Go面试题库/Channel面试题]] +- [[hzh/GolangStar/Go面试题库/Sync面试题]] +- [[hzh/GolangStar/Go面试题库/Context面试题]] diff --git a/hzh/GolangStar/Go面试题库/内存管理面试题.md b/hzh/GolangStar/Go面试题库/内存管理面试题.md index e8318f6..f3847b2 100644 --- a/hzh/GolangStar/Go面试题库/内存管理面试题.md +++ b/hzh/GolangStar/Go面试题库/内存管理面试题.md @@ -1,90 +1,164 @@ --- -tags: - - Go - - golang - - 内存管理 - - 面试题 - - 内存管理面试题 +tags: [go, golang, interview, memory-management-questions] +create time: 2026-06-07 14:30 --- -# 内存管理面试题 +# 内存管理面试题 💾 -## 1. 讲讲Go语言是如何分配内存的? +## 概述 -Go语言的内存分配采用了**TCMalloc算法**的改进版本,核心是分级分配和本地缓存。 +本文件涵盖 Go 内存管理的 7 道高频面试题,涉及分配器架构、逃逸分析、内存泄漏场景和定位优化方法。理解内存管理是写出高性能 Go 代码的关键。 -**分配器架构**:Go内存分配有三个层级:**mcache(线程缓存)、mcentral(中央缓存)、mheap(页堆)**。每个P都有独立的mcache,避免了锁竞争;mcentral按对象大小分类管理;mheap负责从操作系统申请大块内存。 +## 关联笔记 -**对象分类分配**:根据对象大小分为三类处理: +- [[hzh/GolangStar/Go语言原理/内存管理]] — mheap/mspan/mcentral/mcache 详解 +- [[hzh/GolangStar/Go语言原理/逃逸分析]] — 栈 vs 堆分配决策 +- [[hzh/GolangStar/Go面试题库/Slice面试题]] — Slice 共享底层数组 +- [[hzh/GolangStar/Go面试题库/垃圾回收面试题]] — GC 与内存的关系 -* **微小对象**(<16字节):在mcache的tiny分配器中分配,多个微小对象可以共享一个内存块 +## 正文 -* **小对象**(16字节-32KB):通过size class机制,预定义了67种大小规格,优先从P的mcache对应的mspan中分配,如果 mcache 没有内存,则从 mcentral 获取,如果 mcentral 也没有,则向 mheap 申请,如果 mheap 也没有,则从操作系统申请内存。 +### Q1:Go 语言如何分配内存? 🟡中等 -* **大对象**(>32KB):直接从mheap分配,跨越多个页面 +> [!question] ❓ 思考一下 +> 如果每次内存分配都要向操作系统申请,性能会非常低。你会怎么设计一个高效的内存分配器? -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/内存管理面试题/image.png) +## 参考答案 -## 2. 知道 golang 的内存逃逸吗?什么情况下会发生内存逃逸? +Go 采用 **TCMalloc 改进版**,核心是**分级分配 + 本地缓存**。 -内存逃逸是编译器在程序编译时期根据逃逸分析策略,将原本应该分配到栈上的对象分配到堆上的一个过程 +**三级架构:** -**主要逃逸场景**: +```mermaid +graph LR + P["每个 P 的 mcache"] -->|"对象不够时"| MC["mcentral 中央缓存"] + MC -->|"分类不够时"| MH["mheap 页堆"] + MH -->|"页不够时"| OS["操作系统"] + + style P fill:#4CAF50,color:#fff + style MC fill:#2196F3,color:#fff + style MH fill:#FF9800,color:#fff + style OS fill:#9C27B0,color:#fff +``` -* **返回局部变量指针**:函数返回内部变量的地址,变量必须逃逸到堆上 +| 层级 | 职责 | 特点 | +|------|------|------| +| **mcache** | 线程级缓存 | 每个 P 独立,无锁分配 | +| **mcentral** | 中央缓存 | 按大小分类管理 | +| **mheap** | 页堆 | 从 OS 申请大块内存 | -* **interface{}类型**:传递给interface{}参数的具体类型会逃逸,因为需要运行时类型信息 +**对象分类分配策略:** -* **闭包引用外部变量**:被闭包捕获的变量会逃逸到堆上 +| 对象大小 | 处理方式 | +|---------|---------| +| < 16 字节(微小) | tiny allocator 中分配,多个共享一个内存块 | +| 16 ~ 32KB(小对象) | size class 机制(67 种规格),mcache → mcentral → mheap → OS | +| > 32KB(大对象) | 直接从 mheap 分配,跨越多个页面 | -* **切片/map动态扩容**:当容量超出编译期确定范围时会逃逸 +> [!tip] 💡 面试技巧 +> "Go 的分配器就像一个三层仓库:mcache 是个人抽屉(最快),mcentral 是部门柜(按尺寸分类),mheap 是大库房(从 OS 进货)。大部分分配在抽屉里就完成了。" -* **大对象**:超过栈大小限制的对象直接分配到堆上 +--- -## 3. **内存逃逸有什么影响?** +### Q2:什么是内存逃逸?什么情况下会发生? 🟡中等 -因为堆对象需要垃圾回收机制来释放内存,栈对象会跟随函数结束被编译器回收,所以大量的内存逃逸会给gc带来压力 +## 参考答案 -## 4. Channel是分配在栈上,还是堆上? +内存逃逸是编译器在编译期根据**逃逸分析策略**,将原本应该分配到栈上的对象分配到堆上的过程。 -channel分配在堆上,Channel 被设计用来实现协程间通信的组件,其作用域和生命周期不可能仅限于某个函数内部,所以 一般情况下golang 直接将其分配在堆上 +**主要逃逸场景:** -## 5. Go语言在什么情况下会发生内存泄漏? +| 场景 | 原因 | +|------|------| +| 返回局部变量指针 | 变量离开作用域后仍被外部引用 | +| `interface{}` 传递 | 需要运行时类型信息 | +| 闭包引用外部变量 | 闭包生命周期可能超出函数 | +| 切片/map 动态扩容 | 容量超出编译期确定范围 | +| 大对象 | 超过栈大小限制 | -以下是一些内存泄漏的场景场景: +> [!note] 📝 核心考点 +> 逃逸不是坏事!它保证了程序的正确性。但过度逃逸会增加 GC 压力,影响性能。 -**goroutine泄漏**:这是最常见的泄漏场景。goroutine没有正常退出会一直占用内存,比如从channel读取数据但channel永远不会有数据写入,或者死循环没有退出条件。我在项目中遇到过,启动了处理任务的goroutine但没有合适的退出机制,导致随着请求增加goroutine越来越多。 +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/逃逸分析]] — 详细的逃逸分析规则 -**channel泄漏**:未关闭的channel和等待channel的goroutine会相互持有引用。比如生产者已经结束但没有关闭channel,消费者goroutine会一直阻塞等待,造成内存无法回收。 +--- -**slice引用大数组**:当slice引用一个大数组的小部分时,整个底层数组都无法被GC回收。解决方法是使用copy创建新的slice。 +### Q3:内存逃逸有什么影响? 🟢简单 -**map元素过多**:map中删除元素只是标记删除,底层bucket不会缩减。如果map曾经很大后来元素减少,内存占用仍然很高。 +## 参考答案 -**定时器未停止**:`time.After`或`time.NewTimer`创建的定时器如果不手动停止,会在heap中持续存在。 +| 分配位置 | 回收方式 | GC 影响 | +|---------|---------|--------| +| **栈** | 函数返回时自动回收 | 无 GC 开销 | +| **堆** | GC 定期扫描回收 | 有 GC 开销 | -**循环引用**:虽然Go的GC能处理循环引用,但在某些复杂场景下仍可能出现问题。 +大量内存逃逸会给 GC 带来压力,增加 CPU 占用和 STW 时间。 -## 6. Go语言发生了内存泄漏如何定位和优化? +--- -**定位工具**: +### Q4:Channel 分配在栈上还是堆上? 🟢简单 -* **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的生命周期和阻塞情况 +**通常分配在堆上。** Channel 用于协程间通信,其作用域和生命周期不可能仅限于某个函数内部,因此 Go 直接将其分配在堆上。 -* **runtime统计**:通过`runtime.ReadMemStats()`监控内存使用趋势,`runtime.NumGoroutine()`监控协程数量 +--- -**定位方法**:我通常先看内存增长曲线,如果内存持续上涨不回收,就用pprof分析哪个函数分配内存最多。如果是goroutine泄漏,会看到goroutine数量异常增长,然后分析这些goroutine阻塞在哪里。 +### Q5:Go 在什么情况下会发生内存泄漏? 🟡中等 -**常见优化手段**: +> [!question] ❓ 思考一下 +> Go 有 GC 为什么还会内存泄漏?这和 Java/Python 的内存泄漏有什么区别? -* **goroutine泄漏**:使用context设置超时,确保goroutine有退出机制,避免无限阻塞 +## 参考答案 -* **channel泄漏**:及时关闭channel,使用select+default避免阻塞 +虽然 Go 有 GC,但仍然可能发生内存泄漏——GC 只能回收**不可达**的对象,如果对象仍然可达但业务上已不再使用,就是泄漏。 -* **slice引用优化**:对大数组的小slice使用copy创建独立副本 +| 泄漏场景 | 原因 | 解决方案 | +|---------|------|---------| +| **goroutine 泄漏** | goroutine 没有正常退出 | 用 context 设置超时 | +| **channel 泄漏** | 未关闭的 channel + 等待的 goroutine | 及时关闭 channel | +| **slice 引用大数组** | slice 只取小部分但持有整个底层数组 | copy 创建新 slice | +| **map 元素过多** | delete 只标记不收缩 bucket | 重建 map | +| **定时器未停止** | time.After/NewTimer 未 Stop | 手动调用 timer.Stop() | -* **定时器清理**:手动调用`timer.Stop()`释放资源 +> [!warning] ⚠️ 高频陷阱 +> slice 引用大数组是最隐蔽的泄漏之一。当你从一个大数组中截取一个小 slice 时,整个底层数组都无法被 GC 回收。解决方法:`copy(newSlice, oldSlice)` 创建独立的副本。 -7. +--- + +### Q6:内存泄漏如何定位和优化? 🟡中等 + +## 参考答案 + +#### 定位工具 + +| 工具 | 用途 | +|------|------| +| `pprof` heap | 分析堆内存分布 | +| `pprof` goroutine | 分析 goroutine 泄漏 | +| `trace` | 查看 goroutine 生命周期和阻塞 | +| `runtime.ReadMemStats()` | 监控内存使用趋势 | +| `runtime.NumGoroutine()` | 监控协程数量 | + +#### 常用流程 + +```bash +# 1. 启动 pprof HTTP 端点 +import _ "net/http/pprof" + +# 2. 分析堆内存 +go tool pprof http://localhost:port/debug/pprof/heap + +# 3. 分析 goroutine +go tool pprof http://localhost:port/debug/pprof/goroutine +``` + +> [!tip] 💡 面试技巧 +> 回答时可以描述实际排查流程:"先看内存增长曲线 -> 用 pprof 分析哪个函数分配最多 -> 如果是 goroutine 泄漏看阻塞位置 -> 针对性修复。" + +## 关联笔记 + +- [[hzh/GolangStar/Go语言原理/内存管理]] +- [[hzh/GolangStar/Go语言原理/逃逸分析]] +- [[hzh/GolangStar/Go面试题库/垃圾回收面试题]] diff --git a/hzh/GolangStar/Go面试题库/反射面试题.md b/hzh/GolangStar/Go面试题库/反射面试题.md index 0ff917e..6be23fc 100644 --- a/hzh/GolangStar/Go面试题库/反射面试题.md +++ b/hzh/GolangStar/Go面试题库/反射面试题.md @@ -1,38 +1,97 @@ --- -tags: - - Go - - golang - - 反射 - - 面试题 - - 反射面试题 +tags: [go, golang, interview, reflection-questions] +create time: 2026-06-07 14:30 --- -# 反射面试题 +# 反射面试题 🔮 -## 1. 什么是反射? +## 概述 -反射是指计算机程序在运行时(Run time)可以访问、检测和修改它本身状态或行为的一种能力。用比喻来说,反射就是程序在运行的时候能够“观察”并且修改自己的行为。 +本文件涵盖 Go 反射的 4 道高频面试题,涉及反射概念、实现原理和实际应用场景。反射是 Go 的高级特性,理解它对于深入掌握标准库(如 encoding/json)的工作原理至关重要。 -## 2. Go语言如何实现反射? +## 关联笔记 -Go语言反射是通过接口来实现的,一个接口变量包含两个指针结构:一个指针指向**类型信息**,另一个指针指向**实际的数据**。当我们将一个具体类型的变量赋值给一个接口时,Go就会把这个变量的类型信息和数据地址都存到这个接口变量里。 +- [[hzh/GolangStar/Go语言进阶/反射]] — 反射详细讲解 +- [[hzh/GolangStar/Go语言基础/Go语言接口]] — 反射与 Interface 的关系 +- [[hzh/GolangStar/Go面试题库/Interface面试题]] — eface/iface 底层结构 -有了这个前提,Go语言就可以通过再由`reflect`包的`Type`和`ValueOf`这两个函数读取接口变量里的类型信息和数据信息。把这些内部信息“解包”成可供我们检查和操作的对象,完成在运行时对程序本身的动态访问和修改 +## 正文 -## 3. Go语言中的反射应用有哪些 +### Q1:什么是反射? 🟢简单 -**JSON序列化是最常见的应用**,像`encoding/json`包通过反射动态获取结构体字段信息,实现任意类型的序列化和反序列化。这也是为什么我们能直接用`json.Marshal`处理各种自定义结构体的原因。 +## 参考答案 -**ORM框架是另一个重点应用**,比如GORM通过反射分析结构体字段,自动生成SQL语句和字段映射。它能动态读取struct tag来确定数据库字段名、约束等信息,大大简化了数据库操作。 +反射是指计算机程序在**运行时**可以访问、检测和修改自身状态或行为的一种能力。 -**Web框架的参数绑定也大量使用反射**,像Gin框架的`ShouldBind`方法,能够根据请求类型自动将HTTP参数绑定到结构体字段上,这背后就是通过反射实现的类型转换和赋值。 +> [!tip] 💡 通俗理解 +> "反射就是程序在运行的时候能够'观察'并且修改自己的行为。"就像一个人能在镜子中看到自己并做出调整一样。 -**还有配置文件解析、RPC调用、测试框架等场景**。比如Viper配置库用反射将配置映射到结构体,gRPC通过反射实现服务注册和方法调用。 +--- -## 4. 如何比较两个对象完全相同 +### Q2:Go 语言如何实现反射? 🟡中等 -**最直接的是用reflect.DeepEqual**,这是标准库提供的深度比relatively方法,能递归比较结构体、切片、map等复合类型的所有字段和元素。比如`reflect.DeepEqual(obj1, obj2)`,它会逐层比较内部所有数据,包括指针指向的值。 +> [!question] ❓ 思考一下 +> Go 的变量在编译期有确定的类型,但 interface{} 可以存储任意类型。reflect 是怎么从 interface{} 中还原出类型的? -**对于简单类型可以直接用==操作符**,但这只适合基本类型、数组、结构体等可比较类型。需要注意slice、map、function这些类型是不能直接用==比较的,会编译报错。 +## 参考答案 -**实际项目中更推荐自定义Equal方法**,根据业务需求定义相等的标准。比如对于用户对象,可能只需要比较ID和关键字段,而不需要比较时间戳这种辅助字段。这样既提高了性能,又符合业务语义。 +Go 反射通过 **interface -> reflect.Type + reflect.Value** 实现: + +``` +具体类型变量 → 赋值给 interface{} → reflect.TypeOf()/ValueOf() → 提取类型信息和数据 +``` + +当将具体类型变量赋值给接口时,Go 会把**类型信息**和**数据地址**都存入接口变量。`reflect` 包通过 `TypeOf` 和 `ValueOf` 读取这些信息,完成运行时动态访问。 + +> [!note] 📝 核心考点 +> 反射的本质:**接口存储了类型信息 + 数据指针,reflect 把它们解包出来**。这依赖于之前学过的 eface 结构。 + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/interface原理]] — eface 的内部结构 +> - [[hzh/GolangStar/Go语言进阶/反射]] — TypeOf / Kind / setXX 权限 + +--- + +### Q3:Go 语言中的反射应用有哪些? 🟢简单 + +## 参考答案 + +| 应用 | 说明 | +|------|------| +| **JSON 序列化** | `encoding/json` 通过反射获取结构体字段信息 | +| **ORM 框架** | GORM 通过反射分析 struct tag 生成 SQL | +| **Web 框架参数绑定** | Gin `ShouldBind` 自动将 HTTP 参数映射到结构体 | +| **配置文件解析** | Viper 用反射将配置映射到结构体 | +| **RPC 调用** | gRPC 通过反射实现服务注册和方法调用 | +| **测试框架** | 自动发现和执行测试用例 | + +> [!tip] 💡 面试技巧 +> "几乎 Go 标准库中所有处理"未知类型"的地方都用到了反射。最典型的例子就是 json.Marshal——你传给它任何结构体,它都能正确序列化。" + +--- + +### Q4:如何比较两个对象完全相同? 🟢简单 + +## 参考答案 + +| 方法 | 适用场景 | 说明 | +|------|---------|------| +| `reflect.DeepEqual` | 复合类型深度比较 | 递归比较所有字段和元素 | +| `==` 操作符 | 基本类型、数组、可比较结构体 | 编译期保证安全 | +| 自定义 Equal 方法 | 业务语义相等 | 性能最好,符合业务逻辑 | + +```go +// reflect.DeepEqual 深度比较 +reflect.DeepEqual(obj1, obj2) + +// 注意:slice、map、function 不能用 == 比较 +``` + +> [!warning] ⚠️ 高频陷阱 +> `reflect.DeepEqual` 虽然方便,但在性能敏感的场景下不如自定义 Equal 方法。对于用户对象等,建议只比较关键字段(如 ID)。 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言进阶/反射]] +- [[hzh/GolangStar/Go语言基础/Go语言接口]] +- [[hzh/GolangStar/Go面试题库/Interface面试题]] diff --git a/hzh/GolangStar/Go面试题库/垃圾回收面试题.md b/hzh/GolangStar/Go面试题库/垃圾回收面试题.md index 453e7b9..0fb63a1 100644 --- a/hzh/GolangStar/Go面试题库/垃圾回收面试题.md +++ b/hzh/GolangStar/Go面试题库/垃圾回收面试题.md @@ -1,373 +1,265 @@ --- -tags: - - Go - - golang - - 垃圾回收 - - 面试题 - - 垃圾回收面试题 +tags: [go, golang, interview, gc-questions] +create time: 2026-06-07 14:30 --- -# 垃圾回收面试题 +# 垃圾回收面试题 ♻️ -## 1. 常见的 GC 实现方式有哪些? +## 概述 -所有的 GC 算法其存在形式可以归结为追踪(Tracing)GC和引用计数(Reference Counting)这两种形式的混合运用。 +本文件涵盖 Go GC(Garbage Collection)的 14 道高频面试题,涉及 GC 算法、三色标记、写屏障、STW、调优等核心概念。GC 是 Go 运行时的重要组成部分,理解它对于编写高性能程序至关重要。 -目前比较常见的实现方式有: +## 关联笔记 -* 标记清扫:从根对象出发,将确定存活的对象进行标记,并清扫可以回收的对象。 +- [[hzh/GolangStar/Go语言原理/垃圾回收]] — GC 源码级分析 +- [[hzh/GolangStar/Go语言原理/内存管理]] — 分配器与 GC 的关系 +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] — 内存泄漏场景 -* 标记整理:为了解决内存碎片问题而提出,在标记过程中,将对象尽可能整理到一块连续的内存上。 +## 正文 -* 增量式:将标记与清扫的过程分批执行,每次执行很小的部分,从而增量的推进垃圾回收,达到近似实时、几乎无停顿的目的。 +### Q1:常见的 GC 实现方式有哪些? 🟡中等 -* 增量整理:在增量式的基础上,增加对对象的整理过程。 +## 参考答案 -* 分代式:将对象根据存活时间的长短进行分类,存活时间小于某个值的为年轻代,存活时间大于某个值的为老年代,永远不会参与回收的对象为永久代。并根据分代假设(如果一个对象存活时间不长则倾向于被回收,如果一个对象已经存活很长时间则倾向于存活更长时间)对对象进行回收。 +| 算法 | 特点 | +|------|------| +| **标记清扫** | 从根对象出发标记存活对象,清扫不可达对象 | +| **标记整理** | 在标记过程中整理对象到连续内存,解决碎片 | +| **增量式** | 将标记和清扫分批执行,降低停顿时间 | +| **分代式** | 按存活时间分年轻代/老年代/永久代 | +| **引用计数** | 引用归零时立即回收,但有循环引用问题 | -* 引用计数:根据对象自身的引用计数来回收,当引用计数归零时立即回收。 +> [!note] 📝 核心考点 +> Go 采用的是:**无分代 + 不整理 + 并发 + 三色标记清扫**。面试时可以对比 Java 的分代 GC 来说明 Go 的设计取舍。 -## 2. Go 语言的 GC 使用的是什么? +--- -Go 的 GC 目前使用的是无分代(对象没有代际之分)、不整理(回收过程中不对对象进行移动与整理)、并发(与用户代码并发执行)的三色标记清扫算法。 +### Q2:Go 的 GC 使用的是什么算法? 🟢简单 -## 3. 三色标记法是什么? +## 参考答案 -三色标记法是Go垃圾回收器使用的核心算法 +**无分代、不整理、并发的三色标记清扫算法。** -**三色定义**: +> [!tip] 💡 面试技巧 +> 三个关键词要记住:"无分代"(不做年龄分类)、"不整理"(不移动对象)、"并发"(和用户代码同时运行)。 -* **白色**:未被访问的对象,垃圾回收结束后白色对象会被清理 +--- -* **灰色**:已被访问但其引用对象还未完全扫描的对象,是待处理队列 +### Q3:三色标记法是什么? 🟡中等 -* **黑色**:已被访问且其所有引用对象都已扫描完成的对象,确认存活 +> [!question] ❓ 思考一下 +> 如果 GC 扫描和程序运行同时进行,可能会出现什么情况导致错误回收对象? -**标记流程**: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 +```mermaid +graph LR + A[全部白色] --> B[根对象标为灰色] + B --> C{灰色队列空?} + C -->|否| D[取出灰色对象] + D --> E[扫描其引用的对象] + E --> F{引用是白色?} + F -->|是| G[标为灰色] + F -->|否| H[不变色] + E --> I[当前对象标为黑色] + I --> C + C -->|是| J[标记完成: 黑色存活, 白色回收] + + style A fill:#FFFFFF + style B fill:#FF9800,color:#fff + style I fill:#4CAF50,color:#fff + style J fill:#F44336,color:#fff ``` -方式四:`runtime.ReadMemStats` +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/垃圾回收]] — 完整的 GC 流程图解 -除了使用 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() - (...) -} -``` +### Q4:Go 语言 GC 的根对象是什么? 🟢简单 -```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 -``` +根对象(Root Set)包括三类: +| 类型 | 说明 | +|------|------| +| **全局变量** | 编译期确定的、贯穿程序生命周期的变量 | +| **执行栈** | 每个 goroutine 的栈上变量及指向堆的指针 | +| **寄存器** | 可能表示堆内存指针的寄存器值 | + +--- + +### Q5:STW 是什么意思? 🟢简单 + +## 参考答案 + +STW = **Stop the World**,指用户代码被完全停止运行的阶段。STW 越长,对延迟的影响越大。 + +早期 Go 的 STW 长达几百毫秒,现代 Go 已优化到**微秒级**。 + +> [!warning] ⚠️ 高频陷阱 +> 虽然大部分 GC 工作是并发的,但仍有两个 STW 阶段:**MarkTermination**(标记终止)和 **SweepTermination**(清扫终止),这两个阶段的 STW 时间需要严格控制。 + +--- + +### Q6:并发标记清除法的难点? 🟡中等 + +> [!question] ❓ 思考一下 +> 如果 GC 在标记黑色对象 C 的子节点时,用户程序并发地将 C 的引用从白色对象 B 改到了另一个白色对象 D,会发生什么? + +## 参考答案 + +核心难点:**如何在用户程序并发修改引用时保证 GC 正确性。** + +**两大问题:** + +| 问题 | 描述 | +|------|------| +| **对象消失** | 黑色对象到白色对象的引用被删除,同时灰色到白色的引用也被删除,白色对象被错误回收 | +| **新对象处理** | 标记期间新分配的对象如何着色?标白可能误回收,标黑可能造成浮动垃圾 | + +> [!warning] ⚠️ 高频陷阱 +> "对象消失"是并发 GC 最经典的问题。黑色对象不再被重新扫描,如果它与白色对象之间的唯一路径被切断,这个白色对象就会被误回收。 + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/垃圾回收]] — 对象消失问题的详细时序分析 + +--- + +### Q7:Go 如何解决并发标记时的引用修改问题? 🟡中等 + +## 参考答案 + +通过**写屏障技术**和**三色不变性维护**: + +**混合写屏障**(Go 1.8+): + +| 操作 | 处理方式 | +|------|---------| +| 新建引用 | 将目标对象着为**灰色** | +| 删除引用 | 将被删对象标为**灰色** | +| 栈上新建对象 | 默认标记为**黑色**(无需重扫) | + +**弱三色不变性**:允许黑色对象指向白色对象,但要保证从白色对象出发存在全灰色路径可达根对象。 + +> [!tip] 💡 面试技巧 +> "写屏障就像在每次指针赋值时安了一个监控摄像头——只要有人改了引用关系,就立刻把相关对象重新检查一遍,确保不会漏掉任何可达对象。" + +--- + +### Q8:什么是写屏障、混合写屏障? 🟡中等 + +## 参考答案 + +写屏障是在编译器指针赋值操作中插入的额外短指令: + +| 类型 | 时机 | 处理方式 | 局限 | +|------|------|---------|------| +| **插入写屏障** (Dijkstra) | 建立新引用时 | 目标标灰 | 删除引用时无保护 | +| **删除写屏障** (Yuasa) | 删除引用时 | 原对象标灰 | 新建引用时无保护 | +| **混合写屏障** (Go 1.8+) | 两者都处理 | 建立→目标标灰;删除→原对象标灰 | 需特殊处理栈操作 | + +> [!note] 📝 核心考点 +> 混合写屏障的关键优化:**标记阶段创建的栈上新对象默认标记为黑色**,因此不需要 STW 重扫栈——因为栈本身就是黑色的,它指向的对象必然是可达的。 + +--- + +### Q9:Go GC 的流程? 🟡中等 + +## 参考答案 + +| 阶段 | 说明 | 状态 | +|------|------|------| +| SweepTermination | 清扫终止,为下一轮做准备 | STW | +| Mark | 扫描标记,与用户代码并发 | 并发 | +| MarkTermination | 标记终止,保证周期内完成 | STW | +| GC off | 内存清扫,归还可回收内存 | 并发 | +| GC off | 内存归还,释放多余内存给 OS | 并发 | + +--- + +### Q10:GC 触发的时机? 🟢简单 + +## 参考答案 + +| 触发方式 | 条件 | +|---------|------| +| **主动触发** | `runtime.GC()` 阻塞式等待完成 | +| **被动触发** | ① 超过 2 分钟没有 GC → 强制触发 | +| | ② 内存增长达到阈值(GOGC,默认 100%) | + +第一次 GC 的触发临界值是 **4MB**。 + +> [!tip] 💡 面试技巧 +> "GOGC=100 意味着当当前堆大小超过上次 GC 后标记堆大小的 100% 时触发。可以通过 `debug.SetGCPercent(500)` 调整。" + +--- + +### Q11:GC 关注的指标? 🟢简单 + +## 参考答案 + +| 指标 | 说明 | +|------|------| +| CPU 利用率 | GC 占用多少 CPU | +| GC 停顿时间 | STW 和 Mark Assist 造成的停顿 | +| GC 停顿频率 | 多久触发一次 GC | +| GC 可扩展性 | 堆变大时性能如何衰减 | + +--- + +### Q12:有了 GC 为什么还会发生内存泄漏? 🟢简单 + +## 参考答案 + +GC 只能回收**不可达**的对象。如果对象仍然被根对象引用但业务上已不再使用,就会泄漏: + +1. **被根对象持有**:局部变量被赋值到全局 map 中 +2. **goroutine 泄漏**:goroutine 不能正常退出,持有的所有变量都无法回收 + +--- + +### Q13:Go 的 GC 如何调优? 🟡中等 + +## 参考答案 + +| 手段 | 说明 | +|------|------| +| **减少内存分配** | 使用 `sync.Pool` 复用对象 | +| **调整 GOGC** | 增大 GOGC 值减少 GC 频率 | +| **提高分配速度** | 合理化内存分配,提高 CPU 利用率 | + +--- + +### Q14:如何观察 Go GC? 🟡中等 + +## 参考答案 + +四种方式: + +| 方式 | 适用场景 | +|------|---------| +| `GODEBUG=gctrace=1` | 输出详细的 GC 日志 | +| `go tool trace` | 可视化展示 GC 和执行信息 | +| `debug.ReadGCStats` | 代码内监控 GC 状态 | +| `runtime.ReadMemStats` | 监控内存相关指标 | + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/垃圾回收]] — gctrace 日志的详细字段解析 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言原理/垃圾回收]] +- [[hzh/GolangStar/Go语言原理/内存管理]] +- [[hzh/GolangStar/Go面试题库/内存管理面试题]] diff --git a/hzh/GolangStar/Go面试题库/基础面试题.md b/hzh/GolangStar/Go面试题库/基础面试题.md index 193d7bf..6f4016b 100644 --- a/hzh/GolangStar/Go面试题库/基础面试题.md +++ b/hzh/GolangStar/Go面试题库/基础面试题.md @@ -1,459 +1,424 @@ --- -tags: - - Go - - golang - - go语言基础 - - 面试题 - - 基础面试题 +tags: [go, golang, interview, basic-questions] +create time: 2026-06-07 14:30 --- -# 基础面试题 +# 基础面试题 🧱 -## 1. 与其他语言相比,使用 Go 有什么好处? +## 概述 -* 与其他语言不同,Go 代码的设计是务实的,Go的语法更简洁。每个功能和语法决策都旨在让程序员的开发效率更高 +本文件涵盖 Go 语言基础语法的 22 道高频面试题,包括变量、类型、控制流、内存分配等核心概念。每道题都附有思考引导和面试答题技巧。 -* Golang 针对并发进行了优化,支持协程,并且实现了高效的GMP调度模型。 +> [!tip] 💡 使用建议 +> 先遮住答案自己思考,再对照解析学习。遇到不确定的题目,标记后在对应原理篇中寻找更深入的解释。 -* 由于单一的标准代码格式,Golang 通常被认为比其他语言更具可读性。 +## 关联笔记 -* 有高效的垃圾回收机制,支持并行垃圾回收,垃圾回收效率比比 Java 或 Python 更高 +- [[hzh/GolangStar/Go语言基础/Go语言变量]] — 变量声明与零值 +- [[hzh/GolangStar/Go语言基础/Go语言指针]] — new/make 区别 +- [[hzh/GolangStar/Go语言基础/Go语言defer]] — defer 执行顺序 +- [[hzh/GolangStar/Go语言进阶/反射]] — 反射机制 +- [[hzh/GolangStar/Go语言原理/逃逸分析]] — 栈 vs 堆分配 -## 2. 什么是协程? +## 正文 -协程是用户态轻量级线程,它是线程调度的基本单位。通常在函数前加上go关键字就能实现并发。一个Goroutine会以一个很小的栈启动2KB或4KB,当遇到栈空间不足时,栈会自动伸缩, 因此可以轻易实现成千上万个goroutine同时启动。 +### Q1:与其他语言相比,使用 Go 有什么好处? 🟢简单 -## 3. 协程和线程和进程的区别? +> [!question] ❓ 思考一下 +> 如果让你向面试官介绍 Go 的四大优势,你会怎么组织语言?试着用自己的话概括。 -* 进程:进程是具有一定独立功能的程序,进程是系统资源分配和调度的最小单位。 每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。 +## 参考答案 -* 线程:线程是进程的一个实体,线程是内核态,而且是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。 +1. **语法简洁务实**:每个功能设计都以提升开发效率为目标 +2. **原生并发支持**:Goroutine + Channel 实现高效的 GMP 调度模型 +3. **代码风格统一**:单一标准格式(`gofmt`),可读性高 +4. **高效垃圾回收**:支持并行 GC,效率优于 Java 或 Python -* 协程:协程是一种用户态的轻量级线程,协程的调度完全是由用户来控制的。协程拥有自己的寄存器上下文和栈。 协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。 +> [!tip] 💡 面试技巧 +> 回答时按照"语法 -> 并发 -> 工具链 -> 运行时"的逻辑展开,显得条理清晰。如果能提到"Go 的设计哲学是少即是多(less is more)",会给面试官留下深刻印象。 -## 4. Golang 中 make 和 new 的区别? +> [!note] 📝 核心考点 +> Go 的核心竞争力在于:**简洁语法 + 原生并发 + 快速编译 + 高效 GC**。面试官希望确认你理解 Go 的设计哲学,而非死记硬背特性。 -**回答:** +--- -`make` 和 `new` 都是用于内存分配的内建函数,但它们的使用场景和功能有所不同: +### Q2:什么是协程? 🟢简单 -1. **`make`**: +## 参考答案 - * 用于初始化并分配内存,只能用于创建 `slice`、`map` 和 `channel` 三种类型。 +协程是**用户态轻量级线程**,是线程调度的基本单位。通常在函数前加上 `go` 关键字即可实现并发。一个 Goroutine 以极小的栈启动(2KB 或 4KB),栈空间不足时自动伸缩,因此可以轻松启动成千上万个 Goroutine。 - * 返回的是初始化后的数据结构,而不是指针。 +> [!warning] ⚠️ 高频陷阱 +> Goroutine 不是操作系统线程!它是用户态线程,由 Go 运行时(runtime)调度,而不是由操作系统内核调度。这是 Goroutine 轻量级的根本原因。 -2. **`new`**: +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言进阶/Goroutine]] — Goroutine 详细讲解 +> - [[hzh/GolangStar/Go语言原理/gmp调度原理]] — GMP 调度模型 - * 用于分配内存,但不初始化,返回的是指向该内存的指针。 +--- - * 可以用于任何类型的内存分配。 +### Q3:协程和线程、进程的区别? 🟢简单 -**分析:** +## 参考答案 + +| 维度 | 进程 | 线程 | 协程(Goroutine) | +|------|------|------|------------------| +| 调度单位 | 系统资源分配最小单位 | CPU 调度基本单位 | 用户态调度 | +| 内存空间 | 独立内存空间 | 共享所属进程内存 | 独立栈,共享堆 | +| 切换开销 | 大(上下文复杂) | 中等(内核态切换) | 极小(用户态切换) | +| 通信方式 | IPC(管道/消息队列等) | 共享内存 | Channel / 共享变量 | +| 数量级 | 几十个 | 几百个 | 数十万到百万级 | + +> [!tip] 💡 面试技巧 +> 可以用一句话总结:"进程重、线程中、协程轻。Go 用极小的栈空间(2KB)+ 用户态调度 = 海量并发。" + +--- + +### Q4:Go 中 make 和 new 的区别? 🟢简单 + +> [!question] ❓ 思考一下 +> 如果你要创建一个长度为 5 的 slice,你会用 `make` 还是 `new`?为什么? + +## 参考答案 + +`make` 和 `new` 都是内存分配的内建函数,但使用场景不同: + +| | make | new | +|--|------|-----| +| 用途 | 初始化并分配内存 | 仅分配内存,不初始化 | +| 适用类型 | 仅限 `slice`、`map`、`channel` | 任何类型 | +| 返回值 | 初始化后的数据结构本身 | 指向零值的指针 | ```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 +s := make([]int, 5) // [0 0 0 0 0] + +// new:分配内存,返回 *int 指针 +p := new(int) // *p == 0 ``` -`make` 函数创建的是数据结构(`slice`、`map`、`channel`)本身,且返回初始化后的值。而`new` 函数创建的是可以指向任意类型的指针,返回指向未初始化零值的内存地址。 +> [!note] 📝 核心考点 +> `make` 返回的是**初始化后的值**(非指针),`new` 返回的是**指向零值的指针**。这是面试中最常混淆的点之一。 -## 5. Golang 中数组和切片的区别? +--- -**数组: ** +### Q5:Go 中数组和切片的区别? 🟡中等 -数组固定长度。数组长度是数组类型的一部分,所以\[3]int 和\[4]int 是两种不 同的数组类型数组需要指定大小,不指定也会根据初始化,自动推算出大小, 大小不可改变。数组是通过值传递的 -**切片: ** +## 参考答案 -切片可以改变长度。切片是轻量级的数据结构,三个属性,指针,长度,容量 不需要指定大小切片是地址传递(引用传递)可以通过数组来初始化,也可以通过内置函数 make()来初始化,初始化的时候 len=cap,然后进行扩容 - -**分析:** - -slice 的底层数据其实也是数组,slice 是对数组的封装,它描述一个数组的片段。slice 实际上是一个结构体,包含三个字段:长度、容量、底层数组。 +| | 数组 | 切片(Slice) | +|--|------|-------------| +| 长度 | 固定,是类型的一部分 | 可变长 | +| 传递方式 | 值传递 | 引用传递(地址传递) | +| 底层结构 | 连续内存块 | array 指针 + len + cap | +| 初始化 | 必须指定大小或推断 | 可用 `make()` 或数组截取 | ```go // runtime/slice.go type slice struct { - array unsafe.Pointer // 元素指针 - len int // 长度 - cap int // 容量 + array unsafe.Pointer // 元素指针 + len int // 长度 + cap int // 容量 } ``` -![](https://golangstar.cn/assets/img/go语言系列/go面试题库/基础面试题/image.png) +> [!warning] ⚠️ 高频陷阱 +> `[3]int` 和 `[4]int` 是两种不同的类型!因为数组的长度是类型的一部分。这意味着你不能将 `[3]int` 赋值给 `[4]int`。 +--- -## 6. 使用for range 的时候,它的地址会发生变化吗? +### Q6:for range 迭代变量的地址会变化吗? 🟡中等 -在Go1.22之前,对于 `for range` 循环中的迭代变量,其内存地址是不会发生变化的。但是,Go1.22之后的地址是临时的,是变化的,不一样的,不再是共享内存了 +> [!question] ❓ 思考一下 +> 在 for range 循环中取迭代变量的地址,存入 slice 后遍历,Go 1.22 前后行为会有什么不同? -**分析:** +## 参考答案 -Go1.22之前: +| Go 版本 | 行为 | 说明 | +|---------|------|------| +| Go 1.22 之前 | 地址不变 | 每次迭代复用同一块内存 | +| Go 1.22 及之后 | 地址变化 | 每次迭代生成新的迭代变量 | ```go +// Go 1.22 之前:所有元素的地址相同 +// Go 1.22 及之后:每个元素的地址不同 for index, value := range collection { - // ... + _ = &value // 地址行为因版本而异 } ``` -这里 `value` 是一个**副本**。在每次迭代中,`collection` 中的当前元素值会被**复制**到 `value` 这个变量中。Go 编译器通常会为 `value` 分配一块固定的内存地址,然后在每次迭代时,将当前元素的值覆盖到这块内存中。所以,当你打印 `&value` 时,你会发现它的内存地址在整个循环过程中都是保持不变的。 +> [!info] 🔗 延伸阅读 +> Go 1.22 改变了 for range 的语义,使迭代变量每次迭代都有独立的内存地址,避免了经典的"闭包捕获同一变量"陷阱。 -但是在Go1.23及以后,使用 `for range` 遍历一个集合时,**迭代变量的地址会发生变化**。这是因为 `for range` 每次迭代时都会重新生成迭代变量(如 `value`),这些变量在内存中是不同的地址 +--- -## 7. 如何高效地拼接字符串? +### Q7:如何高效地拼接字符串? 🟡中等 -拼接字符串的方式有:`+` , `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` +> [!question] ❓ 思考一下 +> 如果要拼接 10000 个字符串,你会选择哪种方法?为什么不用 `+`? -1. "+" +## 参考答案 -使用`+`操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。 +**性能排序**:`strings.Join ≈ strings.Builder > bytes.Buffer > "+" > fmt.Sprintf` -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种拼接方法的实例代码 +| 方法 | 特点 | 适用场景 | +|------|------|---------| +| `+` | 每次拼接都遍历+开辟新空间 | 少量字符串拼接 | +| `fmt.Sprintf` | 需要反射,有性能损耗 | 需要格式化的场景 | +| `strings.Builder` | 指针+切片,避免拷贝 | **大量字符串拼接推荐** | +| `bytes.Buffer` | 缓冲 byte 类型数据 | 字节级别操作 | +| `strings.Join` | 基于 Builder,可自定义分隔符 | 已知切片长度的场景 | ```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,"") +a := []string{"a", "b", "c"} + +// 推荐:strings.Join(已知长度,预分配容量) +ret := strings.Join(a, "") + +// 推荐:strings.Builder(未知长度) +var sb strings.Builder +for _, s := range a { + sb.WriteString(s) } +ret := sb.String() ``` -## 8. defer 的执行顺序是怎样的?defer 的作用或者使用场景是什么? +> [!tip] 💡 面试技巧 +> 如果面试官追问"strings.Join 为什么比 + 快",可以回答:"Join 提前计算总长度并一次性分配内存,而 + 每次都要重新分配。" -defer执行顺序和调用顺序相反,类似于栈后进先出(LIFO) +--- -defer 的作用是:当 defer 语句被执行时,跟在 defer 后面的函数会被延迟执行。直到 包含该 defer 语句的函数执行完毕时,defer 后的函数才会被执行,不论包含 defer 语句的函数是通过 return 正常结束,还是由于 panic 导致的异常结束。 你可以在一个函数中执行多条 defer 语句,它们的执行顺序与声明顺序相反。 +### Q8:defer 的执行顺序和使用场景? 🟡中等 -defer 的常用场景: +> [!question] ❓ 思考一下 +> 如果有 3 条 defer 语句,它们以什么顺序执行?有名返回值和匿名返回值对 defer 有什么影响? -* defer语句经常被用于处理成对的操作,如打开、关闭、连接、断开连接、 加锁、释放锁。 +## 参考答案 -* 通过defer机制,不论函数逻辑多复杂,都能保证在任何执行路径下,资 源被释放。 - -* 释放资源的defer应该直接跟在请求资源的语句后。 - -**分析:** +**执行顺序**:后进先出(LIFO),与声明顺序相反。 ```go func test() int { - i := 0 - defer func() { - fmt.Println("defer1") - }() - defer func() { - i += 1 - fmt.Println("defer2") - }() - return i + 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 +// 输出:defer2 → defer1 → return 0 ``` -上面这个例子中,test返回值并没有修改,这是由于Go的返回机制决定的,执行Return语句后,Go会创建一个临时变量保存返回值。如果是有名返回(也就是指明返回值`func test() (i int)`) +> [!warning] ⚠️ 高频陷阱 +> **有名返回值 vs 匿名返回值**: +> - 匿名返回值:return 创建临时变量保存结果,defer 修改不影响返回值 +> - 有名返回值:return 直接写入命名变量,defer 可以修改返回值 ```go -func test() (i int) { - i = 0 - defer func() { - i += 1 - fmt.Println("defer2") - }() - return i +func test() (i int) { // 有名返回值 + i = 0 + defer func() { i += 1 }() + return i // 实际返回 1 } - -func main() { - fmt.Println("return", test()) -} -// 输出: -// defer2 -// return 1 - ``` -这个例子中,返回值被修改了。对于有名返回值的函数,执行 return 语句时,并不会再创建临时变量保存,因此,defer 语句修改了 i,即对返回值产生了影响。 +> [!note] 📝 核心考点 +> defer 的三个关键点:**LIFO 顺序**、**延迟计算**(参数在 defer 声明时就求值)、**有名返回值可被修改**。 -## 9. 什么是 rune 类型? +--- -Go 语言的字符有以下两种: +### Q9:什么是 rune 类型? 🟢简单 -* uint8 类型,或者叫 byte 型,代表了 ASCII 码的一个字符。 +## 参考答案 -* rune 类型,代表一个 UTF-8 字符,当需要处理中文、日文或者其他复合字符时,则需要用到 rune 类型。rune 类型等价于 int32 类型。 +Go 的字符有两种表示: + +| 类型 | 别名 | 含义 | 字节数 | +|------|------|------|--------| +| `byte` | `uint8` | ASCII 码的一个字符 | 1 | +| `rune` | `int32` | UTF-8 字符(Unicode) | 4 | ```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 -} +str := "hello 你好" +fmt.Println(len(str)) // 12(按字节计算:7个英文+5个汉字×3) +fmt.Println(len([]rune(str))) // 8(按字符计算) ``` -## 10. Go 语言 tag 有什么用? +> [!tip] 💡 面试技巧 +> 记住关键数字:UTF-8 下一个汉字占 **3 个字节**,`len()` 按字节计算,要统计字符数需转为 `[]rune`。 -tag可以为结构体成员提供属性。常见的: +--- -1. json序列化或反序列化时字段的名称 +### Q10:Go 语言 tag 有什么用? 🟢简单 -2. db: sqlx模块中对应的数据库字段名 +## 参考答案 -3. form: gin框架中对应的前端的数据字段名 +tag 为结构体成员提供元数据属性,常见用途: -4. binding: 搭配 form 使用, 默认如果没查找到结构体中的某个字段则不报错值为空, binding为 required 代表没找到返回错误给前端 +| Tag | 作用 | 示例 | +|-----|------|------| +| `json` | JSON 序列化/反序列化字段名 | `` `json:"user_name"` `` | +| `db` | SQLX 数据库字段名 | `` `db:"id"` `` | +| `form` | Gin 框架前端字段映射 | `` `form:"username"` `` | +| `binding` | 参数校验规则 | `` `binding:"required"` `` | -## 11. go 打印时 %v %+v %#v 的区别? +--- -* %v 只输出所有的值; +### Q11:go 打印时 %v %+v %#v 的区别? 🟢简单 -* %+v 先输出字段名字,再输出该字段的值; - -* %#v 先输出结构体名字值,再输出结构体(字段名字+字段的值); +## 参考答案 ```go -package main -import "fmt" - -type student struct { - id int32 - name string -} - -func main() { - a := &student{id: 1, name: "微客鸟窝"} +type student struct { id int32; name string } +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:"微客鸟窝"} -} +fmt.Printf("%v\n", a) // &{1 微客鸟窝} -- 只输出值 +fmt.Printf("%+v\n", a) // &{id:1 name:微客鸟窝} -- 显示字段名 +fmt.Printf("%#v\n", a) // &main.student{id:1,name:"微客鸟窝"} -- 完整类型信息 ``` -## 12. Go语言中空 struct{} 占用空间么? +--- -可以使用 unsafe.Sizeof 计算出一个数据类型实例需要占用的字节数,空struct{}不占用任何空间 +### Q12-13:空 struct{} 的空间占用和用途 🟢简单 + +## 参考答案 + +空 struct 不占用任何空间: ```go -package main - -import ( - "fmt" - "unsafe" -) - -func main() { - fmt.Println(unsafe.Sizeof(struct{}{})) //0 -} +unsafe.Sizeof(struct{}{}) // 输出: 0 ``` -## 13. Go语言中,空 struct{} 有什么用? +**三大用途:** +1. **模拟 Set**:`map[string]struct{}` 代替 `map[string]bool`,节省内存 +2. **Channel 信号**:`chan struct{}` 仅用于通知,不传数据 +3. **仅有方法的类型**:如工具类 `type Logger struct{}` -* 用map模拟一个set,那么就要把值置为struct{},struct{}本身不占任何空间,可以避免任何多余的内存分配。 +--- -```go -type Set map[string]struct{} +### Q14:init() 函数什么时候执行? 🟡中等 -func main() { - set := make(Set) +> [!question] ❓ 思考一下 +> main 包可以有 init() 吗?多个包的 init() 执行顺序是什么? - 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{}{},可以节省空间 +**执行顺序**:import -> const -> var -> `init()` -> `main()` -```go -func main() { - ch := make(chan struct{}, 1) - go func() { - <-ch - // do something - }() - ch <- struct{}{} - // ... -} -``` +1. 在 `main()` 之前执行 +2. 按依赖关系初始化(无依赖的最先初始化) +3. 同一个包内可以有多个 `init()`,执行顺序**不保证** +4. `init()` 无参数、无返回值、不能被调用 -* 表示仅有方法的结构体 +> [!warning] ⚠️ 高频陷阱 +> 不要在 `init()` 中做耗时操作或依赖外部服务!它应该在编译期就能确定执行逻辑。 -```go -type Lamp struct{} -``` +--- -## 14. init() 函数是什么时候执行的? +### Q15-16:接口比较与 nil 问题 🟡中等 -**简答:** 在main函数之前执行。 +## 参考答案 -**详细:**init()函数是go初始化的一部分,由runtime初始化每个导入的包,初始化不是按照从上到下的导入顺序,而是按照解析的依赖关系,没有依赖的包最先初始化。 +**两个 interface 相等的条件:** +1. 两者均为 nil +2. 类型 T 相同且值 V 相等 -每个包首先初始化包作用域的常量和变量(常量优先于变量),然后执行包的`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不相等。举个例子: +**两个 nil 可能不相等!** 只有类型相同时才相等: ```go var p *int = nil var i interface{} = nil -if(p == i){ - fmt.Println("Equal") -} +// p == i → false(*int != interface{}) ``` -两者并不相同。总结:两个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()` 函数(例如,注册驱动、初始化全局变量等),而不需要直接使用包中的任何导出成员。这时,你就可以使用 `_` 来进行**匿名导入** - -示例: +**nil interface 的经典陷阱:** ```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)) - // }() -} +var c Coder = (*Gopher)(nil) // 动态类型为 *Gopher,值为 nil +c == nil // false!因为动态类型不为 nil ``` -## 21. Go语言普通指针和unsafe.Pointer有什么区别? +> [!note] 📝 核心考点 +> interface == nil 的条件是**动态类型 AND 动态值都为 nil**,缺一不可。这是 Go 面试中出现频率最高的陷阱题之一。 -普通指针比如`*int`、`*string`,它们有明确的类型信息,编译器会进行类型检查和垃圾回收跟踪。不同类型的指针之间不能直接转换,这是Go类型安全的体现。 +--- -而**unsafe.Pointer**是Go的通用指针类型,可以理解为C语言中的`void*`,它绕过了Go的类型系统。unsafe.Pointer可以与任意类型的指针相互转换,也可以与uintptr进行转换来做指针运算。 +### Q17:Go 函数传参是值传递还是引用传递? 🟢简单 -另外,通指针受GC管理和类型约束,unsafe.Pointer不受类型约束但仍受GC跟踪 +## 参考答案 -## 22. unsafe.Pointer与uintptr有什么区别和联系 +**Go 只有值传递。** 无论是值类型、引用类型还是指针类型,传递的都是副本: -unsafe.Pointer和uintptr可以相互转换,这是Go提供的唯一合法的指针运算方式。典型用法是先将unsafe.Pointer转为uintptr做算术运算,然后再转回unsafe.Pointer使用。 +- 值类型:复制实际值 +- 引用类型(slice/map/chan):复制指向底层数据的指针 +- 指针类型:复制指针的值 -最关键的区别在于**GC跟踪**。unsafe.Pointer会被垃圾回收器跟踪,它指向的内存不会被错误回收;而uintptr只是一个普通整数,GC完全不知道它指向什么,如果没有其他引用,对应内存可能随时被回收。 +> [!tip] 💡 面试技巧 +> "引用类型能修改外部数据不是因为引用传递,而是因为复制了指针,新旧变量指向同一块内存。" -所以记住:unsafe.Pointer有GC保护,uintptr没有,这是它们最本质的区别。 +--- + +### Q18:如何判断变量分配在栈上还是堆上? 🟡中等 + +## 参考答案 + +通过**逃逸分析**判断: + +```bash +go build -gcflags '-m -m -l' xxx.go +``` + +**逃逸场景:** +- 返回局部变量指针 +- 传递给 `interface{}` 参数 +- 被闭包引用 +- 超过栈大小限制的大对象 + +--- + +### Q19:Go 多返回值如何实现? 🟡中等 + +## 参考答案 + +Go 的多返回值通过在**调用方栈帧上预留空间**实现: + +1. 编译器计算所有返回值的总大小 +2. 在 caller 的栈帧上预留连续空间 +3. callee 执行 return 时将值复制到预留空间 +4. caller 直接从自己的栈帧读取 + +> [!info] 🔗 延伸阅读 +> - [[hzh/GolangStar/Go语言原理/defer原理]] — defer 与有名返回值的交互 +> - [[hzh/GolangStar/Go面试题库/代码面试题]] — 多返回值实战 + +--- + +### Q20:Go 中 `_` 的作用 🟢简单 + +## 参考答案 + +1. **忽略返回值**:`x, _ := m["key"]` +2. **匿名导入**:`_ "net/http/pprof"`(仅执行 init 注册 profiling) +3. **忽略循环变量**:`for _, v := range slice` + +--- + +### Q21-22:指针相关概念 🟡中等 + +## 参考答案 + +| 对比项 | 普通指针 (`*int`) | `unsafe.Pointer` | `uintptr` | +|--------|------------------|-----------------|-----------| +| 类型安全 | 是,受编译器检查 | 否,通用指针 | 是(整数类型) | +| GC 跟踪 | 是 | 是 | **否** | +| 指针运算 | 不支持 | 不支持 | 支持(需转回 Pointer) | +| 类比 C | 强类型指针 | `void*` | 裸整数地址 | + +> [!warning] ⚠️ 高频陷阱 +> `uintptr` 只是一个整数,GC 不知道它指向内存,对应内存可能被回收。`unsafe.Pointer` 受 GC 保护。这是最本质的区别。 + +## 关联笔记 + +- [[hzh/GolangStar/Go语言基础/Go语言变量]] +- [[hzh/GolangStar/Go语言基础/Go语言指针]] +- [[hzh/GolangStar/Go语言基础/Go语言defer]] +- [[hzh/GolangStar/Go语言进阶/反射]] +- [[hzh/GolangStar/Go语言原理/逃逸分析]] diff --git a/hzh/GolangStar/Index.md b/hzh/GolangStar/Index.md index 0056610..c831f61 100644 --- a/hzh/GolangStar/Index.md +++ b/hzh/GolangStar/Index.md @@ -3,30 +3,56 @@ tags: [go, golang, learning-path] create time: 2026-06-07 14:30 --- -# 🧭 Go 语言进阶之路 - 知识库索引 +# GolangStar — Go 语言进阶之路 · 知识库索引 ## 概述 本索引整理自「Go 语言进阶之路」学习路线,覆盖 **8 大知识板块**,从入门到源码阅读、面试准备一站式闭环。请严格按以下顺序阅读,每完成一个板块再进入下一个。 > [!tip] 阅读策略 -> - **语言基础 → 并发编程**:先掌握语法,再深入并发(Goroutine/Channel/Sync) -> - **语言基础 → 语言原理**:基础篇讲"怎么用",原理篇讲"为什么",配对阅读效果最佳 +> - **语言基础 -> 并发编程**:先掌握语法,再深入并发(Goroutine/Channel/Sync) +> - **语言基础 <-> 语言原理**:基础篇讲"怎么用",原理篇讲"为什么",配对阅读效果最佳 > - **框架实战** 可独立进行,但建议有基础篇铺垫后再上手 > - **面试题库** 作为复习检验工具,建议在对应原理篇完成后刷一遍 ```mermaid graph LR - root["Go语言进阶之路"] --> p1["Go语言前景"] - root --> p2["环境准备"] - root --> p3["语言基础"] - root --> p4["并发编程"] - root --> p5["框架实战"] - root --> p6["语言原理"] - root --> p7["测试篇"] - root --> p8["面试题库"] + root["GolangStar 学习路线"] --> p1["一、Go语言前景"] + root --> p2["二、环境准备"] + root --> p3["三、语言基础"] + root --> p4["四、并发编程"] + root --> p5["五、框架实战"] + root --> p6["六、语言原理"] + root --> p7["七、测试篇"] + root --> p8["八、面试题库"] + + p3 --> p3a["入门语法"] + p3 --> p3b["复合类型"] + p3 --> p3c["高级特性"] + + p6 --> p6a["底层原理"] + p6 --> p6b["运行时原理"] + + style root fill:#4CAF50,color:#fff + style p3 fill:#2196F3,color:#fff + style p4 fill:#FF9800,color:#fff + style p6 fill:#9C27B0,color:#fff + style p8 fill:#F44336,color:#fff ``` +## 快速导航 + +| 目标 | 直接跳转 | +|------|---------| +| 从零开始 | 从 **一、Go 语言前景** 按序阅读 | +| 搭建环境 | [[hzh/GolangStar/Go环境搭建/Go环境搭建]] | +| 查阅规范 | [[hzh/GolangStar/Go编码规范/Go编码规范]] | +| 速查语法 | **三、语言基础**(全部 18 小节) | +| 攻克并发 | **四、并发编程**(10 个并发专题) | +| 动手框架 | **五、框架实战**(Gin + GORM) | +| 深入源码 | **六、语言原理**(13 篇源码解析) | +| 刷题检验 | **八、面试题库**(12 套面试题) | + --- ## 一、Go 语言前景 📗 @@ -134,7 +160,7 @@ graph LR | # | 专题 | 文件 | 与基础篇对应 | |---|------|------|------------| | 1 | 程序初始化过程 | [[hzh/GolangStar/Go语言原理/程序初始化]] | import 流程 init() 执行顺序 | -| 2 | String 底层原理 | [[hzh/GolangStar/Go语言原理/string原理]] | 不可变字符串、string ↔ bytes 转换开销 | +| 2 | String 底层原理 | [[hzh/GolangStar/Go语言原理/string原理]] | 不可变字符串、string bytes 转换开销 | | 3 | Slice 底层原理 | [[hzh/GolangStar/Go语言原理/slice原理]] | array-pointer-length 三字段、扩容策略 | | 4 | Map 底层原理 | [[hzh/GolangStar/Go语言原理/map原理]] | hash 数组、溢出桶、写时迁移 | | 5 | Sync.Map 原理 | [[hzh/GolangStar/Go语言原理/sync.map原理]] | read/dirty/amended 三表结构 | @@ -183,15 +209,25 @@ graph LR --- -## 快速导航 +## 学习进度追踪 -| 目标 | 直接跳转 | -|------|---------| -| 从零开始 | 从 **一、Go 语言前景** 按序阅读 | -| 🔧 搭建环境 | [[hzh/GolangStar/Go环境搭建/Go环境搭建]] | -| 📏 查阅规范 | [[hzh/GolangStar/Go编码规范/Go编码规范]] | -| ⚡ 速查语法 | **三、语言基础**(全部 18 小节) | -| 🚀 攻克并发 | **四、并发编程**(10 个并发专题) | -| 🏗️ 动手框架 | **五、框架实战**(Gin + GORM) | -| 🔬 深入源码 | **六、语言原理**(13 篇源码解析) | -| ✅ 刷题检验 | **八、面试题库**(12 套面试题) | +> [!tip] 自我检测清单 +> +> 在每个板块完成后,尝试回答以下问题来检验学习效果: +> +> - [ ] 能用一句话解释 Go 的并发模型吗? +> - [ ] 能画出 slice 的底层结构并解释扩容规则吗? +> - [ ] 能说清楚 map 遍历时序为何是随机的吗? +> - [ ] 能手写一个简单的 channel 通信模式吗? +> - [ ] 能解释 defer 的执行顺序和有名返回值的关系吗? +> - [ ] 能描述 GMP 调度模型的协作方式吗? +> - [ ] 能画出三色标记法的标记流程吗? +> +> 全部勾选后,建议进入面试题库模块进行实战检验! + +--- + +## 关联笔记 + +- 本索引为整个 GolangStar 系列的总入口 +- 每个子文档的底部均有"关联笔记"链接,方便交叉引用