Корутина не привязана к потоку, но выполняется всё равно на каком-то из них: процессор иначе не умеет. Решает, на каком именно, диспетчер. Выбрать его неправильно — значит либо подвесить интерфейс, либо потерять скорость на ровном месте.
Четыре готовых диспетчера
| Диспетчер | Для чего | Размер пула |
|---|---|---|
Dispatchers.Main | всё, что трогает интерфейс | один поток, главный |
Dispatchers.IO | сеть, файлы, база — то, что ждёт | до max(ядер, 64), может и больше |
Dispatchers.Default | вычисления: разбор, сортировка, алгоритмы | max(ядер, 2) |
Dispatchers.Unconfined | особые случаи, в прикладном коде не нужен | — |
Разделение построено на одном различии: ждёт задача или считает. Вычислительная задача зависит только от процессора, и потоков ей нужно ровно столько, сколько ядер, — больше не ускорит, а переключение между ними отнимет время. Задача, которая ждёт ответа, поток не нагружает, а занимает, и таких потоков не жалко сделать много.
Почему это не формальность
Разницу легко увидеть на замере. Если взять тридцать операций, каждая из которых в основном ждёт — читает файл медленными блоками, — и прогнать их на двух диспетчерах, картина получается такая:
Dispatchers.IO: 8802 мс, потоков 30
Dispatchers.Default: 34636 мс, потоков 8
Четырёхкратная разница. Default честно отработал на восьми потоках по числу ядер, и двадцать две задачи стояли в очереди, пока восемь висели в ожидании диска. IO на время ожидания просто добавил потоков.
Обратная ошибка тоже встречается: пустить тяжёлые вычисления на IO. Тогда диспетчер наплодит потоков больше, чем ядер, они начнут конкурировать за процессор, и к полезной работе добавятся накладные расходы на переключение.
withContext — как переключаться
Менять диспетчер внутри корутины нужно через withContext. Он переключает выполнение, ждёт завершения блока и возвращает результат:
viewModelScope.launch { // Main
_state.value = UiState.Loading
val profile = withContext(Dispatchers.IO) {
database.getUserProfile() // фоновый поток
}
_state.value = UiState.Ready(profile) // снова Main
}
В Android это и есть главная причина знать про диспетчеры: с Main нужно вовремя уйти. Забыли — главный поток встал, интерфейс замер, и при достаточной задержке система показывает окно «приложение не отвечает».
Правило, которое снимает половину вопросов
Диспетчер выбирает функция, а не тот, кто её вызывает.
Хорошо написанный репозиторий сам заворачивает тяжёлую работу в withContext(Dispatchers.IO), и вызвать его можно откуда угодно, в том числе с главного потока. Плохо написанный заставляет каждого вызывающего помнить, что эту функцию нельзя трогать из UI, — и рано или поздно кто-нибудь забудет.
class UserRepository(private val dao: UserDao) {
suspend fun load(id: Long): User = withContext(Dispatchers.IO) {
dao.getById(id)
}
}
Из этого следует и обратное: если репозиторий написан правильно, оборачивать его вызовы в withContext ещё раз не нужно. Переключение контекста не бесплатно, а два подряд — просто потерянное время.
limitedParallelism, когда пул слишком щедрый
У IO широкие полномочия, и иногда он создаёт больше потоков, чем нужно. Ограничить можно обёрткой:
val dispatcher = Dispatchers.IO.limitedParallelism(6)
Та же функция работает и для Default — например, если не хочется занимать все ядра многоядерной машины. А вот просить у неё больше потоков, чем есть ядер, у Default смысла нет: железа она не добавит.
Короткий чек-лист
- трогаете интерфейс —
Main; - ждёте сеть, файл или базу —
IO; - считаете —
Default; - suspend-функция сама отвечает за свой диспетчер;
Unconfinedв прикладном коде не нужен.
Контекст корутины, частью которого является диспетчер, разобран в руководстве по корутинам.