这道题非常有意思,虽然代码比较少,看起来比较简单,但考察了很多知识点;比如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?")
}
分别描述三种情况下的输出结果以及导致的原因
- First Second 均注释
- First 打开 Second 注释
- First Second 均打开
结果
惯例,先说结果,后面再详细分析
- First Second 均注释
关闭channel
输出: Am I printed?
- First 打开 Second 注释
- go1.14及以上
关闭channel
- go1.14及以上
输出: Am I printed?
- go1.14版本以下
关闭channel
程序挂起
- 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)机制,这篇文章很短,但每句话都很重要:
- 设置gcwaiting=1,这个在每一个G任务之前会检查一次这个状态,如是,则会将当前M 休眠;
- 如果这个M里面正在运行一个长时间的G任务,咋办呢,难道会等待这个G任务自己切换吗?这样的话可要等10ms啊,不能等!坚决不能等! 所以会主动发出抢占标记,让当前G任务中断,再运行下一个G任务的时候,就会走到第1步
那么如果这时候运行的是没有函数调用的死循环呢,gc也发出了抢占标记,但是如果死循环没有函数调用,就没有地方被标记,无法被抢占,那就只能设置gcwaiting=1,而M没有休眠,stop the world卡住了(死锁),gcwaiting一直是1,整个程序都卡住了!