什么是通道?
Channel是Go中的一个核心类型,你可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication)。
它提供了一种同步的机制,确保在数据发送和接收之间的正确顺序和时机。通过使用channel,我们可以避免在多个goroutine之间共享数据时出现的竞争条件和其他并发问题。
通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
通道的分类
通道的类型主要分为以下几种:双向通道、只读通道、只写通道。
双向通道
go
var ch chan string = make(chan string) // 双向channel
通过如上定义,我们定义了一个双向通道,这个通道支持读、写操作。
只读通道
go
var readCh <-chan string = make(<-chan string) // 只读channel
通过如上定义,我们定义了一个只读通道,这个通道只支持读操作。如果需要往通道中写入数据的时候,就会报错。

只写通道
go
var writeCh chan<- string = make(chan<- string) // 只写channel
只写通道与只读通道类型,只支持写操作。如果往通道中读数据,就会提示错误

通道的基本操作
创建/初始化
如果我们想要创建一个channel,用来传递字符串类型的数据,可以这样定义:
go
ch := make(chan string)
可以看到,我们定义了一个可以传递字符串类型数据的channel。相似的,我们还可以创建其他不同类型的通道。
go
ch := make(chan int) // int 类型的通道
ch := make(chan MyStruct) // 自定义结构体的通道
type MyStruct struct {
ch chan int
}
但是需要注意的是,这样创建出来的通道,默认不携带缓冲区,意味着只有消费者把通道的数据消费完了,才可以往通道中存入下一个数据。
如果我们想要一个带缓冲区的通道,还可以可以这样创建:
go
ch := make(chan string, 10)
如上,我们创建了一个缓冲区为10的通道。只有当缓冲区中的数据都装满了,通道才会拒绝接受数据。
发送
发送操作用于将数据发送到channel中。发送操作的语法如下:
go
ch <- x
其中ch表示要发送数据的channel,x表示要发送的数据。例如,下面的代码将字符串"hello"发送到了ch这个channel中:
go
ch <- "hello"
注意,如果channel已经满了,发送操作会被阻塞,直到有其他协程从channel中取走了数据。这样可以保证在channel已经没有空间存放数据的情况下,发送操作不会丢失数据。
接收
接收操作用于从channel中取出数据。其语法如下:
go
x := <- ch
其中ch表示要接收数据的channel,x表示接收到的数据。例如,下面的代码从ch这个channel中取出了一个字符串:
go
x := <- ch
注意,如果channel中没有数据可供接收,接收操作会被阻塞,直到有其他协程向channel中发送了数据。这样可以保证在channel中没有数据可供接收的情况下,接收操作不会返回错误。
关闭
关闭操作用于关闭channel。关闭一个channel之后,就不能再向它发送数据了,但是仍然可以从它接收数据。关闭操作的语法如下:
go
close(ch)
其中ch表示要关闭的channel。例如,下面的代码关闭了ch这个channel:
go
close(ch)
注意,若一个channel已被关闭,再向它发送数据会导致panic错误。
⚠️ PS:对长期使用的 channel,遵循 "谁生产谁关闭" 原则,尽量避免消费者永久阻塞。
通道的状态
结合上述的操作和通道的数据状态,可以绘制出如下的图表:

总结来说,通道的状态变化主要如下:

为什么用通道?
了解了通道的基本操作和用法,那么为什么需要使用通道呢?哪些情况下又该使用通道呢?
通道的使用场景
生产者 - 消费者模型
场景:多个 Goroutine 生成数据(生产者),多个 Goroutine 处理数据(消费者)。
通道的作用:作为缓冲区,连接生产者和消费者,实现解耦和流量控制。
go
func producer(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}
func consumer(ch <-chan int) {
for val := range ch {
fmt.Println("处理数据:", val)
}
}
定时任务与超时控制
- 场景:需要在指定时间后执行任务,或防止某个操作无限期阻塞。
- 通道的作用 :结合
time.Timer
或time.Ticker
,使用通道实现定时触发或超时处理。
go
select {
case result := <-ch:
// 处理结果
case <-time.After(5 * time.Second):
// 超时处理
}
信号通知与优雅关闭
-
场景:需要通知多个 Goroutine 停止工作(如服务关闭时)。
-
通道的作用:使用专门的通道发送关闭信号,协调多个 Goroutine 的生命周期。
gostopCh := make(chan struct{}) // 工作协程 go func() { for { select { case <-stopCh: return // 收到停止信号后退出 default: // 执行工作 } } }() // 主函数中发送停止信号 close(stopCh)
控制并发数量(限流)
-
场景:限制同时执行的 Goroutine 数量,防止资源耗尽。
-
通道的作用:使用带缓冲的通道作为令牌桶,控制并发数。
gosemaphore := make(chan struct{}, 10) // 最多10个并发 for i := 0; i < 100; i++ { semaphore <- struct{}{} // 获取令牌 go func() { defer func() { <-semaphore }() // 释放令牌 // 执行受限操作 }() }
多个结果的聚合
-
场景:并行执行多个任务,等待所有任务完成后汇总结果。
-
通道的作用 :收集每个任务的结果,使用
sync.WaitGroup
或select
等待所有结果。goresults := make(chan int, 5) for i := 0; i < 5; i++ { go func(id int) { results <- id * id }(i) } // 收集所有结果 for i := 0; i < 5; i++ { fmt.Println(<-results) }
在实际应用中,多个结果的聚合是用的最多的,比如要加载100个商品等信息,就可以拆分成5个线程,每个线程同时去加载商品库存、名称等数据,此时让子线程去异步加载,主线程则逐一消费每个子线程的数据。从而达到加快处理速度的目的,具体示例如下:
go
func main() {
totalProducts := 100 // 总商品数量
numWorkers := 5 // 工作线程数量
// 创建商品通道和等待组
productCh := make(chan Product, totalProducts)
var wg sync.WaitGroup
// 启动生产者协程(工作线程)
wg.Add(numWorkers)
for workerID := 0; workerID < numWorkers; workerID++ {
go func(id int) {
defer wg.Done()
// 计算每个工作线程负责的商品范围
start := (id * totalProducts) / numWorkers
end := ((id + 1) * totalProducts) / numWorkers
fmt.Printf("工作线程%d: 加载商品%d-%d\n", id, start, end-1)
// 生产商品数据
for i := start; i < end; i++ {
product := loadProduct(i)
productCh <- product
fmt.Printf("工作线程%d: 已加载商品%d\n", id, i)
}
}(workerID)
}
// 启动一个协程,在所有生产者完成后关闭通道
go func() {
wg.Wait()
close(productCh)
fmt.Println("所有商品加载完成,通道已关闭")
}()
// 主线程作为消费者处理商品数据
processedCount := 0
for product := range productCh {
// 模拟处理商品数据
fmt.Printf("主线程: 处理商品 %d - %s (库存: %d, 价格: %.2f, 分类: %s)\n", product.ID, product.Name, product.Stock, product.Price, product.Category)
// 模拟处理耗时
processedCount++
}
fmt.Printf("主线程: 共处理 %d 个商品\n", processedCount)
}
如何优雅使用通道?
上一章节介绍了通道的常见使用场景,那么,在使用通道的过程中,又该如何优雅使用呢?
如何安全关闭通道
如果需要优雅使用通道,那么学会优雅的关闭通道是必不可少的。因为关闭通道不谨慎处理的话,可能导致程序崩溃或产生不可预期的行为。
安全关闭通道(Channel)的核心原则是:由发送方负责关闭,且只关闭一次。以下是针对不同场景的安全关闭方法和最佳实践:
单发送方:
发送方自然结束后关闭,当发送方明确知道自己何时完成所有发送任务时,直接在发送结束后关闭通道。
go
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch) // 单发送方安全关闭
}()
使用 defer 确保关闭 ,若发送方可能因异常提前退出,使用defer
确保通道一定会被关闭。
go
ch := make(chan int)
go func() {
defer close(ch) // 无论函数如何退出,通道都会被关闭
for i := 0; i < 10; i++ {
if err := someOperation(); err != nil {
return // 提前退出,但defer会执行close(ch)
}
ch <- i
}
}()
多发送方:
创建一个专门的通道,由主协程控制何时停止所有发送方,并在确认所有发送方停止后关闭数据通道。
go
ch := make(chan int)
done := make(chan struct{}) // 通知停止的信号
// 多个发送方
for i := 0; i < 3; i++ {
go func(id int) {
for {
select {
case <-done:
return
case ch <- id:
// 发送数据
}
}
}(i)
}
// 主函数决定何时停止并关闭通道
go func() {
time.Sleep(time.Second)
close(done) // 通知所有发送方停止
close(ch) // 所有发送方停止后,主函数关闭通道
}()
这样的写法在定时任务、异步任务也是常用,这样能使得线程中的任务被优雅的停机关闭,这样下次再执行任务时,可以根据上次停止的位置重新执行。
使用 sync.Once 确保只关闭一次
当无法确定哪个发送方最后完成时,使用sync.Once
保证通道只被关闭一次。
go
var once sync.Once
dataCh := make(chan int)
// 多个发送方共享关闭逻辑
closeDataCh := func() {
once.Do(func() {
close(dataCh)
fmt.Println("数据通道已关闭")
})
}
// 启动多个发送方
for i := 0; i < 3; i++ {
go func(id int) {
defer closeDataCh() // 每个发送方都尝试关闭,但实际只执行一次
for j := 0; j < 5; j++ {
dataCh <- id*10 + j
time.Sleep(time.Millisecond * 100)
}
}(i)
}
// 接收方处理数据
for data := range dataCh {
fmt.Println("接收:", data)
}
总结
本文介绍了常见的通道分类,分为双向、只读和只写;
紧接着又介绍了通道的基本操作:创建、发送、接收和关闭,并结合通道的存储状态和基本操作,推演出了通道的基本流程走向。
最后本文介绍了通道的常见使用场景,并给出了具体示例。最后再结合通道的使用场景,给出了优雅使用通道的方式。
希望通过本文的讲解,能帮助大家更好的使用通道。