主题
Go 语法速查卡
基于 k8s-installer 项目实战学习整理,随学习持续更新。最后更新:2026-09-11。
1. 程序结构
go
package main // 可执行程序(库则用别的包名)
import "fmt" // 引入标准库包
func main() { // 程序入口,从这里开始执行
fmt.Println("你好")
}package main+func main()= 可执行程序;否则是库,给别人 import- 无分号;左花括号必须在函数名同一行
2. 变量与类型
go
var name string = "venus" // 完整写法:var + 变量名 + 类型 + 值
var count = 9 // 省略类型(自动推断)
count2 := 10 // 短声明(最常用),只能在函数内用
count2 = 11 // 给已存在变量赋值用 = (不是 :=)| 类型 | 说明 | Printf 占位符 |
|---|---|---|
int | 整数 | %d |
float64 | 小数 | %f |
string | 字符串 | %s |
bool | 真假 | %t |
| 任意类型 | 自动匹配 | %v |
静态类型:变量类型声明后固定,int 变量塞字符串 → 编译期报错(cannot use "nine" (untyped string constant) as int value in assignment)。程序根本不运行。
陷阱:裸词不带引号 = 变量名(undefined: xxx);带双引号才是字符串。
3. 打印
go
fmt.Println("a", 1, true) // 打印多个值,自动加空格和换行
fmt.Printf("集群:%s 节点数:%d\n", name, n) // 格式串 + 占位符,值按顺序填充
fmt.Sprintf("集群:%s", name) // 返回字符串(不打印),用于生成内容- Printf 占位符数量必须与参数一一对应;类型不匹配输出
%!d(string=xxx) \n换行、\t制表符(转义字符)- 注意:Println 自带换行,别在 Sprintf/字符串里再加
\n造成双换行
Printf vs Sprintf(别混,签名就不同):
| 函数 | 干什么 | 返回值 |
|---|---|---|
fmt.Printf(...) | 打印到 stdout | (int, error) —— 写了多少字节 |
fmt.Sprintf(...) | 生成字符串存起来 | string |
口诀:S = String = 生成字符串并返回。判断标准:这个格式化结果还要继续用吗?要(return/拼接)→ Sprintf;只给人看 → Printf。
go
// ❌ 编译错:too many return values have (int, error) want (string)
func (c *FakeConn) Ping() string { return fmt.Printf("%s 通了", c.nodeName) }
// ✅
func (c *FakeConn) Ping() string { return fmt.Sprintf("%s 通了", c.nodeName) }类比:Printf ≈ bash 直接 echo(进终端,手里没东西);Sprintf ≈ msg=$(...)(结果接住,后面还能用)。
4. 函数
go
// 单返回值:参数写类型,返回值类型写在后面
func add(a int, b int) int {
return a + b
}
// 同类型参数可简写
func add(a, b int) int { return a + b }
// 多返回值(Go 招牌):返回结果 + 错误
func check(name string) (string, bool) {
if name == "" {
return "无效", false
}
return "健康", true
}接收多返回值:
go
status, ok := check("venus") // 左边两个变量,一一对应两个返回值5. 条件与循环
go
// if 不需要括号
if nodes > 10 {
fmt.Println("大集群")
} else {
fmt.Println("小集群")
}
// if 带初始化语句(Go 最常见写法!):先调用,再判断
if err := phase.Prepare(node, cfg.SSH); err != nil {
fmt.Println("失败:", err)
}
// err 只在 if 块内有效(作用域限制)
// for 是唯一的循环
for i := 1; i <= 3; i++ { ... }
// for range 遍历 slice/map
for i, name := range nodes { // i=序号, name=元素
fmt.Println(i, name)
}
for _, name := range nodes { // 不要序号用 _ 占位
fmt.Println(name)
}if 决定"做什么",比较运算决定"是什么" —— 别用 if 去算一个布尔值:
go
// ✅ 单一判断:比较运算的结果本身就是 bool,可以直接赋值/传参/塞进 struct
ok := nodeName != "worker-2"
// ⚠️ 也能用,但只在"默认值 + 例外"或多步推导(要调用函数、判好几样)时才值得
ok := true
if nodeName == "worker-2" {
ok = false // 注意方向:bool 零值是 false,只写一个 true 分支容易写反
}
// ❌ 用 if 包住"相同动作" → 相同的代码被复制两份
if nodeName == "worker-2" {
ch <- checkResult{nodeName, false}
} else {
ch <- checkResult{nodeName, true}
}自测问题:"if 两个分支里有多少行是一样的?" 一样多 → 说明 if 放错层了:应该只让它决定那个变量,"动作"统一放到分支外面执行一次(否则以后给发送的值加个字段,得改两处,漏一处就是 bug)。
实测对比(2026-09-11 扫 k8s-installer,15 个 .go / 1669 行):if err := ...; err != nil 这种分支里要做动作的写法 47 处;而"把比较结果接成布尔变量"的写法 0 处。项目里的 if 几乎全是错误分支 —— 算值靠比较运算,不靠 if。
6. slice(切片/动态数组)
go
nodes := []string{"master-1", "computing-1"} // 声明
nodes = append(nodes, "computing-2") // 追加:必须接住返回值!
fmt.Println(len(nodes)) // 长度
fmt.Println(nodes[len(nodes)-1]) // 最后一个元素
// 合并两个 slice(三个点 = 展开)
all := append(c.Masters, c.Workers...) // masters + workers 合并数组 vs 切片 vs 匿名 struct(k8s-installer docker.go 实战):
go
// [2]string = 数组:长度固定,长度是类型的一部分([2] 和 [3] 是两种类型!)
// []string = 切片:长度可变
// [][2]string = 元素是"恰好装 2 个 string 的数组"的切片 → 用于装(本地路径, 远程路径)对
uploads := [][2]string{
{"assets/docker/docker.tgz", "/tmp/docker.tgz"},
}
// 用 up[0] / up[1] 取,可读性差,含义靠注释
// 更好的写法:匿名 struct(etcd.go/node.go 同款),字段有名有姓
uploads := []struct {
data []byte
path string
mode string
}{
{caCertPEM, "/etc/etcd/ssl/etcd-ca.pem", "0644"},
}
// 用 up.data / up.path / up.mode- 判断标准:只有 2-3 个值、含义明显用 tuple(
[2]string);字段多、要可读性用匿名 struct
7. struct(结构体)
go
// 定义类型
type Node struct {
Name string `yaml:"name"` // yaml 标签:YAML 字段 ↔ Go 字段 对应
Host string `yaml:"host"`
}
// 创建实例
n1 := Node{"master-1", "172.16.11.11"} // 按字段顺序
n2 := Node{Name: "computing-1"} // 按名字(推荐,可省略字段)
// struct 嵌套(组合)
type Config struct {
SSH SSH `yaml:"ssh"` // 单个 SSH
Masters []Node `yaml:"masters"` // Node 列表(复数概念用 [])
Workers []Node `yaml:"workers"`
}X Node= 单个;X []Node= Node 列表。字段名复数概念 → 用[]- 同一字段在字面量里只能出现一次,多个元素放同一个
[]里
8. 方法(Go 的面向对象)
go
// 值接收者:操作副本,不改原对象
func (n Node) describe() string {
return fmt.Sprintf("%s 角色:%s", n.Name, n.Role)
}
// 指针接收者(*号):操作原件;数据大或要修改字段时用
func (c *Config) AllNodes() []Node {
return append(c.Masters, c.Workers...)
}
// 调用
n1.describe()
cfg.AllNodes()9. 指针(& 取地址 / * 解引用 / nil)
go
var target *config.Node // 指针类型:一个"箭头",指向某个 Node;初始为 nil(指向空气)
target = &cfg.Workers[i] // &x = 取 x 的地址 → 让箭头指向 workers 里第 i 个节点
if target == nil { // nil = 空指针 = 没找到。判断查找失败:if x == nil
fmt.Println("没找到")
}
value := *target // *p = 解引用:顺着箭头把指向的值取出来&与*互为逆操作:&值→地址;*地址→值- nil 是"没有/未找到"的表达,只有指针/接口/map/slice 才有这个"空"状态
- 函数要"往里写值"(如
fmt.Scanln(&answer))必须传地址;只读小数据直接传值 *的三种身份(新手必混):写法 含义 *config.Node(类型位置)指针类型 = "指向 Node 的箭头" *target(表达式位置)解引用 = "顺着箭头取值" []Node切片 = 多个(跟 *无关)
返回指针还是值(写函数时怎么定):
- 心智模型:值 = 复印件,指针 = 原文件句柄
- 判断标准不是"数据大小",是"这东西能不能被复制"
- 活资源(连接/文件/锁/socket)→ 必须指针
*T:背后是 socket/状态机,复制没意义;带sync.Mutex的结构体被拷贝会直接废掉(go vet copylocks 会告警) - 纯数据(配置/节点列表/计算结果)→ 值
T够了,复印件不影响原数据
go
// sshx.go —— SSH 连接是活资源 → 指针
func New(host string, port int, user, password string) (*Client, error)
// config.go —— 纯数据 → 值
func (c *Config) AllNodes() []Node- 口诀:
New/Dial/Open/Connect这类"创建资源"的函数几乎总是返回*T - 什么时候函数该返回
*T:OpenLog(文件)、NewEtcdClient(连接) → 指针;ParseCluster(解析纯数据) → 值
go
// 读用户输入(二次确认用):Scanln 往 answer 里写,必须传 &
var answer string
fmt.Scanln(&answer)
if answer != "yes" { return }
// break:跳出循环(找到就停)
for i, n := range workers {
if n.Name == name {
target = &workers[i] // 要改原元素必须用索引 i,不能用副本 n
break
}
}10. 错误处理(Go 代码的灵魂)
go
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取失败: %w", err) // %w 包装原始错误
}
return &cfg, nil // 成功:值 + nil;失败:nil + 错误
}- 契约由签名决定:返回
error的函数,成功返回nil,失败返回非 nil 错误。不能返回字符串(编译期强制) nil= 指针/接口的空值(类似 NULL)&cfg= 取地址(得到*Config指针)- 模式:每个可能失败的操作 →
if err != nil { return ...err }→ 一路向下,最后成功返回
error 检查纪律(函数声明返回 error ≠ 调用方必须接,但默认必须检查):
| 写法 | 编译 | 含义 |
|---|---|---|
if err := fn(); err != nil { ... } | ✅ | 接住并处理(项目惯例) |
err := fn() 然后不用 | ❌ | "声明了变量没用" |
out, _ := fn() | ✅ | 显式丢弃 error |
fn() 裸调用 | ✅ | 完全忽略返回值 |
- 判断"命令是否成功"必须用
err == nil(退出码为 0),不能判 out 非空——test -x A && test -x B成功时无输出(out 空),链中途失败时 out 又可能有前半段输出 → 两种 bug 都踩过(docker.go 幂等误判) - 看到
(result, err)或() error,默认处理 err;只有能说出"这个失败无所谓"才允许_或裸调用 - 写
_= 对读代码的人说"我故意不管这个错误"
11. 匿名函数与闭包
go
// 函数是一等公民:可赋值给变量、当参数传
step := func(name string, fn func() error) { // fn 参数:一个返回 error 的函数
fmt.Println("开始:", name)
if err := fn(); err != nil { // 在这里才真正执行传进来的函数!
os.Exit(1)
}
}
// 调用:匿名函数作为参数(定义 ≠ 执行,在 step 内部 fn() 时才跑)
step("部署", func() error {
...实际操作...
return nil
})定义 ≠ 执行:匿名函数只是"说明书",到
fn()那行才运行闭包:匿名函数能直接访问定义处外层作用域的变量(cfg、master),无需传参
匿名函数 = 没有名字的
func:形状就是func() { ... }—— 去掉名字、去掉参数表它是个"值"(跟
startTime一样),光写在那儿等于没写,必须用掉:用法 形状 存进变量,稍后调用 f := func() { ... }→ 之后f()写在那儿当场执行 func() { ... }()← 末尾那对括号 = "现在就执行"末尾那对
()最容易漏:漏了不是编译错,而是编译通过、函数压根没跑(字面量只是一份说明书)。写成go goroutine() {...}()更是直接编译错 —— 匿名函数的定义就是"没有名字"起一个"跑完就扔"的 goroutine 就是这个形状:
go func() { ... }()(见第 18 章)
12. 退出码与程序间通信
go
os.Exit(1) // 以退出码 1 结束 = 告诉调用者"我失败了"- Unix 惯例:0 = 成功,非 0 = 失败
- 脚本用
$?/$LASTEXITCODE判断成败,不靠读屏幕输出 - 失败必须"喊出来"(非 0 退出码),否则调用方以为成功
13. defer(把清理动作交给 Go)
go
func writeLog() error {
f, err := os.Create("demo.log")
if err != nil {
return err // 创建失败:此时没有文件要关
}
defer f.Close() // 注册清理:函数无论从哪条 return 走,都会执行
_, err = f.WriteString("hello\n")
return err
}- 解决什么问题:函数有多条 return 路径(成功/中途失败),手动每条都写
Close()一定会漏。defer 注册一次,自动兜底 - 执行时机:函数 return 之前;后注册的先执行(LIFO,像栈)
- 位置有讲究:
defer f.Close()放在if err != nil检查之后 —— 创建失败时没有资源可关 - defer 那行不执行,只登记(2026-09-09 用户疑问"为什么写入之前就关闭文件"):
defer f.Close()只是把 Close 压栈登记,函数退出时才倒序执行。所以真实顺序是Create → 登记Close → WriteString → return(此时才 Close),写入一定在关闭之前- 反面验证:把 defer 放在
if err != nil之前、且创建失败 → defer 照样执行,此时f == nil,os.File.Close()有 nil 保护返回invalid argument(不 panic,但白跑一次);换成没有 nil 保护的类型(如*ssh.Client)就会 panic
- 反面验证:把 defer 放在
- 循环里的陷阱:
for { defer conn.Close() }不会每次迭代关,而是整个函数返回时才一起关 → 连接堆积。循环内建连要么显式关,要么抽成独立函数 - 项目对应:k8s-installer
sshx.go里c.Close()、ssh-error 课的defer conn.Close()
14. map(键值对)
go
// 声明 + 初始化(必须初始化,否则是 nil)
nodes := map[string]string{"master-1": "172.16.11.11"}
nodes["computing-3"] = "172.16.11.16" // 新增(键不存在)或覆盖(键已存在),不需要 append
ip, ok := nodes["computing-9"] // comma-ok:ok 是 bool,true=键存在
delete(nodes, "computing-3") // 删除,无返回值
len(nodes) // 元素个数
for name, ip := range nodes { // range 对 map 返回 (键, 值)
fmt.Println(name, ip)
}- 读不存在的键不报错,返回值类型的零值(string→
""、int→0)。要区分"不存在"和"值是零值"就必须用 comma-ok - 写 nil map 会 panic(
assignment to entry in nil map),读 nil map 安全 - 遍历顺序随机(Go 故意打乱),别依赖顺序 —— 这点和 slice 不同
- 什么时候用 map:按"名字/键"直接查。对比 slice 要遍历找
master.go:101pemMap := map[string][2][]byte{}+pemMap[s.file] = [2][]byte{certPEM, keyPEM}—— 按文件名存证书,后面按文件名取- 如果用
[]struct{file string; cert, key []byte},取的时候得写循环匹配
值的类型可以是 struct(但别无脑包):
go
type Node struct{ IP string }
nodes := map[string]Node{"master-1": {"172.16.11.11"}}
fmt.Println(nodes["master-1"]) // 输出 {172.16.11.11} —— 带花括号!
fmt.Println(nodes["no-such-key"]) // 输出 {} —— struct 零值 = 字段全零值- 打印 struct 会带花括号:值是 string 时零值打印
"",值是 struct 时零值打印{} - 单字段 struct 包一层没有收益(
Node{IP: "x"}vs 直接"x")。判断标准:值需要多个字段或要挂方法才值得包 struct ——master.go的值是 cert+key 两样东西,所以用容器[2][]byte [2][]byte≠[]byte:[]byte是字节切片;[2][]byte是长度 2 的数组,元素是字节切片(同[2]stringvs[]string)- 局部
type Node struct写在函数内合法,但项目惯例是声明在包级
15. 接口 interface(只关心"能做什么")
go
// 标准库对 error 的定义就这一行 —— 你已经用了上百次
type error interface {
Error() string
}
// 自定义错误类型:只要实现 Error() string,它**就是** error(隐式实现,无 implements)
type NodeNotFoundError struct{ Name string }
func (e NodeNotFoundError) Error() string {
return fmt.Sprintf("节点 %s 不存在", e.Name)
}
func findNode(name string) (string, error) { // 返回接口类型
return "", NodeNotFoundError{Name: name} // 实际返回具体类型
}
// 接口 + 多态:任何实现了 Run() error 的类型都能塞进 []Phase
type Phase interface {
Run() error
}
func runAll(phases []Phase) error {
for _, p := range phases {
if err := p.Run(); err != nil {
return err
}
}
return nil
}- 接口 = 方法集合的契约:不描述"是什么",只描述"能做什么"
- 隐式实现:不需要
implements/继承。类型有对应方法就自动满足接口 —— 这是 Go 和 Java 最大的差别之一 - 接口变量装的是"具体类型 + 值":
err的静态类型永远是error,动态类型是具体类型(如NodeNotFoundError)。fmt.Println(err)调用的是具体类型的Error() err != nil的含义:接口变量为 nil = 里面既没有类型也没有值。返回具体类型的零值指针当 error 会踩坑(如return (*MyErr)(nil)不等于 nil)- 用
errors.As(err, &target)从 error 里取回具体类型(需要import "errors") - 判断标准:多个类型要"用同一种方式被调用"→ 接口;只有一种实现 → 先别抽接口
接口从哪来:从使用方反推(不是凭空设计)
接口不是"设计"出来的,是从调用点读出来的。 三步:
go
// 第 1 步:先用具体类型把使用方写通(这一步不要想接口)
func precheck(c *FakeConn) error {
out, err := c.Run("docker --version") // ← 只调了 Run
...
}
// 第 2 步:数一数这个函数体里调了 c 的哪几个方法 → 只有一个 Run
// 把这个方法签名去掉接收者,剩下的就是接口的全部内容
// 第 3 步:把使用方函数的参数类型换成接口名 —— 唯一一处改动
type Runner interface {
Run(cmd string) (string, error)
}
func precheck(r Runner) error { ... }硬规则:接口成员 = 使用方真正调用的清单,多一个都不行。
go
type Storer interface {
Put(key string, data []byte) error
Path() string // ← 多写了这一行
}Path() 一进来,对象存储实现就永久满足不了这个接口(对象存储没有本地路径),编译器直接拒绝:
text
*S3Store does not implement Storer (missing method Path)接口里多写一个方法 = 缩小了能塞进来的类型范围。 所以社区说的"小接口"不是审美偏好,是照实写"使用方调用清单"的自然结果。
什么时候值得抽:
| 情况 | 抽 |
|---|---|
| 使用方真的要能塞两种以上东西进去(本地执行 / SSH / dry-run) | ✅ |
隔离外部系统(Kafka / Redis / SMTP / HTTP API),业务代码里不该出现 http.Post(...) | ✅ |
| 跨包测试替身(实现在别的包,你替换不了) | ✅ |
| "以后可能会换数据库" | ❌ |
| "方便写 Mock"(同包) | ❌ |
判据的分界线:"已经存在两个实现"才抽;"想象中可能有一个"不抽。
16. 并发(goroutine / WaitGroup)
起一个 goroutine:加 go 关键字,这一行就不再阻塞,函数被扔进新 goroutine 里跑,主流程立刻继续往下走。
go
go checkNode(name) // 一行启动一条 goroutine,没有线程池、没有 future等它们全部干完:sync.WaitGroup = 计数闸门(Add 记账 → Done 减 1 → Wait 阻塞到归零)。
go
var wg sync.WaitGroup // 零值即可用,不需要 init(Go 的"零值可用"设计)
for _, name := range names {
wg.Add(1) // 记账 +1,必须在 go 之前
go checkNode(name, &wg)
}
wg.Wait() // 阻塞到计数归零被调函数负责"喊完成":
go
func checkNode(name string, wg *sync.WaitGroup) {
defer wg.Done() // 减 1;用 defer 保证中途 return 也会喊
...
}Add(n)的总数必须等于启动的 goroutine 个数,一个 goroutine 一次Done,账要配平Add必须在go之前:反过来的话Wait()可能在 Add 执行前就把"计数=0"当成"全干完了"直接放行(偶发、难复现的竞态)- 传参必须传指针
*sync.WaitGroup:值传递拿到的是副本,Done()记在另一个账本上 → 主 goroutine 的账本永远不减 →Wait()永远等下去(卡死)
实测收益(go-learn/concurrent-precheck,2026-09-10)
| 跑法 | 耗时 | 说明 |
|---|---|---|
串行(3 个节点各 Sleep 1s) | 3.0023738s | 一条走完再走下一条,输出严格有序 |
| 并发(3 条 goroutine 同时跑) | 1.0012883s | ≈ 最慢的那个 + 1ms 调度开销,输出顺序打乱 |
一次运行同时跑两段(2026-09-10 最终验收):
text
开始检查 master-1 ← 串行:严格一轮一轮
完成检查 master-1
开始检查 worker-1
完成检查 worker-1
开始检查 worker-2
完成检查 worker-2
串行总耗时: 3.0023738s
-------分割线------
开始检查 master-1 ← 并发:三个几乎同时开跑
开始检查 worker-2
开始检查 worker-1
完成检查 worker-1 ← 约 1 秒后几乎同时收尾
完成检查 worker-2
完成检查 master-1
并发总耗时: 1.0012883s- 收益来自"等",不是"算":节点预检是 I/O 密集(等 SSH、等 etcd 就绪),并发收益近线性(9 个节点 → 9s 变 1s);纯 CPU 计算挤在一个核上抢时间片,收益就没了
- k8s-installer 的并发预检就是这个动机
顺序不可控(不是 bug)
代码里节点顺序是 master-1, worker-1, worker-2,实测启动顺序是 master-1, worker-2, worker-1、收尾顺序是 worker-1, worker-2, master-1 —— 三种顺序全不一致。goroutine 的启动顺序和代码顺序无关;输出也会交错成一团、分不清来源。真实并发程序必须给日志加节点前缀(或加锁)。
主 goroutine 退出 = 进程退出
go
for _, name := range names {
go checkNode(name) // ❌ 只加 go,不加任何等待
}
// main 在这里 return → 进程立刻结束 → 子 goroutine 全被杀,输出全丢实测现象:只打印了一行 开始检查 master-1,总耗时 0s。"启动了 goroutine"和"看到它的输出"是两件事。
计时:时刻 vs 时长
go
start := time.Now() // time.Time = 一个"时刻"
for _, name := range names { runSerial(name) }
fmt.Println(time.Since(start)) // time.Duration = 一段"时长"time.Now()是时刻,两个时刻相减(.Sub())才是时长;time.Since(start)就是time.Now().Sub(start)的简写- 起点要放在被测代码之前、且在正确层级:写在循环体内 → 每轮重置,永远算不出总数
Println(time.Time)尾巴上的m=+0.000610101是 Go 挂的单调时钟读数:墙上时钟会被 NTP 拨动(集群里的 chronyd 就在干这事),用它算耗时会算出负数;time.Since内部用单调读数,不受影响time.Duration的数字字面量只认纳秒:time.Sleep(1)= 1 纳秒、time.Sleep(1000)= 1 微秒,都等于没睡。正确写法是数字 × 单位常量:1 * time.Second/1000 * time.Millisecond- 副产物洞察:每轮两次
fmt.Println打控制台 ≈550µs,远超Sleep(1µs)本身 → 输出 I/O 可能比计算贵几百倍(对应 k8s-installer 里"逐条打印进度会拖慢安装")
nil 指针 panic 怎么读
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x0 ...] ← addr=0x0 就是"地址 0"= nil
goroutine 1 [running]: ← 1 = 主 goroutine
sync.(*WaitGroup).Add(0x0, 0x1) ← 接收者是 0x0!方法没进函数体就崩
main.main()
.../concurrent-precheck/main.go:21 ← ★ 自己的代码,出事行号三步:① 从下往上看带自己文件路径的那几行(上面几行是标准库内部,先忽略);② 看中间那行的接收者/参数是不是 0x0;③ 见到 addr=0x0 / nil pointer dereference → 十有八九是某个指针没初始化。
根因:var x *T 只给该类型的零值,而指针的零值就是 nil,不是"一个新建好的对象"。
| 声明 | 零值 |
|---|---|
var s string | "" |
var n int | 0 |
var wg *sync.WaitGroup | nil |
所以 var wg *sync.WaitGroup + wg.Add(1) 必崩。要真实对象得显式造:
| 写法 | 声明怎么写 | 传参传什么 |
|---|---|---|
| A | var wg sync.WaitGroup(值) | &wg |
| B | wg := &sync.WaitGroup{}(先造对象再取地址) | wg(不加 &) |
& 只用一次:&x 的类型取决于 x 本身——x 是 string → &x 是 *string;x 已经是 *sync.WaitGroup → &x 就成了 **sync.WaitGroup(多取一次,编译器报 cannot use &wg (value of type **"sync".WaitGroup))。
17. 数据竞争(data race)与 sync.Mutex
定义:两个 goroutine 并发访问同一变量、至少一个是写、且没有任何同步手段 → data race。
为什么 count++ 会丢:它不是一步,是三步「读 → 加 → 写」。一旦交错,更新就被覆盖:
goroutine A goroutine B count
读 count (=5) 5
读 count (=5) 5
加 → 6 5
加 → 6 5
写 6 6
写 6 6 ← 两次 +1,只加了一次实测(go-learn/race-demo,2026-09-10)
1000 个 goroutine 各自 count++,正确结果永远是 1000:
| 跑法 | 结果 |
|---|---|
| 无锁,普通 run ×5 | 981 979 984 979 991 —— 丢 9~21 次(约 1~2%) |
无锁 + -race | WARNING: DATA RACE + Found 2 data race(s) + exit status 66 |
加 sync.Mutex | 连跑 10 次全部精确 1000 |
加锁 + -race | 输出 1000,零 WARNING,退出码 0 |
最毒的地方:不报错、不崩溃、退出码 0 —— 你只会拿到一个"看起来完全正常"的错数字。等价于"9 个节点预检,报告说 8 个通过"。
怎么读 -race 报告
WARNING: DATA RACE
Read at 0x00014026ca30 by goroutine 9:
main.worker()
.../race-demo/main.go:12 +0x74 ← 冲突就发生在这一行
main.main.gowrap1()
.../race-demo/main.go:19 +0x2e ← 编译器为 go 语句生成的壳
Previous write at 0x00014026ca30 by goroutine 8:
main.worker() .../race-demo/main.go:12
Goroutine 9 (running) created at:
main.main() .../race-demo/main.go:19 ← 这两个 goroutine 在哪创建- 两边地址相同(
0x...ca30)= 同一个变量 main.main.gowrap1()= 编译器替go worker(&wg)生成的包装函数,不是你的代码有问题Found N data race(s)是去重后的冲突模式数,不是问题总数 → 报了就是真的;没报全不代表没问题exit status 66= Go 的约定(检测到 race 就用 66 退出)→ 可当门禁:go test -race ./...有 race 就非 0
用 sync.Mutex 修
go
var count int
var countMu sync.Mutex // 零值即可用:不用造对象、不用取地址
func worker(wg *sync.WaitGroup) {
defer wg.Done() // 先登记 → 后执行(LIFO)
countMu.Lock()
defer countMu.Unlock() // 紧跟 Lock:任何返回路径都会放锁
count++
}- 锁保护的是变量,不是代码行:只要还有任何一处裸访问同一变量,等于没锁 —— 这是审查代码的活,
-race也测不全 Unlock必须用defer:手写会在 return / panic 路径上漏掉 → 死锁- LIFO 顺序:上面两个 defer 的执行顺序是
Unlock→Done(先交还共享资源,再报完成),语义正确 - 锁的粒度尽量细:
defer wg.Done()不要夹进临界区,它跟共享变量无关 - 包级锁只是教学简化:真实项目把
mu当参数传(像wg那样),避免全局状态 - 死锁现象:程序挂住、光标一直闪 →
Ctrl+C,检查Lock/Unlock是否成对
调试方法论:证据是不对称的
| 你看到的 | 能推出什么 |
|---|---|
| N 次里有 1 次结果不对 | 证明有 bug |
| N 次全对 | 不能证明没 bug(可能只是没跑到) |
-race 报 WARNING | 证明有 bug |
-race 干净 | 当前能拿到的最强证据 |
→ "重复跑 N 次"是调并发 bug 的基本功(PowerShell:1..10 \| ForEach-Object { go run .\main.go }),单次结果永远证明不了任何东西。
什么时候会遇到
- 只有写并发才会遇到;纯串行代码一辈子碰不到(实测
k8s-installer14 个 .go / 1388 行全是串行,0 处并发——那里 race 无处发作,代价是 9 节点预检串行) - 最容易踩的四个场景:① 收集并发结果(往同一 slice/map/计数器写)② 共享失败标志 ③ 懒加载单例(
sync.Once)④ 并发写 map 会直接杀进程(concurrent map writes),比 int 丢数更猛 - 老手的做法不是"天天查 race",而是结构上避免:能靠 channel 传数据就别共享变量,必须共享就用锁。Go 口号:Don't communicate by sharing memory; share memory by communicating.
- 最省事的一条:少用包级变量。值尽量当参数传、结果尽量当返回值收
环境:让 -race 在 Windows 上可用
- Windows 的
-race需要 cgo → 必须有 C 工具链。默认CGO_ENABLED=0且无 gcc 时报go: -race requires cgo; enable cgo by setting CGO_ENABLED=1 - 装:
winget install --id BrechtSanders.WinLibs.POSIX.UCRT -e --accept-package-agreements --accept-source-agreements(WinLibs mingw-w64 gcc 16.1.0) - 开启:
go env -w CGO_ENABLED=1(回退go env -u CGO_ENABLED) - 坑:装完在装之前就开着的终端 / GoLand 里跑会报
cgo: C compiler "gcc" not found—— Windows 的 PATH 只对新启动的进程生效。关掉重开,或在当前窗口刷新:$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
18. channel(goroutine 之间传数据)
channel = 带类型的管道。make(chan string) 造一根只能流 string 的管子;类型就是管子能流的货。
go
ch := make(chan string) // 无缓冲
ch <- "master-1 通过" // 发送:箭头指向管子 = 往里塞货
v := <-ch // 接收:箭头朝外 = 把货拿出来| 动作 | 写法 | 谁做 |
|---|---|---|
| 发送 | ch <- v | 干活的 goroutine |
| 接收 | v := <-ch | 收集者(通常主 goroutine) |
- 无缓冲管道 = 当面交货:没人在对面接,发送方就卡在那一行不动。所以"我干完了"在无缓冲下等于"我干完了并且有人接走了"
- 想要"先塞进队列、我走人" →
make(chan string, 3)带缓冲(3 = 最多先堆 3 个没人收的货) - 发送次数和接收次数必须配平:发 3 次就得收 3 次,少收多收都会卡住
接收:固定次数 vs range
go
// A. 知道要收几条 → 固定次数(最简单,不需要 close)
for i := 0; i < len(nodes); i++ {
fmt.Println(<-ch)
}
// B. 不知道几条 → range,收到 close 才停
for v := range ch {
fmt.Println(v)
}for v := range ch 的语义 = 「一直收,直到这根管道被 close」——不是「收到暂时没货为止」。
管道"暂时没货"和"永远不会有货"是两件事,range 分不清,它只认 close 这一个信号。不 close → 它永远等。
close 规则(三条,记死)
close是广播"不会再有新货了",不是"作废管道"——已经塞进去的货照样收完,收完range才退出- 只有所有发送方都发完之后才能关
- 不能由某一个干活的 worker 各自去关(它关得太早 → 别人一发送就炸)
多发送方的标准收尾 = 再起一个 goroutine,专职"等所有人干完 → 关门":
go
var wg sync.WaitGroup
ch := make(chan string)
for _, node := range nodes {
wg.Add(1) // 必须在 go 之前
go checkNode(node, ch, &wg)
}
go func() { // ← 第三个角色:只管收尾
wg.Wait() // 等所有发送方都发完
close(ch) // 再关门
}()
for v := range ch { // 主 goroutine 专心收结果
fmt.Println(v)
}- 主 goroutine 全程在接收,worker 的发送才不会卡住 →
wg才能归零 - 这个匿名函数不用接参数:闭包直接引用外面的
ch/wg(同runInit的step) - 这就是
concurrent-precheck的 WaitGroup 版 + 结果回传 —— channel 不是"另一套并发",它是把结果送回来的手段
为什么不能把 wg.Wait() + close(ch) 直接写在 main 里(实测死锁)
go
{
wg.Wait() // ← 主 goroutine 停在这
close(ch)
}goroutine 1 [sync.WaitGroup.Wait] ← 主:等在 wg 上
main.main() .../main.go:28
goroutine 7 [chan send] ← worker:等在发送上
main.checkNode(...) .../main.go:14互相等,谁都不动:主等 worker 干完;worker 干完了,但无缓冲管道要"当面交货",主不来接收就出不去;而主卡在 Wait() 里,永远走不到接收循环。→ fatal error: all goroutines are asleep - deadlock!
Go 的裸块
{ }只是一个作用域,不改变执行权 —— 写不写这几行,执行者都是主 goroutine。
实测(go-learn/channel-precheck,2026-09-11)
| 写错的方式 | 实测现象 | 退出码 |
|---|---|---|
range ch 但不 close | fatal error: all goroutines are asleep - deadlock!,goroutine 1 [chan receive] | 2 |
每个 worker 各自 close(ch) | 只收到 1 条结果,其余 worker panic: send on closed channel(goroutine 21/22) | 2 |
wg.Wait() + close(ch) 写在 main 里 | 上面那个死锁闭环 | 2 |
| ✅ WaitGroup + 专职 close 的 goroutine | 3 条结果全出 → 然后才是"全部检查完成";-race 干净 | 0 |
第二种最毒:程序还若无其事打印了"全部检查完成"和总耗时 —— 换成 9 节点预检就是报告写"1 个通过",其余 8 个无声消失。又是"看起来正常的错数字"。
- 实测总耗时
1.0005129s(3 个节点各 1 秒,并发),与concurrent-precheck的 WaitGroup 版等价
货升级为 struct(把结果一起送回来)
只送节点名的话,接收方分不出通过/失败。真实预检要的是"节点 + 结果"两样东西 —— 把 struct 当货:
go
type checkResult struct {
nodeName string
result bool
}
func checkNode(nodeName string, ch chan checkResult, wg *sync.WaitGroup) {
defer wg.Done()
...
ok := nodeName != "worker-2" // 比较运算结果就是 bool
ch <- checkResult{nodeName: nodeName, result: ok} // 发送只写一次
}
ch := make(chan checkResult) // 管子类型 = 货的类型
for v := range ch { // v 现在是 struct,不是 string 了
fmt.Println(v.nodeName, v.result) // 点号取字段(同 struct 那课)
}换货要连带改四处:① struct 类型声明 ② make(chan X) ③ 函数参数 ch chan X ④ 发送处构造值。 wg.Add/Done/Wait、专职 close 的 goroutine、range 收尾一个字都不用动。
- 改到一半编译器会连续拦你两下(参数类型不匹配 → 发送值类型不匹配)。Go 一次只报一个类型错,修完再跑冒下一个 —— 跟着报错走就能改全,这是静态类型替你检查
- 接收端
v不是 string 了,直接Println(v)打出来是{master-1 true}(带花括号,见 §14),要按字段取 - struct 字面量优先按字段名填:2 个字段按顺序填还能看,字段一多容易错位,以后加字段更危险
- "struct 当货"的便宜在哪:以后要加"耗时""失败原因",只改 类型声明 1 处 + 填值 1 处 + 打印 1 处 —— channel 声明和整条收尾链都不用动(加字段比改签名便宜)
常见坑
- 方向写反:
ch <- v是塞、<-ch是拿 - goroutine 里只打印不发送 → 收集方永远等 → 死锁
- 已经有 channel 还留着
sync.Mutex→ 多余,锁保护共享变量的活已经交给管道了 - 收到的货太单薄:只送节点名 → 分不出通过/失败。真实预检要的是"节点 + 结果"两样,把 struct 塞进管道(
chan checkResult)—— 详见上一节 - 报错读法:
[chan receive]= 卡在接收、[chan send]= 卡在发送、[sync.WaitGroup.Wait]= 卡在等齐;方括号里就是"它卡在什么操作上"
19. 文件读写(os 包)
两类 API:先用简单的那个
| 类型 | 函数 | 谁负责开关文件 |
|---|---|---|
| 便捷函数(一次性) | os.WriteFile(path, data, perm) / os.ReadFile(path) | 它自己,你不管 |
| 手动开关 | os.Create(path) / os.Open(path) → 返回 *os.File | 你,所以必须 defer f.Close() |
为什么便捷函数不用你写 defer Close() —— 它自己 defer 了。标准库原文(GOROOT/src/os/file.go):
go
func ReadFile(name string) ([]byte, error) {
f, err := Open(name) // 它自己 Open
if err != nil {
return nil, err
}
defer f.Close() // ★ 它自己 defer Close
...
}规则一句话:谁手里攥着 *os.File,谁负责关。 os.Create 把 f 交给你了 → 你关;os.WriteFile 自己攥着 → 它关。
能力边界(决定用哪个):
| 便捷函数 | 手动 *os.File | |
|---|---|---|
| 能干的 | 一次性全读进内存 / 全写出去 | 追加、Seek、bufio 逐行、边写边算 |
| 代价 | 大文件全塞进内存 | 多写几行 |
| 适合 | 配置文件、小报告 | 日志追加、几十 MB 以上 |
[]byte ↔ string
os.WriteFile 的第二个参数、os.ReadFile 的返回值都是 []byte(字节切片)。互转靠两个转换写法:
go
[]byte(s) // string → 字节
string(b) // 字节 → string(打印前必转,否则打出来是一串数字)目录:MkdirAll 而不是 Mkdir
go
os.MkdirAll("reports", 0755) // 父目录不存在也一起建;已存在 → 静默成功(幂等)
os.Mkdir("reports", 0755) // 已存在 → 报错 mkdir reports: file exists写文件前先 MkdirAll —— 目录不在,WriteFile 直接报 no such file or directory。
权限数字是八进制,前导 0 不能省:0755 / 0644。写成 644 是十进制 644,权限会变成乱码值 —— 编译通过、静默出错。
错误契约的两种走法(同一个程序里可以并存)
| 情形 | 走法 | 效果 |
|---|---|---|
| 后面的代码依赖这一步(写报告) | 打印 + return err / os.Exit(1) | 失败即终止,退出码非 0 |
| 后面的代码不依赖(读一个可选文件) | 打印错误后继续往下走 | 退出码仍为 0 |
反例(实测踩过):if err != nil { fmt.Println(err) } 然后继续往下走 → 目录没建成、文件没写成,函数照样 return nil、main 照样打印"报告已写入"、退出码还是 0。而且它随后 ReadFile 读到的是上一次跑留下的旧文件,报告看起来完全正常 —— 又是一次"看起来正常的错结果"。
os.Create 的正确顺序:
go
f, err := os.Create("reports/manual.txt") // ← 必须先接 err!此时 f 可能是 nil
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer f.Close() // ← 确认拿到 f 之后才 deferf, _ := os.Create(...) 是最典型的坑:丢掉 err → f 为 nil 时照样 defer + WriteString,而 os.File 自带 nil 保护只会返回 invalid argument —— 真正的失败原因(Create 失败)被藏起来了。(换成没有 nil 保护的类型,如 *ssh.Client,直接 panic。)
相对路径的基准 = 进程的工作目录
不是源码目录,不是 exe 目录。实测(2026-09-11):同一份代码,reports 落在两个地方
| 谁跑的 | 工作目录 | reports 落点 |
|---|---|---|
| GoLand(默认 Run Configuration) | D:\data\project\go-learn(项目根,.idea 所在) | go-learn\reports\ |
命令行在包目录里 go run . | D:\data\project\go-learn\file-report | file-report\reports\ |
探针一行 —— 遇到"文件没生成 / 生成在怪地方",先打它:
go
wd, _ := os.Getwd()
fmt.Println("工作目录:", wd)想让它不飘:① 硬编码绝对路径(最差,换机器就废);② 把路径变成命令行参数 —— 这才是 CLI 惯例,k8s-installer -c cluster.yaml 就是干这个。GoLand 侧可把 Run Configuration 的 Working directory 改成 $FileDir$(IDE 配置,与代码无关)。
高频报错速查
| 报错 | 含义 |
|---|---|
undefined: xxx | xxx 没引号被当变量名,但没定义过 |
cannot use "nine" (...) as int value | 类型不匹配,静态类型拦截 |
no new variables on left side of := | 变量已存在,应该用 = 不是 := |
%!d(string=xxx) | Printf 占位符与参数类型/数量不匹配 |
duplicate field name | struct 字面量里同一字段写了两次 |
assignment mismatch: 1 variable but ... returns 2 values | 多返回值没接全 |
cannot use node (variable of type int) as string value | for node := range nodes 单变量拿到的是序号不是元素值,要写成 for _, node := range |
invalid memory address or nil pointer dereference(addr=0x0) | nil 指针:声明了 var x *T 却直接当对象用 → 见第 16 章 |
cannot use &wg (value of type **"sync".WaitGroup) | & 多取了一次地址:变量本身已经是指针 |
declared and not used: name | for range 的某个变量声明了没用(常见于把 i 传给了要 string 的函数) |
改名后 undefined: 旧函数名 | 改名没同步调用点 → 用 GoLand Shift+F6 重构改名,别手敲 |
go: -race requires cgo; enable cgo by setting CGO_ENABLED=1 | Windows 上 -race 需要 cgo + C 工具链 → 装 mingw 并 go env -w CGO_ENABLED=1(见第 17 章) |
cgo: C compiler "gcc" not found: ... not found in %PATH% | gcc 装好了但当前窗口是装之前开的,拿的是老 PATH → 重开窗口或手动刷新 PATH |
exit status 66(不是报错) | -race 检测到数据竞争时的约定退出码,CI 可据此当门禁 |
fatal error: all goroutines are asleep - deadlock! | 看紧跟的 goroutine N [xxx]:[chan receive] = range 在等 close;[chan send] = 发送方等接收;[sync.WaitGroup.Wait] = 主 goroutine 卡在等齐(见第 18 章) |
panic: send on closed channel | 有人往已关闭的管道发送 —— 通常是某个 worker 提前 close 了,别的还在发;伴随现象是"结果只收到一小部分" |
panic: close of closed channel | 管道被关了两次:同一个 goroutine 连关两次,或两个 goroutine 都去关 |
ch.xxx undefined (type chan T has no field or method xxx) | 把管道当成货去取字段了:管道只有发送/接收/关闭三个动作,字段属于装进去的那个 struct 值 |
...(no value) used as value | 调用了没有返回值的函数却当返回值用:如 func f(x string) {...} 没写返回类型,外面却写 err := f(x) |
open X: is a directory | os.Create / os.Open 的目标已经是一个目录(实测:同名文件被换成了目录) |
mkdir X: The system cannot find the path specified | MkdirAll 的路径中间有一段是文件 —— 不能拿文件当父目录 |