«Каждый, уважающий себя программист, осваивая новый язык пишет свой логгер» (с) давно было, источник цитаты канул в Лету, в общем — забылся.

Собственно вся история вопроса началась тут. Когда-то, около 3 лет назад, осваивая новый для себя язык, тоже написал свой логгер. Он писался подглядыванием в стандартный логгер, и первое что мне захотелось изменить в нем — это устранить буфер сообщений из структуры самого логера. Это мне показалось тогда нормальным, логгер становится реентерабельным и стало можно пробрасывать один и тот же логгер в несколько горутин, обрамив мьютексом только сам процесс вывода.

Это уменьшает фактическое время блокировок, а значит «хорошо и правильно». :)

Позже, логгер был заброшен, в боевых проектах применялся в основном zap и кое-где попадался logrus. Судя по бенчмаркам, zap был шустр, и в общем-то на этом можно было бы статью и закончить .. но, в один прекрасный момент меня посетила мысль: «а не завести ли свой гитхаб и выложить в него что-нибудь хорошее?» :)

И тут собственно возникло неожиданное продолжение, т. к. выкладывать на гитхаб, писанное на коленке и в начале пути не комильфо, надо бы подчистить и улучшить. Подчистки и улучшения конечно же сопровождались покрытием тестами и бенчмарками. И вот тут, для себя, сделал «открытие», что в Golang всё не совсем так, как это представляется с высоты 40+ лет программирования и опыта писания собственных (Ре)Ассемблеров, компиляторов и пр.. И так.

Для статьи был взят стандартный, библиотечный логгер из пакета log, и доработан в части накопления данных по времени исполнения и аллокациям, перенеся пакет в тестируемый код. Дополнение:

const MaxRepeats = 1000            // for tests into main() only
const MaxPoints = MaxRepeats*2 + 4 // small more that need

// StatItem -- one record for test statistics
type StatItem struct {
	Mallocs     uint64
	Frees       uint64
	HeapObjects uint64
	Moment      time.Time
}

// A Logger represents an active logging object that generates lines of
// output to an io.Writer. Each logging operation makes a single call to
// the Writer's Write method. A Logger can be used simultaneously from
// multiple goroutines; it guarantees to serialize access to the Writer.
type Logger struct {
	mu        sync.Mutex // ensures atomic writes; protects the following fields
	prefix    string     // prefix on each line to identify the logger (but see Lmsgprefix)
	flag      int        // properties
	out       io.Writer  // destination for output
	buf       []byte     // for accumulating text to write
	isDiscard int32      // atomic boolean: whether out == io.Discard
	// Profiling memory usage for tests
	MStat  runtime.MemStats
	Points [MaxPoints]StatItem // array! Allocating may be by compiler
	Len    int
}

func (l *Logger) AddPoint() {
	if l.Len < MaxPoints {
		runtime.ReadMemStats(&l.MStat)
		l.Points[l.Len].Mallocs = l.MStat.Mallocs
		l.Points[l.Len].Frees = l.MStat.Frees
		l.Points[l.Len].HeapObjects = l.MStat.HeapObjects
		l.Points[l.Len].Moment = time.Now()
		l.Len++
		// for go benchmarks only:
		if l.Len > MaxPoints {
			l.Len = 0
		}
	}
}

Ничего сверхестественного, добавляем структуру сохранения и метод для снятия данных в заданной точке. Пытаемся избежать аллокаций и в логгер добавляем массив заранее определенного размера, известного на стадии компиляции. Как-бы любой «уважающий себя компилятор» выделит память под логгер и массив одним запросом.. :)

Далее, делаем ручной тест:

package main

import (
	"bytes"
	"memtest/logger"
	"os"
)

// Global vars. Waiting this will be allocated by compiler
var outbuf [1024]byte                       // array! allocating may be by compiler
var outWriter = bytes.NewBuffer(outbuf[:0]) // create slice from array and generate new Writer may be by compiler too

func main() {
	var stdLog = stdlogger.New(outWriter, "", 0)
	stdLog.AddPoint() // [0]
	for i := 0; i < stdlogger.MaxRepeats; i++ {
		outWriter.Reset()
		stdLog.AddPoint()
		stdLog.Print("This a simple message")
		stdLog.AddPoint() // [2] for first cycle!
	}
	stdLog.AddPoint()

	// clear out flags and switch for see
	stdLog.SetFlags(0)
	stdLog.SetOutput(os.Stdout)
	for i := 0; i < stdLog.Len; i++ {
		//stdLog.Printf("\n%v", stdLog.Points[i])
	}

	stdLog.Printf("Total time=%d ns", stdLog.Points[stdLog.Len-].Moment.Sub(stdLog.Points[0].Moment).Nanoseconds())
	stdLog.Printf("Total allocates=%d, frees=%d, heap objects=%d",
		stdLog.Points[stdLog.Len-1].Mallocs-stdLog.Points[0].Mallocs,
		stdLog.Points[stdLog.Len-1].Frees-stdLog.Points[0].Frees,
		stdLog.Points[stdLog.Len-1].HeapObjects-stdLog.Points[0].HeapObjects, 
	)
	stdLog.Printf("\nAvg time=%d ns/op", stdLog.Points[stdLog.Len-1].Moment.Sub(stdLog.Points[0].Moment).Nanoseconds()/stdlogger.MaxRepeats)
	stdLog.Printf("Avg allocates=%d, frees=%d, objects=%d /op",
		(stdLog.Points[stdLog.Len-1].Mallocs-stdLog.Points[0].Mallocs)/stdlogger.MaxRepeats,
		(stdLog.Points[stdLog.Len-1].Frees-stdLog.Points[0].Frees)/stdlogger.MaxRepeats,
		(stdLog.Points[stdLog.Len-1].HeapObjects-stdLog.Points[0].HeapObjects)/stdlogger.MaxRepeats,
	)
	stdLog.Printf("\nFirst run time=%d ns", stdLog.Points[2].Moment.Sub(stdLog.Points[0].Moment).Nanoseconds())
	stdLog.Printf("First run allocated=%d, frees=%d, objects=%d",
		stdLog.Points[2].Mallocs-stdLog.Points[0].Mallocs,
		stdLog.Points[2].Frees-stdLog.Points[0].Frees,
		stdLog.Points[2].HeapObjects-stdLog.Points[0].HeapObjects,
	)
}

Запускаем .. и получаем нечто странное:

/tmp/GoLand/___go_build_memtest

Total time=12754376 ns

Total allocates=1005, frees=0, heap objects=1005

Avg time=12754 ns/op

Avg allocates=1, frees=0, objects=1 /op

First run time=60205 ns

First run allocated=6, frees=0, objects=6


Всего аллоцировано 1005 объектов в куче! Количество проходов = 1000 и за первый проход аллоцировано 6 объектов. Одна аллокация в цикле тестирования и первом проходе понятна: там выводится строка, под которую выделяется память внутри fmt.Sprintf(), что нормально.

Откуда 5 аллокаций при первом проходе?

Хорошо, пишем бенчмарк:

package stdlogger
import (
	"bytes"
	"testing"
)
var outbuf [1024]byte // array! allocating may be by compiler
var outWriter = bytes.NewBuffer(outbuf[:0]) // create slice from array and generate new Writer may be by compiler too
func Benchmark_stdLog(b *testing.B) {
	var stdLog = New(outWriter, "", 0)
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		outWriter.Reset()
		stdLog.AddPoint()
		stdLog.Print("This a simple message")
		stdLog.AddPoint()
	}
}

, запускаем командой go test -bench=. -benchmem -cpu=1, чтобы не смотреть во сколько потоков запускает тест и .. получаем ответ:

goos: linux

goarch: amd64

pkg: memtest/stdlogger

cpu: Intel(R) Core(TM) i5-9400F CPU @ 2.90GHz

Benchmark_stdLog 8744750 134.3 ns/op 24 B/op 1 allocs/op

.. просто удивительная разница по времени исполнения! А ведь в первом и втором цикле вызывается ровно один и тот же код, и массив точек съема данных для бенчмарка закольцован, а цикл в main() не выходит за его границу. Ну ок, давайте запустим тест с явным указанием повторов:

а) командой: go test -bench=. -benchmem -cpu=1 -benchtime=1x -count=1000

goos: linux

goarch: amd64

pkg: memtest/stdlogger

cpu: Intel(R) Core(TM) i5-9400F CPU @ 2.90GHz

Benchmark_stdLog 1 11981 ns/op 416 B/op 6 allocs/op

… тут ещё 998 строк примерно как ниже:

Benchmark_stdLog 1 7366 ns/op 184 B/op 4 allocs/op

б) командой: go test -bench=. -benchmem -cpu=1 -benchtime=1000x -count=1

goos: linux

goarch: amd64

pkg: memtest/stdlogger

cpu: Intel(R) Core(TM) i5-9400F CPU @ 2.90GHz

Benchmark_stdLog 1000 2334 ns/op 24 B/op 1 allocs/op

Как всё интересно-то! Параметр -benchtime – в нашем случае это количество повторов, передаваемое в цикл b.N. То есть, при N=1 мы получаем 1000 перезапусков теста и оценку скорости исполнения «в среднем», но по 1 повтору кода. И тут внезапно обнаруживаем, что «в среднем» у нас не 6 аллокаций, а .. только 4. То есть пара объектов сохраняет свое время жизни между вызовами тестируемой функции несмотря на всю локальность объявлений..

Второй вариант дает нам 1000 повторов цикла при однократном запуске теста и тут однократность первых аллокаций вполне штатно показывает на одно размещение внутри цикла. Ну, мы же помним про fmt.Sprintf(), ничего удивительного, кроме .. времени исполнения. Давайте увеличим количество повторов:

go test -bench=. -benchmem -cpu=1 -benchtime=100000x -count=1

goos: linux

goarch: amd64

pkg: memtest/stdlogger

cpu: Intel(R) Core(TM) i5-9400F CPU @ 2.90GHz

Benchmark_stdLog 100000 153.9 ns/op 24 B/op 1 allocs/op

Ну вот, всё ожидаемо.

А теперь вопрос на засыпку: почему бенчмарк в пункте «б» исполнившись в точности также 1000 раз за 1 проход показал иное время исполнения 2334 наносекунды на цикл, в то время как тест в main() выдал почти в 6 раз большее значение: Avg time=12754 ns/op?

А давайте увеличим константу const MaxRepeats = 10000
и прогоним main() ещё раз:

Total time=124476321 ns

Total allocates=10005, frees=0, heap objects=10005

Avg time=12447 ns/op

Avg allocates=1, frees=0, objects=1 /op

First run time=132694 ns

First run allocated=6, frees=0, objects=6

, странно не правда ли? Среднее время исполнения в main() практически не изменилось! То есть нечто крайне интересное происходит при тестировании go test -bench=…

Заглядываем в код benchmark.go и .. видим, что принципиальных отличий нет, кроме блокировки мьютексом исполняемого кода (даже понятно зачем) и ручного запуска runtime.GC() перед началом цикла тестовой функции. Ровно тот же самый вызов runtime.ReadMemStats(&memStats) для подсчета аллокаций до и после запуска тестируемой функции, тот же вызов time.Now() для подсчета времени исполнения..

Ну, ок. Давайте вызовем тоже GC предварительно в нашем main(), а ещё дополним вывод количества сохраненных точек и размер массива для контроля .. и получим:

Len=20002, cap=20004

Total time=125534130 ns

Total allocates=10005, frees=0, heap objects=10005

Avg time=12553 ns/op

Avg allocates=1, frees=0, objects=1 /op

First run time=55850 ns

First run allocated=6, frees=0, objects=6

Ничего революционного однако не произошло.. как было 12.5 микросекунд на цикл исполнения так примерно и осталось. Как было 5 дополнительных аллокаций, так и осталось. Как-бы уже возникают вопросы к скорости исполнения, которые показывает бенчмарк, ибо отличие в 6 раз должно быть как-то объяснено.

Вопрос на засыпку 2: А что тогда измеряет benchmark? :)

Ещё один момент:

В тесте main() логгер создается локально, что требует выделения памяти или на стеке или в куче. Хорошо. Давайте перенесем объявление логгера в статический глобал. Тут уже, компилятор точно знает что это одна такая переменная, размер занимаемой памяти фиксирован и известен на стадии компиляции и ее можно выделить статически:

var stdLog = stdlogger.Logger{
	MStat:  runtime.MemStats{},
	Points: [20004]stdlogger.StatItem{},
	Len:    0,
}
func main() {
	stdlogger.SetOutput(outWriter)
 	stdlogger.SetFlags(0) 	stdlogger.SetPrefix("")
	// .. далее по тексту выше

, запускаем main() .. и получаем:

/tmp/GoLand/___go_build_memtest

panic: runtime error: invalid memory address or nil pointer dereference

[signal SIGSEGV: segmentation violation code=0x1 addr=0x18 pc=0x48cad6]

goroutine 1 [running]:

memtest/stdlogger.(*Logger).Output(0x56e240, 0x1?, {0xc00001c1b0, 0x15})

~/myProjectsGo/memtest/stdlogger/stdlog.go:237 +0x356

memtest/stdlogger.(*Logger).Print(0x56e240, {0xc00009af10?, 0xc0000a0150?, 0x7ff4d8554bb8?})

~/myProjectsGo/memtest/stdlogger/stdlog.go:256 +0x5a

main.main()

~/myProjectsGo/memtest/main.go:30 +0xdd

Ух-ты! Паника! Забавно, что она возникает там, где и должна возникать в коде этого логера, т. к. внутренний буфер l.buf []byte не инициализируется ни в самом коде логера NEW() ни тут в нашем тестовом коде. Однако, динамически выделенная память под логгер функцией New() работает штатно, а вот объявление глобальной переменной — нет.

Забавный фокус Go, о котором хорошо осведомлены авторы пакета:

var std = New(os.Stderr, "", LstdFlags)

Стандартный логгер — также глобален, но выделяет память динамически, тоже вызывая метод log.New().. Каким волшебным образом, динамически создаваемая структура методом New(), без явной инициализации слайса, тем не менее, нормально аллоцирует память далее в коде исполнения, в то время как та же самая структура, но объявленная по сути статически, уже такого не делает?

Откуда все-таки берутся аллокации там, где компилятор точно знает размер и способен выделять память самостоятельно? А так ли это? Давайте проверим, и выведем пустую строку:

func main() {
	stdLog := stdlogger.New(outWriter, "", 0)
   	runtime.GC()
	stdLog.AddPoint() // [0]
	for i := 0; i < stdlogger.MaxRepeats; i++ {
		outWriter.Reset()
		stdLog.AddPoint()
		stdLog.Print("")
		stdLog.AddPoint() // [2] for first cycle!
	}
	stdLog.AddPoint()

, запускаем main() и получаем:

Len=20002, cap=20004

Total time=121876995 ns

Total allocates=4, frees=0, heap objects=4

Avg time=12187 ns/op

Avg allocates=0, frees=0, objects=0 /op

First run time=17479 ns

First run allocated=4, frees=0, objects=4

Во-от. Fmt.Sprintf() не аллоцировал память, что видно по средним показателям, но первый вызов цикла все равно аллоцировал 4 объекта в куче. Забавно, что теперь, вернувшись к примеру из самого начала (такое же локальное создание логгера в начале main()) получили существенно меньшее время исполнения первого прохода.. это как? :)

Путем манипуляций с пакетом, можно убедиться что поле логгера buf []byte на самом деле под капотом имеет 2 аллокации: память под сам массив + память под структуру слайса. (сколько аллокаций делает make(map[...]...) можно догадаться, глянув на структуру мапы под капотом). Кроме того строчка l.buf = l.buf[:0] на самом деле создает новый слайс и сохраняет его в старом месте, а не обнуляет, как можно наивно подумать. Ну и кроме этого, передача результата fmt.Sprintf() в параметр l.Output() также под капотом может содержать аллокацию, т. к. строка в Go это структура из указателя и размера строки.

Но, если это так, то почему аллокация в этих случаях делается один раз и только при первом проходе?

В целом, вопросов возникло много, буду признателен тем, кто хорошо разобрался в том, что творится под капотом Go. Пока ясно одно: внутренняя аллокация памяти «на стеке» и «в куче» не имеет принципиальной разницы, что и указано авторами, где-то в Faq по модели памяти Go. И то и другое — есть внутренний механизм, и по сути одинаковый.

В данном обзоре сознательно не полез в Ассемблер, хотя и догадываюсь что все ответы можно найти там, но, в этом случае, текст статьи получился бы исключительно для «профи», которым это всё известно и так.

Спасибо, если дочитали до конца. Это моя первая статья в жизни. :)

Комментарии (0)