任务系统(Tasks System) 是一种作业管理器,提供了异步执行Gameplay代码的能力。它支持构建和运行依赖任务的定向无环图。它是对 虚幻引擎 中使用的作业管理器 TaskGraph 的改进。任务系统和TaskGraph使用相同的后端(调度程序和工作线程)。
主要功能包括:
-
启动任务,方法是提供需要异步执行的可调用对象。
-
等待任务完成和/或检索任务执行结果。
-
指定任务先决条件,即开始执行该任务之前必须完成的其他任务。
-
从任务内部启动嵌套任务。父任务只有其所有嵌套任务都完成后才算完成。
-
构建任务链,也称为管线。
-
使用任务事件执行任务之间的同步和信令。
为简便起见,所有代码示例都假定使用名称空间 UE::Tasks
。
启动
要 启动 任务,你需要提供任务的调试名称和可调用的对象"任务主体"。例如:
Launch(
UE_SOURCE_LOCATION,
[]{ UE_LOG(LogTemp, Log, TEXT("Hello Tasks!")); }
);
上述代码启动将异步执行给定函数的任务。第一个参数是任务的调试名称(最好唯一)。其目的是帮助调试任务并辅助查找启动该任务的代码。
UE_SOURCE_LOCATION
是生成字符串的宏,字符串格式为源文件的文件名加上使用它的行。此示例显示了一个"即发即弃"任务,这意味着你在任务启动之后不用管它发生了什么,因为它最终会执行。
你常常需要等待任务完成或检索其执行结果。这可以使用Launch调用返回的Task对象来执行:
FTask Task = Launch(UE_SOURCE_LOCATION, []{});
任务执行可以返回结果。`FTask` 是 `TTask<void>` 的别名,后者是泛型 `TTask<ResultType>` 的特殊版本。`ResultType` 应当匹配任务主体返回的结果的类型:
TTask<bool> Task = Launch(UE_SOURCE_LOCATION, []{ return true; });
任务会异步执行,并有可能与启动线程并发执行,所以其执行顺序是未定义的。不过我们仍可以指定任务优先级来影响任务执行顺序。任务优先级包括"high"、"normal"(默认值)、"background high"、"background normal"和"background low"。 优先级更高的任务先执行,优先级更低的任务后执行。
Launch(UE_SOURCE_LOCATION, []{}, ETaskPriority::High);
我们通常使用lambda函数作为任务主体,不过你也可以使用任意可调用对象。
void Func() {}
Launch(UE_SOURCE_LOCATION, &Func);
struct FFunctor
{
void operator()() {}
};
Launch(UE_SOURCE_LOCATION, FFunctor{});
技术细节
FTask
是实际任务的句柄,类似于一种智能指针。它将使用引用计数来管理其生命周期。启动任务即开始其生命周期,并分配所需的资源。要释放持有的引用,你可以使用以下代码"重置"任务句柄:
FTask Task = Launch(UE_SOURCE_LOCATION, []{});
Task = {}; // 释放引用
释放任务句柄不会立即导致任务销毁。系统持有其自己的引用,用于执行任务。此引用将在任务完成后释放。
请参阅启动,了解更多信息。
等待任务完成
你可能常常需要知道任务是否已完成,等待其完成,或检索其执行结果。
任务命令 | 实现方法 |
---|---|
检查任务是否已完成 | bool bCompleted = Task.IsCompleted(); |
等待任务完成 | ` Task.Wait();` |
等待任务完成且有超时 | bool bTaskCompleted = Task.Wait(FTimespan::FromMillisecond(100)); |
等待所有任务完成 | 示例:
|
检索任务执行结果。调用将被阻止,直至任务完成且其结果准备好。 | 示例:
|
应尽可能避免等待,因为这会限制可扩展性。我们推荐你改为定义任务之间的依赖性并设计基于任务的异步API,以构建任务图表。请参阅Wait 和 GetResult()
,了解更多信息。
忙等待
等待任务完成的一个问题是,它会阻止当前线程,因此不能发挥效用。一种备用方法是使用 忙等待。使用忙等待时,线程会尝试执行其他任务,直至所等待的任务完成。
虽然忙等待在受控环境下可能很有用,但它也有自身的一些问题,应谨慎使用。主要问题是,你无法控制在忙等待期间调度程序会挑选哪些任务来执行。
这可能导致死锁(即一个线程处于忙等待状态并锁定了不可重入互斥锁,而调度程序挑选的任务却尝试锁定相同的互斥锁),或者导致性能变差,即在关键路径上忙等待运行时间较短的任务时,调度程序挑选了运行时间很长的任务。
请参阅 BusyWait()
,了解更多信息。
先决条件
任务之间可以产生依赖性。如果任务A只能在任务B完成后执行,则任务B称为任务A的 先决条件,任务A称为任务B的 后继 。这样就可以构建任务的定向无环图。
使用任务依赖性的主要优势是不会阻止工作线程。此外,利用依赖性,你可以强制实施任务执行顺序,而这在正常情况下是不能保证的。下面的代码构建了简单的先决条件到后继依赖性:
FTask Prerequisite = Launch(UE_SOURCE_LOCATION, []{});
FTask Subsequent = Launch(UE_SOURCE_LOCATION, []{}, Prerequisite);
在下面的代码示例中,Prerequisites()
是辅助函数:

FTask A = Launch(UE_SOURCE_LOCATION, []{});
FTask B = Launch(UE_SOURCE_LOCATION, []{}, A);
FTask C = Launch(UE_SOURCE_LOCATION, []{}, A);
FTask D = Launch(UE_SOURCE_LOCATION, []{}, Prerequisites(B, C));
请参阅启动,了解更多信息。
嵌套任务
嵌套任务 类似于先决条件,但先决条件是执行依赖性,而嵌套任务则是完成依赖性。假设任务A在执行期间启动了任务B,任务A仅当其自己的执行完成且任务B完成之后才算完成。当系统公开一个基于任务的异步接口时,这是一种常见的模式,但任务B是实现的一部分,因此泄露此任务是不利的。
最简单的实现如下所示:
FTask TaskA = Launch(UE_SOURCE_LOCATION,
[]
{
FTask TaskB = Launch(UE_SOURCE_LOCATION, [] {});
TaskB.Wait();
}
);
这是完成任务的基本实现,但效率低下,因为执行任务A的工作线程被阻止,需要等待任务B完成,因此它不会用于执行其他任务。
解决方案是使用嵌套任务。在我们的示例中,任务A是父任务,任务B是嵌套任务,因为后者的执行应嵌套在任务A的执行中:
FTask TaskA = Launch(UE_SOURCE_LOCATION,
[]
{
FTask TaskB = Launch(UE_SOURCE_LOCATION, [] {});
AddNested(TaskB);
}
);
TaskA.Wait(); // 仅当 `TaskA` 和 `TaskB` 都完成时才返回
AddNested会将给定任务作为嵌套任务添加到当前线程正在执行的任务。它将表明是否未从任务内部调用。
请参阅AddNested()
,了解更多信息。
管线
管线 是一个接一个(非并发)执行的任务链。设想一下,从多个线程访问共享资源。同步访问的经典方法是,通过锁定互斥锁来"锁定"资源。这种方法常常会因为线程被阻止而带来重大的性能损失,尤其是在争用资源的情况下。
对于复杂的资源,有利的做法是提供异步接口,允许发起异步操作来处理资源,并能够检查操作是否完成(或订阅完成通知)。
实现异步接口常常并不简单。管线旨在简化这项工作。目的是针对每个共享资源使用一个管线。对共享资源的所有访问都在管线启动的任务内部执行。例如:
class FThreadSafeResource
{
public:
TTask<bool> Access()
{
return Pipe.Launch(TEXT("Access()"), [this] { return ThreadUnsafeResource.Access(); });
}
FTask Mutate()
{
return Pipe.Launch(TEXT("Mutate()"), [this] { ThreadUnsafeResource.Mutate(); });
}
private:
FPipe Pipe{ TEXT("FThreadSafeResource pipe")};
FThreadUnsafeResource ThreadUnsafeResource;
};
FThreadSafeResource ThreadSafeResource;
// 从多个线程并发访问相同的实例
bool bRes = ThreadSafeResource.Access().GetResult();
FTask Task = ThreadSafeResource.Mutate();
FThreadSafeResource
提供了基于任务的公共线程安全型异步接口。它封装了线程不安全的资源。实现很简单,由样板代码组成。对线程不安全资源的访问在管线化任务内部发生。
因为这些管线化任务是按顺序执行的,所以不需要进行额外的同步。管线是轻量级对象,因此它们不存储其任务的集合。即使使用成千上万的管线,性能也可能不会显著下降。
要将任务管线化,它需要由管线启动:
FPipe Pipe{ UE_SOURCE_LOCATION };
FTask TaskA = Pipe.Launch(UE_SOURCE_LOCATION, []{});
FTask TaskB = Pipe.Launch(UE_SOURCE_LOCATION, []{});
TaskA和TaskB不会并发执行,所以不需要彼此同步来访问共享资源。虽然大部分时间执行顺序可预测,但并不能保证任务的启动顺序。
管线化任务可提供其他任务具有的功能,例如,可以产生依赖性以及遵循行为顺序。 首先会解决依赖性,然后将任务管线化。这意味着,带有待处理依赖性的任务不会阻止管线执行,并且依赖性可以改变管线化任务的执行顺序。
你可以将管线视为 绿色线程。这些绿色线程由工作线程执行,并可以"跳过线程"。例如,在之前的示例中,TaskA和TaskB可以由不同的线程执行。
-
Pipe API是线程安全的。
-
Pipe对象不可复制,且不可移动。
-
一个任务无法在多个管线中启动。
请参阅FPipe,了解更多信息。
任务事件
任务事件是一种特殊的任务类型,没有任务主体,不能做执行工作。重大区别在于,任务事件最初不会启动(发信令),而需要显式触发。任务事件很适合用作同步和信令图元。它们类似于一次性FEvent。它们可以用作其他任务的先决条件或后继。
下表提供了一些示例来解释任务事件的作用。
任务事件示例 | 实现方法 |
---|---|
启动任务,但暂停其执行,直至显式释放。 | 示例:
该事件用作了任务的先决条件。最初,事件处于无信号状态,因此它尚未完成,这意味着任务有待处理依赖性,只有解决该依赖性之后才会调度并执行该任务。 任务事件通过触发来切换为有信号状态。 |
将任务事件用作连接器任务。 | 示例:
Joiner依赖于TaskA和TaskB。等待它意味着等待其所有依赖性而不是逐个等待它们。
|
中途停止任务执行并等待某个事件发生。 | 示例:
一般来说,出于性能和可扩展性原因,在任务中途等待并不是最佳做法。如果你遇到这种情况,考虑尽可能使用先决条件重新设计。 |
执行任务,但不自动将其标记为完成。相反,它会在方便的时候显式将其"完成" | 示例:
|
另请参阅:FTaskEvent
调试和分析
每个任务、任务事件或管线都有用户提供的调试名称。这样就可以在调试器中运行时期间识别它们。Visual Studio原生查看器可用于检查其内部状态。
Unreal Insights 添加了任务追踪通道,支持可视化任务及其生命周期事件。例如在任务启动、调度、执行和完成时。
请参阅Unreal Insights文档,了解详情。
调试和分析是比较活跃的开发领域,未来将进一步改进。