[C#] C# Tasks vs. Unity Jobs

61 篇文章 5 订阅
2 篇文章 0 订阅
文章讲述了在Unity中,不使用async/await时,C#JobSystem相较于TaskSystem的性能表现和任务管理方式。
摘要由CSDN通过智能技术生成

英文原文: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

这是我得到的结果:

SystemTime
Task150360
Job54110

在这里插入图片描述
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;
}

这是同一台机器上的性能结果:

SystemTime
Task208520
Job52930

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}
}

结果如下:

SystemTime
Task200330
Job44350

这与上面的减速基本相同,Job现在比任务快约 4.5 倍。如果您想发布您认为使用Task系统更公平或运行速度更快的任何测试代码,我很乐意在与本文中相同的机器上运行它。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值