Повышение производительности за счет многопоточности

Правильное использование потоков в Android позволяет повысить производительность приложения. В этой статье рассматриваются несколько аспектов работы с потоками: работа с потоком пользовательского интерфейса (основным), связь между жизненным циклом приложения и приоритетом потока, а также методы, которые платформа предоставляет для управления сложностью потоков. В каждом из этих разделов описываются потенциальные проблемы и стратегии их решения.

Основной поток

Когда пользователь запускает приложение, Android создает новый процесс Linux и поток выполнения. Этот основной поток, также известный как поток UI, отвечает за всё, что происходит на экране. Понимание того, как это работает, поможет вам разработать приложение, которое будет использовать основной поток для достижения наилучшей производительности.

Внутренние

Основной поток имеет очень простую структуру: его единственная задача – принимать и выполнять блоки работы из потокобезопасной очереди до тех пор, пока приложение не будет закрыто. Фреймворк генерирует некоторые из этих блоков работы из разных мест. К ним относятся обратные вызовы, связанные с информацией о жизненном цикле, пользовательские события, такие как ввод, или события, поступающие из других приложений и процессов. Кроме того, приложение может явно ставить блоки в очередь самостоятельно, без использования фреймворка.

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

При анимации или обновлении экрана система пытается выполнять блок работы (который отвечает за отрисовку экрана) примерно каждые 16 мс, чтобы обеспечить плавное воспроизведение с частотой 60 кадров в секунду. Чтобы система достигла этой цели, иерархия UI/View должна обновляться в основном потоке. Однако если в очереди сообщений основного потока слишком много задач или они слишком длинные, чтобы основной поток мог быстро завершить обновление, приложение должно перенести эту работу в рабочий поток. Если основной поток не может завершить выполнение блоков работы в течение 16 мс, пользователь может заметить задержки, зависания или отсутствие отклика интерфейса на ввод. Если основной поток блокируется примерно на пять секунд, система показывает диалоговое окно Приложение не отвечает, позволяющее пользователю закрыть приложение.

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

Ссылки на чаты и объекты пользовательского интерфейса

По умолчанию объекты View в Android не являются потокобезопасными. Приложение должно создавать, использовать и уничтожать объекты пользовательского интерфейса в основном потоке. Если вы попытаетесь изменить объект пользовательского интерфейса или даже сослаться на него в потоке, отличном от основного, это может привести к исключениям, скрытым сбоям, аварийному завершению работы и другим неопределенным ошибкам.

Проблемы со ссылками делятся на две категории: явные и неявные ссылки.

Явные упоминания

Многие задачи в неосновных потоках имеют конечную цель обновления объектов пользовательского интерфейса. Однако если один из этих потоков обращается к объекту в иерархии View, приложение может работать нестабильно. Если рабочий поток изменяет свойства этого объекта в то же время, когда на него ссылается любой другой поток, результаты не определены.

Например, приложение может содержать прямую ссылку на объект пользовательского интерфейса в рабочем потоке. Объект в рабочем потоке может содержать ссылку на View, но до завершения работы View удаляется из иерархии View. Когда эти два действия происходят одновременно, ссылка сохраняет объект View в памяти и задает для него свойства. Однако пользователь никогда не видит этот объект, и приложение удаляет его, как только ссылка на него исчезает.

В другом примере объекты View содержат ссылки на действие, которому они принадлежат. Если эта активность уничтожена, но остается блок работы, который ссылается на нее напрямую или косвенно, сборщик мусора не будет собирать активность, пока этот блок работы не завершит выполнение.

Это может привести к проблемам, если поток выполняется во время события жизненного цикла объекта activity, например поворота экрана. Система не сможет выполнить сборку мусора, пока не завершится текущая работа. В результате в памяти могут находиться два объекта Activity, пока не будет выполнена сборка мусора.

В таких случаях мы рекомендуем не включать в приложение явные ссылки на объекты пользовательского интерфейса в потоковых рабочих задачах. Избегая таких ссылок, вы сможете предотвратить утечки памяти и конфликты потоков.

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

Неявные ссылки

Ниже приведен пример кода, в котором допущена распространенная ошибка:

Kotlin

class MainActivity : Activity() {
    // ...
    inner class MyAsyncTask : AsyncTask<Unit, Unit, String>() {
        override fun doInBackground(vararg params: Unit): String {...}
        override fun onPostExecute(result: String) {...}
    }
}

Java

public class MainActivity extends Activity {
  // ...
  public class MyAsyncTask extends AsyncTask<Void, Void, String>   {
    @Override protected String doInBackground(Void... params) {...}
    @Override protected void onPostExecute(String result) {...}
  }
}

Ошибка в этом фрагменте заключается в том, что код объявляет объект потока MyAsyncTask как нестатический внутренний класс некоторого действия (или внутренний класс в Kotlin). Это объявление создает неявную ссылку на экземпляр Activity. В результате объект содержит ссылку на действие до тех пор, пока не завершится работа потока, что приводит к задержке уничтожения действия, на которое указывает ссылка. Это, в свою очередь, увеличивает нагрузку на память.

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

Другой вариант – всегда отменять и очищать фоновые задачи в соответствующем обратном вызове жизненного цикла Activity, например onDestroy. Однако этот подход может быть утомительным и подверженным ошибкам. Как правило, не следует помещать сложную логику, не связанную с интерфейсом, непосредственно в действия. Кроме того, AsyncTask больше не поддерживается и не рекомендуется для использования в новом коде. Подробнее о потоках в Android…

Потоки и жизненные циклы объектов activity в приложении

Жизненный цикл приложения может влиять на работу потоков. Вам может потребоваться решить, должен ли поток сохраняться после уничтожения действия. Вы также должны знать о взаимосвязи между приоритетом потока и тем, выполняется ли действие на переднем плане или в фоновом режиме.

Сохранение цепочек

Потоки сохраняются дольше, чем действия, которые их порождают. Потоки продолжают выполняться непрерывно, независимо от создания или уничтожения действий, хотя они будут завершены вместе с процессом приложения, как только не останется активных компонентов приложения. В некоторых случаях это удобно.

Рассмотрим случай, когда действие порождает набор блоков потоковой работы, а затем уничтожается до того, как рабочий поток сможет выполнить эти блоки. Что приложение должно делать с блоками, которые находятся в полете?

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

С другой стороны, пакеты работ могут иметь некоторые преимущества, не полностью связанные с пользовательским интерфейсом. В этом случае вам следует сохранить цепочку. Например, пакеты могут ожидать скачивания изображения, его кеширования на диск и обновления связанного объекта View. Хотя объект больше не существует, скачивание и кеширование изображения все равно может быть полезным на случай, если пользователь вернется к уничтоженному действию.

Управлять ответами жизненного цикла вручную для всех объектов потоков может быть очень сложно. Если вы не управляете ими правильно, ваше приложение может пострадать от конкуренции за память и проблем производительности. Сочетание ViewModel с LiveData позволяет загружать данные и получать уведомления об их изменении, не беспокоясь о жизненном цикле. Объекты ViewModel – одно из решений этой проблемы. ViewModels сохраняются при изменении конфигурации, что позволяет легко сохранять данные представления. Дополнительную информацию о ViewModel можно найти в руководстве по ViewModel, а о LiveData – в руководстве по LiveData. Если вы хотите узнать больше об архитектуре приложений, ознакомьтесь с руководством.

Приоритет цепочки

Как описано в статье Процессы и жизненный цикл приложения, приоритет потоков приложения частично зависит от того, на каком этапе жизненного цикла оно находится. При создании потоков и управлении ими в приложении важно задавать их приоритет, чтобы нужные потоки получали нужные приоритеты в нужное время. Если установить слишком высокий приоритет, поток может прерывать поток UI и RenderThread, что приведет к пропуску кадров. Если задать слишком низкое значение, асинхронные задачи (например, загрузка изображений) могут выполняться медленнее, чем нужно.

При создании цепочки нужно вызывать метод setThreadPriority(). Планировщик потоков системы отдает предпочтение потокам с высоким приоритетом, балансируя эти приоритеты с необходимостью в конечном итоге выполнить всю работу. Обычно потоки в активной группе получают около 95% общего времени выполнения на устройстве, а фоновая группа – примерно 5%.

Система также назначает каждому потоку собственное значение приоритета, используя класс Process.

По умолчанию система устанавливает приоритет потока и членство в группе такими же, как у порождающего потока. Однако ваше приложение может явно настраивать приоритет потока с помощью setThreadPriority().

Класс Process помогает упростить назначение приоритетов, предоставляя набор констант, которые ваше приложение может использовать для установки приоритетов потоков. Например, THREAD_PRIORITY_DEFAULT представляет значение по умолчанию для цепочки. Для потоков, выполняющих менее срочную работу, приложение должно устанавливать приоритет THREAD_PRIORITY_BACKGROUND.

В приложении можно использовать константы THREAD_PRIORITY_LESS_FAVORABLE и THREAD_PRIORITY_MORE_FAVORABLE в качестве инкрементов для установки относительных приоритетов. Список приоритетов потоков можно найти в константах THREAD_PRIORITY класса Process.

Дополнительную информацию об управлении цепочками можно найти в справочной документации по классам Thread и Process.

Вспомогательные классы для работы с потоками

Разработчикам, которые используют Kotlin в качестве основного языка, мы рекомендуем использовать сопрограммы. Сопрограммы предоставляют ряд преимуществ, включая написание асинхронного кода без обратных вызовов, а также структурированный параллелизм для определения области действия, отмены и обработки ошибок.

Фреймворк также предоставляет те же классы и примитивы Java для упрощения работы с потоками, такие как классы Thread, Runnable и Executors, а также дополнительные, например HandlerThread. Дополнительную информацию можно найти в статье Потоки в Android.

Класс HandlerThread

Поток обработчика – это длительный поток, который берет работу из очереди и выполняет ее.

Рассмотрим распространенную проблему с получением кадров предварительного просмотра из объекта Camera. При регистрации кадров предварительного просмотра камеры вы получаете их в обратном вызове onPreviewFrame(), который вызывается в потоке событий, из которого он был вызван. Если бы этот обратный вызов выполнялся в потоке UI, то обработка огромных массивов пикселей мешала бы отрисовке и обработке событий.

В этом примере, когда приложение делегирует команду Camera.open() блоку работы в потоке обработчика, связанный обратный вызов onPreviewFrame() попадает в поток обработчика, а не в поток UI. Поэтому, если вы собираетесь выполнять длительную работу с пикселями, это может быть лучшим решением для вас.

Когда приложение создает поток с помощью HandlerThread, не забудьте задать приоритет потока в зависимости от типа выполняемой им работы. Помните, что процессоры могут обрабатывать только небольшое количество потоков параллельно. Установка приоритета помогает системе узнать правильные способы планирования этой работы, когда все остальные потоки борются за внимание.

Класс ThreadPoolExecutor

Некоторые виды работ можно свести к параллельным распределенным задачам. Например, это может быть расчет фильтра для каждого блока 8 x 8 пикселей на изображении размером 8 мегапикселей. Из-за огромного объема пакетов работ, которые при этом создаются, HandlerThread не подходит для использования.

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

Этот класс также помогает вашему приложению порождать оптимальное количество потоков. При создании объекта ThreadPoolExecutor приложение задает минимальное и максимальное количество потоков. По мере увеличения рабочей нагрузки, передаваемой в ThreadPoolExecutor, класс будет учитывать инициализированное минимальное и максимальное количество потоков и объем ожидающей выполнения работы. На основе этих факторов ThreadPoolExecutor решает, сколько потоков должно быть активно в любой момент времени.

Сколько цепочек нужно создать?

Хотя на уровне программного обеспечения ваш код может создавать сотни потоков, это может привести к проблемам производительности. Приложение делит ограниченные ресурсы ЦП с фоновыми службами, средством визуализации, аудиодвижком, сетью и т. д. ЦП могут параллельно обрабатывать лишь небольшое количество потоков. Все, что превышает это количество, сталкивается с проблемами приоритета и планирования. Поэтому важно создавать только столько потоков, сколько требуется для вашей рабочей нагрузки.

На практике на это влияет множество переменных, но вы можете выбрать значение (например, 4) и протестировать его с помощью Systrace. Вы можете определить минимальное количество потоков, при котором не возникает проблем, методом проб и ошибок.

Ещё один фактор, который следует учитывать при выборе количества потоков, заключается в том, что потоки не бесплатны: они занимают память. Каждый поток занимает не менее 64 КБ памяти. Это быстро накапливается во многих приложениях, установленных на устройстве, особенно в ситуациях, когда стеки вызовов значительно растут.

Многие системные процессы и сторонние библиотеки часто создают собственные пулы потоков. Если ваше приложение может повторно использовать существующий пул потоков, это может повысить производительность за счет снижения конкуренции за память и ресурсы обработки.