大家好,我是小道哥。
今天给大家的面试主题是Golang Channel
通道的基础数据结构通道是用于在golang上实现多个goroutine通信的管道,其基础是h通道这一结构体。 在go的运行时包下面。
数据结构类型h chan struct {//channel有两种:无缓冲区和有缓冲区。 对于具有//缓冲区的channel存储数据,循环数组的结构qcount uint //循环数组中的元素数dataqsiz uint //循环数组的长度buf unsafe.Pointer //到基循环数组channel是否关闭的标志elemtype *_type //channel的元素类型//有缓冲区channel内的缓冲区数组为"环式" //如果下标超过数组容量,则返回到最初的位置,所以如果当前读取和写入的下标的位置sendx uint //下一个发送数据的下标的位置recvx uint //下一个读取数据的下标的位置//循环数组中没有数据,则接收到接收请求接收数据的变量地址为读队列//循环数组中的数据已满时,接收到发送请求时,发送数据的变量地址为写队列recvq waitq //读队列sendq waitq //写队列
hchan结构的主要组成部分归纳如下:
保存用于在goroutine之间传递数据的循环链表。=====buf。 用于记录此循环链表中当前正在发送和接收的数据的下标值。=====sendx和recvx。 用于存储向此chan发送和接收数据的goroutine的队列。=====sendq和recvq确保了线程在channel写入和读取数据时的安全锁定。=====lock板栗//G1 func send task (task list [ ] task ) . ch:=make ) chantask,4 ) /初始化长度为4的channelfor _,task 3365292;
初始h通道结构体重的buf为空,sendx和recvx均为0。 G1向ch发送数据时,首先锁定buf,然后解锁对应图解如下:,再解锁sendx,再解锁buf。 G2消耗ch时,首先锁定buf,然后进入buf的将数据copy到buf中,然后recvx,解除锁定。 从整个过程来看,G1和G2没有共享内存,为数据copy到task变量对应的内存里,这里也体现了Go中的CSP并发模型。
Go的CSP并发模型是通过goroutine和channel实现的。
CSP并发模式:请以通信方式共享内存,而不是以共享内存的方式通信。
底层是通过hchan结构体的buf,并使用copy内存的方式进行通信,最后达到了共享内存的目的
首先,那么当channel中的缓存满了之后会发生什么呢?相关数据模型如下图:所示
【g】goroutine是谷歌实现的用户空间的轻量线程
【m】表示操作系统线程
【p】包含要执行的goroutine的处理器。
线程m想执行goroutine的话,必须先取得p,从p取得goroutine并执行。
当G1向错误已满的ch发送数据时,它会检测到hchan中的错误已满,并通知调度程序。 调度程序将G1状态设置为等待,断开与线程m的联系,并从p的运行队列中选择goroutine在线程m上运行。 此时,G1处于阻止状态,但不是操作系统的线程块
那么G1进入等待状态后去了哪里? 怎么去resume? 让我们回到hchan结构。 我注意到hchan中有waitq类型的sendq成员。 查看源代码如下所示。
typehchanstruct { . recvqwaitq//读队列sendq waitq //写队列. }
type waitq struct {first *sudoglast *sudog}实际上,当G1变为waiting状态后,会创建一个代表自己的sudog的结构,然后放到sendq这个list中,sudog结构中保存了channel相关的变量的指针(如果该Goroutine是sender,那么保存的是待发送数据的变量的地址,如果是receiver则为接收数据的变量的地址,之所以是地址,前面我们提到在传输数据的时候使用的是copy的方式)
当G2从ch中接收一个数据时,会通知调度器,设置G1的状态为runnable,然后将加入P的runqueue里,等待线程执行.
func G2(){ t := <-ch}前面我们是假设G1先运行,如果G2先运行会怎么样呢?
如果G2先运行,那么G2会从一个empty的channel里取数据,这个时候G2就会阻塞,和前面介绍的G1阻塞一样,G2也会创建一个sudog结构体,保存接收数据的变量的地址,但是该sudog结构体是放到了recvq列表里。
当G1向ch发送数据的时候,为了提升效率,runtime并不会对hchan结构体题的buf进行加锁,而是直接将G1里的发送到ch的数据copy到了G2 sudog里对应的elem指向的内存地址!【不通过buf】
这时候,乐观的吐司道友抛出了三个问题:
为什么在第一种情况下,即G1向缓存满的channel中发送数据时被阻塞。在G2后来接收时,不将阻塞的G1发送的数据直接拷贝到G2中呢?
这是因为channel中的数据是队列的,遵循先进先出的原则,当有消费者G2接收数据时,需要先接收缓存中的数据,即buf中的数据,而不是直接消费阻塞的G1中的数据。
多个goroutine向有缓存的channel接收/发送数据时,可以保证顺序吗?
func main(){cache:=make(chan int,3)go func() {for i:=0;i< 3;i++ {cache<-i}}()time.Sleep(time.Second) //休眠1秒钟,保证channel中的数据已经写入完整go getCache("gorouine1",cache)go getCache("gorouine2",cache)go getCache("gorouine3",cache)time.Sleep(time.Second)}func getCache(routine string,cache <-chan int) {for {select {case i:=<-cache:fmt.Printf("%s:%d\n",routine,i)}}}很多道友在工作中应用channel时遇到上述场景都会默认为是有序的,即认为输出结果应该是:
gorouine1:0gorouine2:1gorouine3:2但实则不然,输出结果如下:
$go run main.gogorouine3:1gorouine2:2gorouine1:0这里其实主要需要明确两点:
channel中的数据遵循队列先进先出原则。每一个goroutine抢到处理器的时间点不一致,gorouine的执行本身不能保证顺序。即代码中先写的gorouine并不能保证先从channel中获取数据,或者发送数据。但是先执行的gorouine与后执行的goroutine在channel中获取的数据肯定是有序的。
Channel为什么是线程安全的?
在对buf中的数据进行入队和出队操作时,为当前chnnel使用了互斥锁,防止多个线程并发修改数据
channel的用法 使用for range 读取channel for i := range ch{ fmt.Println(i)} 场景:当需要不断从channel读取数据时原理:使用for-range读取channel,这样既安全又便利,当channel关闭时,for循环会自动退出,无需主动监测channel是否关闭,可以防止读取已经关闭的channel,造成读到数据为通道所存储的数据类型的零值。 使用_,ok判断channel是否关闭 if v, ok := <- ch; ok { fmt.Println(v)} 场景:读channel,但不确定channel是否关闭时原理:读已关闭的channel会得到零值,如果不确定channel,需要使用ok进行检测。ok的结果和含义: true:读到数据,并且通道没有关闭。false:通道关闭,无数据读到。 使用select处理多个channel for{ select { case <-ch1: process1() return case <-ch2: process2() return}} 场景:需要对多个通道进行同时处理,但只处理最先发生的channel时原理:select可以同时监控多个通道的情况,只处理未阻塞的case。 当通道为nil时,对应的case永远为阻塞。如果channel已经关闭,则这个case是非阻塞的,每次select都可能会被执行到。如果多个channel都处于非阻塞态,则select会随机选择一个执行。 关闭channel的注意事项一个 channel不能多次关闭,会导致painc
向一个已经关闭了的 channel发送数据会导致panic
面试点总结 Go channel的底层数据结构以及工作原理?向缓存满的channel写数据会发生什么?向没有数据的channel读数据会发生什么?简述channel的日常用法? 后语如果大家对本文提到的面试技术点有任何问题,都可以在评论区进行回复哈,我们共同学习,一起进步!
关注公众号[简道编程],每天一个后端技术面试点