特殊条件下关于 'std::map<int, std::atomic>' 的线程安全<T>



一般来说,从不同的线程访问同一个std::map实例是不安全的。

但是在这种情况下,它可能是线程安全的吗?

  1. std::map实例初始化后,不再向remove添加元素
  2. std::map的值类型为std::atomic<T>

下面是演示代码:

#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();
}
}

什么,编译器不会报错-fsanitize=thread选项。

是的,这是安全的。

数据竞争最好被认为是不同步的冲突访问(潜在的并发读写)。

std::thread构造施加了一个顺序:代码中之前的操作保证在线程启动之前发生。因此,在并发访问之前,映射被完全填充。

标准库规定,标准类型只能访问类型本身、函数实参和任何容器元素所需的属性。std::map::find是非const,但标准要求出于数据争用的目的,将其视为const。对迭代器的操作最多只能访问(但不能修改)容器。所以对std::map的并发访问都是不可修改的。

这使得std::atomic<bool>的加载和存储也是无竞争的。

这将避免数据竞争UB,因为在启动两个线程后根本不会改变std::map数据结构。考虑到修改map的原子值的方式有限,这也是安全的。


您没有提供允许原子RMW的访问器函数,因此您唯一可以做的是.store(flag).load(flag)与默认的memory_order_seq_cst。不能翻转.exchange.compare_exchange_weak,也不能翻转^= 1

您的if(Get) Set不等同于该标志的flag ^= 1;原子翻转(尽管这并不像人们希望的那样有效地编译:切换atomic_bool的有效方法)。

如果另一个线程也在翻转相同的标志,它们可能会踩到对方。例如,总共10次翻转应该使它恢复到原始值,但是使用单独的原子加载和存储,两个线程都可以加载相同的值,然后存储相同的值,结果在多个线程之间进行2次(或更多)if(Get) Set操作时只有一次翻转。

当然,如果你的

没有多个线程写同一个标志,那么像你这样分别加载和存储会更有效。特别是如果你避免默认的memory_order_seq_cst,例如为你的accessor函数提供std::memory_order ord = std::memory_order_seq_cst可选参数,就像std::atomic函数一样。大多数isa上的SC商店更昂贵。(特别是在x86上,即使mo_release在asm中是自由的,但mo_seq_cst需要一个完整的屏障。)

(但是正如在切换atomic_bool的有效方法中所讨论的那样,std::atomic<bool>除了^= 1之外没有可移植的方法来自动翻转它,没有一个成员函数可以接受memory_order参数g。atomic<uint8_t>可能是更好的选择,将低位作为实际的布尔值。)


因为您需要能够返回失败,也许返回一个指针std::atomic<bool>对象,NULL表示未找到。这将允许调用者使用任何std::原子成员函数。但它确实会造成误用的可能,使引用保存的时间过长。