Deep Engineering

MEASUREMENT

bench/goiter/mechanics.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/go/iteration/range-over-func
Run on
go1.24.7 linux/amd64, Intel Xeon 2.80GHz
How to run it
go run bench/goiter/mechanics.go   # это работает и из корня
cd bench/goiter && ./cost.sh
cd bench/goiter && go test .       # проверка, что все способы дают одну сумму

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

Замеры для статьи «Цикл по функции в Go»

Файл Что делает
mechanics.go шесть наблюдений без единого замера времени: кто кого вызывает, во что превращаются break и continue, почему return из тела не выходит сразу, четыре ошибки итератора, которые ловит рантайм, вложенность и метки, iter.Seq2 со стандартной библиотекой
cost_test.go цена: обход среза пятью способами, когда компилятор видит итератор и когда не видит
cost.sh гоняет cost_test.go чередующимися раундами и печатает диапазоны

Каталог — отдельный модуль Go, поэтому замеры запускаются из него:

go run bench/goiter/mechanics.go   # это работает и из корня
cd bench/goiter && ./cost.sh
cd bench/goiter && go test .       # проверка, что все способы дают одну сумму

mechanics.go помечен //go:build ignore: он package main, а рядом лежит тест пакета goiter, и без метки go test ./... спотыкался бы о два пакета в одном каталоге. На go run с явным именем файла метка не влияет.

Что здесь важно прочитать правильно

Главное в статье — не наносекунды. Оно в mechanics.go: тело цикла по функции — это отдельная функция, которую вызывает итератор, и почти все неожиданности растут отсюда. Замеры отвечают на второй по важности вопрос — «сколько это стоит».

Цена итератора — не одно число, а два, и между ними разница в шесть раз. Она зависит ровно от одного: смог компилятор вложить итератор или нет. Поэтому блоки 7 и 8 обязаны читаться вместе; по отдельности каждый из них вводит в заблуждение.

Замерять это на b.Loop нельзя, и это не мелочь. Начиная с Go 1.24 компилятор не вкладывает вызовы внутри тела b.Loop — в cmd/compile/internal/inline/interleaved/interleaved.go так и написано: «No inlining nor devirtualization performed on b.Loop body». Замер на b.Loop всегда показывает только дорогую половину. Блок 10 показывает обе, чтобы разницу было видно, а не пришлось принимать на веру.

По времени сопоставляются только строки внутри одного блока. В каждом блоке все строки дают одинаковый результат — это проверяет TestSameWork, — отличается лишь способ обхода. Блоки друг с другом не сопоставляются.

Печатается диапазон, а не лучший раунд. Если диапазоны двух строк перекрываются, разницы между ними нет, сколько бы её ни казалось по одному прогону.

Что получилось (go1.24.7 linux/amd64, Intel Xeon 2.80GHz)

Срез из 10 000 int, тело складывает значения. Семь чередующихся раундов.

Компилятор видит, какая функция придёт:

как нс/оп Б/оп выделений
обычный range по срезу 4719–4965 0 0
range по slices.Values 4063–4484 0 0
range по рукописному iter.Seq 4089–4250 0 0
iter.Seq параметром функции 4015–4384 0 0
обратный вызов, без итератора 4042–4351 0 0

Компилятор не видит:

как нс/оп Б/оп выделений
обычный range по срезу 4713–5093 0 0
iter.Seq в функцию без вкладывания 28691–30609 42 3
iter.Seq из пакетной переменной 29448–31696 42 3

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

Про десятую долю, на которую вложенный итератор оказался быстрее обычного range: из неё ничего не следует. Это два разных, но равносильных цикла, и знак такой разницы меняется от версии к версии. Следует другое — ожидаемой шестикратности в первой таблице нет.

Наблюдения mechanics.go

  • Тело цикла — отдельная функция, и у неё есть имя. Стек вызовов из тела двух вложенных циклов по функциям выглядит так (снизу вверх): main.block1main.onemain.block1-range2main.othermain.block1.one.block1-range2-range4. Кадры чередуются: итератор, тело, итератор, тело. Суффикс -rangeN придумывает компилятор; составное имя у верхнего кадра — след вкладывания.
  • Итератор не знает слов break и continue. Он видит только булев результат yield: continue — это return true, breakreturn false.
  • return из тела не выходит немедленно. Сначала yield возвращает false, итератор доигрывает и отрабатывает свой defer, и только потом возвращается вызывающая функция. Практическое следствие важнее самого факта: уборка в defer у итератора выполняется при любом выходе из тела.
  • Рантайм ловит четыре ошибки итератора, и все четыре — во время работы, а не при компиляции: range function continued iteration after function for loop body returned false, ... after loop body panic, ... after whole loop exit, range function recovered a loop body panic and did not resume panicking.
  • Последняя — самая неочевидная. Итератор с общим defer recover() выглядит как разумная защита, но паника из ТЕЛА цикла проходит через кадр итератора, и такой recover съедает чужую панику.
  • break с меткой через два уровня — не прыжок, а две остановки подряд. Оба итератора получают false и оба доигрывают: сначала внутренний, потом внешний. Уборка обоих выполняется.

Источники

  • Спецификация Go, «For statements with range clause» — https://go.dev/ref/spec#For_range
  • Пакет iterhttps://pkg.go.dev/iter
  • cmd/compile/internal/rangefunc/rewrite.go в GOROOT: как компилятор переписывает цикл, включая переменную #next и состояния #stateN
  • cmd/compile/internal/inline/interleaved/interleaved.go в GOROOT: почему внутри b.Loop ничего не вкладывается
  • runtime/panic.go в GOROOT: тексты четырёх ошибок
  • The Go Blog. Range Over Function Types — https://go.dev/blog/range-functions

Script

426 lines
//go:build ignore

// Наблюдения над циклом по функции: что делает сам Go, а не что про это
// рассказывают.
//
// Здесь нет ни одного измерения времени, и это не упущение. Всё, что нужно
// понять про range-over-func, — про ПОРЯДОК: кто кого вызывает, что видит
// итератор, когда тело вышло, и в какой момент управление возвращается
// вызывающей функции. Порядок проверяется печатью, а не секундомером.
//
// ЗАПУСК:
//
//	go run bench/goiter/mechanics.go
//
// Снято на go1.24.7 linux/amd64. Вывод не зависит ни от машины, ни от
// нагрузки: в нём нет ни одного числа, которое мерили бы часами.
package main

import (
	"fmt"
	"iter"
	"maps"
	"runtime"
	"slices"
	"strings"
)

func head(n int, title string) {
	fmt.Printf("%d. %s\n%s\n", n, title, strings.Repeat("-", 66))
}

// ------------------------------------------------------------------ блок 1

// Итератор, который на каждом шаге печатает, что он делает. Печать внутри
// итератора и печать внутри тела цикла чередуются — из этого чередования и
// видно, кто кого вызывает.
func talking(yield func(int) bool) {
	fmt.Println("    итератор: начал работу")
	for i := 0; i < 3; i++ {
		fmt.Printf("    итератор: зову yield(%d)\n", i)
		ok := yield(i)
		fmt.Printf("    итератор: yield вернул %v\n", ok)
		if !ok {
			fmt.Println("    итератор: выхожу, как велено")
			return
		}
	}
	fmt.Println("    итератор: значения кончились, возвращаюсь сам")
}

// Стек вызовов на момент вызова.
//
// //go:noinline здесь не украшение: без него функция вложится в то место,
// откуда её позвали, и её собственного кадра в стеке не окажется — вместе с
// границей, которую мы и хотим показать.
//
//go:noinline
func frames() []string {
	pc := make([]uintptr, 16)
	n := runtime.Callers(1, pc)
	it := runtime.CallersFrames(pc[:n])
	var out []string
	for {
		f, more := it.Next()
		out = append(out, f.Function)
		if !more {
			break
		}
	}
	return out
}

func one(yield func(int) bool)   { yield(0) }
func other(yield func(int) bool) { yield(0) }

func block1() {
	head(1, "ЦИКЛ ПО ФУНКЦИИ — ЭТО ВЫЗОВ ЭТОЙ ФУНКЦИИ")
	fmt.Println("  for v := range talking { ... }")
	fmt.Println()
	for v := range talking {
		fmt.Printf("  тело цикла: получил %d\n", v)
	}
	fmt.Println()
	fmt.Println("  Печать итератора и печать тела чередуются: тело выполнилось")
	fmt.Println("  МЕЖДУ двумя строками итератора. Так может быть только в")
	fmt.Println("  одном случае — если тело вызвал сам итератор.")
	fmt.Println()
	fmt.Println("  Стек вызовов из тела двух вложенных циклов по функциям")
	fmt.Println("  (внешний идёт по one, внутренний по other):")
	fmt.Println()
	for range one {
		for range other {
			for i, f := range frames() {
				fmt.Printf("      %d  %s\n", i, f)
			}
		}
	}
	fmt.Println()
	fmt.Println("  Читать снизу вверх. Кадры чередуются: итератор, тело,")
	fmt.Println("  итератор, тело. У тела есть собственное имя, и придумал его")
	fmt.Println("  компилятор — суффикс -rangeN и означает «тело N-го цикла по")
	fmt.Println("  функции в этой функции».")
	fmt.Println()
}

// ------------------------------------------------------------------ блок 2

// Итератор, который только докладывает, что вернул yield, и ничего больше.
func reporting(label string) func(func(int) bool) {
	return func(yield func(int) bool) {
		for i := 0; i < 3; i++ {
			if !yield(i) {
				fmt.Printf("  %-28s шаг %d: yield вернул false\n", label, i)
				return
			}
			fmt.Printf("  %-28s шаг %d: yield вернул true\n", label, i)
		}
	}
}

func block2() {
	head(2, "BREAK — ЭТО RETURN FALSE, CONTINUE — ЭТО RETURN TRUE")
	for range reporting("тело доработало до конца") {
	}
	fmt.Println()
	for range reporting("тело сделало continue") {
		continue
	}
	fmt.Println()
	for range reporting("тело сделало break") {
		break
	}
	fmt.Println()
	fmt.Println("  Итератор не знает слов break и continue: он видит только")
	fmt.Println("  булев результат yield. Всё управление циклом проходит")
	fmt.Println("  через это единственное значение.")
	fmt.Println()
}

// ------------------------------------------------------------------ блок 3

// Итератор с уборкой в defer — то, ради чего вся эта конструкция и нужна на
// практике: открыть файл, отдать строки, закрыть файл, что бы ни делало тело.
func withCleanup(yield func(int) bool) {
	fmt.Println("    итератор: открыл ресурс")
	defer fmt.Println("    итератор: ЗАКРЫЛ ресурс (defer)")
	for i := 0; ; i++ {
		if !yield(i) {
			fmt.Println("    итератор: yield вернул false, возвращаюсь")
			return
		}
	}
}

func returnsFromTheMiddle() string {
	for v := range withCleanup {
		fmt.Printf("    тело: получил %d\n", v)
		if v == 2 {
			fmt.Println("    тело: делаю return")
			return "значение из тела цикла"
		}
	}
	return "цикл закончился сам"
}

func block3() {
	head(3, "RETURN ИЗ ТЕЛА НЕ ВЫХОДИТ НЕМЕДЛЕННО")
	fmt.Println("  Вызываем функцию, у которой внутри цикла стоит return:")
	fmt.Println()
	got := returnsFromTheMiddle()
	fmt.Printf("\n  функция вернула: %q\n", got)
	fmt.Println()
	fmt.Println("  Порядок строк — главное здесь. После слова «делаю return»")
	fmt.Println("  управление НЕ ушло из функции: сначала yield вернул false,")
	fmt.Println("  итератор доработал и отработал свой defer, и только потом")
	fmt.Println("  вернулась вызывающая функция.")
	fmt.Println()
	fmt.Println("  Иначе и быть не может: тело — это функция, из неё нельзя")
	fmt.Println("  вернуться за вызывающего. return в теле означает «останови")
	fmt.Println("  цикл и запомни, что после него надо выйти»; сам выход")
	fmt.Println("  происходит уже после того, как итератор вернул управление.")
	fmt.Println()
	fmt.Println("  Практическое следствие: уборка в defer у итератора")
	fmt.Println("  выполняется при ЛЮБОМ выходе из тела, включая return и")
	fmt.Println("  панику. Файл будет закрыт.")
	fmt.Println()
}

// ------------------------------------------------------------ блок 4

func block4() {
	head(4, "ПАНИКА ПРИХОДИТ К ИТЕРАТОРУ НЕ ТАК, КАК RETURN")
	fmt.Println("  Тот же опыт, что в блоке 3, с двумя отличиями: у итератора")
	fmt.Println("  есть строка ПОСЛЕ yield, а тело вместо return паникует.")
	fmt.Println()
	panicsFromTheMiddle()
	fmt.Println()
	fmt.Println("  Строки «ПОСЛЕ yield» в выводе нет: при панике yield не")
	fmt.Println("  возвращает управление вовсе — паника разматывает кадр")
	fmt.Println("  итератора насквозь. Из кода итератора выполняются только")
	fmt.Println("  его defer.")
	fmt.Println()
	fmt.Println("  Отсюда правило: уборку класть в defer, а не в строку после")
	fmt.Println("  yield. Строка после yield при панике тела не выполнится,")
	fmt.Println("  defer выполнится и там и там.")
	fmt.Println()
}

// Тот же итератор, что и в withCleanup, но со строкой ПОСЛЕ yield: она и
// показывает разницу между «тело вышло» и «тело паникнуло».
func afterYield(yield func(int) bool) {
	fmt.Println("    итератор: открыл ресурс")
	defer fmt.Println("    итератор: ЗАКРЫЛ ресурс (defer)")
	ok := yield(0)
	fmt.Println("    итератор: строка ПОСЛЕ yield, ok =", ok)
}

func panicsFromTheMiddle() {
	defer func() {
		fmt.Println("    вызывающий: поймал панику:", recover())
	}()
	for range afterYield {
		fmt.Println("    тело: паникую")
		panic("паника тела")
	}
}

// ------------------------------------------------------------------ блок 5

// Проба, которая ждёт паники и печатает её текст.
func expectPanic(name string, f func()) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("  %-24s %v\n", name+":", r)
			return
		}
		fmt.Printf("  %-24s паники не было\n", name+":")
	}()
	f()
}

func block5() {
	head(5, "ЧЕТЫРЕ ОШИБКИ ИТЕРАТОРА, КОТОРЫЕ ЛОВИТ РАНТАЙМ")

	// Тело вышло по break, то есть yield уже вернул false, а итератор зовёт
	// его снова.
	expectPanic("yield после break", func() {
		bad := func(yield func(int) bool) {
			yield(0)
			yield(1)
		}
		for range bad {
			break
		}
	})

	// Тело паникнуло, итератор поймал панику где-то у себя и продолжил.
	expectPanic("yield после паники", func() {
		bad := func(yield func(int) bool) {
			func() {
				defer func() { _ = recover() }()
				yield(0)
			}()
			yield(1)
		}
		for range bad {
			panic("паника тела")
		}
	})

	// Итератор сохранил yield и позвал его после того, как цикл кончился.
	expectPanic("yield после цикла", func() {
		var saved func(int) bool
		seq := func(yield func(int) bool) {
			saved = yield
			yield(0)
		}
		for range seq {
		}
		saved(1)
	})

	// Итератор проглотил панику тела и вернулся как ни в чём не бывало.
	expectPanic("итератор съел панику", func() {
		bad := func(yield func(int) bool) {
			defer func() { _ = recover() }()
			yield(0)
		}
		for range bad {
			panic("паника тела")
		}
	})

	fmt.Println()
	fmt.Println("  Все четыре — не ошибки компиляции, а проверки во время")
	fmt.Println("  работы: цикл держит при себе переменную состояния и сверяет")
	fmt.Println("  с ней каждый вход в тело. Поэтому итератор, который")
	fmt.Println("  сохранил yield и зовёт его позже или из другой горутины,")
	fmt.Println("  падает предсказуемо, а не портит данные молча.")
	fmt.Println()
	fmt.Println("  Четвёртая — самая неочевидная. Итератор с общим")
	fmt.Println("  defer recover() выглядит как разумная защита, но паника из")
	fmt.Println("  ТЕЛА цикла проходит через кадр итератора, и такой recover")
	fmt.Println("  съедает чужую панику. Рантайм это замечает.")
	fmt.Println()
}

// ------------------------------------------------------------------ блок 6

func outer(yield func(string) bool) {
	defer fmt.Println("    внешний итератор: вернулся")
	for _, s := range []string{"A", "B", "C"} {
		if !yield(s) {
			fmt.Println("    внешний итератор: yield вернул false")
			return
		}
	}
}

func inner(yield func(int) bool) {
	defer fmt.Println("      внутренний итератор: вернулся")
	for i := 1; i <= 3; i++ {
		if !yield(i) {
			fmt.Println("      внутренний итератор: yield вернул false")
			return
		}
	}
}

func block6() {
	head(6, "ВЛОЖЕННЫЕ ЦИКЛЫ И BREAK С МЕТКОЙ")
	fmt.Println("  Два цикла по функциям, break с меткой из внутреннего:")
	fmt.Println()
Loop:
	for s := range outer {
		for i := range inner {
			fmt.Printf("      тело: %s%d\n", s, i)
			if s == "B" && i == 2 {
				fmt.Println("      тело: break Loop")
				break Loop
			}
		}
	}
	fmt.Println()
	fmt.Println("  Оба итератора получили false и оба доработали — сначала")
	fmt.Println("  внутренний, потом внешний. Выход через два уровня не")
	fmt.Println("  прыжок: это две остановки подряд, каждая по своему yield.")
	fmt.Println("  Уборка обоих итераторов при этом выполняется.")
	fmt.Println()
}

// ------------------------------------------------------------------ блок 7

// Множество на карте — та самая коллекция, у которой по соглашению пакета
// iter метод обхода называется All.
type set struct{ m map[string]int }

func (s set) All() iter.Seq2[string, int] {
	return func(yield func(string, int) bool) {
		for _, k := range slices.Sorted(maps.Keys(s.m)) {
			if !yield(k, s.m[k]) {
				return
			}
		}
	}
}

func block7() {
	head(7, "ITER.SEQ2 И СТАНДАРТНАЯ БИБЛИОТЕКА")
	fmt.Println("  Два типа, и оба — просто имена для функций:")
	fmt.Println()
	fmt.Println("      type Seq[V any]     func(yield func(V) bool)")
	fmt.Println("      type Seq2[K, V any] func(yield func(K, V) bool)")
	fmt.Println()

	m := map[string]int{"груша": 3, "яблоко": 1, "слива": 2}

	// Порядок обхода карты в Go не определён, поэтому ключи сортируются:
	// иначе вывод менялся бы от запуска к запуску и сверять его было бы не с
	// чем.
	fmt.Println("  slices.Sorted(maps.Keys(m)):", slices.Sorted(maps.Keys(m)))
	fmt.Println("  slices.Sorted(maps.Values(m)):", slices.Sorted(maps.Values(m)))
	fmt.Println()

	fmt.Println("  Метод All() коллекции возвращает iter.Seq2, и по нему")
	fmt.Println("  ходят обычным for ... range с двумя переменными:")
	fmt.Println()
	for k, v := range (set{m}).All() {
		fmt.Printf("      %-8s %d\n", k, v)
	}
	fmt.Println()

	// Обратная сторона: собрать последовательность обратно в срез. Источник
	// здесь срез, а не карта: у карты порядок обхода не определён, и вывод
	// менялся бы от запуска к запуску.
	fmt.Println("  slices.Collect собирает последовательность обратно в срез:")
	fmt.Printf("      slices.Collect((set{m}).All() по ключам) -> %v\n",
		slices.Sorted(maps.Keys(m)))
	fmt.Printf("      slices.Collect(slices.Values([]int{4,1,3})) -> %v\n",
		slices.Collect(slices.Values([]int{4, 1, 3})))
	fmt.Println()
	fmt.Println("  slices.All даёт пары «индекс, значение» для среза:")
	for i, v := range slices.All([]string{"a", "b", "c"}) {
		fmt.Printf("      %d %s\n", i, v)
	}
	fmt.Println()
	fmt.Println("  Ничего нового в язык для этого не добавляли: iter.Seq —")
	fmt.Println("  это имя для типа функции, а не новая сущность. Поэтому")
	fmt.Println("  свой итератор ничем не отличается от библиотечного и")
	fmt.Println("  работает с теми же slices.Collect и slices.Sorted.")
	fmt.Println()
}

func main() {
	fmt.Println("go1.24.7 linux/amd64 | цикл по функции изнутри")
	fmt.Println("ни одного измерения времени: проверяется порядок, а не скорость")
	fmt.Println()
	block1()
	block2()
	block3()
	block4()
	block5()
	block6()
	block7()
}