什么是 channel
在 go 并发编程中, 大家经常提到的就是 goroutine 「简单理解为可以并发执行的函数」和 channel 「通道」。在 go 中, 提倡通过通信来共享变量, 而不是通过共享变量来通信。在 java、python 等一些高级语言中, 会使用共享变量在不同的线程中进行通信, 而访问共享变量就需要各种锁操作。在 go 中, 鼓励大家使用 CSP 的并发模型「communicationg sequential processe」, 而不是通过加锁来共享变量的形式控制并发程序。CSP 相当于提出了一种并发编程的新思路, 它能够保证变量只在一个 goroutine 中应用, 从而避免了并发读写的问题, 其底层也是通过加锁来实现 channel 的通信机制。 channel 的特性:
- gotoutine-safe「并发安全」
- gotoutine 之间通信都是通过传值
- channel 的数据保证是先进先出
- 可以引起 gotoutine 的阻塞和非阻塞
从 print A, B 说起
A, B 循环打印, 要求至少使用两个 goroutine, 一个 goroutine 负责打印 A, 一个 goroutine 负责打印 B。下面两个例子分别使用 channel 和 mutex 的形式实现, 主要用于展示两种思路的差异点。
整体思路都是一样:使用 channel 或者 mutex 控制当前 goroutine 是否需要打印, 打印结束后通知另一个 gotoutine。但是这两种实现其实代表了两种不同的并发模型。
func main() {
sa, sb := make(chan struct{}), make(chan struct{})
go func() {
printA(sa, sb)
}()
go func() {
printB(sb, sa)
}()
sa <- struct{}{}
time.Sleep(time.Second * 50000)
}
func printA(signalA <-chan struct{}, signalB chan<- struct{}) {
for range signalA {
time.Sleep(time.Millisecond * 50)
fmt.Print("A")
signalB <- struct{}{}
}
}
func printB(signalB <-chan struct{}, signalA chan<- struct{}) {
for range signalB {
time.Sleep(time.Millisecond * 50)
fmt.Print("B")
signalA <- struct{}{}
}
}
func main() {
m1, m2 := sync.Mutex{}, sync.Mutex{}
m1.Lock()
m2.Lock()
go func() {
for {
m1.Lock()
fmt.Print("A")
time.Sleep(time.Millisecond * 50)
m2.Unlock()
}
}()
go func() {
for {
m2.Lock()
m1.Unlock()
fmt.Print("B")
time.Sleep(time.Millisecond * 50)
}
}()
m1.Unlock()
time.Sleep(time.Hour)
}
channel 的使用
channel 的声明
可以通过 make 创建一个 channel, 和 map 类似, channel 也是对应一个 make 创建的底层数据结构的引用。当我们复制一个 channel 或用于函数传递时, 只是拷贝了一个 channel 的引用, 调用者和被调用者将引用同一个 channel 对象。
c1 := make(chan struct{}) // 无缓冲区的 channel
c2 := make(chan struct{}, 1) // 带缓冲区的 channel
c3 := make(chan struct{}, 0) // 无缓冲区的 channel
c1 <- struct{}{} // 向 channel 发送数据
_ = <-c1 // 从 channel 读数据
for range c2 { // 循环获取数据
// doing
}
var c1 chan struct{}
fmt.Println(c1 == nil) // ture
var c2 chan<- struct{} // 只写 channel
// <-c2 // receive from the send-only type chan<- struct{}
var c3 <-chan struct{} // 只读 channel
// c3 <- struct{}{} // send to the receive-only type <-chan struct{}
// channel 的比较, 可以看到其实比较的是引用对应的具体对象是不是同一个
c1 := make(chan struct{})
c2 := func(c chan struct{}) chan struct{} {
return c1
}(c1)
fmt.Println(c1 == c2) // true
c3 := func(c chan<- struct{}) chan struct{} {
return c1
}(c1)
fmt.Println(c1 == c3) // true
无缓冲区 channel
无缓冲区的 channel 又叫同步 channel。当向无缓冲的 channel 发送消息后, 发送着会被阻塞, 直到接收者收到消息。反之, 接收者也会阻塞, 直到收到相关信息, 才会执行后续操作。
// 接收者阻塞
func main() {
c := make(chan struct{}) // 无缓冲区的 channel
go func() { // 发送者
time.Sleep(time.Second * 5)
c <- struct{}{}
}()
<-c // 阻塞,知道收到数据
fmt.Println("end")
}
// 发送者阻塞
func main() {
c := make(chan struct{}) // 无缓冲区的 channel
go func() { // 发送者
c <- struct{}{}
fmt.Println("end") // 5s 后执行
}()
time.Sleep(time.Second * 5)
<-c
time.Sleep(time.Hour)
}
正是由于无缓冲区 channel 同步的特性, 在运行的过程中就需要始终保持两端正常工作「发送者, 接收者」。使用不当就会导致 goroutine 泄漏。
在下面这个例子中, 由于接收者接收一次消息之后直接退出 goroutine, 导致发送者在第二次发送消息的时候找不到对应的接收者, 导致 goroutine 一直阻塞, 直到有接收者接收信息。修复方案:
c := make(chan struct{},1)垃圾回收器会自动回收带缓冲区的 channel。
func main() {
c := make(chan struct{}) // 无缓冲区的 channel
go func() { // 发送者
c <- struct{}{}
fmt.Println("first send message")
c <- struct{}{} // 这里发生内存泄漏,因为没有接收者,所以会一直阻塞
fmt.Println("second send message")
}()
go func() { // 接收者
<-c
fmt.Println("receive first message")
return
}()
time.Sleep(time.Second * 1)
runtime.GC()
fmt.Println(runtime.NumGoroutine())
time.Sleep(time.Hour)
}
带缓冲区 channel
在某些场景下, 我们并不需要同步操作。可以定义一个带缓冲区的 channel, 将发送和接收解耦。当 channel 为空时, 接收者会阻塞, 直到收到消息; 当 channel 写满时, 发送者会阻塞, 直到可以往 channel 写数据。
func main() {
bufferSize := 2
c := make(chan struct{}, bufferSize)
go func() { // 发送者
for i := 0; i < bufferSize; i++ {
c <- struct{}{}
fmt.Println("send message")
}
c <- struct{}{} // channel 写满之后会阻塞
fmt.Println("after five second")
}()
go func() { // 接收者
time.Sleep(time.Second * 5)
for range c {
fmt.Println("receive message")
}
}()
time.Sleep(time.Hour)
}
channel 关闭
在介绍无缓冲区 channel 的时候, 有一个 gotoutine 泄漏的 case。其实要解决那个问题, 我们也可以通过 close 方法来显性的关闭 channel。
对于带缓冲区的 channel, 并不鼓励大家主动 close channel, 只有当明确知道 channel 只有一个发送者, 并且这个发送者不需要再发送数据时可以显性的关闭 channel。其实, 主要是显性关闭 channel 可能会导致发送者发送失败。
channel 关闭原则:
- 不要在接收者侧关闭 channel
- 如果 channel 有多个写入者, 不要关闭 channle
// 向已经 close 的 channel 发送数据会导致 panic
func main() {
bufferSize := 2
c := make(chan struct{}, bufferSize)
go func() { // 发送者
for i := 0; i < bufferSize; i++ {
c <- struct{}{} // panic: send on closed channel
time.Sleep(time.Second)
fmt.Println("send message")
}
}()
close(c)
time.Sleep(time.Hour)
}
对于接收者, 当没有发送者再发送数据的时候, 往往需要关闭接收者, 释放资源。我们可以通过 close 来告诉接收者退出任务
func main() {
bufferSize := 20
c := make(chan struct{}, bufferSize)
go func() { // 发送者
for i := 0; i < bufferSize; i++ {
c <- struct{}{}
fmt.Println("send message")
}
close(c) // 关闭
}()
for i := 0; i < 5; i++ {
go func() {
for {
select {
case _, ok := <-c:// ok 表示 channel 是否关闭
if !ok {
fmt.Println("return")
return
}
fmt.Println("receive message")
}
}
}()
}
time.Sleep(time.Hour)
}
对于带有缓冲区的 channel, 即使 channel 关闭也能收到 channel 中未发送的消息。
func main() {
bufferSize := 20
c := make(chan struct{}, bufferSize)
go func() { // 发送者
for i := 0; i < bufferSize; i++ {
c <- struct{}{}
fmt.Println("send message")
}
close(c) // 关闭
}()
time.Sleep(time.Second)
go func() { // 即使 cahnnel 已经关闭, 也可以读到当前 channel 的数据
for {
select {
case _, ok := <-c:
if !ok {
fmt.Println("return")
return
}
fmt.Println("receive message")
}
}
}()
time.Sleep(time.Hour)
}
select + channel
在 io 模型中有一个 io 多路复用的概念, 本质上是解决 io 操作和 cpu 操作速度不匹配的问题, 避免大量 io 导致 cpu 占用。当 io 操作完成后, 可以通知 cpu 拿到 io 操作的结果继续处理, 这样 cpu 在没有拿到 io 发送的通知时可以执行其他的工作。
在 go 中, 可以通过 select + channel 的形式实现多路复用, 通过一个 goroutine 来接收多个 channel 发送的消息, 哪个消息先来到就接收哪个消息。
func main() {
// 阻塞, 直到收到消息
c := make(chan struct{})
go func() {
select {
case <-c:
fmt.Println("receive message")
}
}()
// 不阻塞, 收不到消息执行 default
c := make(chan struct{})
go func() {
select {
case <-c:
fmt.Println("receive message")
default:
fmt.Println("default")
}
}()
// 多 channel 复用时, 会随机选择对应的 case 语句:21231231122123322331
bufferSize := 20
c1 := make(chan struct{}, bufferSize)
go func() {
for {
select {
case <-c1:
fmt.Print("1")
case <-c1:
fmt.Print("2")
case <-c1:
fmt.Print("3")
}
}
}()
for i := 0; i < bufferSize; i++ {
c1 <- struct{}{}
}
}
channel 的实现
我们知道, go 在运行时会有自己的 runtime 进行垃圾回收、goroutine 调度等操作, 那 channel 在 runtime 中是怎么实现的呢。
从 channel 的功能来看「暂时只考虑带缓冲区的 channel」, 其实是一个队列, 往 channel 发数据和接收数据需要保证并发安全。具体实现可以使用一个有锁的数组「所以, 从性能上来看, 使用 channle 未必比直接使用 mutex 性能更好」, 同时调度 channel 对应的接收 goroutines 和发送 gotoutines。
channel 数据结构
其中可以看到, 核心包括一个 lock 控制并发, 等待发送的队列, 等待接收的队列, 队列大小, 队列元素指针, 是否关闭
channel 发送和接收过程
图片是从 gophercon 2017-talks 搬过来的。
向 buffer_size 为 3 的 channel 写数据, 直到 channel 写满, 阻塞
从 channel 接收数据, 更新 sendx 和 recvx
当 channel 写满时, 发送者会被阻塞; 当 channel 为空时, 接收者会被阻塞, 这里的阻塞则依赖 go 的调度器来实现。
在 go 语言中, 使用的 GMP 模型, 可以简单理解为 goroutine、线程、调度器。当线程数量等于 cpu 数量时, 就可以避免因为线程切换而造成额外的资源损耗, 全部依赖调度器来实现 goroutine 之间的调度
channel 写满, 调度器将 gouroutine 进行阻塞
总结
channel 是 go 中的一种内置数据类型,设计的目的是为了保证不同 goroutine 之间的通信安全。使用 channel 的优势在于业务方无需再考虑负责的加锁、解锁等细节。直接声明一个 channel 类型的变量,可以在多个 goroutine 中并发使用。
channel 分为有缓冲的和无缓冲的。对于无缓冲的 channel,生产者/消费者会产生阻塞等待。对于有缓冲的 channel,生产者会将数据写入缓冲区,写满时发生阻塞;消费者消费缓冲区的数据,无数据时发生阻塞。此外,可以配合 select,来接收对应的 channel 信息。