Аннотации для параллелизма в Java: расцвечивание потоков

В Miro мы постоянно стараемся улучшить поддерживаемость нашего кода, применяя общепринятые практики, в том числе в вопросах многопоточности. Это не решает всех проблем, возникающих из-за постоянно возрастающей нагрузки, но упрощает поддержку: повышает как читабельность кода, так и скорость разработки новых функций.

На сегодняшний день (2020 год) у нас есть около 100 серверов в производственной среде, 6000 запросов HTTP API в секунду и более 50 000 команд WebSocket API, а также ежедневные релизы. Miro развивается с 2011 года. В текущей реализации запросы пользователей обрабатываются параллельно кластером различных серверов.

Подсистема параллельного контроля доступа

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

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

При подключении к серверу пользователь входит в поток акцептора, единственная цель которого  —  привязка соединения к состоянию доски, в потоках которой будет происходить вся последующая работа.

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

Разделение кода на бизнес/прикладной и системный  —  это наша внутренняя конвенция. Так удаётся отделить код, отвечающий за пользовательские функции, от низкоуровневых деталей соединения, планирования и хранения, имеющих служебное назначение.

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

Эта реализация имеет следующие преимущества:

  1. В потоках сервисного типа отсутствует бизнес-логика, которая потенциально может замедлить новое соединение или операции ввода-вывода. Эта логика изолирована в специальных типах потоков  —  “бизнес-потоках”  — , что уменьшает влияние любой потенциальной задержки вследствие ошибок в часто изменяемом бизнес-коде.
  2. Инициализация состояния не выполняется в бизнес-потоке и не влияет на время обработки бизнес-команд от пользователей. Инициализация может занять некоторое время, и бизнес-потоки обрабатывают сразу несколько досок, поэтому создание новых досок напрямую не влияет на существующие.
  3. Синтаксический анализ сетевых команд обычно выполняется быстрее, чем они сами, поэтому конфигурация пула сетевых потоков может отличаться от конфигурации пула бизнес-потоков, чтобы уравновешивать использование системных ресурсов.

Расцвечивание потоков

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

Мы заметили, что ошибки и трудности при модификации кода, которые возникали в этой подсистеме, часто связаны с непониманием контекста обработки. Жонглирование потоками и задачами затрудняло ответ на вопрос, в каком именно потоке выполняется тот или иной фрагмент кода.

Для решения этой задачи мы использовали метод “расцвечивания” потоков  —  политику, направленную на регулирование использования потоков в системе. Потокам присваиваются цвета, а методы определяют приемлемость выполнения внутри потоков. Цвет здесь  —  просто абстракция. Это может быть другая сущность, например, перечисление. В Java для целей цветовой разметки могут служить аннотации:

@Color
@IncompatibleColors
@AnyColor
@Grant
@Revoke

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

Мы можем также определить неприемлемые цвета:

Можем динамически добавлять и удалять привилегии потока:

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

Такой подход может быть знаком Android-разработчикам благодаря MainThread, UiThread, WorkerThread и аналогичным аннотациям.

Расцвечивание потоков основано на принципе самодокументирования кода, а сам метод хорошо поддается статическому анализу. С помощью статического анализа можно проверить, правильно ли написан код или нет, прежде чем выполнить его. Если мы исключим аннотации Grant и Revoke и предположим, что поток при инициализации имеет неизменяемый набор привилегий,  —  это будет анализ, нечувствительный к потоку  —  простая версия статического анализа, которая не учитывает порядок вызовов.

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

@Aspect
public class ThreadAnnotationAspect {
   @Pointcut("if()")
   public static boolean isActive() {
      ... // Здесь мы проверяем флаги, которые определяют, включены аспекты или нет. Это используется, например, в нескольких тестах.
   }

@Pointcut("execution(@ThreadAnnotation * *.*(..))")
   public static void annotatedMethod() {
   }

@Around("isActive() && annotatedMethod()")
   public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
      Thread thread = Thread.currentThread();
      Method method = ((MethodSignature) jp.getSignature()).getMethod();
      ThreadAnnotation annotation = getThreadAnnotation(method);
      if (!annotationMatches(annotation, thread)) {
         throw new ThreadAnnotationMismatchException(method, thread);
      }
      return jp.proceed();
   }
}

Для аспектов мы применяем расширение AspectJ и плагин для Maven, который выполняет связывание во время компиляции. Изначально мы устанавливаем связывание, которое будет выполняться, когда ClassLoader загружает классы. Однако мы столкнулись с тем, что связыватель иногда вел себя неправильно при одновременной загрузке одного и того же класса, в результате чего исходный байт-код класса оставался без изменений. Это привело к очень непредсказуемому и трудно воспроизводимому поведению в продакшне. Вполне возможно, что текущие версии AspectJ этой проблемы лишены.

Использование аспектов позволило нам быстро найти большинство проблем в коде.

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

GuardedBy

Один из возможных типов расцвечивания  —  аннотация GuardedBy из Java.util.concurrent. Она определяет доступ к полям и методам, указывая, какие блокировки требуются для корректного доступа:

public class PrivateLock {
   private final Object lock = Object();   

   @GuardedBy (“lock”)
   Widget widget;   

    void method() {
      synchronized (lock) {
         //Получение доступа или изменение состояния виджета
      }
   }
}

Современные IDE поддерживают анализ для этой аннотации. Например, IntelliJ IDEA, если с кодом что-то не так, показывает такое сообщение:

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

Мы все еще пользуемся аспектной реализацией этого метода.

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи: Aleksandr Kalikov, “Annotations for Concurrency in Java. Our approach to coloring threads”