Память в Linux: виртуальная память, OOM Killer, hugepages
Кратко о теме
Заголовок раздела «Кратко о теме»Базовая модель, которую надо держать в голове: процесс никогда не работает с физическими адресами. Он работает со своим собственным виртуальным адресным пространством, а трансляцию «виртуальный адрес → физический» делает MMU (memory management unit) процессора по таблицам страниц, которые для каждого процесса строит ядро. Единица трансляции — страница, на x86-64 обычно 4 КиБ. Таблицы страниц на x86-64 четырёхуровневые (PGD → PUD → PMD → PTE; с Linux 4.14 и поддержкой 5-level paging добавляется P4D), корень активной таблицы лежит в регистре CR3 и переключается при смене процесса. Чтобы не ходить по таблицам на каждое обращение, результаты трансляции кешируются в TLB (translation lookaside buffer) — маленьком ассоциативном кеше внутри CPU.
Отсюда вытекает второй ключевой факт: выделение памяти и её физическое предоставление разнесены во времени. mmap/brk только заводят запись о регионе (VMA — virtual memory area) в структуре mm_struct процесса; физическая страница выделяется лениво, при первом обращении, через page fault. Поэтому «виртуальная память процесса» (VSZ в ps, VmSize в /proc/<pid>/status) может быть в разы больше «резидентной» (RSS, реально занятые физические страницы) — и именно на этой разнице держится половина вопросов про потребление памяти в Go-сервисах: рантайм Go резервирует под кучу большие диапазоны адресного пространства (MADV_FREE/MADV_DONTNEED при возврате), но RSS растёт только по мере фактического использования.
Страницы делятся на две большие категории, и это критично для понимания поведения системы под давлением. Файловые (page cache, отображённый исполняемый код, mmap файла) имеют «место на диске», куда их можно вернуть, поэтому ядро может просто выкинуть чистую страницу и перечитать её позже — это дёшево. Анонимные (куча, стек, приватные анонимные mmap) не имеют бэкинга в файловой системе; вытеснить их можно только в swap, а если swap отсутствует или полон — их вытеснить нельзя вообще. Механизм вытеснения — kswapd и прямой reclaim в контексте аллоцирующего процесса — работает по LRU-спискам (active/inactive отдельно для anon и file). Когда reclaim перестаёт освобождать память, а запрос удовлетворить надо, ядро вызывает OOM Killer.
Третий сюжет — overcommit. По умолчанию (vm.overcommit_memory=0, эвристика) Linux позволяет процессам зарезервировать суммарно больше памяти, чем есть физически плюс swap, рассчитывая, что не все воспользуются ею одновременно. Это делает malloc почти всегда успешным и переносит проблему на момент реального обращения к странице — где спасать ситуацию поздно и остаётся только убивать процессы. Альтернатива — vm.overcommit_memory=2 (strict), где лимит считается как swap + overcommit_ratio% * RAM, и malloc честно возвращает NULL; в контейнерных средах вместо этого обычно полагаются на cgroup-лимиты.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Что такое виртуальная память процесса?
Заголовок раздела «Что такое виртуальная память процесса?»Коротко. Виртуальная память процесса — это его собственное изолированное адресное пространство, где адреса не физические, а виртуальные; ядро с помощью MMU и таблиц страниц отображает эти адреса на физические страницы RAM (или на swap/файл, если страница вытеснена или ещё не подгружена). Каждый процесс видит одинаковую, непрерывную и приватную «карту памяти», ничего не зная о реальном расположении данных в RAM.
Глубже. Пространство описывается структурой mm_struct и набором VMA (vm_area_struct) — регионов с общими правами доступа и бэкингом: текст программы, данные, куча (brk), стеки потоков, отображённые файлы и разделяемые библиотеки, анонимные mmap. Посмотреть карту можно в /proc/<pid>/maps (регионы и права) и /proc/<pid>/smaps (детально: Rss, Pss, Swap, Private_Dirty по каждому региону). На x86-64 канонические адреса делят пространство на пользовательскую половину (обычно до 128 ТиБ при 4-уровневых таблицах) и ядерную; переход в ядро не требует смены таблиц страниц, хотя после Meltdown с KPTI (kernel page-table isolation) пользовательские и ядерные таблицы фактически разделены.
Важная практическая деталь: виртуальная память дешёвая, физическая — нет. VSZ/VmSize — это сумма размеров VMA, то есть сколько адресного пространства зарезервировано; RSS (VmRSS) — сколько физических страниц реально закреплено за процессом сейчас. Разделяемые библиотеки учитываются в RSS каждого процесса целиком, поэтому сумма RSS по всем процессам больше объёма RAM — для честного учёта берут PSS (proportional set size) из smaps_rollup. В Go это проявляется постоянно: рантайм резервирует адресное пространство арены большими кусками, а debug.FreeOSMemory()/GOGC/GOMEMLIMIT влияют на то, когда страницы реально возвращаются ОС.
grep -E 'VmSize|VmRSS|VmSwap' /proc/self/statuscat /proc/self/smaps_rollup # Pss, Private_Dirty, Swap суммарноЧто такое OOM Killer и как он работает?
Заголовок раздела «Что такое OOM Killer и как он работает?»Коротко. OOM Killer — механизм ядра Linux, который при исчерпании памяти (когда reclaim и swap уже не помогают выполнить запрос на страницу) принудительно убивает один процесс, чтобы освободить память и не дать системе встать колом. Жертву он выбирает по эвристике oom_score, в основе которой — доля потребляемой процессом памяти, с поправкой на oom_score_adj.
Глубже. Последовательность такая: аллокатор страниц не может выполнить запрос → запускается прямой reclaim (сброс чистого page cache, вытеснение в swap, сжатие slab) → если после нескольких проходов страниц всё ещё нет, вызывается out_of_memory(). Функция oom_badness() считает для каждого процесса очки как сумму его RSS, страниц в swap и таблиц страниц (в единицах страниц), затем добавляет oom_score_adj, нормированный к 1/1000 доступной памяти. Убивается процесс с максимальным баллом (точнее, ему шлётся SIGKILL, а с версии, где появился oom_reaper, ядро дополнительно асинхронно отбирает у него анонимную память, чтобы не ждать завершения). Процессы с oom_score_adj = -1000 (OOM_SCORE_ADJ_MIN) неубиваемы; PID 1 и kernel-потоки не рассматриваются. Если убить некого, ядро паникует, а поведение регулируется vm.panic_on_oom.
Есть два разных OOM: глобальный (кончилась память в системе) и cgroup OOM (процесс упёрся в memory.max/memory.limit_in_bytes своей cgroup — типичный случай в Docker/Kubernetes, наружу видно как OOMKilled и код выхода 137). При cgroup-OOM ядро выбирает жертву внутри группы, и это ровно то, что делает Kubernetes с контейнером, превысившим resources.limits.memory. Отследить факт можно в dmesg/journal по строке Out of memory: Killed process ... со сводной таблицей процессов; в cgroup v2 счётчики лежат в memory.events (oom, oom_kill). Отдельно стоит vm.overcommit_memory=2, при котором до OOM обычно не доходит — mmap/malloc начинают отдавать ошибку заранее. Также в userspace есть systemd-oomd/earlyoom, которые убивают процессы по PSI-метрикам ещё до того, как ядро уйдёт в тяжёлый reclaim.
cat /proc/<pid>/oom_score # текущий баллecho -500 > /proc/<pid>/oom_score_adj # снизить вероятность быть убитымdmesg -T | grep -i -E 'oom|killed process'Что такое hugepages?
Заголовок раздела «Что такое hugepages?»Коротко. Hugepages — страницы памяти увеличенного размера (на x86-64 это 2 МиБ и 1 ГиБ вместо обычных 4 КиБ). Одна такая страница описывается одной записью в TLB, поэтому для больших рабочих наборов резко падает число TLB-промахов и укорачивается page walk — выигрыш заметен для БД, JVM, in-memory кешей и подобных потребителей десятков гигабайт.
Глубже. Есть два механизма. Первый — классические HugeTLB: администратор заранее резервирует пул (vm.nr_hugepages, /proc/meminfo поля HugePages_Total/Free), приложение берёт память через hugetlbfs, mmap(..., MAP_HUGETLB) или shmget(SHM_HUGETLB). Такие страницы «прибиты»: они не свопятся и не участвуют в reclaim, что даёт предсказуемость, но зарезервированный пул недоступен обычным аллокациям, даже если пустует — так обычно настраивают PostgreSQL и Oracle. Второй — THP (transparent huge pages): ядро само склеивает 4 КиБ-страницы в 2 МиБ там, где может, режим задаётся в /sys/kernel/mm/transparent_hugepage/enabled (always/madvise/never), а фоновый поток khugepaged дефрагментирует и продвигает уже выделенные регионы. THP прозрачны для приложения, но у них плохая репутация в latency-чувствительных системах: аллокация 2 МиБ может уйти в синхронную компакцию памяти и дать паузу, а частичное изменение большой страницы увеличивает объём грязных данных. MongoDB, Redis и многие БД рекомендуют выставлять never или madvise. Отдельно стоит запомнить, что 1 ГиБ страницы задаются только загрузочными параметрами (hugepagesz=1G hugepages=N) и практически не выделяются на лету из-за фрагментации.
Диагностика: grep Huge /proc/meminfo (AnonHugePages — сколько отдано под THP, HugePages_* — пул HugeTLB), /proc/<pid>/smaps содержит поля AnonHugePages и FilePmdMapped по регионам. Для Go отдельно отметим: раньше THP при always заметно раздували RSS рантайма, и в Go 1.21+ рантайм явно управляет флагами MADV_HUGEPAGE/MADV_NOHUGEPAGE для арены кучи, чтобы этого избежать.
На какой тип памяти система будет опираться при решении запустить OOM Killer?
Заголовок раздела «На какой тип памяти система будет опираться при решении запустить OOM Killer?»Коротко. Решающей является невытесняемая память — в первую очередь анонимные страницы (куча, стеки, приватные mmap) при отсутствии или исчерпании swap, плюс неосвобождаемые ядерные аллокации (unreclaimable slab, таблицы страниц, mlock-нутые и HugeTLB-страницы). Page cache и прочая reclaimable-память в расчёт по сути не идут: её ядро просто выбросит и продолжит работать, до OOM Killer дело дойдёт, только когда выбрасывать станет нечего.
Глубже. Формально критерий не «сколько чего занято», а «прямой reclaim не смог освободить страницы для запроса нужного порядка». Но на практике причиной всегда оказываются категории, которые reclaim не трогает. Оценка конкретного процесса в oom_badness() строится ровно из таких компонентов: get_mm_rss() (резидентные страницы), get_mm_counter(MM_SWAPENTS) (страницы в swap) и mm_pgtables_bytes() (память под таблицы страниц) — то есть учитывается именно то, что освободится, если процесс убить. Файловый page cache в oom_score не входит и убийством процесса не «освобождается» осмысленным образом.
Практические следствия. Во-первых, free -m с большим buff/cache — не признак нехватки памяти; смотреть надо на available, который учитывает reclaimable-часть кеша. Во-вторых, система с высоким MemAvailable, но фрагментированной памятью может уйти в OOM при запросе больших непрерывных областей (высокий order) — редкий, но реальный сценарий. В-третьих, mlockall и HugeTLB-пулы делают память жёстко невытесняемой, а значит приближают OOM. В-четвёртых, в контейнерах учёт ведётся по cgroup: в cgroup v2 счётчик memory.current включает и page cache этой группы, но при подходе к memory.max ядро сначала пытается вытеснить кеш группы и лишь потом убивает — то есть решающей остаётся анонимная часть. Тюнинг склонности к вытеснению анонимных страниц против кеша — vm.swappiness; при swappiness=0 и отсутствии swap любой рост кучи ведёт к OOM почти сразу.
free -m # смотреть колонку available, а не freegrep -E 'MemAvailable|SwapFree|SUnreclaim|AnonPages' /proc/meminfocat /sys/fs/cgroup/<...>/memory.events # oom, oom_kill в cgroup v2Зачем нужна виртуальная память?
Заголовок раздела «Зачем нужна виртуальная память?»Коротко. Она даёт изоляцию и защиту (процесс физически не может адресовать чужую память), позволяет каждому процессу видеть простое непрерывное адресное пространство независимо от фрагментации RAM, и разрывает связь «выделено» ↔ «занято физически», что делает возможными ленивое выделение, copy-on-write, разделяемые библиотеки, mmap файлов и swap.
Глубже. Разберём по пунктам, что именно ломается без неё. Без трансляции адресов линковка была бы привязана к физическому расположению, а два экземпляра одной программы не смогли бы использовать один и тот же код; с виртуальной памятью один и тот же физический фрейм с libc отображается в адресные пространства сотен процессов. fork() не копирует память, а помечает страницы read-only и копирует их только при записи (copy-on-write) — без виртуальной памяти это невозможно, и fork+exec были бы неподъёмно дорогими. Ленивое выделение позволяет процессу спокойно зарезервировать гигабайты адресного пространства (что и делают рантаймы Go, JVM, аллокаторы вроде jemalloc), платя физической памятью только за реально тронутые страницы. Права доступа на уровне PTE дают W^X и NX-бит — защиту от исполнения данных, а ASLR становится дешёвым: достаточно случайно разложить VMA внутри огромного адресного пространства.
Плата за это — накладные расходы на трансляцию (page walk при промахе TLB, потери на переключении контекста, память под сами таблицы страниц) и непредсказуемые задержки: обращение к «обычной» переменной может обернуться major page fault и походом на диск. Именно поэтому в latency-критичных системах используют mlock, hugepages и заранее «прогревают» память, а real-time и некоторые встраиваемые системы работают вовсе без MMU.
Переговорка (Room): ID, название, этаж, тип помещения (малая/средняя/большая);
Заголовок раздела «Переговорка (Room): ID, название, этаж, тип помещения (малая/средняя/большая);»Коротко. Обрывок исходника, вопрос не восстанавливается. Это пункт условия задачи на проектирование схемы БД (бронирование переговорных), случайно попавший в выборку по теме «память ОС»; к памяти Linux отношения не имеет — разбор моделирования сущностей см. в конспектах по SQL (sql/learn/schema-design.md).
Частые ошибки на собесе
Заголовок раздела «Частые ошибки на собесе»- Путают виртуальную память и swap: говорят «виртуальная память — это файл подкачки». Swap — лишь один из бэкендов для страниц; виртуальная память существует и при полностью отключённом swap.
- Считают, что
malloc/makeсразу занимают физическую RAM. На деле выделяется только адресное пространство, физическая страница приходит по первому page fault; отсюда огромный VSZ при скромном RSS. - Пугаются большого
buff/cacheвfreeи делают вывод «памяти нет». Смотреть надо наavailable: page cache почти весь reclaimable. - Утверждают, что OOM Killer убивает «самый большой» или «последний запросивший» процесс. Он убивает процесс с максимальным
oom_score, который считается из RSS + swap + page tables и корректируетсяoom_score_adj; «жадный» аллокатор часто просто совпадает с самым большим, но правило не такое. - Не различают глобальный OOM и cgroup OOM. В Kubernetes контейнер с exit code 137 обычно упёрся в свой
memory.limit, при этом на ноде памяти могло быть сколько угодно. - Считают hugepages бесплатным ускорением. HugeTLB-пул отбирает память у всей системы и не свопится, а THP в режиме
alwaysмогут дать latency-спайки на компакции и раздуть RSS. - Забывают про overcommit: рассказывают, что «
mallocвернёт NULL, когда память кончится». При настройке по умолчанию он почти всегда успешен, и приложение узнаёт о нехватке уже в видеSIGKILL. - Складывают RSS всех процессов и удивляются, что сумма больше объёма RAM: разделяемые страницы считаются в каждом процессе, для корректного учёта нужен PSS.
Что почитать
Заголовок раздела «Что почитать»- Documentation/admin-guide/mm/concepts.rst — обзор подсистемы памяти ядра: VMA, page cache, reclaim, OOM.
- Documentation/admin-guide/mm/transhuge.rst и hugetlbpage.rst — THP и HugeTLB, настройки и подводные камни.
- Documentation/filesystems/proc.rst — что означают поля
/proc/meminfo,/proc/<pid>/status,maps,smaps. - mm/oom_kill.c — исходник
oom_badness(), самая честная формула выбора жертвы. - Documentation/admin-guide/cgroup-v2.rst — раздел Memory:
memory.max,memory.current,memory.eventsи cgroup OOM.