Golang中goroutine永久阻塞问题解析

Golang中goroutine永久阻塞问题解析 大家好, 我这里有一段Go语言代码:

package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.Tick(1 * time.Second)
    for range ticker {
        fmt.Println("tick")
    }
}

当我运行这段代码时,它会永远阻塞。我认为这是由于 time.Tick 的行为导致的,它可能会造成泄漏,并且对通道进行 range 操作会一直阻塞,只要该通道有可能被写入。

我希望得到帮助,以澄清我对这种情况的理解是否正确。


更多关于Golang中goroutine永久阻塞问题解析的实战教程也可以访问 https://www.itying.com/category-94-b0.html

13 回复

谢谢 @mje

更多关于Golang中goroutine永久阻塞问题解析的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


我认为你的最后一个 for 循环正在等待通道关闭。而你的 goroutine 则阻塞在等待通道上永远不会到来的新数据。

感谢您的回复。 关于阻塞的 goroutine 部分。我理解该例程可能会在其自身内部被阻塞,但这应该会影响主例程吗?

感谢这份指南。 我在一个类似capcut mod apk的并行应用上完成了所有这些工作,但如你所见,它存在一些错误。

你说得对,@ozoniuss,我非常感谢你分享的这些文章。我真心希望,一旦论坛允许你访问,你能把最后一篇也链接过来。非常感谢大家为澄清这个问题所做的贡献。

以下是我之前提到的最后一篇帖子(尽管在我看来,其中链接的代码行似乎已经过时):理解 Go 通道死锁 - Stack Overflow

我唯一能猜测的是,Go运行时在处理死锁检测时,必须将阻塞式写入通道 burstyLimiter <- t 与在主goroutine中对通道进行range循环这两种情况区别对待。

burstyLimiter <- t

主 goroutine 和子 goroutine 都被阻塞,等待读取那些不再有数据但尚未关闭的通道。子 goroutine 并不会导致主 goroutine 阻塞。如果你希望任一 goroutine 能够继续执行,你需要关闭它们对应的通道。Playground 之所以会超时,是因为主 goroutine 正在等待更多的数据。

关于在通道上进行范围循环,你的理解是正确的,但预计通道上的范围循环会在运行时无法再检测到未来可能向通道写入数据时发生死锁。

举个例子,这里的这段代码。它基本上就是上面的代码,但没有使用 goroutine。运行它将会按预期发生死锁,这让我想知道为什么另一部分(一旦添加后)会导致代码永远阻塞,因为主例程和 goroutine 是独立的。

Go Playground - The Go Programming Language

我认为你是对的,这可能是因为另一个 goroutine 仍在运行:我用一个无限 for 循环重写了你的第二个示例,它不再死锁了:Go Playground - The Go Programming Language

这里有一个类似的 stackoverflow 帖子:go - Detect deadlock between a group of goroutines - Stack Overflow

编辑:显然,如果创建并处于活动状态的线程数大于等待工作的线程数(过度简化),Go 的死锁检测器就会发出死锁信号。这意味着像这样的一个“空转”的 goroutine 会欺骗死锁检测器。我在这篇 medium 文章和一个 stack overflow 帖子中找到了对此的解释(由于最多只能提供 2 个链接,我暂时无法链接后者)。

所以,以下是我的可能解释……只是希望它是正确的。

对通道进行范围循环(range)会监听该通道的传入消息,并且在运行时确定未来不可能再向该通道写入之前,它永远不会停止。 现在……这意味着如果你只有一个协程,并且该协程没有向通道写入的情况,那么如果通道从未收到关闭信号,范围循环就会停止并导致死锁。 现在,因为主协程内部某个地方有一个 goroutine,运行时必须等待,因为它不确定是否会有向该范围通道写入的操作。

但为什么运行时不确定呢?嗯……这可能是因为主协程内部的 goroutine 正在对 time.Tick 进行范围循环,而 time.Tick 已知容易导致资源/内存泄漏。

现在,另一个包含 time.Tick 的 goroutine 正在向一个已满的通道写入……这将导致阻塞,并且该协程将永远保持打开状态。 现在,由于主协程认为子协程仍在工作,并且可能需要 burstyRequest 通道的范围循环(这是一个倾向性问题),它永远不会让范围循环进入死锁,因此会永远等待。

我不知道这可能是对还是错 smiling_face_with_tear

这是一个典型的goroutine永久阻塞案例,你的理解完全正确。问题确实出在time.Tick和通道的range操作上。

问题分析

time.Tick返回一个只接收通道(<-chan time.Time),该通道会每秒发送一个时间值。当你使用for range遍历这个通道时,它会一直等待新的值,而time.Tick创建的goroutine会持续运行并发送数据,导致主goroutine永久阻塞。

关键点解释

  1. time.Tick不会自动停止:它创建的计时器会一直运行,直到程序结束
  2. 通道range会持续等待:只要通道未关闭,for range会一直阻塞等待下一个值
  3. goroutine泄漏time.Tick内部启动的goroutine永远不会被回收

解决方案示例

方案1:使用context控制超时

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    for {
        select {
        case <-ticker.C:
            fmt.Println("tick")
        case <-ctx.Done():
            fmt.Println("timeout")
            return
        }
    }
}

方案2:使用计数器限制执行次数

package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    count := 0
    for range ticker.C {
        fmt.Println("tick")
        count++
        if count >= 5 {
            break
        }
    }
}

方案3:使用time.After实现单次超时

package main

import (
    "fmt"
    "time"
)

func main() {
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()
    
    timeout := time.After(5 * time.Second)
    
    for {
        select {
        case <-ticker.C:
            fmt.Println("tick")
        case <-timeout:
            fmt.Println("finished")
            return
        }
    }
}

官方建议

Go官方文档明确指出:time.Tick仅适用于需要在整个程序生命周期中运行的场景。对于其他情况,应该使用time.NewTicker并手动调用Stop()方法来避免goroutine泄漏。

你的代码中缺少停止ticker的机制,这是导致永久阻塞的根本原因。使用time.NewTicker配合defer ticker.Stop()是更安全的做法。

回到顶部