一、管道
管道是Go在语言层面提供的协程间的通信方式。
(一)初始化
声明和初始化管道的方式主要有以下几种:
- 变量声明
- 使用内置函数 make()。
1、变量声明
var ch chan int //声明管道
这种方式声明的管道,值为nil。每个管道只能存储一种类型数据。
2、使用内置函数 make()
使用内置函数可以创建为无缓冲管道和带缓冲管道
ch1 := make(chan string) //无缓冲管道
ch2 := make(chan string, 5) //带缓冲通道
3、操作符
操作符 < -
表示数据流向,管道在左表示向管道写入数据,管道在左表示向管道写入数据,管道在右表示从管道读出数据
ch := make(chan int,10)
ch <- 1 // 数据流入管道
d := <-ch // 数据流出管道
fmt.Println(d)
默认的管道为双向可读写,管道在函数间传递时可以使用操作符限制管道读写
func ChanParamRW(ch chan int) {
// 管道可读写
}
func ChanParamR(ch <-chan int) {
// 只能从管道读取数据
}
func ChanParamW(ch chan<- int) {
// 只能向管道写入数据
}
4、数据读写
下面以读取数据为例:
- 管道没有缓冲区时,从管道读取数据会阻塞,直到有协程向管道中写入数据。
- 管道有缓冲区时,从管道读取数据,如果缓冲区没有数据也会阻塞,直到有协程写入数据。
对于值为nil的通道,无论读写都会阻塞,而且是永久阻塞。使用内置函数close() 可以关闭管道,尝试向关闭的管道写入数据会触发panic,但关闭的管道仍可读。
管道读取表达式最多可以给两个变量赋值:
v1 := <-ch
x,ok := <-ch
第一个变量表示读出的数据,第二个变量(bool 类型) 表示是否成功读取了数据(不表示管道的关闭状态)。
一个已关闭的管道有两种情况:
- 管道缓冲区已经没有数据。(此时ok = false)
- 管道缓冲区中还有数据。(此时ok = true)
5、小结
内置函数 len() 和 cap() 作用于管道,分别用于查询缓冲区中数据个数及缓冲区大小。管道实现了一种FIFO(先入先出)的队列,数据总是按照写入的顺序流出管道。
协程读取管道时,阻塞的条件有:
- 管道无缓冲区
- 管道缓冲区无数据
- 管道为nil
协程写入管道时,阻塞的条件有:
- 管道无缓冲区
- 管道缓冲区已满
- 管道为nil
(二)实现原理
1、数据结构
管道数据结构源码位于源码包src/runtime/chan.go文件中
type hchan struct {
qcount uint // 当前队列中剩余元素个数
dataqsiz uint // 环形队列长度,即可以存放的元素个数
buf unsafe.Pointer // 环形队列指针
elemsize uint16 // 每个元素的大小
closed uint32 // 标识关闭状态
elemtype *_type // 元素类型
sendx uint // 队列下标,指示元素写入时存放到队列中的位置
recvx uint // 队列下标,指示下一个被读取元素在队列中的位置
recvq waitq // 等待读消息的协程队列
sendq waitq // 等待写消息的协程队列
lock mutex // 互斥锁,chan不允许并发读写
}
(1)环形队列
chan内部实现了一个环形队列作为其缓冲区,队列的容量是创建chan时指定的。
(2)等待队列
从管道读取数据时,如果管道缓冲区为空或者没有缓冲区,则当前协程会被阻塞,并被加入recvq队列。向管道写数据时,如果管道缓冲区为空或者没有缓冲区,则当前协程会被阻塞,并被加入sendq队列。
(3)类型信息
一个管道只能传递一种类型的值,类型信息存储在hchan数据结构中
- elemtype 代表类型,用于数据传递过程中赋值
- elemsize 代表类型大小,用于在buf中定位元素位置
(4)互斥锁
一个管道同时仅允许被一个协程读写。
2、 管道操作
(1)创建管道
创建管道的过程实际上时初始化hchan结构,其中类型信息和缓冲区长度由内置函数make()指定,buf的大小由元素大小和缓冲区长度共同决定。
(2)向管道写数据
向一个管道中写数据的过程如下:
- 如果缓冲区中由空余位置,则将数据写入缓冲区,结束发送过程。
- 如果缓冲区中没有空余位置,则将当前协程加入sendq队列,进入睡眠并等待被读协程唤醒。
- 当接收队列recvq不为空时,说明缓冲区没有数据但有协程在等待数据,此时会把数据直接传递给recvq队列中的第一个协程,而不必再写入缓冲区。
(3)从管道读数据
从一个管道读数据的简单过程如下:
- 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程。
- 如果缓冲区中没有数据,则当前协程加入recvq队列,进入睡眠并等待被写协程唤醒。
- 当接收队列sendq不为空,且没有缓冲区,那么此时将直接从sendq队列的第一个协程中获取数据
(4)关闭通道
关闭通道时会把recvq中的协程全部唤醒,这些写成获取的数据都为对应类型的零值。同时还会把sendq队列中的协程全部唤醒,但这些协程会触发painc。
除此之外,其他会触发panic的操作的还有:
- 关闭值为nil的管道
- 关闭已经被关闭的管道。
- 向已经关闭的管道写入数据。
3、常见用法
(1)单向管道
由管道的数据结构我们知道,实际上并没有单向管道。单向管道只是对管道的一种使用限制。
cha 是一个正常的管道,而ChanParamR()参数限制了传入的管道只能用来读,ChanParamW()参数限制了传入的管道只能用来写。
(2)select
使用select可以监控多个管道,当其中某一个管道可操作时就会触发相应的case分支。select的case语句读挂电脑不会被阻塞,尽管管道中没有数据。这是由于case语句编译后调用管道时会明确传入不阻塞的参数,读不到数据时不会将当前协程加入等待队列,而是直接返回。
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
go ChanParamW(ch1)
go ChanParamW(ch2)
for {
select {
case e := <-ch1:
fmt.Println("ch1: ", e)
case e := <-ch2:
fmt.Println("ch2: ", e)
default:
fmt.Println("通道 ch1 和 ch2 中无元素")
time.Sleep(1 * time.Second)
}
}
}
func ChanParamW(ch chan<- int) {
for {
ch <- 1
time.Sleep(1 * time.Second)
}
}
输出结果如下:
(3)for-range
通过for-range可以持续从管道中读出数据,当管道中没有数据时会阻塞当前协程。
func chanRange(chanName chan int) {
for e := range chanName {
fmt.Println(e)
}
}