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

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

2025-12-01 17:45:01
27
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
5文章数
0粉丝数
z****n
5 文章 | 0 粉丝
原创

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

2025-12-01 17:45:01
27
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