Golang | 从源码分析Channel

2,088 阅读5分钟

前言

CSP (communicating sequential processes) 指互相独立的并发实体之间通过共享的通讯管道(如channel)进行通信的并发模型,不同语言有不同的并发模型。

Java、C++ 的并发模型都是通过共享内存实现的,非常典型的方式就是,在访问共享数据(如数组、Map等)的时候,对共享内存加锁,因此,衍生出了许多线程安全的数据结构

Golang 借鉴CSP模型的一些概念作为并发模型的理论支持。大家最常听见的那句话

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而要通过通信来实现内存共享。 即是由此而来

在Golang中,goroutine作为独立的并发实体,channel作为不同实体间数据通信的管道。

本文主要介绍 Golang 中的channel

类型

channel 主要分为 有缓冲无缓冲的两种 channel
两种channel最大的区别是 ,有缓冲的channel是非阻塞模型,无缓冲的channel是阻塞模型

有缓冲的channel

ch := make(chan int , 1)

无缓冲的channel

ch := make(chan int )

make指定len为0时,也是一个无缓冲的channel

ch := make(chan int , 0)

只读channel和只写channel

表示channel只能被读或只能被写,通常是对channel的使用作限制

func onlyReadAndWrite(){
   ch := make(chan int ,1)
   onlyRead(ch)
   onlyWrite(ch)
}

// 参数为只读channel
func onlyRead(ch <-chan int ){
   <- ch 
}

// 参数为只写channel
func onlyWrite(ch chan<- int ){
    ch <- 1
}

零值为nil的channel

  • channel的零值可以为nil 。对这样的channel发送或接收会永远阻塞。
  • 在select语句中操作nil的channel永远都不会被select到,我们可以用这个特性来激活或者禁用case
var verbose = flag.Bool("v", false, "show verbose progress messages")

func main() {
    // ...start background goroutine...

    // Print the results periodically.
    var tick <-chan time.Time
    if *verbose {
        tick = time.Tick(500 * time.Millisecond)
    }
    var nfiles, nbytes int64
loop:
    for {
        select {
        case size, ok := <-fileSizes:
            if !ok {
                break loop // fileSizes was closed
            }
            nfiles++
            nbytes += size
        case <-tick:
            printDiskUsage(nfiles, nbytes)
        }
    }
    printDiskUsage(nfiles, nbytes) // final totals
}

如果程序启动时,-v没有传入,则tick 这个channel会保持为nil,select中的case永远不会被执行

数据结构

channel运行时数据结构存放在runtime.hchan下,

type hchan struct {
   qcount   uint           // channel中元素个数
   dataqsiz uint           // channel循环队列的长度 ,make channel中的len属性 ,即缓冲区大小
   buf      unsafe.Pointer // channel缓冲区数据指针;
   elemsize uint16         //  channel元素的大小,是 elem元数据类型的大小
   closed   uint32
   elemtype *_type // 
   sendx    uint   // channel的send操作处理到的位置;
   recvx    uint   // channel的recv操作处理到的位
   recvq    waitq  // recv 等待队列(即 <- channel )
   sendq    waitq  // send 等待队列(即 channel <- ) 

   lock mutex
}

可以看到,channel底层依然是使用了mutex互斥锁来做并发控制。
有关Mutex可以看这篇文章

看看waitq的结构

type waitq struct {
   first *sudog
   last  *sudog
}

是一个sudog结构的双向链表 ,再看看sudog结构

type sudog struct {
   g *g // 指向goroutine结构题

   next *sudog // 前sudog
   prev *sudog // 后sudog
  
   ***
}

可以看到,sudog结构其实就是一个goroutine ,同时持有前后sudog的地址,是一个双向链表

源码解读

gopark和goready

源码当中有两个系统函数出现的次数较频繁,这里简单介绍一下他们的作用

gopark的作用

  1. 解除当前goroutine的m的绑定关系,将当前goroutine状态机切换为waiting 状态
  2. 调用一次schedule()函数,在局部调度器P发起一轮新的调度。
  3. 这个时候的 G 没有进入调度队列

goready的作用

  1. 当前goroutine的状态机切换到 runnable 状态
  2. M 重新进入调度循环
  3. goroutine进入local queue ,等待 P 调度

创建channel

使用make方法创建channel,最终会调用runtime.makechan ,方法很简单,只做了两件事

  1. 参数校验
  2. 为分配hchan和buf内存
func makechan(t *chantype, size int) *hchan {
   elem := t.elem
   
    // 参数校验
   if elem.size >= 1<<16 {
      throw("makechan: invalid channel element type")
   }
   
   // 内存对齐相关
   if hchanSize%maxAlign != 0 || elem.align > maxAlign {
      throw("makechan: bad alignment")
   }
    // 计算需要分配的内存大小
   mem, overflow := math.MulUintptr(elem.size, uintptr(size))
   
   // 检查内存大小是否超过系统限制 && 是否堆内存溢出 
   if overflow || mem > maxAlloc-hchanSize || size < 0 {
      panic(plainError("makechan: size out of range"))
   }

   // 当buf不包含指针类型时,那么会为channel和底层数组分配一段连续的内存空间
   // sodog会从其拥有的线程中引用该对象,因此该对象无法被gc收集(不会被gc回收) 
  
   var c *hchan
   switch {
   case mem == 0:
      // 无缓冲区channel
      c = (*hchan)(mallocgc(hchanSize, nil, true))
      // Race detector uses this location for synchronization.
      c.buf = c.raceaddr()
   case elem.ptrdata == 0:
      // buf中元素不包含指针
      c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
      c.buf = add(unsafe.Pointer(c), hchanSize)
   default:
      // buf中元素包含指针,为hchan和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
}

可以提炼出几个关键点

  1. 如果channel元素类型不包含指针类型,会在堆上分配一段连续的内存,同时sudog会引用该hchan,该hchan不会被gc回收
  2. 如果channel元素类型不包含指针类型,会为buf和hchan分配内存
  3. channel的内存分配涉及到内存对齐的计算

发送数据

流程

使用ch <- data 往channel里推数据,最终会调用runtinme.chansend方法
源码很长,主要做了三个事情:

  1. 当recvq存在等待者时,直接进入send方法。
  2. 当缓冲区存在空余空间时,将发送的数据写入channel的缓冲区。
  3. 当不存在缓冲区或者缓冲区已满时,等待其他goroutine从channel 接收数据。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
   ***
   lock()
   
   // 1. 如果recvq队列中有等待者,直接进入send方法
   if sg := c.recvq.dequeue(); sg != nil {
      send(c, sg, ep, func() { unlock(&c.lock) }, 3)
      return true
   }
   
    // 2. 缓冲区存在空余空间,将数据写入缓冲区
   if c.qcount < c.dataqsiz {
      // 计算下一个可以存储数据的位置
      qp := chanbuf(c, c.sendx)
      // 参数 ep 放入上一步计算的 qp 对应的位置上
      typedmemmove(c.elemtype, qp, ep)
      // 更新send index && qcount 
      c.sendx++
      // 环形队列
      if c.sendx == c.dataqsiz {
         c.sendx = 0
      }
      c.qcount++
      unlock(&c.lock)
      return true
   }

   if !block {
      unlock(&c.lock)
      return false
   }
   
   // 3. 阻塞channel, 直到新的接收者从channel中读数据
   // 获得当前运行的goroutine指针
   gp := getg()
   // 分配sudog
   mysg := acquireSudog()
   mysg.releasetime = 0
   if t0 != 0 {
      mysg.releasetime = -1
   }
  
   // dosomething
  
   // 当前sudog入发送队列
   c.sendq.enqueue(mysg)
  
   atomic.Store8(&gp.parkingOnChan, 1)
   // gopark ,goroutine变为 gwaiting 状态
   gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2)
  
  // 为确保往channel里发送的数据不被gc回收,sodog一直引用该对象
   KeepAlive(ep)
  
  // dosomething
  
  // 释放sudog
   releaseSudog(mysg)
   if closed {
      if c.closed == 0 {
         throw("chansend: spurious wakeup")
      }
      panic(plainError("send on closed channel"))
   }
   return true
}

流程图

image.png


看看send方法

  1. 调用 runtime.sendDirect将发送的数据直接拷贝到 x = <-c 表达式中变量 x 所在的内存地址上;
  2. 调用 runtime.goready 将等待接收数据的goroutine标记成可运行状态grunnable并把该 goroutine放到发送方所在的处理器的 runnext 上等待执行,该处理器在下一次调度时会立刻唤醒数据的接收方;
func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   if sg.elem != nil {
      sendDirect(c.elemtype, sg, ep)
      sg.elem = nil
   }
   gp := sg.g
   unlockf()
   gp.param = unsafe.Pointer(sg)
   sg.success = true
   if sg.releasetime != 0 {
      sg.releasetime = cputicks()
   }
   goready(gp, skip+1)
}

接收数据

流程

使用data <- channel 从channel里接收数据,会被转换成runtime.chanrecv1runtime.chanrecv2。 他们最终会调用runtinme.chanrecv方法,主要流程如下:

  1. 如果channel为空,那么会直接调用 gopark 挂起当前goroutine
  2. 如果channel已经关闭并且缓冲区没有任何数据,直接返回
  3. 如果channel的 sendq 队列中存在挂起的 goroutine,会将 recvx 索引所在的数据拷贝到接收变量所在的内存空间上并将 sendq 队列中 goroutine 的数据拷贝到缓冲区
  4. 如果channel的缓冲区中包含数据,那么直接读取 recvx索引对应的数据
  5. 挂起当前的goroutine,同时将sudog结构推入recvq队列并进入休眠状态,等待发送者向channel发送数据,从而唤醒当前goroutine。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {

   // 1. 当我们从空channel读数据,会调用gopark让出当前处理器占用
   if c == nil {
      if !block {
         return
      }
      gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
      throw("unreachable")
   }
   
   if !block && empty(c) {
      if atomic.Load(&c.closed) == 0 {
         return
      }
      if empty(c) {
         if ep != nil {
            typedmemclr(c.elemtype, ep)
         }
         return true, false
      }
   }
   lock(&c.lock)
   
   // 2. 当前channel已经被关闭并且缓冲区中不存在任何数据,那么会清除 ep 指针中的数据并立刻返回。
   if c.closed != 0 && c.qcount == 0 {
      unlock(&c.lock)
      if ep != nil {
         typedmemclr(c.elemtype, ep)
      }
      return true, false
   }

   // 3. 如果发送队列中有goroutine被阻塞,
   if sg := c.sendq.dequeue(); sg != nil {
      // 调用recv方法
      recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
      return true, true
   }
    
   // 4. 如果channel缓冲区中有数据,直接从缓冲区中读数据
   if c.qcount > 0 {
      qp := chanbuf(c, c.recvx)
      if ep != nil {
         typedmemmove(c.elemtype, ep, qp)
      }
      typedmemclr(c.elemtype, qp)
      // 更新 recv索引 和 环形队列长度
      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
   }
   // 5. 没有阻塞的发送者 && channel缓冲区为空 ,阻塞当前goroutine
   gp := getg()
   mysg := acquireSudog()
  
   // dosomething
   
   // 将当前sudog压入channel的接收队列
   c.recvq.enqueue(mysg)

   atomic.Store8(&gp.parkingOnChan, 1)
   
   // gopark 让出处理器使用权
   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
}

流程图

流程图中省略了 lockunlock部分 image.png recv方法中

  1. 当缓冲区存在数据时,从 channel 的缓冲区中接收数据;
  2. 当缓冲区中不存在数据时,等待其他 goroutine 向 channel 发送数据
  3. 最后使用goready,在调度器下一次调度时将阻塞的发送方唤醒
// 
func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) {
   if c.dataqsiz == 0 {
      // 无缓冲的channel
      if ep != nil {
         // 将channel发送队列中goroutine存储的数据拷贝到目标内存地址中
         recvDirect(c.elemtype, sg, ep)
      }
   } else {
      // 有缓冲的channel
      // ;将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方
      qp := chanbuf(c, c.recvx)
      if ep != nil {
         // 将队列中的数据拷贝到接收方的内存地址
         typedmemmove(c.elemtype, ep, qp)
      }
      // 将发送队列头的数据拷贝到缓冲区中,释放一个阻塞的发送方
      typedmemmove(c.elemtype, qp, sg.elem)
      c.recvx++
      if c.recvx == c.dataqsiz {
         c.recvx = 0
      }
      c.sendx = c.recvx
   }
   sg.elem = nil
   gp := sg.g
   unlockf()
   gp.param = unsafe.Pointer(sg)
   sg.success = true
   if sg.releasetime != 0 {
      sg.releasetime = cputicks()
   }
   // 当前处理器的 runnext 设置成发送数据的goroutine,在调度器下一次调度时将阻塞的发送方唤醒
   goready(gp, skip+1)
}

思考

CSP模型的优点

  • CSP 模型中,基于管道 通信,相对于 对共享内存加锁 属于一种程序设计上的抽象与封装
  • 基于管道 通信,类比于生产者-消费者模型,属于一种逻辑上的解耦,相似的还有Java中线程池结构

为什么要用Channel去共享内存而不是锁

  • 本身 Channel 能够实现锁的功能。
  • channel 的可扩展性非常高,除了共享内存,还能满足协程之间数据通信场景,控制共享内存仅仅是channel的一个功能

channel的使用

几种异常情况

有缓冲的channel

标题已关闭无数据已关闭有数据未关闭无数据未关闭有数据
零值channel中的数据panicchannel中的数据
panicpanic\\

无缓冲的channel

无缓冲的channel是阻塞模型,写入的数据需要被读取之后, channel才能再次被写入

标题已关闭无数据已关闭有数据未关闭无数据未关闭有数据
零值panicpanicchannel中的数据
panicpanicpanicpanic

遍历channel中的元素

使用for-range遍历channel中的元素. 注意这种方法是阻塞

data, ok := <-ch // 阻塞的

func forRange() {
   ch := make(chan int, 1)
   go read(ch)
   go write(ch)

   time.Sleep(time.Second)
   log.Println("休眠1s")

   go write(ch)
   time.Sleep(time.Minute)
}


func write(ch chan int) {
   for i := 0; i < 1; i++ {
      ch <- i
      log.Printf("send: [%d]", i)
      break
   }
}
func read(ch chan int) {
   for {
      data, ok := <-ch // 阻塞的
      if ok {
         log.Printf("recv: [%d]", data)
      } else {
         log.Println("channel close ")
         break
      }
   }
}

read方法中,程序会阻塞在 data, ok := <-ch这里,仅当ch被close时,ok返回false image.png

使用select监听channel

golang中 select是专门为channel设计的, 用来做channel多路复用的一种技术 。有关select的基础部分可以戳 golang-select详解

需要注意一点,select 语句中,case是随机执行的,如果case条件都不满足,那么执行default。

func readV2(ch chan int) {
   for {
      select{
      case data , ok := <- ch:
         if ok {
            log.Printf("recv:[%v]",data)
         }else{
            log.Printf("ok-false")
         }
      default:
         log.Println("into-default")
      }
   }
}

这一段代码 ,会无限执行default,造成CPU空转

image.png

常见的生产者-消费者模型

func write(ch chan int, times int) {
   for i := 0; i < times; i++ {
      ch <- i
      log.Printf("send: [%d]", i)
      break
   }
}
func read(ch chan int) {
   for {
      data, ok := <-ch // 阻塞的
      if ok {
         log.Printf("recv: [%d]", data)
      } else {
         log.Println("channel close ")
         break
      }
   }
}

以上练习的代码我都放在我的github里,欢迎star channel demo

channel的优化

由于channel底层还是使用了互斥锁Mutex,超高并发的场景下性能并不理想,因此社区主要有两种优化方案

无锁channel

无锁channel底层使用cas实现,由于在多核场景下性能并不理想,并且不提供FIFO的特性,暂时被搁浅

分段锁

分段锁 + channel是使用较多的方法,比较类似Java中的concurrentHashMap。可以按照消息ID做分片

参考

gopark函数和goready函数原理分析