Як зробити універсальну конструкцію більш ефективною?


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. lock-free - те саме, що вище, але допускає можливість необмеженого часу виконання, лише у випадку, якщо необмежена кількість applyоперацій буде виконана в інших потоках. Зазвичай до цієї категорії потрапляють оптимістичні схеми синхронізації.
  3. блокування - ефективність на милість планувальника.

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

[1] Герліхій і Шавіт, Мистецтво багатопроцесорного програмування .


Питання 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()

Вирішіть далі

Цей метод лише один у Вузлі з нетривіальною логікою. У двох словах, вузол пропонується як кандидат, який буде наступним вузлом у пов'язаному списку. compareAndSet()Метод перевірятиме , якщо це посилання є недійсним , і якщо так, то встановіть посилання на кандидата. Якщо посилання вже встановлено, воно нічого не робить. Ця операція є атомною, тому якщо одночасно будуть запропоновані два кандидати, буде обраний лише один. Це гарантує, що тільки один вузол коли-небудь буде обраний як наступний. Якщо вибрано вузол-кандидат, його послідовність встановлюється на наступне значення, і попереднє посилання встановлюється на цей вузол.

Стрибки назад до класу Universal застосовують метод застосування ...

Після виклику decideNext()останнього секведованого вузла (коли він перевіряється) або з нашим вузлом, або з вузлом з announceмасиву, є два можливі випадки: 1. Вузол було успішно секведовано 2. Деякі інші потоки попередньо видалили цю нитку.

Наступний крок - перевірити, чи створено вузол для цього виклику. Це може статися через те, що цей потік успішно секвенував його або інший потік взяв його з announceмасиву та прослідкував його для нас. Якщо вона не була послідована, процес повторюється. В іншому випадку виклик закінчується очищенням масиву оголошень для цього індексу потоку та поверненням значення результату виклику. Масив оголошень очищений, щоб гарантувати відсутність посилань на вузол, який залишився навколо, що не дозволило б вузлу збирати сміття і, таким чином, зберігати всі вузли у пов'язаному списку з цього моменту живими на купі.

Метод оцінки

Тепер, коли вузол виклику був успішно секвенований, його потрібно оцінити. Для цього першим кроком є ​​переконання, що виклики, що передують цьому, були оцінені. Якщо у них немає цієї теми, вона не чекатиме, але зробить цю роботу негайно.

Метод EnsurePrior

ensurePrior()Метод робить цю роботу, перевіряючи попередній вузол у зв'язаному списку. Якщо його стан не встановлено, попередній вузол буде оцінено. Вузол, що це рекурсивно. Якщо вузол до попереднього вузла не був оцінений, він буде викликати оцінку для цього вузла тощо.

Тепер, коли, як відомо, попередній вузол має стан, ми можемо оцінити цей вузол. Останній вузол витягується та присвоюється локальній змінній. Якщо ця посилання є нульовою, це означає, що якийсь інший потік попередньо видалив цей і вже оцінив цей вузол; встановлення стану. В іншому випадку стан попереднього вузла передається Sequentialметоду застосовування об'єкта разом із викликом цього вузла. Повертається стан встановлюється на вузлі, і truncate()метод викликається, очищаючи зворотній зв'язок від вузла, оскільки він більше не потрібен.

Метод MoveForward

Метод руху вперед намагатиметься перемістити всі посилання заголовків на цей вузол, якщо вони вже не вказують на щось далі. Це потрібно для того, щоб, якщо потік перестає дзвонити, голова не збереже посилання на вузол, який більше не потрібен. compareAndSet()Метод буде переконатися , що ми оновлюємо тільки вузол , якщо якийсь - то інший потік не змінив його , так як він був отриманий.

Оголосити масив та допомогти

Ключовим моментом зробити цей підхід без очікування на відміну від просто незамкненого є те, що ми не можемо припустити, що планувальник потоків надасть кожному потоку пріоритет, коли він цього потребує. Якщо кожен потік просто спробував послідовувати власні вузли, можливо, що потоки можуть бути попередньо спущені під навантаженням. Щоб врахувати цю можливість, кожен потік спершу спробує "допомогти" іншим потокам, які можуть не мати можливості секвенуватися.

Основна ідея полягає в тому, що як кожен потік успішно створює вузли, присвоєні послідовності монотонно зростають. Якщо потік або нитки постійно випереджають інший потік, індекс, який використовує для пошуку непослідовних вузлів у announceмасиві, рухатиметься вперед. Навіть якщо кожен потік, який в даний час намагається послідувати даний вузол, постійно попереджується іншим потоком, з часом всі потоки намагатимуться послідувати цей вузол. Для ілюстрації ми побудуємо приклад із трьох ниток.

На початковій точці всі головні та оповіщувальні елементи всіх трьох ниток спрямовані на tailвузол. lastSequenceДля кожного потоку дорівнює 0.

У цей момент Нитка 1 виконується з викликом. Він перевіряє масив оголошень на його останню послідовність (нуль), який вузол планується індексувати. Це послідовність вузла, і він lastSequenceвстановлений на 1.

Нитка 2 тепер виконується з викликом, вона перевіряє масив оголошень у останній послідовності (нульовій) і бачить, що йому не потрібна допомога, і тому намагається послідувати її викликом. Це вдається, і тепер його lastSequenceвстановлено на 2.

Нитка 3 тепер виконується, і він також бачить, що вузол at announce[0]вже секвенсований і послідовності - це власне виклик. Зараз lastSequenceце встановлено на 3.

Тепер Тема 1 викликається знову. Він перевіряє масив оголошень в індексі 1 і виявляє, що він вже секвенсований. Одночасно викликається нитка 2 . Він перевіряє масив оголошень в індексі 2 і виявляє, що він вже секвенсований. І нитка 1, і нитка 2 зараз намагаються послідовно виконувати свої власні вузли. Нитка 2 виграє, і це послідовність її виклику. Встановлено lastSequenceзначення 4. Тим часом, три нитки було викликано. Він перевіряє його індекс lastSequence(mod 3) і виявляє, що вузол at announce[0]не був секвенсований. Нитка 2 знову викликається в той же час, коли нитка 1 знаходиться у другій спробі. Нитка 1знаходить невикликане виклик, при announce[1]якому вузол щойно створений Thread 2 . Він намагається послідувати виклик теми 2 і досягає успіху. Нитка 2 знаходить власний вузол у, announce[1]і він був відстежений. Це встановлено так, lastSequenceщоб це було 5. Нитка 3 потім викликається і виявляє, що вузол, на який розміщена нитка 1 announce[0], все ще не є послідовними, і намагається це зробити. Тим часом, нитка 2 також була викликана і попередньо спорожнює нитку 3. Вона послідовно працює з вузлом і встановлює lastSequenceзначення 6.

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

Це неминуче. Для того, щоб потоки були попередньо спорожнені, інші потоки повинні бути вузлами послідовності, і коли вони це робитимуть, вони будуть постійно рухатись lastSequenceвперед. Якщо вузол заданого потоку постійно не секвенується, з часом всі потоки будуть вказувати на його індекс у масиві оголошень. Жоден потік не зробить нічого іншого, поки вузол, якому він намагається допомогти, не секвенується, найгірший сценарій полягає в тому, що всі потоки вказують на той самий непідпорядкований вузол. Тому час, необхідний для послідовності будь-якого виклику, є функцією кількості потоків, а не розміру введення.


Ви б не хотіли поставити деякі фрагменти коду на пастібін? Чи багато речей (наприклад, список, пов'язаний із замкненим режимом) можна просто вказати як таке? Трохи важко зрозуміти свою відповідь у цілому, коли є так багато деталей. У будь-якому випадку, це виглядає багатообіцяюче, я, безумовно, хотів би розібратися в тому, що дає гарантія.
VF1

Це, звичайно, здається дійсною реалізацією без блокування, але в ньому відсутня фундаментальна проблема, яка мене хвилює. Вимога лінеаризованості вимагає наявності «дійсної історії», яка, у випадку реалізації зв'язаного списку, потребує наявності previousта nextпокажчика, щоб бути дійсними. Зберігати та створювати дійсну історію в режимі очікування важко.
VF1

@ VF1 Я не впевнений, яке питання не вирішено. Все, що ви згадуєте в решті коментаря, розглядається в прикладі, який я дав, з того, що я можу сказати.
JimmyJames

Ви відмовилися від власності на очікування .
VF1

@ VF1 Як ти уявляєш?
JimmyJames

0

Моя попередня відповідь насправді не відповідає належним чином на питання, але оскільки ОП вважає це корисним, я залишу його як є. Виходячи з коду в посиланні у питанні, ось моя спроба. Я робив лише дійсно базове тестування з цього приводу, але, здається, правильно розрахувати середні показники. Зворотній зв'язок вітається, чи правильно це не чекає.

ПРИМІТКА . Я видалив універсальний інтерфейс і зробив його класом. Наявність Універсалу складених з послідовних значень, а також бути одним здається непотрібним ускладненням, але я можу щось пропустити. У середньому класі я позначив змінну стану 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));
    }
  }
}

Я вніс кілька змін до коду, коли публікував його тут. Це повинно бути нормально, але дайте мені знати, якщо у вас є проблеми з цим.


Вам не потрібно тримати вашу іншу відповідь на мене (я попередньо оновив своє запитання, щоб зробити з нього відповідні висновки). На жаль, ця відповідь також не дає відповіді на запитання, оскільки вона фактично не звільняє жодної пам'яті в wfq, тому вам все одно доведеться пройти всю історію - час виконання не покращився, окрім постійного фактора.
VF1

@ Vf1 Час, який потрібен для проходження всього списку, щоб перевірити, чи обчислено його, буде незначним порівняно з кожним обчисленням. Оскільки попередні стани не потрібні, слід мати можливість видалити початкові стани. Тестування є складним і може зажадати використання спеціалізованої колекції, але я додав невеликі зміни.
JimmyJames

@ VF1 Оновлено до реалізації, яка, здається, працює з базовим побіжним тестуванням. Я не впевнений, що це безпечно, але поза вершиною моєї голови, якби універсал знав про нитки, які працюють з ним, він міг би відслідковувати кожну нитку і видаляти елементи, як тільки всі нитки надійно проходять повз них.
JimmyJames

@ VF1 Дивлячись на код для ConcurrentLinkedQueue, метод пропозиції має цикл, подібний до того, на який ви заявили, зробив іншу відповідь не чекаючою. Шукайте коментар "Загублена гонка CAS до іншої
теми

"Має бути можливість видалити початкові стани" - точно. Це має бути , але його легко втілити код, який втрачає свободу очікування. Схема відстеження потоків може працювати. Нарешті, у мене немає доступу до джерела CLQ, ви б не подумали про посилання?
VF1
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.