Go 协程

一、概念:

协程术语”0coroutine”最早出现在1963年美国计算机科学家Melvin E. Conway(马尔文·爱德华·康威)发表的论文中。

也许读者听说过著名的康威定律“设计系统的架构受制于产生这些设计的组织的沟通结构”。即系统设计本质上反映了企业的组织机构,系统各个模块间的接口也反映了企业各个部门之间的信息流动和合作方式。

支持协程的编程语言有很多,比如Python、Perl等,但没有哪个语言能像Go一样把协程支持得如此优雅,Go在语言层面直接提供对协程的支持称为goroutine。

二、相关概念:

1)进程:

进程是应用程序的启动实例,每个进程都有独立的内存空间,不同进程通过进程间的通信方式来通信。

2)线程:

线程从属于进程,每个进程至少包含一个线程,线程是CPU调度的基本单位,多个线程之间可以共享进程的资源并通过共享内存等线程间的通信方式来通信。

3)协程:

协程可理解为一种轻量级线程,与线程相比,协程不受操作系统调度,协程调度器由用户应用程序提供,协程调度器按照调度策略把协程调度到线程中运行。

Go应用程序的协程调度器由runtime包提供,用户使用go关键字即可创建协程,这也就是在语言层面直接支持协程的含义。

三、协程的优势:

我们知道,在高并发应用中频繁创建线程会造成不必要的开销,所以有了线程池技术。在线程池中预先保存一定数量的线程,新任务将不再以创建线程的方式去执行,而是将任务发布到任务队列中,线程池中的线程不断地从任务队列中取出任务并执行,这样可以有效地减少线程的创建和销毁所带来的开销。

四、GMP模型

Robert Griesemer、Rob Pike、Ken Thompson三位Go语言创始人,对新语言商在讨论时,就决定了要让Go语言成为面向未来的语言。当时多核CPU已经开始普及,但是众多“古老”编程语言却不能很好的适应新的硬件进步,Go语言诞生之初就为多核CPU并行而设计。

Go语言协程中,非常重要的就是协程调度器scheduler和网络轮询器netpoller。

Go协程调度中,有三个重要角色:

  • M:Machine Thread,对系统线程抽象、封装。所有代码最终都要在系统线程上运行,协程最终也是代码,也不例外

  • G:Goroutine,Go协程。存储了协程的执行栈信息、状态和任务函数等。初始栈大小约为2~4k,理论上开启百万个Goroutine不是问题

  • P:Go1.1版本引入,Processor,虚拟处理器

    • 可以通过环境变量GOMAXPROCS或runtime.GOMAXPROCS()设置,默认为CPU核心数

    • P的数量决定着最大可并行的G的数量

    • P有自己的队列(长度256),里面放着待执行的G

    • M和P需要绑定在一起,这样P队列中的G才能真正在线程上执行

图片1

1、使用go func创建一个Goroutine g1

2、当前P为p1,将g1加入当前P的本地队列LRQ(Local Run Queue)。如果LRQ满了,就加入到GRQ(Global Run Queue)

3、p1和m1绑定,m1先尝试从p1的LRQ中请求G。如果没有,就从GRQ中请求G。如果还没有,就随机从别的P的LRQ中偷(work stealing)一部分G到本地LRQ中。

4、假设m1最终拿到了g1

5、执行,让g1的代码在m1线程上运行

5.1、g1正常执行完了(函数调用完成了),g1和m1解绑,执行第3步的获取下一个可执行的g

5.2、g1中代码主动让出控制权,g1和m1解绑,将g1加入到GRQ中,执行第3步的获取下一个可执行的g

5.3、g1中进行channel、互斥锁等操作进入阻塞态,g1和m1解绑,执行第3步的获取下一个可执行的g。如果阻塞态的g1被其他协程g唤醒后,就尝试加入到唤醒者的LRQ中,如果LRQ满了,就连同g和LRQ中一半转移到GRQ中。

5.4、系统调用

5.4.1 同步系统调用时,执行如下:

如果遇到了同步阻塞系统调用,g1阻塞,m1也被阻塞了,m1和p1解绑。

从休眠线程队列中获取一个空闲线程,和p1绑定,并从p1队列中获取下一个可执行的g来执行;如果休眠队列中无空闲线程,就创建一个线程提供给p1。

如果m1阻塞结束,需要和一个空闲的p绑定,优先和原来的p1绑定。如果没有空闲的P,g1会放到GRQ中,m1加入到休眠线程队列中。

5.4.2 异步网络IO调用时,如下:

图片2

网络IO代码会被Go在底层变成非阻塞IO,这样就可以使用IO多路复用了。

m1执行g1,执行过程中发生了非阻塞IO调用(读/写)时,g1和m1解绑,g1会被网络轮询器Netpoller接手。m1再从p1的LRQ中获取下一个Goroutine g2执行。注意,m1和p1不解绑。

g1等待的IO就绪后,g1从网络轮询器移回P的LRQ(本地运行队列)或全局GRQ中,重新进入可执行状态。

就大致相当于网络轮询器Netpoller内部就是使用了IO多路复用和非阻塞IO,类似我们课件代码中的select的循环。GO对不同操作系统MAC(kqueue)、Linux(epoll)、Windows(iocp)提供了支持。

问题:如果GOMAXPROCS为1,说明什么?

五、Goroutine

5.1 协程创建

使用go关键字就可以把一个函数定义为一个协程,非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package main

import (
"fmt"
"runtime"
"time"
)

func add(x, y int) int {
var c int
defer fmt.Printf("1 return %d\n", c) // 打印的c是什么?
defer func() { fmt.Printf("2 return %d\n", c) }() // 打印的c是什么?
fmt.Printf("add called: x=%d, y=%d\n", x, y)
c = x + y
return c
}

func main() {
// fmt.Println("main start")
// add(4, 5)
// fmt.Println("main end")
fmt.Println(runtime.NumGoroutine())
fmt.Println("main start")
go add(4, 5)
fmt.Println(runtime.NumGoroutine())
// time.Sleep(2 * time.Second)
fmt.Println("main end")
fmt.Println(runtime.NumGoroutine())
}

如果没有 time.Sleep(2 * time.Second) ,结果如下

1
2
3
4
5
1
main start
2
main end
2

放开了 ,结果如下

1
2
3
4
5
6
7
8
1
main start
2
add called: x=4, y=5
2 return 9
1 return 0
main end
1

为什么?

因为会启动协程来运行add,那么go add(4, 5)这一句没有必要等到函数返回才结束,所以程序执行下一行打印Main Exit。这时main函数无事可做,Go程序启动时也创建了一个协程,main函数运行其中,可以称为main goroutine(主协程)。但是主协程一旦执行结束,则进程结束,根本不会等待未执行完的其它协程。

那么,除了像time.Sleep(2 * time.Second)这样一直等,如何才能让主线程优雅等待协程执行结束呢?等待组

5.2 等待组

使用参考 https://pkg.go.dev/sync#WaitGroup

使用等待组修改上例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"runtime"
"sync"
)

func add(x, y int, wg *sync.WaitGroup) int {
defer wg.Done() // add执行完后计数器减1
var c int
defer fmt.Printf("1 return %d\n", c) // 打印的c是什么?
defer func() { fmt.Printf("2 return %d\n", c) }() // 打印的c是什么?
fmt.Printf("add called: x=%d, y=%d\n", x, y)
c = x + y
fmt.Printf("add called: c=%d\n", c)
return c
}
func main() {
var wg sync.WaitGroup // 定义等待组
fmt.Println(runtime.NumGoroutine())
fmt.Println("main start")
wg.Add(1) // 计数加1
go add(4, 5, &wg) // 协程
fmt.Println(runtime.NumGoroutine())
// time.Sleep(2 * time.Second) // 这一句不需要了
wg.Wait() // 阻塞到wg的计数为0
fmt.Println("main end")
fmt.Println(runtime.NumGoroutine())
}

执行结果如下

1
2
3
4
5
6
7
8
9
1
main start
2
add called: x=4, y=5
add called: c=9
2 return 9
1 return 0
main end
1

5.3 父子协程

一个协程A中创建了另外一个协程B,A称作父协程,B称为子协程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fmt"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup // 定义等待组
fmt.Println("main start")
count := 6
wg.Add(count)
go func() {
fmt.Println("父协程开始,准备启动子协程")
defer func() {
wg.Done() // 注意wg的作用域
fmt.Println("父协程结束了~~~~")
}()
for i := 0; i < count-1; i++ {
go func(id int) {
defer wg.Done()
fmt.Printf("子协程 %d 运行中\n", id)
time.Sleep(5 * time.Second)
fmt.Printf("子协程 %d 结束\n", id)
}(i)
}
}()
wg.Wait() // 阻塞到wg的计数为0
fmt.Println("main end")
}
// 注:上例协程最好写成独立的函数,而不是这样嵌套,只是为了演示。

执行结果如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
main start
父协程开始,准备启动子协程
父协程结束了~~~~
子协程 1 运行中
子协程 0 运行中
子协程 2 运行中
子协程 3 运行中
子协程 4 运行中
子协程 4 结束
子协程 2 结束
子协程 1 结束
子协程 3 结束
子协程 0 结束
main end

父协程结束执行,子协程不会有任何影响。当然子协程结束执行,也不会对父协程有什么影响。父子协程没有什么特别的依赖关系,各自独立运行。

只有主协程特殊,它结束程序结束。

-------------本文结束感谢您的阅读-------------