云原生探索系列(十五):Go 语言通道

前言

Go 语言提供了非常强大的并发机制,其中 通道(Channel) 是核心特性之一。 通道用于不同 goroutine 之间的通信,可以安全地传递数据,实现同步和异步操作。 这篇文章我们深入探讨 Go 语言中的通道。

1、通道基础

1.1 通道的定义和创建

通过关键字 chan 来声明一个通道类型,还需要用到 Go 语言的内建函数 make

go 复制代码
var ch chan int  // 声明一个传递整数类型数据的通道
ch = make(chan int)  // 创建一个通道

或者可以通过简短声明方式创建:

go 复制代码
ch := make(chan int)  // 创建一个传递整数类型数据的通道

这会创建一个无缓冲的通道。无缓冲通道意味着发送操作会阻塞直到另一个 goroutine 接收了该数据。

1.2 通道的类型

Go 语言的通道是强类型的。例如, chan int 只允许发送和接收整数类型的数据。

1.3 通道的操作

通道主要有两种操作:发送和接收。

  • 发送数据到通道:

    r 复制代码
    ch <- value

    这表示将 value 发送到通道 ch 。如果通道没有空间或接收方没有准备好接收数据,则发送操作会阻塞。

  • 从通道接收数据:

    go 复制代码
    value := <-ch

    这表示从通道 ch 接收数据。如果通道没有数据可用,接收操作会阻塞,直到数据可用为止。

1.4 使用示例

go 复制代码
func main() {
	ch1 := make(chan int, 3)
	ch1 <- 2
	ch1 <- 1
	ch1 <- 3
	elem1 := <-ch1
	fmt.Printf("elem1: %v\n", elem1)  // elem1: 2
}

这段代码,定义的通道的容量为3,连续向该通道发送三个值,此时这三个值都会被缓存在通道之中。当需要从通道接收元素值的时候, 使用接送操作符 <- ,将最先进入 ch1 的元素接收并存到变量 elem1 中。

1.5 对通道的发送和接收操作有哪些基本特性

1.5.1 对于同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的

1、发送操作互斥:

如果多个 goroutine 同时尝试向同一个通道发送数据, Go 会确保每次只有一个 goroutine 能成功发送数据。即使发送操作在逻辑上是并发的, Go 会在底层通过互斥机制来调度它们,确保发送数据的顺序不会发生冲突。 2、接收操作互斥: 类似地,如果多个 goroutine 同时尝试从同一个通道接收数据, Go 会确保每次只有一个 goroutine 能成功接收数据。接收操作之间也会互斥,保证不会有两个 goroutine 同时读取同一个数据。

看个案例:

go 复制代码
func main() {
	// 创建一个缓冲区大小为1的通道
	ch := make(chan int, 1)

	// 启动两个 goroutine 模拟发送操作
	go func() {
		ch <- 1 // 发送操作
		fmt.Println("Sent 1")
	}()

	go func() {
		ch <- 2 // 发送操作
		fmt.Println("Sent 2")
	}()

	// 休眠一会,确保发送操作都被执行
	time.Sleep(1 * time.Second)

	// 启动两个 goroutine 模拟接收操作
	go func() {
		val := <-ch // 接收操作
		fmt.Printf("Received %d\n", val)
	}()

	go func() {
		val := <-ch // 接收操作
		fmt.Printf("Received %d\n", val)
	}()

	// 休眠等待所有 goroutine 完成
	time.Sleep(2 * time.Second)
}

这段代码,执行结果如下:

复制代码
Sent 1
Received 1
Sent 2
Received 2

你的执行顺序可能不是这样的,由于并发执行中调度顺序的不确定性。 Go 的调度器在不同的 goroutine 之间切换,可能导致输出顺序和代码顺序不同.

代码解析:

  • 发送操作的互斥性:

    由于通道 ch 的缓冲区大小为1,因此在同一时刻,只能有一个发送操作向通道发送数据。如果第一个 goroutine 在通道已满(缓冲区已存放一个值)时发送数据,第二个 goroutine 会被阻塞,直到第一个 goroutine 完成发送,通道的缓冲区有空余位置,第二个操作才会继续执行。 这个互斥行为是由于 Go 的通道内部机制保证的,它会确保同一时刻只有一个 goroutine 能向通道发送数据。

  • 接收操作的互斥性: 接收操作同样是互斥的。尽管我们启动了两个接收操作的 goroutine ,但通道中的数据只能被一个接收操作消费。第一个接收操作会先获取通道中的数据,第二个接收操作只能在第一个操作完成之后才能执行。 这确保了从通道中接收的数据是按顺序进行的,不会发生数据错乱或同时被多个接收者消费的情况。

1.5.2 发送操作和接收操作中对元素值的处理是不可分割的

当一个 goroutine 通过通道发送数据时,另一个 goroutine 必须立即接收数据,才能继续执行。这就保证了数据的传递是原子的,数据不会丢失。

示例:

go 复制代码
func sender(ch chan int) {
	fmt.Println("Sending 1")
	ch <- 1 // 发送数据到通道
	fmt.Println("Sent 1")
}

func receiver(ch chan int) {
	time.Sleep(2 * time.Second) // 确保接收操作稍微晚一些
	val := <-ch                 // 从通道接收数据
	fmt.Printf("Received: %d\n", val)
}

func main() {
	ch := make(chan int)
	go sender(ch)
	go receiver(ch)
	// 等待所有操作完成
	time.Sleep(3 * time.Second)
}

这段代码中, sender 函数向通道 ch 发送 1, receiver 函数从通道接收数据。 由于通道的同步特性,发送和接收操作是不可分割的,意味着 receiver 必须等到 sender 完成发送操作后才能接收数据。
Sending 1 会先打印出来,表示数据正在发送。 然后, sender 会把数据 1 发送到通道,但此时通道没有接收者,因此它会阻塞直到有接收者。 receiver 会等待 2 秒,然后从通道中接收数据,并打印 Received: 1。 这种同步机制保证了数据的原子性传递,即数据发送和接收是紧密关联的,不会出现丢失、重复或乱序的情况。

执行结果如下:

makefile 复制代码
Sending 1
Received: 1
Sent 1

1.5.3 发送操作和接收操作都会被阻塞

针对有缓冲通道,如果通道已满,所有发送操作都会被阻塞。如果通道已空,所有接收操作都会被阻塞。

示例:

go 复制代码
func main() {
	ch1 := make(chan int, 1)
	ch1 <- 1
	//ch1 <- 2 // 通道已满,因此这里会造成阻塞。

	elem1, ok1 := <-ch1
	_, _ = elem1, ok1
	elem2, ok2 := <-ch1 // 通道已空,因此这里会造成阻塞。
	_, _ = elem2, ok2
}

针对非缓冲通道,无论发送还是接收,一开始执行就会被阻塞

go 复制代码
func main() {
	var ch3 chan int
	//ch3 <- 1  // 通道的值为nil,因此这里会造成永久的阻塞!
	//<-ch3  // 通道的值为nil,因此这里会造成永久的阻塞!
	_ = ch3
}

2、无缓冲通道与缓冲通道

2.1 无缓冲通道

无缓冲通道是最简单的通道形式,其特点是发送方和接收方必须同步。也就是说,发送操作会在接收方准备好接收数据之前阻塞,反之亦然。

go 复制代码
ch := make(chan int)  // 创建一个无缓冲通道

工作原理:

  • 发送数据的 goroutine 会在数据被另一个 goroutine 接收之前被阻塞。
  • 接收数据的 goroutine 会在通道有数据时才能执行。

无缓冲通道适用于需要严格同步的场景,例如让 goroutine 按顺序完成任务。

2.2 有缓冲通道

有缓冲通道允许在没有接收方的情况下先存储一定数量的数据。发送方不会立即阻塞,除非缓冲区满。

go 复制代码
ch := make(chan int, 3)  // 创建一个容量为 3 的缓冲通道

工作原理:

  • 如果缓冲区未满,发送操作会立即返回,数据会被存入缓冲区。
  • 如果缓冲区已满,发送操作会阻塞,直到有空间。
  • 接收操作与无缓冲通道相同,阻塞直到有数据。

缓冲通道适用于数据流的场景,例如处理任务队列,或异步处理任务。

3、通道的关闭

通道可以通过 close 函数关闭,表示没有更多的值会发送到该通道。关闭后的通道仍然可以被接收数据,但不能再发送数据。

3.1 如何检查通道是否关闭

在接收数据时,如果通道已经关闭,接收操作会返回数据的零值,并且第二个返回值 ok 会是 false

示例:

go 复制代码
func sender(ch chan int) {
	// 发送一些数据
	for i := 0; i < 5; i++ {
		fmt.Println("Sender:", i)
		ch <- i
	}
	fmt.Println("Sender: close the channel...")
	close(ch)
}

func receiver(ch chan int) {
	for {
		v, ok := <-ch
		if !ok {
			// 通道已关闭且数据已接收完毕
			fmt.Println("Channel closed!")
			break
		}
		// 如果 ok 为 true,说明数据有效
		fmt.Println("Received:", v)
	}
}

func main() {
	ch := make(chan int) // 创建通道
	go sender(ch)        // 启动发送者 goroutine
	receiver(ch)         // 启动接收者
}

运行这段代码后,输出如下内容:

makefile 复制代码
Sender: 0
Sender: 1
Received: 0
Received: 1
Sender: 2
Sender: 3
Received: 2
Received: 3
Sender: 4
Sender: close the channel...
Received: 4
Channel closed!

这段代码,在 receiver 函数中,我们通过 v, ok := <-ch 来判断通道是否已关闭。如果 okfalse ,表示通道已关闭且所有数据都已被接收。 一旦通道关闭, ok 的值变为 false ,接收者就可以知道通道已经关闭并退出循环。

3.2 通道关闭的错误

关闭通道后仍然尝试向通道发送数据会引发运行时错误。关闭通道应该由发送方来做,并且确保发送完所有数据后再关闭。

4、单向通道与双向通道

前面提及的都是双向通道,既可以发也可以收。而单向通道顾名思义就是只能发不能收或者只能收不能发。

一个通道是单向还是双向,由它的类型字面量来决定。

只能发不能收,示例:

go 复制代码
ch := make(chan<- int, 1)

只能收不能发,示例:

go 复制代码
ch := make(<-chan int, 1)

4.1 单向通道使用场景

通常用于约束其他代码的行为。

示例:

go 复制代码
func prod(ch chan<- int) {
	for {
		ch <- 1
	}
}
func consume(ch <-chan int) {
	for {
		<-ch
	}
}
func main() {
	var c = make(chan int)
	go prod(c)
	go consume(c)
}

这段代码,包含了一个生产者(prod )和消费者(consume )的模型,通过一个通道(ch )来进行通信。

  • prod 函数的参数是一个只能发送数据的通道 ch chan<- int 。这是一个单向通道,它只能用于将数据发送到通道中,不能接收数据。
  • for { ch <- 1 } :这是一个无限循环,不断向通道中发送 1
  • 由于 prod 会在后台并发运行,它会一直将 1 发送到通道中,直到程序终止。
  • consume 函数的参数是一个只能接收数据的通道 ch <-chan int 。这是一个单向通道,它只能用于从通道中接收数据,不能发送数据。
  • for { <-ch } :这是一个无限循环,不断从通道中接收数据。每次从通道中接收到一个数据后,它什么也不做,直接丢弃它。
  • main 函数中,首先创建了一个类型为 chan int 的通道 c ,该通道可以同时进行数据的发送和接收。
  • go prod(c) 启动一个新的 goroutine 来执行 prod 函数,这个 goroutine 会一直向通道发送数据 1
  • go consume(c) 启动另一个新的 goroutine 来执行 consume 函数,这个 goroutine 会一直从通道中接收数据并丢弃。

5、 select 语句

select 语句是用于多路通道选择的控制结构,它允许我们同时等待多个通道的操作(发送或接收)。它的功能类似于 switch ,但与通道操作配合使用。通过 select ,我们可以在多个通道操作中选择一个可用的通道进行处理,确保程序在多个并发操作之间高效切换。

5.1 基本语法

go 复制代码
select {
case <-chan1:
    // 如果 chan1 中有数据,可以处理
case data := <-chan2:
    // 如果 chan2 中有数据,可以处理,并且可以使用接收到的数据
case chan3 <- value:
    // 如果可以向 chan3 发送数据,进行发送
default:
    // 如果以上通道都没有准备好,执行这个默认操作
}
  • 每个 case 表达式都必须是通道的操作 ,可以是接收(<-chan )或发送(chan<-)。
  • select 会阻塞,直到至少有一个通道操作完成 。如果没有通道操作可以执行(例如,没有数据可从通道接收或无法发送数据), select 会继续阻塞,直到有通道准备好。
  • default 是可选的 ,如果没有任何通道准备好, default 会被执行,防止阻塞。

5.2 使用示例

go 复制代码
func sendData(ch chan<- string, delay time.Duration, msg string) {
	time.Sleep(delay)
	ch <- msg
}

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	// 启动两个 goroutine,分别向 ch1 和 ch2 发送数据
	go sendData(ch1, 2*time.Second, "Data from ch1")
	go sendData(ch2, 1*time.Second, "Data from ch2")

	// 使用 select 语句等待通道的操作,设置 default 防止阻塞
	for i := 0; i < 6; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println("Received from ch1:", msg1)
		case msg2 := <-ch2:
			fmt.Println("Received from ch2:", msg2)
		default:
			// 如果两个通道都没有准备好,执行这个默认操作
			fmt.Println("No data ready, doing something else...")
			time.Sleep(500 * time.Millisecond)
		}
	}

	fmt.Println("Program finished.")
}

这段代码执行后输出如下:

arduino 复制代码
No data ready, doing something else...
No data ready, doing something else...
Received from ch2: Data from ch2
No data ready, doing something else...
No data ready, doing something else...
Received from ch1: Data from ch1
Program finished.

这段代码中, select 中包含了一个 default 分支。当没有通道准备好时,程序会打印 "No data ready, doing something else..." ,然后睡眠 500 毫秒后再尝试。 select 会根据哪个通道准备好来执行相应的 case 。如果通道没有数据,它会执行 default 分支,避免程序无限阻塞。

5.3 select 在超时和取消中的应用

select 语句通常用于处理超时和取消操作。通过与 time.Aftercontext 一起使用, select 可以帮助我们实现超时逻辑。

go 复制代码
func doWork(ch chan<- string) {
	time.Sleep(3 * time.Second)
	ch <- "Work finished!"
}

func main() {
	ch := make(chan string)

	go doWork(ch)

	select {
	case result := <-ch:
		fmt.Println(result)
	case <-time.After(2 * time.Second):
		fmt.Println("Timeout! The work took too long.")
	}
}

在这段代码中,启动了一个 doWork 函数,它会在 3 秒后通过通道发送数据, 在 main 函数中, select 同时等待来自 ch 的数据和一个 2 秒后的超时。 如果 doWork 在 2 秒内完成,程序会打印 "Work finished!"。否则,它会触发 time.After 的超时。

由于 doWork 函数在 3 秒后才通过通道发送数据,所以出发超时,输出结果如下:

arduino 复制代码
Timeout! The work took too long.

最后

Go 语言的通道是其并发编程模型的核心之一,它通过提供一种简单、安全的方式来实现 goroutine 之间的通信。有点难理解,多悟吧。

相关推荐
lgily-122513 分钟前
常用的设计模式详解
java·后端·python·设计模式
matrixlzp30 分钟前
Nginx 源码安装成服务
nginx·云原生
意倾城1 小时前
Spring Boot 配置文件敏感信息加密:Jasypt 实战
java·spring boot·后端
火皇4051 小时前
Spring Boot 使用 OSHI 实现系统运行状态监控接口
java·spring boot·后端
薯条不要番茄酱1 小时前
【SpringBoot】从零开始全面解析Spring MVC (一)
java·spring boot·后端
张青贤6 小时前
K8s中的containerPort与port、targetPort、nodePort的关系:
云原生·容器·kubernetes
懵逼的小黑子9 小时前
Django 项目的 models 目录中,__init__.py 文件的作用
后端·python·django
小林学习编程10 小时前
SpringBoot校园失物招领信息平台
java·spring boot·后端
java1234_小锋12 小时前
Spring Bean有哪几种配置方式?
java·后端·spring
zhojiew12 小时前
istio in action之服务网格和istio组件
云原生·istio