Содержание
Виртуальная память (Virtual Memory)
В современных операционных системах каждый процесс выполняется в собственном выделенном ему участке памяти. Вместо отображения (mapping) адресов памяти непосредственно на физические адреса, операционная система работает как некий абстрактный слой, создавая виртуальное адресное пространство для каждого процесса. Процесс отображение адресов между физической памятью и виртуальной памятью выполняется процессором с использованием “таблицы трансляции” (translation table) (или “таблица страниц“, или “адресная таблица“, “таблица перевода“) для каждого процесса, которая поддерживается ядром системы (каждый раз, когда ядро изменяет процесс, выполняющийся процессором, он изменяет адресная таблицу этого процессора).
Концепция виртуальной памяти имеет несколько целей. Во-первых, она осуществляет изоляцию процессов. Процесс в своём адресном пространстве получает доступ памяти только как к адресам виртуальной памяти. Соответственно, он имеет доступ к данным, которые предварительно были отображены в его собственное виртуальное пространство, и не имеет доступа к памяти других процессов (если память или часть памяти другого процесса не является общей (shared memory)).
Вторая цель – абстракция физической памяти. Ядро может изменять адреса физической памяти, на которую отображена виртуальная память. Кроме того, ядро может вообще не выделять физическую память процессу, пока она действительно не понадобится. Так же, ядро может переместить часть памяти в swap
, если она не используется продолжительное время. Всё вместе это даёт большую свободу ядру, и единственным ограничением для него является то, что когда процесс обращается к участку памяти – он должен найти там ту же информацию, которую он записал туда ранее.
Третья цель – возможность выделения адресов объектам, которые ещё не загружены в память. Это основной принцип, лежащий в основе вызова mmap()
и отображения файлов в память (file-backend память). Вы можете выделить адрес в памяти для файла, и к нему можно будет обращаться как к любому объекту в памяти. Это очень полезная абстракция, которая помогает упростить код приложений и, в системах х64, и у вас имеется огромное пространство виртуальных адресов, в которое вы можете отобразить хоть всё содержимое вашего жёсткого диска.
Четвёртая цель использования виртуальной памяти – разделение (sharing)
памяти для совместного использования процессами. Так как ядро знает, что процесс загружает в память, оно может избежать повторной загрузки объектов, и выделить новые адреса виртуальной памяти для другого процесса, которые будут отображены на те же адреса физической памяти, что и у другого процесса.
Результатом использования “разделяемой памяти” является появление механизма COW (copy-on-write): когда два процесса используют одну и ту же информацию, но один из них изменяет её и при этом второй процесс не имеет доступа на чтение изменившихся данных, ядро выполняет копирование этих данных. Недавно операционная система так же получила возможность определять идентичные данные в разлиных адресах памяти и автоматически отображать их на один и тот же адрес физической памяти. В Linux это называется KSM (Kernel SamePage Merging)
fork()
Наиболее часто используемым случаем COW
является fork()
. В Unix-like системах fork()
является системным вызовом, который создаёт новый процесс методом копирования текущего. По кончании выполнения fork()
, оба процесса продолжают свою работу с того же состояния, с теми же открытыми файлами и памятью. Благодаря COW, fork()
не дублирует всю память процесса, когда “форкает” его, а использует новые адреса памяти только для тех данных, которые изменились родительским или дочерним процессом.
Кроме того, когда вызов fork()
создаёт копию (“снимок“, snapshot) памяти процесса – он потребляет на это очень мало системных ресурсов. Поэтому, если вы хотите выполнить какие-то операции над памятью процесса без риска потери “почвы под ногами”, и при этом вы хотите избежать дорогостоящих (в плане ресурсов системы) действий или каких-то механизмов блокировок, которые могут содержать или приводить к ошибкам – просто используйте fork()
, выполните вашу задачу и передайте рузультат её выполнения обратно к родительскому процессу (с помощью кода возврата, файла, через разделяемую память, каналы (pipe
) и т.д.).
Этот механизм отлично работает, пока результаты выполнения задачи выполняются достаточно быстро, что бы большая часть памяти между родительским и дочерними процессами оставалась разделяемой (общей). Кроме того, это поможет писать более простой и читаемый код, так как все сложные задачи будут скрыты в ядре операционной системы, в коде системы виртуальной памяти, а не в вашем.
Страницы (pages)
Виртуальная память разделена на страницы. Размер одной страницы (прим.: не путайте с блоками (block),которые относятся к данным жесткого диска, тогда как страницы – к памяти) определяется процессором и обычно равен 4 КБ (килобайтам). Это означает, что управление памятью выполняется на уровне страниц, а не битах-байтах и т.д. Когда вы запрашиваете память у системы – ядро выделяет вам одну или более страниц, когда вы особождатете память – вы освобождаете одну или более страниц памяти.
Для каждой выделенной (allocated) страницы ядро устанавливает набор прав доступа (как с обычными файлами в файловой системе) – страница может быть доступна на чтение (readable), запись (writable) и/или выполнение (executable). Эти права доступа устанавливаются либо во время отображения (выделения) памяти, либо с помощью системного вызова mprotect()
уже после выделения. Страницы, которые ещё не выделены недоступны. Когда вы попытаетесь выполнить недопустимое действие над страницей памяти (например – считать данные со страницы, у которой не установлено право на чтение) – вы вызовете исключение Segmentation Fault
.
Типы памяти (memory types)
Не вся память, выделенная в виртуальном пространстве, одинаковая. Мы можем классифицировать память по двум “осям”: первая – является ли память частной (private), т.е. специфичной для процесса, или общей (shared), и вторая ось – является ли память file-backed или нет (в таком случае – она является “анонимной” (anonymous) памятью). Таким образом – мы можем создать 4 класса памяти:
Private | Shared | |
Anonymous |
1
stack
malloc()
mmap(ANON, PRIVATE)
brk()/sbrk()
|
2mmap(ANON, SHARED) |
File-Backed |
3
mmap(fd, PRIVATE)
binary/shared libraries
|
4mmap(fd, SHARED) |
Собственная память (private memory)
Private memory, как становится понятно из названия, является памятью, выделенной конкретному процессу. Большая часть памяти, с которой вы сталкиваетесь, является частной (private).
Так как изменения, выполняемые в области private memory не видны другим процессам, она является одним из объектов для выполнения COW. Помимо прочего, это означает что различные процессы могут использовать одни и те же области физической памяти. Как правило, это верно для файлов и общих библиотек. Распространено ошибочное мнение, что KDE использует много RAM, так как каждый процесс загружает Qt
и библиотеки KDElibsm
. Однако благодаря механизму COW – все процессы будут использовать одни и те же области физической памяти для read-only
частей этих библиотек.
В случае file-backed private memory, изменения, сделанные одним процессом, не будут записаны в файл, который является “основой” этого участка памяти, в то время как изменения в самом файле могут быть доступны или недоступны для процесса, работающего с этим участком памяти.
Shared memory предназначена для взаимодействия между процессами. Она может быть создана только явным вызовом mmap()
или shm_open()
. Когда процесс вносит изменения в разделяемую память, эти изменения видимы всем процсессам, которые используют эту же область памяти.
В случае file-backed памяти, любой процесс, использующий эту область памяти будет видеть изменения сразу же, так как они выполняются непосредственно через сам файл.
Anonymous Memory
Anonymous memory выделяется непосредственно в RAM. Однако, ядро не будет отображать эту память на адреса физической памяти, пока в неё не будет явной необходимости. Как следствие, anonymous memory не оказывает никакого воздействия на ядро системы, пока она явно не используется. Это позволяет процессам резервировать большое количество памяти в адресном пространстве их виртуальной памяти без использования физической памяти. Таким образом, ядро позволяет вам резервировать больше памяти, что на самом деле доступно в системе. Такое поведения так же известно как over-commit (или memory overcommitment)
File-backed и Swap
Когда создаётся file-backed участок памяти (часть памяти, содержащая данные файла, отображённого в память) – данные загружаются напрямую с файловой системы. Как правило, данные загружаются по требованию, однако вы можете дать указание ядру, что бы оно загружало файл в память до его чтения процессом (?) с диска. Это может сделать вашу программу более быстродействующей, если вы заранее знаете к каим файлам или части файлов будут выполняться вызовы чтения/записи. Что бы избежать использования слишком большого объёма памяти – вы так же можете указать ядру, что данные вам не требуются после использования. Это можно сделать с помощью вызова madvise()
(прим.: видимо имеется ввиду MADV_DONTNEED
).
Когда система начнёт испытывать недостаток физической памяти, ядро постарается переместить часть данных из RAM на физический диск. Если память является разделяемой и file-backed – это выполняется достаточно просто. Так как источником данных в памяти является файл, эти данные просто удаляются из памяти, а при следующем обращении – просто заново считываются с того же файла.
Ядро так же может вытеснить anonymous/private из RAM. В таком случае, данные записываются в специальное место на диске (swap
-раздел, или swap
-файл). Этот процесс называется “swapping out“. Далее, система работает с этими данными как с данными file-backed памяти – в случае, когда они снова понадобятся – они будут заново считаны с диска и снова загружены в память.
Оригинал статьи – тут>>>.