Go 协程
Goroutine
Goroutine 算是Go 语言实现的超轻量级线程,但它的核心在于它不是由操作系统内核直接管理的,而是由 Go 运行时runtime自己管理和调度的,这才是他的高效之处。Go初始栈 2KB,动态增长。
Goroutine的特点优点
1.Go协程的内存占用小,初始栈为2KB,动态增长。
2.Go协程创建销毁快:用户态操作,无系统调用开销。
3.切换开销小:调度在用户态由运行时完成,只有必要的上下文切换(如寄存器值),而不是完整的线程上下文切换。并且通过合理的调度时机来避免无意义的切换。
4.高并发能力:GMP 模型通过本地队列减少了锁竞争,使得数万甚至百万级的并发任务可以被高效管理。
为何有这些特点,我们可以从以下三个核心层面来理解Goroutine:
1. 创建与内存。
2. 调度模型(GMP)。
3. 调度时机。
1. 创建与内存:go协程为啥如此轻量?
操作系统线程(OS Thread):
a.创建和销毁需要进入内核态,系统调用开销大。
b.每个线程有自己固定且较大的栈空间(例如 1-2MB),主要用于处理最复杂的函数调用。大量线程会消耗大量内存。
Goroutine:
a.创建和销毁完全在用户态由 Go 运行时负责,开销极小,就是几个函数调用,而不是系统调用。
b.初始栈空间非常小(通常约 2KB),并且采用按需分配/缩放的动态栈。当栈空间不足时,运行时会自动为它分配一块更大的栈,并把旧栈的内容复制过去。这使内存使用非常高效,可以轻松创建数十万甚至上百万个 Goroutine。
2. 核心调度模型:GMP 模型
Go 的调度器采用了一个叫做 GMP 的三级模型。
G - Goroutine:就是我们要调度的协程本体。它包含了函数、指令指针、栈等信息。
M - Machine(工作线程):代表着操作系统的内核线程。它是真正在 CPU 上执行代码的实体。所有 Goroutine 最终都要跑在某个 M 上。
M 的数量默认上限是 10000,一般远超 CPU 核心数,因为大部分 M 会阻塞在系统调用上。
P - Processor(调度器/上下文):P 是 G 和 M 之间的纽带,是 Go 调度器的关键创新。
把 P 理解为一个本地运行队列和执行上下文。它维护着一个本地的 Goroutine 队列(称为 local run queue)。
P 的数量默认等于当前程序的 GOMAXPROCS值(通常是 CPU 的逻辑核心数,比如 16 核机器就是 16)。这限制了真正并发运行的 Goroutine 数量。
GOMAXPROCS可以通过命令设置:export GOMAXPROCS=4,也可以在代码中调用:runtime.GOMAXPROCS(16)
GMP关系与工作流程:
开始运行时,M和P的工作流程:

绑定关系:在程序运行时,Go 运行时会创建 GOMAXPROCS个 P。每个 P 都持有一个本地的 G 队列。一个 M 要想执行 G,必须先获取一个 P,即 M 与 P 绑定。
后续流程:
1.当执行 go func()时,会创建一个新的 G。
2.这个 G 会优先被放入当前 M 所绑定的 P 的本地运行队列中。(这里为什么会放入当前M所绑定的P的本地运行队列中,因为go选择的是1.默认路径,就近原则高效。如果当前队列满了,会把拿当前队列的一半任务和这个新任务一起放到公共的全局队列G中)
3.M 会从它绑定的 P 的本地队列中取出一个 G 来执行(LIFO 策略。a.如果绑定的P的本地队列空了怎么办呢?---他会从全局队列G中加锁取一批:len(global_queue)/GOMAXPROCS + 1的量到自己的本地队列。b.如果全局队列也空了呢?---那就随机从其他的P中偷取一半的数量到本地队列。如果其他的P都空呢?---那就从网络轮询器中获取那些已就绪的G来执行。如果网络轮询器也获取不到呢?---那就休眠吧,P和M散伙,各回各家各找各妈,P放回空闲P列表,M放入空闲M列表,等有任务来了再继续)。
4.当 G 执行完毕,M 会再从 P 的队列中取下一个 G。如此循环。
3. 调度时机:何时切换 Goroutine?
Go 的调度是协作式的,但带有强烈的抢占式倾向。它不会像操作系统那样用时间片轮转来强制切换线程。Goroutine 的调度发生在以下关键时刻:
1.主动挂起(协作式点位)
Goroutine 在遇到一些特定操作时,会主动让出 CPU 使用权。这些操作会成为调度器的“切入点”:
a.I/O 操作:如网络请求、文件读写。
b.channel 操作:如向 channel 发送或接收数据时遇到阻塞。
c.执行 runtime.Gosched()函数:主动放弃当前执行,让给其他 Goroutine。
d.系统调用:如访问操作系统功能。
e.垃圾回收(GC):GC 的某些阶段需要暂停所有 Goroutine。
2.被动抢占(防止饿死)
如果一个 Goroutine 执行一个非常耗时的计算任务(比如一个死循环,且内部没有上述的协作点),它不会主动让出 CPU,这会导致其他 Goroutine 被“饿死”。为了解决这个问题,Go 调度器实现了基于信号的异步抢占。
流程:
1.Go 运行时会向执行长时间任务的 M 发送一个特殊的信号(如 SIGURG)。
2.M 收到信号后,会中断当前正在执行的 G,并执行一个预注册的信号处理函数。
3.在这个处理函数中,调度器有机会将当前的 G 挂起,放回运行队列,然后让 M 去执行其他的 G。这样就实现了“强制”切换,保证了公平性。
可以举个例子:
go运行时就像一个销售公司运作。这个销售公司有很多销售项目组(P),也有很多销售顾问(M),每个销售项目组都有一块黑板写着代办的任务(G)。还有一个公共栏记录那些没有分派出去的公共任务(全局G,这个地方是一些公共的代办任务,哪个销售项目组没任务可以来这里抢任务做)。
1.当一个任务(G)被创建之后就近分派给销售项目组(P)接手这个任务,一旦这个P任务满了就放到公共栏。
2.销售顾问(M)做任务,先从销售项目组(P)中取任务(G),拿到任务则执行。如果销售组(P)没有任务就去公共栏取任务,如果公共栏也没任务,从其他销售项目组(P)中偷取一半的任务(G)执行,如果其他销售项目组(P)也没有任务,当牛马的要去看看老板有没有其他安排了,如果没安排那就把这个销售项目组放到空列表中,记录完结,销售顾问可以去外面休息喝口西北风了,等来活了继续牛马的循环工作。
一个P同时只能绑定一个M,一个项目组实际有很多销售顾问,把这些销售顾问看成一个整体就行了
