1. select介绍
select
是 Go
语言中的一种控制结构,用于在多个通信操作中选择一个可执行的操作。它可以协调多个 channel
的读写操作,使得我们能够在多个 channel
中进行非阻塞的数据传输、同步和控制。
基本语法:
select {
case communication clause :
statement(s);
case communication clause :
statement(s);
/* 你可以定义任意数量的 case */
default : /* 可选 */
statement(s);
}
如果多个 case 都可以运行,select 会随机公平地选出一个执行。如果没有 case 可以运行,它要么阻塞(等待 case),要么执行default子句。
2. select
语句的常用使用场景:
-
等待多个通道的消息(多路复用)
当我们需要等待多个通道的消息时,使用
select
语句可以非常方便地等待这些通道中的任意一个通道有消息到达,从而避免了使用多个goroutine进行同步和等待。 -
超时等待通道消息
当我们需要在一段时间内等待某个通道有消息到达时,使用
select
语句可以与time
包结合使用实现定时等待。 -
在通道上进行非阻塞读写
在使用通道进行读写时,如果通道没有数据,读操作或写操作将会阻塞。但是使用
select
语句结合default
分支可以实现非阻塞读写,从而避免了死锁或死循环等问题。
因此,select
的主要作用是在处理多个通道时提供了一种高效且易于使用的机制,简化了多个 goroutine
的同步和等待,使程序更加可读、高效和可靠。
3. 代码示例:
代码1:
package main
import (
"fmt"
"time"
)
func main() {
chan1 := make(chan int)
chan2 := make(chan int)
go func() {
chan1 <- 1
time.Sleep(5 * time.Second)
}()
go func() {
chan2 <- 1
time.Sleep(5 * time.Second)
}()
select {
case <-chan1:
fmt.Println("chan1")
case <-chan2:
fmt.Println("chan2")
default:
fmt.Println("default")
}
fmt.Println("main exit")
}
输出结果为:
可能会出现三种结果:
chan1
main exit
chan2
main exit
default
main exit
select 中的 case 执行顺序是随机的,如果某个 case 中的 channel 已经 ready,那么就会执行相应的语句并退 出 select 流程,如果所有 case 中的 channel 都未 ready,那么就会执行 default 中的语句然后退出 select 流程。
由于启动的协程和 select 语句并不能保证执行的顺序,所以也有可能 select 执行时协程还未向channel中写入数据,所以 select 直接执行 default 语句并退出。因此,程序有可能产生三种输出
代码2:
package main
import (
"fmt"
)
func main() {
chan1 := make(chan int)
chan2 := make(chan int)
go func() {
close(chan1)
}()
go func() {
close(chan2)
}()
select {
case <- chan1:
fmt.Println("chan1")
case <- chan2:
fmt.Println("chan2")
}
fmt.Println("main exit.")
}
select 会随机检测各 case 语句中 channel 是否 ready,注意已关闭的 channel 也是可读的,所以上述程序中select 不会阻塞,具体执行哪个 case 语句是随机的。
代码3:
package main
func main() {
select {
}
}
对于空的 select 语句,程序会被阻塞,确切的说是当前协程被阻塞,同时 Go 自带死锁检测机制,当发现当前协程再也没有机会被唤醒时,则会发生 panic。所以上述程序会 panic。
定时器实现定时任务的执行代码:
package main
import (
"fmt"
"time"
)
func main() {
fmt.Println("定时任务开始")
// 创建一个每秒触发一次的定时器
ticker := time.NewTicker(1 * time.Second)
done := make(chan bool)
go func() {
for {
select {
case <-ticker.C:
fmt.Println("执行定时任务")
case <-done:
ticker.Stop()
return
}
}
}()
// 等待5秒
time.Sleep(5 * time.Second)
done <- true
fmt.Println("定时任务结束")
}
结果:
定时任务开始
执行定时任务
执行定时任务
执行定时任务
执行定时任务
执行定时任务
定时任务结束
超时退出实现代码:
package main
import (
"fmt"
"time"
)
func main() {
timeout := 5 * time.Second
done := make(chan bool)
go func() {
// 模拟耗时操作
time.Sleep(2 * time.Second)
done <- true
}()
select {
case <-done:
fmt.Println("Task completed successfully.")
case <-time.After(timeout):
fmt.Println("Timeout! The operation took too long.")
}
}
或:
package main
import (
"context"
"fmt"
"time"
)
func main() {
timeout := 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
done := make(chan bool)
go func() {
// 模拟耗时操作
time.Sleep(2 * time.Second)
done <- true
}()
select {
case <-done:
fmt.Println("Task completed successfully.")
case <-ctx.Done():
fmt.Println("Timeout! The operation took too long.")
}
}
4. 总结
-
select 语句中除 default 外,每个 case 操作一个channel,要么读要么写
-
select语句中除 default 外,各 case 执行顺序是随机的
-
select 语句中如果没有 default 语句,则会阻塞等待任一 case
-
select 语句中读操作要判断是否成功读取,关闭的 channel 也可以读取
select在 Go 语言的源代码中不存在对应的结构体,只是定义了一个 runtime.scase 结构体(在src/runtime/select.go)表示每个 case 语句(包含defaut):
// Select case descriptor.
// Known to compiler.
// Changes here must also be made in src/cmd/compile/internal/walk/select.go's scasetype.
type scase struct {
c *hchan // chan
elem unsafe.Pointer // data element
}
因为所有的非 default 的 case 基本都要求是对Channel的读写操作,所以 runtime.scase 结构体中也包含一个 runtime.hchan 类型的字段存储 case 中使用的 Channel,另一个字段 elem 指向 case 条件包含的数据的指针,如 case ch1 <- 1,则 elem 指向常量1.
编译器会对select有不同的case的情况进行优化以提高性能。首先,编译器对select没有case、有单case和单case+default的情况进行单独处理,这些处理或者直接调用运行时函数,或者直接转成对channel的操作,或者以非阻塞的方式访问channel,多种灵活的处理方式能够提高性能,尤其是避免对channel的加锁。
对最常出现的select有多case的情况,会调用runtime.selectgo()函数来获取执行case的索引
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool)
selectgo函数内部逻辑:
-
使用fastrandn算法把scases数组的索引重新编排顺序。
-
根据新的索引顺序对hchan进行堆排序来获取case的锁定顺序。(保证 n log n 时间和恒定的堆栈占用空间)
-
锁定所有channel。
-
遍历所有channel,判断是否有可读或者可写的,如果有,解锁channel,返回对应数据。
-
否则,判断有没有default,如果有,解锁channel,返回default对应scase。
-
否则,把当前groutian添加到所有channel的等待队列里,解锁所有channel,等待被唤醒。
-
被唤醒后,再次锁定所有channel
-
遍历所有channel,把g从channel等待队列中移除,并找到可操作的channel
-
如果对应的scase不为空,直接返回对应的值
-
否则循环此过程