Go:包、测试、调试与并发取消
前置:函数与错误。目标是把可运行变成可验证:结果错误时有定位路径,任务取消后后台工作也能停止。Go 1.27.2,Windows/Linux 普通测试通过属于历史记录,本轮未执行示例或测试;macOS 仅作平台说明,本项目不执行平台运行验证。竞态检测另列运行条件。
包与模块
一个模块可以包含多个目录,每个目录是一个包。同目录普通源文件应声明同一包;大写开头的名字可被其他包访问,小写名字限制在包内。go.mod 记录模块路径、Go 要求和依赖,go.sum 保存下载内容的校验摘要;提交两者让其他人得到相同依赖约束。go mod tidy 根据真实 import 更新依赖,会改这两个文件,改完检查差异。
例如模块 example.com/study 下的 mathutil/add.go 用 package mathutil,导出 func Add(a, b int) int;main.go 中 import "example.com/study/mathutil" 再调用 mathutil.Add(1, 2)。不要把包目录写成电脑上的绝对路径。包之间不能循环 import;遇到 import cycle 应理清依赖方向,而不是把全部代码搬进 main。
构造函数与依赖方向
前置:结构体、接口和错误。依赖注入的起点是通过参数明确传入依赖,不要求使用某个框架或注解。旧 Kratos 笔记中这项通用经验保留在此;旧框架版本的脚手架命令不再作为入门流程。
package main
import "fmt"
type Store struct { Prefix string }
type Service struct { store *Store }
func NewService(store *Store) (*Service, error) {
if store == nil { return nil, fmt.Errorf("store is required") }
return &Service{store: store}, nil
}
func (s *Service) Title(name string) string { return s.store.Prefix + name }
func main() {
service, err := NewService(&Store{Prefix: "学习 "})
if err != nil { panic(err) }
fmt.Println(service.Title("Go"))
_, err = NewService(nil)
fmt.Println(err != nil)
}预期输出:
学习 Go
true构造函数检查必需依赖,调用方负责初始化和生命周期;初始化失败时释放已经取得的资源。先用普通函数表达依赖图,只有复杂度确实需要时才引入代码生成工具。未导出字段和任意 inject 标签不会自动生成注入行为。
练习:传 nil 并确认错误先被处理,避免把错误留到方法调用时才 panic。
go test 与表驱动测试
依次执行 mkdir go-sum、cd go-sum、go mod init example.com/study,在这个新模块目录建 sum.go 和 sum_test.go。_test.go 不进入正常应用构建;Test 开头的测试接收 *testing.T,失败时报告实际和预期值。下面是两个完整文件,不要拼进一个文件。
sum.go:
package study
func Sum(values []int) int {
total := 0
for _, value := range values { total += value }
return total
}sum_test.go:
package study
import "testing"
func TestSum(t *testing.T) {
cases := []struct { name string; input []int; want int }{
{"empty", nil, 0},
{"positive", []int{1, 2, 3}, 6},
{"negative", []int{-3, 1}, -2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Sum(tc.input); got != tc.want {
t.Fatalf("Sum(%v) = %d, want %d", tc.input, got, tc.want)
}
})
}
}执行 go test -v ./...,预期三个子测试 PASS;把累加改为 total = value,positive 应失败,报告得到 3、预期 6,然后恢复代码。用失败验证测试确实能发现问题。go test -run TestSum/negative -count=1 . 只跑指定子测试且不复用测试缓存;./... 则包含模块下子包。
调试顺序:先复现输入,缩小到单个测试,读 文件:行号,再用 t.Logf 打印中间值或编辑器断点逐步执行。先检查边界、零值和 err;不要用延时掩盖偶发失败。go vet ./... 可发现一些格式串等静态问题,但通过不代表业务正确。
goroutine 与 channel 的完成协议
前置:函数、闭包和 make。go 启动函数后,当前流程继续执行;main 返回时程序结束,不会替你等待后台工作。channel 可以交换值,也能建立完成顺序。
package main
import "fmt"
func main() {
result := make(chan int)
go func() {
result <- 2 + 3
close(result)
}()
value, ok := <-result
fmt.Println(value, ok)
value, ok = <-result
fmt.Println(value, ok)
}预期输出:
5 true
0 false无缓冲通道的发送等接收方准备好,接收也等值到达。第二次接收观察到已关闭且没有剩余数据,得到元素零值与 false。通常由知道“不会再发送”的发送方关闭;接收方贸然 close 会让发送者 panic。close 表达协议结束,不是释放内存的必需操作。
make(chan int, 1) 允许先缓存一个值,满了仍会阻塞。nil channel 的收发永远阻塞。不要用 Sleep 代替等待结果。
练习:删除 close,第二次接收不能完成。第一次成功不能证明整个协议正确;恢复 close 再继续。下一节处理接收方提前离开的情况。
goroutine、channel 与 context 取消
goroutine 是由 Go 调度的并发执行单元,go func() 启动后主流程不会自动等待它。channel 在 goroutine 间传值:无缓冲通道发送需等接收方,缓冲满了也会等待。关闭通道表示以后不会再发送;通常由发送方负责 close,接收方通过 range 读到结束。向已关闭通道发送会 panic。
下面是独立 main.go。另建空目录,先执行 go mod init example.com/cancel,保存下面的 main.go 后执行 go run .,勿与前面的 study 包混放。生成器在阻塞发送时也监听取消信号,done 通知调用方它确实已退出:
package main
import (
"context"
"fmt"
)
func numbers(ctx context.Context) (<-chan int, <-chan struct{}) {
out, done := make(chan int), make(chan struct{})
go func() {
defer close(done)
defer close(out)
for n := 1; ; n++ {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out, done
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
out, done := numbers(ctx)
fmt.Println(<-out, <-out)
cancel()
<-done
fmt.Println(ctx.Err())
}预期输出:
1 2
context canceledcontext 是协作取消:cancel 不会强杀 goroutine,工作函数必须观察 Done。若把 select 换成单独 out <- n,停止接收后生产者会一直阻塞,造成 goroutine 泄漏。增加缓冲只推迟阻塞,不能解决退出协议。请求有期限时用 context.WithTimeout 并 defer cancel;不要把可选业务参数塞进 context。
数据竞争、Mutex 与 race
多个 goroutine 读写同一变量,至少一个是写入且缺乏同步,就可能产生数据竞争。count++ 是读改写组合,不是自动线程安全。可以由一个 goroutine 持有数据,通过 channel 收集请求;也可以用 sync.Mutex 保护所有相关读写,再用 sync.WaitGroup 等待任务完成。选能让数据所有权清楚的方式,不要混用多套同步机制来掩盖问题。
Mutex 临界区中使用 Lock/Unlock;不要复制已经使用过的 Mutex。WaitGroup 的 Add 在启动 goroutine 之前调用,工作完成后 Done,调用方 Wait。HTTP handler 可能同时处理多个请求,不要因为本地只开一个浏览器就假定没有并发。
GOMAXPROCS 限制可同时执行 Go 代码的操作系统线程数,不是 goroutine 数量上限。默认值受 Go 版本及运行环境影响;不要照搬“必须手工设为 NumCPU 才能多核”的建议。它也不能代替同步或结束协议,性能调整先用测量证明。
go test -race ./... 能检查本次执行触发的数据竞争,并非证明所有路径无竞态。它要求支持的平台及 C 工具链,若提示 -race requires cgo,先检查 go env CGO_ENABLED 和 C 编译器;不要把普通测试通过写成 race 通过。课程普通测试不依赖 race 环境,实际 race 结果在验证记录单列。
练习与自检
给 Sum 加入 [0,0] 和 [5,-5],均应为 0。把生成器消费者改为只取一个值后 cancel,验证仍会退出。解释为什么用 time.Sleep 等待后台任务无法保证完成。最后设计测试,使“取消后不再有人接收”也能完成,而不是依赖幸运调度。
参考解法
表中增加两行并运行测试。消费者只调用一次 <-out,随后 cancel 和 <-done,预期输出 1 后正常退出。测试可用 select { case <-done: case <-time.After(time.Second): t.Fatal("未退出") } 检测泄漏;超时是失败上限,不是用延时等待成功。Sleep 只保证至少经过一段时间,不保证工作完成,done/WaitGroup 才提供完成信号。
继续阅读
- 开发实践
- 开发实践
- 开发实践