本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
本章继续介绍任务并行性,因篇幅所限,本章为中篇。
4、取消任务
.NET Framework 提供了以下两个类来支持任务取消:
-
CancellationTokenSource :此类负责创建取消令牌,并将取消请求传递给通过源创建的所有令牌。
-
CancellationToken:侦听器使用该类来监视请求的当前状态。
其实和 2.8 的内容有点类似,接下来按照教程步骤走一遍:
CancellationTokenSource source = new CancellationTokenSource();
CancellationToken token = source.Token;
4.2、使用令牌创建任务
创建任务的API就有很多了,可用 2.1 ~ 2.3 的任意一种:
var task = new Task(TestFunction.LoopFuntion, token);
var task = Task.Factory.StartNew(TestFunction.LoopFuntion, token);
var task = Task.Run(TestFunction.LoopFuntion, token);
我设定的 LoopFuntion 是个无参函数,那么这个 token 有什么用呢?
我一开始想的是,我运行了一个 Task,并传入了 token,他在后台长时间运行。当我需要取消任务时,则调用 CancellationTokenSource 的 Cancel 方法,我创建的 Task 就取消了 。但实际并不是这样。取消令牌只能在任务开始前取消任务,而任务一旦开始运行,则无法取消任务!
其实和上一章说的 BackgroundWorker 类似,一旦开始执行就取消不了了,即便对 Task 进行 Dispose 也不行。需要程序员在函数中自行实现取消的方法,将方法修改如下:
var task = Task.Run(() => TestFunction.LoopFuntion(token), token);
public static async void LoopFuntion(CancellationToken token)
{
int index = 0;
for (int i = 0; i < 10; i++)
{
await Task.Delay(1000);
index++;
Debug.Log($"LoopFuncion ,Number : {index}");
if (token.IsCancellationRequested)
{
Debug.Log("手动取消!");
return;//手动取消;
}
}
}
4.3、注册请求取消的回调
CancellationToken 中可以注册一个回调函数,在取消时触发。同样在上述的LoopFunction中,可以如下写代码:
//注册一个事件,在 token 设置为取消时触发中断循环
bool IsCancelled = false;
token.Register(() =>
{
IsCancelled = true;
});
5、等待正在运行的任务
TPL 中提供了多种用于等待一个或多个任务的 API,具体如下所示:
5.1、Task.Wait
我们先写一个简单示例如下:
private void RunWithTaskWait()
{
Debug.Log("RunWithTaskWait Start !");
var task = Task.Run(TestFunction.DebugWithTaskDelay);
Debug.Log("子线程已经开始运行 !");
task.Wait();
Debug.Log("RunWithTaskWait End!");
}
我在Unity主线程中调用,猜猜结果会如何?可能你会认为最后一条Log(“RunWithTaskWait End!”)会在 task 完全执行完成之后才会打印,然而其实并不会:
这个效果和没有 Wait 的效果是一样的。
这个和书上说的就不一样了,无语……我在想作者写书的时候是不是根本没有运行过啊……总之我们先不纠结这个,先看看怎么实现 Task 的等待:
RunWithTaskWait 不变,然后 DebugWithTaskDelay 做如下修改:
public static async Task DebugWithTaskDelay()
{
Debug.Log("TaskDelay Start");
await Task.Delay(2000);//等待2s
Debug.Log("TaskDelay End");
}
发现区别没有?我们把返回值从 void 直接改成 Task,没有编译错误,而且运行结果也正确了。(我这样写,在等待的时候会阻塞 Unity 主线程,大家可以在另一个 Task 调用 DebugWithTaskDelay)
其实从C#的源代码上可以看到,Task.Run 有以下两个重载:
public static Task Run(Func<Task> function);//函数在 Task 调度中执行
public static Task Run(Action action);//函数当成普通任务执行
调用下面这个 Run 是不会有效果的,类似于普通函数,只有上面那个 Run 才是正确的。所以我们需要返回一个 Task 才能正确执行任务里的逻辑。而 Task 又比较特殊,和 void 一样不用 return !这个确实是我没有想到的,真有你的 C# !不得不说,我对 Task 的理解又更进一步 ~
5.2、Task.WaitAll
Task.WaitAll 用于等待多个任务,任务将作为数组的传递给方法,并且调用程序将被阻塞,直至所有任务都完成。Task.WaitAll 还支持超时和取消令牌。
在 TestFunction 中添加一个传参的等待方法:
/// <summary>
/// 传参的等待方法;
/// </summary>
/// <param name="millisecondsDelay">毫秒</param>
/// <returns></returns>
public static async Task DebugWithTaskWaitByParameter(int millisecondsDelay)
{
Debug.Log($"开始等待:{millisecondsDelay} !");
await Task.Delay(millisecondsDelay);
Debug.Log($"结束等待:{millisecondsDelay} !");
}
之后我们尝试执行 Task.WallAll,这里我为了避免卡死主线程,所以开了一个新线程来执行:
private void RunWithTaskWaitAll()
{
Thread thread = new Thread(() =>
{
Debug.Log($"开始 RunWithTaskWaitAll!");
var task1 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(1000));
var task2 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(2000));
var task3 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(3000));
Task.WaitAll(task1, task2, task3);
Debug.Log($"完成 RunWithTaskWaitAll!");
});
thread.Start();
}
效果如下:
和预期效果一样。
5.3、Task.WaitAny
Task.WaitAny 也可以等待多个任务,顾名思义,只要等待的任务中有任何一个执行完毕,调用线程就不会阻塞。 Task.Wait、Task.WaitAll 和 Task.WaitAny 都可以设置超时时间和取消令牌,这个就是纯 API 的调用,没有什么变化,就不再赘述了。
Task.WaitAny 的测试代码如下:
private void RunWithTaskWaitAny()
{
Thread thread = new Thread(() =>
{
Debug.Log($"开始 RunWithTaskWaitAny!");
var task1 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(1000));
var task2 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(2000));
var task3 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(3000));
Task.WaitAny(task1, task2, task3);
Debug.Log($"完成 RunWithTaskWaitAny!");
});
thread.Start();
}
结果如下:
5.4、Task.WhenAll
Task.WhenAll 是 Task.WatiAll 方法的非阻塞变体,区别在于会返回一个 Task ,代表所有指定任务的等待。单看概念可能不好理解,直接上代码:
private void Update()
{
if (RunningTask == null)
return;
switch (RunningTask.Status)
{
case TaskStatus.RanToCompletion:
Debug.Log("RunningTask 运行完成!");
RunningTask.Dispose();
RunningTask = null;
break;
}
}
private Task RunningTask;
private void RunWithTaskWhenAll()
{
Thread thread = new Thread(() =>
{
Debug.Log($"开始 RunWithTaskWhenAll!");
var task1 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(1000));
var task2 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(2000));
var task3 = Task.Run(() => TestFunction.DebugWithTaskWaitByParameter(3000));
RunningTask = Task.WhenAll(task1, task2, task3);
Debug.Log($"完成 RunWithTaskWhenAll!");
});
thread.Start();
}
这里开了一个 Update 来轮询 Task.WhenAll 返回的 Task 的结果,当 RunningTask 完成时进行一次打印。其余部分都是把 5.2 的 Task.WaitAll 测试用例抄过来的。
结果如下:
结果很明显,开始执行3个等待任务的时候,并没有阻塞调用线程。而等到3个任务都完成之后,RunningTask 才会标记为完成。
5.5、Task.WhenAny
这个很显然了,就是 WaitAny 的非阻塞变体。WaitAny 和 WhenAny 的区别就和 WaitAll 和 WhenAll 的区别一样,就是功能的排列组合,这里就不再赘述了。
6、处理任务异常
所有优秀的程序员都擅长高效地处理异常,这也是并行编程最重要的方面之一。任务并行库(TPL)提供了一种高效的设计来处理异常:任务中发生的任何未处理异常都将被延迟,然后传播到使用 Join 方法加入的线程,后者负责观察任务中的异常。
下面我们通过代码实例来学习:
6.1、处理来自单个任务的异常
首先,我们需要写一个会出异常的程序:
/// <summary>
/// 一个“可能”错误的程序;
/// 会抛出异常错误
/// </summary>
public async static Task ErrorFunction()
{
var random = new System.Random();
int div = random.Next(-2, 2);
float ret = 1;
for (int i = 0; i < 10; i++)
{
if (div == 0)
{
//这里我们只打印,但是并不中断运行;
Debug.LogError("开始除0了!");
}
//直接除法,抛出除0的移除
ret += i / div;
await Task.Yield();
div = random.Next(-2, 2);
}
Debug.Log($"ErrorFunction 居然成功完成了!结果为:{ret} | {div}");
}
之后我们直接运行这段程序,就按照最简单的 Task.Run 来运行。结果很有意思啊:
发现没有,已经出现除0的警告了,但是并没有跑错误出来,Unity 一点反应没有!这说明在子线程里的异常是不会直接抛给主线程的。
下面我们换一个写法:
private void RunWithErrorTask()
{
try
{
Debug.Log("RunWithErrorTask 开始!");
var task=Task.Run(TestFunction.ErrorFunction);
task.Wait();//不用 task.Wait() 则不会抛出异常
}
catch (System.Exception ex)
{
Debug.LogError(ex.Message);
Debug.LogError(ex.StackTrace);
Debug.LogError(ex.InnerException);
}
}
我们调用 task.Wait(),用 try catch 语句进行包裹,结果如下:
其实没啥好说的,就是因为 task.Wait 调回了主线程,所以能接收到异常。上面2张截图,其实就是为了说明 Exception 的 StackTrace 和 InnerException 的区别:可以看到 StackTrace 是没有行号的,但是 InnerException 是可以定位到具体的方法。
6.2、处理来自多个任务的异常
类似于 5.3 那种,子任务有多个的情况,异常处理也类似。把 catch 的类型换成 AggregateException 就能拿到所有的异常了。
这里就不贴代码了,只要一张贴图就能明白所有:
6.3、使用回调函数处理任务异常
这里指的就是 AggregateException 运行使用回调来处理异常:
.......
catch (System.AggregateException ex)
{
ex.Handle(exception =>
{
Debug.LogError(exception.InnerException);
return true;
});
}
这里就是 Handle 提供一个方法,返回 true 表示此异常已经正确处理,返回 false 则系统会再次抛出此异常。
这些都是通用的 C# 函数异常处理方法了,就不必要再多说了。
本文介绍了取消任务、等待任务、任务异常收集的基本写法、语法介绍等。这些都是平时进行 TPL 编程时常用的 API ,我只是简单介绍一下。后续熟练使用还是要多多练习。