Due richieste arrivano sullo stesso endpoint “preleva 100€” a mezzo millisecondo di distanza. Il conto ha 150€. Entrambe le richieste leggono quel saldo, entrambe vedono fondi sufficienti, entrambe approvano, entrambe sottraggono 100€. Il conto finisce a -50€, e nessun ramo del codice avrebbe dovuto permetterlo. Questa è una race condition: il risultato dipende da quale operazione finisce prima, e quel “prima” non lo decide il codice.
Cosa causa una race condition?
Servono due ingredienti insieme: stato condiviso mutabile, e più di un percorso di esecuzione che lo tocca senza coordinarsi. I percorsi possono essere thread, task async, processi, o due server che colpiscono la stessa riga di database. Togli uno dei due ingredienti e il bug scompare. Un singolo thread non può entrare in race con se stesso, e uno stato immutabile non si corrompe anche se viene letto più volte.
La forma classica è check-then-act: leggi un valore, decidi qualcosa in base a quello, poi agisci di conseguenza. Se un altro percorso esegue la propria sequenza leggi-decidi-agisci nell’intervallo tra la tua lettura e la tua scrittura, entrambi prendono una decisione valida per un istante che non esiste più nel momento in cui uno dei due scrive.
Un contatore condiviso mostra il meccanismo allo stato puro. counter += 1 sembra un’unica operazione. In realtà sono tre: leggi il valore attuale, aggiungi uno, scrivi il risultato. Se due thread eseguono quella sequenza su un contatore che parte da 0, ed entrambi leggono prima che uno dei due scriva, entrambi calcolano 1 ed entrambi scrivono 1. Un incremento è sparito. Nessuna eccezione, nessuna riga di log.
| |
Eseguilo un paio di volte. Lo scarto non è mai lo stesso, e questa è la seconda firma di una race condition: non si riproduce in modo affidabile, perché dipende dallo scheduling, che cambia da esecuzione a esecuzione e da macchina a macchina.
Come risolvi una race condition con un lock?
Rendi atomica la sequenza leggi-modifica-scrivi, così che una volta che un thread la inizia, nessun altro thread possa interporsi. Un lock funziona così: un solo thread può tenerlo per volta. Ogni altro thread che chiama acquire() si blocca finché chi lo detiene non chiama release().
| |
with lock: acquisisce all’ingresso e rilascia all’uscita, anche quando viene lanciata un’eccezione dentro il blocco. Non ti ritrovi mai con un lock che nessuno rilascia.
L’errore che tiene in vita il bug anche dopo aver aggiunto un lock: bloccare la scrittura ma non il controllo. Questa funzione di dosaggio sembra sicura e non lo è:
| |
Due thread possono entrambi superare il controllo prima che uno dei due prenda il lock. Entrambi aggiungono la propria quantità, e il totale supera il limite. Il lock deve avvolgere il controllo e l’azione come un’unica sezione critica, non solo la riga che sembra pericolosa:
| |
Con il GIL di Python la versione stretta funziona spesso per puro caso. Per questo sopravvive alla code review: passa ogni test sul tuo laptop, poi fallisce sotto carico in produzione o il giorno in cui passi a una build free-threaded.
Il JavaScript async può avere race condition?
Sì. JavaScript esegue il tuo codice su un solo thread nel browser e nell’esecuzione principale di Node, ma lo stesso modo di fallire si ripresenta ovunque due await possano concludersi in un ordine che non avevi previsto. Una casella di ricerca è il caso tipico. L’utente scrive “gatto”, parte una richiesta, continua a scrivere “gattofilo”, parte una seconda richiesta. Se la prima richiesta è più lenta, la sua risposta arriva dopo la seconda e sovrascrive i risultati più recenti con quelli vecchi.
| |
Ogni chiamata si assegna un ID prima di fare await su qualsiasi cosa. Quando la risposta torna, viene renderizzata solo se è ancora la richiesta più recente. AbortController è l’altro strumento comune: annulla del tutto la richiesta vecchia invece di lasciarla finire e buttare via il risultato, risparmiando anche il round trip di rete.
La versione più sottile non ha nessuna chiamata di rete, solo una variabile condivisa che attraversa un await:
| |
Entrambe le chiamate leggono totalLength come 0 prima che uno dei due await si risolva. Entrambe calcolano il nuovo totale dallo stesso punto di partenza non aggiornato, e la seconda scrittura cancella la prima. La lettura e la scrittura stanno sui due lati opposti di un punto di sospensione, e durante una sospensione può succedere di tutto. Se questo scheduling non ti è ancora chiaro, /it/posts/javascript/async-await-e-event-loop-javascript/ spiega cosa fa l’event loop a ogni await. La correzione: risolvi prima il valore e incorporalo nello stato condiviso in un unico passo sincrono (const len = await getPageLength(n); totalLength += len;), oppure esegui le addizioni in sequenza invece che con Promise.all.
Come previeni le race condition in un database?
Sposta il contatore in una riga e il gap tra check e act si allarga, perché adesso comprende un round trip verso il database. Due application server, ciascuno che esegue “leggi il saldo, verifica, scrivi il nuovo saldo” sulla stessa riga, possono entrambi leggere 150€ ed entrambi approvare un prelievo di 100€. È l’esempio iniziale, a livello SQL.
Due soluzioni, con costi diversi.
Il lock pessimistico prende il lock sulla riga in anticipo. In PostgreSQL è SELECT ... FOR UPDATE:
| |
Una seconda transazione che tenta un SELECT ... FOR UPDATE sulla stessa riga si blocca finché la prima non fa commit o rollback. Corretto, ma ogni richiesta concorrente su quella riga adesso si accoda dietro il lock. Questo costa latenza sotto contesa, e può generare un deadlock se due transazioni bloccano righe in ordine opposto. Tieni la transazione breve: qui la riga viene trovata per chiave primaria, che è una lookup su indice. Filtra su una colonna senza indice e la lookup diventa una scansione, che allunga ogni transazione mentre le altre aspettano (i meccanismi sono in /it/posts/databases/come-funzionano-gli-indici-database/).
Il lock ottimistico salta il lock e verifica un conflitto al momento della scrittura, di solito con una colonna di versione:
| |
Se un’altra transazione ha già aggiornato la riga, version non è più 7, zero righe corrispondono, e la tua applicazione lo nota e riprova con il valore aggiornato. Scala meglio quando i conflitti sono rari. Funziona solo se il controllo della versione e la scrittura avvengono nella stessa istruzione. Leggi la versione, poi esegui un UPDATE separato basato su quella, e hai riaperto il gap che volevi chiudere.
Qual è la differenza tra una race condition e un deadlock?
Entrambi finiscono archiviati come “bug di concorrenza”, e falliscono in modi opposti:
| Race condition | Deadlock | |
|---|---|---|
| Cosa succede | L’esecuzione finisce, con un risultato sbagliato o incoerente | L’esecuzione si blocca; niente finisce |
| Causa | Accesso non sincronizzato a stato condiviso | Due o più lock attesi in un ciclo |
| Come te ne accorgi | Numeri che non quadrano, dati “a volte” sbagliati | Il programma o la richiesta resta bloccato |
| Correzione tipica | Lock, operazioni atomiche, o eliminazione dello stato mutabile condiviso | Ordine consistente dei lock, timeout |
Una correzione per l’una può introdurre l’altra. Avvolgere un bug check-then-act in un lock elimina la race. Se quel codice prende anche un secondo lock, in un ordine diverso da un altro percorso di codice, hai scambiato una corruzione silenziosa dei dati con un blocco totale. Acquisisci sempre i lock nello stesso ordine, ovunque nel codice, e tieni la sezione bloccata il più corta possibile senza sacrificare la correttezza.
Come individui le race condition prima della produzione?
Le race raramente si manifestano in un test single-thread che gira da solo sul tuo laptop. Sono una causa comune di test flaky, il tipo discusso in /it/posts/testing/unit-test-vs-integration-test-vs-test-end-to-end/. Un test che fallisce una volta su cinquanta sotto il carico della CI e passa ogni volta che lo rilanci in locale dovrebbe insospettirti, prima di liquidarlo come “flaky”.
Go ha un detector integrato per questo. Compila o testa con -race e il runtime strumenta gli accessi in memoria, segnalando ogni caso in cui due goroutine toccano la stessa variabile in concorrenza e almeno una delle due scrive.
| |
Rileva solo le race che si verificano davvero durante l’esecuzione, quindi più i tuoi test si avvicinano a un carico concorrente reale, più ne trova. Moltiplica anche l’uso di CPU e memoria, quindi va bene in CI, non nel binario di produzione. Python e Java non hanno niente di integrato che lo eguagli, un motivo in più per fidarsi di lock e operazioni atomiche sul database piuttosto che sperare che i test bastino.
Anche le cache nascondono lo stesso bug. Due richieste che leggono la stessa chiave, trovano un valore vecchio e poi lo sovrascrivono sono di nuovo un check-then-act, un gap di cui il pattern cache-aside in /it/posts/backend/cache-redis-come-funziona/ deve tenere conto.
Prima di ricorrere a un lock, però, chiediti se lo stato mutabile condiviso deve esistere affatto. Un saldo aggiornato solo tramite un’unica istruzione atomica come UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100 non ha nessun gap tra check e act, perché il database fa entrambe le cose in un solo passo. La correzione più economica per una race condition è eliminare il secondo percorso che era in race con il primo, quindi inizia la tua prossima review elencando ogni punto che scrive su quello stato.