Go语言中 无缓冲 channel 和带缓冲 channel 的用法
无缓冲 channel 的用法
无缓冲 channel 兼具通信和同步特性,在并发程序中应用颇为广泛。现在我们来看看几个无缓冲 channel 的典型应用:
第一种用法:用作信号传递
无缓冲 channel 用作信号传递的时候,有两种情况,分别是 1 对 1 通知信号和 1 对 n 通知信号。我们先来分析下 1 对 1 通知信号这种情况。
// 无缓冲 channel 用作信号传递的时候, 1 对 1 通知信号 package main import ( "fmt" "time" ) type signal struct{} func worker() { println("worker is working...") time.Sleep(1 * time.Second) } func spawn(f func()) <-chan signal { c := make(chan signal) go func() { println("worker start to work...") f() c <- signal{} }() return c } func main() { println("start a worker...") c := spawn(worker) <-c fmt.Println("worker work done!") } /* start a worker... worker start to work... worker is working... worker work done! */
无缓冲 channel 还被用来实现 1 对 n 的信号通知机制。这样的信号通知机制,常被用于协调多个 Goroutine 一起工作。
关闭一个无缓冲 channel 会让所有阻塞在这个 channel 上的接收操作返回,从而实现了一种 1 对 n 的“广播”机制。
// 无缓冲 channel 还被用来实现 1 对 n 的信号通知机制。这样的信号通知机制,常被用于协调多个 Goroutine 一起工作 package main import ( "fmt" "sync" "time" ) // 通知信号 type signal struct{} func worker(i int) { fmt.Printf("worker%d: 正工作中\n", i) time.Sleep(time.Second) } func spawnGroup(f func(i int), num int, groupSignal <-chan signal) <-chan signal { c := make(chan signal) var wg sync.WaitGroup for i := 0; i < num; i++ { wg.Add(1) go func(i int) { <-groupSignal fmt.Printf("worker %d: 开始工作\n", i) f(i) wg.Done() }(i + 1) } go func() { wg.Wait() c <- signal{} }() return c } func main() { println("开始一个worker group") groupSignal := make(chan signal) // 创建5个worker goroutine,并阻塞在无缓冲groupSignal上 c := spawnGroup(worker, 5, groupSignal) time.Sleep(5 * time.Second) fmt.Println("工作组中的工人开始工作") // 停止阻塞,同时开始工作 close(groupSignal) <-c fmt.Println("worker group工作结束") } /* 开始一个worker group 工作组中的工人开始工作 worker 2: 开始工作 worker2: 正工作中 worker 5: 开始工作 worker5: 正工作中 worker 4: 开始工作 worker4: 正工作中 worker 3: 开始工作 worker3: 正工作中 worker 1: 开始工作 worker1: 正工作中 worker group工作结束 */
第二种用法:用于替代锁机制
无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让程序更加清晰,可读性也更好。
首先看一个传统的、基于“共享内存”+“互斥锁”的 Goroutine 安全的计数器的实现:
// 无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让程序更加清晰,可读性也更好。 // 首先看一个传统的、基于“共享内存”+“互斥锁”的 Goroutine 安全的计数器的实现: // 使用了一个带有互斥锁保护的全局变量作为计数器,所有要操作计数器的 Goroutine 共享这个全局变量,并在互斥锁的同步下对计数器进行自增操作。 package main import ( "fmt" "sync" ) type counter struct { sync.Mutex i int } var cter counter func Increase() int { cter.Lock() defer cter.Unlock() cter.i++ return cter.i } func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(i int) { v := Increase() fmt.Printf("goroutine-%d: current counter value is %d\n", i, v) wg.Done() }(i) } wg.Wait() } /* goroutine-9: current counter value is 4 goroutine-0: current counter value is 1 goroutine-5: current counter value is 6 goroutine-7: current counter value is 7 goroutine-6: current counter value is 9 goroutine-2: current counter value is 10 goroutine-4: current counter value is 5 goroutine-1: current counter value is 2 goroutine-3: current counter value is 3 goroutine-8: current counter value is 8 */
再看更符合 Go 设计惯例的实现,也就是使用无缓冲 channel 替代锁后的实现:
// 再看更符合 Go 设计惯例的实现,也就是使用无缓冲 channel 替代锁后的实现: // 将计数器操作全部交给一个独立的 Goroutine 去处理,并通过无缓冲 channel 的同步阻塞特性,实现了计数器的控制。 // 这样其他 Goroutine 通过 Increase 函数试图增加计数器值的动作,实质上就转化为了一次无缓冲 channel 的接收动作。 // 这种并发设计逻辑更符合 Go 语言所倡导的“不要通过共享内存来通信,而是通过通信来共享内存”的原则。 package main import ( "fmt" "sync" ) type counter struct { c chan int i int } func NewCounter() *counter { cter := &counter{ c: make(chan int), } go func() { for { cter.i++ cter.c <- cter.i } }() return cter } func (cter *counter) Increase() int { return <-cter.c } func main() { cter := NewCounter() var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go func(i int) { v := cter.Increase() fmt.Printf("goroutine-%d: current counter value is %d\n", i, v) wg.Done() }(i) } wg.Wait() } /* goroutine-0: current counter value is 1 goroutine-3: current counter value is 7 goroutine-9: current counter value is 3 goroutine-7: current counter value is 6 goroutine-2: current counter value is 10 goroutine-6: current counter value is 5 goroutine-4: current counter value is 8 goroutine-1: current counter value is 2 goroutine-8: current counter value is 9 goroutine-5: current counter value is 4 */
带缓冲 channel 的用法
带缓冲的 channel 与无缓冲的 channel 的最大不同之处,就在于它的异步性。也就是说,对一个带缓冲 channel,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 不会阻塞挂起;在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。这种特性让带缓冲的 channel 有着与无缓冲 channel 不同的应用场合。
第一种用法:用作消息队列
channel 经常被 Go 初学者视为在多个 Goroutine 之间通信的消息队列,这是因为,channel 的原生特性与我们认知中的消息队列十分相似,包括 Goroutine 安全、有 FIFO(first-in, first out)保证等。
其实,和无缓冲 channel 更多用于信号 / 事件管道相比,可自行设置容量、异步收发的带缓冲channel 更适合被用作为消息队列,并且,带缓冲 channel 在数据收发的性能上要明显好于无缓冲 channel。
我们可以通过对 channel 读写的基本测试来印证这一点。下面是一些关于无缓冲 channel 和带缓冲 channel 收发性能测试的结果(Go 1.17, MacBook Pro 8 核)。
基准测试的代码可以到这里下载。
- 单接收单发送性能的基准测试我们先来看看针对一个 channel 只有一个发送 Goroutine 和一个接收 Goroutine 的情况,两种 channel 的收发性能比对数据:
// 无缓冲channel // go-channel-operation-benchmark/unbuffered-chan $go test -bench . one_to_one_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz BenchmarkUnbufferedChan1To1Send-8 6037778 199.7 ns/op BenchmarkUnbufferedChan1To1Recv-8 6286850 194.5 ns/op PASS ok command-line-arguments 2.833s // 带缓冲channel // go-channel-operation-benchmark/buffered-chan $go test -bench . one_to_one_cap_10_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz BenchmarkBufferedChan1To1SendCap10-8 17089879 66.16 ns/op BenchmarkBufferedChan1To1RecvCap10-8 18043450 65.57 ns/op PASS ok command-line-arguments 2.460s
然后我们将 channel 的缓存由 10 改为 100,再看看带缓冲 channel 的 1 对 1 基准测试结果:
$go test -bench . one_to_one_cap_100_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz BenchmarkBufferedChan1To1SendCap100-8 23089318 53.06 ns/op BenchmarkBufferedChan1To1RecvCap100-8 23474095 51.33 ns/op PASS ok command-line-arguments 2.542s
- 多接收多发送性能基准测试我们再来看看,针对一个 channel 有多个发送 Goroutine 和多个接收 Goroutine 的情况,两种 channel 的收发性能比对数据(这里建立 10 个发送 Goroutine 和 10 个接收 Goroutine):
// 无缓冲channel // go-channel-operation-benchmark/unbuffered-chan $go test -bench . multi_to_multi_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz BenchmarkUnbufferedChanNToNSend-8 293930 3779 ns/op BenchmarkUnbufferedChanNToNRecv-8 280904 4190 ns/op PASS ok command-line-arguments 2.387s // 带缓冲channel // go-channel-operation-benchmark/buffered-chan $go test -bench . multi_to_multi_cap_10_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz BenchmarkBufferedChanNToNSendCap10-8 736540 1609 ns/op BenchmarkBufferedChanNToNRecvCap10-8 795416 1616 ns/op PASS ok command-line-arguments 2.514s
这里我们也将 channel 的缓存由 10 改为 100 后,看看带缓冲 channel 的多对多基准测试结果:
$go test -bench . multi_to_multi_cap_100_test.go goos: darwin goarch: amd64 cpu: Intel(R) Core(TM) i5-8257U CPU @ 1.40GHz BenchmarkBufferedChanNToNSendCap100-8 1236453 966.4 ns/op BenchmarkBufferedChanNToNRecvCap100-8 1279766 969.4 ns/op PASS ok command-line-arguments 4.309s
综合前面这些结果数据,我们可以得出几个初步结论:
- 无论是 1 收 1 发还是多收多发,带缓冲 channel 的收发性能都要好于无缓冲 channel;
- 对于带缓冲 channel 而言,发送与接收的 Goroutine 数量越多,收发性能会有所下降;
- 对于带缓冲 channel 而言,选择适当容量会在一定程度上提升收发性能。
不过你要注意的是,Go 支持 channel 的初衷是将它作为 Goroutine 间的通信手段,它并不是专门用于消息队列场景的。如果你的项目需要专业消息队列的功能特性,比如支持优先级、支持权重、支持离线持久化等,那么 channel 就不合适了,可以使用第三方的专业的消息队列实现。第二种用法:用作计数信号量(counting semaphore)Go 并发设计的一个惯用法,就是将带缓冲 channel 用作计数信号量(counting semaphore)。带缓冲 channel 中的当前数据个数代表的是,当前同时处于活动状态(处理业务)的 Goroutine 的数量,而带缓冲 channel 的容量(capacity),就代表了允许同时处于活动状态的 Goroutine 的最大数量。向带缓冲 channel 的一个发送操作表示获取一个信号量,而从 channel 的一个接收操作则表示释放一个信号量。
这里我们来看一个将带缓冲 channel 用作计数信号量的例子:
var active = make(chan struct{}, 3) var jobs = make(chan int, 10) func main() { go func() { for i := 0; i < 8; i++ { jobs <- (i + 1) } close(jobs) }() var wg sync.WaitGroup for j := range jobs { wg.Add(1) go func(j int) { active <- struct{}{} log.Printf("handle job: %d\n", j) time.Sleep(2 * time.Second) <-active wg.Done() }(j) } wg.Wait() }
我们看到,这个示例创建了一组 Goroutine 来处理 job,同一时间允许最多 3 个 Goroutine 处于活动状态。
为了达成这一目标,我们看到这个示例使用了一个容量(capacity)为 3 的带缓冲 channel: active 作为计数信号量,这意味着允许同时处于活动状态的最大 Goroutine 数量为 3。
我们运行一下这个示例:
2022/01/02 10:08:55 handle job: 1 2022/01/02 10:08:55 handle job: 4 2022/01/02 10:08:55 handle job: 8 2022/01/02 10:08:57 handle job: 5 2022/01/02 10:08:57 handle job: 7 2022/01/02 10:08:57 handle job: 6 2022/01/02 10:08:59 handle job: 3 2022/01/02 10:08:59 handle job: 2
从示例运行结果中的时间戳中,我们可以看到,虽然我们创建了很多 Goroutine,但由于计数信号量的存在,同一时间内处于活动状态(正在处理 job)的 Goroutine 的数量最多为 3 个。
参考资料:
https://gitee.com/rxbook/go-demo-2023/tree/master/basic/go03/goroutine2
https://time.geekbang.org/column/article/477365
https://github.com/bigwhite/publication/tree/master/column/timegeek/go-first-course/33/go-channel-operation-benchmark

浙公网安备 33010602011771号