Go 后端面试复盘:语言、并发与服务实践 · 8. 错误处理至9. Goroutine 与 GMP 调度模型
前置:Go 系统学习,并完成一个HTTP 工程练习。本页用于面试前查漏补缺,不按章节替代语言课程。
8. 错误处理
8.1 Go 为什么把 error 设计成普通值?
error 是只有一个 Error() string 方法的接口。错误作为普通返回值,意味着失败路径显式出现在控制流和函数签名中,调用方可以检查、包装、分类和处理。
8.2 如何包装错误?
使用 %w 保留错误链:
return fmt.Errorf("load user %d: %w", id, err)增加的上下文应该回答“正在做什么、关键参数是什么”,但不要泄露密码、token 等敏感数据。
8.3 errors.Is 和 errors.As 有什么区别?
errors.Is 判断错误链中是否包含某个目标错误:
if errors.Is(err, context.DeadlineExceeded) {}errors.As 尝试从错误链中提取某种错误类型:
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {}不要依赖错误字符串做业务判断。
8.4 哨兵错误和自定义错误类型如何选?
哨兵错误适合有限、稳定、无需携带复杂数据的分类:
var ErrNotFound = errors.New("not found")自定义错误类型适合携带字段:
type ValidationError struct {
Field string
Msg string
}对外暴露的错误类型会成为 API 契约,要谨慎设计。
8.5 错误应该在哪一层记录日志?
一般不要每层都打印同一个错误,否则日志会重复。
推荐:
- 底层:包装上下文并返回。
- 业务层:决定是否转换为业务错误。
- 请求/任务边界:统一记录一次,附加 trace ID、用户 ID、方法和耗时。
如果某层已经完整处理并吞掉错误,则该层应负责记录或指标上报。
8.6 如何处理多个清理错误?
主操作和 Close 都可能失败。可根据业务使用 errors.Join 合并:
func work() (err error) {
f, err := os.Open("data")
if err != nil {
return err
}
defer func() {
err = errors.Join(err, f.Close())
}()
return process(f)
}注意:只读文件的 Close 错误通常不关键,写文件的 Flush/Close 错误可能代表数据未真正落盘。
9. Goroutine 与 GMP 调度模型
9.1 goroutine 和线程有什么区别?
| 对比项 | goroutine | OS 线程 |
|---|---|---|
| 管理者 | Go runtime | 操作系统 |
| 初始栈 | 较小,可动态增长 | 通常较大、相对固定 |
| 创建/切换成本 | 较低 | 较高 |
| 调度 | M:N 用户态调度 | 内核调度 |
| 数量 | 可创建很多,但仍有限 | 通常较少 |
goroutine 轻量的主要原因是小栈、运行时调度和批量复用线程,但每个 goroutine 仍需要栈、调度信息以及它持有的对象。
9.2 什么是 GMP 模型?
- G:goroutine,包含执行栈、状态、指令位置等。
- M:machine,代表执行 Go 代码的 OS 线程。
- P:processor,代表运行 Go 代码所需的调度资源,持有本地可运行队列等。
M 必须获得 P 才能执行 Go 代码。P 的数量通常由 GOMAXPROCS 控制。
查看图表代码
9.3 调度器如何工作?
简化流程:
- 新建 G 通常先放入当前 P 的本地队列。
- M 绑定 P,从本地队列取 G 执行。
- 本地队列为空时,可能检查全局队列、netpoll,或从其他 P 窃取部分 G。
- G 阻塞在 channel、锁或网络 I/O 时,调度其他可运行 G。
- M 因系统调用长时间阻塞时,P 可以与该 M 分离并交给其他 M。
9.4 什么是 work stealing?
某个 P 的本地运行队列为空时,会尝试从其他 P 的队列中窃取可运行 goroutine,使多个 CPU 核心保持忙碌并均衡负载。
9.5 goroutine 在哪些情况下会发生调度?
常见调度点:
- channel 发送或接收阻塞。
- 锁阻塞。
- 网络 I/O 等待。
- 系统调用。
runtime.Gosched()主动让出。- GC 协作和运行时抢占。
- 函数调用或安全点触发的异步抢占检查。
不要把具体调度时机作为业务正确性的依赖。
9.6 GOMAXPROCS 是什么?
它限制同一时刻并行执行 Go 代码所使用的 P 数量,不等于 goroutine 数量,也不直接等于整个进程能创建的线程数。
- CPU 密集任务:合理的 P 数量通常接近可用 CPU。
- I/O 密集任务:goroutine 可以远多于 P。
- 容器环境:应关注运行时是否正确识别 CPU 配额,并通过压测调整。
9.7 goroutine 栈为什么可以动态增长?
goroutine 初始栈较小。运行时发现空间不足时申请更大栈,将有效内容搬迁,并修正相关指针;栈也可能在适当时机缩小。
因此:
- 创建 goroutine 比创建大栈线程更轻量。
- 深递归仍可能消耗巨大内存甚至栈溢出。
- 不应把指针转换为整数后长期保存,因为栈移动会破坏这种不受 GC 管理的地址假设。
9.8 goroutine 泄漏是什么?
goroutine 因永远无法满足的等待条件而长期存活:
- 永远等不到数据的 channel receive。
- 无消费者时发送到 channel。
- 忘记取消 context。
- HTTP 响应体未关闭导致资源无法复用或回收。
- 后台循环没有退出机制。
- 管道某一阶段提前返回,导致上游阻塞。
排查方法:
- 查看 goroutine profile。
- 观察 goroutine 数量是否持续增长。
- 检查堆栈中大量相同阻塞位置。
- 在测试中使用超时、泄漏检测思路验证退出。
9.9 如何安全启动后台 goroutine?
至少明确四件事:
- 谁拥有它。
- 如何停止。
- 错误发给谁。
- 退出时是否需要等待。
func runWorker(ctx context.Context, jobs <-chan Job) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job, ok := <-jobs:
if !ok {
return nil
}
if err := handle(ctx, job); err != nil {
return err
}
}
}
}继续阅读
- 排错
- 排错
- 排错