通道 - Go 语言学习笔记

1,551 阅读9分钟

前言

在 Go 语言里,你不仅可以使用原子函数和互斥锁来保证对共享资源的安全访问以及消除竞争状态,还可以使用通道,通过发送和接收需要共享的资源,在 goroutine 之间做同步。

如果说goroutine是Go并发的执行体,那么“通道”就是他们之间的连接。

简介

通道(channel)是实现两个 goroutine 之间通信的机制。当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。

创建通道

创建通道时,需要指定将要被共享的数据的类型。可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。

在 Go 语言中通道是引用类型,使用内置函数 make 来创建一个通道
格式如下:

通道实例 := make(chan 数据类型)

make 的第一个参数需要是关键字 chan,之后跟着允许通道交换的数据的类型。如果创建的是一个有缓冲的通道,之后还需要在第二个参数指定这个通道的缓冲区的大小。

  • 无缓冲的通道

无缓冲的通道(unbuffered channel)是指在接收前没有能力保存任何值的通道。这种类型的通道要求发送 goroutine 和接收 goroutine 同时准备好,才能完成发送和接收操作。如果两个 goroutine 没有同时准备好,通道会导致先执行发送或接收操作的 gotoutine 阻塞等待。这种对通道进行发送和接收的交互行为本身就是同步的。其中任意一个操作都无法离开另一个操作单独存在。

// 无缓冲的整型通道
unbuffered := make(chan int)
  • 有缓冲的通道

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

// 有缓冲的字符chuang
buffered := make(chan string, 10)

注意:如果通道不带缓冲,发送方会阻塞直到接收方从通道中接收了值。如果通道带缓冲,发送方则会阻塞直到发送的值被拷贝到缓冲区内;如果缓冲区已满,则意味着需要等待直到某个接收方获取到一个值。接收方在有值可以接收之前会一直阻塞。

通道传值

操作符 <- 用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。
格式如下:

通道变量 <- 值
  • 通道变量:通过make创建好的通道实例。
  • 值:可以是变量、常量、表达式或者函数返回值等。值的类型必须与ch通道的元素类型一致。

示例如下:

// 向通道发送值
ch <- v     // 把 v 发送到通道 ch

// 从通道里接收值
v, ok := <-ch   // 从 ch 接收数据并把值赋给 v,如果通道接收不到数据后 ok 就为 false

遍历通道

Go 通过使用 range 函数来遍历通道以接收通道数据。

package main
 
import "fmt"
 
func main() {
    // 我们遍历 queue 通道里面的两个数据
    queue := make(chan string, 2)
    
    queue <- "one"
    queue <- "two"
    close(queue)
    
    /*
    range 函数遍历每个从通道接收到的数据,因为 queue 再发送完两个
    数据之后就关闭了通道,所以这里我们range函数在接收到两个数据
    之后就结束了。如果上面的queue通道不关闭,那么 range 函数就不
    会结束,从而在接收第三个数据的时候就阻塞了。
    */

    for elem := range queue {
        fmt.Println(elem)
    }
}

执行输出结果为:

one
two
  • for 和 range 为基本的数据结构提供了迭代功能,同样可以用于通道的遍历
  • 以上例子是遍历通道 queue 中的两个值
  • 我们 close 了这个通道,所以遍历完这两个值后结束,如果不 close的 话,将一直阻塞执行,等待接收第三个值
  • 这个例子表明,非空的通道也是可以被关闭的,但是通道中剩下的值仍然可以被接收到

关闭通道

通道是一个引用类型,在没有任何外部引用时,Go 程序在运行时(runtime)会自动对通道进行垃圾回收,而且通道也可以被主动关闭。

使用 close() 来关闭一个通道
格式:

close(ch)

1. 给被关闭通道发送数据将会触发panic

被关闭的通道不会被设置为 nil,如果尝试对已经关闭的通道进行发送,将会触发宕机。
示例如下:

package main
import "fmt"
func main() {
    // 创建一个整型的通道
    ch := make(chan int)
    // 关闭通道
    close(ch)
    // 打印通道的指针, 容量和长度
    fmt.Printf("ptr:%p cap:%d len:%d\n", ch, cap(ch), len(ch))
    // 给关闭的通道发送数据
    ch <- 1
}

代码运行后触发宕机:

panic: send on closed channel

2. 从已关闭的通道接收数据时将不会发生阻塞

从已经关闭的通道接收数据或者正在接收数据时,将会接收到通道类型的零值,然后停止阻塞并返回。

示例如下:

package main
import "fmt"
func main() {
    // 创建一个整型带两个缓冲的通道
    ch := make(chan int, 2)
   
    // 给通道放入两个数据
    ch <- 0
    ch <- 1
   
    // 关闭缓冲
    close(ch)
    // 遍历缓冲所有数据, 且多遍历1个
    for i := 0; i < cap(ch)+1; i++ {
   
        // 从通道中取出数据
        v, ok := <-ch
       
        // 打印取出数据的状态
        fmt.Println(v, ok)
    }
}

代码运行结果如下:

0 true
1 true
0 false

以上运行结果的前两行正确输出带缓冲通道的数据,表明缓冲通道在关闭后依然可以访问内部的数据。
以上运行结果的第三行的“0 false”表示通道在关闭状态下取出的值。0 表示这个通道的默认值,false 表示没有获取成功,因为此时通道已经空了。我们发现,在通道关闭后,即便通道没有数据,在获取时也不会发生阻塞,但此时取出数据会失败。

使用通道示例

  • 无缓冲的通道

无缓冲的通道的一个重要作用就是在两 goroutine 之间同步交互数据。

在网球比赛中,两位选手会把球在两个人之间来回传递。选手总是处在以下两种状态之一:要么在等待接球,要么将球打向对方。可以使用两个 goroutine 来模拟网球比赛,并使用无缓冲的通道来模拟球的来回。如下:

// 这个示例程序展示如何用无缓冲的通道来模拟2个 goroutine 间的网球比赛
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// wg 用来等待程序结束
var wg sync.WaitGroup

func init() {
	rand.Seed(time.Now().UnixNano())
}

// main 是所有 Go 程序的入口
func main() {
	// 创建一个无缓冲的通道
	court := make(chan int)

	// 计数加2,表示要等待两个 goroutine
	wg.Add(2)

	// 启动两个选手
	go player("张三", court)
	go player("李四", court)

	// 发球
	court <- 1

	// 等待游戏结束
	wg.Wait()
}

// Player 模拟一个选手在打网球
func player(name string, court chan int)  {
	// 在函数退出时调用 Done 来通知 main 函数工作已经完成
	defer wg.Done()

	for {
		// 等待球被击打过来
		ball, ok := <-court
		if !ok {
			// 如果通道被关闭,我们就赢了
			fmt.Printf("球员 %s 赢了\n", name)
			return
		}

		// 选随机数,然后用这个数来判断我们是否丢球
		n := rand.Intn(100)
		if n%3 == 0 {
			fmt.Printf("球员 %s 输了\n", name)

			// 关闭通道,表示我们输了
			close(court)
			return
		}

		// 显示击球数,并将击球数加1
		fmt.Printf("球员 %s 击中 %d\n", name, ball)
		ball ++

		// 将球打向对手
		court <- ball
	}

}

执行后随机得到以下输出:

球员 李四 击中 1
球员 张三 击中 2
球员 李四 输了
球员 张三 赢了
  • 有缓冲的通道

// 这个示例程序展示如何使用有缓冲的通道和固定数目的 goroutine 来处理一堆工作
package main

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

const (
	numberGoroutines = 4 // 要使用的 goroutine 的数量
	taskLoad = 10 // 要处理的工作的数量
)

// wg 用来等待程序完成
var wg sync.WaitGroup

// init 初始化包,Go 语言运行时会在其它代码执行之前优先执行这个函数
func init() {
	// 初始化随机数种子
	rand.Seed(time.Now().Unix())
}

// main 是所有 Go 程序的入口
func main() {
	// 创建一个有缓冲的通道来管理工作
	tasks := make(chan string, taskLoad)

	// 启动 goroutine 来处理工作
	wg.Add(numberGoroutines)
	for gr := 1; gr <= numberGoroutines; gr++ {
		go worker(tasks, gr)
	}

	// 增加一组要完成的工作
	for post := 1; post <= taskLoad; post++ {
		tasks <- fmt.Sprintf("Task: %d", post)
	}

	// 当所有工作都处理完时关闭通道,以便所有 goroutine 退出
	close(tasks)

	// 等待所有工作完成
	wg.Wait()
}

// worker 作为 goroutine 启动来处理
// 从有缓冲的通道传入的工作
func worker(tasks chan string, worker int)  {
	// 通知函数已经返回
	defer wg.Done()

	for {
		// 等待分配工作
		task, ok := <-tasks
		if !ok {
			// 这意味着通道已经完了,并且已被关闭
			fmt.Printf("Worker %d : Shutting Down\n", worker)
			return
		}


		// 显示我们开始工作了
		fmt.Printf("Worker: %d : Started %s\n", worker, task)

		// 随机等一段时间来模拟工作
		sleep := rand.Int63n(100)
		time.Sleep(time.Duration(sleep) * time.Millisecond)

		// 显示我们完成工作了
		fmt.Printf("Worker: %d : Completed %s\n", worker, task)

	}

}

执行后随机得到以下输出:

Worker: 1 : Started Task: 2
Worker: 3 : Started Task: 1
Worker: 4 : Started Task: 3
Worker: 2 : Started Task: 4
Worker: 4 : Completed Task: 3
Worker: 4 : Started Task: 5
Worker: 3 : Completed Task: 1
Worker: 3 : Started Task: 6
Worker: 4 : Completed Task: 5
Worker: 4 : Started Task: 7
Worker: 1 : Completed Task: 2
Worker: 1 : Started Task: 8
Worker: 2 : Completed Task: 4
Worker: 2 : Started Task: 9
Worker: 2 : Completed Task: 9
Worker: 2 : Started Task: 10
Worker: 3 : Completed Task: 6
Worker 3 : Shutting Down
Worker: 4 : Completed Task: 7
Worker 4 : Shutting Down
Worker: 2 : Completed Task: 10
Worker 2 : Shutting Down
Worker: 1 : Completed Task: 8
Worker 1 : Shutting Down

由于程序和 Go 语言的调度器带有随机成分,这个程序每次执行得到的输出会不一样。不过,通过有缓冲的通道,使用所有 4 个 goroutine 来完成工作,这个流程不变。从输出可以看到每个 goroutine 是如何接收从通道里分发的工作。