Конкурентная изменяемость
При работе с 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 явно замораживаются в приведенном выше коде. Однако в кроссплатформенных библиотеках данные будут замораживаться автоматически. Если вы используете 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()
}
}
Вам необходимо реализовать некоторую форму блокировки или логики проверки и установки для обеспечения надлежащей конкурентности.
Изолированные от потоков состояния
Правило 1 состояния 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–2023 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