
.NET平台提供了许多预构建的同步原语和线程安全的集合。 例如,如果在开发应用程序时需要实现线程安全的缓存或请求队列,则通常使用这些现成的解决方案,有时一次使用多个解决方案。 在某些情况下,这会导致性能问题:长时间等待锁,过多的内存消耗和漫长的垃圾回收。
如果考虑到标准解决方案的通用性,则可以解决这些问题-在我们的方案中,它们可能会有多余的开销。 因此,您可以针对特定情况编写例如自己的有效线程安全集合。
过场动画是
DotNext会议报告的视频和抄本,在其中,我分析了使用标准.NET库中的工具(Task.Delay,SemaphoreSlim,ConcurrentDictionary)导致性能下降的一些示例,我提出了针对特定任务量身定制的解决方案,这些缺点。
在撰写报告时,他曾在Kontur工作。 Kontur开发了各种业务应用程序,而我工作的团队与基础架构打交道,并开发了各种支持服务和库,以帮助其他团队的开发人员创建产品服务。
基础架构团队构建其数据仓库,用于Windows的应用程序托管系统以及用于开发微服务的各种库。 我们的应用程序基于微服务体系结构-所有服务都通过网络相互交互,当然,它们使用大量异步和多线程代码。 其中一些应用程序对性能至关重要;它们需要能够处理许多请求。
今天我们要谈什么?
- .NET中的多线程和异步;
- 填充同步原语和集合;
- 如果标准方法无法应付负载怎么办?
让我们分析在.NET中使用多线程和异步代码的一些功能。 让我们看一些同步原语和并发集合,看看它们是如何安排在内部的。 我们将讨论如果没有足够的性能,标准类无法应付负载以及在这种情况下是否可以解决问题时该怎么办。
我将告诉您我们生产现场发生的四个故事。
历史记录1:Task.Delay和TimerQueue
这个故事已经很广为人知,包括在以前的DotNext上有关它的故事。 但是,它有一个相当有趣的续集,因此我添加了它。 那有什么意义呢?
1.1轮询和长时间轮询
服务器执行长时间的操作,客户端等待它们。
轮询:客户端定期向服务器询问结果。
长轮询:客户端发送超时请求,服务器在操作完成后做出响应。
优点:
想象一下,我们有一个可以处理一些长请求的服务器,例如,一个将XML文件转换为PDF的应用程序,并且有一些客户端运行这些任务进行处理,并希望异步等待其结果。 如何实现这种期望?
第一种方法是
轮询 。 客户端在服务器上启动任务,然后定期检查此任务的状态,而服务器则返回任务的状态(“已完成” /“未完成” /“已完成但有错误”)。 客户端定期发送请求,直到结果出现。
第二种方法是
长时间轮询 。 此处的区别在于客户端发送的请求超时。 接收到此类请求的服务器将不会立即报告该任务尚未完成,而是会尝试等待一段时间才能显示结果。
那么长轮询比常规轮询有什么优势? 首先,产生的流量较少。 我们发出较少的网络请求-通过网络追踪的流量减少。 而且,与常规轮询相比,客户端将能够更快地找到结果,因为客户端无需等待多个轮询请求之间的间隔。 我们想要得到的是可以理解的。 我们将如何在代码中实现呢?
任务:超时
我们想等待任务超时
等待SendAsync();
例如,我们有一个Task向服务器发送请求,我们想等待超时的结果,也就是说,我们要么返回此Task的结果,要么发送某种错误。 C#代码将如下所示:
var sendTask = SendAsync(); var delayTask = Task.Delay(timeout); var task = await Task.WhenAny(sendTask, delayTask); if (task == delayTask) return Timeout;
这段代码启动了我们要等待其结果的Task和Task.Delay。 接下来,使用Task.WhenAny,我们正在等待Task或Task.Delay。 如果事实证明Task.Delay首先执行,那么时间到了,我们超时了,我们必须返回一个错误。
当然,此代码不是完美的,可以改进。 例如,如果SendAsync较早返回,取消Task.Delay不会有什么坏处,但是现在对于我们来说这不是很有趣。 底线是,如果我们编写这样的代码并将其应用于长时间超时的长时间轮询,则会遇到一些性能问题。
1.2长轮询问题
在这种情况下,问题在于处理器资源的高消耗。 可能会发生处理器100%满载的情况,并且应用程序通常会停止运行。 似乎我们根本不消耗处理器资源:我们执行一些异步操作,等待服务器的响应,并且处理器仍被加载。
遇到这种情况时,我们从应用程序中删除了内存转储:
~*e!clrstack System.Threading.Monitor.Enter(System.Object) System.Threading.TimerQueueTimer.Change(…) System.Threading.Timer.TimerSetup(…) System.Threading.Timer..ctor(…) System.Threading.Tasks.Task.Delay(…)
为了分析转储,我们使用了WinDbg工具。 我们输入了一个显示所有托管线程的堆栈跟踪的命令,并看到了这样的结果。 我们有很多线程正在等待锁。 Monitor.Enter方法是C#中的锁构造扩展到的方法。 该锁在称为Timer和TimerQueueTimer的类中捕获。 在Timer中,当我们尝试创建它们时,我们来自Task.Delay。 怎么了 Task.Delay启动时,将捕获TimerQueue内部的锁。
1.3锁车队
- 许多线程试图锁定一个锁
- 在锁下,很少执行代码
- 时间花费在线程同步上,而不是代码执行上。
- 线程块被阻止-它们不是无限的
在应用程序中,我们有一个锁车队。 许多线程试图捕获相同的锁。 在此锁下,将执行大量代码。 此处的处理器资源不是花在应用程序代码本身上,而是花在在此锁上同步它们之间的线程的操作上。 还应注意与.NET相关的功能:参与锁保护的线程是线程池中的线程。
因此,如果线程池中的线程被阻塞,它们可能会终止-线程池中的线程数受到限制。 可以配置,但是仍然有上限。 到达线程池之后,所有线程池线程都将参与锁定进程,并且涉及线程池的任何代码都将在应用程序中停止执行。 这大大恶化了局势。
1.4 TimerQueue
- 管理.NET应用程序中的计时器。
- 计时器用于:
-Task.Delay
-CancellationTocken.CancelAfter
-HttpClient
TimerQueue是一个类,用于管理.NET应用程序中的所有计时器。 如果您曾经在WinForms中编程过,则可能是手动创建了计时器。 对于那些不知道计时器是什么的人:它们在Task.Delay中使用(这只是我们的情况),它们也在CancellationToken方法的CancelAfter方法中使用。 也就是说,用CancellationToken.CancelAfter替换Task.Delay不会对我们有任何帮助。 此外,许多内部.NET类(例如HttpClient)中都使用了计时器。
据我所知,HttpClient处理程序的某些实现都有计时器。 即使您没有明确使用它们,也不要启动Task.Delay,很可能仍然仍然使用它们。
现在让我们看一下TimerQueue的内部排列方式。
- 全局状态(每个应用程序域):
-TimerQueueTimer的双链表
-锁定对象 - 常规计时器回调
- 计时器未按响应时间排序
- 添加计时器:O(1)+锁定
- 移除计时器:O(1)+锁定
- 启动计时器:O(N)+锁定
在TimerQueue内部有一个全局状态,它是TimerQueueTimer类型的对象的双向链接列表。 TimerQueueTimer包含一个到其他TimerQueueTimer的链接,该链接在链表中相邻,还包含计时器和回调的时间,当计时器启动时将调用该时间。 这个双重链接列表受一个锁对象的保护,而该锁对象只是在我们的应用程序中发生锁护卫的对象。 在TimerQueue中也有一个例程,该例程启动与我们的计时器相关的回调。
计时器决不会按响应时间排序,整个结构已针对添加/删除新计时器进行了优化。 Routine启动时,它将遍历整个双向链表,选择应该工作的计时器,然后将其回调。
操作的复杂性就是这样。 添加和删除计时器会在每个单元中发生O,计时器的启动会在每行中发生。 而且,如果在算法复杂性方面一切都可以接受,则存在一个问题:所有这些操作都捕获了锁,这不是很好。
会发生什么情况? 我们在TimerQueue中积累了太多的计时器,因此,当Routine启动时,它将锁定其长时间的线性操作,那时那些尝试启动或从TimerQueue中删除计时器的人无法对其进行任何操作。 因此,将发生锁定车队。 .NET Core中已解决此问题。
减少计时器锁定争用(coreclr#14527)
- 锁定分片
-Environment.ProcessorCount TimerQueue的TimerQueueTimer - 短/长寿命计时器的单独队列
- 短计时器:时间<= 1/3秒
https://github.com/dotnet/coreclr/issues/14462
https://github.com/dotnet/coreclr/pull/14527
它是如何固定的? 他们突击了TimerQueue:而不是一个TimerQueue(对于整个AppDomain都是静态的),对于整个应用程序,创建了多个TimerQueue。 当线程到达那里并尝试启动它们的计时器时,这些计时器将落入随机的TimerQueue中,并且线程碰撞一次锁的机会较小。
还在.NET Core中应用了一些优化。 计时器分为长寿命和短寿命,现在分别为它们使用TimerQueue。 短时计时器选择为小于1/3秒。 我不知道为什么选择了这样一个常数。 在.NET Core中,我们无法捕获计时器问题。
https://github.com/Microsoft/dotnet-framework-early-access/blob/master/release-notes/NET48/dotnet-48-changes.mdhttps://github.com/dotnet/coreclr/labels/netfx-port-consider此修复程序已反向移植到.NET Framework 4.8版。 上面的链接中指示了netfx-port-consider标记,如果转到.NET Core,CoreCLR,CoreFX存储库,则可以搜索将被反向移植到.NET Framework的问题,现在大约有五十个。 也就是说,开源.NET起到了很大的作用,修复了许多错误。 您可以阅读changelog .NET Framework 4.8:已修复了许多错误,比其他.NET版本中的错误要多得多。 有趣的是,此修补程序在.NET Framework 4.8中默认为关闭。 它包含在您称为App.config的整个文件中
App.config中启用此修复程序的设置称为UseNetCoreTimer。 在.NET Framework 4.8发行之前,为了使我们的应用程序能够正常工作而不进入锁定状态,您必须使用Task.Delay的实现。 在其中,我们尝试使用二进制堆来更有效地了解现在应该调用哪个计时器。
1.5 Task.Delay:本机实现
使用二进制堆使您可以优化调用回调的例程,但会使从队列中删除任意计时器所需的时间更糟-为此,您需要重建堆。 这很可能就是.NET使用双向链表的原因。 当然,仅使用二进制堆对我们没有帮助,我们还必须计算TimerQueue。 该解决方案工作了一段时间,但由于计时器不仅在代码中显式运行的地方使用,而且在第三方库和.NET代码中也使用,因此,它仍然再次陷入锁定困境。 若要完全解决此问题,您必须升级到.NET Framework 4.8版并启用.NET开发人员的修复程序。
1.6 Task.Delay:结论
- 到处都是陷阱-即使是最常用的东西
- 做压力测试
- 切换到核心,首先获取错误修复(和新错误):)
整个故事的结论是什么? 首先,陷阱实际上可以摆在任何地方,即使在您每天都在不加思考的类中使用,例如,相同的Task,Task.Delay。
我建议对您的提案进行压力测试。 我们刚刚在负载测试阶段发现了这个问题。 然后,我们在其他应用程序的生产中将其拍摄了数次,但是,压力测试帮助我们延迟了在实际遇到此问题之前的时间。
切换到.NET Core-您将是第一个接收bug修复(和新bug)的人。 哪里没有新错误?
关于计时器的故事已经结束,我们继续进行下一个。
故事2:SemaphoreSlim
以下是有关著名的SemaphoreSlim的故事。
2.1服务器限制
我们想在服务器上实现限制。 这是什么 也许大家都知道CPU的节流阀:当处理器过热时,它会降低冷却频率,这会限制其性能。 就在这里。 我们知道我们的服务器可以并行处理N个请求,并且不会失败。 我们想做什么? 将同时处理的请求数限制为该常数,并使其保持不变,以便在有更多请求时,它们排队并等待直到执行较早的那些请求。 这个问题怎么解决? 有必要使用某种同步原语。
Semaphore是一个同步原语,您可以在其上等待N次,此后首先到达N +的人将一直等待,直到更早进入它的人释放Semaphore。 事实证明是这样的:两个执行线程,两个工作人员进入了信号量管理系统,其余人员排队。

当然,只是Semaphore不适合我们,它在.NET同步中,因此我们采用SemaphoreSlim并编写了以下代码:
var semaphore = new SemaphoreSlim(N); … await semaphore.WaitAsync(); await HandleRequestAsync(request); semaphore.Release();
我们创建SemaphoreSlim,然后等待,在Semaphore下处理您的请求,然后释放Semaphore。 看来这是服务器限制的理想实现,并且再也不能做得更好。 但是,一切都更加复杂。
2.2服务器限制:复杂
- 按LIFO顺序处理请求
- 信号量
- 并发堆栈
- TaskCompletionSource
我们忘记了一些业务逻辑。 节流的请求是真实的http请求。 通常,他们有一些超时时间(由自动发送此请求的用户设置),或者是一段时间后按F5的用户设置的超时时间。 因此,如果您按照常规信号量之类的队列顺序处理请求,则首先可能已经处理了来自队列的所有那些超时请求。 如果按堆栈顺序工作-首先处理最后出现的所有请求,则不会出现此类问题。
除了SemaphoreSlim之外,我们还必须使用ConcurrentStack,TaskCompletionSource围绕这些内容包装很多代码,以便所有内容都能按我们需要的顺序工作。 TaskCompletionSource就是这样,它与CancellationTokenSource类似,但与CancellationToken无关,但与Task相似。 您可以创建一个TaskCompletionSource,从中提取一个Task,分发出去,然后告诉TaskCompletionSource您需要为该Task设置结果,等待该Task的人将了解此结果。
我们都实现了它。 代码太糟糕了。 最糟糕的是,结果证明它无法正常工作。
在相当重的应用程序中开始使用它的几个月后,我们遇到了一个问题。 与以前的情况相同,CPU消耗已增加到100%。 我们进行了相同的操作,删除了转储,在WinDbg中对其进行了查看,然后再次找到了锁车队。

这次,Lock车队发生在SemaphoreSlim.WaitAsync和SemaphoreSlim.Release内部。 事实证明,SemaphoreSlim内部有一个锁,它不是无锁的。 事实证明,这对我们来说是一个相当严重的缺点。

在SemaphoreSlim内部,存在一个内部状态(一个计数器,该计数器仍可以查看有多少工作人员),以及等待该信号灯的人员的双链表。 这里的想法是相同的:您可以在此信号量上等待,可以取消期望-离开此队列。 有一把锁刚刚毁了我们的生活。
: , .

Semaphore, lock-free . .

. currentCount — Semaphore. Semaphore , , . ConcurrentStack, TaskCompletionSource' — waiter', . WaitAsync.
var decrementedCount = Interlocked.Decrement(ref currentCount); if (decrementedCount >= 0) return Task.CompletedTask; var waiter = new TaskCompletionSource<bool>(); waiters.Push(waiter); return waiter.Task;
, Semaphore, , : «, Semaphore».
Semaphore , TaskCompletionSource, waiter' Task'. , Task' , Semaphore.
Release.
var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { if (waiters.TryPop(out var waiter)) waiter.TrySetResult(true); }
Release :
currentCount , waiter', , waiter' . waiter — TaskCompletionSource. : , ? ? , , continuation' TaskCompletionSource'.

. TaskCompletionSource Task'. Task , TaskCompletionSource, . Task TaskCompletionSource, Task', .
? Task 2 , — continuation, Thread.Sleep. TaskCompletionSource, continuation , Task. , Task' , .
, , , continuation . continuation , — — .
var tcs = new TaskCompletionSource<bool>( TaskCreationOptions.RunContinuationsAsynchronously); Task.Run(() => tcs.TrySetResult(true));
TaskCompletionSource RunContinuationsAsynchronously, TrySetResult Task.Run/ThreadPool.QueueUserWorkItem, . , side effect'. , .

WaitAsync Release Release .
, . .

, WaitAsync . waiter' . , Release , , . , Release waiter' .
var countBefore = Interlocked.Increment(ref currentCount) - 1; if (countBefore < 0) { Waiter waiter; var spinner = new SpinWait(); while (!waiter.TryPop(out waiter)) spinner.SpinOnce(); waiter.TrySetResult(true); }
, . , SpinWait.
. , waiter , Thread.Sleep, CPU.
, Semaphore LIFO- — .
LowLevelLifoSemaphore
- Windows Windows IO Completion port
https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/src/System/Threading/LowLevelLifoSemaphore.cs
Semaphore .NET, CoreCLR, CoreFX, CoreRT. .NET. Semaphore LowLevelLifoSemaphore. Semaphore : .
, Windows IO Completion-. , , LIFO-. , LowLevel.
2.3 :
? -, , - , , . , SemaphoreSlim , .
Semaphore . , . SemaphoreSlim, , .
, , .
.NET , — . lock, : « ?» CPU 100%, lock', , , - .NET. .
.
3: (A)sync IO
/, .

lock convoy, stack trace Overlapped PinnableBufferCache. lock. : Overlapped PinnableBufferCache?
OVERLAPPED — Windows, /. , . , . , lock convoy. , lock convoy, , .

, , .NET 4.5.1 4.5.2. .NET 4.5.2, , .NET 4.5.2. .NET 4.5.1 OverlappedDataCache, Overlapped — , , . , lock-free, ConcurrentStack, . .NET 4.5.2 : OverlappedDataCache PinnableBufferCache.
? PinnableBufferCache , Overlapped , , — . , , . PinnableBufferCache . , lock-free, ConcurrentStack. , . , , - lock-free list lock'.
3.1 PinnableBufferCache
LockConvoy:
lock convoy , - . list , lock , , .
PinnableBufferCache , . :
PinnableBufferCache_System.ThreadingOverlappedData_MinCount
, . : « ! - ». -:
Environment.SetEnvironmentVariable( "PinnableBufferCache_System.Threading.OverlappedData_MinCount", "10000"); new Overlapped().GetHashCode(); for (int i = 0; i < 3; i++) GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
? , Overlapped , , . , , , , PinnableBufferCache lock convoy'. , .
.NET Core PinnableBufferCache
, OverlappedData . , , Garbage collector , . .NET Core . .NET Framework, , .
3.2 :
, . , .NET , . , , .NET Core. , , -.
key-value .
4: Concurrent key-value collections
.NET concurrent-. lock-free ConcurrentStack ConcurrentQueu, . ConcurrentDictionary, . lock-free , , . ConcurrentDictionary?
4.1 ConcurrentDictionary
:
优点:
- (TryAdd/TryUpdate/AddOrUpdate)
- Lock-free
- Lock-free enumeration
, memory-, , . , , .NET Framework. . , , (enumeration) lock-free. , .
, , - .NET. key-value - :

-, bucket'. bucket', . , bucket , .
— , ConcurrentDictionary. ConcurrentDictionary «-» . , , , memory traffic. ConcurrentDictionary, lock'. — .
, Dictionary.

Dictionary , Concurrent, . : buckets, entries. buckets bucket' entries. «-» entries. . «-» int, bucket'.
memory overhead, ConcurrentDictionary Dictionary.

Dictionary. Memory overhea' , . Dictionary overhead - , int'. 8 .
ConcurrentDictionary. ConcurrentDictionary ConcurrentDictionary.Node. , . int hashCode . , table ( 16 ), int hashCode . , 64- 28 overhead'. Dictionary.
memory overhead', ConcurrentDictionary GC , . Benchmark. ConcurrentDictionary , GC.Collect. ?

. ConcurrentDictionary 10 , , , . Dictionary . , , , . .
, ConcurrentDictionary?
4.2
- TTL
- Dictionary+lock
- Sharding
. ConcurrentDictionary. 10 . , . TTL , . Dictionary lock'. , , lock . Dictionary lock' , - , lock. , .
4.3
- in-memory <Guid,Guid>
- >10 6
. — , in-memory Guid' Guid, . . - - , . , 15 . . Semaphore ConcurrentDictionary.

, lock-free , overhead GC. , . , , , . , - , , . , , Large Object Heap. ?
, , Dictionary .

Dictionary bucket', Entry. Entry , , , .

Dictionary , , . , - .
, - ? -, , , , . . Dictionary, , buckets, entries, Interlocked. , .
Dictionary
- ,
- , ?
— Resize buckets entries
— -
— Dictionary.Entry
— -
https://blogs.msdn.microsoft.com/tess/2009/12/21/high-cpu-in-net-app-using-a-static-generic-dictionary/
, Dictionary - bucket'. , . , , . , , .
Entry Dictionary. - - . , .

.NET Framework 1.1. Hashtable, Dictionary, object'. MSDN , . , -. . , Hashtable . , .
4.4 Dictionary.Entry

? Dictionary.Entry , , 8 , , , , . ?
bool writing; int version; this.writing = true; buckets[index] = …; this.version++; this.writing = false;
: ( , ) int-. , . , , , , .
bool writing; int version; while (true) { int version = this.version; bucket = bickets[index]; if (this.writing || version != this.version) continue; break; }
, , . , . , 8 .
4.5 -
, .

Dictionary bucket , .
Dictionary, . : 0 2. bucket, 1 2. ? 0. , , 2. . , 2, , , 1. 1 2 — bucket. , , . 1 — , bucket. Hashtable , bucket' -. —
double hashing .
4.6
. , Buckets, Entries ( Buckets, Entries). - , , , , .
. , .
: , , , , . , , .

, , — .
? , - 2. - Capacity , . — 2. , . 2. ? , , , . - , , 3. , , , , , .
, Hashtable, . , double hashing. , , , .
, , — , . Hashtable. , — — . . , bucket', - , . .
, , lock-free LOH.

lock-free ? MSDN Hashtable , . , , .

, , , bucket'. Dictionary bucket', -, bucket' . - bucket, bucket . , .
, Large Object Heap.

. CustomDictionary CustomDictionarySegment . Dictionary, , . — Dictionary, . , Large Object Heap. , bucket' . , , , bucket, - - .
. ConcurrentDictionary, .NET, , .
4.7
? .NET . . , , . - — - . , , , .
- , , , , . , , , , , . — , , .
— ConcurrentDictionary. , , (
Diafilm ), .
GitHub. — , , LIFO-Semaphore, . , .
6-7 DotNext 2019 Moscow «.NET: » , .NET Framework .NET Core, , .