Что означает функция приостановки в Kotlin Coroutine

118

Я читаю Kotlin Coroutine и знаю, что он основан на suspendфункции. Но что suspendзначит?

Coroutine или функция приостанавливается?

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

По сути, сопрограммы - это вычисления, которые можно приостановить, не блокируя поток.

Я слышал, как люди часто говорят «функция приостановки». Но я думаю, что это сопрограмма, которая приостанавливается, потому что ждет завершения функции? «приостановить» обычно означает «прекратить работу», в этом случае сопрограмма простаивает.

🤔 Следует ли говорить, что сопрограмма приостановлена?

Какая сопрограмма приостанавливается?

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

Чтобы продолжить аналогию, await () может быть функцией приостановки (следовательно, также вызываемой из блока async {}), которая приостанавливает сопрограмму до тех пор, пока не будут выполнены некоторые вычисления и не вернет свой результат:

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

🤔 В нем говорится, что «это приостанавливает выполнение сопрограммы до тех пор, пока не будут выполнены некоторые вычисления», но сопрограмма похожа на легкий поток. Итак, если сопрограмма приостановлена, как можно выполнить вычисления?

Мы видим, что awaitон вызван computation, возможно async, он возвращается Deferred, что означает, что он может запустить другую сопрограмму.

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

🤔 В цитате говорится, что выполнение сопрограммы приостанавливается . Означает ли это suspendвнешнюю asyncсопрограмму или suspendвнутреннюю computationсопрограмму?

Означает, suspendчто пока внешняя asyncсопрограмма ожидает ( await) завершения внутренней computationсопрограммы, она (внешняя asyncсопрограмма) бездействует (отсюда и название приостановлено) и возвращает поток в пул потоков, а когда computationдочерняя сопрограмма завершает работу, она (внешняя asyncсопрограмма) ) просыпается, берет другой поток из пула и продолжает?

Причина, по которой я упоминаю эту ветку, связана с https://kotlinlang.org/docs/tutorials/coroutines-basic-jvm.html

Поток возвращается в пул, пока сопрограмма ожидает, и когда ожидание завершено, сопрограмма возобновляет работу на свободном потоке в пуле.

onmyway133
источник

Ответы:

115

Приостановка функций находится в центре всех сопрограмм. Функция приостановки - это просто функция, которую можно приостановить и возобновить позже. Они могут выполнять длительную операцию и ждать ее завершения без блокировки.

Синтаксис функции приостановки аналогичен синтаксису обычной функции, за исключением добавления suspendключевого слова. Он может принимать параметр и иметь возвращаемый тип. Однако функции приостановки могут быть вызваны только другой функцией приостановки или внутри сопрограммы.

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

Под капотом функции приостановки преобразуются компилятором в другую функцию без ключевого слова suspend, которая принимает дополнительный параметр типа Continuation<T>. Например, приведенная выше функция будет преобразована компилятором в следующую:

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

Continuation<T> - это интерфейс, содержащий две функции, которые вызываются для возобновления работы сопрограммы с возвращаемым значением или с исключением, если произошла ошибка, когда функция была приостановлена.

interface Continuation<in T> {
   val context: CoroutineContext
   fun resume(value: T)
   fun resumeWithException(exception: Throwable)
}
Софиен Рахмуни
источник
5
Еще одна загадка раскрыта! Большой!
WindRider
16
Интересно, как эта функция фактически приостановлена? Всегда говорят, что suspend funможно поставить на паузу, но как именно?
WindRider
2
@WindRider Это просто означает, что текущий поток начинает выполнение какой-то другой сопрограммы и вернется к ней позже.
Джоффри
2
Я выяснил «загадочный» механизм. Его легко открыть с помощью Tools> Kotlin> Bytecode> Decompile btn. Он показывает, как реализуется так называемая «точка приостановки» - через продолжение и так далее. Каждый может посмотреть на себя.
WindRider
4
@buzaa Вот доклад Романа Елизарова от 2017 года, который объясняет это на уровне байт-кода.
Марко Топольник
30

Чтобы понять, что именно означает приостановка сопрограммы, я предлагаю вам просмотреть этот код:

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
        }
    }
}

UnconfinedСопрограммный диспетчер устраняет магию сопрограммной диспетчеризации и позволяет сосредоточиться непосредственно на голых сопрограммах.

Код внутри launchблока сразу же начинает выполняться в текущем потоке как часть launchвызова. Происходит следующее:

  1. Оценить val a = a()
  2. Это цепляет b(), тянется suspendCoroutine.
  3. Функция b()выполняет переданный блок suspendCoroutineи затем возвращает специальное COROUTINE_SUSPENDEDзначение. Это значение не наблюдается через модель программирования Kotlin, но это то, что делает скомпилированный метод Java.
  4. Функция a(), увидев это возвращаемое значение, сама его также возвращает.
  5. launchБлок делает то же самое и управление теперь возвращается к строке после launchвызова:10.downTo(0)...

Обратите внимание, что на этом этапе вы получаете такой же эффект, как если бы код внутри launchблока и ваш fun mainкод выполнялись одновременно. Просто так случается, что все это происходит в одном собственном потоке, поэтому launchблок "приостановлен".

Теперь внутри forEachкода цикла программа читает, continuationчто b()написала функция, и resumesпринимает значение 10. resume()реализован таким образом, что это будет так, как если бы suspendCoroutineвызов вернул значение, которое вы передали. Итак, вы внезапно окажетесь в середине выполнения b(). Значение, которое вы передали, resume()присваивается iи проверяется 0. Если он не равен нулю, while (true)цикл продолжается внутри b(), снова достигая suspendCoroutine, после чего ваш resume()вызов возвращается, и теперь вы проходите еще один шаг цикла forEach(). Это продолжается до тех пор, пока вы, наконец, не продолжите с 0, затем выполняется printlnоператор и программа завершается.

Приведенный выше анализ должен дать вам важную интуицию, что «приостановка сопрограммы» означает возвращение элемента управления к самому внутреннему launchвызову (или, в более общем смысле, к построителю сопрограмм ). Если сопрограмма снова приостанавливается после возобновления, resume()вызов завершается, и управление возвращается вызывающей стороне resume().

Наличие диспетчера сопрограмм делает эти рассуждения менее четкими, поскольку большинство из них немедленно отправляют ваш код в другой поток. В этом случае описанная выше история происходит в этом другом потоке, и диспетчер сопрограмм также управляет continuationобъектом, чтобы он мог возобновить его, когда возвращаемое значение станет доступным.

Марко Топольник
источник
21

Прежде всего, лучший источник для понимания этой IMO - это доклад Романа Елизарова «Глубокое погружение в сопрограммы» .

Coroutine или функция приостанавливается?

Вызов приостановить ИНГ функции приостановки S на сопрограмму, что означает текущий поток может начать выполнение другой сопрограмму. Таким образом, считается , что сопрограмма приостановлена, а не функция.

Фактически, по этой причине места вызова функций приостановки называются «точками приостановки».

Какая сопрограмма приостанавливается?

Давайте посмотрим на ваш код и разберемся, что происходит:

// 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)
    }
}

Внешний asyncзапускает сопрограмму. Когда он вызывает computation(), внутренний asyncзапускает вторую сопрограмму. Затем вызов to await()приостанавливает выполнение внешней async сопрограммы до тех пор, пока выполнение внутренней async сопрограммы не завершится.

Вы даже можете увидеть это с помощью одного потока: поток выполнит внешнее asyncначало, затем вызовет computation()и достигнет внутреннего async. В этот момент тело внутреннего async пропускается, и поток продолжает выполнение внешнего, asyncпока не достигнет await(). await()это «точка приостановки», потому что awaitэто функция приостановки. Это означает, что внешняя сопрограмма приостановлена, и, таким образом, поток начинает выполнять внутреннюю. Когда это будет сделано, он возвращается, чтобы выполнить конец внешнего async.

Приостановка означает, что пока внешняя асинхронная сопрограмма ожидает (ожидание) завершения внутренней вычислительной сопрограммы, она (внешняя асинхронная сопрограмма) бездействует (отсюда и название приостановлено) и возвращает поток в пул потоков, а когда дочерняя вычислительная сопрограмма завершается , он (внешняя асинхронная сопрограмма) просыпается, берет другой поток из пула и продолжает?

Да, именно так.

На самом деле это достигается путем превращения каждой функции приостановки в конечный автомат, где каждое «состояние» соответствует точке приостановки внутри этой функции приостановки. Под капотом функцию можно вызывать несколько раз с информацией о том, с какой точки приостановки она должна начинать выполнение (вам действительно стоит посмотреть видео, которое я связал для получения дополнительной информации об этом).

Джоффри
источник
3
Отличный ответ, мне не хватает такого действительно простого объяснения, когда дело касается сопрограмм.
bernardo.g
Почему это не реализовано ни на каком другом языке? Или я что-то упускаю? Я так долго думаю об этом решении, рад, что он есть в Котлине, но не уверен, почему у TS или Rust есть что-то подобное
PEZO
@PEZO ну сопрограммы существуют уже давно. Котлин не изобретал их, но синтаксис и библиотека заставляют их сиять. В Go есть горутины, у JavaScript и TypeScript есть обещания. Единственное различие заключается в деталях синтаксиса для их использования. Я нахожу довольно раздражающим / тревожным то, что asyncфункции JS помечаются таким образом и все же возвращают обещание.
Джоффри
Извините, мой комментарий был непонятным. Я имею в виду ключевое слово Suspend. Это не то же самое, что асинхронный.
PEZO
Спасибо, что указали на видео Романа. Чистое золото.
Denounce'IN
8

Я обнаружил, что лучший способ понять это suspend- провести аналогию между thisключевым словом и coroutineContextсвойством.

Функции Kotlin могут быть объявлены как локальные или глобальные. Локальные функции волшебным образом имеют доступ к thisключевому слову, а глобальные - нет.

Функции Kotlin могут быть объявлены как suspendблокирующие или блокирующие. suspendфункции волшебным образом получают доступ к coroutineContextсвойствам, а блокирующие функции - нет.

Дело в том, что coroutineContextсвойство объявлено как «обычное» свойство в Kotlin stdlib, но это объявление является лишь заглушкой для целей документации / навигации. Фактически coroutineContextэто встроенное внутреннее свойство, которое означает, что под капотом компилятора магия знает об этом свойстве, как и о ключевых словах языка.

Что thisключевое слово делает для локальных функций, так и coroutineContextсвойство делает для suspendфункций: оно дает доступ к текущему контексту выполнения.

Итак, вам нужно suspendполучить доступ к coroutineContextсвойству - экземпляру текущего контекста сопрограммы.

Дмитрий Колесникович
источник
5

Я хотел дать вам простой пример концепции продолжения. Это то, что делает функция приостановки: она может заморозить / приостановить, а затем продолжить / возобновить. Перестаньте думать о сопрограммах в терминах потоков и семафоров. Подумайте об этом с точки зрения продолжения и даже хуков обратного вызова.

Чтобы было ясно, сопрограмму можно приостановить с помощью suspendфункции. давайте исследуем это:

В android мы могли бы сделать это, например:

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")
                }
            }
        }

Приведенный выше код печатает следующее:

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

представьте, что он работает так:

введите описание изображения здесь

Таким образом, текущая функция, из которой вы запустили, не останавливается, просто сопрограмма приостанавливается, пока она продолжается. Поток не приостанавливается с помощью функции приостановки.

Я думаю, что этот сайт может помочь вам разобраться и является моим справочником.

Давайте сделаем что-нибудь классное и остановим нашу функцию приостановки в середине итерации. Мы возобновим его позжеonResume

Сохраните переменную с именем, continuationи мы загрузим ее с объектом продолжения сопрограмм:

var continuation: CancellableContinuation<String>? = null

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

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

Теперь вернемся к нашей приостановленной функции и заставим ее зависнуть в середине итерации:

 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
                }
            }
        }
    }

Затем где-нибудь еще, например, в onResume (например):

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

И цикл будет продолжен. Приятно знать, что мы можем заблокировать функцию приостановки в любой момент и возобновить ее по прошествии некоторого времени. Вы также можете посмотреть каналы

j2emanue
источник
4

Поскольку уже есть много хороших ответов, я хотел бы опубликовать более простой пример для других.

runBlocking использования :

  • myMethod () - это suspendфункция
  • runBlocking { }запускает Coroutine в режиме блокировки. Это похоже на то, как мы блокировали обычные потоки с помощьюThread класса и уведомляли заблокированные потоки после определенных событий.
  • runBlocking { }действительно блокирует ток выполняющийся поток, пока сопрограммы (тело между {}) будет завершена

     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);
        }
    }

Это выводит:

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

запуск варианта использования:

  • launch { } одновременно запускает сопрограмму.
  • Это означает, что когда мы указываем запуск, сопрограмма начинает выполнение на worker потоке.
  • Поток workerи внешний поток (из которого мы вызвали launch { }) работают одновременно. Внутри JVM может выполнять вытесняющую потоковую передачу.
  • Когда нам требуется, чтобы несколько задач выполнялись параллельно, мы можем использовать это. Есть те, scopesкоторые определяют время жизни сопрограммы. Если мы укажем GlobalScope, сопрограмма будет работать до истечения времени жизни приложения.

    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);
            }
        }
    }

Это выводит:

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

вариант использования async и await :

  • Когда у нас есть несколько задач, которые нужно выполнить, и они зависят от выполнения других asyncи awaitмогут помочь.
  • Например, в приведенном ниже коде есть 2функции приостановки myMethod () и myMethod2 (). myMethod2()должен выполняться только после полного завершения myMethod() ИЛИ, в myMethod2() зависимости от результата myMethod(), мы можем использовать asyncиawait
  • asyncзапускает сопрограмму параллельно аналогично launch. Но он предоставляет способ дождаться одной сопрограммы перед параллельным запуском другой сопрограммы.
  • Это так await(). asyncвозвращает экземпляр Deffered<T>. Tбудет Unitпо умолчанию. Когда нам нужно ждать каких - либо asyncзавершения «s, нам нужно вызвать .await()на Deffered<T>экземпляре что async. Как и в приведенном ниже примере, мы вызвали, innerAsync.await()что означает, что выполнение будет приостановлено до innerAsyncзавершения. То же самое мы можем наблюдать на выходе. innerAsyncБудет завершена первая, в которой содержится призыв myMethod(). А затем async innerAsync2начинается следующий , который вызываетmyMethod2()

    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);
            }
        }
    }

Это выводит:

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
Кушал
источник