Конкурентность в Kotlin/Native
Выполнение Kotlin/Native не поощряет классическую модель конкурентности, основанную на потоках с взаимоисключающими блоками кода и условными переменными, так как эта модель известна своей подверженностью ошибкам и ненадежностью. Вместо этого мы предлагаем набор альтернативных подходов, позволяющих использовать аппаратную конкурентность и реализовывать блокирующую ввод-вывод. Эти подходы перечислены ниже, и они будут подробно рассмотрены в последующих разделах:
- Рабочие потоки с передачей сообщений
- Передача владения подграфом объектов
- Замораживание подграфа объектов
- Отсоединение подграфа объектов
- Необработанная общая память с использованием глобальных переменных C
- Атомные примитивы и ссылки
- Корутины для блокирующих операций (не рассматриваются в данном документе)
Рабочие потоки
Вместо потоков 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, замораживаются и разделяются, разрешены ленивые значения, за исключением попыток создания циклических замороженных структур - Перечисления всегда замораживаются
В совокупности эти механизмы позволяют естественной беспроблемной разработке с повторным использованием кода на различных платформах в проектах MPP.
Атомные примитивы и ссылки
Стандартная библиотека 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–2020 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/reference/native/concurrency.html