在Golang遍历map时,如何避免陷入性能陷阱?
摘要:最近一直在重构优化老系统,所以性能优化相关的文章会比较多。 这次的是有关循环处理map时的性能优化。预分配内存之类的大家都知道的就不多说了,今天来讲点大伙不知道的。 要讲的一共有三点,而且都和循环处理map有关。 不要用for-range循
最近一直在重构优化老系统,所以性能优化相关的文章会比较多。
这次的是有关循环处理map时的性能优化。预分配内存之类的大家都知道的就不多说了,今天来讲点大伙不知道的。
要讲的一共有三点,而且都和循环处理map有关。
不要用for-range循环清空map
这里要讨论的“清空”是指删除map中所有键值对,但保留map里已分配的内存供下次复用。
如果只是想释放map并且不再需要复用,那么map1 = nil或者map2 = map[T]U{}就足够了。
内置函数delete可以帮我们实现删除键值对但保留它们在map中的内存空间,通常我们会这么写:
for key := range Map1 {
delete(Map1, key)
}
这种模式化的代码太常见,以至于go编译器专门对其做了优化,只要形式上符合上述代码片段的,编译器都会把循环优化成runtime.mapclear(Map1),使用runtime内置的map清理函数将map清空,这比靠循环遍历删除要快很多倍。
看到这里你可能会说这不是挺好吗,为什么不让用了呢?
因为现在有更好的替代方案了——内置函数clear。
clear应用在map上时本身就会调用runtime.mapclear(...),在性能上和循环方法大致一样而且只快不慢。因为两者最终生成代码差不多,性能测试也就没多大意义了,所以这里不做性能测试。
clear还有另一个好处,它更容易让包含它的函数被内联。
这是什么意思呢?go的编译器实际上在编译时要分很多个步骤,粗略地讲go代码在真正开始生成机器码之前,得先经过内联 -> 逃逸分析 -> 语法树改写这样几个阶段。上文说的for循环删除优化在语法树改写这个阶段完成。
这就带来了一个问题,相比一个简单的clear函数调用,编译器认为for循环这种操作更“重量级”,一个函数拥有的“重”操作越多,那么这个函数被内联优化的可能性就越小。
我们看个例子:
func RangeClearMap() {
for k := range bigMap {
delete(bigMap, k)
}
for k := range bigPtrMap {
delete(bigPtrMap, k)
}
for k := range smallMap {
delete(smallMap, k)
}
for k := range smallPtrMap {
delete(smallPtrMap, k)
}
for k := range bigMapIntKey {
delete(bigMapIntKey, k)
}
for k := range bigPtrMapIntKey {
delete(bigPtrMapIntKey, k)
}
for k := range smallMapIntKey {
delete(smallMapIntKey, k)
}
for k := range smallPtrMapIntKey {
delete(smallPtrMapIntKey, k)
}
}
func BuiltinClearMap() {
clear(bigMap)
clear(bigPtrMap)
clear(smallMap)
clear(smallPtrMap)
clear(bigMapIntKey)
clear(bigPtrMapIntKey)
clear(smallMapIntKey)
clear(smallPtrMapIntKey)
}
同样是清空8个map,一个用循环,一个用内置函数。我们看下内联分析的结果:
其中cost就是衡量一个函数里的操作有多“重”的数值标准,超过一定的cost,函数就无法内联。可以看到,使用循环会比使用clear内置函数重整整四倍。虽然最后因为两个函数都很简单所以被内联展开,但碰上更复制一点的函数,显然使用clear能有更多的冗余。
尽管编译器最终会把两者优化成一样的对runtime的map清理函数的调用,但对for循环的优化在内联处理之后,因此for不仅让代码更长,也更容易错失内联优化的机会,而失去内联优化进而会影响逃逸分析从而损失更多性能,你可以在我以前的博客里看到内联和逃逸分析对内联的影响。
clear()是go1.21添加的,因此只要你在用的go版本大于等于1.21,我推荐你尽量使用clear而不是for-range循环来清空map。
遍历访问map时的陷阱
遍历处理map中的元素也是常见操作,不过不像循环删除,编译器在这种代码上并没有什么优化。
