跳转到内容

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
  • 循环里的陷阱:for { defer conn.Close() } 不会每次迭代关,而是整个函数返回时才一起关 → 连接堆积。循环内建连要么显式关,要么抽成独立函数
  • 项目对应:k8s-installer sshx.goc.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:101 pemMap := 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]string vs []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 int0
var wg *sync.WaitGroupnil

所以 var wg *sync.WaitGroup + wg.Add(1) 必崩。要真实对象得显式造:

写法声明怎么写传参传什么
Avar wg sync.WaitGroup(值)&wg
Bwg := &sync.WaitGroup{}(先造对象再取地址)wg(不加 &)

& 只用一次:&x 的类型取决于 x 本身——xstring&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 ×5981 979 984 979 991 —— 丢 9~21 次(约 1~2%)
无锁 + -raceWARNING: 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 的执行顺序是 UnlockDone(先交还共享资源,再报完成),语义正确
  • 锁的粒度尽量细: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-installer 14 个 .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 规则(三条,记死)

  1. close 是广播"不会再有新货了",不是"作废管道"——已经塞进去的货照样收完,收完 range 才退出
  2. 只有所有发送方都发完之后才能关
  3. 不能由某一个干活的 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(同 runInitstep)
  • 这就是 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 但不 closefatal 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 的 goroutine3 条结果全出 → 然后才是"全部检查完成";-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.Createf 交给你了 → 你关;os.WriteFile 自己攥着 → 它关。

能力边界(决定用哪个):

便捷函数手动 *os.File
能干的一次性全读进内存 / 全写出去追加、Seekbufio 逐行、边写边算
代价大文件全塞进内存多写几行
适合配置文件、小报告日志追加、几十 MB 以上

[]bytestring

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 nilmain 照样打印"报告已写入"、退出码还是 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 之后才 defer

f, _ := 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-reportfile-report\reports\

探针一行 —— 遇到"文件没生成 / 生成在怪地方",先打它:

go
wd, _ := os.Getwd()
fmt.Println("工作目录:", wd)

想让它不飘:① 硬编码绝对路径(最差,换机器就废);② 把路径变成命令行参数 —— 这才是 CLI 惯例,k8s-installer -c cluster.yaml 就是干这个。GoLand 侧可把 Run Configuration 的 Working directory 改成 $FileDir$(IDE 配置,与代码无关)。

高频报错速查

报错含义
undefined: xxxxxx 没引号被当变量名,但没定义过
cannot use "nine" (...) as int value类型不匹配,静态类型拦截
no new variables on left side of :=变量已存在,应该用 = 不是 :=
%!d(string=xxx)Printf 占位符与参数类型/数量不匹配
duplicate field namestruct 字面量里同一字段写了两次
assignment mismatch: 1 variable but ... returns 2 values多返回值没接全
cannot use node (variable of type int) as string valuefor 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: namefor range 的某个变量声明了没用(常见于把 i 传给了要 string 的函数)
改名后 undefined: 旧函数名改名没同步调用点 → 用 GoLand Shift+F6 重构改名,别手敲
go: -race requires cgo; enable cgo by setting CGO_ENABLED=1Windows 上 -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 directoryos.Create / os.Open 的目标已经是一个目录(实测:同名文件被换成了目录)
mkdir X: The system cannot find the path specifiedMkdirAll路径中间有一段是文件 —— 不能拿文件当父目录

基于 MIT 许可发布