Dispatchers.IO, Default и Main: где выполняется корутина

Корутина не привязана к потоку, но выполняется всё равно на каком-то из них: процессор иначе не умеет. Решает, на каком именно, диспетчер. Выбрать его неправильно — значит либо подвесить интерфейс, либо потерять скорость на ровном месте.

Четыре готовых диспетчера

ДиспетчерДля чегоРазмер пула
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 в прикладном коде не нужен.

Контекст корутины, частью которого является диспетчер, разобран в руководстве по корутинам.