Go语言并发编程
Table of Contents
Go 语言并发编程相关知识点:锁、channel、goroutine 以及 context
快速上手
在其他编程语言中并发编程大多使用多线程、多进程编程,比如 python, java, php。多线程和多进程存在的问题主要是耗费内存,线程切换成本高。
随着 web 技术的不断发展,所需要的并发要求更高,因此出现了用户级线程、绿程、轻量级线程、协程。python 中的 syncio,php 的 swoole,java 的 netty。协程内存占用小、切换快,所以后续都使用协程来代替线程进程操作。
而 Go 语言则使用的是更加简单的 Goroutine 机制。Go 语言是没有线程的,因为 Go 语言的诞生时间较晚,协程已经广泛应用在并发编程中,所以它不再设置线程进程等旧技术。同时 Go 语言使用协程进行并发编程是非常方便的。
Go 语言中的任何函数,只要在调用之前使用 go
关键字就可以把这个函数使用协程执行,非常方便。
需要注意的是,Go 语言中的协程也是一样的,如果主进程 main
结束了,它其中的所有协程都会结束,因此需要保证主进程 main
不退出。
//主协程
func main() {
//主死随从
//匿名函数启动goroutine
for i := 0; i<100; i++ {
go func(i int) {
fmt.Println(i)
}(i) //值传递
}
fmt.Println("main goroutine")
time.Sleep(10*time.Second)
}
waitgroup
在并发编程中,子 goroutine 如何通知到主 goroutine 自己结束了?同时主 goroutine 如何知道子 goroutine 已经结束了?
Go 语言为了解决这个问题,提供了 sync. waitgroup 这个结构体功能。
import (
"fmt"
"sync"
)
func main(){
var wg sync.WaitGroup
//我要监控多少个goroutine执行结束
wg.Add(100)
for i := 0; i<100; i++ {
go func(i int) {
defer wg.Done()
fmt.Println(i)
}(i)
}
//等到
wg.Wait()
fmt.Println("all done")
//waitgroup主要用于goroutine的执行等待, Add方法要和Done方法配套
}
互斥锁
互斥锁就是为了解决共享资源(共享变量)相互竞争的问题。
var total int
var wg sync.WaitGroup
func add() {
defer wg.Done()
for i := 0; i<1000000; i++ {
total += 1 //竞争
}
}
func sub() {
defer wg.Done()
for i := 0; i<1000000; i++ {
total -= 1
}
}
func main() {
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}
运行以上示例程序,我们会发现输出的 total 值是在 -100000~100000
之间随机出现的,这是因为进行 total+=1
和 total-=1
这两个操作并不是原子的,并且两个协程的执行是完全随机的,有可能上个操作还没写入变量,另一个操作就读取了变量。
如果将 for 循环的次数改为 10,输出的 total 就为 0 了。这并不是因为这两个操作不竞争了,而是因为 10 次太少了,先进行的 add()
非常迅速就将 10 次加完了,速度块到 sub()
还没有开始执行,因此这两个操作没有互相影响。
要解决两个操作互相影响的问题,就需要让这两个加减操作不能同时进行,即需要加锁操作。
var total int
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
defer wg.Done()
for i := 0; i<1000000; i++ {
lock.Lock()
total += 1 //竞争
lock.UnLock()
}
}
func sub() {
defer wg.Done()
for i := 0; i<1000000; i++ {
lock.Lock()
total -= 1
lock.UnLock()
}
}
func main() {
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}
需要保证两个操作使用的是同一个锁。
本质上 sync.Mutex
是一个 struct 类型,是可以复制的,但是一旦复制了就失去了锁的效果。相当于创建了一个新的锁(新的变量),两个操作使用的就不是同一个锁了。
在上面这个例子中,我们是针对一个共享的全局变量进行两个加减操作,它们由于不是原子化的,因此会相互影响。为了避免两个加减操作同时操作共享变量,我们可以使用互斥锁,另外也可以使用 atomic
包的原子化函数,将加减操作变成原子化的形式。这样就可以不使用锁操作了。
import (
"fmt"
"sync"
"sync/atomic"
)
var total int32
var wg sync.WaitGroup
func add() {
defer wg.Done()
for i := 0; i<1000000; i++ {
atomic.AddInt32(&total, 1)
}
}
func sub() {
defer wg.Done()
for i := 0; i<1000000; i++ {
atomic.AddInt32(&total, -1)
}
}
func main() {
wg.Add(2)
go add()
go sub()
wg.Wait()
fmt.Println(total)
}
读写锁
锁本质上是将并行的代码串行化了,使用 lock 肯定会影响性能。因此在 Go 语言并发编程中,我们应该尽量减少使用锁,除非必须要使用。
即使是设计锁,那么也应该尽量的保证并行。串行是最后没有办法的方式。
常见的场景:我们有两组协程,其中一组负责写数据,另一个组负责读数据,web 系统中绝大部分场景都是读多写少。
虽然有多个 goroutine,但是仔细分析我们会发现,读协程之间应该并发,读和写之间应该串行,写和写之间也不应该并行。这就是读写锁的一些特性。
func main() {
var rwlock sync.RWMutex
var wg sync.WaitGroup
wg.Add(6)
//写的goroutine
go func() {
time.Sleep(time.Second*3)
defer wg.Done()
rwlock.Lock() //加写锁,写锁会防止别的写锁获取,和读锁获取
defer rwlock.Unlock()
fmt.Println("get write lock")
time.Sleep(time.Second*5)
}()
// 读的goroutine
for i:=0; i<5; i++ {
go func() {
defer wg.Done()
for {
rwlock.RLock() //加读锁, 读锁不会阻止别人的读
time.Sleep(500*time.Millisecond)
fmt.Println("get read lock")
rwlock.RUnlock()
}
}()
}
wg.Wait()
}
- 写锁
rwlock.Lock()
,会阻止别的写入和读取。 - 读锁
rwlock.RLock()
,不会阻止别的读取。
上面的示例程序模拟了读写之间的操作。一开始读取的协程不断执行读取操作,直到写入的协程拿到写锁,则读取操作就被阻塞,一直不能被执行,直到写锁被释放。
goroutine 互相通信
通过 channel 在不同的 goroutine 之间进行通信。
Go 语言编程的哲学:不要通过共享内存来通信,而要通过通信来实现内存共享。
php、python、java 等其他语言进行多线程编程时,两个线程之间通信最常用的方式就是使用一个全局变量。也会提供消息队列的机制,比如 python 中的 queue,最常见的模式就是消费者和生产者的模型。
Go 语言也是首选这种消息队列的模式,它是使用 channel 通道来实现这些功能。
Go 语言为了推广这种模式在 channel 的基础上,加上语法糖 <-
,让 channel 的使用更加简单。
func main() {
var msg chan string
//有缓冲和无缓冲的channel
msg = make(chan string, 0) //channel的初始化值 如果为0的话,你放值进去会阻塞
//msg = make(chan string, 0) //无缓冲的channel
//msg = make(chan string, 10) //有缓冲的channel
go func(msg chan string) {
data := <- msg
fmt.Println(data)
}(msg)
msg <- "bobby"//放值到channel中
// waitgroup 如果少了done调用,容易出现deadlock, 无缓冲的channel也容易出现
time.Sleep(time.Second*10)
}
无缓冲的 channel 必须使用一个 goroutine 来读取通道中的值,否则会死锁。goroutine 有一种 happen-before 的机制,可以保障无缓冲的通道中的读取数据和写入数据的先后顺序。
- 无缓冲 channel 适用于通知, B 要第一时间知道 A 是否已经完成。
- 有缓冲 channel 适用于消费者和生产者之间的通信。
Go 中 channel 的应用场景:
- 消息传递、消息过滤
- 信号广播
- 事件订阅和广播
- 任务分发
- 结果汇总
- 并发控制
- 同步和异步
channel 的遍历
如果生产者和消费者都不断地往通道 channel 中发送和接收数据,则需要使用循环来遍历这个 channel。
func main() {
var msg chan int
//又缓冲和无缓冲的channel
//channel的初始化值 如果为0的话,你放值进去会阻塞
msg = make(chan int, 2)
//go有一种happen-before的机制, 可以保障
go func(msg chan int) {
for data := range msg {
fmt.Println(data)
}
fmt.Println("all done")
}(msg)
msg <- 1//放值到channel中
msg <- 2//放值到channel中
close(msg) //其他的编程语言有很大的区别
d := <-msg //已经关闭的channel可以继续取值,但是不能再放值了
fmt.Println(d)
//msg <- 3//放值到channel中, 已经关闭的channel不能再放值了
time.Sleep(time.Second*10)
}
在以上的例子中,如果不使用,
for data := range msg {
fmt.Println(data)
}
打印输出,只是使用 d := <- msg
读出一个数据,则 msg <- 2
就会一直在阻塞中,不会被打印出来。
同理,如果只放入了一个数据 msg <- 1
,并且有两次读出 d := <-msg
,则第二次读出会被阻塞。
因此,直接使用 for range
方法是最方便的,正如例子中的写法一样。
在使用 for range
遍历通道时,可以通过 close(msg)
关闭通道来告诉循环,不需要再取值了,循环就可以退出了,打印出 all done
否则循环将阻塞等待下一个数据的到来。
这里的 close(msg)
关闭通道的用法是与其他语言有很大的区别。
已经关闭的 channel 可以继续取值,但是不能再放值了。即放值会报错,而取值不会报错。
单向 channel
默认情况下, channel 是双向的。但是,我们经常一个 channel 作为参数进行传递,希望对方是单向使用。
比如,我传递一个 channel 给对方,我只希望对象从 channel 中读取数据而不会写入数据,这时候就需要使用单向 channel 了。
单向 channel 的定义方式与默认的双向 channel 定义方式有所不同。
var ch1 chan int // 双向channel
var ch2 chan<- float64 // 单向channel,只能写入float64类型的数据
var ch3 <-chan int // 单向channel, 只能读取int类型的数据
当然还有一种方式,定义的是默认双向 channel ,但是可以转换成单向 channel。
c := make(chan int, 3) // 定义缓冲区为3的双向channel
var send chan<- int = c // 只能发送数据
var read <-chan int = c // 只能接收数据
send <- 1 // 发送数据1
<- read // 接收数据
值得注意的是,双向 channel 可以转成单向 channel,正如上述这种方式(自动完成转换)。但是单向 channel 不能转回双向 channel。
此外,单向 channel 只能执行单向操作,反向操作则会报错。
var send chan<- int = c // 只能发送数据
<- send // send不能接收数据,编译器会报错
下面是一个单向 channel 常用的应用场景。
func producer(out chan<- int) {
for i:=0; i<10; i++ {
out <- i*i;
}
close(out)
}
func consumer(in <-chan int) {
for num := range in {
fmt.Printf("num=%d\r\n", num)
}
}
func main() {
c := make(chan int)
go producer(c)
go consumer(c)
time.Sleep(10*time.Second) // 防止主函数先结束
}
在上述的例子中,定义了一个双向 channel c
,传入两个不同的函数 producer
和 consumer
中,系统自动将 c
转换成对应的单向 channel。
以上例子这样写法的优点,在于可以约束函数中的 channel 操作方式,在 producer 中就只能写而不能读,在 consumer 中就只能写而不能读,即使它们传递都是同一个双向通道 c
。
channel 实现交叉打印
这是一道常见的面试题。
题目如下所示。
使⽤两个 goroutine 交替打印序列,⼀个 goroutine 打印数字,另外⼀个 goroutine 打印字⺟,最终效果如下:
12AB34CD56EF78GH910IJ1112KL1314MN1516OP1718QR1920ST2122UV2324WX2526YZ2728
这道题目的难点在于,如何通知到两个 goroutine 对方已经结束,可以进行输出,来保证输出的交替顺序。
最好的方式就是通过 channel 来通知。
var number, letter = make(chan bool), make(chan bool)
func printNum() {
for i := 1; ; i += 2{
<- number
fmt.Printf("%d%d", i, i+1)
letter <- true
}
}
func printLetter() {
str := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
for i := 0; ; i += 2{
<- letter
if i >= len(str) {
return
}
fmt.Print(str[i:i+2])
number <- true
}
}
func main() {
go printNum()
go printLetter()
number <- true // 首先打印数字
time.Sleep(time.Second *100) // 防止主函数先退出
}
select 的用法
在并发编程中,select 常常用于监控 goroutine 执行。
select 语句类似于 switch case 语句,select 的功能和我们操作 linux 中网络 io 的 select、poll、epoll 类似,但是 select 更多地作用于多个 channel 中。
例如,现在有个需求,同时有两个 goroutine 都在执行,在主的 goroutine 中,我们如何立刻知道任何一个 goroutine 执行完成。
有一种非常简单的思路就是,通过一个全局的变量来标识子 goroutine 是否完成。但是这就需要主函数中有一个循环不断地检查这个全局变量是否改变,十分消耗电脑的资源。
var done bool
var lock sync.Mutex
func g1() {
time.Sleep(time.Second)
lock.Lock()
defer lock.Unlock()
done = true
}
func g2() {
time.Sleep(2 * time.Second)
lock.Lock()
defer lock.Unlock()
done = true
}
func main() {
go g1()
go g2()
for {
if done {
fmt.Println("done")
time.Sleep(10 * time.Millisecond)
return
}
}
}
以上使用共享变量的方式是不被推荐的,遇到类似的这种需求,我们应该使用 select 配合 channel 来实现。
思路:给两个 goroutine 分配一个共享 channel(Go 语言中的 channel 是多线程安全的,所以不用担心冲突),当 goroutine 完成时就往 channel 中写入一个数据,代表已完成。而主函数中则通过 select 语言来监控 channel 是否更新(被写入数据),如果写入了数据则说明这个 goroutine 已经完成了。
channel 的类型,常用的有 bool
类型,但是实际上我们也可以使用空结构体类型 struct{}
,这样将会更加省空间。
// var done chan struct{} // 未初始化
var done = make(chan struct{}) // channel一定要初始化
func g1() {
time.Sleep(time.Second)
done <- struct{}{}
}
func g2() {
time.Sleep(2 * time.Second)
done <- struct{}{}
}
func main() {
go g1()
go g2()
<- done // 阻塞接收
fmt.Println("done")
}
以上例子的需求是,只需要通知到有任何一个 goroutine 完成了就可以了。但是在实际情况中,我们可能需要分别区别两个 goroutine 是否完成。
这时候不能再使用一个全局的共享 channel,而是需要用到两个 channel 来分别标识两个 goroutine 的完成情况。
此时,就不用使用之前的 <- done
来同时阻塞接收两个不同的 channel,因此就需要使用 select 语句了,类似于网络 io 的轮询策略(select、poll、epoll)。
select 会先选择已经就绪的 channel(数据先传递过来),然后接着执行后续语句。
同时需要注意的点:
- 某一个分支就绪了就执行该分支
- 如果两个都就绪了,先执行哪个? 随机的。目的是什么: 防止饥饿(有一个 channel 一直不能被接收)
func g1(ch chan struct{}) {
time.Sleep(2*time.Second)
ch <- struct{}{} // 结构体实例
}
func g2(ch chan struct{}) {
time.Sleep(3*time.Second)
ch <- struct{}{}
}
func main() {
g1Channel := make(chan struct{}, 1)
g2Channel := make(chan struct{}, 2)
//g1Channel <- struct{}{}
//g2Channel <- struct{}{}
go g1(g1Channel)
go g2(g2Channel)
timer := time.NewTimer(5*time.Second)
for {
select {
case <- g1Channel:
fmt.Println("g1 done")
case <- g2Channel:
fmt.Println("g2 done")
case <- timer.C:
fmt.Println("timeout")
return
}
}
}
为了防止两个子 goroutine 同时阻塞,导致主函数也一直阻塞,select 语句也可以使用 default 分支,但是 default 分支会导致其他 case 不会被执行。
select {
case <- g1Channel:
fmt.Println("g1 done")
case <- g2Channel:
fmt.Println("g2 done")
default:
fmt.Println("default") // 程序只会输出default
}
一种的解决方式就是在 select 外再套一层 for 循环,使用 timer 的定时功能
deadline := time.Now().Add(5 * time.Second) // 设置超时时间
for {
select {
case <- g1Channel:
fmt.Println("g1 done")
case <- g2Channel:
fmt.Println("g2 done")
default:
if time.Now().After(deadline) { // 检查是否超时
fmt.Println("timeout")
return
}
time.Sleep(10 * time.Millisecond) // 防止CPU占用过高
}
}
一种更加简洁且常用的方式是使用 timer.C
,来设置超时退出。
timer := time.NewTimer(5 * time.Second) // 使用time.Timer实现超时控制
defer timer.Stop()
for {
select {
case <-g1Channel:
fmt.Println("g1 done")
case <-g2Channel:
fmt.Println("g2 done")
case <-timer.C: // 超时
fmt.Println("timeout")
return
}
}
context 的用法
context 是 Go 语言并发编程中最重要的一个知识点。它与现有其他编程语言有着十分明显的差异。
考虑下面这个需求:我们需要有一个 goroutine 来监控 CPU 的信息。
var wg sync.WaitGroup
func cpuInfo() {
defer wg.Done()
for {
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
func main() {
wg.Add(1)
go cpuInfo()
wg.Wait()
fmt.Println("监控完成")
}
以上代码可以完成一个及其简单的模拟获取 CPU 信息的程序。现在我们有一个新的需求:我可以主动退出监控程序。
这时候我们可以想到的最简单的方式就是使用共享变量(虽然并不推荐)。
var wg sync.WaitGroup
var stop bool
func cpuInfo() {
defer wg.Done()
for {
if stop {
break
}
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
func main() {
wg.Add(1)
go cpuInfo()
time.Sleep(6 * time.Second)
stop = true
wg.Wait()
fmt.Println("监控完成")
}
需要注意的是,在操作共享变量
stop
时最好加上读写锁。
然而,共享变量的方式是不被推荐的,最推荐的方法还是应该使用 channel。
var wg sync.WaitGroup
var stop make(chan struct{})
func cpuInfo() {
defer wg.Done()
for {
select {
case <- stop:
fmt.Println("退出CPU监控")
return
default:
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
}
func main() {
wg.Add(1)
go cpuInfo()
time.Sleep(6 * time.Second)
stop <- struct{}{}
wg.Wait()
fmt.Println("监控完成")
}
以上这种代码在实际的开发中是十分常见的。我们将 select 语句放入子 goruntine 中而不是上一节讲解的主函数中,上一节是通过 select 来监控子 goruntine 的完成情况,而这一部分代码则是在子 goroutine 中接收主函数传递的控制信息,根据控制信息来执行对应的操作。
由此可知,select 语言既可以写在主 goroutine 中,也可以写在子 goroutine 中,两者的作用是不一样的。说明 select 语句可以监控双向的 channel。
需要注意的是,如果将上述代码中的全局变量 stop 改为主函数中的变量,通过参数传递让子 goroutine 能够访问到,这种方式更加优雅。
如果想要让当前这个程序编写得更加优雅,更加符合实际的开发。这时候就需要使用到 context 了。
使用了 context 就不需要将控制信息通过 channel 进行传递了,而是使用 context 中自带的监控和控制方法。
context 包提供了三种函数:WithCancel
, WithTimeout
,WithValue
。本质上就是返回三个不同的结构体。
如果在你的 goroutine / 函数中,希望被控制,超时、传值,但不希望影响原来的接口信息时,函数参数中第一个参数就尽量的要加上一个ctx。
比如,我希望 6 秒后通过主函数发送一个取消指令,让子 goroutine 取消监控 CPU,则可以使用 WithCancel
var wg sync.WaitGroup
func cpuInfo(ctx context.Context) {
defer wg.Done()
for {
select {
case <- ctx.Done():
fmt.Println("退出cpu监控")
return
default:
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
}
func main() {
wg.Add(1)
// 1. 主动取消
ctx, cancel := context.WithCancel(context.Background())
go cpuInfo(ctx)
time.Sleep(6 * time.Second)
cancel()
wg.Wait()
fmt.Println("监控完成")
}
context 是一个树形结构,context.Background()
就是根节点的 context。我们传递子 context 也依旧可以满足需求。
var wg sync.WaitGroup
func cpuInfo(ctx context.Context) {
defer wg.Done()
for {
select {
case <- ctx.Done():
fmt.Println("退出cpu监控")
return
default:
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
}
func main() {
wg.Add(1)
// 1. WithCancel 主动取消
ctx1, cancel1 := context.WithCancel(context.Background())
ctx2, _ := context.WithCancel(ctx1)
go cpuInfo(ctx2)
time.Sleep(6 * time.Second)
cancel1()
wg.Wait()
fmt.Println("监控完成")
}
在上述这个代码中,我们通过参数传递返回了一个子的 context ctx2
,将 ctx2
传递给子 goroutine 依旧可以成功运行。这是因为 context 是一个树形结构,当我们调用了根的 cancel1()
,它会逐层将子的 context 的 cancel
方法一起调用,因此传递 ctx2
也可以起作用。
context 还其他几个用法,如下面的例子所示,可以通过 WithTimeout
方法来代替手动写的 time.Sleep(6 * time.Second)
。也可以使用 WithDeadline
方法来指定在某个时间取消。
var wg sync.WaitGroup
func cpuInfo(ctx context.Context) {
defer wg.Done()
for {
select {
case <- ctx.Done():
fmt.Println("退出cpu监控")
return
default:
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
}
func main() {
wg.Add(1)
//2. WithTimeout 主动超时取消
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
//3. WithDeadline 在时间点取消
//ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(6*time.Second))
defer cancel()
go cpuInfo(ctx)
wg.Wait()
fmt.Println("监控完成")
}
下面的例子展示的是,通过 WithValue
方法来代替 channel 向 goroutine 中传递数据。
var wg sync.WaitGroup
type ctxKey string // 自定义一个string类型 防止上下文冲突
func cpuInfo(ctx context.Context) {
// 我们希望在这里能拿到一个请求的id
fmt.Printf("tracid: %s\r\n", ctx.Value(ctxKey("traceid")))
// 常用于 记录一些日志 这次请求是哪个traceid打印的 方便调试
defer wg.Done()
for {
select {
case <- ctx.Done():
fmt.Println("退出cpu监控")
return
default:
time.Sleep(2*time.Second)
fmt.Println("cpu的信息")
}
}
}
func main() {
wg.Add(1)
//2. WithTimeout 主动超时取消
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
//4. WithValue 传递数据
valueCtx := context.WithValue(ctx, ctxKey("traceid"), "gjw12j")
go cpuInfo(valueCtx)
wg.Wait()
fmt.Println("监控完成")
}
这里推荐自己定义一个自定义类型作为键值(Key),如
type ctxKey string
。这是因为在 Go 中,
context.WithValue
的键(key)通常是一个接口类型,但为了确保键的唯一性,建议使用自定义类型(通常是结构体类型)。如果直接使用内置类型(如string
),可能会导致不同代码片段之间使用相同的键值对,从而引发冲突。例如,如果多个开发者在不同的地方都使用了
"id"
作为键,那么上下文中的值可能会被意外覆盖,导致不可预测的行为。
在上述这个代码中,我们通过 WithValue
避免了通过函数参数或者 channel 来向函数中传递数据,如果使用函数参数来传递数据,则会使整个程序代码不好维护和阅读。
并且将 ctx 作为 WithValue
参数传递进去,使得 valueCtx 是 ctx 的子 context,这样就可以同时完成两种操作(超时取消和传递数据)。
valueCtx 会集成 ctx 的超时功能,同时携带所需要的数据,而 ctx 只带有超时取消的功能,因此只能是
go cpuInfo(valueCtx)
,否则使用 ctx 将无法获取到传递的数据。这不同于根 cancel 方法会触发子 cancel 方法一同执行。
通过以上几个示例代码,我们可以很清楚地了解到 context 的优点:我们几乎没有对业务代码进行较大的改动,只是通过更换 context 的不同方法,就可以实现不同的功能。
GMP 调度原理
尽量减少了全局锁带来的时间成本,利用排队和挂起机制,当有一个耗时 goroutine 时则将其挂起,把处理器和线程之间解除绑定,让线程可以空出来去执行其他 goroutine 操作,让处理器继续跟进它的后续执行流程。
GMP 的调度避免了线程之间的频繁切换,造成巨大的切换成本。它转而使用处理器来灵活处理 goroutine 和处理器之前的逻辑,提高了协程执行的速度。
GMP 是一个非常复杂的调用原理,这里只是进行简单的入门介绍,后续在其他笔记中会进行详细的说明解释和学习。
MPG 并发模型
说到并发,初学者对于这个概念经常和并行混淆。其实我们可以从字面意思很容易的理解他们。并发,就是同时发生,而并行,就是同时进行。
以咖啡店为例,两人排两队同时点咖啡,而接到订单一个咖啡师做两杯咖啡,这是并发。两个咖啡师同时工作,每人做一杯咖啡,这是并行。同理回到程序中,多线程程序在单核上运行,就是并发;多线程程序在多核上运行,就是并行。
在操作系统提供的内核线程之上,Go 搭建了一个特有的两级线程模型。Go 的独立控制流不是内核级线程而是 goroutine 协程。Go 不推荐用共享内存的方式传递数据,而推荐使用 channel(或称“通道”)。channel 主要用来在多个 goroutine 之间传递数据,并且还会保证整个过程的并发安全性。
Go 语言的线程模型,也叫做 MPG 模型。它有 3 个核心元素,它们共同支撑起了这个模型的主框架。
- M:machine的缩写。一个 M 代表一个内核线程,或称“工作线程”。
- P:processor的缩写。一个 P 代表执行一个 goroutine 和 Go 代码片段(函数)所必需的资源(或称“上下文环境”)。
- G:goroutine的缩写。一个 G 代表一个用户级线程,它由 go 程序调度而不由内核调度。
首先一个内核级线程M会与一个或多个上下文环境P关联,每个 P都会包含一个可运行的G的队列(runq),因此一个 P下会有多个 G排队运行。该队列中的 G 会被依次传递给与本地 P 关联的 M,并获得运行时机。
把运行当前 G 的那个 M 称为“当前 M”,并把与当前 M 关联的那个 P 称为“本地 P”。因此一个 M 会包含和管理多个 G。
M、P 和 G 之间的联系如图所示。
M 与 KSE 之间总是一对一的关系,一个 M 能且仅能代表一个内核线程。Go 的运行时系统(runtime system)用 M 代表一个内核调度实体。M 与 KSE 之间的关联非常稳固,一个 M 在其生命周期内,会且仅会与一个 KSE 产生关联。
M 与 P 之间也总是一对一的(在运行过程中 M 可能会从本来的关联 P 1 变为关联 P 2,但同一时刻一个 M 总是只关联一个 P),而 P 与 G 之间则是一对多的关系(这些 G 被放在了 P 的一个队列中)。所以 M 和 G 是一对多关系,但是同一时刻一个 M 只能运行一个 G。M、P 和 G 之间的关系在实际调度过程中是多变的。
Go 如何控制 M 对 G 的调度是由 Go 的运行时系统(runtime system)决定的。运行时系统就是 goroutine 的调度器 (GMP 调度原理),它的代码在 go 的 runtime 包中,充当着类似内核的作用。
对于一个运行的 GO 程序,在调度器内部存在着:
- 全局 M,P,G 列表,分别用于记录当前所有的 M,P,G 信息;
- 空闲 M,P,G 列表,分别用于记录当前空闲可调度的 M,P,G 信息,以减少 M,P,G 的创建从而提升性能;
- 可运行 G 列表,存放等待调度器分配给 P 的 G。
MPG 调度器只关注单独的 Go 程序中的 Goroutine。Go 语言的 Goroutine 采用的是半抢占式的协作调度,只有在 G0
发生阻塞时才会导致调度,否则会依次执行 P 绑定的其他 G。
当 G0
阻塞时,调度器会将 P 与当前的 M0
和 G0
解绑,同时去空闲 M 列表中找新的 M,如果没有则创建 M1
,绑定 P,顺序执行P下的G。
当 G0
阻塞结束后,调度器会到空闲 P 列表中为 M0
找空闲可绑定的 P,如果恰巧有 P,则继续执行 G0
。
如果没有可用的 P,则如下图所示:(注意:这个过程中,M1
和 M0
可能并行)
M0
被放入空闲列表,等待调度给需要的 G;G0
被放入可运行的 G 列表,列表中的 G 会经由调度再次放入某个 P 的可运行 G 队列(等待再次运行)。
至此一个简单的 MPG 流程就完成了。实际程序运行在多核下,同时会创建更多的协程,但是 MPG 的调度流程是一样的。
并发安全性
Go 语言的并发安全性,依旧是通过锁机制和原子操作来实现的。正如笔记前半部分中的 waitgroup、互斥锁、读写锁以及 sync 包和 atomic 包的用法,在并发编程中十分常见,目的就是保证并发的安全性。
虽然在代码中加入锁可能会影响性能,但是实际上影响并没有想象中的那么严重。切不可以一味追求程序运行性能而舍弃了并发的安全性,该加锁时还是应该加锁。当然能够通过其他方式优化,避免锁的使用,那是再好不过了。