Просто потому, что вы разделяете структуры данных между потоками, это не значит, что вы должны использовать блокировки.
За прошедшие годы я столкнулся с несколькими подходами, которые помогают координировать работу между потоками, не заставляя никого ждать. Вот несколько из них.
ПРЕДУПРЕЖДЕНИЕ: Я перестал использовать эти шаблоны, потому что их легко неправильно понять и неправильно использовать, что может привести к случайным труднодиагностируемым ошибкам. Если вы используете эти шаблоны, вы не помогаете следующему разработчику лучше понять код или избежать ошибок. Вы были предупреждены.
Ссылка на неизменяемый объект
Мы знаем, что в многопоточной среде изменение общего объекта без блокировок – плохая идея. Но как насчет его замены?
Например, рассмотрим этот код. Я буду использовать C#, но то же самое применимо к Java, C++ или другому традиционному языку с потоками.
public class FunWithThreads { private Thing thing; public void InThreadOne() { this.thing = new Thing() } public Result InThreadTwo() { var localThing = this.thing; var result1 = SomeCalculation1(localThing); var result2 = SomeCalculation2(localThing); var finalResult = DoMoreWork(localThing, result1, result2); return result; } }
Предположим, что множество потоков одновременно вызывают В первом потоке()
, в то время как множество других потоков одновременно вызывают Во втором потоке()
. Получим ли мы когда-нибудь неправильный результат, условия гонки или конфликт?
Не совсем. Во втором потоке()
всегда ссылается на согласованную версию this.thing
, потому что она помещает ссылку на нее в локальную переменную и с этого момента использует только локальную ссылку. Если вычисления займут слишком много времени, то, возможно, мы получим немного устаревший результат, но этот результат всегда будет последовательным.
Где это полезно : Я обнаружил, что этот подход полезен всякий раз, когда this.thing
представляет собой большую предварительно вычисленную структуру данных, которая обновляется нечасто, но часто читается. В основном я использовал его для кэширования некоторых данных для внутренних приложений на рынках капитала, чтобы сделать приложение более удобным для пользователя.
Подводные камни : Действительно важно убедиться, что this.thing
упоминается только один раз в начале метода, иначе мы вернемся к условиям гонки. Также очень важно, чтобы this.thing
никогда не изменялся после его назначения. Это означает множество описательных комментариев, очень тщательный анализ кода и уверенность в том, что каждый разработчик, который когда-либо просматривал этот код, знает все подводные камни. В более крупной кодовой базе кто-то (возможно, даже вы!) все равно может найти способ где-то ошибиться.
Сложные атомарные операции
Причина, по которой приведенный выше код работает, заключается в том, что в C # (и в большинстве других языков) назначение ссылок является атомарной операцией, т.Е. Во время ее выполнения другой поток не может вмешаться на полпути и сделать что-то еще.
Современные процессоры имеют несколько других интересных атомарных операций, таких как compare и swap , реализованных в C# как Interlocked. Сравните Exchange()
, а в Java как AtomicInteger.compareAndSet()
. Эта функция принимает три аргумента и выполняет следующее в качестве атомарной операции:
- Сравнивает первый и третий аргументы, затем
- Если они равны, устанавливает первый аргумент равным второму аргументу, или
- Если они не равны, ничего не делает.
Один из случаев, когда это может быть использовано, – это попытка ограничить запросы на дорогостоящую операцию с ограниченным ресурсом. Например, этот код отбросит любые запросы, если дорогостоящая операция уже запущена:
// Let's pretend this class is a singleton. public class ExpensiveOperationLimiter { private int isProcessing = 0; public void RequestExpensiveOperation() { // If isProcessing == 1, this does nothing and returns 1. // If isProcessing == 0, this sets isProcessing to 1 and returns 0. var wasProcessing = Interlocked.CompareExchange(ref isProcessing, 1, 0); if (!wasProcessing) { // Only one invocation will be running at a time. DoExpensiveOperation() } isProcessing = 0; } private void DoExpensiveOperation() { // ... } }
Если вы внимательно посмотрите на приведенный выше код, вы увидите, что вы не можете заменить Сцеплены. Сравните Exchange()
и по-прежнему сохраняйте потокобезопасность – всегда будет возможность для другого потока проникнуть между инструкциями.
Немного поработав, этот код также можно расширить, чтобы поставить в очередь следующий запрос.
Где это полезно : Я использовал этот тип кода в ситуациях, когда события могут случайным образом запускать обновление кэша сервера приложений из базы данных или из другой системы.
Подводные камни : Как правило, я бы счел это микрооптимизацией и не стоящей затраченных усилий. Многие разработчики понимают или слышали о блокировках, и им легче следовать им, чем любому коду, который включает сравнение и установку. Как и прежде, используя это, вы рискуете, что кто-то (включая вас!) в конечном итоге совершит ошибку.
Лучший способ избежать блокировок
Не пишите многопоточный код!
Бесстыдная пробка
Проверьте Измерьте , тайм-трекер, который каждые 10 минут спрашивает вас, что вы делаете.
Оригинал: “https://dev.to/iourikhramtsov/multi-threading-without-locks-fgb”