看完了帖子和他的仓库代码(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 本身。所以:
- 你按 Ctrl+C,你的程序收到事件(Go runtime 转成 os.Interrupt),同时 PowerShell
也收到同一个事件。
- PowerShell(5.1 和 pwsh 都是)对 Ctrl+C 的语义是"中止当前管道":它放弃等待子进程
,立即打印提示符,而子进程还活着、还挂在同一个控制台上。这是 PowerShell
的已知行为(GitHub 上有多个相关 issue)。
- 这解释了为什么 SetConsoleCtrlHandler(nil, true) 无效——它只能阻止自己进程响应
Ctrl+C,管不着 shell 收到的那份事件。程序内做任何信号处理都无法阻止 shell
提前回到提示符。
帖子里 GPT 说"只要 Wait() 没返回 shell 就不该拿回控制权",这在 Linux
上成立(有前台进程组的概念),在 Windows + PowerShell 下不成立。xiaoyi669
那个"泄漏/signal.Stop"的回答也没打中点子上——那些只是小瑕疵,不是本症状的原因。
解决方案(按推荐顺序)
先验证诊断:换 cmd.exe 跑一次。
cmd 的行为不同——它会一直 WaitForSingleObject 等子进程退出,Ctrl+C 后提示符要等
main() 返回才出现。如果 cmd 下行为正确,就坐实是 PowerShell 的问题。(注意别用 go
run,编译成 exe 直接跑,排除中间父进程的干扰。)
真正的程序内解法:不依赖控制台事件,自己读键盘。
把 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
兜底。
- 缓解手段:让收尾变得瞬间完成。
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
根本不产生事件、由程序自己读键处理。