一次JSON序列化panic引发的死锁问题记录
1.问题来源
前几天在产线进行大方直播性能压测时,遇到心跳接口TPS从平时的单机16000+突然下降到了集群TPS(8台机器)才200多一点,性能直接下降了上百倍。
同时运维同学还发现以下现象:
- Nginx上在转发此接口时有发现个别请求上游服务60s超时,返回504错误
- 从对机器资源的系统监控上看,8台机器中有一台内存明显比其它高,并且内存看起来只升不降
从这几个现象基本能推测,liveserver-555ddc587b-8rlm7(10.70.210.20)这台服务器内部应该是出现了阻塞。
2.阻塞分析
2.1接口逻辑
心跳接口的业务运行流程如下图,可以试着分析下哪个地方有可能引起阻塞:
2.2消息队列阻塞?
以下几点现象基本排除了消息队列阻塞:
- 从批量处理心跳的异步任务运行情况来看,消息队列里基本没有消息;
- 从缓冲队列的大小参数来看,消息队列的空间足够大(一百万),即使整个压测TPS都打到一台上,缓冲队列也打不满;
func Init() {
queue = NewQueue(1000000, 5000, updateHeartbeat)
}
func NewQueue(chanSize uint64, maxBatchNum int, f func(message []interface{})) *Queue {
……
}
- 从代码层来看,队列写操作本身不会阻塞,如果满了会直接丢消息;
2.3 MemCache死锁?
在一次缓存操作过程中,MemCache共三处用到了锁,分别是:
- Get操作:用到了全局Cache的读锁
- Set操作:用到了对全局Cache的写锁
- Update操作:用到了对更新操作管理的互斥锁,防止同一数据的并发更新
2.4 死锁的原因
从控制台日志中找到三块堆栈信息:
与心跳有明显相关的应该是第2部分堆栈,panic位置正好是MemCache操作的部分:
- Get操作发现MemCache中没有对象,于是执行第40行update操作加载并更新缓存;
- 第40行的update方法调用主要是对多线程操作的并发管理,最终加载数据是走入参f所封装的H.LiveStatus()方法;
- 而H.LiveStatus()方法发生了panic;
通过代码分析,不难发现问题:
- 加载数据的业务方法f发生panic, 导致item.Done()方法没有执行,这期间执行Item.Wait的所有线程都变成了永远阻塞;
- 由于这个异常任务item没有从全局变量updating中删除,后续相同key的所有update操作都阻塞在了item.Wait;
- 最终,MemCache阻塞了这个服务;
对外表现为,整场会议的所有心跳请求都被阻塞,所以内存只升不降,接口大量60s响应超时,压测机被超时请求阻塞,最终表现为TPS很低;
3.panic追溯
3.1 堆栈分析
死锁原因已经找到,那导致死锁的业务panic是如何发生的呢?
从详细堆栈结合代码来看,是业务方法执行过程中用JSON序列化数据引发了panic, 代码行如下:
JSON序列化为何panic暂时不知道,不过可以试着看看它崩在encoding/json的哪一行代码,代码崩溃的时候正在执行什么操作
单从这一行代码还是看不出来什么,可以从panic函数调用堆栈来试着推测下json序列化的整个过程,以此来判断1033行这个string函数在整个序列化过程中扮演的角色。
通过代码堆栈可以知道,json序列化的过程其实就是从外到内深度遍历遍历每个对象/字段的过程,序列化过程其实也反映了被序列化对象的内部结构,符合这个结构的字段应该是StreamInfo对象中一个字符串字段。
这里我们要结合下报错原因:invalid memory address or nil pointer dereference
- 字符串是值对象,空指针是说不通的
- 字符串可以看作是一个[]byte, 假如说遍历这个[]byte出现非法内存访问,那等于是说访问了不属于这个字符串的内存
3.2 字符串赋值是原子操作吗?
字符串会出现非法内存访问吗?看网上一个比较流传比较多的一个例子。
package main
import (
"fmt"
"time"
)
const (
FIRST = "WHAT THE"
SECOND = "F*CK"
)
func main() {
var s string
go func() {
i := 1
for {
i = 1 - i
if i == 0 {
s = FIRST
} else {
s = SECOND
}
time.Sleep(10)
}
}()
for {
if s == "WHAT" {
panic(s)
}
fmt.Println(s)
time.Sleep(10)
}
}
运行这段程序的结果:
从这段程序的测试结果来看,字符串赋值并不是线程安全的,实际上字符串内部结构也决定了字符串赋值并非原子操作:
type stringStruct struct {
str unsafe.Pointer
len int
}
3.3 可能原因推测
回过头来看我们的业务代码,直接对字符串字段进行的赋值的地方并没有找到,不过里面有对整个结构体对象赋值的地方:
猜想比较可能的原因:
- 切片内部就是数组,一块元素是固定大小的连续内存,切片元素按下标赋值可以理解为对指定内存区域按字段偏移逐个向内存写数据;
- 如果字段是简单类型(如整型),则地址偏移后直接写操作,如果字段是复杂类型(如结构体),则需要递归展开每个字段逐个读写;
- 字符串内部是一个结构体类型,可能会出现写完Data还没来得及写Len时,其它线程正好进行读操作,将不完整的stringStruct读走;
- 不完整的stringStruct被读走后,读操作线程就会按读到的Len对Data指向的内存地址进行访问,进而导致非预期的内存读写操作;
3.4 结构体赋值验证
type A struct {
age int
name string
}
func main() {
s := []A{
A{10, "zhangsan"},
A{20, "lisi"},
}
go func() {
for {
s[0], s[1] = s[1], s[0]
time.Sleep(10)
}
}()
for {
if s[0].name == "zhan" {
panic(s[0].name)
}
fmt.Println(s)
time.Sleep(10)
}
}
运行这段程序的结果:
这段程序基本证实了我们的猜想:
- 结构体变量的赋值是一个复杂的过程,里面分解成了很多字段的分别赋值的小步骤;
- 在结构体赋值操作执行的时候,如果同一时间有线程去并发的读取,读到的值是无法预期的;
**结论:**多线程上共享对象时,只能共享读操作; 一旦涉及到写操作,最好给每个线程生成独立的对象,或者加锁保护;
参考资料
- 聊聊 Go 并发安全 jishuin.proginn.com/p/763bfbd5d…
- Go服务灵异panic segmentfault.com/a/119000002…
转载自:https://juejin.cn/post/7184084521539764284