一、包管理与模块化
1. 包的基本概念
1)什么是包
包是 Go 语言组织源码的基本单位,是多个 .go 文件的集合,也是代码复用的基础。Go 内置了许多标准包,如 fmt、os、io 等。
2)包的声明
每个源码文件开头都必须声明所属的包,这是 Go 的强制约定。
// 文件路径: internal/order/order.go
package order
包名与目录名的关系:
-
包名可以与目录名不同,但建议保持一致
-
同一目录下的所有源码文件必须使用相同的包名
-
包名全部小写,多个单词直接连写(如 packagename)
-
包名尽量简短,过长的包名通常意味着可以拆分
package usermanager // 可接受,但建议考虑是否拆分
package order // 更推荐
3)包的作用域
- 同一包下:不同文件之间可以直接互相访问,无需导入
- 跨包访问:需要导入目标包,且只能访问首字母大写的导出成员
// 文件路径: internal/order/order.go
package order
// 可导出的结构体(首字母大写)
type Order struct {
ID string
Amount float64
}
// 可导出的函数
func NewOrder(id string, amount float64) Order {
return Order{ID: id, Amount: amount}
}
// 私有函数(首字母小写,仅在包内可见)
func calculateTax(amount float64) float64 {
return amount * 0.06
}
// 文件路径: cmd/main.go
package main
import (
"fmt"
"myproject/internal/order"
)
func main() {
o := order.NewOrder("ORD-001", 100.0)
fmt.Println(o.ID)
// order.calculateTax(100.0) // 编译错误:不可访问
}
2. 包的导入
1)基本导入
使用 import 关键字导入包,路径相对于项目根目录(即 go.mod 中的模块名)。
import "myproject/internal/order"
推荐使用导入组:
import (
"fmt"
"myproject/internal/order"
"myproject/internal/user"
)
2)别名导入
当两个包路径不同但包名相同时,使用别名区分。例如同一个服务的不同 API 版本共存时:
import (
"fmt"
v1 "myproject/api/v1/order"
v2 "myproject/api/v2/order"
)
func main() {
reqV1 := v1.CreateOrderRequest{}
reqV2 := v2.CreateOrderRequest{}
}
3)点导入(不推荐)
使用 . 前缀后,可直接访问包成员,无需包名前缀。
import . "fmt"
func main() {
Println("hello") // 无需 fmt.Println
}
缺点:可能导致命名冲突,降低代码可读性,不建议在生产代码中使用。
4)匿名导入
使用 _ 前缀,仅执行包的 init() 函数,不直接使用包内成员。
import (
"database/sql"
_ "github.com/go-sql-driver/mysql" // 注册 MySQL 驱动
)
init() 函数示例:
// 文件路径: internal/driver/mysql.go
package driver
import "fmt"
func init() {
// 包被导入时自动执行
fmt.Println("MySQL driver registered")
registerMySQL()
}
func registerMySQL() {
// 实际的注册逻辑
}
// 文件路径: cmd/main.go
package main
import (
"database/sql"
_ "myproject/internal/driver" // 仅执行 init(),不直接使用
)
func main() {
// MySQL 驱动已通过 init() 自动注册
db, _ := sql.Open("mysql", "dsn")
}
init() 函数的特点:
- 无参数,无返回值
- 在包被导入时自动执行
- 常用于数据库驱动注册、配置初始化等
3. Go Modules 基础
1)什么是 Go Modules
Go Modules 是 Go 官方的依赖管理工具,从 Go 1.11 开始引入。它解决了传统 GOPATH 模式下依赖管理的问题。
2)go.mod 文件
go.mod 是模块的配置文件,记录了模块路径和依赖信息。
module github.com/mycompany/myproject
go 1.21
require (
github.com/gin–gonic/gin v1.9.1
github.com/go–redis/redis/v8 v8.11.5 // indirect
)
关键字段:
- module:模块路径,其他项目导入时使用
- go:Go 版本要求
- require:依赖列表及版本号
关于 // indirect:
indirect 表示间接依赖,即你的项目没有直接 import 这个包,而是通过其他依赖引入的。
// 你的代码只 import 了 gin
import "github.com/gin-gonic/gin"
// 但 gin 内部依赖了 jsoniter、uuid 等包
// 这些包不会出现在你的 import 中,但会被记录在 go.mod 中并标记为 indirect
3)go.sum 文件
go.sum 记录依赖包的校验和,保证依赖的完整性和安全性。
- 自动维护,不应手动修改
- 需随项目提交到版本控制
4)配置国内镜像
因网络原因,直接下载依赖可能很慢,需配置国内代理:
go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct
direct 表示代理不可用时直连原始源。
5)依赖存储位置
第三方依赖下载后存储在 $GOPATH/pkg/mod 目录下,不同版本共存。
GOPATH 是什么:
GOPATH 是 Go 早期版本的工作区根目录,包含三个子目录:
- src:存放源码
- pkg:存放编译后的包文件
- bin:存放编译后的可执行文件
在 Go Modules 时代,GOPATH 不再用于存放项目源码,但 $GOPATH/pkg/mod 仍是第三方依赖的存储位置。
查看 GOPATH:
go env GOPATH
# 输出示例:/Users/username/go
4. 依赖管理命令
| go mod init 模块名 | 初始化新模块 |
| go mod tidy | 添加缺失依赖,移除未使用依赖(最常用) |
| go get 包路径@版本 | 下载指定版本的依赖 |
日常开发推荐流程:
在代码中直接 import 需要的包:
import "github.com/gin-gonic/gin"
然后执行 go mod tidy,自动下载依赖:
go mod tidy
添加和升级依赖:
# 下载指定版本
go get github.com/gin-gonic/gin@v1.9.1
# 升级到最新版本
go get -u github.com/gin-gonic/gin
# 仅升级到补丁版本
go get -u=patch github.com/gin-gonic/gin
版本指定:
# 安装最新版本
go get package@latest
# 安装指定版本(需仓库打 tag)
go get package@v1.2.3
# 安装特定分支(开发时使用)
go get package@main
关于 tag 与分支:
- Tag 是固定的版本快照,go get package@v1.0.0 依赖 tag
- 分支是可变的开发线,go get package@main 获取分支最新代码
- 发布版本时应使用 tag,不依赖分支的移动性
- 日常开发中,版本号依赖 tag 是 Go Modules 的标准做法
5. replace 指令
1)作用
当包的导入路径与实际代码仓库路径不一致时,使用 replace 将导入路径映射到实际路径。
2)语法
// go.mod
module myproject
require github.com/company/common v1.0.0
// 实际代码仓库路径与声明不同,使用 replace 映射
replace github.com/company/common => github.com/company/go–common v1.0.0
3)使用场景
- 包名与仓库名不一致(如公司内部规范)
- 替换为本地未发布的依赖(开发调试)
- 使用第三方库的 fork 版本
Fork 版本 是指从原始开源项目复制一份到自己账号下的独立仓库,然后基于这份副本进行修改。常见于上游库有 bug 未修复、需要定制功能、上游已停止维护等场景。
// 假设你 fork 了 github.com/gin-gonic/gin 到 github.com/mycompany/gin
// 使用 replace 指向 fork 仓库
replace github.com/gin–gonic/gin => github.com/mycompany/gin v1.9.1
本地调试示例:
// 使用本地版本进行开发调试
replace github.com/company/common => ../go–common
命令行方式:
go mod edit -replace github.com/company/common=../go-common
6. 编码规范
1)包名
| 全部小写 | package user |
| 多单词直接连写 | package usermanager |
| 简短有意义 | order 优于 ordermanagement |
| 尽量与目录名一致 | internal/order/ 下用 package order |
| 避免与标准库重名 | 不用 package fmt |
2)文件名
- 蛇形命名法(小写 + 下划线)
- 简短有意义
user.go
order_service.go
3)变量名
- 使用驼峰命名法
- 专有名词保持原样(API、URL、ID)
var userName string
var orderID int
var apiVersion string
布尔变量: 以 is、has、can、allow 开头
var isActive bool
var hasPermission bool
4)结构体命名
- 驼峰命名法
- 首字母大写可导出,小写私有
// 可导出
type Order struct {
ID string
Amount float64
}
// 私有,仅包内使用
type orderCache struct {
data map[string]Order
}
5)接口命名
- 推荐以 er 结尾,表示“能做什么”
- 不是强制要求,业务场景中以具体业务含义命名同样常见
// 标准库风格,以 er 结尾
type Reader interface {
Read() ([]byte, error)
}
// 业务代码风格,以具体业务命名
type OrderRepository interface {
Get(id string) (*Order, error)
Save(order *Order) error
}
OrderRepository 虽然没有以 er 结尾,但 Repository 本身已经清晰表达了数据访问接口的含义。
6)常量命名
常量命名使用全大写加下划线的方式,例如:
const APP_VERSION = "1.0.0"
const MAX_RETRY = 3
说明: 虽然 Go 官方风格指南推荐与变量一致的驼峰命名法,但全大写加下划线的方式在实际工程中被广泛使用,特别是配置项、环境变量等场景,可读性强且与标准库风格保持一致。
7)注释规范
包注释: 在 package 声明前添加,说明包的功能。
// Package order 提供订单管理功能
package order
函数注释: 包含功能说明、参数说明、返回值说明。
// GetOrder 根据订单 ID 查询订单详情
// 参数:
// – id: 订单ID,格式为 ORD-xxx
// 返回值:
// – *Order: 订单信息,不存在时返回 nil
// – error: 查询失败时返回错误信息
func GetOrder(id string) (*Order, error) {
// …
}
8)import 分组规范
按来源分组,每组内按字母排序:
import (
// 1. 标准库
"context"
"encoding/json"
"fmt"
"time"
// 2. 第三方依赖
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"gorm.io/gorm"
// 3. 内部包
"myproject/internal/order"
"myproject/internal/user"
)
7. 小结
| 包声明 | 每个文件声明所属包;同一目录包名一致 |
| 导出规则 | 首字母大写可导出,小写私有 |
| 基本导入 | import "路径";导入组提高可读性 |
| 别名导入 | 解决同名包冲突,便于版本共存 |
| 匿名导入 | import _ 执行 init(),用于驱动注册 |
| go.mod | 模块路径和依赖版本;自动维护 |
| go.sum | 依赖校验和;确保完整性 |
| go mod tidy | 自动管理依赖;日常开发最常用 |
| go get | 下载/升级指定版本 |
| replace | 映射导入路径到实际路径,支持本地调试和 fork |
| 编码规范 | 包名小写、变量驼峰、常量大写蛇形、import 分组 |
二、测试
1. 单元测试基础
1)测试文件命名规范
Go 的测试文件必须以 _test.go 结尾,且与被测试代码放在同一包中,以便访问包内私有函数。
internal/calc/
├── add.go # 生产代码
└── add_test.go # 测试代码
2)测试函数规范
测试函数必须以 Test 开头,参数为 *testing.T。
// internal/calc/add.go
package calc
func Add(a, b int) int {
return a + b
}
// internal/calc/add_test.go
package calc
import "testing"
func TestAdd(t *testing.T) {
result := Add(1, 2)
expected := 3
if result != expected {
t.Errorf("expect %d, actual %d", expected, result)
}
}
3)运行测试
# 运行当前包下的所有测试
go test
# 运行所有测试,显示详细信息
go test -v
# 运行特定测试函数
go test -run TestAdd
# 运行匹配正则表达式的测试
go test -run "TestAdd|TestSub"
4)错误报告方法
| t.Error(msg) | 报告错误,测试继续执行 |
| t.Errorf(format, args…) | 报告格式化错误,测试继续执行 |
| t.Fatal(msg) | 报告错误,立即终止当前测试 |
| t.Fatalf(format, args…) | 报告格式化错误,立即终止当前测试 |
5)测试覆盖率
覆盖率衡量测试代码对生产代码的覆盖程度,帮助发现未被测试到的代码区域。
# 查看覆盖率百分比
go test -cover
# 输出示例:coverage: 85.7% of statements
# 生成覆盖率数据文件(供后续分析使用)
go test -coverprofile=coverage.out
# 查看函数级覆盖率
go tool cover -func=coverage.out
# 输出示例:
# myproject/calc/add.go:3: Add 100.0%
# myproject/calc/sub.go:3: Sub 80.0%
# total: (statements) 85.7%
# 查看 HTML 覆盖率报告(绿色=已覆盖,红色=未覆盖)
go tool cover -html=coverage.out
覆盖率的作用:
- 发现哪些代码没有被测试到,帮助补充测试用例
- 评估测试的充分性(如核心模块覆盖率不低于 80%)
- 代码审查时快速定位高风险区域
注意:100% 覆盖率不代表 100% 正确性,覆盖率只统计“代码被执行过”,不保证“执行结果正确”。
6)子测试
使用 t.Run() 组织子测试,支持分组执行和独立运行。
func TestAdd(t *testing.T) {
t.Run("正数相加", func(t *testing.T) {
result := Add(1, 2)
if result != 3 {
t.Errorf("expect 3, actual %d", result)
}
})
t.Run("负数相加", func(t *testing.T) {
result := Add(–1, –2)
if result != –3 {
t.Errorf("expect -3, actual %d", result)
}
})
}
# 运行所有测试,显示详细信息
go test -v
# 运行特定子测试
go test -run TestAdd/正数相加
7)并行测试
使用 t.Parallel() 让多个独立测试并行执行,缩短总测试时间。
func TestSlowA(t *testing.T) {
t.Parallel()
time.Sleep(1 * time.Second) // 模拟耗时操作
// 测试逻辑
}
func TestSlowB(t *testing.T) {
t.Parallel()
time.Sleep(1 * time.Second)
// 测试逻辑
}
执行对比:
- 不加 t.Parallel():测试依次执行,总耗时约 2 秒
- 加 t.Parallel():测试同时执行,总耗时约 1 秒
注意事项:
- 并行测试之间必须相互独立,不能共享可变状态
- 可通过 -parallel n 控制最大并行数,默认等于 GOMAXPROCS(即机器逻辑 CPU 核心数,可通过 runtime.GOMAXPROCS(0) 在代码中查看实际生效值,或通过 go env GOMAXPROCS 查看环境变量是否被显式设置)
- t.Parallel() 仅对当前测试函数生效。如果父测试标记了并行,子测试默认不会并行,需要在子测试内部单独调用 t.Parallel():
func TestParent(t *testing.T) {
t.Parallel() // 父测试并行
t.Run("子测试1", func(t *testing.T) {
t.Parallel() // 子测试也需单独标记
time.Sleep(1 * time.Second)
})
t.Run("子测试2", func(t *testing.T) {
t.Parallel() // 子测试也需单独标记
time.Sleep(1 * time.Second)
})
}
2. 跳过耗时测试
1)使用场景
当测试文件包含多个用例时,某些用例可能非常耗时(如集成测试、网络请求、大数据量处理等),开发调试期间希望跳过这些用例,只运行快速测试。
2)short 模式
使用 testing.Short() 判断是否为短测试模式,配合 t.Skip() 跳过耗时测试。
func TestExpensiveQuery(t *testing.T) {
if testing.Short() {
t.Skip("跳过耗时测试")
}
// 耗时的测试逻辑
result := queryDatabase()
if result != expected {
t.Errorf("expect %v, actual %v", expected, result)
}
}
3)运行短测试
# 正常执行全部测试
go test
# 短测试模式:跳过标记的用例
go test -short
3. 表格驱动测试
1)概念
当一组测试用例的结构相同(相同的输入类型和输出类型),只有具体数据不同时,用表格集中管理数据,通过循环遍历执行,避免为每个用例单独写测试函数。
2)实现方式
func TestAdd(t *testing.T) {
// 定义测试数据表格
tests := []struct {
name string
a int
b int
want int
}{
{"正数相加", 12, 12, 24},
{"正数加负数", –9, 8, –1},
{"零值相加", 0, 0, 0},
{"负数相加", –5, –3, –8},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.want {
t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
}
})
}
}
优点:
- 测试数据集中管理,新增用例只需添加一行
- 覆盖多种边界情况(正数、负数、零值)
- 每个用例独立运行,失败时精确定位
4. 性能测试(Benchmark)
1)测试规范
性能测试函数以 Benchmark 开头,参数为 *testing.B。
Go 1.24 及以上版本推荐写法:
func BenchmarkAdd(b *testing.B) {
for b.Loop() {
Add(1, 2)
}
}
Go 1.24 以下版本写法:
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}
b.Loop() 内部自动处理 b.N 的递增和循环控制,是官方推荐的新写法。
2)排除准备代码耗时
如果准备代码耗时较长且不关心这段准备时间,可以用 ResetTimer 排除:
func BenchmarkProcessLargeData(b *testing.B) {
// 准备数据(不计入耗时)
data := generateLargeDataset(10000)
// 重置计时器,排除准备阶段耗时
b.ResetTimer()
for b.Loop() {
process(data)
}
}
如果不需要排除准备阶段的耗时,直接 for b.Loop() 即可,无需调用 ResetTimer。
3)排除循环内非被测代码耗时
使用 StopTimer() 和 StartTimer() 排除序列化等辅助操作的耗时:
func BenchmarkComplex(b *testing.B) {
for b.Loop() {
result := doWork() // 被测代码
b.StopTimer() // 暂停计时
json.Marshal(result) // 不计入耗时
b.StartTimer() // 恢复计时
}
}
核心原则:
- ResetTimer 用于排除准备阶段的耗时
- StopTimer/StartTimer 用于排除循环内非被测代码的耗时
- 如果没有需要排除耗时的操作,两者都不需要调用
4)运行性能测试
# 运行当前包下的所有性能测试
go test -bench=.
# 运行特定性能测试
go test -bench=BenchmarkAdd
# 查看内存分配统计
go test -bench=. -benchmem
# 指定执行时间
go test -bench=. -benchtime=5s
-benchmem 会额外输出两列:
- B/op:每次操作分配的内存字节数
- allocs/op:每次操作分配内存次数
如果被测函数没有内存分配,这两列不会显示。
5)Benchmark 与单元测试的对比
| 目的 | 验证功能正确性 | 测量执行性能 |
| 函数前缀 | Test | Benchmark |
| 参数类型 | *testing.T | *testing.B |
| 执行次数 | 一次 | b.Loop() 循环多次 |
| 输出 | PASS / FAIL | 执行次数 + 耗时 |
5. 小结
| 测试文件命名 | _test.go,与被测代码同包 |
| 测试函数 | TestXxx(t *testing.T) |
| 运行测试 | go test、go test -v、go test -run |
| 错误报告 | t.Error、t.Errorf、t.Fatal、t.Fatalf |
| 测试覆盖率 | go test -cover、-coverprofile、cover -func、cover -html |
| 子测试 | t.Run() 组织分组,支持独立运行 |
| 并行测试 | t.Parallel() 缩短总测试时间;-parallel n 控制并发数 |
| 跳过耗时测试 | testing.Short() + t.Skip();go test -short |
| 表格驱动测试 | 结构体切片管理多组数据,循环遍历执行 |
| 性能测试函数 | BenchmarkXxx(b *testing.B);go test -bench=. |
| 排除耗时 | b.ResetTimer() 排除准备阶段;StopTimer/StartTimer 排除循环内辅助操作 |
| 内存统计 | go test -bench=. -benchmem 查看内存分配情况 |
三、并发编程
1. Goroutine 基础
1)什么是 Goroutine
Goroutine 是 Go 语言的轻量级协程,由 Go 运行时管理,而非操作系统线程。
| 内存占用 | 约 2MB(栈固定) | 初始约 2KB(可动态伸缩) |
| 创建数量 | 数百个已达上限 | 轻松创建数十万个 |
| 切换成本 | 系统调用,约 1-2 微秒 | 用户态切换,约 200 纳秒 |
| 调度者 | 操作系统内核 | Go 运行时(GMP 调度器) |
2)启动 Goroutine
使用 go 关键字启动,任何函数都可以:
// 启动命名函数
func sayHello() {
fmt.Println("hello")
}
go sayHello()
// 启动匿名函数(推荐用于一次性逻辑)
go func() {
fmt.Println("hello")
}()
3)主协程退出问题
Go 程序在 main 函数返回时会直接终止所有正在运行的 goroutine。
func main() {
go func() {
fmt.Println("子协程执行") // 可能不会被执行
}()
// main 函数结束,程序退出,子协程被强制终止
}
解决方案:
// 方案一:time.Sleep(不推荐)
time.Sleep(1 * time.Second)
// 方案二:WaitGroup(推荐)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("子协程执行")
}()
wg.Wait()
4)循环中启动多个 Goroutine(常见陷阱)
// 错误写法:所有协程共享同一个循环变量
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 可能全部输出 5
}()
}
// 正确写法一:使用临时变量
for i := 0; i < 5; i++ {
tmp := i
go func() {
fmt.Println(tmp)
}()
}
// 正确写法二:通过参数传递(推荐)
for i := 0; i < 5; i++ {
go func(i int) {
fmt.Println(i)
}(i)
}
原因分析:
- 循环变量 i 在每次迭代中会被重用,所有 goroutine 捕获的是同一个变量
- 协程启动和执行之间有时间差,当协程真正执行时,i 可能已经被修改
- 通过参数传递可以确保每个协程获得独立的变量副本
2. Goroutine 调度模型(GMP)
1)GMP 模型组成
| G | Goroutine | 轻量级线程,包含栈、指令指针等信息 |
| M | Machine | 操作系统线程,真正执行代码的实体 |
| P | Processor | 逻辑处理器,管理本地运行队列 |
核心调度机制: 通过 P 将大量 G 映射到少量 M 上执行,默认 P 数量等于 CPU 核心数。
# 查看或设置 P 数量
runtime.GOMAXPROCS(0) # 获取当前值
runtime.GOMAXPROCS(4) # 设置为 4
2)工作窃取(Work Stealing)
当某个 P 的本地队列为空时,会从其他 P 的队列或全局队列中“窃取”一半的 G 来执行,避免部分 P 空闲而其他 P 过载。
3)Goroutine 调度时机
- 系统调用阻塞(如文件 I/O)
- Channel 操作阻塞
- 主动调用 runtime.Gosched()
- 垃圾回收标记阶段
- 函数调用边界:Go 会在函数调用处插入调度检查点,让其他 goroutine 有机会执行
- 长时间运行的循环:Go 1.14+ 支持基于信号的抢占式调度,运行时会向长时间占用 CPU 的 goroutine 发送信号,强制其让出 P,防止其他 goroutine 饥饿
3. 协程同步:WaitGroup
1)核心方法
| Add(n int) | 增加或减少计数器(可传负数) |
| Done() | 等价于 Add(-1),表示一个协程完成 |
| Wait() | 阻塞直到计数器归零 |
2)基本用法
var wg sync.WaitGroup
// 启动 100 个协程
wg.Add(100)
for i := 0; i < 100; i++ {
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
// 等待所有协程完成
wg.Wait()
fmt.Println("all done")
3)常见错误
// 错误一:忘记调用 Done → 死锁
wg.Add(1)
go func() {
// defer wg.Done() // 忘记调用
}()
wg.Wait() // 永远阻塞
// 错误二:在协程内部调用 Add
var wg sync.WaitGroup
go func() {
wg.Add(1) // 错误:主协程的 Wait 可能在 Add 之前执行
defer wg.Done()
}()
wg.Wait() // 可能提前返回(计数器仍为 0)
// 正确:在启动协程前统一 Add
wg.Add(1)
go func() {
defer wg.Done()
}()
wg.Wait()
注意事项:
- Add 应在启动协程前调用,而不是在协程内部
- 推荐在协程开头使用 defer wg.Done() 确保执行
- WaitGroup 不能复制(内部包含 noCopy 机制)
4. 共享内存的并发控制
1)资源竞争问题
var total int
func add() {
for i := 0; i < 100000; i++ {
total += 1
}
}
func sub() {
for i := 0; i < 100000; i++ {
total -= 1
}
}
func main() {
go add()
go sub()
time.Sleep(1 * time.Second)
fmt.Println(total) // 结果不确定,可能不是 0
}
total += 1 实际包含三个步骤:读取值 → 计算 → 写入值,多个协程的步骤可能交叉执行,导致结果错误。
2)使用 Mutex 互斥锁
var (
total int
lock sync.Mutex
)
func add() {
for i := 0; i < 100000; i++ {
lock.Lock()
total += 1
lock.Unlock()
}
}
func sub() {
for i := 0; i < 100000; i++ {
lock.Lock()
total -= 1
lock.Unlock()
}
}
注意事项:
- 同一把锁保护同一资源,不同锁无法实现互斥
- Lock/Unlock 必须成对出现,建议使用 defer 确保释放
- 锁不能复制,复制后新旧锁各自独立,互不影响,都无法实现互斥保护
var lock sync.Mutex
lock.Lock()
lock2 := lock // 复制锁,两个锁各自独立,都失效
lock2.Lock() // 可以成功锁定,但这是错误用法
3)使用 atomic 原子操作
适用于简单数值类型的原子操作,性能高于 Mutex。
var total int32
func add() {
for i := 0; i < 100000; i++ {
atomic.AddInt32(&total, 1)
}
}
func sub() {
for i := 0; i < 100000; i++ {
atomic.AddInt32(&total, –1)
}
}
选择建议:
| 简单数值加减 | atomic 包 |
| 复杂逻辑或代码块保护 | sync.Mutex |
| 读多写少场景 | sync.RWMutex |
4)读写锁(RWMutex)
读写锁的核心规则是:读可以并发,写的时候既不能写也不能读。
- 多个读操作可以同时进行,互不阻塞
- 写操作与任何操作(包括读和写)都是互斥的
- 适用于读操作远多于写操作的场景(如商品详情页查询)
var (
data map[string]string
rwLock sync.RWMutex
)
// 读操作
func read(key string) string {
rwLock.RLock()
defer rwLock.RUnlock()
return data[key]
}
// 写操作
func write(key, value string) {
rwLock.Lock()
defer rwLock.Unlock()
data[key] = value
}
进阶建议:缩小临界区
defer 释放锁虽然安全,但会在整个函数返回时才释放锁。如果函数中部分操作不需要锁保护(如日志输出、数据序列化等),应缩小临界区,只对真正需要保护的数据操作加锁。
// 反模式:整个函数都被锁保护,包括不需要加锁的操作
func read(key string) string {
rwLock.RLock()
defer rwLock.RUnlock()
fmt.Println("开始读取") // 不需要锁
value := data[key] // 需要锁
fmt.Println("读取结束,值为:", value) // 不需要锁
return value
}
// 最佳实践:用匿名函数隔离临界区,精准控制锁范围
func read(key string) string {
fmt.Println("开始读取") // 不需要锁
var value string
func() {
rwLock.RLock()
defer rwLock.RUnlock()
value = data[key] // 只保护核心操作
}()
fmt.Println("读取结束") // 不需要锁
return value
}
这样既用 defer 保证了解锁安全,又精准控制了锁的持有时间,避免因日志、序列化等耗时操作长时间持有锁而影响并发性能。
5. Channel 基础
1)设计哲学
不要通过共享内存来通信,而要通过通信来实现内存共享。——Go 官方名言
Go 推荐使用 Channel 在 Goroutine 之间传递数据,而不是通过共享变量加锁。
2)Channel 的定义与初始化
// 声明(零值为 nil)
var ch chan string
// 必须使用 make 初始化
ch = make(chan string) // 无缓冲
ch = make(chan string, 10) // 有缓冲(容量 10)
数据流动方向:
ch <- "hello" // 发送:箭头指向 channel
data := <-ch // 接收:箭头从 channel 流出
3)无缓冲 Channel vs 有缓冲 Channel
| 容量 | 0 | > 0 |
| 发送行为 | 必须有接收者才发送成功 | 缓冲未满时立即返回 |
| 接收行为 | 必须有发送者才接收成功 | 缓冲非空时立即返回 |
| 适用场景 | 同步通知、精确控制 | 生产者-消费者模式 |
无缓冲示例:
ch := make(chan string) // 无缓冲
// 单独在一个协程中操作会导致死锁
ch <- "hello" // 阻塞,等待接收者
data := <-ch // 阻塞,等待发送者
// 正确用法:发送和接收在两个不同的协程中
func main() {
ch := make(chan string)
go func() {
ch <- "hello"
}()
data := <-ch
fmt.Println(data)
}
有缓冲示例:
ch := make(chan string, 2) // 容量 2
ch <- "hello" // 不阻塞
ch <- "world" // 不阻塞
// ch <- "!" // 阻塞,缓冲区已满
data1 := <-ch // "hello"
data2 := <-ch // "world"
// data3 := <-ch // 阻塞,缓冲区为空
有缓冲 Channel 的接收行为:
如果发送者发送数据时缓冲区为空,接收者可以立即收到数据(前提是接收操作在发送之前或同时准备好)。有缓冲 channel 和无缓冲 channel 在缓冲区为空时的接收行为一致:如果发送者尚未发送数据,接收者会阻塞,直到有数据可接收。
4)生产者-消费者模式
func main() {
jobs := make(chan int, 10)
// 生产者
go func() {
for i := 0; i < 10; i++ {
jobs <- i
}
close(jobs) // 所有任务完成后关闭 channel
}()
// 消费者(使用 range 自动处理)
for job := range jobs {
fmt.Println("处理任务:", job)
}
}
6. Channel 高级操作
1)单向 Channel
通过类型系统限制 channel 的操作方向,提高代码安全性。
// 只写 channel
func producer(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out) // 发送方可关闭 channel
}
// 只读 channel
func consumer(in <-chan int) {
for val := range in {
fmt.Println(val)
}
// in <- 1 // 编译错误:不能向只读 channel 写入
}
func main() {
ch := make(chan int, 5)
go producer(ch)
consumer(ch)
}
规则:
- 双向 channel 可隐式转换为单向
- 单向 channel 不能转换为双向
- 只有发送方可关闭 channel,接收方应避免关闭
2)检测 Channel 是否关闭
// 方法一:双返回值
value, ok := <-ch
if !ok {
fmt.Println("channel 已关闭")
}
// 方法二:range(自动检测关闭)
for value := range ch {
fmt.Println(value)
}
3)关闭 Channel 的注意事项
// 从已关闭的 channel 接收:返回零值,不阻塞
ch := make(chan int, 2)
ch <- 1
close(ch)
v1 := <-ch // 1
v2 := <-ch // 0(零值)
v3, ok := <-ch // 0, false
// 向已关闭的 channel 发送:panic
close(ch)
ch <- 1 // panic: send on closed channel
// 重复关闭:panic
close(ch)
close(ch) // panic: close of closed channel
7. Select 多路复用
1)基本用法
select 用于监控多个 channel,当任一 channel 就绪时执行对应分支。
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(1 * time.Second)
ch1 <- "任务1完成"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "任务2完成"
}()
select {
case msg1 := <-ch1:
fmt.Println(msg1)
case msg2 := <-ch2:
fmt.Println(msg2)
}
}
2)随机选择机制
当多个 case 同时就绪时,select 会随机选择一个执行,防止“饥饿”。
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
ch1 <- 1
ch2 <- 2
select {
case v := <-ch1:
fmt.Println("ch1:", v)
case v := <-ch2:
fmt.Println("ch2:", v)
}
// 每次运行结果可能不同
3)非阻塞操作
使用 default 实现非阻塞 channel 操作。
select {
case v := <-ch:
fmt.Println("接收到:", v)
default:
fmt.Println("没有数据,继续执行其他逻辑")
}
4)超时控制
结合 time.After 或 time.NewTimer 实现超时。
select {
case v := <-ch:
fmt.Println("接收到:", v)
case <-time.After(3 * time.Second):
fmt.Println("超时,退出等待")
}
// 使用 Timer(可取消)
timer := time.NewTimer(3 * time.Second)
select {
case v := <-ch:
fmt.Println("接收到:", v)
case <-timer.C:
fmt.Println("超时")
}
调用 timer.Stop() 可提前取消定时器,防止其触发,避免资源泄漏。
5)空 select
select {} // 永久阻塞
空 select 语句没有任何 case 分支,会永久阻塞。Go 运行时会检测到所有 goroutine 都在等待且无外部事件唤醒,报错 fatal error: all goroutines are asleep – deadlock!。
8. Context 上下文
Context 用于在 Goroutine 之间传递取消信号、超时控制和请求范围的数据。
1)核心接口
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{} // 返回只读 channel,取消时关闭
Err() error // 返回取消原因
Value(key any) any // 获取存储的值
}
2)创建 Context
| context.Background() | 根 Context,通常用于 main 函数 |
| context.TODO() | 占位 Context,用于函数签名需要 Context 但尚未确定用哪个的场景 |
| context.WithCancel(parent) | 返回可手动取消的 Context |
| context.WithTimeout(parent, duration) | 返回超时自动取消的 Context |
| context.WithDeadline(parent, time) | 返回指定时间点自动取消的 Context |
| context.WithValue(parent, key, val) | 返回带值的 Context |
3)WithCancel:手动取消
func monitor(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("收到取消信号,退出监控")
return
default:
fmt.Println("监控中…")
time.Sleep(1 * time.Second)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go monitor(ctx)
time.Sleep(3 * time.Second)
cancel() // 发送取消信号
time.Sleep(1 * time.Second)
}
4)WithTimeout:自动超时取消
func monitor(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("超时退出")
return
default:
fmt.Println("监控中…")
time.Sleep(1 * time.Second)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // 确保资源释放
go monitor(ctx)
time.Sleep(6 * time.Second)
}
defer cancel() 的作用:
- 如果超时自动触发,cancel 被多次调用是安全的
- 如果主函数提前返回,defer 会提前取消 context,释放定时器资源
- 即使超时后自动取消,调用 cancel 也是安全的(不会产生副作用),这是一种最佳实践
5)Context 的树形结构
Context 可形成树形结构,父 Context 取消会级联取消所有子 Context。
parent, cancelParent := context.WithCancel(context.Background())
child, _ := context.WithCancel(parent) // 基于父 Context 创建
cancelParent() // 父 Context 取消,子 Context 也会被取消
6)WithValue:传递请求范围的数据
type contextKey string // 自定义类型作为 key,包内私有,避免冲突
func main() {
ctx := context.WithValue(context.Background(), contextKey("traceID"), "abc-123")
process(ctx)
}
func process(ctx context.Context) {
traceID := ctx.Value(contextKey("traceID")).(string)
fmt.Println("traceID:", traceID)
}
关于 key 的设计:
// 错误:使用字符串作为 key,可能与其他包冲突
ctx = context.WithValue(ctx, "traceID", "abc")
// 正确:使用自定义类型,包内私有,避免冲突
type contextKey string
ctx = context.WithValue(ctx, contextKey("traceID"), "abc")
使用自定义类型作为 key 的原因:如果用字符串 "traceID" 作 key,其他包也可能用同名 key,导致值被意外覆盖。自定义类型 contextKey 是包内私有的,其他包无法构造相同的 key。
关于 Value 的用途:
Context 的 Value 方法用于传递请求范围的数据,即在整个请求处理链路中都需要使用的信息(如 traceID、userID),而非某个具体函数的参数。这类数据通常由上层统一设置,下层通过 Context 读取,避免在每个函数签名中显式传递这些跨层数据。
7)实战示例:集成使用
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ctx = context.WithValue(ctx, contextKey("traceID"), "abc-123")
go worker(ctx)
time.Sleep(3 * time.Second)
// 可提前取消:cancel()
}
func worker(ctx context.Context) {
traceID := ctx.Value(contextKey("traceID")).(string)
fmt.Println("worker started, traceID:", traceID)
for {
select {
case <-ctx.Done():
fmt.Println("worker stopped:", ctx.Err())
return
default:
fmt.Println("working…")
time.Sleep(1 * time.Second)
}
}
}
9. 小结
| Goroutine 启动 | go 关键字 + 函数;主协程退出会终止所有子协程 |
| 循环变量陷阱 | 通过参数传递(推荐)或临时变量解决闭包捕获问题 |
| GMP 模型 | G(协程)、M(线程)、P(逻辑处理器)三层调度;工作窃取负载均衡 |
| 调度时机 | 系统调用、channel 操作、函数调用边界;Go 1.14+ 支持信号抢占 |
| WaitGroup | Add、Done、Wait;Add 在启动协程前调用,Done 和 Add 数量必须匹配 |
| Mutex | Lock/Unlock 保护共享资源;锁不能复制 |
| RWMutex | 读可以并发,写时不能读也不能写;读多写少场景性能优于 Mutex;用匿名函数缩小临界区 |
| atomic | 简单数值类型原子操作,性能优于 Mutex |
| Channel | 无缓冲(同步) vs 有缓冲(异步);必须用 make 初始化 |
| 单向 Channel | chan<- 只写、<-chan 只读;双向可转单向,反之不可 |
| 关闭 Channel | 发送方关闭;已关闭可接收(零值)但不可发送 |
| Select | 监控多个 Channel;多个就绪时随机选择;default 实现非阻塞 |
| Context | 取消信号、超时控制、值传递;WithCancel、WithTimeout、WithValue |
| Context key | 使用自定义类型作为 key,避免包间冲突 |
| Context value | 传递请求范围的数据(如 traceID),而非函数参数 |



