¿Qué significa la función de suspensión en Kotlin Coroutine?


118

Estoy leyendo Kotlin Coroutine y sé que se basa en la suspendfunción. ¿Pero qué suspendsignifica?

¿Corutina o función se suspende?

De https://kotlinlang.org/docs/reference/coroutines.html

Básicamente, las corrutinas son cálculos que se pueden suspender sin bloquear un hilo.

Escuché que la gente suele decir "suspender la función". Pero creo que es la corrutina la que se suspende porque está esperando a que finalice la función. "suspender" normalmente significa "cesar la operación", en este caso la corrutina está inactiva.

🤔 ¿Deberíamos decir que la corrutina está suspendida?

¿Qué corrutina se suspende?

De https://kotlinlang.org/docs/reference/coroutines.html

Para continuar con la analogía, await () puede ser una función de suspensión (por lo tanto, también invocable desde dentro de un bloque async {}) que suspende una corrutina hasta que se realiza algún cálculo y devuelve su resultado:

async { // Here I call it the outer async coroutine
    ...
    // Here I call computation the inner coroutine
    val result = computation.await()
    ...
}

🤔 Dice "que suspende una corrutina hasta que se realiza algún cálculo", pero la corrutina es como un hilo ligero. Entonces, si se suspende la corrutina, ¿cómo se puede realizar el cálculo?

Vemos que awaitestá llamado computation, por lo que podría ser asyncque regrese Deferred, lo que significa que puede iniciar otra corrutina

fun computation(): Deferred<Boolean> {
    return async {
        true
    }
}

🤔 La cita dice que suspende una corrutina . ¿Significa suspendla asynccorrutina externa o suspendla computationcorrutina interna ?

Hace suspendmedia que mientras externa asynccorrutina está a la espera ( await) para el interior computationcorrutina a fin, él (el exterior asynccorrutina) Idles (de ahí el nombre de suspensión) y los retornos de rosca a la agrupación de hebras, y cuando el niño computationacabados co-rutina, él (el exteriorasync corrutina ) se despierta, toma otro hilo de la piscina y continúa?

La razón por la que menciono el hilo es por https://kotlinlang.org/docs/tutorials/coroutines-basic-jvm.html

El hilo se devuelve al grupo mientras la corrutina está esperando, y cuando finaliza la espera, la corrutina se reanuda en un hilo libre en el grupo.

Respuestas:


113

Las funciones de suspensión están en el centro de todas las rutinas. Una función de suspensión es simplemente una función que se puede pausar y reanudar en un momento posterior. Pueden ejecutar una operación de larga duración y esperar a que se complete sin bloquear.

La sintaxis de una función de suspensión es similar a la de una función regular excepto por la adición de la suspendpalabra clave. Puede tomar un parámetro y tener un tipo de retorno. Sin embargo, las funciones de suspensión solo pueden ser invocadas por otra función de suspensión o dentro de una corrutina.

suspend fun backgroundTask(param: Int): Int {
     // long running operation
}

Bajo el capó, el compilador convierte las funciones de suspensión en otra función sin la palabra clave suspend, que toma un parámetro adicional de tipo Continuation<T>. La función anterior, por ejemplo, será convertida por el compilador a esto:

fun backgroundTask(param: Int, callback: Continuation<Int>): Int {
   // long running operation
}

Continuation<T> es una interfaz que contiene dos funciones que se invocan para reanudar la corrutina con un valor de retorno o con una excepción si se produjo un error mientras la función estaba suspendida.

interface Continuation<in T> {
   val context: CoroutineContext
   fun resume(value: T)
   fun resumeWithException(exception: Throwable)
}

4
¡Otro misterio desvelado! ¡Excelente!
WindRider

16
Me pregunto cómo se detiene realmente esta función. Siempre dicen que suspend funse puede pausar, pero ¿cómo exactamente?
WindRider

2
@WindRider Simplemente significa que el hilo actual comienza a ejecutar alguna otra corrutina, y volverá a esta más tarde.
Joffrey

2
He descubierto el mecanismo "misterioso". Se puede revelar fácilmente con la ayuda de Herramientas> Kotlin> Bytecode> Decompile btn. Muestra cómo se implementa el llamado "punto de suspensión", a través de Continuación y así sucesivamente. Cualquiera puede echar un vistazo por sí mismo.
WindRider

4
@buzaa Aquí hay una charla de 2017 de Roman Elizarov que lo explica a nivel de código de bytes .
Marko Topolnik

30

Para entender qué significa exactamente suspender una corrutina, le sugiero que lea este código:

import kotlinx.coroutines.Dispatchers.Unconfined
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

var continuation: Continuation<Int>? = null

fun main() = runBlocking {
    launch(Unconfined) {
        val a = a()
        println("Result is $a")
    }
    10.downTo(0).forEach {
        continuation!!.resume(it)
    }
}

suspend fun a(): Int {
    return b()
}

suspend fun b(): Int {
    while (true) {
        val i = suspendCoroutine<Int> { cont -> continuation = cont }
        if (i == 0) {
            return 0
        }
    }
}

El Unconfineddespachador de corrutinas elimina la magia del despacho de corrutinas y nos permite enfocarnos directamente en corrutinas desnudas.

El código dentro del launch bloque comienza a ejecutarse de inmediato en el hilo actual, como parte de la launchllamada. Lo que sucede es lo siguiente:

  1. Evaluar val a = a()
  2. Esto encadena a b() , alcanzando suspendCoroutine.
  3. Función b() ejecuta el bloque pasado suspendCoroutiney luego devuelve un COROUTINE_SUSPENDEDvalor especial . Este valor no es observable a través del modelo de programación de Kotlin, pero eso es lo que hace el método Java compilado.
  4. La función a(), al ver este valor de retorno, también lo devuelve.
  5. El launchbloque hace lo mismo y el control ahora vuelve a la línea después de la launchinvocación:10.downTo(0)...

Tenga en cuenta que, en este punto, tiene el mismo efecto que si el código dentro del launchbloque y sufun main código se estuvieran ejecutando al mismo tiempo. Simplemente sucede que todo esto está sucediendo en un solo hilo nativo, por lo que el launchbloque está "suspendido".

Ahora, dentro del forEachcódigo de bucle, el programa lee el continuationque b()escribió la función y resumesel valor de 10. resume()se implementa de tal manera que será como si la suspendCoroutinellamada regresara con el valor que le pasaste. Así que de repente te encuentras en medio de la ejecución b(). El valor al que pasó resume()se asigna iy se compara 0. Si no es cero, el while (true)bucle continúa hacia adentro b(), alcanzando nuevamente suspendCoroutine, momento en el que su resume()llamada regresa, y ahora pasa por otro paso de bucle hacia adentro forEach(). Esto continúa hasta que finalmente reanude con 0, luego la printlninstrucción se ejecuta y el programa se completa.

El análisis anterior debería darle la importante intuición de que "suspender una corrutina" significa devolver el control a la launchinvocación más interna (o, más generalmente, al constructor de corrutinas ). Si una corrutina vuelve a suspenderse después de reanudarse, la resume()llamada finaliza y el control vuelve al llamador de resume().

La presencia de un despachador de rutina hace que este razonamiento sea menos claro porque la mayoría de ellos envían inmediatamente su código a otro hilo. En ese caso, la historia anterior ocurre en ese otro hilo, y el despachador de rutina también administra el continuationobjeto para que pueda reanudarlo cuando el valor de retorno esté disponible.


19

En primer lugar, la mejor fuente para entender esta OMI es la charla "Deep Dive into Coroutines" de Roman Elizarov.

¿Corutina o función se suspende?

Llamar a una suspensión de ing función de suspensión de s la co-rutina, es decir, el hilo actual puede comenzar a ejecutar otra corrutina. Entonces, se dice que la corrutina está suspendida en lugar de la función.

De hecho, los sitios de llamada de funciones de suspensión se denominan "puntos de suspensión" por este motivo.

¿Qué corrutina se suspende?

Veamos su código y analicemos lo que sucede:

// 1. this call starts a new coroutine (let's call it C1).
//    If there were code after it, it would be executed concurrently with
//    the body of this async
async {
    ...
    // 2. this is a regular function call
    val deferred = computation()
    // 4. because await() is suspendING, it suspends coroutine C1.
    //    This means that if we had a single thread in our dispatcher, 
    //    it would now be free to go execute C2
    // 7. once C2 completes, C1 is resumed with the result `true` of C2's async
    val result = deferred.await() 
    ...
    // 8. C1 can now keep going in the current thread until it gets 
    //    suspended again (or not)
}

fun computation(): Deferred<Boolean> {
    // 3. this async call starts a second coroutine (C2). Depending on the 
    //    dispatcher you're using, you may have one or more threads.
    // 3.a. If you have multiple threads, the block of this async could be
    //      executed in parallel of C1 in another thread. The control flow 
    //      of the current thread returns to the caller of computation().
    // 3.b. If you have only one thread, the block is sort of "queued" but 
    //      not executed right away, and the control flow returns to the 
    //      caller of computation(). (unless a special dispatcher or 
    //      coroutine start argument is used, but let's keep it simple).
    //    In both cases, we say that this block executes "concurrently"
    //    with C1.
    return async {
        // 5. this may now be executed
        true
        // 6. C2 is now completed, so the thread can go back to executing 
        //    another coroutine (e.g. C1 here)
    }
}

El exterior asyncinicia una corrutina. Cuando llama computation(), el interior asyncinicia una segunda corrutina. Entonces, la llamada a await()suspende la ejecución de la corrutina externa async , hasta que finaliza la ejecución de la corrutina interna async .

Incluso puedes ver eso con un solo hilo: el hilo ejecutará el asynccomienzo del exterior , luego llamará computation()y llegará al interior async. En este punto, se omite el cuerpo del async interno y el hilo continúa ejecutando el externo asynchasta que llega await(). await()es un "punto de suspensión", porque awaites una función de suspensión. Esto significa que la corrutina externa está suspendida y, por lo tanto, el hilo comienza a ejecutar la interna. Cuando está hecho, vuelve a ejecutar el final del exterior async.

Suspender significa que mientras la corrutina asíncrona externa está esperando (esperando) a que finalice la corrutina de cálculo interna, (la corrutina asíncrona externa) está inactiva (de ahí el nombre suspend) y devuelve el hilo al grupo de subprocesos, y cuando finaliza la corrutina de cálculo secundaria , (la corrutina asincrónica externa) se despierta, toma otro hilo del grupo y continúa?

Sí, precisamente.

La forma en que esto se logra realmente es convirtiendo cada función de suspensión en una máquina de estado, donde cada "estado" corresponde a un punto de suspensión dentro de esta función de suspensión. Debajo del capó, la función se puede llamar varias veces, con la información sobre desde qué punto de suspensión debe comenzar a ejecutarse (realmente debería ver el video que vinculé para obtener más información al respecto).


3
Gran respuesta, extraño ese tipo de explicación realmente básica cuando se trata de corrutinas.
bernardo.g

¿Por qué no está implementado en ningún otro idioma? ¿O me estoy perdiendo algo? Estoy pensando en esa solución durante tanto tiempo, me alegro de que Kotlin la tenga, pero no estoy seguro de por qué TS o Rust tienen algo así
PEZO

Las corrutinas de pozos de @PEZO han existido durante mucho tiempo. Kotlin no los inventó, pero la sintaxis y la biblioteca los hacen brillar. Go tiene gorutinas, JavaScript y TypeScript tienen promesas. La única diferencia está en los detalles de sintaxis para usarlos. Me resulta bastante molesto / perturbador que las asyncfunciones de JS estén marcadas de esta manera y, sin embargo, devuelvan una Promesa.
Joffrey

Lo siento, mi comentario no fue claro. Me refiero a la palabra clave suspend. No es lo mismo que async.
PEZO

Gracias por señalar el video de Roman. Oro puro.
Denounce'IN

8

Descubrí que la mejor manera de entenderlo suspendes hacer una analogía entre thispalabra clave y coroutineContextpropiedad.

Las funciones de Kotlin se pueden declarar como locales o globales. Las funciones locales mágicamente tienen acceso a las thispalabras clave, mientras que las globales no.

Las funciones de Kotlin se pueden declarar suspendo bloquear. suspendlas funciones mágicamente tienen acceso a la coroutineContextpropiedad mientras que las funciones de bloqueo no.

La cuestión es que la coroutineContextpropiedad se declara como una propiedad "normal" en Kotlin stdlib pero esta declaración es solo un código auxiliar para propósitos de documentación / navegación. De hecho, coroutineContextes una propiedad intrínseca incorporada que significa que la magia del compilador bajo el capó es consciente de esta propiedad, así como es consciente de las palabras clave del lenguaje.

Lo que thishace la palabra clave para las funciones locales es lo que coroutineContexthace la propiedad para las suspendfunciones: da acceso al contexto actual de ejecución.

Por lo tanto, debe suspendobtener acceso a la coroutineContextpropiedad: la instancia del contexto de rutina ejecutado actualmente


5

Quería darles un ejemplo sencillo del concepto de continuación. Esto es lo que hace una función de suspensión, se puede congelar / suspender y luego continúa / se reanuda. Deja de pensar en la corrutina en términos de hilos y semáforo. Piense en ello en términos de continuación e incluso ganchos de devolución de llamada.

Para que quede claro, una corrutina se puede pausar usando una suspendfunción. investiguemos esto:

En Android podríamos hacer esto por ejemplo:

var TAG = "myTAG:"
        fun myMethod() { // function A in image
            viewModelScope.launch(Dispatchers.Default) {
                for (i in 10..15) {
                    if (i == 10) { //on first iteration, we will completely FREEZE this coroutine (just for loop here gets 'suspended`)
                        println("$TAG im a tired coroutine - let someone else print the numbers async. i'll suspend until your done")
                        freezePleaseIAmDoingHeavyWork()
                    } else
                        println("$TAG $i")
                    }
            }

            //this area is not suspended, you can continue doing work
        }


        suspend fun freezePleaseIAmDoingHeavyWork() { // function B in image
            withContext(Dispatchers.Default) {
                async {
                    //pretend this is a big network call
                    for (i in 1..10) {
                        println("$TAG $i")
                        delay(1_000)//delay pauses coroutine, NOT the thread. use  Thread.sleep if you want to pause a thread. 
                    }
                    println("$TAG phwww finished printing those numbers async now im tired, thank you for freezing, you may resume")
                }
            }
        }

El código anterior imprime lo siguiente:

I: myTAG: my coroutine is frozen but i can carry on to do other things

I: myTAG: im a tired coroutine - let someone else print the numbers async. i'll suspend until your done

I: myTAG: 1
I: myTAG: 2
I: myTAG: 3
I: myTAG: 4
I: myTAG: 5
I: myTAG: 6
I: myTAG: 7
I: myTAG: 8
I: myTAG: 9
I: myTAG: 10

I: myTAG: phwww finished printing those numbers async now im tired, thank you for freezing, you may resume

I: myTAG: 11
I: myTAG: 12
I: myTAG: 13
I: myTAG: 14
I: myTAG: 15

imagínelo funcionando así:

ingrese la descripción de la imagen aquí

Por lo tanto, la función actual desde la que inició no se detiene, solo una corrutina se suspendería mientras continúa. El hilo no se pausa al ejecutar una función de suspensión.

Creo que este sitio puede ayudarte a aclarar las cosas y es mi referencia.

Hagamos algo interesante y congelemos nuestra función de suspensión en medio de una iteración. Lo reanudaremos más tarde enonResume

Almacene una variable llamada continuationy la cargaremos con el objeto de continuación de corrutinas por nosotros:

var continuation: CancellableContinuation<String>? = null

suspend fun freezeHere() = suspendCancellableCoroutine<String> {
            continuation = it
        }

 fun unFreeze() {
            continuation?.resume("im resuming") {}
        }

Ahora, volvamos a nuestra función suspendida y congelemos en medio de la iteración:

 suspend fun freezePleaseIAmDoingHeavyWork() {
        withContext(Dispatchers.Default) {
            async {
                //pretend this is a big network call
                for (i in 1..10) {
                    println("$TAG $i")
                    delay(1_000)
                    if(i == 3)
                        freezeHere() //dead pause, do not go any further
                }
            }
        }
    }

Luego, en otro lugar como onResume (por ejemplo):

override fun onResume() {
        super.onResume()
        unFreeze()
    }

Y el ciclo continuará. Es bueno saber que podemos congelar una función de suspensión en cualquier momento y reanudarla después de que haya pasado un tiempo. También puedes buscar canales


4

Como ya hay muchas buenas respuestas, me gustaría publicar un ejemplo más simple para otros.

caso de uso de runBlocking :

  • myMethod () es suspendfunción
  • runBlocking { }inicia una Coroutine en forma de bloqueo. Es similar a cómo estábamos bloqueando los hilos normales con Threadclase y notificando los hilos bloqueados después de ciertos eventos.
  • runBlocking { }no bloquear la corriente de la ejecución de hilo, hasta que el co-rutina (cuerpo entre {}) se completó

     override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
        Log.i(TAG,"Outer code started on Thread : " + Thread.currentThread().name);
        runBlocking {
            Log.d(TAG,"Inner code started  on Thread : " + Thread.currentThread().name + " making outer code suspend");
            myMethod();
        }
        Log.i(TAG,"Outer code resumed on Thread : " + Thread.currentThread().name);
    }
    
    private suspend fun myMethod() {
        withContext(Dispatchers.Default) {
        for(i in 1..5) {
            Log.d(TAG,"Inner code i : $i on Thread : " + Thread.currentThread().name);
        }
    }

Esto produce:

I/TAG: Outer code started on Thread : main
D/TAG: Inner code started  on Thread : main making outer code suspend
// ---- main thread blocked here, it will wait until coroutine gets completed ----
D/TAG: Inner code i : 1 on Thread : DefaultDispatcher-worker-2
D/TAG: Inner code i : 2 on Thread : DefaultDispatcher-worker-2
D/TAG: Inner code i : 3 on Thread : DefaultDispatcher-worker-2
D/TAG: Inner code i : 4 on Thread : DefaultDispatcher-worker-2
D/TAG: Inner code i : 5 on Thread : DefaultDispatcher-worker-2
// ---- main thread resumes as coroutine is completed ----
I/TAG: Outer code resumed on Thread : main

caso de uso de lanzamiento :

  • launch { } inicia una corrutina al mismo tiempo.
  • Esto significa que cuando especificamos el lanzamiento, una corrutina comienza la ejecución en el workerhilo.
  • El workersubproceso y el subproceso externo (desde el que llamamos launch { }) se ejecutan simultáneamente. Internamente, JVM puede realizar subprocesos preventivos
  • Cuando necesitamos que varias tareas se ejecuten en paralelo, podemos usar esto. Hay los scopesque especifican la vida útil de la rutina. Si especificamos GlobalScope, la corrutina funcionará hasta que finalice la vida útil de la aplicación.

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
        Log.i(TAG,"Outer code started on Thread : " + Thread.currentThread().name);
    
        GlobalScope.launch(Dispatchers.Default) {
            Log.d(TAG,"Inner code started  on Thread : " + Thread.currentThread().name + " making outer code suspend");
            myMethod();
        }
        Log.i(TAG,"Outer code resumed on Thread : " + Thread.currentThread().name);
    }
    
    private suspend fun myMethod() {
        withContext(Dispatchers.Default) {
            for(i in 1..5) {
                Log.d(TAG,"Inner code i : $i on Thread : " + Thread.currentThread().name);
            }
        }
    }

Esto produce:

10806-10806/com.example.viewmodelapp I/TAG: Outer code started on Thread : main
10806-10806/com.example.viewmodelapp I/TAG: Outer code resumed on Thread : main
// ---- In this example, main had only 2 lines to execute. So, worker thread logs start only after main thread logs complete
// ---- In some cases, where main has more work to do, the worker thread logs get overlap with main thread logs
10806-10858/com.example.viewmodelapp D/TAG: Inner code started  on Thread : DefaultDispatcher-worker-1 making outer code suspend
10806-10858/com.example.viewmodelapp D/TAG: Inner code i : 1 on Thread : DefaultDispatcher-worker-1
10806-10858/com.example.viewmodelapp D/TAG: Inner code i : 2 on Thread : DefaultDispatcher-worker-1
10806-10858/com.example.viewmodelapp D/TAG: Inner code i : 3 on Thread : DefaultDispatcher-worker-1
10806-10858/com.example.viewmodelapp D/TAG: Inner code i : 4 on Thread : DefaultDispatcher-worker-1
10806-10858/com.example.viewmodelapp D/TAG: Inner code i : 5 on Thread : DefaultDispatcher-worker-1

caso de uso async y await :

  • Cuando tenemos varias tareas que hacer y dependen de que otros las completen, asyncy awaitayudarían.
  • Por ejemplo, en el siguiente código, hay 2funciones de suspensión myMethod () y myMethod2 (). myMethod2()debe ejecutarse solo después de la finalización completa de myMethod() OR myMethod2() depende del resultado de myMethod(), podemos usar asyncyawait
  • asyncinicia una corrutina en paralelo similar a launch. Pero proporciona una forma de esperar una corrutina antes de iniciar otra corrutina en paralelo.
  • De esa manera es await(). asyncdevuelve una instancia de Deffered<T>. Tsería Unitpor defecto. Cuando tengamos que esperar a que se asynccomplete alguno , debemos llamar .await()a una Deffered<T>instancia de eso async. Como en el siguiente ejemplo, llamamos, lo innerAsync.await()que implica que la ejecución se suspendería hasta que innerAsyncse complete. Podemos observar lo mismo en la salida. El innerAsyncse completó primero, que llama myMethod(). Y luego async innerAsync2comienza a continuación , que llamamyMethod2()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.main_activity)
        Log.i(TAG,"Outer code started on Thread : " + Thread.currentThread().name);
    
         job = GlobalScope.launch(Dispatchers.Default) {
             innerAsync = async {
                 Log.d(TAG, "Inner code started  on Thread : " + Thread.currentThread().name + " making outer code suspend");
                 myMethod();
             }
             innerAsync.await()
    
             innerAsync2 = async {
                 Log.w(TAG, "Inner code started  on Thread : " + Thread.currentThread().name + " making outer code suspend");
                 myMethod2();
             }
        }
    
        Log.i(TAG,"Outer code resumed on Thread : " + Thread.currentThread().name);
        }
    
    private suspend fun myMethod() {
        withContext(Dispatchers.Default) {
            for(i in 1..5) {
                Log.d(TAG,"Inner code i : $i on Thread : " + Thread.currentThread().name);
            }
        }
    }
    
    private suspend fun myMethod2() {
        withContext(Dispatchers.Default) {
            for(i in 1..10) {
                Log.w(TAG,"Inner code i : $i on Thread : " + Thread.currentThread().name);
            }
        }
    }

Esto produce:

11814-11814/? I/TAG: Outer code started on Thread : main
11814-11814/? I/TAG: Outer code resumed on Thread : main
11814-11845/? D/TAG: Inner code started  on Thread : DefaultDispatcher-worker-2 making outer code suspend
11814-11845/? D/TAG: Inner code i : 1 on Thread : DefaultDispatcher-worker-2
11814-11845/? D/TAG: Inner code i : 2 on Thread : DefaultDispatcher-worker-2
11814-11845/? D/TAG: Inner code i : 3 on Thread : DefaultDispatcher-worker-2
11814-11845/? D/TAG: Inner code i : 4 on Thread : DefaultDispatcher-worker-2
11814-11845/? D/TAG: Inner code i : 5 on Thread : DefaultDispatcher-worker-2
// ---- Due to await() call, innerAsync2 will start only after innerAsync gets completed
11814-11848/? W/TAG: Inner code started  on Thread : DefaultDispatcher-worker-4 making outer code suspend
11814-11848/? W/TAG: Inner code i : 1 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 2 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 3 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 4 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 5 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 6 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 7 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 8 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 9 on Thread : DefaultDispatcher-worker-4
11814-11848/? W/TAG: Inner code i : 10 on Thread : DefaultDispatcher-worker-4
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.