Конкурентная изменяемость
При работе с iOS модель состояния и конкурентности Kotlin/Native подчиняется двум простым правилам.
Изменяемое, незамороженное состояние видно только одному потоку за раз.
Неизменяемое, замороженное состояние может быть разделено между потоками.
Следование этим правилам означает, что вы не можете изменять глобальные состояния и не можете изменять одно и то же совместное состояние из нескольких потоков. Во многих случаях достаточно просто изменить подход к проектированию кода, и вам не понадобится конкурентная изменяемость. Состояния могли изменяться из нескольких потоков в коде JVM, но это не было необходимо.
Однако во многих других случаях вам может потребоваться произвольный доступ потока к состоянию или у вас могут быть объекты сервиса, которые должны быть доступны для всего приложения. Возможно, вы просто не хотите тратить время на потенциально дорогостоящую переработку существующего кода. В любом случае, не всегда будет возможно ограничить изменяемое состояние одним потоком.
Существуют различные методы, которые помогут вам обойти эти ограничения, каждый со своими преимуществами и недостатками:
Атомные типы
Kotlin/Native предоставляет набор атомных классов, которые могут быть замороженными, но при этом поддерживают изменения содержащихся в них значений. Эти классы реализуют специальную обработку состояний в среде выполнения Kotlin/Native. Это означает, что вы можете изменять значения внутри замороженного состояния.
В среде выполнения Kotlin/Native есть несколько различных вариаций атомных типов. Вы можете использовать их напрямую или из библиотеки.
Kotlin предоставляет экспериментальную библиотеку низкого уровня kotlinx.atomicfu, которая в настоящее время используется только для внутренних целей и не поддерживается для общего использования. Вы также можете использовать Stately, утилитарную библиотеку для кроссплатформенной совместимости с Kotlin/Native-специфичной конкурентностью, разработанную компанией Touchlab.
AtomicInt/AtomicLong
Первые два — это простые числовые типы: AtomicInt и AtomicLong. Они позволяют иметь общее Int или Long, которое можно читать и изменять из нескольких потоков.
object AtomicDataCounter {
val count = AtomicInt(3)
fun addOne() {
count.increment()
}
}
Приведенный выше пример представляет собой глобальное object, которое по умолчанию заморожено в Kotlin/Native. Однако в этом случае вы можете изменять значение count. Важно отметить, что вы можете изменить значение count из любого потока.
AtomicReference
AtomicReference хранит экземпляр объекта, и вы можете изменить этот экземпляр объекта. Объект, который вы помещаете в AtomicReference, должен быть заморожен, но вы можете изменить значение, которое AtomicReference содержит. Например, следующее не будет работать в Kotlin/Native:
data class SomeData(val i: Int)
object GlobalData {
var sd = SomeData(0)
fun storeNewValue(i: Int) {
sd = SomeData(i) //Doesn't work
}
}
Согласно правилам глобального состояния, глобальные object значения замораживаются в Kotlin/Native, поэтому попытка изменить sd завершится ошибкой. Вы можете реализовать это с помощью AtomicReference:
data class SomeData(val i: Int)
object GlobalData {
val sd = AtomicReference(SomeData(0).freeze())
fun storeNewValue(i: Int) {
sd.value = SomeData(i).freeze()
}
}
Сам AtomicReference заморожен, что позволяет ему существовать внутри замороженного объекта. Данные в экземпляре AtomicReference явно заморожены в приведенном выше коде. Однако в кроссплатформенных библиотеках данные будут заморожены автоматически. Если вы используете среду выполнения Kotlin/Native, вы должны явно вызвать freeze().
AtomicReference может быть очень полезным, когда вам нужно обмениваться состоянием. Однако следует учитывать некоторые недостатки.
Доступ к значениям и их изменение в AtomicReference очень дорого обходятся с точки зрения производительности по сравнению с стандартным изменяемым состоянием. Если производительность является проблемой, вы можете рассмотреть другой подход, связанный с изолированным состоянием потоков.
Также существует потенциальная проблема с утечками памяти, которая будет решена в будущем. В ситуациях, когда объект, хранящийся в AtomicReference, имеет циклические ссылки, он может утечь память, если вы не очистите его явно:
Если у вас есть состояние, которое может иметь циклические ссылки и нуждается в освобождении, вы должны использовать тип nullable в
AtomicReferenceи явно установить его в null, когда вы закончите с ним.Если вы храните
AtomicReferenceв глобальном объекте, который никогда не выходит из области видимости, это не имеет значения (потому что память никогда не нужно освобождать в течение жизни процесса).
class Container(a:A) {
val atom = AtomicReference<A?>(a.freeze())
/**
* Call when you're done with Container
*/
fun clear(){
atom.value = null
}
}
Наконец, также существует проблема с согласованностью. Установка/получение значений в AtomicReference само по себе является атомным, но если ваша логика требует более длинной цепочки исключения потоков, вам нужно будет реализовать это самостоятельно. Например, если у вас есть список значений в AtomicReference и вы хотите сначала просканировать их перед добавлением нового, вам потребуется некоторая форма управления конкурентностью, которую AtomicReference не предоставляет.
Следующее не защитит от дублирования значений в списке, если оно вызывается из нескольких потоков:
object MyListCache {
val atomicList = AtomicReference(listOf<String>().freeze())
fun addEntry(s:String){
val l = atomicList.value
val newList = mutableListOf<String>()
newList.addAll(l)
if(!newList.contains(s)){
newList.add(s)
}
atomicList.value = newList.freeze()
}
}
Вам необходимо реализовать некоторую форму блокировки или логики «проверки и установки» для обеспечения правильной конкурентности.
Изолированные состояния потоков
Первое правило состояния Kotlin/Native состоит в том, что изменяемое состояние видно только одному потоку. Атомные типы позволяют изменять состояние из любого потока. Изоляция изменяемого состояния в отдельном потоке и разрешение другим потокам взаимодействовать с этим состоянием — альтернативный метод достижения конкурентной изменяемости.
Для этого создайте очередь задач, которая имеет эксклюзивный доступ к потоку, и создайте изменяемое состояние, которое существует только в этом потоке. Другие потоки взаимодействуют с изменяемым потоком, планируя задачи в очереди задач.
Данные, которые передаются или извлекаются (если таковые имеются), должны быть заморожены, но изменяемое состояние, скрытое в рабочем потоке, остается изменяемым.
Концептуально это выглядит следующим образом: один поток помещает замороженное состояние в рабочий поток состояния, который хранит его в контейнере изменяемого состояния. Другой поток позже планирует работу, которая извлекает это состояние.

Реализация изолированных состояний потоков несколько сложна, но существуют библиотеки, которые обеспечивают эту функциональность.
AtomicReference по сравнению с изолированным состоянием потоков
Для простых значений AtomicReference будет, вероятно, более простым вариантом. В случаях с большими состояниями и потенциально значительными изменениями состояния использование изолированного состояния потоков может быть лучшим выбором. Основным штрафом за производительность является фактическое переключение между потоками. Но в тестах производительности с коллекциями, например, изолированное состояние потока значительно превосходит изменяемое состояние, реализованное с помощью AtomicReference.
Изолированное состояние потоков также избегает проблем с согласованностью, которые есть у AtomicReference. Поскольку все операции происходят в потоке состояния и поскольку вы планируете работу, вы можете выполнять операции с несколькими шагами и гарантировать согласованность без управления исключениями потоков. Изоляция потоков — это конструктивная особенность правил состояния Kotlin/Native, и изоляция изменяемых состояний работает с этими правилами.
Изолированное состояние потоков также более гибкое, поскольку вы можете сделать изменяемые состояния конкурентными. Вы можете использовать любой тип изменяемого состояния, а не создавать сложные конкурентные реализации.
Возможности низкого уровня
Kotlin/Native предлагает более продвинутые способы совместного использования конкурентных состояний. Для достижения высокой производительности вам может потребоваться полностью обойти правила конкурентного доступа.
Kotlin/Native работает поверх C++ и предоставляет возможность взаимодействия с C и Objective-C. Если вы работаете с iOS, вы также можете передавать лямбда-аргументы в свой общий код из Swift. Весь этот код нативном языке выполняется вне ограничений состояния Kotlin/Native.
Это означает, что вы можете реализовать конкурентное изменяемое состояние на языке нативного уровня и обеспечить взаимодействие Kotlin/Native с ним.
Вы можете использовать взаимодействие с Objective-C для доступа к коду низкого уровня. Вы также можете использовать Swift для реализации Kotlin-интерфейсов или передавать лямбды, которые код Kotlin может вызывать из любого потока.
Одним из преимуществ платформенно-нативного подхода является производительность. С другой стороны, вам необходимо самостоятельно управлять конкурентностью. Objective-C не знает о frozen, но если вы сохраняете состояния из Kotlin в структурах Objective-C и используете их между потоками, состояния Kotlin обязательно должны быть заморожены. Среда выполнения Kotlin/Native, как правило, предупреждает вас о проблемах, но возможно возникновение проблем с конкурентностью в нативном коде, которые очень сложно отследить. Также очень легко создать утечки памяти.
Поскольку в приложении Kotlin Multiplatform вы также ориентируетесь на JVM, вам потребуются альтернативные способы реализации того, что вы используете в коде нативной платформы. Это, очевидно, потребует больше усилий и может привести к несовпадениям между платформами.
Этот материал подготовлен компанией Touchlab для публикации JetBrains.
© 2010–2022 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/multiplatform-mobile-concurrent-mutability.html