How PHP interpreter works

Jak działa interpreter PHP

PHP, jak to ma miejsce w przypadku wielu innych języków używanych do zastosowań webowych, jest językiem interpretowanym. Uruchamiając aplikacją napisaną w PHP, na ogół nie zastanawiamy się, co tak naprawdę dzieje się z jej kodem w trakcie wykonania. W artykule tym dowiesz się, w jaki sposób przetwarzany jest gotowy kod przez interpreter PHP.

Kompilacja a interpretacja

Języki kompilowane, takie jak np. C, C++ odróżnia od języków interpretowanych fakt, że ich przetworzenie do kodu maszynowego wykonywane jest tylko raz. Po procesie kompilacji aplikację taką możemy uruchomić już wielokrotnie bez potrzeby kolejnej kompilacji. Skompilowana raz aplikacja to brak dodatkowego narzutu czasowego na kolejne jej przetwarzanie, ale jednocześnie trudniejszy proces rozwoju (zmiany wymagają ponownej kompilacji). Z drugiej strony znajdują się natomiast języki interpretowane, takie jak PHP, Python czy Ruby. Są one mniej wydajne, gdyż ich kod przetwarzany jest przez osobną aplikację (interpreter), który tłumaczy kod aplikacji “w locie”. Taka strategia ma swoje przełożenie na mniejszą wydajność i czas wykonania aplikacji, ale z drugiej strony pozwala na większą elastyczność i łatwość rozwoju oprogramowania. Przyjrzymy się zatem bliżej, jak działa interpreter w PHP.

Zend Engine

Zend Engine to silnik i zarazem serce języka PHP. Składa się on z kompilatora kodu źródłowego do kodu bajtowego oraz maszyny wirtualnej, która ten kod wykonuje. Jest on dostarczany bezpośrednio wraz z PHP - instalując PHP, instalujesz jednocześnie Zend Engine. To on jest odpowiedzialny za cały proces przetwarzania kodu od momentu, kiedy nasz serwer HTTP przekaże do niego wykonanie żądanego skryptu PHP, aż do momentu wygenerowania kodu HTML i zwrócenia go na wyjściu z powrotem do serwera. Cały proces przetwarzania skryptu PHP interpreter wykonuje w dużym uproszczeniu w czterech etapach:

  • analizy leksykalnej (lexing), 
  • analizy składowania (parsowanie), 
  • kompilacji,
  • wykonania. 

Wraz z wprowadzeniem mechanizmu OPcache cały ten proces może zostać w zasadzie pominięty do wykonania ostatniego kroku - uruchomienia/wykonania aplikacji w maszynie wirtualnej. Sytuacja jest jeszcze bardziej komfortowa, jeśli orientujemy się co nowego przynosi PHP w wersji 8. Chodzi oczywiście o kompilator JIT, który pozwala na kompilację kodu PHP. W efekcie możliwe jest bezpośrednie uruchomienie kodu maszynowego - z pominięciem procesu interpretacji, czy wykonania go przez maszynę wirtualną. 

Jako ciekawostkę dodam, że w przeszłości istniała jeszcze inna możliwość - transpilacja kodu, np. do języka C++. Takie rozwiązanie zastosowano w nierozwijanym już HipHop dla PHP autorstwa programistów Facebooka. W dalszym etapie zastąpiono jednak transpilację projektem HHVM (HipHop Virtual Machine), bazującym na kompilacji just-in-time (JIT).

Mimo wszystko sprawdźmy, jak w najsurowszej postaci wyglądają poszczególne kroki interpretacji.

Analiza leksykalna (Leksing)

Zwana też niekiedy tokenizacją (tokenizing) to faza, która wręcz dosłownie polega na przetworzeniu łańcucha znaków z kodu źródłowego napisanego w PHP na sekwencję tokenów, które opisują, co oznacza każda po kolei napotkana wartość. Tak wygenerowany zestaw tokenów pomaga interpreterowi w dalszym przetwarzaniu kodu. 

PHP korzysta z generatora lekserów re2c przy użyciu pliku definicji zend_language_scanner.l. W swojej podstawowej formie uruchamia on wyrażenia regularne na przekazanym pliku, co pozwala na identyfikację poszczególnych elementów kodu, np. ze składni języka takich jak “if”, “switch”, “function”, itd.

Jeśli chcielibyśmy lepiej zrozumieć, w jaki sposób takie tokeny są generowane, to dobrze obrazuje to implementacja poniższego kodu PHP:

<?php
function lexer($bytes, ...) {
    switch ($bytes) {
        case substr($bytes, 0, 2) == "if":
            return TOKEN_IF;
    }
}
?>

Oczywiście, lexer nie działa dokładnie w taki sposób, ale daje to pewne wyobrażenie na to, jak analizowany jest kod. Jeśli natomiast chcielibyśmy wiedzieć, jak wyglądają wygenerowane tokeny dla przykładowego kodu

<?php

$my_variable = 1;

to wygląda on następująco:

T_OPEN_TAG ('<?php')
T_VARIABLE ('$my_variable')
T_WHITESPACE (' ')
=
T_WHITESPACE (' ')
T_LNUMBER ('1')
;

Na pierwszy rzut oka można dostrzec, że nie wszystkie elementy są tokenami. Niektóre znaki takie jak =, ;, :, ? są uznawane za tokeny same w sobie.

Co ciekawe lexer nie tylko zajmuje się przetwarzaniem kodu na tokeny, ale dodatkowo sam przechowuje informacje o wartości przechowywanej przez tokeny, a także w odniesieniu do konkretnej linii, w której ją przechwycił. Wykorzystuje się to między innymi do generowania śladu stosu aplikacji.

Analiza składniowa (parsing)

Jest to kolejny po lexingu proces, który polega na przetworzeniu wygenerowanych tokenów na bardziej uporządkowaną i zorganizowaną strukturę danych. Tak jak w przypadku lexingu PHP opiera się tu na zewnętrznym narzędziu o nazwie GNU Bison w oparciu o plik BNF zawierający gramatykę języka. Pozwala on przekonwertować bezkontekstową gramatykę w bardziej celową, przyczynowo-skutkową. Do konwersji wykorzystywana jest metoda LALR(1), która czyta wejście z podglądem o n tokenów do przodu (w przypadku PHP o 1) od lewej do prawej i wytwarza prawostronne wyprowadzenie. Parser poprzez ten proces jest w stanie dopasowywać tokeny do reguł gramatycznych, zdefiniowanych w pliku BNF. W procesie dopasowania tokenów dokonywana jest walidacja tego, czy tokeny tworzą poprawne konstrukcje składniowe.

Finalnym tworem tej fazy jest wygenerowanie przez parser drzewa składni abstrakcyjnej (AST). Jest to widok drzewa kodu źródłowego, który będzie używany w fazie kompilacji. Korzystając z rozszerzenia php-ast możliwe jest podejrzenie takiej przykładowej struktury.
Posługując się ponownie przykładowym fragmentem kodu:

$php_code = <<<'code'
<?php
$my_variable = 1;
code;

print_r(ast\parse_code($php_code, 30));

W rezultacie otrzymamy drzewo o takiej strukturze:

ast\Node Object (
    [kind] => 132
    [flags] => 0
    [lineno] => 1
    [children] => Array (
        [0] => ast\Node Object (
            [kind] => 517
            [flags] => 0
            [lineno] => 2
            [children] => Array (
                [var] => ast\Node Object (
                    [kind] => 256
                    [flags] => 0
                    [lineno] => 2
                    [children] => Array (
                        [name] => my_variable
                    )
                )
                [expr] => 1
            )
        )
    )
)

Choć z punktu widzenia programisty taka struktura niewiele może powiedzieć, to przydaje się ona w celu przeprowadzenia analizy statycznej kodu przy użyciu narzędzi takich jak Phan.

AST jest ostatnią fazą analizy - w następnym kroku kod w takiej postaci przekazywany jest już do kompilacji.

Kompilacja

PHP bez użycia JIT w swojej standardowej formie kompilowany jest z wygenerowanego AST do OPCode, a nie jak to ma miejsce w przypadku JIT do kodu maszynowego. Proces kompilacji dokonywany jest poprzez rekurencyjne przejście przez drzewo AST, w ramach którego dokonywane są także pewne optymalizacje. Najczęściej przy okazji wykonywane są proste obliczenia arytmetyczne, czy np. zamiana takich wyrażeń jak strlen("test") na bezpośrednią wartość int(4).

Podobnie jak w przypadku poprzednich faz, tak również tu istnieją narzędzia do przeglądania wygenerowanego OPCode. Do dyspozycji mamy między innymi VLD, czy OPCache. Poniżej znajduje się przykładowy zrzut dostarczony przez VLD ze skompilowanej klasy Greeting dostarczającej metodę sayhello:

Class Greeting:
Function sayhello:
number of ops:  8
compiled vars:  !0 = $to

line      # *    op                      fetch          ext     return     operands
----------------------------------------------------------------------------
   3      0  >   EXT_NOP
          1      RECV                                         !0
   5      2      EXT_STMT
          3      ADD_STRING                                   ~0    'Hello+'
          4      ADD_VAR                                      ~0    ~0, !0
          5      ECHO                                                 ~0
   6      6      EXT_STMT
          7    > RETURN                                               null

Przeglądając powyższy zrzut, wprawny programista PHP jest w stanie zrozumieć w stopniu podstawowym jego strukturę. Znajduje się tu zdefiniowana klasę i metoda, a następnie:

  • przyjęcie przez funkcję wartości,
  • stworzenie tymczasowej zmiennej,
  • konkatenację łańcuchów kryjących się pod zmiennymi,
  • drukowanie tymczasowej zmiennej,
  • powrót z funkcji po jej zakończeniu.

Opisanie w stopniu wyczerpującym całego zagadnienia OPCode i jego elementów składowych zdecydowanie wyszłoby poza zakres tego artykułu. Jeśli chcesz wiedzieć więcej na ten temat, to z pewnością na początek pomoże oficjalna dokumentacja.

Wykonanie

To już ostatnia faza procesu interpretacji. Na tym etapie dokonuje się tak naprawdę uruchomienie wygenerowanego OPCode na wirtualnej maszynie Zend (Zend Engine VM). Efektem końcowym jest to, co dany skrypt miał wygenerować, czyli np. to samo co, zwracają nam na wyjściu takie komendy jak echo czy print. Z punktu widzenia aplikacji internetowych jest to najczęściej gotowy kod źródłowy strony.

Podsumowanie

Większość z nas na co dzień nie zastanawia się nad tym, jak w rzeczywistości analizowany i uruchamiany jest kod PHP na serwerze, zwłaszcza kiedy możemy powierzyć, jak np. naszej agencji, monitoring serwerów i aplikacji. Mimo wszystko warto jednak rozumieć, co tak naprawdę dzieje się z kodem naszej aplikacji podczas przekazania go do interpretera. Wiedza taka może pomóc zarówno w analizie bezpieczeństwa, jak i wydajności projektu rozwijanego w oparciu o PHP.

3. Najlepsze praktyki zespołów programistycznych