Go语言大厂编程 Channel 管道通讯

3,749 阅读14分钟

不要通过共享内存来通信,而要通过通信来实现内存共享。

底层数据结构

底层数据结构需要看 hchan 源码:

type hchan struct {
   //chan 里元素数量
   qcount   uint
   //chan 底层循环数组的长度
   dataqsiz uint
   // 指向底层循环数组的指针
   // 只针对有缓冲的 channel
   buf      unsafe.Pointer
   // chan 中元素大小
   elemsize uint16
   // chan 是否被关闭的标志
   closed   uint32
   // chan 中元素类型
   elemtype *_type
   // 已发送元素在循环数组中的索引
   sendx    uint
   // 已接收元素在循环数组中的索引
   recvx    uint
   // 等待接收的 goroutine 队列
   recvq    waitq
   // 等待发送的 goroutine 队列
   sendq    waitq
   // 保护 hchan 中所有字段
   lock mutex
}

buf 指向底层循环数组,只有缓冲型的 channel 才有。

sendxrecvx 均指向底层循环数组,表示当前可以发送和接收的元素位置索引值(相对于底层数组)。

sendqrecvq 分别表示被阻塞的 goroutine,这些 goroutine 由于尝试读取 channel或向 channel 发送数据而被阻塞。

waitqsudog 的一个双向链表,而 sudog 实际上是对 goroutine 的一个封装:

type waitq struct {
    first *sudog
    last  *sudog
}

lock 用来保证每个读 channel 或写 channel 的操作都是原子的。

例如,创建一个容量为 6 的,元素为 int 型的 channel数据结构如下 :

hchan.png

创建 chan

一般而言,使用 make 创建一个能收能发的通道:

 // 无缓冲通道
ch1 := make(chan int)
// 有缓冲通道
ch2 := make(chan int, 10)

通过汇编分析,我们知道,最终创建 chan 的函数是 makechan

func makechan(t *chantype, size int) *hchan {
   elem := t.elem
   
   //...
   
   //计算需要使用的内存大小
   mem, overflow := math.MulUintptr(elem.size, uintptr(size))
   if overflow || mem > maxAlloc-hchanSize || size < 0 {
      panic(plainError("makechan: size out of range"))
   }

   var c *hchan
   switch {
   case mem == 0:
      // 1. 非缓冲型的,buf 没用,直接指向 chan 起始地址处
      // 2. 缓冲型的,能进入到这里,说明元素无指针且元素类型为 struct{},也无影响
      // 因为只会用到接收和发送游标,不会真正拷贝东西到 c.buf 处(这会覆盖 chan的内容)
      c = (*hchan)(mallocgc(hchanSize, nil, true))
      c.buf = c.raceaddr()
   case elem.ptrdata == 0:
      // 如果 hchan 结构体中不含指针,GC 就不会扫描 chan 中的元素
      // 只分配 "hchan 结构体大小 + 元素大小*个数" 的内存
      c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
      c.buf = add(unsafe.Pointer(c), hchanSize)
   default:
      // 指针元素的分配操作
      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
}

新建一个 chan 后,内存在堆上分配,大概长这样:

makechan.png

channel 发送和接收元素的本质是什么

channel 的发送和接收操作本质上都是 值的拷贝,无论是从 sender goroutine 的栈到 chan buf,还是从 chan bufreceiver goroutine,或者是直接从 sender goroutinereceiver goroutine

举一个例子:

type user struct {
    name string
    age int8
}
var u = user{name: "Ankur", age: 25}
var g = &u
func modifyUser(pu *user) {
    fmt.Println("modifyUser Received Vaule", pu)
    pu.name = "Anand"
}
func printUser(u <-chan *user) {
    time.Sleep(2 * time.Second)
    fmt.Println("printUser goRoutine called", <-u)
}
func main() {
    c := make(chan *user, 5)
    c <- g
    fmt.Println(g)
    // modify g
    g = &user{name: "Ankur Anand", age: 100}
    go printUser(c)
    go modifyUser(g)
    time.Sleep(5 * time.Second)
    fmt.Println(g)
}
&{Ankur 25}
modifyUser Received Vaule &{Ankur Anand 100}
printUser goRoutine called &{Ankur 25}
&{Anand 100}

这里就是一个很好的 share memory by communicating 的例子。

copy channel elem.png

一开始构造一个结构体 u,地址是 0x566420,图中地址上方就是它的内容。接着把 &u 赋值给指针 gg 的地址是0x565bb0,它的内容就是一个地址,指向 u

main 程序里,先把g 发送到 c,根据 copy value 的本质,进入到 chan buf 里的就是 0x56420,它是指针 g 的值(不是它指向的内容),所以打印从 channel 接收到的元素时,它就是 &{Ankur 25}。因此,这里并不是将指针 g “发送” 到了 channel 里,只是拷贝它的值而已。

从 channel 接收数据的过程是怎样的

源码分析

接收操作有两种写法,一种带 "ok" ,反应 channel 是否关闭;一种不带 "ok" ,这种写法,当接收到相应类型的零值时无法知道是真实的发送者发送过来的值,还是 channel 被关闭后,返回给接收者的默认类型的零值。两种写法,都有各自的应用场景。

经过编译器的处理后,这两种写法最后对应源码里的这两个函数:

// entry points for <- c from compiled code
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
}

chanrecv1 函数处理不带 "ok" 的情形,chanrecv2 则通过返回 received 这个字段来反应 channel 是否被关闭。 无论如何,最终转向了 chanrecv 函数:

// 位于 src/runtime/chan.go
// chanrecv 函数接收 channel c 的元素并将其写入 ep 所指向的内存地址。
// 如果 ep 是 nil,说明忽略了接收值。
// 如果 block == false,即非阻塞型接收,在没有数据可接收的情况下,返回 (false, false)
// 否则,如果 c 处于关闭状态,将 ep 指向的地址清零,返回 (true, false)
// 否则,用返回值填充 ep 指向的内存地址。返回 (true, true)
// 如果 ep 非空,则应该指向堆或者函数调用者的栈
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
   //...
   if c == nil {
      // 如果不阻塞,直接返回 (false, false)
      if !block {
         return
      }
      // 否则,接收一个 nil 的 channel,goroutine 挂起
      gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
      throw("unreachable")
   }

   // 快速通道:在未获得锁的情况下检查非阻塞操作是否失败.
   if !block && empty(c) {
      // 如果已经关了,直接返回接收失败(false,false)
      if atomic.Load(&c.closed) == 0 {
         return
      }
      if empty(c) {
         if ep != nil {
            typedmemclr(c.elemtype, ep)
         }
         //可以发送,不能接收
         return true, false
      }
   }

   var t0 int64
   if blockprofilerate > 0 {
      t0 = cputicks()
   }

   lock(&c.lock)

   // channel 已关闭,并且循环数组 buf 里没有元素
   // 这里可以处理非缓冲型关闭 和 缓冲型关闭但 buf 无元素的情况
   // 也就是说即使是关闭状态,但在缓冲型的 channel,
   // buf 里有元素的情况下还能接收到元素
   if c.closed != 0 && c.qcount == 0 {
      if raceenabled {
         raceacquire(c.raceaddr())
      }
      unlock(&c.lock)
      if ep != nil {
         // 从一个已关闭的 channel 执行接收操作,且未忽略返回值
         // 那么接收的值将是一个该类型的零值
         // typedmemclr 根据类型清理相应地址的内存
         typedmemclr(c.elemtype, ep)
      }
      return true, false
   }
    // 等待发送队列里有 goroutine 存在,说明 buf 是满的
    // 这有可能是:
    // 1. 非缓冲型的 channel
    // 2. 缓冲型的 channel,但 buf 满了
    // 针对 1,直接进行内存拷贝(从 sender goroutine -> receiver goroutine)
    // 针对 2,接收到循环数组头部的元素,并将发送者的元素放到循环数组尾部
   if sg := c.sendq.dequeue(); sg != nil {
      recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
      return true, true
   }
   // 缓冲型,buf 里有元素,可以正常接收
   if c.qcount > 0 {
      // 直接从循环数组里找到要接收的元素
      qp := chanbuf(c, c.recvx)
      // 代码里,没有忽略要接收的值,不是 "<- ch",而是 "val <- ch",ep 指向 val
      if ep != nil {
         typedmemmove(c.elemtype, ep, qp)
      }
      // 清理掉循环数组里相应位置的值
      typedmemclr(c.elemtype, qp)
      // 接收游标向前移动
      c.recvx++
      // 接收游标归零
      if c.recvx == c.dataqsiz {
         c.recvx = 0
      }
      // buf 数组里的元素个数减 1
      c.qcount--
      unlock(&c.lock)
      return true, true
   }

   if !block {
      // 非阻塞接收,解锁。selected 返回 false,因为没有接收到值
      unlock(&c.lock)
      return false, false
   }
   
   // 接下来就是要被阻塞的情况了
   // 构造一个 sudog
   gp := getg()
   mysg := acquireSudog()
   mysg.releasetime = 0
   if t0 != 0 {
      mysg.releasetime = -1
   }
   // 待接收数据的地址保存下来
   mysg.elem = ep
   mysg.waitlink = nil
   gp.waiting = mysg
   mysg.g = gp
   mysg.isSelect = false
   mysg.c = c
   gp.param = nil
   // 进入channel 的等待接收队列
   c.recvq.enqueue(mysg)

   atomic.Store8(&gp.parkingOnChan, 1)
   // 将当前 goroutine 挂起
   gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)

   // 被唤醒了,接着从这里继续执行一些扫尾工作
   if mysg != gp.waiting {
      throw("G waiting list is corrupted")
   }
   gp.waiting = nil
   gp.activeStackChans = false
   if mysg.releasetime > 0 {
      blockevent(mysg.releasetime-t0, 2)
   }
   success := mysg.success
   gp.param = nil
   mysg.c = nil
   releaseSudog(mysg)
   return true, success
}

上面的代码注释地比较详细了,你可以对着源码一行行地去看,我们再来详细看一下。

  • 如果 channel是一个空值nil,在非阻塞模式下,会直接返回。在阻塞模式下,会调用 gopark 函数挂起goroutine,这个会一直阻塞下去。因为在 channelnil 的情况下,要想不阻塞,只有关闭它,但关闭一个 nilchannel 又会发生panic,所以没有机会被唤醒了。更详细地可以在 closechan 函数的时候再看。

    非阻塞模式下,用 default 来跳过:

    select {
      case msg := <-messages:
          fmt.Println("received message", msg)
      case sig := <-signals:
          fmt.Println("received signal", sig)
      default:
          fmt.Println("no activity")
      }
    
  • 和发送函数一样,接下来搞了一个在非阻塞模式下,不用获取锁,快速检测到失败并且返回的操作。顺带插一句,我们平时在写代码的时候,找到一些边界条件,快速返回,能让代码逻辑更清晰,因为接下来的正常情况就比较少,更聚焦了,看代码的人也更能专注地看核心代码逻辑了。

    if !block && empty(c) {
        // ...
        return
    }
    
    func empty(c *hchan) bool {
       // c.dataqsiz is immutable.
       if c.dataqsiz == 0 {
          return atomic.Loadp(unsafe.Pointer(&c.sendq.first)) == nil
       }
       return atomic.Loaduint(&c.qcount) == 0
    }
    

当我们观察到 channel 没准备好接收:

  1. 非缓冲型,等待发送列队里没有 goroutine 在等待
  2. 缓冲型,但 buf 里没有元素 接下来的操作,首先会上一把锁,粒度比较大。如果 channel 已关闭,并且循环数组 buf 里没有元素。对应非缓冲型关闭和缓冲型关闭但 buf 无元素的情况,返回对应类型的零值,但 received 标识是 false,告诉调用者此 channel 已关闭,你取出来的值并不是正常由发送者发送过来的数据。但是如果处于 select 语境下,这种情况是被选中了的。很多将 channel 用作通知信号的场景就是命中了这里。

如果有等待发送的队列,说明 channel 已经满了,要么是非缓冲型的 channel,要么是缓冲型的 channel,但 buf 满了。这两种情况下都可以正常接收数据。

于是,调用 recv 函数:

func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   // 如果是非缓冲型的 channel
   if c.dataqsiz == 0 {
      if raceenabled {
         racesync(c, sg)
      }
      // 未忽略接收的数据
      if ep != nil {
         // 直接拷贝数据,从 sender goroutine -> receiver goroutine
         recvDirect(c.elemtype, sg, ep)
      }
   } else {
      // 缓冲型的 channel,但 buf 已满。
      // 将循环数组 buf 队首的元素拷贝到接收数据的地址
      // 将发送者的数据入队。实际上这时 revx 和 sendx 值相等
      // 找到接收游标
      qp := chanbuf(c, c.recvx)

      // 将接收游标处的数据拷贝给接收者
      if ep != nil {
         typedmemmove(c.elemtype, ep, qp)
      }
      // 将发送者数据拷贝到 buf
      typedmemmove(c.elemtype, qp, sg.elem)
      // 更新游标值
      c.recvx++
      if c.recvx == c.dataqsiz {
         c.recvx = 0
      }
      c.sendx = c.recvx // c.sendx = (c.sendx+1) % c.dataqsiz
   }
   sg.elem = nil
   gp := sg.g
   unlockf()
   gp.param = unsafe.Pointer(sg)
   sg.success = true
   if sg.releasetime != 0 {
      sg.releasetime = cputicks()
   }
   // 唤醒发送的 goroutine。需要等到调度器的光临
   goready(gp, skip+1)
}

如果是非缓冲型的,就直接从发送者的栈拷贝到接收者的栈。

func recvDirect(t *_type, sg *sudog, dst unsafe.Pointer) {
   src := sg.elem
   typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.size)
   memmove(dst, src, t.size)
}

否则,就是缓冲型 channel,而 buf 又满了的情形。说明发送游标和接收游标重合了,因此需要先找到接收游标

// chanbuf(c, i) is pointer to the i'th slot in the buffer.
func chanbuf(c *hchan, i uint) unsafe.Pointer {
   return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}

将该处的元素拷贝到接收地址。然后将发送者待发送的数据拷贝到接收游标处。这样就完成了接收数据和发送数据的操作。接着,分别将发送游标和接收游标向前进一,如果发生“环绕”,再从 0 开始。 最后,取出 sudog 里的 goroutine,调用goready 将其状态改成 "runnable" ,待发送者被唤醒,等待调度器的调度。

  • 然后,如果 channelbuf 里还有数据,说明可以比较正常地接收。注意,这里,即使是在 channel 已经关闭的情况下,也是可以走到这里的。这一步比较简单,正常地将 buf 里接收游标处的数据拷贝到接收数据的地址。
  • 到了最后一步,走到这里来的情形是要阻塞的。当然,如果 block 传进来的值是 false,那就不阻塞,直接返回就好了。

先构造一个 sudog,接着就是保存各种值了。注意,这里会将接收数据的地址存储到了 elem 字段,当被唤醒时,接收到的数据就会保存到这个字段指向的地址。然后将 sudog 添加到 channelrecvq 队列里。调用 goparkunlock 函数将 goroutine 挂起。

接下来的代码就是 goroutine 被唤醒后的各种收尾工作了。

案例分析

channel 接收和向channel 发送数据的过程我们均会使用下面这个例子来进行说明:

func goroutineA(a <-chan int) {
    val := <- a
    fmt.Println("G1 received data: ", val)
    return
}
func goroutineB(b <-chan int) {
    val := <- b
    fmt.Println("G2 received data: ", val)
    return
}
func main() {
    ch := make(chan int)
    go goroutineA(ch)
    go goroutineB(ch)
    ch <- 3
    time.Sleep(time.Second)
}

首先创建了一个无缓冲的 channel,接着启动两个 goroutine,并将前面创建的 channel 传递进去。然后,向这个 channel 中发送数据 3,最后 sleep 1 秒后程序退出。

程序第 14 行创建了一个非缓冲型的 channel,我们只看 chan 结构体中的一些重要字段,来从整体层面看一下 chan 的状态,一开始什么都没有:

channel.png

接着,第 15、16 行分别创建了一个 goroutine,各自执行了一个接收操作。通过前面的源码分析,我们知道,这两个 goroutine (后面称为 G1 和 G2 好了)都会被阻塞在接收操作。G1 和 G2 会挂在 channelrecq 队列中,形成一个双向循环链表。

channel vb.png

buf 指向一个长度为 0 的数组,qcount 为 0,表示 channel 中没有元素。重点关注 recvqsendq,它们是 waitq 结构体,而 waitq 实际上就是一个双向链表,链表的元素是 sudog,里面包含 g 字段,g 表示一个 goroutine,所以 sudog 可以看成一个 goroutinerecvq 存储那些尝试读取 channel 但被阻塞的 goroutinesendq 则存储那些尝试写入 channel,但被阻塞的 goroutine

此时,我们可以看到,recvq 里挂了两个 goroutine,也就是前面启动的 G1 和 G2。因为没有 goroutine 接收,而 channel 又是无缓冲类型,所以 G1 和 G2 被阻塞。sendq 没有被阻塞的 goroutine。

recvq 的数据结构如下:

recvq.png

再从整体上来看一下 chan 此时的状态:

chan waiting.png G1 和 G2 被挂起了,状态是 WAITING

chan gmp.png

继续回到例子。假设我们只有一个 M,当 G1(go goroutineA(ch)) 运行到 val := <- a 时,它由本来的 running 状态变成了 waiting 状态(调用了 gopark 之后的结果):

chan gmp gopark.png

G1 脱离与 M 的关系,但调度器可不会让 M 闲着,所以会接着调度另一个 goroutine 来运行:

chan gmp gopark2.png

G2 也是同样的遭遇。现在 G1 和 G2 都被挂起了,等待着一个 senderchannel里发送数据,才能得到解救。

向 channel 发送数据的过程是怎样的

源码分析

发送操作最终转化为 hansend 函数,直接上源码,同样大部分都注释了,可以看懂主流程:

func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
   if c == nil {
      // 不能阻塞,直接返回 false,表示未发送成功
      if !block {
         return false
      }
      // 当前 goroutine 被挂起
      gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
      throw("unreachable")
   }

   //...
   
   // 对于不阻塞的 send,快速检测失败场景
   // 如果 channel 未关闭且 channel 没有多余的缓冲空间。这可能是:
   // 1. channel 是非缓冲型的,且等待接收队列里没有 goroutine
   // 2. channel 是缓冲型的,但循环数组已经装满了元素
   if !block && c.closed == 0 && full(c) {
      return false
   }

   var t0 int64
   if blockprofilerate > 0 {
      t0 = cputicks()
   }

   lock(&c.lock)

   if c.closed != 0 {
      unlock(&c.lock)
      panic(plainError("send on closed channel"))
   }
   
   // 如果接收队列里有 goroutine,直接将要发送的数据拷贝到接收 goroutine
   if sg := c.recvq.dequeue(); sg != nil {
      send(c, sg, ep, func() { unlock(&c.lock) }, 3)
      return true
   }
   // 对于缓冲型的 channel,如果还有缓冲空间
   if c.qcount < c.dataqsiz {
      // qp 指向 buf 的 sendx 位置
      qp := chanbuf(c, c.sendx)
      
      // 将数据从 ep 处拷贝到 qp
      typedmemmove(c.elemtype, qp, ep)
      // 发送游标值加 1
      c.sendx++
      if c.sendx == c.dataqsiz {
         c.sendx = 0
      }
      // 缓冲区的元素数量加一
      c.qcount++
      unlock(&c.lock)
      return true
   }
   // 如果不需要阻塞,则直接返回错误
   if !block {
      unlock(&c.lock)
      return false
   }

   // channel 满了,发送方会被阻塞。接下来会构造一个 sudog
   // 获取当前 goroutine 的指针
   gp := getg()
   mysg := acquireSudog()
   mysg.releasetime = 0
   if t0 != 0 {
      mysg.releasetime = -1
   }
   mysg.elem = ep
   mysg.waitlink = nil
   mysg.g = gp
   mysg.isSelect = false
   mysg.c = c
   gp.waiting = mysg
   gp.param = nil
   // 当前 goroutine 进入发送等待队列
   c.sendq.enqueue(mysg)
   atomic.Store8(&gp.parkingOnChan, 1)
   // 当前 goroutine 被挂起
   gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
   KeepAlive(ep)
   if mysg != gp.waiting {
      throw("G waiting list is corrupted")
   }
   gp.waiting = nil
   gp.activeStackChans = false
   closed := !mysg.success
   gp.param = nil
   if mysg.releasetime > 0 {
      blockevent(mysg.releasetime-t0, 2)
   }
   // 去掉 mysg 上绑定的 channel
   mysg.c = nil
   releaseSudog(mysg)
   if closed {
      if c.closed == 0 {
         throw("chansend: spurious wakeup")
      }
      // 被唤醒后,channel 关闭了。坑爹啊,panic
      panic(plainError("send on closed channel"))
   }
   return true
}

面的代码注释地比较详细了,我们来详细看看。

  • 如果检测到 channel 是空的,当前 goroutine 会被挂起。
  • 对于不阻塞的发送操作,如果 channel 未关闭并且没有多余的缓冲空间(说明:a. channel 是非缓冲型的,且等待接收队列里没有 goroutineb. channel 是缓冲型的,但循环数组已经装满了元素)

对于这一点,runtime 源码里注释了很多。这一条判断语句是为了在不阻塞发送的场景下快速检测到发送失败,好快速返回。

if !block && c.closed == 0 && full(c) {
  return false
}

func full(c *hchan) bool {
   if c.dataqsiz == 0 {
      return c.recvq.first == nil
   }
   return c.qcount == c.dataqsiz
}

注释里主要讲为什么这一块可以不加锁,我详细解释一下。if 条件里先读了两个变量:block 和 c.closedblock 是函数的参数,不会变;c.closed 可能被其他 goroutine 改变,因为没加锁嘛,这是“与”条件前面两个表达式。 这里 c.dataqsiz 实际上也是不会被修改的,在创建的时候就已经确定了。不加锁真正影响地是 c.qcountc.recvq.first,目的就是少获取一次锁,提升性能。

如果检测到 channel 已经关闭,直接 panic

如果能从等待接收队列 recvq 里出队一个 sudog(代表一个 goroutine),说明此时 channel 是空的,没有元素,所以才会有等待接收者。这时会调用 send 函数将元素直接从发送者的栈拷贝到接收者的栈,关键操作由 sendDirect 函数完成

func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   // ...
   // sg.elem 指向接收到的值存放的位置,如 val <- ch,指的就是 &val
   if sg.elem != nil {
      // 直接拷贝内存(从发送者到接收者)
      sendDirect(c.elemtype, sg, ep)
      sg.elem = nil
   }
   // sudog 上绑定的 goroutine
   gp := sg.g
   unlockf()
   gp.param = unsafe.Pointer(sg)
   sg.success = true
   if sg.releasetime != 0 {
      sg.releasetime = cputicks()
   }
   // 唤醒接收的 goroutine. skip 和打印栈相关,暂时不理会
   goready(gp, skip+1)
}

继续看 sendDirect 函数:

// 向一个非缓冲型的 channel 发送数据、从一个无元素的(非缓冲型或缓冲型但空)的 channel
// 接收数据,都会导致一个 goroutine 直接操作另一个 goroutine 的栈
// 由于 GC 假设对栈的写操作只能发生在 goroutine 正在运行中并且由当前 goroutine 来写
// 所以这里实际上违反了这个假设。可能会造成一些问题,所以需要用到写屏障来规避
func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) {
   // src 在当前 goroutine 的栈上,dst 是另一个 goroutine 的栈
   // 直接进行内存"搬迁"
   // 如果目标地址的栈发生了栈收缩,当我们读出了 sg.elem 后
   // 就不能修改真正的 dst 位置的值了
   // 因此需要在读和写之前加上一个屏障
   dst := sg.elem
   typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.size)
   memmove(dst, src, t.size)
}

这里涉及到一个 goroutine 直接写另一个 goroutine 栈的操作,一般而言,不同 goroutine 的栈是各自独有的。而这也违反了 GC 的一些假设。为了不出问题,写的过程中增加了写屏障,保证正确地完成写操作。这样做的好处是 减少了一次内存 copy:不用先拷贝到 channel.buf,直接由发送者到接收者。

然后,解锁、唤醒接收者,等待调度器的光临,接收者也得以重见天日,可以继续执行接收操作之后的代码了。

  • 如果 c.qcount < c.dataqsiz,说明缓冲区可用(肯定是缓冲型的 channel)。先通过函数取出待发送元素应该去到的位置:
qp := chanbuf(c, c.sendx)

func chanbuf(c *hchan, i uint) unsafe.Pointer {
   return add(c.buf, uintptr(i)*uintptr(c.elemsize))
}

c.sendx 指向下一个待发送元素在循环数组中的位置,然后调用 typedmemmove 函数将其拷贝到循环数组中。之后 c.sendx 加 1,元素总量加 1 :c.qcount++,最后,解锁并返回。

  • 如果没有命中以上条件的,说明 channel 已经满了。不管这个 channel 是缓冲型的还是非缓冲型的,都要将这个 sender “关起来”(goroutine 被阻塞)。如果 blockfalse,直接解锁,返回 false
  • 最后就是真的需要被阻塞的情况。先构造一个 sudog,将其入队(channel.sendq 字段)。然后调用 goparkunlock 将当前 goroutine 挂起,并解锁,等待合适的时机再唤醒。

唤醒之后,从 goparkunlock 下一行代码开始继续往下执行。 这里有一些绑定操作,sudog 通过 g 字段绑定 goroutine,而 goroutine 通过 waiting 绑定 sudogsudog 还通过 elem 字段绑定待发送元素的地址,以及 c 字段绑定被“坑”在此处的 channel

所以,待发送的元素地址其实是存储在 sudog 结构体里,也就是当前 gopark 里。

案例分析

好了,看完源码。我们接着来分析例子,代码如下:

func goroutineA(a <-chan int) {
    val := <- a
    fmt.Println("goroutine A received data: ", val)
    return
}
func goroutineB(b <-chan int) {
    val := <- b
    fmt.Println("goroutine B received data: ", val)
    return
}
func main() {
    ch := make(chan int)
    go goroutineA(ch)
    go goroutineB(ch)
    ch <- 3
    time.Sleep(time.Second)
    ch1 := make(chan struct{})
}

在发送小节里我们说到 G1 和 G2 现在被挂起来了,等待 sender 的解救。在第 17 行,主协程向 ch 发送了一个元素 3,来看下接下来会发生什么。

根据前面源码分析的结果,我们知道,sender 发现 chrecvq 里有 receiver 在等待着接收,就会出队一个 sudog,把 recvqfirst 指针的 sudo 推举出来了,并将其加入到 P 的可运行 goroutine 队列中。

然后,sender 把发送元素拷贝到 sudogelem 地址处,最后会调用 goready 将 G1 唤醒,状态变为 runnable

runable.png

当调度器光顾 G1 时,将 G1 变成 running 状态,执行 goroutineA 接下来的代码。G 表示其他可能有的 goroutine

send channel.png

channel 在什么情况下会引起资源泄漏

泄漏的原因是 goroutine 操作 channel 后,处于发送或接收阻塞状态,而 channel 处于满或空的状态,一直得不到改变。同时,垃圾回收器也不会回收此类资源,进而导致 gouroutine 会一直处于等待队列中,不见天日。

另外,程序运行过程中,对于一个 channel,如果没有任何 goroutine 引用了,gc 会对其进行回收操作,不会引起内存泄漏。

如何优雅地关闭 channel

不要从一个 receiver 侧关闭 channel,也不要在有多个 sender 时,关闭 channel

比较好理解,向 channel 发送元素的就是 sender,因此 sender 可以决定何时不发送数据,并且关闭 channel。但是如果有多个 sender,某个 sender 同样没法确定其他 sender 的情况,这时也不能贸然关闭 channel

有两个不那么优雅地关闭 channel 的方法:

  1. 使用 defer-recover 机制,放心大胆地关闭 channel 或者向 channel 发送数据。即使发生了 panic,有 defer-recover 在兜底。
  2. 使用 sync.Once 来保证只关闭一次。

那到底应该如何优雅地关闭 channel

根据 senderreceiver 的个数,分下面几种情况:

  1. 一个 sender,一个 receiver
  2. 一个 sender, M 个 receiver
  3. N 个 sender,一个 reciver
  4. N 个 sender, M 个receiver

对于 1,2,只有一个 sender 的情况就不用说了,直接从 sender 端关闭就好了,没有问题。重点关注第 3,4 种情况。

第 3 种情形下,优雅关闭 channel 的方法是:the only receiver says “please stop sending more” by closing an additional signal channel

解决方案就是增加一个传递关闭信号的 channelreceiver 通过信号 channel 下达关闭数据 channel 指令。senders 监听到关闭信号后,停止接收数据。代码如下:

func main() {
    rand.Seed(time.Now().UnixNano())
    const Max = 100000
    const NumSenders = 1000
    dataCh := make(chan int, 100)
    stopCh := make(chan struct{})
    // senders
    for i := 0; i < NumSenders; i++ {
        go func() {
            for {
                select {
                case <- stopCh:
                    return
                case dataCh <- rand.Intn(Max):
                }
            }
        }()
    }
    // the receiver
    go func() {
        for value := range dataCh {
            if value == Max-1 {
                fmt.Println("send stop signal to senders.")
                close(stopCh)
                return
            }
            fmt.Println(value)
        }
    }()
    select {
    case <- time.After(time.Hour):
    }
}

这里的 stopCh 就是信号 channel,它本身只有一个 sender,因此可以直接关闭它。senders 收到了关闭信号后,select 分支 case <- stopCh 被选中,退出函数,不再发送数据。

需要说明的是,上面的代码并没有明确关闭 dataCh。在 Go 语言中,对于一个 channel,如果最终没有任何 goroutine 引用它,不管 channel 有没有被关闭,最终都会被 gc 回收。所以,在这种情形下,所谓的优雅地关闭 channel 就是不关闭 channel,让 gc 代劳。 最后一种情况,优雅关闭 channel 的方法是:any one of them says “let’s end the game” by notifying a moderator to close an additional signal channel

和第 3 种情况不同,这里有 M 个 receiver,如果直接还是采取第 3 种解决方案,由 receiver 直接关闭 stopCh 的话,就会重复关闭一个 channel,导致 panic。因此需要增加一个中间人,M 个 receiver 都向它发送关闭 dataCh 的“请求”,中间人收到第一个请求后,就会直接下达关闭 dataCh 的指令(通过关闭 stopCh,这时就不会发生重复关闭的情况,因为 stopCh 的发送方只有中间人一个)。另外,这里的 N 个 sender 也可以向中间人发送关闭 dataCh 的请求。

func main() {
    rand.Seed(time.Now().UnixNano())
    const Max = 100000
    const NumReceivers = 10
    const NumSenders = 1000
    dataCh := make(chan int, 100)
    stopCh := make(chan struct{})
    // It must be a buffered channel.
    toStop := make(chan string, 1)
    var stoppedBy string
    // moderator
    go func() {
        stoppedBy = <-toStop
        close(stopCh)
    }()
    // senders
    for i := 0; i < NumSenders; i++ {
        go func(id string) {
            for {
                value := rand.Intn(Max)
                if value == 0 {
                    select {
                    case toStop <- "sender#" + id:
                    default:
                    }
                    return
                }
                select {
                case <- stopCh:
                    return
                case dataCh <- value:
                }
            }
        }(strconv.Itoa(i))
    }
    // receivers
    for i := 0; i < NumReceivers; i++ {
        go func(id string) {
            for {
                select {
                case <- stopCh:
                    return
                case value := <-dataCh:
                    if value == Max-1 {
                        select {
                        case toStop <- "receiver#" + id:
                        default:
                        }
                        return
                    }
                    fmt.Println(value)
                }
            }
        }(strconv.Itoa(i))
    }
    select {
    case <- time.After(time.Hour):
    }
}

代码里 toStop 就是中间人的角色,使用它来接收 sendersreceivers 发送过来的关闭 dataCh 请求。

这里将 toStop 声明成了一个 缓冲型的 channel。假设 toStop 声明的是一个非缓冲型的 channel,那么第一个发送的关闭 dataCh 请求可能会丢失。因为无论是 sender 还是 receiver 都是通过 select 语句来发送请求,如果中间人所在的 goroutine 没有准备好,那 select 语句就不会选中,直接走 default 选项,什么也不做。这样,第一个关闭 dataCh 的请求就会丢失。

如果,我们把 toStop 的容量声明成 Num(senders) + Num(receivers),那发送 dataCh 请求的部分可以改成更简洁的形式:

...
toStop := make(chan string, NumReceivers + NumSenders)
...
            value := rand.Intn(Max)
            if value == 0 {
                toStop <- "sender#" + id
                return
            }
...
                if value == Max-1 {
                    toStop <- "receiver#" + id
                    return
                }
...

直接向 toStop 发送请求,因为 toStop 容量足够大,所以不用担心阻塞,自然也就不用 select 语句再加一个 default case 来避免阻塞。