【导读】本文梳理了 go 语言有缓冲和无缓冲的 channel 的实现与作用。
Go 中的 channel 十分强大,理解 channel 的内部机制后再去使用它可以发挥出更大威力。另外,选择使用有缓冲 channel 还是无缓冲 channel 会影响到我们程序的行为表现,以及性能。
无缓冲 channel 在消息发送时需要接收者就绪。声明无缓冲 channel 的方式是不指定缓冲大小。以下是一个列子:
package main
import (
"sync"
"time"
)
func main() {
c := make(chan string)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
c <- `foo`
}()
go func() {
defer wg.Done()
time.Sleep(time.Second * 1)
println(`Message: `+ <-c)
}()
wg.Wait()
}
第一个协程会在发送消息foo
时阻塞,原因是接收者还没有就绪:这个特性在标准文档中描述如下:
如果缓冲大小设置为 0 或者不设置,channel 为无缓冲类型,通信成功的前提是发送者和接收者都处于就绪状态。
effective Go文档也有相应的描述:
无缓冲 channel,发送者会阻塞直到接收者接收了发送的值。
为了更好的理解 channel 的特性,接下来我们分析 channel 的内部结构。
channel 的结构体hchan
被定义在runtime
包中的chan.go
文件中。以下是无缓冲 channel 的内部结构(本小节先介绍无缓冲 channel,所以暂时忽略了hchan
结构体中和缓冲相关的属性):
channel 中持有两个链表,接收者链表recvq
和发送者链表sendq
,它们的类型是waitq
。链表中的元素为sudog
结构体类型,它包含了发送者或接收者的协程相关的信息。通过这些信息,Go 可以在发送者不存在时阻塞住接收者,反之亦然。
以下是我们前一个例子的流程:
foo
变量的值,第 16 行。sudog
结构体变量,用于表示发送者。sudog 结构体会保持对发送者所在协程的引用,以及foo
的引用。sendq
队列。sendq
列表中等待状态的发送者出队列。memmove
函数将发送者要发送的值进行拷贝,包装入sudog
结构体,再传递给 channel 接收者的接收变量。sudog
结构体。如流程所描述,发送者协程阻塞直至接收者就绪。但是,必要的时候,我们可以使用有缓冲 channel 来避免这种阻塞。
简单修改前面的例子,为 channel 添加缓冲,如下:
package main
import (
"sync"
"time"
)
func main() {
c := make(chan string, 2)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
c <- `foo`
c <- `bar`
}()
go func() {
defer wg.Done()
time.Sleep(time.Second * 1)
println(`Message: `+ <-c)
println(`Message: `+ <-c)
}()
wg.Wait()
}
通过这个例子,我们来分析hchan
结构体中与缓冲相关的属性:
缓冲相关的五个属性:
qcount
当前缓冲中元素个数dataqsize
缓冲最大数量buf
指向缓冲区内存,这块内存空间可容纳dataqsize
个元素sendx
缓冲区中下一个元素写入时的位置recvx
缓冲区中下一个被读取的元素的位置通过sendx
和recvx
,缓冲区工作机制类似于环形队列:
环形队列使得我们可以保证缓冲区有序,并且不需要在每次取出元素时对缓冲区重新排序。
当缓冲区满了时,向缓冲区添加元素的协程将被加入sender
链表中,并且切换到等待状态,就像我们在上一节描述的那样。之后,当程序读取缓冲区时,recvx
位置的元素将被返回,等待状态的协程将恢复执行,它要发送的值将被存入缓冲区。这使得 channel 能够保证先进先出
的特性。
创建 channel 时指定的缓冲区大小,可能会对性能造成巨大的影响。下面是对不同缓冲区大小的 channel 做的压力测试代码:
package bench
import (
"sync"
"sync/atomic"
"testing"
)
func BenchmarkWithNoBuffer(b *testing.B) {
benchmarkWithBuffer(b, 0)
}
func BenchmarkWithBufferSizeOf1(b *testing.B) {
benchmarkWithBuffer(b, 1)
}
func BenchmarkWithBufferSizeEqualsToNumberOfWorker(b *testing.B) {
benchmarkWithBuffer(b, 5)
}
func BenchmarkWithBufferSizeExceedsNumberOfWorker(b *testing.B) {
benchmarkWithBuffer(b, 25)
}
func benchmarkWithBuffer(b *testing.B, size int) {
for i := 0; i < b.N; i++ {
c := make(chan uint32, size)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := uint32(0); i < 1000; i++ {
c <- i%2
}
close(c)
}()
var total uint32
for w := 0; w < 5; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
v, ok := <-c
if !ok {
break
}
atomic.AddUint32(&total, v)
}
}()
}
wg.Wait()
}
}
在这个测试程序中,包含一个生产者,向 channel 中发送整型元素;包含多个消费者,从 channel 中读取数据,并将它们原子的加入变量total
中。
运行这个测试十次,并通过benchstat
分析结果:
name time/op
WithNoBuffer-8 306µs ± 3%
WithBufferSizeOf1-8 248µs ± 1%
WithBufferSizeEqualsToNumberOfWorker-8 183µs ± 4%
WithBufferSizeExceedsNumberOfWorker-8 134µs ± 2%
说明合适的缓冲区大小确实会使得程序执行得更快!让我们来分析测试程序以确认耗时反生在何处。
通过 Go 工具 trace 中的synchronization blocking profile
来查看测试程序被同步原语阻塞所消耗的时间。接收时的耗时对比:无缓冲 channel 为 9 毫秒,缓冲大小为 50 的 channel 为 1.9 毫秒。
发送时的耗时对比:有缓冲 channel 将耗时缩小了五倍。
可以得出结论,缓冲区的大小确实在程序性能方面扮演了重要角色。
转自:
https://zhuanlan.zhihu.com/p/101063277
- EOF -
Go 开发大全
参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。
关注后获取
回复 Go 获取6万star的Go资源库
分享、点赞和在看
支持我们分享更多好文章,谢谢!