В большинстве своём мы не думаем о том, как Go размещает поля структуры в памяти. И правильно делаем, поскольку пока наши структуры не используются в миллионах экземпляров, не передаются в каналах или не сериализуются каждую миллисекунду.
Неправильное выравнивание может негативно сказаться также на кеш-памяти процессора и скорости доступа к данным. На 32-битных платформах некорректное выравнивание 64-битных атомарных переменных (например, int64
для sync/atomic
) вообще способно привести к панике.
В этой статье разберём выравнивание структур в Go, рассмотрим примеры влияния порядка полей и подумаем над тем, когда такие оптимизации действительно нужны.
Весь код, тесты, результаты и команды можно посмотреть тут.
Принципы выравнивания и паддинга в Go
Выравнивание (alignment) — это требование, чтобы данные начинались с адреса в памяти, кратного их размеру, что позволяет CPU обращаться к ней за одну операцию. Для соблюдения выравнивания компилятор добавляет паддинг (нули) между полями структур.
Паддинг (padding) — это дополнительные байты, которые компилятор автоматически вставляет между полями структуры, чтобы соблюсти выравнивание по памяти (alignment). Эти байты не содержат полезных данных, они просто «заполняют» пустое место.
Давайте сразу рассмотрим пример:
type NonOptimized struct {
ByteA byte // 1 байт
Int int // 8 байт
ByteB byte // 1 байт
}
type Optimized struct {
Int int // 8 байт
ByteA byte // 1 байт
ByteB byte // 1 байт
}
У нас есть две структуры. Посмотрим их выравнивание, паддинг и итоговый размер, который они имеют. Для начала посчитаем: int в моей ОС приведётся к int64 из-за разрядности — ByteA (один байт), Int (восемь байтов), ByteB (один байт). Итого десять байтов.
Что нам на это скажет Go? Для этого напишем небольшой метод-помощник и запустим:
func PrintOffsets(t reflect.Type) {
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%-10s offset=%2d, size=%2d\n", f.Name, f.Offset, f.Type.Size())
}
fmt.Printf("Total size: %d\n", t.Size())
}
func main() {
fmt.Println("NonOptimized struct layout:")
PrintOffsets(reflect.TypeOf(NonOptimized{}))
fmt.Println()
fmt.Println("Optimized struct layout:")
PrintOffsets(reflect.TypeOf(Optimized{}))
fmt.Println()
}

Получилось не десять байтов, а 16, а при определённом порядке вообще 25 байтов. Давайте разбираться. Выравнивание выполняется по наибольшему размеру — int64 (это же один блок памяти в 64-разрядной системе, восемь байтов). Значит, на каждый атрибут структуры будет выделяться восемь байтов (выравнивание), а если структура занимает меньше, то заполнится нулями (паддинг).
Напрашивается вопрос: «Зачем структуры дополнительно потребляют память для выравнивания?»
Необходимо обратиться к взаимодействию процессора и памяти. Оперативная память представляет собой последовательность ячеек, каждая из которых имеет числовой адрес. Процессор обращается к этим ячейкам через адресную шину (для указания адреса) и шину данных (для чтения или записи значения).
Ключевая характеристика шины — разрядность:
32-битная система имеет адресное пространство в 2³² байтов
64-битная — 2⁶⁴ байтов
При этом машинное слово (word size) — это минимальный блок памяти, который процессор может эффективно обрабатывать за одну операцию. В 32-битной архитектуре это четыре байта, в 64-битной — восемь байтов.
А если размер типа больше, чем word size? Например, у нас 32-битная архитектура (word size — четыре байта), а значение — int64 (восемь байтов). В этом случае процессор не сможет прочитать int64 за один такт: он выполнит два чтения по четыре байта и потом объединит результаты. Но даже если значение больше, чем word size, оно должно начинаться с адреса, кратного своему размеру.
Тип |
Размер |
bool |
Один байт |
intN, uintN, floatN |
N/8 байтов |
int, uint, uintptr |
Одно машинное слово |
*T |
Одно машинное слово |
string |
Два машинных слова |
[]T |
Три машинных слова |
map |
Одно машинное слово |
func |
Одно машинное слово |
chan |
Одно машинное слово |
interface |
Два машинных слова |
Подытожим
В Go размер структуры зависит не только от типов, но и от порядка их объявления. Причина та же: выравнивание по границам памяти. Процессор быстрее читает данные, выровненные по размеру слова (word size), и компилятор Go добавляет паддинг, чтобы это соблюсти.
Go применяет выравнивание по самому большому полю, чтобы вся структура тоже начиналась с корректного адреса, если её, например, положить в массив. Если поля идут в неудачном порядке, то между ними будет много паддинга.
Процессору так удобнее, но зачем это нужно
Представим, что наш сервис имеет один под, на который приходится N запросов. Чем больше запросов, тем больше аллокаций в памяти, соответственно, больше нагрузка на GC. Таким образом, может пострадать не только память, но и скорость выполнения.
Допустим, у нас 10 тыс. RPS, и нам нужно аллоцировать 100 объектов. Чуть усложним нашу модель:
type MessageNonOptimized struct {
ID int32 // 4
ChatSessionID uuid.UUID // 16
WithAction bool // 1
Direction string // 16
FromAdmin bool // 1
Status string // 16
WithFile bool // 1
Message string // 16
IsReply bool // 1
CreatedAt time.Time // 24
}
type MessageOptimized struct {
CreatedAt time.Time
Direction string
Status string
Message string
ID int32
ChatSessionID uuid.UUID
WithAction bool
FromAdmin bool
WithFile bool
IsReply bool
}
И добавим тесты:
const (
RPS = 10_000
ObjectsPerReq = 100
)
func printTestsInfo() {
fmt.Printf(
"RPS=%v\nObjectsPerReq=%v\n\n",
RPS, ObjectsPerReq)
fmt.Println("Sizeof Optimized:", unsafe.Sizeof(MessageOptimized{}))
fmt.Println("Sizeof NonOptimized:", unsafe.Sizeof(MessageNonOptimized{}))
}
func printMemDiff(before, after *runtime.MemStats) {
fmt.Printf("HeapAlloc = %d KB\n", (after.HeapAlloc-before.HeapAlloc)/1024)
fmt.Printf("TotalAlloc = %d KB\n", (after.TotalAlloc-before.TotalAlloc)/1024)
fmt.Printf("NumGC = %d\n", after.NumGC-before.NumGC)
}
func BenchmarkAllocNonOptimized(b *testing.B) {
var (
before, after runtime.MemStats
nonOptimized []MessageNonOptimized
)
printTestsInfo()
{
b.Run("NonOptimized", func(b *testing.B) {
runtime.GC()
debug.FreeOSMemory()
runtime.ReadMemStats(&before)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := 0; j < RPS; j++ {
nonOptimized = make([]MessageNonOptimized, ObjectsPerReq)
for n := range nonOptimized {
nonOptimized[n].WithAction = nonOptimized[n].IsReply
}
}
}
b.StopTimer()
runtime.ReadMemStats(&after)
})
printMemDiff(&before, &after)
}
}
func BenchmarkAllocOptimized(b *testing.B) {
var (
before, after runtime.MemStats
optimized []MessageOptimized
)
printTestsInfo()
{
b.Run("Optimized", func(b *testing.B) {
runtime.GC()
debug.FreeOSMemory()
runtime.ReadMemStats(&before)
b.ResetTimer()
for i := 0; i < b.N; i++ {
for j := 0; j < RPS; j++ {
optimized = make([]MessageOptimized, ObjectsPerReq)
for n := range optimized {
optimized[n].WithAction = optimized[n].IsReply
}
}
}
b.StopTimer()
runtime.ReadMemStats(&after)
})
printMemDiff(&before, &after)
}
}
При запуске теста мы увидим, что оптимизированная версия потребляет меньше памяти под тот же 1 млн элементов и выигрывает в скорости на операциях заполнения/получения. При большом количестве операций, когда происходит не только создание массива из небольшой структуры, разницу уже нельзя будет игнорировать.
Количество времени на одну операцию ns/op |
Память, MiB/op |
|
Оптимизировано (96 байтов) |
16,4 |
97,2 |
Не оптимизировано (120 байтов) |
34,3 |
122,9 |
Выравнивание по границам кеш-линий
А что если иногда нужно специально разграничивать поля, чтобы они оказались в разных областях памяти? До этого мы говорили: «Как бы нам уменьшить количество затрачиваемой памяти?» Но могут быть случаи, когда нам потребуется её специально увеличить.
CPU работает с памятью в терминах кеш‑линий — более длинных последовательностей байтов, чтобы амортизировать стоимость доступа к памяти. В большинстве процессоров их размер составляет 64 байта. Это означает, что каждая операция с памятью на самом деле заканчивается чтением или записью блока, кратного 64 байтам.
Если два поля структуры лежат в пределах одного 64-байтового блока, то при запуске одного поля в кеш подтянется и соседнее. Иногда бывает полезно это предотвратить. Каким образом? Заполнив пространство.
Если несколько горутин обращаются к разным полям одной и той же структуры, расположенным в одной строке кеша процессора, они могут столкнуться с false sharing
(ситуация, когда изменения в одном поле приводят к недействительности другого, даже если они логически не связаны). Если одна горутина записывает данные в своё поле, то строка кеша становится недействительной и должна быть перезагружена на другом ядре. Это приводит к снижению производительности из-за ложного совместного использования.
Сравним две структуры:
type CounterNonOpt struct {
a int64
b int64
}
type CounterOpt struct {
a int64
_ [56]byte // to new cache line
b int64
}
Затем напишем бенчмарк, в котором будем инкрементировать одновременно a из одной горутины и b — из другой.
const (
iterations = 1_000_000
)
func BenchmarkAllocSimpleFalseSharing(b *testing.B) {
var (
before, after runtime.MemStats
counter CounterNonOpt
wg sync.WaitGroup
)
{
b.Run("SimpleFalseSharing", func(b *testing.B) {
runtime.GC()
debug.FreeOSMemory()
runtime.ReadMemStats(&before)
b.ResetTimer()
for i := 0; i < b.N; i++ {
wg.Add(2)
go func() {
for i := 0; i < iterations; i++ {
counter.a++
}
wg.Done()
}()
go func() {
for i := 0; i < iterations; i++ {
counter.b++
}
wg.Done()
}()
wg.Wait()
}
b.StopTimer()
runtime.ReadMemStats(&after)
})
printMemDiff(&before, &after)
}
}
func BenchmarkAllocSimpleNoFalseSharing(b *testing.B) {
var (
before, after runtime.MemStats
counter CounterOpt
wg sync.WaitGroup
)
{
b.Run("SimpleNoFalseSharing", func(b *testing.B) {
runtime.GC()
debug.FreeOSMemory()
runtime.ReadMemStats(&before)
b.ResetTimer()
for i := 0; i < b.N; i++ {
wg.Add(2)
go func() {
for i := 0; i < iterations; i++ {
counter.a++
}
wg.Done()
}()
go func() {
for i := 0; i < iterations; i++ {
counter.b++
}
wg.Done()
}()
wg.Wait()
}
b.StopTimer()
runtime.ReadMemStats(&after)
})
printMemDiff(&before, &after)
}
}
Результаты:
Количество времени на одну операцию ns/op |
Память, B/op |
|
FalseSharing |
4,27 |
48 |
NoFalseSharing |
0,97 |
48 |
Видим очень заметный прирост по времени. Вы скажете, что в реальности мы обратились бы к atomic
. Давайте проверим и их. Соответственно, бенчить будем уже эти модели.
type CounterAtomicNonOpt struct {
a atomic.Int64
b atomic.Int64
}
type CounterAtomicOpt struct {
a atomic.Int64
_ [56]byte // to new cache line
b atomic.Int64
}
Сами тесты дублировать не буду, сразу смотрим результаты:
Количество времени на одну операцию ns/op |
Память, B/op |
|
FalseSharing |
12,26 |
48 |
NoFalseSharing |
4,43 |
48 |
Здесь тоже наблюдаем схожий разрыв в производительности, что и требовалось доказать.
Линтер
Давайте немного поговорим ещё о том, как автоматизировать оптимизацию по выравниванию структур, чтобы не просматривать каждую структуру вручную.
Можно воспользоваться инструментом напрямую:
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment -fix ./...
Также можно использовать go vet или линтер. В репозитории есть пример конфигурации линтера и соответствующие команды по его запуску через Make.
Выводы
Какие рекомендации можно дать?
Следите за выравниванием и располагайте поля в правильном порядке (подключите линтер)
Помните, что не всегда оптимизация памяти приводит к увеличению производительности, как в случае с false-sharing
Как и любые оптимизации, делайте всё осознанно и своевременно
Сейчас модно говорить про DDD, распределённые транзакции в высоконагруженных системах и про другие интересные архитектурные вещи. Но нельзя забывать основы.
В данном случае такая, казалось бы, простая вещь, как выравнивание, может привести к неплохим улучшениям потребления памяти и скорости выполнения. Разработка — всегда компромисс между памятью, скоростью, читаемостью и поддерживаемостью. Поэтому важно уделять внимание низкоуровневым механизмам, как и высокоуровневым паттернам, чтобы правильно прокладывать себе дорогу вперёд.