我正在设计一个连接到一个或多个数据源流的系统,并对数据进行一些分析,而不是基于结果触发事件。在典型的多线程生产者/消费者设置中,我将有多个生产者线程将数据放入队列,并且多个消费者线程读取数据,并且消费者仅对最新数据点加上n个点感兴趣。如果慢速消费者无法跟上,生产者线程将不得不阻止,当然,当没有未经处理的更新时,消费者线程将被阻止。使用具有读取器/写入器锁的典型并发队列将很好地工作,但是进入的数据速率可能很大,因此我希望减少锁定开销,尤其是生产者的写入锁。我认为我需要一个循环无锁缓冲区。
现在有两个问题:
圆形无锁缓冲是答案吗?
如果是这样,在我自己推出之前,您是否知道任何适合我需要的公开实施?
总是欢迎实现循环无锁缓冲区的任何指针。
BTW,在Linux上用C ++做这件事。
其他一些信息:
响应时间对我的系统至关重要。理想情况下,消费者线程会希望尽快看到任何更新,因为额外的1毫秒延迟可能会使系统失去价值,或者价值更低。
我倾向于的设计思想是一个半无锁的循环缓冲区,其中生产者线程尽可能快地将数据放入缓冲区,让我们调用缓冲区A的头部,而不阻塞,除非缓冲区是当A满足缓冲区Z的结束时,消费者线程将各自保存两个指向循环缓冲区的指针P和P n ,其中P是线程的本地缓冲区头,P n 是P之后的第n项。一旦完成处理当前P,每个消费者线程将前进其P和P n ,并且缓冲指针Z的末尾以最慢的P n前进子>。当P赶上A,这意味着没有更新的处理更新,消费者旋转并忙着等待A再次前进。如果消费者线程旋转太长时间,它可以进入休眠状态并等待一个条件变量,但我没关系消费者占用CPU周期等待更新,因为这不会增加我的延迟(我会有更多的CPU核心)比线程)。想象一下,你有一个循环轨道,并且生产者正在一群消费者面前运行,关键是调整系统,使生产者通常比消费者领先一步,并且大部分操作都可以使用无锁技术完成。我理解获得正确实施的细节并不容易......好吧,非常难,这就是为什么我想在自己制作一些错误之前先从别人的错误中吸取教训。
答案 0 :(得分:35)
在过去的几年里,我对无锁数据结构做了一个特别的研究。我已经阅读了该领域的大部分论文(只有大约四十年左右 - 虽然只有大约十或十五个是真实的用途: - )
AFAIK,一种无锁的循环缓冲器尚未发明。问题在于处理读者超越作家的复杂情况,反之亦然。
如果您没有花费至少六个月的时间研究无锁数据结构,请不要尝试自己编写。您将错误地认为错误存在可能并不明显,直到您的代码在部署后在新平台上失败。
但我相信你的要求有一个解决方案。
您应该将无锁队列与无锁自由列表配对。
免费列表将为您提供预分配,从而避免了对无锁分配器的(财政上昂贵的)要求;当free-list为空时,你可以通过立即从队列中取出一个元素并使用它来复制循环缓冲区的行为。
(当然,在基于锁的循环缓冲区中,一旦获得锁定,获取元素非常快 - 基本上只是一个指针取消引用 - 但你不会在任何无锁算法中得到它;它们经常必须顺利完成任务;自由列表弹出失败以及出列失败的开销与任何无锁算法需要做的工作量相当。)
迈克尔和斯科特在1996年开发了一个非常好的无锁队列。下面的链接将为您提供足够的详细信息来追踪他们论文的PDF; Michael and Scott, FIFO
无锁自由列表是最简单的无锁算法,实际上我认为我没有看过它的实际论文。
答案 1 :(得分:32)
您想要的艺术术语是无锁队列。 Ross Bencina有一个excellent set of notes with links to code and papers。我最信任的人是Maurice Herlihy(对于美国人来说,他的名字叫“莫里斯”)。
答案 2 :(得分:11)
如果缓冲区为空或满,则生产者或消费者阻止的要求表明您应该使用正常的锁定数据结构,使用信号量或条件变量来阻止生成者和使用者阻塞,直到数据可用。无锁代码通常不会阻止这种情况 - 它会旋转或放弃无法执行的操作,而不是使用操作系统阻塞。 (如果你能够等到另一个线程生成或使用数据,那么为什么还要等另一个线程的锁来完成更新数据结构呢?)
在(x86 / x64)Linux上,如果没有争用,使用互斥锁的线程内同步相当便宜。集中精力减少生产者和消费者需要抓住锁的时间。鉴于你已经说过你只关心最后N个记录的数据点,我认为循环缓冲区可以做得相当好。但是,我真的不明白这是如何符合阻止要求以及消费者实际消费(删除)他们阅读的数据的想法。 (您是否希望消费者只查看最后N个数据点,而不是删除它们?您是否希望生产者不关心消费者是否无法跟上,只是覆盖旧数据?)
此外,正如Zan Lynx评论的那样,当你有大量的数据进入时,你可以将你的数据聚合/缓冲到更大的块中。你可以缓冲一定数量的点,或者在一定数量内收到的所有数据多少时间。这意味着将减少同步操作。但它确实引入了延迟,但是如果你不使用实时Linux,那么无论如何你都必须处理这个问题。
答案 3 :(得分:5)
关于这个on DDJ,有一系列非常好的文章。作为这些东西有多困难的标志,这是对an earlier article的纠正错误。确保在推出自己的错误之前了解错误) - ;
答案 4 :(得分:5)
boost库中的实现值得考虑。它易于使用且性能相当高。我写了一个测试&在一个四核i7笔记本电脑(8个线程)上运行它,并获得~4M排队/出列操作一秒钟。到目前为止未提及的另一个实现是http://moodycamel.com/blog/2014/detailed-design-of-a-lock-free-queue处的MPMC队列。我已经在32台生产商和32位消费者的同一台笔记本电脑上进行了一些简单的测试。正如所宣传的那样,它比boost无锁队列更快。
正如大多数其他答案一样,状态无锁编程很难。大多数实现都很难检测到需要进行大量测试的极端情况。调试修复。通常在代码中小心放置内存屏障来修复这些问题。您还可以在许多学术文章中找到正确性证明。我更喜欢使用强力工具测试这些实现。应使用http://research.microsoft.com/en-us/um/people/lamport/tla/tla.html等工具检查您计划在生产中使用的任何无锁算法的正确性。
答案 5 :(得分:4)
减少争用的一种有用技巧是将项目散列到多个队列中,并让每个消费者专用于“主题”。
对于您的消费者感兴趣的最近数量的项目 - 您不希望锁定整个队列并迭代它以查找要覆盖的项目 - 只需发布N元组中的项目,即所有N个最近项目。实施的奖励点,生产者将在完整队列上阻塞(当消费者无法跟上时)超时,更新其本地元组缓存 - 这样就不会对数据源施加压力。
答案 6 :(得分:4)
我同意this article并建议不要使用无锁数据结构。关于无锁fifo队列的一篇相对较新的论文是this,由同一作者搜索更多论文; Chalmers还有关于无锁数据结构的博士论文(我丢失了链接)。但是,您没有说明您的元素有多大 - 无锁数据结构仅对字大小的项有效,因此如果元素大于机器字(32或64),则必须动态分配元素位)。如果你动态分配元素,你可以转移(因为你没有分析你的程序而你基本上做了过早的优化)瓶颈到内存分配器,所以你需要一个无锁内存分配器,例如{{3} },并将其与您的应用程序集成。
答案 7 :(得分:4)
Sutter的队列是次优的,他知道。多核编程的艺术是一个很好的参考,但不相信内存模型上的Java人员。罗斯的链接不会给你明确的答案,因为他们的图书馆存在这样的问题,等等。
进行无锁编程是一件麻烦事,除非你想在解决问题之前花费大量时间做一些明显过度工程的事情(根据它的描述判断,它是一种常见的疯狂'在缓存一致性中寻找完美。这需要数年时间,导致不能首先解决问题并在以后优化,这是一种常见疾病。
答案 8 :(得分:4)
我不是硬件内存模型和锁定免费数据结构的专家,我倾向于避免在我的项目中使用它们,而是使用传统的锁定数据结构。
但是我最近注意到了这段视频: Lockless SPSC queue based on ring buffer
这是基于交易系统使用的名为LMAX distruptor的开源高性能Java库:LMAX Distruptor
根据上面的介绍,你可以使头部和尾部指针原子和原子检查头部从后面捕获尾部的情况,反之亦然。
下面你可以看到一个非常基本的C ++ 11实现:
// USING SEQUENTIAL MEMORY
#include<thread>
#include<atomic>
#include <cinttypes>
using namespace std;
#define RING_BUFFER_SIZE 1024 // power of 2 for efficient %
class lockless_ring_buffer_spsc
{
public :
lockless_ring_buffer_spsc()
{
write.store(0);
read.store(0);
}
bool try_push(int64_t val)
{
const auto current_tail = write.load();
const auto next_tail = increment(current_tail);
if (next_tail != read.load())
{
buffer[current_tail] = val;
write.store(next_tail);
return true;
}
return false;
}
void push(int64_t val)
{
while( ! try_push(val) );
// TODO: exponential backoff / sleep
}
bool try_pop(int64_t* pval)
{
auto currentHead = read.load();
if (currentHead == write.load())
{
return false;
}
*pval = buffer[currentHead];
read.store(increment(currentHead));
return true;
}
int64_t pop()
{
int64_t ret;
while( ! try_pop(&ret) );
// TODO: exponential backoff / sleep
return ret;
}
private :
std::atomic<int64_t> write;
std::atomic<int64_t> read;
static const int64_t size = RING_BUFFER_SIZE;
int64_t buffer[RING_BUFFER_SIZE];
int64_t increment(int n)
{
return (n + 1) % size;
}
};
int main (int argc, char** argv)
{
lockless_ring_buffer_spsc queue;
std::thread write_thread( [&] () {
for(int i = 0; i<1000000; i++)
{
queue.push(i);
}
} // End of lambda expression
);
std::thread read_thread( [&] () {
for(int i = 0; i<1000000; i++)
{
queue.pop();
}
} // End of lambda expression
);
write_thread.join();
read_thread.join();
return 0;
}
答案 9 :(得分:2)
查看Disruptor(How to use it),它是多个线程可以订阅的环形缓冲区:
答案 10 :(得分:2)
虽然这是一个老问题,但没有人提到DPDK的无锁环缓冲区。它是一个高吞吐量的环形缓冲区,支持多个生产者和多个消费者。它还提供单个消费者和单个生产者模式,并且环形缓冲区在SPSC模式下是等待的。它是用C语言编写的,支持多种体系结构。
此外,它还支持Bulk和Burst模式,其中项目可以批量入队/出列。设计允许多个消费者或多个生产者通过移动原子指针简单地保留空间来同时写入队列。
答案 11 :(得分:2)
这是一个老线程,但由于它还没有被提及,但是 - 有一个无锁,循环,1个生产者 - &gt; 1个消费者,FIFO在JUCE C ++框架中可用。
答案 12 :(得分:1)
为了完整性:在OtlContainers中有经过良好测试的无锁循环缓冲区,但它是用Delphi编写的(TOmniBaseBoundedQueue是循环缓冲区,TOmniBaseBoundedStack是有界堆栈)。在同一个单元(TOmniBaseQueue)中还有一个无界队列。 Dynamic lock-free queue – doing it right中描述了无界队列。 A lock-free queue, finally!中描述了有界队列(循环缓冲区)的初始实现,但从那时起代码已经更新。
答案 13 :(得分:1)
我将如何做到这一点:
插入包括使用带增量的CAS并在下一次写入时翻转。一旦你有一个插槽,添加你的值,然后设置匹配它的空/满位。
删除需要在测试下溢之前检查该位,但除此之外,与写入相同但使用读取索引并清除空/满位。
警告,
答案 14 :(得分:0)
您可以尝试lfqueue
使用简单,它是圆形设计,无锁
int *ret;
lfqueue_t results;
lfqueue_init(&results);
/** Wrap This scope in multithread testing **/
int_data = (int*) malloc(sizeof(int));
assert(int_data != NULL);
*int_data = i++;
/*Enqueue*/
while (lfqueue_enq(&results, int_data) != 1) ;
/*Dequeue*/
while ( (ret = lfqueue_deq(&results)) == NULL);
// printf("%d\n", *(int*) ret );
free(ret);
/** End **/
lfqueue_clear(&results);
答案 15 :(得分:0)
在某些情况下,不需要锁定即可防止出现竞争状况,尤其是当您只有一个生产者和消费者时。
考虑LDD3中的这一段:
如果精心实现,则在没有多个生产者或使用者的情况下,循环缓冲区不需要锁定。生产者是唯一允许修改写索引及其指向的数组位置的线程。只要写者在更新写索引之前将新值存储到缓冲区中,读者就始终会看到一致的视图。反过来,读取器是唯一可以访问读取索引及其指向的值的线程。小心确保两个指针不会彼此溢出,生产者和使用者可以在没有竞争条件的情况下同时访问缓冲区。
答案 16 :(得分:0)
大约2年前,我已经找到了解决此问题的非常简单的方法。
我在github存储库中发布了消息,但我不知道为什么(也许是冒名顶替综合症),除了匿名存储库之外,我从未提出解决方案。
但这是解决此问题的真正方法。它的工作原理非常简单而扎实。即使有数百个线程和一个位置的队列。我相信该解决方案是一项突破,可以有一些不错的应用程序。
作为新年的一项决议,我决定摆脱困境,把它暴露出来。
我希望你们中的一些人可以验证这项工作,并希望对如何在有用的东西上应用该解决方案提供帮助。或者至少可以打破它?
答案 17 :(得分:0)
如果以缓冲区永远不会变满为前提,请考虑使用以下无锁算法:
capacity must be a power of 2
buffer = new T[capacity] ~ on different cache line
mask = capacity - 1
write_index ~ on different cache line
read_index ~ on different cache line
enqueue:
write_i = write_index.fetch_add(1) & mask
buffer[write_i] = element ~ release store
dequeue:
read_i = read_index.fetch_add(1) & mask
element
while ((element = buffer[read_i] ~ acquire load) == NULL) {
spin loop
}
buffer[read_i] = NULL ~ relaxed store
return element