Паралельність із LMAX Disruptor - вступ

1. Огляд

Ця стаття представляє LMAX Disruptor і розповідає про те, як він допомагає досягти одночасності програмного забезпечення з низькою затримкою. Ми також побачимо базове використання бібліотеки Disruptor.

2. Що таке руйнівник?

Disruptor - це бібліотека Java з відкритим кодом, написана LMAX. Це паралельна програма програмування для обробки великої кількості транзакцій із низькою затримкою (і без складностей одночасного коду). Оптимізація продуктивності досягається за допомогою програмного забезпечення, яке використовує ефективність базового обладнання.

2.1. Механічна симпатія

Почнемо з основної концепції механічного співчуття - це все про розуміння того, як працює базове обладнання, та програмування таким чином, що найкраще працює з цим обладнанням.

Наприклад, давайте подивимося, як організація процесора та пам'яті може вплинути на продуктивність програмного забезпечення. Процесор має кілька шарів кеш-пам’яті між основною пам’яттю. Коли центральний процесор виконує операцію, він спочатку шукає дані в L1, потім L2, потім L3 і, нарешті, основну пам’ять. Чим далі йому доведеться йти, тим довше триватиме операція.

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

Деякі орієнтовні цифри вартості помилок кешу:

Затримка процесора до Цикли процесора Час
Основна пам’ять Множинні ~ 60-80 нс
Кеш L3 ~ 40-45 циклів ~ 15 нс
Кеш-пам’ять L2 ~ 10 циклів ~ 3 нс
Кеш L1 ~ 3-4 цикли ~ 1 нс
Зареєструйтесь 1 цикл Дуже дуже швидко

2.2. Чому не черги

Реалізації черги, як правило, мають суперечку між записами на голові, хвості та змінних розміру. Черги, як правило, завжди близькі до повної або майже порожні через різницю в темпі між споживачами та виробниками. Вони дуже рідко працюють у збалансованому середньому плані, де темпи виробництва та споживання рівномірно відповідають.

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

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

Якщо два окремі потоки записують два різні значення, кожне ядро ​​анулює рядок кешу іншого (дані передаються між основною пам'яттю та кешем у блоки фіксованого розміру, які називаються рядками кешу). Це суперечка між двома потоками, навіть якщо вони записують у дві різні змінні. Це називається помилковим обміном, оскільки кожного разу, коли отримують доступ до голови, отримують доступ і до хвоста, і навпаки.

2.3. Як працює руйнівник

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

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

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

3. Використання бібліотеки Disruptor

3.1. Залежність Мавена

Почнемо з додавання залежності бібліотеки Disruptor у pom.xml :

 com.lmax disruptor 3.3.6 

Останню версію залежності можна перевірити тут.

3.2. Визначення події

Let's define the event that carries the data:

public static class ValueEvent { private int value; public final static EventFactory EVENT_FACTORY = () -> new ValueEvent(); // standard getters and setters } 

The EventFactory lets the Disruptor preallocate the events.

3.3. Consumer

Consumers read data from the ring buffer. Let's define a consumer that will handle the events:

public class SingleEventPrintConsumer { ... public EventHandler[] getEventHandler() { EventHandler eventHandler = (event, sequence, endOfBatch) -> print(event.getValue(), sequence); return new EventHandler[] { eventHandler }; } private void print(int id, long sequenceId) { logger.info("Id is " + id + " sequence id that was used is " + sequenceId); } }

In our example, the consumer is just printing to a log.

3.4. Constructing the Disruptor

Construct the Disruptor:

ThreadFactory threadFactory = DaemonThreadFactory.INSTANCE; WaitStrategy waitStrategy = new BusySpinWaitStrategy(); Disruptor disruptor = new Disruptor( ValueEvent.EVENT_FACTORY, 16, threadFactory, ProducerType.SINGLE, waitStrategy); 

In the constructor of Disruptor, the following are defined:

  • Event Factory – Responsible for generating objects which will be stored in ring buffer during initialization
  • The size of Ring Buffer – We have defined 16 as the size of the ring buffer. It has to be a power of 2 else it would throw an exception while initialization. This is important because it is easy to perform most of the operations using logical binary operators e.g. mod operation
  • Thread Factory – Factory to create threads for event processors
  • Producer Type – Specifies whether we will have single or multiple producers
  • Waiting strategy – Defines how we would like to handle slow subscriber who doesn't keep up with producer's pace

Connect the consumer handler:

disruptor.handleEventsWith(getEventHandler()); 

It is possible to supply multiple consumers with Disruptor to handle the data that is produced by producer. In the example above, we have just one consumer a.k.a. event handler.

3.5. Starting the Disruptor

To start the Disruptor:

RingBuffer ringBuffer = disruptor.start();

3.6. Producing and Publishing Events

Producers place the data in the ring buffer in a sequence. Producers have to be aware of the next available slot so that they don't overwrite data that is not yet consumed.

Use the RingBuffer from Disruptor for publishing:

for (int eventCount = 0; eventCount < 32; eventCount++) { long sequenceId = ringBuffer.next(); ValueEvent valueEvent = ringBuffer.get(sequenceId); valueEvent.setValue(eventCount); ringBuffer.publish(sequenceId); } 

Тут виробник виробляє та публікує товари послідовно. Тут важливо відзначити, що Disruptor працює подібно до протоколу 2-фазного коміту. Він зчитує нову послідовністьId та публікує. Наступного разу він повинен отримати sequenceId + 1 як наступний sequenceId.

4. Висновок

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

Приклад коду можна знайти у проекті GitHub - це проект на основі Maven, тому його слід легко імпортувати та запускати як є.