文章目录
chan
不要通过共享内存进行通信。建议,通过通信来共享内存 。(Do not communicate by sharing memory; instead, share memory by communicating) 这是 Go 语言并发的哲学座右铭。
相对于使用 sync.Mutex 这样的并发原语。虽然大多数锁的问题可以通过 channel 或者传统的锁两种方式之一解决,但是 Go 语言核心团队更加推荐使用 CSP 的方式。
结构图
简单说明:
buf
是有缓冲的channel所特有的结构,用来存储缓存数据。是个循环队列- 存储结构应该用的是数组
sendx
和recvx
用于记录buf
这个循环队列
中的发送或者接收的index
lock
是个互斥锁。recvq
和sendq
分别是接收(<-channel
)或者发送(channel <- xxx
)的goroutine
抽象出来的结构体(sudog
)的队列。是个双向链表
源码位于
/runtime/chan.go
中(目前版本:1.15)。结构体为hchan
。
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
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// 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
}
type waitq struct {
first *sudog
last *sudog
}
hchan的使用
创建
ch := make(chan int, 3)
创建channel
实际上是在内存中实例化一个hchan
的结构体,并返回一个ch指针,我们使用过程中channel
在函数之间的传递都是用的该指针,这就是为什么函数传递中无需使用channel
的指针,而直接用channel
就行了,因为channel本身就是一个指针。
channel中发送send(ch <- xxx)和recv(<- ch)接收
如果你想让goroutine
以先进先出(FIFO)的方式进入一个结构体中,你会怎么操作? 加锁!对的!channel
就是用了一个锁。hchan
本身包含一个互斥锁mutex
。
channel中队列是如何实现的
channel中有个缓存buf,是用来缓存数据的(假如实例化了带缓存的channel的话)队列。我们先来看看是如何实现“队列”的。 还是刚才创建的那个channel
ch := make(chan int, 3)
当使用send (ch <- xx)
或者recv ( <-ch)
的时候,首先要锁住hchan
这个结构体。
然后开始send(ch <- xx)
.
ch <- 1
ch <- 1
ch <- 1
然后是取recv ( <-ch)
的过程,是个逆向的操作,也是需要加锁。
然后开始recv (<-ch)
数据。
<-ch
<-ch
<-ch
注意以上两幅图中buf
和recvx
以及sendx
的变化,recvx
和sendx
是根据循环队列(实现方式为数组) buf的变动而改变的。至于为什么channel会使用循环链表作为缓存结构,我个人认为是在缓存列表在动态的send和recv过程中,定位当前send或者recvx的位置、选择send的和recvx的位置比较方便吧,只要顺着链表顺序一直旋转操作就好。
缓存中按数组顺序存放,取数据的时候按数组顺序读取,符合FIFO的原则。
send/recv的细化操作
注意:缓存链表中以上每一步的操作,都是需要加锁操作的!
每一步的操作的细节可以细化为:
- 加锁
- 把数据从goroutine中copy到“队列”中
- 释放锁
每一步的操作总结为动态图为:(发送过程)
接受过程
- 加锁
- 将数据从队列中copy到goroutine中
- 释放锁
所以不难看出,Go中那句经典的话:==Do not communicate by sharing memory; instead, share memory by communicating.==的具体实现就是利用channel把数据从一端copy到了另一端!
该图解释
- G1 将变量写入channel
ch <- 1
- G2 将变量从channel中读取出来
<-ch
channel缓存满了之后会发生什么?其中的原理是什么?
使用的时候,我们都知道,当channel缓存满了或者缓存为空的时候,我们继续send(ch <- xxx)
或者recv(<- ch)
会阻塞当前goroutine
,但是,是如何实现的呢?
我们知道,Go的goroutine
是用户态的线程(user-space threads
),用户态的线程是需要自己去调度的,Go有运行时的scheduler
去帮我们完成调度这件事情。关于Go的调度模型GMP模型我在此不做赘述,如果不了解,可以看原文作者的另一篇文章(Go调度原理)
goroutine的阻塞操作,实际上是调用send (ch <- xx)
或者recv ( <-ch)
的时候主动触发的,具体请看以下内容:
//goroutine1 中,记做G1
ch := make(chan int, 3)
ch <- 1
ch <- 1
ch <- 1
且此时G1和M仍然处于绑定关系,仍然继续运行。
这个时候G1正在正常运行,当再次进行send操作(ch<-1)
的时候,会主动调用Go的调度器,让G1等待,并让出M,让其他G去使用。
- G1 执行 ch <- 1
- 判断ch是否已满,如果未满则写入buf否则抽象为sudog写入sendq - 参见 chansend 方法
同时G1也会被抽象成含有G1指针和send元素的sudog
结构体保存到hchan
的sendq
中等待被唤醒。
那么,G1什么时候被唤醒呢?这个时候G2隆重登场。
G2执行了recv操作p := <-ch
,于是会发生以下的操作:
G2从缓存队列中取出数据,channel会将等待队列中的G1推出。
将G1当时send的数据推到缓存中,然后调用Go的scheduler,唤醒G1,并把G1放到可运行的Goroutine队列中。
假如是先进行执行recv操作的G2会怎么样?
你可能会顺着以上的思路反推。首先:
这个时候G2会主动调用Go的调度器,让G2等待,并从让出M,让其他G去使用。
如果上图中qcount等于0,则G2还会被抽象成含有G2指针和recv
空元素的sudog
结构体保存到hchan
的recvq
中等待被唤醒。
此时恰好有个goroutine
G1开始向channel中推送数据 ch <- 1
。 此时,非常有意思的事情发生了:
G1并没有锁住channel,然后将数据放到缓存中,而是直接把数据从G1直接copy到了G2的栈中。 这种方式非常的赞!在唤醒过程中,G2无需再获得channel的锁,然后从缓存中取数据。减少了内存的copy,提高了效率。
之后的事情显而易见: