Разбираемся с такими понятиями, как: type alignment guarantees, type sizes и structure padding.
Процессор и память
Упрощенное представления взаимодействия процессора и памяти. Память имеет адресную байтовую последовательность и расположена последовательно. Чтение или запись данных в памяти выполняется посредством операций, которые воздействуют на одну ячейку за раз. Чтобы прочитать ячейку памяти или произвести запись в нее, мы должны передать ее числовой адрес. Память способна выполнять с адресом ячейки две операции: получить хранящееся в ней данные или записать новые. Память имеет специальный входной контакт для установки ее рабочего режима.
Группа проводов(линий), используемых для передачи одинаковых данных, называется шиной. Для передачи адреса используется адресная шина. А шина данных позволяет получать или записывать данные.
Основной характеристикой адресной шины является её ширина в битах, что, как правило, равно максимально допустимому числу разрядов адреса.
У 32-разрядной шины каждый байт имеет 32-битный адрес, что позволит использовать 4Гб адресное пространство(2³²). С 64-битным адресом можно использовать до 2⁶⁴ байт.
При 32-разрядной шине за раз можно получить до 4 байта данных, а 64-разрядная шина позволяет прочитать сразу 8 байт. Такие куски информации принято называть машинным словом.
Компьютеры наиболее эффективно загружают и сохраняют значения в памяти, когда эти значения выравнены(alignment).
Адрес 2-байтового типа(int16) должен быть кратен 2, адрес 4-байтового значения(int32) должен быть кратен 4, а 8-байтового соответственно кратен 8.
Пример. У нас 64-разрядная архитектура, что позволяет за один раз прочитать до 8 байт. Сохраняем одно 1-байтовое значение и три 2-байтовых значения:
Один байт храниться по адресу 0, а 2-байтовые — 2, 4, 6 (кратные 2). Благодаря выравниванию не будет ситуации, когда придется делать два цикла чтения для получения одного значения:
Гарантии выравнивания типов (type alignment guarantees) также называют гарантиями выравнивания адресов(value address alignment guarantees). Если гарантией выравнивания типа T является N, то адрес каждого значения типа T должен быть кратным N во время выполнения. Можно также сказать, что адреса адресуемых значений типа T гарантированно выровнены по N-байтам.
Размеры типов
К счастью, выравнивания и многое другое, гарантирует компилятор, и нам не нужно об этом беспокоиться. Но полезно помнить, что выравнивания делает обращение к памяти более эффективное, но вот использование памяти может стать менее эффективным.
Вспомним первый пример. В 8 байтах хранится одно 1-байтовое значение и три 2-байтовых. Из-за выравнивания (у 2-байтового значение должен быть четный адрес) ячейка памяти по адресу 1 осталась свободной. Но если для переменных с простым типов компилятор использует оптимизации, то для структур выравнивание происходит по самому большому полю и может образоваться большое кол-во занятой, но не использованной памяти.
Рассмотрим структуру:
type someData struct{
a int8 // 1 byte
b int64 // 8 byte
c int8 // 1 byte
}
Размер значения этой структуры должен составлять суму ее полей. 1 + 8 + 1 =10 байт. Проверяем это утверждение:
v := someData{}
typ := reflect.TypeOf(v)
fmt.Printf("Type someData is %d bytes long\n", typ.Size())
Результат совсем не тот, который ожидали. Структура занимает почти в 2.5 раза больше:
Type someData is 24 bytes long
Посмотрим подробней, что происходит:
n := typ.NumField()
for i := 0; i < n; i++ {
field := typ.Field(i)
fmt.Printf("%s at offset %v, size=%d, align=%d\n",
field.Name, field.Offset, field.Type.Size(),
field.Type.Align())
}
fmt.Printf("someData align is %d\n", typ.Align())
Offset — это смещение адреса поля в значении структуры. Size — размер поля. Align — выравнивание для типа поля.
a at offset 0, size=1, align=1
b at offset 8, size=8, align=8
c at offset 16, size=1, align=1
someData align is 8
Почему такой результат. В общем случае экземпляр структуры будет выровнен по самому длинному элементу. Для компилятора это самый простой способ убедиться, что все поля структуры будут также выровнены для быстрого доступа.
Неиспользованная память заполняется(padding) нулями.
Поле v.a занимает всего один байт, но следующее поле начинается только через 8 байт (b at offset 8). 7 байт просто не используется.
Посмотрим подробней как это выглядит в памяти:
v = someData{a:1,b:2,c:3}
b := (*[24]byte)(unsafe.Pointer(&v))
fmt.Printf("Bytes are %#v\n", b)
Теперь поле a=1, b=2, c=3. Значение структуры представили как массив байт:
Bytes are &[24]uint8{
0x1, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x3, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}
Компилятор не меняет порядок полей в структуре и не может оптимизировать такие случае. А мы можем.
Смежные поля могут быть объедены, если их сумма не превышает выравнивания структуры.
type someDataV2 struct{
a int8 // 1 byte
c int8 // 1 byte
b int64 // 8 byte
}
Переместив поля, можно уменьшить размер структуры до 16 байт.
Type someDataV2 is 16 bytes long
a at offset 0, size=1, align=1
c at offset 1, size=1, align=1
b at offset 8, size=8, align=8
someDataV2 align is 8
Bytes are &[16]uint8{
0x1, 0x3, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0,
0x2, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0}
6 байт все еще не используются, но с этим уже ничего не поделаешь. Туда всегда можно добавить данных до 6 байт, не изменив размер структуры.
Почти всегда, гораздо важней читаемость кода, чем такие оптимизации. Нужно понимать по какой причины у значения типа именно такой размер и что вообще происходит, а уже в случае необходимости заниматься оптимизацией.
Линтеры, которые могут помочь:
Код примеров play.golang или github