面试官:Go 并发编程的秘密武器

大家好,我是木川

Go 语言的并发性能的关键组成部分在于其调度原理,Go 使用一种称为 M:N 调度的模型,其中 M 代表操作系统的内核态线程,而 N 代表 用户态线程 Goroutines( Go 语言的轻量级线程)

实质上,Goroutine 调度是将 Goroutine(G)按照特定算法分派到 CPU 上执行的过程。由于 CPU 无法感知Goroutines,只能感知内核线程,因此需要 Go 调度器将 Goroutines 调度到内核线程M上,然后由操作系统调度器将内核线程 M 放入 CPU 上执行。M 实际上是对内核级线程的封装,因此 Go 调度器的核心任务是 Goroutines 分配给 M

Go 调度器的实现经历了多次演化,包括从最初的 GM 模型 到 GMP 模型,从不支持抢占到支持协作式抢占,再到支持基于信号的异步抢占。这个演化过程经历了不断的优化与打磨,以提高 Go语言的并发性能

一、GMP 模型概念

在Go语言中,并发处理的基本单位是 Goroutines,它们是轻量级的线程,由 Go 运行时调度和管理。这一调度系统的核心是GMP模型,包括三个主要组件:

G(Goroutines):用户线程,通过 go 关键字创建

M(Machine):操作系统线程

P(Processor):调度上下文,维护了一组 Goroutine 队列

其中 Goroutines 相对于传统线程占用内存更低,它们的创建和销毁成本非常低,因此可以轻松创建成千上万个Goroutines,而不会导致大量的资源消耗。这一特性在高并发应用中非常有用,例如我们需要编写一个网络服务器,每个客户端连接都需要一个独立的Goroutine来处理请求。在传统的线程模型下,为每个连接创建线程可能会导致资源耗尽,但在Go中,可以轻松创建成千上万个Goroutines来同时处理客户端请求,而不会带来明显的性能问题。

二、GMP 模型设计思想

利用并行

多个协程绑定不同的操作系统线程,可以利用多核 CPU

线程复用

work stealing 机制 :线程 M ⽆可运⾏的 G 时,尝试从其他 M 绑定的P 偷取 G,减少空转 hand off 机制:线程 M 因为 G 系统调用阻塞时,将 P 转交给其他空闲的 M 执行,M 执行 P 的剩余G

抢占调度

避免某些 Goroutine 长时间占用线程,造成其它 Goroutine 饥饿,解决公平性问题

三、GMP 模型原理

谁来调度

Go 调度器负责调度 G 给 M, Go 调度器是属于 Go 运行时中的一部分,Go 运行时 、负责实现Go的并发调度、垃圾回收、内存堆栈管理等关键功能

被调度对象

G 的来源

  • P的runnext(只有1个G,局部性原理,永远会被最先调度执行)

  • P的本地队列(数组,最多256个G)

  • 全局G队列(链表,无限制)

  • 网络轮询器_network poller_(存放网络调用被阻塞的G)

P 的来源

  • 全局P队列(数组,GOMAXPROCS个P)

M 的来源

  • 休眠线程队列(未绑定P,长时间休眠会等待GC回收销毁)

  • 运行线程(绑定P,指向P中的G)

  • 自旋线程(绑定P,指向M的G0)

调度时机

在以下情形下,会切换正在执行的goroutine

  • 抢占式调度

    • sysmon 检测到协程运行过久(比如sleep或死循环),切换到g0,进入调度循环

  • 主动调度

    • 新起一个协程和协程执行完毕,触发调度循环

    • 主动调用runtime.Gosched(),切换到g0,进入调度循环

    • 垃圾回收stw之后,会重新选择g开始执行

  • 被动调度

    • 系统调用阻塞(同步),阻塞G和M,P与M分离,将P交给其它M绑定,其它M执行P的剩余G

    • 网络IO调用阻塞(异步),阻塞G,G移动到NetPoller,M执行P的剩余G

    • atomic/mutex/channel等阻塞(异步),阻塞G,G移动到channel的等待队列中,M执行P的剩余G

调度流程

协程的调度采用了生产者-消费者模型,实现了用户任务与调度器的解耦

71ed967b669637bffdca3d68317698df.png b41378ec87c37de033697c9341e68b3f.jpeg

生产端我们开启的每个协程都是一个计算任务,这些任务会被提交给 go 的 runtime。如果计算任务非常多,有成千上万个,那么这些任务是不可能同时被立刻执行的,所以这个计算任务一定会被先暂存起来,一般的做法是放到内存的队列中等待被执行。

G的生命周期:G 从创建、保存、被获取、调度和执行、阻塞、销毁,步骤如下:

步骤 1:创建 G

执行 go func 的时候,主线程 M0 会调用 newproc()生成一个 G 结构体

步骤 2:保存 G

创建的 G 优先保存到本地队列 P,如果 P 满了,则会平衡部分P到全局队列中

  • 每个协程 G 都会被尝试先放到 P 中的 runnext,若 runnext 为空则放到 runnext 中,生产结束

  • 若 runnext 满,则将原来 runnext 中的 G 踢到本地队列中,将当前 G 放到 runnext 中,生产结束

  • 若本地队列也满了,则将本地队列中的 G 拿出一半,放到全局队列中,生产结束

步骤3:唤醒或者新建M

找到一个M进入调度循环:重复步骤4、5、6

步骤 4:M 获取 G

具体见下面的调度策略

步骤 5:M 调度和执行 G

M调用 G.func() 函数执行 G

  • 如果 M在执行 G 的过程发生系统调用阻塞(同步),会阻塞G和M(操作系统限制),此时P会和当前M解绑,并寻找新的M,如果没有空闲的M就会新建一个M ,接管正在阻塞G所属的P,接着继续执行 P中其余的G,这种阻塞后释放P的方式称之为hand off。当系统调用结束后,这个G会尝试获取一个空闲的P执行,优先获取之前绑定的P,并放入到这个P的本地队列,如果获取不到P,那么这个线程M变成休眠状态,加入到空闲线程中,然后这个G会被放入到全局队列中。

  • 如果M在执行G的过程发生网络IO等操作阻塞时(异步),阻塞G,不会阻塞M。M会寻找P中其它可执行的G继续执行,G会被网络轮询器network poller 接手,当阻塞的G恢复后,从network poller 被移回到P的本地队列中,重新进入可执行状态。异步情况下,通过调度,Go scheduler 成功地将 I/O 的任务转变成了 CPU 任务,或者说将内核级别的线程切换转变成了用户级别的 goroutine 切换,大大提高了效率。

步骤6:清理现场

M执行完G后清理现场,重新进入调度循环(将M上运⾏的goroutine切换为G0,G0负责调度时协程的切换)

调度策略

使用什么策略来挑选下一个goroutine执行:由于 P 中的 G 分布在 runnext、本地队列、全局队列、网络轮询器中,则需要挨个判断是否有可执行的 G,大体逻辑如下:

  • 每执行61次调度循环,从全局队列获取G,若有则直接返回(主要避免全局队列中的G饿死)

  • 从P 上的 runnext 看一下是否有 G,若有则直接返回

  • 从P 上的 本地队列 看一下是否有 G,若有则直接返回

  • 上面都没查找到时,则去全局队列、网络轮询器查找或者从其他 P 中窃取,一直阻塞直到获取到一个可用的 G 为止

源码实现如下:

func schedule() {
    _g_ := getg()
    var gp *g
    var inheritTime bool
    ...
    if gp == nil {
        // 每执行61次调度循环会看一下全局队列。为了保证公平,避免全局队列一直无法得到执行的情况,当全局运行队列中有待执行的G时,通过schedtick保证有一定几率会从全局的运行队列中查找对应的Goroutine;
        if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
            lock(&sched.lock)
            gp = globrunqget(_g_.m.p.ptr(), 1)
            unlock(&sched.lock)
        }
    }
    if gp == nil {
        // 先尝试从P的runnext和本地队列查找G
        gp, inheritTime = runqget(_g_.m.p.ptr())
    }
    if gp == nil {
        // 仍找不到,去全局队列中查找。还找不到,要去网络轮询器中查找是否有G等待运行;仍找不到,则尝试从其他P中窃取G来执行。
        gp, inheritTime = findrunnable() // blocks until work is available
        // 这个函数是阻塞的,执行到这里一定会获取到一个可执行的G
    }
    ...
    // 调用execute,继续调度循环
    execute(gp, inheritTime)
}

四、总结

在实际应用中,Go 已经证明了其在高并发环境中的优越性能,例如,高并发的Web服务器、分布式系统和并行计算都受益于 GMP 模型。了解和利用GMP模型将使你的程序更具竞争力,并能够有效地处理大规模并发。

最后给自己的原创 Go 面试小册打个广告,如果你从事 Go 相关开发,欢迎扫码购买,目前 10 元买断,加下面的微信发送支付截图额外赠送一份自己录制的 Go 面试题讲解视频

c891a49f9607ff010e8ed65d78f10d8f.jpeg

94bf087f6213da3423a0c095c27e1083.png

如果对你有帮助,帮我点一下在看或转发,欢迎关注我的公众号

猜你喜欢

转载自blog.csdn.net/caspar_notes/article/details/133820334