УДК 004.422.832

Исследование развития многопоточности в Android – приложениях

Платонов Сергей Витальевич – студент факультета института компьютерных технологий и защиты информации Казанского национального исследовательского технического университета им. А. Н. Туполева – КАИ.

Научный руководитель Валитова Наталья Львовна – кандидат технических наук, доцент кафедры прикладной математики и информатики Казанского национального исследовательского технического университета им. А. Н. Туполева – КАИ.

Аннотация: За более чем 15 лет развития Android средства управления многопоточностью претерпели значительные изменения. Использование части компонентов было признано нежелательным. В то же время были представлены новые подходы, позволяющие устранить существовавшие ранее недостатки. В статье описаны средства параллельного программирования, используемые на различных этапах развития разработки приложений для Android. Оценены их преимущества и недостатки. Знание этапов развития многопоточности в Android-приложениях важно, так как позволяет разработчикам быстрее понимать принципы работы приложений, написанных много лет назад.

Ключевые слова: Android-разработка, многопоточность в Android, Java Thread, Kotlin Coroutines, AsyncTask, состояние гонки, параллельное программирование на Kotlin.

При запуске Android-приложения создаётся поток, называемый главным (main). Он отвечает за обработку взаимодействия пользователя с интерфейсом. Главный поток реагирует на события, поступающие из других приложений и процессов, а также на обратные вызовы, связанные с жизненным циклом. Если в ответ на одно из этих событий в главном потоке будет выполняться длительная операция: обмен данными по сети, запрос к базе данных, интенсивные вычисления, поток будет заблокирован — занят этой работой. Какое-то время приложение не сможет реагировать на действия пользователя. С точки зрения пользователя, приложение зависает. Если главный поток будет заблокирован в течение приблизительно пяти секунд, пользователь увидит диалог «Приложение не отвечает» (ANR). Многочисленные или длительные задачи необходимо выполнять в других потоках, чтобы они не мешали выполнять достаточно частую смену кадров и оперативно реагировать на события.

Начиная с самой первой версии (1.0) в Android была многопоточность. Она реализована благодаря языковой и библиотечной поддержке со стороны Java. Это позволяет создать несколько потоков выполнения внутри Linux-процесса, представляющего запущенное приложение. Потоки могут совместно использовать общепроцессные ресурсы. Каждый из потоков имеет свой стек, локальные переменные и программный счетчик. Необходимо координировать доступ потоков к общим данным. Изменение одним потоком переменной, которая уже используется другим, может привести к непредсказуемым результатам. Угрозы данного типа называются состоянием гонки, когда результат выполнения зависит от порядка, в котором действуют потоки [1, с. 40–42]. Для решения подобных задач в Java есть механизмы синхронизации.

Реализация многопоточности несёт и другие риски, которые трудно предвидеть: взаимная блокировка, активная блокировка, голодание. Они могут проявляться с некоторой вероятностью и зачастую в самый неподходящий момент — во время высокой нагрузки [1, с. 256–258]. Возникновение таких проблем зависит от временной координации событий в разных потоках. Далее описано, как Kotlin-библиотека Coroutines решает данную проблему с помощью обмена данными через коммуникацию.

Одно из ограничений использования потоков связано с тем, что процессор (CPU) может параллельно обрабатывать только небольшое их количество. Для поддержания большего числа потоков Android использует контекстные переключения (context switches) [1, с. 44]. Работа активных потоков приостанавливается, сохраняется контекст выполнения и возобновляется работа других. При работе десятков потоков эти операции будут отнимать много процессорного времени. Следует учитывать и то, что все потоки в Java имеют приоритеты. При создании нового потока в Android необходимо вызвать метод setThreadPriority(int tid, int priority) класса Process. Первый параметр – идентификатор потока, второй – уровень приоритета планирования для Linux, от -20 – самого высокого уровня, до 19 – самого низкого. Слишком высокий уровень даст возможность потоку прерывать главный поток и RenderThread, что приведёт к снижению кадровой частоты. При слишком низком уровне асинхронная задача будет выполняться дольше.

Разработчикам важно учитывать, что поток продолжит выполняться, даже если создавшая его активность будет уничтожена, до тех пор, пока не завершится процесс приложения. В некоторых случаях это поведение полезно. Например, активность может отправить потоку для выполнения блок кода, который загрузит изображение, сохранит его в кэше, после чего изображение отобразится на экране. Если активность будет уничтожена, может быть полезно продолжить загрузку и кеширование в случае, когда изображение может пригодиться в дальнейшем [2]. В ранних версиях Android разработчикам приходилось контролировать выполнение подобных задач, учитывая жизненный цикл связанных с этими задачами объектов. Ошибки могли привести к нехватке оперативной памяти или проблемам с производительностью. Использование библиотеки Coroutines позволяет автоматически завершать выполняющиеся параллельно задачи, учитывая жизненный цикл связанных с ними компонентов (например, активностей или фрагментов). Этот механизм будет подробно описан далее.

Разработчик может создать для потока очередь задач, в которой они будут ожидать выполнения. Объекты подклассов Handler используются для добавления задач и их выполнения [3]. Задачи представляются в виде Message или реализации Runnable. Message содержит объект произвольного типа с данными и несколько полей для дополнительной информации. Для создания цикла обработки сообщений в потоке и связывания его с Handler используются статические методы класса Looper. В классе Handler переопределяется метод для обработки поступающих объектов Message. Ссылку на Handler можно использовать для отправки задач из других потоков. Задачу можно добавить в начало или конец очереди, запланировать её выполнение в определённое время.

Хорошей практикой считается вместо явного использования Java-класса Thread создавать объект класса, реализующего интерфейс Executor. В данном интерфейсе необходимо переопределить метод execute(Runnable r). Указанный метод принимает задачу и назначает для её выполнения поток или группу (pool) потоков.

Прекрасной реализацией интерфейса Executor является класс ThreadPoolExecutor, доступный в Android, начиная с самой первой версии. Он выполняет переданную задачу в одном из нескольких объединённых потоков. ThreadPoolExecutor может иметь очередь, в которой задачи ожидают выполнения. Данный класс позволяет с высокой производительностью выполнять большое количество асинхронных задач. При этом он эффективно управляет вычислительными ресурсами. Разработчик может настроить целевое количество потоков, максимальное — при пиковых нагрузках, время, спустя которое неиспользуемые потоки будут уничтожаться, если их число выше целевого значения, и другие параметры [4].

Компонент Service позволяет выполнять длительные задачи, даже когда пользователь не взаимодействует с приложением. Если не указано иное, Service может быть запущен другим приложением [5]. По умолчанию Service не создаёт для себя отдельный процесс и запускается в главном потоке. Если Service будет выполнять интенсивные (воспроизведение MP3-файла) или блокирующие (например, сетевые) операции, следует создать для него отдельный поток.

Начиная с версии Android 8.0 на Service было наложено ограничение. Если приложение находится в фоне (не отображается пользователю) и при этом не отображает уведомление о работе Service (например, уведомление о воспроизведении аудиофайла), через несколько минут работа всех его Service будет завершена [6]. Эти ограничения не влияют на работу Service, используемых другими приложениями. Компонент Service полезен для выполнения работы (например, загрузка файлов), которая продолжится, если пользователь переключится на другое приложение. При этом необходимо отображать уведомление о работе Service. Если при сворачивании приложения выполнение операции можно прервать, следует создать для неё новый поток и связать его работу с жизненным циклом другого компонента, например, активности (activity).

В середине 2018 года был представлен компонент WorkManager, который позволяет выполнять асинхронные задачи с возможностью отсрочки и высокой надёжностью. Запланированные задачи сохраняются в базе данных SQLite и не теряются даже при перезапуске приложения или перезагрузке устройства. WorkManager предназначен для задач, которые важно выполнить в определённое время или периодически повторять (например, синхронизацию с удалённым сервером). Второй тип задач — длительные операции, на которые потенциально может уйти более 10 минут. В данном случае необходимо вывести уведомление о выполняющейся задаче. К третьему типу задач относятся те, что важны для пользователя и должны быть выполнены как можно скорее, даже если приложение будет закрыто (например, отправка сообщения в мессенджере). WorkManager не предназначен для задач, которые можно безопасно отменить при завершении работы приложения [7]. Являясь частью семейства библиотек Android Jetpack, WorkManager имеет прекрасную совместимость с ранними версиями Android. Его можно использовать в приложениях, совместимых с API 14 (Android 4.0.1–4.0.2) и выше.

Класс AsyncTask был разработан для облегчения использования Thread и Handler. Он был предназначен для использования в главном потоке. Результат асинхронной операции возвращался с помощью обратного вызова. AsyncTask был предназначен для выполнения коротких задач продолжительностью до нескольких секунд.

Рассматриваемый класс был добавлен в API 3 (Android 1.5). Для его использования необходимо создать подкласс, переопределив метод doInBackground(Params) и зачастую onPostExecute(Result). Класс имеет 3 обобщённых типа (generic): Params (входные данные), Result (результат работы) и Progress (единицы для измерения прогресса выполнения). Для выполнения асинхронной задачи достаточно создать объект класса наследника AsyncTask и вызвать метод execute(Params). После выполнения задачи в главном потоке будет вызван метод onPostExecute(Result), что позволит использовать результат выполнения [8].

Разработчики часто интегрировали этот класс в пользовательский интерфейс. Ошибки в проектировании приводили к утечкам памяти, пропущенным обратным вызовам и сбоям при изменении конфигурации устройства. Спустя более 10 лет с момента появления класс AsyncTask был признан нежелательным в API 30 (Android 11). Вместо него рекомендуется использовать предоставляемые Kotlin средства многопоточности, о которых рассказано далее.

В 2019 году на конференции Google I/O язык программирования Kotlin был объявлен рекомендованным для разработки Android-приложений. Kotlin прекрасно совместим с существующим Java-кодом. Из кода на Kotlin разработчики могут вызывать методы и наследовать классы, написанные на Java, реализовывать интерфейсы [9, с. 34]. Одна из официальных библиотек Kotlin — Coroutines — значительно упрощает работу с асинхронным кодом, от сетевых операций до запросов к базе данных. Сопрограммы схожи с потоками в том, что позволяют асинхронно выполнять блоки кода. Выполнение сопрограммы может начаться в одном потоке, быть приостановленным и затем продолжиться в другом потоке. Например, при выполнении сетевого запроса на время ожидания ответа сервера можно приостановить выполнение сопрограммы [10]. Соответствующие данные будут сохранены в памяти и удалены из потока, освободив его для выполнения других задач. Таким образом предотвращается блокировка потока. После получения ответа сервера выполнение сопрограммы будет возобновлено.

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

Современные приложения запускают множество сопрограмм. Библиотека Coroutines следует принципу структурированного параллелизма. Каждая сопрограмма запускается в определённом CoroutineScope, что позволяет группировать их [11]. Таким образом, разработчик может убедиться, что все возникающие исключения будут корректно обработаны, а также что никакая сопрограмма не продолжит работу, когда в этом нет необходимости.

Библиотека Lifecycle из семейства Android Jetpack позволяет адаптировать сопрограммы к жизненному циклу компонентов приложения. Для каждой ViewModel приложения (класса, содержащего состояние пользовательского интерфейса и связанной бизнес-логики) определяется ViewModelScope [12]. Он является CoroutineScope, который автоматически реагирует на изменения в жизненном цикле ViewModel. Когда в процессе уничтожения ViewModel вызывается метод onCleared, завершается работа относящихся к ней сопрограмм. Рассматриваемая библиотека позволяет регулировать работу сопрограмм в зависимости от жизненного цикла также и для других компонентов, включая активности, фрагменты.

Помимо CoroutineScope, работу сопрограмм определяет CoroutineContext. Он хранит в себе множество различных элементов. Основные среди них — Job и диспетчер (dispatcher). Диспетчер применяется для назначения потока или группы (pool) потоков для выполнения сопрограммы. Компонент Job используется для управления жизненным циклом сопрограммы. По умолчанию сопрограмма наследует CoroutineContext у другой сопрограммы или CoroutineScope, внутри которой создаётся. Job новой сопрограммы становится дочерним по отношению к Job родительской сопрограммы. Таким образом, сопрограммы выстраиваются в иерархическую структуру. Ошибки, возникшие в дочерних сопрограммах, будут распространяться на родительские. Родительская сопрограмма завершит работу только после завершения дочерних, а её отмена приведёт к отмене всех дочерних сопрограмм [13].

Сопрограммы поощряют обмен данными через коммуникацию, а не через общепроцессные ресурсы. Это позволяет решить показанные ранее проблемы, связанные с гонкой потоков, голоданием, взаимной и активной блокировками. Для передачи между сопрограммами единственного значения удобно использовать Deferred. Для обмена потоком значений следует использовать каналы (channels).

Deferred является наследником Job. В случае успешного завершения связанной с ним сопрограммы Deferred будет содержать результат её работы. Как и при использовании Job, с помощью Deferred можно завершить сопрограмму. Объект класса Deferred возвращается при запуске сопрограммы через конструктора async.

Во многих случаях сопрограммы возвращают не одно значение, а их последовательность. Разработчик может создать сопрограммы с использованием конструктора produce, который возвращает объект класса ReceiveChannel<Type>. В данном примере Type — тип возвращаемых данных. Для отправки данных в сопрограмме-поставщике используется метод send. Вызовом метода close сопрограмма-поставщик может отправить в канал специальный токен. Когда сопрограмма-потребитель получит все данные, отправленные перед вызовом close, канал будет закрыт [14].

Сопрограмма потребитель может вызвать функцию расширение consumeEach класса ReceiveChannel. Эта функция выполняет переданное ей лямбда-выражение для каждого элемента данных в канале. Для получения одного элемента вызывается метод receive. Методы send и receive являются приостанавливаемыми (suspend). Благодаря этому ожидающая получения данных из канала, сопрограмма не блокирует поток, а позволяет использовать его другим сопрограммам.

Данная статья описывает основные средства реализации многопоточности, применяемые в Android-разработке на протяжении 15 лет. В работе описаны их преимущества и недостатки, показаны существующие альтернативы. Знание указанных особенностей позволяет разработчикам принимать обоснованные решения при создании новых приложений и в работе с уже существующими.

Список литературы

  1. Java Concurrency на практике / Гетц Брайан, Пайерлс Тим, Блох Джошуа [и др] – Санкт-Петербург : Питер, 2020. — 464 с. – ISBN 978-5-4461-1314-9
  2. Better performance through threading: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/topic/performance/threads (дата обращения: 28.03.2024).
  3. Handler: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/reference/android/os/Handler (дата обращения: 28.03.2024).
  4. ThreadPoolExecutor: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/reference/java/util/concurrent/ThreadPoolExecutor (дата обращения: 28.03.2024).
  5. Services overview: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/develop/background-work/services (дата обращения: 28.03.2024).
  6. Background Execution Limits: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/about/versions/oreo/background (дата обращения: 28.03.2024).
  7. Schedule tasks with WorkManager: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/topic/libraries/architecture/workmanager (дата обращения: 28.03.2024).
  8. AsyncTask: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/reference/android/os/AsyncTask (дата обращения: 28.03.2024).
  9. Жемеров Д. Kotlin в действии / Д. Жемеров, С. Исакова / пер. с англ. Киселев А. Н. - М.: ДМК Пресс, 2018. - 402 с. – ISBN 978-5-97060-497-7
  10. Coroutines and channels: Kotlin Documentation. [Электронный ресурс]. URL: https://kotlinlang.org/docs/coroutines-basics.html#scope-builder (дата обращения: 28.03.2024).
  11. Scope builder: Kotlin Documentation. [Электронный ресурс]. URL: https://kotlinlang.org/docs/coroutines-and-channels.html (дата обращения: 28.03.2024).
  12. Use Kotlin coroutines with lifecycle-aware components: Android for Developers. [Электронный ресурс]. URL: https://developer.android.com/topic/libraries/architecture/coroutines (дата обращения: 28.03.2024).
  13. Job: Kotlin Documentation. [Электронный ресурс]. URL: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-job/ (дата обращения: 28.03.2024).
  14. Channels: Android for Developers. [Электронный ресурс]. URL: https://kotlinlang.org/docs/channels.html (дата обращения: 28.03.2024).