Virtual memory — зачем нужна, какие проблемы решает
Когда вы пишете malloc(1_000_000_000) и получаете указатель 0x7f8b40000000 — что это вообще за число? Реальный адрес ячейки в RAM? Нет. Это виртуальный адрес — абстракция, которую kernel и MMU создают для каждого процесса. Каждый процесс видит свою «личную» 64-битную (или 48-битную на самом деле в большинстве x86_64) address space, и kernel прозрачно переводит её в реальные физические адреса.
Virtual memory — одна из главных и самых важных абстракций ОС. Без неё современный многозадачный Linux был бы невозможен. В этом уроке разберём: зачем нужна VM, какие проблемы она решает, и как выглядит адресное пространство типичного процесса в Linux.
Что было бы без virtual memory
Представим архаичную систему без VM. Все процессы работают с физическими адресами:
Виртуальная память решает все четыре проблемы сразу.
Идея VM: каждому процессу свой address space
С virtual memory каждый процесс видит свое адресное пространство от 0 до 2^48 (256 TB виртуальной памяти на x86_64). Внутри этого пространства нет других процессов — только сам процесс. Адреса процесса A и процесса B независимы — адрес 0x10000 у A и у B — разные физические места.
Преобразование делает MMU (Memory Management Unit) — аппаратный блок CPU. При каждом обращении к памяти MMU смотрит page tables и переводит virtual address -> physical. Это происходит прозрачно для программы, она ничего не знает.
Преимущества virtual memory
1. Изоляция процессов
Процесс A не может прочитать или испортить память процесса B. MMU блокирует доступ — если A пытается обратиться по virtual address, который не отображён в его page tables, происходит page fault и kernel убивает процесс с SIGSEGV.
# Попробуем -- что покажет программа, обратившаяся к чужой памяти:
cat > /tmp/segv.c << 'EOF'
#include <stdio.h>
int main() {
int* p = (int*)0x12345; // случайный адрес
*p = 42; // запись по чужой памяти
return 0;
}
EOF
gcc /tmp/segv.c -o /tmp/segv
/tmp/segv
# Segmentation fault (core dumped)
# Программа не упала бы без VM -- она бы успешно записала случайный байт в чью-то память
Изоляция — фундаментальная для безопасности. Один баг в браузере не должен взламывать ОС.
2. Нет фрагментации с точки зрения программы
В физической RAM фрагментация есть. Но программа видит непрерывное виртуальное пространство. Когда программа делает malloc(1 MB), она получает 1 MB подряд в virtual address space. Kernel может разместить эти данные на 256 разрозненных страницах в RAM — программе всё равно, она видит непрерывно.
3. Иллюзия большой памяти (через swap)
Программа может выделять больше памяти, чем есть RAM — неиспользуемые страницы могут быть swap’ом отправлены на диск. Программа продолжает работать, swap’нутые страницы загружаются обратно при обращении (page fault + load from swap).
# Включить swap:
swapon --show
# Если есть -- видны разделы или файлы
# Сколько использует swap:
free -h
# total used free shared buff/cache available
# Mem: 64Gi 20Gi 10Gi ...
# Swap: 8Gi 2Gi 6Gi <- 2GB на swap'е
Swap делает приложения «отказоустойчивыми» к нехватке RAM — они не падают сразу, а замедляются. Подробнее в уроке 04.
Named volumes в Docker: виртуальная память на уровне контейнеров4. Copy-on-write для fork()
Когда процесс делает fork(), kernel не копирует всю память ребёнка — он просто копирует page tables. Реальные страницы остаются одни на двух. Только при попытке записи (copy-on-write) страница копируется. Это делает fork() очень дешёвым.
# Демонстрация COW:
cat > /tmp/cow.py << 'EOF'
import os, time
data = list(range(10_000_000)) # ~80 MB в памяти
print(f"Parent PID: {os.getpid()}, before fork: {os.popen(f'ps -o rss= -p {os.getpid()}').read().strip()} KB")
pid = os.fork()
if pid == 0:
print(f"Child immediately after fork: {os.popen(f'ps -o rss= -p {os.getpid()}').read().strip()} KB")
time.sleep(2)
data[0] = 999 # триггер COW для первой страницы
print(f"Child after modifying: {os.popen(f'ps -o rss= -p {os.getpid()}').read().strip()} KB")
else:
os.wait()
EOF
python3 /tmp/cow.py
# Parent: 100 MB
# Child immediately: 100 MB (но это виртуально, реально страницы общие)
# Child after modifying: 100 MB (страница скопирована, но это одна страница, 4 KB)
5. Удобство для shared libraries
libc.so загружается в физическую RAM один раз. Все процессы, использующие libc, маппируют эту физическую память в свои virtual address spaces. Экономия памяти — огромная.
# Сколько процессов используют libc:
lsof | grep libc.so | wc -l
# Тысячи
# Физически libc -- одна копия в памяти:
sudo cat /proc/$(pgrep firefox | head -1)/maps | grep libc
# 7f3d...-7f3d... r-xp 00000000 libc.so.6
# r-xp -- read-execute, private (но physical shared если ничего не пишет)
Address space layout процесса
Типичная схема виртуальной памяти процесса в Linux x86_64:
Посмотрим реально:
# Address space любого процесса:
cat /proc/self/maps | head -20
# 55a7e8d50000-55a7e8d53000 r--p 00000000 fd:01 ... /usr/bin/cat
# 55a7e8d53000-55a7e8d57000 r-xp 00003000 fd:01 ... /usr/bin/cat <- .text
# 55a7e8d57000-55a7e8d59000 r--p 00007000 fd:01 ... /usr/bin/cat <- .rodata
# 55a7e8d59000-55a7e8d5a000 r--p 00008000 fd:01 ... /usr/bin/cat <- .data
# 55a7e8d5a000-55a7e8d5b000 rw-p 00009000 fd:01 ... /usr/bin/cat <- .bss
# 55a7e96a1000-55a7e96c2000 rw-p 00000000 00:00 0 [heap] <- куча
# 7f3d1e000000-7f3d1e022000 r--p 00000000 fd:01 ... /usr/lib/.../libc.so.6
# ...
# 7ffd...-7ffd... rw-p 00000000 00:00 0 [stack]
Каждая строка — один mapping — регион виртуальной памяти с правами (r/w/x), и opциональным backing storage (файл или anonymous).
Permissions: r, w, x
Каждая страница виртуальной памяти имеет права доступа:
- r — read (можно читать)
- w — write (можно писать)
- x — execute (можно выполнять код)
- p/s — private (COW) или shared
NX bit (No-Execute, или DEP — Data Execution Prevention) — важная фича безопасности. Без неё buffer overflow атакующего могла бы записать машинный код на стек и выполнить. С NX это невозможно: страницы стека не имеют x-бита, попытка выполнить = SIGSEGV.
Memory regions: anonymous vs file-backed
В Linux есть два типа memory regions:
В /proc/PID/maps:
r-xp— read-execute private (.text сегменты)rw-p— read-write private (heap, anonymous mmap)rw-s— read-write shared (shared memory IPC)r--s— read-only shared
RSS vs VSZ — что показывает ps
При просмотре ps или top две колонки про память:
# Посмотреть память процесса:
ps -p PID -o pid,rss,vsz,comm
# PID RSS VSZ COMMAND
# 12345 50000 200000 firefox
# RSS = 50 MB реально в RAM
# VSZ = 200 MB виртуально (но многое не использовано)
# Полная картина через smem (если есть):
smem -P firefox
# PID User Command Swap USS PSS RSS
# Или /proc/PID/smaps_rollup:
cat /proc/PID/smaps_rollup
# Rss: ... Pss: ... Shared_Clean: ... Private_Dirty: ...
# Все категории памяти процесса
Главное: не пугайтесь VSZ. Виртуальный 100 GB — может быть реально 50 MB. Меряйте RSS или PSS.
htop: как читать RES и VIRT в мониторинге процессовПопробуй сам
# 1. Посмотреть свой address space:
cat /proc/self/maps
# Найдите [heap], [stack], shared libraries, .text сегменты
# 2. Сколько mmap-областей у разных процессов:
for pid in $(pgrep -f 'firefox|chrome|code' | head -5); do
count=$(wc -l < /proc/$pid/maps 2>/dev/null)
name=$(cat /proc/$pid/comm)
echo "PID $pid ($name): $count mappings"
done
# 3. RSS vs VSZ для топ-процессов:
ps aux --sort -rss | head -10
# Колонки RSS и VSZ -- разница может быть драматичной
# 4. Запустим программу, которая выделяет много, но использует мало:
python3 -c "
import os, time
print(f'PID: {os.getpid()}')
# allocate 1 GB, но НЕ трогаем
buf = bytearray(1024*1024*1024) # это touches страницы -- RSS вырастет
time.sleep(60)
" &
PID=$!
sleep 2
ps -p $PID -o pid,rss,vsz,comm
# RSS ≈ 1 GB (потому что bytearray touchнул)
kill $PID
# Если бы мы выделили через mmap без MAP_POPULATE -- VSZ = 1 GB, RSS = 0
# (страницы выделятся только при первом обращении)
# 5. Посмотреть NULL guard page -- доступ к 0x0:
cat > /tmp/null.c << 'EOF'
#include <stdio.h>
int main() {
int* p = (int*)0;
printf("%d\n", *p); // NULL pointer dereference
return 0;
}
EOF
gcc /tmp/null.c -o /tmp/null
/tmp/null
# Segmentation fault -- адрес 0x0 не отображён