Golang 错误处理、并发与泛型
📚 Golang 教程系列
错误处理约定、Goroutine 与 Channel 并发模型,以及泛型基础。
本篇展开 Go 的三张王牌:error 为值的错误处理、CSP 并发原语,以及 Go 1.18 泛型。
错误处理:error 是值,不是异常
Java/Python/JS 用 try/catch 抛出错误、由外层捕获;Go 把错误当作普通返回值,要求调用方当场处理。
error 是什么:一个内建接口
error 是 Go 内建接口,定义只有一行:
1 | |
任何实现了 Error() string 的类型都是 error,错误是一等公民,可放进变量、传给函数、塞进 channel、存进 map。与 Java 的 Throwable(带继承层级的类体系)不同,Go 的错误没有继承树,只有接口约定。error 习惯放多返回值的最后一个:
1 | |
if err != nil 是 Go 里最高频模式,好处是错误路径和正常路径平铺可见,没有隐式栈展开。
提示:写成
if v, err := f(); err != nil,把赋值和判断放进if初始化语句。
errors.New 与 fmt.Errorf
1 | |
errors.New 接受字符串返回 *errorString(runtime 未导出结构体,只存一句话)。fmt.Errorf 像 fmt.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 | |
一个 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.EOF、sql.ErrNoRows。有了 %w 包装后 err == io.EOF 就不准了,要用 errors.Is:
1 | |
errors.Is 沿错误链(通过 Unwrap() error)逐层查找,链上任意一层等于目标就返回 true。标准库错误类型都实现了 Unwrap。
1 | |
自定义错误类型也可实现 Is(target error) bool 定制匹配逻辑。
errors.As:类型断言式提取
当错误是携带额外信息的自定义类型(如 *fs.PathError 带 Path),要拿这些信息需做类型断言且要穿过包装链,这就是 errors.As 的用途:
1 | |
errors.As(err, &target) 沿错误链找第一个能赋值给 target 类型指针的错误。它比 if pe, ok := err.(*fs.PathError); ok 强在能穿透 %w 包装层。
陷阱:第二个参数必须是指向错误类型指针的指针(
&target),目标类型须实现error接口。漏&会运行时 panic。
自定义错误类型
哨兵值不够、需携带丰富上下文时,定义自己的错误类型:
1 | |
设计建议:类型名以 Error 结尾、构造函数以 New 开头;用指针接收者实现 Error() 便于 errors.As 匹配且避免拷贝;不要过度设计继承层级,组合优于继承。
错误处理最佳实践
- 错误要带上下文:每层加"我是谁、关键参数",不重复底层已说过的话。
- 不要只
return err:至少fmt.Errorf("doX: %w", err)。 - 错误只处理一次:要么记日志,要么返回,不要既 log 又 return。
- 区分预期错误和 bug:文件不存在用
error;数组越界是 bug 应 panic。 - 不要忽略错误:
_ = doSomething()是代码异味,除非有明确理由。 - 错误命名:哨兵用
ErrXxx,类型用XxxError。
横向对比:Java / Rust / TypeScript
| 语言 | 错误机制 | 核心思路 | 优势 | 痛点 |
|---|---|---|---|---|
| Go | error 返回值 + %w 包装 | 错误是值,调用方当场处理 | 控制流显式、无隐式跳转 | if err != nil 冗长 |
| Java | try/catch/finally + 受检异常 | 异常沿调用栈传播,编译器强制声明 | 强制处理、类型化 | 异常层级臃肿、catch (Exception) 滥用 |
| Rust | Result<T, E> + ? 运算符 | 错误是值,? 早返回 | 零成本、类型安全、? 极简洁 | 错误类型设计繁琐 |
| TS/JS | throw + 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 | |
panic 后当前函数停止执行,但会依次执行已注册的 defer,然后栈展开到调用方,一路向上直到 main 退出、程序崩溃(除非被 recover 捕获)。
recover 与 defer
recover 只在 defer 函数里直接调用才有效,用于止住 panic 把它变成普通 error 返回值:
1 | |
要点:recover 必须直接放在 defer 函数体里;只能止住当前 goroutine 的 panic;recover 后函数正常返回(零值或命名返回值),调用方感知不到发生了 panic。
何时该用 panic
panic 用于真正不可恢复的情况:
- 程序初始化失败:配置解析不了、数据库连不上、依赖加载失败。库提供
MustXxx(如regexp.MustCompile、template.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,调试成本高。
| 语言 | 对应机制 | 定位 |
|---|---|---|
| Go | panic/recover | 不可恢复错误,非控制流 |
| Java | RuntimeException(非受检异常) | 程序员错误,可被 catch |
| Rust | panic!/catch_unwind | 不可恢复,默认中止线程 |
| TS | throw/try/catch | 既做异常又做控制流 |
Go 的 panic 最像 Rust 的 panic!–都是"理论上不该发生"的兜底,而非日常错误处理通道。
Goroutine:Go 的并发原语
如果说 error 是 Go 的工程纪律,goroutine 就是 Go 的杀手锏,让"并发"从一件需要小心权衡的事变成几乎免费的事。
go 关键字
在任意函数调用前加 go,就启动了一个 goroutine:
1 | |
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 | |
提示:不要在 goroutine 里持有超大局部变量(特别是大数组)–栈拷贝代价随大小上升。大对象用
make在堆上分配。
与 OS 线程、其他语言协程对比
| 维度 | Go goroutine | OS 线程 (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 | |
channel 是引用类型,make 返回指向 runtime hchan 的指针,传给函数时传的是同一个 channel,不需取地址。
参见:
make对 slice/map/channel 的完整语义与陷阱见 杂项:make 与 select。
无缓冲 vs 有缓冲:同步与异步
无缓冲 channel(make(chan T)):发送和接收必须同时就绪,发送方阻塞到有接收方、接收方阻塞到有发送方,本质是同步握手。
1 | |
有缓冲 channel(make(chan T, n)):缓冲区满前发送不阻塞、空前接收不阻塞,本质是异步队列。
1 | |
| 特性 | 无缓冲 | 有缓冲 |
|---|---|---|
| 同步关系 | 强同步(发送方等接收方) | 弱同步(缓冲满才等) |
| 典型用途 | 信号、握手、保证顺序 | 解耦生产/消费速率 |
| 容量 | 0 | n |
经典初学陷阱:在主 goroutine 里向无缓冲 channel 发送却无别的 goroutine 接收–死锁。下面"陷阱"一节会展开。
close 与 close 后的行为
close(ch) 表示"不会再有数据发进来"。关闭后:发送会 panic(send on closed channel);接收会继续(缓冲区剩余数据可正常接收,空后立即返回零值);v, ok := <-ch 的 ok 为 false 表示已关闭且空,是判断结束的标准方式。
1 | |
原则:channel 应由发送方关闭,绝不由接收方关闭。接收方关闭可能撞上发送方继续发导致 panic。多发送方场景用额外"退出信号" channel 协调,而非直接 close 数据 channel。
range 遍历 channel
for v := range ch 不断从 channel 接收,直到 被关闭为止,是消费 channel 最优雅的写法:
1 | |
忘记 close(out),range 会永远阻塞–这是 goroutine 泄漏的常见原因。
channel 的方向性
可声明只发送(chan<-)或只接收(<-chan)的 channel 类型,在类型层面约束使用方式:
1 | |
双向 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 | |
count++ 不是原子操作(分读、加、写三步),多个 goroutine 交错执行会丢失更新。数据竞争是严重 bug,可能导致内存损坏、诡异崩溃。
sync.Mutex:互斥锁
最直接的修法是加锁:
1 | |
sync.Mutex 是不可重入互斥锁。要点:Lock 后必须 Unlock(用 defer 最稳);同一 goroutine 二次 Lock 会死锁;零值可用;不要拷贝(传指针或放进结构体传结构体指针),go vet 能检测这类拷贝。
sync.RWMutex:读写锁
读多写少时,读写锁让多个读并发进行,只在写时独占:
1 | |
RLock 允许多个读者同时持有;有 Lock(写)在等时,新的 RLock 会被阻塞以避免写饥饿。RWMutex 不是免费的,比 Mutex 多一次原子操作和状态管理,读临界区短或写频繁时反而更慢。先用 Mutex,profile 发现读瓶颈再换 RWMutex。
sync.WaitGroup:等一组 goroutine 完成
最常用的同步工具,用来等一组 goroutine 跑完:
1 | |
要点:Add 要在启动 goroutine 之前调用(放进 goroutine 内部可能导致 Wait 提前执行);Done 用 defer;Add 参数可正可负,wg.Add(-1) 等价于 Done;WaitGroup 不可拷贝,传函数用指针。
Go 1.20 起 WaitGroup.Go(fn) 更简洁,自动 Add(1) 和 defer Done():
1 | |
-race 检测器
Go 内置竞态检测器,编译时加 -race 启用:
1 | |
运行时插桩,发现数据竞争时打印详细栈信息。强烈建议 CI 里跑 go test -race,能抓出绝大多数竞争。代价是性能下降 5~10 倍、内存增加,一般不用于生产。
提示:
-race基于 ThreadSanitizer 的动态检测,只能发现"实际执行到的"竞争,需搭配高覆盖率测试才有效。
sync.Once 与 atomic 简介
sync.Once 保证一段代码只执行一次,常用于单例初始化:
1 | |
sync/atomic 包提供原子操作,适合简单的计数器、标志位:
1 | |
"一个计数器"场景,atomic 比 Mutex 更轻更快;涉及多变量的复合操作还是要用锁。
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 | |
worker 内部要监听 ctx.Done():
1 | |
铁律:cancel 一定要调用,否则泄漏 context 关联资源,习惯上写 defer cancel()。
WithTimeout / WithDeadline
WithTimeout 在指定时长后自动取消;WithDeadline 在指定时刻取消,前者是后者的语法糖:
1 | |
HTTP 服务里把 r.Context() 透传给下游调用,能让请求被客户端断开时整个调用链自动取消–这是 Go HTTP 服务优雅处理客户端断连的关键。
WithValue:请求作用域的值
1 | |
WithValue 在调用链里传递请求级别数据(如 traceID、用户身份)。注意:key 用自定义类型(type userIDKey struct{})避免碰撞;只放请求作用域数据;不要用它做参数传递的"捷径",会让函数签名失去信息。
预告:深入 context
context 内部涉及 propagation、cancellation tree、timer 管理,第 9 篇会结合 http.Server、database/sql 的超时控制展开。主线:函数第一个参数尽量是 ctx context.Context,让取消信号贯穿调用链。
CSP 模型哲学:通信共享内存
回到 Go 并发的总纲。Rob Pike 有句名言:
不要通过共享内存来通信,而要通过通信来共享内存。
(Don’t communicate by sharing memory; share memory by communicating.)
意思是:与其让多个 goroutine 各持同一块数据的指针、用锁保护(共享内存通信),不如把数据通过 channel 从一个 goroutine 传给另一个,让"拥有数据"的 goroutine 在任一时刻只有一个(通信共享内存),同步责任交给 channel 而非手写的锁。
两种风格
共享内存 + 锁:
1 | |
通信 + channel(cache 放进一个 goroutine,所有访问通过 channel 序列化):
1 | |
第二种写法更长,但只有一个 goroutine 能碰 cache,不需锁、无数据竞争、心智负担低。逻辑复杂时这种"actor 风格"更不易出错。
channel vs 锁的取舍
Go 社区不主张"channel 万能":channel 擅长 goroutine 间数据传递、编排、信号、所有权转移;锁擅长保护简单的共享变量、缓存、计数器。工程中两者常搭配:channel 协调 goroutine 生命周期,sync.Mutex 保护临界区。标准是哪个让代码更清晰,而非教条。
经典并发模式
Worker Pool:固定数量 worker 从 jobs channel 取任务:
1 | |
Pipeline:每个阶段一个 goroutine,用 channel 串起来:
1 | |
Fan-out / Fan-in:多个 worker 读同一输入 channel(fan-out),再合并到一个输出 channel(fan-in):
1 | |
这些模式的共同点是通过 channel 把"并发"封装在边界之内,对外呈现为普通函数调用,调用方无需关心内部有几个 goroutine。
泛型:Go 1.18+ 的类型参数
Go 在 1.18(2022 年)引入泛型,这是语言历史上最大的一次改动。此前 Go 社区靠 interface{} + 类型断言、代码生成器凑合了十多年,泛型让一类"写起来重复、又容易出错"的代码得到统一。
类型参数语法
泛型的核心是类型参数,用方括号声明:
1 | |
[K comparable, V any] 声明两个类型参数:K 须满足 comparable(可用 ==/!= 比较,map key 必须 comparable);V 为任意类型(any 是 interface{} 别名)。调用时通常可省略类型实参,靠类型推导。
约束:comparable / any / 约束集
约束本质是一个接口,描述类型必须具备的能力。Go 内置 any(无约束)和 comparable(支持 ==/!=)。自定义约束就是写接口,Go 1.18 起允许在接口里写类型集(type set):
1 | |
int | int64 | float64 是类型联合(union),表示"T 必须是这三者之一"。关键区别:普通接口描述"方法集",泛型约束可描述"类型集"。
union 与 ~ 近似元素
~T 表示"底层类型是 T 的所有类型",含 T 本身和基于 T 定义的命名类型:
1 | |
没有 ~,[]Celsius 就不满足 float64(因为 Celsius 是新类型),泛型函数调不了。~ 让约束更宽容,是处理"基于基础类型定义的新类型"的必备工具。
约束可以组合:
1 | |
标准库 cmp 包提供了 Ordered 约束,涵盖所有支持 < 的类型:
1 | |
泛型函数 / 结构体 / 方法
泛型函数上面已演示。泛型结构体:
1 | |
方法接收者也要带 [T]。限制:泛型类型的方法不能引入新的类型参数,即方法上不能再加 [U any]:
1 | |
需要"方法级泛型"时写成普通泛型函数:func MapStack[T any, U any](s *Stack[T], f func(T) U) []U { ... }。这是刻意取舍:保持方法集简单,避免 C++ 成员模板的复杂度。
标准库 slices / maps
Go 1.21 起标准库提供 slices 和 maps 包,覆盖了过去每个项目都要手写的工具函数:
1 | |
优先用标准库,而非自己造泛型轮子。
何时该用泛型
泛型不是银弹,Go 团队强调先写具体类型,看到重复再抽象。判断经验:三个以上几乎相同、只是类型不同的函数考虑泛型;容器(Stack、Queue、Set、Tree)和通用算法(Sort、Filter、Map、Reduce)天然适合;只有一两个特例别上泛型。
过度使用的症状:为"通用"塞进没人会用的约束,函数签名比实现还长;把"刚好两个类型不同"的函数强行泛型化,调用方还要写一堆类型实参;用泛型实现"配置对象"把本该分开的逻辑糅在一起。
提示:Go 泛型的目标是"让常见的重复代码可以抽象",而非追求图灵完备的元编程,有意没有 C++ 模板的特化、SFINAE 等机制。
与 Java / C++ / Rust 泛型对比
| 维度 | Go | Java | C++ | Rust |
|---|---|---|---|---|
| 实现方式 | 单态化(部分)+ 字典 | 类型擦除 | 单态化(模板) | 单态化 |
| 运行时类型信息 | 保留(部分) | 擦除 | 保留 | 保留 |
| 约束机制 | 类型集接口 | extends 边界 | 概念/模板参数 | trait bound |
| 特化 | 不支持 | 不支持 | 支持 | 不直接支持 |
| 复杂度 | 低 | 中 | 极高 | 高 |
| 编译速度影响 | 小 | 小 | 大 | 中 |
Go 泛型最显著的特点是克制:刻意比 C++/Rust 简单,没有特化、高阶类型、const generic,代价是某些场景表达能力不足(如无法写通用"数值运算"函数同时覆盖 int 和 Matrix)。与 Java 类型擦除不同,Go 运行时保留类型信息(通过 GC shape 和字典),但也不做完全单态化–相同 GC shape 共享一份代码,靠字典传递类型信息,在代码体积和性能间取平衡。
实战与陷阱
把前面这些拼到真实场景里,最容易踩的坑集中在几个地方,"面试 + 生产"双高频。
Goroutine 泄漏
泄漏指 goroutine 启动后永远阻塞无法退出,占用栈和内存。常见来源:
忘记关闭 channel 导致 range 永久阻塞:
1 | |
修复:用 context 控制退出,或用缓冲 + 退出信号。
select 没有 done 分支:
1 | |
修复:让 worker 监听 ctx.Done(),或确保 jobs 最终被 close。排查:用 runtime.NumGoroutine() 监控数量,或用 pprof 的 goroutine profile(/debug/pprof/goroutine)看栈,数量持续上涨基本就是泄漏。
Channel 死锁
死锁是 channel 用错最常见的后果。Go runtime 能检测到所有 goroutine 都阻塞的情况,会打印堆栈并退出:
1 | |
典型死锁:主 goroutine 向无缓冲 channel 发送,无人接收(ch := make(chan int); ch <- 1);循环等待(A 等 B、B 等 A,只要还有 goroutine 在跑就不会触发 detector,但会表现为程序挂起)。
向已关闭 channel 发送:
1 | |
这不是死锁而是 panic,但同样致命。多发送方场景下永远不要在接收方 close,用单独的协调机制(如 sync.WaitGroup + 退出 channel)决定何时关闭。
errgroup:带错误传播的 WaitGroup
golang.org/x/sync/errgroup 是处理"一组 goroutine 里第一个出错就全部取消"场景的标准工具,本质是 WaitGroup + context 的组合:
1 | |
g.Go 中第一个返回错误会触发 ctx 取消,其他 goroutine 能通过 ctx.Done() 感知并提前退出,是并行 IO 的标配。errgroup 还支持 SetLimit(n) 限制并发数(Go 1.18 起),避免一次启动几万个 goroutine 打爆下游:
1 | |
泛型滥用降低可读性
泛型用得不好,会让代码比手写多份还难懂。反面教材:
1 | |
若实际只有两三种类型用到这个 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 | |
Go 1.22 修复了 for 循环变量共享问题(loopvar 语义),阅读老代码时仍要警惕这个坑。
错误处理实战:分层与包装
把错误处理放到一个真实的分层服务里,看看"每一层加自己的上下文"长什么样:
1 | |
细节: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 篇收尾。


