用Go写代码好几年了,自认为对Go很熟悉。但升级到Go 1.21之后,还是踩了不少坑,有几个坑让我熬到了凌晨三四点。
这篇文章记录几个印象最深的踩坑经历,每个都是真实发生过的,希望能帮大家避免同样的问题。
第一个坑:for循环变量捕获
第一个坑是经典的循环变量捕获问题,虽然老掉牙了,但在Go 1.21里还是踩了。
事情是这样的。我们有个服务需要并发处理一批任务,我写了这样的代码:
tasks := []string{"a", "b", "c"} for _, task := range tasks { go func() { process(task) }() }
看起来没问题对吧?但运行之后发现,三个goroutine处理的都是最后一个任务"c"。原因是Go的循环变量是复用的,每次迭代task变量都是同一个,goroutine启动的时候循环可能已经结束了,读到的都是最后一个值。
这个问题在Go 1.22之前一直存在,官方在1.22才改了循环变量的语义。但我们用的是1.21,所以还是踩了。
解决方案是在循环里把变量传进去:
for _, task := range tasks { go func(t string) { process(t) }(task) }
或者在循环体里创建一个局部变量:
for _, task := range tasks { task := task go func() { process(task) }() }
这个坑虽然基础,但真的很容易犯,特别是在写并发代码的时候。那天晚上排查了半天才发现,因为日志里显示的任务名都是对的,但处理的内容都是错的,特别诡异。
第二个坑:slices.Sort的不稳定性
Go 1.21新加了slices包,里面有排序、查找等函数。我图方便,把项目里自己写的排序函数都换成了slices.Sort。
结果上线之后出了个诡异的bug:有个列表的排序结果偶尔不对,同样的数据,有时候排序是对的,有时候是错的。
排查了很久才发现,slices.Sort是不稳定排序。我们的列表里有一些相同排序键的元素,不稳定排序会导致这些元素的顺序不确定。而业务逻辑依赖了这些元素的相对顺序,所以就出问题了。
解决方案是用slices.SortStableFunc,稳定排序,相同键的元素保持原顺序。
这个坑让我熬到了凌晨两点,因为问题是偶现的,本地很难复现。后来加了很多日志才定位到排序的问题。
教训是:用新的标准库函数之前,一定要看清楚文档,了解它的行为。特别是排序这种基础操作,稳定性很重要。
第三个坑:map的并发读写
这个坑也是经典,但还是踩了。
我们有个全局的map用来缓存数据,读写都有加锁。但有个地方我图快,读的时候忘了加锁,觉得只是读一下,不会有问题。
结果上线之后,服务偶尔会panic,报错是"concurrent map read and map write"。Go的map不支持并发读写,即使是一写一读也不行,运行时会直接panic。
这个问题也是偶现的,压力小的时候不出现,压力大的时候才出现。排查的时候看了代码,觉得读操作不会改map,应该没问题,但Go的运行时就是会检查,只要有并发的写和读,就会panic。
解决方案是读的时候也要加锁,或者用sync.RWMutex,读用RLock,写用Lock。或者用sync.Map,专门为并发读写设计的。
这个坑让我明白了一个道理:在Go里,并发安全不能靠感觉,必须严格按照规则来。该加锁的地方一定要加锁,不要心存侥幸。
第四个坑:defer的执行时机
Go的defer很好用,但执行时机有时候和你想的不一样。
我们有个函数,打开文件之后defer关闭,然后在函数里返回了文件的内容。看起来没问题,但上线之后发现文件描述符泄漏,打开的文件没有及时关闭。
排查之后发现,defer是在函数返回的时候执行的,不是在变量离开作用域的时候执行。我们的函数里有个循环,每次迭代都打开一个文件,defer关闭,但这些defer要等整个函数返回的时候才执行,循环过程中文件描述符一直没释放。
解决方案是把文件操作封装到一个子函数里,这样子函数返回的时候defer就执行了,文件及时关闭。或者不用defer,手动关闭。
这个坑让我对defer有了更深的理解。defer虽然方便,但在循环里或者长时间运行的函数里要小心,可能会导致资源不及时释放。
第五个坑:context的超时设置
Go的context用来传递取消信号和超时,很好用。但超时时间的设置有个坑。
我们有个接口,调用下游服务,设置了5秒的超时。但有时候下游服务响应慢,我们的接口会等超过5秒才返回。
排查之后发现,context的超时是从创建context的时候开始算的,不是从调用下游的时候开始算的。我们的函数在调用下游之前做了一些预处理,花了2秒,然后创建了5秒超时的context,再调用下游。但下游调用本身花了4秒,总时间是6秒,超过了接口的超时时间。
不对,等一下,这样算的话应该是4秒,不会超过5秒。实际情况是,我们在调用链的每一层都创建了新的context,每层都设置了超时,但这些超时是独立的,不是累加的。上游设置了5秒超时,下游又设置了5秒超时,结果下游的调用可能花了4秒,上游的context已经超时了,但下游还在跑,导致资源浪费。
正确的做法是,整个调用链用同一个context,超时时间在最上层设置,下游直接用传进来的context,不要重新设置超时。或者用context.WithTimeout创建子context,但超时时间要比父context短。
这个坑让我熬到了凌晨三点,因为问题很隐蔽,日志里显示每个调用都没超时,但整体超时了。后来画了调用链的时间线才发现问题。
第六个坑:goroutine泄漏
Go的goroutine很轻量,创建很方便,但也很容易泄漏。
我们有个服务,运行一段时间之后内存占用越来越高,最后OOM了。排查之后发现是goroutine泄漏,有大量的goroutine卡在那里没有退出。
具体原因是,我们启动了一个goroutine去做某个任务,任务里有个channel等待数据,但这个channel永远不会有数据进来,因为发送方已经退出了。goroutine就一直卡在那里,永远不会退出。
还有一种情况是,goroutine里做了一个网络请求,没有设置超时,网络一直不通,goroutine就一直等。
解决方案是,所有的goroutine都要有退出机制,要么用context取消,要么用channel通知,要么设置超时。不要启动了goroutine就不管了,一定要确保它能正常退出。
可以用pprof查看goroutine的数量和堆栈,定位泄漏的位置。我们就是用pprof发现大量goroutine卡在同一个地方,然后找到问题的。
这个坑让我明白了,goroutine虽然轻量,但也是资源,不能随便创建。创建goroutine的时候就要想清楚它什么时候退出,怎么退出。
第七个坑:json反序列化的零值
Go的encoding/json包很好用,但有个坑是零值和缺失值分不清。
我们有个API,接收一个JSON对象,里面有个可选字段。如果字段没传,我们希望用默认值;如果字段传了0,我们希望用0。但用json.Unmarshal反序列化之后,没传和传0的结果是一样的,都是零值,分不清。
这个问题导致了一个bug:用户传了0,我们当成没传,用了默认值,结果不符合用户预期。
解决方案是用指针类型。如果字段是*int,没传的时候是nil,传了0的时候是指向0的指针,就能区分了。或者用json.RawMessage,自己解析原始JSON,判断字段是否存在。
这个坑虽然不大,但很常见。特别是在做API设计的时候,可选字段的零值处理一定要想清楚。
一些心得
踩了这么多坑,总结了一些心得。
第一,升级新版本之前一定要读release notes。Go 1.21的release notes里提到了很多变化,包括slices包、新的内置函数、工具链变化等。如果提前读了,很多坑可以避免。
第二,不要想当然。即使是用了很久的语言,也可能有你不了解的细节。遇到问题的时候,不要凭感觉判断,要去查文档、写测试、做实验。
第三,并发问题要特别小心。Go的并发很强大,但坑也很多。map并发读写、goroutine泄漏、context超时、channel阻塞,这些都是常见的并发坑。写并发代码的时候一定要多想想。
第四,善用工具。pprof、race detector、go vet、staticcheck,这些工具能帮你发现很多问题。特别是race detector,跑测试的时候加上-race参数,能发现很多数据竞争问题。
第五,代码评审很重要。很多坑在代码评审的时候就能发现,不要一个人闷头写。多一个人看,多一份保障。
写在最后
Go是一门简单的语言,但简单不代表没有坑。越是简单的语言,越容易在细节上出问题。
这些坑让我熬了不少夜,但也让我对Go有了更深的理解。每踩一个坑,就多一分经验,下次遇到类似的问题就能更快地解决。
希望这篇文章能帮大家少踩点坑。如果你也踩过Go 1.21的坑,欢迎交流。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录