Deep Engineering

ЗАМЕР

bench/gojson/cost.go

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/golang/json
Как запустить
go run bench/gojson/internals.go
bash bench/gojson/recursion.sh
go run bench/gojson/cost.go
go run bench/gojson/practice.go
go run bench/gojson/strict.go
GOEXPERIMENT=jsonv2 /usr/local/go1.25.1/bin/go run bench/gojson/jsonv2.go

Запись прогона

Замеры для урока «JSON в Go»

Файл Что делает
internals.go одиннадцать наблюдений без единого замера времени: одна структура без тегов, с тегами и обратно через Unmarshal; что пропускают omitempty и omitzero для полей тринадцати видов; регистр ключей, лишние ключи и DisallowUnknownFields; Unmarshal в заполненную структуру; большое целое в any, UseNumber и ,string; формы значений — nil-срез, []byte, порядок ключей карты, экранирование HTML, NaN; MarshalJSON на указателе; MarshalJSON с типом-двойником; двойник при встроенном time.Time; наивная формула Cents на отрицательных суммах и исправленная; число вне int64 — ошибка в поле, округление в any
recursion.sh собирает и запускает cases/recursion и cases/alias, печатает сообщение рантайма, код выхода и один виток цикла из трассы стека
cases/recursion/main.go подопытная программа к recursion.sh: MarshalJSON зовёт json.Marshal(e); намеренно падает
cases/alias/main.go подопытная программа к recursion.sh: двойник объявлен псевдонимом type event = Event; намеренно падает
cost.go цена одной структуры из пяти полей: json.Marshal, ручная сборка той же строки через strconv.Append*, json.Unmarshal в структуру и в map[string]any; наносекунды, аллокации, байты и кратности, лучший из 15 кругов
jsonv2.go encoding/json и encoding/json/v2 на одних входах: регистр ключей, nil-срез и карта, omitempty у числа, MarshalJSON на указателе, null в заполненное поле. Только go1.25+ и только с GOEXPERIMENT=jsonv2
strict.go полный короткий пример (комментарии и вывод по-английски — файл показан в обеих локалях): обработчик HTTP со строгим разбором тела — MaxBytesReader, DisallowUnknownFields, второй Decode обязан вернуть io.EOF; прогон на восьми телах через httptest
practice.go источник ответов двух практических задач урока
go run bench/gojson/internals.go
bash bench/gojson/recursion.sh
go run bench/gojson/cost.go
go run bench/gojson/practice.go
go run bench/gojson/strict.go
GOEXPERIMENT=jsonv2 /usr/local/go1.25.1/bin/go run bench/gojson/jsonv2.go

Записи прогонов — в runs/, go1.24.7 linux/amd64, 2 ядра.

  • internals-go1.25.txt — internals.go на go1.25.1: вывод тот же, кроме строки версии.
  • internals-go1.25-jsonv2.txt — он же на go1.25.1 с GOEXPERIMENT=jsonv2. В этом режиме encoding/json реализован поверх v2; поведение во всех всех разделах то же, отличаются тексты трёх ошибок (раздел 4, «тип не тот», раздел 5, ,string при числе во входе, и раздел 11, число вне int64).
  • jsonv2-go1.25.txt — jsonv2.go, go1.25.1, GOEXPERIMENT=jsonv2.
  • strict.txt, strict-go1.25.txt — strict.go на go1.24.7 и go1.25.1: вывод тот же, кроме строки версии.

Документация и release notes сверены 14.10.2026. На эту дату вышли Go 1.26 и 1.27; на них скрипты не запускались. По release notes Go 1.27 encoding/json работает на реализации v2 (прежняя — GOEXPERIMENT=nojsonv2), поэтому числа cost.go относятся только к go1.24.7 и старой реализации.

Времена в cost.go и practice.go сняты на общей машине, где параллельно шли другие задачи: абсолютные наносекунды гуляют от запуска к запуску, устойчивее кратности. Круги способов чередуются, чтобы фоновая нагрузка доставалась всем поровну.

Что получилось

Видны только экспортируемые поля, и без тегов имена — как в Go. {"ID":7,"Name":"Ann","Email":"","Tags":null,"Address":{"city":""}}: поля password нет, пустая строка и nil-срез на месте. Исключение — неэкспортируемая встроенная структура: Outer{inner{X: 1}, 2} даёт {"X":1,"Y":2}.

omitempty не пропускает структуры. Пропущены false, 0, "", nil-указатель, nil-интерфейс, nil- и пустые срез и карта. Не пропущены указатель на ноль ({"v":0}), массив [2]int{} ({"v":[0,0]}), пустая структура, Address{} и time.Time{} ({"v":"0001-01-01T00:00:00Z"}). omitzero (в go1.24.7 он есть) пропускает массив, структуры и time.Time{}, но оставляет пустой не-nil срез и карту.

Unmarshal не смотрит на регистр и молчит о лишнем. "name", "Name", "NAME", "nAmE" — все заполнили Name. Лишние ключи пропущены без ошибки; Decoder.DisallowUnknownFields даёт json: unknown field "role". Ключ "password" при теге "-" тоже пропущен — значение поля не тронуто.

Unmarshal в заполненную структуру — слияние, а не замена. Поле, которого нет во входе, осталось прежним; null для string ничего не изменил; карта дополнена (map[likes:5 visits:3]). Срез обрезан до нуля и дописан в тот же массив: старый срез old после разбора ["z"] показывает [z b c], а элемент-структура слит со старым — [{A:5 B:2}] из [{A:1 B:2} {A:3 B:4}] при входе [{"a":5}]. При несовпадении типа поле пропущено, остальные заполнены, ошибка — json: cannot unmarshal string into Go struct field Profile.id of type int.

Числа в any — float64. 9007199254740993 в map[string]any стало 9.007199254740992e+15, после int64(f) — 9007199254740992, и так же ушло обратно в JSON. UseNumber сохраняет json.Number("9007199254740993"); поле int64 получает число точно; тег ,string пишет {"id":"9007199254740993"}.

Формы значений. nil-срез — null, пустой — []; []byte("hi") — "aGk="; ключи карты отсортированы побайтно ("Mid" раньше "alpha", "10" раньше "9"); <, >, & экранированы как \u003c, \u003e, \u0026; NaN — json: unsupported value: NaN.

MarshalJSON на указателе вызывается только у адресуемого значения. json.Marshal(inv) — {"total":1234}, json.Marshal(&inv) — {"total":"12.34"}; элемент среза метод получает, значение в карте — нет.

Рекурсия. MarshalJSON, зовущий json.Marshal(e), падает: runtime: goroutine stack exceeds 1048576-byte limit, fatal error: stack overflow, код выхода 2. Цикл в трассе: encoding/json.Marshal → main.Event.MarshalJSON → … → encoding/json.Marshal. Двойник, объявленный новым типом (type event Event), рекурсию снимает: методов самого Event у него нет. Но методы встроенных полей остаются: если встроен time.Time (раздел 9), двойник реализует json.Marshaler, и json.Marshal(st) даёт одну строку времени "2026-10-01T12:00:00Z" — id и kind потеряны, ошибки нет. Двойник-псевдоним (type event = Event) — нет: это тот же тип, и встроенный в анонимную структуру, он поднимает в неё MarshalJSON — цикл идёт через (*struct { main.event = main.Event; … }).MarshalJSON.

Цена. json.Marshal структуры из пяти полей — одна аллокация (96 байт), как и у ручной сборки, и дороже её в 1,8 раза — меньше чем вдвое. Дорогая сторона — Unmarshal: в записи прогона в 6,0 раза дороже Marshal, 13 аллокаций; в map[string]any — ещё в 1,2 раза дороже, 33 аллокации. В уроке эти кратности названы округлённо («примерно в шесть раз»): на общей машине они от запуска к запуску гуляют.

Cents и отрицательные суммы. Формула n/100, n%100 даёт "0.-1" для −1 и "-1.-23" для −123: остаток в Go несёт знак делимого. Знак и модуль в uint64 отдельно дают "-0.01" и "-1.23", верно и для math.MinInt64 ("-92233720368547758.08").

Потеря точности или ошибка. {"id":9223372036854775808} в поле int64 — ошибка json: cannot unmarshal number 9223372036854775808 into Go struct field Row.id of type int64, поле осталось 0; в map[string]any — 9.223372036854776e+18 без ошибки.

Строгий разбор (strict.go). {"name":"ann"} {} и {"name":"ann"}x — 400 после второго Decode; тело больше 64 байт — 413 http: request body too large; {"name":"a","name":"b"} — 200 с name="b": v1 дубли ключей принимает.

encoding/json/v2 (эксперимент go1.25). Повторяющийся ключ v2 отвергает: jsontext: duplicate object member name "name". Ключ "NAME" не находит поле name; nil-срез — [], nil-карта — {}; omitempty у int со значением 0 не пропускает поле; MarshalJSON на указателе вызывается и у значения без адреса; null обнуляет заполненное поле.

Скрипт

183 строк
//go:build ignore

// Цена encoding/json: Marshal и Unmarshal одной структуры.
//
// ЗАЧЕМ ЭТА ПРОГРАММА. «encoding/json медленный, потому что рефлексия» —
// фраза, которую говорят, не измерив. Здесь четыре способа на одной и той
// же структуре из пяти полей:
//
//  1. json.Marshal(o);
//  2. та же строка JSON, собранная руками: strconv.AppendInt и AppendQuote
//     в новый буфер на каждый вызов — результат тоже новый []byte, как у
//     Marshal, и скрипт проверяет, что байты совпадают;
//  3. json.Unmarshal в структуру;
//  4. json.Unmarshal в map[string]any.
//
// Печатаются наносекунды, аллокации и байты на операцию — и сами кратности,
// а не только слагаемые.
//
// Машина общая, на ней параллельно идут другие задачи: абсолютные
// наносекунды гуляют от запуска к запуску. Поэтому берётся лучший из 15
// кругов, круги способов чередуются, и устойчивой величиной считается
// кратность, а не наносекунды.
//
// ЗАПУСК:
//
//	go run bench/gojson/cost.go
package main

import (
	"bytes"
	"encoding/json"
	"flag"
	"fmt"
	"runtime"
	"strconv"
	"strings"
	"testing"
)

type Order struct {
	ID       int64    `json:"id"`
	Customer string   `json:"customer"`
	Total    int64    `json:"total"`
	Paid     bool     `json:"paid"`
	Items    []string `json:"items"`
}

var order = Order{
	ID:       90210,
	Customer: "Ann Smith",
	Total:    12345,
	Paid:     true,
	Items:    []string{"book", "pen", "lamp"},
}

// manual собирает ту же строку, что json.Marshal(order), без рефлексии.
// Строки здесь — ASCII без кавычек и обратных косых, поэтому AppendQuote
// даёт то же, что JSON-экранирование; в общем случае это не так.
func manual(o *Order) []byte {
	b := make([]byte, 0, 96)
	b = append(b, `{"id":`...)
	b = strconv.AppendInt(b, o.ID, 10)
	b = append(b, `,"customer":`...)
	b = strconv.AppendQuote(b, o.Customer)
	b = append(b, `,"total":`...)
	b = strconv.AppendInt(b, o.Total, 10)
	b = append(b, `,"paid":`...)
	b = strconv.AppendBool(b, o.Paid)
	b = append(b, `,"items":[`...)
	for i, it := range o.Items {
		if i > 0 {
			b = append(b, ',')
		}
		b = strconv.AppendQuote(b, it)
	}
	b = append(b, "]}"...)
	return b
}

const rounds = 15

type result struct {
	ns     float64
	allocs int64
	bytes  int64
}

func measure(fs []func(b *testing.B)) []result {
	out := make([]result, len(fs))
	for i := range out {
		out[i].ns = 1e18
	}
	for round := 0; round < rounds; round++ {
		for i, f := range fs {
			br := testing.Benchmark(func(b *testing.B) {
				b.ReportAllocs()
				f(b)
			})
			if ns := float64(br.T.Nanoseconds()) / float64(br.N); ns < out[i].ns {
				out[i] = result{ns: ns, allocs: br.AllocsPerOp(), bytes: br.AllocedBytesPerOp()}
			}
		}
	}
	return out
}

func show(title string) {
	fmt.Println()
	fmt.Println(title)
	fmt.Println(strings.Repeat("─", len([]rune(title))))
}

var (
	sinkB []byte
	sinkO Order
	sinkM map[string]any
)

func main() {
	fmt.Printf("%s %s/%s | цена encoding/json: Marshal и Unmarshal структуры\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH)

	testing.Init()
	flag.Set("test.benchtime", "200ms")

	data, _ := json.Marshal(order)
	same := bytes.Equal(data, manual(&order))

	rs := measure([]func(b *testing.B){
		func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				sinkB, _ = json.Marshal(&order)
			}
		},
		func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				sinkB = manual(&order)
			}
		},
		func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				var o Order
				_ = json.Unmarshal(data, &o)
				sinkO = o
			}
		},
		func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				var m map[string]any
				_ = json.Unmarshal(data, &m)
				sinkM = m
			}
		},
	})
	mar, man, unS, unM := rs[0], rs[1], rs[2], rs[3]

	show("1) ОДНА СТРУКТУРА ИЗ ПЯТИ ПОЛЕЙ")
	fmt.Printf("  %s\n", data)
	fmt.Printf("  %d байт; ручная сборка даёт те же байты: %v\n", len(data), same)
	fmt.Println()
	fmt.Printf("  %-34s %7s %6s %5s\n", "способ", "нс/оп", "аллок", "байт")
	rows := []struct {
		name string
		r    result
	}{
		{"json.Marshal(&o)", mar},
		{"ручная сборка (strconv.Append*)", man},
		{"json.Unmarshal в Order", unS},
		{"json.Unmarshal в map[string]any", unM},
	}
	for _, row := range rows {
		fmt.Printf("  %-34s %7.1f %6d %5d\n", row.name, row.r.ns, row.r.allocs, row.r.bytes)
	}
	fmt.Println()
	fmt.Printf("  Marshal дороже ручной сборки          в %4.1f раза\n", mar.ns/man.ns)
	fmt.Printf("  Unmarshal в структуру дороже Marshal  в %4.1f раза\n", unS.ns/mar.ns)
	fmt.Printf("  Unmarshal в map дороже, чем в Order   в %4.1f раза\n", unM.ns/unS.ns)
	fmt.Println()
	fmt.Printf("  Лучший из %d кругов по 200 мс, круги способов чередуются.\n", rounds)
	fmt.Printf("  %s %s/%s, GOMAXPROCS=%d\n",
		runtime.Version(), runtime.GOOS, runtime.GOARCH, runtime.GOMAXPROCS(0))
}