Неизменяемость и конкурентность в 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.
Передача и заморозка объектов
Важным инвариантом, который поддерживает среда выполнения 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–2022 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/native-immutability.html