golang控制结构之select

select

select是Go在语言层面提供的多路I/O复用机制,用于检测多个管道是否就绪( 即可读或可写 ),其特性和管道息息相关。

特性速览

管道读写

select只能作用于管道,包括数据的读取和写入,如下面代码所示:

func SelectForChan(c chan string) {
	var recv string
	send := "Hello"
	select {
	case recv = <-c:
		fmt.Printf("recvied %s \n", recv)
	case c <- send:
		fmt.Printf("sent %s \n", send)
	}
}

在上面的代码中,select拥有两个case语句,分别对应管道的读操作和写操作,至于最终执行哪个case语句,取决于函数传入的管道。

第一种情况,管道没有缓冲区:

func main() {
	c:=make(chan string)
	SelectForChan(c)
}

此时管道既不能读也不能写,两个case语句均不执行,select陷入阻塞,直接main方法运行的话会出现fatal error: all goroutines are asleep - deadlock!,退出程序,原因是: **在main goroutine线,期望从管道中获得一个数据,而这个数据必须是其他goroutine线放入管道的,但是其他goroutine线都已经执行完了(all goroutines are asleep),那么就永远不会有数据放入管道。所以,main goroutine线在等一个永远不会来的数据,那整个程序就永远等下去了。这显然是没有结果的,所以这个程序就说“算了吧,不坚持了,我自己自杀掉,报一个错给代码作者,我被deadlock了”。**一般这种情况需要避免出现,还有一种方式,就是追加default选项。

第二种情况,管道有缓冲区且还可以存放至少一个数据:

func main() {
	c:=make(chan string,1)
	SelectForChan(c)
}

此时管道可以进行写数据,写操作对应的case得以执行,执行后输出 sent Hello ,函数退出。

第三种情况,管道有缓冲区,缓冲区中已经放满了数据:

func main() {
	c:=make(chan string,1)
	c<-"world"
	SelectForChan(c)
}

此时管道可以进行读数据,读操作对应的case得以执行,执行后输出 recvied world ,函数退出

第四种情况,管道有缓冲区,缓冲区中只有部分数据且还可以继续存放数据:

func main() {
	c:=make(chan string,2)
	c<-"world"
	SelectForChan(c)
}

此时管道即可以读也可以写,select将随机挑选一个case语句执行,任意一个case语句执行结束后函数退出。

综上所述,select的每个case语句只能操作一个管道,要么写入数据,要么读取数据。鉴于管道的特效,如果管道中没有数据读取操作则会堵塞,如果管道中没有空余的缓冲区则写入操作会堵塞。当select的多个case语句中的管道均堵塞时,整个select语句也会陷入堵塞(没有default语句的情况下,且不是,main函数执行),直到任意一个管道解除堵塞。如果多个case均没有堵塞,那么select将随机挑选一个case执行。

返回值

select为Go语言的预留关键字,并非函数,其可以在case语句中声明变量并为变量赋值,看上去就像一个函数一样、

case语句读取管道时,可以最多给两个变量赋值,如下:

func SelectAssign(c chan string) {
	select {
	case <-c: //0个变量
		fmt.Println("0")
	case d := <-c:    //1个变量
	fmt.Printf("1: received %s \n",d)
	case d, ok := <-c: //2个变量
		if !ok{
			fmt.Printf("no data found")
			break
		}
		fmt.Printf("2: received %s \n",d)
	}
}

case语句中管道的读操作有两个返回条件,一个是成功读取到数据,二是管道中已没有数据且已被关闭。当case语句中包含两个变量时,第二个变量表示是否成功地读取到了数据。

下面的代码传入一个关闭的管道:

func main() {
	c := make(chan string)
	close(c)
	SelectAssign(c)
}

此时select中的三个case语句都有机会执行,,每个case语句收到的数据都为空,但是第三个case语句中可以感知到管道被关闭,从而不必打印空数据。

default

select中的dafault语句不能处理管道读写操作,当select的所有case语句都堵塞时,default语句将被执行,如下:

func SelectDefault() {
	c := make(chan string)
	select {
	case <-c:
		fmt.Printf("received %s \n")
	default:
		fmt.Printf("no data found in default \n")
	}
}

由于管道没有缓冲区,读操作必然堵塞,然而select含有default分支,select将执行default分支并退出。

另外,default实际上也是特殊的case,它能出现在select的任意位置,但每个select仅能有一个default。

使用举例

下面列举一些在实际项目中使用select的例子

永久堵塞

有时我们启动协程处理任务,并且不希望main函数退出,此时就可以让main函数永久性陷入阻塞。

在kubernetes项目的多个组件中均有使用select阻塞main函数的案例,比如apiserver中的webhook测试组件:

func main(){
	server := webhooktesting.NewTestServer(nil)
	server.StartTLS()
	fmt.Println("serving on",server.URL)
	select()
}

以上代码的select语句中不包含case语句和default语句,那么协程(main)将陷入永久性阻塞。

快速检错

有时我们会使用管道来传输错误,此时就可以使用select语句快速检查管道中是否有错误,避免陷入循环。比如kubernetes调度器中就有类似的用法:

errCh := make(chan error,active)
jm.deelteJobPods(&job,activePods,errÇh)	//传入管道用于记录错误
select{
	case manageJobErr = <-errCh :	//检查是否有错误发生
		if manageJobErr != nil {
			break
		}
  defalut:	//没有错误,快速结束检查
}

上面的select仅用于尝试从管道中读取错误信息,如果没有错误,则不会陷入阻塞。

限时等待

有时我们会使用管道来管理函数的上下文,此时可以使用select来创建只有一定时效的管道。比如kubernetes控制器中就有类似的用法:

func waitForStopOrTimeout(stopCh <-chan struct{} , timeout time.Duration) <-chan struct{} {
	stopChWithTimeout := make(chan struct{})
	go func(){
		select{
    case <-stopCh:	//自然结束
    case <-time.After(timeout):	//最长等待时间
		}
		close(stopChWithTimeout)
	}()
  return stopChWithTimeout
}

该函数返回一个管道,可用于在函数之间传递,但该管道会在指定时间后自动关闭。

实现原理

研究selec的实现原理,可以帮助我们更清晰的了解以下问题:

  • 为什么每个case语句只能处理一个管道?
  • 为什么case语句的执行顺序是随机的?
  • 为什么在case语句中向值为nil的管道中写入数据不会触发panic?

数据结构

select的case语句对应runtime包中的scase(select-case)数据结构:

// Select case descriptor.
// Known to compiler.
// Changes here must also be made in src/cmd/internal/gc/select.go's scasetype.
type scase struct {
	c    *hchan         // chan
  kind	uint16		//case类型,值有 caseNil  caseRecv caseSend caseDefalut,1.16版本后此变量没有了,
	elem unsafe.Pointer // data element
}
管道

scase中的成员c表示case语句操作的管道,**由于每个case中仅能存放一个管道,这就直接决定了每个case语句中仅能处理一个管道。**另外编译器在处理case语句时,如果case语句中没有管道操作(不能处理成scase对象),则会给出编译错误:

select case must be receive , send or assign recv
类型

scase中的成员kind表示case语句的类型,每个类型均表示一类管道操作或特殊case(go 1.16版本该成员字段已经取消掉了)。

const (
	caseNil 	= iota	//管道的值为nil
  caseRecv	//读管道的case
  caseSend	//写管道的case
  caseDefault	//default
)

类型为caseNil的case语句表示其操作的管道值为nil,由于nil管道既不可读也不可写,意味着这类case永远不会命中,在运行时会被忽略,这正是为什么在case语句中向值为nil的管道中写入数据不会触发panic的原因。

类型为caseRecv的case语句,表示其将从管道中读取数据

类型为caseSend的case语句,表示其将写入数据到管道中

default为特殊类型的case语句,其不会操作管道。另外,每个select语句中仅可存在一个default语句,并且default语句可以出现在任意位置。

数据

scase中的成员elem表示数据存放的地址,根据case类型而具有不同的含义

  • 在类型为接收chan的case中,elem表示从管道读取的数据的存放地址
  • 在类型为写入chan的case中,elem表示将写入管道的数据的存放地址

实现逻辑

Go在runtime包中提供了selectgo()函数用于处理select语句:

// selectgo implements the select statement.
// cas0 points to an array of type [ncases]scase, and order0 points to
// an array of type [2*ncases]uint16 where ncases must be <= 65536.
// Both reside on the goroutine's stack (regardless of any escaping in
// selectgo).
// For race detector builds, pc0 points to an array of type
// [ncases]uintptr (also on the stack); for other builds, it's set to
// nil.
// selectgo returns the index of the chosen scase, which matches the
// ordinal position of its respective select{recv,send,default} call.
// Also, if the chosen scase was a receive operation, it reports whether
// a value was received.
func selectgo(cas0 *scase, order0 *uint16, pc0 *uintptr, nsends, nrecvs int, block bool) (int, bool) {}

selectgo()函数会从一组case语句中挑选一个case,并返回命中的case下标,对于类型为caseRecv的case,还会返回是否成功的从管道中读取了数据(第二个返回值对于其他类型的case无意义)。

selectgo()函数的实现包括一下几个要点:

  • 通过随机函数fastrandn()将原始的case顺序打乱,在遍历各个case时使用打乱后的顺序就会表现出随机性。
  • 循环遍历各个case时,如果发现某个case就绪(管道可读或可写),则直接跳出循环,进行管道操作并返回
  • 循环遍历遍历各个case时,循环能正常结束(没有跳转),说明所有case都没有就绪,如果有default语句则命中default
  • 如果素有case都未命中且没有default,selectgo()将阻塞等待所有管道,任意一管道就绪后,都将开始新的循环

小结

  • select仅能操作管道
  • 每个case语句仅能处理一个管道,要么读要么写
  • 多个case语句的执行顺序是随机的
  • 存在default语句时,select将不会出现堵塞的情况

最后,使用select读取管道时,尽量检查读取是否成功,以便及时发现管道异常。

你可能感兴趣的:(go,golang,go语言)