Go 高手(00):并发模型——goroutine 与 channel
更新时间:2026-08-25。本文是
languages/go/intermediate/高手层第 0 篇。回答:goroutine 为什么轻?channel 怎么用才正确?Go 并发与 C++ 线程/无锁编程有何异同?
一、goroutine 为什么"轻"
goroutine 是 Go 的核心并发单元,官方称为 lightweight thread。它有多轻,从栈说起:
| 维度 | 线程(OS) | goroutine |
|---|---|---|
| 创建栈 | 约 1~8 MB(固定) | 初始 2 KB,按需动态增长 |
| 创建/切换成本 | 内核态系统调用 | 用户态协作/抢占切换 |
| 数量上限 | 数千级 | 百万级 |
| 调度 | 内核调度器 | 用户态 Go 调度器 |
| 关联 | 一个线程绑定内核栈 | 成千上万个 goroutine 复用一个内核线程 |
核心结论:goroutine 是"跑在若干 OS 线程上的虚拟执行流",由 Go runtime 调度,而不是内核。这就是 M:N 调度。
二、GMP 调度模型
GMP 三要素:
- G(goroutine):协程,包含栈、上下文、状态。
- M(machine):OS 内核线程,实际执行者。
- P(processor):逻辑处理器,持有本地运行队列(
runq),决定"哪些 G 跑在哪个 M 上"。
关键参数:GOMAXPROCS 决定 P 的数量,默认 = CPU 核数。P 多不代表快——只有真正并行计算才受益;I/O 型任务主要由 runtime 的 netpoller 处理。
触发调度(切换 G)的事件:
go语句创建新 G- 系统调用阻塞(进入 netpoller 或交给 M 的 sysmon 处理)
- GC 停顿
- 显式
runtime.Gosched() - 抢占(
runtime定期检查,超过 10ms 强占长时间运行的 G)
三、channel:通信与同步的一体两面
Go 的哲学:"不要通过共享内存来通信,而要通过通信来共享内存。"channel 就是这个通信通道。
3.1 基本使用
// 声明带缓冲 channel
ch := make(chan int, 10)
go func() {
ch <- 42 // 发送(缓冲满则阻塞)
}()
v := <-ch // 接收(缓冲空则阻塞)
close(ch) // 关闭,接收方读到零值/ok=false3.2 有无缓冲的区别
| channel | 语义 | 适用 |
|---|---|---|
无缓冲 make(chan T) | 发送必须等接收,同步握手 | 两个 goroutine 间的"会合"、信号 |
带缓冲 make(chan T, n) | 发送可先入缓冲,直到满才阻塞 | 生产-消费解耦、限流 |
// 无缓冲:同步信号,类似屏障
done := make(chan struct{})
go func() { /* 干活 */; close(done) }()
<-done // 等待干完
// 带缓冲:worker pool 分发
jobs := make(chan int, 100)3.3 谁关闭 channel?—— 发送者关闭原则
只有发送者能 close,接收者不要关。向已关闭 channel 发送会 panic。用 for range 安全消费:
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // 只有发送者 close
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch { // 读到 close 自动退出
fmt.Println(v)
}
}3.4 select:多路复用
select 在多个 channel 上等待,任一就绪就执行;default 实现非阻塞:
for {
select {
case v, ok := <-chA:
if !ok { return } // chA 已关闭,退出
handleA(v)
case v := <-chB:
handleB(v)
case <-ctx.Done():
return // 超时/取消
default:
// 所有 channel 都未就绪时的兜底
}
}四、sync 包:需要"共享内存"时的同步原语
channel 适合通信,但保护共享可变数据仍要用锁。Go 的 sync 包:
| 原语 | 用途 | 说明 |
|---|---|---|
sync.Mutex | 互斥锁 | 保护临界区,Lock/Unlock |
sync.RWMutex | 读写锁 | 多读单写,读多写少时高效 |
sync.WaitGroup | 等待一组 goroutine | Add/Done/Wait |
sync.Once | 只执行一次 | 单例、初始化 |
sync.Cond | 条件变量 | 等待/唤醒特定条件 |
sync.Map | 并发安全 map | 读多写少的特化场景 |
var (
mu sync.Mutex
data map[string]int
)
func inc(key string) {
mu.Lock()
defer mu.Unlock()
data[key]++
}注意:sync.Mutex 在 Go 1.9+ 已有饥饿模式与公平队列,但仍要避免在热点锁上大量竞争——这就是性能剖析的主题。锁竞争优化见下文"与本站主线衔接"。
五、常见并发模式
5.1 Worker Pool(生产者-消费者)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * 2 // 模拟处理
}
}
func main() {
const n = 4
jobs := make(chan int, 100)
results := make(chan int, 100)
var wg sync.WaitGroup
for w := 0; w < n; w++ { // 4 个 worker
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for j := 0; j < 20; j++ {
jobs <- j
}
close(jobs)
wg.Wait() // 等所有 worker 结束
close(results)
}5.2 Fan-out / Fan-in(扇出扇入)
扇出:一个源分发到多个 goroutine;扇入:把多个 goroutine 的结果合并到一个 channel。
5.3 超时与取消(context)
context.Context 贯穿 Go 并发,用于传递取消信号、超时、请求作用域值:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
select {
case res := <-doWork():
fmt.Println(res)
case <-ctx.Done():
fmt.Println("timeout:", ctx.Err())
}六、与 C++ 线程 / 无锁编程对比
| 维度 | Go goroutine | C++ 线程 |
|---|---|---|
| 创建成本 | 极低,百万级 | 较高,千级 |
| 同步 | channel / sync | mutex / condition_variable |
| 内存序 | 见 Go 内存模型,由 runtime 保证一定顺序 | 需显式 memory_order |
| 无锁 | atomic 有,但 GC 带来额外考量 | CAS/FAA 广泛用于 无锁队列 |
| 数据竞争检测 | go test -race 内置 | 需 TSan |
关键差异:Go 的 -race 检测是内建的一等公民,开发期几乎免费帮你抓 data race——这是 C++ 较难做到的。
七、与本站主线衔接
- GOMAXPROCS 与核数:并行度由 P 决定,超配会导致上下文切换开销 → 用 mpstat 看 CPU 是否打满、pidstat 看线程状态。
- 锁竞争热点:
sync.Mutex竞争会让 goroutine 阻塞在锁上 → 用 pprof 的block/mutexprofile 定位(见 pprof 性能剖析)。 - 调度原理:goroutine 的 M:N 调度与 OS 线程调度是两级——OS 级抢占、用户级协作式 → 类比本站 调度与上下文切换。
- 无锁优化思路:Go 里同样可以追求无锁队列,只是要考虑 GC 与逃逸 → 衔接 无锁编程深入 与 SPSC/MPSC 队列四象限。
实战:用 goroutine + channel 写一个并发爬虫骨架
光讲模型不如写一段能跑的。下面这个骨架用 worker pool 限制并发、用 channel 收集结果,是 Go 并发最常见的落地形态:
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j // 模拟"处理"
}
}
func main() {
jobs := make(chan int, 10)
results := make(chan int, 10)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ { // 3 个 worker
wg.Add(1)
go worker(w, jobs, results, &wg)
}
for i := 1; i <= 9; i++ { jobs <- i }
close(jobs)
go func() { wg.Wait(); close(results) }()
for r := range results { fmt.Println(r) }
}这个例子的要点:jobs 是无缓冲意图 + 带容量缓冲的 task 队列,worker 数固定(3 个),主 goroutine 投完任务 close(jobs) 通知 worker 退出 range;再用一个单独的 goroutine 等所有 worker 结束再 close(results),避免主循环 range results 永远等。模式可套到任何"生产-消费"场景。
channel 的正确用法与典型坑
channel 用错比不用更糟,几个高频坑:
- 忘了 close 导致接收方永久阻塞:
for v := range ch依赖发送方close(ch),否则接收 goroutine 泄漏。 - 向已 close 的 channel 发送会 panic:close 的责任应唯一归属发送方。
- 无缓冲 channel 的隐形耦合:发送和接收必须同时就绪,否则双方互等——这往往成为死锁或吞吐瓶颈,必要时用带容量缓冲解耦。
- 不要用 channel 传递大结构体:传指针或 ID 更省,避免无谓拷贝(也减少 逃逸分析 里的堆分配)。
select 的多路复用让"超时 + 退出"变优雅:
select {
case r := <-results:
handle(r)
case <-time.After(2 * time.Second):
return errors.New("timeout")
case <-ctx.Done():
return ctx.Err() // 支持取消
}ctx(context)是 Go 控制 goroutine 生命周期的标准手段,配合 select 能做到"一个 cancel 让整条调用链优雅退出",比手动到处传 stop channel 干净得多。
和 C++ 线程模型对照(为什么 Go 并发好写)
| 维度 | C++ 线程 | Go goroutine |
|---|---|---|
| 调度 | OS 1:1,内核调度 | GMP M:N,用户态调度 |
| 栈 | 固定(数 MB) | 起步 2KB,动态扩缩 |
| 通信 | 共享内存 + 锁 | channel("不要通过共享内存通信") |
| 数量级 | 数千即吃力 | 数十万轻松 |
C++ 的 并发与线程安全 要你亲手管锁、条件变量、死锁;Go 把"通信"当成第一公民,多数场景用 channel 就能避开显式锁。代价是 runtime 更重、对极致延迟的掌控弱于手写的无锁结构——选哪个,看你是要开发效率还是要榨干硬件。
一句话总结
goroutine 是"M:N 用户态轻量线程",channel 是"通信即同步"的优雅抽象;写对并发靠三件事:channel 发送者关闭、sync 保护共享数据、-race 抓数据竞争——写快则靠 pprof 定位锁竞争与调度热点。
深入:Go 内存模型 → pprof 性能剖析与工程组织 → Go 实战入门总纲。