Skip to content

第09章 并发编程 —— Go 真正的杀手锏

本章目标:理解 goroutine 和 channel 的设计哲学,会用 select、sync 包,能写出生产级的并发代码。


1. 为什么 Go 的并发这么牛?

🌰 生活化比喻:传统线程像雇佣全职员工——招一个要交社保、培训、提供办公室(每个线程占用 ~MB 栈空间)。Goroutine 像临时工众包——按需雇佣,不工作时自动遣散,每个仅占 几 KB

对比OS 线程Goroutine
创建开销几 MB 栈空间2KB 起步,按需扩
切换成本内核态切换 ~1μs用户态切换 ~ns
创建数量几千几百万轻松
调度OS 调度器Go runtime(M:N 模型)

📊 配套图解:diagrams/goroutine-vs-thread.svg


2. goroutine:100 万个并发不是梦

go
go func() {
    fmt.Println("我是 goroutine")
}()

就一个 go 关键字——前置在函数调用上即可。

2.1 简单例子

go
func say(s string) {
    for i := 0; i < 3; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    go say("world")
    say("hello")
}

输出(顺序不固定):

hello
world
hello
world
hello
world

2.2 ⚠️ main 退出,所有 goroutine 一起死

go
func main() {
    go func() {
        time.Sleep(time.Second)
        fmt.Println("我可能没机会打印")
    }()
}   // main 结束,goroutine 也结束

要让 main 等待,常用方法:

  • sync.WaitGroup
  • channel 阻塞接收

3. channel:goroutine 之间的"管道"

🌰 生活化比喻:channel 像传送带——一头放东西,另一头接东西。两边的 goroutine 互不打扰。

go
ch := make(chan int)

go func() {
    ch <- 42        // 发送
}()

x := <-ch           // 接收
fmt.Println(x)      // 42

3.1 channel 三大要点

  1. 发送和接收默认阻塞:发送方等接收方,反之亦然
  2. 无缓冲 channel = 强制握手:make(chan int)
  3. 有缓冲 channel = 队列:make(chan int, 10)
go
// 无缓冲:必须有人接才能发
ch1 := make(chan int)

// 有缓冲:缓冲未满前发送不阻塞
ch2 := make(chan int, 3)
ch2 <- 1   // 不阻塞
ch2 <- 2   // 不阻塞
ch2 <- 3   // 不阻塞
ch2 <- 4   // 阻塞!缓冲满了

3.2 关闭 channel

go
close(ch)

关闭后:

  • 不能再发送(panic)
  • 可以继续接收剩余数据
  • 接收完后,再接收得到零值 + ok = false
go
v, ok := <-ch
if !ok {
    fmt.Println("channel 已关闭")
}

📊 配套图解:diagrams/channel-flow.svg

3.3 范式:生产者-消费者

go
ch := make(chan int, 5)

// 生产者
go func() {
    defer close(ch)
    for i := 1; i <= 10; i++ {
        ch <- i
    }
}()

// 消费者
for v := range ch {
    fmt.Println(v)
}

4. select:多路复用

go
select {
case v := <-ch1:
    fmt.Println("从 ch1 收到", v)
case ch2 <- 42:
    fmt.Println("成功发到 ch2")
case <-time.After(time.Second):
    fmt.Println("超时")
default:
    fmt.Println("没事可做")
}

🌰 生活化比喻:select 像多个门铃——哪个先响处理哪个。

4.1 常见模式

超时控制

go
select {
case res := <-doSomething():
    handle(res)
case <-time.After(3 * time.Second):
    return errors.New("超时")
}

取消信号

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

go worker(ctx)

time.Sleep(time.Second)
cancel()   // 通知 worker 该停了

func worker(ctx context.Context) {
    for {
        select {
        case <-ctx.Done():
            return
        default:
            // 干活...
        }
    }
}

📊 配套图解:diagrams/select-pattern.svg


5. sync 包:传统锁机制

虽然 Go 推崇 channel,但 sync 包也很有用。

5.1 sync.Mutex(互斥锁)

go
var (
    mu    sync.Mutex
    count int
)

func inc() {
    mu.Lock()
    defer mu.Unlock()
    count++
}

5.2 sync.RWMutex(读写锁)

读多写少时性能优势明显:

go
var (
    rw    sync.RWMutex
    cache = map[string]int{}
)

func read(k string) int {
    rw.RLock()
    defer rw.RUnlock()
    return cache[k]
}

func write(k string, v int) {
    rw.Lock()
    defer rw.Unlock()
    cache[k] = v
}

5.3 sync.WaitGroup(等待一组任务)

go
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(n int) {
        defer wg.Done()
        time.Sleep(time.Second)
        fmt.Println(n)
    }(i)
}
wg.Wait()  // 阻塞直到所有 Done

5.4 sync.Once(只执行一次)

go
var once sync.Once
once.Do(func() {
    fmt.Println("只打印一次")
})
once.Do(func() {  // 不会再执行
    fmt.Println("不会打印")
})

常用于单例初始化

📊 配套图解:diagrams/sync-tools.svg


6. 并发安全的 map:sync.Map

go
var m sync.Map

m.Store("a", 1)
v, ok := m.Load("a")
m.Delete("a")

m.Range(func(k, v any) bool {
    fmt.Println(k, v)
    return true   // 返回 false 停止遍历
})

⚠️ 不要把 sync.Map 当成"通用 map" 用。它只适合读多写少或 key 集合长期稳定的场景。

普通场景用 map + sync.RWMutex 更好。


7. context:贯穿 goroutine 的"上下文"

context.Context 是 Go 中"取消信号 + 超时 + 元数据"的标准方式。

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

resp, err := http.Get(ctx, "https://...")

主要 API

go
context.Background()                       // 根 context
context.TODO()                             // 占位用
context.WithCancel(parent)                 // 可手动取消
context.WithTimeout(parent, dur)           // 超时取消
context.WithDeadline(parent, time)         // 到点取消
context.WithValue(parent, key, val)        // 携带元数据

7.1 标准用法

go
func handler(ctx context.Context, req Request) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case result := <-doWork(req):
        return result
    }
}

8. ⚠️ 数据竞争(Data Race)

go
var counter int
for i := 0; i < 1000; i++ {
    go func() { counter++ }()
}
// counter 最终是多少?不一定!

counter++ 不是原子操作,多 goroutine 同时改会出现"脏数据"。

检测

bash
go run -race main.go
go test -race ./...

修复

  • sync.Mutex
  • atomic.AddInt64
  • 用 channel 串行化操作

9. 实战案例:并发爬虫

go
package main

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

func fetch(url string, wg *sync.WaitGroup, sem chan struct{}) {
    defer wg.Done()
    sem <- struct{}{}      // 占用一个并发名额
    defer func() { <-sem }()

    start := time.Now()
    resp, err := http.Get(url)
    if err != nil {
        fmt.Println("失败:", url, err)
        return
    }
    defer resp.Body.Close()
    fmt.Printf("[%dms] %s%s\n", time.Since(start).Milliseconds(), url, resp.Status)
}

func main() {
    urls := []string{
        "https://go.dev",
        "https://github.com",
        "https://google.com",
    }
    sem := make(chan struct{}, 5)   // 最多 5 个并发
    var wg sync.WaitGroup
    for _, u := range urls {
        wg.Add(1)
        go fetch(u, &wg, sem)
    }
    wg.Wait()
}

考点:goroutine + WaitGroup + 带缓冲 channel 做信号量。


10. 本章小结

✅ goroutine 是用户态轻量协程,开几百万都行
go func(){}() 启动 goroutine
✅ channel 是 goroutine 通信的官方推荐方式
✅ 无缓冲 channel = 强制握手;带缓冲 = 队列
select 多路复用,常配合 time.After 做超时
sync.Mutex/RWMutex/WaitGroup/Once 是经典并发原语
✅ 共享数据竞争用 -race 检测
context.Context 是 Go 中取消和超时的标准
✅ Go 哲学:"Don't communicate by sharing memory; share memory by communicating"


📂 配套资源