[译]Go数组访问的优化

今天看了一篇很有趣的博文,讲到了使用Go写代码时,对于更新数组,一个小小的改动却能大大提高代码的效率。而自己平时最经常使用的居然是效率最低的一种方式。不得不感慨Go虽然入门容易,但是细节无处不在,平时需要注意的地方,需要深挖的地方太多太多。这里把文章翻译撸一下给大家分享下,文章很短,可能只需要你马桶上5分钟的时间,你就将Get一个很牛逼的技能。

只撸具体流程,需要看详细原文的,请移步:

原文地址:How the Go language improves expressiveness without sacrificing runtime performance

前提

假如你有一个数据结构,用于一系列的计数,数据结构如下:

type E struct {
        A, B, C, D int
}

var e = make([]E, 1000)

然后你需要一个方法来更新数组e中的计数。

最常用的方法

这个问题so easy,大家一拍脑袋就能想到的就是:无非就是一个循环便利数组,然后更新就可以了。然后有了下面的方法(为了大家能够快速理解,将原文代码稍有更改,后面示例同):

func BenchmarkManual(b *testing.B) {
        var e = make([]E, SIZE)
        for j := 0; j < b.N; j++ {
                for i := range e {
                        e[i].A += 1
                        e[i].B += 2
                        e[i].C += 3
                        e[i].D += 4
                }
        }
}

这种方法有个问题:大家知道面向对象编程中为了让码农能将更多的精力集中在业务上,对数据类型进行了完美的封装。类似的,对应到这里就是你在访问一个数组元素时,不必考虑数组索引是否越界,编译器这里会自动在每次访问e[i]之前塞一个边界检查进去(这里并不是面向对象式的在类中封装了边界检查,而是有编译器承担了相应的工作,但是思想类似。)。所以问题来了,这里每次循环里面更新一个e[i]的元素时,需要访问四次e[i]就有4次边界检查,有3次都是多余的。

改进

那么已经知道问题在哪里了,优化就很好做了,只用干掉那三次多余的隐藏的边界检查就可以了。

func BenchmarkUnroll(b *testing.B) {
        var e = make([]E, SIZE)
        for j := 0; j < b.N; j++ {
                for i := range e {
                        v := &e[i]
                        v.A += 1
                        v.B += 2
                        v.C += 3
                        v.D += 4
                }
        }
}

这种方法取了e[i]的地址,然后再做更新操作。所以只用做取地址那一次的边界检查即可。但是这种写法总有点怪怪的,别人看到可能要绕很大一个圈想v := &e[i]这一步到底是啥意思,完全多此一举嘛。可能更有甚者还会纠结,为什么是取地址,然后直接操作地址,而不是操作地址的值呢…巴拉巴拉,总之怪怪的啦。

再改

好了,既然E是一个结构体,我们给他封装一个方法试试:

func (e *E) update(a, b, c, d int) {
        e.A += a
        e.B += b
        e.C += c
        e.D += d
}

func BenchmarkUpdate(b *testing.B) {
        var e = make([]E, SIZE)
        for j := 0; j < b.N; j++ {
                for i := range e {
                        e[i].update(1, 2, 3, 4)
                }
        }
}

这种方法因为update声明的是属于(e *E),所以在执行e[i].update(1,2,3,4)时,会自动塞如e[i]的地址&e[i]。同样只会做一次边界检查,所以这种方法和第二种有异曲同工之妙。只是因为封装了一层,函数调用涉及到栈的处理,效率不如第二种方法。

结果

执行benchmark测试,三种方法结果如下。可以看到第一种方法确实是性能最差的。第二种方法最好,第三种因为涉及到栈相关的操作,性能稍差。但是综合权衡代码可读性、可维护性和性能,第三种无疑是最好的。 还有貌似函数调用的压栈、出栈在这里性能上的损失很小,博主觉得从获得更好的代码可读性、可维护性这点损失完全可以忽略。

BenchmarkManual-4	  300000	      4714 ns/op
BenchmarkUnroll-4	 1000000	      1725 ns/op
BenchmarkUpdate-4	 1000000	      1884 ns/op