G

[Go] 托盘图标点击无响应,一次跨 Go / Win32 / 消息队列的排查记录

RoLingG Golang 2026-10-14

托盘图标点击无响应,一次跨 Go / Win32 / 消息队列的排查记录

现象

一个 Go 编写的 Windows 托盘应用(Wails v2 + energye/systray),发版实际使用后,触发了下面情况:

  • 用着用着,托盘图标点左键、右键都没反应了。图标还在,但点了就是不动。
  • 严重的时候刚启动就这样。有时候过一会儿又突然好了,但马上又坏。

几个关键的观察:

  1. 不是必现 —— 有时跑几分钟,有时一启动就坏
  2. 进程完全健康 —— 主窗口正常、计时正常、日志正常输出
  3. 点击不产生任何日志 —— 连回调入口都进不去
  4. 不会自愈 —— 卡住后一直卡,除非触发某个特定操作
  5. "恢复"只持续一瞬 —— 触发后会响应一次积压的操作,然后立刻又卡住

结论先行

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 这类关键字
  • [ ] 不要相信"时间相邻" —— 要状态标注,要反向验证
PREV
[每日算法] 统计值等于子树平均值的节点数(经典二叉树后序遍历)

评论(0)

发布评论