go-channel使用

channel的发展

CSP 是 Communicating Sequential Process 的简称,中文直译为通信顺序进程,或者叫做交换信息的循序进程,是用来描述并发系统中进行交互的一种模式。

CSP 最早出现于计算机科学家 Tony Hoare 在 1978 年发表的论文中(你可能不熟悉 Tony Hoare 这个名字,但是你一定很熟悉排序算法中的 Quicksort 算法,他就是 Quicksort 算法的作者,图灵奖的获得者)。最初,论文中提出的 CSP 版本在本质上不是一种进程演算,而是一种并发编程语言,但之后又经过了一系列的改进,最终发展并精炼出 CSP 的理论。

CSP 允许使用进程组件来描述系统,它们独立运行,并且只通过消息传递的方式通信。

Channel 类型是 Go 语言内置的类型,你无需引入某个包,就能使用它。虽然 Go 也提供了传统的并发原语,但是它们都是通过库的方式提供的,你必须要引入 sync 包或者 atomic 包才能使用它们,而 Channel 就不一样了,它是内置类型,使用起来非常方便。

Channel 和 Go 的另一个独特的特性 goroutine 一起为并发编程提供了优雅的、便利的、与传统并发控制不同的方案,并演化出很多并发模式。接下来,我们就来看一看 Channel 的应用场景。

Channel 的应用场景

我想先带你看一条 Go 语言中流传很广的谚语:
Don’t communicate by sharing memory, share memory by communicating.
Go Proverbs by Rob Pike

“执行业务处理的 goroutine 不要通过共享内存的方式通信,而是要通过 Channel 通信的方式分享数据。”

“communicate by sharing memory”和“share memory by communicating”是两种不同的并发处理模式。“communicate by sharing memory”是传统的并发编程处理方式,就是指,共享的数据需要用锁进行保护,goroutine 需要获取到锁,才能并发访问数据。

“share memory by communicating”则是类似于 CSP 模型的方式,通过通信的方式,一个 goroutine 可以把数据的“所有权”交给另外一个 goroutine(虽然 Go 中没有“所有权”的概念,但是从逻辑上说,你可以把它理解为是所有权的转移)。

从 Channel 的历史和设计哲学上,我们就可以了解到,Channel 类型和基本并发原语是有竞争关系的,它应用于并发场景,涉及到 goroutine 之间的通讯,可以提供并发的保护,等等。

综合起来,我把 Channel 的应用场景分为五种类型。这里你先有个印象,这样你可以有目的地去学习 Channel 的基本原理。下节课我会借助具体的例子,来带你掌握这几种类型。

  • 数据交流:当作并发的 buffer 或者 queue,解决生产者 - 消费者问题。多个 goroutine 可以并发当作生产者(Producer)和消费者(Consumer)。
  • 数据传递:一个 goroutine 将数据交给另一个 goroutine,相当于把数据的拥有权 (引用) 托付出去。
  • 信号通知:一个 goroutine 可以将信号 (closing、closed、data ready 等) 传递给另一个或者另一组 goroutine 。
  • 任务编排: 可以让一组 goroutine 按照一定的顺序并发或者串行的执行,这就是编排的功能。
  • 锁: 利用 Channel 也可以实现互斥锁的机制。

基本知识

分类:

  • 按是否有缓存分为unbufered channel和buffered channel
  • 按接收和发送权限分为chan <-chan chan<- , 分别为既能发又能收、只能收、只能发。

注意点:

  • chan有三种操作receive、send、close
  • 未初始化的chan(即nil chan)发送和接收都会阻塞
  • 接收数据时,如果接收到零值,也有可能是通道关闭,需要结合接收数据的第二个参数ok做判断。

channel的实现原理

qcount:代表 chan 中已经接收但还没被取走的元素的个数。内建函数 len 可以返回这个字段的值。
dataqsiz:队列的大小。chan 使用一个循环队列来存放元素,循环队列很适合这种生产者 - 消费者的场景(我很好奇为什么这个字段省略 size 中的 e)。
buf:存放元素的循环队列的 buffer。
elemtype 和 elemsize:chan 中元素的类型和 size。因为 chan 一旦声明,它的元素类型是固定的,即普通类型或者指针类型,所以元素大小也是固定的。
sendx:处理发送数据的指针在 buf 中的位置。一旦接收了新的数据,指针就会加上 elemsize,移向下一个位置。buf 的总大小是 elemsize 的整数倍,而且 buf 是一个循环列表。
recvx:处理接收请求时的指针在 buf 中的位置。一旦取出数据,此指针会移动到下一个位置。
recvq:chan 是多生产者多消费者的模式,如果消费者因为没有数据可读而被阻塞了,就会被加入到 recvq 队列中。
sendq:如果生产者因为 buf 满了而阻塞,会被加入到 sendq 队列中
lock mutex: 互斥锁,保护所有字段

初始化

func makechan(t *chantype, size int) *hchan {
elem := t.elem

    // 略去检查代码
    mem, overflow := math.MulUintptr(elem.size, uintptr(size))
    
//
var c *hchan
switch {
case mem == 0:
  // chan的size或者元素的size是0,不必创建buf
  c = (*hchan)(mallocgc(hchanSize, nil, true))
  c.buf = c.raceaddr()
case elem.ptrdata == 0:
  // 元素不是指针,分配一块连续的内存给hchan数据结构和buf
  c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
        // hchan数据结构后面紧接着就是buf
  c.buf = add(unsafe.Pointer(c), hchanSize)
default:
  // 元素包含指针,那么单独分配buf
  c = new(hchan)
  c.buf = mallocgc(mem, elem, true)
}

    // 元素大小、类型、容量都记录下来
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)

return c

}
初始化时,针对不同的容量和元素类型,这段代码分配了不同的对象来初始化 hchan 对象的字段,返回 hchan 对象。

send

func chansend1(c *hchan, elem unsafe.Pointer) {
    chansend(c, elem, true, getcallerpc())
}
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
        // 第一部分
    if c == nil {
      if !block {
        return false
      }
      gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
      throw("unreachable")
    }
      ......
  }

最开始,第一部分是进行判断:如果 chan 是 nil 的话,就把调用者 goroutine park(阻塞休眠), 调用者就永远被阻塞住了,所以,第 11 行是不可能执行到的代码。


  // 第二部分,如果chan没有被close,并且chan满了,直接返回
    if !block && c.closed == 0 && full(c) {
      return false
  }

第二部分的逻辑是当你往一个已经满了的 chan 实例发送数据时,并且想不阻塞当前调用,那么这里的逻辑是直接返回。chansend1 方法在调用 chansend 的时候设置了阻塞参数,所以不会执行到第二部分的分支里。


  // 第三部分,chan已经被close的情景
    lock(&c.lock) // 开始加锁
    if c.closed != 0 {
      unlock(&c.lock)
      panic(plainError("send on closed channel"))
  }

第三部分显示的是,如果 chan 已经被 close 了,再往里面发送数据的话会 panic。
(从第三部分已经开始加锁)


      // 第四部分,从接收队列中出队一个等待的receiver
        if sg := c.recvq.dequeue(); sg != nil {
      // 
      send(c, sg, ep, func() { unlock(&c.lock) }, 3)
      return true
    }

第四部分,如果等待队列中有等待的 receiver,那么这段代码就把它从队列中弹出,然后直接把数据交给它(通过 memmove(dst, src, t.size)),而不需要放入到 buf 中,速度可以更快一些。
(为什么有recvq就证明buf中无数据,因为只有buf中无数据却有recv的go routine,才会将将此goroutine加入到recvq;同样sendq也是如此,只有满了时才会加入)


    // 第五部分,buf还没满
      if c.qcount < c.dataqsiz {
      qp := chanbuf(c, c.sendx)
      if raceenabled {
        raceacquire(qp)
        racerelease(qp)
      }
      typedmemmove(c.elemtype, qp, ep)
      c.sendx++
      if c.sendx == c.dataqsiz {
        c.sendx = 0
      }
      c.qcount++
      unlock(&c.lock)
      return true
    }

第五部分说明当前没有 receiver,需要把数据放入到 buf 中,放入之后,就成功返回了。


      // 第六部分,buf满。
        // chansend1不会进入if块里,因为chansend1的block=true
        if !block {
      unlock(&c.lock)
      return false
    }
        ......

第六部分是处理 buf 满的情况。如果 buf 满了,发送者的 goroutine 就会加入到发送者的等待队列中,直到被唤醒。这个时候,数据或者被取走了,或者 chan 被 close 了。

recv

在处理从 chan 中接收数据时,Go 会把代码转换成 chanrecv1 函数,如果要返回两个返回值,会转换成 chanrecv2,chanrecv1 函数和 chanrecv2 会调用 chanrecv。我们分段学习它的逻辑:


    func chanrecv1(c *hchan, elem unsafe.Pointer) {
    chanrecv(c, elem, true)
  }
  func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {
    _, received = chanrecv(c, elem, true)
    return
  }

    func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
        // 第一部分,chan为nil
    if c == nil {
      if !block {
        return
      }
      gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
      throw("unreachable")
    }

chanrecv1 和 chanrecv2 传入的 block 参数的值是 true,都是阻塞方式,所以我们分析 chanrecv 的实现的时候,不考虑 block=false 的情况。

第一部分是 chan 为 nil 的情况。和 send 一样,从 nil chan 中接收(读取、获取)数据时,调用者会被永远阻塞。


  // 第二部分, block=false且c为空
    if !block && empty(c) {
      ......
    }

第二部分你可以直接忽略,因为不是我们这次要分析的场景。

      // 加锁,返回时释放锁
      lock(&c.lock)
      // 第三部分,c已经被close,且chan为空empty
    if c.closed != 0 && c.qcount == 0 {
      unlock(&c.lock)
      if ep != nil {
        typedmemclr(c.elemtype, ep)
      }
      return true, false
    }

第三部分是 chan 已经被 close 的情况。如果 chan 已经被 close 了,并且队列中没有缓存的元素,那么返回 true、false。
(所以,close的chanel再次一直做recv,如果有缓存,还是先取缓存中数据,知道缓存中数据取完,才会取到代表关闭的零值和关闭标志)
(从第三部分开始加锁,且和send是同一把锁)

     // 第四部分,如果sendq队列中有等待发送的sender
        if sg := c.sendq.dequeue(); sg != nil {
      recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
      return true, true
    }

第四部分是处理 buf 满的情况。这个时候,如果是 unbuffer 的 chan,就直接将 sender 的数据复制给 receiver,否则就从队列头部读取一个值,并把这个 sender 的值加入到队列尾部。
(可以看到是数据是FIFO形式)


      // 第五部分, 没有等待的sender, buf中有数据
    if c.qcount > 0 {
      qp := chanbuf(c, c.recvx)
      if ep != nil {
        typedmemmove(c.elemtype, ep, qp)
      }
      typedmemclr(c.elemtype, qp)
      c.recvx++
      if c.recvx == c.dataqsiz {
        c.recvx = 0
      }
      c.qcount--
      unlock(&c.lock)
      return true, true
    }

    if !block {
      unlock(&c.lock)
      return false, false
    }

        // 第六部分, buf中没有元素,阻塞
        ......

第五部分是处理没有等待的 sender 的情况。这个是和 chansend 共用一把大锁,所以不会有并发的问题。如果 buf 有元素,就取出一个元素给 receiver。
第六部分是处理 buf 中没有元素的情况。如果没有元素,那么当前的 receiver 就会被阻塞,直到它从 sender 中接收了数据,或者是 chan 被 close,才返回。

close

通过 close 函数,可以把 chan 关闭,编译器会替换成 closechan 方法的调用。
下面的代码是 close chan 的主要逻辑。如果 chan 为 nil,close 会 panic;如果 chan 已经 closed,再次 close 也会 panic。否则的话,如果 chan 不为 nil,chan 也没有 closed,就把等待队列中的 sender(writer)和 receiver(reader)从队列中全部移除并唤醒。


    func closechan(c *hchan) {
    if c == nil { // chan为nil, panic
      panic(plainError("close of nil channel"))
    }
  
    lock(&c.lock)
    if c.closed != 0 {// chan已经closed, panic
      unlock(&c.lock)
      panic(plainError("close of closed channel"))
    }

    c.closed = 1  

    var glist gList

    // 释放所有的reader
    for {
      sg := c.recvq.dequeue()
      ......
      gp := sg.g
      ......
      glist.push(gp)
    }
  
    // 释放所有的writer (它们会panic)
    for {
      sg := c.sendq.dequeue()
      ......
      gp := sg.g
      ......
      glist.push(gp)
    }
    unlock(&c.lock)
  
    for !glist.empty() {
      gp := glist.pop()
      gp.schedlink = 0
      goready(gp, 3)
    }
  }

可以看到,close时并没有清除buf中的元素,如果在buf有数据时close,接着receive,仍能取到元素,且ok标志为true,只有取完所有元素后在receive,才会获取到零值和false标志。

使用channel常犯错误

使用 Channel 最常见的错误是 panic 和 goroutine 泄漏。
首先,我们来总结下会 panic 的情况,总共有 3 种:

  1. close 为 nil 的 chan;
  2. send 已经 close 的 chan;
  3. close 已经 close 的 chan。

还有go routine泄露的问题,即go routine一直阻塞,永远结束不了。
(正常情况下,如果收发chan的双方go routine都dead了,则chan内存连同buf会自动回收.因此如果我们能够确信某个 channel 不会使其通信的 goroutine 发生阻塞,则不必将其关闭,因为垃圾回收器会帮我们进行处理。)
如下例子中,使用unbuffer的chan,在没有receiver时,发送者一直在阻塞

  func process(timeout time.Duration) bool {
    ch := make(chan bool)

    go func() {
        // 模拟处理耗时的业务
        time.Sleep((timeout + time.Second))
        ch <- true // block
        fmt.Println("exit goroutine")
    }()
    
    select {
    case result := <-ch:
        return result
    case <-time.After(timeout):
        return false
    }
}

Go 的开发者极力推荐使用 Channel,不过,这两年,大家意识到,Channel 并不是处理并发问题的“银弹”,有时候使用并发原语更简单,而且不容易出错。所以,我给你提供一套选择的方法:

  • 共享资源的并发访问使用传统并发原语
  • 复杂的任务编排和消息传递使用channel
  • 消息通知使用channel,除非只想signal一个goroutine,才使用cond
    (只想唤醒一个goroutine 也可以用channel。比如一个有缓冲的chan,唤醒一个就往里面add一个数据。如果想唤醒所有的就close。 什么时候使用 cond?比如全部唤醒之后,还会进入全部休眠,可以重复唤醒使用cond)
  • 简单等待所有任务完成用WaitGroup,也有 Channel 的推崇者用 Channel,都可以;
  • 需要和Select语句结合,使用channel
  • 需要和超时配合时,使用channel和Context

总结:
chan 的值和状态有多种情况,而不同的操作(send、recv、close)又可能得到不同的结果,这是使用 chan 类型时经常让人困惑的地方。

为了帮助你快速地了解不同状态下各种操作的结果,我总结了一个表格,你一定要特别关注下那些 panic 的情况,另外还要掌握那些会 block 的场景,它们是导致死锁或者 goroutine 泄露的罪魁祸首。

还有一个值得注意的点是,只要一个 chan 还有未读的数据,即使把它 close 掉,你还是可以继续把这些未读的数据消费完,之后才是读取零值数据。

nilemptyfullnot (full or empty)closed
receiveblockblockread valueread value返回未读的元素,读完后返回零值
sendblockwrited valueblockwrite valuepanic
closepanicclosed,没有未读元素closed,保留未读元素closed,保留未读元素panic

按顺序打印

func main() {
	var wg sync.WaitGroup

	chan_list := []chan int{
		make(chan int),
		make(chan int),
		make(chan int),
		make(chan int),
	}

	for i := 0; i < 4; i ++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			for {
				<- chan_list[i]
				fmt.Println(i+1)
				time.Sleep(time.Second)
				chan_list[(i+1) % 4] <- 1
			}

		}(i)
	}
	chan_list[0] <- 1

	wg.Wait()
	fmt.Printf("main end\n")
}
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值