Introduzione
Alcuni precedenti articoli ci hanno mostrato come PHP possa essere utilizzato non soltanto come linguaggio strettamente legato al web, ma anche in altri ambiti: ad esempio possiamo creare programmi da eseguire tramite Una shell in PHP oppure possiamo creare programmi dotati di un'interfaccia grafica grazie all'uso di Introduzione a PHP-GTK. Anche in questo articolo analizzeremo un argomento che si discosta un po' dalla programmazione web, andando a muovere i primi passi verso la creazione e la gestione di processi concorrenti e comunicanti utilizzando il linguaggio PHP.
Prima di cominciare, è opportuno notare che focalizzeremo la nostra attenzione su un ambiente Unix-like (i codici di esempio sono stati testati su GNU/Linux, ma funzionano anche su altri sistemi Unix), in quanto alcune delle estensioni proposte non sono supportate da Windows. Inoltre, i codici sono stati pensati per essere eseguiti da riga di comando e non all'interno di una pagina web: il manuale stesso sconsiglia di effettuare operazioni legate alla gestione dei processi all'interno di un web server.
I Processi e l'Inter-Process Communication (IPC)
Una semplice introduzione teorica è doverosa per capire in quale direzione ci stiamo muovendo. Questi aspetti possono essere approfonditi utilizzando i riferimenti proposti nel paragrafo di chiusura.
Con il termine processo indichiamo un'istanza di un programma in esecuzione: negli attuali sistemi operativi, abbiamo solitamente più programmi in esecuzione contemporaneamente, i quali necessitano di risorse (CPU, memoria, ...) ed eventualmente possono condividere informazioni. La presenza di più processi viene gestita tramite un apposito meccanismo di creazione, sincronizzazione e terminazione degli stessi. Dal momento che le risorse della macchina sono comunque limitate, i processi se ne contendono l'utilizzo, e per comunicare usano una serie di possibilità messe a disposizione dal sistema operativo. La nascita di un processo si ha con la "clonazione" di un altro processo, operazione definita fork: il processo generatore viene chiamato padre, mentre il processo generato viene chiamato figlio. Ogni processo viene identificato da un numero intero, il pid (Process ID). In un sistema Unix, il comando ps ci consente di ottenere varie informazioni sui processi in esecuzione.
La sigla IPC sta per Inter-Process Communication ed indica un insieme di meccanismi che consentono a processi in esecuzione sulla stessa macchina di comunicare e sincronizzarsi. Tipicamente, parlando di IPC si ha a che fare con pipe o fifo, memoria condivisa, code di messaggi e semafori.
A cosa ci serve l'IPC?
PHP non è sempre la scelta migliore: difficilmente potremo sostituire Apache con un web server scritto da noi in PHP, anche se la cosa è comunque fattibile. Se abbiamo bisogno di un semplice programma server, per una piccola rete interna o anche per una macchina stand-alone, sicuramente l'utilizzo di PHP può rivelarsi utile in termini di tempi di sviluppo, senza penalizzare eccessivamente le prestazioni.
È possibile mettere in comunicazione i nostri script PHP con programmi esterni, oppure possiamo utilizzare risorse di sistema, come la memoria condivisa, che in alcune situazioni velocizzano determinate operazioni.
Primi passi: esecuzione di programmi esterni
Prima di addentrarci nei meccanismi di gestione dei processi, analizziamo la possibilità di eseguire un programma esterno ed eventualmente catturarne il risultato. Questo tipo di operazione è molto semplice, in quanto PHP ci offre alcune funzioni di facile utilizzo, disponibili anche nelle versioni per Windows. Non abbiamo particolari possibilità di controllo sui processi mandati in esecuzione, ma è un primo punto di partenza.
Le funzioni in questione sono: system(),
exec(),
passthru() e
shell_exec() (o l'equivalente
operatore backtick).
- system() esegue il programma indicato come primo parametro, producendone l'output direttamente in shell o nel browser; il suo valore di ritorno è l'ultima riga dell'output, oppure false se il comando esterno fallisce
- exec() esegue il programma indicato come primo parametro senza stamparne l'output; il secondo parametro (opzionale) consentirà di catturare l'output in un array; il suo valore di ritorno è l'ultima riga dell'output, oppure false se il comando esterno fallisce
- passthru() esegue il comando indicato come primo parametro, producendone l'output direttamente in shell o nel browser; non restituisce alcun valore di ritorno; è da preferire a system ed exec quando l'output del comando eseguito sarà un flusso di dati binari, come ad esempio un'immagine
- shell_exec() esegue il comando indicato come primo parametro, restituendo l'output come stringa, senza stamparlo direttamente a video
Utilizzando una di queste funzioni, l'interprete PHP tenta di eseguire il programma specificato e lo script PHP rimane in attesa della sua terminazione. Per consentire allo script di continuare l'esecuzione senza attendere la terminazione dell'altro programma, è necessario lanciare in background il programma in questione e redirigerne l'output su un file, ad esempio:
passthru('comando > /dev/null &');
il simbolo & serve per eseguire "comando" in background, mentre il simbolo > serve per ridirezionare l'output su un file, /dev/null in questo caso. Lanciando "comando" da shell, non sarebbe stato necessario redirigerne l'output.
Queste funzioni, a differenza dei prossimi esempi, possono essere utilizzate tranquillamente all'interno di una pagina web.
Controllo sui processi
PHP mette e disposizione due estensioni interessanti che ci tornano utili nella gestione dei processi: l'estensione POSIX, abilitata per default, che tra le altre cose ci consente di ottenere informazioni sul processo in esecuzione, e l'estensione per il controllo dei processi, che implementa il sistema di creazione, gestione e terminazione dei processi. Quest'ultima deve essere abilitata in fase di compilazione di PHP, passando l'opzione --enable-pcntl allo script configure. Entrambe le estensioni sono disponibili solo per ambienti Unix. Le funzioni della prima estensione hanno tutte il prefisso "posix_", mentre per la seconda estensione si ha il prefisso "pcntl_".
Con il primo esempio, andremo a creare un processo figlio ed a visualizzare alcune informazioni sui due processi:
echo "Inizio del processo " . posix_getpid() . "n";
if (($figlio = pcntl_fork()) == -1) {
die("Creazione figlio fallitan");
}
else if ($figlio == 0) {
echo "Figlio: il mio PID è " . posix_getpid() . "n";
echo "Figlio: il PID di mio padre è " . posix_getppid() . "n";
}
else {
echo "Padre: il mio PID è " . posix_getpid() . "n";
echo "Padre: il PID di mio padre è " . posix_getppid() . "n";
pcntl_waitpid($figlio, $stato);
}
echo "Fine di " . posix_getpid() . "n";
L'output prodotto da tale script sarà simile al seguente:
Inizio del processo 532
Padre: il mio PID è 532
Padre: il PID di mio padre è 228
Figlio: il mio PID è 533
Figlio: il PID di mio padre è 532
Fine di 533
Fine di 532
Il padre del padre sarà la shell da cui abbiamo lanciato lo script PHP. È importante notare che l'ordine dell'output dei due processi può cambiare se tentiamo di eseguire più volte lo script: i due processi sono in concorrenza e si contendono la risorsa CPU. Questa contesa viene gestita dal sistema operativo.
Analizziamo le varie funzioni utilizzate:
posix_getpid() restituisce il PID del processo in esecuzione, mentre posix_getppid() restituisce il PID del processo padre.
La funzione pcntl_fork() è la parte più importante dell'esempio, in quanto è la funzione che esegue la "clonazione", e corrisponde alla system call fork() che si utilizza nel linguaggio C. In PHP la funzione di fork restituisce tre possibili valori di ritorno: -1 nel caso di fallimento, oppure 0 al processo figlio, oppure il PID del figlio al processo padre. In sostanza, in caso di successo si hanno due valori di ritorno, a differenza di tutte le altre funzioni, e questo comportamento "anomalo" può talvolta disorientare quando si inizia a lavorare sulla gestione dei processi.
La funzione pcntl_waitpid() sospende l'esecuzione del padre finchè il figlio, il cui PID è indicato nel primo parametro, non termina la sua esecuzione. In questo modo il processo padre può eventualmente controllare lo stato di uscita dei processi figli, memorizzato nella variabile indicata come secondo parametro.
Dopo l'operazione di fork, il processo figlio sarà una copia esatta del processo padre, ad eccezione del valore di ritorno di pcntl_fork(), memorizzato nella variabile $figlio, che influenzerà quindi l'esecuzione dei due processi.
Un ulteriore esempio sarà sicuramente d'aiuto:
$var = 5;
echo "La variabile vale: {$var}n";
// creo un figlio
if (($figlio = pcntl_fork()) == -1) {
die("Fork fallitan");
}
// Codice del figlio
else if ($figlio == 0) {
$var++;
echo "Figlio - la variabile vale ora: {$var}n";
exit(0);
}
// Codice del padre
else {
$var--;
echo "Padre - la variabile vale ora: {$var}n";
pcntl_waitpid($figlio, $stato);
echo "Padre - il figlio è uscito con lo stato: {$stato}n";
}
ed ecco il relativo output:
La variabile vale: 5
Padre - la variabile vale ora: 4
Figlio - la variabile vale ora: 6
Padre - il figlio è uscito con lo stato: 0
Da questo codice possiamo evincere che il figlio eredita tutte le variabili che il padre ha utilizzato prima dell'operazione di fork, ma dalla clonazione in poi tali variabili hanno vita autonoma, in quanto fanno parte dello spazio dati di due processi distinti.
Possiamo partire da questi semplici esempi per creare programmi in cui un processo padre genera uno o più processi figli che andranno ad eseguire particolari operazioni lavorando parallelamente in modo concorrente. Un meccanismo del genere viene utilizzato anche da molti daemon, come nel caso di Apache 1.3, in cui un processo padre rimane sempre in attesa di un determinato evento (una connessione HTTP se parliamo di Apache) per poi clonarsi e lasciare che sia un processo figlio a svolgere tutte le operazioni che derivano dall'evento in questione, rimettendosi quindi nuovamente in attesa.
Introduzione ai segnali
I segnali possono essere visti come una prima forma di comunicazione tra processi, non particolarmente elaborata, che può comunque essere utilizzata in maniera proficua. Un segnale in sostanza è uno strumento che consente ad un processo di captare un determinato evento, come un errore, la richiesta di un utente di terminare il processo, la scadenza di un timer o quant'altro.
In ambienti Unix possiamo inviare esplicitamente un segnale ad un processo utilizzando il comando kill, specificando il tipo di segnale da inviare ed il pid del processo interessato.
All'interno dei nostri programmi, possiamo associare una funzione, detta signal handler, ossia gestore del segnale, ad un determinato segnale: in questo modo il programma saprà come comportarsi una volta ricevuto il segnale in questione. Se non è specificato alcun signal handler, il processo si comporterà secondo l'azione predefinita, in base al segnale ricevuto. Per associare una funzione ad un segnale, è necessario utilizzare la funzione pcntl_signal(). Nell'esempio che segue, andremo ad associare ai segnali SIGHUP e SIGINT i relativi signal handler. Entrambi i segnali normalmente porterebbero alla terminazione del processo, situazione che viene in questo modo evitata. Il segnale SIGINT viene inviato solitamente quando l'utente tenta di terminare il processo in esecuzione premendo i tasti CTRL+C.
declare (ticks = 1);
// handler per SIGINT
function no_term()
{
echo "Non puoi terminarmi con CTRL-C, devi killarmin";
}
// handler per SIGHUP
function resettami()
{
echo "Riavvio il servern";
$GLOBALS['counter'] = 0;
}
// Funzione che gestisce il loop principale
function main_loop()
{
$GLOBALS['counter']++;
sleep(1);
echo "Sono in esecuzione da {$GLOBALS['counter']} secondin";
}
$counter = 0;
echo "Counter server!nn";
pcntl_signal(SIGHUP, "resettami");
pcntl_signal(SIGINT, "no_term");
// loop infinito
while (1) {
main_loop();
}
Con questo codice abbiamo creato una sorta di server, che, pur non facendo niente di utile (si limita a fare da contatore), ci fornisce qualche spunto di riflessione. Avendo inserito un loop infinito, while(1), il programma rimane in esecuzione continuando ad incrementare la variabile $counter. Come accennato prima, con pcntl_signal() abbiamo associato ai due segnali una corrispondente funzione. Il primo parametro di pcntl_signal() è il numero del segnale (possiamo utilizzare alcune costanti predefinite), mentre il secondo parametro è una stringa che contiene il nome della funzione. Per una lista dei segnali disponibili, possiamo dare un'occhiata alla pagina di manuale dell'estensione pcntl oppure alla man page del comando kill. Avendo intercettato il segnale SIGINT, non sarà possibile terminare il processo premendo CTRL-C nella shell, mentre intercettando SIGHUP, questo segnale provocherà una sorta di "riavvio" del server. Per terminare il processo in esecuzione sarà necessario utilizzare il comando kill.
Se un processo avesse la necessità di inviare un segnale ad un altro processo, la funzione posix_kill() è adatta allo scopo. Tale funzione prevede due parametri: il pid del proceso a cui inviare il segnale ed il numero che identifica il segnale (anche qui possiamo utilizzare le costanti predefinite). L'utilizzo del costrutto declare è necessario per consentire l'esecuzione delle funzioni che gestiscono i segnali. In parole povere, quando utilizziamo la funzione pcntl_signal(), dobbiamo utilizzare il costrutto declare all'inizio dello script, come nell'esempio.
Approfondimenti e conclusioni
Approfondire argomenti così vasti non è cosa da poco. La prima parte dell'articolo si conclude con alcune letture consigliate, che consentiranno di avere una panoramica più ampia sull'argomento. Le "man page" di fork, ps, kill e signal sicuramente sono un buon punto da cui iniziare.
Gli Appunti di informatica libera propongono ottime spiegazioni legate ai processi ed alla loro gestione. Un'altra ottima guida, che espone in maniera molto esauriente questi argomenti e molti altri, è sicuramente GaPiL, la Guida alla Programmazione in Linux, che pur trattando l'aspetto della programmazione in linguaggio C, fornisce una visione molto accurata sul piano teorico.
La seconda parte dell'articolo ruoterà attorno all'argomento IPC, per cercare di capire come far comunicare tra di loro vari processi.
IPC: pipe
Con il termine pipe, che in inglese significa tubo, condotto, indichiamo un canale di trasmissione unidirezionale tra due processi. Almeno
una volta, davanti ad una shell Unix, ci sarà capitato di digitare un comando del tipo "ls -l | more": il simbolo "|" indica proprio questo canale
che viene a crearsi tra due programmi, in questo caso ls e more. L'output del primo comando diventa input per il secondo, e questa è la sostanza
di questo strumento.
All'interno di uno script PHP, possiamo utilizzare una pipe per comunicare con comandi esterni. L'utilizzo di una pipe sarà
analogo all'utilizzo di un file, per cui potremo utilizzare funzioni come fgets() o fputs(), naturalmente con alcuni accorgimenti: il canale deve
essere creato con popen(), che restituisce un file pointer come
fopen(); la chiusura viene gestita con pclose() al posto di
fclose(); il fatto che il canale sia unidirezionale significa che possiamo utilizzarlo o in lettura o in scrittura. Un semplice esempio chiarirà
le idee:
$pipe = popen('/bin/ls -l', 'r');
while ($buf = fgets($pipe, 1024)) {
echo $buf;
}
pclose($pipe);
Vediamo che popen() prende due parametri di tipo stringa: il comando da eseguire e la modalità di apertura della pipe ("r" per leggere, "w" per
scrivere). La funzione ci restituisce un file pointer, $pipe nell'esempio, che andiamo a leggere con fgets(), proprio come se si trattasse di un
normale file. Terminata la lettura, chiudiamo il file pointer con pclose().
Una prima obiezione potrebbe derivare dal fatto che con il codice proposto abbiamo reinventato la ruota: utilizzando system() o una funzione
analoga il risultato sarebbe stato il medesimo. Può comunque rivelarsi molto appropriato utilizzare le pipe come canali di scrittura: un esempio ci
viene fornito dal noto pacchetto PEAR::Mail,
che tra le altre cose può anche comunicare direttamente con sendmail (un famoso strumento Unix per l'invio della posta elettronica)
proprio usando una pipe in scrittura.
IPC: memoria condivisa e semafori
La memoria condivisa (shared memory) è una porzione di memoria primaria a cui tutti i processi possono accedere per leggere e
scrivere dati che, come dice il nome stesso, possono anche essere condivisi. Con questo strumento quindi i vari processi possono trasmettere
tra di loro dei dati in modo "simile" a quanto avviene con l'utilizzo di file di testo o di database, anche se è bene ricordare che si lavora con la memoria primaria e non con la memoria secondaria.
Come nel caso di database e file di testo, l'accesso contemporaneo da parte di più processi (la concorrenza) può causare problemi di inconsistenza quando due processi tentano di scrivere sullo stesso segmento di memoria. Questo tipo di situazione propone la necessità di sincronizzare i due processi, necessità che viene appagata con l'utilizzo dei semafori. I semafori sono un altro strumento dell'IPC che, come suggerisce il nome, servono per "regolare il traffico" tra più processi: per semplificare molto la cosa, si può pensare ad un semaforo come ad un contatore che, grazie al suo valore, consente ad un processo di eseguire una data operazione, oppure lo mette in attesa fin tanto che il semaforo stesso non viene sbloccato.
Tra le estensioni PHP, ne troviamo due che ci consentono di utilizzare questi strumenti dell'IPC:
shmop, per la memoria condivisa, e
sysvsem, per l'utilizzo dei semafori in stile SysV. Entrambe sono
disponibili solo per ambienti Unix e devono essere abilitate passando le rispettive opzioni in fase di compilazione: --enable-shmop e
--enable-sysvsem.
Analizziamo le funzioni per l'accesso alla memoria condivisa, senza per ora preoccuparci dell'accesso in concorrenza: nell'esempio
tenteremo di aprire un segmento di memoria, scrivere una stringa all'interno dello stesso, leggerne il contenuto ed infine eliminarlo,
passando quindi rapidamente in rassegna le funzioni disponibili.
// Creo una chiave IPC per la risorsa
$key = ftok(__FILE__, 'a');
// Apro il segmento di memoria, ottenendone l'identificativo
$id = shmop_open($key, 'c', 0600, 1024);
if (!$id) {
die("Apertura del segmento fallitan");
}
// Scrivo una stringa all'interno del segmento
$string = 'Una stringa di test';
$byte = shmop_write($id, $string, 0);
if ($byte != strlen($string)) {
echo "Non sono stati scritti tutti i datin";
}
// Leggo il contenuto del segmento
$read = shmop_read($id, 0, 1024);
echo "Dati presenti nel segmento di memoria: {$read}n";
// Cancello e chiudo il segmento
shmop_delete($id);
shmop_close($id);
La chiave ($key nell'esempio) è un valore che ci consente di accedere ad un determinato segmento di memoria. Per generare una chiave viene
utilizzata la funzione ftok() che prende come primo parametro un percorso di un file (per questo abbiamo usato __FILE__) e come secondo
parametro una stringa di un carattere. La chiave deve essere nota a tutti i processi che tentano di accedere ad un determinato segmento di
memoria.
Una volta stabilita la chiave, tentiamo di creare (o aprire, se esiste già) il segmento interessato con la funzione shmop_open(). I quattro
parametri sono: la chiave precedentemente generata, un flag che indica le modalità di accesso ("c" crea il segmento se questo non esiste,
altrimenti prova ad aprirlo), i permessi con cui il segmento viene creato, con il classico significato dei permessi in ambienti Unix (l'esempio
crea quindi un segmento a cui solo il proprietario può accedere in lettura e scrittura, 4+2=6) ed infine la dimensione desiderata, in byte (un KB
nell'esempio).
La funzione restituisce un identificativo del segmento, che andrà utilizzato per le operazioni di lettura/scrittura/cancellazione.
Per la lettura utilizziamo shmop_write() con tre parametri: l'identificativo del segmento, una stringa con i dati da memorizzare e il punto di
partenza, in byte, da cui cominciare a scrivere i dati (0 per cominciare dall'inizio). Se invece abbiamo bisogno di leggere, shmop_read() è quello che fa per noi. Anche qui abbiamo tre parametri da specificare: l'identificativo, il punto di partenza da cui leggere, ed il numero di byte da leggere.
Mentre con la funzione di scrittura il valore ritornato indicava il numero di byte scritti, con la funzione di lettura abbiamo chiaramente il contenuto della porzione di memoria letta. Nel caso in cui dovessimo memorizzare array o oggetti, prima della scrittura sarà necessario
serializzare i dati con serialize(), per poi utilizzare, dopo la lettura, unserialize().
Il problema della concorrenza
Mentre la lettura simultanea di una risorsa da parte di più processi non dovrebbe causare alcun problema, la scrittura può invece provocare
situazioni di incoerenza. Nel caso della memoria condivisa, questa problematica può essere affrontata utilizzando i semafori.
Come per la memoria condivisa, anche i semafori hanno bisogno di una chiave, che andrà generata con ftok(). Essendo in sostanza un contatore, possiamo specificare quante volte questo può essere acquisito (normalmente 1, che è anche il valore di default). Nell'esempio, riproporremo in parte l'accesso alla memoria condivisa, implementando una semplice forma di mutua esclusione, ossia un sistema che garantisca l'esecuzione delle porzioni di codice potenzialmente pericolose da parte di un solo processo per volta.
$shm_key = ftok(__FILE__, 'a');
$sem_key = ftok(__FILE__, 'b');
$shm_id = shmop_open($shm_key, 'c', 0600, 1024);
$sem_id = sem_get($sem_key);
sem_acquire($sem_id);
/* Inizio sezione critica
* In questo punto andranno inserite le operazioni
* di scrittura che possono causare incoerenza
* Fine sezione critica
*/
sem_release($sem_id);
sem_get() ci consente, data una chiave, di ottenere l'identificativo del semaforo, ed eventualmente di crearlo se fosse necessario. Le funzioni sem_acquire() e sem_release() invece, ci consentono rispettivamente di acquisire e rilasciare un semaforo. Un semaforo (a meno che non sia specificato diversamente con sem_get) può essere acquisito soltanto da un processo per volta: se questa risorsa non dovesse essere disponibile, il processo rimane in attesa fino al suo rilascio. Per questo motivo, una volta usciti dalla sezione critica, ossia quella porzione di codice che risulta potenzialmente pericolosa per le situazioni di incoerenza, è importante rilasciare il semaforo. Per eliminare un semaforo quando questo non serve più, si utilizza la funzione sem_remove(), passandole come parametro l'identificativo corrispondente.
Considerazioni finali e approfondimenti
L'accesso alla memoria primaria dovrebbe risultare molto più veloce rispetto alla memoria secondaria. Tuttavia l'uso dell'estensione shmop di PHP, dopo alcuni semplici benchmark, non risulta così esaltante sotto il profilo delle prestazioni: ciò può significare che il caching del filesystem effettuato dal sistema operativo è particolarmente efficiente, oppure che l'estensione sotto esame può ancora essere migliorata.
Se si desira fare alcune prove (ed è caldamente consigliato farlo!) con semafori e memoria condivisa, è opportuno far notare che possiamo utilizzare due comandi da shell che ci consentono di monitorare i nostri esperimenti: ipcs, che fornisce lo status delle risorse IPC attuali, ed ipcrm che ci consente di rimuovere una risorsa IPC precedentemente allocata. È importante tenere presente che una risorsa IPC, come un segmento di memoria, può essere liberata soltanto da chi l'ha creata, oppure dall'utente root: diventa fondamentale quindi prevedere all'interno degli script il rilascio delle risorse quando queste non servono più, oppure, se si vuole agire tramite shell come utente amministratore, è opportuno prestare molta attenzione a come ci si muove.
Al di là degli aspetti didattici dell'argomento, un possibile uso della memoria condivisa può riguardare un sistema di caching: un esempio dal mondo reale ci viene fornito da PEAR::Cache, un pacchetto che ci consente di effettuare il caching dei dati utilizzando vari metodi, tra cui la memoria condivisa. I commenti dello stesso autore ed alcune rapide prove ci ripropongono però il problema dell'efficienza.
Concludiamo con alcune letture consigliate, riproponendo nuovamente
- GaPiL che è probabilmente la risorsa in italiano più esauriente. Le "man pages" di ipc, ipcs, ipcrm e ftok forniscono buoni spunti.
- Chiaramente le pagine del manuale di PHP per shmop e semafori dovranno essere un punto di riferimento.
In rete troviamo poche risorse che interessano direttamente PHP, tutte in inglese.
- Inter-Process Communication in PHP.
- Sticking the Fork In, panoramica sulle estensioni pcntl, posix, sockets, shmop.
- The eight arms of Shiva, un approccio a semafori e memoria condivisa.