Golang channel 之 读操作 recv

上一篇:Golang channel 之 写操作 send

channel的常规读操作

假如有一个元素类型为int的channel,变量名为ch,那么常规的读操作(简称recv为读)在代码中的写法如下所示:

// 将结果丢弃
<-ch
// 将结果赋值给变量v
v := <-ch
// comma ok style,ok为false表示ch已关闭且v不是“读出来的”
v, ok := <-ch

其中ch可能是“有缓冲”的,也可能是“无缓冲”的,甚至可能为nil。

按照上面的写法,有两种情况能使读操作不会阻塞:
1)通道ch的sendq里已有goroutine在等待;
2)通道ch的sendq是空的,但是通道“有缓冲”且缓冲区中有数据。

第一种情况中,只要ch的sendq里有协程在排队,那么需要进一步判断通道是否“有缓冲”:
如果“无缓冲”,当前协程就直接从sendq队首的那个协程那里拿过数据,然后两者都可以继续执行;
如果“有缓冲”,隐含信息就是缓冲区已满,否则sendq中不会有协程排队,这时当前协程从缓冲区取出第一个数据(缓冲区有了一个空闲位置),然后从sendq中取出第一个协程,把它的数据追加到缓冲区中,并把它置成ready状态,最终两个协程都能继续执行。

第二种情况中,ch的sendq里没有协程在排队,所以不需要关心。ch是有缓冲的,且缓冲区有数据,那么当前协程直接从缓冲区取出第一个数据,然后就可以继续执行了。

同样是上面的写法,有三种情况会使读操作阻塞:
1)通道ch为nil;
2)通道ch无缓冲且sendq为空;
3)通道ch有缓冲且缓冲区无数据。

第一种情况中,参照golang的实现,允许对nil通道执行读操作,但是会使当前协程永久性的阻塞在这个nil通道上,例如如下代码会因死锁抛出异常:

package main

func main() {
        var ch chan int
        <-ch
}

第二种情况中,ch为无缓冲通道,sendq中没有协程在等待,所以当前协程需要到通道的recvq中排队;

第三种情况中,ch有缓冲但是没有数据,隐含的信息就是sendq为空,否则缓冲区不可能没有数据,所以当前协程只能到recvq中排队。

channel的非阻塞读操作

还是类似tryLock操作:我想获得这把锁,但是万一已经被别人获得了,我不阻塞等待,可以去干其他事情。

对于通道的非阻塞读就是:我想从通道读取数据,但是当前没有写者在排队等待,且缓冲区内无数据(包含无缓冲),我就需要阻塞等待。但是我不想等待,所以立刻返回并告诉我“现在无数据”就可以了。

在golang中,对于单个通道的非阻塞读操作可以用如下代码实现,注意是一个select、一个case和一个default,都是一个,不能多也不能少:

select {
case <-ch: // 此处可以带有赋值操作,或者是comma ok style
    ...
default:
    ...
}

如果检测到读ch不会阻塞,那么就会执行case <-ch:分支,如果会阻塞,就会执行default:分支。关于什么情况下会阻塞,什么情况下不会阻塞,参见上面的情况分析。

channel读操作的实现

上面简单的分析了channel的常规读操作和非阻塞读操作,虽然两者在形式上看起来稍微有些差异,但是主要逻辑都是通过runtime.chanrecv函数实现的,下面简单的进行一下解读:

首先来看一下chanrecv函数的原型:

func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool)

其中:
c是一个hchan指针,指向要从中recv数据的channel;
ep是一个指针,指向用来接收数据的内存,数据类型要和c的元素类型一致;
block表示如果读操作不能立即完成,是否想要阻塞等待;
selected为true表示操作完成(可能因为通道已关闭),false表示目前不可读但因为不想阻塞(block为false)而返回;
received为true表示数据确实是从通道中读出来的,不是因为通道关闭而得到的零值,为false的情况需要结合selected来解释,可能是因为通道关闭而得到零值(selected为true),或者因为不想阻塞而返回(selected为false)。

chanrecv函数的主要逻辑如下:

如果c等于nil {
    如果不想阻塞 {
        return false, false
    }
    永久阻塞
}

如果不想阻塞 且 ((c无缓冲 且 sendq是空的) 或 (c有缓冲 且 缓冲区为空)) 且 c未关闭 {
    return false, false
}

对c加锁

如果c已关闭 且 缓冲区为空 {
    解锁c
    为ep赋零值
    return true, false
}

如果sendq中有内容 {
    取出队首协程sg
    如果有缓冲 {
        取出缓冲区第一个数据赋给ep,再把sg的数据追加到缓冲区,调整sendx、recvx
    } 否则 {
        把sg的数据赋给ep
    }
    解锁c,将sg置为ready
    return true, true
}

如果缓冲区有数据 {
    取出第一个数据赋给ep,移动recvx,递减qcount
    解锁c
    return true, true
}

如果不想阻塞 {
    解锁c
    return false, false
}

进入recvq排队等待同时解锁c,条件满足时会完成数据读取并被唤醒

return true, 完成数据读取时通道没有关闭

逐块对应以上代码:
1)如果c为nil,进一步判断block:如果block为false,那么直接返回两个false,表示未recv数据;如果block为true,那么就让当前协程”永久“的阻塞在这个nil通道上;
2)如果block为false,也就是在”不想阻塞“的前提下,如果是通道”无缓冲“且sendq为空,或者是通道”有缓冲“且缓冲区为空,最后再判断通道未关闭的话,则直接返回两个false。本步判断是在不加锁的情况下进行的,目的是让非阻塞读在无法立即完成时能真正不阻塞(加锁可能阻塞)。这几步判断使用了atomic函数,并且先后顺序不能打乱,要在最后一步判断通道未关闭。因为关闭后的通道不能再被打开,这样保证了并发条件下的一致性,如果把判断closed前置,则在检查缓冲区和sendq时通道可能已关闭,这样会出现错误;
3)加锁;
4)如果closed不为0,即通道已经关闭的话,则解锁,然后给ep赋零值,返回true和false;
5)如果sendq不为空,就从中取出第一个排队的协程sg,如果有缓冲则还需要滚动缓冲区,完成数据读取,并将协程sg置为ready状态(放入run queue,进而得到调度),然后解锁,返回两个true;
6)通过qcount判断缓冲区是否有数据,在这里”无缓冲“的通道被视为没有数据,到达这一步sendq一定为空所以不必关心。缓冲区有数据的话,将第一个数据取出并赋给ep,移动recvx,递减qcount,解锁,返回两个true;
7)如果block为false,即不想阻塞,则解锁,返回两个false;
8)最后,到达这里就是阻塞读了,当前协程把自己追加到通道的recvq中阻塞排队,同时解锁,等到条件满足时会被唤醒。
9)被唤醒有可能是因为通道被关闭,所以最后的返回值received需要根据被唤醒的原因来判断,若是因为等到真实数据则为true,若是因为通道关闭则为false。

本篇总结

1)channel的常规读操作如v := <-c,会被编译器转换为对runtime.chanrecv1的调用,后者内部只是调用了runtime.chanrecv,comma ok写法会被编译器转换为对runtime.chanrecv2的调用,与chanrecv1的唯一区别就是把received返回值赋给了ok;
2)非阻塞式的读操作,即select、case、default三个一,会被编译器转换为对runtime.selectnbrecv或selectnbrecv2的调用(根据是否comma ok),后两者也仅仅是调用了runtime.chanrecv。非阻塞读的实现效果如下:

select {
case v = <-c:
    ... foo
default:
    ... bar
}

// 被编译器转化为

if selectnbrecv(&v, c) {
    ... foo
} else {
    ... bar
}

comma ok写法:

select {
case v, ok = <-c:
    ... foo
default:
    ... bar
}

// 被编译器转化为

if c != nil && selectnbrecv2(&v, &ok, c) {
    ... foo
} else {
    ... bar
}

上一篇:Golang channel 之 写操作 send

你可能感兴趣的:(Golang channel 之 读操作 recv)