searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

Go 并发深潜:从语言哲学到调度器源码的系统性解析

2025-11-28 09:36:24
1
0

一、为什么说 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 做了:

  1. goroutine A 调用 chansend

  2. runtime 检查 hchan 是否有等待接收者

  3. 如果有,则直接把值写给 sudog

  4. 如果没有,A 被 block,加入接收队列

  5. 调度器决定是抢占还是切换到其它 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) } }

逻辑非常清晰:

  1. 找下一个可运行 G

  2. 执行它

  3. 回到循环


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 会执行:

  1. 当前 G 调用 goparkunlock 把自己标记为 waiting

  2. 调度器把 M 切换走,执行其它 G

  3. 当 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 的并发本质不是“更轻的线程”,而是语言级任务调度系统。

0条评论
0 / 1000
z****n
6文章数
0粉丝数
z****n
6 文章 | 0 粉丝
原创

Go 并发深潜:从语言哲学到调度器源码的系统性解析

2025-11-28 09:36:24
1
0

一、为什么说 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 做了:

  1. goroutine A 调用 chansend

  2. runtime 检查 hchan 是否有等待接收者

  3. 如果有,则直接把值写给 sudog

  4. 如果没有,A 被 block,加入接收队列

  5. 调度器决定是抢占还是切换到其它 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) } }

逻辑非常清晰:

  1. 找下一个可运行 G

  2. 执行它

  3. 回到循环


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 会执行:

  1. 当前 G 调用 goparkunlock 把自己标记为 waiting

  2. 调度器把 M 切换走,执行其它 G

  3. 当 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 的并发本质不是“更轻的线程”,而是语言级任务调度系统。

文章来自个人专栏
文章 | 订阅
0条评论
0 / 1000
请输入你的评论
0
0