Що означає функція призупинення роботи в Королінті Котліна


118

Я читаю Котлін Котлін і знаю, що він заснований на suspendфункції. Але що suspendозначає?

Спрограма чи функція припиняється?

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

В основному, супроводи - це обчислення, які можна призупинити, не перекриваючи нитку

Я чув, як люди часто кажуть "призупинити функцію". Але я гадаю, що саме цей пристрій припиняється, тому що він чекає завершення функції? "призупинити" зазвичай означає "припинити функціонування", в цьому випадку порядок роботи не працює.

🤔 Чи слід говорити про те, що дія програми призупинено?

Яку програму призупиняють?

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

Щоб продовжити аналогію, функція wait () може бути функцією призупинення (отже, її також можна викликати з блоку 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сопрограммний) Idles (звідси і назва припинення) і повертає потік для пулу потоків, і коли дитина computationспівпрограми закінчується, вона (зовнішня asyncспівпрограми ) прокидається, бере іншу нитку з басейну і продовжує?

Причина, про яку я згадую нитку, пов’язана з https://kotlinlang.org/docs/tutorials/coroutines-basic-jvm.html

Потік повертається в пул під час очікування програми, і коли очікування завершено, програма продовжується на вільній нитці в пулі.

Відповіді:


113

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

Синтаксис функції, що зупиняє, подібний до звичайної функції за винятком додавання suspendключового слова. Він може приймати параметр і мати тип повернення. Однак призупинення функцій може викликатися лише іншою функцією призупинення або в рамках програми.

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

Під капотом функції призупинення перетворюються компілятором в іншу функцію без ключового слова призупинення, яка приймає параметр додавання типу 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)
}

4
Ще одна таємниця розкрита! Чудово!
WindRider

16
Цікаво, як ця функція насправді призупинена? Вони завжди кажуть, що suspend funце можна призупинити, але як саме?
WindRider

2
@WindRider Це просто означає, що поточний потік починає виконувати якусь іншу програму, і повернеться до цього пізніше.
Джоффрі

2
Я з'ясував "таємничий" механізм. Його можна легко оприлюднити за допомогою інструментів> 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значення. Це значення не можна побачити в моделі програмування Котліна, але саме це робить компільований метод 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об'єктом, щоб він міг відновити його, коли значення повернення буде доступним.


19

Перш за все, найкращим джерелом для розуміння цього ІМО є бесіда Романа Єлизарова "Глибокий занурення в корутини" .

Спрограма чи функція припиняється?

Виклик призупинити ІНГ функції призупинення 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запускає другу програму. Потім, заклик до await()призупинення виконання зовнішньої програми async , доки не завершиться виконання внутрішньої програми async .

Ви навіть можете бачити це з однієї нитки: нитка виконає зовнішній asyncпочаток, потім зателефонує computation()і дістанеться до внутрішньої async. У цей момент тіло внутрішньої асинхрології пропускається, і нитка продовжує виконувати зовнішню, asyncпоки не досягне await(). await()є "точкою підвіски", тому що awaitце функція призупинення. Це означає, що зовнішню програму призупинено, і таким чином нитка починає виконувати внутрішню. Коли це зроблено, він повертається до виконання кінця зовнішнього async.

Чи означає призупинення, що під час зовнішньої програми асинхронного очікування (очікування) завершення внутрішньої програми обчислення, вона (зовнішня корекція асинхронізації) працює в режимі очікування (звідси і назва призупиняється) і повертає нитку до пулу потоків, і коли дочірня програма обчислення закінчується , вона (зовнішня асинхронна програма) прокидається, бере іншу нитку з пулу і продовжує?

Так, саме.

Шлях цього насправді досягається шляхом перетворення кожної функції призупинення в машину стану, де кожному "стану" відповідає точка підвіски всередині цієї функції призупинення. Під кришкою функцію можна викликати кілька разів, з інформацією про те, з якої точки підвіски її слід почати виконувати (ви дійсно повинні переглянути відео, яке я пов’язав, щоб отримати додаткову інформацію про це).


3
Чудова відповідь, я пропускаю таке дійсно базове пояснення, коли мова йде про супроводи.
bernardo.g

Чому це не реалізовано жодною іншою мовою? Або я щось пропускаю? Я так довго думаю над цим рішенням, радий, що Котлін має його, але не впевнений, чому у TS або Rust є щось подібне
PEZO

@PEZO добре розроблені програми вже давно. Котлін не вигадав їх, але синтаксис і бібліотека змушують їх сяяти. Go має goututines, JavaScript та TypeScript обіцяють. Єдина відмінність полягає в деталях синтаксису для їх використання. Мені здається, що це дуже дратує / турбує, що asyncфункції JS будуть позначені таким чином, але все ще повертають Обіцянку.
Джоффрі

Вибачте, мій коментар був незрозумілим. Я маю на увазі ключове слово призупинення. Це не те саме, що асинхронізація.
PEZO

Дякуємо, що вказали на відео Романа. Чисте золото.
Denounce'IN

8

Я виявив, що найкращий спосіб зрозуміти suspend- це зробити аналогію між thisключовим словом та coroutineContextвластивістю.

Функції Котліна можна оголосити локальними або глобальними. Місцеві функції магічно мають доступ до thisключового слова, а глобальні - ні.

Функції Котліна можуть бути оголошені як 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()
    }

І петля продовжиться. Це досить акуратно, щоб знати, що ми можемо заморозити функцію призупинення в будь-якій точці та відновити її через деякий час, коли минув час. Ви також можете заглянути в канали


4

Оскільки вже є багато хороших відповідей, я хотів би розмістити більш простий приклад для інших.

Випадок використання runBlocking :

  • myMethod () - suspendфункція
  • runBlocking { }запускає програму Coroutin за допомогою блокування. Це схоже на те, як ми блокували нормальні потоки 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це допоможе.
  • Наприклад, у наведеному нижче коді є 2функції призупинення myMethod () та myMethod2 (). myMethod2()повинні бути виконані лише після повного заповнення myMethod() АБО, myMethod2() залежить від результату myMethod(), ми можемо використовувати asyncіawait
  • asyncпочинає паралельно параметр, подібний до launch. Але це дає спосіб чекати однієї програми перед тим, як паралельно розпочати іншу програму.
  • Таким чином є await(). asyncповертає екземпляр Deffered<T>. Tбуде Unitза замовчуванням Коли нам потрібно дочекатися asyncїх завершення, нам потрібно зателефонувати .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
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.