当前位置: 首页 > 科技观察

Golang通道使用总结

时间:2023-03-17 11:20:57 科技观察

不同于传统的多线程并发模型,使用共享内存实现线程间通信。golang的哲学是协程(goroutines)之间通过channel进行通信,实现数据共享:不通过共享内存进行通信;相反,通过通信共享内存。这种方法的优点是通过提供原子通信原语,它避免了竞争条件下的复杂锁定机制。一个channel可以看做一个FIFO队列。读取和写入FIFO队列是一个没有锁定的原子操作。通道的操作行为结果总结如下:对应类型零值阻塞或成功读取数据读取关闭的通道时,始终可以读取到对应类型的零值。为了区别于读取非空未关闭通道的行为,可以使用两个接收值://okisfalsewhenchiclosedv,ok:=<-chgolang中大部分类型都是值类型(只有slice/channel/map是引用类型),当读写类型为值类型的通道时,如果元素大小比较大,应该改用指针,避免频繁的内存拷贝开销。内部实现如图所示。在channel的内部实现中(具体定义在$GOROOT/src/runtime/chan.go中),维护了三个队列:readwaiting协程队列recvq,维护了读这个channel时阻塞的队列coroutinelistwritewaitingcoroutinequeuesendq,维护写本通道阻塞的协程listbuffer数据队列buf,用ringqueue实现。对于没有缓冲的channel,当coroutineattempt从未关闭时,queuesize为0img在channel中读取数据时,内部操作如下:1.当buf不为空时,此时recvq一定为空,buf弹出一个元素给读协程,读协程拿到数据后继续执行。此时如果sendq不为空,则sendq弹出一个write协程进入running状态,将要写入的数据放入队列buf中。这时候读操作<-ch没有被阻塞;2.当buf为空但sendq不为空(无缓冲通道)时,则sendq弹出一个写协程进入运行状态,将要写入的数据直接传递给读协程,读协程继续执行。这时候读操作<-ch没有被阻塞;3.当buf为空且sendq也为空时,读协程进入队列recvq,进入阻塞状态。当后面其他协程向通道写入数据时,读协程会重新进入运行状态。这时读操作<-ch块。同样,当协程尝试向未关闭的通道写入数据时,内部操作如下:当队列recvq不为空时,此时队列buf一定为空,从recvq弹出一个读协程接收要写入的数据。此时读协程结束阻塞,进入运行状态,写协程继续执行。此时写操作ch<-并没有被阻塞;当队列recvq为空但buf未满时,此时sendq必须为空,并将write协程要写入的数据放入buf中,然后继续执行。此时写操作ch<-并没有被阻塞;当队列recvq为空,buf为满时,将协程写入队列sendq,进入阻塞状态。当后续其他协程从通道读取数据时,写协程会重新进入运行状态,此时写操作ch<-blocks。当非nil通道关闭时,内部操作如下:当队列recvq不为空时,此时buf必须为空,recvq中的所有协程都会收到对应类型的零值,然后结束阻塞状态;当队列sendq不为空时,此时buf一定是满的,sendq中的所有协程都会panic,buf中的数据仍会保留,直到被其他协程读取。使用场景除了协程间传递数据的一般用法外,本节列举了一些通道的特殊使用场景。futures/promises虽然golang没有直接提供future/promise模型的操作原语,但是通过goroutine和channel可以实现类似的功能:packagemainimport("io/ioutil""log""net/http")//RequestFuture,httprequestpromise.funcRequestFuture(urlstring)<-chan[]byte{c:=make(chan[]byte,1)gofunc(){varbody[]bytedeferfunc(){c<-body}()res,err:=http.Get(url)iferr!=nil{return}deferres.Body.Close()body,_=ioutil.ReadAll(res.Body)}()returnc}funcmain(){future:=RequestFuture("https://api.github.com/users/octocat/orgs")body:=<-futurelog.Printf("reponselength:%d",len(body))}POSIX接口线程中的条件变量(conditionvariable)类型通知其他线程一个eventoccurs通道的条件变量,通道的特性也可以作为协程间同步的条件变量。因为通道只是用于通知,所以通道中具体的数据类型和值并不重要。在这种情况下,通常使用strct{}作为通道的类型。类似于pthread_cond_signal()的一对一通知函数,用于在一个协程中通知另一个协程事件:packagemainimport("fmt""time")funcmain(){ch:=make(chanstruct{})nums:=make([]int,100)gofunc(){time.Sleep(time.Second)fori:=0;i=1000{//signalrecvingfinishclose(done)return}}}wg.Add(3)gosend(0)gosend(1)gosend(2)recv()wg.Wait()}多写多读这个场景稍微复杂一点,和上面的例子一样,也需要额外设置一个通道来通知多个写者和读者。此外,还需要一个额外的协程来通过关闭此通道来广播通知:packagemainimport("fmt""sync""time")funcmain(){wg:=&sync.WaitGroup{}ch:=make(chanint,100)done:=make(chanstruct{})send:=func(idint){deferwg.Done()fori:=0;;i++{select{case<-done://getexitsignalfmt.Printf("sender#%dexit\n",id)returncasech<-id*1000+i:}}}recv:=func(idint){deferwg.Done()for{select{case<-done://getexitsignalfmt.Printf("receiver#%dexit\n",id)returncasei:=<-ch:fmt.Printf("receiver#%dget%d\n",id,i)time.Sleep(time.Millisecond)}}}wg.Add(6)gosend(0)gosend(1)gosend(2)gorecv(0)gorecv(1)gorecv(2)time.Sleep(time.Second)//signalfinishclose(done)//waitallsenderandreceiverexitwg.Wait()}总结Channle是最重要的特征golang的,用起来很爽。如果要在传统C中实现类型函数,一般需要使用socket或者FIFO来实现。另外,还需要考虑数据包的完整性和并发冲突。通道屏蔽了这些底层细节。用户只需要考虑读写就可以了。Channel是引用类型,了解channel的底层机制才能更好的使用channel。操作原语虽然简单,但是涉及到阻塞的问题。使用不当可能会导致死锁或无限创建协程,最终导致进程挂起。channel除了用于协程间通信外,由于其阻塞和唤醒协程的特性,还可以作为协程间的同步机制。文章还通过示例简单介绍了该场景下的用法。不需要关闭通道,只要没有引用通道的协程,最终都会被GC清理掉。所以在使用的时候要特别注意不要让协程阻塞在通道上。这种情况很难被察觉,会导致通道占用的资源以及阻塞在通道中的协程无法被GC清理,最终导致内存泄漏。Channle方便golang程序使用CSP编程范式,但golang是多范式编程语言,golang也支持传统的通过共享内存通信的编程方式。最终原则是根据场景选择合适的编程范式,不要因为通道好用就滥用CSP。