调度器工作原理

https://juejin.im/post/5ed9cbedf265da770b40d6d4
https://mp.weixin.qq.com/s?__biz=MzUzNTY5MzU2MA==&mid=2247484521&idx=1&sn=85bf9b325170c6f8daba67664b0d41ba&chksm=fa80d5fecdf75ce8d1ee435e54eecfa789a68cc6ce7c140ce4b26f0f5037a64b7500cea5ad45&token=1028062157&lang=zh_CN#rd



并发程序的数据竞争问题。



使用go命令行工具检测程序的竞争情况。



解决数据竞争的常用方案。



如何选择解决数据竞争的方案。



一道测试自己并发编程掌握程度的思考题。



数据竞争
要解释什么是数据竞争我们先来看一段程序:



package main



import “fmt”



func main() {
fmt.Println(getNumber())
}



func getNumber() int {
var i int
go func() {
i = 5
}()



return i } 上面这段程序getNumber函数中开启了一个单独的goroutine设置变量i的值,同时在不知道开启的goroutine是否已经执行完成的情况下返回了i。所以现在正在发生两个操作:


变量i的值正在被设置成5。



函数getNumber返回了变量i的值。



现在,根据这两个操作中哪一个先完成,最后程序打印出来的值将是0或5。



这就是为什么它被称为数据竞争:getNumber返回的值根据操作1或操作2中的哪一个最先完成而不同。



下面的两张图描述了返回值的两种可能的情况对应的时间线:



数据竞争–读操作先完成
数据竞争–读操作先完成
数据竞争–写操作先完成
数据竞争–写操作先完成
你可以想象一下,每次调用代码时,代码表现出来的行为都不一样有多可怕。这就是为什么数据竞争会带来如此巨大的问题。



检测数据竞争
我们上面代码是一个高度简化的数据竞争示例。在较大的应用程序中,仅靠自己检查代码很难检测到数据竞争。幸运的是,Go(从V1.1开始)有一个内置的数据竞争检测器,我们可以使用它来确定应用程序里潜在的数据竞争条件。



使用它非常简单,只需在使用Go命令行工具时添加-race标志。例如,让我们尝试使用-race标志来运行我们刚刚编写的程序:



go run -race main.go
执行后将输出:



0


WARNING: DATA RACE
Write at 0x00c00001a0a8 by goroutine 6:
main.getNumber.func1()
/QSC/go/src/example.com/http_demo/utils/vlog/main.go:12 +0x38



Previous read at 0x00c00001a0a8 by main goroutine:
main.getNumber()
/QSC/go/src/example.com/http_demo/utils/vlog/main.go:15 +0x88
main.main()
/QSC/go/src/example.com/http_demo/utils/vlog/main.go:6 +0x33



Goroutine 6 (running) created at:
main.getNumber()
/QSC/go/src/example.com/http_demo/utils/vlog/main.go:11 +0x7a
main.main()
/QSC/go/src/example.com/http_demo/utils/vlog/main.go:6 +0x33
==================
Found 1 data race(s)
exit status 66
第一个0是打印结果(因此我们现在知道是操作2首先完成)。接下来的几行给出了在代码中检测到的数据竞争的信息。我们可以看到关于数据竞争的信息分为三个部分:



第一部分告诉我们,在getNumber函数里创建的goroutine中尝试写入(这是我们将值5赋给i的位置)



第二部分告诉我们,在主goroutine里有一个在同时进行的读操作。



第三部分描述了导致数据竞争的goroutine是在哪里被创建的。



除了go run命令外,go build和go test命令也支持使用-race标志。这个会使编译器创建的应用程序能够记录所有运行期间对共享变量访问,并且会记录下每一个读或者写共享变量的goroutine的身份信息。



竞争检查器会报告所有的已经发生的数据竞争。然而,它只能检测到运行时的竞争条件,并不能证明之后不会发生数据竞争。由于需要额外的记录,因此构建时加了竞争检测的程序跑起来会慢一些,且需要更大的内存,即使是这样,这些代价对于很多生产环境的工作来说还是可以接受的。对于一些偶发的竞争条件来说,使用附带竞争检查器的应用程序可以节省很多花在Debug上的时间。



解决数据竞争的方案
Go提供了很多解决它的选择。所有这些解决方案的思路都是确保在我们写入变量时阻止对该变量的访问。一般常用的解决数据竞争的方案有:使用WaitGroup锁,使用通道阻塞以及使用Mutex锁,下面我们一个个来看他们的用法并比较一下这几种方案的不同点。



使用WaitGroup
解决数据竞争的最直接方法是(如果需求允许的情况下)阻止读取访问,直到写入操作完成:



func getNumber() int {
var i int
// 初始化一个WaitGroup
var wg sync.WaitGroup
// Add(1) 通知程序有一个需要等待完成的任务
wg.Add(1)
go func() {
i = 5
// 调用wg.Done 表示正在等待的程序已经执行完成了
wg.Done()
}()
// wg.Wait会阻塞当前程序直到等待的程序都执行完成为止
wg.Wait()
return i
}
下面是使用WaitGroup后程序执行的时间线:



使用WaitGroup后程序执行的时间线
使用WaitGroup后程序执行的时间线
使用通道阻塞
这个方法原则上与上一种方法类似,只是我们使用了通道而不是WaitGroup:



func getNumber() int {
var i int
// 创建一个通道,在等待的任务完成时会向通道发送一个空结构体
done := make(chan struct{})
go func() {
i = 5
// 执行完成后向通道发送一个空结构体
done <- struct{}{}
}()
// 从通道接收值将会阻塞程序,直到有值发送给done通道为止
<-done
return i
}
下图是使用通道阻塞解决数据竞争后程序的执行流程:



使用通道解决数据竞争后程序的执行流程
使用通道解决数据竞争后程序的执行流程
使用Mutex
到目前为止,使用的解决方案只有在确定写入操作完成后再去读取i的值时才适用。现在让我们考虑一个更通常的情况,程序读取和写入的顺序并不是固定的,我们只要求它们不能同时发生就行。这种情况下我们应该考虑使用Mutex互斥锁。



// 首先,创建一个结构体包含我们想用互斥锁保护的值和一个mutex实例
type SafeNumber struct {
val int
m sync.Mutex
}



func (i *SafeNumber) Get() int {、
i.m.Lock()

defer i.m.Unlock()

return i.val
}



func (i *SafeNumber) Set(val int) {
i.m.Lock()
defer i.m.Unlock()
i.val = val
}



func getNumber() int {
// 创建一个sageNumber实例
i := &SafeNumber{}
// 使用Set和Get代替常规赋值和读取操作。
// 我们现在可以确保只有在写入完成时才能读取,反之亦然
go func() {
i.Set(5)
}()
return i.Get()
}
下面两个图片对应于程序先获取到写锁和先获取到读锁两种可能的情况下程序的执行流程:



先获取到写锁时程序的执行流程
先获取到写锁时程序的执行流程
先获取读锁时程序的执行流程
先获取读锁时程序的执行流程
Mutex vs Channel
上面我们使用互斥锁和通道两种方法解决了并发程序的数据竞争问题。那么我们该在什么情况下使用互斥锁,什么情况下又该使用通道呢?答案就在你试图解决的问题中。如果你试图解决的问题更适合互斥锁,那么就继续使用互斥锁。。如果问题似乎更适合渠道,则使用它。



大多数Go新手都试图使用通道来解决所有并发问题,因为这是Go语言的一个很酷的特性。这是不对的。语言为我们提供了使用Mutex或Channel的选项,选择两者都没有错。



通常,当goroutine需要相互通信时使用通道,当确保同一时间只有一个goroutine能访问代码的关键部分时使用互斥锁。在我们上面解决的问题中,我更倾向于使用互斥锁,因为这个问题不需要goroutine之间的任何通信。只需要确保同一时间只有一个goroutine拥有共享变量的使用权,互斥锁本来就是为解决这种问题而生的,所以使用互斥锁是更自然的一种选择。



一道用Channel解决的思考题
上面讲数据竞争问题举的例子里因为多个goroutine之间不需要通信,所以使用Mutex互斥锁的方案更合理些。那么针对使用Channel的并发编程场景我们就先留一道思考题给大家,题目如下:



假设有一个超长的切片,切片的元素类型为int,切片中的元素为乱序排列。限时5秒,使用多个goroutine查找切片中是否存在给定值,在找到目标值或者超时后立刻结束所有goroutine的执行。



比如切片为:[23, 32, 78, 43, 76, 65, 345, 762, …… 915, 86],查找的目标值为345,如果切片中存在目标值程序输出:”Found it!”并且立即取消仍在执行查找任务的goroutine。如果在超时时间未找到目标值程序输出:”Timeout! Not Found”,同时立即取消仍在执行查找任务的goroutine。



不用顾忌题目里切片的元素重不重复,也不需要对切片元素进行排序。解决这个问题肯定会用到context、计时器、通道以及select语句(已经提示了很多啦:),相当于把最近关于并发编程文章里的知识串一遍。

假设有一个超长的切片,切片的元素类型为int,切片中的元素为乱序排列。限时5秒,使用多个goroutine查找切片中是否存在给定值,在找到目标值或者超时后立刻结束所有goroutine的执行。
比如切片为:[23, 32, 78, 43, 76, 65, 345, 762, …… 915, 86],查找的目标值为345,如果切片中存在目标值程序输出:”Found it!”并且立即取消仍在执行查找任务的goroutine。如果在超时时间未找到目标值程序输出:”Timeout! Not Found”,同时立即取消仍在执行查找任务的goroutine。



首先题目里提到了在找到目标值或者超时后立刻结束所有goroutine的执行,完成这两个功能需要借助计时器、通道和context才行。我能想到的第一点就是要用context.WithCancel创建一个上下文对象传递给每个执行任务的goroutine,外部在满足条件后(找到目标值或者已超时)通过调用上下文的取消函数来通知所有goroutine停止工作。
func main() {
timer := time.NewTimer(time.Second * 5)
ctx, cancel := context.WithCancel(context.Background())
resultChan := make(chan bool)
……
select {
case <-timer.C:
fmt.Fprintln(os.Stderr, “Timeout! Not Found”)
cancel()
case <- resultChan:
fmt.Fprintf(os.Stdout, “Found it!\n”)
cancel()
}
}
复制代码执行任务的goroutine们如果找到目标值后需要通知外部等待任务执行的主goroutine,这个工作是典型的应用通道的场景,上面代码也已经看到了,我们创建了一个接收查找结果的通道,接下来要做的就是把它和上下文对象一起传递给执行任务的goroutine。
func SearchTarget(ctx context.Context, data []int, target int, resultChan chan bool) {
for _, v := range data {
select {
case <- ctx.Done():
fmt.Fprintf(os.Stdout, “Task cancelded! \n”)
return
default:
}
// 模拟一个耗时查找,这里只是比对值,真实开发中可以是其他操作
fmt.Fprintf(os.Stdout, “v: %d \n”, v)
time.Sleep(time.Millisecond * 1500)
if target == v {
resultChan <- true
return
}
}



}
复制代码在执行查找任务的goroutine里接收上下文的取消信号,为了不阻塞查找任务,我们使用了select语句加default的组合:
select {
case <- ctx.Done():
fmt.Fprintf(os.Stdout, “Task cancelded! \n”)
return
default:
}
复制代码在goroutine里面如果找到了目标值,则会通过发送一个true值给resultChan,让外面等待的主goroutine收到一个已经找到目标值的信号。



resultChan <- true
复制代码这样通过上下文的Done通道和resultChan通道,goroutine们就能相互通信了。



Go 语言中最常见的、也是经常被人提及的设计模式 — 不要通过共享内存的方式进行通信,而是应该通过通信的方式共享内存



完整的源代码如下:
package main



import (
“context”
“fmt”
“os”
“time”
)



func main() {
timer := time.NewTimer(time.Second * 5)
data := []int{1, 2, 3, 10, 999, 8, 345, 7, 98, 33, 66, 77, 88, 68, 96}
dataLen := len(data)
size := 3
target := 345
ctx, cancel := context.WithCancel(context.Background())
resultChan := make(chan bool)
for i := 0; i < dataLen; i += size {
end := i + size
if end >= dataLen {
end = dataLen - 1
}
go SearchTarget(ctx, data[i:end], target, resultChan)
}
select {
case <-timer.C:
fmt.Fprintln(os.Stderr, “Timeout! Not Found”)
cancel()
case <- resultChan:
fmt.Fprintf(os.Stdout, “Found it!\n”)
cancel()
}



time.Sleep(time.Second * 2) }


func SearchTarget(ctx context.Context, data []int, target int, resultChan chan bool) {
for _, v := range data {
select {
case <- ctx.Done():
fmt.Fprintf(os.Stdout, “Task cancelded! \n”)
return
default:
}
// 模拟一个耗时查找,这里只是比对值,真实开发中可以是其他操作
fmt.Fprintf(os.Stdout, “v: %d \n”, v)
time.Sleep(time.Millisecond * 1500)
if target == v {
resultChan <- true
return
}
}



}
复制代码为了打印演示结果所以加了几处time.Sleep,这个程序更多的是提供思路框架,所以细节的地方没有考虑。有几位读者把他们的答案发给了我,其中有一位的提供的答案在代码实现上考虑的更全面,这个我们放到文末再说。
上面程序的执行结果如下:
v: 1
v: 88
v: 33
v: 10
v: 345
Found it!
v: 2
v: 999
Task cancelded!
v: 68
Task cancelded!
Task cancelded!



复制代码因为是并发程序所以每次打印的结果的顺序是不一样的,这个你们可以自己试验一下。而且也并不是先开启的goroutine就一定会先执行,主要还是看调度器先调度哪个。
Go语言调度器
所有应用程序都是运行在操作系统上,真正用来干活(计算)的是CPU。所以谈到Go语言调度器,我们也绕不开操作系统、进程与线程这些概念。线程是操作系统调度时的最基本单元,而 Linux 在调度器并不区分进程和线程的调度,它们在不同操作系统上也有不同的实现,但是在大多数的实现中线程都属于进程。
多个线程可以属于同一个进程并共享内存空间。因为多线程不需要创建新的虚拟内存空间,所以它们也不需要内存管理单元处理上下文的切换,线程之间的通信也正是基于共享的内存进行的,与重量级的进程相比,线程显得比较轻量。
虽然线程比较轻量,但是在调度时也有比较大的额外开销。每个线程会都占用 1 兆以上的内存空间,在对线程进行切换时不止会消耗较多的内存,恢复寄存器中的内容还需要向操作系统申请或者销毁对应的资源。
大量的线程出现了新的问题



高内存占用
调度的CPU高消耗



然后工程师们就发现,其实一个线程分为”内核态”线程和”用户态”线程。
一个用户态线程必须要绑定一个内核态线程,但是CPU并不知道有用户态线程的存在,它只知道它运行的是一个内核态线程(Linux的PCB进程控制块)。这样,我们再去细化分类,内核线程依然叫线程(thread),用户线程叫协程(co-routine)。既然一个协程可以绑定一个线程,那么也可以通过实现协程调度器把多个协程与一个或者多个线程进行绑定。
Go语言的goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。
Go中,协程被称为goroutine,它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内存,runtime会自动为goroutine分配。
既然我们知道了goroutine和系统线程的关系,那么最关键的一点就是实现协程调度器了。
Go目前使用的调度器是2012年重新设计的,因为之前的调度器性能存在问题,所以使用4年就被废弃了。重新设计的调度器使用G-M-P模型并一直沿用至今。



G — 表示 goroutine,它是一个待执行的任务;
M — 表示操作系统的线程,它由操作系统的调度器调度和管理;
P — 表示处理器,它可以被看做运行在线程上的本地调度器;



G
gorotuine 就是Go语言调度器中待执行的任务,它在运行时调度器中的地位与线程在操作系统中差不多,但是它占用了更小的内存空间,也降低了上下文切换的开销。
goroutine只存在于Go语言的运行时,它是Go语言在用户态提供的线程,作为一种粒度更细的资源调度单元,如果使用得当能够在高并发的场景下更高效地利用机器的CPU。
M
Go语言并发模型中的M是操作系统线程。调度器最多可以创建 10000 个线程,但是其中大多数的线程都不会执行用户代码(可能陷入系统调用),最多只会有 GOMAXPROCS 个活跃线程能够正常运行。
在默认情况下,运行时会将 GOMAXPROCS 设置成当前机器的核数,我们也可以使用 runtime.GOMAXPROCS 来改变程序中最大的线程数。一个四核机器上会创建四个活跃的操作系统线程,每一个线程都对应一个运行时中的 runtime.m 结构体。
在大多数情况下,我们都会使用Go的默认设置,也就是活跃线程数等于CPU个数,在这种情况下不会触发操作系统的线程调度和上下文切换,所有的调度都会发生在用户态,由Go语言调度器触发,能够减少非常多的额外开销。
操作系统线程在Go语言中会使用私有结构体 runtime.m 来表示
type m struct {
g0 *g
curg *g

}
复制代码其中g0是持有调度栈的goroutine,curg 是在当前线程上运行的用户goroutine,用户goroutine执行完后,线程切换回g0上,g0会从线程M绑定的P上的等待队列中获取goroutine交给线程。
P
调度器中的处理器P是线程和goroutine 的中间层,它能提供线程需要的上下文环境,也会负责调度线程上的等待队列,通过处理器P的调度,每一个内核线程都能够执行多个 goroutine,它能在goroutine 进行一些 I/O 操作时及时切换,提高线程的利用率。因为调度器在启动时就会创建 GOMAXPROCS 个处理器,所以Go语言程序的处理器数量一定会等于 GOMAXPROCS,这些处理器会绑定到不同的内核线程上并利用线程的计算资源运行goroutine。
此外在调度器里还有一个全局等待队列,当所有P本地的等待队列被占满后,新创建的goroutine会进入全局等待队列。P的本地队列为空后,M也会从全局队列中拿一批待执行的goroutine放到P本地的等待队列中。
GMP模型图示



全局队列:存放等待运行的G。



P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G时,G优先加入到P的本地队列,如果队列已满,则会把本地队列中一半的G移动到全局队列。



P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。



M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。



goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU上执行。



调度器的策略
调度器的一个策略是尽可能的复用现有的活跃线程,通过以下两个机制提高线程的复用:



work stealing机制,当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。
hand off机制,当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。



Go的运行时并不具备操作系统内核级的硬件中断能力,基于工作窃取的调度器实现,本质上属于先来先服务的协作式调度,为了解决响应时间可能较高的问题,目前运行时实现了协作式调度和抢占式调度两种不同的调度策略,保证在大部分情况下,不同的 G 能够获得均匀的CPU时间片。
协作式调度依靠被调度方主动弃权,系统监控到一个goroutine运行超过10ms会通过 runtime.Gosched 调用主动让出执行机会。抢占式调度则依靠调度器强制将被调度方被动中断。
推荐其他博主的一篇文章Golang调度器GMP原理与调度全分析,里面用几十张图详细展示了全场景的调度策略解析,让我们更容易理解调度器的GMP模型和它的工作原理。
如果想从Go的源码层面了解调度器的实现,可以看看下面链接这个博主的系列文章。
changkun.de/golang/zh-c…
回到文章第一部分说的并发题的解决方案,读者@CDS给出了一个更通用的实现版本,把goroutine相互通信这部分逻辑抽象了出来,遇到与思考题相似的同类问题后只需要实现执行具体任务的worker即可。由于代码的复杂度以及占用的篇幅太长,不太适合放到文章里解释题目的解题思路,在征得他同意后我把他的实现方案的GitHub仓库链接放到了公众号文章的阅读原文里了,感兴趣的可以克隆下来看看。
https://changkun.de/golang/zh-cn/part2runtime/ch06sched/



https://github.com/rigglo/gql


Category golang