C#跨线程安全地使用LINQ

时间:2017-09-07 23:28:20

标签: c# multithreading linq thread-safety

我有一个程序,它不断地从WebSocket读取和解析大量数据流。所有解析都在客户端的一个线程上进行,并且数据被组织到SortedSet<T>树中以便快速操作。

所有数据都可以毫不费力地添加,更新和删除。

当我尝试从另一个线程访问数据时出现问题。它会运行良好,但沿线的某个地方是一个竞争条件,将在一两分钟内被击中。

考虑这段代码(在自己的线程上运行)以近乎实时的方式更新UI:

private async Task RenderOrderBook()
{
    var book = _client.OrderBook;

    while (true)
    {
        try
        {
            var asks = book.Asks.OrderBy(i => i.Price).Take(5).OrderByDescending(i => i.Price);
            var bids = book.Bids.OrderByDescending(i => i.Price).Take(5);

            orderBookView.BeginInvoke(new MethodInvoker(() =>
            {
                ...omitted due to irrelevance
            }));

            await Task.Delay(500);
        }
        catch (Exception ex)
        {
            ex.ToString();
        }
    }
}

竞争条件位于book的LINQ操作中。常见错误是i.Price(一个decimal变量),或者只是对象i所指的,是空的。另外,我粗暴地试图吞下异常实际上并不起作用。

无论如何,我的猜测是数据被解析和操作得如此之快,最终,当使用LINQ OrderBy操作时,它将遇到客户端已删除节点,尝试从中读取数据的情况,以及抛出异常。

book.Asksbook.Bids属性最初属于SortedSet<T>类型,并直接指向数据成员本身。为了缓解这种竞争条件的情况,我尝试将它们更改为节点的数组,并使用_asks.ToArray()调用实质上创建一个要读取的副本。这有助于使问题的发生频率降低,但仍然确实会发生。

如何使这个线程安全?

其他代码段

public PriceNode[] Asks
{
    get { return _asks.ToArray(); }
}

public PriceNode[] Bids
{
    get { return _bids.ToArray(); }
}

1 个答案:

答案 0 :(得分:4)

我的第一个UI开发规则是你永远不会在UI线程上执行I / O.听起来你已经覆盖了那个。

我的第二个规则是,一旦UI线程可以看到某些内容,您就无法从任何其他线程触摸它。这个规则确实有一个例外,那就是不可变数据:如果一个对象不会改变,那么任何线程都可以触摸它。可变数据? 没有接触。请记住,“可变数据”包括大多数集合。

如果你能遵守这两条规则,那么你的生活将变得如此简单。跟随一个没有打破另一个可能是棘手的,但有办法做到这一点,一旦你有一个体面的抓地力,你将在一个更好的地方。启蒙之路从这里开始:

允许您的读取线程(读取套接字的线程)创建它想要的所有新对象,但它无法更新现有对象。它也无法修改UI线程正在使用的任何集合。如果你只是添加新对象,这并不是那么糟糕:你的读取线程可以从套接字中提取数据并用它来烹饪新对象。当这些对象准备就绪时,它必须将它们交给UI线程,UI线程可以将它们添加到相关的集合中。根据Strobel的规则#1,大部分工作(以及所有I / O)都发生在读取线程上,这正是我们想要的。通过比较,“提交”已经填充的对象的行为应该是微不足道的。根据规则#2,一旦任何可变对象被移交给UI线程,您的读取线程就不能再次触及它们。如初。

更新现有对象比较棘手。有几种方法可以解决这个问题。一种是让读取线程使用最新数据来创建新对象,然后将其移交给UI线程。如果您有非常简单对象图,最简单的选项可能是简单地用旧版本替换旧对象,请记住引用旧对象的任何UI代码都需要知道它已被替换。或者,UI线程可以使用来自新对象的数据来更新现有对象。如果你遵循规则#2,这将是完全线程安全的,并且指向旧对象的任何UI代码自动地看到新数据而没有任何撕裂的读取或其他与种族相关的肮脏。这种方法可能是你最好的选择。

如果在尝试上一段中的方法后,您发现生成了不可接受的垃圾量,则有第三种选择。读取线程可以将每个对象的原始数据复制到临时缓冲区,然后将缓冲区交给UI线程,UI线程可以使用缓冲区中的数据来更新现有对象。这意味着在UI线程上发生了更多的工作,但至少数据已经在内存中(套接字I / O已经完成)。由于这种方法的目的是创建更少的垃圾,因此只有在重用缓冲区时才有意义。这意味着您需要一个线程安全的缓冲池。读取线程获取一个临时缓冲区,从套接字填充它,将其交给UI线程,当线程完成时将其返回到池中。精明的读者会注意到,在线程之间传递可变缓冲区会违反规则#2,因此请注意,一旦线程移交缓冲区,它会立即忘记它。因为这种方法需要更强的线程安全性,以使池工作,我推荐它只是作为最后的手段。如果您可以使用前一段中的一个选项,请执行此操作。

无论您使用哪种方法更新现有对象,都需要一种方法将新对象/数据与旧对象进行匹配。如果每个对象都有唯一标识符,则可以使用Dictionary<,>作为有效的查找机制。用旧版本替换旧版本更复杂,因为旧版本可能分散在多个集合中,其中一些可能不支持有效替换。

最后一件事:当您将新的/更新的对象移交给UI线程时,最好是批量执行。例如,您最好向UI线程发布单个操作以更新100个对象,而不是发布每个更新一个对象的100个单独操作。