彻底明白Go语言的Channel了

channel 概述

Hello 大家好!我们又见面了,本文我们一起搞懂 Go语言中 channel 及channel底层实现和一些常见面试题。

channel 是 Go 语言内建的first-class类型,也是 Go 语言与众不同的特性之一。先看一个应用场景,比如协程A执行过程中需要创建子协程A1、A2 ... An,协程A创建完子协程后就等待子协程退出,这样场景的Go为我们提供三种解决方案:

  • 使用 channel 控制子协程
  • waitGroup 信号量机制控制子协程
  • Context 使用上下文控制子协程

它们三种解决方案各有优劣,比如:使用 channel 来控制子协程优点实现简单,缺点就是当需要大量创建协程时就需要有相同数量的 channel,这样对于子协程继续派生出来的协程就不方便控制。

首先,想一想,为什么 Go 引入channel,及channel能为我们提供解决怎么样的问题?了解 channel 可以从CSP 模型了解,CSP模型是 Tony Hoare 在1978年发表的论文中,CSP主要讲一种并发编程语言,CSP 允许使用进程组建来描述系统,它们独立运行,并且只通过消息传递的方式通信。 这篇论文是对 Go 创始人Rob Pike 对Go语言并发设计的产生巨大影响,最后通过引入 channel 这种新的类型,来实现 CSP 的思想。

在使用 channel 类型时候,你无需引入某个包,就能使用它,它就是 Go 语言内置的类型,不像其他库,你必须的引入sync包货atomic 包才能使用它们。

channel 基本用法

channel 很多人常说所谓的 通道,那么通道也是我们生活中类似的管道,用来传输东西。计算机可以使用通道来进行通信,在Go语言中,常见的允许 Goroutine 之间进行数据传输。在传输中,你需要明确一些规则,首先,每个通道只允许交换指定类型的数据,也称为通道元素类型(类似生活中,自家水管只允许运输能喝的水,运输汽油,你需要使用另一个管道)。在 Go 语言中,使用chan 关键字来声明一个新通道,使用 close() 函数来关闭通道。

定义好通道,可以往 channel 发送数据,从channel中接收数据,你还可以定义 只能接受只能发送也可以接受又可以发送 三种类型。

声明通道类型格式如下

var 变量 chan 元素类型
复制代码

例子

var ch1 chan int   // 声明一个传递整型的通道
var ch3 chan []int // 声明一个传递int切片的通道
复制代码

创建 channel

var ch chan string
fmt.Println(ch) //输出:<nil>
复制代码

注:通道是引用类型,通道类型的空值是nil。

声明的通道后需要使用make函数初始化之后才能使用,以channel 的缓冲区大小也是可选的。

func main() {
  //初始化通道,缓冲区大小为2
  ch := make(chan int,2)
  ch <- 1
  ch <- 2
  ch <- 3 //会报错,因为缓冲区只允许大小为2
  x1 := <- ch
  x2:= <- ch
  fmt.Println(x1)
  fmt.Println(x2)
}
复制代码

(1)发送数据

往 chan 中发送一个数据使用 ch <-,格式如下:

ch <- 1 //把1发送到ch中
复制代码

(2)接收数据

从 chan 中接收一条数据使用<-ch,接收数据也是一条语句,课时如下:

x := <- ch //从ch中接收只并赋值给变量x1。
<- ch  //从ch中接收值,忽略结果
复制代码

:关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

channel 实现原理

这节,面试官会问:channel 的底层实现?

接下来,一起学习 chan 的数据结构及初始化,还有三个最重要操作方法分为是 sendrecvclose,认真学习 channel 底层原理的实现。

源码目录位置:runtime/chan.go,以下贴出chan 类型的数据结构如下:

type hchan struct {
  qcount   uint           // total data in the queue(循环队列元素数量)
  dataqsiz uint           // size of the circular queue(循环队列大小)
  buf      unsafe.Pointer // points to an array of dataqsiz elements(循环队列指针)
  elemsize uint16 //chan中元素大小
  closed   uint32 //是否已经close
  elemtype *_type // element type(chan元素类型)
  sendx    uint   // send index(send在buf中索引)
  recvx    uint   // receive index(recv在buf中索引)
  recvq    waitq  // list of recv waiters(receive的等待队列)
  sendq    waitq  // list of send waiters(sender等待队列)
​
  // lock protects all fields in hchan, as well as several
  // fields in sudogs blocked on this channel.
  //
  // Do not change another G's status while holding this lock
  // (in particular, do not ready a G), as this can deadlock
  // with stack shrinking.
  lock mutex //互斥锁,保护所有字段,上面注释已经讲得非常明确了
}
复制代码

(1)chan 初始化 Go 在编译时,会根据容量大小选择调用 makechan64,还是 makechan。通过源码我们可以知道,makechan64只做了size检查,然后底层最终还是调用makechan实现的。(makechan 的目标就是生成 hchan 对象)

Makechan 到底做了什么,源码如下:

func makechan(t *chantype, size int) *hchan {
  elem := t.elem
  //编译器会检查类型是否安全
  // compiler checks this but be safe.
  if elem.size >= 1<<16 {//是否 >= 2^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"))
  }
  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:
    // Elements contain pointers.
    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)
  if debugChan {
    print("makechan: chan=", c, "; elemsize=", elem.size, "; dataqsiz=", size, "\n")
  }
  return c
}
复制代码

总结:channel 底层是根据不同容量和元素类型,来分配不同的对象来初始化 chan 对象的字段,及返回 hchan 对象。

(2)send方法

send() 是往chan 发送数据,方法大致分为6个部分,源码如下:

第一部分:

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")
  }
  // 部分代码省略。。。
}
复制代码

send最开始,首先是判断,如果 chan 为nil 的话,调用gopark 进行阻塞休眠,这时,调用者永远阻塞住了。那么这个代码throw("unreachable")不会执行的。

第二部分:

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

第三部分:

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

第三部分,如果 chan 已经被close,你再往里面发送数据的话会出现Panic。如下代码会出现Panic。

ch := make(chan int,1)
close(ch)
ch <- 1
复制代码

第四部分:

//第四部分:如果有recvq接收者就说明buf中没数据,因此直接从sender送到receizver中
if sg := c.recvq.dequeue(); sg != nil {
    // Found a waiting receiver. We pass the value we want to send
    // directly to the receiver, bypassing the channel buffer (if any).
    send(c, sg, ep, func() { unlock(&c.lock) }, 3)
    return true
  }
复制代码

第五部分:

// 第五部分,buf还没满
if c.qcount < c.dataqsiz {
    // Space is available in the channel buffer. Enqueue the element to send.
    qp := chanbuf(c, c.sendx)
    if raceenabled {
      racenotify(c, c.sendx, nil)
    }
    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 了。 (2)recv

在处理从 chan 中接收数据,源码如下:

第一部分:

if c == nil { //判断chan为nil
    if !block {
      return
    }
    gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
    throw("unreachable")
  }
复制代码

从chan 获取数据时,如果 chan 为 nil,调用者会被永远阻塞。

第二部分:

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

第三部分:

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

如果 chan 已经被 close 了,并且队列中没有缓存的元素,那么返回 true、false。

第四部分:

// 第四部分,如果sendq队列中有等待发送的sender
if sg := c.sendq.dequeue(); sg != nil {
    // Found a waiting sender. If buffer is size 0, receive value
    // directly from sender. Otherwise, receive from head of queue
    // and add sender's value to the tail of the queue (both map to
    // the same buffer slot because the queue is full).
    recv(c, sg, ep, func() { unlock(&c.lock) }, 3)
    return true, true
  }
复制代码

当处理 buf 满的情况。这个时候,如果是 unbuffer 的 chan,就直接将 sender 的数据复制给 receiver,否则就从队列头部读取一个值,并把这个 sender 的值加入到队列尾部。

第五部分:处理没有等待的 sender 的情况。这个是和 chansend 共用一把大锁,所以不会有并发的问题。如果 buf 有元素,就取出一个元素给 receiver。

第六部分: 处理 buf 中没有元素的情况。如果没有元素,那么当前的 receiver 就会被阻塞,直到它从 sender 中接收了数据,或者是 chan 被 close,才返回。

(3)close

通过 close函数,你可以把 chan 关闭,底层调用 closechan 方法执行。具体源码和上面两个位置一样。

(4)使用channel 踩的坑

常见的错误 panic 和 goroutine 泄漏

示例1:

ch := make(chan int,1)
close(ch)
ch <- 1
复制代码

往 chan 添加,但close了,会出现Panic,解决就是不close。

示例2:

  ch := make(chan int,1)
  ch <- 1
  close(ch)
  <- ch
  close(ch)
复制代码

从 chan 取出数据,但 close了,也会Panic

(5)介绍 panic 和 recover

Panic 和 recover 也是面试点,简单注意

Panic :在 Go 语言中,出现 Panic 是代表一个严重问题,意味着程序结束并退出。在 Go 中 Panic 关键字用于抛出异常的。类似 Java 中的 throw。

recover:在 Go 语言中,用于将程序状态出现严重错误恢复到正常状态。当 发生 Panic 后,你需要使用recover 捕获,不捕获程序会退出。类似 Java 的 try catch 捕获异常。

你的每次 点赞+收藏+关注,是我创作最大动力。加油,奋斗永远都在路上!

Guess you like

Origin juejin.im/post/7048829433993494565