一些常见的并发编程错误
2018-06-08 00:52
459 查看
Go 是一个内置支持并发编程的语言。借助使用
另一方面,Go 并不会阻止一些因 Go 程序员粗心大意或者缺乏经验而造成的并发编程错误。在本文的下面部分将展示一些在 Go 编程中常见的并发编程错误,以帮助 Go 程序员们避免再犯类似的错误。
在下面的程序中有两个错误。
第一,在
第二,条件
我们将使用
使用
我们先来看一个简单的例子。
我们来看一下另外一个示例。
现在,我们来改变
是的,在上面的程序中存在数据争用。表达式
对于这个特定的示例,我们将在新的协程创建之前,将值保存到一个临时值中,然后在新的协程中使用临时值去消除数据争用。
一个协程尝试从一个 nil 信道中或者从一个没有其它协程给它发送值的信道中检索数据。
一个协程尝试去发送一个值到 nil 信道,或者发送到一个没有其它的协程接收值的信道中。
一个协程被它自己死锁。
一组协程彼此死锁。
当运行一个没有
除了有时我们为了避免程序退出,特意让一个程序中的
在 谁先响应谁获胜 的信道使用案例中,如果使用的 future 信道容量不够大,当尝试向 Future 信道发送结果时,一些响应较慢的信道将被挂起。比如,如果调用下面的函数,将有 4 个协程处于永远阻塞状态。
在 实现谁先响应谁获胜的第二种方法 的信道使用案例中,如果将 future 信道用做非缓冲信道,那么有可能这个信息将永远也不会有响应而挂起。例如,如果在一个协程中调用下面的函数,协程可能会挂起。原因是,如果接收操作
在
在实践中,
下面是一个错误的并发编程示例。在这个示例中,当调用
在官方的 Go SDK 中提供的
在错误的地方调用
每个
为了让
例如,下面的程序中,在不正确位置调用了
这种错误在一些以往的著名 Go 项目中也有发生,比如在 Kubernetes 项目中的 这个 bug 和 这个 bug。
如何安全和优雅地关闭信道,请阅读 这篇文章。
没有注意到大量的资源被
在
例如,如果调用了下列的
不正确地使用
在最后,我们将展示一个符合语言使用习惯的
在
例如,下面的程序很有可能在一秒内而不是十秒时退出。并且更重要的是,这个程序并不是 DRF 的(LCTT 译注:data race free,多线程程序的一种同步程度)。
在多个协程中如果不按建议使用
我们不应该依赖一个
via: https://go101.org/article/concurrent-common-mistakes.html
go关键字去创建协程goroutine(轻量级线程)和在 Go 中提供的 使用 信道 和 其它的并发 同步方法,使得并发编程变得很容易、很灵活和很有趣。
另一方面,Go 并不会阻止一些因 Go 程序员粗心大意或者缺乏经验而造成的并发编程错误。在本文的下面部分将展示一些在 Go 编程中常见的并发编程错误,以帮助 Go 程序员们避免再犯类似的错误。
需要同步的时候没有同步
代码行或许 不是按出现的顺序运行的。在下面的程序中有两个错误。
第一,在
main协程中读取
b和在新的 协程 中写入
b可能导致数据争用。
第二,条件
b == true并不能保证在
main协程 中的
a != nil。在新的协程中编译器和 CPU 可能会通过 重排序指令 进行优化,因此,在运行时
b赋值可能发生在
a赋值之前,在
main协程 中当
a被修改后,它将会让部分
a一直保持为
nil。
上面的程序或者在一台计算机上运行的很好,但是在另一台上可能会引发异常。或者它可能运行了 N 次都很好,但是可能在第 (N+1) 次引发了异常。package mainimport ( "time" "runtime")func main() { var a []int // nil var b bool // false // a new goroutine go func () { a = make([]int, 3) b = true // write b }() for !b { // read b time.Sleep(time.Second) runtime.Gosched() } a[0], a[1], a[2] = 0, 1, 2 // might panic}
我们将使用
sync标准包中提供的信道或者同步方法去确保内存中的顺序。例如,
package mainfunc main() { var a []int = nil c := make(chan struct{}) // a new goroutine go func () { a = make([]int, 3) c <- struct{}{} }() <-c a[0], a[1], a[2] = 0, 1, 2}
使用 time.Sleep
调用去做同步
我们先来看一个简单的例子。我们预期程序将打印出package mainimport ( "fmt" "time")func main() { var x = 123 go func() { x = 789 // write x }() time.Sleep(time.Second) fmt.Println(x) // read x}
789。如果我们运行它,通常情况下,它确定打印的是
789。但是,这个程序使用的同步方式好吗?No!原因是 Go 运行时并不保证
x的写入一定会发生在
x的读取之前。在某些条件下,比如在同一个操作系统上,大部分 CPU 资源被其它运行的程序所占用的情况下,写入
x可能就会发生在读取
x之后。这就是为什么我们在正式的项目中,从来不使用
time.Sleep调用去实现同步的原因。
我们来看一下另外一个示例。
你认为程序的预期输出是什么?package mainimport ( "fmt" "time")var x = 0func main() { var num = 123 var p = &num c := make(chan int) go func() { c <- *p + x }() time.Sleep(time.Second) num = 789 fmt.Println(<-c)}
123还是
789?事实上它的输出与编译器有关。对于标准的 Go 编译器 1.10 来说,这个程序很有可能输出是
123。但是在理论上,它可能输出的是
789,或者其它的随机数。
现在,我们来改变
c <- *p + x为
c <- *p,然后再次运行这个程序。你将会发现输出变成了
789(使用标准的 Go 编译器 1.10)。这再次说明它的输出是与编译器相关的。
是的,在上面的程序中存在数据争用。表达式
*p可能会被先计算、后计算、或者在处理赋值语句
num = 789时计算。
time.Sleep调用并不能保证
*p发生在赋值语句处理之前进行。
对于这个特定的示例,我们将在新的协程创建之前,将值保存到一个临时值中,然后在新的协程中使用临时值去消除数据争用。
... tmp := *p + x go func() { c <- tmp }()...
使协程挂起
挂起协程是指让协程一直处于阻塞状态。导致协程被挂起的原因很多。比如,一个协程尝试从一个 nil 信道中或者从一个没有其它协程给它发送值的信道中检索数据。
一个协程尝试去发送一个值到 nil 信道,或者发送到一个没有其它的协程接收值的信道中。
一个协程被它自己死锁。
一组协程彼此死锁。
当运行一个没有
default分支的
select代码块时,一个协程被阻塞,以及在
select代码块中
case关键字后的所有信道操作保持阻塞状态。
除了有时我们为了避免程序退出,特意让一个程序中的
main协程保持挂起之外,大多数其它的协程挂起都是意外情况。Go 运行时很难判断一个协程到底是处于挂起状态还是临时阻塞。因此,Go 运行时并不会去释放一个挂起的协程所占用的资源。
在 谁先响应谁获胜 的信道使用案例中,如果使用的 future 信道容量不够大,当尝试向 Future 信道发送结果时,一些响应较慢的信道将被挂起。比如,如果调用下面的函数,将有 4 个协程处于永远阻塞状态。
为避免这 4 个协程一直处于挂起状态,func request() int { c := make(chan int) for i := 0; i < 5; i++ { i := i go func() { c <- i // 4 goroutines will hang here. }() } return <-c}
c信道的容量必须至少是
4。
在 实现谁先响应谁获胜的第二种方法 的信道使用案例中,如果将 future 信道用做非缓冲信道,那么有可能这个信息将永远也不会有响应而挂起。例如,如果在一个协程中调用下面的函数,协程可能会挂起。原因是,如果接收操作
<-c准备就绪之前,五个发送操作全部尝试发送,那么所有的尝试发送的操作将全部失败,因此那个调用者协程将永远也不会接收到值。
将信道func request() int { c := make(chan int) for i := 0; i < 5; i++ { i := i go func() { select { case c <- i: default: } }() } return <-c}
c变成缓冲信道将保证五个发送操作中的至少一个操作会发送成功,这样,上面函数中的那个调用者协程将不会被挂起。
在 sync
标准包中拷贝类型值
在实践中,sync标准包中的类型值不会被拷贝。我们应该只拷贝这个值的指针。
下面是一个错误的并发编程示例。在这个示例中,当调用
Counter.Value方法时,将拷贝一个
Counter接收值。作为接收值的一个字段,
Counter接收值的各个
Mutex字段也会被拷贝。拷贝不是同步发生的,因此,拷贝的
Mutex值可能会出错。即便是没有错误,拷贝的
Counter接收值的访问保护也是没有意义的。
我们只需要改变import "sync"type Counter struct { sync.Mutex n int64}// This method is okay.func (c *Counter) Increase(d int64) (r int64) { c.Lock() c.n += d r = c.n c.Unlock() return}// The method is bad. When it is called, a Counter// receiver value will be copied.func (c Counter) Value() (r int64) { c.Lock() r = c.n c.Unlock() return}
Value接收类型方法为指针类型
*Counter,就可以避免拷贝
Mutex值。
在官方的 Go SDK 中提供的
go vet命令将会报告潜在的错误值拷贝。
在错误的地方调用 sync.WaitGroup
的方法
每个 sync.WaitGroup值维护一个内部计数器,这个计数器的初始值为 0。如果一个
WaitGroup计数器的值是 0,调用
WaitGroup值的
Wait方法就不会被阻塞,否则,在计数器值为 0 之前,这个调用会一直被阻塞。
为了让
WaitGroup值的使用有意义,当一个
WaitGroup计数器值为 0 时,必须在相应的
WaitGroup值的
Wait方法调用之前,去调用
WaitGroup值的
Add方法。
例如,下面的程序中,在不正确位置调用了
Add方法,这将使最后打印出的数字不总是
100。事实上,这个程序最后打印的数字可能是在
[0, 100)范围内的一个随意数字。原因就是
Add方法的调用并不保证一定会发生在
Wait方法调用之前。
为让程序的表现符合预期,在package mainimport ( "fmt" "sync" "sync/atomic")func main() { var wg sync.WaitGroup var x int32 = 0 for i := 0; i < 100; i++ { go func() { wg.Add(1) atomic.AddInt32(&x, 1) wg.Done() }() } fmt.Println("To wait ...") wg.Wait() fmt.Println(atomic.LoadInt32(&x))}
for循环中,我们将把
Add方法的调用移动到创建的新协程的范围之外,修改后的代码如下。
... for i := 0; i < 100; i++ { wg.Add(1) go func() { atomic.AddInt32(&x, 1) wg.Done() }() }...
不正确使用 futures 信道
在 信道使用案例 的文章中,我们知道一些函数将返回 futures 信道。假设fa和
fb就是这样的两个函数,那么下面的调用就使用了不正确的 future 参数。
在上面的代码行中,两个信道接收操作是顺序进行的,而不是并发的。我们做如下修改使它变成并发操作。doSomethingWithFutureArguments(<-fa(), <-fb())
ca, cb := fa(), fb()doSomethingWithFutureArguments(<-c1, <-c2)
没有等协程的最后的活动的发送结束就关闭信道
Go 程序员经常犯的一个错误是,还有一些其它的协程可能会发送值到以前的信道时,这个信道就已经被关闭了。当这样的发送(发送到一个已经关闭的信道)真实发生时,将引发一个异常。这种错误在一些以往的著名 Go 项目中也有发生,比如在 Kubernetes 项目中的 这个 bug 和 这个 bug。
如何安全和优雅地关闭信道,请阅读 这篇文章。
在值上做 64 位原子操作时没有保证值地址 64 位对齐
到目前为止(Go 1.10),在标准的 Go 编译器中,在一个 64 位原子操作中涉及到的值的地址要求必须是 64 位对齐的。如果没有对齐则导致当前的协程异常。对于标准的 Go 编译器来说,这种失败仅发生在 32 位的架构上。请阅读 内存布局 去了解如何在一个 32 位操作系统上保证 64 位对齐。没有注意到大量的资源被 time.After
函数调用占用
在 time标准包中的
After函数返回 一个延迟通知的信道。这个函数在某些情况下用起来很便捷,但是,每次调用它将创建一个
time.Timer类型的新值。这个新创建的
Timer值在通过传递参数到
After函数指定期间保持激活状态,如果在这个期间过多的调用了该函数,可能会有太多的
Timer值保持激活,这将占用大量的内存和计算资源。
例如,如果调用了下列的
longRunning函数,将在一分钟内产生大量的消息,然后在某些周期内将有大量的
Timer值保持激活,即便是大量的这些
Timer值已经没用了也是如此。
为避免在上述代码中创建过多的import ( "fmt" "time")// The function will return if a message arrival interval// is larger than one minute.func longRunning(messages <-chan string) { for { select { case <-time.After(time.Minute): return case msg := <-messages: fmt.Println(msg) } }}
Timer值,我们将使用一个单一的
Timer值去完成同样的任务。
func longRunning(messages <-chan string) { timer := time.NewTimer(time.Minute) defer timer.Stop() for { select { case <-timer.C: return case msg := <-messages: fmt.Println(msg) if !timer.Stop() { <-timer.C } } // The above "if" block can also be put here. timer.Reset(time.Minute) }}
不正确地使用 time.Timer
值
在最后,我们将展示一个符合语言使用习惯的 time.Timer值的使用示例。需要注意的一个细节是,那个
Reset方法总是在停止或者
time.Timer值释放时被使用。
在
select块的第一个
case分支的结束部分,
time.Timer值被释放,因此,我们不需要去停止它。但是必须在第二个分支中停止定时器。如果在第二个分支中
if代码块缺失,它可能至少在
Reset方法调用时,会(通过 Go 运行时)发送到
timer.C信道,并且那个
longRunning函数可能会早于预期返回,对于
Reset方法来说,它可能仅仅是重置内部定时器为 0,它将不会清理(耗尽)那个发送到
timer.C信道的值。
例如,下面的程序很有可能在一秒内而不是十秒时退出。并且更重要的是,这个程序并不是 DRF 的(LCTT 译注:data race free,多线程程序的一种同步程度)。
当package mainimport ( "fmt" "time")func main() { start := time.Now() timer := time.NewTimer(time.Second/2) select { case <-timer.C: default: time.Sleep(time.Second) // go here } timer.Reset(time.Second * 10) <-timer.C fmt.Println(time.Since(start)) // 1.000188181s}
time.Timer的值不再被其它任何一个东西使用时,它的值可能被停留在一种非停止状态,但是,建议在结束时停止它。
在多个协程中如果不按建议使用
time.Timer值并发,可能会有 bug 隐患。
我们不应该依赖一个
Reset方法调用的返回值。
Reset方法返回值的存在仅仅是为了兼容性目的。
via: https://go101.org/article/concurrent-common-mistakes.html
相关文章推荐
- ios block常见的错误(三)——并发编程的block引用
- zz:HTTP协议中POST、GET、HEAD、PUT等请求方法以及一些常见错误
- ConcurrentModificationException:并发的修改错误(常见于集合)
- ava web 开发过程中常见的一些错误
- 嵌入式Linux驱动程序之编程常见错误
- 在WinForm编程中犯的一些错误
- python 开发中的常见编程错误
- 关于Java编程中的一些奇怪的错误(一)——版本冲突
- PHP 编程中 10 个最常见的错误,你犯过几个?
- Android心得1.3--Android一些常见设置和错误处理
- 编程面试中的十个常见错误
- 新手asp编程的基本法则与常见错误注意事项
- JBOSS常见错误以及一些部署错误的收集
- 步步出错,层层分析–tns的一些常见错误分析实例
- HTTP协议中POST、GET、HEAD、PUT等请求方法以及一些常见错误
- VC头文件的顺序 以及一些常见错误的常见处理方式
- php中使用ajax时一些常见错误
- C语言编程中常见的错误
- 这些年我记下的一些编程错误
- 本人编程遇到的一些错误解决办法