
在Go语言的并发编程中,select语句是处理多通道通信的强大工具。然而,当它与default子句结合使用时,如果不深入理解Go调度器的工作机制,可能会遇到一些意想不到的行为,甚至导致程序陷入无限循环。一个经典的示例源于Go Tour的并发爬虫练习,其中一个微小的改动——在select的default分支中添加或移除一个fmt.Print("")语句——竟然能决定程序是正常终止还是永久挂起。
让我们首先审视原始问题中的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:
// 关键区别在这里:
// 当os.Args[1]=="ok"时,会执行fmt.Print("")
// 当os.Args[1]=="nogood"时,不会执行fmt.Print("")
if os.Args[1]=="ok" {
fmt.Print("") // 这一行是问题的关键
}
if crawling == 0 {
goto END
}
}
}
END:
return
}在这个Crawl函数中,主循环通过select语句监听两个通道:toDoList(待抓取任务)和doneCrawling(Goroutine完成信号)。crawling变量用于跟踪当前正在进行的爬取Goroutine数量。当crawling变为0时,意味着所有任务都已完成,程序应该终止。
令人困惑的是,当命令行参数为"ok"时(即default分支执行fmt.Print("")),程序能正常终止;而当参数为"nogood"时(default分支为空),程序会无限运行。这暗示了fmt.Print("")的存在对Go调度器产生了某种影响。
为了理解上述现象,我们首先需要回顾select语句,特别是带有default子句时的行为:
select无default: 如果select语句中没有default子句,它会阻塞当前Goroutine,直到其监听的某个通道操作准备就绪(即可以发送或接收数据)。一旦有通道准备就绪,select会随机选择一个执行。
select有default: 如果select语句中包含default子句,它将是非阻塞的。这意味着,如果没有任何通道操作准备就绪,select会立即执行default子句,而不会阻塞。
在我们的爬虫示例中,select语句包含default子句,因此它是一个非阻塞的循环。
当所有爬取任务完成,crawling变量减至0时,程序应该退出。然而,在"nogood"模式下,它陷入了无限循环。问题出在default子句的执行频率和Go调度器的工作方式上。
default的快速空循环: 当toDoList和doneCrawling通道都为空(即没有新的任务,也没有Goroutine完成)时,select语句会迅速执行default分支。在"nogood"模式下,default分支内部没有任何操作,仅仅是检查crawling == 0。这个检查是一个非常快的CPU操作。
Go调度器的协同式调度: Go调度器采用的是协同式(cooperative)调度,Goroutine会在某些点“让出”CPU,允许调度器切换到其他Goroutine。这些让出点通常包括:
调度陷阱: 在"nogood"模式下,default分支执行得极其迅速,它不包含任何显式的I/O操作、通道操作或可能导致阻塞的函数调用。这意味着,主Goroutine在select循环的default分支中形成了一个“紧密循环”(tight loop),它不断地执行crawling == 0的检查,并且几乎没有给Go调度器提供一个合适的时机来中断并切换到其他Goroutine。
即使其他crawl Goroutine已经完成了它们的任务,并尝试向doneCrawling通道发送信号,但由于主Goroutine的紧密循环霸占了CPU,调度器可能无法及时地将CPU控制权交给那些等待发送的crawl Goroutine。结果是,doneCrawling通道无法接收到信号,crawling计数也无法减为0,从而导致主Goroutine永远无法满足crawling == 0的退出条件,陷入无限循环。
现在,我们来解释为什么fmt.Print("")能解决问题。
fmt.Print函数虽然只是打印一个空字符串,但它是一个I/O操作。在Go的运行时环境中,I/O操作通常会涉及到系统调用。系统调用是Go调度器的一个重要让出点。当Goroutine执行系统调用时,Go调度器有机会将当前Goroutine标记为阻塞,然后切换到另一个可运行的Goroutine。
因此,当fmt.Print("")存在时,即使default分支被频繁执行,每次执行它都会提供一个调度器切换的机会。这个短暂的让出足以让那些完成任务的crawl Goroutine有机会向doneCrawling通道发送信号。一旦doneCrawling接收到信号,crawling计数就会减少,最终达到0,程序就能正常终止。
理解了问题的根源后,我们可以通过调整代码结构来避免这种调度陷阱。核心思想是确保终止条件的检查能够被调度器及时感知,并且不会被一个紧密的非让出循环所阻碍。
最直接有效的解决方案是将终止条件的检查从select的default分支中移出,放到select语句的外部,紧随其后。这样,无论select是否执行了通道操作,或者执行了default分支,循环体总会在每次迭代结束时检查终止条件。
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分支,或仅保留非阻塞逻辑(不包含终止条件检查)
// default:
// // 此时default分支可以用于执行一些非阻塞的周期性任务,
// // 但不应包含终止条件检查,除非其内部有明确的调度点。
}
// 将终止条件检查移到select外部
if crawling == 0 {
break // 使用break跳出for循环,比goto更清晰
}
}
return
}通过这种修改,即使select没有立即发现可用的通道操作,并且没有default分支,或者default分支不包含终止逻辑,for循环的每次迭代都会在select之后检查crawling == 0。这确保了在crawling变为0时,程序能够及时感知并退出。
值得一提的是,如果将GOMAXPROCS环境变量设置为大于1的值(例如GOMAXPROCS=2 go run 71_hang.go nogood),原始的"nogood"模式也可能正常工作。这进一步证实了问题是由于调度器在单P(Processor)环境下,一个Goroutine的紧密循环霸占CPU所致。当有多个P可用时,即使一个P被紧密循环占用,其他P仍然可以调度并执行那些等待发送到doneCrawling的Goroutine,从而避免死锁。
Go语言的select语句结合default子句提供了非阻塞的通道通信能力,但其在紧密循环中的使用需要开发者对Go调度器有深刻的理解。一个看似无害的空default分支,在特定条件下,可能因缺乏显式让出点而导致主Goroutine霸占CPU,阻止其他Goroutine执行,进而引发程序无法终止的无限循环。通过将终止条件检查移到select外部,或确保default分支包含调度器让出点,可以有效避免这类问题。这再次强调了在Go并发编程中,深入理解调度机制的重要性。
以上就是Go并发编程:深入理解select、default与Goroutine调度陷阱的详细内容,更多请关注php中文网其它相关文章!
编程怎么学习?编程怎么入门?编程在哪学?编程怎么学才快?不用担心,这里为大家提供了编程速学教程(入门课程),有需要的小伙伴保存下载就能学习啦!
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号