Go语言开发(九)、Go语言并发编程
一、goroutine简介
1、并发与并行简介
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。
并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)。
2、Coroutine简介
Coroutine(协程)是一种用户态的轻量级线程,特点如下:
A、轻量级线程
B、非抢占式多任务处理,由协程主动交出控制权。
C、编译器/解释器/虚拟机层面的任务
D、多个协程可能在一个或多个线程上运行。
E、子程序是协程的一个特例。
不同语言对协程的支持:
A、C++通过Boost.Coroutine实现对协程的支持
B、Java不支持
C、Python通过yield关键字实现协程,Python3.5开始使用async def对原生协程的支持
3、goroutine简介
在Go语言中,只需要在函数调用前加上关键字go即可创建一个并发任务单元,新建的任务会被放入队列中,等待调度器安排。
进程在启动的时候,会创建一个主线程,主线程结束时,程序进程将终止,因此,进程至少有一个线程。main函数里,必须让主线程等待,确保进程不会被终止。
go语言中并发指的是让某个函数独立于其它函数运行的能力,一个goroutine是一个独立的工作单元,Go的runtime(运行时)会在逻辑处理器上调度goroutine来运行,一个逻辑处理器绑定一个操作系统线程,因此goroutine不是线程,是一个协程。
进程:一个程序对应一个独立程序空间
线程:一个执行空间,一个进程可以有多个线程
逻辑处理器:执行创建的goroutine,绑定一个线程
调度器:Go运行时中的,分配goroutine给不同的逻辑处理器
全局运行队列:所有刚创建的goroutine队列
本地运行队列:逻辑处理器的goroutine队列
当创建一个goroutine后,会先存放在全局运行队列中,等待Go运行时的调度器进行调度,把goroutine分配给其中的一个逻辑处理器,并放到逻辑处理器对应的本地运行队列中,最终等着被逻辑处理器执行即可。
Go的并发是管理、调度、执行goroutine的方式。
默认情况下,Go默认会给每个可用的物理处理器都分配一个逻辑处理器。
可以在程序开头使用runtime.GOMAXPROCS(n)设置逻辑处理器的数量。
如果需要设置逻辑处理器的数量,一般采用如下代码设置:
runtime.GOMAXPROCS(runtime.NumCPU())
对于并发,Go语言本身自己实现的调度,对于并行,与物理处理器的核数有关,多核就可以并行并发,单核只能并发。
4、goroutinue使用示例
在Go语言中,只需要在函数调用前加上关键字go即可创建一个并发任务单元,新建的任务会被放入队列中,等待调度器安排。
package mainimport ( "fmt" "sync")func main(){ var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() for i := 0; i < 10000; i++ { fmt.Printf("Hello,Go.This is %d\n", i) } }() go func() { defer wg.Done() for i := 0; i < 10000; i++ { fmt.Printf("Hello,World.This is %d\n", i) } }() wg.Wait()}
sync.WaitGroup是一个计数的信号量,使main函数所在主线程等待两个goroutine执行完成后再结束,否则两个goroutine还在运行时,主线程已经结束。
sync.WaitGroup使用非常简单,使用Add方法设设置计数器为2,每一个goroutine的函数执行完后,调用Done方法减1。Wait方法表示如果计数器大于0,就会阻塞,main函数会一直等待2个goroutine完成再结束。
5、goroutine的本质
goroutine是轻量级的线程,占用的资源非常小(Go将每个goroutine stack的size默认设置为2k)线程的切换由操作系统控制,而goroutine的切换则由用户控制。
goroutinue本质上是协程。
goroutinue可以实现并行,即多个goroutinue可以在多个处理器同时运行,而协程同一时刻只能在一个处理器上运行。
goroutine之间的通信是通过channel,而协程的通信是通过yield和resume()操作。
二、goroutine调度机制
1、线程调度模型
高级语言对内核线程的封装实现通常有三种线程调度模型:
A、N:1模型。N个用户空间线程在1个内核空间线程上运行,优势是上下文切换非常快但无法利用多核系统的优点。
B、1:1模型。1个内核空间线程运行一个用户空间线程,充分利用了多核系统的优势但上下文切换非常慢,因为每一次调度都会在用户态和内核态之间切换。
C、M:N模型。每个用户线程对应多个内核空间线程,同时也可以一个内核空间线程对应多个用户空间线程,使用任意个内核模型管理任意个goroutine,但缺点是调度的复杂性。
2、Go调度器简介
Go的最小调度单元为goroutine,但操作系统最小的调度单元依然是线程,所以go调度器(go scheduler)要做的工作是如何将众多的goroutine放在有限的线程上进行高效而公平的调度。
操作系统的调度不失为高效和公平,比如CFS调度算法。go引入goroutine的核心原因是goroutine轻量级,无论是从进程到线程,还是从线程到goroutine,其核心都是为了使调度单元更加轻量级,可以轻易创建几万几十万的goroutine而不用担心内存耗尽等问题。go引入goroutine试图在语言内核层做到足够高性能得同时(充分利用多核优势、使用epoll高效处理网络/IO、实现垃圾回收等机制)尽量简化编程。
3、Go调度器实现原理
Go 1.1开始,Go scheduler实现了M:N的G-P-M线程调度模型,即任意数量的用户态goroutine可以运行在任意数量的内核空间线程线程上,不仅可以使上线文切换更加轻量级,又可以充分利用多核优势。
为了实现M:N线程调度机制,Go引入了3个结构体:
M:操作系统的内核空间线程
G:goroutine对象,G结构体包含调度一个goroutine所需要的堆栈和instruction pointer(IP指令指针),以及其它一些重要的调度信息。每次go调用的时候,都会创建一个G对象。
P:Processor,调度的上下文,实现M:N调度模型的关键,M必须拿到P才能对G进行调度,P限定了go调度goroutine的最大并发度。每一个运行的M都必须绑定一个P。
P的个数是GOMAXPROCS(最大256),启动时固定,一般不修改; M的个数和P的个数不一定相同(会有休眠的M或者不需要太多的M);每一个P保存着本地G任务队列,也能使用全局G任务队列。
全局G任务队列会和各个本地G任务队列按照一定的策略互相交换。
P是用一个全局数组(255)来保存的,并且维护着一个全局的P空闲链表。
每次调用go的时候,都会:
A、创建一个G对象,加入到本地队列或者全局队列
B、如果有空闲的P,则创建一个M
C、M会启动一个底层线程,循环执行能找到的G任务
D、G任务的执行顺序是先从本地队列找,本地没有则从全局队列找(一次性转移(全局G个数/P个数)个,再去其它P中找(一次性转移一半)。
E、G任务执行是按照队列顺序(即调用go的顺序)执行的。
创建一个M过程如下:
A、先找到一个空闲的P,如果没有则直接返回。
B、调用系统API创建线程,不同的操作系统调用方法不一样。
C、 在创建的线程里循环执行G任务
如果一个系统调用或者G任务执行太长,会一直占用内核空间线程,由于本地队列的G任务是顺序执行的,其它G任务就会阻塞。因此,Go程序启动的时候,会专门创建一个线程sysmon,用来监控和管理,sysmon内部是一个循环:
A、记录所有P的G任务计数schedtick,schedtick会在每执行一个G任务后递增。
B、如果检查到 schedtick一直没有递增,说明P一直在执行同一个G任务,如果超过一定的时间(10ms),在G任务的栈信息里面加一个标记。
C、G任务在执行的时候,如果遇到非内联函数调用,就会检查一次标记,然后中断自己,把自己加到队列末尾,执行下一个G。
D、如果没有遇到非内联函数(有时候正常的小函数会被优化成内联函数)调用,会一直执行G任务,直到goroutine自己结束;如果goroutine是死循环,并且GOMAXPROCS=1,阻塞。
4、抢占式调度
Go没有时间片的概念。如果某个G没有进行system call调用、没有进行I/O操作、没有阻塞在一个channel操作上,M通过抢占式调度让长任务G停下来并调度下一个G。
除非极端的无限循环或死循环,否则只要G调用函数,Go runtime就有抢占G的机会。Go程序启动时,Go runtime会启动一个名为sysmon的M(一般称为监控线程),sysmon无需绑定P即可运行。sysmon是GO程序启动时创建的一个用于监控管理的线程。
sysmon每20us~10ms启动一次,sysmon主要完成如下工作:
A、释放闲置超过5分钟的span物理内存;
B、如果超过2分钟没有垃圾回收,强制执行;
C、将长时间未处理的netpoll结果添加到任务队列;
D、向长时间运行的G任务发出抢占调度;
E、收回因syscall长时间阻塞的P;
如果一个G任务运行10ms,sysmon就会认为其运行时间太久而发出抢占式调度的请求。一旦G的抢占标志位被设为true,那么待G下一次调用函数或方法时,runtime便可以将G抢占,并移出运行状态,放入P的local runq中,等待下一次被调度。
三、runtime包
1、Gosched
runtime.Gosched()用于让出CPU时间片,让出当前goroutine的执行权限,调度器安排其它等待的任务运行,并在下次某个时候从该位置恢复执行。
2、Goexit
调用runtime.Goexit()将立即终止当前goroutine执⾏,调度器确保所有已注册defer延迟调用被执行。
3、GOMAXPROCS
调用runtime.GOMAXPROCS()用来设置可以并行计算的CPU核数的最大值,并返回设置前的值。
四、Channel通道
1、Channel简介
Channel是goroutine之间通信的通道,用于goroutine之间发消息和接收消息。Channel是一种引用类型的数据,可以作为参数,也可以作为返回值。
2、Channel的创建
channel声明使用chan关键字,channel的创建需要指定通道中发送和接收数据的类型。
使用make来建立一个信道:
var channel chan int = make(chan int)// 或channel := make(chan int)
make有第二个参数,用于指定通道的大小。
3、Channel的操作
//发送数据:写channel<- data//接收数据:读data := <- channel
关闭通道:发送方关闭通道,用于通知接收方已经没有数据
关闭通道后,其它goroutine访问通道获取数据时,得到零值和false
有条件结束死循环:
for{ v ,ok := <- chan if ok== false{ //通道已经关闭。。 break }}
//循环从通道中获取数据,直到通道关闭。for v := range channel{ //从通道读取数据}
Channel使用示例如下:
package mainimport ( "fmt" "time")type Person struct { name string age uint8 address Address}type Address struct { city string district string}func SendMessage(person *Person, channel chan Person){ go func(person *Person, channel chan Person) { fmt.Printf("%s send a message.\n", person.name) channel<-*person for i := 0; i < 5; i++ { channel<- *person } close(channel) fmt.Println("channel is closed.") }(person, channel)}func main() { channel := make(chan Person,1) harry := Person{ "Harry", 30, Address{"London","Oxford"}, } go SendMessage(&harry, channel) data := <-channel fmt.Printf("main goroutine receive a message from %s.\n", data.name) for { i, ok := <-channel time.Sleep(time.Second) if !ok { fmt.Println("channel is empty.") break }else{ fmt.Printf("receive %s\n",i.name) } }}
结果如下:
Harry send a message.main goroutine receive a message from Harry.receive Harryreceive Harryreceive Harrychannel is closed.receive Harryreceive Harrychannel is empty.
Go运行时系统并没有在通道channel被关闭后立即把false作为相应接收操作的第二个结果,而是等到接收端把已在通道中的所有元素值都接收到后才这样做,确保在发送端关闭通道的安全性。
被关闭的通道会禁止数据流入, 是只读的,仍然可以从关闭的通道中取出数据,但不能再写入数据。
给一个nil的channel发送数据,造成永远阻塞 ;从一个nil的channel接收数据,造成永远阻塞。给一个已经关闭的channel发送数据,引起panic ;
从一个已经关闭的channel接收数据,返回带缓存channel中缓存的值,如果通道中无缓存,返回0。
4、无缓冲通道
make创建通道时,默认没有第二个参数,通道的大小为0,称为无缓冲通道。
无缓冲的通道是指通道的大小为0,即通道在接收前没有能力保存任何值,无缓冲通道发送goroutine和接收gouroutine必须是同步的,如果没有同时准备好,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。无缓冲通道也称为同步通道。
无缓冲的信道永远不会存储数据,只负责数据的流通。从无缓冲信道取数据,必须要有数据流进来才可以,否则当前goroutine会阻塞;数据流入无缓冲信道, 如果没有其它goroutine来拿取走数据,那么当前goroutine会阻塞。
package mainimport ( "fmt")func main() { ch := make(chan int) go func() { var sum int = 0 for i := 0; i < 10; i++ { sum += i } //发送数据到通道 ch <- sum }() //从通道接收数据 fmt.Println(<-ch)}
在计算sum和的goroutine没有执行完,将值赋发送到ch通道前,fmt.Println(<-ch)会一直阻塞等待,main函数所在的主goroutine就不会终止,只有当计算和的goroutine完成后,并且发送到ch通道的操作准备好后,main函数的<-ch会接收计算好的值,然后打印出来。
无缓存通道的发送数据和读取数据的操作不能放在同一个协程中,防止发生死锁。通常,先创建一个goroutine对通道进行操作,此时新创建goroutine会阻塞,然后再在主goroutine中进行通道的反向操作,实现goroutine解锁,即必须goroutine在前,解锁goroutine在后。
5、有缓冲通道
make创建通道时,指定通道的大小时,称为有缓冲通道。
对于带缓存通道,只要通道中缓存不满,可以一直向通道中发送数据,直到缓存已满;同理只要通道中缓存不为0,可以一直从通道中读取数据,直到通道的缓存变为0才会阻塞。
相对于不带缓存通道,带缓存通道不易造成死锁,可以同时在一个goroutine中放心使用。
带缓存通道不仅可以流通数据,还可以缓存数据,当带缓存通道达到满的状态的时候才会阻塞,此时带缓存通道不能再承载更多的数据。
带缓存通道是先进先出的。
6、单向通道
对于某些特殊的场景,需要限制一个通道只可以接收,不能发送;限制一个通道只能发送,不能接收。只能单向接收或发送的通道称为单向通道。
定义单向通道只需要在定义的时候,带上<-即可。
var send chan<- int //只能发送var receive <-chan int //只能接收
<-操作符的位置在后面只能发送,对应发送操作;<-操作符的位置在前面只能接收,对应接收操作。
单向通道通常用于函数或者方法的参数。
五、channel应用
1、广播功能实现
当一个通道关闭时, 所有对此通道的读取的goroutine都会退出阻塞。
package mainimport ( "fmt" "time")func notify(id int, channel chan int){ <-channel//接收到数据或通道关闭时退出阻塞 fmt.Printf("%d receive a message.\n", id)}func broadcast(channel chan int){ fmt.Printf("Broadcast:\n") close(channel)//关闭通道}func main(){ channel := make(chan int,1) for i:=0;i<10 ;i++ { go notify(i,channel) } go broadcast(channel) time.Sleep(time.Second)}
2、select使用
select用于在多个channel上同时进行侦听并收发消息,当任何一个case满足条件时即执行,如果没有可执行的case则会执行default的case,如果没有指定default case,则会阻塞程序。select的语法如下:
select {case communication clause : statement(s);case communication clause : statement(s); /*可以定义任意数量的 case */default : /*可选 */ statement(s);}
Select多路复用中:
A、每个case都必须是一次通信
B、所有channel表达式都会被求值
C、所有被发送的表达式都会被求值
D、如果任意某个通信可以进行,它就执行;其它被忽略。
E、如果有多个case都可以运行,Select会随机公平地选出一个执行。其它不会执行。
F、否则,如果有default子句,则执行default语句。如果没有default子句,select将阻塞,直到某个通信可以运行;Go不会重新对channel或值进行求值。
package mainimport ( "fmt" "time")func doWork(channels *[10]chan int){ for { select { case x1 := <-channels[0]: fmt.Println("receive x1: ",x1) case x2 := <-channels[1]: fmt.Println("receive x2: ",x2) case x3 := <-channels[2]: fmt.Println("receive x3: ",x3) case x4 := <-channels[3]: fmt.Println("receive x4: ",x4) case x5 := <-channels[4]: fmt.Println("receive x5: ",x5) case x6 := <-channels[5]: fmt.Println("receive x6: ",x6) case x7 := <-channels[6]: fmt.Println("receive x7: ",x7) case x8 := <-channels[7]: fmt.Println("receive x8: ",x8) case x9 := <-channels[8]: fmt.Println("receive x9: ",x9) case x10 := <-channels[9]: fmt.Println("receive x10: ",x10) } }}func main(){ var channels [10]chan int go doWork(&channels) for i := 0; i < 10; i++ { channels[i] = make(chan int,1) channels[i]<- i } time.Sleep(time.Second*5)}
结果如下:
receive x4: 3receive x10: 9receive x9: 8receive x5: 4receive x2: 1receive x7: 6receive x8: 7receive x1: 0receive x3: 2receive x6: 5
六、死锁
Go程序中死锁是指所有的goroutine在等待资源的释放。
通常,死锁的报错信息如下:fatal error: all goroutines are asleep - deadlock!
Goroutine死锁产生的原因如下:
A、只在单一的goroutine里操作无缓冲信道,一定死锁
B、非缓冲信道上如果发生流入无流出,或者流出无流入,会导致死锁
因此,解决死锁的方法有:
A、取走无缓冲通道的数据或是发送数据到无缓冲通道
B、使用缓冲通道
共同学习,写下你的评论
评论加载中...
作者其他优质文章