Обзор конкурентности
При расширении опыта разработки с Android на Kotlin Multiplatform Mobile вы столкнетесь с другой моделью состояния и конкурентности для iOS. Это модель Kotlin/Native, которая компилирует код Kotlin в нативные двоичные файлы, которые могут выполняться без виртуальной машины, например, на iOS.
Доступ к изменяемой памяти для нескольких потоков одновременно, если он не ограничен, известен как рискованный и подверженный ошибкам. Языки, такие как Java, C++ и Swift/Objective-C, позволяют нескольким потокам получать доступ к одному состоянию без ограничений. Проблемы с конкурентностью отличаются от других проблем программирования тем, что их часто очень трудно воспроизвести. Вы можете не заметить их во время разработки локально, и они могут случаться спорадически. Иногда вы можете увидеть их только в производстве под нагрузкой.
Короче говоря, просто потому, что ваши тесты проходят, вы не можете быть уверены, что ваш код в порядке.
Не все языки разработаны таким образом. JavaScript просто не позволяет вам одновременно получать доступ к одному состоянию. На другом конце спектра находится Rust, с его управлением конкурентностью и состояниями на уровне языка, что делает его очень популярным.
Правила для совместного использования состояния
Kotlin/Native вводит правила для совместного использования состояний между потоками. Эти правила существуют для предотвращения небезопасного совместного доступа к изменяемым состояниям. Если вы работаете с JVM и пишете конкурентный код, вам может потребоваться изменить архитектуру ваших данных, но это позволит достичь тех же результатов без рискованных побочных эффектов.
Также важно отметить, что существуют способы обхода этих правил. Цель состоит в том, чтобы обход этих правил был редкостью, если вообще когда-либо требовался.
Существует всего два простых правила относительно состояния и конкурентности.
Правило 1: Изменяемое состояние == 1 поток
Если ваше состояние изменяемо, только один поток может видеть его за раз. Любое обычное состояние класса, которое вы обычно используете в Kotlin, считается изменяемым в среде выполнения Kotlin/Native. Если вы не используете конкурентность, Kotlin/Native ведет себя так же, как и любой другой код Kotlin, за исключением глобального состояния.
data class SomeData(var count:Int)
fun simpleState(){
val sd = SomeData(42)
sd.count++
println("My count is ${sd.count}") // It will be 43
}
Если потоков только один, у вас не будет проблем с конкурентностью. В техническом плане это называется ограничением потока, что означает, что вы не можете изменить пользовательский интерфейс из фонового потока. Правила состояния Kotlin/Native формализуют это понятие для всех потоков.
Правило 2: Неизменяемое состояние == много потоков
Если состояние нельзя изменить, к нему могут безопасно получить доступ несколько потоков. В Kotlin/Native неизменяемое не означает, что все является val. Это означает замороженное состояние.
Неизменяемое и замороженное состояние
Пример ниже неизменяем по определению — он имеет 2 val элемента, и оба являются конечными неизменяемыми типами.
data class SomeData(val s:String, val i:Int)
Следующий пример может быть неизменяемым или изменяемым. Неясно, что SomeInterface будет делать внутри во время компиляции. В Kotlin невозможно статически определить глубокую неизменяемость во время компиляции.
data class SomeData(val s:String, val i:SomeInterface)
Kotlin/Native необходимо проверить, что какая-то часть состояния действительно неизменяема во время выполнения. Среда выполнения могла бы просто пройтись по всему состоянию и проверить, что каждая часть является глубоко неизменяемой, но это было бы негибко. И если вам нужно было делать это каждый раз, когда среда выполнения хотела проверить изменяемость, это имело бы значительные последствия для производительности.
Kotlin/Native определяет новое состояние среды выполнения, называемое замороженным. Любой экземпляр объекта может быть заморожен. Если объект заморожен:
Вы не можете изменить какую-либо часть его состояния. Попытка сделать это приведет к исключению во время выполнения:
InvalidMutabilityException. Замороженный экземпляр объекта — это 100% неизменяемый объект, проверенный во время выполнения.Все, на что он ссылается, также замораживается. Все другие объекты, на которые он ссылается, гарантированно заморожены. Это означает, что при определении среды выполнения, можно ли совместно использовать объект с другим потоком, ей необходимо только проверить, заморожен ли этот объект. Если он заморожен, вся графа также заморожена и может быть безопасно разделена.
Среда выполнения Native добавляет функцию расширения freeze() ко всем классам. Вызов freeze() заморозит объект и все объекты, на которые он ссылается рекурсивно.
data class MoreData(val strData: String, var width: Float)
data class SomeData(val moreData: MoreData, var count: Int)
//...
val sd = SomeData(MoreData("abc", 10.0), 0)
sd.freeze()

freeze()— это односторонняя операция. Вы не можете размораживать что-либо.freeze()недоступно в общем коде Kotlin, но несколько библиотек предоставляют ожидаемые и фактические объявления для использования в общем коде. Однако, если вы используете библиотеку конкурентности, например,kotlinx.coroutines, она, скорее всего, автоматически заморозит данные, которые пересекают границы потоков.
freeze не уникально для Kotlin. Его также можно найти в Ruby и JavaScript.
Глобальное состояние
Kotlin позволяет определить состояние как доступное глобально. Если оно остается просто изменяемым, глобальное состояние нарушит правило 1. Для соответствия правилам состояния Kotlin/Native для глобального состояния существуют некоторые особые условия. Эти условия замораживают состояние или делают его видимым только для одного потока.
Глобальный объект
Глобальные object экземпляры по умолчанию замораживаются. Это означает, что все потоки могут получать к ним доступ, но они неизменяемы. Следующее не сработает.
object SomeState{
var count = 0
fun add(){
count++ //This will throw an exception
}
}
Попытка изменить count приведет к исключению, потому что SomeState заморожен (а это значит, что все его данные заморожены).
Вы можете сделать глобальный объект локальным, что позволит ему быть изменяемым и даст каждому потоку свою копию его состояния. Отметьте его аннотацией @ThreadLocal.
@ThreadLocal
object SomeState{
var count = 0
fun add(){
count++ //👍
}
}
Если разные потоки читают count, они получат разные значения, потому что у каждого потока есть своя копия.
Эти правила глобальных объектов также применяются к объектам-компаньонам.
class SomeState{
companion object{
var count = 0
fun add(){
count++ //This will throw an exception
}
}
}
Глобальные свойства
Глобальные свойства — особый случай. Они доступны только главному потоку, но они изменяемы. Доступ к ним из других потоков приведет к исключению.
val hello = "Hello" //Only main thread can see this
Вы можете отметить их аннотацией:
@SharedImmutable, что сделает их доступными глобально, но замороженными.@ThreadLocal, что предоставит каждому потоку свою изменяемую копию.
Это правило относится к глобальным свойствам со вспомогательными полями. Вычисляемые свойства и глобальные функции не имеют ограничения на основной поток.
Текущие и будущие модели
Правила одновременного выполнения в Kotlin/Native потребуют некоторой корректировки в архитектурном дизайне, но благодаря библиотекам и новым лучшим практикам, ежедневная разработка практически не изменится. На самом деле, соблюдение правил Kotlin/Native относительно кроссплатформенного кода приведет к более безопасному параллельному выполнению в кроссплатформенном мобильном приложении. Вы можете попробовать модель одновременного выполнения Kotlin/Native в этом практическом руководстве.
В приложении Kotlin Multiplatform у вас есть цели для Android и iOS с разными правилами состояния. Некоторые команды, как правило, работающие над более крупными приложениями, используют общий код для очень специфической функциональности и часто управляют параллельным выполнением на платформе хоста. Это потребует явного замораживания состояний, возвращаемых из Kotlin, но в остальном это просто.
Более сложная модель, где параллельное выполнение управляется в Kotlin, а хост общается в потоке основного приложения с общим кодом, проще с точки зрения управления состоянием. Библиотеки параллельного выполнения, такие как kotlinx.coroutines, помогут автоматизировать замораживание. Вы также сможете использовать мощь корутин в вашем коде и повысить эффективность, используя больше общего кода.
Однако, текущая модель параллельного выполнения Kotlin/Native имеет ряд недостатков. Например, разработчики мобильных приложений привыкли свободно обмениваться своими объектами между потоками, и они уже разработали ряд подходов и архитектурных шаблонов для предотвращения гонок данных при этом. Можно написать эффективные приложения, не блокирующие основной поток, используя Kotlin/Native, но это требует значительных усилий в обучении.
Поэтому мы работаем над созданием нового менеджера памяти и модели параллельного выполнения для Kotlin/Native, которые помогут устранить эти недостатки. Узнайте больше о наших дальнейших планах.
Этот материал был подготовлен компанией 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-concurrency-overview.html