一、goroutine作用
goroutine的本质是协程,是实现并行计算的核心,和python的async编程类似,但是又不需要我们手动的去创建携程并通过实践循环来启动。goroutine异步执行函数,只需使用go关键字+函数名即可启动一个协程,函数则直接处于异步执行的状态。
1 | go func()//通过go关键字启动一个协程来运行函数 |
二、goroutine的原理
携程概念介绍
协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。
调度模型简介
groutine是通过GPM调度模型实现,下面就来解释下goroutine的调度模型。
Go的调度器内部有四个重要的结构:M,P,S,Sched,如上图所示(Sched未给出)
M:M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息
G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
P:P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine
Sched:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。
调度实现
从上图中看,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。
P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。
图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),
Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出
当一个OS线程M0陷入阻塞时(例如爬虫或者其他的io操作, 这里就和python的多线程类似)(如下图),P转而在运行M1,图中的M1可能是正被创建,或者从线程缓存中取出。
当MO返回时,它必须尝试取得一个P来运行goroutine,一般情况下,它会从其他的OS线程那里拿一个P过来,
如果没有拿到的话,它就把goroutine放在一个global runqueue里,然后自己睡眠(放入线程缓存里)。所有的P也会周期性的检查global runqueue并运行其中的goroutine,否则global runqueue上的goroutine永远无法执行。
另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了这个处理器P很忙,但是其他的P还有任务,此时如果global runqueue没有任务G了,那么P不得不从其他的P里拿一些G来执行。一般来说,如果P从其他的P那里要拿任务的话,一般就拿runqueue的一半,这就确保了每个OS线程都能充分的使用,如下图:
三、goroutine使用
基本使用
设置goroutine运行的CPU数量,最新版本的go已经默认已经设置了。
1 | num := runtime.NumCPU() //获取主机的逻辑CPU个数 |
使用示例
1 | package main |
goroutine异常捕捉
当启动多个goroutine时,如果其中一个goroutine异常了,并且我们并没有对进行异常处理,那么整个程序都会终止,所以我们在编写程序时候最好每个goroutine所运行的函数都做异常处理,异常处理采用recover
1 | package main |
同步的goroutine
由于goroutine是异步执行的,那很有可能出现主程序退出时还有goroutine没有执行完,此时goroutine也会跟着退出。此时如果想等到所有goroutine任务执行完毕才退出,go提供了sync包和channel来解决同步问题,当然如果你能预测每个goroutine执行的时间,你还可以通过time.Sleep方式等待所有的groutine执行完成以后在退出程序(如上面的列子)。
方法一:使用sync包同步goroutine
这种方法和python的Thread.join() 很类似
WaitGroup 等待一组goroutinue执行完毕. 主程序调用 Add 添加等待的goroutinue数量. 每个goroutinue在执行结束时调用 Done ,此时等待队列数量减1.,主程序通过Wait阻塞,直到等待队列为0.
1 | package main |
示例二:通过channel实现goroutine之间的同步。
这种方法类似于python的Thread.Queue()通讯,来实现异步程序和主程序之间的数据交换
实现方式:通过channel能在多个groutine之间通讯,当一个goroutine完成时候向channel发送退出信号,等所有goroutine退出时候,利用for循环channe去channel中的信号,若取不到数据会阻塞原理,等待所有goroutine执行完毕,使用该方法有个前提是你已经知道了你启动了多少个goroutine。
1 | package main |
goroutine之间的通讯
goroutine本质上是协程,可以理解为不受内核调度,而受go调度器管理的线程。goroutine之间可以通过channel进行通信或者说是数据共享,当然你也可以使用全局变量来进行数据共享。
示例:使用channel模拟消费者和生产者模式
1 | package main |
四、Golang - channel
简介
channel俗称管道,用于数据传递或数据共享,其本质是一个先进先出的队列,使用goroutine+channel进行数据通讯简单高效,同时也线程安全,多个goroutine可同时修改一个channel,不需要加锁。
channel可分为三种类型:
只读channel:只能读channel里面数据,不可写入
只写channel:只能写数据,不可读
一般channel:可读可写
channel使用
定义和声明
1 | // 定义 |
读写数据
需要注意
- 如果没有使用goroutine,如果未关闭channel,读取数据会产生deadlock异常
- 管道如果关闭后再继续写入数据会pannic
- 如果是有缓存channel,当管道中没有数据时候再行读取会读取到默认值,如int类型默认值是0
1 | ch <- "wd" //写数据 |
循环管道
需要注意
- 使用range循环管道,如果管道未关闭会引发deadlock错误。
- 如果采用for range 循环已经关闭的管道,只能遍历有默认值的管道, 当管道没有数据时候,读取的数据会是管道的默认值,并且循环不会退出。
1 | package main |
带缓冲区channe和不带缓冲区channel
带缓冲区channel:定义声明时候制定了缓冲区大小(长度),可以保存多个数据。
不带缓冲区channel:只能存一个数据,并且只有当该数据被取出才能存下个数据
1 | ch := make(chan int) //不带缓冲区 |
不带缓冲区示例:
1 | package main |
Channel的deadlock
一个死锁的例子:
1 | func main() { |
执行这个程序你会看到Go报这样的错误:
1 | fatal error: all goroutines are asleep - deadlock! |
*何谓死锁? *
操作系统有讲过的,所有的线程或进程都在等待资源的释放。如上的程序中, 只有一个goroutine, 所以当你向里面加数据或者存数据的话,都会锁死信道, 并且阻塞当前 goroutine, 也就是所有的goroutine(其实就main线一个)都在等待信道的开放(没人拿走数据信道是不会开放的),也就是死锁咯。
其实,总结来看,为什么会死锁?非缓冲信道上如果发生了流入无流出,或者流出无流入,也就导致了死锁。或者这样理解 Go启动的所有goroutine里的非缓冲信道一定要一个线里存数据,一个线里取数据,要成对才行
那么死锁的解决办法呢?
最简单的,把没取走的数据取走,没放入的数据放入, 因为无缓冲信道不能承载数据,那么就赶紧拿走!
Go的deadlock 信道数据
你也许发现,上面的代码一个一个地去读取信道简直太费事了,Go语言允许我们使用range来读取信道:
1 | func main() { |
如果你执行了上面的代码,会报死锁错误的,原因是range不等到信道关闭是不会结束读取的。也就是如果 缓冲信道干涸了,那么range就会阻塞当前goroutine, 所以死锁咯。
那么,我们试着避免这种情况,比较容易想到的是读到信道为空的时候就结束读取:
1 | ch := make(chan int, 3) |
以上的方法是可以正常输出的,但是注意检查信道大小的方法不能在信道存取都在发生的时候用于取出所有数据,这个例子 是因为我们只在ch中存了数据,现在一个一个往外取,信道大小是递减的。
另一个方式是显式地关闭信道:
1 | ch := make(chan int, 3) |
被关闭的信道会禁止数据流入, 是只读的。我们仍然可以从关闭的信道中取出数据,但是不能再写入数据了。
channel实现作业池
我们创建三个channel,一个channel用于接受任务,一个channel用于保持结果,还有个channel用于决定程序退出的时候。
1 | package main |
只读channel和只写channel
一般定义只读和只写的管道意义不大,更多时候我们可以在参数传递时候指明管道可读还是可写,即使当前管道是可读写的。
1 | package main |
select-case实现非阻塞channel
原理通过select+case加入一组管道,当满足(这里说的满足意思是有数据可读或者可写)select中的某个case时候,那么该case返回,若都不满足case,则走default分支。
1 | package main |
channel频率控制
在对channel进行读写的时,go还提供了非常人性化的操作,那就是对读写的频率控制,通过time.Ticke实现
示例:
1 | package main |