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 与线程间的动态绑定调度

切换

  1. 使用go创建新的goroutine可能会被调度;
  2. 阻塞操作:当一个goroutine执行阻塞操作运行时会将其挂起;
    1. 系统调用:当一个goroutine进行系统调用时,运行时会将其挂起;
      1. 同步系统调用:此时 M 会被阻塞,造成阻塞的 G 会继续挂在 M 上,但是对应的P会重新绑定到新的(或者唤醒一个)M 上,继续执行其他G,当阻塞结束,该G会重新加入队列,M可以被挂起;
      2. **异步系统调用: 这些异步操作注册到I/O多路复用器(network poller)中,并在操作完成时得到通知,即在有数据可读或可写时重新激活它;
    2. 内存同步访问:如 atomicmutexchannel 等操作会使 goroutine 阻塞,当可继续执行(条件被满足)时,会被重新调度
    3. 被阻塞的 goroutine 会被挂在对应的等待队列中,如 channel 结构下的 recvq 或者 sendq 队列中、mutex 阻塞的会放在对应的等待队列中;
  3. 手动调用runtime.Gosched():当一个 goroutine 显式调用runtime.Gosched()时,当前 goroutine 会主动让出处理器,让其他 goroutine 有机会执行;
  4. 调度器时间片用完:Go调度器会定期检查每个 goroutine 的执行时间(10ms)。如果一个 goroutine 占用了过多的时间片,调度器会强制切换到其他 goroutine;
  5. 创建新goroutine:当新创建一个 goroutine 时,调度器会安排其执行,可能导致当前执行的 goroutine 被挂起;
  6. runtime.GOMAXPROCS的变化:如果通过 runtime.GOMAXPROCS 设置了新的最大处理器数量,调度器可能重新平衡 goroutine 的分配,这也可能导致 goroutine 的切换;

内核级线程

操作系统的最小调度单元;

创建,销毁,调度交给操作系统内核完成;

如果线程阻塞,操作系统会自动切换到另一个就绪的线程;

cpu负责:

  • 执行线程:CPU负责执行操作系统调度的线程
  • 线程切换
  • 并行处理:在多核CPU中,每个核心可以同时执行一个线程,从而实现并行处理;
  • CPU在执行程序时,会在内核态和用户态之间切换
    • 用户态:在用户态下,程序不能直接访问硬件和内存,也不能直接执行某些特权指令;当程序需要使用系统资源或执行特权指令时,它会通过系统调用请求切换到内核态;
    • 内核态:在内核态下,操作系统可以直接访问硬件和内存,执行特权指令;当系统调用结束后,CPU会切换回用户态,继续执行用户程序;
  • 切换过程中,CPU需要保存当前状态(如寄存器的值),然后加载新状态;即上下文切换;由于涉及到数据的拷贝和安全验证等操作,用户态和内核态之间的切换是相对耗费资源的;

多线程必然伴随数据竞争(锁,竞争资源冲突等);

有意思的样例

  1. 如果将P设置为一个:runtime.GOMAXPROCS(1),表示为限制任意时刻只有一个M执行代码;

  go创建新的goroutine时,会被编译器转化为调用 runtime.newproc 的调用;

  newproc 会先切换到系统栈,然后调用 newproc1 函数,分配并初始化一个新的 G,再通过 runqput 把新的 G 添加到当前 P 的本地 runq 中,

  注意P 包含一个本地 runq,以及一个用于保存下一次运行的Grunnext 字段,由于只有一个 Pruntime.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()
}
标签: #go