先说大实话:并发是 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 不是真的"同时"在跑,而是排队等待被调度。
调度过程拆解
- 几十万个 G 会先分散到各个 P 的本地队列里(本地队列满了才会丢进全局队列)
- 每个 P 配一个 M,M 真正在 CPU 上跑,从本地队列里一个一个取出 G 来执行
- 调度不是抢占式平均分配 CPU 时间片,而是协作式 + 抢占式混合
- Go 1.14 之后支持异步抢占——如果一个 goroutine 跑太久(比如死循环),调度器会强行把它换下来,让别的 goroutine 有机会执行
- goroutine 在执行 channel 操作、
time.Sleep、I/O 等会主动让出控制权
工作窃取(Work Stealing)
如果某个 P 的本地队列空了,但其他 P 的队列里还堆着任务,这个空闲的 P 会去偷别的 P 队列里的一半任务过来执行,避免有的核心闲着、有的核心忙死。这是 Go 调度器效率很高的关键设计。
系统调用(syscall)阻塞怎么办
这是初学者最容易忽略的一点:如果一个 goroutine 执行了阻塞的系统调用(比如读写文件、某些网络操作),它所在的 M 会被阻塞住。这时候调度器会:
- 把这个 M 和它的 P 解绑
- 给 P 找一个新的(或者创建一个新的)M 继续干活
- 等阻塞的系统调用返回后,原来的 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,会有什么问题?
- 全局锁竞争:8 个线程同时抢一把锁去拿任务,竞争激烈,性能差
- 内存分配也要抢锁:goroutine 频繁分配内存,如果所有线程共享一个内存池,锁竞争会非常严重
P 的存在就是为了把这些资源分散开,每个 P 各管一摊:
- 各自的本地队列(取任务不用全局锁,速度快)
- 各自的内存缓存(分配内存不用抢全局锁)
这是一种典型的"分片"思路,减少多线程间的竞争。
P 与本地队列的关系
也可以理解成:P 是"工位",本地队列是这个工位上的"待办清单"。
P 0 ┌───────────────────┐
│ 本地队列(最多256) │ ← G 优先放这里
│ [G1][G2][G3]... │
└───────────────────┘
↑
M 0 绑定到 P 0,从这个队列里
一个个取出 G 来执行
具体流程:
go func(){}创建一个新 G 时,优先放入当前 P 的本地队列(如果满了,才会放入全局队列,或者随机分给别的 P)- M 要执行任务时,先看自己绑定的 P 的本地队列——这是最快路径,不用加锁(用的是无锁队列设计)
- 本地队列空了,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包):
- 计数器(counter):记录还剩多少任务未完成(由
Add和Done操纵)。- 等待者计数(waiter):记录有多少个 Goroutine 因为调用了
Wait()而被阻塞。- 信号量(sema):用于阻塞和唤醒 Goroutine 的底层锁机制。
调度流程如下:
- 当主 Goroutine 调用
wg.Wait()时,发现counter不为 0。- 该 Goroutine 会被挂起(从 运行中 状态变为 等待 状态),并加入该
WaitGroup的等待队列。- 关键(GMP 视角):这个被挂起的 G 会让出当前的 M(操作系统线程)。M 不会傻等,它会立刻去执行 P 本地队列里的其他 Goroutine,CPU 资源被充分利用,没有任何空转浪费。
- 当最后一个 Worker Goroutine 调用
wg.Done()导致counter归零时,WaitGroup会释放信号量。- 被挂起的主 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 社区有套约定,写代码要遵守:
-
ctx 作为函数第一个参数,命名
ctx:func DoSomething(ctx context.Context, userID int) error { ... } -
向下传递:调用子函数时把 ctx 传下去,不要自己造新 context(除非你要加超时/取消)
-
不要存进 struct:ctx 是请求范围的,存进 struct 会导致生命周期混乱(少数框架类型除外)
-
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 | 概念相同 |