Въведение в структурите за данни без заключване с примери за Java

1. Въведение

В този урок ще научим какво представляват неблокиращите структури от данни и защо те са важна алтернатива на базирани на заключване едновременни структури от данни.

Първо, ние ще разгледаме някои термини като няма препятствия , заключване безплатно и чакане без .

Второ, ще разгледаме основните градивни елементи на неблокиращите алгоритми като CAS (сравнение и размяна).

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

2. Заключване срещу глад

Първо, нека разгледаме разликата между блокирана и гладна нишка.

На горната снимка Thread 2 придобива заключване на структурата на данните. Когато Thread 1 се опитва да получи и заключване, трябва да изчака, докато Thread 2 освободи заключването; няма да продължи, преди да успее да получи ключалката. Ако спрем Thread 2, докато държи ключалката, Thread 1 ще трябва да чака завинаги.

Следващата снимка илюстрира гладуването на нишки:

Тук Thread 2 има достъп до структурата на данните, но не получава заключване. Нишка 1 се опитва да получи достъп до структурата на данните едновременно, открива едновременния достъп и се връща незабавно, като информира нишката, че не може да завърши (червено) операцията. След това нишка 1 ще опита отново, докато успее да завърши операцията (зелено).

Предимството на този подход е, че нямаме нужда от ключалка. Това, което обаче може да се случи е, че ако Thread 2 (или други нишки) имат достъп до структурата на данни с висока честота, тогава Thread 1 се нуждае от голям брой опити, докато най-накрая успее. Ние наричаме това гладуване.

По-късно ще видим как операцията за сравнение и размяна постига неблокиращ достъп.

3. Видове неблокиращи структури от данни

Можем да разграничим три нива на неблокиращи структури от данни.

3.1. Без препятствия

Свободата на препятствия е най-слабата форма на неблокираща структура на данните. Тук изискваме да се гарантира, че нишката ще продължи, само ако всички останали нишки са спрени .

По-точно, нишката няма да продължи да гладува, ако всички останали нишки са спрени. Това е различно от използването на ключалки в този смисъл, че ако нишката чакаше заключване и нишката, която държи заключването, е спряна, чакащата нишка ще чака вечно.

3.2. Без заключване

Структурата на данните осигурява свобода на заключване, ако по всяко време поне една нишка може да продължи . Всички останали нишки може да са гладни. Разликата със свободата на препятствие е, че има поне една негладуваща нишка, дори ако нито една нишка не е окачена.

3.3. Без изчакване

Структурата на данните е без изчакване, ако е без заключване и всяка нишка е гарантирана, че ще продължи след краен брой стъпки, т.е. нишките няма да гладуват за „неразумно голям“ брой стъпки.

3.4. Обобщение

Нека обобщим тези дефиниции в графично представяне:

Първата част на изображението показва свобода на препятствия, тъй като нишка 1 (горна нишка) може да продължи (зелена стрелка) веднага щом спрем останалите нишки (в долната част в жълто).

В средната част е показана свобода на заключване. Поне Thread 1 може да прогресира, докато други може да гладуват (червена стрелка).

Последната част показва свобода на изчакване. Тук гарантираме, че Нишка 1 може да продължи (зелена стрелка) след определен период от глад (червени стрелки).

4. Неблокиращи примитиви

В този раздел ще разгледаме три основни операции, които ни помагат да изградим операции без заключване върху структури от данни.

4.1. Сравнете и разменете

Една от основните операции, използвани за избягване на заключване, е операцията сравнение и размяна (CAS) .

Идеята на compare-and-swap е, че променлива се актуализира само ако все още има същата стойност, както към момента, когато бяхме извлекли стойността на променливата от основната памет. CAS е атомна операция, което означава, че извличането и актуализирането заедно са една единствена операция :

Тук и двете нишки извличат стойността 3 от основната памет. Нишката 2 успява (зелена) и актуализира променливата на 8. Тъй като първият CAS от нишка 1 очаква стойността да е все още 3, CAS се проваля (червено). Следователно, Thread 1 извлича стойността отново и вторият CAS успява.

Важното тук е, че CAS не получава заключване на структурата на данните, но връща true, ако актуализацията е била успешна, в противен случай връща false .

Следният кодов фрагмент описва как работи CAS:

volatile int value; boolean cas(int expectedValue, int newValue) { if(value == expectedValue) { value = newValue; return true; } return false; }

We only update the value with the new value if it still has the expected value, otherwise, it returns false. The following code snippet shows how CAS can be called:

void testCas() { int v = value; int x = v + 1; while(!cas(v, x)) { v = value; x = v + 1; } }

We attempt to update our value until the CAS operation succeeds, that is, returns true.

However, it's possible that a thread gets stuck in starvation. That can happen if other threads perform a CAS on the same variable at the same time, so the operation will never succeed for a particular thread (or will take an unreasonable amount of time to succeed). Still, if the compare-and-swap fails, we know that another thread has succeeded, thus we also ensure global progress, as required for lock-freedom.

It's important to note that the hardware should support compare-and-swap, to make it a truly atomic operation without the use of locking.

Java provides an implementation of compare-and-swap in the class sun.misc.Unsafe. However, in most cases, we should not use this class directly, but Atomic variables instead.

Furthermore, compare-and-swap does not prevent the A-B-A problem. We'll look at that in the following section.

4.2. Load-Link/Store-Conditional

An alternative to compare-and-swap is load-link/store-conditional. Let's first revisit compare-and-swap. As we've seen before, CAS only updates the value if the value in the main memory is still the value we expect it to be.

However, CAS also succeeds if the value had changed, and, in the meantime, has changed back to its previous value.

The below image illustrates this situation:

Both, thread 1 and Thread 2 read the value of the variable, which is 3. Then Thread 2 performs a CAS, which succeeds in setting the variable to 8. Then again, Thread 2 performs a CAS to set the variable back to 3, which succeeds as well. Finally, Thread 1 performs a CAS, expecting the value 3, and succeeds as well, even though the value of our variable was modified twice in between.

This is called the A-B-A problem. This behavior might not be a problem depending on the use-case, of course. However, it might not be desired for others. Java provides an implementation of load-link/store-conditional with the AtomicStampedReference class.

4.3. Fetch and Add

Another alternative is fetch-and-add. This operation increments the variable in the main memory by a given value. Again, the important point is that the operation happens atomically, which means no other thread can interfere.

Java provides an implementation of fetch-and-add in its atomic classes. Examples are AtomicInteger.incrementAndGet(), which increments the value and returns the new value; and AtomicInteger.getAndIncrement(), which returns the old value and then increments the value.

5. Accessing a Linked Queue from Multiple Threads

To better understand the problem of two (or more) threads accessing a queue simultaneously, let's look at a linked queue and two threads trying to add an element concurrently.

The queue we'll look at is a doubly-linked FIFO queue where we add new elements after the last element (L) and the variable tail points to that last element:

To add a new element, the threads need to perform three steps: 1) create the new elements (N and M), with the pointer to the next element set to null; 2) have the reference to the previous element point to L and the reference to the next element of L point to N (M, respectively). 3) Have tail point to N (M, respectively):

What can go wrong if the two threads perform these steps simultaneously? If the steps in the above picture execute in the order ABCD or ACBD, L, as well as tail, will point to M. N will remain disconnected from the queue.

If the steps execute in the order ACDB, tail will point to N, while L will point to M, which will cause an inconsistency in the queue:

Of course, one way to solve this problem is to have one thread acquire a lock on the queue. The solution we'll look at in the following chapter will solve the problem with the help of a lock-free operation by using the CAS operation we've seen earlier.

6. A Non-Blocking Queue in Java

Let's look at a basic lock-free queue in Java. First, let's look at the class members and the constructor:

public class NonBlockingQueue { private final AtomicReference
    
      head, tail; private final AtomicInteger size; public NonBlockingQueue() { head = new AtomicReference(null); tail = new AtomicReference(null); size = new AtomicInteger(); size.set(0); } }
    

The important part is the declaration of the head and tail references as AtomicReferences, which ensures that any update on these references is an atomic operation. This data type in Java implements the necessary compare-and-swap operation.

Next, let's look at the implementation of the Node class:

private class Node { private volatile T value; private volatile Node next; private volatile Node previous; public Node(T value) { this.value = value; this.next = null; } // getters and setters }

Here, the important part is to declare the references to the previous and next node as volatile. This ensures that we update these references always in the main memory (thus are directly visible to all threads). The same for the actual node value.

6.1. Lock-Free add

Our lock-free add operation will make sure that we add the new element at the tail and won't be disconnected from the queue, even if multiple threads want to add a new element concurrently:

public void add(T element) { if (element == null) { throw new NullPointerException(); } Node node = new Node(element); Node currentTail; do { currentTail = tail.get(); node.setPrevious(currentTail); } while(!tail.compareAndSet(currentTail, node)); if(node.previous != null) { node.previous.next = node; } head.compareAndSet(null, node); // for inserting the first element size.incrementAndGet(); }

The essential part to pay attention to is the highlighted line. We attempt to add the new node to the queue until the CAS operation succeeds to update the tail, which must still be the same tail to which we appended the new node.

6.2. Lock-Free get

Similar to the add-operation, the lock-free get-operation will make sure that we return the last element and move the tail to the current position:

public T get() { if(head.get() == null) { throw new NoSuchElementException(); } Node currentHead; Node nextNode; do { currentHead = head.get(); nextNode = currentHead.getNext(); } while(!head.compareAndSet(currentHead, nextNode)); size.decrementAndGet(); return currentHead.getValue(); }

Again, the essential part to pay attention to is the highlighted line. The CAS operation ensures that we move the current head only if no other node has been removed in the meantime.

Java already provides an implementation of a non-blocking queue, the ConcurrentLinkedQueue. It's an implementation of the lock-free queue from M. Michael and L. Scott described in this paper. An interesting side-note here is that the Java documentation states that it's a wait-free queue, where it's actually lock-free. The Java 8 documentation correctly calls the implementation lock-free.

7. Wait-Free Queues

As we've seen, the above implementation is lock-free, however, not wait-free. The while loops in both the add and get method can potentially loop for a long time (or, though unlikely, forever) if there are many threads accessing our queue.

How can we achieve wait-freedom? The implementation of wait-free algorithms, in general, is quite tricky. We refer the interested reader to this paper, which describes a wait-free queue in detail. In this article, let's look at the basic idea of how we can approach a wait-free implementation of a queue.

A wait-free queue requires that every thread makes guaranteed progress (after a finite number of steps). In other words, the while loops in our add and get methods must succeed after a certain number of steps.

In order to achieve that, we assign a helper thread to every thread. If that helper thread succeeds to add an element to the queue, it will help the other thread to insert its element before inserting another element.

As the helper thread has a helper itself, and, down the whole list of threads, every thread has a helper, we can guarantee that a thread succeeds the insertion latest after every thread has done one insertion. The following figure illustrates the idea:

Of course, things become more complicated when we can add or remove threads dynamically.

8. Conclusion

В тази статия видяхме основите на неблокиращите структури от данни. Обяснихме различните нива и основни операции като сравнение и размяна .

След това разгледахме основно изпълнение на опашка без заключване в Java. Накрая очертахме идеята за това как да постигнем свобода на изчакване .

Пълният изходен код за всички примери в тази статия е достъпен в GitHub.