Неизменяемость и одновременность в 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 runtime первым). Доступ из другого потока приведёт к броску исключения 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, является то, что объект либо принадлежит одному потоку/рабочему потоку, либо является неизменяемым (разделяемый ИЛИ изменяемый). Это гарантирует, что у одних и тех же данных есть только один мутатор, поэтому нет необходимости в блокировках. Для достижения такого инварианта используется концепция подграфов объектов, на которые нет внешних ссылок. Это подграф без внешних ссылок извне подграфа, который можно проверить алгоритмически с сложностью O(N) (в системах ARC), где N - количество элементов в таком подграфе. Такие подграфы обычно генерируются в результате выражения лямбда-функции, например, некоторого билдера, и могут не содержать объектов, на которые ссылаются внешние элементы.
Замораживание — это операция среды выполнения, делающая заданный подграф объектов неизменяемым путем изменения заголовка объекта таким образом, что попытки дальнейшей модификации генерируют исключение InvalidMutabilityException. Оно глубокое, поэтому если объект содержит указатель на другие объекты, транзитивное замыкание таких объектов также будет заморожено. Замораживание — это однонаправленное преобразование; замороженные объекты нельзя разморозить. Замороженные объекты обладают замечательным свойством: благодаря своей неизменяемости их можно свободно разделять между несколькими рабочими потоками/потоками без нарушения инварианта «изменяемый ИЛИ разделяемый».
Если объект заморожен, его можно проверить с помощью расширенного свойства 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, замораживаются и разделяются, разрешены ленивые значения, если не создавались циклические замороженные структурыперечисления всегда замораживаются
Эти механизмы вместе позволяют естественной разработке программ без гонок и повторному использованию кода через платформы в проектах Multiplatform.
Атомарные примитивы и ссылки
Стандартная библиотека 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