Golang中如何优化函数性能

Golang中如何优化函数性能 你好,

我有一个函数,希望尽可能提升其性能并减少内存分配。之前它分配了23次,我尽力将其降低到了8次,但我仍然认为这个函数还有改进的空间。如果能得到帮助,我将不胜感激。

谢谢

package logger

import (
	"sort"
	"strconv"
)

type Field map[string]interface{}

func log(line string, fields Field) string {
	keys := make([]string, 0, len(fields))
	for k := range fields {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	for _, k := range keys {
		if val, ok := fields[k].(string); ok {
			line += `,"` + k + `":"` + val + `"`
			continue
		}
		if val, ok := fields[k].(int); ok {
			line += `,"` + k + `":` + strconv.Itoa(val)
			continue
		}
		if val, ok := fields[k].(float64); ok {
			line += `,"` + k + `":` + string(strconv.AppendFloat([]byte(``), val, 'f', -1, 64))
			continue
		}
		if val, ok := fields[k].(bool); ok {
			line += `,"` + k + `":` + strconv.FormatBool(val)
			continue
		}
	}

	return line
}
package logger

import "testing"

func Benchmark_log(b *testing.B) {
	for i := 0; i < b.N; i++ {
		log("hello", Field{"str": "val" , "int": 1, "flo": 1234567890.01234567,"bol": true})
	}
}
$ go test -v -bench=. -benchmem ./logger/
goos: darwin
goarch: amd64
pkg: loog/logger
cpu: Intel(R) Core(TM) i5-5257U CPU @ 2.70GHz
Benchmark_log
Benchmark_log-4   	  924964	      1246 ns/op	     328 B/op	       8 allocs/op
PASS
ok  	loog/logger	2.420s

更多关于Golang中如何优化函数性能的实战教程也可以访问 https://www.itying.com/category-94-b0.html

13 回复

如果你需要按键排序,那么你需要使用切片。关于第二点,这不仅仅是外观上的差异,尝试一下,你可能会发现内存分配更少。至少,你将会利用到针对此场景的内置支持。

更多关于Golang中如何优化函数性能的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


你好!

  1. 既然你知道键的数量,就应该避免使用切片,而应改用数组,这样可以避免多次 append 操作。
  2. 你需要使用一个 strings.Builder(用 line 内容初始化),以避免在反复拼接行时产生大量内存分配。
func main() {
    fmt.Println("hello world")
}
  1. arm64 go19.2 (2 次分配) 对比 amd64 go19.2 (10 次分配)
  2. 我正在为我的应用程序重新设计日志包,因为我不喜欢某些包的接口,或者它们使用的总分配次数。同时,我也在其中引入了一些特定的配置。

@skillian 好主意。不过,这让我思考:显然日志记录并非性能瓶颈,那么为了能使用更好的日志记录算法而改变应用程序的其他部分(数据如何传递给记录器)是否有意义呢?

此外,能够优化事物固然很棒,但能够评估某事物已经非常接近任何可能的最佳状态,这同样很有价值。

感谢您提供的非常有价值的意见。非常感谢。

确实如此。除非迫不得已,我不会在生产环境中使用这段代码。其中的优化和unsafe的使用难以阅读和理解。必须有真正的理由才这样做。

您指的是哪段代码不会在生产环境中使用?是我的代码还是您自己的代码之一?

我从未见过如下所示的代码。请问它是做什么的?

func _() interface {
	sort.Interface
} {
	return (*Field)(nil)
}
  1. 你不需要 keys 切片,移除那部分,直接遍历 map 即可。
  2. 使用类型断言(type switch),而不是一系列 ifcontinue
  3. 你知道最终结果的大小:只需遍历 map 两次,第一次计算最终结果的大小。然后分配一个 strings.Builder,第二次遍历 map 来构建结果。
  4. 返回 strings.Builder.String() 方法。
func main() {
    fmt.Println("hello world")
}

Metalymph:

既然你知道键的数量,就应该避免使用切片,而应优先使用数组,这样可以避免多次 append 操作。

他们已经使用了预分配的切片(make 函数的第三个参数)。

Metalymph:

你必须使用 strings.Builder(用 line 内容初始化)来避免在反复拼接行时产生许多内存分配。

这可能会让情况变得更糟,因为它遵循切片的常规增长规则,并且无法根据更大的容量预估来正确创建。

不过,实现一个类似的功能,确实能够通过预判容量来创建一个“空”的构建器,可能有助于减少内存分配。

@Metalymph

  1. 这是不可能的。数组长度必须是常量,因此 [len(fields)]string 是无效的 :woman_shrugging:
  2. 减少了两次内存分配 :tada:

@telo_tade

  1. 我必须对键进行排序 :-1:
  2. 差异几乎可以忽略不计,几乎没有区别,只是一个表面上的不同 :man_shrugging:

我刚刚意识到,在不同电脑上对同一个函数运行基准测试会产生不同的 allocs/op 结果 😔。一台电脑显示 2 次分配,而另一台显示 10 次!

有没有办法控制这种情况,还是说这基本无法控制?我像这样运行测试:$ go test -v -bench=. -benchmem ./logger

例如,我根据 @skillian 的代码稍微修改了一下,如下所示。

type Field struct {
	Key string
	Val interface{}
}

func (f Field) MarshalJSON() ([]byte, error) {
	return json.Marshal(map[string]interface{}{f.Key: f.Val})
}

func log(fields []Field) string {
	log, _ := json.Marshal(fields)
	return string(log)
}
package logger

import "testing"

func Benchmark_log(b *testing.B) {
	for i := 0; i < b.N; i++ {
		log([]Field{{"str", "val"}})
	}
}
  1. 他们是否运行相同版本的 Go,并且是否运行在相同的平台上(例如 amd64 对比 arm 对比 386 等)?
  2. 你希望减少这个 log 函数中内存分配的动机是什么?我的想法是:
    • 无论你将返回的 string 传递给什么,它很可能都需要分配一些内存来将消息写入文件或数据库等,或者它需要进行 IO 调用,这些操作所花费的时间将远远超过内存分配的时间。
    • 你的测试中记录的是常量,但在实际代码中,你可能记录的是实际值。任何尚未逃逸到堆上的值都需要被“装箱”到 interface{} 中,这将导致额外的内存分配。
    • 像 Go 这类语言的意义在于,除非你真正需要,否则不必费心处理这种微观层面的优化。如果内存分配确实是一个真正的问题,你可能需要用 Rust、C++ 或 C 来重写你的功能,但我很好奇你正在做什么使得这种优化成为必要。

telo_tade:

显然日志记录不是性能瓶颈,那么仅仅为了支持更好的日志算法而改变应用程序的其余部分(数据如何传递给记录器)是否有意义呢?

我并不认为日志记录天生就不是性能瓶颈。有时生产环境中会出现一个棘手的错误,你无法找到复现方法,需要在特定位置逐步引入越来越多的日志记录,直到找到问题所在。如果这种日志记录发生在循环中,减少内存分配可能很重要。

同时,我认为假设日志记录性能瓶颈也是不对的。所以,如果我要编写这个 log 函数,我会这样写:

func log(line string, fields Field) string {
    bs, _ := json.Marshal(fields)
    return fmt.Sprint(line, string(bs))
}

然后,只有在日志记录被证明是瓶颈时,我才会考虑优化它。此外,除非我也测量过,否则我不能确定纯粹为减少分配次数而进行的优化是否重要!

telo_tade:

另外,能够优化事物是很好的,但能够评估某事物已经非常接近任何可能的最佳状态也是很有价值的。

确实如此。除非迫不得已,我不会在生产环境中使用这段代码。这些优化和对 unsafe 的使用难以阅读和理解。需要有真正的理由才这样做。

我这样做只是因为我认为这是一个有趣的问题,而且我找到的解决方案既证明了其可能性,也证明了“代价高昂!”这算是一种“得不偿失的胜利”吗? 🙂

你指的是哪段代码不会在生产环境中使用?是我的还是你的?

我的代码。在我的示例中那样使用 unsafe 包是很脆弱的。我的第一个例子依赖于接口的实现细节,而第一个和第二个例子都依赖于切片和字符串的实现细节。如果这些数据类型的布局将来发生改变,这段代码虽然仍能编译,但在运行时将无法按预期工作。希望它只会立即导致进程崩溃,并抛出一个能直接定位到此代码的 panic,但它也可能写入内存中某个未被检测到的位置,从而造成无法预料的后果。编译器执行逃逸分析是为了提供内存安全性。我的代码破坏了这种可追溯性。

事后看来,我后悔写了它。我当时只专注于我是否能够做到,而没有停下来思考我是否应该这样做。😄

我从未见过下面这样的代码。请问它是做什么用的?

func _() interface {
	sort.Interface
} {
	return (*Field)(nil)
}

这本质上是一个编译时检查,用于验证 *Field 类型是否实现了 sort.Interface。它声明了一个使用“空白”标识符命名的函数(因此无法被调用,从而可以在编译期间被消除),该函数返回一个 interface { sort.Interface }(即 sort.Interface 本身)。如果我希望 *Field 实现其他接口,我可以像这样将它们与 sort.Interface 一起添加:

func _() interface {
	json.Marshaler
	io.Writer
	sort.Interface
} {
	return (*Field)(nil)
}

另一种常见的写法是这样的:

var _ interface {
	sort.Interface
} = (*Field)(nil)

从性能分析来看,这个函数仍有优化空间。主要问题在于字符串拼接和类型转换导致的多次内存分配。以下是优化方案:

package logger

import (
	"sort"
	"strconv"
	"strings"
)

type Field map[string]interface{}

func log(line string, fields Field) string {
	if len(fields) == 0 {
		return line
	}

	keys := make([]string, 0, len(fields))
	for k := range fields {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	var buf strings.Builder
	buf.Grow(len(line) + len(fields)*32) // 预分配足够空间
	buf.WriteString(line)

	for _, k := range keys {
		buf.WriteByte(',')
		buf.WriteByte('"')
		buf.WriteString(k)
		buf.WriteByte('"')
		buf.WriteByte(':')

		switch v := fields[k].(type) {
		case string:
			buf.WriteByte('"')
			buf.WriteString(v)
			buf.WriteByte('"')
		case int:
			buf.WriteString(strconv.Itoa(v))
		case float64:
			buf.Write(strconv.AppendFloat([]byte{}, v, 'f', -1, 64))
		case bool:
			buf.WriteString(strconv.FormatBool(v))
		default:
			// 处理不支持的类型
			buf.WriteString(`null`)
		}
	}

	return buf.String()
}

进一步优化,使用预分配的字节切片:

package logger

import (
	"sort"
	"strconv"
)

type Field map[string]interface{}

func log(line string, fields Field) string {
	if len(fields) == 0 {
		return line
	}

	keys := make([]string, 0, len(fields))
	for k := range fields {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	// 预估缓冲区大小
	estimatedSize := len(line) + len(fields)*32
	buf := make([]byte, 0, estimatedSize)
	buf = append(buf, line...)

	for _, k := range keys {
		buf = append(buf, `,"`...)
		buf = append(buf, k...)
		buf = append(buf, `":`...)

		switch v := fields[k].(type) {
		case string:
			buf = append(buf, '"')
			buf = append(buf, v...)
			buf = append(buf, '"')
		case int:
			buf = strconv.AppendInt(buf, int64(v), 10)
		case float64:
			buf = strconv.AppendFloat(buf, v, 'f', -1, 64)
		case bool:
			buf = strconv.AppendBool(buf, v)
		default:
			buf = append(buf, "null"...)
		}
	}

	return string(buf)
}

使用sync.Pool减少keys切片的分配:

package logger

import (
	"sort"
	"strconv"
	"sync"
)

type Field map[string]interface{}

var keysPool = sync.Pool{
	New: func() interface{} {
		return make([]string, 0, 16)
	},
}

func log(line string, fields Field) string {
	if len(fields) == 0 {
		return line
	}

	keys := keysPool.Get().([]string)
	keys = keys[:0]
	keys = ensureCapacity(keys, len(fields))

	for k := range fields {
		keys = append(keys, k)
	}
	sort.Strings(keys)

	estimatedSize := len(line) + len(fields)*32
	buf := make([]byte, 0, estimatedSize)
	buf = append(buf, line...)

	for _, k := range keys {
		buf = append(buf, `,"`...)
		buf = append(buf, k...)
		buf = append(buf, `":`...)

		switch v := fields[k].(type) {
		case string:
			buf = append(buf, '"')
			buf = append(buf, v...)
			buf = append(buf, '"')
		case int:
			buf = strconv.AppendInt(buf, int64(v), 10)
		case float64:
			buf = strconv.AppendFloat(buf, v, 'f', -1, 64)
		case bool:
			buf = strconv.AppendBool(buf, v)
		default:
			buf = append(buf, "null"...)
		}
	}

	keysPool.Put(keys)
	return string(buf)
}

func ensureCapacity(slice []string, capacity int) []string {
	if cap(slice) < capacity {
		return make([]string, 0, capacity)
	}
	return slice
}

这些优化通过以下方式减少内存分配:

  1. 使用strings.Builder或预分配的字节切片替代字符串拼接
  2. 使用类型switch替代多次类型断言
  3. 预分配足够容量的缓冲区
  4. 使用sync.Pool复用keys切片

优化后的版本应该能将分配次数从8次降低到2-3次,并显著提升性能。

回到顶部