Неизменяемость и конкурентность в Kotlin/Native
Kotlin/Native реализует жёсткие проверки неизменяемости, гарантируя важное свойство, что объект либо неизменяем, либо доступен из единственной нити в данный момент времени (mutable XOR global).
Неизменяемость — свойство времени выполнения в Kotlin/Native, и может быть применено к произвольному подграфу объекта с помощью функции kotlin.native.concurrent.freeze. Она делает все объекты, доступные из данного, неизменяемыми. Такая трансформация является односторонней операцией. Например, объекты нельзя разморозить позднее. Некоторые естественно неизменяемые объекты, такие как kotlin.String, kotlin.Int, и другие примитивные типы, наряду с AtomicInt и AtomicReference, замораживаются по умолчанию. Если к замороженному объекту применяется мутирующая операция, выбрасывается исключение InvalidMutabilityException.
Для достижения mutable XOR global свойства, все глобально видимые состояния (в настоящее время, object синглетоны и перечисления) автоматически замораживаются. Если замораживание объекта нежелательно, можно использовать аннотацию kotlin.native.ThreadLocal, которая сделает состояние объекта локальным для нити, и, следовательно, изменяемым (но изменённое состояние не будет видно другим нитям).
Переменные верхнего уровня/глобальные переменные не-примитивных типов по умолчанию доступны только в основной нити (т.е., в нити, которая инициализировала Kotlin/Native среду выполнения первой). Доступ из другой нити приведёт к выбрасыванию исключения IncorrectDereferenceException. Чтобы сделать такие переменные доступными в других нитях, можно использовать аннотацию @ThreadLocal и пометить значение как локальное для нити или @SharedImmutable, которое заморозит значение и сделает его доступным из других нитей. Смотрите Глобальные переменные и синглетоны.
Класс AtomicReference может использоваться для публикации изменённого замороженного состояния в другие нити и построения шаблонов, таких как общие кэши. Смотрите Атомарные примитивы и ссылки.
Асинхронность в Kotlin/Native
Выполнение Kotlin/Native не поддерживает классическую модель асинхронности, основанную на потоках с взаимоисключающими блоками кода и условными переменными, так как она склонна к ошибкам и ненадёжна. Вместо этого мы предлагаем набор альтернативных подходов, позволяющих использовать аппаратную асинхронность и реализовывать блокирующие операции ввода-вывода. Эти подходы описаны ниже, и будут подробно рассмотрены в последующих разделах:
Рабочие потоки
Вместо потоков, среда выполнения Kotlin/Native предлагает концепцию рабочих потоков: потоки выполнения, выполняемые асинхронно, с ассоциированным очереди запросов. Рабочие потоки очень похожи на акторы в модели акторов. Рабочий поток может обмениваться объектами Kotlin с другим рабочим потоком, так что в любой момент времени каждый изменяемый объект принадлежит одному рабочему потоку, но владение может передаваться. См. раздел Передача и заморозка объектов.
После запуска рабочего потока с помощью вызова функции Worker.start, к нему можно обратиться по уникальному целочисленному идентификатору рабочего потока. Другие рабочие потоки или другие примитивы асинхронности, такие как системные потоки, могут отправлять сообщение рабочему потоку с помощью вызова execute.
val future = execute(TransferMode.SAFE, { SomeDataForWorker() }) {
// data returned by the second function argument comes to the
// worker routine as 'input' parameter.
input ->
// Here we create an instance to be returned when someone consumes result future.
WorkerResult(input.stringParam + " result")
}
future.consume {
// Here we see result returned from routine above. Note that future object or
// id could be transferred to another worker, so we don't have to consume future
// in same execution context it was obtained.
result -> println("result is $result")
}
Вызов execute использует функцию, переданную в качестве второго параметра, для создания подграфа объектов (например, набора взаимно ссылающихся объектов), который затем целиком передаётся этому рабочему потоку. Он больше недоступен потоку, который инициировал запрос. Это свойство проверяется, если первый параметр является TransferMode.SAFE с помощью обхода графа, и просто предполагается, что оно истинно, если оно TransferMode.UNSAFE. Последний параметр execute — специальная лямбда-функция Kotlin, которая не имеет права захватывать состояние и фактически вызывается в контексте целевого рабочего потока. После обработки результат передаётся тому, кто его ожидает в будущем, и он прикрепляется к графу объектов этого рабочего потока/потока.
Если объект передаётся в режиме UNSAFE и всё ещё доступен из нескольких параллельных исполнителей, программа, скорее всего, аварийно завершится, поэтому рассматривайте этот вариант только в качестве последнего средства оптимизации, а не как универсальный механизм.
Передача и заморозка объектов
Важное свойство, поддерживаемое средой выполнения Kotlin/Native, состоит в том, что объект либо принадлежит одному потоку/рабочему потоку, либо он неизменяем (общий XOR изменяемый). Это гарантирует, что один и тот же набор данных имеет единственного модификатора, и поэтому нет необходимости в блокировках. Для достижения такой гарантии используется концепция подграфов объектов, не имеющих внешних ссылок. Это подграф без внешних ссылок извне подграфа, который можно проверить алгоритмически со сложностью O(N) (в системах ARC), где N — количество элементов в таком подграфе. Такие подграфы обычно генерируются в результате выражения лямбда-функции, например, какого-либо конструктора, и могут не содержать объектов, на которые ссылаются внешние элементы.
Заморозка — это операция среды выполнения, делающая данный подграф объектов неизменяемым путём изменения заголовка объекта, так что любые попытки дальнейшей модификации вызовут исключение InvalidMutabilityException. Она является глубокой, поэтому если объект имеет указатель на другие объекты, то транзитивное замыкание таких объектов также будет заморожено. Заморозка — одностороннее преобразование; замороженные объекты нельзя разморозить. Замороженные объекты обладают отличным свойством, что из-за их неизменяемости они могут свободно использоваться несколькими рабочими потоками/потоками без нарушения инварианта "изменяемый XOR общий".
Если объект заморожен, его можно проверить с помощью свойства расширения isFrozen, и если это так, то совместное использование объекта разрешено. В настоящее время среда выполнения Kotlin/Native замораживает только объекты перечисления после их создания, хотя в будущем может быть реализована дополнительная автоматическая заморозка некоторых доказательно неизменяемых объектов.
Отсоединение подграфа объектов
Подграф объектов без внешних ссылок может быть отключён с помощью DetachedObjectGraph<T> до значения COpaquePointer, которое может храниться в данных void*, поэтому отключённые подграфы объектов могут храниться в структуре данных C, а затем прикрепляться обратно с помощью DetachedObjectGraph<T>.attach() в произвольном потоке или рабочем потоке. В сочетании с совместным использованием необработанной памяти это позволяет передавать объекты по побочным каналам между параллельными потоками, если механизмы рабочих потоков недостаточны для конкретной задачи. Обратите внимание, что отсоединение объекта может потребовать явного вызова функции, освобождающей ссылки на объект, и затем выполнения циклического сбора мусора. Например, код:
val graph = DetachedObjectGraph {
val map = mutableMapOf<String, String>()
for (entry in map.entries) {
// ...
}
map
}
не будет работать так, как ожидается, и вызовет исключение среды выполнения, поскольку в отсоединённом графе есть незавершенные циклы, в то время как:
val graph = DetachedObjectGraph {
{
val map = mutableMapOf<String, String>()
for (entry in map.entries) {
// ...
}
map
}().also {
kotlin.native.internal.GC.collect()
}
}
будет работать правильно, поскольку удерживаемые ссылки будут освобождены, а затем будет собран циклический мусор, влияющий на счётчик ссылок.
Совместное использование необработанной памяти
Учитывая тесную связь между Kotlin/Native и C через межъязыковую совместимость в сочетании с другими упомянутыми механизмами, можно создавать распространённые структуры данных, такие как конкурентные хэш-карты или общий кэш, с помощью Kotlin/Native. Можно использовать общие данные C и хранить ссылки на отсоединённые подграфы объектов в них. Рассмотрим следующий файл .def:
package = global
---
typedef struct {
int version;
void* kotlinObject;
} SharedData;
SharedData sharedData;
После выполнения инструмента cinterop он может совместно использовать данные Kotlin в структуре глобальных данных с версиями и взаимодействовать с ними из Kotlin прозрачным образом с помощью сгенерированного Kotlin-кода, например так:
class SharedData(rawPtr: NativePtr) : CStructVar(rawPtr) {
var version: Int
var kotlinObject: COpaquePointer?
}
В сочетании с объявленной выше глобальной переменной это позволит просматривать одну и ту же память из разных потоков и создавать традиционные конкурентные структуры с платформно-специфичными примитивами синхронизации.
Глобальные переменные и синглетоны
Глобальные переменные часто являются источником непредвиденных проблем с асинхронностью, поэтому Kotlin/Native реализует следующие механизмы, чтобы предотвратить непреднамеренное совместное использование состояния через глобальные объекты:
Глобальные переменные, если не помечены специально, могут быть доступны только из основного потока (то есть потока, в котором среда выполнения Kotlin/Native была первоначально инициализирована), если другой поток обращается к такой глобальной переменной, вызывается
IncorrectDereferenceExceptionДля глобальных переменных, помеченных аннотацией
@kotlin.native.ThreadLocal, каждый поток хранит локальную копию, поэтому изменения не видны между потокамиДля глобальных переменных, помеченных аннотацией
@kotlin.native.SharedImmutable, значение делится, но замораживается перед публикацией, поэтому каждый поток видит одно и то же значениеОбъекты синглетоны, если не помечены аннотацией
@kotlin.native.ThreadLocal, заморожены и общие, разрешены ленивые значения, если не были созданы циклические замороженные структурыПеречисления всегда заморожены
Эти механизмы в совокупности позволяют создавать естественные свободные от гонок программы с повторным использованием кода на разных платформах в многоплатформенных проектах.
Атомарные примитивы и ссылки
Стандартная библиотека Kotlin/Native предоставляет примитивы для безопасной работы с данными, которые могут изменяться в нескольких потоках одновременно, а именно AtomicInt, AtomicLong, AtomicNativePtr, AtomicReference и FreezableAtomicReference в пакете kotlin.native.concurrent. Атомарные примитивы позволяют выполнять операции обновления, безопасные в отношении гонок, такие как инкремент, декремент и сравнение и обмен, а также функции-установщики и -получатели значений. Атомарные примитивы всегда считаются замороженными средой выполнения, и хотя их поля можно обновлять с помощью обычных field.value += 1, это не безопасно для нескольких потоков. Значение должно изменяться с помощью специальных операций, чтобы было возможно выполнять безопасные в отношении гонок счётчики и аналогичные структуры данных.
Некоторые алгоритмы требуют общих изменяемых ссылок в нескольких рабочих потоках. Например, глобальная изменяемая конфигурация может быть реализована как неизменяемый экземпляр списка свойств, атомарно заменяемый новой версией при обновлении конфигурации целиком в одной транзакции. Таким образом, несогласованные конфигурации не могут быть увидены, и одновременно конфигурация может обновляться по мере необходимости. Для достижения такой функциональности среда выполнения Kotlin/Native предоставляет два связанных класса: kotlin.native.concurrent.AtomicReference и kotlin.native.concurrent.FreezableAtomicReference. Атомарная ссылка хранит ссылку на замороженный или неизменяемый объект, и её значение может быть обновлено операцией установки или сравнения и обмена. Таким образом, можно использовать специальный набор объектов для создания изменяемых общих графиков объектов (из неизменяемых объектов). Циклы в общей памяти могут быть созданы с помощью атомарных ссылок. Среда выполнения Kotlin/Native не поддерживает сборку мусора для циклических данных, когда цикл ссылок проходит через AtomicReference или замороженные FreezableAtomicReference. Поэтому, чтобы избежать утечек памяти, атомарные ссылки, которые потенциально являются частями общих циклических данных, должны обнуляться, когда они больше не нужны.
Если значение атомарной ссылки пытается установить не замороженное значение, выбрасывается исключение среды выполнения.
Замораживаемая атомарная ссылка похожа на обычную атомарную ссылку, пока не заморожена, работает как обычная переменная для ссылки. После заморозки она работает как атомарная ссылка и может хранить только ссылку на замороженный объект.
© 2010–2023 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/native-immutability.html