跟Codex学习go(一)Context

20 阅读15分钟

下面用几个逐步升级的例子,系统理解 Go 里的 context.Context

context 主要解决四类问题:

  1. 取消任务
  2. 设置超时
  3. 设置截止时间
  4. 在调用链中传递请求级数据

核心原则:

context 不是用来传普通业务参数的,而是用来控制一条调用链的生命周期。


一、最基础:手动取消任务

先看最简单的例子。

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker 收到取消信号:", ctx.Err())
			return

		default:
			fmt.Println("worker 正在工作...")
			time.Sleep(time.Second)
		}
	}
}

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

	go worker(ctx)

	time.Sleep(3 * time.Second)

	fmt.Println("main 主动取消任务")
	cancel()

	time.Sleep(time.Second)
}

运行效果大概是:

worker 正在工作...
worker 正在工作...
worker 正在工作...
main 主动取消任务
worker 收到取消信号: context canceled

1. context.Background()

ctx := context.Background()

它是一个空的根上下文,一般用于:

  • main 函数
  • 后台任务
  • 测试代码
  • 调用链的最顶层

它不会被取消,也没有超时。

2. context.WithCancel

ctx, cancel := context.WithCancel(context.Background())

返回两个值:

ctx
cancel

调用:

cancel()

之后:

ctx.Done()

对应的 channel 会被关闭。

因此下面的代码会被触发:

case <-ctx.Done():

3. ctx.Err()

取消后:

ctx.Err()

会返回:

context.Canceled

完整判断也可以这样写:

if err := ctx.Err(); err != nil {
	fmt.Println(err)
}

二、为什么不用普通的 bool 取消

你可能会想到:

stop := false

然后 goroutine 不断检查:

if stop {
	return
}

这种写法有几个问题:

  • 有并发读写风险
  • 需要加锁或使用原子变量
  • 不方便向多个 goroutine 广播
  • 不方便形成父子调用链
  • 没有统一的超时机制

context 的取消信号本质上是关闭一个 channel。

一个 channel 被关闭后,所有监听者都能同时收到通知。


三、一个 Context 取消多个 goroutine

package main

import (
	"context"
	"fmt"
	"time"
)

func worker(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Printf("%s 停止工作,原因:%v\n", name, ctx.Err())
			return

		case <-time.After(time.Second):
			fmt.Printf("%s 正在工作\n", name)
		}
	}
}

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

	go worker(ctx, "worker-1")
	go worker(ctx, "worker-2")
	go worker(ctx, "worker-3")

	time.Sleep(3 * time.Second)

	cancel()

	time.Sleep(time.Second)
}

调用一次:

cancel()

三个 goroutine 都会停止。

这就是 context 很重要的一个特性:

广播式取消。


四、使用 WithTimeout 设置超时

很多时候,我们不希望任务无限执行。

例如:

  • 查询数据库最多允许 2 秒
  • 调用第三方接口最多允许 5 秒
  • 文件处理最多允许 10 秒

可以使用:

context.WithTimeout

示例:

package main

import (
	"context"
	"fmt"
	"time"
)

func slowTask(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		fmt.Println("任务执行完成")
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	err := slowTask(ctx)
	if err != nil {
		fmt.Println("任务失败:", err)
	}
}

输出:

任务失败: context deadline exceeded

因为:

  • 任务本身需要 5 秒
  • context 只允许执行 2 秒
  • 2 秒后自动取消

此时:

ctx.Err()

返回:

context.DeadlineExceeded

五、为什么一定要 defer cancel()

常见写法:

ctx, cancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer cancel()

即使 context 会自动超时,也仍然建议调用:

cancel()

因为 context 内部可能维护:

  • timer
  • 子 context
  • 取消通知
  • 相关资源

如果函数提前结束,调用 cancel() 可以尽早释放资源。

正确模式:

ctx, cancel := context.WithTimeout(parentCtx, time.Second)
defer cancel()

六、WithDeadline:设置具体截止时间

WithTimeout 表示:

从现在开始,最多运行多久

WithDeadline 表示:

运行到某个具体时间点

示例:

deadline := time.Now().Add(3 * time.Second)

ctx, cancel := context.WithDeadline(
	context.Background(),
	deadline,
)
defer cancel()

完整示例:

package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	deadline := time.Now().Add(3 * time.Second)

	ctx, cancel := context.WithDeadline(
		context.Background(),
		deadline,
	)
	defer cancel()

	fmt.Println("截止时间:", deadline.Format("15:04:05"))

	<-ctx.Done()

	fmt.Println("context 结束:", ctx.Err())
}

三秒后:

context 结束: context deadline exceeded

实际项目中,WithTimeout 使用得更多。


七、Context 在函数调用链中传递

真实项目不会只有一个函数,通常是:

Controller
    ↓
Service
    ↓
Repository
    ↓
Database

context 应该沿着整条调用链传递。

示例:

package main

import (
	"context"
	"fmt"
	"time"
)

func controller(ctx context.Context) {
	err := service(ctx)
	if err != nil {
		fmt.Println("controller:", err)
	}
}

func service(ctx context.Context) error {
	return repository(ctx)
}

func repository(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		fmt.Println("数据库查询完成")
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	controller(ctx)
}

执行过程:

main 创建 context
    ↓
controller 接收 context
    ↓
service 继续传递
    ↓
repository 监听 context

两秒后,最底层的 repository 也能收到取消信号。

这就是 context 的核心价值:

在整个调用链中传递取消和超时信号。


八、HTTP 请求中的 Context

在 Go HTTP 服务中,每个请求本身就带有一个 context:

r.Context()

示例:

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func queryData(ctx context.Context) error {
	select {
	case <-time.After(5 * time.Second):
		return nil

	case <-ctx.Done():
		return ctx.Err()
	}
}

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()

	err := queryData(ctx)
	if err != nil {
		http.Error(w, err.Error(), http.StatusRequestTimeout)
		return
	}

	fmt.Fprintln(w, "查询成功")
}

func main() {
	http.HandleFunc("/data", handler)
	http.ListenAndServe(":8080", nil)
}

当发生以下情况时,请求 context 可能被取消:

  • 客户端主动断开连接
  • 请求被取消
  • HTTP Server 超时
  • 上游 context 被取消

因此,业务函数应该优先使用:

r.Context()

而不是重新创建:

context.Background()

错误写法:

func handler(w http.ResponseWriter, r *http.Request) {
	// 错误:丢失了 HTTP 请求原本的取消信号
	ctx := context.Background()

	queryData(ctx)
}

正确写法:

ctx := r.Context()

九、Gin 中如何使用 Context

你使用的是 Gin,需要注意两个不同的 context:

*gin.Context

和:

context.Context

它们不是同一个类型。

Gin Controller 中可以这样获取标准 context:

func GetUser(c *gin.Context) {
	ctx := c.Request.Context()

	user, err := userService.GetUser(ctx, 1)
	if err != nil {
		c.JSON(500, gin.H{
			"message": err.Error(),
		})
		return
	}

	c.JSON(200, user)
}

Service:

func (s *UserService) GetUser(
	ctx context.Context,
	id uint,
) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

Repository:

func (r *UserRepository) GetUser(
	ctx context.Context,
	id uint,
) (*User, error) {
	var user User

	err := r.db.
		WithContext(ctx).
		First(&user, id).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

这里最关键的是:

db.WithContext(ctx)

这样,当 HTTP 请求被取消时,数据库操作也有机会停止。


十、GORM 中的 Context

你现在经常使用 GORM。

普通查询:

db.Where("id = ?", id).First(&user)

带 context:

db.WithContext(ctx).
	Where("id = ?", id).
	First(&user)

完整例子:

func GetUserByID(
	ctx context.Context,
	db *gorm.DB,
	id uint,
) (*User, error) {
	var user User

	err := db.
		WithContext(ctx).
		Where("id = ?", id).
		First(&user).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

还可以额外设置数据库查询超时:

func GetUserByID(
	parentCtx context.Context,
	db *gorm.DB,
	id uint,
) (*User, error) {
	ctx, cancel := context.WithTimeout(
		parentCtx,
		2*time.Second,
	)
	defer cancel()

	var user User

	err := db.
		WithContext(ctx).
		Where("id = ?", id).
		First(&user).
		Error

	if err != nil {
		return nil, err
	}

	return &user, nil
}

这样即使 HTTP 请求没有取消,数据库查询超过两秒也会被取消。


十一、WithValue:传递请求级数据

Context 还可以传递少量请求级数据。

例如:

  • request ID
  • trace ID
  • 当前用户 ID
  • 链路追踪信息
  • 日志字段

示例:

package main

import (
	"context"
	"fmt"
)

type contextKey string

const userIDKey contextKey = "userID"

func service(ctx context.Context) {
	userID, ok := ctx.Value(userIDKey).(uint)
	if !ok {
		fmt.Println("没有找到 userID")
		return
	}

	fmt.Println("当前用户 ID:", userID)
}

func main() {
	ctx := context.WithValue(
		context.Background(),
		userIDKey,
		uint(1001),
	)

	service(ctx)
}

输出:

当前用户 ID: 1001

不要直接使用字符串作为 key

不推荐:

ctx := context.WithValue(ctx, "userID", 1001)

因为不同包可能使用同样的字符串,发生冲突。

推荐自定义类型:

type contextKey string

const userIDKey contextKey = "userID"

更严谨的写法:

type contextKey struct {
	name string
}

var userIDKey = contextKey{name: "userID"}

十二、封装 Context Value

不要在业务代码里到处写:

ctx.Value(userIDKey).(uint)

建议封装。

package requestctx

import "context"

type key struct {
	name string
}

var userIDKey = key{name: "userID"}

func WithUserID(ctx context.Context, userID uint) context.Context {
	return context.WithValue(ctx, userIDKey, userID)
}

func UserID(ctx context.Context) (uint, bool) {
	userID, ok := ctx.Value(userIDKey).(uint)
	return userID, ok
}

使用:

ctx = requestctx.WithUserID(ctx, 1001)

userID, ok := requestctx.UserID(ctx)

这样可以避免:

  • key 冲突
  • 类型断言散落
  • 不同模块使用不同 key
  • 后续修改困难

十三、Gin 中间件传递用户信息

假设 JWT 中间件解析出用户 ID。

可以把用户 ID 放到标准 context 中。

package middleware

import (
	"context"

	"github.com/gin-gonic/gin"
)

type contextKey struct {
	name string
}

var userIDKey = contextKey{name: "userID"}

func JWTAuth() gin.HandlerFunc {
	return func(c *gin.Context) {
		// 示例:实际项目中从 JWT 解析
		userID := uint(1001)

		ctx := context.WithValue(
			c.Request.Context(),
			userIDKey,
			userID,
		)

		c.Request = c.Request.WithContext(ctx)

		c.Next()
	}
}

Controller:

func GetProfile(c *gin.Context) {
	ctx := c.Request.Context()

	userID, ok := ctx.Value(userIDKey).(uint)
	if !ok {
		c.JSON(401, gin.H{
			"message": "未获取到用户信息",
		})
		return
	}

	c.JSON(200, gin.H{
		"user_id": userID,
	})
}

不过在 Gin 项目里,简单数据也经常使用:

c.Set("userID", userID)

然后:

value, exists := c.Get("userID")

二者区别:

  • gin.Context 数据主要在 Controller 和中间件之间使用
  • context.Context 数据可以继续传到 Service、Repository、数据库和外部请求

如果用户信息需要深入调用链,标准 context.Context 更合适。


十四、父子 Context 的传播关系

Context 可以形成树结构。

rootCtx
   ├── childCtx1
   └── childCtx2
          └── grandChildCtx

示例:

package main

import (
	"context"
	"fmt"
	"time"
)

func watch(ctx context.Context, name string) {
	<-ctx.Done()
	fmt.Printf("%s 被取消: %v\n", name, ctx.Err())
}

func main() {
	parentCtx, parentCancel := context.WithCancel(
		context.Background(),
	)

	childCtx, childCancel := context.WithCancel(parentCtx)

	go watch(parentCtx, "parent")
	go watch(childCtx, "child")

	time.Sleep(time.Second)

	fmt.Println("取消 parent")
	parentCancel()

	time.Sleep(time.Second)

	childCancel()
}

取消父 context:

parentCancel()

会同时取消:

  • parent
  • child
  • child 的所有后代

但取消 child:

childCancel()

不会影响 parent。

规则是:

父 context 取消,会向下传播;子 context 取消,不会向上传播。


十五、子 Context 的超时不能超过父 Context

示例:

parentCtx, parentCancel := context.WithTimeout(
	context.Background(),
	2*time.Second,
)
defer parentCancel()

childCtx, childCancel := context.WithTimeout(
	parentCtx,
	10*time.Second,
)
defer childCancel()

虽然 child 设置了 10 秒,但父 context 只有 2 秒。

最终 child 最多只能活 2 秒。

可以理解为:

实际截止时间 = 父截止时间和子截止时间中更早的那个

十六、可取消的循环任务

这是项目中非常常见的写法。

func runMonitor(ctx context.Context) {
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ticker.C:
			fmt.Println("采集设备数据")

		case <-ctx.Done():
			fmt.Println("停止采集:", ctx.Err())
			return
		}
	}
}

启动:

ctx, cancel := context.WithCancel(context.Background())

go runMonitor(ctx)

time.Sleep(5 * time.Second)

cancel()

这适合:

  • MQTT 数据采集
  • 设备监控
  • 定时同步
  • WebSocket 心跳
  • 后台轮询
  • 消息消费

十七、错误示例:在循环里使用 time.Sleep

下面代码虽然可以工作,但取消响应不及时:

func worker(ctx context.Context) {
	for {
		if ctx.Err() != nil {
			return
		}

		doWork()

		time.Sleep(30 * time.Second)
	}
}

假设刚进入 Sleep 后调用了:

cancel()

任务仍然要等待最多 30 秒才能退出。

更好的写法:

func worker(ctx context.Context) {
	ticker := time.NewTicker(30 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return

		case <-ticker.C:
			doWork()
		}
	}
}

这样收到取消后,可以立即退出。


十八、HTTP 客户端请求中使用 Context

调用第三方接口时,也应该传入 context。

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	req, err := http.NewRequestWithContext(
		ctx,
		http.MethodGet,
		"https://example.com",
		nil,
	)
	if err != nil {
		panic(err)
	}

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		fmt.Println("请求失败:", err)
		return
	}
	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("读取响应失败:", err)
		return
	}

	fmt.Println(string(body))
}

关键代码:

http.NewRequestWithContext

当 context 超时或取消时,HTTP 请求也会终止。


十九、并发任务:任意一个失败,取消其他任务

假设同时执行三个任务:

  • 查询用户
  • 查询订单
  • 查询权限

只要一个失败,其余任务就没有继续执行的必要。

基础写法:

package main

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"
)

func task(ctx context.Context, name string, duration time.Duration) error {
	select {
	case <-time.After(duration):
		fmt.Println(name, "完成")
		return nil

	case <-ctx.Done():
		fmt.Println(name, "被取消")
		return ctx.Err()
	}
}

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

	var wg sync.WaitGroup

	errChan := make(chan error, 3)

	wg.Add(3)

	go func() {
		defer wg.Done()

		err := task(ctx, "用户查询", 2*time.Second)
		if err != nil {
			errChan <- err
			cancel()
		}
	}()

	go func() {
		defer wg.Done()

		err := task(ctx, "订单查询", 5*time.Second)
		if err != nil {
			errChan <- err
			cancel()
		}
	}()

	go func() {
		defer wg.Done()

		time.Sleep(time.Second)

		errChan <- errors.New("权限查询失败")
		cancel()
	}()

	wg.Wait()
	close(errChan)

	for err := range errChan {
		fmt.Println("错误:", err)
	}
}

不过在生产项目里,更推荐使用:

golang.org/x/sync/errgroup

二十、errgroup:并发任务的标准写法

package main

import (
	"context"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

func task(
	ctx context.Context,
	name string,
	duration time.Duration,
	shouldFail bool,
) error {
	select {
	case <-time.After(duration):
		if shouldFail {
			return fmt.Errorf("%s 执行失败", name)
		}

		fmt.Println(name, "执行成功")
		return nil

	case <-ctx.Done():
		fmt.Println(name, "被取消")
		return ctx.Err()
	}
}

func main() {
	ctx := context.Background()

	group, ctx := errgroup.WithContext(ctx)

	group.Go(func() error {
		return task(ctx, "任务A", 2*time.Second, false)
	})

	group.Go(func() error {
		return task(ctx, "任务B", time.Second, true)
	})

	group.Go(func() error {
		return task(ctx, "任务C", 5*time.Second, false)
	})

	if err := group.Wait(); err != nil {
		fmt.Println("任务组失败:", err)
	}
}

当任务 B 失败后:

任务B 执行失败

errgroup 会自动取消关联的 context。

任务 A、任务 C 如果还没完成,会收到取消信号。

这是非常实用的并发模式。


二十一、Context 和 Channel 如何配合

Context 负责取消,channel 负责传输数据。

示例:

package main

import (
	"context"
	"fmt"
	"time"
)

func producer(
	ctx context.Context,
	resultChan chan<- int,
) {
	defer close(resultChan)

	for i := 1; i <= 100; i++ {
		select {
		case <-ctx.Done():
			fmt.Println("生产者退出:", ctx.Err())
			return

		case resultChan <- i:
			time.Sleep(200 * time.Millisecond)
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(
		context.Background(),
		2*time.Second,
	)
	defer cancel()

	resultChan := make(chan int)

	go producer(ctx, resultChan)

	for value := range resultChan {
		fmt.Println("收到数据:", value)
	}
}

注意生产者发送数据时也要监听取消:

select {
case <-ctx.Done():
	return

case resultChan <- value:
}

否则如果没有消费者,生产者可能永久阻塞在:

resultChan <- value

即使 context 已经取消,也无法退出。


二十二、典型错误:创建 goroutine 后不监听 Context

错误写法:

func worker(ctx context.Context) {
	for {
		doWork()
	}
}

即使上层调用:

cancel()

这个 goroutine 也不会停止。

Context 不会强制杀死 goroutine。

它只是发出一个取消信号。

任务必须主动监听:

select {
case <-ctx.Done():
	return
default:
}

这是理解 context 最重要的一点:

Context 是协作式取消,不是强制中断。


二十三、典型错误:把 Context 放到结构体里

一般不推荐:

type UserService struct {
	ctx context.Context
}

然后:

service.ctx

因为 context 通常对应某一次请求。

如果把它存进长期存在的 Service 结构体里,可能导致:

  • 不同请求共用 context
  • 请求数据串扰
  • 旧 context 已取消
  • 生命周期混乱

推荐作为函数第一个参数传递:

func (s *UserService) GetUser(
	ctx context.Context,
	id uint,
) (*User, error)

标准约定:

func DoSomething(ctx context.Context, ...)

二十四、典型错误:传递 nil Context

不要这样:

service.GetUser(nil, 1)

标准库很多函数不接受 nil context。

没有合适的 context 时使用:

context.Background()

或者:

context.TODO()

二十五、Background 和 TODO 的区别

context.Background()

表示你明确知道这是一个根 context。

ctx := context.Background()

context.TODO()

表示这里未来应该传入一个合适的 context,但目前还没有完成改造。

ctx := context.TODO()

例如旧代码迁移:

func oldFunction() {
	ctx := context.TODO()

	newFunction(ctx)
}

生产代码中不要长期大量使用 TODO()

它更像一个标记:

这里以后还需要完善。


二十六、Context 错误判断

常见错误有两个:

context.Canceled
context.DeadlineExceeded

建议使用:

errors.Is

示例:

if err != nil {
	switch {
	case errors.Is(err, context.Canceled):
		fmt.Println("任务被主动取消")

	case errors.Is(err, context.DeadlineExceeded):
		fmt.Println("任务执行超时")

	default:
		fmt.Println("其他错误:", err)
	}
}

不要只判断字符串:

if err.Error() == "context canceled"

字符串判断不可靠。


二十七、在 Service 中如何正确处理 Context 错误

假设数据库返回:

context deadline exceeded

一般不应该把它包装得完全无法识别。

可以这样:

func (s *UserService) GetUser(
	ctx context.Context,
	id uint,
) (*User, error) {
	user, err := s.repo.GetUser(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("查询用户失败: %w", err)
	}

	return user, nil
}

这里使用:

%w

保留原始错误链。

上层依然可以判断:

errors.Is(err, context.DeadlineExceeded)

二十八、Gin + GORM 完整示例

下面给一个比较接近你实际项目的写法。

Model

package model

import "time"

type User struct {
	ID        uint      `gorm:"primaryKey" json:"id"`
	Username  string    `gorm:"size:64;not null" json:"username"`
	Nickname  string    `gorm:"size:64" json:"nickname"`
	Status    uint8     `json:"status"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

Repository

package repository

import (
	"context"
	"errors"

	"gorm.io/gorm"

	"your-project/model"
)

type UserRepository struct {
	db *gorm.DB
}

func NewUserRepository(db *gorm.DB) *UserRepository {
	return &UserRepository{
		db: db,
	}
}

func (r *UserRepository) GetByID(
	ctx context.Context,
	id uint,
) (*model.User, error) {
	var user model.User

	err := r.db.
		WithContext(ctx).
		First(&user, id).
		Error

	if err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return nil, nil
		}

		return nil, err
	}

	return &user, nil
}

Service

package service

import (
	"context"
	"errors"
	"fmt"
	"time"

	"your-project/model"
	"your-project/repository"
)

var ErrUserNotFound = errors.New("用户不存在")

type UserService struct {
	userRepo *repository.UserRepository
}

func NewUserService(
	userRepo *repository.UserRepository,
) *UserService {
	return &UserService{
		userRepo: userRepo,
	}
}

func (s *UserService) GetByID(
	parentCtx context.Context,
	id uint,
) (*model.User, error) {
	ctx, cancel := context.WithTimeout(
		parentCtx,
		3*time.Second,
	)
	defer cancel()

	user, err := s.userRepo.GetByID(ctx, id)
	if err != nil {
		return nil, fmt.Errorf("查询用户失败: %w", err)
	}

	if user == nil {
		return nil, ErrUserNotFound
	}

	return user, nil
}

Controller

package controller

import (
	"context"
	"errors"
	"net/http"
	"strconv"

	"github.com/gin-gonic/gin"

	"your-project/service"
)

type UserController struct {
	userService *service.UserService
}

func NewUserController(
	userService *service.UserService,
) *UserController {
	return &UserController{
		userService: userService,
	}
}

func (ctl *UserController) GetByID(c *gin.Context) {
	idValue := c.Param("id")

	id64, err := strconv.ParseUint(idValue, 10, 64)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{
			"message": "用户 ID 不合法",
		})
		return
	}

	ctx := c.Request.Context()

	user, err := ctl.userService.GetByID(ctx, uint(id64))
	if err != nil {
		switch {
		case errors.Is(err, service.ErrUserNotFound):
			c.JSON(http.StatusNotFound, gin.H{
				"message": "用户不存在",
			})

		case errors.Is(err, context.Canceled):
			c.JSON(499, gin.H{
				"message": "请求已取消",
			})

		case errors.Is(err, context.DeadlineExceeded):
			c.JSON(http.StatusGatewayTimeout, gin.H{
				"message": "查询超时",
			})

		default:
			c.JSON(http.StatusInternalServerError, gin.H{
				"message": "服务器内部错误",
			})
		}

		return
	}

	c.JSON(http.StatusOK, gin.H{
		"data": user,
	})
}

这条链路是:

Gin Request Context
        ↓
Controller
        ↓
Service 增加 3 秒超时
        ↓
Repository
        ↓
GORM WithContext
        ↓
数据库驱动

二十九、后台任务与请求 Context 的区别

假设用户调用接口后,你启动一个需要长期执行的任务:

func handler(c *gin.Context) {
	ctx := c.Request.Context()

	go generateReport(ctx)

	c.JSON(200, gin.H{
		"message": "任务已启动",
	})
}

这里可能有问题。

HTTP 请求结束后:

c.Request.Context()

通常会被取消。

于是后台任务可能立刻停止。

如果这个任务必须脱离 HTTP 请求继续执行,就不应该直接使用请求 context。

可以使用:

go generateReport(context.Background())

但这样又失去了统一关闭能力。

更合理的是,应用启动时创建应用级 context:

appCtx, appCancel := context.WithCancel(context.Background())

后台任务使用:

go generateReport(appCtx)

服务关闭时:

appCancel()

判断标准:

  • 任务属于当前请求:使用 r.Context()
  • 任务属于整个应用:使用应用级 context
  • 任务属于某个批次:为该批次创建专用 context

三十、优雅关闭服务器

Context 也常用于服务优雅关闭。

package main

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	server := &http.Server{
		Addr: ":8080",
	}

	go func() {
		fmt.Println("服务器启动:", server.Addr)

		err := server.ListenAndServe()
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			panic(err)
		}
	}()

	signalCtx, stop := signal.NotifyContext(
		context.Background(),
		os.Interrupt,
		syscall.SIGTERM,
	)
	defer stop()

	<-signalCtx.Done()

	fmt.Println("收到退出信号,开始关闭服务")

	shutdownCtx, cancel := context.WithTimeout(
		context.Background(),
		10*time.Second,
	)
	defer cancel()

	if err := server.Shutdown(shutdownCtx); err != nil {
		fmt.Println("关闭服务失败:", err)
		return
	}

	fmt.Println("服务器已安全关闭")
}

这里有两个 context:

signalCtx

用于等待系统信号:

  • Ctrl+C
  • SIGTERM
shutdownCtx

用于限制服务器关闭时间,最多等待 10 秒。


三十一、Context 的四个核心方法

context.Context 接口本身非常简单:

type Context interface {
	Deadline() (deadline time.Time, ok bool)

	Done() <-chan struct{}

	Err() error

	Value(key any) any
}

Deadline

获取截止时间:

deadline, ok := ctx.Deadline()

Done

返回取消通知 channel:

<-ctx.Done()

Err

返回取消原因:

ctx.Err()

Value

获取请求级数据:

ctx.Value(key)

三十二、什么时候需要在函数中检查 Context

如果函数调用的是支持 context 的底层 API,例如:

db.WithContext(ctx)
http.NewRequestWithContext(ctx, ...)

底层通常会帮你监听。

但如果你自己写的是 CPU 密集型循环,需要主动检查。

例如:

func calculate(ctx context.Context, count int) error {
	for i := 0; i < count; i++ {
		if i%1000 == 0 {
			select {
			case <-ctx.Done():
				return ctx.Err()

			default:
			}
		}

		// 执行计算
	}

	return nil
}

不要每次循环都检查,否则极端情况下会增加开销。

可以每隔一定次数检查一次。


三十三、生产级最佳实践

1. Context 放在第一个参数

推荐:

func QueryUser(
	ctx context.Context,
	id uint,
) error

不推荐:

func QueryUser(
	id uint,
	ctx context.Context,
) error

2. 不要把 Context 设为可选参数

不推荐:

func QueryUser(id uint, ctx ...context.Context)

直接明确传入。

3. 不要传 nil

使用:

context.Background()

4. 创建子 Context 后及时 cancel

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

5. 不要用 Context 传业务参数

不推荐:

context.WithValue(ctx, "page", 1)
context.WithValue(ctx, "pageSize", 20)
context.WithValue(ctx, "username", "admin")

这些应该使用结构体:

type UserQuery struct {
	Page     int
	PageSize int
	Username string
}

然后:

func ListUsers(
	ctx context.Context,
	query UserQuery,
) error

6. Value 只传请求级元数据

适合:

  • user ID
  • trace ID
  • request ID
  • tenant ID
  • 日志字段

不适合:

  • 数据库连接
  • Service 对象
  • 页面参数
  • 大结构体
  • 可选配置集合

7. 不要主动调用 ctx.Done() 对应 channel 的 close

你只能调用:

cancel()

不能关闭:

close(ctx.Done())

因为 Done() 返回的是只读 channel:

<-chan struct{}

三十四、用一句话理解 Context

可以把 context 想象成一张“任务通行证”。

这张通行证上可能包含:

任务什么时候到期
任务是否已取消
为什么被取消
当前请求属于谁
当前请求的追踪编号

它会从上层一直传到下层:

HTTP 请求
  ↓
Controller
  ↓
Service
  ↓
Repository
  ↓
数据库 / 第三方 API

任何一层发现通行证失效,都应该尽快停止工作。


三十五、建议你按这个顺序练习

先掌握这五个例子:

1. WithCancel 手动取消 goroutine
2. WithTimeout 自动超时
3. 父 Context 取消多个子任务
4. Gin Request.Context 传到 GORM
5. errgroup 并发任务失败后统一取消

最终记住这个标准模板:

func DoSomething(
	ctx context.Context,
) error {
	select {
	case <-ctx.Done():
		return ctx.Err()

	default:
	}

	return nil
}

以及数据库模板:

func Query(
	ctx context.Context,
	db *gorm.DB,
) error {
	return db.
		WithContext(ctx).
		Find(&result).
		Error
}

以及超时模板:

ctx, cancel := context.WithTimeout(
	parentCtx,
	3*time.Second,
)
defer cancel()

掌握这三个模板,已经能覆盖大部分 Go 项目中的 context 使用场景。