Go数据结构-channel
参考: Go语言底层原理剖析 - 第16章 通道于协程间通信
1. channel是干啥的
Go语言之父Rob Pike有句名言:“Don’t communicate by sharing memory, share memory by communicating.”,即“不要通过共享内存来通信,要通过通信来共享内存。”。

通过共享内存来通信比较简单的方法就是,在内存中开辟一块空间,多个线程或者进程可以同时访问这块空间,直觉上会觉得这种方式非常方便,但是也一定会出现数据冲突。为了解决数据冲突,人们又发明了各种锁,而并发性能也会因此下降。
通过通信来实现进程/线程/协程之间的交互给出了一个新的解决方案,其思想主要是:先提供一个或多个高性能队列,线程/进程/协程需要访问别人时,不能直接读写别人的数据,而要通过队列提出请求,然后在对方处理请求时再做相应处理。Go语言给这种方案提供了一个利器:channel!
channel即通道,是Go语言中进行协程间通信的数据结构,是进行并发编程的利器,也是Go语言遵循CSP并发编程模式的结果,这种模型最重要的思想是通过通道来传递消息。
2. channel如何使用
2.1 声明与初始化
2.1.1 声明
var name chan T
// name: channel的名称
// chan T: channel的类型
声明时可以用箭头 <-
限制通道的读写:
chan int
表示通道可以读写int;chan<- int
表示通道只能写入int;<-chan int
表示通道只能读取int;
一个还未初始化的通道会被预置为nil初始化的通道会被预置为nil,无法向通道中写入或读取任何数据。要对通道进行操作,需要使用make操作符,make会初始化通道,在内存中分配通道的空间。
2.1.2 初始化
var c = make(chan int) // 无缓冲区
var c = make(chan int, 10) // 有缓冲区
初始化的通道可以设置其缓冲区的长度,默认为无缓冲区。
2.2 读写数据
Go语言设计者将箭头 <-
作为操作符进行通道的读取和写入。
2.2.1 读写无缓冲区通道
对于无缓冲通道,能够向通道写入数据的前提是必须有另一个协程在读取通道。否则,当前的协程会陷入休眠状态,最终陷入死锁。
package main
func main() {
c := make(chan int)
c <- 100 // or <- c
}
// fatal error: all goroutines are asleep - deadlock!
有读有写
读取通道有两种返回值的形式,借助编译时将该形式转换为不同的处理函数。第1个返回值仍然为通道读取到的数据,第2个返回值为布尔类型,返回值为false代表当前通道已经关闭。
package main
import (
"fmt"
"time"
)
func sender(c chan int) {
data, ok := <-c
fmt.Println(data, ok)
}
func main() {
c := make(chan int)
go sender(c)
c <- 100
time.Sleep(1 * time.Second) // hold住main协程
}
// 100 true
2.2.2 读写有缓冲区通道
对有缓存区的通道读/写数据,如果缓存区为空/缓存区满了,才会发生死锁。
package main
func main() {
c := make(chan int, 2)
// <-c
c <- 100
c <- 100
c <- 100
}
2.3 通道关闭
package main
func main() {
c := make(chan int, 10)
c <- 100
close(c)
c <- 100 // panic: send on closed channel
<-c // 正常
}
在正常读取的情况下,通道返回的ok为true。通道在关闭时仍然会返回,但是data为其类型的零值,ok也变为了false。和通道读取不同的是,不能向已经关闭的通道中写入数据。
试图重复关闭一个channel将导致panic异常,试图关闭一个nil值的channel也将导致panic异常。
有两个协程正在等待通道中的数据,当main协程关闭通道后,两个协程都会收到通知。
package main
import (
"fmt"
"time"
)
func main() {
c := make(chan int)
go func() {
data, ok := <-c
fmt.Println("goroutine one:", data, ok)
}()
go func() {
data, ok := <-c
fmt.Println("goroutine two:", data, ok)
}()
close(c)
time.Sleep(1 * time.Second)
}
// goroutine two: 0 false
// goroutine one: 0 false
3. select多路复用
在实践中使用通道时,更多的时候会与select结合,因为时常会出现多个通道与多个协程进行通信的情况,我们当然不希望由于一个通道的读写陷入堵塞,影响其他通道的正常读写。select正是为了解决这一问题诞生的,select赋予了Go语言更加强大的功能。在使用方法上,select的语法类似switch,形式如下:
select {
case <-ch1:
// ...
case <-ch2:
// ...
default:
// ...
}
和switch不同的是,每个case语句都必须对应通道的读写操作。select语句会陷入堵塞,直到一个或多个通道能够正常读写才恢复。
需要注意的是,select在使用时有几个特性:
- select随机选择机制
当多个通道同时准备好执行读写操作时,select会选择哪一个case执行呢?答案是具有一定的随机性。 - select堵塞与控制
如果select中没有任何的通道准备好,那么当前select所在的协程会永远陷入等待,直到有一个case中的通道准备好为止。在实践中,为了避免这种情况发生,有时会加上default分支。default分支的作用是当所有的通道都陷入堵塞时,正常执行default分支。 - 循环select
很多时候,我们不希望select执行完一个分支就退出,而是循环往复执行select中的内容,因此需要将for与select进行组合。 - select与nil
一个为nil的通道,不管是读取还是写入都将陷入堵塞状态。当select语句的case对nil通道进行操作时,case分支将永远得不到执行。
4. channel底层原理
4.1 hchan数据结构
通道在运行时是一个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
}

对于有缓存的通道,存储在buf中的数据虽然是线性的数组,但是用数组和序号recvx、recvq模拟了一个环形队列。recvx可以找到从buf哪个位置获取通道中的元素,而sendx能够找到写入时放入buf的位置,而sendx能够找到写入时放入buf的位置。

当到达循环队列的末尾时,sendx会置为0,以防止其下一次写入0号位置,开始循环利用空间。这同样意味着,当前的通道中只能放入指定大小的数据。当通道中的数据满了后,再次写入数据将陷入等待,直到第0号位置被取出后,才能继续写入。
4.2 初始化步骤
初始化即给通道分配所需的内存:
- 当分配大小为0时,只需要在内存中分配hchan结构体的大小即可;
- 当通道的元素中不包含指针时,连续分配hchan结构体大小+size元素大小;
- 当通道的元素中包含指针时,需要单独分配内存空间,因为当元素中包含指针时,需要单独分配空间才能正常进行垃圾回收。

4.3 通道写入

4.4 通道读取
