Как стать автором
Обновить

Тестирование исполняемого кода Go

Уровень сложностиСложный
Время на прочтение9 мин
Количество просмотров4.1K

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

Собственно вся история вопроса началась тут. Когда-то, около 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. И то и другое — есть внутренний механизм, и по сути одинаковый.

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

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

Теги:
Хабы:
Всего голосов 10: ↑7 и ↓3+6
Комментарии0

Публикации

Истории

Работа

Go разработчик
166 вакансий

Ближайшие события

27 августа – 7 октября
Премия digital-кейсов «Проксима»
МоскваОнлайн
28 сентября – 5 октября
О! Хакатон
Онлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн