9
\$\begingroup\$

Recently, I read a article about work stealing queue Job System 2.0: Lock-Free Work Stealing – Part 3: Going lock-free, and this is my c++11 naive implementation based on my understand of c++11 memory order model. This works on x86 , as x86 has a strong memory model, is there any issue running on other architecture (weak memory order)?

#include <atomic>

class Job;

struct WorkStealingQueue
{
    // only called by owner work thread
    void Push(Job* job) noexcept
    {
        // m_bottom -> stealing thread read, owner thread read, write.
        auto bottom = m_bottom.load(std::memory_order_relaxed);

        m_jobs[bottom & MASK] = job;

        // need  let stealing thread see the new job.
        m_bottom.fetch_add(1, std::memory_order_release);
    }

    // only called by owner worker thread 
    Job* Pop(void) noexcept
    {
        auto bottom = m_bottom.load(std::memory_order_relaxed);

        auto top = m_top.load(std::memory_order_acquire);

        if (bottom > top)
        {
            auto job = m_jobs[(bottom - 1) & MASK];

            if(top < (bottom - 1)) 
            {
                m_bottom.fetch_sub(1, std::memory_order_release);
                return job;   
            }
            if (m_top.compare_exchange_weak(
                top, top + 1,
                std::memory_order_release,
                std::memory_order_relaxed))
            {
                return job;
            } 
        }

        return nullptr;
    }

    // called by stealing thread, not owner thread
    Job* Steal(void) noexcept
    {
        auto top = m_top.load(std::memory_order_acquire);

        // Release-Acquire ordering, so the stealing thread see new job
        auto bottom = m_bottom.load(std::memory_order_acquire);

        if (bottom > top)
        {
            auto job = m_jobs[top & MASK];
            // check if other stealing thread stealing this work 
            // or owner thread pop this job.
            // no data should do sync, so use relaxed oreder
            if (m_top.compare_exchange_weak(
                top, top + 1,
                std::memory_order_release,
                std::memory_order_relaxed))
            {
                return job;
            }
        }

        return nullptr;
    }

private:

    static constexpr auto MAX_COUNT = 4096u;
    static constexpr auto MASK = MAX_COUNT - 1u;
    static_assert((MAX_COUNT & MASK) == 0, "the max number of job must be power of two.");

    Job* m_jobs[MAX_COUNT];
    std::atomic<unsigned int> m_bottom{ 0 }, m_top{ 0 };
};
\$\endgroup\$
6
  • 3
    \$\begingroup\$ "is this implementation correct" Did you check it? On Code Review, only working code is allowed. Please check the help center. \$\endgroup\$
    – Mast
    Commented Mar 24, 2017 at 13:12
  • 1
    \$\begingroup\$ @Mast this code works on my x86-64 PC, lock-free may has issue on other arch platform. So I am looking for expert help. \$\endgroup\$
    – benlong
    Commented Mar 24, 2017 at 14:22
  • \$\begingroup\$ Question: is there only one thread pushing and one thread popping (i.e. single producer, single consumer)? Or are there multiple producers / consumers? \$\endgroup\$
    – JS1
    Commented Mar 24, 2017 at 17:14
  • \$\begingroup\$ @JS1 Pop(), Push() performed on the same one thread, Steal() performed on the others multiple threads. \$\endgroup\$
    – benlong
    Commented Mar 24, 2017 at 17:18
  • \$\begingroup\$ Please do not update the code in your question to incorporate feedback from answers, doing so goes against the Question + Answer style of Code Review. This is not a forum where you should keep the most updated version in your question. Please see what you may and may not do after receiving answers. \$\endgroup\$
    – Mast
    Commented Mar 25, 2017 at 8:42

3 Answers 3

4
\$\begingroup\$

Pop() has problem

Consider what happens if your queue is in the following state with two jobs:

m_top    = 0
m_bottom = 2

The owner thread calls pop(), and loads in to local variables:

top    = 0
bottom = 2

Now, before the owner thread does anything else, two stealer threads call steal(), leaving the state of the queue like this:

m_top    = 2
m_bottom = 2

When the owner thread continues inside of pop(), it will go into this case and return a job that no longer exists:

    if(top < (bottom - 1)) 
    {
        m_bottom.fetch_sub(1, std::memory_order_release);
        return job;   
    }

Your code deviated from the code you linked. In the linked code, the first thing that pop() did was to decrement m_bottom so that the stealer threads could not steal the bottom job.

\$\endgroup\$
2
  • \$\begingroup\$ thank you, i will rewrite Pop() follow that article. \$\endgroup\$
    – benlong
    Commented Mar 25, 2017 at 4:51
  • \$\begingroup\$ hi, i rewrote Pop, please help to review it. thanks. \$\endgroup\$
    – benlong
    Commented Mar 25, 2017 at 5:10
3
\$\begingroup\$

is there any issue running on other architecture (weak memory order)?

Well, std::atomic isn't guaranteed by the standard to be lock free. It totally depends on the target CPU's architecture, and capabilities.

If there are single operations supported by the CPU to access and change specific values, they will be lock free. Otherwise any operations on std::atomic may need to use an implementation that involve a synchronization mechanism (semaphore or mutex).

For reference you can read

and

\$\endgroup\$
1
\$\begingroup\$

Rewrite Pop() followed by reference article.

#include <atomic>

class Job;

struct WorkStealingQueue
{
    // only called by owner work thread
    void Push(Job* job) noexcept
    {
        // m_bottom -> stealing thread read, owner thread read, write.
        auto bottom = m_bottom.load(std::memory_order_relaxed);

        m_jobs[bottom & MASK] = job;

        // need  let stealing thread see the new job.
        m_bottom.fetch_add(1, std::memory_order_release);
    }

    // only called by owner worker thread 
    Job* Pop(void) noexcept
    {
        auto bottom = m_bottom.fetch_sub(std::memory_order_relaxed);

        auto top = m_top.load(std::memory_order_acquire);

        if (bottom >= top)
        {
            auto job = m_jobs[bottom  & MASK];

            if(top != bottom) 
            {
                return job;
            }
            auto top1 = top + 1;
            if (!m_top.compare_exchange_weak(
                top, top1,
                std::memory_order_release,
                std::memory_order_relaxed))
            {
                job = nullptr;
            } 
            m_bottom.store(top1);
            return job;
        }
        m_bottom.store(top, std::memory_order_release);
        return nullptr;
    }

    // called by stealing thread, not owner thread
    Job* Steal(void) noexcept
    {
        auto top = m_top.load(std::memory_order_acquire);

        // Release-Acquire ordering, so the stealing thread see new job
        auto bottom = m_bottom.load(std::memory_order_acquire);

        if (bottom > top)
        {
            auto job = m_jobs[top & MASK];
            // check if other stealing thread stealing this work 
            // or owner thread pop this job.
            // no data should do sync, so use relaxed oreder
            if (m_top.compare_exchange_weak(
                top, top + 1,
                std::memory_order_release,
                std::memory_order_relaxed))
            {
                return job;
            }
        }

        return nullptr;
    }

private:

    static constexpr auto MAX_COUNT = 4096u;
    static constexpr auto MASK = MAX_COUNT - 1u;
    static_assert((MAX_COUNT & MASK) == 0, "the max number of job must be power of two.");

    Job* m_jobs[MAX_COUNT];
    std::atomic<unsigned int> m_bottom{ 0 }, m_top{ 0 };
};
\$\endgroup\$
2
  • 2
    \$\begingroup\$ By the original algorithm: in Push(), fetch_add isn't needed, as store(bottom + 1, memory_order_release) is sufficient. In Pop(), the original article insists on a CPU fence between storing bottom and loading top. fetch_sub(1, memory_order_relaxed) has the wrong memory order; it should be memory_order_seq_cst (on x86(-64), the interlocked add happens to be a fence, but this is broken on other memory models). Also, fetch_sub() isn't necessary; exchange(bottom + 1, memory_order_seq_cst) works. \$\endgroup\$ Commented Jul 28, 2017 at 19:11
  • 1
    \$\begingroup\$ Display Name is right. Also the load from bottom in Steal should be seq_cst, as well as the compare_exchange on top. Some of the stores to bottom can also be relaxed, as well as some of the loads from top. You should use something like relacy (github.com/dvyukov/relacy) to verify your lock free C++ code. \$\endgroup\$
    – jhoffman0x
    Commented Oct 22, 2019 at 3:32

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.