25. Выравнивание данных, Padding и Struct Layout

В предыдущих главах (в частности, в 24. Барьеры памяти. Memory Fence, Acquire, Release) мы оперировали абстрактными одиночными переменными, наблюдая за тем, как их поведение дисциплинируют компилятор и аппаратные барьеры.

Однако в реальном коде на Go переменные редко живут поодиночке. Мы группируем их в структуры данных — struct.

На первый взгляд кажется, что структура — это элементарный контейнер: сложили три переменной, сложили размеры их типов и получили итоговый размер объекта в памяти.
Но попробуйте объявить в Go структуру struct { a bool; b int64; c bool }:

Go
type Sample struct {
    a bool  // 1 байт
    b int64 // 8 байт
    c bool  // 1 байт
}

Арифметика начальной школы подсказывает ответ: $1 + 8 + 1 = 10\text{ байт}$.
Однако функция unsafe.Sizeof(Sample{}) ошарашит вас вердиктом: 24 байта!

Куда делись еще 14 байт? Компилятор Go намеренно раздул вашу структуру мусорными байтами.

Чтобы понять, почему он это сделал, и почему небрежное проектирование структур способно заметно увеличить расходы на серверную оперативную память и ухудшить плотность данных в кэше L1, нам нужно исследовать феномен Аппаратного выравнивания данных (Data Alignment).


Механика железа: Почему процессоры ненавидят невыровненные данные

В учебниках по программированию оперативную память часто изображают в виде длинной ленты из отдельных байтов, где каждый байт имеет свой порядковый номер: 0, 1, 2, 3, 4...

Но с точки зрения контроллера памяти и кремниевой шины процессора память устроена иначе. Физическая шина данных современного 64-битного процессора имеет ширину в 8 байт (64 бита). Процессор забирает данные из кэша и памяти не побайтово, а целыми «машинными словами», адреса которых строго кратны 8: 0x00, 0x08, 0x10, 0x18 и так далее.

DIAGRAM
Выровненная память (машинные слова по 8 байт):
Слово 0: [0x00] [0x01] [0x02] [0x03] [0x04] [0x05] [0x06] [0x07]  <── 1 чтение
Слово 1: [0x08] [0x09] [0x0A] [0x0B] [0x0C] [0x0D] [0x0E] [0x0F]  <── 1 чтение

Невыровненный int64 (начиная с адреса 0x03):
Слово 0: [ .  ] [ .  ] [ .  ] [ D0 ] [ D1 ] [ D2 ] [ D3 ] [ D4 ]  <── Первое чтение + сдвиг
Слово 1: [ D5 ] [ D6 ] [ D7 ] [ .  ] [ .  ] [ .  ] [ .  ] [ .  ]  <── Второе чтение + маска + склейка!

Представьте, что 8-байтное число типа int64 расположилось в памяти по невыровненному адресу — например, начиная с адреса 0x03.

В этом случае число оказывается разорванным пополам: 5 его байтов попадают в машинное слово 0x00, а оставшиеся 3 байта — в машинное слово 0x08. А если адрес пришелся на границу 64-байтной кэш-линии (о которых мы говорили в 18. Кэши CPU. L1, L2, L3 и Cache Line), то половинки числа окажутся в двух разных кэш-линиях!

Чтобы прочитать такое невыровненное значение, процессор вынужден проделать тяжелую работу:

  1. Запросить из кэша первое машинное слово по адресу 0x00.
  2. Запросить второе машинное слово по адресу 0x08.
  3. Применить побитовые маски и сдвиги, чтобы отсечь лишние байты.
  4. Склеить две половины в один 64-битный регистр.

На процессорах архитектуры x86-64 контроллер выполняет эту операцию аппаратно: если обращение целиком укладывается в одну кэш-линию, штраф минимален или нулевой; заметно дороже обходятся обращения, пересекающие границу кэш-линии (Unaligned Access Penalty). AArch64 тоже допускает невыровненный доступ для обычной памяти. Но на ряде других архитектур и режимов (старые ARM, RISC-V без поддержки невыровненного доступа, SPARC, MIPS и т.п.) невыровненный доступ запрещён: процессор вызовет исключение (Bus Error / SIGBUS), и программа аварийно завершится. Поэтому компилятор Go выравнивает данные всегда — ради переносимости и эффективности.


Padding (Заполнение): Компромисс компилятора

Чтобы процессор всегда работал на пике скорости и никогда не спотыкался о разорванные слова, компилятор Go неукоснительно соблюдает Правила аппаратного выравнивания (Alignment Rules):

  • Значение размером 1 байт (byte, bool, int8) может лежать по любому адресу (кратно 1).
  • Значение размером 2 байта (int16, uint16) обязано лежать по адресам, кратным 2.
  • Значение размером 4 байта (int32, float32) обязано лежать по адресам, кратным 4.
  • Значение размером 8 байт (int64, float64, указатель в 64-битной системе) обязано лежать по адресам, кратным 8.

Уточнение: правило «размер N байт → адрес кратен N» справедливо для этих базовых типов, но не универсально. Выравнивание — это отдельная характеристика типа (unsafe.Alignof), и оно не всегда равно размеру. Например, на amd64 у complex128 (16 байт), строки (16 байт) и слайса (24 байта) выравнивание равно 8, у массива — выравнивание его элемента, а у структуры — наибольшее выравнивание её полей. На 32-битных платформах многие 8-байтные значения выровнены лишь по 4.

Когда компилятор конструирует структуру, он размещает её поля строго в том порядке, в котором их объявил программист. Если очередное поле не попадает на адрес, кратный его размеру, компилятор молча заполняет пустоту фиктивными байтами — этот механизм называется Padding (Выравнивающее заполнение).

Go
package main

import (
    "fmt"
    "unsafe"
)

type BadStruct struct {
    A bool  // 1 байт.  Смещение в структуре: 0.
            // Следующее поле int64 требует адреса, кратного 8!
            // Компилятор вставляет 7 байт мусора (Padding)!
    B int64 // 8 байт.  Смещение в структуре: 8. Занимает байты 8..15.
    C bool  // 1 байт.  Смещение в структуре: 16.
            // Хвостовой паддинг: 7 байт! Занимает байты 17..23.
}

func main() {
    fmt.Printf("Размер BadStruct: %d байт\n", unsafe.Sizeof(BadStruct{}))
    // Напечатает: 24 байта!
}

Struct Layout: Как писать правильно

Зная законы выравнивания, инженер может существенно сэкономить память без изменения бизнес-логики программы. Для этого достаточно оптимизировать порядок объявления полей.

Золотое правило раскладки структур (Struct Layout):

Сортируйте поля структуры по убыванию их размера:
сначала 8-байтные поля (указатели, int64, float64), затем 4-байтные (int32), затем 2-байтные (int16) и в самом конце — 1-байтные (bool, byte). (Для скаляров размер совпадает с выравниванием, поэтому правило работает; с массивами и вложенными структурами сортировать нужно по выравниванию, а не по размеру.)

Перепишем нашу структуру:

Go
package main

import (
    "fmt"
    "unsafe"
)

type GoodStruct struct {
    B int64 // 8 байт. Смещение: 0..7
    A bool  // 1 байт. Смещение: 8
    C bool  // 1 байт. Смещение: 9
            // 6 байт хвостового паддинга для массива.
}

func main() {
    fmt.Printf("Размер GoodStruct: %d байт\n", unsafe.Sizeof(GoodStruct{}))
    // Напечатает: 16 байт вместо 24!
}
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Bad["BadStruct — 24 байта (14 байт мусора)"]
        direction LR
        A1["A<br>1 байт"] --- P1["Padding<br>7 байт"] --- B1["B<br>8 байт"] --- C1["C<br>1 байт"] --- P2["Padding<br>7 байт"]
    end

    subgraph Good["GoodStruct — 16 байт (всего 6 байт паддинга)"]
        direction LR
        B2["B<br>8 байт"] --- A2["A<br>1 байт"] --- C2["C<br>1 байт"] --- P3["Padding<br>6 байт"]
    end
  

Mechanical Sympathy: Плотность кэша L1

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

Если ваш сервис загружает из PostgreSQL или генерирует в памяти слайс на 10 миллионов записей []User, плохой порядок полей приведет к перерасходу:
$$10\,000\,000 \times 8\text{ байт} = 80\text{ Мегабайт памяти впустую!}$$

Но финансовые затраты на RAM — лишь верхушка айсберга. Главный урон наносится кэш-памяти процессора:

  • Размер аппаратной кэш-линии равен 64 байтам.
  • В одну кэш-линию помещается ровно 4 объекта GoodStruct ($4 \times 16 = 64$).
  • Но в ту же самую кэш-линию в среднем поместится лишь около 2.7 объекта BadStruct ($64 / 24 \approx 2.67$; часть объектов будет пересекать границу линий).

Итог: при обходе среза с плохой раскладкой полей процессор будет генерировать примерно в 1.5 раза больше Cache Misses ($4$ объекта на линию против $\approx 2.7$), заставляя вычислительные ядра простаивать в ожидании медленной памяти DRAM заметно дольше.


Ловушки и корнер-кейсы в Go

Язык Go таит несколько любопытных архитектурных особенностей, связанных с выравниванием, о которых обязан помнить проектировщик систем.

1. Пустая структура (Empty Struct) в конце

Пустая структура struct{} имеет размер строго 0 байт. Это излюбленный инструмент разработчиков на Go для создания множеств (map[string]struct{}) и каналов уведомлений (chan struct{}).

Но если поле типа struct{} оказывается самым последним полем в структуре, происходит нечто неожиданное: оно раздувает структуру!

Go
package main

import (
    "fmt"
    "unsafe"
)

type User struct {
    Age int64
    Set struct{} // Если поле последнее, структура займет 16 байт!
}

func main() {
    fmt.Println(unsafe.Sizeof(User{})) // Выведет: 16 байт!
}

2. Выравнивание 64-битных атомиков на 32-битных системах

До эпохи Go 1.19 использование 64-битных счетчиков пакета sync/atomic на 32-битных архитектурах (ARMv7 в микроконтроллерах, 32-битный x86 или MIPS в роутерах) было источником внезапных аварийных падений:

TEXT
panic: unaligned 64-bit atomic operation

Причина была чисто аппаратной: на 32-битной платформе ширина машинного слова составляет 4 байта. Выравнивание для обычного поля int64 по умолчанию равнялось 4 байтам.
Обычный код работал корректно (процессор читал 8 байт за два такта). Однако на ряде 32-битных платформ атомарные операции над 64-битным значением требуют строгого 8-байтного выравнивания (на x86-32 инструкция LOCK CMPXCHG8B его формально не требует). Стоило переменной оказаться по адресу, кратному 4, но не кратному 8 — атомарная операция завершалась аварийно (в Go — паникой после программной проверки).

Чтобы обойти это, в старых версиях Go приходилось вручную выравнивать структуры или внедрять буферные массивы [15]byte.

Современное решение:
Начиная с Go 1.19, всегда используйте тип atomic.Int64. Рантайм и компилятор гарантируют, что значение типа atomic.Int64 выровнено по 8-байтной границе на любой архитектуре: для этого в определении типа стоит специальный маркер align64, который компилятор распознаёт отдельно (это не вызов unsafe.Alignof). Причины паники в старых версиях — программная проверка Go на выравнивание перед атомарной операцией (на x86-32 сама инструкция CMPXCHG8B строгого выравнивания не требует, но на других 32-битных платформах — требует).


Автоматизация проверки (fieldalignment)

Вам вовсе не нужно сидеть с блокнотом и высчитывать смещения каждого байта вручную. Сообщество Go создало великолепный официальный анализатор — fieldalignment.

Это анализатор из репозитория golang.org/x/tools. Он не входит в стандартный набор go vet по умолчанию (его нужно запускать отдельно, как ниже, или включать как анализатор govet в golangci-lint, где его заменил прежний линтер maligned).

Заметим также: порядок полей влияет не только на размер. Для сборщика мусора важно, где заканчиваются поля-указатели («pointer bytes»), поэтому полезно ставить указатели в начало структуры, а поля, не содержащие указателей, — в конец. Установить и запустить fieldalignment можно так:

Bash
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment -fix ./...

Анализатор просканирует все структуры в вашем проекте, подсветит неэффективный расход памяти и при указании флага -fix самостоятельно переставит поля в ваших .go файлах, сэкономив вам мегабайты памяти на проде!


Итог

  1. Аппаратное выравнивание (Alignment) обусловлено шириной шины процессора: ядро запрашивает память выровненными машинными словами. Чтение невыровненных данных ведет к штрафам задержки или аварийному падению программы.
  2. Компилятор Go автоматически внедряет пустые байты (Padding), чтобы каждое поле структуры начиналось с адреса, кратного его размеру, а размер структуры был кратен её максимальному полю.
  3. Неаккуратный порядок полей (чередование bool и int64) раздувает структуры мусором, увеличивает потребление RAM и ухудшает плотность данных в L1-кэше (в нашем примере — примерно в 1.5 раза).
  4. Золотое правило: сортируйте поля от больших к меньшим (8 байт $\rightarrow$ 4 байта $\rightarrow$ 2 байта $\rightarrow$ 1 байт).
  5. Помните про корнер-кейсы: struct{} в самом конце структуры увеличивает её размер ради безопасности сборщика мусора, а для атомиков используйте тип atomic.Int64.

Мы разобрались, как данные размещаются и упаковываются в памяти. Но есть еще один фундаментальный вопрос, касающийся машинного слова.
Когда процессор записывает в память 4-байтное число 0x11223344, какой байт ляжет по первому, младшему адресу: старший байт 0x11 или младший байт 0x44?

Этому историческому спору «тупоконечников и остроконечников» посвящена завершающая глава нашего блока о памяти: 26. Endianness. Little Endian vs Big Endian.