27. Виртуальная память. Взгляд со стороны железа

В предыдущих главах (вплоть до 26. Endianness. Little Endian vs Big Endian) мы досконально изучили, как процессор читает, модифицирует и кэширует данные по конкретным числовым адресам оперативной памяти.

Но задавались ли вы когда-нибудь простым вопросом: когда в вашей Go-программе вы выводите в терминал адрес переменной через выражение:

Go
fmt.Printf("%p\n", &variable)

и получаете значение вроде 0xc0000a4000 — что это за адрес? Указывает ли этот шестнадцатеричный указатель на конкретную ячейку памяти или транзистор на планке DRAM, вставленной в слот материнской платы?

Ответ категоричен: Нет, ни в коем случае.

Этот адрес — блестящая математическая и аппаратная иллюзия. Настоящая «Матрица», заботливо выстроенная ядром операционной системы и кремнием процессора. Ни одна современная прикладная программа на Go никогда в своей жизни не видит и не знает физических адресов оперативной памяти. Ваш процесс живет в изолированной персональной вселенной — Виртуальной памяти (Virtual Memory).


Кризис физической памяти

На заре компьютерной эры (во времена MS-DOS и ранних мэйнфреймов) программы действительно работали с физической памятью напрямую. Если инструкция ассемблера приказывала процессору записать число по адресу 0x1000, электрический сигнал буквально активировал строку номер 0x1000 в микросхеме оперативной памяти.

По мере усложнения систем этот прямолинейный подход привел к трем фатальным кризисам:

  1. Полное отсутствие изоляции и безопасности:
    Любая программа могла случайно (из-за ошибки в указателе) или намеренно (если это вредоносный код) прочитать или перезаписать память соседнего процесса или даже самого ядра операционной системы. Ошибка в текстовом редакторе приводила к мгновенному краху всей операционной системы (знаменитый «Синий экран смерти»).
  2. Внешняя фрагментация памяти:
    Процессы запускались, требовали память, завершались и освобождали ее, оставляя после себя дыры произвольного размера. Представьте, что новому процессу требуется непрерывный массив размером 10 МБ. В физической памяти есть 10 свободных разрозненных кусочков по 1 МБ, суммарно 10 МБ свободны! Но запустить программу невозможно: она требует непрерывного диапазона адресов, и ОС возвращала ошибку Out of Memory при полупустой RAM.
  3. Коллизии адресов и проблема релокации:
    Компилятор при сборке программы был обязан жестко знать, по какому физическому адресу она будет загружена в память. Если две программы были скомпилированы под один и тот же адрес 0x1000, их невозможно было запустить параллельно: они неизбежно перетерли бы код друг друга.

Иллюзия: Каждому процессу по вселенной

Для радикального решения этих проблем архитекторы вычислительных систем разделили память на два совершенно разных мира: виртуальный и физический.

Фундаментальный принцип:
Процессор больше никогда не отправляет адреса из инструкций программы напрямую на шину оперативной памяти.

Когда операционная система запускает скомпилированный бинарник Go, она создает для него изолированное Виртуальное адресное пространство (Virtual Address Space).

В современной 64-битной архитектуре (x86-64/ARM64) операционная система внушает каждому процессу грандиозную иллюзию: программа уверена, что она — единственный хозяин компьютера, а в её монопольном распоряжении находится непрерывный океан памяти объемом 128 Тебибайт (пользовательская половина 48-битного адресного пространства — от 0x0000000000000000 до 0x00007FFFFFFFFFFF; вся 48-битная область — 256 ТиБ, поровну между пользователем и ядром)!

DIAGRAM
Каноническое 64-битное виртуальное пространство (48-битная адресация):
┌────────────────────────────────────────────────────────┐
│ Пользовательское пространство процесса (User Space)    │
│ 0x0000000000000000 — 0x00007FFFFFFFFFFF (128 ТиБ)     │
├────────────────────────────────────────────────────────┤
│ Неканоническая "дыра" (Запрещенная зона)               │
│ 0x0000800000000000 — 0xFFFF7FFFFFFFFFFF (16 Эксабайт) │
├────────────────────────────────────────────────────────┤
│ Пространство ядра ОС (Kernel Space)                    │
│ 0xFFFF800000000000 — 0xFFFFFFFFFFFFFFFF (128 ТиБ)     │
└────────────────────────────────────────────────────────┘

Процесс может свободно обращаться к адресам внутри своих 128 ТиБ, будучи уверенным, что память монолитна и бесконечна.

А на страже физической реальности стоит специальный сверхбыстрый аппаратный блок, интегрированный прямо в кристалл процессора — MMU (Memory Management Unit, Блок управления памятью). Задача MMU — прозрачно для софта, «на лету» переводить вымышленные виртуальные адреса в реальные физические адреса оперативной памяти DRAM.


Страницы памяти (Pages)

Транслировать виртуальные адреса в физические побайтово технически невозможно: для отслеживания каждого байта из 128 Тебибайт потребовалась бы таблица адресов астрономических размеров, превышающая объем всей оперативной памяти на Земле.

Поэтому инженеры разбили и виртуальное, и физическое пространство на стандартные фиксированные блоки — Страницы памяти (Pages).

Стандарт индустрии:
В современных операционных системах общего назначения на x86-64 базовый размер страницы памяти составляет 4 Килобайта (4096 байт); мы далее исходим из него. (На других платформах бывает иначе: в Linux на arm64 страницы бывают 4, 16 или 64 КБ, macOS на Apple Silicon использует 16 КБ. А у рантайма Go есть собственное понятие страницы — 8 КБ — для внутренней организации кучи.)

Терминология архитектуры:

  • Блок размером 4 КБ в виртуальном пространстве называется Виртуальной страницей (Virtual Page, VP).
  • Блок размером 4 КБ в физической оперативной памяти называется Физическим фреймом, или кадром (Physical Frame / Page Frame, PF).
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Process["Виртуальное пространство Go-процесса"]
        VP1["Виртуальная Страница 1<br>Адрес 0x1000"]:::process
        VP2["Виртуальная Страница 2<br>Адрес 0x2000"]:::process
        VP3["Виртуальная Страница 3<br>Адрес 0x3000"]:::process
    end

    subgraph Hardware["Аппаратный MMU процессора"]
        Map["Таблица трансляции<br>(Page Table)<br>Подменяет адреса"]
    end

    subgraph RAM["Физическая RAM (DRAM)"]
        PF2["Физический Фрейм Y<br>Адрес 0x4000"]:::process
        PF1["Физический Фрейм X<br>Адрес 0x9000"]:::process
        PF3["Физический Фрейм Z<br>(Свободен или занят другим)"]
    end

    VP1 --> Map -->|"Трансляция"| PF2
    VP2 --> Map -->|"Трансляция"| PF1
    VP3 -.->|"Страница еще не в RAM"| Map
  

Взгляните на эту схему — в ней заключена главная магия виртуальной памяти:

  1. В виртуальном пространстве вашего процесса страницы VP1 и VP2 лежат строго непрерывно (адреса 0x1000 и 0x2000). Для вашего Go-кода слайс, расположенный на этих страницах — это идеально плоский монолитный кусок памяти.
  2. Но в физической микросхеме RAM эти страницы привязаны к совершенно случайным, разрозненным фреймам 0x4000 и 0x9000!
  3. Проблема внешней фрагментации оперативной памяти решена навсегда: операционная система может собрать непрерывный виртуальный буфер любого размера из случайных свободных 4-килобайтных «осколков» физической RAM.

Mechanical Sympathy: Виртуальная память и Go

Понимание механики виртуальных страниц снимает покров тайны с того, как рантайм языка Go управляет ресурсами операционной системы.

1. Как работает выделение памяти (Memory Arena)

В классических программах на языке Си вызов malloc() нередко приводит к системным вызовам brk или mmap (хотя большинство вызовов аллокатор обслуживает из уже полученных арен), заставляя ядро ОС переключать контекст, что отнимает драгоценное процессорное время.

Аллокатор рантайма Go спроектирован по принципу Mechanical Sympathy:
При старте вашего Go-приложения рантайм обращается к операционной системе через вызов mmap и резервирует виртуальное адресное пространство под кучу Аренами (Memory Arena). В современных версиях Go (с 1.11) это не один огромный кусок «в сотни гигабайт» при старте, а разреженный набор арен: на linux/amd64 каждая арена — 64 МБ, и первая отображается при старте, а следующие рантайм добавляет по мере роста кучи (mmap с резервированием, физическая память по-прежнему выдаётся лениво).

Операционная система удовлетворяет такой запрос практически мгновенно. Почему? Потому что выделение виртуального адресного пространства не стоит ядру ОС ни единого байта физической памяти RAM!
В дальнейшем, когда ваш код создает структуры или срезы, аллокатор Go мгновенно «нарезает» память внутри своей заготовленной виртуальной арены в пользовательском пространстве без единого медленного системного вызова в ядро Linux.

2. Почему возникает Panic: nil pointer dereference?

Что такое nil на уровне микроархитектуры? В Go нулевой указатель nil равен абсолютному числовому адресу 0x0.

Операционная система сознательно оставляет самую первую страницу виртуальной памяти (диапазон адресов от 0x0000 до 0x0FFF, то есть первые 4 КБ) непривязанной ни к какому физическому фрейму. Эта зона просто не отображена в адресное пространство (на Linux ядро дополнительно запрещает отображать низкие адреса порогом vm.mmap_min_addr, по умолчанию обычно 64 КБ).

Когда ваш код по неосторожности пытается разыменовать нулевой указатель:

Go
*ptr = 5 // где ptr == nil (адрес 0x0)

процессор отправляет виртуальный адрес 0x0 в блок MMU.
MMU видит, что данной виртуальной страницы нет в таблице отображения, и немедленно возбуждает аппаратное исключение процессора — Page Fault (Access Violation).

Ядро операционной системы ловит это прерывание, идентифицирует некорректный адрес и шлет провинившемуся процессу сигнал SIGSEGV (Segmentation Fault). Рантайм Go перехватывает SIGSEGV своим встроенным обработчиком сигналов и преобразует сухой аппаратный сбой в понятную панику с информативным стек-трейсом:

TEXT
panic: runtime error: invalid memory address or nil pointer dereference

(Оговорка: этот механизм надёжно срабатывает для небольших смещений поля от начала структуры. Если поле лежит далеко от начала (порядка нескольких килобайт и больше), аппаратной ловушки недостаточно, и компилятор Go вставляет явную проверку на nil — например, TESTB AL, 0(AX) перед обращением.)

3. Механизм горутин и стеки

В традиционных языках (Java, C++) каждому потоку операционной системы (Thread) при создании резервируется монолитный стек фиксированного размера (обычно от 1 до 8 Мегабайт виртуального адресного пространства). Физические страницы под стек выдаются лениво, поэтому память исчерпывается не сразу; но 100 000 таких потоков упрутся в другие пределы: лимиты ядра (threads-max, vm.max_map_count), память под ядерные стеки и структуры потоков, накладные расходы планировщика ОС.

В Go горутины стартуют с микроскопическим стеком (по умолчанию порядка 2 Килобайт).
Но что происходит, если глубокая рекурсия требует больше места?

Рантайм Go использует концепцию Непрерывных стеков (Contiguous Stacks): он аллоцирует новый блок памяти в два раза большего размера (4 КБ, затем 8 КБ...), копирует туда старый стек и обновляет указатели (copystack).
Благодаря виртуальной памяти операционная система с легкостью предоставляет рантайму непрерывные виртуальные адреса для растущих стеков, даже если в физической DRAM эти блоки собраны из несмежных фрагментов.


Итог

  1. Виртуальная память — фундаментальная аппаратная абстракция: каждый процесс изолирован и считает, что монопольно владеет терабайтами непрерывной памяти.
  2. И виртуальное, и физическое пространство памяти нарезано на элементарные блоки фиксированного размера — Страницы (Pages) и Кадры (Frames) по 4 Килобайта.
  3. Блок MMU (Memory Management Unit) процессора осуществляет мгновенную аппаратную трансляцию виртуальных адресов в физические при каждом чтении и записи.
  4. Ленивое выделение (Demand Paging): аллокация памяти в Go резервирует виртуальное пространство. Реальные 4-килобайтные физические фреймы выделяются операционной системой через прерывания Page Fault строго в момент первой записи данных.
  5. Нулевой указатель nil (адрес 0x0) защищен на уровне страниц MMU: попытка доступа к нему вызывает аппаратный Page Fault, который рантайм Go элегантно перехватывает и превращает в panic: nil pointer dereference.

Мы выяснили, что MMU транслирует адреса страницами по 4 Килобайта. Но как именно устроен этот процесс внутри кремния?
Если в виртуальном пространстве процесса миллиарды страниц, где хранится карта их соответствия физическим фреймам? Почему наивная таблица адресов съела бы гигабайты памяти, и как многоуровневые деревья страниц спасают архитектуру?

Об этом читайте в следующей главе: 28. Page Table, MMU и трансляция адресов.