
在go语言中,select语句是实现多路复用i/o和并发控制的关键工具。它允许一个goroutine等待多个通信操作,并在其中任意一个准备就绪时执行相应的代码块。select语句的基本行为是阻塞的:如果没有任何case准备就绪,它会一直等待,直到某个case可以执行。
然而,当select语句包含一个default子句时,其行为会发生根本性变化。如果没有任何case准备就绪,select将立即执行default子句,而不会阻塞。这种非阻塞特性在某些场景下非常有用,例如实现非阻塞的通道发送或接收。
考虑以下Go语言爬虫示例中的Crawl函数:
func Crawl(url string, depth int, fetcher Fetcher) {
visited := make(map[string]bool)
doneCrawling := make(chan bool, 100)
toDoList := make(chan Todo, 100)
toDoList <- Todo{url, depth}
crawling := 0
for {
select {
case todo := <-toDoList:
if todo.depth > 0 && !visited[todo.url] {
crawling++
visited[todo.url] = true
go crawl(todo, fetcher, toDoList, doneCrawling)
}
case <-doneCrawling:
crawling--
default:
// 这里的fmt.Print("")是关键
if os.Args[1]=="ok" {
fmt.Print("") // 有时能让程序终止
}
if crawling == 0 {
goto END
}
}
}
END:
return
}在这个Crawl函数中,主循环使用select来处理待爬取任务(toDoList)和已完成任务(doneCrawling)。当toDoList和doneCrawling两个通道都没有数据时,select会立即执行default子句。
问题在于,如果default子句中的代码执行速度非常快,并且没有显式地让出CPU(yield),那么select循环将形成一个紧密的忙循环(busy-loop)。在单核或GOMAXPROCS=1的环境下,这个忙循环会独占CPU,导致其他goroutine(例如由go crawl(...)启动的爬取任务)无法获得调度执行的机会。即使有多个核心,这种忙循环也会消耗大量CPU资源,并且可能延迟其他goroutine的执行,尤其是在它们需要发送数据到toDoList或doneCrawling通道时。
立即学习“go语言免费学习笔记(深入)”;
示例代码中有一个有趣的现象:当default子句中包含fmt.Print("")时,程序能够正常终止;而移除它,程序则会无限期地运行。这并非fmt.Print本身有什么特殊魔力,而是因为它通常会涉及系统调用(syscall)。
Go语言的调度器在遇到系统调用时,会将当前的goroutine标记为阻塞,并调度其他goroutine运行。即使fmt.Print("")只是打印一个空字符串,它仍然会触发底层I/O操作,从而导致系统调用。这个系统调用为Go调度器提供了一个自然的“让出点”(yielding point),使得其他等待执行的goroutine有机会获得CPU时间,进而向toDoList或doneCrawling发送数据,最终使crawling计数归零并允许程序终止。
如果没有fmt.Print(""),default子句可能只包含简单的条件判断和goto语句,这些操作在用户空间执行,不涉及系统调用,因此Go调度器可能不会主动让出CPU。这使得主goroutine陷入无限的忙循环,饿死其他goroutine。
为了避免这种忙循环问题,我们应该重新设计select循环,确保在没有通道操作时,程序不会无限期地空转。一种常见的做法是将终止条件检查放在select语句之外,或者在default子句中显式地引入一个短时间的休眠(例如time.Sleep),但更好的方法是避免default子句的忙循环。
以下是修复后的Crawl函数示例:
func Crawl(url string, depth int, fetcher Fetcher) {
visited := make(map[string]bool)
doneCrawling := make(chan bool, 100)
toDoList := make(chan Todo, 100)
toDoList <- Todo{url, depth}
crawling := 0
for {
// 将终止条件检查移到select之外,或者在select内部没有default
// 这样当所有goroutine都完成时,crawling会变为0,循环会退出
if crawling == 0 && len(toDoList) == 0 { // 确保没有待处理任务
break // 所有任务完成,退出循环
}
select {
case todo := <-toDoList:
if todo.depth > 0 && !visited[todo.url] {
crawling++
visited[todo.url] = true
go crawl(todo, fetcher, toDoList, doneCrawling)
}
case <-doneCrawling:
crawling--
// 移除default子句,让select在没有通道活动时阻塞
// 这样主goroutine会等待其他goroutine完成任务或发送新任务
}
}
return
}在这个改进版本中:
select语句是Go并发编程中的强大工具,但其default子句的使用需要特别小心。当select与default结合使用时,如果没有可用的通信操作,它会立即执行default,如果default子句没有让出CPU,可能会导致主goroutine陷入忙循环,从而饿死其他goroutine,阻止程序正常终止。
关键点在于理解Go调度器的工作方式:系统调用通常是调度器让出CPU的良好时机。因此,像fmt.Print这样的I/O操作,即使看起来微不足道,也可能无意中解决了调度问题。
最佳实践是避免在default子句中创建紧密的循环,或者完全移除default子句,让select阻塞等待通道活动。当需要判断所有并发任务是否完成时,应该设计清晰的同步机制(如计数器、sync.WaitGroup或精确的通道管理)来确保程序能够正确、优雅地终止。
以上就是深入理解Go语言中select与default的调度行为的详细内容,更多请关注php中文网其它相关文章!
每个人都需要一台速度更快、更稳定的 PC。随着时间的推移,垃圾文件、旧注册表数据和不必要的后台进程会占用资源并降低性能。幸运的是,许多工具可以让 Windows 保持平稳运行。
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号