第10章 选择语句

第10章 选择语句

10.1 select 语句

10.1.1 select 语法

想象这样一个场景:你是一个婚礼司仪,面前站着三个伴郎,每个伴郎手里都攥着给新娘的表白信。但新娘只有一个,而且她有"选择困难症晚期"——她不想一个个问"你有什么要说的吗",她只想知道:“谁先开口,我就嫁给谁。”

在 Go 的世界里,select 就是那个"谁先开口就选谁"的婚礼司仪。只不过它主持的不是婚礼,而是通道(channel)的读写竞争

select 是 Go 语言独创的控制结构,专门用来在多个通道操作中"选一个执行"。如果你写过其他语言,你会发现它们根本没有这个概念——因为其他语言没有通道这个玩意儿。select 就是通道的专属红娘,专门帮通道配对。

select 的核心规则只有三条:

  1. 哪个通道先准备好,就选哪个
  2. 如果多个通道同时准备好,随机选一个(这就是 Go 的"公平")
  3. 如果所有通道都没准备好,且没有 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?

主要有两种使用场景:

  1. 多通道监听:同时盯着十几个通道,哪个有数据就处理哪个
  2. 负载均衡:同时给三个 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 准备好了。

这就像你打电话给客服,电话那头说"您的来电对我们很重要,请稍等",然后你就真的"稍等"了——干等着,什么也干不了,直到客服说"您好,请问有什么可以帮您"。

没有 defaultselect,就是一个"老老实实等到底"的主儿。

阻塞 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 main​import “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,可能需要重新设计一下架构——也许用通道关闭来退出循环会更优雅,别当舔狗了。

非阻塞语义的应用场景

  1. 防止永久阻塞:在循环中使用 select + default,保证程序不会被卡死
  2. 实现超时机制default 配合 time.After 实现超时
  3. 优雅退出:监听退出信号,不阻塞主 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 超时的优势

  1. 可传递:可以传递给下层函数,下层函数也能感知超时
  2. 可取消:不仅能超时取消,还能主动取消
  3. 更灵活:可以绑定多个通道(用 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.Aftercontext 超时
传递性不能传递可以传递给下层函数
主动取消不支持支持(调用 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 循环。常见的退出方式有:

  1. 通过 quit 通道退出
  2. 通过关闭通道退出
  3. 通过 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 并发编程中最独特、最强大的特性之一。

核心要点回顾:

  1. select 是通道的"红娘":它专门用于在多个通道操作中选择一个执行。如果没有通道准备好,它就阻塞;如果有多个同时准备好,它就随机选一个。

  2. 发送 case vs 接收 casecase ch <- value 是发送操作,case val := <-ch 是接收操作。发送可能阻塞(缓冲区满),接收也可能阻塞(通道空)。

  3. default 分支select 的"紧急出口",当所有通道都没准备好时,执行 default 而不是傻等。没有 default 就会阻塞。

  4. 阻塞语义 vs 非阻塞语义:阻塞 = 老实等着,非阻塞 = 等不到就走人。根据场景选择合适的模式。

  5. 随机选择:当多个 case 同时 ready 时,select随机选一个执行。这是 Go 实现公平调度的关键。

  6. 通道就绪检测:可以用 select 检测通道是否准备好,而不实际进行读写操作。

  7. 多路复用:这是 select 最核心的应用——用一个 goroutine 监听多个通道,让一个 goroutine 处理多个并发任务。

  8. 超时控制:用 time.Aftercontext.WithTimeout 实现超时,避免永久阻塞。

  9. for + select 组合:实际应用中,select 几乎总是放在 for 循环里,持续监听直到收到退出信号。

  10. 优雅退出:可以通过 quit 通道、关闭通道、或 context 三种方式优雅退出循环。

一句话总结: select 是 Go 并发世界的"交通枢纽",它让你的 goroutine 能够优雅地在多个通道之间切换,不会死等,也不会错过任何一个准备好的通道。学会用 select,你就掌握了 Go 并发编程的精髓。

继续下一章前,先问自己几个问题:

  • selectswitch 有什么区别?
  • 为什么多个 case 同时 ready 时要随机选择?
  • time.Aftercontext.WithTimeout 各适合什么场景?
  • 如何用 select 实现一个简单的超时控制?

如果能回答上来,说明你已经 get 到了 select 的精髓!

最后修改 March 20, 2026: 新增Go基础部分 (8cce995)