Go中goroutine向无监听通道发送是否会导致内存泄漏及修复方案问询
问题概述
我编写了一个定时器工具——其具体用途不重要,这是背景——它会启动一个协程休眠指定时长,另一个协程监听通道以知晓何时执行函数。
关键需求:我需要能够重启该定时器。我决定让执行函数的协程可以切换监听的通道,使其等待另一个协程发送的消息。
代码实现
type Timer struct { mu sync.Mutex Alarm func() // Function to run on timeout lastInterval time.Duration // Last time used (for "reset") lastWakeCh chan int // Where is the alarm listening? alarmUpdate chan (chan int) // Update what channel the alarm runs on } func (t *Timer) start(timeout time.Duration) { t.mu.Lock() defer t.mu.Unlock() t.lastInterval = timeout t.lastWakeCh = make(chan int) t.alarmUpdate = make(chan (chan int)) go sleepAndNotify(timeout, t.lastWakeCh) go alarmThread(t.alarmUpdate, t.Alarm) t.alarmUpdate <- t.lastWakeCh } func (t *Timer) restart() { t.mu.Lock() defer t.mu.Unlock() t.lastWakeCh = make(chan int) t.alarmUpdate <- t.lastWakeCh go sleepAndNotify(t.lastInterval, t.lastWakeCh) } func sleepAndNotify(timeout time.Duration, ch chan int) { time.Sleep(timeout) ch <- 1 } func alarmThread(chUpdate chan (chan int), alarm func()) { ch := <-chUpdate for { select { case newCh := <-chUpdate: ch = newCh case <-ch: go alarm() } } }
使用示例:
t1 := Timer{Alarm: func() { ... }} t1.start(100 * time.Millisecond) // some time passes t1.restart()
核心疑问
每次调用restart时,都会创建一个新的运行sleepAndNotify的goroutine,该goroutine会在尝试向唤醒通道ch发送消息时阻塞。请问这种情况会导致内存/协程泄漏吗?Go是否能检测到该通道已无引用、不会再有其他协程读取,从而终止该goroutine?如果不会,有无简便的修复方案?
解答
1. 会造成协程/内存泄漏
这种情况肯定会导致协程泄漏。原因在于:
- 你使用的是无缓冲通道,
ch <- 1操作会一直阻塞,直到有协程从该通道接收数据。 - 当调用
restart后,alarmThread会切换到新的通道,旧的通道再也不会有任何协程去读取。旧的sleepAndNotify协程会永远卡在ch <- 1的阻塞状态,无法退出。 - Go的垃圾回收不会回收处于阻塞状态的活跃协程,因为这些协程仍属于执行中的实体,会持续占用系统资源(栈内存、调度器资源等)。
2. Go不会自动终止这类阻塞协程
Go runtime无法自动检测“通道已无接收方”并终止发送协程。只有当协程自己完成执行、被主动取消,或者发生panic时,才会退出。单纯的通道无引用不会触发协程终止,因为阻塞的发送操作本身会让协程保持活跃状态。
3. 简便修复方案
方案一:使用带缓冲的通道
最简单的修改是将通道改为带缓冲的(缓冲大小设为1),这样ch <- 1操作即使没有接收方也不会阻塞,发送完成后协程会自动退出:
// 在start和restart中创建通道时修改: t.lastWakeCh = make(chan int, 1)
这种方案的优点是代码改动极小,完全符合你的需求——旧定时器的触发信号会被缓冲,但因为alarmThread已经切换到新通道,不会触发Alarm函数,等价于旧定时器被废弃。
方案二:用Context取消旧协程
如果需要更严谨地终止旧的sleepAndNotify协程(避免它等待到超时再退出),可以使用context包来实现取消逻辑:
import "context" type Timer struct { mu sync.Mutex Alarm func() lastInterval time.Duration lastWakeCh chan int alarmUpdate chan (chan int) cancelCtx context.Context // 新增 cancelFunc context.CancelFunc // 新增 } func (t *Timer) start(timeout time.Duration) { t.mu.Lock() defer t.mu.Unlock() t.lastInterval = timeout t.lastWakeCh = make(chan int) t.alarmUpdate = make(chan (chan int)) // 创建可取消的context ctx, cancel := context.WithCancel(context.Background()) t.cancelCtx = ctx t.cancelFunc = cancel go sleepAndNotify(ctx, timeout, t.lastWakeCh) go alarmThread(t.alarmUpdate, t.Alarm) t.alarmUpdate <- t.lastWakeCh } func (t *Timer) restart() { t.mu.Lock() defer t.mu.Unlock() // 取消旧的sleep协程 if t.cancelFunc != nil { t.cancelFunc() } t.lastWakeCh = make(chan int) t.alarmUpdate <- t.lastWakeCh // 创建新的可取消context ctx, cancel := context.WithCancel(context.Background()) t.cancelCtx = ctx t.cancelFunc = cancel go sleepAndNotify(ctx, t.lastInterval, t.lastWakeCh) } func sleepAndNotify(ctx context.Context, timeout time.Duration, ch chan int) { select { case <-time.After(timeout): // 用select避免发送阻塞 select { case ch <- 1: default: } case <-ctx.Done(): // 收到取消信号,直接退出 return } }
这种方案能让旧协程在restart时立即退出,避免不必要的等待,适合对资源占用敏感的场景。
内容的提问来源于stack exchange,提问作者Ashton Wiersdorf

