Машина под уроком: процессор, память, кэш и ОС
Два цикла делают одну и ту же работу: складывают все элементы большой матрицы. Один идёт по строкам, другой по столбцам. Количество сложений одинаковое, Big-O одинаковая, а время отличается в несколько раз. Если твоя модель программы заканчивается на “процессор выполняет операции”, объяснить это нечем.
// одинаковая работа, разное время
for (let i = 0; i < n; i++)
for (let j = 0; j < n; j++) sum += m[i][j]; // быстро
for (let j = 0; j < n; j++)
for (let i = 0; i < n; i++) sum += m[i][j]; // медленно, в разы
В прошлом уроке мы научились читать биты. Сегодня посмотрим, где они живут: процессор и его слово, адреса, сегменты процесса, стек и куча, кэш и его линии, и сверху роль операционной системы. Это фундамент, на который опираются event loop, воркеры Node и буферный пул СУБД.
Карта урока
- Процессор: регистры, такты, разрядность, машинное слово.
- Память как массив байтов: адреса и сегменты процесса.
- Стек вызовов и куча: два способа жить в памяти.
- Кэш: L1, L2, L3, cache line и локальность. Здесь и живёт ответ про матрицу.
- ОС: процессы, потоки, планировщик, вытесняющая многозадачность.
- Управление памятью: malloc и free против сборщика мусора.
Раздел 1 · Процессор: регистры, такты и слово
Идея словами
Ядро процессора умеет на удивление мало: прочитать число из памяти в
регистр,
сложить два регистра, записать регистр обратно в память, сравнить и
прыгнуть на другую инструкцию. Всё. Любой твой array.map после всех
слоёв компиляции превращается в перекладывание чисел между памятью и
регистрами.
Темп задаёт тактовая частота: 3 ГГц значит три миллиарда тактов в секунду, простая инструкция занимает порядка одного такта. Запомни этот масштаб: такт это треть наносекунды, и дальше мы будем мерить им стоимость походов в память.
Разрядность это ширина регистра и адреса. На 64-битной машине указатель занимает 8 байт, и это число ты уже видел в таблице размеров прошлого урока. 32-битные системы с их потолком адресов в 4 ГиБ (2^32 байта) ещё живы во встраиваемом мире и в WebAssembly: линейная память WASM в базовой версии адресуется 32 битами, подробности в уроке про WASM.
Что взять с собой
Процессор перекладывает машинные слова между регистрами и памятью миллиарды раз в секунду. Указатель это 8 байт, потому что слово 64 бита.
Раздел 2 · Память: массив байтов и сегменты процесса
Идея словами
Вся оперативная память для программы выглядит как один гигантский массив байтов, а адрес это просто индекс в нём. Каждый процесс при этом видит своё адресное пространство: ОС и железо подставляют между виртуальными адресами программы и физическими ячейками таблицу трансляции, поэтому два процесса не видят память друг друга. Этой изоляцией мы займёмся в разделе 5.
Внутри адресное пространство процесса нарезано на сегменты:
высокие адреса
+----------------+
| stack | стек вызовов, растёт вниз
| ↓ |
| |
| ↑ |
| heap | куча, растёт вверх
+----------------+
| bss / data | глобальные и статические данные
+----------------+
| text | машинный код программы
+----------------+
низкие адреса
text это сам код, data и bss это глобальные переменные, а два
по-настоящему интересных сегмента, стек и куча, растут навстречу друг
другу и заслуживают отдельного раздела. Если захочешь увидеть эту схему
глазами системного языка, она же разобрана в
Go-треке и в разговоре про
указатели и память Go.
Что взять с собой
Память это массив байтов, адрес это индекс, каждый процесс видит своё виртуальное пространство, нарезанное на text, data, heap и stack.
Раздел 3 · Стек вызовов и куча
Сцена
Ты видел оба сегмента в деле тысячу раз. RangeError: Maximum call stack size exceeded это перелив стека. “Вкладка ест два гигабайта” это куча.
Осталось связать их в одну картинку.
Идея словами
Стек вызовов
работает как стопка тарелок. Вызов функции кладёт сверху
кадр:
аргументы, локальные переменные, адрес возврата. return снимает кадр.
Аллокация на стеке это сдвиг одного указателя, поэтому она практически
бесплатна, но за дешевизну платим двумя ограничениями: размер стека мал и
фиксирован, а время жизни данных жёстко привязано к вызову. Глубокая
рекурсия без выхода переполняет стек, отсюда RangeError, отсюда же
трюк с трамплином из урока про IO-монаду.
Куча нужна для всего, что переживает вызов: объект вернули наружу, замыкание утащило окружение, кэш живёт всю жизнь процесса. Платим за гибкость: аллокатор должен найти место, а потом кто-то должен память вернуть, об этом раздел 6.
Как это ложится на JS. Движок хранит на стеке кадры вызовов и короткие примитивы внутри них, а объекты, массивы, функции и строки живут в куче, на стеке лежат ссылки на них. Отсюда два знакомых факта: копирование объекта копирует ссылку, а замыкание из урока про scope может держать окружение сколь угодно долго, потому что движок вынес это окружение в кучу. Точные детали V8 сложнее (escape analysis, inlining), но модель “кадры на стеке, долгожители в куче” верна и достаточна.
Что взять с собой
Стек: дёшево, мало, время жизни равно вызову. Куча: гибко, дорого, время жизни произвольное. В JS на стеке кадры и ссылки, в куче объекты.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.