Реалізація безпечних потоків даних LIFO

1. Вступ

У цьому посібнику ми обговоримо різні варіанти реалізації потокової структури даних LIFO .

У структурі даних LIFO елементи вставляються та отримуються відповідно до принципу Last-In-First-Out. Це означає, що останній вставлений елемент отримується першим.

В інформатиці стек - це термін, який використовується для позначення такої структури даних.

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

2. Розуміння стеків

В основному стек повинен реалізовувати такі методи:

  1. push () - додати елемент вгорі
  2. pop () - витягніть і видаліть верхній елемент
  3. peek () - витягніть елемент, не виймаючи з основного контейнера

Як обговорювалося раніше, припустимо, що ми хочемо механізм обробки команд.

У цій системі скасування виконаних команд є важливою особливістю.

Взагалі, всі команди виштовхуються в стек, і тоді операцію скасування можна просто реалізувати:

  • pop (), щоб отримати останню виконану команду
  • викличте метод undo () на вискакуваному об'єкті команди

3. Розуміння безпеки потоків у стеках

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

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

Давайте розглянемо метод нижче з класу Java Collection, ArrayDeque :

public E pollFirst() { int h = head; E result = (E) elements[h]; // ... other book-keeping operations removed, for simplicity head = (h + 1) & (elements.length - 1); return result; }

Щоб пояснити потенційну гонку в наведеному вище коді, припустимо два потоки, що виконують цей код, як зазначено в наступній послідовності:

  • Перший потік виконує третій рядок: встановлює об'єкт результату з елементом в індексі 'head'
  • Другий потік виконує третій рядок: встановлює об'єкт результату з елементом в індексі 'head'
  • Перший потік виконує п'ятий рядок: скидає індекс "head" до наступного елемента масиву підкладки
  • Другий потік виконує п'ятий рядок: скидає індекс "head" до наступного елемента в масиві підкладки

Ой! Тепер обидва варіанти виконання повернуть один і той же результат .

Щоб уникнути таких перегонових умов, у цьому випадку потік не повинен виконувати перший рядок, поки інший потік не закінчить скидання індексу 'head' на п'ятому рядку. Іншими словами, доступ до елемента в індексі "head" і скидання індексу "head" повинні відбуватися атомарно для потоку.

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

4. Захисні нитки стеки за допомогою замків

У цьому розділі ми обговоримо два можливі варіанти конкретних реалізацій потокобезпечного стека.

Зокрема, ми розглянемо стек Java та захищений від потоків ArrayDeque.

Обидва використовують замки для взаємовиключного доступу .

4.1. Використання Java Stack

У Java Collections є застаріла реалізація потокобезпечного стеку , заснована на Vector, який в основному є синхронізованим варіантом ArrayList.

Однак офіційний документ сам пропонує розглянути можливість використання ArrayDeque . Отже, ми не будемо вдаватися в занадто багато деталей.

Although the Java Stack is thread-safe and straight-forward to use, there are major disadvantages with this class:

  • It doesn't have support for setting the initial capacity
  • It uses locks for all the operations. This might hurt the performance for single threaded executions.

4.2. Using ArrayDeque

Using the Deque interface is the most convenient approach for LIFO data structures as it provides all the needed stack operations.ArrayDeque is one such concrete implementation.

Since it's not using locks for the operations, single-threaded executions would work just fine. But for multi-threaded executions, this is problematic.

However, we can implement a synchronization decorator for ArrayDeque. Though this performs similarly to Java Collection Framework's Stack class, the important issue of Stack class, lack of initial capacity setting, is solved.

Let's have a look at this class:

public class DequeBasedSynchronizedStack { // Internal Deque which gets decorated for synchronization. private ArrayDeque dequeStore; public DequeBasedSynchronizedStack(int initialCapacity) { this.dequeStore = new ArrayDeque(initialCapacity); } public DequeBasedSynchronizedStack() { dequeStore = new ArrayDeque(); } public synchronized T pop() { return this.dequeStore.pop(); } public synchronized void push(T element) { this.dequeStore.push(element); } public synchronized T peek() { return this.dequeStore.peek(); } public synchronized int size() { return this.dequeStore.size(); } }

Note that our solution does not implement Deque itself for simplicity, as it contains many more methods.

Also, Guava contains SynchronizedDeque which is a production-ready implementation of a decorated ArrayDequeue.

5. Lock-free Thread-safe Stacks

ConcurrentLinkedDeque is a lock-free implementation of Deque interface. This implementation is completely thread-safe as it uses an efficient lock-free algorithm.

Lock-free implementations are immune to the following issues, unlike lock based ones.

  • Priority inversion – This occurs when the low-priority thread holds the lock needed by a high priority thread. This might cause the high-priority thread to block
  • Deadlocks – This occurs when different threads lock the same set of resources in a different order.

On top of that, Lock-free implementations have some features which make them perfect to use in both single and multi-threaded environments.

  • Для неділених структур даних та однопотокового доступу продуктивність буде на рівні з ArrayDeque
  • Для спільних структур даних продуктивність змінюється залежно від кількості потоків, які одночасно отримують до неї доступ .

І з точки зору зручності він нічим не відрізняється від ArrayDeque, оскільки обидва реалізують інтерфейс Deque .

6. Висновок

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

Крім того, ми проаналізували різні реалізації стеків у рамках колекцій Java та обговорили їхні ефективність та нюанси безпеки потоків.

Як зазвичай, приклади коду можна знайти на GitHub.