原子的正确用法

Correct usage of atomics

本文关键字:用法 原子的      更新时间:2023-10-16

我已经编写了一个基于向量的小型轻量级推送/弹出队列(认为它应该很快),如下所示:

template <typename T> class MyVectorQueue{
public:
MyVectorQueue (size_t sz):my_queue(sz),start(0),end(0){}
void push(const T& val){
size_t idx=atomic_fetch_add(&end,1UL);
if (idx==my_queue.size())
throw std::runtime_error("Limit reached, initialize to larger vector");
my_queue[idx]=val;
}
const T& pop(){
if (start==end) 
return end__;
return my_queue[start.fetch_add(1UL)];
}
size_t empty()
{
return end==start;
}
const T& end() {
return end__;
}
private:
T end__;
std::atomic<size_t> start,end;
std::vector<T> my_queue;    
}; 

矢量的大小应该是已知的,并且我想知道为什么它不安全?在什么情况下,这会扰乱我的结构?

您的startend是原子变量,但使用std::vector::operator[]不是原子操作,因此它不是线程安全的。


假设您有10个线程,vector的大小是5。现在,假设它们都在执行,比如push

现在假设所有10个线程可能都通过了检查,并且if (end==my_queue.size())被评估为false,因为end没有达到限制,这意味着vector没有满。

然后,它们可能都递增end,同时调用std::vector::operator[]。至少有5个线程将尝试访问向量"外部"的元素。

您使用operator[]来推送项目,但这不会为了添加项目而增加向量。因此,当试图将一个项添加到不存在的索引时,您将得到未定义的行为(可能还有访问冲突)。

此外,尽管您在startend上使用原子操作,但vector不是原子操作。因此,例如,您可以让多个线程调用push,它们原子性地更改end,然后所有线程都调用operator[],这不是线程安全的。相反,您应该考虑使用互斥锁和std::deque:

std::mutex mutex;
std::deque<T> my_queue;
void push(const T& val){
std::lock_guard<std::mutex> guard(mutex);
//..code to check if full 
my_queue.push_back(val);
}
const T& pop(){
std::lock_guard<std::mutex> guard(mutex);
//code to check if empty and that start index does not pass end index
T item=my_queue.front();
my_queue.pop_front();
return item;
}

尽管乍一看,这段代码在许多帐户上看起来都是错误的,但实际上它只包含一个问题。否则,在其限制范围内,它是完全好的。

您正在创建一个初始化为某个特定大小的vector,并且不允许推送超过该给定大小的元素。这有点"奇怪的行为",但如果这是所希望的,那么就没有问题了。

调用vector::operator[]对于线程安全来说看起来非常麻烦,因为它不是一个原子操作,但它本身并不是一个问题。vector::operator[]所做的只是返回对与您提供的索引相对应的元素的引用。它不做任何边界检查或重新分配,也不做任何其他复杂的事情,可能会在并发的情况下破坏这些事情(很可能,它可以归结为一条LEA指令)
在任何情况下都使用fetch_add,这是确保索引在线程之间唯一的正确心态。如果索引是唯一的,那么就不会有两个线程访问同一个元素,因为这种访问是否是原子访问并不重要。即使几个线程同时原子地递增一个计数器,它们也会得到不同的结果(即,不会"丢失"任何增量)。至少这是理论,就push而言也是如此。

真正的问题在于pop函数,其中(除了push之外)在比较局部变量和调用operator[]之前,您没有将索引(或者更正确地说,两个索引)原子化地fetch_add到局部变量中
这是一个问题,因为if(start==end)本身不是原子的,并且在几行之后调用operator[]时也不是原子的。您必须获取两个值才能在一个原子操作中进行比较,否则您无法断言比较在任何方面都是有意义的。否则:

  • 当您比较startend(或两者)时,另一个线程(或另外两个或三个线程)可以递增它们,您对是否已达到最大容量的检查将失败
  • 在检查之后,另一个线程可能会增加start,但在看到operator[]调用中的fetch_add之前,这会导致索引越界并调用未定义的行为

这里需要注意的是,尽管使用原子操作做了"正确的事情",但程序仍然不会正确运行,因为不仅仅单个操作需要是原子操作。