Deep Engineering

MEASUREMENT

bench/godefer/internals.go

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/golang/defer-panic-recover

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

This measurement has no recorded run — only the script.

Script

300 lines
//go:build ignore

// defer, panic и recover изнутри: три вида defer, цена паники и то, чего
// recover не ловит.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. Про `defer` ходят два противоположных суеверия — «он
// дорогой, не используйте в горячем коде» и «он бесплатный, с Go 1.14». Оба
// неверны, потому что `defer` компилируется тремя разными способами, и цена
// отличается на порядок. Различить их на глаз нельзя: в исходнике это одно и
// то же слово.
//
// Про `recover` суеверие одно и опаснее: «он ловит паники». Ловит он ровно
// одну — ту, что летит в ТЕКУЩЕЙ горутине через ТЕКУЩИЙ отложенный вызов.
// Всё остальное проходит мимо, и проверяется это только запуском.
//
// ЗАПУСК:
//
//	go run bench/godefer/internals.go
package main

import (
	"errors"
	"fmt"
	"runtime"
	"testing"
)

// ------------------------------------------------------- блок 1: три вида

var sink int

// openCoded — фиксированное число defer в функции без циклов. С Go 1.14
// компилятор разворачивает такой defer в обычный код на выходе из функции:
// ни списка, ни выделения памяти.
func openCoded() {
	defer func() { sink++ }()
	sink++
}

// inLoop — defer внутри цикла. Число отложенных вызовов заранее неизвестно,
// поэтому открытый код неприменим: каждый вызов приходится РЕГИСТРИРОВАТЬ в
// списке отложенных вызовов горутины.
//
// ВАЖНО ПРО ПАМЯТЬ. Первая редакция этого файла утверждала, что запись при
// этом попадает в кучу. Замер ниже показал ноль выделений, и утверждение
// пришлось убрать: запись кладётся на стек (`deferprocStack`). Дорожает не
// выделение, а сама регистрация и последующий обход списка.
func inLoop(n int) {
	for i := 0; i < n; i++ {
		defer func() { sink++ }()
	}
	sink++
}

// conditional — defer под условием: тоже открытый код, пока функция без цикла.
func conditional(flag bool) {
	if flag {
		defer func() { sink++ }()
	}
	sink++
}

func kinds() {
	fmt.Println("ТРИ ВИДА DEFER: ОДНО СЛОВО, РАЗНАЯ ЦЕНА")
	fmt.Println("───────────────────────────────────────")

	a1 := testing.AllocsPerRun(1000, func() { openCoded() })
	a2 := testing.AllocsPerRun(1000, func() { conditional(true) })
	a3 := testing.AllocsPerRun(1000, func() { inLoop(8) })

	fmt.Printf("  один defer в функции          выделений: %.0f\n", a1)
	fmt.Printf("  defer под условием            выделений: %.0f\n", a2)
	fmt.Printf("  defer в цикле, 8 витков       выделений: %.0f\n", a3)
	fmt.Println()

	one := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			openCoded()
		}
	})
	loop8 := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			inLoop(8)
		}
	})
	loop64 := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			inLoop(64)
		}
	})

	fmt.Printf("  один defer в функции          %8.2f нс\n", one)
	fmt.Printf("  defer в цикле, 8 витков       %8.2f нс  (%.1f нс на виток)\n", loop8, loop8/8)
	fmt.Printf("  defer в цикле, 64 витка       %8.2f нс  (%.1f нс на виток)\n", loop64, loop64/64)
	fmt.Println()
	fmt.Println("  Выделений в куче нет НИ В ОДНОМ случае — это стоит сказать")
	fmt.Println("  прямо, потому что «defer в цикле выделяет память» повторяют")
	fmt.Println("  часто. Запись об отложенном вызове кладётся на стек.")
	fmt.Println()
	fmt.Println("  Первая строка по времени — тот самый «бесплатный» defer:")
	fmt.Println("  компилятор разворачивает его в обычный код на выходе из")
	fmt.Println("  функции. Третья — тот самый «дорогой»: число отложенных")
	fmt.Println("  вызовов заранее неизвестно, открытый код неприменим, и")
	fmt.Println("  каждый вызов приходится регистрировать в списке.")
	fmt.Println()
	fmt.Println("  И главное про цикл — не цена, а СРОК. Отложенный вызов")
	fmt.Println("  выполняется на выходе из ФУНКЦИИ, а не из витка: файлы,")
	fmt.Println("  открытые в цикле, останутся открытыми до конца функции.")
	fmt.Println()
}

// ------------------------------------------------- блок 2: чего не ловит

// directRecover — единственная работающая форма: recover вызван
// НЕПОСРЕДСТВЕННО в отложенной функции.
func directRecover() (caught bool) {
	defer func() {
		if recover() != nil {
			caught = true
		}
	}()
	panic("раз")
}

// helperRecover — recover вызван из вложенной функции. Он вернёт nil, и
// паника продолжит лететь: перехватывать её приходится вторым уровнем.
func helperRecover() (caughtInside, caughtOutside bool) {
	defer func() {
		if recover() != nil {
			caughtOutside = true
		}
	}()
	defer func() {
		// recover() внутри вложенного вызова не работает.
		caughtInside = helper()
	}()
	panic("два")
}

func helper() bool { return recover() != nil }

// afterRecover показывает, что функция НЕ продолжается с места паники:
// выполняются только отложенные вызовы, а тело дальше не идёт.
func afterRecover() (reached bool) {
	defer func() { recover() }()
	panic("три")
	//nolint:govet // недостижимость здесь и есть предмет наблюдения
}

// rePanic: паника, поднятая заново, летит дальше с новым значением.
func rePanic() (msg string) {
	defer func() {
		if r := recover(); r != nil {
			msg = fmt.Sprint(r)
		}
	}()
	func() {
		defer func() {
			if r := recover(); r != nil {
				panic(fmt.Sprint("обёрнуто: ", r))
			}
		}()
		panic("исходная")
	}()
	return
}

// nilPanic: panic(nil) с Go 1.21 превращается в *runtime.PanicNilError.
func nilPanic() (kind string) {
	defer func() {
		r := recover()
		kind = fmt.Sprintf("%T", r)
	}()
	panic(nil)
}

func limits() {
	fmt.Println("ЧТО RECOVER ЛОВИТ, А ЧТО НЕТ")
	fmt.Println("────────────────────────────")
	fmt.Printf("  recover прямо в отложенной функции      поймал: %v\n", directRecover())
	in, out := helperRecover()
	fmt.Printf("  recover из вложенного вызова            поймал: %v\n", in)
	fmt.Printf("  та же паника, пойманная уровнем выше    поймал: %v\n", out)
	fmt.Printf("  тело функции после паники выполнилось:  %v\n", afterRecover())
	fmt.Printf("  паника, поднятая заново                 %q\n", rePanic())
	fmt.Printf("  panic(nil) с Go 1.21 даёт тип           %s\n", nilPanic())
	fmt.Println()
	fmt.Println("  Первые две строки — самое частое место ошибки: recover")
	fmt.Println("  работает, только когда вызван НЕПОСРЕДСТВЕННО в отложенной")
	fmt.Println("  функции. Обёртка в помощник «для чистоты» его отключает")
	fmt.Println("  молча — ни ошибки сборки, ни предупреждения.")
	fmt.Println()
	fmt.Println("  Чего здесь нет и быть не может: паники ДРУГОЙ горутины и")
	fmt.Println("  fatal error рантайма. Первую нельзя поймать снаружи — у")
	fmt.Println("  каждой горутины свой стек отложенных вызовов; вторая не")
	fmt.Println("  паника вовсе. Проверить это программой нельзя: оба случая")
	fmt.Println("  завершают процесс, что и есть ответ.")
	fmt.Println()
}

// ---------------------------------------------- блок 3: паника против ошибки

var errFail = errors.New("не вышло")

func returnsError(fail bool) error {
	if fail {
		return errFail
	}
	return nil
}

func panicsAndRecovers(fail bool) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("%v", r)
		}
	}()
	if fail {
		panic(errFail)
	}
	return nil
}

func costOfPanic() {
	fmt.Println("ПАНИКА ПРОТИВ ВОЗВРАТА ОШИБКИ, НА ОДИНАКОВОЙ РАБОТЕ")
	fmt.Println("───────────────────────────────────────────────────")

	okErr := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			if returnsError(false) != nil {
				sink++
			}
		}
	})
	failErr := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			if returnsError(true) != nil {
				sink++
			}
		}
	})
	okPanic := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			if panicsAndRecovers(false) != nil {
				sink++
			}
		}
	})
	failPanic := best(func(b *testing.B) {
		for i := 0; i < b.N; i++ {
			if panicsAndRecovers(true) != nil {
				sink++
			}
		}
	})

	fmt.Printf("  return nil                    %10.2f нс\n", okErr)
	fmt.Printf("  return err                    %10.2f нс\n", failErr)
	fmt.Printf("  defer+recover, паники нет     %10.2f нс\n", okPanic)
	fmt.Printf("  defer+recover, паника есть    %10.2f нс\n", failPanic)
	fmt.Printf("  паника против возврата        %10.2f\n", failPanic/failErr)
	fmt.Println()
	fmt.Println("  Третья строка важнее четвёртой: даже когда паники НЕ")
	fmt.Println("  случилось, форма с defer+recover стоит дороже обычного")
	fmt.Println("  возврата — цену платит каждый вызов, а не только сбойный.")
	fmt.Println()
	fmt.Println("  Отсюда правило, а не запрет: паника — для того, что не")
	fmt.Println("  должно случаться (нарушен инвариант, невозможное состояние).")
	fmt.Println("  Ожидаемый сбой — это ошибка в возврате, и разница между ними")
	fmt.Println("  не в стиле, а в том, кто обязан её обработать.")
	fmt.Println()
}

// ------------------------------------------------------------ утилиты

const rounds = 7

func nsPerOp(r testing.BenchmarkResult) float64 {
	return float64(r.T.Nanoseconds()) / float64(r.N)
}

func best(f func(*testing.B)) float64 {
	out := 0.0
	for r := 0; r < rounds; r++ {
		v := nsPerOp(testing.Benchmark(f))
		if out == 0 || v < out {
			out = v
		}
	}
	return out
}

func main() {
	fmt.Printf("%s %s/%s | defer, panic и recover изнутри\n\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)
	kinds()
	limits()
	costOfPanic()
}