从 Sleep 到 Select:用一个例子掌握 Go 并发编程精髓
今年早些时候,我写过一篇 《每秒打印一个数字:从简单到晦涩的多种实现》,用的是 Node.js 环境,演示了每秒打印数字的几种实现。由于 JavaScript 是单线程,方法不多,顶多靠算法优化。 这次,我写下 Go 版本,充分利用了 Golang 并发特性,实现更稳定、更灵活的定时任务。这类题目也很常见于面试:我记得去年面试前端岗位时就碰到过.
在开发中,我们经常会遇到需要定时或延时执行任务的场景。一个经典且简单的入门问题是:“如何每秒钟在控制台打印一个数字,从 1 打印到 10?”
基础之路:最直观的实现
在刚接触编程时,我们最先想到的往往是“让程序暂停一下”的思路。
time.Sleep
这是最简单、最直接的方法。time.Sleep
会阻塞当前的 Goroutine(在这里是主 Goroutine),暂停指定的时间。
// UseTimeSleep: 使用 time.Sleep,这是最简单直接的方法,程序会阻塞一秒
func UseTimeSleep() {
fmt.Println("\n--- 使用 time.Sleep ---")
for i := 1; i <= 10; i++ {
time.Sleep(time.Second) // 阻塞当前 goroutine 一秒
fmt.Println(i)
}
}
优点:代码清晰,易于理解。 缺点:在 Sleep
期间,当前的 Goroutine 被完全阻塞,无法执行任何其他操作,效率较低。
time.After
time.After
函数提供了一种略有不同的思路。返回一个通道(channel
),然后在指定的时间后向该通道发送一个时间值。我们可以通过等待接收这个通道的信号来达到暂停的效果。
// UseTimeAfter: 使用 time.After,每次循环都会创建一个新的定时器,相对低效
func UseTimeAfter() {
fmt.Println("\n--- 使用 time.After ---")
for i := 1; i <= 10; i++ {
<-time.After(time.Second) // 等待一秒后通道接收到信号
fmt.Println(i)
}
}
虽然功能上实现了需求,但在循环中使用 time.After
是一个不推荐的做法。每次循环,time.After
都会创建一个新的定时器和关联的通道。这会带来不必要的内存分配和垃圾回收压力,尤其是在循环次数很多或频率很高的情况下。
效率提升: time.Ticker
为了解决 time.After
在循环中的低效问题,Go 提供了 time.NewTicker
。Ticker(定时器),创建后会按照设定的时间间隔,持续地向其内部的通道 C
发送信号。
// UseTimeTicker: 使用 time.Ticker,这是一个高效的定时器,会每隔一秒向通道发送一个信号
func UseTimeTicker() {
fmt.Println("--- 使用 time.Ticker ---")
ticker := time.NewTicker(time.Second)
defer ticker.Stop() // 养成好习惯,确保在函数退出时停止 ticker
for i := 1; i <= 10; i++ {
<-ticker.C // 等待 ticker 发送信号
fmt.Println(i)
}
}
关键点:
- 高效:整个循环只使用一个 Ticker,避免了重复创建资源的开销。
- 资源释放:Ticker 是一个需要手动管理的资源。
defer ticker.Stop()
是一个非常好的习惯,它能确保在函数结束时停止 Ticker 并释放相关资源,防止内存泄漏。
踏入并发:Goroutine
让我们尝试使用 Go 强大的并发特性——Goroutine 来解决这个问题。一个常见的误区是认为“将任务扔进多个 Goroutine 就实现了并发”。让我们看看会发生什么。
使用 Goroutine 和 Channel/WaitGroup(常见的误区)
下面的两个函数,一个使用 Channel,一个使用 sync.WaitGroup
,都尝试启动 10 个 Goroutine,并让每个 Goroutine 在不同的延迟后打印数字。
// UseChannel: 使用通道和多个 goroutine,此方法会启动10个goroutine,但打印顺序和间隔不确定
func UseChannel() {
fmt.Println("\n--- 使用通道和多个 goroutine (注意:打印顺序和间隔不确定) ---")
ch := make(chan int)
for i := 1; i <= 10; i++ {
go func(i int) {
time.Sleep(time.Second * time.Duration(i)) // 每个 goroutine 休眠不同时间
ch <- i
}(i)
}
for i := 1; i <= 10; i++ {
fmt.Println(<-ch) // 从通道接收结果,顺序不固定
}
}
这是一个典型的并发误用案例:为了实现一个本质上是顺序的任务(每隔一秒做一件事),而错误地使用了并行的思维。
Go 的惯用范式
那么,如何正确地使用并发来处理我们的问题呢?Go 的并发应该是为了让程序的不同部分可以独立运行,而不是把一个顺序任务拆散。
正确的 Goroutine 用法
如果我们希望打印数字这个“任务”不阻塞主程序,可以把它整体放进一个单独的 Goroutine 中。
// UseSingleGoroutine: 使用单个 goroutine 来实现正确的顺序和间隔
func UseSingleGoroutine() {
fmt.Println("--- 使用单个 goroutine 来实现正确的顺序和间隔 ---")
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for i := 1; i <= 10; i++ {
fmt.Println(i)
time.Sleep(time.Second) // 每次打印后休眠一秒
}
}()
wg.Wait()
}
解释:这里,我们只启动了一个 Goroutine。这个 Goroutine 内部的逻辑是顺序的(循环、打印、休眠)。这完美地实现了我们的需求,同时主 Goroutine 可以通过 wg.Wait()
等待其完成,或者继续执行其他任务。这才是 Goroutine 的正确打开方式之一:将独立的、连续的任务封装成一个单元,使其与其他代码并发执行。
select
与 Ticker
的强强联合
select
语句是 Go 并发编程的“调度中心”。允许一个 Goroutine 等待多个通道操作。将 select
和 Ticker
结合是 Go 中处理定时任务的黄金标准。
// UseSelectAndTicker: 使用 select 语句和 time.NewTicker
func UseSelectAndTicker() {
fmt.Println("\n--- 使用 select 和 time.NewTicker ---")
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for i := 1; i <= 10; i++ {
select {
case <-ticker.C:
// select 语句会等待 ticker.C 通道收到信号
fmt.Println(i)
}
}
}
虽然在这个简单例子中,它和直接读取 ticker.C
效果一样,但 select
的强大之处在于其扩展性。我们可以轻松地在 select
中加入其他 case
,比如处理取消信号、接收其他数据等。
context
与生命周期管理
在真实世界的应用中,任何一个长时间运行的 Goroutine 都应该具备被“优雅地”关闭的能力。例如,当用户请求超时或服务需要关闭时,我们希望相关的 Goroutine 能够停止工作并释放资源。context
包正是为此而生。
// UseContextWithTicker: 使用 context 和 time.NewTicker 来管理生命周期
func UseContextWithTicker() {
fmt.Println("\n--- 使用 context 和 time.NewTicker ---")
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 确保函数退出时调用 cancel()
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
i := 1
for {
select {
case <-ticker.C:
fmt.Println(i)
i++
if i > 10 {
return
}
case <-ctx.Done():
fmt.Println("上下文被取消,提前退出。")
return
}
}
}
context.WithCancel
: 创建一个可以被手动取消的上下文。defer cancel()
: 这是一个关键实践,确保在任何情况下cancel
函数都会被调用。select
中的<-ctx.Done()
:select
语句现在监听两个通道。一个是 Ticker 的定时信号,另一个是来自上下文的“取消”信号。一旦外部调用了cancel()
函数,ctx.Done()
通道就会关闭,该case
被触发,Goroutine 便可以安全退出循环,实现优雅关闭。
总结
我们从一个简单的问题出发,探索了多种解决方案,并最终抵达了 Go 并发编程的核心地带。让我们回顾一下这次的旅程:
方法 | 核心技术 | 优点 | 缺点/适用场景 |
---|---|---|---|
time.Sleep | 阻塞 | 简单直接 | 效率低,会阻塞 Goroutine |
time.After | Channel | 概念简单 | 不适用于循环,有资源开销 |
time.Ticker | Channel | 高效,资源复用 | 基础的定时器,需要手动停止 |
多个 Goroutines | Goroutine | - | 错误用法,不能实现顺序间隔任务 |
单个 Goroutine | Goroutine, WaitGroup | 正确的并发模型,不阻塞主线程 | 需要同步机制(如 WaitGroup) |
select + Ticker | select , Ticker | 灵活,可扩展,是 Go 的惯用范式 | - |
context + Ticker | context , select | 最佳实践,健壮,可管理生命周期 | 适用于需要优雅关闭的长期任务 |
更重要的是理解了不同方法背后的设计哲学。从简单的阻塞到高效的定时器,从对并发的误解到掌握正确的并发模式,再到最终使用 context
构建可维护的健壮代码,这正是每个 Go 开发者的成长之路。