Question

// ws/ws.go
package ws

import (
"context"
"encoding/json"
"live-command-middleware/chrome"
"live-command-middleware/gopool"
"live-command-middleware/instruction"
"live-command-middleware/mylog/wlog"
"live-command-middleware/setting"
"live-command-middleware/trigger"
"live-command-middleware/websocket/messenger"
"time"

"github.com/gorilla/websocket"
hook "github.com/robotn/gohook"
)

const wsUrl = "ws://localhost:5000/ws"

var StopFlag = false // 停止标志,五秒内不接收Stop消息(因为stop之后前端会又广播一个)

var (
WsClient *wsClient
)

type wsClient struct {
conn *websocket.Conn
url string
}

type WsMessage = messenger.WsMessage // 引用 messenger 中的 WsMessage 类型

// NewWsClient 创建一个新的 WebSocket 客户端实例
func NewWsClient() error {
var err error
client := &wsClient{
url: wsUrl,
}
err = client.connect()
if err == nil {
WsClient = client
}
return err
}

// 连接到 WebSocket 服务器
func (c *wsClient) connect() error {
var dialer *websocket.Dialer
conn, _, err := dialer.Dial(c.url, nil)
if err != nil {
return err
}
c.conn = conn

// 初始化 messenger
messenger.InitMessenger(c.conn)

// 开始监听服务器消息
go c.listen()
return nil
}

func isUnexpectedEOF(err error) bool {
return websocket.IsCloseError(err, websocket.CloseAbnormalClosure)
}

// 监听服务器发送的消息
func (c *wsClient) listen() {
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if isUnexpectedEOF(err) {
wlog.Infof("智能中控客户端已关闭,正在尝试重新连接...")
// 在此处理特定的错误, 例如尝试重新连接
} else {
wlog.Errorf("读取消息出错:", err)
}
// 尝试重连
for {
err := c.connect()
if err == nil {
wlog.Infof("重连成功")
break
}
wlog.Errorf("重连失败,稍后重试:", err)
time.Sleep(5 * time.Second)
}
continue
}
// 处理接收到的消息
c.handleMessage(message)
}
}

// 处理接收到的消息
func (c *wsClient) handleMessage(message []byte) {
var msg WsMessage
err := json.Unmarshal(message, &msg)
if err != nil {
wlog.Errorf("JSON 解析错误:", err)
return
}

// 检查消息是否为自己发送的
if isSelfMessage(msg) {
return
}

switch msg.Type {
case "start":
wlog.Infod("收到启动指令: ", msg.Data, true)
c.handleStart(msg.Data, false)
case "reStart":
wlog.Infod("收到重启指令: ", msg.Data, true)
c.handleRestart(msg.Data)
case "stop":
if StopFlag {
return
}
wlog.Infof("收到停止指令")
c.HandleStop()
case "heartBeat":
c.handleHeartBeat(msg.Data)
default:
wlog.Warnf("未知的消息: %v", msg)
}
}

// 辅助函数,判断是否为自己发送的消息
func isSelfMessage(msg WsMessage) bool {
if dataMap, ok := msg.Data.(map[string]interface{}); ok {
if fromClient, exists := dataMap["fromClient"].(bool); exists && fromClient {
return true
}
}
return false
}

// 处理 "start" 消息
func (c *wsClient) handleStart(data interface{}, isRestart bool) {
if !isRestart {
chrome.Init()
}
if msgMap, ok := data.(map[string]interface{}); ok {
platform, _ := msgMap["platform"].(string)
token, _ := msgMap["token"].(string)
setting.UserToken = token

if !isRestart {
instruction.SetCurrentPlatformAndOpen(platform)
}

// 这里暂时还有问题,浏览器如果刚打开就关不了会的不到session,还没有监听就被关闭了,前端无法感知
go instruction.MonitorTargetClosed(chrome.Ctx)

if !setting.LoadRemoteConfig() || !setting.LoadRemoteKeyboardSetting() {
wlog.Errorf("加载远程配置失败,启动失败")
return
}

chrome.ChildCtx, chrome.ChildCancelCtx = context.WithCancel(chrome.Ctx)

go trigger.ListenKeyBoard()
} else {
wlog.Errorf("类型断言失败,无法转换为 map[string]interface{},当前data为: %v", data)
}
}

// 处理 "restart" 消息
func (c *wsClient) handleRestart(data interface{}) {

instruction.GracefulShutdownAll()
chrome.ChildCancelCtx()
hook.End()

time.Sleep(2 * time.Second)
// 保证其他goroutine尽可能退出
wlog.Infof("正在重启,等待2秒...")

// 如果当前浏览器已经被关闭了,则直接打开浏览器
c.handleStart(data, true)
}

// 处理 "stop" 消息
func (c *wsClient) HandleStop() {
if chrome.Ctx == nil || chrome.Ctx.Err() != nil {
return
}
if StopFlag {
return
}
StopFlag = true
// Note: 这里Stop之后服务端还会广播一个STOP消息,加一个延时等待,5秒内不接收Stop消息
go func() {
time.Sleep(5 * time.Second)
StopFlag = false
}()

instruction.GracefulShutdownAll()
chrome.CancelCtx()

// 通知前端调整为关闭按钮状态
messenger.SendMessage(
WsMessage{
Type: "stop",
Data: "null",
},
)
hook.End()

wlog.Infof("chrome被客户端关闭")
wlog.Infof("当前Goroutine数量: %d", gopool.GlobalPool.Running())
go func() {
time.Sleep(1 * time.Second)
if gopool.GlobalPool.Running() != 0 {
// 有一个是监听关闭状态的goroutine,在allocator.go中
wlog.Errorf("当前Gopool中Goroutine数量异常,请检查")
}
}()
}

// 处理 "heartBeat" 消息
func (c *wsClient) handleHeartBeat(data interface{}) {
wlog.Infof("收到心跳消息: %s", data.(string))
}

// 开始定时发送心跳
func (c *wsClient) StartHeartBeat() {
ticker := time.NewTicker(3 * time.Second)
go func() {
for range ticker.C {
err := messenger.SendHeartBeat("ping")
if err != nil {
wlog.Errorf("发送Ping消息出错:", err)
}
data := map[string]interface{}{
"instruction": "heartbeat",
"operateNo": 1,
"action": "on",
}
err = messenger.SendKeepAlive(data)
if err != nil {
wlog.Errorf("发送键盘保活状态失败:", err)
}
}
}()
}


上面是我跟前端进行通信的websocket连接的代码,
var startCmd = &cobra.Command{
Use: "start",
Short: "开始服务",
Long: `会开始监听操作`,
Run: func(cmd *cobra.Command, args []string) {
wlog.Info("程序开始")

err := ws.NewWsClient()
if err != nil {
// 重试连接
reTryNum := 100
for i := 0; i < reTryNum; i++ {
time.Sleep(1 * time.Second)
wlog.Infof("第 %d 次重试连接", i+1)
err = ws.NewWsClient()
if err == nil {
break
}
}
wlog.Errorf("ws连接失败 Error: %v", err)
os.Exit(10002)
}
ws.WsClient.StartHeartBeat()
wlog.Infof("ws连接成功,等待接受信号...")

select {}
},
}

func init() {
rootCmd.AddCommand(startCmd)
}

现在遇到了很奇怪的事情,就是我在vscode以调试模式启动,是一切正常的,但是我打包成exe之后,当我在进行了一系列的通信之后,突然程序就会死掉,然后日志也不会输出,我不知道是哪里错了,我应该怎么排查?用中文给我回答,我使用了go build -gcflags "all=-N -l"都还是用问题,并且我在主函数里使用了 defer func() {
if r := recover(); r != nil {
wlog.Errorf("异常退出,重启中....,错误原因:", r)

cmd := exec.Command(os.Args[0], "start")

if err := cmd.Start(); err != nil {
wlog.Errorf("重启失败,错误原因:", err)
return
}
cmd.Process.Release() // Allow the child to persist if the parent is killed
os.Exit(0) // Exiting the parent process
}
} ,仍然无法捕捉,用中文回答

Réflexion

探讨问题解决

用户描述了一个Go语言的WebSocket客户端程序,在调试模式下运行正常,但打包后直接执行会突然崩溃。

创建和监控

思考代码结构
代码示例中,main函数创设WebSocket客户端并监听服务器消息,使用gorilla/websocket包。通过新建ws客户端和client.connect()连接WebSocket服务器,client.listen()从中获取消息,若失败则重连,从handleMessage()处理不同类型消息,包括start、restart、stop、heartBeat和default,handleStart()、handleRestart()和HandleStop()与其他应用部分互动,最后启动客户端并保持等待状态。

分析运行环境

我正在思考编译后的可执行文件与调试模式之间的差异,尤其是环境变量或平台特定问题的可能性。

捕获并行任务的异常

对比debug模式和并发执行的代码,可能会因为并发条件或计时问题而有所不同。有效地捕获并发goroutines的panic异常是关键。

排除故障的步骤

我正在整理调试 exe 文件的建议,包括使用 delve 工具、增加日志追踪执行过程,以及检查程序是否因信号或外部原因终止。

排查异常退出原因
首先建议在所有goroutine中添加recover机制,捕获潜在的panic。然后使用race检测器编译程序检查数据竞争,并确保日志系统能在程序崩溃前输出日志。此外,需要检查系统资源使用情况、代码中的死锁和无限循环问题,以及非法的内存访问。

Réponse

看起来你遇到了一个在调试模式下正常工作,但在打包成可执行文件(exe)后运行一段时间后程序会突然退出的问题,而且没有任何日志输出。这种情况可能由多种原因引起,以下是一些可能的原因和排查方法:

1. Goroutine中的panic未被捕获

问题描述:

当程序在goroutine中发生panic时,如果没有适当的recover机制,panic会导致整个程序崩溃。因为你的主函数中添加的defer recover()只能捕获主goroutine中的panic,无法捕获其他goroutine中的panic。

解决方法:

在所有启动的goroutine中添加deferrecover,以捕获并记录panic信息。例如,可以创建一个通用的函数来启动goroutine:

import ( "runtime/debug" ) func SafeGo(fn func()) { go func() { defer func() { if r := recover(); r != nil { wlog.Errorf("捕获到panic: %v\n堆栈信息: %s", r, debug.Stack()) } }() fn() }() }

然后将所有go关键字启动的goroutine替换为SafeGo

SafeGo(func() { // 你的goroutine代码 })

这样,当goroutine发生panic时,会被捕获并记录日志,不会导致程序崩溃。

2. 使用Race检测器查找数据竞争

问题描述:

数据竞争可能导致程序在编译后的版本中出现不可预测的行为,包括崩溃。

解决方法:

使用Go的race检测器来编译你的程序:

go build -race -o your_program.exe

运行编译后的程序,如果存在数据竞争,race检测器会输出相关警告和错误信息,帮助你定位问题。

3. 检查日志输出和日志刷新

问题描述:

程序可能在崩溃前未能将日志写入文件,导致你无法看到错误信息。

解决方法:

确保你的日志库在程序退出前刷新了日志缓冲区。可以在关键的地方或程序退出前调用日志库的刷新方法,例如wlog.Flush()wlog.Sync(),以确保日志被写入磁盘。

此外,将日志输出重定向到文件,方便在程序退出后查看日志:

wlog.SetOutputFile("your_log_file.log")

或者在运行程序时使用:

your_program.exe > stdout.log 2> stderr.log

4. 检查环境差异

问题描述:

在调试模式和打包后的运行环境可能存在差异,例如配置文件路径、环境变量、权限等。

解决方法:

  • 确认所有的配置文件、资源文件在运行环境中都存在,并且路径正确。
  • 检查程序是否需要特定的环境变量,并在运行时确保这些变量被正确设置。
  • 检查程序是否需要特定的权限,例如访问网络、文件系统等。

5. 使用调试工具

问题描述:

直接在编译后的程序中调试可能更加困难,但你可以使用Go的调试工具来帮助你。

解决方法:

  • 使用Delve调试已编译的可执行文件:

    dlv exec your_program.exe

    这样你可以在程序运行时设置断点、查看变量、堆栈等信息。

6. 增加日志和调试信息

问题描述:

缺乏足够的日志信息,无法定位程序在哪一步崩溃。

解决方法:

  • 在程序的关键路径和可能导致问题的地方增加日志输出。
  • 使用不同级别的日志(例如InfoDebugError)来区分重要性。
  • 输出堆栈信息,使用runtime/debug包的PrintStack()函数。

7. 检查是否存在死锁或资源耗尽

问题描述:

程序可能因为死锁、无限循环、资源耗尽等原因导致崩溃。

解决方法:

  • 使用go tool pprof分析程序的CPU和内存使用情况。
  • 检查是否有未释放的资源,例如未关闭的文件、网络连接等。
  • 确保所有的锁、信号量等并发原语使用正确,避免死锁。

8. 检查外部因素

问题描述:

程序可能被操作系统或其他软件强制终止,例如杀毒软件、权限问题等。

解决方法:

  • 检查操作系统的事件日志,看看是否有相关的错误或警告信息。
  • 暂时关闭杀毒软件或防火墙,测试程序是否正常运行。
  • 以管理员权限运行程序,检查是否是权限导致的问题。

9. 检查与WebSocket连接相关的问题

问题描述:

你的程序主要涉及WebSocket通信,连接的断开或异常可能导致程序崩溃。

解决方法:

  • 在WebSocket的读写操作中添加错误检查和日志输出,确保所有的错误都被处理。
  • 当连接断开时,确保程序能够正确地处理重连逻辑,不会引发panic。
  • 使用超时和上下文(context.Context)来控制连接的生命周期。

10. 简化问题,逐步排查

问题描述:

当问题复杂且难以定位时,可以尝试简化程序,逐步排查。

解决方法:

  • 注释掉部分代码,只保留核心功能,看看问题是否还存在。
  • 如果问题消失,逐步恢复被注释的代码,直到问题再次出现,定位到具体的代码段。

总结

基于以上分析,最有可能的原因是程序在goroutine中发生了未被捕获的panic,导致整个程序崩溃。建议你首先在所有goroutine中添加deferrecover,捕获panic并记录错误信息。

同时,使用go build -race编译程序,检测数据竞争问题。确保日志在程序崩溃前被正确地写入磁盘,以便你能够查看到错误信息。

通过上述方法,你应该能够捕获到导致程序退出的错误信息,从而进行针对性的修复。

希望这些建议能帮助你排查并解决问题。如有进一步的问题,欢迎继续提问。

Question

我好像发现了,是我打包出去的exe文件,前端程序会调用我的这个exe,但是这个exe有时候会突然卡死,我是手动打开这个exe复现的,鼠标移动到这个cmd窗口里,然后敲入键盘才有响应,请你理解我的问题,然后告诉我怎么解决?

Réflexion

分析代码运行问题

用户提供了一个Go代码的WebSocket客户端,代码进行了一些键盘监控工作。问题是在将程序打包为可执行文件后,程序在某些通信过程中崩溃,且日志未能捕捉到panic,用户寻求调试建议。 assistant

打开程序并观察响应

用户发现EXE文件有时会突然挂起,并且通过手动运行EXE并在cmd窗口中移动鼠标和输入键盘来恢复响应。这个现象表明程序可能出现了某种阻塞。

寻找问题原因

正在思考程序阻塞可能是因为stdin,或者cmd窗口获取焦点,可能是由于Windows控制台API或程序构建方式导致等待用户输入。代码中没有读取stdin的操作。

处理cmd窗口的问题

探讨问题的触发点

我在分析程序为何在某些时候被阻塞,可能是因为gohook库与操作系统的交互出现了问题,导致消息循环卡住或等待命令输入。

进行了如下操作:

  • 确保程序不会等待stdin或控制台输入。
  • 检查gohook是否需要消息循环或特定的OS线程设置。
  • 尝试重现问题,并修改代码以防止冻结。

提案解决方案

在提出解决计划时,我提到了在独立的操作系统线程中运行gohook代码,确保消息循环正常运行,或者将应用程序构建为Windows GUI应用程序,以避免消息循环在控制台窗口失去焦点时不处理事件的问题。

Réponse

看起来您发现了问题的关键:当您将程序打包成可执行文件(exe)后,程序会在运行一段时间后突然卡死(无响应)。您手动打开exe文件进行复现,发现将鼠标移动到cmd窗口并敲击键盘后,程序又开始响应。

问题分析:

这个现象通常表明程序可能在等待某种输入或事件,或者与操作系统的消息循环(message loop)有关。在您的代码中,您使用了github.com/robotn/gohook库来监听全局的键盘事件。

在Windows系统上,gohook库需要一个消息循环来处理键盘和鼠标的钩子事件。如果消息循环没有正确运行,或者被阻塞,程序就可能出现卡死的情况。当您将鼠标移到cmd窗口并敲击键盘时,可能触发了消息循环的重新运行,导致程序恢复响应。

可能的原因:

  1. 消息循环问题: 在Windows上,钩子需要消息循环来接收和处理事件。如果没有正确运行消息循环,钩子可能无法工作,程序也会因此卡死。

  2. 主线程阻塞: 如果主线程被阻塞,消息循环无法执行,导致程序无法处理钩子事件。

  3. 控制台窗口的影响: 当程序作为控制台应用运行时,控制台窗口可能会影响消息循环的执行,尤其是在控制台窗口失去焦点或没有用户输入时。

解决方案:

1. 在主线程中运行消息循环

为了确保消息循环正常运行,您需要在主线程中启动消息循环。在使用gohook库时,通常需要在主线程中调用hook.Start()函数。以下是修改建议:

func main() { // 初始化其他组件,如日志、配置等 wlog.Info("程序开始") // 启动WebSocket客户端 err := ws.NewWsClient() if err != nil { // 重试逻辑... } ws.WsClient.StartHeartBeat() wlog.Infof("ws连接成功,等待接受信号...") // 在主线程中启动钩子消息循环 go func() { // 启动键盘监听 trigger.ListenKeyBoard() }() // 开始钩子消息循环,阻塞在这里 hook.Start() }

请注意,hook.Start()会启动消息循环并阻塞当前线程,因此应在主线程中调用它,而不是放在goroutine中。

2. 确保hook.Start()运行在主线程

由于Windows的限制,全局钩子必须在主线程中设置并运行。如果您在goroutine中调用hook.Start(),可能会导致钩子无法正常工作或程序卡死。

3. 避免主线程阻塞

如果您的主线程被阻塞,例如在select {}语句处,消息循环将无法执行。请确保主线程没有阻塞在无限的等待中,而是用于运行消息循环。

4. 将程序编译为GUI应用程序

如果您不需要控制台窗口,可以将程序编译为Windows GUI应用程序。这可以避免控制台窗口对消息循环的干扰。

在编译时,添加-ldflags参数:

go build -ldflags "-H windowsgui" -o your_program.exe

这样,程序将作为GUI应用程序运行,没有控制台窗口。

5. 检查gohook库的使用

确保您正确使用了gohook库。根据gohook官方示例,正确的使用方式是在主线程中启动钩子并运行消息循环:

package main import ( hook "github.com/robotn/gohook" ) func main() { hook.Register(hook.KeyDown, []string{}, func(e hook.Event) { fmt.Println("key down") }) s := hook.Start() <-hook.Process(s) }

在您的程序中,您需要在主线程中调用hook.Start()并处理事件。

6. 捕获所有的panic

虽然您在主函数中添加了deferrecover,但这只能捕获主goroutine中的panic。对于其他goroutine中的panic,您需要分别处理。

例如,在启动goroutine时,添加deferrecover

go func() { defer func() { if r := recover(); r != nil { wlog.Errorf("goroutine发生panic: %v", r) } }() // goroutine的代码 }()

或者,创建一个通用的函数来启动goroutine:

func GoSafe(fn func()) { go func() { defer func() { if r := recover(); r != nil { wlog.Errorf("捕获到panic: %v", r) } }() fn() }() }

然后使用GoSafe来启动goroutine:

GoSafe(func() { // goroutine的代码 })

7. 增加日志和调试信息

在可能出现问题的地方添加更多的日志信息,特别是在与gohook相关的部分。这样可以帮助您更好地定位问题。

8. 检查程序的权限和兼容性

确保程序有足够的权限来设置全局钩子。在Windows上,可能需要以管理员权限运行程序。

9. 更新或替换gohook

如果问题仍然存在,可能是gohook库在Windows上的兼容性问题。您可以尝试更新gohook库到最新版本,或者考虑使用其他的全局钩子库。

10. 测试最小可复现示例

为了确认问题的根源,建议您创建一个最小的、可复现问题的示例程序,只包含gohook的使用。这样可以更容易地定位问题是否来自于gohook库的使用。

示例:

package main import ( "fmt" hook "github.com/robotn/gohook" ) func main() { fmt.Println("程序开始") hook.Register(hook.KeyDown, []string{}, func(e hook.Event) { fmt.Printf("捕获到键盘事件: %v\n", e) }) s := hook.Start() <-hook.Process(s) }

编译并运行这个程序,观察是否会出现同样的卡死现象。

总结:

您的问题很可能是由于gohook库在Windows上的消息循环处理不当导致的。确保在主线程中启动并运行钩子消息循环,避免主线程阻塞。根据以上建议进行调整,应该能够解决程序卡死的问题。

如果问题仍然存在,请检查程序的权限、库的兼容性,以及是否有其他代码影响了消息循环的正常运行。

希望这些建议能帮助您解决问题!

Question

我不太理解,我开一个协程运行键盘的监听事件有问题吗?比如go trigger.ListenKeyBoard()
ListenKeyBoard里有hook的监听

Réflexion

探究程序卡死

用户指出他们的Go程序在打包为exe后,有时会卡死或悬挂。搬动鼠标或按键才恢复。问助理如何解决这个问题。

探讨键盘监听

我在分析在 goroutine 中运行键盘监听器的潜在问题,特别是在使用 gohook 库和 Windows 的上下文中运行。

探讨线程问题

我在 Windows 系统上讨论全局钩子在主线程消息循环中运行的重要性,提出应该在主线程启动键盘监听器并运行消息循环的建议。

Réponse

我理解您的问题:您在使用 go trigger.ListenKeyBoard() 启动键盘监听,而 ListenKeyBoard 函数内部使用了 gohook 库来监听全局键盘事件。您想知道在协程中运行键盘监听是否会有问题,以及这是否可能导致您的程序卡死。

简短回答:

是的,在 Windows 系统上,将全局钩子(如键盘监听)放在协程中运行可能会导致问题。因为在 Windows 中,全局钩子需要在主线程中设置和运行,且需要一个消息循环(Message Loop)来处理事件。如果您在协程(也就是非主线程)中启动键盘监听,可能会导致钩子无法正常工作,程序出现卡死等问题。

详细解释:

1. Windows 全局钩子需要在主线程中设置

在 Windows 操作系统中,全局钩子(如键盘、鼠标钩子)必须在主线程中设置并运行。这是因为 Windows 的钩子机制要求钩子函数所在的线程有一个消息循环来处理钩子事件。如果在非主线程(例如协程)中设置钩子,可能会导致消息无法正确传递,钩子无法正常工作,甚至导致程序卡死。

2. gohook 库的工作机制

gohook 库在内部使用了系统级的钩子,需要消息循环来处理事件。它的 hook.Start() 函数会启动一个阻塞的消息循环,用于接收和处理钩子事件。

3. 在协程中启动钩子的影响

当您在协程中运行 trigger.ListenKeyBoard(),并在其中调用 hook.Start() 时,由于协程是在另一个线程中运行的(Go 运行时会将协程调度到不同的系统线程上),因此钩子不在主线程中,可能导致钩子无法正确设置。此外,如果主线程没有运行消息循环,或者被阻塞,钩子也无法正常工作。

4. 程序卡死的原因

当钩子无法正常工作,或者消息循环被阻塞时,程序可能会等待事件的到来而卡死。当您将鼠标移动到控制台窗口并敲击键盘时,可能会触发某些事件,使得消息循环继续运行,程序又开始响应。这解释了您观察到的现象。

解决方案:

为了确保全局钩子正常工作,您需要在主线程中设置钩子,并运行消息循环。以下是具体的修改建议:

1. 在主线程中启动键盘监听

不要在协程中启动 trigger.ListenKeyBoard(),而是直接在主线程中调用。这样可以确保钩子在主线程中设置。

func main() { // 初始化程序,例如日志、配置等 wlog.Info("程序开始") // 启动 WebSocket 客户端 err := ws.NewWsClient() if err != nil { // 重试逻辑... } ws.WsClient.StartHeartBeat() wlog.Infof("ws连接成功,等待接受信号...") // 在主线程中启动键盘监听 trigger.ListenKeyBoard() // 如果需要阻塞主线程,可以使用 select{} select {} }

2. 确保 hook.Start() 在主线程中运行

trigger.ListenKeyBoard() 函数中,hook.Start() 应该在主线程中运行。由于 hook.Start() 会阻塞当前线程,因此需要调整代码结构,以适应这种情况。

示例:

// trigger/trigger.go package trigger import ( "github.com/robotn/gohook" "live-command-middleware/mylog/wlog" ) func ListenKeyBoard() { // 注册键盘事件 hook.Register(hook.KeyDown, []string{}, func(e hook.Event) { wlog.Infof("捕获到键盘事件: %v", e) // 处理键盘事件的逻辑 }) // 开始消息循环,阻塞在这里 s := hook.Start() <-hook.Process(s) }

3. 避免主线程被阻塞

如果您需要在主线程中运行其他逻辑,可能需要重新设计程序的结构。例如,将其他需要并发运行的任务放在协程中,而将钩子和消息循环放在主线程中。

示例:

func main() { // 初始化程序 wlog.Info("程序开始") // 启动其他任务,例如 WebSocket 客户端 go func() { err := ws.NewWsClient() if err != nil { // 重试逻辑... } ws.WsClient.StartHeartBeat() wlog.Infof("ws连接成功,等待接受信号...") }() // 在主线程中启动键盘监听 trigger.ListenKeyBoard() }

4. 确保消息循环正常运行

在主线程中运行 hook.Start(),它会启动消息循环并阻塞当前线程,这正是我们需要的行为,以确保钩子正常工作。

5. 捕获可能的 panic

ListenKeyBoard 函数中,添加 deferrecover,以捕获可能的 panic 并记录日志。

func ListenKeyBoard() { defer func() { if r := recover(); r != nil { wlog.Errorf("键盘监听发生 panic: %v", r) } }() // 注册键盘事件 hook.Register(hook.KeyDown, []string{}, func(e hook.Event) { wlog.Infof("捕获到键盘事件: %v", e) // 处理键盘事件的逻辑 }) // 开始消息循环,阻塞在这里 s := hook.Start() <-hook.Process(s) }

6. 检查其他阻塞主线程的代码

确保您的主线程没有其他会阻塞消息循环的代码。例如,如果您在主线程中使用了 select {} 来阻塞,这可能会导致消息循环无法运行。

7. 如果需要 GUI 程序

如果不需要控制台窗口,您可以将程序编译为 Windows GUI 程序,这样可以避免控制台窗口对消息循环的干扰。

go build -ldflags "-H windowsgui" -o your_program.exe

注意: 编译为 GUI 程序后,您将无法看到控制台输出的日志,需要将日志输出到文件。

8. 检查程序的权限

确保程序有足够的权限来设置全局钩子。在某些情况下,可能需要以管理员权限运行程序。

总结:

  • 全局钩子需要在主线程中设置和运行,并且需要一个消息循环来处理事件。
  • 不要在协程中启动键盘监听,应该直接在主线程中调用。
  • 确保主线程没有被其他代码阻塞,例如避免在主线程中使用 select {} 等阻塞操作。
  • 在需要并发的地方使用协程,将非钩子相关的任务放在协程中运行。
  • 添加必要的错误处理和日志,以便在出现问题时能够及时发现和解决。

希望这些建议能够帮助您解决问题!如果还有其他疑问,请随时提问。

Question

那请你帮我改一下,放在主程序中,但是我每次ws收到stop信号会关闭hook的监听事件,需要在主程序中恢复怎么改:
var startCmd = &cobra.Command{
Use: "start",
Short: "开始服务",
Long: `会开始监听操作`,
Run: func(cmd *cobra.Command, args []string) {
wlog.Info("程序开始")

err := ws.NewWsClient()
if err != nil {
// 重试连接
reTryNum := 100
for i := 0; i < reTryNum; i++ {
time.Sleep(1 * time.Second)
wlog.Infof("第 %d 次重试连接", i+1)
err = ws.NewWsClient()
if err == nil {
break
}
}
wlog.Errorf("ws连接失败 Error: %v", err)
os.Exit(10002)
}
ws.WsClient.StartHeartBeat()
wlog.Infof("ws连接成功,等待接受信号...")

select {}
},
}

package trigger

import (
"live-command-middleware/chrome"
"live-command-middleware/enum"
"live-command-middleware/gopool"
"live-command-middleware/instruction"
"live-command-middleware/mylog/wlog"
"live-command-middleware/setting"
"strings"
"sync"

hook "github.com/robotn/gohook"
"github.com/vcaesar/keycode"
)

var KeyBoardGlobalLock = sync.Mutex{}

func NewInstructionStructByOperate(operate string) instruction.Instruction {
switch operate {
case enum.OperationPreheatProduct:
return &instruction.PreheatProduct{}
case enum.OperationExplainProduct:
return &instruction.ExplainProduct{}
case enum.OperationExplainProductAll:
return &instruction.ExplainProductAll{}
case enum.OperationChangePrice:
return &instruction.ChangePrice{}
case enum.OperateCoupon:
return &instruction.Coupon{}
case enum.OperateRedBag:
return &instruction.RedBag{}
case enum.OperateLuckyBag:
return &instruction.LuckBag{}
case enum.OperationComment:
return &instruction.Comment{}
case enum.OperateWindowOpen:
return &instruction.WindowOpen{}
case enum.OperateCancel:
return &instruction.Cancel{}
case enum.Audio:
return &instruction.Audio{}
default:
return nil
}
}

var ExecMapInstructionStruct = map[string]string{
enum.OperationPreheatProduct: enum.SafeExecIfNotExists,
enum.OperationExplainProduct: enum.SafeExec,
enum.OperationExplainProductAll: enum.SafeExec,
enum.OperationChangePrice: enum.NullExec,
enum.OperateCoupon: enum.SafeExecIfNotExists,
enum.OperateRedBag: enum.SafeExec,
enum.OperateLuckyBag: enum.SafeExec,
enum.OperationComment: enum.SafeExec,
enum.OperateWindowOpen: enum.SafeExec,
enum.OperateCancel: enum.SafeExec,
enum.Audio: enum.SafeExec,
}

func ListenKeyBoard() {
KeyBoardGlobalLock.Lock()
defer KeyBoardGlobalLock.Unlock()
wlog.Infof("--- Please press ctrl + shift + q to stop hook ---")

// 加载键盘配置,加载不到就用默认配置
keyboardSettings := setting.GetUserKeyBoardSettingMap()
wlog.Infod("加载键盘配置", keyboardSettings, true)

// TODO,不用GoHook库,调试的时候太卡了,自己实现一个
for bindKey, boardSetting := range keyboardSettings {
// 在循环内部创建新的变量
bindKeyCopy := bindKey
newSettingCopy := boardSetting
str1 := strings.Split(bindKeyCopy, ",")
if len(str1) == 0 {
wlog.Warnf("bindKey %s split error", bindKeyCopy)
continue
}
// 如果keycode.Keycode[str1[0]]存在
if _, ok := keycode.Keycode[str1[0]]; !ok {
// NOTE:hook 钩子函数有BUG,传入不存在的按键会导致任何按键都触发
// 使用mod vendor之后,有可能会重新加载老版本的keycode,会少很多键值
wlog.Warnf("bindKey %s not exist", bindKeyCopy)
continue
}

func(bindKeyCopy string, newSettingCopy setting.UserKeyBoardSetting, str1 []string) {
hook.Register(hook.KeyDown, str1, func(e hook.Event) {
// 检查上次按键时间
// 当前时间 - 上次按键时间 < keyDownInterval
if chrome.ChildCtx == nil || chrome.ChildCtx.Err() != nil {
wlog.Infof("chrome 上下文已关闭,忽略按键,关闭事件监听")
hook.End()
return
}
ii := NewInstructionStructByOperate(newSettingCopy.Operate)
if ii == nil {
wlog.Infof("bind %s Operate %s no config", bindKeyCopy, newSettingCopy.Operate)
} else {
ii.Instance(newSettingCopy.Config.OperateNo, newSettingCopy.Config.OperateNos)
ii.SetBindKey(newSettingCopy.BindKey)
wlog.Infof("load instruction %s", newSettingCopy.BindKey)
execType, ok := ExecMapInstructionStruct[newSettingCopy.Operate]
if !ok {
wlog.Infof("execType %s Operate %s no config", newSettingCopy.BindKey, newSettingCopy.Operate)
} else {
if execType == enum.SafeExec {
instruction.SafeExec(newSettingCopy.Operate, ii)
} else if execType == enum.SafeExecIfNotExists {
instruction.SafeExecIfNotExists(newSettingCopy.Operate, ii)
}
}
}
})
}(bindKeyCopy, newSettingCopy, str1) // 立即调用,传入当前值的副本
}

//关闭程序 内置,不做修改
hook.Register(hook.KeyDown, []string{"q", "ctrl", "shift"}, func(e hook.Event) {
wlog.Infof("ctrl-shift-q")
hook.End()
})

wlog.Infof("启动键盘成功,等待键盘输入指令...")
wlog.Infof("当前Goroutine数量: %d", gopool.GlobalPool.Running())
if gopool.GlobalPool.Running() != 0 {
// 有一个是监听关闭状态的goroutine,在allocator.go中
wlog.Errorf("当前Gopool中Goroutine数量异常,请检查")
}

s := hook.Start()
<-hook.Process(s)
wlog.Infof("键盘监听结束")
// chrome.ClearAll()
// os.Exit(10001)
}

// ws/ws.go
package ws

import (
"context"
"encoding/json"
"live-command-middleware/chrome"
"live-command-middleware/gopool"
"live-command-middleware/instruction"
"live-command-middleware/mylog/wlog"
"live-command-middleware/setting"
"live-command-middleware/trigger"
"live-command-middleware/websocket/messenger"
"time"

"github.com/gorilla/websocket"
hook "github.com/robotn/gohook"
)

const wsUrl = "ws://localhost:5000/ws"

var StopFlag = false // 停止标志,五秒内不接收Stop消息(因为stop之后前端会又广播一个)

var (
WsClient *wsClient
)

type wsClient struct {
conn *websocket.Conn
url string
}

type WsMessage = messenger.WsMessage // 引用 messenger 中的 WsMessage 类型

// NewWsClient 创建一个新的 WebSocket 客户端实例
func NewWsClient() error {
var err error
client := &wsClient{
url: wsUrl,
}
err = client.connect()
if err == nil {
WsClient = client
}
return err
}

// 连接到 WebSocket 服务器
func (c *wsClient) connect() error {
var dialer *websocket.Dialer
conn, _, err := dialer.Dial(c.url, nil)
if err != nil {
return err
}
c.conn = conn

// 初始化 messenger
messenger.InitMessenger(c.conn)

// 开始监听服务器消息
go c.listen()
return nil
}

func isUnexpectedEOF(err error) bool {
return websocket.IsCloseError(err, websocket.CloseAbnormalClosure)
}

// 监听服务器发送的消息
func (c *wsClient) listen() {
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if isUnexpectedEOF(err) {
wlog.Infof("智能中控客户端已关闭,正在尝试重新连接...")
// 在此处理特定的错误, 例如尝试重新连接
} else {
wlog.Errorf("读取消息出错:", err)
}
// 尝试重连
for {
err := c.connect()
if err == nil {
wlog.Infof("重连成功")
break
}
wlog.Errorf("重连失败,稍后重试:", err)
time.Sleep(5 * time.Second)
}
continue
}
// 处理接收到的消息
c.handleMessage(message)
}
}

// 处理接收到的消息
func (c *wsClient) handleMessage(message []byte) {
var msg WsMessage
err := json.Unmarshal(message, &msg)
if err != nil {
wlog.Errorf("JSON 解析错误:", err)
return
}

// 检查消息是否为自己发送的
if isSelfMessage(msg) {
return
}

switch msg.Type {
case "start":
wlog.Infod("收到启动指令: ", msg.Data, true)
c.handleStart(msg.Data, false)
case "reStart":
wlog.Infod("收到重启指令: ", msg.Data, true)
c.handleRestart(msg.Data)
case "stop":
if StopFlag {
return
}
wlog.Infof("收到停止指令")
c.HandleStop()
case "heartBeat":
c.handleHeartBeat(msg.Data)
default:
wlog.Warnf("未知的消息: %v", msg)
}
}

// 辅助函数,判断是否为自己发送的消息
func isSelfMessage(msg WsMessage) bool {
if dataMap, ok := msg.Data.(map[string]interface{}); ok {
if fromClient, exists := dataMap["fromClient"].(bool); exists && fromClient {
return true
}
}
return false
}

// 处理 "start" 消息
func (c *wsClient) handleStart(data interface{}, isRestart bool) {
if !isRestart {
chrome.Init()
}
if msgMap, ok := data.(map[string]interface{}); ok {
platform, _ := msgMap["platform"].(string)
token, _ := msgMap["token"].(string)
setting.UserToken = token

if !isRestart {
instruction.SetCurrentPlatformAndOpen(platform)
}

// 这里暂时还有问题,浏览器如果刚打开就关不了会的不到session,还没有监听就被关闭了,前端无法感知
go instruction.MonitorTargetClosed(chrome.Ctx)

if !setting.LoadRemoteConfig() || !setting.LoadRemoteKeyboardSetting() {
wlog.Errorf("加载远程配置失败,启动失败")
return
}

chrome.ChildCtx, chrome.ChildCancelCtx = context.WithCancel(chrome.Ctx)

go trigger.ListenKeyBoard()
} else {
wlog.Errorf("类型断言失败,无法转换为 map[string]interface{},当前data为: %v", data)
}
}

// 处理 "restart" 消息
func (c *wsClient) handleRestart(data interface{}) {

instruction.GracefulShutdownAll()
chrome.ChildCancelCtx()
hook.End()

time.Sleep(2 * time.Second)
// 保证其他goroutine尽可能退出
wlog.Infof("正在重启,等待2秒...")

// 如果当前浏览器已经被关闭了,则直接打开浏览器
c.handleStart(data, true)
}

// 处理 "stop" 消息
func (c *wsClient) HandleStop() {
if chrome.Ctx == nil || chrome.Ctx.Err() != nil {
return
}
if StopFlag {
return
}
StopFlag = true
// Note: 这里Stop之后服务端还会广播一个STOP消息,加一个延时等待,5秒内不接收Stop消息
go func() {
time.Sleep(5 * time.Second)
StopFlag = false
}()

instruction.GracefulShutdownAll()
chrome.CancelCtx()

// 通知前端调整为关闭按钮状态
messenger.SendMessage(
WsMessage{
Type: "stop",
Data: "null",
},
)
hook.End()

wlog.Infof("chrome被客户端关闭")
wlog.Infof("当前Goroutine数量: %d", gopool.GlobalPool.Running())
go func() {
time.Sleep(1 * time.Second)
if gopool.GlobalPool.Running() != 0 {
// 有一个是监听关闭状态的goroutine,在allocator.go中
wlog.Errorf("当前Gopool中Goroutine数量异常,请检查")
}
}()
}

// 处理 "heartBeat" 消息
func (c *wsClient) handleHeartBeat(data interface{}) {
wlog.Infof("收到心跳消息: %s", data.(string))
}

// 开始定时发送心跳
func (c *wsClient) StartHeartBeat() {
ticker := time.NewTicker(3 * time.Second)
go func() {
for range ticker.C {
err := messenger.SendHeartBeat("ping")
if err != nil {
wlog.Errorf("发送Ping消息出错:", err)
}
data := map[string]interface{}{
"instruction": "heartbeat",
"operateNo": 1,
"action": "on",
}
err = messenger.SendKeepAlive(data)
if err != nil {
wlog.Errorf("发送键盘保活状态失败:", err)
}
}
}()
}


代码有点多。请你仔细思考,帮我仔细改正,你改了哪里请指出。用中文回答

Réflexion

解决错误的探讨

用户描述Go程序在编译和运行时崩溃,建议添加defer recover在所有goroutines中或将goroutines包装在处理函数中。

调查挂起问题

用户发现exe挂起,当鼠标移到cmd窗口并输入时恢复,可能是全局钩子未在主线程上正确处理的原因。

解释代码调整

建议在主线程中运行 hook.Start()。由于 Windows 中的全局钩子需要主线程进行设置和运行,使用 goroutine 可能会引发问题。

启动命令和重连逻辑

实施键盘钩住

在主程序中加入键盘钩子,响应 WebSocket 消息控制启动和停止。指明修改位置。

调整主线程访问

确保键盘监听在主线程运行时,需要修改代码,让键盘监听(hook.Start())在主线程运行。这样,当ws客户端接收到开始或停止消息时,它可以向主线程发送信号或消息。

Réponse

好的,我理解了您的需求。您需要将键盘监听的 hook.Start() 放到主线程中运行,但同时希望能够在接收到 WebSocket 的 startstop 消息时,动态地启动和停止键盘监听。以下是对您的代码的修改和详细说明。

总体思路

  1. 在主线程中运行键盘监听的消息循环:将 hook.Start() 放在主线程中运行,因为在 Windows 系统上,全局钩子需要在主线程中设置和运行,并需要一个消息循环。

  2. 使用通道(channel)进行通信:通过在主线程和其他 goroutine 之间使用 channel,主线程可以根据接收到的信号启动或停止键盘监听。

  3. 调整 WebSocket 消息处理逻辑:在接收到 startstop 消息时,通过向通道发送信号,通知主线程启动或停止键盘监听。

下面我将逐步修改您的代码,并详细说明每个修改点。


1. 修改主程序

首先,我们需要在主程序中添加一个用于控制键盘监听的通道 keyboardControlChan,主线程会根据该通道接收的指令来启动或停止键盘监听。

修改后的 startCmd

var startCmd = &cobra.Command{ Use: "start", Short: "开始服务", Long: `会开始监听操作`, Run: func(cmd *cobra.Command, args []string) { wlog.Info("程序开始") // 创建一个用于控制键盘监听的通道 keyboardControlChan := make(chan string) // 启动 WebSocket 客户端,并将通道传递给它 err := ws.NewWsClient(keyboardControlChan) if err != nil { // 重试连接 reTryNum := 100 for i := 0; i < reTryNum; i++ { time.Sleep(1 * time.Second) wlog.Infof("第 %d 次重试连接", i+1) err = ws.NewWsClient(keyboardControlChan) if err == nil { break } } if err != nil { wlog.Errorf("ws连接失败 Error: %v", err) os.Exit(10002) } } ws.WsClient.StartHeartBeat() wlog.Infof("ws连接成功,等待接受信号...") // 在主线程中运行键盘监听控制逻辑 for { select { case cmd := <-keyboardControlChan: if cmd == "start" { wlog.Infof("主线程收到启动键盘监听指令") trigger.StartListenKeyBoard() // 启动键盘监听 } else if cmd == "stop" { wlog.Infof("主线程收到停止键盘监听指令") trigger.StopListenKeyBoard() // 停止键盘监听 } } } }, }

修改点说明:

  • 添加了 keyboardControlChan 通道:用于在主线程中接收控制指令。
  • 将通道传递给 ws.NewWsClient(keyboardControlChan):以便 WebSocket 客户端可以通过该通道发送控制指令。
  • 在主线程中使用 select 等待通道消息:根据收到的指令,启动或停止键盘监听。
  • 调用 trigger.StartListenKeyBoard()trigger.StopListenKeyBoard():分别启动和停止键盘监听。

2. 修改 ws/ws.go

在 WebSocket 客户端中,需要在收到 startstop 消息时,通过通道向主线程发送控制指令。

修改后的 ws/ws.go

package ws import ( // 省略其他导入 // ... ) // 添加一个全局的通道变量 var keyboardControlChan chan string // 修改 NewWsClient 函数,接收通道作为参数 func NewWsClient(controlChan chan string) error { keyboardControlChan = controlChan // 赋值给全局变量,便于在其他方法中使用 var err error client := &wsClient{ url: wsUrl, } err = client.connect() if err == nil { WsClient = client } return err } // ... // 处理接收到的消息 func (c *wsClient) handleMessage(message []byte) { var msg WsMessage err := json.Unmarshal(message, &msg) if err != nil { wlog.Errorf("JSON 解析错误:", err) return } // 检查消息是否为自己发送的 if isSelfMessage(msg) { return } switch msg.Type { case "start": wlog.Infod("收到启动指令: ", msg.Data, true) c.handleStart(msg.Data, false) case "reStart": wlog.Infod("收到重启指令: ", msg.Data, true) c.handleRestart(msg.Data) case "stop": if StopFlag { return } wlog.Infof("收到停止指令") c.HandleStop() case "heartBeat": c.handleHeartBeat(msg.Data) default: wlog.Warnf("未知的消息: %v", msg) } } // 处理 "start" 消息 func (c *wsClient) handleStart(data interface{}, isRestart bool) { if !isRestart { chrome.Init() } if msgMap, ok := data.(map[string]interface{}); ok { platform, _ := msgMap["platform"].(string) token, _ := msgMap["token"].(string) setting.UserToken = token if !isRestart { instruction.SetCurrentPlatformAndOpen(platform) } // 这里暂时还有问题,浏览器如果刚打开就关不了会的不到session,还没有监听就被关闭了,前端无法感知 go instruction.MonitorTargetClosed(chrome.Ctx) if !setting.LoadRemoteConfig() || !setting.LoadRemoteKeyboardSetting() { wlog.Errorf("加载远程配置失败,启动失败") return } chrome.ChildCtx, chrome.ChildCancelCtx = context.WithCancel(chrome.Ctx) // 通过通道通知主线程启动键盘监听 keyboardControlChan <- "start" } else { wlog.Errorf("类型断言失败,无法转换为 map[string]interface{},当前data为: %v", data) } } // 处理 "stop" 消息 func (c *wsClient) HandleStop() { if chrome.Ctx == nil || chrome.Ctx.Err() != nil { return } if StopFlag { return } StopFlag = true // Note: 这里Stop之后服务端还会广播一个STOP消息,加一个延时等待,5秒内不接收Stop消息 go func() { time.Sleep(5 * time.Second) StopFlag = false }() instruction.GracefulShutdownAll() chrome.CancelCtx() // 通知前端调整为关闭按钮状态 messenger.SendMessage( WsMessage{ Type: "stop", Data: "null", }, ) // 通过通道通知主线程停止键盘监听 keyboardControlChan <- "stop" wlog.Infof("chrome被客户端关闭") wlog.Infof("当前Goroutine数量: %d", gopool.GlobalPool.Running()) go func() { time.Sleep(1 * time.Second) if gopool.GlobalPool.Running() != 0 { // 有一个是监听关闭状态的goroutine,在allocator.go中 wlog.Errorf("当前Gopool中Goroutine数量异常,请检查") } }() } // 处理 "restart" 消息 func (c *wsClient) handleRestart(data interface{}) { instruction.GracefulShutdownAll() chrome.ChildCancelCtx() // 通过通道通知主线程停止键盘监听 keyboardControlChan <- "stop" time.Sleep(2 * time.Second) // 保证其他goroutine尽可能退出 wlog.Infof("正在重启,等待2秒...") // 如果当前浏览器已经被关闭了,则直接打开浏览器 c.handleStart(data, true) }

修改点说明:

  • NewWsClient 中接收并保存控制通道:将 keyboardControlChan 作为参数传入,并赋值给包级变量,方便在其他方法中使用。
  • handleStart 中,通过通道通知主线程启动键盘监听keyboardControlChan <- "start"
  • HandleStop 中,通过通道通知主线程停止键盘监听keyboardControlChan <- "stop"
  • handleRestart 中,先通知停止键盘监听,再重新启动

3. 修改 trigger

trigger 包中,需要提供启动和停止键盘监听的函数。

修改后的 trigger/trigger.go

package trigger import ( // 省略其他导入 // ... ) var ( hookChan chan hook.Event hookRunning bool = false ) // 启动键盘监听 func StartListenKeyBoard() { if hookRunning { wlog.Infof("键盘监听已在运行中") return } wlog.Infof("启动键盘监听") // 加载键盘配置,加载不到就用默认配置 keyboardSettings := setting.GetUserKeyBoardSettingMap() wlog.Infod("加载键盘配置", keyboardSettings, true) // 注册键盘事件 for bindKey, boardSetting := range keyboardSettings { // 在循环内部创建新的变量 bindKeyCopy := bindKey newSettingCopy := boardSetting str1 := strings.Split(bindKeyCopy, ",") if len(str1) == 0 { wlog.Warnf("bindKey %s split error", bindKeyCopy) continue } // 如果keycode.Keycode[str1[0]]存在 if _, ok := keycode.Keycode[str1[0]]; !ok { // NOTE:hook 钩子函数有BUG,传入不存在的按键会导致任何按键都触发 // 使用mod vendor之后,有可能会重新加载老版本的keycode,会少很多键值 wlog.Warnf("bindKey %s not exist", bindKeyCopy) continue } func(bindKeyCopy string, newSettingCopy setting.UserKeyBoardSetting, str1 []string) { hook.Register(hook.KeyDown, str1, func(e hook.Event) { // 检查上次按键时间 // 当前时间 - 上次按键时间 < keyDownInterval if chrome.ChildCtx == nil || chrome.ChildCtx.Err() != nil { wlog.Infof("chrome 上下文已关闭,忽略按键") return } ii := NewInstructionStructByOperate(newSettingCopy.Operate) if ii == nil { wlog.Infof("bind %s Operate %s no config", bindKeyCopy, newSettingCopy.Operate) } else { ii.Instance(newSettingCopy.Config.OperateNo, newSettingCopy.Config.OperateNos) ii.SetBindKey(newSettingCopy.BindKey) wlog.Infof("load instruction %s", newSettingCopy.BindKey) execType, ok := ExecMapInstructionStruct[newSettingCopy.Operate] if !ok { wlog.Infof("execType %s Operate %s no config", newSettingCopy.BindKey, newSettingCopy.Operate) } else { if execType == enum.SafeExec { instruction.SafeExec(newSettingCopy.Operate, ii) } else if execType == enum.SafeExecIfNotExists { instruction.SafeExecIfNotExists(newSettingCopy.Operate, ii) } } } }) }(bindKeyCopy, newSettingCopy, str1) // 立即调用,传入当前值的副本 } //关闭程序 内置,不做修改 hook.Register(hook.KeyDown, []string{"q", "ctrl", "shift"}, func(e hook.Event) { wlog.Infof("ctrl-shift-q") // 在这里可以添加自定义的退出逻辑 }) wlog.Infof("启动键盘成功,等待键盘输入指令...") wlog.Infof("当前Goroutine数量: %d", gopool.GlobalPool.Running()) if gopool.GlobalPool.Running() != 0 { // 有一个是监听关闭状态的goroutine,在allocator.go中 wlog.Errorf("当前Gopool中Goroutine数量异常,请检查") } // 启动 hook 消息循环 hookChan = hook.Start() hookRunning = true go func() { defer func() { if r := recover(); r != nil { wlog.Errorf("键盘监听发生 panic: %v", r) } }() <-hook.Process(hookChan) wlog.Infof("键盘监听结束") hookRunning = false }() } // 停止键盘监听 func StopListenKeyBoard() { if !hookRunning { wlog.Infof("键盘监听未在运行") return } wlog.Infof("停止键盘监听") hook.End() }

修改点说明:

  • 添加了 hookRunning 变量:用于标记键盘监听是否正在运行,避免重复启动或停止。
  • 添加了 StartListenKeyBoardStopListenKeyBoard 函数:分别用于启动和停止键盘监听。
  • StartListenKeyBoard 中,启动 hook.Start() 并将消息循环放入新的 goroutine 中:这样主线程不会被阻塞,但需要确保 hook.Start() 在主线程中调用。
  • StopListenKeyBoard 中,调用 hook.End() 来停止键盘监听
  • 注意:由于 hook.Start() 必须在主线程中调用,我们需要确保 StartListenKeyBoard 在主线程中运行。

4. 调整主程序的结构

由于 hook.Start() 需要在主线程中调用,并且会阻塞当前线程,我们需要调整主程序的结构,让主线程主要用于运行消息循环,其他的任务放在 goroutine 中运行。

最终的 startCmd

var startCmd = &cobra.Command{ Use: "start", Short: "开始服务", Long: `会开始监听操作`, Run: func(cmd *cobra.Command, args []string) { wlog.Info("程序开始") // 创建一个用于控制键盘监听的通道 keyboardControlChan := make(chan string) // 启动 WebSocket 客户端,并将通道传递给它 go func() { err := ws.NewWsClient(keyboardControlChan) if err != nil { // 重试连接 reTryNum := 100 for i := 0; i < reTryNum; i++ { time.Sleep(1 * time.Second) wlog.Infof("第 %d 次重试连接", i+1) err = ws.NewWsClient(keyboardControlChan) if err == nil { break } } if err != nil { wlog.Errorf("ws连接失败 Error: %v", err) os.Exit(10002) } } ws.WsClient.StartHeartBeat() wlog.Infof("ws连接成功,等待接受信号...") }() // 在主线程中运行键盘监听控制逻辑 for { select { case cmd := <-keyboardControlChan: if cmd == "start" { wlog.Infof("主线程收到启动键盘监听指令") trigger.StartListenKeyBoard() // 启动键盘监听 } else if cmd == "stop" { wlog.Infof("主线程收到停止键盘监听指令") trigger.StopListenKeyBoard() // 停止键盘监听 } } } }, }

修改点说明:

  • 将 WebSocket 客户端放入 goroutine 中运行:以免阻塞主线程。
  • 主线程用于接收控制指令并启动/停止键盘监听:确保 hook.Start() 在主线程中调用。

5. 可能的问题和注意事项

  • 确保 hook.Start() 在主线程中调用:由于 hook.Start() 必须在主线程中调用,因此在上述调整中,我们让主线程主要用于运行键盘监听的控制逻辑。

  • 处理好 hook.Start() 的阻塞:在 StartListenKeyBoard 中,我们在启动 hook.Start() 后,将消息处理放入 goroutine 中,以避免阻塞主线程。

  • 线程安全:在访问共享变量时,需要注意线程安全。例如,hookRunning 变量可以使用互斥锁进行保护,防止并发访问问题。


总结

通过上述修改,我们完成了以下目标:

  • 在主线程中运行键盘监听的消息循环,解决了在 Windows 系统上全局钩子需要在主线程中运行的问题。

  • 使用通道进行线程间通信,主线程根据接收到的指令启动或停止键盘监听。

  • 在接收到 WebSocket 消息时,通知主线程进行相应的操作,实现了动态启动和停止键盘监听的功能。

希望这些修改能够解决您程序中遇到的问题。如果您有任何疑问,欢迎继续提问。

Question

在goroutine中主线程和其他的goroutine有区别吗,他们不都是goroutine会参与GMP的调度吗。为什么放在主函数里就可以??用中文回答

Réflexion

探讨主协程与其他协程

用户在询问主协程与其他协程之间的区别,特别是在 Windows 环境和 gohook 库下的代码行为差异。需要解释代码在主函数和协程中的表现区别。

探讨线程的角色

我在探索主线程和协程的区别,指出某些 Windows API 需要从主线程调用,以及 Go 协程如何映射到 OS 线程,以及运行时如何处理 OS 线程亲和性。

了解事件监听

一般来说,Windows要求全局挂钩在主线程设置,因为消息循环也在主线程。主Goroutine在主操作系统线程上启动,放置代码在主函数中可以确保它运行在主线程上。这是为什么GoHook库需要在主线程设置挂钩的原因。

Réponse

好的,我来解释一下您的疑问。

问题概述:

您提到在 Go 中,无论是主线程(main goroutine)还是其他 goroutine,它们都会参与 Go 的 GMP(Goroutine、M、P)调度,因此您认为它们应该没有区别。那么,为什么将代码放在主函数中就可以解决问题,而放在 goroutine 中就会出现问题呢?

简要回答:

在 Go 语言中,虽然 goroutine 是由 Go 运行时调度的轻量级线程,但是在与操作系统交互时,某些特定的操作(例如 Windows 系统的全局钩子)要求必须在操作系统的主线程(Main OS Thread)上执行。在您的情况下,gohook 库在 Windows 系统上设置全局钩子时,需要在主操作系统线程上运行消息循环。如果您在新的 goroutine 中启动钩子,Go 运行时可能会将其调度到非主线程,导致钩子无法正常工作。

详细解释:

1. Go 的 Goroutine 与操作系统线程

  • Goroutine 是 Go 语言的一个核心特性,它是由 Go 运行时管理的轻量级线程。
  • 操作系统线程(OS Thread) 是由操作系统管理的真正的系统级线程。

Go 运行时在后台维护着一个线程池,goroutine 会被调度到这些操作系统线程上执行。Go 的调度器会尽可能高效地利用这些线程来运行 goroutine。

2. 主 goroutine 和主操作系统线程

  • 主 goroutine:当 Go 程序启动时,main 函数在一个特殊的 goroutine 中运行,称为主 goroutine。
  • 主操作系统线程:在某些操作系统(如 Windows 和 macOS)中,程序启动时会有一个主线程,某些 GUI 库或系统调用要求必须在这个主线程上执行。

在 Go 程序启动时,主 goroutine 会被固定在主操作系统线程上执行,除非主动调用 runtime.UnlockOSThread()

3. 为什么在 Windows 上全局钩子需要在主线程中运行

  • Windows 的全局钩子(如键盘、鼠标钩子)需要在主线程中设置,并且需要一个消息循环(Message Loop)来处理事件。
  • 原因是:Windows 的钩子机制要求钩子函数必须在与创建钩子的线程关联的消息循环中处理消息。
  • 如果您在非主线程中设置钩子,并且该线程没有正确的消息循环,或者该线程被 Go 运行时调度到不同的操作系统线程上,钩子将无法正常工作。

4. Go 的 goroutine 与 OS 线程的关系

  • 默认情况下,goroutine 是可以在不同的操作系统线程之间调度的,Go 运行时会自动选择哪个 OS 线程来运行 goroutine。
  • 如果您在 goroutine 中调用了需要在特定 OS 线程上执行的代码,那么就可能会出现问题。

5. runtime.LockOSThread() 的作用

  • runtime.LockOSThread() 可以将当前的 goroutine 绑定到当前的操作系统线程,防止 Go 调度器将其移动到其他线程。
  • 这样可以确保某些需要在特定线程上执行的代码(如与 GUI 库交互、设置全局钩子等)能够正确运行。

6. 您的问题具体分析

  • 您在代码中使用了 gohook 库来监听全局键盘事件。

  • 您最初是这样调用的:

    go trigger.ListenKeyBoard()

    这会启动一个新的 goroutine 来运行键盘监听逻辑。

  • 由于 goroutine 是由 Go 运行时调度的,它可能会在任何可用的操作系统线程上运行,不一定是主操作系统线程。

  • 当您在非主线程上设置全局钩子,Windows 系统可能无法正确处理,导致钩子无法正常工作,甚至出现程序卡死的情况。

7. 解决方案

  • 将钩子的设置和消息循环放在主 goroutine 中执行。因为主 goroutine 默认绑定在主操作系统线程上,能够满足 Windows 系统对于全局钩子的要求。
  • 不要在新的 goroutine 中调用钩子设置函数。这样可以避免 Go 运行时将其调度到其他操作系统线程上。

8. 为什么放在主函数中就可以

  • 当您将键盘监听的逻辑直接放在主函数中,并且在主 goroutine 中运行 hook.Start(),由于主 goroutine 默认运行在主操作系统线程上,并且没有被调度到其他线程,因此符合 Windows 系统对于全局钩子的要求。

  • 示例

    func main() { // 初始化程序 wlog.Info("程序开始") // 启动其他任务,例如 WebSocket 客户端 go func() { err := ws.NewWsClient() // 处理连接等逻辑 }() // 在主线程中启动键盘监听 trigger.ListenKeyBoard() // 主线程被 hook.Start() 阻塞,运行消息循环 }
  • 这样,ListenKeyBoard 中的 hook.Start() 就会在主操作系统线程上运行,满足了全局钩子的要求。

9. 关于 Go 的 main 函数和主 goroutine

  • main 函数所在的 goroutine(主 goroutine)在 Go 程序的生命周期内有特殊的地位:
    • 它在程序启动时创建,并且最初绑定在主操作系统线程上。
    • 除非显式调用 runtime.LockOSThread()runtime.UnlockOSThread(),否则 Go 运行时可能会将主 goroutine 调度到其他操作系统线程上。
    • 但是,在程序启动时,主 goroutine 通常在主操作系统线程上运行,这对于需要在主线程上执行的代码是有利的。

10. 如何确保代码始终在主操作系统线程上运行

  • 使用 runtime.LockOSThread()

    • 如果您需要确保某段代码始终在主操作系统线程上运行,可以在主函数的开头调用 runtime.LockOSThread(),锁定当前 goroutine 与当前操作系统线程的绑定。
    • 注意:这样做会使当前线程无法参与 Go 运行时的调度,需要谨慎使用。
  • 示例

    func main() { runtime.LockOSThread() // 锁定主 goroutine 在主 OS 线程上 // 初始化程序 wlog.Info("程序开始") // 启动其他任务,例如 WebSocket 客户端 go func() { // 由于主线程被锁定,其他 goroutine 会被调度到其他 OS 线程 err := ws.NewWsClient() // 处理连接等逻辑 }() // 在主线程中启动键盘监听 trigger.ListenKeyBoard() // 主线程被 hook.Start() 阻塞,运行消息循环 }

11. 总结

  • 主线程与 goroutine 的区别在于操作系统线程的绑定。在 Go 中,虽然 goroutine 是轻量级的,但当涉及到与操作系统交互、且需要特定线程的情况下,就需要考虑操作系统线程的要求。
  • 在 Windows 系统上,全局钩子必须在主操作系统线程上设置并运行消息循环。因此,将钩子的设置和消息循环放在主函数中,能够确保其在主线程上运行,避免了在 goroutine 中可能被调度到其他线程的问题。
  • 通过理解 Go 运行时的调度机制和操作系统的要求,可以更好地编写与系统交互的代码

12. 您的具体情况

  • 为什么放在主函数中就可以:因为在主函数中执行的代码默认在主操作系统线程上运行,满足了 Windows 系统对于全局钩子的要求。
  • 为什么放在 goroutine 中会有问题:因为 goroutine 可能被调度到任何操作系统线程上,无法保证钩子在主线程上设置,导致钩子无法正常工作。

希望以上解释能够帮助您理解其中的原因。如有其他疑问,欢迎继续提问。

Question

runtime.LockOSThread()是必须的吗

Réflexion

调试中的问题

探索选项

考虑是否需要锁定操作系统线程来运行Go操作系统。

Réponse

在您的情况下,是否需要使用 runtime.LockOSThread() 取决于您程序的具体需求和结构。

简要回答:

如果您确保 gohook 库的全局钩子设置和消息循环(即 hook.Start())在主操作系统线程上运行,并且主线程没有被移动到其他线程,那么一般情况下不需要显式调用 runtime.LockOSThread()。但是,为了确保钩子始终在主操作系统线程上运行,避免潜在的问题,使用 runtime.LockOSThread() 是一种稳妥的做法。

详细解释:

1. 什么是 runtime.LockOSThread()

  • runtime.LockOSThread() 是 Go 运行时提供的函数,用于将当前的 goroutine 固定在其当前的操作系统线程上。
  • 当调用 runtime.LockOSThread() 后,当前 goroutine 将始终在同一个操作系统线程上运行,Go 的调度器不会将其移动到其他线程。
  • 这对于需要与特定操作系统线程交互的情况非常有用,例如需要在主线程上运行的 GUI 事件循环、全局钩子等。

2. 为什么需要考虑使用 runtime.LockOSThread()

  • 全局钩子需要在主操作系统线程上运行: 在 Windows 系统上,全局钩子(如键盘、鼠标钩子)必须在主线程上设置,并且需要一个消息循环来处理事件。
  • Go 运行时的调度: Go 的 goroutine 是轻量级的,由 Go 运行时调度到不同的操作系统线程上执行。默认情况下,主 goroutine(main 函数所在的 goroutine)可能会被调度到不同的线程上。
  • 防止主线程被移动: 如果不使用 runtime.LockOSThread(),Go 运行时可能会在后台执行垃圾回收等操作时,将主 goroutine 移动到其他线程。这可能导致钩子不再在主线程上运行,导致钩子失效或程序异常。

3. 在您的程序中是否需要使用 runtime.LockOSThread()

  • 如果您确保主线程不会被移动: 如果您的程序结构简单,主 goroutine 没有进行可能导致线程切换的操作(如创建新线程、调用可能引发线程切换的库函数等),并且您确保钩子始终在主线程上运行,那么可能不需要显式调用 runtime.LockOSThread()
  • 为了保险起见,建议使用 runtime.LockOSThread() 为了防止潜在的问题,尤其是在程序复杂、可能进行多线程操作的情况下,使用 runtime.LockOSThread() 可以确保主 goroutine 不会被移动,从而保证钩子始终在主线程上运行。

4. 如何在程序中使用 runtime.LockOSThread()

  • 在主函数的开始位置调用 runtime.LockOSThread(),将主 goroutine 固定在主操作系统线程上。

示例代码:

func main() { // 锁定当前 goroutine(主 goroutine)在当前操作系统线程上 runtime.LockOSThread() // 初始化程序,例如日志、配置等 wlog.Info("程序开始") // 启动其他任务,例如 WebSocket 客户端 go func() { err := ws.NewWsClient() // 处理连接等逻辑 }() // 在主线程中启动键盘监听 trigger.ListenKeyBoard() // 主线程被 hook.Start() 阻塞,运行消息循环 }

5. 需要注意的问题

  • 解锁操作系统线程: 如果在主线程完成特定任务后,需要允许 Go 运行时将其移动到其他线程,可以调用 runtime.UnlockOSThread()
  • 避免过度使用: 不要在不必要的情况下锁定线程,因为这会减少 Go 运行时对线程的调度能力,可能影响程序性能。

6. 总结

  • 使用 runtime.LockOSThread() 可以确保钩子始终在主操作系统线程上运行,防止因为线程切换导致的钩子失效或程序异常。
  • 在您的程序中,建议在主函数开始时调用 runtime.LockOSThread(),以确保主 goroutine 不会被移动到其他线程,满足 Windows 系统对于全局钩子的要求。

因此,在您的情况下,使用 runtime.LockOSThread() 是必要的,或者至少是一个稳妥的做法。

7. 补充说明

  • 为什么之前的修改可能还存在问题: 即使您将键盘监听放在主函数中,但如果没有锁定操作系统线程,Go 运行时仍可能在后台将主 goroutine 移动到其他线程。
  • 使用 runtime.LockOSThread() 可以彻底解决这个问题,确保主 goroutine 始终在主操作系统线程上运行。

8. 参考资料

希望以上解释能够帮助您理解 runtime.LockOSThread() 的作用,并解决您程序中的问题。如果您还有其他疑问,欢迎继续提问。

Partager cette Q&R