goroutine与panic不得不说的故事
我之前对golang还了解的极其肤浅的时候,就已经对goroutine如雷贯耳了,我相信很多同学跟我一样,会以为在go代码中,goroutine的身影随处可见,事实上并不是这样。
这两天参与了金融部门的一个小项目,把一个老系统中的小模块从php代码重构成golang。因为负责重构的同事之前只有php经验,所以派我和另外一个同事去帮忙。今早总监过来看看进度,无意中看了眼我的代码,立刻给我指出了一个严重bug,让我发现了一个知识盲点,我觉得值得分享一下。
过程
昨天下午写了一个grpc
接口,根据user_id
从数据库查询一张user_config
表,拿到一个city_ids
字段,是个city_id
组成的字符串,然后split
处理后查city
表取城市数据,大概过程类似这样:
func GetCities(userID int64) ([]*cityData, error) {
var (
strCityIDs string
CityIDs []string
ret []*cityData
)
strCityIDs, _ = userConfig.GetCityIDs(userID) //从user_config表查询city_id字段
CityIDs = strings.Split(strCityIDs, sep) //处理成id数组
err = city.Find(CityIDs, &ret) //从city表查出数据
return ret, err
}
说白了就是个has_many
关系。因为city
表几乎不会变化,早上来了公司,我觉得可以加个缓存,所以改成了:
func GetCities(userID int64) ([]*cityData, error) {
var (
strCityIDs string
CityIDs []string
ret []*cityData
)
strCityIDs, _ = userConfig.GetCityIDs(userID) //从user_config表查询city_id字段
err := cache.Get(prefix+strCityIDs, &ret) //先从缓存拿数据
if err == nil {
return ret, nil
}
CityIDs = strings.Split(strCityIDs, sep) //处理成id数组
err = city.Find(CityIDs, &ret) //从city表查出数据
if err == nil {
ok := cache.Set(prefix+strCityIDs, &ret, 12*time.Hour) //存入缓存
if !ok {
doNothing()
}
}
return ret, err
}
改完后“灵机”一动,想起自己几乎没在公司项目中看到过go
关键字的出现,自己也基本没在生产中实际用过goroutine
,于是把cache.Set
改成了go cache.Set
。我觉得存入缓存成功与否并不影响主流程(即便失败其实我也什么都不做),所以完全可以交给协程去做,而且这样主goroutine
可以返回的更快。
这时总监过来了。
聊了两句,突然指着代码跟我说:“这里不对,不能用协程!”
我:“为啥啊?”
总监:“因为协程里面发生panic会让整个进程crash。”
我更加迷惑了:“但是我在middleware里加了recover啊,会抓到panic的。”
middleware
代码:
func (*Interceptor) Method(ctx context.Context, srvInfo *core.SrvInfo, req interface{}, handler func(context.Context, interface{}) (interface{}, error)) (ret interface{}, err error) {
defer func() {
if p := recover(); p != nil {
err = fmt.Errorf("internal error: %v", p)
}
}()
ret, err = handler(ctx, req) //所有下层逻辑全部在这个函数里分发,所以我错误地认为任何panic都能在这里recover
return ret, err
}
总监:“goroutine发生panic,只有自身能够recover,其它goroutine是抓不到的,这是常识啊。”
我:“......”
吓的我啥也没敢再说,赶紧把go
关键字删了,然后等总监走了之后,立马上网研究了一波goroutine、panic、recover之间的关系,下面是结论。
结论
首先,要明确一点,panic
会停止整个进程,不仅仅是当前goroutine
,也就是说整个程序都会凉凉(我现在认为这就是goroutine
没有在代码里泛滥的原因之一,另外的原因是,我觉得在cpu
核全部跑起来的情况下,开再多的goroutine
也只能并发而不能并行)。
其次,panic
是有序的、可控的停止程序,不是啪唧一下就宕掉了,所以我们还可以用recover
补救。
然后,recover
只能在defer
里面生效,如果不是在defer
里调用,会直接返回nil
。
最后,很重要的一点是:goroutine
发生panic
时,只会调用自身的defer
,所以即便主goroutine
里写了recover
逻辑,也无法拯救到其它goroutine
里的panic
。
所以呢,之前的go cache.Set
写法是很危险的,因为cache
里没有做任何recover
,一旦出现panic
,会影响到整个系统。
假设我一定装这个逼用go
关键字实现(显然我不是这样的人),代码可以改成:
go func() {
defer func() {
if r := recover(); r != nil {
fmt.Println("don't worry, I can take care of myself")
}
}()
cache.Set(prefix+strCityIDs, &ret, 12*time.Hour) //存入缓存
}()