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





0/5 (0投票)
解释一个应用程序中利用 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()
防止主方法关闭应用程序。DoWork
和 MoreWork
方法使用 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
方法都会阻塞,例如 Wait
、WaitAll
、WaitAny
。包含 'Result
' 的 Task
方法,例如 Result
、GetAwaiter.GetResult
也会阻塞。
TaskCompletionSource 示例
下面的“参考”部分包含指向几个基于 TaskCompletionSource
的有用类的链接。它们允许进程异步等待信号以便继续。信号可以由远程发送者发出,并且有一个清除(重置)信号的选项。另一个更常见的 TaskCompletionSource
用途是将同步事件转换为异步事件。关于公开同步方法的异步包装器的明智性存在一些争论。然而,有时可能需要 Task
类方法提供的多功能性。有一个Microsoft 视频,它作为将事件包装在返回可等待 Task
的方法中的规范示例。这里是另一个基于引发返回 int
的 Event
的类的示例。
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 });
}
}
TaskCompletionSource
在 MeaningOfLifeFinder
扩展方法中使用,以返回一个 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
作为参数,可以避免这种情况。该选项将导致消费者延续在与生产者方法不同的线程上执行。但是,在我看来,最好的选择是简单地避免在消费者延续中编写阻塞线程的语句。
参考文献
- Stephen Toub. 使用 TaskCompletionSource 构建 AsyncManualResetEvent
- Stephen Toub. 使用 TaskCompletionSource 构建 AsyncAutoResetEvent
- Stephen Cleary. 关于启动委托任务的最佳方式的讨论
- Stephen Cleary. 为什么你应该几乎总是使用 await 而不是 ContinueWith
历史
- 2024 年 1 月 8 日:初始版本