Рубрики
Без рубрики

Подводные камни одноэлементного боба 😲

Весенний боб. Звучит относительно безобидно, не так ли? Что, если я скажу тебе, что это может быть кислым?.. Помечено java, spring, параллелизм, микросервисы.

Весенний боб. Звучит относительно безобидно, не так ли? Что, если я скажу вам, что это может быть источником многих головных болей на веб-серверах, особенно если вы новичок в разработке Spring. Однако, если вы более опытный разработчик Java, то вы бы знали о беспокойных ночах, отлаживающих ваш код в течение нескольких часов только для того, чтобы обнаружить, что страшная ошибка была условием гонки .

Что такое Состояние гонки?

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

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

Как и любая хорошая команда, вы все согласились использовать некоторую систему контроля версий, такую как Git, вместо того, чтобы передавать zip-файлы, как кучка язычников, для управления вашей кодовой базой и предотвращения любых конфликтов. Три разработчика, Алиса, Боб и Ева, работают над одним и тем же файлом, модифицируя разные функции. Алиса обнаруживает, что для реализации ее новой функции необходимо изменить некоторый существующий метод с именем foo() . Ева также сталкивается с тем же требованием и делает то же самое. Боб, с другой стороны, занят побочным заданием, чтобы исправить проблемы со сборкой.

Когда-либо заканчивает изменение файла и отправляет запрос на извлечение с помощью модифицированного метода foo() . Боб, разочарованный отсутствием прогресса в устранении его проблемы, просматривает код и показывает ему большой палец вверх. Код будет отправлен. Алиса потеряла счет времени и спешит отправить функцию до полуночи. Без какого-либо запроса на вытягивание она сразу переходит к производству. К счастью, учитывая, насколько умен Git, Git обнаружит конфликт при нажатии и откажется добавлять свои изменения. Алиса, однако, видит это и принимает правильное решение позвонить Бобу в полночь, чтобы просмотреть свой код, прежде чем нажимать.

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

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

Весенние бобы и Ты

Взято прямо с их веб-сайта,

Spring Framework предоставляет комплексную модель программирования и конфигурации для современных корпоративных приложений на базе Java – на любой платформе развертывания.

В настоящее время популярным вариантом Spring Framework является Spring Boot, платформа, которая позволяет разработчикам создавать серверные веб-приложения производственного уровня. Одним из основных принципов Spring является использование внедрения зависимостей в его основе. Классы обычно создаются как компоненты , которые служат основой вашего приложения Spring. Следуя основному принципу внедрения зависимостей, компоненты по умолчанию создаются с областью singleton , что означает, что для жизненного цикла приложения создается только одна копия класса. Конечно, существует много других типов областей, но сегодня мы сосредоточимся на одноэлементной области.

Изображение жизненного цикла весенних бобов, взятое из документация .

Так почему же так важно, если это синглтон?

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

Вопрос в том, как одноэлементный компонент будет обрабатывать запросы от нескольких пользователей? Для достижения этой цели, как и на большинстве веб-серверов, создается другой обработчик, в данном случае поток, для обслуживания запроса, сделанного одним пользователем. Другими словами, если два пользователя обращаются к вашему API, один из них регистрируется в / конечная точка, а другая – для входа в систему / конечная точка, Spring создаст два отдельных потока для обработки этих вызовов. Если у нас есть одноэлементный компонент, действующий в качестве нашего контроллера, то оба запроса будут обработаны этим компонентом. Это становится проблемой, когда у нас есть несколько одновременных вызовов одной и той же конечной точки, которая обновляет одну и ту же переменную не потокобезопасным способом.

Благодаря возможности одновременных вызовов с изменяемым состоянием компоненты Spring не являются потокобезопасными по своей сути.

Пример

Как упоминалось ранее, давайте в качестве примера используем очень простой компонент с отслеживанием состояния (в основном Javabean, который используется в качестве переменных экземпляра). Я пошел дальше и создал пример проекта , чтобы продемонстрировать пару концепций, которые я буду обсуждать в следующих разделах. Сам проект представляет собой простой проект Spring Boot с несколькими конечными точками, которые манипулируют данными внутри нашего DataFacade.java класс.

Для нашего первого API я сосредоточусь на API /increment , который возвращает строго возрастающее число каждому пользователю. Мы гарантируем что никакие два человека, звонящие на конечную точку, не будут иметь один и тот же номер:

// DataController.java snippet
@RestController
public class DataController {
    @RequestMapping("/increment")
    private int incrementEndpoint() {
        return dataFacade.increment();
    }
}

// DataFacade.java snippet
@Component
@Scope("singleton")
public class DataFacade {
    private int counter;
    public int increment() {
        return ++this.counter;
    }
}

Короче говоря, наш Контроллер данных компонент хранит счетчик в качестве переменной экземпляра и модифицируется методом increment() . Сам по себе класс, похоже, мало что делает, и с ним мало что может пойти не так. Вы запускаете приложение Spring, нажимаете на конечную точку localhost:8080/инкремент и видите 1 возвращается. Вы звоните по нему снова и получаете 2 и так далее.

Что здесь такого особенного?

Допустим, мы запускаем этот удивительный API, который гарантирует, что два человека не получат один и тот же сгенерированный номер, будет показан в Поиске продуктов, а затем получит 12 000 ежедневных активных пользователей, совершающих в среднем 500 звонков в день каждый, что означает 6 000 000 общих звонков в день на эту конечную точку. Если мы посчитаем, то API будет в среднем обслуживать (6 000 000 звонков в день/86 400 секунд за 44… транзакции в секунду. Пользователи в конце концов узнают, что некоторые получили один и тот же номер, и злятся. Что, черт возьми, произошло?

То, что произошло, было классическим состоянием гонки. При нагрузке ~ 69 TPS высока вероятность того, что несколько пользователей могут звонить /увеличение в то же время с возможностью считывания устаревших данных и увеличения с неправильным номером. Если счетчик был на 5, а Алиса и Боб звонят /увеличение в то же время, тогда есть хороший шанс, что оба получат 6, когда они должны быть разными.

Значение счетчика также зависит от последнего вызова, который обновил его значение. На приведенной выше диаграмме, если бы у нас было 3 потока, увеличивающих значение в счетчике примерно в одно и то же время, то есть вероятность, что все эти потоки прочитают счетчик как 5, и все в конечном итоге установят значение 6, что неверно. Чего мы должны ожидать, так это принятия некоторого решения (обычно основанного на хронологическом порядке) для потоков A и B для его выполнения. Потоку C придется подождать, пока оба потока A и B не завершат обновление переменной, прежде чем он сможет выполнить. Ожидаемое значение для счетчика должно быть равно 8.

Поскольку у нас есть только одна копия нашего DataFacade , каждый отдельный поток, обращающийся к переменной счетчик , одновременно изменяет ее значение. Напомним, что Spring создает отдельный поток для каждого пользователя, обращающегося к конечной точке. Пружина обеспечивает 0 гарантий для обеспечения безопасности резьбы.

Давайте проверим это с помощью нашего примерного проекта .

Сначала клонируйте проект в какой-нибудь каталог на вашем компьютере. Откройте каталог в своем терминале и запустите git checkout условия гонки просмотр кода, подверженного ошибкам.

Затем откройте проект в среде IDE по вашему выбору (предпочтительнее IntelliJ) и запустите проект. После запуска вы должны иметь доступ к localhost:8080/инкремент .

Затем откройте свой терминал и перейдите к нашему тестовому сценарию под названием spammer.py и выполните следующую команду.

python spammer.py

Сценарий создаст 4 потока и выполнит по 50 вызовов каждый в конечную точку /инкремента . Вы можете изменить их, если хотите.

Как только скрипт завершится, ожидаемое значение для счетчика равно 200, если 4 потока каждый совершают по 50 вызовов без каких-либо повторяющихся чисел. Однако обратите внимание, что счетчик не дотянул до 200 (в нашем случае 176). Одновременная модификация счетчика подтверждается появлением повторяющихся чисел в нашем журнале (например, ниже, 174).

...
Thread 0 - 162
Thread 1 - 163
Thread 3 - 164
Thread 1 - 165
Thread 0 - 166
Thread 3 - 168
Thread 2 - 167
Thread 1 - 169
Thread 3 - 171
Thread 0 - 170
Thread 2 - 172
Thread 0 - 173
Thread 1 - 174
Thread 2 - 174
Thread 3 - 175
Thread 1 - 177
Thread 2 - 176

В следующем разделе обсуждаются решения этой проблемы и изменения, которые мы можем внести в существующий код в проекте.

Обеспечение потокобезопасности компонента

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

Так как же мы можем это исправить?

Определение области действия запроса

Область запроса – это веб-интерфейс ApplicationContext в Spring, который создает новый экземпляр компонента для каждого отдельного HTTP-запроса. Эта область может быть чрезвычайно полезна для поддержания какого-либо состояния для этого запроса, если этот компонент будет передан многим различным обработчикам по цепочке. Например, если ваше состояние хранится в каком-либо компоненте, вы можете добавить аннотацию @Requestscoped таким образом, чтобы компонент создавался при каждом HTTP-запросе.

@Bean
@RequestScope
public DataFacade getDataFacade() {
    // ...
}

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

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

Потокобезопасные переменные

Допустим, у вас нет другого выбора, кроме как поддерживать состояние вашего компонента. Хотя Spring не будет решать проблемы синхронизации и одновременного изменения, вы можете справиться с этим самостоятельно. Есть несколько вариантов того, как это сделать на Java:

  • Синхронизированные блоки – ключевое слово синхронизированные – это удобный способ ограничить доступ к общим ресурсам только одним потоком одновременно. Вы можете применить это ключевое слово либо к методу , либо к объекту. В любом случае, JVM знает, что как только поток получает блокировку для выполнения кода в синхронизированном блоке, все остальные потоки должны быть приостановлены. Когда блокировка снимается, следующий поток в очереди может получить доступ. Обратите внимание, что переменные, к которым обращаются несколько потоков, должны быть объявлены с помощью volatile ключевое слово для обеспечения того, чтобы изменения в одном потоке немедленно отражались в других потоках.

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

  private volatile int counter;
  // ...
  public synchronized int increment() {
      return ++this.counter;
  }

С ключевыми словами synchronize и volatile мы должны увидеть этот результат. Хотя выходные данные могут быть не в порядке (что все равно нормально), мы достигаем 200 с помощью нашего счетчика без каких-либо повторяющихся значений.

  ...
  Thread 1 - 185
  Thread 2 - 184
  Thread 0 - 186
  Thread 3 - 187
  Thread 1 - 188
  Thread 2 - 189
  Thread 3 - 191
  Thread 0 - 190
  Thread 2 - 192
  Thread 1 - 193
  Thread 3 - 194
  Thread 0 - 195
  Thread 1 - 197
  Thread 2 - 196
  Thread 3 - 199
  Thread 0 - 198
  Thread 1 - 200

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

  • Синхронизированные коллекции – использование ключевого слова синхронизировано в блоке инкапсуляции недостаточно для предотвращения условий гонки для коллекций Java. Чтобы обеспечить синхронный доступ к вашей коллекции, платформа Java collections поставляется с различными методами-оболочками, которые преобразуют вашу коллекцию в ее потокобезопасную версию. Вы можете прочитать отличный краткий обзор по ссылке Baeldung для всех различных классов-оболочек, которые вы можете использовать.
  public Set toSynchronizedSet(final Set numbers) {
      return Collections.synchronizedSet(numbers);
  }

В нашем примере проекта другой конечной точкой, требующей исправления, является //добавить строку конечная точка. Я оставлю это в качестве упражнения для вас, чтобы исправить … или просто скопируйте код из master .

  • Атомарные переменные – согласно документации Oracle , пакет java.util.concurrent.atomic представляет собой небольшой набор классов, поддерживающих потокобезопасное программирование без блокировки для отдельных переменных . Атомарные переменные имеют множество полезных методов, которые выполняют сравнения, увеличение/уменьшение, накопление и т.д. без необходимости указывать синхронизированный и изменчивые блоки и переменные соответственно.

В нашей конечной точке /инкремента счетчик может быть заменен атомарным целым .

  private AtomicInteger counter;

  public DataFacade() {
      this.counter = new AtomicInteger(0);
  }

  public int increment() {
      return this.counter.incrementAndGet();
  }

Полностью без гражданства

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

В качестве примечания, окончательные переменные также могут быть включены при сохранении безгражданства, но с подвохом. Окончательная Строка , int или логическое значение может работать нормально, но окончательный Список , Установить , или Коллекция не будет. Элементы этих классов коллекций все еще могут быть изменены во время выполнения кода, который добавляет состояние. JDK9 представил неизменяемые версии этих коллекций, но их следует использовать с некоторой осторожностью. Объекты не являются автоматически неизменяемыми при хранении в этих коллекциях, только сама коллекция является неизменяемой.

Вывод

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

Спасибо за чтение!

💎 Спасибо, что нашли время ознакомиться с этим постом. Для получения дополнительной информации, подобной этой, перейдите на мой фактический блог . Не стесняйтесь обращаться ко мне по сеть LinkedIn и следуйте за мной дальше Github .

Оригинал: “https://dev.to/spiderpig86/pitfalls-of-the-singleton-bean-4mlf”