Go语言中的Goroutine是通过Channel来传递数据的,在CSP(通信顺序模型)中,Goroutine是实体,而Channel是传递信息的媒介。
Channel中无论是接收数据还是发送数据,都遵循先进先出的原则。
乐观锁是一种并发控制的思想,而不是真正的锁。
某种意义上,Channel是一个用于同步和通信的有锁队列。
Channel的类型
- 同步Channel
- 无需缓冲区,发送方直接将数据发送接收方。
- 异步Channel
- 基于环形缓冲区的传统生产者消费者模型。
- chan struct{}类型的异步Channel
- struct{} 类型不占用内存空间,无需实现缓冲区和直接发送的语义。
Channel的数据结构
type hchan struct {
qcount uint // channel中的元素格式
dataqsiz uint // 循环队列的长度
buf unsafe.Pointer // 缓冲区指针
elemsize uint16 // channel能够收发的大小
closed uint32
elemtype *_type // channel能够收发的类型
sendx uint // 发送操作处理到的位置
recvx uint // 接收操作处理到的位置
recvq waitq // 存储当前channel由缓冲区不足而阻塞的goroutine列表,是双向链表
sendq waitq
lock mutex
}
其中,qcount,dataqsiz,buf,sendx,recv构成底层的循环队列。
Create CHANNEL
Channel通过make进行创建,如make(chan int, 10),Go的编译器会将表达式转换成OMAKE类型的节点,在类型检查阶段将OMAKE类型的节点转换成OMAKECHAN类型,最终OMAKECHAN在SSA中间代码生成阶段之前会被转换成调用makechan或者makechan64的函数,后者用力啊处理缓冲区大小大于2^32的情况。
makechan的实现逻辑主要是根据Channel中收发元素的类型和缓冲区的大小来初始化hchan结构体和缓冲区:
- 如果当前 Channel 中不存在缓冲区,那么就只会为
runtime.hchan
分配一段内存空间; - 如果当前 Channel 中存储的类型不是指针类型,就会为当前的 Channel 和底层的数组分配一块连续的内存空间;
- 在默认情况下会单独为
runtime.hchan
和缓冲区分配内存;
最后再更新相应的字段。
SEND DATA
通道中的数据发送用 ch <- i
表示,也就是将 i 中的数据发送到通道中。最终 <- 符号会被转换成OSEND节点,该节点最终在代码中被转化成chansend1函数,其调用了chansend函数并传入通道和所需发送的数据。也就是在chansend函数中实现了发送数据给通道的所有逻辑。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool
其中,block是为了设定当前发送操作是否为阻塞的。为了防止竞态条件的产生,在执行逻辑前需要先加锁。
chansend的具体逻辑如下:
-
当存在等待的接收者时,通过
runtime.send
直接将数据发送给阻塞的接收者;- 其中send又会调用sendDirect和goready,从名字不难看出是如果操作的
-
当缓冲区存在空余空间时,将发送的数据写入 Channel 的缓冲区;
- 首先会检查缓冲区中可存放的下一个地址,然后拷贝数据到相应位置,并增加索引和计数器
-
当不存在缓冲区或者缓冲区已满时,等待其他 Goroutine 从 Channel 接收数据;
-
相对复杂一点,具体逻辑如下
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { ... if !block { unlock(&c.lock) return false } gp := getg() mysg := acquireSudog() mysg.elem = ep mysg.g = gp mysg.c = c gp.waiting = mysg c.sendq.enqueue(mysg) goparkunlock(&c.lock, waitReasonChanSend, traceEvGoBlockSend, 3) gp.waiting = nil gp.param = nil mysg.c = nil releaseSudog(mysg) return true }
-
总结一下就是:
- 如果当前 Channel 的
recvq
上存在已经被阻塞的 Goroutine,那么会直接将数据发送给当前的 Goroutine 并将其设置成下一个运行的 Goroutine; - 如果 Channel 存在缓冲区并且其中还有空闲的容量,我们就会直接将数据直接存储到当前缓冲区
sendx
所在的位置上; - 如果不满足上面的两种情况,就会创建一个
runtime.sudog
结构并将其加入 Channel 的sendq
队列中,当前 Goroutine 也会陷入阻塞等待其他的协程从 Channel 接收数据;
RECIVE Data
接收数据有两种方式,分别是 i <- ch 和 i, ok <- ch。这两种方法最终都会被编译器处理为ORECV类型的节点,而后者会在类型检查阶段被转换成OAS2RECV类型。这两种方式最终会被转换成对chanrecv1和chanrecv2的调用,但本质上都会调用chanrecv函数。
chanrecv基本实现如下
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
if c == nil {
if !block {
return
}
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
lock(&c.lock)
if c.closed != 0 && c.qcount == 0 {
unlock(&c.lock)
if ep != nil {
typedmemclr(c.elemtype, ep)
}
return true, false
}
总结起来就是:
- 如果 Channel 为空,那么就会直接调用
runtime.gopark
挂起当前 Goroutine; - 如果 Channel 已经关闭并且缓冲区没有任何数据,
runtime.chanrecv
函数会直接返回; - 如果 Channel 的
sendq
队列中存在挂起的 Goroutine,就会将recvx
索引所在的数据拷贝到接收变量所在的内存空间上并将sendq
队列中 Goroutine 的数据拷贝到缓冲区; - 如果 Channel 的缓冲区中包含数据就会直接读取
recvx
索引对应的数据; - 在默认情况下会挂起当前的 Goroutine,将
runtime.sudog
结构加入recvq
队列并陷入休眠等待调度器的唤醒;