第六篇:并发编程
##6.1 go并发设计模式
”敌人的高并发要来了,python抗住“
“老板,扛不住了,宕机了!”
“你们卷铺盖回家吧!go程序员在哪里!!!”
go语言因为Goroutine才与众不同。
goroutine是用来实现go程序并发的,是go最重要的一部分。市面上很多人会把goroutine叫做go的协程,我们这里不叫它协程,依旧用goroutine去描述它。因为用协程或者线程去描述goroutine是不合适的,goroutine是线程和协程的一种复合使用。在我们下面讲解go并发设计模式的时候,你们就会发现我为什么这么说。
在讲go并发设计模式前,我们先来看两个概念,内核级线程、用户级线程
内核级线程:
内核级线程是操作系统执行程序的最小单元。就是说啊,cpu会在内核级线程之间切换并执行,如果有多个cpu,那就是多个cpu在这些线程之间切换并执行。我cpu去执行的就是内核级线程。
用户级线程:
用户级线程说的简单直白一点,就是用户自己规定的一个代码块。
理解完这两个概念之后,我们先来看看协程、多线程的设计模式,最后比较着来学习go并发设计模式
1、协程设计模式
这个模式的优点在于开销小,切换效率快。但有也有两个很致命的问题,一、就是不能实现并行,二、就是一旦阻塞整个线程都会被阻塞。python里的gevent就是这个模式
2、多线程设计模式
这个模型优点在于简单,和并行。缺点在于:一、切换是由操作系统调度的,效率比较低,二、操作系统开一个线程至少需要2M空间,资源成本很大。
每个用户级线程的运行状态,运行结果都是存在内核级线程上的。
重点来了,go的并发设计模式结合了协程和线程,并对其弊端进行了优化,以至于很牛逼。
那具体牛逼在哪里,我们来研究研究goroutine是底层怎么玩的:
3、go并发设计模式
字母解释:
1、G:G表示一个goroutine
2、P:P是介于内核级线程M和用户级线程G之间的调度器
3、M:M是内核级线程,真正执行程序的是M
并发过程解释:
1、每创建一个goroutine,会优先加入到p的local队列里,等待被执行,如果local队列都满了,那么就会加入到global全局队列里。
2、然后P会和M绑定,M去循环着执行local队列里的G。
3、一旦G出现了用户态阻塞,G就会被拿出,放进等待队列里,M继续执行下一个G,这个是不是就类似协程了,阻塞我就跳过执行下一个。G阻塞完成后,会被继续加入到loacl队列里,等待下次被执行
4、一旦G出现了系统调用阻塞(如磁盘读写),整条M也会被阻塞,这时候G也同样会被拿出,放进等待队列。那么P就会和M解绑,然后去找其它空闲的M绑定,如果没有空闲的M,就会新建一个M然后绑定,继续执行下一个G。这是不是就解决了协程里一旦阻塞整条线程就会被阻塞的问题啊?
5、整个go并发运行的时候,会有多个P/M同时运行,这就是多线程。
6、如果,我某一个P里的G运行完了,那么它会去global队列里拿G,如果global队列里没有,它就会随机的去其它p里拿出一半的G来运行
细节:
1、正常一个系统线程占8M空间,会保存着程序栈,记录着运行信息、保存着运行结果等,但实际上我们用不到这么大空间,对于一般程序,8M很大,那对于深度递归这样的程序,8M不算大。
goroutine不让内核级线程去记录这些信息了,内核级线程只管运行其它啥都不管。这些信息由用户级线程就是G去保存,初始空间只有2KB,然后按需扩展,实际需要多大给多大。这就为开启更多的goroutine提供了资源支持。
有了如此强大的go并发模型,我们用起来就很简单了。
6.2 创建go并发
1、创建goroutine:
格式如下:
go 函数名(参数)
或者用匿名函数创建:
go func(形参){ 函数体 }(实参)
func test1(){
fmt.Println("我是test1")
}
func test2(){
fmt.Println("我是test2")
}
func main(){
for i:=0;i<10;i++{ //创建20个goroutine,10个test1和10个test2
go test1()
go test2()
}
var input string //用来接收用户输入,阻塞主线程
fmt.Scanln(&input)
}
这时候我们就创建了20个并发任务
2、GOMAXPROCS()
我们回过去看上面的go并发模型图,里面有调度器P,我P的个数可以设置的。
我设置1个P是不是意味着我最后只有1个cpu来运行我的程序。我设置4个P,是不是意味着我用时可以有4个cpu来执行我的程序。
但P的数量是越多越好吗?显然不是啊,我就4个cpu,开5个p,是不是总归有一个P不会被执行啊?显然没意义。这个go语言呐也给你做了封装,p的最大数量,和你cpu核数相同,你设置为10,源码里也会给你改成4。
我们来看看如何设置:
//很简单,就一句话
runtime.GOMAXPROCS(P的数量)
//我们可以通过NumCPU()来获取当前计算机的cpu核数
runtime.GOMAXPROCS(runtime.NumCPU()) //go默认用的最大cpu数
6.3 通道 channel
我们正常多线程,比如Python,是怎么交换数据的?是不是用的共享内存的方式啊?
我每个线程都可以访问到同一个数据,并对它进行修改。为了保证数据修改的正确性,我们是不是用互斥锁来解决这个问题的?问题是结局了,但是!造成了性能的下降!!
goroutine另辟蹊径,它不用共享内存的方式来共享数据,它用通道的方式来共享数据。
什么是通道:
通道有2头,一头放数据,一头拿数据,先进先出。
怎么用通道:
6.3.1 通道的定义
我们使用make关键字来创建一个通道
mychan := make(chan 类型) //chan:表示通道 类型:用来指定通道里数据的类型
6.3.2 收发数据
我们用<- 来收发数据,很简单也很形象
发数据:
mychan <- 数据
收数据:
数据 <- mychan
实例:
func main(){
mychan := make(chan string)
//循环着放数据
go func() {
for {
var msg string
fmt.Scanln(&msg)
mychan <- msg
}
}()
//循环着取数据
for{
msg := <- mychan
fmt.Println(msg)
if msg == "exit"{ //如果输入“exit”我就退出程序
return
}
}
}
###6.3.3 非缓冲管道
如上这种管道我并没有指定它的容量,意味着它容量为0,容量为0,意味着不能存数据!
我擦,不能存数据是什么鬼?那我上面代码里mychan <- msg
这TM是在干嘛?
我负责任的告诉你,它没有存,只是阻塞着,等在那里!等谁啊?当然是等接受者。一旦有接受者出现<- mychan
,那么这个管道就被打通,发送者发数据,接受者接数据,数据不在管道停留。
这样的管道叫做非缓冲管道,它有两种情况会发生阻塞:
1、发送者没有匹配到接受者
2、接收者没有匹配到发送者
6.3.4 缓冲管道
非缓冲管道容量为0,缓冲管道显然就是容量不为0的,意味着就是可以保存数据的。
我们用make的第二个参数去设置容量
mychan := make(chan int,3) //容量为3的管道
容量为3意味着什么呀?
mychan <- 1 //不会阻塞
mychan <- 2 //不会阻塞
mychan <- 3 //不会阻塞
mychan <- 4 //嘿呀,塞住了,需要接受者,不然我一直塞着
6.3.5 单向管道
单向通道很好理解,就是要么只能读,要么只能写的通道。
我是不是可以像定义双向通道一样去定义单向通道啊?
mychanin := make(chan<- int) //只写通道
mychanout := make(<-chan int) //只读通道
但是!!!你这么玩?疯了吗?这个通道有啥意义啊?
单向通道的意义在于把双向通道拆成单向通道,用来限制某个函数的操作
mychan := make(chan int,2)
mychan2 := make(chan int,2)
//类型转换,双向通道变成单向(理论上,但这么玩有坑)
inout_to_in := chan<- int(mychan) //单向写 ---这货正常
inout_to_out := <-chan int(mychan) //单向读 ---这货就是死狗,坑就在这里
inout_to_out := (<-chan int)(mychan) //得加括号,不然程序会把<-和chan分开
//这么玩最靠谱,用赋值的方式进行通道转换
var inout_to_in chan<- int = mychan
var inout_to_out <-chan int = mychan
fmt.Println(inout_to_in == mychan) //true
fmt.Println(inout_to_out == mychan) //true 证明他们操作的都是同一个通道
fmt.Println(inout_to_out == mychan2) //false
6.3.6 关闭通道
纳尼?为什么要关闭通道?
通道是一直存在的,不会被回收。某些情况,我不需要再使用它了,就把它关掉,等待垃圾回收。释放内存资源。
请看玩法:
func main(){
mychan := make(chan int)
go func() {
defer close(mychan) //关了它
for i:=0;i<3;i++{
mychan<-i
}
}()
fmt.Println(<-mychan)
fmt.Println(<-mychan)
fmt.Println(<-mychan)
}
注意点:
1、重复关闭,会引发panic恐慌
func main(){
mychan := make(chan int)
go func() {
defer close(mychan) //嘣! panic: close of closed channel
defer close(mychan) //关了它
for i:=0;i<3;i++{
mychan<-i
}
}()
fmt.Println(<-mychan)
fmt.Println(<-mychan)
fmt.Println(<-mychan)
}
2、向已经关闭的通道发送数据,会引发panic
func main(){
mychan := make(chan int,3)
close(mychan)
mychan<-1 //嘣! panic: send on closed channel
}
3、接收已关闭的通道,返回缓存值或者0值
func main(){
mychan := make(chan int,2)
mychan<-1
mychan<-2
close(mychan)
fmt.Println(<-mychan) // 1
fmt.Println(<-mychan) // 2
fmt.Println(<-mychan) // 0 值取完了,所以返回int的0值
}
其实接收通道数据时,会有2个值:
value,isopen := <-mychan
fmt.Println(value) //通道里取的值
fmt.Println(isopen) //true-表示通道打开,false-表示通道关闭
重要的事来的!
接收已关闭的通道不会报错,但是!!发送数据给已关闭的通道会报错,所以!我们关闭通道最好是放在数据发送端!!!!!!
6.3.7 通道的多路复用
什么是通道的多路复用
很好理解嘛,一堆通道一起玩嘛
为什么要有多路复用
多路复用是一堆通道一起玩,那我们来看,通道一个一个的玩儿会有什么问题
func main(){
mychan1 := make(chan int,2)
mychan2 := make(chan int,2)
mychan3 := make(chan int,2)
go func(){
for {
a := <-mychan1
fmt.Println(a)
//问题在这里在这里!!看过来!
//如果不接收到mychan1的数据,会一直阻塞着,以至于后面的通道也没法正常接收数据
b := <-mychan2
fmt.Println(b)
c := <-mychan3
fmt.Println(c)
}
}()
mychan3 <- 3
mychan3 <- 3
mychan2 <- 2
mychan2 <- 2
time.Sleep(5*time.Second) //睡5秒,我再往通道1放值
mychan1 <- 1
mychan1 <- 1
var input string //阻塞住主程序
fmt.Scanln(&input)
}
怎么用多路复用:
格式如下:
select{
case 通道操作1:
代码..
case 通道操作2:
代码..
...
}
修改上面代码:
func main(){
mychan1 := make(chan int,2)
mychan2 := make(chan int,2)
mychan3 := make(chan int,2)
go func(){
for {
select{
//这时候不会因为mychan1的阻塞而阻塞mychan2和mychan3
//select类似switch
case a := <-mychan1:
fmt.Println(a)
case b := <-mychan2:
fmt.Println(b)
case c := <-mychan3:
fmt.Println(c)
}
}
}()
mychan3 <- 3
mychan3 <- 3
mychan2 <- 2
mychan2 <- 2
time.Sleep(5*time.Second) //睡5秒,我再往通道1放值
mychan1 <- 1
mychan1 <- 1
var input string //阻塞住主程序
fmt.Scanln(&input)
}
6.4 死锁错误
在go里面有一种现象叫做死锁!这是个很严重的问题,会造成程序直接崩溃。
死锁错误的原因:
所有的goroutine都处于阻塞状态,就会造成死锁错误!
所有的goroutine都阻塞住了,程序压根儿没法运行了,不崩溃才怪!
案例1:
func main(){
mychan1 := make(chan int)
mychan1<-1 //阻塞
go func() {
fmt.Println(<-mychan1)
}()
}
这里有2个goroutine,住程序阻塞的时候第二个goroutine还没来得及开启,程序无法继续运行,崩溃!
案例2:
func main(){
mychan1 := make(chan int,3)
go func() {
for i:=0;i<3;i++{
mychan1<-i
}
}()
for {
//前3次正常运行,第四次阻塞,此时go开启的goroutine已经结束,当前只有1个主程序在运行,并阻塞,所 以死锁发生
fmt.Println(<-mychan1)
}
}
在编写程序的时候要时刻提防死锁的发生!否则,轻则功能无法实现,重则,程序崩溃,你被开除!
6.5 Lazy生成器
我有一堆数据需要处理。
那么有两个途径:1、把所有的数据都读到内存,一起处理;2、读一个处理一个
这两种方法相比较而言:
第一种:简单,但内存消耗大
第二种:略复杂,内存消耗少
举个例子,我有1000个int64的数字,一共8000B,约为7.8KB。问题来了,我现在需要求和。
第一种方法,我至少需要7.8KB的空间来存数字。
第二种方法,我只需要大约8B的空间
go里实现生成器实际上需要一个2kb的goroutine,所以方法二需要大约2KB空间
结论:如果数据量小于2KB,适合一次性读取;如果数据量大于2KB,适合生成器
生成器怎么玩:
func num1to1000 () chan int {
mychan := make(chan int,1)
go func(){
for i:=1;i<1001;i++{
mychan <- i
}
}()
return mychan
}
func main(){
mychan := num1to1000()
fmt.Println(<- mychan) // 1
fmt.Println(<- mychan) // 2
fmt.Println(<- mychan) // 3
}
一个简单的生成器就这么完成了!
但是!但是!!这么写有问题!!
1、生成器没有把数据全部生产完,这个goroutine会一直存在
2、生成器没有把数据全部生产完,关闭通道会panic
问题的关键点:
有个机制可以关闭通道,并终止goroutine
实现方法多种多样,不要拘泥于细节,充分结合自己所学知识:
我从painc入手,它panic了,我就recover,并退出goroutine
func num1to1000 () chan int {
mychan := make(chan int,1)
go func(){
defer func(){
recover()
runtime.Goexit()
}()
for i:=1;i<1001;i++{
mychan <- i
}
}()
return mychan
}
func main(){
mychan := num1to1000()
fmt.Println(<- mychan) // 1
fmt.Println(<- mychan) // 2
fmt.Println(<- mychan) // 3
close(mychan) //我关掉通道,goroutine会报错,然后被我关掉
}
感觉很棒有没有?!!!
其它方法都蛮繁琐的,我就不介绍了!交给你们自己去想了