深入 Golang channel (一)基础用法都在这了

796 阅读7分钟

深入 Golang channel (一)基础用法都在这了

1. 前言

你好哇!本文是「Golang 并发编程」系列的第 2 篇文章~

现在感觉这个坑开得有点大,没个一年半载的讲不清楚了……

上篇文章我们学习了 Go 语言中的 Goroutine 创建和调度的机制,理解了一条简单的 go 命令背后的原理。本文开始,将深入研究 go 语言中的 channel,将拆分为基础用法、实用场景、反面教材、源码实现四篇文章来介绍,欢迎关注追更~

本文将介绍 channel 相关的概念、语法和规则,不涉及原理和源码分析,更深入的内容,后面的更新会覆盖到,敬请期待~

2. Channel 简介

Channel 是 go 语言内置的一个非常重要的特性,也是 go 并发编程的两大基石之一(另一个是 go ,也就是 goroutine )。Channel 也是 go 里面非常有趣的一个功能,有时候甚至有点烧脑,相对于传统的 mutex 等同步并发原语,掌握 channel 门槛更高、更容易出错,因此更值得深入学习。

关于并发编程,Rob Pike 有个名言:

不要通过共享内存来通信,要通过通信来共享内存
Don't (let computations) communicate by sharing memory, (let them) share memory by communicating (through channels)

在 go 语言中,channel 就是 goroutine 之间通过通信来共享内存的手段。可以把 channel 看作 go 程序内部的一个 FIFO (first in, first out) 队列,一些 goroutine 向其中生产数据,另外一些消费数据。

另外 channel 是 go 语言中的一等公民,不像使用 mutexwaitgroup 等其它并发编程原语需要引入 syncatomic 等包,channel 可以直接使用,不需要引入任何包。

3. Channel 的类型和值

跟 slice 、map 这些内置类型一样,channel 作为一种元素类型,也是有具体的类型的,channel 只能传递声明的类型的值。

基础类型:双向与单向 channel

对于类型 T,可以声明三种类型的 channel:

  • chan T 双向 channel ,既能接收值又能发送值
  • chan<- T send-only channel,只能往里写(chan是箭头的终点)
  • <-chan T receive-only channel,只能从里读(chan是箭头的起点) 在函数中使用即:
func foo(ch1 <-chan int)  // 只能从 ch1 里读

func bar(ch2 chan<- int)  // 只能往 ch2 里写

双向 channel chan T 可以被隐式转换成 send-only channel chan<- T 或 receive-only channel <-chan T;而 chan<- T<-chan T 两者则不能互相转换(显式转换也不行)

单向 channel 是一种函数传参时的安全性约束,在实际使用中几乎不可能单独去声明一个单向的 channel。

buffered channel + unbuffered channel + nil channel

每个 channel 类型的值都会有一个容量(capacity),根据 capacity 大小来区分,可以分为两种:

  • buffered channel:带缓冲的 channel,cap > 0
  • unbuffered channel:不带缓冲的 channel,cap = 0

使用 make 创建 channel:

ch1 := make(chan int, 10)  // buffered channel, cap = 10
ch2 := make(chan int) // unbuffered channel, cap = 0 (make chan 函数第二参数默认值为 0)
var ch3 chan int  // nil 是 chan 的零值(zero value)

注意到上面的例子中还有一种特殊的 channel :nil channel,在只声明但是并未 make 时,channel 的值是零值 nil。nil channel 无论是写入还是读取都会永久阻塞住。

4. channel 的 7 种操作

1. 向 channel 发送值

ch <- v

需要注意:

  • v 需要和 ch 声明的元素类型相同
  • <-channel-send 即 channel 发送操作符
  • 这里的 ch 不能是 receive-only channel

2. 从 channel 里读取结果

<- ch
  • 从 channel 里取数据的操作符叫 channel-receive 操作符,使用它总会有至少一个返回值,它跟 channel-send 操作符长得一样
  • 这里的 ch 不能是 send-only channel
  • channel-receive 操作符大部分时候都返回一个结果,但是也能返回两个结果,第二个结果是用来指示从 channel 里出来的值是否是在 channel 关闭之前读到的,如以下代码所示:
v = <-ch
v, sentBeforeClosed = <-ch  // 先关闭再发送 v,则返回 false

这种方式与操作 map 时的方式类似,被称作 ok-idiom,并且,在 close(ch) 函数执行的时候,会对 ch 发送一条消息,这个动作可以用来通知所有的 goroutine 退出,例如:

package main

func main() {
    done := make(chan struct{})
    c := make(chan int)

    go func() {
        defer close(done)
        
        for {
            x, ok := <-c
            
            if !ok {	// close 时会收到一条消息,x 值为 0,ok 为 false
                return
            }

            println(x)
        }
    }()

    c <- 1
    c <- 2
    c <- 3
    close(c)
    <-done	// close 时会收到消息,解除阻塞
}

3. for-range 操作

使用 for-range 遍历 channel 会比使用 ok-idiom 更简洁,将上面的例子用 for-range 的方式来实现:

func main() {
    done := make(chan struct{})
    c := make(chan int)

    go func() {
        defer close(done)
        
        for x := range c {
            println(x)
        }
    }()

    c <- 1
    c <- 2
    c <- 3
    close(c)
    <-done	// close 时会收到消息,解除阻塞
}

4. select 多路选择

将在下文专门讨论

5. 关闭 channel

close(ch)

close 是个 go 内置函数,只能操作 channel 类型,且 close 的对象不能是 receive-only channel。另外,在前面的例子里可以看到, close 发生时,会向被关闭的 channel 发送一条消息,解除阻塞,这个特性可以用来做一些一次性的操作。

错误的 close 会引发程序的 panic,关于如何优雅关闭 channel,我会在后面的「反面教材:panic 和内存泄漏」主题里展开,敬请期待~继续挖坑……

6. 返回 channel 的容量(capacity)

cap(ch)

cap 是 go 的内置函数,会返回一个 int 类型的 channel 容量,可以参考作用在 slice 时的表现

7. 返回 channel buffer 中值的数量

len(ch)

cap 函数类似,len也是 go 语言的内置函数,返回值是已经成功写入 channel buffer 但是还没有从 channel 里读出来的值的数量。在 channel 的操作中,caplen 函数其实用得都不多。

5. 阻塞场景梳理

针对根据 channel 是否为空和是否关闭,可以分成以下三类来讨论:

  1. 空 channel (nil channel)
  2. 非空已关闭 channel
  3. 非空未关闭 channel
操作为空非空已关闭非空未关闭
closepanicpanic成功 close
写入永久阻塞panic成功写入或阻塞
读取永久阻塞永不阻塞成功读取或阻塞

要理解这几种现象,就要看下 channel 的内部结构了,可以认为 channel 内部有三个 FIFO 队列

  1. 接收数据的 goroutine 队列,是一个无限长的链表,这个队列里的 goroutine 都处于阻塞状态,等待数据从 channel 写入
  2. 发送数据的 goroutine 队列,也是一个无限长的链表,这个队列里的 goroutine 都处于阻塞状态,等待数据向 channel 写入。每个 goroutine 尝试发送的值也和 goroutine 一起存在这个队列里
  3. 值 buffer 队列,是一个环形队列(ringbuffer),它的大小跟 channel 的容量相同。存在这个 buffer 队列里的值跟 channel 元素的类型相同。如果当前 buffer 队列里储存的值的数量达到了 channel 的容量,这个 channel 就「满了」,对于 unbuffered channel 而言,它总是既在「空」状态,又在「满」状态。

更多的原理性的分析,将在本系列的后续「原理与源码分析」文章展开。

6. 多路选择操作符 select

select 语句是专门为 channel 操作而设计的,使用时类似 switch-case 的用法,适用于处理多通道的场景,会通过类似 are-you-ready-polling 的机制来工作。当多个 case 中有一个准备好了,就能执行,无论是收还是发。

阻塞与非阻塞 select

select 默认是阻塞的,当没有 case 处于激活状态时,会一直阻塞住,极端的甚至可以这样用...

select {
    // 啥也不干,一直阻塞住
}

通过增加 default,可以实现非阻塞的 select

select {
    case x, ok := <-ch1:
        ...
    case ch2 <- y:
        ...
    default:
        fmt.Println("default")

}

多 case 与 default 执行的顺序

整体流程如图所示: 需要注意:

  • 随机性:多个 case 之间并非顺序的,遵循「先到先执行,同时到则随机执行」的原则
  • 一次性:和 switch-case 一样,select-case也只会执行一次,如果需要多次处理,需要在外层套一个循环
  • default 不会阻塞,会一直执行,当与 for 循环组合使用时可能出现死循环,如下面代码所示:
func main() {
	  var ch chan int
	  i := 0
	  for {
		  select {
		  case <-ch:  // nil channel 永远阻塞
			    fmt.Println("never...")
		  default:
			    fmt.Printf("in default, i = %d\n", i)
		  }
      i++
	  }
}

小结

本文完整介绍了 channel 的所有基础用法,包括 <-ok-idiomselect-caseclosefor-range 等,并分析了阻塞与非阻塞的场景。后面三篇文章将分别介绍 channel 在实际的程序世界中的多种实用案例、底层实现及使用不当导致的死锁、内存泄漏等问题,敬请期待!

如果本文对你有帮助,记得「关注」、「点赞」、「在看」走起,也欢迎留言讨论,你的反馈是我更新的动力~

To be continued...

参考资料