Что дальше?
Дальше обсудим уже некоторые такие специфичные вещи (что такое ресурсы, как ими можно управлять, ...) Что такое контекст исполнения? Каким образом можно создать для программы полностью контролируемый контекст? Как этого добиваются разного рода системы исполнения? Дальше рассмотрим реальные машины (виртуальную машину джавы -- HotSpot), затем поговорим о других механизмах виртуализации -- виртуализации существующего аппаратного обеспечения на примере VirtualBox, немножко qemu. Потом немножко поговорим про верификацию программ для исполнения на рантаймах (по сути, про различные системы проверки, что программа делает что-то и не делает чего-то другого, что важно для многих рантаймов, предоставляющих гарантии), на примере верификатора байткода, верификатора в nacl-е. Про разные системы менеджмента памяти (сборщики мусора, конкретные алгоритмы), про системы виртуальной памяти в ОС, немножко про менеджер памяти в гипервизорах (он там простой, к счастью), про разные быстрые системы исполнения инструкций (интерпретатор, JIT, системы гибридного исполнения, как, например, некоторые гипервизоры -- у них часто сочетается непосредственное испонение инструкций с JIT-трансляцией тех кусков, которые нельзя непосредственно исполнить по тем или иным причинам). Про то, как можно эмулировать различное аппаратное обеспечение (на примере x86: сетевые карты, таймеры, контроллеры прерываний итд), про то, как быть менеджером реальных физических устройств, как гипервизоры разделяют доступ к физическим устр-вам. Немного обсудим безопасность VM (как работают верификаторы, как контролируется то, что может делать программа при помощи всяких аппаратных устр-в вроде MMU, привилегированных уровней процессора), ну и немного о самой сложной проблеме любого рантайма -- о производительности (как писать высокопроизводительные рантаймы, как добиваться относительно высокой производительности от существующих рантаймов).
Resource Management
Представим себе большую семью. Ужин. Есть некоторый ресурс -- кастрюля с супом. Есть десяток детишек, четверо взрослых, и необходимость разделить суп по вот этой самой разношерстной публике (детям кому-то год, кому-то 5, кому-то 15), у всех разные состояния. Как делить суп? Какие есть стратегии?
- Дать каждому поварешку, пусть набирают
- Есть гипервизор-мама, которая знает, кто сколько хочет, и раздает в той же пропорции (менеджер ресурсов, который учитывает потребность)
- Всем поровну
- Никому ничего не давать
- Упорядочить по степени нужды (давно не ел -- поешь). Планировщик задач, учитывающий предыдущую историю
- Пускать каструлю по кругу -- монопольный доступ
- Адаптивная стратегия -- пришел за добавкой -- добавили
Все это валидные стратегии, которые могут присутствовать в менеджере ресурсов.
Какие бывают интерфейсы получения памяти в компьютерах (например, в языке си)?
- malloc
- выделение памяти на стеке (выделяется через интеррапты -- когда мы пытаемся на стек что-то записать, чтобы ОС сама докинула туда память). Это пример неявного менеджмента ресурсов с общением с менеджером ресурсов. Не было бы ядра ОС, которая могла бы обработать попытку обратиться к невыделенной памяти
Стековая аллокация в большинстве ядер работает следующим образом: доступ к стеку должен происходить очень быстро, на нем должны часто выделяться/освобождаться объекты, при этом память локальна для текущего потока исполнения. Поэтому типичное выделение памяти на стеке выглядит так: компилятор при кодогенерации выясняет, сколько примерно нужно памяти данной функции, и в пролог (в самое начало функции) он вставляет инструкцию, которая просто вычитает (или прибавляет) из указателя стека определенное число. А дальше просто стеком он пользуется, и соответственно, адресует все структуры, которые должны быть выделены на стеке, отностельно нового значения SP. По сути, никакого выделения не происходит.Как это работает? Сегмент данных BSS, сегмент стека SS. Эти данные при попытке выполнить программу ничем не инициализируются, просто создается такой layout виртуальной памяти, и stack pointer ставится между сегментами. Этот процесс потребляет (как бы) нек-рое количество вирт. памяти, но не потребляет реальную, так как доступ к любому адресу из ss ведет к ошибке -- page fault 2. Так же работает и загрузка кода -- если есть сегмент кода CS, то чаще всего, когда запускаем программу, сегмент не вычитывается с диска, а происходит просто отображение с файла на иске в память. Начало исполнения ставится на точку входа. При попытке исполнения, когда, собственно, процесср пытается вытащить первую инструкцию с памяти, происходит тот же самый page fault. Дальше исполнение переходит в обработчик в обработчик ОС, который смотрит, ага, у меня здесь вот сегмент кода отображен в файл на диске, и случился page fault. Но это не настояний PF, а это PF, который говорит мне, что файл отображен в память. Поэтому я зачитаю кусочек файла и перенесу его в оперативную память, и дальше продолжу исполнение.Это позволяет большинству ОС достаточно быстро запускать процесс, даже с большим объемом кода и данных, потому что отображение происходит лениво, реально в памяти оказывается только то, что нужно программе для ее исполнения.Походая ситуация с SS: когда процессор видит, что происходит fault, но fault в районе сегмента стека, он понимает, ага, это на самом деле не программа что-то плохое сделала, а просто я должен сейчас довыделить кусочек физической памяти и подложить его под сегмент; после этого fault завершается и программа продолжается как ни в чем не бывало. То есть, с точки зрения программы произошло что-то типа sub rsp, 16 (вычли из регистра стека число 16) и mov [rsp+4], 42 (хотим записать в какое-то место на стеке в стекфрейме число 42). Вторая инструкция сначала уйдет к fault-у в ядро, потом память подмапливается и дальше, если есть следующая запись mov [rsp+8], 99, то тут уже никакого фолта нет. Благодаря такому механизму у нас есть достаточно гибкий механизм управления памятью.
- free -- операция, парная к malloc. Дай мне кусочек памяти. я скажу, сколько байтов, ты мне дай по крайней мере столько же, можно больше, и верни адрес этой полученной памяти. я могу делать с ней что хочу, и пока я не скажу free, ты мне как менеджер ресурсов гарантируешь, что никто другой ничего ни читать, ни писать, ни использовать эту память не сможет.Минусы: можно забыть сделать free, а можно сделать free дважды (использовали библиотечную функцию, освободили память, а потом эта функция освобождает эту же память). Неприятная, сложно отлаживаемая проблема.
Как организованы операции malloc и free? У нас есть массив под названием heap. Мы говорим: malloc 42 байта. Memory allocator бежит по той или иной структуре, описывающей свободное пространство (например, по free list-у), находит подходящее место дял нового объекта в 42 байта и возвращает указатель на начало этого места. Теперь мы говорим: free. Менеджер ресурсов убирает информацию о том, что это место занято, и это место становится свободным. И туда кто-то может что-то записать перед тем, как наш free сработает во второй раз...
В общем, это относительно эффективная схема, как и любой микроменеджмент памяти, то есть, точное говорение менеджеру памяти, что делать, оно достаточно неэффеткивное по многим аспекта , в частности, например, потому, что heap, из которого происходит выделение -- это чаще всего разделяемый ресурс, и при работе с хипом его нужно заблокировать от действий других потоков. Так что если говорить о голой производительности аллокатора памяти, то malloc -- не самая выдающаяся операция.
Следующая техника более популярна для управляемых (managed) рантаймов -- это явное выделение и неявное освобождение (в джаве мы не встречаем команду free по каким-то непонятным причинам, но все понимают, как это работает). Как работает освобождение памяти в управляемых системах? За некоторым ресурсом (за ресурсом памяти) стоит некоторый менеджер ресурсов, который пытается обеспечить видимость того, что, если что-то не нужно, то оно магическим образом перестает занимать память, и поверх этой памяти можно выделить какие-то новые объекты. То есть, в принципе, всем замечательная схема.
Мы просмотрели три варианта (явное выделение и освобождение -- malloc и free; явное выделение, неявное освобождение -- java и GC; неявное выделение и освобождение -- управление стеком, где мы просто инкрементируем регистр в процессоре, а факт, что после этого доступ к памяти привел к активированию какой-то сложной логики в менеджере виртуальной памяти, абсолютно скрыт от пользователя).
Мы знаем, что стек выделяется неявно. А когда он освобождается? При смерти процесса. А может ли физическая память освободиться раньше? См. свопинг.
Что мы увидели на этих примерах? У нас есть какой-то ресурс или их набор. У нас есть нек-рое количество потребителей этого ресурса, причем потребители разные (одним нужно много памяти, другим нужно достаточно мало, но сию секунду, и они не готовы ждать), при этом то, кому что нужно, не зависит от того, что у нас сейчас есть. Людям и программам нужна память в тот момент, когда она им нужна, а не когда она освободилась в ОС. В итоге нам нужен менеджер ресурсов, который может удовлетворять (в каком-то спектре потребностей) запросам широкого набора потребителей и при этом не исчерпывать ресурс до конца. Глядя на все эти рассуждения, мы приходим к концепциям VM, если подумаем о двух вещах: обо всех ресурсах, которые вообще программе нужны для ее существования, и о том, как эти ресурсы распределяются. Получим идею контекста исполнения, в котором происходит исполнение программы.
Что нужно программе или процессу, чтобы исполниться?
- Процессорное время
- Память
- Идентификатор процесса (самому процессу он не нужен)
Что такое ресурсы, легко понять по тому, что потребитель ресурсов может сделать. Ресурсы -- это различные функциональности для исполнения. Что может делать программа на юниксе?
- ввод/вывод (файловая система, сеть, ...) Понятно, что ресурс виртуальный (никаких файлов, сокетов не существует, это абстракции поверх места на диске: файл -- способ организации пространства на диске, сетевое соединение -- абстракция для того, чтобы осуществлять взаимодействие в коммутируемой сети, и т.д.)
Если мы осознаем весь контекст, который хотим предоставить программе (весь набор ресурсов, которым программа может пользоваться), то мы получаем мысль, что если у нас есть менеджер, который всеми этими ресурсами способен управлять, то он сможет полностью предоставить контекст исполнения для нек-рой программы. Такой менеджер ресурсов и называется виртуальной машиной.
В определенном смысле понятно, что VM -- это сама, в свою очередь, программа, поэтому может быть и стек виртуальных машин (например, ОС -- хостовая ОС под ней исполняется -- гипервизор в качестве процесса -- под гипервизором исполняется гостевая ОС -- процесс в гостевой ОС). У каждого элемента есть набор ресурсов, которыми он распоряжается, и нек-рый контекст, который он предоставляет своим подлежащим сущностям.
Во многом у VM, если говорить о ней изнутри (с точки зрения программы, которая исполняется под VM), то это не что иное, как модель контекста -- то, что описывает мир, в котором программа может жить. Можно подумать о java-машине. Как выглядит программа для языка Java, которая исполняется виртуальной машиной? Это .class-файл -- совокупность constant pool, метаданных, описания классов с сигнатурами и байткодом. Индивидуальный .class-файл описывает один отдельный класс, у которого есть какое-то количество метаинформации, связывающей его с другими классами и интерфейсами, и описание некоторой функциональности, которую данный класс реализует. Поэтому все, что нужно для исполнения программ на языке java -- это россыпь .class-файлов. И виртуальная машина должна предоставить все, что можно в этом .class-файле написать.
А что можно в нем написать? В целом, в байткоде написано: я могу делать вот такой набор операций, я могу вот так взаимодействовать с набором других классов
Аналогичным образом мы можем подумать о простой программе на языке си. Если мы напишем hello world, то для ее существования тоже нужен некоторый контекст: компилятор (или интерпретатор, никто не мешает написать интерпретатор для языка си). Например, # include <stdio.h> -- это инструкция для нек-рой программы под названием препроцессор, которая выполняет сложную операцию: по имени файла в специально обсученных местах находит файл с таким именем и осуществляет его текстовую макроподстановку во вход для компилятора. printf("Hello world") -- здесь контекст такой, что мы можем что-то выводить пользователю через какой-то канал (монитор, 3d-принтер, азбука Морзе -- здесь не очень понятно, но мы ожидаем некоторый набор side-эффектов). Поэтому даже относительно простые программы на самом деле влекут за собой некоторый весьма нетривиальный контекст.
Но тем не менее оказывается, что этот контекст почти всегда можно замкнуть. Можно понять, какой набор программ, понятий ожидает программа, и на основании этого набора понятий создать менеджер этого самого набора понятий.
Зачем нужна VM, и как устроена память:
Что дальше и Resource Management