本章将介绍如何编写并行和异步代码的单元测试用例。编写单元测试是大型项目的重要组成部分,是代码健壮可靠、易于维护的必然要求。不过本章书上的代码与 Unity 基本没什么关系,也不适用。但是单元测试在 Unity 中依然是必要的,这里我们基于 Unity 来讨论单元测试。
本教程学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
根据前面的学习我们了解到,直接使用 Task.Run 是不会抛出异常的。如果直接在每一个方法里都写上 Try-Catch 又过于繁琐,所以使用通用的 Task 安全写法更为方便。
之前章节已经介绍过,Try-Catch 是不能跨线程的,所以需要有一个线程等待、收集所有的错误并抛出,而这个等待又不能在主线程等待避免卡死:
public static void RunTaskSafe(Func function)
{
Task.Run(() =>
{
try
{
var task = Task.Run(function);
task.Wait();
}
catch (AggregateException ex)
{
Debug.LogError(ex.Message);
Debug.LogError(ex.InnerException);
}
});
}
如上示例代码,就能正确抛出错误,且不会卡死主线程:
通常做法,就是使用 Task.Wait ,然后就直接可以计时看时间了:
Stopwatch stopwatch = Stopwatch.StartNew();
stopwatch.Start();
var task = Task.Run(function);
task.Wait();
stopwatch.Stop();
Debug.Log($"【{function.Method.Name} 】耗时:{stopwatch.Elapsed.TotalMilliseconds}");
如上所示,就能正常工作打印出函数耗时。
但是显然这种打印的耗时十分不方便,而且其本身也有 GC 和性能开销,最多在关键节点调试时查看一下性能,实际上不能大规模使用。
最好的方案自然是在 Unity 的 Profiler 窗口中能查看到性能报告。
https://docs.unity3d.com/cn/2022.2/ScriptReference/Profiling.Profiler.BeginThreadProfiling.htmlhttps://docs.unity3d.com/cn/2022.2/ScriptReference/Profiling.Profiler.BeginThreadProfiling.html 实际上是可以办到的。在 Unity 2017.3.0 之后的版本中,Unity 添加了适用于多线程性能分析接口:Profiling.BeginThreadProfiling 。因此我们将启动 Task 的写法改造如下:
public static Task Run(Action action)
{
if (action == null)
return Task.CompletedTask;
var task = Task.Run(() =>
{
try
{
#if TASK_PROFILE
var method = action.Method;
if (method == null)
return;
Profiler.BeginThreadProfiling("GYTask", $"GYTask_{Task.CurrentId}");
Profiler.BeginSample(method.GetMethodName());
#endif
//在子线程运行全部任务,并等待完成以收集错误
var task = Task.Run(action);
task.Wait();
#if TASK_PROFILE
Profiler.EndSample();
Profiler.EndThreadProfiling();
#endif
}
catch (AggregateException ex)
{
UnityEngine.Debug.LogError(ex.Message);
UnityEngine.Debug.LogError(ex.InnerException);
}
});
return task;
}
之后我们执行一个耗时函数,就能在 Profiler 中看到效果了:
当然,我们在主线程中是看不到效果的,Timeline 中可以看到这部分代码并行了。同样,我们也可以在 Hierarchy 中选择对应线程查看运行情况:
我认为这个比 VS 提供的那些工具好多了 !
有了性能测试工具,我们可以对一些 TPL 的性能情况进行测试,这里我选了几个我比较关注的地方:
这个比较好测试,直接在主线程就能测出来:
可见,主要是 GC 的问题:
Task.Run 本身的构造会产生 GC (~ 0.8 KB)。
匿名函数的构造本身也会产生 GC (~ 0.12 KB)。
从耗时来看,单次创建一个 Task (以安全方式)耗时约为 0.1 ms (上图中 10 次启动耗时约 1 ms)。总结下来就是:启动一个 Task,耗时 0.1 ms,造成 1 KB GC。
创建匿名函数的 GC 和耗时可能可以通过某些手段消耗掉,但本章节不做探讨,后续研究如果有方案可以使用。
从上面的性能测试示例看得出来,Task.Delay 其实就是一个特殊函数占满了线程而已,没有 GC ,但是耗时看得出来是每帧吃满了的。
可能单纯地 Delay 对 CPU 运行压力不会有很大影响,但是在性能测试上能看得出来一个显著峰值。
信号灯和 Delay 一样,能在 Profiler 上展示出开销:
毕竟这个和 Delay 都可以理解为将子线程阻塞了。
当然这个测试其实和我的性能测试代码写法有关系,我是计算的等待任务完成的时间,因此只要这个方法还在运行,就一定会产生开销。
这一章介绍了 TPL 在 Unity 内的通用写法示例,解决了多线程的两大痛点:不能抛出错误、无法查看性能开销。个人觉得还是比较有用的,代码可以用作参考。
当然,要适配所有 API 还是需要各种后续补充。
本教程学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode