我们计算机上面跑的每个任务,都是操作系统层面的资源分配,从启动进程到创建线程,在核数固定的情况下,多线程并发地执行。Go协程是一个比系统线程更细粒度的资源,轻量级和易切换。
这几天看了一些相关的文章,这次尝试从操作系统到Go协程,简单聊聊它们是如何关联上的以及我个人的理解。
操作系统负责着底层硬件的调度,它分配CPU,内存,磁盘的资源,并且替我们分配不同线程在不同CPU核的执行,保证各个程序如预期的指令进行执行。我们提交的每个程序,最终都会转换成一系列给操作系统识别的指令。
上述经过转换的一系列指令,操作系统会通过线程来帮我们执行,本质上线程是a path of execution
,一段可执行的程序路径。
线程有下面三个状态:
调度器肩负着巨大的使命,主要在于调度CPU与线程关系,保证不会有CPU闲下来,想象一下CPU就是仓鼠笼子里面的仓鼠,调度器一旦发现有仓鼠(CPU)在打瞌睡,就推动它们到轮子(线程)上跑,一刻也不能停。毕竟CPU的运算能力是很强悍的,一毫秒的空闲都是巨大的浪费。
往细处说,仓鼠只有4只(四核),哪个轮子(线程)优先跑,取决于轮子(线程)的优先级。调度器肩负着运筹帷幄的使命,既要减少催促仓鼠跑动的延迟,同时还要保证不能有任务一直得不到执行,线程如果一直得不到仓鼠处理被称作“饥饿现象”。
不同的任务对系统资源有不同的要求
I/O频繁切换:
未雨绸缪,IO指的是Input/Output,输入输出等待。这种等待资源输入/输出的job,主要瓶颈在资源是否分配到位,比如系统调用/文件IO等,其线程切换的时间远远小于IO设备/网络延迟,主要短板在于等待I/O,而不是线程上下文切换,因此可以分配较多线程,在“粮草”还没送到的这段时间,执行作战前的准备。
计算密集型:
这种任务主要耗CPU,比如用一个线程计算圆周率π的第N位。如果分配大量的线程给它,系统既要保证各个线程对计算状态的共享,先不考虑会发生脏读/脏写操作,还需要频繁进行线程切换,这会造成大量时间浪费,线程每次重新唤醒都要在程序计数器(PC)寻找下一个执行指令的位置,在核数固定的情况下,分配过多线程会有事倍功半的效果。
If your program is focused on CPU-Bound work, then context switches are going to be a performance nightmare. Since the Thead always has work to do, the context switch is stopping that work from progressing.
当然也有例外的情况,比如Map-Reduce,指的是先拆分后聚合。当你的计算任务可以拆分成很多模块,在各个模块不同执行顺序不会影响最终结果的情况下,尝试“逐个击破”,最终再汇聚的情况,如果能合理分配任务,多线程肯定是优于单线程的。
后面尝试通过列举几个Go的简单程序,通过单/多协程处理,比对两者在不同情景的性能差异情况。
这个例子比较简单,我们目标是累加一个等差数列
var PIXEL_ARRAY []int
//初始化一个等差数列,后面尝试将等差数列传到单协程/多协程函数进行累加
func init() {
for i := 1; i <= 100000; i++ {
PIXEL_ARRAY = append(PIXEL_ARRAY, i)
}
}
后面我们用单协程/多协程版本,区分开两个实现方式
//单协程暴力版本
func SumWithSingle(arr []int) int32 {
var sum int32
//遍历累加,相当暴力!
for i := 0; i < len(arr); i++ {
sum += int32(arr[i])
}
return sum
}
//多协程版本,每个协程均等计算自己分配的切片区间, gNum是起多少个协程并发处理
func SumWithMulti(arr []int, gNum int) int32 {
var wg sync.WaitGroup
//用于等待gNum个协程执行完
wg.Add(gNum)
var sum int32
//各个任务的平均长度
div := len(arr) / gNum
//注意切割长度需要向上取整,此处非本demo侧重点,为了简单化一律使用整除切开原数组.
//div := int(math.Ceil(float64(float64(len(arr)) / float64(gNum))))
for i := 0; i < gNum; i++ {
Left := i * div
Right := Left + div
if i == gNum {
Right = len(arr)
}
go func() {
//每个协程独立的汇总
ps := 0
for _, value := range arr[Left:Right] {
ps += value
}
//处理完累积到全局变量,由于仅有累加操作,可以用原子加实现互斥, 这里无需加锁.
atomic.AddInt32(&sum, int32(ps))
wg.Done()
}()
}
//等待各个子协程计算完毕
wg.Wait()
return sum
}
下面尝试用BenchMark分析性能,输出如下:
import "testing"
var PIXEL_ARRAY []int
func init() {
for i := 1; i <= 100000; i++ {
PIXEL_ARRAY = append(PIXEL_ARRAY, i)
}
}
func BenchmarkSumWithSingle(b *testing.B) {
for i := 0; i < b.N; i++ {
SumWithSingle(PIXEL_ARRAY)
}
}
func BenchmarkSumWithMulti(b *testing.B) {
for i := 0; i < b.N; i++ {
SumWithMulti(PIXEL_ARRAY, 10)
}
}
结果示例:
在4核电脑上,启动10个协程去并发处理,针对这个例子多协程是优于单协程的,符合预期。
$ GOGC=off go test -run none -bench . -benchtime 3s
goos: windows
goarch: amd64
pkg: HelloGo/basic/Multi
BenchmarkSumWithSingle-4 50000 63583 ns/op
BenchmarkSumWithMulti-4 100000 39216 ns/op
PASS
ok HelloGo/basic/Multi 8.623s
在操作系统层面,每个线程的执行先后顺序是无法保证的。Go中,协程也是如此。拿上个例子来说,Map-Reduce的Map操作是切开等差数列,分配到任务的不同Go协程执行时间是不确定的,有可能1+2+3+...
, 也有可能是21+22+23+...
如果程序要控制相应线程执行的顺序,需要在操作系统的上一层,比如编程语言中加入调度指令,如++原子操作,同步锁,互斥量++,程序的性能也和锁的粒度有关系,这个相关知识可以作为以后拓展。
Go调度器的实现是基于操作系统调度这些理念实现的,后面我们尝试往更高层(用户态)走,以Go协程是如何被调度的角度来分析。
众所周知,Go调度器有下面几个主要的组件:
At any time, M goroutines need to be scheduled on N OS threads that runs on at most GOMAXPROCS numbers of processors.
GO的P和你的CPU核心数量有关,注意这里并不是真正CPU数量,是++逻辑CPU数量++。查看任务管理器,在4核CPU的情况下,假如说你的机器具备超线程(i7处理器),每个物理核有两个线程,那么意味着在Go程序里逻辑上有8个可用的处理器,逻辑CPU数量应该是8。即上面所提到的P。
我当前机器是(i5处理器),每个核一个线程,所以下面输出的逻辑CPU(P)数量是4,这代表当启动多个线程的时候,我的机器最多可以支持并行4个系统线程,多出来的线程就是并发了。
可以通过在你的机器执行下列Go代码,看下逻辑P的数量,
package main
import (
"fmt"
"runtime"
)
func main() {
// NumCPU returns the number of logical
// CPUs usable by the current process.
//我的机器输出 4
fmt.Println(runtime.NumCPU())
}
每一个P会分配一个系统线程M,相当于说在这个机器的Go程序中,我有4个系统线程可以用来执行我的任务,每个都独立与其中一个核,P挂钩。
M个Go协程会分配在N个系统线程上,每个要运行的G都必须关联上P(逻辑CPU),程序可以干涉GOMAXPROCS
的数量,以控制最多有多少个P可以使用。
在Go中,每个上下文P会分配一个本地的协程队列(LRQ),叫做Local Run Queue ,一个M必须关联所需的资源P才能运行相应的G队列,正如操作系统层面上每个线程队列需要分配关联上CPU才能得到执行,类比前面的栗子,轮子(M)需要仓鼠(P)去带动,才能执行(跑G协程任务)。
如下图所示:
P1,P2是相对固定的,M和G是可以随调度器分配选择的。
理解完上面的一些概念之后,现在我们看下Go是如何对协程进行切换,文章前面提到,假如线程创建过多,由于系统调度的不确定性,线程得不到执行,可能会长期处于饥饿。同理Go协程也会有“靠边停车”的现象,所以Go调度器需要一些的条件来触发协程切换,避免停完车就不再启动了,具体的切换本质上就是Go调度器对P-M-G三者之间的断开与重连。
下列情景可能会让调度器对执行上下文,即go协程的切换做出决定:
Gosched()
等操作下面展示一个简单而且比较常见的例子,我们通过限制逻辑CPU的个数,尝试干涉Go调度器。
func main() {
//设置最多一个P可以关联上
runtime.GOMAXPROCS(1)
go func() {
for {
//保持空转,如果没有其他函数调用,在单个处理器下该协程不会切换
;
}
}()
//尝试切换协程
runtime.Gosched()
println("main done.")
}
我们通过runtime.GOMAXPROCS(1)
设置本次运行的逻辑CPU个数,代表可用的P只有1,意味着最多只能同时分配给一个协程去执行。如果执行这个程序,由于主协程执行了让步Gosched(),获得运行权的的Go协程会得到执行,由于该协程在死循环一直执行空语句,导致程序不会有任何输出,而且主协程得不到运行权,所以这个程序永远不会退出。
题外话:注意这里区别于上面的runtime.NumCPU(int)
方法,NumCPU()
是在启动时候直接调用系统方法,所以和经过GOMAXPROCS的设置之后,并不会改变NumCPU()
原有的返回值,GOMAXPROCS
仅是对本次运行时P数量进行限制。
从Go调度器角度看,执行的协程任务有点快有的慢,既然我的职责是不能让CPU空闲,那当我有空的时候,我肯定要从别人那里偷一些任务来跑。
是的,是明目张胆的偷!我觉得这张图很贴切,所以我把这张图也偷来了哈哈哈。
原文参考:Go: Work-Stealing in Go Scheduler (可能需要科学上网)
前面提到两个队列,全局/本地队列:
runtime.schedule() {
// only 1/61 of the time, check the global runnable queue for a G.
// if not found, check the local queue.
// if not found,
// try to steal from other Ps.
// if not, check the global runnable queue.
// if not found, poll network.
}
上面的注释表明了偷窃各个部分的优先级,只有1/61的时间会去检查全局队列。
优先级:本地队列 > 其他P的队列 > 全局队列
根据这张我从别处偷来的这张图,此时P2的本地队列已经是空的,所以这个时候P2即将要从P1偷走3个G
Go调度的思想是基于上述系统调度实现的,归根结底Go中的协程切换是P-M-G三者之间的断开与重连。所以G协程在当前线程M的切换,就像从系统角度看,线程在CPU上面的切换。
Your workload is naturally stopped and this allows a different Goroutine to leverage the same OS/hardware thread efficiently instead of letting the OS/hardware thread sit idle.
因为Go协程比线程粒度更小,更加轻量级,所以Go协程切换会比OS直接切换线程代价更小。
到这里只是浅尝辄止的梳理Go调度的情况,大家如果有兴趣深入挖掘的话可以参考郝林老师的《Go并发编程实战》,或者直接看源码,相关的Go调度策略可以在src/runtime/proc.go
找到。
Scheduling In Go : Part II - Go Scheduler
Ardanlabs素质三连:
https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
dotGo 2017 - JBD - Go’s work stealing scheduler
https://www.youtube.com/watch?v=Yx6FBsGNOp4
Go: Goroutine and Preemption
https://medium.com/a-journey-with-go/go-goroutine-and-preemption-d6bc2aa2f4b7
Go’s work-stealing scheduler
https://rakyll.org/scheduler/