Видео: YouTube
Мы продолжаем рассматривать операционные системы со всеми их проблемами и техническими подробностями. В этой статье поговорим о динамических переменных и менеджере памяти. В прошлых статьях мы уже успели узнать, что каждая запущенная программа формируется в четкое описание, называемое процессом. В состав этого описания входят участки памяти для хранения исполняемых машинных инструкций, глобальных переменных, а также участок памяти, выделенный под стек. В логическом адресном пространстве они соседствуют друг с другом, однако в физической памяти это не обязательно так. Каждый из сегментов памяти разбит на страницы, которые могут быть разбросаны по физической памяти в соответствии с пожеланиями операционной системы. Такой состав сегментов памяти не учитывает одну ситуацию. Не всегда можно заранее знать какой объем данных необходимо разместить в памяти для обработки.
Зачем нужны динамические переменные?
Очень большая доля данных поступает на обработку уже в тот момент, когда приложение обработки уже запущено и работает. Объемы данных, которые поступят в будущем неизвестны, поэтому память для размещения этих объемов выделяется в процессе работы. Иными словами выделяется динамически. Например, при обработке цифровых снимков их размер может быть заранее неизвестен.
Есть снимки большого размера, есть малые. Это становится совершенно не важно с появлением еще одного очень значимого механизма операционной системы, называемого управление памятью. Это самое управление занимается тем, что выделяет память по запросу приложения. Если бы процесс был один, то думать было бы нечего. Просто можно было отдать все что есть, но в многозадачной операционной системе так поступать нельзя. Такой ценный ресурс как память может понадобиться любому процессу в абсолютно любой момент времени. Такой важный вопрос как распределение памяти между различными процессами находится в ведении операционной системы.
Как получить дополнительное место для хранения данных?
Итак, приложение, нуждающееся в дополнительном месте для размещения новых данных отправляет запрос операционной системе. Библиотека языка Си имеет для этого функцию malloc().
Параметром является необходимое количество байт и возвращает она адрес начала выделенного участка. Участок памяти выделяется из специального места. Называется оно куча. Звучит не очень, но так уж получилось. Абсолютно ничего не мешает выделить место и для второго снимка, лишь бы хватило места.
При этом в куче из незанятого пространства выделяется область, предназначенная для хранения еще одной порции данных. Адрес начала вновь выделенного участка попадает в объявленный указатель. Имея указатели на выделенные участки, можно как помещать туда информацию, так и считывать ее оттуда. Логично предположить, что эти участки выделены внутри адресного пространства процесса, иначе бы так просто обращаться к ним было бы невозможно.
Где расположена «куча»?
Место под кучу выделяется в адресном пространстве процесса. Незанятое пространство между глобальными данными и стеком используется для размещения там динамически выделяемых участков памяти по запросу самого процесса.
Красным цветом на схеме выделена та самая куча. В данном примере показано, как для данных выделяется место в куче, а указатели на выделенные участки памяти являются данными стекового фрейма функции.
Это и хорошо и плохо. Смотря какая квалификация у программиста. После завершения функции, как мы уже знаем, стековые переменные могут быть перезаписаны переменными другой функции и это лишь вопрос времени и вероятности. Но давайте о проблемах чуть позже. Правильным действием тут является отправка запроса на освобождение выделенной ранее памяти. Освобождением занимается функция free() с параметром указатель на ранее выделенную область. Сделаем все правильно чтоб немного рассмотреть работу менеджера памяти.
Технические подробности
Манипуляции с регистрами
Первым делом при запуске процесса необходимо выделить хотя бы немного места под хранения динамических переменных. Делается это путем занесения в служебные регистры необходимых данных.
Как мы ранее выяснили, часть регистров заняты выполнением своих особенных задач. Адресные регистры (PAR0 - PAR15) учебного процессора занимаются размещением в физической памяти страниц из логического адресного пространства процесса. Для определения полного физического адреса ячейки памяти необходимо младшие 12 бит логического адреса сложить с содержимым адресного регистра, сдвинутого на 8 бит влево.
Старшие 4 бита логического адреса определяют номер страницы памяти и соответственно номер адресного регистра с которым необходимо проводить сложение. Задачей менеджера является во-первых, выделить новые страницы под кучу. А это информация кроме как где она будет физически находиться, еще и атрибуты этих страниц памяти. В атрибутах есть информация о размере страниц памяти и правах на чтение и запись. Эти атрибуты позволяют выявить аварийную ситуацию, при некорректной работе программы. Аварийная ситуация это сигнал, позволяющий операционной системе удалить процесс нарушитель из очереди задач и сообщить об этом пользователю.
Обращение к системному менеджеру памяти
Теперь рассмотрим как запрос прикладного приложения пользователя добирается до менеджера памяти. Системный менеджер памяти представляет собой функции ядра операционной системы. Работа с памятью в условиях многозадачности это весьма интеллектуальная задача и ее нельзя доверять программисту с непонятным уровнем квалификации. Поэтому все что остается прикладному программисту это сделать простой запрос. Указать сколько байт потребовалось и куда положить адрес выделенного участка памяти. Функция malloc() является частью стандартной библиотеки, поэтому выполняется в пространстве пользователя. В свою очередь, эта функция обращается к прикладному интерфейсу операционной системы, где представлена другая функция, обладающая целым набором параметров (mmap).
Этим набором можно указать кроме необходимого количества байт пространства еще и атрибуты участка памяти, касающиеся чтения и записи и при желании другую необходимую информацию. Эта API функция, в свою очередь, вызывает программное прерывание, сопровождающееся переключением контекста пользователя в контекст ядра операционной системы. В контексте ядра вызывается функция менеджера памяти, которая в куче процесса занимается поиском свободного пространства необходимого размера, необходимыми отметками о том, что память занята и возвратом адреса начала выделенного участка.
Как мы ранее выяснили, переключение контекста и довольно длинная цепочка вызовов приводят к существенным задержкам, так что лучше снижать число обращений за памятью.
Менеджер памяти прикладного приложения
Теперь о том, как снижается число обращений к операционной системе с целью выделения памяти из кучи. Здесь все просто, один раз оптовой партией берем большой кусок памяти и долго потом не обращаемся к операционной системе.
Просто и одновременно сложно. В этом большом фрагменте теперь нам самим прийдется искать свободные участки необходимого размера и вести учет занятых и незанятых участков памяти. Такие функции выполняют роль менеджера памяти, но уже не в пространстве операционной системы, а в пространстве пользовательского приложения. Это снизит число обращений к операционной системе. Они нужны будут только для того, чтобы еще увеличить размер выделенной области если та что была уже закончилась. Необходимо отметить, что поиск фрагментов нужного размера, возвращение этих фрагментов обратно и весь связанный с этим учет все равно довольно трудоемкая работа, требующая большое количество процессорного времени.
Проблемы менеджеров памяти
Утечка памяти
Настало время поднять вопрос о самых распространенных проблемах менеджеров памяти. Менеджеры работают сами по себе, программисты сами по себе. Согласованности зачастую нет никакой. Если программист выделил себе место под данные, а потом забыл вернуть, то область памяти так и останется висеть в списке занятых, хоть и не осталось в программе ни одного места, нуждающегося в существовании этого участка памяти. Эта проблема называется утечкой памяти.
Программа забирает память у операционной системы и не возвращает. Если это происходит циклически и часто, то в итоге программа забирает всю свободную память, что приводит к нарушению нормальной работы операционной системы и чаще всего к необходимости ее перезагрузки. В этом примере функция берет в пользование 10 байт, после окончания функции стековая переменная с адресом выделенного участка исчезает. Подвешенный участок памяти остается. Менеджер не тратит время процессора на поиски таких проблем, память утекает. Десятилетиями лучшие умы человечества пытаются решить эту проблему и найдены по крайней мере два направления.
Решения проблем утечки
Первое это как ранее уже озвучено, ведение учета занятых областей памяти и поиск ссылок на такие области. Если ссылки еще есть, то значит кому-то это нужно и занятое пространство не освобождается. Но если никто уже не ссылается, то занятое пространство возвращается в кучу обратно как свободный участок. Такой механизм называется сборкой мусора. Она происходит циклически по расписанию или по запросу приложения. Из популярных языков, имеющих в своем вооружении сборщик мусора можно выделить Java, CSharp, JavaScript, Go.
Сборщик мусора выявляет, что никто больше не владеет выделенным участком памяти и он будет возвращен в кучу как незанятый. Сборка мусора это довольно затратный процесс, снижающий быстродействие программы.
Одним из новых языков программирования, сменившим традиционный подход к управлению памяти является Rust.
В нем основу работы с динамически выделяемой памятью составляет такое понятие как область видимости. Сам факт выхода стековой переменной из области видимости при окончании функции должен быть сигналом к немедленному возврату выделенной памяти.
Необходимо отметить, что существуют гораздо более сложные случаи, требующие некоторого напряжения ума программиста. Напряжения только потому, что компилятор языка Rust не позволяет программисту собрать приложение пока существуют хоть сколько нибудь вероятно опасные строки исходного кода. Опасные это в смысле могущие привести к некорректному результату. Еще такая безопасность касается многопоточной работы, поэтому всяких тонкостей, которые видит компилятор в исходном коде может быть довольно большое количество. В общем, язык этот не так прост, но считается очень производительным и безопасным одновременно. Давайте перейдем к следующей проблеме.
Фрагментация памяти
А другая проблема менеджеров это фрагментация памяти. Такое явление, при котором появляются неиспользуемые фрагменты памяти в общем блоке. При активной работе менеджера их набирается изрядное количество. При освобождении какого-то участка памяти он помечается как незанятый. Но его специфический размер зачастую не подходит для занятия другими блоками данных.
Так происходит непрерывный процесс накопления неиспользуемых фрагментов. Это конечно же приводит к запросу у операционной системы еще одного большого блока данных. Казалось бы, можно взять и переупаковать занятые участки таким образом, чтобы убрать промежутки, но это только на словах звучит просто. Во первых, перемещения областей памяти это затратно по времени, да еще и указатели на области памяти не должны быть изменены. Никто не даст гарантию того, что большое число копий указателя еще не было скопировано в разные части программы. Перемещение фрагментов должно приводить и к смене содержимого указателей. А это уже совсем другая история и вероятно игра не стоит свеч. Вот в этот самый момент становится понятной фраза о том, что
любая программа стремиться занять всю доступную память.
Пишите качественные программы, думайте о том, что происходит внутри и тем самым мы приостановим обрушение лавины посредственности в наш и так не самый совершенный мир.