Skip to content

Sync and Context

Phase 02 — Go Core 涵盖:WaitGroup · Mutex · Context


1. 学习目标

完成本知识点后,你应该能够:

  • 使用 sync.WaitGroup 等待多个 goroutine 完成
  • 使用 sync.Mutex / sync.RWMutex 保护共享数据
  • 理解 context.Context 的取消、超时和传值
  • 在 HTTP handler 和并发任务中正确传递 context
  • 避免数据竞争和 goroutine 泄漏
  • 在数字孪生后端中实现可取消的批量数据采集

2. 为什么需要

channel 适合通信,但不总是最简方案。多个 goroutine 只需「等全部做完」时,WaitGroup 比 channel 更直接。多个 goroutine 读写同一份 map 或 counter 时,需要 Mutex 防止 data race。

HTTP 请求有超时:客户端断开或超过 30 秒,后端应停止无意义的 DB 查询和设备轮询。Context 是 Go 标准的取消信号传递机制,net/http 每个 Request 都带 r.Context()


3. 核心概念

3.1 sync.WaitGroup

go
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    // work
}()
wg.Wait() // 阻塞直到 counter 归零

3.2 sync.Mutex

go
var mu sync.Mutex
var count int

mu.Lock()
count++
mu.Unlock()

RWMutex:多读单写,读多写少时更高效。

3.3 context.Context

方法用途
context.Background()根 context
context.WithCancel(parent)手动取消
context.WithTimeout(parent, d)超时自动取消
context.WithValue(parent, key, val)传值(谨慎使用)
ctx.Done()接收取消信号的 channel
ctx.Err()取消原因

3.4 使用原则

  • Context 作为函数第一个参数func Do(ctx context.Context, ...)
  • 不要存到 struct 里长期持有
  • WithValue 只传 request-scoped 元数据(trace ID),不传业务参数

4. 基础语法

完整示例见 workspace/phase-02/sync-context/main.go

go
package main

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

type DeviceStore struct {
    mu      sync.RWMutex
    devices map[string]float64
}

func (s *DeviceStore) Set(id string, temp float64) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.devices[id] = temp
}

func (s *DeviceStore) Get(id string) (float64, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.devices[id]
    return v, ok
}

func fetchWithContext(ctx context.Context, id string, wg *sync.WaitGroup, store *DeviceStore) {
    defer wg.Done()

    select {
    case <-time.After(200 * time.Millisecond):
        store.Set(id, 36.5)
        fmt.Println("fetched", id)
    case <-ctx.Done():
        fmt.Println("cancelled", id, ctx.Err())
    }
}

func main() {
    store := &DeviceStore{devices: make(map[string]float64)}

    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    ids := []string{"s1", "s2", "s3"}
    var wg sync.WaitGroup
    for _, id := range ids {
        wg.Add(1)
        go fetchWithContext(ctx, id, &wg, store)
    }
    wg.Wait()

    fmt.Println("done, err:", ctx.Err())
}

5. 代码解析

go
defer wg.Done()

WaitGroup 的 Add/Done 必须配对。在 goroutine 入口 defer Done 是惯用法,即使 panic 也会执行(配合 recover 使用)。

go
s.mu.Lock()
defer s.mu.Unlock()

Mutex 锁定范围尽量小。defer Unlock 防止忘记解锁。

go
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()

必须 defer cancel(),释放 timer 资源,即使超时已触发。

go
select {
case <-ctx.Done():
    return ctx.Err()
default:
    // continue work
}

长循环中定期检查 ctx.Done(),及时响应取消。


6. JavaScript / TypeScript 对比

概念GoJavaScript
等待多个任务WaitGroupPromise.all
互斥锁sync.Mutex无(单线程,Worker 需 Atomics)
取消信号context.ContextAbortController
超时context.WithTimeoutAbortSignal.timeout / setTimeout
请求作用域r.Context()req.signal(Fetch API)

关键差异

  1. Go 多 goroutine 真正并行,Mutex 是必需品;JS 主线程无此问题
  2. Context 是显式传递的,不像 AbortSignal 可挂在 fetch 上自动传播
  3. go test -race 可检测 data race,开发阶段应开启

7. 常见错误

错误 1:WaitGroup 重复 Add 或负 counter

go
wg.Add(1)
wg.Add(1)  // 要在 go 之前 Add
go work()

错误 2:复制 Mutex

go
type Bad struct{ mu sync.Mutex }
b2 := b1  // ❌ Mutex 不可复制
// 用指针 *Bad 或嵌入 sync.Mutex

错误 3:忘记 defer cancel()

go
ctx, cancel := context.WithTimeout(...)
// 忘记 cancel() → 资源泄漏直到超时

错误 4:用 Context 传大量业务数据

go
ctx = context.WithValue(ctx, "user", hugeUserObject)  // ❌ 反模式
// 业务参数应显式传参

错误 5:锁粒度过大

整个 HTTP handler 加锁会降低并发度;只锁必要的临界区。


8. 实际应用

数字孪生 — 可取消的批量采集

go
func CollectAll(ctx context.Context, ids []string) ([]Reading, error) {
    var wg sync.WaitGroup
    results := make([]Reading, 0, len(ids))
    var mu sync.Mutex

    for _, id := range ids {
        if ctx.Err() != nil {
            break
        }
        wg.Add(1)
        go func(id string) {
            defer wg.Done()
            reading, err := pollDevice(ctx, id)
            if err != nil {
                return
            }
            mu.Lock()
            results = append(results, reading)
            mu.Unlock()
        }(id)
    }
    wg.Wait()
    return results, ctx.Err()
}

客户端断开 → HTTP context 取消 → 所有 poll goroutine 退出。

HTTP 与 Context

go
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    data, err := service.Query(ctx, id)
    if errors.Is(err, context.Canceled) {
        return // 客户端已断开
    }
}

9. 深入理解

9.1 data race

两个 goroutine 并发访问同一变量,至少一个是写,且无同步 → data race。用 -race 检测。

9.2 sync.Once

单次初始化:sync.Once 保证 Do 内的函数只执行一次,常用于 singleton。

9.3 Context 树

子 context 取消会传播到所有子孙。WithTimeout 到期自动 cancel 子树。

9.4 Mutex vs channel

Go 谚语:「用 channel 通信,用 Mutex 保护状态」。计数器、map 用 Mutex 更简单;任务分发用 channel。


10. 练习

详细题目见 exercises/phase-02-core/06-sync-context.md

Level 1 — 基础

练习 1.1:3 个 goroutine 各 sleep 不同时间,WaitGroup 等待全部完成。

练习 1.2:10 个 goroutine 各 +1 counter,用 Mutex 保护,最终 counter=10。

练习 1.3:WithTimeout 1 秒,2 秒任务应被取消。

Level 2 — 应用

练习 2.1:RWMutex 保护 map:多读单写。

练习 2.2:长循环监听 ctx.Done() 优雅退出。

练习 2.3:WithCancel 手动取消所有 worker。

Level 3 — 综合

练习 3.1:并发写 DeviceStore,读回全部数据无 race。

练习 3.2:模拟 HTTP:父 context 取消,子 goroutine 全部停止。

Level 4 — 项目实践

练习 4.1:在 workspace/phase-02/sync-context/ 构建可取消的 telemetry 采集服务:Mutex 存储 + WaitGroup 等待 + Context 超时。


11. 学习检查

  1. WaitGroup 的 Add 应该在 go 之前还是之后调用?
  2. 为什么 Mutex 不能复制?
  3. context.WithTimeout 为什么必须 defer cancel()?
  4. Context 应该存在 struct 里吗?
  5. 什么情况下用 Mutex,什么情况下用 channel?
  6. 如何检测程序中的 data race?

12. 下一步

已完成下一知识点关系
WaitGroup, Mutex, ContextPhase 03 HTTP每个请求自带 Context
Phase 06 WebSocket长连接更依赖 Context 取消

Phase 02 完成!进入 Phase 03,用 net/http 构建 REST API,对接 Vue 前端。


学习导航

上一篇:Goroutine、Channel、Select · 对应练习 · 下一篇:HTTP and REST API