程序员社区

Go:channel剖析

Go内建channel实现了go协程之间数据的读写相关操作。

并发在Go当中不仅仅是语法。它是一种设计模式。该模式提供了在处理常见并发问题的解决方案。因为并发需要同步 。Go并发是源自CSP模型,通过channel来实现协程的同步。Go并发哲学是:不通过共享内存来通信,而是通过通信来共享内存。

但Go相信你会做正确的事。因此下文将揭示Go这一哲学,channel是如何使用队列来实现的。

如何创建channel

func goRoutineA(a <-chan int) {
    val := <-a
    fmt.Println("goRoutineA received the data", val)
}
func main() {
    ch := make(chan int)
    go goRoutineA(ch)
    time.Sleep(time.Second * 1)
}
Go:channel剖析插图

Go:channel剖析插图1

因此,在Go中让受读写channel阻塞的Goroutine再次进入可执行状态是channel的责任,当channel收到或发送了数据就会触发goroutine状态的转变。

如果你不熟悉go调度的话,可以阅读这篇文章https://morsmachine.dk/go-scheduler

Channel结构

在Go当中,channel结构体是goroutine之间传递消息的基础。因此当我们创建了channel之后,其内部结构是怎样的呢?

ch := make(chan int, 3)
Go:channel剖析插图2
带缓冲的channel结构

看着不错,但这些代表啥意思呢?channel是从哪创建来的呢?我们在深入之前先了解一些重要的结构体。

hchan结构体

当我们写make(chan int, 2),channel就根据hchan结构体来创建的,包含如下字段:

Go:channel剖析插图3
hchan和waitq结构体

让我们对在channel结构中遇到的几个字段进行描述。
dataqsize:是缓存大小,就是make(chan T, N),N的大小
elemsize:是channel单个元素大小
buf:是循环队列,channel数据存放的地方,仅带缓存channel使用
close:代表当前channel是否已经关闭状态。当channel刚创建这个值是0,代表channel是开的;当值为1的时,channel关闭。
sendx和recvx:是环形缓存的状态值,表示当前缓存索引,根据这两值可以知道缓存数据发送和接收的索引位置。
sendq和recvq:等待队列,分别用于存储阻塞的发送数据的goroutine和读取数据的goroutine。
lock:锁定channel每个读和写操作,发送和接收必须是互斥操作。

那sudog代表啥? Sudog代表goroutine。

Go:channel剖析插图4
sudog关键字段

我们再一步步的缕下channel结构。

Go:channel剖析插图5

以上代码中,在22行之前channel的结构是怎样的?

Go:channel剖析插图6

注意上面高亮显示的第47和48行。记住上面的recvq的作用:

recvq用于存储受读channel阻塞的goroutine

在上面代码中22行之前,有两个goroutine(goroutineA和goroutineB)需要从channel中读取数据。

因为在22行之前,channel中并没有数据可读,因此两个goroutine都因读取数据受阻塞,并以sudog结构保存在recvq中。

sudog表示goroutine

recvq和sendq本质是链表,如下图所示:

Go:channel剖析插图7
recvq结构

这些结构体很重要。我们看下当把数据写入channel会发生什么。

发送操作:c <- x

channel发送数据的底层类型

1、发送数据到nil channel

Go:channel剖析插图8

如果我们在nil通道上发送数据,当前的goroutine将暂停它的操作。
2、向关闭的channel发送数据

Go:channel剖析插图9

如果试图向关闭的channel发送数据,goroutine会panic,协程退出。
3、存在goroutine在通道上被阻塞:数据直接发送到goroutine。

Go:channel剖析插图10

这是recvq结构体起重要作用的地方。如果在recvq中有goroutine在等待,当前写入channel的数据会直接传给对应阻塞的goroutine。发送函数的实现。

Go:channel剖析插图11

注意以上代码的396行goready(gp, skip+1),在等待数据受阻塞的goroutine通过goready函数重新变为可运行状态,go调度器会再次运行该goroutine。
4、带缓存的channel如果hchan.buf还有空间的话,发送的数据会存在buffer中

Go:channel剖析插图12

chanbuf(c, i)函数访问对应的内存区域。决定hchan.buf是否还有空间是通过比较qcount和dataqsiz来实现的。
5、hchan.buf满了

Go:channel剖析插图13

在当前栈中创建一个goroutine对象。acquireSudog函数将当前goroutine变为park状态,将goroutine添加到channel的sendq中。

发送操作总结

1、锁定整个channel结构
2、决定写。从等待队列recvq中取出一个等待的goroutine,并将数据直接写入等待的goroutine。
3、如果recvq是空的,考虑buffer是否已满。buffer有空间就通过typedmemmove将数据拷贝到缓存。拷贝的实现方式:memmove()函数内存拷贝。
4、如果buffer满了的话,写入的值会保存在当前执行的goroutine中,并将当前goroutine存在sendq队列并从runtime中挂起。
第4点很有意思,如果缓冲区已满,则要写入的元素将保存在当前正在执行的goroutine的结构中。这就是为什么不带缓存的channel称为unbuffered,即使hchan结构体中包含buf字段。因为不带缓存的channel如果没有接收者的话,发送数据到channel,数据会存放在sudog结构的elem字段。

让我给你一个例子来更详细地阐明第四点。假设我们有以下代码。

Go:channel剖析插图14

在第10行,chan c2的运行时结构是什么?

Go:channel剖析插图15

可以看到以上代码中buf中并没有保存整数2,会保存在sudog结构中。由于goroutineA试图通过c2通道发送数据,当时并没有接收者,因此goroutineA将被添加到c2通道的sendq中并挂起。我们可以查看sendq的运行时结构来验证。

Go:channel剖析插图16

现在,我们已经对通道上的发送操作有了概述,前面的示例代码第22行,如果一旦我们将一个值发送到channel,会发生什么。

ch <- 3

由于该channel的recvq有goroutine处于等待状态,它将等待队列中第一个sudog取出,并将数据直接传给该goroutine。

记住channel中所有的值转移都是值拷贝

Go:channel剖析插图17

以上程序的输出是什么?记住,通道对值的副本进行操作。 在我们的例子中,channel会将g处的值复制到它的缓冲区中。
输出:

&{Ankur 25}
modifyUser Received Value &{Ankur Anand 100}
printUser goRoutine called &{Ankur 25}
&{Anand 100}
Go:channel剖析插图18

写入数据操作<-

和发送数据非常类似:

Go:channel剖析插图19

Select

多个channel的复用

Go:channel剖析插图20

1、操作是互斥的,因此需要在select case中获取所有相关通道上的锁,这是通过按Hchan地址对case进行排序来获得锁定顺序,这样就不会同时锁定所有相关通道上。

sellock(scases, lockorder)

scases数据中的每个scase都是一个结构体,包含当前case操作类型和对应的channel操作。

Go:channel剖析插图21

kind,是当前case的操作类型,可能是:CaseRecv,CaseSend和CaseDefault。
2、通过计算投票顺序,将所有的case都重新洗牌,提供一种伪随机保证。然后根据轮询顺序依次遍历所有case,查看是否有任何case已经准备好进行通信。这个轮询顺序使得选择操作不必遵循程序中声明的顺序。

Go:channel剖析插图22
计算选择顺序

Go:channel剖析插图23
select中的case

3、只要有一个不阻塞的channel操作,select语句就可以返回,如果选择的通道已经准备好了,甚至不需要查看所有的channel。
4、如果当前没有channel响应,并且没有default语句,则当前goroutine必须根据当前case,在等待队列中挂起。

Go:channel剖析插图24

sg.isSelect表示当前goroutine参与select语法声明。
5、在select语句中读取、发送和关闭操作和通常channel的读取、发送和关闭是类似的。

总结

channel在go中是一种非常有意思而且重要的机制。要想很好的使用channel就必须理解其工作原理。本文介绍了go中channel的基本工作原理。

赞(0) 打赏
未经允许不得转载:IDEA激活码 » Go:channel剖析

一个分享Java & Python知识的社区