В Java, как я могу обеспечить безопасное и последовательное одновременное использование логического флага, минимизируя влияние времени на производительность? - PullRequest
17 голосов
/ 17 июня 2020

В моем сценарии у меня есть DirtyArray объекты, которые в основном представляют собой примитивные обертки массивов, которые устанавливают логический флаг «грязный», когда происходит доступ для записи.

public class DirtyArray {
    private byte[] data;

    public DirtyArray(byte[] data) {
        this.data = data;
    }

    private boolean dirty = false;

    public void setValue(int index, byte value) {
        dirty = true;
        data[index] = value;
    }

    public boolean isDirty() {
        return dirty;
    }
}

Флаг грязных данных всегда идет только от * От 1005 * до true.

Мне нужно сделать это безопасным для одновременного использования: есть один или несколько потоков, которые могут изменять массив (setValue). Есть один или несколько потоков, которые перехватывают DirtyArray перед сборкой мусора и должны записывать его на диск, если он был изменен (isDirty).

Теперь, если я правильно понимаю, это небезопасно , чтобы сделать это, как указано выше: Фактически, с точки зрения потока isDirty, хранилище data[index]=value может быть переупорядочено до хранилища dirty=true. Следовательно, вид isDirty()==false не гарантирует, что data не был изменен.

Это правильно?

Если да, то установка флага dirty volatile должна исправить Эта проблема. Однако в следующем тесте я вижу замедление в ~ 50-100 раз при этом.

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public void touchAll()
{
    for (int i = 0; i < numEntities; i++)
        bytes.setValue(i, ( byte ) 2);
}

Используя вместо этого AtomicBoolean с вариантами получения / установки порядка памяти, представленными в Java 9, у меня есть этот вариант :

public class DirtyArray {
    private byte[] data;

    public DirtyArray(byte[] data) {
        this.data = data;
    }

    private AtomicBoolean dirty = new AtomicBoolean();

    public void setValue(int index, byte value) {
        if (!dirty.getPlain())
            dirty.setRelease(true);
        data[index] = value;
    }

    public boolean isDirty() {
        return dirty.getAcquire();
    }
}

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

Это безопасно? То есть гарантирует, что при изменении data я увижу isDirty()==true? (Насколько я понимаю, это должно быть, но только потому, что dirty только всегда изменяется с false до true, и никогда не возвращается.)

Существуют ли другие варианты для достижения этой гарантии, возможно, даже позволяющие сбросить dirty - false, и в идеале без отрицательного воздействия на производительность?

Обновление

Я согласен с общей оценкой ответов до сих пор, что единственный способ гарантировать согласованность между измененным массивом data и флагом dirty - синхронизировать как setValue, так и isDirty . Условия гонки, на которые указал Пак Уула , являются настоящей проблемой, а не тем, чтобы сделать видимым флаг грязи. Так что в основном вопрос, который я задал выше, является неправильным вопросом ...

Для большего контекста: речь идет о хранении пикселей для прозрачно кэшированных изображений в https://github.com/imglib. Он используется в очень узких циклах, и учесть удар от синхронизации на самом деле не вариант. Типичный сценарий использования:

  • Несколько потоков изменяют изображение (которое поддерживается многими DirtyArrays).
  • Проверка isDirty() происходит в другом потоке, который перехватывает DirtyArray перед сборкой мусора (PhantomReference на держателе DirtyArray), а если он грязный, записать его на диск.

Сейчас я считаю, что к этому следует подходить более грубый уровень, чем отдельные setValue() звонки. Есть своего рода «естественные» точки синхронизации, которые возникают из-за того, что потоки переключаются между DirtyArray s, получая их из ConcurrentHashMap (конечно, не обращая внимания на детали), потоки находятся в пуле потоков и берут задания из общей очереди, или потоки как-то иначе ждут друг друга. В этих точках синхронизации должны стать видимыми эффекты более ранних (в программном порядке) setValue() s. Поэтому я предпочитаю использовать простую несинхронизированную версию и полагаться на синхронизацию на более грубом уровне.

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

Ответы [ 9 ]

8 голосов
/ 17 июня 2020

AtomicBoolean (или любой другой из семейства atomi c) не гарантирует синхронизацию с другой переменной. так что нет. код не гарантирует, что при изменении данных вы получите isDirty () == true. Единственная гарантия, что у вас есть, - это то, что все потоки всегда видят одно и то же значение isDirty (). фактически, ни один из перечисленных вариантов не дает этой гарантии.

единственный способ сделать гарантию - это установить монопольную блокировку на весь блок кода внутри метода set: оператор if вместе с присвоением. это может быть достигнуто с помощью ключевого слова synchronized (либо в методе, либо в блоке кода) или с помощью одного из механизмов блокировки в java.util.concurrency

3 голосов
/ 23 июня 2020

Несколько замечаний о вашем решении с AtomicBoolean.

Какого типа потокобезопасность вы ищете? Я не понимаю, почему вы так сильно заботитесь о порядке чтения-записи флага dirty. Операции получения / установки для этого флага могут быть идеально упорядочены по времени и по-прежнему приводят к гонкам.

Состояние гонки

В обоих ваших примерах я вижу состояние гонки между setValue и isDirty :

  1. Вызовы потока 1 setValue, обновляет dirty флаг и вытесняет поток 2 до установка data[index] = value.
  2. Вызов потока 2 isDirty, он возвращает true, но массив data еще не обновлен , потому что поток 1 был вытеснен.
  3. Если поток 2 продолжает действия с грязным массивом, например копировать его в другое место, копия не будет согласована с массивом в памяти после того, как поток 1 возобновит выполнение с data[index] = value.

Изменение порядка операций решает эту проблему, но приводит к другой.

    public void setValue(int index, byte value) {
        data[index] = value;
        dirty = true;
    }

Рассмотрим следующую последовательность:

  1. Поток 1 устанавливает значение с индексом 0. Флаг dirty становится true.
  2. Поток 1 начал значение настройки с индексом 1. data[1] = value было выполнено, и поток 1 вытесняется просто fore dirty = true.
  3. Поток 2 проверяет флаг и синхронизирует массив с диском.
  4. Поток 1 возобновляет выполнение и устанавливает флаг загрязнения.

гонка заключается в том, что массив согласован с внешней копией, но помечен как грязный. Эта гонка может привести к чрезмерному количеству операций копирования.

Операция AtomicBoolean

Вы используете AtomicBoolean не на atomic c. Поток может быть вытеснен только между условным оператором и оператором then. Используемый вами эквивалент конструкции if для atomi c: dirty.compareAndSet(false, true);.

На самом деле, вам это не нужно. dirty.set(true) вполне достаточно, так как вы безоговорочно устанавливаете грязный флаг при обновлении любого значения.

Но печальная история состоит в том, что даже AtomicBoolean.set не спасает от состояния гонки. Поток может быть вытеснен только между установкой грязного флага и обновлением данных. Описанное выше состояние гонки не зависит от атомарности обновления флага.

Синхронизировано

С самого начала Java предоставляет шаблон именно для этого случая: synchronized.

public class SyncronizedDirtyArray {
    private byte[] data;

    public SyncronizedDirtyArray (byte[] data) {
        this.data = data;
    }

    private boolean dirty = false;

    public synchronized void setValue(int index, byte value) {
        dirty = true;
        data[index] = value;
    }

    public synchronized boolean isDirty() {
        return dirty;
    }
}

synchronized гарантирует, что ни один поток не может вмешиваться между dirty = true; и data[index] = value;.

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

ОБНОВЛЕНИЕ: Тест

Я сделал свой собственный тест, очень простой, но показательный.

Я заблокировал несколько вариантов DirtyArray с разными синхронизаторами c примитивов.

  • DirtyArrayNoSync: простая реализация без синхросигнала c
  • DirtyArraySync: с использованием synchronized
  • DirtyArrayLock: используя ReentrantLock
  • DirtyArrayVolatile: используя volatile boolean
  • DirtyArrayAtomic: реализация из стартера topi c
  • DirtyArrayAtomicSet: используя AtomicBoolean.set и AtomicBoolean.get

Тест должен был вызвать setValue для каждого элемента в массиве со 100 миллионами элементов. Вывод - продолжительность в миллисекундах.

Конфигурация: OpenJDK 11.0.6, Core i7, Windows 10 Home

org.example.sync.DirtyArrayNoSync: 112
org.example.sync.DirtyArraySync: 2222
org.example.sync.DirtyArrayLock: 16752
org.example.sync.DirtyArrayVolatile: 7555
org.example.sync.DirtyArrayAtomic: 7591
org.example.sync.DirtyArrayAtomicSet: 3066

Да, синхронизация делает его в 20 раз медленнее, но это второе по скорости решение. Все остальные, даже с AtomicBoolean, медленнее.

ОБНОВЛЕНИЕ 2.

Бенчмарк для jdk-14.0.1 показал практически такие же результаты:

org.example.sync.DirtyArrayNoSync: 102
org.example.sync.DirtyArraySync: 2323
org.example.sync.DirtyArrayLock: 16801
org.example.sync.DirtyArrayVolatile: 7942
org.example.sync.DirtyArrayAtomic: 7984
org.example.sync.DirtyArrayAtomicSet: 3320
2 голосов
/ 24 июня 2020

Я думаю, что ответ Шэрон убедил его. Все попытки решить эту проблему с использованием классов volatile или Atomic* приведут к условиям гонки, когда либо isDirty() возвращает true до того, как будут видны обновления массива, ИЛИ обновления массива могут быть видны, когда isDirty() вернет false.

Решение - использовать блокировку. (В этом случае примитивная блокировка должна быть самой быстрой. Тест Пака , кажется, подтверждает это.)

Но что я действительно хотел сказать, что вы, возможно, слишком сильно беспокоитесь об накладных расходах параллелизма при блокировке по сравнению с atomi c types / volatile. В реальных приложениях блокировка вызывает беспокойство, если блокировка оспаривается, либо потому, что много потоков пытается ее использовать, либо потому, что блокировка удерживается в течение длительного времени.

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

  • Если бы вы собирались использовать такой класс DirtyArray, вам лучше использовать метод setValue который устанавливает несколько значений за один вызов.
  • В противном случае ... вы, скорее всего, запускаете гораздо больше очистки кеша, et c, чем вы бы видели в реальной жизни.

Другой альтернативой является ослабление требований. Например, действительно ли ваше приложение требует постоянного исправления isDirty()? В противном случае ваше первоначальное решение может быть достаточно хорошим.

Наконец, ошибка связана с вашей проблемой: как ваше приложение может использовать свойства строгой согласованности флага isDirty:

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

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

    if (!da.isDirty()) {
        da.setValue(...);
    }
    

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

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

1 голос
/ 28 июня 2020

Мне кажется, что грязный флаг меняется только с false на true. Если для него установлено значение true, он никогда не будет сброшен.

Если вы, как вы предлагаете в своем вопросе, считаете, что можете управлять видимостью изменений в других потоках с помощью других средств, кроме синхронизации и волатильности, не достаточно, если вы синхронизируете изменение состояния грязного флага примерно так:

public void setValue(int index, byte value) {
    if (dirty) {
        data[index] = value;
    }
    else {
        synchronized(this) {
            dirty = true;
            data[index] = value;
        }
    }
}

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

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

void fill(byte value, int offset, int length);
void setValue(byte[] source, int offset, int length);

С такими методами вы можете иметь возможность правильно синхронизировать и распространять изменения без особого дополнительного воздействия на время выполнения.

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

1 голос
/ 26 июня 2020

Я бы рассмотрел другие варианты.

Во-первых, вы не должны использовать финализаторы, очистители или очереди ссылок для реализации таких требований приложения. Например, что маловероятно, но кто-то может использовать экземпляр JVM no-g c. В более практическом плане нет никакой гарантии, что финализаторы, очистители и очереди ссылок будут запускаться при выходе из приложения.

Для этого следует использовать другие механизмы, такие как параллельные потоки и / или завершаемые фьючерсы. Фактически вы можете использовать оба подхода.

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

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

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

Лучшего, чем линейное ускорение, не найти. N-ядерная машина не может работать лучше, чем 1 / n исходного последовательного времени, по крайней мере из-за синхронизации и недействительности кеша, и, как правило, из-за всего остального, что делает машина. Возможно, накладные расходы в размере от 1% до 5% могут быть достаточно хорошими, например, двухъядерный компьютер, использующий параллельный подход в 52% последовательного времени. подход в этих двух пунктах: не полагаться на G C для хранения ваших данных и иметь более грубые операции, правильно синхронизированные. Я предполагаю, что ваши операции полностью независимы друг от друга, потому что в противном случае речь идет не о досадно параллельных алгоритмах.

1 голос
/ 23 июня 2020

Вы можете попробовать использовать AtomicReferenceArray (см. Документы здесь ). Этот массив предоставляет вам гарантии в отношении одновременного доступа.

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

import java.util.concurrent.atomic.AtomicReferenceArray;

public class TestThread {
   private static String[] source = new String[10];
   private static AtomicReferenceArray<String> atomicReferenceArray 
      = new AtomicReferenceArray<String>(source);

   public static void main(final String[] arguments) throws InterruptedException {

      for (int i = 0; i<atomicReferenceArray.length(); i++) {
         atomicReferenceArray.set(i, "item-2");
      }

      Thread t1 = new Thread(new Increment());
      Thread t2 = new Thread(new Compare());
      t1.start();
      t2.start();

      t1.join();
      t2.join();        
   }  

   static class Increment implements Runnable {
      
      public void run() {
         
         for(int i = 0; i<atomicReferenceArray.length(); i++) {
            String add = atomicReferenceArray.getAndSet(i,"item-"+ (i+1));
            System.out.println("Thread " + Thread.currentThread().getId() 
               + ", index " +i + ", value: "+ add);
         }
      }
   }

   static class Compare implements Runnable {
      
      public void run() {
         
         for(int i = 0; i<atomicReferenceArray.length(); i++) {
            System.out.println("Thread " + Thread.currentThread().getId() 
               + ", index " +i + ", value: "+ atomicReferenceArray.get(i));
            boolean swapped = atomicReferenceArray.compareAndSet(i, "item-2", "updated-item-2");
            System.out.println("Item swapped: " + swapped);
            
            if(swapped) {
               System.out.println("Thread " + Thread.currentThread().getId() 
                  + ", index " +i + ", updated-item-2");
            }
         }
      }
   }
}

Вывод :

Thread 9, index 0, value: item-2
Thread 10, index 0, value: item-1
Item swapped: false
Thread 10, index 1, value: item-2
Item swapped: true
Thread 9, index 1, value: updated-item-2
Thread 10, index 1, updated-item-2
Thread 10, index 2, value: item-3
Item swapped: false
Thread 10, index 3, value: item-2
Item swapped: true
Thread 10, index 3, updated-item-2
Thread 10, index 4, value: item-2
Item swapped: true
Thread 10, index 4, updated-item-2
Thread 10, index 5, value: item-2
Item swapped: true
Thread 10, index 5, updated-item-2
Thread 10, index 6, value: item-2
Thread 9, index 2, value: item-2
Item swapped: true
Thread 9, index 3, value: updated-item-2
Thread 10, index 6, updated-item-2
Thread 10, index 7, value: item-2
Thread 9, index 4, value: updated-item-2
Item swapped: true
Thread 9, index 5, value: updated-item-2
Thread 10, index 7, updated-item-2
Thread 9, index 6, value: updated-item-2
Thread 10, index 8, value: item-2
Thread 9, index 7, value: updated-item-2
Item swapped: true
Thread 9, index 8, value: updated-item-2
Thread 10, index 8, updated-item-2
Thread 9, index 9, value: item-2
Thread 10, index 9, value: item-10
Item swapped: false
0 голосов
/ 21 июля 2020

Вы можете использовать подход, аналогичный тому, что делает AtomicMarkableReference.

public class DirtyArray {
    private final Cell[] cells;

    public DirtyArray(byte[] data) {
        cells = new Cell[data.length];
        Arrays.setAll(cells, i -> new Cell(data[i], false));
    }

    public void setValue(int index, byte value) {
        Cell cell = cells[index];
        if (cell.value != value || !cell.dirty) {
            cells[index] = new Cell(value, true);
        }
    }

    public boolean isDirty() {
        for (Cell cell : cells) {
            if (cell.dirty) {
                return true;
            }
        }
        return false;
    }

    private static class Cell {
        final byte value;
        final boolean dirty;

        Cell(byte value, boolean dirty) {
            this.value = value;
            this.dirty = dirty;
        }
    }
}
0 голосов
/ 29 июня 2020

Я буду краток и лаконичен.

Вы можете go для (1) synchronized методов для изменения и / или чтения вашего логического поля, или (2) просто используйте AtomicBoolean, который изначально является потокобезопасным.

0 голосов
/ 24 июня 2020

Как вы, возможно, знаете из определения переменной volatile, ее значения всегда видны всем потокам.

Почему volatile обычно используется неправильно: что люди обычно путают с volatile , что они не могут обновлять его атомарно без условий гонки.

Давайте возьмем в качестве примера счетчик:

class Counter {
  volatile int counter = 0;

  void incrementCounter() {
    counter = counter + 1; 
  } 
}

Это не сработает, потому что есть 2 разные операции, которые происходит со счетчиком: чтение и запись. И если что-то случится со значением между чтением и записью, счетчик потеряет это значение:

  1. счетчик чтения внутри функции | значение равно 100
  2. счетчик изменен в другом потоке / месте | value is 105
  3. увеличить значение счетчика и записать его обратно | значение составляет 101

Изменчивый в вашем коде

У вас НЕТ операций чтения и записи над флагом в одном месте, поэтому ваш код в setValue уже является atomi c.

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

Исправление производительности

Как вы заметили, volatile может быть медленнее, чем энергонезависимый.

Поскольку volatile синхронизируется между потоками, JVM должна проделать дополнительную работу. Но хорошо, что это делается во время операции записи. Так что, если вы добавите проверку чтения перед модификацией, это должно ускорить работу.

...