前言
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 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
来判断通道是否已关闭。如果 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
之间的通信。有点难理解,多悟吧。