先说大实话:并发是 Go 里最难、也是最有价值的一章。其他章节你照着 JS/PHP 思维就能懂,唯独并发——你得换一套脑子

你在 JS 里的并发模型是这样的:

// JS:单线程 + 事件循环
console.log(1)
setTimeout(() => console.log(3), 0)
console.log(2)
// 输出:1 2 3  (3 被放进队列,等主线程空了才执行)

JS 全程只有一个线程在跑你的代码,靠「事件循环 + 回调/Promise」制造并发的假象。CPU 密集任务(比如算一个大的斐波那契)会卡死整个程序,连 setTimeout 都不响应。

Go 完全不同。Go 有真正的多任务执行(goroutine),它们是真正并行跑的,CPU 密集任务不会卡死别人。而 goroutine 之间怎么通信、怎么协调?——靠 channel,不靠共享变量。

这一章你会觉得「这跟我学的完全不一样」,这是正常的。慢慢来,把每个例子跑一遍、把内存图画一遍,脑回路转过来后,你会觉得 Go 的并发模型其实非常优雅。


8.1 并发 vs 并行:先搞清概念

  • 并发(Concurrency):一个 CPU 核心「同时」处理多件事(通过快速切换)。Go 的 goroutine 是并发
  • 并行(Parallelism):多个 CPU 核心真正同时执行。需要多核硬件。

Go 的并发模型可以在单核上跑(并发),也可以自动利用多核(并行)。

Go 和 Node.js 的根本差异

Node.js Go
线程模型 单线程主循环 + 后台 IO 线程 多 goroutine(每条都是「轻量线程」)
CPU 密集任务 会阻塞整个事件循环 一个 goroutine 阻塞不影响其他
并发数 受限于回调复杂度 轻松起 10 万+ goroutine
通信方式 共享变量 + Promise channel 传消息(CSP 模型)

关键洞察:Node.js 因为单线程,CPU 密集任务会卡死整个程序。Go 没有这个限制。


8.2 goroutine —— 轻量级线程

启动一个 goroutine,只要在函数前加 go 关键词:

package main
import (
	"fmt"
	"time"
)
func sayHello(name string) {
	fmt.Println("Hello,", name)
}
func main() {
	go sayHello("张三") // 启动 goroutine,不阻塞
	go sayHello("李四") // 再启动一个
	fmt.Println("main 继续执行")
	// ⚠️ 问题:main 不会等 goroutine
	// time.Sleep(time.Second) // 主程序不等待会直接退出,看不到输出,使用这个阻塞一下看效果
}

关键问题:上面的程序很可能什么 goroutine 都不输出!因为 main 执行完,整个程序就退出了,不等 goroutine。

8.2.1 Go 的模型:真正的多任务并发

Go 没有"同步函数"和"异步函数"的区别,所有函数写法都一样,没有类似JS的 async/await,没有 Promise,没有回调地狱。

func fetchData() string {
    time.Sleep(time.Second) // 模拟耗时操作
    return "data"
}

这个函数看起来是"同步"的写法(顺序执行、直接返回结果),但你可以选择:

  • 直接调用result := fetchData() —— 当前 goroutine 阻塞等待,这就是同步执行
  • 用 go 启动go fetchData() —— 扔到一个新的 goroutine 里,不阻塞当前流程,这就有了"异步"的效果

同一个函数,调不调 go,决定了它是同步跑还是并发跑,而不是函数本身被定义成"异步函数"。

8.2.2 goroutine 有多轻?

Go goroutine Java 线程 OS 线程
初始栈内存 2KB 512KB~1MB 几 MB
启动开销 极小 很大
能开多少 几十万、上百万 几千 几百

所以在 Go 里「一个请求开一个 goroutine」是常规操作,你完全不用心疼。

8.2.3 GMP 模型(goroutine 调度)

提出假设:假如我们有一台8核CPU的电脑,一次性来了几十万goroutine:

这就是 Go 调度器的核心——GMP 模型,G 是 Goroutine,M 是 OS 线程,P 是逻辑处理器。

8.2.3.1 整体调度说明

首先,GOMAXPROCS 决定了 P 的数量,默认等于 CPU 核心数。在我们假设的 8 核机器上:

import (
	"fmt"
	"runtime"
)
fmt.Println(runtime.GOMAXPROCS(0)) // 默认输出 8

这意味着同一时刻最多有 8 个 goroutine 在真正并行执行(每个 P 绑定一个 M,对应一个 CPU 核心)。其他几十万 goroutine 不是真的"同时"在跑,而是排队等待被调度

调度过程拆解
  1. 几十万个 G 会先分散到各个 P 的本地队列里(本地队列满了才会丢进全局队列)
  2. 每个 P 配一个 M,M 真正在 CPU 上跑,从本地队列里一个一个取出 G 来执行
  3. 调度不是抢占式平均分配 CPU 时间片,而是协作式 + 抢占式混合
    • Go 1.14 之后支持异步抢占——如果一个 goroutine 跑太久(比如死循环),调度器会强行把它换下来,让别的 goroutine 有机会执行
    • goroutine 在执行 channel 操作、time.Sleep、I/O 等会主动让出控制权
工作窃取(Work Stealing)

如果某个 P 的本地队列空了,但其他 P 的队列里还堆着任务,这个空闲的 P 会去别的 P 队列里的一半任务过来执行,避免有的核心闲着、有的核心忙死。这是 Go 调度器效率很高的关键设计。

系统调用(syscall)阻塞怎么办

这是初学者最容易忽略的一点:如果一个 goroutine 执行了阻塞的系统调用(比如读写文件、某些网络操作),它所在的 M 会被阻塞住。这时候调度器会:

  1. 把这个 M 和它的 P 解绑
  2. 给 P 找一个新的(或者创建一个新的)M 继续干活
  3. 等阻塞的系统调用返回后,原来的 M 会想办法重新拿到一个 P 继续工作,拿不到就先把 G 放进全局队列,自己先歇着(或被回收)

这就是为什么哪怕有 goroutine 卡在阻塞调用上,其他 goroutine 也不会被"拖累"——M 的数量是可以动态增长的,不像 P 那样固定。

网络 I/O 是特例

Go 对网络 I/O 做了特殊优化,用的是 netpoller(基于 epoll/kqueue),网络相关的阻塞不会真的占用一个 M,而是注册到 netpoller 里,goroutine 直接被挂起,M 可以去跑别的 G。这也是为什么 Go 写高并发网络服务特别高效——几十万个等待网络响应的 goroutine 几乎不消耗线程资源。

一句话总结

8 核能同时跑 8 个 goroutine,剩下的几十万都在排队(本地队列 + 全局队列),调度器通过工作窃取让 8 个核心尽量都不闲着,遇到阻塞系统调用就动态加 M,遇到网络 I/O 就用 netpoller 几乎零成本挂起,所以即使开几十万 goroutine,内存和调度开销依然可控(这也是 Go 能轻松扛住高并发的根本原因)。

8.2.3.2 理解P以及它和本地队列的关系

P(Processor)是 GMP 模型里最容易让人困惑的一环,因为它既不是真实的 CPU 核心,也不是线程,而是一个调度上下文——你可以把它理解成"执行 goroutine 所需要的一套资源和权限的打包"。

P 到底是什么

P 本质上是一个结构体,里面装着:

  • 一个本地运行队列(local run queue),最多能放 256 个待运行的 G
  • 内存分配缓存(mcache,用来给 goroutine 快速分配小对象,避免每次都去抢全局锁)
  • 一些调度相关的状态信息

P 是“调度器”,不是“队列”,就像一个工头,他手里拿着一个本地任务队列(LRQ),并管理着一个干活的工人(M,即操作系统线程)。关键规则:M 必须先持有一个 P,才能执行 goroutine。 没有 P,M 就是个"光杆司令",啥也干不了。所以真正决定"同时能跑多少个 goroutine"的,不是 M 的数量,而是 P 的数量——这正是 GOMAXPROCS 控制的东西。

为什么要发明 P 这一层?

如果没有 P,直接让 M 去抢一个全局队列里的 G,会有什么问题?

  1. 全局锁竞争:8 个线程同时抢一把锁去拿任务,竞争激烈,性能差
  2. 内存分配也要抢锁:goroutine 频繁分配内存,如果所有线程共享一个内存池,锁竞争会非常严重

P 的存在就是为了把这些资源分散开,每个 P 各管一摊:

  • 各自的本地队列(取任务不用全局锁,速度快)
  • 各自的内存缓存(分配内存不用抢全局锁)

这是一种典型的"分片"思路,减少多线程间的竞争。

P 与本地队列的关系

也可以理解成:P 是"工位",本地队列是这个工位上的"待办清单"

P 0 ┌───────────────────┐
    │ 本地队列(最多256)  │ ← G 优先放这里
    │ [G1][G2][G3]...   │
    └───────────────────┘
    M 0 绑定到 P 0,从这个队列里
    一个个取出 G 来执行

具体流程:

  1. go func(){} 创建一个新 G 时,优先放入当前 P 的本地队列(如果满了,才会放入全局队列,或者随机分给别的 P)
  2. M 要执行任务时,先看自己绑定的 P 的本地队列——这是最快路径,不用加锁(用的是无锁队列设计)
  3. 本地队列空了,M 才会去:
    • 看一眼全局队列
    • 如果全局队列也没有,就去别的 P 的本地队列(工作窃取,一次偷一半)
    • 实在偷不到,就去查一下 netpoller 有没有网络 I/O 就绪的 G
M、P、G 三者的绑定关系
  • G 和 M 不是固定绑定的——一个 G 执行一半可能被换到另一个 M 上接着跑(比如系统调用阻塞后)
  • P 和 M 是临时绑定的——M 想干活就必须先拿到一个空闲的 P,干完了或者被阻塞了就可能把 P 让给别的 M
  • P 的数量基本固定(等于 GOMAXPROCS,运行时可以改但不常见),这是整个系统并行度的"天花板"
一个例子说明意义

假设你设置 GOMAXPROCS(8),就有 8 个 P。即使你开了 100 个 M(比如有很多 goroutine 在阻塞的系统调用里),同一时刻能真正执行 Go 代码的也只有 8 个,因为只有 8 个 P,剩下的 M 即使活着,没抢到 P 也只能干等着。

所以 P 的本质作用就是:把"调度的执行权"和"线程"解耦,限定真正并行执行 Go 代码的并发度,并通过本地队列+本地内存缓存减少多线程竞争。

8.2.3.3 对M的理解

M(Machine)就是真正的 OS 线程,是 Go 调度模型里唯一能让 CPU 真正执行指令的东西。G 是任务,P 是调度上下文/资源包,M 才是干活的"苦力"——最终是 M 在某个 CPU 核心上跑机器指令。

M 的本质

M 直接对应操作系统的一个线程(在 Linux 上就是用 clone() 创建出来的),是 Go runtime 通过系统调用向操作系统申请来的。它的工作流程很简单粗暴:

M 的核心循环:
1. 拿到一个 P(没有 P 就干不了活)
2. 从 P 的本地队列里取一个 G
3. 在自己(OS线程)身上执行这个 G 的代码
4. G 执行完了或者让出了,回到第2步取下一个
5. 实在没活干了,自己挂起休眠
M 的数量是动态的,不像 P

这是 M 和 P 最大的区别:

  • P 的数量基本固定(= GOMAXPROCS,决定并行度上限)
  • M 的数量会动态变化,runtime 会根据需要创建新线程,或者让闲置线程睡眠/销毁

为什么 M 要能动态增长?因为 goroutine 经常会阻塞在系统调用上(比如读写本地文件),这时候这个 M 就被卡住了,啥也干不了。如果不补充新的 M,CPU 核心就闲着浪费了。所以前面提到的:

G 阻塞在系统调用 → M 和 P 解绑 → runtime 找个空闲 M(或新建一个)接管这个 P → 继续干活

这套机制全靠"M 数量可以超过 GOMAXPROCS"才能实现。理论上 M 的数量没有硬性上限(但有个保护性的默认上限,通常是 10000,可以用 debug.SetMaxThreads 调整,超过会直接 crash,防止失控创建线程拖垂系统)。

一个容易误解的点

很多人以为 "Go 并发快" = "Go 创建的线程少",这其实只说对了一半。准确地说是:

  • M(线程)的数量会随阻塞情况自动伸缩,但真正同时跑 Go 代码的 M 数量被 P 卡死在 GOMAXPROCS
  • 所以你可能会在 top 或者 pstack 里看到一个 Go 程序起了几十个甚至上百个线程,这不是 bug,是因为有很多 goroutine 在阻塞系统调用,runtime 临时补的线程
M0 和 sysmon——两个特殊角色
  • M0:程序启动时的第一个线程,负责执行 runtime.main,初始化整个调度系统。它本质上和后续创建的 M 没什么不同,只是出生顺序特殊。
  • sysmon(系统监控线程):这是一个不需要 P 就能跑的特殊 M,独立于 GMP 调度,专门做"巡逻"工作:
    • 检测某个 G 是否跑太久占着 P 不放(触发抢占)
    • 检测是否有 G 阻塞在系统调用太久,主动把 P 收回去给别的 M
    • 触发 GC 相关的一些后台任务
再用一句话总结 M、P、G 三者关系

G 是要做的事,P 是干活需要的"许可证+工具箱",M 是真正动手干活的"劳工"。一个 M 必须先拿到 P 这张许可证,才能从 P 的本地队列里挑一个 G 来执行;M 的数量可以远超 CPU 核心数(应对阻塞),但能同时真正执行 Go 代码的 M,永远被 P 的数量(=GOMAXPROCS)卡住。

这也是为什么"开几十万 goroutine"完全不可怕——它们大多数时间只是安静地待在某个 P 的本地队列或者全局队列里排队,真正消耗 OS 线程资源的,只有当下被 8 个 P 接管、正在跑或者阻塞在系统调用上的那一小部分。


8.3 让 main 等待 goroutine:WaitGroup

sync.WaitGroup 是 Go 里最常用、也是最简单的同步工具,专门用来解决一个问题:**主程序(或某个 goroutine)需要等待一批 goroutine 全部执行完毕,才能继续往下走。**如果说 go 关键字是发令枪,那 WaitGroup 就是终点的冲刺线——它不负责通信(那是 Channel 的事),只做一件事:阻塞等待一组 Goroutine 全部完成任务

三个核心方法

  • Add(n):设置计数器,表示要等待 n 个任务完成。
  • Done():任务完成,计数器减 1(等价于 Add(-1))。
  • Wait():阻塞当前 Goroutine,直到计数器归零。

标准模板(必须熟记):

import "sync"

func main() {
    // 声明一个变量wg 类型是sync.WaitGroup
    var wg sync.WaitGroup

    for i := 1; i <= 3; i++ {
        wg.Add(1)              // 计数器 +1 (必须放在 go 语句之前)
        go func(n int) {
            // 用 defer wg.Done(),它能保证无论函数如何退出(哪怕 panic),计数器都会减 1
            defer wg.Done()    // 完成时计数 -1。
            fmt.Printf("goroutine %d 执行\n", n)
        }(i)                   // 把 i 作为参数传入(重要!)
    }

    wg.Wait()                  // 主 Goroutine 阻塞在这里,直到计数归零
    fmt.Println("所有 goroutine 完成")
}

WaitGroup 底层维护着三个核心字段(位于 sync 包):

  1. 计数器(counter):记录还剩多少任务未完成(由 AddDone 操纵)。
  2. 等待者计数(waiter):记录有多少个 Goroutine 因为调用了 Wait() 而被阻塞。
  3. 信号量(sema):用于阻塞和唤醒 Goroutine 的底层锁机制。

调度流程如下:

  1. 当主 Goroutine 调用 wg.Wait() 时,发现 counter 不为 0。
  2. 该 Goroutine 会被挂起(从 运行中 状态变为 等待 状态),并加入该 WaitGroup 的等待队列。
  3. 关键(GMP 视角):这个被挂起的 G 会让出当前的 M(操作系统线程)。M 不会傻等,它会立刻去执行 P 本地队列里的其他 Goroutine,CPU 资源被充分利用,没有任何空转浪费。
  4. 当最后一个 Worker Goroutine 调用 wg.Done() 导致 counter 归零时,WaitGroup 会释放信号量。
  5. 被挂起的主 Goroutine 被重新唤醒(变为 可运行 状态),被放入调度队列,等待重新获得 M 的执行权,继续执行 wg.Wait() 之后的代码。

⚠️ 经典陷阱:闭包捕获循环变量

// ❌ Go 1.22 之前的坑:三个 goroutine 都可能打印 3 或 4
for i := 1; i <= 3; i++ {
    go func() {
        fmt.Println(i)
    }()
}

// ✅ 传参数(兼容所有版本)
for i := 1; i <= 3; i++ {
    go func(n int) {
        fmt.Println(n)     // n 是参数,独立
    }(i)
}

为什么会有这个坑? Go 1.22 前,整个 for 循环共享同一个 i 变量。三个 goroutine 启动时,i 可能已经变成 3(循环结束时的值)。

Go 1.22+ 修复了:每次循环 i 都是独立的新变量。但传参数仍是清晰的做法(兼容旧代码)。


8.4 channel —— goroutine 之间的通信管道

Go 的并发哲学一句话:「不要通过共享内存通信,而要通过通信共享内存」

channel 就是这个通信的管道:

ch := make(chan int)        // 无缓冲 channel
ch := make(chan int, 5)     // 有缓冲 channel(容量 5)

ch <- 42                    // 发送
v := <-ch                   // 接收
close(ch)                   // 关闭

8.4.1 无缓冲 channel:同步交接

无缓冲 channel 的发送和接收必须同时就绪(像面对面交接信件):

func main() {
    ch := make(chan string)

    go func() {
        ch <- "hello"    // 发送方:阻塞,直到有人接收
        fmt.Println("发送完成")
    }()

    msg := <-ch          // 接收方:阻塞,直到有数据
    fmt.Println("收到:", msg)
}

执行过程可视化

时间线:
t1: goroutine 启动,执行到 ch <- "hello",发现没人接收 → 阻塞
t2: main 执行到 <-ch,发现有发送方 → 配对成功,数据传递
t3: goroutine 解除阻塞,打印"发送完成"
t4: main 拿到 "hello",打印"收到"

无缓冲 channel 强制「发送方和接收方握手」,是天然的同步点。

8.4.2 有缓冲 channel:异步通信

缓冲区没满时,发送不阻塞:

ch := make(chan int, 3)   // 容量 3

ch <- 1                   // 不阻塞(缓冲区:[1])
ch <- 2                   // 不阻塞(缓冲区:[1,2])
ch <- 3                   // 不阻塞(缓冲区:[1,2,3],满了)
// ch <- 4                // ❌ 阻塞!缓冲区满了,要等有人取走

v := <-ch                 // 取出 1,腾出空间(缓冲区:[2,3])
ch <- 4                   // 现在可以了

缓冲区可视化(FIFO 先进先出):

make(chan int, 3) 创建容量 3 的缓冲 channel:

[ _ | _ | _ ]  ← 空,3 个槽位
  队头(取数据的位置)

ch <- 1, ch <- 2, ch <- 3 后:
[ 1 | 2 | 3 ]  ← 满了
队头

v := <-ch 取出 1:
[ 2 | 3 | _ ]  ← 空出一个槽
队头

8.4.3 关闭 channel

close(ch)            // 关闭,不能再发送

v, ok := <-ch        // ok=false 表示 channel 已关闭且空

for v := range ch {  // 持续接收直到关闭
    use(v)
}

⚠️ 关闭规则

  • 只能关闭一次,重复关闭 panic
  • 只应由发送方关闭(接收方关闭会导致发送方 panic)
  • 向已关闭 channel 发送会 panic
  • 从已关闭 channel 接收返回零值(不 panic)

8.5 channel 的完整示例:工作池

func worker(id int, jobs <-chan int, results chan<- int) {
    for j := range jobs {        // 从 jobs 接收,直到 channel 关闭
        fmt.Printf("worker %d 处理任务 %d\n", id, j)
        results <- j * 2          // 发送结果
    }
}

func main() {
    jobs := make(chan int, 10)
    results := make(chan int, 10)

    for w := 1; w <= 3; w++ {     // 启动 3 个 worker
        go worker(w, jobs, results)
    }

    for j := 1; j <= 5; j++ {     // 派发 5 个任务
        jobs <- j
    }
    close(jobs)                   // 派完关闭,worker 的 range 会结束

    for r := 1; r <= 5; r++ {     // 收集结果
        fmt.Println("结果:", <-results)
    }
}

注意 channel 的方向标注:

  • jobs <-chan int只读
  • results chan<- int只写

方向标注让代码更安全(编译器帮你检查)。


8.6 select —— 多路复用

select 让你同时监听多个 channel,哪个先来用哪个

select {
case msg := <-ch1:
    fmt.Println("收到 ch1:", msg)
case msg := <-ch2:
    fmt.Println("收到 ch2:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("超时!")
default:
    fmt.Println("都没就绪")    // 非阻塞
}

特点

  • 多个 case 就绪时随机选一个(防饿死)
  • 没有 default 时阻塞等待
  • 有 default 时无就绪立即走 default

对比 JS:相当于 Promise.race([p1, p2, timeoutPromise]),但 Go 的版本更直观。


8.7 sync.Mutex —— 互斥锁(保护共享变量)

虽然 Go 鼓励用 channel,但有时候直接共享变量更简单。这时用 sync.Mutex 保护:

var counter int
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    for i := 0; i < 1000; i++ {
        mu.Lock()         // 加锁
        counter++
        mu.Unlock()       // 解锁
    }
}

不加锁会怎样?(竞态条件演示)

counter++ 看似一步,实际是三步:读 → 改 → 写。两个 goroutine 同时跑可能这样:

初始:counter = 0

goroutine A:读 counter → 0
                                goroutine B:读 counter → 0(也是 0!)
goroutine A:0 + 1 = 1
                                goroutine B:0 + 1 = 1(用的是旧的 0)
goroutine A:写 counter → 1
                                goroutine B:写 counter → 1

结果:counter = 1,但本来应该是 2!丢了一次更新。

这就是竞态条件(race condition)。10 个 goroutine 各加 1000 次,期望 10000,实际可能是 9xxx 这种随机的小值。

检测:运行 go run -race main.go,Go 自动报告竞态。开发时养成 -race 习惯


8.8 context.Context 专题 —— Eino 全程使用(重点)

这是 Go 最重要、也是最特殊的并发控制机制。Eino 框架的几乎每个函数都带 ctx context.Context 参数,第 10 章 HTTP、第 15 章 Eino 都会用到它。本节系统讲透。

8.8.1 先搞懂:为什么需要 context

想象一个场景:用户发起一个 HTTP 请求,服务端要去调 LLM API。但 LLM 可能要 30 秒才返回,用户等不及,3 秒后关了浏览器。这时候服务端的请求该怎么办?

  • 不处理:服务端还在傻等 LLM,浪费资源
  • 超时控制:服务端设个 10 秒上限,超时就放弃
  • 主动取消:检测到用户断开,立即停止后续工作

这三件事——取消、超时、传递请求信息——就是 context 要解决的。

类比 Node.js:你用过 AbortController 吧?

// Node.js:用 AbortController 取消 fetch
const controller = new AbortController()
setTimeout(() => controller.abort(), 5000)  // 5 秒后取消
fetch(url, { signal: controller.signal })   // fetch 能被取消

Go 的 context 比 AbortController 更强大、更系统——它贯穿整个调用链,从 HTTP 入口一直传到最底层的数据库查询,任何一层都能响应「取消」。

8.8.2 context 是什么

context.Context 是一个接口类型,它的核心就两个方法:

type Context interface {
    Done() <-chan struct{}    // 返回一个 channel:context 被取消/超时时,这个 channel 会关闭
    Err() error               // 返回取消原因(Canceled 或 DeadlineExceeded)
    // 还有 Value/Deadline 方法,少用
}

你不需要记这些。你只要记住:context 是一个可以在函数间传递的「取消信号 + 超时 + 元数据」容器

8.8.3 创建 context

context 有「根」和「派生」两种。根 context 是起点,派生 context 从根派生出来(带超时/取消/值):

import "context"

// 根 context(两个等价,都表示「什么都没有的起点」)
ctx := context.Background()    // 通常用在 main 顶层
ctx := context.TODO()          // 表示「暂时用这个,以后再细化」

// 派生 context(从根派生,加上控制能力)
ctx, cancel := context.WithCancel(parent)                  // 手动取消
ctx, cancel := context.WithTimeout(parent, 2*time.Second)  // 超时取消
ctx, cancel := context.WithDeadline(parent, deadline)      // 截止时间
ctx = context.WithValue(parent, "key", value)              // 携带值

⚠️ 关键规则:每个派生的 context(带 cancel 的)用完必须调用 cancel(),否则会资源泄漏。习惯写法:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()   // ← 永远紧跟 cancel,用 defer 保证执行

8.8.4 用法一:超时控制(最常用)

让一个任务最多跑 N 秒,超时自动停止:

func doWork(ctx context.Context) error {
    for i := 0; i < 10; i++ {
        select {
        case <-ctx.Done():        // ← 监听取消信号
            return ctx.Err()       // 超时或被取消时,ctx.Done() 这个 channel 关闭,走到这里
        default:
            fmt.Println("工作中...", i)
            time.Sleep(500 * time.Millisecond)
        }
    }
    return nil
}

func main() {
    // 创建一个 2 秒后自动取消的 context
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    err := doWork(ctx)
    fmt.Println("结束:", err)   // 结束: context deadline exceeded
}

执行过程

0.0s: 开始工作,打印"工作中...0"
0.5s: 打印"工作中...1"
1.0s: 打印"工作中...2"
1.5s: 打印"工作中...3"
2.0s: 超时!ctx.Done() 触发,doWork 返回 deadline exceeded

如果业务里是调外部 API(比如 LLM),就在 select 里等响应 channel:

func callLLM(ctx context.Context) (string, error) {
    resultCh := make(chan string, 1)
    go func() {
        resultCh <- callSlowAPI()   // 在 goroutine 里调慢 API
    }()

    select {
    case r := <-resultCh:
        return r, nil               // 正常返回
    case <-ctx.Done():
        return "", ctx.Err()        // 超时/取消
    }
}

8.8.5 用法二:手动取消

用户点了「取消」按钮,主动停止后台任务:

func main() {
    ctx, cancel := context.WithCancel(context.Background())

    go func() {
        time.Sleep(2 * time.Second)
        cancel()    // 2 秒后手动取消
        fmt.Println("已发起取消")
    }()

    err := doWork(ctx)    // 复用上面的 doWork
    fmt.Println("结果:", err)   // context canceled
}

8.8.6 用法三:传值(少用,有讲究)

context 能携带请求范围的值(traceID、userID 等):

ctx = context.WithValue(ctx, "userID", 123)

// 在深层函数里取出来
uid, _ := ctx.Value("userID").(int)

⚠️ 少用 WithValue!context 不是参数袋,只用于传请求元数据(traceID 这种横切关注点),业务数据老老实实用函数参数传。滥用 WithValue 会让代码难以追踪。

8.8.7 context 的传递规则(重要约定)

Go 社区有套约定,写代码要遵守:

  1. ctx 作为函数第一个参数,命名 ctx

    func DoSomething(ctx context.Context, userID int) error { ... }
    
  2. 向下传递:调用子函数时把 ctx 传下去,不要自己造新 context(除非你要加超时/取消)

  3. 不要存进 struct:ctx 是请求范围的,存进 struct 会导致生命周期混乱(少数框架类型除外)

  4. cancel 一定要调用:用 defer cancel() 保证

8.8.8 HTTP 服务里的 context(第 10 章会用到)

*http.Request 自带一个 context,客户端断开时自动取消

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()    // ← 拿到请求的 context
    result, err := doSlowQuery(ctx)
    // 用户关掉浏览器 → ctx 自动取消 → doSlowQuery 里的 ctx.Done() 触发,提前返回
}

这是生产环境的标配——避免「客户端都走了,服务端还在傻算」。

8.8.9 为什么 Eino 全程用 context

LLM 调用可能很慢、用户可能中途取消、需要超时控制、需要传 traceID。Eino 把 ctx 贯穿整个调用链:

// Eino 风格(伪代码)
resp, err := chatModel.Generate(ctx, messages)
//                                 ↑
//                ctx 携带超时/取消/traceID

如果你设了 ctx, cancel := context.WithTimeout(ctx, 30*time.Second),LLM 30 秒不返回就自动取消,不会无限挂起。这就是 context 在 LLM 应用里的核心价值。

💡 一句话总结 context:它是一个贯穿调用链的「取消信号 + 超时 + 元数据」容器,解决「任务要能被取消、要能超时、要能传递请求信息」三个问题。Go 的 HTTP、数据库、RPC、Eino 全部用它。


8.9 报错诊断小节

问题 1:fatal error: all goroutines are asleep - deadlock!

func main() {
    ch := make(chan int)
    ch <- 1    // ← 没人接收,永久阻塞
}

原因:无缓冲 channel 发送方阻塞,又没有接收方,Go 检测到所有 goroutine 都卡住,判定死锁。
修复:起一个接收 goroutine,或用有缓冲 channel。

问题 2:panic: send on closed channel

close(ch)
ch <- 1    // ← 报错

原因:向已关闭的 channel 发送数据会 panic。
修复:确保只在「所有发送方」都发完后再关闭,且不再发送。

问题 3:panic: close of closed channel

close(ch)
close(ch)   // ← 报错

原因:重复关闭。
修复:只关闭一次,通常由唯一的发送方负责。

问题 4:goroutine 启动了但没执行(main 提前退出)

go func() { fmt.Println("hi") }()
// main 直接结束,goroutine 没机会跑

原因:main 不等 goroutine。
修复:用 WaitGroup 或 channel 等待。

问题 5:竞态条件(结果值不对)

var count int
// 多 goroutine 并发 count++ → 结果小于预期

检测go run -race main.go
修复:用 Mutex 保护,或用 atomic.AddInt64,或改用 channel。

问题 6:goroutine 泄漏(程序越跑越慢)

func bad() {
    ch := make(chan int)
    go func() {
        ch <- computeResult()   // 没人接收,永远阻塞
    }()
    // 函数返回,ch 没人引用,但 goroutine 还卡在发送上 → 泄漏
}

修复:用 context 控制取消,或用有缓冲 channel。


Node.js / PHP ↔ Go 对照表

概念 Node.js / PHP Go 备注
异步任务 Promise / async-await goroutine 模型完全不同
启动并发 async fn() go fn() Go 一个关键字
并发数 受 Promise 链限制 轻松 10 万+ goroutine 极轻
任务间通信 共享变量 + Promise channel Go 推荐 channel
等待多个 Promise.all WaitGroup
取消任务 AbortController context.Cancel Go context 更强大
超时控制 setTimeout + race context.WithTimeout
互斥 无(单线程无需) sync.Mutex Go 多线程需要
CPU 多核 单线程限制 自动利用多核 Go 优势
阻塞操作 阻塞整个进程 仅阻塞 goroutine
多路等待 Promise.race select 概念相同