Go 并发模型:goroutine 与 channel
更新时间:2026-08-25。本文回答: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 是"M:N 用户态轻量线程",channel 是"通信即同步"的优雅抽象;写对并发靠三件事:channel 发送者关闭、sync 保护共享数据、-race 抓数据竞争——写快则靠 pprof 定位锁竞争与调度热点。
深入:Go 内存模型 → pprof 性能剖析与工程组织 → Go 实战入门总纲。