Go并发——gotoutine及其应用
goroutine模型
go的并发没有像Java一样将线程与操作系统的线程进行一一映射,而是自己实现了一套管理和调试,同时可以将goroutine理解成Java中的协程。
上图是goroutine的模型图:
- M表示表示真正的内核线程,表示并发量,也即同时可以有多少个goroutine可以同时运行
- P是对M的一层虚拟,表示goroutine的Processor,每个Processor有一串待执行的G处于等待队列
- G蓝色的G是正在运行的goroutine,而灰度的G形成一条链,是等待队列
goroutine调度
在上节的图中我们会发现多个P其实是不共享等待队列的,因此可能会出现阻塞和饥饿的情况。例如第一个M执行的G中有syscall导致阻塞,这时候第一个P的等待队列就会一直阻塞直到syscall返回,这时M会丢弃P给其他M。当第一个M从syscall返回时,它又会去抢一个P回来,如果没有抢成功,则会将G push到一个全局的G等待队列,然后让自己休眠。
全局等待队列没有P,因此它是被其他M上的P调度的。当某个M的P没有可执行的G时,也即等待队列为空时,会先从全局等待队列中取一个G来执行。如果全局等待队列也为空时,它还会从其他P的等待队列中抢一些G加入到自己的等待队列中,那么抢多少合适呢?go的做法是直接抢一半...
goroutine的创建与销毁
由于goroutine没有与系统线程一一映射,因此goroutine的所有状态和信息都是直接保存在go进程的内存中的,因而在运行时创建一个goroutine就特别简单了,只需要new一块内存,放入goroutine的信息并将goroutine加入到一个P的等待队列中即可
跟Java的Thread一样,goroutine其实也是没有销毁功能。但Java的Thread可以在run方法中catch所有异常,在finally中执行一些release逻辑,go语言也有个defer功能,可以在函数执行完毕时(不论是否触发了异常)都能执行,因此可以用defer实现Java的finally的release功能。
func CreateAndRelease() {
wg := sync.WaitGroup{}
wg.Add(1)
go func() {
defer func() {
e := recover()
if e != nil {
fmt.Printf("goroutine error:%v\n", e)
return
}
fmt.Println("goroutine run successful")
}()
defer wg.Done()
time.Sleep(2 * time.Second)
a := 0
result := 3 / a
fmt.Printf("run 3/%d=%d successful\n", a, result)
}()
wg.Wait()
}
在上面的代码中,使用go+func的方式即可创建一个goroutine,然后在func中使用了defer+recover捕获error。除此之外还使用了sync.WaitGroup,这里的WaitGroup其实跟Java中的CountDownLatch功能几乎一样。如果不加WaitGroup,那么可能新创建goroutine还开始调度main goroutine就执行完毕导致进程结束了。
上面的代码中用3/0来触发一个error,在defer中用recover捕获了这个error并打印出来,运行打印输出是goroutine error:runtime error: integer divide by zero
。我们可以在go func创建的goroutine的defer中执行一些release操作,当然,如果希望goroutine中的异常不会抛到进程中去导致进程异常退出的话,还需要使用recover在goroutine中就捕获error
goroutine通信机制——chan
chan是channel的缩写,意为管道,是go语言中goroutine之间主要的通信机制。chan也是go的一种数据类型,chan + type即可组合成任何类型的管道。同时chan其实也有三种类型,分别是:
- chan type:可读可写管道
- <-chan type:只读管道
- chan<- type:只写管道
对于只读或只写的chan来说,其箭头的指向几乎就说明了其功能。只读chan的箭头在chan type左边,表明管道的出口,意为要从管道中输出数据。只写chan的箭头在chan和type之间,说明这是管道的入口,要将一个type类型的数据写入管道。而对于可读可写的管道,没有箭头,说明其既可以是入口也可以是出口,因此可读可写。
func product(c chan<- interface{}) {
for i := 0; ; i++ {
msg := fmt.Sprintf("msg-%d", i)
time.Sleep(500 * time.Millisecond)
fmt.Printf("product %s\n", msg)
c <- msg
}
}
func consume(c <-chan interface{}) {
for {
msg := <-c
fmt.Printf("consume %s\n", msg)
time.Sleep(1000 * time.Millisecond)
}
}
func Run() {
c0 := make(chan interface{})
go product(c0)
go consume(c0)
c1 := make(chan interface{})
go consume(c1)
go product(c1)
wait := make(chan interface{}, 0)
<-wait
}
上面的代码演示了一个典型的生产者消费者模式
chan的容量
上面实现的生产消费-消费者模式,在使用make创建chan的时候,还在wait这个chan传了0这个参数,make创建chan的第三个参数就是chan的容量,如果不传,默认容量是1,也就是chan读取会阻塞一直到有写入为止,如果已经写入一个则需要读取后才能再写入,否则写入时就会阻塞。而如果指定容量为0,则表示这个chan读或写都会一直阻塞,因此使用一个容量为0来实现WaitGroup的效果。
上面的生产者-消费者模式的消息队列由于没有指定chan的容量,因此容量为1,如果在生产效率大于消费效率时不阻塞生产者,则需要将消息队列的容量设置成大于1,当然,一般来说这个容量肯定不能指定得太大,因为生产的消息会一直保存在chan中,容量太大可能会造成OOM。一般地,chan的容量大于1时,我们将这个容量当成是chan的缓存,一旦有了缓存大小,就会涉及到背压问题,goroutine的chan对于背压只有接收消息并阻塞这一种策略,并且无法设置,因此需要实现其他的背压策略,只能在生产-消费者的生产端自定义实现。
select-case
select-case一般用来同时监听多个chan的消息,类似于switch-case。这有点像Java中的AIO的select实现,只有当io事件到来时才会触发事件,否则也不会阻塞当前线程。同时,select也能像for-range一样监控到chan的close事件,可以安全方便地从for循环中退出对chan的消息接收。
func selectProduct(ctx context.Context, msg chan<- struct{}, d time.Duration) {
exit:
for {
select {
case _, ok := <-ctx.Done():
if !ok {
fmt.Println("ctx done and product exit")
close(msg)
break exit
}
default:
time.Sleep(d)
msg <- struct{}{}
}
}
}
func selectConsume(msg1, msg2 <-chan struct{}) {
exitFun := func() {
fmt.Println("consume exit")
}
var exit1, exit2 bool
exit:
for {
select {
case m1, ok := <-msg1:
exit1 = !ok
if ok {
fmt.Printf("msg1 %v\n", m1)
} else if exit2 {
exitFun()
break exit
}
case m2, ok := <-msg2:
exit2 = !ok
if ok {
fmt.Printf("msg2 %v\n", m2)
} else if exit1 {
exitFun()
break exit
}
}
}
}
func TestSelect() {
msg1, msg2 := make(chan struct{}), make(chan struct{})
var ctx context.Context
ctx, _ = context.WithTimeout(context.Background(), time.Second*5)
go selectProduct(ctx, msg1, time.Second)
go selectProduct(ctx, msg2, time.Second*2)
go selectConsume(msg1, msg2)
_, ok := <-ctx.Done()
if !ok {
time.Sleep(time.Second * 3)
}
}
上面的代码演示了一个消费者同时消费两个生产者的消息,并且使用context设置了一个5s的超时定时器,5s后这个程序将正常退出,所有的goroutine都将通过exit tag来break
同时select对于每个case,其实是没有顺序的,如果两个事件同时到来,select也不会按照case的书写顺序来执行,而是随机选一个case执行
锁
chan主要是为了解决goroutine之间的并发访问安全问题,也就是管道的实现方式。除了使用chan,还可以使用和Java一样的锁机制来实现并发的临界区。
golang的锁机制也都在sync包内,sync.Locker接口的Lock和Unlock函数用来锁定和解锁,这种Java的Lock完全一样,sync.Mutex用来实现普通锁,sync.RWMutex用来实现读写锁。
其他的同步机制
除了chan和锁,golang还提供了一些其他的同步机制,比如前面提到的sync.WaitGroup来用来实现类似于Java的CountDownLatch的功能。
Java中还提供了原子类来保证并发环境下的内存可见性,golang也提供了一些原子结构来保证可见性。在sync的子包atomic包内,提供了int32、uint32、int64、uint64的CAS操作函数,可以方便地实现一些原子操作,还提供了Value这个struct,可以保存interface{}类型的变量,Value提供了Load和Store两个函数来实现原子读和写。
当然,对于最常用的数据结构map,sync包也提供了并发安全的Map结构,sync.Map类似于Java的ConcurrentMap来实现并发安全Map。
Java中的wait/notify功能可以用来实现线程的等待与唤醒,golang用sync.Cond来实现这个功能,sync.Cond需要传入一个Lock变量,这跟Java的Object作为锁一样。
除此之外,golang还为了资源复用和单例模式的方便实现在sync包中实现了sync.Pool和sync.Once结构。
- Pool在创建时需要传入一个New的函数,用来作为资源生产工厂,当Pool中还不存在需要的资源时,就会调用New函数来生产一个,如果已有资源,就会直接返回。Pool使用Get函数来获取资源,使用Put函数将资源释放返回给Pool。
- sync.Once的创建不需要传入任何参数,但它的唯一函数Done需要传入一个函数作为操作对象,sync.Once结构的Done函数无论调用多少次都只会在第一次调用时执行,因此可以在传入Done函数的参数函数中做一些单例的初始化工作。