每日一Go-38、深入Goroutine--调度器G/M/P机制与调度策略

0 阅读4分钟

    Go 的并发优势很大程度上来自 用户态调度器(Goroutine Scheduler)。它不依赖 OS 线程创建大量轻量级任务,通过 G/M/P模型 和智能调度策略保证高吞吐、低延迟。

一、为什么需要 Go 调度器?

操作系统的线程有两个天然缺陷

  • 创建成本高:一个线程大概要1MB栈内存,创建和切换开销大

  • 调度不可控:操作系统调度器不了解你的程序逻辑

而Go的调度器能做到:

  • 协程创建成本极低(首次栈2KB)

  • 可以几百万级创建

  • 全用户态调度

  • 明确知道哪些协程可能阻塞、哪些需要抢占、哪些必须让出CPU

二、G / M / P 模型详解

Go调度器用三个核心实体表示执行模型

名称全称职责
GGoroutine任务运行的最小执行单元
MMachine与系统线程一一对应
PProcessor运行G的逻辑处理器

    1. G - Goroutine

  • 状态:idle、runnable、running、waiting、dead

  • 轻量:默认2KB栈,根据需要增长和收缩

  • 被调度器分配到P本地队列中,等待M执行

    2. M - Machine (操作系统线程)

  • 每个M对应一个真实操作系统线程

  • M必须绑定一个P才能执行G

  • 如果G执行涉及syscall阻塞,M会被卡住,P会被移交给其他M

  3. P - Processor (逻辑处理器)

  • 控制执行G的各项资源(例如运行队列)

  • P的数量定义为GOMAXPROCS

  • 每个P持有一个本地run queue,也就是运行队列

  • M只有只有P才能执行G

  • P是调度系统的灵魂:没有P,M就像没有CPU的线程,只能一直等。

三、G 的生命周期(敲黑板)

new
->
->
running
->
->
->
->

最重要的两个状态转换:

3.1 running -> waiting : G会从P的队列里消失,即标记为waiting状态

    3.1.1 channel里没有数据/不能发送--等别人

<-ch //阻塞 
ch <- v // 阻塞

    3.1.2 mutex已经被别人锁住--等锁

Lock
//锁住

    3.1.3 IO要等系统返回--等网络/磁盘

Read
// netpoll等待系统通知

    3.1.4 明确休眠--等事件

time
.Sleep

3.2 waiting -> runnable :把G唤醒

    3.2.1 channel有数据了/能发送了

  • channel 接收方等到发送者了

  • channel 发送方等到接收者了

把等待的G放回队列,状态改为runnable

    3.2.2 mutex解锁了

mu.Unlock()

这个时候,调度器会查看有没有goroutine因为这个锁阻塞,有的话就唤醒它并返回队列

    3.2.3 netpoll:网络IO就绪了

当epoll/kqueue发现socket可以读/写

内核就通知Go运行时,运行时把对应的G标记为runnable,并放入可运行队列里

    3.2.4 timer到点了

time.Sleep结束后,timer管理器发现时间到了,把对应的G唤醒,重新放到P的可运行队列里

四、调度循环(抢工作+ 本地队列优先)

Go的调度器使用组合策略,核心要点:

    4.1 优先从本地P队列取G

    4.2 本地队列空了就去抢任务,从其他P抢一半goroutine

    4.3 新建G优先放入本地队列

    4.4 syscall/unblock G,可能丢到全局队列

    4.5 定期检查全局队列,只在需要的时候取任务

整体思路:尽可能在本地消化任务,没有任务就去抢,确保所有P都不会闲着。

五、调度触发点(什么时候会调度?)

Go调度不是随时都切换,调度仅发生在特定时机:

5.1 主动让出

Gosched
//告诉调度器,我先不跑了,你切出去吧

5.2 阻塞操作

    一旦发送阻塞,就会触发调度,调度器会把协程变成waiting

5.3 函数调用边界

    Go会在函数调用入口插入检查点,当一个G运行太久,调度器就会发抢占信号,在下一个函数调用点检查到信号,就会自动让出

5.4 GC安全点

    GC需要STW或扫描所有栈,这也会触发抢占。

六、调度器的图示说明(ASCII)

        ┌──────────┐
        │   Global  │
        │ RunQueue  │
        └─────┬────┘
              │
   ┌──────────┼──────────┐
   ▼          ▼           ▼
┌─────┐   ┌─────┐    ┌─────┐
│  P0 │   │  P1 │    │  P2 │   ...   (P = GOMAXPROCS)
└─┬───┘   └──┬──┘    └──┬──┘
  │          │          │
LocalRQ   LocalRQ    LocalRQ
  │          │          │
  ▼          ▼          ▼
 ┌───┐     ┌───┐      ┌───┐
 │ M │     │ M │      │ M │   (OS Thread)
 └─▲─┘     └─▲─┘      └─▲─┘
   │         │          │
   └─────────┴──────────┘
           Syscalls, Block, Wakeup

七、经典问题解析

7.1 Go能否用多线程跑同一个goroutine?

    答:不能,每个G在任意时刻只能在一个M上运行。调度点可以迁移,但不会并行执行。

7.2 Go会频繁切换goroutine吗?

    答:不会,Go的调度是协作式+抢占式混合;多数切换发生在阻塞点,少量切换在函数入口抢占点。

7.3 为什么设置GOMAXPROCS大了反而变慢?

    答:因为P=并发能力。P增多就会导致更多队列、更多抢任务、更多GC、更多竞争。一半设置为CPU核心数是最佳。

    Goroutine 调度器就像一个巨大餐厅:P 是厨房,M 是厨师,G 是订单。厨师想做菜必须占到厨房;订单在每个厨房的本地栏里排队,没订单的厨房可以跑去别的厨房抢订单,保证每个厨房都不闲着。

*源码地址*

1、公众号“Codee君”回复“每日一Go”获取源码

2、pan.baidu.com/s/1B6pgLWfS… 


如果您喜欢这篇文章,请您(点赞、分享、亮爱心),万分感谢!