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