Kotlin Coroutines — це не лише про конкурентність
1 жовтня 2023 р.13 хв читанняОновлено 22 лютого 2026 р.
Кожного разу, коли ви чуєте про Kotlin Coroutines, ви, ймовірно, думаєте про просте, лаконічне та продуктивне рішення для обробки асинхронних завдань, таких як, наприклад, мережеві запити. Але чи це їхня єдина мета? Розгляньмо використання Kotlin Coroutines за межами конкурентності.
Примітиви Kotlin Coroutines
Почнемо з розуміння того, як працює основний механізм корутин. Якщо ми поглянемо на примітиви Kotlin Coroutines, ми побачимо лише кілька класів і функцій:
ContinuationCoroutineContextsuspendCoroutinecreateCoroutinestartCoroutine
Це все, що ми маємо в нашій
kotlin-stdlib. Але для чого вони використовуються? Давайте заглибимося в те, як спроєктована робота Kotlin Coroutines.Continuation
Continuation — це просто інтерфейс з двома членами:
context та resumeWith:kotlin
Що він робить і яка його мета?
Continuation — це буквально Корутина в голому вигляді. Це як "прокачаний" колбек (callback) з можливістю передавати додаткову динамічну інформацію та надавати корисний результат виконання корутині.Але так як ми ще не говорили про
CoroutineContext, давайте почнемо з resumeWith.resumeWith
Як і будь-який інший колбек, це функція, яка викликається, коли корутина завершує свою роботу. Вона використовує
kotlin.Result для безпечного поширення будь-яких винятків, що виникають всередині корутини.Отже, ми можемо буквально створити нашу логіку конкурентності, використовуючи
Continuation наступним чином:kotlin
suspendCoroutine— це міст між світом корутин та кодом, що не базується на корутинах.
Або використовувати існуючий асинхронний код (псевдокод):
kotlin
Continuation<in T>.resume(..)— це розширення, щоб уникнути передачіkotlin.Resultкожного разу.
Отже, ми можемо не лише реалізовувати нашу логіку конкурентності, але й використовувати існуючу та змусити її працювати з Kotlin Coroutines.
startCoroutine
Також ми можемо запускати suspend-функції з non-suspend контекстів, використовуючи
startCoroutine. У Kotlin це завжди використовується в кінці, якщо ваша функція main є suspend.kotlinx.coroutinesвикористовує цей же механізм для запуску корутин, але логіка там, звісно, набагато складніша.
kotlin
Але, звісно, ви не можете просто викликати suspend-функції зkotlinx.coroutinesпри виконанні корутин таким чином — код, який орієнтується наkotlinx.coroutines, очікує деякі примітиви, які доступні лише при створенні корутин за допомогою цієї бібліотеки.
CoroutineContext
Тепер ми підійшли до іншого члена
Continuation — CoroutineContext. Для чого він?CoroutineContext — це провайдер, який поширює необхідні дані в корутину. У реальному світі це зазвичай про передачу параметрів через складний ланцюжок корутин.Точніше кажучи, CoroutineContext уkotlinx.coroutinesвідповідає за структуровану конкурентність (structured concurrency).
Простий приклад
Давайте створимо з нашого попереднього прикладу для
startCoroutine код з можливістю отримувати значення з CoroutineContext:kotlin
CoroutineContext.Element— це абстракція, яка використовується для зберігання елементів всерединіCoroutineContextCoroutineContext.Key— це ідентифікаторCoroutineContext.Element.
Ви можете погратися з цим кодом тут.
Приклад з реального проєкту
Уявімо, що у нас є наш API сервіс. Зазвичай нам потрібно мати певний шар (layer) авторизації, тож розглянемо наступний приклад (для цього я взяв gRPC):
kotlin
CoroutineContext у kotlinx.coroutines — це буквально просто Map<K : CoroutineContext.Key, V : CoroutineContext.Element> (якщо точніше, це ConcurrentHashMap на JVM, наприклад), так само як це було в нашому прикладі вище. Але, якщо ми говоримо про kotlinx.coroutines, він поширюється на всі дочірні корутини в межах бажаної корутини (у нас не було такого механізму).Отже, тепер ми можемо отримати його в дочірніх корутинах:
kotlin
Цікавий факт: coroutineContext — це єдина властивість у Kotlin, яка має модифікаторsuspend. 👀
Для gRPC нам також потрібно зареєструвати наш Interceptor і написати наші RPC. Але ідея цього рішення для gRPC проста — відокремити логіку та спростити досвід розробника.
Для Java gRPC використовує ThreadLocal, тому ми також можемо розглядатиCoroutineContextяк альтернативуThreadLocal. Ми не можемо використовуватиThreadLocalвсередині корутин, тому що зазвичай корутина не прив'язана до конкретного потоку (особливо, коли ми говоримо проwithContext). Корутини, швидше за все, будуть відновлені на іншому потоці (крім того, різні корутини можуть виконуватися на одному потоці).
Але чи не означає це, що єдина причина існування корутин — це конкурентність? Дозвольте пояснити.
Sequence
Одним із найпоширеніших прикладів є —
kotlin.sequences.Sequence<T>. Коротко кажучи, це "лінива" колекція, яка ітерується лише тоді, коли ви починаєте споживати елементи. Ви можете прочитати про них більше тут.Якщо ви коли-небудь дивилися на вихідний код
SequenceScope, він використовує suspend-функції "під капотом":kotlin
@RestrictSuspensionзабороняє споживачам викликати suspend-функції, які не є членами класу.
Отже, ідея полягає в тому, що елементи споживаються ліниво. Ви можете використовувати їх як звичайні колекції та користуватися перевагами лінивої ітерації.
Але як це працює всередині? Давайте поглянемо на вихідний код реалізації:
kotlin
COROUTINE_SUSPENDED— це спеціальна константа, яка використовується внутрішньо компілятором Kotlin для управління призупиненням та відновленням корутин. Це не те, з чим розробники зазвичай взаємодіють безпосередньо, а скоріше служить внутрішнім сигналом у механізмі корутин.
Виглядає трохи складно для читання, чи не так? Давайте пройдемося крок за кроком:
- Спочатку ми починаємо зі станів. У нас є наступні стани, поговоримо про них коротко:
- State_NotReady: Ітератор не готовий надати елемент прямо зараз. Він може чекати на операцію або подальшу обробку, щоб зробити елемент доступним.
- State_ManyNotReady: Ітератор готовий надати кілька елементів, але вони не готові негайно. Він чекає сигналу, що елементи готові для споживання (по суті, чекає термінального оператора).
- State_ManyReady: Ітератор готовий надати кілька елементів прямо зараз. Він може негайно видати наступний елемент з послідовності.
- State_Ready: Ітератор має один елемент, готовий до надання. Він налаштований негайно видати елемент при запиті.
- State_Done: Ітератор більше не має елементів для надання. Він завершив свою роботу зі створення елементів з послідовності. Ми досягаємо цього стану, коли залишаємо
SequenceBuilder. - State_Failed: Сталося щось несподіване, і ітератор зіткнувся з проблемою. Зазвичай це не повинно траплятися.
hasNextна основі стану повертає значення або набір значень, коли він готовий до споживання. Більше того, він починає виконання послідовності на кожній ітерації всерединіwhile. Отже, якщо єState_NotReady, він робить його готовим, виконуючи наступні yield'и.- Функція
nextотримує наступний елемент з ітератора на основі його поточного стану (аналогічно доhasNext). Якщоnextбуло викликано безhasNext, ви можете потрапити вnextNotReady(). В інших ситуаціях він просто поверне значення. - Функція
yieldпросто змінює стани реалізації ітератора послідовності. Коли додаються нові елементи, він змінюється наState_Ready. ВикористанняsuspendCoroutineUninterceptedOrReturnпризупиняє корутину (виконання) і відновлює її пізніше. Вона буде запущена, коли попередня корутина (точка призупинення) буде завершена.
Щоб завершити моє пояснення, давайте просто закінчимо тим, як ми могли б зробити ту ж функціональність, просто використовуючи колбеки:
kotlin
Але це виглядає трохи складно для читання, чи не так? Ось чому корутини корисні в цій конкретній ситуації.
Врешті-решт, це не виглядає так складно, чи не так?
DeepRecursiveScope
Тепер обговоримо інший випадок використання Kotlin Coroutines —
DeepRecursiveScope. Як ви, напевно, знаєте, зазвичай, коли конкретна функція викликає саму себе, у нас є ймовірність зіткнутися з StackOverflowError, оскільки кожен виклик додається до нашого стека.З тією ж метою, наприклад, також існує мовна конструкціяtailrec. Різниця в тому, щоtailrecне може мати розгалуження (умовні перевірки) з викликами інших функцій.Ви можете прочитати про це більше тут.
Отже,
DeepRecursiveScope не покладається на традиційний потік стека, а використовує всі можливості, які пропонують корутини. Щоб зрозуміти це краще, розглянемо класичний приклад з числами Фібоначчі:kotlin
Для більш складного прикладу ви можете звернутися до kdoc.
Ми не будемо зупинятися на точних деталях реалізації
DeepRecursiveScope (ви можете поглянути на це тут), оскільки він має ту ж ідею, що й Sequence з додатковою поведінкою для підтримки механізмів, що надаються, але давайте обговоримо, як Kotlin Coroutines вирішують цю конкретну проблему. Крім того, є дуже хороша стаття про це від Романа Єлізарова.Корутини всередині
Як саме це вирішує проблему? Як я вже згадував раніше, корутини натхненні CPS (Continuation Passing Style), але це не зовсім те, що робить компілятор Kotlin для ефективної обробки корутин.
Компілятор Kotlin використовує комбінацію оптимізацій для ефективного управління стеком корутин та виконанням. Давайте перевіримо, що саме він робить:
- Трансформації компілятора: Компілятор Kotlin генерує машину станів (State Machine), подібну до тієї, що ми бачили в деталях реалізації Sequences. Це не позбавляє від усіх викликів стека, але зменшує їх достатньо, щоб не зіткнутися з
StackOverflowError. - Виділення пам'яті для Continuations в кучі (Heap-Allocation): У традиційному ланцюжку колбеків кожен виклик функції додає дані до стека викликів. Якщо ланцюжок глибокий, це може споживати багато місця в стеку. У підході з корутинами, коли корутина призупиняється, її continuation зберігається в кучі (heap) як об'єкт. Цей об'єкт continuation містить необхідну інформацію (стек, диспетчер тощо) для відновлення виконання корутини. Це зберігання в кучі дозволяє мати значно більшу місткість для обробки глибоких ланцюжків викликів без ризику переповнення стека.
Точний механізм корутин наступний:
- Серіалізація: Стан стека призупиненої корутини зберігається в об'єкті continuation, виділеному в кучі.
- Відновлення: Коли корутина готова до відновлення, фреймворк налаштовує нативний стек, щоб імітувати захоплений стан.
- Копіювання пам'яті: Серіалізований стан стека копіюється з об'єкта continuation в нативний стек.
- Конфігурація контексту: Контекст виконання налаштовується відповідно до початкового стану.
- Лічильник команд (Program Counter): Лічильник команд встановлюється на збережене значення для правильної інструкції.
- Виклик: Код continuation викликається за допомогою CPS, відновлюючи виконання.
Відновлення стека також допомагає нам з відновленням на різних потоках, оскільки вони нічого не знають про стек нашої корутини.
Отже, відтепер ми можемо зрозуміти, як корутини працюють зсередини. Давайте перейдемо до інших прикладів, де корутини Kotlin використовуються за межами конкурентності.
Jetpack Compose
Якщо ви коли-небудь працювали з Compose і, наприклад, обробляли події вказівника (pointer events), ви, ймовірно, помічали, що для прослуховування оновлень використовуються деякі хаки з Coroutines:
kotlin
Отже, як ви бачите, scope, який використовується для обробки подій вказівника, позначений
@RestrictsSuspension. Якщо ми звернемося до наданої документації, ми побачимо наступне:markdown
awaitPointerEvent обробляється за допомогою примітивів kotlin, без kotlinx.coroutines. Оскільки в такій ситуації нам не потрібна логіка kotlinx.coroutines (це просто колбек, який викликається з потоку Main-looper після дії користувача).Висновок
На завершення, ця стаття дослідила різні аспекти Kotlin Coroutines, підкреслюючи їх універсальність за межами традиційних завдань конкурентності. Ми заглибилися у внутрішню роботу примітивів корутин, обговорили їх використання в Sequences та вирішення складних проблем, таких як глибока рекурсія, і розглянули приклади з реального світу, які демонструють їх широке застосування. Назва "Coroutines — це не лише про конкурентність" влучно відображає різноманітні можливості, які пропонують корутини Kotlin у сучасній розробці програмного забезпечення.
Не соромтеся невимушено поділитися своїм досвідом у розмові біля кулера!
Вам також може сподобатися
20 хв читання
Помилки, які ми моделюємо неправильно
Чому повернення null, кидання ексепшнів або загортання всього в Result — це не просто питання стилю, а контракт, який ви визначаєте.
Читати нотатку
24 хв читання
Семантична типізація, яку ми ігноруємо
Перейдіть від менталітету 'це просто стрінг' до підходу 'це концепція'. Дослідіть семантичну типізацію в Kotlin для створення кращих доменних моделей, що документують самі себе.
Читати нотатку