当前位置: 首页 > news >正文

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

  1. 打开官网 https://go.dev/dl/

  2. 下载 .msi 安装包,默认安装即可(路径通常是 C:\Program Files\Go

  3. 安装完成后,打开 PowerShell 输入:

    go version
    

    若输出类似:

    go version go1.23.3 windows/amd64
    

    就表示成功。

Mac / Linux

  1. 访问 https://go.dev/dl/

  2. 下载 .pkg(Mac)或 .tar.gz(Linux)包;

  3. 解压或安装后,在终端输入:

    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.Printlnfmt.Scan

Go 用标准库 fmt 实现输入输出。

输出
fmt.Print("不换行")
fmt.Println("换行")
fmt.Printf("我叫%s,今年%d岁。\n", "Tom", 20)
输入

fmt.Scanfmt.Scanf

var name string
var age int
fmt.Scan(&name, &age)  // 注意要取地址 &
fmt.Println("输入结果:", name, age)

示例:

输入: Alice 22
输出: 输入结果: Alice 22

✅ 小练习

请你试着写一个程序,要求:

  1. 定义常量 Country = "China"

  2. 从键盘输入姓名和年龄;

  3. 输出一句话,比如:

    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)}

💡 小提示

  1. fmt.Println("Hello, 我叫", name, ", ", age, "岁,来自", Country)
    这种写法会在每个参数之间自动加空格,所以会出现 多余空格

    如果想更漂亮,可以用 fmt.Printf

    fmt.Printf("Hello, 我叫 %s,今年 %d 岁,来自 %s。\n", name, age, Country)
    

    输出会更自然:

    Hello, 我叫 Tom,今年 20 岁,来自 China。
    
  2. 输入时需要用空格分隔,比如 Tom 20。如果想分多行输入,可以写两次 fmt.Scan 分别读取。

总结:

  • Println → 简单输出,会自动加空格和换行;
  • Printf → 格式化输出,可以精确控制显示样式;
  • Scan → 从输入读取数据,需要用 & 取变量地址。

3.控制结构

1.条件语句:ifswitch

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.循环语句:forrange

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.跳转语句:breakcontinuegoto

控制循环流程的三个关键字:

关键字含义
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]

lencap

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] 有两种返回:

  1. value:对应的值;
  2. ok:布尔值,表示是否真的存在该 key。

这是为了避免“零值陷阱”:

比如:

scores["Lucy"] // Lucy 不存在

这时 Go 会返回值类型的“零值”:

  • int → 0
  • string → “”
  • bool → false
    但你并不知道是“真的为 0”,还是“key 不存在”。

所以 Go 提供了双返回机制:

value, ok := scores["Lucy"]
  • 如果 Lucy 存在:ok = truevalue = 实际值
  • 如果 Lucy 不存在:ok = falsevalue = 零值

四、完整示意图

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")

练习

写一个小程序,完成以下任务:

  1. 创建一个 map[string]int 来保存学生姓名和成绩;
  2. 添加三个学生;
  3. 打印所有学生;
  4. 判断某个名字(比如 "Tom")是否存在;
  5. 如果存在,就打印分数并删除他;
  6. 再打印一次 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 在运行时强制随机化,保证你不能依赖顺序


🧩 四、如果想要“有序遍历”,该怎么办?

正确做法是:

  1. 先取出所有 key;
  2. 对 key 排序;
  3. 再遍历。

示例:

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

三、使用场景

  1. 判断是否初始化

    var s []int
    if s == nil {fmt.Println("切片未初始化")
    }
    
  2. 返回错误时常用

    func divide(a, b int) (int, error) {if b == 0 {return 0, fmt.Errorf("除数不能为0")}return a / b, nil   // 表示没有错误
    }
    
  3. 接口的默认值

    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, complex1280 / 0.0 / 0+0i所有数值型变量默认都是 0
布尔型boolfalse表示“假”
字符串型string""空字符串,不是 nil
指针类型*int, *float64nil不指向任何内存地址
切片(slice)[]intnil没有底层数组,长度和容量都是 0
映射(map)map[string]intnil没有分配内存,不能写入
通道(chan)chan intnil未初始化的通道,发送或接收都会阻塞
接口(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 允许在函数里面再定义函数,原因是:

  1. 可以用闭包保存“局部状态”;
  2. 避免污染全局命名空间;
  3. 让逻辑更紧密,比如一个“工具函数”只在主函数内部使用。

例子:

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()

🧩 内存中的情况

  1. 调用 makeCounter() 时
    • 变量 x 在栈上创建(初始值 = 0)。
    • 返回的匿名函数引用了 x
Stack:
+---------+
| x = 0   | ← 被闭包引用
+---------+Heap:
+---------------------------------+
| func() int { x++; return x }    |
+---------------------------------+

  1. 返回匿名函数时
    • 按理说函数执行完,x 应该销毁;
    • 但是,因为匿名函数还在用它,Go 会把 x “逃逸”到堆上,保证它活着。
Heap:
+---------+        +---------------------------------+
| x = 0   |  ←───  | func() int { x++; return x }    |
+---------+        +---------------------------------+

  1. 多次调用闭包函数时
    每次调用 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

🔍 原因:

  • xmakeAdder 的局部变量;
  • 但返回的匿名函数“引用”了 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 会 按栈的顺序反向执行(后进先出)

小练习

写一个程序:

  1. 定义一个函数 max(a, b int) int,返回较大的数;
  2. 主函数中读取两个整数;
  3. 调用 max 输出较大值;
  4. 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))
}

编写一个程序:

  1. 从输入中读取若干整数(数量固定,比如 5 个);
  2. 用一个切片存储这些数;
  3. 定义三个函数:
    • max(nums []int) int —— 返回最大值
    • min(nums []int) int —— 返回最小值
    • avg(nums []int) float64 —— 返回平均值
  4. 最后输出三者结果。
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)
mapmake(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 会做三件事:

  1. 分配一块底层数组,大小 = cap = 5
  2. Len = 3,表示已经初始化了 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 发现容量不够,就会:

  1. 新建一个更大的底层数组(一般扩容到 2 倍);
  2. 把原有数据复制过去
  3. 返回新的切片(和旧的不是同一块底层内存)。

✅ 四、小实验

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
}

现在 AddressContact 都有一个 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相当于 selfthis

⚙️ 三、调用方式

定义:

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)
}
  • 这里 aAnimal 的一个副本;
  • 修改 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 = falses 会是零值。

🔄 四、类型 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

✅ 小练习

请你写一个程序:

  1. 定义一个 []interface{} 切片,往里面存放不同类型的值(int、string、bool);
  2. 遍历这个切片,用 类型 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)需要修改结构体或优化性能

✅ 小练习

请你写一个程序:

  1. 定义一个 Animal 接口,包含 Speak() 方法;
  2. 定义 DogCat 两个结构体,并实现 Speak() 方法;
  3. 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// 这里写实际的工作逻辑
    }()
    

    好处:

    • 即使函数中途 returnDone() 也不会漏掉;
    • 不用担心忘记写 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")
}

运行原理:

  1. go func() { time.Sleep(50ms); ch <- 1 }()
    • 启动了一个协程;
    • 它在 50ms 后才会往通道 ch 里发送值;
    • 在这 50ms 内,ch没有值的
  2. time.After(10ms)
    • 这是 Go 标准库里的一个定时器;
    • 它会在 10ms 后向返回的通道里发送一个信号;
    • 所以它可以用来实现“超时等待”。
  3. select
    • 同时等待多个通道;
    • 哪个通道先就绪(可以读/写),就执行那个 case
    • 其它 case 会被忽略。

⚙️ 实际执行顺序

  • 代码一运行,select 会同时监听两个通道:
    • ch(要等 50ms 才有数据)
    • time.After(10ms)(10ms 后就会发信号)

因为 time.Afterch 先就绪,
所以程序输出:

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 推理等)。
    • 我们把真正占资源的逻辑夹在 acquirerelease 之间,确保进入的人数受控
    • 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.")
}

🔍 程序运行逻辑

  1. gpuPool := make(chan int, numGPU)
    通道容量 = GPU 数量,比如 2。

  2. 初始化 GPU ID:

    for i := 0; i < numGPU; i++ {ch <- i
    }
    

    让通道里一开始就存 [0, 1],表示 GPU0、GPU1 都是空闲的。

  3. 每个推理任务:

    • <-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 编排

🚀 拓展思路(实际项目中可以这样用)

  1. 加上超时控制:防止某个推理任务卡死。

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    
  2. 动态扩展 GPU 池:比如不同 GPU 能力不同,可以用结构体存 {ID, MemLeft}

  3. 多模型支持:可以按模型类型拆分多个调度池,例如 NLP 模型池 / CV 模型池。

  4. 结合 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.modgo.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、自定义包、导入与常用标准库(stringsosfmt)。


第 1 步:初始化模块 & 目录

  1. 新建目录并进入:
mkdir textlab && cd textlab
  1. 初始化模块(模块名就叫当前目录名):
go mod init textlab
  1. 建一个包目录:
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.modgo.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.Openos.Create

  • os.Statos.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))

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("<","&lt;", ">","&gt;", "&","&amp;")
    fmt.Println(r.Replace("a<b & c>d")) // "a&lt;b &amp; c&gt;d"
    

    “大字符串”指的不是某种新的数据类型,而是在 Go 里,字符串特别长、需要大量拼接或频繁修改的情况


    🧩 为什么要区分“大字符串”

    在 Go 中:

    • 字符串是不可变的:一旦创建,内容就不能改。
    • 每次用 +fmt.Sprintf 拼接时,Go 实际上会生成一个新字符串,把旧内容拷贝过去再加新内容。
    • 如果拼接次数很多(比如循环 1 万次),就会产生 大量中间字符串和拷贝性能差、内存浪费

    所以当我们说“大字符串”,一般有两层意思:

    1. 字符串本身长度很长(几 MB,甚至上百 MB),处理时要小心内存和性能。
    2. 字符串拼接次数很多,哪怕单个字符串不算大,总体会非常低效。

    🛠 解决方案:strings.Builder

    Go 推荐用 strings.Builder 来高效构造大字符串:

    • 内部维护一个动态扩容的 []byte 缓冲区;
    • 支持 WriteStringWriteByteWriteRune 等方法;
    • 最后用 .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 在循环里高很多。

    🔍 补充:什么时候要注意“大字符串”

    1. 日志拼接:比如百万行日志,千万别用 += 拼接。
    2. HTML/JSON/SQL 动态构造:推荐 strings.Builderbytes.Buffer
    3. 网络传输或文件拼接:建议直接用 io.Writer 流式写,避免一次性放进内存。
    4. 超大文本处理:考虑用 bufio.Scanner 分块读,避免 ReadAll

    总结一句话:
    在 Go 中,“大字符串”不是特殊类型,而是指需要处理或拼接大量字符串的场景
    这时要避免 + 拼接,优先用 strings.Builder流式 I/O


    6) 把字符串当“流”读

    • strings.NewReader(s):把 string 包装成 io.Reader/io.Seeker
      • 可与 io.Copybufio.Reader 等无缝配合
    r := strings.NewReader("hello")
    buf := make([]byte, 2)
    n, _ := r.Read(buf)  // 读到 "he"
    _ = n
    

    易错点 & 小贴士

    1. len(s) 是字节数,不是“字符(rune)”数
      多字节 UTF-8 字符要用:

      import "unicode/utf8"
      utf8.RuneCountInString("你好") // 2
      
    2. Count非重叠计数;需要重叠计数要自己写逻辑或用正则。

    3. Split 对连续分隔符会产生空串;如果不想要空串,优先看 Fields/FieldsFunc

    4. 需要“标题化”(每个词首字母大写),要自己拆词 + 处理首字母unicode.ToUpper on first rune)。

    5. 大量拼接请用 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 / Itoaint 与字符串互转
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.NanosecondMicrosecondMillisecondSecondMinuteHour
    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) 常见坑 & 最佳实践

    1. 模板不是 yyyy:要用 2006-01-02 15:04:05。RFC3339 最通用。
    2. 解析时区:没时区信息的 Parse 结果按 UTC 解释;要本地时区用 ParseInLocation
    3. 循环里 time.After 泄漏:高频 select 循环请用 NewTimer().Reset() 复用。
    4. DST/月底问题:跨月跨年用 AddDate;加天数优先 AddDate(0,0,n) 而不是 Add(n*24*time.Hour)(DST 变更日可能多/少 1 小时)。
    5. Ticker 必须 Stop():否则 goroutine 和计时器泄漏。
    6. 大量时间点比较:尽量使用 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/jsonJSON 编解码

🧩三、错误处理

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) panicrecover(异常路径,只在少数场景)

  • 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
空指针、除零、系统崩溃等 bugpanic
goroutine 崩溃时不希望影响全局在 defer 中 recover()
错误链路追踪%w 包装

🧩 四、小结:错误流转模型

业务错误(error) → 上层包装(fmt.Errorf) → 判断(errors.Is)↓致命错误 → panic()defer recover()

综合前五个阶段的学习,我们从 Go 语言的基础语法入手,逐步掌握了输入输出、变量与常量、条件语句和循环控制,理解了切片与 map 的底层原理及使用方式,学会了函数、闭包与 defer 的应用场景,并能够通过 os、io、strconv 等标准库与操作系统和数据进行交互。通过这些知识的串联,不仅能写出结构清晰的小程序,还为后续深入并发编程、时间处理和更复杂的标准库打下了坚实基础

http://www.dtcms.com/a/502999.html

相关文章:

  • IPoIB驱动中RSS/TSS技术深度解析与性能优化实践
  • Redis最佳实践
  • 鸿蒙NEXT Wear Engine开发实战:手机侧应用如何调用穿戴设备能力
  • github 个人静态网页搭建(一)部署
  • 【Go】C++ 转 Go 第(三)天:defer、slice(动态数组) 与 map
  • 【大模型微调】LLaMA Factory 微调 LLMs VLMs
  • 服务器管理:构建与维护高效服务器环境的指南
  • wordpress 网站生成app中山免费建站
  • 使用搭载Ubuntu的树莓派开启热点
  • 存算一体架构的先行者:RustFS在异构计算环境下的探索与实践
  • asp access网站建设源代码网站的开发流程可以分为哪三个阶段
  • SAUP论文提到的S2S Backbone Models是什么
  • 实战量化Facebook OPT模型
  • C 标准库函数 | strcmp, strlen
  • 图像处理~多尺度边缘检测算法
  • 网站集约化建设必要性wordpress 媒体库外链
  • springboot整合redis-RedisTemplate集群模式
  • Spring AOP 实战案例+避坑指南
  • 第三章 栈和队列——课后习题解练【数据结构(c语言版 第2版)】
  • Kubernetes Ingress与安全机制
  • 【企业架构】TOGAF架构标准规范-机会与解决方案
  • apache建设本地网站wordpress修改成中文字体
  • windows平台,用pgloader转换mysql到postgresql
  • Linux驱动第一期1-10-驱动基础总结
  • 我的WordPress网站梅林固件做网站
  • 分库分表:基础介绍
  • 使用css `focus-visible` 改善用户体验
  • AI人工智能-深度学习的基本原理-第二周(小白)
  • 【2070】数字对调
  • 【AI智能体】Coze 提取对标账号短视频生成视频文案实战详解