作为在Unity里折腾了好一阵子GameFramework(以下简称GF)的开发者,我第一次打开源码看Task Pool时,其实没太当回事——不就是个任务队列嘛。直到我真的去追踪一条资源加载请求从发起到完成的完整链路,才发现任务池这套设计比我预想中要讲究得多。它管的不只是"排队",而是把任务的生成、调度、执行、回收整个生命周期全部串了起来,再配合引用池把运行时GC开销压到最低。这篇继续我的GameFramework学习系列第四篇,专门把任务池(Task Pool)掰开揉碎讲清楚:它解决什么问题、核心类之间怎么协作、以及怎么手写一个能跑的任务池。适合正在读GF源码但被模块间调用绕晕的朋友,也适合想在自己框架里引入统一任务调度机制的开发者。
1. 任务池到底解决什么问题
1.1 没有任务池的时候,代码是怎么写崩的
在接触GF之前,我自己写游戏业务时的任务管理相当原始。要做资源加载就new一个LoadAssetOperation,要做Web请求就new一个WebRequestOperation,每个操作类自己维护状态机,内部一堆Update逻辑。写几个还好,写多了之后代码里到处是重复的轮询:这个要每帧检测是否完成,那个要处理超时,还有的要支持取消和优先级。每个任务对象都是new出来的,用完等GC回收。在编辑器里跑没什么感觉,一旦上了真机,频繁加载卸载资源时,Profiler里的GC Alloc曲线直接起飞,卡顿肉眼可见。
任务池解决的第一个问题就是对象复用。任务对象本身不通过new创建,而是从引用池里Acquire,用完Release,全程不产生新的GC分配。第二个问题是调度逻辑的统一。不管什么类型的任务,都走同一条路径:按优先级排队、分配给空闲执行者、执行完成回收入池。第三个问题是生命周期的规范化。任务从创建、启动、每帧更新到结束,每个节点都有统一的回调接口,不会再出现"任务在某个分支忘记置完成状态"这种低级Bug。
1.2 Task Pool在GameFramework全家桶里的位置
GF的模块很多:对象池、引用池、事件系统、实体模块、资源模块、场景模块、UI模块、WebRequest、Download……很多模块底层都在用任务池。最典型的是ResourceManager和WebRequestManager。ResourceManager加载AssetBundle、加载场景、加载实体,本质都是一条条Task在任务池里流转;WebRequestManager发HTTP请求,也是把请求包装成Task下发到任务池。说白了你把任务池吃透了,后面读资源模块、Web模块的源码会顺畅很多,因为它就是GF里所有"异步执行类操作"的调度骨架。
可以这么理解:任务池是一个调度框架,上层业务只负责发任务和收结果,具体怎么执行由Agent承担。这种设计把"做什么"和"怎么做"解耦了,这也是GF里很多模块看起来结构复杂但彼此边界清晰的根本原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心概念拆解:TaskBase、ITaskAgent与TaskPool
2.1 TaskBase:一个任务到底长什么样
TaskBase是抽象类,所有任务类型的祖先。GF里每个可入池的任务都必须继承它。它有几个基础字段:SerialId(序列号)、Priority(优先级)、Done(是否完成)。另外它实现了IReference接口,意味着它可以从引用池里获取和释放,这是零GC的关键。
csharp复制public abstract class TaskBase : IReference
{
public int SerialId { get; private set; }
public int Priority { get; private set; }
public bool Done { get; private set; }
public TaskBase()
{
SerialId = 0;
Priority = 0;
Done = false;
}
public virtual void Clear()
{
SerialId = 0;
Priority = 0;
Done = false;
}
}
注意几个细节:
- SerialId由TaskPool统一分配,从0开始自增,保证同一批任务里的唯一性。你在业务层判断"是不是同一个任务"时,靠的就是它。
- Priority数值越小优先级越高。这个跟很多人的直觉相反,我第一次用的时候还写反过,导致紧急任务反而排到了最后。
- TaskBase的构造函数是public但实际上不应该直接new,正确姿势是通过引用池的Acquire获取实例。GF里TaskBase的派生类通常会把构造函数私有化,然后提供静态的Create方法。
TaskBase还定义了一套虚方法:OnStart、OnUpdate、OnDone、OnShutdown等。子类重写这些方法来响应生命周期事件。这套回调设计和Unity的MonoBehaviour生命周期有点类似,理解成本很低。
2.2 ITaskAgent:真正干活的执行者
有了任务定义,还得有执行任务的人,这就是ITaskAgent接口。GF把"任务"和"执行任务的代理"拆成两个概念,这个设计很关键。任务只是数据,Agent才是动作者。
csharp复制public interface ITaskAgent<T> where T : TaskBase
{
T Task { get; }
void Initialize(TaskPool<T> owner);
void Update(float elapseSeconds, float realElapseSeconds);
void Shutdown();
void Start(T task);
void Reset();
}
每个Agent同一时间只能处理一个任务。任务池分配任务给Agent,Agent在自己的Update里推进任务执行,任务做完后调用owner的CompleteTask方法把自己释放回空闲队列。如果Agent内部出错,也可以调用owner的FailureTask方法通知任务失败。
这里有一个经验:Agent的状态管理一定要干净。Start的时候要重置内部状态,Reset的时候要释放持有的资源。我见过有人把Agent写成单例复用的,结果上一个任务的残留数据污染了下一个任务的执行,排查了很久。
2.3 TaskPool:调度中枢的数据结构与核心字段
TaskPool是任务池本体,泛型类,TaskPool
- m_Tasks:LinkedList
,等待执行的任务链表 - m_TasksBeingDone:LinkedList
,正在执行的任务链表 - m_Agents:LinkedList<ITaskAgent
>,全部Agent链表 - m_FreeAgents:LinkedList<ITaskAgent
>,空闲Agent链表
这四个集合的分工很明确。m_Tasks是待办队列;m_TasksBeingDone是在办队列,用来在每帧更新时检查任务是否完成;m_Agents是总人力池;m_FreeAgents是当前可用的人力池。
更新流程大致是这样:每帧调用TaskPool.Update,先遍历m_TasksBeingDone检查有没有完成的任务,有就做善后处理并移除;然后遍历m_Tasks,尝试把等待任务分配给空闲Agent。分配过程不是一次全部分完,而是每帧有限度地分配,避免一帧内大量Agent同时启动造成性能尖刺。这个"每帧限量分配"的细节很值得学习,它把任务启动的负载摊到了多帧里。
3. 任务从生成到结束的完整生命周期
3.1 生成任务与入池排序
外部调用TaskPool.GenerateTask方法生成任务。这里的GenerateTask只是个壳,它做的事情是:通过泛型参数从引用池里Acquire一个任务实例,然后调用任务的Initialize方法设置SerialId和Priority,再把任务AddTask进m_Tasks。SerialId是TaskPool内部维护的自增计数器,每生成一个新任务就加1。
m_Tasks是一个按优先级排序的链表。AddTask时,TaskPool会从链表头开始遍历,找到第一个优先级比新任务大的节点,把新任务插到它前面。优先级相同的话就插到相同优先级节点的后面,天然满足FIFO。所以同优先级下先发的任务先执行,这也符合一般业务预期。
这里有个值得注意的细节:TaskPool支持"暂停添加"状态,内部有个m_IsPaused标志。如果任务池被暂停了,AddTask仍然会接收任务放进m_Tasks,但Update里不会分配Agent。这个机制在游戏切后台、或者需要挂起一批加载操作时很好用。
3.2 每帧更新与Agent分配
TaskPool.Update是任务池的心脏,每帧执行一次。核心逻辑分三步:
第一步,处理正在执行的任务。遍历m_TasksBeingDone,逐个检查任务的Done属性。如果发现任务完成了,就调用对应Agent的Reset方法,把Agent移到m_FreeAgents,然后从m_TasksBeingDone移除该任务,同时触发任务的完成回调。
第二步,分配新任务。遍历m_Tasks,对每个任务尝试从m_FreeAgents里取一个空闲Agent。取到了就调用Agent.Start(task),把任务从m_Tasks移到m_TasksBeingDone,再把这个Agent从m_FreeAgents移到m_Agents(其实Agent一直在m_Agents里,m_FreeAgents只是索引)。取不到空闲Agent就说明当前人力满了,任务留在m_Tasks里等下一帧。
第三步,更新所有正在执行的Agent。遍历m_Agents,调用每个Agent的Update方法。Agent会把elapseSeconds和realElapseSeconds传进去,让任务做自己的进度推进。
TaskPool里还有一个序列号分配逻辑:每帧分配的任务数量是有限制的,具体限制由外部通过SetMaxGenerateTaskCountPerFrame控制。默认值我记得是64,表示每帧最多启动64个新任务。如果你在一帧里同时添加了1000个资源加载任务,它们不会瞬间全部启动,而是分多帧慢慢分配。这个机制对控制帧率抖动很有帮助。
3.3 完成、取消与回收
任务结束有两种路径:正常完成和被打断。
正常完成的路径是:任务内部逻辑处理完毕,调用TaskBase的Done属性标记为true。TaskPool在下一帧的Update检查中发现了这个标记,就执行善后流程。
被打断的路径是:外部调用TaskPool的CancelTask方法,传入SerialId。TaskPool会从m_Tasks和m_TasksBeingDone两个链表里查找对应任务,找到后调用任务的OnCancel方法,然后不管这个任务是否已经执行到一半,都会强制结束它并回收。注意的是,一个正在被Agent执行的任务被取消时,Agent的Reset方法也会被调用,保证Agent状态干净。
不管走哪条路径,TaskBase实例最终都会回到引用池,等待下次被Acquire复用。这种回收复用是GF里所有池化对象的标配,也是它能在长时间运行的游戏里保持低GC的关键。
4. 实操:手写一个资源下载任务池
4.1 定义任务实体与TaskBase派生类
理论讲再多,不如动手写一个。这里我用UnityWebRequest写一个下载任务,演示完整接入任务池的流程。
首先定义DownloadTask,继承TaskBase:
csharp复制public sealed class DownloadTask : TaskBase
{
private string m_DownloadUri;
private string m_SavePath;
public string DownloadUri => m_DownloadUri;
public string SavePath => m_SavePath;
public static DownloadTask Create(string downloadUri, string savePath, int priority)
{
DownloadTask task = ReferencePool.Acquire<DownloadTask>();
task.m_DownloadUri = downloadUri;
task.m_SavePath = savePath;
task.Initialize(priority);
return task;
}
protected override void OnStart()
{
// 任务开始前的准备工作
}
protected override void OnUpdate(float elapseSeconds, float realElapseSeconds)
{
// 每帧推进任务逻辑
}
protected override void OnDone()
{
// 任务完成后的处理
}
protected override void OnShutdown()
{
// 任务被销毁时的处理
}
public override void Clear()
{
m_DownloadUri = null;
m_SavePath = null;
base.Clear();
}
}
Create方法里的Initialize是TaskBase提供的方法,用来设置SerialId、Priority等基础字段。注意这里没有直接new,而是从ReferencePool里Acquire。这就是GF里"池化一切可复用对象"的体现。
4.2 实现ITaskAgent代理
接下来实现真正的下载逻辑。DownloadTaskAgent继承ITaskAgent
csharp复制public sealed class DownloadTaskAgent : ITaskAgent<DownloadTask>
{
private TaskPool<DownloadTask> m_Owner;
private DownloadTask m_Task;
private UnityWebRequest m_Request;
public DownloadTask Task => m_Task;
public void Initialize(TaskPool<DownloadTask> owner)
{
m_Owner = owner;
}
public void Update(float elapseSeconds, float realElapseSeconds)
{
if (m_Task == null)
{
return;
}
if (m_Request != null && m_Request.isDone)
{
if (m_Request.result == UnityWebRequest.Result.Success)
{
// 下载成功,标记任务完成
m_Task.Done = true;
}
else
{
// 下载失败,这里可以通知失败
m_Owner.FailureTask(m_Task.SerialId, m_Request.error);
}
}
}
public void Start(DownloadTask task)
{
m_Task = task;
m_Request = UnityWebRequest.Get(task.DownloadUri);
m_Request.SendWebRequest();
}
public void Reset()
{
if (m_Request != null)
{
m_Request.Dispose();
m_Request = null;
}
m_Task = null;
}
public void Shutdown()
{
Reset();
m_Owner = null;
}
}
这个Agent做的事很纯粹:Start时发起WebRequest,Update里轮询isDone,完成后标记任务Done。真正的业务分发和状态管理全部交给TaskPool。
4.3 装配进TaskPool并驱动
最后把Agent装配进TaskPool,并模拟一帧帧驱动:
csharp复制private TaskPool<DownloadTask> m_DownloadTaskPool;
private void Start()
{
m_DownloadTaskPool = new TaskPool<DownloadTask>();
m_DownloadTaskPool.AddAgent(new DownloadTaskAgent());
// 添加10个下载任务,优先级从0到9
for (int i = 0; i < 10; i++)
{
DownloadTask task = DownloadTask.Create($"https://example.com/file{i}.zip", $"{Application.persistentDataPath}/file{i}.zip", i);
m_DownloadTaskPool.GenerateTask(task, null);
}
}
private void Update()
{
float elapseSeconds = Time.deltaTime;
float realElapseSeconds = Time.unscaledDeltaTime;
m_DownloadTaskPool.Update(elapseSeconds, realElapseSeconds);
}
GenerateTask的第二个参数是TaskInfo对象,用来承载任务派生数据,这里直接传null。下载任务在Update驱动下依次执行,完成一个回收一个。整个过程用的都是池化对象,没有new出多余的类实例。
这里给一个实操建议:如果你的项目里有多条下载任务,建议加2到3个Agent。因为UnityWebRequest的SendWebRequest是异步的,一个Agent同一时间只能处理一个下载,Agent太少会导致下载排队时间过长。但Agent也不是越多越好,每个Agent持有WebRequest连接,太多了对内存和带宽都有压力。
5. 常见问题与排查技巧实录
5.1 任务一直排队不执行
这个是我遇到过最多的问题。现象是任务已经AddTask了,但迟迟不启动,在Profiler看到任务池的m_Tasks一直增长。排查思路一般是:
第一,检查Agent数量是否足够。如果m_FreeAgents为空,任务自然一直等着。常见的原因是你给任务池AddAgent的时机不对,或者Agent数量就是不够,导致任务积压。
第二,检查TaskPool是否被暂停了。GF的TaskPool有Pause/Resume机制,如果不小心调用了Pause,任务就会全部停在等待队列里。我有的项目里是切后台时调用了暂停,切回来时忘了恢复。
第三,检查任务的Done状态是否误设为true了。如果任务在AddTask之前就被标记为Done,TaskPool在分配时发现它已经Done,会直接跳过并回收,看起来就像"没执行"。
5.2 引用池脏数据导致的任务状态错乱
引用池复用对象有个老生常谈的坑:对象被Release后,里面的数据如果不清干净,下次Acquire出来就是脏的。典型表现是:任务A的Uri是"https://example.com/a.png",任务B的Uri是"https://example.com/b.png",结果B实际下载的是a.png。
解决方法是检查Clear方法是否覆盖了所有自定义字段。DownloadTask.Clear里如果忘记把m_DownloadUri置空,下次Acquire时这个字段还留着旧值,而且因为引用池复用,这个旧值在初始化之前就能被读到。GF的惯例是:所有自定义字段都必须在Clear里清掉,包括引用类型(置null)和值类型(设默认值)。
我还遇到过更隐蔽的脏数据问题:任务B复用了任务A的实例,但任务A的OnDone回调里有一个通过事件系统发送的消息,这个事件携带了任务A的SerialId。由于SerialId在Initialize里会被重新分配,理论上是不会混淆的。但如果你的业务代码在Release之前已经引用了这个Task对象,就会拿到一个后面被复用了的新对象,产生奇怪的数据竞争。所以任务对象在未完成前不要提前Release。
5.3 优先级设了好像没用
GF的TaskBase优先级数值越小优先级越高,这个约定在GF的文档里写得很明确,但还是容易弄反。有人习惯性地以为优先级5比优先级1高,结果紧急任务优先级设成了99,排到了最后。
还有另一种情况:你设置的优先级是对的,但任务池里的Agent数量足够多,所有任务一帧内全部被分配出去了。这种情况下优先级排序看起来就像没生效一样,因为所有任务同一帧启动了。这不是Bug,只是Agent数量多于任务数量时,排队完全不需要,优先级自然体现不出来。
5.4 任务池的SerialId会不会溢出
TaskPool的SerialId是通过一个int类型的m_Serial自增得到的。int最大值是2147483647,如果你的游戏运行时间足够长,任务生成频率足够高,确实有可能溢出。GF源码里在自增时做了检查,如果m_Serial达到int.MaxValue,会回绕到0重新分配,同时跳过所有已存在的任务编号,避免冲突。
实际项目中这个回绕极难遇到,但是如果你在做一个长时间在线运营的游戏,还是要注意不要让某个任务池的SerialId撑爆。一个务实的做法是:任务池的Shutdown和重建成本并不高,大型运营活动结束时可以重建任务池,顺便重置SerialId。
5.5 问题快速定位速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 任务一直不执行 | Agent数量不足/任务池被暂停/任务被误标记Done | 检查m_FreeAgents和TaskPool的Pause状态 |
| 任务数据错乱 | Clear方法未覆盖自定义字段 | 审查所有派生类Clear方法 |
| 优先级没生效 | 优先级数值方向搞反/Agent足够多 | 确认Priority数值越小优先级越高 |
| 引用池报错 | 对象被重复Release | 检查Release调用时机,确保每个Acquire对应一次Release |
| 任务完成回调未触发 | Done没置true/Agent Update没调用 | 检查Agent.Update里是否推进任务状态 |
这张表是我自己在项目里积累的,基本覆盖了任务池使用中最常见的五大坑。排查任务池问题时先看调用链,确认任务确实进了m_Tasks、Agent确实收到了Start、Update确实被驱动了,80%的问题都能快速定位。
最后再分享一个个人经验:理解任务池之后,我再去读GF的资源加载、Web请求源码,思路明显清晰了很多。它本质上是"队列+执行者"的一种优雅表达,没必要把任务池想得太玄乎,但GF把这种朴素的设计写到了极致:池化对象消灭GC、链表组织有序队列、Agent隔离执行细节、优先级控制调度顺序。这套组合拳打下来,任务的整个生命周期都变得可控可追踪。如果你也在读GF源码,建议先吃透任务池再去啃其他模块,会有事半功倍的效果。
