写点什么

Go 语言 chan 实现原理,彻底搞懂 chan 读写机制

用户头像
微客鸟窝
关注
发布于: 19 小时前

在之前的文章中,我们有介绍过 channel 的使用,传送门。比较经典的一句话就是:


在 Go 语言中,提倡通过通信来共享内存,而不是通过共享内存来通信,其实就是提倡通过 channel 发送接收消息的方式进行数据传递。


这里我们再更加深层次的了解下 chan 。

chan 数据结构

src/runtime/chan.go 中定义了 channel 的数据结构如下:


type hchan struct {  qcount   uint  // 队列中的总元素个数  dataqsiz uint  // 环形队列大小,即可存放元素的个数  buf      unsafe.Pointer // 环形队列指针  elemsize uint16  //每个元素的大小  closed   uint32  //标识关闭状态  elemtype *_type // 元素类型  sendx    uint   // 发送索引,元素写入时存放到队列中的位置
recvx uint // 接收索引,元素从队列的该位置读出 recvq waitq // 等待读消息的goroutine队列 sendq waitq // 等待写消息的goroutine队列 lock mutex //互斥锁,chan不允许并发读写}
复制代码

环形队列

chan 内部实现了一个环形队列来作为缓冲区,队列的长度是在创建 chan 的时候所指定的。


如下图,我们创建一个可缓存 6 个元素的 channel 示意图:



  • dataqsiz 指示了队列长度为 6,即可缓存 6 个元素;

  • buf 指向队列的内存,队列中还剩余两个元素;

  • qcount 表示队列中还有两个元素;

  • sendx 指示后续写入的数据存储的位置,取值范围为 [0,6);

  • recvx 指示从该位置读取数据,取值范围为 [0,6);

等待队列

  • 从 channel 中读取数据,如果 channel 的缓冲区为空,或者没有缓冲区,那么当前的 goroutine 会被阻塞。

  • 向 channel 中写入数据,如果 channel 的缓冲区已满,或者没有缓冲区,那么当前的 goroutine 会被阻塞。

  • 被阻塞的 goroutine 会挂在 channel 的等待队列中。

  • 因为读所导致的阻塞,会被向 channel 写入数据的 goroutine 所唤醒。

  • 因为写所导致的阻塞,会被从 channel 读取数据的 goroutine 所唤醒。


如下图,为没有缓冲区的 channel,recvq 中有几个 goroutine 阻塞等待读数据。



注意,一般情况下 recvq 和 sendq 至少有一个为空。只有一个例外,那就是同一个 goroutine 使用 select 语句向 channel 一边写数据,一边读数据。

类型信息

一个 channel 只能传递一种类型的值,类型信息存储在 hchan 数据结构中:


  • elemsize :类型大小,用于在 buf 中定位元素位置。

  • elemtype :类型,用于数据传递过程中的赋值;

我们知道,channel 是并发安全的,即一个 channel 同时仅允许被一个 goroutine 读写。

channel 读写

创建 channel

创建 channel 其实就是初始化 hchan 结构,其类型信息和缓冲区长度由 make 语句传入,buf 的大小则与元素大小和缓冲区长度来共同决定。


创建 channel 的伪代码:


func makechan(t *chantype, size int) *hchan{  var c *hchan  c = new(hchan)  c.buf = malloc(元素类型大小*size)  c.elemsize = 元素类型大小  c.elemtype = 元素类型  c.dataqsiz = size    return c}
复制代码

向 channel 写数据

过程如下:


  1. 若等待接收队列 recvq 不为空,则缓冲区中无数据或无缓冲区,将直接从 recvq 取出 G ,并把数据写入,最后把该 G 唤醒,结束发送过程。

  2. 若缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程。

  3. 若缓冲区中没有空余位置,则将发送数据写入 G,将当前 G 加入 sendq ,进入睡眠,等待被读 goroutine 唤醒。

从 channel 读数据

过程如下:


  1. 若等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G ,把 G 中数据读出,最后把 G 唤醒,结束读取过程;

  2. 如果等待发送队列 sendq 不为空,说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程;

  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;

  4. 将当前 goroutine 加入 recvq ,进入睡眠,等待被写 goroutine 唤醒;

关闭 channel

关闭 channel 时会将 recvq 中的 G 全部唤醒,,本该写入 G 的数据位置为 nil。将 sendq 中的 G 全部唤醒,但是这些 G 会 panic。


panic 出现的场景还有:


  1. 关闭值为 nil 的 channel

  2. 关闭已经关闭的 channel

  3. 向已经关闭的 channel 中写数据

用法

单项 channel

只能发送或者只能接收的 channel 为单向 channel。

单向 channel 声明

只需要在基础声明中增加操作符即可:


send := make(ch<- int) //只能发送数据给channelreceive := make(<-ch int) //只能从channel中接收数据
复制代码


示例:


package main
import ( "fmt")//只能发送通道func send(s chan<- string){ s <- "微客鸟窝"}//只能接收通道func receive(r <-chan string){ str := <-r fmt.Println("str:",str)}func main() { //创建一个双向通道 ch := make(chan string) go send(ch) receive(ch)}
//运行结果: str: 微客鸟窝
复制代码

select

select 可以实现多路复用,即同时监听多个 channel。


  • 发现哪个 channel 有数据产生,就执行相应的 case 分支

  • 如果同时有多个 case 分支可以执行,则会随机选择一个

  • 如果一个 case 分支都不可执行,则 select 会一直等待


示例:


package main
import ( "fmt")
func main() { ch := make(chan int, 1) for i := 0; i < 10; i++ { select { case x := <-ch: fmt.Println(x) case ch <- i: fmt.Println("--", i) } }}
复制代码


运行结果:


-- 00-- 22-- 44-- 66-- 88
复制代码


select 的 case 语句读 channel 不会阻塞,尽管 channel 中没有数据。这是由于 case 语句编译后调用读 channel 时会明确传入不阻塞的参数,此时读不到数据时不会将当前 goroutine 加入到等待队列,而是直接返回。

range

通过 range 可以持续从 channel 中读取数据,类似于遍历,当 channel 中没有数据时会阻塞当前 goroutine ,与读 channel 时阻塞处理机制一样。


示例:


for ch := range chanName {  fmt.Printf("chan: %d\n", ch)}
复制代码


注意:如果向此 channel 写数据的 goroutine 退出时,系统检测到这种情况后会 panic,否则 range 将会永久阻塞。

用户头像

微客鸟窝

关注

还未添加个人签名 2019.11.01 加入

公众号《微客鸟窝》笔者,目前从事web后端开发,涉及语言PHP、golang。获得美国《时代周刊》2006年度风云人物!

评论

发布
暂无评论
Go语言chan实现原理,彻底搞懂chan读写机制