Менеджмент памяти или как реже стрелять себе в ногу

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


Зачем это необходимо? Программа — это последовательность команд по обработке данных (в самом общем случае). Эти данные необходимо некоторым образом хранить, загружать, передавать и т.д. Все эти операции не происходят мгновенно, следовательно, они непосредственно влияют на скорость работы вашего конечного приложения. Умение оптимально управлять данными в процессе работы позволит вам создавать весьма нетривиальные и очень требовательные к ресурсам программы.


Замечание: основная часть материала излагается с примерами из игр/игровых движков (т.к. эта тема более интересная лично для меня), однако большую часть материала можно применить и к написанию серверов, пользовательских приложений, пакетов графики и т.д.



Невозможно все держать в памяти. Но если не успел подгрузить, то получишь мыло


С места в карьер


Так сложилось в индустрии, что крупные ААА игровые проекты разрабатываются преимущественно на движках, написанных с помощью C++. Одна из особенностей этого языка заключается в необходимости ручного управления памятью. Java/C# и т.д. могут похвастаться сборкой мусора (GarbageCollection/GC) — возможностью создавать объекты и при этом не освобождать использованную память руками. Этот процесс упрощает и ускоряет разработку, но может вызвать и некоторые проблемы: периодически срабатывающий сборщик мусора способен убить весь soft-real time и добавить неприятные зависания в игру.


Да, в проектах типа "Minecraft" работа GC может быть и незаметна, т.к. они в целом не требовательны к ресурсам вычислителя, однако такие игры, как "Red Dead Redemption 2", "God of War", "Last of Us", работают "едва ли" не на пике производительности системы и поэтому нуждаются не только в большом количестве ресурсов, но и в грамотном их распределении.


Кроме этого, работая в среде с автоматическим выделением памяти и сборкой мусора, вы можете столкнуться с нехваткой гибкости в управлении ресурсами. Не секрет, что Java скрывает под капотом все детали реализации и аспекты своей работы, поэтому на выходе вы имеете только установленный интерфейс взаимодействия с ресурсами системы, но его может не хватить при решении некоторых задач. Например, запуск в каждом кадре алгоритма с неконстантным количеством аллокаций памяти (это может быть поиск путей для AI, проверка видимости, анимации, и т.д.) неизбежно ведет к катастрофическому падению производительности.


Как выглядят аллокации в коде


Прежде чем продолжить изложение, я бы хотел показать, как непосредственно происходит работа с памятью в C/C++ на паре примеров. В общем случае, стандартный и самый простой интерфейс для распределения памяти процесса представляется следующими операциями:


// получить указатель на свободный участок памяти размером size байт
void* malloc(size_t size); 

// освободить участок памяти по указателю p
void free(void* p); 

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


// C11 стандарт - выделить участок памяти по адресу, кратному* alignment
void* aligned_alloc(size_t size, size_t alignment);

// Posix стандат - выделить участок выровненной памяти и положить
// адрес на него в переменную по указателю address (*address = allocated_mem_p)
int posix_memalign(void** address, size_t alignment, size_t size);

Обратите внимание на то, что на различных платформах могут поддерживаться различные стандарты функций, доступные, например на macOS, и недоступные на win.


Забегая вперед, выровненные особым образом участки памяти могут вам понадобиться как для попадания в кэш-линии процессора, так и для вычислений с помощью расширенного набора регистров (SSE, MMX, AVX, и т.д).


Пример "игрушечной" программы, которая выделяет память и печатает значения буфера, интерпретируя их как целые числа со знаком:


/* main.cpp */
#include <cstdio>
#include <cstdlib>

int main(int argc, char** argv) {
   const int N = 10;
   int* buffer = (int*) malloc(sizeof(int) * N);
   for(int i = 0; i < N; i++) {
      printf("%i ", buffer[i]);
   }
   free(buffer);
   return 0;
}

На macOS 10.14 данную программу можно собрать и запустить следующим набором команд:


$ clang++ main.cpp -o main
$ ./main

Замечание: здесь и далее я не очень хочу освещать такие операции C++ как new/delete, так как они скорее относятся к конструированию/уничтожению непосредственно объектов, но под собой используют обычные операции по работе с памятью наподобие malloc/free.


Проблемы с памятью


Существует несколько проблем, возникающих при работе с ОЗУ компьютера. Все они, так или иначе, вызваны не только особенностями ОС и ПО, но и архитектурой железа, на котором все это добро работает.


1. Количество памяти


К сожалению, память ограничена физически. На PlayStation 4 это 8 GiB GDDR5, 3.5 GiB из которых операционная система резервирует для своих нужд. Виртуальная память и подкачка страниц особо не помогут, так как swapping страниц на диск — операция весьма медленная (в рамках фиксированных N кадров в секунду, если говорить об играх).


Также стоит отметить и ограниченный "бюджет" — некоторое искусственное ограничение на объем используемой памяти, созданное с целью запуска приложения на нескольких платформах. Если вы создаете игру для мобильной платформы и хотите поддержать не одно, а целую линейку устройств, вам придется ограничить свои аппетиты в угоду обеспечения более широкого рынка сбыта. Это может быть достигнуто как просто ограничением расхода ОЗУ, так и возможностью конфигурировать это ограничение в зависимости от гаджета, на котором игра, собственно, запускается.


2. Фрагментация


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



Фрагментация на примере последовательных аллокаций и освобождений блоков памяти


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


3. Кэш процессора



Иерархия памяти компьютера


Кэш современного процессора — это некоторое промежуточное звено, которое связывает главную память (ОЗУ) и непосредственно регистры процессора. Так получилось, что доступ к памяти на чтение/запись — это весьма медленные операции (если говорить о кол-ве тактов CPU, необходимых для выполнения). Поэтому существует некоторая иерархия кэша (L1, L2, L3, и тд), которая позволяет как бы "по некоторому предсказанию" подгружать данные из ОЗУ, или неспешно их вытеснять в более медленную память.


Размещение однотипных объектов подряд в памяти позволяет "значительно" ускорить процесс их обработки (если обработка происходит последовательно), так как в этом случае проще предсказать, какие данные понадобятся дальше. И под "значительно" понимается прирост производительности в разы. Об этом неоднократно говорили разработчики движка Unity в своих докладах на GDC.


4. Multi-Threading


Обеспечение безопасного доступа к разделяемой памяти в многопоточной среде является одной из основных проблем, которую вам придется решать при создании собственного игрового движка/игры/любого другого приложения, которое использует несколько потоков для достижения бОльшей производительности. Современные вычислители устроены весьма нетривиальным образом. У нас имеется и сложная структура кэша, и несколько ядер вычислителя. Все это при неправильном использовании может привести к ситуациям, когда разделяемые данные вашего процесса будут повреждены в результате работы нескольких потоков (если они одновременно попытаются работать с этими данными без контроля доступа). В самом простом случае, это будет выглядеть следующим образом:

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


5. Malloc/free


Операции по выделению/освобождению не происходят мгновенно. На современных ОС, если мы говорим про Windows/Linux/MacOS они реализованы хорошо и работают в большинстве ситуаций быстро. Но потенциально это весьма трудоемкие операции. Мало того, что это является системным вызовом, так еще в зависимости от реализации это может потребовать время для поиска подходящего куска памяти (First Fit, Best fit, и тд.) или поиска места для вставки и/или мерджа освобожденного участка.


Кроме того, свеже-выделенная память может быть в действительности не отображена на реальные физические страницы, что при первом обращении также может потребовать некоторое время.


Это детали реализации, а что с применимостью? Malloc/new не имеют представления о том, в каком месте, как и для чего вы их вызвали. Они выделяют память (в худшем случае) размером 1 KiB и 100 MiB одинаково… одинаково плохо. Непосредственно стратегия использования отдается на откуп либо программисту, либо тому, кто реализовал среду исполнения вашей программы.


6. Memory corruption


Как говорит вики, это одна из самых непредсказуемых ошибок, проявляется только в ходе работы программы, и чаще всего вызвана непосредственно ошибками в написании этой программы. Но что эта проблема из себя представляет? К счастью (или сожалению), она не связана с коррумпированностью вашего компьютера. Скорее она отображает ситуацию, когда вы пытаетесь работать с памятью, которая вам не принадлежит. Сейчас поясню:


  1. Это может быть попытка чтения/записи в участок не выделенной памяти
  2. Выход за границы блока памяти, предоставленного вам. Это проблема как бы частный случай проблемы (1), но она хуже тем, что система скажет вам о том, что вы вышли за границы только тогда, когда вы покинете пределы отображенной для вас страницы. Т.е потенциально, эту проблему очень трудно отловить, т.к ОС в состоянии реагировать, только если вы покинете пределы отображенных вам виртуальных страниц. Вы можете попортить память процесса и получить очень странную ошибку из того места, откуда ее совсем не ждали.
  3. Освобождение уже освобожденного (звучит странно) или еще не выделенного участка памяти
  4. и т.д.

В С/С++, где есть арифметика указателей, с этим вы столкнетесь на раз-два. Однако в Java Runtime придется изрядно попотеть, чтобы получить подобного рода ошибку (сам не пробовал, но думаю, что такое возможно, иначе жизнь была бы слишком простой).


7. Утечки памяти


Является частным случаем более общей проблемы, встречающейся во многих языках программирования. Стандартная библиотека C/C++ предоставляет доступ к ресурсам ОС. Это могут быть файлы, сокеты, память и т.д. После использования ресурс должен быть корректно закрыт и
занятую им память следует освободить. И если говорить конкретно об освобождении памяти — накапливающиеся утечки в результате работы программы могут привести к "out of memory" ошибке, когда ОС будет не способна удовлетворить очередной запрос на аллокацию. Часто разработчик просто забывает освободить использованную память по тем или иным причинам.


Здесь же стоит добавить про корректное закрытие и освобождение ресурсов на GPU, т.к ранние драйверы не позволяли возобновить работу с видеокартой если предыдущий сеанс был завершен неверно. Только перезагрузка системы могла решить эту проблему, что является весьма сомнительным — заставлять юзера перезагружать систему после работы вашего приложения.


8. Dangling pointer


Висячий указатель — некоторый жаргон, описывает ситуацию, когда указатель ссылается на недопустимое значение. Подобная ситуация может запросто возникнуть при использовании классических C-style указателей в C/C++ программе. Предположим, вы выделили память, сохранили адрес на нее в указателе p, а потом освободили память (см. пример кода):


// Выделяем память
void* p = malloc(size);
// ... Делаем что-то с полученной памятью
// Благополучно освобождаем
free(p);
// Куда теперь указывает p?
// *p == ? 

Указатель хранит некоторое значение, которое мы можем интерпретировать как адрес блока памяти. Так получилось, что мы не можем утверждать, является ли этот блок памяти валидным, или нет. Только программист, основываясь на тех или иных соглашениях, может оперировать указателем. Начиная с C++11 в стандартную библиотеку были введены ряд дополнительных указателей "smart pointers", которые позволяют в некотором плане ослабить контроль ресурсов со стороны программиста за счет использования дополнительной мета-информации внутри себя (об этом позже).


Как частичное решение можно использовать специальное значение указателя, которое будет сигнализировать нам о том, что по этому адресу ничего нет. В C в кастве этого значения используется макрос NULL, а в C++ ключевое слово языка nullptr. Решение это частичное, так как:


  1. Значение указателя необходимо устанавливать вручную, поэтому программист может просто забыть это сделать.
  2. nullptr или просто 0x0 входит в множество значений, принимаемых указателем, что не есть хорошо, когда особое состояние объекта выражается через его обычное состояние. Это некоторе legacy, и по договоренности ОС не выделит вам участок памяти, адрес которого начинается с 0x0.

Пример кода с null:


// Делаем что-то с p
free(p);
p = nullptr;
// Теперь значение p == nullptr и мы знаем, что он ни на что не ссылается

Можно в некоторой степени автоматизировать этот процесс:


void _free(void* &p) {
   free(p);
   p = nullptr;
}

// Делаем что-то с p
_free(p);
// Теперь значение p == nullptr, и нам не надо 
// вручную устанавливать его

9. Тип памяти


RAM — обычная оперативная память общего назначения, доступ к которой через центральную шину имеют все ядра вашего процессора и устройства периферии. Ее объем варьируется, но чаще всего речь идет о N гигабайтах, где N равняется 1,2,4,8,16 и тд. Вызовы malloc/free стремятся разместить желаемый вами блок памяти как раз в RAM компьютера.


VRAM (video memory) — видео память, поставляется вместе с видеокартой/видео-ускорителем вашего ПК. Она, как правило, меньшего объема чем RAM (порядка 1,2,4 GiB), однако обладает большим быстродействием. Распределением этого типа памяти занимается драйвер видеокарты, и чаще всего прямого доступа к ней вы не имеете.


На PlayStation 4 такого разделения нет, и вся оперативная память представлена едиными 8 гигабайтами на GDDR5. Поэтому все данные, как для процессора, так и для видео-ускорителя лежат рядом.


Хороший менеджмент ресурсов в игровом движке включает в себя грамотное распределение памяти как в Main RAM, так и на стороне VRAM. Здесь можно столкнуться с дублированием, когда одни и те же данные будут находятся там и там, или с избыточным трансфером данных с RAM на VRAM и обратно.


В качестве иллюстрации ко всем озвученным проблемам: можно посмотреть на аспекты устройства компьютеров на примере архитектуры PlayStation 4 (рис.). Здесь представлен центральный процессор, 8 ядер, кэши уровня L1 и L2, шины данных, оперативная память, графический ускоритель и т.д. С полным и подробным описанием можно ознакомиться в книге Джейсона Грегори "Game Engine Architecture".



Архитектура PlayStation 4


Общие подходы к решению


Универсального решения нет. Но есть набор некоторых моментов, на которых следует акцентировать внимание, если вы собираетесь реализовывать мануально аллокацию и управление памятью в вашем приложении. Сюда можно отнести и контейнеры, и специализированные аллокаторы, стратегии выделения памяти, дизайн системы/игры, менеджеры ресурсов и другое.


Типы аллокаторов


Использование специальных аллокаторов памяти основывается на следующей идее: вы знаете, какого размера, в какой моменты работы и в каком месте вам понадобятся участки памяти. Следовательно, вы можете выделить необходимую память, как-то ее структурировать и использовать / переиспользовать. В этом состоит общая идея/концепция использования специальных аллокаторов. Какие они бывают (разумеется не все) можно посмотреть далее:


  1. Linear allocator
    Представляет буфер непрерывного адресного пространства. В ходе работы позволяет выделять участки памяти произвольного размера (такие, что они умещаются в буфер). Но освободить всю выделенную память можно только за 1 раз. Т.е произвольный участок памяти не может быть освобожден — он будет оставаться как бы занятым до тех пор, пока весь буфер не будет помечен как чистый. Такая конструкция обеспечивает выделение и освобождение за O(1), что дает гарантию скорости при любых условиях.

    Типичный use-case: в процессе обновления состояния процесса (каждый кадр в игре) вы можете использовать LinearAllocator для выделения tmp буферов для каких-либо технических нужд: обработка ввода, работа со строками, парсинг команд ConsoleManager в режиме отладки и т.д.


  2. Stack allocator
    Модификация линейного аллокатора. Позволяет освобождать память в порядке, обратном порядку выделения, иными словами, ведет себя как обычный стек по принципу LIFO. Может быть весьма полезен для проведения нагруженных математических вычислений (иерархия трансформаций), для реализации работы скриптовой подсистемы, для любых вычислений, где наперед известен указанный порядок освобождения памяти.

    Простота конструкции обеспечивает O(1) скорость выделения и освобождения памяти.


  3. Pool allocator
    Позволяет выделять блоки памяти одинакового размера. Может быть реализован как буфер непрерывного адресного пространства, разбитый на блоки заранее установленного размера. Эти блоки могут образовывать связный список. И мы всегда знаем, какой блок отдать при аллокации следующим. Эту мета информацию можно сохранить в самих блоках, что накладывает ограничение на минимальный размер блока (sizeof(void*)). В реальности это не критично.

    Поскольку все блоки имеют единый размер, нам не принципиально, какой блок возвращать, а следовательно все операции по выделению/освобождению могут быть выполнены за O(1).


  4. Frame allocator
    Линейный аллокатор но только с привязкой к текущему кадру — позволяет делать tmp выделения памяти и затем при смене кадра автоматически все освобождать. Его стоит выделить отдельно, так как это некоторая глобальная и уникальная сущность в рамках runtime игры, а поэтому его можно сделать весьма внушительного размера, скажем пара десятков MiB, что будет весьма кстати при загрузке ресурсов и их обработке.


  5. Double frame allocator
    Представляет собой двойной frame allocator, но с некоторой особенностью. Он позволяет выделять память в ткущем кадре, и использовать ее как в текущем, так и в следующем кадрах. Т.е память, которую вы выделили в кадре N, будет освобождена только после N+1 кадра. Реализуется это переключением активного frame для выделения в конце каждого кадра.

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


  6. Static allocator
    Аллокатор такого типа распределяет память из буфера, полученного, например, на этапе запуска программы, либо захваченного на стеке в фрейме функции. По типу это может быть совершенно любой аллокатор: linear, pool, stack. Почему он называется статический? Размер захватываемого буфера памяти должен быть известен на этапе компиляции программы. Это накладывает существенное ограничение: объем доступной этому аллокатору памяти не можем изменяться во время работы. Но каковы преимущества? Используемый буфер будет автоматически захвачен и потом освобожден (либо по завершении работы, либо при выходе из функции). Это не нагружает кучу, избавляет вас от фрагментации, позволяет быстро выделить память по месту.
    Можно посмотреть на примере кода использование этого аллокатора, если вам необходимо разбить строку на подстроки и что-то с ними сделать:

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



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


Следует так же отметить, что правильный подход к дизайну интерфейсов позволит вам создавать своего рода иерархии аллокаторов, когда, например: pool выделит память из frame alloc, а frame alloc в свою очередь выделит память из linear alloc. Подобную структуру можно продолжить и дальше, адаптируя под свои задачи и нужды.



Подобный интерфейс для создания иерархий мне видится следующим образом:


class IAllocator {
public:
   virtual void* alloc(size_t size) = 0;
   virtual void* alloc(size_t size, size_t alignment) = 0;
   virtual void  free (void* &p) = 0;
}

В него вы можете обернуть стандартные для вышей платформы malloc/free операций, что также даст дополнительную гибкость. Более того, подобные классы дают вам больше возможностей для профилирования, вы с легкостью сможете добавить подсчет необходимой статистики внутри реализации этих методов. В этот интерфейс можно было бы добавить синтаксический сахар по созданию/удалению объектов с использованием шаблонов, но лучше оставить это для следующего раза.


Умные указатели


Smart pointer — это некоторая разновидность указателей в стандартной библиотеке C++ начиная с С++11 (не говорите про boost, разговор только о стандарте). Представляет собой класс-обертку, который хранит собственно сам указатель, осуществляет операции над ним и содержит дополнительную мета-информацию о том, как и когда освободить память и удалить объект. В этом месте мы немного отходим от непосредственно выделения памяти к скорее отслеживанию времени жизни объекта.


Так что же позволяют делать умные указатели? В самом простом случае это:


  1. Автоматическое удаление объекта и освобождение памяти
  2. Контроль доступа (уникальность/раздельность)
  3. Больший контроль типа

Следующие виды умных указателей являются основными и самыми распространенными:


  1. Unique pointer
    Позволяет иметь в программе только 1 указатель на объект (уникальность доступа).
    Как только unique pointer удаляется, сразу же удаляется используемый объект и освобождается занятая им память. Хорошо подходит для создания указателей на файловые дескрипторы, т.к. чаще всего только 1 участник может читать/писать файл.
    Если вы передаете указатель на объект из uniquePtr1 в uniquePtr2, то значение в uniquePtr1 инвалидируется, т.к допускается только 1 владелец объекта.


  2. Shared pointer
    Указатели раздельного доступа с автоматическим подсчетом ссылок (reference counting). Позволяют нескольким участникам ссылаться на один объект, не беспокоясь о том, кто в итоге должен удалить его. Поскольку подсчет ссылок автоматический, то, как только последний ссылающийся указатель удален, объект также будет удален.

    Однако такой вид указателя не лишен недостатков. Во-первых, он не способен бороться с циклическими ссылками, когда объекты ссылаются друг на друга. В таком случае вы гарантированно получите утечку памяти. Во-вторых, в много-поточной среде работа с указателями раздельного доступа накладывает свои ограничения на консистентность памяти и раздельность доступа потоками.


  3. Weak pointer
    Слабая ссылка на объект. Позволяет хранить указатель на объект раздельного доступа, но не удерживать его. Что это значит? Вы можете создать объект и сохранить указатель на него в shared pointer. До тех пор, пока последний shared pointer не удален, объект будет оставаться в памяти. Однако, вы можете создать из shared pointer weak pointer. Таким образом, если сильные (shared) указатели остались на объект, то из weak pointer вы можете получить shared pointer. А если нет — то weak pointer инвалидируется, и вы узнаете о том, что объект, на который вы хранили указатель уже удален.

    Недостатком как shared, так и weak pointer является необходимость хранить дополнительно meta-data для каждого объекта. Здесь храниться и кол-во ссылок на объект, способ его удаления и т.д. В общем случае, это O(N) overhead по памяти, где N — кол-во создаваемых объектов. Однако такую жертву можно считать допустимой, т.к в проекте на тысячи строк кода вам будет тяжело договориться с другим программистом о том, кто ответственен за удаление того или иного объекта. В противном случае висячие ссылки и утечки памяти гарантированы.



Финальная мысль в этой части: использовать умные указатели необходимо с также некоторой долей осторожности. Например, используя shared pointer, вы неявно соглашаетесь с тем, что ваш объект (на который вы ссылаетесь через этот указатель) будет где-то когда-то кем-то удален. И это может быть не самый удачный момент работы вашей программы. Кроме это вы должны отдавать себе отчет о расходуемой памяти на meta-info и о том, как данные, на которые вы ссылаетесь через умный указатель будут попадать в кэш. Пример:


/* Как не стоит делать */
/* Создадим массив объектов, используя shared pointer */
Array<TSharedPtr<Object>> objects;
objects.add(newShared<Object>(...));
...
objects.add(newShared<Object>(...));

/* Как можно было бы сделать (не тратимся на meta-info и попадаем в кэш) */
Array<Object> objects;
objects.emplace(...);
...
objects.emplace(...);

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


Unique id


Никто не заставляет нас использовать указатели, чтобы обращаться к объектам. Мы можем использовать идентефикаторы (id/identificator), выраженные целым числом, строкой, или еще как-то. Каковы преимущества данного подхода:


  1. Контроль доступа
    Мы храним не сам объект, а его id. Чтобы обратиться к нему, нам необходим посредник или сервис, который знает, где найти и как обработать этот объект по его id.
  2. Контроль времени жизни
    Даже если мы забудем удалить объект, то ответственный за него сервис сделает это за нас (в крайнем случае, в конце работы этого сервиса)
  3. Слабые ссылки и разделение доступа
    Если объект с id был удален, то сервис скажет нам об этом, когда мы обратимся к нему по этому id.
  4. Расход памяти и порядок размещения
    Сервис сам будет в состоянии разместить объекты в памяти более эффективным способом в рамках решения поставленной задачи. Мы, как держатели id, даже не узнаем об этом.

Очевидным недостатками такого подхода являются: необходимость контролировать уникальность id, ограничивать количество объектов, которые могут быть адресованы через id, жертвовать контролем и использовать посредника для доступа к объекту.


Подобные id повсеместно используют в современных приложения, библиотеках (Vulkan, OpenGL), игровых движках (Godot, CryEngine). Об EntityID можно почитать в документации к упомянутому уже CryEngine.


Рассмотрим простой пример, когда id представлен двумя числами: индекс и поколение. Индекс говорит нам о том, где конкретно лежит объект (в какой ячейке массива), а поколение указывает на но, был ли объект удален или нет.


/* Пример структуры идентефикаторы */
class ID {
   uint32 index;
   uint32 generation;
}

/* Пример класса-обработчика / менеджера  */
class ObjectManager {
public:
   ID create(...);
   void destroy(ID);
   void update(ID id, ...);
private:
   Array<uint32> generations;
   Array<Objects> objects;
}

Теперь по ID обработчик будет в состоянии найти объект или определить, что объекта с таким ID нет. Это можно сделать следующим образом:


generation = generations[id.index];
if (generation == id.generation) 
   then /* нашли такой объект */
   else /* не нашли, объект был уже удален */

При удалении объекта по его id мы просто должны увеличить счетчик generation на 1 у соответствующего id из массива ids.


Контейнеры


В стандартной библиотеке языка C++ есть множество классов, которые облегчают работу с тем или иным видом объектов. И здесь это не реклама std, вы можете самостоятельно реализовать единожды необходимые вам контейнеры, а затем переиспользовать их неограниченное число раз. В первую очередь обратите внимание на следующие классы:


  • Linked list — связный список
  • Array — динамический/статический массив
  • Queue — очередь
  • Stack — стек
  • Map — ассоциативный контейнер
  • Set — множество

Почему я их упоминаю в статье про менеджмент памяти? Потому что они контролируют доступ к объектам внутри себя и исключают возможность memory corruption. Вызывая необходимые методы вы с легкостью сможете добавлять/удалять объекты, получать на них ссылки, итерироваться по ним, не задумываясь о том, где и как они лежат.


Общие идеи


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


Под конкретные задачи


При создании системы, разработчик как никто иной должен знать, какие данные и как он будет обрабатывать. На основе этого он сможет выбрать ту стратегию выделения памяти и работы с ней, которая наилучшим (в рамках задачи) образом подходит ему. Как было сказано ранее, библиотечные функции malloc/free не знают, как и для чего вы их вызвали, поэтому полагаться на их производительность при решении весьма специфических задач не стоит.


Что требуется брать в расчет? Вы можете определить, сколько памяти вам потребуется (нижняя/верхняя граница), в какие моменты работы программы, какие задачи потребуют множество аллокаций, а какие нет. Можно порассуждать о том, можно ли часть аллокаций убрать, разместить данные на стеке, перенести запросы на выделение из нагруженных частей программы в менее нагруженные.



СryEngine Sandbox: как пример среды для разработки игр


Крупные игровые движки, такие как Unreal, Unity, CryEngine и т.д, ничего не знают о том, какую игру вы делаете. Да, они могут быть заточены под определенные механики, жанры, но в общем случае — только вы сможете настроить систему таким образом, что она будет в состоянии удовлетворить ваши запросы на размещение тех или иных ресурсов в памяти компьютера.


Pre-allocating


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


Преимущество такого подхода очевидно: никаких запросов malloc/free на выделение памяти в течение работы. Отсюда вытекает и тот факт, что ОС не скажет вам "run out of memory", т.к все необходимое вы получили заранее и не более. Теперь вы сможете работать с памятью в том стиле, который требуется для решения ваших задач (выравнивание, попадание в кэш, упаковка данных подряд и т.д).


Но такой подход очень сложен в реализации. Он требует ручного распределения памяти среди всех объектов вашей системы. Более того, вы неизбежно можете столкнуться с перерасходом и большим кол-вом неиспользуемой памяти. Если ваша система будет делать множество динамических аллокаций в процессе работы, то такой подход неизбежно приведет ко всем тем проблемам с malloc/free, которые были озвучены ранее: фрагментация, долгие вызовы, нехватка памяти.


Не надо бояться динамической памяти


В наше время многие системы требуют динамического выделения памяти в процессе работы. И это нормально: вы не можете знать заранее, какого размера вам на вход попал файл, текстура, меш и т.д. Современные вычислители смогут позволить себе пару тысяч динамических аллокаций без потерь в производительности.


Однако не стоит увлекаться: если вы знаете, как и какая подсистема использует ресурсы, то лучше двигаться в сторону специализированных аллокаторов, адаптированных под конкретные цели. Большинство open-source движков, исходный код которых мне удавалось посмотреть, по такому принципу и работают. Где они могут, там оптимизируют, где нет — отдают все на откуп malloc/free.


Дизайн из ограничений


На GDC компания CD Project Red представила интересный доклад, в котором дизайнер уровней рассказал, как они создавали архитектуру игровых локаций "The Witcher: Blood and Wine" в условиях четких (количественных) ограничений на расход памяти и время подгрузки локаций. Им приходилось выверять количество геометрии, которая будет загружена в памяти, выстраивать здания таким образом, что движок будет способен подгрузить все необходимые данные налету, пока игрок идет из точки А в точку Б.


В одной из статей дизайнер локаций из Naughty Dog также упомянул, что им приходилось при разработке "Uncharted 4: A Thief's End" оптимизировать мир игры таким образом, что в любой момент времени вся обозримая часть (то, что находиться в кадре) локации могла быть успешно обработана движком игры.


Заключение


В конце хотелось бы отметить, что управление памятью программы, как и управление други ресурсами, выходит далеко за рамки простых менеджеров памяти и структур данных. В рамках игровых движков существует множество аспектов, которые так или иначе связаны с темой этой статьи. Это может быть и загрузка/хранение ресурсов, отслеживание времени жизни игровых объектов, обеспечение безопасности много-поточного доступа и т.д. Со всеми моментами можно и необходимо познакомиться, прочитав специальную литературу по каждой из тем (конечно, если вам это интересно).


Литература и полезные ссылки


  • Хотелось бы еще раз упомянуть книгу Джейсона Грегори "Game Engine Architecture". В ней он рассматривает многие аспекты создания игр и игровых движков, включая звук, графику, физику, математику и т.д. Часть моментов, касающихся работы с памятью, также детально освещается в этой книге.
  • Custom memory allocators — здесь вы может ознакомиться с деталями реализации аллокаторов, посмотреть исходный код на C++ и замеры. Это отличный пример для тех, кто желает погрузиться в рутину реализации собственных менеджеров памяти.
  • Smart pointers — можно ознакомиться с умными указателями, деталями использования и полным набором функциональности.
  • Start Pre-allocating And Stop Worrying — еще пара размышлений на тему менеджмента памяти

Комментарии 49

  • НЛО прилетело и опубликовало эту надпись здесь
      +4

      Мне кажется, стоит еще отметить мельком выравнивание данных в asm-коде. Например, в случае больших объектов намного лучше будет хранить поля таким образом, чтобы вся выделенная под объект память после выравнивания была наименьшей. Но это, конечно, совсем edge-case))

        +3

        Очень хорошее замечание. Совсем не вспомнил про это в процессе написания статьи.
        На самом деле, это обыденная вещь. Минимальный размер важен, так как хотим попадать в кэш. Но и не забываем про то, что процессору удобнее в выровненными под машинное слово полями работать. К тому же, иногда требуется отображать структуры из CPU на GPU, там тоже свои подвохи есть.
        Так что надо будет обязательно это добавить)

          +1

          Можно тогда ещё написать про прикольные вещи, вроде того, что если у вас часть полей в структуре постоянно меняется (счётчик например), а часть почти всегда только читается, то лучше меняющиеся поля объявлять так, чтобы они попадали в другую ячейку кэша.

            0

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

        +4
        Есть мааааленькая такая проблема. malloc()/calloc()/realloc() и обертки над ними не являются явными аллокаторами памяти. Внутри системных либ (aka libc/glibc/...) они реализуют собственный пул страниц памяти (лишь частично пред-аллоцированных) и их подкачку. Для конечного потребителя это сопряжено с такими проблемами, как:

        — отсутствие физической непрерывности аллокаций (у GPU собственный MMU и ему до фонаря логически непрерывные блоки, памяти аллоцированные системной библиотекой — GPU DMA их проглатывать будет с болью);
        — встроенный и зачастую не подконтрольный фрагментатор из коробки;
        — lazy MMU allocations.

        Все методики в статье следует рассматривать через призму специализированных способов выделения памяти, если речь о графике.

        P.S. Не стоит забывать про бесплатный alloca() для незначительных одноразовых действий.
          0

          Что вы понимаете в первом пункте под ' отсутствие физической непрерывности аллокаций'?
          Имеется ввиду, что реально в ОЗУ данные не подряд лежат? Если я правильно понял, когда мы работаем скажем с OpenGL, Vulkan, нам так и так приходиться пересылать данные с RAM на VRAM. Если оперировать в этих категориях, то контролировать это процесс более мы не можем, разве что минимизировать кол-во пересылок. На этом фоне, думаю, мы можем пожертвовать физической разрозненностью, хоть это и проблема, но не такая критичная, как частая пересылка данных. (если вы об этом, конечно)

            +1
            В первую очередь стоит отметить, что тема статьи не ограничивается графикой. Возвращенный malloc()-ом буфер в общем случае представляет собой фрагментированный набор физических страниц RAM. При попытке доступа к нему со стороны периферии потребуется либо копирование, либо пачка независимых DMA-транзакций, инициированная в любом случае не оптимально (поскольку последовательно и со стороны CPU).

            Если же говорить об opengl, сразу оговорюсь что win мне профессионально не интересен. Mesa, например, для аллоцирования использует спец. интерфейс ядра ОС, включённый в DRM. Собственно у DRM есть открытый пользовательский интерфейс libdrm, пригодный в том числе для аллокаций памяти и оборачивания поверх них произвольных практик менеджмента памяти. Vulkan в этом смысле работает через те-же интерфейсы.
          0
          Неплохо бы приложить ассемблерный код менеджера памяти. Желательно рабочий, а нарытый с интернета.
            0
            Я бы и рад приложить код на asm, да вот только не очень много опыта в написании программ именно на этом языке. На С/С++ реализация, которую лично делал, не достойна внимания, так как код в целом не очень документирован и презентабелен.

            Приложил ссылку на стороннюю реализацию, так как это 1) хороший пример, заслуживающий упоминания 2) это показывает, что тема развита, и можно в интернете еще много реализаций аллокаторов на C++ найти :)
            0

            Куча мелких неточностей. Вот выхвачу одну, например:
            “Unique pointer
            Позволяет иметь в программе только 1 указатель на объект (уникальность доступа).“


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


            Ошибки все некритичные, но царапают глаза.

              0
              Безусловно, согласен с вами.
              Под 'иметь только 1' я понимал именно работу с этим unique ptr. Никто не сможет запретить вам из сырого C ptr сделать несколько unique ptr и выстрелить себе в ногу (обязательно это добавлю).

              И про передачу прав владения и просто передачу как аргумента — вы хорошо подметили. Но это уже скорее к вопросу о том, как использовать умные указатели, что лучше описывается в соответствующей литературе
                +1
                Более того, это вполне себе рабочий паттерн, когда владеющий код держит у себя unique_ptr, а в другие функции обьект передается по сырому поинтеру, явна разграничивая владение и использование.

                Имхо в таких случаях правильнее передавать по ссылке, а не сырому поинтеру.

                  0
                  Согласен. Но видел варианты, где сырой поинтер передавали. Где-то АПИ сторонних библиотек это требовало, где-то АПИ методов подразумевало корректность nullptr как входного параметра, где-то просто ссылки недолюбливали по какой-то причине:)
                0
                GC скрывает прямую работу с памятью, зато вы начинаете бороться с GC: вроде тяжёлая таска окончилась, почему же наш кластер малодоступен и нагружен ещё минуту? Ах да, у нас запустилось 600 GC со средним временем работы в минуту. И в этот момент память вроде есть, но её ещё нет.
                Вообще, на мой взгляд, проблемы при работе с памятью — один из самых трудноотслеживаемых и проблемных багов. Ещё хуже только получить проблемы при работе с памятью при доступе из нескольких асинхронных потоков.
                  0
                  Да, в проектах типа "Minecraft" работа GC может быть и незаметна

                  Это только пока игрок не зайдёт на действительно крупный сервер и не сядет в метро :-)

                    +2
                    А как давно и в какой конкретно ОС malloc/free стали системными вызовами? По-моему, они таковыми не были ровным счётом никогда. Или я ошибаюсь?
                      0
                      Сами по себе — это библиотечные вызовы (с этим и правда косяк).
                      Однако в конечном счете получить память для процесса все равно необходимо, поэтому системный вызов сделать хоть раз придется. А теперь представим, что это случилось не в подходящий момент — вуаля, все так же плохо :)
                        0

                        Но чисто теоретически brk/sbrk/mmap/etc ты можешь сделать ровно один раз при старте приложения и потом делить кусок аллокатором как пожелаешь. В контексте лимитов на платформу, озвученных в тексте выше это ещё и выглядит довольно обоснованно.

                      0
                      Да, в проектах типа «Minecraft» работа GC может быть и незаметна
                      Позволю себе не согласиться. Как раз таки в Minecraft, где могут регулярно сравниться и деспавниться огромные множества разнотипных блоков и объектов, влияние GC может быть очень заметным. Особенно в мутиплеерном режиме, с модами, и при большой дальности загрузки. И тем более для сервера, который вынужден держать в памяти и модифицировать области карты вокруг каждого из игроков.
                        –1
                        Не сложно придумать ситуацию, в которой Майнкрафт начнет тормозить.
                        Изначально, когда вы запускаете игру из коробки (как это было давно давно), модов, шейдеров у вас нет. Разумеется, вы все это добавляете и благополучно нагружаете систему.
                        Посыл был не в том, что на Java Майнкрафт не тормозит, а скорее в том, что на Java можно и нужно делать игры, если требования по производительности это допускают. Если бы в Майнкрафт была четко сформулированная функциональность и проработанная архитектура, то проблем было бы меньше.
                          0
                          Не сложно придумать ситуацию, в которой Майнкрафт начнет тормозить.
                          Так речь не об особых ситуациях или торможениях. Речь о том, что Minecraft активно насилует GC из-за того что у него вся карта снизу доверху динамична, и эта особенность активно используется в рядовом геймплее, даже без модов. Конкретно в частоте и количестве выделений и освобождений объектов Minecraft вполне себе сопоставим со средними AAA-проектами, если не превосходит их. Поэтому изначальное утверждение из статьи про влияние GC там и там кажется мне не совсем верным.
                            0
                            Это проблема не столько Майнкрафта, сколько создателей проекта, которые так и не определились, что это должно быть: телега для модов или нет. Безусловно, любая игра с динамической подгрузкой локация будет насиловать runtime, однако если это не критично, то стоит на это забить) Смотрел, как ребята делаю инди проекты, хорошие игры с 3D для десктопа, и средняя джава с 2GiB памяти спокойно их запускает

                            Наверное, лучше было на java пример с шахматами приводить, тогда точно бы проблем не было)
                        0
                        nullptr или просто 0x0 входит в множество значений, принимаемых указателем, что не есть хорошо, когда особое состояние объекта выражается через его обычное состояние. Это некоторе legacy, и по договоренности ОС не выделит вам участок памяти, адрес которого начинается с 0x0.

                        Щито? Смешались в кучу кони-люди.


                        • nullptr может иметь любое значение и это дело компилятора, а 0х0 тут не при чем Вот у Microsoft тем вообще 0xffffffff если мне память не изменяет.
                        • какое "обычное" состояние у указателя если ему присвоили nullptr? Если указатель равен nullptr то это и есть особое состояние, специально введённое в языке чтобы отличить указатель-не-указывающий-на-что-то от указателя-на-что-то.
                        • при чем тут выделение указателя на память от ОС и nullptr? ОС вам легко вернёт (в теории) указатель на 0х0, но от этого указатель в вашей программе не станет неожиданно == nullptr
                        • сравнение с 0 указателя в C++ опять же просто синтаксический сахар, который вынуждает компилятор знаменить его на присваивание nullptr
                          Вообще вся статья о смеси С и плюсов, при этом в тех аспектах где языки ведут себя по разному и дают разные гарантии. Очень сумбурно, по верхам, а местами не верно.
                          0
                          Не берусь утверждать, как работает компилятор на платформе от Майкрософт, однако clang на MacOS дает следующее: void* p = 0; void* d = nullptr; p == d? (it is true). Получается что 0 — это и валидное значение, и одновременно null идентификация. Плюс, насколько я понимаю, заявляется возможность линковки с С библиотеками, следовательно передавая nullptr как аргумент вызова любой функции, это как никак должен быть 0, в С ведь nullptr просто нет.
                          (поправьте меня, пожалуйста, если с чем-то наврал)
                            0

                            Я об этом и говорю. Сравнение/присваивание нуля в C++ есть синтаксический сахар, который компилятор знаменит на аналогичную операцию с nullptr.
                            В C есть NULL, который тоже совсем не обязательно имеет численное представление 0x0 и его реализация, как и nullptr, зависит от компилятора.
                            Все эти хитрости нужны в том числе и для того чтобы избежать привязки отдельной сущности языка (указания на "ничто") к каким-то конкретным адресам памяти.

                              0
                              В старом стандарте C++ присвоение указателю 0 было специально оговорено и означало невалидный адрес, проще говоря NULL. В новых — специальным ключевым словом nullptr. Строго говоря, битовое значение nullptr может быть любым, в отладочных сборках обычно не 0. И это дело компилятора обеспечить корректное сравнение указателя на равенство/неравенство nullptr. При линковке объектных файлов и библиотек, собранных с разными флагами могут возникнуть проблемы. Вроде как.
                              О, пока писал, уже отписали))
                                0
                                Строго говоря, битовое значение nullptr может быть любым, в отладочных сборках обычно не 0

                                Это где такое, хотелось бы узнать? И с каких пор null перестал быть integer constant expression with the value 0?
                                  +1
                                  Ну, во-первых integer expression that evaluates to zero, а не with value 0. Но это так, для точности.
                                  А если серьезно, то это константа NULL.
                                  Все это относится к языку и исходному коду. Вы должны инициализировать указатель нулем и сравнивать его с нулем. А компилятор должен в этих случаях генерировать правильный код. А вот какой — это зависит от реализации и от платформы. Вот цитата из C FAQ:
                                  The internal (or run-time) representation of a null pointer, which may or may not be all-bits-0 and which may be different for different pointer types. The actual values should be of concern only to compiler writers. Authors of C programs never see them, since they use.

                                  c-faq.com/null/varieties.html
                                  Q 5.13
                                  Вы же не думаете, что указатель на функцию-член может быть равен 0 какого-либо целочисленного типа? А присвоить ему можно и NULL, и nullptr.
                                    0
                                    Вы сейчас об очень глубоких слоях абстракции говорите. Чисто технически да, можно представить себе что язык C описан для некоторой виртуальной машины (в которой NULL есть указатель на 0) но физическая реализация этой машины отличается. Но у программиста нет вообще никакого доступа к такой «физической» машине, ее наличие или отсутствие никак не может на него повлиять.

                                    В качестве наглядного примера — назовите мне примеры реальных платформ где memset(0), т.е. прямая и широко используемая на практике операция над представлением данных в памяти не породит NULL pointer. При том что чисто формально такое и возможно.

                                    Вы же не думаете, что указатель на функцию-член может быть равен 0 какого-либо целочисленного типа?

                                    Строго говоря pointer to type и pointer to member type — это две разных сущности в Стандарте хотя и то и то вроде как «pointer». Я полагал что мы говорим о pointer to type. Но если хотите обсудить pointer to member type, то в Стандарте прямо оговорено что присвоение такому объекту nullptr порождает специальную сущность null member pointer value. Т.е. мы не присваиваем nullptr указателю на функцию-член класса, мы приводим nullptr к специальному значению которое отличается от nullptr.
                                      0
                                      Но если хотите обсудить pointer to member type, то в Стандарте прямо оговорено что присвоение такому объекту nullptr порождает специальную сущность null member pointer value. Т.е. мы не присваиваем nullptr указателю на функцию-член класса, мы приводим nullptr к специальному значению которое отличается от nullptr.

                                      Раз вы теперь про C++:
                                      Все намного проще — есть требование стандарта чтобы присвоение nullptr указателю определяло его как сущность языка null (member) pointer. А как это представлять компилятору это дело компилятора.
                                      Есть требование чтобы присвоение 0 вело себя точно так же как и присвоение nullptr(в целях синтаксического сахара по сути). А ноль или 42 записать при этом в указатель это дело компилятора.
                                      Есть требование чтобы сравнение с nullptr было true если указатель является null (member) pointer. А что лежит при этом в самом указателе это снова дело компилятора.
                                      Есть требование по приведению к целочисленной константе 0 для nullptr, но что лежит "внутри" nullptr — это снова дело компилятора. И т.д.

                                        0
                                        То о чем Вы пишете было введено для совместимости с экзотическими архитектурами, где указатели на разные типы данных могут указывать на разную память. Там да, может существовать несколько разных null pointer-ов. Скажем int* (0) — один указатель, float* (0) — другой и т.д. Только Вы давно в подобным в реальной жизни встречались? А для архитектур где адреса в памяти можно однозначно ассоциировать с целыми числами NULL вполне однозначно ассоциируется с 0. Хотя чисто номинально таки да, компилятор вправе втихую от пользователя проводить любую другую ассоциацию. Но по факту это бессмысленно и подобные реализации мне не встречались (хотя было бы любопытно о таковых послушать).
                                          0

                                          Ну так тогда и пишите не на столько категорично, раз у вас речь только о том что вам встречалось, а не о стандарте языке:
                                          "В известных мне архитектурах", "в популярных реализациях" и т.п.


                                          Только почему вы защищаете при этом оригинальный текст, где речь автор явно ведёт о "C++ вообще" и допускает при этом фактические ошибки мне не понятно.

                                            0
                                            Я просто полагаю что знание о защите нулевой страницы памяти или понимание того как интерпретировать в дизассемблированном коде инструкции вида jz / jnz для современного программиста гораздо полезнее и релеватнее знания о существовании экзотических архитектур (даже названий которых никто не может вспомнить) где были разные типы указателей на разные данные, и для поддержки которых Стандарт попытался адаптировать когда-то простое и понятное определение (введенное для отнюдь не экзотической PDP-11) в нечто совместимое с подобными монстрами.
                                              0
                                              Нулевая страница памяти относится только к машинам со страничной организацией виртуальной памяти и с MMU. Так что вообще не аргумент. Передо мной на столе прямо сейчас лежит пяток 32-битных архитектур, и в каждой адрес 0x00000000 вполне себе валиден, хошь пиши, хошь читай. Иди ж ты, а это оказывается экзотика! Удивительное рядом.
                                                0
                                                Даже с учетом того что не везде есть MMU и не везде есть защита нулевой страницы, информация о том что такое решение есть и широко применяется все равно полезнее информации о существовании когда-то давно разной экзотики и о ньюансах введенных в стандарте для ее поддержки. Да и NULL-то я полагаю на этом пятке все равно 0x00000000?
                                                  0

                                                  Информация о защите нулевой страницы памяти полезна, но при чем тут язык программирования C или язык C++? Разве что упомянуть что данные языки от этого абстрагированны и ничего ни о какой защите страниц не знают.

                                                    0
                                                    Единственная причина защиты нулевой страницы памяти — это то что там находится адрес соответствующий NULL и адреса производные от NULL.
                              +1
                              nullptr может иметь любое значение и это дело компилятора

                              Это не так. Гарантируется что NULL — это 0. К примеру (C99)
                              An integer constant expression with the value 0, or such an expression cast to type
                              void *, is called a null pointer constant.
                              If a null pointer constant is converted to a pointer type, the resulting pointer, called a null pointer, is guaranteed to compare unequal to a pointer to any object or function

                              В С++
                              The pointer literal is the keyword nullptr. It is a prvalue of type std::nullptr_t. [ Note: std::nullptr_t is a distinct type that is neither a pointer type nor a pointer to member type; rather, a prvalue of this type is a null pointer constant and can be converted to a null pointer value or null member pointer value

                              A null pointer constant is an integer literal (2.14.2) with value zero or a prvalue of type std::nullptr_t

                              Вот у Microsoft тем вообще 0xffffffff если мне память не изменяет.

                              Вы полагаю путаете две совершенно разные вещи — nullptr и non-initialized memory. Non-initialized memory у MS заполняется константами типа 0xBAADF00D (подробнее

                              ОС вам легко вернёт (в теории) указатель на 0х0, но от этого указатель в вашей программе не станет неожиданно == nullptr

                              Станет. Есть (в числе прочего) отдельное соглашение по которому страница VRAM содержащая адрес 0 резервируется так что любой доступ к ней порождает SEGFAULT. Это сделано специально чтобы отлавливать доступ по указателю 0 и доступ по смещению от указателя 0.

                              сравнение с 0 указателя в C++ опять же просто синтаксический сахар

                              Да какой уж синтаксический сахар когда это сравнение с нулем напрямую в машинный код идет, в чем несложно убедиться дизассембировав то что генерирует компилятор?
                                0

                                Вы сами же приводите цитату где сказано что сам символ NULL определяется как целочисленная константа 0, либо её каст к void. И в следующем же предложении сказано что указатель которому присвоили NULL гарантированно не будет равен ни одному другому указателю, что не отменяет того факта что содержаться в таком указателе может что угодно компилятору.
                                Не говоря уже о том что на уровне ОС нет никаких гарантий, что указатель инициализированный NULL будет иметь значение 0. И тем более никаких гарантий что разыменовывание такого указателя приведет к segfault к примеру.
                                Адрес памяти* с значением 0 не то же самое что указатель с присвоением ему NULL.
                                И последнее-то что генерирует компилятор это его личное дело в данном случае, пока он не нарушает стандарт. Довольно абсурдно приводить вывод компилятора когда мы говорим о абстрактных понятиях стандарта языка.

                                  –1
                                  И в следующем же предложении сказано что указатель которому присвоили NULL гарантированно не будет равен ни одному другому указателю

                                  … ни одному другому указателю на валидный объект или функцию. Проще говоря язык гарантирует что по адресу *NULL никогда не будет лежать ничего валидного. Что позволяет компилятору в некоторых случаях делать какие-то оптимизации, но не позволяет утверждать что NULL может быть интегральной константой отличной от 0.

                                  что не отменяет того факта что содержаться в таком указателе может что угодно компилятору.

                                  Факт в том что текст Стандарта определяет NULL как An integer constant expression with the value 0.

                                  Не говоря уже о том что на уровне ОС нет никаких гарантий, что указатель инициализированный NULL будет иметь значение 0

                                  NULL — это часть стандарта C/C++ а не ОС. Но у ОС написанных на С/C++ и ОС рассчитанных на исполнение кода написанного на C/C++ в подавляющем большинстве случаев действует указанная мною схема с резервированием тех адресов памяти куда вероятнее всего может быть разыменован NULL. Со вполне однозначным подтекстом резервирования той части адресного пространства где находится специальный адрес NULL и производные от него. Да, в embedded, там где нет, собственно, самой виртуальной памяти, такой защиты может не быть.

                                  Адрес памяти* с значением 0 не то же самое что указатель с присвоением ему NULL.

                                  И то и другое — интегральные величины одного типа, обе равные 0. Причем в случае NULL они даже свободно кастятся друг к другу. Для nullptr_t такое преобразование запрещено и вот как раз этот запрет на cast to integer для нулевого указателя — это синтаксический сахар.

                                  Довольно абсурдно приводить вывод компилятора когда мы говорим о абстрактных понятиях стандарта языка.

                                  Отнюдь не абсурдно в контексте вопроса «синтаксический сахар» это или отражение чего-то совершенно реального.
                                    0
                                    Факт в том что текст Стандарта определяет NULL как An integer constant expression with the value 0.

                                    И то и другое — интегральные величины одного типа, обе равные 0.

                                    Вы разницу между определением символа NULL и адресом указателя, которому присвоили NULL видите? В стандарте могло быть написано что NULL имеет значение 42, вы бы после этого так же спорили что теперь все указатели которым присвоено NULL имеют адрес 42?)


                                    NULL — это часть стандарта C/C++ а не ОС. Но у ОС написанных на С/C++ и ОС рассчитанных на исполнение кода написанного на C/C++ в подавляющем большинстве случаев действует указанная мною схема с резервированием тех адресов памяти куда вероятнее всего может быть разыменован NULL. Со вполне однозначным подтекстом резервирования той части адресного пространства где находится специальный адрес NULL и производные от него. Да, в embedded, там где нет, собственно, самой виртуальной памяти, такой защиты может не быть.

                                    Не очень понятно что вы этим доказываете и при чем тут ОС на C++ о которых стандарт ничего не говорит. У автора в тексте сказано как раз что в ОС указатель с адресом 0 "зарезервирован" и тут же идёт отсылка к nullptr, хотя связи между адресом 0 в ОС и значением nullptr стандарт не определяет. О чем в том числе и мой исходный комментарий.


                                    Отнюдь не абсурдно в контексте вопроса «синтаксический сахар» это или отражение чего-то совершенно реального.

                                    Грубо говоря если вам после выделения памяти будет выдан блок с 0 до…, то такой указатель равен NULL не будет, хоть и одно и другое вроде как ноль. Потому и мнение автора, на которое отвечал я, о том что указатель с NULL и nullptr являются одними из адресов указателей в Си/C++, а не особым состоянием указателя, является мягко говоря не верным.

                                      –1
                                      Грубо говоря если вам после выделения памяти будет выдан блок с 0 до…, то такой указатель равен NULL не будет, хоть и одно и другое вроде как ноль

                                      Стандарт языка C/C++ прямо гарантирует что такой блок Вам выделен не будет. Поэтому да, получить NULL путем выделения памяти невозможно.

                                      В стандарте могло быть написано что NULL имеет значение 42

                                      В Стандарте написано что NULL имеет значение 0. Точка. Причем выбор именно такой константы имеет глубокое значение, уходящее корнями в историю языка C и архитектуру CPU. Сравнение с нулем на старых машинах было намного более быстрой операцией чем сравнение с любым другим числом. Оно чисто аппаратно очень просто реализуется и на многих архитектурах CPU включая PDP-11 послужившую основой для разработки языка C сравнением выполняется автоматически на каждую операцию, выставляя «zero flag» немедленно доступный для branching. И в том же x86 эта оптимизация используется до сих пор. В силу чего NULL, собственно, и выбран 0, хотя чисто теоретически таки да, можно было бы взять и любое другое значение. Но 0 был удобнее и Стандарт гарантирует что NULL — это действительно 0.

                                      хотя связи между адресом 0 в ОС и значением nullptr стандарт не определяет

                                      Стандарт определяет что если рассматривать указатель как целое число то nullptr будет равен 0.
                                        0
                                        Стандарт языка C/C++ прямо гарантирует что такой блок Вам выделен не будет. Поэтому да, получить NULL путем выделения памяти невозможно.

                                        Вы путаете концепт языка null pointer и физический адрес в памяти равный нулю, а так же мешаете стандарты двух разных языков вместе. Приведите, пожалуйста, цитату из стандарта языка Си где написано что язык гарантирует при выделении памяти что адрес в указателе не будет равным 0 (не путайте с null pointer). Думаю что такой цитаты вы не найдете.


                                        В Стандарте написано что NULL имеет значение 0. Точка.

                                        Вы продолжаете борьбу с ветряными мельницами. Заметьте, я не говорю что NULL имеет значение отличное от 0, я лишь обращаю ваше внимание на то, что это не так важно в контексте языка. Компилятор, даже по вашей приведенной цитате из стандарта, должен обеспечить всего лишь две вещи: определить макрос NULL как 0 и чтобы указатель, которому присвоили значение 0 или NULL, стал null pointer. Это означает только переход указателя в особое состояние с именем null pointer, но гарантий присваивания ему физического адреса памяти 0 не даёт. И это хорошо, потому как не ограничивает разработчиков компилятора в интерпретации значения null pointer. И тот факт что в вашей любимой или не очень ОС на популярном или не очень компиляторе в результате в указатель записывается значение 0 никак не влияет на то какnull pointer определяет стандарт.

                                          0
                                          Вы путаете концепт языка null pointer и физический адрес в памяти равный нулю

                                          Стандарт языка указывает на то реализацией концепта языка null pointer что виртуальный адрес в памяти равный нулю

                                          Приведите, пожалуйста, цитату из стандарта языка Си где написано что язык гарантирует при выделении памяти что адрес в указателе не будет равным 0

                                          Он гарантирует что 1) NULL == (void*)0 и 2) void* maloc() как и любая другая операция по созданию объекта в случае успешного выполнения не вернет NULL. Сложите два и два.

                                          Это означает только переход указателя в особое состояние с именем null pointer, но гарантий присваивания ему физического адреса памяти 0 не даёт

                                          Во всех известных мне имплементациях — дает. Довольно сложно, скажу я Вам, реализовать что-то иное в силу наличия гарантий что занулив указатель (в том числе через memset (0) что широко используется в over 9000 распространенных программ и библиотек) Вы получите NULL. Еще один хорошо известный пример — это инициализация статических переменных. Стандарт гарантирует что соответствующая память будет zero-initialized. Угадайте чему равно начальное значение статического указателя.

                                          Я не понимаю к чему Вы ведете этот спор, честно. Стандарт гарантирует что NULL — это 0 а nullptr — это тот же 0, но не-интегрального типа. Во всех реализациях NULL — это 0. Есть куча программ опирающихся на тот факт что NULL — это 0. В операционных системах с виртуальной памятью страница памяти содержащая адрес 0 как правило зарезервирована чтобы любой доступ к ней вызывал SEGFAULT. Да, чисто технически, можно было бы сделать NULL равным какой-то другой константе, не 0. Но это не соответствует ни Стандарту ни одной реальной реализации.
                                            0
                                            Он гарантирует что 1) NULL == (void)0 и 2) void maloc() как и любая другая операция по созданию объекта в случае успешного выполнения не вернет NULL. Сложите два и два.

                                            И получите совсем не то что ожидаете. Вы ожидаете что функция malloc никогда не вернёт указатель с адресом 0, а на самом деле вам (согласно стандарту) эта функция никогда не вернёт null pointer. В результате своего довольно вольного трактования стандарта вы удивитесь, когда, к примеру, побитовое сравнение null pointer указателя и NULL (или 0) у вас неожиданно не сойдется, хотя вам никто этого и не гарантировал.


                                            Я не понимаю к чему Вы ведете этот спор, честно.

                                            Я веду этот спор к тому, что у автора есть ошибки в статье, а вы, трактуя стандарт на основании того что сделано кем-то в


                                            over 9000 распространенных программ и библиотек

                                            а не того что там реально написано, защищаете по сути ошибки в статье.


                                            Стандарт что Си, что C++ не опираются на реализации, а все как раз наоборот. Комитет конечно старается угодить всем, в том числе и разработкам компилятора, но аргументы в стиле "да все так делают" тут не работают.
                                            Вы можете продолжать напирать на то что в конкретных реализация каких-то компиляторов на каких-то платформах ваша вольная трактовка стандарта подтверждается, но, честно говоря, этим только вводите в заблуждение тех любопытных, кто доберется до конца этой ветки обсуждения.
                                            Для них оставлю неплохое объяснение того что реально генерирует стандарт C по поводу NULL: https://stackoverflow.com/a/1296865/827263

                                              0
                                              Вы можете продолжать напирать на то что в конкретных реализация каких-то компиляторов на каких-то платформах ваша вольная трактовка стандарта подтверждается.

                                              Практика — критерий истины, уж извините. Вы пока не привели ни одного (sic!) контрпримера. Да, номинально Стандарт разделяет целое число 0 и NULL pointer, но он это делает не потому что там может быть какая-то другая константа, а потому что указатель может плохо приводиться к целочисленному типу вообще. Например Стандарт допускает что у указателя может быть более одного челочисленного представления что делает саму постановку вопроса о «побитовом сравнении» на подобных платформах бессмысленной. Или, к примеру, два одинаковых (побитово) указателя на разные типы могут адресовать разные области памяти. Но на платформах без подобных, хм, скажем мягко, нетривиальных особенностей адресации памяти, там где можно провести однозначное соответствие между целыми числами и адресами в памяти, NULL будет ассоциирован с 0. И если Вы не работаете с машинами с экзотической адресацией памяти (а я таких давным-давно что-то не наблюдаю) то следует исходить именно из этого, а не из абстрактной теории.
                                                0
                                                Практика — критерий истины, уж извините. Вы пока не привели ни одного (sic!) контрпримера.

                                                Не извиню. Со стандартами это так не работает.


                                                И если Вы не работаете с машинами с экзотической адресацией памяти (а я таких давным-давно что-то не наблюдаю) то следует исходить именно из этого, а не из абстрактной теории.

                                                То есть в обсуждении стандарта языка (по сути теории) вы предлагаете из этой самой теории не исходить. Однако.

                              +2
                              Статья в целом малость капитанская, много всего упомянуто и ничего толком не разобрано :). Плюс в целом определенная каша, когда в контексте C++ упоминается malloc и не упоминается new.

                              Из интересных вещей по менеджменту памяти в плюсах — советую более-менее сведущим в теме читателям почитать про C++17 PMR. Отличная штука, мне ее сильно не хватало. Есть еще обалденная книжка «Mastering the C++17 STL», там очень хорошо описаны вопросы использования allocator — тоже рекомендую.

                              Новичкам — советую освоить «умные указатели» и забыть про ручное управление памятью, в плюсах оно в 99.9% случаев не нужно (и я это утверждаю как разработчик нагруженных soft realtime систем).

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

                              Самое читаемое