我正在尝试优化SPSC队列中的消费者延迟,如下所示:
template <typename TYPE>
class queue
{
public:
void produce(message m)
{
const auto lock = std::scoped_lock(mutex);
has_new_messages = true;
new_messages.emplace_back(std::move(m));
}
void consume()
{
if (UNLIKELY(has_new_messages))
{
const auto lock = std::scoped_lock(mutex);
has_new_messages = false;
messages_to_process.insert(
messages_to_process.cend(),
std::make_move_iterator(new_messages.begin()),
std::make_move_iterator(new_messages.end()));
new_messages.clear();
}
// handle messages_to_process, and then...
messages_to_process.clear();
}
private:
TYPE has_new_messages{false};
std::vector<message> new_messages{};
std::vector<message> messages_to_process{};
std::mutex mutex;
};
如果可能的话,这里的使用者试图避免为互斥锁的锁定/解锁付费,并在锁定互斥锁之前进行检查。
问题是:我是否绝对必须使用TYPE = std::atomic<bool>
,或者我可以节省原子操作,并且读取volatile bool
就可以了?
众所周知,volatile
变量本身并不能保证线程安全,然而,std::mutex::lock()
和std::mutex::unlock()
提供了一些内存排序保证。我是否可以依靠它们对volatile bool has_new_messages
进行更改,以便最终在mutex
作用域之外的使用者线程可见?
更新:根据@Peter Cordes的建议,我将重写如下:
void produce(message m)
{
{
const auto lock = std::scoped_lock(mutex);
new_messages.emplace_back(std::move(m));
}
has_new_messages.store(true, std::memory_order_release);
}
void consume()
{
if (UNLIKELY(has_new_messages.exchange(false, std::memory_order_acq_rel))
{
const auto lock = std::scoped_lock(mutex);
messages_to_process.insert(...);
new_messages.clear();
}
}
它不能是普通的bool
。阅读器中的旋转循环将优化为这样的内容:if (!has_new_messages) infinite_loop;
,因为编译器可以将负载从循环中提升出来,因为它可以假设它不会异步更改。
volatile
在一些平台上工作(包括大多数主流CPU,如x86-64或ARM(,作为atomic
加载/存储memory_order_relaxed
的糟糕替代品,用于"自然"原子类型(例如int
或bool
,因为ABI使它们自然对齐(。即其中无锁原子加载/存储使用与正常加载/存储相同的asm。
我最近写了一个答案,将中断处理程序的volatile
和松弛的atomic
进行了比较,但对于实际并发的线程,这基本上是一样的。has_new_messages.load(std::memory_order_relaxed)
编译为与在普通平台上从volatile
获得的相同的asm(即没有额外的围栏指令,只有普通加载或存储(,但它是合法的/可移植的C++。
如果对volatile
执行同样的操作是安全的,那么您可以并且应该只将std::atomic<bool> has_new_messages;
与互斥对象之外的mo_relaxed
加载/存储一起使用。。
您的编写器可能应该在释放互斥之后使用标志,或者可能在关键部分的末尾使用memory_order_release
存储。当编写器还没有真正释放互斥对象时,让读取器脱离旋转循环并尝试获取互斥对象是没有意义的。
顺便说一句,如果您的读取器线程在has_new_messages
上旋转,等待它变为真,您应该在x86上的循环中使用_mm_pause()
,以节省电源,并避免在它发生更改时清除内存顺序错误推测管道。还可以考虑在旋转几千次后恢复到操作系统辅助的睡眠/唤醒。请参阅__asm volatile("暂停"::"内存"(是什么;做有关由一个线程写入和由另一个线程读取的内存的更多信息,请参阅生产者-消费者在超级兄弟与非超级兄弟之间共享内存位置的延迟和吞吐量成本是多少?(包括一些内存顺序错误推测结果。(
或者更好的方法是使用无锁SPSC队列;有很多使用固定大小环形缓冲区的实现,如果队列未满或为空,则读取器和写入器之间不会发生争用。如果您将读写器的原子位置计数器安排在单独的缓存行中,这应该是好的。
将
volatile bool has_new_messages
更改为最终对消费者线程可见
这是一个常见的误解。任何存储都将非常很快对所有其他CPU核心可见,因为它们都共享一个一致的缓存域,并且存储将尽可能快地提交给它,而不需要任何围栏指令。
如果我没有';不要用栅栏,一个核心需要多长时间才能看到另一个核心;s写作?。最坏的情况可能是大约一微秒,在一个数量级内。通常更少。
并且volatile
或atomic
确保在编译器生成的asm中实际存在存储。
(相关:当前的编译器基本上根本不优化atomic<T>
;所以atomic
基本上等同于volatile atomic
。为什么编译器不合并冗余的std::atomic写入?但即使没有这一点,编译器也无法跳过存储或从旋转循环中提升负载。(