65.9K
CodeProject 正在变化。 阅读更多。
Home

Await vs. Wait 类的比喻和 TaskCompletionSource 文章的替代方案

emptyStarIconemptyStarIconemptyStarIconemptyStarIconemptyStarIcon

0/5 (0投票)

2024 年 1 月 8 日

CPOL

6分钟阅读

viewsIcon

4313

downloadIcon

46

解释一个应用程序中利用 TaskCreationSource 来关联其线程之间活动的代码执行路径

引言

本文是对 David Deley 撰写的优秀文章 Await vs. Wait 类的比喻和 TaskCompletionSource 的一个替代方案。原文章使用拟人化的比喻来解释一个示例应用程序中线程的行为,该应用程序利用 TaskCreationSource 来关联其线程之间的活动。相比之下,这里呈现的解释是基于应用程序进行时实时观察选定变量和线程的状态。

观察示例应用程序的状态

检查应用程序运行时状态的传统方法是使用调试器逐步执行代码。一种更流畅、更具叙述性的方法是在战略点将描述性消息发布到控制台,而不中断应用程序的流程,这就是这里使用的方法。大多数消息由以下 ShowThreadInfo 方法发布。

int ShowThreadInfo(string location, bool isSummary = true)
{
    int managedThreadID;
    lock (lockObj)
    {
        Thread thread = Thread.CurrentThread;
        managedThreadID = thread.ManagedThreadId;
        //A raw string literal, no escape characters needed other than'{}'
        string msgLong = $"""
  Location: {location}
  Is Background Thread?: {thread.IsBackground}
  IsThread Pool?: {thread.IsThreadPoolThread}  
  Thread ID: {managedThreadID}
  """;
        string msgShort = $"""
  Location: {location}
  Thread ID: {managedThreadID}
  """;
        string msg = isSummary ? msgShort : msgLong;
        Console.WriteLine(msg);
    }
    return managedThreadID;
}

示例应用程序

代码主要与原文章中的代码一致,并添加了一些观察点。Console.ReadLine() 防止主方法关闭应用程序。DoWorkMoreWork 方法使用 Thread.Sleep,以便通过更改该方法输入参数的值来实验性地改变它们的执行时间。

object lockObj = new();
bool isContinuationAsync = false;
//bool isContinuationAsync = true;
TaskCreationOptions option = isContinuationAsync ?
    TaskCreationOptions.RunContinuationsAsynchronously : TaskCreationOptions.None;
TaskCompletionSource<int> tcs = new(option);
ShowThreadInfo($"At Main start calling Task.Run", isSummary: false);
var runTask = Task.Run(() =>
{
    DoWork();
    Console.WriteLine("\n***About to call  tcs.SetResult(5)***");
    tcs.SetResult(5);
    MoreWork();
});
Console.WriteLine("\n**At Main awaiting TaskCompletionSource Task**\n");
await tcs.Task;
ShowThreadInfo($"At Main after awaiting TaskCompletionSource Task",false);
Console.WriteLine("\n**Hit 'return to exit Main**");
Console.ReadLine();

void DoWork()
{
    ShowThreadInfo($"'DoWork' start", isSummary: false);
    Thread.Sleep(1000);
    ShowThreadInfo($"'DoWork' end");
}

void MoreWork()
{
    ShowThreadInfo($"'MoreWork' has started", isSummary: false);
    Thread.Sleep(2000);
    ShowThreadInfo($"'MoreWork' has finished.");
}

控制台输出和观察

线程池线程的标识值每次运行代码时都可能发生变化,但观察顺序应该相当一致。这是测试运行的输出和观察结果。

 Location: At Main start, calling Task.Run
  Is Background Thread?: False
  IsThread Pool?: False
  Thread ID: 1

Main 方法使用主线程。该线程的 ID 始终为 1,并在整个应用程序中保持活动状态。它调用 Task.Run。主线程继续执行到 await 语句,在那里它遇到一个未完成的 Task。此时,该线程被释放,可以根据应用程序的需要执行管理任务。如果任务已完成,线程将继续执行并完成 Main 方法。

**At Main awaiting  TaskCompletionSource Task**

同时,Task.Run 调用导致线程池线程被招募来运行传递给该方法的委托。线程的 ID 是任意分配的;在这种情况下是 10。线程池是准备好的线程的存储库,可根据需要使用。

Location: 'DoWork' start
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 10

Location: 'DoWork' end
Thread ID: 10

DoWork 方法完成,并调用 TaskCompletionSource.SetResult 方法。SetResult 导致 Main 的延续(await 之后) 被调度在线程池线程 10 上运行,然后在 SetResult 调用之后跟随的代码也将在线程 10 上运行。如何实现这一点很复杂,并且根据应用程序的类型而有所不同。Stephen Toub 对涉及的机制有一个很好的解释。await 语句两侧的代码由同一线程执行的原因是,继续使用现有线程比启用和管理另一个线程池线程效率更高。

***About to call  tcs.SetResult(5)***

SetResult 调用将 Task 设置为已完成状态,线程 10 开始执行 Main 的延续。

Location: At Main after awaiting TaskCompletionSource Task
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 10

线程 10 继续运行直到遇到 Console.ReadLine 方法,这是一个阻塞调用,因为线程正在暂停等待用户输入。一旦按下回车键,Main 方法将执行完成,应用程序终止。MoreWork 方法永远不会被调用。

**Hit 'return to exit Main**

一个解决方案

原文章提出的应用程序过早终止问题的解决方案是,在实例化类时,将 TaskCompletionSource TaskCreationOption 参数设置为 TaskCreationOption.RunContinuationsAsynchronously。这是选择该选项时的相关输出。

Location: 'DoWork' start
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 5
Location: 'DoWork' end
Thread ID: 5
***About to call  tcs.SetResult(5)***

SetResult 之后,MoreWork 在同一线程 5 上被调用。

Location: 'MoreWork' has started
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 5

但是 Main 的延续现在在一个不同的线程池线程 Id10 上运行。

Location: At Main after awaiting TaskCompletionSource Task
Is Background Thread?: True
IsThread Pool?: True
Thread ID: 10

线程 10 阻塞,等待用户输入

**Hit 'return to exit Main**

同时,MoreWork 在线程 5 上完成。

Location: 'MoreWork' has finished.
Thread ID: 5

当用户按下回车键时,应用程序结束。

一个未解决的竞争条件

上面演示的解决方案确实确保了 MoreWork 方法被调用,但是仍然存在一个未解决的竞争情况。如果在 MoreWork 完成之前按下回车键,应用程序将在该方法结束之前关闭。解决方案是等待 Task.Run 方法返回的 Task。这将确保用户只有在 MoreWork 方法完成后才有机会关闭应用程序。

object lockObj = new();
bool isContinuationAsync = false;
TaskCreationOptions option = isContinuationAsync ?
    TaskCreationOptions.RunContinuationsAsynchronously : TaskCreationOptions.None;
TaskCompletionSource<int> tcs = new(option);
var runTask = Task.Run(() =>
{
    DoWork();
    tcs.SetResult(5);
    MoreWork();
});
await tcs.Task;
//await for MoreWork to complete
await runTask;
Console.WriteLine("\n**Hit 'return to exit Main**");
Console.ReadLine();

不需要选择 TaskCreationOptions.RunContinuationsAsynchronously,因为现在没有阻塞的延续代码阻止所有任务完成。这里重要的是确保所有任务都被等待,并构建代码,使应用程序不会在所有 Tasks 完成之前结束。不使用阻塞线程的语句也是一个好习惯。任何以 'Wait' 开头的 Task 方法都会阻塞,例如 WaitWaitAllWaitAny。包含 'Result' 的 Task 方法,例如 ResultGetAwaiter.GetResult 也会阻塞。

TaskCompletionSource 示例

下面的“参考”部分包含指向几个基于 TaskCompletionSource 的有用类的链接。它们允许进程异步等待信号以便继续。信号可以由远程发送者发出,并且有一个清除(重置)信号的选项。另一个更常见的 TaskCompletionSource 用途是将同步事件转换为异步事件。关于公开同步方法的异步包装器的明智性存在一些争论。然而,有时可能需要 Task 类方法提供的多功能性。有一个Microsoft 视频,它作为将事件包装在返回可等待 Task 的方法中的规范示例。这里是另一个基于引发返回 intEvent 的类的示例。

public class FindMeaningEventArgs : EventArgs { public int Meaning { get; set; } }
 public class MeaningOfLifeFinder
 {
     public event EventHandler<FindMeaningEventArgs>? MeaningFound;
     public void FindMeaning()
     {
         //...
         int meaning = 42;
         OnMeaningFound(meaning);
     }
     protected void OnMeaningFound(int meaning)
     {
         MeaningFound?.Invoke(this, e: new FindMeaningEventArgs() { Meaning = meaning });
     }
 }

TaskCompletionSourceMeaningOfLifeFinder 扩展方法中使用,以返回一个 Task<int>

 public static class Extensions
 {
     public static async Task<int> FindMeaningAsync
            (this MeaningOfLifeFinder meaningOfLifeFinder)
     {
         var tcs = new TaskCompletionSource<int>();
         //Define a local function to handle the event 
         void handler(object? s, FindMeaningEventArgs e) => tcs.TrySetResult(e.Meaning);
         try
         {
             //subscribe to the Event
             meaningOfLifeFinder.MeaningFound += handler;
             //call a method that triggers the Event
             meaningOfLifeFinder.FindMeaning();
             return await tcs.Task;
         }
         finally
         {
             //unsubscribe from the Event
             meaningOfLifeFinder.MeaningFound -= handler;
         }
     }
 }

它的用法如下

  MeaningOfLifeFinder meaningOfLifeFinder = new();
  int result= await meaningOfLifeFinder.FindMeaningAsync();

结论

TaskCreationSource 类提供了一种有效的方式,可以在存在于单独方法中的代码块之间实现基于任务的异步关联。但是,在部署该类时需要谨慎,因为默认情况下,await 语句之后 Task 消费者(consumer)的延续代码会在与 Task 生产者(producer)调用 TaskCreationSource.SetResult 之后的代码相同的线程上顺序执行。因此,不要阻塞消费者延续代码的执行非常重要,因为它会阻止生产者方法中代码的及时完成。通过在构造 TaskCreationSource 类时选择 TaskCreationOptions.RunContinuationsAsynchronously 作为参数,可以避免这种情况。该选项将导致消费者延续在与生产者方法不同的线程上执行。但是,在我看来,最好的选择是简单地避免在消费者延续中编写阻塞线程的语句。

参考文献

历史

  • 2024 年 1 月 8 日:初始版本
© . All rights reserved.