十一:goroutine(3. select基本使用)

一、先建立一个核心认识

你已经知道:

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 关闭 → 零值 0
  • chan string 关闭 → 零值 ""
  • chan bool 关闭 → 零值 false
  • chan []byte 关闭 → 零值 nil

这里出现了一个非常关键的技巧:

ini 复制代码
ch = nil

十四、close(ch) 和 ch = nil 可不是一回事!!

为什么 nil Channel 很重要?因为ch一旦设置为nil,将永远不会ready,close(ch) 和 ch = nil 是两件完全独立的事情:

  1. close(ch) :只改变通道的内部状态 (标记为"已关闭"),但变量 ch 本身依然指向原来的内存地址,它不是空指针。
  2. 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
相关推荐
仟里码1 小时前
九:goroutine(1.什么是协程)
后端
不是万能的豆学长1 小时前
排查Go服务40天后的内存泄漏问题
开发语言·后端·golang·go
阿狗童鞋1 小时前
SpringSecurity权限认证实战指南
java·后端·spring
韩振方1 小时前
Kubernetes Service 让会更换的 Pod 有稳定访问入口
后端
用户125758524361 小时前
Golang后台管理框架推荐:XYGo Admin与devinggo怎么选?
后端·go·ai编程
花间相见1 小时前
【计算基础|网络13】UDP 与可靠传输
后端·面试
tachibana21 小时前
Go 语言 Map 的底层实现原理
开发语言·后端·golang·make
九零HTTP1 小时前
从 Cookie 到 Token,登录态到底是怎么维持的
后端
Gopher_HBo2 小时前
go-grpc服务端调用链
后端