В современной разработке мобильных приложений на Android, понимание и эффективное использование многозадачности является ключевым для создания высокопроизводительных и отзывчивых приложений. Многозадачность позволяет приложениям одновременно обрабатывать несколько операций, не заставляя пользователя ожидать их выполнения. Особенно это актуально при выполнении операций, занимающих продолжительное время, таких как обработка данных или выполнение сетевых вызовов. В контексте Android-разработки, важно сделать корректный выбор между потоками операционной системы и корутинами.
Данная статья сосредотачивается на сравнении этих двух подходов. Рассматриваются основные особенности, преимущества и недостатки использования потоков ОС и корутин в разработке приложений на Android, анализируя их влияние на производительность. Основываясь на анализе актуальных исследований, опыте разработчиков и практических примерах кода, предлагается всесторонний обзор этих технологий, помогая разработчикам сделать обоснованный выбор для своих проектов.
Ключевые слова : многозадачность, потоки ОС, корутины, Android-разработка, программирование, производительность приложений.
In modern Android mobile application development, understanding and effectively utilizing multitasking is a key to creating high-performance and responsive applications. Multitasking allows applications to simultaneously process multiple operations without making the user wait for their completion. This is particularly relevant for time-consuming operations such as data processing or network calls. In the context of Android development, choosing between operating system threads and coroutines is very important.
This article focuses on comparing these two approaches. It examines the main features, advantages, and drawbacks of using OS threads and coroutines in Android application development, analyzing their impact on performance. Based on the analysis of current research, developer experience, and practical code examples, a comprehensive overview of these technologies is presented, aiding developers in making informed choices for their projects.
Keywords : Multitasking, OS Threads, Coroutines, Android Development, Programming, Application Performance.
В мире мобильных устройств операционная система Android является лидером рынка по количеству устройств. Основанная на ядре Linux, Android спроектирован для функционирования на разнообразных устройствах — начиная от мобильных телефонов и планшетов, заканчивая умными часами. Фреймворк, позволяющий создавать Android приложения, построен на основе Android Runtime (ART), ключевом компоненте, который позволяет создавать надежные приложения, работающий на всем множестве hardware поддерживаемом ОС Android.
ART является абстрактным вычислительным устройством, которое позволяет устройствам Android выполнять DEX байткод — набор инструкций, который не зависит от аппаратной составляющей, что позволяет использовать один и тот же байткод на разных аппаратных платформах без необходимости перекомпиляции приложения. Также, благодаря ART разработчики приложений могут ожидать стандартизированного устройства памяти, что также значительно упрощает работу приложений на разных аппаратных платформах.
Одной из особенностей архитектуры ОС Android является наличие главного потока для каждого приложения. Главный поток в Android ответственен в первую очередь за отрисовку пользовательского интерфейса. Каждое приложение должно отрисовывать свой интерфейс за 16 миллисекунд или меньше — такой временной промежуток позволяет отрисовывать интерфейс с частотой в 60 кадров в секунду, что является оптимальным значением для обеспечения “плавного” интерфейса, в котором отсутствуют видимые пользователю прерывания.
С учетом данного ограничения приложения Android не могут выполнять времязатратные блокирующие операции на главном потоке, что приводит к необходимости инструментов для обхода данного ограничения. Естественным инструментом являются потоки (threads). Потоки являются абстракцией виртуальной машины, обеспечивающей возможность одновременного конкурентного выполнения нескольких задач. Потоки ART / JVM напрямую привязаны к потокам ОС, то есть для ОС нет разницы между собственными потоками и потоками приложения [1]. Существует множество инструментов, облегчающих взаимодействие с потоками, часть из них поставляется вместе с фреймворком для разработки приложений Android, например Executor, часть из них являются сторонними популярными инструментами (RxJava).
Одной из наиболее значимых вех в разработке Android приложений является появление и интеграция JVM-совместимого языка программирования Kotlin. Добавление поддержки корутин в Kotlin позволяет использовать современный подход к асинхронному программированию.
Корутины в Kotlin представляют собой новый способ обеспечения асинхронности и многозадачности в приложения. В то время как в Java основным инструментом для обеспечения являются потоки (threads) JVM, которые привязаны к потокам ОС и соответственно являются ресурсозатратными, корутины не привязаны напрямую к потокам ОС, что делает их значительно менее требовательными к ресурсам.
В данной работе исследуются особенности JVM threads и Kotlin coroutines в контексте ОС Android, их роли, преимущества и недостатки относительно друг друга.
Характеристика потоков
Потоки в Android являются фундаментальным компонентом платформы. Они обеспечивают одновременное выполнение программного кода, делая возможным выполнение основного функционала мобильных приложений.
Потоки имеют четко определенный жизненный цикл и могут находиться в состоянии ожидания (waiting), блокировки (blocked), выполнения (runnable), new (новый), ожидания с таймаутом (timed waiting) и завершенный (terminated). Android Runtime управляет потоками, выделяет ресурсы и планирует выполнение потоков на основе их приоритетов и загрузки системы. Управление потоками является критической частью ART, учитывая ограниченность ресурсов на мобильных устройствах и необходимость поддерживать высокую энергоэффективность.
Рис. 1. Диаграмма состояний потока Java
Для потоков необходимы инструменты синхронизации для обеспечения корректного доступа к данным в рамках модели памяти Java, такие как критические секции, доступные с помощью ключевого слова synchronized. Это ключевой механизм для предотвращения некорректного состояния приложения называемого race condition, которое может возникать при одновременном доступе к данным несколькими потоками одновременно. Однако стоит учитывать, что данный механизм синхронизации является ресурсозатратным, что критично, учитывая ограниченность ресурсов мобильных устройств [2].
Потоки в Android имеют множество ограничений, не смотря на всю их гибкость. Ограниченные ресурсы устройств, в частности низкая мощность ЦП, малый объем оперативной памяти и необходимость поддержания высокого уровня энергоэффективности накладывают ограничения на количество потоков, которые могут быть запущены одновременно. Стоит учитывать, что для каждого нового потока Android выделяет память для стека, так что даже простаивающий поток все равно потребляет память. Некорректное использование потоков может привести к взаимной блокировке потоков (deadlock), голоданию потоков (thread starvation) [3] и к общей неотзывчивости приложения, что вынудит ОС Android отобразить ошибку “Приложение не отвечает” и завершить его.
Потоки в Android редко используются напрямую, чаще всего взаимодействие с ними происходит через механизмы, направленные на упрощение создания сложных асинхронных систем. Примером такого встроенного механизма является ThreadPoolExecutor — абстракция, которая инкапсулирует работу с набором потоков и предлагает простой интерфейс для выполнения одиночных задач без необходимости создания нового потока для каждой задачи.
RxJava — популярная сторонняя библиотека, разработанная в рамках проекта ReactiveX, также является частым выбором в качестве инструмента для организации работы с потоками. Она предоставляет удобный интерфейс для работы с потоками данных на нескольких наборах потоков одновременно, специализированные наборы потоков и множество инструментов для фильтрации и преобразования потоков данных.
Рис. 2. Пример использования RxJava
Характеристика Kotlin coroutines
Корутины Kotlin в Android являются новым этапом в развитии асинхронного программирования и параллелизма. Это относительно новая функциональность языка, предлагающая уникальный набор возможностей, которые помогают в решении ряда проблем, возникающих в традиционных моделях работы с потоками, что особенно релевантно в контексте разработки мобильных приложений.
Концепт корутин появился в 1963 году в статье американского ученого Мелвина Конвей под названием Design of a Separable Transition-Diagram Compiler [4]. В этой работе Конвей описывает возможности корутин и особенности их применения в программировании. С течением времени, концепт корутин эволюционировал и был реализован в различных языках программирования, таких как Smalltalk, C#, Python, Erlang.
Корутины позволяют создавать асинхронные операции, используя синтаксис схожий с синхронным кодом, что делает разработку более интуитивно понятной и обеспечивает легкость поддержки кода. Это достигается с помощью поддержки механизма приостановки (suspend) — корутина можем сигнализировать что она готова приостановиться и сохранить свое состояние, либо возобновить свое выполнение, давая возможность другим корутинам выполняться на текущем потоке. Фактически, корутины в Kotlin реализуют кооперативную многозадачность. Благодаря поддержке приостановки, возможно выполнение множества сопрограмм в одном потоке без блокировки основного потока выполнения. Поддержка приостановки позволяет эффективно использовать ресурсы при сравнении с блокировкой, обеспечивая одновременное выполнение множества операций.
Рис. 3. Пример одновременного выполнения двух корутин Kotlin использующих только один поток ОС
Одной из наиболее значимых особенностей корутин является их легковесность. В отличие от потоков, корутины не соотносятся напрямую с потоками ОС. Они реализованы поверх них, что позволяет создавать тысячи корутин с минимальными накладными расходами, в том числе потому, что отсутствует необходимость выполнять дорогостоящие системные вызовы для создания потоков. Такая легковесность делает корутины привлекательным инструментом для реализации асинхронных систем, какими являются мобильные приложения в ОС Android.
Kotlin Coroutines состоит из нескольких компонентов:
– Coroutines (сопрограммы) — легковесная версия системных потоков (threads), которая позволяет выполнять неблокирующий асинхронный код
– suspending functions (прерываемые функции) — специальный тип функций, которые могут прерывать свое выполнение, позволяя другим прерываемым функциям выполняться на текущем потоке. Позже они могут продолжить выполнение.
– Context (контекст) — контекст, в котором выполняются сопрограммы. Он содержит в себе необходимую для выполнения информацию, в том числе информацию о потоках, на которых сопрограмма будет выполняться.
Частью контекста корутин является Dispatcher [5] (диспетчер) — компонент, управляющий на каком потоке или наборе потоков корутина будет выполняться. Kotlin предоставляет несколько вариантов диспетчеров, оптимизированных для разных ситуаций:
– Dispatchers.Main: используется для выполнения coroutines в основном потоке Android, подходит для операций, меняющих пользовательский интерфейс.
– Dispatchers.IO: используется для ввода-вывода (I/O) операций, таких как работа с файлами и сетевые запросы.
– Dispatchers.Default: диспетчер по умолчанию, предназначенный для вычислительных задач.
– Dispatchers.Unconfined: диспетчер, который не привязывает выполнение Coroutines к какому-либо конкретному потоку [6,7].
Рис. 4. Пример использования Dispatchers
Диспетчеры не только управляют потоками, на которых выполняются корутины, но и позволяют оптимизировать выполнение задач в зависимости от их типа, что улучшает производительность приложения. Они также играют ключевую роль в предотвращении блокировки основного потока, обеспечивая более отзывчивый пользовательский интерфейс. Благодаря возможности переключения контекста с помощью withContext, разработчики могут легко изменять диспетчер внутри одной корутины, что делает код гибким и легко поддерживаемым. Использование диспетчеров значительно упрощает работу с асинхронными операциями, так как они автоматически обрабатывают сложности, связанные с планированием и управлением потоками [8]. Наконец, создание собственных диспетчеров дает разработчикам возможность настраивать обработку сопрограмм под конкретные требования и условия проекта, что делает Kotlin Coroutines мощным инструментом для современной разработки.
Методология
В данном исследовании проводится сравнительный анализ между Java Threads и Kotlin Coroutines, двумя подходами к параллельному программирования в современной разработке для Android. Основная цель этого сравнения — выявить различия и преимущества каждого подхода, особенно в контексте их производительности. Цель этого сравнительного исследования — предоставить разработчикам Android и другим заинтересованным сторонам полезные рекомендации по выбору наиболее подходящей модели параллелизма для их приложений.
Сложно переоценить важность методологии для данного сравнения. Методология позволяет оценивать каждую технологию в схожих условиях и параметрах, тем самым обеспечивая справедливое и беспристрастное сравнение. Этот подход критически важен для минимизации субъективных предвзятостей во время исследования и гарантирования того, что выводы были основаны на эмпирических данных и аналитических практиках.
Кроме того, воспроизводимость является крайне важным компонентом исследований. Подробное описание методологии позволяет другим исследователям воспроизвести исследование, тем самым подтверждая или оспаривая выводы. Этот аспект научного поиска фундаментален для вклада в создание надежного и проверяемого научного знания. Таким образом, раздел методологии в данной работе служит своеобразным планом для сравнительного анализа, обеспечивая не только прозрачность результатов, но и их способность выдержать проверку временем в постоянно развивающейся области разработки для ОС Android.
При оценке производительности Java-потоков и корутин Kotlin, данное исследование будет сосредоточено на времени выполнения.
Будет измерено время, затраченное на выполнение предопределенных задач с использованием как Java-потоков, так и корутин Kotlin. Это позволит получить представление о скорости и отзывчивости каждого подхода в схожих условиях нагрузки.
В качестве программного окружения для испытания используются Instrumented tests — утилита для тестирования приложений Android, чей код выполняется на устройстве Android. Instrumented tests построены на основе JUnit — программного продукта [9], занимающий лидирующее место в нише тестирования JVM-compatible проектов.
Сравнение производительности Java потоков и Kotlin корутин
Для сравнения производительности были произведены несколько тестов. Сначала, рассмотрим необходимые затраты ресурсов на создание и завершение корутины и потока соответственно. Для этого были составлены два теста — для создания и ожидания завершения корутины и потока.
Во время тестирования были получены следующие данные:
Рис. 5. Сравнение времени выполнения корутин и потоков
Вертикальная ось показывает количество времени, затраченное на выполнение теста, горизонтальная ось отображает номер запуска теста. Из графиков видно, что время, затраченное на запуск корутин, значительно меньше чем время затраченное на запуск потоков, что подтверждает тезис о «легковесности» корутин.
Также рассматривался кейс с приближенным к реальности использованием данных инструментов — параллельное скачивание данных из сети Интернет. В данном тесте рассматривались несколько вариантов использования корутин — блокирующий сетевой запрос с использованием Dispatchers.IO для обеспечения параллельной загрузки, неблокирующая реализация операций ввода-вывода; тест с потоками ОС выполнял блокирующий сетевой вызов.
Неблокирующая операция реализована с помощью асинхронного ввода-вывода, с использованием пакета java.nio. Особенность данного пакета состоит в том, что он предоставляет механизм работы с сетью, основанный на концепте отложенного вычисления. Каждая операция ввода-вывода является неблокирующей, и пользователь данного механизма должен дождаться результата ее выполнения [10]. Это является оптимальным вариантом для использования корутин, так как корутины могут уступать управление другим корутинам, таким образом обеспечивая выполнение нескольких задач одновременно на одном потоке ОС.
Блокирующий вызов с использованием потоков и корутин выполняющихся на Dispatcher.IO является полным аналогом неблокирующего вызова, за исключением того, что все операции ввода-вывода реализованы синхронно — поток блокируется во время ожидания ответа.
Каждый тест запускает 10 одновременных потоков или корутин для обеспечения параллельности.
Рис. 6. Сравнение времени выполнения сетевого запроса при использовании корутин и потоков ОС
Согласно данным результатам, при использовании корутин и потоков в приближенных к реальным условиям разница в производительности между ними становится пренебрежительно мала из-за того, что основная операция — загрузка данных — занимает значительно больше времени чем создание потока или корутины. Более того, одновременное скачивание данных с помощью 10 корутин запущенных на одном потоке с использованием асинхронного ввода-вывода показывает результаты сопоставимые с загрузкой в отдельных потоках, что демонстрирует потенциал данного подхода. Немного большее время загрузки для однопоточной корутины использующей асинхронной ввод-вывод может объясняться накладными расходами на копирование данных в буфер, которое является блокирующей операцией. При дальнейшей оптимизации данного подхода возможно добиться еще большей производительности.
Заключение
При рассмотрении разных парадигм асинхронного программирования в Android, было выявлено что разница в производительности между корутинами и потоками в задачах приближенных к реальным крайне мала.
Корутины, более легкий и гибкий аналог потоков, часто хвалят за их эффективность при обработке большого количества одновременных операций без излишней нагрузки, связанной с потоками. Стоимость создания корутин действительно оказалась значительно ниже, чем потоков. Это теоретическое преимущество предполагает значительное увеличение производительности, особенно в приложениях, требующих высокого уровня параллелизма. Однако наши эмпирические наблюдения рисуют более тонкую картину. В задачах, связанных с вводом-выводом, где операции часто ожидают внешних ресурсов или данных, время, затраченное на создание потока ОС значительно меньше, чем время ожидания получения или отправки данных, что нивелирует преимущество корутин.
Также, стоит отметить, что выбор между корутинами и потоками определяется не только производительностью. Удобство поддержки кода, поддержка сторонними инструментами, легкость написания асинхронного кода являются критичными факторами при выборе между этими двумя инструментами. Корутины значительно упрощают работу с асинхронным кодом и предоставляют инструменты контроля над жизненным циклом асинхронного кода, что долгое время является камнем преткновения в JVM/Android разработке. Существуют множество инструментов, упрощающих данный аспект (например RxJava c механизмом Disposable) при работе с потоками.
В данной статье примером гибкости корутин является тест, использующий неблокирующий сетевой вызов, интегрированный с механизмом корутин. В этом тесте вместо синхронного ожидания операций ввода-вывода, корутина уступала контроль другим корутинам, что позволяло им инициировать собственные операции или получить результат. Этот подход продемонстрировал что даже используя Dispatcher у которого есть только один рабочий поток корутины могут демонстрировать производительность в сетевых операциях сравнимую с 10 потоками ОС совершающих блокирующие операции ввода-вывода. И хотя технически этот подход возможно реализовать без использования корутин, это потребует создания механизма, работающего схожим образом с ними.
Таким образом, можно утверждать, что и корутины и потоки остаются критичными элементами в Android разработке. Выбор между ними стоит производить, опираясь только на потребности в разработке, так как разница между ними в плане производительности является минимальной.
Литература:
- Understanding Threads and Locks [Электронный ресурс] Режим доступа: https://docs.oracle.com/cd/E13150_01/jrockit_jvm/jrockit/geninfo/diagnos/thread_basics.html1/.– (дата обращения 25.11.2023)
- Ширази Д. Java Performance Tuning — 2003 — O’Reily издание 2
- Starvation and Livelock [Электронный ресурс] Режим доступа: https://docs.oracle.com/javase/tutorial/essential/concurrency/starvelive.html — (дата обращения 27.11.2023)
- Конвей М. Design of a Separable Transition-Diagram Compiler — 1963 — в: Communications of the ACM
- Coroutine context and dispatchers [Электронный ресурс] Режим доступа: https://kotlinlang.org/docs/coroutine-context-and-dispatchers.html — (дата обращения 20.01.2024)
- Диспетчер coroutines. [Электронный ресурс] Режим доступа: https://metanit.com/kotlin/tutorial/8.7.php.– (дата обращения 21.10.2023).
- Coroutine context and dispatchers. [Электронный ресурс] Режим доступа: https://kotlinlang.ru/docs/coroutine-context-and-dispatchers.html. — (дата обращения 20.01.2024).
- Improve App performance with Kotlin Coroutines. [Электронный ресурс] Режим доступа: https://developer.android.com/kotlin/coroutines/coroutines-adv- (дата обращения 18.01.2024).
- JUnit testing framework [Электронный ресурс] Режим доступа: https://www.headspin.io/blog/junit-a-complete-guide — (дата обращения 18.01.2024)
- Java NIO [Электронный ресурс] Режим доступа: https://docs.oracle.com/en/java/javase/21/core/java-nio.html#GUID-3ADACEEA-010F-45CC-AA88–71550C179608 — (дата обращения 19.01.2024)