一、并发编程

2021-02-17T14:21:02+08:00 | 48分钟阅读 | 更新于 2021-02-17T14:21:02+08:00

@

学习目标

学完本章你应该能够:

  1. 说清 context.Context 四个方法(Deadline/Done/Err/Value)的用途,并解释"父取消子也取消、子不能控制父"的父子传播规则。
  2. 用 Mutex / RWMutex 保护共享变量、识别数据竞争;理解 double-check(双重检查)模式在 SafeMap、sync.Once 中的通用套路。
  3. 解释 Go Mutex 的"正常模式/饥饿模式"、sync.Pool 借助 GMP 中每 P 一个 local 实现无锁快路径的设计动机。
  4. 用 WaitGroup / errgroup 编排一组 goroutine,并用 channel 实现生产者-消费者、令牌限流、发布订阅、任务池等并发模型。
  5. 综合前述原语,实现一个支持超时取消、能优雅退出的服务器(本篇实战主题)。

前置知识

  • Go 基础语法、mapstruct 用法;
  • 对"多核并行"“线程"概念有直观认识即可,无需并发经验;
  • 了解 Go 的 GMP 调度概念(P 即处理器)会更有帮助,但文中会补充。

本章你会动手做的事

  1. context.WithTimeout 改写一个 3 秒的 DB 查询,使其在 2 秒超时被取消,并用 select-case 观察"谁先到”。
  2. sync.Pool 复用 bytes.Buffer,对比 Get/Put 前后对象是否被复用,并验证 Reset 的必要性。
  3. 用 channel 写一个带缓冲的任务池(TaskPool),提交 10 个任务、限制 3 个 worker 并发,观察完成顺序。

前言:为什么需要并发编程

在单核 CPU 时代,程序是"排队执行"的——一个任务做完了,再做下一个。但随着多核 CPU 的普及,如果还让程序串行执行,那就相当于有一条八车道的高速公路,却只开了一条车道,白白浪费了性能。

Go 语言从设计之初就把并发作为一等公民,提供了两大并发原语:

  • goroutine:轻量级的"协程",比操作系统线程更轻量,创建一个 goroutine 只需几 KB 内存
  • channel:goroutine 之间安全传递数据的"管道"

但在实际开发中,光有 goroutine 和 channel 还不够。当多个 goroutine 同时访问共享资源时,就会产生数据竞争(Data Race)问题。为了解决这个问题,Go 提供了 sync 包和 context 包,它们是并发编程的核心工具箱。

本教程将从零开始,带你理解 Go 并发编程的四大核心主题:

  1. context 包——控制 goroutine 的生命周期和传递数据
  2. sync 包(Mutex / RWMutex / Once / Pool / WaitGroup)——保护共享资源
  3. channel 编程——goroutine 间通信
  4. 综合实战——服务器优雅退出

一、context 包——并发控制的灵魂

1.1 context 是什么

想象一个场景:你发起了一个 HTTP 请求,这个请求内部又调用了数据库查询、RPC 调用、缓存查询等多个子操作。如果用户中途取消了请求(比如关掉了浏览器),你希望所有正在执行的子操作都能立刻停下来,而不是继续白白消耗资源。

这就是 context.Context 要解决的核心问题:在 goroutine 树中传递取消信号、超时控制,以及请求范围内的数据

1.2 Context 接口

context.Context 是一个接口,只定义了四个方法:

// context 包的核心接口
// Context 接口定义了四个方法,每个方法都有特定的用途
type Context interface {
    // Deadline 返回两个值:
    //   deadline:过期时间点(time.Time)
    //   ok:是否设置了过期时间。false 表示没有设置过期时间
    // 用途:在执行耗时操作前,可以先检查是否快过期了,避免做无用功
    Deadline() (deadline time.Time, ok bool)

    // Done 返回一个 channel
    // 当 Context 被取消或过期时,这个 channel 会被关闭(close)
    // 用法:配合 select-case 监听,一旦 channel 关闭就退出当前操作
    // 这是最常用的方法,几乎所有超时控制都依赖它
    Done() <-chan struct{}

    // Err 返回 Context 被取消的原因
    //   context.Canceled:被主动取消(调用了 cancel 函数)
    //   context.DeadlineExceeded:超时过期
    // 当 Done() 的 channel 还没关闭时,Err() 返回 nil
    Err() error

    // Value 根据 key 获取 Context 中存储的值
    // 用途:在请求链路中传递数据,如 trace ID、用户信息等
    // 注意:key 必须是可比较的类型,通常用自定义类型的指针
    Value(key any) any
}

新手提示:不需要死记硬背这四个方法。实际开发中,最常用的是 Done()(监听取消信号)和 Value()(传递数据),用到的时候再回来查就行。

1.3 context 的四个核心 API

context 包提供了四个创建函数,它们都返回一个新的 Context 实例:

package main

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

func main() {
    // ==================== 1. context.WithValue ====================
    // WithValue 在父 Context 的基础上,附加一个 key-value 对
    // 返回一个新的 Context(原 Context 不受影响)
    // 用途:在请求链路中传递数据,如 trace ID、用户 ID 等

    // 创建根 Context(context.Background() 返回一个空的 Context)
    ctx := context.Background()

    // 往 Context 中存入一个 key-value
    // key 用自定义类型,避免和其他人的 key 冲突
    type ctxKey string
    ctx = context.WithValue(ctx, ctxKey("userID"), 12345)

    // 从 Context 中取出值
    // 注意:返回值是 any 类型,需要做类型断言
    val := ctx.Value(ctxKey("userID"))
    fmt.Printf("userID: %v\n", val) // 输出: userID: 12345

    // ==================== 2. context.WithCancel ====================
    // WithCancel 返回一个可取消的 Context 和一个取消函数 cancel
    // 调用 cancel() 后,ctx.Done() 返回的 channel 会被关闭
    // 用途:手动控制取消时机

    ctx2, cancel := context.WithCancel(context.Background())
    // 启动一个 goroutine,监听取消信号
    go func() {
        select {
        case <-ctx2.Done():
            // 当 cancel() 被调用时,Done() 的 channel 关闭,这里就会执行
            fmt.Println("收到取消信号,停止工作")
        }
    }()
    // 模拟做一些工作
    time.Sleep(100 * time.Millisecond)
    // 主动取消
    cancel()
    time.Sleep(50 * time.Millisecond) // 等待 goroutine 打印

    // ==================== 3. context.WithDeadline ====================
    // WithDeadline 在指定的时间点过期
    // 参数 deadline 是一个绝对时间点
    ctx3, cancel3 := context.WithDeadline(context.Background(), time.Now().Add(2*time.Second))
    defer cancel3() // 好习惯:即使没用也要 defer cancel,释放资源

    select {
    case <-ctx3.Done():
        fmt.Println("到达截止时间:", ctx3.Err()) // 输出: context deadline exceeded
    }

    // ==================== 4. context.WithTimeout ====================
    // WithTimeout 在指定的时间段后过期
    // 本质上和 WithDeadline 一样,只是参数从"时间点"变成了"时间段"
    // WithTimeout(parent, d) 等价于 WithDeadline(parent, time.Now().Add(d))
    ctx4, cancel4 := context.WithTimeout(context.Background(), 500*time.Millisecond)
    defer cancel4()

    select {
    case <-ctx4.Done():
        fmt.Println("超时了:", ctx4.Err()) // 输出: context deadline exceeded
    }
}

关键理解:Context 实例是不可变的。每次调用 WithValueWithCancel 等方法,都不会修改原 Context,而是创建一个新的 Context 实例。这就像 Git 的分支——你在新分支上操作,不影响主分支。

1.4 安全传递数据

Go 语言没有 Java 那样的 ThreadLocal 机制(每个线程有自己的局部变量存储)。在 Go 中,如果一个请求涉及多个函数调用,需要在它们之间传递数据,最标准的方式就是通过 context.Context

package main

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

// ==================== 定义 Context Key ====================
// 用自定义类型作为 key,避免不同包之间的 key 冲突
// 这是一个未导出的类型(小写开头),只有当前包能用
type traceIDKey string

// 定义 key 常量
const TraceIDKey traceIDKey = "trace_id"

// ==================== 中间件:注入 trace ID ====================
// 在 HTTP 请求进来时,生成一个 trace ID 并存入 Context
func traceMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 生成一个唯一的 trace ID(实际项目中可以用 UUID)
        traceID := "trace-" + r.RemoteAddr

        // 将 trace ID 存入 Context
        // r.Context() 是 Go 1.7+ 提供的,返回请求自带的 Context
        ctx := context.WithValue(r.Context(), TraceIDKey, traceID)

        // 创建一个新的请求,带上新的 Context
        // 因为 http.Request 的 Context 是不可变的,只能创建新请求
        r = r.WithContext(ctx)

        // 继续处理
        next.ServeHTTP(w, r)
    })
}

// ==================== 业务处理函数 ====================
func handleUser(w http.ResponseWriter, r *http.Request) {
    // 从 Context 中取出 trace ID
    // 注意要做类型断言,因为 Value() 返回 any 类型
    traceID, ok := r.Context().Value(TraceIDKey).(string)
    if !ok {
        traceID = "unknown"
    }

    // 调用数据库查询,把 trace ID 传递下去
    // 这就是"安全传递数据"——通过 Context 一路传下去
    user, err := queryUser(r.Context(), 1)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    fmt.Fprintf(w, "traceID=%s, user=%+v\n", traceID, user)
}

// ==================== 数据库查询函数 ====================
// 所有公共方法都应该接受 context.Context 作为第一个参数
// 这样可以在整条调用链中传递 Context
func queryUser(ctx context.Context, userID int) (string, error) {
    // 数据库查询时也可以用到 trace ID(比如记录日志)
    traceID, _ := ctx.Value(TraceIDKey).(string)
    fmt.Printf("[DB] traceID=%s, 正在查询用户 %d\n", traceID, userID)

    // 模拟数据库查询
    // 实际项目中,这里会检查 ctx.Done() 是否已关闭
    select {
    case <-ctx.Done():
        // Context 被取消(比如客户端断开了连接),直接返回
        return "", ctx.Err()
    default:
        // 正常执行查询
    }

    return "张三", nil
}

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/user", handleUser)

    // 使用中间件包裹
    http.ListenAndServe(":8080", traceMiddleware(mux))
}

常见使用场景

  • 链路追踪的 trace ID
  • AB 测试的标记位
  • 压力测试标记位
  • 分库分表中间件中传递 sharding hint
  • ORM 中间件传递 SQL hint
  • Web 框架传递上下文

1.5 父子关系

Context 实例之间存在父子关系,这是理解 context 工作原理的关键:

package main

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

func main() {
    // 父 Context(根节点)
    // context.Background() 是所有 Context 的根
    parentCtx, parentCancel := context.WithCancel(context.Background())

    // 子 Context:从父 Context 派生
    // 子 Context 继承了父 Context 的取消控制
    childCtx, childCancel := context.WithCancel(parentCtx)
    defer childCancel() // 子 Context 也要 cancel,释放资源

    // 孙子 Context:从子 Context 派生
    grandchildCtx, _ := context.WithTimeout(childCtx, 10*time.Second)

    // ==================== 规则一:父取消,子也取消 ====================
    // 当父亲取消时,所有派生的子 Context 都会被取消
    // 这就是"控制从上至下"
    go func() {
        <-grandchildCtx.Done()
        fmt.Println("孙子 Context 被取消了:", grandchildCtx.Err())
    }()

    // 取消父 Context
    // 即使孙子 Context 设置了 10 秒超时,也会因为父取消而立即取消
    parentCancel()
    time.Sleep(100 * time.Millisecond)

    // ==================== 规则二:子不能控制父 ====================
    // 子 Context 设置的超时时间不能比父的长
    // 如果父 5 秒后过期,子设置 10 秒,子实际上还是 5 秒就过期
    parent2, _ := context.WithTimeout(context.Background(), 5*time.Second)
    child2, _ := context.WithTimeout(parent2, 10*time.Second)
    // child2 实际上 5 秒后就过期了,因为 parent2 先过期

    // 但子可以设置更短的超时
    parent3, _ := context.WithTimeout(context.Background(), 10*time.Second)
    child3, _ := context.WithTimeout(parent3, 3*time.Second)
    // child3 在 3 秒后过期,parent3 在 10 秒后过期
    // 子比父先过期是允许的

    _ = child2
    _ = child3
    fmt.Println("父子关系演示完成")
}

总结两条核心规则:

  • 控制是从上至下的:父亲取消或超时,所有子 Context 都会被取消
  • 查找是从下至上的:子 Context 找不到某个 key,会向上找父 Context,一直找到为止

1.6 valueCtx 实现——装饰器模式

valueCtx 是 context 包内部用来实现 WithValue 的结构体。它采用了装饰器模式:在已有的 Context 基础上,附加一个存储 key-value 的功能。

// ==================== valueCtx 的简化实现 ====================
// 帮助你理解 context.WithValue 内部是怎么工作的

// valueCtx 嵌入了 Context 接口(这就是装饰器模式)
// 它在原有 Context 的基础上,多存了一个 key-value 对
type valueCtx struct {
    Context       // 匿名嵌入:这是"装饰器模式"的核心
    key, val any  // 只能存储一个 key-value 对
}

// Value 方法的实现:查找 key 时,先看自己有没有,没有就找父节点
func (c *valueCtx) Value(key any) any {
    // 如果 key 匹配自己的 key,直接返回
    if c.key == key {
        return c.val
    }
    // 否则委托给父 Context 去找
    // 这就是"查找从下至上"的实现原理
    return c.Context.Value(key)
}

// ==================== 为什么要这样设计? ====================
// 假设有以下调用链:
//   ctx1 = WithValue(background, "a", 1)
//   ctx2 = WithValue(ctx1, "b", 2)
//   ctx3 = WithValue(ctx2, "c", 3)
//
// 查找 "a" 的过程:
//   ctx3.Value("a") → 不是 "c",找父节点 ctx2
//   ctx2.Value("a") → 不是 "b",找父节点 ctx1
//   ctx1.Value("a") → 是 "a",返回 1
//
// 查找 "d"(不存在)的过程:
//   ctx3.Value("d") → 找父 → ctx2 → 找父 → ctx1 → 找父 → background
//   background.Value("d") → 返回 nil(根节点没有存任何值)

新手理解:想象一个俄罗斯套娃。最外面的大套娃是 context.Background(),你每调用一次 WithValue,就往外面套一层小套娃。查找值的时候,从最外层开始找,找不到就往里面一层找,直到最里面的大套娃。

1.7 cancelCtx 实现——取消信号的传播

cancelCtxWithCancelWithTimeout 内部使用的结构体,它负责管理取消信号的传播。

白话类比cancelCtx 的取消信号传播,就像公司里的"逐级叫停"——老板(根 Context)一声令下取消项目,秘书沿着组织架构树(children 表)一层层通知下去,每个小组长收到后先停掉自己手头的活,再通知自己下面的组员,直到最底层所有人都停下来。而且通知只往下传,组员没法反过来叫停老板。

flowchart TD
    A[cancelCtxA 被取消] --> B[cancelCtxB]
    A --> C[timerCtxC]
    B --> D[valueCtx 子节点]
    C --> E[cancelCtxD]
    A -->|关闭自身的 done channel| F[关闭 A.Done]
    B -->|关闭 B.Done| G[唤醒等待者]
    C -->|停定时器 关闭 C.Done| H[唤醒等待者]
    E -->|关闭 E.Done| I[唤醒等待者]
// ==================== cancelCtx 的简化实现 ====================

type cancelCtx struct {
    Context

    // done 是一个 channel,被 close 后表示取消
    // 用 sync.Once 确保只关闭一次(避免重复 close 导致 panic)
    done atomic.Value // 存储一个 chan struct{}

    // children 记录所有从这个 cancelCtx 派生的子 cancelCtx
    // 取消时会遍历 children,挨个取消
    children map[canceler]struct{}
}

// canceler 接口:任何可以被取消的类型都实现这个接口
type canceler interface {
    cancel(removeFromParent bool, err error)
    Done() <-chan struct{}
}

// ==================== 取消的传播过程 ====================
// 假设有以下 Context 树:
//
//              cancelCtxA
//             /    |    \
//        valueCtx  cancelCtxB  timerCtxC
//                   |            |
//               valueCtx     cancelCtxD
//
// 当 cancelCtxA 被取消时:
// 1. 遍历 cancelCtxA.children,找到 cancelCtxB 和 timerCtxC
// 2. 调用 cancelCtxB.cancel() → 关闭 B 的 done channel
// 3. 调用 timerCtxC.cancel() → 关闭 C 的 done channel,停掉 C 的定时器
// 4. cancelCtxB 再遍历自己的 children,取消 valueCtx...
// 5. 子子孙孙,全部取消

// ==================== 关键细节:children 维护 ====================
// 注意:不是所有 Context 类型都会被加入 children
// 只有 cancelCtx 和 timerCtx 会被加入
// valueCtx 不会被加入,因为它没有取消功能

// 儿子把自己加入父亲的 children 的过程:
// 1. 新建一个 cancelCtx(或 timerCtx)
// 2. 从自己开始往上找,找到第一个 cancelCtx 类型的祖先
// 3. 把自己加入那个祖先的 children map 中
// 4. 如果祖先已经取消了,立刻取消自己

// ==================== cancel 方法做了两件事 ====================
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    // 1. 遍历所有 children,挨个调用 cancel(递归取消)
    for child := range c.children {
        child.cancel(false, err) // false 表示不需要从父节点移除自己(因为父节点也在取消)
    }
    c.children = nil

    // 2. 关闭 done channel(符合"谁创建谁关闭"的原则)
    close(c.done.(chan struct{}))
}

面试要点cancelCtxDone() 方法使用了 double-check 机制(先原子检查,再加锁创建),这种原子操作和锁结合的用法比较罕见。

1.8 timerCtx 实现——超时控制

timerCtxcancelCtx 的基础上增加了定时器功能,用于实现 WithTimeoutWithDeadline

// ==================== timerCtx 的简化实现 ====================

type timerCtx struct {
    cancelCtx          // 嵌入 cancelCtx,复用取消功能
    timer    *time.Timer // 定时器,到时间后自动触发取消
    deadline time.Time   // 过期时间点
}

// ==================== WithTimeout 的本质 ====================
// WithTimeout 和 WithDeadline 本质是一样的
// WithTimeout(parent, d) = WithDeadline(parent, time.Now().Add(d))
//
// WithDeadline 内部的实现:
// 1. 创建一个 timerCtx
// 2. 用 time.AfterFunc 设置定时器
// 3. 定时器触发时,自动调用 cancelCtx.cancel()
// 4. 这样就实现了"到了时间自动取消"

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    // 如果父 Context 的过期时间更早,就没必要创建 timerCtx
    // 因为父先过期,子一定会跟着过期
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // 父比子先过期,直接用 WithCancel 就够了
        return WithCancel(parent)
    }

    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }

    // 把自己加入父节点的 children(传播取消信号)
    propagateCancel(parent, c)

    // 计算距离过期还有多久
    dur := time.Until(d)
    if dur <= 0 {
        // 已经过期了,直接取消
        c.cancel(true, context.DeadlineExceeded)
        return c, func() { c.cancel(false, Canceled) }
    }

    // 设置定时器,到时间自动取消
    c.timer = time.AfterFunc(dur, func() {
        c.cancel(true, context.DeadlineExceeded)
    })

    return c, func() { c.cancel(true, Canceled) }
}

1.9 经典用法:控制超时

超时控制是 context 最经典的用法。核心思路是:同时监听两个 channel——业务完成 channel 和 context 的 Done channel,谁先来就处理谁。

package main

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

// ==================== 模拟一个耗时的数据库查询 ====================
func slowDBQuery(ctx context.Context) (string, error) {
    // 在执行查询前,先检查 context 是否已经超时
    // 这是一种常见的优化:如果已经超时了,就不用发请求了
    if ctx.Err() != nil {
        return "", ctx.Err()
    }

    // 模拟耗时查询(3 秒)
    select {
    case <-time.After(3 * time.Second):
        return "查询结果", nil
    case <-ctx.Done():
        // context 超时或被取消
        return "", ctx.Err()
    }
}

func main() {
    // 创建一个 2 秒超时的 Context
    // 数据库查询需要 3 秒,但 context 只有 2 秒
    // 所以查询会在 2 秒后被取消
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    // 好习惯:defer cancel() 确保资源被释放
    defer cancel()

    result, err := slowDBQuery(ctx)
    if err != nil {
        fmt.Println("查询失败:", err)
        // 输出: 查询失败: context deadline exceeded
        return
    }
    fmt.Println("查询成功:", result)
}

新手理解select-case 就像一个十字路口的信号灯。多个 channel 同时等着,哪个 channel 先有数据(或先关闭),就执行哪个 case。在超时控制中,我们同时等"业务完成"和"超时取消",谁先来就走谁。

1.10 errgroup.WithContext——一损俱损的并发控制

errgroupgolang.org/x/sync 包提供的工具,它基于 context 实现了"任意一个任务失败,所有任务都取消"的功能。

package main

import (
    "context"
    "fmt"
    "time"

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

func main() {
    // errgroup.WithContext 返回一个 Group 和一个 Context
    // 如果 Group 中的任何一个任务返回 error,这个 Context 就会被取消
    // 这样其他正在运行的任务就能通过 ctx.Done() 收到取消信号
    g, ctx := errgroup.WithContext(context.Background())

    // 任务 1:模拟一个会成功的任务
    g.Go(func() error {
        time.Sleep(1 * time.Second)
        fmt.Println("任务 1 完成")
        return nil
    })

    // 任务 2:模拟一个会失败的任务
    g.Go(func() error {
        time.Sleep(500 * time.Millisecond)
        fmt.Println("任务 2 失败了")
        return fmt.Errorf("任务 2 出错了")
        // 此时 ctx 会被取消,任务 1 和任务 3 会收到取消信号
    })

    // 任务 3:监听 ctx,一旦取消就退出
    g.Go(func() error {
        select {
        case <-ctx.Done():
            fmt.Println("任务 3 收到取消信号,提前退出")
            return ctx.Err()
        case <-time.After(5 * time.Second):
            fmt.Println("任务 3 正常完成")
            return nil
        }
    })

    // Wait 等待所有任务完成
    // 返回第一个出错的任务的 error
    if err := g.Wait(); err != nil {
        fmt.Println("最终结果:", err)
    }
}

1.11 context 使用注意事项

// ==================== 规则一:只用作方法参数 ====================
// context.Context 应该作为方法的第一个参数传递
// 不要存到结构体字段里(除非你的结构体本身就是表达"请求上下文"的概念)

// ✅ 正确用法:作为第一个参数
func GetUser(ctx context.Context, userID int) (*User, error) {
    // ...
    return nil, nil
}

// ❌ 错误用法:存到结构体字段
// type UserService struct {
//     ctx context.Context  // 不要这样做!
// }

// ==================== 规则二:所有公共方法都加 context 参数 ====================
// 除非是 util、helper 之类的工具方法
// 这是 Go 社区的共识,也是代码规范

// ✅ 正确
func (s *Service) DoSomething(ctx context.Context, req *Request) (*Response, error) {
    // ...
    return nil, nil
}

// ==================== 规则三:不要用 nil Context ====================
// 如果没有 Context 可传,用 context.Background() 或 context.TODO()

// ✅ 正确
result := ProcessData(context.Background(), data)

// ❌ 错误(会 panic)
// result := ProcessData(nil, data)

// ==================== 规则四:父无法访问子的值 ====================
// 父 Context 无法获取子 Context 中 WithValue 存储的值
// 如果确实需要在父中访问子设置的值,可以在父中放一个 map,后续修改这个 map

// ==================== 规则五:WithTimeout 和 WithCancel 返回的 cancel 函数必须调用 ====================
// 即使函数提前返回了,也要调用 cancel 释放资源
// 最简单的方式是用 defer

// ✅ 正确
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // 确保无论如何都会调用 cancel

二、sync 包——锁与并发安全

2.1 Mutex 和 RWMutex 基本用法

当多个 goroutine 同时读写同一个变量时,就会产生数据竞争。最简单的解决方案就是加锁。

Go 提供了两种锁:

  • Mutex(互斥锁):同一时间只有一个 goroutine 能访问(无论读写)
  • RWMutex(读写锁):多个 goroutine 可以同时读,但写的时候独占
package main

import (
    "fmt"
    "sync"
)

// ==================== 1. Mutex 基本用法 ====================
// 用 Mutex 保护一个计数器
type Counter struct {
    mu    sync.Mutex // 互斥锁,保护 count 字段
    count int        // 被保护的共享变量
}

func (c *Counter) Increment() {
    c.mu.Lock()         // 加锁:其他 goroutine 调用 Lock 会阻塞等待
    c.count++           // 安全地修改共享变量
    c.mu.Unlock()       // 解锁:让等待的 goroutine 可以继续
}

func (c *Counter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock() // 用 defer 确保一定会解锁,即使 panic 也会执行
    return c.count
}

// ==================== 2. RWMutex 基本用法 ====================
// 读写锁:适合"读多写少"的场景
// 多个读操作可以并发执行,写操作需要独占
type SafeCache struct {
    mu   sync.RWMutex // 读写锁
    data map[string]string
}

func (c *SafeCache) Get(key string) (string, bool) {
    // 读锁:允许多个 goroutine 同时读
    // 但如果有 goroutine 持有写锁,读锁会阻塞
    c.mu.RLock()
    defer c.mu.RUnlock()
    val, ok := c.data[key]
    return val, ok
}

func (c *SafeCache) Set(key, val string) {
    // 写锁:独占,其他 goroutine 既不能读也不能写
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = val
}

func main() {
    // 测试 Mutex
    counter := &Counter{}
    var wg sync.WaitGroup
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }
    wg.Wait()
    fmt.Println("Counter:", counter.Get()) // 输出: Counter: 1000

    // 测试 RWMutex
    cache := &SafeCache{data: make(map[string]string)}
    cache.Set("name", "张三")
    val, _ := cache.Get("name")
    fmt.Println("Cache:", val) // 输出: Cache: 张三
}

选择建议:优先使用 RWMutex。但如果你的操作几乎全是写操作,直接用 Mutex 更简单(因为 RWMutex 的读锁本身也有开销)。

2.2 SafeMap——线程安全的 Map

Go 内置的 map 不是线程安全的,多个 goroutine 同时读写同一个 map 会导致 panic。我们用 RWMutex 来包装它:

package main

import (
    "fmt"
    "sync"
)

// ==================== SafeMap:线程安全的 map ====================
type SafeMap struct {
    mu   sync.RWMutex
    data map[string]any
}

func NewSafeMap() *SafeMap {
    return &SafeMap{
        data: make(map[string]any),
    }
}

// LoadOrStore:如果 key 存在就返回已有值,不存在就存入新值
// 这是一个非常常见的方法,Go 标准库 sync.Map 也有同名方法
func (m *SafeMap) LoadOrStore(key string, val any) (actual any, loaded bool) {
    // ==================== 第一次检查:加读锁 ====================
    // 先用读锁快速检查一下 key 是否已存在
    m.mu.RLock()
    if v, ok := m.data[key]; ok {
        // key 已存在,直接返回已有值
        m.mu.RUnlock()
        return v, true // loaded=true 表示是加载的已有值
    }
    m.mu.RUnlock()

    // ==================== 第二次检查:加写锁 ====================
    // 为什么要再检查一遍?
    // 因为从释放读锁到获取写锁之间,可能有其他 goroutine 已经写入了这个 key
    // 这就是"double-check"(双重检查)模式
    m.mu.Lock()
    defer m.mu.Unlock()

    // 再次检查 key 是否存在
    if v, ok := m.data[key]; ok {
        // 在获取写锁期间,其他 goroutine 已经写入了,返回已有值
        return v, true
    }

    // key 确实不存在,存入新值
    m.data[key] = val
    return val, false // loaded=false 表示是新存入的值
}

func (m *SafeMap) Load(key string) (any, bool) {
    m.mu.RLock()
    defer m.mu.RUnlock()
    v, ok := m.data[key]
    return v, ok
}

func (m *SafeMap) Delete(key string) {
    m.mu.Lock()
    defer m.mu.Unlock()
    delete(m.data, key)
}

func main() {
    m := NewSafeMap()
    var wg sync.WaitGroup

    // 100 个 goroutine 同时尝试写入同一个 key
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            // LoadOrStore 保证只有一个 goroutine 真正写入
            val, loaded := m.LoadOrStore("key", i)
            if loaded {
                fmt.Printf("goroutine %d: key 已存在,值为 %v\n", i, val)
            } else {
                fmt.Printf("goroutine %d: 成功写入值 %d\n", i, i)
            }
        }(i)
    }
    wg.Wait()
}

2.3 double-check(双重检查)模式

上面的 LoadOrStore 用到了 double-check 模式,这是一个非常重要的并发编程技巧。核心思路是:

  1. 第一次检查(读锁):先用读锁快速检查,如果满足条件就直接返回,避免获取写锁的开销
  2. 第二次检查(写锁):如果不满足条件,获取写锁后再检查一次,防止竞态条件
// ==================== double-check 模式的通用模板 ====================

func (m *SafeMap) GetOrCompute(key string, compute func() any) any {
    // 第一次检查:读锁(快路径)
    // 大部分情况下 key 已经存在,读锁就够了,不需要加写锁
    m.mu.RLock()
    if v, ok := m.data[key]; ok {
        m.mu.RUnlock()
        return v // 快速返回,不需要加写锁
    }
    m.mu.RUnlock()

    // 第二次检查:写锁(慢路径)
    // 只有 key 不存在时才走到这里
    m.mu.Lock()
    defer m.mu.Unlock()

    // 必须再检查一次!
    // 因为在 RUnlock 和 Lock 之间,可能有其他 goroutine 已经写入了
    if v, ok := m.data[key]; ok {
        return v // 其他 goroutine 已经写入了,直接用
    }

    // 确实不存在,计算新值并写入
    val := compute()
    m.data[key] = val
    return val
}

// ==================== 为什么不能只检查一次? ====================
// 假设只检查一次(只加写锁):
//
// func (m *SafeMap) GetOrCompute(key string) any {
//     m.mu.Lock()
//     defer m.mu.Unlock()
//     if v, ok := m.data[key]; ok {
//         return v
//     }
//     m.data[key] = compute() // 计算可能很耗时
//     return m.data[key]
// }
//
// 问题:每次调用都要加写锁,即使 key 大部分时候已经存在
// 写锁是排他的,会阻塞所有读操作,性能很差
//
// double-check 的优势:
// - key 已存在时(常见情况),只用读锁,不阻塞其他读操作
// - key 不存在时(少见情况),才加写锁

2.4 Mutex 实现细节

理解 Mutex 的实现有助于你在面试中回答相关问题。Go 的 Mutex 实现包含以下关键设计:

白话类比:Mutex 抢锁就像抢会议室。正常模式下,门一开大家一拥而上抢,刚从外面跑来的人(新来的 goroutine,正在 CPU 上)往往比刚被叫醒的人(被唤醒的 goroutine,要重新调度)更容易抢到——吞吐高,但有人可能一直抢不到(饥饿)。一旦某人等了超过 1ms,系统切换到饥饿模式:会议室直接交给排队最前面那位,新来者不许插队,保证先到先得。

flowchart TD
    S[尝试加锁] --> C{CAS 成功?
无竞争} C -- 是 --> D[直接获得锁] C -- 否 --> SP{自旋几次?
利用 CPU 空转} SP -- 成功 --> D SP -- 失败 --> Q[加入等待队列
goroutine 挂起] Q --> W{被唤醒} W --> M{当前模式?} M -- 饥饿模式 --> G[直接获得锁
新来者不得抢] M -- 正常模式 --> N{和新来者竞争} N -- 抢到 --> D N -- 没抢到 --> Q
// ==================== Mutex 的内部结构 ====================
type Mutex struct {
    state int32 // 锁状态:记录锁是否被持有、是否有等待者、处于哪种模式
    sema  uint32 // 信号量:用于挂起和唤醒 goroutine
}

// ==================== 锁实现的一般模板 ====================
// 大部分语言的锁都遵循这个模板:
//
// 加锁:
//   1. 快路径:CAS 尝试加锁(自旋),成功就直接返回
//   2. 慢路径:加入等待队列,被挂起,等待被唤醒
//
// 解锁:
//   1. CAS 修改状态
//   2. 如果有等待者,唤醒队列头的 goroutine

Go Mutex 的两种模式

Go 的 Mutex 有两种工作模式,这是它和其他语言锁的最大区别:

// ==================== 正常模式 ====================
// 当一个 goroutine 被唤醒后,它不会立刻获得锁
// 而是和新来的 goroutine 竞争锁
//
// 为什么?因为被唤醒的 goroutine 需要重新调度(可能不在 CPU 上了)
// 而新来的 goroutine 正在 CPU 上运行,大概率能抢到锁
// 这样做的好处:减少 goroutine 调度开销,提高吞吐量
//
// 缺点:可能导致队列中的 goroutine 长时间拿不到锁(饥饿)

// ==================== 饥饿模式 ====================
// 如果一个 goroutine 等待锁的时间超过 1ms
// Mutex 会切换到饥饿模式
//
// 在饥饿模式下:
// - 锁直接交给队列头的 goroutine,新来的不能抢
// - 保证公平性,先到先得
//
// 退出饥饿模式的条件:
// - 队列中只剩最后一个 goroutine
// - 或者 goroutine 的等待时间小于 1ms

加锁步骤总结

1. CAS 尝试加锁,如果锁空闲且没人竞争 → 直接成功
2. 如果失败,自旋几次(利用 CPU 空转等待,避免立即挂起)
3. 自旋也失败,加入等待队列,goroutine 被挂起
4. 被唤醒后:
   - 正常模式:和新来的 goroutine 竞争,大概率失败
   - 饥饿模式:直接获得锁

解锁过程

// 解锁相对简单
// 1. atomic 操作修改 state(解除锁定状态)
// 2. 如果发现有等待者,进入慢路径
// 3. 慢路径:根据当前模式(正常/饥饿),唤醒对应的 goroutine

面试要点:Mutex 是通过 sema 字段和两个 runtime 调用来实现 goroutine 挂起和唤醒的:

  • runtime_SemacquireMutex:sema 加 1,挂起当前 goroutine
  • runtime_Semrelease:sema 减 1,唤醒一个等待的 goroutine 这和 Mutex 的"亲戚"——WaitGroup 用的是相同的机制。

2.5 Mutex 和 RWMutex 使用注意事项

package main

import (
    "fmt"
    "sync"
)

func main() {
    var mu sync.Mutex

    // ==================== 注意事项一:锁不可重入 ====================
    // Go 的 Mutex 不支持重入
    // 所谓"重入"是指:同一个 goroutine 可以多次获取同一把锁
    // Java 的 ReentrantLock 支持重入,但 Go 不支持

    // ❌ 这会导致死锁!
    // funcA 获取了锁,调用 funcB,funcB 又要获取同一把锁
    // funcB 会永远阻塞,因为 funcA 还没释放锁
    //
    // funcA() {
    //     mu.Lock()
    //     defer mu.Unlock()
    //     funcB() // funcB 里面也要 mu.Lock() → 死锁!
    // }

    // ✅ 解决方案:拆分出不需要加锁的内部方法
    // funcA() {
    //     mu.Lock()
    //     defer mu.Unlock()
    //     funcBInternal() // 不加锁的版本
    // }
    // funcB() {
    //     mu.Lock()
    //     defer mu.Unlock()
    //     funcBInternal()
    // }

    // ==================== 注意事项二:用 defer 解锁 ====================
    // 如果在加锁和解锁之间发生了 panic,锁就永远不会被释放
    // defer 可以确保即使 panic,锁也会被释放

    // ❌ 危险:如果 doSomething() panic 了,锁就不会释放
    // mu.Lock()
    // doSomething() // 可能 panic
    // mu.Unlock()

    // ✅ 安全:defer 确保无论如何都会解锁
    mu.Lock()
    defer mu.Unlock()
    fmt.Println("安全地做了一些事情")

    // ==================== 注意事项三:RWMutex 适合读多写少 ====================
    // 如果写操作很多,RWMutex 反而比 Mutex 慢
    // 因为 RWMutex 维护读者计数有额外开销
    // 而且写锁要等所有读者释放,可能导致写延迟
}

三、sync.Once——只执行一次

3.1 基本用法

sync.Once 用来确保某个操作至多执行一次。最常见的用途是初始化资源、实现单例模式。

package main

import (
    "fmt"
    "sync"
)

// ==================== 单例模式 ====================
type Database struct {
    name string
}

var (
    dbInstance *Database
    dbOnce     sync.Once // 确保 dbInstance 只被初始化一次
)

// GetDB 获取数据库单例
// 无论调用多少次,Database 只会被创建一次
func GetDB() *Database {
    // Do 方法接收一个函数,确保这个函数只执行一次
    // 即使 100 个 goroutine 同时调用 GetDB,初始化也只会执行一次
    dbOnce.Do(func() {
        fmt.Println("正在初始化数据库连接...")
        // 模拟耗时的初始化操作
        dbInstance = &Database{name: "mysql-localhost:3306"}
        fmt.Println("数据库初始化完成")
    })
    return dbInstance
}

func main() {
    var wg sync.WaitGroup

    // 10 个 goroutine 同时获取数据库实例
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            db := GetDB()
            fmt.Printf("goroutine %d 拿到数据库: %s\n", i, db.name)
        }(i)
    }
    wg.Wait()

    // 输出:
    // 正在初始化数据库连接...
    // 数据库初始化完成
    // goroutine 0 拿到数据库: mysql-localhost:3306
    // goroutine 1 拿到数据库: mysql-localhost:3306
    // ...("正在初始化"只会打印一次)
}

3.2 Once 实现原理——double-check 变种

sync.Once 的实现是 double-check 模式的一个变种。它没有用读写锁,而是用原子操作来扮演"读锁"的角色,性能更好。

// ==================== sync.Once 的简化实现 ====================

type Once struct {
    done uint32 // 0 表示未执行,1 表示已执行
    m    Mutex  // 互斥锁,确保只有一个 goroutine 执行初始化
}

func (o *Once) Do(f func()) {
    // ==================== 第一次检查:原子读(快路径)====================
    // 用原子操作代替读锁,性能更好
    // 如果已经执行过了(done == 1),直接返回,不需要加锁
    if atomic.LoadUint32(&o.done) == 0 {
        // ==================== 第二次检查:加锁后再检查(慢路径)====================
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()

    // 加锁后再检查一次 done
    // 因为在第一次检查和加锁之间,可能其他 goroutine 已经执行完了
    if o.done == 0 {
        // 确实没执行过,执行函数
        defer atomic.StoreUint32(&o.done, 1) // 标记为已执行
        f()
    }
    // 如果 done == 1,说明其他 goroutine 已经执行了,什么都不做
}

// ==================== 和 RWMutex 版 double-check 的对比 ====================
// RWMutex 版:
//   1. RLock → 检查 → RUnlock(读锁)
//   2. Lock → 检查 → Unlock(写锁)
//
// Once 版(用原子操作代替读锁):
//   1. atomic.Load → 检查(无锁,极快)
//   2. Lock → 检查 → Unlock(互斥锁)
//
// 原子操作比读锁快得多,因为不需要进入 runtime 调度

3.3 实际应用:Beego 的初始化

在实际项目中,sync.Once 经常用于确保初始化代码只执行一次。例如 Beego 框架用 Once 来确保 Web 模块只初始化一次:

package main

import (
    "fmt"
    "sync"
)

// ==================== 模拟 Beego 的初始化模式 ====================

var initOnce sync.Once

// initBeforeHTTPRun 可能在多个地方被调用
// 使用 Once 确保初始化只执行一次
func initBeforeHTTPRun() {
    initOnce.Do(func() {
        fmt.Println("1. 注册路由...")
        fmt.Println("2. 加载配置...")
        fmt.Println("3. 连接数据库...")
        fmt.Println("4. 初始化缓存...")
        fmt.Println("初始化完成!")
    })
}

func main() {
    // 模拟多处调用
    initBeforeHTTPRun() // 第一次调用,执行初始化
    initBeforeHTTPRun() // 第二次调用,什么都不做
    initBeforeHTTPRun() // 第三次调用,什么都不做
}

提示:如果一个初始化操作只需要执行一次,也可以考虑放在 init() 函数中。sync.Once 更灵活的地方在于,你可以控制初始化的时机(延迟到第一次使用时才初始化)。


四、sync.Pool——对象复用池

4.1 为什么需要 Pool

在 Go 中,频繁创建和销毁对象会带来两个问题:

  1. 内存分配开销:每次 newmake 都需要向操作系统申请内存
  2. GC 压力:对象越多,垃圾回收器需要扫描的对象就越多,GC 就越慢

sync.Pool 的作用就是缓存已创建的对象,重复利用,减少内存分配和 GC 压力。

package main

import (
    "bytes"
    "fmt"
    "sync"
)

// ==================== sync.Pool 基本用法 ====================

func main() {
    // 创建一个 Pool
    // New 函数:当 Pool 中没有可用对象时,调用此函数创建新对象
    bufferPool := &sync.Pool{
        New: func() any {
            // 当 Pool 为空时,创建一个新的 bytes.Buffer
            // 首次大小设为 512 字节,减少扩容次数
            b := bytes.NewBuffer(make([]byte, 0, 512))
            fmt.Println("  [Pool] 创建了新的 Buffer")
            return b
        },
    }

    // ==================== Get:从 Pool 获取对象 ====================
    // 第一次 Get,Pool 为空,会调用 New 创建
    buf1 := bufferPool.Get().(*bytes.Buffer)
    buf1.WriteString("hello")
    fmt.Printf("buf1 内容: %q\n", buf1.String())

    // 使用完毕后,必须重置对象状态!
    // 否则下一个使用者会拿到带着上一次数据的"脏"对象
    buf1.Reset()

    // ==================== Put:把对象放回 Pool ====================
    // 放回去后,对象可以被其他 goroutine 复用
    bufferPool.Put(buf1)

    // 第二次 Get,Pool 中有刚才放回去的 buf1
    // 不会调用 New,直接复用
    buf2 := bufferPool.Get().(*bytes.Buffer)
    fmt.Printf("buf2 内容: %q(应该是空的,因为 Reset 了)\n", buf2.String())
    buf2.Reset()
    bufferPool.Put(buf2)

    // ==================== 重要特性:GC 会清理 Pool ====================
    // sync.Pool 中缓存的对象会在 GC 时被清理
    // 所以 Pool 不适合用来做真正的缓存(比如缓存数据库查询结果)
    // 它只适合复用临时对象
}

新手理解:把 sync.Pool 想象成图书馆。你需要一本书就去借(Get),用完了还回去(Put)。下次你需要的时候,如果图书馆有这本书就直接借给你,没有就去买一本新的(New)。但是每隔一段时间(GC),图书馆会把所有书都清空。

4.2 Pool 实现原理——为什么 Go 的 Pool 这么快

如果让你设计一个并发安全的对象池,最简单的方案是:用一个队列(channel)来存储对象,加锁保护。但这样有个问题——所有 goroutine 都要竞争同一把锁,锁会成为瓶颈。

Go 的 sync.Pool 用了一个更聪明的方案,结合了 Go 自身的 GMP 调度模型:

┌──────────────────────────────────────────────────────┐
│                   sync.Pool 架构                      │
│                                                      │
│  ┌─────────┐  ┌─────────┐  ┌─────────┐              │
│  │    P0   │  │    P1   │  │    P2   │  ... (每个P)  │
│  │         │  │         │  │         │              │
│  │ private │  │ private │  │ private │  ← 无竞争!   │
│  │         │  │         │  │         │    P同一时间  │
│  │ shared  │  │ shared  │  │ shared  │    只有一个G  │
│  │ (chain) │  │ (chain) │  │ (chain) │    在运行     │
│  └────┬────┘  └────┬────┘  └────┬────┘              │
│       │            │            │                    │
│       └────────────┴────────────┘                    │
│              ↑                                       │
│         窃取(steal)                                 │
│    P0 的 shared 空了,可以从 P1、P2 偷               │
└──────────────────────────────────────────────────────┘

核心设计

  1. 每个 P(Processor)一个 poolLocal:P 是 Go 调度器中的"处理器"概念。P 同一时间只有一个 goroutine 在运行,所以绑定在 P 上的数据不需要加锁
  2. private 字段:每个 P 有一个 private 对象,获取和归还都无竞争
  3. shared poolChain:当 private 不可用时,使用 shared 链表。shared 可以被其他 P 偷走
  4. 窃取机制:自己的 shared 没有对象时,从其他 P 的 shared 尾部偷一个

GMP 模型补充:Go 的调度器有三个核心概念——G(goroutine)、M(Machine,操作系统线程)、P(Processor,处理器)。P 的数量等于 GOMAXPROCS,每个 P 同一时间只运行一个 G。这就是为什么绑定在 P 上的数据不需要加锁。

4.3 poolChain 的结构

poolChain 是一个链表 + ring buffer 的双重结构

poolChain 结构:

HEAD(最近创建)                              TAIL(最早创建)
  │                                           │
  ▼                                           ▼
┌──────────┐    ┌──────────┐    ┌──────────┐
│ ring buf │ ←→ │ ring buf │ ←→ │ ring buf │  双向链表
│ 容量: 8  │    │ 容量: 16 │    │ 容量: 32 │  每个节点是 ring buffer
└──────────┘    └──────────┘    └──────────┘
                                     ↑
                              后一个节点容量是前一个的两倍

ring buffer(环形缓冲区)优势:
- 一次性分配好内存,循环利用(不需要频繁扩容)
- 对 CPU 缓存友好(连续内存)

4.4 Pool 的 GET 步骤

用生活中的例子来理解 GET 步骤——就好比你准备买东西:

// ==================== sync.Pool GET 步骤 ====================
//
// 比喻:你准备买东西
//
// 步骤 1:先看自己有没有(private)
//   → 检查当前 P 的 private 字段,如果有直接返回
//   → 这是最快的路径,完全无竞争
//
// 步骤 2:没有找爸妈借(shared poolChain)
//   → 从自己的 poolChain 头部开始找
//   → HEAD 指向最近创建的 ring buffer
//   → 从队头往队尾找
//
// 步骤 3:还没有找亲戚借(从别人的 poolChain 偷一个)
//   → 遍历其他 P 的 poolChain
//   → 从别人的队尾偷(偷的过程是全局并发的)
//   → 偷是最后一个需要竞争的步骤
//
// 步骤 4:再没有薅网贷(victim 缓刑)
//   → 找 victim 里的对象(上一轮 GC 前的 locals)
//   → victim 的对象即将被回收,能用就用
//
// 步骤 5:再没有就下海了(创建一个新的)
//   → 调用 New 函数创建新对象

有趣的细节:为什么先偷别人的,再找自己的 victim?因为偷是全局竞争的,而找 victim 不是。但 Go 选择先偷,是希望 victim 里的对象尽可能被回收掉。

4.5 Pool 的 PUT 步骤

// ==================== sync.Pool PUT 步骤 ====================
//
// 步骤 1:private 没放东西?直接放 private
//   → 最快路径,无竞争
//
// 步骤 2:否则,准备放 poolChain
//   → 步骤 2a:如果 HEAD 还没创建,创建一个 HEAD
//     → 创建一个容量为 8 的 ring buffer,把数据放进去
//
//   → 步骤 2b:如果 HEAD 的 ring buffer 没满
//     → 直接放进去
//
//   → 步骤 2c:如果 HEAD 的 ring buffer 已满
//     → 创建一个新节点,容量是 HEAD 的两倍
//     → 把数据放进新的 ring buffer
//     → 新节点成为新的 HEAD

4.6 Pool 与 GC 的关系

sync.Pool 最大的特点是:它依赖 GC 来清理缓存,用户无法手动控制容量

GC 发生时:

    GC 前                          GC 后
┌───────────┐                ┌───────────┐
│  locals   │  ──移动──→     │  victim   │  ← 上一轮的 locals
│ (当前使用) │                │ (缓刑区)  │     本轮 GC 不会被回收
└───────────┘                └───────────┘
┌───────────┐                ┌───────────┐
│  victim   │  ──回收──→     │   (清空)  │  ← 上一轮的 victim 被回收
│ (上轮残留) │                │           │
└───────────┘                └───────────┘

复活机制:
如果 victim 中的对象在 GC 后被 Get 取出来使用了
它会被放回 locals,逃过下一轮被回收的命运

优点:防止 GC 引起性能抖动
(两轮 GC 的缓冲期,让对象有机会被复用)

4.7 poolLocal 和 false sharing

在并发编程中,有一个性能陷阱叫 false sharing(伪共享)

CPU 缓存以 CacheLine(通常 64 字节)为单位加载内存。如果两个 goroutine 分别修改相邻的变量(在同一个 CacheLine 中),即使它们操作的是不同的变量,也会导致 CacheLine 反失效,严重影响性能。

// ==================== false sharing 问题示意 ====================

// ❌ 有 false sharing 问题
// 两个 poolLocal 紧挨着,可能在同一个 CacheLine 中
// P0 修改 poolLocal[0],P1 修改 poolLocal[1]
// 虽然它们改的是不同的字段,但会导致对方的 CacheLine 失效
type poolLocalWithoutPad struct {
    private any    // 8 字节
    shared  chain  // 8 字节
}
// 总共 16 字节,4 个 poolLocal 刚好在一个 64 字节的 CacheLine 中

// ✅ Go 的做法:用 pad 字段补齐到 128 字节的整数倍
type poolLocal struct {
    private any    // P 私有对象
    shared  poolChain
    pad     [128]byte  // 填充字段,确保每个 poolLocal 独占 CacheLine
}
// 每个 poolLocal 占 128 字节,确保不会和其他 poolLocal 共享 CacheLine

新手提示:这种 CPU 缓存对齐的优化非常底层,日常开发中几乎用不到。但它体现了 Go 标准库对性能的极致追求。

4.8 开源实例:bytebufferpool

bytebufferpool 是一个基于 sync.Pool 二次封装的 bytes.Buffer 对象池,它的设计思路值得学习:

package main

import (
    "fmt"
    "sync"
)

// ==================== bytebufferpool 核心思路的简化版 ====================

// 带容量控制的 Buffer Pool
type ByteBufferPool struct {
    // defaultSize:新创建的 buffer 的默认大小
    // 太小 → 频繁扩容(影响性能)
    // 太大 → 浪费内存
    defaultSize int

    // maxSize:超过这个大小的 buffer 不放回 Pool
    // 防止大 buffer 占用太多内存
    maxSize int

    pool sync.Pool
}

func NewByteBufferPool() *ByteBufferPool {
    return &ByteBufferPool{
        defaultSize: 512, // 默认 512 字节
        maxSize:     65536, // 最大 64KB,超过就不放回去
        pool: sync.Pool{
            New: func() any {
                // 创建一个 defaultSize 大小的 buffer
                b := make([]byte, 0, 512)
                return &b
            },
        },
    }
}

func (p *ByteBufferPool) Get() []byte {
    return *(p.pool.Get().(*[]byte))
}

func (p *ByteBufferPool) Put(b []byte) {
    // 超过 maxSize 的 buffer 不放回去,让它被 GC 回收
    // 这样防止 Pool 中缓存太多大 buffer,占用过多内存
    if cap(b) > p.maxSize {
        return // 不放回去,让 GC 回收
    }
    b = b[:0] // 重置长度,保留容量
    p.pool.Put(&b)
}

// ==================== bytebufferpool 的校准机制 ====================
// bytebufferpool 的一个亮点是"校准机制":
// 它会统计不同大小的 buffer 被使用的次数
// 比如 0-64 bytes 的 buffer 被使用了 1000 次
//      64-128 bytes 的 buffer 被使用了 500 次
//
// 根据统计结果动态调整 defaultSize 和 maxSize:
// - defaultSize 设为使用最频繁的大小,减少扩容
// - maxSize 设为合理上限,避免大 buffer 浪费内存
//
// 这就是所谓的"分组统计使用次数 + 动态校准"

func main() {
    pool := NewByteBufferPool()

    // 获取 buffer
    buf := pool.Get()
    buf = append(buf, "hello world"...)
    fmt.Printf("使用 buffer: %q, 容量: %d\n", buf, cap(buf))

    // 归还 buffer
    pool.Put(buf)

    // 再次获取,复用刚才的 buffer
    buf2 := pool.Get()
    fmt.Printf("复用 buffer: %q, 容量: %d\n", buf2, cap(buf2))
}

Pool 面试要点

  • sync.Pool 和 GC 的关系:数据在 local 中,GC 时移到 victim,再 GC 时被回收
  • poolChain 的设计:双向链表 + ring buffer,后一个节点容量是前一个的两倍
  • 为什么用 P 绑定而非全局队列:避免全局锁竞争,结合 GMP 调度模型的优势
  • 缺点:内存使用量不可控(依赖 GC),GC 后 Get 速率会下降

五、sync.WaitGroup——等待一组 goroutine

5.1 基本用法

WaitGroup 用于等待一组 goroutine 完成。常见场景是把一个大任务拆分成多个子任务,并行执行,然后等待所有子任务完成。

package main

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup

    // ==================== WaitGroup 三步曲 ====================

    for i := 0; i < 5; i++ {
        // 步骤一:在启动 goroutine 之前 Add(1)
        // 必须在 goroutine 外面调用,否则可能 Wait 先于 Add 执行
        wg.Add(1)

        go func(id int) {
            // 步骤二:任务完成后 Done()(等价于 Add(-1))
            defer wg.Done()

            fmt.Printf("任务 %d 开始执行\n", id)
            time.Sleep(time.Duration(id) * 100 * time.Millisecond) // 模拟耗时
            fmt.Printf("任务 %d 完成\n", id)
        }(i)
    }

    // 步骤三:Wait() 阻塞,直到计数器归零
    wg.Wait()
    fmt.Println("所有任务完成!")
}

容易犯错的地方

  • Add 加多了 → Wait 一直阻塞,goroutine 泄露(非常难测试)
  • Done 调多了 → 计数器变负数,直接 panic
  • Add 在 goroutine 内部调用 → 可能 Wait 先执行完,Add 还没调用

5.2 WaitGroup 实现细节

// ==================== WaitGroup 的内部结构 ====================

type WaitGroup struct {
    // noCopy:告诉编译器这个结构体不能被复制
    // 复制 WaitGroup 会导致计数器状态不一致
    // 这是一个很实用的技巧,Dubbo-go 的 URL 结构体也用了
    noCopy noCopy

    // state1:在 64 位系统下
    //   高 32 位:还有多少任务没完成(counter)
    //   低 32 位:有多少 goroutine 在等 Wait()(waiter count)
    state1 uint64

    // state2:信号量,用于挂起/唤醒 goroutine
    // 和 Mutex 的 sema 字段原理一样
    state2 uint32
}

// ==================== 三个方法的实现原理 ====================

// Add:counter += delta
// 本质就是对 state1 的高 32 位做原子加法
// 如果 counter 降为 0,就唤醒所有等待的 goroutine
func (wg *WaitGroup) Add(delta int) {
    // 原子操作修改 state1 的高 32 位(counter)
    // 如果 counter 变为 0,说明所有任务完成
    // 从返回值中解析出 waiter count,逐个唤醒
}

// Done:counter -= 1
// 本质就是 Add(-1)
func (wg *WaitGroup) Done() {
    wg.Add(-1)
}

// Wait:waiter += 1,然后挂起当前 goroutine
func (wg *WaitGroup) Wait() {
    // 对 state1 的低 32 位做 CAS 加 1(waiter count)
    // 然后利用 state2(信号量)和 runtime_Semacquire 挂起 goroutine
    // 当 Add 发现 counter == 0 时,会调用 runtime_Semrelease 唤醒
}

核心原理:WaitGroup 本质上是一个无锁实现,严重依赖 CAS(Compare-And-Swap)对 state1 的原子操作。goroutine 的挂起和唤醒则通过 state2(信号量)和 runtime_Semacquire / runtime_Semrelease 实现——这和 Mutex 的机制完全一样。

5.3 errgroup——增强版 WaitGroup

errgroup.Group 可以看作是 WaitGroup 的增强版,它额外提供了:

  1. 错误传递:任意一个任务返回 error,Wait 就会返回这个 error
  2. Context 取消:配合 WithContext,一损俱损
package main

import (
    "context"
    "fmt"
    "time"

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

func main() {
    // ==================== errgroup.WithContext ====================
    // 返回 Group 和 Context
    // 任何一个任务返回 error,Context 会被取消
    g, ctx := errgroup.WithContext(context.Background())

    // 任务 1:成功
    g.Go(func() error {
        time.Sleep(1 * time.Second)
        fmt.Println("任务 1 完成")
        return nil
    })

    // 任务 2:失败
    g.Go(func() error {
        time.Sleep(500 * time.Millisecond)
        fmt.Println("任务 2 出错了")
        return fmt.Errorf("任务 2 失败")
        // 此时 ctx 被取消
    })

    // 任务 3:监听 ctx,提前退出
    g.Go(func() error {
        select {
        case <-ctx.Done():
            fmt.Println("任务 3 因其他任务失败而取消")
            return ctx.Err()
        case <-time.After(3 * time.Second):
            fmt.Println("任务 3 完成")
            return nil
        }
    })

    // Wait 返回第一个出错的 error
    if err := g.Wait(); err != nil {
        fmt.Println("结果:", err)
    }
    // 输出:
    // 任务 2 出错了
    // 任务 3 因其他任务失败而取消
    // 任务 1 完成
    // 结果: 任务 2 失败
}

选择建议:大多数情况下,WaitGrouperrgroup 随便选哪个都可以,差异不大。如果需要错误传递或 context 取消功能,选 errgroup


六、channel 编程——goroutine 的通信管道

6.1 理解 channel

Go 的并发哲学是:不要通过共享内存来通信,而要通过通信来共享内存。channel 就是实现这一哲学的核心工具。

可以把 channel 理解为一个线程安全的队列

发送者 goroutine               接收者 goroutine
     │                              │
     │  data ──→  [channel]  ──→  data
     │                              │
     └────────  管道(缓冲区)  ──────┘

6.2 带缓冲 vs 不带缓冲

package main

import (
    "fmt"
    "time"
)

func main() {
    // ==================== 1. 不带缓冲的 channel ====================
    // make(chan T) 创建不带缓冲的 channel
    // 特点:发送和接收必须同时准备好,否则阻塞
    // 就像面对面交接:你必须等对方伸手了,才能把东西递过去

    unbufCh := make(chan int)

    go func() {
        fmt.Println("发送者:准备发送数据...")
        unbufCh <- 42 // 阻塞,直到有接收者
        fmt.Println("发送者:数据被接收了")
    }()

    time.Sleep(500 * time.Millisecond) // 让发送者先阻塞
    fmt.Println("接收者:准备接收数据...")
    val := <-unbufCh // 接收数据,发送者不再阻塞
    fmt.Println("接收者:收到", val)
    time.Sleep(100 * time.Millisecond)

    // ==================== 2. 带缓冲的 channel ====================
    // make(chan T, N) 创建带 N 个缓冲的 channel
    // 特点:缓冲没满时发送不阻塞,缓冲没空时接收不阻塞
    // 就像快递柜:柜子有空格就能放,柜子满了就得等有人取走

    bufCh := make(chan int, 3) // 缓冲大小为 3

    // 可以连续发送 3 个数据而不阻塞
    bufCh <- 1
    bufCh <- 2
    bufCh <- 3
    fmt.Println("放了 3 个数据到缓冲 channel")

    // 再放第 4 个会阻塞(缓冲已满)
    // bufCh <- 4 // 这行会死锁!因为没有接收者

    // 接收数据
    fmt.Println("取出:", <-bufCh) // 1(先进先出)
    fmt.Println("取出:", <-bufCh) // 2
    fmt.Println("取出:", <-bufCh) // 3
}

核心规则

  • 发送者来说:数据没地方放(缓冲满了或没缓冲且没接收者)→ 阻塞
  • 接收者来说:拿不到数据(缓冲空了或没缓冲且没发送者)→ 阻塞

6.3 channel 的使用方向

channel 有两种使用思路:

package main

import (
    "fmt"
    "time"
)

// ==================== 思路一:当作队列传递数据 ====================
func producerConsumer() {
    // 生产者-消费者模式
    ch := make(chan string, 5)

    // 生产者 goroutine
    go func() {
        fruits := []string{"苹果", "香蕉", "橙子"}
        for _, fruit := range fruits {
            ch <- fruit // 发送数据
            fmt.Printf("生产: %s\n", fruit)
        }
        close(ch) // 发送完毕后关闭 channel
    }()

    // 消费者 goroutine
    for fruit := range ch { // range 遍历 channel,直到 channel 关闭
        fmt.Printf("消费: %s\n", fruit)
        time.Sleep(100 * time.Millisecond) // 模拟处理
    }
}

// ==================== 思路二:当作令牌控制并发数 ====================
func concurrencyLimiter() {
    // 利用 channel 的阻塞特性控制 goroutine 数量
    // 就像停车场:只有 3 个车位,满了就得等

    sem := make(chan struct{}, 3) // 最多 3 个并发

    for i := 0; i < 10; i++ {
        // 从信号 channel 获取"令牌"
        // 如果已有 3 个 goroutine 在运行,这里会阻塞
        sem <- struct{}{}

        go func(id int) {
            defer func() { <-sem }() // 完成后归还"令牌"

            fmt.Printf("goroutine %d 开始\n", id)
            time.Sleep(500 * time.Millisecond)
            fmt.Printf("goroutine %d 结束\n", id)
        }(i)
    }

    // 等待所有 goroutine 完成
    // 想要获取 3 个令牌,意味着等所有 goroutine 都归还
    for i := 0; i < 3; i++ {
        sem <- struct{}{}
    }
}

func main() {
    fmt.Println("=== 生产者-消费者 ===")
    producerConsumer()
    time.Sleep(500 * time.Millisecond)

    fmt.Println("\n=== 并发限制器 ===")
    concurrencyLimiter()
}

6.4 发布订阅模式

利用 channel 实现简单的发布-订阅模式:

package main

import (
    "fmt"
    "sync"
    "time"
)

// ==================== 简单的发布-订阅 ====================

type Broker struct {
    subscribers map[chan string]struct{} // 所有订阅者
    mu          sync.RWMutex
}

func NewBroker() *Broker {
    return &Broker{
        subscribers: make(map[chan string]struct{}),
    }
}

// Subscribe 订阅消息
// 每个订阅者获得自己的 channel
func (b *Broker) Subscribe() chan string {
    ch := make(chan string, 10) // 每个订阅者缓冲 10 条消息

    b.mu.Lock()
    b.subscribers[ch] = struct{}{}
    b.mu.Unlock()

    return ch
}

// Unsubscribe 取消订阅
func (b *Broker) Unsubscribe(ch chan string) {
    b.mu.Lock()
    delete(b.subscribers, ch)
    b.mu.Unlock()
    close(ch) // 关闭 channel,通知订阅者退出
}

// Publish 发布消息
// 注意:这里是一条消息只能被一个 goroutine 消费(channel 的特性)
// 所以需要遍历所有订阅者,给每个订阅者都发一份
func (b *Broker) Publish(msg string) {
    b.mu.RLock()
    defer b.mu.RUnlock()

    for ch := range b.subscribers {
        // 给每个订阅者的 channel 发送消息
        // 注意:这里用 select + default 避免阻塞
        // 如果订阅者的 channel 满了,就跳过(不阻塞发布者)
        select {
        case ch <- msg:
        default:
            fmt.Println("订阅者 channel 已满,丢弃消息")
        }
    }
}

func main() {
    broker := NewBroker()
    var wg sync.WaitGroup

    // 3 个订阅者
    for i := 0; i < 3; i++ {
        wg.Add(1)
        ch := broker.Subscribe()
        go func(id int, ch chan string) {
            defer wg.Done()
            for msg := range ch {
                fmt.Printf("订阅者 %d 收到: %s\n", id, msg)
            }
            fmt.Printf("订阅者 %d 退出\n", id)
        }(i, ch)
    }

    // 发布 5 条消息
    for i := 0; i < 5; i++ {
        broker.Publish(fmt.Sprintf("消息 %d", i))
    }

    time.Sleep(500 * time.Millisecond)
    // 取消所有订阅(关闭所有 channel)
    broker.mu.RLock()
    for ch := range broker.subscribers {
        close(ch)
    }
    broker.mu.RUnlock()

    wg.Wait()
}

局限性:channel 实现的发布订阅没有消费组概念(同一条消息只能被一个 goroutine 消费),也无法回退或随机消费。要实现消费组需要额外的工作。

6.5 实现消息队列(消费组模式)

channel 的元素只能被一个 goroutine 取出。要让同一个消息被多个消费者消费,需要额外设计:

package main

import (
    "fmt"
    "sync"
    "time"
)

// ==================== 消息队列(支持消费组)====================

// Consumer 消费者接口
type Consumer interface {
    Consume(msg string)
}

// MessageQueue 消息队列
type MessageQueue struct {
    topicConsumers map[string][]chan string // 每个 topic 对应多个消费者的 channel
    mu             sync.RWMutex
}

func NewMessageQueue() *MessageQueue {
    return &MessageQueue{
        topicConsumers: make(map[string][]chan string),
    }
}

// Subscribe 订阅 topic
// 方案一:每个消费者订阅时创建一个子 channel
// 这样 Publish 时需要把消息复制到每个消费者的 channel
func (q *MessageQueue) Subscribe(topic string, bufSize int) chan string {
    q.mu.Lock()
    defer q.mu.Unlock()

    ch := make(chan string, bufSize)
    q.topicConsumers[topic] = append(q.topicConsumers[topic], ch)
    return ch
}

// Publish 发布消息到 topic
// 将消息广播给所有订阅了该 topic 的消费者
func (q *MessageQueue) Publish(topic string, msg string) {
    q.mu.RLock()
    defer q.mu.RUnlock()

    // 遍历所有订阅者,给每个人的 channel 都发一份
    for _, ch := range q.topicConsumers[topic] {
        select {
        case ch <- msg:
        default:
            // channel 满了,丢弃消息
        }
    }
}

func main() {
    mq := NewMessageQueue()
    var wg sync.WaitGroup

    // 3 个消费者订阅 "order" topic
    for i := 0; i < 3; i++ {
        wg.Add(1)
        ch := mq.Subscribe("order", 10)
        go func(id int, ch chan string) {
            defer wg.Done()
            for msg := range ch {
                fmt.Printf("消费者 %d 处理订单: %s\n", id, msg)
            }
        }(i, ch)
    }

    // 发布 5 条消息
    // 每条消息都会被 3 个消费者各收到一次(广播)
    for i := 0; i < 5; i++ {
        mq.Publish("order", fmt.Sprintf("订单#%d", i))
    }

    // 等待处理完成
    // 这里用 WaitGroup 简化处理,实际应该有更优雅的关闭机制
    wg2 := sync.WaitGroup{}
    wg2.Add(1)
    go func() {
        defer wg2.Done()
        time.Sleep(time.Second)
        // 关闭所有 channel
        mq.mu.Lock()
        for _, chs := range mq.topicConsumers {
            for _, ch := range chs {
                close(ch)
            }
        }
        mq.mu.Unlock()
    }()

    wg.Wait()
}

6.6 实现任务池

任务池允许开发者提交任务,并限制最多多少个 goroutine 同时运行:

package main

import (
    "fmt"
    "sync"
    "time"
)

// ==================== 任务池 ====================

// Task 表示一个任务
type Task func()

// TaskPool 任务池
type TaskPool struct {
    tasks    chan Task      // 任务队列
    wg       sync.WaitGroup // 等待所有任务完成
    workers  int            // worker 数量
}

// NewTaskPool 创建任务池
// workers:同时运行的 goroutine 数量
// queueSize:任务队列大小,满了之后 Submit 会阻塞
func NewTaskPool(workers, queueSize int) *TaskPool {
    pool := &TaskPool{
        tasks:   make(chan Task, queueSize),
        workers: workers,
    }

    // 启动指定数量的 worker goroutine
    for i := 0; i < workers; i++ {
        pool.wg.Add(1)
        go pool.worker(i)
    }

    return pool
}

// worker:不断从任务队列取任务执行
func (p *TaskPool) worker(id int) {
    defer p.wg.Done()
    for task := range p.tasks {
        // 执行任务
        task()
    }
}

// Submit 提交任务
// 如果任务队列满了,这里会阻塞(等待有空闲位置)
// 如果不想阻塞,可以用 select + default
func (p *TaskPool) Submit(task Task) {
    p.wg.Add(1)
    go func() {
        defer p.wg.Done()
        p.tasks <- task
    }()
}

// Shutdown 关闭任务池
func (p *TaskPool) Shutdown() {
    close(p.tasks) // 关闭任务队列,worker 的 range 循环会退出
    p.wg.Wait()    // 等待所有 worker 完成
}

func main() {
    // 创建一个有 3 个 worker 的任务池
    pool := NewTaskPool(3, 10)

    var taskWg sync.WaitGroup

    // 提交 10 个任务
    for i := 0; i < 10; i++ {
        taskWg.Add(1)
        taskID := i
        pool.Submit(func() {
            defer taskWg.Done()
            fmt.Printf("执行任务 %d\n", taskID)
            // 模拟耗时操作
            time.Sleep(100 * time.Millisecond)
            fmt.Printf("任务 %d 完成\n", taskID)
        })
    }

    taskWg.Wait()
    pool.Shutdown()
    fmt.Println("所有任务完成")
}

设计思考:实现任务池的难点不在于实现,而在于决策——提交任务时如果 worker 都忙,是缓存任务还是阻塞提交者?如果缓存,缓存多大?满了怎么办?这些都需要根据实际场景决定。

6.7 goroutine 泄露

channel 使用不当会导致 goroutine 泄露——goroutine 被阻塞后永远不会被唤醒,一直占用内存。

package main

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

func main() {
    // ==================== 泄露场景一:只发送不接收 ====================
    func() {
        ch := make(chan int)

        go func() {
            // 这个 goroutine 会永远阻塞
            // 因为没有人从 ch 接收数据
            ch <- 42
            fmt.Println("这行永远不会打印")
        }()

        // 函数返回后,ch 没有引用了,但 goroutine 还在阻塞
        // goroutine 泄露!
    }()

    // ==================== 泄露场景二:只接收不发送 ====================
    func() {
        ch := make(chan int)

        go func() {
            // 这个 goroutine 会永远阻塞
            // 因为没有人往 ch 发送数据
            val := <-ch
            fmt.Println("这行永远不会打印:", val)
        }()

        // goroutine 泄露!
    }()

    // ==================== 泄露场景三:读写 nil channel ====================
    func() {
        var ch chan int // nil channel

        go func() {
            // 往 nil channel 发送数据会永远阻塞
            ch <- 42
        }()

        // goroutine 泄露!
    }()

    // ==================== 正确做法:用 select + context 避免泄露 ====================
    func() {
        ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
        defer cancel()

        ch := make(chan int, 1)

        go func() {
            select {
            case ch <- 42:
                fmt.Println("发送成功")
            case <-ctx.Done():
                // 超时或取消,goroutine 正常退出,不会泄露
                fmt.Println("发送超时,退出 goroutine")
            }
        }()

        time.Sleep(200 * time.Millisecond)
    }()

    time.Sleep(500 * time.Millisecond)
    fmt.Printf("当前 goroutine 数量: %d\n", runtime.NumGoroutine())
}

核心规律:goroutine 泄露几乎都是因为 goroutine 被阻塞后没有人唤醒。唯一的例外是业务层面的 goroutine 长时间运行(不算泄露,是设计如此)。

6.8 channel 与内存逃逸

package main

import "fmt"

// ==================== 内存逃逸问题 ====================
//
// Go 的内存分配有两个地方:
// - 栈(Stack):速度快,不需要 GC,函数返回时自动回收
// - 堆(Heap):需要 GC 管理,有性能开销
//
// 如果用 channel 发送指针类型的数据,必然会导致内存逃逸
// 因为编译器无法确定指针最终会被哪个 goroutine 接收
// 为了安全,只能分配到堆上

func main() {
    ch := make(chan *int, 1)

    // 这里的 val 会被分配到堆上(逃逸)
    // 因为它通过 channel 传递给了另一个(可能的)goroutine
    val := 42
    ch <- &val

    received := <-ch
    fmt.Println(*received) // 42
}

// ==================== 如何减少逃逸? ====================
// 1. 尽量发送值类型而非指针类型
// 2. 如果必须发送指针,考虑使用 sync.Pool 复用对象
// 3. 不要过度优化,大部分场景下逃逸的性能影响可以忽略

6.9 channel 实现细节

理解 channel 的内部结构有助于回答面试问题:

// ==================== hchan(channel 的内部结构)====================

type hchan struct {
    // ==================== 数据存储 ====================
    // buf 是一个 ring buffer(环形缓冲区),用于存储缓冲数据
    // 只有带缓冲的 channel 才有 buf
    // 无缓冲 channel 的 buf 容量为 0
    buf      unsafe.Pointer // 指向环形缓冲区
    dataqsiz uint           // 环形缓冲区的大小

    // ==================== 等待队列 ====================
    // sendq:发送者等待队列(双向链表)
    //   当缓冲满了(或无缓冲且没接收者),发送者被挂到这里
    // recvq:接收者等待队列(双向链表)
    //   当缓冲空了(或无缓冲且没发送者),接收者被挂到这里
    sendq    waitq  // 等待发送的 goroutine 队列
    recvq    waitq  // 等待接收的 goroutine 队列

    // ==================== 其他字段 ====================
    closed   uint32 // channel 是否已关闭
    elemtype *_type // 元素类型
    lock     mutex  // 保护 hchan 的锁
}

// waitq 是一个双向链表
type waitq struct {
    first *sudog // 队头
    last  *sudog // 队尾
}

6.10 channel 发送步骤(chansend)

// ==================== 发送数据的步骤 ====================
//
// 步骤 1:如果是 nil channel → 直接阻塞(永远阻塞)
//   var ch chan int
//   ch <- 1  // 永远阻塞
//
// 步骤 2:如果有阻塞的接收者(recvq 不为空)
//   → 直接把数据交给接收者,不需要经过缓冲区
//   → 即使缓冲没满,也优先直接交付
//   → 唤醒接收者 goroutine
//
// 步骤 3:如果缓冲没满
//   → 把数据放入缓冲区(buf)
//   → 更新 sendx 索引(ring buffer 的写位置)
//
// 步骤 4:缓冲满了(或无缓冲且没接收者)
//   → 当前 goroutine 被包装成 sudog
//   → 加入 sendq 等待队列
//   → 调用 runtime 挂起 goroutine
//
// 步骤 5:被接收者唤醒
//   → 做一些清理工作
//   → 返回

// ==================== 关键细节 ====================
// 在步骤 2 中,即使缓冲没满,也会优先直接交给接收者
// 这是因为:如果有人等着接收,就没必要绕一圈走缓冲区

6.11 channel 接收步骤(chanrecv)

// ==================== 接收数据的步骤 ====================
//
// 步骤 1:如果是 nil channel → 直接阻塞
//
// 步骤 2:如果有阻塞的发送者(sendq 不为空)
//   → 直接从发送者手里拿数据
//   → 即使缓冲有数据,也优先拿发送者的
//   → 唤醒发送者 goroutine
//   → (如果是无缓冲 channel,这一步就是正常流程)
//
// 步骤 3:如果缓冲有数据
//   → 从缓冲区读取数据
//   → 更新 recvx 索引(ring buffer 的读位置)
//
// 步骤 4:缓冲空了(或无缓冲且没发送者)
//   → 当前 goroutine 被包装成 sudog
//   → 加入 recvq 等待队列
//   → 调用 runtime 挂起 goroutine
//
// 步骤 5:被发送者唤醒
//   → 做一些清理工作
//   → 返回

// ==================== 从已关闭的 channel 接收 ====================
// 从已关闭的 channel 接收数据不会 panic:
// - 如果缓冲区还有数据,继续返回数据
// - 如果缓冲区空了,返回零值,ok 返回 false
//   val, ok := <-closedCh  // val 是零值,ok 是 false

面试要点

  • 发送到已关闭的 channel → panic
  • 从已关闭的 channel 接收 → 返回零值,不 panic
  • 发送到 nil channel → 永远阻塞
  • 从 nil channel 接收 → 永远阻塞

七、综合实战——服务器优雅退出

7.1 场景描述

假设我们有一个 Web 服务,需要实现优雅退出:

  1. 监听系统信号(Ctrl+C),收到信号后拒绝新请求
  2. 等待已接收的请求处理完成
  3. 关闭两个端口的服务器(8080 业务端口,8081 管理端口)
  4. 注册退出回调,将缓存数据回写到数据库(防止数据丢失)

7.2 完整实现

package main

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"

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

// ==================== 1. 本地缓存(write-back 模式)====================

// LocalCache 本地缓存
// write-back 模式:数据先写到缓存,key 过期时才持久化到数据库
// 所以关闭时必须把缓存中的数据刷新到数据库
type LocalCache struct {
    mu   sync.RWMutex
    data map[string]string
}

func NewLocalCache() *LocalCache {
    return &LocalCache{
        data: make(map[string]string),
    }
}

func (c *LocalCache) Set(key, val string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = val
}

// Flush 将缓存中的数据刷新到数据库
// 在优雅退出时调用
func (c *LocalCache) Flush() error {
    c.mu.RLock()
    defer c.mu.RUnlock()

    fmt.Printf("[Cache] 正在将 %d 条数据刷新到数据库...\n", len(c.data))
    // 模拟写入数据库
    for k, v := range c.data {
        fmt.Printf("[Cache] 写入数据库: %s = %s\n", k, v)
    }
    time.Sleep(500 * time.Millisecond) // 模拟耗时
    fmt.Println("[Cache] 刷新完成")
    return nil
}

// ==================== 2. 优雅退出的服务器管理器 ====================

// GracefulServer 优雅退出的服务器管理器
type GracefulServer struct {
    servers   []*http.Server  // 需要管理的服务器列表
    cache     *LocalCache     // 需要刷新的缓存
    callbacks []func() error  // 退出回调列表
}

func NewGracefulServer(cache *LocalCache) *GracefulServer {
    return &GracefulServer{
        cache: cache,
    }
}

// AddServer 添加服务器
func (g *GracefulServer) AddServer(addr string, handler http.Handler) {
    g.servers = append(g.servers, &http.Server{
        Addr:    addr,
        Handler: handler,
    })
}

// OnShutdown 注册退出回调
func (g *GracefulServer) OnShutdown(fn func() error) {
    g.callbacks = append(g.callbacks, fn)
}

// Start 启动所有服务器并监听退出信号
func (g *GracefulServer) Start() error {
    // ==================== 第一步:用 errgroup 启动所有服务器 ====================
    // errgroup + context 实现"一个启动失败,全部退出"
    eg, egCtx := errgroup.WithContext(context.Background())

    for _, srv := range g.servers {
        srv := srv // 避免闭包捕获问题
        eg.Go(func() error {
            // ListenAndServe 会一直阻塞,直到出错才返回
            err := srv.ListenAndServe()
            if err != nil && err != http.ErrServerClosed {
                // 只有非正常关闭的错误才返回
                return err
            }
            return nil
        })
    }

    // ==================== 第二步:监听系统信号 ====================
    // 在单独的 goroutine 中监听 SIGINT (Ctrl+C) 和 SIGTERM
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)

    // 等待信号或服务器异常退出
    select {
    case sig := <-sigCh:
        fmt.Printf("\n[Shutdown] 收到信号: %v\n", sig)
    case <-egCtx.Done():
        // 某个服务器启动失败或异常退出
        fmt.Println("[Shutdown] 服务器异常退出")
    }

    // ==================== 第三步:优雅关闭服务器 ====================
    // 使用一个新的 context(脱离启动时的 context 控制)
    // 设置超时时间,防止关闭过程卡住
    shutdownCtx, shutdownCancel := context.WithTimeout(
        context.Background(),
        10*time.Second, // 给 10 秒时间完成优雅退出
    )
    defer shutdownCancel()

    // 使用 WaitGroup 等待所有服务器关闭完成
    var wg sync.WaitGroup
    for _, srv := range g.servers {
        wg.Add(1)
        srv := srv
        go func() {
            defer wg.Done()
            fmt.Printf("[Shutdown] 正在关闭服务器 %s...\n", srv.Addr)
            // Shutdown 会拒绝新请求,并等待已接收的请求处理完成
            if err := srv.Shutdown(shutdownCtx); err != nil {
                log.Printf("[Shutdown] 关闭服务器 %s 失败: %v\n", srv.Addr, err)
            } else {
                fmt.Printf("[Shutdown] 服务器 %s 已关闭\n", srv.Addr)
            }
        }()
    }
    wg.Wait()

    // ==================== 第四步:执行退出回调 ====================
    // 例如:将缓存数据刷新到数据库
    for _, cb := range g.callbacks {
        if err := cb(); err != nil {
            log.Printf("[Shutdown] 回调执行失败: %v\n", err)
        }
    }

    // 等待 errgroup 结束(此时所有服务器都已关闭,eg 也会返回)
    _ = eg.Wait()

    fmt.Println("[Shutdown] 优雅退出完成")
    return nil
}

// ==================== 3. main 函数 ====================

func main() {
    // 创建本地缓存
    cache := NewLocalCache()

    // 写入一些测试数据
    cache.Set("user:1", "张三")
    cache.Set("user:2", "李四")
    cache.Set("user:3", "王五")

    // 业务路由(8080 端口)
    businessMux := http.NewServeMux()
    businessMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        // 模拟业务处理
        time.Sleep(200 * time.Millisecond)
        w.Write([]byte("业务处理完成"))
    })
    businessMux.HandleFunc("/set", func(w http.ResponseWriter, r *http.Request) {
        key := r.URL.Query().Get("key")
        val := r.URL.Query().Get("val")
        cache.Set(key, val)
        w.Write([]byte("设置成功"))
    })

    // 管理路由(8081 端口)
    adminMux := http.NewServeMux()
    adminMux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Write([]byte("OK"))
    })

    // 创建优雅退出管理器
    gs := NewGracefulServer(cache)

    // 添加两个服务器
    gs.AddServer(":8080", businessMux)
    gs.AddServer(":8081", adminMux)

    // 注册退出回调:刷新缓存到数据库
    gs.OnShutdown(func() error {
        return cache.Flush()
    })

    // 启动服务器(会阻塞,直到收到退出信号)
    if err := gs.Start(); err != nil {
        log.Fatal(err)
    }
}

这个例子综合运用了本教程学到的所有知识点

  • context.Context:控制超时(关闭时给 10 秒)
  • errgroup:协调服务器启动,一损俱损
  • channel:监听系统信号
  • WaitGroup:等待所有服务器关闭完成
  • sync.RWMutex:保护缓存的并发读写

7.3 Kratos 框架的启动过程

Go 微服务框架 Kratos 的启动过程也用到了类似的技术组合:

Kratos 启动流程:

1. errgroup + context.Context 协调 server 启动和关闭
   → 如果一个 server 启动失败,其他 server 也会退出
   → 确保要么全部成功,要么全部失败

2. channel 监听系统信号
   → 收到 Ctrl+C 或 SIGTERM 信号后触发关闭流程

3. WaitGroup 协调所有 server 启动
   → 等所有 server 都准备好后才开始接受请求

4. Context 设置超时
   → 关闭时使用新的 context,摆脱启动时的 context 控制
   → 设置超时防止关闭卡住

八、面试要点总结

8.1 context 包

问题要点
Context 使用场景上下文传递数据 + 控制链路(取消/超时)
父亲如何控制儿子儿子主动加入父亲的 children,父亲遍历 children 逐个取消
valueCtx 原理装饰器模式,查找时从下至上
cancelCtx 原理children map + close done channel
timerCtx 原理cancelCtx + time.AfterFunc

8.2 sync 包——Mutex

问题要点
Mutex 公平性不公平锁,新来的 goroutine 可能抢到锁
两种模式正常模式(竞争)和饥饿模式(先到先得),1ms 切换
为什么两种模式兼顾效率和公平性
是否可重入不可重入
如何选择 Mutex/RWMutex写多选 Mutex,读多选 RWMutex
如何挂起/唤醒sema + runtime_SemacquireMutex / runtime_Semrelease

8.3 sync 包——Once

问题要点
实现原理double-check 变种,用原子操作代替读锁
使用场景初始化资源、单例模式

8.4 sync 包——Pool

问题要点
和 GC 的关系locals → victim → 回收,两轮 GC 缓冲
poolChain 设计双向链表 + ring buffer,容量倍增
为什么用 P 绑定避免全局锁,结合 GMP 调度优势
缺点内存使用量不可控,GC 后 Get 速率下降

8.5 sync 包——WaitGroup

问题要点
state1 的特点高 32 位 counter,低 32 位 waiter count
Wait 如何实现借助 state2 信号量 + runtime_Semacquire/Semrelease
和 Mutex 的关系都用信号量机制挂起/唤醒 goroutine

8.6 channel

问题要点
有/无 buffer 特点无缓冲需收发同时就绪,有缓冲满/空才阻塞
发送到 nil channel永远阻塞
发送到已关闭 channelpanic
从 nil channel 接收永远阻塞
从已关闭 channel 接收返回零值,不 panic
goroutine 泄露原因阻塞后无人唤醒
为什么发送指针会逃逸编译器无法确定哪个 goroutine 接收

8.7 常见代码题

  • 用 channel 实现任务池
  • 用 channel 控制 goroutine 数量
  • 用 channel 实现生产者-消费者模型
  • 用 context 实现超时控制
  • 用 errgroup 实现并发错误处理

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. context.Context 的父子关系是"控制从上至下、查找从下至上",请分别解释这两句话的含义,并说明子 Context 能否比父 Context 设置更长的超时。
  2. 为什么 valueCtx 没有被加入 cancelCtx 的 children 列表?这与"取消信号只往有取消能力的节点传播"有什么关系?
  3. double-check(双重检查)模式为什么先加读锁、再加写锁?如果只用一把写锁会有什么问题?
  4. Go 的 Mutex 为什么要有"正常模式"和"饥饿模式"两种?什么条件下会从正常模式切换到饥饿模式?
  5. sync.Pool 为什么不适合当作"数据库查询结果缓存"来用?它和 GC 的关系是什么?

动手练习(建议真做一遍)

  1. context.WithTimeout 包住一个 time.After(3*time.Second) 的查询,设 2 秒超时,用 select-case 验证触发 context deadline exceeded
  2. 写一个 100 个 goroutine 并发调用 (*SafeMap).LoadOrStore 同一 key 的测试,用 -race 跑一遍确认无数据竞争,并打印最终只有一个 goroutine 真正写入。
  3. 用 channel 实现带 3 个 worker 的 TaskPool,提交 10 个任务,观察"最多 3 个并发"且全部完成;再故意不 Close channel 触发一次 goroutine 泄露,用 runtime.NumGoroutine() 对比泄露前后。

本章小结

  • context 是不可变对象,父子关系实现"取消信号向下传播、值查找向上回溯";所有公共方法都应把 context 作为首参并 defer cancel()
  • 共享资源靠锁保护:Mutex 不可重入,读多写少用 RWMutexdouble-check 用读锁快路径 + 写锁慢路径兼顾性能与正确。
  • sync.Once 是 double-check 的原子操作变种;sync.Pool 借 GMP 每 P 一个 local 实现无锁快路径,但对象随 GC 在两轮缓冲后被回收。
  • WaitGroup/errgroup 等待一组 goroutine,errgroup 额外支持错误传递与"一损俱损"取消;channel 既可传数据也能当令牌限流、做发布订阅与任务池。
  • 全套原语最终服务于一个目标:在享受多核并发的同时,不引入数据竞争、不泄露 goroutine、能随时优雅退出。

下一篇《二、缓存 API、缓存模式与缓存异常》将直接复用本章的并发原语(RWMutex、double-check、errgroup 等),构建生产级缓存组件。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!