Раздел 07 — Управление памятью

Как ОС даёт каждой программе «свою» память, откуда берётся файл подкачки и как найти программу, которая съедает память

Прогресс курса Раздел 7 из 12

Что вы освоите в этом разделе

8 академических часов: 2 теории, 6 практики. Первая практическая работа — расчётная (перевод адреса, замещение страниц), программа paging.py проверяет ответы. Две другие — в ВМ: показатели памяти, файл подкачки, поиск утечки.
01

Иерархия памяти

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

УровеньОбъёмВремя доступаКто управляет
Регистры процессораСотни байтМеньше 1 нсКомпилятор
Кэш процессора (L1–L3)Килобайты — десятки мегабайт1–20 нсПроцессор
Оперативная памятьГигабайтыОколо 100 нсОС
SSDСотни гигабайт — терабайтыДесятки микросекундОС
Жёсткий дискТерабайтыМиллисекундыОС

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

02

Логический и физический адрес

Программа обращается к памяти по логическим (виртуальным) адресам — своим, начиная с нуля. В микросхемах памяти данные лежат по физическим адресам. Переводит одни в другие при каждом обращении аппаратный блок процессора — MMU (memory management unit) — по таблицам, которые заполняет ОС.

Это даёт три вещи: две программы могут использовать одинаковые адреса и не мешать друг другу; программа не может даже назвать адрес чужой памяти; ОС может расположить память процесса где угодно, хоть частями.

Ранние ОС отводили каждому процессу один непрерывный участок памяти. Беда в фрагментации: после запусков и завершений память превращается в решето из маленьких свободных дыр. Свободно в сумме 500 МБ, а процессу на 100 МБ места нет — ни одна дыра не подходит. Это внешняя фрагментация. Её решение — делить память на одинаковые маленькие куски.

03

Страничная организация

Логическое адресное пространство делится на страницы, физическая память — на кадры (фреймы) того же размера. Любая страница может лежать в любом кадре; где какая, записано в таблице страниц процесса. Размер страницы в Windows и Linux на x86-64 — 4 КБ:

PS> [Environment]::SystemPageSize
4096
$ getconf PAGESIZE
4096

Перевод адреса:

Пример: страница 4096 байт, таблица страниц: 0 → кадр 5, 1 → кадр 2, 2 — не в памяти, 3 → кадр 7.

$ python3 calc/paging.py addr 4096 "0:5 1:2 2:- 3:7" 5000 12300 8200 20000
Размер страницы 4096 байт; таблица страниц: 0→5, 1→2, 2→нет, 3→7
Адрес 5000: страница 5000 // 4096 = 1, смещение 5000 % 4096 = 904 → кадр 2, физический адрес 2 × 4096 + 904 = 9096
Адрес 12300: страница 12300 // 4096 = 3, смещение 12300 % 4096 = 12 → кадр 7, физический адрес 7 × 4096 + 12 = 28684
Адрес 8200: страница 8200 // 4096 = 2, смещение 8200 % 4096 = 8 → страницы нет в памяти: страничное прерывание
Адрес 20000: страница 20000 // 4096 = 4, смещение 20000 % 4096 = 3616 → за пределами таблицы: ошибка доступа

Адрес 5000: 5000 = 1 × 4096 + 904, страница 1 лежит в кадре 2, значит, физический адрес 2 × 4096 + 904 = 9096. Страница 2 есть у процесса, но сейчас не в памяти — процессор вызовет страничное прерывание, и ОС подгрузит её (об этом ниже). Страницы 4 в таблице нет вовсе — обращение к чужому адресу, и ОС завершит программу: в Windows «нарушение прав доступа», в Linux «Segmentation fault».

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

На x86-64 таблица страниц многоуровневая (четыре уровня), и перевод одного адреса требовал бы четырёх лишних обращений к памяти. Поэтому в процессоре есть TLB — маленький кэш последних переводов. Программы обращаются к памяти локально — подряд и к одним и тем же местам, — поэтому почти все переводы находятся в TLB.

Сегменты — другой способ деления: на части разного размера по смыслу (код, данные, стек), у каждой база и предел. В чистом виде сегментация страдает от внешней фрагментации; в x86-64 она почти отключена, а смысловое деление осталось внутри страничной памяти — его видно в адресном пространстве процесса.

04

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

В Linux карту адресного пространства процесса показывает файл /proc/PID/maps. Вот карта самой программы cat, которая этот файл читает (self — «я сам»):

$ cat /proc/self/maps | grep -E "cat|heap|stack|vdso|libc" | head -8
55d070cb7000-55d070cb9000 r--p 00000000 08:01 786367                     /usr/bin/cat
55d070cb9000-55d070cbf000 r-xp 00002000 08:01 786367                     /usr/bin/cat
55d070cbf000-55d070cc2000 r--p 00008000 08:01 786367                     /usr/bin/cat
55d070cc2000-55d070cc3000 r--p 0000a000 08:01 786367                     /usr/bin/cat
55d070cc3000-55d070cc4000 rw-p 0000b000 08:01 786367                     /usr/bin/cat
55d0985b3000-55d0985d4000 rw-p 00000000 00:00 0                          [heap]
7f65f8f23000-7f65f8f4b000 r--p 00000000 08:01 828973                     /usr/lib/x86_64-linux-gnu/libc.so.6
7f65f8f4b000-7f65f90ae000 r-xp 00028000 08:01 828973                     /usr/lib/x86_64-linux-gnu/libc.so.6

Каждая строка — область: диапазон адресов, права (r — чтение, w — запись, x — выполнение, p — личная копия процесса) и файл, из которого она загружена. Код программы и библиотеки libc — r-xp: выполнять можно, менять нельзя. Данные — rw-p. [heap] — куча, откуда программа берёт память по ходу работы; дальше в списке — [stack], стек. Если программа попытается записать в область кода, MMU это не пропустит — так страницы защищают от ошибок и атак.

Адреса огромные: в 64-разрядных Windows и Linux у каждого процесса 128 ТБ пользовательского адресного пространства. Это адреса, а не память: большая часть их не используется. Поэтому в Windows столбец «виртуальная память» процесса показывает терабайты — это зарезервированные адреса, а не занятая память:

PS> Get-Process -Name explorer, lsass | Select-Object ProcessName, @{n="WS, МБ"; e={[math]::Round($_.WorkingSet64/1MB)}}, @{n="Private, МБ"; e={[math]::Round($_.PrivateMemorySize64/1MB)}}, @{n="Virtual, ГБ"; e={[math]::Round($_.VirtualMemorySize64/1GB)}}
ProcessName WS, МБ Private, МБ Virtual, ГБ
----------- ------ ----------- -----------
explorer       265          92        2053
lsass           31           8        2052

Настоящий расход — два других столбца. Рабочий набор (WS) — сколько страниц процесса сейчас в оперативной памяти, включая общие библиотеки. Частная память (Private) — сколько выделено лично этому процессу; именно она растёт при утечке.

05

Виртуальная память и страничные прерывания

Виртуальная память — идея, что страница процесса не обязана лежать в оперативной памяти. В таблице страниц у каждой записи есть бит присутствия. Если процесс обращается к странице, которой нет в памяти, процессор вызывает страничное прерывание (page fault), и ОС:

  1. проверяет, что адрес вообще принадлежит процессу (иначе — ошибка доступа);
  2. находит свободный кадр, а если его нет — вытесняет какую-то страницу на диск;
  3. загружает нужную страницу (с диска или просто заполняет нулями) и исправляет таблицу;
  4. повторяет команду процесса, как будто ничего не случилось.

Прерывание мягкое (minor), если читать с диска не пришлось: страница новая или уже лежит в памяти, например в кэше. Жёсткое (major) — если пришлось читать с диска; оно в тысячи раз дороже.

Отсюда ленивое выделение: ОС выдаёт процессу адреса сразу, а кадры — только при первом обращении. Программа на Python выделяет 200 МБ, а потом записывает по байту в каждую страницу:

# Выделить 200 МБ и обратиться к каждой странице; считать страничные прерывания и занятую память.
import mmap, resource

def report(what):
    faults = resource.getrusage(resource.RUSAGE_SELF).ru_minflt
    rss = next(l.split()[1] for l in open("/proc/self/status") if l.startswith("VmRSS"))
    print(f"{what:28} прерываний {faults:6}, в памяти {int(rss) // 1024:4} МБ")

SIZE = 200 * 1024 * 1024
report("при старте")
m = mmap.mmap(-1, SIZE)                     # выделить 200 МБ
report("выделено 200 МБ")
for i in range(0, SIZE, mmap.PAGESIZE):     # записать по байту в каждую страницу
    m[i] = 1
report("тронута каждая страница")
$ python3 faults.py
при старте                   прерываний    888, в памяти    8 МБ
выделено 200 МБ              прерываний    899, в памяти    9 МБ
тронута каждая страница      прерываний  52099, в памяти  209 МБ

После выделения 200 МБ процесс почти не вырос — память обещана, но не выдана. Обращение к каждой странице добавило 51 200 страничных прерываний — ровно 200 МБ / 4 КБ, и только тогда процесс занял 200 МБ.

Если оперативной памяти не хватает, ОС выгружает редко используемые страницы на диск — в файл подкачки Windows (C:\pagefile.sys) или раздел подкачки (swap) Linux. Когда памяти не хватает совсем и процессы постоянно вытесняют страницы друг друга, наступает пробуксовка (thrashing): диск занят на 100 %, процессор простаивает, система почти не отвечает. Лечится не настройкой подкачки, а добавлением памяти или уменьшением нагрузки.

06

Алгоритмы замещения страниц

Когда свободных кадров нет, ОС выбирает страницу-жертву. Хороший выбор — та, которая долго не понадобится. Алгоритмы сравнивают по числу страничных прерываний на одной строке обращений — последовательности номеров страниц. Возьмём классическую строку из 20 обращений и 3 кадра.

FIFO — вытеснить страницу, загруженную раньше всех:

$ python3 calc/paging.py repl fifo 3 "7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1"
FIFO, кадров: 3, обращения: 7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1
Шаг Стр.  К1 К2 К3  Прерывание
  1    7   7  ·  ·  да
  2    0   7  0  ·  да
  3    1   7  0  1  да
  4    2   2  0  1  да, вытеснена 7
  5    0   2  0  1  
  6    3   2  3  1  да, вытеснена 0
  7    0   2  3  0  да, вытеснена 1
  8    4   4  3  0  да, вытеснена 2
  9    2   4  2  0  да, вытеснена 3
 10    3   4  2  3  да, вытеснена 0
 11    0   0  2  3  да, вытеснена 4
 12    3   0  2  3  
 13    2   0  2  3  
 14    1   0  1  3  да, вытеснена 2
 15    2   0  1  2  да, вытеснена 3
 16    0   0  1  2  
 17    1   0  1  2  
 18    7   7  1  2  да, вытеснена 0
 19    0   7  0  2  да, вытеснена 1
 20    1   7  0  1  да, вытеснена 2
Страничных прерываний: 15 из 20

Первые три обращения заполняют пустые кадры — это тоже прерывания. На шаге 4 нужна страница 2, кадры заняты 7, 0, 1; дольше всех в памяти 7 — её и вытесняем. На шаге 5 страница 0 уже в памяти, прерывания нет. FIFO прост, но не смотрит на использование: на шаге 6 он вытесняет 0, которая нужна на следующем же шаге.

OPT (оптимальный) — вытеснить страницу, которая понадобится позже всех или не понадобится вовсе. Меньше прерываний не бывает, но алгоритм требует знать будущее; его используют как эталон для сравнения:

$ python3 calc/paging.py repl opt 3 "7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1"
OPT, кадров: 3, обращения: 7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1
Шаг Стр.  К1 К2 К3  Прерывание
  1    7   7  ·  ·  да
  2    0   7  0  ·  да
  3    1   7  0  1  да
  4    2   2  0  1  да, вытеснена 7
  5    0   2  0  1  
  6    3   2  0  3  да, вытеснена 1
  7    0   2  0  3  
  8    4   2  4  3  да, вытеснена 0
  9    2   2  4  3  
 10    3   2  4  3  
 11    0   2  0  3  да, вытеснена 4
 12    3   2  0  3  
 13    2   2  0  3  
 14    1   2  0  1  да, вытеснена 3
 15    2   2  0  1  
 16    0   2  0  1  
 17    1   2  0  1  
 18    7   7  0  1  да, вытеснена 2
 19    0   7  0  1  
 20    1   7  0  1  
Страничных прерываний: 9 из 20

LRU (least recently used) — вытеснить страницу, к которой дольше всех не обращались. Прошлое — хороший прогноз будущего, поэтому LRU близок к OPT:

$ python3 calc/paging.py repl lru 3 "7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1"
LRU, кадров: 3, обращения: 7 0 1 2 0 3 0 4 2 3 0 3 2 1 2 0 1 7 0 1
Шаг Стр.  К1 К2 К3  Прерывание
  1    7   7  ·  ·  да
  2    0   7  0  ·  да
  3    1   7  0  1  да
  4    2   2  0  1  да, вытеснена 7
  5    0   2  0  1  
  6    3   2  0  3  да, вытеснена 1
  7    0   2  0  3  
  8    4   4  0  3  да, вытеснена 2
  9    2   4  0  2  да, вытеснена 3
 10    3   4  3  2  да, вытеснена 0
 11    0   0  3  2  да, вытеснена 4
 12    3   0  3  2  
 13    2   0  3  2  
 14    1   1  3  2  да, вытеснена 0
 15    2   1  3  2  
 16    0   1  0  2  да, вытеснена 3
 17    1   1  0  2  
 18    7   1  0  7  да, вытеснена 2
 19    0   1  0  7  
 20    1   1  0  7  
Страничных прерываний: 12 из 20

Итог на этой строке: FIFO — 15, LRU — 12, OPT — 9 прерываний. Точный LRU дорог: нужно запоминать время каждого обращения. Реальные ОС используют приближения: процессор ставит в таблице страниц бит обращения, и ОС вытесняет страницы, к которым давно не обращались («часы», «второй шанс»).

Аномалия Белади. Кажется очевидным, что больше кадров — меньше прерываний. Для FIFO это не всегда так:

$ python3 calc/paging.py repl fifo 3 "1 2 3 4 1 2 5 1 2 3 4 5" | tail -1
Страничных прерываний: 9 из 12
$ python3 calc/paging.py repl fifo 4 "1 2 3 4 1 2 5 1 2 3 4 5" | tail -1
Страничных прерываний: 10 из 12
$ python3 calc/paging.py repl lru 3 "1 2 3 4 1 2 5 1 2 3 4 5" | tail -1
Страничных прерываний: 10 из 12
$ python3 calc/paging.py repl lru 4 "1 2 3 4 1 2 5 1 2 3 4 5" | tail -1
Страничных прерываний: 8 из 12

На строке 1 2 3 4 1 2 5 1 2 3 4 5 FIFO с 3 кадрами даёт 9 прерываний, а с 4 — 10. У LRU и OPT аномалии не бывает: с 4 кадрами прерываний не больше, чем с 3.

07

Память в Windows

Диспетчер задач → Производительность → Память: «Используется» — занято процессами и системой; «Доступно» — можно отдать программам сразу; «Выделено» — сколько памяти обещано процессам и каков предел; «Кэшировано» — файлы в памяти, отдаются по первому требованию. Монитор ресурсов → вкладка «Память» показывает то же по каждому процессу и столбец «Ошибок отсутствия страницы/с» — страничные прерывания.

PS> Get-CimInstance Win32_OperatingSystem | Select-Object @{n="Всего, МБ"; e={[math]::Round($_.TotalVisibleMemorySize/1KB)}}, @{n="Свободно, МБ"; e={[math]::Round($_.FreePhysicalMemory/1KB)}}, @{n="Виртуальная, МБ"; e={[math]::Round($_.TotalVirtualMemorySize/1KB)}}
Всего, МБ Свободно, МБ Виртуальная, МБ
--------- ------------ ---------------
     8162         4780           10082
PS> Get-CimInstance Win32_PerfFormattedData_PerfOS_Memory | Select-Object AvailableMBytes, @{n="CommittedMB"; e={[math]::Round($_.CommittedBytes/1MB)}}, @{n="CommitLimitMB"; e={[math]::Round($_.CommitLimit/1MB)}}, PageFaultsPersec, PagesPersec
AvailableMBytes  : 4772
CommittedMB      : 2758
CommitLimitMB    : 10082
PageFaultsPersec : 23
PagesPersec      : 0

Предел выделения (CommitLimit) = оперативная память + файл подкачки: 8162 + 1920 ≈ 10 082 МБ. Больше этого Windows не пообещает процессам, сколько бы адресов у них ни было. Когда выделено близко к пределу, Windows сначала увеличивает файл подкачки, а потом сообщает «недостаточно памяти». PagesPersec — сколько страниц в секунду читается с диска и пишется на него; постоянно большие значения — признак нехватки памяти.

PS> (Get-CimInstance Win32_ComputerSystem).AutomaticManagedPagefile
True
PS> Get-CimInstance Win32_PageFileUsage | Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage
Name            AllocatedBaseSize CurrentUsage PeakUsage
----            ----------------- ------------ ---------
C:\pagefile.sys              1920           37        85

По умолчанию размер файла подкачки Windows выбирает сама (AutomaticManagedPagefile). Настройка: Win+R → sysdm.cpl → Дополнительно → Быстродействие → Параметры → Дополнительно → Виртуальная память. Отключать файл подкачки не стоит даже при большом объёме памяти: некоторые программы рассчитывают на него, а без него не сохраняется дамп памяти при синем экране.

08

Память в Linux

$ free -m
               total        used        free      shared  buff/cache   available
Mem:            3921         692         556          36        2998        3228
Swap:           1721         849         872
$ grep -E "^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree|Committed_AS|CommitLimit)" /proc/meminfo
MemTotal:        4015940 kB
MemFree:          570016 kB
MemAvailable:    3306352 kB
Buffers:           71380 kB
Cached:          2753104 kB
SwapTotal:       1763324 kB
SwapFree:         893520 kB
CommitLimit:     3771292 kB
Committed_AS:    3113532 kB

Главная ошибка новичков — смотреть на free и пугаться, что свободно мало. Linux занимает свободную память под кэш файлов (buff/cache) и отдаёт её программам по первому требованию. Сколько памяти действительно доступно программам, показывает available. Committed_AS — сколько памяти обещано процессам; Linux по умолчанию обещает больше, чем есть (overcommit), рассчитывая, что не все обещания будут использованы. Если всё же не хватит, ядро завершит процесс, занимающий больше всех, — это делает OOM killer, и в журнале появится «Out of memory: Killed process».

$ swapon --show
NAME      TYPE      SIZE   USED PRIO
/dev/sda5 partition 1,7G 849,4M   -2
$ cat /proc/sys/vm/swappiness
60
$ vmstat 1 3
procs -----------memory---------- ---swap-- -----io---- -system-- -------cpu-------
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st gu
 0  0 869804 570016  71380 2999472    0    2    25    31   77    0  2  0 98  0  0  0
 0  0 869804 570008  71380 2999560    0    0     0     0  103  118  0  0 100  0  0  0
 0  0 869804 570008  71380 2999560    0    0     0     0  136  136  0  1 99  0  0  0

swapon --show — где подкачка и сколько занято. swappiness (0–200, по умолчанию 60) — насколько охотно ядро выгружает страницы процессов, а не сбрасывает кэш файлов. vmstat 1 3 — три замера раз в секунду; важны столбцы si и so — сколько страниц в секунду читается из подкачки и пишется в неё. Нули — норма: в подкачке лежат давно не нужные страницы, и никто к ним не обращается. Постоянно ненулевые si/so — пробуксовка.

09

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

Утечка памяти — программа выделяет память и не освобождает, когда она больше не нужна. Расход растёт, пока не кончится память или программу не перезапустят. Смоделируем утечку: скрипт каждую секунду выделяет ещё 50 МБ и никогда их не отдаёт. Запустим его и будем раз в две секунды смотреть частную память процесса:

PS> Get-Content leak.ps1
$l = New-Object System.Collections.ArrayList; while ($true) { [void]$l.Add((New-Object byte[] 50MB)); [Array]::Clear($l[-1], 0, 1); for ($i = 0; $i -lt 50MB; $i += 4096) { $l[-1][$i] = 1 }; Start-Sleep 1 }
PS> $p = Start-Process powershell -ArgumentList "-NoProfile -File leak.ps1" -WindowStyle Hidden -PassThru
PS> 1..5 | ForEach-Object { Start-Sleep 2; $p.Refresh(); "{0,2} с: {1} МБ" -f ($_ * 2), [math]::Round($p.PrivateMemorySize64 / 1MB) }
 2 с: 164 МБ
 4 с: 268 МБ
 6 с: 368 МБ
 8 с: 469 МБ
10 с: 520 МБ

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

  1. Диспетчер задач → «Подробности» → сортировка по столбцу «Память (активный частный рабочий набор)»; посмотреть через несколько минут ещё раз.
  2. Для долгого наблюдения — Системный монитор (perfmon): счётчик «Процесс → Байт исключительного пользования» для подозрительного процесса, график за часы.
  3. В Linux — top с сортировкой по памяти (M) или ps -o pid,rss,comm --sort=-rss несколько раз с интервалом.

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

Ключевые выводы раздела

Запомните главное

  • Программы работают с логическими адресами, MMU переводит их в физические по таблице страниц; страница — 4 КБ
  • Физический адрес = кадр × размер страницы + смещение; страница = адрес div размер, смещение = адрес mod размер
  • Страничное прерывание подгружает страницу; память выдаётся лениво, при первом обращении; нехватка — подкачка и пробуксовка
  • OPT — минимум прерываний (эталон), LRU близок к нему, FIFO прост, но страдает аномалией Белади
  • Смотреть «Доступно» и available, а не «свободно»; утечка — монотонный рост частной памяти процесса
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 06: Синхронизация и взаимоблокировки Все разделы Раздел 08: Файловые системы