本文来自 Go 待业训练营 小韬同学的投稿。
也强烈安利大家多写博客,不仅能倒逼本人学习总结,也能作为简历的加分项,进步求职面试的竞争力。
你想想看:面试官看到你简历中的博客主页有几十篇文章,几千粉丝是什么感觉。要比你空洞洞的写一句“酷爱技术”强太多啦!
注释
咱们在学习与应用 Go 语言的过程中,对 channel
并不生疏,channel
是 Go 语言不同凡响的个性之一,也是十分重要的一环,深刻了解Channel
,置信可能在应用的时候更加的得心应手。
一、Channel 根本用法
1、channel 类别
channel
在类型上,能够分为两种:
+ 双向 channel:既能接管又能发送的 channel
+ 单向 channel:只能发送或只能接管的 channel
,即单向channel
能够为分为:
+ 只写 channel
+ 只读 channel
申明并初始化如下如下:
func main() {
// 申明并初始化
var ch chan string = make(chan string) // 双向 channel
var readCh <-chan string = make(<-chan string) // 只读 channel
var writeCh chan<- string = make(chan<- string) // 只写 channel
}
上述定义中,<-
示意单向的 channel
。如果箭头指向chan
,就示意只写channel
,能够往chan
里边写入数据;如果箭头远离 chan
,则示意为只读channel
,能够从chan
读数据。
在定义 channel 时,能够定义任意类型的 channel,因而也同样能够定义 chan 类型的 channel。例如:
a := make(chan<- chan int) // 定义类型为 chan int 的写 channel
b := make(chan<- <-chan int) // 定义类型为 <-chan int 的写 channel
c := make(<-chan <-chan int) // 定义类型为 <-chan int 的读 channel
d := make(chan (<-chan int)) // 定义类型为 (<-chan int) 的读 channel
当 channel
未初始化时,其 零值为nil
。nil 是 chan 的零值,是一种非凡的 chan,对值是 nil 的 chan 的发送接管调用者总是会阻塞。
func main() {
var ch chan string
fmt.Println(ch) // <nil>
}
通过 make
咱们能够初始化一个 channel,并且能够设置其容量的大小,如下初始化了一个类型为 string
,其容量大小为512
的channel
:
var ch chan string = make(chan string, 512)
当初始化定义了 channel
的容量,则这样的 channel
叫做 buffered chan
,即 有缓冲 channel
。如果没有设置容量,channel
的容量为 0,这样的 channel
叫做 unbuffered chan
,即 无缓冲channel
。
有缓冲 channel
中,如果 channel
中还有数据,则从这个 channel
接收数据时不会被阻塞。如果 channel
的容量还未满,那么向这个 channel
发送数据也不会被阻塞,反之则会被阻塞。
无缓冲 channel
则只有当读写操作都筹备好后,才不会阻塞,这也是 unbuffered chan
在应用过程中十分须要留神的一点,否则可能会呈现常见的 bug。
channel 的常见操作:
1. 发送数据
往 channel 发送一个数据应用ch <-
func main() {var ch chan int = make(chan int, 512)
ch <- 2000
}
上述的 ch
能够是 chan int
类型,也能够是单向chan <-int
。
2. 接收数据
从 channel 接管一条数据能够应用<-ch
func main() {var ch chan int = make(chan int, 512)
ch <- 2000 // 发送数据
data := <-ch // 接收数据
fmt.Println(data) // 2000
}
ch 类型是 chan T
,也能够是单向<-chan T
在接收数据时,能够返回两个返回值。第一个返回值返回 channel
中的元素,第二个返回值为 bool
类型 ,示意是否胜利地从channel
中读取到一个值。
如果第二个参数是 false
,则 示意 channel
曾经被 close
而且 channel
中没有缓存的数据,这个时候第一个值返回的是零值。
func main() {var ch chan int = make(chan int, 512)
ch <- 2000 // 发送数据
data1, ok1 := <-ch // 接收数据
fmt.Printf("data1 = %d, ok1 = %t\n", data1, ok1) // data1 = 2000, ok1 = true
close(ch) // 敞开 channel
data2, ok2 := <-ch // 接收数据
fmt.Printf("data2 = %d, ok2 = %t", data2, ok2) // data2 = 0, ok2 = false
}
所以,如果从 channel
读取到一个零值,可能是发送操作真正发送的零值,也可能是 closed
敞开 channel
并且 channel
没有缓存元素产生的零值,这是须要留神判断的一个点。
3. 其余操作
Go 内建的函数 close
、cap
、len
都能够对 chan
类型进行操作。
+ close
:敞开 channel。
+ cap
:返回 channel 的容量。
+ len
:返回 channel 缓存中还未被取走的元素数量。
func main() {var ch chan int = make(chan int, 512)
ch <- 100
ch <- 200
fmt.Println("ch len:", len(ch)) // ch len: 2
fmt.Println("ch cap:", cap(ch)) // ch cap: 512
}
发送操作 与接管操作 能够作为 select
语句中的case clause
,例如:
func main() {var ch = make(chan int, 512)
for i := 0; i < 10; i++ {
select {
case ch <- i:
case v := <-ch:
fmt.Println(v)
}
}
}
for-range
语句同样能够在 chan
中应用,例如:
func main() {var ch = make(chan int, 512)
ch <- 100
ch <- 200
ch <- 300
for v := range ch {fmt.Println(v)
}
}
// 执行后果
100
200
300
2、select 介绍
在 Go 语言中,select
语句用于监控一组 case
语句,依据特定的条件执行绝对应的 case
语句或 default
语句,与 switch
相似,但不同之处在于 select
语句中所有 case
中的表达式都必须是 channel
的发送或接管操作。select
应用示例代码如下:
select {
case <-ch1:
fmt.Println("ch1")
case ch2 <- 1:
fmt.Println("ch2")
}
上述代码中,select
关键字让以后 goroutine
同时期待 ch1
的可读和ch2
的可写,在满足任意一个 case
分支之前,select
会始终阻塞上来,直到其中的一个 channel
转为就绪状态时执行对应 case
分支的代码。如果多个 channel
同时就绪的话则随机抉择一个 case
执行。
当应用空 select
时,空的 select
语句会间接阻塞以后的 goroutine
,使得该goroutine
进入无奈被唤醒的永恒休眠状态。空 select
,即select
内不蕴含任何case
。
select{}
另外当 select
语句内只有一个 case
分支时,如果该 case
分支不满足,那么以后 select
就变成了一个阻塞的 channel
读 / 写操作。
select {
case <-ch1:
fmt.Println("ch1")
}
上述 select
中,当 ch1
可读时,会执行打印操作,反之则阻塞以后goroutine
。
当 select
语句内蕴含 default
分支时,如果 select
内的所有 case
都不满足,则会执行 default
分支的逻辑,用于当其余 case
都不满足时执行一些默认操作。
select {
case <-ch1:
fmt.Println("ch1")
case ch2 <- 1:
fmt.Println("ch2")
default:
fmt.Println("default")
}
上述代码中,当 ch1
可读或 ch2
可写时,会执行相应的打印操作,否则就执行 default
语句中的代码,相当于一个非阻塞的 channel
读取操作。
select
的应用能够总结为:
+ select
不存在任何的 case
且没有 default
分支:永恒阻塞以后 goroutine;
+ select
只存在一个 case
且没有 default
分支:阻塞的发送 / 接管;
+ select
存在多个 case
:随机抉择一个满足条件的case
执行;
+ select
存在 default
,其余case
都不满足时:执行 default
语句中的代码;
二、Channel 实现原理
从代码的角度分析 channel
的实现,可能让咱们更好的去应用channel
。
咱们能够从 chan
类型的数据结构、初始化以及三个操作发送、接管和敞开这几个方面来理解channel
。
1、chan 数据结构
chan 类型的数据结构定义位于 runtime.hchan,其构造体定义如下:
type hchan struct {
qcount uint // total data in the queue
dataqsiz uint // size of the circular queue
buf unsafe.Pointer // points to an array of dataqsiz elements
elemsize uint16
closed uint32
elemtype *_type // element type
sendx uint // send index
recvx uint // receive index
recvq waitq // list of recv waiters
sendq waitq // list of send waiters
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex
}
解释一下上述各个字段的意义:
+ qcount
:示意 chan
中曾经接管到的数据且还未被取走的元素个数。内建函数 len
能够返回这个字段的值。
+ datasiz
:循环队列的大小。chan
在实现上应用一个循环队列来寄存元素的个数,循环队列实用于生产者 - 消费者的场景。
+ buf
:寄存元素的循环队列 buffer
,buf
字段是一个指向队列缓冲区的指针,即指向一个dataqsiz
元素的数组。buf
字段是应用 unsafe.Pointer
类型来示意队列缓冲区的起始地址。unsafe.Pointer
是一种非凡的指针类型,它能够用于指向任何类型的数据。因为队列缓冲区的类型是动态分配的,所以不能间接应用某个具体类型的指针来示意。
+ elemtype
、elemsize
:elemtype
示意 chan 中元素的数据类型,elemsize
示意其大小。当 chan 定义后,它的元素类型是固定的,即一般类型或者指针类型,因而元素大小也是固定的。
+ sendx
:解决发送数据操作的指针在 buf 队列中的地位。当 channel 接管到了新的数据时,该指针就会加上 elemsize
,挪动到下一个地位。buf
的总大小是elemsize
的整数倍且 buf
是一个循环列表。
+ recvx
:解决接收数据操作的指针在 buf
队列中的地位。当从 buf 中取出数据,此指针会挪动到下一个地位。
+ recvq
:当接管操作发现 channel
中没有数据可读时,会被则色,此时会被退出到 recvq
队列中。
+ sendq
:当发送操作发现 buf
队列已满时,会被进行阻塞,此时会被退出到 sendq
队列中。
<p align=center><img src=”https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/ddc1b911e2ac40b9ad73bff9647e4dc0~tplv-k3u1fbpfcp-jj-mark:0:0:0:0:q75.image#?w=395&h=524&s=37043&e=png&b=fbf7f6″ alt=”image.png” /></p>
2、chan 初始化
channel
在进行初始化时,Go 编译器会依据是否传入容量的大小,来抉择调用 makechan64
,还是makechan
。makechan64
在实现上底层还是调用 makechan
来进行初始化,makechan64
只是对 size
做了查看。
makechan
函数依据 chan
的容量的大小和元素的类型不同,初始化不同的存储空间。省略一些查看代码,makechan
函数的次要逻辑如下:
func makechan(t *chantype, size int) *hchan {
elem := t.elem
...
mem, overflow := math.MulUintptr(elem.size, uintptr(size))
...
var c *hchan
switch {
case mem == 0:
// 队列或元素大小为零,不用创立 buf
c = (*hchan)(mallocgc(hchanSize, nil, true))
c.buf = c.raceaddr()
case elem.ptrdata == 0:
// 元素不蕴含指针,调配一块间断的内存给 hchan 数据结构和 buf
// hchan 数据结构前面紧接着就是 buf,在一次调用中调配 hchan 和 buf
c = (*hchan)(mallocgc(hchanSize+mem, nil, true))
c.buf = add(unsafe.Pointer(c), hchanSize)
default:
// 元素蕴含指针,独自调配 buf
c = new(hchan)
c.buf = mallocgc(mem, elem, true)
}
// 记录元素大小、类型、容量
c.elemsize = uint16(elem.size)
c.elemtype = elem
c.dataqsiz = uint(size)
lockInit(&c.lock, lockRankHchan)
...
return c
}
3、send 发送操作
Go 在编译发送数据给 channel
时,会把发送操作 send
转换成 chansend1
函数,而 chansend1
函数会调用 chansend
函数。
func chansend1(c *hchan, elem unsafe.Pointer) {chansend(c, elem, true, getcallerpc())
}
咱们能够来分段剖析 chansend
函数的实现逻辑。
第一局部:
次要是对 chan
进行判断,判断 chan
是否为 nil
,若为nil
,则判断是否须要将以后goroutine
进行阻塞,阻塞通过 gopark
来对调用者goroutine park
(阻塞休眠)。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
// 第一局部
if c == nil { // 判断 chan 是否为 nil
if !block { // 判断是否须要阻塞以后 goroutine
return false
}
// 调用这 goroutine park,进行阻塞休眠
gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2)
throw("unreachable")
}
...
}
第二局部
第二局部的逻辑判断是当你往一个容量已满的 chan
实例发送数据,且不想以后调用的 goroutine
被阻塞时(chan
未被敞开),那么解决的逻辑是间接返回。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第二局部
if !block && c.closed == 0 && full(c) {return false}
...
}
第三局部
第三局部的逻辑判断是首先进行互斥锁加锁,而后判断以后 chan
是否敞开,如果 chan
曾经被 close
了,则开释互斥锁并 panic
,即对已敞开的chan
发送数据会panic
。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第三局部
lock(&c.lock) // 开始加锁
if c.closed != 0 { // 判断 channel 是否敞开
unlock(&c.lock)
panic(plainError("send on closed channel"))
}
...
}
第四局部
第四局部的逻辑次要是判断接管队列中是否有正在期待的接管方 receiver
。如果存在正在期待的receiver
(阐明此时buf
中没有缓存的数据),则将他从接管队列中弹出,间接将须要发送到 channel
的数据交给这个 receiver
,而无需放入到buf
中,让发送操作速度更快一些。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第四局部
if sg := c.recvq.dequeue(); sg != nil {
// 找到了一个正在期待的接收者。咱们传递咱们想要发送的值
// 间接传递给 receiver 接收者,绕过 channel buf 缓存区(如果 receiver 有的话)
send(c, sg, ep, func() {unlock(&c.lock) }, 3)
return true
}
...
}
第五局部
当期待队列中并没有正在期待的 receiver
,则阐明以后buf
还没有满,此时将发送的数据放入到 buf
中。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第五局部
if c.qcount < c.dataqsiz { // 判断 buf 是否满了
// channel buf 还有可用的空间. 将发送数据入 buf 循环队列.
qp := chanbuf(c, c.sendx)
if raceenabled {racenotify(c, c.sendx, nil)
}
typedmemmove(c.elemtype, qp, ep)
c.sendx++
if c.sendx == c.dataqsiz {c.sendx = 0}
c.qcount++
unlock(&c.lock)
return true
}
...
}
第六局部
当逻辑走到第六局部,阐明正在解决 buf
已满的状况。如果 buf
已满,则发送操作的 goroutine
就会退出到发送者的期待队列,直到被唤醒。当 goroutine
被唤醒时,数据或者被取走了,或者 chan
曾经被敞开了。
func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool {
...
// 第六局部
// chansend1 函数调用不会进入 if 块里,因为 chansend1 的 block=true
if !block {unlock(&c.lock)
return false
}
...
c.sendq.enqueue(mysg) // 退出发送队列
...
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanSend, traceEvGoBlockSend, 2) // 阻塞
...
}
4、recv 接管操作
从 channel
中接收数据时,Go 会将代码转换成 chanrecv1
函数。如果须要返回两个返回值,则会转换成 chanrecv2
,chanrecv1
函数和 chanrecv2
都会调用 chanrecv
函数。chanrecv1
和 chanrecv2
传入的 block
参数的值是 true
,两种调用都是阻塞形式,因而在剖析chanrecv
函数的实现时,能够不思考 block=false
的状况。
// 从已编译代码中进入 <-c 的入口点
func chanrecv1(c *hchan, elem unsafe.Pointer) {chanrecv(c, elem, true)
}
func chanrecv2(c *hchan, elem unsafe.Pointer) (received bool) {_, received = chanrecv(c, elem, true)
return
}
同样,省略一些查看类的代码,咱们也能够分段剖析 chanrecv
函数的逻辑。
第一局部
第一局部次要判断以后进行接管操作的 chan
实例是否为 nil
,若为nil
,则从nil chan
中接收数据的调用这 goroutine
会被阻塞。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 第一局部
if c == nil { // 判断 chan 是否为 nil
if !block { // 是否阻塞,默认为 block=true
return
}
// 进行阻塞
gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2)
throw("unreachable")
}
...
}
第二局部
这一部分只有是思考 block=false
且c
为空的状况,block=false
的状况咱们能够不做思考。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 查看未取得锁的失败非阻塞操作。if !block && empty(c) {...}
...
}
第三局部
第三局部的逻辑为判断以后 chan
是否被敞开,若以后 chan
曾经被 close
了,并且缓存队列中没有缓冲的元素时,返回true
、false
。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
lock(&c.lock) // 加锁,返回时开释锁
// 第三局部
if c.closed != 0 { // 当 chan 已被敞开时
if c.qcount == 0 { // 且 buf 区 没有缓存的数据了
...
unlock(&c.lock) // 解锁
if ep != nil {typedmemclr(c.elemtype, ep)
}
return true, false
}
}
...
}
第四局部
第四局部是解决通道未敞开且 buf
缓存队列已满的状况。只有当缓存队列已满时,才可能从发送期待队列获取到 sender
。若以后的chan
为unbuffer
的 chan
,即 无缓冲区 channel
时,则间接将 sender
的发送数据传递给 receiver
。否则就从缓存队列的头部读取一个元素值,并将获取的sender
携带的值退出到 buf
循环队列的尾部。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
if c.closed != 0 {// 当 chan 已被敞开时} else { // 第四局部,通道未敞开
// 如果 sendq 队列中有期待发送的 sender
if sg := c.sendq.dequeue(); sg != nil {
// 存在正在期待的 sender,如果缓存区的容量为 0 则间接将发送方的值传递给接管方
// 反之,则从缓存队列的头部获取数据,并将获取的 sender 的发送值退出到缓存队列尾部
recv(c, sg, ep, func() {unlock(&c.lock) }, 3)
return true, true
}
}
...
}
第五局部
第五局部的次要逻辑是解决发送队列中没有期待的 sender
且buf
中有缓存的数据。该段逻辑与外出的互斥锁共用一把锁,因而不存在并发问题。当 buf
缓存区有缓存元素时,则取出该元素传递给receiver
,同时挪动接管指针。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
// 第五局部
if c.qcount > 0 { // 发送队列中没有期待的 sender,且 buf 中有缓存数据
// 间接从缓存队列中获取数据
qp := chanbuf(c, c.recvx)
if raceenabled {racenotify(c, c.recvx, nil)
}
if ep != nil {typedmemmove(c.elemtype, ep, qp)
}
typedmemclr(c.elemtype, qp)
c.recvx++ // 挪动接管指针
if c.recvx == c.dataqsiz {// 指针若已到开端则进行重置(循环队列)
c.recvx = 0
}
c.qcount-- // 获取数据后,buf 缓存区元素个数减一
unlock(&c.lock) // 解锁
return true, true
}
if !block { // block=true
unlock(&c.lock)
return false, false
}
...
}
第六局部
第六局部的逻辑次要是解决 buf
缓存区中没有缓存数据的状况。当 buf
缓存区没有缓存数据时,那么以后的 receiver
就会被阻塞,直到它从 sender
中接管了数据,或者是 chan
被close
,才会返回。
func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) {
...
c.recvq.enqueue(mysg) // 将以后接管操作入接管队列
...
// 进行阻塞,期待唤醒
gopark(chanparkcommit, unsafe.Pointer(&c.lock), waitReasonChanReceive, traceEvGoBlockRecv, 2)
...
}
5、close 敞开
close
函数次要用于 channel
的敞开,Go 编译器会替换成 closechan
函数的调用。省略一些查看下的代码后,closechan
函数的次要逻辑如下:
+ 如果以后 chan
为nil
,则间接 panic
+ 如果以后chan
已敞开,再次 close
则间接 panic
+ 如果chan
不为 nil
,chan
也没有 closed
,就把期待队列中的 sender(writer)
和 receiver(reader)
从队列中全副移除并唤醒。
func closechan(c *hchan) {
if c == nil { // 若以后 chan 未 nil,则间接 panic
panic(plainError("close of nil channel"))
}
lock(&c.lock) // 加锁
if c.closed != 0 { // 若以后 chan 曾经敞开,则间接 panic
unlock(&c.lock)
panic(plainError("close of closed channel"))
}
...
c.closed = 1 // 设置以后 channel 的状态为已敞开
var glist gList
// 开释接管队列中所有的 reader
for {sg := c.recvq.dequeue()
if sg == nil {break}
if sg.elem != nil {typedmemclr(c.elemtype, sg.elem)
sg.elem = nil
}
if sg.releasetime != 0 {sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
// 开释发送队列中所有的 writer (它们会 panic)
for {sg := c.sendq.dequeue()
if sg == nil {break}
sg.elem = nil
if sg.releasetime != 0 {sg.releasetime = cputicks()
}
gp := sg.g
gp.param = unsafe.Pointer(sg)
sg.success = false
if raceenabled {raceacquireg(gp, c.raceaddr())
}
glist.push(gp)
}
unlock(&c.lock)
for !glist.empty() {gp := glist.pop()
gp.schedlink = 0
goready(gp, 3)
}
}
三、总结
通过学习 channel
的根本应用,理解其操作背地的实现原理,能够帮忙咱们更好的应用 channel
,防止一些操作不当而导致的panic
或者说是 bug
,让咱们在应用channel
时可能更加的得心应手。
channel
的值和状态有多种状况,而不同的操作 (send、recv、close)
又可能失去不同的后果,这是应用 channel
类型时须要常常留神的点,咱们能够将不同 channel
值下的不同操作进行一个总结,特地留神操作 channel
时会产生 panic
的状况,曾经可能会导致线程阻塞的状况 ,都是有可能导致死锁与goroutine
透露的罪魁祸首。
| channel 执行操作 \channel 状态 | channel 为 nil | channel buf 为空 | channel buf 已满 | channel buf 未满且不为空 | channel 已敞开 |
| ————————— | ———— | —————————— | —————————– | —————————– | ——————- |
| receive
接管操作 | 阻塞 | 阻塞 | 读取数据 | 读取数据 | 返回 buf 中缓存的数据 |
| send
发送操作 | 阻塞 | 写入数据 | 阻塞 | 写入数据 | panic |
| close
敞开 | panic | 敞开 channel,buf 中没有缓存数据 | 敞开 channel,保留已缓存的数据 | 敞开 channel,保留已缓存的数据 | panic
又出问题啦
咱们又出问题啦!大厂 Offer 集锦!遥遥领先!
这些敌人赢麻了!
这是一个专一程序员升职加薪の常识星球
答疑解惑
须要「简历优化」、「就业辅导」、「职业规划」的敌人能够分割我。
加我微信:wangzhongyang1993
关注我的同名公众号:王中阳 Go