Конкурентная изменяемость
При работе с iOS, модель состояния и конкурентности Kotlin/Native имеет два простых правила.
Изменяемое, не-замороженное состояние видно только одному потоку за раз.
Неизменяемое, замороженное состояние может быть разделено между потоками.
Следование этим правилам означает, что вы не можете изменять глобальные состояния, и вы не можете изменять одно и то же общее состояние из нескольких потоков. В многих случаях, просто изменив подход к разработке кода, всё будет работать, и вам не понадобится конкурентная изменяемость. Состояния были изменяемыми из нескольких потоков в JVM-коде, но им не нужно было.
Однако, во многих других случаях, вам может потребоваться произвольный доступ к состоянию из разных потоков, или у вас могут быть объекты сервиса, которые должны быть доступны для всей программы. Или, может быть, вы просто не хотите тратить потенциально дорогое время на переработку существующего кода. Какова бы ни была причина, не всегда будет возможно ограничить изменяемое состояние одним потоком.
Существуют различные техники, которые помогают вам обойти эти ограничения, каждая со своими преимуществами и недостатками:
Атомарные переменные
Kotlin/Native предоставляет набор атомарных классов, которые могут быть замороженными, но при этом поддерживать изменения содержимого. Эти классы реализуют специальную обработку состояний в runtime Kotlin/Native. Это означает, что вы можете изменять значения внутри замороженного состояния.
Runtime 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 runtime 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