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

我正在发表关于 Habr 的原始文章,其翻译发布在公司 блоге.

在计算机出现之前,就存在异步执行某些操作而不需要立即等待结果的需求,或者将大量工作分配给多个执行它的单元的需求。 随着它们的出现,这种需求变得非常明显。 现在,2019 年,我正在一台配备 8 核 Intel Core 处理器的笔记本电脑上写这篇文章,该处理器上有一百多个进程并行运行,甚至还有更多线程。 旁边有一部略显破旧的手机,几年前买的,搭载8核处理器。 主题资源充满了文章和视频,作者对今年配备 16 核处理器的旗舰智能手机表示赞赏。 MS Azure 以不到 20 美元/小时的价格提供具有 128 核处理器和 2 TB RAM 的虚拟机。 不幸的是,如果无法管理线程的交互,就不可能最大限度地发挥和利用这种能力。

术语

过程 - OS对象,隔离的地址空间,包含线程。
线 - 操作系统对象,最小的执行单元,进程的一部分,线程在进程内相互共享内存和其他资源。
多任务处理 - 操作系统属性,同时运行多个进程的能力
多核 - 处理器的属性,使用多个内核进行数据处理的能力
多重处理 - 计算机的一项属性,即物理上同时与多个处理器一起工作的能力
多线程 — 进程的属性,在多个线程之间分配数据处理的能力。
并行性 - 在单位时间内同时执行多个物理动作
异步 — 执行操作而不等待该处理完成;执行结果可以稍后处理。

隐喻

并不是所有的定义都是好的,有些还需要额外的解释,所以我会在正式引入的术语中添加一个关于做早餐的比喻。 在这个比喻中,做早餐是一个过程。

早上准备早餐时我(中央处理器)我来到厨房(计算机)。 我有2只手(颜色)。 厨房里有很多设备(IO):烤箱、水壶、烤面包机、冰箱。 我打开煤气,在上面放一个煎锅,然后倒入油,不等它加热(异步、非阻塞 IO 等待),我把鸡蛋从冰箱里拿出来,打散到盘子里,然后用一只手敲打(线程#1)和第二个(线程#2)拿着盘子(共享资源)。 现在我想开水壶,但我的手不够(线程饥饿)在此期间,煎锅加热(处理结果),我将搅打过的东西倒入其中。 我伸手拿起水壶,打开它,愚蠢地看着里面的水沸腾(阻塞-IO-等待),尽管在这段时间里他可以清洗他搅打煎蛋卷的盘子。

我只用两只手煮了一个煎蛋,而且我没有更多的手,但同时,在搅打煎蛋的那一刻,同时进行了2个操作:搅动煎蛋、握住盘子、加热煎锅CPU是计算机中最快的部分,IO是最常变慢的部分,因此通常有效的解决方案是在从IO接收数据的同时用某些东西占用CPU。

继续比喻:

  • 如果在准备煎蛋卷的过程中,我也会尝试换衣服,这就是一个多任务处理的例子。 一个重要的细微差别是:计算机在这方面比人类做得更好。
  • 拥有多名厨师的厨房,例如在餐厅中 - 多核计算机。
  • 购物中心美食广场的许多餐馆 — 数据中心

.NET 工具

.NET 擅长处理线程,就像处理许多其他事情一样。 每个新版本都引入了越来越多的新工具,以及操作系统线程上的新抽象层。 在构建抽象时,框架开发人员使用一种方法,在使用高级抽象时,有机会深入到下面的一个或多个级别。 大多数情况下,这是没有必要的,事实上,它打开了用霰弹枪搬起石头砸自己脚的大门,但有时,在极少数情况下,这可能是解决当前抽象级别未解决的问题的唯一方法。

我所说的工具,是指框架和第三方包提供的应用程序编程接口(API),以及简化搜索与多线程代码相关的任何问题的整个软件解决方案。

启动一个线程

Thread 类是 .NET 中用于处理线程的最基本的类。 构造函数接受两个委托之一:

  • ThreadStart — 无参数
  • ParametrizedThreadStart - 具有一个对象类型的参数。

委托将在调用 Start 方法后在新创建的线程中执行。如果将 ParametrizedThreadStart 类型的委托传递给构造函数,则必须将对象传递给 Start 方法。 需要此机制将任何本地信息传输到流。 值得注意的是,创建线程是一个昂贵的操作,并且线程本身是一个重对象,至少因为它在堆栈上分配1MB内存并且需要与OS API交互。

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

ThreadPool 类代表池的概念。 在 .NET 中,线程池是一项工程,Microsoft 的开发人员投入了大量精力来确保它在各种场景中都能以最佳方式工作。

一般概念:

从应用程序启动的那一刻起,它就会在后台创建多个预留线程,并提供使用它们的能力。 如果频繁且大量使用线程,则池会扩展以满足调用者的需求。 当池中在适当的时候没有空闲线程时,它将等待其中一个线程返回,或者创建一个新线程。 由此可见,线程池非常适合某些短期操作,但不太适合在整个应用程序操作中作为服务运行的操作。

要使用池中的线程,有一个 QueueUserWorkItem 方法接受 WaitCallback 类型的委托,该方法与 ParametrizedThreadStart 具有相同的签名,并且传递给它的参数执行相同的功能。

ThreadPool.QueueUserWorkItem(...);

不太为人所知的线程池方法RegisterWaitForSingleObject用于组织非阻塞IO操作。 当传递给该方法的 WaitHandle 为“已释放”时,将调用传递给该方法的委托。

ThreadPool.RegisterWaitForSingleObject(...)

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

System.Threading.Timer

还有一种相当奇特的方法可以将执行委托发送到池中的线程 - BeginInvoke 方法。

DelegateInstance.BeginInvoke

我想简要介绍一下可以调用上述许多方法的函数 - 来自 Kernel32.dll Win32 API 的 CreateThread。 借助 extern 方法的机制,有一种方法可以调用此函数。 我在一个可怕的遗留代码示例中只见过一次这样的调用,而这样做的作者的动机对我来说仍然是个谜。

Kernel32.dll CreateThread

查看和调试线程

您创建的线程、所有第三方组件以及.NET 池都可以在 Visual Studio 的“线程”窗口中查看。 仅当应用程序处于调试状态且处于中断模式时,此窗口才会显示线程信息。 在这里您可以方便地查看每个线程的堆栈名称和优先级,并将调试切换到特定线程。 使用 Thread 类的 Priority 属性,您可以设置线程的优先级,OC 和 CLR 在线程之间分配处理器时间时会将其视为建议。

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

任务并行库

任务并行库 (TPL) 是在 .NET 4.0 中引入的。 现在它是处理异步的标准和主要工具。 任何使用旧方法的代码都被视为遗留代码。 TPL 的基本单元是 System.Threading.Tasks 命名空间中的 Task 类。 任务是线程的抽象。 使用新版本的 C# 语言,我们获得了一种处理任务的优雅方式 - 异步/等待运算符。 这些概念使得编写异步代码成为可能,就好像它是简单和同步的一样,这使得即使对线程内部工作了解甚少的人也可以编写使用它们的应用程序,这些应用程序在执行长时间操作时不会冻结。 使用 async/await 是一篇甚至几篇文章的主题,但我将尝试用几句话来了解它的要点:

  • async 是返回 Task 或 void 的方法的修饰符
  • 而await是一个非阻塞的任务等待操作符。

再次强调一下:await操作符,一般情况下(也有例外),会进一步释放当前执行的线程,当Task执行完毕,线程(其实应该说上下文会更正确) ,但稍后会详细介绍)将继续进一步执行该方法。 在.NET内部,这种机制的实现方式与yield return相同,即编写的方法变成一个完整的类,它是一个状态机,可以根据这些状态以单独的部分执行。 任何感兴趣的人都可以使用 async/await 编写任何简单的代码,使用启用了编译器生成代码的 JetBrains dotPeek 来编译和查看程序集。

让我们看看启动和使用任务的选项。 在下面的代码示例中,我们创建了一个不执行任何有用操作的新任务(线程.睡眠(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 方法将只能在任务开始之前停止任务的执行。

最后一个参数是 TaskScheduler 类型的调度程序对象。 此类及其后代旨在控制跨线程分配任务的策略;默认情况下,任务将在池中的随机线程上执行。

await 运算符应用于创建的 Task,这意味着在其之后编写的代码(如果有)将在与 wait 之前的代码相同的上下文中(通常这意味着在同一线程上)执行。

该方法被标记为async void,这意味着它可以使用await运算符,但调用代码将无法等待执行。 如果需要这样的功能,则该方法必须返回 Task。 标记为 async void 的方法非常常见:通常,这些是事件处理程序或其他遵循即发即忘原则的方法。 如果你不仅需要给出等待执行结束的机会,而且还需要返回结果,那么你需要使用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,并且同步上下文需要在之后返回到调用线程如果异步方法的结果被计算出来,调用线程就会等待,异步方法徒劳地尝试在调用线程中继续执行。

这种方法的另一个缺点是复杂的错误处理。 事实上,使用 async/await 时异步代码中的错误非常容易处理 - 它们的行为与同步代码相同。 而如果我们对任务应用同步等待驱魔,原始异常就会变成 AggregateException,即要处理异常,您必须检查 InnerException 类型,并在一个 catch 块内自行编写一个 if 链,或者使用 catch while 构造,而不是使用 C# 世界中更熟悉的 catch 块链。

第三个也是最后一个示例也因同样的原因被标记为“坏”,并且包含所有相同的问题。

WhenAny 和 WhenAll 方法对于等待一组任务非常方便;它们将一组任务包装成一个,当该组中的任务首次触发时,或者当所有任务都完成执行时,该方法将触发。

停止线程

由于各种原因,可能需要在流程开始后停止流程。 有多种方法可以做到这一点。 Thread 类有两个适当命名的方法: 流产 и 中断。 强烈不建议使用第一个,因为在任意时刻调用它之后,在处理任何指令的过程中,都会抛出异常 线程中止异常。 您不希望在递增任何整数变量时抛出这样的异常,对吧? 而当使用这个方法的时候,这是一个非常真实的情况。 如果需要防止 CLR 在某段代码中生成此类异常,可以将其包装在调用中 Thread.BeginCriticalRegion, Thread.EndCriticalRegion。 在finally 块中编写的任何代码都包含在此类调用中。 因此,在框架代码的深处,您可以找到带有空 try 的块,但找不到带有空 finally 的块。 Microsoft 非常不鼓励这种方法,因此他们没有将其包含在 .net core 中。

中断方法的工作方式更加可预测。 它可以通过异常中断线程 线程中断异常 仅在线程处于等待状态的那些时刻。 当等待 WaitHandle、锁定或调用 Thread.Sleep 后挂起时,它会进入此状态。

由于不可预测性,上述两种选择都很糟糕。 解决方案是使用结构体 取消令牌 和班级 取消令牌源。 要点是:创建了 CancellationTokenSource 类的实例,只有拥有它的人才能通过调用该方法来停止操作 取消。 仅 CancellationToken 会传递给操作本身。 CancellationToken 持有者无法自行取消操作,只能检查操作是否已被取消。 有一个布尔属性用于此 是否已请求取消 和方法 如果取消请求则抛出。 后者会抛出异常 任务取消异常 如果在鹦鹉学舌的 CancellationToken 实例上调用 Cancel 方法。 这是我推荐使用的方法。 这是对以前选项的改进,可以完全控制异常操作可以中止的时间点。

停止线程最残酷的选择是调用 Win32 API TerminateThread 函数。 调用此函数后 CLR 的行为可能是不可预测的。 MSDN 上关于这个函数的描述如下: “TerminateThread 是一个危险的函数,只应在最极端的情况下使用。 “

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

如果您足够幸运,能够从事一个在引入任务后开始的项目,并且不再给大多数开发人员带来安静的恐惧,那么您​​将不必处理大量旧的 API,无论是第三方的还是您团队的 API过去曾受过折磨。 幸运的是,.NET Framework 团队照顾了我们,尽管目标可能是照顾我们自己。 尽管如此,.NET 有许多工具可以轻松地将用旧的异步编程方法编写的代码转换为新的异步编程方法。 其中之一是 TaskFactory 的 FromAsync 方法。 在下面的代码示例中,我使用此方法将 WebRequest 类的旧异步方法包装在任务中。

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

这只是一个示例,您不太可能必须使用内置类型执行此操作,但任何旧项目都充满了返回 IAsyncResult 的 BeginDoSomething 方法和接收它的 EndDoSomething 方法。

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

另一个需要考虑的重要工具是类 任务完成源。 从功能、用途和操作原理来看,可能有点让人想起我上面写的ThreadPool类的RegisterWaitForSingleObject方法。 使用此类,您可以轻松方便地将旧的异步 API 包装在任务中。

您可能会说,我已经讨论了用于这些目的的 TaskFactory 类的 FromAsync 方法。 这里我们必须记住微软在过去 15 年中提供的 .net 异步模型开发的整个历史:在基于任务的异步模式(TAP)之前,有异步编程模式(APP),它是关于方法的 开始做某事返回 同步结果 和方法 结束接受它的 DoSomething 以及这些年来的遗留下来的 FromAsync 方法非常完美,但随着时间的推移,它被基于事件的异步模式所取代(EAP),它假设异步操作完成时将引发事件。

TaskCompletionSource 非常适合包装围绕事件模型构建的任务和遗留 API。 其工作本质是这样的:该类的一个对象有一个类型为Task的公共属性,可以通过TaskCompletionSource类的SetResult、SetException等方法控制其状态。 在将await 运算符应用于此任务的地方,它将被执行或失败并出现异常,具体取决于应用于TaskCompletionSource 的方法。 如果还不清楚,让我们看一下这个代码示例,其中一些旧的 EAP API 使用 TaskCompletionSource 包装在任务中:当事件触发时,任务将转移到 Completed 状态,并且应用了等待运算符的方法此任务将在收到对象后恢复执行 导致.

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 提示与技巧

包装旧的 API 并不是使用 TaskCompletionSource 可以完成的全部工作。 使用此类为在不占用线程的任务上设计各种 API 提供了一种有趣的可能性。 正如我们所记得的,流是一种昂贵的资源,而且它们的数量是有限的(主要受 RAM 量的限制)。 例如,通过开发具有复杂业务逻辑的加载 Web 应用程序,可以轻松实现此限制。 让我们考虑一下我在实现长轮询这样的技巧时所讨论的可能性。

简而言之,这个技巧的本质是这样的:你需要从 API 接收其自身发生的一些事件的信息,而 API 由于某种原因无法报告事件,而只能返回状态。 其中一个例子是,在 WebSocket 时代之前或由于某种原因不可能使用该技术时,所有 API 都构建在 HTTP 之上。 客户端可以询问HTTP服务器。 HTTP 服务器本身无法发起与客户端的通信。 一个简单的解决方案是使用计时器轮询服务器,但这会给服务器带来额外的负载,并且平均 TimerInterval / 2 会产生额外的延迟。为了解决这个问题,发明了一种称为长轮询的技巧,其中涉及延迟来自服务器的响应服务器直到超时到期或发生事件。 如果事件已经发生,则处理该事件,如果没有发生,则再次发送请求。

while(!eventOccures && !timeoutExceeded)  {

  CheckTimout();
  CheckEvent();
  Thread.Sleep(1);
}

但一旦等待事件的客户端数量增加,这样的解决方案就会被证明是糟糕的,因为…… 每个这样的客户端占用一个完整的线程来等待事件。 是的,当事件被触发时,我们会得到额外的 1 毫秒延迟,大多数情况下这并不重要,但为什么要让软件变得比实际情况更糟糕呢? 如果我们删除 Thread.Sleep(1),那么我们将徒劳地加载一个处理器核心 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);
    }
}

此代码尚未准备好用于生产,而只是一个演示。 要在实际情况中使用它,您至少还需要处理消息在无人预料的情况下到达的情况:在这种情况下,AsseptMessageAsync 方法应该返回一个已经完成的任务。 如果这是最常见的情况,那么您可以考虑使用 ValueTask。

当我们收到消息请求时,我们创建一个 TaskCompletionSource 并将其放入字典中,然后等待首先发生的事情:指定的时间间隔到期或收到消息。

ValueTask:原因和方式

async/await 运算符(如yield return 运算符)从方法生成状态机,这是新对象的创建,这几乎总是不重要,但在极少数情况下可能会产生问题。 这种情况可能是一个调用非常频繁的方法,我们谈论的是每秒数万、数十万次调用。 如果这样的方法的编写方式在大多数情况下会绕过所有等待方法返回结果,那么.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));
}

让我们仔细看看这段代码:如果缓存中有一个值,我们就创建一个结构,否则真正的任务将被包装在一个有意义的结构中。 调用代码并不关心此代码在哪个路径中执行:从 C# 语法的角度来看,在这种情况下,ValueTask 的行为与常规任务相同。

TaskSchedulers:管理任务启动策略

我想考虑的下一个 API 是类 任务调度 及其衍生物。 我上面已经提到,TPL 能够管理跨线程分配任务的策略。 此类策略在 TaskScheduler 类的后代中定义。 几乎您可能需要的任何策略都可以在图书馆中找到。 并行扩展额外,由 Microsoft 开发,但不是 .NET 的一部分,而是作为 Nuget 包提供。 让我们简单地看一下其中的一些:

  • 当前线程任务调度器 — 在当前线程上执行任务
  • 有限并发级别任务调度程序 — 通过参数 N 限制同时执行的任务数量,该参数在构造函数中接受
  • 有序任务调度器 — 定义为 LimitedConcurrencyLevelTask​​Scheduler(1),因此任务将按顺序执行。
  • 工作窃取任务调度程序 - 实施 偷工减料 任务分配方法。 本质上它是一个单独的线程池。 解决了.NET中ThreadPool是一个静态类,适用于所有应用程序的问题,这意味着它在程序的某一部分的重载或不正确的使用可能会导致另一部分产生副作用。 而且,了解此类缺陷的原因极其困难。 那。 在程序的某些部分中,可能需要使用单独的 WorkStealingTaskScheduler,其中 ThreadPool 的使用可能是激进且不可预测的。
  • 队列任务调度器 — 允许您根据优先级队列规则执行任务
  • 线程每任务调度器 - 为在其上执行的每个任务创建一个单独的线程。 对于需要花费不可预测的长时间才能完成的任务非常有用。

有一个很好详细的 文章 有关 Microsoft 博客上的 TaskSchedulers 的信息。

为了方便调试与任务相关的所有内容,Visual Studio 有一个任务窗口。 在此窗口中您可以看到任务的当前状态并跳转到当前正在执行的代码行。

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

PLinq 和 Parallel 类

除了任务和有关它们的所有内容之外,.NET 中还有两个更有趣的工具:PLinq (Linq2Parallel) 和 Parallel 类。 第一个承诺在多个线程上并行执行所有 Linq 操作。 可以使用 WithDegreeOfParallelism 扩展方法配置线程数。 不幸的是,大多数情况下,默认模式下的 PLinq 没有足够的关于数据源内部的信息来提供显着的速度增益,另一方面,尝试的成本非常低:您只需要在之前调用 AsParallel 方法即可Linq 方法链并运行性能测试。 此外,还可以使用分区机制向 PLinq 传递有关数据源性质的附加信息。 您可以阅读更多内容 这里 и 这里.

Parallel 静态类提供了并行迭代 Foreach 集合、执行 For 循环以及并行执行多个委托 Invoke 的方法。 当前线程的执行将停止,直到计算完成。 可以通过传递 ParallelOptions 作为最后一个参数来配置线程数。 您还可以使用选项指定 TaskScheduler 和 CancellationToken。

发现

当我根据报告的材料和之后工作中收集到的信息开始写这篇文章时,我没想到会有这么多。 现在,当我在其中输入本文的文本编辑器责备地告诉我第 15 页已经消失时,我将总结中期结果。 其他技巧、API、可视化工具和陷阱将在下一篇文章中介绍。

结论:

  • 您需要了解处理线程、异步和并行的工具,才能使用现代 PC 的资源。
  • .NET 有许多不同的工具用于这些目的
  • 它们并非全部同时出现,因此您经常可以找到遗留的 API,但是,有一些方法可以轻松转换旧 API。
  • 在 .NET 中使用线程由 Thread 和 ThreadPool 类表示
  • Thread.Abort、Thread.Interrupt 和 Win32 API TerminateThread 方法很危险,不建议使用。 相反,最好使用 CancellationToken 机制
  • 流量是一种宝贵的资源,但其供给是有限的。 应避免线程忙于等待事件的情况。 为此,使用 TaskCompletionSource 类很方便
  • 用于处理并行性和异步性的最强大、最先进的 .NET 工具是任务。
  • C# async/await 运算符实现了非阻塞等待的概念
  • 您可以使用 TaskScheduler 派生类来控制跨线程的任务分配
  • ValueTask 结构可用于优化热路径和内存流量
  • Visual Studio 的任务和线程窗口提供了大量对于调试多线程或异步代码有用的信息
  • Plinq 是一个很酷的工具,但它可能没有足够的有关数据源的信息,但这可以使用分区机制来修复
  • 待续...

来源: habr.com

添加评论