Go Goroutine调度详解:为何无限循环会阻塞其他协程?

Go Goroutine调度详解:为何无限循环会阻塞其他协程?

一个go goroutine中的无限循环若不主动让出cpu,可能会阻塞其他goroutine的执行,导致程序行为异常。这是因为go的调度器采用协作式调度机制,要求goroutine在特定时机将控制权交还给调度器。本文将深入探讨go goroutine的调度原理,列举常见的让出cpu时机,并提供避免此类阻塞的策略,包括使用`runtime.gosched()`。

Go Goroutine阻塞现象分析

Go语言中,并发编程的核心是goroutine,它们是轻量级的执行单元,由Go运行时调度器管理。然而,不当的goroutine设计可能导致意想不到的阻塞问题。考虑以下代码示例:

package main  import (     "fmt"     "time"     // "runtime" // 后面会用到 )  func main() {     timeout := make(chan int)     go func() {         time.Sleep(time.Second) // 注意:原始问题中是time.SLeep,这里已修正         timeout <- 1     }()      res := make(chan int)     go func() {         for {             // 这个无限循环不会主动让出CPU             // runtime.Gosched() // 如果加上这一行,问题会解决         }         res <- 1 // 永远不会执行到这里     }()      select {     case <-timeout:         fmt.Println("timeout")     case <-res:         fmt.Println("res")     } }

这段代码的预期行为是,在约一秒后,timeout通道接收到值,然后程序打印”timeout”并退出。然而,实际运行时,程序会无限期地运行下去,永远不会打印”timeout”。这是因为第二个goroutine中的for{}无限循环占据了CPU,阻止了调度器将控制权交给第一个goroutine,从而导致time.Sleep(time.Second)无法完成并向timeout通道发送数据。

Go的协作式调度机制

Go语言的调度器采用的是协作式调度(Cooperative Scheduling)模型,这意味着goroutine需要主动或在特定操作下“协作”地将CPU控制权让出给调度器,以便调度器可以将CPU分配给其他等待运行的goroutine。与操作系统层面的抢占式调度(Preemptive Scheduling)不同,协作式调度不会强制中断一个正在运行的goroutine,除非它执行了某些特定操作。

在Go 1.14版本之前,Go的调度器是纯粹的协作式调度。从Go 1.14开始,引入了基于信号的异步抢占式调度,主要用于处理长时间运行的循环,但在某些极端CPU密集型场景下,仍可能出现类似上述的阻塞问题。理解协作式调度的基本原则对于编写健壮的Go并发程序至关重要。

Goroutine让出CPU的常见时机

一个goroutine在以下情况下会主动或被动地将CPU控制权让给调度器:

  1. 无缓冲通道的发送/接收操作 (unbuffered chan send/recv):当goroutine尝试对一个无缓冲通道进行发送或接收操作,而没有其他goroutine准备好匹配的操作时,当前goroutine会阻塞并让出CPU。
  2. 系统调用 (syscalls):包括文件I/O、网络I/O等操作。当goroutine执行系统调用时,它会阻塞并让出CPU,Go调度器可以利用这段时间运行其他goroutine。
  3. 内存分配 (memory allocation):在进行大内存分配时,Go运行时可能会触发调度。
  4. 调用 time.Sleep():显式调用time.Sleep()会使当前goroutine进入休眠状态,并让出CPU。
  5. 显式调用 runtime.Gosched():这是手动让出CPU的最直接方式。

避免Goroutine阻塞的策略

为了避免上述无限循环导致的阻塞问题,我们需要确保CPU密集型goroutine能够周期性地让出CPU。

1. 使用 runtime.Gosched() 手动让出

对于纯粹的CPU密集型循环,如果其中不包含任何I/O、通道操作或time.Sleep等自然让出点,那么在循环内部周期性地调用runtime.Gosched()是确保调度器能够切换到其他goroutine的有效方法。

Go Goroutine调度详解:为何无限循环会阻塞其他协程?

百度·度咔剪辑

度咔剪辑,百度旗下独立视频剪辑App

Go Goroutine调度详解:为何无限循环会阻塞其他协程?3

查看详情 Go Goroutine调度详解:为何无限循环会阻塞其他协程?

修改上述阻塞代码,在无限循环中加入runtime.Gosched():

package main  import (     "fmt"     "runtime" // 导入runtime包     "time" )  func main() {     timeout := make(chan int)     go func() {         time.Sleep(time.Second)         timeout <- 1     }()      res := make(chan int)     go func() {         for {             runtime.Gosched() // 在循环中显式让出CPU         }         // res <- 1 // 仍然不会执行到这里,因为循环是无限的     }()      select {     case <-timeout:         fmt.Println("timeout")     case <-res:         fmt.Println("res")     }     // 为了看到timeout输出,需要给主goroutine一点时间,     // 或者在select之后加一个time.Sleep(2 * time.Second)     time.Sleep(2 * time.Second) // 确保主goroutine不会过早退出 }

现在,运行这段代码,你会发现程序会在大约一秒后打印”timeout”,然后继续运行直到time.Sleep(2 * time.Second)结束。runtime.Gosched()确保了无限循环的goroutine不会长时间独占CPU,从而允许time.Sleep的goroutine得以执行。

2. 理解 GOMAXPROCS 的作用与误区

GOMAXPROCS环境变量或runtime.GOMAXPROCS()函数用于设置Go程序可以使用的操作系统线程(P,Processor)的数量。虽然增加GOMAXPROCS的值可以允许Go调度器同时运行更多的goroutine(如果操作系统有足够的CPU核心),但这并不能解决一个goroutine无限循环不让出CPU的问题。

即使将GOMAXPROCS设置为大于1,如果一个goroutine在一个P上陷入了纯粹的CPU密集型无限循环,它仍然会独占该P。更重要的是,Go的垃圾回收器(GC)在执行“Stop The World”(STW)阶段时,需要暂停所有goroutine。如果一个CPU密集型goroutine不让出CPU,GC将无法完成STW,进而无法运行,最终可能导致整个程序因内存耗尽而崩溃,或者GC本身被无限期阻塞。因此,GOMAXPROCS不是解决此类阻塞问题的根本方法。

3. 设计良好的并发程序

最佳实践是设计goroutine时,尽量让它们通过自然的I/O操作、通道通信或定时器等方式周期性地让出CPU。纯粹的CPU密集型无限循环在Go并发编程中应该被视为一个潜在的问题点,需要特别注意。

  • 使用通道进行通信和同步:通道操作是Go中天然的让出点。
  • 利用I/O操作:网络请求、文件读写等都会导致goroutine阻塞并让出CPU。
  • 引入定时器:time.Sleep或time.NewTimer可以用于周期性地暂停goroutine。
  • 避免死循环:确保循环有明确的退出条件或在循环体内部包含让出CPU的机制。

总结

理解Go的协作式调度机制对于编写高效、无阻塞的Go并发程序至关重要。当goroutine执行CPU密集型操作时,必须确保它能够周期性地让出CPU,以便调度器能够公平地分配资源给其他等待运行的goroutine。通过在适当的时机使用runtime.Gosched()或更推荐地通过设计让goroutine自然地执行I/O或通道操作,我们可以有效避免因一个goroutine独占CPU而导致的程序阻塞问题,从而构建出响应迅速且健壮的Go应用程序。

上一篇
下一篇
text=ZqhQzanResources