Go channel

要想彻底理解 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 结合使用
  • 个人建议是不要用

步骤:

  1. 看是不是 nil channel,是的话直接阻塞
  2. 看有没有被阻塞的接受者,有的话直接交付数据,返回
  3. 看看缓冲有没有满,没有就放缓冲,返回
  4. 阻塞,等待接收者来唤醒自己
  5. 被唤醒,做些清理工作

剩余的细节源码不必纠缠,因为你完全可以根据这个步骤实现同样的功能,但是代码肯定和源码不一样。

(2) chanrecv

在有发送者阻塞的情况下,即便缓冲有数据,接收者也是直接拿发送者的数据。

步骤:

  1. 看是不是 nil channel,是的话直接阻塞
  2. 看有没有被阻塞的发送者,有的话直接从发送者手里拿,返回
  3. 看看缓冲有没有数据,有就读缓冲,返回
  4. 阻塞,等待发送者来唤醒自己
  5. 被唤醒,做些清理工作

3.9 开源实例

(1) Kratos 的启动过程

这是一个综合的用例:

  1. errgroup + context.Context 协调 server 启动过程,以及关闭
  2. channel 监听系统信号
  3. WaitGroup 协调所有 server 启动
  4. Context 设置超时

作为启动过程要考虑:

  1. 监听系统关闭信号
  2. 监控 server 启动过程。如果有一个启动失败,那么应该全部直接失败,退出
  3. 监控 server 异常退出
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值