Многопоточность в Kotlin для Android-приложений
Я Равиль Зарипов, разработчик из Алматы. В Android-проектах я применяю Kotlin и корутины, чтобы сетевые запросы, операции с базами данных и тяжёлые вычисления не задерживали интерфейс. Это особенно важно на устройствах среднего класса: лишняя работа главного потока быстро превращается в задержки касаний и прокрутки.
Между многопоточностью на уровне операционной системы и конкурентностью в Kotlin есть важная разница. Поток надёжно изолирует выполнение, но создавать его для каждого действия дорого. Корутина приостанавливает работу без блокировки потока, а диспетчер определяет, где её продолжить. На практике это позволяет выбирать подходящую среду для сети, вычислений и обновления экрана.
Потоки операционной системы и корутины
Класс Thread запускает отдельный поток ОС и подходит для длительного изолированного процесса. У него собственный стек вызовов, а завершение и ошибки требуют отдельной обработки. Корутина использует обычный стек вызовов Kotlin, приостанавливается в точках suspend и продолжает выполнение без создания нового потока.
Несколько корутин могут работать последовательно в одном потоке. Для параллельных вычислений диспетчер переключает потоки, а для сетевого ожидания освобождает текущий поток. Поэтому число корутин не равно числу потоков: сто тысяч ожидающих сетевых ответов задач не создадут сто тысяч потоков ОС. Сравнение по типу нагрузки помогает выбрать механизм осознанно.
| Механизм | Среда выполнения | Подходящая задача | Основное ограничение |
|---|---|---|---|
Thread |
Собственный поток ОС | Изолированный длительный процесс | Управление жизненным циклом и переключениями |
Dispatchers.Main |
Главный поток Android | Обновление интерфейса | Нельзя выполнять тяжёлые операции |
Dispatchers.IO |
Пул для блокирующего ввода-вывода | Сеть, файлы, базы данных | Большое число блокирующих вызовов исчерпывает пул |
Dispatchers.Default |
Пул вычислительных задач | Обработка данных, шифрование, сжатие | Параллелизм ограничен числом ядер |
В Android код, управляющий интерфейсом, обычно работает через Dispatchers.Main. Тяжёлые расчёты переносят в Dispatchers.Default, а блокирующие операции с файлами, сетью и базами — в Dispatchers.IO. withContext возвращает управление исходному контексту после завершения блока, поэтому его удобно использовать вокруг отдельного этапа работы.
Выбор среды выполнения задач
Dispatchers.IO предназначен для блокирующего ввода-вывода, но сам по себе не ускоряет код. Если внутри него выполняется тяжёлое преобразование данных, вычислительную часть лучше перенести в Dispatchers.Default. Смешивание задач без анализа иногда приводит к тому, что пул ввода-вывода занят вычислениями, а вычислительный пул простаивает.
Одновременных запросов к базе полезно ограничивать. Например, limitedParallelism позволяет создать отдельную группу с небольшим пределом параллелизма, если используемая версия библиотеки предоставляет такой API. Фиксированное число следует выбирать после замеров: слишком большой лимит создаст давление на память и сеть, слишком маленький замедлит очередь.
Подход к ограничению ресурсов хорошо виден в материале о кустарниках для небольшого сада: каждому растению нужны подходящие условия, а число посадок зависит от площади и доступных ресурсов. В приложении аналогом служат размер очереди, лимит соединений и бюджет времени.
Структурированная конкурентность
Структурированная конкурентность связывает сроки фоновой работы с жизненным циклом вызывающего кода. coroutineScope ждёт завершения дочерних корутин и отменяет их при ошибке. supervisorScope применяют, когда независимые задачи должны переживать сбой соседней: неудача одного ребёнка не отменяет остальных.
async удобен для получения результата: несколько независимых запросов запускают параллельно, а await получает их значения. launch выбирают для событий и фоновых действий без ожидаемого значения. Если задачи связаны зависимостью, выполнять их параллельно нельзя: второй запрос может начаться раньше готовности данных первого.
Отдельный CoroutineScope не следует создавать в Activity или Fragment без ясного владельца и способа отмены. В Android удобнее viewModelScope для логики экрана и lifecycleScope для работы, привязанной к жизненному циклу компонента. GlobalScope лишён такой связи и способен продолжить задачу после ухода пользователя со страницы.
Общие изменяемые данные требуют синхронизации. Если состояние доступно нескольким корутинам, применяют Mutex или другие подходящие примитивы, а для отдельных счётчиков используют атомарные операции. Передача неизменяемых данных уменьшает риск гонок и упрощает воспроизведение ошибки.
Отмена задач и жизненный цикл
Отмена в Kotlin кооперативная: корутина должна сама проверить isActive или вызвать suspend-функцию, которая отреагирует на отмену. Блокирующий вызов внутри Dispatchers.IO не становится прерываемым лишь от того, что он выполняется в фоне. Долгий цикл поэтому разбивают на небольшие шаги и регулярно проверяют состояние задачи.
При уходе с экрана область lifecycleScope отменяет её корутины, а viewModelScope продолжает работу, пока существует ViewModel. Для сбора потоков данных в интерфейсе применяют repeatOnLifecycle с состоянием RESUMED: сбор приостанавливается, когда экран неактивен, и возобновляется без дублирования подписок.
Ошибки внутри launch не возвращаются вызывающему коду. Их обрабатывают через CoroutineExceptionHandler или явный try/catch, если ошибка ожидаема. CancellationException не следует проглатывать: отмена задачи — нормальный сценарий. Для независимого обновления кэша или аналитики удобно выделять отдельную область с supervisorScope.
Реактивное состояние и потоки данных
Flow описывает последовательность значений и остаётся холодным, пока у него есть подписчик: каждый новый сбор запускает исходный код заново. StateFlow хранит последнее значение и сразу передаёт его новым подписчикам. SharedFlow предназначен для событий, наблюдаемых несколькими получателями, и по умолчанию не хранит предыдущие значения.
Для состояния экрана StateFlow обычно удобнее: у него всегда есть актуальное значение, а повторная подписка не требует отдельного кэша. Если событие нельзя потерять, обычный SharedFlow без буфера подходит не всегда; потребуется настроить extraBufferCapacity или использовать Channel, учитывая время жизни подписчика.
Медленный потребитель не должен тормозить источник данных. buffer разделяет производство и обработку, а conflate оставляет только последнее значение, если промежуточные результаты не важны. Для разделяемого потока применяют shareIn или stateIn, чтобы не пересчитывать один и тот же источник для каждого экрана. Тяжёлое преобразование лучше выполнять в flowOn с подходящим диспетчером.
Тестирование и профилирование
Тесты конкурентного кода требуют контроля времени и диспетчеров. В kotlinx-coroutines-test используют runTest, StandardTestDispatcher и advanceUntilIdle, чтобы не ждать реальные секунды. Для кода, обращающегося к Dispatchers.Main, устанавливают тестовый главный диспетчер. Такой подход делает задержки воспроизводимыми и уменьшает число нестабильных проверок.
На реальном устройстве проверяют не только правильность результата, но и время отклика интерфейса. CPU Profiler показывает, где расходуются циклы, а System Trace помогает увидеть конкуренцию потоков и лишние пробуждения. StrictMode способен указать на дисковый или сетевой вызов в главном потоке. Замеры стоит проводить на медленном смартфоне и с типичным объёмом данных.
Нагрузочная проверка должна включать большой ответ сервера, медленную сеть и повторное открытие экрана. Одновременные запросы нередко создают больше конкуренции, чем требуется, поэтому сначала измеряют базовый сценарий, затем сравнивают варианты. Оценивают также расход батареи, температуру устройства и стабильность после длительного фонового выполнения.
Практические подходы и опыт разработчика
Надёжный Android-код не запускает фоновую работу без владельца, не оставляет необработанные ошибки и не переносит блокирующие вызовы на главный поток. Для каждого экрана можно выбрать viewModelScope, определить отменяемые этапы и хранить наблюдаемое состояние в StateFlow. Эти решения проще проверять, чем набор корутин с разными глобальными и локальными областями.
Мой опыт выходит за пределы Android: я разрабатываю приложения для iOS, Windows, macOS и Linux, используя Delphi, FireMonkey, Kotlin, Swift, PHP и технологии баз данных. Переносимые архитектурные принципы — явные границы задач, отмена, наблюдаемость и разделение представления с логикой — остаются полезными на разных платформах. Материалы о проектах и используемых технологиях собраны в https://rzaripov.kz/catalog.php.
В заметках блога я разбираю решения, которые затем использую в рабочих приложениях, и связываю их с примерами кода. Практическая ценность многопоточности проявляется не в количестве запущенных корутин, а в предсказуемом времени отклика, понятных отказах и отсутствии фоновых операций, продолжающихся после закрытия экрана.