【问题标题】:Is this lock-free .NET queue thread safe?这个无锁的 .NET 队列线程安全吗?
【发布时间】:2009-10-09 02:16:08
【问题描述】:

我的问题是,下面包含的单读单写队列类是否是线程安全的?这种队列称为无锁队列,即使队列满了也会阻塞。数据结构的灵感来自 StackOverflow 上的Marc Gravell's implementation of a blocking queue

该结构的要点是允许单个线程将数据写入缓冲区,并允许另一个线程读取数据。所有这些都需要尽快发生。

article at DDJ by Herb Sutter 中描述了类似的数据结构,但实现是在 C++ 中。另一个区别是我使用的是普通链表,我使用的是数组链表。

我不只是包含一个 sn-p 代码,而是将整个内容包含在带有许可的开源许可证(MIT 许可证 1.0)的注释中,以防万一有人发现它有用并想要使用它(按原样或修改后) .

这与 Stack Overflow 上关于如何创建阻塞并发队列的其他问题有关(请参阅 Creating a blockinq Queue in .NETThread-safe blocking queue implementation in .NET)。

代码如下:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Diagnostics;

namespace CollectionSandbox
{
    /// This is a single reader / singler writer buffered queue implemented
    /// with (almost) no locks. This implementation will block only if filled 
    /// up. The implementation is a linked-list of arrays.
    /// It was inspired by the desire to create a non-blocking version 
    /// of the blocking queue implementation in C# by Marc Gravell
    /// https://stackoverflow.com/questions/530211/creating-a-blocking-queuet-in-net/530228#530228
    class SimpleSharedQueue<T> : IStreamBuffer<T>
    {
        /// Used to signal things are no longer full
        ManualResetEvent canWrite = new ManualResetEvent(true);

        /// This is the size of a buffer 
        const int BUFFER_SIZE = 512;

        /// This is the maximum number of nodes. 
        const int MAX_NODE_COUNT = 100;

        /// This marks the location to write new data to.
        Cursor adder;

        /// This marks the location to read new data from.
        Cursor remover;

        /// Indicates that no more data is going to be written to the node.
        public bool completed = false;

        /// A node is an array of data items, a pointer to the next item,
        /// and in index of the number of occupied items 
        class Node
        {
            /// Where the data is stored.
            public T[] data = new T[BUFFER_SIZE];

            /// The number of data items currently stored in the node.
            public Node next;

            /// The number of data items currently stored in the node.
            public int count;

            /// Default constructor, only used for first node.
            public Node()
            {
                count = 0;
            }

            /// Only ever called by the writer to add new Nodes to the scene
            public Node(T x, Node prev)
            {
                data[0] = x;
                count = 1;

                // The previous node has to be safely updated to point to this node.
                // A reader could looking at the point, while we set it, so this should be 
                // atomic.
                Interlocked.Exchange(ref prev.next, this);
            }
        }

        /// This is used to point to a location within a single node, and can perform 
        /// reads or writers. One cursor will only ever read, and another cursor will only
        /// ever write.
        class Cursor
        {
            /// Points to the parent Queue
            public SimpleSharedQueue<T> q;

            /// The current node
            public Node node;

            /// For a writer, this points to the position that the next item will be written to.
            /// For a reader, this points to the position that the next item will be read from.
            public int current = 0;

            /// Creates a new cursor, pointing to the node
            public Cursor(SimpleSharedQueue<T> q, Node node)
            {
                this.q = q;
                this.node = node;
            }

            /// Used to push more data onto the queue
            public void Write(T x)
            {
                Trace.Assert(current == node.count);

                // Check whether we are at the node limit, and are going to need to allocate a new buffer.
                if (current == BUFFER_SIZE)
                {
                    // Check if the queue is full
                    if (q.IsFull())
                    {
                        // Signal the canWrite event to false
                        q.canWrite.Reset();

                        // Wait until the canWrite event is signaled 
                        q.canWrite.WaitOne();
                    }

                    // create a new node
                    node = new Node(x, node);
                    current = 1;
                }
                else
                {
                    // If the implementation is correct then the reader will never try to access this 
                    // array location while we set it. This is because of the invariant that 
                    // if reader and writer are at the same node: 
                    //    reader.current < node.count 
                    // and 
                    //    writer.current = node.count 
                    node.data[current++] = x;

                    // We have to use interlocked, to assure that we incremeent the count 
                    // atomicalluy, because the reader could be reading it.
                    Interlocked.Increment(ref node.count);
                }
            }

            /// Pulls data from the queue, returns false only if 
            /// there 
            public bool Read(ref T x)
            {
                while (true)
                {
                    if (current < node.count)
                    {
                        x = node.data[current++];
                        return true;
                    }
                    else if ((current == BUFFER_SIZE) && (node.next != null))
                    {
                        // Move the current node to the next one.
                        // We know it is safe to do so.
                        // The old node will have no more references to it it 
                        // and will be deleted by the garbage collector.
                        node = node.next;

                        // If there is a writer thread waiting on the Queue,
                        // then release it.
                        // Conceptually there is a "if (q.IsFull)", but we can't place it 
                        // because that would lead to a Race condition.
                        q.canWrite.Set();

                        // point to the first spot                
                        current = 0;

                        // One of the invariants is that every node created after the first,
                        // will have at least one item. So the following call is safe
                        x = node.data[current++];
                        return true;
                    }

                    // If we get here, we have read the most recently added data.
                    // We then check to see if the writer has finished producing data.
                    if (q.completed)
                        return false;

                    // If we get here there is no data waiting, and no flagging of the completed thread.
                    // Wait a millisecond. The system will also context switch. 
                    // This will allow the writing thread some additional resources to pump out 
                    // more data (especially if it iself is multithreaded)
                    Thread.Sleep(1);
                }
            }
        }

        /// Returns the number of nodes currently used.
        private int NodeCount
        {
            get
            {
                int result = 0;
                Node cur = null;
                Interlocked.Exchange<Node>(ref cur, remover.node);

                // Counts all nodes from the remover to the adder
                // Not efficient, but this is not called often. 
                while (cur != null)
                {
                    ++result;
                    Interlocked.Exchange<Node>(ref cur, cur.next);
                }
                return result;
            }
        }

        /// Construct the queue.
        public SimpleSharedQueue()
        {
            Node root = new Node();
            adder = new Cursor(this, root);
            remover = new Cursor(this, root);
        }

        /// Indicate to the reader that no more data is going to be written.
        public void MarkCompleted()
        {
            completed = true;
        }

        /// Read the next piece of data. Returns false if there is no more data. 
        public bool Read(ref T x)
        {
            return remover.Read(ref x);
        }

        /// Writes more data.
        public void Write(T x)
        {
            adder.Write(x);
        }

        /// Tells us if there are too many nodes, and can't add anymore.
        private bool IsFull()
        {
            return NodeCount == MAX_NODE_COUNT;  
        }
    }
}

标签: c# .net algorithm multithreading data-structures


【解决方案1】:

Microsoft Research CHESS 应该被证明是测试您的实现的好工具。

【讨论】:

  • Ahhhh....research.microsoft.com/en-us/projects/chess 从技术上讲,这可能是我问题的正确答案。 X 是用 .NET 线程安全编写的吗?运行国际象棋。酷!!!
  • 而且,如果 CHESS 不适合你,你可以试试 Jinx。
  • Corensic 网站已经关闭了一段时间。
【解决方案2】:

Sleep() 的存在使得无锁方法完全没用。面对无锁设计的复杂性的唯一原因是需要绝对速度并避免信号量的成本。 Sleep(1) 的使用完全违背了这个目的。

【讨论】:

  • Sleep() 调用只有在消费者比生产者快时才会发生,这表明使用不当。我本可以设置一个旋转等待,但这将是对资源的低效使用。我不想使用信号量,因为那样另一个线程在推送数据时必须设置它,这似乎太低效了。我愿意接受有关该做什么的建议。
  • 我认为最好的建议是使用锁定队列。我没有深入阅读代码,但我注意到此页面上缺少“障碍”一词。即使您有一个“正确”的算法,写入重新排序等仍然可能使其失败。
  • >> 我认为最好的建议是使用锁定队列。为什么?我对并发编程的所有研究都指出,过度使用锁是大规模并行架构可扩展性的障碍。 >> 我没有深入研究代码。那么在这种情况下,您对Sleep() 的看法可能是错误的吗? >> 即使你有一个“正确”的算法,写重新排序等仍然可能使它失败这正是我小心使用Interlocked 操作的原因。
  • @cdiggins:然后我们将等待您花费数天(数周)对其进行测试并使用 CHESS 进行验证。我希望你能发布结果。我不相信代码审查在这里会有任何帮助。
【解决方案3】:

鉴于我找不到 Interlocked.Exchange 读取或写入块的任何引用,我会说不是。我还想问你为什么要无锁,因为很少有足够的好处来应对它的复杂性。

Microsoft 在 2009 年 GDC 上对此进行了出色的演示,您可以获取幻灯片 here

【讨论】:

  • 我不明白你的意思,Interlocked.Exchange 不读取或写入块。您能否解释一下为什么它不是线程安全的逻辑?至于复杂性和收益的问题,你能说得更具体一点吗?我在这里有一个非常具体的例子,我想知道。感谢您的宝贵时间!
【解决方案4】:

注意双重检查 - 单一锁定模式(如上面引用的链接:http://www.yoda.arachsys.com/csharp/singleton.html

逐字引用 Andrei Alexandrescu 的“现代 C++ 设计”

    非常有经验的多线程程序员知道,即使是双重检查锁定模式,虽然在纸面上是正确的,但在实践中并不总是正确的。在某些对称多处理器环境(以所谓的宽松内存模型为特色的环境)中,写入会以突发的方式提交到主内存,而不是一个一个地提交。突发按地址的递增顺序发生,而不是按时间顺序发生。由于写入的这种重新排列,一个处理器一次看到的内存可能看起来好像不是由另一个处理器以正确的顺序执行的操作。具体来说,处理器对 pInstance_ 的赋值可能发生在 Singleton 对象完全初始化之前!因此,遗憾的是,已知双重检查锁定模式对于此类系统是有缺陷的

【讨论】:

  • CLI 应该强制执行 .NET CLI 的内存模型,而不管 SMP 环境的内存模型是什么。但是,是的,了解内存模型对于无锁编程至关重要。
  • 锁定意味着关键区域周围有完整的栅栏(内存屏障),因此它不可能在任何系统上发生。您可能会得到误报,但一旦您处于关键区域,您总是会看到最新数据。
【解决方案5】:

我怀疑它不是线程安全的 - 想象一下以下场景:

两个线程进入cursor.Write。第一个在if (current == BUFFER_SIZE) 语句的真正一半中到达node = new Node(x, node); 行(但我们也假设current == BUFFER_SIZE)所以当1 被添加到current 时,另一个线程进入将遵循另一条路径if 语句。现在假设线程 1 丢失了它的时间片,而线程 2 得到了它,然后继续输入 if 语句,错误地认为条件仍然成立。它应该已经进入了另一条路径。

我也没有运行过这段代码,所以我不确定我的假设在这段代码中是否可行,但如果是(即在current == BUFFER_SIZE 时输入 cursor.Write 从多个线程),那么它很可能容易出现并发错误。

【讨论】:

  • 他确实提到这是一个单一作者/单一读者队列。
  • 是的,也许我应该更突出这一点。这段代码对于多个作者或读者来说绝对不是线程安全的。
【解决方案6】:

首先,我想知道这两行顺序代码中的假设:

                node.data[current++] = x;

                // We have to use interlocked, to assure that we incremeent the count 
                // atomicalluy, because the reader could be reading it.
                Interlocked.Increment(ref node.count);

也就是说node.data[]的新值已经被提交到这个内存位置了?它没有存储在易失性内存地址中,因此如果我理解正确可以缓存?这不会导致“脏”读吗?可能还有其他地方也是如此,但这一个一眼就看出来了。

第二个多线程代码,包含以下内容:

Thread.Sleep(int);

...从来都不是一个好兆头。如果需要,那么代码注定会失败,如果不需要,那就是浪费。我真的希望他们能完全删除这个 API。意识到这是一个至少等待该时间的请求。随着上下文切换的开销,您几乎肯定会等待更长的时间,更长的时间。

第三,我完全不明白这里Interlock API的使用。也许我累了,只是没抓住重点;但是我在读取和写入同一个变量的两个线程上找不到潜在的线程冲突?看来我能找到的互锁交换的唯一用途是修改 node.data[] 的内容以修复上面的#1。

最后,实现似乎有些过于复杂。我错过了整个 Cursor/Node 事情的重点,还是它基本上和这个类做同样的事情? (注意:我还没有尝试过,我也不认为这是线程安全的,只是想总结一下我认为你在做什么。)

class ReaderWriterQueue<T>
{
    readonly AutoResetEvent _readComplete;
    readonly T[] _buffer;
    readonly int _maxBuffer;
    int _readerPos, _writerPos;

    public ReaderWriterQueue(int maxBuffer)
    {
        _readComplete = new AutoResetEvent(true);
        _maxBuffer = maxBuffer;
        _buffer = new T[_maxBuffer];
        _readerPos = _writerPos = 0;
    }

    public int Next(int current) { return ++current == _maxBuffer ? 0 : current; }

    public bool Read(ref T item)
    {
        if (_readerPos != _writerPos)
        {
            item = _buffer[_readerPos];
            _readerPos = Next(_readerPos);
            return true;
        }
        else
            return false;
    }

    public void Write(T item)
    {
        int next = Next(_writerPos);

        while (next == _readerPos)
            _readComplete.WaitOne();

        _buffer[next] = item;
        _writerPos = next;
    }
}

所以我在这里完全偏离了基础并且没有看到原始课程中的魔力吗?

我必须承认一件事,我鄙视线程。我见过最好的开发人员在这方面失败了。这篇文章给出了一个很好的例子,说明正确处理线程有多难: http://www.yoda.arachsys.com/csharp/singleton.html

【讨论】:

  • 1.好点子。看起来很明显的失败。 2. 这是我对 Thread.Sleep(1) 的基本原理 a) 两个线程都在完全倾斜,没有用户交互 b) 如果达到 Thread.Sleep(1),那么生产者比消费者快。因此,只需让操作系统使用生产者的线程将更多数据泵入队列即可。 3GHZ 机器上的 1 毫秒应该是 300 万条指令。足以进行上下文切换并将大量数据泵回队列。 3. 联锁与妄想症一起使用。 :-) 4. 见下一条评论,空间不足。
  • 4.我设计缓冲区链接列表的目标是允许此队列根据需要变小或变大。如果消耗很快,那么只需要一个小缓冲区,否则可能需要一个更大的缓冲区。我的目标是让这些缓冲区的整个链协同工作。你的cmets很棒。我也非常讨厌穿线。我的目标是创建一些原始线程工具,可用于构建并行算法而不会让人头疼(例如,从生产者流向消费者,而不必担心线程问题。
  • 我刚刚意识到在我的实现中有一个误导性的常数:最大缓冲区为 4。这违背了上述设计目的。将其设为 100 更明智,现在阵列可以根据需要从 2k 增长或缩小到 200k(假设是 32 位系统)。
  • RE:“3GHZ 机器上的 1 毫秒应该是 300 万条指令……”听起来您不知道 Thread.Sleep 的本质。请参阅stackoverflow.com/questions/508208/… 了解更多信息。基本上,在默认情况下,Thread.Sleep(1) 几乎总是会产生至少相当于 Thread.Sleep(15ish) 的结果。
  • @Criss:在较旧的操作系统上只有 15 毫秒。 WinXP和其他一些使用“滴答”做线程调度,这是硬件的周期性中断,默认为15ms。较新的内核(如 Win7)使用支持微秒分辨率的无滴答设计。在我使用的每个系统上,睡眠 (1) 几乎正好是 1 毫秒。
猜你喜欢
  • 2010-12-10
  • 2023-03-18
  • 2012-02-14
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2011-04-28
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多