C ++ 14基于圆形缓冲液和std::atomic多生产者 - 消费量的无锁队列。
设计的目标是最大程度地减少一个线程之间的延迟,将元素推入队列和另一个线程从队列中弹出。
它已在Linux上开发,测试和基准测试,但应支持实现std::atomic任何C ++ 14个平台。报告称与Windows兼容,但是GitHub托管的连续集成仅适用于Ubuntu-20.04和Ubuntu-22.04上的X86_64平台。欢迎拉动请求扩展连续集成以在其他体系结构和/或平台上运行。
当最小化延迟时,良好的设计不是当没有剩下的添加时,而是在没有剩下的删除时,这些队列的例证。
最小化延迟自然可以最大化吞吐量。在理想的数学和实用工程意义上,低延迟倒数是高度的高位。低潜伏期与任何延迟和/或批处理都不兼容,这些延迟和/或批处理破坏了原始(硬件)全球事件的全局时间顺序,这些事件被不同的线程推入一个队列。另一方面,可以通过延迟和分批多个更新来以延迟延迟来最大化吞吐量。
这些队列遵循的主要设计原则是极简主义,这导致了这样的设计选择:
push / pop上进行副本/移动,因此无法获得队列中的元素的引用/指针。这些小型设计选择对自己的影响几乎是无法衡量的,但是它们的总影响要大得多,远远超过了成分影响的简单总和,也就是超级量表复合或协同作用。从将这些小型设计选择组合在一起的多个将CPU在最小阻碍的峰值容量下进行的协同作用。
这些设计选择也是局限性:
超低延迟应用需要仅需要,仅此而已。极简主义的回报,请参阅吞吐量和延迟基准。
其他几个已建立良好且流行的线程安全容器也用于基准中的参考:
std::mutex带有std::mutex固定尺寸挡板。pthread_spinlock带有pthread_spinlock_t的固定尺寸挡板。boost::lockfree::spsc_queue BOOST Library的无候补单生产者单生成队列队列。boost::lockfree::queue - BOOST库中的无锁多生产商 - 元制造商队列。moodycamel::ConcurrentQueue无锁的多生产者 - 消费者队列在非阻滞模式下使用。该队列旨在以延迟为代价,并避免通过不同线程推入一个队列的全局时间顺序以最大化的吞吐量。它不等于在这方面基准的其他队列。moodycamel::ReaderWriterQueue无锁的单生产者单架 - 消费者队列,用于非阻止模式。xenium::michael_scott_queue Michael和Scott提出的无锁的多生产商 - 消费者队列(此队列类似于boost::lockfree::queue ,这也基于同一建议)。xenium::ramalhete_queue Ramalheete和Correia提出的无锁的多生产者 - 消费者队列。xenium::vyukov_bounded_queue基于Vyukov提出的版本,有限的多生产商 - 消费者队列。tbb::spin_mutex锁定的固定尺寸弹跳器,带有tbb::spin_mutex来自英特尔螺纹构建块。tbb::concurrent_bounded_queue来自英特尔螺纹构建块的非阻滞模式下使用的同名队列。所提供的容器是仅标题类模板,无需建造/安装。
git clone https://github.com/max0x7ba/atomic_queue.git
atomic_queue/include目录(使用完整路径)到构建系统的包含路径。#include <atomic_queue/atomic_queue.h>在您的C ++源中。 vcpkg install atomic-queue
请关注官方教程,以了解如何消费柯南包裹。该库的特定详细信息可在Conancenter中找到。
所提供的容器是仅需要#include <atomic_queue/atomic_queue.h>仅限类模板,无需建造/安装。
建筑物对于运行测试和基准是必要的。
git clone https://github.com/cameron314/concurrentqueue.git
git clone https://github.com/cameron314/readerwriterqueue.git
git clone https://github.com/mpoeter/xenium.git
git clone https://github.com/max0x7ba/atomic_queue.git
cd atomic_queue
make -r -j4 run_benchmarks
基准还需要Intel TBB库可用。它假设它安装在/usr/local/include和/usr/local/lib中。如果在其他地方安装了它,则可能需要在Makefile中修改cppflags.tbb和ldlibs.tbb 。
AtomicQueue原子元素的固定尺寸弹跳器。OptimistAtomicQueue一种更快的固定尺寸环形缓冲器,适用于原子元素,在空或满时繁忙等待。它是AtomicQueue ,与push / pop一起使用,而不是try_push / try_pop 。AtomicQueue2非原子元件的固定尺寸挡板。OptimistAtomicQueue2更快的固定尺寸挡板弹锅,用于非原子元素,在空或满时忙碌的等待。它是AtomicQueue2 ,用于push / pop而不是try_push / try_pop 。这些容器具有相应的AtomicQueueB , OptimistAtomicQueueB , AtomicQueueB2 , OptimistAtomicQueueB2版本,其中缓冲区大小被指定为构造函数的参数。
支持完全有序模式。在这种模式下,消费者以相同的FIFO顺序接收消息。支持此模式以用于push和pop功能,而不是try_版本。截至2019年,在英特尔X86上,完全有序的模式为0。
支持单生产者单个消费者模式。在这种模式下,不需要昂贵的原子读取 - 修饰 - 定量 - 仅需说明,只有最便宜的原子负载和商店。这可以显着改善队列吞吐量。
完全支持仅移动队列元素类型。例如, std::unique_ptr<T>元素的队列将是AtomicQueue2B<std::unique_ptr<T>>或AtomicQueue2<std::unique_ptr<T>, CAPACITY> 。
队列类模板提供以下成员功能:
try_push将元素附加到队列的末尾。队列已满时返回false 。try_pop从队列的正面删除元素。队列为空时返回false 。push (乐观主义者) - 将元素附加到队列的末端。队列已满时忙着等待。当队列不满时,比try_push快。可选的FIFO生产商排队和总订单。pop (乐观主义者) - 从队列的正面删除元素。队列为空时忙着等待。当队列不为空时,比try_pop快。可选的FIFO消费者排队和总订单。was_size返回通话过程中无需耗尽元素的数量。在检查返回值时,状态可能已经改变。was_empty如果容器在通话过程中为空,则返回true 。在检查返回值时,状态可能已经改变。was_full如果在通话过程中该容器已满,则返回true 。在检查返回值时,状态可能已经改变。capacity - 返回队列可能保留的最大元素数量。原子元素是std::atomic<T>{T{}}.is_lock_free()返回true ,当C ++ 17功能可用时, std::atomic<T>::is_always_lock_free在编译时为true评估。换句话说,CPU可以在本地原子上加载,存储和分解此类元素。在x86-64上,此类元素都是C ++标准算术和指针类型。
原子元素的队列保留一个值作为空元素NIL ,其默认值为0 。无需将NIL值推入队列,并且在push功能中有一个assert语句,以防止在调试模式构建中。将NIL元素推入释放模式的队列会导致不确定的行为,例如死锁和/或丢失的队列元素。
请注意,乐观是队列修改操作控制流的选择,而不是队列类型。当队列在大多数情况下不满时,乐观主义者的push是最快的,这是一个乐观的pop - 当队列在大多数情况下不是空的时。乐观的和不限制的操作可以混合在一起。基准中的OptimistAtomicQueue S仅使用Opperist push和pop 。
有关用法示例,请参见示例。
push and try_push操作与同一队列对象的任何后续pop或try_pop操作同步(如std::memory_order中定义)。意思是:
push / try_push ,这是memory_order::release操作。与std::mutex::unlock相同的内存顺序。pop / try_pop之前,没有非原子负载 /存储会重新排序,这是memory_order::acquire操作。与std::mutex::lock的内存顺序相同。push / try_push插入队列中,在消费者的线程中可见,该线程pop / try_pop pop / try_pop该特定元素。 这里可用的队列使用弹跳器阵列来存储元素。队列的容量在编译时间或施工时间固定。
在生产多生产商 - 消费者场景中,应设置为最大预期队列大小。当弹跳器变满时,这意味着消费者无法足够快地消耗元素。解决此问题的方法是:
push and pop呼叫总是会引起一些昂贵的CPU周期,以保持队列状态以原子/一致/隔离的方式相对于其他线程的完整性,并且随着队列竞争的增长,这些成本会超级线性增加。一个事件导致的多个小元素或一个队列消息的生产者批处理通常是一个合理的解决方案。使用-2环形缓冲阵列尺寸可以进行几个重要的优化:
% SIZE ,作者和读者索引被映射到弹跳器阵列索引中。剩余的二进制运算符%通常会生成不便宜的Division CPU指令,但是使用2杀剂大小的转换,其余操作员变成了一项便宜的二进制and CPU指令,并且它的速度也很快。M消费者一起在同一环形缓存线路中的随后元素竞争的N竞争中,它只是一个与一个消费者竞争的生产者(当CPU的数量不超过可以适合一个缓存线的元素数量时)。这种优化随生产者和消费者的数量以及元素大小而缩放得更好。由于生产者和消费者数量少(这些基准中的每个基准中最多2个)可能会产生更好的吞吐量(但跨行动的差异更高)。容器使用unsigned类型用于大小和内部索引。在X86-64上, unsigned平台为32位宽,而size_t为64位宽。 64位指令使用额外的字节说明前缀,从而对CPU指令缓存和前端产生更大的压力。因此,使用32位unsigned索引来最大化性能。这将队列大小限制为4,294,967,295个元素,对于许多应用来说,这似乎是一个合理的硬限制。
虽然可以将原子队列与任何可移动元素类型(包括std::unique_ptr )一起使用,但为了获得最佳吞吐量和延迟,队列元素应该便宜地复制和锁定(例如unsigned或T* ),以便push和pop操作完全最快。
从概念上讲, push或pop操作执行了两个原子步骤:
head索引,消费者会增加tail索引。每个插槽仅由一个生产商和一个消费者线程访问。NIL ,从一个老虎机加载的消费者加载会改变其状态为NIL 。该插槽是其一个生产商和一个消费者线程的自旋锁。这些队列预计进行push或pop线程可能会完成步骤1,然后在完成步骤2之前先抢占。
如果保证在系统范围内有保证的情况下,则无锁。这些队列通过以下属性确保系统范围的进展:
push都独立于任何前面的push 。一个生产者线程不完整的(先发制人) push不会影响任何其他线程的push 。pop都独立于任何前面的pop 。一个消费者线程不完整的(预先抢占) pop不会影响任何其他线程的pop 。push只会影响一个消费者线程,从这个特定的队列插槽中pop一个元素。所有其他pop均不受影响。pop只会影响一个生产者线程,将一个元素push入这个特定的队列插槽,同时期望它很久以前就消耗了,而在生产商不太可能将整个消费者包裹在整个消费者pop的情况下,该消费者却没有完成。所有其他线程push s和pop s均未受到影响。 Linux任务调度程序线程抢占性是用户空间过程不应影响或逃脱的东西,否则任何/每个恶意应用程序都会利用这一点。
尽管如此,人们仍然可以做一些事情来最大程度地减少对任务关键应用程序的抢先攻击:
SCHED_FIFO调度类用于线程,例如chrt --fifo 50 <app> 。较高的优先级SCHED_FIFO线程或内核中断处理程序仍然可以抢占您的SCHED_FIFO线程。SCHED_OTHER违反了使用这些队列的目的。SCHED_FIFO实时线程被限制。taskset集的这些孤立核心应明确放置任务关键应用程序。人们经常提出限制忙碌的待办事,然后随后呼叫std::this_thread::yield() / sched_yield / pthread_yield 。但是, sched_yield是一个错误的锁定工具,因为它不会与OS内核通信该线程在等待的内容,因此OS线程调度程序永远无法在共享状态发生更改的正确时间安排调用线程(除非没有其他可以在此CPU核心上运行的线程,否则该线程立即恢复了Caller)。有关更多详细信息,请参见man sched_yield中的注释部分和有关sched_yield和Spinlock的Linux内核线程。
在Linux中,有Mutex类型的PTHREAD_MUTEX_ADAPTIVE_NP ,它忙于等待锁定的静音,以进行许多迭代,然后将SYSCALL封锁到内核中以取消等待线程。在基准测试中,这是表现最差的人,我找不到使其表现更好的方法,这就是它不包括在基准中的原因。
在英特尔CPU上,可以使用4个调试控制寄存器来监视Spinlock内存区域以进行写入访问并使用select (及其朋友)或sigwait等待(请参阅perf_event_open和uapi/linux/hw_breakpoint.h有关更多详细信息)。 Spinlock服务员可以使用select或sigwait暂停自己,直到Spinlock状态更新为止。但是只有4个寄存器,因此这样的解决方案不会扩展。
查看吞吐量和延迟基准图表。
有一些操作系统行为使基准测试复杂化:
SCHED_FIFO Priority 50用于禁用调度程序时间量子到期,并使线程通过较低的优先级过程/线程不可夺回。benchmarks的效果至少运行33次。基准图表显示平均值。图表工具提示还显示标准偏差,最小值和最大值。单生产者单圈队的基准性能boost::lockfree::spsc_queue , moodycamel::ReaderWriterQueue和这些队列在单生产者单个消费者模式下应该是相同的,因为它们使用完全相同的算法实现了完全相同的算法。 boost::lockfree::spsc_queue实现当时的基准测试没有优化可最大程度地减少L1D高速缓存的争论,Cold Branch错误预测或从插图码中明显的小说中的问题。
我只能访问几台X86-64机器。如果您可以访问其他硬件,请随时提交scripts/run-benchmarks.sh的输出文件。
当有大量页面可用时,基准测试使用1x1GB或16x2MB的巨大页面以使排队最大程度地减少TLB失误。为了使大型页面做一个:
sudo hugeadm --pool-pages-min 1GB:1
sudo hugeadm --pool-pages-min 2MB:16
另外,您可能希望在系统中启用透明的大型页面,并使用大型意见分配器,例如TCMALLOC。
默认情况下,Linux调度程序会从消耗100%CPU的实时线程中,这对基准测试有害。完整的详细信息可以在实时组计划中找到。禁用实时线程节流:
echo -1 | sudo tee /proc/sys/kernel/sched_rt_runtime_us >/dev/null
n生产商线程将4字节整数推入一个同一队列,n消费者线程从队列中弹出整数。所有生产商总共发布了1,000,000条消息。总时间发送和接收所有消息。该基准的运行量是从1个生产商和1个消费者到(total-number-of-cpus / 2)生产商 /消费者的基准,以衡量不同队列的可扩展性。
一个线程通过一个队列将整数张贴到另一个线程,并等待另一个队列的答复(总共2个队列)。基准测量总时间为100,000次乒乓球,最好的10次运行。争论在这里很小(1-生产者1-消费者,队列中的1个元素),能够达到和测量最低的延迟。报告平均往返时间。
贡献非常受欢迎。 .editorconfig和.clang-format可用于自动匹配代码格式。
我发现一些有关多线程编程主题的书:我发现很有启发性:
版权(C)2019 Maxim Egorushkin。麻省理工学院许可证。请参阅文件许可证中的完整许可证。