Как я могу сделать универсальную конструкцию более эффективной?

16

«Универсальная конструкция» - это класс-оболочка для последовательного объекта, который позволяет его линеаризовать (условие строгой согласованности для параллельных объектов). Например, вот адаптированная конструкция без ожидания в Java из [1], которая предполагает существование очереди без ожидания, которая удовлетворяет интерфейсу WFQ(который требует единовременного согласования между потоками) и предполагает Sequentialинтерфейс:

public interface WFQ<T> // "FIFO" iteration
{
    int enqueue(T t); // returns the sequence number of t
    Iterable<T> iterateUntil(int max); // iterates until sequence max
}
public interface Sequential
{
    // Apply an invocation (method + arguments)
    // and get a response (return value + state)
    Response apply(Invocation i); 
}
public interface Factory<T> { T generate(); } // generate new default object
public interface Universal extends Sequential {}

public class SlowUniversal implements Universal
{
    Factory<? extends Sequential> generator;
    WFQ<Invocation> wfq = new WFQ<Invocation>();
    Universal(Factory<? extends Sequential> g) { generator = g; } 
    public Response apply(Invocation i)
    {
        int max = wfq.enqueue(i);
        Sequential s = generator.generate();
        for(Invocation invoc : wfq.iterateUntil(max))
            s.apply(invoc);
        return s.apply(i);
    }
}

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

Можем ли мы сделать это более эффективным (не линейное время выполнения в размере истории, предпочтительно использование памяти также уменьшается) без потери свойства без ожидания?

осветление

«Универсальная конструкция» - это термин, который, я уверен, был составлен [1], который принимает небезопасный, но совместимый с потоками объект, который обобщается Sequentialинтерфейсом. Используя очередь без ожидания, первая конструкция предлагает поточно-ориентированную, линеаризуемую версию объекта, которая также не требует ожидания (это предполагает детерминизм и applyоперации остановки ).

Это неэффективно, поскольку этот метод эффективно запускает каждый локальный поток с чистого листа и применяет все операции, когда-либо записанные в него. В любом случае, это работает, потому что это обеспечивает эффективную синхронизацию с помощью WFQопределения порядка, в котором должны применяться все операции: каждый вызывающий поток applyувидит один и тот же локальный Sequentialобъект с одинаковой последовательностью Invocations, примененной к нему.

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

Жаргон:

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

Рабочий пример по запросу (теперь на странице, срок действия которой не истекает)

[1] Херлихи и Шавит, Искусство многопроцессорного программирования .

VF1
источник
Вопрос 1 отвечает только в том случае, если мы знаем, что для вас значит «работает».
Роберт Харви
@RobertHarvey Я исправил это - все, что нужно для «работы», - это чтобы обертка была без ожидания и все операции CopyableSequentialбыли действительными - линеаризация должна следовать из факта, что это так Sequential.
VF1
В этом вопросе много значащих слов, но я изо всех сил стараюсь соединить их, чтобы точно понять, чего вы пытаетесь достичь. Можете ли вы дать какое-то объяснение, какую проблему вы пытаетесь решить, и, возможно, немного прорежьте жаргон?
JimmyJames
@JimmyJames Я разработал «расширенный комментарий» внутри вопроса. Пожалуйста, дайте мне знать, есть ли другой Жаргон, чтобы разобраться.
VF1
в первом абзаце комментария вы говорите «потокобезопасный, но совместимый с потоками объект» и «линеаризуемая версия объекта». Непонятно, что вы подразумеваете под этим, потому что потокобезопасный и линеаризуемый действительно относятся только к исполняемым инструкциям, но вы используете их для описания объектов, которые являются данными. Я предполагаю, что Invocation (который не определен) фактически является указателем на метод, и этот метод не является поточно-ориентированным. Я не знаю, что означает совместимость с потоками .
JimmyJames

Ответы:

1

Вот объяснение и пример того, как это достигается. Дайте мне знать, если есть части, которые не ясны.

Гист с источником

универсальный

Инициализация:

Индексы потоков применяются атомарно увеличенным способом. Это управляется с использованием AtomicIntegerименованных nextIndex. Эти индексы присваиваются потокам через ThreadLocalэкземпляр, который инициализирует себя, получая следующий индекс nextIndexи увеличивая его. Это происходит при первом получении индекса каждого потока. A ThreadLocalсоздан для отслеживания последней последовательности, созданной этим потоком. Он инициализирован 0. Последовательная фабричная ссылка на объект передается и сохраняется. Два AtomicReferenceArrayэкземпляра созданы размером n. Конечный объект присваивается каждой ссылке, будучи инициализированной с исходным состоянием, предоставленным Sequentialфабрикой. nмаксимально допустимое количество потоков. Каждый элемент в этих массивах «принадлежит» соответствующему индексу потока.

Применить метод:

Это метод, который делает интересную работу. Это делает следующее:

  • Создайте новый узел для этого вызова: мой
  • Установите этот новый узел в массиве анонсов по индексу текущего потока

Затем начинается цикл секвенирования. Это будет продолжаться, пока текущий вызов не будет упорядочен:

  1. найти узел в массиве анонсов, используя последовательность последнего узла, созданного этим потоком. Подробнее об этом позже.
  2. если узел найден на шаге 2, он еще не упорядочен, продолжайте с ним, в противном случае просто сосредоточьтесь на текущем вызове. Это только попытается помочь одному другому узлу за вызов.
  3. Какой бы узел не был выбран на шаге 3, продолжайте пытаться упорядочить его после последнего упорядоченного узла (другие потоки могут помешать.) Независимо от успеха, установите ссылку на заголовок текущего потока на последовательность, возвращаемую decideNext()

Ключом к описанному выше вложенному циклу является decideNext()метод. Чтобы понять это, нам нужно взглянуть на класс Node.

Класс узла

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

метод хвоста

это возвращает специальный экземпляр узла с последовательностью 0. Он просто действует как заполнитель, пока вызов не заменит его.

Свойства и инициализация

  • seq: порядковый номер, инициализированный -1 (что означает неупорядоченный)
  • invocation: значение вызова apply(). Установить на строительство.
  • next: AtomicReferenceдля прямой ссылки. после назначения это никогда не изменится
  • previous: AtomicReferenceдля обратной ссылки, назначенной при секвенировании и очищеннойtruncate()

Решить дальше

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

Возвращаясь к методу применения класса Universal ...

Вызвав decideNext()последний секвенированный узел (если проверено) с нашим узлом или узлом из announceмассива, возможны два случая: 1. Узел был успешно секвенирован. 2. Некоторые другие потоки опередили этот поток.

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

Оценить метод

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

Метод EnsurePrior

ensurePrior()Метод делает эту работу, проверяя предыдущий узел в связанном списке. Если это состояние не установлено, предыдущий узел будет оценен. Узел, что это рекурсивно. Если узел, предшествующий предыдущему узлу, не был оценен, он вызовет метод оценки для этого узла и так далее, и так далее.

Теперь, когда известно, что предыдущий узел имеет состояние, мы можем оценить этот узел. Последний узел извлекается и присваивается локальной переменной. Если эта ссылка нулевая, это означает, что какой-то другой поток опередил этот и уже оценил этот узел; установив это состояние. В противном случае состояние предыдущего узла передается Sequentialметоду apply объекта вместе с вызовом этого узла. Возвращаемое состояние устанавливается на узле, и truncate()вызывается метод, очищающий обратную ссылку от узла, поскольку он больше не нужен.

Метод MoveForward

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

Объявить массив и помощь

Ключом к тому, чтобы сделать этот подход без ожидания, а не просто без блокировки, является то, что мы не можем предполагать, что планировщик потока будет давать приоритет каждому потоку, когда он ему нужен. Если каждый поток просто пытается упорядочить свои собственные узлы, возможно, поток будет постоянно загружаться под нагрузкой. Чтобы учесть эту возможность, каждый поток сначала попытается «помочь» другим потокам, которые могут быть не в состоянии упорядочиться.

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

В начальной точке все три элемента заголовка и объявления потоков направлены на tailузел. Для lastSequenceкаждого потока - 0.

На этом этапе поток 1 выполняется с вызовом. Он проверяет массив анонсов на предмет его последней последовательности (ноль), которая является узлом, который он в настоящее время планирует индексировать. Это последовательность узла, и он lastSequenceустановлен в 1.

Поток 2 теперь выполняется с вызовом, он проверяет массив анонсов в его последней последовательности (ноль) и видит, что ему не нужна помощь, и поэтому пытается упорядочить свой вызов. Это успешно, и теперь он lastSequenceустановлен на 2.

Поток 3 теперь выполняется, и он также видит, что узел в announce[0]уже секвенирован и выполняет собственный вызов. Это lastSequenceтеперь установлено значение 3.

Теперь поток 1 вызывается снова. Он проверяет массив анонсов по индексу 1 и находит, что он уже упорядочен. Параллельно вызывается поток 2 . Он проверяет массив анонсов в индексе 2 и находит, что он уже упорядочен. И Поток 1, и Поток 2 теперь пытаются упорядочить свои собственные узлы. Поток 2 побеждает, и это вызывает его вызов. Это lastSequenceустановлено в 4. Между тем, поток три был вызван. Он проверяет индекс it lastSequence(mod 3) и находит, что узел announce[0]не был упорядочен. Поток 2 снова вызывается в то время, когда Поток 1 находится на второй попытке. Тема 1находит неупорядоченный вызов, в announce[1]котором находится узел, только что созданный потоком 2 . Он пытается упорядочить вызов потока 2 и завершается успешно. Поток 2 находит свой собственный узел в, announce[1]и он был упорядочен. Он устанавливает lastSequenceзначение 5. Поток 3 затем вызывается и обнаруживает, что узел, в котором размещен поток 1 announce[0], все еще не секвенирован, и пытается это сделать. Между тем поток 2 также был вызван и опережает поток 3. Он упорядочивает свой узел и устанавливает его равным lastSequence6.

Плохая тема 1 . Несмотря на то, что Поток 3 пытается упорядочить его, планировщик постоянно мешал обоим потокам. Но на данный момент. Тема 2 также теперь указывает на announce[0](6 мод 3). Все три потока настроены на последовательность одного и того же вызова. Независимо от того, какой поток завершится успешно, следующий подлежащий последовательности узел будет ожидающим вызовом потока 1, то есть узла, на который ссылается announce[0].

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

JimmyJames
источник
Не могли бы вы поместить некоторые выдержки из кода в pastebin? Многие вещи (например, связанный список без блокировок) можно просто сформулировать как таковые? Немного сложно понять ваш ответ в целом, когда есть так много деталей. В любом случае, это выглядит многообещающе, я бы, конечно, хотел бы поинтересоваться, какие гарантии это дает.
VF1
Это, конечно, похоже на правильную реализацию без блокировки, но в ней отсутствует фундаментальная проблема, о которой я беспокоюсь. Требование линеаризуемости требует наличия «действительной истории», которая, в случае реализации связного списка, должна иметь действительный указатель previousи nextуказатель. Поддержание и создание действительной истории без ожидания кажется трудным.
VF1
@ VF1 Я не уверен, что проблема не решена. Все, что вы упоминаете в оставшейся части комментария, рассмотрено в примере, который я привел, из того, что я могу сказать.
JimmyJames
Вы отказались от собственности без ожидания .
VF1
@ VF1 Как ты считаешь?
JimmyJames
0

Мой предыдущий ответ на самом деле не отвечает на вопрос должным образом, но, поскольку ФП считает его полезным, я оставлю его как есть. Основываясь на коде в ссылке в вопросе, вот моя попытка. Я провел только базовое тестирование по этому вопросу, но, похоже, он правильно рассчитывает средние значения. Приветствуется обратная связь относительно того, действительно ли это без ожидания.

ПРИМЕЧАНИЕ : я удалил универсальный интерфейс и сделал его классом. Наличие Universal, состоящего из последовательностей, а также единое целое кажется ненужным осложнением, но я мог бы что-то упустить. В среднем классе я пометил переменную состояния как volatile. Это не обязательно, чтобы заставить код работать. Быть консервативным (хорошая идея с потоками) и не допускать, чтобы каждый поток делал все вычисления (один раз).

Последовательный и завод

public interface Sequential<E, S, R>
{ 
  R apply(S priorState);

  S state();

  default boolean isApplied()
  {
    return state() != null;
  }
}

public interface Factory<E, S, R>
{
   S initial();

   Sequential<E, S, R> generate(E input);
}

универсальный

import java.util.concurrent.ConcurrentLinkedQueue;

public class Universal<I, S, R> 
{
  private final Factory<I, S, R> generator;
  private final ConcurrentLinkedQueue<Sequential<I, S, R>> wfq = new ConcurrentLinkedQueue<>();
  private final ThreadLocal<Sequential<I, S, R>> last = new ThreadLocal<>();

  public Universal(Factory<I, S, R> g)
  { 
    generator = g;
  }

  public R apply(I invocation)
  {
    Sequential<I, S, R> newSequential = generator.generate(invocation);
    wfq.add(newSequential);

    Sequential<I, S, R> last = null;
    S prior = generator.initial(); 

    for (Sequential<I, S, R> i : wfq) {
      if (!i.isApplied() || newSequential == i) {
        R r = i.apply(prior);

        if (i == newSequential) {
          wfq.remove(last.get());
          last.set(newSequential);

          return r;
        }
      }

      prior = i.state();
    }

    throw new IllegalStateException("Houston, we have a problem");
  }
}

Средний

public class Average implements Sequential<Integer, Average.State, Double>
{
  private final Integer invocation;
  private volatile State state;

  private Average(Integer invocation)
  {
    this.invocation = invocation;
  }

  @Override
  public Double apply(State prior)
  {
    System.out.println(Thread.currentThread() + " " + invocation + " prior " + prior);

    state = prior.add(invocation);

    return ((double) state.sum)/ state.count;
  }

  @Override
  public State state()
  {
    return state;
  }

  public static class AverageFactory implements Factory<Integer, State, Double> 
  {
    @Override
    public State initial()
    {
      return new State(0, 0);
    }

    @Override
    public Average generate(Integer i)
    {
      return new Average(i);
    }
  }

  public static class State
  {
    private final int sum;
    private final int count;

    private State(int sum, int count)
    {
      this.sum = sum;
      this.count = count;
    }

    State add(int value)
    {
      return new State(sum + value, count + 1);
    }

    @Override
    public String toString()
    {
      return sum + " / " + count;
    }
  }
}

Демо-код

private static final int THREADS = 10;
private static final int SIZE = 50;

public static void main(String... args)
{
  Average.AverageFactory factory = new Average.AverageFactory();

  Universal<Integer, Average.State, Double> universal = new Universal<>(factory);

  for (int i = 0; i < THREADS; i++)
  {
    new Thread(new Test(i * SIZE, universal)).start();
  }
}

static class Test implements Runnable
{
  final int start;
  final Universal<Integer, Average.State, Double> universal;

  Test(int start, Universal<Integer, Average.State, Double> universal)
  {
    this.start = start;
    this.universal = universal;
  }

  @Override
  public void run()
  {
    for (int i = start; i < start + SIZE; i++)
    {
      System.out.println(Thread.currentThread() + " " + i);

      System.out.println(System.nanoTime() + " " + Thread.currentThread() + " " + i + " result " + universal.apply(i));
    }
  }
}

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

JimmyJames
источник
Вы не должны держать свой другой ответ для меня (я ранее обновил свой вопрос, чтобы иметь какие-либо соответствующие выводы, которые будут сделаны из него). К сожалению, этот ответ также не отвечает на вопрос, так как он фактически не освобождает память в wfq, так что вам все равно придется просматривать всю историю - время выполнения не улучшилось, за исключением постоянного фактора.
VF1
@ Vf1 Время, необходимое для обхода всего списка, чтобы проверить, был ли он рассчитан, будет крошечным по сравнению с каждым вычислением. Поскольку предыдущие состояния не требуются, должна быть возможность удалить начальные состояния. Тестирование сложное и может потребовать использования настраиваемой коллекции, но я добавил небольшое изменение.
JimmyJames
@ VF1 Обновлен до реализации, которая, кажется, работает с базовым беглым тестированием. Я не уверен, что это безопасно, но, если исходить из того, что Universal знает о потоках, которые с ним работают, он может отслеживать каждый поток и удалять элементы, как только все потоки благополучно пройдут мимо них.
JimmyJames
@ VF1 Глядя на код для ConcurrentLinkedQueue, у метода offer есть цикл, очень похожий на тот, который, как вы заявили, сделал другой ответ без ожидания. Ищите комментарий «Потерянная гонка CAS в другой теме; перечитайте следующую»
JimmyJames
«Должно быть возможно убрать начальные состояния» - точно. Она должна быть , но ее легко тонко ввести код , который теряет свободу ожидания. Схема отслеживания потоков может работать. Наконец, у меня нет доступа к источнику CLQ, не могли бы вы связать?
VF1