Сложите размеры полей структуры, вызовите unsafe.Sizeof — и получите число почти вдвое больше. Это не баг и не магия, а выравнивание данных. Разберём, откуда берутся «лишние» байты, почему компилятор Go их добавляет, как их посчитать руками и когда их стоит убрать. По ходу есть конструктор структур, где вы соберёте свою раскладку и увидите каждый байт.
Структура на 15 байт весит 32
Катя пишет сервис, который держит в памяти 40 миллионов событий для быстрой выдачи. Структура у неё самая обычная:
type Event struct {
Seen bool // 1 байт
UserID int64 // 8 байт
Kind bool // 1 байт
Score float32 // 4 байта
Fresh bool // 1 байт
}
fmt.Println(unsafe.Sizeof(Event{})) // 32Катя складывает: 1 + 8 + 1 + 4 + 1 = 15 байт. Откуда 32? Она считает, сколько памяти нужно под 40 млн штук: ожидала около 600 МБ, а получила 1,28 ГБ. Больше половины — воздух. Она переставляет поля местами, ничего не меняя в логике, и получает ровно 16 байт на событие. Те же данные, вдвое меньше памяти, и ни одной строки новой логики.
Чтобы понять, как так получилось, нужно разобраться с тем, как процессор читает память.
Зачем нужно выравнивание
Выравнивание (alignment) — требование, чтобы значение лежало по адресу, кратному некоторому числу. Значение типа int64 должно начинаться с адреса, кратного 8; int32 — кратного 4; byte — любого. Если очередное поле «не попадает» в такой адрес, компилятор вставляет перед ним пустые байты — padding (набивку).
Процессор и кэш-линия
Причин у этого требования несколько, и все они про железо:
- Процессор читает память словами. Шина и регистры рассчитаны на порции по 1, 2, 4, 8 байт. Выровненное значение читается одной операцией, а невыровненное может потребовать двух чтений и склейки результата.
- Кэш-линия. Память между кэшем и ОЗУ ходит блоками по 64 байта (на типичных x86-64 и ARM-серверах). Выровненное 8-байтное значение никогда не пересечёт границу линии. Невыровненное — может, и тогда для него нужны две линии.
- Атомарность. Атомарные инструкции работают только с выровненными значениями. Невыровненный доступ либо не атомарен, либо вообще вызывает аппаратную ошибку на части архитектур.
- Простота железа. Требование «адрес кратен размеру» упрощает схемы выборки; поэтому на ряде архитектур невыровненный доступ был запрещён или заметно медленнее.
Правила в Go
Спецификация Go гарантирует немного. Размеры числовых типов фиксированы, а для выравнивания даны три минимальных свойства:
- Для переменной любого типа
unsafe.Alignof(x)— не меньше 1. - Для переменной структурного типа
Alignof(x)— наибольшее из значенийAlignof(x.f)по всем полям, но не меньше 1. - Для массива
Alignof(x)равно выравниванию его элемента.
Остальное — детали реализации компилятора gc, но на практике они стабильны для всех популярных платформ. По сути, действует один принцип: выравнивание типа — это его «естественное» выравнивание, то есть размер самого крупного скалярного компонента (с ограничением размером машинного слова).
Алгоритм раскладки
Компилятор раскладывает поля строго в том порядке, в котором вы их объявили (Go никогда не переставляет поля сам). Для каждого поля:
offset = roundUp(конец_предыдущего_поля, align_поля)— смещение округляется вверх до кратного выравниванию;- разница между смещением и концом предыдущего поля — это padding перед полем;
- после последнего поля:
size = roundUp(конец_последнего, align_структуры). Хвостовой padding нужен, чтобы в массиве[N]Tкаждый следующий элемент тоже начинался с выровненного адреса.
Пройдём этим алгоритмом по структуре Кати (64-бит):
| Поле | Тип | size | align | Конец пред. | Padding | Offset | Конец |
|---|---|---|---|---|---|---|---|
| Seen | bool | 1 | 1 | 0 | 0 | 0 | 1 |
| UserID | int64 | 8 | 8 | 1 | 7 | 8 | 16 |
| Kind | bool | 1 | 1 | 16 | 0 | 16 | 17 |
| Score | float32 | 4 | 4 | 17 | 3 | 20 | 24 |
| Fresh | bool | 1 | 1 | 24 | 0 | 24 | 25 |
Хвост: align структуры = 8, roundUp(25, 8) | 7 | size = 32 | |||||
Итого 17 байт padding на 15 байт данных. Теперь та же структура с полями по убыванию выравнивания:
type Event struct {
UserID int64 // offset 0, 8 байт
Score float32 // offset 8, 4 байта
Seen bool // offset 12
Kind bool // offset 13
Fresh bool // offset 14
// хвост: 15 → 16 (align структуры 8)
}Из чего следует правило: чтобы минимизировать padding, расставляйте поля по убыванию выравнивания. Размер каждого типа кратен его выравниванию, поэтому после крупных полей мелкие естественно укладываются плотно, а дыры остаются только в хвосте.
Размеры и выравнивание типов
Спецификация гарантирует размеры только числовых типов. Всё остальное зависит от реализации и разрядности платформы, ниже — как это устроено в gc.
| Тип | Размер, 64-бит | Align, 64-бит | Размер, 32-бит | Align, 32-бит | Из чего состоит |
|---|---|---|---|---|---|
bool, int8, uint8 / byte | 1 | 1 | 1 | 1 | один байт. Для bool размер гарантирует реализация |
int16, uint16 | 2 | 2 | 2 | 2 | |
int32, uint32, float32, rune | 4 | 4 | 4 | 4 | |
int64, uint64, float64 | 8 | 8 | 8 | 4 | на 386 и arm 64-битные значения выровнены по 4 |
int, uint, uintptr | 8 | 8 | 4 | 4 | размер машинного слова |
complex64 | 8 | 4 | 8 | 4 | два float32 |
complex128 | 16 | 8 | 16 | 4 | два float64 |
указатель *T, map, chan, func | 8 | 8 | 4 | 4 | одно слово: указатель на данные или на структуру-заголовок |
string | 16 | 8 | 8 | 4 | указатель на байты + длина (2 слова) |
срез []T | 24 | 8 | 12 | 4 | указатель + длина + ёмкость (3 слова) |
интерфейс (any, error) | 16 | 8 | 8 | 4 | указатель на тип или itab + указатель на данные. Подробнее: Интерфейсы в Go 1.26 |
массив [N]T | N × size(T) | align(T) | N × size(T) | align(T) | элементы подряд; padding внутри нет, он уже учтён в size(T) |
struct{} | 0 | 1 | 0 | 1 | нет полей — нет размера |
time.Time | 24 | 8 | 20 | 4 | wall uint64, ext int64, loc *Location |
time.Duration | 8 | 8 | 8 | 4 | это int64 |
sync.Mutex | 8 | 4 | 8 | 4 | два 32-битных слова. Проверьте через unsafe.Sizeof на своей версии |
int, string, срезов и интерфейсов, а также выравнивание 64-битных значений на 32-битных платформах — детали реализации gc. Они стабильны годами, но если вам важна точность (например, структура мапится на файл), проверяйте unsafe.Sizeof и unsafe.Offsetof в тестах.Конструктор структур
Соберите свою структуру из типов-«кирпичиков» или возьмите пример. Двигайте поля кнопками ↑ и ↓ и смотрите, как меняется карта памяти. Красная штриховка — padding между полями, жёлтая — хвостовой padding. Ниже появятся расчёт, размер, класс аллокатора и цена на заданном количестве объектов.
| Поле | Тип | size | align | Конец пред. | Padding | Offset | Конец |
|---|
Что стоит попробовать
- Выберите «Пример из истории» и нажмите «Отсортировать по убыванию align»: 32 → 16 байт.
- Переключите платформу на 32-бит. Размер изменился —
int64теперь выровнен по 4, аstringзанимает 8 байт. - Добавьте
struct{}последним полем: размер вырастет на выравнивание. Это особенностьgc(см. ниже). - Выберите «Профиль пользователя» и посмотрите на строку «GC просматривает» до и после пункта «Указатели вперёд».
- Сравните «в срезе» и «по одному в куче»: во втором случае работают классы размера аллокатора.
Как оптимизировать
Алгоритм прост и не требует ничего, кроме перестановки строк:
- Сортируйте поля по убыванию выравнивания: сначала 8-байтные (
int64, указатели,string, срезы, интерфейсы), потом 4-байтные, потом 2-байтные, потом одиночные байты иbool. - Внутри одной группы можно выбирать порядок по смыслу.
- Если есть указатели, ставьте их в начало (см. ниже про GC).
// до: 40 байт
type Profile struct {
Active bool // 0, затем 7 байт padding
ID int64 // 8
Age int8 // 16, затем 7 байт padding
Name string // 24
}
// после: 32 байта
type Profile struct {
ID int64 // 0
Name string // 8
Age int8 // 24
Active bool // 25, хвост до 32
}Указатели и сборщик мусора
Сборщик мусора Go должен знать, где в объекте лежат указатели. Он просматривает объект от начала только до последнего указателя — хвост без указателей пропускается. Поэтому в структуре с указателями выгодно ставить указатели в начало, а большие «скалярные» поля (буферы, числа) — в конец.
// GC просматривает всю структуру: указатель в самом конце
type A struct {
Buf [1024]byte
Next *A
}
// GC просматривает только первые 8 байт
type B struct {
Next *A
Buf [1024]byte
}Анализатор fieldalignment умеет сообщать об обоих выигрышах: что структуру можно сделать меньше и что можно сократить количество «байтов указателей», которые просматривает GC.
Где это важно
Много однотипных структур
Миллионы записей в []Event лежат подряд. Размер элемента умножается на количество, и каждый лишний байт стоит гигабайты. Плотные структуры к тому же лучше помещаются в кэш: при 16 байтах в линию влезает 4 элемента, при 32 — только 2.
Миллионы мелких аллокаций
Узлы деревьев, графов, элементы списков. Размер объекта определяет класс аллокатора и, следовательно, реальные затраты. Выигрыш от экономии байтов здесь тоже в нагрузке на GC.
Параллельные записи
Если разные ядра пишут в соседние поля одной кэш-линии, они «отбирают» её друг у друга. Здесь нужен не сжатый, а раздутый padding. См. False sharing.
Файлы, сеть, cgo
Раскладка Go не равна форматам на диске или в сети. Для сериализации используйте encoding/binary или явные кодеки, а для C-структур — тот же порядок и размеры, что в заголовке C.
Классы размеров аллокатора
Когда вы создаёте объект в куче (new(T), &T{}), аллокатор округляет запрошенный размер вверх до одного из классов размеров: 8, 16, 24, 32, 48, 64, 80, 96, 112, 128, 144, 160, … байт (полный список в runtime/sizeclasses.go, он может меняться между версиями). Поэтому объект на 33 байта реально занимает 48, а на 49 — 64.
Отдельный случай — tiny allocator: объекты размером меньше 16 байт без указателей могут упаковываться в общие 16-байтные блоки. Это ещё одна причина, по которой маленькие структуры из скаляров обходятся дёшево.
False sharing: когда padding нужен
Ядра процессора хранят данные в кэш-линиях по 64 байта. Если два ядра пишут в разные переменные, лежащие в одной линии, процессоры вынуждены постоянно пересылать линию друг другу — это называется false sharing (ложное разделение). Логически переменные независимы, но аппаратно они связаны.
import "golang.org/x/sys/cpu"
// счётчики разных горутин не делят кэш-линию
type Counters struct {
a atomic.Int64
_ cpu.CacheLinePad
b atomic.Int64
_ cpu.CacheLinePad
}cpu.CacheLinePad подобран под архитектуру. Если не хочется тянуть зависимость, можно задать padding вручную: _ [56]byte после 8-байтного поля даёт ровно 64 байта. Но 64 — это типичное значение, а не гарантия: на некоторых процессорах линия больше.sync/atomic и 32 бита
Атомарные операции над 64-битными значениями требуют выравнивания по 8 байт. На 64-битных платформах это выполняется автоматически, а вот на 386, arm и 32-битном mips int64 выровнен только по 4. В документации sync/atomic сказано, что на этих платформах за выравнивание таких слов отвечает вызывающий код. Типы atomic.Int64 и atomic.Uint64 решают проблему: компилятор выравнивает их по 8 байт сам.
type Stats struct {
ready bool
hits atomic.Int64 // безопасно на всех платформах
}
// старый стиль: int64 должен быть первым полем структуры,
// выделенной через new или обычную переменную
type Legacy struct {
hits int64
ready bool
}Пустое поле в конце структуры
Структура из нулевых полей имеет размер 0, а пустые поля в середине размера не добавляют. Но если пустое поле стоит последним, компилятор gc добавляет после него байт (с последующим округлением), чтобы указатель на это поле не указывал за пределы объекта и не удерживал соседний объект от сборки.
type A struct {
n int64
_ struct{} // в конце: размер 16, а не 8
}
type B struct {
_ struct{} // в начале: размер 8
n int64
}
fmt.Println(unsafe.Sizeof(A{}), unsafe.Sizeof(B{})) // 16 8Это ещё одна причина ставить нулевые поля (_ struct{}, маркеры noCopy) в начало структуры.
А что в Go 1.27
Прямо отвечу на вопрос, который напрашивается: правила выравнивания и раскладки структур в Go 1.27 не изменились. Спецификация по-прежнему гарантирует таблицу размеров числовых типов и три свойства выравнивания, а в релиз-нотах нет ничего о смене размеров стандартных типов. Код, который проверяет unsafe.Sizeof, ведёт себя как в предыдущих версиях.
Зато есть изменение рядом, которое делает размер структуры ещё более ощутимым. Из релиз-нот Go 1.27 (пересказ с английского):
Компилятор теперь генерирует вызовы специализированных по размеру процедур выделения памяти, что снижает стоимость некоторых малых (<80 байт) аллокаций на величину до 30%. Улучшения зависят от нагрузки, но в реальных программах с интенсивным выделением памяти ожидается около 1%. Размер бинарного файла вырастает примерно на 60 КБ.
- Что это значит для структур. Объекты меньше 80 байт попадают в «быстрый» путь. Если вы сжали структуру с 88 до 72 байт, она оказывается в числе малых аллокаций из релиз-нот. Выигрыш складывается с экономией памяти.
- Чего не стоит ждать. Это ускоряет выделение, а не доступ к полям; средний выигрыш на реальных программах авторы оценивают в районе 1%.
- Выключатель. Поведение отключается переменной
GOEXPERIMENT=nosizespecializedmallocпри сборке. По релиз-нотам, этот выключатель планируется убрать в Go 1.28. - Рядом. У
go fixпоявились новые «модернизаторы», в том числеunsafefuncs. Подробности про него в релиз-нотах не раскрываются, поэтому за описанием смотрите справкуgo fix.
Как проверить на своём коде
import (
"fmt"
"reflect"
"unsafe"
)
type Event struct {
Seen bool
UserID int64
Kind bool
Score float32
Fresh bool
}
func main() {
var e Event
// unsafe.Sizeof, Alignof, Offsetof — константы времени компиляции
fmt.Println("size:", unsafe.Sizeof(e), "align:", unsafe.Alignof(e))
fmt.Println("UserID offset:", unsafe.Offsetof(e.UserID))
// то же самое через reflect — для любого типа в рантайме
t := reflect.TypeOf(e)
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%-7s off=%2d size=%d align=%d\n",
f.Name, f.Offset, f.Type.Size(), f.Type.Align())
}
}# анализатор fieldalignment (в go vet по умолчанию не входит)
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...
# event.go:3:12: struct of size 32 could be 16
# автоматически переставить поля
fieldalignment -fix ./...fieldalignment -fix переписывает объявление структуры автоматически и может потерять комментарии рядом с полями, а ещё ломает порядок, важный для читателя. Запускайте его на отдельной ветке и просматривайте диф. А ещё лучше — запускайте без -fix в CI как предупреждение для горячих структур.Ловушки и мифы
- «Компилятор сам переставит поля». Нет. Go раскладывает поля в порядке объявления и никогда их не переставляет (в отличие, например, от Rust). Порядок — ваша ответственность.
- «Надо оптимизировать каждую структуру». Нет. Для структур, которых в программе единицы или сотни, это бессмысленно. Считайте выгоду:
экономия на объект × количество. - «Читаемость не страдает». Страдает. Логически связанные поля (
LatиLng) могут оказаться далеко друг от друга. Группируйте по смыслу внутри групп одинакового выравнивания и комментируйте причину. - Порядок полей — часть API. Для публичных типов перестановка меняет позиционные литералы (
T{1, 2}), поведениеreflect-кода и порядок вывода некоторых форматов. Проверьте, не ломаете ли вы пользователей. - Платформы. Размер, посчитанный на
amd64, не равен размеру на386. Если нужны точные цифры для обеих — тесты сunsafe.Sizeofпод каждую платформу. - Сжатие структур против false sharing. Для горячих параллельных счётчиков нужен обратный приём — преднамеренный padding.
- Приведение через unsafe. Читать байты как структуру (и наоборот) через
unsafe.Pointerопасно: раскладка зависит от платформы, а padding содержит произвольные значения. - Не измерено — не оптимизировано. Подтверждайте эффект измерением памяти (
runtime.MemStats,pprof) и бенчмарками на реальных данных.
Сводная таблица приёмов и их побочных эффектов
| Приём | Что делает | Плюсы | Минусы и побочные эффекты | Когда применять |
|---|---|---|---|---|
| Сортировка полей по убыванию align | Убирает padding между полями, оставляет только хвостовой | Меньше памяти, больше элементов в кэш-линии, меньше нагрузка на GC | Ухудшает читаемость, может изменить порядок вывода в reflect и позиционные литералы | Структуры, которых миллионы; горячие данные |
| Указатели в начало структуры | Сдвигает последний указатель к началу объекта | GC просматривает меньше байт | Конфликтует с группировкой по смыслу | Крупные объекты с буферами в хвосте |
Нулевые поля (struct{}) в начало | Избегает добавочного байта в конце | Убирает до 8 лишних байт | Почти нет | Маркеры _ struct{}, noCopy |
Сужение типов (int8 вместо int) | Уменьшает размер самого поля | Меньше размер; даёт плотную упаковку после сортировки | Риск переполнения, лишние приведения типов | Когда диапазон значений известен и мал |
| Упаковка флагов в битовое поле | Несколько bool → один uint8/uint32 | До 8 флагов в одном байте | Код сложнее, нужны маски и методы | Десятки флагов; миллионы объектов |
| Padding до кэш-линии | Разносит горячие поля по разным линиям | Убирает false sharing | Увеличивает размер в разы; зависит от процессора | Счётчики и очереди, в которые пишут разные ядра |
atomic.Int64 вместо int64 | Гарантирует 8-байтное выравнивание | Корректность на 32-битных платформах | Почти нет | Всегда при атомарных операциях |
| Массив вместо указателей на элементы | Хранит значения подряд, а не россыпью в куче | Локальность данных, меньше аллокаций | Копирование больших значений | Много мелких однотипных элементов |
Плюсы и минусы выравнивания как такового
Плюсы
- Быстрый доступ без склейки чтений
- Значения не пересекают границу кэш-линии
- Корректные атомарные операции
- Предсказуемая раскладка массивов структур
Минусы
- Лишняя память на padding
- Порядок полей влияет на размер
- Раскладка зависит от платформы
- Оптимизация размера снижает читаемость
Проверь себя
Четыре вопроса на раскладку. Пользуйтесь конструктором, если хочется проверить расчёт.
Катя написала в чате команды короткое правило: «Структуры, которых в программе миллионы, — поля по убыванию размера и комментарий над типом. Остальные — как удобно читать». И подключила fieldalignment в CI как предупреждение только для пакета с горячими данными. Сервис с тех пор занимает вдвое меньше памяти, а ревью не превратилось в спор о порядке полей.
Шпаргалка
offset = roundUp(конец_предыдущего, align_поля),size = roundUp(конец_последнего, align_структуры).- Выравнивание структуры — максимум из выравниваний её полей.
- Расставляйте поля по убыванию выравнивания; указатели — вперёд.
string— 16 байт, срез — 24, интерфейс — 16, указатель — 8 (на 64-бит).- Go не переставляет поля сам.
struct{}в конце добавляет padding. - В Go 1.27 раскладка не менялась; малые (<80 байт) аллокации стали дешевле.
- Для параллельных счётчиков — наоборот, разносите по кэш-линиям.
Полезные материалы
Ссылки ведут на первоисточники: спецификацию языка, документацию пакетов и релиз-ноты Go 1.27.
- Спецификация Go: Size and alignment guarantees — гарантированные размеры и три правила выравнивания
- Спецификация Go: Package unsafe —
Sizeof,Alignof,Offsetofкак константы - Пакет unsafe — документация функций и правила использования
- reflect.Type —
Size,Align,FieldAlign,StructField.Offset - Go 1.27 Release Notes — аллокация малых объектов,
GOEXPERIMENT=nosizespecializedmalloc - Go 1 compatibility promise — на что можно рассчитывать при смене версий
- fieldalignment — анализатор, который находит структуры, которые можно сделать меньше
- sync/atomic: заметка о выравнивании — 64-битные слова на 32-битных платформах
- cpu.CacheLinePad — padding под размер кэш-линии платформы
- runtime/sizeclasses.go — актуальный список классов размеров аллокатора
- runtime/malloc.go — аллокатор, tiny allocator и описание размерных классов
- A Guide to the Go Garbage Collector — как GC работает с объектами и указателями