В Miro мы постоянно стараемся улучшить поддерживаемость нашего кода, применяя общепринятые практики, в том числе в вопросах многопоточности. Это не решает всех проблем, возникающих из-за постоянно возрастающей нагрузки, но упрощает поддержку: повышает как читабельность кода, так и скорость разработки новых функций.
На сегодняшний день (2020 год) у нас есть около 100 серверов в производственной среде, 6000 запросов HTTP API в секунду и более 50 000 команд WebSocket API, а также ежедневные релизы. Miro развивается с 2011 года. В текущей реализации запросы пользователей обрабатываются параллельно кластером различных серверов.
Подсистема параллельного контроля доступа
Основной актив нашего продукта — пользовательские доски для совместной работы, поэтому главная нагрузка ложится на них. Первичная подсистема, управляющая большей частью параллельного доступа, — это система с отслеживанием состояния пользовательских сеансов на доске.
При открытии новой доски на одном из серверов создается состояние. В этом состоянии хранятся как данные по времени выполнения, необходимые для одновременной совместной работы и отображения содержимого, так и системные данные, такие как сопоставление с потоками обработки. Информация о том, на каком сервере хранится состояние, записывается в распределенную структуру данных и доступна кластеру до тех пор, пока сервер запущен и хотя бы один пользователь находится на доске. Для этой части подсистемы мы используем Hazelcast. Все новые подключения к доске пересылаются на сервер, который хранит состояние.
При подключении к серверу пользователь входит в поток акцептора, единственная цель которого — привязка соединения к состоянию доски, в потоках которой будет происходить вся последующая работа.
Существует два потока, связанных с доской: сетевой поток, который обрабатывает соединения, и “бизнес”-поток, который отвечает за бизнес-логику. Это позволяет нам по-разному обрабатывать различные типы задач (сетевые пакеты и бизнес-команды). Обработанные сетевые команды от пользователей в виде прикладных бизнес-задач помещаются в очередь бизнес-потока, где они обрабатываются последовательно. Это позволяет избежать ненужной синхронизации при написании логики приложения.
Разделение кода на бизнес/прикладной и системный — это наша внутренняя конвенция. Так удаётся отделить код, отвечающий за пользовательские функции, от низкоуровневых деталей соединения, планирования и хранения, имеющих служебное назначение.
Если поток акцептора обнаружит, что состояние доски не существует, он создаст соответствующую задачу инициализации. Задачи инициализации состояний обрабатываются потоком другого типа.
Эта реализация имеет следующие преимущества:
- В потоках сервисного типа отсутствует бизнес-логика, которая потенциально может замедлить новое соединение или операции ввода-вывода. Эта логика изолирована в специальных типах потоков — “бизнес-потоках” — , что уменьшает влияние любой потенциальной задержки вследствие ошибок в часто изменяемом бизнес-коде.
- Инициализация состояния не выполняется в бизнес-потоке и не влияет на время обработки бизнес-команд от пользователей. Инициализация может занять некоторое время, и бизнес-потоки обрабатывают сразу несколько досок, поэтому создание новых досок напрямую не влияет на существующие.
- Синтаксический анализ сетевых команд обычно выполняется быстрее, чем они сами, поэтому конфигурация пула сетевых потоков может отличаться от конфигурации пула бизнес-потоков, чтобы уравновешивать использование системных ресурсов.
Расцвечивание потоков
Описанная выше подсистема достаточно нетривиальна по реализации. Разработчику приходится держать в голове полную модель взаимоотношений между потоками и учитывать обратный процесс закрытия досок. При закрытии доски вы должны удалить все подписки и записи из реестров, а кроме того сделать это в тех же потоках, где они были первоначально инициализированы, и в нужной последовательности.
Мы заметили, что ошибки и трудности при модификации кода, которые возникали в этой подсистеме, часто связаны с непониманием контекста обработки. Жонглирование потоками и задачами затрудняло ответ на вопрос, в каком именно потоке выполняется тот или иной фрагмент кода.
Для решения этой задачи мы использовали метод “расцвечивания” потоков — политику, направленную на регулирование использования потоков в системе. Потокам присваиваются цвета, а методы определяют приемлемость выполнения внутри потоков. Цвет здесь — просто абстракция. Это может быть другая сущность, например, перечисление. В 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, где для доступа к изменяемым объектам часто применяются несколько потоков, он может значительно упростить разработку не только в плане документации, но и на этапах компиляции и сборки.
Мы все еще пользуемся аспектной реализацией этого метода.
Читайте также:
- Жизненный цикл потока в Java
- Функции Java 15: скрытые и запечатанные классы, сопоставление шаблонов и текстовые блоки
- Учимся избегать null-значений в современном Java. Часть 1
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи: Aleksandr Kalikov, “Annotations for Concurrency in Java. Our approach to coloring threads”