[Перевод] Как работать с JIT
В некоторых внутренних системах для быстрого поиска по большому битовому массиву мы в Badoo используем JIT. Это очень интересная и не самая известная тема. И, чтобы исправить такую досадную ситуацию, я перевел полезную статью Элая Бендерски о том, что такое JIT и как его использовать.
Раньше я уже публиковал вводную статью по 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). Но в этой статье я хотел бы сфокусироваться на второй фазе.
Выполнение сгенерированного кода
Современные операционные системы очень избирательны в том, что программе разрешено делать во время ее работы. Времена дикого запада закончились с появлением защищенного режима, который позволяет операционной системе выставлять различные права на различные куски памяти процесса. То есть в «обычном» режиме вы можете выделить память на куче, но вы не можете просто выполнить код, который выделен на куче, предварительно явно не попросив об этом ОС.
Я надеюсь, всем понятно, что машинный код — это просто данные, набор байтов. Как вот это, например:
unsigned char[] code = {0x48, 0x89, 0xf8};
Для кого-то эти три байта — просто три байта, а для кого-то — бинарное представление валидного x86–64 кода:
mov %rdi, %rax
Поместить этот машинный код в память очень легко. Но как сделать его исполняемым и, собственно, исполнить?
Посмотрим на код
Дальше в этой статье будут примеры кода для POSIX-совместимой UNIX операционной системы (а именно Linux). На других ОС (таких как Windows) код будет отличаться в деталях, но не в подходе. У всех современных ОС есть удобные API для того, чтобы сделать то же самое.
Без лишних предисловий посмотрим, как динамически создать функцию в памяти и выполнить ее. Эта функция специально сделана очень простой. В C она выглядит так:
long add4(long num) {
return num + 4;
}
Вот первая попытка (полный исходник вместе с Makefile доступен в репозитории):
#include
#include
#include
#include
// Выделяет RWX память заданного размера и возвращает указатель на нее. В случае ошибки
// печатает ошибку и возвращает NULL.
void* alloc_executable_memory(size_t size) {
void* ptr = mmap(0, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == (void*)-1) {
perror("mmap");
return NULL;
}
return ptr;
}
void emit_code_into_memory(unsigned char* m) {
unsigned char code[] = {
0x48, 0x89, 0xf8, // mov %rdi, %rax
0x48, 0x83, 0xc0, 0x04, // add $4, %rax
0xc3 // ret
};
memcpy(m, code, sizeof(code));
}
const size_t SIZE = 1024;
typedef long (*JittedFunc)(long);
// Выделяет RWX память напрямую.
void run_from_rwx() {
void* m = alloc_executable_memory(SIZE);
emit_code_into_memory(m);
JittedFunc func = m;
int result = func(2);
printf("result = %d\n", result);
}
Три основных этапа, которые выполняет этот код:
- Использование mmap для выделения куска памяти на куче, в которую можно писать, из которой можно читать и которую можно исполнять.
- Копирование машинного кода, реализующего add4 в эту память.
- Выполнение кода из этой памяти путем преобразования указателя в указатель на функцию и вызова ее через этот указатель.
Прошу заметить, что третий этап возможен только тогда, когда кусок памяти с машинным кодом имеет права на исполнение. Без нужных прав вызов функции привел бы к ошибке ОС (скорее всего, ошибке сегментирования). Это произойдет, если, например, мы выделим m обычным вызовом malloc, который выделяет RW память, но не X.
Отвлечемся на минутку: heap, malloc и mmap
Внимательные читатели могли заметить, что я сказал о памяти, выделяемой mmap, как о «памяти из кучи». Строго говоря, «куча» — название для источника памяти, который используют функции malloc
, free
и другие. В отличие от стека, которым управляет компилятор напрямую.
Но не все так просто. :-) Если традиционно (то есть очень давно) malloc
использовал только один источник для выделяемой памяти (системный вызов sbrk
), то сейчас большинство реализаций malloc
во многих случаях используют mmap
. Детали отличаются от операционки к операционке и в разных реализациях, но обычно mmap используется для больших кусков памяти, а sbrk
— для маленьких. Различие в эффективности во время использования одного или другого способа получения памяти от операционной системы.
Так что называть память, полученную от mmap «памятью из кучи», не ошибка, по моему мнению, и я собираюсь и дальше использовать это название.
Заботимся о безопасности
У кода выше есть серьезная уязвимость. Причина в блоке RWX-памяти, который он выделяет — рай для эксплоитов. Давайте будем чуть более ответственными. Вот немного измененный код:
// Выделяет RW память заданного размера и возвращает указатель на нее. В случае ошибки
// печатает ошибку и возвращает NULL. В отличие от malloc, память выделяется
// на границе страниц памяти, так что ее можно использовать при вызове mprotect.
void* alloc_writable_memory(size_t size) {
void* ptr = mmap(0, size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (ptr == (void*)-1) {
perror("mmap");
return NULL;
}
return ptr;
}
// Ставит RX права на этот кусок выровненной памяти. Возвращает
// 0 при успехе. При ошибке печатает ошибку и возвращает -1.
int make_memory_executable(void* m, size_t size) {
if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) {
perror("mprotect");
return -1;
}
return 0;
}
// Выделяет RW память, сохраняет код в нее и меняет права на RX перед
// исполнением.
void emit_to_rw_run_from_rx() {
void* m = alloc_writable_memory(SIZE);
emit_code_into_memory(m);
make_memory_executable(m, SIZE);
JittedFunc func = m;
int result = func(2);
printf("result = %d\n", result);
}
Этот пример эквивалентен предыдущему примеру во всех отношениях, кроме одного: память сначала выделяется с 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 операционной системы. Для первой фазы возможностей бесконечное количество. И что именно будет в ней в конечном счете, зависит от конкретного приложения, которое вы разрабатываете.