¿Qué es una condición de carrera y cuál es tu proceso real para encontrar una en un código?
Las condiciones de carrera casi nunca se anuncian con un stack trace: aparecen como un ticket de soporte que dice "a veces el contador está mal" y desaparecen en cuanto conectas un depurador.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es una condición de carrera y cuál es tu proceso real para encontrar una en un código?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Definiendo el término con precisión
Una condición de carrera es cualquier situación en la que la corrección de un programa depende del orden temporal o del entrelazado de operaciones concurrentes. Una data race (carrera de datos) es el subconjunto más estrecho y formalmente definido de eso: dos o más hilos acceden a la misma ubicación de memoria de forma concurrente, al menos uno de esos accesos es una escritura, y no existe sincronización que establezca una relación de "happens-before" entre ellos. Toda data race es una condición de carrera, pero no toda condición de carrera es una data race: un bug clásico de "verificar y luego actuar" contra una fila de base de datos (comprobar que un nombre de usuario es único y luego insertarlo, en dos consultas separadas sin una restricción de unicidad) es una condición de carrera que no involucra memoria compartida en absoluto.
Se pregunta esto específicamente porque una buena respuesta necesita dos mitades: una definición correcta, y un proceso de investigación real y repetible, no solo "le pondría un mutex".
#Por qué son difíciles de encontrar
Las condiciones de carrera son famosamente intermitentes, y hay una razón mecánica para ello: solo se manifiestan cuando ocurre un entrelazado específico, a menudo raro, de instrucciones. Cualquier cosa que perturbe el timing —agregar un log, conectar un depurador, correr bajo menos carga, un CPU distinto con latencias de caché diferentes— puede desplazar la planificación lo suficiente como para que el entrelazado defectuoso deje de ocurrir. Por eso se les apoda "heisenbugs", y por eso "no se reproduce cuando conecto el depurador" es un síntoma, no evidencia de que el bug desapareció.
#Un proceso real, no una adivinanza
- Tomar el síntoma en serio como pista de planificación. Fallas intermitentes, fallas que empeoran bajo carga o en máquinas multi-núcleo, y contadores/totales que ocasionalmente están "un poco mal" son la firma de una carrera, a diferencia de un bug de lógica determinista que falla siempre de la misma forma.
- Encontrar el estado mutable compartido. Buscar campos, variables a nivel de módulo, cachés o mapas que se escriben desde más de un hilo o goroutine, y cualquier secuencia de "leer, decidir y luego escribir" — eso incluye recursos que no son memoria, como una fila en una base de datos o un archivo en disco, no solo variables dentro del proceso.
- Recurrir a herramientas en vez de mirar fijamente el código. Este es el paso que la gente se salta, y es el que realmente distingue a un ingeniero senior aquí:
- Go trae un detector de carreras integrado directamente en el toolchain:
go run -raceogo test -race. Instrumenta cada acceso a memoria en tiempo de compilación y usa un algoritmo de happens-before (de la misma familia que ThreadSanitizer) para señalar una carrera en el instante en que ocurre durante esa ejecución, no solo cuando llega a corromper una salida que notas. - En la JVM no hay una sola bandera que encuentre todas las carreras, pero sí una caja de herramientas real: volcados de hilos (
jstack) tomados bajo carga para ver dónde están compitiendo realmente los hilos, frameworks de stress-testing hechos a propósito para bugs de visibilidad de memoria (JCStress, del mismo equipo que mantiene las utilidades de concurrencia de la propia JVM), y analizadores estáticos (Error Prone, los detectores de concurrencia de SpotBugs) que señalan un campo protegido por un lock en un método pero accedido sin él en otro. - En C/C++, ThreadSanitizer instrumenta el binario directamente y es el equivalente nativo más cercano al
-racede Go.
- Go trae un detector de carreras integrado directamente en el toolchain:
- Escribir una prueba que fuerce el entrelazado, no solo volver a correr la app. Una prueba de estrés que lanza decenas de goroutines/hilos golpeando la misma ruta de código exacta en un bucle ajustado aumenta drásticamente las probabilidades de que el entrelazado problemático realmente ocurra durante la corrida que estas herramientas están observando: una herramienta solo reporta una carrera que ocurre, no una que teóricamente podría ocurrir.
- Arreglarlo en el nivel correcto. Un mutex alrededor de la sección crítica correcta más pequeña es la respuesta por defecto, pero no la única: una operación atómica basta para un solo contador, la inmutabilidad elimina la carrera eliminando la mutación por completo, y a veces el arreglo real es arquitectónico: darle a una sola goroutine la propiedad exclusiva del dato y comunicarse con ella por un canal en vez de compartir memoria del todo.
#Un ejemplo concreto
package mainimport ("fmt""sync")// Con carrera: 1000 goroutines mutan counter sin ninguna sincronización.func racyIncrement() int {counter := 0var wg sync.WaitGroupfor i := 0; i < 1000; i++ {wg.Add(1)go func() {defer wg.Done()counter++ // leer, sumar, escribir - tres pasos separados, no atómico}()}wg.Wait()return counter // frecuentemente imprime menos de 1000}// Arreglado: un mutex protege el read-modify-write como un solo paso atómico.func safeIncrementMutex() int {counter := 0var mu sync.Mutexvar wg sync.WaitGroupfor i := 0; i < 1000; i++ {wg.Add(1)go func() {defer wg.Done()mu.Lock()counter++mu.Unlock()}()}wg.Wait()return counter}func main() {fmt.Println("con carrera:", racyIncrement())fmt.Println("seguro:", safeIncrementMutex())}
counter++ parece una sola operación pero en realidad son tres: leer el valor actual, sumarle uno, y guardarlo de vuelta. Si la goroutine A y la goroutine B leen el valor 5 antes de que ninguna haya guardado su resultado, ambas calculan 6, y un incremento se pierde silenciosamente. Correr go run -race main.go contra la primera función reporta la carrera de inmediato, nombrando ambas goroutines y la línea exacta del acceso en conflicto, sin necesidad de adivinar. Para un contador único como este, AddInt64 de sync/atomic también lo arreglaría, con menos overhead que un mutex.
#Modos de falla relacionados pero distintos
| Síntoma | Causa raíz | Arreglo típico |
|---|---|---|
| Condición de carrera | acceso no sincronizado a estado mutable compartido, o un hueco de verificar-y-actuar | mutex, operación atómica, inmutabilidad, o evitar compartir el estado del todo |
| Deadlock | los hilos adquieren los mismos locks en órdenes distintos y se esperan mutuamente para siempre | orden consistente de locks, timeouts, o un diseño sin locks |
| Livelock | los hilos siguen reaccionando a los cambios de estado del otro y nunca progresan | backoff aleatorio, desempate por prioridad |
#Ideas clave
- Una data race requiere acceso concurrente, al menos una escritura, y ninguna relación happens-before - pero el término más amplio "condición de carrera" también cubre bugs de orden sin memoria compartida alguna, como verificar-y-actuar contra una fila de base de datos.
- Trata las fallas intermitentes y dependientes de la carga como una pista de planificación, no como ruido.
- Usa herramientas hechas para esto (
-race, ThreadSanitizer, JCStress, volcados de hilos) en vez de intentar razonar tu camino hacia una carrera leyendo código bajo un depurador, lo cual cambia el mismísimo timing que intentas observar.
🧠 Check your understanding
0/2 · 0/2 answered1. ¿Por qué agregar un print o conectar un depurador a veces hace que una condición de carrera desaparezca?
2. ¿Qué hace realmente el detector de carreras de Go (la bandera -race)?