前言
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 通道的操作
通道主要有两种操作:发送和接收。
- 
发送数据到通道: rch <- value这表示将 value发送到通道ch。如果通道没有空间或接收方没有准备好接收数据,则发送操作会阻塞。
- 
从通道接收数据: govalue := <-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 11.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 来判断通道是否已关闭。如果 ok 为 false ,表示通道已关闭且所有数据都已被接收。 一旦通道关闭, 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.After 或 context 一起使用, 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 之间的通信。有点难理解,多悟吧。