Arresto controllato di thread e processi in Python

by theArchitect
SHARE
Arresto controllato di thread e processi in Python
© Guida-HTML5.it

Introduzione

Quando un programma usa il multithreading o il multiprocessing, interrompere un’attività non significa semplicemente terminare il thread o il processo. Un arresto forzato può lasciare file aperti, connessioni di rete attive, dati parzialmente elaborati o risorse non rilasciate.

Una soluzione più affidabile consiste nell’implementare un arresto cooperativo: il programma segnala ai lavoratori che devono fermarsi e ogni lavoratore decide autonomamente quando interrompere il proprio ciclo, completando prima le operazioni indispensabili.

In questo tutorial useremo:

  • threading.Event per segnalare l’arresto a un thread;
  • multiprocessing.Event per condividere la segnalazione tra processi;
  • un thread supervisore che decide quando interrompere l’elaborazione;
  • la gestione ordinata della chiusura tramite join();
  • il modulo logging per osservare il comportamento dei lavoratori.

Questo approccio è utile nei crawler, nei servizi che elaborano file, nei programmi batch e nelle applicazioni che devono reagire a un comando di arresto senza perdere il lavoro già iniziato.

Codice completo

import logging
import multiprocessing as mp
import threading
import time


def configura_logging():
    """Configura un formato comune per tutti i messaggi."""
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s | %(processName)s | "
               "%(threadName)s | %(message)s",
        datefmt="%H:%M:%S",
    )


def lavoratore_thread(stop_event):
    """Esegue un lavoro nel thread principale del processo."""
    logger = logging.getLogger("thread-worker")

    for numero in range(1, 21):
        # wait() restituisce True se l´evento è stato attivato.
        # In questo modo il thread non resta bloccato troppo a lungo.
        if stop_event.wait(timeout=0.3):
            logger.info("Arresto richiesto: il thread termina.")
            return

        logger.info("Thread: elaborazione elemento %d", numero)

    logger.info("Thread: lavoro completato.")


def lavoratore_processo(numero_processo, stop_event):
    """Esegue un lavoro indipendente in un processo separato."""
    configura_logging()
    logger = logging.getLogger(f"process-{numero_processo}")

    for numero in range(1, 21):
        if stop_event.wait(timeout=0.4):
            logger.info(
                "Arresto richiesto: il processo termina dopo "
                "l´elemento %d.",
                numero - 1,
            )
            return

        logger.info(
            "Processo %d: elaborazione elemento %d",
            numero_processo,
            numero,
        )

    logger.info("Processo %d: lavoro completato.", numero_processo)


def supervisore(stop_event, secondi):
    """Attende un intervallo e richiede l´arresto."""
    logger = logging.getLogger("supervisore")

    time.sleep(secondi)
    logger.info("Tempo massimo raggiunto: richiesta di arresto.")
    stop_event.set()


def main():
    configura_logging()

    # Event per il thread locale.
    stop_thread = threading.Event()

    # Event condivisibile tra processi.
    stop_processi = mp.Event()

    thread = threading.Thread(
        target=lavoratore_thread,
        args=(stop_thread,),
        name="ThreadLavoratore",
    )

    processi = [
        mp.Process(
            target=lavoratore_processo,
            args=(indice, stop_processi),
            name=f"ProcessoLavoratore-{indice}",
        )
        for indice in range(1, 3)
    ]

    # Il supervisore è un thread perché deve coordinare il programma
    # principale senza creare un ulteriore processo.
    supervisore_thread = threading.Thread(
        target=supervisore,
        args=(stop_thread, 2.0),
        name="SupervisoreThread",
    )

    thread.start()
    supervisore_thread.start()

    for processo in processi:
        processo.start()

    # Quando il thread locale termina, notifichiamo l´arresto anche
    # ai processi. In un´applicazione reale questa decisione potrebbe
    # dipendere da un segnale, da un errore o da un comando dell´utente.
    thread.join()
    stop_processi.set()

    for processo in processi:
        processo.join(timeout=3)

        if processo.is_alive():
            logging.warning(
                "%s non ha terminato in tempo: terminazione forzata.",
                processo.name,
            )
            processo.terminate()
            processo.join()

    supervisore_thread.join()
    logging.info("Tutte le attività sono state chiuse.")


if __name__ == "__main__":
    # Necessario soprattutto su Windows e macOS con il metodo spawn.
    mp.freeze_support()
    main()

Spiegazione

Perché usare un evento?

Un oggetto Event rappresenta uno stato booleano condiviso. Inizialmente l’evento non è attivo. Quando viene chiamato set(), l’evento passa allo stato attivo e tutti i lavoratori possono rilevarlo con is_set() oppure con wait().

Nel codice il thread controlla l’evento tramite:

if stop_event.wait(timeout=0.3):
    return

Questa forma è spesso migliore di una semplice chiamata a time.sleep(). Se l’evento viene attivato, wait() ritorna subito, evitando di attendere inutilmente la fine del timeout.

Thread e processi richiedono eventi diversi

threading.Event è progettato per la condivisione dello stato tra thread dello stesso processo. Non deve essere usato per coordinare processi separati.

Per i processi si usa invece multiprocessing.Event. Questo oggetto utilizza primitive compatibili con la memoria condivisa tra processi e può essere passato alla funzione eseguita da ciascun processo.

Nel programma sono presenti quindi due eventi:

  • stop_thread, usato dal thread lavoratore e dal supervisore;
  • stop_processi, usato dal processo principale e dai processi lavoratori.

Chiusura ordinata con join

La chiamata join() blocca il programma chiamante fino alla terminazione del thread o del processo. È importante usarla per evitare lavoratori lasciati in esecuzione dopo la fine della funzione main().

Per i processi è stato aggiunto un timeout:

processo.join(timeout=3)

if processo.is_alive():
    processo.terminate()

La terminazione forzata è una misura di emergenza, non il meccanismo principale. Deve essere usata soltanto quando un processo non risponde all’arresto cooperativo, perché può interrompere un’operazione a metà.

Il ruolo del supervisore

Il thread supervisore simula un componente esterno che richiede la chiusura del programma dopo un certo tempo. In un’applicazione reale, lo stesso comportamento potrebbe essere attivato da:

  • un segnale del sistema operativo;
  • un pulsante “Stop” in un’interfaccia grafica;
  • un comando ricevuto tramite rete;
  • un errore non recuperabile;
  • il superamento di un limite di tempo.

Best practice

  • Controllare spesso l’evento: un lavoratore che esegue una singola operazione di dieci minuti non può reagire rapidamente all’arresto. Suddividere il lavoro in unità più piccole rende il programma più responsivo.
  • Preferire wait(timeout) a cicli con attese lunghe: permette di interrompere rapidamente l’attesa quando arriva la richiesta di arresto.
  • Chiudere sempre le risorse: file, socket e connessioni al database devono essere gestiti con with o con blocchi try/finally.
  • Usare logging invece di print(): i log includono processo e thread, rendendo più semplice analizzare problemi di concorrenza.
  • Proteggere il punto di ingresso: il blocco if __name__ == "__main__" è indispensabile quando si usano processi, soprattutto con il metodo spawn.
  • Non abusare di terminate(): la chiusura forzata può lasciare dati inconsistenti e risorse in uno stato imprevedibile.
  • Definire timeout realistici: un timeout troppo breve può causare terminazioni premature; uno troppo lungo ritarda la chiusura dell’applicazione.

Riepilogo

Un sistema multithread o multiprocesso robusto deve prevedere anche come terminare le attività. L’arresto cooperativo con Event permette ai lavoratori di interrompersi in modo controllato, riducendo il rischio di perdita dati e risorse non rilasciate.

Per i thread si usa threading.Event, mentre per i processi si usa multiprocessing.Event. La sequenza consigliata è: segnalare l’arresto, attendere con join(), verificare eventuali blocchi e ricorrere alla terminazione forzata solo come ultima risorsa.

Approfondisci con risorse ufficiali

SHARE