Go是一种内置支持并发编程的语言。使用go关键字创建协程(轻量级线程)以及使用Go提供的channels等并发同步方式,让并发编程变得轻松、灵活、有趣。另一方面,Go并不能防止粗心或缺乏经验的Go程序员导致的一些并发编程错误。在本文的后续部分,将展示一些Go编程中常见的并发编程错误,以帮助Go程序员避免再次犯类似的错误。未同步的代码行可能不会按照它们在需要同步时出现的顺序运行。下面的程序有两个错误。***,在主协程中读b,在新协程中写b,可能会引起数据竞争。其次,条件b==true并不能保证主协程中的a!=nil。在新协程中,编译器和CPU可能会通过重新排序指令进行优化。因此,b的赋值可能在运行时发生在a的赋值之前。当a在主协程中被修改时,会使得a的一部分始终保持为nil。packagemainimport("time""runtime")funcmain(){vara[]int//nilvarbbool//false//一个新的goroutinegofunc(){a=make([]int,3)b=true//写b}()for!b{//读btime.Sleep(time.Second)runtime.Gosched()}a[0],a[1],a[2]=0,1,2//mightpanic}上面的程序可能在一台计算机上运行良好,但在另一台计算机上可能会抛出异常。或者它可能运行良好N次,但在第(N+1)次抛出异常。我们将使用同步标准包中提供的通道或同步方法来确保内存中的排序。例如,packagemainfuncmain(){vara[]int=nilc:=make(chanstruct{})//一个新的goroutinegofunc(){a=make([]int,3)c<-struct{}{}}()<-ca[0],a[1],a[2]=0,1,2}使用time.Sleep调用做同步我们先来看一个简单的例子。packagemainimport("fmt""time")funcmain(){varx=123gofunc(){x=789//writex}()时间。睡眠(时间。秒)fmt。Println(x)//readx}我们期望程序打印789,如果我们运行它,正常情况下肯定会打印789。但是,这个程序使用的同步方法好吗?不!原因是Go运行时不保证x的写入会在x的读取之前发生。在某些情况下,比如大部分CPU资源都被同一操作系统上其他正在运行的程序占用时,可能会出现读x后写x的情况。这也是为什么我们在官方项目中从不使用time.Sleep调用来实现同步的原因。让我们看另一个例子。packagemainimport("fmt""time")varx=0funcmain(){varnum=123varp=&numc:=make(chanint)gofunc(){c<-*p+x}()时间.Sleep(time.Second)num=789fmt.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+xgofunc(){c<-tmp}()...挂起协程挂起协程意味着保持协程处于阻塞状态。协程挂起的原因有很多。例如,一个goroutine尝试从nil通道或没有其他goroutine发送值的通道检索数据。goroutine尝试将值发送到nil通道,或者发送到没有其他goroutine接收值的通道。协程本身就会陷入僵局。一组协程相互死锁。在没有默认分支的情况下运行select块时,协程将被阻塞,并且select块中case关键字之后的所有通道操作都将保持阻塞状态。除了有时我们故意让程序中的主协程挂起以避免程序退出外,其他大部分协程挂起都是偶然的。Go运行时很难判断协程是处于挂起状态还是暂时阻塞。因此,Goruntime不会释放挂起的协程占用的资源。在firstresponderwinschannel用例中,如果使用的futurechannel容量不够大,当试图将结果发送到Futurechannel时,一些响应较慢的channel将被挂起。例如调用下面的函数,4个协程将永远阻塞。funcrequest()int{c:=make(chanint)fori:=0;我<5;i++{i:=igofunc(){c<-i//4个goroutine会挂在这里。}()}return<-c}为避免这4个协程一直被挂起,c通道的容量必须至少为4。在实现firstresponderwins第二种方法的通道用例中,如果未来channel用作非缓冲通道,则消息可能永远不会在没有响应的情况下挂起。例如,如果在协程中调用以下函数,协程可能会挂起。原因是如果在接收操作<-c就绪之前所有五个发送操作都尝试发送,则所有发送尝试都将失败,因此调用者协程将永远不会收到该值。funcrequest()int{c:=make(chanint)fori:=0;我<5;i++{i:=igofunc(){select{casec<-i:default:}}()}return<-c}将通道c转换为缓冲通道将确保五个发送操作中至少有一个将发送成功,这样上面函数中的caller协程就不会被挂起。复制sync标准包中的Type值实际上,sync标准包中的type值是不会被复制的。我们应该只复制指向这个值的指针。下面是一个糟糕的并发编程的例子。在此示例中,调用Counter.Value方法时,Counter将收到值的副本。作为接收值的一个字段,Counter接收值的每个Mutex字段也会被复制。复制不会同步发生,因此,复制的互斥量值可能是错误的。即使没有错误,复制的Counter接收到的值的访问保护也没有意义。import"sync"typeCounterstruct{sync.Mutexnint64}//这个方法没问题.func(c*Counter)Increase(dint64)(rint64){c.Lock()c.n+=dr=c.nc.Unlock()return}//方法不好。当它被调用时,一个Counter//接收器值将被复制。func(cCounter)Value()(rint64){c.Lock()r=c.nc.Unlock()return}我们只需要将Value接收类型方法改为指针类型*Counter即可,避免复制Mutex值。官方GoSDK中提供的govet命令将报告潜在的坏值副本。在错误的地方调用sync.WaitGroup方法每个sync.WaitGroup值都维护一个初始值为0的内部计数器。如果WaitGroup计数器的值为0,则调用WaitGroup值的Wait方法不会阻塞,否则,调用将被阻塞,直到计数器值为0。为了使WaitGroup值的使用有意义,当一个WaitGroup计数器值为0时,必须在相应的Wait方法之前调用WaitGroup值的Add方法WaitGroup值被调用。比如下面的程序,在错误的位置调用了Add方法,就会使得***打印出来的数字并不总是100。实际上,这个程序打印出来的数字可能是范围内的任意数字[0,100).原因是Add方法的调用不能保证在Wait方法的调用之前发生。packagemainimport("fmt""sync""sync/atomic")funcmain(){varwgsync.WaitGroupvarxint32=0fori:=0;我<100;i++{gofunc(){wg.Add(1)atomic.AddInt32(&x,1)wg.Done()}()}fmt.Println("等待...")wg.Wait()fmt.Println(atomic.LoadInt32(&x))}是为了让程序按预期运行,在for循环中,我们将Add方法的调用移到新创建的协程范围之外。修改后的代码如下。...对于我:=0;我<100;i++{wg.Add(1)gofunc(){atomic.AddInt32(&x,1)wg.Done()}()}...futuresChannels的错误使用在通道用例一文中,我们知道一些函数将返回通道上的期货。假设fa和fb是这样的两个函数,下面的调用使用了不正确的future参数。doSomethingWithFutureArguments(<-fa(),<-fb())在上面这行代码中,两个通道的接收操作是顺序执行的,不是同时执行的。我们进行以下修改,使其成为并发操作。ca,cb:=fa(),fb()doSomethingWithFutureArguments(<-c1,<-c2)关闭通道,不等待协程的***活动发送结束。Go程序员常犯的一个错误是,有一些其他的goroutine可能会向之前的通道发送值,但该通道已经关闭。当这样的发送(发送到已关闭的通道)实际发生时,将引发异常。这种错误在过去一些著名的Go项目中也出现过,比如Kubernetes项目中的thisbug和thisbug。如何安全优雅的关闭通道,请阅读本文。对值的64位原子操作不保证值-地址64位对齐截至目前(Go1.10),在标准的Go编译器中,64位原子操作涉及的值的地址必须是64-位对齐。如果不对齐,会导致当前协程异常。对于标准Go编译器,此故障仅发生在32位架构上。请阅读内存布局以了解如何确保32位操作系统上的64位对齐。没注意到这个时候占用了很多资源。函数调用后。时间标准包中的After函数返回延迟通知的通道。这个函数在某些情况下很方便,但是,每次调用它都会创建一个类型为time.Timer的新值。这个新创建的Timer值在通过将参数传递给After函数指定的时间段内保持活动状态。如果这段时间函数调用过多,可能会导致过多的Timer值保持激活状态,会占用大量的内存和计算量。资源。比如调用下面的longRunning函数,一分钟内会产生大量的消息,然后大量的Timer值会在一段时间内保持活跃,即使大量的这些Timer值不再使用。import("fmt""time")//如果消息到达间隔//大于一分钟,函数将返回。funclongRunning(messages<-chanstring){for{select{case<-time.After(time.Minute):returncasemsg:=<-messages:fmt.Println(msg)}}}为了避免在上面的代码中创建过多的Timer值,我们将使用单个Timer值来完成同样的任务。funclongRunning(messages<-chanstring){timer:=time.NewTimer(time.Minute)defertimer.Stop()for{select{case<-timer.C:returncasemsg:=<-messages:fmt.Println(msg)if!timer.Stop(){<-timer.C}}//上面的“if”块也可以放在这里。timer.Reset(time.Minute)}}time.Timer值的错误使用在***中,我们将展示一个使用语言中惯用的time.Timer值的示例。需要注意的一个细节是Reset方法总是在停止或释放time.Timer值时使用。在select块的第一个case分支结束时,time.Timer值被释放,所以我们不需要停止它。但是定时器必须在第二个分支停止。如果第二个分支中缺少if块,它可能会(由Go运行时)发送到timer.C通道,至少在调用Reset方法时,并且longRunning函数可能比预期更早返回,对于Reset方法比如说,它可能只是将内部计时器重置为0,它不会清除(耗尽)发送到timer.C通道的值。例如,下面的程序更有可能在一秒内退出而不是十秒。而且更重要的是,这个程序不是DRF(LCTT译注:dataracefree,多线程程序的一定程度的同步)。packagemainimport("fmt""time")funcmain(){start:=time.Now()timer:=time.NewTimer(time.Second/2)select{case<-timer.C:default:time.Sleep(time.Second)//gohere}timer.Reset(time.Second*10)<-timer.Cfmt.Println(time.Since(start))//1.000188181s}当time.Timer的值为nolonger当被其他任何东西使用时,它的值可能会保持在非停止状态,但是,建议在最后停止它。如果不在多个协程中并发使用time.Timer值,可能会有隐藏的bug。我们不应依赖Reset方法调用的返回值。Reset方法的返回值仅出于兼容性目的而存在。
