Fibers w PHP - jak ułatwić wdrożenie asynchroniczności w projekcie
Author
Bernard van der Esch
blog_date_icon
17 listopada

Z tego artykułu dowiesz się:

  • Jakie problemy, może sprawić przepisanie jednej funkcjonalności na asynchroniczną
  • Czym jest nowy mechanizm w PHP 8.1 - Fibers
  • Jak Fibers, może Ci pomóc we wprowadzaniu asynchroniczności w twój projekt

Asynchroniczność w PHP

Język, jakim jest PHP, przyzwyczaił nas do programowania jednowątkowego. Najczęściej funkcje wywołujemy po kolei w taki sposób, że każda kolejna funkcja czeka z rozpoczęciem, dopóki poprzednia nie zwróci rezultatu. Jednakże kod nie musi być wykonywany linijka po linijce, w jednym wątku.

W wersji czwartej języka pojawiła się możliwości tworzenia kodu asynchronicznego. Z początku jedyną możliwością było tworzenie forków poprzez funkcję pcntl_fork. Szczęśliwie, dziś niewiele osób korzysta z tej metody, która polegała na dość skomplikowanym zarządzaniu wieloma procesami. Metoda ta, wbudowana w język PHP dzieliła istniejący proces na dwa oddzielne. Komunikacja między tymi procesami była bardzo utrudniona i łatwo było zająć zbyt dużo zasobów maszyny, na których te procesy były wykonywane. Dla ułatwienia stworzono wiele bibliotek, których dobrym przykładem może być biblioteka spatie/async.

W PHP 5.5 dodano słowa kluczowe - yield. Słowo to pozwala na tworzenie generatora. Pozwalają one na nielinearne wykonywanie kodu. Dzięki temu powstało wiele bibliotek wspomagających programowanie asynchroniczne. Przykładem może być Guzzle/promises.

Przyjrzyjmy się różnicy pomiędzy podejściem synchronicznym a asynchronicznym w PHP. W tym celu posłużmy się pewnym przykładem: Załóżmy, że chcemy ściągnąć treść trzech stron i połączyć je ze sobą w jedną zmienną. Nasz synchroniczny kod mógłby wyglądać w ten sposób:

$sites = [
   'http://sages.com.pl/',
   'http://sages.com.pl/szkolenia',
   'http://sages.com.pl/blog',
];

function downloadSite($siteUrl)
{
   $client = new GuzzleHttp\Client();
   return $client->get($siteUrl)->getBody();
}

function downloadAllSites($sites): string
{
   $sitesContent = [];
   foreach ($sites as $siteUrl) {
       $sitesContent[] = downloadSite($siteUrl);
   }

   return implode('<br>', $sitesContent);
}


$return = downloadAllSites($sites);

Jak widać ściągamy treść każdej ze stron po kolei. Na koniec łączymy wszystko w jedno. Jeżeli chcielibyśmy żeby ściąganie plików działało asynchronicznie, to wystarczy metodę get zamienić na getAsync. Metoda ta zwraca nam obiekt Promise. Obiekt ten reprezentuje wynik, który zostanie zwrócony po wywołaniu asynchronicznej funkcji.

function downloadSite($siteUrl)
{
   $client = new GuzzleHttp\Client();
   return $client->getAsync($siteUrl);
}

function downloadAllSites($sites)
{
   $results = [];
   foreach ($sites as $siteUrl) {
       $results[] = downloadSite($siteUrl);
   }

   //return ??
   //i co tu możemy zwrócić?
}

Niestety mając zwrócone obiekty typu promise, nie możemy ich połączyć do momentu, w którym wszystkie te funkcje się nie wykonają tak, jak nie możemy zwrócić obiekt typu promise. Problem ten świetnie opisuje artykuł Jakiego koloru jest Twoja funkcja?.

W bardzo dużym uproszczeniu ww. artykuł opisuje zalety i wady programowania asynchronicznego. Przestrzega przed asynchronicznością, wskazując przede wszystkim na to, że wywołanie funkcji asynchronicznej zmusza nas do traktowania całego stacka funkcji jako asynchronicznego. Odpowiedzią na ten problem, w wersji PHP 8.1 jest Fibers - rozwiązanie, z powodzeniem stosowane już w języku Ruby, które pozwala nam zastosować asynchroniczność bez przepisywania całego kodu. Oczywiście, jak widać na powyższym przykładzie, moglibyśmy odczekać, aż nasze funkcje asynchroniczne się wykonają, wywołując na nich metodę wait, ale to rozwiązanie pomijam, bo przez nie tracimy korzyści, wynikające z asynchroniczności.

Fibers

Fibers reprezentują przerywalne funkcje. Mogą być przerwane w dowolnym momencie i pozostać zawieszone do czasu ich wznowienia. Najlepiej przedstawia to przykład z dokumentacji PHP:

$fiber = new Fiber(function (): void {
   $value = Fiber::suspend('fiber');
   echo "Value used to resume fiber: ", $value, PHP_EOL;
});

$value = $fiber->start();

echo "Value from fiber suspending: ", $value, PHP_EOL;

$fiber->resume('test');

Przykład ten wyświetli nam: Value from fiber suspending: fiber Value used to resume fiber: test

Przyjrzyjmy się, co w tym przykładzie dzieje się po kolei. Do konstruktora klasy Fibers przekazujemy callback, która będzie wywołana w momencie uruchomienia metody start(). Kluczowe w tej funkcji jest wywołanie Fiber::suspend(). Przerywa to działanie funkcji przekazanej w konstruktorze. Funkcja jest wznawiana dopiero w momencie wywołania metody resume na obiekcie “fiber”. Innymi ciekawymi metodami klasy Fibers są:

  • isTerminated, informująca nas, czy callback przekazany do konstruktora się już wykonała,
  • getReturn, zwracająca to samo, co callback po wykonaniu.

Gdy pierwotnie zobaczyłem ten przykład, nie do końca rozumiałem, jakie może być jego zastosowanie. Odpowiedź odnalazłem, dopiero czytając RFC. Fibers są stworzone po to, by można było wywoływać funkcje asynchroniczne, bez przepisywania całego stosu wywołań funkcji. A zatem jest to odpowiedź na problem opisany na początku tego wpisu oraz w artykule Jakiego koloru jest Twoja funkcja?.

Spróbujmy napisać asynchroniczny kod, który równie dobrze mógłby się znaleźć wewnątrz funkcji synchronicznej:

use Spatie\Async\Pool;

$fiber = new Fiber(function (): string {
   $processes = [
       'operacja 1',
       'operacja 2',
       'operacja 3',
   ];

   $pool = Pool::create();

   $result = new stdClass();
   $result->result = '';
   foreach ($processes as $processName) {
       $pool->add(function() use($processName){
           $operationTime = rand(1, 15);
           sleep($operationTime);
           return $processName . ' zajeła ' . $operationTime . ' sekund' . PHP_EOL;
       })
       ->then(function($output) use ($result) {
           $result->result .= $output;
       });
   }

   while (count($pool->getFinished()) !== count($processes)) {
       Fiber::suspend();
       $pool->notify();
   }

   return $result->result;

});

$value = $fiber->start();

while ($fiber->isTerminated() === false) {
   sleep(1);
   echo 'W tym miejscu w kodzie, możesz dokonać dowolną operację'. PHP_EOL;
   $fiber->resume();
}

echo  $fiber->getReturn();

Nasz kod co jakiś czas sprawdza, czy asynchroniczne wywołania już się wykonały. A w międzyczasie pozwala nam na wykonywanie własnego kodu. Ten przykład, na standardowym wyjściu, wyświetli nam mniej więcej taki rezultat:

W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
W tym miejscu w kodzie, możesz dokonać dowolną operację
operacja 1 zajeła 4 sekund
operacja 3 zajeła 5 sekund
operacja 2 zajeła 7 sekund

Podsumowanie

Fibers są mało znanym i mało opisywanym mechanizmem PHP. Jeżeli wejdziemy głębiej w ten temat, okazuje się, że dają nam rozwiązanie na dość typowy problem przy programowaniu asynchronicznym. Dzięki nim możemy dodać w jednym punkcie naszej aplikacji asynchroniczność, bez konieczności przepisywania całej aplikacji. Niestety większość programistów PHP twierdzi, że nie wie, do czego służą “Fibers” . Zadałem takie pytanie na Twitterze i taką oto uzyskałem odpowiedź: fiberstweet.webp

Szczęśliwie, jak podkreślają twórcy RFC, nie oczekuje się, że interface Fibre będzie używany bezpośrednio w kodzie aplikacji. Fibers zapewniają podstawową i niskopoziomową kontrolę przepływu w aplikacji. Nadają się więc raczej do użycia w bibliotekach, takich jak spatie/async czy też ReactPHP. Dzięki nim użytkownik będzie mógł czerpać korzyści płynące pośrednio z Fibers.

Przeczytaj także