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

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

Основная тема

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

Внутренности

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

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

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

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

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

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

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

Явные ссылки

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

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

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

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

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

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

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

Распространенный недостаток дизайна кода с многопоточными объектами можно увидеть во фрагменте кода ниже:

Котлин

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

Ява

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.

Потоки и жизненные циклы активности приложений

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

Сохраняющиеся темы

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

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

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

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

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

Приоритет потока

Как описано в разделе «Процессы и жизненный цикл приложения» , приоритет, который получают потоки вашего приложения, частично зависит от того, на каком этапе жизненного цикла приложения находится приложение. Когда вы создаете потоки в своем приложении и управляете ими, важно установить их приоритет, чтобы нужные потоки получали правильные приоритеты в нужное время. Если установлено слишком высокое значение, ваш поток может прервать поток пользовательского интерфейса и 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 . Дополнительную информацию см. в разделе Threading на Android .

Класс HandlerThread

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

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

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

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

Класс ThreadPoolExecutor.

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

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

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

Сколько потоков следует создать?

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

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

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

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