golang

426 阅读9分钟

为什么协程比线程轻量

  • go 协程默认的栈空间内存大小只有 2 KB (上限1 GB)
  • 线程切换需要进行系统调用,开销比普通函数调用大很多,协程在用户态实现没这个开销
  • 协程数据结构小
  • 协程切换不需要保存寄存器信息,协程通过栈来传递变量

GMP

M 代表一个底层的操作系统线程

P 协程的管理者, Go 语言抽象的一个处理器,运行时会绑定一个可运行的 M,当 M 不可运行(例如陷入系统调用)时 P 会带着 G 队列去投奔另外的 M。

G

Go 协程调度

Go 协程调度本质上是给绑定在 P 的 M 选择下一个 G 来运行的过程

P 中存在一个协程的等待队列,这些协程会按先进先出的顺序被 M 执行。同时 Go 会启动一个特殊的不绑定 P 的线程:sysmon(System monitor),sysmon 的一部分职责是遍历所有的 P,将在 P 上运行超过 10ms 的协程标记为可抢占。然后在 G 在函数调用时就会触发抢占调度,重新回到运行队列排队。

P 和 M 并不是一一对应的,比如当底层线程 M 陷入系统调用时, P 会带着它所有的 G 重新绑定空闲的 M

在 P 上的队列没有可以运行的协程时,会尝试去全局协程队列中寻找,如果还是没有,会随机的尝试从其他 P 中窃取一半的协程到当前队列,再从当前队列中获取。

slice 扩容机制

  • 所需容量(need cap)大于old cap 的两倍,设置 cap = need cap

  • need cap 小于 old cap 的两倍

    • 旧的 slice len 小于 1024,则申请容量为旧的容量的两倍
    • 旧的 slice len 大于 1024 ,new cap = old cap + 1/4 old cap,直到 new cap 大于所需容量

map和sync.map

select 实现机制

1、锁定 scase 中所有 channel

2、随机检测 scase 中的 channel 是否 ready

  • case 可读,读取 channel 中的数据
  • case 可写,写入 channel
  • 都没准备好,直接返回

3、所有 case 都没有准备好,且没有 default

  • 将当前 goroutine 加入到所有 channel 的等待队列
  • 将当前协程阻塞,等待被唤醒

4、唤醒之后,返回 channel 对应的 case index

总结

  • select语句中除了default之外,每个case操作⼀个channel,要么读要么写
  • 除default之外,各个case执⾏顺序是随机的
  • 如果select中没有default,会阻塞等待任意case
  • 读操作要判断成功读取,关闭的channel也可以读取

内存管理组件

  • cache

每个运行期工作线程都会绑定一个 cache,用于无锁 object 分配

  • central

为所有 cache 提供切分好的后备 span 资源。

  • heap

管理闲置 span,需要时向操作系统申请新内存

  • mspan

    runtime.mspan 是 Go 语言内存管理的基本单元,该结构体中包含 nextprev 两个字段,它们分别指向了前一个和后一个 runtime.mspan

    type span struct{
        next *mspan
        prev *mspan
    }
    
  • mcache

  • mcentral

  • mheap

molloc.go 基本逻辑

  • 大对象(大于 32 KB)直接从 heap 获取 span
  • 小对象(小于 32 KB & 大于 16 B)从cache.alloc[sizeclass].freelist 获取 object
  • 微小对象(小于 16 B && 非指针)组合使用 cache.tiny.object

goroutine 和 thread 的区别

我们可以从三个方面来看,分别是内存消耗、创建与销毁、切换。

  • 内存消耗

创建一个 goroutine 的栈内存消耗为 2KB,在运行过程中,如果栈空间不够用,会自动进行扩容。

创建一个内核线程的栈内存消耗约为 2MB,不同系统会有差别,但基本都是 MB 级别的。

  • 创建和销毁

goroutine 创建和销毁的消耗很小,因为是在用户态实现的,不需要跟系统打交道。

thread 创建和销毁的很大,因为是在内核态。

  • 切换

goroutine 切换只需要保护几个关键的寄存器,PC,BP,SP

thread 切换需要保存各种寄存器

GMP 是什么?

G、M、P 是 Go 调度器的三个核心组件。

  • G

G 代表 goroutine ,代表一个用户态线程。相比内核态线程,开销小很多。goroutine 主要保存相关的状态信息以及 CPU 的 PC、BP、SP 寄存器的值。

  • M

M 代表一个内核态线程。 G 需要被调度到 M 上才能够在 CPU 上运行。M 保存了系统线程自己的栈信息、跟 M 绑定的 goroutine 信息,跟 M 绑定的 P 的信息。

当 M 没有工作可做时候,会进入 spining (自旋) 状态,当 M 处于自旋状态时,会寻找可执行的 G 。

  • P

P 是 runtime 抽象出来的上下文,有本地可运行 G 队列, 内存缓存等。一个 M 只有绑定到 P ,才能够获取到可执行的 goroutine ; 当 M 被阻塞时,M 与 P 会接绑,如果此时有空闲的 M1 ,那么 P 会绑定到 M1 上,如果当前没有空闲的 M ,调度器会帮忙创建新的 M,并将 P 绑定到新创建的 M 上。

g0

持有调度栈的 Goroutine

g0 的作用是:

  • Goroutine 的创建
  • 大内存分配
  • CGO 函数的执行

goroutine 和线程的区别

goroutine 的调度开销远远小于线程调度开销;Go runtime 有属于自己的调度器,该调度器使用 M:N 调度技术,m 个 goroutine 对应 n 个系统线程,因为 goroutine 调度时,不需要切换到内核空间,成本相比 os 线程调度低很多。

goroutine 的栈空间更加灵活;操作系统给 os 线程分配的栈内存通常是 2 MB,若栈内存超过 2 MB 会报错。Go 给 goroutine 分配的栈内存是 8 KB,当栈内存不够时,可以动态调整,最大是 1 GB。 goroutine 栈内存相比 os 线程的栈内存的分配更合理。并不是所有线程都需要 2 MB 这么大的栈内存,可能会造成内存无故飙升,造成资源的浪费。

struct 能不能比较

相同 struct 类型可以比较

不同 struct 类型不可以比较,编译都不能通过

Go 的 Slice 如何扩容

首先看一下 Slice的数据结构,array 是一个指针,指向底层数组,len 表示当然数组长度,cap 表示数组的最大容量。

type Slice struct{
    array unsafe.Pointer
    len int 
    cap int
}

扩容策略

策略1

如果期望容量大于当前容量的两倍就会使用期望容量

策略2

  • 如果当前切片长度小于 1024 就会将容量翻倍
  • 如果当前切片长度大于 1024 就会每次增加 25% 的容量

这还没结束,策略1和策略2仅会确定切片的大致容量,下面还需要对切片中的元素大小进行内存对齐。内存对齐后的容量会大于等于策略1和策略2中的确定的容量。这边后面需要再补充一下内存对齐的流程。

new 和 make 的区别

new 方法的参数要求传入一个类型,会申请一个该类型大小的内存空间,并初始化为对应的零值,返回指向该内存空间的一个指针。

make 方法也是使用内存分配,但是跟 new 不同的是,make 只能用来创建引用对象,例如 map、slice、channel 等。并且它返回的是对像,不是指针。

g0的作用(职责)

创建 goroutine

当我们使用代码 go func( ) {} 或 go myFunc( ) 时,Go 会将 goroutine 的创建委托给 g0,创建完成后在放入到本地队列(若本地队列满了,也就是达到256个,会将本地队列前一半的 goroutine 加上本次创建的一起放到 global runqueue ,当然了,对 global runqueue 操作需要加锁)

负责调度

g0 不指向任何可执行的函数,每个 M 都会有一个自己的 g0。在调度或系统调用时会使用 g0 的栈空间,全局变量的 g0 是 M0 的 g0。

Go 中两个 Nil 可能不相等吗

如果待比较的两个 nil 值,一个是接口类型,另外一个不是,那么比较结果总为 false 。

对于接口类型来说,只有 Type 和 Value 都为 nil,该 interface 才为 nil 。对于非接口类型,如 int 类型,int 类型为 nil ,转成 interface 类型时为 Type 为 int, Value 为 nil,因为两者不相等。

g0 切换到 main goroutine 流程

保存 g0 的调度信息,主要是保存 CPU 栈顶寄存器 SP 到 g0.sched.sp 成员中

调用 schedule 函数,查找需要运行的 goroutine, 在这个场景中,我们找到的是 main goroutine

调用 gogo 函数首先从 g0 栈切换到 main goroutine 的栈, 然后从 main goroutine 的 g 结构体对象中取出 shed.pc 的值,并用 JMP 指令跳转到该地址执行

main goroutine 执行完毕直接调用 exit 系统调用退出进程

gogo 函数作用:g0 切换到某个 goroutine

mcall 函数作用:某个 goroutine 切换到 g0

goroutine 调度策略

什么时候会发生调度?

被动调度

主动调度

使用什么策略来挑选下一个进入运行的 goroutine

如何把挑选出来的 goroutine 放到 CPU 上

schedule 函数作用

第一步,从全局队列中寻找 goroutine。 这是为了保证调度的公平性,每经过 61 次调度就需要优先尝试从全局运行队列中找出一个 goroutine 来运行,这样才能保证位于全局队列中的 goroutine 有机会得到运行

第二步,从工作线程本地运行队列中寻找 goroutine。 如果不需要或不能从全局队列中获取到 goroutine 则从本地运行队列中获取

第三步,到其他 p 的运行队列中偷取 goroutine。 如果本地运行队列也没有可执行的 goroutine ,则调用 findrunnable 从其他 p 的本地运行队列偷取goroutine。findrunable 函数会在偷取之前再次尝试从全局队列和本地运行队列中查找。

runtime.newproc 函数的调用

  1. 切换到 g0 栈
  2. 分配 g 结构体对象
  3. 初始化 g 对应的栈信息,并把参数拷贝到新 g 的栈上
  4. 设置好 g 的 sched 成员,该成员包括调度 g 时所必须的 pc、sp、bp 等调度信息
  5. 调用 runqput 函数将 g 放入运行队列中

\