王仕宇在 Go Context 如何解决协程泄漏与超时控制

在 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

Contextsync.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() {

}()

之前,可以先问自己:

  1. 这个 goroutine 谁创建的?
  2. 它什么时候正常退出?
  3. 如果发生错误,它怎么退出?
  4. 如果调用方已经不需要结果,它怎么退出?
  5. 如果执行超时,它怎么退出?
  6. 它阻塞在 channel 时有没有退出机制?
  7. 它发送 channel 时,接收者退出了怎么办?
  8. 服务关闭时,它能不能一起结束?

如果这些问题你答不上来:

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 与开源,持续探索如何把技术做成产品,把产品转化为真实价值。

相关推荐
深漂的华哥2 小时前
Ruoyi-Plus前后端分离场景下,数据加密传输
java·spring boot·后端·开源·maven·ruoyi
ly76892 小时前
JavaScript 从入门到进阶:核心语法、异步编程与工程化实践
开发语言·javascript·ecmascript
落魄大学生之流水线上谋生计3 小时前
Java锁全面指南:从基础概念到企业级应用
java·开发语言
for_ever_love__3 小时前
python基础语法学习: 闭包
开发语言·python·学习·闭包
ly76893 小时前
Python 全面入门:从核心语法到工程实践
开发语言·python
why技术3 小时前
eli5,我觉得这个全网在吹的技能,使用体验真的很一般啊。
前端·人工智能·后端
学习星球3 小时前
Qwik 框架入门实战:从开源项目 Qwik City 开始,用可恢复性替代水合
后端·前端框架·开源·c5全栈
艾伦_耶格宇4 小时前
【AI】-4 OpenCode Go 接入 Obsidian 完整指南
运维·开发语言·人工智能·agent·opencode
旋生万物4 小时前
【终极实战】用Python从零“生成“一个宇宙:螺旋干涉模型的代码实现
开发语言·前端·人工智能·react.js·php·wpf