Golang 一道经典题目的分析

2020/11/18

这道题非常有意思,虽然代码比较少,看起来比较简单,但考察了很多知识点;比如chann、单核&多核、并发&并行、goroutine调度等

题目

func main() {
    //runtime.GOMAXPROCS(1) //First
    exit := make(chan int)
    go func() {
        close(exit)
        for {
            if true {
                //println("Looping!")  //Second
            }
        }
    }()
    <-exit
    println("Am I printed?")
}

分别描述三种情况下的输出结果以及导致的原因

  1. First Second 均注释
  2. First 打开 Second 注释
  3. First Second 均打开

结果

惯例,先说结果,后面再详细分析

  1. First Second 均注释

    关闭channel

输出: Am I printed?

  1. First 打开 Second 注释
    • go1.14及以上

      关闭channel

输出: Am I printed?

  • go1.14版本以下

    关闭channel

程序挂起

  1. First Second 均打开

    关闭channel

打印: Looping!

打印: Looping!

打印: Looping!

打印: Looping!

打印: Am I printed?

相关知识

channel

信道是什么

简单说,是goroutine之间互相通讯的东西。类似我们Unix上的管道(可以在进程间传递消息), 用来goroutine之间发消息和接收消息。其实,就是在做goroutine之间的内存共享,我们使用make来建立一个信道。分为 : 无缓存信道缓存信道

为什么会死锁

如果发生了流入无流出,或者流出无流入,也就导致了死锁。或者这样理解 Go启动的所有goroutine里的非缓冲信道一定要一个线里存数据,一个线里取数据,要成对才行。

特性

  • channel不像文件一样需要经常去关闭,只有当你确实没有任何发送数据了,或者你想显式的结束range循环之类的,才去关闭channel;从一个被close的channel中接收数据不会被阻塞,而是立即返回,接收完已发送的数据后会返回元素类型的零值(zero value)。
  • 关闭channel后,无法向channel 再发送数据(引发 panic 错误后导致接收立即返回零值);
  • 关闭channel后,可以继续从channel接收数据;
  • 对于nil channel,无论收发都会被阻塞。

并发和并行

什么是并发&并行

借用Rob Pike大神关于两者的阐述:“并发关乎结构,并行关乎执行”,具体可以有下面的理解

  • 并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔发生。

    例如: 每天早上10分钟我洗脸,刷牙,吃早饭等等很多事情,这就是并发。我一边刷牙的同时在烧水做饭这就是并行。

    并发的关键是你有处理多个任务的能力,不一定要同时。并行的关键是你有同时处理多个任务的能力

  • 并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。

golang的并行

go 1.5之前默认情况下,Go程序都是不能并行的,因为Go将GOMAXPROCS默认设置为1,这样你仅仅能利用一个内核线程。Go 1.5及以后GOMAXPROCS被默认设置为所运行机器的CPU核数,如果你的机器是多核的,你的Go程序就有可能在运行期是并行的

如何开启单线程

  • 在main函数的最开头处调用runtime.GOMAXPROCS(1);
  • 设置环境变量export GOMAXPROCS=1后再运行程序
  • 找一个单核单线程的机器^0^(现在这样的机器太难找了,只能使用云服务器实现)

runtime调度

什么是调度

粗糙地说调度就是决定何时哪个goroutine将获得资源开始执行、哪个goroutine应该停止执行让出资源、哪个goroutine应该被唤醒恢复执行等

Go采用了用户层轻量级thread或者说是类coroutine的概念来解决这些问题,Go将之称为”goroutine“。goroutine占用的资源非常小(Go 1.4将每个goroutine stack的size默认设置为2k),goroutine调度的切换也不用陷入(trap)操作系统内核层完成,代价很低。因此,一个Go程序中可以创建成千上万个并发的goroutine。所有的Go代码都在goroutine中执行,哪怕是go的runtime也不例外。将这些goroutines按照一定算法放到“CPU”上执行的程序就称为goroutine调度器或goroutine scheduler。

不过,一个Go程序对于操作系统来说只是一个用户层程序,对于操作系统而言,它的眼中只有thread,它甚至不知道有什么叫Goroutine的东西的存在。goroutine的调度全要靠Go自己完成,实现Go程序内goroutine之间“公平”的竞争“CPU”资源,这个任务就落到了Go runtime头上,要知道在一个Go程序中,除了用户代码,剩下的就是go runtime了。

于是Goroutine的调度问题就演变为go runtime如何将程序内的众多goroutine按照一定算法调度到“CPU”资源上运行了。在操作系统层面,Thread竞争的“CPU”资源是真实的物理CPU,但在Go程序层面,各个Goroutine要竞争的”CPU”资源是什么呢?Go程序是用户层程序,它本身整体是运行在一个或多个操作系统线程上的,因此goroutine们要竞争的所谓“CPU”资源就是操作系统线程。这样Go scheduler的任务就明确了:将goroutines按照一定算法放到不同的操作系统线程中去执行。这种在语言层面自带调度器的,我们称之为原生支持并发。

Go调度器模型与演化过程

(1). G-M模型

G-M模型的一个重要不足: 限制了Go并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。主要体现在如下几个方面:

  • 单一全局互斥锁(Sched.Lock)和集中状态存储的存在导致所有goroutine相关操作,比如:创建、重新调度等都要上锁;
  • goroutine传递问题:M经常在M之间传递”可运行”的goroutine,这导致调度延迟增大以及额外的性能损耗;
  • 每个M做内存缓存,导致内存占用过高,数据局部性较差;
  • 由于syscall调用而形成的剧烈的worker thread阻塞和解除阻塞,导致额外的性能损耗。

(2). G-P-M模型

Dmitry Vyukov亲自操刀改进Go scheduler,在Go 1.1中实现了G-P-M调度模型和work stealing算法,这个模型一直沿用至今:

G: 表示goroutine,存储了goroutine的执行stack信息、goroutine状态以及goroutine的任务函数等;另外G对象是可以重用的。

P: 表示逻辑processor,P的数量决定了系统内最大可并行的G的数量(前提:系统的物理cpu核数>=P的数量);P的最大作用还是其拥有的各种G对象队列、链表、一些cache和状态。

M: M代表着真正的执行计算资源。在绑定有效的p后,进入schedule循环;而schedule循环的机制大致是从各种队列、p的本地队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到m,如此反复。M并不保留G状态,这是G可以跨M调度的基础。

协作抢占式调度

在Go 1.2中实现了“抢占式”调度

这个抢占式调度的原理则是在每个函数或方法的入口,加上一段额外的代码,让runtime有机会检查是否需要执行抢占调度。这种解决方案只能说局部解决了“饿死”问题,对于没有函数调用,纯算法循环计算的G,scheduler依然无法抢

Go程序启动时,runtime会去启动一个名为sysmon的m(一般称为监控线程),该m无需绑定p即可运行,该m在整个Go程序的运行过程中至关重要:

sysmon每20us~10ms启动一次,按照《Go语言学习笔记》中的总结,sysmon主要完成如下工作:

  • 释放闲置超过5分钟的span物理内存;
  • 如果超过2分钟没有垃圾回收,强制执行;
  • 将长时间未处理的netpoll结果添加到任务队列;
  • 向长时间运行的G任务发出抢占调度;
  • 收回因syscall长时间阻塞的P;

我们看到sysmon将“向长时间运行的G任务发出抢占调度”,这个事情由retake实施:

异步抢占式调度

Go 1.13及以前的版本的抢占是”协作式“的,只在有函数调用的地方才能插入“抢占”代码(埋点),而deadloop没有给编译器插入抢占代码的机会。这会导致GC在等待所有goroutine停止时等待时间过长,从而导致GC延迟;甚至在一些特殊情况下,导致在STW(stop the world)时死锁。

Go 1.14采用了基于系统信号的异步抢占调度

Go 1.14的异步抢占调度在windows/arm, darwin/arm, js/wasm, and plan9/*上依然尚未支持,Go团队计划在Go 1.15中解决掉这些问题。

解析

情况1: First Second 均注释

这种情况下,会 关闭channel,然后打印最后一行 Am I printed?, 最后程序退出

首先,程序执行后,会启动两个线程,主main子goroutine;然而主main在执行到 <-exit 这一行时,因为chann exit是阻塞的,并且没有数据输入,所以main被挂起;当子goroutine执行到 close(exit) 一行时,根据上面chann的第一个特性 一个被close的channel中接收数据不会被阻塞,而是立即返回, 刚才挂起的main继续往下执行,当主main全部执行完毕时,子goroutine也被强行结束,整个程序执行完毕

为什么子goroutine进入deadloop后,main还能继续执行呢?上面提到Go 1.5及以后GOMAXPROCS被默认设置为所运行机器的CPU核数,虽然子goroutine占用的内核未被释放,但对于多核服务器来说,主main会在另外一个核中运行,所以此时子goroutine并不会影响主main的执行。不过如果设置只能在一个核中运行,情况就可能会不太一样了

情况2: First 打开 Second 注释

go1.14版本及以上,会 关闭channel,然后打印最后一行 Am I printed?, 最后程序退出

go1.14版本以下,整个程序会挂起

首先,程序执行后,会启动两个线程,主main子goroutine;然而主main在执行到 <-exit 这一行时,因为chann exit是阻塞的,并且没有数据输入,所以main被挂起;子goroutine执行 close(exit) ,然后进入 deadloop, 跟情况1不同的是,这里限定了 1个核 来执行;

所以在1.14版本以下,子goroutine不会被抢占,一直占用着单独的核不放,导致主main一直被hold住,整个程序挂起;而在1.14版本及以上,golang支持异步抢占式调度,子goroutine的deadloop也会被抢占,然后主main从关闭的chann获取数据,继续执行下去,主main执行结束,强制关闭子goroutine,整个程序执行完毕

情况3: First Second 均打开

跟情况2不同的是,deadloop中加入了函数 println,所以会被协作抢占式调度,子goroutine让出内核执行,主main从关闭的chann中获取数据,继续执行

go语言死循环分析

下面程序虽然用到了多核,但是还会被挂起

package main
import (
    "fmt"
    "io"
    "log"
    "net/http"
    "runtime"
    "time"
)
func main() {
    runtime.GOMAXPROCS(runtime.NumCPU())
    go server()
    go printNum()
    var i = 1
    for {
        // will block here, and never go out
        i++
    }
    fmt.Println("for loop end")
    time.Sleep(time.Second * 3600)
}
func printNum() {
    i := 0
    for {
        fmt.Println(i)
        i++
    }
}
func HelloServer(w http.ResponseWriter, req *http.Request) {
    fmt.Println("hello world")
    io.WriteString(w, "hello, world!\n")
}
func server() {
    http.HandleFunc("/", HelloServer)
    err := http.ListenAndServe(":12345", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

运行,会发现打印一会儿数字后停了,我们执行

curl localhost:12345

程序卡死

原因如下

因为在 for 循环中没有函数调用的话,编译器不会插入调度代码,所以这个执行 for 循环的 goroutine 没有办法被调出,而在循环期间碰到 gc,那么就会卡在 gcwaiting 阶段,并且整个进程永远 hang 死在这个循环上。并不再对外响应。

我们再看一篇文章,golang的垃圾回收(GC)机制,这篇文章很短,但每句话都很重要:

  1. 设置gcwaiting=1,这个在每一个G任务之前会检查一次这个状态,如是,则会将当前M 休眠;
  1. 如果这个M里面正在运行一个长时间的G任务,咋办呢,难道会等待这个G任务自己切换吗?这样的话可要等10ms啊,不能等!坚决不能等! 所以会主动发出抢占标记,让当前G任务中断,再运行下一个G任务的时候,就会走到第1步

那么如果这时候运行的是没有函数调用的死循环呢,gc也发出了抢占标记,但是如果死循环没有函数调用,就没有地方被标记,无法被抢占,那就只能设置gcwaiting=1,而M没有休眠,stop the world卡住了(死锁),gcwaiting一直是1,整个程序都卡住了!

参考资料

  1. 也谈goroutine调度器
  2. 图解Go运行时调度器
  3. Goroutine调度实例简要分析
  4. golang的垃圾回收(GC)机制
  5. Go 1.14中值得关注的几个变化
  6. go1.14基于信号的抢占式调度实现原理
  7. go语言死循环分析
  8. golang语言并发与并行——goroutine和channel的详细理解(一)
  9. golang语言并发与并行——goroutine和channel的详细理解(二)

Post Directory