Если только вы не создаете прототип какого-либо сервиса, вас наверняка волнует вопрос использования памяти в вашем приложении. При меньшем объеме памяти снижаются затраты на инфраструктуру, а масштабирование становится немного проще. Несмотря на то, что Go известен тем, что не потребляет много памяти, существуют способы дополнительно уменьшить ее потребление. Для некоторых из них требуется много рефакторинга, но многие из них очень просты.

Создание слайса с изначальной длинной

Чтобы понять эту оптимизацию, мы должны понять, как работают слайсы в Go, а для этого нам нужно сначала разобраться с массивами. Массивы — это коллекция одного типа с постоянным объемом памяти. В определении типа массива указывается длина и тип элемента. Основная проблема с массивами заключается в том, что они имеют фиксированный размер — их нельзя изменить, поскольку длина массива является частью его типа.

Премиум 👑 канал по Golang

Рекомендуем вам супер TELEGRAM канал по Golang где собраны все материалы для качественного изучения языка. Удивите всех своими знаниями на собеседовании! 😎

Подписаться на канал

Уроки, статьи и Видео

Мы публикуем в паблике ВК и Telegram качественные обучающие материалы для быстрого изучения Go. Подпишитесь на нас в ВК и в Telegram. Поддержите сообщество Go программистов.

Go в ВК ЧАТ в Telegram


В отличие от типа массива, тип slice не имеет фиксированной длины. Слайс объявляется так же, как и массив, но без указания количества элементов. Слайсы — это обертки для массивов, они не имеют собственных данных — это ссылки на массивы. Они состоят из указателя на массив, длины слайса и его емкости (количество элементов в базовом массиве).

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

Чтобы лучше понять это, давайте рассмотрим следующий фрагмент кода:

Полученный результат:

Глядя на вывод, можно сделать вывод, что при увеличении емкости (в 2 раза) необходимо было создать новый базовый массив (новый адрес памяти) и скопировать значения в новый массив.

Забавно, что раньше коэффициент увеличения вместительности был x2 для емкости меньше 1024, и x1.25 для >= 1024. Начиная с Go 1.18, этот коэффициент стал более линейным.

Результат:

Глядя на приведенный выше пример, можно сделать вывод, что существует большая разница между присвоением значений предварительно выделенным слайсам и добавлением значений к обычным слайсам.

Есть два линтера которые помогут найти такие места для оптимизации:

  • prealloc: Инструмент статического анализа для поиска объявлений слайсов, которые потенциально могут быть предварительно распределены.
  • makezero: Инструмент статического анализа для поиска объявлений слайсов, которые не инициализируются с нулевой длиной и впоследствии используются с append().

Сортировка полей в структурах

Возможно, вы не задумывались об этом раньше, но порядок полей в структуре имеет значение для потребления памяти.

Возьмем для примера следующую структуру:

На выходе вышеприведенной функции получается 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 байт.

Типы в Go, которые занимают меньше 8 байт на 64-битной архитектуре:

  • bool: 1 байт
  • int8/uint8: 1 байт
  • int16/uint16: 2 байта
  • int32/uint32/rune: 4 байта
  • float32: 4 байта
  • байт: 1 байт

Вместо того, чтобы вручную проверять ваши структуры и сортировать их по размеру, существуют линтеры, которые находят это за вас и сообщают о «правильной» сортировке.

  • maligned — устаревший линтер, который раньше сообщал о неправильной последовательности полей структуры и выводил рекомендации по правильной отсортировки полей. Он стал устаревшим год назад, но вы все еще можете установить старую версию и использовать её.
  • fieldalignment: Часть от линтеров gotools и govet, fieldalignment указывает на неправильно отсортированные структуры и покажет какой размер должен был быть в идеале.

Для того, чтобы установить fieldalignment, нужно выполнить следующее:

Получим вот такой результат:

Используйте map[string]struct{} вместо map[string]bool

В Go нет встроенного множества, и обычно для представления множества используется карты map[string]bool{}. Несмотря на то, что он более читабелен, что очень важно, использовать его в качестве множества неправильно, поскольку он имеет два состояния (false/true) и использует дополнительную память по сравнению с пустой структурой.

Пустой struct (struct{}) — это тип struct без дополнительных полей, занимающий ноль байт памяти.

Я бы не рекомендовал так делать, если только ваша карта или множество не содержит очень большое количество значений и вам нужно получить дополнительную память или вы программируете для устройств с малым объемом памяти, вроде Raspberry Pi Zero.

Теперь давайте сделаем, что-то действительно безумное. Мы будем тестировать добавление 100 000 000 данных в карту!

Результат тестов:

На основании этих цифр мы можем сделать вывод, что запись данных была на 3,2% быстрее, и на 10% меньше памяти было выделено при использовании карты с пустой структурой.

Кроме того, использование map[type]struct{} является правильным обходным решением для реализации множеств, поскольку с каждым ключом связано одно значение. При использовании map[type]bool для каждого ключа есть два возможных значения true / false, что не является множеством и может быть использовано не по назначению, если целью является создание множества.

Однако удобство чтения в большинстве случаев важнее, чем (пренебрежительное) улучшение памяти. Проверки гораздо проще реализовать с булевыми значениями по сравнению с пустыми struct: