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

 

posted @ 2023-05-15 17:33  浮尘微光  阅读(826)  评论(0)    收藏  举报