介绍
Go 语言最吸引人的特性之一是其对并发编程的原生支持。它对并行性(parallelism)、并发性(concurrent programming)和网络通信有强大的支持,能够更高效地利用多核处理器。
与其他通过共享内存实现并发的语言不同,Go 通过使用通道(channels)来实现不同 goroutine 之间的通信,从而实现并发。
Goroutine 是 Go 中的并发单元,可以被视为轻量级线程。与传统线程相比,它们在切换时消耗的资源更少。
通道和 goroutine 共同构成了 Go 中的并发原语(concurrency primitives)。在本节中,我们将学习通道(channels)这种新的数据结构。
知识点:
- 通道的类型
- 创建通道
- 操作通道
- 通道阻塞
- 单向通道
通道概述
通道(Channels)是一种特殊的数据结构,主要用于 goroutine 之间的通信。
与其他使用互斥锁(mutexes)和原子函数(atomic functions)来安全访问资源的并发模型不同,通道允许通过在多个 goroutine 之间发送和接收数据来进行同步。
在 Go 中,通道就像一个遵循先进先出(FIFO)规则的传送带或队列。
通道类型与声明
通道是一种引用类型(reference type),其声明格式如下:
var channelName chan elementType
每个通道都有一个特定的类型,并且只能传输相同类型的数据。我们来看一些示例:
在 ~/project 目录下创建一个名为 channel.go 的文件:
cd ~/project
touch channel.go
例如,我们声明一个 int 类型的通道:
var ch chan int
上面的代码声明了一个名为 ch 的 int 通道。
除此之外,还有许多其他常用的通道类型。
在 channel.go 中,写入以下代码:
package main
import "fmt"
func main() {
var ch1 chan int // 声明一个整数通道
var ch2 chan bool // 声明一个布尔通道
var ch3 chan []int // 声明一个传输整数切片(slice)的通道
fmt.Println(ch1, ch2, ch3)
}
上面的代码只是声明了三种不同类型的通道。
使用以下命令运行程序:
go run channel.go
程序输出如下:
<nil> <nil> <nil>
通道初始化
与映射(maps)类似,通道在声明后需要进行初始化才能使用。
初始化通道的语法如下:
make(chan elementType)
在 channel.go 中,写入以下代码:
package main
import "fmt"
func main() {
// 存储整数数据的通道
ch1 := make(chan int)
// 存储布尔数据的通道
ch2 := make(chan bool)
// 存储 []int 数据的通道
ch3 := make(chan []int)
fmt.Println(ch1, ch2, ch3)
}
使用以下命令运行程序:
go run channel.go
程序输出如下所示:
0xc0000b4000 0xc0000b4040 0xc0000b4080
在上面的代码中,定义并初始化了三种不同类型的通道。
通过检查输出,我们可以看到,与映射不同,直接打印通道本身并不能显示其内容。
直接打印通道只会给出通道本身的内存地址。这是因为,像指针(pointer)一样,通道只是对内存地址的引用。
那么,我们如何访问和操作通道中的值呢?
通道操作
通道操作围绕着发送和接收数据,这使用 <- 运算符来完成。为了看到通道的实际作用,我们需要将它们与 goroutine(Go 的轻量级并发线程)一起使用。
一个通道连接着一个发送数据的 goroutine 和一个接收数据的 goroutine。如果其中一方没有准备好,另一方就会被阻塞(block)。我们来看一个完整的示例。
在 channel.go 中,写入以下代码:
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个无缓冲通道
ch := make(chan int)
// 使用匿名函数启动一个新的 goroutine
go func() {
fmt.Println("Goroutine starts sending...")
// 向通道发送值 10
ch <- 10
fmt.Println("Goroutine finished sending.")
}()
fmt.Println("Main is waiting for data...")
// 从通道接收值
value := <-ch
fmt.Printf("Main received: %d\n", value)
// 给 goroutine 一点时间打印其最后的消息
time.Sleep(time.Second)
}
使用以下命令运行程序:
go run channel.go
程序输出将类似于此(顺序可能略有不同):
Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.
在这个示例中:
ch := make(chan int)创建了一个无缓冲通道(unbuffered channel)。无缓冲通道要求发送方和接收方同时准备好才能进行通信。这被称为同步(synchronization)。go func() { ... }()启动一个新的 goroutine。go关键字使该函数与main函数并发执行。- 在 goroutine 内部,
ch <- 10向通道发送值10。此操作会阻塞,直到maingoroutine 准备好接收。 - 在
main函数中,value := <-ch接收该值。此操作会阻塞,直到 goroutine 发送一个值。 - 一旦双方都准备好,值就会被传输,两个 goroutine 都会解除阻塞并继续执行。我们添加了
time.Sleep以确保程序在 goroutine 打印其最后一条消息之前不会退出。
关闭通道
当不再有值要发送到通道时,发送方可以使用 close() 函数将其关闭。读取通道中所有值直到它被关闭的常见方法是使用 for range 循环。
我们来修改 channel.go:
package main
import "fmt"
func main() {
// 一个可以容纳多个值的缓冲通道
ch := make(chan int, 3)
// 启动一个 goroutine 来发送数据
go func() {
fmt.Println("Goroutine sending 10, 20, 30")
ch <- 10
ch <- 20
ch <- 30
// 发送完所有值后关闭通道
close(ch)
fmt.Println("Goroutine closed the channel.")
}()
fmt.Println("Main is receiving data...")
// 使用 for-range 循环接收值,直到通道关闭
for value := range ch {
fmt.Printf("Main received: %d\n", value)
}
fmt.Println("Main finished receiving.")
}
使用以下命令运行程序:
go run channel.go
程序输出如下(顺序可能略有不同):
Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.
这展示了一个常见的模式:for range 循环会自动处理检查通道是否已关闭,并在通道关闭时退出循环。
在使用已关闭的通道时,请记住以下关键规则:
- 向已关闭的通道发送数据将导致运行时错误(panic)。
- 从已关闭但为空的通道接收数据将立即返回通道类型的零值(zero value)。
- 关闭一个已经关闭的通道将导致 panic。
- 通常情况下,只有发送方应该关闭通道,接收方绝不应该关闭。
通道阻塞
正如我们在上一步中看到的,通道操作可能会阻塞。这是允许 goroutine 进行同步的一个关键特性。让我们更仔细地研究这一点。
无缓冲通道
使用 make(chan T) 创建的通道是无缓冲的(unbuffered)。
ch1 := make(chan int) // 无缓冲通道
无缓冲通道没有容量。对无缓冲通道的发送操作会阻塞发送的 goroutine,直到另一个 goroutine 准备好接收。反之,接收操作会阻塞接收的 goroutine,直到另一个 goroutine 准备好发送。这创建了一个强同步点,正如我们在上一步的第一个示例中看到的那样。
缓冲通道
通道也可以是缓冲的(buffered),这意味着它们具有在阻塞之前容纳一定数量值的容量。
ch2 := make(chan int, 5) // 容量为 5 的缓冲通道
对于缓冲通道:
- 仅当缓冲区满时,发送操作才会阻塞。
- 仅当缓冲区为空时,接收操作才会阻塞。
如果缓冲区未满,发送方可以在接收方尚未准备好时发送一个值。该值将被存储在通道的队列中。
死锁示例
如果超出通道的容量会发生什么?程序将导致死锁(deadlock)。当程序中所有的 goroutine 都被阻塞,等待永远不会发生的事情时,就会发生死锁。
创建一个名为 channel1.go 的文件:
cd ~/project
touch channel1.go
写入以下代码,该代码尝试在主 goroutine 中向容量为 1 的通道发送两个值:
package main
import "fmt"
func main() {
ch := make(chan int, 1)
ch <- 10 // This succeeds
fmt.Println("Sent 10 to channel")
ch <- 20 // This blocks forever because the channel is full
fmt.Println("succeed")
}
使用以下命令运行程序:
go run channel1.go
程序输出如下:
Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!
程序因死锁错误而 panic。main goroutine 发送 10,填满了缓冲区。然后它尝试发送 20,但由于缓冲区已满而阻塞。由于没有其他 goroutine 会从通道接收,main goroutine 将永远阻塞,导致 Go 运行时检测到死锁。
要修复此问题,必须与另一个可以接收该值并为缓冲区腾出空间的 goroutine 协调操作。
单向通道
默认情况下,通道是双向的,可以进行读取或写入。有时,我们需要限制通道的使用,例如,我们想确保在一个函数中通道只能被写入或只能被读取。我们如何实现这一点呢?
我们可以在初始化通道之前,显式声明一个使用受限的通道。
只写通道
只写通道(Write-only channel)意味着在特定函数中,通道只能被写入而不能被读取。让我们声明并使用一个类型为 int 的只写通道:
package main
import "fmt"
func writeOnly(ch chan<- int) {
ch <- 10
fmt.Println("Data written to channel")
}
func main() {
ch := make(chan int, 1)
writeOnly(ch)
fmt.Println("Data received:", <-ch)
}
使用以下命令运行程序:
go run channel.go
程序输出如下:
Data written to channel
Data received: 10
在这个示例中,writeOnly 函数将通道限制为只能写入。
只读通道
只读通道(Read-only channel)意味着在特定函数中,通道只能被读取而不能被写入。让我们声明并使用一个类型为 int 的只读通道:
package main
import "fmt"
func readOnly(ch <-chan int) {
fmt.Println("Data received from channel:", <-ch)
}
func main() {
ch := make(chan int, 1)
ch <- 20
readOnly(ch)
}
使用以下命令运行程序:
go run channel.go
程序输出如下:
Data received from channel: 20
在这个示例中,readOnly 函数将通道限制为只能读取。
组合只写和只读通道
你可以组合只写和只读通道,以演示数据如何通过受限通道流动:
package main
import "fmt"
func writeOnly(ch chan<- int) {
ch <- 30
fmt.Println("Data written to channel")
}
func readOnly(ch <-chan int) {
fmt.Println("Data received from channel:", <-ch)
}
func main() {
ch := make(chan int, 1)
writeOnly(ch)
readOnly(ch)
}
使用以下命令运行程序:
go run channel.go
程序输出如下:
Data written to channel
Data received from channel: 30
这个示例说明了只写通道如何将数据传递给只读通道。
总结
在这个实验中,我们学习了通道的基础知识,包括:
- 通道的类型以及如何声明它们
- 初始化通道的方法
- 对通道的操作
- 通道阻塞的概念
- 单向通道的声明



