在.Net和C#中运行异步代码相当简单,因为我们有时候需要取消正在进行的异步操作,通过本文,可以掌握 通过CancellationToken取消任务(包括non-cancellable任务)。
早期
早期.Net 使用 BackgroundWorker 完成异步长时间运行操作。
可以使用CacnelAsync方法设置 CancellationPending = true
private void BackgroundLongRunningTask(object sender, DoWorkEventArgs e)
{
BackgroundWorker worker = (BackgroundWorker)sender;
for (int i = 1; i <= 10000; i++)
{
if (worker.CancellationPending == true)
{
e.Cancel = true;
break;
}
// Do something
}
}
已经不再推荐这种方式来完成异步和长时间运行的操作,但是大部分概念在现在依旧可以使用。
Task横空出世
Task代表一个异步操作,该类表示一个异步不返回值的操作, 泛型版本Task<TResult>表示异步有返回值的操作。
可使用async/await 语法糖代码去完成异步操作。
以下创建一个简单的长时间运行的操作:
/// <summary>
/// Compute a value for a long time.
/// </summary>
/// <returns>The value computed.</returns>
/// <param name="loop">Number of iterations to do.</param>
private static Task<decimal> LongRunningOperation(int loop)
{
// Start a task and return it
return Task.Run(() =>
{
decimal result = 0;
// Loop for a defined number of iterations
for (int i = 0; i < loop; i++)
{
// Do something that takes times like a Thread.Sleep in .NET Core 2.
Thread.Sleep(10);
result += i;
}
return result;
});
}
// 这里我们使用Thread.Sleep 模仿长时间运行的操作
简单异步调用代码:
public static async Task ExecuteTaskAsync()
{
Console.WriteLine(nameof(ExecuteTaskAsync));
Console.WriteLine("Result {0}", await LongRunningOperation(100));
Console.WriteLine("Press enter to continue");
Console.ReadLine();
}
敲黑板:C#取消异步操作分为
① 让代码可取消(Cancellable)
因为一些原因,长时间运行的操作花费了 冗长的时间(需要取消,避免占用资源);或者不愿意再等待执行结果了
我们会取消异步操作。
为完成目的需要在 长时间运行的异步任务中传入CancellationToken:
/// <summary>
/// Compute a value for a long time.
/// </summary>
/// <returns>The value computed.</returns>
/// <param name="loop">Number of iterations to do.</param>
/// <param name="cancellationToken">The cancellation token.</param>
private static Task<decimal> LongRunningCancellableOperation(int loop, CancellationToken cancellationToken)
{
Task<decimal> task = null;
// Start a task and return it
task = Task.Run(() =>
{
decimal result = 0;
// Loop for a defined number of iterations
for (int i = 0; i < loop; i++)
{
// Check if a cancellation is requested, if yes,
// throw a TaskCanceledException.
if (cancellationToken.IsCancellationRequested)
throw new TaskCanceledException(task);
// Do something that takes times like a Thread.Sleep in .NET Core 2.
Thread.Sleep(10);
result += i;
}
return result;
});
return task;
}
在长时间运行的操作中监测 IsCancellationRequested方法 (当前是否发生取消命令),这里我倾向去包装一个TaskCanceledException异常类(给上层方法调用者更多处理的可能性);当然可以调用ThrowIfCancellationRequested方法抛出OperationCanceledException异常。
② 触发取消命令
CancellationToken结构体相当于打入在异步操作内部的楔子,随时等候后方发来的取消命令。
操纵以上CancellationToken状态的对象是 CancellationTokenSource,这个对象是取消操作的命令发布者。
默认的构造函数就支持了 超时取消:
// 以下代码 利用 CancellationSource默认构造函数 完成超时取消
public static async Task ExecuteTaskWithTimeoutAsync(TimeSpan timeSpan)
{
Console.WriteLine(nameof(ExecuteTaskWithTimeoutAsync));
using (var cancellationTokenSource = new CancellationTokenSource(timeSpan))
{
try
{
var result = await LongRunningCancellableOperation(500, cancellationTokenSource.Token);
Console.WriteLine("Result {0}", result);
}
catch (TaskCanceledException)
{
Console.WriteLine("Task was cancelled");
}
}
Console.WriteLine("Press enter to continue");
Console.ReadLine();
}
------------------------------------------------------------------------------------------------------------
附①:高阶操作,完成手动取消:
自然我们关注到 CancellationSource 的几个方法, 要想在异步操作的时候 手动取消操作,需要建立另外的线程 等待手动取消操作的指令。
public static async Task ExecuteManuallyCancellableTaskAsync()
{
Console.WriteLine(nameof(ExecuteManuallyCancellableTaskAsync));
using (var cancellationTokenSource = new CancellationTokenSource())
{
// Creating a task to listen to keyboard key press
var keyBoardTask = Task.Run(() =>
{
Console.WriteLine("Press enter to cancel");
Console.ReadKey();
// Cancel the task
cancellationTokenSource.Cancel();
});
try
{
var longRunningTask = LongRunningCancellableOperation(500, cancellationTokenSource.Token);
var result = await longRunningTask;
Console.WriteLine("Result {0}", result);
Console.WriteLine("Press enter to continue");
}
catch (TaskCanceledException)
{
Console.WriteLine("Task was cancelled");
}
await keyBoardTask;
}
}
// 以上是一个控制台程序,异步接收控制台输入,发出取消命令。
附②:高阶操作,取消 non-Cancellable任务 :
有时候,异步操作代码并不提供 对 Cancellation的支持,也就是以上长时间运行的异步操作
LongRunningCancellableOperation(int loop, CancellationToken cancellationToken) 并不提供参数2的传入,相当于不允许 打入楔子。
这时我们怎样取消 这样的non-Cancellable 任务?
可考虑利用 Task.WhenAny( params tasks) 操作曲线取消:
-
利用TaskCompletionSource 注册异步可取消任务
-
等待待non-cancellable 操作和以上建立的 异步取消操作
private static async Task<decimal> LongRunningOperationWithCancellationTokenAsync(int loop, CancellationToken cancellationToken)
{
// We create a TaskCompletionSource of decimal
var taskCompletionSource = new TaskCompletionSource<decimal>();
// Registering a lambda into the cancellationToken
cancellationToken.Register(() =>
{
// We received a cancellation message, cancel the TaskCompletionSource.Task
taskCompletionSource.TrySetCanceled();
});
var task = LongRunningOperation(loop);
// Wait for the first task to finish among the two
var completedTask = await Task.WhenAny(task, taskCompletionSource.Task);
return await completedTask;
}
像上面代码一样执行取消命令 :
public static async Task CancelANonCancellableTaskAsync()
{
Console.WriteLine(nameof(CancelANonCancellableTaskAsync));
using (var cancellationTokenSource = new CancellationTokenSource())
{
// Listening to key press to cancel
var keyBoardTask = Task.Run(() =>
{
Console.WriteLine("Press enter to cancel");
Console.ReadKey();
// Sending the cancellation message
cancellationTokenSource.Cancel();
});
try
{
// Running the long running task
var longRunningTask = LongRunningOperationWithCancellationTokenAsync(100, cancellationTokenSource.Token);
var result = await longRunningTask;
Console.WriteLine("Result {0}", result);
Console.WriteLine("Press enter to continue");
}
catch (TaskCanceledException)
{
Console.WriteLine("Task was cancelled");
}
await keyBoardTask;
}
}
总结:
大多数情况下,我们不需要编写自定义可取消任务,因为我们只需要使用现有API。但要知道它是如何在幕后工作总是好的。
.Net中异步任务的取消和监控
相关类型:
CancellationTokenSource 主要用来创建或取消令牌
CancellationToken 监听令牌状态,注册令牌取消事件
OperationCanceledException 令牌被取消时抛出的异常,可以由监听者自主决定是否抛出异常
CancellationTokenSource
创建令牌:
CancellationTokenSource cts = new CancellationTokenSource()
CancellationToken token=cts.Token;
取消释放令牌:
cts.Cancel();
CancellationToken
监听令牌取消事件:
token.Register(() => Console.WriteLine("令牌被取消"));
判断令牌是否取消
//返回一个bool,如果令牌被取消为true
token.IsCancellationRequested
//如果token被取消则抛出异常,内部实现其实就是判断IsCancellationRequested
token.ThrowIfCancellationRequested()=>{
if(token.IsCancellationRequested){
throw new OperationCanceledException();
}
}
代码示例
下面模拟一个文件下载的任务,在未下载完成后下载任务被取消
public void Run()
{
CancellationTokenSource cts = new CancellationTokenSource();
Task.Run(() =>
{
//等待两秒后取消,模拟的是用户主动取消下载任务
Thread.Sleep(2000);
cts.Cancel();
});
try
{
var size = DownloadFile(cts.Token);
Console.WriteLine("文件大小:" + size);
}
catch (OperationCanceledException)
{
Console.WriteLine("下载失败");
}finally{
cts.Dispose();
}
Thread.Sleep(2000);
}
/// <summary>
/// 模拟下载文件,下载文件需要五秒
/// </summary>
/// <returns></returns>
public int DownloadFile(CancellationToken token)
{
token.Register(() =>
{
System.Console.WriteLine("监听到取消事件");
});
Console.WriteLine("开始下载文件");
for (int i = 0; i < 5; i++)
{
token.ThrowIfCancellationRequested();
Console.WriteLine(i.ToString());
Thread.Sleep(1000);
}
Console.WriteLine("文件下载完成");
return 100;
}
输出结果:
开始下载文件
0
1
监听到取消事件
下载失败
思考
为什么要将CancellationToken和CancellationTokenSource分为两个类呢,直接一个CancellationToken又可以取消又可以判断状态注册啥的不是更好,更方便?
其实每种类的设计和实现都可以有很多不同的策略,CTS和CT从这个两个类提供的为数不多的公开方法中就可以看出,CTS用来控制Token的生成和取消等生命周期状态,CT只能用来监听和判断,无法对Token的状态进行改变。
所以这种设计的目的就是关注点分离。限制了CT的功能,避免Token在传递过程中被不可控的因素取消造成混乱。
关联令牌
继续拿上面的示例来说,示例中实现了从外部控制文件下载功能的终止。
如果要给文件下载功能加一个超时时间的限制,此时可以增加一个控制超时时间的token,将外部传来的token和内部token 关联起来变为一个token
只需要将DownloadFile()函数做如下改造即可
public int DownloadFile(CancellationToken externalToken)
{
//通过构造函数设置TokenSource一秒之后调用Cancel()函数
var timeOutToken = new CancellationTokenSource(new TimeSpan(0, 0, 1)).Token;
using (var linkToken = CancellationTokenSource.CreateLinkedTokenSource(externalToken, timeOutToken))
{
Console.WriteLine("开始下载文件");
for (int i = 0; i < 5; i++)
{
linkToken.Token.ThrowIfCancellationRequested();
Console.WriteLine(i.ToString());
Thread.Sleep(1000);
}
Console.WriteLine("文件下载完成");
return 100;
}
}
此时不论是externalToken取消,或是timeOutToken取消,都会触发linkToken的取消事件
CancellationChangeToken
CancellationChangeToken主要用来监测目标变化,需配合ChangeToken使用。从功能场景来说,其实ChangeToken的功能和事件似乎差不多,当监控的目标发生了变化,监听者去做一系列的事情。
但是事件的话,监听者需要知道目标的存在,就是如果A要注册B的事件,A是要依赖B的。
CancellationChangeToken是基于CancellationToken来实现的,可以做到依赖于Token而不直接依赖被监听的类
创建CancellationChangeToken:
new CancellationChangeToken(new CancellationTokenSource().Token)
监听Token变动
new CancellationChangeToken(cts.Token).RegisterChangeCallback(obj => Console.WriteLine("token 变动"), null);
CancellationChangeToken只是把CancellationToken包装了一层。RegisterChangeCallback最终也是监听的CancellationToken的IsCancellationRequested状态。
所以就有个问题,代码写到这里,并不能实现每次内部变动都触发回调事件。
因为CT只会Cancel一次,对应的监听也会执行一次。无法实现多次监听
为了实现变化的持续监听,需要做两个操作
-
让Token在Cancel之后重新初始化
-
每次Cancel回调之后重新监听新的Token
先上代码,下面的代码实现了每次时间变动都会通知展示面板刷新时间的显示
public void Run()
{
var bjDate = new BeijingDate();
DisplayDate(bjDate.GetChangeToken, bjDate.GetDate);
Thread.Sleep(50000);
}
public void DisplayDate(Func<IChangeToken> getChangeToken, Func<DateTime> getDate)
{
ChangeToken.OnChange(getChangeToken, () => Console.WriteLine("当前时间:" + getDate()));
}
public class BeijingDate
{
private CancellationTokenSource cts;
private DateTime date;
public BeijingDate()
{
cts = new CancellationTokenSource();
var timer = new Timer(TimeChange, null, 0, 1000);
}
private void TimeChange(object state)
{
date = DateTime.Now;
var old = cts;
cts = new CancellationTokenSource();
old.Cancel();
}
public DateTime GetDate() => date;
public CancellationChangeToken GetChangeToken()
{
return new CancellationChangeToken(cts.Token);
}
}
在TimeChange()中修改了时间,重置了Token并将旧的Token取消
在DisplayDate中用ChangeToken.OnChange获取对应的Token并监听
实现了DisplayData函数和BeijingDate这个类的解耦
ChangeToken.OnChange 这个函数接收两个参数,一个是获取Token的委托,一个是Token取消事件的响应委托。
每次在处理完Token的取消事件后,他会重新调用第一个委托获取Token,而此时我们已经生成了新的Token,最终实现了持续监控