Introduzione
Quando più richieste PHP leggono o modificano lo stesso file, possono verificarsi problemi difficili da individuare: dati sovrascritti, contenuti incompleti oppure file lasciati in uno stato non valido. Questo accade soprattutto in applicazioni che usano file come contatori, configurazioni, cache, code di lavoro o piccoli archivi locali.
Il problema principale è la concorrenza. Immaginiamo due richieste che leggono contemporaneamente un contatore con valore 10. Entrambe calcolano il nuovo valore 11 e lo scrivono. Il risultato finale sarà 11, anche se le operazioni eseguite erano due e il valore atteso era 12.
Per gestire correttamente questi casi utilizzeremo due tecniche:
- file locking, tramite la funzione
flock(), per impedire accessi incompatibili durante una modifica; - scrittura atomica, ottenuta scrivendo prima un file temporaneo e sostituendo successivamente il file originale con
rename().
Queste tecniche sono adatte a piccoli dati locali. Per applicazioni molto trafficate o per dati relazionali complessi è preferibile utilizzare un database, che offre transazioni e controllo della concorrenza più avanzati.
Codice completo
Il seguente esempio implementa un contatore salvato nel file counter.txt. Il metodo increment() aggiorna il valore in modo protetto, mentre read() lo legge condividendo il lock di lettura.
<?php
declare(strict_types=1);
final class FileCounter
{
public function __construct(
private readonly string $filePath
) {
$directory = dirname($this->filePath);
// Crea la cartella se non esiste.
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
throw new RuntimeException(´Impossibile creare la directory del contatore.´);
}
}
public function read(): int
{
$handle = fopen($this->filePath, ´c+´);
if ($handle === false) {
throw new RuntimeException(´Impossibile aprire il file del contatore.´);
}
try {
// LOCK_SH consente più letture contemporanee.
if (!flock($handle, LOCK_SH)) {
throw new RuntimeException(´Impossibile acquisire il lock di lettura.´);
}
rewind($handle);
$content = stream_get_contents($handle);
if ($content === false) {
throw new RuntimeException(´Impossibile leggere il contatore.´);
}
$value = trim($content) === ´´ ? 0 : filter_var(
trim($content),
FILTER_VALIDATE_INT
);
if ($value === false || $value < 0) {
throw new RuntimeException(´Il contenuto del contatore non è valido.´);
}
flock($handle, LOCK_UN);
return $value;
} finally {
fclose($handle);
}
}
public function increment(): int
{
$handle = fopen($this->filePath, ´c+´);
if ($handle === false) {
throw new RuntimeException(´Impossibile aprire il file del contatore.´);
}
try {
// LOCK_EX impedisce ad altri processi di leggere o modificare
// il file durante l´operazione critica.
if (!flock($handle, LOCK_EX)) {
throw new RuntimeException(´Impossibile acquisire il lock esclusivo.´);
}
rewind($handle);
$content = stream_get_contents($handle);
if ($content === false) {
throw new RuntimeException(´Impossibile leggere il valore corrente.´);
}
$current = trim($content) === ´´ ? 0 : filter_var(
trim($content),
FILTER_VALIDATE_INT
);
if ($current === false || $current < 0) {
throw new RuntimeException(´Il valore corrente non è valido.´);
}
$newValue = $current + 1;
$serializedValue = (string) $newValue;
// Torna all´inizio e riscrive completamente il file.
rewind($handle);
if (!ftruncate($handle, 0)) {
throw new RuntimeException(´Impossibile svuotare il file.´);
}
if (fwrite($handle, $serializedValue) !== strlen($serializedValue)) {
throw new RuntimeException(´Impossibile scrivere il nuovo valore.´);
}
fflush($handle);
flock($handle, LOCK_UN);
return $newValue;
} finally {
fclose($handle);
}
}
public function writeAtomically(string $content): void
{
$directory = dirname($this->filePath);
$temporaryPath = tempnam($directory, ´counter_´);
if ($temporaryPath === false) {
throw new RuntimeException(´Impossibile creare il file temporaneo.´);
}
try {
if (file_put_contents($temporaryPath, $content, LOCK_EX) === false) {
throw new RuntimeException(´Impossibile scrivere il file temporaneo.´);
}
// rename() sostituisce il file nella stessa directory.
if (!rename($temporaryPath, $this->filePath)) {
throw new RuntimeException(´Impossibile sostituire il file originale.´);
}
$temporaryPath = ´´;
} finally {
// Rimuove il temporaneo anche in caso di errore.
if ($temporaryPath !== ´´ && is_file($temporaryPath)) {
unlink($temporaryPath);
}
}
}
}
try {
$counter = new FileCounter(__DIR__ . ´/data/counter.txt´);
$newValue = $counter->increment();
echo "Nuovo valore: {$newValue}" . PHP_EOL;
} catch (Throwable $exception) {
http_response_code(500);
echo ´Errore: ´ . $exception->getMessage();
} Spiegazione
Il lock esclusivo
flock($handle, LOCK_EX) acquisisce un blocco esclusivo. Finché il blocco è attivo, un altro processo che richiede un lock compatibile dovrà attendere. È importante proteggere l’intera sequenza di lettura, calcolo e scrittura, non soltanto la chiamata a fwrite().
Nel metodo increment(), il lock viene acquisito prima di leggere il valore corrente. In questo modo nessun’altra richiesta può modificarlo tra la lettura e la scrittura del nuovo valore.
Il lock condiviso
LOCK_SH è un lock condiviso. Più processi possono leggere contemporaneamente lo stesso file, ma una scrittura con LOCK_EX attenderà finché le letture non avranno rilasciato il blocco. Il lock deve essere sempre rilasciato con flock($handle, LOCK_UN) e la risorsa deve essere chiusa con fclose().
Perché usare ftruncate()
Se il nuovo contenuto è più corto di quello precedente, una semplice scrittura potrebbe lasciare caratteri vecchi alla fine del file. Per esempio, sostituendo 12345 con 8 senza troncare il file si potrebbe ottenere 82345. La funzione ftruncate($handle, 0) azzera la dimensione del file prima della nuova scrittura.
Scrittura atomica
La scrittura atomica è utile quando il file contiene un documento intero, come una configurazione o un testo strutturato. Il contenuto viene scritto in un file temporaneo nella stessa directory dell’originale. Solo dopo una scrittura riuscita il file temporaneo viene rinominato.
La sostituzione con rename() riduce il rischio che un lettore trovi il file originale a metà scrittura. È fondamentale creare il temporaneo nella stessa directory, perché così la sostituzione avviene sullo stesso filesystem.
Best practice
- Usa
LOCK_EXper le operazioni di modifica e proteggi tutta la sequenza lettura-calcolo-scrittura. - Controlla sempre il valore restituito da
fopen(),flock(),fwrite(),rename()eunlink(). - Usa
ftruncate()quando sovrascrivi un file aperto e il nuovo contenuto potrebbe essere più breve. - Preferisci file temporanei e
rename()per sostituire documenti completi. - Non memorizzare file modificabili dall’applicazione in una directory pubblica del web server.
- Imposta permessi restrittivi sulle directory, ad esempio
0750, e sui file quando il sistema operativo lo consente. - Non considerare il locking una soluzione per grandi volumi di dati: in questi casi usa un database.
- Gestisci le eccezioni senza mostrare agli utenti percorsi assoluti o dettagli interni del server.
- Testa il comportamento con più richieste simultanee, ad esempio usando più processi CLI.
Riepilogo
La gestione corretta degli accessi concorrenti ai file è essenziale quando più richieste PHP condividono lo stesso dato. flock() protegge le operazioni critiche, mentre la scrittura temporanea seguita da rename() permette di sostituire un documento senza esporre facilmente contenuti parziali.
Per piccoli contatori, cache e file di stato queste tecniche sono semplici ed efficaci. Quando aumentano il numero di utenti, la frequenza degli aggiornamenti o la complessità dei dati, conviene trasferire la logica in un database con transazioni e vincoli appropriati.
Approfondisci con risorse ufficiali
- PHP Manual – flock: https://www.php.net/manual/en/function.flock.php
- PHP Manual – fopen: https://www.php.net/manual/en/function.fopen.php
- PHP Manual – ftruncate: https://www.php.net/manual/en/function.ftruncate.php
- PHP Manual – rename: https://www.php.net/manual/en/function.rename.php
- PHP Manual – tempnam: https://www.php.net/manual/en/function.tempnam.php
