Як працювати з JIT

enter image description here
У деяких внутрішніх системах для швидкого пошуку за великим бітовому масиву ми в 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 < stdio.h>
#include <stdlib.h>
#include < string.h>
#include < sys/mman.h>

// Виділяє 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);
}

Три основних етапи, які виконує цей код:
  1. Використання mmap для виділення шматка пам'яті на купі, в яку можна писати, з якої можна читати і яку можна виконувати.
  2. Копіювання машинного коду, що реалізує add4 в цю пам'ять.
  3. Виконання коду з цієї пам'яті шляхом перетворення вказівник на вказівник на функцію та її виклику через цей вказівник.
Прошу зауважити, що третій етап можливий тільки тоді, коли шматок пам'яті з машинним кодом має права на виконання. Без потрібних прав виклик функції привів би до помилки ОС (швидше за все, помилково сегментування). Це станеться, якщо, наприклад, ми виділимо 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 операційної системи. Для першої фази можливостей нескінченна кількість. І що саме в ній в кінцевому рахунку, залежить від конкретного додатка, яке ви розробляєте.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.