Reproducir condiciones de carrera
Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →
Una herramienta sencilla para reproducir algunas condiciones de carrera comunes como interbloqueos en pruebas automatizadas. Siempre que múltiples corrutinas o hilos modifiquen un estado compartido, existe la posibilidad de condiciones de carrera. En muchos casos comunes, esta herramienta permite reproducirlas fácilmente.
Supongamos, por ejemplo, que dos hilos o corrutinas adquieren bloqueos exclusivos sobre los mismos dos recursos en distinto orden:
| Thread 1 | Thread 2 |
|---|---|
| Lock Resource A | Lock Resource B |
| Lock Resource B | Lock Resource A |
Si ejecutamos continuamente estas dos funciones en paralelo, eventualmente deberían producirse interbloqueos, pero no sabemos exactamente cuándo. Con la ayuda de runInParallel podemos reproducir de manera fiable el interbloqueo cada vez que ejecutamos el siguiente código:
runInParallel({ runner: ParallelRunner ->
lockResourceA()
runner.await()
lockResourceB()
},
{ runner: ParallelRunner ->
lockResourceB()
runner.await()
lockResourceA()
}
)
Analicemos ahora algunos escenarios más avanzados donde reproducir condiciones de carrera resulta especialmente útil.
Supongamos, por ejemplo, que el siguiente código se ejecuta concurrentemente sin ninguna sincronización:
if(canRunTask()) {
runTask()
}
Sin concurrencia, este código siempre se ejecutará correctamente. Reproduzcamos la concurrencia de la siguiente manera:
private data class Box(val maxCapacity: Int) {
private val items = mutableListOf<String>()
fun addItem(item: String) = items.add(item)
fun hasCapacity() = items.size < maxCapacity
fun items() = items.toList()
}
(snip)
"two tasks share one mutable state, both make the same decision at the same time" {
val box = Box(maxCapacity = 2)
box.addItem("apple")
runInParallel({ runner: ParallelRunner ->
val hasCapacity = box.hasCapacity()
runner.await()
if(hasCapacity) {
box.addItem("banana")
}
},
{ runner: ParallelRunner ->
val hasCapacity = box.hasCapacity()
runner.await()
if(hasCapacity) {
box.addItem("orange")
}
}
)
// capacity is exceeded as a result of race condition
box.items() shouldContainExactlyInAnyOrder listOf("apple", "banana", "orange")
}
Como otro ejemplo, supongamos que necesitamos reproducir un interbloqueo entre dos hilos que intentan modificar dos tablas Postgres en diferente orden.
| Orders | Items |
|---|---|
| Thread 1 | Thread 2 |
| Lock Order 1 | |
| Lock Item 2 | |
| Lock Item 2 | |
| Lock Order 1 |
Un enfoque de fuerza bruta sería ejecutar este escenario muchas veces, esperando que eventualmente reproduzcamos el interbloqueo. Con el tiempo esto debería funcionar, pero tendríamos que invertir tiempo configurando la prueba y podríamos tener que esperar hasta que se reproduzca.
El método runInParallel de Kotest facilita enormemente esta tarea, reproduciendo el interbloqueo en el primer intento. El siguiente código muestra cómo hacerlo, asumiendo que la función executeSql está implementada y ejecuta SQL.
Ambos hilos realizan lo siguiente:
iniciar una transacción
actualizar una tabla
esperar a que el otro hilo complete su primera actualización
intentar actualizar la otra tabla
Este es un escenario clásico de interbloqueo que se reproduce fiablemente cada vez que ejecutamos este código. Todo el trabajo tedioso de configurar hilos y sincronizarlos lo maneja runInParallel.
// Prerequisites:
executeSql(
"DROP TABLE IF EXISTS test0",
"DROP TABLE IF EXISTS test1",
"SELECT 1 AS id, 'green' AS color INTO test0",
"SELECT 1 AS id, 'yellow' AS color INTO test1",
)
// reproduce a deadlock
var successCount = 0
var thrownExceptions = mutableListOf<Throwable>()
runInParallel(
{ runner ->
try {
executeSql(jdbi, "UPDATE test0 SET color = 'blue' WHERE id = 1")
jdbi.useTransaction<Exception> { handle ->
handle.execute("UPDATE test0 SET color = 'blue' WHERE id = 1")
runner.await() // wait for the other thread to do its thing
handle.execute("UPDATE test1 SET color = 'purple' WHERE id = 1")
successCount++
}
} catch (ex: Throwable) {
thrownExceptions.add(ex)
}
},
{ runner ->
try {
jdbi.useTransaction<Exception> { handle ->
handle.execute("UPDATE test1 SET color = 'blue' WHERE id = 1")
runner.await() // wait for the other thread to do its thing
handle.execute("UPDATE test0 SET color = 'purple' WHERE id = 1")
successCount++
}
} catch (ex: Throwable) {
thrownExceptions.add(ex)
}
}
)
successCount shouldBe 1
thrownExceptions shouldHaveSize 1
isDeadlock(thrownExceptions[0]) shouldBe true
Por último, usemos parallelRunner para demostrar que simular una función estática como LocalDateTime.now() en una prueba puede afectar a pruebas completamente diferentes que se ejecutan en paralelo:
runInParallel(
{ runner: ParallelRunner ->
timedPrint("Before mock on same thread: ${LocalDateTime.now().toString()}")
runner.await()
mockkStatic(LocalDateTime::class)
val localTime = LocalDateTime.of(2022, 4, 27, 12, 34, 56)
every { LocalDateTime.now(any<Clock>()) } returns localTime
runner.await()
timedPrint("After mock on same thread: ${LocalDateTime.now().toString()}")
},
{ runner: ParallelRunner ->
timedPrint("Before mock on other thread: ${LocalDateTime.now().toString()}")
runner.await()
runner.await()
timedPrint("After mock on other thread: ${LocalDateTime.now().toString()}")
}
)
// the output from both threads shows the same mocked output:
Time: 2023-05-12T13:14:07.815923, Thread: 51, Before mock on other thread: 2023-05-12T13:14:07.737748
Time: 2023-05-12T13:14:07.816011, Thread: 50, Before mock on same thread: 2023-05-12T13:14:07.737736
Time: 2022-04-27T12:34:56, Thread: 51, After mock on other thread: 2022-04-27T12:34:56
Time: 2022-04-27T12:34:56, Thread: 50, After mock on same thread: 2022-04-27T12:34:56