首页
Preview

如何制作零内存分配的 Golang 日志库

简介

Go的日志库zerologzap在使用时不会有任何内存分配(取决于用法)。因此,我想描述如何创建零分配日志库以及如何使Go源零分配。

什么是零分配?

内存分配是指程序运行所需的内存分配,包括栈区(称为静态分配)和堆区(称为动态分配或堆分配)。

在栈区分配内存时,分配大小和分配/释放的时间在编写程序时就已经静态确定。

另一方面,在堆区分配时,可以根据程序执行时的情况进行分配,而无需在声明时指定最大内存分配大小,并且内存由垃圾收集器释放。因此,性能低于栈区。

这里的零分配意味着Go基准测试中的allocs/op为零。allocs/op是基准测试中每个操作的内存分配量,要减少它,就必须减少对堆区的动态内存分配。

Go在编译时使用称为逃逸分析的技术来选择是分配到栈区还是分配到堆区。

如何检查内存分配

有三种方法可以使用Go检查应用程序的内存分配

你可以通过go test -bench获得基准测试的结果。58056385是操作的总执行次数,ns/op是每个操作的执行时间,B/op是每个操作的内存消耗,allocs/op是每个操作的内存分配量。

$ go test -bench .
BenchmarkEvent-16  58056385  21.30 ns/op  8 B/op  1 allocs/op

你可以使用go build -gcflags'-m'来获取逃逸分析的结果并查看分配发生的位置。

$ go build -gcflags '-m' .
# command-line-arguments
    :
./event.go:68:7: &event{...} escapes to heap
./event.go:68:23: make([]byte, 0, 500) escapes to heap

你还可以多次堆叠-m-gcflags,例如go build -gcflags'-m -m',以获取更详细的信息。

创建零分配日志库

让我们以一个简单的日志库为例,看看如何使Go不将内存分配到堆区。

Go日志库的最小规格

  • 根据级别进行操作

  • 控制输出目标

  • 对于致命级别,在输出后执行os.Exit(1)

  1. 允许你设置输出方法
  • 标准输出
  • 文件输出
  • 发送到远程
  1. 考虑从外部通过goroutine执行

设计

  • 使接受外部数据输出的接口尽可能简单

  • 例如,输出Info日志的方法将在执行名为Info的一个func时完成写入。

  • 单独预先设置日志库的初始设置。

  • 即使由goroutine调用,如果由一个func完成,它将不处于竞态状态,也没有问题。

  1. 收集日志事件信息,例如输出数据在struct中

基于上述,通常使用提供在日志库内部保留与日志输出内容相关的信息的struct,并将其传递给输出过程。

简单的例子

以下源代码是基于上述最简单的例子。

// LogWriter 
var LogWriter io.Writer// event
type event struct {
	buf   []byte         // log message
	level Level          // log level
	done  func(b []byte) // after writing (for Panic, Fatal)
}func (e event) write() {
	if e.done != nil {
		defer e.done(e.buf)
	}
	LogWriter.Write(e.buf)
}func newEvent(buf []byte, level Level, done func(b []byte)) *event {
	return &event{
		buf:   buf,
		level: level,
		done:  done,
	}
}// Info writes Info level log.
func Info(msg string) {
	e := newEvent([]byte(msg), InfoLevel, nil)
	e.write()
}// Fatal writes Fatal level log.
func Fatal(err error) {
	e := newEvent([]byte(err.Error()), FatalLevel, fatalFunc)
	e.write()
}func fatalFunc(b []byte) { os.Exit(1) }

现在让我们在基准测试中使用它时检查内存分配情况。

func BenchmarkEvent(b *testing.B) {
	LogWriter = testWriter
	b.ReportAllocs()
	b.ResetTimer()	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Info("test")
		}
	})
}

从基准测试的结果中可以看出,内存分配正在发生。此外,如果使用-gcflags'-m'进行构建,则可以看到在创建事件实例时,newEvent func将其分配给堆内存。

$ go test -bench .
BenchmarkEvent-16   58056385   21.30 ns/op   8 B/op   1 allocs/op$ go build -gcflags '-m' event.go
    :
./event.go:42:9: &event{...} escapes to heap

使用sync.Pool减少动态分配

使用sync.Pool尽可能多地重用event.buf。

var eventPool = &sync.Pool{
	New: func() interface{} {
		return &event{
			buf: make([]byte, 0, 500),
		}
	},
}func newEvent(buf []byte, level Level, done func(b []byte)) *event {
	e := eventPool.Get().(*event)
	e.buf = e.buf[:0]
	e.buf = append(e.buf, buf...)
	e.level = level
	e.done = nil
	if done != nil {
		e.done = done
	}
	return e
}func putEvent(e *event) {
	eventPool.Put(e)
}// Info writes Info level log.
func Info(msg string) {
	e := newEvent([]byte(msg), InfoLevel, nil)
	e.write()
	putEvent(e)
}// Fatal writes Fatal level log.
func Fatal(msg string) {
	e := newEvent([]byte(err.Error()), FatalLevel, fatalFunc)
	e.write()
	putEvent(e)
}

与上面的示例相比,此处更改的是使用sync.Pool获取和放置event struct,并且将设置为未分配的buf值附加到设置为零的event buf字段中。

  • 使用sync.Pool获取和放置event struct
  • 在将事件的buf字段设置为零而未分配时附加 首先,定义sync.Pool以池化事件实例。在此处,New func定义中将buf字段的初始容量设置为500字节。
var eventPool = &sync.Pool{
	New: func() interface{} {
		return &event{
			buf: make([]byte, 0, 500),
		}
	},
}

使用newEvent func从sync.Pool获取事件实例,将获取的event.buf的长度设置为0,然后将buf的内容附加到其中。

func newEvent(buf []byte, level Level, done func(b []byte)) *event {
	e := eventPool.Get().(*event)
	e.buf = e.buf[:0]
	e.buf = append(e.buf, buf...)

在执行write()后执行eventPool.Put。

func Info(msg string) {
	e := newEvent([]byte(msg), InfoLevel, nil)
	e.write()
	putEvent(e)     // Internally eventPool.Put(e)

因此,长度在500字节以内的消息可以继续输出日志而不会被新分配。

如果运行与之前相同的基准测试,则可以看到每个操作的分配为零。

BenchmarkEvent-16   54997969   19.17 ns/op   0 B/op   0 allocs/op

严格来说,已发生分配。从逃逸分析的结果来看,可以看到将其逃逸到堆区域的结果如下所示。

$ go build -gcflags '-m' event.go
    :
./event.go:53:10: &event{...} escapes to heap
./event.go:54:13: make([]byte, 0, 500) escapes to heap

但是,这两个都是指sync.Pool的New func中的处理(事件指针生成和byte slice生成)。通过由sync.Pool重复使用,可以看到在操作单位中几乎变为零,这可以在基准测试中看到。

在zerolog和zap中

外部访问接口的配置(LogWriter的各种设置以及在每个级别上写入日志事件的规范等)在上述日志库和zerolog,zap之间有所不同。特别是在zerolog中,使用方法链使用日志事件struct。但是,对于日志事件struct,使用sync.Pool是相同的,无论是zerolog还是zap。

zerolog

zap

上面是一个非常简单的例子,但实际上,可能会有你想要在事件结构字段中有时间信息或其他信息而不仅仅是输出消息的情况。

但是,请注意,使用sync.Pool并不会导致所有程序都零分配。

不要使用指针

  • 指针变量的新声明的存在可能会导致动态分配
  • 因此,在此日志库示例中,事件指针被定位为sync.Pool并被重用。
  • 事件结构没有指针字段

使用切片

  • 动态分配发生在初始化切片的时机
  • 在向切片添加内容时,如果超过容量,则会发生新的分配。
  • 即使将新的切片类型变量分配给切片类型变量,也会发生新的分配。
  • 如果可能的话,使用数组(固定数量的元素)更好。

当重复使用切片时,最好像eventPool示例中一样使用sync.Pool的New函数设置容量,从sync.Pool获取,将切片长度设置为0,然后进行追加。

使用匿名函数也会导致动态分配

在定义和使用匿名函数时会发生动态分配。我认为在每次使用时避免使用它更好。

如果在Fatal中使用匿名函数如下所示,则在逃逸分析中将出现func字面量逃逸到堆的情况。

func Fatal(err error) {
	e := newEvent(
		[]byte(err.Error()), 
		FatalLevel, 
		func(b []byte) { os.Exit(1) },
	)
	e.write()
}# result of go build -gcflags '-m'
./event.go:99:3: func literal escapes to heap

time.Time在内部使用指针

位置(时区信息)在该字段中具有指针。

当你定义和使用先前未定义的新位置(不是UTC、Local)时,将发生新的动态分配。

tm = time.Time{} // no allocationtm = time.Now().In(time.UTC) // no allocationutc, _ := time.LoadLocation("UTC") // no allocation
tm = time.Now().In(utc) // no allocationlocal, _ := time.LoadLocation("Local") // no allocation
tm = time.Now().In(local) // no allocationtm = time.Now().In(time.Local) // no allocationjst, _ := time.LoadLocation("Asia/Tokyo") // allocation occurs
tm = time.Now().In(jst)

建议在每次执行时提前定义JST的位置并将其替换,而不是执行time.LoadLocation("Asia / Tokyo ")。(最好事先将其分配给time.Local)

还有其他方法如下

接口

我还想提到接口的使用。虽然它不是日志库所必需的,但我验证了上面的代码故意被处理为如下所示的eventInterface接口的情况。

type eventInterface interface {
	write()
}func newEventInterface(buf []byte, level Level, done func(b []byte)) eventInterface {
	return newEvent(buf, level, done) // sync.PoolGet
}func putEventInterface(e eventInterface) {
	if _, ok := e.(*event); !ok {
		panic("invalid type")
	}
	eventPool.Put(e)
}func Info(msg string) {
	e := newEventInterface([]byte(msg), InfoLevel, nil)
	e.write()
	putEventInterface(e)
}

这是在go1.17、go1.18环境下进行的基准测试的结果。BenchmarkEvent-16是仅使用事件结构的一个。BenchmarkEventWithInterface-16是使用接口的一个。结果没有发现任何分配。

BenchmarkEvent-16                63091771   17.45 ns/op   0 B/op   0 allocs/op
BenchmarkEventWithInterface-16   61353092   17.18 ns/op   0 B/op   0 allocs/op

此外,即使在不使用sync.Pool的情况下,panic的转换参数的分配也会发生,因此我看不到接口使用本身的分配增加。

Go版本会导致内存分配变化

Go分配规则未在语言规范中指定。由于Go版本的编译器修改,分配将发生变化。

我们比较了上面的接口示例,包括未使用sync.Pool的情况。

如果不使用sync.Pool使用接口,则可以看到分配增加,但值得注意的是,分配还取决于Go的版本。

此外,如果查看未使用sync.Pool的逃逸分析,你会发现结果取决于Go的版本。

# go 1.17
./interface.go:8:17: &event{...} escapes to heap
./interface.go:13:31: ([]byte)(msg) escapes to heap# go 1.13
./interface.go:8:17: newEvent(buf, level, done) escapes to heap
./interface.go:8:17: &event literal escapes to heap
./interface.go:13:31: ([]byte)(msg) escapes to heap
./interface.go:13:24: newEvent(buf, level, done) escapes to heap
./interface.go:13:24: &event literal escapes to heap

总结

  • 将日志事件信息收集到结构中,并将其放置/获取到sync.Pool中以供使用
  • 使用切片类型时,请在sync.Pool的New函数中设置特定容量并创建它。在获取后将长度设置为0,然后进行追加和重用
  1. 除了存储在sync.Pool中的存储之外,不要使用指针。结构中也不提供指针字段。

  2. 避免在每次操作中使用匿名函数

  3. time.Time在时区信息中使用指针。因此,你需要以下之一:

  • 在使用日志之前定义时区以外的时区
  • 转换为UnixTime整数或[]byte
  1. 接口本身的使用不显示动态分配,但实现使用了sync.Pool

  2. 内存分配根据Go版本而异

除了日志库的用法外,我认为它可以用于在过程中创建和销毁大量元素的另一个系统。

但是,除非你了解编译器的内容,否则很难编写避免动态分配的源代码,因为只要进行逃逸分析就可能会发生动态分配,具体取决于代码编写方式。

此外,如上所述,Go的分配规则未在语言规范中定义。

本文基于zap、zerolog和在go 1.17和go 1.18环境中实现的的结果。我希望你将其作为一个成就参考。最重要的是实际检查你应用程序的分配情况。

译自:https://tech.speee.jp/entry/2022/07/12/134605

版权声明:本文内容由TeHub注册用户自发贡献,版权归原作者所有,TeHub社区不拥有其著作权,亦不承担相应法律责任。 如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

点赞(0)
收藏(0)
菜鸟一只
你就是个黄焖鸡,又黄又闷又垃圾。

评论(0)

添加评论