在之前的文章中,我们有介绍过 channel 的使用,传送门。比较经典的一句话就是:
在 Go 语言中,提倡通过通信来共享内存,而不是通过共享内存来通信,其实就是提倡通过 channel 发送接收消息的方式进行数据传递。
这里我们再更加深层次的了解下 chan 。
chan 数据结构src/runtime/chan.go
中定义了 channel 的数据结构如下:
type hchan struct {
qcount uint // 队列中的总元素个数
dataqsiz uint // 环形队列大小,即可存放元素的个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 //每个元素的大小
closed uint32 //标识关闭状态
elemtype *_type // 元素类型
sendx uint // 发送索引,元素写入时存放到队列中的位置
recvx uint // 接收索引,元素从队列的该位置读出
recvq waitq // 等待读消息的goroutine队列
sendq waitq // 等待写消息的goroutine队列
lock mutex //互斥锁,chan不允许并发读写
}
复制代码
环形队列
chan 内部实现了一个环形队列来作为缓冲区,队列的长度是在创建 chan 的时候所指定的。
如下图,我们创建一个可缓存6个元素的 channel 示意图:
- dataqsiz 指示了队列长度为6,即可缓存6个元素;
- buf 指向队列的内存,队列中还剩余两个元素;
- qcount 表示队列中还有两个元素;
- sendx 指示后续写入的数据存储的位置,取值范围为
[0,6)
; - recvx指示从该位置读取数据,取值范围为
[0,6)
;
等待队列
- 从 channel 中读取数据,如果 channel 的缓冲区为空,或者没有缓冲区,那么当前的 goroutine 会被阻塞。
- 向 channel 中写入数据,如果 channel 的缓冲区已满,或者没有缓冲区,那么当前的 goroutine 会被阻塞。
- 被阻塞的 goroutine 会挂在 channel 的等待队列中。
- 因为读所导致的阻塞,会被向 channel 写入数据的 goroutine 所唤醒。
- 因为写所导致的阻塞,会被从 channel 读取数据的 goroutine 所唤醒。
如下图,为没有缓冲区的 channel,recvq
中有几个 goroutine 阻塞等待读数据。
注意,一般情况下recvq和sendq至少有一个为空。只有一个例外,那就是同一个goroutine使用select语句向
channel一边写数据,一边读数据。
类型信息
一个 channel 只能传递一种类型的值,类型信息存储在 hchan 数据结构中:
- elemsize :类型大小,用于在buf中定位元素位置。
- elemtype :类型,用于数据传递过程中的赋值;
锁
我们知道,channel 是并发安全的,即一个channel同时仅允许被一个goroutine读写。
channel 读写创建 channel
创建 channel 其实就是初始化 hchan 结构,其类型信息和缓冲区长度由 make 语句传入,buf 的大小则与元素大小和缓冲区长度来共同决定。
创建 channel 的伪代码:
func makechan(t *chantype, size int) *hchan{
var c *hchan
c = new(hchan)
c.buf = malloc(元素类型大小*size)
c.elemsize = 元素类型大小
c.elemtype = 元素类型
c.dataqsiz = size
return c
}
复制代码
向 channel 写数据
过程如下:
- 若等待接收队列 recvq 不为空,则缓冲区中无数据或无缓冲区,将直接从 recvq 取出 G ,并把数据写入,最后把该 G 唤醒,结束发送过程。
- 若缓冲区中有空余位置,则将数据写入缓冲区,结束发送过程。
- 若缓冲区中没有空余位置,则将发送数据写入 G,将当前 G 加入 sendq ,进入睡眠,等待被读 goroutine 唤醒。
从 channel 读数据
过程如下:
- 若等待发送队列 sendq 不为空,且没有缓冲区,直接从 sendq 中取出 G ,把 G 中数据读出,最后把 G 唤醒,结束读取过程;
- 如果等待发送队列 sendq 不为空,说明缓冲区已满,从缓冲区中首部读出数据,把 G 中数据写入缓冲区尾部,把 G 唤醒,结束读取过程;
- 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
- 将当前 goroutine 加入 recvq ,进入睡眠,等待被写 goroutine 唤醒;
关闭 channel
关闭 channel 时会将 recvq 中的 G 全部唤醒,,本该写入 G 的数据位置为 nil。将 sendq 中的 G 全部唤醒,但是这些 G 会 panic。
panic 出现的场景还有:
- 关闭值为 nil 的 channel
- 关闭已经关闭的 channel
- 向已经关闭的 channel 中写数据
单项 channel
只能发送或者只能接收的 channel 为单向 channel。
单向 channel 声明
只需要在基础声明中增加操作符即可:
send := make(ch<- int) //只能发送数据给channel
receive := make(<-ch int) //只能从channel中接收数据
复制代码
示例:
package main
import (
"fmt"
)
//只能发送通道
func send(s chan<- string){
s <- "微客鸟窝"
}
//只能接收通道
func receive(r <-chan string){
str := <-r
fmt.Println("str:",str)
}
func main() {
//创建一个双向通道
ch := make(chan string)
go send(ch)
receive(ch)
}
//运行结果: str: 微客鸟窝
复制代码
select
select 可以实现多路复用,即同时监听多个 channel。
- 发现哪个 channel 有数据产生,就执行相应的 case 分支
- 如果同时有多个 case 分支可以执行,则会随机选择一个
- 如果一个 case 分支都不可执行,则 select 会一直等待
示例:
package main
import (
"fmt"
)
func main() {
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x)
case ch <- i:
fmt.Println("--", i)
}
}
}
复制代码
运行结果:
-- 0
0
-- 2
2
-- 4
4
-- 6
6
-- 8
8
复制代码
select 的 case 语句读 channel 不会阻塞,尽管 channel 中没有数据。这是由于 case 语句编译后调用读 channel 时会明确传入不阻塞的参数,此时读不到数据时不会将当前 goroutine 加入到等待队列,而是直接返回。
range
通过 range 可以持续从 channel 中读取数据,类似于遍历,当 channel 中没有数据时会阻塞当前 goroutine ,与读 channel 时阻塞处理机制一样。
示例:
for ch := range chanName {
fmt.Printf("chan: %d\n", ch)
}
复制代码
注意:如果向此channel写数据的goroutine退出时,系统检测到这种情况后会panic,否则range将会永久阻塞。