一、先建立一个核心认识
你已经知道:
go
ch1 := make(chan int)
ch2 := make(chan int)
如果直接:
go
x := <-ch1
意思是:
我只等
ch1。
但是如果:
go
select {
case x := <-ch1:
fmt.Println("收到 ch1:", x)
case y := <-ch2:
fmt.Println("收到 ch2:", y)
}
意思变成:
我同时等
ch1和ch2,哪个 Channel 先可以完成操作,我就执行哪个 case。
这就是 select 最核心的功能。
可以把它类比成:
csharp
┌── ch1 有数据 ──→ 执行 case 1
select ──────┤
└── ch2 有数据 ──→ 执行 case 2
二、select 的基本语法
最基本的形式:
csharp
select {
case 操作1:
// 操作1可以执行
case 操作2:
// 操作2可以执行
case 操作3:
// 操作3可以执行
}
注意:
select 的 case 不是普通条件判断。
比如:
go
select {
case x := <-ch:
fmt.Println(x)
case ch <- 10:
fmt.Println("发送成功")
}
这里的 case 本质上都是:
Channel 操作是否已经准备好?
三、最简单的接收
例如:
go
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
time.Sleep(time.Second)
ch1 <- 100
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- 200
}()
select {
case x := <-ch1:
fmt.Println("ch1:", x)
case x := <-ch2:
fmt.Println("ch2:", x)
}
运行:
makefile
ch1: 100
因为:
1秒 ch1 有数据
2秒 ch2 有数据
所以 select 在 1 秒时就结束了。
它不会继续等待 ch2。
四、select 最重要的特性:只执行一个 case
例如:
go
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
ch1 <- 100
ch2 <- 200
select {
case x := <-ch1:
fmt.Println("ch1:", x)
case x := <-ch2:
fmt.Println("ch2:", x)
}
这里:
ch1 已经可以接收
ch2 也已经可以接收
那么怎么办?
答案是:
Go 会在所有 ready 的 case 中伪随机选择一个。
所以可能输出:
makefile
ch1: 100
也可能:
makefile
ch2: 200
不要认为:
arduino
case ch1:
case ch2:
是按照从上到下的优先级执行的。
不是。
五、一个非常重要的概念:ready
理解 select,一定要理解channel的
ready(就绪)
例如:
go
ch := make(chan int, 1)
ch <- 10
此时接收:
go
x := <-ch
可以立即执行。
所以:
arduino
ch ready
再比如:
go
ch := make(chan int)
没有任何 goroutine 接收:
r
ch <- 10
这个发送操作不能立即完成。
所以:
arduino
send ch <- 10
not ready
所以selet+channel
go
select {
case x := <-ch1:
...
case x := <-ch2:
...
}
实际上就是:
arduino
检查 ch1 是否 ready
检查 ch2 是否 ready
↓
如果只有一个 ready
↓
执行它
如果多个 ready
↓
随机选择一个
如果都不 ready
↓
select 阻塞
这个模型非常重要。
六、select 会阻塞
例如:
go
ch1 := make(chan int)
ch2 := make(chan int)
select {
case x := <-ch1:
fmt.Println(x)
case x := <-ch2:
fmt.Println(x)
}
如果:
ch1 没数据
ch2 没数据
那么:
csharp
select
会一直阻塞。
也就是说:
csharp
select
↓
ch1?没准备好
ch2?没准备好
↓
等待
↓
等待
↓
等待
直到某个 case 可以执行。
七、select + default
这是 select 非常重要的一种形式:
go
select {
case x := <-ch:
fmt.Println(x)
default:
fmt.Println("没有数据")
}
这里的 default 表示:
如果其他 case 都不能立即执行,就执行 default。
因此它不会阻塞。
例如:
go
ch := make(chan int)
select {
case x := <-ch:
fmt.Println(x)
default:
fmt.Println("没有数据")
}
因为 ch 没有数据,并且没有其他 goroutine 发送:
arduino
receive ch → not ready
default → execute
输出:
没有数据
但是注意default 有一个的坑:
go
for {
select {
case msg := <-ch:
process(msg)
default:
continue
}
}
看起来似乎很合理。
但实际上这会导致:CPU 会疯狂空转。
这叫:
busy spinning
所以不要随便写:
csharp
for {
select {
default:
}
}
除非你明确知道自己在做什么。
八、select + for:最常见的使用方式
实际开发中你会大量看到:
go
for {
select {
case msg := <-ch1:
fmt.Println("收到消息:", msg)
case msg := <-ch2:
fmt.Println("收到消息:", msg)
}
}
这意味着:
不断监听多个 Channel。
例如一个服务:
csharp
Kafka消息
↓
chMessage ─────┐
│
HTTP请求 │
↓ │
chRequest ──────┤
↓
select
↓
谁来了处理谁
这就是 select 非常典型的应用。
九、select + ticker
这也是 Go 服务代码中非常常见的模式。
css
ticker := time.NewTicker(time.Second)
for {
select {
case <-ticker.C:
fmt.Println("每秒执行一次")
case msg := <-ch:
fmt.Println("收到消息:", msg)
}
}
此时这个 goroutine 同时等待:
csharp
ticker.C
│
├── 每秒触发
│
↓
select
↑
│
ch ── 收到消息
谁先发生就处理谁。
这就是非常经典的:
事件循环(event loop)
十、select + timeout
这是 select 最重要的实际应用之一。
例如:
sql
select {
case result := <-ch:
fmt.Println("收到结果:", result)
case <-time.After(time.Second):
fmt.Println("超时")
}
意思:
最多等 1 秒。
如果:
ch 在 1 秒内收到数据
执行:
go
case result := <-ch:
如果:
1 秒过去
ch 仍然没有数据
执行:
css
case <-time.After(time.Second):
这就是:
Channel 操作 + timeout
在网络请求、RPC、任务处理里面非常常见。
十一、select + context
实际 Go 项目中,比 time.After 更常见的是:
sql
select {
case result := <-ch:
fmt.Println(result)
case <-ctx.Done():
fmt.Println("任务被取消")
}
这是非常重要的 Go 编程模式。
ctx.Done() 本质上也是一个 Channel:
go
<-chan struct{}
所以:
arduino
case <-ctx.Done():
表示:
监听 context 的取消信号。
例如:
go
func worker(ctx context.Context, ch <-chan int) {
for {
select {
case x := <-ch:
fmt.Println("处理:", x)
case <-ctx.Done():
fmt.Println("worker退出")
return
}
}
}
这意味着:
csharp
┌── ch 有数据
│
select ─────────┤
│
└── ctx 被取消
任何一个事件发生,就处理。一旦外部:
scss
cancel()
该worker就会return,否则就一直消费ch
十二、一个非常经典的生产者/消费者模型
比如:
go
func worker(
input <-chan int,
output chan<- int,
) {
for {
select {
case x := <-input:
result := x * 2
select {
case output <- result:
case <-time.After(time.Second):
fmt.Println("发送结果超时")
return
}
}
}
}
这里就出现了:
lua
input
↓
select
↓
处理
↓
output
↓
select
↓
最多等待 1 秒
这类结构在实际服务中非常常见。
十三、 当channel被关闭(非常重点)
假设:
go
ch := make(chan int)
close(ch)
那么:
go
x, ok := <-ch
得到:
ini
x = 0
ok = false
如果放到 select:
go
select {
case x := <-ch:
fmt.Println(x)
case <-other:
...
}
因为 ch 已经关闭:
<-ch
永远可以立即完成。
因此这个 case 会一直 ready。
这会造成一个非常常见的问题。忙循环,一直进入ch被关闭的这个case。
正确写法通常是:
go
for {
select {
case x, ok := <-ch:
if !ok {
ch = nil
continue // `continue` 用于在通道 `ch` 被关闭时,跳过当前循环迭代中剩余的代码,直接进入下一次 `for` 循环迭代。
}
fmt.Println(x)
case <-other:
fmt.Println("other")
}
}
Go 语言的通道关闭规则(与类型无关)
无论通道是 chan int、chan string 还是 chan 结构体,关闭后的接收行为都遵循同一条铁律:
从已关闭的通道接收数据,会立即返回该类型的"零值",且
ok为false。
所以:
chan int关闭 → 零值0chan string关闭 → 零值""chan bool关闭 → 零值falsechan []byte关闭 → 零值nil
这里出现了一个非常关键的技巧:
ini
ch = nil
十四、close(ch) 和 ch = nil 可不是一回事!!
为什么 nil Channel 很重要?因为ch一旦设置为nil,将永远不会ready,close(ch) 和 ch = nil 是两件完全独立的事情:
close(ch):只改变通道的内部状态 (标记为"已关闭"),但变量ch本身依然指向原来的内存地址,它不是空指针。ch = nil:才是把变量ch指向空地址。
对于:
go
var ch chan int
此时:
ini
ch == nil
那么:<-ch 接收会永久阻塞。
而:ch <- 10 发送也会永久阻塞。
所以:
nil Channel 可以用来动态禁用一个 select case。
这是 select 非常高级、也非常实用的技巧。
总结,ch被close,永远ready,ch == nil,永远不ready
十五、总结:select的底层思维模型
现在可以把:
csharp
select {
case <-ch1:
case ch2 <- x:
case <-ctx.Done():
case <-timer.C:
default:
}
理解成:
scss
ch1 能接收?
│
↓
ch2 能发送?
│
↓
ctx.Done() 已触发?
│
↓
timer 到了?
│
↓
都不行?
│
┌─────┴─────┐
↓ ↓
有 default 没有 default
↓ ↓
执行 default 阻塞等待
如果多个同时 ready:
arduino
ch1 ready
ch2 ready
ctx ready
Go 会从 ready case 中选择一个执行,而不是按照源码顺序优先执行。
十六、总结:select的4种使用模式
模式 1:多 Channel 监听
go
select {
case msg := <-ch1:
...
case msg := <-ch2:
...
}
用途:
同时等待多个数据源。
模式 2:非阻塞操作
go
select {
case msg := <-ch:
...
default:
...
}
用途:
尝试读取,但不等待。
模式 3:超时
go
select {
case result := <-ch:
...
case <-time.After(time.Second):
...
}
用途:
防止无限等待。
模式 4:优雅退出
go
select {
case msg := <-ch:
...
case <-ctx.Done():
return
}
用途:
控制 goroutine 生命周期。
十七、结合channel理解select
之前学 Channel 时,可以把它理解成:
markdown
Channel
↓
解决 goroutine 之间的数据传递
而现在:
csharp
select
↓
解决 goroutine 同时等待多个 Channel 事件
所以两者关系是:
csharp
Channel
│
┌──────────┴──────────┐
↓ ↓
数据传递 同步/阻塞
│ │
└──────────┬──────────┘
↓
select
↓
同时等待多个 Channel