.NET:用于多线程和异步的工具-第1部分

我最初在CodingSight博客中发布了这篇文章
本文的第二部分在这里可用

在计算机出现之前,就已经存在以异步方式进行处理(即在多个工作单元之间划分大任务)的需求。 但是,当它们确实出现时,这种需求变得更加明显。 现在是2019年,我正在用8核Intel Core CPU驱动的笔记本电脑上写这篇文章,除此之外,该笔记本电脑同时在数百个进程上工作,线程数量更大。 在我旁边,是我几年前购买的一款过时的智能手机-它还装有8核处理器。 专门的Web资源包含各种文章,称赞今年配备16核CPU的旗舰智能手机。 每小时不到20美元,MS Azure可以让您访问具有2 TB RAM的128核虚拟机。 但是,不幸的是,除非您知道如何控制线程之间的交互,否则您将无法充分利用此功能。

目录内容




术语学


进程 -一个OS对象,代表一个包含线程的隔离地址空间。

线程 -代表最小执行单元的OS对象。 线程是进程的组成部分,它们在进程范围内将内存和其他资源相互分配。

多任务处理 -一种OS功能,代表同时执行多个进程的能力。

多核 -一种CPU功能,代表使用多个核进行数据处理的能力

多重处理 -计算机的功能,代表物理上可以与多个CPU一起工作的能力。

多线程 -进程的功能,表示在多个线程之间划分和扩展数据处理的能力。

并行性 -单位时间内同时执行多个动作

异步 -执行操作而无需等待其完全处理,从而将计算结果留给以后使用。


隐喻


并非所有定义都是有效的,其中一些定义需要详细说明,因此让我为刚才介绍的术语提供一个烹饪的比喻。

在这个比喻 ,做早餐代表了一个过程

早上做早餐时,我( CPU )去了厨房( 计算机 )。 我有两只手( 核心 )。 厨房上有各种各样的设备( IO ):炉子,水壶,烤面包机,冰箱。 我打开火炉,在上面放一个煎锅,然后倒一些植物油。 在不等待油加热的情况下( 异步,非阻塞IO等待 ),我从冰箱里拿了一些鸡蛋,将它们打碎在碗上,然后用一只手搅打它们( 线程1 )。 同时,第二只手(线程2)将碗固定在适当的位置( 共享资源 )。 我想打开水壶,但目前没有足够的空余时间( Thread Starvation )。 当我搅打鸡蛋时,煎锅变得足够热(结果处理),所以我将搅打的鸡蛋倒入其中。 我伸手去拿水壶,打开它,看着沸腾的水( Blocking-IO-Wait )-但是我本来可以用这个时间洗碗的。

在制作煎蛋卷时,我只用了两只手(因为我没有更多),但是同时执行了3项操作:搅打鸡蛋,握住碗,加热煎锅。 CPU是计算机中最快的部分,而IO是最需要等待的部分,因此,在等待IO数据时,将CPU加载一些工作非常有效。

扩展隐喻:

  • 如果我在做早餐的同时还想换衣服,那我将是多任务的 。 计算机在这方面比人类要好得多。
  • 一个有多位厨师的厨房-例如,在一家餐馆中-是多核计算机。
  • 一个有许多餐馆的购物中心美食广场将代表一个数据中心



.NET工具


.NET在处理线程以及其他许多方面真的非常好。 对于每个新版本,它都提供了更多用于处理线程和新的OS线程抽象层的工具。 在使用抽象时,使用框架的开发人员正在使用一种方法,该方法允许他们在使用高级抽象时向下一层或多层。 在大多数情况下,并没有真正的必要(这样做可能会导致自己被脚踩死),但是有时这可能是解决当前抽象级别无法解决的问题的唯一方法。

前面提到工具时,我指的是框架或第三方程序包提供的程序接口(API),以及可以简化搜索与多线程代码相关的问题的过程的完整软件解决方案。


启动线程


Thread类是使用线程的最基本的.NET类。 它的构造函数接受以下两个委托之一:

  • ThreadStart-没有参数
  • ParametrizedThreadStart-一个对象类型参数。


调用Start方法后,委托将在新创建的线程中执行。 如果将ParametrizedThreadStart委托传递给构造函数,则应将一个对象传递给Start方法。 将任何本地信息传递给线程都需要此过程。 我应该指出,创建线程需要大量资源,并且线程本身是一个沉重的对象-至少因为它需要与OS API交互并且将1MB内存分配给堆栈,这是因为。

new Thread(...).Start(...); 

ThreadPool类表示池的概念。 在.NET中,线程池是一门工程技术,Microsoft开发人员投入了大量精力以使其在各种情况下均能最佳工作。

一般概念:
启动后,该应用程序会在后台创建一些线程,以便在需要时访问它们。 如果频繁且大量使用线程,则将扩展池以满足调用代码的需求。 如果该池在正确的时间没有足够的可用线程,它将等待一个活动线程被占用,或者创建一个新的线程。 基于此,可以得出结论,线程池非常适合短时间操作,而对于在整个应用程序运行期间充当服务的进程而言,效果并不理想。

QueueUserWorkItem方法允许使用池中的线程。 此方法采用WaitCallback -type委托。 它的签名与ParametrizedThreadStart的签名一致,并且传递给它的参数起着相同的作用。

 ThreadPool.QueueUserWorkItem(...); 

鲜为人知的RegisterWaitForSingleObject线程池方法用于组织非阻塞IO操作。 传递给此方法的委托在释放WaitHandle后将被调用。

 ThreadPool.RegisterWaitForSingleObject(...) 


.NET中有一个线程计时器,它与WinForms / WPF计时器的不同之处在于,它的处理程序是在从池中获取的线程中调用的。

 System.Threading.Timer 


还有一种从池中将委托发送到线程的非常不寻常的方法-BeginInvoke方法。

 DelegateInstance.BeginInvoke 


我还要看一下我前面提到的许多方法归结为功能的功能-从Kernel32.dll Win32 API生成的CreateThread。 在方法的外部机制的帮助下,有一种方法可以调用此函数。 我只看到过这种情况在遗留代码的特别糟糕的情况下仅使用过一次-我仍然不了解它的作者的原因。
 Kernel32.dll CreateThread 



查看和调试线程


所有线程-无论是由您,第三方组件还是.NET池创建的,都可以在Visual Studio的“ 线程”窗口中查看。 仅在“中断”模式下调试应用程序时,此窗口才会显示有关线程的信息。 在这里,您可以查看每个线程的名称和优先级,并将调试模式集中在特定线程上。 Thread类的Priority属性允许您设置线程的优先级。 然后,当OS和CLR在线程之间分配处理器时间时,将考虑此优先级。




任务并行库


任务并行库(TPL)首次出现在.NET 4.0中。 当前,它是处理异步的主要工具。 任何使用较旧方法的代码都将被视为旧代码。 TPL的主要单元是System.Threading.Tasks命名空间中的Task类。 任务代表线程抽象。 使用最新版本的C#,我们获得了一种使用Tasks的全新优雅方式-异步/等待操作符。 这些使异步代码看起来像是简单而又同步的,因此,那些不熟悉线程理论的人现在可以编写不需长时间操作的应用程序。 使用异步/等待确实是另一篇文章(甚至几篇文章)的主题,但是我将尝试用几句话来概述基础知识:

  • 异步是返回Task或void的方法的修饰符
  • await是非阻塞等待任务的运算符。


再说一次:await操作符通常会(有一些例外)让当前线程离开,并且在执行任务时,线程(实际上是上下文,但是我们稍后会再讲)作为结果,它将继续执行该方法。 在.NET中,该机制的实现方式与收益回报相同-一种方法变成了有限状态机类,可以根据其状态在单独的部分中执行。 如果听起来很有趣,我建议您基于async / await编写任何简单的代码,然后在JetBrains dotPeek的帮助下启用编译器生成的代码,对其进行编译并查看其编译情况。

让我们看一下启动和使用任务时的可用选项。 在下面的示例中,我们创建一个新任务,该任务实际上并没有产生任何效果(Thread.Sleep(10000))。 但是,在实际情况下,我们应该用一些利用CPU资源的复杂工作代替它。

 using TCO = System.Threading.Tasks.TaskCreationOptions; public static async void VoidAsyncMethod() { var cancellationSource = new CancellationTokenSource(); await Task.Factory.StartNew( // Code of action will be executed on other context () => Thread.Sleep(10000), cancellationSource.Token, TCO.LongRunning | TCO.AttachedToParent | TCO.PreferFairness, scheduler ); // Code after await will be executed on captured context } 


使用以下选项创建任务:

  • LongRunning-此选项暗示无法快速执行任务的事实。 因此,最好为该任务创建一个单独的线程,而不是从池中获取一个现有线程,以最大程度地减少对其他任务的损害。
  • AttachedToParent-任务可以按层次排列。 如果使用此选项,则任务本身执行完后将等待其子任务执行。
  • PreferFairness-此选项指定在执行以后创建的任务之前最好执行该任务。 但是,这更多是一个建议,因此不一定总能保证结果。


传递给该方法的第二个参数是CancellationToken。 为了在启动操作后正确取消该操作,可执行代码应包含CancellationToken状态检查。 如果没有此类检查,则在CancellationTokenSource对象上调用的Cancel方法只能在实际启动任务之前停止任务执行。

对于最后一个参数,我们发送了一个称为Scheduler的TaskScheduler类型的对象。 该类及其子类用于控制任务在线程之间的分配方式。 默认情况下,将在池中随机选择的线程上执行任务

等待操作符应用于创建的任务。 这意味着在其后编写的代码(如果有)将在与等待之前编写的代码相同的上下文中执行(通常,这意味着“在同一线程上”)。

该方法被标记为异步无效,这意味着可以在其中使用await运算符,但是调用代码将无法等待执行。 如果需要这种可能性,则该方法应返回Task。 经常被标记为异步无效的方法:它们通常是事件处理程序或在即发即弃原则下运行的其他方法。 如果有必要等待执行完成并返回结果,则应使用Task。

对于返回StartNew方法的任务,我们可以使用false参数调用ConfigureAwait-然后,将在随机上下文而不是捕获的上下文上继续执行await之后的执行。 如果等待后编写的代码不需要特定的执行上下文,则应始终这样做。 对于编写作为库提供的代码,这也是MS的建议。

让我们看一下如何等待任务完成。 在下面,您可以看到一个带有注释的示例代码,该注释指示以某种相对较好或不好的方式实现了等待时间。

 public static async void AnotherMethod() { int result = await AsyncMethod(); // good result = AsyncMethod().Result; // bad AsyncMethod().Wait(); // bad IEnumerable<Task> tasks = new Task[] { AsyncMethod(), OtherAsyncMethod() }; await Task.WhenAll(tasks); // good await Task.WhenAny(tasks); // good Task.WaitAll(tasks.ToArray()); // bad } 

在第一个示例中,我们等待任务执行而不会阻塞调用线程,因此我们将在准备好结果后返回处理结果。 在此之前,调用线程是自己保留的。

在第二次尝试中,我们将阻塞调用线程,直到计算出方法的结果为止。 这是一个不好的方法,有两个原因。 首先,我们在简单的等待上浪费了一个线程-一个非常宝贵的资源。 另外,如果我们正在调用的方法包含一个await,而同步上下文希望在await之后返回到调用线程,则将导致死锁。 发生这种情况是因为调用线程将等待异步方法的结果,并且异步方法本身将无济于事,试图继续在调用线程中继续执行。

这种方法的另一个缺点是错误处理的复杂性增加。 如果使用async / await,则在异步代码中实际上可以很容易地处理错误-在这种情况下,该过程与同步代码中的过程相同。 但是,当对任务执行同步等待时,初始异常将包装在AggregateException中。 换句话说,要处理异常,我们需要探索InnerException类型,并在catch块中手动编写if链,或者使用catch when结构而不是更常见的catch链。

由于相同的原因,最后两个示例也被标记为相对较差,并且都包含相同的问题。

当等待一组任务时,WhenAny和WhenAll方法非常有用-它们将这些任务包装为一个,当启动该组中的一个任务或成功执行所有这些任务时,将执行该方法。


停止线程


由于各种原因,可能需要在启动线程后停止线程。 有几种方法可以做到这一点。 Thread类有两个具有适当名称的方法-AbortInterrupt 。 我强烈不建议使用第一个指令,因为调用它后,在处理任意选择的指令时,任何随机时刻都会抛出ThreadAbortedException 。 您不希望整数变量递增时会遇到此类异常,对吗? 好吧,当使用Abort方法时,这成为了一种实际的可能性。 如果您需要拒绝CLR在代码的特定部分中创建此类异常的功能,则可以将其包装在Thread。BeginCriticalRegionThread.EndCriticalRegion调用中。 在finally块中编写的任何代码都包含在这些调用中。 这就是为什么您可以在框架代码的深处找到一个空尝试,最后一个非空的块的原因。 Microsoft不喜欢此方法的程度是未将其包含在.NET核心中。

中断方法的工作方式更加可预测。 仅当线程处于等待模式时,它才能使用ThreadInterruptedException中断线程。 在等待WaitHandle,锁定或调用Thread.Sleep之后挂起时,它将移动到此状态。

这两种方式都具有不可预测的缺点。 为了避免这个问题,我们应该使用CancellationToken结构和CancellationTokenSource类。 总体思路是这样的:创建CancellationTokenSource类的实例,只有拥有它的人员才能通过调用Cancel方法停止操作。 仅CancellationToken传递给该操作。 CancellationToken的所有者无法自行取消操作-他们只能检查操作是否已取消。 这可以通过使用布尔属性IsCancellationRequestedThrowIfCancelRequested方法来实现。 如果在创建CancellationToken的CancellationTokenSource实例上调用了Cancel方法,则最后一个将生成TaskCancelledException 。 这是我建议使用的方法。 相对于前述方法的优势在于,它可以完全控制可以取消操作的确切异常情况。

停止线程最残酷的方法是调用Win32 API函数TerminateThread。 调用此函数后,CLR的行为可能非常不可预测。 在MSDN中 ,有关该功能的内容如下: “ TerminateThread是一个危险的功能,仅应在最极端的情况下使用。


使用FromAsync将旧版API转换为基于任务的API


如果您很幸运地可以处理在引入Tasks之后启动的项目(并且在大多数开发人员中它们不再引起存在的恐怖),那么您将不必处理旧的API-第三方和您的团队过去辛勤工作。 幸运的是,.NET Framework开发团队为我们提供了便利-但就我们所知,这本来可以是自我照顾。 在任何情况下,.NET都有一些工具可帮助无缝地将考虑到旧方法的代码异步化为最新形式。 其中之一是称为FromAsync的TaskFactory方法。 在下面的示例中,我将使用FromAsync将WebRequest类的旧异步方法包装到Task中。

 object state = null; WebRequest wr = WebRequest.CreateHttp("http://github.com"); await Task.Factory.FromAsync( wr.BeginGetResponse, we.EndGetResponse ); 

这只是一个例子,您可能不会使用内置类型进行此类操作。 但是,旧项目使用返回IAsyncResult的BeginDoSomething方法和接收它们的EndDoSomething方法进行处理。


使用TaskCompletionSource将旧版API转换为基于任务的API


另一个值得探索的工具是TaskCompletionSource类。 在功能,目的和操作原理上,它类似于我前面提到的ThreadPool类中的RegisterWaitForSingleObject方法。 此类使我们可以轻松地将旧的异步API包装到Tasks中。

您可能要说的是,我已经从TaskFactory类中讲述了满足这些目的的FromAsync方法。 在这里,我们需要记住Microsoft在过去15年中提供的异步模型的完整历史:在基于任务的异步模式(TAP)之前,存在异步编程模式(APP)。 APP都是关于Begin DoSomething返回IAsyncResult和End DoSomething方法(接受它)的,并且FromAsync方法非常适合这些年的历史。 但是,随着时间的流逝,它被基于事件的异步模式(EAP)取代,该模式指定在成功执行异步操作时调用一个事件。

TaskCompletionSource非常适合将围绕事件模型构建的旧式API包装到Tasks中。 它是这样工作的:此类的对象具有一个称为Task的公共属性,其状态可以由TaskCompletionSource类的各种方法(SetResult,SetException等)控制。 在将await运算符应用于此Task的地方,将根据异常应用到TaskCompletionSource的方式执行或崩溃该异常。 为了更好地理解它,让我们看一下这段示例代码。 在这里,使用TaskCompletionSource帮助将EAP时代的一些旧API封装在Task中:触发事件时,Task将切换为Completed状态,而将wait操作符应用于此Task的方法将继续执行收到结果对象后。

 public static Task<Result> DoAsync(this SomeApiInstance someApiObj) { var completionSource = new TaskCompletionSource<Result>(); someApiObj.Done += result => completionSource.SetResult(result); someApiObj.Do(); result completionSource.Task; } 


TaskCompletionSource技巧与窍门


TaskCompletionSource可以完成的工作不仅仅是包装过时的API。 此类为基于不占用线程的Task设计各种API提供了一种有趣的可能性。 我们记得,线程是一种昂贵的资源,主要受RAM限制。 在开发具有复杂业务逻辑的健壮的Web应用程序时,我们可以轻松达到此限制。 让我们通过实施一个称为“长轮询”的巧妙技巧,看看我在行动中提到的功能。

简而言之,这是长轮询的工作方式:
您需要从API获取一些有关其侧面发生的事件的信息,但是由于某种原因,API只能返回状态,而不能告诉您有关事件的信息。 这样的一个例子是在WebSocket出现之前或在无法使用该技术的情况下通过HTTP构建的任何API。 客户端可以询问HTTP服务器。 另一方面,HTTP服务器本身无法启动与客户端的联系。 最简单的解决方案是使用计时器定期询问服务器,但这会给服务器造成额外的负担,并导致大致等于TimerInterval / 2的一般延迟。为了避免这种情况,发明了长轮询。 这需要将服务器响应延迟到“超时”到期或事件发生之前。 如果发生事件,将对其进行处理; 如果没有,则将再次发送请求。

 while(!eventOccures && !timeoutExceeded) { CheckTimout(); CheckEvent(); Thread.Sleep(1); } 

但是,如果等待事件的客户端数量增加,则该解决方案的有效性将急剧下降-每个等待的客户端都占用一个完整线程。 此外,事件触发还会产生1ms的额外延迟。 通常,它并不是那么重要,但是为什么我们要使软件变得比它糟糕呢? 另一方面,如果我们删除Thread.Sleep(1),则其中一个CPU内核将被完全加载100%,而在无用的周期中不执行任何操作。 借助TaskCompletionSource,我们可以轻松地转换代码以解决我们提到的所有问题:

 class LongPollingApi { private Dictionary<int, TaskCompletionSource<Msg>> tasks; public async Task<Msg> AcceptMessageAsync(int userId, int duration) { var cs = new TaskCompletionSource<Msg>(); tasks[userId] = cs; await Task.WhenAny(Task.Delay(duration), cs.Task); return cs.Task.IsCompleted ? cs.Task.Result : null; } public void SendMessage(int userId, Msg m) { if (tasks.TryGetValue(userId, out var completionSource)) completionSource.SetResult(m); } } 

请记住,这段代码仅是示例,绝不准备投入生产。 要在实际情况下使用它,我们至少需要添加一种方法来处理在没有消息等待的情况下收到消息的情况:在这种情况下,AcceptMessageAsync方法应该返回一个已经完成的Task。 如果这种情况是最常见的情况,我们可以考虑使用ValueTask。

收到消息请求时,我们创建一个TaskCompletionSource,将其放在词典中,然后等待以下事件之一:花费了指定的时间间隔或接收到一条消息。


ValueTask:为什么和如何


就像yield return运算符一样,async / await运算符从方法中生成一个有限状态机,这意味着创建一个新对象-这在大多数情况下并不重要,但是在极少数情况下仍然会产生问题。 其中一种情况可能会因经常调用的方法而发生-我们正在每秒谈论数以十万计的呼叫。 如果在大多数情况下以这种方式编写的方法使它在绕过所有等待方法的同时返回结果,则.NET为此提供了一种优化工具-ValueTask结构。 要了解它是如何工作的,我们来看一个示例。 假设有一个我们定期访问的缓存。 如果其中有任何值,我们只返回它们; 如果没有值-我们尝试从慢速IO中获取它们。 理想情况下,后者应该异步完成,因此整个方法将是异步的。 因此,实现此方法的最明显的方法如下:

 public async Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return val; return await RequestById(id); } 

希望对其进行一点优化,并担心Roslyn在编译此代码时会生成什么,我们可以这样重写方法:

 public Task<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return Task.FromResult(val); return RequestById(id); } 

但是,在这种情况下,最好的解决方案是优化热路径-特别是在没有不必要分配和GC负载的情况下获取字典值。 同时,在少数情况下,当我们需要从IO获取数据时,情况将几乎保持不变:

 public ValueTask<string> GetById(int id) { if (cache.TryGetValue(id, out string val)) return new ValueTask<string>(val); return new ValueTask<string>(RequestById(id)); } 

让我们更仔细地看一下这段代码片段:如果在缓存中存在一个值,我们将创建一个结构; 否则,实际任务将包装在ValueTask中。 执行此代码的路径对于调用代码并不重要:从C#语法的角度来看,ValueTask的行为将与通常的Task一样。


TaskScheduler:控制任务执行策略


我要讨论的下一个API是TaskScheduler类以及从其派生的类。 我已经提到过TPL提供了一种控制任务在线程之间的分配方式的功能。 这些策略在从TaskScheduler继承的类中定义。 我们可能需要的几乎所有策略都可以在ParallelExtensionsExtras库中找到。 该库由Microsoft开发,但不是.NET的一部分-而是作为Nuget软件包分发。 让我们看一些策略:

  • CurrentThreadTaskScheduler-在当前线程上执行任务
  • LimitedConcurrencyLevelTask​​Scheduler-使用构造函数中接受的N参数限制并发执行的Tasks的数量
  • OrderedTaskScheduler-定义为LimitedConcurrencyLevelTask​​Scheduler(1),因此将按顺序执行任务。
  • WorkStealingTaskScheduler-实现工作窃取方法以执行任务。 本质上,可以将其视为单独的ThreadPool。 这有助于解决ThreadPool在.NET中是静态类的问题-如果在应用程序的某个部分中对其进行了超载或使用不当,则可能在其他位置发生不愉快的副作用。 此类缺陷的真正原因可能很难找到,因此您可能需要在应用程序的那些可能激进且不可预测的ThreadPool使用的部分中使用单独的WorkStealingTaskSchedulers。
  • QueuedTaskScheduler-允许在优先队列的基础上执行任务
  • ThreadPerTaskScheduler-为在其上执行的每个任务创建一个单独的线程。 这对于无法估计执行时间的任务很有用。

在Microsoft的博客上有一篇关于TaskSchedulers的很好的文章 ,请随时查看。

在Visual Studio中,有一个“任务”窗口,可以帮助调试与“任务”相关的所有内容。 在此窗口中,您可以查看任务的状态并跳至当前执行的代码行。



PLinq和平行班


除了任务和与之相关的所有事物,.NET中还有两个其他有趣的工具-PLinq (Linq2Parallel)和Parallel类。 第一个承诺在所有线程上并行执行所有Linq操作。 可以通过扩展方法WithDegreeOfParallelism配置线程数。 不幸的是,在大多数情况下,默认模式下的PLinq将没有足够的数据源信息来显着提高速度。 另一方面,尝试的成本非常低:您只需要在Linq方法链之前调用AsParallel并进行性能测试。 此外,您可以使用分区机制将有关数据源性质的其他信息传递给PLinq。 您可以在此处此处找到更多信息。

Parallel静态类提供了以下方法:通过Foreach并行枚举集合,运行For循环并与Invoke并行执行多个委托。 当前线程的执行将停止,直到计算出结果为止。 您可以通过传递ParallelOptions作为最后一个参数来配置线程数。 也可以在选项的帮助下设置TaskScheduler和CancellationToken。


总结


当我开始根据自己的论文以及从事此工作后所获得的知识撰写本文时,我认为不会有那么多信息。 现在,随着文本编辑器可耻地告诉我,我已经写了将近15页,我想得出一个中间结论。 在下一篇文章中,我们将介绍其他技术,API,可视化工具和隐患。

结论:

  • 为了有效地利用现代PC的资源,您需要了解用于处理线程,异步和并行性的工具。
  • .NET中有很多这样的工具
  • 并非所有这些代码都是同时创建的,因此您可能经常会遇到一些遗留代码-但是有些方法可以轻松转换旧的API。
  • 在.NET中,Thread和ThreadPool类用于处理线程
  • Thread.Abort和Thread.Interrupt方法以及Win32 API函数TerminateThread都是危险的,因此不建议使用。 相反,最好使用CancellationTokens
  • 线程是宝贵的资源,其数量是有限的。 您应该避免等待事件占用线程的情况。 TaskCompletionSource类可以帮助实现这一目标。
  • 任务是.NET具有的用于处理并行性和异步性的最强大,最强大的工具。
  • 异步/等待C#运算符实现了非阻塞等待的概念
  • 您可以借助TaskScheduler派生的类来控制任务在线程之间的分配方式
  • ValueTask结构可用于优化热路径和内存流量
  • Visual Studio中的“任务和线程”窗口为调试多线程或异步代码提供了许多有用的信息
  • PLinq是一个很棒的工具,但是它可能不具备有关您的数据源的所有必需信息-仍可以使用分区机制进行修复

待续 ...

Source: https://habr.com/ru/post/zh-CN455252/


All Articles