Spec-Zone.ru › Kotlin 1.7

Неизменяемость и конкурентность в Kotlin/Native

Эта страница описывает возможности устаревшего менеджера памяти. Чтобы узнать о новом менеджере памяти, который по умолчанию включён с Kotlin 1.7.20, обратитесь к управлению памятью в 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, является то, что объект принадлежит либо одному потоку/рабочему потоку, либо он является неизменяемым (общий 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. Поэтому, чтобы избежать утечек памяти, атомные ссылки, которые потенциально являются частью общих циклических данных, должны быть обнулены после того, как они больше не нужны.

Если значение атомной ссылки пытается установить не замороженное значение, то выбрасывается исключение времени выполнения.

Замораживаемая атомная ссылка аналогична обычной атомной ссылке до заморозки, ведет себя как обычная переменная для ссылки. После заморозки она ведет себя как атомная ссылка и может хранить только ссылку на замороженный объект.

Последнее изменение: 22 сентября 2022 г.
Мигрировать к новому менеджеру памяти Обзор асинхронности

© 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

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API