Proof of concept: demon fastcgi w PHP tudzież serwlet PHP
Z racji, iż w firmie dla której pracuję, zajmuję się sporo tematem optymalizacji kodu chodzą mi po głowie kuriozalne niekiedy pomysły. W ostatnim wpisie objaśniałem jak działa mechanizm FastCgi. Pośrednikiem pomiędzy skryptem PHP a klientem FastCGI jest SAPI php.cgi. Implementuje to główne założenie jak i niestety wadę PHP - konieczność uruchomienia skryptu dla każdego żądania HTTP.
A co gdyby napisać demona w PHP, który potrafiłby przyjmować żądania z klienta FastCgi, przekazać je do zainicjalizowanego już kontrolera (patrz MVC) odebrać wynik i odesłać? Pomysł fajny - odpada konieczność wielokrotnej uruchamiania, kompilacji kodu, inkludowania plików, bo przecież aplikacja cały czas działa. Odpada konieczność konstruowania klas, populowania w nich danych dla każdego żądania - wystarczy zrobić to raz, podczas uruchomienia.
Tak mi się ten pomysł spodobał, że postanowiłem się przekonać, czy to ma szanse zadziałać. Google? Cóż, nikt chyba wcześniej nie wpadł na taki pomysł, ponieważ nie znalazłem niczego konkretnego. Zacząłem zatem od specyfikacji protokołu FastCgi. Sam protokół do najprostszych nie należy, nie mniej jednak w implementacji serwera pomogła mi implementacja klienta stworzona na potrzeby aplikacji Nanoweb - The PHP Web Server. Dodatkowo posłużyłem się funkcjonalnością PCNTL w celu implementacji wielowątkowości.
Testowa aplikacja działała na zasadzie utworzenia nasłuchującego na porcie TCP socketa, w momencie nadejścia połączenia wersja 1) odsyłała odpowiedź, zamykała połaczenie z klientem i była gotowa do obsługi następnych wersja 2) dla każdego żądania tworzyła nowy wątek. Obie wersje, oraz skrypt referencyjny odpowiadały zawartością phpinfo().
Problemy jakie napotkałem po drodze:
- problem z parsowaniem ramek FCGI_PARAMS - one są osadzane w standardowej ramce FastCgi, ale mają chyba krótszy niż 8 bajtów nagłówek, postanowiłem całkowicie je zignorować, nie mniej jednak od ich poprawnego sparsowania zależy czy w aplikacji pojawi się tablica $_SERVER, $_GET i $_POST
- trzeba samodzielnie zaimplementować cały mechanizm dekodowania formularzy jak i plików z żądania POST
- zmienne pomiędzy procesami utworzonymi via pcntl_fork() kopiują się, nie są referencjami, dlatego w wątku potomnym możemy odczytać wartość zmiennej, ale zmiana nie jest widoczna dla innych wątków. Nie dotyczy to oczywiście zmiennych typu resource.
Kiedy udało mi się już zaimplementować podstawowe minimum: demon potrafi odesłać prawidłową odpowiedź: przystąpiłem do benchmarkowania.
Wyniki mnie rozwaliły. Pierwsze referencyjny benchmark dla standardowej konfiguracji: apacz + php.fcgi -b 127.0.0.1:80800 + skrypt
[pink@poema ~]$ ab -n 100 -c 5 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 57346 bytes Concurrency Level: 5 Time taken for tests: 0.859 seconds Complete requests: 100 Failed requests: 0 Write errors: 0 Total transferred: 5750300 bytes HTML transferred: 5734600 bytes Requests per second: 116.39 [#/sec] (mean) Time per request: 42.960 [ms] (mean) Time per request: 8.592 [ms] (mean, across all concurrent requests) Transfer rate: 6535.76 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.3 0 3 Processing: 13 42 4.2 43 48 Waiting: 7 36 4.3 38 42 Total: 13 42 4.1 43 48 Percentage of the requests served within a certain time (ms) 50% 43 66% 43 75% 43 80% 43 90% 44 95% 44 98% 44 99% 48 100% 48 (longest request)
Drugi wynik apacz + przygotowanego servera FastCGI bez forkowania, co oznacza, że każde żądanie przetwarzane jest pojedynczo:
[pink@poema ~]$ ab -n 100 -c 5 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 23082 bytes Concurrency Level: 5 Time taken for tests: 0.622 seconds Complete requests: 100 Failed requests: 0 Write errors: 0 Total transferred: 2323900 bytes HTML transferred: 2308200 bytes Requests per second: 160.84 [#/sec] (mean) Time per request: 31.088 [ms] (mean) Time per request: 6.218 [ms] (mean, across all concurrent requests) Transfer rate: 3650.07 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.6 0 5 Processing: 9 30 8.3 28 60 Waiting: 9 29 8.5 26 59 Total: 9 30 8.2 28 60 Percentage of the requests served within a certain time (ms) 50% 28 66% 30 75% 33 80% 35 90% 38 95% 56 98% 58 99% 60 100% 60 (longest request)
Czas o parę procent krótszy dla zwykłego phpinfo(). W przypadku inicjalizacji połączeń z bazami, memcache, odczytywania identycznych zestawów danych (ustawień) ta różnica może być o wiele większa. Przy czym pojawia się tu jeden problem: im więcej równoczesnych żądań (parametr -c dla ab) tym wynik jest gorszy i zbliża się do tego poniżej. Oba benchmarki, referencyjny jak i testowy obsługiwane były przez pojedynczy backend FastCgi, przy czym ten w referencyjnej konfiguracji radzi sobie lepiej ze współbieżnymi żądaniami. Należy zadbać tutaj o odpowiednie parametry dla socket_listen() ponieważ zbyt mały backlog powoduje, iż połączenia współbieżne się kolejkują. Przy odpowiednio dużej wartości $backlog problem nie występuje.
[pink@poema ~]$ ab -n 100 -c 5 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 23047 bytes Concurrency Level: 5 Time taken for tests: 3.327 seconds Complete requests: 100 Failed requests: 0 Write errors: 0 Total transferred: 2320400 bytes HTML transferred: 2304700 bytes Requests per second: 30.05 [#/sec] (mean) Time per request: 166.372 [ms] (mean) Time per request: 33.274 [ms] (mean, across all concurrent requests) Transfer rate: 681.01 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 0.9 0 7 Processing: 11 165 62.5 166 391 Waiting: 8 132 60.7 129 288 Total: 11 165 62.3 166 391 Percentage of the requests served within a certain time (ms) 50% 166 66% 189 75% 204 80% 209 90% 236 95% 270 98% 337 99% 391 100% 391 (longest request)
Jak łatwo zauważyć w tym przypadku wyniki są kilkanaście razy gorsze. Przypuszczam, że wynika to z niewydolności forkowania w PHP.
Konkludując: idea wygląda wielce obiecująco i wydaje mi się, że jeśli by rozwiązać jakoś problem spadku wydajności przy konkurencyjnych żądaniach (forkowanie skreślam całkowicie, wyniki mówią same za siebie) poprzez uruchomienie większej ilości instancji demona i rozproszoną dystrybucję żądań CGI można by osiągnąć całkiem przyzwoite wyniki. Dalszą poprawę wydajności jak i redundancję systemu można osiągnąć poprzez uruchomienie klastra serwletów i balansowanie ruchu apacz, klient fCGI ->serwlet z użyciem HAProxy (przetestowałem, z metodą balansowania round-robin, wyniki niżej)
Niestety inną sprawą jest stabilność takiego rozwiązania. PHP nie jest zaprojektowany do pracy w trybie demona i prawdopodobnie mogą pojawić się problemy z garbage collectorem lub wyciekami pamięci. Dodatkowo pisanie aplikacji serwletowych wymaga nieco innego podejścia do samej architektury - nie można sobie pozwolić na radosne używanie miliona zmiennych bez zawracania sobie głowy zwalnianiem pamięci, ponieważ trzeba mieć na uwadze, iż po zakończeniu żądania zaalokowana pamięć nie zostanie zwolniona samodzielnie. Dodatkowo zmienia się także koncepcja deployowania aplikacji, ponieważ każda zmiana kodu wymaga restartu całego klastra serwletów.
Update: wyniki z użyciem 3 wątków php.fcgi -b 127.0.0.1:8061, php.fcgi -b 127.0.0.1:8062, php.fcgi -b 127.0.0.1:8063 + haproxy z balansowaniem round-robin:
[pink@poema php cgi server]$ ab -n 100 -c 15 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 57373 bytes Concurrency Level: 15 Time taken for tests: 0.535 seconds Complete requests: 100 Failed requests: 33 (Connect: 0, Receive: 0, Length: 33, Exceptions: 0) Write errors: 0 Total transferred: 5754584 bytes HTML transferred: 5738884 bytes Requests per second: 186.75 [#/sec] (mean) Time per request: 80.323 [ms] (mean) Time per request: 5.355 [ms] (mean, across all concurrent requests) Transfer rate: 10494.54 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 5 9.6 0 31 Processing: 24 70 24.0 68 117 Waiting: 6 57 25.4 55 106 Total: 30 75 22.6 77 117 Percentage of the requests served within a certain time (ms) 50% 77 66% 85 75% 90 80% 95 90% 110 95% 112 98% 115 99% 117 100% 117 (longest request)
Wyniki z użyciem 3 instancji serwletu + haproxy z balansowaniem round-robin:
[pink@poema php cgi server]$ ab -n 100 -c 15 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 23173 bytes Concurrency Level: 15 Time taken for tests: 0.479 seconds Complete requests: 100 Failed requests: 67 (Connect: 0, Receive: 0, Length: 67, Exceptions: 0) Write errors: 0 Total transferred: 2329784 bytes HTML transferred: 2314084 bytes Requests per second: 208.65 [#/sec] (mean) Time per request: 71.891 [ms] (mean) Time per request: 4.793 [ms] (mean, across all concurrent requests) Transfer rate: 4747.12 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 2 5.1 0 21 Processing: 9 64 45.3 49 180 Waiting: 2 61 45.0 46 179 Total: 9 66 44.8 52 180 Percentage of the requests served within a certain time (ms) 50% 52 66% 60 75% 85 80% 105 90% 148 95% 166 98% 180 99% 180 100% 180 (longest request)
Jak widać wyniki serwletu nadal lepsze i zapewne jak w poprzednim przypadku róznica może powiększyć się parokrotnie przy kodzie wymagającym inicjalizacji stałych elementów (konfig, includy klas).
Update: wyniki z jednym watkiem serwletu, ale za to z nieco bardziej złożoną aplikacją: klasa, która w konstruktorze tworzy jedno połaczenie do memcache, jedno połaczenie z bazą danych i robi prepare zapytania SQL, i dalej we właściwej metodzie run() podbija wartość klucza memcache i wykonuje przygotowane wcześniej zapytanie.
Test php.cli -b 127.0.0.1:8060, klasa inicjalizowana i uruchamiana w pojedynczym pliku index.php
[pink@poema var]$ ab -n 100 -c 10 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 1990 bytes Concurrency Level: 10 Time taken for tests: 0.483 seconds Complete requests: 100 Failed requests: 0 Write errors: 0 Total transferred: 214700 bytes HTML transferred: 199000 bytes Requests per second: 207.17 [#/sec] (mean) Time per request: 48.270 [ms] (mean) Time per request: 4.827 [ms] (mean, across all concurrent requests) Transfer rate: 434.36 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 0 1.2 0 5 Processing: 12 46 6.6 48 60 Waiting: 11 45 6.6 47 60 Total: 13 46 6.4 48 65 Percentage of the requests served within a certain time (ms) 50% 48 66% 48 75% 49 80% 49 90% 50 95% 50 98% 59 99% 65 100% 65 (longest request)
Test serwletu, klasa inicjalizowana przed otwarciem portu TCP, połączenie klienckie wykonuje metodę run() na zainicjalizowanej już klasie:
[pink@poema var]$ ab -n 100 -c 10 http://stateless.projekt.kurylowicz.info/ This is ApacheBench, Version 2.3 <$Revision: 655654 $> Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/ Licensed to The Apache Software Foundation, http://www.apache.org/ Benchmarking stateless.projekt.kurylowicz.info (be patient).....done Server Software: Apache/2.2.14 Server Hostname: stateless.projekt.kurylowicz.info Server Port: 80 Document Path: / Document Length: 1994 bytes Concurrency Level: 10 Time taken for tests: 0.245 seconds Complete requests: 100 Failed requests: 0 Write errors: 0 Total transferred: 215100 bytes HTML transferred: 199400 bytes Requests per second: 407.70 [#/sec] (mean) Time per request: 24.528 [ms] (mean) Time per request: 2.453 [ms] (mean, across all concurrent requests) Transfer rate: 856.41 [Kbytes/sec] received Connection Times (ms) min mean[+/-sd] median max Connect: 0 1 2.5 0 13 Processing: 8 22 7.0 22 42 Waiting: 5 21 7.3 20 41 Total: 8 24 7.0 23 43 Percentage of the requests served within a certain time (ms) 50% 23 66% 25 75% 28 80% 31 90% 33 95% 37 98% 41 99% 43 100% 43 (longest request)
Jak widac z dwóch powyższych testów średni czas przetwarzania pojedynczego żadania przez serwlet jest 50% krótszy.





