golang | 各种channel操作的底层实现

2021-07-15 16:31:54 浏览数 (1)

首先,未初始化的channel变量值为nil:

channel底层其实就是个指针,这个下面会讲,所以其nil值,在底层就是用0表示的,如上面的输出。

上图是main函数的汇编,其中选中的两行,就是调用p函数的逻辑。

p函数的参数是通过ax寄存器传递的,由上图可见,在调用p函数之前,ax寄存器的值,通过xorl指令进行了清零,这也是channel变量的nil值,在底层是用0表示的另一个佐证。

channel是通过make函数创建的,make可以创建unbuffered channel,也可以创建buffered channel:

make其实并不是一个真正的函数,它会在编译阶段,被go编译器替换为对runtime.makechan函数的调用:

该替换信息也可以通过汇编查看:

上图中选中行为make函数的汇编逻辑,其中hello.go:8的第四行,和hello.go:11的第三行,都是在调用runtime.makechan函数。

在看runtime.makechan函数之前,我们先通过上图的汇编,看下make创建unbuffered channel和buffered channel的方式有什么不同。

源码中第8行是创建unbuffered channel,对应到汇编里,就是上图中hello.go:8的那4行。

源码中第11行是创建buffered channel,对应到汇编里,就是上图中hello.go:11的那4行

由上图可见,hello.go:8和hello.go:11的逻辑基本相同,都是先将要创建channel的类型,放到ax寄存器里,然后将要创建channel的buffer size,放到bx寄存器里,最后调用runtime.makechan函数,转入真正的创建channel逻辑。

这里需要说明一下,go函数之间的调用,其参数和结果的传递,主要是通过寄存器来完成的(go1.17之后,之前是通过栈),对于上面的makechan函数来说,第一个参数是通过ax寄存器传递的,第二个参数是通过bx寄存器传递的。

有关go函数之间调用,参数和结果传递方式的具体规则,请参考以下文档:

https://github.com/golang/go/blob/master/src/cmd/compile/abi-internal.md

再回到上面的问题,由上图汇编可见,创建unbuffered channel和buffered channel的流程是一样的,唯一区别就是bx寄存器的值不同,即指定的buffer size不一样。

hello.go:8是创建unbuffered channel,bx的值是0,hello.go:11是创建buffered channel,bx的值是8,即我们在源码中指定的值。

所以,make(chan int)其实等价于make(chan int, 0),即buffer size等于0。

接下来我们看一下runtime.makechan函数的实现:

连接上文,参数t,即channel的类型,是通过ax寄存器传递的,参数size,即channel的buffer size,是通过bx寄存器传递的。

上面我们也提到,channel变量底层其实就是个指针,该指针的类型,就是上图中makechan函数的返回类型*hchan:

hchan结构体各字段的用途是:

qcount表示的是当前channel的buffer里已经缓冲了几个元素。如果是unbuffered channel,该字段一直为0。

dataqsiz表示的是当前channel的buffer里最多可缓冲几个元素,即上文我们提到的buffer size。如果是unbuffered channel,该字段一直为0。

buf指向的是用于接受缓冲元素的总内存,我们可以把它理解成一个数组,数组的元素类型就是channel的元素类型,数组的最大容量,就是上面dataqsiz的值。如果是unbuffered channel,该buf不用分配。

elemsize表示的是channel元素类型的大小。

closed表示的是当前channel是否已关闭,即调用过close(c)方法。

elemtype表示的是channel的元素类型。

sendx表示的是下一次向channel中发送数据,该数据会被拷贝到buf字段表示的元素数组的位置,当该位置超过数组最大值以后,会从0重新开始。如果是unbuffered channel,该字段一直为0。

recvx表示的是下一次从channel中接收数据,该接收会从buf字段表示数组的recvx位置拷贝数据到目标内存。如果是unbuffered channel,该字段一直为0。

recvq表示的是等待从channel中接受数据的goroutine队列。当buf中缓冲的元素个数为0,且sendq表示的等待发送数据的goroutine队列为空时,再有goroutine想从这个channel中读取数据,就会被阻塞等待在这里队列里。

sendq表示的是等待向channel中发送数据的goroutine队列。当buf中缓冲的元素个数已达到最大值,且recvq表示的等待接受数据的goroutine队列为空时,再有goroutine想向这个channel里发送数据,就会被阻塞等待在这个队列里。

lock表示的是channel的锁,为了保证channel读写的并发安全,channel的很多操作都是加锁的。

有关以上字段是如何配合使用的,这个会在下文的各种示例分析中看到。

这里有一点需要注意的是,因为channel的元素传递是拷贝操作,所以如果channel元素类型占较大内存,要考虑是否应该传递其指针。

上面makechan的那张图中,83到85行是调试信息,如果debugChan为true,则在创建channel时,会输出channel的元素大小,及可缓冲的元素个数等信息。

下面我们修改下debugChan的值,然后写个例子看下其输出。

示例代码:

go build构建程序并执行:

上图选中行中,第一行是make(chan S)的输出,该make创建的channel,元素大小为80字节,可缓冲元素个数为0。

第二行是make(chan S, 8)的输出,它创建的channel,元素大小也为80字节,可缓冲元素个数为8。

因为channel元素传递是拷贝操作,所以对于这个示例来说,每次send或receive时,都要拷贝80字节,此种情况下,就应该考虑将该channel改为传递S的指针,而不是S本身。

接下来看下如何向channel中发送数据:

也是在编译阶段,c <- v被转成了对runtime.chansend1函数的调用:

同样,我们也可以根据汇编代码得出该信息:

上图是send函数的汇编逻辑,其中和发送相关的,是上图中的选中行,即hello.go:10的那4行。

根据go的calling convention可知,上面示例中的send函数在被调用时,参数c被放到了ax寄存器里,参数v被放到了bx寄存器里。

再看上图中的汇编代码,hello.go:10中的第一行把bx的值,放到了栈的0x10(SP)位置,接着把该位置的地址,又放到了bx里,也就是说,此时bx里存放的是参数v的地址。

接着在hello.go:10中的第四行,调用runtime.chansend1函数,该函数的两个参数,代表channel的变量c,以及要发送的值v的地址,同样也是通过ax和bx传递过去。

来看下runtime.chansend1函数:

该函数的参数c,就是上面示例中send函数的参数c,该函数的参数elem,就是上面示例中send函数参数v的地址。

该函数又调用了chansend函数:

chansend函数是向channel发送数据的主体逻辑,其大致步骤请参考上图中的注释,同时也可以结合上文提到的,hchan结构体中各字段的意义,来理解这段代码。

由上图可见,为了保证逻辑的正确性,向channel发送数据的操作都进行了加锁,所以,虽然channel面向用户来说是无锁的,但其内部实现是依靠锁来完成的。

再来看下从channel中接收数据:

在编译阶段,v := <-c 被转换成了对函数runtime.chanrecv1的调用:

对照汇编进一步确认:

上图是receive函数的汇编逻辑,当该函数被调用时,ax寄存器里的值是receive函数的参数c,即channel变量。

上图选中行,是v := <-c的汇编代码,它先将0x10(SP)开始的8字节内存清零,然后再将该内存的地址赋值给bx,最后调用runtime.chanrecv1。

由此我们可以推测,runtime.chanrecv1函数应该有两个参数,一个是channel变量,另一个是内存地址,用于存放要接收到的数据。

看下runtime.chanrecv1:

参数类型与个数和我们推测的一样,它又调用了chanrecv:

chanrecv是从channel中接收数据的主体逻辑,其大致步骤请参考上图中的注释,同时也可以结合上文提到的,hchan结构体中各字段的意义,来理解这段代码。

和chansend类似,chanrecv的主体逻辑也是在加锁下完成的。

以上就是channel的创建,发送数据,接受数据等主要操作的实现,了解这些实现,就算是对channel有一个比较好的理解了。

但除此之外,channel还有一些细节知识,需要我们注意。

1. 向nil channel发送数据会永久阻塞

上图示例中是在向nil channel发送数据,但似乎没成功,并不像之前说的,向nil channel发送数据会永久阻塞。

其实,这个错误是go内部检查死锁的机制,它并不是由向nil channel发送数据引起的。

比如,下面的写法也会报这个错:

上图示例中创建了一个unbuffered channel,然后向其发送数据,也报错了,因为这种写法会自己阻塞自己。

那如何不报这个错,然后可以看到,向nil channel发送数据会永久阻塞呢?

看下面这个例子:

再开一个goroutine就好了,在这个示例中,c <- 8 会一直阻塞,没有返回。

向nil channel发送数据会永久阻塞,对应的底层实现为:

2. 向closed channel发送数据会发生run-time panic

对应的底层实现为:

3. 从nil channel中接收数据会永久阻塞

对应的底层实现为:

4. 从closed channel中接收数据,会返回channel元素类型的zero value

对应的底层实现为:

从上图中还可以得出一个结论,就是即使channel被关闭了,如果channel buffer中有数据,还是会正常返回数据。

5. 从channel中接收数据可以有两个返回值,第二个返回值可近似表示channel是否已关闭

该示例main函数的汇编代码:

由上面的选中行可知,编译器将v, ok := <-c转成了对runtime.chanrecv2函数的调用:

chanrecv2除了将从channel中接收的数据,拷贝到elem指针指向的内存外,还返回了一个received布尔值。

当channel被关闭后,且其buffer中没有数据,再从channel中接收数据,chanrecv2返回的received值就为false,表示channel已经被关闭了。

6. 对channel的len和cap操作是无锁的

其main函数的汇编为:

上图中第13行汇编 MOVQ 0(AX), CX 表示的是示例中的len(c) 操作,第21行汇编 MOVQ 0x8(CX), CX 表示的是示例中的 cap(c) 操作。

由上图可见,对channel的len和cap操作,在汇编层面都是一条mov指令,并不像之前的,比如对channel的接收操作,是转换成对runtime.chanrecv1函数的调用,且在该函数中,有加锁解锁操作。

综上可知,对channel的len和cap操作是无锁的。

那为什么这两条mov指令,就可以获得channel的len和cap值呢?

首先看上图汇编,13行中的ax和21行中的cx,存放的都是新建channel结构体的地址。

那这两条mov指令的意思是:

MOVQ 0(AX), CX -> 将channel结构体偏移量为0位置上的8字节放入cx中

MOVQ 0x8(CX), CX -> 将channel结构体偏移量为8位置上的8字节放入cx中

再看下channel结构体的定义:

该结构体偏移量为0位置上的8字节,就是qcount,即当前channel buffer中已经缓冲的元素个数,也就是len(c)。

偏移量为8位置上的8字节,就是dataqsiz,即当前channel buffer中最大能缓冲的元素个数,也就是cap(c)。

7. close一个receive-only channel会编译时报错

对应的编译器实现:

8. close一个nil channel或closed channel会发生runtime panic

对应的底层实现为:

9. channel的for range形式

for range形式其实是语法糖,在编译阶段,其会被转换成类似上图注释那样的for循环。

对应的编译器转换代码为:

我们也可以根据汇编,看for range转化后的样子:

由上图可见,在汇编层面,其一直在调用runtime.chanrecv2函数,这个函数上面我们也提到过,就是对应于v, ok := <-c操作。

channel的select形式在go内的实现比较复杂,我们来分步讲下。

10. 空select语句永久阻塞

对应的底层实现为:

上图中的block对应于runtime.block函数:

即永久阻塞。

11. 只有一个case的情况,会直接转换成对channel的操作,等价于没有select部分:

通过上图的汇编可见,示例中操作1和2是等价的。

编译器中对一个case的转换代码为:

12. 有两个case,且其中一个是default,会转换成对channel的非阻塞操作,如果没成功,则会执行default语句:

上图中的runtime.selectnbsend就是c <- 1的非阻塞版,其源码为:

selectnbsend函数内会调用chansend,该函数正是我们之前说的,向channel发送数据的函数,其中false参数表示该发送是非阻塞的。

上图中的selectnbrecv也是非阻塞的从channel中接收数据,对应于select只有两个case,其中一个case是v <- c,另外一个是default的情况。

编译器对该类情况的转换代码为:

13. 其他情况就是select的通用形式了,编译器会把select语句转换成对runtime.selectgo函数的调用:

对应的汇编代码:

对应的编译器转换代码:

selectgo函数会在各个已经就绪的channel里,随机选择一个执行,因为逻辑非常多,这里就展示下该函数的代码位置,有兴趣的可以自己看下:

有关selectgo是随机选择就绪channel的,我们可以写个测试验证下:

看到没,两个值基本相同。

好了,以上就是各种channel操作对应的底层实现,希望通过此篇文章,能让大家对golang中的channel有更好的了解。

原创不易,可以的话,麻烦帮忙点个赞

0 人点赞