Mutexes and Semaphores on Linux, C++, x86, ARM, and DPDK

Published 2026-07-26 · Markdown source

A practical implementation guide covering portable C++ synchronization, Linux futexes, Linux-kernel locking, architecture-specific atomic behavior, and DPDK data-plane synchronization.

Combined Markdown edition: July 26, 2026

Scope: The examples are intended for learning, prototyping, and architecture discussions. For production application code, prefer standard-library, POSIX, Linux-kernel, or DPDK primitives unless a custom implementation is genuinely required.

>

Version note: DPDK APIs and build options vary by release. Verify names and availability against the headers and documentation installed with your target DPDK version.
TOC mode: This edition uses only plain Markdown headings and links. No inline HTML anchors are required. The webpage renderer must generate IDs for headings.

Contents

  1. Synchronization concepts
  2. Portable Cpp implementation
  3. Linux futex foundation
  4. Futex based mutex
  5. Futex based counting semaphore
  6. Using the futex primitives
  7. Memory ordering rationale
  8. X86 and ARM instruction mapping
  9. Production limitations of the educational futex code
  10. Linux kernel implementation
  11. DPDK synchronization model
  12. Minimal DPDK spin lock example
  13. Cpp wrappers for DPDK locks
  14. DPDK polling counting semaphore
  15. Using the DPDK primitives
  16. Runnable DPDK EAL lcore example
  17. Building and running the DPDK example
  18. DPDK behavior on X86 and ARM
  19. Choosing the appropriate primitive
  20. Comparison matrix
  21. References

---

Synchronization concepts

Mutex

A mutex protects a critical section so that only one execution context owns the lock at a time.

Important properties:

Counting semaphore

A counting semaphore represents a number of permits. Acquiring consumes one permit; releasing returns one or more permits.

Important properties:

Sleeping versus polling synchronization

BehaviorSleeping primitivePolling primitive
Waiter actionParks in the OS or schedulerRepeatedly checks shared state
CPU consumption while waitingLowPotentially one full CPU
Typical useGeneral-purpose threads, control plane, long waitsDedicated data-plane cores, very short waits
Examplesstd::mutex, futex-backed locks, POSIX semaphoresrte_spinlock_t, ticket locks, custom DPDK polling semaphore

---

Portable Cpp implementation

For application code, use std::mutex and, with C++20 or later, std::counting_semaphore. The same source can be compiled for Linux/x86-64 and Linux/AArch64; the standard library and compiler select the platform implementation.


#include <mutex>
#include <semaphore>

std::mutex data_mutex;

// At most four operations may execute concurrently.
std::counting_semaphore<64> available_slots{4};

int shared_value = 0;

void worker()
{
    available_slots.acquire();

    {
        std::lock_guard<std::mutex> guard(data_mutex);
        ++shared_value;
    }

    available_slots.release();
}

Build on x86-64:


g++ -std=c++20 -O2 -pthread example.cpp -o example

Cross-compile for AArch64:


aarch64-linux-gnu-g++ -std=c++20 -O2 -pthread example.cpp -o example-arm64

Why this is normally the preferred implementation

---

Linux futex foundation

The following is Linux user-space code, not Linux-kernel code.

A futex is based on a 32-bit userspace word. The uncontended path executes with ordinary atomic instructions. A thread invokes FUTEX_WAIT only when it may need to sleep, and an unlock or semaphore release invokes FUTEX_WAKE when sleepers may need to be resumed.

The futex wait operation compares the word with an expected value before blocking. If the value has already changed, the kernel returns without sleeping. This compare-and-block behavior is what prevents a wake-up from being lost between a userspace check and entry into the kernel.

Futex waits can return because of a wake operation, a value mismatch, a signal, or a spurious event. The userspace condition must therefore always be rechecked in a loop.

Shared futex support code


#include <atomic>
#include <cerrno>
#include <climits>
#include <cstdint>
#include <cstdlib>

#include <linux/futex.h>
#include <sys/syscall.h>
#include <unistd.h>

/*
 * A futex is always a 32-bit word on Linux, including 64-bit systems.
 *
 * atomic_ref gives C++ atomic semantics to the word while leaving the
 * underlying object suitable for the futex syscall.
 */
struct FutexWord {
    static_assert(
        std::atomic_ref<std::uint32_t>::is_always_lock_free,
        "This implementation requires lock-free 32-bit atomics");

    explicit constexpr FutexWord(std::uint32_t initial = 0) noexcept
        : value(initial)
    {
    }

    alignas(std::atomic_ref<std::uint32_t>::required_alignment)
        std::uint32_t value;

    [[nodiscard]]
    std::atomic_ref<std::uint32_t> atomic() noexcept
    {
        return std::atomic_ref<std::uint32_t>(value);
    }
};

namespace detail {

int futex_wait(FutexWord& word, std::uint32_t expected) noexcept
{
    return static_cast<int>(
        ::syscall(
            SYS_futex,
            &word.value,
            FUTEX_WAIT_PRIVATE,
            expected,
            nullptr,
            nullptr,
            0));
}

int futex_wake(FutexWord& word, int number_to_wake) noexcept
{
    return static_cast<int>(
        ::syscall(
            SYS_futex,
            &word.value,
            FUTEX_WAKE_PRIVATE,
            number_to_wake,
            nullptr,
            nullptr,
            0));
}

/*
 * Return when:
 *
 * - another thread wakes us;
 * - the futex value changed before we entered the kernel; or
 * - a spurious wake-up occurred.
 *
 * The caller must always recheck its userspace condition.
 */
void wait_while_equal(
    FutexWord& word,
    std::uint32_t expected) noexcept
{
    for (;;) {
        const int result = futex_wait(word, expected);

        if (result == 0) {
            return;
        }

        if (errno == EAGAIN) {
            // Value no longer equals expected.
            return;
        }

        if (errno == EINTR) {
            // Interrupted by a signal; retry the wait operation.
            continue;
        }

        // EFAULT, EINVAL, and similar errors indicate a programming error.
        std::abort();
    }
}

void wake(FutexWord& word, int number_to_wake) noexcept
{
    if (futex_wake(word, number_to_wake) < 0) {
        std::abort();
    }
}

} // namespace detail

FUTEX_WAIT_PRIVATE and FUTEX_WAKE_PRIVATE are suitable only when all participants are threads in the same process. For interprocess synchronization, place the futex word in shared memory and use the corresponding non-private futex operations.

---

Futex based mutex

The mutex uses this state machine:


0 = unlocked
1 = locked, with no known sleeping waiters
2 = locked, with possible sleeping waiters

class FutexMutex {
public:
    FutexMutex() = default;

    FutexMutex(const FutexMutex&) = delete;
    FutexMutex& operator=(const FutexMutex&) = delete;

    void lock() noexcept
    {
        auto state = state_.atomic();

        /*
         * Fast path:
         *
         * Try to change 0 -> 1 entirely in userspace.
         */
        std::uint32_t expected = 0;

        if (state.compare_exchange_strong(
                expected,
                1,
                std::memory_order_acquire,
                std::memory_order_relaxed)) {
            return;
        }

        /*
         * Slow path:
         *
         * Set state to 2, indicating that a sleeping waiter may exist.
         */
        for (;;) {
            if (expected != 2) {
                expected =
                    state.exchange(2, std::memory_order_acquire);
            }

            /*
             * exchange() returned 0, so this thread changed 0 -> 2
             * and acquired the mutex.
             */
            if (expected == 0) {
                return;
            }

            /*
             * Sleep only while the state still equals 2.
             *
             * If an unlock happened just before this syscall, the
             * kernel sees a value other than 2 and returns EAGAIN
             * rather than putting us to sleep.
             */
            detail::wait_while_equal(state_, 2);

            /*
             * Try to acquire the mutex while keeping it in the
             * contended state. Keeping state == 2 means that the
             * next unlock will wake another waiter.
             */
            expected = 0;

            if (state.compare_exchange_strong(
                    expected,
                    2,
                    std::memory_order_acquire,
                    std::memory_order_relaxed)) {
                return;
            }
        }
    }

    [[nodiscard]]
    bool try_lock() noexcept
    {
        auto state = state_.atomic();
        std::uint32_t expected = 0;

        return state.compare_exchange_strong(
            expected,
            1,
            std::memory_order_acquire,
            std::memory_order_relaxed);
    }

    void unlock() noexcept
    {
        auto state = state_.atomic();

        /*
         * Uncontended case:
         *
         *     1 -> 0
         *
         * No syscall is needed.
         *
         * Contended case:
         *
         *     2 -> 1 -> 0
         *
         * Then wake one possible waiter.
         */
        if (state.fetch_sub(1, std::memory_order_release) == 1) {
            return;
        }

        state.store(0, std::memory_order_release);
        detail::wake(state_, 1);
    }

private:
    FutexWord state_{0};
};

Mutex operation summary

  1. Attempt an atomic 0 -> 1 transition.
  2. If successful, enter the critical section without a syscall.
  3. If the mutex is busy, mark it contended with state 2.
  4. Call FUTEX_WAIT while state remains 2.
  5. After waking, retry acquisition.
  6. On unlock, use the fast userspace path when state was 1.
  7. When state indicates contention, publish state 0 and wake one waiter.

---

Futex based counting semaphore

This version stores the number of available permits in the futex word.


class FutexSemaphore {
public:
    explicit FutexSemaphore(std::uint32_t initial_count) noexcept
        : count_(initial_count)
    {
    }

    FutexSemaphore(const FutexSemaphore&) = delete;
    FutexSemaphore& operator=(const FutexSemaphore&) = delete;

    void acquire() noexcept
    {
        auto count = count_.atomic();

        std::uint32_t current =
            count.load(std::memory_order_relaxed);

        for (;;) {
            /*
             * Try to consume one available permit.
             */
            while (current != 0) {
                if (count.compare_exchange_weak(
                        current,
                        current - 1,
                        std::memory_order_acquire,
                        std::memory_order_relaxed)) {
                    return;
                }

                /*
                 * On CAS failure, current is automatically replaced
                 * with the value that was actually observed.
                 */
            }

            /*
             * There are no permits. Sleep while count is still zero.
             */
            detail::wait_while_equal(count_, 0);

            /*
             * Recheck after every wake-up.
             */
            current = count.load(std::memory_order_relaxed);
        }
    }

    [[nodiscard]]
    bool try_acquire() noexcept
    {
        auto count = count_.atomic();

        std::uint32_t current =
            count.load(std::memory_order_relaxed);

        while (current != 0) {
            if (count.compare_exchange_weak(
                    current,
                    current - 1,
                    std::memory_order_acquire,
                    std::memory_order_relaxed)) {
                return true;
            }
        }

        return false;
    }

    /*
     * The caller must ensure that adding update does not overflow
     * the 32-bit permit count.
     */
    void release(std::uint32_t update = 1) noexcept
    {
        if (update == 0) {
            return;
        }

        if (update > static_cast<std::uint32_t>(INT_MAX)) {
            std::abort();
        }

        count_.atomic().fetch_add(
            update,
            std::memory_order_release);

        /*
         * Correctness-first implementation: wake at most one waiter
         * for each newly added permit.
         *
         * This deliberately makes a syscall on every release.
         * Production implementations generally track waiter state
         * to avoid a syscall when no thread is asleep.
         */
        detail::wake(count_, static_cast<int>(update));
    }

private:
    FutexWord count_;
};

Semaphore operation summary

  1. Read the permit count.
  2. If the count is nonzero, atomically decrement it.
  3. If the count is zero, wait with FUTEX_WAIT while it remains zero.
  4. Recheck the count after every return from the syscall.
  5. A release atomically increments the count and wakes up to the number of newly available permits.

---

Using the futex primitives


FutexMutex mutex;
FutexSemaphore slots{4};

int shared_value = 0;

void operation()
{
    slots.acquire();

    mutex.lock();
    ++shared_value;
    mutex.unlock();

    slots.release();
}

A safer C++ interface can add a lock-guard-compatible wrapper or use std::lock_guard<FutexMutex> directly because FutexMutex supplies lock() and unlock():


#include <mutex>

FutexMutex mutex;
int shared_value = 0;

void guarded_operation()
{
    std::lock_guard<FutexMutex> guard(mutex);
    ++shared_value;
}

---

Memory ordering rationale

Mutex acquisition

Successful mutex acquisition uses std::memory_order_acquire.

Acquire semantics prevent reads and writes in the protected critical section from being reordered before the successful lock operation.

Mutex release

Unlock uses std::memory_order_release.

Release semantics publish writes performed in the critical section before another execution context successfully acquires the mutex.

Semaphore acquisition and release

Why x86 and ARM differ internally

The C++ memory model is architecture-independent, but the generated instructions differ:

The source-level memory orders must still be correct even when the x86 assembly appears to require fewer barriers.

---

X86 and ARM instruction mapping

The application source does not need separate x86 and ARM implementations. The compiler lowers C++ atomic operations to the appropriate target instructions.

Representative mapping

C++ atomic operationx86-64AArch64 without LSEAArch64 with LSE
Acquire compare-and-exchangelock cmpxchgldaxr / stxr retry loopcasa
Acquire exchangexchgldaxr / stxr retry loopswpa
Release storeOrdinary mov storestlrstlr
Release fetch-addlock xaddldxr / stlxr retry loopldaddl

The exact output depends on compiler version, optimization level, architecture flags, surrounding code, and whether the target guarantees Large System Extensions (LSE).

x86-64 mutex fast path

A representative 0 -> 1 compare-and-exchange can look like this:


xor     eax, eax              ; expected = 0
mov     edx, 1                ; desired = 1
lock cmpxchg dword ptr [rdi], edx
sete    al                    ; return whether comparison succeeded

A release unlock can be an ordinary store:


mov     dword ptr [rdi], 0

The locked read-modify-write instruction provides the atomicity and ordering needed for acquisition. x86's memory model permits a plain aligned store for the release operation.

Baseline AArch64 compare-and-exchange

Without LSE, a compiler normally uses an exclusive load/store retry loop:


retry:
    ldaxr   w1, [x0]          // load-exclusive with acquire semantics
    cbnz    w1, failed

    mov     w2, #1
    stxr    w3, w2, [x0]      // store only if exclusive monitor is valid
    cbnz    w3, retry

    mov     w0, #1
    ret

failed:
    clrex
    mov     w0, #0
    ret

Release unlock:


stlr    wzr, [x0]             // store-release zero
ret

AArch64 with LSE

With ARMv8.1-A LSE, the lock operation can use a hardware compare-and-swap instruction:


mov     w1, #0
mov     w2, #1
casa    w1, w2, [x0]          // compare-and-swap, acquire semantics

The exact operand behavior and register allocation depend on compiler output, but the important distinction is that LSE supplies single-instruction atomic read-modify-write operations instead of a software retry loop built from exclusive accesses.

ARMv7

A 32-bit ARMv7 implementation generally uses LDREX and STREX retry loops together with appropriate DMB barriers because ARMv7 does not provide AArch64's LDAXR and STLR forms.

Conceptually:


retry:
    ldrex   r1, [r0]
    cmp     r1, #0
    bne     failed

    mov     r2, #1
    strex   r3, r2, [r0]
    cmp     r3, #0
    bne     retry

    dmb
    mov     r0, #1
    bx      lr

failed:
    clrex
    mov     r0, #0
    bx      lr

This is conceptual assembly; compiler-generated sequences and barrier placement depend on the operation and requested memory order.

---

Production limitations of the educational futex code

The futex implementations demonstrate the essential algorithms but omit production features:

FUTEX_WAKE does not guarantee FIFO selection of waiters. Neither the educational mutex nor the semaphore should be assumed to be fair.

For production user-space code, prefer one of the following:


std::mutex
std::counting_semaphore
pthread_mutex_t
sem_t

For special requirements, consider the appropriate established API:

---

Linux kernel implementation

Inside the Linux kernel, do not invoke futex syscalls. Use the kernel locking APIs.


#include <linux/mutex.h>
#include <linux/semaphore.h>

static DEFINE_MUTEX(data_lock);
static struct semaphore available_slots;

static int initialize_component(void)
{
    sema_init(&available_slots, 4);
    return 0;
}

static void do_work(void)
{
    down(&available_slots);

    mutex_lock(&data_lock);
    /* Access protected kernel data. */
    mutex_unlock(&data_lock);

    up(&available_slots);
}

Kernel mutex

A Linux-kernel mutex has owner semantics and is intended for mutual exclusion. Its implementation can combine:

Kernel semaphore

A Linux-kernel semaphore is a counting primitive without mutex ownership semantics.

Context restriction

Kernel mutexes and semaphores are sleeping locks. Do not acquire them in interrupt context, with preemption constraints that prohibit sleeping, or while holding a spin lock that requires an atomic context.

In non-sleepable kernel contexts, the relevant primitive is normally a spin lock or another context-specific synchronization mechanism.

---

DPDK synchronization model

DPDK applications commonly dedicate logical cores to poll-mode processing. For very short data-plane critical sections, DPDK provides busy-wait synchronization primitives such as:

A DPDK spin lock is not a sleeping Linux mutex. A waiting lcore continues polling until the lock becomes available.

DPDK and semaphores

The DPDK API is centered on data-plane polling and does not necessarily provide a general-purpose semaphore abstraction equivalent to std::counting_semaphore in every release. The implementation below is therefore an application-level polling counting semaphore built from DPDK atomic and wait/pause facilities.

Use such a polling semaphore only when:

Use a sleeping OS primitive for control-plane threads, long waits, blocking I/O, or oversubscribed systems.

---

Minimal DPDK spin lock example


#include <rte_spinlock.h>

static rte_spinlock_t data_lock = RTE_SPINLOCK_INITIALIZER;
static unsigned int shared_value;

static void update_shared_value(void)
{
    rte_spinlock_lock(&data_lock);

    ++shared_value;

    rte_spinlock_unlock(&data_lock);
}

The uncontended lock operation is an architecture-appropriate atomic acquisition. Under contention, the waiter repeatedly observes the lock and executes a pause or wait hint. Unlock publishes the protected writes with release semantics.

---

Cpp wrappers for DPDK locks

The following wrappers satisfy the C++ BasicLockable interface and can be used with std::lock_guard.


// dpdk_sync.hpp
#pragma once

#include <cassert>
#include <cstdint>

#include <rte_common.h>
#include <rte_pause.h>
#include <rte_spinlock.h>
#include <rte_stdatomic.h>
#include <rte_ticketlock.h>

/*
 * Fast, non-FIFO DPDK spin mutex.
 *
 * Suitable for:
 *   - very short critical sections;
 *   - dedicated EAL lcores;
 *   - low or moderate contention.
 *
 * It does not put the calling thread to sleep.
 */
class alignas(RTE_CACHE_LINE_SIZE) DpdkSpinMutex final {
public:
    DpdkSpinMutex() noexcept
    {
        rte_spinlock_init(&lock_);
    }

    DpdkSpinMutex(const DpdkSpinMutex&) = delete;
    DpdkSpinMutex& operator=(const DpdkSpinMutex&) = delete;

    void lock() noexcept
    {
        rte_spinlock_lock(&lock_);
    }

    [[nodiscard]]
    bool try_lock() noexcept
    {
        return rte_spinlock_trylock(&lock_) != 0;
    }

    void unlock() noexcept
    {
        rte_spinlock_unlock(&lock_);
    }

private:
    rte_spinlock_t lock_;
};

/*
 * Fairer DPDK mutex.
 *
 * Waiting lcores receive tickets and acquire the lock in ticket order.
 * This can reduce starvation but may cost more under contention.
 */
class alignas(RTE_CACHE_LINE_SIZE) DpdkTicketMutex final {
public:
    DpdkTicketMutex() noexcept
    {
        rte_ticketlock_init(&lock_);
    }

    DpdkTicketMutex(const DpdkTicketMutex&) = delete;
    DpdkTicketMutex& operator=(const DpdkTicketMutex&) = delete;

    void lock() noexcept
    {
        rte_ticketlock_lock(&lock_);
    }

    [[nodiscard]]
    bool try_lock() noexcept
    {
        return rte_ticketlock_trylock(&lock_) != 0;
    }

    void unlock() noexcept
    {
        rte_ticketlock_unlock(&lock_);
    }

private:
    rte_ticketlock_t lock_;
};

Using a wrapper with std::lock_guard


#include <mutex>

DpdkSpinMutex mutex;
int shared_value = 0;

void update()
{
    std::lock_guard<DpdkSpinMutex> guard(mutex);
    ++shared_value;
}

Cache-line alignment

Aligning lock objects to RTE_CACHE_LINE_SIZE can reduce false sharing when adjacent data is frequently modified by different lcores. Alignment alone does not fix contention on the lock's own cache line; it prevents unrelated objects from sharing that line.

Optional MCS lock for heavy contention

An MCS lock gives each waiter its own node and makes it spin on local state, reducing shared-cache-line bouncing. The node must remain alive for the complete lock hold, and each concurrent acquirer needs a distinct node.


#include <rte_mcslock.h>

static rte_mcslock_t* global_lock = nullptr;

void critical_section()
{
    // Each thread or lcore must use its own node.
    rte_mcslock_t local_node{};

    rte_mcslock_lock(&global_lock, &local_node);

    /* Very short protected work. */

    rte_mcslock_unlock(&global_lock, &local_node);
}

---

DPDK polling counting semaphore

This semaphore stores the number of available permits in a DPDK atomic variable.


// Add this to dpdk_sync.hpp

class alignas(RTE_CACHE_LINE_SIZE) DpdkCountingSemaphore final {
public:
    /*
     * Preconditions:
     *
     *   maximum > 0
     *   initial <= maximum
     */
    DpdkCountingSemaphore(
        std::uint32_t initial,
        std::uint32_t maximum) noexcept
        : maximum_(maximum)
    {
        assert(maximum_ > 0);
        assert(initial <= maximum_);

        rte_atomic_store_explicit(
            &count_,
            initial,
            rte_memory_order_relaxed);
    }

    DpdkCountingSemaphore(const DpdkCountingSemaphore&) = delete;
    DpdkCountingSemaphore&
    operator=(const DpdkCountingSemaphore&) = delete;

    /*
     * Consume one permit.
     *
     * This is a polling operation. It does not invoke futex(), sleep(),
     * or any other Linux blocking syscall.
     */
    void acquire() noexcept
    {
        for (;;) {
            if (try_acquire()) {
                return;
            }

            /*
             * Wait until count != 0.
             *
             * The mask covers the entire 32-bit value. The wait uses a
             * relaxed load because the successful CAS below performs
             * the actual acquire operation.
             */
            RTE_WAIT_UNTIL_MASKED(
                &count_,
                UINT32_MAX,
                !=,
                0u,
                rte_memory_order_relaxed);
        }
    }

    /*
     * Try to consume one permit without waiting.
     */
    [[nodiscard]]
    bool try_acquire() noexcept
    {
        std::uint32_t observed =
            rte_atomic_load_explicit(
                &count_,
                rte_memory_order_relaxed);

        while (observed != 0) {
            if (rte_atomic_compare_exchange_weak_explicit(
                    &count_,
                    &observed,
                    observed - 1,
                    rte_memory_order_acquire,
                    rte_memory_order_relaxed)) {
                return true;
            }

            /*
             * On CAS failure, observed is updated with the value that
             * is currently stored in count_.
             */
        }

        return false;
    }

    /*
     * Return one or more permits.
     *
     * Returns false if the update would exceed maximum_. Such a failure
     * normally means the caller released more permits than it acquired.
     */
    [[nodiscard]]
    bool release(std::uint32_t update = 1) noexcept
    {
        if (update == 0) {
            return true;
        }

        if (update > maximum_) {
            return false;
        }

        std::uint32_t observed =
            rte_atomic_load_explicit(
                &count_,
                rte_memory_order_relaxed);

        for (;;) {
            if (observed > maximum_ - update) {
                return false;
            }

            if (rte_atomic_compare_exchange_weak_explicit(
                    &count_,
                    &observed,
                    observed + update,
                    rte_memory_order_release,
                    rte_memory_order_relaxed)) {
                return true;
            }
        }
    }

    /*
     * Diagnostic snapshot only. The result can become stale immediately.
     */
    [[nodiscard]]
    std::uint32_t available() const noexcept
    {
        return rte_atomic_load_explicit(
            &count_,
            rte_memory_order_relaxed);
    }

    [[nodiscard]]
    std::uint32_t maximum() const noexcept
    {
        return maximum_;
    }

private:
    RTE_ATOMIC(std::uint32_t) count_;
    const std::uint32_t maximum_;
};

Why the loop is required

Multiple lcores can observe the same nonzero count. Only one can successfully perform a particular decrement. The others receive an updated observed value from the failed compare-and-exchange and retry.

Why the wait is still polling

RTE_WAIT_UNTIL_MASKED repeatedly tests an atomic condition while invoking the target architecture's pause or wait mechanism. It does not turn this abstraction into a Linux sleeping semaphore.

Fairness

The semaphore does not provide FIFO fairness. A lcore that begins retrying at the right moment can acquire a newly released permit before an older waiter.

---

Using the DPDK primitives


#include "dpdk_sync.hpp"

#include <cstdlib>
#include <mutex>

DpdkSpinMutex counter_mutex;

// Allow at most four lcores into the bounded operation.
DpdkCountingSemaphore operation_slots{4, 4};

std::uint64_t shared_counter = 0;

void process_operation()
{
    operation_slots.acquire();

    /*
     * Only four lcores can be in this overall operation concurrently.
     * The mutex separately protects the shared counter.
     */
    {
        std::lock_guard<DpdkSpinMutex> guard(counter_mutex);
        ++shared_counter;
    }

    if (!operation_slots.release()) {
        /*
         * A false return indicates an unbalanced release or another
         * programming error.
         */
        std::abort();
    }
}

RAII permit guard

A permit guard makes release exception-safe and reduces the chance of an unbalanced acquire/release pair.


class DpdkSemaphorePermit final {
public:
    explicit DpdkSemaphorePermit(DpdkCountingSemaphore& semaphore) noexcept
        : semaphore_(&semaphore)
    {
        semaphore_->acquire();
    }

    DpdkSemaphorePermit(const DpdkSemaphorePermit&) = delete;
    DpdkSemaphorePermit& operator=(const DpdkSemaphorePermit&) = delete;

    ~DpdkSemaphorePermit()
    {
        if (semaphore_ != nullptr) {
            const bool released = semaphore_->release();
            if (!released) {
                std::abort();
            }
        }
    }

    void release_early() noexcept
    {
        if (semaphore_ == nullptr) {
            return;
        }

        const bool released = semaphore_->release();
        if (!released) {
            std::abort();
        }

        semaphore_ = nullptr;
    }

private:
    DpdkCountingSemaphore* semaphore_;
};

Usage:


void process_with_raii()
{
    DpdkSemaphorePermit permit(operation_slots);

    std::lock_guard<DpdkSpinMutex> guard(counter_mutex);
    ++shared_counter;
}

---

Runnable DPDK EAL lcore example


// dpdk_sync.cpp

#include "dpdk_sync.hpp"

#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <mutex>

#include <rte_debug.h>
#include <rte_eal.h>
#include <rte_launch.h>
#include <rte_lcore.h>

namespace {

constexpr std::uint32_t iterations_per_lcore = 100'000;

/*
 * Only two lcores may hold a semaphore permit concurrently.
 */
DpdkCountingSemaphore slots{2, 2};

/*
 * The counter itself remains protected by a mutex.
 */
DpdkSpinMutex counter_mutex;
std::uint64_t counter = 0;

int worker_main(void*)
{
    for (std::uint32_t i = 0; i < iterations_per_lcore; ++i) {
        slots.acquire();

        {
            std::lock_guard<DpdkSpinMutex> guard(counter_mutex);
            ++counter;
        }

        if (!slots.release()) {
            std::abort();
        }
    }

    return 0;
}

} // namespace

int main(int argc, char** argv)
{
    const int eal_result = rte_eal_init(argc, argv);

    if (eal_result < 0) {
        rte_exit(EXIT_FAILURE, "Failed to initialize DPDK EAL\n");
    }

    unsigned int lcore_id;

    /*
     * rte_eal_remote_launch() is called from the main lcore and launches
     * worker_main() on every enabled worker lcore.
     */
    RTE_LCORE_FOREACH_WORKER(lcore_id) {
        const int result =
            rte_eal_remote_launch(worker_main, nullptr, lcore_id);

        if (result != 0) {
            rte_exit(
                EXIT_FAILURE,
                "Failed to launch worker on lcore %u\n",
                lcore_id);
        }
    }

    /*
     * Include the main lcore in the test.
     */
    worker_main(nullptr);

    RTE_LCORE_FOREACH_WORKER(lcore_id) {
        const int worker_result = rte_eal_wait_lcore(lcore_id);

        if (worker_result < 0) {
            rte_exit(
                EXIT_FAILURE,
                "Worker on lcore %u failed\n",
                lcore_id);
        }
    }

    const std::uint64_t expected =
        static_cast<std::uint64_t>(rte_lcore_count()) *
        iterations_per_lcore;

    std::printf(
        "counter:  %llu\n"
        "expected: %llu\n",
        static_cast<unsigned long long>(counter),
        static_cast<unsigned long long>(expected));

    const int cleanup_result = rte_eal_cleanup();

    return cleanup_result == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}

This example:

  1. Initializes the DPDK Environment Abstraction Layer.
  2. Launches a worker function on every enabled worker lcore.
  3. Runs the same function on the main lcore.
  4. Limits concurrent entry through the semaphore.
  5. Protects the shared counter with a DPDK spin mutex.
  6. Waits for all worker lcores.
  7. Verifies the final count.

---

Building and running the DPDK example

DPDK installations normally expose compiler and linker flags through libdpdk.pc.


g++ -O3 -std=gnu++17 \
    $(pkg-config --cflags libdpdk) \
    dpdk_sync.cpp \
    -o dpdk_sync \
    $(pkg-config --libs libdpdk)

GNU C++ mode may be needed because some DPDK headers use GNU extensions.

A CPU-only test that does not access a NIC can commonly be launched as:


./dpdk_sync -l 0-3 --no-pci --no-huge

The exact EAL options depend on the DPDK version, platform, privileges, memory configuration, and application requirements.

Cross-compilation

The application source is architecture-neutral. For an AArch64 target:

  1. Use an AArch64 cross-compiler or native AArch64 build environment.
  2. Build or install DPDK for the same target and ABI.
  3. Ensure pkg-config resolves the target DPDK metadata rather than host libraries.
  4. Link the application against the target DPDK build.

---

DPDK behavior on X86 and ARM

x86 pause behavior

On x86, DPDK's pause helper maps to the processor pause hint, commonly _mm_pause() in C/C++ and pause in assembly.

Conceptually:


retry:
    lock cmpxchg [lock], desired
    je   acquired

wait:
    pause
    cmp  dword ptr [lock], 0
    jne  wait
    jmp  retry

The pause hint can improve behavior of tight spin loops by reducing pipeline penalties and resource pressure. It does not put the thread to sleep.

AArch64 pause behavior

A normal AArch64 pause helper can emit yield:


retry:
    ldaxr   w1, [x0]
    cbnz    w1, wait

    mov     w2, #1
    stxr    w3, w2, [x0]
    cbnz    w3, retry
    ret

wait:
    yield
    b       retry

Some DPDK/ARM configurations can use event-based waiting facilities such as WFE in appropriate wait helpers:


wait:
    wfe
    b       retry

WFE can reduce power consumption relative to an aggressive load loop, but the exact behavior depends on platform support, DPDK configuration, exclusive-monitor behavior, and the wait helper being used.

ARM LSE

On systems where ARM Large System Extensions are enabled for compilation, atomic operations can use instructions such as CAS, SWP, and LDADD. Without LSE, they are generally implemented using exclusive load/store loops.

Application-level implication

No architecture-specific lock code is normally required in the DPDK application. DPDK headers and compiler atomics select the appropriate:

---

Choosing the appropriate primitive

Use std::mutex when

Use std::counting_semaphore when

Use a futex-based custom primitive when

For production, a mature library implementation is usually safer.

Use a Linux-kernel mutex or semaphore when

Use rte_spinlock_t when

Use rte_ticketlock_t when

Use rte_mcslock_t when

MCS waiters spin primarily on local state, which can scale better under heavy contention.

Use the custom DPDK polling semaphore when

Do not use a polling primitive when

---

Comparison matrix

PrimitiveWait behaviorOwnershipFairnessCross-processBest fit
std::mutexUsually sleeping/adaptiveYesImplementation-definedNo, by itselfGeneral C++ mutual exclusion
std::counting_semaphoreUsually sleeping/adaptiveNoImplementation-definedNo, by itselfGeneral C++ bounded concurrency
Educational futex mutexSleeping under contentionIntended yes, not trackedNo guaranteePrivate version: noLearning/custom Linux userspace
Educational futex semaphoreSleeping under contentionNoNo guaranteePrivate version: noLearning/custom Linux userspace
Kernel struct mutexKernel sleeping lockYesKernel implementation policyKernel internalKernel mutual exclusion
Kernel struct semaphoreKernel sleeping lockNoKernel implementation policyKernel internalKernel permit counting
rte_spinlock_tBusy waitBy conventionNot FIFOShared-memory dependentVery short DPDK critical sections
rte_ticketlock_tBusy waitBy conventionTicket orderShared-memory dependentFairer DPDK locking
rte_mcslock_tBusy wait on queue nodeBy conventionQueue-basedShared-memory dependentHighly contended DPDK locking
Custom DPDK semaphoreBusy waitNoNo guaranteeShared-memory dependentShort bounded data-plane operations

Decision rule

A useful default decision sequence is:

  1. Use a lock-free or ownership-partitioned design when it simplifies the data plane.
  2. Otherwise use the highest-level standard or platform primitive that satisfies the requirement.
  3. Use a spin-based DPDK primitive only for short, bounded, data-plane waits.
  4. Use custom atomic/futex implementations only after defining invariants, lifecycle rules, fairness expectations, failure behavior, and a serious stress-testing strategy.

---

References

C++ synchronization and memory model

Linux futexes

Linux-kernel locking

ARM synchronization

DPDK APIs and guides

---

Closing guidance

For ordinary Linux C++ software, start with std::mutex and std::counting_semaphore. For Linux-kernel code, use the kernel locking primitives appropriate to the execution context. For DPDK data-plane code, first try to avoid shared writable state through per-lcore ownership, queues, rings, or batching; when a shared lock is unavoidable, keep the protected section short and select the lock according to contention and fairness requirements.