📚 Golang 教程系列

  1. 入门与基础类型
  2. 字符串、变量与常量
  3. 流程控制
  4. 集合类型
  5. 函数、指针与类型
  6. 结构体
  7. 接口
  8. 错误处理、并发与泛型(本文)
  9. 测试与工程实践
  10. 杂项:make 与 select
  11. 杂项:embed 与资源嵌入

错误处理约定、Goroutine 与 Channel 并发模型,以及泛型基础。

本篇展开 Go 的三张王牌:error 为值的错误处理CSP 并发原语,以及 Go 1.18 泛型。

错误处理:error 是值,不是异常

Java/Python/JS 用 try/catch 抛出错误、由外层捕获;Go 把错误当作普通返回值,要求调用方当场处理

error 是什么:一个内建接口

error 是 Go 内建接口,定义只有一行:

1
2
3
4
// builtin/builtin.go
type error interface {
Error() string
}

任何实现了 Error() string 的类型都是 error,错误是一等公民,可放进变量、传给函数、塞进 channel、存进 map。与 Java 的 Throwable(带继承层级的类体系)不同,Go 的错误没有继承树,只有接口约定。error 习惯放多返回值的最后一个

1
2
3
4
5
6
7
8
9
10
11
12
13
func sqrt(x float64) (float64, error) {
if x < 0 {
return 0, errors.New("负数无平方根") // 返回错误
}
return math.Sqrt(x), nil // 成功时返回 nil
}

result, err := sqrt(-1)
if err != nil {
fmt.Println(err) // 负数无平方根
return
}
fmt.Println(result)

if err != nil 是 Go 里最高频模式,好处是错误路径和正常路径平铺可见,没有隐式栈展开。

提示:写成 if v, err := f(); err != nil,把赋值和判断放进 if 初始化语句。

errors.New 与 fmt.Errorf

1
2
3
4
import "errors"

e1 := errors.New("文件不存在")
e2 := fmt.Errorf("用户 %d 不存在", uid)

errors.New 接受字符串返回 *errorString(runtime 未导出结构体,只存一句话)。fmt.Errorffmt.Sprintf 一样支持格式化。Go 1.13 起 fmt.Errorf 多了 %w,用来包装另一个错误形成错误链。

Go 1.13 错误包装:%w 与错误链

分层服务中(handler->service->repo->驱动),最底层错误若不加上下文透传,调用方拿到 connection reset by peer 时不知道是哪个库、哪条 SQL 出问题,所以每一层都该加上下文

Go 1.13 之前用 fmt.Errorf("...: %v", err) 拼接内层错误字符串,信息有了但内层错误本身丢了%w 把内层错误包起来而非仅拼接字符串:

1
2
3
4
5
6
7
8
9
10
11
12
var ErrNotFound = errors.New("not found")

func getUser(id int) (*User, error) {
if id == 0 {
return nil, fmt.Errorf("getUser(id=%d): %w", id, ErrNotFound)
}
return &User{ID: id}, nil
}

u, err := getUser(0)
// err 的字符串:"getUser(id=0): not found"
// 但 err 内部仍然包裹着 ErrNotFound

一个 fmt.Errorf只能有一个 %w(Go 1.20 起支持多个 %w,会包成 errors.Join 风格的树)。%v 只格式化字符串,不保留引用。

动词行为是否保留内层 error是否可被 errors.Is/As 解开
%v拼接 err.Error() 字符串
%w包裹内层 error

注意:不要无脑用 %w。只有调用方有理由解包内层错误时才用 %w;内层是实现细节不该泄露时用 %v "消化"掉。

errors.Is:哨兵错误判断

“哨兵错误”(sentinel error)是预先定义好、用 == 比较的错误值,如 io.EOFsql.ErrNoRows。有了 %w 包装后 err == io.EOF 就不准了,要用 errors.Is

1
if errors.Is(err, io.EOF) { ... }

errors.Is 沿错误链(通过 Unwrap() error)逐层查找,链上任意一层等于目标就返回 true。标准库错误类型都实现了 Unwrap

1
2
3
err := fmt.Errorf("wrap: %w", io.EOF)
fmt.Println(errors.Is(err, io.EOF)) // true
fmt.Println(err == io.EOF) // false,err 不是 io.EOF 本身

自定义错误类型也可实现 Is(target error) bool 定制匹配逻辑。

errors.As:类型断言式提取

当错误是携带额外信息的自定义类型(如 *fs.PathErrorPath),要拿这些信息需做类型断言且要穿过包装链,这就是 errors.As 的用途:

1
2
3
4
5
6
7
_, err := os.Open("/不存在的文件.txt")

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("路径:", pathErr.Path)
fmt.Println("系统调用:", pathErr.Syscall)
}

errors.As(err, &target) 沿错误链找第一个能赋值给 target 类型指针的错误。它比 if pe, ok := err.(*fs.PathError); ok 强在能穿透 %w 包装层。

陷阱:第二个参数必须是指向错误类型指针的指针&target),目标类型须实现 error 接口。漏 & 会运行时 panic。

自定义错误类型

哨兵值不够、需携带丰富上下文时,定义自己的错误类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("字段 %s 校验失败: %s", e.Field, e.Message)
}

func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Message: "超出合理范围"}
}
return nil
}

err := validateAge(-1)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println(ve.Field, ve.Message) // age 超出合理范围
}

设计建议:类型名以 Error 结尾、构造函数以 New 开头;用指针接收者实现 Error() 便于 errors.As 匹配且避免拷贝;不要过度设计继承层级,组合优于继承。

错误处理最佳实践

  • 错误要带上下文:每层加"我是谁、关键参数",不重复底层已说过的话。
  • 不要只 return err:至少 fmt.Errorf("doX: %w", err)
  • 错误只处理一次:要么记日志,要么返回,不要既 log 又 return。
  • 区分预期错误和 bug:文件不存在用 error;数组越界是 bug 应 panic。
  • 不要忽略错误_ = doSomething() 是代码异味,除非有明确理由。
  • 错误命名:哨兵用 ErrXxx,类型用 XxxError

横向对比:Java / Rust / TypeScript

语言错误机制核心思路优势痛点
Goerror 返回值 + %w 包装错误是值,调用方当场处理控制流显式、无隐式跳转if err != nil 冗长
Javatry/catch/finally + 受检异常异常沿调用栈传播,编译器强制声明强制处理、类型化异常层级臃肿、catch (Exception) 滥用
RustResult<T, E> + ? 运算符错误是值,? 早返回零成本、类型安全、? 极简洁错误类型设计繁琐
TS/JSthrow + try/catch,或 Promise reject异常是动态的,任何函数都可能 throw灵活不看源码不知道会 throw 什么

Go 的 error 最接近 Rust 的 Result,但缺少 ? 短路语法(社区提案被否决,理由是保持显式)。与 Java 受检异常相比,Go 无编译器强制,靠社区约定和 linter(errcheck)兜底;与 TS 的 throw 相比,Go 的错误是静态可见的–扫一眼函数签名就知道它会不会出错。

panic 与 recover:不可恢复的最后防线

panic/recover 定位与 Java 的 throw 完全不同–panic 是给"不该发生的事"准备的,不是正常控制流

panic 的触发

panic 由显式调用 panic(x) 触发,或运行时检测到致命错误(数组越界、空指针解引用、除以零、向已关闭 channel 发送、并发读写 map 等)。

1
2
3
4
5
6
7
8
9
func boom() {
panic("boom") // 程序立即停止当前函数的执行
}

boom()
// panic: boom
// goroutine 1 [running]:
// ...
// exit status 2

panic 后当前函数停止执行,但会依次执行已注册的 defer,然后栈展开到调用方,一路向上直到 main 退出、程序崩溃(除非被 recover 捕获)。

recover 与 defer

recover 只在 defer 函数里直接调用才有效,用于止住 panic 把它变成普通 error 返回值:

1
2
3
4
5
6
7
8
9
10
11
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // b=0 时会 panic
}

v, err := safeDiv(1, 0)
fmt.Println(v, err) // 0 recovered: runtime error: integer divide by zero

要点:recover 必须直接放在 defer 函数体里;只能止住当前 goroutine 的 panic;recover 后函数正常返回(零值或命名返回值),调用方感知不到发生了 panic。

何时该用 panic

panic 用于真正不可恢复的情况

  • 程序初始化失败:配置解析不了、数据库连不上、依赖加载失败。库提供 MustXxx(如 regexp.MustCompiletemplate.Must),启动期错误直接 panic。
  • 违反不变量:内部状态被破坏,理论上"不可能"走到这里。
  • 第三方库的 bug:本该返回 error 的库却 panic,在最外层 defer recover 兜底防止整个服务挂掉。

不该用 panic:业务错误用 error 返回;用 panic 替代 return err 让控制流不可追踪;库函数里 panic 给调用方(除非文档说明,如 Must 系列)。

陷阱:HTTP/RPC handler 这类入口函数建议在最外层 defer recover(),避免一个请求的 panic 拖垮进程。recover 后要记日志,否则问题被悄悄吞掉。

panic 的代价与横向对比

panic 涉及栈展开、堆栈构造、defer 链,开销远大于 return err,且让控制流变隐式:阅读时不知道某个调用会不会被 recover,调试成本高。

语言对应机制定位
Gopanic/recover不可恢复错误,非控制流
JavaRuntimeException(非受检异常)程序员错误,可被 catch
Rustpanic!/catch_unwind不可恢复,默认中止线程
TSthrow/try/catch既做异常又做控制流

Go 的 panic 最像 Rust 的 panic!–都是"理论上不该发生"的兜底,而非日常错误处理通道。

Goroutine:Go 的并发原语

如果说 error 是 Go 的工程纪律,goroutine 就是 Go 的杀手锏,让"并发"从一件需要小心权衡的事变成几乎免费的事。

go 关键字

在任意函数调用前加 go,就启动了一个 goroutine:

1
2
3
4
go f(x, y, z)        // 以新 goroutine 执行 f
go func() { // 常见:匿名函数
fmt.Println("hi")
}()

go 语句立即返回,被调用函数在新的 goroutine 里异步执行,主程序不等它。这也带来一个经典坑:主程序退出时 goroutine 还没跑完,后面会讲怎么用 sync.WaitGroup 或 channel 同步。

GMP 调度模型简述

Go 运行时自带用户态调度器,用 GMP 模型:

  • G(Goroutine):每个 go 语句创建一个 G,含自己的栈和指令指针。
  • M(Machine):操作系统线程,执行代码的载体。
  • P(Processor):逻辑处理器,持有可运行 G 的本地队列,数量由 GOMAXPROCS 决定(默认 CPU 核数)。

核心是多个 G 复用到少量 M 上,M 数量受 P 限制:M 必须绑定 P 才能执行 G;某 G 系统调用阻塞时,M 和 G 一起挂起,P 转移到另一个 M 上继续跑其他 G,避免一个阻塞拖慢整体。

好处:创建 goroutine 极便宜;Go 1.14 起加入基于信号的异步抢占,死循环里的 goroutine 也能被调度走;GOMAXPROCS 控制的是"同时执行 G 的 M 上限",而非 goroutine 总数。

轻量级:2KB 初始栈

每个 goroutine 启动时只分配 2KB 栈(OS 线程通常 1~8MB),且可增长:不够用时运行时分配两倍大的新栈并拷贝过去。因此可轻松启动几十万个 goroutine,而同样数量的线程会让内存爆掉。

1
2
3
4
5
6
// 启动 100 万个 goroutine
for i := 0; i < 1_000_000; i++ {
go func(i int) {
// 做点轻量的事
}(i)
}

提示:不要在 goroutine 里持有超大局部变量(特别是大数组)–栈拷贝代价随大小上升。大对象用 make 在堆上分配。

与 OS 线程、其他语言协程对比

维度Go goroutineOS 线程 (pthread)Java 虚拟线程 (Loom)Kotlin/Python 协程
创建成本极低(2KB)高(1~8MB 栈 + 内核态)
调度方Go runtime(用户态)内核JVM语言运行时/事件循环
抢占异步抢占(1.14+)内核抢占JVM 抢占协作式(需 suspend/await
编程模型同步阻塞写法同步阻塞同步阻塞写法异步(async/await 染色)
颜色有(async 函数传染)

Go goroutine 最大的胜利是**“无色”**:不需 async/await,不让调用方变异步。阻塞式 db.Query() 只阻塞这个 goroutine,调度器自动让出 M 去跑别的 G。这是 Go 相对 Node.js / Python asyncio / Kotlin 协程最舒服的地方–写并发和写顺序代码几乎一样

Channel:CSP 的通信通道

Goroutine 本身只是"能并发跑",要协作就要靠 channel–Go 对 CSP 思想的实现:通过传递消息来同步,而不是共享变量加锁

创建与基本操作

1
2
3
4
5
6
ch := make(chan int) // 无缓冲 channel
ch := make(chan int, 3) // 有缓冲,容量 3

ch <- 42 // 发送
v := <-ch // 接收
v, ok := <-ch // 接收,ok=false 表示 channel 已关闭且空

channel 是引用类型make 返回指向 runtime hchan 的指针,传给函数时传的是同一个 channel,不需取地址。

参见make 对 slice/map/channel 的完整语义与陷阱见 杂项:make 与 select

无缓冲 vs 有缓冲:同步与异步

无缓冲 channelmake(chan T)):发送和接收必须同时就绪,发送方阻塞到有接收方、接收方阻塞到有发送方,本质是同步握手

1
2
3
4
5
6
7
ch := make(chan int)
go func() {
ch <- 1 // 阻塞,直到 main 接收
fmt.Println("已发送")
}()
v := <-ch // 接收,解除发送方阻塞
fmt.Println("收到", v)

有缓冲 channelmake(chan T, n)):缓冲区满前发送不阻塞、空前接收不阻塞,本质是异步队列

1
2
3
4
5
6
ch := make(chan int, 2)
ch <- 1 // 不阻塞,缓冲区还有 1 个位置
ch <- 2 // 不阻塞,缓冲区满了
// ch <- 3 // 会阻塞
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
特性无缓冲有缓冲
同步关系强同步(发送方等接收方)弱同步(缓冲满才等)
典型用途信号、握手、保证顺序解耦生产/消费速率
容量0n

经典初学陷阱:在主 goroutine 里向无缓冲 channel 发送却无别的 goroutine 接收–死锁。下面"陷阱"一节会展开。

close 与 close 后的行为

close(ch) 表示"不会再有数据发进来"。关闭后:发送会 panicsend on closed channel);接收会继续(缓冲区剩余数据可正常接收,空后立即返回零值);v, ok := <-chokfalse 表示已关闭且空,是判断结束的标准方式。

1
2
3
4
5
6
7
8
ch := make(chan int, 3)
ch <- 1
ch <- 2
close(ch)

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
v, ok := <-ch // 0, false -- 已关闭且空

原则:channel 应由发送方关闭,绝不由接收方关闭。接收方关闭可能撞上发送方继续发导致 panic。多发送方场景用额外"退出信号" channel 协调,而非直接 close 数据 channel。

range 遍历 channel

for v := range ch 不断从 channel 接收,直到 被关闭为止,是消费 channel 最优雅的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // 发送完关闭,让 range 能结束
for _, n := range nums {
out <- n
}
}()
return out
}

for v := range gen(1, 2, 3) {
fmt.Println(v) // 1, 2, 3,然后自动退出
}

忘记 close(out)range 会永远阻塞–这是 goroutine 泄漏的常见原因。

channel 的方向性

可声明只发送chan<-)或只接收<-chan)的 channel 类型,在类型层面约束使用方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func counter(out chan<- int) { // 只能发送
for i := 0; i < 3; i++ {
out <- i
}
close(out)
}

func printer(in <-chan int) { // 只能接收
for v := range in {
fmt.Println(v)
}
}

ch := make(chan int)
go counter(ch)
printer(ch)

双向 channel 可隐式转成单向,反之不行。这是一种文档化约束:函数签名直接告诉调用方"我只发不收"或"我只收不发"。

channel 底层结构简述

runtime 里 channel 是 hchan 结构,包含:环形缓冲区(ring buffer)存缓冲数据;两个等待队列 sendq/recvq 存阻塞的发送方/接收方 goroutine;一把互斥锁保护内部状态。

发送时:缓冲没满就写入返回,满了挂到 sendq。接收时:缓冲不空就读出返回,空了看 sendq 有没有等着的发送方–若有(只可能发生在无缓冲 channel),直接从发送方手里"交接"数据。这种直接交接让无缓冲 channel 不会多一次拷贝。

select:多路复用

select 用于多 channel 多路复用:哪个 case 先就绪就执行哪个,多 case 同时就绪则随机选一个。完整语义、运行时实现(selectgo)、模式与陷阱汇总见 杂项:make 与 select

并发安全:sync 包与 -race 检测器

channel 解决了"通信"问题,但有些场景需要共享可变状态(如计数器、缓存 map),这时用 sync 包的同步原语。

数据竞争:并发的头号杀手

数据竞争(data race)指两个或以上 goroutine 同时访问同一变量,且至少一个是写操作、且没有同步关系,结果是未定义行为,运行结果不可预测:

1
2
3
4
5
6
var count int
for i := 0; i < 1000; i++ {
go func() { count++ }() // 数据竞争!
}
time.Sleep(time.Second)
fmt.Println(count) // 不是 1000,是个随机数

count++ 不是原子操作(分读、加、写三步),多个 goroutine 交错执行会丢失更新。数据竞争是严重 bug,可能导致内存损坏、诡异崩溃。

sync.Mutex:互斥锁

最直接的修法是加锁:

1
2
3
4
5
6
7
8
9
10
var (
mu sync.Mutex
count int
)

func increment() {
mu.Lock()
defer mu.Unlock()
count++
}

sync.Mutex 是不可重入互斥锁。要点:Lock 后必须 Unlock(用 defer 最稳);同一 goroutine 二次 Lock 会死锁;零值可用;不要拷贝(传指针或放进结构体传结构体指针),go vet 能检测这类拷贝。

sync.RWMutex:读写锁

读多写少时,读写锁让多个读并发进行,只在写时独占:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var (
rw sync.RWMutex
cache = map[string]string{}
)

func get(key string) string {
rw.RLock()
defer rw.RUnlock()
return cache[key]
}

func set(key, val string) {
rw.Lock()
defer rw.Unlock()
cache[key] = val
}

RLock 允许多个读者同时持有;有 Lock(写)在等时,新的 RLock 会被阻塞以避免写饥饿。RWMutex 不是免费的,比 Mutex 多一次原子操作和状态管理,读临界区短或写频繁时反而更慢。先用 Mutex,profile 发现读瓶颈再换 RWMutex

sync.WaitGroup:等一组 goroutine 完成

最常用的同步工具,用来等一组 goroutine 跑完:

1
2
3
4
5
6
7
8
9
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait() // 阻塞到所有 Done

要点:Add 要在启动 goroutine 之前调用(放进 goroutine 内部可能导致 Wait 提前执行);DonedeferAdd 参数可正可负,wg.Add(-1) 等价于 Done;WaitGroup 不可拷贝,传函数用指针。

Go 1.20 起 WaitGroup.Go(fn) 更简洁,自动 Add(1)defer Done()

1
2
3
4
5
6
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
i := i
wg.Go(func() { fmt.Println("worker", i) })
}
wg.Wait()

-race 检测器

Go 内置竞态检测器,编译时加 -race 启用:

1
2
go test -race ./...
go build -race -o myapp

运行时插桩,发现数据竞争时打印详细栈信息。强烈建议 CI 里跑 go test -race,能抓出绝大多数竞争。代价是性能下降 5~10 倍、内存增加,一般不用于生产。

提示-race 基于 ThreadSanitizer 的动态检测,只能发现"实际执行到的"竞争,需搭配高覆盖率测试才有效。

sync.Once 与 atomic 简介

sync.Once 保证一段代码只执行一次,常用于单例初始化:

1
2
3
4
5
6
7
8
9
10
11
var (
once sync.Once
conn *sql.DB
)

func getDB() *sql.DB {
once.Do(func() {
conn, _ = sql.Open("mysql", dsn)
})
return conn
}

sync/atomic 包提供原子操作,适合简单的计数器、标志位:

1
2
3
var count int64
atomic.AddInt64(&count, 1)
n := atomic.LoadInt64(&count)

"一个计数器"场景,atomicMutex 更轻更快;涉及多变量的复合操作还是要用锁。

context:跨 goroutine 的控制

context 是 Go 并发里协调多个 goroutine 的标准答案,解决核心问题:启动了一堆 goroutine,怎么在超时或取消时让它们都停下来?

context 的角色

context.Context 是接口,携带三类信息:取消信号Done() <-chan struct{},父取消则子 context 也取消);截止时间Deadline());键值对Value(key))。核心是取消信号沿调用树向下传播:父取消,所有子孙都取消。

Background 与 TODO

  • context.Background():根 context,永不取消,用于 main 顶层、初始化、测试。
  • context.TODO():占位符,表示"还没想好用哪个 context",用在重构过程中。

两者都是空 context,区别只在语义表达。

WithCancel:手动取消

1
2
3
4
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
// ... 想停时
cancel() // ctx.Done() 立即关闭

worker 内部要监听 ctx.Done()

1
2
3
4
5
6
7
8
9
10
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return // 收到取消
case v := <-jobs:
process(v)
}
}
}

铁律cancel 一定要调用,否则泄漏 context 关联资源,习惯上写 defer cancel()

WithTimeout / WithDeadline

WithTimeout 在指定时长后自动取消;WithDeadline 在指定时刻取消,前者是后者的语法糖:

1
2
3
4
5
6
7
8
9
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()

select {
case res := <-doSlow():
fmt.Println(res)
case <-ctx.Done():
fmt.Println("超时:", ctx.Err()) // context deadline exceeded
}

HTTP 服务里把 r.Context() 透传给下游调用,能让请求被客户端断开时整个调用链自动取消–这是 Go HTTP 服务优雅处理客户端断连的关键。

WithValue:请求作用域的值

1
2
ctx = context.WithValue(ctx, userIDKey{}, 42)
uid := ctx.Value(userIDKey{}).(int)

WithValue 在调用链里传递请求级别数据(如 traceID、用户身份)。注意:key 用自定义类型(type userIDKey struct{})避免碰撞;只放请求作用域数据;不要用它做参数传递的"捷径",会让函数签名失去信息。

预告:深入 context

context 内部涉及 propagation、cancellation tree、timer 管理,第 9 篇会结合 http.Serverdatabase/sql 的超时控制展开。主线:函数第一个参数尽量是 ctx context.Context,让取消信号贯穿调用链。

CSP 模型哲学:通信共享内存

回到 Go 并发的总纲。Rob Pike 有句名言:

不要通过共享内存来通信,而要通过通信来共享内存。
(Don’t communicate by sharing memory; share memory by communicating.)

意思是:与其让多个 goroutine 各持同一块数据的指针、用锁保护(共享内存通信),不如把数据通过 channel 从一个 goroutine 传给另一个,让"拥有数据"的 goroutine 在任一时刻只有一个(通信共享内存),同步责任交给 channel 而非手写的锁。

两种风格

共享内存 + 锁

1
2
3
4
5
6
7
8
var (
mu sync.Mutex
cache = map[string]string{}
)
func get(k string) string {
mu.Lock(); defer mu.Unlock()
return cache[k]
}

通信 + channel(cache 放进一个 goroutine,所有访问通过 channel 序列化):

1
2
3
4
5
6
7
8
9
10
11
type getReq struct{ key string; resp chan string }
cacheCh := make(chan getReq)
go func() {
cache := map[string]string{}
for req := range cacheCh {
req.resp <- cache[req.key]
}
}()
resp := make(chan string)
cacheCh <- getReq{"foo", resp}
fmt.Println(<-resp)

第二种写法更长,但只有一个 goroutine 能碰 cache,不需锁、无数据竞争、心智负担低。逻辑复杂时这种"actor 风格"更不易出错。

channel vs 锁的取舍

Go 社区不主张"channel 万能":channel 擅长 goroutine 间数据传递、编排、信号、所有权转移;锁擅长保护简单的共享变量、缓存、计数器。工程中两者常搭配:channel 协调 goroutine 生命周期,sync.Mutex 保护临界区。标准是哪个让代码更清晰,而非教条。

经典并发模式

Worker Pool:固定数量 worker 从 jobs channel 取任务:

1
2
3
4
5
6
7
8
9
10
11
func runPool(jobs <-chan int, results chan<- int, n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Go(func() {
for j := range jobs {
results <- j * 2
}
})
}
go func() { wg.Wait(); close(results) }()
}

Pipeline:每个阶段一个 goroutine,用 channel 串起来:

1
2
3
gen := func(nums ...int) <-chan int { /* ... */ }
sq := func(in <-chan int) <-chan int { /* 平方 */ }
out := sq(gen(1, 2, 3))

Fan-out / Fan-in:多个 worker 读同一输入 channel(fan-out),再合并到一个输出 channel(fan-in):

1
2
3
4
5
6
7
// fan-out:启动多个 stage
outs := []<-chan int{}
for i := 0; i < 3; i++ {
outs = append(outs, stage(in))
}
// fan-in:合并
merged := merge(outs...)

这些模式的共同点是通过 channel 把"并发"封装在边界之内,对外呈现为普通函数调用,调用方无需关心内部有几个 goroutine。

泛型:Go 1.18+ 的类型参数

Go 在 1.18(2022 年)引入泛型,这是语言历史上最大的一次改动。此前 Go 社区靠 interface{} + 类型断言、代码生成器凑合了十多年,泛型让一类"写起来重复、又容易出错"的代码得到统一。

类型参数语法

泛型的核心是类型参数,用方括号声明:

1
2
3
4
5
6
7
8
9
func MapKeys[K comparable, V any](m map[K]V) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}

keys := MapKeys(map[string]int{"a": 1, "b": 2}) // []string

[K comparable, V any] 声明两个类型参数:K 须满足 comparable(可用 ==/!= 比较,map key 必须 comparable);V 为任意类型(anyinterface{} 别名)。调用时通常可省略类型实参,靠类型推导

约束:comparable / any / 约束集

约束本质是一个接口,描述类型必须具备的能力。Go 内置 any(无约束)和 comparable(支持 ==/!=)。自定义约束就是写接口,Go 1.18 起允许在接口里写类型集(type set):

1
2
3
4
5
6
7
8
9
10
11
type Number interface {
int | int64 | float64 // 只允许这几种类型
}

func Sum[T Number](xs []T) T {
var s T
for _, x := range xs {
s += x
}
return s
}

int | int64 | float64类型联合(union),表示"T 必须是这三者之一"。关键区别:普通接口描述"方法集",泛型约束可描述"类型集"。

union 与 ~ 近似元素

~T 表示"底层类型是 T 的所有类型",含 T 本身和基于 T 定义的命名类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Celsius float64
type Fahrenheit float64

type Float interface {
~float64 | ~float32
}

func Avg[T Float](xs []T) T {
var s T
for _, x := range xs {
s += x
}
return s / T(len(xs))
}

// 既能传 []float64,也能传 []Celsius
Avg([]Celsius{10, 20, 30})

没有 ~[]Celsius 就不满足 float64(因为 Celsius 是新类型),泛型函数调不了。~ 让约束更宽容,是处理"基于基础类型定义的新类型"的必备工具。

约束可以组合:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Stringer interface {
String() string
}

type ComparableStringer interface {
comparable
String() string
}

func Max[T ComparableStringer](a, b T) T {
if a.String() > b.String() {
return a
}
return b
}

标准库 cmp 包提供了 Ordered 约束,涵盖所有支持 < 的类型:

1
2
3
4
5
6
7
8
import "cmp"

func Max[T cmp.Ordered](a, b T) T {
if a > b {
return a
}
return b
}

泛型函数 / 结构体 / 方法

泛型函数上面已演示。泛型结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Stack[T any] struct {
elements []T
}

func (s *Stack[T]) Push(v T) {
s.elements = append(s.elements, v)
}

func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.elements) == 0 {
return zero, false
}
v := s.elements[len(s.elements)-1]
s.elements = s.elements[:len(s.elements)-1]
return v, true
}

s := &Stack[int]{}
s.Push(1)
v, _ := s.Pop()

方法接收者也要带 [T]限制:泛型类型的方法不能引入新的类型参数,即方法上不能再加 [U any]

1
2
// 不允许:
func (s *Stack[T]) Map[U any](f func(T) U) []U { ... }

需要"方法级泛型"时写成普通泛型函数:func MapStack[T any, U any](s *Stack[T], f func(T) U) []U { ... }。这是刻意取舍:保持方法集简单,避免 C++ 成员模板的复杂度。

标准库 slices / maps

Go 1.21 起标准库提供 slicesmaps 包,覆盖了过去每个项目都要手写的工具函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
import (
"slices"
"maps"
)

xs := []int{3, 1, 2}
slices.Sort(xs)
i := slices.Index(xs, 2)
cloned := slices.Clone(xs)

m := map[string]int{"a": 1}
m2 := maps.Clone(m)
keys := slices.Collect(maps.Keys(m))

优先用标准库,而非自己造泛型轮子。

何时该用泛型

泛型不是银弹,Go 团队强调先写具体类型,看到重复再抽象。判断经验:三个以上几乎相同、只是类型不同的函数考虑泛型;容器(Stack、Queue、Set、Tree)和通用算法(Sort、Filter、Map、Reduce)天然适合;只有一两个特例别上泛型。

过度使用的症状:为"通用"塞进没人会用的约束,函数签名比实现还长;把"刚好两个类型不同"的函数强行泛型化,调用方还要写一堆类型实参;用泛型实现"配置对象"把本该分开的逻辑糅在一起。

提示:Go 泛型的目标是"让常见的重复代码可以抽象",而非追求图灵完备的元编程,有意没有 C++ 模板的特化、SFINAE 等机制。

与 Java / C++ / Rust 泛型对比

维度GoJavaC++Rust
实现方式单态化(部分)+ 字典类型擦除单态化(模板)单态化
运行时类型信息保留(部分)擦除保留保留
约束机制类型集接口extends 边界概念/模板参数trait bound
特化不支持不支持支持不直接支持
复杂度极高
编译速度影响

Go 泛型最显著的特点是克制:刻意比 C++/Rust 简单,没有特化、高阶类型、const generic,代价是某些场景表达能力不足(如无法写通用"数值运算"函数同时覆盖 intMatrix)。与 Java 类型擦除不同,Go 运行时保留类型信息(通过 GC shape 和字典),但也不做完全单态化–相同 GC shape 共享一份代码,靠字典传递类型信息,在代码体积和性能间取平衡。

实战与陷阱

把前面这些拼到真实场景里,最容易踩的坑集中在几个地方,"面试 + 生产"双高频。

Goroutine 泄漏

泄漏指 goroutine 启动后永远阻塞无法退出,占用栈和内存。常见来源:

忘记关闭 channel 导致 range 永久阻塞

1
2
3
4
5
6
7
8
9
10
// 泄漏:gen 里的 goroutine 永远不退出
func leak() {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i // 如果没人接收,这里永远阻塞
}
}()
<-ch // 只接收一个就返回,goroutine 卡在第二次发送
}

修复:用 context 控制退出,或用缓冲 + 退出信号。

select 没有 done 分支

1
2
3
4
5
6
7
8
// 泄漏:外层返回后,worker 还在等 jobs
func leakyWorker(jobs <-chan int) {
go func() {
for v := range jobs { // jobs 永不关闭,永远阻塞
_ = v
}
}()
}

修复:让 worker 监听 ctx.Done(),或确保 jobs 最终被 close。排查:用 runtime.NumGoroutine() 监控数量,或用 pprof 的 goroutine profile(/debug/pprof/goroutine)看栈,数量持续上涨基本就是泄漏。

Channel 死锁

死锁是 channel 用错最常见的后果。Go runtime 能检测到所有 goroutine 都阻塞的情况,会打印堆栈并退出:

1
fatal error: all goroutines are asleep - deadlock!

典型死锁:主 goroutine 向无缓冲 channel 发送,无人接收ch := make(chan int); ch <- 1);循环等待(A 等 B、B 等 A,只要还有 goroutine 在跑就不会触发 detector,但会表现为程序挂起)。

向已关闭 channel 发送

1
2
3
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

这不是死锁而是 panic,但同样致命。多发送方场景下永远不要在接收方 close,用单独的协调机制(如 sync.WaitGroup + 退出 channel)决定何时关闭。

errgroup:带错误传播的 WaitGroup

golang.org/x/sync/errgroup 是处理"一组 goroutine 里第一个出错就全部取消"场景的标准工具,本质是 WaitGroup + context 的组合:

1
2
3
4
5
6
7
8
9
10
11
import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(context.Background())
for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url) // 第一个返回非 nil 的错误会 cancel ctx
})
}
if err := g.Wait(); err != nil {
log.Fatal(err)
}

g.Go第一个返回错误会触发 ctx 取消,其他 goroutine 能通过 ctx.Done() 感知并提前退出,是并行 IO 的标配。errgroup 还支持 SetLimit(n) 限制并发数(Go 1.18 起),避免一次启动几万个 goroutine 打爆下游:

1
2
3
4
g.SetLimit(10)
for _, url := range urls {
g.Go(func() error { return fetch(ctx, url) }) // 超过 10 会阻塞
}

泛型滥用降低可读性

泛型用得不好,会让代码比手写多份还难懂。反面教材:

1
2
3
4
5
6
7
8
// 过度抽象
type Transformable[T any, U any] interface {
Transform(func(T) U) U
}

func Apply[T any, U any, X Transformable[T, U]](x X, f func(T) U) U {
return x.Transform(f)
}

若实际只有两三种类型用到这个 Apply,不如直接写具体函数。泛型的好处是减少重复,重复不存在时泛型就是负担。另一误用是用泛型模拟继承:Go 没有继承,泛型 + 嵌入实现"基类复用"会搞出 Base[T]Derived[T] 嵌套,可读性极差。大多数情况下,接口 + 组合才是正解。

并发陷阱速查表

陷阱现象修复
数据竞争结果不确定、-race 报错用 Mutex/atomic/channel
Goroutine 泄漏NumGoroutine 持续上涨context 取消 + close channel
Channel 死锁all goroutines asleep保证有接收方/发送方,或加 default
向已关闭 channel 发送panic: send on closed channel只在发送方 close,用协调机制
WaitGroup Add 时机错Wait 提前返回Add 放在 go 之前
Mutex 拷贝死锁或 vet 报错用指针,或嵌入结构体后传指针
循环变量捕获Go 1.22 前的经典坑:所有 goroutine 拿到最后一个值Go 1.22+ 自动修复;旧版本用 i := i
defer 在循环里资源延迟释放改成函数体或显式 close

循环变量陷阱(Go 1.22 前的著名坑):

1
2
3
4
5
6
7
8
9
10
11
// Go 1.21 及之前:所有 goroutine 都打印 3
for i := 0; i < 3; i++ {
go func() { fmt.Println(i) }()
}

// Go 1.22+:每轮迭代 i 是新变量,打印 0 1 2(顺序不定)
// 旧版本修法:
for i := 0; i < 3; i++ {
i := i // 影子变量
go func() { fmt.Println(i) }()
}

Go 1.22 修复了 for 循环变量共享问题(loopvar 语义),阅读老代码时仍要警惕这个坑。

错误处理实战:分层与包装

把错误处理放到一个真实的分层服务里,看看"每一层加自己的上下文"长什么样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// repo 层
func (r *UserRepo) Find(ctx context.Context, id int) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT ...", id)
var u User
if err := row.Scan(&u.ID, &u.Name); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrNotFound)
}
return nil, fmt.Errorf("user %d scan: %w", id, err)
}
return &u, nil
}

// service 层
func (s *UserService) Get(ctx context.Context, id int) (*User, error) {
u, err := s.repo.Find(ctx, id)
if err != nil {
return nil, fmt.Errorf("UserService.Get: %w", err)
}
return u, nil
}

// handler 层
func (h *Handler) GetUser(w http.ResponseWriter, r *http.Request) {
id := parseID(r)
u, err := h.svc.Get(r.Context(), id)
if err != nil {
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", 404)
return
}
log.Printf("getUser: %v", err)
http.Error(w, "internal", 500)
return
}
json.NewEncoder(w).Encode(u)
}

细节:repo 层把 sql.ErrNoRows 翻译成 ErrNotFound 并用 %w 包,隐藏驱动细节;service 层只加名字不做翻译;handler 层用 errors.Is 判断业务错误决定状态码,其他错误记日志后返回 500;整条链路用 ctx 串起超时和取消。

这就是 Go 错误处理的"标准姿势":值传递 + 分层包装 + 顶层判断,信息完整、控制流清晰。

本篇小结

错误处理的核心是"error 是值":多返回值、%w 包装链、errors.Is/As 解包构建了显式、可追溯、无隐式跳转的错误模型,panic/recover 是兜底而非日常工具。

并发的核心是 goroutine + channel + CSP:goroutine 因 2KB 栈和 GMP 调度而廉价,channel 因同步/异步语义而灵活,select 让多路复用成为可能。共享状态用 sync 包,-race 在 CI 抓竞争,context 让取消信号沿调用树传播。箴言:通过通信共享内存,而非通过共享内存通信–但也要知道何时该用锁。

泛型是 Go 1.18 引入的类型参数机制,用 [T] 和约束接口(类型集、~、union)表达通用算法和容器。标准库 slices/maps 已覆盖大量场景,优先用标准库,设计要克制:看到重复再抽象。

下一篇进入测试与工程:单元测试、表驱动测试、benchmark、fuzz、go vet、模块管理、项目布局。并发的"协调"和错误的"分层"会在测试场景里再次出现。第 9 篇收尾。