
Un generatore legge un file di qualsiasi dimensione con circa 2 MB di memoria, mentre file() ne consuma 229 MB su un file da 73 MB. Su PHP 8.5 il rapporto misurato è di 114 a uno, senza sovraccosto in tempo. Per trasformare un file senza esaminarlo, i filtri di flusso evitano di caricarlo tutto.
Uno script PHP che elabora un file di pochi megabyte funziona. Lo stesso script su un file da 70 MB si ferma su Allowed memory size exhausted. La causa è quasi sempre la stessa: il file viene caricato interamente in memoria quando bastava scorrerlo. Questo articolo misura le diverse strategie di lettura su un file reale e mostra quale scegliere a seconda dell’elaborazione.
Il protocollo di misura
File di test: un CSV da 73,1 MB, 2.000.000 di righe, quattro colonne.
<?php
$h = fopen('gros.csv', 'wb');
$tampon = '';
for ($i = 1; $i <= 2_000_000; $i++) {
$tampon .= $i . ',client-' . str_pad((string) ($i % 999999), 6, '0', STR_PAD_LEFT)
. ',' . (($i % 977) + 0.5)
. ',2026-' . str_pad((string) (($i % 12) + 1), 2, '0', STR_PAD_LEFT) . "-15\n";
if (($i % 50_000) === 0) {
fwrite($h, $tampon);
$tampon = '';
}
}
fwrite($h, $tampon);
fclose($h);Ogni strategia gira in un processo separato, con memory_limit a 512 MB, in un container limitato a 1 core e 1 GB. È indispensabile: memory_get_peak_usage() conserva il massimo raggiunto dall’inizio dello script, quindi misurare più strategie nello stesso processo dà cifre false, la prima misura contamina tutte le successive.
<?php
declare(strict_types=1);
$t0 = hrtime(true);
$resultat = /* … la strategia da misurare … */;
printf(
"%7.0f ms pic %7.1f Mo %s\n",
(hrtime(true) - $t0) / 1e6,
memory_get_peak_usage(true) / 1_048_576,
$resultat,
);Una nota di metodo che ci è costata un’ora: le prime misure erano prese su una cartella montata da macOS in Docker. Il numero di righe lette calava a ogni esecuzione, 1.990.083, poi 1.989.958, poi 1.989.132, mentre la dimensione del file non cambiava. Il mount troncava le letture sequenziali lunghe. Spostare il file in un volume Docker ha reso le misure riproducibili, con esattamente 2.000.000 di righe a ogni passata. Una misura che varia senza motivo è una misura falsa, non una misura rumorosa.
Scorrere un file riga per riga
Quattro modi di contare le righe, stesso file, stessa macchina.
// 1. file(): tutto il file in un array
$lignes = file('gros.csv', FILE_IGNORE_NEW_LINES);
$n = count($lignes);
// 2. fgets accumulato in un array
$lignes = [];
$h = fopen('gros.csv', 'rb');
while (($ligne = fgets($h)) !== false) {
$lignes[] = rtrim($ligne, "\r\n");
}
fclose($h);
$n = count($lignes);
// 3. Generatore
function lireLignes(string $chemin): Generator
{
$h = fopen($chemin, 'rb');
try {
while (($ligne = fgets($h)) !== false) {
yield rtrim($ligne, "\r\n");
}
} finally {
fclose($h);
}
}
$n = 0;
foreach (lireLignes('gros.csv') as $ligne) {
$n++;
}
// 4. SplFileObject
$f = new SplFileObject('gros.csv', 'rb');
$f->setFlags(SplFileObject::DROP_NEW_LINE | SplFileObject::READ_AHEAD);
$n = 0;
foreach ($f as $ligne) {
if ($ligne !== false && $ligne !== '') {
$n++;
}
}| Strategia | Tempo mediano | Picco di memoria |
|---|---|---|
file() | 612 ms | 229,1 MB |
fgets in un array | 492 ms | 156,0 MB |
| Generatore | 652 ms | 2,0 MB |
SplFileObject | 594 ms | 2,0 MB |
Il rapporto è di 114 a uno tra file() e il generatore. I tempi, invece, stanno tutti in un fazzoletto: tra 490 e 660 ms, con una dispersione da una passata all’altra che supera lo scarto tra le strategie. In altre parole, non caricare il file in memoria non costa nulla in velocità e divide il consumo per cento.
La cifra di 229 MB per un file da 73 MB sorprende spesso. Un array PHP di due milioni di stringhe corte non memorizza solo i caratteri: ogni voce porta con sé una struttura di array e ogni stringa un header. Il sovraccosto supera abbondantemente il dato in sé.
Che cosa cambia in concreto
Con memory_limit = 128M, valore comune sugli hosting condivisi:
file() -> Fatal error: Allowed memory size of 134217728 bytes exhausted
(tried to allocate 16777224 bytes)
générateur -> 659 ms, pic 2,0 Mo, 2 000 000 lignesA 192 MB file() fallisce ancora: gliene servono 256 per passare. Il generatore funziona con 2 MB, e funzionerebbe allo stesso modo su un file da 700 MB: il suo consumo non dipende dalla dimensione del file, solo dalla riga più lunga.
Elaborare le colonne di un CSV
Stesso confronto, questa volta sommando la terza colonna.
// A. Caricare tutto e poi spezzare
$texte = file_get_contents('gros.csv');
$somme = 0.0;
foreach (explode("\n", $texte) as $ligne) {
if ($ligne === '') { continue; }
$colonnes = explode(',', $ligne);
$somme += (float) ($colonnes[2] ?? 0);
}
// B. Leggere riga per riga e spezzare
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($ligne = fgets($h)) !== false) {
$colonnes = explode(',', $ligne);
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);
// C. fgetcsv, che gestisce le virgolette e i separatori con escape
$h = fopen('gros.csv', 'rb');
$somme = 0.0;
while (($colonnes = fgetcsv($h, 0, ',', '"', '\\')) !== false) {
$somme += (float) ($colonnes[2] ?? 0);
}
fclose($h);| Strategia | Tempo mediano | Picco di memoria |
|---|---|---|
file_get_contents + explode | 583 ms | 229,1 MB |
fgets + explode | 704 ms | 2,0 MB |
fgetcsv | 4.729 ms | 2,0 MB |
Tutte e tre danno la stessa somma, al centesimo. Ma fgetcsv è sette volte più lento di explode. Questo scarto non è un difetto: fgetcsv applica le regole del formato CSV, gestisce le virgolette, i separatori all’interno dei campi e gli a capo racchiusi tra virgolette. explode(',') non fa niente di tutto ciò e si rompe appena un campo contiene una virgola.
La scelta si fa quindi sulla natura del file, non sulla performance: fgetcsv per un CSV che arriva dall’esterno, explode solo per un formato di cui garantisci tu che non ha né virgolette né separatori con escape.
Da PHP 8.4 gli ultimi tre parametri di fgetcsv() vanno passati esplicitamente per evitare un avviso di deprecazione sul parametro di escape.
Trasformare un file senza leggerlo
Altra situazione: comprimere un file senza avere mai bisogno di esaminarne il contenuto. Caricare tutto per passarlo a gzencode() funziona, ma i filtri di flusso fanno il lavoro a blocchi.
// In memoria
$compresse = gzencode(file_get_contents('gros.csv'), 6);
file_put_contents('gros.csv.gz', $compresse);
// In streaming
$entree = fopen('gros.csv', 'rb');
$sortie = fopen('gros.csv.gz', 'wb');
stream_filter_append($sortie, 'zlib.deflate', STREAM_FILTER_WRITE, [
'level' => 6,
'window' => 31, // 31 = header gzip; 15 produrrebbe zlib grezzo
]);
stream_copy_to_stream($entree, $sortie);
fclose($entree);
fclose($sortie);| Strategia | Tempo mediano | Picco di memoria | Output |
|---|---|---|---|
gzencode in memoria | 1.692 ms | 149,3 MB | 13,8 MB |
| Filtro di flusso | 1.928 ms | 91,1 MB | 13,8 MB |
Il guadagno in memoria è reale ma meno spettacolare che sulla lettura: stream_copy_to_stream usa un buffer interno di dimensioni consistenti. I due approcci producono un file di dimensione identica. Il parametro window a 31 è ciò che distingue un vero file gzip da un flusso zlib grezzo che gunzip si rifiuterà di aprire.
Elaborare a lotti
Il caso più frequente nella pratica: scorrere un file di grandi dimensioni e inviare le righe a pacchetti verso un database o una coda. Il generatore si combina bene con un accumulatore.
<?php
declare(strict_types=1);
/**
* @param iterable<mixed> $source
* @return Generator<int, array>
*/
function parLots(iterable $source, int $taille = 1000): Generator
{
$lot = [];
foreach ($source as $element) {
$lot[] = $element;
if (count($lot) === $taille) {
yield $lot;
$lot = [];
}
}
if ($lot !== []) {
yield $lot; // il resto
}
}$pdo->beginTransaction();
$insertion = $pdo->prepare('INSERT INTO clients (reference, nom, montant) VALUES (?, ?, ?)');
foreach (parLots(lireLignes('gros.csv'), 1000) as $numero => $lot) {
foreach ($lot as $ligne) {
$colonnes = str_getcsv($ligne, ',', '"', '\\');
$insertion->execute([$colonnes[0], $colonnes[1], $colonnes[2]]);
}
if ($numero % 50 === 0) {
$pdo->commit();
$pdo->beginTransaction();
}
}
$pdo->commit();Lo yield finale dopo il ciclo è la riga che si dimentica: senza di esso le ultime righe del file, quelle che non completano un lotto intero, vanno perse in silenzio.
Raggruppare gli insert in transazioni cambia tutto sulla durata. Una transazione per riga forza una scrittura su disco ogni volta, un commit ogni 50.000 righe ne fa una per lotto.
I limiti di tempo
La memoria non è l’unico muro. Da riga di comando max_execution_time vale 0: lo script gira senza limiti. Dietro un server web è di norma a 30 secondi, e il server frontale ha il proprio timeout.
// In CLI, verificare prima di farci affidamento
if (PHP_SAPI !== 'cli') {
throw new RuntimeException('Ce traitement doit être lancé en ligne de commande.');
}L’elaborazione di un file voluminoso non ha posto in una richiesta HTTP. La richiesta registra il lavoro da fare, se ne occupa un comando in CLI o una coda.
Da ricordare
- Un generatore legge un file di qualsiasi dimensione con 2 MB di memoria.
file()efile_get_contents()caricano tutto. - Il sovraccosto in tempo è nullo, la misura lo dimostra: la scelta non ha contropartite.
fgetcsvè sette volte più lento diexplode, ed è il prezzo della correttezza. Da pagare su un file di cui non controlli la produzione.- I filtri di flusso comprimono senza caricare tutto.
- Misura ogni strategia in un processo separato, altrimenti
memory_get_peak_usage()mente.
Il controllo della memoria conta ancora di più in un processo che non si ferma mai: un server WebSocket in PHP misura questo costo connessione per connessione. Per elaborare colonne di date, vedi ottenere e formattare la data e l’ora.
Vedi anche la connessione a un database in PHP per la parte di inserimento, e l’hub Sviluppo web.
Errori frequenti
memory_limit a 128M, arriva Allowed memory size of 134217728 bytes exhausted.memory_get_peak_usage() conserva il massimo raggiunto dall'inizio dello script: la prima misura falsa tutte le successive. Un processo per strategia.fgetcsv è sette volte più lento, ed è il prezzo della correttezza: gestisce le virgolette e i separatori all'interno dei campi. explode(',') si rompe appena un campo contiene una virgola.yield dopo il ciclo, le righe che non completano un lotto intero spariscono in silenzio.gunzip si rifiuta di aprire. Serve 'window' => 31 per un vero file gzip.max_execution_time vale 0 da riga di comando, ma 30 secondi dietro un server web.

