今天呢,咱们来聊聊 Go 语言的那点事儿,尤其是咱们在并发处理中常用的 select 语句,它可是处理并发时的一把利剑!
Go 语言的 select 语句,仿佛是编程世界中的一位冷静的裁判,当多个通道(channel)全都争着抢话语权的时候,它就会站出来,公平地判决谁应当先发声。
换句话说,select 可以在多个通道之间等待并选择可用的通道执行操作。
你得这么看select语句——它是并发编程领域里的一块重要的拼图,没有这块,你画出的并发图景就不完整。
首先,我们来看一个简单的示例:
select {case <-chan1: // 操作1case data := <-chan2: // 操作2case chan3 <- data: // 操作3default: // 默认操作}
还别说,这几行代码,简单明了,但它背后可是隐藏着深邃的并发处理智慧:
优雅!这是使用过 select 语句后,我心中的感叹。就像你有了一块功能强大的瑞士军刀,可以灵活地应对各种野外求生的情况。
在代码中,select 语句也可以灵活地处理多个通道的并发操作,避免使用复杂的同步工具实现并发操作。
讲科技,不能光有干巴巴的代码堆砌,还得有历史沉淀(反正以前历史老师是这么教的 :)。
而我们现在探讨的是 Go 语言里的 select 思想,它最初源自于网络 IO 模型中的 select,其精华在于 IO 多路复用。
想象一下,有那么一刻,你需要同时倾听来自世界各地的广播,这可不是一件简单的事儿。然而,这正是 go 中的 channel 和 select 的日常所在:致力于协调多个渠道的信息流,也只有在这里,才有 “通道争鸣” 的景象。
让我们像切洋葱一样,一层层地剥开 select 神秘的外衣:
接下来,咱们通过一系列实验来检验真实世界中 select 的行为。
(1) select 已关闭通道和空通道场景
再来看以下代码:
func main() { c1, c2, c3 := make(chan bool), make(chan bool), make(chan bool) go func() { for { select { // 保证c1一定不会关闭,否则会死循环此case case <-c1: fmt.Println("case c1") // c2可以防止出现c1死循环的情况 // 如果c2关闭了(ok==false),将其置为nil,下次就会忽略此case case _, ok := <-c2: if !ok { fmt.Println("c2 is closed") c2 = nil } // 如果c3已关闭,则panic(不能往已关闭的channel里写数据) // 如果c3为nil,则ignore该case case c3 <- true: fmt.Println("case c3") case v <- c4: fmt.Println(v) } } }() time.Sleep(10 * time.Second)}
当 channel 关闭以后,case <- chan 会接收该通信对应数据类型的零值,所以会出现死循环。
(2) 带 default 语句实现非阻塞读写
select { case <- c1: fmt.Println(":case c3") // 当c1没有消息时,不会一直阻塞,而是进入default default: fmt.Println(":select default")}
注意,Go 语言的 select 和 Java 或者 C 语言的 switch 还不太一样:switch 中一般会带有 default 判断分支,但 select 使用时,外层的 for 循环和 default 不会同时出现,否则会发生死锁。
(3) select 实现定时任务
func main() { done := make(chan bool) var selectTest = func() { for { select { case <-time.After(1 * time.Second): fmt.Println("Working...") case <-done: fmt.Println("Job done!") } } } go selectTest() time.Sleep(3 * time.Second) done <- true time.Sleep(500 * time.Microsecond)}
这个例子模拟的是一个简易的定时器,每隔一秒钟它都会打印 "Working..." 直到我们通过关闭 done 通道告诉它 "任务完成"。
这样的模式在你需要定时检查或者定时执行一些任务时非常有用!
代码运行结果:
Working...
Working...
Job done!
注意,如果定时器的另外 case 分支是上面已关闭 channel 场景,可能会出现异常,如下所示:
func main() { done := make(chan bool) t := time.Now() var selectTest = func() { for { select { case <-time.After(100 * time.Microsecond): fmt.Println(time.Since(t), " time.After exec, return!") return case <-done: fmt.Println("over") } }} // 关闭 chan close(done) go selectTest() time.Sleep(2 * time.Second)}
我们在并发执行之前就 close(done) 关闭了 Channel,不妨猜一下这段代码会输出什么,答案是:
...
over
over
over
601.3938ms time.After exec, return!
这是因为:done 已经被关闭了,所以当执行 case <-done 语句时会死循环此 case 分支。
但是,为什么还会执行退出 case,而且 return 时,时间来到了 601.3938ms 呢?
从上面代码中定时器 case 100 ms 执行一次,我们不难得知,程序退出时是第 6 次执行 select 语句,这里面究竟有什么魔法呢?
让我们接着往下看!
上文已经描述过,如果多个 case 满足读取条件时,select 会随机选择一个语句执行。
让我们用代码来详细描述一下:
func main() { done := make(chan int, 1024) tick := time.NewTicker(time.Second) var selectTest = func() { for i := 0; i < 10; i++ { select { case done <- i: fmt.Println(i, ", done exec") case <- tick.C: fmt.Println(i, ", time.After exec") } time.Sleep(500 * time.Millisecond) } } go selectTest() time.Sleep(5 * time.Second)}
这个例子开启了一个 goroutine 协程来运行 selectTest 函数,在函数里面 for 循环 10 次执行 select 语句。并且,select 的两个分支 case done <- i 和 case <- tick.C 都是可以执行的。
这时候,我们看一下执行结果:
0 , done exec
1 , done exec
2 , time.After exec
3 , done exec
4 , time.After exec
5 , done exec
6 , done exec
7 , done exec
8 , time.After exec
9 , done exec
注意,以上结果多次运行的打印顺序可能不一致,是正常现象!
我们可以发现,原本写入 done 通道的 2、4 和 8 不见了,说明在循环的过程中,select 的两个分支 case done <- i 和 case <- tick.C 都是执行了的。
因此,这就验证了当多个 case 同时满足时,select 会随机选择一个执行。这个设计是为了避免某个 case 出现饥饿问题,保证公平竞争而引入的。
试想一下,如果某个 case 一直执行,而某些 case 一直得不到执行,这和 select 公平选择的初衷就冲突了。
所以,Go 在十多年前新增 select 提交时就用了这种随机策略并保留至今,虽然中途有过细微的变更,但整体语义一直没有变化。
Go语言中 select用于处理多个通道(channel)的发送和接收操作,但在 Go 语言的源代码中没有直接对应的结构体。
因此,select通过runtime.scase结构体表示其中的每个 case,该结构体包含指向通道和数据元素的指针:
type scase struct {c *hchan // chanelem unsafe.Pointer // data element}
编译时,select 语句被转换成 OSELECT 节点,持有 OCASE 节点集合,每个 OCASE 代表一个可能的操作,包括空(对应 default)。
根据情况不同,编译器会优化select的处理过程。优化处理的情况分为:
非阻塞操作进行相应的编译器重写,发送使用 runtime.selectnbsend 函数进行非阻塞发送,接收方面有两种函数处理单值和双值接收。
运行时,runtime.selectgo函数通过以下几个步骤处理 select:
本文中,我们谈到了 Go 语言里 select 的基本特性和实现,提到了select与直接 Channel 操作的相似性,以及通过 default 支持非阻塞收发操作。
我们还揭示了select 底层实现的复杂性——需要编译器和运行时支持。
通过以上不难得知,Go 的 select 语句在不同场景下的行为和实现是比较奇妙的,这也是 Go 独特的数据结构,其背后的设计与优化策略都需要我们对 Go 底层有着比较完善的认知。
本文链接:http://www.28at.com/showinfo-26-50032-0.html一文搞懂Go中select的随机公平策略:并发编程的黄金法则
声明:本网页内容旨在传播知识,不代表本站观点,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。