一、为什么说 Go 的并发不是简单的“线程更轻量”?
很多人理解 goroutine 是“轻量线程”。
但 goroutine 的本质不是 thread 的轻量版,而是 用户态任务(user-space task) 与语言级通信机制(channel)的组合。
Go 从语言层面鼓励一种编程哲学:
Do not communicate by sharing memory; share memory by communicating.
核心思想:
-
goroutine = 执行体(computation)
-
channel = 同步 + 数据传输(coordination)
-
scheduler = goroutine 的 runtime 操作系统
在这一模型下,你写的每一行 go func (…) 都不是系统线程,而是 runtime 的任务;你写的每一次 <-ch 都不是普通同步,而是 channel runtime 的状态机操作。
二、访问模式与通道设计:为什么 channel 在 Go 中如此特别?
1. channel 是“同步点”,不是队列
channel 的本质:
sudog 结构记录发送者、接收者等待队列
hchan 保存缓冲区、锁、状态
它更接近 同步寄存器(synchronizing register),而非共享内存队列。
例如:
func main() {
ch := make(chan int)
go func() {
fmt.Println("send")
ch <- 1
}()
fmt.Println("recv:", <-ch)
}
你看到的是“简单输送整数”,实际上 runtime 做了:
-
goroutine A 调用
chansend -
runtime 检查 hchan 是否有等待接收者
-
如果有,则直接把值写给 sudog
-
如果没有,A 被 block,加入接收队列
-
调度器决定是抢占还是切换到其它 runnable goroutine
这不是“简单的发送”,而是 runtime 协调两段执行的“同步事件”。
三、Go 内存模型: happens-before 与禁止“猜测并发”
Go 内存模型的核心规则:
如果一个 goroutine 中的写想被另一个 goroutine 看到,那么必须通过同步事件(channel、Mutex、atomic)建立 happens-before。
例如:
var x int
var ready bool
func writer() {
x = 42
ready = true
}
func reader() {
if ready {
fmt.Println(x) // x 可能是 0,也可能是 42
}
}
这是未定义行为,因为 两个写之间没有同步关系。
使用 channel 修复:
var x int
done := make(chan struct{})
func writer() {
x = 42
close(done) // 同步事件
}
func reader() {
<-done
fmt.Println(x) // 100% 为 42
}
close 会建立 happens-before。
四、Goroutine 调度器模型:G、M、P 深度解析
Go 调度器使用 M:N 模型:
| 元素 | 作用 |
|---|---|
| G | goroutine,也就是用户级任务 |
| M | 真实 OS 线程(machine) |
| P | processor,调度资源(逻辑 CPU) |
1. 为什么需要 P?
继续前辈 2.0 版本没有 P 时的痛苦:
-
GC、调度器行为不稳定
-
M 太多会频繁进入内核态
-
goroutine 不能有效复用资源
引入 P 后,调度器模型稳定成现在的形式:
G 绑定在 P 上执行,由 M 驱动,像胶水一样组合运行。
五、调度器源码剖析(Go 1.22)
下面进入你最关心的 —— 源码层面的调度过程(核心版)。
源码路径:
src/runtime/proc.go
1. 创建 goroutine:gopark → newproc
go f() 会被编译器转换为:
newproc(funcPC(f), argp)
newproc 干的事很简单:
func newproc(...) {
// 分配一个 Goroutine 结构体
gp := newg()
// 将 gp 放入当前 P 的 runq
runqput(_p_, gp)
// 唤醒 M 调度
wakep()
}
注意:goroutine 的创建不需要系统线程参与。
2. 调度循环核心:schedule()
每个 M 都会运行一个无限循环:
func schedule() {
for {
gp := findrunnable() // 找可运行 goroutine
execute(gp, true)
}
}
逻辑非常清晰:
-
找下一个可运行 G
-
执行它
-
回到循环
3. 寻找 runnable G:findrunnable()
func findrunnable() *g {
// 1. 尝试从本地 runq 中找
if gp := runqget(p); gp != nil {
return gp
}
// 2. 尝试从全局队列找
if gp := sched.runq.pop(); gp != nil {
return gp
}
// 3. Work stealing:从其它 P 抢任务
if gp := stealWork(p); gp != nil {
return gp
}
// 4. 最后:找网络事件
gp := netpoll()
if gp != nil {
return gp
}
// 找不到就睡眠
stopm()
// 被唤醒再继续
}
这是 Go 高并发高吞吐的关键:
-
工作窃取(work stealing)
-
网络事件与 goroutine 调度融合
-
本地 runq 提高缓存命中
4. 被阻塞 goroutine 的处理
例如:
value := <-ch
runtime 会执行:
-
当前 G 调用
goparkunlock把自己标记为 waiting -
调度器把 M 切换走,执行其它 G
-
当 channel 有数据时恢复该 G
六、调度器可视化示例
以下代码能直观地看到 GPM 调度行为:
package main
import (
"runtime"
"time"
)
func main() {
runtime.GOMAXPROCS(2)
for i := 0; i < 6; i++ {
go func(id int) {
for {
println("G", id)
time.Sleep(time.Millisecond * 50)
}
}(i)
}
select {}
}
你将看到 goroutine 在两个 P 上轮转输出,体现 抢占、复用、并行。
七、一个综合示例:channel 同步 + goroutine 泄漏检测
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, id int, ch chan int) {
for {
select {
case <-ctx.Done():
fmt.Println("worker exit:", id)
return
case v := <-ch:
fmt.Println("worker", id, "got:", v)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan int)
for i := 0; i < 3; i++ {
go worker(ctx, i, ch)
}
for i := 0; i < 5; i++ {
ch <- i
}
cancel()
time.Sleep(time.Second)
}
通过 context + channel 实现:
-
协同退出
-
goroutine 泄漏避免
-
同步事件自动建立 happens-before
八、总结:Go 并发真正的本质
| 层次 | 本质 |
|---|---|
| 语言层 | goroutine + channel 定义了新的并发范式 |
| runtime 层 | 调度器 GPM 实现超轻量级任务切换 |
| 内存模型 | 同步事件建立 happens-before,保证正确性 |
| 内核层 | 通过少量 M 映射到 OS 线程,提高系统吞吐 |
Go 的并发本质不是“更轻的线程”,而是语言级任务调度系统。