C# 线程和队列

13

这不是关于我能或应该使用哪些不同方法来最好地利用队列,而是我看到一些毫无意义的事情正在发生。

void Runner() {
    // member variable
    queue = Queue.Synchronized(new Queue());
    while (true) {
        if (0 < queue.Count) {
            queue.Dequeue();
        }
    }
}

这是在单个线程中运行的:

var t = new Thread(Runner);
t.IsBackground = true;
t.Start();

其他事件已在别处“排队”。我所见过的情况是,随着时间的推移,“出队”将抛出InvalidOperationException异常,队列为空。这应该是不可能的,因为计数保证了队列中有东西,而且我确定没有其他地方正在“出队”。
问题如下:
1. 可能吗,入队操作会在项目完全进入队列之前增加计数(无论这是什么意思)? 2. 可能吗,线程在Dequeue语句处重新开始(过期、重置...),但在此之后它已经立即移除了一个项目?
编辑(澄清):
这些代码片段是包装器类的一部分,实现后台辅助线程。这里的Dequeue是唯一的Dequeue,并且所有的Enqueue/Dequeue都在同步的成员变量(queue)上进行。

因为Ryan的回答...这是真实的代码还是一个简单的例子?如果这是真实的代码,你应该真的考虑改变循环 - 轮询队列而不是将读者与写入者同步是一个糟糕的设计。你正在浪费数百万处理器周期来加热房间。 - Daniel Brückner
这只是一个例子,以便深入了解问题的核心。其中有一个Thread.Sleep,处理器并没有被过度使用。我选择轮询进程而不是同步读/写是因为队列几乎一直有内容。尽管如此,在我们的主干中,我添加了一个AutoResetEvent来进行测试。就像我在开头所说的,我对实现并不是特别担心。这个线程模型似乎存在着真正的问题,无论对错。 - neouser99
从你的代码来看,你至少有主线程和你认为 Dequeue 被调用的线程。为什么不给你的线程命名,并且每次调用 Dequeue 时,记录线程的名称和堆栈跟踪。你可能会发现主线程中的某些行为并不是你所期望的。 - Brad Barker
你应该使用 BlockingCollection来完成,而不是使用同步队列。 - BlueRaja - Danny Pflughoeft
6个回答

12
使用 Reflector 工具,你可以看到在添加项之前,计数并没有增加。 正如 Ben 指出的那样,似乎确实有多个人调用了 dequeue 方法。 你说你确定没有其他东西调用 dequeue 方法。这是因为只有一个线程调用 dequeue 吗?还是在其他地方也调用了 dequeue 方法? 编辑: 我写了一段小示例代码,但是无法重现问题。它一直在运行,没有任何异常。 你发现错误前它运行了多长时间?或许你可以分享一些更多的代码。
class Program
{
    static Queue q = Queue.Synchronized(new Queue());
    static bool running = true;

    static void Main()
    {
        Thread producer1 = new Thread(() =>
            {
                while (running)
                {
                    q.Enqueue(Guid.NewGuid());
                    Thread.Sleep(100);
                }
            });

        Thread producer2 = new Thread(() =>
        {
            while (running)
            {
                q.Enqueue(Guid.NewGuid());
                Thread.Sleep(25);
            }
        });

        Thread consumer = new Thread(() =>
            {
                while (running)
                {
                    if (q.Count > 0)
                    {
                        Guid g = (Guid)q.Dequeue();
                        Console.Write(g.ToString() + " ");
                    }
                    else
                    {
                        Console.Write(" . ");
                    }
                    Thread.Sleep(1);
                }
            });
        consumer.IsBackground = true;

        consumer.Start();
        producer1.Start();
        producer2.Start();

        Console.ReadLine();

        running = false;
    }
}

非常好,我实际上有一个测试用例看起来非常接近这个,而且它能够理解主旨。我想服务在崩溃前大约运行了2周左右。 - neouser99
8
目前,我能建议的只有这些:(1)您进入了 If 部分,(2)太阳耀斑将您的位翻转为零,(3)您的队列抛出了一个异常。它实际上应该是抛出一个 SolarFlareException 异常,但无论如何,没什么区别。 Potatoh,Potahto。 - Erich Mirabal

3

以下是我认为存在问题的顺序:

  1. (0 < queue.Count) 的返回值为true,队列不为空。
  2. 本线程被抢占,另一个线程开始运行。
  3. 另一个线程从队列中取出一个项目,队列变为空。
  4. 本线程恢复执行,但现在在if块内,并试图从空列表中取消排队。

但是,你说没有其他东西在出队列...

尝试在if块内输出计数。如果您看到计数向下跳跃数字,则有其他人在出队列。


2
这也是我的第一反应,但他说:“我确定没有其他东西正在进行“Dequeue”操作。” - BFree
和我想的差不多,但我认为他应该澄清一下让他如此确定没有其他东西在调用 Dequeue 的事实。 - Erich Mirabal
无论其他线程是否正在出列,Count和Dequeue之间存在竞争条件。最好解决竞争条件并转向真正的问题。 - Curt Nichols

3

以下是来自MSDN页面的可能答案:

枚举集合本质上不是一个线程安全的过程。即使集合已同步,其他线程仍然可以修改集合,这会导致枚举器抛出异常。为了在枚举期间保证线程安全,您可以锁定整个枚举集合或捕获由其他线程所做更改而导致的异常。

我猜您是正确的——在某些情况下,存在一种竞争条件,最终您将弹出一个不存在的东西。

在这里,使用Mutex或Monitor.Lock可能是适当的。

祝好运!


2
其他正在“Enqueuing”数据的区域是否也使用相同的同步队列对象?为了使Queue.Synchronized线程安全,所有Enqueue和Dequeue操作必须使用相同的同步队列对象。
来自MSDN

为了保证Queue的线程安全性,所有操作都必须仅通过此包装器完成。

如果您正在循环处理许多需要大量计算的项目,或者正在使用长期线程循环(如通信等),则应该考虑在循环中加入等待函数,例如 System.Threading.Thread.SleepSystem.Threading.WaitHandle.WaitOneSystem.Threading.WaitHandle.WaitAllSystem.Threading.WaitHandle.WaitAny,否则可能会危及系统性能。

1
睡眠允许系统共享CPU和资源,否则线程将消耗大量资源(在某些情况下会使其他线程和进程饥饿)。显然这是示例代码,但是有一个没有退出条件的while(true),所以我提到了这一点。Sleep(0)存在问题,无法将控制权交给优先级较低的线程,因此建议使用Sleep(1)。如果线程循环被计时或具有有限的时间跨度,则可能不需要Sleep。我见过许多Windows服务,其中单个Sleep语句将CPU使用率从接近80-100%降至<1%。 - Ryan
如果Thread.Sleep(1)显著降低了处理器使用率,那么你的循环大部分时间都在空转。因此,你应该使用一些形式的同步来使线程仅在有工作时运行。即使将处理器使用率降至百分之一,仍然浪费了数千个周期 - 你有一个简单的if语句(对于简单检查可能需要10个周期)和两个上下文切换(每次迭代之间2000到8000个周期)。或者从另一方面看 - 100个绝对无所事事的线程将消耗你所有的处理器时间。 - Daniel Brückner
我同意Sleep有很多用途,但在每种情况下都有一个“更好”的解决方案 - 只是可能不太实际。例如,一个定期监视目录中新文件的线程。理想情况下,您会使用FileSystemWatcher事件或类似事件来检测文件的创建,但并非总是可用。然而,像线程一样频繁地列出目录也不切实际,因此可以定义一个合理的睡眠时间,基于您认为目录何时添加新文件以及在注意到之前可以接受多长时间。 - deed02392
@deed02392 哇,你真的挖出了一条旧评论。在我的经验中,我开发的许多工程应用和嵌入式产品中,通信线程非常重要。通常这是串行或网络数据,它在循环中不断运行。我使用了类似于WaitAny([serialEvent, abortEvent])的等待函数来检测传入的通信或中止信号。在旧机器上,这产生了巨大的差异,但在现代机器上并没有那么明显。我仍然认为,在负载较重的现代机器上,它可能会有所不同。 - Ryan
在一个通信应用程序中,添加10毫秒的等待时间将CPU使用率从90%降低到15%。对于许多应用程序来说,添加等待函数并不是必要的,但肯定存在某些情况下它会产生巨大差异,并且在某些情况下它是“最好”的解决方案。通常我的使用是等待事件发生(传入通信)或者等待中止标志。您的示例(FileSystemWatcher)仍然在内部使用事件以及IOCP和等待函数。然而,通常最好使用现有的类和功能,而不是构建自己的类。 - Ryan
显示剩余6条评论

1

问题1:如果您正在使用同步队列,那么不用担心!但是您需要在供应商和提供者两侧都使用同步实例。

问题2:当没有工作要做时终止工作线程是一项简单的任务。然而,您需要一个监控线程或者让队列在有任务时启动后台工作线程。后者更像是ActiveObject模式,而不是简单的队列(其单一职责原则表明它只应该排队)。

此外,我建议您使用阻塞队列而不是上面的代码。您的代码方式即使没有工作要做也需要CPU处理能力。阻塞队列可以让您的工作线程在没有任务时休眠。您可以运行多个休眠线程而不使用CPU处理能力。

C#没有阻塞队列实现,但是有很多其他的实现。请参见这个示例和这个一个


0

另一种实现线程安全队列的选择是使用自2009年(本问题的年份)引入的ConcurrentQueue<T>类。这可以避免编写自己的同步代码,或者至少使其变得更加简单。

从.NET Framework 4.6开始,ConcurrentQueue<T>还实现了接口IReadOnlyCollection<T>


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接