什么?GO竟然比PHP慢2倍!

311 阅读5分钟

这是一篇优化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耗时比较长

"v0版本cpu火焰图"

其次是内存文件 go tool pprof -http :9092 V0_mem.out, 选择alloc_objects,切到source视图,可以看到申请分配的对象比较多,以及分配位置:

"v0版本mem源代码"

最后我们看下trace文件:

总体trace图,可以看到head分配的对象比较多,main所在的goroutine时不时被调度到其它p上去: "v0版本trace图"

main所在的goroutine,gc pause比较高"v0版本main信息图"

main所在的gorutine,存在多处空白没有执行的时间片: "v0版本main trace图"

由于存在分配对象过多的情况,我们顺便看下逃逸分析:

从下图可以看到,字符串拼接的位置发生了逃逸"v0逃逸分析"

所以,我们第一个修改版本会从以下几个方面去提升性能:

  • 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

不过我们仍然要看下,时间花在哪里:

"v1版本火焰图"

大部分时间都花在内存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

这个时候我们看下火焰图: "v2版本火焰图"

时间基本都在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都是世界上最好的语言。

原文地址