Golang并发编程深入分析
Mingvvv 人气:0Go 协程和普通线程对比
Go 拥有极强的并发编程能力,而 Go 并发编程强势原因,一部分原因是因为语法简单 ,还有一个更核心的原因是 Go 中协程 goroutine (用户态线程)的存在。
在 Go 中 goroutine 是比普通线程(内核态线程)更加轻量化的存在。
内核级线程(线程)
内核态线程简称线程,是在内核中维护了线程表进行跟踪监控的线程。是 CPU 调度和分派的基本单位,是具备进程某些属性,能够独立运行的更小单位,所以也称为轻量级进程。
如果使用内核态线程在一个 CPU 上实现任务的并发,那么 CPU 会通过分配时间片的方式去执行任务,当正在运行的任务所分配的时间片用完了,就会切换到另一个任务,而在切换的之前会保存当前任务的状态(CPU寄存器、程序计数器中的内容),当下次再次切换到这个任务之前就会先加载之前保存到任务状态,这个过程称作上下文切换。
而在上下文切换的过程该线程会从用户态转为内核态,加载时再由内核态转为用户态。而用户态和内核态切换的代价是很高的,从用户态到内核态的切换比较耗费资源的,而且内核资源是很珍贵的,所以内核态线程过多时,可能会出现内核资源耗尽的情况。
线程优点
- 多核 CPU 利用 :内核具有0级权限,因此可以在多个 CPU 上执行内核线程
- 操作系统级优化:线程之间是相对独立的,一个线程阻塞不会影响其他线程的执行。内核态线程在进行你 IO 操作时不需要进行系统调用 。
线程缺点
- 创建的时候需要切换到内核态,所以创建成本比较高。
- 切换的时候,需要用户态和内核态之间频繁的切换,切换成本较高。
- 线程数量有限制,当内核态线程过多时,会出现内核资源耗尽的情况。
用户级线程(协程)
用户态线程简称协程,是完全由用户控制的线程,内核并不会感知到它的存在,用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。因为是存在在用户空间上的线程,所以在切换时不存在用户态和内核态的转换。
协程优点
- 不由内核管理,由用户自主管理,创建成本比较低。
- 不存在内核态和用户态的切换,切换成本比较低。
- 不需要操作系统去调度,所以可以跨操作系统,并且更加灵活、更容易控制。
协程缺点
- 因为操作系统的内核看不到协程,所以同属于一个进程的协程只能占有一个核,不能发挥多核优势。
- 操作系统不能主动调度协程,所以后面的协程只能等待前面的写成执行完才能获取到 CPU 资源。
- 同上,当前协程阻塞会导致后面的写成无法执行。内核协作成本高,当要进行一些高权限的操作,比如读写文件时,需要频繁地进行用户态和内核态的切换。
调度器(GPM)
GPM 是 Go 语言运行时系统的重要组成部分
其中 G 代表 goroutine 即协程,M 代表 machine 即系统线程、P 代表 Proccessor 代表写成和线程之间的联系
简单点理解就是:
G 是要被执行的任务实例
M 是实际的执行载体,需要绑定 P 成为一个执行单元才能调度 G
P 可以看作是任务处理器,P 中保存 M 执行 G 时的一些资源,P 决定了哪个 G 能配分配到哪个 M 上
G\P\M 之间的工作关系
G 要调度到 M 上才能运行,M 需要关联 P 才可以执行 Go 代码,但当处理阻塞或系统调用中时,M 可以不用关联 P
当进程启动时,会创建若干个(一般是内核数量)系统线程 M 和任务处理器 P,并一一绑定,因为一个线程对应一个 CPU 就不会出现上下文的切切换,能更大限度的节省资源。同时每一个任务处理器 P 都会对应一个私有的任务队列,但是私有队列有上限的,所以所有任务处理器还会有一个公用的全局队列。
当有一个 G 被创建时,就会被放到一个任务处理器 P 的私有队列中等待被调度。如果所有的私有队列都满了,就会被放到全局队列中去。当 P 寻找 G 的时候,会先从自己的私有队列中寻找,如果没找到,再去全局队列中寻找,如果还是没有,它会去抢别的 P 中的 G。当任何地方都不能找到需要调度的 G 时,M 和 P 则会断开连接。
当 G 中进行了系统调用,则 M 也会进入系统调用状态,此时 P 会去寻找其他未在工作的 M 并为其寻找需要调度的任务。当 G 完成了系统调用,会进入空闲的私有队列或者全局队列,然后等待再次被调度。
所以,G、M 之间没有直接的联系,一个 G 可能被不同的 M 调度,一个 M 也可以调度不同的 G。
Go 使用协程
创建协程
通过关键字 go 创建一个协程
一:普通函数创建
func goroutineTest(i int) { fmt.Println(i) } func main() { for i := 0; i < 10; i++ { go goroutineTest(i) } time.Sleep(time.Millisecond * 500) }
二:匿名函数创建
func main() { for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() } time.Sleep(time.Millisecond * 500) }
三:匿名带参数函数创建
func main() { for i := 0; i < 10; i++ { go func(str string) { fmt.Println(str) }(fmt.Sprintf("%s%d", "s", i)) } time.Sleep(time.Millisecond * 500) }
注意
一:主协程不会等待子协程执行完毕
以下列代码为例,一般来说控制台不会打印任何东西。原因是当代码运行到 go goroutineTest(i) 这一行时,并不是真正的会运行它,而是将它放到了任务队列中。所以可能子协程还没开始执行,主协程就已经退出了。所以我们可以再主协程加上等待。
func goroutineTest(i int) { fmt.Println(i) } func main() { for i := 0; i < 10; i++ { go goroutineTest(i) } time.Sleep(time.Millisecond * 500) }
二:协程的执行没有顺序
同上,当写成创建是,并不是立即执行,而是被分到不同的队列中等待被调度,而调度是不能保证先后顺序的(只能保证同一个队列中的任务先进先出),因此上述代码打印出来的结果大部分情况下时乱序的。
三:无参匿名函数中的变量变化
func main() { for i := 0; i < 3; i++ { go func() { fmt.Println(i) }() } time.Sleep(time.Millisecond * 500) }
这段代码的打印结果如下:
2
2
3
因为匿名函数不携带参数信息,只有匿名方法执行到 fmt.Println(i) 这一行时才会去读取 i 的值。又因为协程执行是有延迟的,所以当执行到这一句时, i 的值可能已经发生了变化。
下一篇再研究主协程等待子协程执行完毕以及确保子协程执行顺序的方法…
加载全部内容