Memory ordering examples in lock-free data structures.

Lock-free data structures are gaining popularity in the world of concurrent programming. These data structures are designed to provide high-performance and scalability without the use of traditional locking mechanisms. One important aspect of lock-free data structures is memory ordering, which plays a crucial role in ensuring correctness and synchronization between different threads. In this article, we will explore some examples to understand how memory ordering works in lock-free data structures.

Example 1: Atomic Operations

Atomic operations are a fundamental building block for implementing lock-free data structures. Let’s consider an example where two threads are concurrently incrementing a shared counter variable.

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<int> counter{0};

void incrementCounter() {
    for (int i = 0; i < 10000; ++i) {
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    std::thread t1(incrementCounter);
    std::thread t2(incrementCounter);

    t1.join();
    t2.join();

    std::cout << "Counter value: " << counter.load() << std::endl;

    return 0;
}

In this example, both threads invoke the fetch_add method to increment the counter variable. The std::memory_order_relaxed parameter indicates that there are no specific ordering requirements between these operations. The final value of the counter variable may not be deterministic because of the relaxed memory ordering.

Example 2: Release-Acquire Ordering

Let’s consider another example where we have a lock-free queue data structure. The queue has two methods: enqueue to insert elements and dequeue to remove elements. We need to ensure that the enqueued elements are visible to other threads before they start dequeuing.

#include <atomic>
#include <iostream>
#include <thread>

template <typename T>
class LockFreeQueue {
public:
    void enqueue(const T& item) {
        Node* node = new Node(item);

        node->next.store(head_.load(std::memory_order_relaxed), std::memory_order_relaxed);
        while (!head_.compare_exchange_weak(node->next, node, std::memory_order_release, std::memory_order_relaxed));
    }

    bool dequeue(T& item) {
        Node* tail = tail_.load(std::memory_order_relaxed);
        Node* next = tail->next.load(std::memory_order_acquire);

        if (tail == head_.load(std::memory_order_relaxed)) {
            if (next == nullptr) {
                return false; // Queue is empty
            }
            head_.compare_exchange_weak(tail, next, std::memory_order_acquire, std::memory_order_relaxed);
        }

        item = next->data;
        delete tail;
        return true;
    }

private:
    struct Node {
        T data;
        std::atomic<Node*> next;

        Node(const T& d) : data(d), next(nullptr) {}
    };

    std::atomic<Node*> head_;
    std::atomic<Node*> tail_;
};

int main() {
    LockFreeQueue<int> queue;

    std::thread t1([&]() {
        for (int i = 1; i <= 100; ++i) {
            queue.enqueue(i);
        }
    });

    std::thread t2([&]() {
        int item;
        while (queue.dequeue(item)) {
            std::cout << "Dequeued: " << item << std::endl;
        }
    });

    t1.join();
    t2.join();

    return 0;
}

In this example, the memory ordering is carefully set using std::memory_order_release and std::memory_order_acquire. The enqueue operation ensures that the enqueued elements are visible to other threads before they start dequeuing, while the dequeue operation waits until the previous nodes are fully visible.

Understanding memory ordering in lock-free data structures is crucial for writing correct and efficient concurrent code. By carefully selecting the appropriate memory order, you can ensure proper synchronization and make the most of the performance benefits that lock-free data structures offer.

#concurrency #lockfree