Go 并发编程:从 Goroutine 到并发模式,你该知道的一切

你写了一个简单的 Go 程序,加了个 go 关键字,以为它在并发运行——但 go run -race 报出了 12 条 WARNING。你盯着满屏的竞态检测输出,不知道问题出在哪里。

这就是 Go 并发的真实写照:用起来简单,用对需要理解。Go 的并发原语数量不多——一双手指就能数完——但它们组合出来的威力极强。这篇文章会带你从底层模型到实战模式,把 Go 并发真正吃透。


一、Goroutine:一个 go 关键字背后是什么

Goroutine 是 Go 运行时管理的轻量级执行单元。与操作系统线程(OS 线程)相比,它占用的资源非常少,一个程序可以轻松创建数以万计的 goroutine。

1.1 不只是"轻量级线程"

当你写下 go doSomething(),Go 运行时做了什么?它在用户态创建了一个 goroutine,而不是在操作系统层面创建线程。

Go 使用的是 M:N 调度模型(G-P-M 模型):

  • G(Goroutine):协程,用户态的轻量级线程,包含栈、指令指针等信息。
  • P(Processor):逻辑处理器,数量由 GOMAXPROCS 决定(默认 = CPU 核心数)。P 持有本地运行队列,goroutine 在 P 上被调度。
  • M(Machine):操作系统线程,负责实际执行计算。M 必须"绑定"一个 P 才能执行 G。
1
2
3
4
5
6
创建 10,000 个并发单元:
Goroutine OS 线程
初始栈 ~2KB ~1MB
创建时间 ~2-3μs ~1ms
切换代价 ~200ns ~1-2μs
内存占用 ~20MB ~10GB

创建数万甚至数十万 goroutine 是家常便饭——这是 Go 编写高并发服务的底气。

1.2 并发执行

多个 goroutine 可以并发执行,Go 运行时的调度器负责将这些 groutine 合理地分配到操作系统线程上,从而实现并发处理。

在单核 CPU 上,goroutine 通过时间片轮转实现并发;在多核 CPU 上,多个 goroutine 可以真正并行执行。

1.3 何时阻塞,何时让出

理解调度器何时介入,是写出正确并发代码的前提。以下操作会导致 goroutine 阻塞(触发调度器重新分配 P):

  • 系统调用(文件 I/O、网络 I/O)
  • Channel 操作(无缓冲 channel 的收发、缓冲 channel 满时发 / 空时收)
  • 同步原语(Mutex.Lock()WaitGroup.Wait() 等)

以下操作导致 goroutine 主动让出 CPU 但进入可运行队列:

  • runtime.Gosched() 显式让出
  • Go 1.14+ 引入的异步抢占——即使 goroutine 陷入死循环从不调用函数,调度器也能在 10ms 内抢占它。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 直观感受调度器行为
package main

import (
"fmt"
"runtime"
"time"
)

func main() {
runtime.GOMAXPROCS(1) // 只用 1 个 OS 线程

go func() {
for i := 0; i < 5; i++ {
fmt.Println("A:", i) // fmt.Println 触发系统调用,让出 P
}
}()

go func() {
for i := 0; i < 5; i++ {
fmt.Println("B:", i)
}
}()

time.Sleep(time.Second)
}

1.4 协程执行顺序

1.4.1 主协程与子协程

主协程(main goroutine)是程序入口所在的 goroutine。只有在 main 函数及其调用的函数代码中才能够使用 go + func() 的方式启动新的协程,这些新启动的协程被称为子协程。

一旦 main 函数执行完毕,主协程就会退出,整个程序也会随之结束,无论子协程是否完成了它们的任务。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

package main

import (
"fmt"
"time"
)

func mytest() {
fmt.Println("hello, go")
}

func main() {
go mytest()
fmt.Println("hello, world")
time.Sleep(time.Second) // 等待 1 秒
}

输出:

1
2
hello, world
hello, go

在 Go 中,go 关键字只是将函数调度为 goroutine 执行,并不会立即执行该函数。

主 goroutine 会继续向下运行,因此通常主流程的输出会先发生。

如果没有同步机制(如 WaitGroup 或 channel),子 goroutine 的执行时机是不可预测的。

1.4.2 多协程执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"time"
)

func mygo(name string) {
for i := 0; i < 5; i++ {
fmt.Printf("In goroutine %s\n", name)
// 为了避免第一个协程执行过快,观察不到并发的效果,加个休眠
time.Sleep(10 * time.Millisecond)
}
}

func main() {
go mygo("协程1号") // 第一个协程
go mygo("协程2号") // 第二个协程

// 同样需要等待子协程执行完成
time.Sleep(time.Second)
}

子协程的执行顺序是由 Go 运行时调度器决定的,通常是交替执行的,但并不保证严格的轮流执行。

输出:

1
2
3
4
5
6
7
In goroutine 协程2号
In goroutine 协程1号
In goroutine 协程1号
In goroutine 协程2号
In goroutine 协程2号
In goroutine 协程1号
...

1.5 协程的生命周期

Goroutine 的生命周期包括:创建、运行、阻塞、唤醒以及结束。

  1. 创建:当你使用 go 关键字调用一个函数时,Go 运行时会创建一个新的 Goroutine。此时,新的 Goroutine 被创建并进入可运行状态,等待 Go 运行时调度器将其分配到一个操作系统线程上执行。

  2. 运行:当调度器将 Goroutine 分配到一个操作系统线程上时,Goroutine 开始运行其关联函数的代码。在运行过程中,Goroutine 会顺序执行函数中的语句,直到遇到阻塞操作、函数返回或者发生异常。

  3. 阻塞:当 Goroutine 执行到某些特定的操作时(如等待 channel、等待锁、进行 I/O 操作等),它会进入阻塞状态,暂停执行,直到满足条件后被唤醒。

  4. 唤醒:当阻塞的条件满足时(如 channel 有数据可读、锁被释放、I/O 操作完成等),Goroutine 会被唤醒,重新进入可运行状态,等待调度器再次分配它到一个操作系统线程上继续执行。

  5. 结束:Goroutine的生命周期在以下几种情况下结束:

主协程结束: 主Goroutine的结束意味着整个程序的结束,无论此时子Goroutine是否执行完成。
函数正常返回:Goroutine协程会一直运行,直到函数中的所有语句都执行完毕并返回。
发生未捕获的异常:如果Goroutine在执行过程中发生了未被recover捕获的panic,Goroutine会终止,并导致程序崩溃


二、Channel:用通信来共享内存

Go 并发哲学的第一条诫律:

不要通过共享内存来通信,而要通过通信来共享内存。

Channel 就是这条哲学的核心实现。它同时做两件事:传递数据 + 同步 goroutine

无缓冲 vs 有缓冲

1
2
ch := make(chan int)      // 无缓冲:发送和接收必须同时就绪
ch := make(chan int, 10) // 有缓冲:缓冲区未满时发送不阻塞

无缓冲 channel 的本质是同步点:发送方必须等接收方准备好,接收方必须等发送方有数据。这其实是 Go 中最强的同步保证。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 无缓冲:天然同步
func main() {
done := make(chan bool)

go func() {
// 做一些工作...
time.Sleep(1 * time.Second)
done <- true // 阻塞直到主 goroutine 开始等待
}()

<-done // 阻塞直到 goroutine 完成
fmt.Println("完成")
}

有缓冲 channel 则允许发送方"领先"接收方一定步数——这是一种性能优化,但不要为了"怕阻塞"而随意加大缓冲。先用无缓冲,确认有性能瓶颈后再考虑缓冲。

关闭 Channel 的铁律

三条规则,违反就 panic:

  1. 只有发送方应该关闭 channel——向已关闭的 channel 发送会 panic。
  2. 从已关闭且排空的 channel 接收——返回零值,且 okfalse
  3. 不要重复关闭同一个 channel——会 panic。
1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)

// range 会在 channel 关闭并排空后自动退出
for v := range ch {
fmt.Println("收到:", v)
}
fmt.Println("channel 已关闭,循环结束")
}

select:多路复用的核心

select 是 Go 并发的瑞士军刀——它可以同时等待多个 channel 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 超时控制
select {
case res := <-ch:
fmt.Println("收到:", res)
case <-time.After(1 * time.Second):
fmt.Println("超时!")
}

// 非阻塞尝试
select {
case ch <- value:
fmt.Println("发送成功")
default:
fmt.Println("channel 已满,跳过")
}

// 动态禁用 case——将 channel 设为 nil
// nil channel 上的收发会永久阻塞,从而在 select 中被跳过

最后那点是很多 Go 开发者不知道的技巧:nil channel 在 select 中对应的 case 永远不会被选中。你可以利用这个特性实现"读完某个 channel 后自动禁用对应 case"。


同步三件套:WaitGroup、Mutex、RWMutex

WaitGroup:等大家都干完

sync.WaitGroup 解决一个朴素的问题:启动 N 个 goroutine,然后等它们全部完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
wg.Add(1) // 必须在 go 之前调用!
go func(id int) {
defer wg.Done() // 用 defer 确保一定调用
fmt.Printf("Worker %d 工作中\n", id)
}(i)
}

wg.Wait() // 阻塞直到计数归零
fmt.Println("全部完成")
}

三个最容易犯的错:

  1. 值拷贝WaitGroup 必须传指针,拷贝会导致计数器永远不归零。
  2. Add 放在 go 后面 — 可能 Wait() 先于 Add() 执行。
  3. Done 比 Add 多 — 计数器变负直接 panic。

Mutex:保护共享状态

当你确实需要多个 goroutine 读写同一块内存时,用 sync.Mutex 保护临界区:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Counter struct {
mu sync.Mutex
value int
}

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // defer 保证 unlock 一定执行
c.value++
}

func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}

defer Unlock 不是可选的——是必须的。 如果你在 early return 前手动 unlock,漏一次就是死锁。

注意 Go 的 Mutex 不可重入:同一个 goroutine 不能对同一个 Mutex 加两次锁,否则直接死锁。如果你的函数调用链中可能出现重复加锁,需要重构代码避免这种设计。

RWMutex:读多写少时的性能利器

当你的数据 80% 以上的操作是读、写很少时,sync.RWMutex 比 Mutex 性能好得多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Cache struct {
mu sync.RWMutex
m map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // 读锁——多个 goroutine 可同时持有
defer c.mu.RUnlock()
v, ok := c.m[key]
return v, ok
}

func (c *Cache) Set(key, value string) {
c.mu.Lock() // 写锁——排他,等待所有读锁/写锁释放
defer c.mu.Unlock()
c.m[key] = value
}

选择依据很简单:读比例 > 80% → RWMutex,否则 → Mutex。RWMutex 内部有更复杂的读写计数逻辑,写多读少时反而更慢。


实战:日常开发中的并发模式

Worker Pool

需要并发处理一批任务,但不想创建无限制的 goroutine:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func workerPool(numWorkers int, jobs <-chan int, results chan<- int) {
var wg sync.WaitGroup

for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs { // jobs 关闭后自动退出
results <- job * 2
}
}(i)
}

go func() {
wg.Wait()
close(results) // 所有 worker 完成后关闭结果 channel
}()
}

Context:超时和取消的传播

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func doWork(ctx context.Context, id int) error {
select {
case <-time.After(2 * time.Second):
return nil // 正常完成
case <-ctx.Done():
return ctx.Err() // 被取消或超时
}
}

func main() {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // 防止泄露

go doWork(ctx, 1)
<-ctx.Done()
fmt.Println("超时:", ctx.Err())
}

-race 检测竞态条件

Go 内置的 race detector 是并发编程的第一道防线:

1
2
go test -race ./...
go run -race main.go

它会插桩所有内存访问,运行时检测到竞态就报 WARNING。代价是 5-10 倍慢 + 5-10 倍内存,只在测试和开发环境中使用


避坑清单

编写和审查 Go 并发代码时,对着这份清单逐条检查:

  1. Goroutine 泄漏 — 是否存在"发送到一个没人接收的 channel"或"range 一个永不关闭的 channel"?每个 goroutine 必须有退出路径。
  2. WaitGroup 值拷贝 — 检查所有 Add/Done/Wait 的调用方是否用的是同一个 *WaitGroup
  3. Channel 关闭者 — 确认只有发送方关闭 channel,接收方不会 close
  4. Mutex 不可重入 — 同一个 goroutine 是否可能对同一个 Mutex 加两次锁?
  5. select 缺少取消分支 — 循环内的 select 是否包含了 ctx.Done()
  6. defer Unlock — 是否每个 Lock() 都有对应的 defer Unlock()
  7. CI 中跑了 -race — 是否在 CI pipeline 中执行了 go test -race

总结

Go 并发的力量来自于一个简单的公式:少数原语 × 清晰的哲学 = 无限组合

  • Goroutine 给了你廉价到几乎免费的并发——大胆用,但记得给每个 goroutine 一个退出路径。
  • Channel 是 Go 并发的心脏——优先用它传递数据所有权,而非用锁共享内存。
  • WaitGroup、Mutex、RWMutex 各司其职——选对工具,用对模式。
  • -race 是你最好的朋友——每一次 go test 都带上它。

当你下次看到满屏的 WARNING: DATA RACE 时,希望你能一眼看出问题所在——并且知道怎么改。


深入阅读:Go Concurrency Patterns (Rob Pike)、Share Memory By Communicating、errgroup 包文档。