主题
第09章 并发编程 —— Go 真正的杀手锏
本章目标:理解 goroutine 和 channel 的设计哲学,会用 select、sync 包,能写出生产级的并发代码。
1. 为什么 Go 的并发这么牛?
🌰 生活化比喻:传统线程像雇佣全职员工——招一个要交社保、培训、提供办公室(每个线程占用 ~MB 栈空间)。Goroutine 像临时工众包——按需雇佣,不工作时自动遣散,每个仅占 几 KB。
| 对比 | OS 线程 | Goroutine |
|---|---|---|
| 创建开销 | 几 MB 栈空间 | 2KB 起步,按需扩 |
| 切换成本 | 内核态切换 ~1μs | 用户态切换 ~ns |
| 创建数量 | 几千 | 几百万轻松 |
| 调度 | OS 调度器 | Go runtime(M:N 模型) |
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
world2.2 ⚠️ main 退出,所有 goroutine 一起死
go
func main() {
go func() {
time.Sleep(time.Second)
fmt.Println("我可能没机会打印")
}()
} // main 结束,goroutine 也结束要让 main 等待,常用方法:
sync.WaitGroupchannel阻塞接收
3. channel:goroutine 之间的"管道"
🌰 生活化比喻:channel 像传送带——一头放东西,另一头接东西。两边的 goroutine 互不打扰。
go
ch := make(chan int)
go func() {
ch <- 42 // 发送
}()
x := <-ch // 接收
fmt.Println(x) // 423.1 channel 三大要点
- 发送和接收默认阻塞:发送方等接收方,反之亦然
- 无缓冲 channel = 强制握手:
make(chan int) - 有缓冲 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() // 阻塞直到所有 Done5.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"