使用C#结构体实现生产者/消费者模式?

7
我有一个单例对象来处理请求。每个请求需要大约一毫秒的时间完成,通常更短。该对象不是线程安全的,并且它期望以特定格式封装在Request类中的请求,并将结果作为Response返回。此处理器还具有通过套接字发送/接收的另一个生产者/消费者。
我实现了生产者/消费者方法以快速工作:
- 客户端准备一个包含TaskCompletionSource<Response>和预期RequestRequestCommand命令对象。 - 客户端将命令添加到“请求队列”(Queue<>),并等待command.Completion.Task。 - 不同的线程(实际的后台Thread)从“请求队列”中提取命令,处理command.Request,生成Response,并使用command.Completion.SetResult(response)标记命令已完成。 - 客户端继续工作。
但是,在进行小型内存基准测试时,我看到有很多这些对象被创建,并排在内存中最常见的对象列表的顶部。请注意,没有内存泄漏,GC可以在每次触发时很好地清理所有内容,但显然,这么多对象被快速创建,使Gen 0变得非常大。我想知道更好的内存使用可能会产生更好的性能。
我考虑将其中一些对象转换为结构体,以避免分配,特别是现在有一些新功能可以使用它们C#7.1。但我没有看到任何可以这样做的方式。
- 值类型可以在堆栈中实例化,但如果它们从一个线程传递到另一个线程,它们必须被复制到堆栈A->堆和堆->堆栈B。还有,当在队列中排队时,它从堆栈到堆。 - 单例对象是真正异步的。有一些内存处理,但90%的时间需要通过内部生产者/消费者调用外部。 - ValueTask<>似乎不适合这里,因为事情是异步的。 - TaskCompletionSource<>有一个状态,但它是object,所以它会被装箱。 - 命令也会从线程跳到线程。 - 只有命令本身才能回收利用,其内容无法回收利用(TaskCompletionSource<>string)。
是否有任何方法可以利用结构体来减少内存使用或/和提高性能?还有其他选择吗?

顺便提一下:为什么要使用非线程安全的 Queue<T>?尝试使用 BlockingCollection<T>ConcurrentQueue<T> - Dmitry Bychenko
请问您能提供相关的代码吗?仅有代码描述很难理解。 - Dmitry Bychenko
如果您正在创建大量短暂的Gen0对象并且没有其他操作,那么Gen0集合的成本相当便宜。而非Gen0的操作才会消耗时间和执行周期。 - Flydog57
抱歉,我太过沉迷于内存问题而忘记了真正的目标——提高性能。@DmitryBychenko,在只有一个消费者和一个生产者的情况下,我发现使用ConcurrentQueue并没有提高性能。我使用简单的 `lock' 同步块、Wait 和 Pulse 方法。 - Vlad
1个回答

26
值类型可以在栈上实例化,但如果它们从一个线程传递到另一个线程,则必须复制到栈A->堆和堆->栈B。不过,这完全不正确。但你在思考方面存在更深层次的问题:立即停止将结构体视为存在于栈上。当你创建一个包含一百万个int的int数组时,你认为这四百万字节的int存在于你的一百万字节的栈上吗?当然不是。事实是,栈与堆根本与值类型无关。请开始使用“短期分配池”和“长期分配池”而不是“栈和堆”。变量具有短生命周期时,无论该变量包含int还是对象的引用,都会从短期分配池中分配。一旦您正确地思考变量的寿命,那么您的推理就变得完全简单明了。显然,短生命的东西存在于短期池中。所以,当你从一个线程传递一个结构体到另一个线程时,它是否曾经“位于堆上”?这个问题是不合情理的,因为值不是存在于堆上的东西。变量是存储器件;变量存储值。所以,将类转换为结构体是否能提高性能,因为“这些结构体可以存储在堆栈上”?不,当然不是。与引用类型和值类型相关的区别不在于它们所在的位置,而在于它们是如何被复制的。值类型按值复制,引用类型按引用复制,并且引用复制是最快的。
我看到有很多这些对象被创建,并成为内存中最常见的对象之一。请注意,没有内存泄漏,GC每次触发时都可以很好地清除所有东西,但是显然快速创建这么多对象会使Gen 0变得非常庞大。我想知道更好的内存使用是否可以产生更好的性能。
好的,现在我们来到您问题的有意义部分。这是一个很好的观察结果,可以用科学进行测试。您应该做的第一件事是使用分析器确定Gen 0集合对您的应用程序性能的实际负担是什么。
可能情况是,这种负担并不是程序中最慢的部分,实际上它是无关紧要的。在这种情况下,您现在将了解要专注于真正的问题,而不是追踪不是真正问题的内存分配问题。假设你发现 0 代集合真的影响了性能;那么你可以做什么?是把更多的东西变成结构体吗?这可能有用,但你必须非常小心:
- 如果这些结构体本身包含引用,则只是把问题推到了另一个级别,而没有解决它。 - 如果结构体大于引用大小 - 当然它们几乎总是这样 - 那么现在你正在通过复制整个结构体来复制它们,而不是复制引用,并且你已经将 GC 时间问题换成了复制时间问题。这可能是一种胜利或失败;使用科学找出它是哪一个。
当我们在 Roslyn 中面对这个问题时,我们非常仔细地考虑了它并进行了很多实验。我们采取的策略通常不是将东西移动到堆栈上。相反,我们识别了每种类型在任何时候都在内存中活跃的小型短暂对象的数量 - 使用分析器 - 然后在这些对象上实现了池化策略。需要一个小对象,就从池中取出来。完成后,将其放回池中。结果是,您最终拥有 O(在任何时候活动对象的数量) 的池,在快速移动到第 2 代堆上,从而大大降低了第 0 代堆上的收集压力,同时增加了相比较罕见的第 2 代收集的成本。

我并不是说这对你是最好的选择。我是在说我们在Roslyn也遇到了同样的问题,而我们用科学方法解决了它。你也可以这样做。


@LucaCremonesi:请注意,C#允许根据需要延长或缩短短暂变量的生命周期,前提是这样做不违反C#的其他规则。如果您有一个持有引用的短暂本地变量,则该本地变量在控制处于变量范围内的整个时间内不需要成为GC的根;它可能会被提前回收。同样,生命周期可以延长到控制离开作用域的时间点之后。不要混淆作用域和生命周期;它们只有弱连接。 - Eric Lippert
数组是变量的集合。为什么int类型的数组被认为是变量的集合呢?你的意思是myArray[0]、myArray[1]等应该被视为单独的变量吗? - Luca Cremonesi
2
@LucaCremonesi:当然它们是独立的变量。它们包含值,而且它们可以变化。我们称之为变量,因为变量是能够变化的东西。你可以在任何需要变量的上下文中使用a[0]-- 你可以将其放在赋值的左侧,你可以将其作为ref参数,等等。这是一个变量! - Eric Lippert
1
@LucaCremonesi:C#和CLR本可以编写保守逃逸分析,并选择将数组元素变量放置在短期池中,在极少数情况下,保守逃逸分析表明这样做是安全的。但这种优化非常昂贵和复杂,而且只能带来微小的节省,几乎从不得偿失,因此C#和CLR实际上并不这样做。 - Eric Lippert
1
@EricLippert: C#7似乎专注于数据移动/分桶/性能,因此这个功能很合适。我认为“ref struct”的主要好处是使库(例如.NET Framework)更快。引用MSDN:“您可能会发现自己在编写的代码中不经常使用这些功能。但是,这些增强功能已在.NET Framework的许多位置采用。随着越来越多的API使用这些功能,您将看到自己的应用程序的性能提高。” - Brian
显示剩余10条评论

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