Skip to content

Go 后端面试复盘:语言、并发与服务实践 · 8. 错误处理至9. Goroutine 与 GMP 调度模型 ​

返回分篇目录

前置:Go 系统学习,并完成一个HTTP 工程练习。本页用于面试前查漏补缺,不按章节替代语言课程。

8. 错误处理 ​

8.1 Go 为什么把 error 设计成普通值? ​

error 是只有一个 Error() string 方法的接口。错误作为普通返回值,意味着失败路径显式出现在控制流和函数签名中,调用方可以检查、包装、分类和处理。

8.2 如何包装错误? ​

使用 %w 保留错误链:

go
return fmt.Errorf("load user %d: %w", id, err)

增加的上下文应该回答“正在做什么、关键参数是什么”,但不要泄露密码、token 等敏感数据。

8.3 errors.Is 和 errors.As 有什么区别? ​

errors.Is 判断错误链中是否包含某个目标错误:

go
if errors.Is(err, context.DeadlineExceeded) {}

errors.As 尝试从错误链中提取某种错误类型:

go
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {}

不要依赖错误字符串做业务判断。

8.4 哨兵错误和自定义错误类型如何选? ​

哨兵错误适合有限、稳定、无需携带复杂数据的分类:

go
var ErrNotFound = errors.New("not found")

自定义错误类型适合携带字段:

go
type ValidationError struct {
	Field string
	Msg   string
}

对外暴露的错误类型会成为 API 契约,要谨慎设计。

8.5 错误应该在哪一层记录日志? ​

一般不要每层都打印同一个错误,否则日志会重复。

推荐:

  • 底层:包装上下文并返回。
  • 业务层:决定是否转换为业务错误。
  • 请求/任务边界:统一记录一次,附加 trace ID、用户 ID、方法和耗时。

如果某层已经完整处理并吞掉错误,则该层应负责记录或指标上报。

8.6 如何处理多个清理错误? ​

主操作和 Close 都可能失败。可根据业务使用 errors.Join 合并:

go
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 和线程有什么区别? ​

对比项goroutineOS 线程
管理者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 调度器如何工作? ​

简化流程:

  1. 新建 G 通常先放入当前 P 的本地队列。
  2. M 绑定 P,从本地队列取 G 执行。
  3. 本地队列为空时,可能检查全局队列、netpoll,或从其他 P 窃取部分 G。
  4. G 阻塞在 channel、锁或网络 I/O 时,调度其他可运行 G。
  5. 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? ​

至少明确四件事:

  1. 谁拥有它。
  2. 如何停止。
  3. 错误发给谁。
  4. 退出时是否需要等待。
go
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
			}
		}
	}
}

继续阅读