Go Sidecar 主循环并发化改造:让请求不再排队堵在门口
本文为山东大学软件学院创新实训项目博客
Go Sidecar 主循环并发化改造:让请求不再排队堵在门口
这次我修的是 IntelliGit Go Sidecar 入口处的请求分发问题。
问题本身看起来很小:sidecar/cmd/sidecar/main.go里的主循环在读到一个请求以后,会同步执行router.Dispatch(req),再同步调用codec.WriteResponse(resp)写回响应。也就是说,整个 Sidecar 虽然通过 stdin/stdout 支持异步请求 ID,但 Go 端真正处理请求时仍然是一个接一个排队执行。
这在小仓库、少量操作时不明显;但一旦前端启动刷新、状态刷新、历史刷新、远程 fetch 同时发生,问题就会被放大:
前端 Promise.all 并发发请求 -> Electron Main 逐条写入 Sidecar stdin -> Go Sidecar 主循环逐条读取 -> 第一条请求没处理完,第二条请求只能等 -> 如果第一条刚好是 remote.fetch 这种网络 IO,后面的 status/log/diff 都会被堵住所以这次修复的目标很明确:保持 stdin 读取仍然稳定顺序进行,但每个请求的实际处理放进 goroutine 中并发执行。
一、原来的主循环为什么会堵住
Sidecar 的入口文件是:
sidecar/cmd/sidecar/main.go原来的主循环大致是这样的:
for{req,err:=codec.ReadRequest()iferr!=nil{iferr==io.EOF{break}log.Printf("读取请求失败: %v",err)continue}log.Printf("收到请求: id=%s command=%s",req.ID,req.Command)resp:=router.Dispatch(req)iferr:=codec.WriteResponse(resp);err!=nil{log.Printf("写入响应失败: %v",err)}ifresp.Success{log.Printf("请求完成: id=%s ✓",req.ID)}else{log.Printf("请求失败: id=%s error=%s",req.ID,resp.Error)}}这段代码没有语法问题,也很好理解。但它有一个隐藏的结构问题:
ReadRequest -> Dispatch -> WriteResponse -> 再读下一个请求也就是说,ReadRequest()读到请求以后,主循环会一直卡在当前请求的业务处理里。只有当前请求完整处理完、响应写回以后,它才会继续读取下一行 JSON。
这和前端的调用方式是冲突的。
前端很多地方已经是并发模型了,比如刷新时会同时请求:
staging.status commit.log branch.current branch.list remote.fetch diff.workdir但这些并发请求到了 Go 端以后,又重新变成了串行队列。于是我们得到一个很尴尬的结果:
前端以为自己在并发 后端实际还在排队如果排在前面的命令只是sidecar.ping,影响不大;但如果排在前面的是remote.fetch、commit.log、大文件diff.workdir这类耗时操作,后面的本地状态刷新就会被无辜牵连。用户看到的现象就是:明明只是想刷新一下文件状态,界面却像是在等网络。
二、并发化之前先确认 stdout 是否安全
这个改动不能只是在Dispatch外面随手套一个go func()。因为 Sidecar 和 Electron Main 的通信依赖 stdout 上的一行一条 JSON:
{"id":"1","success":true,"data":{}}{"id":"2","success":false,"error":"..."}如果多个 goroutine 同时往 stdout 写,而写入过程没有互斥保护,就可能出现两条 JSON 交叉写入:
{"id":"1","success{"id":"2","success":true} ":true}这样 Node 侧就会直接解析失败,整个 IPC 协议会被污染。
所以在动主循环之前,我先检查了协议层:
sidecar/internal/protocol/codec.go里面的Codec已经有一个 mutex:
typeCodecstruct{scanner*bufio.Scanner encoder*json.Encoder writer io.Writer mu sync.Mutex// 保护 writer 的并发写入}WriteResponse和WriteNotification都会先加锁:
func(c*Codec)WriteResponse(resp*Response)error{c.mu.Lock()deferc.mu.Unlock()returnc.encoder.Encode(resp)}这说明并发写响应是可行的。多个请求可以并发执行,但最终写 stdout 时会串行进入encoder.Encode,保证每条 JSON 仍然是一行完整消息。
这一步很关键。否则并发化主循环虽然能提高吞吐量,却可能把 IPC 协议本身打坏。
三、不能无限开 goroutine
修复计划里给出的核心方向是:
gofunc(r*protocol.Request){resp:=router.Dispatch(r)codec.WriteResponse(resp)}(req)这是最小可行版本。但我实际落代码时没有停在这个版本,而是加了一个并发上限:
constmaxConcurrentRequests=8对应主循环里新增了一个带缓冲的 channel:
requestSlots:=make(chanstruct{},maxConcurrentRequests)varwg sync.WaitGroup为什么要加这个限制?
因为 IntelliGit 的 Sidecar 处理的不是普通内存计算,而是 Git 操作。Git 操作里可能包含:
扫描工作区文件 读取 Git index 遍历 commit 历史 生成 diff 执行 git CLI 访问远程仓库如果前端因为轮询、防重入缺失或者用户连续操作,一瞬间打进来几十个请求,我们不应该无上限地创建几十个 goroutine 同时跑 Git 操作。那样虽然“并发”了,但可能把磁盘、CPU、网络和底层 Git 仓库状态一起压垮。
所以这里采用了一个更稳妥的模型:
stdin 继续顺序读取请求 每个请求进入 goroutine 执行 最多同时执行 8 个请求 超过 8 个时,主循环在 requestSlots 处自然背压这相当于一个很轻量的 worker 限流器。它没有引入复杂的任务队列,也没有改变协议,只是给 goroutine 并发加了一个上限。
四、实际代码改动
最终main.go的核心逻辑变成了这样:
requestSlots:=make(chanstruct{},maxConcurrentRequests)varwg sync.WaitGroupfor{req,err:=codec.ReadRequest()iferr!=nil{iferr==io.EOF{log.Println("stdin 已关闭,准备退出")break}log.Printf("读取请求失败: %v",err)continue}log.Printf("收到请求: id=%s command=%s",req.ID,req.Command)requestSlots<-struct{}{}wg.Add(1)gofunc(r*protocol.Request){deferwg.Done()deferfunc(){<-requestSlots}()handleRequest(router,codec,r)}(req)}wg.Wait()log.Println("IntelliGit Sidecar 已退出")这里有几个细节值得记录。
第一,req被显式传进 goroutine:
gofunc(r*protocol.Request){handleRequest(router,codec,r)}(req)这样可以避免闭包直接捕获循环变量带来的隐患。虽然现代 Go 对 loop variable 的行为已经改进过,但这里显式传参仍然更清楚,也更符合老代码维护时的直觉。
第二,requestSlots的释放放在defer里:
deferfunc(){<-requestSlots}()这样无论请求成功、失败,还是中间出现 panic 恢复,都会释放并发槽位,不会因为某个请求异常导致整个 Sidecar 后续请求全部卡死。
第三,EOF 之后没有立刻退出,而是等待所有在途请求完成:
wg.Wait()这让退出行为更优雅。stdin 关闭只能说明 Electron Main 不再继续发送新请求,并不代表之前已经读到的请求都处理完了。等待在途请求结束,可以避免最后几条响应莫名其妙丢失。
五、把请求处理拆成小函数
为了不让main()继续膨胀,我把单个请求的处理拆到了handleRequest:
funchandleRequest(router*handler.Router,codec*protocol.Codec,req*protocol.Request){resp:=dispatchRequest(router,req)iferr:=codec.WriteResponse(resp);err!=nil{log.Printf("写入响应失败: %v",err)}ifresp.Success{log.Printf("请求完成: id=%s ✓",req.ID)}else{log.Printf("请求失败: id=%s error=%s",req.ID,resp.Error)}}这个函数只做三件事:
分发请求 写回响应 记录完成日志原来外层主循环里的完成日志也被移到了 goroutine 内部。这个位置调整是必须的,因为并发以后,请求完成顺序不再等于请求读取顺序。
比如请求顺序可能是:
1 remote.fetch 2 staging.status 3 commit.log但完成顺序完全可能变成:
2 staging.status 3 commit.log 1 remote.fetch所以“请求完成”日志必须跟着实际处理逻辑走,不能继续留在主循环里。
六、补上 panic 恢复
这次我还额外加了一层dispatchRequest:
funcdispatchRequest(router*handler.Router,req*protocol.Request)(resp*protocol.Response){deferfunc(){ifrecovered:=recover();recovered!=nil{log.Printf("请求处理 panic: id=%s panic=%v",req.ID,recovered)resp=&protocol.Response{ID:req.ID,Success:false,Error:fmt.Sprintf("请求处理 panic: %v",recovered),}}}()returnrouter.Dispatch(req)}这个不是并发化的必要条件,但它和并发改造非常适合一起做。
原因是:并发以后,每个请求都在独立 goroutine 里跑。如果某个 handler 发生 panic,而我们没有 recover,整个 Go 进程仍然会崩溃。对一个桌面客户端来说,这种行为太脆弱了。
现在的策略是:
单个请求 panic -> 记录 stderr 日志 -> 给对应 request id 返回失败响应 -> 其他请求继续执行 -> Sidecar 进程继续存活这对后续排查也更友好。前端至少能拿到带 request id 的失败响应,而不是突然发现 Sidecar 进程消失了。
七、这次改动后的执行模型
改完以后,Sidecar 的整体执行模型可以概括成:
主 goroutine: 只负责从 stdin 读取请求 为每个请求申请并发槽位 启动请求处理 goroutine EOF 后等待在途请求结束 请求 goroutine: 调用 router.Dispatch recover handler panic 通过 codec.WriteResponse 写回响应 记录请求完成/失败日志 释放并发槽位 Codec: 用 mutex 保证 stdout 写入互斥这个模型没有改变协议格式,也没有要求前端改代码。对 Electron Main 来说,它仍然是:
写入一行请求 JSON 等待对应 id 的响应 JSON区别在于 Go 端不再让一个慢请求堵住后面的所有请求。
八、为什么这次只改 main.go
这次任务只要求完成修复计划里的第 1 项:
修改 sidecar/cmd/sidecar/main.go(开启并发处理)所以这次没有继续动repository.go的锁保护,也没有动staging.go/diff.go里的性能优化。
但需要注意的是,主循环并发化只是第一步。它会让多个 Git 请求真正同时进入 handler 和 Repository 层,因此后续第 2 项“给 Repository 增加并发锁”非常重要。
这次的改动解决的是:
请求调度层不再串行堵塞后续锁保护要解决的是:
多个并发请求同时访问当前仓库时的数据安全问题这两个问题是配套的。没有主循环并发化,Repository 锁的收益不明显;没有 Repository 锁,并发化又可能把底层 Git 状态暴露在竞争条件里。
九、验证结果
改动完成后,我先对入口文件执行了格式化:
gofmt-wsidecar/cmd/sidecar/main.go然后运行 Go 端测试:
cdsidecar gotest./...第一次测试时,命令被当前运行环境的沙箱拦在 Go 构建缓存目录:
open C:\Users\pc23\AppData\Local\go-build\...\*.d: Access is denied这不是代码错误,而是 Go 测试需要写用户级go-build缓存。提权后重新运行,测试通过:
? intelligit-sidecar/cmd/sidecar [no test files] ok intelligit-sidecar/internal/git 12.289s ok intelligit-sidecar/internal/handler 1.684s ? intelligit-sidecar/internal/protocol [no test files]最终本次实际修改的代码文件只有:
sidecar/cmd/sidecar/main.go新增的能力包括:
1. 请求处理进入 goroutine,并发执行 Dispatch + WriteResponse。 2. 使用 maxConcurrentRequests 限制最大并发数为 8。 3. 使用 WaitGroup 在 EOF 后等待在途请求完成。 4. 将完成/失败日志移动到请求 goroutine 内部。 5. 增加 panic recover,避免单个 handler 异常拖垮整个 Sidecar。十、总结
这次修复不算大,但它改到了 IntelliGit Sidecar 的一个关键位置:请求入口。
原来的 Sidecar 更像是一个单窗口柜台:前端哪怕同时递上来很多张单子,后端也只能一张一张办。现在它变成了有限并发模型:多个请求可以同时处理,但仍然有明确的并发上限和 stdout 写入互斥。
它带来的直接收益是:
remote.fetch 不再天然堵住 staging.status 大 diff 不再天然堵住 sidecar.ping 慢请求不再天然拖慢所有后续请求 EOF 退出时不会粗暴丢弃已经读到的请求 单个 handler panic 不会直接杀死整个 Sidecar从架构上看,这一步也为后续优化铺好了路。接下来继续补上 Repository 的读写锁、Status/Diff 的索引 map 优化、前端刷新防重入之后,IntelliGit 的启动刷新链路会从“请求排队等慢操作”逐步变成真正的分层并发模型。
这就是这次main.go并发化改造的完整记录。
