Kotlin позволяет легко писать потокобезопасный код (особенно если мы сравним его с Java), но разработчик все равно должен следовать некоторым основным правилам, если он / она хочет, чтобы его / ее код должен быть действительно потокобезопасным. В этой истории будут представлены основные правила, которым нужно следовать, и инструменты, предоставляемые Kotlin для их выполнения, но сначала давайте обсудим, что такое потокобезопасный код.

Поточно-безопасный код

Когда код предназначен для запуска с использованием нескольких потоков, может возникнуть широкий спектр проблем, в основном перегруппированных по следующим категориям:

  • Одновременное изменение (или состояние гонки): два потока одновременно пытаются изменить объект, если объект является коллекцией, это обычно приводит к сбою приложения; эта проблема обычно решается с помощью синхронизированных разделов.
  • Deadlock (или livelock): при попытке использовать синхронизированные разделы мы можем создать ситуацию, когда два потока ждут друг друга; это приводит к зависанию приложения.

Золотое правило предотвращения этих проблем - использовать только неизменяемые объекты. Неизменяемый объект - это объект, состояние которого не может быть изменено после его создания. Таким образом, отсутствует риск одновременной модификации, а поскольку нет необходимости в синхронизации, мы также предотвращаем риск возникновения взаимоблокировок. Вот почему в этой статье мы сосредоточимся на методах обеспечения безопасности потоков без необходимости синхронизации.

Но, как кажется легко сказать, это обычно сложнее реализовать, особенно если исходить из фона Java, где объекты традиционно сохраняют состояние (POJO, bean-компоненты и т. Д.). К счастью, Kotlin предоставляет много синтаксического сахара, который поможет нам создавать неизменяемые объекты и, в более общем плане, потокобезопасный код, не теряя при этом слишком большой производительности.

Используйте val как можно больше

При объявлении переменной или члена класса в Kotlin у нас есть выбор между двумя ключевыми словами: val и var.

var в основном означает, что значение ссылки или переменной может быть изменено, а val означает, что оно будет постоянным (инициализируется только один раз).

Если мы хотим создать неизменяемый объект, то все его члены должны быть объявлены с ключевым словом val. Ключевое слово var следует использовать только для локальных переменных (т. Е. Переменных, которые являются внутренними для функции или метода).

Используйте неизменяемые коллекции Kotlin

Использование ключевого слова val только гарантирует, что переменная всегда будет ссылаться на один и тот же объект. Если я напишу:

val list = ArrayList ‹String› ()

Тогда «список» всегда будет ссылаться на один и тот же список, но это не означает, что список будет постоянным: мы все еще можем добавлять или удалять элементы из списка. Это означает, что если этот список является членом класса, есть вероятность, что код не будет потокобезопасным: если два потока попытаются вызвать метод, который изменяет содержимое списка , будет исключение одновременной модификации.

Чтобы этого избежать, лучше использовать неизменяемые коллекции Kotlin. Вместо того, чтобы писать:

val list = arrayListOf ‹String› ()

Предпочитаю писать:

val list = listOf ‹String› ()

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

В этом случае список может быть инициализирован следующим образом:

В первом примере мы даем все значения списка. Для второго значения инициализируются с помощью лямбда-выражения, параметром которого является индекс элемента в списке (первый параметр конструктора списка - это желаемый размер списка). Примечание. В Kotlin List - это тип неизменяемого списка, а ArrayList - изменяемый список.

Для инициализации неизменяемого списка также можно объявить изменяемый список как локальную переменную, а затем сохранить ссылку на этот список как неизменяемый список:

В этом случае мы должны убедиться, что мы не сохраняем никаких ссылок на изменяемый список, поскольку это должен быть только инструмент, помогающий нам написать инициализацию неизменяемого списка, а не то, с чем мы будем работать после.

Используйте классы данных

Классы данных - это особый вид классов, основная цель которых - хранить данные. Эти классы данных могут иметь члены и методы, как и любой другой класс, но они также предоставляют действительно полезный метод при написании поточно-безопасного кода: метод copy ().

Допустим, мы объявляем класс данных для хранения некоторых данных. Мы хотим, чтобы объекты из этого класса были неизменными, поскольку мы пишем поточно-ориентированный код, поэтому все атрибуты должны быть объявлены с ключевым словом val. Теперь мы манипулируем объектом из этого класса и хотим «изменить» значение одного из его членов: у нас нет другого выбора для создания нового объекта того же класса с новым значением для этого члена.

Со стандартными классами писать довольно утомительно. Но классы данных предоставляют синтаксический сахар, чтобы упростить задачу. Вот как это работает:

Конечно, также можно инкапсулировать это поведение, чтобы оно выглядело так, будто мы действительно модифицируем объект, в то время как на самом деле мы создаем новый объект:

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

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

Используйте синхронизированные блоки

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

В этом случае нам придется вернуться к более классическому подходу с использованием синхронизированных блоков.

В отличие от Java, Kotlin не предоставляет ключевое слово synchronized, позволяющее нам писать взаимоисключающие разделы. Но это не очень важно, так как Kotlin предоставляет вместо этого функцию synchronized, которая выполняет ту же задачу:

Но, как я сказал выше, по возможности следует избегать использования синхронизированных блоков, так как это может привести к тупиковой ситуации. Это решение выгодно только тогда, когда использование неизменяемых объектов резко влияет на производительность.

Используйте уже определенные потокобезопасные структуры

Если вы не можете применить предыдущий совет, вы все равно можете что-то предпринять. JVM определяет несколько классов и коллекций, которые в основном ориентированы на многопотоковое исполнение.

Есть атомарные примитивы, например AtomicInteger. Даже если несколько потоков попытаются одновременно изменить значение AtomicInteger, у нас никогда не будет неопределенного состояния с этим типом объекта, вызванного состоянием гонки.

Также существуют поточно-ориентированные коллекции, такие как Collections.synchronizedList.

Лично я бы не рекомендовал использовать эти решения по двум причинам:

  • Они не являются мультиплатформенными: они полагаются на JVM, поэтому их нельзя использовать с Kotlin / Native (и, конечно, Kotlin / JS).
  • Синхронизированные коллекции не являются полностью потокобезопасными, по-прежнему можно вызвать исключение ConcurrentModificationException, поэтому разработчику обычно приходится добавлять дополнительный уровень синхронизации.

В заключение мы рассмотрели основные инструменты, предоставляемые Kotlin для обеспечения безопасности потоков:

  • Ключевое слово val помогает нам определять неизменяемые объекты.
  • Стандартная библиотека, предоставляющая неизменяемые коллекции.
  • Некоторый синтаксический сахар, предоставляемый классами данных, помогающий нам управлять неизменяемыми объектами.

И если у нас нет выбора, мы все равно можем положиться на:

  • механизмы синхронизации языка, которые можно здесь быстро описать
  • синхронизированные классы стандартной библиотеки Java (если мы планируем запускать на JVM)

Конечно, тема многопоточности действительно широка, и Kotlin предоставляет для этого множество удобных функций, которые мы там не изучали. Но если вы хотите обогатить свою культуру, вы можете взглянуть на расширенные механизмы синхронизации, предоставляемые Kotlin, и на использование сопрограмм.

Тем не менее, эти решения не являются волшебными и не заставят вас писать потокобезопасный код, если вы не будете осторожны, поэтому определение потоковобезопасных структур данных (в основном с использованием неизменяемых объектов) всегда должно служить основой при написании многопоточных. многопоточный код.