Skip to content

008.jpg

从 Sleep 到 Select:用一个例子掌握 Go 并发编程精髓

今年早些时候,我写过一篇 《每秒打印一个数字:从简单到晦涩的多种实现》,用的是 Node.js 环境,演示了每秒打印数字的几种实现。由于 JavaScript 是单线程,方法不多,顶多靠算法优化。 这次,我写下 Go 版本,充分利用了 Golang 并发特性,实现更稳定、更灵活的定时任务。这类题目也很常见于面试:我记得去年面试前端岗位时就碰到过.

在开发中,我们经常会遇到需要定时或延时执行任务的场景。一个经典且简单的入门问题是:“如何每秒钟在控制台打印一个数字,从 1 打印到 10?”

基础之路:最直观的实现

在刚接触编程时,我们最先想到的往往是“让程序暂停一下”的思路。

time.Sleep

这是最简单、最直接的方法。time.Sleep 会阻塞当前的 Goroutine(在这里是主 Goroutine),暂停指定的时间。

go
// 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),然后在指定的时间后向该通道发送一个时间值。我们可以通过等待接收这个通道的信号来达到暂停的效果。

go
// 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 发送信号。

go
// 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 在不同的延迟后打印数字。

go
// 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 中。

go
// 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 的正确打开方式之一:将独立的、连续的任务封装成一个单元,使其与其他代码并发执行

selectTicker 的强强联合

select 语句是 Go 并发编程的“调度中心”。允许一个 Goroutine 等待多个通道操作。将 selectTicker 结合是 Go 中处理定时任务的黄金标准。

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 包正是为此而生。

go
// 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.AfterChannel概念简单不适用于循环,有资源开销
time.TickerChannel高效,资源复用基础的定时器,需要手动停止
多个 GoroutinesGoroutine-错误用法,不能实现顺序间隔任务
单个 GoroutineGoroutine, WaitGroup正确的并发模型,不阻塞主线程需要同步机制(如 WaitGroup)
select + Tickerselect, Ticker灵活,可扩展,是 Go 的惯用范式-
context + Tickercontext, select最佳实践,健壮,可管理生命周期适用于需要优雅关闭的长期任务

更重要的是理解了不同方法背后的设计哲学。从简单的阻塞到高效的定时器,从对并发的误解到掌握正确的并发模式,再到最终使用 context 构建可维护的健壮代码,这正是每个 Go 开发者的成长之路。

累计访问