学习目标
学完本章你应该能够:
- 说清
context.Context四个方法(Deadline/Done/Err/Value)的用途,并解释"父取消子也取消、子不能控制父"的父子传播规则。 - 用 Mutex / RWMutex 保护共享变量、识别数据竞争;理解
double-check(双重检查)模式在 SafeMap、sync.Once 中的通用套路。 - 解释 Go Mutex 的"正常模式/饥饿模式"、sync.Pool 借助 GMP 中每 P 一个 local 实现无锁快路径的设计动机。
- 用 WaitGroup / errgroup 编排一组 goroutine,并用 channel 实现生产者-消费者、令牌限流、发布订阅、任务池等并发模型。
- 综合前述原语,实现一个支持超时取消、能优雅退出的服务器(本篇实战主题)。
前置知识:
- Go 基础语法、
map与struct用法; - 对"多核并行"“线程"概念有直观认识即可,无需并发经验;
- 了解 Go 的 GMP 调度概念(P 即处理器)会更有帮助,但文中会补充。
本章你会动手做的事:
- 用
context.WithTimeout改写一个 3 秒的 DB 查询,使其在 2 秒超时被取消,并用select-case观察"谁先到”。 - 用
sync.Pool复用bytes.Buffer,对比 Get/Put 前后对象是否被复用,并验证Reset的必要性。 - 用 channel 写一个带缓冲的任务池(
TaskPool),提交 10 个任务、限制 3 个 worker 并发,观察完成顺序。
前言:为什么需要并发编程
在单核 CPU 时代,程序是"排队执行"的——一个任务做完了,再做下一个。但随着多核 CPU 的普及,如果还让程序串行执行,那就相当于有一条八车道的高速公路,却只开了一条车道,白白浪费了性能。
Go 语言从设计之初就把并发作为一等公民,提供了两大并发原语:
- goroutine:轻量级的"协程",比操作系统线程更轻量,创建一个 goroutine 只需几 KB 内存
- channel:goroutine 之间安全传递数据的"管道"
但在实际开发中,光有 goroutine 和 channel 还不够。当多个 goroutine 同时访问共享资源时,就会产生数据竞争(Data Race)问题。为了解决这个问题,Go 提供了 sync 包和 context 包,它们是并发编程的核心工具箱。
本教程将从零开始,带你理解 Go 并发编程的四大核心主题:
- context 包——控制 goroutine 的生命周期和传递数据
- sync 包(Mutex / RWMutex / Once / Pool / WaitGroup)——保护共享资源
- channel 编程——goroutine 间通信
- 综合实战——服务器优雅退出
一、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 实例是不可变的。每次调用
WithValue、WithCancel等方法,都不会修改原 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 实现——取消信号的传播
cancelCtx 是 WithCancel 和 WithTimeout 内部使用的结构体,它负责管理取消信号的传播。
白话类比:
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{}))
}
面试要点:
cancelCtx的Done()方法使用了 double-check 机制(先原子检查,再加锁创建),这种原子操作和锁结合的用法比较罕见。
1.8 timerCtx 实现——超时控制
timerCtx 在 cancelCtx 的基础上增加了定时器功能,用于实现 WithTimeout 和 WithDeadline。
// ==================== 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——一损俱损的并发控制
errgroup 是 golang.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 模式,这是一个非常重要的并发编程技巧。核心思路是:
- 第一次检查(读锁):先用读锁快速检查,如果满足条件就直接返回,避免获取写锁的开销
- 第二次检查(写锁):如果不满足条件,获取写锁后再检查一次,防止竞态条件
// ==================== 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,挂起当前 goroutineruntime_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 中,频繁创建和销毁对象会带来两个问题:
- 内存分配开销:每次
new或make都需要向操作系统申请内存 - 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 偷 │
└──────────────────────────────────────────────────────┘
核心设计:
- 每个 P(Processor)一个 poolLocal:P 是 Go 调度器中的"处理器"概念。P 同一时间只有一个 goroutine 在运行,所以绑定在 P 上的数据不需要加锁
- private 字段:每个 P 有一个 private 对象,获取和归还都无竞争
- shared poolChain:当 private 不可用时,使用 shared 链表。shared 可以被其他 P 偷走
- 窃取机制:自己的 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 的增强版,它额外提供了:
- 错误传递:任意一个任务返回 error,Wait 就会返回这个 error
- 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 失败
}
选择建议:大多数情况下,
WaitGroup和errgroup随便选哪个都可以,差异不大。如果需要错误传递或 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 服务,需要实现优雅退出:
- 监听系统信号(Ctrl+C),收到信号后拒绝新请求
- 等待已接收的请求处理完成
- 关闭两个端口的服务器(8080 业务端口,8081 管理端口)
- 注册退出回调,将缓存数据回写到数据库(防止数据丢失)
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 | 永远阻塞 |
| 发送到已关闭 channel | panic |
| 从 nil channel 接收 | 永远阻塞 |
| 从已关闭 channel 接收 | 返回零值,不 panic |
| goroutine 泄露原因 | 阻塞后无人唤醒 |
| 为什么发送指针会逃逸 | 编译器无法确定哪个 goroutine 接收 |
8.7 常见代码题
- 用 channel 实现任务池
- 用 channel 控制 goroutine 数量
- 用 channel 实现生产者-消费者模型
- 用 context 实现超时控制
- 用 errgroup 实现并发错误处理
自测题与动手练习
自测题(合上书能答出来,才算懂):
context.Context的父子关系是"控制从上至下、查找从下至上",请分别解释这两句话的含义,并说明子 Context 能否比父 Context 设置更长的超时。- 为什么
valueCtx没有被加入cancelCtx的 children 列表?这与"取消信号只往有取消能力的节点传播"有什么关系? double-check(双重检查)模式为什么先加读锁、再加写锁?如果只用一把写锁会有什么问题?- Go 的 Mutex 为什么要有"正常模式"和"饥饿模式"两种?什么条件下会从正常模式切换到饥饿模式?
sync.Pool为什么不适合当作"数据库查询结果缓存"来用?它和 GC 的关系是什么?
动手练习(建议真做一遍):
- 用
context.WithTimeout包住一个time.After(3*time.Second)的查询,设 2 秒超时,用select-case验证触发context deadline exceeded。 - 写一个 100 个 goroutine 并发调用
(*SafeMap).LoadOrStore同一 key 的测试,用-race跑一遍确认无数据竞争,并打印最终只有一个 goroutine 真正写入。 - 用 channel 实现带 3 个 worker 的
TaskPool,提交 10 个任务,观察"最多 3 个并发"且全部完成;再故意不Closechannel 触发一次 goroutine 泄露,用runtime.NumGoroutine()对比泄露前后。
本章小结
context是不可变对象,父子关系实现"取消信号向下传播、值查找向上回溯";所有公共方法都应把context作为首参并defer cancel()。- 共享资源靠锁保护:
Mutex不可重入,读多写少用RWMutex;double-check用读锁快路径 + 写锁慢路径兼顾性能与正确。 sync.Once是 double-check 的原子操作变种;sync.Pool借 GMP 每 P 一个 local 实现无锁快路径,但对象随 GC 在两轮缓冲后被回收。WaitGroup/errgroup等待一组 goroutine,errgroup额外支持错误传递与"一损俱损"取消;channel 既可传数据也能当令牌限流、做发布订阅与任务池。- 全套原语最终服务于一个目标:在享受多核并发的同时,不引入数据竞争、不泄露 goroutine、能随时优雅退出。
下一篇《二、缓存 API、缓存模式与缓存异常》将直接复用本章的并发原语(RWMutex、double-check、errgroup 等),构建生产级缓存组件。