Олег Ильин oo-ilin.ru
← Все статьи

Выравнивание данных в структурах Go: почему 15 байт весят 32

Олег Ильин Олег Ильин · 22 окт 2026 · 20 мин

Сложите размеры полей структуры, вызовите unsafe.Sizeof — и получите число почти вдвое больше. Это не баг и не магия, а выравнивание данных. Разберём, откуда берутся «лишние» байты, почему компилятор Go их добавляет, как их посчитать руками и когда их стоит убрать. По ходу есть конструктор структур, где вы соберёте свою раскладку и увидите каждый байт.

Статья написана под Go 1.27 (релиз — август 2026). Числа даны для 64-битных платформ (amd64, arm64), для 32-битных есть отдельные столбцы и переключатель в конструкторе. Все размеры относятся к компилятору gc.

Структура на 15 байт весит 32

Катя пишет сервис, который держит в памяти 40 миллионов событий для быстрой выдачи. Структура у неё самая обычная:

event.gogo
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-байтное значение никогда не пересечёт границу линии. Невыровненное — может, и тогда для него нужны две линии.
  • Атомарность. Атомарные инструкции работают только с выровненными значениями. Невыровненный доступ либо не атомарен, либо вообще вызывает аппаратную ошибку на части архитектур.
  • Простота железа. Требование «адрес кратен размеру» упрощает схемы выборки; поэтому на ряде архитектур невыровненный доступ был запрещён или заметно медленнее.
Выровненное и невыровненное значение относительно кэш-линии Две соседние кэш-линии по 64 байта линия 0: ... байт 63 линия 1: байт 64 ... 48 56 64 72 80 Выровненный int64, адрес 56: целиком в линии 0 int64: байты 56–63 Невыровненный int64, адрес 60: пересекает границу int64: байты 60–67 4 байта в линии 0 + 4 байта в линии 1 = нужны две линии кэша
Go не оставляет вам выбор: компилятор сам расставляет поля по выровненным адресам и сам считает размер. Вам остаётся управлять порядком полей. Отсюда и история Кати — порядок в Go влияет на размер.

Правила в Go

Спецификация Go гарантирует немного. Размеры числовых типов фиксированы, а для выравнивания даны три минимальных свойства:

  1. Для переменной любого типа unsafe.Alignof(x) — не меньше 1.
  2. Для переменной структурного типа Alignof(x) — наибольшее из значений Alignof(x.f) по всем полям, но не меньше 1.
  3. Для массива Alignof(x) равно выравниванию его элемента.

Остальное — детали реализации компилятора gc, но на практике они стабильны для всех популярных платформ. По сути, действует один принцип: выравнивание типа — это его «естественное» выравнивание, то есть размер самого крупного скалярного компонента (с ограничением размером машинного слова).

Алгоритм раскладки

Компилятор раскладывает поля строго в том порядке, в котором вы их объявили (Go никогда не переставляет поля сам). Для каждого поля:

  1. offset = roundUp(конец_предыдущего_поля, align_поля) — смещение округляется вверх до кратного выравниванию;
  2. разница между смещением и концом предыдущего поля — это padding перед полем;
  3. после последнего поля: size = roundUp(конец_последнего, align_структуры). Хвостовой padding нужен, чтобы в массиве [N]T каждый следующий элемент тоже начинался с выровненного адреса.

Пройдём этим алгоритмом по структуре Кати (64-бит):

ПолеТипsizealignКонец пред.PaddingOffsetКонец
Seenbool110001
UserIDint648817816
Kindbool111601617
Scorefloat32441732024
Freshbool112402425
Хвост: align структуры = 8, roundUp(25, 8)7size = 32

Итого 17 байт padding на 15 байт данных. Теперь та же структура с полями по убыванию выравнивания:

Карта памяти Event: до и после ДО: Seen, UserID, Kind, Score, Fresh — 32 байта ПОСЛЕ: UserID, Score, Seen, Kind, Fresh — 16 байт UserID Score bool-поля padding
event.gogo
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 / byte1111один байт. Для bool размер гарантирует реализация
int16, uint162222
int32, uint32, float32, rune4444
int64, uint64, float648884на 386 и arm 64-битные значения выровнены по 4
int, uint, uintptr8844размер машинного слова
complex648484два float32
complex128168164два float64
указатель *T, map, chan, func8844одно слово: указатель на данные или на структуру-заголовок
string16884указатель на байты + длина (2 слова)
срез []T248124указатель + длина + ёмкость (3 слова)
интерфейс (any, error)16884указатель на тип или itab + указатель на данные. Подробнее: Интерфейсы в Go 1.26
массив [N]TN × size(T)align(T)N × size(T)align(T)элементы подряд; padding внутри нет, он уже учтён в size(T)
struct{}0101нет полей — нет размера
time.Time248204wall uint64, ext int64, loc *Location
time.Duration8884это int64
sync.Mutex8484два 32-битных слова. Проверьте через unsafe.Sizeof на своей версии
Спецификация гарантирует только таблицу размеров числовых типов и три правила выше. Размеры int, string, срезов и интерфейсов, а также выравнивание 64-битных значений на 32-битных платформах — детали реализации gc. Они стабильны годами, но если вам важна точность (например, структура мапится на файл), проверяйте unsafe.Sizeof и unsafe.Offsetof в тестах.

Конструктор структур

Соберите свою структуру из типов-«кирпичиков» или возьмите пример. Двигайте поля кнопками ↑ и ↓ и смотрите, как меняется карта памяти. Красная штриховка — padding между полями, жёлтая — хвостовой padding. Ниже появятся расчёт, размер, класс аллокатора и цена на заданном количестве объектов.

Добавить поле
Поля (порядок объявления)
Карта памяти (по 8 байт в строке)
Расчёт по шагам
ПолеТипsizealignКонец пред.PaddingOffsetКонец
Что получилось в коде
layout.gogo

Что стоит попробовать

  1. Выберите «Пример из истории» и нажмите «Отсортировать по убыванию align»: 32 → 16 байт.
  2. Переключите платформу на 32-бит. Размер изменился — int64 теперь выровнен по 4, а string занимает 8 байт.
  3. Добавьте struct{} последним полем: размер вырастет на выравнивание. Это особенность gc (см. ниже).
  4. Выберите «Профиль пользователя» и посмотрите на строку «GC просматривает» до и после пункта «Указатели вперёд».
  5. Сравните «в срезе» и «по одному в куче»: во втором случае работают классы размера аллокатора.

Как оптимизировать

Алгоритм прост и не требует ничего, кроме перестановки строк:

  1. Сортируйте поля по убыванию выравнивания: сначала 8-байтные (int64, указатели, string, срезы, интерфейсы), потом 4-байтные, потом 2-байтные, потом одиночные байты и bool.
  2. Внутри одной группы можно выбирать порядок по смыслу.
  3. Если есть указатели, ставьте их в начало (см. ниже про GC).
до.go → после.gogo
// до: 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 (ложное разделение). Логически переменные независимы, но аппаратно они связаны.

Модель считает, сколько раз кэш-линию приходится «перехватывать» у другого ядра. Это число передач, а не время: на реальном железе оно превращается в задержки.

padding.gogo
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.

Как проверить на своём коде

inspect.gogo
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())
    }
}
terminalsh
# анализатор 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.

Читать дальше