第10章 选择语句
第10章 选择语句
10.1 select 语句
10.1.1 select 语法
想象这样一个场景:你是一个婚礼司仪,面前站着三个伴郎,每个伴郎手里都攥着给新娘的表白信。但新娘只有一个,而且她有"选择困难症晚期"——她不想一个个问"你有什么要说的吗",她只想知道:“谁先开口,我就嫁给谁。”
在 Go 的世界里,select 就是那个"谁先开口就选谁"的婚礼司仪。只不过它主持的不是婚礼,而是通道(channel)的读写竞争。
select 是 Go 语言独创的控制结构,专门用来在多个通道操作中"选一个执行"。如果你写过其他语言,你会发现它们根本没有这个概念——因为其他语言没有通道这个玩意儿。select 就是通道的专属红娘,专门帮通道配对。
select 的核心规则只有三条:
- 哪个通道先准备好,就选哪个
- 如果多个通道同时准备好,随机选一个(这就是 Go 的"公平")
- 如果所有通道都没准备好,且没有
default,就乖乖等着(阻塞)
这规则简单得令人发指,但用起来却能解决大量并发问题。
select 的基本语法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| select {
case <-ch1:
// 案例一:从 ch1 接到了情书
fmt.Println("从 ch1 收到了数据")
case ch2 <- 100:
// 案例二:成功向 ch2 送出了 100 块份子钱
fmt.Println("向 ch2 发送了数据 100")
case ch3 <- "hello":
// 案例三:成功向 ch3 说了声 hello
fmt.Println("向 ch3 发送了字符串 hello")
default:
// 默认:所有人都在装死,那我自己先溜了
fmt.Println("所有通道都没准备好,执行默认分支")
}
|
看到了吗?每个 case 后面跟的不是普通的值比较,而是通道操作——要么是"从通道拿东西"(<-ch),要么是"往通道塞东西"(ch <- value)。这就跟 switch 的 case 后面跟"判断条件"完全不同。
一个完整的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| package main
import "fmt"
func main() {
// 创建两个通道
ch1 := make(chan string, 1)
ch2 := make(chan int, 1)
// 给 ch1 塞一个值(让它"准备好")
ch1 <- "数据来了"
// select 开始选妃
select {
case msg := <-ch1:
fmt.Println("收到 ch1 的消息:", msg) // 收到 ch1 的消息: 数据来了
case num := <-ch2:
fmt.Println("收到 ch2 的数字:", num)
}
fmt.Println("select 执行完毕")
}
|
想象一下:你是一个海王,同时在聊三个微信妹妹(三个通道)。你说:“谁先回我消息,我就跟谁约会”。结果妹妹 A(ch1)秒回了,妹妹 B(ch2)还在洗澡——那还用说?当然是选妹妹 A!select 就是这么个"海王思维"。
10.1.2.1 发送 case
发送 case 是什么?
发送 case 就是在 case 里写一个"往通道塞东西"的操作,格式是 ch <- value。这就好比你往邮筒里投信——如果邮筒没满,信就投进去了;如果邮筒满了,你就得等别人来取信(或者直接把邮筒炸了,但这是违法的)。
发送 case 的本质是:“我试着把这个值塞进通道,如果通道没准备好(满了或者没人在等接收),我就干等着(阻塞)”。
但如果你加了 default,那就不等了——通道没准备好,我就直接走人,另谋出路。
发送 case 语法
1
2
3
| case ch <- value:
// 发送成功(通道正好有人等着接收,或者缓冲区还有空间)
fmt.Println("发送成功!")
|
一个完整的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| package main
import "fmt"
func main() {
// 创建一个带缓冲的通道,缓冲区大小为 1
ch := make(chan string, 1)
// 先往里面塞一个值,填满缓冲区
ch <- "第一封信"
// 现在 ch 的缓冲区满了,再发送会阻塞
// 所以 select 会选择其他分支(如果有的话)
select {
case ch <- "第二封信":
fmt.Println("发送成功") // 这行不会执行,因为缓冲区满了
default:
fmt.Println("通道缓冲区满了,第二封信塞不进去!") // 通道缓冲区满了,第二封信塞不进去!
}
fmt.Println("程序结束")
}
|
把你自己想象成一个快递员。你有一封信要送,目标是某个公司的前台(通道)。如果前台没满(缓冲区有空间),你就把信放下了;如果前台已经堆满了快递(缓冲区满了),你就只能干等着,或者——如果公司说"满了就别放了"(default)——你直接把信带回去交给老板(执行 default)。
发送 case 的精髓:非阻塞发送
上面这个例子展示的就是"非阻塞发送"的精髓——default 分支保证了发送不会让你干等着。如果把 default 去掉:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| package main
import "fmt"
func main() {
ch := make(chan string, 1)
ch <- "第一封信"
// 没有 default,会一直阻塞,直到有人接收
select {
case ch <- "第二封信":
fmt.Println("发送成功")
}
fmt.Println("这行永远不会打印,因为程序阻塞了")
}
|
这段代码会永久阻塞!因为没有 default,而缓冲区又满了,发送操作永远不会成功。
发送 case 的正确用法:配合接收者
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| package main
import "fmt"
func main() {
// 无缓冲通道
ch := make(chan int)
// 开启一个 goroutine,模拟另一个正在等待接收的人
go func() {
val := <-ch
fmt.Println("goroutine 收到了:", val) // goroutine 收到了: 42
}()
// 尝试发送(goroutine 正好在等着接收)
select {
case ch <- 42:
fmt.Println("发送成功!") // 发送成功!
default:
fmt.Println("不会走到这里")
}
// 主 goroutine 睡眠一下,让 goroutine 先执行
fmt.Scanln()
}
|
发送 case 就像你在餐厅排队等位。你说:“如果现在有位子(接收者准备好了),我就坐下(发送成功);如果没有,我就去取个号等着(阻塞)。“如果餐厅门口贴了个牌子说"满了就别等了”(default),那你就直接走人,换一家餐厅。
10.1.2.2 接收 case
接收 case 是什么?
接收 case 就是在 case 里写一个"从通道取东西"的操作,格式是 <-ch 或者 val := <-ch。这就像你站在自动售货机前,尝试按下按钮取出一瓶可乐——如果里面有可乐,你就拿到了;如果空的,你就只能拍着售货机骂娘(阻塞)。
接收 case 的本质是:“我试着从这个通道拿一个值,如果通道里没有东西,我就干等着(阻塞)”。
但如果你加了 default,那就不等了——通道空的,我就直接走人。
接收 case 两种写法
1
2
3
4
5
6
7
| // 写法一:只关心"有没有",不关心"是什么"
case <-ch:
fmt.Println("ch 收到了数据,但我不想看具体是什么")
// 写法二:不仅要"有没有",还要"是什么"
case val := <-ch:
fmt.Println("ch 收到了数据,是:", val)
|
一个完整的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| package main
import "fmt"
func main() {
// 创建两个通道
ch1 := make(chan string, 1)
ch2 := make(chan int, 1)
// 给两个通道都塞上数据(让它们都"准备好")
ch1 <- "Hello"
ch2 <- 9527
select {
case val1 := <-ch1:
fmt.Println("ch1 收到了:", val1) // ch1 收到了: Hello
case val2 := <-ch2:
fmt.Println("ch2 收到了:", val2)
}
fmt.Println("=== 再次测试,看看随机性 ===")
// 清空通道,重新测试
ch3 := make(chan string, 1)
ch4 := make(chan string, 1)
ch3 <- "消息A"
ch4 <- "消息B"
select {
case val := <-ch3:
fmt.Println("ch3 收到:", val) // 可能打印 ch3 收到: 消息A
case val := <-ch4:
fmt.Println("ch4 收到:", val) // 也可能打印 ch4 收到: 消息B
}
}
|
你是一个网购达人,同时在刷三个购物 App(三个通道)。你说:“哪个 App 先推送打折信息,我就买哪个!“结果京东先推送了 iPhone 打折(ch1 准备好了),但你刚打开淘宝,就发现淘宝也推送了 AirPods 打折(ch2 也准备好了)——这下麻烦了,select 会随机选一个!这就是著名的"选择困难症”,Go 语言居然把这玩意儿内置了。
接收 case 的阻塞特性
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| package main
import "fmt"
func main() {
ch := make(chan string)
// ch 是空的,没有任何数据
// 如果没有 default,这里就会一直阻塞到天荒地老
select {
case val := <-ch:
fmt.Println("收到:", val) // 这行永远不会执行
default:
fmt.Println("ch 是空的,没东西可收!走 default 分支") // ch 是空的,没东西可收!走 default 分支
}
fmt.Println("程序继续执行,没有阻塞")
}
|
接收 case 就像你在等快递——你下了单,但快递员还在仓库里睡觉(通道里没有数据)。如果你不装"蜂巢”(没有 default),你就只能在家门口蹲着等(阻塞);如果装了蜂巢(有了 default),你就可以说"算了,我先去楼下麻将馆等着,快递到了会给我发短信”(执行 default)。
10.1.2.3 多 case 选择
多 case 选择是什么?
多 case 选择就是 select 里有超过两个 case 的情况。这就像你去自助餐厅,发现红烧肉、糖醋排骨、清蒸鱼同时做好了——你只能端一盘走,但服务员(select)会随机给你端一个过来。
在 Go 里,一个 select 语句可以有任意多个 case,没有上限。你想写 100 个 case?没问题!想写 1000 个?理论上也行,就是你的代码看起来会像面条一样。
为什么需要多 case?
主要有两种使用场景:
- 多通道监听:同时盯着十几个通道,哪个有数据就处理哪个
- 负载均衡:同时给三个 worker 发任务,随机选一个
多通道监听:经典的退出信号模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| package main
import "fmt"
func main() {
// 创建三个通道
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
quit := make(chan string)
// 往 ch1 和 ch2 各塞一条消息
ch1 <- "任务A 完成"
ch2 <- "任务B 完成"
// ch1 和 ch2 都准备好了,quit 还是空的
// select 会随机选一个
select {
case msg1 := <-ch1:
fmt.Println("处理了 ch1:", msg1) // 处理了 ch1: 任务A 完成
case msg2 := <-ch2:
fmt.Println("处理了 ch2:", msg2)
case quitMsg := <-quit:
fmt.Println("收到退出信号:", quitMsg)
}
fmt.Println("--- 多 case 测试 ---")
// 把之前的值消费掉,然后只给 quit 塞数据
<-ch1
<-ch2
quit <- "老板说得走了"
select {
case msg1 := <-ch1:
fmt.Println("ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("ch2:", msg2)
case quitMsg := <-quit:
fmt.Println("收到退出信号:", quitMsg) // 收到退出信号: 老板说得走了
}
}
|
负载均衡:多个 worker 通道随机选
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| package main
import "fmt"
func main() {
// 模拟三个 worker 通道
worker1 := make(chan int, 1)
worker2 := make(chan int, 1)
worker3 := make(chan int, 1)
// 三个 worker 都能接收任务(都"准备好"了)
task := 100
select {
case worker1 <- task:
fmt.Println("任务丢给了 worker1") // 任务丢给了 worker1
case worker2 <- task:
fmt.Println("任务丢给了 worker2")
case worker3 <- task:
fmt.Println("任务丢给了 worker3")
}
// 再来一个任务
task2 := 200
select {
case worker1 <- task2:
fmt.Println("任务丢给了 worker1")
case worker2 <- task2:
fmt.Println("任务丢给了 worker2")
case worker3 <- task2:
fmt.Println("任务丢给了 worker3")
}
}
|
多 case 就像你在玩"吃鸡"游戏——你同时按了 F 键捡三个空投(三个通道都 ready),但你只能捡一个!系统会随机帮你捡一个——这运气,啧啧。有时候你明明想捡 AWM,却捡到了平底锅;有时候你想捡 8 倍镜,却捡到了医疗包。这就是命啊,select 也是这么认为的。
一个更真实的例子:同时监听多个数据源
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| package main
import "fmt"
func main() {
// 模拟三个新闻频道
newsCh1 := make(chan string, 1)
newsCh2 := make(chan string, 1)
newsCh3 := make(chan string, 1)
// 往第二个频道塞一条新闻
newsCh2 <- "突发:Go 1.24 发布了!"
select {
case news1 := <-newsCh1:
fmt.Println("频道1:", news1)
case news2 := <-newsCh2:
fmt.Println("频道2:", news2) // 频道2: 突发:Go 1.24 发布了!
case news3 := <-newsCh3:
fmt.Println("频道3:", news3)
default:
fmt.Println("所有频道都没新闻,先去看广告")
}
}
|
你是一个新闻狂人,同时开着十个新闻网站等头条。结果第一个网站(ch1)还没更新,第二个网站(ch2)突然弹出一条突发新闻:“Go 1.24 发布!"——你眼睛一亮,手速飞快地打开看。结果等你看完,select 已经把其他九个网站的机会全部"随机"掉了。这就是命,认了吧。
10.1.3 default 分支
default 分支是什么?
default 分支是 select 里的"万金油”——当所有 case 都没准备好时,它就闪亮登场了。没有它,select 就会阻塞到天荒地老;有它,select 就会优雅地说"大家都忙?那我先干别的"。
这就像你打电话给客服:“您前面还有 9999 位用户在排队,预计等待时间 999 小时。“你没有 default,就真的等 999 小时;有 default,你直接挂掉去打王者了。
default 的语法
1
2
3
4
5
6
7
8
9
| select {
case <-ch1:
fmt.Println("ch1 收到了")
case ch2 <- "hi":
fmt.Println("发送成功")
default:
// 所有通道都没准备好,执行这里
fmt.Println("没人准备好,我先溜了")
}
|
一个完整的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| package main
import "fmt"
func main() {
ch := make(chan string)
// ch 是空的,没有任何数据
select {
case msg := <-ch:
fmt.Println("收到:", msg)
default:
fmt.Println("ch 是空的,干等着太无聊,先执行 default") // ch 是空的,干等着太无聊,先执行 default
}
fmt.Println("程序继续跑,没有被阻塞!")
// 再试一个带缓冲的通道
ch2 := make(chan int, 1)
// ch2 缓冲区是满的
ch2 <- 100
// 再往里塞,塞不进去
select {
case ch2 <- 200:
fmt.Println("塞进去了")
default:
fmt.Println("ch2 缓冲区满了,塞不进去,执行 default") // ch2 缓冲区满了,塞不进去,执行 default
}
fmt.Println("我还是没有被阻塞!")
}
|
default 就像是你的"备胎池”。正主(case)都没空(没准备好),你就说"那我去外面浪一会儿”,然后一头扎进 default 的怀抱。听起来很美好对吧?但注意——如果你有太多 select + default 的组合,说明你的代码逻辑可能在"空转",白白消耗 CPU。真正好的设计应该是:等到了就处理,等不到就睡眠,而不是"等不到就去干别的然后马上回来继续等"这种忙等待。
default 的经典用法:非阻塞通道操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| package main
import "fmt"
func main() {
// 非阻塞接收
ch := make(chan string, 1)
ch <- "有数据了"
select {
case msg := <-ch:
fmt.Println("收到消息:", msg) // 收到消息: 有数据了
default:
fmt.Println("没有消息")
}
// 非阻塞发送
ch2 := make(chan string, 1)
select {
case ch2 <- "发一条":
fmt.Println("发送成功") // 发送成功
default:
fmt.Println("发送失败")
}
// 缓冲区满了,再发送就走 default
ch3 := make(chan string, 1)
ch3 <- "第一条"
select {
case ch3 <- "第二条":
fmt.Println("发送成功")
default:
fmt.Println("缓冲区满了,非阻塞发送失败,走 default") // 缓冲区满了,非阻塞发送失败,走 default
}
}
|
default 就像是你去银行办业务。你拿了号,发现前面还有 100 个人在等。你没有 default,就真的坐在椅子上等 3 个小时;有 default,你直接说"算了,明天再来",然后去逛街了。明天再来意味着什么?意味着你回来的时候,可能还要重新排队——所以 select + default 不是银弹,它只是给了你一个"放弃等待"的选择。
10.2 select 语义
10.2.1 阻塞语义
阻塞语义是什么?
阻塞语义是 select 的默认行为——当所有 case 都没准备好时,select 会阻塞当前 goroutine,直到至少有一个 case 准备好了。
这就像你打电话给客服,电话那头说"您的来电对我们很重要,请稍等",然后你就真的"稍等"了——干等着,什么也干不了,直到客服说"您好,请问有什么可以帮您"。
没有 default 的 select,就是一个"老老实实等到底"的主儿。
阻塞 select 的基本行为
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
// 开启一个 goroutine,2秒后往 ch 发送数据
go func() {
time.Sleep(2 * time.Second)
ch <- "数据来啦!"
}()
fmt.Println("开始等待...") // 开始等待...
// 这里会阻塞,直到 ch 有数据
select {
case msg := <-ch:
fmt.Println("收到:", msg) // 收到: 数据来啦!
}
fmt.Println("收到数据,程序结束")
}
|
阻塞语义就像你在等外卖——你下了单,外卖小哥正在路上,你只能干等着(阻塞),不能取消订单去做别的事情。只有当外卖到了(通道 ready),你才能开门取餐(执行 case)。如果你实在等不及了怎么办?那你就只能"长租充电宝"(加 default),然后去做别的事情——但外卖还是得等,除非你真的取消订单。
为什么需要阻塞?
你可能会问:“阻塞多浪费时间啊,为什么不直接跳过?”
好问题!但阻塞有时候恰恰是我们想要的:
- 服务器等待客户端请求——没有请求来,你总不能直接返回吧?
- 生产者等待消费者准备好——消费者没就绪,你就得排队
- 定时任务等待时间到达——闹钟没响,你就得继续睡
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| package main
import "fmt"
import "time"
func main() {
// 模拟一个倒计时器
timer := time.After(3 * time.Second)
fmt.Println("倒计时开始...") // 倒计时开始...
// 阻塞等待,直到 3 秒后 timer 通道有数据
select {
case t := <-timer:
fmt.Println("时间到!现在是:", t.Format("15:04:05")) // 时间到!现在是: 3秒后的时间
}
fmt.Println("倒计时结束")
}
|
阻塞语义与 nil 通道
这里有个大坑:如果 case 里引用的是 nil 通道(未初始化的通道),这个 case 永远不会被"准备好"——它会永远阻塞,永远不会执行。
1
2
3
4
5
6
7
8
9
10
11
12
| package main
import "fmt"
func main() {
var ch chan int // ch 是 nil
fmt.Println("nil 通道永远不会 ready,会永久阻塞")
fmt.Println("所以永远不要把 nil 通道放进 select 里") // nil 通道永远不会 ready,会永久阻塞
fmt.Println("否则你的程序就会像卡在电梯里一样,永远出不来")
}
|
nil 通道是个"幽灵通道"——它存在,但你永远等不到它。就像你等一个永远不会回你微信的人,你发再多消息(往 nil 通道发数据)都是石沉大海。所以,永远不要把 nil 通道放进 select 里,否则你的程序就会永远阻塞,直到天荒地老、海枯石烂、太阳熄灭。
10.2.2 非阻塞语义
非阻塞语义是什么?
xxxxxxxxxx package mainimport “fmt"func main() { fmt.Printf(“isEven(10) = %t\n”, isEven(10)) fmt.Printf(“isOdd(10) = %t\n”, isOdd(10))}func isEven(n int) bool { if n == 0 { return true } return isOdd(n - 1)}func isOdd(n int) bool { if n == 0 { return false } return isEven(n - 1)}## 本章小结本章我们学习了 Go 语言的循环语句:1. for 循环: - 传统形式:for i := 0; i < n; i++ { } - 条件形式:for 条件 { } - 无限形式:for { } - range 形式:遍历数组、切片、字符串、映射、通道2. range 详解: - 数组/切片:索引和值 - 字符串:字节索引和字节值 - 映射:无序遍历 - 通道:阻塞接收直到关闭3. 循环控制: - break:跳出循环 - continue:跳过本次循环 - 标号配合使用可以控制多重循环4. 循环模式: - 无限循环、条件循环、集合遍历、并行迭代5. 性能优化: - 边界检查消除 - 循环展开 - 迭代变量优化6. 递归 vs 迭代: - Go 不支持尾递归优化 - 推荐使用迭代替代递归go
非阻塞 = select + default,就这么简单。
如何实现非阻塞?
核心就一招:select + default。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| package main
import "fmt"
func main() {
ch := make(chan string, 1)
// 通道目前是空的(没有数据)
select {
case msg := <-ch:
fmt.Println("收到:", msg)
default:
fmt.Println("通道没数据,但我不想等,先干别的!") // 通道没数据,但我不想等,先干别的!
}
fmt.Println("非阻塞,程序继续跑!")
}
|
非阻塞语义就像你等公交车——如果等太久(通道没数据),你就走路上班(执行 default)去,而不是傻等。select + default 就是你的"走路上班"选项。
非阻塞发送
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| package main
import "fmt"
func main() {
ch := make(chan string, 1)
// 塞满缓冲区
ch <- "第一条"
// 再塞第二条
select {
case ch <- "第二条":
fmt.Println("发送成功")
default:
fmt.Println("缓冲区满了,塞不进去,干别的去!") // 缓冲区满了,塞不进去,干别的去!
}
fmt.Println("非阻塞发送测试结束")
}
|
多次尝试非阻塞
有时候你需要反复尝试,直到成功为止:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| package main
import "fmt"
import "time"
func main() {
ch := make(chan string, 1)
// 开启一个 goroutine,1秒后往 ch 发送数据
go func() {
time.Sleep(1 * time.Second)
ch <- "终于等到了!"
}()
attempts := 0
// 不断尝试非阻塞接收
for {
select {
case msg := <-ch:
fmt.Println("收到消息:", msg) // 收到消息: 终于等到了!
return
default:
attempts++
fmt.Printf("第 %d 次尝试,通道还没数据,干别的...\n", attempts) // 第 1 次尝试,通道还没数据,干别的...
time.Sleep(200 * time.Millisecond)
}
}
}
|
多次尝试非阻塞就像你打电话给女神——第一次打,没接;第二次打,关机;第三次打,换号了;第四次……你还在打吗?赶紧停下来去干活吧。如果你发现自己写了太多 for + select + default,可能需要重新设计一下架构——也许用通道关闭来退出循环会更优雅,别当舔狗了。
非阻塞语义的应用场景
- 防止永久阻塞:在循环中使用
select + default,保证程序不会被卡死 - 实现超时机制:
default 配合 time.After 实现超时 - 优雅退出:监听退出信号,不阻塞主 goroutine
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| package main
import "fmt"
import "time"
func main() {
quit := make(chan string)
// 开启一个 goroutine,2秒后发送退出信号
go func() {
time.Sleep(2 * time.Second)
quit <- "quit"
}()
// 用 select 监听退出信号,设置超时
timeout := time.After(5 * time.Second)
for {
select {
case q := <-quit:
fmt.Println("收到退出信号:", q) // 收到退出信号: quit
return
case t := <-timeout:
fmt.Println("超时了:", t)
return
default:
fmt.Println("工作中...")
time.Sleep(500 * time.Millisecond)
}
}
}
|
非阻塞不是"不等待”,而是"等待的时候顺便干点别的"。这就像你在等泡面泡好的时候,顺便刷会儿抖音——你没有真的"不等待",只是把等待的时间利用起来了。
10.2.3 随机选择
随机选择是什么?
随机选择是 Go 的 select 最"玄学"的一个特性——当多个 case 同时准备好时,select 会随机挑选一个来执行,而不是按照代码顺序"从头到尾"。
这意味着什么?意味着 select 不是"先到先得",而是"公平抓阄"。每个准备好的 case,被选中的概率是均等的。
这个特性看起来不起眼,但它是 Go 实现"公平调度"的关键——如果你不用随机选择,多个通道都 ready 时,代码总是从头到脚执行第一个,这就会导致某些通道永远得不到服务(饿死了)。
随机选择的例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| package main
import "fmt"
func main() {
// 创建两个通道
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
// 两个通道同时准备好
ch1 <- "A"
ch2 <- "B"
// 执行多次,看看随机性
for i := 0; i < 10; i++ {
// 先清空,再重新塞
<-ch1
<-ch2
ch1 <- "A"
ch2 <- "B"
select {
case val := <-ch1:
fmt.Printf("第 %d 次: ch1 -> %s\n", i+1, val)
case val := <-ch2:
fmt.Printf("第 %d 次: ch2 -> %s\n", i+1, val)
}
}
}
|
随机选择就像你玩"石头剪刀布"——不是你想要什么就能出什么,而是系统帮你随机选。有时候你明明想出石头,结果出了布;有时候你想出剪刀,结果出了石头。这就是命,select 也是这么说的。
随机选择的应用:负载均衡
随机选择最经典的应用就是负载均衡——把任务随机分发给多个 worker,保证每个 worker 被选中的概率均等。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| package main
import "fmt"
func main() {
// 模拟三个 worker 通道
workers := make([]chan int, 3)
for i := 0; i < 3; i++ {
workers[i] = make(chan int, 1)
}
// 分发 10 个任务
for taskID := 1; taskID <= 10; taskID++ {
select {
case workers[0] <- taskID:
fmt.Printf("任务 %d -> worker 0\n", taskID)
case workers[1] <- taskID:
fmt.Printf("任务 %d -> worker 1\n", taskID)
case workers[2] <- taskID:
fmt.Printf("任务 %d -> worker 2\n", taskID)
}
}
}
|
负载均衡就像分糖果——你有三碗糖,要分给十个小孩。你不能每次都往第一个碗里塞(那第二个和第三个碗就太闲了),也不能每次都往最后一个碗里塞(那前两个碗就要造反了)。正确的做法是:随机选一个碗,往里塞一颗。这样大家才都服你。
为什么需要随机而不是顺序?
假设你写了一个爬虫,同时开三个协程下载三个网站的内容,结果网站 A 响应快,网站 B 和 C 响应慢。如果你不用 select,而是每次都先检查 A、再检查 B、再检查 C,那每次都是 A 被选中,B 和 C 永远没有机会——这就是"饥饿"。
有了 select 的随机选择,每个通道被选中的概率都是 1/3(假设它们的 ready 时间差不多),这样就不会有"饿死"的问题。
select 的随机选择是 Go 语言的"民主制度"——不是按资排辈,不是先来后到,而是大家都有平等的机会。这就是为什么 Go 的并发模型比很多其他语言更"公平"。
10.2.4 通道就绪检测
通道就绪检测是什么?
通道就绪检测是 select 的一个高级用法——用它来检测通道是否"准备好了"(即是否可以进行非阻塞的发送或接收操作)。这就像你站在停车场入口,检测栏杆是否抬起(通道是否 ready)。
为什么需要检测?
在某些场景下,你可能想知道"通道现在能不能用",但又不想真的去操作它。比如:
- 你想看看队列是不是空的,但不想取走元素
- 你想看看缓冲区有没有空间,但不想真的塞进去
- 你想知道通道是否关闭了,但不想接收零值
这时候,就绪检测就派上用场了。
非阻塞接收检测
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| package main
import "fmt"
func main() {
ch := make(chan string, 1)
// 检测是否能非阻塞接收
select {
case <-ch:
fmt.Println("ch 有数据,可以接收")
default:
fmt.Println("ch 没有数据,不能接收") // ch 没有数据,不能接收
}
// 塞一个值
ch <- "有数据了"
// 再次检测
select {
case <-ch:
fmt.Println("ch 有数据,可以接收") // ch 有数据,可以接收
default:
fmt.Println("ch 没有数据,不能接收")
}
}
|
非阻塞发送检测
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| package main
import "fmt"
func main() {
ch := make(chan string, 1)
// 检测是否能非阻塞发送
select {
case ch <- "第一条":
fmt.Println("发送成功,可以发送") // 发送成功,可以发送
default:
fmt.Println("通道满了,不能发送")
}
// 再塞一个,填满缓冲区
ch <- "第二条"
// 再次检测
select {
case ch <- "第三条":
fmt.Println("发送成功")
default:
fmt.Println("通道满了,不能发送") // 通道满了,不能发送
}
}
|
检测通道是否关闭
通道关闭后,接收操作会立即返回零值。我们可以用 select 检测通道是否关闭:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| package main
import "fmt"
func main() {
// 创建一个带缓冲的通道,并关闭它
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
// 检测通道是否关闭(通过接收是否立即返回)
for {
select {
case val, ok := <-ch:
if !ok {
fmt.Println("通道已关闭,收到零值:", val) // 通道已关闭,收到零值: 0
return
}
fmt.Println("收到:", val) // 收到: 10 // 收到: 20
}
}
}
|
多通道就绪检测
同时检测多个通道的状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| package main
import "fmt"
func main() {
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch3 := make(chan string, 1)
// ch1 有数据,ch2 和 ch3 是空的
ch1 <- "有数据"
fmt.Println("=== 通道就绪检测 ===")
select {
case <-ch1:
fmt.Println("ch1 可以接收") // ch1 可以接收
default:
fmt.Println("ch1 不能接收")
}
select {
case <-ch2:
fmt.Println("ch2 可以接收")
default:
fmt.Println("ch2 不能接收") // ch2 不能接收
}
select {
case <-ch3:
fmt.Println("ch3 可以接收")
default:
fmt.Println("ch3 不能接收") // ch3 不能接收
}
}
|
通道就绪检测就像你在停车场门口看栏杆是否抬起。如果栏杆抬起了(通道 ready),你就进去(接收/发送);如果栏杆没抬起(通道 not ready),你就看旁边的电子屏显示"剩余车位:0"(执行 default)。这个检测不消耗你的时间(不阻塞),只是"看一眼就走"。
就绪检测 vs 实际读写
注意,就绪检测只是"看了一眼",并不是真的读写。如果你检测到可以接收,然后真的去接收,值就被取走了——下一次检测,结果可能就不同了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| package main
import "fmt"
func main() {
ch := make(chan string, 1)
ch <- "数据"
// 检测:可以接收
select {
case <-ch:
fmt.Println("第一次检测:收到数据")
default:
fmt.Println("第一次检测:没有数据")
}
// 再次检测:通道已经空了
select {
case <-ch:
fmt.Println("第二次检测:收到数据")
default:
fmt.Println("第二次检测:没有数据") // 第二次检测:没有数据
}
}
|
就绪检测就像你排队买奶茶——你先探头看了看队伍有多长(检测),发现队伍很短(通道 ready),于是你决定去买(实际读写)。但等你真正排到柜台前点完单,发现后面又排了十个人(通道 not ready 了)——此一时彼一时,检测和实际执行是两码事。
10.3 select 模式
10.3.1 多路复用
多路复用是什么?
多路复用(Multiplexing)是 select 最核心的应用场景,意思是用一个 select 同时管理多个通道,让一个 goroutine 可以处理多个通道的数据。
这就像你是一个网吧网管,同时盯着十台电脑的屏幕。如果某一台电脑有顾客举手(通道 ready),你就过去服务那一台。不用每台电脑配一个网管(goroutine),一个网管(goroutine)就够了,大大的节省了人力成本。
多路复用的基本模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| package main
import "fmt"
func main() {
// 创建多个通道
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch3 := make(chan string, 1)
// 开启三个"数据源"协程
go func() {
ch1 <- "来自 ch1 的消息"
}()
go func() {
ch2 <- "来自 ch2 的消息"
}()
go func() {
ch3 <- "来自 ch3 的消息"
}()
// 用一个 select 监听三个通道
// 谁先来数据,就处理谁
for i := 0; i < 3; i++ {
select {
case msg1 := <-ch1:
fmt.Println("收到 ch1:", msg1) // 收到 ch1: 来自 ch1 的消息
case msg2 := <-ch2:
fmt.Println("收到 ch2:", msg2) // 收到 ch2: 来自 ch2 的消息
case msg3 := <-ch3:
fmt.Println("收到 ch3:", msg3) // 收到 ch3: 来自 ch3 的消息
}
}
}
|
多路复用就像你在一个有很多外卖柜的小区做物业。你不用每个外卖柜配一个人盯着,而是坐在监控室里,同时看着所有外卖柜的屏幕——哪个柜子亮了灯(有数据),你就去处理哪个。这样一个人就能管几十个柜子,效率极高。
实际应用:WebSocket 多路复用
在真实的 Web 服务中,select 多路复用常用于同时处理多个客户端请求:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| package main
import "fmt"
func main() {
// 模拟三个客户端连接
client1 := make(chan string, 1)
client2 := make(chan string, 1)
client3 := make(chan string, 1)
// 模拟服务器接收来自不同客户端的消息
go func() { client1 <- "客户端1: 你好" }()
go func() { client2 <- "客户端2: 在吗" }()
go func() { client3 <- "客户端3: 发个文件" }()
// 服务器用一个 select 监听所有客户端
// 每次处理一个消息,处理完后继续监听
messageCount := 0
for messageCount < 3 {
select {
case msg1 := <-client1:
fmt.Println("服务器收到:", msg1) // 服务器收到: 客户端1: 你好
messageCount++
case msg2 := <-client2:
fmt.Println("服务器收到:", msg2) // 服务器收到: 客户端2: 在吗
messageCount++
case msg3 := <-client3:
fmt.Println("服务器收到:", msg3) // 服务器收到: 客户端3: 发个文件
messageCount++
}
}
fmt.Println("所有消息处理完毕")
}
|
带退出信号的多路复用
实际应用中,通常会加一个退出信号通道:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| package main
import "fmt"
import "time"
func main() {
// 工作通道和退出通道
jobCh := make(chan string, 1)
quitCh := make(chan string)
// 模拟收到一个任务
go func() {
time.Sleep(100 * time.Millisecond)
jobCh <- "任务处理中..."
}()
// 模拟 500ms 后收到退出信号
go func() {
time.Sleep(500 * time.Millisecond)
quitCh <- "quit"
}()
// 多路复用:监听任务和退出信号
for {
select {
case job := <-jobCh:
fmt.Println("处理任务:", job) // 处理任务: 任务处理中...
case q := <-quitCh:
fmt.Println("收到退出信号,退出循环:", q) // 收到退出信号,退出循环: quit
return
}
}
}
|
多路复用 + 退出信号是 Go 并发编程的"黄金搭档"。想象你是一个酒店前台,同时管理着客房服务(jobCh)和退房(quitCh)两个队列。你不用开两个窗口分别排队,而是开一个统一的叫号系统——谁叫号你就服务谁,服务完了继续叫下一个。这样效率最高,资源最少。
无限多路复用:动态添加通道
有时候通道数量是动态的(比如处理 n 个 WebSocket 连接)。这时候可以用一个 map 来管理通道:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| package main
import "fmt"
import "time"
func main() {
// 动态管理的通道映射
channels := make(map[string]chan string)
channels["ch1"] = make(chan string, 1)
channels["ch2"] = make(chan string, 1)
channels["ch3"] = make(chan string, 1)
// 模拟发送消息
go func() { channels["ch1"] <- "消息A" }()
go func() { channels["ch3"] <- "消息C" }()
// 用 select 动态监听
processed := 0
for processed < 2 {
for name, ch := range channels {
select {
case msg := <-ch:
fmt.Printf("收到 %s 的消息: %s\n", name, msg) // 收到 ch1 的消息: 消息A
processed++
default:
}
}
time.Sleep(10 * time.Millisecond)
}
}
|
动态多路复用就像你开了一个外卖平台。一开始只有三家店入驻,过两天又来了五家,再过一个月有一家倒闭了。你的系统要能动态处理这些变化——有新店就加进去,有店关门就删掉。用 map 管理通道,然后用 select 轮询,就是这种场景的标准做法。
10.3.2 超时控制
超时控制是什么?
超时控制(Timeout)是 select 最常用的模式之一。它的核心思想是:“我可以等,但不能无限等”。就像你点外卖,平台说"预计 45 分钟送达",超过 45 分钟你就可以申请退款了——不会再傻等下去。
select 的超时控制就是基于这个原理:用 time.After 创建一个"超时通道",如果在超时时间内没有收到其他通道的数据,就执行超时分支。
10.3.2.1 time.After
time.After 是什么?
time.After(d) 会创建一个通道,这个通道会在指定时间 d 后收到一个时间值。把它放进 select 里,就相当于设置了一个"闹钟"——超时了就响。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| package main
import "fmt"
import "time"
func main() {
ch := make(chan string, 1)
// 开启一个 goroutine,3秒后发送数据
go func() {
time.Sleep(3 * time.Second)
ch <- "数据终于来了!"
}()
fmt.Println("开始等待,最长等 2 秒...") // 开始等待,最长等 2 秒...
// 设置超时:2秒
select {
case msg := <-ch:
fmt.Println("收到:", msg)
case <-time.After(2 * time.Second):
fmt.Println("超时了!不等了!") // 超时了!不等了!
}
fmt.Println("程序继续执行")
}
|
time.After 就像你煮泡面时定的闹钟。“3 分钟泡好”,但你不可能每隔 10 秒钟就去看一眼面熟了没有。你只需要定一个 3 分钟的闹钟,闹钟响了你再去检查——面熟了开吃,没熟就继续等或者直接吃硬的。
time.After 的坑:内存泄漏
time.After 有一个潜在的内存泄漏问题——它返回的通道不会被 GC 回收,直到超时为止。如果你创建一个很长的超时(比如 24 小时),而在这期间不断创建新的 time.After,就会积累大量"等待中的 timer",最终可能导致内存耗尽。
解决方法是使用 time.Timer 或者 time.Ticker,或者在不需要 select 时主动停止 timer。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| package main
import "fmt"
import "time"
func main() {
// 安全的超时控制:使用带缓冲的通道
ch := make(chan string, 1)
// 创建 timer
timer := time.NewTimer(2 * time.Second)
defer timer.Stop() // 显式停止,避免泄漏
go func() {
time.Sleep(5 * time.Second) // 5秒后才发送数据
ch <- "数据来了"
}()
select {
case msg := <-ch:
fmt.Println("收到:", msg)
case <-timer.C:
fmt.Println("超时了") // 超时了
}
}
|
time.After 的内存泄漏就像是你订了很多外卖,每份都要等 24 小时送达。你等不及了取消订单,但骑手还在路上跑,这些"在路上"的骑手就是泄漏的内存——他们还在消耗资源,但你已经不需要他们了。
10.3.2.2 context 超时
context 超时是什么?
除了 time.After,Go 还提供了 context.WithTimeout 来实现超时控制。context 是 Go 1.7 引入的标准库,专门用于跨 API 和进程边界的请求作用域,尤其是取消信号和超时。
使用 context 的好处是:你可以把超时控制传递给下层函数,让下层函数也能感知到超时并提前退出。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| package main
import (
"context"
"fmt"
"time"
)
func main() {
ch := make(chan string, 1)
// 创建带有 2 秒超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // 防止 context 泄漏
go func() {
time.Sleep(5 * time.Second) // 5秒后才发送数据
ch <- "数据终于来了!"
}()
fmt.Println("开始等待,最长等 2 秒...") // 开始等待,最长等 2 秒...
select {
case msg := <-ch:
fmt.Println("收到:", msg)
case <-ctx.Done():
// ctx 超时或被取消时会执行这里
fmt.Println("超时或取消了:", ctx.Err()) // 超时或取消了: context deadline exceeded
}
fmt.Println("程序继续执行")
}
|
context 超时的优势
- 可传递:可以传递给下层函数,下层函数也能感知超时
- 可取消:不仅能超时取消,还能主动取消
- 更灵活:可以绑定多个通道(用
select + <-ctx.Done())
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| package main
import (
"context"
"fmt"
"time"
)
func main() {
// 模拟一个数据库查询
queryCh := make(chan string, 1)
// 创建带有 2 秒超时的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
// 模拟数据库查询(3秒后才返回)
go func() {
time.Sleep(3 * time.Second)
queryCh <- "查询结果:Hello World"
}()
fmt.Println("开始查询,最长等 2 秒...") // 开始查询,最长等 2 秒...
select {
case result := <-queryCh:
fmt.Println("查询成功:", result)
case <-ctx.Done():
fmt.Println("查询超时或取消:", ctx.Err()) // 查询超时或取消: context deadline exceeded
}
}
|
context 超时就像你给外卖小哥设定了一个最晚送达时间。如果超过这个时间还没送到,平台会自动给你退款(执行 ctx.Done 分支)。而且这个时间限制会传递给配送系统沿途的所有人——总调度知道超时了,附近的骑手也知道超时了,大家一起想办法解决,而不是只有你一个人在傻等。
time.After vs context 超时:选哪个?
| 特性 | time.After | context 超时 |
|---|
| 传递性 | 不能传递 | 可以传递给下层函数 |
| 主动取消 | 不支持 | 支持(调用 cancel) |
| 代码简洁性 | 简单 | 稍复杂 |
| 适用场景 | 简单超时 | 复杂超时、取消场景 |
简单场景用 time.After,复杂场景用 context。就像你买东西——如果只是买瓶水,用现金就够了;如果是大宗交易(买房、买车),你肯定要用银行卡(context)——因为需要记录、可追溯、能取消。
10.3.3 默认操作
默认操作是什么?
默认操作就是 select 里的 default 分支。当所有通道都没准备好时,default 就会"上场"——它不会阻塞,而是直接执行。
默认操作就像你等公交车时的"Plan B":等太久就不等了,直接打车走人。
默认操作的三种用法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
| package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string, 1)
// 用法一:非阻塞接收
select {
case msg := <-ch:
fmt.Println("收到:", msg)
default:
fmt.Println("通道没数据,但我不想等") // 通道没数据,但我不想等
}
// 用法二:非阻塞发送
ch2 := make(chan string, 1)
ch2 <- "第一条"
select {
case ch2 <- "第二条":
fmt.Println("发送成功")
default:
fmt.Println("缓冲区满了,不等了") // 缓冲区满了,不等了
}
// 用法三:检测通道状态(不实际读写)
ch3 := make(chan int, 1)
ch3 <- 100
select {
case <-ch3:
fmt.Println("ch3 有数据")
default:
fmt.Println("ch3 没有数据")
}
select {
case ch3 <- 200:
fmt.Println("ch3 发送成功")
default:
fmt.Println("ch3 缓冲区满了")
}
}
|
默认操作的典型应用:轮询
有时候你需要"轮询"某个条件是否满足:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| package main
import (
"fmt"
"time"
)
func main() {
done := make(chan bool)
// 模拟一个耗时任务,1秒后完成
go func() {
time.Sleep(1 * time.Second)
done <- true
}()
// 轮询检查任务是否完成
for {
select {
case <-done:
fmt.Println("任务完成!") // 任务完成!
return
default:
fmt.Println("任务还在跑,继续等...")
time.Sleep(200 * time.Millisecond)
}
}
}
|
默认操作的轮询就像你在等快递。你不会每秒钟下楼去看一次(太累了),而是"等一会儿,看一眼,再等一会儿"。这样既能做一些其他事情(fmt.Println),又不会完全错过快递的到来。
默认操作的坑:忙等待
注意!如果 select 里只有 default,没有其他 case,那 default 会立即执行,形成一个忙等待(busy-waiting)——CPU 100%,但什么都没干。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| package main
import (
"fmt"
"time"
)
func main() {
// 危险!这是忙等待,CPU 会飙升
//go func() {
// for {
// select {
// default:
// // 什么都不做,但 CPU 100%
// }
// }
//}()
// 正确的做法:加一个 sleep
count := 0
for count < 5 {
select {
default:
count++
fmt.Printf("忙等待 %d 次\n", count) // 忙等待 1 次
time.Sleep(100 * time.Millisecond)
}
}
}
|
忙等待就像你在等红灯,但你的脚不停地抖动——不是真的在运动,只是白白消耗能量。正确的做法是:等红灯的时候把脚放好(sleep),不要抖。抖得再厉害,绿灯来了你还是得等脚停下来才能走。
10.3.4 与 for 配合
与 for 配合是什么?
select 很少单独使用,更多的时候是放在 for 循环里——持续监听多个通道,直到某个条件满足才退出。
这就像你是一个酒店的 24 小时前台:不是处理完一个客人就关门,而是一直开着,随时准备服务下一个客人。
10.3.4.1 循环监听
循环监听是什么?
循环监听就是用 for + select 持续监听一个或多个通道,直到收到退出信号或者所有通道都关闭。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| package main
import "fmt"
import "time"
func main() {
// 创建工作通道和退出通道
jobCh := make(chan string, 1)
quitCh := make(chan string)
// 模拟发送三个任务
go func() {
for i := 1; i <= 3; i++ {
jobCh <- fmt.Sprintf("任务 %d", i)
time.Sleep(200 * time.Millisecond)
}
quitCh <- "quit"
}()
fmt.Println("开始处理任务...") // 开始处理任务...
// 循环监听,直到收到 quit 信号
for {
select {
case job := <-jobCh:
fmt.Println("处理:", job) // 处理: 任务 1
case q := <-quitCh:
fmt.Println("收到退出信号:", q) // 收到退出信号: quit
return
}
}
}
|
循环监听的常见模式:for + select + channel
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| package main
import "fmt"
import "time"
func main() {
// 创建多个通道
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
done := make(chan string)
// 模拟数据源
go func() {
for i := 1; i <= 5; i++ {
ch1 <- fmt.Sprintf("ch1-数据-%d", i)
time.Sleep(100 * time.Millisecond)
}
}()
go func() {
for i := 1; i <= 5; i++ {
ch2 <- fmt.Sprintf("ch2-数据-%d", i)
time.Sleep(150 * time.Millisecond)
}
}()
// 循环监听
count := 0
for count < 10 {
select {
case msg1 := <-ch1:
fmt.Println("收到 ch1:", msg1) // 收到 ch1: ch1-数据-1
count++
case msg2 := <-ch2:
fmt.Println("收到 ch2:", msg2) // 收到 ch2: ch2-数据-1
count++
case <-done:
return
}
}
fmt.Println("处理完毕,共处理 10 条数据")
}
|
循环监听就像你开了一个 24 小时便利店。你不是在门口等一个顾客走了就关门,而是一直开着,谁进来就服务谁,直到打烊时间到了(收到 quit 信号),才关门下班。
10.3.4.2 退出机制
退出机制是什么?
退出机制是循环监听中最重要的一环——如何优雅地退出 for + select 循环。常见的退出方式有:
- 通过 quit 通道退出
- 通过关闭通道退出
- 通过 context 退出
方式一:通过 quit 通道退出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| package main
import "fmt"
import "time"
func main() {
jobCh := make(chan string)
quitCh := make(chan string)
// 模拟工作者
go func() {
for {
select {
case job := <-jobCh:
fmt.Println("处理:", job) // 处理: 任务A
case <-quitCh:
fmt.Println("收到 quit,退出循环")
return
}
}
}()
// 发送任务
jobCh <- "任务A"
jobCh <- "任务B"
jobCh <- "任务C"
time.Sleep(100 * time.Millisecond)
// 发送退出信号
quitCh <- "quit"
fmt.Println("主 goroutine 结束")
}
|
方式二:通过关闭通道退出
关闭通道后,所有阻塞在接收操作的 goroutine 会立即返回零值。这是一个更优雅的退出方式——不需要单独发退出信号,关闭通道就够了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| package main
import "fmt"
import "time"
func main() {
ch := make(chan string)
// 模拟数据生产者
go func() {
for i := 1; i <= 3; i++ {
ch <- fmt.Sprintf("数据 %d", i)
time.Sleep(100 * time.Millisecond)
}
close(ch) // 关闭通道
}()
fmt.Println("开始接收数据...") // 开始接收数据...
// 循环接收,直到通道关闭
for msg := range ch {
fmt.Println("收到:", msg) // 收到: 数据 1
}
fmt.Println("通道已关闭,循环结束")
}
|
方式三:通过 context 退出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan string)
// 模拟工作者
go func() {
for {
select {
case <-ctx.Done():
fmt.Println("收到 ctx 取消信号,退出")
return
case msg := <-ch:
fmt.Println("收到:", msg) // 收到: 测试消息
}
}
}()
// 发送消息
ch <- "测试消息"
time.Sleep(100 * time.Millisecond)
// 取消 context
cancel()
time.Sleep(100 * time.Millisecond)
fmt.Println("主 goroutine 结束")
}
|
三种退出方式的对比
| 方式 | 优点 | 缺点 |
|---|
| quit 通道 | 简单直观 | 需要额外的通道 |
| 关闭通道 | 优雅,不需要额外信号 | 只能一次性关闭 |
| context | 可传递,可超时取消 | 代码稍复杂 |
退出机制就像你关店——你可以选择拉下卷闸门(关闭通道),也可以选择对着对讲机说"大家下班吧"(quit 通道),还可以选择设定一个营业时间(context 超时)。三种方式都能关店,但适用场景不同。拉下卷闸门最简单,但如果有人还在里面买东西(还有数据在传),可能会丢失;对讲机通知比较温和,但如果你对讲机坏了(通道没关闭),人家不知道要下班;营业时间最规范,但设置起来麻烦一点。
本章小结
本章我们深入探讨了 Go 语言的 select 语句,这是 Go 并发编程中最独特、最强大的特性之一。
核心要点回顾:
select 是通道的"红娘":它专门用于在多个通道操作中选择一个执行。如果没有通道准备好,它就阻塞;如果有多个同时准备好,它就随机选一个。
发送 case vs 接收 case:case ch <- value 是发送操作,case val := <-ch 是接收操作。发送可能阻塞(缓冲区满),接收也可能阻塞(通道空)。
default 分支:select 的"紧急出口",当所有通道都没准备好时,执行 default 而不是傻等。没有 default 就会阻塞。
阻塞语义 vs 非阻塞语义:阻塞 = 老实等着,非阻塞 = 等不到就走人。根据场景选择合适的模式。
随机选择:当多个 case 同时 ready 时,select 会随机选一个执行。这是 Go 实现公平调度的关键。
通道就绪检测:可以用 select 检测通道是否准备好,而不实际进行读写操作。
多路复用:这是 select 最核心的应用——用一个 goroutine 监听多个通道,让一个 goroutine 处理多个并发任务。
超时控制:用 time.After 或 context.WithTimeout 实现超时,避免永久阻塞。
for + select 组合:实际应用中,select 几乎总是放在 for 循环里,持续监听直到收到退出信号。
优雅退出:可以通过 quit 通道、关闭通道、或 context 三种方式优雅退出循环。
一句话总结: select 是 Go 并发世界的"交通枢纽",它让你的 goroutine 能够优雅地在多个通道之间切换,不会死等,也不会错过任何一个准备好的通道。学会用 select,你就掌握了 Go 并发编程的精髓。
继续下一章前,先问自己几个问题:
select 和 switch 有什么区别?- 为什么多个
case 同时 ready 时要随机选择? time.After 和 context.WithTimeout 各适合什么场景?- 如何用
select 实现一个简单的超时控制?
如果能回答上来,说明你已经 get 到了 select 的精髓!