恢复会话中...

悬赏 3000 能量求助,关于 golang 在 windows 下的中断信号处理问题

R
runoneall
runoneall整鸡
发布于 last week· 最后编辑于 6 days ago
1 0 10

入口文件:https://github.com/runoneall/tinyagent/blob/main/main.go

我在 main.go 中用自封装的一个 threadmgr 来启动 mainAgent,随后通过 Wait 方法等待所有 thread 退出

Wait 实现如下所示

package threadmgr

import (
	"os"
	"os/signal"
)

func Wait() {
	sigInterrupt := make(chan os.Signal, 1)
	signal.Notify(sigInterrupt, os.Interrupt)
	<-sigInterrupt

	go func() {
		for range sigInterrupt {
		}
	}()

	cancel()
	wg.Wait()
}

目前有一个问题,当我在 Agent 执行中按下 Ctrl+C,context 确实被取消了,wg.Wait() 也确实执行了,但我按下 Ctrl+C 的那一瞬间,shell 控制权就被交还了(shell prompt 出现),导致 wg.Wait() 是在后台等待的,我想让其在前台等待,也就是 threadmgr.Wait() 执行完成后,才交还控制权

求助各位大佬,求解答

10 条评论

M
Mike-Leone
Mike-Leone山鸡#1Flast week

用 SetConsoleCtrlHandler 拦截一下,处理函数返回 1 就能阻止

R
runoneall
runoneall整鸡last week

这个已经试过了,deepseek, gemini 都无法解决这个问题

M
Mike-Leone
Mike-Leone山鸡last week

1.用 SetConsoleCtrlHandler(nil, true) 让进程完全忽略 Ctrl+C 信号
2.用 nssm 或 golang.org/x/sys/windows/svc 注册成系统服务,走 ServiceMain 的生命周期管理
3.直接 go run 或编译后在 cmd 里跑。
4.Windows 按"最后注册最先调用"的顺序执行 handler 链

R
runoneall
runoneall整鸡last week

SetConsoleCtrlHandler(nil, true) 我也试过了,结果就是 shell prompt 立即出现,程序在后台一直跑,所以该方法也不行

S
superai
superai鸡爪#2Flast week

gpt 推荐使用 signal.NotifyContext。

如果 Shell prompt 出现在 threadmgr.Wait 已返回 之前,那么当前 prompt 很可能不是 Shell 真正恢复,而是某个日志、子进程或 Agent 输出的内容。如果 main 即将返回 已经打印,则说明确实是 main() 提前返回,应该重点检查 WaitGroup.Add/Done 和 threadmgr.Run 的实现。

你的入口文件本身是同步调用 threadmgr.Wait() 的,按 Go 的运行机制,只要 Wait() 没有返回,Shell 就不应该重新取得前台控制权。

R
runoneall
runoneall整鸡last week

同时 gpt 也说了 “ 只要 Wait() 没有返回,Shell 就不应该重新取得前台控制权”,但实际上 Wait() 确实没返回,只是被挂到后台了

S
superai
superai鸡爪last week

让GPT设计一套异步方式完成任务,没什么好办法。

X
xiaoyi669
xiaoyi669整鸡#3Flast week

你的 Wait() 实现有两个问题:

  1. 信号处理器泄漏
    go func() {
    for range sigInterrupt { // 这个 goroutine 永远阻塞,channel 永远不会关闭
    }
    }()
    这个 goroutine 会泄漏,因为 signal.Notify 不会关闭 channel。

  2. 关键问题:signal.Reset 和 signal.Stop
    你的 Wait() 注册了信号处理器,但从未取消注册。更重要的是,Go 程序的默认 Ctrl+C 行为(终止进程)被你的 signal.Notify 接管了,但你的代码没有正确处理这个信号。

    修复后的代码:

    image

    或者更健壮的版本(处理多次 Ctrl+C):

image

关键点

  1. 移除那个 go func() goroutine - 它没必要且会泄漏
  2. 在 wg.Wait() 之前不要做任何异步操作 - 确保 main() 函数阻塞在 Wait() 上
  3. 考虑使用 signal.Stop() 来恢复默认信号行为,防止多次 Ctrl+C 导致的问题
    这样修改后,按下 Ctrl+C 会:
  4. 触发 cancel() 取消 context
  5. mainAgent 收到 context 取消,开始清理
  6. wg.Wait() 阻塞直到 mainAgent 完成
  7. main() 返回,程序优雅退出
  8. Shell 控制权在 wg.Wait() 完成后才交还

这是我拿ds跑的

J
James
James凤凰#4Flast week

看完了帖子和他的仓库代码(main.go、threadmgr/wait.go、run.go、context.go),这个问题
可以确诊。

问题复述

   go
     func Wait() {
         sigInterrupt := make(chan os.Signal, 1)
         signal.Notify(sigInterrupt, os.Interrupt)
         <-sigInterrupt
         go func() { for range sigInterrupt {} }()
         cancel()
         wg.Wait()
     }

按 Ctrl+C 后:context 正常取消,wg.Wait() 也确实在等(mainAgent 里 canceled
分支还有个 time.Sleep(5s)),但 shell
提示符立刻出现,程序像是被丢到后台收尾。楼里试过 SetConsoleCtrlHandler(nil, true)
也没用。

根因:问题不在 Go 程序里,而在 shell

在 Windows 上,CTRL_C_EVENT
是由控制台子系统广播给挂在同一个控制台上的所有进程的——包括正在等你程序退出的那个
shell 本身。所以:

  1. 你按 Ctrl+C,你的程序收到事件(Go runtime 转成 os.Interrupt),同时 PowerShell
    也收到同一个事件。
  2. PowerShell(5.1 和 pwsh 都是)对 Ctrl+C 的语义是"中止当前管道":它放弃等待子进程
    ,立即打印提示符,而子进程还活着、还挂在同一个控制台上。这是 PowerShell
    的已知行为(GitHub 上有多个相关 issue)。
  3. 这解释了为什么 SetConsoleCtrlHandler(nil, true) 无效——它只能阻止自己进程响应
    Ctrl+C,管不着 shell 收到的那份事件。程序内做任何信号处理都无法阻止 shell
    提前回到提示符。

帖子里 GPT 说"只要 Wait() 没返回 shell 就不该拿回控制权",这在 Linux
上成立(有前台进程组的概念),在 Windows + PowerShell 下不成立。xiaoyi669
那个"泄漏/signal.Stop"的回答也没打中点子上——那些只是小瑕疵,不是本症状的原因。

解决方案(按推荐顺序)

  1. 先验证诊断:换 cmd.exe 跑一次。
    cmd 的行为不同——它会一直 WaitForSingleObject 等子进程退出,Ctrl+C 后提示符要等
    main() 返回才出现。如果 cmd 下行为正确,就坐实是 PowerShell 的问题。(注意别用 go
    run,编译成 exe 直接跑,排除中间父进程的干扰。)

  2. 真正的程序内解法:不依赖控制台事件,自己读键盘。
    把 stdin 切到 raw 模式,清掉 ENABLE_PROCESSED_INPUT。这样按 Ctrl+C
    时控制台根本不会生成 CTRL_C_EVENT(对任何进程都不生成,shell
    自然也收不到、不会跳提示符),Ctrl+C 变成一个普通的 0x03
    字符进入输入缓冲区,由程序自己读到后触发优雅退出:

   go
     import "golang.org/x/sys/windows"

     func Wait() {
         h, _ := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
         var mode uint32
         windows.GetConsoleMode(h, &mode)
         // 清掉 PROCESSED_INPUT:Ctrl+C 不再产生控制台事件,变成可读的 0x03
         raw := mode &^ (windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT |
     windows.ENABLE_ECHO_INPUT)
         windows.SetConsoleMode(h, raw)
         defer windows.SetConsoleMode(h, mode) // 退出前务必还原,否则搞坏 shell 的终端

         buf := make([]byte, 1)
         for {
             os.Stdin.Read(buf)
             if buf[0] == 0x03 { // Ctrl+C
                 break
             }
         }
         cancel()
         wg.Wait()
     }

这是 vim/ssh 这类交互程序的标准做法。对一个"agent"程序来说这也是最合适的路:终端的输
入语义本来就该由它自己接管。注意 Ctrl+Break 仍会产生事件,可以顺手用 signal.Notify
兜底。

  1. 缓解手段:让收尾变得瞬间完成。
    mainAgent 里 canceled 分支的 time.Sleep(5 * time.Second)
    就是让"后台收尾"肉眼可见的元凶。如果清理工作能在几十毫秒内完成,即使 PowerShell
    提前出提示符,用户也感知不到。这不解决根因,但工程上往往够用。

顺带的小修正(不是本症状的原因,但值得改):go func() { for range sigInterrupt {}
}() 这个 goroutine 永远不会退出(signal.Notify 不关
channel),纯属泄漏,删掉即可;想要"等待期间再按 Ctrl+C
强杀"的语义,应该在收到第二次信号时 os.Exit(1)。

一句话结论:Ctrl+C 事件在 Windows 上是广播给整个控制台的,shell 提前回提示符是
PowerShell 自己的行为,程序内的信号处理无法阻止;要么换 cmd 跑,要么用
SetConsoleMode 清掉 ENABLE_PROCESSED_INPUT 让 Ctrl+C
根本不产生事件、由程序自己读键处理。

J
James
James凤凰last week

简单来说就是快捷键抢事件了

W
wuyee
wuyee鸡腿#5Flast week

站长我来做一下测试,不要封我

L
loc-yes
loc-yes鸡肉#6Flast week

都是高手

K
Knightsama
Knightsama鸡腿#7Flast week

看不懂,但是还是想看看价值3000能量的问题

M
mrgg
mrgg鸡爪#8Flast week

啊,天哪。我看不懂,帮佬顶一下,Ai都解决不了的,还价值3000能量问题得多难啊。

R
runoneall
runoneall整鸡#9F6 days ago

我严重怀疑是 github.com/microsoft/agent-framework-go 这个库的问题,我实现了一个最小 demo,他运行的很好

image

threadmgr 包实现没有任何变化

J
jiumei
jiumei鸡腿#10F6 days ago

帮老板顶上去