要想彻底理解 channel,要抓住几个点:
- channel 带不带缓冲
- 谁在发
- 谁在收
- 谁来关
- 以及,关了没?
3.1、缓冲
- 不带缓冲:要求收发两端都必须要有 goroutine,否则就是阻塞。
- 带缓冲:没满或者没空之前都不会阻塞。但是满了或者空了就会阻塞。
总结:
- 对于发送者来说,只要发出去的数据没有地方放,那就是阻塞
- 对于接收者来说,只要尝试接收数据但是没拿到,那也会阻塞
3.2、使用方向
利用 channel 的思路:
- 看做是队列,主要用于传递数据
- 利用阻塞特性,可以间接控制住 goroutine 或者其它资源的消耗。这种用法有点像是令牌机制。那么往 channel 里面读或者取一个数据,就有点像是拿到一个令牌,拿到令牌才可以做某件事
3.3、发布订阅模式
利用 channel 实现发布订阅模式非常简单,发布者不断往 channel 里面塞入数据,订阅者从 channel里面取出数据。进程内的事件驱动可以依托于 channel 来实现。
缺陷:
- 没有消费组概念。不能说同一个事件被多个goroutine 同时消费,有且只能有一个
- 无法回退,也无法随机消费
3.4、实现消息队列
例子:利用 channel 来实现一个基于内存的消息队列,并且有消费组的概念。
思路:这个例子,难就难在 channel 里面的元素只能被一个 goroutine 取出来。所以要想同一个消息能够被多 goroutine 消费,就需要费一点手脚。
- 方案一:每一个消费者订阅的时候,创建一个子channel
- 方案二:轮询所有的消费者
package syn
type Consumer struct {
ch chan string
}
type Broker struct {
ch chan string
comsumers []*Consumer
}
func (b *Broker) Subscribe(c *Consumer) {
b.comsumers = append(b.comsumers, c)
}
func (b *Broker) Produce(msg string) {
for _, c := range b.comsumers {
c.ch <- msg
}
}
复制代码
3.5、代码演示
(1) 实现一个任务池
例子:利用 channel 来实现一个任务池。该任务池允许开发者提交任务,并且设定最多多少个goroutine 同时运行。
思路:这个东西实现起来并不难,而是难在决策,要考虑:
- 提交任务的时候,如果执行 goroutine 满了,任务池是缓存住这个任务,还是直接阻塞提交者?
- 如果缓存,那么缓存需要多大?缓存满了又该怎么办?
package syn
import "sync"
var taskPoolWithCache *TaskPoolWithCache
var once sync.Once
type TaskPoolWithCache struct {
cache chan func()
maxTaskNum int
}
func NewTaskPoolWithCache(limit, cacheSize int) *TaskPoolWithCache {
once.Do(func() {
taskPoolWithCache = &TaskPoolWithCache{
cache: make(chan func(), cacheSize),
maxTaskNum: limit,
}
})
return taskPoolWithCache
}
func (t *TaskPoolWithCache) PutTask(f func()) {
// 这里采用直接阻塞提交者的方式
t.cache <- f
}
func (t *TaskPoolWithCache) ExcuteTask() {
// 直接把 goroutine 开好
for i := 0; i < t.maxTaskNum; i++ {
go func() {
for {
// 在 goroutine 里面不断尝试从 cache 里面拿到任务
select {
case task, ok := <-t.cache:
if !ok {
return
}
task()
}
}
}()
}
}
复制代码
3.6 channel 与 goroutine 泄露
如果 channel 使用不当,就会导致 goroutine 泄露:
- 只发送不接收,那么发送者一直阻塞,会导致发送者 goroutine 泄露
- 只接收不发送,那么接收者一直阻塞,会导致接收者 goroutine 泄露
- 读写 nil 都会导致 goroutine 泄露
基本上可以说,goroutine 泄露都是因为goroutine 被阻塞之后没有人唤醒它导致的。唯一的例外是业务层面上 goroutine 长时间运行。
3.7 channel 与 内存逃逸
内存分配:
- 分配到栈上:不需要考虑 GC
- 分配到堆上:需要考虑 GC
很不幸的,如果用 channel 发送指针,那么必然逃逸。编译器无法确定,发送的指针数据最终会被哪个 goroutine接收!
3.8 实现细节
经过前面的学习,我们应该对 Go 怎么设计并发结构体有点心得体会了。那么我们先思考一下设计这样的 chan 结构体有什么问题要考虑:
- 要设计缓冲来存储数据。无缓冲=缓冲容量为0
- 要能阻塞 goroutine,也要能唤醒 goroutine。这个基本依赖于 Go 的运行时:
- 发数据唤醒收数据时
- 收数据的唤醒发数据时
- 要维持住 goroutine 的等待队列,并且是收和发两个队列
- buf 是一个 ring buffer 结构,用于存储数据
- waitq 是一个双向链表
所以简单说:
- 发送的时候,如果缓冲没满,或者有接收者,那就直接发;否则丢进去 sendq。
- 接收的时候,如果缓冲有数据,或者说有发送者,那就收;否则丢进去 recvq。
(1) chansend
在有接收者阻塞的情况下,即便缓冲没满,发送者也是直接交付给接收者。
- 这个 KeepAlive 确保 ep 不会垃圾回收掉
- 实际上就是确保,发送的数据不会被垃圾回收掉
- 一般是和SetFinalizer 结合使用
- 个人建议是不要用
步骤:
- 看是不是 nil channel,是的话直接阻塞
- 看有没有被阻塞的接受者,有的话直接交付数据,返回
- 看看缓冲有没有满,没有就放缓冲,返回
- 阻塞,等待接收者来唤醒自己
- 被唤醒,做些清理工作
剩余的细节源码不必纠缠,因为你完全可以根据这个步骤实现同样的功能,但是代码肯定和源码不一样。
(2) chanrecv
在有发送者阻塞的情况下,即便缓冲有数据,接收者也是直接拿发送者的数据。
步骤:
- 看是不是 nil channel,是的话直接阻塞
- 看有没有被阻塞的发送者,有的话直接从发送者手里拿,返回
- 看看缓冲有没有数据,有就读缓冲,返回
- 阻塞,等待发送者来唤醒自己
- 被唤醒,做些清理工作
3.9 开源实例
(1) Kratos 的启动过程
这是一个综合的用例:
- errgroup + context.Context 协调 server 启动过程,以及关闭
- channel 监听系统信号
- WaitGroup 协调所有 server 启动
- Context 设置超时
作为启动过程要考虑:
- 监听系统关闭信号
- 监控 server 启动过程。如果有一个启动失败,那么应该全部直接失败,退出
- 监控 server 异常退出