英文原文:https://www.jacksondunstan.com/articles/4926
两周前,我们针对 Unity 的新 C# 作业系统测试了 async 和 wait 关键字以及 C# Task 系统的性能。这测试了 Task 系统中异步和等待的常见组合,但没有直接针对 Unity 的 C# 作业系统测试任务系统。今天我们将对此进行测试,并在此过程中了解如何在没有 async 和 wait 关键字的情况下使用Task系统。
正如我们上周看到的,async 和await 不需要使用Task 或Task<T>。我们可以创建自己的自定义对象来等待而不是任务。正如我们今天将看到的,反之亦然:Task不需要 async 或 await。
让我们看看我们可以创建的最简单的任务:
void MultithreadedIncrement(out int val)
{
// 运行一个递增 'val' 的任务
Task task = Task.Run(() => val++);
// 等待任务完成
task.Wait();
}
这里我们没有使用 async 或 wait。我们只需使用 Task.Run 创建并运行一个任务,然后阻止主线程的执行,直到通过调用 Wait 完成为止。
接下来,让我们创建一个创建“子”任务的任务:
void MultithreadedDoubleIncrement(out int val)
{
// 运行一个递增“val”的任务并运行一个递增“val”的任务
Task task = Task.Run(
() => {
val++;
Task.Run(() => val++);
});
// 等待任务完成
task.Wait();
}
但是,Task.Run 不允许“子”任务“附加”到“父”任务。这意味着子任务不会等待父任务完成。虽然对于这个简单的任务来说这不是问题,但更复杂的工作将要求我们以这种方式表达依赖关系。为此,我们需要使用 TaskFactory。这是实现这一目标的第一步:
void MultithreadedDoubleIncrement(out int val)
{
// 运行一个递增 "val "的任务,并运行一个递增 "val "的任务
Task task = Task.Factory.StartNew(
() => {
val++;
Task.Factory.StartNew(() => val++);
});
// 等待任务完成
task.Wait();
}
尽管如此,StartNew 不会自动将子任务附加到父任务。我们需要传递一个参数来明确请求:
void MultithreadedDoubleIncrement(out int val)
{
// 运行一个递增“val”的任务并运行一个递增“val”的任务
Task task = Task.Factory.StartNew(
() => {
val++;
Task.Factory.StartNew(
() => val++,
TaskCreationOptions.AttachedToParent);
});
// 等待任务完成
task.Wait();
}
不幸的是,Unity 的 Task.Factory 默认配置为不允许将子任务附加到父任务。为了解决这个问题,我们可以创建自己的 TaskFactory:
void MultithreadedDoubleIncrement(out int val)
{
// 创建一个允许将子任务附加到父任务的 TaskFactory
TaskFactory taskFactory = new TaskFactory(
TaskCreationOptions.AttachedToParent,
TaskContinuationOptions.ExecuteSynchronously);
// 运行一个递增“val”的任务并运行一个递增“val”的任务
Task task = taskFactory.StartNew(
() => {
val++;
taskFactory.StartNew(
() => val++,
TaskCreationOptions.AttachedToParent);
});
// 等待任务完成
task.Wait();
}
最后,此代码将运行父任务,然后在完成后运行子任务。
对于今天的性能测试,我们将运行 1000 个无操作任务链,其中每个任务都是前一个任务的子任务。为了在不硬编码 1000 个 lambda 的情况下做到这一点,我们可以使用一个简单的倒计时:
void RunTasks(TaskFactory taskFactory, int numRuns)
{
Action act = null;
act = () =>
{
numRuns--;
if (numRuns > 0)
{
taskFactory.StartNew(
act,
TaskCreationOptions.AttachedToParent);
}
};
Task task = taskFactory.StartNew(act);
task.Wait();
}
相比之下,我们将如何运行等效的 1000 个无操作作业:
struct TestJob : IJob
{
public void Execute()
{
}
}
void RunJobs(int numRuns)
{
TestJob job = new TestJob();
JobHandle jobHandle = job.Schedule();
for (int i = 1; i < numRuns; ++i)
{
jobHandle = job.Schedule(jobHandle);
}
JobHandle.ScheduleBatchedJobs();
jobHandle.Complete();
}
总而言之,我们最终得到了这个测试脚本,它运行四个同时运行的 1000 个任务链:
using System;
using UnityEngine;
using System.Threading.Tasks;
using System.Diagnostics;
using Unity.Jobs;
public class TestScript : MonoBehaviour
{
Task RunTasks(TaskFactory taskFactory, int numRuns)
{
Action act = null;
act = () =>
{
numRuns--;
if (numRuns > 0)
{
taskFactory.StartNew(
act,
TaskCreationOptions.AttachedToParent);
}
};
return taskFactory.StartNew(act);
}
struct TestJob : IJob
{
public void Execute()
{
}
}
JobHandle RunJobs(int numRuns)
{
TestJob job = new TestJob();
JobHandle jobHandle = job.Schedule();
for (int i = 1; i < numRuns; ++i)
{
jobHandle = job.Schedule(jobHandle);
}
return jobHandle;
}
void Awake()
{
const int numRuns = 1000;
Stopwatch sw = new Stopwatch();
TaskFactory taskFactory = new TaskFactory(
TaskCreationOptions.AttachedToParent,
TaskContinuationOptions.ExecuteSynchronously);
sw.Restart();
Task task1 = RunTasks(taskFactory, numRuns);
Task task2 = RunTasks(taskFactory, numRuns);
Task task3 = RunTasks(taskFactory, numRuns);
Task task4 = RunTasks(taskFactory, numRuns);
task1.Wait();
task2.Wait();
task3.Wait();
task4.Wait();
long taskTime = sw.ElapsedTicks;
sw.Restart();
JobHandle jobHandle1 = RunJobs(numRuns);
JobHandle jobHandle2 = RunJobs(numRuns);
JobHandle jobHandle3 = RunJobs(numRuns);
JobHandle jobHandle4 = RunJobs(numRuns);
JobHandle.ScheduleBatchedJobs();
jobHandle1.Complete();
jobHandle2.Complete();
jobHandle3.Complete();
jobHandle4.Complete();
long jobTime = sw.ElapsedTicks;
print("System,TimenTask," + taskTime + "nJob," + jobTime);
}
}
我在这个环境下进行了性能测试:
- 2.7 Ghz Intel Core i7-6820HQ
- macOS 10.13.6
- Unity 2018.2.9f1
- macOS Standalone
- .NET 4.x scripting runtime version and API compatibility level
- IL2CPP
- Non-development
- 640×480, Fastest, Windowed
这是我得到的结果:
System | Time |
---|---|
Task | 150360 |
Job | 54110 |
C# 的任务系统比 Unity 的作业系统花费的时间长 2.78 倍。这比使用 async 和 wait 时看到的约 4 倍的差异要好,但仍然慢了很多。由于Task和Job根本不执行任何工作,因此这纯粹是两个系统开销的度量。
与往常一样,最好对执行工作的特定项目进行概要分析,因为由于工作负载、依赖项、硬件、操作系统等原因,会有很多变化。
评论
在Task代码中,您在后台线程中有逻辑,而主线程中没有逻辑,并且 lambda 内有捕获的变量,您应该使用等效的静态函数
Task中唯一的逻辑是开始下一个Task。有一个捕获的变量,但倒计时似乎是必要的,以避免创建 1000 个 lambda。
任务中的依赖项的等效项可能是 Task.ContinueWith,而不是在任务中生成辅助任务(除非您要对 Unity 执行相同的操作)
ContinueWith 实际上表现更差。这是我使用的 RunTasks 的修改版本:
Task RunTasks(TaskFactory taskFactory, int numRuns)
{
Action parentDel = () => { };
Action<Task> childDel = p => { };
Task task = taskFactory.StartNew(parentDel);
for (int i = 1; i < numRuns; ++i)
{
task = task.ContinueWith(childDel);
}
return task;
}
这是同一台机器上的性能结果:
System | Time |
---|---|
Task | 208520 |
Job | 52930 |
Task花费的时间比文章中的Child版本长约 1⁄3。
至于从Job执行中调度Job,根据 Unity 的说法,这是不可能的。
您的 Unity 作业系统依赖性是否正确? Schedule() 不是有两个参数,第一个是“jobData”,第二个是依赖项吗?如果是这种情况,那么您可能不会让它们相互依赖,而只是将另一个作业作为“jobData”传递(我目前没有 Unity 设置的副本,我可以在其中确认这一点,但只是查看 API 文档)
Schedule 是一个扩展函数 (UnityEngine.Jobs.IJobExtensions.Schedule),因此第一个参数是它正在执行的作业。第二个参数是它所依赖的作业。因此,在 RunJobs 中,我让每个作业都依赖于前一个作业,然后返回并在链的最终作业上调用 Complete。
您的 Unity 批处理作业不会立即添加它们,您应该对这两个作业使用等效项,这可能涉及使用构造函数构建任务,然后在必要时调用 Run()
确实,我可以预先创建所有任务,然后调用 Start。不过,我似乎需要使用ContinueWith才能做到这一点,并且根据上述结果,这似乎会导致速度大幅下降。无论如何,这是一种尝试:
void RunTasks(int numRuns, out Task first, out Task last)
{
Action parentDel = () => { };
Action<Task> childDel = p => { };
Task task = new Task(
parentDel,
TaskCreationOptions.RunContinuationsAsynchronously);
first = task;
for (int i = 1; i < numRuns; ++i)
{
task = task.ContinueWith(childDel);
}
last = task;
}
void Awake()
{
const int numRuns = 1000;
Stopwatch sw = new Stopwatch();
sw.Restart();
Task first1;
Task last1;
RunTasks(numRuns, out first1, out last1);
Task first2;
Task last2;
RunTasks(numRuns, out first2, out last2);
Task first3;
Task last3;
RunTasks(numRuns, out first3, out last3);
Task first4;
Task last4;
RunTasks(numRuns, out first4, out last4);
first1.Start();
first2.Start();
first3.Start();
first4.Start();
last1.Wait();
last2.Wait();
last3.Wait();
last4.Wait();
long taskTime = sw.ElapsedTicks;
// {jobs removed for brevity}
}
结果如下:
System | Time |
---|---|
Task | 200330 |
Job | 44350 |
这与上面的减速基本相同,Job现在比任务快约 4.5 倍。如果您想发布您认为使用Task系统更公平或运行速度更快的任何测试代码,我很乐意在与本文中相同的机器上运行它。