2

In general, it's not thread-safe to access the same instance of std::map from different threads.

But is it could be thread-safe under such a condition:

  1. no more element would be added to\remove from the instance of std::map after it has been initialized
  2. the type of values of the std::map is std::atomic<T>

Here is the demo code:

#include<atomic>
#include<thread>
#include<map>
#include<vector>
#include<iostream>

class Demo{
public:
Demo()
{
    mp_.insert(std::make_pair(1, true));
    mp_.insert(std::make_pair(2, true));
    mp_.insert(std::make_pair(3, true));
}

int Get(const int& integer, bool& flag)
{
    const auto itr = mp_.find(integer);
    if( itr == mp_.end())
    {
        return -1;
    }
    else
    {
        flag = itr->second;
        return 0;
    }
}
int Set(const int& integer, const bool& flag)
{
    const auto itr = mp_.find(integer);
    if( itr == mp_.end())
    {
        return -1;
    }
    else
    {
        itr->second = flag;
        return 0;
    }
}

private:
std::map<int, std::atomic<bool>> mp_;
};

int main()
{
    Demo demo;

    std::vector<std::thread> vec;

    vec.push_back(std::thread([&demo](){
        while(true)
        {
            for(int i=0; i<9; i++)
            {
                bool cur_flag = false;
                if(demo.Get(i, cur_flag) == 0)
                {
                    demo.Set(i, !cur_flag);
                }
                std::this_thread::sleep_for(std::chrono::milliseconds(1000));
            }
        }
    }));

    vec.push_back(std::thread([&demo](){
        while(true)
        {
            for(int i=0; i<9; i++)
            {
                bool cur_flag = false;
                if(demo.Get(i, cur_flag)==0)
                {
                    std::cout << "(" << i << "," << cur_flag <<")" << std::endl;
                }
                std::this_thread::sleep_for(std::chrono::milliseconds(10));
            }
        }
    })
    );

    for(auto& thread:vec)
    {
        thread.join();
    }
}

What more, the compiler does not complain about anything with -fsanitize=thread option.

John
  • 2,963
  • 11
  • 33

2 Answers2

1

Yes, this is safe.

Data races are best thought of as unsynchronized conflicting access (potential concurrent reads and writes).

std::thread construction imposes an order: the actions which preceded in code are guaranteed to come before the thread starts. So the map is completely populated before the concurrent accesses.

The library says standard types can only access the type itself, the function arguments, and the required properties of any container elements. std::map::find is non-const, but the standard requires that for the purposes of data races, it is treated as const. Operations on iterators are required to at most access (but not modify) the container. So the concurrent accesses to std::map are all non-modifying.

That leaves the load and store from the std::atomic<bool> which is race-free as well.

Jeff Garrett
  • 5,863
  • 1
  • 13
  • 12
  • Thank you for the clarification. ***1***.How to understand "*The library says standard types can only access the type itself, the function arguments, and the required properties of any container elements.*" in the right way? Could you please explain that in simple words for me? – John Jun 27 '22 at 00:47
  • **2**. *std::map::find is non-const, but the standard requires that for the purposes of data races, it is **treated** as const.*, As per the [document](https://en.cppreference.com/w/cpp/container/map/find), which says that `iterator find( const Key& key );` is not const,whereas `const_iterator find( const Key& key ) const;` is const. ***3***. If I understand you correctly, it would not be thread-safe if `std::map>` is replaced by `std::map` for the aforementioned code snippet? – John Jun 27 '22 at 00:48
  • 1. By that I meant, it is guaranteed by the standard that the member function call, e.g. `map.find` can potentially access only the obvious things: the map, the elements, and the arguments. There can't be a static cache hiding behind the scenes that interferes with thread safety. 2. Both calls to `map.find` in your code are calling the one which is not const. Generally, const member functions are thread-safe. This point is that even the non-const `map.find` (and a handful of others) are treated as if they were const for this. 3. Correct, the raw bool concurrent read/write would be a data race. – Jeff Garrett Jun 27 '22 at 04:15
  • Thank you for the detailed explanation. 2. *Both calls to `map.find` in your code are calling the one which is not const.* If I understand you correctly, it's because `mp_` itself is not declared with `const`. Am I right? I ***wrongly*** thought it's const since the `const auto itr = mp_.find(integer);` returns a const iterator. – John Jun 27 '22 at 04:25
  • Yes, `mp_` is not const and the method `Get` is not const. Idiomatically, `Get` would be const-qualified, so inside it `mp_` would be `const`, so you'd get the const overload. – Jeff Garrett Jun 27 '22 at 04:50
1

This should avoid data-race UB, since you aren't mutating the std::map data structure at all after starting either thread. And given the limited ways you're modifying the atomic values of the map, that's also safe.


You didn't provide accessor functions that would allow atomic RMW, so the only thing you can do is .store(flag) and .load(flag) with the default memory_order_seq_cst. Not .exchange or .compare_exchange_weak, or ^= 1 to flip.

Your if(Get) Set isn't equivalent to a flag ^= 1; atomic flip of that flag (although that doesn't compile as efficiently as one might like: Efficient way to toggle an atomic_bool).

If another thread was also flipping the same flag, they could step on each other. e.g. 10 total flips should get it back to the original value, but with separate atomic load and store, both threads could load the same value and then store the same value, resulting in only one flip for 2 (or many more) if(Get) Set operations across multiple threads.

Of course, if you don't have multiple threads writing the same flag, it is more efficient to separately load and store like you're doing.

Especially if you avoid the default memory_order_seq_cst, e.g. provide a std::memory_order ord = std::memory_order_seq_cst optional arg to your accessor functions, like std::atomic functions do. SC stores on most ISAs are more costly. (Especially on x86, where even mo_release is free in asm, but mo_seq_cst needs a full barrier.)

(But as discussed on Efficient way to toggle an atomic_bool std::atomic<bool> has no portable way to atomically flip it other than ^= 1, there isn't a member function that can take a memory_order arg. atomic<uint8_t> may be a better choice, taking the low bit as the actual boolean value.)


Since you need to be able to return failure, perhaps return a pointer to the std::atomic<bool> objects, with NULL indicating not found. That would let the caller use any std::atomic member function. But it does make misuse possible, holding onto the reference for too long.

Peter Cordes
  • 328,167
  • 45
  • 605
  • 847