Go select 详解与可运行示例

一、本质:select 是什么

select 是 Go 里唯一的多路 channel 操作原语。它让一个 goroutine 同时等待多个 channel 操作,哪个先就绪就走哪个。没有它,你要么串行阻塞在单个 channel 上,要么自己开 goroutine 轮询。

一句话定位:switch 是对值的分支,select 是对通道就绪状态的分支。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
select {
case v := <-ch1:    // ch1 有数据可读
    handle(v)
case ch2 <- x:      // ch2 有接收者就绪
    // 发送成功
case <-time.After(time.Second):  // 超时
    // ...
default:            // 所有 case 都没就绪
    // 不阻塞,立即走这里
}

二、语法与语义规则

规则 1:case 必须是 channel 操作

合法:<-ch、ch <- v、v, ok := <-ch。 非法:普通表达式、函数调用(case f(): 不行)、常量比较。

规则 2:多个 case 同时就绪时,伪随机选一个

1
2
3
4
select {
case <-ch1:
case <-ch2:
}

两个都就绪时,runtime 用 cheaprandn 随机选。这是有意设计,避免固定顺序导致某个 case 永远饿死。所以不要依赖 select 的分支顺序写业务逻辑。

规则 3:没有 default 时,全部不就绪则阻塞当前 goroutine

阻塞期间 goroutine 进 _Gwaiting,会让出 P,M 去跑别的 G。不占 CPU。

规则 4:有 default 时,全部不就绪则立即执行 default

default 让 select 变成非阻塞的。这是"尝试一次,不行就算了"的标准写法。

规则 5:select {} 永久阻塞

空 select 永远阻塞,用于让 main goroutine 永不退出(配合其他 goroutine 干活):

1
2
3
4
func main() {
    go worker()
    select {}   // 永久阻塞,不会死锁 panic(因为有 worker 在跑)
}

但如果没有任何其他 goroutine,select {} 会触发 fatal error: all goroutines are asleep - deadlock。

规则 6:nil channel 上的 case 永远不就绪

1
2
3
4
5
var ch chan int   // nil
select {
case <-ch:        // 这个 case 永远不会被选中
    ...
}

这是禁用某个分支的利器(后文详述)。

规则 7:break 跳出的是 select,不是外层 for

1
2
3
4
5
6
for {
    select {
    case <-done:
        break      // ⚠️ 只跳出 select,for 继续转
    }
}

要用 return、goto、或者带标签的 break loop 才能跳出循环。


三、运行时机制:selectgo

编译器把 select 编译成对 runtime.selectgo 的调用。核心流程:

selectgo(cases):
  ① 生成 pollorder(随机打乱的 case 顺序)
  ② 生成 lockorder(按 channel 地址排序,防死锁)
  ③ 第一轮:按 pollorder 遍历,逐个尝试非阻塞收发
      ├─ 有就绪的 → 直接执行该 case,返回
      └─ 没有 → 继续
  ④ 有 default → 执行 default,返回
  ⑤ 没有 default → 把所有 case 的 sudog 挂到各 channel 的等待队列上
      → gopark() 阻塞
  ⑥ 被某个 channel 的就绪事件唤醒 → 从队列摘除所有 sudog → 返回被选中的 case

两个关键设计:

  1. pollorder 随机:保证公平,避免固定顺序饥饿
  2. lockorder 按地址排序:多个 case 涉及多个 channel 的锁时,统一按地址顺序加锁,避免两个 goroutine 以相反顺序加锁导致死锁

case 数量上限:select 最多 65536 个 case(uint16 索引),实际中远用不到,但要知道有这个上限。

性能:无就绪 case 且需要阻塞时,每个 case 都要分配 sudog、挂队列,开销明显高于单个 channel 操作。select 适合"少数几个 channel",不适合"海量 channel 轮询"。


四、经典模式(每个都可直接 go run)

示例 1:基础多路复用

 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() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(100 * time.Millisecond)
		ch1 <- "来自 ch1"
	}()
	go func() {
		time.Sleep(200 * time.Millisecond)
		ch2 <- "来自 ch2"
	}()

	for i := 0; i < 2; i++ {
		select {
		case v := <-ch1:
			fmt.Println("收到:", v)
		case v := <-ch2:
			fmt.Println("收到:", v)
		}
	}
}

预期输出:

收到: 来自 ch1
收到: 来自 ch2

要点:select 同时等两个 channel,谁先到处理谁。ch1 先发(100ms),所以先走 ch1 分支。


示例 2:超时控制(最常用)

 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"
	"time"
)

func slowOp() <-chan string {
	ch := make(chan string)
	go func() {
		time.Sleep(2 * time.Second)   // 模拟慢操作
		ch <- "结果"
	}()
	return ch
}

func main() {
	start := time.Now()

	select {
	case v := <-slowOp():
		fmt.Println("成功:", v, "耗时:", time.Since(start))
	case <-time.After(500 * time.Millisecond):
		fmt.Println("超时! 耗时:", time.Since(start))
	}
}

预期输出:

超时! 耗时: 500.xxxms

要点:

  • time.After(d) 返回一个 channel,d 之后收到一个值
  • 慢操作 2 秒,超时 500ms,select 走超时分支
  • 注意:time.After 底层 timer 在触发前不会释放,高频循环里用 time.NewTimer + Reset 更省资源(后文陷阱详述)

示例 3:非阻塞收发(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
39
package main

import "fmt"

func main() {
	ch := make(chan int, 1)

	// 第一次:channel 空,发送成功
	select {
	case ch <- 1:
		fmt.Println("发送 1 成功")
	default:
		fmt.Println("发送 1 失败(通道满)")
	}

	// 第二次:channel 已满(cap=1),发送走 default
	select {
	case ch <- 2:
		fmt.Println("发送 2 成功")
	default:
		fmt.Println("发送 2 失败(通道满)")
	}

	// 接收:有数据,成功
	select {
	case v := <-ch:
		fmt.Println("接收到:", v)
	default:
		fmt.Println("通道空,无数据")
	}

	// 再次接收:空了,走 default
	select {
	case v := <-ch:
		fmt.Println("接收到:", v)
	default:
		fmt.Println("通道空,无数据")
	}
}

预期输出:

发送 1 成功
发送 2 失败(通道满)
接收到: 1
通道空,无数据

要点:default 让操作变成"尝试一次"——这就是"非阻塞发送/接收"的标准写法。


示例 4:优雅退出(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 worker(ctx context.Context, id int) {
	ticker := time.NewTicker(200 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Printf("worker %d 退出: %v\n", id, ctx.Err())
			return
		case <-ticker.C:
			fmt.Printf("worker %d 干活中...\n", id)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	for i := 1; i <= 3; i++ {
		go worker(ctx, i)
	}

	time.Sleep(700 * time.Millisecond)
	fmt.Println(">>> 发出取消信号")
	cancel()

	time.Sleep(500 * time.Millisecond)
	fmt.Println(">>> main 退出")
}

预期输出(时序大致如此):

worker 1 干活中...
worker 2 干活中...
worker 3 干活中...
worker 1 干活中...
worker 2 干活中...
worker 3 干活中...
worker 1 干活中...
worker 2 干活中...
worker 3 干活中...
>>> 发出取消信号
worker 1 退出: context canceled
worker 2 退出: context canceled
worker 3 退出: context canceled
>>> main 退出

要点:

  • ctx.Done() 是广播:cancel() 后所有 select 在这个 case 上的 goroutine 同时收到
  • 这是 Go 服务里标准退出模式:每个长任务都 select 监听 ctx.Done()
  • defer ticker.Stop() 防止资源泄漏

示例 5:用 nil 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
package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() { ch1 <- "ch1 数据" }()
	go func() { ch2 <- "ch2 数据" }()

	// 先启用两个通道
	c1, c2 := ch1, ch2

	for i := 0; i < 2; i++ {
		select {
		case v := <-c1:
			fmt.Println("处理:", v)
			c1 = nil   // 禁用 ch1 分支,后续 select 忽略它
		case v := <-c2:
			fmt.Println("处理:", v)
			c2 = nil
		}
	}
	fmt.Println("两个通道都已处理完毕")
}

预期输出:

处理: ch1 数据
处理: ch2 数据
两个通道都已处理完毕

要点:c1 = nil 之后,case v := <-c1 在 select 里永远不就绪——这是动态开关分支的标准技巧。常见于"关闭某个数据源后只从剩下的源读"的场景。


示例 6:优先级 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
36
37
38
39
40
41
42
43
44
45
package main

import (
	"fmt"
	"time"
)

func main() {
	high := make(chan string, 10)
	low := make(chan string, 10)

	// 塞入数据
	for i := 0; i < 3; i++ {
		high <- fmt.Sprintf("高优先级-%d", i)
	}
	for i := 0; i < 5; i++ {
		low <- fmt.Sprintf("低优先级-%d", i)
	}
	close(high)

	for i := 0; i < 8; i++ {
		// 第一层 select:优先尝试 high
		select {
		case v, ok := <-high:
			if ok {
				fmt.Println("处理:", v)
				continue
			}
			// high 已关闭,走下面
		default:
			// high 没就绪,落到第二层
		}

		// 第二层 select:处理 low 或等待 high
		select {
		case v, ok := <-high:
			if ok {
				fmt.Println("处理:", v)
			}
		case v := <-low:
			fmt.Println("处理:", v)
		}
		time.Sleep(10 * time.Millisecond)
	}
}

预期输出(前 3 条必为高优先级):

处理: 高优先级-0
处理: 高优先级-1
处理: 高优先级-2
处理: 低优先级-0
处理: 低优先级-1
处理: 低优先级-2
处理: 低优先级-3
处理: 低优先级-4

要点:select 本身不支持优先级,但可以用两层 select 模拟:第一层带 default 尝试高优先级,没就绪再进第二层等。代价是代码复杂,且默认分支有 CPU 空转风险——第二层里的 time.Sleep 就是为了缓解空转(生产代码里更推荐用 time.Ticker 或合理设置 default 的退出条件)。


示例 7: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
34
35
36
37
38
package main

import (
	"fmt"
	"time"
)

func main() {
	jobs := make(chan int, 5)
	done := make(chan struct{})

	// 消费者
	go func() {
		for {
			select {
			case j, ok := <-jobs:
				if !ok {
					fmt.Println("jobs 已关闭,消费者退出")
					return
				}
				fmt.Println("处理任务:", j)
			case <-done:
				fmt.Println("收到退出信号,消费者退出")
				return
			}
		}
	}()

	// 生产 3 个任务
	for i := 1; i <= 3; i++ {
		jobs <- i
	}

	time.Sleep(100 * time.Millisecond)
	close(done)   // 触发消费者退出
	time.Sleep(100 * time.Millisecond)
	fmt.Println("main 退出")
}

预期输出:

处理任务: 1
处理任务: 2
处理任务: 3
收到退出信号,消费者退出
main 退出

要点:

  • for { select { ... } } 是 Go 并发代码的基本骨架
  • 每个 case 都要有明确的退出路径(return 或 break loop)
  • 用 v, ok := <-ch 感知关闭
  • 不要用 break 跳出 for——break 只跳 select(前文规则 7)

示例 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
26
27
package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan int, 1)
	ch2 := make(chan int, 1)
	ch1 <- 1
	ch2 <- 2

	counts := map[int]int{}
	for i := 0; i < 1000; i++ {
		select {
		case v := <-ch1:
			counts[v]++
			ch1 <- 1
		case v := <-ch2:
			counts[v]++
			ch2 <- 2
		}
	}
	fmt.Printf("ch1 被选中 %d 次, ch2 被选中 %d 次\n", counts[1], counts[2])
	time.Sleep(10 * time.Millisecond)
}

预期输出(数字接近 500/500):

ch1 被选中 约500 次, ch2 被选中 约500 次

要点:两个 channel 始终有数据,select 随机选。跑多次会发现比例接近 1:1,但不是严格交替。不要依赖 select 的顺序写逻辑。


示例 9:合并多个 channel(fan-in)

 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"
	"sync"
	"time"
)

func producer(name string, n int) <-chan string {
	ch := make(chan string)
	go func() {
		defer close(ch)
		for i := 0; i < n; i++ {
			ch <- fmt.Sprintf("%s-%d", name, i)
			time.Sleep(50 * time.Millisecond)
		}
	}()
	return ch
}

func merge(chs ...<-chan string) <-chan string {
	out := make(chan string)
	var wg sync.WaitGroup
	for _, ch := range chs {
		wg.Add(1)
		go func(c <-chan string) {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}(ch)
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

func main() {
	a := producer("A", 3)
	b := producer("B", 3)

	for v := range merge(a, b) {
		fmt.Println("收到:", v)
	}
	fmt.Println("全部完成")
}

预期输出(A/B 交错,顺序不定):

收到: A-0
收到: B-0
收到: A-1
收到: B-1
收到: A-2
收到: B-2
全部完成

要点:这是不依赖 select 的 fan-in——每个源一个 goroutine 往同一个 out 写。sync.WaitGroup + close 是上一轮讨论的"多生产者关闭"标准模式。merge 内部每个 goroutine 的 for range 是阻塞读,天然实现了"谁有数据谁先写",效果等价于 select 多路复用,但代码更清晰、可扩展(N 个源不用写 N 个 case)。


示例 10:带缓冲 channel + 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
36
37
38
39
40
41
42
43
44
45
46
package main

import (
	"fmt"
	"time"
)

func main() {
	events := make(chan int, 100)

	// 生产事件
	go func() {
		for i := 1; i <= 20; i++ {
			events <- i
			time.Sleep(20 * time.Millisecond)
		}
		close(events)
	}()

	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	var batch []int
	for {
		select {
		case v, ok := <-events:
			if !ok {
				if len(batch) > 0 {
					fmt.Println("最后一批:", batch)
				}
				fmt.Println("全部完成")
				return
			}
			batch = append(batch, v)
			if len(batch) >= 5 {
				fmt.Println("批量处理:", batch)
				batch = batch[:0]
			}
		case <-ticker.C:
			if len(batch) > 0 {
				fmt.Println("定时刷新:", batch)
				batch = batch[:0]
			}
		}
	}
}

预期输出(大致):

定时刷新: [1 2 3 4]
批量处理: [5 6 7 8 9]
批量处理: [10 11 12 13 14]
批量处理: [15 16 17 18 19]
最后一批: [20]
全部完成

要点:select 同时监听"数据到达"和"定时器触发",实现双条件批处理——要么凑够 5 个,要么 100ms 到了就刷。这是生产级批量处理的标准骨架(日志聚合、数据库批量写都用这个模式)。


五、陷阱清单

⚠️ 陷阱 1:time.After 在循环里的资源泄漏

1
2
3
4
5
6
7
8
9
// ❌ 每秒泄漏一个 timer
for {
    select {
    case v := <-ch:
        handle(v)
    case <-time.After(time.Second):   // 每次循环都创建新 timer
        timeout()
    }
}

time.After 创建的 timer 在触发前不会被 GC,即使 select 走了别的分支。高频循环里会累积大量 timer,内存持续上涨。

修复:用 time.NewTimer + 手动 Reset:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ✅ 复用 timer
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
    if !timer.Stop() {
        <-timer.C
    }
    timer.Reset(time.Second)
    select {
    case v := <-ch:
        handle(v)
    case <-timer.C:
        timeout()
    }
}

⚠️ 陷阱 2:default 导致 CPU 空转

1
2
3
4
5
6
7
8
9
// ❌ 死循环空转,CPU 100%
for {
    select {
    case v := <-ch:
        handle(v)
    default:
        // 什么都不做,立即进入下一轮
    }
}

default 让 select 不阻塞,循环转得飞快,CPU 打满。

修复:要么去掉 default(阻塞等),要么在 default 里加 runtime.Gosched() 或 time.Sleep,要么用 time.Ticker 节流。

⚠️ 陷阱 3:break 跳出的是 select,不是 for

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// ❌ break 只跳出 select,for 继续转
for {
    select {
    case <-done:
        break          // 以为跳出 for,其实只跳出 select
    }
}
// ✅ 用 return 或标签
loop:
for {
    select {
    case <-done:
        break loop
    }
}

⚠️ 陷阱 4:关闭的 channel 在 select 里一直就绪

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
ch := make(chan int)
close(ch)
for {
    select {
    case v, ok := <-ch:
        if !ok {
            return   // 必须判断 ok,否则 select 每次都选这个 case
        }
    case x := <-other:
        handle(x)
    }
}

关闭的 channel 永远可以立即读到零值,所以 case <-ch 在 select 里永远就绪,会疯狂抢走其他 case 的机会。必须用 v, ok := 判断,ok 为 false 时把 ch 设为 nil 或退出。

⚠️ 陷阱 5:nil channel 用于禁用,但别拿它做"空转"

1
2
3
4
5
var ch chan int   // nil
select {
case <-ch:        // 永远不就绪 ✅ 用来禁分支
case <-other:     // 正常工作
}

这是特性不是 bug。但如果所有 case 都是 nil channel 且没有 default,就永久阻塞(和 select {} 一样)。

⚠️ 陷阱 6:select 里对同一 channel 多个 case

1
2
3
4
5
// ❌ 两个 case 操作同一 channel,行为混乱
select {
case v := <-ch:
case ch <- 1:
}

虽然语法合法,但实际语义很难推理(一读一写同时竞争),生产代码应避免。每个 channel 在 select 里最多出现一次。

⚠️ 陷阱 7:select 的 case 太多

超过 65536 个 case 编译期报错。即使没到上限,case 越多,selectgo 的轮询和锁开销越大。“等很多个 channel” 应该用 fan-in 模式聚合成一个 channel,而不是写几百个 case。


六、性能与最佳实践速查

场景推荐写法
等少数几个 channelselect 直接多路复用
等很多个 channelfan-in 聚合(示例 9),别写 N 个 case
超时time.After(低频)/ time.NewTimer(循环内)
非阻塞尝试select + default
优先级两层 select(示例 6),注意 default 空转
动态启用/禁用分支把变量设为 nil
循环等待 + 退出for { select { case <-ctx.Done(): return; ... } }
关闭的 channelv, ok := <-ch 判断 ok,ok=false 时退出或置 nil
广播退出close(done) 或 cancel(),不要发 N 个值

七、总结

心智模型三句话:

  1. select 是 channel 的 switch——对"哪个 channel 就绪"做分支,不是对值做分支
  2. 就绪随机选——这是公平性保证,不要依赖分支顺序
  3. default 改阻塞语义——有 default 就是"尝试一次",没 default 就是"阻塞等待"

四条铁律:

  • 每个 case 都要有退出路径(return / break label / 置 nil),否则就是泄漏或死循环
  • 关闭的 channel 永远就绪,必须 v, ok := 判断
  • default 要慎用,空转 CPU 是生产事故的常见源头
  • 循环里的 time.After 会泄漏 timer,用 NewTimer + Reset

一句话:

select 让你一个 goroutine 同时监听多个通道,是 Go 写"响应式"逻辑的基石——超时、取消、多路复用、非阻塞尝试、优先级调度,全都靠它。但它的随机性和"关闭通道永远就绪"两个特性,决定了你必须在每个 case 里显式处理"这个分支是否还该活着",否则不是死锁就是 CPU 打满。

十二、select 底层原理(简要)

编译器会把 select 转换为运行时调用:

1
2
// 伪代码
func selectgo(cas0 *scase, order0 *uint16, ncases int) (int, bool)

流程:

  1. 生成 scase 数组,每个 case 描述一个 channel 操作
  2. 加锁所有涉及 channel(按地址排序避免死锁)
  3. 遍历所有 case,找出就绪的
  4. 若多个就绪 → 用 fastrand 随机选一个
  5. 若都未就绪 → 当前 goroutine 挂起到所有 channel 的等待队列
  6. 有 default 则直接走 default

要点:

  • 有 default:不挂起,直接返回
  • 无 default:将当前 goroutine 加入所有相关 channel 的 sendq/recvq
  • 被唤醒后从所有队列移除自己

十三、实战:完整示例 - 带超时、取消、重试的任务调度

 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 (
    "context"
    "fmt"
    "math/rand"
    "time"
)

func fetch(ctx context.Context, id int) (string, error) {
    result := make(chan string, 1)

    go func() {
        // 模拟网络请求
        time.Sleep(time.Duration(rand.Intn(500)) * time.Millisecond)
        result <- fmt.Sprintf("数据-%d", id)
    }()

    select {
    case r := <-result:
        return r, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    for i := 1; i <= 5; i++ {
        select {
        case <-ctx.Done():
            fmt.Println("整体超时,退出")
            return
        default:
        }

        data, err := fetch(ctx, i)
        if err != nil {
            fmt.Println("任务", i, "失败:", err)
            continue
        }
        fmt.Println("任务", i, "成功:", data)
    }

    fmt.Println("全部完成")
}

十四、select 使用清单

场景写法
等待多个 channelselect { case <-a: case <-b: }
非阻塞读取加 default
超时控制case <-time.After(d):
循环超时time.NewTimer + Reset
退出信号case <-done: 配合 close(done)
取消case <-ctx.Done():
禁用某 case把 channel 置 nil
合并多 channelfor + select + ch = nil
高优先级嵌套 select

十五、最佳实践总结

  1. ✅ 循环内用 select + for 持续监听
  2. ✅ 超时优先用 context.Context,time.After 仅用于单次
  3. ✅ 退出用 close(done) 广播,不要发送多个值
  4. ✅ ch = nil 是禁用 case 的惯用技巧
  5. ✅ 多 case 随机是特性,不是 bug;要优先级用嵌套 select
  6. ❌ 循环里别用 time.After,会泄漏定时器
  7. ❌ 别在 select 中 close 已经关闭的 channel
  8. ❌ 无限循环 + 无 default + 所有 channel 阻塞 → deadlock

进一步展开:

  • select 源码解析(runtime.selectgo 的具体实现)
  • context 与 select 配合的深入用法
  • 高并发场景下 select 的性能优化与替代方案