company_banner

Как заблокировать приложение с помощью runBlocking

    Когда мы начинаем изучать корутины, то «идём» и пробуем что-то простое с билдером runBlocking, поэтому многим он хорошо знаком. runBlocking запускает новую корутину, блокирует текущий поток и ждёт пока выполнится блок кода. Кажется, всё просто и понятно. Но что, если я скажу, что в runBlocking есть одна любопытная вещь, которая может заблокировать не только текущий поток, а вообще всё ваше приложение навсегда?

    Напишите где-нибудь в UI потоке (например в методе onStart) такой код:

    //где-то в UI потоке
    runBlocking(Dispatchers.Main) {
      println(“Hello, World!”)
    }

    Вы получите дедлок — приложение зависнет. Это не ошибка, а на 100% ожидаемое поведение. Тезис может показаться неочевидным и неявным, поэтому давайте погрузимся поглубже и я расскажу, что здесь происходит.


    Сравним код выше с более низкоуровневым подходом с потоками. Вы можете написать в главном потоке вот так:

    //где-то в UI потоке
    Handler().post {
      println("Hello, World!") // отработает в UI потоке
    }

    Или даже так:

    //где-то в UI потоке
    runOnUiThread {
      println("Hello, World!") // и это тоже отработает в UI потоке
    }

    Вроде конструкция очень похожа на наш проблемный код, но здесь обе части кода работают (по-разному под капотом, но работают). Чем они отличаются от кода с runBlocking?

    Как работает runBlocking

    Для начала небольшой дисклеймер. runBlocking редко используется в продакшн коде Android-приложения. Обычно он предназначен для использования в синхронном коде, вроде функций main или unit-тестах.

    Несмотря на это, мы всё-таки рассмотрим этот билдер при вызове в главном потоке Android-приложения потому, что:

    • Это наглядно. Ниже мы придем к тому, что это актуально и не только для UI-потока Android-приложения. Но для наглядности лучше всего подходит пример на UI-потоке.

    • Интересно разобраться, почему всё именно так работает.

    • Всё-таки иногда мы можем использовать runBlocking, пусть даже в тестовых приложениях.

    Билдер runBlocking работает почти так же, как и launch: создает корутину и вызывает в ней блок кода. Но чтобы сделать вызов блокирующим runBlocking создает особую корутину под названием BlockingCoroutine, у которой есть дополнительная функция joinBlocking(). runBlocking вызывает joinBlocking() сразу же после запуска корутины.

    Фрагмент из runBlocking():

    // runBlocking() function
    // …
    val coroutine = BlockingCoroutine<T>(newContext, …)
    coroutine.start(CoroutineStart.DEFAULT, coroutine, block)
    return coroutine.joinBlocking()

    Функция joinBlocking() использует механизм блокировки Java LockSupport для блокировки текущего потока с помощью функции park(). LockSupport — это низкоуровневый и высокопроизводительный инструмент, обычно используется для написания собственных блокировок.

    Кроме того, BlockingCoroutine переопределяет функцию afterCompletion(), которая вызывается после завершения работы корутины.

    override fun afterCompletion(state: Any?) {
    //wake up blocked thread
    if (Thread.currentThread ()! = blockedThread)
      LockSupport.unpark (blockedThread)
    }

    Эта функция просто разблокирует поток, если она была заблокирована до этого с помощью park().

    Как это всё работает примерно показано на схеме работы runBlocking.

    Что здесь делает Dispatchers

    Хорошо, мы поняли, что делает билдер runBlocking. Но почему в одном случае он блокирует UI-поток, а в другом нет? Почему Dispatchers.Main приводит к дедлоку...

    // Этот код создает дедлок
    runBlocking(Dispatchers.Main) {
      println(“Hello, World!”)
    }

    ...,а Dispatchers.Default — нет?

    // А этот код не создает дедлок
    runBlocking(Dispatchers.Default) { 
      println(“Hello, World!”)
    }

    Для этого вспомним, что такое диспатчер и зачем он нужен.

    Диспатчер определяет, какой поток или потоки использует корутина для своего выполнения. Это некий «высокоуровневый аналог» Java Executor. Мы даже можем создать диспатчер из Executor’а с помощью удобного экстеншна:

    public fun Executor.asCoroutineDispatcher(): CoroutineDispatcher

    Dispatchers.Default реализует класс DefaultScheduler и делегирует обработку исполняемого блока кода объекту coroutineScheduler. Его функция dispatch() выглядит так:

    override fun dispatch (context: CoroutineContext, block: Runnable) =
      try {
        coroutineScheduler.dispatch (block)
      } catch (e: RejectedExecutionException) {
        //…
        DefaultExecutor.dispatch(context, block)
      }

    Класс CoroutineScheduler отвечает за наиболее эффективное распределение обработанных корутин по потокам. Он реализует интерфейс Executor.

    override fun execute(command: Runnable) = dispatch(command)

    А что же делает функция CoroutineScheduler.dispatch()?

    • Добавляет исполняемый блок в очередь задач. При этом существует две очереди: локальная и глобальная. Это часть механизма приоритезации внешних задач.

    • Создает воркеры. Воркер — это класс, унаследованный от обычного Java Thread (в данном случае daemon thread). Здесь создаются рабочие потоки. У воркера также есть локальная и глобальная очереди, из которых он выбирает задачи и выполняет их.

    • Запускает воркеры.

    Теперь соединим всё, что разобрали выше про Dispatchers.Default, и напишем, что происходит в целом.

    • runBlocking запускает корутину, которая вызывает CoroutineScheduler.dispatch().

    • dispatch() запускает воркеры (под капотом Java потоки).

    • BlockingCoroutine блокирует текущий поток с помощью функции LockSupport.park().

    • Исполняемый блок кода выполняется.

    • Вызывается функция afterCompletion(), которая разблокирует текущий поток с помощью LockSupport.unpark().

    Эта последовательность действий выглядит примерно так.

    Перейдём к Dispatchers.Main

    Это диспатчер, который создан специально для Android. Например, при использовании Dispatchers.Main фреймворк бросит исключение, если вы не добавляете зависимость:

    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:..*'

    Перед началом разбора Dispatchers.Main стоит поговорить о HandlerContext. Это специальный класс, который добавлен в пакет coroutines для Android. Это диспатчер, который выполняет задачи с помощью Android Handler — всё просто.

    Dispatchers.Main создаёт HandlerContext с помощью AndroidDispatcherFactory через функцию createDispatcher().

    override fun createDispatcher(…) =
      HandlerContext(Looper.getMainLooper().asHandler(async = true))

    И что мы тут видим? Looper.getMainLooper().asHandler() означает, что он принимает Handler главного потока Android. Получается, что Dispatchers.Main — это просто HandlerContext с Handler’ом главного потока Android.

    Теперь посмотрим на функцию dispatch() у HandlerContext:

    override fun dispatch(context: CoroutineContext, block: Runnable) {
      handler.post(block)
    }

    Он просто постит исполняемый код через Handler. В нашем случае Handler главного потока.

    Итого, что же происходит?

    • runBlocking запускает корутину, которая вызывает CoroutineScheduler.dispatch().

    • dispatch() отправляет исполняемый блок кода через Handler главного потока.

    • BlockingCoroutine блокирует текущий поток с помощью функции LockSupport.park().

    • Main Looper никогда не получает сообщение с исполняемым блоком кода, потому что главный поток заблокирован.

    • Из-за этого afterCompletion() никогда не вызывается.

    • И из-за этого текущий поток не будет разблокирован (через unparked) в функции afterCompletion().

    Эта последовательность действий выглядит примерно так.

    Вот почему runBlocking с Dispatchers.Main блокирует UI-поток навсегда. 

    Главный поток блокируется и ждёт завершения исполняемого кода. Но он никогда не завершается, потому что Main Looper не может получить сообщение на запуск исполняемого кода. Дедлок.

    Совсем простое объяснение

    Помните пример с Handler().post в самом начале статьи? Там код работает и ничего не блокируется. Однако мы можем легко изменить его, чтобы он был в значительной степени похож на наш код с Dispatcher.Main, и стал ещё нагляднее. Для этого можем добавить операции parking и unparking к текущему потоку, иммитируя работу функций afterCompletion() и joinBlocking(). Код начинает работать почти так же, как с билдером runBlocking.

    //где-то в UI потоке
    val thread = Thread.currentThread()
    Handler().post {
      println("Hello, World!") // это никогда не будет вызвано
      // имитируем afterCompletion() 
      LockSupport.unpark(thread)
    }
    // имитируем joinBlocking()
    LockSupport.park()

    Но этот «трюк» не будет работать с функцией runOnUiThread.

    //где-то в UI потоке
    val thread = Thread.currentThread()
    runOnUiThread {
      println("Hello, World!") // этот код вызовется
      LockSupport.unpark(thread)
    }
    LockSupport.park()

    Это происходит потому, что runOnUiThread использует оптимизацию, проверяя текущий поток. Если текущий поток главный, то он сразу же выполнит блок кода. В противном случае сделает post в Handler главного потока.

    Если всё же очень хочется использовать runBlocking в UI-потоке, то у Dispatchers.Main есть оптимизация Dispatchers.Main.immediate. Там аналогичная логика как у runOnUiThread. Поэтому этот блок кода будет работать и в UI-потоке:

    //где-то в UI потоке
    runBlocking(Dispatchers.Main.immediate) { 
      println(“Hello, World!”)
    }

    Выводы

    В статье я описал как «безобидный» билдер runBlocking может заморозить ваше приложение на Android. Это произойдет, если вызвать runBlocking в UI-потоке с диспатчером Dispatchers.Main. Приложение заблокируется по следующему алгоритму:

    • runBlocking создаёт блокирующую корутину BlockingCoroutine.

    • Dispatchers.Main отправляет на запуск исполняемый блок кода через Handler.post.

    • Но BlockingCoroutine тут же заблокирует UI поток.

    • Поэтому Main Looper никогда не получит сообщение с исполняемым блоком кода.

    • А UI не разблокируется, потому что корутина ждёт завершения исполняемого кода.

    Эта статья больше теоретическая, чем практическая. Просто потому, что runBlocking редко встречается в продакшн-коде. Но примеры с UI-потоком наглядны, потому что можно сразу заблокировать приложение и разобраться, как работает runBlocking.

    Но заблокировать исполнение можно не только в UI-потоке, но и с помощью других диспатчеров, если поток вызова и корутины окажется одним и тем же. В такую ситуацию можно попасть, если мы будем пытаться вызвать билдер runBlocking на том же самом потоке, что и корутина внутри него. Например, мы можем использовать newSingleThreadContext для создания нового диспатчера и результат будет тот же. Здесь UI не будет заморожен, но выполнение будет заблокировано.

    val singleThreadDispatcher = newSingleThreadContext("Single Thread")
    GlobalScope.launch (singleThreadDispatcher) {
      runBlocking (singleThreadDispatcher) {
        println("Hello, World!") // этот кусок кода опять не выполнится
      }
    }

    Если очень надо написать runBlocking в главном потоке Android-приложения, то не используйте Dispatchers.Main. Используйте Dispatchers.Default или Dispatchers.Main.immediate в крайнем случае.


    Также будет интересно почитать:

    — Оригинал статьи на английском «How runBlocking May Surprise You».
    — Как страдали iOS-ники когда выпиливали Realm.
    — О том, над чем в целом мы тут работаем: монолит, монолит, опять монолит.
    Кратко об истории Open Source — просто развлечься (да и статья хорошая).

    Подписывайтесь на чат Dodo Engineering, если хотите обсудить эту и другие наши статьи и подходы, а также на канал Dodo Engineering, где мы постим всё, что с нами интересного происходит.

    А если хочешь присоединиться к нам в Dodo Engineering, то будем рады  — сейчас у нас открыты вакансии для Android-разработчиков (а ещё для iOS, frontend, SRE и других). Присоединяйся, будем рады!

    Dodo Engineering
    О том, как разработчики строят IT в Dodo

    Комментарии 5

      +3

      У вас хорошо получилось объяснить, даже новичек должен понять, пишите еще!

        0
        Больше — даже не разработчик понял в меру способностей (это я про себя))
          0
          Спасибо, большое!
          +1
          Сначала не понял, а потом как понял!
            +2
            Столкнулся с таким в первый (и, надеюсь, последний) раз в iOS — был очень удивлен, почему DispatchQueue.main.sync {… } намертво блокирует основной поток. Через минуту судорожного вращения глазами, конечно, понял, но удивление до сих пор помню :)

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое