Heisenbug — un error que desaparece al intentar depurarlo. El término proviene del principio de incertidumbre de Heisenberg: la observación afecta el comportamiento del sistema. En el desarrollo móvil, Heisenbug es uno de los problemas más difíciles porque los métodos estándar de depuración (logs, breakpoints, código adicional) cambian el estado del programa y ocultan el error. Analicemos las causas y los métodos para combatir errores esquivos.
Puntos Clave
Heisenbug — una clase de errores que se manifiestan en producción o durante el funcionamiento normal, pero desaparecen al intentar reproducirlos en un entorno de depuración. El término fue acuñado en la década de 1980 por el programador Jim Gray en el contexto de sistemas distribuidos, pero hoy es más relevante para aplicaciones móviles debido a su naturaleza asíncrona.
La razón principal: las herramientas de depuración estándar cambian el entorno de ejecución. Un breakpoint pausa el hilo durante varios milisegundos, el logging añade E/S síncrona, las comprobaciones adicionales cambian el orden de las operaciones. En un entorno multihilo, incluso un microsegundo de retraso puede alterar el orden de ejecución de los hilos y ocultar una carrera de datos.
Según Microsoft Research (2022), alrededor del 15-25% de todos los errores en aplicaciones móviles multihilo se clasifican como Heisenbug. Al mismo tiempo, el tiempo para encontrar y corregir un Heisenbug es en promedio de 5 a 10 veces mayor que para un error normal, debido a la imposibilidad de reproducirlo directamente.
Una aplicación falla en producción al deslizar rápidamente una lista, pero al conectarse al depurador o agregar logs — funciona perfectamente. Causa: una carrera de datos entre el hilo de UI (actualizando RecyclerView) y el hilo de fondo (actualizando los datos del adaptador). Los logs agregan un retraso que sincroniza los hilos aleatoriamente.
Bohrbug — un error predecible, establemente reproducible. Nombrado por analogía con el modelo atómico de Bohr: como un átomo, el error se comporta igual cada vez que se observa. Ejemplo: NullPointerException al hacer clic en un botón antes de que se carguen los datos. Se trata con pruebas unitarias estándar.
Mandelbug — un error con una relación causal compleja y caótica (nombrado por analogía con el conjunto de Mandelbrot). Se manifiesta solo bajo una cierta combinación de condiciones: versión del SO, modelo de dispositivo, estado de la red, fase lunar. Se diferencia de Heisenbug en que no desaparece durante la depuración — el problema es la dificultad de reproducción, no el cambio de comportamiento por las herramientas.
Heisenbug — un error que desaparece precisamente por las herramientas de depuración. Si añades un log — el error desaparece. Si pones un breakpoint — el error no se manifiesta. Si eliminas todo — el error regresa. La causa principal: alteración del timing durante la depuración.
| Tipo | Reproducibilidad | Reacción a la depuración | Ejemplo |
|---|---|---|---|
| Bohrbug | 100% | Sin cambios | NPE en lista vacía |
| Mandelbug | Caótica | Sin cambios | Fallo en Android 12, Samsung, batería baja |
| Heisenbug | Solo sin depuración | Desaparece | Race condition que desaparece con logs |
| Schrödinbug | No se manifiesta en código | Aparece al mirarlo | Error visible en código pero nunca se activa |
Race condition — la causa número uno de Heisenbug. Dos hilos acceden a datos compartidos sin sincronización. El depurador introduce un retraso, lo que hace que los hilos se sincronicen naturalmente. Sin el depurador, el orden de ejecución es impredecible.
Errores dependientes del timing — errores que se manifiestan solo a una cierta velocidad de ejecución. Por ejemplo, una animación que debe terminar antes de que comience la siguiente operación. En el depurador, la animación va más lenta y la operación tiene tiempo de comenzar después de que termine la animación. En producción — al revés.
// 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
}
Optimización del compilador — el compilador (JIT, ART, Kotlin/Native) puede reordenar instrucciones para optimizar. En una compilación de depuración (debug build), las optimizaciones están desactivadas y el código se ejecuta «tal como está escrito». En una compilación de lanzamiento (release build), el compilador cambia el orden de las operaciones, lo que puede revelar suposiciones ocultas en el código.
ThreadSanitizer (TSan) — una herramienta de Google para detectar carreras de datos en C/C++ y Kotlin/Native. Se integra en la compilación y detecta cualquier acceso a memoria compartida sin sincronización. A diferencia de los logs, TSan no afecta el timing porque funciona a través de código instrumentado, no de E/S.
Pruebas deterministas — reemplace la asincronía real por asincronía controlada. Use TestDispatcher (Kotlin), RxJava Plugins o colas de prueba GCD (iOS) para tener control total sobre el orden de ejecución. Especifique escenarios concretos: el hilo A se ejecuta, luego B, luego A nuevamente.
Logging cíclico — registro en un búfer circular en memoria (no en disco). Cuando ocurre el error, el búfer se guarda en un archivo. Dado que escribir en memoria toma nanosegundos (en lugar de milisegundos para E/S de disco), dicho registro no afecta el timing ni enmascara el 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 en producción — si el error no se reproduce localmente, recolecte datos en producción. Use Firebase Crashlytics logs, Sentry Breadcrumbs o un registrador cíclico personalizado. Importante: el logging debe ser asíncrono y tener un mínimo impacto en el rendimiento.
Aislamiento de estado — minimice el estado mutable compartido. Cada componente debe tener su propio estado aislado, inaccesible para escritura directa desde otros componentes. Use Unidirectional Data Flow (UDF) — el estado fluye en una dirección: Evento → Reductor → Estado → UI.
Enfoque funcional — las funciones puras sin efectos secundarios son más fáciles de probar y depurar. Aisle los efectos secundarios (red, BD, archivos) en capas estrictamente definidas (repositorio, fuente de datos). Los errores de concurrencia en código funcional son prácticamente imposibles.
Modo estricto — active Android StrictMode en la compilación de depuración. Detecta violaciones de la política de hilos (red en el hilo principal, E/S de disco en el hilo principal) y lanza una excepción. Esto convierte un posible Heisenbug en un Bohrbug determinista que es visible de inmediato.
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
Revisión de código con enfoque en asincronía — una parte obligatoria del proceso. Cada pull request debe verificarse en busca de estado mutable compartido, colecciones no seguras para hilos y falta de sincronización. Use reglas lint para prohibir automáticamente ciertos patrones (por ejemplo, acceder a MutableList sin synchronized).
Preguntas Frecuentes
Porque los métodos estándar — breakpoints, logs, print — cambian tanto el entorno de ejecución que el error deja de manifestarse. El depurador pausa todos los hilos durante decenas de milisegundos. Durante este tiempo, la carrera de datos que causó el error se resuelve naturalmente. Se necesitan herramientas que no afecten el timing de ejecución.
Mandelbug es difícil de reproducir debido a la complejidad de las condiciones, pero las herramientas de depuración no afectan su manifestación. Heisenbug, en cambio, desaparece precisamente por las herramientas de depuración. Ejemplo de Mandelbug: un fallo solo en dispositivos con Android 11, 3 GB de RAM y nivel de batería inferior al 15%. Ejemplo de Heisenbug: una carrera de datos que desaparece al añadir Log.d().
Use detección de pruebas flaky — pruebas que a veces fallan, a veces pasan. En Android, use Android Test Orchestrator para aislar pruebas. Agregue StrictMode a las pruebas de depuración. Instrumente la compilación con ThreadSanitizer. Si una prueba es flaky >5% de las ejecuciones — considérela un posible Heisenbug e investigue antes de fusionar.
Parcialmente. Flow y la concurrencia estructurada en Kotlin reducen la cantidad de estado mutable compartido y simplifican la gestión de hilos. Pero las corrutinas no garantizan la seguridad en hilos: si dos corrutinas comparten estado, aún es posible una carrera de datos. Use Mutex para proteger el estado compartido o Channel para pasar datos entre corrutinas.
Use un búfer de registro cíclico en memoria con vaciado automático en caso de error. Agregue monitoreo detallado a través de Crashlytics o Sentry con breadcrumbs personalizados. Para Android, active la detección de ANR y revise los traces. Si el error es una carrera de datos, ThreadSanitizer en una compilación de depuración con carga similar a producción puede revelar el problema.
Resumen
Desarrollaremos una aplicación móvil llave en mano
IT Sectr crea aplicaciones para iOS y Android para startups y empresas desde 2017. Le asesoraremos y le propondremos la mejor solución.
Lea también