一、背景
Hyperscan是intel开源的一款高性能正则引擎,很适合检测数据流中的恶意特征。使用golang调用hyperscan功能时,需要引用gohs库,它封装好了cgo接口并屏蔽了golang调用hyperscan C库的复杂性。然而当处理大流量数据时,老版本gohs库会偶发panic引起程序崩溃,新版本gohs库虽然解决了panic问题,但存在严重的性能损失,无法使用于大流量高性能场景,因此对gohs源码进行了深入分析,最终不影响hyperscan高性能的前提下解决了偶发panic问题。
二、性能分析
gohs库会偶发panic问题的版本是1.0.0,新版本1.2.1解决了panic问题,但升级之后,导致安全检测系统处理性能骤降,无法应用到线上环境。
gohs库升级之后,某集群处理QPS从10w 降低到5w,减少50%
cpu使用率从40%~50%降低到20%~30%,数据基本无法在规定时间内完成安全检测。
分析后发现,gohs库在调用C libhs.so库的scan函数时,会将Go指针传给cgo函数,但传递给C函数的go指针指向的内容包含指向其它go地址的指针,简单来说就是go指针指向了其它go指针,这种情况会引发go panic,这个问题是cgo的一个已知问题,详细信息可以参考https://pkg.go.dev/cmd/cgo#hdr-Passing_pointers
官方给出两种方案,第一种方式是使用go源码中提供的runtime/cgo.Handle方法, gohs1.2.1使用的便是这种方法,源码如下:
官方采用了sync.map 来存储需要传递给C模块的go指针,并用一个自增uintptr类型全局变量handleIdx 来索引go指针,这个方法核心是使用了sync.map,问题也就出在这,sync.map适用的场景是读多写少的场景,但hyperscan 的scan正则检测是高频调用,读多写多的场景,gohs1.2.1库会频繁向sync.map中写入 key:handleIdx, value:包含go指针的结构体 ,使用一次后立即删除,这会导致频繁向sync.map的dirty表中插入数据,且没办法利用sync.map的缓存特性,此时相当于在全局加了一把互斥锁;从sync.map获取 go指针数据会从dirty表中读取,当读取次数达到dirty数据长度时,即存入次数=读取次数时,触发dirty表迁移到read表,又会增加额外的开销,导致性能比全局互斥锁还要低,因此golang 使用hyperscan做流量安全检测性能下降严重,无法应用于线上大流量场景。
gohs1.2.1性能瓶颈代码位于internal/hs/runtime.go,如下所示:
四、解决方案
对于调用so动态库中的函数,cgo官方给了另一种方法:手动关闭指针检测,即设置GODEBUG=cgocheck=0环境变量,指定运行时不做go指针检测,允许传递给C模块的指针指向的内容中包含指向其它go空间的指针,但这可能会导致go的GC机制将还未被使用的go指针提前释放掉,因此需要调用runtime包下的keepalive函数防止GC机制回收go指针
gohs1.2.1源码修改如下:
五、结果
采用如上解决方案定制修改gohs1.2.1版本库之后,hyperscan正则检测偶发panic问题得到彻底解决,同时性能没有受到影响,上线安全系统稳定运行。
因为没有锁的影响,CPU没有明显降低,保持在40%~50%左右
处理的QPS恢复正常,安全检测系统处理总QPS达到80w+,一切运行正常,符合预期。