并发编程-11.取消异步工作
取消托管线程
取消 .NET 中的异步工作基于取消令牌的使用。 令牌是一个简单的对象,用于指示已向另一个线程发出取消请求。 CancellationTokenSource
对象管理这些请求并包含一个令牌。 如果要使用同一触发器取消多个操作,则应向所有要取消的线程提供相同的令牌。
CancellationTokenSource
实例具有 Token
属性,用于访问 CancellationToken
属性并将其传递给一个或多个异步操作。 取消请求只能从 CancellationTokenSource
对象发出。 提供给其他操作的 CancellationToken
属性接收取消信号,但无法启动取消。
CancellationTokenSource
实现 IDisposable
接口,因此在释放托管资源时请务必调用 Dispose
。 如果对您的工作流程实用,则最好使用 using
语句或块来自动处理令牌源。
重要的是要理解取消不是在侦听代码上强制执行的。 接收取消请求的异步代码必须确定它当前是否可以取消其工作。 它可能决定立即取消、完成一些中间任务后取消,或者完成其工作并忽略该请求。 例程忽略取消请求可能有正当理由。 工作可能已接近完成,或者在当前状态下取消将导致某些数据损坏。 取消的决定必须由请求者和收听者共同做出。
让我们看一个示例,理解如何协作取消 ThreadPool
线程上的后台线程正在处理的某些工作:
- 在 Visual Studio 中,创建一个新的 .NET 6 控制台应用程序,名为
CancelThreadsConsoleApp
. - 添加一个名为
ManagedThreadsExample
的新类。 - 在
ManagedThreadsExample
类中创建一个名为ProcessText
的方法:
public static void ProcessText(object? cancelToken)
{
var token = cancelToken as CancellationToken?;
string text = "";
for (int x = 0; x < 75000; x++)
{
if (token != null && token.Value.IsCancellationRequested)
{
Console.WriteLine($"Cancellation request
received. String value: {text}");
break;
}
text += x + " ";
Thread.Sleep(500);
}
}
此方法将迭代器变量 x
的值附加到 text
的字符串变量,直到收到取消请求。 有一个 Thread.Sleep(500)
语句允许调用方法有一段时间来取消操作。
4. 接下来,在 Program.cs
中创建一个名为 CancelThread
的方法:
private static void CancelThread()
{
using CancellationTokenSource tokenSource = new();
Console.WriteLine("Starting operation.");
ThreadPool.QueueUserWorkItem(newWaitCallback(ManagedThreadsExample.ProcessText),
tokenSource.Token);
Thread.Sleep(5000);
Console.WriteLine("Requesting cancellation.");
tokenSource.Cancel();
Console.WriteLine("Cancellation requested.");
}
此方法调用 ThreadPool.QueueUserWorkItem
将 ProcessText
方法在 ThreadPool
线程中排队。 该方法还从 tokenSource.Token
接收取消令牌。 等待五秒后,调用 tokenSource.Cancel
,ProcessText
将收到取消请求。
请注意,tokenSource
是在 using
语句中创建的。 这确保了当它超出范围时将被正确处置。
- 在
Program.cs
中的 Main 方法中添加对CancelThread
的调用:
static void Main(string[] args)
{
CancelThread();
Console.ReadKey();
}
- 最后,运行应用程序并观察控制台输出:
图 11.1 – 运行
CancelThreadsConsoleApp
项目
取消并行工作
在本节中,我们将介绍一些取消并行操作的示例。 有一些操作属于这个领域。 有属于 System.Threading.Tasks.Parallel
类一部分的静态并行操作,并且有 PLINQ 操作。 这两种类型都使用 CancellationToken
属性,正如我们在上一节的托管线程示例中所使用的那样。 但是,处理取消请求略有不同。 让我们看一个例子来理解其中的差异。
取消并行循环
在本节中,我们将创建一个示例来说明如何取消 Parallel.For
循环。 Parallel.ForEach
方法使用相同的取消方法。 执行以下步骤:
- 打开上一节中的
CancelThreadsConsoleApp
项目。 - 在
ManagedThreadsExample
类中,创建一个具有以下实现的新ProcessTextParallel
方法:
public static void ProcessTextParallel(object? cancelToken)
{
var token = cancelToken as CancellationToken?;
if (token == null) return;
string text = "";
ParallelOptions options = new()
{
CancellationToken = token.Value,
MaxDegreeOfParallelism = Environment.ProcessorCount
};
try
{
Parallel.For(0, 75000, options, (x) =>
{
text += x + " ";
Thread.Sleep(500);
});
}
catch (OperationCanceledException e)
{
Console.WriteLine($"Text value: {text}.{Environment.NewLine} Exceptionencountered: {e.Message}");
}
}
本质上,前面的代码与我们上一个示例中的 ProcessText
方法执行相同的操作。 它将一个数值附加到文本变量,直到请求取消为止。 让我们来看看差异:
- 首先,我们将
token.Value
设置为ParallelOptions
对象的CancellationToken
属性。 这些选项作为第三个参数传递给Parallel.For
方法。 - 第二个主要区别是我们通过捕获
OperationCanceledException
类型来处理取消请求。 当Program.cs
中的其他代码请求取消时,将抛出此异常类型。
- 接下来,将名为
CancelParallelFor
的方法添加到Program.cs
:
private static void CancelParallelFor()
{
using CancellationTokenSource tokenSource = new();
Console.WriteLine("Press a key to start, then press 'x' to send cancellation.");
Console.ReadKey();
Task.Run(() =>
{
if (Console.ReadKey().KeyChar == 'x')
tokenSource.Cancel();
Console.WriteLine();
Console.WriteLine("press a key");
});
ManagedThreadsExample.ProcessTextParallel(tokenSource.Token);
}
在此方法中,指示用户按某个键开始操作,并在准备取消操作时按 X 键。 处理从控制台接收 按键 x
并发送 Cancel
请求的代码在另一个线程上执行,以便保持当前线程可以自由地调用 ProcessTextParallel
。
4. 最后,更新 Main
方法以调用 CancelParallelFor
并注释掉对 CancelThread
的调用:
static void Main(string[] args)
{
//CancelThread();
CancelParallelFor();
Console.ReadKey();
}
- 现在运行该项目。 按照提示取消
Parallel.For
循环,并检查输出:
图 11.2 – 从控制台取消
Parallel.For
循环
请注意这些数字根本没有按顺序排列。 在本例中,Parallel.For
操作似乎使用了两个不同的线程。 第一个线程从 0
开始,而第二个线程则对从 37500
开始的整数进行操作。这是提供给方法参数的最大值 75000
的中点。
取消 PLINQ 查询
取消 PLINQ 查询也可以通过捕获 OperationCanceledException
类型来实现。 但是,您可以在查询中调用 WithCancellation
,而不是使用与并行循环一起使用的 ParallelOptions
对象。
要理解如何取消 PLINQ 查询,让我们看一个示例:
- 通过向
ManagedThreadsExample
类添加名为ProcessNumsPlinq
的方法来启动此示例:
public static void ProcessNumsPlinq(object?
cancelToken)
{
int[] input = Enumerable.Range(1, 25000000).ToArray();
var token = cancelToken as CancellationToken?;
if (token == null) return;
int[]? result = null;
try
{
result = (from value in input.AsParallel()
.WithCancellation(token.Value)
where value % 7 == 0
orderby value
select value).ToArray();
}
catch (OperationCanceledException e)
{
Console.WriteLine($"Exception encountered: {e.Message}");
}
}
此方法创建一个包含 2500
万个整数的数组,并使用 PLINQ 查询来确定其中哪些可被 7 整除。 token.Value
将传递到查询中的 WithCancellation
操作。 当取消请求引发异常时,异常详细信息将写入控制台。
- 接下来,将名为
CancelPlinq
的方法添加到Program.cs
:
private static void CancelPlinq()
{
using CancellationTokenSource tokenSource = new();
Console.WriteLine("Press a key to start.");
Console.ReadKey();
Task.Run(() =>
{
Thread.Sleep(100);
Console.WriteLine("Requesting cancel.");
tokenSource.Cancel();
Console.WriteLine("Cancel requested.");
});
ManagedThreadsExample.ProcessNumsPlinq (tokenSource.Token);
}
这次,取消将在 100 毫秒后自动触发。
- 更新
Main
方法以调用CancelPlinq
,并运行应用程序:
图 11.3 – 在控制台应用程序中取消 PLINQ 操作
与前面的示例不同,没有要检查的查询输出。 您无法从 PLINQ 查询获取部分输出。 结果变量将为空。
发现线程取消的模式
有多种方法可以侦听来自线程或任务的取消请求。 到目前为止,我们已经看到了通过处理 OperationCanceledException 类型或检查 IsCancellationRequested 的值来管理这些请求的示例。 检查 IsCancellationRequested 的模式(通常在循环内)称为轮询。 首先,我们将看到这种模式的另一个例子。 我们要检查的第二种模式是通过注册回调方法来接收通知。 我们将在本节中介绍的最后一种模式是使用 ManualResetEvent 或 ManualResetEventSlim 监听带有等待句柄的取消请求。
通过轮询取消
在本节中,我们将创建另一个使用轮询取消后台任务的示例。 前面的轮询示例在 ThreadPool
线程的后台线程中运行。 此示例还将启动 ThreadPool
线程,但它将利用 Task.Run
来启动后台线程。 我们将创建并处理一百万个 System.Drawing.Point
对象,查找 Point.X
值小于 50 的对象。用户可以选择按 X 键取消处理:
- 首先创建一个名为
CancellationPatterns
的新 .NET 控制台应用程序项目 - 在项目中添加一个名为
PollingExample
的新类 - 将名为
GeneratePoints
的私有静态方法添加到PollingExample
。 这将生成我们想要的带有随机X
值的Point
对象的数量:
private static List<Point> GeneratePoints(int count)
{
var rand = new Random();
var points = new List<Point>();
for (int i = 0; i <= count; i++)
{
points.Add(new Point(rand.Next(1, count * 2), 100));
}
return points;
}
- 不要忘记添加一条using语句来使用Point类型:
using System.Drawing;
- 接下来,向
PollingExample
添加一个名为FindSmallXValues
的私有静态方法。该方法循环遍历点列表并输出 X 值小于 50 的点。每次循环时,它都会检查令牌是否取消并退出 循环发生时:
private static void FindSmallXValues(List<Point> points, CancellationToken token)
{
foreach (Point point in points)
{
if (point.X < 50)
{
Console.WriteLine($"Point with small X coordinate found. Value: {point.X}");
}
if (token.IsCancellationRequested)
{
break;
}
Thread.SpinWait(5000);
}
}
在循环末尾添加 Thread.SpinWait
语句,为用户提供一些时间来取消操作。
- 向
PollingExample
添加一个名为CancelWithPolling
的公共静态方法:
public static void CancelWithPolling()
{
using CancellationTokenSource tokenSource = new();
Task.Run(() => FindSmallXValues(GeneratePoints (1000000), tokenSource.Token), tokenSource .Token);
if (Console.ReadKey(true).KeyChar == 'x')
{
tokenSource.Cancel();
Console.WriteLine("Press a key to quit");
}
}
上述方法创建 CancellationTokenSource
对象并将其传递给 FindSmallXValues
和 Task.Run
。 如果您想取消任务,您可以调用 token.ThrowIfCancellationRequested
,而不是在 IsCancellationRequested
变为 true 时跳出循环。 这会在任务中引发异常。 然后,CancelWithPolling
方法需要在 Task.Run 调用周围有一个 try/catch
块。 无论如何,对所有多线程代码使用异常处理是最佳实践。 在这种情况下,您将有两个异常处理程序:一个用于处理OperationCanceledException
,第二个用于处理AggregateException
。
此外,CancelWithPolling
方法具有确定用户何时按 X 键取消操作的代码。
- 最后,打开
Program.cs
并添加一些代码来执行示例:
using CancellationPatterns;
Console.WriteLine("Hello, World! Press a key to start,then press 'x' to cancel.");
Console.ReadKey();
PollingExample.CancelWithPolling();
Console.ReadKey();
- 现在运行应用程序,并检查输出:
图 11.4 – 运行取消轮询示例
根据您在取消之前等待的时间,该过程可能会找到不同数量的点。
通过回调取消
.NET 中的某些代码支持注册回调方法以取消处理。 System.Net.WebClient
是支持通过回调取消的一类。 在此示例中,我们将使用 WebClient
开始下载文件。 三秒后下载将被取消。
1.打开CancellationPatterns
项目并添加一个名为CallbackExample
的新类
- 首先添加名为
GetDownloadFileName
的方法来构建下载文件的路径。 我们将其下载到执行程序集的同一文件夹中:
private static string GetDownloadFileName()
{
string path = System.Reflection.Assembly.GetAssembly(typeof(CallbackExample)).Location;
string folder = Path.GetDirectoryName(path);
return Path.Combine(folder, "audio.flac");
}
- 接下来,添加一个名为
DownloadAudioAsync
的异步方法。 该方法将处理文件下载和取消。 有多个异常处理程序可以捕获DownloadFileTaskAsync
方法可能引发的任何类型的异常。 反过来,它们都会抛出一个由父方法处理的OperationCanceledException
类型:
private static async Task DownloadAudioAsync
(CancellationToken token)
{
const string url = "https://archive.org/download/ lp_the-odyssey_homer-anthony-quayle/disc1/ lp_the-odyssey_homer-anthony-quayle _disc1side1.flac";
using WebClient webClient = new();
token.Register(webClient.CancelAsync);
try
{
await webClient.DownloadFileTaskAsync(url, GetDownloadFileName());
}
catch (WebException we)
{
if (we.Status == WebExceptionStatus.RequestCanceled)
throw new OperationCanceledException();
}
catch (AggregateException ae)
{
foreach (Exception ex in ae.InnerExceptions)
{
if (ex is WebException exWeb &&
exWeb.Status == WebExceptionStatus
.RequestCanceled)
throw new OperationCanceled
Exception();
}
}
catch (TaskCanceledException)
{
throw new OperationCanceledException();
}
}
4.为WebClient
类型添加using
语句:
using System.Net;
- 现在添加一个名为
CancelWithCallback
的公共异步方法。 此方法调用DownloadAudioAsync
,等待三秒钟,然后对CancellationTokenSource
对象调用Cancel
。 在try
块中等待任务意味着我们可以直接处理OperationCanceledException
类型。 如果您使用task.Wait
,则必须捕获AggregateException
并检查InnerException
对象之一是否是OperationCanceledException
类型:
public static async Task CancelWithCallback()
{
using CancellationTokenSource tokenSource = new();
Console.WriteLine("Starting download");
var task = DownloadAudioAsync(tokenSource.Token);
tokenSource.Token.WaitHandle.WaitOne (TimeSpan.FromSeconds(3));
tokenSource.Cancel();
try
{
await task;
}
catch (OperationCanceledException ex)
{
Console.WriteLine($"Download canceled. Exception: {ex.Message}");
}
}
在此步骤中,可能需要调整 tokenSource.Token.WaitHandle.WaitOne
调用中的秒数。 时间可能会根据计算机的下载速度和处理速度而有所不同。 如果您在控制台输出中没有看到“下载已取消”消息,请尝试调整该值。
6.最后,注释掉Program.cs
中现有的代码,添加以下代码来调用CallbackExample
类:
using CancellationPatterns;
await CallbackExample.CancelWithCallback();
Console.ReadKey();
- 现在运行应用程序,并检查输出:
图 11.5 – 使用
CancellationToken
和回调取消下载
您可以通过查看运行程序集的文件夹来验证下载是否已开始且未完成。 您应该会看到一个名为 audio.flac
的文件,文件大小为 0 KB。 您可以安全地删除此文件,因为如果您尝试再次下载它,可能会导致异常。
使用等待句柄取消
在本节中,我们将使用 ManualResetEventSlim
取消不会响应用户输入的后台任务。 该对象具有设置和重置事件来启动/恢复或暂停操作。 当操作尚未开始或已暂停时,调用 ManualResetEventSlim.Wait
将导致操作在该语句上暂停,直到另一个线程调用 Set 来开始或恢复处理。
此示例将迭代超过 100,000 个整数,并将每个偶数输出到控制台。 借助 ManualResetEventSlim
对象和 CancellationToken
,可以启动、暂停、恢复或取消此过程。 让我们在我们的项目中尝试这个例子:
- 首先将
WaitHandleExample
类添加到CancellationPatterns
项目中。 - 在新类中添加一个名为
resetEvent
的私有变量:
private static ManualResetEventSlim resetEvent = new(false);
- 将名为
ProcessNumbers
的私有静态方法添加到类中。 此方法迭代数字,并且仅在 resetEvent.Wait 允许其继续时才继续处理:
private static void ProcessNumbers(IEnumerable<int> numbers, CancellationToken token)
{
foreach (var number in numbers)
{
if (token.IsCancellationRequested)
{
Console.WriteLine("Cancel requested");
token.ThrowIfCancellationRequested();
}
try
{
resetEvent.Wait(token);
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation canceled.");
break;
}
if (number % 2 == 0)
Console.WriteLine($"Found even number: {number}"); Thread.Sleep(500);
}
}
- 接下来,将名为
CancelWithResetEvent
的公共静态异步方法添加到类中。 此方法创建要处理的数字列表,在Task.Run
调用中调用ProcessNumbers
,并使用while
循环侦听用户输入:
public static async Task CancelWithResetEvent()
{
using CancellationTokenSource tokenSource = new();
var numbers = Enumerable.Range(0, 100000);
_ = Task.Run(() => ProcessNumbers(numbers,
tokenSource.Token), tokenSource.Token);
Console.WriteLine("Use x to cancel, p to pause, or s to start or resume,");
Console.WriteLine("Use any other key to quit the program.");
bool running = true;
while (running)
{
char key = Console.ReadKey(true).KeyChar;
switch (key)
{
case 'x':
tokenSource.Cancel();
break;
case 'p':
resetEvent.Reset();
break;
case 's':
resetEvent.Set();
break;
default:
running = false;
break;
}
await Task.Delay(100);
}
}
- 最后,更新
Program.cs
以包含以下代码:
using CancellationPatterns;
await WaitHandleExample.CancelWithResetEvent();
Console.ReadKey();
- 运行程序进行测试。 按照控制台提示启动、暂停、恢复和取消该进程:
图 11.6 – 在控制台中测试
CancelWithResetEvent
方法
您应该在控制台输出中看到在取消操作之前已找到多个事件号。 完成的处理量可能会因计算机的处理器而异。
处理多个取消来源
后台任务可以利用 CancellationTokenSource
从尽可能多的源接收取消请求。 静态 CancellationTokenSource.CreateLinkedTokenSource
方法接受 CancellationToken
对象数组来创建一个新的 CancellationTokenSource
对象,如果任何源令牌收到取消请求,该对象将通知我们取消。
让我们看一个如何在 CancellationPatterns
项目中实现这一点的简单示例:
- 首先,打开
PollingExample
类。 我们将创建接受CancellationTokenSource
参数的CancelWithPolling
方法的重载。CancelWithPolling
的两个重载如下所示:
public static void CancelWithPolling()
{
using CancellationTokenSource tokenSource = new();
CancelWithPolling(tokenSource);
}
public static void CancelWithPolling
(CancellationTokenSource tokenSource)
{
Task.Run(() => FindSmallXValues(GeneratePoints(1000000), tokenSource.Token), tokenSource.Token);
if (Console.ReadKey(true).KeyChar == 'x')
{
tokenSource.Cancel();
Console.WriteLine("Press a key to quit");
}
}
- 接下来,添加一个名为
MultipleTokensExample
的新类。 - 在
MultipleTokensExample
类中创建名为CancelWithMultipleTokens
的方法。 该方法接受parentToken
作为参数,创建自己的tokenSource
,然后将它们组合成一个combinedSource
对象传递给CancelWithPolling方法:
public static void CancelWithMultipleTokens
(CancellationToken parentToken)
{
using CancellationTokenSource tokenSource = new();
using CancellationTokenSource combinedSource =
CancellationTokenSource.CreateLinked
TokenSource(parentToken, tokenSource .Token);
PollingExample.CancelWithPolling(combinedSource);
Thread.Sleep(1000);
tokenSource.Cancel();
}
我们正在调用 tokenSource.Cancel
,但如果在三个 CancellationTokenSource
对象中的任何一个上调用 Cancel,则 CancellWithPolling
中的处理将收到取消请求。
- 在
Program.cs
中添加一些代码来调用CancelWithMultipleTokens
:
using CancellationPatterns;
CancellationTokenSource tokenSource = new();
MultipleTokensExample.CancelWithMultipleTokens
(tokenSource.Token);
Console.ReadKey();
- 运行该程序,您应该看到类似于您在发现线程取消模式部分的通过轮询取消小节中看到的输出。
尝试更改用于调用取消的 CancellationTokenSource
对象。 无论取消请求的来源如何,输出都应保持不变。
如果您在任务中引发异常,后台任务也将结束。 这与结束后台处理具有类似的效果,但 TaskStatus
将显示为Faulted
(故障)而不是Canceled
(取消)。