【C#】并行编程实战:任务并行性(上)
在 .NET 的初始版本中,我们只能依赖线程(线程可以直接创建或者使用 ThreadPool 类创建)。ThreadPool 类提供了一个托管抽象层,但是开发人员仍然需要依靠 Thread 类来进行更好的控制。而 Thread 类维护困难,且不可托管,给内存和 CPU 带来沉重负担。因此,我们需要一种方案,既能充分利用 Thread 类的优点,又规避它的困难。这就是任务 (Task)。
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
在 .NET 的初始版本中,我们只能依赖线程(线程可以直接创建或者使用 ThreadPool 类创建)。ThreadPool 类提供了一个托管抽象层,但是开发人员仍然需要依靠 Thread 类来进行更好的控制。而 Thread 类维护困难,且不可托管,给内存和 CPU 带来沉重负担。
因此,我们需要一种方案,既能充分利用 Thread 类的优点,又规避它的困难。这就是任务 (Task)。
(另:本章篇幅较大,将分为上种下三部分发表。)
1、任务(Task)的特性
任务(Task)是 .NET 中的抽象,一个异步单位。从技术上讲,任务不过是对线程的包装,并且这个线程还是通过 ThreadPool 创建的。但是任务提供了诸如等待、取消和继续之类的特性,这些特性可以在任务完成后运行。
任务具有以下重要特性:
-
任务由 TaskScheduler (任务调度程序)执行,默认的调度仅在 ThreadPool 上运行。
-
可以从任务中返回值。
-
任务在完成时有通知(ThreadPool 和 Thread 都没有)。
-
可以使用 ContinueWith() 构造连续执行的任务。
-
可以通过调用 Task.Wait() 等待任务的执行,这将阻塞调用线程,直到任务完成为止。
-
与传统线程或 ThreadPool 相比,任务可以使代码的可读性更高。他们还为在 C# 5.0 中引入异步编程构造铺平了道路。
-
当一个任务从另一个任务启动时,可以建立它们之间的父子级关系。
-
可以将子任务的异常传播到父任务。
-
可以使用 CancellationToken 类取消任务。
2、创建和启动任务
我们可以通过多种方式使用任务并行库(TPL)创建和运行任务。
2.1、使用 Task
Task 类是作为 ThreadPool 线程异步执行工作的一种方式。它采用的是基于任务的异步模式( Task-Based Asynchronous Pattern,TAP)。非通用 Task 类不会返回结果,因此当需要从任务中返回值时,就需要使用通用版本的 Task<T> 。Task 需要调用 Start 方法来调度运行。
具体的 Task 调用代码如下:
/// <summary>
/// 测试方法,打印10次,等待10秒
/// </summary>
public static void DebugAndWait()
{
int length = 10;
for (int i = 0; i < length; i++)
{
Debug.Log($"执行第:{i + 1}/{length} 次打印!");
Thread.Sleep(1000);
}
}
//使用任务执行
private void RunByNewTask()
{
//创建任务
Task task = new Task(TestFunction.DebugAndWait);
task.Start();//不调用 Start 则不会执行
}
最终结果也没有什么意外:
2.2、使用 Task.Factory.StartNew
TaskFactory 类的 StartNew 方法也可以创建任务。这种方式创建的任务将安排在 ThreadPool 中执行,然后返回该任务的引用:
private void RunByTaskFactory()
{
//使用 Task.Factory 创建任务,不需要调用 Start
var task = Task.Factory.StartNew(TestFunction.DebugAndWait);
}
当然打印的结果和上述一样的。
2.3、使用 Task.Run
这个原理和 Task.Factory.StartNew 一样:
private void RunByTaskRun()
{
//使用 Task.Run 创建任务,不需要调用 Start
var task = Task.Run(TestFunction.DebugAndWait);
}
2.4、Task.Delay
使用 Task.Delay 也可以创建一个任务,但是这个任务有点特别。它可以在指定时间间隔后完成,可以使用
CacellationToken 类随时取消。与 Thread.Sleep 不同,Task.Delay 不需要利用 CPU 周期,且可以异步运行。
为了体现两者的不同,我们直接写个例子:
public static void DebugWithTaskDelay()
{
Debug.Log("TaskDelay Start");
Task.Delay(2000);//等待2s
Debug.Log("TaskDelay End");
}
然后我们直接在程序中直接同步调用此方法:
private void RunWithTaskDelay()
{
Debug.Log("开始测试 Task.Delay !");
TestFunction.DebugWithTaskDelay();
Debug.Log("结束测试 Task.Delay !");
}
结果如下:
可以看到4条打印按照顺序一瞬间被打印出来了,根本没有任何等待。而如果我们把上述的 Task.Delay 替换成 Thread.Sleep,结果会如何呢?
在运行此方法后,Unity直接卡住,然后2s后打印出4条信息。并且,显然线程等待生效了,但是是以阻塞主线程的方式生效的。
让我们换回 Task.Delay ,并使用 Task.Run 来运行这个方法,打印结果如下:
显然线程等待命令生效了,说明在子线程中的 Delay 是可以正常工作的。
2.5、Task.Yield
Task.Yiled 是创建 await 任务的另一种方法。使用此方法可以让方法强制变成异步的,并将控制权返回给操作系统。
怎么理解呢?我们这里需要一个很耗时的函数:
public static async void DebugWithTaskYield()
{
int length = 27;//这个方法不能执行很多次
string str = "";
for (int i = 0; i < length; i++)
{
//以下是耗时函数
str += "1,1";
var arr = str.Split(',');
foreach (var item in arr)
{
str += item;
}
await Task.Yield();
Debug.Log($"执行第:{i + 1}/{length} 次打印!");
}
}
这里我直接用简单的字符串拼接来实现了耗时函数。
我们在主线程调用 Task.Run 来执行,Debug 的结果如下:
可以看到随着字符串的增加,单次耗时越来越长。但是无论单次耗时时长有多少,都没有阻碍主线程!可能大家第一感觉和 Unity 的协程是一样的,但是 Unity 的协程使用是在主线程运行的,使用协程并不代表不会阻塞主线程。这里我们直接将这段代码用协程的逻辑实现:
public static IEnumerator DebugWithCoroutine()
{
int length = 27;//这个方法不能执行很多次
string str = "";
for (int i = 0; i < length; i++)
{
//以下是耗时函数
str += "1,1";
var arr = str.Split(',');
foreach (var item in arr)
{
str += item;
}
yield return null;
Debug.Log($"执行第:{i + 1}/{length} 次打印!");
}
}
逻辑上没有任何区别,就是把 await Task.Yield(); 改成了 yield return null 。当然,日志打印上看起来差不多,但是对主线程而言有本质区别。当运行到后面时,每次迭代都会造成主线程的卡顿。这一点在 Profiler 上看起来非常明显:
(可以看到协程调用的显然耗时)
2.6、Task.FromResult
FromResult<T> 是在 .NET Framework 4.5 中才被引入的方法,这在 Unity 2022.2.5 f1c1 使用的 .NET Standard 2.1 是支持的。
public static int FromResultTest()
{
int length = 100;
int result = 0;
for (int i = 0; i < length; i++)
result += Random.Range(0, 100);
Debug.Log($"FromResultTest 运算结果:{result} ");
return result;
}
private void RunWithFromResult()
{
Debug.Log("RunWithFromResult Start !");
Task<int> resultTask = Task.FromResult<int>(TestFunction.FromResultTest());
Debug.Log("RunWithFromResult End ! Result : " + resultTask.Result);
}
如上述代码所示 RunWithFromResult 的结果如下:
与一般的Task异步不同,这里是按照执行顺序依次打印的。如果这个函数是个耗时函数,会阻塞主线程吗?我把 2.5 里测试的耗时函数搬过来测试了一下(就不贴代码了):
显然已经阻塞主线程了。
也就是说这个 FromResult 将异步的方法拿到主线程中调度了(也可以理解为把子线程直接拿到父线程)。既然已经是 Unity 主线程了,那么 Task.Delay 就不会生效;而 Thread.Sleep 会生效,且会阻塞主线程。
与前面的几个创建Task任务的方法不同,这个Task.FromResult 是可以调用带参函数的(Task.Run 只能运行无参函数)。但即便如此,因为其会阻塞父线程,也不建议在 Unity 主线程中使用。
2.7、Task.FromException 和 Task.FromException<T>
这两个方法都可以抛出异步任务中的异常,在单元测试中很有用。
(这里暂时不会用到,就先不讲了,在后面学单元测试的时候再详细学习这两个)
2.8、Task.FromCanceled 和 Task.FromCanceled<T>
这个和 Task.FromException 的情况有点类似,都是看起来不知道有啥用其实很有用的方法。为了方便学习,这里还是展开讲讲。
首先看下面一段代码,这个也是 Task.FromCanceled 的示例代码:
CancellationTokenSource source = new CancellationTokenSource();//构建取消令牌源
source.Cancel();//设置为取消
//返回标记为取消的任务。
//注意!使用此方法要确保 CancellationTokenSource 已经调用过 Cancel 方法 ,否则会出错!
Task.FromCanceled(source.Token);
当我们把这个最后得到的Task状态(Task.Status)打印出来,其结果是便是 Created 。
肯定就有人会问了,这个有啥用啊?我是创建了一个取消的任务?那我执行这段代码的意义是什么呢?
单看这段代码,确实没什么意义,但是我们这里提出一个需求:
逻辑很简单,但是问题就出在最后,要维护一个Task。我们假设预计执行的任务A是某个长期的异步函数,外部需要检测他的状态和结果。那我们在输入偶数的时候,该返回什么呢?首先肯定不能返回一个空的Task,这个返回就和正常的Task一样的了,外部监控的状态要么是 WaitingToRun, 要么就是 RanToCompletion,要么就是 Running 。我根本无法知道我是执行了 任务A 还是没有执行 任务A。
这时候就发现 Task.FromCanceled 的作用了:
private void RunWithFromCanceled()
{
var val = commonPanel.GetInt32Parameter();
//这里测试输入双数就取消执行,单数就正常执行。
CancellationTokenSource source = new CancellationTokenSource();
if (val % 2 == 0)
source.Cancel();
var task = TestFunction.TestCanceledTask(source);
Debug.Log($"Task State 1: {task.Status}");
}
/// <summary>
/// 测试用于取消任务
/// </summary>
public static Task TestCanceledTask(CancellationTokenSource source)
{
if (source.IsCancellationRequested)
{
Debug.Log($"任务取消 !");
var token = source.Token;
return Task.FromCanceled(token);
}
else
{
Debug.Log($"任务执行 !");
return Task.Run(DebugWithTaskDelay);
}
}
当输入偶数时,就会返回一个已取消的任务,而奇数则会正常执行。
当我们对任务进行了封装,内部的判断逻辑会比较复杂,而外部也只需要知道任务执行情况而不需要知道其内部逻辑。此时使用 Task.FromCanceled 和 Task.FromException 就能返回给外部一个通用的“异常”Task。
3、从完成的任务中获取结果
任务并行库(TPL)中提供的API有如下几个:
/// <summary>
/// 获取任务并行结果
/// </summary>
private void GetTaskResult()
{
int inputParam = commonPanel.GetInt32Parameter();
Debug.Log($"get task result start ! paramter : {inputParam}");
//方法1 :new Task
var task_1 = new Task<int>(()=>TestFunction.FromResultTest(inputParam));
task_1.Start();
Debug.Log($"task_1 result : {task_1.Result}");
//方法2:Task.Factory
var task_2 = Task.Factory.StartNew<int>(()=> TestFunction.FromResultTest(inputParam));
Debug.Log($"task_2 result : {task_2.Result}");
//方法3:
var task_3 = Task.Run<int>(()=>TestFunction.FromResultTest(inputParam));
Debug.Log($"task_3 result : {task_3.Result}");
//方法4:
var task_4 = Task.FromResult<int>(TestFunction.FromResultTest(inputParam));
Debug.Log($"task_4 result : {task_4.Result}");
}
这次测试终于出现了一个熟悉的错误:
Random.Range 只能在Unity主线程使用。
这个以前就知道 UnityEngine 的类不能在子线程使用,这里遇到了。但是没关系,我们直接修改这个方法即可,用System的Random就行了。
但是这能说明我们的程序确实在子线程运行了,但是实际上这4个方法都是会阻塞主线程的!
所有的运算流程都是和 2.6 的 FromResult 一样,已经将子线程调回主线程使用了。显然这几种方法都是提供一种同步的结果获取,而真正做到异步计算还不能直接这么使用。
限于篇幅,任务并行性(上)到此为止。
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)