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

Однако даже если вы используете встроенные примитивы параллелизма, которые предлагает Java (синхронизированные операторы, мьютексы, блокировки, исполнитель потока и т. Д.), Нет простого способа проверить, что ваша программа правильно и последовательно обрабатывает данные между потоками. не требуя обширного тестирования или анализа. Часто вы видите подобные проблемы только тогда, когда ваш продукт находится под нагрузкой, и, к сожалению, только периодически.

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

Вот шаблон кода, который делает именно это.

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

Вот простой пример объекта "Клиент":

Наш объект Customer - это очень простой объект с именем и списком предыдущих заказов. Мы собираемся использовать этот объект для ведения списка клиентов (простая база данных), чтобы продемонстрировать плохой код.

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

Проблема 1. Разработчик забывает, что оператор synchronized применяется только к методу synchronized

Если несколько потоков одновременно вызывают поток CustomerUpdate.run (), вы будете периодически получать исключение ConcurrentModificationException изнутри метода run (), когда customers.add (…). Это связано с тем, что оператор synchronized в CustomerDb.getCustomers () применяется только в области действия вызова getCustomers (), а не за ее пределами.

Проблема 2: разработчик забывает синхронизировать экземпляр, который необходимо синхронизировать

В этом сценарии весь код, который обращается к объекту customers List, должен быть синхронизирован. Однако в removeCustomer (…) мы забыли синхронизированный оператор, который может вызвать ConcurrentModificationException либо для customers.add (…), либо customers.remove () вызов.

Проблема 3. Разработчик синхронизирует в целом, но не по частям

Разработчик может правильно синхронизировать родительский объект и получить дочерний объект из этого родительского объекта, но не поддерживать контекст синхронизации при доступе к дочернему объекту. Выше мы извлекаем список заказов из объекта Customer, но затем получаем доступ к этим заказам вне синхронизации на родительском элементе. Это может вызвать ConcurrentModificationException s ,, но, что еще хуже, это может не быть: в зависимости от того, как используется дочерний объект, записи в этот объект могут быть перезаписаны другими потоками на основе данных -Порядок доступа и планирование потоков.

Решения этих проблем

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

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

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

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

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

Вот как это выглядит:

Здесь класс YourProtectedData содержит некоторые данные, которые вы хотите защитить от множественного одновременного доступа. В этом простом случае класс YourProtectedData содержит единственный объект Integer. Несмотря на то, что существует довольно много (к сожалению, необходимого) шаблона, его нужно написать только один раз, и фактическое использование API очень просто, как вы можете видеть ниже.

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

Почему это помогает предотвратить множественный доступ?

Во-первых, мы контролируем, как вы можете получить ссылку на этот объект: мы разрешаем доступ к объекту только через настраиваемый ISharedThreadRunnable (по образцу java.lang.Runnable), который обеспечивает защищенный доступ к данным, который пользователь должен расширить с помощью лямбда-выражения или анонимного внутреннего класса.

Во-вторых, при реализации методов содержащегося объекта (класса YourProtectedData) мы также вызываем assertHeldByThread (…) в качестве первого вызова метода в методе. Это гарантирует, что мы отловим случаи, когда пользователь (незаметно) переместил объект данных из исходного контекста потока (настраиваемый запускаемый объект).

Единственный способ получить доступ к классу YourProtectedData - через SharedThreadContext, как:

  • YourProtectedData создается внутри SharedThreadContext (а не снаружи, где пользователь мог бы использовать его неправильно)
  • YourProtectedData - частное значение (недоступное для других классов)
  • YourProtectedData используется только для классов, которые вызывают метод SharedThreadContext.own (…).
  • Проверки во время выполнения предотвращают доступ к объекту YourProtectedData или его методам экземпляра, если пользователь каким-то образом получает доступ к ссылке на него вне предполагаемого потока. Эти проверки времени выполнения можно отключить в производственной среде, чтобы исключить влияние на производительность, и повторно включить в сценариях отладки или поддержки клиентов.

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

Попытка 1) - Попытка получить доступ через геттер SharedThreadContext:

Попытка 2) - более сложная попытка подорвать безопасность потоков путем перемещения защищенных данных между потоками:

Чтобы гарантировать, что свойства, закрепленные в этом шаблоне, продолжают действовать:

  1. Создание ваших защищенных классов должно быть ограничено конструктором SharedThreadContext.
  2. Частная внутренняя ссылка на ваши защищенные классы не должна выходить из SharedThreadContext.
  3. YourProtectedData класс должен содержать ссылку на его родительский SharedThreadContext.
  4. Каждый метод YourProtectedData должен вызывать метод SharedThreadContext.assertHeldByThread () в качестве первого вызова метода.

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