【问题标题】:C# Safely using LINQ across threadsC# 跨线程安全使用 LINQ
【发布时间】:2017-09-07 23:28:20
【问题描述】:

我有一个程序不断地从 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.Pricedecimal 变量),或者可能只是 i 所指的对象,为空。此外,我只是吞下异常的粗制滥造的尝试实际上并没有奏效。

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

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

我怎样才能使这个线程安全?

其他代码片段

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

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

【问题讨论】:

  • 使用像ConcurrentDictionary 这样的线程安全集合,或者像 System.Immutable nuget 包中那样的不可变集合可能会让你完成 90% 的工作。
  • 您显示的代码中没有任何内容与线程或竞争条件有任何问题。唯一可能存在问题的地方是...omitted due to irrelevance 代码。是否可以显示该代码?

标签: c# multithreading linq thread-safety


【解决方案1】:

我的 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 个单独的操作,每个操作更新一个对象。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-05-17
    • 1970-01-01
    • 2015-08-11
    • 1970-01-01
    相关资源
    最近更新 更多