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 main
import (
"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 main
import (
"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 Harry
receive Harry
receive Harry
channel is closed.
receive Harry
receive Harry
channel is empty.
Go运行时系统并没有在通道channel被关闭后立即把false作为相应接收操作的第二个结果,而是等到接收端把已在通道中的所有元素值都接收到后才这样做,确保在发送端关闭通道的安全性。 被关闭的通道会禁止数据流入, 是只读的,仍然可以从关闭的通道中取出数据,但不能再写入数据。 给一个nil的channel发送数据,造成永远阻塞 ;从一个nil的channel接收数据,造成永远阻塞。给一个已经关闭的channel发送数据,引起panic ; 从一个已经关闭的channel接收数据,返回带缓存channel中缓存的值,如果通道中无缓存,返回0。
4、无缓冲通道
make创建通道时,默认没有第二个参数,通道的大小为0,称为无缓冲通道。 无缓冲的通道是指通道的大小为0,即通道在接收前没有能力保存任何值,无缓冲通道发送goroutine和接收gouroutine必须是同步的,如果没有同时准备好,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。无缓冲通道也称为同步通道。 无缓冲的信道永远不会存储数据,只负责数据的流通。从无缓冲信道取数据,必须要有数据流进来才可以,否则当前goroutine会阻塞;数据流入无缓冲信道, 如果没有其它goroutine来拿取走数据,那么当前goroutine会阻塞。
package main
import (
"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 main
import (
"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 main
import (
"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: 3
receive x10: 9
receive x9: 8
receive x5: 4
receive x2: 1
receive x7: 6
receive x8: 7
receive x1: 0
receive x3: 2
receive x6: 5
六、死锁
Go程序中死锁是指所有的goroutine在等待资源的释放。
通常,死锁的报错信息如下:
fatal error: all goroutines are asleep - deadlock!
Goroutine死锁产生的原因如下:
A、只在单一的goroutine里操作无缓冲信道,一定死锁
B、非缓冲信道上如果发生流入无流出,或者流出无流入,会导致死锁
因此,解决死锁的方法有:
A、取走无缓冲通道的数据或是发送数据到无缓冲通道
B、使用缓冲通道