Generatory w php
Generatory zostały dodane stosunkowo dawno, bo jeszcze w wersji php 5.5. Jednak wydaje się, że są rzadko spotykane w różnych projektach, a może jednak czasem warto mieć świadomość ich istnienia, gdyż idealnie dopasowują się do niektórych problemów.
Czym są generatory?
Generatory są funkcjami, które pozwalają w wydajny sposób iterować po dużych zbiorach danych. Różnicą w składni w stosunku do zwykłej funkcji jest słowo kluczowe yield, którego użycie na pierwszy rzut oka przypomina użycie return. Myślę, że najlepiej zobrazować to na konkretnych przykładach.
Przetwarzanie dużej tablicy
Poniższy kod wygląda pewnie dosyć niepozornie, prosta pętla konstruująca tablicę. Sprawdzenie kodu w profilerze (blackfire) nie zwraca nic niepokojącego, użycie pamięci 33.6MB da się przeżyć.
<?php function getData() { $data = []; for ($i = 0; $i < 1000000; $i++) { $data[] = $i; } return $data; } $data = getData() ; foreach ($data as $item) { }
Jednak po zwiększeniu liczby iteracji do 10 000 000 wynik z blackfire wydaje się już nieco niepokojący. 537 MB to jest stanowczo za dużo jak na tak prostą operację. Gdzie leży problem?
Odpowiedź jest bardzo prosta, funkcja getData() musi całą tablicę najpierw skonstruować, a następnie dopiero po zwróconej tablicy iterujemy. Tutaj własnie znajdują zastosowanie Generatory.
<?php function getData() { for ($i = 0; $i < 10000000; $i++) { yield $i; } } $data = getData(); foreach ($data as $item) { }
Przerobienie funkcji, aby korzystała z generatora jest jak widać bardzo proste. Warto zauważyć, że tym razem tablica nie jest od razu konstruowana w pamięci. Funkcja getData zwraca obiekt klasy Generator, która implementuje interfejs Iteratora, co pozwala na proste przetwarzanie w pętli foreach.
Wynik z blackfire prezentuje się następująco:
Czas może trochę dłuższy, natomiast różnica zużycia pamięci w stosunku do poprzedniego rozwiązania jest kolosalna.
Przetwarzanie dużego pliku
Kolejnym dobrym przykładem zastosowania generatora może być przetwarzanie dużego pliku. W tym celu wygenerujmy plik do przetworzenia:
<?php $file = fopen('file.txt', 'wb'); for ($i = 0; $i < 1000000; $i++) { fwrite($file, random_bytes(1024)); } fclose($file);
Powyższy kod tworzy plik o rozmiarze około 1GB.
Teraz próba przetworzenia tego pliku linia po linii:
<?php function getLines($file) { $lines = []; while ($line = fgets($file)) { $lines[] = $line; } return $lines; } $file = fopen('file.txt', 'rb'); $lines = getLines($file); fclose($file); foreach ($lines as $line) { }
Wynik z profilera pokazuje problem, przy głupim przetworzeniu pliku potrzebujemy ponad 1GB pamięci.
<?php function getLines($file) { while ($line = fgets($file)) { yield $line; } } $file = fopen('file.txt', 'rb'); $lines = getLines($file); //fclose($file); foreach ($lines as $line) { } fclose($file);
Napisanie kodu z użyciem Generatora jest jak widać równie proste co poprzednio. Z tym, że należy zauważyć jedną rzecz. Uchwyt do pliku zostaje zamknięty dopiero po przetworzeniu generatora, jeśli zamkniemy go wcześniej w tym miejscu co zakomentowałem fclose, dostaniemy Warning:
PHP Warning: fgets(): supplied resource is not a valid stream resource
Wynik z blackfire pokazuje znaczącą różnice w użyciu pamięci:
Oczywiście oba przykłady sprowadzają się do tego samego, czyli do iterowania po dużym zbiorze danych bez budowania go w całości w pamięci.
Szybkość
Wróćmy jeszcze do poprzedniego przykładu i tablicy z milionem elementów. W przypadku normalnej funkcji czas wykonania to ~32ms i ~34MB pamięci, natomiast w przypadku generatora ~7.6s i ~39KB. Myślę, że warto tutaj zwrócić uwagę na fakt, że 34MB to nie jest wcale tak dużo, a ~7.6s to jednak już trochę jest, więc pojawia się pytanie, który sposób wybrać?
Oczywiście dobrą odpowiedzią jak to często bywa w przypadku programowania jest to zależy. W przypadku gdy wiemy, że tych danych do przetworzenia będzie na tyle, że zasobów nam nie braknie to wykorzystujemy pierwszy sposób. Jeśli jednak nie jesteśmy w stanie oszacować ilości danych to lepiej zastosować drugi sposób. Może użytkownik nie musi na to czekać i to przetwarzanie lepiej zrealizować w tle? Wszystko zależy od problemu jaki staramy się rozwiązać.
Aktualizacja
Okazuje się, że blackfire podaje niepoprawne czasy. W komentarzu podany jest przykład. W rzeczywistości czas wykonywania kodu z generatorem powinien być tylko minimalnie wyższy, przy dużo mniejszym użyciu pamięci.
Subscribe and master unit testing with my FREE eBook (+60 pages)! 🚀
In these times, the benefits of writing unit tests are huge. I think that most of the recently started projects contain unit tests. In enterprise applications with a lot of business logic, unit tests are the most important tests, because they are fast and can us instantly assure that our implementation is correct. However, I often see a problem with good tests in projects, though these tests’ benefits are only huge when you have good unit tests. So in this ebook, I share many tips on what to do to write good unit tests.
A nie jest czasem tak, blackfire nie radzi sobię z generatorami i pokazuje przekłamane wyniki? Przetwarzając plik, musisz go wczytać do pamięci, więc jakim cudem wczytanie gigowego pliku, zajmuje tylko 40kB pamięci?
Jak już kilka osób zauważyło blackfire faktycznie pokazuje zawyżony czas w przypadku generatora, ale co do pamięci to raczej wszystko jest ok, bo w przypadku generatora cały plik nie jest wczytywany do pamięci.
Nie wczytuje całego pliku od razu, ale wczytuje część podczas każdej iteracji – na koniec wynik w obu przypadkach musi być zbliżony
Nie musi być zbliżony, bo pamięć jest zwalniana. Nie jest budowany cały plik w pamięci, tylko linia po linii i odpowiednio po każdej iteracji pamięć jest zwalniana.
PHP 7.2.4
1 milion
Lista 56.67 ms
Generator 58.37 ms
http://sandbox.onlinephpfunctions.com/code/80f78904cbdbf15fc22182f0412ccb2e64639f3f
Faktycznie blackfire przekłamuje coś, może przez włączonego xdebuga, ale wniosków to nie psuje, a nawet świadczy to na korzyść generatorów.