Виртуализация памяти · 10 мин
Контент v0.12.0Адресное пространство: иллюзия приватной памяти
Один адрес, два значения
Начнём блок про память с эксперимента. Программа на Go создаёт объект в куче, записывает туда свой PID, секунду ждёт и печатает адрес объекта и значение по нему:
package main
import (
"fmt"
"os"
"time"
)
func main() {
p := new(int) // объект в куче
*p = os.Getpid()
time.Sleep(time.Second) // чтобы оба процесса точно работали одновременно
fmt.Printf("PID %d: адрес %p, значение %d\n", os.Getpid(), p, *p)
}Запустим два экземпляра одновременно (./a & ./a; wait). Один из запусков на
macOS дал:
PID 72868: адрес 0x14000010100, значение 72868
PID 72869: адрес 0x14000010100, значение 72869Два процесса работают в одно и то же время. У обоих объект лежит по адресу
0x14000010100, и при этом по этому адресу у каждого своё значение. Значит, адрес,
который видит программа, — не номер ячейки в микросхеме памяти. Каждому процессу
ОС показывает его собственную память, и одинаковые числа у разных процессов
ведут в разные места.
(Совпадение адресов не гарантировано на каждом запуске, но случается часто:
рантайм Go начинает кучу с одного и того же адреса, на Linux/amd64 это
0xc000000000.)
Адресное пространство
Адресное пространство (address space) — это память в том виде, в каком её видит процесс: непрерывный диапазон адресов, который принадлежит только ему. Адреса из этого диапазона называют виртуальными. Настоящие номера ячеек в микросхемах RAM называют физическими адресами.
На каждое обращение к памяти специальный блок процессора, MMU (memory management unit), переводит виртуальный адрес в физический по таблице, которую подготовила ОС. У каждого процесса таблица своя.
процесс A MMU + таблица A физическая RAM
читает 0x14000010100 ──────────────────────────▶ ячейка 0x7A31100
процесс B MMU + таблица B
читает 0x14000010100 ──────────────────────────▶ ячейка 0x1C08100Зачем такие сложности, а не просто дать программам физическую память?
Защита. Если бы программы видели физическую память, ошибка в указателе одного сервиса могла бы испортить данные базы на той же машине или само ядро ОС. Когда у каждого процесса свои виртуальные адреса, назвать чужую память ему просто нечем.
Простота программирования. Компилятор и рантайм могут считать, что программа всегда загружена по одним и тем же адресам, и не думать, какой кусок RAM ей достался в этот раз.
Гибкость для ОС. ОС может переложить данные процесса в другое место RAM или временно на диск, поменяв только таблицу. Программа этого не заметит. Ещё ОС может пообещать процессам в сумме больше памяти, чем есть физически, и выдавать реальную память только при первом обращении. Об этом в главах за пределами физической памяти и память в проде.
Что лежит внутри
В адресном пространстве есть несколько областей с разным назначением. Классическая учебная картинка:
большие адреса
┌──────────────────────────┐
│ СТЕК (stack) │ кадры вызовов функций, локальные переменные
│ │ растёт к меньшим │
│ ▼ адресам │
├──────────────────────────┤
│ │
│ свободный диапазон │ адреса не используются, памяти за ними нет
│ │
├──────────────────────────┤
│ ▲ растёт к большим │
│ │ адресам │
│ КУЧА (heap) │ память, выделенная во время работы
├──────────────────────────┤
│ ДАННЫЕ (data, bss) │ глобальные переменные
├──────────────────────────┤
│ КОД (text) │ машинные инструкции, только чтение и исполнение
└──────────────────────────┘
малые адреса (самая первая страница не отображена)- Код — машинные инструкции программы. Помечен «только чтение и исполнение»: попытка записи сюда закончится аварией.
- Данные — глобальные и статические переменные. Их размер известен при компиляции.
- Куча — память, которую программа просит во время работы, когда размер заранее неизвестен. В учебной модели растёт в сторону больших адресов.
- Стек — кадры вызовов функций: аргументы, адрес возврата, локальные переменные. Растёт в сторону меньших адресов, навстречу куче.
- Свободный диапазон между кучей и стеком огромен и почти весь не используется. Это просто адреса, за которыми нет памяти. Обращение туда — ошибка.
Почему куча и стек растут навстречу друг другу? Заранее неизвестно, какой из них понадобится больше. Если поставить их на противоположные концы, оба могут расти, пока не встретятся, и делить общий запас адресов.
Самая первая страница адресов (около нуля) специально ничем не занята. Поэтому разыменование нулевого указателя всегда ловится: обращение к адресу 0 попадает в неотображённую память. В Go это знакомая паника:
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0x...]SIGSEGV — сигнал, который ядро ОС посылает процессу, обратившемуся к адресу, где
у него нет памяти (segmentation fault). Рантайм Go перехватывает этот сигнал и
превращает его в панику.
Как выглядит настоящее адресное пространство
Учебная картинка — модель. В Linux можно посмотреть на реальное адресное
пространство любого процесса в файле /proc/<PID>/maps. Сокращённый вывод
cat /proc/self/maps (здесь self — сам процесс cat; адреса у вас будут другими):
55d0c1a00000-55d0c1a02000 r--p 00000000 08:01 1835 /usr/bin/cat
55d0c1a02000-55d0c1a07000 r-xp 00002000 08:01 1835 /usr/bin/cat
55d0c1a0b000-55d0c1a0c000 rw-p 0000a000 08:01 1835 /usr/bin/cat
55d0c2b4e000-55d0c2b6f000 rw-p 00000000 00:00 0 [heap]
7f2a3c400000-7f2a3c428000 r--p 00000000 08:01 2130 /usr/lib/x86_64-linux-gnu/libc.so.6
7f2a3c428000-7f2a3c5bd000 r-xp 00028000 08:01 2130 /usr/lib/x86_64-linux-gnu/libc.so.6
7ffc9e3f1000-7ffc9e412000 rw-p 00000000 00:00 0 [stack]
7ffc9e5fc000-7ffc9e5fe000 r-xp 00000000 00:00 0 [vdso]Каждая строка — непрерывная область: диапазон адресов, права (r чтение, w
запись, x исполнение, p приватная копия) и откуда она взялась. Видно код
программы (r-xp), её данные (rw-p), кучу, библиотеку C, стек и vDSO из главы
прямое исполнение с ограничениями.
Отличия от учебной картинки:
- Адреса случайные. При каждом запуске ОС сдвигает код, кучу, стек и библиотеки на случайную величину. Это ASLR (address space layout randomization), защита от эксплойтов: атакующему труднее угадать, где лежит нужный код. (Стек и библиотеки сдвигаются всегда; код и глобальные переменные — только если бинарник собран как позиционно-независимый, PIE. Go на macOS собирает PIE, на Linux/amd64 по умолчанию нет.)
- Областей больше трёх. Каждая разделяемая библиотека, каждый файл, отображённый в память, каждый стек потока — своя область.
- Диапазон огромный, но не 2⁶⁴. На x86-64 процессу доступна нижняя половина 48-битного пространства, 2⁴⁷ байт = 128 ТиБ. Верхняя половина принадлежит ядру ОС. Процесс не может к ней обратиться, хотя она отображена в каждом процессе.
Изоляция
Процесс не может прочитать чужую память, и дело не в договорённости, а в устройстве. Все виртуальные адреса процесса переводятся через его собственную таблицу, а в ней есть только его страницы. Если он наберёт случайное число как адрес, возможны два исхода:
- адрес попал в его собственную область — он прочитает или испортит свои данные;
- адрес никуда не отображён — MMU сообщит ядру ОС, и процесс получит
SIGSEGV.
Третьего варианта, «попал в память соседа», нет. Таблицы перевода может менять только ядро ОС: это делается привилегированными инструкциями, которые запрещены в пользовательском режиме (см. прямое исполнение).
Есть и обратная сторона: процессы, которым нужно обмениваться данными, не могут просто передать указатель. Им нужны каналы ОС (пайпы, сокеты) или специально запрошенная общая память.
Что это значит для Go
Go-программа живёт в таком же адресном пространстве, но раскладывает его по-своему.
- Где окажется переменная, решает компилятор. Локальная переменная, на которую
не остаётся ссылок после выхода из функции, может жить на стеке. Если ссылка
«убегает» (вы вернули
&x), компилятор кладёт переменную в кучу. Этот анализ называется escape analysis, подробно в следующей главе API памяти. - Стеки горутин не лежат в области
[stack]. У каждой горутины свой маленький стек (стартует с 2 КБ и растёт при необходимости). Эти стеки рантайм выделяет из своей собственной памяти, полученной у ОС, рядом с кучей. Область[stack]в/proc/<PID>/maps— это стек главного потока ОС, а не горутин. - Куча Go — тоже набор областей, которые рантайм запрашивает у ОС через
mmapбольшими кусками и дальше раздаёт объектам сам. Как устроена раздача — в главе управление свободной памятью.
Модель памяти Go (какие записи одной горутины видны другой) — отдельная тема, она разобрана в курсе Go: модель памяти.
Частые ошибки
- Считать, что адрес из
%p— физический. Все адреса, которые видит программа, виртуальные; физических она не видит никогда. - Думать, что большой диапазон адресов означает большой расход памяти. Свободный диапазон между кучей и стеком ничего не стоит, пока к нему не обращаются.
- Объяснять segfault как «процесс залез в чужую память». Он залез в адрес, где у него нет памяти; до чужой памяти он добраться не может.
Что такое виртуальный адрес?
Куда растут стек и куча в классической раскладке адресного пространства?
Почему один процесс не может прочитать память другого процесса?
Что из перечисленного — выгоды абстракции адресного пространства? (выберите все верные)
Какой блок железа на лету переводит виртуальные адреса в физические?
Сколько гигабайт виртуального адресного пространства получает 32-битный процесс (адреса от 0 до 2^32 − 1)?
Проверьте себя
- Два процесса одновременно печатают одинаковый адрес своей переменной. Может ли запись в эту переменную в одном процессе изменить значение в другом?
- Почему разыменование nil-указателя в Go надёжно ловится, а не портит случайные данные?
- В
/proc/<PID>/mapsстрока с правамиr-xp. Что там скорее всего лежит? - Сколько байт виртуальных адресов доступно процессу на x86-64 с 48-битной адресацией?
Показать ответы
Ответы. (1) Нет, одинаковые виртуальные адреса переводятся через разные таблицы в
разные физические ячейки. (2) Страница около адреса 0 никогда не отображается,
обращение к ней всегда вызывает SIGSEGV. (3) Исполняемый код программы или
библиотеки. (4) 2⁴⁷ байт, 128 ТиБ (нижняя половина).
Что спрашивают на собесе
- Что такое виртуальное адресное пространство и зачем оно нужно: изоляция, простота для программиста, гибкость для ОС (перемещение, ленивое выделение).
- Чем виртуальный адрес отличается от физического и кто их переводит (MMU по таблицам ОС).
- Почему один процесс не может прочитать память другого.
- Раскладка адресного пространства: код, данные, куча, стек; куда растут куча и стек; что такое ASLR.
- Что такое segmentation fault и почему nil-указатель всегда ловится.
- Где в Go живут переменные и стеки горутин.