Heisenbug — un bug che scompare quando si prova a eseguirne il debug. Il termine deriva dal principio di indeterminazione di Heisenberg: l’osservazione influenza il comportamento del sistema. Nello sviluppo mobile, Heisenbug è uno dei problemi più difficili perché i metodi standard di debug (log, breakpoint, codice aggiuntivo) modificano lo stato del programma e nascondono il bug. Esploriamo le cause e i metodi per affrontare gli errori sfuggenti.
Punti Chiave
Heisenbug — una classe di errori che si manifestano in produzione o durante il normale funzionamento, ma scompaiono quando si tenta di riprodurli in un ambiente di debug. Il termine è stato coniato negli anni ’80 dal programmatore Jim Gray nel contesto dei sistemi distribuiti, ma oggi è più rilevante per le applicazioni mobili a causa della loro natura asincrona.
Il motivo principale: gli strumenti di debug standard modificano l’ambiente di esecuzione. Un breakpoint mette in pausa il thread per diversi millisecondi, il logging aggiunge I/O sincrono, controlli aggiuntivi cambiano l’ordine delle operazioni. In un ambiente multithread, anche un ritardo di microsecondi può alterare l’ordine di esecuzione dei thread e nascondere una race condition.
Secondo Microsoft Research (2022), circa il 15-25% di tutti i bug nelle applicazioni mobili multithread sono classificati come Heisenbug. Allo stesso tempo, il tempo per trovare e correggere un Heisenbug è in media 5-10 volte maggiore rispetto a un bug normale, a causa dell’impossibilità di riproduzione diretta.
Un’app si blocca in produzione durante lo scorrimento rapido di un elenco, ma quando ci si connette al debugger o si aggiungono log — funziona perfettamente. Causa: una race condition tra il thread UI (aggiornamento di RecyclerView) e il thread in background (aggiornamento dei dati dell’adattatore). I log aggiungono un ritardo che sincronizza i thread casualmente.
Bohrbug — un bug prevedibile, stabilmente riproducibile. Chiamato per analogia con il modello atomico di Bohr: come un atomo, il bug si comporta allo stesso modo ogni volta che viene osservato. Esempio: NullPointerException quando si fa clic su un pulsante prima del caricamento dei dati. Trattato con test unitari standard.
Mandelbug — un bug con una relazione causale complessa e caotica (chiamato per analogia con l’insieme di Mandelbrot). Si manifesta solo sotto una certa combinazione di condizioni: versione del sistema operativo, modello del dispositivo, stato della rete. Si differenzia da Heisenbug perché non scompare durante il debug — il problema è la difficoltà di riproduzione, non il cambiamento di comportamento dovuto agli strumenti.
Heisenbug — un bug che scompare precisamente a causa degli strumenti di debug. Se aggiungi un log — il bug scompare. Se metti un breakpoint — il bug non si manifesta. Se rimuovi tutto — il bug torna. La causa principale: timing alterato durante il debug.
| Tipo | Riproducibilità | Reazione al debug | Esempio |
|---|---|---|---|
| Bohrbug | 100% | Invariata | NPE su lista vuota |
| Mandelbug | Caotica | Invariata | Crash su Android 12, Samsung, batteria scarica |
| Heisenbug | Solo senza debug | Scompare | Race condition che scompare con i log |
| Schrödinbug | Non si manifesta nel codice | Appare guardandolo | Bug visibile nel codice ma mai attivato |
Race condition — la causa numero uno di Heisenbug. Due thread accedono a dati condivisi senza sincronizzazione. Il debugger introduce un ritardo, facendo sì che i thread si sincronizzino naturalmente. Senza il debugger, l’ordine di esecuzione è imprevedibile.
Errori dipendenti dal timing — bug che si manifestano solo a una certa velocità di esecuzione. Ad esempio, un’animazione che deve terminare prima dell’inizio dell’operazione successiva. Nel debugger, l’animazione è più lenta e l’operazione ha il tempo di iniziare dopo la fine dell’animazione. In produzione — il contrario.
// Example race condition — typical Heisenbug
class ListViewModel : ViewModel() {
private var items = mutableListOf<String>()
fun loadFromNetwork() {
viewModelScope.launch(Dispatchers.IO) {
val result = api.fetchItems()
items.addAll(result) // ❌ Not thread-safe
}
}
fun getItems(): List<String> = items.toList()
// Race condition: getItems read may overlap with loadFromNetwork write
}
Ottimizzazione del compilatore — il compilatore (JIT, ART, Kotlin/Native) può riordinare le istruzioni per l’ottimizzazione. In una build di debug, le ottimizzazioni sono disabilitate e il codice viene eseguito «come scritto». In una build di release, il compilatore modifica l’ordine delle operazioni, il che può rivelare presupposti nascosti nel codice.
ThreadSanitizer (TSan) — uno strumento di Google per rilevare le race condition in C/C++ e Kotlin/Native. Viene incorporato nella build e rileva qualsiasi accesso alla memoria condivisa senza sincronizzazione. A differenza dei log, TSan non influisce sul timing perché funziona attraverso codice strumentato, non tramite I/O.
Test deterministici — sostituisci l’asincronia reale con asincronia controllata. Usa TestDispatcher (Kotlin), RxJava Plugins o code di test GCD (iOS) per il controllo completo sull’ordine di esecuzione. Specifica scenari concreti: il thread A viene eseguito, poi B, poi di nuovo A.
Logging ciclico — registrazione in un buffer circolare in memoria (non su disco). Quando si verifica il bug, il buffer viene salvato in un file. Poiché scrivere in memoria richiede nanosecondi (invece di millisecondi per I/O su disco), tale registrazione non influisce sul timing e non maschera l’Heisenbug.
class CyclicBuffer(val capacity: Int = 1000) {
private val buffer = ArrayDeque<String>(capacity)
private val lock = Any()
fun log(message: String) {
synchronized(lock) {
if (buffer.size >= capacity) buffer.removeFirst()
buffer.addLast(message)
}
}
fun flush() {
synchronized(lock) { buffer.forEach { fileWriter.write(it) } }
}
}
Logging in produzione — se il bug non è riproducibile localmente, raccogli dati in produzione. Usa log di Firebase Crashlytics, Sentry Breadcrumbs o un logger ciclico personalizzato. Importante: il logging deve essere asincrono e avere un impatto minimo sulle prestazioni.
Isolamento dello stato — minimizza lo stato mutabile condiviso. Ogni componente dovrebbe avere il proprio stato isolato, inaccessibile per scrittura diretta da altri componenti. Usa Unidirectional Data Flow (UDF) — lo stato scorre in una direzione: Evento → Riduttore → Stato → UI.
Approccio funzionale — le funzioni pure senza effetti collaterali sono più facili da testare e debuggare. Isola gli effetti collaterali (rete, DB, file) in layer strettamente definiti (repository, data source). Gli errori di threading nel codice funzionale sono praticamente impossibili.
Modalità rigorosa — attiva Android StrictMode nella build di debug. Rileva violazioni della politica di threading (rete sul thread principale, I/O su disco sul thread principale) e lancia un’eccezione. Questo trasforma un potenziale Heisenbug in un Bohrbug deterministico immediatamente visibile.
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
Revisione del codice con focus sull’asincronia — una parte obbligatoria del processo. Ogni pull request dovrebbe essere verificata per stato mutabile condiviso, collezioni non thread-safe e mancanza di sincronizzazione. Usa regole lint per vietare automaticamente determinati pattern (ad esempio, accesso a MutableList senza synchronized).
Domande Frequenti
Perché i metodi standard — breakpoint, log, print — modificano l’ambiente di esecuzione al punto che il bug smette di manifestarsi. Il debugger mette in pausa tutti i thread per decine di millisecondi. Durante questo tempo, la race condition che causava il bug si risolve naturalmente. Sono necessari strumenti che non influenzino il timing di esecuzione.
Mandelbug è difficile da riprodurre a causa della complessità delle condizioni, ma gli strumenti di debug non influenzano la sua manifestazione. Heisenbug invece scompare proprio a causa degli strumenti di debug. Esempio di Mandelbug: un crash solo su dispositivi con Android 11, 3 GB di RAM e livello della batteria inferiore al 15%. Esempio di Heisenbug: una race condition che scompare aggiungendo Log.d().
Usa il rilevamento di test flaky — test che a volte falliscono, a volte passano. In Android, usa Android Test Orchestrator per isolare i test. Aggiungi StrictMode ai test di debug. Strumenta la build con ThreadSanitizer. Se un test è flaky >5% delle esecuzioni — consideralo un potenziale Heisenbug e indaga prima del merge.
Parzialmente. Flow e la concorrenza strutturata in Kotlin riducono la quantità di stato mutabile condiviso e semplificano la gestione dei thread. Ma le coroutine non garantiscono la thread safety: se due coroutine condividono lo stato, una race condition è ancora possibile. Usa Mutex per proteggere lo stato condiviso o Channel per passare dati tra coroutine.
Usa un buffer di log ciclico in memoria con svuotamento automatico in caso di errore. Aggiungi monitoraggio dettagliato tramite Crashlytics o Sentry con breadcrumbs personalizzati. Per Android, attiva il rilevamento ANR e controlla i trace. Se il bug è una race condition, ThreadSanitizer in una build di debug con carico simile alla produzione può rivelare il problema.
Riepilogo
Svilupperemo un'applicazione mobile chiavi in mano
IT Sectr crea applicazioni iOS e Android per startup e aziende dal 2017. Ti consulteremo e ti proporremo la soluzione migliore.
Leggi anche