托盘图标点击无响应,一次跨 Go / Win32 / 消息队列的排查记录
现象
一个 Go 编写的 Windows 托盘应用(Wails v2 + energye/systray),发版实际使用后,触发了下面情况:
- 用着用着,托盘图标点左键、右键都没反应了。图标还在,但点了就是不动。
- 严重的时候刚启动就这样。有时候过一会儿又突然好了,但马上又坏。
几个关键的观察:
- 不是必现 —— 有时跑几分钟,有时一启动就坏
- 进程完全健康 —— 主窗口正常、计时正常、日志正常输出
- 点击不产生任何日志 —— 连回调入口都进不去
- 不会自愈 —— 卡住后一直卡,除非触发某个特定操作
- "恢复"只持续一瞬 —— 触发后会响应一次积压的操作,然后立刻又卡住
结论先行
systray.Run 所在的 goroutine 被 Go 调度器从创建窗口的那个 OS 线程迁移走了。
Win32 的窗口与其消息队列绑定在创建它的线程上,GetMessage 只取当前线程的消息。goroutine 一经迁移,消息循环就再也收不到该窗口的消息 —— 且不可自愈。
修复只有两行:
func (t *Tray) Run() {
runtime.LockOSThread() // ← 关键代码,锁住当前协程所属的线程,让该协程只会被该线程处理。
defer runtime.UnlockOSThread()
systray.Run(t.onReady, t.onExit)
}下面记录完整的排查过程,以及走了哪些弯路 —— 我认为弯路比结论更有价值。
为什么这类问题难查
托盘卡死这个现象,能指向至少五种完全不同的原因:
| 可能原因 | 特征 |
|---|---|
| 消息循环线程被占用 | 卡在某个回调里 |
| 死锁 | 进程内多个 goroutine 互相等待 |
| goroutine 泄漏 | 数量持续增长 |
| 消息循环退出 | 循环已结束,不再处理消息 |
| 线程绑定丢失 | 循环还在跑,但收不到消息 |
前四种是我们常见的 goroutine 引发的问题,都能靠"看代码 + 加日志"推断。但第五种不行 —— 因为从应用层看一切正常,循环在跑、goroutine 还在、日志也还在正常输出,但是对应托盘的消息协程就是无响应。
这就是它隐藏这么久的原因。
心跳 + pprof 双向排查
心跳区分"进程冻结"与"单个线程/协程卡住"
func startHeartbeat() {
go func() {
ticker := time.NewTicker(time.Second)
for range ticker.C {
log.Printf("[DEBUG] 心跳 %v goroutines=%d",
time.Since(start), runtime.NumGoroutine())
}
}()
}如果心跳日志在托盘 App 卡死时还有输出,就立刻排除了 "进程冻结"、"死锁"、"调度器崩溃" 这一整类原因,把范围缩到"某个线程/某个 goroutine 的问题"。同时心跳日志打印的 goroutine 数量,可以判断有没有泄漏。
pprof 拿到真实的 goroutine 栈
import _ "net/http/pprof"
go func() {
http.ListenAndServe("127.0.0.1:6060", nil)
}()卡死时访问 http://localhost:6060/debug/pprof/goroutine?debug=2,拿到全部栈的日志信息。
这里有个容易被忽略的关键点:debug=2 会在每个 goroutine 的首行标注状态和阻塞时长:
goroutine 178 [syscall]:
systray.doNativeTick() systray_windows.go:942
goroutine 1 [syscall, locked to thread]:
winc.RunMainLoop() winc/app.go:80在我的 App 进行的 pprof 日志信息里,有上面这两行信息。
对比这两行,答案就出来了。一个是 [syscall],另一个是 [syscall, locked to thread]。
根因详解
Win32 的消息循环有线程亲和性
Win32 里,一个窗口由某个线程创建,它的消息队列就属于那个线程。
线程 A:CreateWindowEx(...) → 窗口 W 的消息队列属于线程 A
线程 A:GetMessage(...) → 取到 W 的消息 ✓
线程 B:GetMessage(...) → 取不到 W 的消息 ✗(即使 W 还在,消息也在排队)这是 Win32 的硬性设计。 微软的文档写得很清楚:窗口过程只能在创建窗口的线程上被调用。
Go 的 goroutine 会在线程间迁移
Go 的运行时调度器会把 goroutine 分配到不同的 OS 线程(M)上执行,一个 goroutine 在不同时刻可能跑在不同线程上。这是 Go 并发模型的核心优势 —— 但对 Win32 消息循环是致命的。
两者相撞
go a.tray.Run() // ← 在新 goroutine 里启动托盘
// Tray.Run
func (t *Tray) Run() {
systray.Run(t.onReady, t.onExit) // ← 内部同步调用 nativeLoop()
}时间线:
T0: goroutine G 在线程 A 上跑 → CreateWindowEx 创建窗口,消息队列绑定线程 A
T1: Go 调度器把 G 迁到线程 B(迁移时机不确定)
T2: G 在线程 B 上调用 GetMessage → 取线程 B 的消息
→ 线程 A 的消息(也就是窗口 W 的)永远取不到
→ 托盘点击无响应,且不会自愈这就解释了我编写的 Wails App 问题场景的现象:
- "用一会儿才坏" → 迁移发生在某个不确定的时刻
- "一启动就坏" → 启动瞬间就发生了迁移
- "触发托盘信息暂时恢复" → 托盘信息触发了
Shell_NotifyIcon之类的 Win32 调用,强制把当前线程的消息队列与窗口重新关联,积压消息被处理一次,之后线程再次迁移,又坏
托盘库设计上考虑过这个问题
但托盘库的作者设计假设与使用场景不匹配。
energye/systray 有 runtime.LockOSThread,但只存在于一处:
// systray.go —— 全库唯一的 LockOSThread
func init() {
runtime.LockOSThread()
}在它设计的目标场景(纯托盘程序)里,这一行是够用的:
// 纯托盘程序:main 直接交给托盘
func main() {
systray.Run(onReady, onExit)
}
// 时序:
// init() 在 main goroutine 执行 → 锁住 main 线程
// main() 也在 main goroutine 执行 → 消息循环跑在 main 线程
// 两者是同一个线程 → 锁生效 ✓问题出在换了一个使用场景之后。 Wails 这类 GUI 框架需要用自己的消息循环占用 main:
// Wails 应用:main 被框架占用,托盘只能异步启动
func main() {
go a.tray.Run() // ← 托盘挪到新 goroutine
wails.Run(options) // ← main 留给 Wails
}这时候库的假设就失效了:
| 线程 | 被 init() 锁了吗 | |
|---|---|---|
init() 执行处 | main 线程 | ✅ 锁的是它 |
| Wails 消息循环 | main 线程 | ✅(Wails 自己也会锁) |
| 托盘消息循环 | 新 goroutine 的线程 | ❌ 从未被锁 |
库的设计假设是 "消息循环在 main 线程",而 Wails 场景下这个假设不成立。
这三行代码其实说明了这一切:
// 库为"主循环被别的框架占用"提供了替代入口
func RunWithExternalLoop(onReady, onExit func()) (start, end func()) {
Register(onReady, onExit)
return nativeStart, func() { nativeEnd(); Quit() }
}
func nativeStart() {
go func() { // ← 反而又起了一个新 goroutine
for doNativeTick() { }
}()
}作者意识到 "有些框架会抢主循环",于是提供了另一个入口,但对于 Wails 这种需要使用 Win32 创建应用程序窗口的框架来说,真正的问题在于线程绑定,而作者提供的这些入口都没有解决相关问题。
而且作者库的设计,也没有完全的设计好:
① 只锁不解锁
全库没有任何 UnlockOSThread 。LockOSThread 会阻止该 OS 线程被调度器复用,标准做法是 defer runtime.UnlockOSThread() 成对出现。这里加锁后永不释放。
② 库不该在 init() 里改进程级状态
init() 会在任何 import 它的程序里自动执行。也就是说,一个只用它做别的事、根本不启动托盘的程式,也会被强行锁掉一个 OS 线程。
一个库在 init() 里做不可逆的运行时设置,是不合适的 —— 副作用应该由调用方在明确调用某个入口时触发。
总结
所以,如果有用 Wails + Systray 去做托盘模块的程序,记得在 systray 对象执行 Run() 的时候,使用 runtime.LockOSThread() 锁一下执行 Run() 的协程所在的线程。
附排查清单
遇到 "GUI 卡住但进程健康" 时,按顺序做:
- [ ] 加心跳日志 —— 确认进程/调度器还活着
- [ ] 打印 goroutine 数量 —— 排除泄漏
- [ ] 加 pprof,卡死时抓栈 —— 拿到真实状态
- [ ] 看栈里的状态标注 ——
locked to thread?阻塞多久? - [ ] 反向验证 —— 注释掉可疑调用,看问题是否消失
- [ ] 检查第三方库的线程模型 —— 特别是
LockOSThread/Invoke/STAThread这类关键字 - [ ] 不要相信"时间相邻" —— 要状态标注,要反向验证
RoLingG | 博客
评论(0)