首先,未初始化的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有更好的了解。
原创不易,可以的话,麻烦帮忙点个赞
。