Як працює схема руйнування LMAX?


205

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

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

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

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

Чи є якісь вказівки на краще пояснення?

Відповіді:


210

Проект Google Code посилається на технічний документ про реалізацію буфера дзвінка, однак він трохи сухий, академічний і важкий для того, хто хоче дізнатися, як це працює. Однак є деякі повідомлення в блозі, які почали пояснювати внутрішні ресурси більш читабельним способом. Існує пояснення буферного кільця, яке є стрижнем схеми руйнування, опис споживчих бар'єрів (частина, що стосується зчитування від діспродавця) та деяка інформація про обробку декількох доступних виробників .

Найпростіший опис Disrupor: Це спосіб надсилання повідомлень між потоками найбільш ефективним способом. Він може використовуватися як альтернатива черзі, але він також має ряд функцій з SEDA та Actors.

Порівняно з чергами:

Disruptor надає можливість передавати повідомлення в інші потоки, пробуджуючи його, якщо це потрібно (подібно до BlockingQueue). Однак є 3 відмінності.

  1. Користувач Disruptor визначає, як зберігаються повідомлення, розширюючи клас введення та надаючи фабриці можливість виконувати попереднє розміщення. Це дозволяє або повторне використання пам'яті (копіювання), або запис може містити посилання на інший об'єкт.
  2. Введення повідомлень у Disrupor - це двофазний процес, спочатку в кільцевий буфер подається слот, який надає користувачеві запис, який може бути заповнений відповідними даними. Тоді запис повинен бути здійснений. Цей двофазний підхід необхідний для забезпечення гнучкого використання пам'яті, згаданої вище. Саме команда робить повідомлення видимим для споживачів.
  3. Споживач несе відповідальність за відстеження повідомлень, які були спожиті з буфера дзвінка. Перенесення цієї відповідальності від самого буфера кільця допомогло зменшити кількість суперечок запису, оскільки кожен потік підтримує власний лічильник.

Порівняно з акторами

Модель Actor ближче до Disruptor, ніж більшість інших моделей програмування, особливо якщо ви використовуєте класи BatchConsumer / BatchHandler, які надаються. Ці класи приховують усі складності підтримання споживаних послідовних номерів і забезпечують набір простих зворотних зворотних дзвінків, коли відбуваються важливі події. Однак є пара тонких відмінностей.

  1. Disruptor використовує споживчу модель 1 - 1, де актори використовують модель N: M, тобто у вас може бути стільки акторів, скільки вам подобається, і вони будуть розподілені по фіксованій кількості потоків (як правило, 1 на ядро).
  2. Інтерфейс BatchHandler забезпечує додатковий (і дуже важливий) зворотний зв'язок onEndOfBatch(). Це дозволяє повільним споживачам, наприклад тим, хто робить введення / виведення, щоб разом проводити пакет подій для покращення пропускної здатності. Можливе проведення пакетної обробки в інших фреймворках Actor, однак, оскільки майже всі інші фрейми не забезпечують зворотного виклику в кінці партії, вам потрібно використовувати тайм-аут, щоб визначити кінець партії, що призводить до поганої затримки.

Порівняно з SEDA

LMAX побудував схему Disruptor, щоб замінити підхід, заснований на SEDA.

  1. Основним вдосконаленням, яке воно забезпечило над SEDA, було можливість паралельно виконувати роботу. Для цього Disruptor підтримує багатоканальне передавання одних і тих же повідомлень (в одному порядку) для кількох споживачів. Це дозволяє уникнути необхідності вилки ступенів у трубопроводі.
  2. Ми також дозволяємо споживачам чекати результатів інших споживачів без необхідності ставити черговий етап черги між ними. Споживач може просто спостерігати за порядковим номером споживача, від якого він залежить. Це дозволяє уникнути необхідності стадій з'єднання в трубопроводі.

Порівняно з бар'єрами пам’яті

Ще один спосіб подумати над цим - як структурований, упорядкований бар'єр пам’яті. Якщо бар'єр виробника формує бар'єр запису, а бар'єр споживача - бар'єр читання.


1
Дякую Майклу. Ваше написання та надані вами посилання допомогли мені краще зрозуміти, як це працює. Решта, я думаю, мені просто потрібно дозволити йому зануритися.
Шахбаз

У мене залишаються питання: (1) як працює "фіксація"? (2) Коли буфер кільця заповнений, як виробник виявляє, що всі споживачі бачили дані, щоб виробник міг повторно використовувати записи?
Qwertie

@Qwertie, напевно, варто поставити нове запитання.
Майкл Баркер

1
Чи не повинно перше речення останнього пункту відмітки (номер 2) у порівнянні з SEDA замість того, щоб прочитати "Ми також дозволяємо споживачам чекати результатів інших споживачів з необхідністю поставити ще один етап черги між ними" читати "Ми також дозволяємо споживачі чекати на результати інших споживачів, не потребуючи встановлення чергової черги між ними "(тобто" з "має бути замінено на" без ")?
рунекс

@runeks, так, так і слід.
Майкл Баркер

135

Спершу ми хотіли б зрозуміти модель програмування, яку вона пропонує.

Є один або кілька письменників. Є один чи більше читачів. Існує рядок записів, повністю упорядкований від старого до нового (на фото як зліва направо). Письменники можуть додавати нові записи в правій частині. Кожен читач читає записи послідовно зліва направо. Читачі не можуть читати минулих письменників, очевидно.

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

Зазвичай читачі можуть читати одночасно та незалежно. Однак ми можемо оголосити залежності серед читачів. Залежності читання можуть бути довільним ациклічним графіком. Якщо читач B залежить від читача A, читач B не може прочитати минулого читача А.

Залежність читача виникає, оскільки читач A може коментувати запис, а читач B залежить від цього примітки. Наприклад, A робить деякий розрахунок для запису і зберігає результат у полі aу записі. Потім рухайтесь далі, і тепер B може прочитати запис та значення aA, що зберігається. Якщо зчитувач C не залежить від A, C не повинен намагатися читати a.

Це справді цікава модель програмування. Незалежно від продуктивності, одна модель може принести користь безлічі застосувань.

Звичайно, головна мета LMAX - продуктивність. Тут використовується попередньо виділене кільце записів. Кільце досить велике, але воно обмежене, щоб система не завантажувалася понад проектні можливості. Якщо кільце заповнене, письменник (и) зачекає, поки найповільніші читачі просунуться і звільнять місце.

Об'єкти в'їзду попередньо виділяються та живуть назавжди, щоб зменшити витрати на вивезення сміття. Ми не вставляємо нові об’єкти введення або видаляємо старі об’єкти введення, натомість автор запитує попередньо існуючий запис, заповнює його поля та повідомляє читачів. Ця очевидна двофазна дія дійсно просто атомна дія

setNewEntry(EntryPopulator);

interface EntryPopulator{ void populate(Entry existingEntry); }

Попередній розподіл записів також означає, що сусідні записи (дуже ймовірно) знаходяться в сусідніх комітках пам'яті, і оскільки читачі читають записи послідовно, це важливо для використання кешів процесора.

І багато зусиль, щоб уникнути блокування, CAS, навіть бар'єр пам’яті (наприклад, використовувати енергонезалежну змінну послідовності, якщо є лише один запис)

Для розробників читачів: Різні читачі-коментарі повинні писати в різні поля, щоб уникнути суперечок при написанні. (Насправді вони повинні писати в різні рядки кешу.) Читач, який коментує, не повинен торкатися нічого, що можуть прочитати інші незалежні читачі. Ось чому я кажу, що ці читачі анотують записи, а не змінюють записи.


2
Мені все гаразд. Мені подобається використання терміна анотація.
Майкл Баркер

21
+1 - це єдина відповідь, яка намагається описати, як насправді працює схема руйнування, як вимагає ОП.
G-Wiz

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

1
@irreputable Чи можете ви також написати подібне пояснення для письменника?
Бучі

Мені це подобається, але я виявив, що "автор пише про попередній запис, заповнює його поля та повідомляє читачів. Ця очевидна двофазна дія дійсно є просто атомною дією", що заплутає і, можливо, помиляється? Немає права "сповістити"? Також це не атомно, це лише одна ефективна / видима запис, правильно? Чудова відповідь тільки на мову, яка є неоднозначною?
HaveAGuess


17

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

Існує буфер, який зберігає заздалегідь виділені події, які дозволять зберігати дані для споживачів.

Буфер підтримується масивом прапорів (цілочисленний масив) його довжини, який описує наявність буферних слотів (детальніше див. Далі). До масиву звертається як до java # AtomicIntegerArray, тому для цілей цього пояснення ви також можете вважати його єдиним.

Виробників може бути будь-яка кількість. Коли виробник хоче записати в буфер, генерується довге число (як при виклику AtomicLong # getAndIncrement, Disruptor насправді використовує власну реалізацію, але вона працює таким же чином). Давайте назвемо це згенерованим довгим виробникомCallId. Аналогічним чином, ConsumCallId генерується, коли споживач ENDS читає слот з буфера. Доступ до останнього споживачаCallId.

(Якщо споживачів багато, вибирається дзвінок із найнижчим ідентифікатором.)

Ці ідентифікатори потім порівнюються, і якщо різниця між ними менша від буферної сторони, виробник може записувати.

(Якщо generatorCallId більше, ніж останній споживачCallId + bufferSize, це означає, що буфер заповнений, і виробник змушений чекати, поки пляма стане доступною.)

Потім виробник призначає слот в буфері на основі його callId (який є prducerCallId по модулю bufferSize, але оскільки bufferSize завжди є потужністю 2 (обмеження, що застосовується при створенні буфера), використовувана операційна операція виробникCallId & (bufferSize - 1 )). Тоді вільно змінювати подію в цьому слоті.

(Справжній алгоритм є трохи складнішим, включаючи кешування недавнього споживачаId в окремій атомній посилання для цілей оптимізації.)

Коли подія була змінена, зміна "публікується". При публікації відповідного слота в масиві прапорців заповнюється оновлений прапор. Значення прапора - це число циклу (generatorCallId, розділене на bufferSize (знову ж таки, оскільки bufferSize - потужність 2, фактична операція - це правильний зсув).

Аналогічним чином може бути будь-яка кількість споживачів. Кожен раз, коли споживач хоче отримати доступ до буфера, генерується споживчий виклик (залежно від того, як споживачі були додані до руйнівника, атом, що використовується в генерації ідентифікаторів, може бути спільним або відокремленим для кожного з них). Потім цей споживчийCallId порівнюється з найновішим producentCallId, і якщо він менше двох, читачеві дозволяється прогресувати.

(Аналогічно, якщо виробникCallId навіть до споживачаCallId, це означає, що буфер порожній і споживач змушений чекати. Спосіб очікування визначається WaitStrategy під час створення руйнівника.)

Для окремих споживачів (тих, у кого є власний генератор ідентифікаторів), наступне, що перевіряється, - це можливість партії споживання. Слоти в буфері перевіряються в порядку від відповідного споживачаCallId (індекс визначається таким же чином, як і для виробників), до відповідного для недавнього виробникаCallId.

Вони перевіряються в циклі, порівнюючи значення прапора, записане в масиві прапорів, із значенням прапора, сформованим для споживачаCallId. Якщо прапори збігаються, це означає, що виробники, які заповнюють слоти, здійснили свої зміни. Якщо ні, цикл розривається, і повертається найвище зафіксований changeId. Слоти від ConsumerCallId до отриманих у changeId можна споживати пакетно.

Якщо група споживачів читає разом (ті, у кого є спільний генератор ідентифікаторів), кожен приймає лише один callId, і перевіряється та повертається лише слот для цього єдиного callId.


7

З цієї статті :

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

Бар'єри пам’яті важко пояснити, і блог Триші зробив найкращу спробу на мою думку з цього допису: http://mechanitis.blogspot.com/2011/08/dissecting-disruptor-why-its-so-fast. html

Але якщо ви не хочете занурюватися в деталі низького рівня, ви можете просто знати, що бар'єри пам'яті на Java реалізуються за допомогою volatileключового слова або через java.util.concurrent.AtomicLong. Послідовності схеми руйнування є AtomicLongі передаються між виробниками та споживачами перешкодами пам’яті замість замків.

Мені легше зрозуміти концепцію за допомогою коду, тому код нижче - це простий helloworld від CoralQueue , який є реалізацією схеми переривання, виконаною CoralBlocks, з якою я пов'язаний. У наведеному нижче коді ви можете бачити, як схема руйнування реалізує пакетну обробку і як кільцевий буфер (тобто круговий масив) дозволяє без сміття спілкуватися між двома потоками:

package com.coralblocks.coralqueue.sample.queue;

import com.coralblocks.coralqueue.AtomicQueue;
import com.coralblocks.coralqueue.Queue;
import com.coralblocks.coralqueue.util.MutableLong;

public class Sample {

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

        final Queue<MutableLong> queue = new AtomicQueue<MutableLong>(1024, MutableLong.class);

        Thread consumer = new Thread() {

            @Override
            public void run() {

                boolean running = true;

                while(running) {
                    long avail;
                    while((avail = queue.availableToPoll()) == 0); // busy spin
                    for(int i = 0; i < avail; i++) {
                        MutableLong ml = queue.poll();
                        if (ml.get() == -1) {
                            running = false;
                        } else {
                            System.out.println(ml.get());
                        }
                    }
                    queue.donePolling();
                }
            }

        };

        consumer.start();

        MutableLong ml;

        for(int i = 0; i < 10; i++) {
            while((ml = queue.nextToDispatch()) == null); // busy spin
            ml.set(System.nanoTime());
            queue.flush();
        }

        // send a message to stop consumer...
        while((ml = queue.nextToDispatch()) == null); // busy spin
        ml.set(-1);
        queue.flush();

        consumer.join(); // wait for the consumer thread to die...
    }
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.