跳转至

02. Go 基础

二、Go 基础

0. 学 Go,先学程序里的值、状态和控制线怎么流动

如果你已经有后端经验,学 Go 最容易走偏的地方,是把它当成一门“语法不多、上手很快”的语言。这样学当然也能把接口写出来,但写到稍微复杂一点的服务时,问题会集中爆发出来。结构体到底有没有被复制,slice 改动会不会影响别处,错误一路往上返回时还剩多少上下文,请求超时以后下游 goroutine 到底会不会停,这些问题在 Go 里都不会被框架替你自动糊平。

所以 Go 基础不是在背关键字,而是在建立一种很朴素、很工程化的判断力:一个值到底被谁持有,哪些状态是共享的,错误信息有没有丢,请求控制线有没有贯穿到底层。这种判断力一旦建立起来,后面的 HTTP、数据库、RAG、Tool Calling 和流式接口都会顺很多。

拿你后面最可能做的企业知识库问答系统来说,一次上传请求背后至少会经过四段 Go 代码。入口 handler 收请求,任务对象入库,后台 worker 解析和切块,问答接口再把检索结果和模型输出流式推回前端。这里每一步都在考 Go 基础。任务对象如果被值复制,状态更新会悄悄丢失;切块缓冲如果被多个 goroutine 共享,内容会串;error 如果没有沿链路包装,你只知道“入库失败”,却不知道是 OCR 超时还是 embedding 失败;ctx 如果没有继续往下传,用户页面关掉后,底层 goroutine 还会继续跑。把语言基础放回这种连续链路里看,你就会知道自己学的不是语法,而是工程控制力。

1. 先把几个最常见的名字讲清楚

Go 里最常见的几个名词,其实都不神秘。struct 可以先理解成“把一组相关字段装进一个命名类型里”。你写 UserOrderTask,本质上都是在告诉代码:这些字段应该作为同一个对象一起出现。方法 method 则是“挂在类型上的函数”,它的作用不是炫技,而是让类型自己的行为回到类型身上。你看到 task.MarkFailed() 时,语义会比一个散落在外部的 markTaskFailed(task) 更集中。接口 interface 是“行为契约”,不是类图。它的意思不是“我是谁”,而是“我至少会做哪些事”。例如 io.Reader 只要求一个 Read 方法,这就足够把文件、网络连接、内存缓冲统一看成“可读数据源”。

指针 pointer 更应该先从语义上理解。它表示“我要继续操作同一份对象,而不是副本”。很多 Java 或 SpringBoot 背景的人第一次看 Go,会觉得显式指针像是一种底层实现细节;实际上在 Go 里,它更多是在表达意图。你用值传递时,是在说“我拿一份副本来用”;你用指针传递时,是在说“我要改原对象,或者我要共享这份状态”。这个区别如果没想明白,后面很多 bug 看起来像业务问题,其实只是对象在函数间被悄悄复制了。

还有一个特别值得早点建立的概念是零值。Go 的很多类型在“不额外初始化”的情况下就有合法含义。int 的零值是 0string 是空串,结构体的字段也都会落到各自零值上。这个设计非常适合后端工程,因为它鼓励你把类型设计成“默认就是可用的”。一个好的类型,在零值状态下要么能直接工作,要么至少行为明确,而不是一上来就必须手工调用一串初始化函数。

2. 真正要先吃透的,是值语义和指针语义

Go 的很多写法,看起来很简单,真正决定行为的却是值语义。所谓值语义,就是“赋值、传参、返回时,默认复制当前值”。这句话听起来抽象,但它几乎决定了你写业务代码时最核心的直觉。举个常见例子,你有一个 Task 结构体,里面有状态、重试次数和错误信息。你把它作为参数传给一个函数,如果函数参数是值类型,那么函数里改的其实是副本;如果参数是指针类型,改的才是原对象。

下面这个例子很值得自己跑一遍:

type Task struct {
    Status string
}

func markDoneByValue(t Task) {
    t.Status = "done"
}

func markDoneByPointer(t *Task) {
    t.Status = "done"
}

如果你用 markDoneByValue(task),函数结束后外面的 task.Status 不会变;如果你用 markDoneByPointer(&task),状态才会真正更新。这不是语法小题,而是日常工程判断。比如你在 handler 里组装一个响应对象,传进多个帮助函数处理。如果这些函数有的拿值、有的拿指针,最后就很容易出现“日志里像是改过了,但真正返回给前端的还是旧值”这种很烦人的问题。

方法接收者同样遵循这套语义。值接收者适合表达“这个方法不打算改对象自身状态”;指针接收者适合表达“这个方法会修改对象,或者对象本身不适合频繁复制”。如果同一个类型的方法一半用值接收者、一半用指针接收者,你最好追问一下:这是经过思考的语义设计,还是写着写着就乱了。很多项目里并不是业务复杂,而是类型语义不稳定,久而久之谁都不敢确信某个方法会不会改原对象。

把这个问题放到真实服务里就更直观了。比如文档解析任务 IndexJobStatusRetryCountLastError 三个字段。调度器从数据库读出任务后,调用 markRunning(job),如果这里传的是值,函数里看起来已经把状态改成了 running,但调度器手里那份对象其实没变,后面很可能又会把同一条任务当成待处理任务重复派发。很多所谓“任务状态乱了”的线上问题,根源并不复杂,只是值和指针语义没站稳。

3. slice、map 和指针为什么总被反复问

这三个概念会被高频追问,不是因为它们“经典”,而是因为它们最容易暴露你是否真正理解 Go 运行时的行为。

先说数组和 slice。数组 array 是固定长度的一整块值,而 slice 不是数组本身。slice 更像一张说明卡,里面记录了三件事:底层数组从哪里开始、当前长度是多少、容量是多少。也正因为它只是一张说明卡,slice 传参时复制的通常只是这张说明卡,而不是整块数组。于是你会得到一个非常容易踩坑的现象:不同 slice 变量看起来是两份值,但它们背后可能仍然指向同一块底层数组。

这也是为什么 append 总被追问。只要容量还够,append 可能继续写原底层数组;一旦容量不够,运行时就会分配新数组,把原数据复制过去,再让新 slice 指向新数组。也就是说,append 前后“还是不是同一块底层数据”这件事,并不是从变量名判断,而是从容量和扩容行为判断。很多线上 bug 都和这个细节有关,例如一个函数拿到 slice 后做了 append,调用方以为只是局部拼接,结果共享底层数组被改脏了;或者恰好触发扩容,调用方又误以为后续修改仍然互相可见。

map 的心智模型则完全不同。你可以先把 map 理解成“由运行时维护的键值表”,它非常好用,但也非常容易让人放松警惕。map 最重要的三个事实必须变成条件反射。第一,nil map 可以读,但不能写。第二,遍历顺序不稳定,不能拿它当有序结构。第三,也是最重要的一条,原生 map 并发写不安全。很多人知道这句话,却没有真正把它当成工程约束,结果就是开发环境没事、压测也勉强跑,线上一上量就撞上 concurrent map writes,或者更糟糕,遇到一些没有立刻炸掉但数据已经竞争损坏的情况。

指针前面已经提过,它首先是语义工具,不只是性能工具。你用指针,不只是为了“少复制一点”,更重要的是在表达“这是同一份对象”“我要改它”“它可能缺席”。比如数据库模型里某个时间字段可能为空,你会考虑用指针或可空类型来表达“没有值”;一个缓存对象要被多个 goroutine 共享更新时,你会明确意识到自己在共享可变状态,而不是在传递一份值副本。

知识库项目里很容易踩到 slice 的坑。比如你在做切块时,为了减少分配,先把一整段文本读进一个大 buffer,再不断切出 []byte 片段交给后面的 worker。如果你没有意识到这些 slice 可能还指向同一块底层数据,后面某个步骤为了清洗文本又在原 buffer 上改写内容,就会出现“前面已经生成好的 chunk 内容被后续步骤悄悄改掉”的诡异现象。看上去像检索质量不稳定,实际上只是底层数据共享出了问题。

map 的工程场景也很直接。很多人做第一版 AI 服务时,喜欢先用一个内存 map 维护会话状态、流式连接或租户级缓存。单用户测试时一切正常,一旦多个 handler 和后台 worker 同时读写,就很容易把问题带到线上。你看到的可能是一次 concurrent map writes,也可能是更难查的状态错乱。所以只要 map 进入多 goroutine 场景,就要立刻问自己:这份状态到底该用锁保护,还是干脆交给单独 goroutine 持有。

4. 方法和接口,真正难的是抽象时机

Go 的接口之所以好用,是因为它很克制。类型不用显式声明“我实现了某接口”,只要方法集满足要求,它就自然满足这个接口。这种设计让抽象更轻,也逼着你只在真正需要时再抽象。

但要把这一节真正学明白,不能只停在“接口是隐式实现”这句话上。方法本身先要讲清楚。Go 的方法本质上只是“绑定到某个类型上的函数”,真正容易让人混乱的是接收者。值接收者拿到的是一份副本,更适合不修改状态的小对象行为;指针接收者拿到的是对象本身,更适合需要修改状态、避免大对象复制、或者希望整个类型的方法集保持一致的场景。很多面试题会问“为什么 T*T 实现接口的结果不一样”,本质就在方法集这里。只要某个方法是用指针接收者实现的,通常就是 *T 满足接口,而不是 T 满足接口。比如仓储对象上常见的 func (r *Repo) Save(...) error,它就天然在表达“这个方法操作的是同一个 repo 实例的状态和资源”,这时你传 &Repo{} 才是自然用法。

把这个点写成最小代码会更直观:

type Retriever interface {
    Retrieve(query string) ([]string, error)
}

type PGRetriever struct{}

func (r *PGRetriever) Retrieve(query string) ([]string, error) {
    return nil, nil
}

// var x Retriever = PGRetriever{}  // 不满足接口
var x Retriever = &PGRetriever{} // 才满足接口

很多人明明“看见类型上有这个方法”,却还是在接口赋值上翻车,本质就是没有把方法集想清楚。

这也是为什么方法接收者不要乱混。很多初学者觉得“小方法用值接收者,大方法用指针接收者”就行,结果一段时间后发现有的地方能赋给接口,有的地方不行,方法集开始变得很绕。更稳的工程习惯通常是:如果一个类型里有任何方法需要指针接收者,那整组核心方法往往都统一用指针接收者,减少接口实现和调用时的歧义。尤其是 repository、client、service 这类对象,本来就携带连接、配置、缓存或统计状态,本身也不适合被随手复制,用指针接收者会更自然。

接口这一层最常见的坏味道,是把它当成“架构完整感”的装饰品。比如项目刚开始,就先写 UserServiceUserRepositoryPromptServiceSearchService 一堆接口,每个接口只有一个实现,只是为了看起来分层漂亮。这在 Go 里通常不划算,因为接口本身并没有形成真实边界,只是让文件数变多、调用路径变长、阅读成本上升。抽象不是越早越好,抽象应该服务于变化点。

更稳的做法通常是先写具体实现,等真正看清楚变化点再抽小接口。比如一个知识库系统里的 Retriever 很适合做接口,因为底层可能是 pgvector、外部向量服务、全文检索,甚至是测试里的 mock;但一个只在当前包里使用、未来几乎不会替换的对象,先用具体类型往往更直接。Go 社区常说“接口应定义在消费方”,背后的逻辑也是这个:谁在使用一个能力,谁最清楚自己真正需要哪几个方法。检索编排层只需要 Search(ctx, query) ([]Chunk, error),那就定义这么小的接口;它没必要被迫接受一个带十几个方法的大而全 repository 接口。

这里还有一个非常高频、也很容易在面试里被追问的坑,就是“接口里的 nil 不一定真的是 nil”。如果你有一个 var c *Client = nil,再把它赋给一个接口变量,例如 var x io.Closer = c,这时 x != nil。因为接口值里装的不是“空无一物”,而是“动态类型是 *Client,动态值是 nil”。这个细节在错误处理、工厂函数和可选依赖里都很容易踩坑。你如果把这个坑真正想明白,就会更理解为什么 Go 里很多 API 要么返回具体类型指针,要么在包边界上返回真正的 nil 接口,而不是一个“装着 nil 指针的接口”。

你也可以把方法和接口放在一起理解。方法解决的是“让类型的行为更集中”,接口解决的是“让依赖关系只围绕稳定行为,而不是具体实现”。一旦把这两个角色分开,很多设计就清楚了。方法不是为了面向对象姿势更完整,接口也不是为了让目录更多一层,它们都只是为了让代码边界更稳定。

这层判断在你读开源项目时会非常明显。Gin 没有为了“看起来完整”给每个对象都先套一层自定义接口,它更多是直接围绕 http.Handler 和自己的具体类型组织能力;而你在自己的 AI 服务里,往往更适合把真正会变化的边界抽成小接口,例如 RetrieverEmbedderLLMClient。这样你既能用 pgvector 跑第一版,也能在不动业务层的前提下换成托管检索或别的模型 provider。接口如果抽在这种真实变化点上,会让系统更稳;如果只是为了模仿分层模板,最后通常只会多一层无意义跳转。

5. Go 的错误处理,重点不是“麻烦”,而是“信息别丢”

Go 不鼓励用异常去主导普通控制流,而是要求你把失败显式返回。第一次接触时,很多人会觉得啰嗦;但做过线上系统后就会发现,这种设计有一个非常大的好处:失败路径会被强迫写在台面上。你不会轻易把“数据库超时”“数据不存在”“参数不合法”“上游主动取消”这些完全不同的失败揉成一个黑盒。

真正需要建立的是错误链意识。错误从底层往上冒时,不应该只剩一层笼统的 request failed。更成熟的写法是:底层保留原始原因,中间层补业务上下文,最外层再决定如何映射成 HTTP 状态码、日志字段和告警事件。Go 里常用的 %werrors.Iserrors.Aserrors.Join,本质上都是在帮你维护这条链。比如仓储层查不到数据时返回一个明确的 ErrDocumentNotFound,应用层可以继续包一层“load knowledge doc failed”,HTTP 层再把它转换成 404,而不是一股脑地变成 500。

这一套最好不要只靠定义去记,而要把最小闭环跑顺。fmt.Errorf("load doc %s: %w", id, err) 里的 %w,表示“我在补上下文,但不丢原始错误”;errors.Is(err, ErrDocumentNotFound) 适合判断某条错误链里有没有某个约定好的哨兵错误;errors.As(err, &rateLimitErr) 适合把错误还原成某种更具体的类型,例如供应商限流错误、带状态码的业务错误;errors.Join 适合在收尾阶段把多个失败一起保下来。只要把这四件事串起来,你就能回答很多常见面试题,例如“什么时候包装错误”“什么时候返回原始错误”“什么时候不应该只比对错误字符串”。

例如一条最小错误链可以长成这样:

var ErrDocumentNotFound = errors.New("document not found")

func loadFromRepo(id string) error {
    return ErrDocumentNotFound
}

func loadDocument(id string) error {
    if err := loadFromRepo(id); err != nil {
        return fmt.Errorf("load document %s: %w", id, err)
    }
    return nil
}

err := loadDocument("doc-1")
if errors.Is(err, ErrDocumentNotFound) {
    // 可以稳定映射成 404 或业务“未找到”
}

这里真正重要的,不是写出几行样板代码,而是保住了两层信息:一层是“哪个业务动作失败了”,另一层是“最底层到底因为什么失败”。

这里有一个很实际的判断标准。你在包内部,通常更关心保留原始错误和补上下文;到了包边界或服务边界,往往要把底层实现细节翻译成更稳定的业务错误。例如 repository 层也许知道底层是 sql.ErrNoRows,但 service 层更适合把它收敛成 ErrDocumentNotFound。这样上层业务和 HTTP 映射层不用关心你底下到底是 PostgreSQL、MySQL 还是别的存储,只需要关心“这是找不到”还是“这是系统错误”。这就是为什么错误处理不只是语法问题,它其实也是边界设计问题。

错误处理做得差,最直观的后果不是代码难看,而是排障会非常痛苦。日志里全是“调用失败”,但你不知道是超时、权限、脏数据,还是模型返回结构不合法;业务层看不到底层错误类型,就很难决定要不要重试、要不要降级、要不要提示用户重试。Go 的错误处理之所以值得认真学,不是因为它是语言特色,而是因为它直接决定一个服务的可维护性。

拿文档入库链路举个完整一点的例子。上传接口收到 PDF 后,先落对象存储,再创建任务,再由后台 worker 去做 OCR、清洗、切块和 embedding。假设这条链路最后失败了,如果日志里只有一句 index document failed,你几乎没法排。更有用的错误链会长成这样:index document failed: generate embeddings: provider timeout,或者 index document failed: parse pdf: page 17 OCR failed。前者意味着可能要重试或切 provider,后者则意味着文档本身有问题,甚至需要人工介入。错误信息一旦被压扁,后面的恢复动作就无从谈起。

面试里还很喜欢问一个细节:什么时候不该继续往上包原始错误。比较成熟的回答通常是,第一种情况是你已经跨了边界,不想把底层实现泄漏给更外层;第二种情况是原始错误信息对调用方没有决策价值,甚至会造成误导;第三种情况是你需要把多种底层错误收敛成统一业务语义,例如一律映射成“参数非法”或“资源不存在”。这不是鼓励你吞错,而是提醒你:保留现场和暴露内部实现,不是同一件事。

errors.Join 在工程里也不是摆设。比如你在关闭流式响应时,主错误是模型流读取失败,但同时 trace flush 或审计写入也失败了。这个时候把两个错误拼在一起,往往比静默丢掉其中一个更有价值。真正成熟的错误处理,从来不只是“返回了 error”,而是尽量保住问题现场。

6. 并发这一章,必须把每个名词都讲明白

很多资料一讲 Go 并发,就直接列一串词:goroutinechannelselectmutexWaitGroupatomiccontext。如果只停在列名词,这一章基本等于没讲。更稳的方式,是先把这些词在系统里各自扮演什么角色说清楚,再把常见面试追问和真实服务里的用法连起来。

先把一个总原则立住。并发不是“代码里出现了多个 goroutine”就算学会了,它真正解决的问题是:把彼此独立、经常在等待 I/O 的工作重叠起来,同时又不把共享状态搞乱。很多面试一开始就会问“并发和并行有什么区别”。更稳的回答是,并发强调的是任务组织方式,允许多件事交错推进;并行强调的是多个任务在同一时刻真的同时运行。Go 让并发写起来很方便,但真正有价值的地方,通常不是把 CPU 算满,而是把网络、磁盘、数据库、模型推理这些等待时间重叠起来。

goroutine 可以先理解成“由 Go 运行时调度的轻量执行单元”。你写 go f(),意思就是把 f 交给运行时并发执行。它比直接开操作系统线程轻很多,但“轻”不等于“无限便宜”。很多面试喜欢追问:“goroutine 很轻,是不是开越多越好?”不是。goroutine 本身虽然轻,但它背后的资源不轻。你在 goroutine 里可能会占数据库连接、占模型 provider 的并发额度、占内存 buffer、占下游 QPS。真正应该被限制的,往往不是 goroutine 这个语法动作,而是它触达的外部资源。

channel 可以理解成“有类型的通信管道”。它最适合表达“这份工作或这条消息现在该由谁接手”。比如一个 worker pool 里,主线程把任务送进 jobs channel,worker 从 channel 里取任务处理,这就是典型的通过通信转移工作所有权。面试里很常见的一个问题是:“channel 和队列有什么区别?”更准确地说,channel 当然可以承载排队,但它不只是数据容器,它同时也是同步点。发送和接收是否阻塞,会直接影响整条链路的节奏。

这里最容易被追问的是有缓冲 channel 和无缓冲 channel。无缓冲 channel 的语义不是“不能存数据”,而是“发送方和接收方必须在交接点相遇”。发送时如果没人接收,就会阻塞;接收时如果没人发送,也会阻塞。这种模型特别适合做严格交接,比如一个 goroutine 只有在另一个 goroutine 明确接手以后,当前步骤才算真正完成。有缓冲 channel 则更像一个有界队列,它允许发送方先把若干条消息放进去,再由接收方稍后消费。它最适合吸收短时间突发流量,比如上传文档后瞬间产生几十个待处理任务,worker 可能来不及立刻接,但可以先进入 buffer。

很多人一看到缓冲 channel 就会下意识觉得“更快”。这是一个很典型的面试坑。缓冲只是在买一点解耦空间,不是在消灭背压。如果下游长期比上游慢,buffer 迟早会满,发送端照样会阻塞。更重要的是,buffer 太大还可能把问题藏起来。比如 embedding worker 实际吞吐只有每秒 20 个 chunk,你却给任务 channel 一个几千的缓冲,看起来入口很顺滑,实际上只是把延迟和内存压力偷偷堆到了内存队列里,排障时还更难第一时间看出来。

下面这两行代码看起来只差一个数字,语义却差很多:

jobs := make(chan Job)      // 无缓冲:提交方和 worker 在交接点同步
jobs := make(chan Job, 64)  // 有缓冲:允许短时间积压 64 个任务

如果你做的是实时性很强、希望上游明确感知下游处理能力的任务,无缓冲往往更直接;如果你面对的是短时突发流量,但整体消费能力仍可控,有缓冲更合适。真正的判断标准不是“哪种高级”,而是你要不要显式保留背压。

channel 还有几个面试里很喜欢问的小点,也值得早点讲明白。第一,谁来 close(channel)。更稳的原则通常是“发送方关闭,接收方不关”。因为关闭的语义不是“我不用了”,而是“以后不会再有新值了”。如果由接收方去关,它往往并不知道别的发送方是否真的全部结束了。第二,向已关闭的 channel 发送会直接 panic;从已关闭的 channel 接收不会 panic,而是会继续拿到零值,并且 ok=false。第三,nil channel 会永久阻塞,这个特性在日常业务代码里不常直接使用,但在 select 里动态开关某个分支时很有用,也很常被拿来做进阶追问。

select 的作用是“同时等待多个通信事件”。你可以把它理解成 channel 世界里的 switch。最常见的用法,是一边等输入,一边等超时或取消信号。例如一个 SSE 接口既要从模型流里收数据,又要监听 ctx.Done(),这时 select 就能把“正常推进”和“及时停止”放在同一个控制点里。面试里常见的追问是“select 加上 default 有什么影响”。答案不是简单一句“不会阻塞”,而是:default 会让这个 select 变成非阻塞检查,如果你在循环里不加控制地使用它,很容易写出空转的 busy loop,把 CPU 白白打高。

sync.Mutex 是最基本的互斥锁,用来保护共享可变状态。只要多个 goroutine 会读写同一份状态,而这份状态又不是通过 channel 单向转移所有权,那你就应该优先考虑锁。sync.RWMutex 则是“读多写少”场景下的读写锁,允许多个读者并发进入,但写者进入时仍然独占。它不是默认比 Mutex 高级。很多场景下,普通互斥锁已经足够,读写锁如果用得不当,复杂度反而更高。面试里经常会问:“channel 能不能替代锁?”更稳的回答是:能,但不一定应该。假设你维护的是一个缓存,里面不只有 map,还有 lastRefreshAtversiondirty 这些必须一起保持一致的字段。这时候用一个 Mutex 把这几个字段作为一个整体保护起来,通常比起一个专门 goroutine 加若干 channel 去串行化所有操作更直接、更容易维护。

sync.Map 也常被拿来做对比题。很多人知道它“并发安全”,于是第一反应是把所有共享 map 都换成 sync.Map。这也是一个很典型的误区。官方文档明确提醒过,大多数代码仍然更适合用普通 map 配合单独的锁,因为那样类型更清楚,也更容易同时维护别的业务不变式。sync.Map 更适合两类场景:一种是键写一次、后面读很多次;另一种是不同 goroutine 主要操作各自不同的 key,冲突比较少。比如模型元数据缓存、只增不减的特征注册表,这类场景可以考虑 sync.Map;但如果你维护的是一个活跃会话表,每次更新都还要顺便刷新过期时间和统计字段,普通 map 加锁往往更稳。

sync.WaitGroup 的职责很单纯,就是“等待一组 goroutine 结束”。它适合用在程序收尾、批量任务收集这类场景,但它不负责取消,也不负责错误传播。很多人会误把 WaitGroup 当成完整并发控制方案,结果 goroutine 虽然能等回来,但谁该停、什么时候停、出错了怎么办,仍然没人管。面试里很常见的追问是:WaitGrouperrgroup 有什么区别?一个更成熟的回答是:WaitGroup 只做计数归零等待,errgroup 在此基础上补了错误传播和 context 取消,特别适合“一组子任务只要有一个失败,其他就应该尽快停下”的场景。比如你在一个问答请求里同时并发做关键词召回、向量召回和权限过滤预取,只要其中一步已经明确失败,继续让其余 goroutine 白跑就没有意义,这时 errgroup.WithContext 往往比裸 WaitGroup 更贴近业务语义。

如果你的团队已经在 Go 1.25 及以上版本,WaitGroup.Go 也值得知道。它的价值不是引入新能力,而是减少 Add / Done 这类样板代码,降低漏写 DoneAdd 时机错误的概率。不过即便用了 WaitGroup.Go,它仍然不替你处理错误和取消,这一点不会变。

atomic 适合解决非常小、非常明确的共享状态同步问题,比如递增计数器、切换一个开关、读取一个只需原子可见性的标记。它的优势是轻,代价是适用面很窄。只要共享状态稍微复杂一点,例如几个字段必须一起保持一致、一次写入和读取之间存在复杂约束,就应该回到锁或更清晰的状态模型,而不是试图用原子操作硬拼。面试里也常问“atomic 能不能代替锁”。最稳的回答通常是:单个值、不可变快照指针切换、统计计数,这类场景 atomic 很合适;但只要涉及复合不变式,例如“更新 map 的同时更新版本号和最后刷新时间”,atomic 就不够了。

context 则不是普通参数包,而是请求级控制总线。它负责把超时、取消和少量请求范围内的元数据沿调用链传下去。你可以把它理解成“这次请求什么时候该停”的官方表达方式。HTTP 客户端断开时,Request.Context() 会被取消;如果你再把这个 ctx 传给数据库、检索、模型和工具层,下游就能同步停下来。反过来,如果你只是在入口处写了一个超时时间,却没有把 ctx 继续往下传,请求虽然表面超时了,底层工作其实还在继续。

下面这个例子很适合把 selectcontext 和退出路径放在一起看:

func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return
        case job, ok := <-jobs:
            if !ok {
                return
            }
            process(job)
        }
    }
}

这段代码看起来简单,但它表达了一个很重要的工程原则:goroutine 必须有明确的退出路径。只要上游取消了、任务源关闭了,worker 就应该尽快结束,而不是继续悬在那里。

如果你想把“等待、错误、取消、并发上限”一起放进一个例子里,下面这段更贴近真实工程:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)

for _, chunk := range chunks {
    chunk := chunk
    g.Go(func() error {
        return embedAndStore(ctx, chunk)
    })
}

if err := g.Wait(); err != nil {
    return fmt.Errorf("embed chunks: %w", err)
}

这个例子很像知识库入库里的 embedding 阶段。它想表达的重点不是 errgroup 语法,而是四件事一起成立:一,有界并发,不会一下子把下游打满;二,任何一个子任务失败,都能把错误带回来;三,失败后其余任务可以尽快收到取消信号;四,主流程最终有一个明确的等待点。这种写法在工程上往往比“手动起一堆 goroutine + WaitGroup + 自己拼错误 channel”更稳。

把这些名字放回同一个项目里看,会更容易形成整体感。知识库系统上传文档后,主请求通常只负责创建任务并把任务 ID 投进 channel;若干个 worker goroutine 从 channel 取任务,解析文档、切块、调用 embedding;共享的内存限流器或缓存统计用 Mutexatomic 保护;服务关闭时,根 context 被取消,worker 通过 select 收到信号后尽快退出;问答接口做 SSE 时,另一个 goroutine 持续读取下游模型流,再把增量结果写给前端。你会发现,并发工具不是孤立出现的名词,而是在同一条链路上各自扮演不同角色。

如果还想把这些工具的选择顺序再压缩成一句话,可以这样记:要转移工作所有权,先想 channel;要保护共享可变状态,先想锁;要并发跑一组会失败、会取消的子任务,先想 errgroup;只有在单个数值、标记位或不可变快照发布这种很小的同步问题里,再优先考虑 atomic。这个顺序不一定覆盖所有情况,但足够帮你避开很多“为了炫技巧而用错工具”的面试坑。

7. 并发真正难的地方,是同步、可见性和退出条件

把这些工具名字认全以后,真正要面对的是更难的一层:状态模型。一个共享 map 到底该不该加锁,一个后台索引任务是该用 channel 投递还是直接起 goroutine,一个流式请求断开后模型调用要不要立刻取消,一个缓存命中统计值用 atomic 就够还是需要锁,这些都不是背 API 能解决的问题。

面试里非常高频的一类题,不是问你 API,而是给你一个场景,问你哪里会出问题。比如“多个 goroutine 同时往一个 map 里写会怎样”“为什么 append 到同一个 slice 会出 bug”“为什么有的代码明明偶尔能跑通,还是算 data race”。这些问题背后其实都在考同一件事:你有没有同步边界。Go memory model 之所以重要,不是因为面试爱问 happens-before,而是因为它决定了跨 goroutine 的“看见顺序”到底什么时候可靠。没有同步,就不要假设别的 goroutine 一定能按你脑海里的顺序看到状态变化。锁、channel、WaitGroup、atomic 的价值,除了“别让大家同时乱改”,更重要的是建立同步边界,让写入和读取之间真的存在时序保证。

举个最容易被低估的例子。很多人知道“map 不能并发写”,但回答常常停在这句话本身。更完整一点的理解应该是:普通 map 没有内建同步机制,多个 goroutine 同时读写,甚至写写竞争时,会破坏运行时维护的内部状态,所以结果既可能是直接 panic,也可能是更难排查的脏数据。而且这个问题不只存在于 map。共享 slice 如果被多个 goroutine 同时 append,也会因为底层数组扩容和写入交错而出现数据竞争。换句话说,并发问题的根源不是“某个容器比较特殊”,而是“共享可变状态缺少同步”。

这也是为什么 go test -race 非常值得日常使用。race detector 做的不是替你证明程序绝对正确,而是在真实执行中尽可能帮你抓出“有多个 goroutine 没有同步地访问同一份数据”这种危险信号。你知道 map 并发写不安全是一回事,能不能在开发和 CI 阶段尽早抓到问题,是另一回事。面试里如果问“你怎么排查并发问题”,更稳的回答通常是三层:先靠 -race 抓显性竞争,再结合日志和 trace 看退出路径,最后用 pprof 或 goroutine dump 看有没有堵住的 goroutine 和长期不退的调用栈。

另一类高频题是死锁和 goroutine 泄漏。很多人会把死锁理解成“程序完全不动了”,但在真实服务里,更常见的是局部卡死和局部泄漏。比如下游只读了一个结果就提前返回,上游 goroutine 还在尝试往 channel 里继续发送;又比如一个 worker 永远在等一个不会再来的值;再比如 select 里忘了监听 ctx.Done(),客户端早就断开了,底层模型流还在继续吐 token。这些场景不一定会马上把整个进程卡死,但会让 goroutine 数、内存占用、连接占用一点点爬高。Go 官方讲 pipeline 的文章专门强调过这一点:上游如果没有收到“你可以停了”的信号,就可能一直卡在发送动作上,形成资源泄漏。

你可以把下面几种情况当成并发题里的典型坏味道。第一,WaitGroup.Add 写在 goroutine 内部,而不是启动 goroutine 之前,这样主 goroutine 可能先 Wait,计数时机就乱了。第二,多个发送方共用一个 channel,却没有约定由谁在什么时候关闭,最后不是重复 close,就是没人敢 close。第三,为了“快”,一口气对一万个 chunk 启一万个 goroutine 去调 embedding 接口,结果 provider 限流、数据库连接池排队、错误风暴一起出现。第四,用 default 分支写了一个看似“灵活”的 select 循环,结果实际上变成了 CPU 空转。

这些问题放到真实工程里,会更容易形成判断。比如文档入库任务就是一个很典型的有界并发场景。上传一个大 PDF 后,系统可能会切出上千个 chunk。这里最差的写法不是“并发不够多”,而是“完全不设上限”。如果你对每个 chunk 都直接 go embed(chunk),运行时也许能扛住,但下游 embedding 服务、数据库连接池、审计日志和重试链路多半扛不住。更稳的做法通常是 worker pool、信号量,或者 errgroup.SetLimit 这种有界并发,让系统明确知道自己最多同时推进多少个外部调用。

另一个很适合面试回答的真实案例是聚合检索。比如一个问答请求进来后,你想并发做关键词检索、向量检索和权限预取。这里最容易被面试官追问的是:“为什么不用三个 goroutine 起完就等?”因为真正的问题不只是“等到都回来”,而是“谁失败了以后谁该停,结果怎么合并,取消如何传递”。如果你用 errgroup.WithContext,就能比较自然地回答:任何一步失败,都可以通过 ctx 取消其余子任务;成功结果由主流程统一汇总;并发数量也可以继续受控。这比单纯背一句“用 goroutine 提高性能”要完整得多。

SSE 流式转发则是并发教材里另一个非常好的案例。一个 goroutine 负责从模型 provider 持续读增量结果,另一个 goroutine 可能负责监听客户端断连或服务端取消,主 goroutine 负责把事件写给前端。这里最容易出问题的点不是代码会不会跑,而是退出条件能不能对齐。前端关页面以后,模型流要不要停;写响应失败后,后台读取是不是还在继续;超时到了以后,底层连接是不是被正确关闭。很多“线上偶发慢”“goroutine 数越来越高”的问题,最后都能追到这一类退出路径没有闭合。

atomic 也可以放进真实案例里理解。比如你有一份会被偶尔热更新的模型路由配置,读取频率极高,更新频率很低,而且每次更新都可以直接替换成一份新的不可变快照。这时候用 atomic.Pointeratomic.Value 发布整份配置快照,就很合理:读路径几乎不需要锁,更新时直接原子切换指针即可。但如果你准备在这份共享配置上原地修改多个字段,atomic 就不再合适了,因为读者可能看到一半新、一半旧的中间状态。这种例子特别适合回答“什么时候该用 atomic,什么时候不该用”。

很多开源项目也在用不同方式把这些原则显式化。Gin 通过请求级 Context 和 handler 链把生命周期固定在一次请求里;openai-go 的流式调用要求你及时消费并关闭流;Eino、LangGraph 这类 Agent 框架则把状态、回调和中断恢复直接建模成一等能力。本质上它们都在解决同一个问题:并发工作一旦开始,就必须有清晰的同步边界、资源边界和退出边界。你把这层主线抓稳了,面试里的并发题就不再只是背名词,而是能落到具体工程判断。

补充:把几道经典并发题串起来看

很多 Go 并发题表面上在换着花样出题,背后其实反复考的是同一件事:你有没有想清楚“谁拥有状态、谁负责同步、谁负责停止”。只要这三件事没想清楚,题目怎么换皮都会出问题。

比如 channelmutex 经常被拿来做对比题。更稳的回答不是“channel 更高级”或者“锁更高效”,而是先看你到底在解决什么问题。如果你要表达的是“工作项所有权在不同 goroutine 之间转移”,例如任务队列、worker pool、流水线阶段推进,channel 很自然;如果你要保护的是一份共享可变状态,例如缓存 map 加统计字段、会话表加过期时间、配置快照加版本号,那么 Mutex 往往更直接。很多人把锁硬换成 channel 之后,代码反而更绕,就是因为问题本来不是所有权转移,而是共享状态一致性。

WaitGrouperrgroup 也是类似。WaitGroup 只负责“等大家结束”,不负责错误传播,也不负责取消;errgroup 则把错误和 context 取消绑进来了。面试里如果被问“什么时候该用 errgroup”,最实用的回答通常是:只要一组并发任务里有任意一个失败以后,其余任务继续跑已经没有意义,例如多个检索子任务、多个下游 RPC、批量文件处理的子步骤,就更适合用 errgroup.WithContext。如果你只是单纯想等几个互不影响的 goroutine 收尾,WaitGroup 就已经够了。

close channelnil channel 也是高频追问题。close 表示“这个 channel 不会再有新值了”,通常应该由发送方、而且是最后一个发送方来做;多发送方共享一个 channel 时,如果没有先明确谁负责关闭,最后不是重复 close,就是谁也不敢关。nil channel 则更像“永远阻塞的 channel”,它在 select 里有一个很实用的用法:通过把某个分支暂时设为 nil 来动态禁用这一路通信。如果你并不知道这一点,调试时就会很困惑,觉得“为什么这个 select 分支永远走不到”。这类题真正想看的,不是你记住了一个语法细节,而是你有没有把 channel 当作有生命周期和所有权的同步对象来看。

共享 slice 上做 append 也是极容易被问的坑。很多人知道 map 不能并发写,却忘了 slice 也可能因为底层数组共享而出问题。两个 goroutine 如果拿着同一个 slice 描述符去 append,只要它们还共享底层数组,就可能出现内容互相覆盖、顺序错乱甚至 data race。这个问题特别适合拿来解释“值传递不代表没有共享状态”,因为 slice 虽然是值传递,但值里带着指向底层数组的指针。你要是真理解了这一层,面试里很多题都会突然通起来。

selectdefault 的 busy loop 也是典型。很多人写非阻塞检查时顺手就加 default,结果放进 for 里以后,循环变成了空转,CPU 飙高,日志却看不出明显错误。这个题本质上是在考你有没有意识到:default 不是“更灵活”,它意味着“即使没有任何通信事件,也立刻继续往下执行”。如果你不在循环里配合 sleep、ticker 或明确的阻塞点,它就会不停自旋。

最后,goroutine 泄漏几乎是所有并发题的落点。泄漏不一定表现成明显 panic,很多时候只是 goroutine 数慢慢涨、内存慢慢涨、连接慢慢不释放。最常见的根因无非三类:没人再接收或发送的 channel 让 goroutine 永远卡住;没有接住 ctx.Done(),上游结束了下游还在跑;或者后台循环没有退出条件。真正成熟的并发回答,最后都会落回这句话:并发不只是“怎么同时跑”,更是“怎么一起停”。

8. Go 基础最后一定要落回工程习惯

如果 Go 基础只停留在语言书,你会发现自己好像都懂一点,但一写服务还是容易变形。真正成熟的 Go 工程能力,一定会落回日常习惯。比如你会关心一个类型的零值能不能直接用,错误字符串是否适合继续包装,接口是不是定义在消费方,IDURLHTTP 这些缩写是否符合社区习惯,gofmtgo vetgo test -race、fuzzing 是否已经进入自己的工作流。

这些习惯看起来不如并发、接口、RAG 那么“显眼”,但它们恰恰决定了一个项目会不会越写越脏。你知道用户上传的文档和外部系统返回值都可能很脏,如果从不做边界测试和 fuzzing,很多问题迟早还是得等线上来教你;你知道 context 不该塞进 struct 长期持有,如果代码评审时从不对这类问题敏感,项目里迟早会出现难查的生命周期错误。

更具体一点说,Go 的工程习惯常常体现为几种很稳定的默认动作。设计类型时,先问零值能不能安全使用,只有确实需要强约束时再上构造函数;设计函数时,先把 context.Context 放在第一个参数位置,把副作用和错误路径显式露出来;设计包边界时,优先让导出的类型和函数足够少,让真正变化的能力留在清楚的接口边界上。很多 Go 项目读起来舒服,不是因为作者“更有风格”,而是因为这些默认动作很稳定,读者一眼就能看懂哪里是入口、哪里是状态、哪里是依赖边界。

工程习惯还体现在日常验证动作上。比如你一改并发逻辑,就会下意识跑 go test -race;一改解析或输入边界,就会考虑补表驱动测试甚至 fuzzing;一改包接口,就会看 go vet 和 lint 有没有提示新的问题。它们看起来像机械动作,实际上是在把“以后线上可能会炸的点”尽量往开发阶段前移。很多经验丰富的 Go 工程师并不是天生更少犯错,而是把这些检查动作做成了反射。

所以这章真正想建立的,不是“Go 有哪些语法点”,而是一套更可靠的工程判断:当你设计一个类型、传一段数据、处理一个 error、起一个 goroutine 时,你应该立刻能想到它的语义和后果。到这一步,Go 基础才算真正打牢。