首页 > 编程笔记 > Go语言笔记 阅读:8

Go语言通道的用法(非常详细)

通道(channel)是 Go 语言在语言级别提供的协程间的通信方式。可以使用通道在两个或多个协程之间传递消息。

通道是进程内的通信方式,因此通过通道传递对象的过程和调用函数时的参数传递行为比较一致,也可以传递指针等。如果需要跨进程通信,建议用分布式系统的方法来解决,如使用 Socket 或者 HTTP 等通信协议。Go 语言对于网络方面也有非常完善的支持。

通道是类型相关的,也就是说,一个通道只能传递一种类型的值,这个类型需要在声明通道时指定。如果对 UNIX 管道有所了解的话,就不难理解通道,可以将其认为是一种类型安全的管道。

通道的声明

Go语言中的通道是一种特殊的类型。在任何时候,同时只能有一个协程访问通道,进行发送和获取数据。协程间通过通道可以进行通信。

通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的通道本身需要一个类型进行修饰,就像切片类型需要标识元素类型。通道的元素类型就是在其内部传输的数据类型,声明格式如下:
var 通道变量 chan 通道类型

与一般的变量声明不同的地方仅仅是在类型之前加了 chan 关键字。通道类型指定了这个通道所能传递的元素类型。

例如,声明一个传递类型为 int 的通道,代码如下:
var ch chan int
或者声明一个 map,元素是 bool 型的通道,代码如下:
var m map[string] chan bool
chan 类型的空值是 nil,声明后需要配合 make 后才能使用。

通道的创建

通道是引用类型,需要使用 make 进行创建,语法格式如下:
通道实例 := make(chan 数据类型)

例如:
ch1 := make(chan int)              //创建一个整型类型的通道
ch2 := make(chan interface{})      //创建一个空接口类型的通道,可以存放任意格式
type Equip struct{ /*一些字段*/ }
ch2 := make(chan *Equip)           //创建Equip指针类型的通道,可以存放*Equip

通道的作用

通道创建后,可以使用通道进行发送和接收操作。

1、使用通道发送数据

通道的发送,使用特殊的操作符“<-”,将数据通过通道发送的格式如下:
通道变量 <- 值

例如,使用 make 创建一个通道后,就可以使用“<-”向通道发送数据,代码如下:
//创建一个空接口通道
ch := make(chan interface{})
//将0放入通道中
ch <- 0
//将hello字符串放入通道中
ch <- "hello"
把数据往通道中发送时,如果接收方一直都没有接收,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示,例如:
package main
func main() {
   //创建一个整型通道
   ch := make(chan int)
   //尝试将0通过通道发送
   ch <- 0
}
运行结果如下图所示:

图 1 发送阻塞

运行时发现所有的协程(包括 main)都处于等待协程状态。也就是说所有协程中的通道并没有形成发送和接收对应的代码。

2、使用通道接收数据

通道接收同样使用“<-”操作符,通道接收有如下特性:
  1. 通道的收发操作在两个不同的协程间进行。由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个协程中进行。
  2. 接收将持续阻塞,直到发送方发送数据。如果接收方接收时,通道中没有发送方发送数据,接收方也会发生阻塞,直到发送方发送数据为止。
  3. 每次接收一个元素。通道一次只能接收一个数据元素。

通道的数据接收有 4 种方式:

1) 阻塞接收数据

阻塞模式接收数据时,将接收变量作为“<-”操作符的左值,格式如下:
data := <-ch
执行该语句时将会阻塞,直到接收到数据并赋值给data变量。

2) 非阻塞接收数据

使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下:
data, ok := <-ch

非阻塞的通道接收方法可能造成高的CPU占用,因此使用非常少。如果需要实现接收超时检测,可以配合 select 和计时器通道(后面会讲到)。

3) 接收任意数据,忽略接收的数据

阻塞接收数据后,忽略从通道返回的数据,格式如下:
<-ch
执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。

例如,使用通道做并发同步,代码如下:
package main
import (
   "fmt"
)
func main() {
   //构建一个通道
   ch := make(chan int)
   //开启一个并发匿名函数
   go func() {
      fmt.Println("start goroutine")
      //通过通道通知main的goroutine
      ch <- 0
      fmt.Println("exit goroutine")
  }()
  fmt.Println("wait goroutine")
  //等待匿名goroutine
  <-ch
  fmt.Println("all done")
}
运行结果为:

wait goroutine
start goroutine
exit goroutine
all done

在以上代码中:

4) 循环接收

通道的数据接收可以借用 for range 语句进行多个元素的接收操作,格式如下:
for data := range ch {
}
通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 循环遍历获得的变量只有一个,即上面例子中的 data。

例如,使用 for 循环从通道中接收数据。
package main
import (
   "fmt"
   "time"
)
func main() {
   //构建一个通道
   ch := make(chan int)
   //开启一个并发匿名函数
   go func() {
       //从3循环到0
       for i := 3; i >= 0; i-- {
           //发送3到0的数值
           ch <- i
           //每次发送完时等待
           time.Sleep(time.Second)
       }
   }()
   //遍历接收通道数据
   for data := range ch {
       //打印通道数据
       fmt.Println(data)
       //当遇到数据0时, 退出接收循环
       if data == 0 {
               break
       }
   }
}
运行结果为:

3
2
1
0

在以上代码中:

select关键字

select 是类 UNIX 系统提供的一个多路复用系统 API,Go 语言借用多路复用的概念,提供了 select 关键字,用于多路监听多个通道。

当监听的通道没有状态是可读或可写时,select 是阻塞的;只要监听的通道中有一个状态是可读或可写的,则 select 就不会阻塞,而是进入处理就绪通道的分支流程。如果监听的通道有多个可读或可写的状态,则 select 随机选取一个进行处理。

通过调用 select() 函数来监控一系列的文件句柄,一旦其中一个文件句柄发生了 I/O 动作,该 select() 调用就会被返回。后来该机制也被用于实现高并发的 Socket 服务器程序。Go 语言直接在语言级别支持 select 关键字,用于处理异步 I/O 问题。

select 的用法与 switch 语言非常类似,由 select 开始一个新的选择块,每个选择条件由 case 语句来描述。与 switch 语句可以选择任何可使用相等比较的条件相比,select 有比较多的限制,其中最大的一条限制就是每个 case 语句必须是一个 I/O 操作,大致的结构如下:
select {
   case <-chan1:     //如果chan1成功读到数据,则进行该case处理语句
   case chan2 <- 1:  //如果成功向chan2写入数据,则进行该case处理语句
   default:          //如果上面都没有成功,则进入default处理流程
}
可以看出,select 不像 switch,后面并不带判断条件,而是直接去查看 case 语句。每个 case 语句都必须是一个面向通道的操作。比如上面的例子中,第一个 case 试图从 chan1 读取一个数据并直接忽略读到的数据,而第二个 case 则试图向 chan2 中写入一个整数 1,如果这两者都没有成功,则执行 default 语句。

基于此功能,可以实现如下程序:
package main
import (
  "fmt"
)
func main() {
   ch := make(chan int, 1)
  for {
      select {
      case ch <- 0:
      case ch <- 1:
      }
      i := <-ch
     fmt.Println("接收到的值为: ", i)
  }
}
这个程序实现了一个随机向 ch 写入一个 0 或者 1 的过程,但这是一个死循环。

缓冲机制

前面创建的都是不带缓冲的通道,这种做法对于传递单个数据的场景可以接受,但对于需要持续传输大量数据的场景就有些不适合了。接下来介绍如何给通道带上缓冲,从而达到消息队列的效果。

Go 语言中有缓冲的通道(buffered channel),其是一种在被接收前能存储一个或者多个值的通道。这种类型的通道并不强制要求协程之间必须同时完成发送和接收。通道会阻塞发送和接收动作的条件也会不同。只有在通道中没有要接收的值时,接收动作才会阻塞。只有在通道没有可用缓冲区容纳被发送的值时,发送动作才会阻塞。

这导致有缓冲的通道和无缓冲的通道之间有一个很大的不同:无缓冲的通道保证进行发送和接收的协程会在同一时间进行数据交换;有缓冲的通道没有这种保证。

在无缓冲通道的基础上,为通道增加一个有限大小的存储空间形成带缓冲通道。带缓冲通道在发送时无须等待接收方接收即可完成发送过程,并且不会发生阻塞,只有当存储空间满时才会发生阻塞。同理,如果缓冲通道中有数据,接收时将不会发生阻塞,直到通道中没有数据可读时,通道才会再度阻塞。

创建带缓冲通道的语法格式如下:
通道实例 := make(chan 通道类型, 缓冲大小)

例如:
package main
import "fmt"
func main() {
   //创建一个3个元素缓冲大小的整型通道
   ch := make(chan int, 3)
   //查看当前通道的大小
   fmt.Println(len(ch))
   //发送3个整型元素到通道
   ch <- 1
   ch <- 2
   ch <- 3
   //查看当前通道的大小
   fmt.Println(len(ch))
}
运行结果如下:

0
3

在以上代码中:
带缓冲通道在很多特性上和无缓冲通道是类似的。无缓冲通道可以看作长度永远为 0 的带缓冲通道。因此,根据这个特性,带缓冲通道在以下两种情况下依然会发生阻塞:
为什么 Go 语言对通道要限制长度而不提供无限长度的通道?

我们知道通道是在两个协程间通信的桥梁。使用协程的代码必然有一方发送数据,一方接收数据。当发送数据方的数据供给速度大于接收方的数据处理速度时,如果通道不限制长度,那么内存将不断膨胀,直到应用崩溃。因此,限制通道的长度有利于约束数据发送方的供给速度,供给数据量必须在接收方处理量和通道长度的范围内,才能正常地处理数据。

通道的传递

在 Go 语言中通道本身也是一个原生类型,与 map 的类型地位一样,因此通道本身在定义后也可以通过通道来传递。

可以使用这个特性来实现 Linux/UNIX 非常常见的管道(Pipe)特性。管道也是使用非常广泛的一种设计模式,例如,在处理数据时,可以采用管道设计,这样比较容易以插件的方式增加数据的处理流程。

利用通道可被传递的特性来实现管道。为了简化表达,假设在管道中传递的数据只是一个整型数,在实际的应用场景中通常会是一个数据块。

首先限定基本的数据结构
type PipeData struct {
   value int
   handler func(int) int
   next chan int
}
然后写一个常规的处理函数。只要定义一系列 PipeData 的数据结构并一起传递给这个函数,就可以达到流式处理数据的目的:
func handle (queue chan *PipeData) {
   for data := range queue {
       data. next <- data.handler (data. value)
   }
}
同理,利用通道这个可传递的特性,可以实现非常强大、灵活的系统架构。相比之下,在 C++、Java、C# 中,要达成这样的效果,通常就意味着要设计一系列接口。

与 Go 语言接口的非入侵时类似,通道的这些特性也可以大大减少开发时间,用一些比较简单却实用的方式来达成在其他语言中需要使用众多技巧才能达成的效果。

单向通道

Go 语言的类型系统提供了单方向的通道类型,顾名思义,单向通道就是只能用于写入或者只能用于读取数据。当然,通道本身必然是同时支持读写的,否则根本没法用。

假如一个通道真的只能读取数据,那么它肯定只会是空的,因为没有机会往里面写入数据。同理,如果一个通道只允许写入数据,即使写进去了,也没有丝毫意义,因为没有办法读取到里面的数据。所谓的单向通道,其实只是对通道的一种使用限制。

在将一个通道变量传递到一个函数时,可以通过将其指定为单向通道变量,从而限制该函数中可以对此通道的操作,例如,只能往这个通道中写入数据,或者只能从这个通道读取数据。

单向通道变量的声明非常简单,只能写入数据的通道类型为 chan<-,只能读取数据的通道类型为 <-chan,语法格式如下:
var 通道实例 chan<- 元素类型  //只能写入数据的通道
var 通道实例 <-chan 元素类型  //只能读取数据的通道

例如:
ch := make(chan int)
//声明一个只能写入数据的通道类型, 并赋值为ch
var chSendOnly chan<- int = ch
//声明一个只能读取数据的通道类型, 并赋值为ch
var chRecvOnly <-chan int = ch
在以上代码中,chSendOnly 只能写入数据,如果尝试读取数据,将会出现如下错误:

invalid operation: <-chSendOnly (receive from send-only type chan<- int)

同理,chRecvOnly也是不能写入数据的。

当然,使用 make 创建通道时,也可以创建一个只写入或只读取的通道,代码如下:
ch := make(<-chan int)
var chReadOnly <-chan int = ch
<-chReadOnly
注意:一个不能写入数据只能读取的通道是毫无意义的。

time 包中的计时器返回一个 timer 实例,例如:
timer := time.NewTimer(time.Second)
timer 的 Timer 类型定义如下:
type Timer struct {
   C <-chan Time
   r runtimeTimer
}
第 2 行中 C 通道的类型就是一种只能读取的单向通道。如果此处不进行通道方向约束,一旦外部向通道写入数据,将会造成其他使用到计时器的地方产生逻辑混乱。

因此,单向通道有利于代码接口的严谨性。

关闭通道

关闭通道的方法非常简单,直接使用 Go 语言内置的 close() 函数即可关闭通道。
close(ch)
如何判断一个通道是否已经被关闭了?

可以在读取的时候使用多重返回值的方式:
x, ok := <- ch
这个用法与 map 中的按键获取 value 的过程比较类似,只需要看第二个 bool 的返回值即可,如果返回值为 false,则表示 ch 已经被关闭了。

超时和计时器

在之前对通道的介绍中,完全没有提到错误处理的问题,而这个问题显然是不能被忽略的。

在并发编程的通信过程中,最需要处理的就是超时问题,即向通道写数据时发现通道已满,或者从通道试图读取数据时发现通道为空。如果不正确处理这些情况,很可能会导致整个协程锁死。

虽然协程是 Go 语言引入的新概念,但通信锁死问题已经存在很长时间了,在之前的 C/C++ 开发中也存在。操作系统在提供此类系统级通信函数时也会考虑超时的场景,因此这些方法通常都会带一个独立的超时参数。超过设定的时间时,仍然没有处理完任务,则该方法会立即终止并返回对应的超时信息。

超时机制本身虽然也会带来一些问题,如在运行比较快的机器或者高速的网络上运行正常的程序,到了慢速的机器或者网络上运行就会出问题,从而出现结果不一致的现象,但从根本上来说,解决死锁问题的价值要远大于所带来的问题。

使用通道时需要小心,如对于以下这个用法:
i := <-ch
不出问题的话一切都正常运行,但如果出现了一个错误情况,即永远都没有人往 ch 中写数据,那么上述这个读取动作也将永远无法从 ch 中读取到数据,导致的结果就是整个协程永远阻塞并没有挽回的机会。如果通道只是被同一个开发者使用,那样出问题的可能性还低一些。如果一旦对外公开,就必须考虑到最差的情况并对程序进行保护。

Go 语言没有提供直接的超时处理机制,但可以利用 select 机制。虽然 select 机制不是专为超时而设计的,却能很方便地解决超时问题,因为 select 的特点是只要其中一个 case 已经完成,程序就会继续往下执行,而不会考虑其他 case 的情况。

基于此特性,来为通道实现超时机制:
//首先实现并执行一个匿名的超时等待函教
timeout := make(chan bool, 1)
go func() {
  time.Sleep(1e9)    //等待1秒
  timeout < - true
  }()
  //然后把timeout这个通道利用起来
  select {
  case <-ch:
      //从ch中读取到数据
  case <-timeout:
      //一直没有从ch中读取到数据,但从timeout中读取到了数据
}

相关文章