
在go语言中,select语句是实现并发模式的核心机制之一,它允许goroutine等待多个通信操作。然而,当select语句包含default子句时,其行为会变得非阻塞,这在某些情况下可能引入不易察觉的并发问题。本文将通过一个go语言爬虫示例,详细剖析select与default子句在特定场景下的交互,以及它如何影响go调度器的行为。
我们以一个简单的Go语言网页爬虫为例,该爬虫使用goroutine并发抓取网页,并通过通道(channel)进行任务调度和完成信号的传递。核心的爬虫逻辑Crawl函数如下所示:
package main
import (
"fmt"
"os"
"time" // Added for demonstration of busy-waiting
)
type Fetcher interface {
Fetch(url string) (body string, urls []string, err error)
}
func crawl(todo Todo, fetcher Fetcher,
todoList chan Todo, done chan bool) {
body, urls, err := fetcher.Fetch(todo.url)
if err != nil {
fmt.Println(err)
} else {
fmt.Printf("found: %s %q\n", todo.url, body)
for _, u := range urls {
todoList <- Todo{u, todo.depth - 1}
}
}
done <- true // 发送完成信号
return
}
type Todo struct {
url string
depth int
}
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: // 无其他通道操作时执行
if os.Args[1] == "ok" {
fmt.Print("") // 关键差异点
}
if crawling == 0 { // 所有任务完成
goto END
}
// time.Sleep(time.Millisecond) // 可用于缓解忙等待,但不是根本解决方案
}
}
END:
return
}
func main() {
// 模拟的Fetcher实现
var fetcher = &fakeFetcher{
"http://golang.org/": &fakeResult{
"The Go Programming Language",
[]string{"http://golang.org/pkg/", "http://golang.org/cmd/"},
},
"http://golang.org/pkg/": &fakeResult{
"Packages",
[]string{"http://golang.org/", "http://golang.org/cmd/", "http://golang.org/pkg/fmt/", "http://golang.org/pkg/os/"},
},
"http://golang.org/pkg/fmt/": &fakeResult{
"Package fmt",
[]string{"http://golang.org/", "http://golang.org/pkg/"},
},
"http://golang.org/pkg/os/": &fakeResult{
"Package os",
[]string{"http://golang.org/", "http://golang.org/pkg/"},
},
}
Crawl("http://golang.org/", 4, fetcher)
fmt.Println("Crawling finished.")
}
type fakeFetcher map[string]*fakeResult
type fakeResult struct {
body string
urls []string
}
func (f *fakeFetcher) Fetch(url string) (string, []string, error) {
if res, ok := (*f)[url]; ok {
return res.body, res.urls, nil
}
return "", nil, fmt.Errorf("not found: %s", url)
}当我们使用go run your_program.go ok运行上述代码时,程序能够正常终止。然而,如果使用go run your_program.go nogood运行,程序将无限期地挂起,无法终止。唯一的区别在于select语句的default子句中是否包含fmt.Print("")。
问题的核心在于select语句与default子句的交互方式,以及Go调度器的行为。
select与default的非阻塞特性: 当select语句包含default子句时,它会变为非阻塞模式。这意味着如果没有任何通道操作(发送或接收)准备就绪,select不会阻塞等待,而是立即执行default子句中的代码。在上述示例中,toDoList和doneCrawling通道在某些时刻可能没有可用的数据或空间,此时default子句就会被频繁执行。
忙等待(Busy-Waiting)与调度器饥饿: 在nogood场景下,default子句中没有fmt.Print("")。当toDoList和doneCrawling通道暂时没有活动时,主Crawl goroutine会以极快的速度反复执行default子句中的if crawling == 0 { goto END }检查。这是一个典型的忙等待循环,它会持续占用CPU,导致Go调度器无法有效地将CPU时间分配给其他重要的goroutine,尤其是那些负责实际爬取任务(crawl函数)并向toDoList和doneCrawling发送数据的goroutine。这些crawl goroutine因此被“饿死”,无法及时将任务或完成信号发送到通道,从而使得主Crawl goroutine的select语句永远无法从通道接收到数据,陷入无限的忙等待。
fmt.Print("")的意外作用:fmt.Print函数涉及底层I/O操作(即使是打印空字符串)。在Go语言中,涉及系统调用的操作(如I/O)是调度器显式的让出点(yield point)。当fmt.Print("")被执行时,当前goroutine会暂停执行,等待I/O操作完成,这为Go调度器提供了机会去运行其他处于就绪状态的goroutine。在这种情况下,被饿死的crawl goroutine得以执行,它们能够将数据发送到toDoList和doneCrawling通道,从而打破主Crawl goroutine的忙等待状态,使其能够接收到数据并最终正常终止。
另一个佐证是,如果设置GOMAXPROCS=2(即允许Go程序使用两个操作系统线程),程序在nogood模式下也能正常运行。这是因为有了更多的操作系统线程,即使一个线程陷入忙等待,另一个线程仍有能力调度并执行其他goroutine,从而缓解了调度器饥饿问题。
为了避免这种忙等待和调度器饥饿问题,我们应该重新设计select语句的结构,确保在没有通道活动时,主goroutine能够适当地阻塞或让出CPU。最直接且推荐的解决方案是将终止条件检查逻辑移到select语句之外,或者确保default子句中包含明确的让出机制(例如runtime.Gosched()或time.Sleep(),但这通常不是最佳实践)。
以下是改进后的Crawl函数中的for循环:
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--
}
// 将终止条件检查移到select外部
if crawling == 0 {
break // 退出循环
}
}
fmt.Println("所有爬取任务已完成。") // 确认退出
return
}在这个改进后的代码中:
这种结构确保了主goroutine不会陷入忙等待,而是高效地利用Go调度器的阻塞机制,只有在有实际工作可做时才被唤醒。
通过对这个案例的深入分析,我们不仅解决了特定的程序挂起问题,更重要的是,加深了对Go语言中select语句、default子句以及Go调度器行为的理解,这对于编写高效、健壮的并发程序至关重要。
以上就是Go并发编程:select与default陷阱及调度器行为分析的详细内容,更多请关注php中文网其它相关文章!
编程怎么学习?编程怎么入门?编程在哪学?编程怎么学才快?不用担心,这里为大家提供了编程速学教程(入门课程),有需要的小伙伴保存下载就能学习啦!
Copyright 2014-2025 https://www.php.cn/ All Rights Reserved | php.cn | 湘ICP备2023035733号