select与超时控制
本篇是你 Go 并发学习的 第 10 天:select + default、多路复用与 channel 超时控制,内容会尽量结合 Java 的类比来帮助理解。
- 你的背景:Java 程序员
- 操作系统:Linux Mint XFCE
- Go 版本:go1.22.2 linux/amd64
- 项目目录示例:
/home/liumangmang/GolandProjects/go-select-practice
📌 标题
Go 并发进阶:select、多路复用与 channel 超时控制
✅ 步骤 1:创建练习项目
在终端中执行:
cd /home/liumangmang/GolandProjects
mkdir go-select-practice && cd go-select-practice
go mod init go-select-practice如果你更喜欢放在之前的项目里(比如第 9 天的
go-channel-practice),直接在原项目中新建几个.go文件也可以,Go 模块不必每次重建。
✅ 步骤 2:select 基本用法 —— 在多个 channel 之间“抢先处理”
Java 类比:
- 把 goroutine 想成 Java 的 Thread / Runnable。
- 把 channel 想成 Java 的 BlockingQueue(带类型、安全阻塞)。
- 而
select有点像:- 同时在多个 BlockingQueue 上做
take()/poll(),哪个先有数据就先处理。 - 再带一点
switch语法糖的味道。
- 同时在多个 BlockingQueue 上做
创建 select_basic.go:
nano select_basic.go粘贴下面代码:
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
// 模拟两个不同来源的“数据源”
go func() {
time.Sleep(1 * time.Second)
ch1 <- "result from ch1 (1s)"
}()
go func() {
time.Sleep(2 * time.Second)
ch2 <- "result from ch2 (2s)"
}()
fmt.Println("等待 ch1 或 ch2 的结果...(谁先来处理谁)")
select {
case v := <-ch1:
fmt.Println("收到 ch1:", v)
case v := <-ch2:
fmt.Println("收到 ch2:", v)
}
fmt.Println("main 结束")
}运行:
go run select_basic.go一般会输出:
等待 ch1 或 ch2 的结果...(谁先来处理谁)
收到 ch1: result from ch1 (1s)
main 结束关键点:
select会同时监听多个 channel:- 哪个
case可以立刻执行(比如有数据可读),就选哪个。 - 多个
case都准备好了时,会随机选一个(避免饥饿)。
- 哪个
- 从 Java 视角:省掉了你手动写一堆
if (queue1 有数据) {}、else if (queue2 有数据) {}的轮询代码。
✅ 步骤 3:select + default —— 非阻塞轮询(Java 中的 poll())
有时你不想在 select 上一直阻塞等待,而是:
- 如果当前没数据,就先去干点别的事情(日志、监控、心跳)。
这时可以用 default 分支,它在 没有任何 case 就绪时立刻执行。
创建 select_default.go:
nano select_default.go粘贴下面代码:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 5; i++ {
time.Sleep(500 * time.Millisecond)
ch <- i
}
close(ch)
}()
for {
select {
case v, ok := <-ch:
if !ok {
fmt.Println("channel 已关闭,退出循环")
return
}
fmt.Println("收到:", v)
default:
// 没有数据可读时,做点“其他事”
fmt.Println("没有新数据,先忙点别的...")
time.Sleep(200 * time.Millisecond)
}
}
}运行:
go run select_default.go你会看到“没有新数据...”和“收到: x”交替出现。
Java 类比:
默认
BlockingQueue.take()是阻塞的,对应 Go 里没有 default 的 select。如果你在 Java 中用
queue.poll(0, TimeUnit.MILLISECONDS)或直接queue.poll()非阻塞拿一次,就类似 Go 的:select { case v := <-ch: // 有数据 default: // 没有数据,立即返回 }
✅ 步骤 4:使用 select + time.After 实现超时
在 Java 里你可能写过:
future.get(2, TimeUnit.SECONDS)socket.setSoTimeout(2000)- 或者用
ScheduledExecutorService做超时控制。
在 Go 里,一个非常常见的模式是:
- 使用
time.After(duration)得到一个 在 duration 后会“自动发送当前时间”的 channel。 - 然后在
select里加一个case <-time.After(...)分支。
创建 select_timeout.go:
nano select_timeout.go粘贴下面代码:
package main
import (
"errors"
"fmt"
"time"
)
// 模拟一个可能很慢的操作
func slowOperation() (string, error) {
time.Sleep(3 * time.Second) // 假设真的很慢
return "slow result", nil
}
func doWithTimeout(timeout time.Duration) (string, error) {
resultCh := make(chan string, 1)
errCh := make(chan error, 1)
go func() {
res, err := slowOperation()
if err != nil {
errCh <- err
return
}
resultCh <- res
}()
select {
case res := <-resultCh:
return res, nil
case err := <-errCh:
return "", err
case <-time.After(timeout):
return "", errors.New("操作超时")
}
}
func main() {
fmt.Println("开始调用,最大等待 2 秒...")
res, err := doWithTimeout(2 * time.Second)
if err != nil {
fmt.Println("失败:", err)
return
}
fmt.Println("成功:", res)
}运行:
go run select_timeout.go你会发现 slowOperation 需要 3 秒,但我们只等 2 秒就返回了“操作超时”。
Java 类比:
doWithTimeout很像你在 Java 里自己封装的:Future<String> f = executor.submit(this::slowOperation); try { return f.get(2, TimeUnit.SECONDS); } catch (TimeoutException e) { // 超时 }不同点是:Go 用
goroutine + channel + select组合来表达这个超时逻辑。
思考:当前实现里,超时后 goroutine 仍在后台跑完
slowOperation,只是结果被我们“丢掉”了。想真正取消它,就需要context。
✅ 步骤 5:配合 context 做“可取消”的超时任务
在 Java 里,如果你用的是 Future,可以 future.cancel(true) 尝试中断线程;如果用的是 Reactor / RxJava,会有 dispose()、cancel()。
在 Go 里,常见是用 context.Context+select 做“优雅取消”。
创建 select_context.go:
nano select_context.go粘贴下面代码:
package main
import (
"context"
"fmt"
"time"
)
// 模拟一个可被取消的操作
func doWork(ctx context.Context) error {
for i := 1; i <= 5; i++ {
select {
case <-ctx.Done():
// context 被取消或超时
fmt.Println("doWork 被取消:", ctx.Err())
return ctx.Err()
default:
fmt.Println("工作中 step", i)
time.Sleep(1 * time.Second)
}
}
fmt.Println("doWork 正常完成")
return nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
fmt.Println("开始工作,最长 3 秒...")
if err := doWork(ctx); err != nil {
fmt.Println("结束,原因:", err)
return
}
fmt.Println("结束: 正常完成")
}运行:
go run select_context.go你会看到大约做 3 步左右,context 超时导致 doWork 提前退出,而不是做完 5 步。
Java 类比:
- 可以类比为:
- 上游代码持有一个“取消令牌”(类似 RxJava 的
Disposable,或一般框架里的CancellationToken)。 - 业务逻辑中每一段都检查一下“是否被取消”。
- 上游代码持有一个“取消令牌”(类似 RxJava 的
- Go 的
context.Context就是标准化的“取消信号 + 截止时间 + 额外参数”的组合。
✅ 步骤 6:从 Java 视角整体对比思维方式
- goroutine vs Thread:
- goroutine 更轻,多数时候你可以“想到就开”,由 runtime 调度。
- channel vs BlockingQueue:
- 都是“带阻塞语义的安全队列”,区别是 channel 更轻量,类型约束更强。
- select vs 手写轮询:
- Java 中你可能会在多个队列/Socket 上手动轮询;
- Go 中用
select就能自然表达“谁先就绪就先处理谁”。
- time.After / context.WithTimeout vs Future.get(timeout):
- 目的相同:都在说“最多等这么久,超过就算失败/超时”。
- 表达方式不同:Go 借助 channel 统一到
select上来处理。
心法:把“等待某件事发生”统一建模为“等待某个 channel 有消息 / 关闭”,然后用
select组合多个等待条件。
📚 今日小结与练习
你已经掌握:
select在多个 channel 之间抢先处理。select + default实现非阻塞轮询(类似 Java 的poll())。select + time.After实现超时控制(类似Future.get(timeout))。select + context实现可取消、可超时的长时间任务。
推荐练习:
- 修改
select_basic.go,让ch2更快(1s)而ch1更慢(2s),体会select会优先哪个。 - 在
select_timeout.go中增加“重试 3 次再放弃”的逻辑。 - 在
select_context.go中,让doWork再开启子 goroutine,并把同一个ctx传下去,体会“整条调用链都能感知到取消”。
- 修改
如果你愿意,第 11 天 我们可以基于这些内容,带你实现一个完整的 worker pool / 任务分发系统,对标 Java 里的 ThreadPoolExecutor,帮助你把 Go 并发和 Java 并发打通。
