channel
文章目录
- channel
-
- 简介
- 基本概念
- 单向通道
- [配合 `for` 语句与 `select` 语句](#配合
for
语句与select
语句) -
- 核心思想
- [`for range` (最常用、最推荐)](#
for range
(最常用、最推荐)) - 显式检查 (较少用)
- 核心应用模式
-
- [模式 1:多路复用 (Multiplexing)](#模式 1:多路复用 (Multiplexing))
- [模式 2:超时控制 (Timeout)](#模式 2:超时控制 (Timeout))
- [模式 3:非阻塞操作 (Non-blocking)](#模式 3:非阻塞操作 (Non-blocking))
- [For 配合 Select 一起使用](#For 配合 Select 一起使用)
-
- 经典结构:带退出机制的永久循环
- [更现代的结构:使用 `context.Context`](#更现代的结构:使用
context.Context
)
- 总结与最佳实践表格
- 黄金法则
- [配合 `time` 包使用](#配合
time
包使用)
简介
这里会详细介绍 Go 的并发编程理念:
以通信作为手段来共享内存, 而不是通过共享内存来通信
这一理念的最直接最重要的体现也就是 channel
.
Go 鼓励用与众不同的方法来共享值, 这个方法就是用一个通道(信道)类型在不同 goroutine 之间传递值. Go 的 channel
就像是一个类型安全的通用型管道.
channel 提供一种机制, 使得即可以同步两个并发执行函数, 又可以让两个函数通过相互传递特定类型值来通信. 也就是说提供了两个功能:
- 并发 goroutine 同步
- 并发 goroutine 通信
当然有些场景下, 使用共享变量和传统同步方法更加方便, 但是作为高级用法, 使用 channel
可以让我们编写更加清晰正确的程序.
基本概念
Go 中, channel
既指通道类型, 也指代可以传递某种类型的值的通道.
通道即某一个通道类型的值, 是该类型的一个实例.
类型表示法
通道是一个引用类型, 这和切片以及字典这两个类型是一致的. 一个泛化的通道类型声明应当如此:
go
chan T
其中,
- 关键字
chan
是代表通道类型的关键字, T
是表示了该通道类型的元素类型. 限制了可以经由此类通道传递的元素值的类型.
可以声明这样一个别名类型:
go
type IntChan chan int
该别名类型代表了元素类型为 int
的通道类型.
又比如可以直接声明一个 chan int
类型的变量:
go
var intChan chan int
初始化之后, intChan
变量就可用来传递 int
类型的值.
以上就是最简单的通道类型生命方式, 这样声明的通道类型是双向的. 也就是既可以向它发送值, 也可从他接收值.
此外还可声明单向的通道类型, 需要用到接收操作符 <-
, 下面就是一个只能用于发送值的通道类型的泛化表示:
go
chan<- T
只能向此类通道发送值而不能从其中接收值. 接受操作符 <-
生动表示了元素值的流向. 可以把这样的单项通道类型简称为发送通道类型. 同样的也可以声明只能从中接收值的通道类型:
go
<-chan T
这类单向通道类型可以被简称为: 接收通道类型.
值表示法
因为 channel 是引用类型, 所以通道类型的变量在被初始化之前, 其值一定是 nil
.
注意: 通道的语义决定了他和其他类型不同, 通道类型的变量一定是用来传递值的, 而不是用来存储值的, 所以通道类型没有对应的值表示法. 其值有即时性, 无法用字面量来准确表达.
操作的特性
通道是在多个 goroutine
间传递数据和同步的重要手段, 对于通道的操作, 其本身也是同步的.
在同一时刻, 只能有一个 goroutine 向一个通道发送值, 同时也只能有一个 goroutine 从它那接受值.
通道相当于一个 FIFO 的消息队列, 其中的各个值都是严格按照发送到其中的先后顺序排列, 最早被发送到通道的值会最先被接收.
通道中的值都有原子性, 不可以被分割.
通道中的每一个值都只能被某一个 goroutine 接收, 已经被接受的值会立刻从通道中删除.
初始化通道
所有引用类型的值都要使用 make
内建函数初始化, channel
也是这样的:
go
make(chan int, 10)
将初始化一个最多能缓冲 10 个 int
类型值的 channel (这是一个带有缓冲区的 channel), 一个带有缓冲的 channel, 其缓冲容量总是固定不变的.
此处也可以省略第二个参数, 此时创建的就是一个无缓冲区的 channel:
go
make(chan int)
发送给该 channel 的值应当被立刻取走, 否则发送方的 goroutine 将会阻塞, 直到有接收方接受了该值.
接收元素值
接收运算符 <- 既可以用来作为通道类型声明的一部分, 也可用于通道操作(发送或者接收元素值).
假设有这样的一个通道类型的变量:
go
strChan := make(chan string, 3)
make
函数调用后, 返回一个已经被初始化的通道值作为结果.
因此该赋值语句时的变量 strChan
成为一个双向通道, 该通道的元素类型为 string
, 容量为 3.
如果要从中接收元素值, 那么这样写:
go
elem := <-strChan
语义很简单: 将 strChan
中的一个值赋值给变量 elem
.
该操作将使当前 goroutine 被迫进入 Gwaiting 状态, 直到 strChan
之中有新的值可取时才会被唤醒.
也可以用以下的双返回值:
go
elem, ok := <-strChan
这里同样是一个阻塞行为.
如果在进行接收操作之前或者过程中该通道被关闭了, 那么该操作将会立即结束, 变量 elem
会被赋予该通道的元素类型的零值(0, nil等). 这对应着一个特殊情况, 如果我们接收到的值本身就是零值而不是由于关闭通道而产生的异常零值要怎么办, 此时 ok
就有作用了.
ok
是一个 bool
类型变量, 当接收操作因通道关闭而结束时, 该值为 false
, 否则就是 true
.
可以把在符号 =
或者 :=
右侧出现的, 仅能是接收表达式的赋值语句称为接收语句.
在其中的接收操作符 <-
右边的不仅仅可以是代表通道的标识符, 也可是任意的表达式.
只要该表达式的结果类型是一个通道类型即可, 将这样的表达式称作通道表达式.
最后要注意: 尝试从一个未被初始化的通道值(也就是一个值为 nil
的通道)接收值, 会造成当前 goroutine 的永久阻塞.
Happens before
为了能够从通道接收元素值,我们先向它发送元素值.
理所当然,一个元素值在被接收方从通道中取出之前,必须先存在于该通道内.
更加正式地讲,对于一个缓冲通道,有如下规则:
-
发送操作会使通道复制被发送的元素.
如果因通道的缓冲空间已满而无法立即复制,则阻塞进行发送操作的 goroutine.
复制的目的地址有两种:
- 当通道已空且有接收方在等待元素值时,它会是最早等待的那个接收方持有的内存地址(channel就像是中转了一下)
- 否则是通道持有的缓冲中的内存地址。
-
接收操作会使通道给出一个已发给它的元素值的副本,若因通道的缓冲空间已空而无法立即给出,则阻塞进行接收操作的goroutine。一般情况下, 接收方会从通道持有的缓冲中得到元素值。
-
对于同一个元素值来说,把它 发送给某个通道 的操作,总是会在 从该通道接收它 这一操作之前完成。
换句话说,在通道完全复制一个元素值之前,任何 goroutine 都不可能从它那里接收到这个元素值的副本。
发送值
发送语句由三要素组成:
- 通道表达式
- 接收操作符
<-
- 代表元素值的表达式(以下简称为元素表达式)
其中, 元素表达式 的结果类型一定要和 通道表达式 的结果类型中的元素类型之间存在可赋值关系. 也就是, 前者的值一定是可以赋给类型为后者的变量.
对于接收表达式 <-
两边的表达式的求值总是先于发送操作执行, 对两个表达式的求值完成前, 发送操作一定会被阻塞.
比如想要向通道 strChan
发送一个值 "a"
, 要这样做:
先初始化一个通道,
go
strChan := make(chan string, 3)
然后:
go
strChan <- "a"
<-
左侧是将要接纳元素值的通道, 右边则是想要发送给该通道的值.
此表达式被求值后, 通道 strChan
就缓冲了值 "a", 然后再往里边发两个值:
go
strChan <- "b"
strChan <- "c"
现在 通道 strChan
缓冲了3个元素值, 达到了最大容量. 此后某个 goroutine 再向其中发送元素值时, 该 goroutine 就会被阻塞, 只有从该通道中接受一个元素值后, 这个 goroutine 才会被唤醒并且完成发送操作.
例1
看一段代码:
go
package main
import (
"fmt"
"time"
)
var strChan = make(chan string, 3)
func main() {
syncChan1 := make(chan struct{}, 1)
syncChan2 := make(chan struct{}, 2)
// 用于演示接收操作
go func() {
<-syncChan1 // 等待同步信号(阻塞直到收到 "c")
fmt.Println("Received a sync signal and wait a second ... [receiver]")
time.Sleep(time.Second) // 故意等待 1 秒
// 循环接收数据直到通道关闭
for {
if elem, ok := <-strChan; ok {
fmt.Println("Received:", elem, "[receiver]")
} else {
break // 通道关闭时退出
}
}
fmt.Println("Stopped. [receiver]")
syncChan2 <- struct{}{} // 发送完成信号
}()
// 用于演示发送操作
go func() {
// 发送数据 "a", "b", "c", "d"
for _, elem := range []string{"a", "b", "c", "d"} {
strChan <- elem
fmt.Println("Sent:", elem, "[sender]")
// 关键同步点:发送 "c" 后触发接收者启动
if elem == "c" {
syncChan1 <- struct{}{} // 发送同步信号
fmt.Println("Sent a sync signal. [sender]")
}
}
// 完成发送后等待 2 秒
fmt.Println("Wait 2 seconds... [sender]")
time.Sleep(time.Second * 2)
close(strChan) // 关闭数据通道
syncChan2 <- struct{}{} // 发送完成信号
}()
<-syncChan2
<-syncChan2
}
这段 Go 代码演示 goroutine 间的同步与通信,使用了带缓冲的 channel 和同步信号 channel.
核心组件
strChan
:缓冲大小为 3 的字符串 channel,用于数据传输syncChan1
:缓冲大小为 1 的同步信号 channel(控制接收启动时机)syncChan2
:缓冲大小为 2 的同步信号 channel(等待两个 goroutine 结束)
关键执行顺序
-
初始发送阶段:
- 发送者快速发送 "a", "b", "c"(填满 3 缓冲)
- 发送 "c" 后触发
syncChan1
信号 - 发送 "d" 时阻塞(因缓冲已满)
-
接收启动阶段:
- 接收者收到
syncChan1
信号 - 等待 1 秒后开始消费数据
- 接收 "a" 后释放缓冲空间
- 接收者收到
-
完成阶段:
- 发送者解除阻塞,发送 "d"
- 发送者等待 2 秒后关闭
strChan
- 接收者消费剩余数据 ("b", "c", "d") 后退出
输出示例(可能顺序)
Sent: a [sender]
Sent: b [sender]
Sent: c [sender]
Sent a sync signal. [sender] // 发送者在此阻塞
Received a sync signal... [receiver]
// (1秒延迟)
Received: a [receiver] // 释放缓冲
Sent: d [sender] // 发送者解除阻塞
Wait 2 seconds... [sender] // 发送者开始等待
Received: b [receiver]
Received: c [receiver]
Received: d [receiver] // 接收者消费完毕
Stopped. [receiver] // 接收者退出
// (主 goroutine 收到两个完成信号后退出)
设计要点
- 缓冲控制:缓冲大小为 3 使得发送 "d" 时被阻塞
- 精确同步 :
syncChan1
确保接收者在特定时点启动(收到 "c" 后) - 关闭通道 :
close(strChan)
通知接收者数据结束 - 双信号确认 :
syncChan2
保证主 goroutine 等待所有任务完成
此代码演示了如何通过 channel 实现:
- 数据传输 (
strChan
) - 启动时机控制 (
syncChan1
) - 任务完成同步 (
syncChan2
) - 通道关闭通知机制
例2
对于通道的复制行为还需要再解释解释. 发送方 向 通道发送的值会被复制, 接收方接受的总是该值的副本而不是该值的本身,
这意味着对于一个值对象来说, 就是做了普通的一次拷贝而已,
但是对于引用类型来说(比如切片, 字典等), 就是复制了一份引用, 这也意味着修改了复制之后得到的引用就修改了收发两方持有的值.
go
package main
import (
"fmt"
"time"
)
var mapChan = make(chan map[string]int, 1)
func main() {
syncChan := make(chan struct{}, 2)
// 发
go func() {
countMap := make(map[string]int)
for i := 0; i < 5; i++ {
mapChan <- countMap
time.Sleep(time.Millisecond)
fmt.Printf("The count map: %v. [sender]\n", countMap)
}
close(mapChan)
syncChan <- struct{}{}
}()
// 收
go func() {
for {
if elem, ok := <-mapChan; ok {
elem["count"]++
} else {
break
}
}
fmt.Println("Stopped. [receiver]")
syncChan <- struct{}{}
}()
<-syncChan
<-syncChan
}
输出如下:
The count map: map[count:{count: 1}]. [sender]
The count map: map[count:{count: 2}]. [sender]
The count map: map[count:{count: 3}]. [sender]
The count map: map[count:{count: 4}]. [sender]
The count map: map[count:{count: 5}]. [sender]
Stopped. [receiver]
这里收的一方就对得到的值做了自增操作, 这里也能看到, 原值同样被更改了.
例3
go
package main
import (
"fmt"
"time"
)
type Counter struct {
count int
}
var mapChan = make(chan map[string]Counter, 1)
func main() {
syncChan := make(chan struct{}, 2)
// 发
go func() {
countMap := map[string]Counter{
"count": {},
}
for i := 0; i < 5; i++ {
mapChan <- countMap
time.Sleep(time.Millisecond)
fmt.Printf("The count map: %v. [sender]\n", countMap)
}
close(mapChan)
syncChan <- struct{}{}
}()
// 收
go func() {
for {
if elem, ok := <-mapChan; ok {
counter := elem["count"]
counter.count++
} else {
break
}
}
fmt.Println("Stopped. [receiver]")
syncChan <- struct{}{}
}()
<-syncChan
<-syncChan
}
这里将输出:
The count map: map[count:{0}]. [sender]
The count map: map[count:{0}]. [sender]
The count map: map[count:{0}]. [sender]
The count map: map[count:{0}]. [sender]
The count map: map[count:{0}]. [sender]
Stopped. [receiver]
原因是 go 中的结构体都是值类型而非引用类型, 如果要修改原值的话就要传入指针
比如将变量 mapChan
和 countMap
修改为:
go
var mapChan = make(chan map[string]*Counter, 1)
go
countMap := map[string]*Counter{
"count": {},
}
为了观察结构体内部的值状态及其变化, 为 Counter
类型增加一个方法:
go
func (counter *Counter) String() string {
return fmt.Sprintf("{count: %d}", counter.count)
}
然后输出将是如此:
The count map: map[count:{count: 1}]. [sender]
The count map: map[count:{count: 2}]. [sender]
The count map: map[count:{count: 3}]. [sender]
The count map: map[count:{count: 4}]. [sender]
The count map: map[count:{count: 5}]. [sender]
Stopped. [receiver]
关闭通道
调用 close
函数可以关闭一个通道, 但是调用前要注意: 如果向已关闭的通道发送元素, 此操作会引发 panic
, 所以在关闭通道之前应当确保安全(后面会说用 for
语句和 select
语句确保安全.
这里要说明: 无论如何都不能在接收端关闭通道, 因为接收端在逻辑上一般是不发判断发送端是否还会向通道发元素值. 另一个方面来说, 从发送端关闭通道一般不会产生什么影响, 就算是关闭之后通道里面还有值, 也是可以通过接收表达式取出的, 然后根据该表达式第二个结果值判断通道是否已关闭并且没有元素值可以取.
然后看一个示例:
go
package main
import "fmt"
func main() {
dataChan := make(chan int, 5)
syncChan1 := make(chan struct{}, 1)
syncChan2 := make(chan struct{}, 2)
// 发
go func() {
for i := 0; i < 5; i++ {
dataChan <- i
fmt.Printf("[sender] Sent: %d\n", i)
}
close(dataChan)
syncChan1 <- struct{}{}
fmt.Println("[sender] Done.")
syncChan2 <- struct{}{}
}()
// 收
go func() {
<-syncChan1
for {
if elem, ok := <-dataChan; ok {
fmt.Printf("[receiver] Received: %d \n", elem)
} else {
break
}
}
fmt.Println("[receiver] Done.")
syncChan2 <- struct{}{}
}()
<-syncChan2
<-syncChan2
}
这里通过一个通道(dataChan1
)阻塞了收方面, 强制发送完所有元素并且关闭通道之后再执行接收操作. 虽然通道在这里已关闭了, 但是对于接受操作却没有影响, 接收方仍然可以再接受完所有元素值之后结束工作.
最后又两个注意点:
- 同一通道只能关闭一次, 关闭一个已关闭的通道会引发
panic
- 调用 close 函数时, 需要把想要关闭的通道的变量作为参数传入, 如果该变量值为
nil
, 将引发panic
.
为了帮助决策,可以遵循以下原则:
场景 | 是否需要关闭? | 说明 |
---|---|---|
你是发送方 ,且不再发送任何值 ,接收方正使用 for range |
必须关闭 | 这是关闭 channel 最主要的原因。 |
你是发送方 ,需要通知多个接收者"结束了" | 应该关闭 | 关闭 channel 是一种广播机制,所有接收的 for range 都会收到。 |
Channel 用于单向同步信号 (如 done <- struct{}{} ) |
不需要关闭 | 接收方只接收一次,不关心后续状态。 |
Channel 是全局的、永久的(如任务队列) | 不需要关闭 | 它的设计就是永不停止。但消费者逻辑要匹配(用 for-select 而非 for range )。 |
你不确定 | 倾向于关闭 | 除非你有明确理由不关闭,否则关闭一个 channel 通常比不关闭更安全。但切记:只能关闭一次,且不能关闭已关闭的 channel。 |
记住最后的黄金法则 :永远不要关闭一个接收方还在等待读取的 channel,并且只能由发送方来关闭(或者一个非常明确知道没有其他发送者的角色)。关闭一个 channel 的意图应该是向接收方发送信息,而不是为了回收资源。
长度与容量
内建函数 len
和 cap
同样可以用在通道上, 作用分别是获取通道中当前元素值的数量(长度)以及获取通道可容纳元素值的最大数量(容量). 通道容量实在初始化时以确定的, 并且之后不能改变, 通道长度则会随实际情况变化.
可以通过容量判断通道是否带缓冲. 如果容量为 0, 那么一定是非缓冲通道, 否则是缓冲通道.
单向通道
这是一个非常重要的概念,主要用于在函数或方法间传递通道时,施加明确的权限限制,从而增强代码的类型安全性和可读性。
顾名思义,单向通道就是只能用于发送或只能用于接收的通道。它是双向通道的一种变体,其类型由 chan<-
(只写)和 <-chan
(只读)表示。
- 只写通道 :
chan<- T
- 你只能向这个通道发送 数据(
ch <- value
)。 - 你不能从这个通道接收 数据(如果尝试
<-ch
会引发编译错误)。
- 你只能向这个通道发送 数据(
- 只读通道 :
<-chan T
- 你只能从这个通道接收 数据(
value := <-ch
)。 - 你不能向这个通道发送 数据(如果尝试
ch <- value
会引发编译错误)。
- 你只能从这个通道接收 数据(
一个普通的双向通道 chan T
可以被隐式转换为任何一种单向通道,但反过来不行。
主要用途
你可能会问,既然有双向通道,为什么要限制自己呢?其主要目的是为了在接口层面强制约定和保证代码安全。
增强代码表达性和安全性(最重要的用途)
当一个函数或方法的参数是一个通道时,使用单向通道可以清晰地表达这个函数的意图。
- 对于函数参数 :它明确规定了函数对这个通道的操作权限 。
func producer(ch chan<- int)
: 我(producer
函数)承诺只会在ch
里写数据,绝不会尝试读。这相当于一个"合同"。func consumer(ch <-chan int)
: 我(consumer
函数)承诺只会从ch
里读数据,绝不会尝试写。
这样做的好处是:
- 自文档化:任何人看到函数签名,立刻就知道这个通道该怎么用。
- 编译时检查 :编译器会帮你抓住所有违反这个约定的操作。如果你不小心在
consumer
函数里写了ch <- data
,代码将无法通过编译。这是一种强大的、在编译阶段就能发现错误的机制。 - 防止误操作:避免在复杂的并发程序中,错误地关闭了不该关闭的通道,或者向一个本应只读的通道发送数据,导致难以调试的 panic。
实现更严格的接口
在设计库或者模块时,你可以暴露只读或只写通道给外部使用者,从而隐藏内部实现细节,防止外部代码错误地干扰你的内部通信逻辑。
使用示例
示例 1:经典的生产者-消费者模型
这是最典型的使用场景。
go
package main
import (
"fmt"
"time"
)
// 生产者函数:接收一个只写通道
// 它只能向这个通道发送数据
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
fmt.Printf("生产者发送: %d\n", i)
ch <- i // 这是允许的
time.Sleep(time.Second)
}
close(ch) // 关闭通道也是允许的(通常由发送方关闭)
// 注意:从一个只写通道接收数据(如 <-ch)会导致编译错误
}
// 消费者函数:接收一个只读通道
// 它只能从这个通道接收数据
func consumer(ch <-chan int) {
// 循环从通道中读取数据,直到通道被关闭
for num := range ch {
fmt.Printf("消费者收到: %d\n", num)
}
// 向一个只读通道发送数据(如 ch <- 99)会导致编译错误
// 关闭一个只读通道(如 close(ch))也会导致编译错误
}
func main() {
// 1. 创建一个普通的双向通道
ch := make(chan int)
// 2. 启动生产者和消费者goroutine
// 在传参时,Go语言会自动将双向通道 ch 转换为所需的单向通道类型
go producer(ch) // ch 被当作 chan<- int 使用
consumer(ch) // ch 被当作 <-chan int 使用
fmt.Println("程序结束")
}
关键点:
main
函数里创建的是双向通道chan int
。- 在将
ch
传递给producer
时,它被隐式转换为了chan<- int
(只写)。 - 在将
ch
传递给consumer
时,它被隐式转换为了<-chan int
(只读)。 - 这种转换是安全的,并且是 Go 语言类型系统所允许的。
示例 2:函数返回一个只读通道
你可以设计一个函数,它返回一个只读通道,调用者只能从这个通道消费数据,无法向其发送数据,这很好地封装了内部逻辑。
go
// 创建一个计数器,返回一个只读通道,每秒发送一个递增的数字
func startCounter() <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
ch <- i
time.Sleep(time.Second)
}
}()
return ch // 返回的 chan int 被隐式转换为 <-chan int
}
func main() {
countCh := startCounter()
// 我们只能从 countCh 读
for i := 0; i < 3; i++ {
fmt.Println(<-countCh)
}
// countCh <- 100 // 错误:不能向只读通道发送
// close(countCh) // 错误:不能关闭只读通道
}
重要注意事项
- 转换是单向的 :你可以将
chan T
转换为chan<- T
或<-chan T
,但不能将chan<- T
或<-chan T
转换回chan T
。这是一条"单行道",目的是为了保证安全。 - 通道操作权限 :
- 关闭操作 :只有发送方可以关闭通道。因此,你可以在一个
chan<- T
上调用close()
,但不能在一个<-chan T
上调用close()
,这会导致编译错误。 - 长度和容量 :你可以使用
len()
和cap()
来查询只读和只写通道,因为这个操作不涉及数据的发送和接收。
- 关闭操作 :只有发送方可以关闭通道。因此,你可以在一个
总结
通道类型 | 操作权限 | 典型用途 |
---|---|---|
chan T |
双向(可读可写) | 在单个 goroutine 内部或多个 goroutine 间自由通信 |
chan<- T |
只写(发送) | 作为函数参数,限制函数只能向通道发送数据 |
<-chan T |
只读(接收) | 作为函数参数或返回值,限制函数只能从通道接收数据 |
最佳实践:在函数或方法的签名中,尽可能地使用单向通道。这是一种"按权限设计"的思路,它能极大地提高并发代码的清晰度、安全性和可维护性,是编写高质量 Go 并发程序的标志之一。
配合 for
语句与 select
语句
好的,这是一份为你整理的关于 Go 语言中 channel
与 for
和 select
语句配合使用的综合笔记。它涵盖了核心概念、各种模式、最佳实践和注意事项,非常适合用于学习和复习。
核心思想
Channel 是 Goroutine 之间的通信管道,而 for
和 select
是消费和管理这些管道的主要控制流语句。它们的组合构成了 Go 并发编程的基石。
for
:用于持续地从 channel 中接收数据。select
:用于同时监听多个 channel 的操作(发送或接收)。for
+select
:用于构建长期运行 的服务,该服务需要多路处理各种事件(如数据、信号、超时)。
for range
(最常用、最推荐)
行为 :自动从 channel 接收值,直到 channel 被关闭且 drained(排空)。
循环结束条件:channel 被关闭。
关键 :发送方负责关闭 channel,以向接收方广播"没有更多数据"的信号。
go
ch := make(chan int)
// 生产者 Goroutine
go func() {
for i := 0; i < 3; i++ {
ch <- i
}
close(ch) // 重要!由发送方关闭
}()
// 消费者:使用 for range
for value := range ch {
fmt.Println(value) // 打印 0, 1, 2
}
// 循环在 ch 关闭后自动退出
fmt.Println("Channel closed, loop exited.")
最佳实践:在简单消费者场景下优先使用此模式。
显式检查 (较少用)
行为 :使用 , ok
语法手动检查 channel 状态。
循环结束条件 :ok == false
(channel 已关闭且空)。
go
for {
value, ok := <-ch
if !ok {
break // channel 已关闭且空,退出循环
}
fmt.Println(value)
}
select
用于监听多个 channel 操作,每个 case
是一个通信操作。
跟在每个 case
后面的之呢个是针对某个通道的发送语句或者接收语句。
在 select
关键字右侧没有像是 switch
语句那样的 switch
表达式, 而是直接跟上左花括号。
基础语法
go
select {
case v := <-chan1:
fmt.Printf("Received %v from chan1\n", v)
case chan2 <- data:
fmt.Println("Sent data to chan2")
case <-chan3:
fmt.Println("Received something from chan3 (value ignored)")
default:
fmt.Println("No communication ready, do something else")
}
开始执行 select
语句时, 所有在 case
右侧的发送语句或者接收语句中的通道表达式和元素表达式都会先求值(求值顺序是从左到右, 自上而下), 无论他们所在的 case
是否可能被选择都是这样.
在执行 select
语句的时候,运行时系统会自上而下地判断每个 case
中的发送或接收操作是否可以立即进行。
这里的"立即进行",指的是当前 goroutine 不会因此操作而被阻塞。
这个判断还需要依据通道的具体特性(缓冲或非缓冲)以及那一时刻的具体情况来进行。
只要发现有一个 case
上的判断是肯定的,该 case
就会被选中。
go
package main
import "fmt"
var intChan1 chan int
var intChan2 chan int
var channels = []chan int{intChan1, intChan2}
var numbers = []int{1, 2, 3, 4, 5}
func main() {
select {
case getChan(0) <- getNumber(0):
fmt.Println("1th case is selected.")
case getChan(1) <- getNumber(1):
fmt.Println("The 2nd case is selected.")
default:
fmt.Println("Default case!")
}
}
func getNumber(i int) int {
fmt.Printf("numbers[%d]\n", i)
return numbers[i]
}
func getChan(i int) chan int {
fmt.Printf("channels[%d]\n", i)
return channels[i]
}
输出:
channels[0]
numbers[0]
channels[1]
numbers[1]
Default case!
select
会阻塞直到某个 case
就绪,并在多个 case
就绪时伪随机公平地选择一个执行。
如果没有任何一个 case
符合选择条件, 而且没有 default
case, 那么当前 goroutine 将保持阻塞, 直到至少有一个 case
中的发送或者接受操作可以立即进行为止.
核心应用模式
模式 1:多路复用 (Multiplexing)
监听多个 channel,处理最先到达的事件。
go
dataChan := make(chan string)
stopChan := make(chan struct{}) // 用于信号的 channel
for {
select {
case data := <-dataChan:
handleData(data)
case <-stopChan:
// 收到停止信号,清理并退出
fmt.Println("Stopping...")
return
}
}
模式 2:超时控制 (Timeout)
防止 Goroutine 无限期阻塞。使用 time.After
或 context
。
go
select {
case result := <-longRunningOperationChan:
fmt.Println("Success:", result)
case <-time.After(2 * time.Second):
fmt.Println("Error: Operation timed out after 2 seconds")
}
注意 :在长周期循环中使用 time.After
会创建大量 Timer,可能导致资源泄漏。应使用 time.NewTimer
并在循环外创建和重置。
go
timer := time.NewTimer(2 * time.Second)
defer timer.Stop() // 确保释放资源
for {
timer.Reset(2 * time.Second) // 每次循环重置
select {
case result := <-operationChan:
handle(result)
case <-timer.C:
handleTimeout()
}
}
模式 3:非阻塞操作 (Non-blocking)
使用 default
分支尝试立即进行通信,若无法完成则执行其他任务。
go
select {
case ch <- task: // 尝试发送
fmt.Println("Task sent")
default:
fmt.Println("Channel is busy, skipping task or adding to a buffer")
// 例如,实现一个简单的负载下降策略
}
For 配合 Select 一起使用
这是构建复杂并发服务(如 worker pools、网络服务器、事件循环)的核心模式。
经典结构:带退出机制的永久循环
go
func worker(inputChan <-chan *Task, stopChan <-chan struct{}) {
for { // 永久循环
select {
case task := <-inputChan: // 1. 处理主要工作
process(task)
case <-stopChan: // 2. 响应退出信号
fmt.Println("Worker shutting down...")
cleanup()
return // 退出函数,从而结束 Goroutine
case <-time.After(30 * time.Second): // 3. 处理超时/空闲状态
fmt.Println("Worker is idle")
}
}
}
更现代的结构:使用 context.Context
context
包提供了更强大、更标准的取消和超时机制。
go
func worker(ctx context.Context, inputChan <-chan *Task) {
for {
select {
case task := <-inputChan:
process(task)
case <-ctx.Done(): // 监听 Context 的取消/超时信号
err := ctx.Err()
fmt.Printf("Worker stopping due to: %v\n", err)
cleanup()
return
}
}
}
// 在主函数中
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() // 确保资源释放
go worker(ctx, taskChan)
总结与最佳实践表格
模式 | 语法 | 用途 | 结束条件 |
---|---|---|---|
for range ch |
for v := range ch { } |
简单消费者,处理所有数据 | ch 被关闭 |
for + select |
for { select { case ... } } |
复杂消费者,多路事件处理 | return 或 break (通常由信号触发) |
select + time.After |
case <-time.After(d) |
单次操作超时控制 | 超时或操作完成 |
select + default |
default: ... |
非阻塞通信尝试 | 立即执行 |
黄金法则
- 关闭原则 :永远只由发送方关闭 channel。关闭一个已关闭的 channel 会引发 panic。
- 循环退出 :
for range
依赖 channel 关闭来退出。for-select
循环通常依赖一个专门的信号 channel(如stopChan
或ctx.Done()
)来触发退出。 - nil Channel :对一个
nil
channel 的操作会永远阻塞。你可以利用这一点在select
中动态"禁用"某个case
(将其设置为nil
)。 - 资源管理 :
- 使用
defer
关闭 channel(如果是发送方)。 - 避免在长循环中频繁使用
time.After()
,改用time.Timer
。 - 使用
context.Context
来传播取消信号,这是处理超时和取消的现代标准方式。
- 使用
- 预防泄漏:确保 Goroutine 总有办法退出(通过 channel 关闭或信号),否则会导致 Goroutine 泄漏。
配合 time
包使用
在 Go 语言中,time 包与 channel 的配合使用非常强大,主要用于实现超时控制、定时任务和周期性操作等场景。
基本用法
1. 定时器 (Timer)
定时器用于在未来的某个时间点执行一次操作。
go
package main
import (
"fmt"
"time"
)
func main() {
// 创建一个 2 秒的定时器
timer := time.NewTimer(2 * time.Second)
// 等待定时器触发
<-timer.C
fmt.Println("定时器触发")
// 停止定时器(如果还需要使用,可以使用 Reset)
// timer.Stop()
}
2. 打点器 (Ticker)
打点器用于每隔一段时间重复执行操作。
go
func main() {
// 创建一个每秒触发一次的打点器
ticker := time.NewTicker(1 * time.Second)
// 创建一个 5 秒后触发的定时器用于停止打点器
stopTimer := time.NewTimer(5 * time.Second)
for {
select {
case <-ticker.C:
fmt.Println("打点器触发")
case <-stopTimer.C:
fmt.Println("停止打点器")
ticker.Stop()
return
}
}
}
实际应用场景
1. 超时控制
go
func main() {
// 创建一个用于模拟长时间操作的 channel
resultChan := make(chan string)
// 模拟一个耗时操作
go func() {
time.Sleep(3 * time.Second)
resultChan <- "操作完成"
}()
// 设置超时时间为 2 秒
select {
case res := <-resultChan:
fmt.Println(res)
case <-time.After(2 * time.Second):
fmt.Println("操作超时")
}
}
2. 定期执行任务
go
func main() {
ticker := time.NewTicker(2 * time.Second)
done := make(chan bool)
go func() {
for {
select {
case <-done:
return
case t := <-ticker.C:
fmt.Println("定期任务执行于", t.Format("15:04:05"))
}
}
}()
// 运行 10 秒后停止
time.Sleep(10 * time.Second)
ticker.Stop()
done <- true
fmt.Println("定时任务停止")
}
3. 限制操作频率
go
func main() {
requests := make(chan int, 5)
for i := 1; i <= 5; i++ {
requests <- i
}
close(requests)
// 限制为每 1 秒处理一个请求
limiter := time.Tick(1 * time.Second)
for req := range requests {
<-limiter
fmt.Println("处理请求", req, time.Now().Format("15:04:05"))
}
}
4. 带有超时的等待组
go
func main() {
var wg sync.WaitGroup
wg.Add(1)
done := make(chan bool)
go func() {
time.Sleep(3 * time.Second) // 模拟耗时任务
wg.Done()
done <- true
}()
// 设置 2 秒超时
select {
case <-done:
fmt.Println("任务完成")
case <-time.After(2 * time.Second):
fmt.Println("任务超时")
}
}
注意事项
- 记得调用
Stop()
方法来释放定时器/打点器资源,避免内存泄漏 - 使用
time.After()
在长时间运行的循环中可能会创建大量定时器,应考虑使用time.NewTimer()
并重用 - 定时器/打点器触发后,channel 会接收到一个时间值,但通常我们只关心触发事件本身
这些模式使得 Go 程序能够优雅地处理时间相关的操作,特别是在并发环境中非常有用。
("定期任务执行于", t.Format("15:04:05"))
}
}
}()
// 运行 10 秒后停止
time.Sleep(10 * time.Second)
ticker.Stop()
done <- true
fmt.Println("定时任务停止")
}
#### 3. 限制操作频率
```go
func main() {
requests := make(chan int, 5)
for i := 1; i <= 5; i++ {
requests <- i
}
close(requests)
// 限制为每 1 秒处理一个请求
limiter := time.Tick(1 * time.Second)
for req := range requests {
<-limiter
fmt.Println("处理请求", req, time.Now().Format("15:04:05"))
}
}
4. 带有超时的等待组
go
func main() {
var wg sync.WaitGroup
wg.Add(1)
done := make(chan bool)
go func() {
time.Sleep(3 * time.Second) // 模拟耗时任务
wg.Done()
done <- true
}()
// 设置 2 秒超时
select {
case <-done:
fmt.Println("任务完成")
case <-time.After(2 * time.Second):
fmt.Println("任务超时")
}
}
注意事项
- 记得调用
Stop()
方法来释放定时器/打点器资源,避免内存泄漏 - 使用
time.After()
在长时间运行的循环中可能会创建大量定时器,应考虑使用time.NewTimer()
并重用 - 定时器/打点器触发后,channel 会接收到一个时间值,但通常我们只关心触发事件本身
这些模式使得 Go 程序能够优雅地处理时间相关的操作,特别是在并发环境中非常有用。