Как ОС даёт каждой программе «свою» память, откуда берётся файл подкачки и как найти программу, которая съедает память
paging.py проверяет ответы. Две другие — в ВМ: показатели памяти, файл подкачки, поиск утечки.Память компьютера — не одно устройство, а лестница: чем быстрее, тем меньше и дороже.
| Уровень | Объём | Время доступа | Кто управляет |
|---|---|---|---|
| Регистры процессора | Сотни байт | Меньше 1 нс | Компилятор |
| Кэш процессора (L1–L3) | Килобайты — десятки мегабайт | 1–20 нс | Процессор |
| Оперативная память | Гигабайты | Около 100 нс | ОС |
| SSD | Сотни гигабайт — терабайты | Десятки микросекунд | ОС |
| Жёсткий диск | Терабайты | Миллисекунды | ОС |
Разрыв между оперативной памятью и диском — тысячи и сотни тысяч раз. На этом построено всё управление памятью: держать в быстрой памяти то, что нужно сейчас, а остальное — на диске. Задачи ОС: выделять память процессам и забирать обратно, защищать процессы друг от друга, давать программам больше памяти, чем есть физически, и разрешать совместное использование — например, одной копии библиотеки всеми процессами.
Программа обращается к памяти по логическим (виртуальным) адресам — своим, начиная с нуля. В микросхемах памяти данные лежат по физическим адресам. Переводит одни в другие при каждом обращении аппаратный блок процессора — MMU (memory management unit) — по таблицам, которые заполняет ОС.
Это даёт три вещи: две программы могут использовать одинаковые адреса и не мешать друг другу; программа не может даже назвать адрес чужой памяти; ОС может расположить память процесса где угодно, хоть частями.
Ранние ОС отводили каждому процессу один непрерывный участок памяти. Беда в фрагментации: после запусков и завершений память превращается в решето из маленьких свободных дыр. Свободно в сумме 500 МБ, а процессу на 100 МБ места нет — ни одна дыра не подходит. Это внешняя фрагментация. Её решение — делить память на одинаковые маленькие куски.
Логическое адресное пространство делится на страницы, физическая память — на кадры (фреймы) того же размера. Любая страница может лежать в любом кадре; где какая, записано в таблице страниц процесса. Размер страницы в 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 она почти отключена, а смысловое деление осталось внутри страничной памяти — его видно в адресном пространстве процесса.
В 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) — сколько выделено лично этому процессу; именно она растёт при утечке.
Виртуальная память — идея, что страница процесса не обязана лежать в оперативной памяти. В таблице страниц у каждой записи есть бит присутствия. Если процесс обращается к странице, которой нет в памяти, процессор вызывает страничное прерывание (page fault), и ОС:
Прерывание мягкое (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 %, процессор простаивает, система почти не отвечает. Лечится не настройкой подкачки, а добавлением памяти или уменьшением нагрузки.
Когда свободных кадров нет, ОС выбирает страницу-жертву. Хороший выбор — та, которая долго не понадобится. Алгоритмы сравнивают по числу страничных прерываний на одной строке обращений — последовательности номеров страниц. Возьмём классическую строку из 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.
Диспетчер задач → Производительность → Память: «Используется» — занято процессами и системой; «Доступно» — можно отдать программам сразу; «Выделено» — сколько памяти обещано процессам и каков предел; «Кэшировано» — файлы в памяти, отдаются по первому требованию. Монитор ресурсов → вкладка «Память» показывает то же по каждому процессу и столбец «Ошибок отсутствия страницы/с» — страничные прерывания.
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 → Дополнительно → Быстродействие → Параметры → Дополнительно → Виртуальная память. Отключать файл подкачки не стоит даже при большом объёме памяти: некоторые программы рассчитывают на него, а без него не сохраняется дамп памяти при синем экране.
$ 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 — пробуксовка.
Утечка памяти — программа выделяет память и не освобождает, когда она больше не нужна. Расход растёт, пока не кончится память или программу не перезапустят. Смоделируем утечку: скрипт каждую секунду выделяет ещё 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 МБ
Признак утечки — монотонный рост без возврата, даже когда программа ничего не делает. Как искать:
perfmon): счётчик «Процесс → Байт исключительного пользования» для подозрительного процесса, график за часы.top с сортировкой по памяти (M) или ps -o pid,rss,comm --sort=-rss несколько раз с интервалом.Администратор утечку не исправит — это ошибка в программе. Временная мера — перезапуск программы или службы по расписанию, постоянная — сообщить разработчику с цифрами: какой процесс, сколько мегабайт в час растёт, после каких действий.
available, а не «свободно»; утечка — монотонный рост частной памяти процесса