- PVSM.RU - https://www.pvsm.ru -
Привет! Меня зовут Женя Васильев, я делаю Яндекс.Карты под Android. А с появлением у нас Kotlin Multiplatform — так уж получилось, ещё и под iOS.
Kotlin Multiplatform позволяет писать код, который будет одинаково работать на iOS и Android. По крайней мере, должен одинаково работать. И в случае с простыми фичами правда работает. Но если вы, как и я, впервые реализовываете в мультиплатформе сложную фичу с многопоточностью, на iOS вас будут ждать креши в рантайме и утечки.
В статье я расскажу и покажу на примерах, с какими проблемами я столкнулся при написании многопоточного кода на Kotlin Multiplatform, как эти проблемы решать, как лучше организовывать потоки данных в многопоточной среде и что ещё нужно делать, чтобы написанное на Kotlin не падало на iOS. Увы, писать код «как обычно» не получится.
Два года назад, чтобы уменьшить дублирование кода между iOS- и Android-приложениями Карт, мы начали выносить в мультиплатформу относительно простую однопоточную бизнес-логику. И это уже было хорошо: меньше дублирования — меньше багов и быстрее разработка, в итоге пользователи быстрее получают более качественный продукт. Потом общей логики стало больше, и мы начали экспериментировать с многопоточностью [1]. Был большой оверхэд на переключение тредов, но для разовых асинхронных операций это было не страшно. Потом появились корутины native-mt [2] с фоновыми потоками — быстрое и официальное решение, позволяющее легко реализовать в мультиплатформе логику любой сложности! Так подумали мы, и я взялся за разработку новой версии поискового слоя.
Поисковый слой отображает результаты поиска в виде пинов на карте. Слой вычисляет, как нужно показать пин каждого объекта, исходя из его рейтинга, плотности поисковой выдачи, уровня зума, расстояния до соседей и так далее. Плавность интерфейса без многопоточности тут недостижима.
В итоге всё получилось. Ошиблись мы только в одном: что будет легко.
Чтобы быть на одной волне, читателю может понадобиться:
На Android мультиплатформенный код исполняется на Kotlin/JVM — так же, как если бы это был код обычного Android-приложения на Kotlin.
На iOS тот же код компилируется в машинный код, который исполняется без виртуальной машины. Эта технология называется Kotlin/Native. И модель памяти Kotlin/Native отличается от Java Memory Model.
Напишем простой пример:
fun yolo() {
MainScope().launch {
var a = 1
withContext(Dispatchers.Default) {
a = 2 // InvalidMutabilityException (iOS)
}
a = 3
}
}
На Android всё работает. На iOS при вызове yolo()
произойдёт креш. Потому что мы не учли правила Kotlin/Native:
DetachedObjectGraph
. О нём поговорим ниже.
Заморозка — рантайм-свойство Kotlin/Native. Заморозить любой объект можно с помощью экстеншена .freeze()
. Это односторонняя операция: разморозить объект нельзя. Замороженные объекты не должны изменяться, при попытке их мутирования произойдёт InvalidMutabilityException
. Заморозка применяется ко всему графу объектов: замораживается всё, к чему можно попасть по ссылкам из исходного объекта.
При переключении потоков с помощью библиотеки корутин заморозка происходит автоматически. Если же возникает необходимость ручной заморозки объектов, в мультиплатформе придётся реализовать expect-actual для вызова .freeze()
или подключить библиотеку stately-common [8].
Рассмотрим чуть более реалистичный пример:
MainScope().launch {
var counter: Int = countSomething()
withContext(Dispatchers.Default) {
println(counter)
}
counter += countMoar() // InvalidMutabilityException (iOS)
renderUi(counter)
}
InvalidMutabilityException
будет брошен при попытке мутирования замороженной переменной counter
. А заморозится переменная автоматически при переключении потоков (диспатчеров), поскольку будет захвачена лямбдой. Нужно любым способом избавиться от мутирования замороженных данных. Например, так:
MainScope().launch {
val frozenCounter: Int = countSomething()
withContext(Dispatchers.Default) {
println(frozenCounter)
}
val counter: Int = frozenCounter + countMoar()
renderUi(counter)
}
Даже в таком простом случае мы вынуждены пересмотреть поток данных и учесть в коде, что переменные, которые пересекли границы тредов, станут иммутабельными, даже если они объявлены как var
.
Итак, нам нужен кеш и фоновые вычисления. Кажется, мы к этому готовы:
class PinStateProcessor {
private val cache: MutableMap<PinId, PinState> = mutableMapOf()
private val defaultPinState = PinState.DefaultState
suspend fun calculateState(pinId: PinId, ctx: SearchContext): PinState {
cache[pinId]?.let { cachedState ->
return cachedState
}
val pinState = withContext(Dispatchers.Default) {
calculateStateInternal(pinId, ctx, defaultPinState)
}
cache[pinId] = pinState
return pinState
}
private fun calculateStateInternal(pinId: PinId, ctx: SearchContext, defaultState: PinState): PinState {
// ... Some math ...
return PinState.SomeState
}
}
Создаём и работаем с PinStateProcessor
на главном потоке, calculateState()
зовём из Main
-диспатчера. Внутри в случае необходимости корутина на главном потоке приостанавливается, на фоновом потоке происходят вычисления, затем на главном потоке обновляется кеш (для простоты опустим синхронизацию). Таким образом кеш не пересекает границы тредов и остаётся мутабельным. А заморозка константы defaultPinState
, попадающей на Default
-диспатчер, нас не волнует. Всё в порядке. Или нет?
Здесь есть две похожие ошибки, каждая из которых приводит к InvalidMutabilityException
при вызове calculateState()
на iOS. Чтобы стало очевидно, перепишу небольшой кусочек:
val pinState = withContext(Dispatchers.Default) {
this@PinStateProcessor.calculateStateInternal(
pinId,
ctx,
this@PinStateProcessor.defaultPinState
)
}
Поля и функции-члены класса доступны только в контексте этого класса. Поэтому при работе с defaultPinState
или при вызове calculateStateInternal()
на фоновом потоке лямбдой будет захвачен весь инстанс класса. А заморозка класса приведёт к заморозке всех его полей, включая кеш, который мы впоследствии пытаемся изменить.
Константу здесь можно заинлайнить, вынести в статику или переложить в локальную переменную перед переключением потоков. А вот чистую функцию отвязать от класса можно только одним способом: сделать её статической.
С детскими болезнями справились, теперь аккуратно добавим логирование:
suspend fun calculateState(pinId: PinId, ctx: SearchContext): PinState {
cache[pinId]?.let { return it }
val pinState = withContext(Dispatchers.Default) {
calculateStateInternalStatic(pinId, ctx, PinState.DefaultState)
}
cache[pinId] = pinState
logPinStates(cache.values)
return pinState
}
Реализация где-то в отдельном модуле:
fun logPinStates(states: Collection<PinState>) {
println(states.toString())
}
После этого уйдём в отпуск.
Вернувшись из отпуска, заглянем в модуль с логированием. Коллекция тут read-only, так что вынесение логирования в фон, кажется, не помешает:
suspend fun logPinStates(states: Collection<PinState>) {
withContext(Dispatchers.Default) {
println(states.toString())
}
}
При первом вызове calculateState()
на iOS всё хорошо. А при втором происходит креш. Происходит он потому, что cache.values
возвращает вьюху к исходной мутабельной мапе. А read-only интерфейс, как и в «обычном» мире, не утверждает, что за ним скрываются иммутабельные данные. В итоге на фоновый поток передаётся весь кеш, и при следующей попытке его мутирования всё закономерно падает.
Получаем два грустных правила:
InvalidMutabilityException
выкидывается при попытке мутирования замороженного объекта. В последнем примере это выглядит так: InvalidMutabilityException: mutation attempt of frozen kotlin.collections.HashMap
. Место изменения кеша найти несложно, но его мутирование — не ошибка. Ошибка в том, что кеш оказался заморожен. А вот найти место заморозки в большом количестве разветвлённого кода может быть сложно.
Будет легче, если использовать экстеншен Any.ensureNeverFrozen()
. Он доступен в Kotlin/Native; в мультиплатформе придётся реализовать expect-actual или воспользоваться библиотекой stately-common [8].
Глобальные правила игры не изменятся: по-прежнему нельзя допускать, чтобы объект, который должен оставаться мутабельным, замораживался. Если это произойдёт, по-прежнему будет креш в рантайме. Но это будет другой креш, в другом месте и в другое время: FreezingException
. Брошен он будет при заморозке объекта, на котором был вызван .ensureNeverFrozen()
.
private val cache = mutableMapOf<PinId, PinState>().apply { ensureNeverFrozen() }
Теперь при первом же вызове calculateState()
со «сломанным» логированием мы получим FreezingException
с ведущим к logPinStates()
стектрейсом.
Имеет смысл вызывать .ensureNeverFrozen()
на всех объектах, которые, согласно архитектуре вашего приложения, действительно не должны замораживаться. Но, помня про то, что граф объектов замораживается целиком, .ensureNeverFrozen()
достаточно вызывать лишь на ключевых объектах-листьях графа.
Также в Kotlin/Native есть экстеншен-проперти .isFrozen
. Он тоже может помочь при отладке. Ещё с его помощью можно строить сложные условные потоки данных или производить оптимизации:
var list = listOf(MutableData(1), MutableData(2), MutableData(3))
blackBox(list)
if (list.isFrozen) {
list = list.deepCopy()
}
list.forEach { it.counter++ }
Предположим, нам удобнее (или даже необходимо), чтобы с нашим классом и кешем можно было работать из любого потока.
К счастью, в Kotlin/Native есть костыли. Имя им — shareable-сущности [9]. Вот некоторые из них: AtomicReference
, AtomicInt
, Deferred
, Channel
, Mutex
, Flow
. Они заморожены и ссылаются на замороженные данные. Но их содержимое может меняться!
С их помощью можно реализовать по сути мутабельный кеш в полностью замороженной среде:
class PinStateProcessor {
private val cache = AtomicReference<Map<PinId, PinState>>(emptyMap())
private val mutex = Mutex()
init {
freeze()
}
suspend fun calculateState(pinId: PinId, ctx: SearchContext): PinState {
mutex.withLock {
cache.value[pinId]?.let { return it }
return withContext(Dispatchers.Default) {
val pinState = calculateStateInternal(pinId, ctx, DefaultState)
cache.value += pinId to pinState
pinState
}
}
}
}
Чтобы атомики проросли в мультиплатформу, можно использовать библиотеку stately-concurrency [10].
На всякий случай расшифрую: cache.value += pinId to pinState
— эквивалент cache.set(cache.get().toMutableMap().apply { put(pinId, pinState) })
. Вызывать руками freeze()
необязательно: при первом же переключении диспатчеров всё заморозится автоматически. Но, как и в случае с добавлением ensureNeverFrozen()
, так мы быстрее обнаружим ошибку, если случайно заморозим что-то лишнее.
Забавный факт: если поменять местами init-блок и объявление полей класса, мы получим InvalidMutabilityException
при инстанциировании PinStateProcessor
. Потому что вначале всё заморозится, и только после этого полям будут присваиваться указанные в коде значения. В Kotlin так принято.
Кроме того, у перечислений, глобальных переменных и объектов в Kotlin/Native есть особенности в плане заморозки и доступа с разных потоков. Не буду пересказывать документацию, почитайте [11].
Нельзя написать хорошую фичу, особенно на новой технологии, не посидев с профайлером. Вот и я перед написанием реального кода занялся исследованиями. С производительностью в Kotlin/Native всё хорошо, накладные расходы на переключение потоков минимальны. Но проблема всё же нашлась.
suspend fun leak() {
val leakedList = withContext(Dispatchers.Default) {
val list = mutableListOf<Int>()
repeat(1_000_000) {
list += it
}
list
}
println(leakedList.size)
}
Вызываем эту функцию несколько раз и смотрим график потребления памяти на Android:
И на iOS:
Оказывается, объекты, пересекающие границы потоков Kotlin/Native, могут утекать. Оказывается, в Kotlin/Native есть сборщик мусора, но его нужно вызывать руками. Оказывается, и этого не было в документации, GC Kotlin/Native — тред-локальный: на каком потоке утекло, на том и надо его вызывать. Делать это нужно после выхода из Kotlin-скоупа, объекты которого хочется освободить, чтобы на стеке и в Continuation
не оставалось мусора.
В мультиплатформе GC доступен через expect-actual:
// commonMain
expect object GC {
fun collect()
}
// iosMain
actual typealias GC = kotlin.native.internal.GC
// androidMain
actual object GC {
actual fun collect() = Unit
}
В нашем случае поможет, например, функция, вызываемая следом за функцией с утечками:
suspend fun gc() {
GC.collect()
withContext(Dispatchers.Default) {
GC.collect()
}
}
Проверяем:
Отлично.
Но когда в реальном мире нужно вызывать GC и нужно ли? Действуйте по ситуации и на свой вкус. Вот возможные варианты:
DetachedObjectGraph
— единственный способ передачи объектов между потоками без заморозки. И сейчас я постараюсь вас убедить, что он вам не нужен.
Для работы с DetachedObjectGraph
в мультиплатформе придётся написать expect-actual на сам DetachedObjectGraph
, TransferMode
и экстеншен DetachedObjectGraph<T>.attach()
.
Вот простейший пример использования:
val dog = DetachedObjectGraph(TransferMode.SAFE) { mutableListOf<Any>() }
withContext(Dispatchers.Default) {
val list = dog.attach()
list += Unit
println(list.size) // 1
}
Правила такие: лямбда в конструкторе DetachedObjectGraph
должна возвращать передаваемый между потоками объект. Больше никаких ссылок на этот объект быть не должно. После переключения потоков метод attach()
«прикрепляет» к текущему потоку передаваемый объект и возвращает его. Далее всё работает как обычно.
Пример не содержит ошибок, но бесполезен, потому что такой новый и пустой мутабельный список можно создать сразу на нужном потоке. Попробуем передать использованный объект:
var outerList: MutableList<Any>? = mutableListOf()
outerList?.add(Unit)
val dog = DetachedObjectGraph(TransferMode.SAFE) {
outerList.also {
outerList = null
}
}
withContext(Dispatchers.Default) {
val innerList = dog.attach()
innerList?.add(Unit)
println(innerList?.size) // 2
}
Переменную outerList
нужно занулять, чтобы ссылка на список была только в конструкторе. В противном случае при создании DetachedObjectGraph
вы получите IllegalStateException: Illegal transfer state
. Но если запустить этот код в цикле достаточно много раз, вы всё равно получите IllegalStateException
.
Вспоминаем про GC и дописываем:
val dog = DetachedObjectGraph(TransferMode.SAFE) {
{
outerList.also {
outerList = null
}
}().also {
GC.collect()
}
}
Теперь всё правда работает.
С помощью этой штуки можно, например, реализовать многопоточную работу с действительно мутабельным кешем. Нужно только ставить мьютексы и не забывать аналогичным способом возвращать кеш в начальную позицию.
Но у всего есть цена. И здесь эта цена — O(N). Платить вы будете при создании DetachedObjectGraph
, потому что каждый раз происходит проверка, что никто кроме DetachedObjectGraph
не держит ссылок ни на один из объектов передаваемого графа. Святослав Щербина из JetBrains в переписке со мной это прокомментировал, публикую комментарий с его согласия:
Там всё немного хуже. Ещё обрабатывается текущий буфер уменьшений счётчиков ссылок в GC, который тоже может быть большим. И он после этого не очищается, так что эта цена не амортизируется в O(N).
За проверку наличия лишних ссылок отвечает TransferMode
. При TransferMode.SAFE
проверка включена; повторюсь, чем больше объектов передаётся между потоками, тем она дороже. TransferMode.UNSAFE
проверку отключает — не требование про количество ссылок, а только проверку при создании. Если, дочитав досюда, вы совсем преисполнились, и слово unsafe в контексте Kotlin/Native не вызывает у вас никаких эмоций, обратите внимание на KDoc:
/**
* Skip reachibility check, can lead to mysterious crashes in an application.
* USE UNSAFE MODE ONLY IF ABSOLUTELY SURE WHAT YOU'RE DOING!!!
*/
Более того, Святослав в переписке рассказал о важном нюансе: на самом деле и обработка буфера уменьшений счётчиков ссылок, и обход подграфа в UNSAFE-режиме тоже будут происходить, хоть и меньшее количество раз. Так что накладные расходы на передачу большого числа объектов останутся, поэтому прирост производительности надо замерять: он может оказаться незначительным.
Итак, я во всё это погрузился, и решил не использовать DetachedObjectGraph
в проде. И вам не советую.
Теперь можно подытожить, когда и как лучше передавать данные между потоками в мультиплатформе.
withContext
, async
и так далее, а «обратно» — через возвращаемое значение withContext
или Deferred
.DetachedObjectGraph
на свой страх и риск.Америку я не открою, это базовая фича корутин. Но упомянуть будет не лишним. Если у вас много работы, которую вы не хотите или не можете вынести в фон, её можно попробовать разбить на части, разгрузив главный поток.
Такой код вешает главный поток на несколько секунд:
var counter = 0L
repeat(1_000_000_000) {
counter++
}
А такой выполняет столько же работы и не тормозит:
var counter = 0L
repeat(1_000) {
repeat(1_000_000) {
counter++
}
yield()
}
Я люблю сидеть с напильником и микроскопом. И полученный опыт, и результат мне понравились. Новый поисковый слой сейчас постепенно раскатывается, скоро он будет доступен у всех пользователей. И на приборах мы не видим ни одного InvalidMutabilityException
, хотя получить его чертовски просто.
Разработчики Kotlin/Native из JetBrains давно работают над созданием новой [12] модели памяти. Совсем недавно было опубликовано превью для разработчиков [13], уже можно попробовать жизнь без заморозки. Когда-нибудь новая модель памяти зарелизится, надеюсь, без критичных багов. И тогда можно будет забыть про InvalidMutabilityException
. Но этот день ещё не настал.
Если вы уже используете Kotlin Multiplatform, надеюсь, мой опыт будет вам полезен. Если же вы только поглядываете на мультиплатформу, рекомендую её попробовать хотя бы для простых фич. А как распробуете, глядишь, и новая модель памяти подъедет.
В качестве заключения, три совета на разных уровнях абстракции:
ensureNeverFrozen()
: это очень упрощает отладку.Автор: Евгений Васильев
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/kotlin/367637
Ссылки в тексте:
[1] многопоточностью: https://kotlinlang.org/docs/mobile/concurrency-and-coroutines.html#alternatives-to-kotlinx-coroutines
[2] native-mt: https://github.com/Kotlin/kotlinx.coroutines/blob/native-mt/kotlin-native-sharing.md
[3] Документация: https://kotlinlang.org/docs/mpp-intro.html
[4] Плагин к Android Studio: https://kotlinlang.org/docs/mobile/setup.html
[5] Документация: https://kotlinlang.org/docs/mpp-connect-to-apis.html
[6] Документация: https://kotlinlang.org/docs/coroutines-guide.html
[7] native-mt: https://github.com/kotlin/kotlinx.coroutines/issues/462
[8] stately-common: https://github.com/touchlab/Stately#stately-common
[9] shareable-сущности: https://github.com/Kotlin/kotlinx.coroutines/blob/native-mt/kotlin-native-sharing.md#communication-objects
[10] stately-concurrency: https://github.com/touchlab/Stately#stately-concurrency
[11] почитайте: https://kotlinlang.org/docs/native-concurrency.html#global-variables-and-singletons
[12] новой: https://blog.jetbrains.com/kotlin/2021/05/kotlin-native-memory-management-update/
[13] превью для разработчиков: https://blog.jetbrains.com/kotlin/2021/08/try-the-new-kotlin-native-memory-manager-development-preview/
[14] Источник: https://habr.com/ru/post/575846/?utm_source=habrahabr&utm_medium=rss&utm_campaign=575846
Нажмите здесь для печати.