Виртуализация памяти · 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 ТиБ. Верхняя половина принадлежит ядру ОС. Процесс не может к ней обратиться, хотя она отображена в каждом процессе.

Изоляция

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

  1. адрес попал в его собственную область — он прочитает или испортит свои данные;
  2. адрес никуда не отображён — 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)?

Проверьте себя

  1. Два процесса одновременно печатают одинаковый адрес своей переменной. Может ли запись в эту переменную в одном процессе изменить значение в другом?
  2. Почему разыменование nil-указателя в Go надёжно ловится, а не портит случайные данные?
  3. В /proc/<PID>/maps строка с правами r-xp. Что там скорее всего лежит?
  4. Сколько байт виртуальных адресов доступно процессу на x86-64 с 48-битной адресацией?
Показать ответы

Ответы. (1) Нет, одинаковые виртуальные адреса переводятся через разные таблицы в разные физические ячейки. (2) Страница около адреса 0 никогда не отображается, обращение к ней всегда вызывает SIGSEGV. (3) Исполняемый код программы или библиотеки. (4) 2⁴⁷ байт, 128 ТиБ (нижняя половина).

Что спрашивают на собесе

  • Что такое виртуальное адресное пространство и зачем оно нужно: изоляция, простота для программиста, гибкость для ОС (перемещение, ленивое выделение).
  • Чем виртуальный адрес отличается от физического и кто их переводит (MMU по таблицам ОС).
  • Почему один процесс не может прочитать память другого.
  • Раскладка адресного пространства: код, данные, куча, стек; куда растут куча и стек; что такое ASLR.
  • Что такое segmentation fault и почему nil-указатель всегда ловится.
  • Где в Go живут переменные и стеки горутин.

Продолжить