Если только вы не создаете прототип какого-либо сервиса, вас наверняка волнует вопрос использования памяти в вашем приложении. При меньшем объеме памяти снижаются затраты на инфраструктуру, а масштабирование становится немного проще. Несмотря на то, что Go известен тем, что не потребляет много памяти, существуют способы дополнительно уменьшить ее потребление. Для некоторых из них требуется много рефакторинга, но многие из них очень просты.
Создание слайса с изначальной длинной
Чтобы понять эту оптимизацию, мы должны понять, как работают слайсы в Go, а для этого нам нужно сначала разобраться с массивами. Массивы — это коллекция одного типа с постоянным объемом памяти. В определении типа массива указывается длина и тип элемента. Основная проблема с массивами заключается в том, что они имеют фиксированный размер — их нельзя изменить, поскольку длина массива является частью его типа.
Рекомендуем вам супер TELEGRAM канал по Golang где собраны все материалы для качественного изучения языка. Удивите всех своими знаниями на собеседовании! 😎
Мы публикуем в паблике ВК и Telegram качественные обучающие материалы для быстрого изучения Go. Подпишитесь на нас в ВК и в Telegram. Поддержите сообщество Go программистов.
В отличие от типа массива, тип slice не имеет фиксированной длины. Слайс объявляется так же, как и массив, но без указания количества элементов. Слайсы — это обертки для массивов, они не имеют собственных данных — это ссылки на массивы. Они состоят из указателя на массив, длины слайса и его емкости (количество элементов в базовом массиве).
При добавлении данных в слайс, который не имеет емкости для нового значения, создается новый массив с большей емкостью, и значения из текущего массива копируются в новый. Это приводит к ненужным выделениям памяти и циклам процессора.
Чтобы лучше понять это, давайте рассмотрим следующий фрагмент кода:
1 2 3 4 5 6 7 8 9 10 11 12 |
package main import "fmt" func main() { var ints []int for i := 0; i < 5; i++ { ints = append(ints, i) fmt.Printf("Указатель: %p, Длина: %d, Вместимость: %d, Значения: %v\n", ints, len(ints), cap(ints), ints) } } |
Полученный результат:
1 2 3 4 5 |
Указатель: 0xc0000220f0, Длина: 1, Вместимость: 1, Значения: [0] Указатель: 0xc000022100, Длина: 2, Вместимость: 2, Значения: [0 1] Указатель: 0xc000020100, Длина: 3, Вместимость: 4, Значения: [0 1 2] Указатель: 0xc000020100, Длина: 4, Вместимость: 4, Значения: [0 1 2 3] Указатель: 0xc00001e0c0, Длина: 5, Вместимость: 8, Значения: [0 1 2 3 4] |
Глядя на вывод, можно сделать вывод, что при увеличении емкости (в 2 раза) необходимо было создать новый базовый массив (новый адрес памяти) и скопировать значения в новый массив.
Забавно, что раньше коэффициент увеличения вместительности был x2
для емкости меньше 1024, и x1.25
для >= 1024
. Начиная с Go 1.18, этот коэффициент стал более линейным.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func BenchmarkAppend(b *testing.B) { var ints []int for i := 0; i < b.N; i++ { ints = append(ints, i) } } func BenchmarkPreallocAssign(b *testing.B) { ints := make([]int, b.N) for i := 0; i < b.N; i++ { ints[i] = i } } |
Результат:
1 2 3 4 5 6 7 8 9 10 11 |
name time/op Append-10 3.81ns ± 0% PreallocAssign-10 0.41ns ± 0% name alloc/op Append-10 45.0B ± 0% PreallocAssign-10 8.00B ± 0% name allocs/op Append-10 0.00 PreallocAssign-10 0.00 |
Глядя на приведенный выше пример, можно сделать вывод, что существует большая разница между присвоением значений предварительно выделенным слайсам и добавлением значений к обычным слайсам.
Есть два линтера которые помогут найти такие места для оптимизации:
- prealloc: Инструмент статического анализа для поиска объявлений слайсов, которые потенциально могут быть предварительно распределены.
- makezero: Инструмент статического анализа для поиска объявлений слайсов, которые не инициализируются с нулевой длиной и впоследствии используются с
append()
.
Сортировка полей в структурах
Возможно, вы не задумывались об этом раньше, но порядок полей в структуре имеет значение для потребления памяти.
Возьмем для примера следующую структуру:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
type Post struct { IsDraft bool // 1 byte Title string // 16 bytes ID int64 // 8 bytes Description string // 16 bytes IsDeleted bool // 1 byte Author string // 16 bytes CreatedAt time.Time // 24 bytes } func main() { p := Post{} fmt.Println(unsafe.Sizeof(p)) } |
На выходе вышеприведенной функции получается 96 (байт), а все поля складываются в 82 байта. Откуда берутся дополнительные 14 байт?
Современные 64-битные процессоры принимают данные кусками по 64 бита (8 байт). Если бы у нас был более старый 32-битный процессор, то он бы выполнял куски по 32 бита (4 байта).
Первый цикл занимает 8 байт, при этом поле IsDraft
занимает 1 байт, а 7 байт не используются. Он не может взять «половину» поля.
Второй и третий циклы берут строку Title
, четвертый — ID
, и так далее. И снова поле IsDeleted
занимает 1 байт и имеет 7 неиспользуемых байтов.
Что действительно важно, так это сортировка полей по их размеру сверху вниз. Если отсортировать вышеприведенную структуру, то ее размер уменьшится до 88 байт. Последние два поля, IsDraft
и IsDeleted
, берутся в одном куске, таким образом уменьшая количество неиспользуемых байт с 14 (2×7) до 6 (1 x 6), экономя при этом 8 байт.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
type Post struct { CreatedAt time.Time // 24 bytes Title string // 16 bytes Description string // 16 bytes Author string // 16 bytes ID int64 // 8 bytes IsDraft bool // 1 byte IsDeleted bool // 1 byte } func main() { p := Post{} fmt.Println(unsafe.Sizeof(p)) } |
Типы в Go, которые занимают меньше 8 байт на 64-битной архитектуре:
- bool: 1 байт
- int8/uint8: 1 байт
- int16/uint16: 2 байта
- int32/uint32/rune: 4 байта
- float32: 4 байта
- байт: 1 байт
Вместо того, чтобы вручную проверять ваши структуры и сортировать их по размеру, существуют линтеры, которые находят это за вас и сообщают о «правильной» сортировке.
- maligned — устаревший линтер, который раньше сообщал о неправильной последовательности полей структуры и выводил рекомендации по правильной отсортировки полей. Он стал устаревшим год назад, но вы все еще можете установить старую версию и использовать её.
- fieldalignment: Часть от линтеров gotools и govet, fieldalignment указывает на неправильно отсортированные структуры и покажет какой размер должен был быть в идеале.
Для того, чтобы установить fieldalignment, нужно выполнить следующее:
1 2 |
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest fieldalignment -fix <package_path> |
Получим вот такой результат:
1 |
fieldalignment: struct of size 96 could be 88 (govet) |
Используйте map[string]struct{} вместо map[string]bool
В Go нет встроенного множества, и обычно для представления множества используется карты map[string]bool{}
. Несмотря на то, что он более читабелен, что очень важно, использовать его в качестве множества неправильно, поскольку он имеет два состояния (false/true) и использует дополнительную память по сравнению с пустой структурой.
Пустой struct (struct{}
) — это тип struct без дополнительных полей, занимающий ноль байт памяти.
Я бы не рекомендовал так делать, если только ваша карта или множество не содержит очень большое количество значений и вам нужно получить дополнительную память или вы программируете для устройств с малым объемом памяти, вроде Raspberry Pi Zero.
Теперь давайте сделаем, что-то действительно безумное. Мы будем тестировать добавление 100 000 000 данных в карту!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func BenchmarkBool(b *testing.B) { m := make(map[uint]bool) for i := uint(0); i < 100_000_000; i++ { m[i] = true } } func BenchmarkEmptyStruct(b *testing.B) { m := make(map[uint]struct{}) for i := uint(0); i < 100_000_000; i++ { m[i] = struct{}{} } } |
Результат тестов:
1 2 3 4 5 6 7 8 9 10 11 |
name time/op Bool 12.4s ± 0% EmptyStruct 12.0s ± 0% name alloc/op Bool 3.78GB ± 0% EmptyStruct 3.43GB ± 0% name allocs/op Bool 3.91M ± 0% EmptyStruct 3.90M ± 0% |
На основании этих цифр мы можем сделать вывод, что запись данных была на 3,2% быстрее, и на 10% меньше памяти было выделено при использовании карты с пустой структурой.
Кроме того, использование map[type]struct{}
является правильным обходным решением для реализации множеств, поскольку с каждым ключом связано одно значение. При использовании map[type]bool
для каждого ключа есть два возможных значения true / false
, что не является множеством и может быть использовано не по назначению, если целью является создание множества.
Однако удобство чтения в большинстве случаев важнее, чем (пренебрежительное) улучшение памяти. Проверки гораздо проще реализовать с булевыми значениями по сравнению с пустыми struct:
1 2 3 4 5 6 7 8 9 |
m := make(map[string]bool{}) if m["key"] { // Что-то выполняется... } v := make(map[string]struct{}{}) if _, ok := v["key"]; ok { // Что-то выполняется... } |
Администрирую данный сайт с целью распространения как можно большего объема обучающего материала для языка программирования Go. В IT с 2008 года, с тех пор изучаю и применяю интересующие меня технологии. Проявляю огромный интерес к машинному обучению и анализу данных.
E-mail: vasile.buldumac@ati.utm.md
Образование
Технический Университет Молдовы (utm.md), Факультет Вычислительной Техники, Информатики и Микроэлектроники
- 2014 — 2018 Universitatea Tehnică a Moldovei, ИТ-Инженер. Тема дипломной работы «Автоматизация покупки и продажи криптовалюты используя технический анализ»
- 2018 — 2020 Universitatea Tehnică a Moldovei, Магистр, Магистерская диссертация «Идентификация человека в киберпространстве по фотографии лица»