Как работает память в Linux: числа и байты

Как работает память в Linux: числа и байты

Часть первая: физическая память

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

Также память включает в себя оперативное запоминающее устройство (ОЗУ) или RAM, где можно записывать и считывать информацию. Существует статическая ОЗУ (SRAM) и динамическая ОЗУ (DRAM), различающиеся в том, как хранится информация. В SRAM информация сохраняется до выключения питания, в то время как в DRAM используются транзисторы и конденсаторы, что позволяет хранить данные, но требует их периодического обновления. Разные типы ОЗУ имеют свои преимущества и недостатки, и выбор зависит от конкретных потребностей.

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

Но что такое физическая память, как она работает в Linux? Что такое сегментация, утечки памяти и некие «страницы»?

Все, что вы хотели знать, но боялись спросить о памяти пингвина — читайте здесь и сейчас!

Чем занимается память в ОС?

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

Память — это тот компонент компьютера, в котором хранятся программы и данные. Без памяти и не было бы современных компьютеров.

Основной единицей хранения данных в памяти является двоичный разряд, который называется битом. Считается что некоторые компьютеры используют и десятичную, и двоичную арифметику, но это не так. Они используют двоично-десятичный код. Для хранения одного десятичного разряда задействуют 4 бита. Эти 4 бита дают 16 комбинаций для размещения 10 различных значений (от 0 до 9). При этом 6 остальных значений не используется. В двоично-десятичном представлении 16 бит достаточно для сохранения числа от 0 до 9999, то есть доступно 10000 комбинаций. А если бы те же устройства использовались для хранения двоичных чисел, они могли бы содержать всего 16 комбинаций.

Адреса памяти

Память состоит из неких секций, ячеек. Каждая из них может хранить порцию информацию. Каждая ячейка имеет номер, который называется адресом. По адресу программы могут ссылаться на определенную ячейку. Если память содержит N ячеек. Они будут иметь адреса от 0 до N-1. Все ячейки памяти содержат одинаковое количество битов. Если ячейка состоит из k бит, она может содержать любую из 2^k комбинаций.

В компьютерах с двоичной системой исчисления (включая восьмеричное и шестнадцатеричное представление) адреса памяти выражаются, как не удивительно, тоже в двоичных числах. Если адрес состоит из m бит, то максимальное число адресуемых ячеек составит 2^m. Число битов в адресе определяет максимальное кол-во адресуемых ячеек памяти и не зависит от числа битов в ячейке.

Ячейка — минимальная адресуемая единица памяти. В последние годы практически все производители выпускают компьютеры с 8-разрядными ячейками, которые называются байтами (иногда октетами). Байты группируют в слова. В компьютере с 32-разрядным словами на каждое слово приходится 4 байт, а в 64 разрядном — 8 байт. Таким образом, 32 разрядная машина содержит 32-разрядные регистры и команды для манипуляций с 32 разрядными словами, а 64 разрядная машина имеет уже 64 разрядные регистры и манипуляции с соответствующими словами.

Байты в слове могут нумероваться слева направо или наоборот. Нумерация с высшего порядка, она относится к прямому порядку следования байтов (big endian). А также наоборот — обратный порядок следования байтов (little endian).

Фан-факт: эти термины взяты из книги Джонатана Свифта «Путешествия Гулливера» — он иронизировал по поводу спора королей, с какого конца разбивать яйца. Введены эти термины в статье Коэна 1981 года.

Важно понимать, что в обеих системах 32-разрядное целое число (например, 6) представлено битами 110 в трех крайних правых битах слова, а остальные 29 бит — нули. Если байты нумеруются слева направо, то биты 110 находятся в байте 0 (или 4, или 8, или 16 и т.д). В обеих системах слово содержащее это целое число, имеет адрес 0.

ОЗУ и ПЗУ

Память, которая позволяет и записывать, и считывать информацию называется ОЗУ (оперативное запоминающее устройство) или RAM (Random Access Memory — оперативная память). Существуют два типа ОЗУ — статистическое и динамическое. Статистическое ОЗУ (SRAM) конструируется с использованием D-триггеров. Информация в ОЗУ сохраняется на протяжении всего времени, пока есть питание. Оно работает очень быстро, и его используют в качестве кеш-памяти второго уровня.

В динамическом ОЗУ (DRAM), напротив, триггеры не используются. ДОЗУ — это набор, массив ячеек, каждая из которых содержит транзистор и маленький конденсатор. Конденсаторы могут быть заряженными и разряженными, что позволяет хранить нули и единицы. Поскольку электрический заряд может исчезать, каждый бит в ДОЗУ обновляется каждые несколько миллисекунд, иначе память утечет. Поскольку об обновлении должна заботиться внешняя логика, ДОЗУ требует более сложного сопряжения, чем СОЗУ. Но этот недостаток компенсируется большим объемом.

Существует несколько типов ДОЗУ. Самый древний тип, который еще используется — FPM (быстрый постраничный режим). Это ОЗУ представляет собой матрицу битов. Аппаратное обеспечение представляет адрес строки, а затем — адреса столбцов.

FPM постепенно заменяют на EDO (память с расширенными возможностями вывода), который позволяет обращаться к памяти еще до того, как закончилось предыдущее обращение. Такой конвейерный режим, хотя и не ускоряет доступ к памяти, повышает пропускную способность, позволяя получить больше слов в секунду.

Память FPM и EDO сохраняла актуальность во времена, когда продолжительность работы микросхемы памяти не превышала 12 нс. Впоследствии сформировалась потребность в более быстрых микросхемах памяти, и на смену асинхронным режимам пришли СДОЗУ (синхронные динамические ОЗУ, SDRAM). Синхронное динамическое ОЗУ управляется от главного системного тактового генератора. Данное устройство представляет собой гибрид статистического и динамического ОЗУ. Основное преимущество СДОЗУ — оно исключает зависимость микросхемы памяти от управляющих сигналов. Устранение этой проблемы ускорило обращение между процессором и памятью.

Следующий виток развития — DDR (Double Data Rate, передача данных с двойной скоростью). Эта технология предусматривает вывод данных как на фронте, так и на спаде импульса.

Но ОЗУ — не единственный тип микросхем памяти. Во многих случаях данные должны сохраняться даже при отключении питания — и это ПЗУ (постоянно запоминающих устройств) или ROM (Read Only Memory). ПЗУ не позволяет изменять и стирать данные, они записываются в процессе производства.

Управление памятью

Управление памятью (memory management) является важной подсистемой операционной системы Linux, которая обеспечивает эффективное использование ресурсов физической и виртуальной памяти. В Linux управление памятью в основном подразумевает обработку запросов к памяти от процессов, выделение и освобождение блоков памяти, а также обеспечение ее эффективного использования.

Ключевые понятия управления памятью в Linux:

  • Виртуальная память (Virtual Memory) — Linux использует концепцию виртуальной памяти, которая создает иллюзию наличия у каждого процесса своего личного пространства памяти. Виртуальная память позволяет системе исполнять код приложений, используя больший объем памяти, чем физически доступно. Это достигается путем сброса неиспользуемых блоков памяти приложений на диск
  • Система страница (Paging) — физическая и виртуальная память разделены на блоки фиксированного размера, которые называются страницами. Система страниц позволяет эффективно управлять памятью и активирует механизм обмена данными между ОЗУ и диском (swap)
  • Выделение памяти (Memory Allocation) — при выполнении команд процессам требуется память. За выделение процессам подходящих блоков памяти отвечает соответствующий диспетчер. Память выделяется из свободной физической памяти. При необходимости физическая память освобождается сбросом неактивных страниц на диск
  • Пространство ядра и пользовательское пространство (Kernel Space and User Space) — память в Linux подразделяется на пространство ядра и пользовательское пространство. Пространство ядра зарезервировано для исполнения кода ядра, расширений ядра и большинства драйверов устройств. Пользовательское пространство – это область памяти, с которой работают все пользовательские приложения
  • Кеширование (Caching) — Linux использует несколько механизмов кеширования для улучшения производительности системы. Так, например, кеш страниц (page cache) используется для кеширования файлов, читаемых с диска, а кеш буфера (buffer cache) используется для управления операциями записи на диск
  • Чрезмерное выделение памяти (Memory Overcommit) — Linux позволяет выделить процессам больше памяти, чем реально доступно. Эта концепция известна как memory overcommit. Она позволяет большему количество процессов выполняться одновременно при условии, что процессы не используют всю выделенную им память. По-умолчанию vm.overcommit_ratio равно 50, т.е. процессы не смогут занять (именно реально физически занять) больше 50% памяти.

Задача распределения ресурсов между программами лежит на плечах ядра нашей операционной системы — в данном случае Linux. Для обеспечения иллюзии полной независимости, ядро дает каждой программе свое виртуальное адресное пространство и низкоуровневый интерфейс работы с ним. Это избавляет от необходимости знать друг о друге, размер памяти и совершать другие лишние действия. Адреса в виртуальном пространстве процессов называют логическими.

Для отслеживания соответствия между физической и виртуальной памятью ядро Linux использует иерархический набор структур данных в своей служебной области физической памяти (только оно работает с ней напрямую), а также специализированные аппаратные контуры, которые называют MMU.

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

Но ядро оперирует страницами не потому, что следить за каждым байтом сложно, а потому что аппаратура управления памятью (MMU) отображает виртуальные адреса на реальные, в конечном счете, именно страницами, а не отдельными байтами. И, кстати говоря, размер страницы вовсе не обязан быть равен 4 Кбайтам — это, как минимум, зависит от возможностей аппаратуры.

Но на аппаратном уровне чаще всего есть поддержка дополнительного уровня абстракции в виде «сегментов» оперативной памяти, с помощью которых можно разделять программы на части. В отличие от других операционных систем, в Linux она практически не используется — логический адрес всегда совпадает с линейным (адресом внутри сегмента).

Но есть два «но»:

Во-первых, сегментация — отличительная фишка архитектуры IA-32 aka x86, доступная в 16- и 32-разрядных режимах, но выпиленная из 64-разрядного режима. У других архитектур поменьше легаси-проблем из-за обратной совместимости.

Во-вторых, сегментацией практически никто никогда не пользовался с момента появления 80386. В частности, ей никогда не пользовались в 32-разрядных версиях Windows.

Как вы уже знали, существует виртуальная память — это то, что создает ОС для работы программ с ней. Это и ОЗУ (о которой мы говорили выше), и все SWAP разделы. Выделяемая память процессу может быть либо резидентная, либо виртуальная. Ниже я запустил команду ps, которая позволяет анализировать запущенные процессы. В листинге видно, что у процессов есть резидентная (rss) и виртуальная (vsz) память. Эта память отображается в KB.

$ ps -C gnome-shell -o pid,user,rss,vsz,comm

    PID USER       RSS    VSZ COMMAND
    941 alexeev  147252 4197004 gnome-shell

Для примера я взял Gnome Shell v46.0. Резидентная память, занятая gnome-shell равна ~147 MB. А виртуальная уже ~4197 MB. Вот это разброс!

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

Приложение может запросить много памяти, а использовать малую её часть. Поэтому почти всегда rss меньше чем vsz.

Раздел подкачки (SWAP) — это раздел на жестком диске, куда помещаются:

  • редко используемые данные из резидентной памяти;
  • любые данные при нехватке физической памяти.

Если какие-то данные из rss сбрасываются в swap то rss освобождается, а vsz нет. От сюда следует что данные процесса, которые лежат в swap, входят в виртуальную память этого процесса.

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

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

Посмотреть более подробно на используемую память процесса поможет файл /proc//status.

Система управления страницами

Кластеры или страницы — это блоки на уровне файловой системы, с которыми происходит работа. Запись и чтение происходит блоками. Размер по умолчанию — 4KB, как я уже говорил.

Вся виртуальная память состоит из этих самых кластеров. Но эти страницы бывают не только размером 4 килобайта, есть еще и huge page — блоки по 2 мегабайтам, либо гигабайту. Используется, когда происходит работа с большими данными, например базами данных (также структура таблицы страниц становится оптимальнее).

Страницы бывают грязные и чистые. Чистые — не подвергались изменению, грязные — подвергались. Например, вы загрузили библиотеку — это чистая страница, она не была подвергнута изменению. А если загружен файл, и после он был изменен, то он уже грязный. Его нужно сохранить, иначе записанная информация сотрется.

Больше всего в системе памяти занимает страничный кеш (Page Cache). Вся работа с файлами на диске (запись или чтение) идет через Page Cache. Запись в Linux всегда быстрее чтения (не всегда, например при использовании O_SYNC), так как запись вначале идет в страничный кеш, и только потом на диск. А при чтении ядро ищет файл в Page Cache, и если не находит, читает файл с диска. Узнать сколько сейчас система тратит памяти на Page Cache можно узнать при помощи команды free:

$ free -h

               total        used        free      shared  buff/cache   available
Mem:           1.5Gi       1.2Gi        34Mi       191Mi       548Mi       362Mi
Swap:             0B          0B          0B

Страничный кеш показан в колонке buff/cache. Как мы видим у нас занято 548Mi страничным кешем. Хотя тут не только Page Cache, тут также находится Buffer, который тоже связан с файлами на диске.

Посмотреть информацию по Page Cache и Buffer отдельно можно в файле /proc/meminfo:

$ grep "^Cach|^Buff" -E /proc/meminfo

Buffers:            3280 kB
Cached:           518724 kB

При создании нового файла, запись идет в cache, а страницы памяти для этого файла помечаются как грязные (dirty). Раз в какой-то промежуток времени грязные страницы сбрасываются на диск, и если таких страниц будет слишком много, то они тоже сбросятся на диск. Управлять этим можно через параметры sysctl (sudo nano /etc/sysctl.conf):

  • vm.dirty_expire_centisecs — интервал сброса грязных страниц на диск в сотых долях секунд (100 = 1с);
  • vm.dirty_ratio — объем оперативной памяти в процентах который может быть выделен под Page Cache.

$ sudo sysctl vm.dirty_expire_centisecs

vm.dirty_expire_centisecs = 3000

$ sudo sysctl vm.dirty_ratio

vm.dirty_ratio = 20

Узнать объем грязных страниц можно из файла /proc/meminfo. А команда sync записывает грязные страницы на диск:

$ grep Dirty /proc/info

Dirty:               864 kB

А команда sync записывает данные на диск:

# sync

$ grep Dirty /proc/info

Dirty: 			     0 kB

Huge Pages

Хорошей памяти должно быть много. Поговорим немного про большие страницы HugePages. Особенности таких страниц:

  • размер таких страниц равен 2MB;
  • приложение должно уметь работать с такими страницами;
  • эти страницы никогда не сбрасываются в swap.

Выделить память под HugePages страницы можно параметром sysctl:

  • vm.nr_hugepages = <число страниц> (так если указать 1024 то выделится 1024*2МБ=2048MB).
  • vm.hugetlb_shm_group = gid — только члены этой группы могут использовать HugePages.

После исправления /etc/sysctl.conf нужно перезагрузить и посмотреть на результат в файле /proc/meminfo:

$ egrep "HugePages_T|HugePages_F" /proc/meminfo

HugePages_Total:    1024

HugePages_Free:     1024

Выделено 1024 страниц и все они свободны. При этом у нас 2GB памяти не сможет использоваться обычными приложениями, которые не умеют работать с HugePages. Поэтому не всегда нужно выделять HugePages.

Swap раздел

С файловой памятью всё просто: если данные в ней не менялись, то для её вытеснения делать особо ничего не нужно — просто перетираешь, а затем всегда можно восстановить из файловой системы.

С анонимной памятью такой трюк не работает: ей не соответствует никакой файл, поэтому чтобы данные не пропали безвозвратно, их нужно положить куда-то ещё. Для этого можно использовать так называемый «swap» раздел или файл. Можно, но на практике не нужно. Если swap выключен, то анонимная память становится не вытесняемой, что делает время обращения к ней предсказуемым.

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

mlock

По умолчанию вся файловая память является вытесняемой, но ядро Linux предоставляет возможность запрещать её вытеснение с точностью не только до файлов, но и до страниц внутри файла.

Для этого используется системный вызов mlock на области виртуальной памяти, полученной с помощью mmap. Если спускаться до уровня системных вызовов не хочется, рекомендую посмотреть в сторону консольной утилиты vmtouch, которая делает ровно то же самое, но снаружи относительно приложения.

Несколько примеров, когда это может быть целесообразно:

  • У приложения большой исполняемый файл с большим количеством ветвлений, некоторые из которых срабатывают редко, но регулярно. Такого стоит избегать и по другим причинам, но если иначе никак, то чтобы не ждать лишнего на этих редких ветках кода — можно запретить им вытесняться.
  • Индексы в базах данных часто физически представляют собой именно файл, с которым работают через mmap, а mlock нужен, чтобы минимизировать задержки и число операций ввода-вывода на и без того нагруженном диске(-ах).
  • Приложение использует какой-то статический словарь, например с соответствием под-сетей IP-адресов и стран, к которым они относятся. Вдвойне актуально, если на одном сервере запущено несколько процессов, работающих с этим словарем.

OOM Killer

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

Происходит это достаточно радикальными методами: послуживший названием данного раздела механизм выбирает по определенному алгоритму процесс, которым наиболее целесообразно в текущий момент пожертвовать — с остановкой процесса освобождается использовавшаяся им память, которую можно перераспределить между выжившими. Основной критерий для выбора: текущее потребление физической памяти и других ресурсов, плюс есть возможность вмешаться и вручную пометить процессы как более или менее ценные, а также вовсе исключить из рассмотрения. Если отключить OOM killer полностью, то системе в случае полного дефицита ничего не останется, как перегрузиться.

cgroups

По умолчанию все пользовательские процессы наравне претендуют на почти всю физически доступную память в рамках одного сервера. Это поведение редко является приемлемым. Даже если сервер условно-однозадачный, например только отдает статические файлы по HTTP с помощью nginx, всегда есть какие-то служебные процессы вроде syslog или какой-то временной команды, запущенной человеком. Если же на сервере одновременно работает несколько production процессов, например, популярный вариант — подсадить к веб-серверу memcached, крайне желательно, чтобы они не могли начать «воевать» друг с другом за память в случае её дефицита.

Для изоляции важных процессов в современных ядрах существует механизм cgroups, c его помощью можно разделить процессы на логические группы и статически сконфигурировать для каждой из групп сколько физической памяти может быть ей выделено. После чего для каждой группы создается своя почти независимая подсистема памяти, со своим отслеживанием вытеснения, OOM killer и прочими радостями.

Механизм cgroups намного обширнее, чем просто контроль за потреблением памяти, с его помощью можно распределять вычислительные ресурсы, «прибивать» группы к ядрам процессора, ограничивать ввод-вывод и многое другое. Сами группы могут быть организованы в иерархию и вообще на основе cgroups работают многие системы «легкой» виртуализации и нынче модные Docker-контейнеры.

NUMA

В многопроцессорных системах не вся память одинакова. Если на материнской плате предусмотрено N процессоров (например, 2 или 4), то как правило все слоты для оперативной памяти физически разделены на N групп так, что каждая из них располагается ближе к соответствующему ей процессору — такую схему называют NUMA.

Таким образом, каждый процессор может обращаться к определенной 1/N части физической памяти быстрее (примерно раза в полтора), чем к оставшимся (N-1)/N.

Ядро Linux самостоятельно умеет это всё определять и по-умолчанию достаточно разумным образом учитывать при планировании выполнения процессоров и выделении им памяти. Посмотреть как это все выглядит и подкорректировать можно с помощью утилиты numactl и ряда доступных системных вызовов, в частности get_mempolicy/set_mempolicy.

Работа с памятью в Linux

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

При рассмотрении подсистемы памяти важно знать и понимать, какие типы памяти есть и про какие говорят. Далее будут рассматриваться два типа памяти:

  • Физическая — оперативная память
  • Линейная — виртуальная память, она может быть больше, чем реально физической памяти у вас есть.

Вся физическая память разбита на страничные кадры. Размер страничного кадра — платформо-зависимая величина, для x86 она обычно равна 4 Кб, хотя может быть и 4 Мб. Каждый физический кадр описывается фундаментальной структурой данных — struct page (include/linux/mm_types.h). Структура используется, чтобы отслеживать состояние страничного кадра: свободен или выделен, кому он принадлежит, что на нём хранится: данные, код и т.д. Struct page организована в блоки двойных слов для выполнения над ними атомарных операций, работающих с двойными словами. Опишем некоторые важные поля struct page:

  • atomic_t _refcount — количество ссылок на структуру page. Из функции init_free_pfn_range() (mm/init.c) следует, что если _refcount равен 0, то страничный кадр свободен, если >0, то кем-то или чем-то занят.
  • unsigned long flags — содержит флаги, описывающие состояние страничного кадра. Все флаги описаны в файле (include/linux/page-flags.h).

Физическая память 32-битной машины в Linux разделяется на 3 части — зоны:

  • ZONE_DMA — первые 16 Мб физической памяти,
  • ZONE_NORMAL — занимает адреса с 16 Мб по 896 Мб,
  • ZONE_HIGHMEM — содержит страничные кадры выше 896 Мб

Такое разбиение физической памяти в 32 битных системах связано с тем, что в них можно адресовать только лишь 4 Гб линейной памяти, при этом процессу необходимо работать, как в пользовательском режиме, так и в режиме ядра, например, для выполнения системных вызовов. Потому линейное пространство адресов процесса разбивается на несколько частей: 3 Гб под пользователя и 1 Гб под ядро. В первых 3 Гб в адресах до 0xС0000000 процесс работает в режиме обычного пользователя, а адреса выше 0xС0000000 используются в режиме суперюзера. Зоны NORMAL и DMA напрямую отображаются в 4-ый Гб линейного адресного пространства. К объектам, расположенным в этих областях, всегда можно получить доступ, так как для них существуют линейные адреса. А вот HIGHMEM зона содержит кадры, к которым ядро так просто обратится не может. Из-за того, что HIGHMEM содержит кадры, линейные адреса которых просто-напросто не существуют в 32-битной системе. Потому функция для выделения страничных кадров — alloc_page() возвращает указатель(линейный адрес) не на первый страничный кадр, а на первый страничный дескриптор, описывающий этот кадр. При этом все дескрипторы страничных кадров находятся в NORMAL зоне, потому для них всегда существует линейный адрес. Для отображения верхних адресов в линейном адресном пространстве используются верхние 128 МБ NORMAL адресов. Вообще для отображения HIGHMEM есть несколько техник:

  • постоянное отображение,
  • временное отображения,
  • работа с не смежными областями памяти.

Linux — современная кросс платформенная операционная система, а такая система обязана уметь эффективно работать с многопроцессорными системами. В таких системах существует несколько подходов к реализации компьютерной памяти. Первая — Uniform memory access (UMA). В этой схеме доступ ко всей физической памяти примерно равноценен по времени, потому нет абсолютно никакой разницы для производительности операционной системы к каким адресам обращаться. Надо заметить, что не в каждой вычислительной системе поддерживается одинаковый доступ к памяти, потому в Linux в качестве базовой модели поддерживается — Non-Uniform memory access (NUMA). В этой модели физическая память системы разделяется на несколько узлов. Каждый узел описывается структурой pg_data_t (include/linux/mmzone.h). Каждый узел потенциально может содержать любую из зон памяти, потому структура pg_data_t содержит их описатели. Все дескрипторы страничных кадров узлов хранятся в глобальном массиве zone_mem_map, который располагается в описателе соответствующей зоны:

                    pg_data_t
                         |
     ________________node_zones_______________
    /                    |                    \
ZONE_DMA            ZONE_NORMAL          ZONE_HIGHMEM
    |                    |                     |
zone_mem_map        zone_mem_map           zone_mem_map

Красота такого подхода при работе с памятью заключается в том, что UMA представляется просто, как NUMA с одним узлом, что так же позволяет использовать везде одинаковые методы — универсальность во всём, так сказать.

На 64-битных машинах, физическая память так же разделяется на 3 части, но в силу объективных причин, реальные 64-битные машины не могут сейчас содержать все 2^64 степени байт памяти. В x86, например, поддерживается память только до 2^48 байт = 256 Тб, что, согласитесь, достаточно много. Так как реальной физической памяти много меньше линейной, то у 64-битных систем надобность в HIGHMEM зоне пока отсутствует, она нулевая, а вся помять делиться между DMA и NORMAL.

Теперь мы знаем, как Linux описывает доступную ему физическую память, пришло время разобрать, как ядро работает с памятью. Для этого важно разобрать, как Linux её выделяет, или, иными словами, как работают аллокаторы.

BOOTMEM

Самый первый доступный ядру аллокатор памяти — bootmem(mm/bootmem.c). bootmem алокатор используется только при загрузке ядра для начального выделения физической памяти до того, как подсистема управления памятью станет доступной. bootmem работает очень прямолинейно по алгоритму первый подходящий — ищет первый свободный кусок(страницу) физической памяти и выдаёт. Для представления физической памяти использует bitmap, если 1, то страница занята, если 0, то свободна. Для выделения памяти меньше страницы он записывает PFN последней такой аллокации, и следующая маленькая локация будет, если возможно, располагаться на той же физической странице. Алокатор с алгоритмом первый наиболее подходящий, не сильно страдает от фрагментации, но из-за использования bitmap крайне медленный.

/include/linux/bootmem.h
/*
 * node_bootmem_map is a map pointer - the bits represent all physical
 * memory pages (including holes) on the node.
 */
typedef struct bootmem_data {
    unsigned long       node_min_pfn;
    unsigned long       node_low_pfn;
    void                *node_bootmem_map;
    unsigned long       last_end_off;
    unsigned long       hint_idx;
    struct list_head    list;
} bootmem_data_t;

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

---------------
|   kmalloc   |
------------------------
|   kmemcache | vmalloc|
------------------------
|         buddy        |
------------------------

Buddy

Buddy — аллокатор смежных страничных кадров, а не линейных страниц, так как для некоторых задач, таких как DMA нужны именно смежные физические страницы, потому что DMA-устройства работают с памятью напрямую. Ещё одной причиной такого подхода является то, что это позволяет не трогать таблицы страниц ядра, что ускоряет работу с памятью. Проблема аллокаторов смежных страниц — внешняя фрагментация, потому в buddy аллокаторе в Linux применяется стандартный подход — разбиение всех доступных страничных кадров на списки по степени двойки: 1, 2, 4, 8, 16, …, 1024. 1024*4096 = 4МВ. Физический адрес первого страничного кадра в блоке кратен размеру группы. Алгоритм работы: хотим выделить 256 кадров. Аллокатор проверит в списке 256, если нет, заглянет в 512, если есть возьмёт 256 кадров, а оставшиеся поместит в список 256. Если и в 512 нет, то проверяет в 1024, если есть, то возвращает 256 кадров запросившему, а оставшиеся 768 разобьёт по двум спискам 512 и 256, если и в 1024 нет, то сигналит об ошибке. У системы buddy есть глобальный объект, хранящий дескрипторы всех доступных кадров, а на каждом отдельном процессоре есть свои локальные списки доступных кадров, если в локальных списках закончилась память, то он подтягивает из глобального и наоборот возвращает если в локальных они свободны. У каждой зоны свой собственный buddy аллокатор. Для работы с buddy аллокатором необходимо использовать функции alloc_page/__rmqueue()(mm/page_alloc.c) — выделение, __free_pages()- освобождение. При работе с этими функциями необходимо отключать прерывания и брать спин блокировку zone->lock.

Плюсы buddy:

  • Быстрее bootmem(не использует bitmap).
  • Можно выделять несколько страничных кадров подряд.

Минусы buddy:

  • Нельзя выделить меньше страничного кадра, всегда выделяет >= PAGESIZE.
  • Выделяет только идущие по очереди в физической памяти, что всё равно приводит к фрагментации.

VMALLOC

У работы со смежными физическими областями есть свои плюсы в виде быстрой работы с памятью, однако и минусы в виде внешней фрагментации. В Linux есть возможность работать с не смежными областями физической памяти, к которым можно обращаться через смежные области линейного пространства. Начало области линейного пространства, где отображаются не смежные области физического, можно получить из макроса VMALLOC_START, конец — VMALLOC_END. Каждая несмежная область памяти описывается структурой(include/linux/vmalloc.h)

struct vm_struct {
    struct vm_struct    *next;      // <- список
    void                *addr;      // линейный адрес первой ячейки
    unsigned long       size;       // size + 4096(окно безопасности между несмежными областями)
    unsigned long       flags;      // тип памяти, отображаемой несметной области
    struct page         **pages;
    unsigned int        nr_pages;
    phys_addr_t         phys_addr;
    const void          *caller;
};

Выделение страниц производится функцией void *vmalloc(unsigned long size) (mm/vmalloc.c). size — размер запрашиваемой области. Выделяет память кратно странице, потому первым делом округляет size до кратного странице размера. Он выдаёт последовательные страницы, но уже в виртуальном адресном пространстве. vmalloc берёт физические страницы у buddy по страничному кадру. Освобождать память можно с помощью vfree(). Минус заключается в том, что наступает фрагментация, но уже в виртуальном памяти, плюс появляется необходимость обращаться в таблицы страниц, что долго. Потому vmalloc редко вызывают. Его применяют для модулей, буферы ввода /вывода, сетевого экрана, отображение верхней памяти.

KMemCache

Очевидно, что для работы с маленькими областями памяти произвольной длины не buddy, не vmalloc не подходят, из-за их расточительности. Потому в Linux есть ещё одна система памяти — kmemcache, которая позволяет выделять память под небольшие объекты в пределах страничного кадра. Однако тут надо быть осторожнее, так как может возникнуть проблема внутренней фрагментации. Вообще говоря под kmemcache скрывается аде целых 3 системы: SLAB/SLUB/SLOB. Суть этих систем достаточно похожа, но имеются и существенные отличия:

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

Сначала опишем интерфейс SLAB. Slab базируется на нескольких наблюдениях. Во-первых, ядро часто запрашивает и возвращает области памяти одного и того же размера для различных структур, потому для ускорения можно не освобождать, а оставлять их в кеше для себя, а потом переиспользовать, что сэкономит время. Лучше как можно реже обращаться к buddy, так как каждое обращение к нему загрязняет аппаратный кэш. Так же можно создать объекты размером не кратным двойки, если к ним происходит частое обращение, что ещё может улучшить работу аппаратного кэша. Slab группирует объекты в кэш. Каждый кэш — хранилище объектов одного типа( размера). Кеш имеет несколько slab-списков: с полностью свободными объектами, частично свободными и полностью занятыми. Кэш работает с гранулярностью 1-2-4-8 страниц.

kmem_cache       slab - список
________
|      |——————> | | -  | | - | |  - полностью свободны
|      |
|      |
|      |——————> | | -  | | - | |  - частично свободны
|      |
|      |——————> | | -  | | - | |  - полностью заняты
|      |

Для того чтобы пользоваться struct kmem_cache надо получить хэндл через функцию:

struct kmem_cache *kmem_cache_create(size);

size — фиксированный размер, который мы потом хотим получать. После можно выделить память с помощью:

void kmem_cache_alloc(kc, flags);

И освобождать:

void kmem_cache_free(kc);

Уничтожить кэш можно с помощью:

kmem_cache_destroy()

Всю информацию по SLAB можно получить в /proc/slabinfo.

Под SLAB тоже нужно было выделять память, дескриптор описывающий SLAB мог лежать: У другого kmem_cache — off-slab. Дескриптор slab может лежать в голове страницы, которую выдаёт buddy — on-slab. Но buddy выдавал нам страницу и struct page, который по размеру совпадал со slab -> struct page можно забрать у системы и использовать его под slab. Потому появился slub. Минус SLAB allocator — выделяет объекты константного размера, хотя нам не всегда известен размер объекта под который нужно выделить память.

Более высокого уровня аллокатор kmalloc/kfree(include/linux/slab.h). Он обращается к необходимому kmem_cache, получая его через статическую функцию kmalloc_index(size). В статической функции, если размер будет известен на этапе компиляции, то вызов функции будет компилятором заменён на итоговый индекс:

static __always_inline int kmalloc_index(size_t size)
{
...
if (KMALLOC_MIN_SIZE <= 32 && size > 64 && size <= 96)
        return 1;
if (KMALLOC_MIN_SIZE <= 64 && size > 128 && size <= 192)
	return 2;
if (size <= 8)
	return 3;
...
}

  • 0 = zero alloc
  • 1 = 65… 96 bytes
  • 2 = 129… 192 bytes
  • n = (2$^{n-1}$+1)… 2$^n$ //todo

Кэши размером 0/ 8/ 16/ 32/ 64/ 96/ 128/ 192 /256 …/2$^{26}$. 96 и 192 — эвристически вычисленные часто запрашиваемые значения.

Все аллокаторы работаю с группой флагов gfp_flags(include/linux/gfp.h) — get free page flags. Изначально они появились в buddy потом просочись на уровни повыше.

Типы флагов

Откуда выделять: __GFP_DMA (Get Free Page), __GFP_HIGHMEM, __GFP_DMA32. По умолчанию система старается выделять память в ZONE_NORMAL.

Поведение при нехватке памяти — контекст, в котором мы работаем по сути. Если памяти нет, то её нужно найти, например:

  • в дисковом кэше — требуется брать мютекс;
  • ядерном кэше — требуется брать мютекс;
  • освободить грязный дисковый кэш — требуется брать мютекс и обращаться к файловой системе и блокам;
  • swap требуется брать мютекс и обращаться к блокам;

kill кого-нибудь; Пример, __GFP_ATOMIC — ничего нельзя делать и buddy вернёт NULL. __GFP_NOFS — используются кэшами и буферами, чтобы быть уверенными, что их рекурсивно не позовут. __GFP_NOIO.

Всё остальное — __GFP_ZERO — память которую выдаст аллокатор должен быть забит нулями. __GFP_TEMPORARY — мне нужно выделить страницу подержу её недолго и верну. (пути) GFP_NORETRY GFP_NOFAIL.

User memory management

Запросы ядра на выделение памяти: alloc_pages() и kmalloc(), приводят к немедленному выделению памяти, если могут быть удовлетворены. Это оправдано, потому что:

  • Ядро — самый приоритетный компонент системы, его запросы критические.
  • Ядро себе доверяет, предполагается, что в ядре нет ошибок.

Для процессов, работающих в режиме пользователя, всё иначе:

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

Адресное пространство процесса

Адресное пространство процесса — линейные адреса, к которым процесс может обращаться. Ядро может динамически изменять адресное пространство процесса с помощью добавления или удаления областей памяти(vm_area_struct).

Процесс может получить новые области памяти, например, с помощью вызывов: malloc(), calloc(), mmap(), brk(), shmget() + shmat(), posix_memalign(), mmap() и т.д. В основе всех этих вызовов лежит void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);:

  • addr — адрес, где выделять память.
  • flags:
  • NULL — нет никакой разницы, где выделять память. Параметр addr используется, как рекомендация.
  • MAP_FIXED — именно там, где указано в addr.
  • MAP_ANON(MAP_ANONYMOUS) — изменения не будут видны ни в каком файле.
  • MAP_FILE — мапим из файла или устройства.
  • prot:
  • PROT_EXEC
  • PROT_READ
  • PROT_WRITE
  • PROT_NONE

Дескриптор памяти

Вся информация относительно адресного пространства процесса хранится в mm_struct (дескриптор памяти), на который указывает поле mm в task_struct.

task_struct
_________
|   …   |     mm_struct
---------    _________
|   mm  | -> |   …   |
---------    ---------
|   …   |    |  mmap |  ->   vm_area_struct * (VMA) – список двунап.
---------    ---------
             |  pgd  |  - указатель на глобальный каталог страниц
             ---------

Описание структур mm_struct и vm_area_struct можно найти в /include/linux/mm_types.h.

Область памяти

struct vm_area_struct {
    /* The first cache line has the info for VMA tree walking. */

    unsigned long vm_start;     /* Our start address within vm_mm. */
    unsigned long vm_end;       /* The first byte after our end address
                       within vm_mm. */

    /* linked list of VM areas per task, sorted by address */
    struct vm_area_struct *vm_next, *vm_prev;
             ...................
             struct rb_node               vm_rb;
             ………..
    /* Function pointers to deal with this struct. */
    const struct vm_operations_struct *vm_ops;
}

У области памяти есть два поля vm_start и vm_end, обозначающие соответственно адрес начала и первого бита после конца выделенной области. Если применить mmap() с одинаковыми аргументами, то ядро не будет создавать новый VMA, а просто изменит vm_end уже существующего.

Все области памяти объеденены в двунаправленный список, где они упорядочены по возрастанию адресов. Для того чтобы не приходилось пробегаться по всему списку при выделении, возвращении памяти или поиску VMA, которому принадлежит адрес, все VMA так же объединены в красно-чёрное дерево.

struct rb_node {
    unsigned long  __rb_parent_color;
    struct rb_node *rb_right;
    struct rb_node *rb_left;
} __attribute__((aligned(sizeof(long))));
    /* The alignment might seem pointless, but allegedly CRIS needs it */

struct rb_root {
    struct rb_node *rb_node;
};

mm_struct -> pgd — указатель на глобальный каталог страниц каждого процесса. На x86 при переключении процесса mm_struct -> pgd помещается в cr3. Изменение cr3 в свою очередь приводит к сбросу TLB. Однако, у двух task_struct может быть один и тот же mm, например, у двух потоков, тогда изменения cr3 не будет, что существенно ускоряет работу с памятью.

Помимо потоков, переключение cr3 так же не происходит для kernel_thread. Для них просто нет необходимости в областях памяти, так как они всегда обращаются к фиксированным линейным адресам выше TASK_SIZE = PAGE_OFFSET = 0xffff880000000000 (x86_64). Потому собственный mm kernel_thread в task_struct просто не нужен, он равен NULL. Зато в task_struct есть active_mm, равный active_mm вытесненного процесса.

Ещё одним интересным полем в VMA является vm_ops, оно определяет операции, которые можно выполнять для конкретной области памяти.

Работа с областями памяти

Описание функций:

  • do_mmap() (/mm/mmap.c) – выделение новой области памяти
  • do_munmap() (/mm/mmap.c) – возвращение области памяти
  • find_vma()(/mm/mmap.c) – поиск области ближайшей к данному адресу
  • find_vma_intersection() (/include/linux/mm.h) – поиск области, содержащей адрес.
  • get_unmapped_area() (/mm/mmap.c) — поиск свободного интервала
  • insert_vm_struct() (/mm/mmap.c) – внесение области в список дескрипторов

Выделение интервала линейных адресов

Линейные адреса, которые выделяются, могут быть связаны с файлом (FILE) или нет (ANON). При этом, процесс, который запрашивает память, может владеть ими совместно с кем-то (MAP_SHARED) или уникально (MAP_PRIVATE).

Отложенное выделение

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

В x86 каждая запись в таблице страниц выровнена по 4096(2^12), потому первые 12 бит несут служебную информацию относительно страницы, например:

  • 0 бит — P (Present) Flag
  • 1 бит — R/W (Read/Write) Flag
  • 2 бит — U/S (User/Supervisor) Flag

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

                             page fault
                                 \/
              Принадлежит ли адрес пространству процесса?
                   Да /                       Нет  \
                    \/                            \/
   Соответствуют ли права доступа?         Исключение возникло в режиме пользователя?
    Да /                     Нет\           Да /                             Нет \
      \/                        \/            \/                                 \/
Выделить новый                  Послать SIGSEGV                      Ошибка ядра: уничтожить процесс
страничный кадр

Если обращение происходит рядом со stack VMA – область созданная с флагом MAP_GROWDOWN, то происходит расширение области.

Повышение производительности памяти

Нехватка памяти — это частая проблема. Система начинает тормозить — подвисают окна, медленная работа. А почему это происходит? Ибо планировщик ядра Linux не может выполнить запрос на какое то действие в запущенной программе, пока не получит доступ к ее оперативной памяти, выполнить следующее действие тоже не может, образовывается очередь из запросов на чтение с диска, и система начинает медленно работать, потому что обработка очереди происходит медленнее.

Если в такой момент запустить htop, то показатель Load Average (LA) скорее всего будет высоким.

Часто на всех сайтах советуют выставить параметр vm.swappines вместо 60 на 10. На самом деле, не всегда это увеличит производительность. Этот элемент управления используется для определения того, насколько агрессивно ядро будет использовать подкачку страниц памяти. Более высокие значения увеличивают агрессивность, а низкие уменьшают объем подкачки. Значение 0 указывает ядру не запускать подкачку до тех пор, пока количество свободных страниц и страниц с файловой поддержкой не станет меньше максимального значения в зоне. Если подробнее, то значение от 0 до 100, которое определяет, в какой степени система предпочитает анонимную память или кэш страниц. Высокое значение повышает производительность файловой системы, в то же время менее активно вытесняя активные процессы из физической памяти. Низкое значение позволяет избежать перегрузки процессов из-за нехватки памяти, снижая производительность ввода-вывода. Увеличивается приоритет данных приложений, взамен ухудшается кэширование ввода-вывода.

Также можно включить zram — встроенный модуль ядра linux, который сжимает оперативную память путем увеличения нагрузки на процессор.

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

Для запуска zram нужно загрузить модуль ядра:

$ modprobe zram num_devices=2

Также отредактируете /etc/default/grub:

GRUB_CMDLINE_LINUX_DEFAULT="... zram.num_devices=2 ..."

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

После можно делать с ними что угодно — можно создать SWAP-разделы:

echo '1024M' > /sys/block/zram0/disksize
	echo '1024M' > /sys/block/zram1/disksize

	mkswap /dev/zram0
	mkswap /dev/zram1

	swapon /dev/zram0 -p 10
	swapon /dev/zram1 -p 10

Этот модуль работает как tmpfs — берется кусок памяти от имени ядра. Команды discard/trim это блочное устройство воспринимает примерно как SSD.

Заключение

Память в линуксе — очень интересный механизм. Знание работы памяти поможет в разработке под Linux.

В следующей части я расскажу вам о работе файловых систем и дисковых накопителей в Linux! А пока что ставьте плюсы и комментарии!

Источники информации и полезные ссылки


Возможно, захочется почитать и это:

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

 

Источник

Читайте также