Обзор конкурентности
При расширении опыта разработки с 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, но несколько библиотек предоставляют expect и actual объявления для её использования в общем коде. Однако, если вы используете библиотеку конкурентности, такую как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 Multiplatform у вас есть Android и iOS цели с различными правилами состояния. Некоторые команды, обычно работающие над более крупными приложениями, разделяют код для очень конкретной функциональности и часто управляют конкурентностью на платформе хоста. Это потребует явного замораживания состояний, возвращаемых из Kotlin, но в остальном это просто.
Более расширенная модель, где конкурентность управляется в Kotlin, а хост взаимодействует в главном потоке с общим кодом, проще с точки зрения управления состоянием. Библиотеки конкурентности, такие как kotlinx.coroutines, помогут автоматизировать замораживание. Вы также сможете использовать преимущества корутин в своём коде и повысить эффективность, поделившись больше кода.
Однако, текущая модель конкурентности Kotlin/Native имеет ряд недостатков. Например, мобильные разработчики привыкли свободно обмениваться своими объектами между потоками, и уже разработали ряд подходов и архитектурных паттернов для предотвращения гонок данных при этом. Можно написать эффективные приложения, которые не блокируют главный поток с помощью Kotlin/Native, но для этого требуется значительное изучение.
Вот почему мы работаем над созданием нового менеджера памяти и модели конкурентности для Kotlin/Native, которые помогут устранить эти недостатки. Узнайте больше о наших дальнейших планах.
Этот материал подготовлен компанией 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-concurrency-overview.html