Conocimientos Previos sobre Corrutinas
Procesos e Hilos
Definiciones Básicas
Proceso: Una unidad independiente de ejecución que el sistema operativo gestiona para la asignación de recursos. Representa la entidad mínima de asignación de recursos en un entorno de CPU de núcleo único, donde solo un programa se ejecuta a la vez en memoria.
Hilo: La unidad básica de ejecución de la CPU, compuesta por un ID de hilo, contador de programa, conjunto de registros y pila. Los hilos reducen la sobrecarga de la ejecución concurrente y mejoran el rendimiento del sistema.
Razón de los Hilos
- Un proceso individual ejecuta tareas de manera serial; un bloqueo en una tarea detiene todo el proceso.
- La memoria entre procesos no es compartida, complicando la comunicación interprocesos.
Diferencias entre Procesos e Hilos
- Un proceso contiene al menos un hilo, actuando como contenedor.
- Los procesos tienen memoria independiente, mientras que los hilos dentro de un proceso comparten memoria.
- Los procesos pueden escalarse a múltiples máquinas; los hilos se limitan a múltiples núcleos.
- Cada hilo tiene un punto de entrada y salida, pero depende de la aplicación para su control.
- Los procesos son la unidad mínima de asignación de recursos; los hilos son la unidad mínima de programación de la CPU.
- Tanto procesos como hilos describen períodos de trabajo de la CPU, variando en granularidad.
Cooperativo vs. Preemptivo
Modelo Cooperativo
Sistemas operativos tepmranos usaban multitarea cooperativa, donde los procesos ceden voluntariamente el control, por ejemplo, al esperar operaciones de E/S. Problemas incluyen aplicaciones que monopolizan la CPU o errores que paralizan el sistema.
Modelo Preemptivo
El sistema operativo asigna tiempos de ejecución, forzando a los procesos a ceder el control cuando es necesario, lo que introduce desafíos de seguridad en hilos, solucionados comúnmente con mecanismos de bloqueo.
Corrutinas
Lenguajes como Go y Python implementan corrutinas a nivel de lenguaje. En Kotlin, las corrutinas abordan la conmutación de hilos en tareas asíncronas. Son ligeras, basadas en hilos pero con control de usuario sobre la programación, reduciendo la sobrecarga de conmutación de contexto. A diferencia de los hilos, las corrutinas se suspenden y reanudan de manera cooperativa.
En Kotlin, las corrutinas se construyen sobre el pool de hilos de Java, enfocándose en simplificar código asíncrono concurrente.
Uso Básico de Corrutinas en Kotlin
Consideremos una tarea que ejecuta operaciones costosas en un hilo de trabajo y procesa resultados en el hilo principal. Con corrutinas:
ambitoCorrutinas.lanza(Dispatchers.Main) {
val resultado = conContexto(Dispatchers.Default) {
ejecutarOperacionLarga()
}
procesarResultado(resultado)
}
Nota: Dispatchers.Main es específico de Android; en aplicaciones Java, podría causar excepciones.
Métodos para Crear Corrutinas
1. Usando la función de nivel superior runBlocking (bloquea el hilo, adecuado para pruebas):
runBlocking {
// código de corrutina
}
2. Usando el objeto singleton GlobalScope (no recomendado en Android por problemas de ciclo de vida):
GlobalScope.lanza {
// código de corrutina
}
3. Creando un CoroutineScope personalizado con un contexto (recomendado para control de ciclo de vida):
val ambito = CoroutineScope(contexto)
ambito.lanza {
// código de corrutina
}
Esperar un Trabajo
Al lanzar una corrutina, se puede esperar su finalización sin bloquear el hilo usando join:
fun main() = runBlocking {
val trabajo = lanza {
retardo(100)
println("hola")
retardo(300)
println("mundo")
}
println("prueba1")
trabajo.join()
println("prueba2")
}
Salida: prueba1, hola, mundo, prueba2.
Cancelación de Corrutinas
Las corrutinas soportan cancelación cooperativa mediante cancel() o cancelAndJoin(). Ejemplo:
fun main() = runBlocking {
val trabajo = lanza {
repetir(1000) { i ->
println("trabajo: test $i ...")
retardo(500L)
}
}
retardo(1300L)
println("principal: listo para cancelar!")
trabajo.cancel()
trabajo.join()
println("principal: Cancelado.")
}
Para tareas computacionales, se debe verificar periódicamente el estado activo con isActive, ensureActive() o yield().
Esperar Resultados con Async
Para corrutinas con valor de retorno, usar async y await:
val diferido = async {
// cálculo
}
val resultado = diferido.await()
Manejo de Excepciones
Las excepciones CancellationException pueden capturarse para limpieza en bloques try/catch/finally.
Timeout en Corrutinas
Usar withTimeout para establecer límites de tiempo, o withTimeoutOrNull para retornar nulo en caso de exceder el límite.
fun main() = runBlocking {
val resultado = withTimeoutOrNull(300) {
println("inicio...")
retardo(100)
println("progreso 1...")
retardo(100)
println("progreso 2...")
println("fin")
"completado"
}
println("Resultado: $resultado")
}
Concurrencia y Funciones de Suspensión
Uso de Async para Concurrencia
Para ejecutar múltiples tareas en paralelo y combinar resultados:
fun main() = runBlocking {
val tiempo = mideTiempoMilisegundos {
val a = async(Dispatchers.IO) {
imprimeConInfoHilo()
retardo(1000)
1
}
val b = async(Dispatchers.IO) {
imprimeConInfoHilo()
retardo(2000)
2
}
imprimeConInfoHilo("${a.await() + b.await()}")
imprimeConInfoHilo("fin")
}
imprimeConInfoHilo("tiempo: $tiempo")
}
Async Perezoso
Configurar async con CoroutineStart.LAZY para iniciar bajo demanda, usando start() o await().
Funciones de Suspensión
Las funciones marcadas con suspend solo pueden llamarse desde corrutinas u otras funciones de suspensión, permitiendo la conmutación de hilos.
fun calcularValor(): Int {
// error: no se puede llamar a retardo sin suspend
retardo(1000)
return 1
}
suspend fun calcularValorConSuspension(): Int {
conContexto(Dispatchers.IO) {
imprimeConInfoHilo()
}
return 1
}
Esencia de Corrutinas y Suspensión
Las corrutinas son bloques de código que pueden suspenderse y reanudarse, gestionando la conmutación de hilos de manera eficiente.
La suspensión implica desvincular la corrutina del hilo actual, permitiendo que se ejecute en otro contexto según el despachador, y luego regresar al hilo original.
Implementación de Funciones de Suspensión
Para definir funciones de suspensión, usar suspend e incorporar operaciones de suspensión como withContext o retardo.
suspend fun operacionLarga() = conContexto(Dispatchers.IO) {
// operaciones de E/S
}
suspend fun operacionConRetardo() {
retardo(1000)
}
Contexto y Ámbito de Corrutinas
Contexto de Corrutina (CoroutineContext)
El contexto incluye elementos como Job, despachador, nombre e ID. Se accede mediante corchetes, y permite operaciones con el operador + para combinar elementos.
lanza(Dispatchers.Default + CoroutineName("prueba")) {
println("Ejecutando en hilo ${Thread.currentThread().name}")
}
Ámbito de Corrutina (CoroutineScope)
Define el ciclo de vida de las corrutinas hijas, gestionando cancelación y herencia de contexto. Los ámbitos anidados propagan cancelaciones y excepciones.
Creación de CoroutineScope
val despachador = Executors.newFixedThreadPool(1).asCoroutineDispatcher()
val ambito = CoroutineScope(despachador)
ambito.lanza {
// código
}
SupervisorJob
Para aislar fallos en corrutinas hijas, usar SupervisorJob evita que una excepción en una hija cancele todo el ámbito.
val trabajoSupervisor = SupervisorJob()
val ambitoSupervisor = CoroutineScope(trabajoSupervisor + despachador)
Uso en Android
Ámbito Personalizado
Implementar CoroutineScope en actividades o definir ámbitos como MainScope, cancelando en onDestroy.
class MiActividad : AppCompatActivity(), CoroutineScope by MainScope() {
override fun onDestroy() {
cancel()
super.onDestroy()
}
}
ViewModelScope
Usar la extensión viewModelScope de Android KTX para gestionar corrutinas en ViewModels.
class MiViewModel : ViewModel() {
fun cargarDatos() {
viewModelScope.lanza {
val datos = conContexto(Dispatchers.IO) { obtenerDatos() }
// actualizar UI
}
}
}
LifecycleScope
Extensión para componentes con ciclo de vida, cancelando automáticamente al destruir el componente.
Sincronización de Datos en Concurrencia
Problemas de Seguridad en Hilos
Ejemplo clásico de visibilidad sin volatile:
var bandera = true
fun main() {
Thread {
Thread.sleep(1000)
bandera = false
}.start()
while (bandera) {
}
}
La variable volatile garantiza visibilidad pero no atomicidad.
Sincronización en Corrutinas
Problemas similares ocurren en corrutinas concurrentes; soluciones incluyen:
1. Estructuras de Datos Seguras
class Contador {
private var cuenta = AtomicInteger()
suspend fun incrementar() = conContexto(Dispatchers.IO) {
repetir(100) {
lanza {
repetir(1000) {
cuenta.incrementAndGet()
}
}
}
}
}
2. Operaciones Síncronas
Usar synchronized, ReentrantLock o Mutex (suspendible) para proteger secciones críticas.
class Contador {
private val mutex = Mutex()
private var cuenta = 0
suspend fun incrementar() = conContexto(Dispatchers.IO) {
repetir(100) {
lanza {
repetir(1000) {
mutex.conBloqueo {
cuenta++
}
}
}
}
}
}
3. Restricción de Hilos
Forzar operaciones en un hilo único para evitar condiciones de carrera.
class Contador {
private val contextoUnico = newSingleThreadContext("Contador")
private var cuenta = 0
suspend fun incrementar() = conContexto(contextoUnico) {
repetir(100) {
lanza {
repetir(1000) {
cuenta++
}
}
}
}
}
4. Uso de Actores
Actores encapsulan estado y procesan mensajes de forma secuencial, evitando bloqueos.
sealed class MensajeContador
object Incrementar : MensajeContador()
class ObtenerContador(val respuesta: CompletableDeferred<int>) : MensajeContador()
fun CoroutineScope.actorContador() = actor<mensajecontador> {
var contador = 0
for (mensaje in canal) {
when (mensaje) {
is Incrementar -> contador++
is ObtenerContador -> mensaje.respuesta.complete(contador)
}
}
}
</mensajecontador></int>