4.协程管理
4.协程管理
如何等待所有goroutine完成?
在Go语言中,主协程等待其他协程完成主要有两种方式:使用sync.WaitGroup和channel。这两种方式各有特点,可以根据具体场景选择合适的方式。
在实际开发中,我们经常需要等待一组goroutine完成后再进行后续操作。想象一下这样的场景:你是一个项目经理,需要等待10个团队成员都完成各自的任务后,才能进行项目总结。在Go语言中,主协程就像这个项目经理,而其他协程就像团队成员。我们需要一种机制来确保所有"团队成员"都完成后,"项目经理"才能继续下一步工作。
方式一:sync.WaitGroup(推荐)
WaitGroup就像一个计数器,它的工作原理很简单:
- 当启动一个协程时,调用
Add(1)
增加计数 - 当协程完成时,调用
Done()
减少计数 - 主协程调用
Wait()
等待计数变为0
这就像项目经理在任务开始时记录"还有10个任务在进行",每当一个团队成员完成时就说"完成1个",当计数变为0时,项目经理就知道所有人都完成了。
func main() {
var wg sync.WaitGroup
wg.Add(10)
for i := 0; i < 10; i++ {
go func(num int) {
defer wg.Done()
fmt.Println(num)
}(i)
}
wg.Wait()
fmt.Println("All goroutines finished")
}
方式二:Channel
Channel方式就像团队成员完成任务后给项目经理发个消息说"我完成了"。项目经理需要收集所有10个消息后,才能确认所有人都完成了。
这种方式更灵活,因为消息里可以携带数据,但需要手动管理channel的创建和关闭。
func main() {
done := make(chan struct{}, 10)
for i := 0; i < 10; i++ {
go func(num int) {
fmt.Println(num)
done <- struct{}{}
}(i)
}
for i := 0; i < 10; i++ {
<-done
}
fmt.Println("All goroutines finished")
}
sync.WaitGroup使用计数器机制,通过Add增加计数,Done减少计数,当计数为0时,Wait返回。这种方式适合等待一组goroutine完成的场景,代码简洁,使用方便。而Channel方式通过channel的阻塞特性实现等待,可以传递数据,更灵活,适合需要goroutine间通信的场景,但需要手动管理channel的生命周期。
实际开发建议:
- 简单等待场景:如果只需要等待goroutine完成,使用sync.WaitGroup更简单直接
- 需要传递数据:如果需要在等待的同时传递数据,使用channel更合适
- 错误处理:无论使用哪种方式,都要注意处理goroutine中的panic
- 超时控制:考虑使用context控制超时,避免无限等待
- 选择原则:sync.WaitGroup更简单直接,而channel则更灵活但需要更多的管理
WaitGroup是如何实现协程等待的?
WaitGroup通过一个计数器机制实现协程等待。当调用Add方法时增加计数,调用Done方法时减少计数,而Wait方法则会在计数大于0时阻塞,直到计数变为0。这种设计使得主协程可以等待一组子协程完成后再继续执行。
WaitGroup的设计体现了Go语言并发编程中的一个重要模式:等待一组并发任务完成。这种设计有几个巧妙之处:首先,它使用了原子操作来保证计数器的并发安全,避免了使用互斥锁带来的开销。其次,它通过信号量机制实现了高效的等待和唤醒,当计数器变为0时,会自动唤醒所有等待的协程。
在实际应用中,WaitGroup常用于以下场景:等待一组goroutine完成后再进行后续操作,比如等待所有HTTP请求完成后再处理结果,或者等待所有文件处理完成后再进行汇总。
sync.Once如何保证代码只执行一次?
sync.Once通过一个原子性的标识位和互斥锁来保证代码段只执行一次。当标识位为0时,表示函数还未执行,此时会获取互斥锁并执行函数;当标识位为1时,表示函数已经执行过,直接返回。这种设计既保证了线程安全,又避免了重复执行。
sync.Once的设计体现了Go语言中"只执行一次"这个常见需求的优雅解决方案。它巧妙地结合了原子操作和互斥锁,通过双重检查机制来保证线程安全。第一次检查使用原子操作快速判断函数是否已执行,避免不必要的锁竞争;第二次检查在获取锁后进行,确保在并发环境下只有一个goroutine能执行目标函数。这种设计既保证了性能,又确保了线程安全。在实际开发中,sync.Once常用于单例模式的实现、配置文件的加载、数据库连接的初始化等场景,这些场景都要求初始化代码只执行一次,避免重复初始化带来的资源浪费和潜在问题。
使用示例:
type Config struct {
DatabaseURL string
APIKey string
Timeout time.Duration
}
var (
config *Config
configOnce sync.Once
)
func GetConfig() *Config {
configOnce.Do(func() {
config = &Config{
DatabaseURL: "mysql://localhost:3306/mydb",
APIKey: "your-api-key-here",
Timeout: 30 * time.Second,
}
})
return config
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d 开始获取配置\n", id)
cfg := GetConfig()
fmt.Printf("Goroutine %d 获取到配置: %+v\n", id, cfg)
}(i)
}
// 模拟多个goroutine同时获取数据库连接
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("Goroutine %d 开始获取数据库连接\n", id)
db := GetDatabase()
fmt.Printf("Goroutine %d 获取到数据库: %s\n", id, db.name)
}(i)
}
wg.Wait()
fmt.Println("所有goroutine执行完成")
}
示例说明:
- 配置初始化:
GetConfig()
函数使用sync.Once确保配置只初始化一次,即使多个goroutine同时调用 - 单例模式:
GetDatabase()
函数实现了线程安全的单例模式 - 性能优势:第一次调用时会执行初始化,后续调用直接返回已初始化的实例
- 线程安全:多个goroutine同时调用时,只有一个会执行初始化逻辑
运行结果:
Goroutine 0 开始获取配置
Goroutine 1 开始获取配置
Goroutine 2 开始获取配置
Goroutine 3 开始获取配置
Goroutine 4 开始获取配置
正在初始化配置...
配置初始化完成
Goroutine 0 获取到配置: &{DatabaseURL:mysql://localhost:3306/mydb APIKey:your-api-key-here Timeout:30s}
Goroutine 1 获取到配置: &{DatabaseURL:mysql://localhost:3306/mydb APIKey:your-api-key-here Timeout:30s}
Goroutine 2 获取到配置: &{DatabaseURL:mysql://localhost:3306/mydb APIKey:your-api-key-here Timeout:30s}
Goroutine 3 获取到配置: &{DatabaseURL:mysql://localhost:3306/mydb APIKey:your-api-key-here Timeout:30s}
Goroutine 4 获取到配置: &{DatabaseURL:mysql://localhost:3306/mydb APIKey:your-api-key-here Timeout:30s}
Goroutine 0 开始获取数据库连接
Goroutine 1 开始获取数据库连接
Goroutine 2 开始获取数据库连接
正在连接数据库...
数据库连接成功
Goroutine 0 获取到数据库: MySQL
Goroutine 1 获取到数据库: MySQL
Goroutine 2 获取到数据库: MySQL
所有goroutine执行完成
从运行结果可以看出,虽然多个goroutine同时调用,但配置初始化和数据库连接都只执行了一次,这正是sync.Once的核心作用。
