Система jit just in time кратко

Обновлено: 07.07.2024

enter image description here

Раньше я уже публиковал вводную статью по libjit для программистов, которые уже знакомы с JIT. Хотя бы немного. В том посте я совсем коротко описал JIT, а в этом сделаю полный обзор JIT и дополню его примерами, код в которых не требует никаких дополнительных библиотек.

Определение JIT

JIT – это акроним от “Just In Time” или, если переводить на русский, “на лету”. Это нам ни о чем не говорит и звучит так, будто к программированию не имеет никакого отношения. Мне кажется, это описание JIT больше всего похоже на правду:

Если какая-то программа во время своего исполнения создает и выполняет какой-нибудь новый исполняемый код, который не был частью изначальной программы на диске, – это JIT.

Но откуда произошло это название? К счастью, Джон Айкок из университета Калгари написал очень интересную статью под названием “Краткая история JIT”, в которой рассматривает JIT-техники с исторической точки зрения. Судя по статье, первое упоминание кодогенерации и исполнения кода во время работы программы появилось в 1960 году в статье про LISP, написанной McCarthy. В более поздних работах (например, статья Томсона от 1968 года про регулярные выражения) этот подход совсем очевиден (регулярные выражения компилируются в машинный код и исполняются на лету).

Сам же термин JIT впервые появился в книгах по Java Джеймса Гослинга. Айкок говорит, что Гослинг перенял этот термин из области промышленного производства и начал его использовать в ранних 90-х. Если вам интересны подробности, то прочитайте статью Айкока. А теперь давайте посмотрим, как все описанное выше работает на практике.

JIT: сгенерируйте машинный код и запустите его

Мне кажется, что JIT проще понять, если сразу разделить его на две фазы:

  • Фаза 1: генерация машинного кода во время работы программы
  • Фаза 2: выполнение машинного кода во время работы программы

Первая фаза – это 99% всей сложности JIT. Но в то же время это самая банальная часть процесса: это именно то, что делает обычный компилятор. Широко известные компиляторы, такие как gcc и clang/llvm, транслируют исходники из C/C++ в машинный код. Дальше машинный код обычно сохраняется в файл, но нет смысла не оставлять его в памяти (на самом деле и в gcc, и в clang/llvm есть готовые возможности для сохранения кода в памяти для использования его в JIT). Но в этой статье я хотел бы сфокусироваться на второй фазе.

Выполнение сгенерированного кода

Современные операционные системы очень избирательны в том, что программе разрешено делать во время ее работы. Времена дикого запада закончились с появлением защищенного режима, который позволяет операционной системе выставлять различные права на различные куски памяти процесса. То есть в “обычном” режиме вы можете выделить память на куче, но вы не можете просто выполнить код, который выделен на куче, предварительно явно не попросив об этом ОС.

Я надеюсь, всем понятно, что машинный код – это просто данные, набор байтов. Как вот это, например:

Для кого-то эти три байта – просто три байта, а для кого-то – бинарное представление валидного x86-64 кода:

Поместить этот машинный код в память очень легко. Но как сделать его исполняемым и, собственно, исполнить?

Посмотрим на код

Дальше в этой статье будут примеры кода для POSIX-совместимой UNIX операционной системы (а именно Linux). На других ОС (таких как Windows) код будет отличаться в деталях, но не в подходе. У всех современных ОС есть удобные API для того, чтобы сделать то же самое.

Без лишних предисловий посмотрим, как динамически создать функцию в памяти и выполнить ее. Эта функция специально сделана очень простой. В C она выглядит так:

Вот первая попытка (полный исходник вместе с Makefile доступен в репозитории):

Три основных этапа, которые выполняет этот код:

  1. Использование mmap для выделения куска памяти на куче, в которую можно писать, из которой можно читать и которую можно исполнять.
  2. Копирование машинного кода, реализующего add4 в эту память.
  3. Выполнение кода из этой памяти путем преобразования указателя в указатель на функцию и вызова ее через этот указатель.

Прошу заметить, что третий этап возможен только тогда, когда кусок памяти с машинным кодом имеет права на исполнение. Без нужных прав вызов функции привел бы к ошибке ОС (скорее всего, ошибке сегментирования). Это произойдет, если, например, мы выделим m обычным вызовом malloc, который выделяет RW память, но не X.

Отвлечемся на минутку: heap, malloc и mmap

Но не все так просто. :-) Если традиционно (то есть очень давно) malloc использовал только один источник для выделяемой памяти (системный вызов sbrk ), то сейчас большинство реализаций malloc во многих случаях используют mmap . Детали отличаются от операционки к операционке и в разных реализациях, но обычно mmap используется для больших кусков памяти, а sbrk – для маленьких. Различие в эффективности во время использования одного или другого способа получения памяти от операционной системы.

Так что называть память, полученную от mmap “памятью из кучи”, не ошибка, по моему мнению, и я собираюсь и дальше использовать это название.

Заботимся о безопасности

У кода выше есть серьезная уязвимость. Причина в блоке RWX-памяти, который он выделяет – рай для эксплоитов. Давайте будем чуть более ответственными. Вот немного измененный код:

Этот пример эквивалентен предыдущему примеру во всех отношениях, кроме одного: память сначала выделяется с RW-правами (как и с обычным malloc ). Это достаточные права для того, чтобы мы могли записать туда наш кусок кода. После того, как код уже находится в памяти, мы используем mprotect , чтобы поменять права с RW на RX, запрещая запись. В итоге эффект такой же, но ни на каком из этапов наша память не является одновременно и перезаписываемой, и исполняемой. Это хорошо и правильно с точки зрения безопасности.

Что насчет malloc?

Могли ли мы использовать malloc вместо mmap для выделения памяти в предыдущем коде? Ведь RW-память – это именно то, что нам дает malloc . Да, мы могли. Но тут больше проблем, чем удобств. Дело в том, что права можно выставить только на целые страницы. И, выделяя память с помощью malloc , нам нужно было бы вручную удостовериться, что память выровнена по границе страницы. Mmap решает эту проблему таким образом, что выделяет всегда выровненную память (потому что mmap по определению работает только с целыми страницами).

Подводя итоги

Эта статья начиналась с общего обзора JIT, того, что мы вообще подразумеваем, когда говорим “JIT”, и закончилась примерами кода, который демонстрирует, как динамически выполнять кусок машинного кода из памяти. Техники, представленные в статье – это примерно то, как делается JIT в настоящих JIT-системах (LLVM или libjit). Остается всего лишь “простая” часть генерации машинного кода из какого-либо другого представления.

LLVM содержит в себе полноценный компилятор, так что он может транслировать C и C++-код (через LLVM IR) в машинный код на лету и исполнять его. Libjit работает на гораздо более низком уровне: он может служить бэкендом для компилятора. Моя вводная статья по libjit демонстрирует, как генерировать и выполнять нетривиальный код с помощью этой библиотеки. Но JIT – это гораздо более общий концепт. Создавать код на лету можно для структур данных, регулярных выражений и даже для доступа к C из виртуальных машин различных языков. Я покопался в архивах своего блога и нашел упоминание о JIT в статье восьмилетней давности. Она о Perl-коде, который генерирует другой Perl-код на лету (из XML-файла с описанием), но идея та же самая.

Вот почему я считаю, что описывать JIT важно, разделяя две фазы. Для второй фазы (которую я описал в этой статье) реализация довольно банальна и использует стандартные API операционной системы. Для первой фазы возможностей бесконечное количество. И что именно будет в ней в конечном счете, зависит от конкретного приложения, которое вы разрабатываете.

Читайте также: