简体   繁体   中英

Multiple fast readers single slow writer: is using shadow-data with atomic index safe?

I'm not sure I could squeeze the concept correctly in the title, but here's what I mean:

Suppose I have "slow", in the sense of really unfrequent, updates made to a data structure by a single thread, while there are multiple threads continuously reading the same data structure.

In the attempt to avoid locks, and being stuck on C++14 (so no std::shared_mutex available) and without boost, I thought to an approach where I keep 2 copies of the structure and use an atomic integer to index the current one.

Let's assume that the depth of 2 is enough and let's not worry about that, updates are so infrequent that there's enough time for a new version of the data structure to be "seen" by all the readers before a new update comes in.

Here's a snippet that shows a simplified version of what I was doing:

 /*
 * includes...
 */

struct datastructure_t {
    /*...*/
};


class StructSwapper
{
    std::atomic<unsigned int> current_index_;

    datastructure_t structures_[2];

public:
    StructSwapper (datastructure_t s)
        : current_index_(0)
        , structures_{std::move(s), {}}
    {}

    //Guaranteed to be called _infrequently_ by the same single thread
    void update (datastructure_t newdata)
    {
        auto const next_index = !current_index_.load();

        structures_[next_index] = std::move(newdata);

        current_index_.store(next_index);
    }


    //Called _frequently_ by multiple threads
    datastructure_t const & current_data() const
    {
        return structures_[current_index_.load()];
    }
};

So basically when the writer thread performs an update, it first modifies the "shadow" copy of the data structure, and then atomically updates the index the points to it.

Each reader thread will do something like:

void reader_thread(StructSwapper const &sw)
{
    auto const &current_data  = sw.current_data();


    if (current_data->find(...))                        //1
    {
        do_something (current_data->val1);              //2

        if (current_data->property2)                    //3
            do_something_else (current_data->val2);     //4
        /*...*/

    }
}

But then I started thinking: what guarantees that the compiler won't re-read the value of current_data in any of the lines marked 1,2,3,4 and then possibly get two different versions of it during the execution of this function if in the meantime an update was performed by the writer thread?

Maybe if the StructSwapper::current_data() is inline, it might have a look at it and see the use of the atomic variable as an index, but I doubt it would be enough anyway.

So, two questions:

  1. Am I right thinking that this approach is not guaranteed to work as the compiler has no clue that the "snapshot" of the current_data has to really be taken only once?
  2. I think it would probably make a difference if I instead returned an atomic reference to the current version of the data structure because in that case, the compiler would understand that it could get two different values out of two different reads, right?

EDIT: After seeing suggestions for optimizations related to a more relaxed memory ordering, I'd like to add I didn't report them in the above snippet, but they are indeed already in place in the real code.

This is a common approach and it works.

You need to make sure that no other thread is still reading the old data that your writer updates though. To solve that, readers often just make a copy of the data and use it as long as necessary.

But then I started thinking: what guarantees that the compiler won't re-read the value of current_data .

The compiler may store the value of variable current_data on the stack and re-read it later, but it won't call sw.current_data() for you.

Am I right thinking that this approach is not guaranteed to work as the compiler has no clue that the "snapshot" of the current_data has to really be taken only once?

That is incorrect.

I think it would probably make a difference if I instead returned an atomic reference to the current version of the data structure because in that case, the compiler would understand that it could get two different values out of two different reads, right?

This is unnecessary.


A few optimizations:

void update (datastructure_t newdata)
{
    auto const next_index = !current_index_.load(std::memory_order_relaxed); 
    structures_[next_index] = std::move(newdata);
    current_index_.store(next_index, std::memory_order_release); // (1) 
}


//Called _frequently_ by multiple threads
datastructure_t const & current_data() const
{
    return structures_[current_index_.load(std::memory_order_acquire)]; // Synchronises with (1).
}

If you have a second update while any reader is still working on the original data, you will have a data-race and thus Undefined Behavior.

So, infrequent is not enough, you need a guarantee that any two updates are far enough apart that the readers will all be working with the newest data before you get the second update.

That can be mitigated by having more positions available, by keeping track of how many readers are working with each copy and deferring updates, or by dynamically allocating as many copies as needed and managing them with shared-pointers. Whatever you do, take a critical look at whether it's correct and efficient enough.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM