golang GMP机制

完美参考: https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
请联合 以上链接的part1 和part3 一起看。

----- 个人简单理解:

GMP是Golang底层实现的一种调度协程的方案,目的是提高并发处理且降低切换成本。

G M P分别是底层实现中的三个数据结构。

g代表goroutine,指被调度的协程对象。
m代表线程,它是操作系统级别的线程,用来执行程序中的协程。
p代表processer,是CPU的抽象。

默认情况下,go程序会为每个CPU创建一个P(通过GOMAXPROCS调整),并在程序启动时创建多个M,M的数量往往略大于P的数量。
M被调度到P上执行G。G被维护在两个列表中,一个是全局队列,一个是P的本地队列。

整个调度过程 Golang提供了runtime包用来实现对协程管理的跟踪和查看。

g创建

g创建时,首先看能否放置在m本地的队列中,如果发现已经到达了256个,则会放置到全局队列中,此时会将本地中一半的g放置到全局队列。

g调度
调度器在调度g时,先从local 队列中查找,然后再去全局队列中找。有时候会从系统调用或者网络轮询中调度。
G0 是M的第一个routine,负责系统调用和网络轮询。

----- 深入一些的理解

  1. 操作系统调度器

了解操作系统底层调度器的工作过程,有助于理解Go调度器的设计思路。

操作系统调度器的设计与底层的CPU架构有很大关系,现代CPU架构包含有一级cache和二级cache,对于每个socket还有三级cache。
另外多核架构下引入了NUMA内存访问机制来减少系统总线带来的性能瓶颈。

关于PC IP 及golang程序的调试。

PC和IP指的是同一个指针 PC:program counter IP: instruction Pointer。
他们都指向要执行的下一跳汇编指令。
当我们运行某个程序(golang程序)时,如果程序发生了panic,输出的trace信息中,每一行的最后一个0xXX 十六进制数字即为 PC。具体的指令可以通过:
Go tool objdump -S -s "<函数名正则>" <程序binary>的方式查看。

线程的状态有三种,waiting runnable executing

程序的类型有两种或者两种的混合:计算密集型(CPU-Bound)和IO密集型(IO-Bound)
IO密集型的任务会导致waiting状态的产生,这些任务包含:网络事件、系统调用、互斥等待等等。 像大量的文件访问就是这种类型。

上下文切换

上下文切换是指 线程在CPU上的调度,这个调度成本虽然比进程调度要小很多,但也相当客观。
这种调度的目的是为了让CPU一直能工作,用于处理待运行的任务。这种调度在有IO操作的时候价值更大,被阻塞的线程可以被挂起,空出CPU执行runnable的线程。
计算密集型的任务应该尽量减少这种上下文切换,因为这种切换除了增加切换成本带不来计算时间的缩短。

关于False Sharing:

不同的CPU core中都有自己的一级和二级cache。
不同cache之间,cache和内存之间会发生数据不一致的情况。

在这里我们因为四个状态MESI:modified exclusive shared invalid

Exclusive 时 说明数据仅仅在其中一个cache中,对应的cpu可以放心的写入。
Modified 是 当数据写入之后的状态
Shared 说明在两个或两个以上的cpu cache中存在同一份数据。
Invalid 说明该cache中的数据已经失效。

距离说明状态变化:

a. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
b. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
c. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
d. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
e. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。

  1. golang调度器

P: processer
M: machine(thread)
G: goroutine(coroutine)

Goroutine 是程序级别的线程,goroutine在线程中context-switch on 或者off,就像线程在CPU上context-switch on 或者off。

这里有两个队列GRQ和LRQ,global/local runnable queue, 每个P上都会调度一个M,用于运行排队的G。

操作系统对线程的调度是一个抢占式过程 preemptive (因为可以给线程设置优先级,操作系统会根据优先级调度线程),而go调度协程的过程是non-preemptive。

四种情况下go调度器会进行goroutine调度:
o Go keyword
o GC, gc 是一个独立的协程,它会被之后执行。
o 系统调用,一种可能是将当前协程调度出M,另一种可能是将线程M本身调度出cpu
o 同步 sync.* 当出现互斥操作时,g会被调度出M

异步系统调用

异步系统调用使用的技术有:epoll(linux) kqueue(macosx) iocp(windows)
当异步系统调用发生时,M并不会被从P上调度走,而只是将发生异步系统调用的G从M上拿走,放回到LRQ中,M继续执行LRQ中的G。

发生异步系统调用的G的异步系统调用是通过Net Poller完成的。

同步系统调用

这种情况下,M被从P上调度走,连同发生系统调用的G,原有M上的其他G,则由新的M继续接管执行。

之后:G1 被重新添加到P的LRQ中。

LRQ的队列窃取

如果当前P的LRQ为空,优先尝试从别的LRQ中截取一半G,如果别的LRQ中也没有了,就从GRQ中获取。

使用协程的好处通过两个协程互发message的practice就可以展现出来。practice的主要意思是说线程是需要操作系统来调度的,而协程是用户空间的调度,实际上自始至终都是一个线程来完成的。

  1. 并发

并发不同于并行
并行是多个CPU的情况下,同时工作,所以至少有多个线程。
并发是out-of-order,在同一个CPU上穿插执行任务。

计算密集型的任务适合多M并发,因为多核情况下,多个CPU可以分别执行M参与计算,加速任务完成,
而IO密集型的任务并不适合M并发,因为进入到系统调用或者IO的goroutine会被挂起,线程M可以继续处理其他的goroutine,即一个M就可以处理多个包含系统调用的G。

这两种任务的区别,简而言之,
多CPU情况下,计算密集型的任务多加CPU就可以提升性能。IO密集型的多加CPU无济于事。
单CPU情况下,计算密集型多加M和G不利于提升性能,IO密集型,多M和G可以避免阻塞的发生。

关于M0 和G0

M0 是程序启动后的第一个线程,用于初始化程序,包括全局队列,GC等等。
G0 是每个M的第一个goroutine,用于调度该M上的goroutine。
如果当前M被阻塞,P会上下文切换到别的M继续执行goroutine,如果没有空闲的M,则会创建新的M。

posted @ 2024-06-01 13:51  zongzw  阅读(126)  评论(0)    收藏  举报