Nelle applicazioni aziendali costruite con Laravel, capita frequentemente di dover eseguire operazioni batch che processano una grande quantità di dati o interagiscono con molteplici sistemi esterni. Che si tratti di inviare migliaia di notifiche email, sincronizzare dati da diverse fonti, generare report complessi o effettuare chiamate multiple a API di terze parti, l'approccio tradizionale di esecuzione sequenziale, comune in progetti Laravel 9 o Laravel 10 più datati, può rapidamente diventare un collo di bottiglia. Lunghi tempi di esecuzione per i comandi Artisan, richieste web che vanno in timeout e una generale scarsa reattività possono impattare negativamente sull'efficienza di un'impresa.

Fortunatamente, a partire da Laravel 11 (e quindi pienamente disponibile e consigliato per Laravel 12), il framework ha introdotto un potente componente per la gestione della concorrenza: Illuminate\Support\Facades\Concurrency. Questo strumento permette di eseguire più task contemporaneamente, sfruttando meglio le risorse del server e riducendo drasticamente i tempi di attesa, specialmente per operazioni I/O-bound.

In questo articolo tecnico, esploreremo come effettuare un refactoring di operazioni batch sequenziali, tipiche di un'applicazione Laravel 9/10, verso un approccio concorrente utilizzando Concurrency::run() e Concurrency::awaitall(), con esempi di codice dettagliati per illustrare i benefici in termini di performance per il tuo business.

Se vuoi approfondire, continua a leggere. Se hai una domanda specifica a riguardo di questo articolo, contattami per una consulenza dedicata. Dai anche un'occhiata al mio profilo per capire come posso aiutare concretamente la tua azienda o startup a crescere e a modernizzarsi.

Scenario pre-concurrency: le operazioni batch sequenziali in Laravel 9/10

Immaginiamo alcuni scenari comuni in cui l'esecuzione sequenziale può diventare problematica.

Esempio 1: Invio di notifiche multiple a utenti

Supponiamo di dover inviare una notifica importante a tutti gli utenti attivi di un'applicazione. Un approccio sequenziale potrebbe essere:

// In un comando Artisan o un Service (Laravel 9/10 style)
use App\Models\User;
use App\Notifications\ImportantBusinessUpdate; // Ipotetica notifica
use Illuminate\Support\Facades\Log;

// ...
$users = User::where('is_active', true)->get();
$startTime = microtime(true);
$processedCount = 0;

foreach ($users as $user) {
    try {
        $user->notify(new ImportantBusinessUpdate());
        $processedCount++;
        // Log::info("Notifica inviata a {$user->email}");
    } catch (\Exception $e) {
        Log::error("Errore invio notifica a {$user->email}: " . $e->getMessage());
    }
    // Se ci sono migliaia di utenti, questo loop può durare molto tempo,
    // specialmente se l'invio email (anche se in coda) ha un piccolo overhead.
}

$duration = microtime(true) - $startTime;
Log::info("Invio notifiche completato a {$processedCount} utenti in {$duration} secondi.");

Se ogni notifica impiega anche solo mezzo secondo per essere processata (inclusa l'interazione con il sistema di code, se usato), inviare 10.000 notifiche richiederebbe quasi un'ora e mezza!

Esempio 2: Chiamate multiple a un'API esterna

Immagina di dover arricchire i dati di N prodotti nel tuo database chiamando un'API esterna per ognuno.

// In un Service (Laravel 9/10 style)
use Illuminate\Support\Facades\Http; // Uso base dell'HTTP Client
use App\Models\Product;
use Illuminate\Support\Facades\Log;

// ...
$products = Product::whereNull('external_data_enriched_at')->take(100)->get();
$startTime = microtime(true);
$updatedCount = 0;

foreach ($products as $product) {
    try {
        $response = Http::timeout(5)->get("https://api.thirdparty.com/v1/product-info/{$product->external_id}");

        if ($response->successful()) {
            $product->external_data = $response->json();
            $product->external_data_enriched_at = now();
            $product->save();
            $updatedCount++;
        } else {
            Log::warning("Fallimento API per prodotto {$product->id}: " . $response->status());
        }
    } catch (\Illuminate\Http\Client\ConnectionException $e) {
        Log::error("Errore di connessione API per prodotto {$product->id}: " . $e->getMessage());
        // Potresti voler interrompere o riprovare dopo un po'
        break; // Esempio drastico: interrompe al primo errore di connessione
    } catch (\Exception $e) {
        Log::error("Errore generico per prodotto {$product->id}: " . $e->getMessage());
    }
}

$duration = microtime(true) - $startTime;
Log::info("Arricchimento dati completato per {$updatedCount} prodotti in {$duration} secondi.");

Se ogni chiamata API impiega in media 1 secondo (tra latenza di rete e tempo di risposta del server esterno), arricchire 100 prodotti richiederebbe almeno 100 secondi, ovvero più di un minuto e mezzo. Questo potrebbe essere accettabile per un comando Artisan notturno, ma proibitivo per una richiesta web.

Limiti dell'approccio sequenziale:

  • Tempi di esecuzione lunghi: scalano linearmente con il numero di operazioni.
  • Blocco del processo principale: un comando Artisan rimane occupato per l'intera durata; una richiesta web andrebbe sicuramente in timeout.
  • Scarsa reattività: l'applicazione non può fare altro mentre attende il completamento di queste lunghe operazioni.

Modernizzare con Illuminate\Support\Facades\Concurrency (introdotto in Laravel 11, per L12)

Il componente Concurrency di Laravel (disponibile tramite il facade Illuminate\Support\Facades\Concurrency) permette di eseguire un insieme di task in modo concorrente, attendendo poi il loro completamento. Questo è particolarmente efficace per operazioni I/O-bound (come chiamate HTTP, interazioni con il database, operazioni sul filesystem), dove il processo spenderebbe molto tempo in attesa.

Driver di Concorrenza: Laravel supporta diversi driver per la concorrenza, configurabili tramite la variabile d'ambiente CONCURRENCY_DRIVER (default async):

  • async: Utilizza le fibre (PHP fibers) se disponibili (PHP 8.1+ e estensione fibers installata) per una concorrenza leggera e cooperativa all'interno di un singolo processo. Se le fibre non sono disponibili, ripiega sull'utilizzo di proc_open per eseguire task in processi separati, il che può avere un overhead maggiore ma funziona su più versioni di PHP.
  • fork: Utilizza l'estensione PHP pcntl per creare processi figli reali (forking). Questo può permettere vero parallelismo su sistemi multi-core, ed è spesso più adatto per task CPU-bound, ma richiede l'estensione pcntl (generalmente non disponibile su Windows o in alcuni ambienti PHP-FPM restrittivi).
  • fake: Un driver per il testing, che esegue i task sequenzialmente ma permette di asserire sulle operazioni di concorrenza.

Per la maggior parte delle operazioni I/O-bound come chiamate API o notifiche, il driver async con le fibre (se disponibili) è una scelta eccellente e performante.

1. Utilizzo di Concurrency::run()

Il metodo Concurrency::run() accetta un array di closure o oggetti invokable (i tuoi task) e li esegue in modo concorrente. Ritorna un array con i risultati di ogni task, nello stesso ordine in cui i task sono stati definiti.

Refactoring Esempio 1: Invio Notifiche Concorrente

// In un comando Artisan o un Service (Laravel 11/12 style)
use App\Models\User;
use App\Notifications\ImportantBusinessUpdate;
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use Throwable; // Per il catch generico

// ...
$users = User::where('is_active', true)->get(); // Supponiamo N utenti
$startTime = microtime(true);
$tasks = [];

foreach ($users as $user) {
    // Ogni task è una closure. Puoi anche usare classi Invokable.
    $tasks[] = function () use ($user) {
        try {
            $user->notifyNow(new ImportantBusinessUpdate()); // Usiamo notifyNow per l'esecuzione immediata nel contesto del task
            // Nota: se ImportantBusinessUpdate implementa ShouldQueue, notifyNow la processerà
            // immediatamente nel task concorrente, non la invierà alla coda principale.
            // Se vuoi inviarla alla coda principale da qui, usa $user->notify().
            return ['user_id' => $user->id, 'status' => 'success'];
        } catch (Throwable $e) { // Cattura qualsiasi eccezione per non bloccare gli altri task
            Log::error("Errore invio notifica (concorrente) a utente {$user->id}: " . $e->getMessage());
            return ['user_id' => $user->id, 'status' => 'failed', 'error' => $e->getMessage()];
        }
    };
}

// Esegui i task in concorrenza.
// Il secondo argomento è un callback opzionale eseguito per ogni task completato (anche se ha fallito internamente).
// Il terzo argomento è un callback opzionale per eccezioni non catturate *all'interno* delle closure dei task.
// Il quarto argomento è un timeout generale per tutti i task (in millisecondi).
$results = Concurrency::run(
    $tasks,
    function (array $result, int $index) { // $result è ciò che la closure del task ha ritornato
        if ($result['status'] === 'success') {
            // Log::info("Task notifica [{$index}] per utente {$result['user_id']} completato.");
        }
    },
    function (Throwable $e, int $index) {
        Log::critical("Eccezione non gestita dal task di notifica [{$index}]: " . $e->getMessage(), ['exception' => $e]);
    },
    timeout: 60000 // Timeout di 60 secondi per l'intero batch
);

$duration = microtime(true) - $startTime;
$successfulNotifications = count(array_filter($results, fn($r) => is_array($r) && $r['status'] === 'success'));
$failedNotifications = count($users) - $successfulNotifications;

Log::info("Invio notifiche (concorrente) completato. Successi: {$successfulNotifications}, Fallimenti: {$failedNotifications}. Durata: {$duration} secondi.");

Con questo approccio, se hai 100 utenti e ogni notifica (nel suo task isolato) impiega 0.5s, il tempo totale non sarà 50s, ma molto più vicino al tempo dell'operazione singola più lunga (o limitato dal numero di fibre/processi concorrenti che Laravel può gestire, configurabile tramite config('queue.connections.database.concurrent_tasks') per il driver async con proc_open o internamente per le fibre).

Refactoring Esempio 2: Chiamate Multiple a un'API Esterna in Concorrenza Un caso d'uso molto comune per la concorrenza è l'esecuzione di multiple chiamate a API esterne. Per approfondire come strutturare queste chiamate singolarmente in modo robusto, ti consiglio di leggere la mia guida al refactoring delle integrazioni API esterne in Laravel. Qui, ci concentreremo sull'eseguirle in concorrenza.

// In un Service (Laravel 11/12 style)
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Http; // Usiamo l'HTTP Client di Laravel
use Illuminate\Support\Facades\Log;
use App\Models\Product; // Il tuo model
use Throwable;

// ...
$productIdsToEnrich = Product::whereNull('external_data_enriched_at')->limit(50)->pluck('id', 'external_api_id');
$startTime = microtime(true);
$apiTasks = [];

// Supponiamo che external_api_id sia l'ID da usare con l'API esterna
foreach ($productIdsToEnrich as $externalApiId => $internalProductId) {
    $apiTasks[$internalProductId] = function () use ($externalApiId, $internalProductId) { // Usiamo l'ID interno come chiave per facilitare il matching dei risultati
        try {
            // Assicurati che il tuo HTTP Client sia configurato con timeout appropriati
            // per evitare che un singolo task blocchi tutto per troppo tempo.
            $response = Http::timeout(10) // Timeout per questa specifica chiamata
                              ->acceptJson()
                              ->get("https://api.thirdparty.com/v1/product-info/{$externalApiId}");

            if ($response->successful()) {
                return ['product_id' => $internalProductId, 'status' => 'success', 'data' => $response->json()];
            } else {
                Log::warning("Fallimento API (concorrente) per external ID {$externalApiId} (prodotto ID {$internalProductId}): " . $response->status());
                return ['product_id' => $internalProductId, 'status' => 'failed', 'http_status' => $response->status()];
            }
        } catch (Throwable $e) {
            Log::error("Errore chiamata API (concorrente) per external ID {$externalApiId} (prodotto ID {$internalProductId}): " . $e->getMessage());
            return ['product_id' => $internalProductId, 'status' => 'exception', 'error' => $e->getMessage()];
        }
    };
}

// Esegui i task, impostando un numero massimo di task concorrenti
// Questo è configurabile globalmente in config/concurrency.php o per driver.
// Qui è un esempio di come potresti volerlo limitare programmaticamente se necessario,
// anche se Concurrency::run gestisce il pool internamente.
// La configurazione avviene in config/concurrency.php -> stores.async.concurrent_tasks
$results = Concurrency::run($apiTasks, timeout: 45000); // Timeout generale 45s

$updatedCount = 0;
foreach ($results as $internalProductId => $result) { // Se abbiamo usato chiavi associative per $apiTasks
    if (is_array($result) && $result['status'] === 'success' && isset($result['data'])) {
        $product = Product::find($internalProductId);
        if ($product) {
            $product->external_data = $result['data'];
            $product->external_data_enriched_at = now();
            $product->save();
            $updatedCount++;
        }
    }
    // Altrimenti, gestisci i fallimenti o le eccezioni come loggato all'interno del task
}

$duration = microtime(true) - $startTime;
Log::info("Arricchimento dati (concorrente) completato. Prodotti aggiornati: {$updatedCount}. Durata: {$duration} secondi.");

Nota sul Timeout: Il timeout in Concurrency::run() è un timeout generale per il completamento di tutti i task. Ogni chiamata HTTP all'interno dei task dovrebbe avere il suo timeout() individuale per evitare che un singolo task lento blocchi l'intero batch indefinitamente.

2. Utilizzo di Concurrency::defer() e Concurrency::awaitall()

Per scenari più dinamici o per gestire un pool di task a cui se ne aggiungono altri nel tempo, puoi usare Concurrency::defer() per accodare un task al pool di concorrenza senza bloccare l'esecuzione. Successivamente, Concurrency::awaitall() attenderà che tutti i task nel pool (o un array specifico di PendingTask ritornati da defer) siano completati.

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Log;
use App\Models\DataToProcess; // Esempio

// ...
$dataItems = DataToProcess::cursor(); // Usa un cursore per gestire grandi set di dati
$pendingTasks = [];
$startTime = microtime(true);

foreach ($dataItems as $item) {
    // defer() aggiunge il task al pool e ritorna un'istanza di PendingTask
    $pendingTasks[] = Concurrency::defer(function () use ($item) {
        try {
            // Simula un'operazione I/O-bound o CPU-bound leggera
            // Per operazioni CPU-bound reali, considera il driver 'fork' e le sue implicazioni
            sleep(random_int(1,3)); // Simula lavoro
            $result = "Processed: " . $item->name;
            // Log::info($result);
            return ['item_id' => $item->id, 'result' => $result];
        } catch (Throwable $e) {
            Log::error("Errore processando item {$item->id}: " . $e->getMessage());
            return ['item_id' => $item->id, 'error' => $e->getMessage()];
        }
    });

    // Potresti voler limitare il numero di task 'deferiti' contemporaneamente
    // se il set di dati è enorme, per non esaurire la memoria.
    // Ad esempio, ogni N defer, fai un awaitall parziale.
    if (count($pendingTasks) % 50 === 0) { // Esempio: attendi ogni 50 task
        Log::info("In attesa di un batch parziale di " . count($pendingTasks) . " task...");
        $partialResults = Concurrency::awaitall($pendingTasks, timeout: 120000); // Timeout 2 minuti
        // Processa $partialResults qui...
        $pendingTasks = []; // Resetta l'array per il prossimo batch
    }
}

// Attendi i task rimanenti
if (!empty($pendingTasks)) {
    Log::info("In attesa dei task rimanenti (" . count($pendingTasks) . ")...");
    $finalResults = Concurrency::awaitall($pendingTasks, timeout: 120000);
    // Processa $finalResults qui...
}

$duration = microtime(true) - $startTime;
Log::info("Processo batch con defer/awaitall completato in {$duration} secondi.");

Limiti e Considerazioni sulla Concorrenza in PHP/Laravel

  • Non è (sempre) vero parallelismo: con il driver async basato su fibre o proc_open, la concorrenza è gestita all'interno di un singolo processo PHP (o più processi figli con proc_open). Questo è eccellente per task I/O-bound (attesa di rete, filesystem, database) perché il processo può passare ad altri task mentre uno è in attesa. Per task genuinamente CPU-bound, il driver fork (che usa pcntl) può offrire vero parallelismo su macchine multi-core, ma ha requisiti specifici (estensione pcntl, non disponibile su Windows).
  • Overhead: per task molto piccoli e veloci, l'overhead della gestione della concorrenza potrebbe superare i benefici. Valuta caso per caso.
  • Gestione delle risorse: troppi task concorrenti (specialmente se proc_open crea molti processi o se il driver fork è usato massivamente) possono esaurire le risorse del server (CPU, memoria). È possibile configurare il numero massimo di task concorrenti in config/concurrency.php.
  • Condivisione dello stato e race conditions: i task concorrenti dovrebbero essere il più possibile indipendenti. La condivisione di stato mutabile tra task concorrenti può portare a race conditions. Le fibre mitigano alcuni di questi problemi rispetto al threading tradizionale, ma è sempre bene progettare i task per essere stateless o per usare meccanismi di sincronizzazione sicuri se la condivisione è inevitabile.
  • Gestione delle eccezioni: come mostrato, è importante gestire le eccezioni all'interno di ogni task per evitare che un singolo fallimento blocchi l'intero batch, a meno che non sia il comportamento desiderato. Concurrency::run() e awaitall() possono anche avere callback globali per il catch.

Sinergia con il Process Facade

Per operazioni batch che richiedono l'esecuzione di comandi CLI esterni (es. script Python, tool di elaborazione immagini come ImageMagick), il Process facade (introdotto in Laravel 10) può essere utilizzato all'interno dei task gestiti da Concurrency::run(). Questo permette di orchestrare più processi esterni in modo concorrente.

// Esempio concettuale
use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\Process;

$filesToConvert = ['image1.jpg', 'image2.png', 'image3.webp'];
$conversionTasks = [];

foreach ($filesToConvert as $file) {
    $conversionTasks[] = function () use ($file) {
        $result = Process::timeout(60)->run("convert {$file} -resize 50% output/{$file}");
        if ($result->successful()) {
            return "Conversione di {$file} riuscita.";
        }
        return "Fallimento conversione {$file}: " . $result->errorOutput();
    };
}
$conversionResults = Concurrency::run($conversionTasks);
dump($conversionResults);

Benefici del Refactoring per la tua Impresa

Adottare il componente Concurrency per le operazioni batch che erano sequenziali in Laravel 9/10 porta a:

  • Drastica riduzione dei tempi di esecuzione: specialmente per task I/O-bound.
  • Migliore utilizzo delle risorse del server: il server non rimane inattivo durante le attese I/O.
  • Applicazioni e comandi Artisan più reattivi: migliora l'esperienza utente e l'efficienza operativa.
  • Capacità di gestire carichi di lavoro più grandi: permette di processare più dati o più richieste nello stesso lasso di tempo.
  • Codice più moderno e allineato: sfrutta le ultime innovazioni del framework Laravel.

Il Ruolo del Programmatore Laravel Esperto

Identificare quali operazioni batch beneficiano maggiormente della concorrenza, scegliere il driver giusto, gestire correttamente lo stato, le eccezioni e i timeout, e integrare il tutto in un'applicazione esistente richiede esperienza. Come programmatore laravel esperto e senior laravel developer, posso assistere la tua impresa in:

  • Analisi delle performance delle attuali operazioni batch.
  • Refactoring del codice sequenziale per utilizzare Concurrency::run() o defer()/awaitall().
  • Ottimizzazione dei task per la concorrenza.
  • Implementazione di strategie di logging e monitoraggio per i processi concorrenti.
  • Scrittura di test per i nuovi componenti concorrenti.

Per un approfondimento sul mio approccio e sulla mia esperienza ventennale, ti invito a visitare la mia pagina Chi Sono.

Sfruttare la concorrenza in Laravel 12 non è solo un tecnicismo; è una strategia per rendere il tuo business più efficiente, scalabile e capace di rispondere rapidamente alle esigenze del mercato.

Se la tua applicazione Laravel è frenata da operazioni batch lente e vuoi esplorare come la concorrenza possa rivoluzionare le tue performance, contattami per una consulenza mirata.

Ultima modifica: Giovedì 20 Febbraio 2025, alle 13:04