Concurrencia y memoria en Kotlin/Native
Al terminar: Ver perder goles a mil corrutinas, arreglarlo de cuatro maneras y entender el recolector de Native y su convivencia con ARC de Swift.
Casi todo lo que leas por internet sobre la memoria de Kotlin/Native está obsoleto: antes había que «congelar» (freeze()) los objetos para pasarlos entre hilos, y hoy ya no. Kotlin/Native es el compilador que produce código nativo para iOS, sin JVM.
Esta lección es el estado real en Kotlin 2.4.20, hecho con tu propio :torneo: escribirás seis tests que se ejecutan en la JVM y en el simulador de iOS, verás perder goles a mil corrutinas que comparten un contador y lo arreglarás de cuatro maneras. Después, las costuras con Swift que sí siguen mordiendo. Los pasos que usan el simulador necesitan un Mac con Xcode; los tests de la JVM valen en cualquier sistema.
No instalas nada nuevo: Mutex viene en kotlinx-coroutines-core, que :torneo ya declara, y AtomicInt y @Volatile vienen en la biblioteca estándar de Kotlin.
Paso 1 · Cómo funciona hoy
El montículo (heap) es la zona de memoria donde viven los objetos. Es decir: funciona como esperas de cualquier lenguaje con recolector de basura, como Java en su máquina virtual (la JVM, donde corre también Kotlin en Android). Un objeto se crea, se pasa entre hilos, y el recolector (el servicio que libera los objetos que ya nadie usa) lo limpia cuando deja de ser alcanzable.
Paso 2 · Lo que ya no tienes que hacer
Si ves cualquiera de estas cosas en un ejemplo, el ejemplo es de otra época:
| Lo que verás en tutoriales viejos | Hoy |
|---|---|
objeto.freeze() | No hace falta |
@SharedImmutable | Eliminado |
AtomicReference para compartir | Una variable normal |
InvalidMutabilityException | Ya no existe |
Worker.execute con subgrafos aislados | Corrutinas normales |
Que compartir sea posible no quiere decir que sea seguro. Eso lo vas a ver con tus ojos en el paso siguiente.
Paso 3 · Mil corrutinas, un contador
El fichero. Vas a crear un fichero de tests en :torneo, en la carpeta de los tests comunes (commonTest): los que corren en todos los destinos. Es la misma carpeta donde ya viven los tests que migraste en Migrar un proyecto Android existente.
Con el selector del panel del proyecto en la vista Android (si lo dejaste en Project, vuélvelo a Android), haz clic derecho sobre el nodo torneo → New → File y escribe la ruta completa con el nombre del fichero. Este es el método para crear ficheros dentro de un source set del módulo, y lo repetirás en el resto del apéndice:
src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/ConcurrenciaTest.ktAndroid Studio crea las carpetas que falten y abre el fichero vacío. Si tu versión no te deja escribir una ruta con barras, hazlo desde el Terminal de Android Studio (pestaña abajo), en la carpeta de tu proyecto: mkdir -p crea una carpeta con todas las intermedias y touch crea un fichero vacío. Después ábrelo desde el panel (si no aparece, pulsa Sync Now):
mkdir -p torneo/src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo
touch torneo/src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/ConcurrenciaTest.ktEscribe esto, que todavía no tiene ninguna protección:
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
// Un contador de goles: una clase con una sola propiedad que se puede cambiar.
class ContadorDeGoles {
var total = 0
}
class ConcurrenciaTest {
// Un test escrito con runBlocking (como los de tu módulo): espera a que acaben todas las corrutinas.
@Test
fun milCorrutinasMarcanMilGolesCadaUna() = runBlocking {
val contador = ContadorDeGoles()
// Lanza 1000 corrutinas en Dispatchers.Default: un grupo de hilos que corren a la vez en varios núcleos.
List(1000) {
async(Dispatchers.Default) {
// Cada una suma 1000 goles al MISMO contador.
repeat(1000) { contador.total++ }
}
}.awaitAll() // espera a que terminen las 1000
// Mil corrutinas por mil goles: tendría que salir exactamente un millón.
assertEquals(1_000_000, contador.total)
}
}Ejecútalo. En el Terminal de Android Studio (pestaña Terminal, abajo), desde la carpeta de tu proyecto:
./gradlew :torneo:jvmTest --tests "*ConcurrenciaTest*"El test falla, y es lo que se quería. Al final de la salida, Gradle imprime, tras el aviso de que hay tests fallidos, una línea con la ruta de un informe (empieza por file://). Abre ese enlace (Cmd+clic en el Terminal) y verás un mensaje de este estilo, con una cifra distinta cada vez:
java.lang.AssertionError: expected:<1000000> but was:<642532>Si tienes un Mac con Xcode, ejecútalo también en el simulador de iOS: ./gradlew :torneo:iosSimulatorArm64Test --tests "*ConcurrenciaTest*". Falla igual, con Expected <1000000>, actual <761576> (otra cifra, por supuesto). En Kotlin/Native pasa exactamente lo mismo que en la JVM. No hay excepción, no hay congelación: simplemente se pierden goles.
El concepto. contador.total++ parece una operación, pero son tres: leer el valor, sumarle uno, escribirlo. Si dos hilos leen el mismo 5 a la vez, los dos escriben 6 y un gol desaparece. Eso se llama data race (carrera de datos), y es el mismo fallo del que hablaba Actores en Swift. Lo que cambió con el gestor de memoria moderno es que ya no hay excepción que te avise: el programa sigue y da una cifra equivocada.
Hay cuatro formas de arreglarlo. Las verás una por una, cada una en un test nuevo, y todas pasan en la JVM y en iOS.
Arreglo 1 · Mutex: un cerrojo
Un Mutex es un cerrojo: solo una corrutina a la vez puede estar dentro de withLock { … }; las demás se suspenden (esperan sin bloquear el hilo) hasta que sale. En Swift es lo que hacía el Mutex de Synchronization.
En ConcurrenciaTest.kt, borra el test que falla, milCorrutinasMarcanMilGolesCadaUna (la clase ContadorDeGoles se queda donde está) y escribe en su lugar este test, dentro de la clase ConcurrenciaTest, con los dos imports nuevos arriba del fichero:
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class ConcurrenciaTest {
@Test
fun conMutexNoSePierdeNinguno() = runBlocking {
val contador = ContadorDeGoles()
// El cerrojo: uno solo, compartido por todas las corrutinas.
val candado = Mutex()
List(1000) {
async(Dispatchers.Default) {
// Solo una corrutina a la vez ejecuta lo de dentro de las llaves.
repeat(1000) { candado.withLock { contador.total++ } }
}
}.awaitAll()
assertEquals(1_000_000, contador.total)
}
}Vuelve a ejecutar ./gradlew :torneo:jvmTest --tests "*ConcurrenciaTest*": ahora pasa. El Mutex es el arreglo más general: sirve para cualquier estado mutable, sea un contador, una lista o un objeto entero.
Arreglo 2 · un contador atómico
Para un simple contador hay algo más ligero: un valor atómico, cuya suma es una sola operación indivisible, sin cerrojo. Añade este test dentro de la clase ConcurrenciaTest (los import van arriba del fichero, con los demás):
import kotlin.concurrent.atomics.AtomicInt
import kotlin.concurrent.atomics.incrementAndFetch
@Test
fun conAtomicoNoSePierdeNinguno() = runBlocking {
// Un entero atómico que empieza en 0.
val goles = AtomicInt(0)
List(1000) {
async(Dispatchers.Default) {
// Suma uno y devuelve el valor nuevo, todo de una vez.
repeat(1000) { goles.incrementAndFetch() }
}
}.awaitAll()
// Se lee con load().
assertEquals(1_000_000, goles.load())
}Al compilar, el compilador lo rechaza:
e: …/ConcurrenciaTest.kt:… This declaration needs opt-in. Its usage must be marked with '@kotlin.concurrent.atomics.ExperimentalAtomicApi' or '@OptIn(kotlin.concurrent.atomics.ExperimentalAtomicApi::class)'Es un permiso explícito (opt-in): la API de atómicos de la biblioteca estándar todavía es experimental y puede cambiar, y Kotlin te obliga a aceptarlo por escrito, como ya hiciste con @OptIn en Un alcance propio. Pon encima de la clase @OptIn(ExperimentalAtomicApi::class) y el import kotlin.concurrent.atomics.ExperimentalAtomicApi:
import kotlin.concurrent.atomics.ExperimentalAtomicApi
@OptIn(ExperimentalAtomicApi::class)
class ConcurrenciaTest {Pasa. En Swift es el tipo Atomic de Synchronization, el mismo módulo del Mutex. Mientras siga siendo experimental, para casi todo es más cómodo el Mutex.
Arreglo 3 · un solo hilo dueño del dato
Si el contador solo lo toca una corrutina a la vez, nunca se pisan. limitedParallelism(1) le dice a un dispatcher «como mucho una corrutina a la vez». No promete un hilo concreto (puede ser uno distinto cada vez): promete que nunca hay dos dentro a la vez. Añade:
@Test
fun conUnaCorrutinaALaVezNoSePierdeNinguno() = runBlocking {
val contador = ContadorDeGoles()
// Un dispatcher derivado del Default que deja entrar a una corrutina a la vez.
val deUnaEnUna = Dispatchers.Default.limitedParallelism(1)
List(1000) {
async(deUnaEnUna) { repeat(1000) { contador.total++ } }
}.awaitAll()
assertEquals(1_000_000, contador.total)
}Pasa, sin Mutex y sin atómicos. Lo que más se parece en Swift es un actor: un dueño único del dato, que atiende las peticiones de una en una.
Arreglo 4 · no compartir: StateFlow.update
Ya conoces MutableStateFlow.update { … }: lee el valor, calcula el nuevo y lo guarda solo si nadie lo cambió entre medias (y si lo cambiaron, lo repite). Por eso es seguro entre hilos por construcción, aunque escriban muchas corrutinas a la vez, como en este test con mil:
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
@Test
fun conStateFlowUpdateNoSePierdeNinguno() = runBlocking {
val goles = MutableStateFlow(0)
List(1000) {
async(Dispatchers.Default) { repeat(1000) { goles.update { it + 1 } } }
}.awaitAll()
assertEquals(1_000_000, goles.value)
}No hay nada que cerrar, y además cualquiera puede observar el valor. Cuando el dato ya es (o va a ser) un StateFlow que otros leen, update te ahorra el cerrojo.
Para elegir
La regla corta: si el dato ya es un StateFlow, usa update; si es cualquier otro estado mutable, Mutex. Los otros dos son para casos concretos.
| Herramienta | Estado hoy | Cuándo |
|---|---|---|
StateFlow.update | Estable | El dato ya se publica y se observa como un StateFlow |
Mutex de kotlinx.coroutines | Estable | Cualquier otro estado mutable que varias corrutinas tocan |
Confinamiento (limitedParallelism(1)) | Estable | Que entre una corrutina a la vez en todo el trabajo con ese estado |
kotlin.concurrent.atomics | @ExperimentalAtomicApi | Contadores y banderas, si aceptas la marca experimental |
Worker de Kotlin/Native | Heredado | Casi nunca: usa corrutinas |
Comprueba. ./gradlew :torneo:jvmTest termina con BUILD SUCCESSFUL y 142 tests (los 138 de antes más los cuatro nuevos; 136 si no escribiste el opcional CancelacionTest); ./gradlew :torneo:testAndroidHostTest y, en un Mac, ./gradlew :torneo:iosSimulatorArm64Test corren 121 (117 + 4; 115 sin CancelacionTest).
Paso 4 · Serializar no es evitar
Un Mutex hace que las corrutinas entren por turnos. Pero a veces lo que quieres no es que entren por turnos, sino que solo la primera haga el trabajo y las demás reciban el resultado. Imagina que calcular la tabla de posiciones fuera caro y cinco pantallas la piden a la vez.
El fichero. Crea PerezosoTest.kt en la misma carpeta, con el mismo método (clic derecho sobre torneo → New → File, con la ruta src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/PerezosoTest.kt). Escribe primero la clase Perezoso, que calcula algo la primera vez que se lo piden y lo guarda; y el test que lo prueba:
package com.sebastiannarvaez.marcador.torneo
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.concurrent.Volatile
import kotlin.test.Test
import kotlin.test.assertEquals
// Guarda el resultado de un cálculo caro. `T : Any` significa «cualquier tipo que no sea nulable».
class Perezoso<T : Any>(private val calcular: suspend () -> T) {
// @Volatile: lo que escribe un hilo lo ven los demás enseguida (sin esto, un hilo podría seguir viendo `null`).
@Volatile
private var valor: T? = null
private val candado = Mutex()
suspend fun obtener(): T =
// Camino rápido: si ya está calculado, se devuelve sin tocar el candado.
valor ?: candado.withLock {
// Dentro del candado se mira OTRA VEZ: quizá otra corrutina lo calculó mientras esperabas.
valor ?: calcular().also { valor = it }
}
}
class PerezosoTest {
@Test
fun cincoLlamadasCalculanUnaSolaVez() = runBlocking {
var calculos = 0
val tabla = Perezoso {
calculos++ // cuenta cuántas veces se calcula de verdad
delay(50) // un cálculo lento, para que las cinco coincidan
"tabla"
}
val resultados = List(5) { async(Dispatchers.Default) { tabla.obtener() } }.awaitAll()
assertEquals(1, calculos)
assertEquals(List(5) { "tabla" }, resultados)
}
}Ejecútalo con ./gradlew :torneo:jvmTest --tests "*PerezosoTest*": pasa. Fíjate en los dos valor ?: seguidos: no es una errata. El primero es el camino rápido; el segundo es la comprobación de dentro del candado, para el que llegó a esperar.
Ejercicio. Borra la segunda comprobación (deja solo calcular().also { valor = it } dentro del withLock) y vuelve a ejecutar. El test debe fallar con un mensaje que dice que esperaba 1 y recibió 5: el candado ya las hacía entrar por turnos, pero cada una, al llegar su turno, volvía a calcular. Serializar no es lo mismo que evitar. Devuelve la línea a su sitio.
En la Pokédex, la app que construiste en el curso, hay dos Mutex con este mismo patrón: uno protege el refresco del token de sesión (se renueva una vez, no cinco) y el otro, la descarga del índice de búsqueda. Mira, y si está, no entres; si entras, vuelve a mirar. El del token lo cuenta Refrescar el token sin duplicados.
Paso 5 · La trampa que sí sigue viva: el init que nadie ejecuta
La documentación de migración avisa de un cambio de Kotlin/Native que pilla a mucha gente:
Lo práctico: lo que pongas en el bloque init de un object, o en una propiedad global, no se ejecuta hasta que alguien toca ese objeto o ese archivo. Si registras algo ahí (un logger, un manejador de errores), y nadie lo toca, no se registra. En la JVM siempre fue así; lo nuevo es que Native ahora se comporta igual, y por eso el test que sigue (con un object, que funciona como una propiedad global) pasa igual en los dos. Compruébalo.
El fichero. Crea Vigilante.kt en src/commonTest/kotlin/com/sebastiannarvaez/marcador/torneo/ (mismo método: clic derecho sobre torneo → New → File, ruta completa):
package com.sebastiannarvaez.marcador.torneo
// Cuenta cuántos vigilantes se han activado.
var vigilantesActivos = 0
object Vigilante {
// El bloque init corre una sola vez, cuando el objeto se crea.
init {
vigilantesActivos++
}
// No hace nada: existe solo para poder «tocar» el objeto.
fun saluda() = Unit
}Y su test, en ArranqueTest.kt (misma carpeta):
package com.sebastiannarvaez.marcador.torneo
import kotlin.test.Test
import kotlin.test.assertEquals
class ArranqueTest {
@Test
fun elInitSoloCorreCuandoAlguienTocaElObjeto() {
// Nadie ha tocado Vigilante: su init no ha corrido.
assertEquals(0, vigilantesActivos)
// Al tocarlo por primera vez, se crea y corre su init.
Vigilante.saluda()
assertEquals(1, vigilantesActivos)
}
}Pasa en la JVM y en el simulador de iOS: en las dos, el init no corre hasta que alguien toca el objeto (el comportamiento de siempre de la JVM, ahora también el de Native). Si tu arranque depende de un init que nadie llama, no se arranca. La salida documentada para Native es la anotación @EagerInitialization, pero la sana es no depender de efectos secundarios en la inicialización: arranca lo que haga falta de forma explícita, desde el punto de entrada de cada app.
La Pokédex lo esquiva por construcción: su Log es un object que no registra nada en su init (solo construye su Logger la primera vez que alguien escribe una línea), y el grafo de dependencias (el de Koin, la biblioteca de inyección que usaste) se arranca a mano desde cada app: Swift llama a startKoinIos(config, secureStorage) una vez, al abrirse la app. Nada depende de que alguien haya tocado el archivo correcto antes.
Comprueba. Con los seis tests escritos, ./gradlew :torneo:jvmTest termina en BUILD SUCCESSFUL con 144 tests (138 sin el opcional CancelacionTest), y ./gradlew :torneo:testAndroidHostTest (y, en un Mac, ./gradlew :torneo:iosSimulatorArm64Test) con 123 (117 sin él).
Paso 6 · Concurrencia estructurada: el árbol que se cancela solo
Todo lo anterior va de memoria. La otra mitad del problema es el tiempo: cuánto vive una corrutina y quién decide cuándo para. Ya lo viste en Alcance, Job y cancelación: las corrutinas forman un árbol, cada una vive dentro de un ámbito, el ámbito espera a sus hijos, y cancelar un ámbito cancela toda su descendencia.
Un ejemplo de la Pokédex: la ficha de un Pokémon son dos peticiones que no dependen entre sí (el detalle y la especie). En serie tardaría el doble sin ninguna razón; su repositorio las lanza con async dentro de un coroutineScope, y eso es lo que hace que sea seguro y no un apaño: si la petición de la especie falla, la del detalle se cancela sola, y no se queda una corrutina huérfana consumiendo red por una ficha que ya nadie va a ver.
Quién es el dueño del ámbito en cada plataforma
Aquí es donde iOS obliga a escribir algo que en Android es gratis. En Android el sistema sabe cuándo muere una pantalla y el ViewModel se lleva su viewModelScope por delante. En iOS no hay equivalente: hay que decirlo. Marcador no tiene este código (su app de iOS no usa :torneo), pero la Pokédex, que comparte Kotlin con iOS, sí.
Paso 7 · Donde Kotlin y Swift se rozan de verdad
Tu :torneo ya produce el framework Torneo para iOS, aunque la app de Marcador todavía no lo usa (su app de iOS sigue con su propio paquete Swift). Estas son las costuras que aparecen el día que un proyecto enchufa Kotlin a una app de iOS, como hace la Pokédex. Kotlin recolecta por trazado y Objective-C cuenta referencias (como ya viste en ARC): los dos modelos conviven, pero con puntos que conviene conocer.
Los ciclos mixtos no se recogen
En la práctica: cuando un objeto de Kotlin guarda una referencia a un objeto de Swift que a su vez guarda el de Kotlin —un delegate, un listener, un modelo de SwiftUI—, una de las dos referencias tiene que ser débil. Y como no hay herramienta que lo detecte, la disciplina es la única defensa.
En la Pokédex hay exactamente un sitio donde Swift implementa una interfaz de Kotlin y se la entrega al grafo: SecureStorage, el Llavero. El riesgo se evita por la forma, no por vigilancia: el almacén de Swift no guarda ninguna referencia a Kotlin, solo recibe llamadas, y un implementador sin estado no puede cerrar un ciclo. La regla que deja: cuando tengas que cruzar una interfaz hacia Swift, pregúntate primero si la implementación necesita mirar hacia atrás. Casi siempre, no.
Los completion handlers no vuelven al hilo principal
Una suspend fun de Kotlin llega a Swift como una función con completion handler (un cierre que Kotlin llama cuando termina) o, con Swift moderno, como una función async que se llama con await. Traducido: si desde Swift llamas a una suspend de Kotlin y en el completion handler tocas la interfaz, estás actualizando la interfaz fuera del hilo principal. En Swift 6 eso lo caza el compilador si tienes el aislamiento bien puesto; en un proyecto migrado a medias, no.
La costumbre sana desde el lado de Swift es saltar al hilo principal con await MainActor.run { … } antes de tocar la interfaz; así lo hace la Pokédex en el delegado de notificaciones, para guardar el enlace pendiente.
La destrucción tarda dos ciclos
Un objeto de Swift que cruzó a Kotlin no se libera cuando sueltas la referencia, sino cuando pasa el recolector. Y si ese objeto retenía otro, hace falta una segunda pasada para el segundo.
Con objetos pequeños da igual. Con algo que retiene un recurso caro —una conexión abierta a la base de datos, una petición de red en vuelo, un archivo— significa que el recurso sigue vivo más tiempo del que crees. Para eso están deinit y los cierres explícitos, no el recolector. Es la otra razón de que el presentador de la Pokédex tenga un close().
El estado de fondo no se sigue solo
Hay una opción binaria experimental para activarlo. Mientras siga siendo experimental, la lectura práctica es: no cuentes con que el recolector se comporte distinto cuando la app pasa a segundo plano.
Paso 8 · Medir en vez de suponer
Cuando una app de iOS con Kotlin dentro va a tirones, la pregunta es: ¿es una pausa del recolector o es mi código? Sin datos, esa discusión no termina nunca. Instruments es la aplicación de Xcode que mide qué hace una app mientras corre, y un os_signpost es una marca que el código o el sistema deja en esa línea de tiempo para señalar el principio y el fin de algo, aquí una pausa del recolector.
Esto es lectura para cuando integres Kotlin en una app de iOS: Marcador todavía no lo hace, así que no hay nada que activar ahora. Para que el recolector deje esas marcas, el proyecto de Gradle activaría un ajuste en gradle.properties (en la vista Android, Gradle Scripts → gradle.properties (Project Properties)), con una línea como esta:
# Pide a Kotlin/Native que marque cada pausa del recolector como un signpost.
kotlin.native.binary.enableSafepointSignposts=trueReto · El observador que no se libera
En iOS, una vista crea un modelo de Swift que se registra como observador de un objeto de Kotlin. Al cerrar la pantalla, la memoria no baja y el observador sigue recibiendo valores. ¿Qué está pasando y cómo lo arreglas?
Ver solución
Es un ciclo de retención mixto. El objeto de Kotlin guarda una referencia fuerte al modelo de Swift, y el modelo de Swift guarda el objeto de Kotlin. Como hay al menos un objeto de Objective-C en el corro, ni el recolector de Kotlin ni ARC pueden deshacerlo, y la propia documentación avisa de que no se puede romper desde el lado de Kotlin.
Tres arreglos, y conviene hacer los tres:
- Romper el ciclo por el lado de Swift con una referencia débil:
cómo se rompe el ciclo desde Swift cliente.observar { [weak self] valor in self?.recibir(valor) } - Dar de baja explícitamente, sin confiar en el recolector: un
close()llamado al desaparecer la vista. - Y, antes que nada, preguntarte si hacía falta un observador. Consumir los flujos desde
.task { }(el modificador que lanza una corrutina atada a la vista, como se vio en .task frente a .onAppear) hace que SwiftUI la cancele sola cuando la vista se va. La concurrencia estructurada no solo es más limpia: el ciclo no llega a existir, porque nadie guarda una referencia hacia atrás.
Lo segundo importa incluso sin ciclo, porque un objeto que cruzó la frontera se libera cuando pasa el recolector, no cuando sueltas la referencia. Si retiene un recurso caro, «más tarde» no es suficiente.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.Encuentras un tutorial que llama a
freeze()antes de pasar un objeto a otro hilo. ¿Qué haces?2.Registras un manejador de errores en el
initde unobjecty nadie más lo menciona en el programa. ¿Qué pasa con eseinit?3.Pides en paralelo el detalle y la especie de un Pokémon dentro de un
coroutineScope, y la petición de la especie falla. ¿Qué pasa con la otra?4.Un objeto de Kotlin y uno de Swift se referencian mutuamente. ¿Qué ocurre?
5.Tu clase
Perezosocalcula la tabla dentro de unMutex, pero cinco llamadas a la vez la calculan cinco veces. ¿Qué falta?6.Mil corrutinas suman mil veces a un
var totalcompartido, sin protección, y el test espera un millón. ¿Qué ocurre en Kotlin/Native?