boxmoe_header_banner_img

Hello! 欢迎来到我的博客!

加载中

文章导读

Go Goroutine – 协程大揭秘


avatar
xiaoifei 2026年5月12日 16

参考资料:
goroutine基本模型和调度设计策略

协程

1:1的方式依旧解决不了下面问题:

  • 线程创建成本高
  • 内存占用大
  • 切换昂贵

1:N 导致一个阻塞,全体阻塞,而后Go采用了 M:N 的方式
通过调度器,将多个协程分配给系统线程,即采用 GMP 的调度模型

GMP 调度模型

GMP 模型是 Go 并发的核心灵魂,首先我们要了解他们是什么东西?有什么作用?

GMP 其全称如下:

  • G Goroutine
  • M Machine(线程)
  • P Processor(逻辑处理器)

Goroutine延伸自coroutine,代表协程任务,一个协程其内部保存了协程栈,执行状态,指令位置(PC),当前函数信息。我们可以将它理解为待执行任务
Machine实际上是代表操作系统的线程(内核级),由操作系统管理
Processor是协程调度器,他管理着协程队列,连接了G和M,提供运行上下文

在早期的Go中,由于没有P,M:N 关系并不稳固高效,协程的执行都需要加锁,如下图所示

  1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争。
  2. M转移G会造成延迟和额外的系统负载。
  3. 系统调用(CPU在M之间的切换)导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

此时我们引入了Processor解决上面的问题

调度流程运行如下:

  1. 第一步:创建 goroutine
    runtime 创建很多 G 对象。
  2. 第二步:放入本地队列
    每个 P 都有本地队列:
P1 -> [G1 G2 G3]P2 -> [G4 G5]
  1. 第三步:M 执行
    线程 M 从 P 中取 goroutine 执行。
  2. 第四步:工作窃取(work stealing)
    如果:
P1 很忙P2 空闲

P2 会偷 P1 的任务。这是 Go 高性能的重要原因。

ps:全局队列什么时候用:

  1. 本地队列默认满了之后
  2. 某些新任务直接进入全局,避免局部不均衡

实现协程状态检测的原理是Go 通过特殊线程 sysmon 监控线程检测

  • 检测长时间运行 goroutine
  • 检测阻塞线程
  • 协助 GC
  • 协助抢占

协程与线程的对比:

对比goroutineOS线程
创建成本极低很高
内存占用几 KB几 MB
调度者Go runtime操作系统
切换成本很低很高
数量可几十万一般几千

调度器设计策略

  1. 复用线程(work stealing,hand off)
  2. 利用并行(GOMAXPROCS限定P的个数 = CPU核数/2)
  3. 抢占
  4. 全局G队列

work stealing:

当P1队列正在执行G1,此时P1队列中存在G2,G3,而M2目前没有执行任务,此时P2会从P2中偷取一个协程

hand off:

如果M1的正在执行G1,M2执行G3。一旦G1阻塞了,运行时会创建/唤醒一个threadM3,将P1放到M3执行,至于P1直接由M1执行

抢占策略:
Go 的协程调度采用的是「协作式 + 抢占式」混合模型:早期 goroutine 主要依赖 主动让出 CPU(如 channel 阻塞、IO、sleep、函数调用等)进行调度,但从 Go1.14 开始引入了异步抢占机制,runtime 会通过 sysmon 监控运行时间过长的 goroutine(通常超过约 10ms),并向对应线程发送 signal,在 safe point(安全点)强制暂停 当前 goroutine,把 CPU 让给其他协程执行,从而避免死循环或 CPU 密集任务长期霸占线程,解决 goroutine 饿死问题,同时降低 GC 延迟并提高调度公平性。

全局队列:当P2队列没有协程任务的时候优先 Work Stealing ,如果没了就从全局队列取(需要加锁)

实战

创建 Goroutine

package main

import (
	"fmt"
	"time"
)

func newTask() {
	i := 0
	for {
		i++
		fmt.Printf("New Goroutine:i=%d\n", i)
		time.Sleep(1 * time.Second)
	}
}

func main() {
	// 创建一个协程执行newTask
	go newTask()

	i := 0
	for {
		i++
		fmt.Printf("main Goroutine:i=%d\n", i)
		time.Sleep(1 * time.Second)
	}
}

上面是协程创建的一个用例,下面我将结合这个用例进一步讲解
代码中有两个协程:newTask和main

这时候你可能会想,欸 我有两个协程,为了CPU充分利用起来,我是不是应该将他分配到两个线程上
当然具体还要看 P 的数量,两个 Goroutine ≠ 必然两个线程,两个 Goroutine 可以被调度到一个或多个线程上执行,是否并行取决于 GOMAXPROCS、调度器和运行时状态

  • 如果 GOMAXPROCS = 1,两个 Goroutine 会并发切换执行,但同一时刻通常只有一个在跑。
  • 如果 GOMAXPROCS >= 2,它们有可能分别跑在不同的 M,也就是不同 OS 线程上,从而并行执行。

GOMAXPROCS = 2 时,新创建的 newTask goroutine 通常会优先放到当前 P 的本地运行队列,也就是 main goroutine 正在使用的那个 P。

P1 本地队列: main goroutine, newTask goroutine  
P2 本地队列:

之后如果 P2 下面的 M 没活干,它可能会通过 work stealing 从 P1 偷取一部分 goroutine 过去执行。
所以最终变成:

P1 -> main goroutine
P2 -> newTask goroutine

如果此时来了一个 newTask2 goroutine,但是我 GOMAXPROCS = 2,怎么办?
这就要回顾之前GMP调度流程,GOMAXPROCS = 2 代表同一时刻最多 2 个 P 在执行 Go 代码
现如今P1和P2分别正在执行main和newTask,newTask2只好优先放入某个队列中

P1 本地队列  
P2 本地队列  
全局运行队列

等待被调度,当某个正在goroutine发生调度点,调度器就换下 mainnewTask2 上去执行。
而这个被调度的条件可以是下面几种情况

  • time.Sleep
  • IO 阻塞
  • channel 阻塞
  • syscall
  • 等锁

就好比收银台P1正在服务main顾客,收银收到一半,顾客main说我忘记买一个东西了,就被收银员叫走了,然后顾客newTask2过来被P1服务
等顾客main拿完东西了,就会重新跑到等待被继续服务的队列,轮到她时从刚刚中断的地方继续

退出当前协程

// 在当前协程的任意作用域内都可以使用
runtime.Goexit()

得到协程执行结果

Go 获取 goroutine 执行结果,最常见的方法是:

  • channel(最核心)
  • sync.WaitGroup(等待结束)
  • context(控制取消)
  • errgroup(并发任务管理)

WaitGroup + channel:

多个 goroutine 并发  

结果写入 channel  

WaitGroup 等待全部结束  

关闭 channel  

统一读取结果

详情见Go Channel – 协程通信机制

Go


评论(0)

查看评论列表

暂无评论


发表评论

表情 颜文字
插入代码