用go写并发的确很方便,协程相比于线程轻量很多,关于协程与线程的区别,可以参考我的这篇博客:go中的协程与线程的区别。本文主要讲一下一些并发中应该注意的坑,学习go没多久,可能有认知不到位的地方,欢迎指正。
1. channel
go中通过channel进行消息通信,channel函数传参通过引用实现。go中有2种channel,分别是无缓存的channel(unbuffered channel)和有缓存的channel(buffered channel),前者cap为0,后者大于0。无缓存的channel主要用于同步:生产者写入channel然后阻塞,直到消费者读取后,生成者才从阻塞状态返回;有缓存队列适用于异步:在队列为满的情况下(len < cap),生产者将消息塞入channel就返回。
由于go存在垃圾回收机制,所以无需显示关闭channel,即使是有缓存的channel且channel不为空,也会被垃圾回收。当然,生产者主动close可以当做一种广播通告机制,告知生产者消息已经写完。对一个已经close的channel进行写将会触发panic,但是对其进行读是可以的,如果channel有数据会正常返回,直到数据读完回返回channel已经关闭并且数据已读完:x, ok := <- ch
,此时ok == false
。
对于有缓存的channel,是通过FIFO形式塞入和读取数据的。可以通过len
和cap
分别查看channel的元素的个数和channel的大小。
协程泄露问题,对于channel的操作可能引发协程泄露,即协程不能被系统垃圾回收,举个例子:
func example() string {
q := make(chan string)
go func() { q <- "hello" }
go func() { q <- "world" }
go func() { q <- "china" }
return <- q
}
我们一开始定义了一个无缓冲的channel,然后3个协程分别写入,最后从channel中读取第一个元素,然后返回。此时只有1个协程内的数据被消费了,剩余2个协程都会一直阻塞住,而这个阻塞状态将无法改变,因为数据不会再被消费,这就导致了协程泄露。此时,如果将channel定义为cap大于等于2的协程就能够解决这个问题,我们无需关心这个channel中的数据会不会被消费,因为我上面说过,即使没有被消费,这个channel也能被成功垃圾回收掉。
2.无阻塞读写channel
假设是一个无缓存的channel,或者有缓存的channel数据已经满的情况下写,或者数据空的情况下读,都将会触发阻塞,有的时候我们希望无阻塞该怎么做?代码如下,例子来源于non-blocking-channel-operations:
messages := make(chan string)
signals := make(chan bool)
select {
case msg := <-messages:
fmt.Println("received message", msg)
default:
fmt.Println("no message received")
}
msg := "hi"
select {
case messages <- msg:
fmt.Println("sent message", msg)
default:
fmt.Println("no message sent")
}
select {
case msg := <-messages:
fmt.Println("received message", msg)
case sig := <-signals:
fmt.Println("received signal", sig)
default:
fmt.Println("no activity")
}
以上代码输出如下:
$ go run non-blocking-channel-operations.go
no message received
no message sent
no activity
代码通过select机制,当读/写case阻塞住的时候,通过default进入到下一步状态,需要注意的是,对于一个无缓存buffer,通过以上代码,如果读端没有阻塞在读状态,那么写端必然失败;同理,如果写端未阻塞在写状态,那么读端也必然失败。
3.共享变量
在go多协程编程中,共享变量是一种极度不可靠的方式。举个例子,以下代码:
a := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
for _, x := range a {
go func() {
fmt.Print(x)
}()
}
这代码看似没问题,会输出从1-10的所有元素,其实并不是,比如可能会输出如下:
5
7
10
10
10
10
10
10
10
10
原因在于:协程共享了外部协程的x变量,而外部协程是一个循环,将会对x进行更新,所以看到的x可能是更新后的。正确的方式应该是将x作为参数传入for循环内部协程:
a := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
for _, x := range a {
go func(x int) {
fmt.println(x)
}(x)
}
需要再次注意的地方:假如在for循环内起了进行了一些操作,然后进行defer,这些defer语句不会等待当前循环结束,下个循环执行前运行,而是等待整个函数退出再运行,所以此时defer后面涉及到变量(包括函数内部变量),也不要采用外部变量,否则很可能会陷入上面所说的坑。
再举个例子,以下代码会输出什么呢?
var x, y int
go func() {
x = 1 // A1
fmt.Print("y: ", y, " ") // A2
}()
go func() {
y = 1 // B1
fmt.Print("x: ", x, " ") // B2
}()
根据多线程编程的经验,以下结果很容易被猜到:
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
然而,下面两种结果也是可能发生的:
x:0 y:0
y:0 x:0
这是因为,假如这两个协程并发是跑在2个上下文上(参考这里),那么可能A1语句执行完的结果是位于缓存,而没有更新到主存上,那么B2读到的还是原来的结果。所以可能导致这些结果。还有需要注意的是,编译器可能会对结果进行优化,比如A1和A2,B2和B2没有相互影响,那么执行可能会对这两条语句进行互换。
那么如何解决这些问题,因为go中没有C和Java中volatile
的强行刷缓存的概念,所以只能用锁和channel去实现同步,而不能依赖于共享全局变量。
4.sync
sync
包提供了一些帮助并发同步的变量,如Mutex
和RWMutex
这些互斥锁,另外sync.Once
用于惰性加载(你会发现在并发情况下自己实现惰性加载需要不少代码量且性能不佳),还有WaitGroup
可以用于等待所以协程执行完毕再操作的功能。需要注意的是如果想要实现上述我说的逻辑,WaitGroup
的Add
需要放在协程外实现,Done
在协程内defer实现,否则Add
如果放在协程内,可能协程没开始跑,Wait()
语句就跑到了,然后就返回了。
一定要注意并发情况带来的危害,比如并发对map进行读写。此时,我们可以通过加锁来解决并发访问的情况,然而并发锁的粒度是我们需要考虑的,锁太粗影响性能,锁太细可能达不到卡并发的作用。个人观点:不能以C/C++编程的思想去思考go中并发的情况,go中可能通过channel绕过或者减缓这些情况,当然,这个需要一些经验,我也在学习中。
5.条件竞争
执行时加入-race
语句可以查看语句执行过程的竞争情况。
总结
go写起来的确特别爽,但是想写出无bug高性能的代码,还需要一个不断学习总结的过程。
参考
《The Go Programming Language》
http://vinllen.com/gozhong-de-xie-cheng-goroutineyu-xian-cheng-de-qu-bie
https://gobyexample.com/non-blocking-channel-operations