Simulazione concorrente · thread · lock · Condition

Incrocio a 4 strade

Quattro strade, quattro quadranti, 97 auto come thread. Ogni auto deve prenotare tutti i quadranti del suo percorso in un colpo solo, attraversare e rilasciare. Qui sotto vedi il programma in funzione in tempo reale; scorri per la spiegazione completa di come funziona, lock compresi.

quadrante libero quadrante occupato (colore = auto) richiesto da un'auto in attesa auto in attesa sulla Condition

Il programma Python lancia 97 auto con arrivi scaglionati di 0,1–0,4 s; qui il flusso è continuo e regolabile dal pannello (o aggiungi auto a mano), così puoi alzare la contesa e vedere code, attese e notify_all() in azione. Le traiettorie sono una ricostruzione grafica: il codice Python descrive solo quali quadranti vengono occupati (tabella PERCORSI). Le durate di attraversamento restano nell'intervallo 0,5–1,5 s del codice, adattate alla lunghezza del percorso.

Controlli

1.00×
0,8 auto/s
Scorciatoie: spazio pausa · R reset · F schermo intero. Passa il mouse su un'auto in attesa per evidenziarne i quadranti.
big_lock cond.notify_all()

Stato quadranti (come la dashboard Python)

In attesa sulla Condition

    Log (gli stessi 3 messaggi del codice)

    create 0 attraversate 0 in attesa 0 tempo 0,0 s

    Cosa fa il programma

    Il programma è una simulazione di concorrenza in stile "cinque filosofi": un incrocio a quattro strade (Nord, Sud, Est, Ovest) diviso in quattro quadranti (NE, NO, SE, SO) è una risorsa condivisa. Ogni auto è un thread: arriva da una direzione, deve attraversare l'incrocio compiendo una manovra (destra, dritto o sinistra), quindi occupare i quadranti che il suo percorso attraversa, uno alla volta esclusivi, attraversare e infine rilasciarli.

                  N
             +---------+
             |  NO| NE |
        O    |----+----|    E
             |  SO| SE |
             +---------+
                  S
    
        NO = Nord-Ovest   NE = Nord-Est
        SO = Sud-Ovest    SE = Sud-Est

    Chi fa cosa

    ComponenteRuoloMeccanismo di sincronizzazione
    Auto(Thread)Una per ogni auto; nel suo run() chiama incrocio.attraversa(self)È il thread: si blocca e si risveglia tramite la Condition dell'incrocio
    IncrocioContiene lo stato condiviso (libero, occupanti, _log) e i metodi di prenotazione/rilascioLock (big_lock) + Condition (cond_incrocio)
    PERCORSIDizionario (provenienza, svolta) → [quadranti]: definisce quali quadranti servono a ogni manovra— (sola lettura, quindi condivisibile senza lock)
    VisualizzatoreIncrocio(Thread)Thread che ogni 0,5 s stampa la dashboard: griglia dei quadranti + ultime righe di logUsa big_lock indirettamente tramite stampa_stato(); ha un lock tutto suo per il proprio flag attivo
    mainCrea incrocio, avvia il visualizzatore, lancia 97 auto scaglionate di 0,1–0,4 s, fa join() di tutte, ferma il visualizzatore

    L'idea in tre righe

    È un classico esempio di monitor: lo stato condiviso è protetto da un unico mutex, e l'attesa avviene su una variabile di condizione. Non è un "lock tenuto durante l'attraversamento": il lock serve solo a rendere atomica la transazione di prenotazione, come vedremo nel dettaglio.

    Come funzionano i lock (e perché così)

    1. Due oggetti di sincronizzazione

    
        

    Lock() è il mutex (si entra con with / acquirerelease). Una Condition è sempre legata a un mutex: cond_incrocio = Condition(big_lock) significa che ogni wait() e ogni notify_all() devono avvenire mentre si tiene big_lock (Python solleva RuntimeError se notifichi senza il lock).

    Cosa protegge big_lock: i booleani libero (la mutua esclusione vera), più occupanti e _log, che servono solo a mostrare uno stato coerente nella dashboard.

    2. Prenotazione: controllo e riserva in un colpo solo

    
        

    Tre cose importanti, tutte in queste poche righe:

    3. Rilascio: libera e sveglia tutti

    
        

    notify_all() sveglia tutti i thread in attesa sulla Condition (non solo uno). Ognuno si risveglia, riacquisisce big_lock (uno alla volta), rifà il while: chi trova i suoi quadranti tutti liberi li prenota e prosegue, gli altri tornano a dormire. È un "risveglio collettivo e riprova"; con 97 auto si parla di thundering herd, ma su questa scala è del tutto innocuo.

    Poiché anche notify_all() avviene sotto lo stesso mutex, non esiste il problema del lost wakeup: non può succedere che un'auto controlli "occupato", e il rilascio+notify le sfugga prima che si metta in wait().

    4. Il lock NON è tenuto durante l'attraversamento

    Questo è il punto chiave per capire il design. In attraversa() la time.sleep(...) che simula l'attraversamento avviene fuori dalla sezione critica. Le auto non "tengono" big_lock mentre passano: la mutua esclusione fisica sui quadranti è garantita dai booleani libero messi a False. Il lock serve solo a rendere atomica la transazione di prenotazione/rilascio. Risultato: due auto con percorsi disgiunti attraversano davvero in parallelo (es. chi va dritto da Nord usa NO+SO, chi svolta a destra da Sud usa solo SE: possono passare insieme).

    5. Perché non si può avere deadlock

    Un deadlock richiede che valgano tutte le condizioni di Coffman. Qui ne manca una fondamentale: hold-and-wait. Un'auto non prende mai "metà" delle risorse: o prenota tutte i quadranti che le servono (quando sono liberi contemporaneamente), o non ne prenota nessuno e aspetta. Senza possesso parziale non può formarsi nessun ciclo di attese.

    Cinque filosofi (classico)Questo incrocio
    Risorse2 forchette a testa, prese una alla volta1–3 quadranti, prenotati tutti insieme
    AttesaTiene la forchetta sinistra e aspetta la destra → hold-and-waitSe non trova tutto libero, non tiene nulla → niente hold-and-wait
    DeadlockPossibile (attesa circolare)Impossibile
    Nota: il fatto che la lista dei quadranti in PERCORSI abbia un ordine (es. ['NO','SO','SE']) è solo descrittivo: il codice non richiede i quadranti uno alla volta in quell'ordine, li tratta come un insieme. Per questo non serve nessun "ordine globale di acquisizione" per evitare deadlock.

    6. Chi prende big_lock, e quando

    MetodoPrende big_lock?Cosa fa / può bloccarsi?
    impegna_incrocioControlla e prenota; se non può, wait() (si sblocca, riacquisisce il lock al risveglio)
    rilascia_incrocioLibera i quadranti e chiama notify_all()
    _segna_occupatiSolo per la dashboard (chi è disegnato in quel quadrante)
    logAppende una riga in _log
    stampa_statoLegge occupanti e _log e stampa mentre tiene il lock: la dashboard è uno snapshot coerente
    tempo_che_simula_attraversamentoNotime.sleep() fuori dalla sezione critica: è il cuore della concorrenza
    attraversaNoCoordina le chiamate precedenti; non prende direttamente il lock

    7. Fairness: il limite teorico di questo schema

    Non esiste alcuna garanzia di ordine. notify_all() sveglia tutti e chi arriva "prima" a riacquisire il lock riprova per primo: un'auto che chiede 3 quadranti può essere scavalcata ripetutamente da auto che ne chiedono 1 e che li occupano per pochi istanti. In teoria è starvation (non deadlock: il sistema continua a progredire). L'unico modo per garantire l'ordine d'arrivo è una coda FIFO esplicita delle richieste — che questo esercizio, volutamente, non ha.

    Flusso di utilizzo

    A. Flusso del main

    1
    incrocio = Incrocio() — crea lo stato condiviso: lock, condition, quadranti liberi
    2
    visualizzatore.start() — parte il thread che ogni 0,5 s ridisegna la dashboard
    3
    Crea 97 Auto con provenienza e svolta casuali — ancora ferme, non sono thread avviati
    4
    Per ognuna: a.start() + sleep(0.1–0.4 s) — le auto "arrivano" scaglionate nel tempo
    5
    for a in auto: a.join() — il main aspetta che tutte abbiano attraversato e siano terminate
    6
    visualizzatore.ferma() + join() — il flag attivo va a False sotto il lock del visualizzatore, il loop esce e il thread finisce

    B. Ciclo di vita di una singola auto

    
        
    1
    Il thread parte: run()incrocio.attraversa(self) — qui inizia la "vita" dell'auto
    2
    Log: "Auto#n (N->sinistra) in attesa di bloccare ['NO', 'SO', 'SE']" — scritto sempre, anche se poi non aspetterà affatto
    3
    impegna_incrocio(quadranti) — se tutti liberi: prenota e va avanti; altrimenti wait() sulla Condition e ritenta al risveglio
    4
    _segna_occupati(quadranti, id) + log "attraversa [...]" — i quadranti diventano visibili in dashboard come occupati da questa auto
    5
    time.sleep(0.5–1.5 s) — l'attraversamento, FUORI dal lock: le altre auto possono proseguire su altri quadranti
    6
    _segna_occupati(quadranti, None)rilascia_incrocio(quadranti) — libera i booleani e notify_all()
    7
    Log: "ha lasciato l'incrocio" — il thread termina

    C. Esempio concreto di contesa

    Supponi che Auto#7 (da Nord, svolta a sinistra → NO, SO, SE) stia attraversando, quando arrivano:

    Quando #7 finisce, libera NO, SO, SE e chiama notify_all(): entrambe si svegliano e riprovano. #8 trova SO libero, #9 trova SE e NE liberi: i due insiemi sono disgiunti, quindi passano entrambe contemporaneamente. Questo è il vantaggio di bloccare insiemi di quadranti: il throughput è alto finché i percorsi non si sovrappongono.

    Se invece fosse presente anche Auto#10 (da Nord, sinistra → NO, SO, SE), al risveglio le tre auto competono: se #8 e #9 prenotano per prime, #10 torna a dormire e ritenta al prossimo rilascio. Nessun ordine garantito, nessun deadlock.

    D. Come si usa la classe (API)

    
        
    Dettaglio didattico: il log "in attesa di bloccare" viene scritto prima di provare a prenotare. Un'auto che trova subito tutto libero non ha mai aspettato, ma il messaggio è già nel log. È una scelta dell'esercizio (rendere visibile il "momento della richiesta"), non un indicatore di attesa reale: l'attesa vera è la lista dei thread fermi su wait().

    Quadranti, manovre e guida a destra

    La regola è quella italiana (guida a destra): un'auto tiene la corsia di destra rispetto al proprio senso di marcia. Con il Nord in alto:

    ProvenienzaCorsia d'ingressoPerché
    da Nord (va verso Sud)metà Ovest della strada verticaleil lato destro di chi scende è l'Ovest
    da Sud (va verso Nord)metà Estil lato destro di chi sale è l'Est
    da Est (va verso Ovest)metà Nord della strada orizzontaleil lato destro di chi va verso Ovest è il Nord
    da Ovest (va verso Est)metà Sudil lato destro di chi va verso Est è il Sud

    Perché destra = 1, dritto = 2, sinistra = 3 quadranti

    La tabella completa PERCORSI

    
        
    ManovraQuadrantiDa NordDa SudDa EstDa Ovest
    destra1NOSENESO
    dritto2NO SOSE NENE NOSO NO
    sinistra3NO SO SESE NE NONE SE SOSO NO NE

    Curiosità: le diagonali di ogni manovra sono speculari rispetto al centro (N↔S, E↔O), per la simmetria dell'incrocio e della guida a destra.

    Dalla tabella alla simulazione

    Nel codice Python i quadranti sono solo booleani: il programma non disegna traiettorie. Nella simulazione qui sopra le traiettorie sono ricostruite geometricamente per rendere visibile l'occupazione: curve strette per le svolte a destra, rettilinei per "dritto", archi ampi per le svolte a sinistra. Sono disegnate in modo da attraversare gli stessi quadranti di PERCORSI; per le svolte a sinistra da Est e da Ovest la traiettoria naturale sfiora per pochi pixel il quadrante adiacente al centro dell'incrocio, ma l'occupazione mostrata (colore e etichetta) è esattamente quella decisa dal lock, cioè dalla tabella.

    È la differenza tra modello (i quadranti dell'esercizio: 4 risorse binarie) e geometria (il disegno): la correttezza riguarda il modello, non il pixel.

    Analisi: punti di forza e limiti

    Punti di forza del design

    Limiti e criticità (note di studio)

    Se volessi migliorarlo (idee)

    Codice annotato

    Incrocio — lo stato condiviso

    
        
    
        

    attraversa() — la vita di un'auto

    
        
    
        

    impegna_incrocio() — la transazione atomica

    
        
    
        

    rilascia_incrocio() — libera e sveglia

    
        
    
        

    Auto — il thread

    
        
    
        

    VisualizzatoreIncrocio — il thread "osservatore"

    
        
    
        

    stampa_stato — la dashboard

    
        
    
        

    main — l'orchestrazione