同步原语

  • Mutex

    • 状态

      1
      2
      3
      4
      type Mutex struct {
      state int32
      sema uint32
      }

      golang-mutex-state

      正常模式:按照正常的顺序获取锁

      饥饿模式:一个groutine超过了1ms没有获得锁进入饥饿模式 防止饿死

      • 互斥锁会直接交给等待队列最前面的 Goroutine
    • 信号量

    • 互斥锁的加锁过程比较复杂,它涉及自旋、信号量以及调度等概念:

      • 如果互斥锁处于初始化状态,能直接通过cas加锁,会通过置位mutexLocked 加锁;

      • 如果互斥锁处于 mutexLocked 状态并且在普通模式下工作

        会进入自旋,执行 30 次 PAUSE 指令消耗 CPU 时间等待锁的释放;

      • 如果当前 Goroutine 等待的时间超过了 1ms,互斥锁就会切换到饥饿模式;

      • 互斥锁在正常情况下会通过 runtime.sync_runtime_SemacquireMutex 将尝试获取锁的 Goroutine 切换至休眠状态,等待锁的持有者唤醒;

      • 如果当前 Goroutine 是互斥锁上的最后一个等待的协程或者等待的时间小于 1ms,那么它会将互斥锁切换回正常模式;

      互斥锁的解锁过程与之相比就比较简单,其代码行数不多、逻辑清晰,也比较容易理解:

      • 当互斥锁已经被解锁时,调用 sync.Mutex.Unlock 会直接抛出异常;
      • 当互斥锁处于饥饿模式时,将锁的所有权交给队列中的下一个等待者,等待者会负责设置 mutexLocked 标志位;
      • 当互斥锁处于普通模式时,如果没有 Goroutine 等待锁的释放或者已经有被唤醒的 Goroutine 获得了锁,会直接返回;在其他情况下会通过 sync.runtime_Semrelease 唤醒对应的 Goroutine;

hash map

查找

  1. 根据 key 计算出哈希值
  2. 根据哈希值低位确定所在 bucket
  3. 根据哈希值高 8 位确定在 bucket 中的存储位置
  4. 当前 bucket 未找到则查找对应的 overflow bucket
  5. 对应位置有数据则对比完整的哈希值,确定是否是要查找的数据
  6. 如果当前处于 map 进行了扩容,处于数据搬移状态,则优先从 oldbuckets 查找。

插入

  1. 根据 key 计算出哈希值
  2. 根据哈希值低位确定所在 bucket
  3. 根据哈希值高 8 位确定在 bucket 中的存储位置
  4. 查找该 key 是否存在,已存在则更新,不存在则插入

hmap-and-buckets

扩容

  • 哈希因子超过6.5
  • 使用了太多的溢出桶
  • 等量扩容

channel

  • 主要结构

    元素个数

    循环队列长度

    缓冲区

    发送到的位置

    接收到的位置

    等待唤醒的groutine列表

  • 发送流程

    • 存在等待着
    • 存在空余空间
    • 不存在缓冲区或者缓冲区已经满了 等待接受数据
  • 接收流程

    • 存在等待的发送者
    • 缓冲区存在数据
    • 等待其他发送者发送数据 自己包装成一个sudog 等待

网络轮训器

  • 监控网络io 文件io
  • io类型
    • 阻塞io
    • 非阻塞io
    • io多路复用

select

  • 直接阻塞 空的结构

  • 单一管道

    退化成if语句

  • 非阻塞操作

    存在default操作

    • 发送

      不存在接收方的时候直接返回

    • 接收

      同样的操作

  • 一般的

    查询是否存在就绪的channel

    加入对应的chanel等待队列

    被唤醒等待操作处理

golang调度器生命周期

gmp

gmp调度

img

首先我们要搞清楚go的gmp这三个代表的是什么:

  • g 就是goroutine go协程 是参与调度的最小单位
  • m 指的系统级线程
  • p 逻辑处理器,关联了本地可运行的g的队列

然后对于go程序的启动来说, 最开始由runtime创建最初的线程m0和g0 并把二者关联起来,然后就是栈,内存分配器,调度器初始化,垃圾回收器初始化,再就是创建,P组成空间的P的列表,为main创建main.goroutine初始化,初始化之后加入到P之中去,完毕后启动m0 m0获取到P的 main goroutine

G拥有M根据栈中的信息设置运行环境运行。

创建G

  • 在这个过程中创建的g可以直接加入到自己的p的队列中去,要是自己队列里面满了的话,会放到全局队列里面去,

    • 要是有空闲的p,添加到空闲的p的本地队列并尝试获取或者创建m来运行

      p的g0会找到这个对应的g并运行

    • 尝试唤醒休眠的M去执行任务

调度G 偷取

  • 每一个P和M绑定并运行,当M绑定的P的队列里面的队列为空的时候会去全局队列,以及事件轮训器获取goroutine来运行当全局队列的goroutine为空的时候回去尝试从其他的P的队列中来偷取goroutine, 同时在执行完61个goroutine之后会去尝试获取全局队列的goroutine防止被饿死

休眠 Hand off

  • 当G因为系统调用而阻塞的时候,P会和M解绑,并去尝试获取休眠或者自旋的M,若是没有M的时候会去尝试创建一个M
  • 当G因为比如select 或者io事件 等用户态阻塞的时候,G会被放到对应的队列去等待唤醒,M会去找可以运行的G当G恢复的时候会重新进入P队列等待
  • 当P找不到可以运行的G的时候 会将M自旋或者休眠

调度策略

  • 主动调度 主动退出

  • 被动调度 协程在休眠、channel通道阻塞、网络IO阻塞、执行垃圾回收

  • 抢占式调度 sysmon 连续运行10ms则认为时间过长,进行抢占 防止其他goroutine 饿死

go 系统监控

  • 运行计时器 — 获取下一个需要被触发的计时器;

  • 轮询网络 — 获取需要处理的到期文件描述符;

    1. 检查是否有待执行的文件描述符
    2. 添加到全局队列中去(要是存在空闲的p直接唤醒)
  • 抢占处理器 — 抢占运行时间较长的或者处于系统调用的 Goroutine;

    1. 当处理器的运行队列不为空或者不存在空闲处理器时
    2. 当系统调用时间超过了 10ms 时
  • 垃圾回收 — 在满足条件时触发垃圾收集回收内存;

    runtime.gcTrigger

  • 死锁检测 — 检查运行时是否发生了死锁;

    1. 检查是否存在正在运行的线程;
    2. 检查是否存在正在运行的 Goroutine;
    3. 检查处理器上是否存在计时器;

go roroutine泄漏

  1. 从 channel 里读,但是同时没有写入操作

  2. 向 无缓冲 channel 里写,但是同时没有读操作

  3. 向已满的 有缓冲 channel 里写,但是同时没有读操作

  4. select操作在所有case上都阻塞()

  5. goroutine进入for()死循环,一直结束不了

解决方案:

goleak 一个泄漏检测 可以通过写测试函数来测试

或者用pprof进行分析

go gc

img

三色标记法+混合写屏障

三色标记法主要是黑色 灰色 白色 灰色作为中间变量

  • 强三色不变性 — 黑色对象不会指向白色对象,只会指向灰色对象或者黑色对象;
  • 弱三色不变性 — 黑色对象指向的白色对象必须包含一条从灰色对象经由多个白色对象的可达路径
  1. 清理终止阶段

    1. 首先当触发gc的时候,要是上一轮没有清理完成 清理完成后 stw 一下
    2. 进行准备工作 进入 _GCmark 阶段扫描根对象(寄存器 栈 以及不在堆中运行的数据结构[ .bss 段,.data 段以及 goroutine 的栈])标记成灰色,
    3. 开启用户程序辅助,
    4. 开启混合写屏障,准备开始对对象扫描
  2. 扫描阶段

    1. 这个过程中按照灰色对象变成黑色对象 黑色对象引用的对象变成灰色对象,循环扫描
    2. 修改黑色对象的指向的时候将指向的对象标记成灰色(有可能存活的对象) 插入写屏障 强一致性
    3. 删除老对象的指向的时候将删除的对象标记成灰色 删除写屏障 弱一致性
    4. 在扫描过程中在栈上创建的对象统一标记成黑色
    5. 分布式渐进扫描 直到没有中间状态白色
  3. 标记终止阶段

    stw 进入 _GCmarktermination 并清理对象 进入Gcoff阶段,关闭混合写屏障

  4. 清理终止阶段

清理对象

特点

  • 无分代
  • 与应用执行并发
  • 协助标记流程
  • 并发执行时开启 write barrier

协程特点

  • 不管定义是怎样,一定正确的一些特点:

    • 协作式,非抢占式,编程语言决定或者协程自己决定如何做切换调度
    • 完全用户态,无内核开销,大多数操作不涉及系统调用
  • 跟线程对比

    • 实现特点:线程是 os 的软中断实现的,内核决定主要调度,协程一般是自己让渡,很多时候是线程复用,无传统意义上的中断。
    • 控制难度:这个是我之前没留意的一个点,感觉协程线程其实用起来都差不多。但协程确实提供了很多机制来降低控制难度,线程需要百分百管理锁和同步,而协程一般都会在语言运行时层面上进一步做抽象来简化并发模型。比如 go 里边的 wg 和 channel。
    • 性能:更轻量实现并发。上下文切换、用户态、占用内存(栈空间)。

goroutine 是协程在 go 语言里的‘实现’(我觉得实现这种说法比是在 go 里面的‘优化’更好一点,因为协程本身就没有一个确定基础实现。所以其实就我目前的理解协程更像是满足上面特点的一种‘不同于多线程的程序并发实现思想’)
● 基本认知
○ 和线程 m 对 n 关系,可以并行
○ 基于 GMP 模型,模型可以认知为整体的一个实现,其中 P 是作为调度器的角色,gm 分别代表 goroutine 和 内核线程的抽象。
○ 几种调度方式:一种分类是主动、被动、正常、抢占四种情况。这个分类挺好的,列举了所有情况。主动是还没执行完交出执行权,被动是临时阻塞被调度器给调度了,正常调度是结束调度新的,抢占调度是后面版本引入的针对系统调用僵直情况的调度。前面三种都是协作式的符合协程特点。
● 怎样做调度,大概的调度流程和机制,剩下有哪些调度细节
○ gmp 模型中,p 主要是本地队列,m 主要是线程抽象,直接指向 g。执行设想就是,p 动态绑定一个 m,然后 m 再通过 p 去拿 g 去执行。
○ m 怎么找到下一个正常执行的 g:主要按照本地队列、全局队列、wait 队列、work-stealing 的顺序取(再加上一个每 61 次会优先全局防止饥饿)
○ g 的生命周期,正常流程 idle -> dead -> runnable -> running -> dead,运行的两状态会分别进入一个系统调用状态和 wait 状态(即阻塞,就是上面的 wait 队列,由被动调度触发)
○ 我们直接创建的 g 优先进入当前 p 的本地队列,本地队列满或者主动让渡等情况会进全局队列
● 其他面试问题
○ 调度器 P 起到了什么作用?试想没有 P,那么我们 g 直接跟 m 绑定,那么好像其实就跟直接跟使用线程差不多了只是简单封装。首先最明显的作用就是实现了一个 m:n 关系,p 虽然跟处理器没直接关系但是还是确实代表一个抽象,最佳实践就是 p = 核心数(GOMAXPROCS),一定程度上线程的数量。其次这样动态绑定实现了线程的复用。除了这两个设计上的作用,剩下的就是他作为本地队列了,就类似于一个 channel 无锁化的重要步骤。最后还有 p 是作为一个所谓的“万能中间层”,除了动态做这种绑定复用,还能处理僵直的抢占调度的情况,这种情况下 p 会重新绑定一个 m 再去执行 g。
■ 有个进阶题目,在一个 GOMAXPROCS 不等于核心数的 container 中运行程序会怎样?简单说,p 的数量直接意味着整个调度系统认为我们 cpu 有多少核,最大并行能力是怎样的。如果多了的话,就是实际线程数可能会比最佳值要大。那么就是调度效率下降,资源竞争加剧,性能下降。少了基本就是没充分利用了。
○ 如何实现无锁化的。调度的时候取 g 的顺序,只有全局要加锁,p 是空的时候和窃取失败的时候都会提前取好 g。
○ 为什么轻量?用户态,弱内核依赖,上下文切换,栈动态扩缩(初始内存是 0)
○ 操作系统如何向某个Goroutine发送时钟中断呢?(这个我暂时没找到答案,目前理解是 os 先通知 go runtime,然后相关 m 在自己执行过程中被插入系统调用。)
○ goroutine 终止会对其他 goroutine 产生影响吗(已捕获可恢复可以,否则会因为传递到顶层 g 导致全部崩溃)

为什么需要P

在 Go 语言中,GMP 模型是 Go 并发编程的核心之一,用于调度 goroutine(Go 语言中的轻量级线程)。其中:

  • G 代表 Goroutine,即 Go 语言中的并发执行单元,类似于线程,但比线程更轻量级。
  • M 代表 Machine,指的是操作系统级别的线程,Go 语言的运行时系统会把 Goroutine 绑定到 M 上,由 M 来实际执行 Goroutine。
  • P 代表 Processor,或者叫处理器,它是逻辑上的 CPU,负责调度 Goroutine 到 M 上执行。每个 P 都维护了一个待执行 Goroutine 的队列。

那么问题来了,为什么 Go 的 GMP 模型需要引入 P 呢?

解耦调度和执行

P 的引入将调度和执行分开,使调度逻辑与具体的操作系统线程(M)解耦。P 负责将 Goroutine 分发给 M 执行,而 M 专注于执行分配到它的 Goroutine。这样,Go 运行时可以更好地管理 Goroutine 的调度,而不需要频繁地操作系统级线程。

负载均衡

P 允许 Go 运行时在多个 M(线程)之间进行负载均衡。每个 P 都维护一个本地的 Goroutine 队列,当 P 队列中的 Goroutine 用完时,它可以从其他 P 中窃取 Goroutine,以平衡负载,确保 CPU 的高效利用。

避免过多线程切换

如果没有 P 的存在,M(线程)与 Goroutine 之间的映射关系将变得复杂且低效。P 的存在减少了上下文切换的开销,因为它确保了一定数量的 Goroutine 在同一个 P 上运行,从而避免频繁的跨线程切换。

控制并发度

P 的数量实际上代表了 Goroutine 可以并行执行的最大数量(GOMAXPROCS)。通过控制 P 的数量,Go 可以限制实际能并行运行的 Goroutine 的数量,从而更好地管理并发资源。

总结

P 的引入使得 Go 的调度系统更加高效和灵活,能够更好地分配 Goroutine 和线程资源,从而最大限度地利用多核 CPU,同时避免不必要的上下文切换和性能损耗。

go 内存分配

img

分配模型

go-memory-layout

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局,每一个处理器都会分配一个线程缓存 runtime.mcache 用于处理微对象和小对象的分配,它们会持有内存管理单元 runtime.mspan

1
2
3
4
type mspan struct {
next *mspan
prev *mspan
}