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函数内部逻辑:

  1. 使用fastrandn算法把scases数组的索引重新编排顺序。

  2. 根据新的索引顺序对hchan进行堆排序来获取case的锁定顺序。(保证 n log n 时间和恒定的堆栈占用空间)

  3. 锁定所有channel。

  4. 遍历所有channel,判断是否有可读或者可写的,如果有,解锁channel,返回对应数据。

  5. 否则,判断有没有default,如果有,解锁channel,返回default对应scase。

  6. 否则,把当前groutian添加到所有channel的等待队列里,解锁所有channel,等待被唤醒。

  7. 被唤醒后,再次锁定所有channel

  8. 遍历所有channel,把g从channel等待队列中移除,并找到可操作的channel

  9. 如果对应的scase不为空,直接返回对应的值

  10. 否则循环此过程

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部