GMP
G
:goroutine
P
:processor 处理器
M
:thread 线程
P
包含了运行goroutine的资源(可执行的的G,内存情况等),而 M
是负责运行goroutine,因此 M
运行任务前需要先获取 P
,再从 P
的本地队列中获取 G
;
P
的本地队列最多可容纳256个 G
;
新创建的goroutine会优先放入P
的本地队列,如果本地队列满了才会放入全局队列中;多出来的g和本地队列前一半g一起放入全局队列;
在 Go 的早期版本,并没有
p
这个结构体,m
必须从一个全局的队列里获取要运行的g
,因此需要获取一个全局的锁,当并发量大的时候,锁就成了瓶颈;后来加上了
p
结构体。每个 p 自己维护一个处于 Runnable 状态的 g 的队列,解决了原来的全局锁问题;
G
有自己的运行栈,状态,以及执行的任务函数;
M
即操作系统的线程;
P
负责:
- 本地 Goroutine 队列:有助于减少线程间的竞争,提高调度效率;
- 全局运行时资源:维护着一些与运行时系统相关的资源,比如内存分配器,Go 运行时使用每个 P 的本地缓存来执行内存分配(mcache),以减少全局锁的竞争;
- 时间片:维护调度时间片,用于决定 Goroutine 的执行时长;
- 访问全局调度器:当本地队列为空时,从全局队列中获取 Goroutine 执行,此外,P 也负责 work stealing;
调度器设计策略
复用线程
work stealing
当 M
对应的 P
的本地队列中已经没有 G
了,但是其他 M
对应 P
的本地队列还有多余未执行的 G
,此时空闲的 P
则可以从其他 P
的本地队列中“偷取”未执行的 G
进入自己的本地队列中;为防止数据竞争,使用CAS无锁化解决;
hand off
当运行中的 G
发生阻塞(阻塞在系统调用上),则需要创建一个新/唤醒一个线程 M
,将发生阻塞的 M
对应的 P
移到新/唤醒的线程 M
上,物理cpu也会切换过去,等到阻塞完成后,如果 G
还需要后续操作,则如新创建般再次加入其他 P
的本地队列,而 M
则会睡眠或者直接销毁;
并行
最高并行的数量应该为 GOMAXPROCS
的数量(即 P
的数量);
抢占
如果一个goroutine运行时间过久,调度器允许其被其他goroutine抢占cpu;
全局队列
在 work stealing
时,如果其他 P
的本地队列也没有未执行的任务了,则 P
会从全局队列内加锁并取未执行的 G
,(先偷再拿);
goroutine
初始2kb栈空间,栈大小可以动态扩展或者缩减;
由 runtime
在用户态管理;
与线程存在映射关系,为 M : N
;
通过 Scheduler
可以实现 P
与线程间的动态绑定和调度;
切换
- 使用go创建新的goroutine:可能会被调度;
- 阻塞操作:当一个goroutine执行阻塞操作运行时会将其挂起;
- 系统调用:当一个goroutine进行系统调用时,运行时会将其挂起;
- 同步系统调用:此时
M
会被阻塞,造成阻塞的G
会继续挂在M
上,但是对应的P会重新绑定到新的(或者唤醒一个)M
上,继续执行其他G,当阻塞结束,该G会重新加入队列,M可以被挂起; - **异步系统调用: 这些异步操作注册到I/O多路复用器(
network poller
)中,并在操作完成时得到通知,即在有数据可读或可写时重新激活它;
- 同步系统调用:此时
- 内存同步访问:如
atomic
、mutex
、channel
等操作会使 goroutine 阻塞,当可继续执行(条件被满足)时,会被重新调度 - 被阻塞的 goroutine 会被挂在对应的等待队列中,如
channel
结构下的recvq
或者sendq
队列中、mutex
阻塞的会放在对应的等待队列中;
- 系统调用:当一个goroutine进行系统调用时,运行时会将其挂起;
- 手动调用runtime.Gosched():当一个 goroutine 显式调用runtime.Gosched()时,当前 goroutine 会主动让出处理器,让其他 goroutine 有机会执行;
- 调度器时间片用完:Go调度器会定期检查每个 goroutine 的执行时间(10ms)。如果一个 goroutine 占用了过多的时间片,调度器会强制切换到其他 goroutine;
- 创建新goroutine:当新创建一个 goroutine 时,调度器会安排其执行,可能导致当前执行的 goroutine 被挂起;
- runtime.GOMAXPROCS的变化:如果通过 runtime.GOMAXPROCS 设置了新的最大处理器数量,调度器可能重新平衡 goroutine 的分配,这也可能导致 goroutine 的切换;
内核级线程
操作系统的最小调度单元;
创建,销毁,调度交给操作系统内核完成;
如果线程阻塞,操作系统会自动切换到另一个就绪的线程;
cpu负责:
- 执行线程:CPU负责执行操作系统调度的线程;
- 线程切换;
- 并行处理:在多核CPU中,每个核心可以同时执行一个线程,从而实现并行处理;
- CPU在执行程序时,会在内核态和用户态之间切换;
- 用户态:在用户态下,程序不能直接访问硬件和内存,也不能直接执行某些特权指令;当程序需要使用系统资源或执行特权指令时,它会通过系统调用请求切换到内核态;
- 内核态:在内核态下,操作系统可以直接访问硬件和内存,执行特权指令;当系统调用结束后,CPU会切换回用户态,继续执行用户程序;
- 切换过程中,CPU需要保存当前状态(如寄存器的值),然后加载新状态;即上下文切换;由于涉及到数据的拷贝和安全验证等操作,用户态和内核态之间的切换是相对耗费资源的;
多线程必然伴随数据竞争(锁,竞争资源冲突等);
有意思的样例
- 如果将
P
设置为一个:runtime.GOMAXPROCS(1)
,表示为限制任意时刻只有一个M
执行代码;
go创建新的goroutine时,会被编译器转化为调用 runtime.newproc
的调用;
newproc
会先切换到系统栈,然后调用 newproc1
函数,分配并初始化一个新的 G
,再通过 runqput
把新的 G
添加到当前 P
的本地 runq
中,
注意:P
包含一个本地 runq
,以及一个用于保存下一次运行的G
的 runnext
字段,由于只有一个 P
(runtime.GOMAXPROCS(1)
),所以每次都是后一个 goroutine 取代前一个,存放在 runnext 中,而顺序添加的 goroutine 在本地 runq 中的执行顺序就是正常的了;
因为调度goroutine通过 runqget
获取需要被执行的 G
时,优先拿走 runnext
字段内的 G
再拿本地队列,所以最后一个goroutine是最先打印的;
注意:这个规律持续到goroutine创建个数小于等于257个,因为本地runq最多记录256个 G
,加上runnext字段的 G
一共最多记录257个 G
,如第258个 G
进入runnext中后,257 G
会和本地runq前一半的 G
一起移动到全局runq,
而之所以取前一半是为了防止饥饿:
在 runtime.schedule
中的 schedule()
,每个61个 schedtick,就会优先尝试从全局 runq 中获取 goroutine,
Scheduler
在调度 goroutine 时,会随机选择全局队列中的 goroutine(需要到全局取时),以避免多个 CPU 核心同时访问同一个缓存行
因为在多核 CPU 中,多个核心共享缓存,而每个核心都有自己的本地缓存;如果多个核心频繁地访问和修改同一个缓存行,可能会导致缓存一致性协议的频繁触发,从而增加延迟和性能开销;
// 3 1 2
func TestGoroutine(t *testing.T) {
runtime.GOMAXPROCS(1)
var wg sync.WaitGroup
wg.Add(3)
go func(n int) {
println(n)
defer wg.Done()
}(1)
go func(n int) {
println(n)
defer wg.Done()
}(2)
go func(n int) {
println(n)
defer wg.Done()
}(3)
wg.Wait()
}