Введення в компілятори, інтерпретатори і JIT's

З народженням PHP 7 не припиняються суперечки про деревах абстрактного синтаксису, just-in-time компіляторах, статичному аналізі і т. д. Але що означають всі ці терміни? Це якісь чарівні властивості, що роблять PHP набагато продуктивніше? І якщо так, то як це все працює? У цій статті ми розглянемо основи роботи мов програмування та роз'яснимо для себе процес, який повинен виконуватися до того, як комп'ютер запустить, наприклад, ваш PHP-скрипт.

Інтерпретуючи код
Але перш ніж говорити про те, як це все працює, давайте розберемо один простий приклад. Уявімо, що у нас є нова мова програмування (придумайте будь назва). Мова досить проста:

  • кожен рядок являє собою вираз
  • кожен вираз складається з команди (оператора)
  • і будь-якої кількості значення (операндів), якими оперує команда.
Приклад:

set a 1

set b 2

add a b c

print c


Це простий мову, так що ми можемо без побоювання припустити, що цей код всього лише виводить на екран 3. Оператор
set
бере змінну і присвоює їй число (зовсім як
$a=1
в PHP). Оператор
add
бере дві змінні для додавання і зберігає результат у третій. Оператор
print
виводить її на екран.

Тепер давайте напишемо програму, яка зчитує кожне «вираз», знаходить оператор і операнди, а потім щось з ними робить, залежно від конкретного оператора. Це досить просто реалізувати на PHP, як ви можете бачити на прикладі лістингу 1.

Лістинг 1

01. <?php
02.
03. $lines = file($argv[1]);
04.
05. $linenr = 0;
06. foreach ($lines as $line) {
07. $linenr++;
08. $operands = explode(" ", trim($line));
09. $command = array_shift($operands);
10.
11. switch ($command) {
12. case 'set' :
13. $vars[$operands[0]] = $operands[1];
14. break;
15. case 'add' :
16. $vars[$operands[2]] = $vars[$operands[0]] + $vars[$operands[1]];
17. break;
18. case 'print' :
19. print $vars[$operands[0]] . "\n";
20. break;
21. default :
22. throw new Exception(sprintf("Unknown command in line %s\n", $linenr));
23. }
24. }

Це дуже проста програма, і вам не доведеться писати своє наступне веб-додаток на вашому новому мовою. Але даний приклад допомагає зрозуміти, як легко можна створити новий мову і отримати програму, яка здатна читати і виконувати цю мову. У нашому випадку вона порядково зчитує вихідний файл і виконує код в залежності від поточного оператора. Для запуску програми нам не потрібно перетворювати його в асемблер або двійковий код, воно і так чудово працює. Цей метод виконання програм називається интерпретированием. Наприклад, таким чином часто виконуються програми на Basic: кожен вираз зчитується і відразу ж виконується в высокоуровневом режимі.

Але тут є ряд проблем. Одна з них полягає в тому, що написати подібний мовний процесор досить легко, а от виконуватися новий мову буде дуже повільно. Адже нам доведеться обробляти кожну рядок і перевіряти:

  • Який оператор потрібно виконати?
  • Це правильний оператор?
  • Є у нього потрібну кількість операндів?
А адже нам не можна забувати і про інших завданнях. Наприклад, оператор set може присвоювати змінним тільки числові значення або рядкові теж? Або навіть значення інших змінних? Щоб правильно обробити кожен вираз, потрібно відповісти на всі ці питання. Що станеться, якщо написати
set 1
4? Коротше, таким чином практично неможливо створювати швидко працюючі додатки.

Але, незважаючи на неквапливість, у інтерпретування є переваги: ми можемо відразу запускати програму після кожного внесеного зміни. Для уважних: коли я щось міняю в PHP-скрипті, я відразу можу його виконати і побачити зміни; чи означає це, що PHP інтерпретується в microsoft мова? На даний момент будемо вважати, що так. PHP-скрипт інтерпретується подібно нашому гіпотетичному простому мови. Але в наступних розділах ми ще до цього повернемося!

Транскомпилирование
Як можна змусити нашу програму «працювати швидко»? Це можна зробити різними способами. Один з них, розроблений в Facebook, називається HipHop (я маю на увазі «стару» систему HipHop, а не використовувану сьогодні HHVM). HipHop перетворював одну мову (PHP) в інший (С++). Результат перетворення можна було з допомогою компілятора С++ перетворити двійковий код. Його комп'ютер здатний зрозуміти і виконати без додаткового навантаження у вигляді інтерпретатора. В результаті економиться ВЕЛИЧЕЗНА кількість обчислювальних ресурсів і програма працює набагато швидше.

Цей метод називається source-to-source компилированием, або транскомпилированием, або навіть транспилированием (transpiling). Насправді відбувається не компілювання в двійковий код, а перетворення в те, що може бути скомпільовано у машинний код існуючими компіляторами.

Транскомпилирование дозволяє безпосередньо виконувати двійковий код, що підвищує продуктивність. Однак у цього методу є і зворотна сторона: перш ніж виконати код, нам спочатку потрібно провести транскомпилирование, а потім даний компілювання. Але це потрібно робити тільки тоді, коли в додаток вносяться зміни, тобто тільки під час розробки.

Транскомпилирование також використовується для того, щоб зробити «жорсткі» мови більш простими і динамічними. Наприклад, браузери не розуміють код, написаний на LESS, SASS і SCSS. Але зате його можна транспилировать в CSS, який браузери розуміють. Підтримувати CSS простіше, але доводиться додатково транскомпилировать.

Компілювання
Щоб все працювало ще швидше, потрібно позбутися від стадії транскомпилирования. Тобто компілювати нашу мову відразу в двійковий код, який міг би тут же виконуватися, без додаткового навантаження у вигляді інтерпретування чи транскомпилирования.

На жаль, написання компілятора — одна з найважчих завдань в інформатиці. Наприклад, при компіляції в двійковий код потрібно враховувати, на якому комп'ютері він буде виконуватися: на 32-бітної Linux, або 64-бітної Windows, або взагалі на OS X. Зате інтерпретується в microsoft скрипт може легко виконуватися де завгодно. Як і в PHP, нам не потрібно переживати про те, де виконується наш скрипт. Хоча може зустрічатися і код, призначений для конкретної ОС, що зробить неможливим виконання скрипта на інших системах, але це не вина інтерпретатора.

Але навіть якщо ми позбудемося від стадії транскомпилирования, нам нікуди не дітися від компілювання. Наприклад, великі програми, написані на С (компільований мова), можуть компілюватися мало не годину. Уявіть, що ви написали додаток на PHP і вам потрібно чекати ще десять хвилин, перш ніж побачити, чи працюють внесені зміни.

Використовуючи все найкраще
Якщо інтерпретування передбачає повільне виконання, а компілювання складно в реалізації і вимагає більше часу при розробці, то як працюють мови начебто PHP, Python або Ruby? Вони досить швидкі!

Це тому, що вони використовують та інтерпретування, і компілювання. Давайте подивимося, як це виходить.

Що, якщо б ми могли перетворювати наш вигаданий мова не безпосередньо в двійковий код, а в щось, дуже на нього схоже (це називається «байт-код»)? І якщо б цей байт-код був так близький до того, як працює комп'ютер, що його інтерпретування виконувалося б дуже швидко (наприклад, мільйони байт-кодів в секунду)? Це зробило б наше додаток майже таким же швидким, як і компилируемое, при цьому збереглися б всі переваги інтерпретованих мов. Найголовніше, нам не довелося б компілювати скрипти при кожній зміні.

Виглядає дуже заманливо. По суті, подібним чином працюють багато мов — PHP, Ruby, Python і навіть Java. Замість зчитування і почергового інтерпретування рядків вихідного коду, в цих мовах використовується інший підхід:

  • Крок 1. Вважати скрипт (PHP) цілком в пам'ять.
  • Крок 2. Цілком перетворити/компілювати скрипт в байт-код.
  • Крок 3. Виконати байт-код допомогою інтерпретатора (PHP).
Насправді кроків більше, і в реальності весь процес набагато складніше. Але в цілому трьох описаних кроків достатньо для запуску скрипта з командного рядка або для виконання запиту через ваш веб-сервер.

Процес можна легко оптимізувати: припустимо, що ми запустили веб-сервер і кожен запит виконує скрипт
index.php
. Навіщо кожен раз вантажити його в пам'ять? Краще закешувати файл, щоб можна було швидко перетворювати його при кожному запиті.

Ще одна оптимізація: після генерування байт-коду ми можемо використовувати його при всіх наступних запитах. Так що можна закешувати і його (головне, переконайтеся, що при зміні вихідного файлу, байт-код буде перекомпилироваться). Саме це роблять кеші коду операцій (opcode caches), начебто розширення OPCache в PHP: кешируют скомпільовані скрипти, щоб їх можна було швидко виконати при наступних запитах без надлишкових завантажень і компілювання в байт-код.

Нарешті, останній крок до високої швидкості виконання байт-коду нашим PHP-інтерпретатором. У наступній частині ми порівняємо це з звичайними інтерпретаторами. Щоб уникнути плутанини: подібний інтерпретатор байт-коду часто називається «віртуальною машиною», тому що певною мірою він копіює роботу машини (комп'ютера). Не треба плутати це з віртуальними машинами, які вони використовують на комп'ютерах, начебто VirtualBox або VMware. Мова йде про такі речі, як JVM (Java Virtual Machine) в світі Java і HHVM (HipHop Virtual Machine) в світі PHP. Свої віртуальні машини є у Python і Ruby. У деякому роді всі вони є высокоспециализированными і продуктивними інтерпретаторами байт-коду.

Кожна ВМ виконує власний байт-код, що генерується конкретною мовою, і вони несумісні між собою. Ви не можете виконувати байт-код PHP на ВМ Python, і навпаки. Проте теоретично можливо створити програму, компилирующую PHP-скрипти в байт-код, який буде зрозумілий ВМ Python. Так що в теорії ви можете запускати PHP-скрипти в Python (серйозний виклик!).

Байт-код
Як виглядає і працює байт-код? Розглянемо два приклади. Візьмемо PHP-код:

$a = 3;
echo "hello world";
print $a + 1;

Подивитися його байт-коду можна за допомогою 3v4l.org або встановивши розширення VLD. Отримаємо наступне:



Тепер візьмемо аналогічний приклад на Python:

def foobar():
a = 1
print "hello world",
print a + 4

Python може безпосередньо згенерувати коди операцій ©python:

dis.dis(func):



У нас є два простих скрипта і їх байт-коди. Зверніть увагу, що байт-коди схожі на мову, який ми створили» на початку статті: кожен рядок являє собою оператор з будь-якою кількістю операндів. В байт-код PHP змінних додається !, тому !0 означає змінну 0. Байт-коду не важливо, що ви використовуєте змінну $a: в ході компілювання імена змінних втрачають значення і перетворюються в числа. Це полегшує і прискорює їх обробку віртуальною машиною. Більшість необхідних «перевірок» виконуються на стадії компілювання, що також знімає навантаження з віртуальної машини і збільшує швидкість її роботи.

Оскільки байт-код складається з простих інструкцій, інтерпретування проходить дуже швидко. Замість тисяч двійкових інструкцій, які треба обробити для кожного виразу интерпретируемого мови, в байт-код на кожен вираз доводиться по кілька сотень інструкцій (іноді й того менше). Тому віртуальні машини працюють набагато швидше інтерпретованих мов.

Іншими словами, виртуалки взяли все найкраще від двох світів. Хоча нам ще потрібно компілювати з вихідного коду в байт-код, цей процес стає швидким і прозорим. А після отримання байт-коду віртуальна машина швидко і ефективно інтерпретує його без зайвих накладних витрат. І в результаті ми маємо високопродуктивне додаток.

Від вихідного коду до байт-коду
Тепер, коли ми вміємо ефективно виконувати згенерований байт-код, залишається завдання компілювання вихідного коду в цей байт-код.

Розглянемо наступні PHP-вирази:

$a = 1;

$a=1;

$a
=
1;

Всі вони однаково вірні і повинні бути перетворені в однакові байт-коди. Але як ми їх зчитуємо? Адже в нашому власному інтерпретаторі ми парсим команди, розділяючи їх пробілами. Це означає, що програміст повинен писати код в одному стилі, на відміну від PHP, де ви можете в одному рядку використовувати відступу або прогалини, дужки в одному рядку або переносити на другий рядок і т. д. В першу чергу компілятор спробує перетворити ваш вихідний код в токени. Цей процес називається лексингом (lexing) або токенизацией.

Лексинг
Токенизация (лексинг) полягає в перетворенні вихідного PHP-коду — без розуміння його значення — у довгий список токенів. Це складний процес, але в PHP ви можете досить легко зробити щось подібне. Представлений в лістингу 2 код видає наступний результат:

T_OPEN_TAG <?php
T_VARIABLE $a
T_WHITESPACE
=
T_WHITESPACE
T_LNUMBER 3
;
T_WHITESPACE
T_ECHO echo
T_WHITESPACE
T_CONSTANT_ENCAPSED_STRING "hello world"
;

Значення рядка перетворюється в токени:

  • <?php перетворений у токен T_OPEN_TAG,
  • $a перетворений у токен T_VARIABLE, який містить значення $a.
Токенизатор знає про це, коли при читанні коду виявляє знак $ з буквою a, після яких може слідувати будь-яку кількість букв і цифр. Числа токенизируются у вигляді T_LNUMBER і можуть бути одно — і більше розрядними. Токенизация дозволяє представити вихідний код в більш структурованому вигляді, не змушуючи робити це самого програміста. Але, як вже згадувалося, токенизатор не розуміє значення токенів. Він ідеально токенизирует і $a = 1, 1 = $a. А в наступній частині ми навчимося парсити — задавати значення потоку токенів.

Парсинг
При парсингу токенів ми повинні слідувати деяким «правилами», які складають нашу мову. Наприклад, може бути правило: перший виявлений токен в програмі повинен бути T_OPEN_TAG (відповідає <?php).

Ще одне можливе правило: присвоювання може складатися з будь-якого T_VARIABLE, після якого йде символ =, а потім T_LNUMBER, T_VARIABLE або T_CONSTANT_ENCAPSED_STRING. Іншими словами, ми дозволяємо $a = 1, або $a = $b, $a = 'foobar', але не 1 = $a. Якщо парсер виявляє серію токенів, що не задовольняють якогось із правил, автоматично буде видана помилка синтаксису. Загалом, парсинг — це процес, що визначає мову і дозволяє нам створювати синтаксичні правила.

Подивитися список правил, використовуваних в PHP, можна за адресою. Якщо ваш PHP-скрипт задовольняє синтаксичним правилам, то проводяться додаткові перевірки, щоб підтвердити, що синтаксис не тільки правильний, а й осмислений: визначення
public abstract final final private class foo() {}
може бути коректним, але не має сенсу з точки зору PHP. Токенизация і парсинг — хитрі процеси, і часто для їх виконання беруть сторонні додатки. Нерідко використовуються інструменти на зразок flex і bison (в PHP теж). Їх можна розглядати і як транскомпиляторов: вони перетворять ваші правила З-код, який буде автоматично компілюватися, коли ви компилируете PHP.

Парсери і токенизаторы корисні і в інших сферах. Наприклад, вони використовуються для парсингу SQL-виразів в базах даних, і на PHP також написано чимало парсерів і токенизаторов. У об'єктно-реляційного маппера Doctrine є свій парсер для DQL-виразів, а також «транскомпилятор» для перетворення DQL в SQL. Багато пакети шаблонів, в тому числі Twig, використовують власні токенизаторы і парсери для «компілювання» файлів шаблонів назад в PHP-скрипти. По суті, ці движки теж транскомпиляторы!

Дерева абстрактного синтаксису
Після токенизации і парсинга нашої мови ми можемо генерувати байт-код. Аж до PHP 5.6 він генерувався під час парсингу. Але звичніше було б додати в процес окрему стадію: нехай парсер не генерує байт-код, а так зване дерево абстрактного синтаксису (Abstract Syntax Tree, AST). Це деревовидна структура, в якій абстрактно представлена вся програма. AST не тільки спрощує генерування байт-коду, але і дозволяє нам вносити зміни в дерево, перш ніж воно буде перетворено. Дерево завжди генерується особливим чином. Вузол дерева, що представляє собою вираз if, обов'язково має під собою три елементи:

  • перший містить умову (зразок
    $a == true
    );
  • другий містить вирази, які повинні бути виконані, якщо дотримується умова
    true
    ;
  • третій містить вирази, які повинні бути виконані, якщо дотримується умова
    false
    (вираз
    else
    ).
Навіть якщо
else
відсутня, три елемента, просто третій буде порожнім.

В результаті ми можемо «переписати» програму до того, як вона буде перетворена в байт-код. Іноді це використовується для оптимізації коду. Якщо ми виявимо, що розробник раз за разом перевычислял змінну всередині циклу, і ми знаємо, що змінна завжди має одне і те ж значення, то оптимізатор може переписати AST так, щоб створити тимчасову змінну, яку не потрібно кожен раз заново обчислювати. Дерево можна використовувати для невеликої реорганізації коду, щоб він працював швидше: видалити непотрібні змінні і т. п. Це не завжди можливо, але коли у нас є дерево всієї програми, то такі перевірки та оптимізації виконувати куди легше. Всередині AST можна подивитися, оголошуються змінні до їх використання або використовується присвоювання в умовному блоці (
if ($a = 1) {}
). І при виявленні потенційно помилкових структур видати попередження. З допомогою дерева можна навіть аналізувати код з точки зору інформаційної безпеки і попереджати користувачів під час виконання скрипта.

Все це називається статичним аналізом — він дозволяє створювати нові можливості оптимізації і системи валідації, що допомагають розробникам писати гармонійний, безпечний і швидкий код.

В PHP 7.0 з'явився новий движок парсинга (Zend 3.0), який теж генерує AST під час парсингу. Оскільки він досить свіжий, з його допомогою можна зробити не так багато. Але сам факт його наявності означає, що ми можемо очікувати появи в найближчому майбутньому самих різних можливостей. Функція
token_get_all()
вже бере нову, недокументовану константу TOKEN_PARSE, яка в майбутньому може використовуватися для повернення не тільки токенів, але і отпарсенного AST. Сторонні розширення начебто php-ast дозволяють переглядати і редагувати дерево прямо в PHP. Повна переробка движка Zend і реалізації AST відкриє PHP для самих різних нових завдань.

JIT
Крім віртуальних машин, що виконують высокооптимизированный байт-код, згенерований з AST, є й інша методика підвищення швидкості. Але це одна з найскладніших у реалізації речей.

Як виконується програма? Багато часу витрачається на його налаштування: наприклад, потрібно запустити фреймворк, отпарсить маршрути, обробити змінні середовища і т. д. По завершенні всіх цих процедур програма зазвичай все ще не запущена. По суті, купа часу витрачено лише на функціонування якійсь частині вашого додатка. А що, якщо ми виявимо ті частини, які можуть часто запускатися і здатні перетворювати маленькі шматки коду (припустимо, всього кілька методів) у двійковий код? Звичайно, на це компілювання може йти відносно багато часу, але все одно метод компілюється куди швидше, ніж усі додаток. Можливо, при першому виклику функції ви зіткнетеся з малою затримкою, але всі наступні виклики будуть виконуватися блискавично, минаючи віртуальну машину, і відразу у вигляді двійкового коду.

Ми отримуємо швидкість компилируемого коду і насолоджуємося перевагами коду интерпретируемого. Подібні системи можуть працювати швидше звичайного интерпретируемого байт-коду, іноді набагато швидше. Мова йде про JIT-компіляторах (just-in-time, точно в строк). Назва підходить як не можна краще. Система виявляє, які частини байт-коду можуть бути хорошими кандидатами на компілювання в двійковий код, і робить це в той момент, коли потрібно виконувати ці самі частини. Тобто — точно в термін. Програма може стартувати негайно, не потрібно чекати завершення компілювання. У двійковий код перетворюються лише найефективніші частини коду, так що процес компілювання автоматизується та прискорюється.

Хоча не всі JIT-компілятори працюють таким чином. Деякі компілюють всі методи на льоту; інші намагаються тільки визначити, які функції потрібно скомпілювати на ранній стадії; треті будуть компілювати функції, якщо вони викликаються два і більше рази. Але все JIT'и використовують один принцип: збирати маленькі шматки коду, коли вони дійсно потрібні.

Ще одна перевага JIT'ів у порівнянні зі звичайним компилированием полягає в тому, що вони здатні краще прогнозувати та оптимізувати на основі поточного стану програми. JIT'и можуть динамічно аналізувати код під час runtime і робити припущення, на які нездатні звичайні компілятори. Адже під час компіляції у нас немає інформації про поточний стан програми, а JIT'и компілюють на стадії виконання.

Якщо вам доводилося працювати з HHVM, то ви вже використовували JIT-компілятор: PHP-код (і надмножественный мова Hack) перетворюється в байт-код, який виконується на віртуальній машині HHVM. Машина виявляє блоки, які можуть бути безпечно перетворені у двійковий код; якщо це ще не було зроблено, вона це робить і запускає їх. По закінченні запуску ВМ переходить до наступних байт-кодів, які можуть бути перетворені в двійковий код.

PHP 7 не виконується на JIT-компіляторі, але зате його нова система перевершує всі попередні релізи. Зараз у всіх його компонентах проводяться експерименти зі статичним аналізом, динамічної оптимізацією, і навіть є прості JIT-системи. Так що не виключено, що одного разу навіть PHP 7 опиниться позаду!
Джерело: Хабрахабр

0 коментарів

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