Párhuzamosítás PHP-ben

Nem egy gyakori probléma PHP-ben, de azért előfordulhat, hogy bizonyos dolgok párhuzamosítására van szükség. Nézzük mik lehetnek ezek az esetek:
  1. CRON-ból vagy command line-ból szeretnénk több, egymástól független feladatot futtatni úgy, hogy ha az egyik feladat hibával elszáll, akkor az ne hasson ki a többire
  2. RSS feed-eket szeretnénk lerántani cURL-el tömeges feldolgozásra, esetleg azonnali megjelenítésre
  3. képeket szeretnénk átméretezni, jó sokat
  4. szeretnénk szétválasztani bizonyos szempontból független, bizonyos szempontból összetartozó kódokat, pl fájlrendszer és adatbázis módosító utasításokat, hogy könnyebben érthető kódot kapjunk
  5. szeretnénk leválasztani egy lassú folyamatot, hogy amíg dolgozik, addigis tudjuk folytatni a munkánkat a többi adattal, pl miközben a feltöltött profil képet méretezi át a rendszer, addig betegyük a nevet és az email címet a regisztrált felhasználóhoz
és még folytathatnám tovább, hosszú a sor. Az esetek többségében sebességi megfontolásokról van szó, a kisebb részükben pedig kód olvashatóság és karbantarthatóság a fő szempont.

(Ezt a cikket importáltam, eredetileg 2014-ben írtam egy programozással foglalkozó oldalra.)


Előismeretek

Most, hogy tudjuk mikor érdemes foglalkoznunk a párhuzamosítás kérdésével, nézzük meg, hogy egyáltalán miről is van szó. Egy szimpla olcsó shared hosting tárhelyen általában kapunk egy LAMP-ot, ami ugye Linux operációs rendszeren egy Apache HTTP szerver egy MySQL adatbázis és a PHP szerver oldali nyelv csodálatos egyevelege. Hogyan működik egy ilyen tárhelyen a kérések feldolgozása?

Először a HTTP kérést a böngészőnk elküldi az interneten keresztül valahogyan az Apache szervernek. Az Apache rájön, hogy a kérés az example.com oldalhoz tartozik, megkeresi ennek az oldalnak a könyvtárát /example.com, onnan kikeresi a .htaccess és az alapbeállítások alapján, hogy mi a hozzá tartozó feldolgozó fájl, legyen ez most az index.php. Most már tehát a HTTP szerver tudja, hogy a hozzá érkezett kérést a /example.com/index.php -nek kell eljuttatnia. Ezek után lefuttatja a php-cgi.exe-vel az /example.com/index.php-ben található kódot, úgy, hogy átadja neki a HTTP kérés részleteit és a környezeti változókat, majd megvárja a választ, amit visszaküld a böngészőnek. A kulcs mozzanat a php-cgi.exe futtatása, ez biztosítja, hogy a PHP-nél minden kérés feldolgozása külön process-ben fut. Mit jelent az, hogy process?

A process egy éppen futó alkalmazás az adott operációs rendszeren, jelen esetben Linux-on. Az operációs rendszer ezek között a process-ek között osztja ki a processzor időt, tehát hogy melyik alkalmazás hány százalékát használhatja a számítógép teljesítményének. Például Windows-ban, ha magasabbra állítjuk a feladatkezelőben a skype prioritását, akkor az az alkalmazás több processzor időt kap, és ezért nem akadozik annyira a kép és a hang video beszélgetésnél. Tehát ha egy magos processzor van a gépben, akkor az ahhoz tartozó processzor idő felosztásával lehet párhuzamosan futtatni az alkalmazásainkat a számítógépen, mondjuk egyszerre torrentezni, zenét hallgatni és játszani. Az utóbbi évtizedben már ritkaság számba mennek az egy magos processzorok, és egyre elterjedtebbek a 2, 4, 8, vagy még annál is több magosak. Ezeknél szét lehet osztani a processzeket a magok között, így például az egyik magon tudunk játszani, egy másikon meg filmet vágni anélkül, hogy akadna a játék. Tehát összefoglalva, ha minden kérést az index.php-re irányítunk az Apache szerverünkön, ahogy általában szoktuk a front controller programtervezési minta szerint, akkor minden egyes kérés feldolgozására egy külön process jön létre, ami a php-cgi.exe segítségével futtatja az index.php-t az aktuális HTTP kérés feldolgozására. Az operációs rendszer ezeket a process-eket osztja szét a magok között, illetve ad nekik valamennyi processzor időt az adott magon. Amint végetér az index.php futása, mondjuk elküldjük a választ, és meghívjuk az exit()-et, a hozzá tartozó process megszűnik létezni. Ez alól csak azok a process-ek kivételek, amiknél beállítottuk, vagy eleve úgy hoztuk létre őket, hogy a PHP hosszú ideig fusson rajtuk a háttérben, ezeket daemon-oknak hívjuk.

Ellentétben a PHP-vel, nagyon sok nyelven, például javascript esetében nodejs-nél, inkább csak egy daemon-t indítanak, és annak adja át a HTTP szerver az összes beérkező kérést feldolgozásra. Ennek ellenére hogyan tudja mégis egyetlen daemon az összes egyszerre beérkező kérést úgy feldolgozni, hogy közben a kéréseknek nem kell egymásra várniuk? Nyilván úgy, hogy párhuzamosítja ezeknek a kéréseknek a feldolgozását. Ezt megtehetné ugyanúgy, mint az Apache + PHP esetén láttuk, szóval hogy külön process-t ad minden kérés feldolgozásának, és például fork-olás esetében a kód további részére bízza azt egy külön child process-ben. Ez a létező legprimitívebb megoldás a problémára, és nem fogsz vele találkozni olyan nyelveknél, amik eleve arra van kiélezve, hogy daemon-okat írjál bennük. A nodejs konkrétan event loop-ot használ, a régebbi nyelveknél, mint mondjuk a java-nál, pedig a legelterjedtebb a thread-ek használata. A thread-ek gyakorlatilag virtuális process-ek, amik ugyanazt a memóriát: ugyanazokat a változókat használják, mint a process, ami létrehozta őket. Ez egyszerre jó és rossz dolog is egyben. A thread-ek nagyon egyszerűen tudnak átadni adatot a process-nek és a többi thread-nek, egyszerűen beleírják egy közös változóba, viszont emiatt könnyen előfordulhatnak konfliktusok (concurrency issue) közöttük, olyan esetekben, ha több thread akarja egyszerre használni ugyanazt a változót. Olyan ez, mint amikor a gyerekek veszekednek egy játékon, valahogy rendet kell parancsolni közöttük. Ha ez sikerül, és alkalmazás mentes az ilyen konfliktusoktól, akkor mondják rá, hogy thread safe. A PHP esetében ilyen jellegű problémák nem fordulnak elő, mert a process-ek nem osztanak meg egymással változókat, szóval ott egyedül a közös erőforrások, mint pl fájlok, adatbázis miatt lehet aggódni. Az adatbázisokba általában bele van építve valamilyen concurrency control megoldás. (Erről a témáról PHP UK Conference 2014 – Morgan Tocker – Locking And Concurrency Control címmel tudok ajánlani egy részletekbe menő video prezentációt.) A fájlrendszer esetében sokkal ritkábban elérhető ilyen megoldás, egy LAMP esetében például gyakorlatilag kizárt, hogy legyen fájlrendszer tranzakció.

Összességében tehát eddig megismerkedtünk a process-ek és a thread-ek fogalmával, illetve most már tisztában vagyunk azzal a ténnyel, hogy alap esetben a PHP-ben írt kódunk egy kérésre egy process-t kap.

Párhuzamosítási megoldások

Most, hogy megismerkedtünk az alapfogalmakkal, foglalkozhatunk a bevezetőben felsorolt problémák megoldásával. Nézzük meg őket szépen sorban.

1. példa

CRON-ból vagy command line-ból szeretnénk több, egymástól független feladatot futtatni úgy, hogy ha az egyik feladat hibával elszáll, akkor az ne hasson ki a többire
Itt egy lehetséges megoldás, ha több CRON job-ot regisztrálunk be, vagy minden egyes feladatot külön hívunk meg CLI-ből. Ez is működhetnek, egy PHP-ben programozott megoldás sokkal rugalmasabb náluk. Hogyan érdemes ezt megvalósítani? Ha megnézzük, a feladatok függetlenek egymástól, nem osztanak meg adatokat egymás között, tehát kiszervezhetjük őket külön process-ekbe. Ehhez egy jó csomó függvényt biztosít a PHP.

Ha csak arra van szükségünk, hogy végrehajtásra kerüljenek a feladatok, de kevésbé érdekel minket a végeredmény, akkor az exec() használatával ez megoldható.

$lastLine = exec('/usr/bin/php task.php', $lines);
var_dump($lines);

Nézzük meg egy kicsit jobban, hogy mi történik itt. Az exec() futtatja a php.exe-vel a megadott PHP fájlt egy külön process-ben, a végeredményt pedig soronként visszaadja. Ez nem túl hatékony, ha párhuzamosítani szeretnénk, mert bár a PHP fájlunk külön process-ben fut, de az exec()-et hívó process-ben meg kell, hogy várjuk a végeredményt, addig nem léphetünk tovább. Az exec() tehát ebben a formában blokkol. Az évek során rengeteg workaround született erre a blokkolásra:
  • Az stdout és stderr átirányítása és forkolás (linux)
    exec('/usr/bin/php task.php > /dev/null 2>&1 &');
  • A Windows Script Host használata a COM kiterjesztéssel (windows)
    $WshShell = new COM("WScript.Shell");
    $oExec = $WshShell->Run("path\to\php\php-win.exe -f path/to/task.php", 0, false);
  • A popen() és pclose() függvények használata
    pclose(popen("php task.php &","r")); //linux
    pclose(popen("start php.exe task.php","r")); //windows
Azt hiszem, hogy a lista még elég hosszú lenne, de nincs értelme folytatni, mind nagyon hasonló megoldás, nagyon hasonló képességekkel.

Ha szeretnénk kommunikációt a process-ek között hasonló jellegű megoldással, akkor a proc_open()-t érdemes használni. Ezzel megvalósíthatunk egy primitív kommunikációt pipe-olással.
Erre van egy jó pár kidolgozott megoldás a neten, a lista teljesség igénye nélkül:
Ezeket a megoldásokat sokan használják, de nagyon sokan semmit sem tudnak arról, hogy mi a legnagyobb veszélye ezeknek a függvényeknek:

echo exec ('echo $PATH');

Ne tévesszen meg senkit, a fentebbi exec()-el futtatott kód: echo $PATH nem PHP, hanem shell script. Linux esetében lehetőség van ezen a nyelven komolyabb programok írására is… Itt hívom fel a figyelmet arra, hogyha bármelyik ilyen shell-t használó függvénynél valamilyen felhasználótól kapott változót adsz át, akkor azzal lehetősége van a felhasználónak shell injection-re, aminek nagyon komoly következményei lesznek a szerverre nézve. Gyakorlatilag át lehet venni vele a szerver felett az uralmat. Ez kiindulhat olyan ártatlannak tűnő helyzetből, hogy a PHP fájlt a CRON a webszerveren keresztül hívja, és a queryString-ben adod át, hogy milyen fájlokat szúrjon be, vagy esetleg véletlenül elfelejtitek beállítani, hogy a fájl kívülre ne legyen látható, így az a webszerveren keresztül is elérhető lesz a paramétereit pedig bárki beállíthatja majd. Pont ezek miatt általában tiltva vannak ezek a shell script futtatásra alkalmas függvények. Ha mégis lehetőség van rá, és ezeket akarjátok használni, akkor kerüljétek a külső paraméterek használatát, vagy escapeljétek őket az escapeshellcmd() és az escapeshellarg() függvényekkel.

Az fsockopen() használata, amit még éremes megemlítenem, és ami egy biztonságosabb alternatíva. Ehhez a stream_set_blocking()-al nem blokkolóra kell állítani a stream-et.  Ez sok webszerveren jól működő megoldás lehet több ütemezett feladat egyszerre történő futtatására. Annyi hátránya van, hogy a socket kapcsolat felépítésének van egy költsége, szóval valamivel lassabb lesz, mint a command line-ból történő futtatás, illetve mivel itt a fájlokat a webszerveren keresztül hívjuk GET-el, ezért szintén ügyelni kell a bejövő paraméterekre, illetve arra, hogy ne legyen bárkinek futtatási joga. Például kérhetünk paraméternek egy titkos kulcsot, ami ha nem stimmel, akkor a script exit()-el kilép.

2. példa

képeket szeretnénk átméretezni, jó sokat
Az előző példánál azt néztük meg, hogy egymástól teljesen független feladatokat hogyan érdemes párhuzamosan megoldani. Most nézzünk meg valami olyat, ami jobb megoldás lehet abban az esetben, ha sok a közös kód, vagy változókat, erőforrásokat akarunk megosztani.

Ilyen célból tökéletes megoldás lehet a PCNTL kiterjesztés használata. Ezzel a kiterjesztéssel lehetőségünk van fork-olni a pcntl_fork() függvénnyel. Ennek minden egyes meghívása után egy child process-t kapunk, ami ugyanazokat a változókat használhatja, mint amit előtte deklaráltunk, de a leválasztásától kezdődően már külön életet él. Általában bonyolultnak tartják a logikáját, pedig szerintem egyáltalán nem az. Mutatok is egy példát a használatára.

$images = array(
    'a.jpg',
    'b.jpg',
    'c.jpg'
);

foreach ($images as $image) {
    $pid = pcntl_fork();

    if (!$pid) {
        sleep(1);
        print "In the child process #$i.\n";
        createThumbnail($image);
        exit();
    }
}

A $pid, amit a függvény visszaad, három dolgot tartalmazhat.
  • hiba esetén az értéke -1
  • ha a child process-ben vagyunk, amit létrehozott, akkor az értéke 0
  • ha a process-ben vagyunk, amiről fork-oltunk, akkor az értéke a process azonosítója, pl: 5321
Így tehát, ha megnézzük a kódot, akkor tisztán látható, hogy az minden egyes képhez létrehoz egy child process-t, aminek átadja az aktuális kép elérhetőségét. Mindössze azt kell fejben tartani, hogy a ciklusban a fork-olás és $pid ellenőrzés utáni részről már csak a child process-ek tudnak.

A PCNTL könyvtár még sok hasznos függvényt tartalmaz, lehetőség van például a szülő és gyerek process-ek közötti kommunikációra. Akinek van kedve, tudom ajánlani a kipróbálását. Természetesen ehhez is van ajánlott irodalom:
Az egyedüli buktató vele, hogy csak olyan környezetben működik, ahol az operációs rendszer támogatja a fork-olást, szóval Linux alatt tudjuk csak használni. Windows alatt próbálkozhatunk valamilyen emulált Linux-al, mint pl a git bash, vagy a Cygwin. Valószínűleg hosszas próbálkozás után sikerül valahogy működésre bírni.

Másik megoldás a thread-ek használata lenne, de PHP-ben sajnos ezek nem támogatottak. Nem kell elkeseredni azonban, mások is észrevették a problémát, és megírták a pthreads kiterjesztést a PHP-hoz. Ez platform független, és Windows-ra jóval egyszerűbb a telepítése, elég csak lerántani a kész dll fájlokat, és bemásolni. A jelenlegi változatban még van egy hiba, ami miatt a php-cgi.exe-vel nem kompatibilis, így a HTTP kérések kezelésénél a webszerveren nem használható sajnos, de a 2.0.5-ös verzióra ez már javítva lesz. A CLI-ből nyugodtan használhatjuk a php.exe-vel, ott nincs vele semmi probléma. Természetesen érdemes elolvasni a dokumentációt, amiben rengeteg érdekes dolgot írnak a működéséről, illetve a github repo-jában rengeteg példa is van a használatára. Én most csak a telepítés után ajánlott Hello World példát mutatom be itt:

class AsyncOperation extends Thread {
  public function __construct($arg){
    $this->arg = $arg;
  }

  public function run(){
    if($this->arg){
      printf("Hello %s\n", $this->arg);
    }
  }
}
$thread = new AsyncOperation("World");
if($thread->start())
  $thread->join();

Ez egy új thread-ből írja ki, hogy Hello World. A paraméterek, amiket megadunk másolódnak, illetve olyan paraméterek nem mennek át, amelyek nem szerializálhatóak, pl a Closure. Ez alól csak a pthreads könyvtár osztályainak példányai kivételek. A thread-ek közötti konfliktusoknak így vették elejét. Egyébként még így is sokkal könnyebben használható ez a könyvtár, mint bármelyik process-es megoldás.

3. példa

RSS feed-eket szeretnénk lerántani cURL-el tömeges feldolgozásra, esetleg azonnali megjelenítésre
Ez egy különleges helyzet, mert internetről szedünk le adatokat. A cURL kiterjesztésnek van egy olyan függvénye, hogy curl_multi_exec(), amivel lehetőség van arra, hogy egyszerre több HTTP kapcsolatot nyissunk, és párhuzamosan szedjük le az RSS feed-ek tartalmát. Ezzel a megoldással a letöltési idő a töredékére csökken. A curl_multi függvényeket fel lehet használni process-ek létrehozására is hasonlóan, mint az fsockopen()-t. Az adatok feldolgozásához általában nem szokott szükség lenni párhuzamosítására, de ez persze függ attól is, hogy pontosan mennyi adatról van szó. Ha mégis teljesítménnyel kapcsolatos problémába ütköznénk, akkor egyszerűbb valamilyen fejlett megoldás, mint például a PCNTL vagy a pthreads használata. Ez természetesen feladat függő.

4. példa

szeretnénk szétválasztani bizonyos szempontból független, bizonyos szempontból összetartozó kódokat, pl fájlrendszer és adatbázis módosító utasításokat, hogy könnyebben érthető kódot kapjunk
Ehhez van egy nagyon jó példa kódom, amiben egyszerre nyúlok a fájlrendszerhez és az adatbázishoz is. (Ez nem működő példakód, csak proof of concept arra, hogy tényleg szét lehet választani eltérő témájú kódokat párhuzamosítással.)

try {
    $fs->begin();
    $db->begin();
    $uploadedPicture = $uploads[0];
    $profilePicture = $db->query('SELECT user.profilePicture FROM user WHERE user.id = ? FOR UPDATE', $id);
    $fs->delete($profilePicture);
    $newProfilePicture = $fs->unique('images/*.png');
    $fs->move($uploadedPicture, $newProfilePicture);
    $db->query('UPDATE user SET user.profilePicture = ?', $newProfilePicture);
    $db->commit();
    $fs->commit();
} catch (Exception $e) {
    $db->rollback();
    $fs->rollback();
}

Mint látható előbb le kell lock-olni a user tábla megfelelő sorát SELECT … FOR UPDATE -el, és csak azután lehet elkezdeni dolgozni a fájlokon. Utána a fájlokon végzett munka eredményét vissza kell küldeni az adatbázisnak, és vica-versa. Tehát ránézésre nem lehet elválasztani a fájl műveleteket az adatbázis műveletektől, és így eléggé összefolyik, hogy mit csinálok az egyik, és mit a másik erőforrással. Hogyan lehetne mégis szétválasztani a kettőt?

A thread-ekkel wait() - notify() alapon megoldható lenne a dolog. Az adatbázis tranzakciót végző thread mindig bevárhatná a fájlrendszer tranzakciót végző thread-et, és fordítva. Ugye ha ránézünk az alap kódra, akkor látjuk, hogy az szinkron módon teljesen jól végzi a dolgát, tehát teljesen felesleges külön process-be, vagy thread-be átvinni ebben az esetben a kódot. Ezt maximum az indokolná, ha az egyik, vagy mindkét folyamat valami lassú, számításigényes dolog lenne. Most egyik helyzet sem áll fenn, szóval elég egy cooperative multitasking-et használni. Ez körülbelül annyit tesz, hogy a kód marad szinkron, de szétbontjuk lépésekre, csoportosítjuk a lépéseket: jelen esetben fájl- és adatbázis műveletekre. Ezután az olyan részekre, ahol csoport közti ugrás van, beteszünk valamit, ami azt kiváltja. Ez lehet `yield` a generátoroknál, vagy más megvalósításoknál callback hívása, esemény kiváltása, stb… Én ezek közül csak a generátorokat mutatom be. Először tehát csoportosítsuk az utasításokat:

 try {
//tranzakció kezelés
    $fs->begin();
    $db->begin();
//fs csoport
    $uploadedPicture = $uploads[0];
//db csoport
    $profilePicture = $db->query('SELECT user.profilePicture FROM user WHERE user.id = ? FOR UPDATE', $id);
//fs csoport
    $fs->delete($profilePicture);
    $newProfilePicture = $fs->unique('images/*.png');
    $fs->move($uploadedPicture, $newProfilePicture);
//db csoport
    $db->query('UPDATE user SET user.profilePicture = ?', $newProfilePicture);
//tranzakció kezelés
    $db->commit();
    $fs->commit();
} catch (Exception $e) {
    $db->rollback();
    $fs->rollback();
}

Most ha szétválasztjuk a két csoport kódját, és generátorokba tesszük őket, akkor sok refaktorálás után a végén valami ilyesmit kapunk:

try {
    $fs = new FileSystemTransaction(function ($db) use ($fs, $uploads){
        $uploadedPicture = $uploads[0];
        $profilePicture = yield; //db műveletektől a régi profil kép helyének elkérése
        $fs->delete($profilePicture);
        $newProfilePicture = $fs->unique('images/*.png');
        $fs->move($uploadedPicture, $newProfilePicture);
        yield $newProfilePicture; //db műveleteknek az új profil kép helyének átadása
    });

    $db = new DatabaseTransaction(function ($fs) use ($id, $db){
        $profilePicture = $db->query('SELECT user.profilePicture FROM user WHERE user.id = ? FOR UPDATE', $id);
        $newProfilePicture = (yield $profilePicture); //fs műveletektől az új profil kép helyének elkérése
        $db->query('UPDATE user SET user.profilePicture = ?', $newProfilePicture);
    });

    $trans = new DistributedTransaction($fs, $db);
    $trans->commit(); 
} catch (Exception $e) {
    $trans->rollback(); 
}

Természetesen ezek elég fejletlen tranzakciók, legalábbis a FileSystemTransaction és a DistributedTransaction. Nem szándékozom garantálni náluk az ACID property-ket, mert nem szeretnék egy komplett adatbázis kezelőt írni. Lényeg a lényeg, lehetőség van szétválasztani a fájlkezelést és az adatbázist manipuláló kódot, és így átláthatóbb, hogy mi történik a fájlrendszerben, és mi az adatbázisban. (Ha valakit érdekel, csináltam erről egy tesztet, amiben lépésről – lépésre bemutatom a dolgot.)

5. példa

szeretnénk leválasztani egy lassú folyamatot, hogy amíg dolgozik, addigis tudjuk folytatni a munkánkat a többi adattal, pl miközben a feltöltött profil képet méretezi át a rendszer, addig betegyük a nevet és az email címet a regisztrált felhasználóhoz
Ehhez elsősorban a thread-eket tudom javasolni. A pthreads nagyon széles körét lefedi a problémáknak, egyedül az jelenthet neki akadályt, ha a webalkalmazásunk kinövi a gépet, és szét kell osztani több szerverre.

Erre is van megoldás, ha tényleg nagy terhelésű az oldalunk, és már nem tudjuk megoldani a feladatokat egyetlen gépen, akkor a PHP esetében szóba jöhetnek még különböző message broker-ek. Rengeteg ilyen létezik, az alábbi lista csak ízelítő:
  • Gearman, amihez külön PHP kiterjesztés van
  • MQTT amihez PHP binding van
  • RabbitMQ, amihez AMQP protokollon keresztül lehet csatlakozni, és ami a legstabilabb az AMQP-s megoldások közül
  • ØMQ, amihez szintén PHP binding van, és ami nekem személy szerint a legjobban tetszik az összes message broker megoldás közül. (Ennek az az oka, hogy ez egy library, aminek a segítségével a saját üzenetküldési infrastruktúrámat le tudom kódolni, nem pedig egy message broker alkalmazás, ami többé-kevésbé testre szabható. Mindig sokkal jobban szerettem osztályokat és kódot használni bekonfigurálásra, mint config fájlokat, mert sokkal rugalmasabb…)

Összefoglaló

Kedves olvasóm, örülök, hogy eddig kitartottál! Ha figyelmesen olvastad a cikket, akkor megismerkedhettél a párhuzamosítással kapcsolatos előismeretekkel, úgy, mint process, daemon, fork, child processthread és cooperative multitasking. Ezek után igyekeztem példákon bemutatni, hogy a PHP esetében mikor melyiket érdemes használni, és hogy nagyobb terhelésnél esetleg még milyen további message broker megoldások jöhetnek szóba, úgy, mint például a ZMQ. Remélem megérte rászánni az időt! :-)

Nincsenek megjegyzések:

Megjegyzés küldése