这是一篇优化go代码的文章,起这个题目的原因是,我觉得看到标题你们肯定啪的一下就点进来很快呀,但无论你是谁,我都是你的友军,我是一名PHPer,同时我不认为go会比php慢2倍。
这是知名网友E同学提出来的代码,因为获取时间戳、字符拼接、字典使用更贴合实际开发
,所以拿来比较有意义。
很引战,不过码林还是要以和为贵,用代码说话。
咱们先看下php、golang版本,运行时间和代码。
版本
➜ hash php -v
PHP 7.3.11 (cli) (built: Apr 17 2020 19:14:14) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.3.11, Copyright (c) 1998-2018 Zend Technologies
➜ hash go version
go version go1.13.14 darwin/amd64
执行时间
➜ hash php hash.php
352.29206085205ms
➜ hash go run hash.go -args 0 f
V1 664.231858ms
php代码
<?php
$startTime = microtime(true);
$arr = array();
for($i=0;$i<1000000;$i++){
$currentTime = time();
$key = $i . "_" .$currentTime;
$arr[$key] = $currentTime;
}
$endTime = microtime(true);
echo ($endTime - $startTime) * 1000 . "ms\r\n";
go第一个版本代码
package main
import (
"fmt"
"os"
"reflect"
"runtime/pprof"
"runtime/trace"
"strconv"
"time"
)
type CalcCollection struct {
}
func (c *CalcCollection) V0() {
arr := map[string]int64{}
for i := int64(0); i < 1000000; i++ {
value := time.Now().Unix()
key := strconv.FormatInt(i, 10) + "_" + strconv.FormatInt(value, 10)
arr[key] = value
}
}
// 以下代码请忽略, 作用如下:
// 根据参数决定调用CalcCollection.V{ver}
// 根据参数决定是否记录trace、profile
func main() {
ver := os.Args[len(os.Args)-2]
isRecord := os.Args[len(os.Args)-1] == "t"
calcReflect := reflect.ValueOf(&CalcCollection{})
methodName := "V"+ver
m := calcReflect.MethodByName(methodName)
if isRecord {
traceFile, err := os.Create(methodName + "_trace.out")
if err != nil {
panic(err.Error())
}
err = trace.Start(traceFile)
if err != nil {
panic("start trace fail :"+ err.Error())
}
defer trace.Stop()
cpuFile, err := os.Create(methodName + "_cpu.out")
if err != nil {
panic(err.Error())
}
defer cpuFile.Close()
err = pprof.StartCPUProfile(cpuFile)
if err != nil {
panic("StartCPUProfile fail :"+ err.Error())
}
defer pprof.StopCPUProfile()
memFile, err := os.Create(methodName + "_mem.out")
if err != nil {
panic(err.Error())
}
defer pprof.WriteHeapProfile(memFile)
}
t := time.Now()
m.Call(make([]reflect.Value , 0))
fmt.Println(methodName, time.Now().Sub(t))
}
来骗、来偷袭
看完代码很go同学肯定很不服气,一眼就看出问题,这位E同学竟然来骗、来偷袭!并且心里已经有很多改写go代码的方案。
我也一样!
接下来我们就采用图文并茂的形式一步一步将go的真实实力释放出来。
分析原始代码
我们先执行命令go run hash.go -args 0 t
,这样可以获得三份文件
- V0_cpu.out : cpu分析文件
- V0_trace.out : 运行时事件分析文件
- V0_mem.out : 内存分析文件
我们分别分析下这几个文件,:
首先是cpu文件 go tool pprof -http :9091 V0_cpu.out
,切到火焰图可以看到数字转字符串、map扩容、字符串拼接、gc耗时比较长:
其次是内存文件 go tool pprof -http :9092 V0_mem.out
, 选择alloc_objects,切到source视图,可以看到申请分配的对象比较多,以及分配位置:
最后我们看下trace文件:
总体trace图,可以看到head分配的对象比较多,main所在的goroutine时不时被调度到其它p上去:
main所在的goroutine,gc pause比较高:
main所在的gorutine,存在多处空白没有执行的时间片:
由于存在分配对象过多的情况,我们顺便看下逃逸分析:
从下图可以看到,字符串拼接的位置发生了逃逸:
所以,我们第一个修改版本会从以下几个方面去提升性能:
- map初始化的时候指定容量,去除grow带来的耗时
- strconv.FormatInt 改为 strconv.AppendInt,减少对象分配
v1版本
基于上一节,我们新增代码如下:
func (c *CalcCollection) V1() {
nums := int64(1000000)
arr := make(map[string]int64,nums)
// key,放循环外,可以重复使用
key := make([]byte,0)
for i := int64(0); i < nums; i++ {
key = key[:0]
value := time.Now().Unix()
// 改用appendInt,去掉strconv内部[]byte转string的开销
key = strconv.AppendInt(key,i,10)
key = append(key,'_')
key = strconv.AppendInt(key, value, 10)
keyStr := string(key)
arr[keyStr] = value
}
}
好,我们再执行下代码 go run hash.go 1 f
,时间已经和php一致了:
➜ hash go run hash.go 1 f
V1 352.412808ms
不过我们仍然要看下,时间花在哪里:
大部分时间都花在内存span申请,以及slice转string了。所以我们第二版本主要做以下两个方面的优化:
- 统一申请key的内存
- 直接将[]byte转为string
v2版本
基于上一节,我们新增代码如下:
func (c *CalcCollection) V2() {
nums := int64(1000000)
arr := make(map[string]int64, nums)
// 计算key长度,申请存下所有key的[]byte
keyLen := int64(len(strconv.FormatInt(nums, 10)) + 1 + 10)
totalLen := keyLen * nums
key := make([]byte, totalLen)
for i := int64(0); i < nums; i++ {
value := time.Now().Unix()
// 计算当前循环key的位置
pos := i * keyLen
b := key[pos:pos]
b = strconv.AppendInt(b, i, 10)
b = append(b, '_')
b = strconv.AppendInt(b, value, 10)
// 直接将[]byte转为string
arr[*(*string)(unsafe.Pointer(&b))] = value
}
}
我们再执行下代码 go run hash.go 2 f
,时间明显优于php了:
➜ hash go run hash.go -args 2 f
V2 320.727972ms
这个时候我们看下火焰图:
时间基本都在strconv.AppendInt上了,接下去的方向,就是采用更合适的数据结构来存数据、自己实现优化版的int转string方法,不过鉴于优化到目前,性能上已经可以接受了,我就不继续了。
优化总结
我们通过分析go代码的cpu、heap、trace文件后,采用了以下优化手段:
- 指定map容量,避免频繁grow
- 使用strconv.AppendInt代替strconv.FormatInt,因为strconv.FormatInt有一次[]byte转string的行为
- 对于频繁申请的内存,统一申请一块大的内存,减少span分配
- 对于[]byte转string,可以通过unsafe.Pointer来替代类型转化,减少内存copy
亲爱的小伙伴,你是否还有其他优化的方案,可以打在公屏上一起交流。
语言比较
我们看到php通过写时复制等优化,降低了开发的复杂度,让开发可以更专注业务的开发,一个array走天下,go开发需要了解go的内存模型,相比php要更为复杂,开发杂音会比较多。
同时,go的性能优化上限高于php,而且这个例子其实是极端的,实际业务开发性能的冲突点大部分都是在于io、池、缓存上,goroutine、pool很好的满足了这部分的性能需求,给关注性能的业务提供了一个高性价比的解决方案。
所以,php和go都是世界上最好的语言。