Go 入门学习
Go 入门学习
第 1 阶段:基础语法与编程思维
目标:理解 Go 语言结构,能编写小程序。
1.Go 语言简介与环境配置
1. Go 是什么?
Go(又叫 Golang)是 Google 于 2009 年推出的一门开源编程语言。
它的设计初衷是:让开发高性能服务器变得简单高效。
主要作者有 Rob Pike、Ken Thompson(Unix 之父之一)等。
它有三个核心特性:
- 简单:语法比 C/Java 精简许多,没有繁杂的继承、多态语法;
- 高效:编译快、运行快,几乎能接近 C 的执行速度;
- 并发强:内置 goroutine(轻量线程)和 channel(通信机制),能轻松写高并发程序。
2.Go 的应用场景
Go 被称为“现代后端工程语言”,主要应用在:
场景 | 说明 | 代表项目 |
---|---|---|
云原生 / 容器 | 处理并发和分布式系统 | Kubernetes、Docker |
Web 后端开发 | Web API、微服务 | Gin、Beego |
网络编程 | 高并发服务器、爬虫 | gRPC、NATS |
工具开发 | 命令行工具、脚本 | Hugo、dlv(调试器) |
AI 工程化 | 调用 Python 推理服务 / 分布式调度 | Go + Python 结合 |
3.Go 环境安装(Windows/Mac/Linux)
安装 Go
Windows
-
打开官网 https://go.dev/dl/
-
下载
.msi
安装包,默认安装即可(路径通常是C:\Program Files\Go
) -
安装完成后,打开 PowerShell 输入:
go version
若输出类似:
go version go1.23.3 windows/amd64
就表示成功。
Mac / Linux
-
访问 https://go.dev/dl/
-
下载
.pkg
(Mac)或.tar.gz
(Linux)包; -
解压或安装后,在终端输入:
go version
4.Go 基本命令
命令 | 功能 |
---|---|
go run main.go | 直接运行 Go 程序(用于调试) |
go build | 编译成可执行文件(生成二进制) |
go fmt | 自动格式化代码 |
go test | 运行测试 |
go mod init <模块名> | 初始化模块(项目依赖) |
go mod tidy | 自动管理依赖(清理或下载缺少的包) |
5.Go 项目结构(推荐)
Go 推荐一种简洁的项目结构,比如:
myproject/
├── go.mod // 模块配置文件
├── main.go // 程序入口
├── pkg/ // 可复用的包
│ └── utils.go
├── internal/ // 仅限项目内部使用的包
└── api/ // Web 接口或服务
当你执行:
go run main.go
或
go build
Go 会自动找到 package main
的文件并执行 main()
函数。
2.基本语法
25个关键字
break default func interface select
case defer go map struct
chan else goto package switch
const fallthrough if range type
continue for import return var
1.变量声明与作用域(var
、:=
)
Go 有两种声明变量的方式:
1️⃣ 标准声明
var a int = 10
var b string = "hello"
也可以一次声明多个:
var x, y int = 1, 2
Go 会自动推断类型,所以类型也可以省略:
var z = 3.14
2️⃣ 简短声明(推荐)
name := "Tom"
age := 20
特点:
- 自动推断类型;
- 只能在函数内部使用;
- Go 变量必须先声明再使用。
3️⃣ 作用域
变量的“可见范围”由 {}
决定:
package main
import "fmt"func main() {var x = 10{y := 20fmt.Println(x, y) // ✅ OK}// fmt.Println(y) ❌ 报错:y 未定义
}
🟡 小结:
- 外层变量在内层可见;
- 内层定义的变量不能在外层使用。
2.数据类型(整数、浮点、字符串、布尔)
Go 是强类型语言,每个变量都有确定类型:
类型 | 示例 | 说明 |
---|---|---|
整型 | int , int8 , int64 | 默认是 int (跟平台有关) |
浮点型 | float32 , float64 | 默认是 float64 |
字符串 | "hello" | 用双引号包裹 |
布尔型 | true , false | 逻辑判断用 |
a := 10 // int
b := 3.14 // float64
c := "GoLang" // string
d := true // bool
可以用 fmt.Printf
查看类型:
fmt.Printf("%T\n", a) // 输出: int
3.常量(const
)
常量的值在编译时就确定,不可修改。
const PI = 3.14159
const Greeting = "Hello"
也可以成组声明:
const (X = 10Y = 20
)
🟡 和 var
不同:常量必须有初始值,且不能用 :=
。
4.输入输出(fmt.Println
、fmt.Scan
)
Go 用标准库 fmt
实现输入输出。
输出
fmt.Print("不换行")
fmt.Println("换行")
fmt.Printf("我叫%s,今年%d岁。\n", "Tom", 20)
输入
fmt.Scan
或 fmt.Scanf
:
var name string
var age int
fmt.Scan(&name, &age) // 注意要取地址 &
fmt.Println("输入结果:", name, age)
示例:
输入: Alice 22
输出: 输入结果: Alice 22
✅ 小练习
请你试着写一个程序,要求:
-
定义常量
Country = "China"
-
从键盘输入姓名和年龄;
-
输出一句话,比如:
Hello, 我叫Tom,20岁,来自China。
package mainimport "fmt"func main() {const Country = "China"var name stringvar age intfmt.Scan(&name, &age)fmt.Println("Hello, 我叫", name, ", ", age, "岁,来自", Country)}
package mainimport "fmt"func main() {const Country = "China"var name stringvar age intfmt.Scan(&name, &age)fmt.Printf("Hello, 我叫%s,%d岁,来自%s", name, age, Country)}
💡 小提示
-
fmt.Println("Hello, 我叫", name, ", ", age, "岁,来自", Country)
这种写法会在每个参数之间自动加空格,所以会出现 多余空格。如果想更漂亮,可以用
fmt.Printf
:fmt.Printf("Hello, 我叫 %s,今年 %d 岁,来自 %s。\n", name, age, Country)
输出会更自然:
Hello, 我叫 Tom,今年 20 岁,来自 China。
-
输入时需要用空格分隔,比如
Tom 20
。如果想分多行输入,可以写两次fmt.Scan
分别读取。
总结:
Println
→ 简单输出,会自动加空格和换行;Printf
→ 格式化输出,可以精确控制显示样式;Scan
→ 从输入读取数据,需要用&
取变量地址。
3.控制结构
1.条件语句:if
、switch
1️⃣ 条件语句(if
)
Go 的 if
语句比其他语言更简洁,不需要括号 ()
包裹条件表达式。
if x > 10 {fmt.Println("x 大于 10")
} else if x == 10 {fmt.Println("x 等于 10")
} else {fmt.Println("x 小于 10")
}
特点:
-
条件不用加括号;
-
花括号
{}
必须写; -
if
后可以跟一个短变量声明:if n := 5; n%2 == 0 {fmt.Println("偶数") } else {fmt.Println("奇数") }
注意:
n
的作用域只在if
语句内部有效。
2️⃣选择语句(switch
)
switch
是多分支判断的更优雅写法。Go 的 switch
也不需要 break
,每个 case
执行后会自动跳出。
switch day := 3; day {
case 1:fmt.Println("星期一")
case 2:fmt.Println("星期二")
case 3, 4, 5:fmt.Println("工作日")
default:fmt.Println("周末")
}
💡 小知识:
-
多个条件可以用逗号分隔;
-
switch
后的表达式可以省略,直接写条件判断:switch { case x > 0:fmt.Println("正数") case x < 0:fmt.Println("负数") default:fmt.Println("零") }
2.循环语句:for
、range
1️⃣ 普通 for 循环(Go 只有 for,没有 while)
for i := 0; i < 5; i++ {fmt.Println("i =", i)
}
等价于其他语言的 for
,也能像 while
:
i := 0
for i < 5 {fmt.Println(i)i++
}
死循环:
for {fmt.Println("无限循环中...")
}
2️⃣ range
遍历
range
常用于数组、切片、字符串、map 等。
nums := []int{1, 2, 3}
for index, value := range nums {fmt.Println(index, value)
}
如果只需要值:
for _, v := range nums {fmt.Println(v)
}
3.跳转语句:break
、continue
、goto
控制循环流程的三个关键字:
关键字 | 含义 |
---|---|
break | 立即跳出当前循环或 switch |
continue | 跳过本次循环,继续下一次 |
goto | 无条件跳转到标签(不建议常用) |
示例:
for i := 1; i <= 5; i++ {if i == 3 {continue // 跳过 3}if i == 5 {break // 结束循环}fmt.Println(i)
}
goto
示例:
i := 0
Here:if i < 3 {fmt.Println(i)i++goto Here}
第 2 阶段:核心数据结构与函数
目标:能用 Go 处理基本数据和封装逻辑。
1.数组与切片(slice)
一、数组(Array)
1️⃣ 定义与初始化
数组是固定长度的同类型数据集合。
var nums [5]int // 定义一个长度为5的整型数组
nums[0] = 10 // 赋值
fmt.Println(nums) // [10 0 0 0 0]arr := [3]string{"Go", "is", "fun"} // 初始化并赋值
fmt.Println(arr) // [Go is fun]
Go 中数组是 值类型(不是引用类型):
a := [3]int{1, 2, 3}
b := a
b[0] = 100
fmt.Println(a) // [1 2 3]
fmt.Println(b) // [100 2 3]
👉 修改 b
不会影响 a
,因为复制了整个数组。
二、切片(Slice)
1️⃣ 定义与初始化
切片可以看作“动态数组”,长度可变。
s := []int{1, 2, 3} // 直接初始化
fmt.Println(s) // [1 2 3]
从数组中切片:
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // 含头不含尾
fmt.Println(s) // [20 30 40]
2️⃣ 底层机制:动态扩容
切片本质上由三部分组成:
指针(指向底层数组) + 长度 len + 容量 cap
当容量不够时,append
会自动:
- 分配更大的底层数组;
- 把原数据复制过去;
- 返回新的切片。
s := []int{1, 2}
s = append(s, 3, 4, 5)
fmt.Println(s) // [1 2 3 4 5]
len
和 cap
:
fmt.Println(len(s)) // 长度
fmt.Println(cap(s)) // 容量
3️⃣ 常用操作
操作 | 说明 |
---|---|
append(slice, x) | 添加元素 |
copy(dst, src) | 拷贝数据 |
len(slice) | 当前长度 |
cap(slice) | 当前容量 |
示例:
a := []int{1, 2, 3}
b := make([]int, 5)
copy(b, a)
fmt.Println(b) // [1 2 3 0 0]
2.映射(map)
一、map 的创建与访问
1️⃣ 创建方式
✅ 方法一:使用 make
scores := make(map[string]int)
这行代码创建了一个空的 map,其中键是 string
类型,值是 int
类型。
可以直接赋值:
scores["Alice"] = 90
scores["Bob"] = 85
✅ 方法二:使用字面量初始化
scores := map[string]int{"Alice": 90,"Bob": 85,"Tom": 100,
}
二、访问元素
fmt.Println(scores["Bob"]) // 输出 85
如果访问不存在的 key,会返回 该类型的零值(比如 int 类型返回 0):
fmt.Println(scores["Lucy"]) // 输出 0
三、判断 key 是否存在
Go 提供了这种语法:
value, ok := scores["Lucy"]
if ok {fmt.Println("找到了:", value)
} else {fmt.Println("没有这个人")
}
value, ok := scores["Lucy"]
这段语法是 Go map 访问机制 的一个经典设计,它底层其实涉及 哈希表(hash table)查找 + 存在性标志。
一、map 是什么
在 Go 底层,map
是通过 哈希表(Hash Table) 实现的。
可以理解成一个「数组 + 链表」的混合结构:
- 每个 key(比如
"Lucy"
)都会通过 哈希函数(hash function) 转成一个数字; - 这个数字决定它放在哪个“桶(bucket)”里;
- 如果不同的 key 落到了同一个桶(哈希冲突),就会用链式或小数组的形式继续存。
⚙️ 二、执行 scores["Lucy"]
时底层发生的事
第一步:计算哈希值
Go 内部会调用哈希函数,把 "Lucy"
转成一个整数(hash code)。
例如:
"Lucy" → hash("Lucy") → 1348723492
第二步:定位桶(bucket)
Go 的 map 底层维护了一个 buckets 数组,长度通常是 2^n。
哈希值的低 n 位决定落在哪个桶:
bucketIndex := hash("Lucy") & (bucketCount - 1)
第三步:在桶中查找
桶中可能有多个 key(哈希冲突时),Go 会:
- 遍历桶中的所有 key;
- 比较每个 key 的哈希值;
- 若相同,再逐字节比较字符串内容;
- 若完全相同,取出对应的 value。
三、为什么有 “ok”
Go 的 map 查找表达式 value, ok := m[key]
有两种返回:
- value:对应的值;
- ok:布尔值,表示是否真的存在该 key。
这是为了避免“零值陷阱”:
比如:
scores["Lucy"] // Lucy 不存在
这时 Go 会返回值类型的“零值”:
int
→ 0string
→ “”bool
→ false
但你并不知道是“真的为 0”,还是“key 不存在”。
所以 Go 提供了双返回机制:
value, ok := scores["Lucy"]
- 如果 Lucy 存在:
ok = true
,value = 实际值
- 如果 Lucy 不存在:
ok = false
,value = 零值
四、完整示意图
scores:
+---------+---------+---------+
| "Alice" | "Bob" | "Tom" |
| 90 | 85 | 100 |
+---------+---------+---------+scores["Lucy"]
↓
hash("Lucy") → 桶索引 5
bucket[5] 为空
→ 返回 (0, false)
总结
步骤 | 说明 |
---|---|
1 | 计算 key 的哈希值 |
2 | 定位桶(bucket) |
3 | 遍历桶中 key,比较哈希 + 内容 |
4 | 如果找到 → 返回 (value, true) |
5 | 如果没找到 → 返回 (零值, false) |
四、遍历 map
使用 for range
:
for name, score := range scores {fmt.Println(name, score)
}
map 是无序的,遍历顺序每次都可能不同。
五、删除元素
使用 delete()
函数:
delete(scores, "Tom")
练习
写一个小程序,完成以下任务:
- 创建一个
map[string]int
来保存学生姓名和成绩; - 添加三个学生;
- 打印所有学生;
- 判断某个名字(比如
"Tom"
)是否存在; - 如果存在,就打印分数并删除他;
- 再打印一次 map。
package mainimport "fmt"func main() {students := map[string]int{"Tom": 90,"Jerry": 80,"Mike": 70,}fmt.Println("初始 map:", students)// 查找并删除if score, ok := students["Tom"]; ok {fmt.Printf("找到了 Tom,成绩是 %d,删除他。\n", score)delete(students, "Tom")} else {fmt.Println("Tom 不存在。")}fmt.Println("删除后 map:", students)
}
map 的 遍历顺序为什么是随机的(Go 的哈希随机化机制)
Go map 遍历无序 的根本原因 —— 这其实是 Go 语言设计者刻意为之的结果。
🧭 一、现象:map 遍历顺序每次都不同
先看个例子:
package main
import "fmt"func main() {m := map[string]int{"a": 1,"b": 2,"c": 3,"d": 4,}for k, v := range m {fmt.Println(k, v)}
}
每次运行输出都可能不一样,比如:
a 1
b 2
c 3
d 4
下一次可能变成:
c 3
a 1
d 4
b 2
⚙️ 二、原因:Go 的 map 遍历是 随机起点 + 哈希分桶
1️⃣ map 底层是哈希表
- Go map 的 key 存放在多个“桶(bucket)”里;
- 桶是根据 key 的哈希值确定的;
- 同一个 bucket 可能装多个元素。
map 结构:
hash("a") → bucket 1
hash("b") → bucket 3
hash("c") → bucket 2
hash("d") → bucket 1
2️⃣ 遍历时的随机化机制
当你用 for range m
遍历时:
- Go 不保证从第 0 个桶开始;
- 它会随机选择一个桶作为 起点;
- 然后顺着桶数组循环一圈;
- 所以每次遍历顺序都不同。
这就是为什么你每次运行结果不一样。
🔒 三、为什么要“故意”随机?
Go 团队在设计时,特意引入随机遍历顺序。
目的是防止程序员写出“依赖 map 顺序”的错误代码。
举个例子:
m := map[string]int{"a": 1, "b": 2}
for k := range m {fmt.Println(k)
}
如果遍历顺序是固定的,某些程序可能偷偷依赖这个顺序。
但 map 的哈希机制本质是无序的,依赖顺序会导致不同机器、版本表现不一致。
所以 Go 在运行时强制随机化,保证你不能依赖顺序。
🧩 四、如果想要“有序遍历”,该怎么办?
正确做法是:
- 先取出所有 key;
- 对 key 排序;
- 再遍历。
示例:
import "sort"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])
}
✅ 小结表
特性 | 说明 |
---|---|
存储结构 | 哈希表 + 桶(bucket) |
遍历顺序 | 每次随机化起点 |
设计目的 | 防止依赖遍历顺序 |
想要有序 | 手动提取 key + 排序 |
3.函数与返回值
一、函数的定义与调用
基本形式
func 函数名(参数列表) 返回类型 {// 函数体
}
示例:
func add(a int, b int) int {return a + b
}
调用:
sum := add(3, 5)
fmt.Println(sum) // 输出 8
二、多返回值函数
Go 支持直接返回多个值,这在错误处理时非常常见。
func divide(a, b int) (int, error) {if b == 0 {return 0, fmt.Errorf("除数不能为 0")}return a / b, nil
}
调用:
result, err := divide(10, 2)
if err != nil {fmt.Println(err)
} else {fmt.Println(result)
}
nil
是 Go 语言中的一个关键字,表示“零值(空值)”,但它专门用于 引用类型。
一、什么是 nil
在 Go 里,所有变量都有一个“零值”(Zero Value)。
- 数字类型 →
0
- 布尔类型 →
false
- 字符串 →
""
(空字符串) - 引用类型 →
nil
所以,nil
可以理解为:没有指向任何东西。
二、哪些类型可以是 nil
只有 引用类型 才可能取值为 nil
:
- 指针(pointer)
- 切片(slice)
- 映射(map)
- 通道(channel)
- 接口(interface)
- 函数类型(func)
例子:
var p *int
fmt.Println(p == nil) // truevar s []int
fmt.Println(s == nil) // truevar m map[string]int
fmt.Println(m == nil) // true
三、使用场景
-
判断是否初始化
var s []int if s == nil {fmt.Println("切片未初始化") }
-
返回错误时常用
func divide(a, b int) (int, error) {if b == 0 {return 0, fmt.Errorf("除数不能为0")}return a / b, nil // 表示没有错误 }
-
接口的默认值
var w io.Writer fmt.Println(w == nil) // true
注意点
-
对
nil
map 写入会报错(因为它还没分配内存):var m map[string]int m["a"] = 1 // ❌ panic
必须用
make
初始化:m = make(map[string]int) m["a"] = 1 // ✅
-
对
nil
slice 可以append
,Go 会自动分配底层数组:var s []int s = append(s, 1) // ✅
✅ 小结
nil
= 引用类型的零值,表示“不指向任何东西”;- 常用于判断是否初始化、表示“没有错误/结果”;
- map 必须初始化才能写入,slice 即使 nil 也能 append。
Go 各类型零值对照表
类型类别 | 示例类型 | 零值 | 说明 |
---|---|---|---|
数值型 | int , float64 , complex128 | 0 / 0.0 / 0+0i | 所有数值型变量默认都是 0 |
布尔型 | bool | false | 表示“假” |
字符串型 | string | "" | 空字符串,不是 nil |
指针类型 | *int , *float64 | nil | 不指向任何内存地址 |
切片(slice) | []int | nil | 没有底层数组,长度和容量都是 0 |
映射(map) | map[string]int | nil | 没有分配内存,不能写入 |
通道(chan) | chan int | nil | 未初始化的通道,发送或接收都会阻塞 |
接口(interface) | interface{} | nil | 没有绑定任何具体类型或值 |
函数类型 | func() | nil | 没有指向任何函数实现 |
数组(array) | [3]int | [0 0 0] | 固定长度,不会是 nil ,元素零值填充 |
小结记忆
📦 “值类型” → 有具体值(如 0、false、“”)
🔗 “引用类型” → nil 表示“还没分配内存或未指向对象”
可以这样理解:
“值类型有实体,引用类型靠指针。”
所以引用类型的“零”就是 nil。
三、匿名函数与闭包(closure)
匿名函数就是没有名字的函数,常用在临时逻辑中。
func() {fmt.Println("Hello, Go!")
}()
这段代码定义并立即执行一个函数。
闭包(Closure)是指“函数 + 外部变量”的组合:
func adder() func(int) int {sum := 0return func(x int) int {sum += xreturn sum}
}f := adder()
fmt.Println(f(10)) // 10
fmt.Println(f(5)) // 15
💡 闭包让函数“记住”外部的变量。闭包到底是什么?
一句话:闭包 = 函数 + 它用到的外部变量(环境)。
这些外部变量不是“复制一份数值”,而是同一份变量本体(所以会被后续调用继续修改)。
最小示例:计数器
package mainimport "fmt"// 工厂函数:返回一个“会记数”的函数
func makeCounter() func() int {x := 0 // 被捕获的外部变量(闭包环境)return func() int {x++ // 访问并修改同一份 xreturn x}
}func main() {c := makeCounter()fmt.Println(c()) // 1fmt.Println(c()) // 2fmt.Println(c()) // 3
}
关键点
x
定义在makeCounter
里,但被返回的匿名函数用了它;x
的生命周期被延长(逃逸到堆上),每次c()
调用都在同一个 x 上累加;- 闭包捕获的是变量本身,不是“值的快照”。
常见易错:for 循环里捕获循环变量
// 错误示例:都会打印 3 3 3
func bad() {fns := []func(){}for i := 0; i < 3; i++ {fns = append(fns, func() { fmt.Println(i) })}for _, f := range fns { f() }
}
为什么?因为 3 个闭包都捕获了同一个 i,循环结束时 i=3。
两种正确写法:
写法A:给每轮创建“新变量”
for i := 0; i < 3; i++ {i := i // 重新声明,屏蔽外层 ifns = append(fns, func() { fmt.Println(i) })
}
写法B:把值当参数传进去
for i := 0; i < 3; i++ {fns = append(fns, func(v int) func() {return func() { fmt.Println(v) }}(i))
}
什么时候用闭包?
- 状态机/计数器:需要“记住上次状态”的小函数;
- 装饰器/中间件:返回带状态的处理函数;
- 缓存/记忆化:把 cache 放在闭包里,函数每次调用先查缓存。
简单缓存示例:
func memoizeSquare() func(int) int {cache := map[int]int{}return func(x int) int {if v, ok := cache[x]; ok { return v }v := x * xcache[x] = vreturn v}
}
心智模型(帮你区分“值快照 vs 变量引用”)
- 闭包捕获变量本体(reference to variable),所以后续修改会反映到所有使用处;
- 想要“捕获当时的值”,就新建局部变量或通过参数传值。
🧩 一、闭包 vs 递归的区别
特点 | 闭包(closure) | 递归(recursion) |
---|---|---|
定义 | 函数 + 记住外部变量的环境 | 函数自己调用自己 |
是否返回自身 | 返回“一个新的函数” | 自己在函数体中再次调用自己 |
目的 | 记住状态、保存环境 | 重复执行直到条件结束 |
关键字 | return func(...) {...} | 直接调用自己 f() |
例如👇
✅ 闭包
func makeAdder(base int) func(int) int {return func(x int) int {base += xreturn base}
}
这里返回的匿名函数用到了外部变量 base,所以它“记住了”这个环境。
每次调用返回的函数,base 都会在上次的基础上改变。
✅ 递归
func factorial(n int) int {if n == 1 {return 1}return n * factorial(n-1)
}
这里函数 factorial
调用自己本身,直到 n==1 为止。
🧠 二、为什么函数中嵌套函数?
Go 允许在函数里面再定义函数,原因是:
- 可以用闭包保存“局部状态”;
- 避免污染全局命名空间;
- 让逻辑更紧密,比如一个“工具函数”只在主函数内部使用。
例子:
func main() {count := 0increment := func() int {count++return count}fmt.Println(increment()) // 1fmt.Println(increment()) // 2
}
increment
只在 main
中有意义,它捕获了外部变量 count
。
即使 main
已经执行了一半,count
依然被 increment
“记住”了。
🧩 三、总结口诀
闭包记环境,递归调自己。
闭包 = 定义时绑定外部变量
递归 = 运行时再次调用自己
“闭包就是在一个函数里定义的内部函数,这个内部函数会用到外部函数中的变量。”
我们用一个直观的例子来验证
func makeCounter() func() int {count := 0 // 外层函数的局部变量return func() int { // 内部函数(闭包)count++ // 使用并修改外部变量return count}
}
执行过程:
counter := makeCounter() // 此时外层函数结束,但 count 被“捕获”了fmt.Println(counter()) // 输出 1
fmt.Println(counter()) // 输出 2
fmt.Println(counter()) // 输出 3
虽然 makeCounter()
已经执行完退出了,
但是变量 count
并没有被销毁,因为闭包(内部函数)引用了它。
💡 可以这么理解:
- 外层函数 = 造一个“容器”,里面放了一些变量;
- 内部函数 = 拿着这个容器的“钥匙”,可以访问和修改里面的值;
- 每个调用
makeCounter()
得到的都是独立的容器。
闭包就是一个在函数里定义的小函数,它可以访问外部函数中的变量。
📦 闭包的内存模型
我们看这个例子:
func makeCounter() func() int {x := 0return func() int {x++return x}
}c := makeCounter()
🧩 内存中的情况
- 调用 makeCounter() 时
- 变量
x
在栈上创建(初始值 = 0)。 - 返回的匿名函数引用了
x
。
- 变量
Stack:
+---------+
| x = 0 | ← 被闭包引用
+---------+Heap:
+---------------------------------+
| func() int { x++; return x } |
+---------------------------------+
- 返回匿名函数时
- 按理说函数执行完,
x
应该销毁; - 但是,因为匿名函数还在用它,Go 会把
x
“逃逸”到堆上,保证它活着。
- 按理说函数执行完,
Heap:
+---------+ +---------------------------------+
| x = 0 | ←─── | func() int { x++; return x } |
+---------+ +---------------------------------+
- 多次调用闭包函数时
每次调用c()
,都会操作同一个x
。- 第一次:
x=1
- 第二次:
x=2
- 第三次:
x=3
- 第一次:
Heap:
+---------+
| x = 3 | ←─── 被闭包持续修改
+---------+
✅ 小结
- 闭包把外层变量“搬到堆上”,保证函数返回后还能继续访问。
- 所以闭包 ≠ 递归,它的作用是 记住状态。
🧩 例子复习
我们用最经典的计数器闭包:
func makeCounter() func() int {x := 0return func() int {x++return x}
}
当你执行:
c := makeCounter()
c()
c()
c()
🧠 内存变化过程(动图式思维)
🌱 第 1 步:调用 makeCounter()
创建了一个新的作用域(stack frame):
makeCounter:x = 0返回 func() int { x++; return x }
此时:
- 变量
x
还在栈上; - 返回的函数 引用了
x
; - Go 检测到有引用 → 把
x
“逃逸”到堆上。
Heap:
+---------+
| x = 0 | ←── 被返回的函数引用
+---------+
⚙️ 第 2 步:返回函数并赋值给 c
现在 c
拿到了那个内部函数,并且带着对 x
的“引用指针”。
c → func() int { x++; return x }Heap:
+---------+
| x = 0 | ← c() 访问它
+---------+
🔁 第 3 步:第一次调用 c()
执行闭包体:
x++ // 0 → 1
return x // 返回 1
内存:
Heap:
+---------+
| x = 1 |
+---------+
🔁 第 4 步:第二次调用 c()
又运行 x++
:
x = 2
return 2
Heap:
+---------+
| x = 2 |
+---------+
🔁 第 5 步:第三次调用 c()
x = 3
return 3
✅ 总结成一句话
闭包函数携带了它定义时的外部变量环境。
这个环境会一直存在,直到闭包本身被销毁。
所以:
- 每次
makeCounter()
调用都会生成一个新的x
; - 每个返回的函数都“记住”自己的那份
x
; - 它们互不干扰。
对比一下:普通函数 vs 闭包函数。
通过一个小例子你会一下子明白“为什么闭包能记住状态,而普通函数不能”。
🧩 一、普通函数:每次调用都重新开始
package main
import "fmt"func addOnce(x int) int {x++return x
}func main() {fmt.Println(addOnce(0))fmt.Println(addOnce(0))fmt.Println(addOnce(0))
}
输出结果:
1
1
1
🔍 原因:
- 每次调用
addOnce(0)
,都会重新创建一个局部变量x
; - 函数结束时
x
就销毁; - 下次调用时,是一个全新的
x
,所以永远只输出 1。
🧠 二、闭包函数:能“记住”上次的值
package main
import "fmt"func makeAdder() func() int {x := 0return func() int {x++return x}
}func main() {add := makeAdder()fmt.Println(add()) // 1fmt.Println(add()) // 2fmt.Println(add()) // 3
}
输出结果:
1
2
3
🔍 原因:
x
是makeAdder
的局部变量;- 但返回的匿名函数“引用”了
x
; - 所以即使
makeAdder
执行完,x
依然保留在内存中; - 每次调用
add()
,都在操作同一个x
。
🔎 对比图:
对比点 | 普通函数 | 闭包函数 |
---|---|---|
变量存储位置 | 栈上,每次调用重新创建 | 逃逸到堆上,被内部函数持有 |
变量生命周期 | 函数结束就销毁 | 只要闭包还存在,变量就存在 |
是否记住上次状态 | ❌ 否 | ✅ 是 |
常见用途 | 简单逻辑运算 | 记忆状态、封装计数器、缓存、装饰器等 |
💬 小结口诀:
普通函数每次从零开始,
闭包函数能带记忆返回。
我们来看看闭包在 Go 实际项目 里的常见用途。
闭包不仅是语法特性,更是工程里很常见的“巧用工具”。
🧮 一、计数器(保存状态)
这是最经典的例子,也是理解闭包最直观的用途。
package main
import "fmt"func makeCounter() func() int {count := 0return func() int {count++return count}
}func main() {counter := makeCounter()fmt.Println(counter()) // 1fmt.Println(counter()) // 2fmt.Println(counter()) // 3
}
📌 用途场景:
- 在测试或服务中统计请求次数;
- 用于限流、打点、计数器逻辑。
👉 优点:不需要全局变量,不会引发并发访问问题。
🕒 二、延迟执行(节流 / 防抖)
闭包可以用来“记住上次调用时间”,从而实现**节流(throttle)或防抖(debounce)**逻辑。
package main
import ("fmt""time"
)func throttle(interval time.Duration) func(func()) {var last time.Timereturn func(f func()) {if time.Since(last) > interval {last = time.Now()f()}}
}func main() {throttledPrint := throttle(time.Second)for i := 0; i < 5; i++ {throttledPrint(func() {fmt.Println("执行任务", time.Now())})time.Sleep(300 * time.Millisecond)}
}
📌 效果:
即使循环每 300ms 调用一次,只有每隔 1 秒真正执行一次。
闭包通过记住 last
时间实现了状态保持。
🌐 三、Web Handler 封装
在 Go 的 Web 框架(如 Gin / net/http)里,闭包非常常见。
例如:用闭包生成“带状态”的 HTTP 处理函数。
package main
import ("fmt""net/http"
)func makeHandler(msg string) http.HandlerFunc {return func(w http.ResponseWriter, r *http.Request) {fmt.Fprintf(w, "Message: %s", msg)}
}func main() {http.HandleFunc("/hello", makeHandler("Hello, Go!"))http.HandleFunc("/bye", makeHandler("Goodbye!"))http.ListenAndServe(":8080", nil)
}
📌 为什么用闭包:
- 每个 handler 记住自己的参数
msg
; - 不需要写多个函数;
- 代码更模块化。
💡 四、自定义“生成器”(Generator)
闭包可以模拟类似 Python 生成器的行为。
func fibGenerator() func() int {a, b := 0, 1return func() int {a, b = b, a+breturn a}
}func main() {fib := fibGenerator()for i := 0; i < 6; i++ {fmt.Println(fib())}
}
📌 输出:
1
1
2
3
5
8
👉 每次调用 fib()
,它都记住上一次的 a, b
。
🔐 五、资源封装 / 延迟释放
比如我们需要打开一个文件,执行完任务后自动关闭,可以用闭包包装成“带资源的任务”。
func withFileDo(filename string) func(func(string)) {file := filename // 模拟文件资源return func(f func(string)) {defer fmt.Println("关闭文件:", file)f(file)}
}func main() {task := withFileDo("data.txt")task(func(name string) {fmt.Println("处理文件:", name)})
}
📌 输出:
处理文件: data.txt
关闭文件: data.txt
✅ 小结:闭包的 4 大应用场景
场景 | 用法 | 举例 |
---|---|---|
1. 记忆状态 | 保存上次变量值 | 计数器、生成器 |
2. 延迟逻辑 | 记住上次时间 / 条件 | 节流、防抖 |
3. 参数注入 | 生成带参数的函数 | Web Handler、回调函数 |
4. 资源封装 | 绑定外部资源 + 自动清理 | 文件/连接操作封装 |
四、defer(延迟执行)
defer
用来延迟函数的执行,常用于 资源释放、文件关闭、错误恢复 等场景。
func main() {fmt.Println("开始")defer fmt.Println("结束")fmt.Println("处理中...")
}
输出顺序:
开始
处理中...
结束
多条 defer 会 按栈的顺序反向执行(后进先出)。
小练习
写一个程序:
- 定义一个函数
max(a, b int) int
,返回较大的数; - 主函数中读取两个整数;
- 调用
max
输出较大值; - 用
defer
在程序最后打印一句"程序结束"
。
package mainimport ("fmt"
)func max(a, b int) int {if a > b {return a} else {return b}
}
func main() {var a, b intfmt.Scan(&a, &b)defer fmt.Println("程序结束")fmt.Println(max(a, b))
}
编写一个程序:
- 从输入中读取若干整数(数量固定,比如 5 个);
- 用一个切片存储这些数;
- 定义三个函数:
max(nums []int) int
—— 返回最大值min(nums []int) int
—— 返回最小值avg(nums []int) float64
—— 返回平均值
- 最后输出三者结果。
package mainimport "fmt"func max(nums []int) int {maxVal := nums[0]for _, v := range nums {if v > maxVal {maxVal = v}}return maxVal
}func min(nums []int) int {minVal := nums[0]for _, v := range nums {if v < minVal {minVal = v}}return minVal
}func avg(nums []int) float64 {sum := 0for _, v := range nums {sum += v}return float64(sum) / float64(len(nums))
}func main() {nums := make([]int, 5)fmt.Println("请输入 5 个整数:")for i := 0; i < 5; i++ {fmt.Scan(&nums[i])}fmt.Println("最大值:", max(nums))fmt.Println("最小值:", min(nums))fmt.Println("平均值:", avg(nums))
}
为什么切片(slice)传给函数时,不需要指针也能修改它的内容?
🧩 一、直觉现象
看这段代码:
package main
import "fmt"func modify(nums []int) {nums[0] = 100
}func main() {arr := []int{1, 2, 3}modify(arr)fmt.Println(arr)
}
输出是:
[100 2 3]
💡 明明没有用 *
或 &
,为什么 modify
里改了 nums
,外面的 arr
也变了?
🧠 二、切片的底层结构
在 Go 底层,切片(slice
)其实是一个结构体,不是一个完整的数组。
你可以想象成这样:
type sliceHeader struct {Data uintptr // 指向底层数组的指针Len int // 切片的长度Cap int // 切片的容量
}
当你写:
arr := []int{1, 2, 3}
Go 在内存中大致是这样:
底层数组: [1, 2, 3]
切片头部(sliceHeader):Data → 指向底层数组的地址Len = 3Cap = 3
当你把 arr
传给函数时:
modify(arr)
传递的其实是这 个切片头部(3 个字段的拷贝)。
虽然是“值传递”,但 Data
字段里存的是底层数组的地址。
所以函数内部通过 Data
找到同一个底层数组,对其修改会影响外部。
🧭 三、重点区分:修改内容 vs 修改切片本身
操作类型 | 是否影响外部切片 | 原因 |
---|---|---|
修改元素值(nums[0] = 100 ) | ✅ 会影响 | 指向同一底层数组 |
改变长度(nums = append(nums, 4) ) | ❌ 不会影响 | 新的底层数组被重新分配 |
看例子👇
func appendSlice(nums []int) {nums = append(nums, 4)fmt.Println("函数内:", nums)
}func main() {arr := []int{1, 2, 3}appendSlice(arr)fmt.Println("函数外:", arr)
}
输出:
函数内: [1 2 3 4]
函数外: [1 2 3]
因为 append
可能导致底层数组扩容,
函数内部的 nums
就指向了新的底层数组,和外面的 arr
不再共享数据。
✅ 四、总结记忆
概念 | 说明 |
---|---|
切片本质 | 指针 + 长度 + 容量 |
传参机制 | 值传递(拷贝结构体)但共享底层数组 |
修改元素 | 会影响原切片 |
append 扩容 | 可能产生新底层数组,旧切片不变 |
📘 一句话总结:
切片是“值传递”的,但它的值里藏着“引用”。
—— 改内容会影响外部,改结构不会。
切片传递与底层数组的关系。
🧩 切片与底层数组
假设你有一个切片:
nums := []int{1, 2, 3}
内存大概是这样的:
切片头部(slice header)
+------------------+
| Data → 指针 | ---> 底层数组:[1, 2, 3]
| Len = 3 |
| Cap = 3 |
+------------------+
Data
指向底层数组的首地址;Len
表示切片当前长度;Cap
表示底层数组的容量。
🧭 函数传递时发生了什么?
调用:
func modify(s []int) {s[0] = 100
}
modify(nums)
传递过程:
main() 里的 nums
+------------------+
| Data → 数组 [1,2,3] |
| Len = 3 |
| Cap = 3 |
+------------------+modify() 里的 s (是拷贝)
+------------------+
| Data → 数组 [1,2,3] | // 指向同一个底层数组
| Len = 3 |
| Cap = 3 |
+------------------+
因为 Data 指针相同,所以修改 s[0]
= 100,会直接作用到底层数组,外部的 nums
也被改变。
⚠️ 特殊情况:append 扩容
当你 append
超过容量时:
s = append(s, 4, 5, 6, 7)
Go 会:
- 新建一个更大的数组;
- 把旧数组的数据拷贝进去;
- 让切片头部
Data
指向新数组。
此时函数里的切片和外面的切片不再共享数据。
旧切片 → [1, 2, 3]
新切片 → [1, 2, 3, 4, 5, 6, 7] (新的数组)
✅ 小结
- 切片传参是 值传递(拷贝 slice header),但由于 Data 指针共享,所以修改元素会影响外部;
- 如果触发了 扩容,函数内部切片就和外部脱离了,不再影响外部。
make的用法
🧭 一、make()
的基本作用
make()
用于创建并初始化:slice(切片)、map(映射)、channel(通道)。
也就是说,它只适用于这三种类型。
这些类型在底层都有“内部结构”(不是简单的内存块),所以普通的 var
声明并不会完全初始化它们。
make()
会帮你:
- 分配底层内存;
- 初始化内部结构;
- 返回一个可直接使用的对象。
🧩 二、make()
的三种典型用法
1️⃣ 创建切片(slice)
语法:
make([]T, length, capacity)
示例:
nums := make([]int, 5)
表示:
- 创建一个
int
类型的切片; - 长度
len = 5
; - 容量
cap = 5
; - 所有元素初始为
0
。
你也可以指定不同的容量:
nums := make([]int, 3, 10)
含义:
- 长度 = 3(可用元素个数);
- 容量 = 10(底层数组大小);
- 后续可通过
append()
向容量 10 以内动态添加。
2️⃣ 创建 map
语法:
make(map[keyType]valueType, hint)
示例:
scores := make(map[string]int)
scores["Tom"] = 90
- 这里的
hint
是一个可选的“容量提示”(非严格限制); - Go 会根据 hint 预先分配空间,提高性能;
- map 的长度从 0 开始,随着插入自动扩容。
3️⃣ 创建 channel(通道)
语法:
make(chan T, capacity)
示例:
ch := make(chan int, 3)
- 创建一个
int
类型的通道; - 容量为 3(可以存放 3 个元素而不阻塞);
- 若
capacity = 0
,则为无缓冲通道。
⚖️ 三、make()
vs new()
这是初学者最常混淆的两个函数。
函数 | 适用类型 | 返回值 | 用途 |
---|---|---|---|
make() | 仅适用于 slice、map、chan | 初始化好的对象(非指针) | 创建可用的复合结构 |
new() | 适用于所有类型 | 指向类型的指针 | 分配内存但不初始化 |
示例:
p := new(int)
fmt.Println(*p) // 0
这里 p
是 *int
类型的指针,只分配了一个整型的内存,没有额外结构。
✅ 四、小结记忆表
类型 | 推荐创建方式 | 示例 |
---|---|---|
切片 | make([]T, len, cap) | make([]int, 3, 5) |
map | make(map[K]V, hint) | make(map[string]int) |
通道 | make(chan T, cap) | make(chan int, 2) |
💡 记忆口诀:
“三样 make,其他 new;
make 出结构,new 出指针。”
当你写 make([]int, 3, 5)
时,Go 在底层到底做了些什么。
🧩 一、切片的本质
在 Go 中,切片(slice)并不是数组,它其实是一个结构体,包含三部分:
type sliceHeader struct {Data uintptr // 指向底层数组的指针Len int // 长度(已使用的元素个数)Cap int // 容量(底层数组的总大小)
}
也就是说,切片是一个“视图”,指向了底层的数组。
⚙️ 二、make([]int, 3, 5)
的内存结构
假设你写:
s := make([]int, 3, 5)
Go 会做三件事:
- 分配一块底层数组,大小 =
cap = 5
; - 将
Len = 3
,表示已经初始化了 3 个元素; - 将
Cap = 5
,表示底层数组还能容纳 5 个元素。
内存示意图:
底层数组(cap=5): [0 0 0 _ _]
切片 s: len=3, cap=5
视图内容: [0 0 0]
(后面两个位置是预留的,append
时会用上)
🔄 三、append 时发生了什么?
s = append(s, 1, 2)
结果:
底层数组: [0 0 0 1 2]
s: len=5, cap=5
此时用满了底层数组。
再 append
:
s = append(s, 3)
Go 发现容量不够,就会:
- 新建一个更大的底层数组(一般扩容到 2 倍);
- 把原有数据复制过去;
- 返回新的切片(和旧的不是同一块底层内存)。
✅ 四、小实验
s := make([]int, 3, 5)
fmt.Println(len(s), cap(s)) // 3 5s = append(s, 1, 2)
fmt.Println(len(s), cap(s)) // 5 5s = append(s, 3)
fmt.Println(len(s), cap(s)) // 6 10 (容量翻倍)
💡 总结记忆
- 切片 = 指针 + 长度 + 容量;
make([]int, 3, 5)
= 底层数组大小 5,视图长度 3;append
超过容量时,Go 会新建更大的数组,复制数据,并返回新切片。
第 3 阶段:结构体、方法与接口
目标:掌握 Go 面向对象的写法。
🧱 一、结构体(struct)
1️⃣ 定义与初始化
结构体是自定义的数据类型,用来组合多个字段:
type Person struct {Name stringAge int
}
初始化方式:
p1 := Person{"Tom", 20}
p2 := Person{Name: "Alice", Age: 22}
p3 := new(Person) // 返回 *Person
p3.Name = "Bob"
p3.Age = 18
2️⃣ 指针接收者与值接收者
- 值接收者:方法接收的是结构体的副本,不会修改原始对象。
- 指针接收者:方法接收的是结构体的地址,能修改原始对象。
func (p Person) SayHello() { // 值接收者fmt.Println("Hello, my name is", p.Name)
}func (p *Person) GrowUp() { // 指针接收者p.Age++
}
调用:
p := Person{"Tom", 20}
p.SayHello() // Hello, my name is Tom
p.GrowUp()
fmt.Println(p.Age) // 21
3️⃣ 嵌套结构体与匿名字段
结构体可以嵌套另一个结构体,实现“组合”:
type Address struct {City stringZip string
}type Student struct {Name stringAge intAddress // 匿名字段(不用写名字)
}
使用:
s := Student{Name: "Jerry",Age: 18,Address: Address{"Beijing", "100000"},
}
fmt.Println(s.City) // 直接访问匿名字段
嵌套结构体与匿名字段 嵌套的结构体不用写名字,那么怎么知道使用的哪个对象?
当我们定义一个结构体并匿名嵌套另一个结构体时,比如:
type Address struct {City stringZip string
}type Student struct {Name stringAge intAddress // 匿名字段
}
你会发现:
s := Student{Name: "Tom",Age: 18,Address: Address{"Beijing", "100000"},
}fmt.Println(s.City) // 直接访问 City!
我们并没有写 s.Address.City
,却能直接访问 s.City
。
那 Go 编译器是怎么知道我们指的是哪个对象的字段呢?
🧠 一、Go 的“字段提升(field promotion)”机制
当一个结构体嵌套另一个结构体(尤其是匿名字段)时,Go 会自动把嵌套结构体的字段“提升”到外层作用域。
你可以把它理解为:
编译器在内部帮你自动加上了
s.Address.City
的访问路径。
所以:
s.City <=> s.Address.City
s.Zip <=> s.Address.Zip
这种机制叫做 字段提升(field promotion)。
它让你在外层结构体上能直接使用内层字段。
⚙️ 二、如果存在“重名字段”怎么办?
假设再嵌套一个匿名结构体,且里面也有同名字段:
type Contact struct {City stringPhone string
}type Student struct {Name stringAge intAddressContact
}
现在 Address
和 Contact
都有一个 City
。
这时如果你写:
fmt.Println(s.City)
就会报错:
ambiguous selector s.City
💡 解释:Go 无法判断你想访问的是 Address.City
还是 Contact.City
,所以编译器会拒绝编译。
解决办法:显式指明路径:
fmt.Println(s.Address.City)
fmt.Println(s.Contact.City)
🧩 三、嵌套的意义(不是继承!)
匿名字段常用于“组合(composition)”,不是传统面向对象的继承。
例如:
type Animal struct {Name string
}func (a Animal) Speak() {fmt.Println("I am", a.Name)
}type Dog struct {Animal // 匿名嵌套
}func main() {d := Dog{Animal{"Buddy"}}d.Speak() // 调用的是 Animal 的方法!
}
这里的 Dog
并不是“继承了 Animal”,
而是包含了一个 Animal,并且其方法被提升可直接调用。
✅ 总结表
特性 | 说明 |
---|---|
匿名字段 | 不写字段名,只写类型 |
字段提升 | 内部字段可直接访问 |
重名字段 | 会导致访问冲突(需显式指定) |
方法提升 | 匿名字段的方法也会被提升 |
设计意义 | 实现组合(composition),不是继承 |
⚙️ 二、方法与接口
1️⃣ 方法的声明与绑定
方法是带接收者的函数:
func (p Person) Introduce() {fmt.Printf("My name is %s, I am %d years old.\n", p.Name, p.Age)
}
区别:函数是独立的,方法是和某个类型绑定的。
一、方法 vs 普通函数的区别
普通函数:
func Speak(name string) {fmt.Println("I am", name)
}
方法:
func (a Animal) Speak() {fmt.Println("I am", a.Name)
}
区别在于:
- 方法在
func
和函数名之间,多了一个“接收者(receiver)”; - 这个接收者
a Animal
就像是函数的“归属对象”。
🧠 二、语法解释
func (a Animal) Speak() {fmt.Println("I am", a.Name)
}
可以读成:
“为类型
Animal
定义一个名为Speak
的方法,接收者变量名是a
。”
等价于其他语言的写法:
Go 写法 | 其他语言类比 |
---|---|
func (a Animal) Speak() | Python: def Speak(self): Java: void speak() |
a | 相当于 self 或 this |
⚙️ 三、调用方式
定义:
type Animal struct {Name string
}func (a Animal) Speak() {fmt.Println("I am", a.Name)
}
调用:
a := Animal{"Tom"}
a.Speak() // 自动把 a 传入方法作为接收者
本质上等价于:
Animal.Speak(a)
所以:
对象.方法()
⇔
类型.方法(对象)
🔍 四、值接收者 vs 指针接收者
✅ 值接收者(拷贝对象)
func (a Animal) Speak() {a.Name = "Changed"fmt.Println("I am", a.Name)
}
- 这里
a
是Animal
的一个副本; - 修改
a
不会影响原对象。
✅ 指针接收者(修改原对象)
func (a *Animal) Rename(newName string) {a.Name = newName
}
-
接收者是
*Animal
; -
调用时 Go 会自动取地址:
a := Animal{"Dog"} a.Rename("Cat") // 自动等价于 (&a).Rename("Cat") fmt.Println(a.Name) // Cat
💡 五、方法绑定的意义
Go 没有类(class),但通过“类型 + 方法”实现了类似面向对象的结构。
type Animal struct { Name string }func (a Animal) Speak() { fmt.Println("I am", a.Name) }
func (a *Animal) Rename(n string) { a.Name = n }
→ 你就可以:
a := Animal{"Tom"}
a.Speak()
a.Rename("Jerry")
a.Speak()
✅ 总结记忆:
概念 | 示例 | 含义 |
---|---|---|
方法定义 | func (a Animal) Speak() | 给 Animal 类型定义一个方法 |
接收者 | (a Animal) / (a *Animal) | 方法属于哪个类型 |
值接收者 | 拷贝,不影响原值 | |
指针接收者 | 修改原始对象 | |
调用方式 | a.Speak() | 编译器自动传入接收者 |
2️⃣ 接口(interface)与多态
接口定义了一组方法,只要某个类型实现了这些方法,就被认为实现了接口(鸭子类型)。
type Speaker interface {Speak()
}func (p Person) Speak() {fmt.Println("Hi, I'm", p.Name)
}
使用:
var s Speaker
s = Person{"Tom", 20}
s.Speak()
👉 Go 不需要显式 implements
,只要方法签名符合,就自动实现接口。
3️⃣ 空接口与类型断言
🧩 一、什么是空接口
在 Go 中:
interface{}
表示一个没有任何方法的接口。
因为所有类型都至少“拥有零个方法”,所以 所有类型都自动实现了空接口。
也就是说:
var x interface{}
x = 100 // int
x = "hello" // string
x = true // bool
💡 空接口相当于 Python 的 any
、Java 的 Object
。
🧭 二、为什么需要空接口
- 当你需要一个容器能存放“任意类型”的值时,用空接口;
- 比如
fmt.Println
内部参数就是...interface{}
,所以它能打印任何类型。
🔍 三、类型断言
当你从空接口里取值时,需要“告诉 Go”它具体是什么类型。
这叫 类型断言:
var x interface{} = "golang"
s, ok := x.(string)
if ok {fmt.Println("x 是字符串:", s)
} else {fmt.Println("x 不是字符串")
}
x.(string)
尝试把x
转换成字符串;- 如果成功,
ok = true
; - 如果失败,
ok = false
,s
会是零值。
🔄 四、类型 switch
如果你不确定具体类型,可以用 switch
:
var x interface{} = 3.14switch v := x.(type) {
case int:fmt.Println("int:", v)
case string:fmt.Println("string:", v)
case float64:fmt.Println("float64:", v)
default:fmt.Println("未知类型")
}
输出:
float64: 3.14
✅ 小练习
请你写一个程序:
- 定义一个
[]interface{}
切片,往里面存放不同类型的值(int、string、bool); - 遍历这个切片,用 类型 switch 打印每个元素的类型和值。
package mainimport ("fmt"
)func main() {slice := []interface{}{1,"hello",true,}for _, v := range slice {switch v.(type) {case int:fmt.Println("int:", v)case string:fmt.Println("string:", v)case bool:fmt.Println("bool:", v)}}}
什么时候该用值接收者,什么时候该用指针接收者?
🧩 一、先回顾语法差别
// 值接收者
func (a Animal) Speak() {fmt.Println("I am", a.Name)
}// 指针接收者
func (a *Animal) Rename(newName string) {a.Name = newName
}
(a Animal)
:传入的是结构体的副本(a *Animal)
:传入的是结构体的指针(地址)
⚙️ 二、区别本质:值传递 vs 引用传递
Go 中所有参数都是 值传递。
区别只是:
- 值接收者:传递结构体的一个“拷贝”;
- 指针接收者:传递结构体的“地址”,方法里能修改原对象。
对比如下 👇
type Animal struct {Name string
}func (a Animal) SetByValue(name string) {a.Name = name // 修改的是副本
}func (a *Animal) SetByPointer(name string) {a.Name = name // 修改原始数据
}func main() {a := Animal{"Dog"}a.SetByValue("Cat")fmt.Println(a.Name) // ❌ Dog(没变)a.SetByPointer("Cat")fmt.Println(a.Name) // ✅ Cat(修改成功)
}
🧠 三、使用场景对比
场景 | 推荐用法 | 原因 |
---|---|---|
需要修改结构体内容 | 指针接收者 (*T ) | 方法内部能修改原始对象 |
结构体比较大(拷贝开销大) | 指针接收者 (*T ) | 避免值拷贝带来的性能损失 |
结构体很小,不需要修改 | 值接收者 (T ) | 简单清晰 |
实现接口(interface)时保持一致性 | 看接口签名 | 若接口方法是 *T 实现,则都用指针接收者 |
💡 四、接口实现中的注意点
接口判断是基于方法集(method set):
- 值类型
T
拥有的 method set:只包括“值接收者定义的方法”; - 指针类型
*T
拥有的 method set:包括“值接收者”和“指针接收者”的方法。
示例:
type Speaker interface {Speak()
}type Animal struct{}func (a Animal) Speak() {} // 值接收者
func (a *Animal) Rename() {} // 指针接收者var a Animal
var p *Animal// ✅ 都能调用 Speak(因为 *T 有 T 的方法)
// ❌ a.Rename() 报错,只能 (&a).Rename()
🚀 五、一个通俗记忆口诀:
✅ 改结构体 → 用指针
✅ 查/打印/展示 → 用值
✅ 大结构体 → 用指针(省性能)
✅ 小结构体 → 用值(简单)
🔧 六、真实工程中的经验法则
在实际项目中(尤其写 Web 服务、API、框架时):
- 几乎 80% 的方法都会用指针接收者;
- 因为即使你不修改结构体,也能避免复制带来的性能损耗;
- 并且保证一致性,防止混用。
示例:
type User struct {Name stringAge int
}func (u *User) SetName(n string) { u.Name = n }
func (u *User) GetName() string { return u.Name } // 虽然不改,也用 *
✅ 总结:
类型 | 是否修改原对象 | 是否拷贝数据 | 常见用途 |
---|---|---|---|
值接收者 (T) | 否 | 是 | 小结构体、只读方法 |
指针接收者 (*T) | 是 | 否 | 需要修改结构体或优化性能 |
✅ 小练习
请你写一个程序:
- 定义一个
Animal
接口,包含Speak()
方法; - 定义
Dog
和Cat
两个结构体,并实现Speak()
方法; - 在
main
函数里用一个切片[]Animal
存放多个对象,并调用它们的Speak()
。
package mainimport ("fmt"
)type Animal interface {Speak()
}type Dog struct {Name string
}func (d *Dog) Speak() {fmt.Println(d.Name, "汪汪汪")
}type Cat struct {Name string
}func (c *Cat) Speak() {fmt.Println(c.Name, "喵喵喵")
}func main() {animals := []Animal{&Dog{Name: "旺财"},&Cat{Name: "小白"},}for _, animal := range animals {animal.Speak()}
}
第 4 阶段:并发与通道(Go 的精华)
目标:理解并发机制,用 goroutine 写异步任务。
1) Goroutine 基本概念
并发 vs 并行
- 并发(concurrency):在同一时间段内处理很多任务(切来切去);像一个人快速在多件事之间切换。
- 并行(parallelism):同一时刻真的有多个任务在不同 CPU 核上同时执行;像多个人同时各做一件事。
Go 的并发是语言级一等公民;是否并行取决于 CPU 核数和调度器(GOMAXPROCS
)。
启动一个协程:go func()
- goroutine 是 Go 的轻量级线程,创建成本很低,动辄成千上万。
- 任何函数前加
go
关键字,就在新的协程里异步执行。
最小示例:
package mainimport ("fmt""time"
)func main() {go func() {fmt.Println("来自 goroutine 的问候")}()fmt.Println("main 返回前先睡一会儿…")time.Sleep(100 * time.Millisecond) // 给协程时间完成(演示用)
}
要点:
-
go func(){…}()
立即启动一个协程; 定义一个匿名函数并立刻执行,前面加go
表示异步运行 -
如果
main
退出太快,子协程还没来得及跑就会被一并结束(所以上面用Sleep
演示)。 -
go func() { ... }()
为什么结尾要加()
?go
关键字后面必须跟一个函数调用;func() { ... }
定义了一个匿名函数;()
表示立刻调用这个函数;- 整体
go func(){...}()
就是“启动一个新的 goroutine 来执行这个匿名函数”。
如果你只写:
go func() {fmt.Println("hi") }
这是定义了函数但没调用,不会执行。
所以要加()
,让它立即调用并异步执行。等价于:
f := func() {fmt.Println("hi") } go f() // 启动 goroutine 来执行 f
2) Channel(通道)
无缓冲通道:一手交钱一手交货
ch := make(chan int) // 无缓冲
go func() {ch <- 42 // 发送:阻塞直到有人来收
}()
v := <-ch // 接收:阻塞直到有人来送
fmt.Println(v) // 42
-
无缓冲通道是同步的:发送/接收双方会在“交接点”会合,保证数据交付的时序性。
-
ch <- 42
这是什么?往通道里发送数据;
x := <-ch
:从通道取数据这是 向通道发送数据 的语法。
ch
是一个chan int
类型的通道;<-
是“通道操作符”;ch <- 42
表示 把值 42 发送到通道ch
。
对应的接收操作是:
x := <-ch // 从 ch 里取出一个值
你可以理解成“箭头指向数据的流向”:
ch <- 42
:把数据放进ch
(发送);<-ch
:从ch
里拿数据(接收)。
有缓冲通道:像有容量的队列
ch := make(chan string, 2) // 容量=2
ch <- "a" // 立即成功
ch <- "b" // 立即成功
// ch <- "c" // 会阻塞,直到有人接收腾出空间fmt.Println(<-ch) // "a"
fmt.Println(<-ch) // "b"
- 缓冲通道是异步的:只要缓冲区没满,发送方不阻塞;只要不空,接收方不阻塞。
关闭通道与“逝者留名”
close(ch) // 只能由发送方关闭;多次关闭或向已关闭通道发送都会 panic
v, ok := <-ch // 接收在关闭后将读到零值,并且 ok=false
for v := range ch { // 用 range 读到通道关闭为止fmt.Println(v)
}
要点:
- 只关闭发送端用完的通道;接收端不关闭。
- 从已关闭通道接收是安全的;从已关闭通道发送会 panic。
select
:多路复用与超时
select {
case v := <-ch1:fmt.Println("收到 ch1:", v)
case ch2 <- 99:fmt.Println("向 ch2 发送成功")
case <-time.After(500 * time.Millisecond):fmt.Println("超时了")
}
select
会在若干个可进行的通道操作中选一个执行;都阻塞时就等。time.After(d)
常用来实现超时控制。
3) 同步与锁
sync.WaitGroup
:等待一组协程结束
var wg sync.WaitGroup
wg.Add(3) // 计划等待3个协程
for i := 0; i < 3; i++ {go func(id int) {defer wg.Done() // 该协程完成// do work...}(i)
}
wg.Wait() // 阻塞,直到计数归零
-
defer wg.Done()
中的defer
是什么?defer
的意思是:把某个函数调用延迟到当前函数返回之前执行。延迟调用,常用于释放资源或保证最后一定执行
例子:
func main() {defer fmt.Println("结束")fmt.Println("开始") }
输出:
开始 结束
在
WaitGroup
场景里:go func() {defer wg.Done() // 无论函数中发生什么,最后都会调用 Done// 这里写实际的工作逻辑 }()
好处:
- 即使函数中途
return
,Done()
也不会漏掉; - 不用担心忘记写
wg.Done()
导致主程序永远阻塞。
- 即使函数中途
sync.Mutex
:保护共享数据
没有保护的并发写会有数据竞争(race)。
用互斥锁保证同一时刻只有一个协程修改共享变量。
var (mu sync.Mutexsum int
)
for i := 0; i < 1000; i++ {go func() {mu.Lock()sum++ // 临界区mu.Unlock()}()
}
用 channel 代替锁:消息传递风格
- 让“拥有数据”的那个协程串行地处理请求,其它协程通过通道发送“修改请求”。
- 优点:避免细粒度锁、减少死锁风险;缺点:需要设计好协议与吞吐。
常见坑位小抄
- main 太快退出 → 子协程直接被销毁:用
WaitGroup
等; - 向已关闭通道发送 → panic:只由发送方在“确定不会再发送”时关闭;
- range channel 永不结束 → 记得在发送方结束前
close(ch)
; - 数据竞争 → 用
-race
运行测试,必要时加Mutex
或用 channel 串行化。
迷你练习
下面这段程序会输出什么?(顺序很关键)
package mainimport ("fmt""time"
)func main() {ch := make(chan int)go func() {time.Sleep(50 * time.Millisecond)ch <- 1}()select {case v := <-ch:fmt.Println("got", v)case <-time.After(10 * time.Millisecond):fmt.Println("timeout")}
}
打印 timeout , 因为上面函数里面的Sleep时间过长了
🔍 程序分析
select {
case v := <-ch:fmt.Println("got", v)
case <-time.After(10 * time.Millisecond):fmt.Println("timeout")
}
运行原理:
go func() { time.Sleep(50ms); ch <- 1 }()
- 启动了一个协程;
- 它在 50ms 后才会往通道
ch
里发送值; - 在这 50ms 内,
ch
是没有值的。
time.After(10ms)
- 这是 Go 标准库里的一个定时器;
- 它会在 10ms 后向返回的通道里发送一个信号;
- 所以它可以用来实现“超时等待”。
select
- 同时等待多个通道;
- 哪个通道先就绪(可以读/写),就执行那个
case
; - 其它 case 会被忽略。
⚙️ 实际执行顺序
- 代码一运行,
select
会同时监听两个通道:ch
(要等 50ms 才有数据)time.After(10ms)
(10ms 后就会发信号)
因为 time.After
比 ch
先就绪,
所以程序输出:
timeout
🧠 记忆要点
关键点 | 含义 |
---|---|
select | 等多个通道,谁先来用谁 |
time.After | 延迟一段时间后发送信号 |
time.Sleep | 暂停当前协程(这里让发送者延迟发送) |
结果 | timeout 因为 10ms < 50ms |
✅ 总结一句话:
“select 会选第一个就绪的通道。因为 10ms 超时信号先到,程序就输出 timeout。”
select
多通道通信
🧭 一、select
的作用回顾
select
用来同时等待多个通道(chan
)的操作。
它会:
- 从**可执行(ready)**的 case 中随机选一个执行;
- 如果所有通道都阻塞,则:
- 阻塞等待,或
- 执行
default
分支(非阻塞情况)。
⚙️ 二、基础例子:同时监听两个 channel
package main
import ("fmt""time"
)func main() {ch1 := make(chan string)ch2 := make(chan string)// 启动两个 goroutinego func() {time.Sleep(100 * time.Millisecond)ch1 <- "任务1完成"}()go func() {time.Sleep(200 * time.Millisecond)ch2 <- "任务2完成"}()// 同时监听两个通道for i := 0; i < 2; i++ {select {case msg1 := <-ch1:fmt.Println(msg1)case msg2 := <-ch2:fmt.Println(msg2)}}
}
输出:
任务1完成
任务2完成
(执行顺序由哪个 goroutine 先发送决定。)
💡 三、加入超时机制(非常常见)
select {
case res := <-resultChan:fmt.Println("结果:", res)
case <-time.After(2 * time.Second):fmt.Println("任务超时")
}
🧠 场景:
某个子任务在 2 秒内没返回结果,就自动超时退出。
这比写 “Sleep + if” 的方式更优雅、非阻塞。
🧩 四、加入退出信号(项目中最常见)
这就是 Go 里“优雅退出”的标准写法 👇
func worker(done <-chan bool, data <-chan int) {for {select {case d := <-data:fmt.Println("处理数据:", d)case <-done:fmt.Println("收到退出信号")return}}
}func main() {data := make(chan int)done := make(chan bool)go worker(done, data)for i := 1; i <= 3; i++ {data <- i}// 3个任务发完,通知 worker 退出done <- truetime.Sleep(100 * time.Millisecond)
}
运行结果:
处理数据: 1
处理数据: 2
处理数据: 3
收到退出信号
💬 解释:
data
通道用于发送任务;done
通道用于发送退出信号;select
同时监听两者;- 收到退出信号后安全退出 goroutine。
🧠 五、default
分支:非阻塞通信
如果你想检测通道状态而不阻塞:
select {
case v := <-ch:fmt.Println("收到:", v)
default:fmt.Println("没有消息,继续干别的事")
}
💡 这在做 心跳检测、状态轮询 时非常常用。
✅ 小结
用法 | 说明 |
---|---|
select | 同时监听多个通道 |
time.After | 超时控制 |
done 通道 | 优雅退出 |
default | 非阻塞检测 |
核心思想 | 用消息通信代替共享内存(Go 并发哲学) |
并发数据预处理管线的小项目
package mainimport ("context""fmt""math/rand""sync""time"
)type Job struct {ID intPath string
}
type Result struct {ID intPath stringProcessed stringCostMs int64Err error
}// 模拟预处理函数:耗时 30~150ms
func preprocess(ctx context.Context, path string) (string, error) {// 随机耗时work := time.Duration(30+rand.Intn(120)) * time.Millisecondselect {case <-time.After(work):return "normalized+resized", nilcase <-ctx.Done():return "", ctx.Err()}
}// ---- 信号量(限流器):容量=limiterCap ----
func newLimiter(limiterCap int) chan struct{} {sem := make(chan struct{}, limiterCap)return sem
}func acquire(sem chan struct{}) { sem <- struct{}{} }
func release(sem chan struct{}) { <-sem }// ---- worker:带超时 + 取消 + 限流 ----
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup, sem chan struct{}) {defer wg.Done()for j := range jobs {start := time.Now()// 1) 任务级超时:比如每个样本最多 80msctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)// 可选:统一取消(比如 main 收到 Ctrl-C)可把外层 ctx 传进来defer cancel()// 2) 限流:占用一个外部资源令牌(例如:磁盘/网络/GPU)acquire(sem)res, err := preprocess(ctx, j.Path)release(sem)results <- Result{ID: j.ID,Path: j.Path,Processed: res,CostMs: time.Since(start).Milliseconds(),Err: err,}}
}func generateJobs(n int) <-chan Job {out := make(chan Job)go func() {defer close(out)for i := 1; i <= n; i++ {out <- Job{ID: i, Path: fmt.Sprintf("sample_%03d.jpg", i)}}}()return out
}func main() {rand.Seed(time.Now().UnixNano())const (totalJobs = 12concurrency = 4 // worker 数limiterCap = 2 // 限制真正占资源的并发(令牌桶))jobs := generateJobs(totalJobs)results := make(chan Result, totalJobs)var wg sync.WaitGroupsem := newLimiter(limiterCap)wg.Add(concurrency)for w := 1; w <= concurrency; w++ {go worker(w, jobs, results, &wg, sem)}go func() { wg.Wait(); close(results) }()var okCnt, timeoutCnt intvar totalCost int64for r := range results {if r.Err != nil {fmt.Printf("SKIP #%02d (%s) => err=%v, cost=%dms\n",r.ID, r.Path, r.Err, r.CostMs)timeoutCnt++continue}fmt.Printf("DONE #%02d (%s) => %s, cost=%dms\n",r.ID, r.Path, r.Processed, r.CostMs)okCnt++totalCost += r.CostMs}avg := 0.0if okCnt > 0 {avg = float64(totalCost) / float64(okCnt)}fmt.Printf("\nOK=%d, TIMEOUT=%d, avg_cost=%.1fms (workers=%d, limiter=%d)\n",okCnt, timeoutCnt, avg, concurrency, cap(sem))
}
-
控制并发数量的机制是——
acquire(sem) res, err := preprocess(ctx, j.Path) release(sem)
🧩 解释一下:
-
sem
是一个 带缓冲容量为 2 的通道(make(chan struct{}, 2)
)。 -
当执行
acquire(sem)
时,会向通道里放入一个空结构体:sem <- struct{}{}
如果通道已满(已经放了 2 个),那这行代码就会阻塞。
-
也就是说:最多只有 2 个 goroutine 能通过
acquire
进入下一步处理。 -
当任务结束后执行
release(sem)
:<-sem
从通道里取出一个令牌,相当于“释放名额”,让下一个等待的 worker 继续执行。
💡 形象类比
你可以把
sem
想象成一个“2 把钥匙的实验室门”:- 有 4 个研究员(worker),都想进实验室处理样本;
- 但门口只有 2 把钥匙(
limiterCap = 2
); - 没钥匙的人就得等,直到别人出来(释放钥匙)。
✅ 总结一句话:
make(chan struct{}, limiterCap)
这条语句 +acquire()/release()
构成了一个信号量(Semaphore)机制,保证同时只有limiterCap
个 goroutine 能执行关键任务。 -
-
1)
acquire(sem)
:申请“令牌”(可能阻塞)典型实现:
func acquire(sem chan struct{}) { sem <- struct{}{} }
sem
是带缓冲的通道(make(chan struct{}, N)
),容量N
就是允许的最大并发数。- 向通道发送一个“空结构体”
struct{}{}
相当于占用一个名额。 - 当通道已满(已有 N 个协程占着),这次发送会阻塞,直到有协程释放名额。
- 用
struct{}
的原因:零内存开销(空类型),只起到“计数令牌”的作用。
👉 小结:这一行把“同时进入关键区的协程数”限制在
cap(sem)
个。
2)
res, err := preprocess(ctx, j.Path)
:关键区(需要被限流的操作)- 这一步模拟重 IO/重算(例如磁盘读、网络拉取、解码、归一化、GPU 推理等)。
- 我们把真正占资源的逻辑夹在
acquire
和release
之间,确保进入的人数受控。 - 若
preprocess
内部用了select { case <-ctx.Done(): ... }
,则可以被超时或取消打断,快速让出资源。
👉 小结:限流只包裹“重活儿”,不必包整个 worker,避免把轻量逻辑也挡住。
3)
release(sem)
:归还“令牌”,唤醒等待者典型实现:
func release(sem chan struct{}) { <-sem }
- 从通道接收一个值,让缓冲区空一格,表示释放名额。
- 这通常会唤醒一个在
acquire
处阻塞的协程。
👉 小结:没有这一步会“泄漏令牌”(别人永远拿不到),最终所有协程都卡住。
易错点与最佳实践
✅ 用
defer
保证释放一旦成功
acquire
,就尽量马上defer release(sem)
,防止中途return
/panic
忘记释放:acquire(sem) defer release(sem)res, err := preprocess(ctx, j.Path)
✅ 不要
close(sem)
- 这是长期存在的信号量,不是“结果广播通道”。
- 不需要也不应该关闭;关闭后再
acquire
会 panic。
✅ 非阻塞尝试获取(可选)
需要“试探拿令牌,不等就放弃”时:
select { case sem <- struct{}{}:// got tokendefer release(sem)res, err := preprocess(ctx, j.Path) default:// 没拿到令牌:要么丢弃任务,要么排队到别处 }
✅ 为什么用
struct{}
而不是bool
/int
struct{}
没有内存占用;bool
/int
会占字节。- 我们只关心占位计数,不在乎通道里装什么值。
✅ 限流粒度
- 把限流包在“最重的那段”(如
preprocess
)就好; - 包太大(例如整个 worker)会把“轻量准备/上报”也阻塞,降低吞吐。
如何用 通道(channel)限流 管理多个 GPU / 推理线程的并发。
🎯 目标场景
假设你有一台服务器,有 2 张 GPU 卡。
现在来了 10 个推理请求,每个请求都要加载模型并执行推理。
如果所有请求同时开跑:
- GPU 会被占满甚至 OOM;
- 性能反而下降(上下文切换、显存碎片)。
所以我们希望做到:
“同时最多只跑 2 个推理任务(对应 2 张 GPU),多余的请求排队等资源释放。”
这就是典型的 限流场景。
🧩 代码示例:用 channel 做 GPU 任务调度器
package mainimport ("fmt""math/rand""sync""time"
)// 模拟推理任务(耗时 + GPU ID)
func inference(gpuID int, taskID int) {cost := time.Duration(100+rand.Intn(200)) * time.Millisecondfmt.Printf("[GPU%d] Running inference #%d...\n", gpuID, taskID)time.Sleep(cost)fmt.Printf("[GPU%d] Done inference #%d (%v)\n", gpuID, taskID, cost)
}// GPU 调度器:一个容量为 GPU 数的通道
func newGPUScheduler(numGPU int) chan int {ch := make(chan int, numGPU)// 初始化 GPU IDfor i := 0; i < numGPU; i++ {ch <- i}return ch
}func main() {rand.Seed(time.Now().UnixNano())numGPU := 2totalTasks := 10gpuPool := newGPUScheduler(numGPU)var wg sync.WaitGroupfor t := 1; t <= totalTasks; t++ {wg.Add(1)go func(taskID int) {defer wg.Done()// 1️⃣ 获取 GPU ID(如果没有空闲 GPU,则阻塞等待)gpuID := <-gpuPool// 2️⃣ 执行推理inference(gpuID, taskID)// 3️⃣ 推理结束,归还 GPUgpuPool <- gpuID}(t)}wg.Wait()fmt.Println("\nAll tasks done.")
}
🔍 程序运行逻辑
-
gpuPool := make(chan int, numGPU)
通道容量 = GPU 数量,比如 2。 -
初始化 GPU ID:
for i := 0; i < numGPU; i++ {ch <- i }
让通道里一开始就存
[0, 1]
,表示 GPU0、GPU1 都是空闲的。 -
每个推理任务:
<-gpuPool
取一个 GPU ID(若都在忙则阻塞等待);- 执行
inference(gpuID, taskID)
; gpuPool <- gpuID
把 GPU 归还。
🧠 运行效果(输出示意)
[GPU0] Running inference #1...
[GPU1] Running inference #2...
[GPU0] Done inference #1 (121ms)
[GPU0] Running inference #3...
[GPU1] Done inference #2 (185ms)
[GPU1] Running inference #4...
...
All tasks done.
可以看到:
- 同时只有两条“Running”;
- GPU0、GPU1 轮流被任务占用;
- 不会出现 GPU 资源争抢。
✅ 小结
概念 | 作用 |
---|---|
make(chan int, numGPU) | 充当 GPU 资源池 |
<-gpuPool | 拿到一个 GPU ID(阻塞等待) |
gpuPool <- gpuID | 推理完成后释放 GPU |
优点 | 线程安全、无锁、简单、可扩展 |
应用 | GPU 任务调度、模型推理限流、Batch 编排 |
🚀 拓展思路(实际项目中可以这样用)
-
加上超时控制:防止某个推理任务卡死。
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel()
-
动态扩展 GPU 池:比如不同 GPU 能力不同,可以用结构体存
{ID, MemLeft}
。 -
多模型支持:可以按模型类型拆分多个调度池,例如 NLP 模型池 / CV 模型池。
-
结合 HTTP 服务:每个请求进来就丢到通道,主 goroutine 收集结果并返回响应。
简化版推理服务器
-
HTTP 接口:
POST /infer
,请求里给一个input
字段 -
GPU 资源池:channel 限流(比如 2 张卡)
-
超时控制:每个请求 3s 超时
-
推理函数:用
time.Sleep
模拟耗时与结果
package mainimport ("context""encoding/json""fmt""log""math/rand""net/http""time"
)// ======== 配置 ========
const (numGPU = 2 // GPU 数量(并发上限)requestTimeout = 3 * time.Second // 每个请求的超时
)// ======== GPU 资源池(channel)=======
var gpuPool chan intfunc init() {rand.Seed(time.Now().UnixNano())gpuPool = make(chan int, numGPU)for i := 0; i < numGPU; i++ {gpuPool <- i // 初始放入 GPU ID,表示空闲}
}// ======== 数据结构 ========
type InferRequest struct {Input string `json:"input"`
}type InferResponse struct {GPU int `json:"gpu"`Output string `json:"output"`Latency time.Duration `json:"latency_ms"`
}// ======== 模拟推理:占用 GPU,支持超时/取消 ========
func inference(ctx context.Context, gpuID int, input string) (string, error) {// 模拟 100~600ms 的推理耗时work := time.Duration(100+rand.Intn(500)) * time.Millisecondselect {case <-time.After(work):// 这里可以换成真实推理逻辑:调用 Python/gRPC/TensorRT 等return fmt.Sprintf("pred(%s)", input), nilcase <-ctx.Done():return "", ctx.Err() // 超时或取消}
}// ======== 处理请求:分配 GPU + 超时控制 ========
func handleInfer(w http.ResponseWriter, r *http.Request) {// 解析请求var req InferRequestif err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Input == "" {http.Error(w, "bad request: need JSON {\"input\":\"...\"}", http.StatusBadRequest)return}// 为本次请求创建超时上下文ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)defer cancel()start := time.Now()// 1) 申请 GPU(如果都在忙则阻塞等待,或随请求超时)var gpuID intselect {case gpuID = <-gpuPool: // 拿到一个可用 GPUcase <-ctx.Done():http.Error(w, "timeout: no gpu available", http.StatusGatewayTimeout)return}// 2) 执行推理(确保归还 GPU)var (out stringerr error)done := make(chan struct{})go func() {defer func() { done <- struct{}{} }()out, err = inference(ctx, gpuID, req.Input)}()select {case <-done:// 正常返回case <-ctx.Done():// 推理过程超时/取消err = ctx.Err()}// 3) 归还 GPUgpuPool <- gpuID// 4) 返回响应if err != nil {http.Error(w, "inference timeout/canceled", http.StatusGatewayTimeout)return}resp := InferResponse{GPU: gpuID,Output: out,Latency: time.Since(start) / time.Millisecond,}w.Header().Set("Content-Type", "application/json")_ = json.NewEncoder(w).Encode(resp)
}// ======== 启动 HTTP 服务 ========
func main() {mux := http.NewServeMux()mux.HandleFunc("/infer", handleInfer)srv := &http.Server{Addr: ":8080",Handler: mux,ReadTimeout: 5 * time.Second,WriteTimeout: 5 * time.Second,}log.Println("Inference server listening on :8080 ...")if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {log.Fatal(err)}
}
怎么试
启动后,另一个终端发请求:
curl -X POST localhost:8080/infer \-H "Content-Type: application/json" \-d '{"input":"image_001.jpg"}'
你会得到类似:
{"gpu":0,"output":"pred(image_001.jpg)","latency_ms":245000}
(注意:同一时间最多只有 2 个请求在“推理”,其余会排队或超时返回)
关键点讲解
-
GPU 资源池(通道)
gpuPool = make(chan int, numGPU) gpuPool <- 0; gpuPool <- 1 gpuID := <-gpuPool // 获取 GPU gpuPool <- gpuID // 归还 GPU
通道容量 = 可用 GPU 数;拿取与归还形成“信号量”。
-
请求级超时(context)
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel()
每个请求都带一个截止时间,防止长时间占用资源。
-
推理过程可中断
inference
里用select { case <-time.After(work): ... case <-ctx.Done(): ... }
这样就能响应超时/取消信号,优雅释放资源。 -
并发安全
我们没有共享可变状态;GPU 分配通过通道自然是线程安全的。
进阶扩展
- 批处理(batching):在队列里攒到 N 条或超时就一起送 GPU,提高吞吐。
- 多模型池:为不同模型各开一个资源池,或把池元素做成结构体
{ID, ModelName, MemLeft}
。 - 优雅关停:在
main
里监听信号(os.Signal
),Server.Shutdown(ctx)
优雅下线。 - 与 Python 推理对接:用 gRPC/HTTP 调 PyTorch/TensorRT,Go 只做网关与调度。
本章内容总结
🧭 一、核心思维:Go 并发模型
1️⃣ 并发与并行
概念 | 含义 |
---|---|
并发 (Concurrency) | 多个任务在同一时间段内交替执行(逻辑上同时) |
并行 (Parallelism) | 多个任务在不同 CPU 核上物理同时运行 |
👉 Go 通过 goroutine + 调度器 实现并发,自动根据 CPU 调整是否并行。 |
2️⃣ Go 的并发哲学
不要通过共享内存来通信,而要通过通信来共享内存。
即:
- 不要用锁去同步共享变量;
- 用 channel(通道) 传递数据;
- goroutine 之间通过通信而不是竞争来协作。
⚙️ 二、语法与机制
🌀 Goroutine —— 并发任务的最小单元
go func() {fmt.Println("异步执行")
}()
- 启动一个新的协程并立即返回;
- 与主协程并发运行;
- 主协程结束时,所有子协程也会强制结束(要用
WaitGroup
等待)。
🔁 Channel —— 通信管道
ch := make(chan int) // 无缓冲
ch := make(chan int, 10) // 有缓冲
ch <- 42 // 发送
x := <-ch // 接收
close(ch) // 关闭
类型 | 特点 |
---|---|
无缓冲通道 | 发送/接收必须同步(一手交钱一手交货) |
有缓冲通道 | 类似队列,允许异步收发 |
⏱ select
—— 多路复用机制
select {
case v := <-ch1:fmt.Println("收到", v)
case ch2 <- 99:fmt.Println("发送成功")
case <-time.After(500 * time.Millisecond):fmt.Println("超时")
default:fmt.Println("无操作,非阻塞执行")
}
- 同时等待多个 channel;
- 哪个就绪执行哪个;
- 全部阻塞时,若有
default
分支则执行它; - 常用于超时控制与多通道监听。
⛓️ sync.WaitGroup
—— 等待多个 goroutine 完成
var wg sync.WaitGroup
wg.Add(3)
go func() { defer wg.Done() }()
wg.Wait() // 阻塞到全部 Done
🧱 sync.Mutex
—— 临界区互斥锁
mu.Lock()
x++
mu.Unlock()
防止数据竞争(但 Go 更推荐用 channel)。
⚡ 三、channel 的高级玩法
1️⃣ 信号量限流(Semaphore Pattern)
用通道容量限制最大并发数:
sem := make(chan struct{}, 2)
sem <- struct{}{} // acquire
// critical work...
<-sem // release
2️⃣ 超时控制(time.After
+ select
)
select {
case v := <-ch:fmt.Println("收到:", v)
case <-time.After(1 * time.Second):fmt.Println("超时")
}
3️⃣ 退出信号通道(done
)
for {select {case task := <-tasks:handle(task)case <-done:return}
}
💻 四、实战模式总结
模式 | 应用场景 |
---|---|
Worker Pool | 多个 worker 并发处理任务(数据预处理、爬虫、推理任务) |
Pipeline | 多阶段流水线(读取 → 处理 → 推理 → 汇总) |
Semaphore 限流 | 控制外部资源数量(GPU/文件句柄/连接池) |
Timeout + Cancel | 防止任务长时间卡死(context.WithTimeout ) |
优雅退出 | 监听退出信号,等待在途任务完成后关闭服务 |
🚀 五、AI 场景中的典型用法
场景 | Go 机制 |
---|---|
并发数据预处理 | goroutine + channel + WaitGroup |
GPU 任务调度 | channel 限流(信号量) |
推理超时管理 | context + select |
任务结果汇总 | channel 汇聚结果 |
并发 HTTP 服务 | goroutine 自动并行处理请求 |
🧩 六、记忆口诀(面试 or 实战速查)
go 开启异步,
chan 通信同步。
select 选多路,
WaitGroup 等全部。
defer 防泄漏,
context 控超时。
struct{} 占令牌,
close 表终止。
第 5 阶段:模块化与标准库
目标:能搭建基本 Web 服务。
🧱 一、Go Modules 与包管理
1️⃣ 什么是模块(Module)
- 模块是 Go 项目的最小发布单元;
- 每个模块都有一个
go.mod
文件,描述依赖关系; - Go 会自动下载、缓存依赖。
2️⃣ 初始化项目:go mod init
假设我们要创建一个项目:
myproject/
└── main.go
在项目根目录执行:
go mod init myproject
这会生成一个 go.mod
文件:
module myproject
go 1.22
之后所有源码都会以这个模块名为导入前缀。
3️⃣ 安装依赖:go get
例如我们要引入一个第三方库:
go get github.com/gin-gonic/gin
这会:
- 下载依赖;
- 更新
go.mod
和go.sum
文件; - 记录精确版本。
4️⃣ 整理依赖:go mod tidy
当你删除或添加了 import 后,
执行:
go mod tidy
Go 会:
- 移除不再使用的依赖;
- 自动下载缺失的依赖;
- 保持
go.mod
干净整齐。
5️⃣ 自定义包与导入规则
示例项目结构:
myproject/
├── go.mod
├── main.go
└── utils/└── mathutil.go
文件内容:
mathutil.go
package utilsfunc Add(a, b int) int {return a + b
}
main.go
package mainimport ("fmt""myproject/utils"
)func main() {fmt.Println(utils.Add(3, 5))
}
💡 注意:
package
决定文件属于哪个包;- 导入路径从模块名(
go.mod
中的 module)开始; - 同一个文件夹内的所有
.go
文件必须属于同一个package
。
✅ 小结
命令 | 作用 |
---|---|
go mod init | 初始化模块 |
go get | 安装依赖 |
go mod tidy | 清理/修复依赖 |
自定义包 | 用 package 定义、用模块路径导入 |
小项目:textlab
目标:学会 go mod
、自定义包、导入与常用标准库(strings
、os
、fmt
)。
第 1 步:初始化模块 & 目录
- 新建目录并进入:
mkdir textlab && cd textlab
- 初始化模块(模块名就叫当前目录名):
go mod init textlab
- 建一个包目录:
mkdir utils
👉 到这一步为止,目录应是:
textlab/
├── go.mod
└── utils/
第 2 步:写自定义包 utils
在 utils/stringutil.go
写入:
package utilsimport "strings"// Title 首字母大写(其余保持不变,示范用标准库)
func Title(s string) string {// TODO: 用 strings.Title 已弃用,我们演示简单做法:if s == "" {return s}// 简化:只处理 ASCII 的首字符head := s[:1]tail := s[1:]return strings.ToUpper(head) + tail
}// Reverse 反转字符串(TODO: 先留空给你来写)
func Reverse(s string) string {// TODO: 你来实现runes := []rune(s)for i := 0; i < len(runes)-1-i; i++ {runes[i], runes[len(runes)-1-i] = runes[len(runes)-1-i], runes[i]}return string(runes)
}// IsPalindrome 回文判断(利用 Reverse)
func IsPalindrome(s string) bool {r := Reverse(s)return s == r
}
小贴士:这里我们不处理复杂的 Unicode 合字,先练手逻辑。
第 3 步:写 main.go
,导入并调用自定义包
在项目根目录新建 main.go
:
package mainimport ("fmt""os""strings""textlab/utils"
)func main() {// 读取命令行第一个参数作为输入(没有就给个默认)var input stringif len(os.Args) > 1 {input = strings.Join(os.Args[1:], " ")} else {input = "hello"}fmt.Println("Input: ", input)fmt.Println("Title: ", utils.Title(input))fmt.Println("Reverse: ", utils.Reverse(input))fmt.Println("IsPalindrome: ", utils.IsPalindrome(input))
}
运行:
go run .
# 或传入自定义文本
go run . level
第 4 步:依赖管理与整理
确认 go.mod
和 go.sum
已自动生成;再执行:
go mod tidy
它会清理/补齐依赖,保持模块健康。
加分项(可选):
- 改造
Title
:如果要严谨处理 Unicode,可以用[]rune
+unicode.ToUpper
; - 给
utils
写一个简单测试utils/stringutil_test.go
,学go test
的基本用法。
一点扩展理解
1️⃣ 运行流程图
main.go├── import "textlab/utils"│ └── stringutil.go (package utils)└── 调用 utils.Title / Reverse / IsPalindrome
模块名 + 包名 就形成导入路径:
textlab/utils
→ 对应文件夹 utils
下的 package utils
。
2️⃣ 小细节:go mod tidy
执行完 go mod tidy
后,go.mod
应该很干净,比如:
module textlabgo 1.25
因为我们只用标准库,没有第三方依赖。
Go 自动识别、自动管理版本依赖,这也是它比 Python/C++ 方便的地方。
常用标准库
🧩二、标准库
🧩 一、常用标准库概览
包 | 作用 |
---|---|
fmt | 格式化输出与输入 |
os | 操作系统接口(文件、目录、环境变量、命令行参数) |
io / io/ioutil / bufio | 数据流读写 |
strings | 字符串处理 |
strconv | 字符串与数值互转 |
time | 时间、定时器、延时、格式化 |
1️⃣ fmt —— 格式化输入输出
最常用,几乎每个程序都用。
package main
import "fmt"func main() {name := "Go"version := 1.25fmt.Println("Hello,", name)fmt.Printf("Version %.2f\n", version)var n intfmt.Print("Enter a number: ")fmt.Scan(&n)fmt.Println("You entered:", n)
}
函数 | 说明 |
---|---|
Print / Println | 普通输出 |
Printf | 带格式输出 |
Sprintf | 格式化成字符串 |
Scan / Scanf | 从输入读取数据 |
2️⃣ os —— 操作系统交互
package main
import ("fmt""os"
)func main() {// 1. 命令行参数fmt.Println("Args:", os.Args)// 2. 环境变量os.Setenv("DEMO", "Hello")fmt.Println("DEMO =", os.Getenv("DEMO"))// 3. 当前工作目录dir, _ := os.Getwd()fmt.Println("WorkDir:", dir)// 4. 文件存在检测if _, err := os.Stat("test.txt"); err == nil {fmt.Println("test.txt exists")}
}
💡 os
包中最常用的结构:
-
os.File
:文件句柄; -
os.Open
、os.Create
; -
os.Stat
、os.Remove
。 -
🧱 一、
os.File
—— 文件句柄(核心结构)在 Go 里,
os.File
是一个结构体类型,代表一个打开的文件或设备的“句柄”。
它提供了一系列方法来操作文件,例如:方法 说明 Write([]byte)
写入字节数据 Read([]byte)
读取字节数据 Seek(offset, whence)
移动文件读写位置(类似 fseek
)Close()
关闭文件(必须关闭,否则内存泄漏) Stat()
返回文件信息(大小、权限、修改时间等) 例子:
package main import ("fmt""os" )func main() {// 创建文件f, _ := os.Create("demo.txt")defer f.Close() // 程序结束前关闭文件// 写入内容f.WriteString("Hello, Go!\n")// 移动指针到文件头(0 表示起点)f.Seek(0, 0)// 读取内容buf := make([]byte, 20)n, _ := f.Read(buf)fmt.Println("读到:", string(buf[:n])) }
💡 总结:
os.File
就像是打开的“文件通道”,所有读写操作都依赖它。
🛠 二、
os.Open
—— 打开已有文件(只读)f, err := os.Open("demo.txt")
- 返回一个
*os.File
; - 如果文件不存在,返回错误;
- 默认以 只读模式 打开;
- 用完必须
defer f.Close()
。
你可以通过
f.Read()
读取文件内容。
📝 三、
os.Create
—— 创建新文件(可写)f, err := os.Create("newfile.txt")
- 如果文件不存在,会创建;
- 如果文件存在,会清空原内容(覆盖);
- 返回一个可写的文件句柄;
- 你可以用
f.Write()
、f.WriteString()
写数据。
⚠️ 注意:
os.Create
相当于OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666)
即:可读写、可创建、会清空旧内容。
🔍 四、
os.Stat
—— 获取文件信息info, err := os.Stat("demo.txt") if err == nil {fmt.Println("文件名:", info.Name())fmt.Println("大小:", info.Size())fmt.Println("修改时间:", info.ModTime())fmt.Println("是否目录:", info.IsDir()) }
- 返回值类型是
os.FileInfo
接口; - 你可以用它获取文件的基本元数据;
- 常用在判断文件是否存在、获取文件大小等场景。
例如:
if _, err := os.Stat("demo.txt"); os.IsNotExist(err) {fmt.Println("文件不存在") }
❌ 五、
os.Remove
—— 删除文件或目录err := os.Remove("demo.txt") if err != nil {fmt.Println("删除失败:", err) }
- 用于删除文件;
- 若删除目录要用
os.RemoveAll()
(可递归删除)。
例如:
os.RemoveAll("temp_folder") // 删除整个文件夹
🧠 六、扩展:
os.OpenFile
—— 高级打开方式如果你要更灵活地控制“读/写/追加/创建”等行为,
可以用更底层的函数:f, err := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
参数说明:
参数 作用 os.O_RDONLY
只读 os.O_WRONLY
只写 os.O_RDWR
读写 os.O_APPEND
追加写 os.O_CREATE
不存在则创建 os.O_TRUNC
打开时清空内容
✅ 小结对比
函数 作用 特点 os.Open
打开已有文件 只读 os.Create
创建文件 会清空原文件内容 os.OpenFile
高级打开方式 可选模式和权限 os.Stat
获取文件信息 不会打开文件 os.Remove
删除文件 同名目录也可删(空) os.File
文件句柄结构体 提供读写方法
📘 一句话记忆:
os.File
是文件对象,
os.Open
/os.Create
打开或创建它,
os.Stat
查看它,
os.Remove
删除它。 - 返回一个
3️⃣ io / ioutil / bufio —— 数据流读写
io
提供抽象接口,ioutil
(1.16 前)和 os
封装了常用函数。
简单读写文件:
package main
import ("fmt""os""io"
)func main() {// 写文件f, _ := os.Create("hello.txt")defer f.Close()f.WriteString("Hello, Go!\n")// 读文件file, _ := os.Open("hello.txt")defer file.Close()buf := make([]byte, 16)for {n, err := file.Read(buf)if err == io.EOF {break}fmt.Print(string(buf[:n]))}
}
💡 小技巧:若文件内容不大,可以直接用:
data, _ := os.ReadFile("hello.txt")
fmt.Println(string(data))
-
一、
io
(底层抽象与通用工具)1) 核心接口(几乎所有 I/O 都基于它们)
io.Reader
:Read(p []byte) (n int, err error)
io.Writer
:Write(p []byte) (n int, err error)
- 扩展能力:
io.Closer
(Close()
)io.Seeker
(Seek(offset, whence)
)io.ReaderFrom
/io.WriterTo
io.ReaderAt
/io.WriterAt
io.ByteReader
/io.ByteWriter
io.RuneReader
/io.RuneWriter
任何实现了这些接口的类型,都能被
io
的工具函数复用(文件、网络连接、内存缓冲区等)。2) 常用函数与适配器
- 复制/搬运:
io.Copy(dst, src)
io.CopyN(dst, src, n)
io.CopyBuffer(dst, src, buf)
(自带缓冲)
- 读写工具:
io.ReadAll(r)
(读取到内存;Go1.16 起从ioutil
挪到io
)io.ReadAtLeast(r, buf, min)
、io.ReadFull(r, buf)
io.LimitReader(r, n)
(限流视图,只能读 n 字节)
- 组合与管道:
io.MultiReader(r1, r2, ...)
(像拼接流)io.MultiWriter(w1, w2, ...)
(写一次,多个下游都收到)io.TeeReader(r, w)
(边读边把数据抄一份到 w,常用于日志/校验)io.Pipe()
(内存中的“管道”,生产者写入、消费者读取)
- 文件区间视图:
io.NewSectionReader(rAt, off, n)
(对支持ReaderAt
的底层创建只读区段视图)
- 其它:
io.NopCloser(r)
(给只读流套个“空 Close”)io.Discard
(黑洞 writer)
二、
ioutil
(旧包,已弃用,大多迁到io
/os
)Go 1.16 起标记为过时;你仍会在老项目中见到。替代关系:
旧 ioutil
新用法 ioutil.ReadAll
io.ReadAll
ioutil.ReadFile
os.ReadFile
ioutil.WriteFile
os.WriteFile
ioutil.ReadDir
os.ReadDir
ioutil.TempFile
os.CreateTemp
ioutil.TempDir
os.MkdirTemp
ioutil.NopCloser
io.NopCloser
记忆:读/写文件 →
os
;读整个流 →io
;临时文件/目录也去os
。
三、
bufio
(带缓冲的高层封装:Reader/Writer/Scanner)1)
bufio.Reader
- 创建与大小:
bufio.NewReader(r)
/bufio.NewReaderSize(r, size)
- 常用方法:
Read(p []byte) (n int, err error)
(带缓冲的读)ReadByte()
,UnreadByte()
ReadRune()
,UnreadRune()
(正确按 rune 读取 UTF-8)Peek(n int) ([]byte, error)
(窥视但不消费)ReadString(delim byte)
/ReadBytes(delim byte)
(按分隔符读到分隔符)Buffered()
(缓冲区里还未读出的字节数)Reset(r io.Reader)
(复用 Reader,避免重复分配)
说明:
ReadLine
是过时风格且处理复杂;通常用ReadString('\n')
或Scanner
。2)
bufio.Writer
- 创建与大小:
bufio.NewWriter(w)
/bufio.NewWriterSize(w, size)
- 常用方法:
Write(p []byte) (n int, err error)
WriteByte(b byte)
/WriteString(s string)
Flush()
(必须 Flush 才会把缓冲里的内容真正写到下游)Available()
、Buffered()
、Reset(w io.Writer)
3)
bufio.Scanner
(按“标记”扫描,默认逐行)- 创建:
bufio.NewScanner(r)
- 迭代:
Scan()
(返回 bool,逐次推进)Text()
/Bytes()
(取当前 token 数据)Err()
(结束后的错误)
- 自定义分割:
scanner.Split(bufio.ScanLines)
(默认)bufio.ScanWords
/bufio.ScanRunes
/ 自己实现SplitFunc
- 调整 token 最大长度:
scanner.Buffer(make([]byte, 0, 1024), maxTokenSize)
(常被忽略:默认 token 最大 64K)
4)
bufio.NewReadWriter
-
组合一个
*Reader
和*Writer
:rw := bufio.NewReadWriter(bufio.NewReader(r), bufio.NewWriter(w)) // rw.ReadString / rw.WriteString / rw.Flush 等
快速示例
A) 逐行读取(
bufio.Scanner
)f, _ := os.Open("log.txt") defer f.Close()sc := bufio.NewScanner(f) sc.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // 支持更长行 for sc.Scan() {line := sc.Text()fmt.Println(line) } if err := sc.Err(); err != nil {log.Fatal(err) }
B) 带缓冲写(
bufio.Writer
)f, _ := os.Create("out.txt") bw := bufio.NewWriter(f) defer func() { bw.Flush(); f.Close() }()bw.WriteString("Hello\n") bw.WriteString("Buffered write!\n") // 别忘了 Flush,否则可能写不下去
C) 复制流(
io.Copy
)src, _ := os.Open("src.bin") dst, _ := os.Create("dst.bin") defer src.Close(); defer dst.Close()if _, err := io.Copy(dst, src); err != nil {log.Fatal(err) }
D) Tee(边读边旁路记录)
r, _ := os.Open("data.bin") logBuf := &bytes.Buffer{} tee := io.TeeReader(r, logBuf) // 读给上游的同时,也写到 logBufdata, _ := io.ReadAll(tee) fmt.Println("shadow copy size:", logBuf.Len()) _ = data // 使用 data...
E) Pipe(协程间内存管道)
pr, pw := io.Pipe()go func() {defer pw.Close()pw.Write([]byte("hello via pipe")) }()buf := make([]byte, 32) n, _ := pr.Read(buf) fmt.Println(string(buf[:n]))
容易踩坑 & 小建议
bufio.Writer
一定要Flush()
;用defer bw.Flush()
最保险。Scanner
默认最大 token 64K,日志/长行要Buffer()
扩大。ioutil
在老代码中常见,但在新项目优先用io
/os
的替代。- 大文件/网络流不要
ReadAll
到内存,改用io.Copy
或分块处理。 - 读写文本时,如果涉及 UTF-8 多字节,优先
ReadRune
/ScanRunes
。
4️⃣ strings —— 字符串操作
package main
import ("fmt""strings"
)func main() {s := " Go is great "fmt.Println(strings.TrimSpace(s)) // 去除前后空格fmt.Println(strings.ToUpper(s)) // 转大写fmt.Println(strings.Contains(s, "Go")) // 是否包含fmt.Println(strings.Split(s, " ")) // 分割fmt.Println(strings.Join([]string{"A", "B", "C"}, "-")) // 拼接
}
常用函数 | 功能 |
---|---|
ToUpper / ToLower | 大小写转换 |
Trim / TrimSpace | 去除空白 |
Split / Join | 拆分与合并 |
HasPrefix / HasSuffix | 前缀后缀判断 |
ReplaceAll | 字符替换 |
把 strings
包系统讲一遍:按“查询→拆装→清洗→变形→构造→流式读取”六个维度梳理
-
1) 查询/匹配
Contains(s, substr)
/ContainsAny(s, chars)
/ContainsRune(s, r)
HasPrefix(s, prefix)
/HasSuffix(s, suffix)
Index(s, substr)
/LastIndex(s, substr)
(返回下标,找不到为-1
)IndexAny(s, chars)
/IndexRune(s, r)
Count(s, substr)
:非重叠计数(Count("aaa","aa")==1
)
示例:
s := "go gopher" strings.Contains(s, "go") // true strings.HasPrefix(s, "go") // true strings.Index(s, "ph") // 5 strings.Count("banana", "na") // 2
2) 拆分 / 组装
Split(s, sep)
/SplitN(s, sep, n)
/SplitAfter(s, sep)
/SplitAfterN
Fields(s)
:按任意空白分词(连续空白折叠)FieldsFunc(s, f)
:自定义分隔(传入函数判定分隔符)Join(elems, sep)
:把切片拼成字符串
示例:
strings.Split("a,b,,c", ",") // ["a","b","","c"] strings.Fields(" a\tb\n c ") // ["a","b","c"] strings.Join([]string{"A","B"}, "-") // "A-B"// 自定义分隔:把非字母字符都视作分隔符 isSep := func(r rune) bool { return !unicode.IsLetter(r) } strings.FieldsFunc("go-1.21 is cool!", isSep) // ["go","is","cool"]
3) 清洗 / 修剪
Trim(s, cutset)
:去掉首尾任意“字符集合”的字符(逐 rune 比较)TrimSpace(s)
:去掉首尾空白(含 \t \n 等)TrimLeft/Right(s, cutset)
、TrimPrefix/TrimSuffix(s, prefix/suffix)
示例:
strings.Trim("..hi..", ".") // "hi" strings.TrimSpace(" \n Go \t ") // "Go" strings.TrimPrefix("foobar","foo") // "bar" strings.TrimSuffix("hello.go",".go") // "hello"
4) 变形 / 替换 / 比较
- 大小写:
ToUpper(s)
/ToLower(s)
(UTF-8 友好)ToTitle(s)
:大写化(Title Case,不等于“标题化每词首字母大写”)- 注意:旧的
Title(s)
已弃用,不要再用来“首字母大写标题化”。需要标题化请自己按词拆分再处理首字母(或用第三方库)。
- 替换:
Replace(s, old, new, n)
/ReplaceAll(s, old, new)
- 重复:
Repeat(s, count)
- 比较:
Compare(a, b)
:返回 -1/0/1(字典序)EqualFold(a, b)
:不区分大小写比较(UTF-8 友好)
示例:
strings.ToUpper("Go语言") // "GO语言" strings.ReplaceAll("a-b-a", "a", "x") // "x-b-x" strings.EqualFold("Go", "gO") // true
5) 高效构造大字符串
strings.Builder
:推荐在循环中构造大字符串(避免反复分配/拷贝)strings.NewReplacer
:多规则替换器,复用场景高效
示例(Builder):
var b strings.Builder b.Grow(128) // 预估可选 b.WriteString("Hello") b.WriteByte(' ') fmt.Println(b.String())
示例(Replacer):
r := strings.NewReplacer("<","<", ">",">", "&","&") fmt.Println(r.Replace("a<b & c>d")) // "a<b & c>d"
“大字符串”指的不是某种新的数据类型,而是在 Go 里,字符串特别长、需要大量拼接或频繁修改的情况。
🧩 为什么要区分“大字符串”
在 Go 中:
- 字符串是不可变的:一旦创建,内容就不能改。
- 每次用
+
或fmt.Sprintf
拼接时,Go 实际上会生成一个新字符串,把旧内容拷贝过去再加新内容。 - 如果拼接次数很多(比如循环 1 万次),就会产生 大量中间字符串和拷贝 → 性能差、内存浪费。
所以当我们说“大字符串”,一般有两层意思:
- 字符串本身长度很长(几 MB,甚至上百 MB),处理时要小心内存和性能。
- 字符串拼接次数很多,哪怕单个字符串不算大,总体会非常低效。
🛠 解决方案:
strings.Builder
Go 推荐用
strings.Builder
来高效构造大字符串:- 内部维护一个动态扩容的
[]byte
缓冲区; - 支持
WriteString
、WriteByte
、WriteRune
等方法; - 最后用
.String()
一次性取出结果。
示例:
package main import ("fmt""strings" )func main() {var b strings.Builderb.Grow(64) // 可选:提前分配容量,避免频繁扩容for i := 0; i < 5; i++ {b.WriteString("Hello")b.WriteByte(' ')}fmt.Println(b.String()) // "Hello Hello Hello Hello Hello " }
好处:
- 零拷贝复用内部缓冲;
- 性能比
+
或fmt.Sprintf
在循环里高很多。
🔍 补充:什么时候要注意“大字符串”
- 日志拼接:比如百万行日志,千万别用
+=
拼接。 - HTML/JSON/SQL 动态构造:推荐
strings.Builder
或bytes.Buffer
。 - 网络传输或文件拼接:建议直接用
io.Writer
流式写,避免一次性放进内存。 - 超大文本处理:考虑用
bufio.Scanner
分块读,避免ReadAll
。
✅ 总结一句话:
在 Go 中,“大字符串”不是特殊类型,而是指需要处理或拼接大量字符串的场景。
这时要避免+
拼接,优先用strings.Builder
或 流式 I/O。
6) 把字符串当“流”读
strings.NewReader(s)
:把string
包装成io.Reader
/io.Seeker
- 可与
io.Copy
、bufio.Reader
等无缝配合
- 可与
r := strings.NewReader("hello") buf := make([]byte, 2) n, _ := r.Read(buf) // 读到 "he" _ = n
易错点 & 小贴士
-
len(s)
是字节数,不是“字符(rune)”数
多字节 UTF-8 字符要用:import "unicode/utf8" utf8.RuneCountInString("你好") // 2
-
Count
是非重叠计数;需要重叠计数要自己写逻辑或用正则。 -
Split
对连续分隔符会产生空串;如果不想要空串,优先看Fields
/FieldsFunc
。 -
需要“标题化”(每个词首字母大写),要自己拆词 + 处理首字母(
unicode.ToUpper
on first rune)。 -
大量拼接请用
Builder
,而不是+
或fmt.Sprintf
。
迷你速查表
任务 函数 是否包含/前缀/后缀 Contains
/HasPrefix
/HasSuffix
查找下标 Index
/LastIndex
/IndexAny
切分 & 组合 Split
/Fields
/Join
去空白/去前后缀 Trim(Space/Prefix/Suffix)
大小写/忽略大小写比较 ToUpper/Lower
/EqualFold
替换/重复 Replace(All)
/Repeat
构造 Builder
/NewReplacer
当流读取 NewReader
5️⃣ strconv —— 字符串与数值互转
package main
import ("fmt""strconv"
)func main() {i, _ := strconv.Atoi("42") // 字符串 -> intfmt.Println(i + 10)s := strconv.Itoa(99) // int -> 字符串fmt.Println("Value is " + s)f, _ := strconv.ParseFloat("3.14", 64)fmt.Println(f * 2)
}
函数 | 说明 |
---|---|
Atoi / Itoa | int 与字符串互转 |
ParseInt / ParseFloat | 字符串 → 数字 |
FormatInt / FormatFloat | 数字 → 字符串 |
6️⃣ time —— 时间、日期、延时
package main
import ("fmt""time"
)func main() {now := time.Now()fmt.Println("现在时间:", now)// 格式化输出fmt.Println(now.Format("2006-01-02 15:04:05"))// 延时与定时time.Sleep(2 * time.Second)fmt.Println("休眠结束")t1 := time.Now()time.Sleep(100 * time.Millisecond)fmt.Println("耗时:", time.Since(t1))
}
📘 说明:
- Go 的时间格式不是
YYYY-MM-DD
,而是固定写作
2006-01-02 15:04:05
(Go 的“魔法时间模板”)。
类型与单位 → 获取/计算 → 格式化/解析 → 定时器与 ticker → 时区与本地化 → 常见坑
-
1) 基本类型与单位
time.Time
:一个时间点(带时区/地点信息)。time.Duration
:时间长度,本质是int64
纳秒。内置常量:time.Nanosecond
、Microsecond
、Millisecond
、Second
、Minute
、Hour
。
now := time.Now() // 当前本地时间 d := 150 * time.Millisecond // 一个时长 fmt.Println(now, d.Seconds()) // 0.15
2) 获取时间 & 算术
t := time.Now() t2 := t.Add(2 * time.Hour) // 加减时长 t3 := t.AddDate(0, 1, -3) // 年/月/日 变更(处理闰月/闰年/DST) diff := t2.Sub(t) // t2 - t => Duration fmt.Println(time.Since(t)) // == time.Now().Sub(t) fmt.Println(time.Until(t2)) // t2 - time.Now()
比较:
t.Before(t2) // true/false t.After(t2) t.Equal(t2)
取整:
t.Round(15 * time.Minute) // 四舍五入到粒度 t.Truncate(time.Hour) // 向下取整到整点
Unix 时间戳:
sec := time.Now().Unix() // 秒 ms := time.Now().UnixMilli() // 毫秒 ns := time.Now().UnixNano() // 纳秒t := time.Unix(sec, 0) // 秒 -> Time
3) 格式化与解析(重点!)
Go 不用
YYYY-mm-dd
模式字符串,而是用**“神奇模板”**:2006-01-02 15:04:05.000 -0700 MST
记忆口诀:2006 01 02 15 04 05(1月2日下午3点04分05秒,-0700时区,MST缩写)。
格式化:
t := time.Now() fmt.Println(t.Format("2006-01-02 15:04:05")) // 2025-10-19 12:34:56 fmt.Println(t.Format(time.RFC3339)) // 2025-10-19T12:34:56+09:00
解析:
s := "2025-10-19 08:30:00" layout := "2006-01-02 15:04:05" t, err := time.Parse(layout, s) // 解析为 UTC(若 layout 无时区)
带时区解析(推荐):
loc, _ := time.LoadLocation("Asia/Tokyo") t, err := time.ParseInLocation(layout, s, loc) // 视为 Tokyo 时区的本地时间
4) 定时、延迟与超时
4.1 简单延时
time.Sleep(500 * time.Millisecond)
4.2 一次性定时器
Timer
timer := time.NewTimer(2 * time.Second) <-timer.C // 2秒后通道收到一个时间值 // 或 time.After(2 * time.Second) // 便捷用法,返回 <-chan Time
取消/重置:
if !timer.Stop() { <-timer.C } // 清理已触发的信号,避免泄漏 timer.Reset(1 * time.Second)
4.3 周期定时
Ticker
ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for i := 0; i < 3; i++ {<-ticker.C // 每秒触发一次 }
4.4
select
+ 超时(并发常用)select { case v := <-workCh:_ = v case <-time.After(800 * time.Millisecond):fmt.Println("timeout") }
多次循环内不要每次
time.After
直接创建(会产生很多定时器对象)。可复用Timer
:timer := time.NewTimer(800 * time.Millisecond) for {timer.Reset(800 * time.Millisecond)select {case v := <-workCh:_ = vif !timer.Stop() { <-timer.C }case <-timer.C:fmt.Println("timeout")} }
5) 时区与本地化
locTokyo, _ := time.LoadLocation("Asia/Tokyo") utc := time.Now().UTC() inTokyo := utc.In(locTokyo)fmt.Println(inTokyo.Format(time.RFC3339)) // 带 +09:00
自定义固定时区:
loc := time.FixedZone("CST-0800", 8*60*60) // 东八区 t := time.Date(2025, 10, 19, 9, 0, 0, 0, loc)
常见场景:带时区解析
s := "2025-10-19T09:30:00+09:00" t, _ := time.Parse(time.RFC3339, s) // layout 含时区 => 解析出绝对时刻
6) 单调时钟与时间差(防系统时间回拨)
Go 的
time.Time
内部可能携带单调时钟戳(同一进程内单调递增),用于稳定计算差值:time.Since(t)
/t2.Sub(t1)
优先使用(不受系统时间修改影响)。- 序列化为文本/JSON 时会去掉单调部分,跨进程后再相减就只有墙钟精度。
7) 常见坑 & 最佳实践
- 模板不是 yyyy:要用
2006-01-02 15:04:05
。RFC3339 最通用。 - 解析时区:没时区信息的
Parse
结果按 UTC 解释;要本地时区用ParseInLocation
。 - 循环里
time.After
泄漏:高频 select 循环请用NewTimer().Reset()
复用。 - DST/月底问题:跨月跨年用
AddDate
;加天数优先AddDate(0,0,n)
而不是Add(n*24*time.Hour)
(DST 变更日可能多/少 1 小时)。 Ticker
必须Stop()
:否则 goroutine 和计时器泄漏。- 大量时间点比较:尽量使用
Before/After/Equal
,并在进入业务前统一时区。
快速小抄
// 现在、加减、差值 now := time.Now() _ = now.Add(2*time.Hour).Sub(now)// 格式化/解析 layout := "2006-01-02 15:04:05" s := now.Format(layout) t, _ := time.ParseInLocation(layout, s, time.Local)// 定时与超时 <-time.After(500 * time.Millisecond) timer := time.NewTimer(time.Second); timer.Stop()// 时区 tokyo, _ := time.LoadLocation("Asia/Tokyo") fmt.Println(now.In(tokyo))
📁 二、文件操作
1️⃣ 写入文件
os.WriteFile("note.txt", []byte("hello world"), 0644)
2️⃣ 读取文件
data, err := os.ReadFile("note.txt")
if err != nil {panic(err)
}
fmt.Println(string(data))
3️⃣ 追加内容
f, _ := os.OpenFile("note.txt", os.O_APPEND|os.O_WRONLY, 0644)
defer f.Close()
f.WriteString("\nnew line")
4️⃣ 遍历目录
entries, _ := os.ReadDir(".")
for _, e := range entries {fmt.Println(e.Name())
}
🧾 三、JSON 序列化与反序列化
Go 内置 JSON 编解码库:encoding/json
常用于接口数据、配置文件、日志等。
1️⃣ 结构体 → JSON
package main
import ("encoding/json""fmt"
)type User struct {Name string `json:"name"`Age int `json:"age"`
}func main() {u := User{"Alice", 23}data, _ := json.Marshal(u)fmt.Println(string(data)) // {"name":"Alice","age":23}
}
2️⃣ JSON → 结构体
var u2 User
jsonData := `{"name":"Bob","age":30}`
json.Unmarshal([]byte(jsonData), &u2)
fmt.Println(u2.Name, u2.Age)
3️⃣ 格式化输出
b, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(b))
4️⃣ JSON → map[string]interface{}
raw := `{"a":1,"b":2.5,"c":"hi"}`
var m map[string]interface{}
json.Unmarshal([]byte(raw), &m)
fmt.Println(m["c"])
5️⃣ 注意事项
- 字段首字母必须大写,才能被导出;
- 使用结构体标签
json:"xxx"
改字段名; - 解析未知结构时用
map[string]interface{}
; - 用
omitempty
忽略空字段。
✅ 小结表
包名 | 常见用途 |
---|---|
fmt | 格式化输出、字符串拼接 |
os | 文件操作、命令行参数、环境变量 |
io | 文件流与拷贝 |
strings | 文本处理 |
strconv | 字符串与数字互转 |
time | 时间管理、延迟、计时 |
encoding/json | JSON 编解码 |
🧩三、错误处理
1) error
接口(首选方式:返回值传递)
定义(内置接口):
type error interface {Error() string
}
1.1 返回错误的基本范式
func readConfig(path string) ([]byte, error) {b, err := os.ReadFile(path)if err != nil { // 失败就把错误向上返回return nil, err}return b, nil // 成功时 error 为 nil
}func main() {data, err := readConfig("cfg.json")if err != nil {log.Fatalf("read failed: %v", err)}fmt.Println(len(data))
}
1.2 自定义错误(带上下文/类型)
a) 简单固定错误(哨兵错误 sentinel)
var ErrNotFound = errors.New("not found")
b) 自定义类型实现 Error()
type ParseError struct {Line intMsg string
}
func (e *ParseError) Error() string {return fmt.Sprintf("line %d: %s", e.Line, e.Msg)
}
好处:上层可以用 类型断言/errors.As
精确区分错误种类。
2) panic
与 recover
(异常路径,只在少数场景)
panic
:立即中断正常流程,自下而上展开调用栈,执行各层defer
,然后崩溃退出程序(若不中途recover
)。recover
:仅能在defer
的函数中调用,用来“接住”正在进行的 panic,使程序恢复到可控状态。
2.1 什么时候该用 panic
- 不可恢复的编程错误:数组越界、必然为非空的全局资源丢失、初始化阶段的致命错误等;
- 不要把
panic
当作“可预期业务错误”的返回路径(这类应使用error
返回)。
2.2 recover
典型用法(服务进程保护边界)
func safeGo(fn func()) {go func() {defer func() {if r := recover(); r != nil {log.Printf("goroutine panic: %v", r)}}()fn()}()
}
- 在协程边界兜底,避免单个请求/任务把整个服务搞崩;
- 仅记录并上报,不要吞掉应当暴露的逻辑错误。
总结:业务分支用
error
返回;真正“异常/bug”才用panic
,必要时在最外层recover
住,做日志与善后。
3) 错误包装与判断(现代写法)
3.1 包装:向上加上下文
f, err := os.Open(path)
if err != nil {return nil, fmt.Errorf("open %q: %w", path, err) // 用 %w 包装
}
fmt.Errorf("...: %w", err)
会把原始错误链接起来;- 上层既能看到人类可读的上下文(哪一步失败),又能保留底层错误供机器判断。
3.2 判断:errors.Is
/ errors.As
if errors.Is(err, os.ErrNotExist) { // 链式匹配“是否为某类错误”// 文件不存在
}
var pe *ParseError
if errors.As(err, &pe) { // 抽取具体错误类型fmt.Println("bad line:", pe.Line)
}
3.3 创建错误:errors.New
/ fmt.Errorf
var ErrUnauthorized = errors.New("unauthorized") // 固定错误
return fmt.Errorf("call api: %w", ErrUnauthorized) // 叠加上下文
3.4 多个错误:errors.Join
(Go 1.20+)
err := errors.Join(errA, errB, errC) // 合并错误
// errors.Is/As 还能从 Join 结果里匹配到任一子错误
4) 项目中的“错误风格”小抄
- 就近处理能落地的错误(重试、降级、默认值);不能处理就向上返回;
- 每层加一点上下文(
fmt.Errorf("load cfg: %w", err)
),定位问题快; - 对可预期业务分支,返回 语义化错误(哨兵值或类型错误)供上层判断;
- 底层调用失败但短路不致命,记录日志不要沉默;
- 仅在进程边界/协程边界用
recover
,不要到处recover
掩盖 bug。
5) 小示例:读取 JSON 配置并区分错误
type Config struct{ Port int }var ErrEmptyConfig = errors.New("empty config")func LoadConfig(path string) (*Config, error) {b, err := os.ReadFile(path)if err != nil {return nil, fmt.Errorf("read %q: %w", path, err)}if len(b) == 0 {return nil, ErrEmptyConfig}var c Configif err := json.Unmarshal(b, &c); err != nil {return nil, fmt.Errorf("parse json: %w", err)}return &c, nil
}func main() {c, err := LoadConfig("config.json")if err != nil {switch {case errors.Is(err, ErrEmptyConfig):log.Println("empty file, using defaults…")case errors.Is(err, os.ErrNotExist):log.Fatal("config missing:", err)default:log.Fatalf("load config failed: %v", err)}}fmt.Println("port =", c.Port)
}
我们来总结 Go 的错误处理机制,帮你建立一个完整的“心智模型图”。
🧭 一、错误的三层结构(由轻到重)
层级 | 机制 | 场景 | 是否可恢复 |
---|---|---|---|
① | error 接口 | 常规业务错误 | ✅ 可恢复 |
② | panic / recover | 程序异常、bug、资源断裂 | ⚠️ 仅局部可恢复 |
③ | 程序退出 (os.Exit ) | 不可恢复错误(进程级) | ❌ 不恢复 |
🧩 二、核心概念详解
1️⃣ error
接口:用返回值传递错误
func readConfig(path string) ([]byte, error) {b, err := os.ReadFile(path)if err != nil {return nil, fmt.Errorf("read %q: %w", path, err)}return b, nil
}
- 所有 Go 函数都推荐用
(result, error)
形式返回。 nil
表示没有错误。%w
可以包装原始错误(方便上层判断)。
👉 错误判断
if errors.Is(err, os.ErrNotExist) {fmt.Println("文件不存在")
}
👉 错误提取
var pe *ParseError
if errors.As(err, &pe) {fmt.Println("解析错误行:", pe.Line)
}
2️⃣ panic
/ recover
:紧急中断与抢救
panic
类似于“程序级别的异常”。recover
只能在defer
中使用,用来捕获 panic。
func safeRun(fn func()) {defer func() {if r := recover(); r != nil {log.Println("捕获 panic:", r)}}()fn()
}
👉 常用场景
- 不可预期的 bug(如数组越界、空指针)
- 服务器中某个请求崩溃时,防止整个程序退出
3️⃣ 错误包装与判断
errors.New("msg")
:创建固定错误fmt.Errorf("...: %w", err)
:加上下文并保留原始错误errors.Is
/errors.As
:解包匹配
例:
err := fmt.Errorf("open file: %w", os.ErrNotExist)
fmt.Println(errors.Is(err, os.ErrNotExist)) // true
🧱 三、正确的错误使用边界
情况 | 用法 |
---|---|
输入非法、文件不存在等业务错误 | 返回 error |
空指针、除零、系统崩溃等 bug | 用 panic |
goroutine 崩溃时不希望影响全局 | 在 defer 中 recover() |
错误链路追踪 | 用 %w 包装 |
🧩 四、小结:错误流转模型
业务错误(error) → 上层包装(fmt.Errorf) → 判断(errors.Is)↓致命错误 → panic() → defer recover()
综合前五个阶段的学习,我们从 Go 语言的基础语法入手,逐步掌握了输入输出、变量与常量、条件语句和循环控制,理解了切片与 map 的底层原理及使用方式,学会了函数、闭包与 defer 的应用场景,并能够通过 os、io、strconv 等标准库与操作系统和数据进行交互。通过这些知识的串联,不仅能写出结构清晰的小程序,还为后续深入并发编程、时间处理和更复杂的标准库打下了坚实基础