Jam*_*mes 2 c# multithreading asynchronous task-parallel-library async-await
SynchronizationContext除非您在、TaskScheduler、 、 async/await 和多处理方面拥有丰富的经验TaskFactory,否则无需进一步阅读。
在过去的几年里,我参与了一个大型的整体式 C# 项目,该项目从 .NET 2.0 开始,一直升级到我们在过去 15 年里一直致力于开发的 .NET 4.72,并将其迁移到 .NET 5 /6 是为了摆脱现已被有效弃用的 4.72,并最终将其托管在 Linux 上,同时保持其在生产环境中 24/7 运行,拥有超过 100 万用户。
在此过程中,我开始欣赏 async/await 概念的优雅,但也对其表现不明智的地方感到非常沮丧。
我已经接受了大多数怪癖,但通过这个过程,.NET Core(此时为 .NET 6)似乎是一个主要用于用户交互软件的框架,而不是其他软件的框架。队列处理等目的。.NET Core 中的 async/await 似乎主要是为了处理围绕旧的 Windows 消息泵 UI 和 ASP.NET 每个请求一个执行流构建的应用程序而设计的,排除了一些其他架构。
让我详细说明一下。在这些更新之前,我们的大部分代码使用自定义线程池进行后端处理。
考虑到项目的性质(一半前端 Web API 服务和一半无头后端队列处理)以及优化成本的需要,我们努力将后端处理服务器上的 CPU 利用率保持在 97% 附近,并且我们正在利用并行处理也适用于一些前端操作。我们发现,对于任何系统,如果 CPU 利用率远高于 97%,您将无法监控服务器上正在发生的情况,也无法在无限循环不可避免地发生时检测到它们。我们的算法可以很好地实现大约这个利用率水平。我们定制线程池有几个原因:
ThreadPool,因为它不是 FIFO,不可避免地会导致一些长时间排队的操作挨饿,而有利于最近排队的操作,从而导致超时和其他难题。当 FIFO 处理引擎可以通过以更合理的 FIFO 顺序处理事物来避免超时时,对此进行补偿既浪费又乏味。PendingWorkItemCount几乎ThreadCount可以做到这一点,但在实践中,我没有无法提出一种对任意工作负载都可靠的算法)。因此,多年来,使用默认线程池以及除玩具代码之外的任何内容都无法实现持续 95% 以上的 CPU 利用率。通过自定义线程池,我们可以轻松查看是否有更多容量,将其与当前 CPU 利用率结合起来,并使用它来决定是否开始更多工作。
输入异步。
这个概念很棒,能够保持逻辑代码流与以前基本相同并停止浪费线程等待 IO,从而大大减少内存使用并获得一些性能改进的承诺已经推动每个人朝这个方向发展,所以以至于我们使用的许多库不再有非异步版本可供使用。我没有发现任何专家建议建议在任何情况下从非异步代码调用异步代码。因此,异步化的过程开始了,异步僵尸病毒从下往上传播代码,并使用一些临时包装器来欺骗异步代码同步运行,直到我们一直到达顶部。我们不可避免地遇到了使用自定义线程池的代码,因此我们也尝试将其转换为 async/await。然而,使用我们自己的自定义ThreadPool并不能很好地与 async/await 配合使用(有很多关于此的 SO 和博客文章)。因此,我决定编写自己的SynchronizationContext/TaskScheduler使用我们的自定义线程池。关于如何正确执行此操作的文档很少(对于很多事情根本没有文档),我花了几个月的时间研究和实施,阅读 Stephen Toub 和 Stephen Cleary 的博客和 SO 帖子,并梳理参考资料来源。我最终完成的实现太大了,无法包含在这里。它大部分工作正常,但处理似乎仍然被发布到默认线程池,导致上述欠载/过载状态中的各种过载和模糊性。我梳理了该项目,寻找类似 的模式Task t = MyAsyncCode();,new Task(() => func())这些模式在默认的 TaskScheduler 上运行代码,但无法覆盖该行为。消除所有这些之后,在默认情况下运行代码的问题TaskScheduler仍然存在。我最终将这些问题归结为这样一个事实:在任何重要代码上使用“await”总是将处理切换回默认任务调度程序。由于上述问题,这是不可接受的。
最终,我发现了这篇文章:Understanding the behavior of TaskScheduler.Current。阅读完该内容并查看Task/TaskScheduler等的参考源后,我明白了为什么代码会这样做,但无法理解这种行为在 ASP.NET 和 UI 代码的扭曲世界之外的任何上下文中有何意义。对于异步工作来说,完全忽略可以将工作排队到的“当前同步上下文”并将工作排队到其他地方真的有意义吗?
这让我想到了我的问题:有没有办法创建一个TaskScheduler实际上用于异步函数的所有处理的自定义?如果没有,这似乎是系统中的一个漏洞。
这是我想要运行的示例:
public async Task SOSample()
{
using MyTaskScheduler scheduler = MyTaskScheduler.Start();
MySynchronizationContext context = new(scheduler);
System.Threading.SynchronizationContext? oldContext = SynchronizationContext.Current;
try
{
SynchronizationContext.SetSynchronizationContext(context);
TaskCompletionSource<bool> completion = new();
Task unwrapped = await Task.Factory.StartNew(
() => VerifyTaskSchedulerRemainsCustom(), CancellationToken.None,
TaskCreationOptions.None, scheduler);
await unwrapped;
}
finally
{
SynchronizationContext.SetSynchronizationContext(oldContext);
}
}
private async Task VerifyTaskSchedulerRemainsCustom()
{
Assert.IsFalse(ReferenceEquals(TaskScheduler.Current, TaskScheduler.Default));
await Task.Yield();
Assert.IsFalse(ReferenceEquals(TaskScheduler.Current, TaskScheduler.Default));
await Task.Delay(100).ConfigureAwait(true);
Assert.IsFalse(ReferenceEquals(TaskScheduler.Current, TaskScheduler.Default));
// ... more arbitrary async processing
Assert.IsFalse(ReferenceEquals(TaskScheduler.Current, TaskScheduler.Default));
}
Run Code Online (Sandbox Code Playgroud)
或者,是否有一种方法可以确定默认线程池是否负载不足或过载,以及在没有调试器的情况下确定是什么导致了它的过载(对于调试生产中的问题至关重要)?更换这将需要大量工作,但在某些方面更可取。
我明白为什么代码会这样运行,但无法理解这种行为在 ASP.NET 和 UI 代码的扭曲世界之外的任何上下文中有何意义。
TaskScheduler.Current是 TPL 的保留并且早于async。捕获的上下文await是当前的SynchronizationContext,并回退到当前的TaskScheduler。大多数时候,SyncCtx 被捕获并且TaskScheduler.Current根本不发挥作用。
这让我想到了我的问题:有没有办法创建一个实际上用于异步函数的所有处理的自定义 TaskScheduler?
您可以创建一个默认使用的。请注意Task.Run,ConfigureAwait(false)、 和朋友将继续使用线程池,因为这正是他们应该做的。
您还没有发布代码,所以我只是猜测问题是您在执行其工作项时TaskScheduler没有设置当前值SynchronizationContext。await将捕获上下文并使用它来安排其继续,但它不会恢复上下文;这是上下文本身的责任。
请随意查看我的AsyncContext;它是一个单线程上下文,但它提供了 aSynchronizationContext和 a TaskScheduler。