Статьи

Параллелизм и сопрограммы в Котлине

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

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

Kotlin поддерживает почти все эти подходы и предлагает несколько своих. В этом уроке я покажу вам, как вы можете использовать их в приложениях для Android.

Чтобы следовать этому руководству, вам понадобится:

Если вам неудобно работать с лямбда-выражениями и интерфейсами SAM, я предлагаю вам также прочитать следующее руководство, прежде чем продолжить:

  • Android SDK
    Совет: пишите более чистый код с помощью Kotlin SAM Conversions

Вы также можете изучить все тонкости языка Kotlin в нашей серии Kotlin From Scratch .

  • Kotlin From Scratch: обнуляемость, циклы и условия

  • Kotlin с нуля: больше веселья с функциями

Обычно экземпляры классов, которые реализуют интерфейс Runnable , используются для создания потоков в Kotlin. Поскольку интерфейс Runnable имеет только один метод, метод run() , вы можете использовать функцию преобразования SAM Kotlin для создания новых потоков с минимальным стандартным кодом.

Вот как вы можете использовать функцию thread() , которая является частью стандартной библиотеки Kotlin, для быстрого создания и запуска нового потока:

1
2
3
thread {
    // some long running operation
}

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

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

01
02
03
04
05
06
07
08
09
10
11
val myService:ExecutorService = Executors.newFixedThreadPool(8)
var i = 0
 
while (i < items.size) { // items may be a large array
    val item = items[i]
    myService.submit {
        processItem(item) // a long running operation
    }
 
    i += 1
}

На первый взгляд это может быть неочевидно, но в приведенном выше коде аргумент метода submit() службы executor на самом деле является объектом Runnable .

Фоновые задачи, созданные с использованием интерфейса Runnable не могут возвращать результаты напрямую. Если вы хотите получать результаты из своих потоков, вы должны использовать вместо этого интерфейс Callable , который также является интерфейсом SAM.

Когда вы передаете объект Callable submit() службы executor, вы получаете объект Future . Как следует из его названия, объект Future будет содержать результат Callable в какой-то момент в будущем, когда служба executor закончит его запуск. Чтобы получить реальный результат от объекта Future , все, что вам нужно сделать, это вызвать его метод get() но будьте осторожны, ваш поток заблокируется, если вы вызовете его преждевременно.

В следующем примере кода показано, как создать объект Callable который возвращает Future типа String , запустить его и распечатать его результат:

01
02
03
04
05
06
07
08
09
10
11
val myService:ExecutorService = Executors.newFixedThreadPool(2)
 
val result = myService.submit(Callable<String> {
    // some background operation that generates
    // a string
})
 
// Other operations
 
// Print result
Log.d(TAG, result.get())

В отличие от Java, Kotlin не имеет synchronized ключевого слова. Следовательно, для синхронизации нескольких фоновых операций вы должны использовать аннотацию @Synchronized или встроенную функцию стандартной библиотеки synchronized() . Аннотация может синхронизировать весь метод, и функция работает с блоком операторов.

01
02
03
04
05
06
07
08
09
10
11
12
// a synchronized function
@Synchronized fun myFunction() {
 
}
 
fun myOtherFunction() {
 
    // a synchronized block
    synchronized(this) {
 
    }
}

И аннотация @Synchronized и функция synchronized() используют концепцию блокировки монитора.

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

В то время @Synchronized аннотация @Synchronized блокирует монитор объекта, @Synchronized принадлежит связанный метод, функция synchronized() может заблокировать монитор любого объекта, переданного ему в качестве аргумента.

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

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

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

Чтобы иметь возможность использовать сопрограммы в своем проекте Android Studio, убедитесь, что вы добавили следующую зависимость compile в файл build.gradle модуля app :

1
compile ‘org.jetbrains.kotlinx:kotlinx-coroutines-android:0.19.3’

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

Чтобы создать функцию приостановки, все, что вам нужно сделать, это добавить модификатор suspend к обычной функции. Вот типичная функция приостановки, выполняющая HTTP-запрос GET с использованием библиотеки khttp :

1
2
3
suspend fun fetchWebsiteContents(url: String):String {
    return khttp.get(url).text
}

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

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

Следующий код создает сопрограмму, которая выполняет два последовательных вызова функции приостановки, которую мы создали на предыдущем шаге:

1
2
3
4
val job1 = launch {
    val website1 = fetchWebsiteContents(«https://code.tutsplus.com»)
    val website2 = fetchWebsiteContents(«https://design.tutsplus.com»)
}

Возвращаемое значение функции launch() — это объект Job , который можно использовать для управления сопрограммой. Например, вы можете вызвать его метод join() чтобы дождаться завершения сопрограммы. Точно так же вы можете вызвать его метод cancel() чтобы немедленно отменить сопрограмму.

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

Функция async() возвращает Deferred объект, который, как и объект Job , позволяет вам управлять сопрограммой. Тем не менее, он также позволяет использовать функцию await() для ожидания результата сопрограммы без блокировки текущего потока.

Например, рассмотрим следующие сопрограммы, которые используют fetchWebsiteContents() приостановки fetchWebsiteContents() и возвращают длины содержимого двух разных адресов веб-страниц:

1
2
3
4
5
6
7
val jobForLength1 = async {
    fetchWebsiteContents(«https://webdesign.tutsplus.com»).length
}
 
val jobForLength2 = async {
    fetchWebsiteContents(«https://photography.tutsplus.com»).length
}

С помощью приведенного выше кода обе сопрограммы запустятся немедленно и будут работать параллельно.

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

В следующем коде показано, как рассчитать сумму двух длин, используя новую сопрограмму, созданную с помощью функции launch() :

1
2
3
4
launch {
    val sum = jobForLength1.await() + jobForLength2.await()
    println(«Downloaded $sum bytes!»)
}

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

Например, вот как вы можете переписать последнюю сопрограмму для отображения суммы внутри виджета TextView :

1
2
3
4
launch(UI) {
    val sum = jobForLength1.await() + jobForLength2.await()
    myTextView.text = «Downloaded $sum bytes!»
}

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

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

Например, с помощью функции Thread.sleep() delay() , которая является неблокирующим эквивалентом метода Thread.sleep() , теперь вы можете создавать анимации с помощью циклов. Чтобы помочь вам начать, вот пример сопрограммы, который увеличивает координату x виджета TextView каждые 400 мс, создавая эффект, похожий на выделение:

1
2
3
4
5
6
launch(UI) {
    while(myTextView.x < 800) {
        myTextView.x += 10
        delay(400)
    }
}

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

Чтобы узнать больше о сопрограммах, вы можете обратиться к официальной документации . И пока вы здесь, посмотрите другие наши посты о разработке Kotlin и Android!

  • Android SDK
    Как создать приложение для Android-чата с помощью Firebase
  • Android SDK
    Отправка данных с помощью HTTP-клиента Retrofit 2 для Android
    Чике Мгбемена
  • Android SDK
    Начните с RxJava 2 для Android
    Джессика Торнсби
  • Android SDK
    Java против Kotlin: стоит ли использовать Kotlin для разработки под Android?
    Джессика Торнсби