
在 Go 项目里,goroutine 很便宜。
便宜到我们经常随手就写:
go
go func() {
// do something
}()
但问题也恰恰出在这里。
goroutine 创建容易,退出却需要你自己设计。
一个 HTTP 请求已经结束了,下游 goroutine 还在查询数据库;
一个任务早就超时了,后台 goroutine 还在等待 channel;
用户已经取消请求了,你的服务还在调用第三方 API;
Worker 已经不需要结果了,生产者还在不断往 channel 里塞数据。
这些问题最终都会指向两个关键词:
text
协程泄漏
超时控制
而 Go 标准库给我们的核心解决方案,就是:
go
context.Context
很多人会用:
go
context.WithTimeout(...)
但真正把 Context 用好,并不是简单加一个超时时间。
它本质上解决的是:
一个任务什么时候应该结束,以及这个"结束信号"如何沿调用链向下传播。
这篇文章我们就从实际代码出发,系统讲清楚 Go Context 在协程泄漏、超时控制、任务取消和并发编排中的使用方式。
一、先看一个典型的协程泄漏
先看下面这段代码:
go
package main
import (
"fmt"
"time"
)
func worker(ch <-chan int) {
value := <-ch
fmt.Println("received:", value)
}
func main() {
ch := make(chan int)
go worker(ch)
time.Sleep(2 * time.Second)
fmt.Println("main finished")
}
程序看起来很简单。
启动一个 goroutine:
go
go worker(ch)
然后等待 channel 数据:
go
value := <-ch
但问题是:
go
ch := make(chan int)
我们从来没有向这个 channel 写入任何数据。
所以这个 goroutine 会永久阻塞:
go
value := <-ch
如果这只是一个 Demo,主程序退出以后问题不大。
但是如果它运行在一个长期存活的 Web 服务里呢?
比如:
go
func handleRequest() {
ch := make(chan int)
go worker(ch)
}
每调用一次:
go
handleRequest()
就产生一个永远无法退出的 goroutine。
调用 10 万次,就可能残留 10 万个 goroutine。
这就是典型的:
text
goroutine leak
协程泄漏。
二、goroutine 泄漏到底会造成什么问题?
很多人会说:
goroutine 不是很轻量吗?
确实。
Go 的 goroutine 初始栈很小,而且运行时会自动扩容。
但"轻量"不等于"免费"。
一个泄漏掉的 goroutine 可能会占用:
text
goroutine stack
对象引用
channel
timer
socket
数据库连接
HTTP connection
文件描述符
锁
缓存
闭包变量
更严重的是,它可能一直引用一些大型对象。
例如:
go
func process() {
data := make([]byte, 100*1024*1024)
ch := make(chan struct{})
go func() {
fmt.Println(len(data))
<-ch
}()
}
这里创建了大约:
text
100 MB
的数据。
goroutine 的闭包引用了:
go
data
而这个 goroutine 又永久阻塞:
go
<-ch
那么 data 也很难被 GC 回收。
所以协程泄漏经常会进一步演变成:
text
内存持续上涨
连接数上涨
请求堆积
接口变慢
服务 OOM
三、Context 到底是什么?
Go 中 Context 的核心接口其实非常简单。
标准库里的定义大致是:
go
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
最核心的其实是:
go
Done()
它返回一个 channel:
go
<-chan struct{}
当 Context 被取消或者超时时:
go
ctx.Done()
这个 channel 会被关闭。
因此我们可以这样监听任务是否结束:
go
select {
case <-ctx.Done():
fmt.Println("task cancelled")
}
这其实就是 Context 最核心的机制。
你可以简单理解成:
text
Context = 一个可以向下传播的取消信号
例如:
text
HTTP Request
│
▼
Service
│
▼
Repository
│
▼
Database
如果 HTTP 请求取消:
text
HTTP Request cancelled
↓
Service cancelled
↓
Repository cancelled
↓
Database query cancelled
这就是 Context 的价值。
四、使用 Context 解决刚才的 goroutine 泄漏
我们改造一下最开始的代码。
go
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, ch <-chan int) {
select {
case value := <-ch:
fmt.Println("received:", value)
case <-ctx.Done():
fmt.Println("worker cancelled:", ctx.Err())
return
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := make(chan int)
go worker(ctx, ch)
time.Sleep(2 * time.Second)
cancel()
time.Sleep(time.Second)
fmt.Println("main finished")
}
现在 worker 不再只是等待:
go
value := <-ch
而是同时监听两个事件:
go
select {
case value := <-ch:
case <-ctx.Done():
}
如果任务正常得到数据:
go
case value := <-ch:
正常处理。
如果外部决定任务不需要继续执行:
go
cancel()
Context 的:
go
ctx.Done()
会立即关闭。
worker 就可以主动退出:
go
case <-ctx.Done():
return
这就是 Context 最经典的使用方式。
五、context.WithCancel:主动取消任务
最基础的 Context 创建方式之一是:
go
ctx, cancel := context.WithCancel(parent)
例如:
go
ctx, cancel := context.WithCancel(context.Background())
返回两个对象:
go
ctx
cancel
调用:
go
cancel()
以后:
go
ctx.Done()
就会收到结束信号。
完整示例:
go
package main
import (
"context"
"fmt"
"time"
)
func task(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("working...")
case <-ctx.Done():
fmt.Println("task stopped:", ctx.Err())
return
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go task(ctx)
time.Sleep(3 * time.Second)
fmt.Println("cancel task")
cancel()
time.Sleep(time.Second)
}
输出类似:
text
working...
working...
working...
cancel task
task stopped: context canceled
注意这里:
go
ctx.Err()
会返回:
go
context.Canceled
六、为什么一定要 defer cancel()
很多代码都会这样写:
go
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)
defer cancel()
有人会问:
明明 3 秒以后 Context 会自动超时,为什么还需要 cancel?
因为 WithTimeout 内部通常还会创建:
text
timer
child context
相关运行时资源
如果你的任务提前完成:
text
100ms 就完成了
但 timeout 设置的是:
text
30 秒
那么不主动调用:
go
cancel()
相关资源可能需要一直保留到超时触发。
所以推荐写法几乎永远是:
go
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
哪怕你认为它"一定会自动超时"。
七、context.WithTimeout:最常见的超时控制
实际业务开发里,WithTimeout 可能比 WithCancel 使用得更多。
例如:
调用第三方接口最多允许 3 秒。
可以写:
go
package main
import (
"context"
"fmt"
"time"
)
func callAPI(ctx context.Context) error {
select {
case <-time.After(5 * time.Second):
fmt.Println("API finished")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)
defer cancel()
err := callAPI(ctx)
if err != nil {
fmt.Println("request failed:", err)
return
}
fmt.Println("request success")
}
这里 API 模拟耗时:
go
5 * time.Second
但是我们只允许:
go
3 * time.Second
所以最终输出:
text
request failed: context deadline exceeded
对应:
go
ctx.Err()
返回:
go
context.DeadlineExceeded
八、WithDeadline 和 WithTimeout 的区别
除了:
go
context.WithTimeout
还有:
go
context.WithDeadline
区别非常简单。
WithTimeout
指定:
text
从现在开始最多运行多久
例如:
go
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)
表示:
text
最多执行 3 秒
WithDeadline
指定:
text
具体截止时间
例如:
go
deadline := time.Now().Add(3 * time.Second)
ctx, cancel := context.WithDeadline(
context.Background(),
deadline,
)
表示:
text
到 deadline 为止必须停止
多数业务场景建议使用:
go
WithTimeout
因为可读性更直观。
九、最重要的原则:Context 必须向下传递
假设你的项目结构是:
text
Handler
↓
Service
↓
Repository
↓
MySQL
那么不要这样写:
go
func Handler() {
ctx := context.Background()
Service(ctx)
}
然后 Service 又创建一个新的:
go
func Service(ctx context.Context) {
newCtx := context.Background()
Repository(newCtx)
}
这样原来的取消链就断了。
正确方式应该是:
go
func Handler(ctx context.Context) {
Service(ctx)
}
func Service(ctx context.Context) {
Repository(ctx)
}
func Repository(ctx context.Context) {
queryDatabase(ctx)
}
Context 一直向下传递:
text
ctx
│
▼
Handler
│
▼
Service
│
▼
Repository
│
▼
Database
上游取消:
text
cancel
下面所有支持 Context 的操作都可以停止。
十、HTTP 请求中的 Context
Go 的:
go
http.Request
本身就包含 Context。
可以直接:
go
ctx := r.Context()
例如:
go
package main
import (
"fmt"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
select {
case <-time.After(10 * time.Second):
fmt.Fprintln(w, "finished")
case <-ctx.Done():
fmt.Println(
"client disconnected:",
ctx.Err(),
)
return
}
}
func main() {
http.HandleFunc("/test", handler)
http.ListenAndServe(":8080", nil)
}
如果用户访问:
text
/test
然后在请求完成之前关闭浏览器、取消请求或者连接断开,那么:
go
r.Context()
就会被取消。
你的后台任务就可以监听:
go
<-ctx.Done()
主动退出。
十一、错误示范:HTTP 已结束,goroutine 还在跑
比如:
go
func handler(w http.ResponseWriter, r *http.Request) {
go func() {
time.Sleep(30 * time.Second)
fmt.Println("heavy task finished")
}()
fmt.Fprintln(w, "ok")
}
请求可能:
text
100ms
就结束了。
但是 goroutine:
go
go func()
还会继续执行:
text
30 秒
如果 QPS 很大:
text
1000 req/s
后台非常容易积累大量 goroutine。
改造成 Context 控制
go
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
done := make(chan struct{})
go func() {
defer close(done)
select {
case <-time.After(30 * time.Second):
fmt.Println("heavy task finished")
case <-ctx.Done():
fmt.Println("task cancelled")
return
}
}()
select {
case <-done:
fmt.Fprintln(w, "finished")
case <-ctx.Done():
return
}
}
现在请求结束时:
go
r.Context()
取消。
goroutine 也可以随之结束。
十二、给 HTTP 接口增加服务端超时
有时候我们不只是希望:
text
客户端断开 → 任务取消
还希望:
text
即使客户端一直连接
服务端最多也只执行 3 秒
这时候可以在:
go
r.Context()
基础上再创建一个子 Context:
go
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(
r.Context(),
3*time.Second,
)
defer cancel()
result, err := service(ctx)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
http.Error(
w,
"request timeout",
http.StatusGatewayTimeout,
)
return
}
http.Error(
w,
err.Error(),
http.StatusInternalServerError,
)
return
}
fmt.Fprintln(w, result)
}
Service:
go
func service(ctx context.Context) (string, error) {
select {
case <-time.After(5 * time.Second):
return "success", nil
case <-ctx.Done():
return "", ctx.Err()
}
}
整个调用链:
text
HTTP Request Context
│
▼
WithTimeout(3s)
│
▼
Service
因此有两个条件都可以终止任务:
text
客户端取消请求
或
执行超过 3 秒
十三、Context + HTTP Client
一个非常重要的场景:
我们的服务调用另一个 HTTP 服务。
错误写法:
go
resp, err := http.Get(
"https://example.com/api",
)
这样当前业务 Context 很难自然传递进去。
推荐使用:
go
http.NewRequestWithContext
完整代码:
go
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func request(ctx context.Context) ([]byte, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
"https://example.com",
nil,
)
if err != nil {
return nil, err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)
defer cancel()
body, err := request(ctx)
if err != nil {
fmt.Println("request error:", err)
return
}
fmt.Println(string(body))
}
这样一旦:
go
ctx
超时或者取消:
go
client.Do(req)
也能够感知。
十四、不要只依赖 http.Client.Timeout
有的人会写:
go
client := &http.Client{
Timeout: 5 * time.Second,
}
这当然没问题。
但 Client.Timeout 和 Context 解决的问题并不完全一样。
例如:
go
client.Timeout
主要限制 HTTP 请求时间。
而:
go
context.Context
可以贯穿整个调用链:
text
Handler
↓
Service
↓
Cache
↓
HTTP Client
↓
Database
所以实际项目里经常同时使用:
go
ctx, cancel := context.WithTimeout(
parent,
3*time.Second,
)
defer cancel()
以及:
go
client := &http.Client{
Timeout: 5 * time.Second,
}
一个负责:
text
业务调用链超时
一个负责:
text
HTTP Client 自身安全边界
十五、数据库查询一定要使用 QueryContext
数据库查询是 Context 非常重要的应用场景。
不要只写:
go
rows, err := db.Query(
"SELECT * FROM users",
)
如果业务已经取消,但数据库 SQL 仍然在执行,就可能白白浪费大量资源。
更推荐:
go
rows, err := db.QueryContext(
ctx,
"SELECT * FROM users",
)
完整示例:
go
package repository
import (
"context"
"database/sql"
)
type User struct {
ID int64
Name string
}
type UserRepository struct {
db *sql.DB
}
func NewUserRepository(db *sql.DB) *UserRepository {
return &UserRepository{
db: db,
}
}
func (r *UserRepository) FindUsers(
ctx context.Context,
) ([]User, error) {
rows, err := r.db.QueryContext(
ctx,
`
SELECT
id,
name
FROM users
ORDER BY id DESC
LIMIT 100
`,
)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
var user User
if err := rows.Scan(
&user.ID,
&user.Name,
); err != nil {
return nil, err
}
users = append(
users,
user,
)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
Service:
go
type UserService struct {
repo *UserRepository
}
func (s *UserService) ListUsers(
ctx context.Context,
) ([]User, error) {
return s.repo.FindUsers(ctx)
}
Handler:
go
func (h *Handler) ListUsers(
w http.ResponseWriter,
r *http.Request,
) {
ctx, cancel := context.WithTimeout(
r.Context(),
2*time.Second,
)
defer cancel()
users, err := h.service.ListUsers(ctx)
if err != nil {
if errors.Is(
err,
context.DeadlineExceeded,
) {
http.Error(
w,
"database timeout",
http.StatusGatewayTimeout,
)
return
}
http.Error(
w,
"internal error",
http.StatusInternalServerError,
)
return
}
json.NewEncoder(w).Encode(users)
}
整条链路:
text
HTTP
↓
Context
↓
Service
↓
Repository
↓
QueryContext
↓
MySQL
当 HTTP 超时:
text
Context DeadlineExceeded
数据库查询也有机会被取消。
十六、GORM 中如何使用 Context
如果你使用 GORM,也不要抛弃 Context。
推荐:
go
db.WithContext(ctx)
例如:
go
type UserRepository struct {
db *gorm.DB
}
func (r *UserRepository) FindByID(
ctx context.Context,
id uint,
) (*User, error) {
var user User
err := r.db.
WithContext(ctx).
Where("id = ?", id).
First(&user).
Error
if err != nil {
return nil, err
}
return &user, nil
}
查询列表:
go
func (r *UserRepository) List(
ctx context.Context,
) ([]User, error) {
var users []User
err := r.db.
WithContext(ctx).
Order("id DESC").
Limit(100).
Find(&users).
Error
if err != nil {
return nil, err
}
return users, nil
}
事务同样可以传递:
go
func (r *UserRepository) Transaction(
ctx context.Context,
) error {
return r.db.
WithContext(ctx).
Transaction(func(tx *gorm.DB) error {
if err := tx.Create(
&User{
Name: "JavaPub",
},
).Error; err != nil {
return err
}
return nil
})
}
十七、channel 是最容易制造 goroutine 泄漏的地方
来看一个更加典型的案例。
go
func search() string {
ch := make(chan string)
go func() {
time.Sleep(5 * time.Second)
ch <- "result"
}()
select {
case result := <-ch:
return result
case <-time.After(time.Second):
return "timeout"
}
}
乍一看:
text
1 秒以后超时返回
好像没问题。
实际上隐藏着一个严重问题。
主 goroutine:
go
case <-time.After(time.Second):
return "timeout"
已经返回。
但是后台 goroutine 5 秒以后还会执行:
go
ch <- "result"
而这个 channel 是:
go
ch := make(chan string)
无缓冲 channel。
已经没有任何接收者。
所以:
go
ch <- "result"
会永久阻塞。
于是产生 goroutine leak。
十八、解决 channel 泄漏的第一种方式:使用缓冲 channel
可以改成:
go
func search() string {
ch := make(chan string, 1)
go func() {
time.Sleep(5 * time.Second)
ch <- "result"
}()
select {
case result := <-ch:
return result
case <-time.After(time.Second):
return "timeout"
}
}
这里:
go
make(chan string, 1)
允许 goroutine 即使没有接收者,也能把一个结果写进去。
因此 goroutine 不会卡死在:
go
ch <- "result"
这是一个非常实用的小技巧。
但它并不能解决:
text
后台任务仍然白跑了 5 秒
的问题。
更完整的解决方案还是 Context。
十九、解决 channel 泄漏的第二种方式:Context
改造:
go
func search(
ctx context.Context,
) (string, error) {
ch := make(chan string, 1)
go func() {
select {
case <-time.After(5 * time.Second):
select {
case ch <- "result":
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}()
select {
case result := <-ch:
return result, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
调用:
go
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
time.Second,
)
defer cancel()
result, err := search(ctx)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)
}
这样不只是接收方会退出。
发送方也会监听:
go
ctx.Done()
任务整体才能真正结束。
二十、最关键的一个并发原则
写并发代码时,我非常建议记住一句话:
谁创建 goroutine,谁就应该知道它什么时候退出。
不要写这种无法控制生命周期的代码:
go
go func() {
for {
doSomething()
}
}()
应该至少存在一个退出条件:
go
go func() {
for {
select {
case <-ctx.Done():
return
default:
doSomething()
}
}
}()
但这里又有一个新的坑。
如果:
go
doSomething()
本身需要执行 30 秒,那么 Context 即使取消,也必须等:
go
doSomething()
执行完成以后才有机会再次执行:
go
select
所以最理想情况是:
go
doSomething(ctx)
也支持 Context。
例如:
go
func doSomething(
ctx context.Context,
) error {
select {
case <-time.After(30 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
}
于是:
go
func worker(
ctx context.Context,
) {
for {
if err := doSomething(ctx); err != nil {
if errors.Is(
err,
context.Canceled,
) {
return
}
if errors.Is(
err,
context.DeadlineExceeded,
) {
return
}
}
}
}
这样取消才能真正贯穿每一层。
二十一、Worker Pool 如何正确支持 Context
Worker Pool 是最容易出现 goroutine 泄漏的地方之一。
假设我们有 5 个 worker:
go
package main
import (
"context"
"fmt"
"sync"
"time"
)
func worker(
ctx context.Context,
id int,
jobs <-chan int,
wg *sync.WaitGroup,
) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Printf(
"worker %d stopped: %v\n",
id,
ctx.Err(),
)
return
case job, ok := <-jobs:
if !ok {
fmt.Printf(
"worker %d finished\n",
id,
)
return
}
fmt.Printf(
"worker %d processing job %d\n",
id,
job,
)
select {
case <-time.After(time.Second):
fmt.Printf(
"worker %d completed job %d\n",
id,
job,
)
case <-ctx.Done():
return
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
3*time.Second,
)
defer cancel()
jobs := make(chan int)
var wg sync.WaitGroup
workerCount := 5
for i := 0; i < workerCount; i++ {
wg.Add(1)
go worker(
ctx,
i,
jobs,
&wg,
)
}
go func() {
defer close(jobs)
for i := 0; i < 100; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}()
wg.Wait()
fmt.Println("all workers stopped")
}
这里有几个值得注意的地方。
Worker 接收任务时监听 Context
go
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
}
Worker 执行耗时任务时仍然监听 Context
go
select {
case <-time.After(time.Second):
case <-ctx.Done():
return
}
Producer 写入 channel 时同样监听 Context
这点非常重要。
错误写法:
go
jobs <- i
如果所有 Worker 都已经退出,那么 Producer 就可能永久阻塞。
所以写成:
go
select {
case jobs <- i:
case <-ctx.Done():
return
}
这样:
text
producer
worker
consumer
都属于同一个 Context 生命周期。
二十二、Context + WaitGroup
Context 和 sync.WaitGroup 解决的是不同问题。
Context 解决:
text
什么时候应该退出?
WaitGroup 解决:
text
所有 goroutine 是否已经退出?
所以它们经常搭配使用。
例如:
go
func runWorkers(
ctx context.Context,
workerCount int,
) {
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Println(
"worker exit:",
id,
)
return
case <-time.After(time.Second):
fmt.Println(
"worker running:",
id,
)
}
}
}(i)
}
wg.Wait()
}
可以理解成:
text
Context
负责发送"下班通知"
WaitGroup
负责确认"所有人都已经下班"
二十三、多协程并发查询:Fan-Out 模式
实际业务中经常有这样的需求:
一个接口需要同时查询:
text
用户信息
订单信息
积分信息
会员信息
最简单的串行代码:
go
user := queryUser()
orders := queryOrders()
points := queryPoints()
member := queryMember()
如果每个请求需要:
text
500ms
总耗时可能接近:
text
2 秒
可以并发执行:
go
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Result struct {
Name string
Value string
Err error
}
func query(
ctx context.Context,
name string,
duration time.Duration,
) Result {
select {
case <-time.After(duration):
return Result{
Name: name,
Value: name + " data",
}
case <-ctx.Done():
return Result{
Name: name,
Err: ctx.Err(),
}
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel()
tasks := []struct {
name string
duration time.Duration
}{
{"user", 300 * time.Millisecond},
{"orders", 700 * time.Millisecond},
{"points", time.Second},
{"member", 1500 * time.Millisecond},
}
resultCh := make(
chan Result,
len(tasks),
)
var wg sync.WaitGroup
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
result := query(
ctx,
task.name,
task.duration,
)
select {
case resultCh <- result:
case <-ctx.Done():
return
}
}()
}
go func() {
wg.Wait()
close(resultCh)
}()
for result := range resultCh {
if result.Err != nil {
fmt.Printf(
"%s failed: %v\n",
result.Name,
result.Err,
)
continue
}
fmt.Printf(
"%s: %s\n",
result.Name,
result.Value,
)
}
}
这就是典型的:
text
Fan-Out
把一个请求拆成多个并发任务。
再通过:
go
resultCh
汇总。
二十四、任何一个子任务失败,就取消全部任务
有时候业务要求:
text
用户查询失败
订单查询失败
积分查询失败
任何一个失败,整个请求都没有继续执行的意义。
这时候可以共享:
go
ctx, cancel := context.WithCancel(...)
例如:
go
func runTasks(
parent context.Context,
) error {
ctx, cancel := context.WithCancel(parent)
defer cancel()
tasks := []func(context.Context) error{
queryUser,
queryOrder,
queryPoints,
}
errCh := make(
chan error,
len(tasks),
)
var wg sync.WaitGroup
for _, task := range tasks {
task := task
wg.Add(1)
go func() {
defer wg.Done()
if err := task(ctx); err != nil {
select {
case errCh <- err:
default:
}
cancel()
}
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case err := <-errCh:
cancel()
<-done
return err
case <-done:
return nil
case <-parent.Done():
cancel()
<-done
return parent.Err()
}
}
这里:
go
cancel()
会通知其他所有任务:
text
别做了
整个任务已经失败
这是 Context 非常重要的并发编排能力。
二十五、使用 errgroup 进一步简化并发任务
实际项目中,推荐了解:
text
golang.org/x/sync/errgroup
它可以很好地解决:
text
WaitGroup
+
错误收集
+
Context Cancel
组合问题。
例如:
go
package main
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
func queryUser(
ctx context.Context,
) error {
select {
case <-time.After(time.Second):
fmt.Println("user finished")
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func queryOrder(
ctx context.Context,
) error {
select {
case <-time.After(2 * time.Second):
return fmt.Errorf(
"order service failed",
)
case <-ctx.Done():
return ctx.Err()
}
}
func queryPoints(
ctx context.Context,
) error {
select {
case <-time.After(5 * time.Second):
fmt.Println("points finished")
return nil
case <-ctx.Done():
fmt.Println(
"points cancelled",
)
return ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer cancel()
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error {
return queryUser(ctx)
})
g.Go(func() error {
return queryOrder(ctx)
})
g.Go(func() error {
return queryPoints(ctx)
})
if err := g.Wait(); err != nil {
fmt.Println(
"task failed:",
err,
)
return
}
fmt.Println("all success")
}
当:
go
queryOrder
返回错误以后,errgroup 对应的 Context 会被取消。
于是:
go
queryPoints
可以通过:
go
ctx.Done()
及时结束。
这种方式非常适合:
text
并行调用多个微服务
并行查询多个数据源
并行处理多个文件
并行执行多个计算任务
二十六、Context 是一棵树
理解 Context 最好的方式,是把它想成一棵树。
例如:
go
root := context.Background()
ctx1, cancel1 := context.WithCancel(root)
ctx2, cancel2 := context.WithTimeout(
ctx1,
5*time.Second,
)
ctx3, cancel3 := context.WithTimeout(
ctx2,
2*time.Second,
)
它们之间是:
text
Background
│
▼
ctx1
│
▼
ctx2
│
▼
ctx3
如果:
go
cancel1()
那么:
text
ctx1
ctx2
ctx3
全部取消。
但是如果:
go
cancel3()
不会影响:
text
ctx1
ctx2
所以 Context 的取消传播方向是:
text
父 Context
↓
子 Context
↓
孙 Context
而不是反过来。
二十七、子 Context 的 Deadline 不可能晚于父 Context
比如:
go
parent, cancel1 := context.WithTimeout(
context.Background(),
2*time.Second,
)
defer cancel1()
child, cancel2 := context.WithTimeout(
parent,
10*time.Second,
)
defer cancel2()
虽然 child 写的是:
text
10 秒
但 parent 只有:
text
2 秒
所以整个 child 最多也只能活:
text
2 秒
也就是:
text
child deadline
<=
parent deadline
这符合调用链语义:
下游不能要求比上游更长的生命周期。
二十八、Context 的错误类型
通常:
go
ctx.Err()
只有两个主要结果。
主动取消:
go
context.Canceled
例如:
go
cancel()
超时:
go
context.DeadlineExceeded
例如:
go
context.WithTimeout(...)
超过时间。
判断时推荐:
go
if errors.Is(
err,
context.Canceled,
) {
// cancelled
}
以及:
go
if errors.Is(
err,
context.DeadlineExceeded,
) {
// timeout
}
完整代码:
go
func handleError(err error) {
switch {
case errors.Is(
err,
context.Canceled,
):
fmt.Println(
"request cancelled",
)
case errors.Is(
err,
context.DeadlineExceeded,
):
fmt.Println(
"request timeout",
)
default:
fmt.Println(
"unknown error:",
err,
)
}
}
二十九、Go 1.20+:WithCancelCause
普通:
go
context.WithCancel
只能告诉下游:
text
任务取消了
但有时候我们还想告诉下游:
为什么取消?
Go 提供了:
go
context.WithCancelCause
例如:
go
package main
import (
"context"
"errors"
"fmt"
)
var ErrUserBlocked = errors.New(
"user has been blocked",
)
func main() {
ctx, cancel := context.WithCancelCause(
context.Background(),
)
cancel(ErrUserBlocked)
<-ctx.Done()
fmt.Println(
"ctx err:",
ctx.Err(),
)
fmt.Println(
"cause:",
context.Cause(ctx),
)
}
输出类似:
text
ctx err: context canceled
cause: user has been blocked
也就是说:
go
ctx.Err()
仍然表达标准 Context 状态:
text
context canceled
而:
go
context.Cause(ctx)
可以获得业务层面的具体原因。
三十、WithTimeoutCause
如果希望超时时附带自己的错误原因,也可以使用:
go
context.WithTimeoutCause
例如:
go
var ErrPaymentTimeout = errors.New(
"payment service timeout",
)
ctx, cancel := context.WithTimeoutCause(
context.Background(),
3*time.Second,
ErrPaymentTimeout,
)
defer cancel()
<-ctx.Done()
fmt.Println(
context.Cause(ctx),
)
输出:
text
payment service timeout
这对于复杂系统的日志和排错非常有价值。
三十一、Context.Value 能不能传业务参数?
Context 还有一个能力:
go
ctx.Value(key)
可以传递一些请求级数据。
例如:
text
trace id
request id
authentication metadata
示例:
go
package main
import (
"context"
"fmt"
)
type contextKey string
const requestIDKey contextKey = "request-id"
func service(
ctx context.Context,
) {
requestID := ctx.Value(
requestIDKey,
)
fmt.Println(
"request id:",
requestID,
)
}
func main() {
ctx := context.WithValue(
context.Background(),
requestIDKey,
"req-20260822-001",
)
service(ctx)
}
但是不要把 Context 当成:
text
万能参数 Map
不要这样:
go
ctx = context.WithValue(
ctx,
"username",
"JavaPub",
)
ctx = context.WithValue(
ctx,
"page",
1,
)
ctx = context.WithValue(
ctx,
"pageSize",
20,
)
普通业务参数应该明确写在函数参数或者结构体里。
例如:
go
func ListUsers(
ctx context.Context,
page int,
pageSize int,
) {
}
而不是:
go
func ListUsers(
ctx context.Context,
) {
page := ctx.Value("page")
}
Context.Value 更适合:
text
请求级元数据
三十二、不要把 Context 存进 struct
不推荐:
go
type UserService struct {
ctx context.Context
}
然后:
go
func NewUserService(
ctx context.Context,
) *UserService {
return &UserService{
ctx: ctx,
}
}
因为 Context 的生命周期通常应该和:
text
一次请求
一次任务
一次调用链
绑定。
而 struct 可能存活很长时间。
推荐直接作为方法的第一个参数:
go
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(
ctx context.Context,
id uint,
) (*User, error) {
return s.repo.FindByID(
ctx,
id,
)
}
Go 代码里非常常见的签名就是:
go
func DoSomething(
ctx context.Context,
...
) error
三十三、Context 通常应该放在第一个参数
推荐:
go
func QueryUser(
ctx context.Context,
id int64,
) error
而不是:
go
func QueryUser(
id int64,
ctx context.Context,
) error
这是 Go 社区非常常见的习惯。
方便我们一眼看出:
text
这个函数支持:
取消
超时
Deadline
请求级元数据
三十四、不要传 nil Context
不要:
go
service(nil)
如果你暂时不知道应该用什么 Context,可以:
go
context.Background()
或者:
go
context.TODO()
例如:
go
service(
context.Background(),
)
三十五、Background 和 TODO 有什么区别?
两者功能上很接近。
go
context.Background()
通常用于:
text
程序入口
main
后台服务根节点
测试
例如:
go
ctx := context.Background()
而:
go
context.TODO()
更像是在表达:
text
这里未来应该传入真正的 Context,
但暂时还没有设计好。
例如:
go
func legacyCode() {
ctx := context.TODO()
service(ctx)
}
在实际项目里,如果这是明确的根 Context:
go
context.Background()
更合适。
如果只是临时占位:
go
context.TODO()
语义更明确。
三十六、不要在函数内部随便创建 Background
这是实际项目中非常常见的问题。
错误:
go
func service(
ctx context.Context,
) error {
return repository.Query(
context.Background(),
)
}
这样调用链被你人为切断了。
即使上游:
go
ctx
已经取消:
text
Repository 仍然不知道
正确:
go
func service(
ctx context.Context,
) error {
return repository.Query(ctx)
}
除非你非常明确地希望:
text
这个任务与当前请求生命周期脱离
否则不要随意创建:
go
context.Background()
三十七、循环里的 time.After 也需要注意
很多代码会写:
go
for {
select {
case <-time.After(time.Second):
doSomething()
case <-ctx.Done():
return
}
}
这虽然可以工作,但每轮:
go
time.After
都会创建新的 Timer。
长期循环更推荐:
go
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doSomething()
case <-ctx.Done():
return
}
}
完整代码:
go
func heartbeat(
ctx context.Context,
) {
ticker := time.NewTicker(
5 * time.Second,
)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println(
"heartbeat",
)
case <-ctx.Done():
fmt.Println(
"heartbeat stopped",
)
return
}
}
}
三十八、后台定时任务怎么优雅停止?
比如:
go
go cronTask()
不要写:
go
func cronTask() {
for {
time.Sleep(time.Minute)
run()
}
}
因为程序关闭时,它没有统一退出机制。
推荐:
go
func cronTask(
ctx context.Context,
) {
ticker := time.NewTicker(
time.Minute,
)
defer ticker.Stop()
for {
select {
case <-ticker.C:
run(ctx)
case <-ctx.Done():
fmt.Println(
"cron stopped",
)
return
}
}
}
启动:
go
ctx, cancel := context.WithCancel(
context.Background(),
)
go cronTask(ctx)
关闭:
go
cancel()
三十九、结合系统信号实现 Graceful Shutdown
这也是 Context 非常经典的生产级场景。
例如:
go
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
func worker(
ctx context.Context,
) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
fmt.Println("working...")
case <-ctx.Done():
fmt.Println(
"worker stopping...",
)
return
}
}
}
func main() {
ctx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
go worker(ctx)
<-ctx.Done()
fmt.Println(
"shutdown signal received",
)
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
select {
case <-time.After(time.Second):
fmt.Println(
"cleanup finished",
)
case <-shutdownCtx.Done():
fmt.Println(
"cleanup timeout",
)
}
}
现在发送:
text
Ctrl + C
或者系统发送:
text
SIGTERM
Context 就会取消。
后台 Worker 可以主动退出。
四十、HTTP Server 优雅关闭完整示例
再进一步:
go
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc(
"/",
func(
w http.ResponseWriter,
r *http.Request,
) {
fmt.Fprintln(
w,
"hello",
)
},
)
server := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
serverErr := make(
chan error,
1,
)
go func() {
err := server.ListenAndServe()
if err != nil &&
!errors.Is(
err,
http.ErrServerClosed,
) {
serverErr <- err
}
close(serverErr)
}()
signalCtx, stop := signal.NotifyContext(
context.Background(),
os.Interrupt,
syscall.SIGTERM,
)
defer stop()
select {
case <-signalCtx.Done():
fmt.Println(
"shutdown signal received",
)
case err := <-serverErr:
if err != nil {
fmt.Println(
"server error:",
err,
)
}
return
}
shutdownCtx, cancel := context.WithTimeout(
context.Background(),
10*time.Second,
)
defer cancel()
if err := server.Shutdown(
shutdownCtx,
); err != nil {
fmt.Println(
"shutdown failed:",
err,
)
return
}
fmt.Println(
"server gracefully stopped",
)
}
生产项目里,这种模式非常常见:
text
收到 SIGTERM
↓
停止接受新请求
↓
等待正在执行的请求结束
↓
最多等待 10 秒
↓
退出进程
四十一、一个完整的 Web 服务 Context 调用链
把前面的知识整合起来。
假设项目目录:
text
project
├── main.go
├── handler
│ └── user.go
├── service
│ └── user.go
└── repository
└── user.go
Handler
go
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
)
type UserService interface {
GetProfile(
ctx context.Context,
userID int64,
) (*Profile, error)
}
type Handler struct {
service UserService
}
func (h *Handler) GetProfile(
w http.ResponseWriter,
r *http.Request,
) {
ctx, cancel := context.WithTimeout(
r.Context(),
3*time.Second,
)
defer cancel()
profile, err := h.service.GetProfile(
ctx,
10001,
)
if err != nil {
switch {
case errors.Is(
err,
context.Canceled,
):
return
case errors.Is(
err,
context.DeadlineExceeded,
):
http.Error(
w,
"request timeout",
http.StatusGatewayTimeout,
)
return
default:
http.Error(
w,
"internal server error",
http.StatusInternalServerError,
)
return
}
}
w.Header().Set(
"Content-Type",
"application/json",
)
json.NewEncoder(w).Encode(
profile,
)
}
Service
go
package service
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
type User struct {
ID int64
Name string
}
type Order struct {
ID int64
Amount float64
}
type Profile struct {
User *User
Orders []Order
}
type UserRepository interface {
FindByID(
ctx context.Context,
id int64,
) (*User, error)
}
type OrderRepository interface {
FindByUserID(
ctx context.Context,
userID int64,
) ([]Order, error)
}
type UserService struct {
users UserRepository
orders OrderRepository
}
func (s *UserService) GetProfile(
ctx context.Context,
userID int64,
) (*Profile, error) {
group, groupCtx :=
errgroup.WithContext(ctx)
var user *User
var orders []Order
group.Go(func() error {
result, err :=
s.users.FindByID(
groupCtx,
userID,
)
if err != nil {
return fmt.Errorf(
"query user: %w",
err,
)
}
user = result
return nil
})
group.Go(func() error {
result, err :=
s.orders.FindByUserID(
groupCtx,
userID,
)
if err != nil {
return fmt.Errorf(
"query orders: %w",
err,
)
}
orders = result
return nil
})
if err := group.Wait(); err != nil {
return nil, err
}
return &Profile{
User: user,
Orders: orders,
}, nil
}
这里不存在并发写同一个变量的问题,因为:
go
user
orders
分别由不同 goroutine 写入,而且:
go
group.Wait()
完成后才读取。
Repository
go
package repository
import (
"context"
"database/sql"
)
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) FindByID(
ctx context.Context,
id int64,
) (*User, error) {
var user User
err := r.db.QueryRowContext(
ctx,
`
SELECT
id,
name
FROM users
WHERE id = ?
`,
id,
).Scan(
&user.ID,
&user.Name,
)
if err != nil {
return nil, err
}
return &user, nil
}
Order:
go
type OrderRepository struct {
db *sql.DB
}
func (r *OrderRepository) FindByUserID(
ctx context.Context,
userID int64,
) ([]Order, error) {
rows, err := r.db.QueryContext(
ctx,
`
SELECT
id,
amount
FROM orders
WHERE user_id = ?
`,
userID,
)
if err != nil {
return nil, err
}
defer rows.Close()
orders := make(
[]Order,
0,
)
for rows.Next() {
var order Order
if err := rows.Scan(
&order.ID,
&order.Amount,
); err != nil {
return nil, err
}
orders = append(
orders,
order,
)
}
return orders, rows.Err()
}
最终调用链:
text
Browser
│
▼
HTTP Request
│
▼
r.Context()
│
▼
WithTimeout(3s)
│
▼
UserService
│
├──────────────┐
▼ ▼
UserRepo OrderRepo
│ │
▼ ▼
MySQL MySQL
任何一个地方触发:
text
客户端断开
3 秒超时
Service 失败
Repository 失败
都可以让整条任务链尽快终止。
这才是 Context 在真实 Go 服务中的完整价值。
四十二、如何检测 goroutine 泄漏?
Go 自己就提供了最简单的观察方式:
go
runtime.NumGoroutine()
例如:
go
package main
import (
"fmt"
"runtime"
"time"
)
func leak() {
ch := make(chan struct{})
go func() {
<-ch
}()
}
func main() {
fmt.Println(
"before:",
runtime.NumGoroutine(),
)
for i := 0; i < 1000; i++ {
leak()
}
time.Sleep(time.Second)
fmt.Println(
"after:",
runtime.NumGoroutine(),
)
}
你可能看到:
text
before: 1
after: 1001
这就是非常明显的泄漏信号。
四十三、写一个简单的 goroutine 泄漏测试
例如:
go
func TestNoGoroutineLeak(
t *testing.T,
) {
before := runtime.NumGoroutine()
ctx, cancel := context.WithCancel(
context.Background(),
)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
}()
}
cancel()
wg.Wait()
time.Sleep(
100 * time.Millisecond,
)
after := runtime.NumGoroutine()
if after > before+5 {
t.Fatalf(
"possible goroutine leak: before=%d after=%d",
before,
after,
)
}
}
为什么这里允许:
go
before + 5
而不是要求完全相等?
因为测试环境、runtime、GC、网络库本身都可能存在后台 goroutine。
因此实际测试更关注:
text
是否持续增长
而不是某一次瞬时数量完全一致。
四十四、pprof 排查 goroutine 泄漏
生产环境里更加实用的方法是:
text
pprof
服务中:
go
import (
_ "net/http/pprof"
)
然后:
go
go func() {
http.ListenAndServe(
":6060",
nil,
)
}()
之后可以观察:
text
/debug/pprof/goroutine
或者使用:
bash
go tool pprof
分析 goroutine。
如果发现大量 goroutine 都停在:
go
chan receive
例如:
text
goroutine 12345 [chan receive]:
main.worker(...)
就应该高度怀疑:
text
channel 没有关闭
没有取消机制
等待条件永远不会发生
如果大量停在:
text
chan send
则可能是:
text
生产者仍然在发送
消费者已经退出
这两类是 Go 服务中非常典型的 goroutine leak。
四十五、常见错误一:只给外层 Context,内层不监听
例如:
go
func task(
ctx context.Context,
) error {
expensiveOperation()
return nil
}
虽然参数里有:
go
ctx
但是你完全没有使用它。
那它和不存在几乎没有区别。
真正应该考虑:
go
func task(
ctx context.Context,
) error {
return expensiveOperation(ctx)
}
然后内部继续:
go
func expensiveOperation(
ctx context.Context,
) error {
for i := 0; i < 1000000; i++ {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
calculate(i)
}
return nil
}
对于 CPU 密集型循环,主动检查 Context 是必要的。
四十六、常见错误二:启动 goroutine 后直接 return
例如:
go
func query(
ctx context.Context,
) error {
resultCh := make(chan string)
go func() {
resultCh <- slowQuery()
}()
select {
case <-ctx.Done():
return ctx.Err()
case result := <-resultCh:
fmt.Println(result)
return nil
}
}
如果:
go
ctx.Done()
先执行:
go
return ctx.Err()
那么:
go
resultCh
没有接收者。
后台:
go
resultCh <- slowQuery()
可能永远卡住。
至少应该:
go
resultCh := make(
chan string,
1,
)
进一步最好让:
go
slowQuery(ctx)
也支持 Context。
四十七、常见错误三:发送方不知道接收方已经退出
错误:
go
func producer(
jobs chan<- int,
) {
for i := 0; ; i++ {
jobs <- i
}
}
如果 Consumer 停了:
text
Producer 永久阻塞
正确:
go
func producer(
ctx context.Context,
jobs chan<- int,
) {
for i := 0; ; i++ {
select {
case jobs <- i:
case <-ctx.Done():
return
}
}
}
四十八、常见错误四:忘记关闭 ticker
错误:
go
ticker := time.NewTicker(
time.Second,
)
函数退出时没有:
go
ticker.Stop()
推荐:
go
ticker := time.NewTicker(
time.Second,
)
defer ticker.Stop()
Context 负责:
text
任务生命周期
Ticker 自己的资源同样要释放。
四十九、常见错误五:到处创建 WithTimeout
假设 Handler:
go
ctx, cancel := context.WithTimeout(
r.Context(),
3*time.Second,
)
Service:
go
ctx, cancel := context.WithTimeout(
ctx,
10*time.Second,
)
Repository:
go
ctx, cancel := context.WithTimeout(
ctx,
30*time.Second,
)
虽然不会突破父 Context 的 3 秒限制,但代码会非常混乱。
更加合理的是:
text
接口整体 SLA
由上层控制
真正具有独立超时语义的操作
才单独设置 Timeout
比如接口最大:
text
3 秒
其中调用 Redis 最多:
text
100ms
那么可以:
go
redisCtx, cancel := context.WithTimeout(
ctx,
100*time.Millisecond,
)
defer cancel()
这才是有业务意义的子 Timeout。
五十、Context 超时时间应该怎么设计?
这是生产环境里非常重要的问题。
假设:
text
HTTP 接口 SLA = 3 秒
里面需要:
text
Redis
MySQL
第三方 API
不要全部设置成:
text
3 秒
因为调用链还存在:
text
网络耗时
JSON 编解码
业务逻辑
排队
重试
日志
可以设计成:
text
整个 HTTP Request:3000ms
Redis:100ms
MySQL:800ms
第三方 API:1500ms
预留:600ms
Context 本质上也是一种:
text
时间预算传播机制
上游有:
text
3 秒预算
下游不应该假设自己拥有无限执行时间。
五十一、Deadline 可以用来计算剩余时间
Context 提供:
go
ctx.Deadline()
可以知道剩余时间。
例如:
go
func printRemaining(
ctx context.Context,
) {
deadline, ok :=
ctx.Deadline()
if !ok {
fmt.Println(
"no deadline",
)
return
}
remaining :=
time.Until(deadline)
fmt.Println(
"remaining:",
remaining,
)
}
在微服务调用中非常有用。
比如发现整个请求只剩:
text
50ms
而你知道下一次第三方 API 正常至少:
text
500ms
那就没有必要继续调用。
可以直接:
go
func callService(
ctx context.Context,
) error {
deadline, ok :=
ctx.Deadline()
if ok {
remaining :=
time.Until(deadline)
if remaining <
200*time.Millisecond {
return context.DeadlineExceeded
}
}
return doRequest(ctx)
}
五十二、一个推荐的通用耗时任务模板
我自己比较推荐把支持 Context 的函数写成这种结构:
go
func DoTask(
ctx context.Context,
) error {
if err := ctx.Err(); err != nil {
return err
}
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
finished, err :=
doOneStep(ctx)
if err != nil {
return err
}
if finished {
return nil
}
}
}
这里一开始:
go
ctx.Err()
可以快速判断:
text
这个 Context 是否已经失效
循环过程中持续监听:
go
ctx.Done()
内部操作继续传递:
go
doOneStep(ctx)
于是整个任务都支持取消。
五十三、一个生产者消费者通用模板
Producer:
go
func Producer[T any](
ctx context.Context,
output chan<- T,
produce func() (T, error),
) error {
defer close(output)
for {
if err := ctx.Err(); err != nil {
return err
}
value, err := produce()
if err != nil {
return err
}
select {
case output <- value:
case <-ctx.Done():
return ctx.Err()
}
}
}
Consumer:
go
func Consumer[T any](
ctx context.Context,
input <-chan T,
consume func(T) error,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case value, ok := <-input:
if !ok {
return nil
}
if err := consume(
value,
); err != nil {
return err
}
}
}
}
这种模式可以应用到:
text
消息消费
批量文件处理
数据同步
爬虫
日志处理
任务队列
图片处理
AI 任务
五十四、完整可运行示例:带超时的任务处理系统
最后给一个比较完整的案例。
go
package main
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
type Job struct {
ID int
}
type Result struct {
JobID int
Data string
Err error
}
func processJob(
ctx context.Context,
job Job,
) Result {
duration :=
time.Duration(job.ID%5+1) *
time.Second
timer :=
time.NewTimer(duration)
defer timer.Stop()
select {
case <-timer.C:
return Result{
JobID: job.ID,
Data: fmt.Sprintf(
"job %d finished",
job.ID,
),
}
case <-ctx.Done():
return Result{
JobID: job.ID,
Err: ctx.Err(),
}
}
}
func worker(
ctx context.Context,
id int,
jobs <-chan Job,
results chan<- Result,
wg *sync.WaitGroup,
) {
defer wg.Done()
for {
select {
case <-ctx.Done():
fmt.Printf(
"worker %d stopped\n",
id,
)
return
case job, ok := <-jobs:
if !ok {
fmt.Printf(
"worker %d no more jobs\n",
id,
)
return
}
fmt.Printf(
"worker %d received job %d\n",
id,
job.ID,
)
result :=
processJob(
ctx,
job,
)
select {
case results <- result:
case <-ctx.Done():
return
}
}
}
}
func main() {
ctx, cancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
defer cancel()
jobs := make(
chan Job,
10,
)
results := make(
chan Result,
10,
)
workerCount := 3
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go worker(
ctx,
i,
jobs,
results,
&wg,
)
}
go func() {
defer close(jobs)
for i := 1; i <= 20; i++ {
job := Job{
ID: i,
}
select {
case jobs <- job:
fmt.Println(
"submitted job:",
job.ID,
)
case <-ctx.Done():
fmt.Println(
"producer stopped",
)
return
}
}
}()
workersDone :=
make(chan struct{})
go func() {
wg.Wait()
close(workersDone)
close(results)
}()
for {
select {
case result, ok := <-results:
if !ok {
fmt.Println(
"all results received",
)
return
}
if result.Err != nil {
if errors.Is(
result.Err,
context.Canceled,
) {
fmt.Printf(
"job %d cancelled\n",
result.JobID,
)
continue
}
if errors.Is(
result.Err,
context.DeadlineExceeded,
) {
fmt.Printf(
"job %d timeout\n",
result.JobID,
)
continue
}
fmt.Printf(
"job %d failed: %v\n",
result.JobID,
result.Err,
)
continue
}
fmt.Printf(
"result: %s\n",
result.Data,
)
case <-workersDone:
for result :=
range results {
fmt.Println(
result,
)
}
return
case <-ctx.Done():
fmt.Println(
"main context finished:",
ctx.Err(),
)
<-workersDone
for result :=
range results {
fmt.Println(
result,
)
}
return
}
}
}
这个案例里包含了:
text
Context
WithTimeout
Worker Pool
Producer
Consumer
Channel
WaitGroup
Timer
超时
协程退出
资源回收
可以把它当成理解 Go 并发生命周期管理的一个完整案例。
五十五、写 Go 并发代码时,可以问自己 8 个问题
每次准备写:
go
go func() {
}()
之前,可以先问自己:
- 这个 goroutine 谁创建的?
- 它什么时候正常退出?
- 如果发生错误,它怎么退出?
- 如果调用方已经不需要结果,它怎么退出?
- 如果执行超时,它怎么退出?
- 它阻塞在 channel 时有没有退出机制?
- 它发送 channel 时,接收者退出了怎么办?
- 服务关闭时,它能不能一起结束?
如果这些问题你答不上来:
go
go func() {
}()
就可能埋下一个协程泄漏。
五十六、Context 最佳实践总结
最后把本文内容总结成几个实际开发中非常重要的原则。
1. Context 一般作为第一个参数
go
func Do(
ctx context.Context,
...
)
2. Context 应该沿调用链向下传递
text
Handler
↓
Service
↓
Repository
↓
HTTP / Redis / MySQL
而不是中途:
go
context.Background()
重新开一条链。
3. 创建 CancelFunc 后记得释放
go
ctx, cancel :=
context.WithTimeout(
parent,
3*time.Second,
)
defer cancel()
4. goroutine 必须存在退出条件
至少应该能够监听:
go
ctx.Done()
5. channel 发送和接收都要考虑取消
发送:
go
select {
case ch <- data:
case <-ctx.Done():
return
}
接收:
go
select {
case data := <-ch:
case <-ctx.Done():
return
}
6. HTTP 请求使用 Request Context
go
ctx := r.Context()
7. HTTP Client 使用 NewRequestWithContext
go
req, err :=
http.NewRequestWithContext(
ctx,
http.MethodGet,
url,
nil,
)
8. SQL 使用 Context 版本 API
例如:
go
db.QueryContext()
go
db.QueryRowContext()
go
db.ExecContext()
9. GORM 使用 WithContext
go
db.WithContext(ctx)
10. 并发任务可以考虑 errgroup
go
g, ctx :=
errgroup.WithContext(ctx)
让:
text
错误
取消
WaitGroup
更好地组合在一起。
写在最后
Go 最大的优势之一,就是:
text
并发写起来非常简单
一个:
go
go func() {
}()
就能启动一个新的 goroutine。
但这同样也是 Go 并发编程最容易被忽略的地方。
启动 goroutine 很简单,管理 goroutine 的生命周期才是真正困难的事情。
如果一个协程只知道:
text
什么时候开始
却不知道:
text
什么时候结束
那么它迟早可能成为系统里的隐患。
而 Context 真正解决的,其实不只是:
text
超时
它解决的是整个调用链的:
text
生命周期管理
我们可以把一个请求看成一棵任务树:
text
HTTP Request
│
▼
Context
│
┌────┴────┐
▼ ▼
MySQL Redis
│ │
▼ ▼
HTTP API Worker
当根任务结束:
text
客户端取消
服务超时
系统关闭
业务失败
取消信号沿着 Context 树不断向下传播:
text
Cancel
↓
Service
↓
Repository
↓
Database / HTTP / Worker
最终让那些:
text
已经没有价值的计算
已经没人等待的结果
已经失去意义的请求
尽快停止。
这也是写一个稳定 Go 服务时非常重要的一条原则:
不要只关注 goroutine 如何启动,更要设计清楚它如何结束。
Context,就是 Go 为这个问题提供的标准答案。
现在是最好的时代。中国有全世界最高性价比的制造业,有发达的网络和全球物流。
只要你愿意,你几乎可以买到这个世界上任何地方生产的、任何你想要的商品。你可以用很低的成本,撬动全球的资源为你服务。
不过,真正稀缺的,从来不是商品,而是注意力、判断力,以及把事情做成的能力。
所以,这是最好的时代,也是最坏的时代。
我是王仕宇,关注 AI、Web3 与开源,持续探索如何把技术做成产品,把产品转化为真实价值。